Ward Zero: Medicine Run
by StealthTiger281309 lines94.1 KB
<!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,viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Ward Zero: Medicine Run</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nosifer&display=swap" rel="stylesheet">
<style>
:root{--red:#b31522;--pale:#dfe7df;--glass:rgba(8,12,14,.62)}
*{box-sizing:border-box;-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#000;color:#fff;font-family:system-ui,-apple-system,Segoe UI,sans-serif;touch-action:none}
#game{position:fixed;inset:0;width:100%;heigg:pixelated}
#hud{position:fixed;inset:0;pointer-events:none}
#top{position:absolute;left:max(12px,env(safe-area-inset-left));right:max(12px,env(safe-area-inset-right));top:max(10px,env(safe-area-inset-top));display:flex;justify-content:space-between;gap:10px;font-weight:800;text-shadow:0 2px 4px #000}
.pill{padding:8px 11px;border:1px solid #ffffff33;border-radius:12px;background:var(--glass);backdrop-filter:blur(5px)}
#mus{position:absolute;left:max(12px,env(safe-area-inset-left));top:calc(max(10px,env(safe-area-inset-top)) + 50px);width:42px;height:34px;padding:0;border:1px solid #ffffff33;border-radius:10px;background:var(--glass);color:#fff;font-size:16px;font-weight:900;pointer-events:auto;touch-action:none}
#mmWrap{position:absolute;right:max(12px,env(safe-area-inset-right));top:calc(max(10px,env(safe-area-inset-top)) + 50px);width:min(186px,20vw);display:flex;flex-direction:column;align-items:flex-end}
#mm{display:block;width:100%;height:auto;border:1px solid #ffffff55;border-radius:8px;background:#050809;image-rendering:pixelated;box-shadow:0 4px 18px #0009}
#mmInfo{margin-top:5px;text-align:right;white-space:nowrap;font-size:11px;font-weight:800;letter-spacing:.06em;line-height:1.45;color:#cfe6d8;text-shadow:0 2px 4px #000}
#roomTag{position:absolute;left:50%;top:calc(max(10px,env(safe-area-inset-top)) + 56px);transform:translateX(-50%);white-space:nowrap;font-size:12px;font-weight:800;letter-spacing:.38em;color:#cfe6d8;text-shadow:0 2px 6px #000;opacity:0;transition:opacity .6s}
#message{position:absolute;top:max(17%,100px);left:50%;transform:translateX(-50%);max-width:70%;text-align:center;text-shadow:0 3px 8px #000;opacity:0;transition:opacity .22s}
#msgMain{font-size:clamp(16px,4vw,24px);font-weight:900}
#msgSub{margin-top:6px;font-size:clamp(12px,2.6vw,15px);font-weight:600;line-height:1.35;color:#d7e2da;display:none}
#crosshair{position:absolute;left:50%;top:50%;width:14px;height:14px;transform:translate(-50%,-50%);opacity:.65}
#crosshair:before,#crosshair:after{content:"";position:absolute;background:#fff}
#crosshair:before{width:14px;height:2px;top:6px}#crosshair:after{height:14px;width:2px;left:6px}
#grab{position:absolute;left:50%;top:62%;width:min(240px,42vw);height:10px;transform:translateX(-50%);border:1px solid #ffffff66;border-radius:6px;background:#000a;overflow:hidden;opacity:0;transition:opacity .12s}
#grabFill{display:block;height:100%;width:0;background:linear-gradient(90deg,#ff6a4d,#b31522)}
#controls{position:fixed;inset:0;pointer-events:none}
#joy{position:absolute;left:max(18px,env(safe-area-inset-left));bottom:max(22px,env(safe-area-inset-bottom));width:132px;height:132px;border-radius:50%;background:#ffffff12;border:2px solid #ffffff38;pointer-events:auto;touch-action:none}
#stick{position:absolute;left:39px;top:39px;width:52px;height:52px;border-radius:50%;background:#ffffff55;border:2px solid #ffffff88;box-shadow:0 3px 15px #0008}
#look{position:absolute;left:42%;right:0;top:16%;bottom:0;pointer-events:auto;touch-action:none}
.btn{position:absolute;right:max(18px,env(safe-area-inset-right));width:76px;height:76px;border-radius:50%;border:2px solid #ffffff55;color:#fff;background:#13191dcc;font-weight:900;font-size:13px;pointer-events:auto;touch-action:none;box-shadow:0 4px 18px #0009}
#flash{bottom:max(26px,env(safe-area-inset-bottom))}#run{bottom:calc(max(26px,env(safe-area-inset-bottom)) + 90px);right:calc(max(18px,env(safe-area-inset-right)) + 18px);width:64px;height:64px}
.act{position:absolute;right:calc(max(18px,env(safe-area-inset-right)) + 106px);width:118px;height:50px;border-radius:26px;border:2px solid #ffffff77;color:#fff;background:#1c3a2ee6;font-weight:900;font-size:14px;letter-spacing:.06em;pointer-events:auto;touch-action:none;display:none;align-items:center;justify-content:center;box-shadow:0 4px 18px #000a}
#act1{bottom:calc(max(26px,env(safe-area-inset-bottom)) + 12px)}#act2{bottom:calc(max(26px,env(safe-area-inset-bottom)) + 76px)}
#end{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;padding:16px;background:radial-gradient(circle at 50% 34%,#263034,#07090a 55%,#000);z-index:20}
#hud,#controls{display:none}
body.playing #hud{display:block}
body.playing #controls{display:block}
#start{position:fixed;inset:0;z-index:20;overflow:hidden}
#startScrim{position:absolute;inset:0;pointer-events:none;background:linear-gradient(to bottom,rgba(2,3,2,.92) 0%,rgba(2,3,2,.38) 24%,rgba(2,3,2,.12) 52%,rgba(2,3,2,.5) 76%,rgba(1,1,1,.95) 100%)}
#startFrame{position:absolute;inset:9px;border:1px solid #ffffff26;pointer-events:none}
#startTop{position:absolute;left:0;right:0;top:max(26px,calc(env(safe-area-inset-top) + 18px));text-align:center;padding:0 16px;pointer-events:none}
#startTitle{font-family:'Nosifer',system-ui,sans-serif;font-weight:800;font-size:clamp(28px,9.5vw,52px);line-height:1;letter-spacing:.01em;margin:0;color:#cabfa9;-webkit-text-stroke:1.3px #180b06;text-shadow:0 1px 0 #6b4626,0 2px 0 #55371c,0 3px 0 #402711,0 5px 10px #000000d0,0 0 30px #00000090}
#startSub{margin-top:8px;font-weight:800;letter-spacing:.48em;font-size:clamp(11px,2.8vw,15px);color:#c07a52;text-shadow:0 2px 6px #000}
#startInfo{position:absolute;left:0;right:0;bottom:max(132px,calc(env(safe-area-inset-bottom) + 128px));text-align:center;padding:0 22px;pointer-events:none}
#startInfo p{max-width:520px;margin:0 auto;font-size:12px;line-height:1.55;color:#d3dad2;text-shadow:0 2px 5px #000}
#startInfo b{color:#f0f4ee}
#diffs{position:absolute;left:0;right:0;bottom:max(18px,env(safe-area-inset-bottom));display:flex;justify-content:center;gap:10px;padding:0 16px}
.cube{flex:1 1 0;max-width:152px;min-width:0;padding:18px 4px 15px;border:0;border-radius:2px;font-family:inherit;cursor:pointer;touch-action:manipulation;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0;transform:translateY(0);transition:transform .08s,box-shadow .08s}
.cube b{display:block;font-weight:900;font-size:clamp(12px,3.6vw,17px);letter-spacing:.05em;line-height:1.3}
.cube:active{transform:translateY(5px)}
.cube-easy{background:linear-gradient(155deg,#68b95d,#2f7a30);box-shadow:0 7px 0 #1c4d1e,0 12px 18px #0009;color:#e9f6e6;text-shadow:0 1px 2px #163018}
.cube-easy:active{box-shadow:0 2px 0 #1c4d1e,0 5px 10px #0009}
.cube-med{background:linear-gradient(155deg,#e0aa46,#a8721f);box-shadow:0 7px 0 #6b4712,0 12px 18px #0009;color:#2c1c08;text-shadow:0 1px 1px #f1cf8e}
.cube-med:active{box-shadow:0 2px 0 #6b4712,0 5px 10px #0009}
.cube-hard{background:linear-gradient(155deg,#a24d47,#6c2b28),radial-gradient(circle at 28% 24%,#0000004d 0 14%,transparent 44%),radial-gradient(circle at 74% 66%,#0000004a 0 16%,transparent 48%);background-blend-mode:normal,multiply,multiply;box-shadow:0 7px 0 #3c1311,0 12px 18px #0009;color:#f7e5e1;text-shadow:0 1px 2px #300}
.cube-hard:active{box-shadow:0 2px 0 #3c1311,0 5px 10px #0009}
.card{max-width:680px;max-height:100%;overflow:auto;touch-action:pan-y;padding:22px;border:1px solid #ffffff2e;border-radius:20px;background:#091014dd;box-shadow:0 20px 80px #000}
h1{letter-spacing:.08em;margin:.15em 0;color:#e6eee8}p{line-height:1.45;color:#ced6d1;margin:.6em 0}
.primary{padding:15px 24px;border:0;border-radius:12px;background:var(--red);color:white;font-size:18px;font-weight:900}
.small{font-size:13px;opacity:.75}
#diffs{display:flex;gap:10px;justify-content:center;margin:14px 0 6px}
.dbtn{flex:1 1 0;min-width:0;padding:12px 8px;border:2px solid #ffffff44;border-radius:14px;background:#101c22;color:#fff;font-family:inherit;touch-action:manipulation}
.dbtn b{display:block;font-size:18px;letter-spacing:.1em;margin-bottom:4px}
.dbtn span{display:block;font-size:11.5px;line-height:1.35;opacity:.8;font-weight:600}
#dEasy b{color:#7dffb0}#dMed b{color:#ffe27a}#dHard b{color:#ff6a5a}
#end{display:none}
#err{position:fixed;left:8px;right:8px;top:8px;z-index:40;display:none;padding:8px 10px;border:1px solid #ff6a5a;border-radius:8px;background:#200000ee;color:#ffb3a8;font:12px/1.35 ui-monospace,Menlo,monospace;pointer-events:none;white-space:pre-wrap;word-break:break-word}
#damage{position:fixed;inset:0;pointer-events:none;z-index:8;box-shadow:inset 0 0 90px 30px #b00000;opacity:0;transition:opacity .15s}
#fade{position:fixed;inset:0;background:#000;opacity:0;pointer-events:none;z-index:15;transition:opacity .3s}
</style>
</head>
<body>
<canvas id="game"></canvas><div id="damage"></div><div id="fade"></div>
<div id="hud"><div id="top"><div class="pill" id="objective">MEDICINES 0/8</div><div class="pill" id="battery">FLASHLIGHT 100%</div></div><button id="mus">♪</button><div id="roomTag"></div><div id="message"><div id="msgMain"></div><div id="msgSub"></div></div><div id="crosshair"></div><div id="grab"><i id="grabFill"></i></div><div id="mmWrap"><canvas id="mm" width="186" height="132"></canvas><div id="mmInfo"></div></div></div>
<div id="controls"><div id="joy"><div id="stick"></div></div><div id="look"></div><button class="btn" id="run">RUN</button><button class="btn" id="flash">LIGHT</button><button class="act" id="act1"></button><button class="act" id="act2"></button></div>
<div id="start"><div id="startScrim"></div><div id="startFrame"></div>
<div id="startTop"><h1 id="startTitle">MEDICINE RUN</h1><div id="startSub">SURVIVAL HORROR</div></div>
<div id="startInfo"><p><b>Saint Dymphna Hospital</b> — find 8 medicines across 3 floors and whatever's below them, then the master key. Left thumb moves, right side looks. Hide in a cupboard or bed if it hunts you; it needs 2 full seconds of contact to finish you.</p></div>
<div id="diffs">
<button class="cube cube-easy" id="dEasy"><b>EASY</b><b>MODE</b></button>
<button class="cube cube-med" id="dMed"><b>MEDIUM</b><b>MODE</b></button>
<button class="cube cube-hard" id="dHard"><b>HARD</b><b>MODE</b></button>
</div></div>
<div id="end"><div class="card"><h1 id="endTitle">YOU ESCAPED</h1><p id="endText"></p><button class="primary" id="again">PLAY AGAIN</button></div></div>
<div id="err"></div>
<script>
'use strict';
const $=id=>document.getElementById(id);
let errShown=0;
function reportError(e){ // shows a problem on screen instead of failing silently
try{console.error(e)}catch(_){}
if(errShown++>2)return;
const el=document.getElementById('err');if(!el)return;
el.style.display='block';
el.textContent='Game error (please screenshot this):\n'+(e&&e.message?e.message:String(e))+(e&&e.stack?'\n'+String(e.stack).split('\n').slice(0,3).join('\n'):'');
}
addEventListener('error',ev=>reportError(ev.error||ev.message));
const canvas=$('game'),ctx=canvas.getContext('2d',{alpha:false}),mm=$('mm'),mctx=mm.getContext('2d');
/* =====================================================================
TUNING — change these numbers freely. Easy / Medium / Hard just override some of them (see DIFFS).
===================================================================== */
const TUNE={
touchTime:2, // seconds the monster must keep touching you before it kills you
touchRange:0.8, // how close (in map cells) the monster must be to count as touching
touchDecay:1, // once it lets go, the grab meter drains at this speed (1 = real time)
grabSlow:0.85, // your speed while it is touching you — hold RUN to break free
walk:1.55, run:2.45,
stamDrain:20, stamRegen:14, // sprint stamina per second (stamDrain 0 = infinite sprint)
batDrain:0.27, batPack:35, // torch % lost per second / % gained per battery pack
monPatrol:0.8, monSearch:0.9, monHunt:1.0, monFinal:1.4, // monster speeds, cells per second (monFinal = after you take the master key)
monStart:2, // the monster wakes up after you have collected this many medicines
hearMul:1, sightMul:1, // how well it hears / sees you
wakeT:0, // it wakes up after this many seconds even if you have found nothing (0 = only after monStart medicines)
roam:60, // if it is on another floor for this many seconds it wanders back to yours
seek:.25, // chance that a patrol heads toward wherever you are
minChase:5, // it chases for at least this long, then vanishes the moment you break line of sight
cool:12, // after vanishing it ignores you for this many seconds
findTime:4.5, // seconds it needs to sniff out your hiding place (0 = it never finds you)
ambMul:1, flickMul:1, scareGap:30, // brightness of the dim corridor lights / light flicker / seconds between spooky noises
musVol:.85, dis:.65, noteGap:5, // music loudness / how dissonant / seconds between music-box notes
eyeGlow:1, beam:10, vig:.76, reveal:5, blip:0, // monster eye glow / torch range / screen edge darkness / minimap reveal radius / show monster blip when near
need:8, lv:2 // medicines needed / how many batteries + notes are lying around (1 = few, 3 = lots)
};
const DIFFS={
easy:{name:'EASY',need:5,lv:3,batDrain:.13,batPack:50,touchTime:3,stamDrain:15,monPatrol:.6,monSearch:.7,monHunt:.8,monFinal:1.0,monStart:3,hearMul:.6,sightMul:.7,roam:110,seek:.1,minChase:3,cool:22,findTime:0,
ambMul:1.3,flickMul:.3,scareGap:55,musVol:.6,dis:.2,noteGap:9,eyeGlow:.6,beam:12,vig:.68,reveal:7,blip:1},
medium:{name:'MEDIUM'},
hard:{name:'HARD',lv:1,batDrain:.55,batPack:25,stamDrain:24,monPatrol:.95,monSearch:1.1,monHunt:1.25,monFinal:1.8,monStart:1,wakeT:90,hearMul:1.3,sightMul:1.25,roam:30,seek:.5,minChase:9,cool:6,findTime:2.8,
ambMul:.7,flickMul:2,scareGap:16,musVol:1,dis:1,noteGap:3.4,eyeGlow:1.2,beam:8.5,vig:.84,reveal:4}
};
let NEED=8,DIFF='medium';
/* =====================================================================
LEVEL — Saint Dymphna Hospital, three floors that share the same footprint
0 floor · 1 wall · 2 blood-streaked wall
===================================================================== */
const FLOORS=[
{name:'1ST FLOOR',
rows:[
'1111111111111111111111111111111',
'1000000000000001000000000000001',
'1000000000000000000000000000001',
'1001101111102111111011111111001',
'1001000001000001000001000001001',
'1001000001000001000001000001001',
'1001000001000001000001000001002',
'1001000001000001000001000001001',
'2001000001000001000001000001001',
'1001111011211011101111110111011',
'1000000000000001000000000000001',
'1000000001000000000001000000001',
'1101110111101111111121122211001',
'1001000001000001000001000001001',
'2002000001000001000002000002001',
'1002000001000002000002000002001',
'1002000001000001000002000002001',
'1001000001000001000001000001001',
'1001122211111011120211120211001',
'1000000000000000000000000000001',
'1000000010000000000000100000001',
'1111111111111111111111111111111'],
rooms:[[1,1,2,2,1],[4,4,8,8,2],[10,4,14,8,3],[16,4,20,8,4],[22,4,26,8,5],[4,13,8,17,6],[10,13,14,17,7],[16,13,20,17,8],[22,13,26,17,9]],
props:[
[4,5,'shelf'],[4,6,'shelf'],[4,7,'shelf'],[8,4,'shelf'],[8,5,'shelf'],[6,6,'table'],[7,6,'table'],[10,4,'cabinet'],[14,4,'cabinet'],
[10,6,'table'],[11,6,'table'],[13,6,'table'],[14,6,'table'],[14,8,'crate'],[16,5,'cabinet'],[16,6,'cabinet'],[20,5,'cabinet'],[20,6,'cabinet'],
[18,6,'table'],[16,8,'crate'],[22,5,'bed'],[22,7,'bed'],[26,5,'bed'],[26,7,'bed'],[22,4,'cabinet'],[26,4,'cabinet'],[24,4,'table'],
[5,14,'body'],[7,14,'body'],[5,16,'body'],[7,16,'gurney'],[4,14,'fridge'],[4,15,'fridge'],[4,16,'fridge'],[4,17,'fridge'],[5,17,'fridge'],
[7,17,'fridge'],[8,17,'fridge'],[10,14,'bed'],[10,16,'bed'],[12,14,'bed'],[12,16,'bed'],[14,15,'bed'],[14,13,'cabinet'],[18,15,'gurney'],
[16,14,'table'],[20,14,'table'],[16,17,'cabinet'],[20,17,'cabinet'],[22,15,'crate'],[26,15,'crate'],[22,17,'bed'],[26,17,'bed'],[22,13,'cabinet'],
[26,13,'cabinet'],[24,13,'pedestal'],[2,6,'gurney'],[1,15,'crate'],[24,2,'gurney'],[29,7,'crate'],[28,15,'gurney'],[5,19,'crate'],[21,20,'gurney'],
[27,11,'crate'],[20,10,'crate']],
way:[[6,5],[12,6],[18,5],[24,6],[6,15],[12,15],[18,14],[24,15],[28,2],[28,19],[2,19],[15,11],[3,10],[27,10],[15,2],[15,20],[2,10],[28,10]]},
{name:'2ND FLOOR',
rows:[
'1111111111111111111111111111111',
'1000000000000000000010000000001',
'1000000000000000000000000000001',
'1001111011112011101112111112001',
'1001000001000001000001000001001',
'1001000001000001000001000001001',
'1001000001000001000001000002001',
'1001000001000001000002000001001',
'1101000001000001000001000001001',
'1001101111101111111011111011001',
'1000000001000000000001000000001',
'1000000000000001000000000000001',
'2001101111110111101111111111001',
'1001000001000002000001000001001',
'1001000001000002000001000001011',
'1002000001000001000002000001001',
'1001000002000001000001000001002',
'1001000002000001000001000001001',
'1001111011111111111021111011001',
'1000000000000000000000000000001',
'1000000000100000000000000000001',
'1111111111111111111111111111111'],
rooms:[[4,4,8,8,10],[10,4,14,8,11],[16,4,20,8,12],[22,4,26,8,13],[4,13,8,17,15],[10,13,14,17,16],[16,13,20,17,17],[22,13,26,17,14]],
props:[
[6,6,'table'],[7,7,'table'],[4,8,'shelf'],[4,6,'shelf'],[8,5,'fridge'],[8,7,'machine'],[12,6,'bassinet'],[13,7,'bassinet'],[11,5,'bassinet'],
[11,4,'cabinet'],[10,8,'cabinet'],[18,7,'scanner'],[20,5,'locker'],[16,5,'locker'],[17,8,'locker'],[19,4,'cabinet'],[25,5,'bed'],[23,6,'bed'],
[24,7,'bed'],[22,8,'locker'],[22,5,'locker'],[24,4,'locker'],[6,16,'bed'],[6,14,'bed'],[7,15,'bed'],[8,17,'cabinet'],[6,17,'cabinet'],
[8,16,'locker'],[13,15,'bed'],[11,14,'bed'],[11,16,'table'],[14,14,'crate'],[10,14,'crate'],[14,16,'cabinet'],[16,15,'crate'],[18,13,'crate'],
[20,16,'crate'],[16,16,'crate'],[18,15,'crate'],[17,16,'crate'],[16,14,'shelf'],[20,17,'shelf'],[19,13,'locker'],[25,15,'bed'],[23,16,'bed'],
[24,14,'bed'],[25,13,'monitor'],[24,17,'monitor'],[23,17,'cabinet'],[29,9,'gurney'],[10,2,'crate'],[16,20,'bed'],[23,2,'gurney'],[20,4,'crate'],
[22,4,'crate'],[24,13,'crate'],[14,15,'crate']],
way:[[6,5],[12,5],[18,6],[24,6],[6,15],[12,15],[18,14],[24,15],[28,2],[28,19],[2,19],[15,10],[3,10],[27,10],[15,2],[15,20],[2,10],[28,10]]},
{name:'3RD FLOOR',
rows:[
'1111111111111111111111111111111',
'1000000000000000000000000100001',
'1000000000000000000000000000001',
'1001110111112011110111111121001',
'1001000001000001000002000001001',
'1001000001000001000002000001001',
'1001000001000001000001000001011',
'1001000001000001000001000002001',
'1001000001000002000001000001001',
'1001111111101211111022110111001',
'2000000000000001000000100000001',
'1000000010000001000000000000001',
'1001211012122011110112111012002',
'1001000001000001000001000001001',
'1101000001000001000001000001001',
'1002000001000001000002000001001',
'1001000001000001000001000001001',
'1001000001000001000001000002001',
'1001201212110111101112110111001',
'1000000000000000000000000000001',
'1000000000000100000000000000001',
'1111111111111111111111111111111'],
rooms:[[4,4,8,8,18],[10,4,14,8,19],[16,4,20,8,20],[22,4,26,8,21],[4,13,8,17,22],[10,13,14,17,23],[16,13,20,17,24],[22,13,26,17,25]],
props:[
[5,5,'pew'],[5,7,'pew'],[7,7,'pew'],[7,5,'pew'],[6,8,'cabinet'],[13,6,'table'],[12,4,'files'],[13,8,'files'],[14,5,'files'],
[10,7,'locker'],[20,6,'files'],[20,4,'files'],[20,7,'files'],[20,5,'files'],[17,8,'files'],[17,7,'files'],[18,6,'files'],[17,5,'files'],
[25,6,'bed'],[23,5,'bed'],[22,7,'machine'],[26,4,'machine'],[23,4,'crate'],[8,13,'machine'],[4,13,'machine'],[4,17,'machine'],[5,15,'machine'],
[7,16,'machine'],[8,15,'crate'],[8,16,'crate'],[12,15,'bed'],[11,16,'bed'],[13,16,'bed'],[10,15,'machine'],[14,15,'cabinet'],[16,14,'monitor'],
[20,17,'monitor'],[20,16,'monitor'],[18,15,'table'],[19,14,'table'],[19,16,'bed'],[18,17,'locker'],[24,14,'table'],[23,16,'table'],[26,16,'machine'],
[22,16,'machine'],[23,17,'shelf'],[23,13,'shelf'],[22,14,'crate'],[29,15,'gurney'],[20,1,'crate'],[29,5,'bed'],[12,1,'crate'],[20,19,'gurney'],
[14,6,'crate'],[14,8,'crate'],[22,13,'crate'],[19,17,'crate'],[22,17,'crate']],
way:[[6,6],[12,6],[18,5],[24,6],[6,15],[12,14],[18,14],[24,15],[28,2],[28,19],[2,19],[14,11],[3,10],[27,10],[15,2],[15,20],[2,10],[28,10]]},
{name:'BASEMENT',
rows:[
'1221222211222112111212121112111',
'2000000000000000000000000000001',
'2000000000000000000000000000001',
'2002210121222212120222211122001',
'1002000001111111000002111111002',
'1002000002111112000002111111001',
'2002000002111112000002111112001',
'1002000001111112000002111111001',
'2002000002111122000002111111001',
'1002221202111100001221111111002',
'1011111121111101002111111111102',
'1001111111112200001111112111002',
'1001111111220201002111220222001',
'1002111112000000001112000002002',
'1002111112000001111111000001001',
'1002111112000002111112000002002',
'1001111111000002111112000002001',
'2001111112000002111112000001001',
'1001212211202212211122022222001',
'2000000000000000000000000000001',
'1000000000000000000000000000001',
'1112221211111112221111212221121'],
rooms:[[4, 4, 8, 8, 26], [16, 4, 20, 8, 27], [10, 13, 14, 17, 28], [22, 13, 26, 17, 29]],
props:[
[4,4,'fridge'],[4,7,'fridge'],[4,8,'fridge'],[5,4,'body'],[6,8,'body'],[8,5,'gurney'],[19,8,'machine'],[20,6,'machine'],[19,4,'machine'],
[17,8,'crate'],[17,4,'crate'],[16,4,'cabinet'],[14,17,'files'],[13,17,'files'],[10,14,'files'],[13,13,'cabinet'],[11,13,'cabinet'],[14,16,'table'],
[22,16,'bed'],[25,13,'locker'],[22,13,'crate'],[26,15,'crate'],[24,17,'cabinet'],[19,1,'crate'],[6,20,'gurney'],[1,9,'crate'],[1,20,'gurney'],
[3,1,'crate'],[28,20,'gurney'],[10,13,'crate']],
way:[[6, 6], [18, 5], [12, 14], [24, 15], [2, 2], [28, 2], [28, 19], [2, 19], [15, 2], [13, 19], [8, 9], [23, 13]]}];
// medicine holders: floor, cell, holder, name, easy-mode?
const MEDPOS=[[0,8,8,'fridge','ANTIVIRAL BATCH 7',1],[0,6,17,'fridge','COLD-CHAIN SERUM',0],[0,18,13,'table','PLASMA VIAL',1],[1,23,13,'table','NEURAL STABILIZER',1],[1,4,5,'table','GENE THERAPY SAMPLE',0],[1,23,4,'fridge','SEDATIVE B-12',1],[2,25,4,'pedestal','STEM-CELL CULTURE',0],[3,26,16,'pedestal','PROTOTYPE CURE',1]];
const KEYPOS=[2,14,7,'table'];
const BATPOS=[[0,10,8,1],[0,10,17,2],[0,2,18,3],[1,10,5,1],[1,8,13,2],[1,20,13,3],[2,8,17,1],[2,10,13,2],[3,6,1,1],[3,24,20,1]]; // floor, x, y, level (1 = every mode, 2 = easy+medium, 3 = easy only)
const ARTPOS=[[0,4,8,"ledger"],[0,14,5,"memo"],[0,20,8,"tape"],[0,24,5,"chart"],[0,14,17,"adrenaline"],[0,24,14,"zerofile"],[1,8,8,"lab"],[1,16,7,"blueprint"],[1,10,17,"shoes"],[1,16,17,"vest"],[2,8,8,"prayer"],[2,10,4,"diary"],[2,16,7,"index"],[2,4,14,"torch"],[2,20,15,"obslog"],[2,22,15,"relocated"],[3,12,15,"archrecord"],[3,18,6,"furnacelog"],[3,3,19,"scratches"]]; // floor, x, y, artifact id
const MW=FLOORS[0].rows[0].length, MH=FLOORS[0].rows.length;
// zone: name shown on entry · wall tint · ambient light (corridors have dim emergency lights, rooms are pitch dark)
const ZDEF=[
{n:'', t:[.65,.72,.68], amb:.30},
{n:'AMBULANCE BAY', t:[.34,.92,.50], amb:.45, exit:true},
{n:'PHARMACY', t:[.52,.88,.74], amb:.12},
{n:'ADMIN OFFICE', t:[.90,.82,.52], amb:.12},
{n:'RADIOLOGY', t:[.62,.56,.90], amb:.12},
{n:'ICU', t:[.46,.68,1.0], amb:.12},
{n:'MORGUE', t:[.56,.88,.98], amb:.12},
{n:'GENERAL WARD', t:[.88,.86,.70], amb:.12},
{n:'SURGERY', t:[.56,1.0,.86], amb:.12},
{n:'WARD ZERO', t:[.86,.68,.62], amb:.12},
{n:'LABORATORY', t:[.70,.95,.55], amb:.12},
{n:'NURSERY', t:[1.0,.78,.85], amb:.12},
{n:'X-RAY', t:[.55,.75,.95], amb:.12},
{n:'PSYCH WARD', t:[.86,.66,.95], amb:.12},
{n:'INTENSIVE CARE', t:[.40,.78,1.0], amb:.12},
{n:'PATIENT ROOMS', t:[.90,.88,.75], amb:.12},
{n:'PHYSIOTHERAPY', t:[.70,.92,.70], amb:.12},
{n:'STORAGE', t:[.78,.70,.52], amb:.12},
{n:'CHAPEL', t:[.98,.84,.52], amb:.12},
{n:"DIRECTOR'S OFFICE",t:[.95,.76,.40], amb:.12},
{n:'ARCHIVES', t:[.72,.72,.62], amb:.12},
{n:'QUARANTINE', t:[.86,.95,.36], amb:.12},
{n:'BOILER ROOM', t:[.82,.66,.52], amb:.12},
{n:'ISOLATION WARD', t:[.66,.82,.96], amb:.12},
{n:'OBSERVATION', t:[.60,.90,.90], amb:.12},
{n:'RESEARCH LAB', t:[.50,.96,.70], amb:.12},
{n:'OLD MORGUE', t:[.58,.60,.56], amb:.07},
{n:'INCINERATOR ROOM', t:[.72,.42,.30], amb:.07},
{n:'RECORDS ARCHIVE', t:[.56,.52,.44], amb:.07},
{n:"PATIENT ZERO'S CELL",t:[.62,.30,.28], amb:.06}];
const STAIR_NE={x:29,y:1,lx:28,ly:2,ax:28.5,ay:2.5,aa:Math.PI},STAIR_SW={x:1,y:20,lx:2,ly:19,ax:2.5,ay:19.5,aa:0}; // north-east and south-west stairwells (same spot on floors 0-2)
const HATCH={x:15,y:19,lx:13,ly:19,ax:14.5,ay:19.5,aa:Math.PI}; // the basement hatch: same physical spot on floor 0 and floor 3, straight down
const FLOOR_STAIRS=[ // explicit per-floor stairwell list: which physical stairwells exist on each floor, and which floor each one leads to
[{...STAIR_NE,upTo:1,downTo:null},{...STAIR_SW,upTo:1,downTo:null},{...HATCH,upTo:null,downTo:3}], // floor 0 (ground): up to 1, or down through the hatch to the basement
[{...STAIR_NE,upTo:2,downTo:0},{...STAIR_SW,upTo:2,downTo:0}], // floor 1
[{...STAIR_NE,upTo:null,downTo:1},{...STAIR_SW,upTo:null,downTo:1}], // floor 2
[{...HATCH,upTo:0,downTo:null}]]; // floor 3 (basement): only the hatch, back up to the ground floor
const FLOOR_ORDER=[3,0,1,2]; // physical stacking order, basement to top, used to route the final chase between non-adjacent floors
function nextHop(from,to){const fi=FLOOR_ORDER.indexOf(from),ti=FLOOR_ORDER.indexOf(to);return fi<0||ti<0||fi===ti?from:FLOOR_ORDER[fi+(ti>fi?1:-1)]}
const EXIT={x:2,y:2,r:1.25}; // ambulance bay, 1st floor
// The medicines: each one lives in ONE fixed room, in a holder against the far wall
const MEDTXT=[
'Scratched into the shelf: IT HEARS EVERYTHING.',
'Drawer 9 hangs open. The tag reads: DO NOT LEAVE ALONE.',
'Surgical log: "We only wanted to take the fever out."',
'Bed 3 chart: "Patient still moving. Pupils fixed." — Dr. Hale',
'The lab notebook stops mid-sentence: "It does not stop when it is cut, it..."',
'Every padded door in this ward is scratched from the inside.',
'A quarantine tag: DO NOT OPEN. The seal was broken from the inside.',
'The last vial hums. Somewhere below you, something wakes.'];
// Artifacts: notes, tapes and gear you can pick up. fx = what they do. lv = which difficulties have them lying around.
const ARTDEF={
ledger: {n:'PHARMACY LEDGER', t:'Every batch and where it was moved. All medicine locations are shown on your map for 10 seconds.',fx:'meds',lv:1,icon:'paper'},
memo: {n:'STAFF MEMO', t:'"It only hunts what it can hear or see. Hide in a cupboard or under a bed and it loses you."',fx:'',lv:2,icon:'paper'},
tape: {n:'SECURITY TAPE', t:'Camera footage. You can track where it is on your map for 15 seconds.',fx:'track',lv:2,icon:'tape'},
chart: {n:'ICU CHART', t:'Transfer note: the neural stabilizer went up to Intensive Care on the 2nd floor.',fx:'',lv:3,icon:'paper'},
adrenaline:{n:'ADRENALINE SHOT', t:'Sprint without tiring for 20 seconds.',fx:'stam',lv:2,icon:'shot'},
zerofile: {n:'PATIENT ZERO FILE',t:"The master key is kept in the Director's Office on the 3rd floor. It only unlocks once every medicine is found.",fx:'keyhint',lv:1,icon:'paper'},
lab: {n:'LAB REPORT', t:'Sample manifest updated. All medicine locations are shown on your map for 10 seconds.',fx:'meds',lv:2,icon:'paper'},
blueprint: {n:'FLOOR BLUEPRINT', t:'This whole floor is now on your map.',fx:'map',lv:2,icon:'paper'},
vest: {n:'PADDED VEST', t:'Thick padding. It needs 1 extra second to finish you.',fx:'armor',lv:2,icon:'paper'},
shoes: {n:'SOFT-SOLED SHOES', t:'You are much quieter for 60 seconds.',fx:'quiet',lv:2,icon:'paper'},
diary: {n:"DIRECTOR'S DIARY", t:'"The key opens the ambulance bay. The moment it is taken, the thing stops wandering and comes for you."',fx:'',lv:1,icon:'paper'},
index: {n:'ARCHIVE INDEX', t:'Both stairwells and the exit are now marked on every floor of your map.',fx:'stairs',lv:2,icon:'paper'},
torch: {n:'SPARE TORCH', t:'A fresh torch. +60% battery.',fx:'torch',lv:1,icon:'torch'},
obslog: {n:'OBSERVATION LOG', t:'The monitors still work. You can track where it is on your map for 15 seconds.',fx:'track',lv:2,icon:'tape'},
prayer: {n:'PRAYER CARD', t:'"It hates being watched. Switch off your light and stand very still."',fx:'',lv:3,icon:'paper'},
relocated: {n:"RESEARCH TRANSFER SLIP",t:'"Sample moved to sub-level containment per Director order. Do not log the reason." A stairwell down is marked on your map.',fx:'stairs',lv:1,icon:'paper'},
archrecord:{n:'ARCHIVE RECORD', t:'"Patient Zero was brought down here after the third incident. We stopped asking what the second one was." The whole basement is now on your map.',fx:'map',lv:1,icon:'paper'},
furnacelog:{n:'INCINERATOR LOG', t:'"Burn everything from the cell. Bedding, clothes, notes. Especially the notes."',fx:'',lv:2,icon:'paper'},
scratches: {n:'CLAWED INTO THE WALL',t:'"IT WAS NEVER SICK. IT WAS HUNGRY."',fx:'',lv:2,icon:'paper'}
};
/* ---------- floors: grids ---------- */
const FL=FLOORS.map(fd=>{
const wall=new Uint8Array(MW*MH),zone=new Uint8Array(MW*MH),base=new Uint8Array(MW*MH);
for(let y=0;y<MH;y++)for(let x=0;x<MW;x++){const c=fd.rows[y].charCodeAt(x)-48;wall[y*MW+x]=c;base[y*MW+x]=c?1:0}
for(const r of fd.rooms)for(let y=r[1];y<=r[3];y++)for(let x=r[0];x<=r[2];x++)zone[y*MW+x]=r[4];
return{name:fd.name,wall,zone,base,block:new Uint8Array(MW*MH),seen:new Uint8Array(MW*MH),spr:[],props:fd.props,way:fd.way,stairs:[]};
});
let F=0,WALL=FL[0].wall,BLOCK=FL[0].block,ZONE=FL[0].zone,SPR=FL[0].spr;
function setFloor(f){F=f;p.f=f;WALL=FL[f].wall;BLOCK=FL[f].block;ZONE=FL[f].zone;SPR=FL[f].spr;curZone=-1;lastRevealCell=-1;pFieldCell=-1}
/* ---------- things you can see (billboard sprites) ---------- */
const HW={shelf:.4,table:.44,cabinet:.3,crate:.28,bed:.46,gurney:.47,body:.47,fridge:.34,pedestal:.22,locker:.28,monitor:.42,bassinet:.3,scanner:.5,machine:.36,pew:.46,files:.38,stairs:.5,key:.44};
const HIDE={cabinet:1,locker:1,bed:2,table:2,gurney:2}; // 1 = climb inside, 2 = crawl underneath
const HOLD={fridge:.40,table:.36,pedestal:.36}; // height of the glowing vial above the floor (in sprite units)
const mon={type:'mon',hw:.26,f:0,x:0,y:0,z:0,active:false,mode:'patrol',dist:99,pd:-1,lost:0,lx:0,ly:0,goal:null,gField:null,wait:0,stuck:0,anim:0,moving:false,reach:0,
chaseT:0,unseen:0,cool:0,stun:0,sniff:0,stairWait:0,away:0};
const MEDS=[],BATS_A=[],ARTS_A=[];let KEY=null;
function buildWorld(){ // (re)builds every floor's furniture and pickups for the chosen difficulty
MEDS.length=BATS_A.length=ARTS_A.length=0;
FL.forEach((fl,f)=>{
fl.block.set(fl.base);fl.seen.fill(0);fl.spr=[];
for(const q of fl.props){fl.block[q[1]*MW+q[0]]=1;fl.spr.push({type:'prop',kind:q[2],cx:q[0],cy:q[1],x:q[0]+.5,y:q[1]+.5,hw:HW[q[2]]||.4,z:fl.zone[q[1]*MW+q[0]],hide:HIDE[q[2]]||0})}
fl.stairs=FLOOR_STAIRS[f].map(s=>{const o={type:'stairs',f,cx:s.x,cy:s.y,x:s.x+.5,y:s.y+.5,hw:.5,z:0,upTo:s.upTo,downTo:s.downTo,lx:s.lx,ly:s.ly,ax:s.ax,ay:s.ay,aa:s.aa,field:null};fl.block[s.y*MW+s.x]=1;fl.spr.push(o);return o});
});
MEDPOS.forEach((m,i)=>{
const f=m[0],fl=FL[f],cx=m[1],cy=m[2],hold=m[3];
fl.block[cy*MW+cx]=1;
const o={type:'med',f,cx,cy,x:cx+.5,y:cy+.5,holder:hold,hw:HW[hold],z:fl.zone[cy*MW+cx],name:m[4],note:MEDTXT[i],taken:false};
if(NEED<8&&!m[5]){o.type='prop';o.kind=hold;o.hide=0} // not used in this mode: just furniture
else MEDS.push(o);
fl.spr.push(o);
});
{const f=KEYPOS[0],fl=FL[f],cx=KEYPOS[1],cy=KEYPOS[2];fl.block[cy*MW+cx]=1;KEY={type:'key',f,cx,cy,x:cx+.5,y:cy+.5,holder:KEYPOS[3],hw:HW.key,z:fl.zone[cy*MW+cx],taken:false};fl.spr.push(KEY)}
for(const b of BATPOS)if(b[3]<=TUNE.lv){const fl=FL[b[0]],o={type:'bat',f:b[0],cx:b[1],cy:b[2],x:b[1]+.5,y:b[2]+.5,hw:.16,z:fl.zone[b[2]*MW+b[1]],taken:false};BATS_A.push(o);fl.spr.push(o)}
for(const a of ARTPOS){const d=ARTDEF[a[3]];if(d.lv<=TUNE.lv){const fl=FL[a[0]],o={type:'art',f:a[0],cx:a[1],cy:a[2],x:a[1]+.5,y:a[2]+.5,hw:.14,z:fl.zone[a[2]*MW+a[1]],id:a[3],icon:d.icon,taken:false};ARTS_A.push(o);fl.spr.push(o)}}
for(const fl of FL)fl.spr.push(mon);
}
/* =====================================================================
STATE
===================================================================== */
let W=0,H=0,depth=new Float32Array(0),bgGrad=null;
let started=false,last=0,T=0,gameT=0,frozen=false,busy=false,final=false,hasKey=false,keyKnown=false,mapStairs=false,graceMsgs=0;
const p={x:1.5,y:1.5,a:0,f:0};
let got=0,flash=true,battery=100,stamina=100,exhausted=false,moving=false,sprinting=false,runHeld=false;
let flashK=1,flickerT=0,ambT=20,beat=0,curZone=-1,lastRevealCell=-1;
let grab=0,touching=false,pField=null,pFieldCell=-1,pFieldF=-1;
let hiding=false,hideProp=null,hideExit=null,hideA=0,flashWas=true;
const intel={meds:0,mon:0,stam:0,quiet:0};
let mmF=0,mmT=0,lastMsgKey=0;
const cD={x:1,y:0},cP={x:0,y:0.577},FOV_T=Math.tan(1.05/2);
const keys={};
const elObj=$('objective'),elBat=$('battery'),elRoom=$('roomTag'),elDmg=$('damage'),elGrab=$('grab'),elGrabFill=$('grabFill'),elMm=$('mmInfo'),elFade=$('fade'),rb=$('run');
function resize(){
const sc=Math.min(window.devicePixelRatio||1,1.5),iw=innerWidth||document.documentElement.clientWidth||800,ih=innerHeight||document.documentElement.clientHeight||600;
W=canvas.width=Math.floor(iw*sc);H=canvas.height=Math.floor(ih*sc);
canvas.style.width=iw+'px';canvas.style.height=ih+'px';
depth=new Float32Array(W);
bgGrad=ctx.createLinearGradient(0,0,0,H);
bgGrad.addColorStop(0,'#11191d');bgGrad.addColorStop(.5,'#0b0d0d');bgGrad.addColorStop(.51,'#171614');bgGrad.addColorStop(1,'#060606');
}
addEventListener('resize',resize);resize();
/* =====================================================================
HELPERS: collision, line of sight, pathfinding (all per floor)
===================================================================== */
const blockedAt=(B,x,y)=>x<0||y<0||x>=MW||y>=MH||B[(y|0)*MW+(x|0)]===1;
const canStand=(B,x,y,r)=>!(blockedAt(B,x-r,y-r)||blockedAt(B,x+r,y-r)||blockedAt(B,x-r,y+r)||blockedAt(B,x+r,y+r));
function moveEntity(e,dx,dy,r){const B=FL[e.f].block;if(canStand(B,e.x+dx,e.y,r))e.x+=dx;if(canStand(B,e.x,e.y+dy,r))e.y+=dy}
function losClear(x0,y0,x1,y1,useBlock,f=F){
const fl=FL[f],G=useBlock?fl.block:fl.wall,dx=x1-x0,dy=y1-y0,n=Math.max(1,Math.ceil(Math.hypot(dx,dy)/.12));
for(let i=1;i<n;i++){if(G[((y0+dy*i/n)|0)*MW+((x0+dx*i/n)|0)])return false}
return true;
}
function bfs(sx,sy,f=F){ // distance (in steps) from every open cell to (sx,sy)
const B=FL[f].block,d=new Int16Array(MW*MH).fill(-1),q=new Int16Array(MW*MH);let h=0,t=0;
const s=sy*MW+sx;d[s]=0;q[t++]=s;
while(h<t){
const c=q[h++],cx=c%MW,cy=(c/MW)|0,nd=d[c]+1;
if(cx>0&&!B[c-1]&&d[c-1]<0){d[c-1]=nd;q[t++]=c-1}
if(cx<MW-1&&!B[c+1]&&d[c+1]<0){d[c+1]=nd;q[t++]=c+1}
if(cy>0&&!B[c-MW]&&d[c-MW]<0){d[c-MW]=nd;q[t++]=c-MW}
if(cy<MH-1&&!B[c+MW]&&d[c+MW]<0){d[c+MW]=nd;q[t++]=c+MW}
}
return d;
}
function flowTarget(fld,cx,cy,f=F){ // centre of the neighbouring cell that is closest to the goal
const B=FL[f].block;let best=fld[cy*MW+cx],bx=-1,by=-1;
if(best<0)return null;
for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){
if(!dx&&!dy)continue;
const nx=cx+dx,ny=cy+dy;if(nx<0||ny<0||nx>=MW||ny>=MH)continue;
const ni=ny*MW+nx;if(B[ni])continue;
if(dx&&dy&&(B[cy*MW+nx]||B[ny*MW+cx]))continue; // no cutting corners
const v=fld[ni];if(v>=0&&v<best){best=v;bx=nx;by=ny}
}
return bx<0?null:[bx+.5,by+.5];
}
/* =====================================================================
AUDIO — sound effects + a synthesised horror soundtrack (no audio files)
===================================================================== */
let audio=null,noiseBuf=null;
function sound(freq=80,dur=.18,type='sawtooth',vol=.08,delay=0){
if(!audio)return;
const t0=audio.currentTime+delay,o=audio.createOscillator(),g=audio.createGain();
o.type=type;o.frequency.setValueAtTime(freq,t0);g.gain.setValueAtTime(vol,t0);g.gain.exponentialRampToValueAtTime(.001,t0+dur);
o.connect(g).connect(audio.destination);o.start(t0);o.stop(t0+dur);
}
function noise(dur=.3,vol=.1,freq=800,q=.8,delay=0){ // filtered noise burst: whispers, breath, impacts
if(!audio)return;
if(!noiseBuf){const n=audio.sampleRate|0;noiseBuf=audio.createBuffer(1,n,audio.sampleRate);const d=noiseBuf.getChannelData(0);for(let i=0;i<n;i++)d[i]=Math.random()*2-1}
const t0=audio.currentTime+delay,s=audio.createBufferSource(),f=audio.createBiquadFilter(),g=audio.createGain();
s.buffer=noiseBuf;f.type='bandpass';f.frequency.value=freq;f.Q.value=q;
g.gain.setValueAtTime(vol,t0);g.gain.exponentialRampToValueAtTime(.001,t0+dur);
s.connect(f);f.connect(g);g.connect(audio.destination);s.start(t0);s.stop(t0+Math.min(dur,.95)+.05);
}
function moan(){ // a long, low, wrong sound from somewhere else in the building
if(!audio)return;
const t0=audio.currentTime,o=audio.createOscillator(),g=audio.createGain(),lp=audio.createBiquadFilter(),v=audio.createOscillator(),vg=audio.createGain();
o.type='sawtooth';o.frequency.setValueAtTime(140,t0);o.frequency.linearRampToValueAtTime(78,t0+1.8);
v.frequency.value=5.5;vg.gain.value=7;v.connect(vg);vg.connect(o.frequency);
lp.type='lowpass';lp.frequency.value=420;g.gain.setValueAtTime(0,t0);g.gain.linearRampToValueAtTime(.05,t0+.5);g.gain.linearRampToValueAtTime(0,t0+1.9);
o.connect(lp);lp.connect(g);g.connect(audio.destination);o.start(t0);v.start(t0);o.stop(t0+2);v.stop(t0+2);
}
function stinger(){sound(70,.7,'sawtooth',.07);sound(74.2,.7,'sawtooth',.06);sound(110,.5,'sawtooth',.04,.05);noise(.5,.06,300,1)}
function stairSfx(){for(let i=0;i<6;i++){sound(92-i*3,.07,'square',.05,i*.1);noise(.06,.04,260,1,i*.1)}}
const MUS={on:true,ready:false,tension:0,nt:5,bt:0,ot:0,os:0,bus:null,master:null,L:{}};
function impulse(a,sec,decay){const n=(a.sampleRate*sec)|0,b=a.createBuffer(2,n,a.sampleRate);for(let c=0;c<2;c++){const d=b.getChannelData(c);for(let i=0;i<n;i++)d[i]=(Math.random()*2-1)*Math.pow(1-i/n,decay)}return b}
function initMusic(){
const a=audio,L=MUS.L;
const comp=a.createDynamicsCompressor();comp.threshold.value=-22;comp.ratio.value=8;comp.connect(a.destination);
const master=a.createGain();master.gain.value=0;master.connect(comp);
const conv=a.createConvolver();conv.buffer=impulse(a,2.6,2.4);const wet=a.createGain();wet.gain.value=.5;conv.connect(wet);wet.connect(master);
const bus=a.createGain();bus.connect(master);bus.connect(conv);
MUS.master=master;MUS.bus=bus;
// 1. a low drone that never quite settles
L.drone=a.createGain();L.drone.gain.value=.075;
const dlp=a.createBiquadFilter();dlp.type='lowpass';dlp.frequency.value=300;dlp.Q.value=.7;L.drone.connect(dlp);dlp.connect(bus);
[[55,'sawtooth'],[55.5,'sawtooth'],[77.8,'triangle'],[27.5,'sine'],[110.4,'sawtooth'],[155.6,'triangle']].forEach(x=>{const o=a.createOscillator();o.type=x[1];o.frequency.value=x[0];o.connect(L.drone);o.start()});
const lfo=a.createOscillator(),lg=a.createGain();lfo.frequency.value=.05;lg.gain.value=110;lfo.connect(lg);lg.connect(dlp.frequency);lfo.start();
// 2. a slow pad (minor 2nd on hard = uneasy, minor 3rd on easy = sad)
L.pad=a.createGain();L.pad.gain.value=.024;
const plp=a.createBiquadFilter();plp.type='lowpass';plp.frequency.value=900;L.pad.connect(plp);plp.connect(bus);
const pn=[220,TUNE.dis>.5?233.08:261.63,TUNE.dis>.5?311.13:329.63];
pn.forEach((f,i)=>{const o=a.createOscillator();o.type='sine';o.frequency.value=f+i*.13;o.connect(L.pad);o.start()});
const pl=a.createOscillator(),plg=a.createGain();pl.frequency.value=.07;plg.gain.value=.018;pl.connect(plg);plg.connect(L.pad.gain);pl.start();
// 3. tremolo strings that swell when it is hunting you
L.tens=a.createGain();L.tens.gain.value=0;
const bp=a.createBiquadFilter();bp.type='bandpass';bp.frequency.value=1150;bp.Q.value=1.3;
const trem=a.createGain();trem.gain.value=.55;
const tl=a.createOscillator(),tlg=a.createGain();tl.frequency.value=6.2;tlg.gain.value=.4;tl.connect(tlg);tlg.connect(trem.gain);tl.start();
[[880,.5],[932.33,.5],[1318.5,.2]].forEach(x=>{const o=a.createOscillator(),g=a.createGain();o.type='sawtooth';o.frequency.value=x[0];g.gain.value=x[1];o.connect(g);g.connect(trem);o.start()});
trem.connect(bp);bp.connect(L.tens);L.tens.connect(bus);
// 4. wind / whispers
const nb=a.createBuffer(1,a.sampleRate*2,a.sampleRate),nd=nb.getChannelData(0);for(let i=0;i<nd.length;i++)nd[i]=Math.random()*2-1;
const ns=a.createBufferSource();ns.buffer=nb;ns.loop=true;const nf=a.createBiquadFilter();nf.type='bandpass';nf.frequency.value=420;nf.Q.value=.9;
L.wind=a.createGain();L.wind.gain.value=.012;ns.connect(nf);nf.connect(L.wind);L.wind.connect(bus);ns.start();
const wl=a.createOscillator(),wg=a.createGain();wl.frequency.value=.11;wg.gain.value=260;wl.connect(wg);wg.connect(nf.frequency);wl.start();
MUS.ready=true;
}
const SCALE=[220,233.08,261.63,311.13,329.63,392,440,466.16,523.25,622.25,659.25,783.99];
function mNote(f,v,d,delay){const a=audio,t=a.currentTime+(delay||0),o=a.createOscillator(),g=a.createGain();o.type='triangle';o.frequency.value=f;g.gain.setValueAtTime(0,t);g.gain.linearRampToValueAtTime(v,t+.012);g.gain.exponentialRampToValueAtTime(.0008,t+d);o.connect(g);g.connect(MUS.bus);o.start(t);o.stop(t+d+.05)}
function phrase(){ // a tiny music-box lullaby that wanders off-key
const n=2+((Math.random()*4)|0),step=.32+Math.random()*.16;let i=(5+Math.random()*6)|0;
for(let k=0;k<n;k++){mNote(SCALE[Math.max(0,Math.min(SCALE.length-1,i))],.05+.03*TUNE.dis,2.2,k*step);i+=(Math.random()<.65?-1:1)*(1+((Math.random()*2)|0))}
if(TUNE.dis>.8&&Math.random()<.4)mNote(SCALE[i&3]*1.0595,.04,2.8,n*step);
}
function kick(v){const a=audio,t=a.currentTime,o=a.createOscillator(),g=a.createGain();o.type='sine';o.frequency.setValueAtTime(95,t);o.frequency.exponentialRampToValueAtTime(34,t+.22);g.gain.setValueAtTime(v,t);g.gain.exponentialRampToValueAtTime(.001,t+.3);o.connect(g);g.connect(MUS.master);o.start(t);o.stop(t+.35)}
const OST=[55,55,58.27,55,55,65.41,55,51.91];
function ostinato(i){const a=audio,t=a.currentTime,o=a.createOscillator(),g=a.createGain(),lp=a.createBiquadFilter();o.type='sawtooth';o.frequency.value=OST[i%8]*(i%4===0?2:1);lp.type='lowpass';lp.frequency.value=420;g.gain.setValueAtTime(.05,t);g.gain.exponentialRampToValueAtTime(.001,t+.2);o.connect(lp);lp.connect(g);g.connect(MUS.bus);o.start(t);o.stop(t+.22)}
function alarm(){ // the building alarm when the master key is taken
if(!audio||!MUS.ready)return;
const a=audio,t=a.currentTime,o=a.createOscillator(),g=a.createGain(),lp=a.createBiquadFilter();o.type='sawtooth';lp.type='lowpass';lp.frequency.value=900;
for(let i=0;i<12;i++)o.frequency.setValueAtTime(i%2?440:587.33,t+i*.5);
g.gain.setValueAtTime(0,t);g.gain.linearRampToValueAtTime(.05,t+.3);g.gain.setValueAtTime(.05,t+5);g.gain.linearRampToValueAtTime(0,t+6);
o.connect(lp);lp.connect(g);g.connect(MUS.bus);o.start(t);o.stop(t+6.1);
}
const ramp=(param,v,t,tc)=>{if(isFinite(v))param.setTargetAtTime(v,t,tc)};
function musicTick(dt,fear){
if(!MUS.ready)return;
const a=audio,t=a.currentTime,L=MUS.L;
const target=Math.max(fear,final?.8:0);
MUS.tension+=(target-MUS.tension)*Math.min(1,dt*(target>MUS.tension?1.8:.45));
MUS.tension=Math.max(0,Math.min(1,MUS.tension||0));
const tn=MUS.tension,v=MUS.on?TUNE.musVol:0;
ramp(MUS.master.gain,.55*v,t,.6);
ramp(L.drone.gain,.075+.09*tn,t,.5);
ramp(L.pad.gain,.024+.02*tn,t,.5);
ramp(L.tens.gain,Math.pow(tn,1.5)*.05*(.35+.65*TUNE.dis),t,.3);
ramp(L.wind.gain,.010+.022*tn,t,.7);
MUS.nt-=dt;if(MUS.nt<=0){MUS.nt=TUNE.noteGap*(.7+Math.random()*.8)*(1-.55*tn);phrase()}
if(tn>.3){MUS.bt-=dt;if(MUS.bt<=0){MUS.bt=.8-.42*tn;kick(.09+.17*tn)}}
if(final){MUS.ot-=dt;if(MUS.ot<=0){MUS.ot=.23;ostinato(MUS.os++)}}
}
function ambientEvent(){ // spooky noises from elsewhere in the hospital
const r=Math.random();
if(r<.25){for(let i=0;i<3;i++)sound(70,.09,'square',.12,i*.17+(i>1?.1:0))}
else if(r<.5){noise(.9,.05,1800,6);noise(.6,.04,1400,5,.45)}
else if(r<.7)moan();
else if(r<.85){sound(330,.5,'square',.05);sound(487,.4,'square',.04,.02)}
else{for(let i=0;i<5;i++)sound(60,.07,'sine',.1,i*.5)}
if(Math.random()<.6*TUNE.flickMul)flickerT=.35+Math.random()*.5;
}
/* =====================================================================
HUD MESSAGES
===================================================================== */
let msgTm,roomTm;
function msg(a,ms=2000,b=''){
$('msgMain').textContent=a;const s=$('msgSub');s.textContent=b;s.style.display=b?'block':'none';
const e=$('message');e.style.opacity=1;clearTimeout(msgTm);msgTm=setTimeout(()=>e.style.opacity=0,ms);
}
function roomTag(n){elRoom.textContent=n;elRoom.style.opacity=1;clearTimeout(roomTm);roomTm=setTimeout(()=>elRoom.style.opacity=0,2600)}
function fade(v,cb,ms=300){elFade.style.transition='opacity '+ms+'ms';elFade.style.opacity=v;if(cb)setTimeout(cb,ms)}
function setObjective(){elObj.textContent=got<NEED?`MEDICINES ${got}/${NEED}`:(!hasKey?'FIND THE MASTER KEY':'ESCAPE · 1ST FLOOR EXIT')}
/* =====================================================================
INPUT
===================================================================== */
let joy={x:0,y:0,id:null};const j=$('joy'),stick=$('stick');
function joySet(e){
const r=j.getBoundingClientRect(),lim=42;let x=e.clientX-(r.left+r.width/2),y=e.clientY-(r.top+r.height/2);
const m=Math.hypot(x,y);if(m>lim){x*=lim/m;y*=lim/m}
joy.x=x/lim;joy.y=y/lim;stick.style.transform=`translate(${x}px,${y}px)`;
}
j.addEventListener('pointerdown',e=>{joy.id=e.pointerId;j.setPointerCapture(e.pointerId);joySet(e)});
j.addEventListener('pointermove',e=>{if(e.pointerId===joy.id)joySet(e)});
function joyUp(e){if(e.pointerId!==joy.id)return;joy={x:0,y:0,id:null};stick.style.transform=''}
j.addEventListener('pointerup',joyUp);j.addEventListener('pointercancel',joyUp);
let lookId=null,lastX=0;const look=$('look');
look.addEventListener('pointerdown',e=>{lookId=e.pointerId;lastX=e.clientX;look.setPointerCapture(e.pointerId)});
look.addEventListener('pointermove',e=>{if(e.pointerId!==lookId)return;p.a+=(e.clientX-lastX)*.006;lastX=e.clientX});
const lookEnd=e=>{if(e.pointerId===lookId)lookId=null};look.addEventListener('pointerup',lookEnd);look.addEventListener('pointercancel',lookEnd);
$('flash').addEventListener('pointerdown',e=>{e.preventDefault();toggleLight()});
function toggleLight(){if(battery>0&&!hiding){flash=!flash;sound(220,.05,'square',.03)}}
rb.addEventListener('pointerdown',e=>{e.preventDefault();runHeld=true});
['pointerup','pointercancel','pointerleave'].forEach(q=>rb.addEventListener(q,()=>runHeld=false));
const actBtn=[$('act1'),$('act2')],acts=[null,null]; // context buttons: HIDE / COME OUT / UP / DOWN
actBtn.forEach((b,i)=>b.addEventListener('pointerdown',e=>{e.preventDefault();if(acts[i]&&!acts[i].off)acts[i].fn()}));
function setAct(i,a){
const b=actBtn[i];acts[i]=a;
if(!a){if(b._on){b.style.display='none';b._on=0;b._lab=''}return}
if(!b._on){b.style.display='flex';b._on=1}
if(b._lab!==a.label){b.textContent=a.label;b._lab=a.label}
b.style.opacity=a.off?.5:1;
}
$('mus').addEventListener('pointerdown',e=>{e.preventDefault();MUS.on=!MUS.on;$('mus').style.opacity=MUS.on?1:.45});
addEventListener('keydown',e=>{keys[e.key]=true;if(e.key==='f'||e.key==='F')toggleLight();if((e.key==='e'||e.key==='E')&&acts[0]&&!acts[0].off)acts[0].fn();if((e.key==='q'||e.key==='Q')&&acts[1]&&!acts[1].off)acts[1].fn()});
addEventListener('keyup',e=>{keys[e.key]=false});
$('dEasy').onclick=()=>startGame('easy');$('dMed').onclick=()=>startGame('medium');$('dHard').onclick=()=>startGame('hard');
function startGame(d){
if(started)return;
try{
resize();
Object.assign(mon,{active:false,mode:'patrol',dist:99,pd:-1,lost:0,lx:0,ly:0,goal:null,gField:null,wait:0,stuck:0,anim:0,moving:false,reach:0,chaseT:0,unseen:0,cool:0,stun:0,sniff:0,stairWait:0,away:0,f:0,x:0,y:0,z:0}); // clear the posed menu-screen monster before real play begins
DIFF=d;Object.assign(TUNE,DIFFS[d]);NEED=TUNE.need;
buildWorld();setFloor(0);p.x=1.5;p.y=1.5;p.a=0;
document.body.classList.add('playing');
try{audio=new (window.AudioContext||window.webkitAudioContext)();const r=audio.resume&&audio.resume();if(r&&r.catch)r.catch(()=>{})}catch(err){audio=null}
if(audio){try{initMusic()}catch(err){MUS.ready=false;try{console.error(err)}catch(_){}}} // no music? the sound effects still work
ambT=TUNE.scareGap*.6;setObjective();
$('start').style.display='none';started=true;last=0;
msg('FIND THE '+NEED+' MEDICINES',5600,'They are spread over 3 floors, and the stairs are in the far corners. Something is below the ground floor, too. Read every note you find. When it hunts you, hide in a cupboard or under a bed.');
requestAnimationFrame(loop);
}catch(e){reportError(e)}
}
$('again').addEventListener('click',()=>location.reload());
/* =====================================================================
RENDERING — walls (DDA raycaster) with streaming blood
===================================================================== */
const hash=(a,b)=>{let h=(a*374761393+b*668265263)|0;h=(h^(h>>>13))*1274126177|0;return((h^(h>>>16))>>>0)/4294967296};
function lightAt(sx,dist,z){
let l=ZDEF[z].amb*TUNE.ambMul;
if(flash){const cone=Math.max(0,1-Math.abs(sx-W/2)/(W*.58));l=Math.max(l,cone*Math.max(.13,1-dist/TUNE.beam)*flashK)}
return l;
}
function drawWalls(){
const step=Math.max(1,Math.floor(W/420)),px=p.x,py=p.y;
for(let x=0;x<W;x+=step){
const cam=2*(x+step*.5)/W-1,rx=cD.x+cP.x*cam,ry=cD.y+cP.y*cam;
let mx=px|0,my=py|0,lx=mx,ly=my,side=0,tile=1,stx,sty,sdx,sdy;
const ddx=rx===0?1e9:Math.abs(1/rx),ddy=ry===0?1e9:Math.abs(1/ry);
if(rx<0){stx=-1;sdx=(px-mx)*ddx}else{stx=1;sdx=(mx+1-px)*ddx}
if(ry<0){sty=-1;sdy=(py-my)*ddy}else{sty=1;sdy=(my+1-py)*ddy}
for(let i=0;i<90;i++){
lx=mx;ly=my;
if(sdx<sdy){sdx+=ddx;mx+=stx;side=0}else{sdy+=ddy;my+=sty;side=1}
if(mx<0||my<0||mx>=MW||my>=MH){tile=1;break}
tile=WALL[my*MW+mx];if(tile)break;
}
let perp=side?sdy-ddy:sdx-ddx;if(perp<.03)perp=.03;
let wx=side?px+perp*rx:py+perp*ry;wx-=Math.floor(wx);
const z=ZONE[ly*MW+lx],zn=ZDEF[z]; // wall takes the colour of the room it faces
let light=lightAt(x+step*.5,perp,z);
if(zn.exit&&hasKey)light=Math.max(light,.55+.45*Math.sin(T*4)); // the exit pulses once you hold the master key
let sh=145*light*(side?.72:1);
if(((wx*4)%1)<.04)sh*=.76; // tile seams
const t=zn.t,h=Math.min(H*3,H/perp),top=(H-h)/2;
ctx.fillStyle=`rgb(${sh*t[0]|0},${sh*t[1]|0},${sh*t[2]|0})`;ctx.fillRect(x,top,step+1,h);
ctx.fillStyle=`rgb(${sh*t[0]*.45|0},${sh*t[1]*.45|0},${sh*t[2]*.45|0})`;ctx.fillRect(x,top+h*.9,step+1,h*.1+1); // baseboard
ctx.fillStyle=`rgb(${Math.min(255,sh*t[0]*1.3)|0},${Math.min(255,sh*t[1]*1.3)|0},${Math.min(255,sh*t[2]*1.3)|0})`;ctx.fillRect(x,top+h*.56,step+1,Math.max(1,h*.025)); // handrail
if(tile===2){ // blood streaming down an ordinary wall
const slot=(wx*7)|0,fr=wx*7-slot,h1=hash(mx*13+slot*31+side*7,my*17+11);
if(h1>.5){
const c=.28+.44*hash(mx*5+slot,my*3+side*17),wd=.10+.14*hash(mx*3+slot*7,my*11+2),dx=Math.abs(fr-c);
if(dx<wd*1.5){
const st=hash(mx+slot*3,my+5)*.35,en=Math.min(1,st+.3+.55*hash(mx*7+slot,my*2+9)),y0=top+h*st,y1=top+h*en;
ctx.fillStyle=`rgb(${sh*.85|0},${sh*.04|0},${sh*.05|0})`;
if(dx<wd*.42)ctx.fillRect(x,y0,step+1,y1-y0); // the run itself
if(dx<wd*1.05)ctx.fillRect(x,y0,step+1,h*.055); // splash where it starts
if(dx<wd*.75)ctx.fillRect(x,y1-h*.022,step+1,h*.038); // fat drip at the bottom
}
}
}
for(let q=0;q<step&&x+q<W;q++)depth[x+q]=perp;
}
}
/* =====================================================================
RENDERING — sprites (props, medicines, batteries, artifacts, stairs, monster)
Same camera maths as the walls, and clipped column-by-column against wall depth,
so nothing can show through a wall or slide around the screen.
===================================================================== */
const col=(r,g,b,k)=>`rgb(${r*k|0},${g*k|0},${b*k|0})`;
function poly(c,a){c.beginPath();c.moveTo(a[0],a[1]);for(let i=2;i<a.length;i+=2)c.lineTo(a[i],a[i+1]);c.closePath();c.fill()}
function ell(c,x,y,rx,ry,n=14){c.beginPath();for(let i=0;i<n;i++){const a=i/n*6.2832,px=x+Math.cos(a)*rx,py=y+Math.sin(a)*ry;if(i)c.lineTo(px,py);else c.moveTo(px,py)}c.closePath();c.fill()}
function limb(c,x0,y0,x1,y1,w0,w1){const dx=x1-x0,dy=y1-y0,L=Math.hypot(dx,dy)||1,nx=-dy/L,ny=dx/L;poly(c,[x0+nx*w0,y0+ny*w0,x1+nx*w1,y1+ny*w1,x1-nx*w1,y1-ny*w1,x0-nx*w0,y0-ny*w0])}
function glow(c,x,y,r,rgb,a){if(r<1)return;const g=c.createRadialGradient(x,y,0,x,y,r);g.addColorStop(0,`rgba(${rgb},${a})`);g.addColorStop(1,`rgba(${rgb},0)`);c.fillStyle=g;c.fillRect(x-r,y-r,r*2,r*2)}
const DRAW={
bed(c,cx,by,s,k){const w=.42*s,h=.26*s;
c.fillStyle=col(28,30,32,k);c.fillRect(cx-w,by-h*.5,w*.1,h*.5);c.fillRect(cx+w*.9,by-h*.5,w*.1,h*.5);
c.fillStyle=col(122,130,126,k);c.fillRect(cx-w,by-h,w*2,h*.55);
c.fillStyle=col(178,186,180,k);c.fillRect(cx-w,by-h*1.14,w*.5,h*.3);
c.fillStyle=col(80,20,24,k);c.fillRect(cx+w*.05,by-h*.95,w*.55,h*.22)},
gurney(c,cx,by,s,k){const w=.44*s;
c.fillStyle=col(120,126,124,k);c.fillRect(cx-w+.02*s,by-.26*s,.02*s,.26*s);c.fillRect(cx+w-.04*s,by-.26*s,.02*s,.26*s);
c.fillStyle=col(150,156,152,k);c.fillRect(cx-w,by-.30*s,w*2,.05*s);
c.fillStyle=col(184,190,182,k);c.fillRect(cx-w,by-.34*s,w*2,.05*s);
c.fillStyle=col(64,18,20,k);c.fillRect(cx+.10*s,by-.335*s,.18*s,.03*s)},
body(c,cx,by,s,k,o){DRAW.gurney(c,cx,by,s,k,o);const w=.4*s;
c.fillStyle=col(208,212,202,k);poly(c,[cx-w,by-.34*s,cx-w*.85,by-.44*s,cx-w*.55,by-.45*s,cx-w*.5,by-.40*s,cx-w*.2,by-.42*s,cx+w*.5,by-.39*s,cx+w,by-.34*s]);
c.fillStyle=col(150,154,146,k);ell(c,cx-w*.72,by-.43*s,.045*s,.03*s,10);
c.fillStyle=col(100,22,26,k);c.fillRect(cx+.04*s,by-.395*s,.10*s,.045*s)},
table(c,cx,by,s,k){const w=.42*s;
c.fillStyle=col(36,32,28,k);c.fillRect(cx-w+.02*s,by-.26*s,.04*s,.26*s);c.fillRect(cx+w-.06*s,by-.26*s,.04*s,.26*s);
c.fillStyle=col(98,86,68,k);c.fillRect(cx-w,by-.30*s,w*2,.05*s);
c.fillStyle=col(58,50,40,k);c.fillRect(cx-w,by-.25*s,w*2,.03*s);
c.fillStyle=col(192,192,178,k);c.fillRect(cx-.20*s,by-.33*s,.14*s,.03*s);c.fillRect(cx+.04*s,by-.32*s,.12*s,.02*s)},
cabinet(c,cx,by,s,k){const w=.27*s,h=.62*s;
c.fillStyle=col(54,70,64,k);c.fillRect(cx-w,by-h,w*2,h);
c.fillStyle=col(34,46,42,k);c.fillRect(cx-.005*s,by-h,.01*s,h);c.fillRect(cx-w,by-h*.5,w*2,.008*s);c.fillRect(cx-w,by-h,w*2,.02*s);
c.fillStyle=col(156,164,156,k);c.fillRect(cx-.05*s,by-h*.55,.02*s,.06*s);c.fillRect(cx+.03*s,by-h*.55,.02*s,.06*s)},
crate(c,cx,by,s,k){const w=.26*s,h=.3*s;
c.fillStyle=col(106,82,54,k);c.fillRect(cx-w,by-h,w*2,h);
c.fillStyle=col(70,52,32,k);c.fillRect(cx-w,by-h,w*2,.03*s);c.fillRect(cx-w,by-.03*s,w*2,.03*s);c.fillRect(cx-.012*s,by-h,.024*s,h);
c.fillStyle=col(60,44,28,k);c.fillRect(cx-w,by-h*.5,w*2,.012*s)},
shelf(c,cx,by,s,k,o){const w=.36*s,h=.78*s,seed=(o.cx*7+o.cy*13)|0,cl=[[70,160,120],[190,150,70],[170,180,175],[90,110,170]];
c.fillStyle=col(30,34,32,k);c.fillRect(cx-w,by-h,w*2,h);
c.fillStyle=col(50,56,52,k);for(let r=0;r<4;r++)c.fillRect(cx-w+.02*s,by-h+.05*s+r*.19*s+.18*s,w*2-.04*s,.012*s);
for(let r=0;r<4;r++)for(let i=0;i<5;i++){const v=(seed+r*5+i*3)%5;if(v===4)continue;const q=cl[v];c.fillStyle=col(q[0],q[1],q[2],k);c.fillRect(cx-w+.05*s+i*.13*s,by-h+.05*s+r*.19*s+.06*s,.07*s,.12*s)}},
fridge(c,cx,by,s,k){const w=.3*s,h=.64*s;
c.fillStyle=col(150,160,156,k);c.fillRect(cx-w,by-h,w*2,h);
c.fillStyle=col(110,120,116,k);c.fillRect(cx-w,by-h*.62,w*2,.014*s);c.fillRect(cx-w,by-h*.31,w*2,.014*s);
c.fillStyle=col(60,66,64,k);c.fillRect(cx+w*.6,by-h*.86,.03*s,.09*s);c.fillRect(cx+w*.6,by-h*.5,.03*s,.09*s);c.fillRect(cx+w*.6,by-h*.2,.03*s,.09*s);
c.fillStyle=col(30,34,32,k);c.fillRect(cx-w,by-h,w*2,.02*s)},
pedestal(c,cx,by,s,k){const w=.16*s;
c.fillStyle=col(72,78,76,k);c.fillRect(cx-w*1.2,by-.05*s,w*2.4,.05*s);c.fillRect(cx-w*.8,by-.26*s,w*1.6,.21*s);c.fillRect(cx-w,by-.30*s,w*2,.04*s);
c.fillStyle=col(36,42,40,k);c.fillRect(cx-w*.8,by-.26*s,.02*s,.21*s);
c.fillStyle=`rgba(${150*k|0},${190*k|0},${180*k|0},.2)`;ell(c,cx,by-.36*s,.15*s,.13*s)},
locker(c,cx,by,s,k){const w=.24*s,h=.70*s;
c.fillStyle=col(66,80,94,k);c.fillRect(cx-w,by-h,w*2,h);
c.fillStyle=col(44,54,66,k);c.fillRect(cx-.006*s,by-h,.012*s,h);c.fillRect(cx-w,by-h,w*2,.02*s);
for(let i=0;i<4;i++){c.fillRect(cx-w*.8,by-h*.92+i*.028*s,w*.7,.008*s);c.fillRect(cx+w*.1,by-h*.92+i*.028*s,w*.7,.008*s)}
c.fillStyle=col(170,176,170,k);c.fillRect(cx-.045*s,by-h*.5,.014*s,.05*s);c.fillRect(cx+.03*s,by-h*.5,.014*s,.05*s)},
monitor(c,cx,by,s,k,o){DRAW.table(c,cx,by,s,k,o);
c.fillStyle=col(28,32,34,k);for(const dx of[-.26,0,.26])c.fillRect(cx+dx*s-.1*s,by-.5*s,.2*s,.17*s);
c.fillStyle=col(20,60,44,k);for(const dx of[-.26,0,.26])c.fillRect(cx+dx*s-.085*s,by-.485*s,.17*s,.14*s)},
bassinet(c,cx,by,s,k){const w=.27*s;
c.fillStyle=col(150,120,110,k);c.fillRect(cx-w,by-.22*s,.02*s,.22*s);c.fillRect(cx+w-.02*s,by-.22*s,.02*s,.22*s);
c.fillStyle=col(210,208,200,k);poly(c,[cx-w,by-.34*s,cx+w,by-.34*s,cx+w*.8,by-.16*s,cx-w*.8,by-.16*s]);
c.fillStyle=col(150,140,150,k);c.fillRect(cx-w*.6,by-.30*s,w*1.2,.04*s)},
scanner(c,cx,by,s,k){const w=.46*s;
c.fillStyle=col(190,196,200,k);c.fillRect(cx-w,by-.60*s,w*2,.60*s);
c.fillStyle=col(20,24,26,k);ell(c,cx,by-.32*s,.24*s,.26*s,18);
c.fillStyle=col(130,140,146,k);ell(c,cx,by-.32*s,.17*s,.19*s,16);
c.fillStyle=col(10,12,14,k);ell(c,cx,by-.32*s,.13*s,.15*s,16);
c.fillStyle=col(160,166,168,k);c.fillRect(cx-.35*s,by-.10*s,.7*s,.05*s)},
machine(c,cx,by,s,k){const w=.32*s,h=.7*s;
c.fillStyle=col(58,62,60,k);c.fillRect(cx-w,by-h,w*2,h);
c.fillStyle=col(40,44,42,k);c.fillRect(cx-w,by-h,w*2,.05*s);c.fillRect(cx-w*.8,by-h*.6,w*1.6,.012*s);
c.fillStyle=col(96,90,80,k);c.fillRect(cx-w-.03*s,by-h*.85,.03*s,h*.7);c.fillRect(cx+w,by-h*.75,.03*s,h*.6);
c.fillStyle=col(28,30,30,k);ell(c,cx,by-h*.4,.08*s,.08*s,10)},
pew(c,cx,by,s,k){const w=.42*s;
c.fillStyle=col(96,66,40,k);c.fillRect(cx-w,by-.20*s,w*2,.05*s);c.fillRect(cx-w,by-.42*s,w*2,.07*s);
c.fillStyle=col(66,44,26,k);c.fillRect(cx-w,by-.20*s,.03*s,.20*s);c.fillRect(cx+w-.03*s,by-.20*s,.03*s,.20*s);c.fillRect(cx-w,by-.42*s,.03*s,.22*s);c.fillRect(cx+w-.03*s,by-.42*s,.03*s,.22*s)},
files(c,cx,by,s,k,o){const w=.34*s,h=.74*s,seed=(o.cx*11+o.cy*7)|0,cl=[[150,140,110],[110,120,130],[140,100,90],[170,166,150]];
c.fillStyle=col(58,54,46,k);c.fillRect(cx-w,by-h,w*2,h);
c.fillStyle=col(36,34,28,k);for(let r=0;r<4;r++)c.fillRect(cx-w+.02*s,by-h+.03*s+r*.185*s+.16*s,w*2-.04*s,.012*s);
for(let r=0;r<4;r++)for(let i=0;i<7;i++){const v=(seed+r*7+i*5)%6;if(v>3)continue;const q=cl[v];c.fillStyle=col(q[0],q[1],q[2],k);c.fillRect(cx-w+.04*s+i*.088*s,by-h+.03*s+r*.185*s+.04*s,.06*s,.12*s)}},
stairs(c,cx,by,s,k,o){const w=.48*s,h=.7*s;
c.fillStyle=col(64,70,74,k);c.fillRect(cx-w,by-h,w*2,h);
c.fillStyle=col(40,46,50,k);c.fillRect(cx-w,by-h,w*2,.03*s);
for(let i=0;i<7;i++){c.fillStyle=col(96+i*7,102+i*7,106+i*7,k);c.fillRect(cx-w*.86+i*w*.045,by-.12*s-i*.075*s,w*1.72-i*w*.09,.055*s)}
c.fillStyle=col(150,154,150,k);c.fillRect(cx-w*.92,by-.52*s,.018*s,.5*s);c.fillRect(cx+w*.92-.018*s,by-.52*s,.018*s,.5*s);
c.fillStyle=col(14,44,28,k);c.fillRect(cx-.17*s,by-h-.11*s,.34*s,.09*s);
c.fillStyle=col(230,240,232,k);
if(o.upTo!=null)poly(c,[cx-.10*s,by-h-.035*s,cx-.05*s,by-h-.035*s,cx-.075*s,by-h-.085*s]);
if(o.downTo!=null)poly(c,[cx+.05*s,by-h-.085*s,cx+.10*s,by-h-.085*s,cx+.075*s,by-h-.035*s])},
med(c,cx,by,s,k,o){DRAW[o.holder](c,cx,by,s,k,o);
if(o.holder==='fridge'){c.fillStyle=col(12,22,20,k);c.fillRect(cx-.11*s,by-.47*s,.22*s,.17*s)}},
key(c,cx,by,s,k,o){DRAW.table(c,cx,by,s,k,o);
c.fillStyle=`rgba(${170*k|0},${210*k|0},${220*k|0},.18)`;c.fillRect(cx-.16*s,by-.5*s,.32*s,.2*s)}, // glass case
bat(c,cx,by,s,k){const w=.05*s,h=.13*s;
c.fillStyle=col(34,36,34,k);c.fillRect(cx-w,by-h,w*2,h);
c.fillStyle=col(224,182,50,k);c.fillRect(cx-w,by-h*.75,w*2,h*.4);
c.fillStyle=col(170,170,160,k);c.fillRect(cx-w*.4,by-h-.02*s,w*.8,.02*s)},
art(c,cx,by,s,k,o){const bob=Math.sin(T*2+o.cx*1.7)*.02,y=by-(.20+bob)*s,w=.07*s,h=.09*s;
if(o.icon==='tape'){c.fillStyle=col(30,32,34,k);c.fillRect(cx-.075*s,y-.09*s,.15*s,.09*s);c.fillStyle=col(200,204,200,k);ell(c,cx-.035*s,y-.045*s,.02*s,.02*s,8);ell(c,cx+.035*s,y-.045*s,.02*s,.02*s,8);return}
if(o.icon==='shot'){c.fillStyle=col(210,220,215,k);c.fillRect(cx-.07*s,y-.045*s,.11*s,.03*s);c.fillStyle=col(255,120,90,k);c.fillRect(cx-.05*s,y-.04*s,.06*s,.02*s);c.fillStyle=col(160,168,164,k);c.fillRect(cx+.04*s,y-.038*s,.05*s,.006*s);return}
if(o.icon==='torch'){c.fillStyle=col(60,64,66,k);c.fillRect(cx-.06*s,y-.05*s,.10*s,.05*s);c.fillStyle=col(230,200,90,k);c.fillRect(cx+.04*s,y-.06*s,.03*s,.07*s);return}
c.fillStyle=col(214,220,214,k);c.fillRect(cx-w,y-h,w*2,h);c.fillStyle=col(96,140,190,k);c.fillRect(cx-w,y-h,w*2,h*.22);
c.fillStyle=col(120,126,120,k);c.fillRect(cx-w*.7,y-h*.6,w*1.4,.008*s);c.fillRect(cx-w*.7,y-h*.35,w*1.0,.008*s)},
mon(c,cx,by,s,k,o){ // tall, starved and narrow
const q=monPose(o,cx,by,s),X=q[0],B=q[1],r=o.reach,L=(a,b)=>a+(b-a)*r,skin=col(150,158,150,k);
c.fillStyle=col(30,34,32,k);limb(c,X-.028*s,B-.34*s,X-.040*s,B,.017*s,.011*s);limb(c,X+.028*s,B-.34*s,X+.040*s,B,.017*s,.011*s); // legs
c.fillRect(X-.06*s,B-.012*s,.045*s,.012*s);c.fillRect(X+.015*s,B-.012*s,.045*s,.012*s);
c.fillStyle=col(58,66,60,k);poly(c,[X-.05*s,B-.40*s,X+.05*s,B-.40*s,X+.062*s,B-.24*s,X+.04*s,B-.27*s,X+.025*s,B-.20*s,X+.003*s,B-.26*s,X-.02*s,B-.19*s,X-.038*s,B-.26*s,X-.06*s,B-.23*s]); // rag around the hips
c.fillStyle=skin;poly(c,[X-.05*s,B-.40*s,X+.05*s,B-.40*s,X+.075*s,B-.62*s,X-.075*s,B-.62*s]); // narrow ribcage
c.fillStyle=col(18,20,19,k);for(let i=0;i<4;i++)c.fillRect(X-.052*s,B-.46*s-i*.03*s,.104*s,.008*s);c.fillRect(X-.004*s,B-.62*s,.008*s,.22*s);
c.fillStyle=col(108,22,26,k);ell(c,X+.02*s,B-.30*s,.02*s,.03*s,8);ell(c,X-.03*s,B-.53*s,.014*s,.02*s,8);
for(const sd of[-1,1]){ // very thin, very long arms
const ax=X+sd*.075*s,ay=B-.60*s,ex=X+sd*L(.115,.20)*s,ey=B-L(.36,.55)*s,hx=X+sd*L(.10,.17)*s,hy=B-L(.13,.80)*s;
c.fillStyle=skin;limb(c,ax,ay,ex,ey,.014*s,.010*s);limb(c,ex,ey,hx,hy,.010*s,.007*s);
c.fillStyle=col(214,218,206,k);for(let i=-1;i<=1;i++)limb(c,hx,hy,hx+sd*(i*.014+.008)*s,hy+L(.065,-.065)*s,.004*s,.0015*s);
}
c.fillStyle=col(128,136,128,k);c.fillRect(X-.012*s,B-.68*s,.024*s,.07*s);
c.fillStyle=col(176,184,174,k);ell(c,X,B-.75*s,.036*s,.066*s); // narrow skull
c.fillStyle=col(8,6,6,k);ell(c,X-.014*s,B-.765*s,.011*s,.016*s,8);ell(c,X+.014*s,B-.765*s,.011*s,.016*s,8);ell(c,X,B-.71*s,.010*s,.022*s*(1+r),8);
c.fillStyle=col(14,12,12,k);limb(c,X-.03*s,B-.80*s,X-.05*s,B-.62*s,.004*s,.0015*s);limb(c,X+.03*s,B-.80*s,X+.055*s,B-.63*s,.004*s,.0015*s);
}};
function monPose(o,cx,by,s){return[cx+Math.sin(o.anim*2.1)*.012*s,by-(o.moving?Math.abs(Math.sin(o.anim*6))*.014*s:0)]}
const EMIT={ // glowing bits, drawn after the darkness pass so they shine through it
med(c,cx,by,s,f,o){const pu=.78+.22*Math.sin(T*3+o.cx),y=by-HOLD[o.holder]*s;
glow(c,cx,y,.42*s,'90,255,170',.36*pu*f);glow(c,cx,y,.14*s,'200,255,230',.7*pu*f);
c.fillStyle=`rgba(210,255,232,${.85*f})`;c.fillRect(cx-.016*s,y-.05*s,.032*s,.10*s);
c.fillStyle=`rgba(255,255,255,${.9*f})`;c.fillRect(cx-.02*s,y-.06*s,.04*s,.014*s)},
key(c,cx,by,s,f,o){const y=by-.38*s,pu=.75+.25*Math.sin(T*3);
glow(c,cx,y,.5*s,'255,210,90',.34*pu*f);glow(c,cx,y,.15*s,'255,240,180',.7*pu*f);
c.fillStyle=`rgba(255,226,120,${.95*f})`;c.fillRect(cx-.055*s,y-.008*s,.13*s,.016*s);c.fillRect(cx+.06*s,y,.014*s,.03*s);c.fillRect(cx+.085*s,y,.014*s,.022*s);ell(c,cx-.075*s,y,.028*s,.028*s,10)},
bat(c,cx,by,s,f){glow(c,cx,by-.07*s,.24*s,'255,205,70',.4*f)},
art(c,cx,by,s,f,o){glow(c,cx,by-.24*s,.30*s,'140,200,255',.30*f*(.8+.2*Math.sin(T*3+o.cx)))},
stairs(c,cx,by,s,f){const pu=final?.55+.45*Math.sin(T*5):.6;glow(c,cx,by-.7*s-.065*s,.34*s,'120,255,170',.28*pu*f)},
monitor(c,cx,by,s,f){glow(c,cx,by-.42*s,.36*s,'80,255,190',.16*f)},
machine(c,cx,by,s,f,o){const b=.6+.4*Math.sin(T*3+o.cx);glow(c,cx+.1*s,by-.5*s,.1*s,'255,60,40',.5*b*f);c.fillStyle=`rgba(255,80,50,${.8*b*f})`;c.fillRect(cx+.085*s,by-.515*s,.03*s,.03*s)},
mon(c,cx,by,s,f,o){const q=monPose(o,cx,by,s),pu=(o.mode==='hunt'?1:.6)*TUNE.eyeGlow;
for(const sd of[-1,1]){const x=q[0]+sd*.014*s,y=q[1]-.765*s;glow(c,x,y,.052*s,'255,40,30',.75*pu);c.fillStyle=`rgba(255,190,170,${Math.min(1,.9*pu)})`;c.fillRect(x-.005*s,y-.005*s,.01*s,.01*s)}}};
const vis=[];
function runsFor(x0,x1,fwd){
x0=Math.max(0,Math.floor(x0));x1=Math.min(W,Math.ceil(x1));
const runs=[];let st=-1;
for(let x=x0;x<x1;x++){if(fwd<depth[x]+.25){if(st<0)st=x}else if(st>=0){runs.push([st,x]);st=-1}}
if(st>=0)runs.push([st,x1]);
return runs;
}
function drawSprites(){
vis.length=0;
for(const o of SPR){
if(o.taken)continue;
if(o.type==='mon'&&(!mon.active||mon.f!==F))continue;
const rx=o.x-p.x,ry=o.y-p.y,fwd=rx*cD.x+ry*cD.y;
if(fwd<.16||fwd>18)continue;
const lat=-rx*cD.y+ry*cD.x,sx=W/2*(1+lat/(FOV_T*fwd)),s=H/fwd,hw=o.hw*s;
if(sx+hw<0||sx-hw>W)continue;
const runs=runsFor(sx-hw,sx+hw,fwd);if(!runs.length)continue;
o.fwd=fwd;o.sx=sx;o.s=s;o.runs=runs;vis.push(o);
}
vis.sort((a,b)=>b.fwd-a.fwd);
for(const o of vis){
const k=lightAt(o.sx,o.fwd,o.z),by=H/2+.5*o.s,fn=DRAW[o.type==='prop'?o.kind:o.type];
for(const r of o.runs){ctx.save();ctx.beginPath();ctx.rect(r[0],0,r[1]-r[0],H);ctx.clip();fn(ctx,o.sx,by,o.s,k,o);ctx.restore()}
}
}
function drawEmissive(){
ctx.globalCompositeOperation='lighter';
for(const o of vis){
const fn=EMIT[o.type==='prop'?o.kind:o.type];if(!fn)continue;
const by=H/2+.5*o.s,f=Math.max(.25,Math.min(1,1.15-o.fwd/16));
for(const r of o.runs){ctx.save();ctx.beginPath();ctx.rect(r[0],0,r[1]-r[0],H);ctx.clip();fn(ctx,o.sx,by,o.s,f,o);ctx.restore()}
}
ctx.globalCompositeOperation='source-over';
}
function drawOverlay(){
if(flash){
const g=ctx.createRadialGradient(W/2,H*.54,15,W/2,H*.54,H*.72);
g.addColorStop(0,'rgba(235,245,215,.12)');g.addColorStop(.45,'rgba(180,190,150,.035)');g.addColorStop(1,`rgba(0,0,0,${TUNE.vig})`);
ctx.fillStyle=g;ctx.fillRect(0,0,W,H);
}else{ctx.fillStyle='rgba(0,0,0,.2)';ctx.fillRect(0,0,W,H)}
}
function drawHideMask(){ // what you see from inside a cupboard / under a bed
const w=W,h=H,sway=Math.sin(T*1.3)*h*.004;
ctx.fillStyle='#020202';
if(hideProp.hide===1){
const sl=w*.075,x0=w/2-sl/2;
ctx.fillRect(0,0,x0,h);ctx.fillRect(x0+sl,0,w-x0-sl,h);
ctx.fillStyle='rgba(2,2,2,.92)';ctx.fillRect(x0,0,sl,h*.12);ctx.fillRect(x0,h*.9,sl,h*.1);
ctx.fillStyle='rgba(70,80,76,.55)';ctx.fillRect(x0-2,0,2,h);ctx.fillRect(x0+sl,0,2,h);
}else{
const y0=h*.56+sway,y1=h*.9+sway;
ctx.fillRect(0,0,w,y0);ctx.fillRect(0,y1,w,h-y1);
ctx.fillStyle='rgba(30,34,32,.95)';ctx.fillRect(0,y0,w*.05,y1-y0);ctx.fillRect(w*.95,y0,w*.05,y1-y0);
}
}
function render(){
const sh=Math.max(touching?grab/TUNE.touchTime*.03:0,scareShakeT>0?.045*Math.min(1,scareShakeT/.3):0),va=p.a+(sh?(Math.random()-.5)*sh*2:0); // camera shakes while it holds you, or during a scare
cD.x=Math.cos(va);cD.y=Math.sin(va);cP.x=-cD.y*FOV_T;cP.y=cD.x*FOV_T;
ctx.fillStyle=bgGrad;ctx.fillRect(0,0,W,H);
drawWalls();drawSprites();drawOverlay();drawEmissive();
if(hiding)drawHideMask();
ctx.fillStyle='rgba(0,0,0,.11)';for(let y=0;y<H;y+=4)ctx.fillRect(0,y,W,1);
drawScareOverlay();
}
/* =====================================================================
MINIMAP — explored rooms only; markers for stairs, exit, key, and any intel you have picked up
===================================================================== */
function reveal(){ // uncover the map around you (and whole rooms once you step in)
const fl=FL[F],R=TUNE.reveal,cx=p.x|0,cy=p.y|0;
for(let dy=-R;dy<=R;dy++)for(let dx=-R;dx<=R;dx++){
const x=cx+dx,y=cy+dy;if(x<0||y<0||x>=MW||y>=MH||dx*dx+dy*dy>R*R)continue;
const i=y*MW+x;if(fl.seen[i]||fl.wall[i])continue;
if(losClear(p.x,p.y,x+.5,y+.5,false))fl.seen[i]=1;
}
const z=fl.zone[cy*MW+cx];
if(z)for(let i=0;i<MW*MH;i++)if(fl.zone[i]===z)fl.seen[i]=1;
}
function revealFloor(f){const fl=FL[f];for(let i=0;i<MW*MH;i++)if(!fl.wall[i])fl.seen[i]=1}
function drawMap(){
const c=mctx,cs=6;
let f=F;
if(intel.meds>0){const ord=[F].concat([0,1,2,3].filter(x=>x!==F));f=ord[Math.min(3,((10-intel.meds)/2.5)|0)]}
mmF=f;const fl=FL[f],blink=((T*3)|0)%2===0;
c.fillStyle='#050809';c.fillRect(0,0,mm.width,mm.height);
for(let y=0;y<MH;y++)for(let x=0;x<MW;x++){
const i=y*MW+x;
if(!fl.wall[i]){
if(fl.seen[i]){const z=fl.zone[i],t=ZDEF[z].t;c.fillStyle=z?`rgb(${t[0]*62|0},${t[1]*62|0},${t[2]*62|0})`:'#2a3a35';c.fillRect(x*cs,y*cs,cs,cs)}
}else{
let nb=false;
for(let dy=-1;dy<=1&&!nb;dy++)for(let dx=-1;dx<=1;dx++){const nx=x+dx,ny=y+dy;if(nx>=0&&ny>=0&&nx<MW&&ny<MH&&!fl.wall[ny*MW+nx]&&fl.seen[ny*MW+nx]){nb=true;break}}
if(nb){c.fillStyle=fl.wall[i]===2?'#8a4a48':'#7d8c86';c.fillRect(x*cs,y*cs,cs,cs)}
}
}
const dot=(x,y,color,r)=>{c.fillStyle=color;c.fillRect(x*cs-r,y*cs-r,r*2,r*2)};
for(const st of fl.stairs)if(mapStairs||fl.seen[st.ly*MW+st.lx])dot(st.x+.5,st.y+.5,final&&f>0?(blink?'#ffffff':'#54d2ff'):'#54d2ff',final&&f>0?3.5:2.5);
if(f===0&&(mapStairs||fl.seen[2*MW+2]||hasKey))dot(EXIT.x,EXIT.y,hasKey?(blink?'#7dffb0':'#1f9a55'):'#39d97a',hasKey?4.5:2.5); // the exit flashes once you hold the key
if(got>=NEED&&!hasKey&&KEY&&KEY.f===f)dot(KEY.x,KEY.y,blink?'#ffe27a':'#a5842a',4);
if(intel.meds>0)for(const m of MEDS)if(!m.taken&&m.f===f)dot(m.x,m.y,blink?'#7dffb0':'#2ea364',3.5);
if(mon.active&&mon.f===f&&(intel.mon>0||(TUNE.blip&&f===F&&mon.dist<9)))dot(mon.x,mon.y,blink?'#ff4a3a':'#8a1a14',3.5);
if(f===F){
const px=p.x*cs,py=p.y*cs,a=p.a;
c.fillStyle='#ffe27a';c.beginPath();c.moveTo(px+Math.cos(a)*5.5,py+Math.sin(a)*5.5);c.lineTo(px+Math.cos(a+2.5)*4,py+Math.sin(a+2.5)*4);c.lineTo(px+Math.cos(a-2.5)*4,py+Math.sin(a-2.5)*4);c.closePath();c.fill();
}
const L=[FL[f].name];
if(intel.meds>0)L.push('MEDICINES SHOWN '+Math.ceil(intel.meds)+'s');
if(intel.mon>0)L.push('TRACKING IT '+Math.ceil(intel.mon)+'s'+(mon.active?(mon.f===f?'':' · '+FL[mon.f].name):' · NOT WOKEN'));
if(intel.stam>0)L.push('ADRENALINE '+Math.ceil(intel.stam)+'s');
if(intel.quiet>0)L.push('QUIET '+Math.ceil(intel.quiet)+'s');
if(got>=NEED&&!hasKey&&KEY)L.push('KEY · '+FL[KEY.f].name);else if(keyKnown&&!hasKey)L.push('KEY · '+FL[KEY.f].name+' (after the medicines)');
if(hasKey)L.push('EXIT · 1ST FLOOR');
elMm.innerHTML=L.join('<br>');
}
/* =====================================================================
MONSTER — patrols the halls, hunts by sound and sight, gives up when you break line of sight
(it teleports somewhere else), and only chases you relentlessly after you take the master key.
It needs TUNE.touchTime seconds of contact to kill you.
===================================================================== */
function setGoal(cx,cy){ // patrol / search target (never inside furniture)
const B=FL[mon.f].block;
if(B[cy*MW+cx]){for(const d of[[1,0],[-1,0],[0,1],[0,-1]]){const nx=cx+d[0],ny=cy+d[1];if(nx>=0&&ny>=0&&nx<MW&&ny<MH&&!B[ny*MW+nx]){cx=nx;cy=ny;break}}}
mon.goal=[cx,cy];mon.gField=bfs(cx,cy,mon.f);mon.wait=0;
}
function pickWaypoint(){
const way=FL[mon.f].way;let w,n=0;
if(mon.f===F&&Math.random()<TUNE.seek){ // sometimes it just happens to wander toward wherever you are
const f=bfs(p.x|0,p.y|0,F);let bd=1e9;
for(const c of way){const d=f[c[1]*MW+c[0]];if(d>=10&&d<bd&&!(mon.goal&&c[0]===mon.goal[0]&&c[1]===mon.goal[1])){bd=d;w=c}}
if(w){setGoal(w[0],w[1]);return}
}
do{w=way[(Math.random()*way.length)|0];n++}while(n<8&&(Math.hypot(w[0]+.5-mon.x,w[1]+.5-mon.y)<4||(mon.goal&&w[0]===mon.goal[0]&&w[1]===mon.goal[1])));
setGoal(w[0],w[1]);
}
function relocateMonster(kind){ // it simply isn't there any more: somewhere else in the building (kind 1 = first appearance, 2 = final chase, 3 = drifts back to your floor)
const sameFirst=kind>=2?true:(kind===1?Math.random()<.35:Math.random()<.55),others=[0,1,2,3].filter(x=>x!==F);
const order=sameFirst?[F].concat(others.sort(()=>Math.random()-.5)):others.sort(()=>Math.random()-.5).concat([F]);
for(const f of order){
const way=FL[f].way;let f2=null;if(f===F)f2=bfs(p.x|0,p.y|0,F);
const idx=way.map((_,i)=>i).sort(()=>Math.random()-.5);
for(const i of idx){
const w=way[i];
if(f===F){const d=f2[w[1]*MW+w[0]];if(d<16||losClear(p.x,p.y,w[0]+.5,w[1]+.5,false,F))continue}
mon.f=f;mon.x=w[0]+.5;mon.y=w[1]+.5;
mon.active=true;mon.mode='patrol';mon.cool=kind===1?8:(kind===2?0:TUNE.cool);mon.chaseT=0;mon.lost=0;mon.goal=null;mon.stuck=0;mon.stun=0;mon.unseen=0;mon.sniff=0;
return true;
}
}
if(kind){const f=(F+1)%4,w=FL[f].way[0];mon.f=f;mon.x=w[0]+.5;mon.y=w[1]+.5;mon.active=true;mon.mode='patrol';mon.cool=kind===1?8:0;mon.chaseT=0;mon.lost=0;mon.goal=null;mon.stuck=0;mon.stun=0;mon.unseen=0;mon.sniff=0;return true} // fallback: it just appears on another floor
return false;
}
function spawnMonster(){if(relocateMonster(1)){moan();if(Math.random()<.7*TUNE.flickMul)flickerT=.6}}
function pickStair(f,target){ // nearest stairwell on floor f that leads toward the given target floor
let best=null,bd=1e9;
for(const st of FL[f].stairs){
if(st.upTo!==target&&st.downTo!==target)continue;
if(!st.field)st.field=bfs(st.lx,st.ly,f);
const d=st.field[(mon.y|0)*MW+(mon.x|0)];
if(d>=0&&d<bd){bd=d;best=st}
}
return best||FL[f].stairs[0];
}
function steer(field,tx,ty,step){
const cx=mon.x|0,cy=mon.y|0;let ax=tx,ay=ty;
if(!(Math.hypot(tx-mon.x,ty-mon.y)<3&&losClear(mon.x,mon.y,tx,ty,true,mon.f))&&field[cy*MW+cx]!==0){const n=flowTarget(field,cx,cy,mon.f);if(n){ax=n[0];ay=n[1]}}
const dx=ax-mon.x,dy=ay-mon.y,d=Math.hypot(dx,dy)||1,st=Math.min(d,step);
moveEntity(mon,dx/d*st,dy/d*st,.2);
}
function updateMonster(dt){
if(!mon.active){mon.dist=99;mon.pd=-1;return}
mon.z=FL[mon.f].zone[(mon.y|0)*MW+(mon.x|0)];
const same=mon.f===F;let dist=99,pd=-1,sees=false,hears=false,los=false;
if(same){
const pc=(p.y|0)*MW+(p.x|0);if(pc!==pFieldCell||pFieldF!==F){pFieldCell=pc;pFieldF=F;pField=bfs(p.x|0,p.y|0,F)}
pd=pField[(mon.y|0)*MW+(mon.x|0)];dist=Math.hypot(p.x-mon.x,p.y-mon.y);
los=losClear(mon.x,mon.y,p.x,p.y,false);
if(!hiding){
sees=dist<(flash?11:7.5)*TUNE.sightMul&&los;
const noise=(moving?(sprinting?9:4.5):2)*(flash?1.15:1)*TUNE.hearMul*(intel.quiet>0?.45:1); // sound travels along the halls, not through walls
hears=pd>=0&&pd<=noise*1.6;
}
}
mon.dist=dist;mon.pd=pd;
if(mon.cool>0){mon.cool-=dt;sees=hears=false}
if(mon.stun>0)mon.stun-=dt;
// ---- what is it doing? ----
if(final)mon.mode='hunt';
else if(sees||hears){
if(mon.mode!=='hunt'){mon.chaseT=0;stinger()}
mon.mode='hunt';mon.lost=0;mon.lx=p.x;mon.ly=p.y;
}else if(mon.mode==='hunt'){mon.lost+=dt;if(mon.lost>5||hiding){mon.mode='search';setGoal(mon.lx|0,mon.ly|0)}}
if(mon.mode!=='patrol')mon.chaseT+=dt;
// ---- it never stays on a floor you have left for long ----
if(!final&&!same){mon.away+=dt;if(mon.away>TUNE.roam){mon.away=0;if(relocateMonster(3))return}}else mon.away=0;
// ---- you turned the corner: it is gone (never during the final chase) ----
const blind=!same||hiding||!los;mon.unseen=blind?mon.unseen+dt:0;
if(!final&&mon.mode!=='patrol'&&mon.chaseT>TUNE.minChase&&mon.unseen>1&&!(same&&dist<4)){if(relocateMonster())return;mon.unseen=0}
// ---- hiding: it may sniff you out ----
if(hiding){
if(same&&dist<2.3&&mon.mode!=='patrol')mon.sniff+=dt;else mon.sniff=Math.max(0,mon.sniff-dt*.7);
if(TUNE.findTime>0&&mon.sniff>TUNE.findTime){foundOut();return}
}else mon.sniff=0;
// ---- where is it going? ----
let speed=0,field=null,tx=0,ty=0;
if(mon.mode==='hunt'){
speed=final?TUNE.monFinal:TUNE.monHunt;
if(same){field=pField;tx=hiding?mon.lx:p.x;ty=hiding?mon.ly:p.y}
else{ // final chase across floors: head for the stairs toward you
const target=nextHop(mon.f,F),st=pickStair(mon.f,target);
field=st.field||(st.field=bfs(st.lx,st.ly,mon.f));tx=st.lx+.5;ty=st.ly+.5;
if(Math.hypot(tx-mon.x,ty-mon.y)<.7){
const arrives=target===F;
if(mon.stun>0)speed=0; // it takes a moment to climb
else if(arrives&&Math.hypot(p.x-st.ax,p.y-st.ay)<2.6&&mon.stairWait<2.5){mon.stairWait+=dt;speed=0} // does not pop out on top of you
else{mon.f=target;mon.x=st.ax;mon.y=st.ay;mon.stun=1.4;mon.stairWait=0;mon.stuck=0;mon.unseen=0;pFieldCell=-1;return}
}
}
}else{
if(!mon.goal)pickWaypoint();
speed=mon.mode==='search'?TUNE.monSearch:TUNE.monPatrol;field=mon.gField;tx=mon.goal[0]+.5;ty=mon.goal[1]+.5;
if(Math.hypot(tx-mon.x,ty-mon.y)<.6){speed=0;mon.wait+=dt;if(mon.wait>(mon.mode==='search'?3:1.2)){mon.mode='patrol';pickWaypoint()}}
}
if(mon.stun>0)speed=0;
mon.moving=false;
if(speed>0&&field&&!(mon.mode==='hunt'&&same&&dist<TUNE.touchRange*.65)){ // once it is on top of you it stops and holds on
const bx=mon.x,by=mon.y;steer(field,tx,ty,speed*dt);
const moved=Math.hypot(mon.x-bx,mon.y-by);mon.moving=moved>speed*dt*.3;
if(moved<speed*dt*.25){mon.stuck+=dt;if(mon.stuck>1.4){mon.x=(mon.x|0)+.5;mon.y=(mon.y|0)+.5;mon.stuck=0}}else mon.stuck=0;
}
mon.anim+=dt*(mon.moving?1.4:.6);
mon.reach=Math.max(0,Math.min(1,(1.7-dist)/1));
}
/* THE 2-SECOND RULE: contact fills the grab meter, breaking away drains it, a full meter kills */
function updateGrab(dt){
const near=mon.active&&mon.f===F&&!hiding&&mon.stun<=0&&mon.dist<TUNE.touchRange&&losClear(mon.x,mon.y,p.x,p.y,true);
if(near&&!touching){sound(60,.6,'sawtooth',.16);noise(.35,.1,500,1);if(graceMsgs++<2)msg('IT HAS YOU — RUN!',1500)}
touching=near;
grab=near?Math.min(TUNE.touchTime,grab+dt):Math.max(0,grab-dt*TUNE.touchDecay);
if(grab>=TUNE.touchTime)die();
}
/* =====================================================================
HIDING + STAIRS (context buttons)
===================================================================== */
function nearStairs(){for(const st of FL[F].stairs)if(Math.hypot(st.x+.5-p.x,st.y+.5-p.y)<1.75&&losClear(p.x,p.y,st.x+.5,st.y+.5,false))return st;return null}
function nearHide(){let best=null,bd=1.4;for(const o of SPR)if(o.type==='prop'&&o.hide){const d=Math.hypot(o.x-p.x,o.y-p.y);if(d<bd&&losClear(p.x,p.y,o.x,o.y,false)){bd=d;best=o}}return best}
function tooClose(){return mon.active&&mon.f===F&&mon.mode==='hunt'&&mon.dist<3.2&&losClear(mon.x,mon.y,p.x,p.y,false)}
function canHide(){return mon.active&&!final&&!touching&&!tooClose()}
function safeToLeave(){return !mon.active||mon.f!==F||mon.dist>7.5||(mon.mode==='patrol'&&mon.dist>5)}
function hideIn(o){
if(hiding||busy||!canHide())return;
busy=true;fade(1,()=>{
hiding=true;hideProp=o;hideExit={x:p.x,y:p.y,a:p.a};
p.x=o.x;p.y=o.y;p.a=hideA=Math.atan2(hideExit.y-o.y,hideExit.x-o.x);
flashWas=flash;flash=false;moving=false;sprinting=false;joy={x:0,y:0,id:null};stick.style.transform='';
if(mon.active&&mon.f===F&&mon.mode==='hunt'){mon.mode='search';mon.lost=0;setGoal(hideExit.x|0,hideExit.y|0)}
msg('YOU ARE HIDING',2200,'Stay still. Wait until it is gone.');
fade(0);busy=false;
});
}
function unhide(){
if(!hiding||busy||!safeToLeave())return;
busy=true;fade(1,()=>{hiding=false;p.x=hideExit.x;p.y=hideExit.y;p.a=hideExit.a;flash=flashWas&&battery>0;fade(0);busy=false});
}
function foundOut(){ // it sniffed you out: dragged out with the meter already filling
if(!hiding)return;
msg('IT FOUND YOU!',1600);sound(70,.5,'sawtooth',.14);noise(.4,.12,700,1);
hiding=false;p.x=hideExit.x;p.y=hideExit.y;p.a=Math.atan2(mon.y-p.y,mon.x-p.x);flash=flashWas&&battery>0;
grab=Math.max(grab,TUNE.touchTime*.55);mon.sniff=0;mon.mode='hunt';mon.lost=0;mon.chaseT=0;
}
function useStairs(target,st){
if(busy||hiding)return;busy=true;stairSfx();
fade(1,()=>{
const nf=target;setFloor(nf);p.x=st.ax;p.y=st.ay;p.a=st.aa;p.f=nf;reveal();
if(mon.active&&!final){mon.mode='patrol';mon.goal=null;mon.chaseT=0;mon.cool=Math.max(mon.cool,TUNE.cool*.6);if(mon.f===nf&&Math.hypot(mon.x-st.ax,mon.y-st.ay)<9)relocateMonster()}
msg(FL[nf].name,2200);fade(0);busy=false;
});
}
function updateContext(){
let a1=null,a2=null;
if(hiding)a1=safeToLeave()?{label:'COME OUT',fn:unhide}:{label:'HIDING…',off:true,fn:()=>{}};
else if(!busy){
const st=nearStairs();
if(st){
if(st.upTo!=null)a1={label:'UP â–²',fn:()=>useStairs(st.upTo,st)};
if(st.downTo!=null){const b={label:st.downTo===3?'BASEMENT â–¼':'DOWN â–¼',fn:()=>useStairs(st.downTo,st)};if(a1)a2=b;else a1=b}
}else if(mon.active){const h=nearHide();if(h)a1=final?{label:'IT KNOWS',off:true,fn:()=>{}}:{label:tooClose()?'TOO CLOSE':'HIDE',off:!canHide(),fn:()=>hideIn(h)}}
}
setAct(0,a1);setAct(1,a2);
}
/* =====================================================================
BASEMENT SCARES — scripted frights, separate from the wandering monster
AI. Each one fires once per playthrough. None of them can kill you by
themselves; the only way to die is the 2-second grab, same as anywhere
else in the hospital.
===================================================================== */
const SCARES=[
{f:3,x:9, y:19,r:1.6,kind:'silhouette'},
{f:3,x:15,y:11,r:1.3,kind:'blackout'},
{f:3,x:8, y:8, r:1.4,kind:'flicker'},
{f:3,x:16,y:8, r:1.4,kind:'clang'},
{f:3,x:11,y:17,r:1.4,kind:'whisper'},
{f:3,x:22,y:17,r:1.7,kind:'faceclose'},
{f:3,x:13,y:19,r:1.6,kind:'finalscream'}];
let scareT=0,scareKind='',scareShakeT=0,scareSide=1,scareT0=0;
function triggerScare(kind){
scareKind=kind;scareSide=Math.random()<.5?-1:1;
if(kind==='silhouette'){scareT=.5;noise(.3,.09,900,3);sound(180,.2,'sawtooth',.06,.08)}
else if(kind==='blackout'){scareT=1.7;flickerT=Math.max(flickerT,1.7);sound(50,.9,'sawtooth',.16);noise(.6,.14,700,1,.5);scareShakeT=.3}
else if(kind==='flicker'){scareT=1.4;flickerT=Math.max(flickerT,1.4);sound(46,.8,'sawtooth',.17,.35);noise(.5,.16,650,1,.35);scareShakeT=.35}
else if(kind==='clang'){scareT=.35;sound(90,.12,'square',.16);sound(60,.3,'square',.14,.06);scareShakeT=.4}
else if(kind==='whisper'){scareT=.6;noise(1.1,.05,1600,4);noise(.8,.04,1200,5,.3)}
else if(kind==='faceclose'){scareT=.55;sound(48,.6,'sawtooth',.2);sound(72,.5,'sawtooth',.15,.03);noise(.4,.18,800,1);scareShakeT=.5}
else if(kind==='finalscream'){scareT=1.1;flickerT=Math.max(flickerT,1.1);sound(52,.7,'sawtooth',.18);noise(.5,.15,750,1,.3);scareShakeT=.4}
scareT0=scareT;
}
function checkScares(){
if(F!==3||hiding)return;
for(const s of SCARES)if(!s.fired&&Math.hypot(s.x+.5-p.x,s.y+.5-p.y)<s.r){s.fired=true;triggerScare(s.kind);break}
}
function drawScareOverlay(){
if(scareT<=0)return;
const fakeMon={reach:1,anim:T*3.4,moving:false,mode:'hunt'};
if(scareKind==='faceclose'){
const s=H*1.15,cx=W/2,by=H*.74,a=Math.min(1,scareT*5);
ctx.save();ctx.globalAlpha=a;ctx.fillStyle='rgba(10,2,2,.55)';ctx.fillRect(0,0,W,H);
DRAW.mon(ctx,cx,by,s,1,fakeMon);EMIT.mon(ctx,cx,by,s,1,fakeMon);ctx.restore();
}else if(scareKind==='silhouette'){
const side=scareSide,s=H*.6,cx=W/2+side*W*.36,by=H*.84,a=Math.min(1,scareT*4)*.8;
ctx.save();ctx.globalAlpha=a;DRAW.mon(ctx,cx,by,s,.2,fakeMon);ctx.restore();
}else if(scareKind==='blackout'||scareKind==='finalscream'){
ctx.save();ctx.fillStyle='#000';ctx.globalAlpha=Math.min(1,scareT*2.4);ctx.fillRect(0,0,W,H);
if(scareT<scareT0*.7){const ex=W/2+Math.sin(T*6.5)*W*.05,ey=H*.44;glow(ctx,ex-W*.026,ey,W*.022,'255,30,20',.92);glow(ctx,ex+W*.026,ey,W*.022,'255,30,20',.92)}
ctx.restore();
}
}
/* =====================================================================
GAME EVENTS
===================================================================== */
const near=o=>Math.hypot(o.x-p.x,o.y-p.y)<1.05&&losClear(p.x,p.y,o.x,o.y,false);
function takeMed(m){
m.taken=true;got++;sound(520,.18,'sine',.08);sound(780,.25,'sine',.06,.12);setObjective();
if(got>=NEED){keyKnown=true;sound(45,1.2,'sawtooth',.12);msg(m.name+' RECOVERED',6200,m.note+" That's all of them. The master key is in the Director's Office on the 3rd floor.")}
else msg(`${m.name} RECOVERED — ${NEED-got} REMAIN`,4200,m.note);
if(!mon.active&&got>=TUNE.monStart)spawnMonster();
}
function tryKey(){
if(got<NEED){const n=performance.now();if(n-lastMsgKey>4500){lastMsgKey=n;msg('THE MASTER KEY IS LOCKED IN ITS CASE',4200,'It only opens once every medicine has been found.')}return}
KEY.taken=true;hasKey=true;sound(880,.2,'sine',.08);sound(1175,.3,'sine',.07,.1);setObjective();
startFinal();
}
function startFinal(){ // the key is taken: the alarm goes, the exit flashes, and it comes for you
final=true;relocateMonster(2);mon.mode='hunt';mon.cool=0;mon.stun=2.5;mon.lost=0;
alarm();flickerT=1;
msg('MASTER KEY TAKEN',5600,'The exit is flashing on your map. It knows where you are. RUN.');
}
function readArt(a){
const d=ARTDEF[a.id];a.taken=true;sound(660,.1,'sine',.06);sound(880,.16,'sine',.05,.09);
switch(d.fx){
case 'meds':intel.meds=10;break;
case 'track':intel.mon=15;break;
case 'stam':intel.stam=20;stamina=100;exhausted=false;break;
case 'keyhint':keyKnown=true;break;
case 'map':revealFloor(F);break;
case 'armor':TUNE.touchTime+=1;break;
case 'quiet':intel.quiet=60;break;
case 'stairs':mapStairs=true;break;
case 'torch':battery=Math.min(100,battery+60);break;
}
msg(d.n,6000,d.t);
}
function die(){ // no jump scare: the light goes out and that is that
if(frozen)return;frozen=true;elDmg.style.opacity=1;
sound(38,1.2,'sawtooth',.25);sound(62,.9,'square',.12,.05);noise(.7,.2,900,1);
setTimeout(()=>fade(1,()=>finish(false),800),350);
}
function win(){if(frozen)return;frozen=true;sound(523,.4,'sine',.07);sound(659,.5,'sine',.06,.15);sound(784,.7,'sine',.06,.3);fade(1,()=>finish(true),800)}
function finish(w){
if(!started)return;started=false;try{if(audio)audio.suspend()}catch(err){}
elDmg.style.opacity=0;elGrab.style.opacity=0;setAct(0,null);setAct(1,null);
const mins=Math.floor(gameT/60),secs=('0'+Math.floor(gameT%60)).slice(-2);
$('end').style.display='flex';
$('endTitle').textContent=w?'YOU ESCAPED':'YOU WERE FOUND';
$('endText').textContent=w?`You carried all ${NEED} medicines and the master key out into the dawn (${DIFFS[DIFF].name} · ${mins}:${secs}). The people outside may have a chance, but something followed the ambulance tracks...`
:`You recovered ${got} of ${NEED} medicines on ${DIFFS[DIFF].name}. The ward keeps what enters.`;
}
let lastBat='',lastDmg=-1,lastStam='';
function hud(dt){
const bt='FLASHLIGHT '+Math.ceil(battery)+'%'+(flash?'':' · OFF');if(bt!==lastBat){elBat.textContent=bt;lastBat=bt}
const dm=Math.round(Math.min(1,grab/TUNE.touchTime)*100)/100;
if(dm!==lastDmg){lastDmg=dm;elDmg.style.opacity=(dm*.95).toFixed(2);elGrab.style.opacity=dm>0?1:0;elGrabFill.style.width=(dm*100)+'%'}
const st=Math.round(stamina/3)+(exhausted?'x':'');
if(st!==lastStam){lastStam=st;rb.style.background=`linear-gradient(to top,${exhausted?'#7a2323':'#256b47'} ${stamina}%,#13191dcc ${stamina}%)`}
mmT+=dt;if(mmT>.1){mmT=0;drawMap()}
}
function update(dt){
T+=dt;if(frozen||busy)return;
gameT+=dt;for(const k in intel)if(intel[k]>0)intel[k]=Math.max(0,intel[k]-dt);
// ---- movement (touch joystick + optional keyboard); frozen in place while hiding ----
let fwd=0,str=0;
if(!hiding){
fwd=-joy.y+(keys.w||keys.W||keys.ArrowUp?1:0)-(keys.s||keys.S||keys.ArrowDown?1:0);str=joy.x+(keys.d||keys.D?1:0)-(keys.a||keys.A?1:0);
const mag=Math.hypot(fwd,str);if(mag>1){fwd/=mag;str/=mag}
moving=mag>.12;
}else moving=false;
if(keys.ArrowLeft)p.a-=2.2*dt;if(keys.ArrowRight)p.a+=2.2*dt;
if(hiding){const d=Math.atan2(Math.sin(p.a-hideA),Math.cos(p.a-hideA));p.a=hideA+Math.max(-.6,Math.min(.6,d))} // you can only peer out of the gap
sprinting=!hiding&&(runHeld||keys.Shift)&&moving&&!exhausted&&stamina>0;
if(intel.stam>0){stamina=100;exhausted=false}
else if(sprinting){stamina=Math.max(0,stamina-TUNE.stamDrain*dt);if(stamina<=0)exhausted=true}
else{stamina=Math.min(100,stamina+TUNE.stamRegen*dt*(moving?1:1.6));if(exhausted&&stamina>=20)exhausted=false}
if(!hiding){const spd=(sprinting?TUNE.run:TUNE.walk)*(touching?TUNE.grabSlow:1)*dt;moveEntity(p,(Math.cos(p.a)*fwd+Math.cos(p.a+1.5708)*str)*spd,(Math.sin(p.a)*fwd+Math.sin(p.a+1.5708)*str)*spd,.22)}
// ---- torch ----
if(flash){battery=Math.max(0,battery-TUNE.batDrain*dt);if(!battery)flash=false}
flickerT=Math.max(0,flickerT-dt);
scareT=Math.max(0,scareT-dt);scareShakeT=Math.max(0,scareShakeT-dt);
flashK=(flickerT>0?(Math.random()<.55?.2:.85):1)*(battery<20?.55+.45*battery/20:1);
ambT-=dt;if(ambT<=0){ambT=TUNE.scareGap*(.6+Math.random()*.8);ambientEvent()}
// ---- pickups ----
if(!hiding){
for(const m of MEDS)if(!m.taken&&m.f===F&&near(m))takeMed(m);
if(KEY&&!KEY.taken&&KEY.f===F&&near(KEY))tryKey();
for(const b of BATS_A)if(!b.taken&&b.f===F&&Math.hypot(b.x-p.x,b.y-p.y)<.6){b.taken=true;battery=Math.min(100,battery+TUNE.batPack);sound(700,.12,'sine',.07);sound(940,.14,'sine',.06,.1);msg('BATTERY +'+TUNE.batPack+'%',1600)}
for(const a of ARTS_A)if(!a.taken&&a.f===F&&near(a))readArt(a);
checkScares();
}
// ---- room banner, map, exit ----
const ci=(p.y|0)*MW+(p.x|0),z=ZONE[ci];if(z!==curZone){curZone=z;if(z)roomTag(ZDEF[z].n)}
if(ci!==lastRevealCell){lastRevealCell=ci;reveal()}
if(F===0&&hasKey&&!hiding&&Math.hypot(p.x-EXIT.x,p.y-EXIT.y)<EXIT.r){win();return}
// ---- monster ----
if(!mon.active&&TUNE.wakeT>0&&gameT>TUNE.wakeT)spawnMonster();
updateMonster(dt);updateGrab(dt);
const threat=mon.active&&mon.f===F?Math.max(0,Math.min(1,1-(mon.pd<0?99:mon.pd)/10)):0,hunting=mon.active&&mon.f===F&&mon.mode==='hunt'?.7:0,fear=Math.max(threat,hunting,grab/TUNE.touchTime);
if(fear>.05){beat-=dt;if(beat<=0){beat=.95-.5*fear;const v=.05+.14*fear;sound(54,.13,'sine',v);sound(46,.16,'sine',v*.8,.16);if(hiding)noise(.5,.04,500,2,.2)}}
if(threat>.6&&Math.random()<dt*.5*TUNE.flickMul)flickerT=Math.max(flickerT,.3);
if(mon.active&&mon.f===F&&mon.dist<5&&Math.random()<dt*1.2)sound(40+Math.random()*20,.14,'sawtooth',.035);
musicTick(dt,fear);
updateContext();hud(dt);
}
function loop(now){
if(!started)return;
const dt=last?Math.max(0,Math.min(.035,(now-last)/1000)):.016;last=now;
try{update(dt);render()}catch(e){reportError(e)}
requestAnimationFrame(loop);
}
/* =====================================================================
START-SCREEN BACKDROP — a live, idle shot of the corridor and the
monster behind the menu, drawn with the game's own renderer (same
DRAW.mon/walls code as real play). Purely decorative: it never calls
update(), and startGame() resets every field it touches before the
real game begins.
===================================================================== */
let menuT0=0;
function menuLoop(now){
if(started)return;
T=((now-menuT0)/1000)||0;
flashK=1.15+.08*Math.sin(T*.7);
mon.anim+=1/60*.6;mon.reach=.8+.05*Math.sin(T*.9);
try{render()}catch(e){reportError(e)}
requestAnimationFrame(menuLoop);
}
try{
buildWorld();setFloor(0);
p.x=6.5;p.y=19.5;p.a=0;flash=true;battery=100;
mon.f=0;mon.x=8.3;mon.y=19.5;mon.active=true;mon.mode='hunt';mon.moving=false;mon.reach=.8;
mon.z=FL[0].zone[19*MW+8];
menuT0=performance.now();
requestAnimationFrame(menuLoop);
}catch(e){reportError(e)}
</script>
</body></html>
Game Source: Ward Zero: Medicine Run
Creator: StealthTiger28
Libraries: none
Complexity: complex (1309 lines, 94.1 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: ward-zero-medicine-run-stealthtiger28" to link back to the original. Then publish at arcadelab.ai/publish.