🎮ArcadeLab

HOLLOW WARD — Low-Poly Survival Horror

by EpicFalcon36
548 lines54.0 KB🛠️ Three.js (3D graphics)
▶ Play
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>HOLLOW WARD — Low-Poly Survival Horror</title>
<link href="https://fonts.googleapis.com/css2?family=VT323&family=Special+Elite&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;background:#000;overflow:hidden;font-family:'VT323',monospace;color:#cfc8b8;user-select:none;-webkit-user-select:none;touch-action:none}
#game{position:fixed;inset:0;width:100%;height:100%;display:block;image-rendering:pixelated;image-rendering:crisp-edges;z-index:0}
.fx{position:fixed;inset:0;width:100%;height:100%;pointer-events:none}
#noise{image-rendering:pixelated;opacity:.07;mix-blend-mode:screen;z-index:5}
#vignette{background:radial-gradient(ellipse at center,transparent 42%,rgba(0,0,0,.55) 72%,rgba(0,0,0,.96) 100%);z-index:6}
#scan{background:repeating-linear-gradient(0deg,rgba(0,0,0,.25) 0 1px,transparent 1px 3px);z-index:7}
#fearTint{background:radial-gradient(ellipse at center,transparent 25%,rgba(130,0,0,.85) 100%);opacity:0;z-index:8}
#scare{display:none;image-rendering:pixelated;z-index:20}
#flash{background:#fff;opacity:0;z-index:21}
#hud{position:fixed;inset:0;pointer-events:none;z-index:11}
.hidden{display:none!important}
#pagesBox{position:absolute;top:18px;left:22px;font-size:30px;letter-spacing:3px;color:#d9d1bd;text-shadow:0 0 8px rgba(0,0,0,.9),2px 2px 0 #000}
#pagesBox .lbl{color:#8a1d1d}
#bars{position:absolute;left:22px;bottom:20px;display:flex;flex-direction:column;gap:8px}
.bar{display:flex;align-items:center;gap:10px;font-size:20px;letter-spacing:2px;text-shadow:2px 2px 0 #000;transition:opacity .5s}
.bar span{width:78px;opacity:.8}
.track{width:150px;height:9px;border:1px solid rgba(220,210,190,.5);background:rgba(0,0,0,.5)}
#batFill{height:100%;width:100%;background:linear-gradient(90deg,#c9b46a,#efe2a8)}
#stamFill{height:100%;width:100%;background:linear-gradient(90deg,#6c7f88,#b8cbd4)}
#cross{position:absolute;left:50%;top:50%;width:4px;height:4px;margin:-2px 0 0 -2px;background:rgba(230,220,200,.55);border-radius:50%}
#msg{position:absolute;left:50%;top:62%;transform:translateX(-50%);font-size:28px;letter-spacing:3px;color:#e6dcc5;text-shadow:0 0 10px #000,2px 2px 0 #000;opacity:0;transition:opacity .6s;text-align:center;width:90%}
#note{position:absolute;left:50%;bottom:70px;transform:translateX(-50%) rotate(-1deg);max-width:520px;width:86%;padding:18px 24px;background:linear-gradient(#d8cfb6,#bfb393);color:#2a1c12;font-family:'Special Elite',monospace;font-size:17px;line-height:1.5;box-shadow:0 10px 40px rgba(0,0,0,.8);opacity:0;transition:opacity .6s;border-left:3px solid #6b1010}
.screen{position:fixed;inset:0;z-index:30;display:flex;align-items:center;justify-content:center;flex-direction:column;text-align:center;background:radial-gradient(ellipse at center,rgba(20,4,4,.82),rgba(0,0,0,.97));padding:20px}
.title{font-family:'Special Elite',monospace;font-size:clamp(46px,10vw,104px);color:#a3120f;letter-spacing:6px;text-shadow:0 0 18px rgba(255,0,0,.35),3px 3px 0 #000,-2px 0 0 rgba(0,180,255,.25);animation:flick 4s infinite}
@keyframes flick{0%,100%{opacity:1}92%{opacity:1}93%{opacity:.3;transform:translateX(3px)}94%{opacity:1;transform:none}96%{opacity:.5}97%{opacity:1}}
.sub{font-size:22px;letter-spacing:5px;color:#8f8676;margin-top:4px}
.story{max-width:560px;font-family:'Special Elite',monospace;font-size:15px;color:#b3a993;line-height:1.6;margin:26px auto 20px}
.btn{pointer-events:auto;font-family:'VT323',monospace;font-size:28px;letter-spacing:4px;color:#e6dcc5;background:linear-gradient(#2a0909,#140303);border:1px solid #6b1515;padding:10px 34px;cursor:pointer;transition:.2s;box-shadow:0 0 0 rgba(200,0,0,0)}
.btn:hover{background:linear-gradient(#4a0d0d,#200404);box-shadow:0 0 24px rgba(200,0,0,.45);transform:scale(1.04)}
.opts{display:flex;gap:8px;margin:0 0 22px;flex-wrap:wrap;justify-content:center}
.opt{font-family:'VT323';font-size:19px;padding:4px 14px;background:transparent;color:#8f8676;border:1px solid #3a3330;cursor:pointer;letter-spacing:2px}
.opt.on{color:#e6dcc5;border-color:#8a1d1d;background:rgba(138,29,29,.25)}
.controls{margin-top:24px;font-size:19px;color:#7a7266;letter-spacing:2px;line-height:1.5}
.warn{margin-top:12px;font-size:16px;color:#7d2b2b;letter-spacing:2px}
.big{font-family:'Special Elite';font-size:clamp(48px,11vw,110px);color:#b0120f;text-shadow:0 0 30px rgba(255,0,0,.4),3px 3px 0 #000;letter-spacing:6px}
.stat{font-size:24px;letter-spacing:3px;margin:12px 0 26px;color:#a59c8a}
#touch{position:fixed;inset:0;z-index:12;display:none}
#joyBase{position:absolute;width:120px;height:120px;border-radius:50%;border:2px solid rgba(220,210,190,.25);background:rgba(0,0,0,.25);left:40px;bottom:50px;pointer-events:none}
#joyKnob{position:absolute;width:50px;height:50px;border-radius:50%;background:rgba(220,210,190,.35);left:35px;top:35px}
.tbtn{position:absolute;right:24px;width:74px;height:74px;border-radius:50%;border:2px solid rgba(220,210,190,.35);background:rgba(20,0,0,.4);color:#e6dcc5;font-family:'VT323';font-size:20px;display:flex;align-items:center;justify-content:center;letter-spacing:1px}
#btnLight{bottom:150px}#btnRun{bottom:50px;right:110px}
@media(max-width:600px){#pagesBox{font-size:24px}.track{width:100px}.bar span{width:64px;font-size:17px}#note{font-size:14px;bottom:150px}}
</style>
</head>
<body>
<canvas id="game"></canvas>
<canvas id="noise" class="fx" width="160" height="120"></canvas>
<div id="vignette" class="fx"></div>
<div id="fearTint" class="fx"></div>
<div id="scan" class="fx"></div>
<canvas id="scare" class="fx" width="200" height="150"></canvas>
<div id="flash" class="fx"></div>

<div id="hud" class="hidden">
  <div id="pagesBox"><span class="lbl">PAGES</span> <span id="pagesN">0</span>/5</div>
  <div id="bars">
    <div class="bar"><span>LIGHT</span><div class="track"><div id="batFill"></div></div></div>
    <div class="bar" id="stamBar"><span>STAMINA</span><div class="track"><div id="stamFill"></div></div></div>
  </div>
  <div id="cross"></div>
  <div id="msg"></div>
  <div id="note"><div id="noteT"></div></div>
</div>

<div id="touch">
  <div id="joyBase"><div id="joyKnob"></div></div>
  <div class="tbtn" id="btnLight">LIGHT</div>
  <div class="tbtn" id="btnRun">RUN</div>
</div>

<div id="menu" class="screen">
  <div class="title">HOLLOW WARD</div>
  <div class="sub">ST. AGATHA'S · EAST WING · 2003</div>
  <div class="story">You wake on a cold floor in the condemned east wing. The exit is sealed with something older than locks. Find the <b>5 torn pages</b>, reach the <b>EXIT</b>. Keep your light on to see — but remember: it sees the light too.</div>
  <div class="opts" id="qOpts">
    <button class="opt" data-r="180">POTATO 180p</button>
    <button class="opt on" data-r="240">PS2 240p</button>
    <button class="opt" data-r="360">CRISP 360p</button>
  </div>
  <button class="btn" id="startBtn">ENTER THE WARD</button>
  <div class="controls">WASD move · MOUSE look · SHIFT sprint · F flashlight<br>Lose it by breaking line of sight. Darkness hides you.</div>
  <div class="warn">⚠ LOUD SOUNDS & FLASHING IMAGES · HEADPHONES RECOMMENDED</div>
</div>
<div id="pause" class="screen hidden"><div class="big" style="font-size:64px">PAUSED</div><div class="stat">It is still out there.</div><button class="btn" id="resumeBtn">RESUME</button></div>
<div id="dead" class="screen hidden"><div class="big">YOU DIED</div><div class="stat" id="deadStat"></div><button class="btn" id="retryBtn">TRY AGAIN</button></div>
<div id="win" class="screen hidden"><div class="big" style="color:#d9d1bd">ESCAPED</div><div class="stat" id="winStat"></div><button class="btn" id="againBtn">PLAY AGAIN</button></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(()=>{
const $=id=>document.getElementById(id);
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
let rng=Math.random;
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const isTouch=('ontouchstart' in window)||navigator.maxTouchPoints>0;

/* ================= AUDIO ================= */
const AU={ctx:null,
init(){if(this.ctx){this.ctx.resume();return;}const C=window.AudioContext||window.webkitAudioContext;if(!C)return;const c=this.ctx=new C();
 this.master=c.createGain();this.master.gain.value=.85;const comp=c.createDynamicsCompressor();comp.threshold.value=-8;comp.ratio.value=6;this.master.connect(comp);comp.connect(c.destination);
 this.rev=c.createConvolver();this.rev.buffer=this.impulse(2.8);const rg=c.createGain();rg.gain.value=.55;this.rev.connect(rg);rg.connect(this.master);
 const len=c.sampleRate*2;const b=c.createBuffer(1,len,c.sampleRate);const d=b.getChannelData(0);for(let i=0;i<len;i++)d[i]=Math.random()*2-1;this.noise=b;
 const bb=c.createBuffer(1,len,c.sampleRate);const bd=bb.getChannelData(0);let l=0;for(let i=0;i<len;i++){l=(l+.02*(Math.random()*2-1))/1.02;bd[i]=l*3.5;}this.brown=bb;
 this.curve=new Float32Array(1024);for(let i=0;i<1024;i++){const x=i/512-1;this.curve[i]=Math.tanh(x*6);}
 this.ambient();this.growlSetup();},
impulse(sec){const c=this.ctx,r=c.sampleRate,len=r*sec|0,b=c.createBuffer(2,len,r);for(let ch=0;ch<2;ch++){const d=b.getChannelData(ch);for(let i=0;i<len;i++)d[i]=(Math.random()*2-1)*Math.pow(1-i/len,3);}return b;},
env(g,t,a,p,dec){g.gain.setValueAtTime(.0001,t);g.gain.exponentialRampToValueAtTime(Math.max(.0002,p),t+a);g.gain.exponentialRampToValueAtTime(.0001,t+a+dec);},
route(node,wet,pan){const c=this.ctx;let n=node;if(pan!==undefined&&c.createStereoPanner){const p=c.createStereoPanner();p.pan.value=clamp(pan,-1,1);node.connect(p);n=p;}n.connect(this.master);if(wet){const g=c.createGain();g.gain.value=wet;n.connect(g);g.connect(this.rev);}},
nsrc(rate=1,buf){const s=this.ctx.createBufferSource();s.buffer=buf||this.noise;s.loop=true;s.playbackRate.value=rate;return s;},
burst(o){if(!this.ctx)return;const c=this.ctx,t=c.currentTime+(o.delay||0),a=o.a||.004,dur=o.dur||.1;const s=this.nsrc(o.rate||1);const f=c.createBiquadFilter();f.type=o.type||'bandpass';f.frequency.value=o.f||400;f.Q.value=o.q||1;const g=c.createGain();this.env(g,t,a,o.vol||.3,dur);s.connect(f);f.connect(g);this.route(g,o.wet||0,o.pan);s.start(t,Math.random()*1.5);s.stop(t+a+dur+.05);},
tone(o){if(!this.ctx)return;const c=this.ctx,t=c.currentTime+(o.delay||0),a=o.a||.005;const os=c.createOscillator();os.type=o.type||'sine';os.frequency.setValueAtTime(o.f,t);if(o.f2)os.frequency.exponentialRampToValueAtTime(o.f2,t+a+o.dur);const g=c.createGain();this.env(g,t,a,o.vol||.3,o.dur);os.connect(g);this.route(g,o.wet||0,o.pan);os.start(t);os.stop(t+a+o.dur+.05);},
ambient(){const c=this.ctx,g=c.createGain();g.gain.value=0;g.gain.linearRampToValueAtTime(.2,c.currentTime+5);
 [38,38.7,57.2].forEach(f=>{const o=c.createOscillator();o.frequency.value=f;const og=c.createGain();og.gain.value=.35;o.connect(og);og.connect(g);o.start();});
 const s=this.nsrc(1,this.brown);const lp=c.createBiquadFilter();lp.type='lowpass';lp.frequency.value=200;const sg=c.createGain();sg.gain.value=.7;s.connect(lp);lp.connect(sg);sg.connect(g);s.start();
 const l=c.createOscillator();l.frequency.value=.06;const lg=c.createGain();lg.gain.value=.07;l.connect(lg);lg.connect(g.gain);l.start();this.route(g,.4);},
growlSetup(){const c=this.ctx;this.gG=c.createGain();this.gG.gain.value=0;const lp=c.createBiquadFilter();lp.type='lowpass';lp.frequency.value=420;lp.Q.value=4;
 [46,49.3].forEach(f=>{const o=c.createOscillator();o.type='sawtooth';o.frequency.value=f;o.connect(lp);o.start();});
 const ns=this.nsrc(.5);const bp=c.createBiquadFilter();bp.type='bandpass';bp.frequency.value=300;bp.Q.value=3;const ng=c.createGain();ng.gain.value=.8;ns.connect(bp);bp.connect(ng);ng.connect(lp);ns.start();
 const tr=c.createGain();tr.gain.value=.6;const l=c.createOscillator();l.frequency.value=6;const lg=c.createGain();lg.gain.value=.4;l.connect(lg);lg.connect(tr.gain);l.start();
 lp.connect(tr);tr.connect(this.gG);if(c.createStereoPanner){this.gPan=c.createStereoPanner();this.gG.connect(this.gPan);this.gPan.connect(this.master);}else this.gG.connect(this.master);
 const w=c.createGain();w.gain.value=.3;this.gG.connect(w);w.connect(this.rev);},
setGrowl(v,p){if(!this.ctx)return;const t=this.ctx.currentTime;this.gG.gain.setTargetAtTime(v,t,.15);if(this.gPan)this.gPan.pan.setTargetAtTime(clamp(p||0,-1,1),t,.1);},
step(vol,pan,heavy){this.burst({f:heavy?140:300+Math.random()*140,q:.8,vol,dur:heavy?.18:.07,rate:.7+Math.random()*.5,wet:.15,pan});this.tone({f:heavy?62:110,f2:heavy?32:70,vol:vol*(heavy?1.3:.5),dur:heavy?.22:.07,pan});},
heart(v){this.tone({f:62,f2:38,vol:v,dur:.16});this.tone({f:58,f2:36,vol:v*.7,dur:.16,delay:.24});},
scream(dur,vol){if(!this.ctx)return;const c=this.ctx,t=c.currentTime;const ws=c.createWaveShaper();ws.curve=this.curve;const out=c.createGain();out.gain.setValueAtTime(.0001,t);out.gain.exponentialRampToValueAtTime(vol,t+.015);out.gain.setValueAtTime(vol,t+dur*.55);out.gain.exponentialRampToValueAtTime(.0001,t+dur);
 const pre=c.createGain();pre.gain.value=.3;pre.connect(ws);ws.connect(out);this.route(out,.5);
 for(let i=0;i<5;i++){const o=c.createOscillator();o.type=i%2?'sawtooth':'square';const f=300+Math.random()*750;o.frequency.setValueAtTime(f*.6,t);o.frequency.exponentialRampToValueAtTime(f*1.7,t+.12);o.frequency.exponentialRampToValueAtTime(f*.45,t+dur);const l=c.createOscillator();l.frequency.value=16+Math.random()*28;const lg=c.createGain();lg.gain.value=f*.09;l.connect(lg);lg.connect(o.frequency);o.connect(pre);o.start(t);l.start(t);o.stop(t+dur+.1);l.stop(t+dur+.1);}
 const s=this.nsrc();const bp=c.createBiquadFilter();bp.type='bandpass';bp.frequency.value=1800;bp.Q.value=.7;s.connect(bp);bp.connect(pre);s.start(t);s.stop(t+dur+.1);
 this.tone({f:85,f2:28,vol:vol*.9,dur:.9});},
sting(v=.6){if(!this.ctx)return;const c=this.ctx,t=c.currentTime;const lp=c.createBiquadFilter();lp.type='lowpass';lp.frequency.setValueAtTime(6000,t);lp.frequency.exponentialRampToValueAtTime(500,t+2.5);const g=c.createGain();this.env(g,t,.01,v,2.6);lp.connect(g);this.route(g,.8);
 [110,116.5,155.6,233.1,247,698,739].forEach(f=>{const o=c.createOscillator();o.type='sawtooth';o.frequency.value=f*(1+(Math.random()-.5)*.01);const og=c.createGain();og.gain.value=.12;o.connect(og);og.connect(lp);o.start(t);o.stop(t+2.8);});
 this.burst({type:'highpass',f:2500,vol:v*.6,dur:.4,wet:.6});this.tone({f:48,f2:30,vol:v,dur:1});},
pickup(){this.burst({type:'highpass',f:2500,vol:.25,dur:.3});this.tone({f:98,vol:.16,a:.3,dur:2.5,wet:.6});this.tone({f:103.8,vol:.16,a:.3,dur:2.5,wet:.6});this.tone({type:'triangle',f:740,vol:.05,a:.05,dur:2,wet:.9});},
metal(){const p=Math.random()*2-1;[173,421,598,887,1243].forEach(f=>this.tone({f:f*(.9+Math.random()*.2),vol:.04,dur:2.5,wet:1,pan:p}));this.burst({f:900,vol:.12,dur:.05,wet:1,pan:p});},
whisper(){if(!this.ctx)return;const c=this.ctx,t=c.currentTime,s=this.nsrc();const f=c.createBiquadFilter();f.type='bandpass';f.Q.value=6;f.frequency.setValueAtTime(1800,t);const g=c.createGain();g.gain.setValueAtTime(0,t);for(let i=1;i<11;i++){f.frequency.linearRampToValueAtTime(1100+Math.random()*2600,t+i*.18);g.gain.linearRampToValueAtTime(Math.random()*.4,t+i*.18);}g.gain.linearRampToValueAtTime(0,t+2.1);s.connect(f);f.connect(g);this.route(g,.6,Math.random()*2-1);s.start(t);s.stop(t+2.2);},
pop(){this.burst({type:'highpass',f:3000,vol:.6,dur:.06,wet:.4});this.tone({f:2800,f2:1100,vol:.08,dur:.3,wet:.5});},
click(){this.burst({type:'highpass',f:4000,vol:.2,dur:.015});},
locked(){this.tone({f:90,f2:50,vol:.4,dur:.25,wet:.3});this.burst({f:200,vol:.3,dur:.15,wet:.3});},
slam(){const p=Math.random()*2-1;this.tone({f:55,f2:30,vol:.5,dur:.5,wet:1,pan:p});this.burst({f:300,vol:.35,dur:.2,wet:1,pan:p});},
winS(){[196,246.9,293.7,392].forEach((f,i)=>this.tone({type:'triangle',f,vol:.12,a:.4,dur:4,wet:.8,delay:i*.15}));}
};

/* ================= RENDERER ================= */
const canvas=$('game');
const renderer=new THREE.WebGLRenderer({canvas,antialias:false,powerPreference:'low-power'});
renderer.setPixelRatio(1);
const scene=new THREE.Scene();scene.background=new THREE.Color(0x020203);
scene.fog=new THREE.FogExp2(0x030304,.085);
const camera=new THREE.PerspectiveCamera(72,1,.05,60);camera.rotation.order='YXZ';scene.add(camera);
const snapU={value:new THREE.Vector2(160,120)};
function psx(m){m.onBeforeCompile=s=>{s.uniforms.uSnap=snapU;s.vertexShader='uniform vec2 uSnap;\n'+s.vertexShader.replace('#include <project_vertex>','#include <project_vertex>\n{vec4 sp=gl_Position;vec2 nd=sp.xy/sp.w;nd=floor(nd*uSnap+0.5)/uSnap;gl_Position.xy=nd*sp.w;}');};m.dithering=true;return m;}
let resH=240;
function resize(){const w=innerWidth,h=innerHeight;const rw=Math.round(resH*w/h);renderer.setSize(rw,resH,false);camera.aspect=w/h;camera.updateProjectionMatrix();snapU.value.set(rw*.6,resH*.6);}
addEventListener('resize',resize);resize();

/* ================= TEXTURES ================= */
function canvasTex(w,h,draw){const c=document.createElement('canvas');c.width=w;c.height=h;const g=c.getContext('2d');draw(g,w,h);const t=new THREE.CanvasTexture(c);t.magFilter=THREE.NearestFilter;t.minFilter=THREE.NearestFilter;t.generateMipmaps=false;t.wrapS=t.wrapT=THREE.RepeatWrapping;return t;}
function noiseFill(g,w,h,amt){const d=g.getImageData(0,0,w,h);for(let i=0;i<d.data.length;i+=4){if(d.data[i+3]===0)continue;const n=(Math.random()-.5)*amt;d.data[i]+=n;d.data[i+1]+=n;d.data[i+2]+=n;}g.putImageData(d,0,0);}
const R=Math.random;
function blob(g,x,y,r,col){const gr=g.createRadialGradient(x,y,0,x,y,r);gr.addColorStop(0,col);gr.addColorStop(1,'rgba(0,0,0,0)');g.fillStyle=gr;g.fillRect(x-r,y-r,r*2,r*2);}
function wallTex(bloody){return canvasTex(64,64,(g,w,h)=>{
 g.fillStyle='#6d705f';g.fillRect(0,0,w,h);
 g.fillStyle='#3d544b';g.fillRect(0,37,w,27);
 g.fillStyle='#24302c';for(let x=0;x<w;x+=8)g.fillRect(x,37,1,27);for(let y=37;y<h;y+=7)g.fillRect(0,y,w,1);
 g.fillStyle='#222520';g.fillRect(0,34,w,3);
 for(let i=0;i<16;i++){g.fillStyle=`rgba(35,28,15,${.12+R()*.28})`;g.fillRect(R()*w|0,0,1+(R()*2|0),R()*44|0);}
 for(let i=0;i<6;i++)blob(g,R()*w,R()*h,4+R()*10,'rgba(20,18,10,.45)');
 for(let i=0;i<3;i++){g.fillStyle='rgba(25,25,20,.6)';g.fillRect(R()*w|0,40+R()*20|0,3,1);} // cracked tiles
 if(bloody){g.fillStyle='rgba(90,0,0,.85)';const x0=10+R()*30;for(let i=0;i<4;i++){g.fillRect(x0+i*3,14+R()*6,2,8+R()*30);}blob(g,x0+6,18,10,'rgba(110,0,0,.8)');for(let i=0;i<8;i++){g.fillRect(R()*w,R()*h,1,1+R()*5);}}
 noiseFill(g,w,h,24);});}
const texWall=wallTex(false),texWallB=wallTex(true);
const texFloor=canvasTex(64,64,(g,w,h)=>{for(let y=0;y<4;y++)for(let x=0;x<4;x++){g.fillStyle=(x+y)%2?'#3b3a34':'#4f4d43';g.fillRect(x*16,y*16,16,16);}
 g.fillStyle='#1d1c18';for(let i=0;i<=64;i+=16){g.fillRect(i,0,1,64);g.fillRect(0,i,64,1);}
 for(let i=0;i<7;i++)blob(g,R()*w,R()*h,5+R()*14,'rgba(15,12,8,.5)');noiseFill(g,w,h,26);});
const texCeil=canvasTex(64,64,(g,w,h)=>{g.fillStyle='#3a3a37';g.fillRect(0,0,w,h);g.fillStyle='#1c1c1a';for(let i=0;i<=64;i+=32){g.fillRect(i,0,2,64);g.fillRect(0,i,64,2);}
 for(let i=0;i<4;i++)blob(g,R()*w,R()*h,6+R()*12,'rgba(60,45,20,.5)');noiseFill(g,w,h,20);});
const texDoor=canvasTex(64,64,(g,w,h)=>{g.fillStyle='#5a2a1e';g.fillRect(0,0,w,h);for(let i=0;i<10;i++)blob(g,R()*w,R()*h,4+R()*10,'rgba(120,60,20,.5)');
 g.fillStyle='#2a120c';g.fillRect(4,4,56,2);g.fillRect(4,58,56,2);g.fillStyle='#121212';g.fillRect(22,10,20,14);g.fillStyle='#8c8c80';for(let y=8;y<60;y+=10){g.fillRect(6,y,2,2);g.fillRect(56,y,2,2);}
 g.fillStyle='#999';g.fillRect(48,32,8,3);noiseFill(g,w,h,22);});
const texCrate=canvasTex(32,32,(g,w,h)=>{g.fillStyle='#5b4528';g.fillRect(0,0,w,h);g.fillStyle='#2e2112';for(let y=0;y<32;y+=8)g.fillRect(0,y,32,1);g.fillRect(0,0,32,2);g.fillRect(0,30,32,2);g.fillRect(0,0,2,32);g.fillRect(30,0,2,32);noiseFill(g,w,h,30);});
const texMetal=canvasTex(32,32,(g,w,h)=>{g.fillStyle='#5d6463';g.fillRect(0,0,w,h);g.fillStyle='#2c302f';g.fillRect(0,15,32,1);g.fillRect(4,6,8,1);g.fillRect(4,22,8,1);for(let i=0;i<4;i++)blob(g,R()*w,R()*h,5,'rgba(90,50,20,.5)');noiseFill(g,w,h,24);});
const texSheet=canvasTex(32,32,(g,w,h)=>{g.fillStyle='#b6b09d';g.fillRect(0,0,w,h);blob(g,10+R()*12,10+R()*12,10,'rgba(110,0,0,.8)');noiseFill(g,w,h,22);});
const texBlood=canvasTex(64,64,(g,w,h)=>{for(let i=0;i<14;i++){g.fillStyle=`rgba(${70+R()*40|0},0,0,${.6+R()*.35})`;g.beginPath();g.arc(32+(R()-.5)*30,32+(R()-.5)*30,2+R()*12,0,7);g.fill();}for(let i=0;i<20;i++){g.fillRect(R()*64,R()*64,1+R()*2,1+R()*2);}});
const texPaper=canvasTex(32,40,(g,w,h)=>{g.fillStyle='#d2c8ad';g.fillRect(0,0,w,h);g.fillStyle='#2a2018';for(let y=5;y<36;y+=4)g.fillRect(3,y,4+R()*22|0,1);blob(g,22,30,8,'rgba(120,0,0,.8)');g.fillStyle='#d2c8ad';g.fillRect(26,0,6,4);noiseFill(g,w,h,20);});
const texExit=canvasTex(64,24,(g,w,h)=>{g.fillStyle='#0a3a14';g.fillRect(0,0,w,h);g.fillStyle='#7dff9a';g.font='bold 18px monospace';g.textAlign='center';g.textBaseline='middle';g.fillText('EXIT',32,13);});
const texSkin=canvasTex(32,32,(g,w,h)=>{g.fillStyle='#3a3533';g.fillRect(0,0,w,h);g.strokeStyle='rgba(70,10,20,.8)';for(let i=0;i<6;i++){g.beginPath();g.moveTo(R()*32,R()*32);g.lineTo(R()*32,R()*32);g.stroke();}for(let i=0;i<4;i++)blob(g,R()*32,R()*32,6,'rgba(15,10,10,.7)');noiseFill(g,w,h,30);});
const WRITINGS=['RUN','IT SEES','NO EXIT','TURN BACK','HELP ME','DONT LOOK','LIGHTS OFF'];
const texWrite=WRITINGS.map(s=>canvasTex(128,64,(g,w,h)=>{g.font='bold 30px "Special Elite",monospace';g.textAlign='center';g.textBaseline='middle';g.fillStyle='rgba(110,0,0,.9)';g.fillText(s,64,28);for(let i=0;i<14;i++)g.fillRect(14+R()*100,34,1+R()*2,4+R()*22);}));

/* scare face */
function drawFace(g,w,h,v){
 g.fillStyle='#000';g.fillRect(0,0,w,h);
 blob(g,w/2,h/2,w*.7,'rgba(70,0,0,.7)');
 const cx=w/2+(R()-.5)*w*.04,cy=h*.52,fw=w*.36*(1+v*.08),fh=h*.62;
 const gr=g.createRadialGradient(cx,cy-fh*.25,fw*.1,cx,cy,fw*1.25);gr.addColorStop(0,'#ddd6c2');gr.addColorStop(.55,'#8d887a');gr.addColorStop(1,'#120e0c');
 g.fillStyle=gr;g.beginPath();g.ellipse(cx,cy,fw,fh,0,0,7);g.fill();
 g.strokeStyle='rgba(60,0,10,.6)';g.lineWidth=Math.max(1,w/160);
 for(let i=0;i<10;i++){g.beginPath();let x=cx+(R()-.5)*fw*1.6,y=cy+(R()-.5)*fh*1.6;g.moveTo(x,y);for(let k=0;k<4;k++){x+=(R()-.5)*w*.08;y+=(R()-.5)*h*.08;g.lineTo(x,y);}g.stroke();}
 for(const s of[-1,1]){const ex=cx+s*fw*.42,ey=cy-fh*.22;g.fillStyle='#030101';g.beginPath();g.ellipse(ex,ey,fw*.24,fh*.18,s*.35,0,7);g.fill();
  g.fillStyle='#fff';const ps=Math.max(1,w/70);g.fillRect(ex-ps/2+(R()-.5)*ps,ey-ps/2,ps,ps);
  g.fillStyle='#5e0000';g.fillRect(ex-w*.008,ey+fh*.1,w*.016,fh*(.35+R()*.4));}
 const mw=fw*.56,mh=fh*.44,my=cy+fh*.36;
 g.fillStyle='#080000';g.beginPath();g.ellipse(cx,my,mw,mh,0,0,7);g.fill();
 g.fillStyle='#3a0000';g.beginPath();g.ellipse(cx,my+mh*.25,mw*.6,mh*.55,0,0,7);g.fill();
 g.fillStyle='#e2d9c0';const n=10;
 for(let i=0;i<n;i++){const tx=cx-mw*.9+(i+.5)*(mw*1.8/n),k=Math.sqrt(Math.max(0,1-((tx-cx)/mw)**2)),tw=mw*1.8/n*.5;
  const ty=my-mh*k*.95;g.beginPath();g.moveTo(tx-tw,ty);g.lineTo(tx+tw,ty);g.lineTo(tx+(R()-.5)*tw,ty+mh*(.3+R()*.35));g.fill();
  const by=my+mh*k*.95;g.beginPath();g.moveTo(tx-tw,by);g.lineTo(tx+tw,by);g.lineTo(tx+(R()-.5)*tw,by-mh*(.25+R()*.3));g.fill();}
 g.fillStyle='#6a0000';for(let i=0;i<6;i++)g.fillRect(cx+(R()-.5)*mw*1.5,my+mh*.7,w*.012,h*(.05+R()*.2));
 noiseFill(g,w,h,70);}
const texFace=canvasTex(64,64,(g,w,h)=>drawFace(g,w,h,0));
const scareC=$('scare'),scareG=scareC.getContext('2d');

/* ================= MATERIALS ================= */
const L=(o)=>psx(new THREE.MeshLambertMaterial(o));
const matWall=L({map:texWall,vertexColors:true}),matWallB=L({map:texWallB,vertexColors:true});
const matFloor=L({map:texFloor,vertexColors:true}),matCeil=L({map:texCeil,vertexColors:true});
const matDoor=L({map:texDoor}),matCrate=L({map:texCrate}),matMetal=L({map:texMetal}),matSheet=L({map:texSheet});
const matFrame=L({color:0x1c1a18});
const matBlood=psx(new THREE.MeshLambertMaterial({map:texBlood,transparent:true,depthWrite:false,polygonOffset:true,polygonOffsetFactor:-2}));
const matPaper=L({map:texPaper,emissive:0x555048,emissiveMap:texPaper,side:THREE.DoubleSide});
const matExit=psx(new THREE.MeshBasicMaterial({map:texExit}));
const matBat=L({color:0x2a3a2a,emissive:0x0a2a0a});
const matBatTip=psx(new THREE.MeshBasicMaterial({color:0x66ff77}));
const matWrite=texWrite.map(t=>psx(new THREE.MeshLambertMaterial({map:t,transparent:true,depthWrite:false,polygonOffset:true,polygonOffsetFactor:-2})));
const matSkin=L({map:texSkin,color:0x9a8f8a});
const matFace=L({map:texFace,emissive:0x2a2826,emissiveMap:texFace});
const matEye=psx(new THREE.MeshBasicMaterial({color:0xff2a14,fog:false}));
const matLampOff=psx(new THREE.MeshBasicMaterial({color:0x222222}));

/* ================= LIGHTS ================= */
const amb=new THREE.AmbientLight(0x3a4560,.55);scene.add(amb);
const spot=new THREE.SpotLight(0xfff0d8,1.8,24,.52,.55,1.3);spot.position.set(.22,-.18,.1);camera.add(spot);spot.target.position.set(0,-.15,-4);camera.add(spot.target);
const pool=[];for(let i=0;i<4;i++){const p=new THREE.PointLight(0xffbb77,0,9,1.6);scene.add(p);pool.push({l:p,lamp:null});}
const exitLight=new THREE.PointLight(0x33ff66,.9,7,2);scene.add(exitLight);

/* ================= LEVEL ================= */
const T=3,H=3.2,PR=.32;
const D4=[[1,0],[-1,0],[0,1],[0,-1]];
let grid,GW,GH,level=null,lamps=[],props=[],pages=[],batteries=[],wallFaces=[],exitInfo=null;
const cellC=g=>g*T+T/2;
const isFloor=(x,y)=>x>=0&&y>=0&&x<GW&&y<GH&&grid[y][x]===0;
function solid(x,z){const gx=Math.floor(x/T),gz=Math.floor(z/T);if(gx<0||gz<0||gx>=GW||gz>=GH)return true;return grid[gz][gx]===1;}
function blocked(x,z,r){if(solid(x-r,z-r)||solid(x+r,z-r)||solid(x-r,z+r)||solid(x+r,z+r))return true;for(const p of props){const dx=x-p.x,dz=z-p.z;if(dx*dx+dz*dz<(r+p.r)*(r+p.r))return true;}return false;}
function los(x0,z0,x1,z1){const dx=x1-x0,dz=z1-z0,Ln=Math.hypot(dx,dz),n=Math.ceil(Ln/.4);for(let i=1;i<n;i++){const t=i/n;if(solid(x0+dx*t,z0+dz*t))return false;}return true;}
function bfs(sx,sy){const d=new Int16Array(GW*GH).fill(-1),q=new Int32Array(GW*GH);let h=0,t=0;d[sy*GW+sx]=0;q[t++]=sy*GW+sx;while(h<t){const c=q[h++],x=c%GW,y=(c/GW)|0;for(const[dx,dy]of D4){const nx=x+dx,ny=y+dy;if(isFloor(nx,ny)&&d[ny*GW+nx]<0){d[ny*GW+nx]=d[c]+1;q[t++]=ny*GW+nx;}}}return d;}
function Geo(){return{p:[],n:[],u:[],c:[],i:[],vc:0}}
function quad(G,p0,p1,p2,p3,nrm,seg,colFn){const b=G.vc;for(let j=0;j<=seg;j++){const v=j/seg;for(let i=0;i<=seg;i++){const u=i/seg;
 const ax=p0[0]+(p1[0]-p0[0])*u,ay=p0[1]+(p1[1]-p0[1])*u,az=p0[2]+(p1[2]-p0[2])*u,bx=p3[0]+(p2[0]-p3[0])*u,by=p3[1]+(p2[1]-p3[1])*u,bz=p3[2]+(p2[2]-p3[2])*u;
 const x=ax+(bx-ax)*v,y=ay+(by-ay)*v,z=az+(bz-az)*v;G.p.push(x,y,z);G.n.push(nrm[0],nrm[1],nrm[2]);G.u.push(u,v);const c=colFn(x,y,z,u,v);G.c.push(c,c*.98,c*.95);}}
 for(let j=0;j<seg;j++)for(let i=0;i<seg;i++){const a=b+j*(seg+1)+i,bb=a+1,d=a+seg+1,c=d+1;G.i.push(a,bb,c,a,c,d);}G.vc+=(seg+1)*(seg+1);}
function toMesh(G,m){const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.Float32BufferAttribute(G.p,3));g.setAttribute('normal',new THREE.Float32BufferAttribute(G.n,3));g.setAttribute('uv',new THREE.Float32BufferAttribute(G.u,2));g.setAttribute('color',new THREE.Float32BufferAttribute(G.c,3));g.setIndex(G.i);return new THREE.Mesh(g,m);}
function ao(x,z){const o=.6;let c=1;if(solid(x-o,z))c-=.2;if(solid(x+o,z))c-=.2;if(solid(x,z-o))c-=.2;if(solid(x,z+o))c-=.2;if(solid(x-o,z-o))c-=.07;if(solid(x+o,z-o))c-=.07;if(solid(x-o,z+o))c-=.07;if(solid(x+o,z+o))c-=.07;return Math.max(.32,c)*(.84+R()*.16);}
const wallCol=(x,y,z,u,v)=>{const s=Math.sin(Math.PI*clamp(y/H,0,1));let c=.42+.58*Math.pow(s,.6);const e=Math.min(u,1-u);if(e<.15)c*=.7+e*2;return c*(.86+R()*.14);};
const box=(w,h,d,m)=>new THREE.Mesh(new THREE.BoxGeometry(w,h,d),m);

function buildLevel(){
 if(level){scene.remove(level);level.traverse(o=>{if(o.geometry)o.geometry.dispose();});}
 level=new THREE.Group();scene.add(level);
 rng=mulberry32((Math.random()*1e9)|0);
 lamps=[];props=[];pages=[];batteries=[];wallFaces=[];
 const CW=10,CH=10;GW=CW*2+1;GH=CH*2+1;
 grid=[];for(let y=0;y<GH;y++)grid.push(new Array(GW).fill(1));
 const vis=[];for(let y=0;y<CH;y++)vis.push(new Array(CW).fill(false));
 const st=[[0,0]];vis[0][0]=true;grid[1][1]=0;
 while(st.length){const[cx,cy]=st[st.length-1];const nb=[];for(const[dx,dy]of D4){const nx=cx+dx,ny=cy+dy;if(nx>=0&&ny>=0&&nx<CW&&ny<CH&&!vis[ny][nx])nb.push([nx,ny,dx,dy]);}
  if(!nb.length){st.pop();continue;}const[nx,ny,dx,dy]=nb[(rng()*nb.length)|0];vis[ny][nx]=true;grid[cy*2+1+dy][cx*2+1+dx]=0;grid[ny*2+1][nx*2+1]=0;st.push([nx,ny]);}
 let made=0;for(let a=0;a<800&&made<16;a++){const x=1+((rng()*(GW-2))|0),y=1+((rng()*(GH-2))|0);if(grid[y][x]!==1)continue;
  if((grid[y][x-1]===0&&grid[y][x+1]===0&&grid[y-1][x]===1&&grid[y+1][x]===1)||(grid[y-1][x]===0&&grid[y+1][x]===0&&grid[y][x-1]===1&&grid[y][x+1]===1)){grid[y][x]=0;made++;}}
 for(let r=0;r<3;r++){const cx=2*(1+((rng()*(CW-2))|0))+1,cy=2*(1+((rng()*(CH-2))|0))+1;for(let y=cy-1;y<=cy+1;y++)for(let x=cx-1;x<=cx+1;x++)grid[y][x]=0;}
 const floors=[],dead=[];
 for(let y=0;y<GH;y++)for(let x=0;x<GW;x++)if(grid[y][x]===0){floors.push([x,y]);let o=0;for(const[dx,dy]of D4)if(isFloor(x+dx,y+dy))o++;if(o===1)dead.push([x,y]);}
 const dS=bfs(1,1);const ds=c=>dS[c[1]*GW+c[0]];
 dead.sort((a,b)=>ds(b)-ds(a));
 const ex=dead.find(c=>!(c[0]===1&&c[1]===1))||floors[floors.length-1];
 const used=new Set(['1,1',ex[0]+','+ex[1]]);
 let cand=dead.filter(c=>!used.has(c[0]+','+c[1])&&ds(c)>=5);
 if(cand.length<5){const extra=floors.filter(c=>!used.has(c[0]+','+c[1])&&ds(c)>=6&&!cand.includes(c));while(cand.length<8&&extra.length)cand.push(extra.splice((rng()*extra.length)|0,1)[0]);}
 const chosen=[];const anchors=[[1,1],ex];
 for(let k=0;k<5&&cand.length;k++){let best=-1,bi=0;cand.forEach((c,i)=>{let m=1e9;for(const a of anchors.concat(chosen))m=Math.min(m,Math.hypot(c[0]-a[0],c[1]-a[1]));if(m>best){best=m;bi=i;}});chosen.push(cand.splice(bi,1)[0]);}
 chosen.forEach(c=>used.add(c[0]+','+c[1]));

 // geometry
 const G1=Geo(),G2=Geo(),GF=Geo(),GC=Geo();
 for(let gy=0;gy<GH;gy++)for(let gx=0;gx<GW;gx++){
  const x0=gx*T,x1=x0+T,z0=gy*T,z1=z0+T;
  if(grid[gy][gx]===1){for(const[dx,dz]of D4){if(!isFloor(gx+dx,gy+dz))continue;const cx=x0+T/2+dx*T/2,cz=z0+T/2+dz*T/2;const rx=dz,rz=-dx;
    const p0=[cx-rx*T/2,0,cz-rz*T/2],p1=[cx+rx*T/2,0,cz+rz*T/2],p2=[p1[0],H,p1[2]],p3=[p0[0],H,p0[2]];
    quad(rng()<.12?G2:G1,p0,p1,p2,p3,[dx,0,dz],4,wallCol);wallFaces.push({x:cx,z:cz,nx:dx,nz:dz});}}
  else{quad(GF,[x0,0,z1],[x1,0,z1],[x1,0,z0],[x0,0,z0],[0,1,0],4,(x,y,z)=>ao(x,z));
   quad(GC,[x0,H,z0],[x1,H,z0],[x1,H,z1],[x0,H,z1],[0,-1,0],3,(x,y,z)=>ao(x,z)*.8);}
 }
 level.add(toMesh(G1,matWall),toMesh(G2,matWallB),toMesh(GF,matFloor),toMesh(GC,matCeil));

 // lamps
 const lampGrid=new Set();
 const addLamp=(x,y,mode)=>{const lm=psx(new THREE.MeshBasicMaterial({color:0xffcc88}));const m=box(.8,.06,.26,lm);m.position.set(cellC(x),H-.04,cellC(y));level.add(m);
  const cord=box(.02,.3,.02,matFrame);cord.position.set(cellC(x),H-.15,cellC(y));level.add(cord);m.position.y=H-.3;
  lamps.push({x:cellC(x),z:cellC(y),mode,mat:lm,on:true,t:R(),lv:1});lampGrid.add(x+','+y);};
 addLamp(1,1,0);
 floors.forEach(([x,y])=>{if(rng()>.3)return;for(const[dx,dy]of D4)if(lampGrid.has((x+dx)+','+(y+dy)))return;if(lampGrid.has(x+','+y))return;const r=rng();addLamp(x,y,r<.2?2:r<.55?1:0);});

 // pages
 chosen.forEach(([x,y],i)=>{let wd=null;for(const[dx,dy]of D4)if(!isFloor(x+dx,y+dy)){const ox=-dx,oy=-dy;if(isFloor(x+ox,y+oy)){wd=[dx,dy];break;}if(!wd)wd=[dx,dy];}
  const m=new THREE.Mesh(new THREE.PlaneGeometry(.36,.46),matPaper);let px=cellC(x),pz=cellC(y);
  if(wd){px+=wd[0]*(T/2-.03);pz+=wd[1]*(T/2-.03);m.position.set(px,1.45,pz);m.rotation.y=Math.atan2(-wd[0],-wd[1]);m.rotation.z=(rng()-.5)*.3;
   const w=new THREE.Mesh(new THREE.PlaneGeometry(1.8,.9),matWrite[(rng()*matWrite.length)|0]);w.position.set(cellC(x)+wd[0]*(T/2-.02),2.25,cellC(y)+wd[1]*(T/2-.02));w.rotation.y=m.rotation.y;level.add(w);}
  else{const c=box(.6,.9,.6,matCrate);c.position.set(px,.45,pz);level.add(c);props.push({x:px,z:pz,r:.45});m.position.set(px,.92,pz);m.rotation.x=-Math.PI/2;}
  level.add(m);const b=new THREE.Mesh(new THREE.PlaneGeometry(1.8,1.8),matBlood);b.rotation.x=-Math.PI/2;b.rotation.z=rng()*6;b.position.set(cellC(x),.015,cellC(y));level.add(b);
  pages.push({m,x:px,z:pz,got:false});});

 // batteries
 const bc=floors.filter(c=>!used.has(c[0]+','+c[1])&&dS[c[1]*GW+c[0]]>3);
 for(let i=0;i<5&&bc.length;i++){const c=bc.splice((rng()*bc.length)|0,1)[0];used.add(c[0]+','+c[1]);const g=new THREE.Group();const b=new THREE.Mesh(new THREE.CylinderGeometry(.06,.06,.2,6),matBat);b.rotation.z=Math.PI/2;g.add(b);const tip=new THREE.Mesh(new THREE.CylinderGeometry(.03,.03,.04,6),matBatTip);tip.rotation.z=Math.PI/2;tip.position.x=.12;g.add(tip);
  const x=cellC(c[0])+(rng()-.5)*1.2,z=cellC(c[1])+(rng()-.5)*1.2;g.position.set(x,.07,z);g.rotation.y=rng()*6;level.add(g);batteries.push({m:g,x,z,got:false});}

 // exit door
 let ed=null;{const[x,y]=ex;for(const[dx,dy]of D4)if(!isFloor(x+dx,y+dy)&&isFloor(x-dx,y-dy)){ed=[dx,dy];break;}if(!ed)for(const[dx,dy]of D4)if(!isFloor(x+dx,y+dy)){ed=[dx,dy];break;}if(!ed)ed=[1,0];}
 const dg=new THREE.Group();const dx=cellC(ex[0])+ed[0]*(T/2-.07),dz=cellC(ex[1])+ed[1]*(T/2-.07);dg.position.set(dx,0,dz);dg.rotation.y=Math.atan2(ed[0],ed[1]);
 const door=box(1.5,2.4,.12,matDoor);door.position.y=1.2;dg.add(door);
 [-1,1].forEach(s=>{const f=box(.16,2.6,.22,matFrame);f.position.set(s*.83,1.3,0);dg.add(f);});const tp=box(1.82,.16,.22,matFrame);tp.position.y=2.6;dg.add(tp);
 const sign=box(.7,.26,.06,matExit);sign.position.set(0,2.92,-.05);dg.add(sign);level.add(dg);
 exitInfo={x:dx,z:dz};exitLight.position.set(dx-ed[0]*.9,2.6,dz-ed[1]*.9);
 const eb=new THREE.Mesh(new THREE.PlaneGeometry(2.4,2.4),matBlood);eb.rotation.x=-Math.PI/2;eb.position.set(cellC(ex[0]),.016,cellC(ex[1]));level.add(eb);

 // props
 const pc=floors.filter(c=>!used.has(c[0]+','+c[1])&&Math.abs(c[0]-1)+Math.abs(c[1]-1)>2);
 for(let i=0;i<26&&pc.length;i++){const c=pc.splice((rng()*pc.length)|0,1)[0];const wds=D4.filter(([a,b])=>!isFloor(c[0]+a,c[1]+b));if(!wds.length)continue;const[wx,wz]=wds[(rng()*wds.length)|0];const px=-wz,pz=wx;const off=(rng()-.5)*1.4;const cx=cellC(c[0]),cz=cellC(c[1]);const t=rng();
  if(t<.45){const s=.6+rng()*.3;const x=cx+wx*(T/2-s/2-.03)+px*off,z=cz+wz*(T/2-s/2-.03)+pz*off;const m=box(s,s,s,matCrate);m.position.set(x,s/2,z);m.rotation.y=(rng()-.5)*.4;level.add(m);
   if(rng()<.4){const s2=s*.7;const m2=box(s2,s2,s2,matCrate);m2.position.set(x,s+s2/2,z);m2.rotation.y=rng();level.add(m2);}props.push({x,z,r:s*.62});}
  else if(t<.7){const x=cx+wx*(T/2-.26)+px*off,z=cz+wz*(T/2-.26)+pz*off;const m=box(.9,1.9,.45,matMetal);m.position.set(x,.95,z);m.rotation.y=Math.atan2(wx,wz);level.add(m);props.push({x,z,r:.5});}
  else{const x=cx+wx*(T/2-.45),z=cz+wz*(T/2-.45);const g=new THREE.Group();g.position.set(x,0,z);g.rotation.y=Math.atan2(wx,wz)+(rng()-.5)*.2;
   const top=box(1.9,.08,.7,matMetal);top.position.y=.82;g.add(top);for(const sx of[-.85,.85])for(const sz of[-.3,.3]){const l=box(.05,.8,.05,matFrame);l.position.set(sx,.4,sz);g.add(l);}
   if(rng()<.55){const body=box(1.6,.26,.5,matSheet);body.position.y=.99;g.add(body);const hd=box(.3,.24,.32,matSheet);hd.position.set(.9,1.0,0);g.add(hd);}
   level.add(g);props.push({x:x+px*.55,z:z+pz*.55,r:.42},{x:x-px*.55,z:z-pz*.55,r:.42});}}
 // floor blood & wall writing
 for(let i=0;i<18;i++){const c=floors[(rng()*floors.length)|0];const b=new THREE.Mesh(new THREE.PlaneGeometry(.8+rng()*1.6,.8+rng()*1.6),matBlood);b.rotation.x=-Math.PI/2;b.rotation.z=rng()*6;b.position.set(cellC(c[0])+(rng()-.5)*1.5,.012,cellC(c[1])+(rng()-.5)*1.5);level.add(b);}
 for(let i=0;i<9&&wallFaces.length;i++){const f=wallFaces[(rng()*wallFaces.length)|0];const w=new THREE.Mesh(new THREE.PlaneGeometry(2,1),matWrite[(rng()*matWrite.length)|0]);w.position.set(f.x+f.nx*.02,1.3+rng()*.8,f.z+f.nz*.02);w.rotation.y=Math.atan2(f.nx,f.nz);w.rotation.z=(rng()-.5)*.15;level.add(w);}
 // start orientation
 let sy=0;for(const[dx,dy]of D4)if(isFloor(1+dx,1+dy)){sy=Math.atan2(-dx,-dy);break;}
 return sy;
}

/* ================= MONSTER ================= */
function buildMonster(){const g=new THREE.Group(),legs=[],arms=[];
 for(const s of[-1,1]){const p=new THREE.Group();p.position.set(s*.14,1.0,0);const m=box(.13,1.0,.15,matSkin);m.position.y=-.5;p.add(m);g.add(p);legs.push(p);}
 const up=new THREE.Group();up.position.y=1.0;up.rotation.x=.28;g.add(up);
 const pel=box(.42,.25,.26,matSkin);pel.position.y=0;up.add(pel);
 const tor=box(.55,.95,.3,matSkin);tor.position.y=.55;up.add(tor);
 for(let i=0;i<3;i++){const rb=box(.58,.04,.32,matFrame);rb.position.y=.4+i*.15;up.add(rb);}
 for(const s of[-1,1]){const p=new THREE.Group();p.position.set(s*.36,.95,0);p.rotation.z=s*.12;const a=box(.1,1.35,.11,matSkin);a.position.y=-.67;p.add(a);
  const h=box(.15,.22,.07,matSkin);h.position.y=-1.43;p.add(h);for(let f=-1;f<=1;f++){const fg=box(.025,.34,.025,matFrame);fg.position.set(f*.05,-1.68,.02);fg.rotation.x=.2;p.add(fg);}up.add(p);arms.push(p);}
 const neck=box(.12,.25,.12,matSkin);neck.position.y=1.12;up.add(neck);
 const hp=new THREE.Group();hp.position.y=1.2;up.add(hp);
 const head=new THREE.Mesh(new THREE.BoxGeometry(.34,.44,.34),[matSkin,matSkin,matSkin,matSkin,matFace,matSkin]);head.position.y=.2;hp.add(head);
 for(const s of[-1,1]){const e=new THREE.Mesh(new THREE.PlaneGeometry(.075,.05),matEye);e.position.set(s*.075,.27,.172);hp.add(e);}
 g.visible=false;scene.add(g);return{g,legs,arms,up,hp};}
const MON=buildMonster(),PH=buildMonster();
const M={x:0,z:0,rot:0,active:false,hunt:0,lk:[1,1],knowT:0,pathT:0,field:null,tgt:null,direct:false,phase:0,lastS:0,unseen:0,tpT:3};
const PHS={on:false,seen:0,life:0};

/* ================= STATE ================= */
const P={x:0,z:0,yaw:0,pitch:0,vx:0,vz:0,stamina:1,ex:false,battery:1,light:true,bob:0,lastS:0,sprinting:false};
const G={pages:0,time:0,eventT:40,ambT:12,lightMul:1,blackT:0,flickT:0,flash:0,flashCol:'#fff',scareT:0,scareTick:0,deathT:0,heartT:0,exitMsgT:0,poolT:0,fear:0,msgT:0,noteT:0};
let state='menu';const keys={};
const NOTES=['"Day 3. The lights keep dying. Something walks the halls when they do. I hear its knees crack."','"Don\'t let it see your light. It is faster when it knows where you are. Darkness is the only place to hide."','"I hid behind the cabinets for hours. It breathed through the wall. It knows my name now."','"The exit is sealed. The pages are the key. It wants them back. It is ALWAYS closer than you think."','"IT\'S RIGHT BEHIND YOU."'];

function msg(t,d=3){const m=$('msg');m.textContent=t;m.style.opacity=1;G.msgT=d;}
function note(t){$('noteT').textContent=t;$('note').style.opacity=1;G.noteT=6;}
function flash(col,v){G.flashCol=col;G.flash=v;}
function showScare(d){G.scareT=d;scareC.style.display='block';drawFace(scareG,200,150,R());}

function resetPlayer(yaw){P.x=cellC(1);P.z=cellC(1);P.yaw=yaw;P.pitch=0;P.vx=P.vz=0;P.stamina=1;P.ex=false;P.battery=1;P.light=true;
 Object.assign(G,{pages:0,time:0,eventT:40,ambT:12,blackT:0,flickT:0,flash:0,scareT:0,deathT:0,fear:0});
 Object.assign(M,{active:false,hunt:0,tgt:null,field:null,unseen:0});MON.g.visible=false;PH.g.visible=false;PHS.on=false;
 camera.fov=72;camera.updateProjectionMatrix();$('pagesN').textContent='0';scareC.style.display='none';AU.setGrowl(0);}

function spawnMonsterFar(minD){const pg=[Math.floor(P.x/T),Math.floor(P.z/T)];const f=bfs(pg[0],pg[1]);let c=[],far=null,fv=-1;
 for(let y=0;y<GH;y++)for(let x=0;x<GW;x++){const v=f[y*GW+x];if(v<0)continue;if(v>fv){fv=v;far=[x,y];}if(v>=minD&&v<=minD+4&&!los(cellC(x),cellC(y),P.x,P.z))c.push([x,y]);}
 const s=c.length?c[(Math.random()*c.length)|0]:far;M.x=cellC(s[0]);M.z=cellC(s[1]);M.tgt=null;M.active=true;MON.g.visible=true;M.lk=pg;M.unseen=0;}

function collectPage(p){p.got=true;p.m.visible=false;G.pages++;$('pagesN').textContent=G.pages;AU.pickup();note(NOTES[G.pages-1]);
 if(M.active)M.lk=[Math.floor(P.x/T),Math.floor(P.z/T)];
 const n=G.pages;
 if(n===1){spawnMonsterFar(9);setTimeout(()=>{if(state!=='play')return;AU.slam();msg('Something woke up.',3);G.flickT=1.2;},1400);}
 else if(n===2){G.blackT=2.3;AU.tone({f:35,f2:25,vol:.8,dur:1.4,wet:.5});setTimeout(()=>{if(state!=='play')return;showScare(.42);AU.scream(1,.9);flash('#fff',.8);},1800);}
 else if(n===3){for(const pl of pool)if(pl.lamp)pl.lamp.mode=2;AU.pop();setTimeout(()=>AU.pop(),120);G.flickT=1;M.hunt=Math.max(M.hunt,4);msg('It heard that.',2.5);}
 else if(n===4){AU.whisper();setTimeout(()=>{if(state==='play')spawnPhantom(true);},600);}
 else if(n===5){const fx=Math.sin(P.yaw),fz=Math.cos(P.yaw);let bx=P.x,bz=P.z;for(let s=.5;s<=5;s+=.5){const x=P.x+fx*s,z=P.z+fz*s;if(solid(x,z))break;bx=x;bz=z;}
  if(Math.hypot(bx-P.x,bz-P.z)<2.6)spawnMonsterFar(2);else{M.x=bx;M.z=bz;M.tgt=null;M.active=true;MON.g.visible=true;}
  M.hunt=9;AU.sting(.9);flash('#600',.7);msg('THE EXIT IS OPEN. RUN.',4);}}

function spawnPhantom(force){const fx=-Math.sin(P.yaw),fz=-Math.cos(P.yaw);let Ln=0;for(let s=.5;s<=16;s+=.5){if(solid(P.x+fx*s,P.z+fz*s))break;Ln=s;}
 if(Ln<6){if(force)AU.sting(.5);return;}const d=Math.min(Ln-.6,12);PH.g.position.set(P.x+fx*d,0,P.z+fz*d);PH.g.rotation.y=Math.atan2(-fx,-fz);PH.g.visible=true;PHS.on=true;PHS.seen=0;PHS.life=6;
 PH.arms[0].rotation.x=PH.arms[1].rotation.x=0;PH.hp.rotation.z=.5;}

function kill(){state='dying';G.deathT=0;AU.scream(2.3,1);AU.setGrowl(0);flash('#a00',1);MON.g.visible=true;PH.g.visible=false;}

/* ================= INPUT ================= */
addEventListener('keydown',e=>{keys[e.code]=true;if(e.code==='KeyF'&&state==='play')toggleLight();if(['Space','ArrowUp','ArrowDown'].includes(e.code))e.preventDefault();});
addEventListener('keyup',e=>{keys[e.code]=false;});
function toggleLight(){if(P.battery<=0){AU.click();msg('The battery is dead.',2);return;}P.light=!P.light;AU.click();}
function look(dx,dy,s){P.yaw-=dx*s;P.pitch=clamp(P.pitch-dy*s,-1.35,1.35);}
addEventListener('mousemove',e=>{if(state!=='play')return;if(document.pointerLockElement===canvas)look(e.movementX,e.movementY,.0022);else if(e.buttons&1)look(e.movementX,e.movementY,.004);});
function lock(){if(isTouch)return;try{const p=canvas.requestPointerLock();if(p&&p.catch)p.catch(()=>{});}catch(e){}}
canvas.addEventListener('click',()=>{if(state==='play'&&document.pointerLockElement!==canvas)lock();});
document.addEventListener('pointerlockchange',()=>{if(document.pointerLockElement!==canvas&&state==='play'){state='paused';$('pause').classList.remove('hidden');}});
document.addEventListener('visibilitychange',()=>{if(document.hidden&&state==='play'){state='paused';$('pause').classList.remove('hidden');}});
// touch
const joy={x:0,y:0,id:null,cx:0,cy:0},lk={id:null,x:0,y:0};let touchRun=false;
const tl=$('touch');
tl.addEventListener('touchstart',e=>{for(const t of e.changedTouches){if(t.target.classList.contains('tbtn'))continue;if(t.clientX<innerWidth*.45&&joy.id===null){joy.id=t.identifier;joy.cx=t.clientX;joy.cy=t.clientY;const jb=$('joyBase');jb.style.left=(t.clientX-60)+'px';jb.style.top=(t.clientY-60)+'px';jb.style.bottom='auto';}else if(lk.id===null){lk.id=t.identifier;lk.x=t.clientX;lk.y=t.clientY;}}e.preventDefault();},{passive:false});
tl.addEventListener('touchmove',e=>{for(const t of e.changedTouches){if(t.identifier===joy.id){let dx=(t.clientX-joy.cx)/50,dy=(t.clientY-joy.cy)/50;const l=Math.hypot(dx,dy);if(l>1){dx/=l;dy/=l;}joy.x=dx;joy.y=-dy;$('joyKnob').style.transform=`translate(${dx*35}px,${dy*35}px)`;}else if(t.identifier===lk.id){if(state==='play')look(t.clientX-lk.x,t.clientY-lk.y,.006);lk.x=t.clientX;lk.y=t.clientY;}}e.preventDefault();},{passive:false});
const tend=e=>{for(const t of e.changedTouches){if(t.identifier===joy.id){joy.id=null;joy.x=joy.y=0;$('joyKnob').style.transform='';}if(t.identifier===lk.id)lk.id=null;}};
tl.addEventListener('touchend',tend);tl.addEventListener('touchcancel',tend);
$('btnLight').addEventListener('touchstart',e=>{e.preventDefault();e.stopPropagation();if(state==='play')toggleLight();},{passive:false});
$('btnRun').addEventListener('touchstart',e=>{e.preventDefault();e.stopPropagation();touchRun=true;},{passive:false});
$('btnRun').addEventListener('touchend',e=>{e.preventDefault();touchRun=false;},{passive:false});

/* ================= UI ================= */
document.querySelectorAll('.opt').forEach(b=>b.onclick=()=>{document.querySelectorAll('.opt').forEach(o=>o.classList.remove('on'));b.classList.add('on');resH=+b.dataset.r;resize();});
function startGame(){AU.init();const y=buildLevel();resetPlayer(y);state='play';['menu','dead','win','pause'].forEach(i=>$(i).classList.add('hidden'));$('hud').classList.remove('hidden');if(isTouch)tl.style.display='block';lock();msg('Find the 5 torn pages. [F] flashlight',4.5);}
$('startBtn').onclick=startGame;$('retryBtn').onclick=startGame;$('againBtn').onclick=startGame;
$('resumeBtn').onclick=()=>{$('pause').classList.add('hidden');state='play';lock();};
function fmt(t){const m=Math.floor(t/60),s=Math.floor(t%60);return m+':'+(s<10?'0':'')+s;}
function endScreen(id,txt){$(id).classList.remove('hidden');$(id==='dead'?'deadStat':'winStat').textContent=txt;$('hud').classList.add('hidden');tl.style.display='none';if(document.pointerLockElement)document.exitPointerLock();}

/* ================= UPDATE ================= */
const lerpA=(a,b,t)=>{const d=((b-a+Math.PI*3)%(Math.PI*2))-Math.PI;return a+d*Math.min(1,t);};
function updatePlayer(dt){
 let f=0,s=0;if(keys.KeyW||keys.ArrowUp)f++;if(keys.KeyS||keys.ArrowDown)f--;if(keys.KeyA||keys.ArrowLeft)s--;if(keys.KeyD||keys.ArrowRight)s++;f+=joy.y;s+=joy.x;
 const len=Math.hypot(f,s);if(len>1){f/=len;s/=len;}
 const want=(keys.ShiftLeft||keys.ShiftRight||touchRun)&&len>.1&&f>.1;
 if(want&&P.stamina>0&&!P.ex){P.sprinting=true;P.stamina-=dt/4.5;if(P.stamina<=0){P.stamina=0;P.ex=true;AU.burst({f:900,q:.6,vol:.18,dur:.5,a:.1});}}
 else{P.sprinting=false;P.stamina=Math.min(1,P.stamina+dt/(P.ex?7:5));if(P.ex&&P.stamina>.35)P.ex=false;}
 const sp=P.sprinting?5.2:2.8,sy=Math.sin(P.yaw),cy=Math.cos(P.yaw);
 const tvx=(-sy*f+cy*s)*sp,tvz=(-cy*f-sy*s)*sp,k=Math.min(1,dt*10);P.vx+=(tvx-P.vx)*k;P.vz+=(tvz-P.vz)*k;
 const nx=P.x+P.vx*dt;if(!blocked(nx,P.z,PR))P.x=nx;else P.vx*=.3;const nz=P.z+P.vz*dt;if(!blocked(P.x,nz,PR))P.z=nz;else P.vz*=.3;
 const v=Math.hypot(P.vx,P.vz);P.bob+=dt*v*2.3;const sb=Math.sin(P.bob);if(v>.5&&Math.sign(sb)!==Math.sign(P.lastS))AU.step(P.sprinting?.33:.2,0,false);P.lastS=sb;
 const amp=Math.min(1,v/3);
 camera.position.set(P.x,1.6+Math.abs(sb)*.06*amp-.02,P.z);
 camera.rotation.set(P.pitch+Math.sin(G.time*1.3)*.004,P.yaw+Math.sin(G.time*.7)*.003,Math.sin(P.bob*.5)*.008*amp+Math.sin(G.time*3)*.01*G.fear);
 if(P.light&&P.battery>0){P.battery-=dt/190;if(P.battery<=0){P.battery=0;P.light=false;AU.click();msg('Your flashlight died.',2.5);}}
 for(const p of pages)if(!p.got&&Math.hypot(p.x-P.x,p.z-P.z)<1.55)collectPage(p);
 for(const b of batteries)if(!b.got&&Math.hypot(b.x-P.x,b.z-P.z)<1.0){b.got=true;b.m.visible=false;P.battery=Math.min(1,P.battery+.55);AU.click();AU.tone({f:660,vol:.06,dur:.15});msg('Battery +',1.5);}
 for(const b of batteries)if(!b.got)b.m.rotation.y+=dt;
 if(Math.hypot(exitInfo.x-P.x,exitInfo.z-P.z)<1.75){if(G.pages>=5){state='win';AU.winS();AU.setGrowl(0);endScreen('win',`You escaped in ${fmt(G.time)}. It will wait for the next one.`);}
  else if(G.time>G.exitMsgT){G.exitMsgT=G.time+3;AU.locked();msg(`Sealed. ${5-G.pages} page${5-G.pages>1?'s':''} remain.`,2.5);}}
}
function updateMonster(dt){
 if(!M.active)return;
 const pgx=Math.floor(P.x/T),pgz=Math.floor(P.z/T),dx=P.x-M.x,dz=P.z-M.z,d=Math.hypot(dx,dz);
 const see=d<22&&los(M.x,M.z,P.x,P.z)&&((P.light&&P.battery>0)||d<5.5||(P.sprinting&&d<10));
 if(see){if(M.hunt<=0&&d>4){AU.sting(.55);flash('#400',.4);}M.hunt=6;M.lk=[pgx,pgz];M.unseen=0;}else{M.hunt-=dt;M.unseen+=dt;}
 const hunting=M.hunt>0;
 M.knowT-=dt;if(M.knowT<=0){M.knowT=4+Math.random()*5;M.lk=[pgx,pgz];}
 M.pathT-=dt;if(M.pathT<=0){M.pathT=.35;M.field=bfs(M.lk[0],M.lk[1]);}
 const speed=hunting?3.2+G.pages*.17:1.5+G.pages*.12;let tx,tz;
 if(hunting&&see&&d<7){tx=P.x;tz=P.z;M.direct=true;}
 else{const mgx=Math.floor(M.x/T),mgz=Math.floor(M.z/T);if(M.direct||!M.tgt){M.direct=false;M.tgt=[cellC(mgx),cellC(mgz)];}
  if(Math.hypot(M.tgt[0]-M.x,M.tgt[1]-M.z)<.15){const f=M.field;let best=null,bv=f?f[mgz*GW+mgx]:-1;
   if(f&&bv>0)for(const[a,b]of D4){const nx=mgx+a,nz=mgz+b;if(!isFloor(nx,nz))continue;const v=f[nz*GW+nx];if(v>=0&&v<bv){bv=v;best=[nx,nz];}}
   if(!best){const o=D4.filter(([a,b])=>isFloor(mgx+a,mgz+b));if(o.length){const r=o[(Math.random()*o.length)|0];best=[mgx+r[0],mgz+r[1]];}}
   if(best)M.tgt=[cellC(best[0]),cellC(best[1])];}
  tx=M.tgt[0];tz=M.tgt[1];}
 const mx=tx-M.x,mz=tz-M.z,ml=Math.hypot(mx,mz);let moving=false;
 if(ml>.02){const s=Math.min(ml,speed*dt);M.x+=mx/ml*s;M.z+=mz/ml*s;moving=true;}
 M.rot=lerpA(M.rot,(hunting&&see)?Math.atan2(dx,dz):Math.atan2(mx,mz),dt*7);
 M.phase+=dt*(moving?speed*2.3:.6);const sp=Math.sin(M.phase);
 MON.legs[0].rotation.x=sp*.7;MON.legs[1].rotation.x=-sp*.7;
 const reach=hunting?-1.1:0;MON.arms[0].rotation.x=-sp*.5+reach;MON.arms[1].rotation.x=sp*.5+reach;
 MON.hp.rotation.z=Math.sin(G.time*13)*.05+(Math.random()<.02?(Math.random()-.5)*1.2:0)*.5+.25;MON.hp.rotation.y=Math.sin(G.time*2.1)*.2;
 MON.up.rotation.x=.28+(hunting?.15:0);
 MON.g.position.set(M.x,Math.abs(sp)*.07,M.z);MON.g.rotation.y=M.rot;
 const rx=Math.cos(P.yaw),rz=-Math.sin(P.yaw),pan=d>.01?(-dx*rx+-dz*rz)/d:0;
 if(moving&&Math.sign(sp)!==Math.sign(M.lastS)){const v=clamp(1-d/24,0,1);if(v>0)AU.step(v*.7,pan,true);}M.lastS=sp;
 AU.setGrowl(clamp(1-d/18,0,1)*(hunting?.5:.22),pan);
 M.tpT-=dt;if(M.tpT<=0){M.tpT=3;if(!hunting&&M.unseen>22){const f=bfs(pgx,pgz);const mv=f[Math.floor(M.z/T)*GW+Math.floor(M.x/T)];if(mv>13){spawnMonsterFar(7);AU.slam();}}}
 if(d<.95)kill();
 G.fear=clamp(1-(d-2)/15,0,1)*(hunting?1:.55);
}
function updatePhantom(dt){if(!PHS.on)return;PHS.life-=dt;const px=PH.g.position.x,pz=PH.g.position.z,dx=px-P.x,dz=pz-P.z,d=Math.hypot(dx,dz);
 const fx=-Math.sin(P.yaw)*Math.cos(P.pitch),fz=-Math.cos(P.yaw)*Math.cos(P.pitch);const dot=(fx*dx+fz*dz)/Math.max(.01,d);
 if(dot>.9&&los(P.x,P.z,px,pz))PHS.seen+=dt;
 if(PHS.seen>.55||d<4.5||PHS.life<0){PH.g.visible=false;PHS.on=false;if(PHS.seen>.3||d<4.5){AU.sting(.7);G.flickT=.7;flash('#300',.4);}}}
function updateEvents(dt){
 G.ambT-=dt;if(G.ambT<=0){G.ambT=12+Math.random()*16;const r=Math.random();if(r<.4)AU.metal();else if(r<.65)AU.slam();else if(G.pages>0)AU.whisper();else AU.metal();}
 if(G.pages<1)return;G.eventT-=dt;
 if(G.eventT<=0&&M.hunt<=0){G.eventT=32+Math.random()*30;const r=Math.random();
  if(r<.4)spawnPhantom(false);else if(r<.6){G.flickT=1.5;AU.pop();}else if(r<.8){AU.whisper();setTimeout(()=>AU.whisper(),900);}
  else{G.blackT=1.1;setTimeout(()=>{if(state==='play'){showScare(.12);AU.burst({type:'highpass',f:1500,vol:.6,dur:.15});}},900);}}
}
function updateLights(dt){
 if(G.blackT>0)G.blackT-=dt;if(G.flickT>0)G.flickT-=dt;
 G.lightMul=G.blackT>0?0:G.flickT>0?(Math.random()<.45?1:.04):1;
 for(const l of lamps){if(l.mode===2)l.lv=0;else if(l.mode===1){l.t-=dt;if(l.t<=0){l.on=!l.on;l.t=l.on?.05+Math.random()*1.8:.03+Math.random()*.25;}l.lv=l.on?1:.06;}else l.lv=.9+Math.random()*.1;
  const v=l.lv*G.lightMul;l.mat.color.setRGB(Math.max(.12,v),Math.max(.12,.82*v),Math.max(.12,.55*v));}
 G.poolT-=dt;if(G.poolT<=0){G.poolT=.3;const s=lamps.filter(l=>l.mode!==2).map(l=>[l,(l.x-P.x)**2+(l.z-P.z)**2]).sort((a,b)=>a[1]-b[1]);
  pool.forEach((p,i)=>{p.lamp=s[i]?s[i][0]:null;if(p.lamp)p.l.position.set(p.lamp.x,H-.45,p.lamp.z);});}
 pool.forEach(p=>p.l.intensity=p.lamp?p.lamp.lv*G.lightMul*1.15:0);
 let si=(P.light&&P.battery>0)?1.8:0;if(si&&P.battery<.2&&Math.random()<.12)si*=.1;if(G.blackT>0)si=0;if(state==='dying')si=2.4;
 spot.intensity=si;exitLight.intensity=(G.pages>=5?1.4:.7)*(G.blackT>0?0:1)*(.85+Math.random()*.15);
}
function updateDying(dt){
 G.deathT+=dt;const t=G.deathT;const fx=-Math.sin(P.yaw),fz=-Math.cos(P.yaw);
 MON.g.position.set(P.x+fx*.72,-.62,P.z+fz*.72);MON.g.rotation.y=Math.atan2(-fx,-fz);MON.up.rotation.x=.35;
 MON.arms[0].rotation.x=-1.5+Math.sin(t*30)*.1;MON.arms[1].rotation.x=-1.4+Math.cos(t*27)*.1;MON.hp.rotation.z=Math.sin(t*40)*.15;
 const sh=Math.max(0,1-t/2.2)*.06;camera.position.set(P.x+(Math.random()-.5)*sh,1.6+(Math.random()-.5)*sh,P.z+(Math.random()-.5)*sh);
 camera.lookAt(P.x+fx*.72,1.62,P.z+fz*.72);camera.rotation.z+=(Math.random()-.5)*sh*2;
 camera.fov=72-Math.min(t*28,26);camera.updateProjectionMatrix();
 if(t>.32&&t<.36&&G.scareT<=0)showScare(1.3);
 if(t>2.3){state='dead';scareC.style.display='none';G.scareT=0;endScreen('dead',`Pages: ${G.pages}/5 · Survived ${fmt(G.time)}`);}
}
const nctx=$('noise').getContext('2d'),nimg=nctx.createImageData(160,120),nbuf=new Uint32Array(nimg.data.buffer);let nf=0;
function updateFX(dt){
 if(G.msgT>0){G.msgT-=dt;if(G.msgT<=0)$('msg').style.opacity=0;}
 if(G.noteT>0){G.noteT-=dt;if(G.noteT<=0)$('note').style.opacity=0;}
 if(G.flash>0){G.flash=Math.max(0,G.flash-dt*2.2);}const fl=$('flash');fl.style.background=G.flashCol;fl.style.opacity=G.flash;
 if(G.scareT>0){G.scareT-=dt;G.scareTick-=dt;if(G.scareTick<=0){G.scareTick=.05;drawFace(scareG,200,150,R());const s=1.05+R()*.35;scareC.style.transform=`translate(${(R()-.5)*40}px,${(R()-.5)*40}px) scale(${s}) rotate(${(R()-.5)*6}deg)`;scareC.style.filter=R()<.3?'invert(1) contrast(2)':'contrast(1.4) saturate(1.5)';scareC.style.opacity=R()<.15?.3:1;}
  if(G.scareT<=0)scareC.style.display='none';}
 const fear=state==='play'?G.fear:0;$('fearTint').style.opacity=fear*.6;$('noise').style.opacity=.06+fear*.16+(state==='dying'?.3:0);
 if(++nf%2===0){for(let i=0;i<nbuf.length;i++){const v=(Math.random()*255)|0;nbuf[i]=0xff000000|(v<<16)|(v<<8)|v;}nctx.putImageData(nimg,0,0);}
 if(state==='play'&&fear>.08){G.heartT-=dt;if(G.heartT<=0){G.heartT=1.2-fear*.8;AU.heart(.25+fear*.5);}}
 $('batFill').style.width=(P.battery*100)+'%';$('batFill').style.opacity=P.light?1:.35;$('stamFill').style.width=(P.stamina*100)+'%';$('stamBar').style.opacity=P.stamina<.99?1:.35;$('stamFill').style.background=P.ex?'#7a2a2a':'';
}
let last=performance.now();
function loop(now){requestAnimationFrame(loop);const dt=Math.min(.05,(now-last)/1000);last=now;
 if(state==='play'){G.time+=dt;updatePlayer(dt);updateMonster(dt);updatePhantom(dt);updateEvents(dt);if(!M.active)G.fear=0;if(state==='play')for(const p of pages)if(!p.got)p.m.rotation.z=Math.sin(G.time*2+p.x)*.06;}
 else if(state==='dying')updateDying(dt);
 if(level){updateLights(dt);}
 updateFX(dt);
 if(level)renderer.render(scene,camera);}
// menu background: build a level for ambience behind menu
(function(){const y=buildLevel();P.x=cellC(1);P.z=cellC(1);P.yaw=y;camera.position.set(P.x,1.6,P.z);camera.rotation.set(0,y,0);})();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>

Game Source: HOLLOW WARD — Low-Poly Survival Horror

Creator: EpicFalcon36

Libraries: three

Complexity: complex (548 lines, 54.0 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: hollow-ward-low-poly-survival-horror-epicfalcon36" to link back to the original. Then publish at arcadelab.ai/publish.