757 lines
22 KiB
JavaScript
757 lines
22 KiB
JavaScript
/**
|
|
* scene-objects.js — Runtime 3D object registry for The Matrix.
|
|
*
|
|
* Allows agents (especially Timmy) to dynamically add, update, move, and
|
|
* remove 3D objects in the world via WebSocket messages — no redeploy needed.
|
|
*
|
|
* Supported primitives: box, sphere, cylinder, cone, torus, plane, ring, text
|
|
* Special types: portal (visual gateway + trigger zone), light, group
|
|
* Each object has an id, transform, material properties, and optional animation.
|
|
*
|
|
* Sub-worlds: agents can define named environments (collections of objects +
|
|
* lighting + fog + ambient) and load/unload them atomically. Portals can
|
|
* reference sub-worlds as their destination.
|
|
*
|
|
* Resolves Issue #8 — Dynamic scene mutation (WS gateway adapter)
|
|
*/
|
|
|
|
import * as THREE from 'three';
|
|
import { addZone, removeZone, clearZones } from './zones.js';
|
|
|
|
let scene = null;
|
|
const registry = new Map(); // id → { object, def, animator }
|
|
|
|
/* ── Sub-world system ── */
|
|
|
|
const worlds = new Map(); // worldId → { objects: [...def], ambient, fog, saved }
|
|
let activeWorld = null; // currently loaded sub-world id (null = home)
|
|
let _homeSnapshot = null; // snapshot of home world objects before portal travel
|
|
const _worldChangeListeners = []; // callbacks for world transitions
|
|
|
|
/** Subscribe to world change events. */
|
|
export function onWorldChange(fn) { _worldChangeListeners.push(fn); }
|
|
|
|
/* ── Geometry factories ── */
|
|
|
|
const GEO_FACTORIES = {
|
|
box: (p) => new THREE.BoxGeometry(p.width ?? 1, p.height ?? 1, p.depth ?? 1),
|
|
sphere: (p) => new THREE.SphereGeometry(p.radius ?? 0.5, p.segments ?? 16, p.segments ?? 16),
|
|
cylinder: (p) => new THREE.CylinderGeometry(p.radiusTop ?? 0.5, p.radiusBottom ?? 0.5, p.height ?? 1, p.segments ?? 16),
|
|
cone: (p) => new THREE.ConeGeometry(p.radius ?? 0.5, p.height ?? 1, p.segments ?? 16),
|
|
torus: (p) => new THREE.TorusGeometry(p.radius ?? 0.5, p.tube ?? 0.15, p.radialSegments ?? 8, p.tubularSegments ?? 24),
|
|
plane: (p) => new THREE.PlaneGeometry(p.width ?? 1, p.height ?? 1),
|
|
ring: (p) => new THREE.RingGeometry(p.innerRadius ?? 0.3, p.outerRadius ?? 0.5, p.segments ?? 24),
|
|
icosahedron: (p) => new THREE.IcosahedronGeometry(p.radius ?? 0.5, p.detail ?? 0),
|
|
octahedron: (p) => new THREE.OctahedronGeometry(p.radius ?? 0.5, p.detail ?? 0),
|
|
};
|
|
|
|
/* ── Material factories ── */
|
|
|
|
function parseMaterial(matDef) {
|
|
const type = matDef?.type ?? 'standard';
|
|
const color = matDef?.color != null ? parseColor(matDef.color) : 0x00ff41;
|
|
|
|
const shared = {
|
|
color,
|
|
transparent: matDef?.opacity != null && matDef.opacity < 1,
|
|
opacity: matDef?.opacity ?? 1,
|
|
side: matDef?.doubleSide ? THREE.DoubleSide : THREE.FrontSide,
|
|
wireframe: matDef?.wireframe ?? false,
|
|
};
|
|
|
|
switch (type) {
|
|
case 'basic':
|
|
return new THREE.MeshBasicMaterial(shared);
|
|
case 'phong':
|
|
return new THREE.MeshPhongMaterial({
|
|
...shared,
|
|
emissive: matDef?.emissive != null ? parseColor(matDef.emissive) : 0x000000,
|
|
emissiveIntensity: matDef?.emissiveIntensity ?? 0,
|
|
shininess: matDef?.shininess ?? 30,
|
|
});
|
|
case 'physical':
|
|
return new THREE.MeshPhysicalMaterial({
|
|
...shared,
|
|
roughness: matDef?.roughness ?? 0.5,
|
|
metalness: matDef?.metalness ?? 0,
|
|
emissive: matDef?.emissive != null ? parseColor(matDef.emissive) : 0x000000,
|
|
emissiveIntensity: matDef?.emissiveIntensity ?? 0,
|
|
clearcoat: matDef?.clearcoat ?? 0,
|
|
transmission: matDef?.transmission ?? 0,
|
|
});
|
|
case 'standard':
|
|
default:
|
|
return new THREE.MeshStandardMaterial({
|
|
...shared,
|
|
roughness: matDef?.roughness ?? 0.5,
|
|
metalness: matDef?.metalness ?? 0,
|
|
emissive: matDef?.emissive != null ? parseColor(matDef.emissive) : 0x000000,
|
|
emissiveIntensity: matDef?.emissiveIntensity ?? 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
function parseColor(c) {
|
|
if (typeof c === 'number') return c;
|
|
if (typeof c === 'string') {
|
|
if (c.startsWith('#')) return parseInt(c.slice(1), 16);
|
|
if (c.startsWith('0x')) return parseInt(c, 16);
|
|
// Try named colors via Three.js
|
|
return new THREE.Color(c).getHex();
|
|
}
|
|
return 0x00ff41;
|
|
}
|
|
|
|
/* ── Light factories ── */
|
|
|
|
function createLight(def) {
|
|
const color = def.color != null ? parseColor(def.color) : 0x00ff41;
|
|
const intensity = def.intensity ?? 1;
|
|
|
|
switch (def.lightType ?? 'point') {
|
|
case 'point':
|
|
return new THREE.PointLight(color, intensity, def.distance ?? 10, def.decay ?? 2);
|
|
case 'spot': {
|
|
const spot = new THREE.SpotLight(color, intensity, def.distance ?? 10, def.angle ?? Math.PI / 6, def.penumbra ?? 0.5);
|
|
if (def.targetPosition) {
|
|
spot.target.position.set(
|
|
def.targetPosition.x ?? 0,
|
|
def.targetPosition.y ?? 0,
|
|
def.targetPosition.z ?? 0,
|
|
);
|
|
}
|
|
return spot;
|
|
}
|
|
case 'directional': {
|
|
const dir = new THREE.DirectionalLight(color, intensity);
|
|
if (def.targetPosition) {
|
|
dir.target.position.set(
|
|
def.targetPosition.x ?? 0,
|
|
def.targetPosition.y ?? 0,
|
|
def.targetPosition.z ?? 0,
|
|
);
|
|
}
|
|
return dir;
|
|
}
|
|
default:
|
|
return new THREE.PointLight(color, intensity, def.distance ?? 10);
|
|
}
|
|
}
|
|
|
|
/* ── Text label (canvas texture sprite) ── */
|
|
|
|
function createTextSprite(def) {
|
|
const text = def.text ?? '';
|
|
const size = def.fontSize ?? 24;
|
|
const color = def.color ?? '#00ff41';
|
|
const font = def.font ?? 'bold ' + size + 'px "Courier New", monospace';
|
|
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.font = font;
|
|
const metrics = ctx.measureText(text);
|
|
canvas.width = Math.ceil(metrics.width) + 16;
|
|
canvas.height = size + 16;
|
|
ctx.font = font;
|
|
ctx.fillStyle = 'transparent';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
ctx.fillStyle = typeof color === 'string' ? color : '#00ff41';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
|
|
|
|
const tex = new THREE.CanvasTexture(canvas);
|
|
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true });
|
|
const sprite = new THREE.Sprite(mat);
|
|
const aspect = canvas.width / canvas.height;
|
|
const scale = def.scale ?? 2;
|
|
sprite.scale.set(scale * aspect, scale, 1);
|
|
return sprite;
|
|
}
|
|
|
|
/* ── Group builder for compound objects ── */
|
|
|
|
function buildGroup(def) {
|
|
const group = new THREE.Group();
|
|
|
|
if (def.children && Array.isArray(def.children)) {
|
|
for (const childDef of def.children) {
|
|
const child = buildObject(childDef);
|
|
if (child) group.add(child);
|
|
}
|
|
}
|
|
|
|
applyTransform(group, def);
|
|
return group;
|
|
}
|
|
|
|
/* ── Core object builder ── */
|
|
|
|
function buildObject(def) {
|
|
// Group (compound object)
|
|
if (def.geometry === 'group') {
|
|
return buildGroup(def);
|
|
}
|
|
|
|
// Light
|
|
if (def.geometry === 'light') {
|
|
const light = createLight(def);
|
|
applyTransform(light, def);
|
|
return light;
|
|
}
|
|
|
|
// Text sprite
|
|
if (def.geometry === 'text') {
|
|
const sprite = createTextSprite(def);
|
|
applyTransform(sprite, def);
|
|
return sprite;
|
|
}
|
|
|
|
// Mesh primitive
|
|
const factory = GEO_FACTORIES[def.geometry];
|
|
if (!factory) {
|
|
console.warn('[SceneObjects] Unknown geometry:', def.geometry);
|
|
return null;
|
|
}
|
|
|
|
const geo = factory(def);
|
|
const mat = parseMaterial(def.material);
|
|
const mesh = new THREE.Mesh(geo, mat);
|
|
applyTransform(mesh, def);
|
|
|
|
// Optional shadow
|
|
if (def.castShadow) mesh.castShadow = true;
|
|
if (def.receiveShadow) mesh.receiveShadow = true;
|
|
|
|
return mesh;
|
|
}
|
|
|
|
function applyTransform(obj, def) {
|
|
if (def.position) {
|
|
obj.position.set(def.position.x ?? 0, def.position.y ?? 0, def.position.z ?? 0);
|
|
}
|
|
if (def.rotation) {
|
|
obj.rotation.set(
|
|
(def.rotation.x ?? 0) * Math.PI / 180,
|
|
(def.rotation.y ?? 0) * Math.PI / 180,
|
|
(def.rotation.z ?? 0) * Math.PI / 180,
|
|
);
|
|
}
|
|
if (def.scale != null) {
|
|
if (typeof def.scale === 'number') {
|
|
obj.scale.setScalar(def.scale);
|
|
} else {
|
|
obj.scale.set(def.scale.x ?? 1, def.scale.y ?? 1, def.scale.z ?? 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── Animation system ── */
|
|
|
|
/**
|
|
* Animation definitions drive per-frame transforms.
|
|
* Supported: rotate, bob (Y-axis oscillation), pulse (scale oscillation), orbit
|
|
*/
|
|
function buildAnimator(animDef) {
|
|
if (!animDef) return null;
|
|
const anims = Array.isArray(animDef) ? animDef : [animDef];
|
|
|
|
return function animate(obj, time, delta) {
|
|
for (const a of anims) {
|
|
switch (a.type) {
|
|
case 'rotate':
|
|
obj.rotation.x += (a.x ?? 0) * delta;
|
|
obj.rotation.y += (a.y ?? 0.5) * delta;
|
|
obj.rotation.z += (a.z ?? 0) * delta;
|
|
break;
|
|
case 'bob':
|
|
obj.position.y = (a.baseY ?? obj.position.y) + Math.sin(time * 0.001 * (a.speed ?? 1)) * (a.amplitude ?? 0.3);
|
|
break;
|
|
case 'pulse': {
|
|
const s = 1 + Math.sin(time * 0.001 * (a.speed ?? 2)) * (a.amplitude ?? 0.1);
|
|
obj.scale.setScalar(s * (a.baseScale ?? 1));
|
|
break;
|
|
}
|
|
case 'orbit': {
|
|
const r = a.radius ?? 3;
|
|
const spd = a.speed ?? 0.5;
|
|
const cx = a.centerX ?? 0;
|
|
const cz = a.centerZ ?? 0;
|
|
obj.position.x = cx + Math.cos(time * 0.001 * spd) * r;
|
|
obj.position.z = cz + Math.sin(time * 0.001 * spd) * r;
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════
|
|
* PUBLIC API — called by websocket.js
|
|
* ═══════════════════════════════════════════════ */
|
|
|
|
/**
|
|
* Bind to the Three.js scene. Call once from main.js after initWorld().
|
|
*/
|
|
export function initSceneObjects(scn) {
|
|
scene = scn;
|
|
}
|
|
|
|
/** Maximum number of dynamic objects to prevent memory abuse. */
|
|
const MAX_OBJECTS = 200;
|
|
|
|
/**
|
|
* Add (or replace) a dynamic object in the scene.
|
|
*
|
|
* @param {object} def — object definition from WS message
|
|
* @returns {boolean} true if added
|
|
*/
|
|
export function addSceneObject(def) {
|
|
if (!scene || !def.id) return false;
|
|
|
|
// Enforce limit
|
|
if (registry.size >= MAX_OBJECTS && !registry.has(def.id)) {
|
|
console.warn('[SceneObjects] Limit reached (' + MAX_OBJECTS + '), ignoring:', def.id);
|
|
return false;
|
|
}
|
|
|
|
// Remove existing if replacing
|
|
if (registry.has(def.id)) {
|
|
removeSceneObject(def.id);
|
|
}
|
|
|
|
const obj = buildObject(def);
|
|
if (!obj) return false;
|
|
|
|
scene.add(obj);
|
|
|
|
const animator = buildAnimator(def.animation);
|
|
|
|
registry.set(def.id, {
|
|
object: obj,
|
|
def,
|
|
animator,
|
|
});
|
|
|
|
console.info('[SceneObjects] Added:', def.id, def.geometry);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Update properties of an existing object without full rebuild.
|
|
* Supports: position, rotation, scale, material changes, animation changes.
|
|
*
|
|
* @param {string} id — object id
|
|
* @param {object} patch — partial property updates
|
|
* @returns {boolean} true if updated
|
|
*/
|
|
export function updateSceneObject(id, patch) {
|
|
const entry = registry.get(id);
|
|
if (!entry) return false;
|
|
|
|
const obj = entry.object;
|
|
|
|
// Transform updates
|
|
if (patch.position) applyTransform(obj, { position: patch.position });
|
|
if (patch.rotation) applyTransform(obj, { rotation: patch.rotation });
|
|
if (patch.scale != null) applyTransform(obj, { scale: patch.scale });
|
|
|
|
// Material updates (mesh only)
|
|
if (patch.material && obj.isMesh) {
|
|
const mat = obj.material;
|
|
if (patch.material.color != null) mat.color.setHex(parseColor(patch.material.color));
|
|
if (patch.material.emissive != null) mat.emissive?.setHex(parseColor(patch.material.emissive));
|
|
if (patch.material.emissiveIntensity != null) mat.emissiveIntensity = patch.material.emissiveIntensity;
|
|
if (patch.material.opacity != null) {
|
|
mat.opacity = patch.material.opacity;
|
|
mat.transparent = patch.material.opacity < 1;
|
|
}
|
|
if (patch.material.wireframe != null) mat.wireframe = patch.material.wireframe;
|
|
}
|
|
|
|
// Visibility
|
|
if (patch.visible != null) obj.visible = patch.visible;
|
|
|
|
// Animation swap
|
|
if (patch.animation !== undefined) {
|
|
entry.animator = buildAnimator(patch.animation);
|
|
}
|
|
|
|
// Merge patch into stored def for future reference
|
|
Object.assign(entry.def, patch);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Remove a dynamic object from the scene and dispose its resources.
|
|
*
|
|
* @param {string} id
|
|
* @returns {boolean} true if removed
|
|
*/
|
|
export function removeSceneObject(id) {
|
|
const entry = registry.get(id);
|
|
if (!entry) return false;
|
|
|
|
scene.remove(entry.object);
|
|
_disposeRecursive(entry.object);
|
|
registry.delete(id);
|
|
|
|
console.info('[SceneObjects] Removed:', id);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Remove all dynamic objects. Called on scene teardown.
|
|
*/
|
|
export function clearSceneObjects() {
|
|
for (const [id] of registry) {
|
|
removeSceneObject(id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return a snapshot of all registered object IDs and their defs.
|
|
* Used for state persistence or debugging.
|
|
*/
|
|
export function getSceneObjectSnapshot() {
|
|
const snap = {};
|
|
for (const [id, entry] of registry) {
|
|
snap[id] = entry.def;
|
|
}
|
|
return snap;
|
|
}
|
|
|
|
/**
|
|
* Per-frame animation update. Call from render loop.
|
|
* @param {number} time — elapsed ms (performance.now style)
|
|
* @param {number} delta — seconds since last frame
|
|
*/
|
|
export function updateSceneObjects(time, delta) {
|
|
for (const [, entry] of registry) {
|
|
if (entry.animator) {
|
|
entry.animator(entry.object, time, delta);
|
|
}
|
|
|
|
// Handle recall pulses
|
|
if (entry.pulse) {
|
|
const elapsed = time - entry.pulse.startTime;
|
|
if (elapsed > entry.pulse.duration) {
|
|
// Reset to base state and clear pulse
|
|
entry.object.scale.setScalar(entry.pulse.baseScale);
|
|
if (entry.object.material?.emissiveIntensity != null) {
|
|
entry.object.material.emissiveIntensity = entry.pulse.baseEmissive;
|
|
}
|
|
entry.pulse = null;
|
|
} else {
|
|
// Sine wave pulse: 0 -> 1 -> 0
|
|
const progress = elapsed / entry.pulse.duration;
|
|
const pulseFactor = Math.sin(progress * Math.PI);
|
|
|
|
const s = entry.pulse.baseScale * (1 + pulseFactor * 0.5);
|
|
entry.object.scale.setScalar(s);
|
|
|
|
if (entry.object.material?.emissiveIntensity != null) {
|
|
entry.object.material.emissiveIntensity = entry.pulse.baseEmissive + pulseFactor * 2;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function pulseFact(id) {
|
|
const entry = registry.get(id);
|
|
if (!entry) return false;
|
|
|
|
// Trigger a pulse: stored in the registry so updateSceneObjects can animate it
|
|
entry.pulse = {
|
|
startTime: performance.now(),
|
|
duration: 1000,
|
|
baseScale: entry.def.scale ?? 1,
|
|
baseEmissive: entry.def.material?.emissiveIntensity ?? 0,
|
|
};
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Return current count of dynamic objects.
|
|
*/
|
|
export function getSceneObjectCount() {
|
|
return registry.size;
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════
|
|
* PORTALS — visual gateway + trigger zone
|
|
* ═══════════════════════════════════════════════ */
|
|
|
|
/**
|
|
* Create a portal — a glowing ring/archway with particle effect
|
|
* and an associated trigger zone. When the visitor walks into the zone,
|
|
* the linked sub-world loads.
|
|
*
|
|
* Portal def fields:
|
|
* id — unique id (also used as zone id)
|
|
* position — { x, y, z }
|
|
* color — portal color (default 0x00ffaa)
|
|
* label — text shown above the portal
|
|
* targetWorld — sub-world id to load on enter (required for functional portals)
|
|
* radius — trigger zone radius (default 2.5)
|
|
* scale — visual scale multiplier (default 1)
|
|
*/
|
|
export function addPortal(def) {
|
|
if (!scene || !def.id) return false;
|
|
|
|
const color = def.color != null ? parseColor(def.color) : 0x00ffaa;
|
|
const s = def.scale ?? 1;
|
|
const group = new THREE.Group();
|
|
|
|
// Outer ring
|
|
const ringGeo = new THREE.TorusGeometry(1.8 * s, 0.08 * s, 8, 48);
|
|
const ringMat = new THREE.MeshStandardMaterial({
|
|
color,
|
|
emissive: color,
|
|
emissiveIntensity: 0.8,
|
|
roughness: 0.2,
|
|
metalness: 0.5,
|
|
});
|
|
const ring = new THREE.Mesh(ringGeo, ringMat);
|
|
ring.rotation.x = Math.PI / 2;
|
|
ring.position.y = 2 * s;
|
|
group.add(ring);
|
|
|
|
// Inner glow disc (the "event horizon")
|
|
const discGeo = new THREE.CircleGeometry(1.6 * s, 32);
|
|
const discMat = new THREE.MeshBasicMaterial({
|
|
color,
|
|
transparent: true,
|
|
opacity: 0.15,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
const disc = new THREE.Mesh(discGeo, discMat);
|
|
disc.rotation.x = Math.PI / 2;
|
|
disc.position.y = 2 * s;
|
|
group.add(disc);
|
|
|
|
// Point light at portal center
|
|
const light = new THREE.PointLight(color, 2, 12);
|
|
light.position.y = 2 * s;
|
|
group.add(light);
|
|
|
|
// Label above portal
|
|
if (def.label) {
|
|
const labelSprite = createTextSprite({
|
|
text: def.label,
|
|
color: typeof color === 'number' ? '#' + color.toString(16).padStart(6, '0') : color,
|
|
fontSize: 20,
|
|
scale: 2.5,
|
|
});
|
|
labelSprite.position.y = 4.2 * s;
|
|
group.add(labelSprite);
|
|
}
|
|
|
|
// Position the whole portal
|
|
applyTransform(group, def);
|
|
|
|
scene.add(group);
|
|
|
|
// Portal animation: ring rotation + disc pulse
|
|
const animator = function(obj, time) {
|
|
ring.rotation.z = time * 0.0005;
|
|
const pulse = 0.1 + Math.sin(time * 0.002) * 0.08;
|
|
discMat.opacity = pulse;
|
|
light.intensity = 1.5 + Math.sin(time * 0.003) * 0.8;
|
|
};
|
|
|
|
registry.set(def.id, {
|
|
object: group,
|
|
def: { ...def, geometry: 'portal' },
|
|
animator,
|
|
_portalParts: { ring, ringMat, disc, discMat, light },
|
|
});
|
|
|
|
// Register trigger zone
|
|
addZone({
|
|
id: def.id,
|
|
position: def.position,
|
|
radius: def.radius ?? 2.5,
|
|
action: 'portal',
|
|
payload: {
|
|
targetWorld: def.targetWorld,
|
|
label: def.label,
|
|
},
|
|
});
|
|
|
|
console.info('[SceneObjects] Portal added:', def.id, '→', def.targetWorld || '(no target)');
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Remove a portal and its associated trigger zone.
|
|
*/
|
|
export function removePortal(id) {
|
|
removeZone(id);
|
|
return removeSceneObject(id);
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════
|
|
* SUB-WORLDS — named scene environments
|
|
* ═══════════════════════════════════════════════ */
|
|
|
|
/**
|
|
* Register a sub-world definition. Does NOT load it — just stores the blueprint.
|
|
* Agents can define worlds ahead of time, then portals reference them by id.
|
|
*
|
|
* @param {object} worldDef
|
|
* @param {string} worldDef.id — unique world identifier
|
|
* @param {Array} worldDef.objects — array of scene object defs to spawn
|
|
* @param {object} worldDef.ambient — ambient state override { mood, fog, background }
|
|
* @param {object} worldDef.spawn — visitor spawn point { x, y, z }
|
|
* @param {string} worldDef.label — display name
|
|
* @param {string} worldDef.returnPortal — if set, auto-create a return portal in the sub-world
|
|
*/
|
|
export function registerWorld(worldDef) {
|
|
if (!worldDef.id) return false;
|
|
worlds.set(worldDef.id, {
|
|
...worldDef,
|
|
loaded: false,
|
|
});
|
|
console.info('[SceneObjects] World registered:', worldDef.id, '(' + (worldDef.objects?.length ?? 0) + ' objects)');
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Load a sub-world — clear current dynamic objects and spawn the world's objects.
|
|
* Saves current state so we can return.
|
|
*
|
|
* @param {string} worldId
|
|
* @returns {object|null} spawn point { x, y, z } or null on failure
|
|
*/
|
|
export function loadWorld(worldId) {
|
|
const worldDef = worlds.get(worldId);
|
|
if (!worldDef) {
|
|
console.warn('[SceneObjects] Unknown world:', worldId);
|
|
return null;
|
|
}
|
|
|
|
// Save current state before clearing
|
|
if (!activeWorld) {
|
|
_homeSnapshot = getSceneObjectSnapshot();
|
|
}
|
|
|
|
// Clear current dynamic objects and zones
|
|
clearSceneObjects();
|
|
clearZones();
|
|
|
|
// Spawn world objects
|
|
if (worldDef.objects && Array.isArray(worldDef.objects)) {
|
|
for (const objDef of worldDef.objects) {
|
|
if (objDef.geometry === 'portal') {
|
|
addPortal(objDef);
|
|
} else {
|
|
addSceneObject(objDef);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-create return portal if specified
|
|
if (worldDef.returnPortal !== false) {
|
|
const returnPos = worldDef.returnPortal?.position ?? { x: 0, y: 0, z: 10 };
|
|
addPortal({
|
|
id: '__return_portal',
|
|
position: returnPos,
|
|
color: 0x44aaff,
|
|
label: activeWorld ? 'BACK' : 'HOME',
|
|
targetWorld: activeWorld || '__home',
|
|
radius: 2.5,
|
|
});
|
|
}
|
|
|
|
activeWorld = worldId;
|
|
worldDef.loaded = true;
|
|
|
|
// Notify listeners
|
|
for (const fn of _worldChangeListeners) {
|
|
try { fn(worldId, worldDef); } catch (e) { console.warn('[SceneObjects] World change listener error:', e); }
|
|
}
|
|
|
|
console.info('[SceneObjects] World loaded:', worldId);
|
|
return worldDef.spawn ?? { x: 0, y: 0, z: 5 };
|
|
}
|
|
|
|
/**
|
|
* Return to the home world (the default Matrix grid).
|
|
* Restores previously saved dynamic objects.
|
|
*/
|
|
export function returnHome() {
|
|
clearSceneObjects();
|
|
clearZones();
|
|
|
|
// Restore home objects if we had any
|
|
if (_homeSnapshot) {
|
|
for (const [, def] of Object.entries(_homeSnapshot)) {
|
|
if (def.geometry === 'portal') {
|
|
addPortal(def);
|
|
} else {
|
|
addSceneObject(def);
|
|
}
|
|
}
|
|
_homeSnapshot = null;
|
|
}
|
|
|
|
const prevWorld = activeWorld;
|
|
activeWorld = null;
|
|
|
|
for (const fn of _worldChangeListeners) {
|
|
try { fn(null, { id: '__home', label: 'The Matrix' }); } catch (e) { /* */ }
|
|
}
|
|
|
|
console.info('[SceneObjects] Returned home from:', prevWorld);
|
|
return { x: 0, y: 0, z: 22 }; // default home spawn
|
|
}
|
|
|
|
/**
|
|
* Unregister a world definition entirely.
|
|
*/
|
|
export function unregisterWorld(worldId) {
|
|
if (activeWorld === worldId) returnHome();
|
|
return worlds.delete(worldId);
|
|
}
|
|
|
|
/**
|
|
* Get the currently active world id (null = home).
|
|
*/
|
|
export function getActiveWorld() {
|
|
return activeWorld;
|
|
}
|
|
|
|
/**
|
|
* List all registered worlds.
|
|
*/
|
|
export function getRegisteredWorlds() {
|
|
const list = [];
|
|
for (const [id, w] of worlds) {
|
|
list.push({ id, label: w.label, objectCount: w.objects?.length ?? 0, loaded: w.loaded });
|
|
}
|
|
return list;
|
|
}
|
|
|
|
/* ── Disposal helper ── */
|
|
|
|
function _disposeRecursive(obj) {
|
|
if (obj.geometry) obj.geometry.dispose();
|
|
if (obj.material) {
|
|
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
|
for (const m of mats) {
|
|
if (m.map) m.map.dispose();
|
|
m.dispose();
|
|
}
|
|
}
|
|
if (obj.children) {
|
|
for (const child of [...obj.children]) {
|
|
_disposeRecursive(child);
|
|
}
|
|
}
|
|
}
|