diff --git a/js/Item.js b/js/Item.js index b9f5aaf..d69065c 100644 --- a/js/Item.js +++ b/js/Item.js @@ -43,6 +43,8 @@ export class Item { this.links = []; this._init(); + + this.isVisible = true; } _init() { @@ -148,8 +150,9 @@ export class Item { } _drawBoundingBox() { - if (this.isCollider) { + if (this.isCollider && this.isVisible) { // 1. Remove the previous frame's box + if (this.debugBox) { this.scene.remove(this.debugBox); this.debugBox.geometry.dispose(); @@ -188,6 +191,26 @@ export class Item { } } + show() { + if (this.object) { + this.object.visible = true; + } + if (this.rb) { + this.rb.setEnabled(true); + } + this.isVisible = true; + } + + hide() { + if (this.object) { + this.object.visible = false; + } + if (this.rb) { + this.rb.setEnabled(false); + } + this.isVisible = false; + } + async _moveActiveObject(delta) { if (this.isActive) { if (this.rb && !this.rb.isKinematic()) { diff --git a/js/ItemManager.js b/js/ItemManager.js index f6aad1a..6edd26b 100644 --- a/js/ItemManager.js +++ b/js/ItemManager.js @@ -3,9 +3,13 @@ import { Player } from './Player'; import { Item } from './Item'; import { TextContent, ImageContent, VideoContent, AudioContent } from './Content'; import { GeometryUtils } from 'three/examples/jsm/Addons.js'; +import { Line2 } from 'three/examples/jsm/lines/Line2.js'; +import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; +import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js'; + export class ItemManager { - constructor(filePath, scene, rapierWorld, player, interactableItems) { + constructor(filePath, preloadedObjects, dynamicGroups, scene, rapierWorld, player, interactableItems) { this.filePath = filePath; this.scene = scene; this.rapierWorld = rapierWorld; @@ -17,6 +21,11 @@ export class ItemManager { this.unloadDistance = 300; this.linkLines = new Map(); this.drawnLinks = new Set(); + this.preloadedObjects = preloadedObjects; + this.dynamicGroups = dynamicGroups; + this.groupCentres = new Map(); + this.groupCenterLinks = new Map(); + this.interGroupLinks = new Map(); this._init(); } @@ -33,7 +42,9 @@ export class ItemManager { const data = await resp.json(); await this._processItemData(data.items); - this._createLinkLines(); + this._createGroupCenterLinks(); + + if (data.groupLinks) this._createInterGroupLinks(data.groupLinks); } catch (error) { console.error("Could not load or process content JSON:", error); @@ -44,6 +55,20 @@ export class ItemManager { for (const itemDef of itemArray) { let contentObject = null; + const preloadedObject = this.preloadedObjects.get(itemDef.name); + + if (!preloadedObject) { + console.warn(`Could not find a preloaded 3D object for item name: "${itemDef.name}"`); + continue; + } + + // Find the correct THREE.Group for this item + const parentGroup = this.dynamicGroups.get(itemDef.groupId); + if (!parentGroup) { + console.warn(`Could not find a group for groupId: "${itemDef.groupId}"`); + continue; + } + // Create content object based on contentType and file if (itemDef.contentType && itemDef.file) { switch (itemDef.contentType) { @@ -76,98 +101,141 @@ export class ItemManager { break; } } - - // Create the Item instance - const position = new THREE.Vector3(itemDef.position.x, itemDef.position.y, itemDef.position.z); - - // The Item constructor expects a single number for scale, which matches your JSON. - const scale = itemDef.scale; const newItem = new Item( this.rapierWorld, - this.scene, + parentGroup, this.player, - itemDef.isCollider, - position, - scale, + true, + preloadedObject.position, + 1, itemDef.name, - itemDef.model, - itemDef.texture, + null, + null, [], // spawnedObjects placeholder - contentObject + contentObject, + preloadedObject ); // Set item id and group id newItem.id = itemDef.id; - newItem.groupId = itemDef.groupId; + newItem.groupId = parentGroup.name; // Set links if (itemDef.links) { newItem.links = itemDef.links; } - if (itemDef.isCollider) { - this.interactableItems.push(newItem); - } + // Item is already loaded from world glb, but this func creates collider etc. + await newItem.loadModel(); + newItem.show(); + + this.interactableItems.push(newItem); // Setting a key and value pair in the Map (id, item) this.itemData.set(itemDef.id, newItem); } } - _createLinkLines() { - + _createGroupCenterLinks() { this.itemData.forEach(item => { - if (!item.links) return; + const groupCenter = this._getGroupCentre(item.groupId); + if (!groupCenter) return; - item.links.forEach(linkedItemId => { - // Goes through each connection; links = [0, 1, 2..] - const linkedItem = this.itemData.get(linkedItemId); - if (!linkedItem) return; + const material = new THREE.LineBasicMaterial({ + color: 0xFF0000, + transparent: false, + opacity: 1 + }); - if (item.groupId !== linkedItem.groupId) return; + const points = [groupCenter.clone(), item.spawnPosition.clone()]; + const geometry = new THREE.BufferGeometry().setFromPoints(points); + + const line = new THREE.Line(geometry, material); + line.visible = false; // Initially hidden - // Creates a key for the Set; a Set can only have unique values, so used for no overlap - const key = [item.id, linkedItemId].sort().join('-'); + this.scene.add(line); - if (this.drawnLinks.has(key)) return; + // Store the line and the group it belongs to, keyed by the item's ID + this.groupCenterLinks.set(item.id, { line, groupId: item.groupId }); + }); + } - const material = new THREE.LineDashedMaterial({ - color: 0x0000ff, - dashSize: 3, - gapSize: 1, - }); + _createInterGroupLinks(groupLinks) { + groupLinks.forEach(linkPair => { + const [groupA_ID, groupB_ID] = linkPair; - const sourcePos = item.spawnPosition.clone(); - const terminalPos = linkedItem.spawnPosition.clone(); - sourcePos.y = 1; terminalPos.y = 1; + const centerA = this._getGroupCentre(groupA_ID); + const centerB = this._getGroupCentre(groupB_ID); - const points = [item.spawnPosition.clone(), linkedItem.spawnPosition.clone()]; - const geometry = new THREE.BufferGeometry().setFromPoints(points); - - const line = new THREE.Line(geometry, material); - line.computeLineDistances(); + if (!centerA || !centerB) { + console.warn(`Could not find centers for group link: ${groupA_ID} to ${groupB_ID}`); + return; + } - this.scene.add(line); + const lineGeometry = new LineGeometry(); + lineGeometry.setPositions([ + centerA.x, centerA.y, centerA.z, + centerB.x, centerB.y, centerB.z + ]); - // The Set is used as the key "0-1", then the item and the linkedItem are set as values in the Map? - this.linkLines.set(key, { line, item1: item, item2: linkedItem }); - // Make sure it is not drawn again, adding the unique pair here - this.drawnLinks.add(key); + const lineMaterial = new LineMaterial({ + color: 0xffa500, // Orange color for distinction + linewidth: 2, // in pixels + resolution: new THREE.Vector2(window.innerWidth, window.innerHeight), + fog:true }); + + const line = new Line2(lineGeometry, lineMaterial); + this.scene.add(line); + + const linkKey = `${groupA_ID}-${groupB_ID}`; + this.interGroupLinks.set(linkKey, { line, groupA_ID, groupB_ID }); + }); + } + + _getGroupCentre(groupId) { + const group = this.dynamicGroups.get(groupId); + if (!group) { + console.warn(`Group with id ${groupId} not found`); + return null; + } + + const bbox = new THREE.Box3().setFromObject(group); + const centre = new THREE.Vector3(); + bbox.getCenter(centre); + + return centre; + } + + _updateGroupCentres() { + this.dynamicGroups.forEach((group, groupId) => { + const center = this._getGroupCentre(groupId); + if (center) { + let helper = this.groupCentres.get(groupId); + if (!helper) { + helper = new THREE.AxesHelper(3); // The number defines the size of the helper + this.scene.add(helper); + this.groupCentres.set(groupId, helper); + } + helper.position.copy(center); + } }); } - _updateLinkLines() { - this.linkLines.forEach(link => { - const { line, item1, item2 } = link; + _updateGroupCenterLinks() { + this.groupCenterLinks.forEach((linkData, itemId) => { + const { line, groupId } = linkData; + const item = this.itemData.get(itemId); + + if (item && item.isVisible) { + const groupCenter = this._getGroupCentre(groupId); + if (!groupCenter) return; - // Only update/show the line if both items are loaded - if (item1.loadState === 'loaded' && item2.loadState === 'loaded') { line.visible = true; const positions = line.geometry.attributes.position; - positions.setXYZ(0, item1.object.position.x, 1, item1.object.position.z); - positions.setXYZ(1, item2.object.position.x, 1, item2.object.position.z); + positions.setXYZ(0, groupCenter.x, groupCenter.y, groupCenter.z); + positions.setXYZ(1, item.object.position.x, item.object.position.y, item.object.position.z); positions.needsUpdate = true; } else { line.visible = false; @@ -175,6 +243,20 @@ export class ItemManager { }); } + _updateInterGroupLinks() { + this.interGroupLinks.forEach(link => { + const centerA = this._getGroupCentre(link.groupA_ID); + const centerB = this._getGroupCentre(link.groupB_ID); + + if (centerA && centerB) { + link.line.geometry.setPositions([ + centerA.x, centerA.y, centerA.z, + centerB.x, centerB.y, centerB.z + ]); + } + }); + } + update() { const playerPosition = this.player.camera.position; this.itemData.forEach(item => { @@ -188,15 +270,78 @@ export class ItemManager { } // Check if item should be loaded - if (distance < this.loadDistance && item.loadState === 'unloaded') { - item.loadModel(); + if (distance < this.loadDistance && !item.isVisible) { + item.show(); } // Check if item should be unloaded - else if (distance > this.unloadDistance && item.loadState === 'loaded') { - item.unloadModel(); + else if (distance > this.unloadDistance && item.isVisible) { + item.hide(); } }); - this._updateLinkLines(); + this._updateGroupCenterLinks(); + this._updateGroupCentres(); + this._updateInterGroupLinks(); } + + // _createLinkLines() { + + // this.itemData.forEach(item => { + // if (!item.links) return; + + // item.links.forEach(linkedItemId => { + // // Goes through each connection; links = [0, 1, 2..] + // const linkedItem = this.itemData.get(linkedItemId); + // if (!linkedItem) return; + + // if (item.groupId !== linkedItem.groupId) return; + + // // Creates a key for the Set; a Set can only have unique values, so used for no overlap + // const key = [item.id, linkedItemId].sort().join('-'); + + // if (this.drawnLinks.has(key)) return; + + // const material = new THREE.LineDashedMaterial({ + // color: 0xffa500, + // dashSize: 10, + // gapSize: 1 + // }); + + // const sourcePos = item.spawnPosition.clone(); + // const terminalPos = linkedItem.spawnPosition.clone(); + // sourcePos.y = 1; terminalPos.y = 1; + + // const points = [item.spawnPosition.clone(), linkedItem.spawnPosition.clone()]; + // const geometry = new THREE.BufferGeometry().setFromPoints(points); + + // const line = new THREE.Line(geometry, material); + // line.computeLineDistances(); + + // this.scene.add(line); + + // // The Set is used as the key "0-1", then the item and the linkedItem are set as values in the Map? + // this.linkLines.set(key, { line, item1: item, item2: linkedItem }); + // // Make sure it is not drawn again, adding the unique pair here + // this.drawnLinks.add(key); + // }); + // }); + // } + + // _updateLinkLines() { + // this.linkLines.forEach(link => { + // const { line, item1, item2 } = link; + + // // Only update/show the line if both items are loaded + // if ((item1.loadState === 'loaded' && item2.loadState === 'loaded') + // && (item1.isVisible && item2.isVisible)) { + // line.visible = true; + // const positions = line.geometry.attributes.position; + // positions.setXYZ(0, item1.object.position.x, 1, item1.object.position.z); + // positions.setXYZ(1, item2.object.position.x, 1, item2.object.position.z); + // positions.needsUpdate = true; + // } else { + // line.visible = false; + // } + // }); + // } } \ No newline at end of file diff --git a/js/ModelLoader.js b/js/ModelLoader.js index 8953a67..1cc5d87 100644 --- a/js/ModelLoader.js +++ b/js/ModelLoader.js @@ -77,6 +77,52 @@ export class ModelLoader { }); } + loadDynamicGroups(modelURL, worldScale) { + return new Promise((resolve, reject) => { + this.gltfLoader.load(modelURL, (gltf) => { + const dynamicGroup = gltf.scene.getObjectByName('Dynamic'); + const groups = []; + + if (dynamicGroup) { + // Iterate over children of 'dynamic', which are the subgroups (e.g., 'reddit') + const subgroups = [...dynamicGroup.children]; + subgroups.forEach(subgroup => { + if (subgroup.isGroup || subgroup.isObject3D) { // Treat empties as groups + const groupData = { + name: subgroup.name, + objects: [] + }; + + // Iterate over the actual mesh objects within the subgroup + const objects = [...subgroup.children]; + objects.forEach(object => { + if (object.isMesh) { + // 1. Apply world scale + object.position.multiplyScalar(worldScale); + object.scale.multiplyScalar(worldScale); + + // 2. Detach from original parent and apply material + gltf.scene.attach(object); + this._applyCustomMaterial(object); + + groupData.objects.push(object); + } + }); + groups.push(groupData); + } + }); + resolve(groups); + } else { + console.warn("Could not find 'dynamic' group in the loaded model."); + resolve([]); + } + }, undefined, (error) => { + console.error('An error happened while loading the dynamic groups:', error); + reject(error); + }); + }); + } + loadDRACOModelURL(modelURL, textureURL, scale) { return new Promise((resolve, reject) => { const applyMaterial = (object, texture) => { diff --git a/js/main.js b/js/main.js index 7027b23..9c50ae8 100644 --- a/js/main.js +++ b/js/main.js @@ -27,6 +27,7 @@ let rapierWorld, debugLines, itemManager; let debugRapier = false; let isPaused = false; let floorGridSize = new THREE.Vector2(10, 200); +const worldScale = 20.0; let socket; const currentPlayers = {}; @@ -89,7 +90,7 @@ async function init() { /* Load Items/Content */ loadWorldModel("models/demo-world-comp.glb"); - itemManager = new ItemManager("json/Items.json", scene, rapierWorld, player, interactableItems); + //itemManager = new ItemManager("json/Items.json", scene, rapierWorld, player, interactableItems); const item = new Item(rapierWorld, scene, player, true, new THREE.Vector3(0, 20, 0), 20, "test", '/models/init.glb', null, spawnedObjects, null); await item.loadModel(); @@ -134,6 +135,8 @@ async function init() { } } }); + + console.log(scene) } async function animate() { @@ -352,7 +355,6 @@ function createTiledFloor(gridSize = 20, tileSize = 50) { async function loadWorldModel(modelUrl) { const modelLoader = new ModelLoader(); - const worldScale = 20.0; const staticWorldGroup = new THREE.Group(); scene.add(staticWorldGroup); @@ -379,6 +381,37 @@ async function loadWorldModel(modelUrl) { console.log("Finished loading world"); } catch (error) { - console.error("Failed to load world model:", error); + console.error("Failed to load static world objects:", error); + } + + try { + const dynamicGroups = await modelLoader.loadDynamicGroups(modelUrl, worldScale); + const preloadedObjectsMap = new Map(); + const dynamicGroupsMap = new Map(); + + dynamicGroups.forEach(groupData => { + const threeGroup = new THREE.Group(); + threeGroup.name = groupData.name; + scene.add(threeGroup); + dynamicGroupsMap.set(groupData.name, threeGroup); + + groupData.objects.forEach(object => { + preloadedObjectsMap.set(object.name, object); + }); + }); + + console.log(dynamicGroupsMap); + + itemManager = new ItemManager( + "json/Items.json", + preloadedObjectsMap, + dynamicGroupsMap, + scene, + rapierWorld, + player, + interactableItems); + + } catch (error) { + console.error("Failed to load dynamic world objects:", error); } } \ No newline at end of file diff --git a/public/json/Items.json b/public/json/Items.json index 5476930..d64e803 100644 --- a/public/json/Items.json +++ b/public/json/Items.json @@ -2,31 +2,39 @@ "items": [ { "id": 0, - "groupId": "reddit1", - "name": "cone", - "model": "./models/cone.glb", - "texture": null, + "groupId": "group_Reddit01", + "name": "dyCone1", "contentType": "text", "file": "./texts/example.md", - "isCollider": true, - "position": { "x": 100, "y": 1, "z": 0 }, - "rotation": { "x": 0, "y": 0, "z": 0 }, - "scale": 5, "links": [1] }, { "id": 1, - "groupId": "reddit", - "name": "cone2", - "model": "./models/cone.glb", - "texture": null, + "groupId": "group_Reddit01", + "name": "dyCone3", "contentType": "image", "file": "./images/test.webp", - "isCollider": true, - "position": { "x": -100, "y": 1, "z": 0 }, - "rotation": { "x": 0, "y": 0, "z": 0 }, - "scale": 10, "links": [0] + }, + { + "id": 2, + "groupId": "group_Reddit", + "name": "dyCone2", + "contentType": "text", + "file": "./texts/example.md", + "links": [3] + }, + { + "id": 3, + "groupId": "group_Reddit", + "name": "dyCone4", + "contentType": "image", + "file": "./images/test.webp", + "links": [2] } + ], + + "groupLinks": [ + ["group_Reddit", "group_Reddit01"] ] } \ No newline at end of file