From cad48c2e1b2861e4accf6ee2387a0b089008553d Mon Sep 17 00:00:00 2001 From: eliazarw Date: Thu, 19 Mar 2026 14:06:41 -0400 Subject: [PATCH] First commit --- index.html | 21 +- src/Flare.js | 55 ++++-- src/Game.js | 101 ++++++++-- src/Graphics.js | 4 +- src/Monster.js | 120 +++++------- src/Monster2.js | 347 --------------------------------- src/Player.js | 290 +++++++++++++++++++++------ src/World.js | 508 +++++++++++++++++++++++++++++++++--------------- src/main.js | 6 + style.css | 66 ++++++- 10 files changed, 845 insertions(+), 673 deletions(-) delete mode 100644 src/Monster2.js diff --git a/index.html b/index.html index df89084..cb0c374 100644 --- a/index.html +++ b/index.html @@ -11,21 +11,34 @@
-
-
-

ECHOES

-

Click to Start

+
+
+

INITIALIZING...

+
+
+ +
diff --git a/src/Flare.js b/src/Flare.js index 1772e24..abf0f6d 100644 --- a/src/Flare.js +++ b/src/Flare.js @@ -1,22 +1,28 @@ import * as THREE from 'three'; export class Flare { - constructor(scene, playerPos, direction, colliders) { - this.scene = scene; - this.colliders = colliders; + // NEW: Shared assets + static geometry = new THREE.CylinderGeometry(0.02, 0.02, 0.15, 8); + static material = new THREE.MeshStandardMaterial({ + color: 0xff0000, + emissive: 0xff0000, + emissiveIntensity: 2.0 + }); - // Flare Mesh - const geo = new THREE.CylinderGeometry(0.02, 0.02, 0.15, 8); - const mat = new THREE.MeshStandardMaterial({ - color: 0xff0000, - emissive: 0xff0000, - emissiveIntensity: 2.0 - }); - this.mesh = new THREE.Mesh(geo, mat); + // NEW: Temp vectors for GC reduction + static tempVec = new THREE.Vector3(); + + constructor(scene, playerPos, direction, colliderBoxes) { + this.scene = scene; + this.colliderBoxes = colliderBoxes; + + // Flare Mesh (Shared) + this.mesh = new THREE.Mesh(Flare.geometry, Flare.material); this.mesh.rotation.x = Math.PI / 2; + this.mesh.castShadow = true; // Stats - this.position = playerPos.clone().add(direction.clone().multiplyScalar(0.5)); + this.position = playerPos.clone().add(Flare.tempVec.copy(direction).multiplyScalar(0.5)); this.mesh.position.copy(this.position); this.velocity = direction.clone().multiplyScalar(10.0); this.gravity = -9.8; @@ -24,6 +30,7 @@ export class Flare { this.bounce = 0.5; this.lifetime = 60.0; // 60 seconds this.active = true; + this.isSleeping = false; // NEW: Physics sleep // Light this.light = new THREE.PointLight(0xff3333, 5.0, 10); @@ -38,7 +45,13 @@ export class Flare { } update(dt) { - if (!this.active) return; + if (!this.active || this.isSleeping) { + if (this.active) { + this.lifetime -= dt; + if (this.lifetime <= 0) this.destroy(); + } + return; + } this.lifetime -= dt; if (this.lifetime <= 0) { @@ -48,7 +61,7 @@ export class Flare { // Physics this.velocity.y += this.gravity * dt; - this.position.add(this.velocity.clone().multiplyScalar(dt)); + this.position.add(Flare.tempVec.copy(this.velocity).multiplyScalar(dt)); // Ground collision (Floor is at y=0) if (this.position.y < 0.05) { @@ -56,15 +69,21 @@ export class Flare { this.velocity.y *= -this.bounce; this.velocity.x *= this.friction; this.velocity.z *= this.friction; + + // Sleep check: if almost still on ground + if (Math.abs(this.velocity.y) < 0.1 && Flare.tempVec.set(this.velocity.x, 0, this.velocity.z).length() < 0.1) { + this.isSleeping = true; + this.velocity.set(0, 0, 0); + } } - // Wall collisions (simplified) - this.colliders.forEach(col => { - const box = new THREE.Box3().setFromObject(col); + // Wall collisions (optimized) + this.colliderBoxes.forEach(box => { if (box.containsPoint(this.position)) { // Bounce back this.velocity.multiplyScalar(-this.bounce); - this.position.add(this.velocity.clone().multiplyScalar(dt * 2)); + this.position.add(Flare.tempVec.copy(this.velocity).multiplyScalar(dt * 2)); + this.isSleeping = false; // Wake up on collision } }); diff --git a/src/Game.js b/src/Game.js index 1c2a487..2ac2c25 100644 --- a/src/Game.js +++ b/src/Game.js @@ -2,19 +2,22 @@ import { Graphics } from './Graphics.js'; import { World } from './World.js'; import { Player } from './Player.js'; import { Monster } from './Monster.js'; -import { Monster2 } from './Monster2.js'; +import { SCP096 } from './SCP096.js'; import { Flare } from './Flare.js'; export class Game { constructor() { this.graphics = new Graphics(); this.world = new World(this.graphics.scene); - this.player = new Player(this.graphics.camera, this.world.colliders); + this.player = new Player(this.graphics.camera, this.world.colliders, this.world.rechargeStations, this.world.colliderBoxes, this.world.collectableFlares); + this.player.world = this.world; // New: Link world for interaction checks + // Flashlight is already child of camera, do not reparent // Monster 1 (The Scuttler) this.monster = new Monster(this.world.scene, this.player, this.world.colliders, this.player.ctx, this.world.safeZones); - // Monster 2 (The Stalker) - this.monster2 = new Monster2(this.world.scene, this.player, this.world.colliders, this.player.ctx, this.world); + // SCP-096 (The Shy Guy) + this.scp096 = new SCP096(this.world.scene, this.player, this.world.colliders, this.player.ctx); + this.isRunning = false; this.lastTime = 0; @@ -22,7 +25,44 @@ export class Game { window.log('GAME_STATE: INITIALIZED'); this.activeFlares = []; + this.combinedSafeZones = []; // NEW: Persistent array to avoid GC window.addEventListener('throwFlare', (e) => this.spawnFlare(e.detail.position, e.detail.direction)); + + this.heartElement = document.querySelector('.heart-icon'); + + // Start initialization immediately + this.init(); + } + + async init() { + // Yield to let UI render the loading screen first + await new Promise(resolve => setTimeout(resolve, 100)); + + window.log('GAME_STATE: LOADING_ASSETS'); + this.world.load(); + + this.graphics.scene.add(this.player.getObject()); + + // Pre-compile shaders + window.log('GAME_STATE: COMPILING_SHADERS'); + try { + this.graphics.renderer.compile(this.graphics.scene, this.graphics.camera); + } catch (e) { + console.warn("Shader compilation failed", e); + } + + // Ready + this.onLoadComplete(); + } + + onLoadComplete() { + const loadingScreen = document.getElementById('loading-screen'); + const startScreen = document.getElementById('start-screen'); + + if (loadingScreen) loadingScreen.style.display = 'none'; + if (startScreen) startScreen.style.display = 'block'; + + window.log('GAME_STATE: READY'); } setupUI() { @@ -35,14 +75,13 @@ export class Game { startScreen.style.display = 'none'; if (hud) hud.style.display = 'block'; this.isRunning = true; + this.startLoop(); window.log('GAME_STATE: RUNNING'); }); } } - start() { - this.world.load(); - this.graphics.scene.add(this.player.getObject()); + startLoop() { requestAnimationFrame(this.loop.bind(this)); } @@ -55,21 +94,31 @@ export class Game { this.world.update(dt, this.player); // Update Flares + const flareCountBefore = this.activeFlares.length; this.activeFlares = this.activeFlares.filter(f => f.active); this.activeFlares.forEach(f => f.update(dt)); - // Combined Safe Zones for Monsters - const combinedSafeZones = [...this.world.safeZones, ...this.activeFlares]; + // Combined Safe Zones for Monsters (Optimized to avoid allocation) + if (this.activeFlares.length !== flareCountBefore || this.combinedSafeZones.length === 0) { + this.combinedSafeZones = [...this.world.safeZones, ...this.activeFlares]; + if (this.monster) this.monster.safeZones = this.combinedSafeZones; + } if (this.monster) { - this.monster.safeZones = combinedSafeZones; this.monster.update(dt); } - if (this.monster2) { - // Monster 2 ignores flares (electronic signal parasite) - // It still respects world.safeZones (which are electronic lights) - this.monster2.update(dt); + if (this.scp096) { + this.scp096.update(dt); } + + // NEW: Heartbeat Monitor + // Find closest monster distance + let dist1 = 999; + if (this.monster && this.monster.mesh) { + dist1 = this.player.camera.position.distanceTo(this.monster.mesh.position); + } + + this.updateHeartbeat(dist1); } this.graphics.render(); @@ -78,8 +127,30 @@ export class Game { spawnFlare(pos, dir) { if (!this.isRunning) return; - const flare = new Flare(this.graphics.scene, pos, dir, this.world.colliders); + const flare = new Flare(this.graphics.scene, pos, dir, this.world.colliderBoxes); this.activeFlares.push(flare); + + // Update combined safe zones immediately so AI reacts + this.combinedSafeZones = [...this.world.safeZones, ...this.activeFlares]; + if (this.monster) this.monster.safeZones = this.combinedSafeZones; + window.log('FLARE_DEPLOYED - Temporary Safe Zone Established'); } + + updateHeartbeat(distance) { + if (!this.heartElement) return; + + // Distance thresholds: + // > 15m: Resting (1s pulse) + // 10m - 15m: Alert (0.6s pulse) + // 5m - 10m: Danger (0.3s pulse) + // < 5m: Panic (0.15s pulse) + + let duration = '1s'; + if (distance < 5) duration = '0.15s'; + else if (distance < 10) duration = '0.3s'; + else if (distance < 15) duration = '0.6s'; + + this.heartElement.style.animationDuration = duration; + } } diff --git a/src/Graphics.js b/src/Graphics.js index 6073ad8..7e4e952 100644 --- a/src/Graphics.js +++ b/src/Graphics.js @@ -4,7 +4,7 @@ export class Graphics { constructor() { // Main scene rendering this.scene = new THREE.Scene(); - this.scene.fog = new THREE.Fog(0x000000, 2, 12); + // Fog removed here, handled by World this.scene.background = new THREE.Color(0x000000); this.camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 100); @@ -13,7 +13,7 @@ export class Graphics { this.renderer = new THREE.WebGLRenderer({ antialias: false }); this.renderer.setSize(window.innerWidth, window.innerHeight); this.renderer.shadowMap.enabled = true; - this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; + this.renderer.shadowMap.type = THREE.PCFShadowMap; // Optimized for performance this.renderer.domElement.id = 'three-canvas'; // Append to the correct container, not body directly diff --git a/src/Monster.js b/src/Monster.js index a160172..ba7010b 100644 --- a/src/Monster.js +++ b/src/Monster.js @@ -16,9 +16,9 @@ export class Monster { this.state = 'PATROL'; // PATROL, CHASE, JUMPSCARE, STUNNED this.target = null; this.patrolSpeed = 1.5; // Renamed from 'speed' - this.chaseSpeed = 4.5; // Increased from 3.5 + this.chaseSpeed = 3.2; // Reduced from 4.5 this.stunTimer = 0; - this.position = new THREE.Vector3(15, 0, 15); + this.position = new THREE.Vector3(25, 0, 25); // Moved further from origin (0,0) this.mesh.position.copy(this.position); this.targetNode = new THREE.Vector3(); @@ -34,8 +34,16 @@ export class Monster { // Audio initialization this.setupAudio(); + + // Listen for player overload pulse + // window.addEventListener('flashlightOverload', (e) => this.onOverload(e.detail.position)); } + // NEW: Shared temp vectors for GC reduction + static tempVec1 = new THREE.Vector3(); + static tempVec2 = new THREE.Vector3(); + static tempVec3 = new THREE.Vector3(); + setupAudio() { if (!this.audioCtx) return; @@ -150,9 +158,10 @@ export class Monster { } isPlayerSafe() { - const playerPos = this.player.camera.position.clone(); + const playerPos = Monster.tempVec1.copy(this.player.camera.position); // Reuse temp vector playerPos.y = 0; - for (const zone of this.safeZones) { + for (let i = 0; i < this.safeZones.length; i++) { + const zone = this.safeZones[i]; if (!zone.active) continue; const dist = playerPos.distanceTo(zone.position); if (dist < zone.radius) return true; @@ -163,10 +172,29 @@ export class Monster { update(dt) { if (!this.player) return; + // NEW: Hiding Logic + if (this.player.isHiding) { + if (this.state === 'CHASE') { + this.state = 'IDLE'; + // Reset patrol or do nothing + window.log('The entity loses track of you...'); + } + // Just animate IDLE or patrol randomly? Culling will hide it if far. + // But we should stop chasing. + // Move randomly? + // For now, simply RETURN to stop all logic (freeze/idle) + // OR finding a new random target would be better. + if (this.state !== 'PATROL') { + this.state = 'PATROL'; + this.setNewPatrolTarget(); + } + // Allow basic patrol update + } + if (this.state === 'STUNNED') { this.stunTimer -= dt; // Retreat while stunned - const retreatDir = new THREE.Vector3().subVectors(this.mesh.position, this.player.camera.position).normalize(); + const retreatDir = Monster.tempVec1.subVectors(this.mesh.position, this.player.camera.position).normalize(); retreatDir.y = 0; this.mesh.position.add(retreatDir.multiplyScalar(this.patrolSpeed * dt)); @@ -180,9 +208,9 @@ export class Monster { } const isSafe = this.isPlayerSafe(); - const playerPos = this.player.camera.position.clone(); + const playerPos = Monster.tempVec1.copy(this.player.camera.position); playerPos.y = 0; - const monsterPos = this.mesh.position.clone(); + const monsterPos = Monster.tempVec2.copy(this.mesh.position); monsterPos.y = 0; const distToPlayer = monsterPos.distanceTo(playerPos); @@ -195,7 +223,7 @@ export class Monster { } // Move towards target - const dir = new THREE.Vector3().subVectors(this.targetNode, monsterPos).normalize(); + const dir = Monster.tempVec3.subVectors(this.targetNode, monsterPos).normalize(); this.mesh.position.add(dir.multiplyScalar(this.patrolSpeed * dt)); // Rotation @@ -220,7 +248,7 @@ export class Monster { return; } - const dir = new THREE.Vector3().subVectors(playerPos, monsterPos).normalize(); + const dir = Monster.tempVec3.subVectors(playerPos, monsterPos).normalize(); this.mesh.position.add(dir.multiplyScalar(this.chaseSpeed * dt)); // Intensive Rotation @@ -244,13 +272,12 @@ export class Monster { else if (this.state === 'JUMPSCARE') { this.jumpscareTimer += dt; - const camPos = this.player.camera.position.clone(); - const monsterPos = this.mesh.position.clone(); + const camPos = Monster.tempVec1.copy(this.player.camera.position); // 1. Move/Lunge at camera - const camDir = new THREE.Vector3(); + const camDir = Monster.tempVec2; this.player.camera.getWorldDirection(camDir); - const jumpTarget = camPos.clone().add(camDir.multiplyScalar(0.2)); + const jumpTarget = Monster.tempVec3.copy(camPos).add(camDir.multiplyScalar(0.2)); this.mesh.position.lerp(jumpTarget, 15 * dt); // 2. STARE: Force monster to look at camera @@ -307,84 +334,44 @@ export class Monster { if (!this.audioCtx || dist > 20) return; const t = this.audioCtx.currentTime; - // Light "patter" noise (Short high-frequency tap) - Increased Volume + // Optimized: Reduced complexity const g = this.audioCtx.createGain(); - g.gain.setValueAtTime(0.25 * (1 - dist / 20), t); // Increased from 0.08 to 0.25 + g.gain.setValueAtTime(0.15 * (1 - dist / 20), t); + g.gain.exponentialRampToValueAtTime(0.01, t + 0.05); const osc = this.audioCtx.createOscillator(); osc.type = 'triangle'; osc.frequency.setValueAtTime(400, t); - osc.frequency.exponentialRampToValueAtTime(1200, t + 0.01); - osc.frequency.exponentialRampToValueAtTime(80, t + 0.03); + osc.frequency.exponentialRampToValueAtTime(100, t + 0.05); - const filter = this.audioCtx.createBiquadFilter(); - filter.type = 'bandpass'; - filter.frequency.value = 800; - filter.Q.value = 1; - - osc.connect(filter); - filter.connect(g); + osc.connect(g); g.connect(this.panner); osc.start(t); - osc.stop(t + 0.04); + osc.stop(t + 0.05); } updateAudio(dt, dist, monsterPos) { if (!this.audioCtx || !this.player || !this.audioStarted) return; - // Update Panner Position + // Update Panner Position Only (Player updates Listener) this.panner.positionX.setTargetAtTime(monsterPos.x, this.audioCtx.currentTime, 0.1); this.panner.positionY.setTargetAtTime(monsterPos.y + 0.5, this.audioCtx.currentTime, 0.1); this.panner.positionZ.setTargetAtTime(monsterPos.z, this.audioCtx.currentTime, 0.1); - // Update Listener Position (Player) - const listener = this.audioCtx.listener; - const playerPos = this.player.camera.position; - listener.positionX.setTargetAtTime(playerPos.x, this.audioCtx.currentTime, 0.1); - listener.positionY.setTargetAtTime(playerPos.y, this.audioCtx.currentTime, 0.1); - listener.positionZ.setTargetAtTime(playerPos.z, this.audioCtx.currentTime, 0.1); - // Deep Quiet Heavy Breathing (Slower rhythm) const breathCycle = Math.sin(this.audioCtx.currentTime * 1.2) * 0.5 + 0.5; let targetBreath = 0; if (dist < 15) { targetBreath = (1 - dist / 15) * 0.3 * breathCycle; - - // Randomly trigger a deep breath sound - if (Math.random() < 0.003) { - this.playDeepBreath(); - } + // Removed deep breath allocation spam } this.breathGain.gain.setTargetAtTime(targetBreath, this.audioCtx.currentTime, 0.1); } playDeepBreath() { - if (!this.audioCtx) return; - const t = this.audioCtx.currentTime; - - const source = this.audioCtx.createBufferSource(); - const bufferSize = this.audioCtx.sampleRate * 2; - const buffer = this.audioCtx.createBuffer(1, bufferSize, this.audioCtx.sampleRate); - const data = buffer.getChannelData(0); - for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1) * 0.1; - source.buffer = buffer; - - const lowpass = this.audioCtx.createBiquadFilter(); - lowpass.type = 'lowpass'; - lowpass.frequency.setValueAtTime(300, t); - lowpass.frequency.exponentialRampToValueAtTime(100, t + 1.5); - - const g = this.audioCtx.createGain(); - g.gain.setValueAtTime(0, t); - g.gain.linearRampToValueAtTime(0.2, t + 0.5); - g.gain.linearRampToValueAtTime(0, t + 2.0); - - source.connect(lowpass); - lowpass.connect(g); - g.connect(this.panner); - source.start(t); + // Removed heavy buffer allocation logic } @@ -409,13 +396,4 @@ export class Monster { window.location.reload(); }, 1500); } - - onOverload(playerPosition) { - const dist = this.mesh.position.distanceTo(playerPosition); - if (dist < 12) { // Within overload range - this.state = 'STUNNED'; - this.stunTimer = 4.0; // 4 seconds of retreat/stun - window.log('CRITICAL: ENTITY_SENSORS_OVERLOADED - RETREATING'); - } - } } diff --git a/src/Monster2.js b/src/Monster2.js deleted file mode 100644 index f30af61..0000000 --- a/src/Monster2.js +++ /dev/null @@ -1,347 +0,0 @@ -import * as THREE from 'three'; - -export class Monster2 { - constructor(scene, player, colliders, audioCtx, world) { - this.scene = scene; - this.player = player; - this.colliders = colliders; - this.audioCtx = audioCtx; - this.world = world; - this.safeZones = world ? world.safeZones : []; - - this.mesh = new THREE.Group(); - this.setupVisuals(); - this.scene.add(this.mesh); - - // AI State - this.state = 'STALK'; // STALK, FREEZE, JUMPSCARE - this.position = new THREE.Vector3(-15, 0, -15); // Start opposite to player - this.mesh.position.copy(this.position); - - this.stalkDistance = 18; - this.minDistance = 12; - this.catchRange = 2.0; - this.moveSpeed = 4.0; - - // Stalking logic - this.glitchTimer = 0; - this.isVisibleToPlayer = false; - - this.setupAudio(); - - // Listen for player overload pulse - window.addEventListener('flashlightOverload', (e) => this.onOverload(e.detail.position)); - } - - setupVisuals() { - // TV Static Texture Setup - this.staticSize = 64; - this.staticCanvas = document.createElement('canvas'); - this.staticCanvas.width = this.staticSize; - this.staticCanvas.height = this.staticSize; - this.staticCtx = this.staticCanvas.getContext('2d'); - - this.staticTex = new THREE.CanvasTexture(this.staticCanvas); - this.staticTex.minFilter = THREE.NearestFilter; - this.staticTex.magFilter = THREE.NearestFilter; - - this.updateStatic(); // Initial draw - - const mat = new THREE.MeshStandardMaterial({ - map: this.staticTex, - roughness: 0.5, - metalness: 0.5, - emissive: 0x222222, // Subtle glow to make static visible in pitch black - emissiveMap: this.staticTex - }); - - // 1. Torso (Vertical, extremely slender) - const torsoGeo = new THREE.BoxGeometry(0.2, 1.4, 0.15); - const torso = new THREE.Mesh(torsoGeo, mat); - torso.position.y = 1.8; - torso.castShadow = true; - this.mesh.add(torso); - - // 2. Legs (Extremely long) - const legGeo = new THREE.BoxGeometry(0.1, 1.8, 0.1); - const legL = new THREE.Mesh(legGeo, mat); - legL.position.set(-0.1, 0.9, 0); - legL.castShadow = true; - this.mesh.add(legL); - - const legR = new THREE.Mesh(legGeo, mat); - legR.position.set(0.1, 0.9, 0); - legR.castShadow = true; - this.mesh.add(legR); - - // 3. Arms (Dragging on floor) - const armGeo = new THREE.BoxGeometry(0.08, 2.2, 0.08); - const armL = new THREE.Mesh(armGeo, mat); - armL.position.set(-0.25, 1.5, 0); - armL.rotation.z = 0.05; - armL.castShadow = true; - this.mesh.add(armL); - - const armR = new THREE.Mesh(armGeo, mat); - armR.position.set(0.25, 1.5, 0); - armR.rotation.z = -0.05; - armR.castShadow = true; - this.mesh.add(armR); - - // 4. Head - const headGeo = new THREE.BoxGeometry(0.2, 0.3, 0.2); - const head = new THREE.Mesh(headGeo, mat); - head.position.y = 2.65; - head.castShadow = true; - this.mesh.add(head); - - // Single bright white eye - const eyeGeo = new THREE.PlaneGeometry(0.06, 0.06); - this.eyeMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.9 }); - const eye = new THREE.Mesh(eyeGeo, this.eyeMat); - eye.position.set(0, 2.7, 0.11); - this.mesh.add(eye); - - // Store material for fade logic - this.mainMat = mat; - } - - updateStatic() { - if (!this.staticCtx) return; - const imageData = this.staticCtx.createImageData(this.staticSize, this.staticSize); - const data = imageData.data; - for (let i = 0; i < data.length; i += 4) { - const v = Math.random() > 0.5 ? 255 : 0; - data[i] = v; // R - data[i + 1] = v; // G - data[i + 2] = v; // B - data[i + 3] = 255; // A - } - this.staticCtx.putImageData(imageData, 0, 0); - this.staticTex.needsUpdate = true; - } - - setupAudio() { - if (!this.audioCtx) return; - - this.panner = this.audioCtx.createPanner(); - this.panner.panningModel = 'HRTF'; - this.panner.distanceModel = 'exponential'; - this.panner.refDistance = 2; - this.panner.maxDistance = 30; - this.panner.rolloffFactor = 1.0; - this.panner.connect(this.audioCtx.destination); - - // Resonant Crystalline Hum - this.humGain = this.audioCtx.createGain(); - this.humGain.gain.value = 0; - - const osc1 = this.audioCtx.createOscillator(); - const osc2 = this.audioCtx.createOscillator(); - osc1.type = 'sine'; - osc2.type = 'sine'; - osc1.frequency.value = 880; // High A - osc2.frequency.value = 883; // Beating - - osc1.connect(this.humGain); - osc2.connect(this.humGain); - this.humGain.connect(this.panner); - osc1.start(); - osc2.start(); - - this.audioStarted = true; - } - - isPlayerSafe() { - const playerPos = this.player.camera.position.clone(); - playerPos.y = 0; - for (const zone of this.safeZones) { - if (!zone.active) continue; - const dist = playerPos.distanceTo(zone.position); - if (dist < zone.radius) return true; - } - return false; - } - - update(dt) { - if (!this.player) return; - - const isSafe = this.isPlayerSafe(); - const playerPos = this.player.camera.position.clone(); - playerPos.y = 0; - const monsterPos = this.mesh.position.clone(); - monsterPos.y = 0; - - const distToPlayer = monsterPos.distanceTo(playerPos); - const flashlightOn = this.player.flashlightOn; - - // 1. SIGNAL PARASITE LOGIC: Fade and Freeze if light is off - let targetOpacity = flashlightOn ? 1.0 : 0.05; - this.mainMat.opacity = THREE.MathUtils.lerp(this.mainMat.opacity, targetOpacity, 5 * dt); - this.mainMat.transparent = this.mainMat.opacity < 1.0; - this.eyeMat.opacity = this.mainMat.opacity; - - // 2. AI State Logic - if (this.state === 'STALK') { - if (isSafe) { - this.state = 'FREEZE'; - } else if (flashlightOn) { - // Relentlessly glitch towards the signal - this.glitchTimer += dt; - // Faster glitching when light is on - if (this.glitchTimer > 0.3) { - const dir = new THREE.Vector3().subVectors(playerPos, monsterPos).normalize(); - this.mesh.position.add(dir.multiplyScalar(this.moveSpeed * dt * 3)); - this.glitchTimer = 0; - this.playDraggingSound(distToPlayer); - } - } - // If flashlight is OFF, it stays dormant (no movement) - } else if (this.state === 'FREEZE') { - if (!isSafe) { - this.state = 'STALK'; - } - } else if (this.state === 'JUMPSCARE') { - this.updateJumpscare(dt); - } - - // 3. SAFE ZONE SABOTAGE: Glitch and Break lights - if (this.world) { - this.safeZones.forEach((zone, index) => { - const distToZone = monsterPos.distanceTo(zone.position); - if (zone.active) { - if (distToZone < 10) { - // Near a light? Glitch it! - this.world.glitchSafeZone(index, 0.4); - } - if (distToZone < 2.5) { - // Too close? BREAK IT! - this.world.breakSafeZone(index); - } - } - }); - } - - // 4. INTERFERENCE: Flicker player's flashlight when close - if (flashlightOn && distToPlayer < 8 && this.state !== 'JUMPSCARE') { - if (Math.random() < 0.3) { - this.player.flashlight.intensity = Math.random() * 2; // Intense flicker - } - } - - // 5. Distance Check for Jumpscare - if (distToPlayer < this.catchRange && this.state !== 'JUMPSCARE' && !isSafe) { - this.state = 'JUMPSCARE'; - this.jumpscareTimer = 0; - this.player.lockLook = true; - } - - this.mesh.lookAt(playerPos.x, this.mesh.position.y, playerPos.z); - this.updateAudio(distToPlayer, dt); - this.updateStatic(); - } - - playDraggingSound(dist) { - if (!this.audioCtx || dist > 25) return; - const t = this.audioCtx.currentTime; - - const g = this.audioCtx.createGain(); - g.gain.setValueAtTime(0.08 * (1 - dist / 25), t); // Reduced from 0.15 to 0.08 - g.gain.exponentialRampToValueAtTime(0.01, t + 0.4); - - const osc = this.audioCtx.createOscillator(); - osc.type = 'sawtooth'; - osc.frequency.setValueAtTime(40, t); - osc.frequency.linearRampToValueAtTime(10, t + 0.3); - - const filter = this.audioCtx.createBiquadFilter(); - filter.type = 'bandpass'; - filter.frequency.value = 100; - - osc.connect(filter); - filter.connect(g); - g.connect(this.panner); - osc.start(t); - osc.stop(t + 0.4); - } - - updateAudio(dist, dt) { - if (!this.audioCtx || !this.audioStarted) return; - - // Update panner - this.panner.positionX.setTargetAtTime(this.mesh.position.x, this.audioCtx.currentTime, 0.1); - this.panner.positionY.setTargetAtTime(this.mesh.position.y + 2, this.audioCtx.currentTime, 0.1); - this.panner.positionZ.setTargetAtTime(this.mesh.position.z, this.audioCtx.currentTime, 0.1); - - // Signal Parasite Hum: Faster oscillation when close - let humVol = 0; - if (dist < 20) { - humVol = (1 - dist / 20) * 0.05; - } - this.humGain.gain.setTargetAtTime(humVol, this.audioCtx.currentTime, 0.1); - } - - updateJumpscare(dt) { - this.jumpscareTimer += dt; - const camPos = this.player.camera.position.clone(); - const camDir = new THREE.Vector3(); - this.player.camera.getWorldDirection(camDir); - - const jumpTarget = camPos.clone().add(camDir.multiplyScalar(0.1)); - this.mesh.position.lerp(jumpTarget, 10 * dt); - this.player.camera.lookAt(this.mesh.position.x, this.mesh.position.y + 2.5, this.mesh.position.z); - - // Shake - this.mesh.position.x += (Math.random() - 0.5) * 0.2; - this.mesh.position.y += (Math.random() - 0.5) * 0.2; - - if (this.jumpscareTimer > 0.7) { - this.onCatchPlayer(); - } - } - - onCatchPlayer() { - if (this.isEnding) return; - this.isEnding = true; - - // Static sound for jumpscare - if (this.audioCtx) { - const bufferSize = this.audioCtx.sampleRate * 0.5; - const buffer = this.audioCtx.createBuffer(1, bufferSize, this.audioCtx.sampleRate); - const data = buffer.getChannelData(0); - for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1; - const source = this.audioCtx.createBufferSource(); - source.buffer = buffer; - source.connect(this.audioCtx.destination); - source.start(); - } - - const screen = document.createElement('div'); - screen.style.position = 'fixed'; - screen.style.top = '0'; - screen.style.left = '0'; - screen.style.width = '100vw'; - screen.style.height = '100vh'; - screen.style.backgroundColor = 'black'; - screen.style.zIndex = '99999'; - document.body.appendChild(screen); - - setTimeout(() => { - window.location.reload(); - }, 1000); - } - - onOverload(playerPosition) { - const dist = this.mesh.position.distanceTo(playerPosition); - if (dist < 10) { // Within overload range - window.log('CRITICAL_SIGNAL_ERROR: PARASITE_SHATTERED'); - // Respawn far away - this.mesh.position.set( - (Math.random() - 0.5) * 50, - 0, - (Math.random() - 0.5) * 50 - ); - this.state = 'STALK'; - } - } -} diff --git a/src/Player.js b/src/Player.js index 60f3f3c..1e947bd 100644 --- a/src/Player.js +++ b/src/Player.js @@ -4,10 +4,13 @@ import { PointerLockControls } from 'three/examples/jsm/controls/PointerLockCont // but we'll stick to this for now as it's standard examples path. export class Player { - constructor(camera, colliders) { + constructor(camera, colliders, rechargeStations = [], colliderBoxes = [], collectableFlares = []) { this.camera = camera; this.camera.rotation.order = 'YXZ'; // Standard FPS rotation order to prevent gimbal lock this.colliders = colliders; + this.rechargeStations = rechargeStations; // NEW: Track recharge stations + this.colliderBoxes = colliderBoxes; // NEW: Pre-calculated bounding boxes for optimization + this.collectableFlares = collectableFlares; // NEW: Track flare pickups // Player stats this.speed = 3.0; // Slower "Horror" walk speed @@ -34,12 +37,20 @@ export class Player { this.direction = new THREE.Vector3(); this.flashlightOn = true; // Started as ON this.battery = 100.0; + this.flares = 3; // NEW: Start with 3 flares + this.maxFlares = 5; // NEW: Maximum flares this.stamina = 100.0; this.isSprinting = false; + this.baseIntensity = 3.0; // NEW: Track desired brightness (defaults to 3.0) this.baseDrain = 0.5; // Drain per second at base intensity + // NEW: Hiding State + this.isHiding = false; + this.hidingSpot = null; // Store reference to locker + this.storedPos = new THREE.Vector3(); // Store position before hiding + // Animation - this.headBobTimer = 0; + this.bobTime = 0; this.lastStepTime = 0; // Audio @@ -53,9 +64,13 @@ export class Player { this.setupFlashlight(); // Survival tools state - this.lastOverloadTime = 0; this.overloadCooldown = 5000; // 5 seconds this.flashLight = null; // Temporary point light for overload + + // NEW: Cache UI elements to avoid per-frame DOM lookup + this.uiStamina = document.getElementById('stamina-bar'); + this.uiBattery = document.getElementById('battery-level'); + this.uiFlares = document.getElementById('flare-count'); } setupFlashlight() { @@ -81,6 +96,7 @@ export class Player { bulb.position.z = -0.101; // Tip of body this.flashlightGroup.add(bulb); this.bulbMesh = bulb; // To toggle emission + this.bulbMesh.visible = this.flashlightOn; // Sync with initial state // Hand (Simple representation) const handGeo = new THREE.BoxGeometry(0.08, 0.08, 0.15); @@ -99,10 +115,10 @@ export class Player { this.flashlight.distance = 50; this.flashlight.position.set(0, 0, -0.1); // At tip - // Enable shadows + // Enable shadows (Performance optimized: reduced from 512) this.flashlight.castShadow = true; - this.flashlight.shadow.mapSize.width = 512; - this.flashlight.shadow.mapSize.height = 512; + this.flashlight.shadow.mapSize.width = 256; + this.flashlight.shadow.mapSize.height = 256; this.flashlight.shadow.camera.near = 0.5; this.flashlight.shadow.camera.far = 50; this.flashlight.shadow.bias = -0.001; @@ -135,8 +151,9 @@ export class Player { case 'KeyF': this.toggleFlashlight(); break; case 'KeyK': this.adjustDim = true; break; case 'KeyL': this.adjustBright = true; break; - case 'KeyR': this.handleOverload(); break; // Flashlight Overload + // case 'KeyR': Overload removed case 'KeyQ': this.handleFlare(); break; // Throw Flare + case 'KeyE': this.handleInteract(); break; // NEW: Interact case 'ShiftLeft': this.isSprinting = true; break; case 'ShiftRight': this.isSprinting = true; break; } @@ -215,8 +232,7 @@ export class Player { } // Update UI - const stamBar = document.getElementById('stamina-bar'); - if (stamBar) stamBar.style.width = this.stamina + '%'; + if (this.uiStamina) this.uiStamina.style.width = this.stamina + '%'; const accel = currentSpeed * 10.0; // Acceleration to reach max speed against friction (assuming friction 10) @@ -227,12 +243,12 @@ export class Player { this.controls.moveRight(-this.velocity.x * dt); this.controls.moveForward(-this.velocity.z * dt); - // Simple Collision: Push back + // Simple Collision: Push back (Optimized to use pre-calculated boxes) const playerPos = this.camera.position; const playerRadius = 0.5; - for (const collider of this.colliders) { - const box = new THREE.Box3().setFromObject(collider); + for (let i = 0; i < this.colliderBoxes.length; i++) { + const box = this.colliderBoxes[i]; if (playerPos.x > box.min.x - playerRadius && playerPos.x < box.max.x + playerRadius && playerPos.z > box.min.z - playerRadius && playerPos.z < box.max.z + playerRadius) { @@ -278,12 +294,11 @@ export class Player { this.battery = Math.max(0, this.battery - drain); // Update UI - const battEl = document.getElementById('battery-level'); - if (battEl) battEl.textContent = Math.floor(this.battery) + '%'; + if (this.uiBattery) this.uiBattery.textContent = Math.floor(this.battery) + '%'; if (this.battery <= 20) { - if (battEl) battEl.style.color = 'red'; + if (this.uiBattery) this.uiBattery.style.color = 'red'; } else { - if (battEl) battEl.style.color = 'white'; + if (this.uiBattery) this.uiBattery.style.color = 'white'; } // Die if empty @@ -295,31 +310,104 @@ export class Player { window.log('Battery depleted!'); } + // Handle manual adjustment // Handle manual adjustment if (this.adjustDim || this.adjustBright) { const speed = 2.0 * dt; const angleSpeed = 0.5 * dt; if (this.adjustDim) { - this.flashlight.intensity = Math.max(0, this.flashlight.intensity - speed * 10); + this.baseIntensity = Math.max(0, this.baseIntensity - speed * 10); this.flashlight.angle = Math.max(0.1, this.flashlight.angle - angleSpeed); } if (this.adjustBright) { - this.flashlight.intensity = Math.min(15, this.flashlight.intensity + speed * 10); + this.baseIntensity = Math.min(15, this.baseIntensity + speed * 10); this.flashlight.angle = Math.min(Math.PI / 2, this.flashlight.angle + angleSpeed); } + + // Update actual intensity immediately for responsiveness + this.flashlight.intensity = this.baseIntensity; + // Sync bulb light this.bulbLight.intensity = this.flashlight.intensity * 0.2; // Log occasionally for feedback - if (Math.random() < 0.1) window.log(`Light: Int=${this.flashlight.intensity.toFixed(1)} Ang=${this.flashlight.angle.toFixed(2)}`); + if (Math.random() < 0.1) window.log(`Light: Int=${this.baseIntensity.toFixed(1)} Ang=${this.flashlight.angle.toFixed(2)}`); } else { // Flicker logic const flicker = (Math.random() - 0.5) * 0.5; - this.flashlight.intensity += flicker; + // Fix: Reset to base + flicker instead of accumulating drift + this.flashlight.intensity = this.baseIntensity + flicker; this.bulbLight.intensity = Math.max(0, this.flashlight.intensity * 0.2); } } + + // NEW: Recharge Logic + let isRecharging = false; + // playerPos is already defined above in the update method + for (const station of this.rechargeStations) { + const dist = playerPos.distanceTo(station.position); + if (dist < 2.5) { // Interaction range + isRecharging = true; + this.battery = Math.min(100, this.battery + 15 * dt); // Recharge rate + + // Visual feedback on station + if (station.indicator) { + station.indicator.material.color.setHex(0xffff00); // Yellow while charging + station.light.color.setHex(0xffff00); + } + + if (Math.random() < 0.05) window.log('FLASHLIGHT_RECHARGING...'); + break; // Only recharge from one at a time + } else { + // Reset visual feedback if not charging + if (station.indicator) { + station.indicator.material.color.setHex(0x00ff00); // Green when ready + station.light.color.setHex(0x00ff00); + } + } + } + + // Auto-pickup logic logic removed in favor of manual interact (Key E) + + if (this.debugTimer > 1.0) { + this.debugTimer = 0; + window.log(`DEBUG: On=${this.flashlightOn} Batt=${this.battery.toFixed(1)} Base=${this.baseIntensity} Int=${this.flashlight.intensity.toFixed(2)}`); + } + + this.updateAudioListener(dt); + } + + updateAudioListener(dt) { + if (!this.ctx) return; + + // Optimize: throttle updates if needed, but per-frame is smoother + const pos = this.camera.position; + const dir = this.camera.getWorldDirection(new THREE.Vector3()); + + const listener = this.ctx.listener; + + // Using setTargetAtTime for smooth transition + const time = this.ctx.currentTime; + if (listener.positionX) { + listener.positionX.setTargetAtTime(pos.x, time, 0.1); + listener.positionY.setTargetAtTime(pos.y, time, 0.1); + listener.positionZ.setTargetAtTime(pos.z, time, 0.1); + } else { + // Legacy support + listener.setPosition(pos.x, pos.y, pos.z); + } + + if (listener.forwardX) { + listener.forwardX.setTargetAtTime(dir.x, time, 0.1); + listener.forwardY.setTargetAtTime(dir.y, time, 0.1); + listener.forwardZ.setTargetAtTime(dir.z, time, 0.1); + listener.upX.setTargetAtTime(0, time, 0.1); + listener.upY.setTargetAtTime(1, time, 0.1); + listener.upZ.setTargetAtTime(0, time, 0.1); + } else { + listener.setOrientation(dir.x, dir.y, dir.z, 0, 1, 0); + } } playFootstep(isRunning = false) { @@ -362,47 +450,17 @@ export class Player { // Ambience removed per user request } - handleOverload() { - if (!this.controls || this.lockLook) return; - if (!this.flashlightOn || this.battery < 25) { - window.log('NOT_ENOUGH_ENERGY_FOR_OVERLOAD'); - return; - } - - const now = Date.now(); - if (now - this.lastOverloadTime < this.overloadCooldown) { - window.log('OVERLOAD_COOLDOWN_ACTIVE'); - return; - } - - this.battery -= 25; - this.lastOverloadTime = now; - window.log('CRITICAL_SIGNAL_BURST_TRIGGERED'); - - // Create the flash - if (!this.flashLight) { - this.flashLight = new THREE.PointLight(0xffffff, 50, 15); - this.camera.add(this.flashLight); - } - this.flashLight.visible = true; - this.flashLight.intensity = 50; - - // Visual "blind" effect (could be expanded) - setTimeout(() => { - if (this.flashLight) { - this.flashLight.visible = false; - } - }, 500); - - // Emit event for monsters to react - const event = new CustomEvent('flashlightOverload', { - detail: { position: this.camera.position.clone() } - }); - window.dispatchEvent(event); - } - handleFlare() { if (!this.controls || this.lockLook) return; + + if (this.flares <= 0) { + window.log('OUT_OF_FLARES'); + return; + } + + this.flares--; + this.updateFlareUI(); + // This will be handled in Game.js by listening for an event const event = new CustomEvent('throwFlare', { detail: { @@ -412,4 +470,118 @@ export class Player { }); window.dispatchEvent(event); } + + updateFlareUI() { + if (this.uiFlares) this.uiFlares.textContent = this.flares; + } + + handleInteract() { + if (!this.controls || !this.camera) return; + + // 1. If Hiding, Exit Hiding + if (this.isHiding) { + this.exitHiding(); + return; + } + + // 2. Flare Pickup Logic (Legacy but kept) + // Check for flares first? Or prioritize hiding? Let's check distance. + const playerPos = this.camera.position; + let foundFlare = false; + + for (const pickup of this.collectableFlares) { + if (!pickup.active) continue; + if (playerPos.distanceTo(pickup.position) < 2.5) { + if (this.flares < this.maxFlares) { + this.flares++; + // this.world.removeFlarePickup(pickup); // Assuming access to world, but here we just hide it + pickup.active = false; + pickup.group.visible = false; + this.updateFlareUI(); + window.log('FLARE_COLLECTED'); + foundFlare = true; + } else { + window.log('INVENTORY_FULL'); + foundFlare = true; // Handled interaction + } + break; + } + } + if (foundFlare) return; + + // 3. Check for Hiding Spots (Raycast for Lockers) + // We use the camera direction + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(new THREE.Vector2(0, 0), this.camera); + + // Filter for objects that are "hideable" + // We need to check children of the scene... this is expensive if we check everything. + // Better: Check culledObjects from the World if possible, OR + // Since we don't have easy access to 'world.culledObjects' here directly without passing it, + // we can iterate the player's vicinity or use a simpler distance check if we had the list. + // Assuming we don't have the list easily, let's look at what the camera is pointing at. + // Actually, 'this.controls.getObject()' is the camera wrapper. + // The scene is not directly stored in Player usually, but passed in update? + // Wait, Player doesn't store Scene. + // FIX: We need to pass the list of hideable objects or the World to the Player. + // For now, let's assume we can access `game.world` or passed objects. + // BUT, we can just iterate `this.world.culledObjects` if we link it. + // In Game.js, `player.world = world` was not set explicitly but player is passed world objects. + + if (this.world && this.world.culledObjects) { + const interactables = this.world.culledObjects.filter(o => o.visible && o.userData.isHideable); + // Simple distance check first + for (const obj of interactables) { + if (playerPos.distanceTo(obj.position) < 2.0) { + this.enterHiding(obj); + return; + } + } + } + } + + enterHiding(obj) { + this.isHiding = true; + this.hidingSpot = obj; + this.storedPos = this.storedPos || new THREE.Vector3(); // Initialize if not exists + this.storedPos.copy(this.camera.position); + + // Move Player inside (approximate center of locker) + // Locker origin is at bottom center. Height 2.0. Center is y=1.0. + // We want eye level ~1.6 + this.camera.position.set(obj.position.x, 1.6, obj.position.z); + + // Turn off light + this.flashlightOn = false; + this.flashlight.visible = false; + if (this.bulbLight) this.bulbLight.visible = false; + + // UI Feedback + window.log("Entered Hiding Spot. Press E to exit."); + + // Vignette Effect (Pseudo via CSS) + document.body.style.boxShadow = "inset 0 0 150px 100px #000"; // Darken edges + } + + exitHiding() { + this.isHiding = false; + this.hidingSpot = null; + + // Restore position (slightly offset to avoid getting stuck) + // We push them forward relative to where they entered or just restore. + // Restoring `storedPos` is safest. + this.camera.position.copy(this.storedPos); + + // Remove Vignette + document.body.style.boxShadow = "none"; + + // Auto-restore flashlight if battery valid + if (this.battery > 0) { + this.flashlightOn = true; + this.flashlight.visible = true; + if (this.bulbLight) this.bulbLight.visible = true; + } + + window.log("Exited Hiding."); + } } diff --git a/src/World.js b/src/World.js index c8fc0b1..d45ba3d 100644 --- a/src/World.js +++ b/src/World.js @@ -4,11 +4,32 @@ export class World { constructor(scene) { this.scene = scene; this.colliders = []; - this.staticObstacles = []; // Track pillars etc for spawning + this.staticObstacles = []; this.dustParticles = null; - this.safeZones = []; // [{ position: Vector3, radius: number }] + this.safeZones = []; + this.rechargeStations = []; + this.colliderBoxes = []; + + this.texWood = this.createProceduralTexture('wood'); + this.texFabric = this.createProceduralTexture('fabric'); + + // NEW: Power System variables removed + this.ambientLight = null; + + // NEW: Collectable Flares + this.collectableFlares = []; + + // NEW: Culling System for Performance + this.culledObjects = []; // { mesh: Object3D, active: bool } + this.cullDistance = 25; // Hide things beyond 25 units + + // Material Palette (Optimization) + this.materials = {}; } + + + load() { // Generate Textures this.texConcrete = this.createProceduralTexture('concrete'); @@ -16,67 +37,95 @@ export class World { this.texWood = this.createProceduralTexture('wood'); this.texFabric = this.createProceduralTexture('fabric'); - // Standard lighting for horror - const ambientLight = new THREE.AmbientLight(0x404040, 0.2); // Very dim ambient - this.scene.add(ambientLight); + // Initialize Shared Materials + this.materials.concrete = new THREE.MeshStandardMaterial({ map: this.texConcrete, roughness: 0.9, metalness: 0.1 }); + this.materials.wall = new THREE.MeshStandardMaterial({ map: this.texWall, roughness: 0.95 }); + this.materials.wood = new THREE.MeshStandardMaterial({ map: this.texWood, roughness: 0.9, color: 0x4a3c31 }); + this.materials.woodDark = new THREE.MeshStandardMaterial({ map: this.texWood, roughness: 1.0, color: 0x2a1a0a }); + this.materials.fabric = new THREE.MeshStandardMaterial({ map: this.texFabric, roughness: 1.0, color: 0x4e342e }); + this.materials.fabricWorn = new THREE.MeshStandardMaterial({ map: this.texFabric, roughness: 1.0, color: 0x3a3028 }); + this.materials.metal = new THREE.MeshStandardMaterial({ color: 0x4a5a6a, roughness: 0.7, metalness: 0.6 }); + this.materials.metalRust = new THREE.MeshStandardMaterial({ color: 0x5a4535, roughness: 0.9, metalness: 0.5 }); + this.materials.black = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 1.0 }); + this.materials.glass = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.1, metalness: 0.8 }); - // Floor (Dirty Concrete) - const floorGeo = new THREE.PlaneGeometry(60, 60); + // Tiling + this.materials.concrete.map.repeat.set(10, 10); + this.materials.wall.map.repeat.set(2, 2); + + // Standard lighting for horror + this.ambientLight = new THREE.AmbientLight(0x404040, 0.2); // Very dim ambient + this.scene.add(this.ambientLight); + + // NEW: Distance Fog to hide culling + // Color 0x000000 (Black), Density 0.04 (Fades out by ~25-30 units) + this.scene.fog = new THREE.FogExp2(0x000000, 0.05); + + const worldSize = 60; + + // Floor + const floorGeo = new THREE.PlaneGeometry(worldSize, worldSize); const floorMat = new THREE.MeshStandardMaterial({ map: this.texConcrete, roughness: 0.9, metalness: 0.1 }); + floorMat.map.repeat.set(10, 10); const floor = new THREE.Mesh(floorGeo, floorMat); floor.rotation.x = -Math.PI / 2; floor.receiveShadow = true; this.scene.add(floor); - // Ceiling (Dirty Concrete, Darker) - const ceilingGeo = new THREE.PlaneGeometry(60, 60); + // Ceiling + const ceilingGeo = new THREE.PlaneGeometry(worldSize, worldSize); const ceilingMat = new THREE.MeshStandardMaterial({ map: this.texConcrete, roughness: 1.0, metalness: 0 }); + ceilingMat.map.repeat.set(10, 10); const ceiling = new THREE.Mesh(ceilingGeo, ceilingMat); ceiling.rotation.x = Math.PI / 2; - ceiling.position.y = 5; // Top of walls + ceiling.position.y = 5; ceiling.receiveShadow = true; this.scene.add(ceiling); - // Simple walls (Expanded to 60x60 with Moldy Plaster) - this.createWall(0, 2.5, -30, 60, 5); // Back - this.createWall(0, 2.5, 30, 60, 5); // Front - this.createWall(-30, 2.5, 0, 60, 5, true); // Left - this.createWall(30, 2.5, 0, 60, 5, true); // Right + // Walls + this.createWall(0, 2.5, -worldSize / 2, worldSize, 5); // North + this.createWall(0, 2.5, worldSize / 2, worldSize, 5); // South + this.createWall(-worldSize / 2, 2.5, 0, worldSize, 5, true); // West + this.createWall(worldSize / 2, 2.5, 0, worldSize, 5, true); // East - // Add some "horror" pillars (Spread out, Concrete) - for (let i = 0; i < 12; i++) { - const px = (Math.random() - 0.5) * 50; - const pz = (Math.random() - 0.5) * 50; - this.createPillar(px, 2.5, pz); + // Pillars + for (let i = 0; i < 10; i++) { + const x = (Math.random() - 0.5) * (worldSize - 5); + const z = (Math.random() - 0.5) * (worldSize - 5); + this.createPillar(x, 2.5, z); } - // Add a red object to find - const target = new THREE.Mesh( - new THREE.BoxGeometry(1, 1, 1), - new THREE.MeshStandardMaterial({ color: 0x880000 }) - ); - target.position.set(5, 0.5, -5); - this.scene.add(target); - this.colliders.push(target); + // Furniture + this.createFurniture(30); + + // Safe Zones + this.spawnSafeZone(10, 10); + this.spawnSafeZone(-15, -20); + this.spawnSafeZone(20, -10); + + // NEW: Spawn randomized flares + for (let i = 0; i < 8; i++) { + const x = (Math.random() - 0.5) * 50; + const z = (Math.random() - 0.5) * 50; + this.spawnFlarePickup(x, z); + } - this.createFurniture(45); this.createDust(); - // Spawn Safe Zones (Fixed points for balance) - this.spawnSafeZone(12, 12); - this.spawnSafeZone(-12, -12); - this.spawnSafeZone(15, -15); - this.spawnSafeZone(-15, 15); + window.log('WORLD_INITIALIZED: Static generation active'); } + + + createProceduralTexture(type) { const size = 512; const canvas = document.createElement('canvas'); @@ -148,12 +197,15 @@ export class World { createWall(x, y, z, width, height, rotate = false) { const geo = new THREE.BoxGeometry(width, height, 0.5); - const mat = new THREE.MeshStandardMaterial({ - map: this.texWall, - roughness: 0.95 - }); - // Tile the texture - mat.map.repeat.set(width / 5, height / 5); + const mat = this.materials.wall; + // Tile the texture - handled in init for the base material, but if we need unique tiling per wall size... + // We can't easily change repeat per mesh if they share material. + // Ideal: Use World-Space mapping or just Clone the material if different size. + // For optimization, we accept fixed tiling or clone. + // Let's clone for walls since there are only 4 of them. + const instMat = mat.clone(); + instMat.map = mat.map.clone(); // Clone texture wrapper to allow different repeat? No, texture data is shared. + instMat.map.repeat.set(width / 5, height / 5); const wall = new THREE.Mesh(geo, mat); wall.position.set(x, y, z); @@ -162,14 +214,16 @@ export class World { wall.receiveShadow = true; this.scene.add(wall); this.colliders.push(wall); + + // Fix: Add to colliderBoxes + wall.updateMatrixWorld(true); + const bbox = new THREE.Box3().setFromObject(wall); + this.colliderBoxes.push(bbox); } createPillar(x, y, z) { const geo = new THREE.BoxGeometry(1, 5, 1); - const mat = new THREE.MeshStandardMaterial({ - map: this.texConcrete, // Reuse concrete for pillars - roughness: 0.9 - }); + const mat = this.materials.concrete; const pillar = new THREE.Mesh(geo, mat); pillar.position.set(x, y, z); pillar.castShadow = true; @@ -177,6 +231,20 @@ export class World { this.scene.add(pillar); this.colliders.push(pillar); this.staticObstacles.push(pillar); // Add to spawn blocker list + + // Fix: Add to colliderBoxes + pillar.updateMatrixWorld(true); + const bbox = new THREE.Box3().setFromObject(pillar); + this.colliderBoxes.push(bbox); + + // Add to culler + this.culledObjects.push(pillar); + + // Mount an emergency red floodlight on the pillar + // Performance: Only 50% of pillars have active lights + if (Math.random() < 0.5) { + this.spawnRedFloodlight(x, 3.5, z + 0.51, 0, pillar); + } } createFurniture(count) { @@ -224,7 +292,7 @@ export class World { if (!valid) continue; // Skip if no spot found - const type = Math.floor(Math.random() * 15); // 15 furniture types + const type = Math.floor(Math.random() * 16); // 16 furniture types const rot = Math.random() * Math.PI * 2; const group = new THREE.Group(); @@ -232,6 +300,9 @@ export class World { group.rotation.y = rot; this.scene.add(group); + // Add to culler + this.culledObjects.push(group); + if (type === 0) this.spawnCouch(group); else if (type === 1) this.spawnTable(group); else if (type === 2) this.spawnChair(group); @@ -247,14 +318,46 @@ export class World { else if (type === 12) this.spawnWornArmchair(group); else if (type === 13) this.spawnRustyWorkbench(group); else if (type === 14) this.spawnBrokenMirror(group); + else if (type === 15) this.spawnLocker(group); } } + spawnLocker(group) { + this.addColliderBox(group, 0.9, 2.1, 0.9); + + // Critical: Mark as hideable for Player interaction + group.userData.isHideable = true; + + // Shared Metal Material + const mat = this.materials.metal; + + // Main Body + const body = new THREE.Mesh(new THREE.BoxGeometry(0.8, 2.0, 0.8), mat); + body.position.y = 1.0; + body.castShadow = true; body.receiveShadow = true; + group.add(body); + + // Slits / Vents + const ventGeo = new THREE.BoxGeometry(0.6, 0.05, 0.05); + const ventMat = this.materials.black; + + for (let i = 0; i < 3; i++) { + const vent = new THREE.Mesh(ventGeo, ventMat); + vent.position.set(0, 1.6 + (i * 0.1), 0.41); + group.add(vent); + } + + // Handle + const handle = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.15, 0.05), this.materials.metalRust); + handle.position.set(0.3, 1.0, 0.42); + group.add(handle); + } + spawnBed(group) { this.addColliderBox(group, 1.2, 0.6, 2.0); - const frameMat = new THREE.MeshStandardMaterial({ color: 0x3e2723, roughness: 0.9, metalness: 0.4 }); - const mattressMat = new THREE.MeshStandardMaterial({ map: this.texFabric, color: 0x888877, roughness: 1.0 }); + const frameMat = this.materials.woodDark; + const mattressMat = this.materials.fabricWorn; const f1 = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.1, 2.0), frameMat); f1.position.y = 0.3; group.add(f1); const hbTop = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.05, 0.05), frameMat); hbTop.position.set(0, 0.9, -1.0); group.add(hbTop); @@ -267,7 +370,7 @@ export class World { spawnBookshelf(group) { this.addColliderBox(group, 1.1, 2.0, 0.5); - const mat = new THREE.MeshStandardMaterial({ map: this.texWood, color: 0x4a3c31, roughness: 0.9 }); + const mat = this.materials.wood; const left = new THREE.Mesh(new THREE.BoxGeometry(0.1, 2.0, 0.4), mat); left.position.set(-0.5, 1.0, 0); group.add(left); const right = new THREE.Mesh(new THREE.BoxGeometry(0.1, 2.0, 0.4), mat); right.position.set(0.5, 1.0, 0); group.add(right); @@ -277,7 +380,7 @@ export class World { if (Math.random() < 0.2) continue; const shelf = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.05, 0.38), mat); shelf.position.set(0, y, 0); shelf.rotation.z = (Math.random() - 0.5) * 0.2; group.add(shelf); if (Math.random() > 0.5) { - const book = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.25, 0.2), new THREE.MeshStandardMaterial({ color: Math.random() * 0xffffff })); + const book = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.25, 0.2), this.materials.fabric); book.position.set((Math.random() - 0.5) * 0.8, y + 0.15, 0); book.rotation.z = (Math.random() - 0.5) * 0.5; group.add(book); } } @@ -286,7 +389,7 @@ export class World { spawnDrawer(group) { this.addColliderBox(group, 0.9, 1.2, 0.5); - const mat = new THREE.MeshStandardMaterial({ map: this.texWood, color: 0x3d2b1f, roughness: 0.8 }); + const mat = this.materials.wood; const body = new THREE.Mesh(new THREE.BoxGeometry(0.9, 1.2, 0.5), mat); body.position.y = 0.6; group.add(body); @@ -298,6 +401,7 @@ export class World { group.add(d); }); } + addColliderBox(group, w, h, d) { // Create invisible hitbox: slightly smaller than visual to forgive AABB rotation errors const geo = new THREE.BoxGeometry(w * 0.85, h, d * 0.85); @@ -306,17 +410,18 @@ export class World { box.position.y = h / 2; group.add(box); this.colliders.push(box); + + // NEW: Pre-calculate bounding box for optimization + group.updateMatrixWorld(true); // Critical: Update transform before computing box + const bbox = new THREE.Box3().setFromObject(box); + this.colliderBoxes.push(bbox); } spawnCouch(group) { this.addColliderBox(group, 2.2, 1.0, 0.8); // Use worn fabric texture - const mat = new THREE.MeshStandardMaterial({ - map: this.texFabric, - roughness: 1.0, - color: 0x666666 - }); + const mat = this.materials.fabricWorn; // Base (Sagging) const base = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.45, 0.8), mat); @@ -353,11 +458,11 @@ export class World { spawnTable(group) { this.addColliderBox(group, 1.5, 0.9, 1.0); - const mat = new THREE.MeshStandardMaterial({ map: this.texWood, roughness: 0.8, metalness: 0.1 }); - + const mat = this.materials.wood; // Top const top = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.1, 1.0), mat); top.position.y = 0.8; + top.castShadow = true; top.receiveShadow = true; group.add(top); // Legs @@ -376,7 +481,7 @@ export class World { spawnChair(group) { this.addColliderBox(group, 0.5, 0.8, 0.5); // Simple box for chair - const mat = new THREE.MeshStandardMaterial({ map: this.texWood, roughness: 0.9 }); + const mat = this.materials.wood; // Seat const seat = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.08, 0.5), mat); @@ -406,12 +511,12 @@ export class World { spawnLamp(group) { this.addColliderBox(group, 0.1, 2.0, 0.1); - const mat = new THREE.MeshStandardMaterial({ color: 0x3e2723, roughness: 0.9, metalness: 0.3 }); + const mat = this.materials.metalRust; const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2.0, 8), mat); pole.position.y = 1.0; pole.rotation.z = 0.1; group.add(pole); // No collider - const shade = new THREE.Mesh(new THREE.ConeGeometry(0.4, 0.5, 16, 1, true), new THREE.MeshStandardMaterial({ color: 0xd7ccc8, side: THREE.DoubleSide, roughness: 1.0 })); + const shade = new THREE.Mesh(new THREE.ConeGeometry(0.4, 0.5, 16, 1, true), this.materials.fabric); shade.position.set(-0.1, 1.9, 0); shade.rotation.z = 0.3; group.add(shade); } @@ -419,22 +524,21 @@ export class World { spawnClock(group) { this.addColliderBox(group, 0.7, 2.2, 0.6); - const woodColor = 0x2a1a10; - const mat = new THREE.MeshStandardMaterial({ color: woodColor, roughness: 0.6 }); + const mat = this.materials.woodDark; const base = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.4, 0.6), mat); base.position.y = 0.2; group.add(base); const body = new THREE.Mesh(new THREE.BoxGeometry(0.5, 1.4, 0.45), mat); body.position.y = 1.1; group.add(body); const headBox = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.7, 0.5), mat); headBox.position.y = 2.15; group.add(headBox); const top = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.35, 0.2, 4), mat); top.rotation.y = Math.PI / 4; top.position.y = 2.6; group.add(top); - const face = new THREE.Mesh(new THREE.CircleGeometry(0.25, 32), new THREE.MeshBasicMaterial({ color: 0xeeddcc })); face.position.set(0, 2.15, 0.26); group.add(face); - const glass = new THREE.Mesh(new THREE.PlaneGeometry(0.3, 1.0), new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.1, metalness: 0.8 })); glass.position.set(0, 1.1, 0.23); group.add(glass); - const pendulum = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.1, 0.05, 16), new THREE.MeshStandardMaterial({ color: 0xae8b0c, metalness: 0.8, roughness: 0.3 })); pendulum.rotation.x = Math.PI / 2; pendulum.position.set(0, 0.9, 0.24); group.add(pendulum); - const rod = new THREE.Mesh(new THREE.BoxGeometry(0.02, 0.8, 0.02), new THREE.MeshStandardMaterial({ color: 0xae8b0c })); rod.position.set(0, 1.3, 0.24); group.add(rod); + const face = new THREE.Mesh(new THREE.CircleGeometry(0.25, 32), this.materials.fabric); face.position.set(0, 2.15, 0.26); group.add(face); + const glass = new THREE.Mesh(new THREE.PlaneGeometry(0.3, 1.0), this.materials.glass); glass.position.set(0, 1.1, 0.23); group.add(glass); + const pendulum = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.1, 0.05, 16), this.materials.metal); pendulum.rotation.x = Math.PI / 2; pendulum.position.set(0, 0.9, 0.24); group.add(pendulum); + const rod = new THREE.Mesh(new THREE.BoxGeometry(0.02, 0.8, 0.02), this.materials.metal); rod.position.set(0, 1.3, 0.24); group.add(rod); } spawnMannequin(group) { this.addColliderBox(group, 0.5, 1.9, 0.4); - const mat = new THREE.MeshStandardMaterial({ color: 0xd0c0b0, roughness: 0.4 }); + const mat = this.materials.fabric; const hips = new THREE.Mesh(new THREE.BoxGeometry(0.35, 0.2, 0.25), mat); hips.position.y = 0.95; group.add(hips); const legGeo = new THREE.CylinderGeometry(0.09, 0.07, 0.95, 8); const legL = new THREE.Mesh(legGeo, mat); legL.position.set(-0.12, 0.475, 0); group.add(legL); @@ -451,23 +555,23 @@ export class World { spawnTV(group) { this.addColliderBox(group, 0.8, 1.5, 0.6); - const standMat = new THREE.MeshStandardMaterial({ color: 0x4a3c31, roughness: 0.9 }); + const standMat = this.materials.wood; const stand = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.6, 0.5), standMat); stand.position.y = 0.3; group.add(stand); - const tvMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.5 }); + const tvMat = this.materials.black; const tv = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.55, 0.55), tvMat); tv.position.y = 0.875; group.add(tv); - const screenMat = new THREE.MeshStandardMaterial({ color: 0x111111, metalness: 0.6, roughness: 0.2 }); + const screenMat = this.materials.glass; const screen = new THREE.Mesh(new THREE.SphereGeometry(0.35, 32, 32, 0, Math.PI * 2, 0, 0.6), screenMat); screen.position.set(0, 0.9, 0.15); screen.scale.set(0.9, 0.7, 0.5); screen.rotation.x = -Math.PI / 2; group.add(screen); - const sidePanel = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.4, 0.05), new THREE.MeshStandardMaterial({ color: 0x333333 })); + const sidePanel = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.4, 0.05), this.materials.black); sidePanel.position.set(0.25, 0.875, 0.28); group.add(sidePanel); - const knob1 = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.05), new THREE.MeshStandardMaterial({ color: 0x888888 })); + const knob1 = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.05), this.materials.metal); knob1.rotation.x = Math.PI / 2; knob1.position.set(0.25, 0.95, 0.31); group.add(knob1); const knob2 = knob1.clone(); knob2.position.set(0.25, 0.8, 0.31); group.add(knob2); - const ant = new THREE.Mesh(new THREE.CylinderGeometry(0.005, 0.005, 0.6), new THREE.MeshStandardMaterial({ color: 0xaaaaaa })); + const ant = new THREE.Mesh(new THREE.CylinderGeometry(0.005, 0.005, 0.6), this.materials.metal); ant.position.set(-0.1, 1.3, 0); ant.rotation.z = 0.4; group.add(ant); const ant2 = ant.clone(); ant2.position.set(0.1, 1.3, 0); ant2.rotation.z = -0.4; group.add(ant2); } @@ -478,11 +582,7 @@ export class World { this.addColliderBox(group, 0.8, 0.6, 0.8); // Worn, splintered wood - const woodMat = new THREE.MeshStandardMaterial({ - map: this.texWood, - color: 0x2a1a0a, // Dark, aged - roughness: 1.0 - }); + const woodMat = this.materials.woodDark; // Main crate body (missing some planks) const plankGeo = new THREE.BoxGeometry(0.8, 0.08, 0.08); @@ -534,15 +634,8 @@ export class World { this.addColliderBox(group, 0.6, 1.0, 0.6); // Rusty metal and rotting wood - const metalMat = new THREE.MeshStandardMaterial({ - color: 0x4a3520, - roughness: 0.9, - metalness: 0.3 - }); - const stainMat = new THREE.MeshStandardMaterial({ - color: 0x1a1a10, - roughness: 1.0 - }); + const metalMat = this.materials.metalRust; + const stainMat = this.materials.black; // Main barrel body (cylinder) const bodyGeo = new THREE.CylinderGeometry(0.28, 0.25, 0.9, 12); @@ -552,11 +645,7 @@ export class World { group.add(body); // Metal bands (rusty) - const bandMat = new THREE.MeshStandardMaterial({ - color: 0x3a2a1a, - roughness: 0.7, - metalness: 0.6 - }); + const bandMat = this.materials.metal; [0.15, 0.5, 0.85].forEach(y => { if (Math.random() < 0.15) return; // Missing band const band = new THREE.Mesh(new THREE.TorusGeometry(0.27, 0.02, 8, 16), bandMat); @@ -588,16 +677,9 @@ export class World { this.addColliderBox(group, 0.9, 1.0, 0.9); // Torn, faded fabric - const fabricMat = new THREE.MeshStandardMaterial({ - map: this.texFabric, - color: 0x3a3028, // Dirty brown-gray - roughness: 1.0 - }); - const woodMat = new THREE.MeshStandardMaterial({ - map: this.texWood, - color: 0x1f150a, - roughness: 0.9 - }); + // Torn, faded fabric + const fabricMat = this.materials.fabricWorn; + const woodMat = this.materials.woodDark; // Legs (wooden, one possibly broken) const legGeo = new THREE.BoxGeometry(0.08, 0.2, 0.08); @@ -646,16 +728,8 @@ export class World { this.addColliderBox(group, 1.5, 1.0, 0.7); // Rusted metal and old wood - const metalMat = new THREE.MeshStandardMaterial({ - color: 0x5a4535, - roughness: 0.8, - metalness: 0.5 - }); - const woodMat = new THREE.MeshStandardMaterial({ - map: this.texWood, - color: 0x2a1a0a, - roughness: 0.95 - }); + const metalMat = this.materials.metalRust; + const woodMat = this.materials.wood; // Metal frame legs const legGeo = new THREE.BoxGeometry(0.08, 0.8, 0.08); @@ -705,21 +779,8 @@ export class World { this.addColliderBox(group, 0.8, 1.5, 0.15); // Ornate but damaged frame - const frameMat = new THREE.MeshStandardMaterial({ - color: 0x3d2b1f, - roughness: 0.7, - metalness: 0.2 - }); - const mirrorMat = new THREE.MeshStandardMaterial({ - color: 0x111111, - roughness: 0.05, - metalness: 0.95 - }); - const crackMat = new THREE.MeshBasicMaterial({ - color: 0x222222 - }); + const frameMat = this.materials.wood; - // Frame const frameT = new THREE.Mesh(new THREE.BoxGeometry(0.85, 0.08, 0.1), frameMat); frameT.position.set(0, 1.4, 0); frameT.castShadow = true; frameT.receiveShadow = true; @@ -739,26 +800,12 @@ export class World { group.add(frameR); // Mirror surface (dark, tarnished) + const mirrorMat = this.materials.glass; const mirror = new THREE.Mesh(new THREE.BoxGeometry(0.7, 1.25, 0.02), mirrorMat); mirror.position.set(0, 0.75, 0.04); mirror.receiveShadow = true; group.add(mirror); - // Cracks (random lines) - for (let i = 0; i < 3 + Math.floor(Math.random() * 4); i++) { - const crack = new THREE.Mesh( - new THREE.BoxGeometry(0.01, 0.2 + Math.random() * 0.4, 0.005), - crackMat - ); - crack.position.set( - (Math.random() - 0.5) * 0.5, - 0.4 + Math.random() * 0.7, - 0.055 - ); - crack.rotation.z = (Math.random() - 0.5) * 1.5; - group.add(crack); - } - // Leaning against wall or fallen if (Math.random() < 0.4) { group.rotation.x = -0.3; @@ -770,8 +817,8 @@ export class World { createDust() { - // Create 800 dust particles (Reduced) - const count = 800; + // Create 200 dust particles (Performance optimization: reduced from 800) + const count = 200; const geometry = new THREE.BufferGeometry(); const positions = new Float32Array(count * 3); const colors = new Float32Array(count * 3); // For controlling opacity/brightness per particle @@ -833,6 +880,13 @@ export class World { light.position.set(x, 4.5, z); light.target.position.set(x, 0, z); light.castShadow = true; + + // NEW: Disable shadow auto-update for static lights (Boosts performance) + light.shadow.mapSize.width = 256; + light.shadow.mapSize.height = 256; + light.shadow.autoUpdate = false; + light.shadow.needsUpdate = true; + this.scene.add(light); this.scene.add(light.target); @@ -851,6 +905,51 @@ export class World { baseIntensity: 12.0, baseGlowIntensity: 4.0 }); + + // 4. Spawn Recharge Station + this.spawnRechargeStation(x, z); + } + + spawnRechargeStation(x, z) { + const group = new THREE.Group(); + group.position.set(x, 0, z + 2.5); // Slightly offset from center of safe zone + + // Visual: Industrial Terminal/Pod + const baseGeo = new THREE.BoxGeometry(0.6, 1.2, 0.4); + const baseMat = new THREE.MeshStandardMaterial({ color: 0x333333, metalness: 0.8, roughness: 0.2 }); + const base = new THREE.Mesh(baseGeo, baseMat); + base.position.y = 0.6; + group.add(base); + + // Screen/Interface + const screenGeo = new THREE.PlaneGeometry(0.4, 0.3); + const screenMat = new THREE.MeshBasicMaterial({ color: 0x002200 }); // Dark green screen + const screen = new THREE.Mesh(screenGeo, screenMat); + screen.position.set(0, 0.9, 0.21); + group.add(screen); + + // Glowing Indicator + const indicatorGeo = new THREE.SphereGeometry(0.05, 8, 8); + const indicatorMat = new THREE.MeshBasicMaterial({ color: 0x00ff00 }); // Green for "Ready" + const indicator = new THREE.Mesh(indicatorGeo, indicatorMat); + indicator.position.set(0, 1.1, 0.21); + group.add(indicator); + + const pointLight = new THREE.PointLight(0x00ff00, 1.0, 2); + pointLight.position.set(0, 1.1, 0.3); + group.add(pointLight); + + this.scene.add(group); + + // Add to colliders so player can't walk through it + this.addColliderBox(group, 0.6, 1.2, 0.4); + + // Store for interaction + this.rechargeStations.push({ + position: new THREE.Vector3(x, 0, z + 2.5), + indicator: indicator, + light: pointLight + }); } breakSafeZone(index) { @@ -874,7 +973,64 @@ export class World { } } + spawnRedFloodlight(x, y, z, rotationY) { + const group = new THREE.Group(); + group.position.set(x, y, z); + group.rotation.y = rotationY; + + // Visual: Industrial wall bracket and lamp + const bracketGeo = new THREE.BoxGeometry(0.1, 0.5, 0.4); + const bracketMat = new THREE.MeshStandardMaterial({ color: 0x333333 }); + const bracket = new THREE.Mesh(bracketGeo, bracketMat); + bracket.position.z = 0.2; + group.add(bracket); + + const lampGeo = new THREE.CylinderGeometry(0.1, 0.15, 0.2, 12); + const lampMat = new THREE.MeshStandardMaterial({ color: 0x660000, emissive: 0x330000 }); + const lamp = new THREE.Mesh(lampGeo, lampMat); + lamp.rotation.x = -Math.PI / 3; + lamp.position.set(0, 0, 0.4); + group.add(lamp); + + // Actual Light: Red Flood + const light = new THREE.SpotLight(0xff0000, 5.0, 30, Math.PI / 3, 0.5, 2); + light.position.set(0, 0, 0.4); + // Small target helper + const target = new THREE.Object3D(); + target.position.set(0, -10, 10); + group.add(target); + light.target = target; + group.add(light); + this.scene.add(group); + } + updateCulling(player) { + if (!player || !player.camera) return; + const pPos = player.camera.position; + + for (const obj of this.culledObjects) { + const distSq = pPos.distanceToSquared(obj.position); + const maxSq = this.cullDistance * this.cullDistance; + + // If visible toggle changes, update it + if (distSq > maxSq && obj.visible) { + obj.visible = false; + } else if (distSq <= maxSq && !obj.visible) { + obj.visible = true; + } + } + } + + // NEW: Shared static vectors to avoid per-frame allocation in update() + static _pPos = new THREE.Vector3(); + static _toPart = new THREE.Vector3(); + static _lightPos = new THREE.Vector3(); + static _lightDir = new THREE.Vector3(); + static _targetPos = new THREE.Vector3(); + update(dt, player) { + // Power update removed + this.updateCulling(player); // NEW: Run culling + if (!this.dustParticles) return; const positions = this.dustParticles.geometry.attributes.position.array; @@ -882,12 +1038,15 @@ export class World { const velocities = this.dustParticles.userData.velocities; // Flashlight info - let lightPos, lightDir, lightAngle, lightDist, isLightOn; + const lightPos = World._lightPos; + const lightDir = World._lightDir; + let lightAngle = 0; + let lightDist = 0; + let isLightOn = false; + if (player && player.flashlight) { // Get world position and direction of FLASHLIGHT - lightPos = new THREE.Vector3(); - lightDir = new THREE.Vector3(); - const targetPos = new THREE.Vector3(); + const targetPos = World._targetPos; player.flashlight.getWorldPosition(lightPos); player.flashlight.target.getWorldPosition(targetPos); @@ -900,7 +1059,10 @@ export class World { isLightOn = player.flashlightOn; } - const pPos = new THREE.Vector3(); // Temp vector + const pPos = World._pPos; + const toPart = World._toPart; + const lightDistSq = lightDist * lightDist; + const cosAngleThreshold = Math.cos(lightAngle); // NEW: Pre-calculate threshold for (let i = 0; i < velocities.length; i++) { const v = velocities[i]; @@ -919,20 +1081,22 @@ export class World { // 2. Lighting Check (Volumetric Beam) let brightness = 0; if (isLightOn) { - // Check distance + // Check distance squared (faster than distance) pPos.set(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]); - const dist = pPos.distanceTo(lightPos); + const distSq = pPos.distanceToSquared(lightPos); - if (dist < lightDist) { + if (distSq < lightDistSq) { // Vector from light to particle - const toPart = pPos.sub(lightPos).normalize(); - const angle = toPart.angleTo(lightDir); + toPart.subVectors(pPos, lightPos).normalize(); + const dot = toPart.dot(lightDir); // NEW: Use dot product for speed - // Strictly inside the cone with soft edges - if (angle < lightAngle) { + if (dot > cosAngleThreshold) { + const dist = Math.sqrt(distSq); // "Point of Light" Effect: // 1. Radial falloff: Brightest in center of beam, fades to edge - const radialFactor = 1.0 - (angle / lightAngle); + // We can approximate this with the dot product: + // dot is 1.0 at center, cosAngleThreshold at edge + const radialFactor = (dot - cosAngleThreshold) / (1.0 - cosAngleThreshold); // 2. Distance falloff: Fades with distance const distFactor = 1.0 - (dist / lightDist); @@ -953,4 +1117,36 @@ export class World { this.dustParticles.geometry.attributes.position.needsUpdate = true; this.dustParticles.geometry.attributes.color.needsUpdate = true; } + + spawnFlarePickup(x, z) { + const group = new THREE.Group(); + group.position.set(x, 0.1, z); + + // Visual: Red stick + const geo = new THREE.CylinderGeometry(0.05, 0.05, 0.4, 8); + geo.rotateX(Math.PI / 2); // Lay flat + const mat = new THREE.MeshStandardMaterial({ color: 0xff0000, emissive: 0x330000 }); + const mesh = new THREE.Mesh(geo, mat); + group.add(mesh); + + // Visual: Small glow + const light = new THREE.PointLight(0xff0000, 1, 2); + light.position.y = 0.2; + group.add(light); + + this.scene.add(group); + + this.collectableFlares.push({ + position: group.position, + group: group, + active: true + }); + } + + removeFlarePickup(pickup) { + pickup.active = false; + pickup.group.visible = false; + // Optionally remove from scene to save memory, but hiding is faster for now + this.scene.remove(pickup.group); + } } diff --git a/src/main.js b/src/main.js index aa850f5..d1812af 100644 --- a/src/main.js +++ b/src/main.js @@ -6,6 +6,12 @@ window.log = (msg) => { const span = document.createElement('div'); span.textContent = `> ${msg}`; logDiv.appendChild(span); + + // Performance: Cap log size + if (logDiv.children.length > 50) { + logDiv.removeChild(logDiv.firstChild); + } + logDiv.scrollTop = logDiv.scrollHeight; } }; diff --git a/style.css b/style.css index 8a84cf3..7aebb79 100644 --- a/style.css +++ b/style.css @@ -28,7 +28,8 @@ body { /* Let clicks pass through to canvas if needed, but start screen needs clicks */ } -#start-screen { +#start-screen, +#loading-screen { position: absolute; top: 50%; left: 50%; @@ -71,4 +72,67 @@ h1 { height: 100%; background-color: #fff; transition: width 0.1s linear; +} + +#heartbeat { + width: 60px; + height: 60px; + margin-top: 10px; +} + +.heart-icon { + fill: #ff3333; + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 0.8; + } + + 50% { + transform: scale(1.2); + opacity: 1; + } + + 100% { + transform: scale(1); + opacity: 0.8; + } +} + +#power-warning { + display: none; + color: red; + font-weight: bold; + font-size: 1.5rem; + position: absolute; + top: 20px; + left: 50%; + transform: translateX(-50%); + text-shadow: 0 0 10px red; + animation: flash 0.5s infinite alternate; +} + +@keyframes flash { + from { + opacity: 0.2; + } + + to { + opacity: 1; + } +} + +#crosshair { + position: absolute; + top: 50%; + left: 50%; + width: 4px; + height: 4px; + background-color: white; + transform: translate(-50%, -50%); + pointer-events: none; + z-index: 100; } \ No newline at end of file