🎮ArcadeLab

Ward Zero: Medicine Run

by StealthTiger28
1309 lines94.1 KB
â–¶ 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,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.