Compare commits
10 Commits
31a4a5ee9b
...
cad48c2e1b
| Author | SHA1 | Date | |
|---|---|---|---|
| cad48c2e1b | |||
| a74ccd1f6d | |||
| d588433d8a | |||
| 94b6d7ac80 | |||
| 2eac31aae9 | |||
| 78c02169ef | |||
| cf4704c71b | |||
| eb5b086c13 | |||
| 6b9063bf34 | |||
| 220a3e5d6e |
76
index.html
76
index.html
@@ -6,67 +6,39 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Horror Game</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script>
|
||||
window.onerror = function (msg, url, lineNo, columnNo, error) {
|
||||
const logDiv = document.getElementById('debug-log');
|
||||
if (logDiv) {
|
||||
const err = document.createElement('div');
|
||||
err.style.color = 'red';
|
||||
err.style.fontWeight = 'bold';
|
||||
err.style.background = 'white';
|
||||
err.style.padding = '5px';
|
||||
err.style.margin = '5px 0';
|
||||
err.textContent = `CRITICAL ERROR: ${msg} [Line: ${lineNo}]`;
|
||||
logDiv.appendChild(err);
|
||||
logDiv.scrollTop = logDiv.scrollHeight;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
function checkWebGL() {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||
if (gl && gl instanceof WebGLRenderingContext) return "WebGL Supported";
|
||||
else return "WebGL NOT Supported";
|
||||
} catch (e) { return "WebGL Error: " + e.message; }
|
||||
}
|
||||
function test2D() {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 100;
|
||||
canvas.height = 100;
|
||||
canvas.style.position = 'fixed';
|
||||
canvas.style.bottom = '10px';
|
||||
canvas.style.right = '10px';
|
||||
canvas.style.zIndex = '10001';
|
||||
canvas.style.border = '2px solid white';
|
||||
document.body.appendChild(canvas);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = 'red';
|
||||
ctx.fillRect(0, 0, 100, 100);
|
||||
window.log("2D Canvas Test: Rendered Red Square");
|
||||
} catch (e) { window.log("2D Canvas Error: " + e.message); }
|
||||
}
|
||||
window.addEventListener('load', test2D);
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="game-container"></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: 80%; overflow-y: auto; font-size: 14px; padding: 10px; width: 300px;">
|
||||
</div>
|
||||
|
||||
<div id="ui-layer">
|
||||
<div id="start-screen">
|
||||
<h1>ECHOES</h1>
|
||||
<p>Click to Start</p>
|
||||
<p class="controls">WASD to Move | Mouse to Look | E to Interact</p>
|
||||
<div id="ui-container">
|
||||
<div id="loading-screen">
|
||||
<h1>INITIALIZING...</h1>
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
<div id="start-screen" style="display: none;">
|
||||
<h1>SUBJECT #096 CONTAINMENT</h1>
|
||||
<p>CLICK TO BEGIN</p>
|
||||
<p class="controls">WASD to Move | Mouse to Look | F Flashlight | P Debug</p>
|
||||
</div>
|
||||
<div id="hud" style="display: none;">
|
||||
<div id="battery">Battery: <span id="battery-level">100%</span></div>
|
||||
<div id="flares">Flares: <span id="flare-count">3</span></div>
|
||||
<div id="stamina-container">
|
||||
<div id="stamina-bar"></div>
|
||||
</div>
|
||||
<div id="heartbeat">
|
||||
<svg viewBox="0 0 100 100" class="heart-icon">
|
||||
<path
|
||||
d="M50 88.9L16.7 55.6C7.2 46.1 7.2 30.9 16.7 21.4s24.7-9.5 33.3 0C58.6 11.9 74.1 11.9 83.3 21.4s9.5 24.7 0 34.2L50 88.9z" />
|
||||
</svg>
|
||||
</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="crosshair"></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
|
||||
104
src/Flare.js
Normal file
104
src/Flare.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class Flare {
|
||||
// NEW: Shared assets
|
||||
static geometry = new THREE.CylinderGeometry(0.02, 0.02, 0.15, 8);
|
||||
static material = new THREE.MeshStandardMaterial({
|
||||
color: 0xff0000,
|
||||
emissive: 0xff0000,
|
||||
emissiveIntensity: 2.0
|
||||
});
|
||||
|
||||
// NEW: Temp vectors for GC reduction
|
||||
static tempVec = new THREE.Vector3();
|
||||
|
||||
constructor(scene, playerPos, direction, colliderBoxes) {
|
||||
this.scene = scene;
|
||||
this.colliderBoxes = colliderBoxes;
|
||||
|
||||
// Flare Mesh (Shared)
|
||||
this.mesh = new THREE.Mesh(Flare.geometry, Flare.material);
|
||||
this.mesh.rotation.x = Math.PI / 2;
|
||||
this.mesh.castShadow = true;
|
||||
|
||||
// Stats
|
||||
this.position = playerPos.clone().add(Flare.tempVec.copy(direction).multiplyScalar(0.5));
|
||||
this.mesh.position.copy(this.position);
|
||||
this.velocity = direction.clone().multiplyScalar(10.0);
|
||||
this.gravity = -9.8;
|
||||
this.friction = 0.98;
|
||||
this.bounce = 0.5;
|
||||
this.lifetime = 60.0; // 60 seconds
|
||||
this.active = true;
|
||||
this.isSleeping = false; // NEW: Physics sleep
|
||||
|
||||
// Light
|
||||
this.light = new THREE.PointLight(0xff3333, 5.0, 10);
|
||||
this.light.position.copy(this.position);
|
||||
|
||||
this.scene.add(this.mesh);
|
||||
this.scene.add(this.light);
|
||||
|
||||
// Safe zone properties for AI
|
||||
this.radius = 2.5;
|
||||
this.isFlare = true; // Identify as flare
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
if (!this.active || this.isSleeping) {
|
||||
if (this.active) {
|
||||
this.lifetime -= dt;
|
||||
if (this.lifetime <= 0) this.destroy();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.lifetime -= dt;
|
||||
if (this.lifetime <= 0) {
|
||||
this.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Physics
|
||||
this.velocity.y += this.gravity * dt;
|
||||
this.position.add(Flare.tempVec.copy(this.velocity).multiplyScalar(dt));
|
||||
|
||||
// Ground collision (Floor is at y=0)
|
||||
if (this.position.y < 0.05) {
|
||||
this.position.y = 0.05;
|
||||
this.velocity.y *= -this.bounce;
|
||||
this.velocity.x *= this.friction;
|
||||
this.velocity.z *= this.friction;
|
||||
|
||||
// Sleep check: if almost still on ground
|
||||
if (Math.abs(this.velocity.y) < 0.1 && Flare.tempVec.set(this.velocity.x, 0, this.velocity.z).length() < 0.1) {
|
||||
this.isSleeping = true;
|
||||
this.velocity.set(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Wall collisions (optimized)
|
||||
this.colliderBoxes.forEach(box => {
|
||||
if (box.containsPoint(this.position)) {
|
||||
// Bounce back
|
||||
this.velocity.multiplyScalar(-this.bounce);
|
||||
this.position.add(Flare.tempVec.copy(this.velocity).multiplyScalar(dt * 2));
|
||||
this.isSleeping = false; // Wake up on collision
|
||||
}
|
||||
});
|
||||
|
||||
this.mesh.position.copy(this.position);
|
||||
this.light.position.copy(this.position);
|
||||
|
||||
// Dim light near end
|
||||
if (this.lifetime < 5) {
|
||||
this.light.intensity = (this.lifetime / 5) * 5.0;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.active = false;
|
||||
this.scene.remove(this.mesh);
|
||||
this.scene.remove(this.light);
|
||||
}
|
||||
}
|
||||
145
src/Game.js
145
src/Game.js
@@ -1,60 +1,88 @@
|
||||
import { Graphics } from './Graphics.js';
|
||||
import { World } from './World.js';
|
||||
import { Player } from './Player.js';
|
||||
import * as THREE from 'three';
|
||||
import { Monster } from './Monster.js';
|
||||
import { SCP096 } from './SCP096.js';
|
||||
import { Flare } from './Flare.js';
|
||||
|
||||
export class Game {
|
||||
constructor() {
|
||||
window.log('Game constructor start');
|
||||
try {
|
||||
this.graphics = new Graphics();
|
||||
this.world = new World(this.graphics.scene);
|
||||
this.player = new Player(this.graphics.camera, this.world.colliders);
|
||||
window.log('All components created successfully');
|
||||
} catch (e) {
|
||||
window.log('CRITICAL ERROR during setup: ' + e.message);
|
||||
}
|
||||
this.player = new Player(this.graphics.camera, this.world.colliders, this.world.rechargeStations, this.world.colliderBoxes, this.world.collectableFlares);
|
||||
this.player.world = this.world; // New: Link world for interaction checks
|
||||
// Flashlight is already child of camera, do not reparent
|
||||
// Monster 1 (The Scuttler)
|
||||
this.monster = new Monster(this.world.scene, this.player, this.world.colliders, this.player.ctx, this.world.safeZones);
|
||||
|
||||
// SCP-096 (The Shy Guy)
|
||||
this.scp096 = new SCP096(this.world.scene, this.player, this.world.colliders, this.player.ctx);
|
||||
|
||||
|
||||
this.isRunning = false;
|
||||
this.lastTime = 0;
|
||||
this.setupUI();
|
||||
window.log('GAME_STATE: INITIALIZED');
|
||||
|
||||
this.activeFlares = [];
|
||||
this.combinedSafeZones = []; // NEW: Persistent array to avoid GC
|
||||
window.addEventListener('throwFlare', (e) => this.spawnFlare(e.detail.position, e.detail.direction));
|
||||
|
||||
this.heartElement = document.querySelector('.heart-icon');
|
||||
|
||||
// Start initialization immediately
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
// Yield to let UI render the loading screen first
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
window.log('GAME_STATE: LOADING_ASSETS');
|
||||
this.world.load();
|
||||
|
||||
this.graphics.scene.add(this.player.getObject());
|
||||
|
||||
// Pre-compile shaders
|
||||
window.log('GAME_STATE: COMPILING_SHADERS');
|
||||
try {
|
||||
this.graphics.renderer.compile(this.graphics.scene, this.graphics.camera);
|
||||
} catch (e) {
|
||||
console.warn("Shader compilation failed", e);
|
||||
}
|
||||
|
||||
// Ready
|
||||
this.onLoadComplete();
|
||||
}
|
||||
|
||||
onLoadComplete() {
|
||||
const loadingScreen = document.getElementById('loading-screen');
|
||||
const startScreen = document.getElementById('start-screen');
|
||||
|
||||
if (loadingScreen) loadingScreen.style.display = 'none';
|
||||
if (startScreen) startScreen.style.display = 'block';
|
||||
|
||||
window.log('GAME_STATE: READY');
|
||||
}
|
||||
|
||||
setupUI() {
|
||||
const startScreen = document.getElementById('start-screen');
|
||||
const hud = document.getElementById('hud');
|
||||
if (!startScreen) {
|
||||
window.log('ERROR: start-screen not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (startScreen) {
|
||||
startScreen.addEventListener('click', () => {
|
||||
window.log('Start screen clicked');
|
||||
if (this.player) {
|
||||
this.player.lockControls();
|
||||
}
|
||||
if (this.player) this.player.lockControls();
|
||||
startScreen.style.display = 'none';
|
||||
if (hud) hud.style.display = 'block';
|
||||
this.isRunning = true;
|
||||
window.log('Game isRunning = true');
|
||||
this.startLoop();
|
||||
window.log('GAME_STATE: RUNNING');
|
||||
});
|
||||
}
|
||||
|
||||
start() {
|
||||
window.log('Game.start() begin');
|
||||
try {
|
||||
if (this.world) this.world.load();
|
||||
if (this.player) {
|
||||
const playerObj = this.player.getObject();
|
||||
this.graphics.scene.add(playerObj);
|
||||
}
|
||||
window.log('World/Player loading complete');
|
||||
} catch (e) {
|
||||
window.log('ERROR in Game.start(): ' + e.message);
|
||||
}
|
||||
|
||||
startLoop() {
|
||||
requestAnimationFrame(this.loop.bind(this));
|
||||
window.log('Animation loop requested');
|
||||
}
|
||||
|
||||
loop(time) {
|
||||
@@ -63,9 +91,66 @@ export class Game {
|
||||
|
||||
if (this.isRunning) {
|
||||
this.player.update(dt);
|
||||
this.world.update(dt, this.player);
|
||||
|
||||
// Update Flares
|
||||
const flareCountBefore = this.activeFlares.length;
|
||||
this.activeFlares = this.activeFlares.filter(f => f.active);
|
||||
this.activeFlares.forEach(f => f.update(dt));
|
||||
|
||||
// Combined Safe Zones for Monsters (Optimized to avoid allocation)
|
||||
if (this.activeFlares.length !== flareCountBefore || this.combinedSafeZones.length === 0) {
|
||||
this.combinedSafeZones = [...this.world.safeZones, ...this.activeFlares];
|
||||
if (this.monster) this.monster.safeZones = this.combinedSafeZones;
|
||||
}
|
||||
|
||||
if (this.monster) {
|
||||
this.monster.update(dt);
|
||||
}
|
||||
if (this.scp096) {
|
||||
this.scp096.update(dt);
|
||||
}
|
||||
|
||||
// NEW: Heartbeat Monitor
|
||||
// Find closest monster distance
|
||||
let dist1 = 999;
|
||||
if (this.monster && this.monster.mesh) {
|
||||
dist1 = this.player.camera.position.distanceTo(this.monster.mesh.position);
|
||||
}
|
||||
|
||||
this.updateHeartbeat(dist1);
|
||||
}
|
||||
|
||||
this.graphics.render();
|
||||
requestAnimationFrame(this.loop.bind(this));
|
||||
}
|
||||
|
||||
spawnFlare(pos, dir) {
|
||||
if (!this.isRunning) return;
|
||||
const flare = new Flare(this.graphics.scene, pos, dir, this.world.colliderBoxes);
|
||||
this.activeFlares.push(flare);
|
||||
|
||||
// Update combined safe zones immediately so AI reacts
|
||||
this.combinedSafeZones = [...this.world.safeZones, ...this.activeFlares];
|
||||
if (this.monster) this.monster.safeZones = this.combinedSafeZones;
|
||||
|
||||
window.log('FLARE_DEPLOYED - Temporary Safe Zone Established');
|
||||
}
|
||||
|
||||
updateHeartbeat(distance) {
|
||||
if (!this.heartElement) return;
|
||||
|
||||
// Distance thresholds:
|
||||
// > 15m: Resting (1s pulse)
|
||||
// 10m - 15m: Alert (0.6s pulse)
|
||||
// 5m - 10m: Danger (0.3s pulse)
|
||||
// < 5m: Panic (0.15s pulse)
|
||||
|
||||
let duration = '1s';
|
||||
if (distance < 5) duration = '0.15s';
|
||||
else if (distance < 10) duration = '0.3s';
|
||||
else if (distance < 15) duration = '0.6s';
|
||||
|
||||
this.heartElement.style.animationDuration = duration;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,44 +4,24 @@ export class Graphics {
|
||||
constructor() {
|
||||
// Main scene rendering
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.fog = new THREE.Fog(0x111122, 2, 15);
|
||||
this.scene.background = new THREE.Color(0x111122); // Dark blue background instead of black
|
||||
// Fog removed here, handled by World
|
||||
this.scene.background = new THREE.Color(0x000000);
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 100);
|
||||
|
||||
// Real Screen Renderer - Force 400x300 for diagnostic visibility
|
||||
const w = 400;
|
||||
const h = 300;
|
||||
// Real Screen Renderer
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: false });
|
||||
this.renderer.setSize(w, h);
|
||||
this.renderer.setClearColor(0xff00ff);
|
||||
this.renderer.domElement.style.position = 'fixed';
|
||||
this.renderer.domElement.style.top = '50%';
|
||||
this.renderer.domElement.style.left = '50%';
|
||||
this.renderer.domElement.style.transform = 'translate(-50%, -50%)';
|
||||
this.renderer.domElement.style.border = '5px solid yellow';
|
||||
this.renderer.domElement.style.zIndex = '1000';
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
this.renderer.shadowMap.type = THREE.PCFShadowMap; // Optimized for performance
|
||||
this.renderer.domElement.id = 'three-canvas';
|
||||
|
||||
document.body.appendChild(this.renderer.domElement);
|
||||
window.log(`GL Vendor: ${this.renderer.getContext().getParameter(this.renderer.getContext().VENDOR)}`);
|
||||
window.log(`Canvas forced to ${w}x${h} (centered)`);
|
||||
|
||||
this.camera.position.set(5, 5, 5);
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
|
||||
// Add a guaranteed object
|
||||
const testCube = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(2, 2, 2),
|
||||
new THREE.MeshBasicMaterial({ color: 0x00ff00 })
|
||||
);
|
||||
this.scene.add(testCube);
|
||||
window.log('Green Cube added to scene');
|
||||
// Append to the correct container, not body directly
|
||||
const container = document.getElementById('game-container');
|
||||
if (container) container.appendChild(this.renderer.domElement);
|
||||
else document.body.appendChild(this.renderer.domElement);
|
||||
|
||||
// --- Retro Pipeline Setup ---
|
||||
|
||||
// 1. Off-screen Render Target (Small Resolution)
|
||||
// We render the 3D scene here first.
|
||||
this.targetWidth = 320;
|
||||
this.targetHeight = 240;
|
||||
|
||||
@@ -51,18 +31,15 @@ export class Graphics {
|
||||
format: THREE.RGBAFormat
|
||||
});
|
||||
|
||||
// 2. Post-Processing Quad
|
||||
// We render this quad to the screen, using the texture from the Render Target.
|
||||
this.postCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
this.postScene = new THREE.Scene();
|
||||
|
||||
const planeGeometry = new THREE.PlaneGeometry(2, 2);
|
||||
|
||||
// Custom Shader for Color Quantization
|
||||
const postMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
tDiffuse: { value: this.renderTarget.texture },
|
||||
colorDepth: { value: 16.0 } // 16 levels per channel (4-bit per channel - 4096 colors)
|
||||
colorDepth: { value: 24.0 } // 24 levels
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
@@ -77,7 +54,6 @@ export class Graphics {
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vec4 tex = texture2D(tDiffuse, vUv);
|
||||
// Quantize color and ensure it's not absolutely zeroed if there's light
|
||||
vec3 color = floor(tex.rgb * colorDepth + 0.5) / colorDepth;
|
||||
gl_FragColor = vec4(color, tex.a);
|
||||
}
|
||||
@@ -87,47 +63,22 @@ export class Graphics {
|
||||
this.postQuad = new THREE.Mesh(planeGeometry, postMaterial);
|
||||
this.postScene.add(this.postQuad);
|
||||
|
||||
// Input & Resize handling
|
||||
this.handleResize();
|
||||
window.addEventListener('resize', this.handleResize.bind(this));
|
||||
}
|
||||
|
||||
init() {
|
||||
// Standard init placeholder
|
||||
}
|
||||
init() { }
|
||||
|
||||
handleResize() {
|
||||
const aspect = window.innerWidth / window.innerHeight;
|
||||
|
||||
// Update 3D Camera
|
||||
this.camera.aspect = aspect;
|
||||
this.camera.updateProjectionMatrix();
|
||||
|
||||
// Update Screen Renderer
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
|
||||
// Update Render Target Size (Fixed low height, aspect-correct width? Or Fixed Width?)
|
||||
// Let's stick to fixed width 320 for that specific PSX feel, height auto.
|
||||
this.targetHeight = Math.floor(this.targetWidth / aspect);
|
||||
this.renderTarget.setSize(this.targetWidth, this.targetHeight);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.renderCount) this.renderCount = 0;
|
||||
this.renderCount++;
|
||||
|
||||
if (this.renderCount % 500 === 0) {
|
||||
window.log(`F:${this.renderCount} | Scene: ${this.scene.children.length} obj | Cam: ${this.camera.position.x.toFixed(1)},${this.camera.position.y.toFixed(1)},${this.camera.position.z.toFixed(1)}`);
|
||||
}
|
||||
|
||||
this.renderer.setClearColor(0xff00ff);
|
||||
this.renderer.clear();
|
||||
|
||||
// 1. Render 3D Scene directly
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
|
||||
/*
|
||||
// Original Post-Processing Pipeline
|
||||
// 1. Render 3D Scene to RenderTarget
|
||||
this.renderer.setRenderTarget(this.renderTarget);
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
@@ -135,6 +86,5 @@ export class Graphics {
|
||||
// 2. Render Post-Processing Quad to Screen
|
||||
this.renderer.setRenderTarget(null);
|
||||
this.renderer.render(this.postScene, this.postCamera);
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
399
src/Monster.js
Normal file
399
src/Monster.js
Normal file
@@ -0,0 +1,399 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class Monster {
|
||||
constructor(scene, player, colliders, audioCtx, safeZones) {
|
||||
this.scene = scene;
|
||||
this.player = player;
|
||||
this.colliders = colliders;
|
||||
this.audioCtx = audioCtx;
|
||||
this.safeZones = safeZones || [];
|
||||
|
||||
this.mesh = new THREE.Group();
|
||||
this.setupVisuals();
|
||||
this.scene.add(this.mesh);
|
||||
|
||||
// AI State
|
||||
this.state = 'PATROL'; // PATROL, CHASE, JUMPSCARE, STUNNED
|
||||
this.target = null;
|
||||
this.patrolSpeed = 1.5; // Renamed from 'speed'
|
||||
this.chaseSpeed = 3.2; // Reduced from 4.5
|
||||
this.stunTimer = 0;
|
||||
this.position = new THREE.Vector3(25, 0, 25); // Moved further from origin (0,0)
|
||||
this.mesh.position.copy(this.position);
|
||||
|
||||
this.targetNode = new THREE.Vector3();
|
||||
this.setNewPatrolTarget();
|
||||
|
||||
this.detectionRange = 15; // Increased from 12
|
||||
this.catchRange = 1.5;
|
||||
this.fov = Math.PI / 1.5; // Wide view
|
||||
|
||||
// Animation
|
||||
this.bobTimer = 0;
|
||||
this.lastUpdateTime = 0;
|
||||
|
||||
// Audio initialization
|
||||
this.setupAudio();
|
||||
|
||||
// Listen for player overload pulse
|
||||
// window.addEventListener('flashlightOverload', (e) => this.onOverload(e.detail.position));
|
||||
}
|
||||
|
||||
// NEW: Shared temp vectors for GC reduction
|
||||
static tempVec1 = new THREE.Vector3();
|
||||
static tempVec2 = new THREE.Vector3();
|
||||
static tempVec3 = new THREE.Vector3();
|
||||
|
||||
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 = Monster.tempVec1.copy(this.player.camera.position); // Reuse temp vector
|
||||
playerPos.y = 0;
|
||||
for (let i = 0; i < this.safeZones.length; i++) {
|
||||
const zone = this.safeZones[i];
|
||||
if (!zone.active) continue;
|
||||
const dist = playerPos.distanceTo(zone.position);
|
||||
if (dist < zone.radius) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
if (!this.player) return;
|
||||
|
||||
// NEW: Hiding Logic
|
||||
if (this.player.isHiding) {
|
||||
if (this.state === 'CHASE') {
|
||||
this.state = 'IDLE';
|
||||
// Reset patrol or do nothing
|
||||
window.log('The entity loses track of you...');
|
||||
}
|
||||
// Just animate IDLE or patrol randomly? Culling will hide it if far.
|
||||
// But we should stop chasing.
|
||||
// Move randomly?
|
||||
// For now, simply RETURN to stop all logic (freeze/idle)
|
||||
// OR finding a new random target would be better.
|
||||
if (this.state !== 'PATROL') {
|
||||
this.state = 'PATROL';
|
||||
this.setNewPatrolTarget();
|
||||
}
|
||||
// Allow basic patrol update
|
||||
}
|
||||
|
||||
if (this.state === 'STUNNED') {
|
||||
this.stunTimer -= dt;
|
||||
// Retreat while stunned
|
||||
const retreatDir = Monster.tempVec1.subVectors(this.mesh.position, this.player.camera.position).normalize();
|
||||
retreatDir.y = 0;
|
||||
this.mesh.position.add(retreatDir.multiplyScalar(this.patrolSpeed * dt));
|
||||
|
||||
if (this.stunTimer <= 0) {
|
||||
this.state = 'PATROL';
|
||||
this.setNewPatrolTarget();
|
||||
}
|
||||
this.updateAnimation(dt);
|
||||
this.updateAudio(dt);
|
||||
return;
|
||||
}
|
||||
|
||||
const isSafe = this.isPlayerSafe();
|
||||
const playerPos = Monster.tempVec1.copy(this.player.camera.position);
|
||||
playerPos.y = 0;
|
||||
const monsterPos = Monster.tempVec2.copy(this.mesh.position);
|
||||
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 = Monster.tempVec3.subVectors(this.targetNode, monsterPos).normalize();
|
||||
this.mesh.position.add(dir.multiplyScalar(this.patrolSpeed * dt));
|
||||
|
||||
// Rotation
|
||||
const targetRotation = Math.atan2(dir.x, dir.z);
|
||||
this.mesh.rotation.y += (targetRotation - this.mesh.rotation.y) * 2 * dt;
|
||||
|
||||
// Detection Check
|
||||
if (distToPlayer < this.detectionRange && !isSafe) {
|
||||
// If player is flashlighting us, or we see them
|
||||
if (this.player.flashlightOn || distToPlayer < 5) {
|
||||
this.state = 'CHASE';
|
||||
window.log('The entity has spotted you!');
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (this.state === 'CHASE') {
|
||||
// Stop chasing if player enters safe zone
|
||||
if (isSafe) {
|
||||
this.state = 'PATROL';
|
||||
this.setNewPatrolTarget();
|
||||
window.log('It cannot reach you in the light...');
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = Monster.tempVec3.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 = Monster.tempVec1.copy(this.player.camera.position);
|
||||
|
||||
// 1. Move/Lunge at camera
|
||||
const camDir = Monster.tempVec2;
|
||||
this.player.camera.getWorldDirection(camDir);
|
||||
const jumpTarget = Monster.tempVec3.copy(camPos).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;
|
||||
|
||||
// Optimized: Reduced complexity
|
||||
const g = this.audioCtx.createGain();
|
||||
g.gain.setValueAtTime(0.15 * (1 - dist / 20), t);
|
||||
g.gain.exponentialRampToValueAtTime(0.01, t + 0.05);
|
||||
|
||||
const osc = this.audioCtx.createOscillator();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(400, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(100, t + 0.05);
|
||||
|
||||
osc.connect(g);
|
||||
g.connect(this.panner);
|
||||
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.05);
|
||||
}
|
||||
|
||||
updateAudio(dt, dist, monsterPos) {
|
||||
if (!this.audioCtx || !this.player || !this.audioStarted) return;
|
||||
|
||||
// Update Panner Position Only (Player updates Listener)
|
||||
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);
|
||||
|
||||
// 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;
|
||||
// Removed deep breath allocation spam
|
||||
}
|
||||
|
||||
this.breathGain.gain.setTargetAtTime(targetBreath, this.audioCtx.currentTime, 0.1);
|
||||
}
|
||||
|
||||
playDeepBreath() {
|
||||
// Removed heavy buffer allocation logic
|
||||
}
|
||||
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
527
src/Player.js
527
src/Player.js
@@ -4,17 +4,23 @@ import { PointerLockControls } from 'three/examples/jsm/controls/PointerLockCont
|
||||
// but we'll stick to this for now as it's standard examples path.
|
||||
|
||||
export class Player {
|
||||
constructor(camera, colliders) {
|
||||
constructor(camera, colliders, rechargeStations = [], colliderBoxes = [], collectableFlares = []) {
|
||||
this.camera = camera;
|
||||
this.camera.rotation.order = 'YXZ'; // Standard FPS rotation order to prevent gimbal lock
|
||||
this.colliders = colliders;
|
||||
this.rechargeStations = rechargeStations; // NEW: Track recharge stations
|
||||
this.colliderBoxes = colliderBoxes; // NEW: Pre-calculated bounding boxes for optimization
|
||||
this.collectableFlares = collectableFlares; // NEW: Track flare pickups
|
||||
|
||||
// Player stats
|
||||
this.speed = 5.0;
|
||||
this.speed = 3.0; // Slower "Horror" walk speed
|
||||
this.height = 1.7; // Eyes height
|
||||
|
||||
// Init controls
|
||||
try {
|
||||
this.controls = new PointerLockControls(camera, document.body);
|
||||
this.controls.addEventListener('lock', () => window.log('POINTER_LOCKED - Input Active'));
|
||||
this.controls.addEventListener('unlock', () => window.log('POINTER_UNLOCKED - Input Passive'));
|
||||
window.log('PointerLockControls initialized');
|
||||
} catch (e) {
|
||||
window.log(`ERROR initializing controls: ${e.message}`);
|
||||
@@ -25,35 +31,131 @@ export class Player {
|
||||
this.moveBackward = false;
|
||||
this.moveLeft = false;
|
||||
this.moveRight = false;
|
||||
this.adjustDim = false; // 'K' key
|
||||
this.adjustBright = false; // 'L' key
|
||||
this.velocity = new THREE.Vector3();
|
||||
this.direction = new THREE.Vector3();
|
||||
this.flashlightOn = true; // Started as ON
|
||||
this.battery = 100.0;
|
||||
this.flares = 3; // NEW: Start with 3 flares
|
||||
this.maxFlares = 5; // NEW: Maximum flares
|
||||
this.stamina = 100.0;
|
||||
this.isSprinting = false;
|
||||
this.baseIntensity = 3.0; // NEW: Track desired brightness (defaults to 3.0)
|
||||
this.baseDrain = 0.5; // Drain per second at base intensity
|
||||
|
||||
// NEW: Hiding State
|
||||
this.isHiding = false;
|
||||
this.hidingSpot = null; // Store reference to locker
|
||||
this.storedPos = new THREE.Vector3(); // Store position before hiding
|
||||
|
||||
// Animation
|
||||
this.bobTime = 0;
|
||||
this.lastStepTime = 0;
|
||||
|
||||
// Audio
|
||||
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
this.audioEnabled = false;
|
||||
|
||||
this.lockLook = false; // To disable controls during jumpscare
|
||||
this.baseDrain = 0.5; // Drain per second at base intensity
|
||||
|
||||
this.setupInput();
|
||||
this.setupFlashlight();
|
||||
|
||||
// Survival tools state
|
||||
this.overloadCooldown = 5000; // 5 seconds
|
||||
this.flashLight = null; // Temporary point light for overload
|
||||
|
||||
// NEW: Cache UI elements to avoid per-frame DOM lookup
|
||||
this.uiStamina = document.getElementById('stamina-bar');
|
||||
this.uiBattery = document.getElementById('battery-level');
|
||||
this.uiFlares = document.getElementById('flare-count');
|
||||
}
|
||||
|
||||
setupFlashlight() {
|
||||
this.flashlight = new THREE.SpotLight(0xffffff, 10);
|
||||
this.flashlight.angle = Math.PI / 6;
|
||||
this.flashlight.penumbra = 0.3;
|
||||
this.flashlight.decay = 2;
|
||||
this.flashlight.distance = 15;
|
||||
// Group to hold the visual model and lights
|
||||
this.flashlightGroup = new THREE.Group();
|
||||
this.camera.add(this.flashlightGroup);
|
||||
|
||||
this.camera.add(this.flashlight);
|
||||
this.flashlight.position.set(0, 0, 0);
|
||||
this.flashlight.target.position.set(0, 0, -1);
|
||||
this.camera.add(this.flashlight.target);
|
||||
// Position: Bottom-right, slightly forward
|
||||
this.flashlightGroup.position.set(0.3, -0.25, -0.4);
|
||||
|
||||
// 1. Visual Model
|
||||
// Flashlight Body
|
||||
const bodyGeo = new THREE.CylinderGeometry(0.03, 0.04, 0.2, 16);
|
||||
bodyGeo.rotateX(-Math.PI / 2); // Point forward
|
||||
const bodyMat = new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.5, metalness: 0.8 });
|
||||
const body = new THREE.Mesh(bodyGeo, bodyMat);
|
||||
this.flashlightGroup.add(body);
|
||||
|
||||
// Flashlight Bulb (Emmissive)
|
||||
const bulbGeo = new THREE.CircleGeometry(0.025, 16);
|
||||
const bulbMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||||
const bulb = new THREE.Mesh(bulbGeo, bulbMat);
|
||||
bulb.position.z = -0.101; // Tip of body
|
||||
this.flashlightGroup.add(bulb);
|
||||
this.bulbMesh = bulb; // To toggle emission
|
||||
this.bulbMesh.visible = this.flashlightOn; // Sync with initial state
|
||||
|
||||
// Hand (Simple representation)
|
||||
const handGeo = new THREE.BoxGeometry(0.08, 0.08, 0.15);
|
||||
const handMat = new THREE.MeshStandardMaterial({ color: 0xdcb898 }); // Skin tone
|
||||
const hand = new THREE.Mesh(handGeo, handMat);
|
||||
hand.position.set(0.05, -0.05, 0.05); // Grip position
|
||||
hand.rotation.set(0.2, 0.2, 0);
|
||||
this.flashlightGroup.add(hand);
|
||||
|
||||
// 2. Lights
|
||||
// Main SpotLight (The beam)
|
||||
this.flashlight = new THREE.SpotLight(0xffffff, 3.0); // Reduced brightness
|
||||
this.flashlight.angle = Math.PI / 6;
|
||||
this.flashlight.penumbra = 0.5; // Softer edges
|
||||
this.flashlight.decay = 2.0; // Faster falloff
|
||||
this.flashlight.distance = 50;
|
||||
this.flashlight.position.set(0, 0, -0.1); // At tip
|
||||
|
||||
// Enable shadows (Performance optimized: reduced from 512)
|
||||
this.flashlight.castShadow = true;
|
||||
this.flashlight.shadow.mapSize.width = 256;
|
||||
this.flashlight.shadow.mapSize.height = 256;
|
||||
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);
|
||||
|
||||
this.flashlightGroup.add(this.flashlight);
|
||||
this.flashlightGroup.add(this.flashlight.target);
|
||||
|
||||
// PointLight (The glow around the player)
|
||||
this.bulbLight = new THREE.PointLight(0xffffff, 0.5, 3); // Very dim local glow
|
||||
this.bulbLight.position.set(0, 0, -0.15); // Slightly ahead of tip
|
||||
this.flashlightGroup.add(this.bulbLight);
|
||||
}
|
||||
|
||||
setupInput() {
|
||||
const onKeyDown = (event) => {
|
||||
if (this.ctx && this.ctx.state === 'suspended') {
|
||||
this.ctx.resume();
|
||||
this.audioEnabled = true;
|
||||
}
|
||||
|
||||
switch (event.code) {
|
||||
case 'KeyW': this.moveForward = true; break;
|
||||
case 'KeyA': this.moveLeft = true; break;
|
||||
case 'KeyS': this.moveBackward = true; break;
|
||||
case 'KeyD': this.moveRight = true; break;
|
||||
case 'KeyF': this.toggleFlashlight(); break;
|
||||
case 'KeyK': this.adjustDim = true; break;
|
||||
case 'KeyL': this.adjustBright = true; break;
|
||||
// case 'KeyR': Overload removed
|
||||
case 'KeyQ': this.handleFlare(); break; // Throw Flare
|
||||
case 'KeyE': this.handleInteract(); break; // NEW: Interact
|
||||
case 'ShiftLeft': this.isSprinting = true; break;
|
||||
case 'ShiftRight': this.isSprinting = true; break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,6 +165,10 @@ export class Player {
|
||||
case 'KeyA': this.moveLeft = false; break;
|
||||
case 'KeyS': this.moveBackward = false; break;
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,56 +177,81 @@ export class Player {
|
||||
}
|
||||
|
||||
toggleFlashlight() {
|
||||
if (!this.controls || !this.controls.isLocked) return; // Only toggle when game is active
|
||||
this.flashlightOn = !this.flashlightOn;
|
||||
if (this.flashlight) {
|
||||
this.flashlight.visible = this.flashlightOn;
|
||||
if (!this.controls) return;
|
||||
// Removed strict isLocked check for better reliability
|
||||
|
||||
if (this.battery <= 0 && this.flashlightOn === false) {
|
||||
window.log('Cannot turn on: Battery empty');
|
||||
return;
|
||||
}
|
||||
|
||||
this.flashlightOn = !this.flashlightOn;
|
||||
// Update all light components
|
||||
if (this.flashlight) this.flashlight.visible = this.flashlightOn;
|
||||
if (this.bulbLight) this.bulbLight.visible = this.flashlightOn;
|
||||
if (this.bulbMesh) this.bulbMesh.material.color.setHex(this.flashlightOn ? 0xffffff : 0x111111);
|
||||
}
|
||||
|
||||
lockControls() {
|
||||
if (this.controls) this.controls.lock();
|
||||
else window.log('WARNING: Controls not initialized for locking');
|
||||
if (this.controls) {
|
||||
this.controls.lock();
|
||||
window.log('Player.lockControls() called');
|
||||
} else {
|
||||
window.log('WARNING: Controls not initialized for locking');
|
||||
}
|
||||
}
|
||||
|
||||
getObject() {
|
||||
return this.controls ? this.controls.getObject() : new THREE.Group();
|
||||
// PointerLockControls in this version operates directly on the camera
|
||||
return this.camera;
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
if (!this.controls || !this.controls.isLocked) return;
|
||||
if (!this.controls || this.lockLook) return;
|
||||
|
||||
// Friction-like dampening
|
||||
// Simple direct velocity for now
|
||||
this.velocity.x = 0;
|
||||
this.velocity.z = 0;
|
||||
// Friction-like dampening (simple decay)
|
||||
this.velocity.x -= this.velocity.x * 10.0 * dt;
|
||||
this.velocity.z -= this.velocity.z * 10.0 * dt;
|
||||
|
||||
this.direction.z = Number(this.moveForward) - Number(this.moveBackward);
|
||||
this.direction.x = Number(this.moveRight) - Number(this.moveLeft);
|
||||
this.direction.normalize(); // Ensure consistent speed in all directions
|
||||
this.direction.normalize();
|
||||
|
||||
if (this.moveForward || this.moveBackward) this.velocity.z -= this.direction.z * this.speed * dt;
|
||||
if (this.moveLeft || this.moveRight) this.velocity.x -= this.direction.x * this.speed * dt;
|
||||
// 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
|
||||
if (this.uiStamina) this.uiStamina.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;
|
||||
|
||||
// Apply movement
|
||||
this.controls.moveRight(-this.velocity.x);
|
||||
this.controls.moveForward(-this.velocity.z);
|
||||
this.controls.moveRight(-this.velocity.x * dt);
|
||||
this.controls.moveForward(-this.velocity.z * dt);
|
||||
|
||||
// Simple Collision: Push back
|
||||
const playerPos = this.controls.getObject().position;
|
||||
const playerRadius = 0.5; // approximated radius
|
||||
// Simple Collision: Push back (Optimized to use pre-calculated boxes)
|
||||
const playerPos = this.camera.position;
|
||||
const playerRadius = 0.5;
|
||||
|
||||
for (const collider of this.colliders) {
|
||||
// Assume colliders are BoxGeometry meshes aligned with axes for now
|
||||
const box = new THREE.Box3().setFromObject(collider);
|
||||
|
||||
// Check if player is inside the box (expanded by radius)
|
||||
// We only check X/Z for walls
|
||||
for (let i = 0; i < this.colliderBoxes.length; i++) {
|
||||
const box = this.colliderBoxes[i];
|
||||
if (playerPos.x > box.min.x - playerRadius && playerPos.x < box.max.x + playerRadius &&
|
||||
playerPos.z > box.min.z - playerRadius && playerPos.z < box.max.z + playerRadius) {
|
||||
|
||||
// Very simple resolution: determine closest edge and push out
|
||||
// This is a naive implementation but works for static orthogonal walls
|
||||
const dx1 = Math.abs(playerPos.x - (box.min.x - playerRadius));
|
||||
const dx2 = Math.abs(playerPos.x - (box.max.x + playerRadius));
|
||||
const dz1 = Math.abs(playerPos.z - (box.min.z - playerRadius));
|
||||
@@ -135,12 +266,322 @@ export class Player {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep player on ground
|
||||
playerPos.y = this.height;
|
||||
// Keep player on ground (Base height)
|
||||
const baseHeight = this.height;
|
||||
playerPos.y = baseHeight;
|
||||
|
||||
// Flashlight flicker effect (subtle)
|
||||
// Audio Only Trigger
|
||||
if (this.moveForward || this.moveBackward || this.moveLeft || this.moveRight) {
|
||||
// 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 > interval) {
|
||||
this.lastStepTime = 0;
|
||||
this.playFootstep(isRunning);
|
||||
}
|
||||
} else {
|
||||
this.lastStepTime = 0.5; // Ready to step properly next time
|
||||
}
|
||||
|
||||
// Flashlight flicker effect (subtle) & Battery Logic
|
||||
if (this.flashlight && this.flashlightOn) {
|
||||
this.flashlight.intensity = 10 + Math.random() * 0.5;
|
||||
// Battery Drain
|
||||
// Base intensity is ~10. Drain proportional to intensity.
|
||||
const drainFactor = this.flashlight.intensity / 10.0;
|
||||
const drain = this.baseDrain * drainFactor * dt;
|
||||
this.battery = Math.max(0, this.battery - drain);
|
||||
|
||||
// Update UI
|
||||
if (this.uiBattery) this.uiBattery.textContent = Math.floor(this.battery) + '%';
|
||||
if (this.battery <= 20) {
|
||||
if (this.uiBattery) this.uiBattery.style.color = 'red';
|
||||
} else {
|
||||
if (this.uiBattery) this.uiBattery.style.color = 'white';
|
||||
}
|
||||
|
||||
// Die if empty
|
||||
if (this.battery <= 0) {
|
||||
this.flashlightOn = false;
|
||||
this.flashlight.visible = false;
|
||||
if (this.bulbLight) this.bulbLight.visible = false;
|
||||
if (this.bulbMesh) this.bulbMesh.material.color.setHex(0x111111);
|
||||
window.log('Battery depleted!');
|
||||
}
|
||||
|
||||
// Handle manual adjustment
|
||||
// Handle manual adjustment
|
||||
if (this.adjustDim || this.adjustBright) {
|
||||
const speed = 2.0 * dt;
|
||||
const angleSpeed = 0.5 * dt;
|
||||
|
||||
if (this.adjustDim) {
|
||||
this.baseIntensity = Math.max(0, this.baseIntensity - speed * 10);
|
||||
this.flashlight.angle = Math.max(0.1, this.flashlight.angle - angleSpeed);
|
||||
}
|
||||
if (this.adjustBright) {
|
||||
this.baseIntensity = Math.min(15, this.baseIntensity + speed * 10);
|
||||
this.flashlight.angle = Math.min(Math.PI / 2, this.flashlight.angle + angleSpeed);
|
||||
}
|
||||
|
||||
// Update actual intensity immediately for responsiveness
|
||||
this.flashlight.intensity = this.baseIntensity;
|
||||
|
||||
// Sync bulb light
|
||||
this.bulbLight.intensity = this.flashlight.intensity * 0.2;
|
||||
|
||||
// Log occasionally for feedback
|
||||
if (Math.random() < 0.1) window.log(`Light: Int=${this.baseIntensity.toFixed(1)} Ang=${this.flashlight.angle.toFixed(2)}`);
|
||||
} else {
|
||||
// Flicker logic
|
||||
const flicker = (Math.random() - 0.5) * 0.5;
|
||||
// Fix: Reset to base + flicker instead of accumulating drift
|
||||
this.flashlight.intensity = this.baseIntensity + flicker;
|
||||
this.bulbLight.intensity = Math.max(0, this.flashlight.intensity * 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// NEW: Recharge Logic
|
||||
let isRecharging = false;
|
||||
// playerPos is already defined above in the update method
|
||||
for (const station of this.rechargeStations) {
|
||||
const dist = playerPos.distanceTo(station.position);
|
||||
if (dist < 2.5) { // Interaction range
|
||||
isRecharging = true;
|
||||
this.battery = Math.min(100, this.battery + 15 * dt); // Recharge rate
|
||||
|
||||
// Visual feedback on station
|
||||
if (station.indicator) {
|
||||
station.indicator.material.color.setHex(0xffff00); // Yellow while charging
|
||||
station.light.color.setHex(0xffff00);
|
||||
}
|
||||
|
||||
if (Math.random() < 0.05) window.log('FLASHLIGHT_RECHARGING...');
|
||||
break; // Only recharge from one at a time
|
||||
} else {
|
||||
// Reset visual feedback if not charging
|
||||
if (station.indicator) {
|
||||
station.indicator.material.color.setHex(0x00ff00); // Green when ready
|
||||
station.light.color.setHex(0x00ff00);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-pickup logic logic removed in favor of manual interact (Key E)
|
||||
|
||||
if (this.debugTimer > 1.0) {
|
||||
this.debugTimer = 0;
|
||||
window.log(`DEBUG: On=${this.flashlightOn} Batt=${this.battery.toFixed(1)} Base=${this.baseIntensity} Int=${this.flashlight.intensity.toFixed(2)}`);
|
||||
}
|
||||
|
||||
this.updateAudioListener(dt);
|
||||
}
|
||||
|
||||
updateAudioListener(dt) {
|
||||
if (!this.ctx) return;
|
||||
|
||||
// Optimize: throttle updates if needed, but per-frame is smoother
|
||||
const pos = this.camera.position;
|
||||
const dir = this.camera.getWorldDirection(new THREE.Vector3());
|
||||
|
||||
const listener = this.ctx.listener;
|
||||
|
||||
// Using setTargetAtTime for smooth transition
|
||||
const time = this.ctx.currentTime;
|
||||
if (listener.positionX) {
|
||||
listener.positionX.setTargetAtTime(pos.x, time, 0.1);
|
||||
listener.positionY.setTargetAtTime(pos.y, time, 0.1);
|
||||
listener.positionZ.setTargetAtTime(pos.z, time, 0.1);
|
||||
} else {
|
||||
// Legacy support
|
||||
listener.setPosition(pos.x, pos.y, pos.z);
|
||||
}
|
||||
|
||||
if (listener.forwardX) {
|
||||
listener.forwardX.setTargetAtTime(dir.x, time, 0.1);
|
||||
listener.forwardY.setTargetAtTime(dir.y, time, 0.1);
|
||||
listener.forwardZ.setTargetAtTime(dir.z, time, 0.1);
|
||||
listener.upX.setTargetAtTime(0, time, 0.1);
|
||||
listener.upY.setTargetAtTime(1, time, 0.1);
|
||||
listener.upZ.setTargetAtTime(0, time, 0.1);
|
||||
} else {
|
||||
listener.setOrientation(dir.x, dir.y, dir.z, 0, 1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
playFootstep(isRunning = false) {
|
||||
if (!this.ctx) return;
|
||||
|
||||
const t = this.ctx.currentTime;
|
||||
const gain = this.ctx.createGain();
|
||||
const filter = this.ctx.createBiquadFilter();
|
||||
|
||||
// Noise buffer for texture
|
||||
const bufferSize = this.ctx.sampleRate * 0.1; // 0.1s duration
|
||||
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = (Math.random() * 2 - 1) * 0.5;
|
||||
}
|
||||
const noise = this.ctx.createBufferSource();
|
||||
noise.buffer = buffer;
|
||||
|
||||
// Filter to make it sound dull (floor/carpet)
|
||||
filter.type = 'lowpass';
|
||||
// Running sounds heavier/sharper
|
||||
filter.frequency.setValueAtTime(isRunning ? 600 : 400, t);
|
||||
|
||||
// Envelope
|
||||
const vol = isRunning ? 0.6 : 0.3;
|
||||
gain.gain.setValueAtTime(vol, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.1);
|
||||
|
||||
// Connect
|
||||
noise.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
noise.start();
|
||||
noise.stop(t + 0.1);
|
||||
}
|
||||
|
||||
startAmbience() {
|
||||
// Ambience removed per user request
|
||||
}
|
||||
|
||||
handleFlare() {
|
||||
if (!this.controls || this.lockLook) return;
|
||||
|
||||
if (this.flares <= 0) {
|
||||
window.log('OUT_OF_FLARES');
|
||||
return;
|
||||
}
|
||||
|
||||
this.flares--;
|
||||
this.updateFlareUI();
|
||||
|
||||
// This will be handled in Game.js by listening for an event
|
||||
const event = new CustomEvent('throwFlare', {
|
||||
detail: {
|
||||
position: this.camera.position.clone(),
|
||||
direction: new THREE.Vector3().set(0, 0, -1).applyQuaternion(this.camera.quaternion)
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
updateFlareUI() {
|
||||
if (this.uiFlares) this.uiFlares.textContent = this.flares;
|
||||
}
|
||||
|
||||
handleInteract() {
|
||||
if (!this.controls || !this.camera) return;
|
||||
|
||||
// 1. If Hiding, Exit Hiding
|
||||
if (this.isHiding) {
|
||||
this.exitHiding();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Flare Pickup Logic (Legacy but kept)
|
||||
// Check for flares first? Or prioritize hiding? Let's check distance.
|
||||
const playerPos = this.camera.position;
|
||||
let foundFlare = false;
|
||||
|
||||
for (const pickup of this.collectableFlares) {
|
||||
if (!pickup.active) continue;
|
||||
if (playerPos.distanceTo(pickup.position) < 2.5) {
|
||||
if (this.flares < this.maxFlares) {
|
||||
this.flares++;
|
||||
// this.world.removeFlarePickup(pickup); // Assuming access to world, but here we just hide it
|
||||
pickup.active = false;
|
||||
pickup.group.visible = false;
|
||||
this.updateFlareUI();
|
||||
window.log('FLARE_COLLECTED');
|
||||
foundFlare = true;
|
||||
} else {
|
||||
window.log('INVENTORY_FULL');
|
||||
foundFlare = true; // Handled interaction
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundFlare) return;
|
||||
|
||||
// 3. Check for Hiding Spots (Raycast for Lockers)
|
||||
// We use the camera direction
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(new THREE.Vector2(0, 0), this.camera);
|
||||
|
||||
// Filter for objects that are "hideable"
|
||||
// We need to check children of the scene... this is expensive if we check everything.
|
||||
// Better: Check culledObjects from the World if possible, OR
|
||||
// Since we don't have easy access to 'world.culledObjects' here directly without passing it,
|
||||
// we can iterate the player's vicinity or use a simpler distance check if we had the list.
|
||||
// Assuming we don't have the list easily, let's look at what the camera is pointing at.
|
||||
// Actually, 'this.controls.getObject()' is the camera wrapper.
|
||||
// The scene is not directly stored in Player usually, but passed in update?
|
||||
// Wait, Player doesn't store Scene.
|
||||
// FIX: We need to pass the list of hideable objects or the World to the Player.
|
||||
// For now, let's assume we can access `game.world` or passed objects.
|
||||
// BUT, we can just iterate `this.world.culledObjects` if we link it.
|
||||
// In Game.js, `player.world = world` was not set explicitly but player is passed world objects.
|
||||
|
||||
if (this.world && this.world.culledObjects) {
|
||||
const interactables = this.world.culledObjects.filter(o => o.visible && o.userData.isHideable);
|
||||
// Simple distance check first
|
||||
for (const obj of interactables) {
|
||||
if (playerPos.distanceTo(obj.position) < 2.0) {
|
||||
this.enterHiding(obj);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enterHiding(obj) {
|
||||
this.isHiding = true;
|
||||
this.hidingSpot = obj;
|
||||
this.storedPos = this.storedPos || new THREE.Vector3(); // Initialize if not exists
|
||||
this.storedPos.copy(this.camera.position);
|
||||
|
||||
// Move Player inside (approximate center of locker)
|
||||
// Locker origin is at bottom center. Height 2.0. Center is y=1.0.
|
||||
// We want eye level ~1.6
|
||||
this.camera.position.set(obj.position.x, 1.6, obj.position.z);
|
||||
|
||||
// Turn off light
|
||||
this.flashlightOn = false;
|
||||
this.flashlight.visible = false;
|
||||
if (this.bulbLight) this.bulbLight.visible = false;
|
||||
|
||||
// UI Feedback
|
||||
window.log("Entered Hiding Spot. Press E to exit.");
|
||||
|
||||
// Vignette Effect (Pseudo via CSS)
|
||||
document.body.style.boxShadow = "inset 0 0 150px 100px #000"; // Darken edges
|
||||
}
|
||||
|
||||
exitHiding() {
|
||||
this.isHiding = false;
|
||||
this.hidingSpot = null;
|
||||
|
||||
// Restore position (slightly offset to avoid getting stuck)
|
||||
// We push them forward relative to where they entered or just restore.
|
||||
// Restoring `storedPos` is safest.
|
||||
this.camera.position.copy(this.storedPos);
|
||||
|
||||
// Remove Vignette
|
||||
document.body.style.boxShadow = "none";
|
||||
|
||||
// Auto-restore flashlight if battery valid
|
||||
if (this.battery > 0) {
|
||||
this.flashlightOn = true;
|
||||
this.flashlight.visible = true;
|
||||
if (this.bulbLight) this.bulbLight.visible = true;
|
||||
}
|
||||
|
||||
window.log("Exited Hiding.");
|
||||
}
|
||||
}
|
||||
|
||||
1151
src/World.js
1151
src/World.js
File diff suppressed because it is too large
Load Diff
17
src/main.js
17
src/main.js
@@ -1,12 +1,19 @@
|
||||
import { Game } from './Game.js';
|
||||
|
||||
window.log = (msg) => {
|
||||
const logDiv = document.getElementById('debug-log');
|
||||
if (logDiv) {
|
||||
const span = document.createElement('div');
|
||||
span.textContent = `> ${msg}`;
|
||||
logDiv.appendChild(span);
|
||||
|
||||
// Performance: Cap log size
|
||||
if (logDiv.children.length > 50) {
|
||||
logDiv.removeChild(logDiv.firstChild);
|
||||
}
|
||||
|
||||
logDiv.scrollTop = logDiv.scrollHeight;
|
||||
}
|
||||
console.log(msg);
|
||||
};
|
||||
|
||||
// Toggle debug log with 'P'
|
||||
@@ -19,15 +26,7 @@ window.addEventListener('keydown', (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
import * as THREE from 'three';
|
||||
window.log(`THREE Revision: ${THREE.REVISION}`);
|
||||
if (window.checkWebGL) window.log(`System Check: ${window.checkWebGL()}`);
|
||||
|
||||
import { Game } from './Game.js';
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
window.log('DOM Content Loaded');
|
||||
const game = new Game();
|
||||
game.start();
|
||||
window.log('Game Started');
|
||||
});
|
||||
|
||||
83
style.css
83
style.css
@@ -28,7 +28,8 @@ body {
|
||||
/* Let clicks pass through to canvas if needed, but start screen needs clicks */
|
||||
}
|
||||
|
||||
#start-screen {
|
||||
#start-screen,
|
||||
#loading-screen {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
@@ -54,4 +55,84 @@ 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;
|
||||
}
|
||||
|
||||
#heartbeat {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.heart-icon {
|
||||
fill: #ff3333;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
#power-warning {
|
||||
display: none;
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-shadow: 0 0 10px red;
|
||||
animation: flash 0.5s infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
from {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#crosshair {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background-color: white;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
Reference in New Issue
Block a user