422 lines
14 KiB
JavaScript
422 lines
14 KiB
JavaScript
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');
|
|
}
|
|
}
|
|
}
|