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:
@@ -19,10 +19,14 @@
|
||||
</div>
|
||||
<div id="hud" style="display: none;">
|
||||
<div id="battery">Battery: <span id="battery-level">100%</span></div>
|
||||
<div id="stamina-container">
|
||||
<div id="stamina-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="debug-log"
|
||||
style="position: absolute; top: 10px; left: 10px; z-index: 10000; color: lime; font-family: monospace; pointer-events: none; background: rgba(0,0,0,0.8); max-height: 50%; overflow-y: auto; font-size: 12px; padding: 10px; width: 300px; display: none;">
|
||||
</div>
|
||||
<div id="test-dot"></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
394
src/Monster.js
Normal file
394
src/Monster.js
Normal 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);
|
||||
}
|
||||
}
|
||||
330
src/Monster2.js
Normal file
330
src/Monster2.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
385
src/World.js
385
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;
|
||||
|
||||
|
||||
31
style.css
31
style.css
@@ -54,4 +54,33 @@ h1 {
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user