1/** 2 * 3D ENGINE - Shared Three.js renderer, camera, and scene lifecycle. 3 * Uses Three.js from CDN via import map. 4 */ 5 6import * as THREE from 'three'; 7import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js'; 8 9// âââ Shared Renderer âââââââââââââââââââââââââââââââââââââââââââ 10let renderer = null; 11let currentRoom = null; 12let animationId = null; 13const BASE_TONE_MAPPING_EXPOSURE = 2.2; 14const LIGHT_BASE_INTENSITY_KEY = 'baseLightIntensity'; 15 16function getConfiguredLightIntensity() { 17 const config = window.gameConfig || {}; 18 const lightIntensity = Number(config.lightIntensity); 19 if (!Number.isFinite(lightIntensity)) return 1; 20 return Math.min(3, Math.max(0.2, lightIntensity)); 21} 22 23function applyConfiguredLighting() { 24 if (renderer) renderer.toneMappingExposure = BASE_TONE_MAPPING_EXPOSURE; 25 if (currentRoom) currentRoom.applyLightIntensity(); 26} 27 28function setLightBaseIntensity(light, baseIntensity) { 29 if (!light) return; 30 light.userData[LIGHT_BASE_INTENSITY_KEY] = baseIntensity; 31 light.intensity = baseIntensity * getConfiguredLightIntensity(); 32} 33 34window.addEventListener('game-config-updated', applyConfiguredLighting); 35 36function getRenderer() { 37 if (!renderer) { 38 renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true }); 39 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); 40 renderer.shadowMap.enabled = true; 41 renderer.shadowMap.type = THREE.PCFSoftShadowMap; 42 renderer.toneMapping = THREE.ACESFilmicToneMapping; 43 renderer.toneMappingExposure = BASE_TONE_MAPPING_EXPOSURE; 44 } 45 return renderer; 46} 47 48// âââ Room Base Class âââââââââââââââââââââââââââââââââââââââââââ 49class Room3D { 50 constructor(containerId) { 51 this.containerId = containerId; 52 this.scene = new THREE.Scene(); 53 this.camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100); 54 this.controls = null; 55 this.clock = new THREE.Clock(); 56 this.hotspots = []; // { mesh, clueIds, label, visited } 57 this.raycaster = new THREE.Raycaster(); 58 this.mouse = new THREE.Vector2(); 59 this.hoveredHotspot = null; 60 this.moveState = { forward: false, backward: false, left: false, right: false }; 61 this.velocity = new THREE.Vector3(); 62 this.moveSpeed = 2.5; 63 this.bounds = { minX: -2, maxX: 2, minZ: -2, maxZ: 2 }; 64 this.colliders = []; // optional solid AABBs (XZ) that block movement 65 this._onResize = this._onResize.bind(this); 66 this._onKeyDown = this._onKeyDown.bind(this); 67 this._onKeyUp = this._onKeyUp.bind(this); 68 this._onClick = this._onClick.bind(this); 69 this._onMouseMove = this._onMouseMove.bind(this); 70 } 71 72 mount() { 73 const container = document.getElementById(this.containerId); 74 if (!container) return; 75 76 const r = getRenderer(); 77 const rect = container.getBoundingClientRect(); 78 r.setSize(rect.width, rect.height); 79 container.appendChild(r.domElement); 80 81 // Camera 82 this.camera.aspect = rect.width / rect.height; 83 this.camera.updateProjectionMatrix(); 84 85 // Pointer lock controls 86 this.controls = new PointerLockControls(this.camera, r.domElement); 87 r.domElement.addEventListener('click', () => { 88 if (!this.controls.isLocked) { 89 this.controls.lock(); 90 } 91 }); 92 93 // HUD / crosshair 94 this._createHUD(container); 95 96 // Events 97 window.addEventListener('resize', this._onResize); 98 document.addEventListener('keydown', this._onKeyDown); 99 document.addEventListener('keyup', this._onKeyUp); 100 r.domElement.addEventListener('click', this._onClick); 101 r.domElement.addEventListener('mousemove', this._onMouseMove); 102 103 // Build the room 104 this.build(); 105 this.captureLightIntensities(); 106 this.applyLightIntensity(); 107 108 // Start render loop 109 this._animate(); 110 } 111 112 unmount() { 113 if (animationId) { 114 cancelAnimationFrame(animationId); 115 animationId = null; 116 } 117 window.removeEventListener('resize', this._onResize); 118 document.removeEventListener('keydown', this._onKeyDown); 119 document.removeEventListener('keyup', this._onKeyUp); 120 121 const r = getRenderer(); 122 r.domElement.removeEventListener('click', this._onClick); 123 r.domElement.removeEventListener('mousemove', this._onMouseMove); 124 125 if (this.controls && this.controls.isLocked) { 126 // Programmatic unlock (teardown) â don't treat it as an ESC "pause". 127 window.__suppressPauseOnUnlock = true; 128 this.controls.unlock(); 129 } 130 131 // Remove canvas from DOM 132 const container = document.getElementById(this.containerId); 133 if (container && r.domElement.parentNode === container) { 134 container.removeChild(r.domElement); 135 } 136 137 // Remove HUD
138 const hud = container && container.querySelector('.room3d-hud'); 139 if (hud) hud.remove(); 140 141 // Dispose 142 this.scene.traverse(obj => { 143 if (obj.geometry) obj.geometry.dispose(); 144 if (obj.material) { 145 if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose()); 146 else obj.material.dispose(); 147 } 148 }); 149 150 this.hotspots = []; 151 this.moveState = { forward: false, backward: false, left: false, right: false }; 152 if (currentRoom === this) currentRoom = null; 153 } 154 155 build() { 156 // Override in subclasses 157 } 158 159 captureLightIntensities() { 160 currentRoom = this; 161 this.scene.traverse(obj => { 162 if (obj.isLight && typeof obj.intensity === 'number' && obj.userData[LIGHT_BASE_INTENSITY_KEY] === undefined) { 163 obj.userData[LIGHT_BASE_INTENSITY_KEY] = obj.intensity; 164 } 165 }); 166 } 167 168 applyLightIntensity() { 169 const lightIntensity = getConfiguredLightIntensity(); 170 this.scene.traverse(obj => { 171 if (obj.isLight && typeof obj.intensity === 'number') { 172 let baseIntensity = obj.userData[LIGHT_BASE_INTENSITY_KEY]; 173 if (typeof baseIntensity !== 'number') { 174 baseIntensity = obj.intensity; 175 obj.userData[LIGHT_BASE_INTENSITY_KEY] = baseIntensity; 176 } 177 obj.intensity = baseIntensity * lightIntensity; 178 } 179 }); 180 } 181 182 update(delta) { 183 // Override for custom per-frame logic 184 } 185 186 // âââ Movement âââââââââââââââââââââââââââââââââââââââââââââââââ 187 _processMovement(delta) { 188 if (!this.controls || !this.controls.isLocked) return; 189 190 const dir = new THREE.Vector3(); 191 const right = new THREE.Vector3(); 192 this.camera.getWorldDirection(dir); 193 dir.y = 0; 194 dir.normalize(); 195 right.crossVectors(dir, new THREE.Vector3(0, 1, 0)).normalize(); 196 197 const speed = this.moveSpeed * delta; 198 199 let dx = 0, dz = 0; 200 if (this.moveState.forward) { dx += dir.x * speed; dz += dir.z * speed; } 201 if (this.moveState.backward) { dx -= dir.x * speed; dz -= dir.z * speed; } 202 if (this.moveState.left) { dx -= right.x * speed; dz -= right.z * speed; } 203 if (this.moveState.right) { dx += right.x * speed; dz += right.z * speed; } 204 205 // Axis-separated movement so the player slides along walls/colliders 206 // instead of sticking when grazing them. 207 const BODY = 0.3; 208 const pos = this.camera.position; 209 210 const nx = Math.max(this.bounds.minX + BODY, Math.min(this.bounds.maxX - BODY, pos.x + dx)); 211 if (!this._collides(nx, pos.z, BODY)) pos.x = nx; 212 213 const nz = Math.max(this.bounds.minZ + BODY, Math.min(this.bounds.maxZ - BODY, pos.z + dz)); 214 if (!this._collides(pos.x, nz, BODY)) pos.z = nz; 215 } 216 217 // Returns true if a body of radius r centred at (x, z) overlaps any collider. 218 _collides(x, z, r) { 219 for (let i = 0; i < this.colliders.length; i++) { 220 const c = this.colliders[i]; 221 if (x > c.minX - r && x < c.maxX + r && z > c.minZ - r && z < c.maxZ + r) return true; 222 } 223 return false; 224 } 225 226 // âââ Hotspot Raycasting âââââââââââââââââââââââââââââââââââââââ 227 _raycastHotspots() { 228 this.raycaster.setFromCamera(new THREE.Vector2(0, 0), this.camera); 229 const meshes = this.hotspots.map(h => h.mesh); 230 const hits = this.raycaster.intersectObjects(meshes); 231 232 const maxDist = this.raycastDistance || 3.5; 233 if (hits.length > 0 && hits[0].distance < maxDist) { 234 const hit = this.hotspots.find(h => h.mesh === hits[0].object); 235 // Show label for unvisited clue hotspots OR navigation hotspots 236 if (hit && (!hit.visited || hit.navigate)) { 237 this.hoveredHotspot = hit; 238 this._updateHUD(hit.label, true); 239 return; 240 } 241 } 242 this.hoveredHotspot = null; 243 this._updateHUD('', false); 244 } 245 246 // âââ HUD ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 247 _createHUD(container) {
248 const hud = document.createElement('div'); 249 hud.className = 'room3d-hud'; 250 hud.innerHTML = ` 251 <div class="crosshair">+</div> 252 <div class="hotspot-label-3d"></div> 253 <div class="room3d-hint">ç¹å»è¿å ¥ · WASDç§»å¨ Â· é¼ æ ç¯é¡¾ · Eäºå¨/è¿å ¥ · ESC æå</div> 254 `; 255 container.appendChild(hud); 256 } 257 258 _updateHUD(label, showInteract) { 259 const container = document.getElementById(this.containerId); 260 if (!container) return; 261 const labelEl = container.querySelector('.hotspot-label-3d'); 262 const crosshair = container.querySelector('.crosshair'); 263 if (labelEl) { 264 labelEl.textContent = showInteract ? `[E] ${label}` : ''; 265 } 266 if (crosshair) { 267 crosshair.classList.toggle('active', showInteract); 268 } 269 } 270 271 // âââ Events âââââââââââââââââââââââââââââââââââââââââââââââââââ 272 _onResize() { 273 const container = document.getElementById(this.containerId); 274 if (!container) return; 275 const rect = container.getBoundingClientRect(); 276 const r = getRenderer(); 277 r.setSize(rect.width, rect.height); 278 this.camera.aspect = rect.width / rect.height; 279 this.camera.updateProjectionMatrix(); 280 } 281 282 _onKeyDown(e) { 283 if (!this.controls || !this.controls.isLocked) return; 284 switch (e.code) { 285 case 'KeyW': case 'ArrowUp': this.moveState.forward = true; break; 286 case 'KeyS': case 'ArrowDown': this.moveState.backward = true; break; 287 case 'KeyA': case 'ArrowLeft': this.moveState.left = true; break; 288 case 'KeyD': case 'ArrowRight': this.moveState.right = true; break; 289 case 'KeyE': 290 if (this.hoveredHotspot) this._activateHotspot(this.hoveredHotspot); 291 break; 292 } 293 } 294 295 _onKeyUp(e) { 296 switch (e.code) { 297 case 'KeyW': case 'ArrowUp': this.moveState.forward = false; break; 298 case 'KeyS': case 'ArrowDown': this.moveState.backward = false; break; 299 case 'KeyA': case 'ArrowLeft': this.moveState.left = false; break; 300 case 'KeyD': case 'ArrowRight': this.moveState.right = false; break; 301 } 302 } 303 304 _onClick() { 305 if (this.controls && this.controls.isLocked && this.hoveredHotspot) { 306 this._activateHotspot(this.hoveredHotspot); 307 } 308 } 309 310 _onMouseMove() { 311 // Raycasting from center is handled in the render loop 312 } 313 314 _activateHotspot(hotspot) { 315 if (hotspot.visited && !hotspot.navigate) return; 316 317 // Navigation hotspots (e.g., door/stairs â another 3D room) 318 if (hotspot.navigate) { 319 if (this.controls && this.controls.isLocked) { 320 // Programmatic unlock for the room switch â not an ESC "pause". 321 window.__suppressPauseOnUnlock = true; 322 this.controls.unlock(); 323 } 324 // Always switch directly to the target 3D room 325 if (typeof window.switch3DRoomBridge === 'function') { 326 window.switch3DRoomBridge(hotspot.navigate); 327 // Update global state 328 if (typeof current3DScene !== 'undefined') current3DScene = hotspot.navigate; 329 gameState.currentScene = hotspot.navigate; 330 saveState(); 331 } 332 return; 333 } 334 335 hotspot.visited = true; 336 337 // Visual: change to green 338 hotspot.mesh.material.color.set(0x5a8a5a); 339 hotspot.mesh.material.emissive.set(0x5a8a5a); 340 341 // Trigger game logic 342 if (hotspot.clueIds && hotspot.clueIds.length > 0) { 343 const newClues = []; 344 hotspot.clueIds.forEach(id => { 345 if (addClue(id)) newClues.push(id); 346 }); 347 if (newClues.length > 0) showClueModal(newClues); 348 } else if (hotspot.message) { 349 showBriefMessage(hotspot.message); 350 } 351 352 // Visit room 353 if (hotspot.room) visitRoom(hotspot.room); 354 } 355 356 // âââ Render Loop ââââââââââââââââââââââââââââââââââââââââââââââ 357 _animate() { 358 animationId = requestAnimationFrame(() => this._animate()); 359 const delta = this.clock.getDelta(); 360 this._processMovement(delta); 361 this._raycastHotspots(); 362 this.update(delta); 363 this.applyLightIntensity(); 364 getRenderer().render(this.scene, this.camera); 365 } 366} 367 368// âââ Helper: Create a glowing hotspot sphere ââââââââââââââââââââ 369function createHotspotMesh(position, color = 0xc8a96e) { 370 const geo = new THREE.SphereGeometry(0.12, 16, 16); 371 const mat = new THREE.MeshStandardMaterial({ 372 color: color, 373 emissive: color, 374 emissiveIntensity: 0.8, 375 transparent: true, 376 opacity: 0.9 377 }); 378 const mesh = new THREE.Mesh(geo, mat); 379 mesh.position.copy(position); 380 return mesh; 381} 382 383// âââ Helper: Create a wall plane ââââââââââââââââââââââââââââââââ 384function createWall(width, height, position, rotation, color = 0x1a1a1a) { 385 const geo = new THREE.PlaneGeometry(width, height); 386 const mat = new THREE.MeshStandardMaterial({ 387 color: color, 388 roughness: 0.9, 389 metalness: 0, 390 side: THREE.DoubleSide 391 }); 392 const mesh = new THREE.Mesh(geo, mat); 393 mesh.position.copy(position); 394 if (rotation) mesh.rotation.set(rotation.x || 0, rotation.y || 0, rotation.z || 0); 395 mesh.receiveShadow = true; 396 return mesh; 397} 398 399// âââ Helper: Wall with a doorway opening ââââââââââââââââââââââââ 400// Builds a Group of wall panels framing a rectangular doorway. 401// Local frame: y=0 at floor, x along the wall width, plane faces +Z. 402// Position/rotate the returned group like any wall (base at floor level). 403function createDoorwayWall({ 404 width, 405 height, 406 doorWidth = 0.95, 407 doorHeight = 2.1, 408 doorOffset = 0, 409 color = 0x333333, 410 side = THREE.DoubleSide, 411}) { 412 const group = new THREE.Group(); 413 const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.9, metalness: 0, side }); 414 415 const doorLeft = doorOffset - doorWidth / 2; 416 const doorRight = doorOffset + doorWidth / 2; 417 const leftW = doorLeft - -width / 2; 418 const rightW = width / 2 - doorRight; 419 const lintelH = height - doorHeight; 420 421 const addPanel = (w, h, cx, cy) => { 422 if (w <= 0.001 || h <= 0.001) return; 423 const panel = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat); 424 panel.position.set(cx, cy, 0); 425 panel.receiveShadow = true; 426 group.add(panel); 427 }; 428
429 addPanel(leftW, height, -width / 2 + leftW / 2, height / 2); 430 addPanel(rightW, height, width / 2 - rightW / 2, height / 2); 431 addPanel(doorWidth, lintelH, doorOffset, doorHeight + lintelH / 2); 432 433 return group; 434} 435 436export { Room3D, getRenderer, createHotspotMesh, createWall, createDoorwayWall, setLightBaseIntensity, THREE };
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.