import * as THREE from 'three'; export class Monster { constructor(scene, player, colliders, audioCtx, safeZones) { this.scene = scene; this.player = player; this.colliders = colliders; this.audioCtx = audioCtx; this.safeZones = safeZones || []; this.mesh = new THREE.Group(); this.setupVisuals(); this.scene.add(this.mesh); // AI State 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.stunTimer = 0; this.position = new THREE.Vector3(15, 0, 15); this.mesh.position.copy(this.position); this.targetNode = new THREE.Vector3(); this.setNewPatrolTarget(); this.detectionRange = 15; // Increased from 12 this.catchRange = 1.5; this.fov = Math.PI / 1.5; // Wide view // Animation this.bobTimer = 0; this.lastUpdateTime = 0; // Audio initialization this.setupAudio(); } setupAudio() { if (!this.audioCtx) return; // Proximity/Breathing Gain this.breathGain = this.audioCtx.createGain(); this.breathGain.gain.value = 0; // Spatial Audio this.panner = this.audioCtx.createPanner(); this.panner.panningModel = 'HRTF'; this.panner.distanceModel = 'exponential'; this.panner.refDistance = 1; this.panner.maxDistance = 25; this.panner.rolloffFactor = 1.5; // Continuous Deep Heavy Breathing (Quiet/Steady) this.breathGain.connect(this.panner); this.panner.connect(this.audioCtx.destination); this.audioStarted = true; } setupVisuals() { // Procedural "Static/Void" Texture for retro look const size = 128; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#050505'; ctx.fillRect(0, 0, size, size); for (let i = 0; i < 500; i++) { ctx.fillStyle = Math.random() > 0.5 ? '#111111' : '#000000'; ctx.fillRect(Math.random() * size, Math.random() * size, 2, 2); } const tex = new THREE.CanvasTexture(canvas); tex.minFilter = THREE.NearestFilter; tex.magFilter = THREE.NearestFilter; const mat = new THREE.MeshStandardMaterial({ map: tex, roughness: 1, metalness: 0, color: 0x111111 }); // 1. Torso (Horizontal for crawling) const torsoGeo = new THREE.BoxGeometry(0.4, 0.3, 1.2); const torso = new THREE.Mesh(torsoGeo, mat); torso.position.y = 0.4; torso.castShadow = true; this.mesh.add(torso); // 2. Long spindly "Back" Legs const legGeo = new THREE.BoxGeometry(0.1, 0.8, 0.1); const legL = new THREE.Mesh(legGeo, mat); legL.position.set(-0.25, 0.4, -0.4); legL.rotation.x = 0.5; legL.castShadow = true; this.mesh.add(legL); this.legL = legL; const legR = new THREE.Mesh(legGeo, mat); legR.position.set(0.25, 0.4, -0.4); legR.rotation.x = 0.5; legR.castShadow = true; this.mesh.add(legR); this.legR = legR; // 3. Spindly "Front" Arms const armL = new THREE.Mesh(legGeo, mat); armL.position.set(-0.25, 0.4, 0.4); armL.rotation.x = -0.5; armL.castShadow = true; this.mesh.add(armL); this.armL = armL; const armR = new THREE.Mesh(legGeo, mat); armR.position.set(0.25, 0.4, 0.4); armR.rotation.x = -0.5; armR.castShadow = true; this.mesh.add(armR); this.armR = armR; // 4. Small, unsettling head (Front-mounted) const headGeo = new THREE.BoxGeometry(0.25, 0.25, 0.3); const head = new THREE.Mesh(headGeo, mat); head.position.set(0, 0.5, 0.7); head.castShadow = true; this.mesh.add(head); // Glowy small eyes const eyeGeo = new THREE.PlaneGeometry(0.04, 0.04); const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff0000 }); const eyeL = new THREE.Mesh(eyeGeo, eyeMat); eyeL.position.set(-0.06, 0.55, 0.86); const eyeR = new THREE.Mesh(eyeGeo, eyeMat); eyeR.position.set(0.06, 0.55, 0.86); this.mesh.add(eyeL); this.mesh.add(eyeR); } setNewPatrolTarget() { // Random point within basement bounds (-28 to 28) this.targetNode.set( (Math.random() - 0.5) * 50, 0, (Math.random() - 0.5) * 50 ); } 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; if (this.state === 'STUNNED') { this.stunTimer -= dt; // Retreat while stunned const retreatDir = new THREE.Vector3().subVectors(this.mesh.position, this.player.camera.position).normalize(); retreatDir.y = 0; this.mesh.position.add(retreatDir.multiplyScalar(this.patrolSpeed * dt)); if (this.stunTimer <= 0) { this.state = 'PATROL'; this.setNewPatrolTarget(); } this.updateAnimation(dt); this.updateAudio(dt); 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); // State Machine if (this.state === 'PATROL') { const distToTarget = monsterPos.distanceTo(this.targetNode); if (distToTarget < 1.0) { this.setNewPatrolTarget(); } // Move towards target const dir = new THREE.Vector3().subVectors(this.targetNode, monsterPos).normalize(); this.mesh.position.add(dir.multiplyScalar(this.patrolSpeed * dt)); // Rotation const targetRotation = Math.atan2(dir.x, dir.z); this.mesh.rotation.y += (targetRotation - this.mesh.rotation.y) * 2 * dt; // Detection Check if (distToPlayer < this.detectionRange && !isSafe) { // If player is flashlighting us, or we see them if (this.player.flashlightOn || distToPlayer < 5) { this.state = 'CHASE'; window.log('The entity has spotted you!'); } } } else if (this.state === 'CHASE') { // Stop chasing if player enters safe zone if (isSafe) { this.state = 'PATROL'; this.setNewPatrolTarget(); window.log('It cannot reach you in the light...'); return; } const dir = new THREE.Vector3().subVectors(playerPos, monsterPos).normalize(); this.mesh.position.add(dir.multiplyScalar(this.chaseSpeed * dt)); // Intensive Rotation const targetRotation = Math.atan2(dir.x, dir.z); this.mesh.rotation.y = targetRotation; // Lost player? if (distToPlayer > this.detectionRange * 1.5) { this.state = 'PATROL'; this.setNewPatrolTarget(); window.log('It lost your trail...'); } // Catch check if (distToPlayer < this.catchRange) { this.state = 'JUMPSCARE'; this.jumpscareTimer = 0; this.player.lockLook = true; // Lock player input } } else if (this.state === 'JUMPSCARE') { this.jumpscareTimer += dt; const camPos = this.player.camera.position.clone(); const monsterPos = this.mesh.position.clone(); // 1. Move/Lunge at camera const camDir = new THREE.Vector3(); this.player.camera.getWorldDirection(camDir); const jumpTarget = camPos.clone().add(camDir.multiplyScalar(0.2)); this.mesh.position.lerp(jumpTarget, 15 * dt); // 2. STARE: Force monster to look at camera this.mesh.lookAt(camPos); // 3. SHAKE: Intense high-frequency jitter/shiver const shakeIntensity = 0.15; this.mesh.position.x += (Math.random() - 0.5) * shakeIntensity; this.mesh.position.y += (Math.random() - 0.5) * shakeIntensity; this.mesh.position.z += (Math.random() - 0.5) * shakeIntensity; // Rotational shakes for a more "visceral/glitchy" feel this.mesh.rotation.x += (Math.random() - 0.5) * 0.4; this.mesh.rotation.y += (Math.random() - 0.5) * 0.4; this.mesh.rotation.z += (Math.random() - 0.5) * 0.4; // FORCE CAMERA TO LOOK AT MONSTER (Keep focused) this.player.camera.lookAt(this.mesh.position); if (this.jumpscareTimer > 0.8) { this.onCatchPlayer(); } } // Crawling scuttle animation this.bobTimer += dt * (this.state === 'CHASE' ? 12 : 6); const bob = Math.sin(this.bobTimer) * 0.05; this.mesh.position.y = bob; // Limb scuttle (opposite diagonal movement) const gait = Math.sin(this.bobTimer); const lastGait = this.prevGait || 0; this.prevGait = gait; this.armL.rotation.x = -0.5 + gait * 0.4; this.legR.rotation.x = 0.5 + gait * 0.4; this.armR.rotation.x = -0.5 - gait * 0.4; this.legL.rotation.x = 0.5 - gait * 0.4; // Trigger pattering sound at gait extremes if (Math.sign(gait) !== Math.sign(lastGait)) { this.playPatteringSound(distToPlayer); } // Slight twitching this.mesh.rotation.z = Math.sin(this.bobTimer * 2) * 0.05; // Update Audio this.updateAudio(dt, distToPlayer, monsterPos); } playPatteringSound(dist) { if (!this.audioCtx || dist > 20) return; const t = this.audioCtx.currentTime; // Light "patter" noise (Short high-frequency tap) - Increased Volume const g = this.audioCtx.createGain(); g.gain.setValueAtTime(0.25 * (1 - dist / 20), t); // Increased from 0.08 to 0.25 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); const filter = this.audioCtx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.value = 800; filter.Q.value = 1; osc.connect(filter); filter.connect(g); g.connect(this.panner); osc.start(t); osc.stop(t + 0.04); } updateAudio(dt, dist, monsterPos) { if (!this.audioCtx || !this.player || !this.audioStarted) return; // Update Panner Position 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(); } } 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); } onCatchPlayer() { if (this.isEnding) return; this.isEnding = true; window.log('FATAL ERROR: PLAYER_RECOVERY_FAILED'); // Blackout and reload without text 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(); }, 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'); } } }