diff --git a/index.html b/index.html index b8c59c9..b3e0cb0 100644 --- a/index.html +++ b/index.html @@ -19,10 +19,14 @@
+ diff --git a/src/Game.js b/src/Game.js index 0598244..550408d 100644 --- a/src/Game.js +++ b/src/Game.js @@ -1,12 +1,19 @@ 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'; 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); + // 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); this.isRunning = false; this.lastTime = 0; @@ -40,6 +47,8 @@ export class Game { if (this.isRunning) { this.player.update(dt); this.world.update(dt, this.player); + if (this.monster) this.monster.update(dt); + if (this.monster2) this.monster2.update(dt); } this.graphics.render(); diff --git a/src/Graphics.js b/src/Graphics.js index e1152a9..6073ad8 100644 --- a/src/Graphics.js +++ b/src/Graphics.js @@ -12,6 +12,8 @@ export class Graphics { // Real Screen Renderer 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.domElement.id = 'three-canvas'; // Append to the correct container, not body directly diff --git a/src/Monster.js b/src/Monster.js new file mode 100644 index 0000000..4c90ce2 --- /dev/null +++ b/src/Monster.js @@ -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); + } +} diff --git a/src/Monster2.js b/src/Monster2.js new file mode 100644 index 0000000..1032918 --- /dev/null +++ b/src/Monster2.js @@ -0,0 +1,330 @@ +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(); + } + + 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); + } +} diff --git a/src/Player.js b/src/Player.js index 9c380fb..24e991d 100644 --- a/src/Player.js +++ b/src/Player.js @@ -30,10 +30,10 @@ export class Player { this.adjustBright = false; // 'L' key this.velocity = new THREE.Vector3(); this.direction = new THREE.Vector3(); - this.direction = new THREE.Vector3(); this.flashlightOn = true; // Started as ON this.battery = 100.0; - this.battery = 100.0; + this.stamina = 100.0; + this.isSprinting = false; this.baseDrain = 0.5; // Drain per second at base intensity // Animation @@ -48,6 +48,7 @@ export class Player { this.headBobTimer = 0; this.baseDrain = 0.5; // Drain per second at base intensity + this.lockLook = false; // To disable controls during jumpscare this.setupInput(); this.setupFlashlight(); @@ -93,6 +94,15 @@ export class Player { this.flashlight.decay = 2.0; // Faster falloff this.flashlight.distance = 50; this.flashlight.position.set(0, 0, -0.1); // At tip + + // Enable shadows + this.flashlight.castShadow = true; + this.flashlight.shadow.mapSize.width = 512; + this.flashlight.shadow.mapSize.height = 512; + this.flashlight.shadow.camera.near = 0.5; + this.flashlight.shadow.camera.far = 50; + this.flashlight.shadow.bias = -0.001; + // Aim inward to crosshair (Converge) // Group is at (0.3, -0.25), so target needs to be (-0.3, 0.25) to hit center 0,0 relative to camera this.flashlight.target.position.set(-0.3, 0.25, -20); @@ -123,6 +133,8 @@ export class Player { case 'KeyF': this.toggleFlashlight(); break; case 'KeyK': this.adjustDim = true; break; case 'KeyL': this.adjustBright = true; break; + case 'ShiftLeft': this.isSprinting = true; break; + case 'ShiftRight': this.isSprinting = true; break; } }; @@ -134,6 +146,8 @@ export class Player { case 'KeyD': this.moveRight = false; break; case 'KeyK': this.adjustDim = false; break; case 'KeyL': this.adjustBright = false; break; + case 'ShiftLeft': this.isSprinting = false; break; + case 'ShiftRight': this.isSprinting = false; break; } }; @@ -179,7 +193,7 @@ export class Player { // window.log(`Controls Locked: ${this.controls.isLocked}`); } - if (!this.controls.isLocked) return; + if (!this.controls.isLocked || this.lockLook) return; // Friction-like dampening (simple decay) this.velocity.x -= this.velocity.x * 10.0 * dt; @@ -189,7 +203,25 @@ export class Player { this.direction.x = Number(this.moveRight) - Number(this.moveLeft); this.direction.normalize(); - const accel = this.speed * 10.0; // Acceleration to reach max speed against friction (assuming friction 10) + // Sprint Logic + let currentSpeed = this.speed; + if (this.isSprinting && (this.moveForward || this.moveBackward || this.moveLeft || this.moveRight)) { + if (this.stamina > 0) { + currentSpeed = this.speed * 2.0; + this.stamina = Math.max(0, this.stamina - 30 * dt); // Drain fast + } else { + currentSpeed = this.speed; // No stamina, no run + } + } else { + // Regen + this.stamina = Math.min(100, this.stamina + 15 * dt); + } + + // Update UI + const stamBar = document.getElementById('stamina-bar'); + if (stamBar) stamBar.style.width = this.stamina + '%'; + + const accel = currentSpeed * 10.0; // Acceleration to reach max speed against friction (assuming friction 10) if (this.moveForward || this.moveBackward) this.velocity.z -= this.direction.z * accel * dt; if (this.moveLeft || this.moveRight) this.velocity.x -= this.direction.x * accel * dt; @@ -227,11 +259,14 @@ export class Player { // Audio Only Trigger if (this.moveForward || this.moveBackward || this.moveLeft || this.moveRight) { - // Step every 0.6 seconds + // Determine if actually running (key held AND stamina available) + const isRunning = this.isSprinting && this.stamina > 0; + const interval = isRunning ? 0.35 : 0.6; // Faster steps when running + this.lastStepTime += dt; - if (this.lastStepTime > 0.6) { + if (this.lastStepTime > interval) { this.lastStepTime = 0; - this.playFootstep(); + this.playFootstep(isRunning); } } else { this.lastStepTime = 0.5; // Ready to step properly next time @@ -290,11 +325,10 @@ export class Player { } } - playFootstep() { + playFootstep(isRunning = false) { if (!this.ctx) return; const t = this.ctx.currentTime; - const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); const filter = this.ctx.createBiquadFilter(); @@ -310,10 +344,12 @@ export class Player { // Filter to make it sound dull (floor/carpet) filter.type = 'lowpass'; - filter.frequency.setValueAtTime(400, t); // Low muffled thud + // Running sounds heavier/sharper + filter.frequency.setValueAtTime(isRunning ? 600 : 400, t); // Envelope - gain.gain.setValueAtTime(0.3, t); + const vol = isRunning ? 0.6 : 0.3; + gain.gain.setValueAtTime(vol, t); gain.gain.exponentialRampToValueAtTime(0.01, t + 0.1); // Connect diff --git a/src/World.js b/src/World.js index 2479e85..c8fc0b1 100644 --- a/src/World.js +++ b/src/World.js @@ -6,6 +6,7 @@ export class World { this.colliders = []; this.staticObstacles = []; // Track pillars etc for spawning this.dustParticles = null; + this.safeZones = []; // [{ position: Vector3, radius: number }] } load() { @@ -28,6 +29,7 @@ export class World { }); const floor = new THREE.Mesh(floorGeo, floorMat); floor.rotation.x = -Math.PI / 2; + floor.receiveShadow = true; this.scene.add(floor); // Ceiling (Dirty Concrete, Darker) @@ -40,6 +42,7 @@ export class World { const ceiling = new THREE.Mesh(ceilingGeo, ceilingMat); ceiling.rotation.x = Math.PI / 2; ceiling.position.y = 5; // Top of walls + ceiling.receiveShadow = true; this.scene.add(ceiling); // Simple walls (Expanded to 60x60 with Moldy Plaster) @@ -66,6 +69,12 @@ export class World { 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); } createProceduralTexture(type) { @@ -149,6 +158,8 @@ export class World { const wall = new THREE.Mesh(geo, mat); wall.position.set(x, y, z); if (rotate) wall.rotation.y = Math.PI / 2; + wall.castShadow = true; + wall.receiveShadow = true; this.scene.add(wall); this.colliders.push(wall); } @@ -161,6 +172,8 @@ export class World { }); const pillar = new THREE.Mesh(geo, mat); pillar.position.set(x, y, z); + pillar.castShadow = true; + pillar.receiveShadow = true; this.scene.add(pillar); this.colliders.push(pillar); this.staticObstacles.push(pillar); // Add to spawn blocker list @@ -211,7 +224,7 @@ export class World { if (!valid) continue; // Skip if no spot found - const type = Math.floor(Math.random() * 10); // Increased variety to 10 types + const type = Math.floor(Math.random() * 15); // 15 furniture types const rot = Math.random() * Math.PI * 2; const group = new THREE.Group(); @@ -226,9 +239,14 @@ export class World { else if (type === 4) this.spawnClock(group); else if (type === 5) this.spawnMannequin(group); else if (type === 6) this.spawnTV(group); - else if (type === 7) this.spawnBed(group); // New - else if (type === 8) this.spawnBookshelf(group); // New - else if (type === 9) this.spawnDrawer(group); // New + else if (type === 7) this.spawnBed(group); + else if (type === 8) this.spawnBookshelf(group); + else if (type === 9) this.spawnDrawer(group); + else if (type === 10) this.spawnBrokenCrate(group); + else if (type === 11) this.spawnOldBarrel(group); + else if (type === 12) this.spawnWornArmchair(group); + else if (type === 13) this.spawnRustyWorkbench(group); + else if (type === 14) this.spawnBrokenMirror(group); } } @@ -454,6 +472,302 @@ export class World { const ant2 = ant.clone(); ant2.position.set(0.1, 1.3, 0); ant2.rotation.z = -0.4; group.add(ant2); } + // === NEW WORN FURNITURE === + + spawnBrokenCrate(group) { + 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 + }); + + // Main crate body (missing some planks) + const plankGeo = new THREE.BoxGeometry(0.8, 0.08, 0.08); + + // Bottom + const bottom = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.05, 0.75), woodMat); + bottom.position.y = 0.025; + bottom.castShadow = true; bottom.receiveShadow = true; + group.add(bottom); + + // Vertical corner posts (some broken) + const postGeo = new THREE.BoxGeometry(0.08, 0.5, 0.08); + const corners = [[-0.35, -0.35], [0.35, -0.35], [-0.35, 0.35], [0.35, 0.35]]; + corners.forEach((c, i) => { + if (Math.random() < 0.2) return; // Missing post + const h = 0.3 + Math.random() * 0.2; // Varying height (broken tops) + const post = new THREE.Mesh(new THREE.BoxGeometry(0.08, h, 0.08), woodMat); + post.position.set(c[0], h / 2, c[1]); + post.rotation.z = (Math.random() - 0.5) * 0.1; // Slight lean + post.castShadow = true; post.receiveShadow = true; + group.add(post); + }); + + // Horizontal slats (some missing) + for (let y = 0.15; y < 0.45; y += 0.15) { + [0, Math.PI / 2].forEach(rot => { + if (Math.random() < 0.3) return; // Missing slat + const slat = new THREE.Mesh(plankGeo, woodMat); + slat.position.set(0, y, rot === 0 ? 0.37 : 0); + slat.rotation.y = rot; + slat.rotation.z = (Math.random() - 0.5) * 0.15; // Warped + slat.castShadow = true; slat.receiveShadow = true; + group.add(slat); + }); + } + + // Optional: Fallen lid nearby + if (Math.random() < 0.4) { + const lid = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.05, 0.7), woodMat); + lid.position.set(0.5, 0.03, 0.3); + lid.rotation.y = Math.random() * Math.PI; + lid.rotation.x = 0.1; + lid.castShadow = true; lid.receiveShadow = true; + group.add(lid); + } + } + + spawnOldBarrel(group) { + 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 + }); + + // Main barrel body (cylinder) + const bodyGeo = new THREE.CylinderGeometry(0.28, 0.25, 0.9, 12); + const body = new THREE.Mesh(bodyGeo, metalMat); + body.position.y = 0.45; + body.castShadow = true; body.receiveShadow = true; + group.add(body); + + // Metal bands (rusty) + const bandMat = new THREE.MeshStandardMaterial({ + color: 0x3a2a1a, + roughness: 0.7, + metalness: 0.6 + }); + [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); + band.position.y = y; + band.rotation.x = Math.PI / 2; + band.castShadow = true; + group.add(band); + }); + + // Dent/damage (random scale distortion) + body.scale.x = 0.9 + Math.random() * 0.2; + body.scale.z = 0.9 + Math.random() * 0.2; + + // Optional: Tipped over + if (Math.random() < 0.3) { + group.rotation.x = Math.PI / 2 - 0.2; + group.position.y = 0.25; + } + + // Stain on floor + const stain = new THREE.Mesh(new THREE.CircleGeometry(0.4, 16), stainMat); + stain.rotation.x = -Math.PI / 2; + stain.position.y = 0.01; + stain.position.z = 0.3; + group.add(stain); + } + + spawnWornArmchair(group) { + 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 + }); + + // Legs (wooden, one possibly broken) + const legGeo = new THREE.BoxGeometry(0.08, 0.2, 0.08); + [[-0.35, -0.35], [0.35, -0.35], [-0.35, 0.35], [0.35, 0.35]].forEach((p, i) => { + const h = i === 2 && Math.random() < 0.3 ? 0.1 : 0.2; // One short leg + const leg = new THREE.Mesh(new THREE.BoxGeometry(0.08, h, 0.08), woodMat); + leg.position.set(p[0], h / 2, p[1]); + leg.castShadow = true; leg.receiveShadow = true; + group.add(leg); + }); + + // Seat cushion (sagging) + const seat = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.15, 0.7), fabricMat); + seat.position.set(0, 0.27, 0.05); + seat.scale.y = 0.7; // Compressed/worn + seat.castShadow = true; seat.receiveShadow = true; + group.add(seat); + + // Back (tilted, torn) + const back = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.6, 0.12), fabricMat); + back.position.set(0, 0.6, -0.35); + back.rotation.x = 0.15; // Leaning back + back.castShadow = true; back.receiveShadow = true; + group.add(back); + + // Armrests (one possibly missing) + const armGeo = new THREE.BoxGeometry(0.1, 0.15, 0.6); + if (Math.random() > 0.2) { + const armL = new THREE.Mesh(armGeo, fabricMat); + armL.position.set(-0.4, 0.42, 0); + armL.castShadow = true; armL.receiveShadow = true; + group.add(armL); + } + if (Math.random() > 0.2) { + const armR = new THREE.Mesh(armGeo, fabricMat); + armR.position.set(0.4, 0.42, 0); + armR.castShadow = true; armR.receiveShadow = true; + group.add(armR); + } + + // Random tilt (unstable) + group.rotation.z = (Math.random() - 0.5) * 0.08; + } + + spawnRustyWorkbench(group) { + 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 + }); + + // Metal frame legs + const legGeo = new THREE.BoxGeometry(0.08, 0.8, 0.08); + [[-0.65, -0.25], [0.65, -0.25], [-0.65, 0.25], [0.65, 0.25]].forEach(p => { + const leg = new THREE.Mesh(legGeo, metalMat); + leg.position.set(p[0], 0.4, p[1]); + leg.castShadow = true; leg.receiveShadow = true; + group.add(leg); + }); + + // Cross braces + const braceGeo = new THREE.BoxGeometry(1.3, 0.04, 0.04); + const brace1 = new THREE.Mesh(braceGeo, metalMat); + brace1.position.set(0, 0.15, -0.25); + brace1.castShadow = true; + group.add(brace1); + const brace2 = brace1.clone(); + brace2.position.z = 0.25; + group.add(brace2); + + // Wooden top (warped, stained) + const top = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.08, 0.65), woodMat); + top.position.y = 0.84; + top.rotation.z = (Math.random() - 0.5) * 0.03; // Slight warp + top.castShadow = true; top.receiveShadow = true; + group.add(top); + + // Random tools/debris on top + if (Math.random() < 0.5) { + const tool = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.05, 0.08), metalMat); + tool.position.set((Math.random() - 0.5) * 0.8, 0.91, (Math.random() - 0.5) * 0.3); + tool.rotation.y = Math.random() * Math.PI; + tool.castShadow = true; + group.add(tool); + } + + // Vise or clamp + if (Math.random() < 0.4) { + const vise = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.12, 0.2), metalMat); + vise.position.set(0.6, 0.94, 0); + vise.castShadow = true; + group.add(vise); + } + } + + spawnBrokenMirror(group) { + 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 + }); + + // 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; + group.add(frameT); + + const frameB = frameT.clone(); + frameB.position.y = 0.1; + group.add(frameB); + + const frameL = new THREE.Mesh(new THREE.BoxGeometry(0.08, 1.35, 0.1), frameMat); + frameL.position.set(-0.38, 0.75, 0); + frameL.castShadow = true; frameL.receiveShadow = true; + group.add(frameL); + + const frameR = frameL.clone(); + frameR.position.x = 0.38; + group.add(frameR); + + // Mirror surface (dark, tarnished) + 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; + group.position.y = 0.1; + } else { + group.rotation.x = -0.1; // Slight lean + } + } + createDust() { // Create 800 dust particles (Reduced) @@ -497,6 +811,69 @@ export class World { } + spawnSafeZone(x, z) { + const radius = 3.5; + const color = 0xffccaa; // Warm safety light + + // 1. Visual Lamp Fixture + const fixtureGeo = new THREE.CylinderGeometry(0.2, 0.1, 0.4, 8); + const fixtureMat = new THREE.MeshStandardMaterial({ color: 0x222222 }); + const fixture = new THREE.Mesh(fixtureGeo, fixtureMat); + fixture.position.set(x, 4.8, z); + this.scene.add(fixture); + + const bulbGeo = new THREE.SphereGeometry(0.1, 8, 8); + const bulbMat = new THREE.MeshBasicMaterial({ color: color }); + const bulb = new THREE.Mesh(bulbGeo, bulbMat); + bulb.position.set(x, 4.6, z); + this.scene.add(bulb); + + // 2. The Safe Light + const light = new THREE.SpotLight(color, 12.0, 20, Math.PI / 3.5, 0.5, 1); // Increased from 4.0 to 12.0 + light.position.set(x, 4.5, z); + light.target.position.set(x, 0, z); + light.castShadow = true; + this.scene.add(light); + this.scene.add(light.target); + + // Subtle ground glow + const glow = new THREE.PointLight(color, 4.0, 8); // Increased from 1.0 to 4.0 + glow.position.set(x, 0.5, z); + this.scene.add(glow); + + // 3. Store for AI + this.safeZones.push({ + position: new THREE.Vector3(x, 0, z), + radius: radius, + active: true, + light: light, + glow: glow, + baseIntensity: 12.0, + baseGlowIntensity: 4.0 + }); + } + + breakSafeZone(index) { + if (!this.safeZones[index]) return; + const zone = this.safeZones[index]; + zone.active = false; + zone.light.visible = false; + zone.glow.visible = false; + window.log('WARNING: LOCAL_POWER_GRID_FAILURE - Safe Zone Lost'); + } + + glitchSafeZone(index, intensity) { + if (!this.safeZones[index] || !this.safeZones[index].active) return; + const zone = this.safeZones[index]; + if (Math.random() < intensity) { + zone.light.intensity = Math.random() * zone.baseIntensity; + zone.glow.intensity = Math.random() * zone.baseGlowIntensity; + } else { + zone.light.intensity = zone.baseIntensity; + zone.glow.intensity = zone.baseGlowIntensity; + } + } + update(dt, player) { if (!this.dustParticles) return; diff --git a/style.css b/style.css index 94570f5..b095bc0 100644 --- a/style.css +++ b/style.css @@ -54,4 +54,33 @@ h1 { bottom: 20px; left: 20px; font-size: 1.2rem; -} \ No newline at end of file + display: flex; + flex-direction: column; + gap: 10px; +} + +#stamina-container { + width: 200px; + height: 10px; + border: 2px solid #555; + background-color: rgba(0, 0, 0, 0.5); +} + +#stamina-bar { + width: 100%; + height: 100%; + background-color: #fff; + transition: width 0.1s linear; + + #test-dot { + position: absolute; + top: 50%; + left: 50%; + width: 12px; + height: 12px; + background: red; + border-radius: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + z-index: 9999; + } \ No newline at end of file