PageSourceSearch

https://yxwan123.github.io/Inference_Engine/js/3d/engine.js

js yxwan123.github.io collected 2026-10-03 10:14:35 UTC 15,646 bytes, 436 lines download raw bytes

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.