feat: Add player sprinting with stamina, flashlight and environmental shadows, and introduce new monster entities with expanded world furniture and safe zones.

This commit is contained in:
2026-01-03 11:07:34 +00:00
parent 94b6d7ac80
commit d588433d8a
8 changed files with 1197 additions and 16 deletions

394
src/Monster.js Normal file
View File

@@ -0,0 +1,394 @@
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
this.speed = 1.5;
this.chaseSpeed = 3.5;
this.position = new THREE.Vector3(15, 0, 15);
this.mesh.position.copy(this.position);
this.targetNode = new THREE.Vector3();
this.setNewPatrolTarget();
this.detectionRange = 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;
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.speed * 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);
}
}