RPG
Deepseek v4.1 Flash · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 139,487 bytes · SHA-256 f934d46dcd65
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<title>Ashes of Aetheria — Action Adventure RPG</title>
<style>
:root{
--gold:#e8c26a; --gold2:#8a6a28; --ink:#0d0b12; --ink2:#1a1622;
--pane:#14101c; --pane2:#201a2c; --line:#4a3b6a; --red:#ff4b3e; --green:#7bd86a;
--blue:#6fd3ff; --frost:#bfefff; --ember:#ff9b3d;
}
*{box-sizing:border-box; -webkit-tap-highlight-color:transparent}
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#07060a;
font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,"Courier New",monospace;
color:#efe7ff; user-select:none;}
canvas{display:block;position:absolute;inset:0;image-rendering:pixelated;}
#wrap{position:fixed;inset:0}
.hud{position:absolute;inset:0;pointer-events:none;z-index:5}
.hud > *{pointer-events:none}
/* ---------- hearts ---------- */
#hearts{position:absolute;left:14px;top:12px;display:flex;gap:3px;filter:drop-shadow(0 2px 0 #000)}
.heart{width:26px;height:23px;position:relative}
.heart svg{width:100%;height:100%;overflow:visible}
.heart .hbg{fill:#2a1720;stroke:#0a0508;stroke-width:1.4}
.heart .hf{fill:var(--red)}
.heart.half .hfR{display:none}
.heart.empty .hf{display:none}
.heart.beat{animation:beat .5s ease-out}
@keyframes beat{0%{transform:scale(1)}35%{transform:scale(1.35)}100%{transform:scale(1)}}
/* ---------- bars ---------- */
#bars{position:absolute;left:14px;top:42px;width:190px}
.barwrap{height:11px;background:#120d18;border:2px solid #3a2c52;border-radius:6px;overflow:hidden;position:relative;
box-shadow:inset 0 2px 6px #000a}
.bar{height:100%;width:100%;transition:width .12s linear}
#staminaBar{background:linear-gradient(#8ff08a,#3f9a3a)}
#xpBar{background:linear-gradient(#9be0ff,#3a7fd0)}
.barlabel{font-size:8px;letter-spacing:2px;color:#9c8fc4;margin:4px 0 2px 2px}
/* ---------- top right ---------- */
#topright{position:absolute;right:14px;top:12px;text-align:right}
#coinbox{display:inline-flex;align-items:center;gap:7px;background:#14101ccc;border:2px solid #4a3b6a;
border-radius:6px;padding:5px 10px;box-shadow:0 2px 0 #000}
#coinbox .coin{width:15px;height:15px;border-radius:50%;background:radial-gradient(circle at 35% 30%,#fff0a8,#e0a920 60%,#8a6610);
box-shadow:inset 0 0 0 2px #6b4c08}
#coinCount{font-size:15px;font-weight:bold;color:#ffe9a8;letter-spacing:1px}
#regionName{margin-top:7px;font-size:11px;letter-spacing:3px;color:#cbb9ff;text-shadow:0 2px 0 #000}
#objective{margin-top:4px;font-size:10px;color:#8e82b8;max-width:250px;line-height:1.5}
/* ---------- bottom ---------- */
#gearbox{position:absolute;right:14px;bottom:14px;display:flex;gap:8px;align-items:flex-end}
.slot{width:46px;height:46px;background:#14101ccc;border:2px solid #4a3b6a;border-radius:7px;position:relative;
box-shadow:0 3px 0 #000;display:flex;align-items:center;justify-content:center;font-size:20px}
.slot .key{position:absolute;top:-7px;left:-6px;background:#4a3b6a;color:#efe7ff;font-size:8px;
padding:1px 4px;border-radius:4px;border:1px solid #6b539b}
.slot .cnt{position:absolute;bottom:-4px;right:-4px;background:#2c2140;border:1px solid #6b539b;border-radius:4px;
font-size:9px;padding:0 4px;color:#ffe9a8}
.slot.hot{border-color:var(--gold);box-shadow:0 0 0 2px #e8c26a55,0 3px 0 #000}
.slot.dim{opacity:.35}
#weaponbox{position:absolute;right:14px;bottom:70px;display:flex;gap:8px;align-items:center;justify-content:flex-end}
.wslot{width:56px;height:52px;background:#14101ccc;border:2px solid #6b539b;border-radius:7px;position:relative;
display:flex;align-items:center;justify-content:center;box-shadow:0 3px 0 #000}
.wslot .wname{position:absolute;bottom:-13px;width:130px;right:0;font-size:8px;text-align:right;color:#a495d4}
#buildtag{position:absolute;left:14px;bottom:14px;font-size:9px;color:#4f4470;letter-spacing:2px}
/* minimap */
#mmwrap{position:absolute;left:14px;bottom:14px;width:172px;
background:#0b0810cc;border:2px solid #4a3b6a;border-radius:7px;overflow:hidden;box-shadow:0 3px 0 #000}
#mmTitle{font-size:8px;letter-spacing:2px;padding:3px 6px 1px;color:#8e82b8}
#mm{display:block;width:100%;height:118px}
/* toasts */
#toasts{position:absolute;left:50%;bottom:110px;transform:translateX(-50%);display:flex;flex-direction:column;
align-items:center;gap:6px;width:520px}
.toast{background:#14101ce6;border:2px solid var(--gold2);border-radius:6px;padding:7px 16px;font-size:12px;
color:#ffe9a8;letter-spacing:1px;box-shadow:0 3px 0 #000;animation:tin .35s ease-out;text-align:center}
.toast.item{border-color:#6b539b;color:#cbb9ff}
.toast.bad{border-color:#7a2222;color:#ff9a8f}
.toast.good{border-color:#2f7a2f;color:#b6ffb0}
@keyframes tin{from{opacity:0;transform:translateY(14px) scale(.9)}to{opacity:1}}
/* prompt */
#prompt{position:absolute;left:50%;bottom:152px;transform:translateX(-50%);font-size:12px;color:#fff;
background:#000000aa;border:2px solid #6b539b;border-radius:6px;padding:6px 14px;display:none;letter-spacing:1px}
#prompt b{color:var(--gold)}
/* damage vignette */
#vig{position:absolute;inset:0;pointer-events:none;box-shadow:inset 0 0 160px 40px #ff0000cc;opacity:0;
transition:opacity .18s}
#flash{position:absolute;inset:0;background:#fff;opacity:0;pointer-events:none;transition:opacity .3s}
/* ---------- overlays ---------- */
.overlay{position:absolute;inset:0;z-index:20;display:none;align-items:center;justify-content:center;
background:#06040ad9;backdrop-filter:blur(3px)}
.overlay.on{display:flex}
.panel{background:linear-gradient(#1b1428,#120e1c);border:3px solid var(--gold2);border-radius:10px;
box-shadow:0 10px 40px #000, inset 0 0 60px #2a1f3f;padding:22px 26px;max-width:640px;width:92%}
.panel h1{margin:0 0 4px;font-size:26px;letter-spacing:6px;color:var(--gold);text-shadow:0 3px 0 #000}
.panel h2{margin:0 0 14px;font-size:13px;letter-spacing:3px;color:#9c8fc4;font-weight:normal}
.rows{display:flex;flex-direction:column;gap:9px;margin-top:12px}
.btnrow{display:flex;gap:10px;margin-top:18px;flex-wrap:wrap}
button.mbtn{flex:1;min-width:130px;background:linear-gradient(#2b2140,#1a1428);border:2px solid #6b539b;color:#efe7ff;
font-family:inherit;font-size:12px;letter-spacing:2px;padding:11px 14px;border-radius:7px;cursor:pointer;
box-shadow:0 4px 0 #000;transition:.1s;pointer-events:auto}
button.mbtn:hover{background:linear-gradient(#3d2f5c,#241b38);border-color:var(--gold);color:#ffe9a8;transform:translateY(-1px)}
button.mbtn:active{transform:translateY(2px);box-shadow:0 1px 0 #000}
.hint{font-size:10px;color:#8e82b8;line-height:1.9}
.hint b{color:#cbb9ff}
kbd{background:#2b2140;border:1px solid #6b539b;border-radius:4px;padding:1px 6px;font-size:10px;color:#ffe9a8}
/* shop */
.shoprow{display:flex;align-items:center;gap:12px;background:#100c18;border:2px solid #3a2c52;border-radius:8px;
padding:9px 12px}
.shoprow .icon{width:34px;height:34px;flex:0 0 34px;display:flex;align-items:center;justify-content:center;font-size:20px;
background:#1d1630;border:2px solid #4a3b6a;border-radius:6px}
.shoprow .info{flex:1}
.shoprow .nm{font-size:12px;color:#ffe9a8;letter-spacing:1px}
.shoprow .ds{font-size:9px;color:#8e82b8;margin-top:3px;line-height:1.5}
.shoprow .pr{font-size:12px;color:#ffd76a;min-width:62px;text-align:right}
.shoprow.sold .nm{color:#5c5478;text-decoration:line-through}
.shoprow button{width:auto;flex:0 0 auto;min-width:0;padding:7px 12px;font-size:10px}
/* inventory grid */
.invgrid{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;margin-top:8px}
.invcell{background:#100c18;border:2px solid #3a2c52;border-radius:8px;padding:8px;min-height:78px;position:relative;
display:flex;flex-direction:column;gap:4px}
.invcell.eq{border-color:var(--gold)}
.invcell .ic{font-size:20px}
.invcell .nm{font-size:10px;color:#ffe9a8;line-height:1.3}
.invcell .ds{font-size:8.5px;color:#8e82b8;line-height:1.45}
.invcell .use{margin-top:auto;font-size:9px;color:#7bd86a;border:1px solid #2f7a2f;border-radius:4px;padding:2px 5px;
text-align:center;cursor:pointer;pointer-events:auto;background:#0d1a0d}
.invcell .use:hover{background:#16301a}
.invcell .cnt{position:absolute;top:4px;right:6px;font-size:10px;color:#ffd76a}
.emptycell{opacity:.28}
/* dialogue */
#dialog .panel{max-width:560px}
#dlgName{font-size:11px;letter-spacing:3px;color:var(--gold)}
#dlgText{font-size:12px;line-height:1.9;color:#e2d9ff;margin-top:8px;min-height:56px}
#dlgHint{font-size:9px;color:#7d72a5;margin-top:8px;text-align:right}
/* title */
#title .panel{text-align:center;max-width:700px;border-color:var(--gold)}
#title h1{font-size:38px;letter-spacing:10px;color:var(--gold);
text-shadow:0 0 22px #e8c26a66,0 4px 0 #000;margin-bottom:2px}
#title .sub{font-size:11px;letter-spacing:5px;color:#9c8fc4;margin-bottom:6px}
.tlore{font-size:10.5px;color:#8e82b8;line-height:2;max-width:470px;margin:14px auto 0}
.klist{display:grid;grid-template-columns:1fr 1fr;gap:4px 18px;text-align:left;max-width:470px;margin:16px auto 0;
font-size:10px;color:#a495d4}
#death .panel,#victory .panel{text-align:center}
#victory h1{color:#ffe9a8}
.stat{display:flex;justify-content:space-between;font-size:11px;color:#cbb9ff;border-bottom:1px dashed #3a2c52;padding:6px 2px}
#loadNote{font-size:10px;color:#7bd86a;height:14px;letter-spacing:2px}
.badge{display:inline-block;font-size:9px;padding:1px 6px;border-radius:4px;background:#2b2140;color:#cbb9ff;
border:1px solid #6b539b;margin-left:6px}
.mute{position:absolute;right:14px;top:64px;font-size:9px;color:#6f648f;letter-spacing:1px;pointer-events:auto;cursor:pointer}
.mute:hover{color:#cbb9ff}
@media (max-width:720px){
#mmwrap{width:126px} #mm{height:86px}
#objective{display:none} .invgrid{grid-template-columns:repeat(3,1fr)}
}
</style>
</head>
<body>
<div id="wrap">
<canvas id="game"></canvas>
<!-- global SVG defs for hearts -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<clipPath id="clipL"><rect x="0" y="0" width="16" height="28"/></clipPath>
<clipPath id="clipR"><rect x="16" y="0" width="16" height="28"/></clipPath>
<path id="heartPath" d="M16 26.4C16 26.4 1.6 18 1.6 9.6 1.6 4.9 4.9 1.4 8.7 1.4 12 1.4 14.8 3.3 16 6.1 17.2 3.3 20 1.4 23.3 1.4 27.1 1.4 30.4 4.9 30.4 9.6 30.4 18 16 26.4 16 26.4Z"/>
</defs>
</svg>
<div class="hud">
<div id="hearts"></div>
<div id="bars">
<div class="barlabel">STAMINA</div>
<div class="barwrap"><div class="bar" id="staminaBar"></div></div>
</div>
<div id="topright">
<div id="coinbox"><div class="coin"></div><span id="coinCount">0</span></div>
<div id="regionName">—</div>
<div id="objective"></div>
</div>
<div id="mmwrap">
<div id="mmTitle">MAP — EXPLORED</div>
<canvas id="mm" width="344" height="236"></canvas>
</div>
<div id="weaponbox">
<div class="wslot" id="slotMelee"><span id="meleeIcon" style="font-size:24px">🗡️</span><span class="wname" id="meleeName"></span></div>
<div class="wslot" id="slotRanged" style="display:none"><span style="font-size:24px">🏹</span><span class="wname" id="rangedName"></span></div>
</div>
<div id="gearbox"></div>
<div id="toasts"></div>
<div id="prompt"></div>
<div class="mute" id="muteBtn">[M] SOUND: ON</div>
<div id="vig"></div>
<div id="flash"></div>
</div>
<!-- TITLE -->
<div class="overlay on" id="title">
<div class="panel">
<div class="sub">A SHORT ACTION-ADVENTURE</div>
<h1>ASHES OF AETHERIA</h1>
<div class="sub" style="letter-spacing:3px">THE FIVE HEARTS OF THE VALE</div>
<div class="tlore">The Warden of Ash has cracked the Aether Seal. The vale bleeds — slimes in the meadow,
wolves in the wood, restless bones in the drowned ruins. Take the rusty blade. Grow strong enough
to reach the Ashen Citadel before the hourglass empties.</div>
<div class="klist">
<div><kbd>WASD</kbd>/<kbd>ARROWS</kbd> move</div><div><kbd>SPACE</kbd>/<kbd>J</kbd> attack</div>
<div><kbd>SHIFT</kbd> dash (stamina)</div><div><kbd>K</kbd> bow shot (stamina)</div>
<div><kbd>E</kbd> interact / save</div><div><kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> use item</div>
<div><kbd>TAB</kbd>/<kbd>I</kbd> inventory</div><div><kbd>M</kbd> mute</div>
</div>
<div id="loadNote"></div>
<div class="btnrow">
<button class="mbtn" id="btnNew">NEW GAME</button>
<button class="mbtn" id="btnCont" style="display:none">CONTINUE</button>
</div>
<div class="hint" style="margin-top:14px;text-align:center">
Hearts hold <b>2</b> points each. Begin with <b>3</b> containers — gather <b>4 Heart Containers</b> to reach <b>5</b>.
</div>
</div>
</div>
<!-- SHOP -->
<div class="overlay" id="shop">
<div class="panel">
<h1 style="font-size:20px;letter-spacing:4px">HOLLOWAY'S WARES</h1>
<h2 id="shopSub">"Everything a hero needs. Coins up front."</h2>
<div class="rows" id="shopRows"></div>
<div class="btnrow"><button class="mbtn" id="shopClose">LEAVE (ESC)</button></div>
<div class="hint" style="text-align:center;margin-top:8px">Coins: <b id="shopCoins">0</b></div>
</div>
</div>
<!-- INVENTORY -->
<div class="overlay" id="inventory">
<div class="panel">
<h1 style="font-size:20px;letter-spacing:4px">SATCHEL</h1>
<h2 id="invSub">Accessories equip automatically · click a consumable to use it</h2>
<div class="invgrid" id="invGrid"></div>
<div class="rows" style="margin-top:14px;font-size:10px;color:#9c8fc4" id="invStats"></div>
<div class="btnrow"><button class="mbtn" id="invClose">CLOSE (TAB)</button></div>
</div>
</div>
<!-- DIALOGUE -->
<div class="overlay" id="dialog">
<div class="panel">
<div id="dlgName">SIGN</div>
<div id="dlgText"></div>
<div id="dlgHint">[E] / [SPACE] continue</div>
</div>
</div>
<!-- DEATH -->
<div class="overlay" id="death">
<div class="panel">
<h1 style="color:#ff6b5e">YOU HAVE FALLEN</h1>
<h2 id="deathSub">The vale takes its tithe…</h2>
<div class="btnrow">
<button class="mbtn" id="btnRespawn">RISE AGAIN</button>
<button class="mbtn" id="btnTitle2">TITLE SCREEN</button>
</div>
</div>
</div>
<!-- VICTORY -->
<div class="overlay" id="victory">
<div class="panel">
<h1>THE ASH SETTLES</h1>
<h2 id="vicSub">The Aether Seal is reforged. The vale breathes.</h2>
<div class="rows" id="vicStats"></div>
<div class="btnrow"><button class="mbtn" id="btnAgain">PLAY AGAIN</button></div>
</div>
</div>
</div>
<script>
/* =========================================================================
ASHES OF AETHERIA — single-file action-adventure RPG
========================================================================= */
'use strict';
/* ------------------------------ constants ------------------------------ */
const TILE = 32, MW = 100, MH = 76;
const WW = MW * TILE, WH = MH * TILE;
const ZOOM = 2;
const SAVE_KEY = 'ashes_of_aetheria_save_v1';
const T = { FLOOR:0, WALL:1, WATER:2, GATE:3, BRZ_OFF:4, BRZ_ON:5, VENT:6, PILLAR:7, CRATE:8, RUNE_ON:9, ASH:10, ICE:11 };
const SOLID = { 1:1, 3:1, 4:1, 5:1, 6:1, 7:1, 8:1, 10:1, 11:1 }; // 3=gate blocks until opened
const gSolid = t => !!SOLID[t];
/* ------------------------------ utils ---------------------------------- */
const clamp = (v,a,b) => v<a?a:v>b?b:v;
const lerp = (a,b,t) => a+(b-a)*t;
const dist2 = (ax,ay,bx,by) => { const dx=ax-bx, dy=ay-by; return dx*dx+dy*dy; };
const len = (x,y) => Math.hypot(x,y);
function rnd(a,b){ return a + Math.random()*(b-a); }
function irnd(a,b){ return Math.floor(rnd(a,b+1)); }
function pick(arr){ return arr[Math.floor(Math.random()*arr.length)]; }
function shuffle(a){ for(let i=a.length-1;i>0;i--){ const j=Math.floor(Math.random()*(i+1)); [a[i],a[j]]=[a[j],a[i]]; } return a; }
function mulberry32(a){ return function(){ a|=0; a=a+0x6D2B79F5|0; let t=Math.imul(a^a>>>15,1|a); t=t+Math.imul(t^t>>>7,61|t)^t; return ((t^t>>>14)>>>0)/4294967296; }; }
function angDiff(a,b){ let d=(b-a)%(Math.PI*2); if(d>Math.PI)d-=Math.PI*2; if(d<-Math.PI)d+=Math.PI*2; return d; }
/* ------------------------------ regions -------------------------------- */
const REGIONS = [
{ id:'meadow', name:'MEADOW VILLAGE', x:6, y:40, w:26, h:26,
floor:'#4e8340', floor2:'#5b9349', accent:'#7bb84f', wall:'#2c4a26', wallTop:'#3f6b34',
obstacle:T.PILLAR, density:0.045, foes:['slime'], packs:[3,5], music:60 },
{ id:'woods', name:'WHISPERING WOODS', x:36, y:40, w:26, h:26,
floor:'#2f5a34', floor2:'#356239', accent:'#4d8a46', wall:'#1a3320', wallTop:'#2a5228',
obstacle:T.PILLAR, density:0.085, foes:['slime','bat','wolf'], packs:[3,4], music:57 },
{ id:'ruins', name:'SUNKEN RUINS', x:66, y:40, w:26, h:26,
floor:'#3b4356', floor2:'#444d63', accent:'#5c6a86', wall:'#252a38', wallTop:'#3a4460',
obstacle:T.PILLAR, density:0.07, foes:['bat','bone'], packs:[3,4], music:53 },
{ id:'ember', name:'EMBER CAVERNS', x:6, y:6, w:26, h:26,
floor:'#4a2a22', floor2:'#553126', accent:'#7a3d26', wall:'#2a1512', wallTop:'#4a2418',
obstacle:T.CRATE, density:0.06, foes:['magma','bat'], packs:[3,4], music:50 },
{ id:'frost', name:'FROSTPEAK PASS', x:36, y:6, w:26, h:26,
floor:'#4a5a72', floor2:'#556684', accent:'#84a6cc', wall:'#2a3448', wallTop:'#465a7a',
obstacle:T.ICE, density:0.06, foes:['wraith','wolf'], packs:[3,4], music:62 },
{ id:'citadel', name:'ASHEN CITADEL', x:66, y:6, w:26, h:26,
floor:'#3a2630', floor2:'#432c38', accent:'#7a3a44', wall:'#1f141c', wallTop:'#43222c',
obstacle:T.CRATE, density:0.055, foes:['bone','magma','wraith'], packs:[3,4], music:47 }
];
const R = {}; REGIONS.forEach(r => R[r.id] = r);
function regionAtTile(tx,ty){
for(const r of REGIONS) if(tx>=r.x && tx<r.x+r.w && ty>=r.y && ty<r.y+r.h) return r;
return null;
}
/* ------------------------------ world ---------------------------------- */
const world = {
tile: new Uint8Array(MW*MH),
region: new Array(MW*MH).fill(null),
explored: new Uint8Array(MW*MH),
reserved: []
};
const idx = (tx,ty) => ty*MW+tx;
const inb = (tx,ty) => tx>=0 && ty>=0 && tx<MW && ty<MH;
function tileAt(tx,ty){ return inb(tx,ty) ? world.tile[idx(tx,ty)] : T.WALL; }
function setTile(tx,ty,v){ if(inb(tx,ty)) world.tile[idx(tx,ty)] = v; }
function regionAtPx(x,y){ return regionAtTile(Math.floor(x/TILE), Math.floor(y/TILE)); }
function tileSolid(tx,ty){ return gSolid(tileAt(tx,ty)); }
function solidAtPx(x,y){ return tileSolid(Math.floor(x/TILE), Math.floor(y/TILE)); }
function fillRect(x,y,w,h,v){ for(let j=y;j<y+h;j++) for(let i=x;i<x+w;i++) setTile(i,j,v); }
function reserve(x,y,w,h){ world.reserved.push([x,y,w,h]); }
function inReserved(tx,ty){
for(const [x,y,w,h] of world.reserved) if(tx>=x&&tx<x+w&&ty>=y&&ty<y+h) return true;
return false;
}
function room(x,y,w,h,gateCol){
// wall ring at x,y,w,h ; interior cleared
fillRect(x,y,w,h,T.WALL);
fillRect(x+1,y+1,w-2,h-2,T.FLOOR);
reserve(x-1,y-1,w+2,h+2);
return {x,y,w,h};
}
/* corridors & gates (defined once, used by gen + gating) */
const GATES = {
sealed: { x:47, y:34, w:4, h:2, label:'SEALED GATE', flag:'gateSealed' },
ashen: { x:63, y:16, w:2, h:4, label:'ASHEN GATE', flag:'gateAshen' }
};
function buildWorld(){
world.tile.fill(T.WALL);
world.region.fill(null);
world.explored.fill(0);
world.reserved.length = 0;
// 1. carve regions
for(const r of REGIONS){
fillRect(r.x, r.y, r.w, r.h, T.FLOOR);
for(let j=r.y;j<r.y+r.h;j++) for(let i=r.x;i<r.x+r.w;i++) world.region[idx(i,j)] = r.id;
}
// 2. corridors
const corr = [
[32,52,4,4], [62,52,4,4], // meadow-woods, woods-ruins
[32,16,4,4], [62,16,4,4], // frost-ember, frost-citadel
[47,32,4,8], // woods -> frost (sealed gate)
[77,32,4,8] // ruins -> citadel (collapsed, decorative)
];
for(const [x,y,w,h] of corr){ fillRect(x,y,w,h,T.FLOOR); reserve(x-1,y-1,w+2,h+2); }
fillRect(77,32,4,8,T.ASH); // collapsed passage: no route
// 3. structures
buildStructures();
// 3b. protect content spots
preReserveContent();
// 4. decorative obstacles
scatterObstacles();
// 5. gates
for(const k in GATES){ const g=GATES[k]; fillRect(g.x,g.y,g.w,g.h,T.GATE); reserve(g.x-1,g.y-1,g.w+2,g.h+2); }
}
function buildStructures(){
/* ---- WOODS : block-push puzzle room -> Heart Container ---- */
room(37,43,13,11); // interior 38..48, 44..52
setTile(40,53,T.FLOOR); setTile(41,53,T.FLOOR); // south entrance
fillRect(46,44,1,9,T.GATE); // gate column (opened by plates)
reserve(36,42,15,13);
PUZ.woods = {
plates:[[39,45],[41,45],[43,45]],
blocks:[[39,49],[41,50],[43,49]],
gate:{x:46,y:44,w:1,h:9},
chest:[47,47], done:false
};
setTile(39,45,T.FLOOR);
/* ---- RUINS : rune sequence -> Boots of Swiftness ---- */
room(70,44,11,9); // interior 71..79, 45..51
setTile(69,48,T.FLOOR); // west entrance
reserve(69,43,13,11);
PUZ.ruins = {
runes:[[72,47],[74,50],[76,47],[78,50],[75,45]],
sign:[68,48], order:[0,1,2,3,4], lit:[], solved:false, chest:[76,49]
};
/* ---- EMBER : a lava lake with an island of obelisks — only ARROWS fly over lava ---- */
reserve(6,6,26,16);
fillRect(8,8,22,10,T.ASH); // lava lake x8..29 y8..17
fillRect(10,10,18,6,T.FLOOR); // island x10..27 y10..15
PUZ.ember = {
obelisks:[[12,12],[17,12],[22,12],[25,14]],
lit:[], solved:false, chest:[18,18]
};
/* ---- FROST : freeze the aetherfire vents with Frostfang -> Heart Container ---- */
room(39,7,11,9); // walls x39..49 y7..15, interior 40..48 / 8..14
setTile(42,15,T.FLOOR); // south entrance
fillRect(44,9,1,5,T.VENT); // the aetherfire curtain
reserve(38,6,13,11);
PUZ.frost = {
vents:[[44,9],[44,10],[44,11],[44,12],[44,13]],
gate:{x:44,y:9,w:1,h:5},
chest:[47,11], done:false
};
/* ---- CITADEL : throne approach ---- */
reserve(70,6,16,14);
}
/* keeps decoration away from every hand-placed content tile */
const CONTENT_SPOTS = [
[26,61],[9,62],[39,61],[58,44],[72,62],[26,27],[10,27],[22,20],[50,28],[42,10],[68,24],
[20,44],[15,48],[17,53],[10,46],[36,52],[68,48],[70,48],[74,43],[8,20],[36,16],[68,20],
[18,18],[57,26],[57,24],[79,60],[82,21],[66,17],[64,17]
];
function preReserveContent(){
for(const [x,y] of CONTENT_SPOTS) reserve(x-1,y-1,3,3);
// boss arenas: clear a ring so bosses never start entombed
for(const [bx,by,rad] of [[79,60,5],[57,24,5],[82,21,6]]){
reserve(bx-rad,by-rad,rad*2+1,rad*2+1);
for(let j=by-rad;j<=by+rad;j++) for(let i=bx-rad;i<=bx+rad;i++){
if(!inb(i,j)) continue;
const r=regionAtTile(i,j); if(!r) continue;
const d=Math.hypot(i-bx,j-by);
if(d<=rad && tileAt(i,j)!==T.FLOOR && tileAt(i,j)!==T.WATER) setTile(i,j,T.FLOOR);
}
}
}
function scatterObstacles(){ const rand = mulberry32(20240917);
for(const r of REGIONS){
const count = Math.floor(r.w*r.h*r.density);
let placed = 0, guard = 0;
while(placed < count && guard++ < count*30){
const tx = r.x + 3 + Math.floor(rand()*(r.w-6));
const ty = r.y + 3 + Math.floor(rand()*(r.h-6));
if(inReserved(tx,ty)) continue;
if(tileAt(tx,ty)!==T.FLOOR) continue;
// keep away from region borders (corridor mouths) and shrine spots
const nearCorridor = (tx<r.x+4||tx>r.x+r.w-5||ty<r.y+4||ty>r.y+r.h-5);
if(nearCorridor && r.id!=='citadel') continue;
let bad=false;
for(const sh of SHRINES) if(Math.abs(sh[0]-tx)<3 && Math.abs(sh[1]-ty)<3) bad=true;
if(bad) continue;
// clusters
const blob = (r.obstacle===T.PILLAR && rand()<0.6) ? 1 : 2;
for(let b=0;b<blob;b++){
const ox = tx + (blob>1?Math.round(rand()*2)-1:0), oy = ty + (b===1? 1:0);
if(inReserved(ox,oy)) continue;
if(tileAt(ox,oy)!==T.FLOOR) continue;
if(isLandmarkAdjacent(ox,oy)) continue;
setTile(ox,oy,r.obstacle);
}
placed++;
}
}
// scattered water ponds + decorative bands
for(const r of REGIONS){
if(r.id==='meadow'){
fillRect(12,58,5,4,T.WATER); fillRect(24,44,4,3,T.WATER);
for(let i=0;i<10;i++){ const tx=irnd(r.x+4,r.x+r.w-5), ty=irnd(r.y+4,r.y+r.h-5);
if(tileAt(tx,ty)===T.FLOOR && !inReserved(tx,ty) && !isLandmarkAdjacent(tx,ty)) setTile(tx,ty,T.PILLAR); }
}
if(r.id==='ruins'){
for(let i=0;i<6;i++){ const tx=irnd(r.x+5,r.x+r.w-6), ty=irnd(r.y+5,r.y+r.h-6);
if(tileAt(tx,ty)===T.FLOOR && !inReserved(tx,ty)) fillRect(tx,ty,2,1,T.WATER); }
for(let k=0;k<14;k++){ const tx=irnd(r.x+4,r.x+r.w-5), ty=irnd(r.y+4,r.y+r.h-5);
if(tileAt(tx,ty)===T.FLOOR && !inReserved(tx,ty) && !isLandmarkAdjacent(tx,ty)) setTile(tx,ty,T.PILLAR); }
}
if(r.id==='ember'){
for(let k=0;k<16;k++){ const tx=irnd(r.x+4,r.x+r.w-5), ty=irnd(r.y+4,r.y+r.h-5);
if(tileAt(tx,ty)===T.FLOOR && !inReserved(tx,ty) && !isLandmarkAdjacent(tx,ty)) setTile(tx,ty,T.ASH); }
}
}
// ensure border ring of world is wall
fillRect(0,0,MW,4,T.WALL); fillRect(0,MH-4,MW,4,T.WALL);
fillRect(0,0,4,MH,T.WALL); fillRect(MW-4,0,4,MH,T.WALL);
}
function isLandmarkAdjacent(tx,ty){
for(const l of LANDMARKS){ if(Math.abs(l[0]-tx)<3 && Math.abs(l[1]-ty)<3) return true; }
return false;
}
/* landmark list (shrines, npcs, chests) — used to keep decoration from blocking them */
const SHRINES = [[12,44],[39,42],[69,42],[39,10],[12,10],[70,10]];
const LANDMARKS = [];
/* ------------------------------ puzzles container ---------------------- */
const PUZ = {};
/* ------------------------------ progression data ----------------------- */
const ITEMS = {
potion : { name:'Health Potion', icon:'🧪', type:'consumable', desc:'Drink to restore 4 health.', price:25, max:6 },
elixir : { name:'Elixir of Vigor', icon:'⚗️', type:'consumable', desc:'Full stamina & +30% speed for 10s.', price:35, max:4 },
bomb : { name:'Ash Bomb', icon:'💣', type:'consumable', desc:'Throw with [B] — heavy area damage.', price:45, max:5 },
charm : { name:'Iron Charm', icon:'🛡️', type:'accessory', desc:'Every hit against you deals 1 less.', price:80 },
greed : { name:'Amulet of Greed', icon:'💰', type:'accessory', desc:'+60% coins from all sources.', price:140 },
boots : { name:'Boots of Swiftness',icon:'👢',type:'accessory', desc:'+35 speed, +70% stamina regen.' },
pendant: { name:"Hunter's Pendant", icon:'📿', type:'accessory', desc:'Bow costs half stamina; arrows pierce.' }
};
const WEAPONS = {
rusty: { name:'Rusty Blade', dmg:10, range:34, arc:1.5, cd:0.34, knock:150, elem:null, col:'#cfd2dd' },
ember: { name:'Ember Edge', dmg:17, range:39, arc:1.9, cd:0.40, knock:220, elem:'fire', col:'#ff8a3d' },
frost: { name:'Frostfang', dmg:27, range:52, arc:1.5, cd:0.29, knock:250, elem:'ice', col:'#9ee7ff' }
};
const BOW = { name:'Stormbow', dmg:14, cost:15, cd:0.42, speed:430, life:1.35, knock:110 };
const EDEF = {
slime : { hp:20, dmg:1, speed:34, r:11, coin:[2,4], look:'slime', ai:'chase' },
bat : { hp:14, dmg:1, speed:82, r:9, coin:[3,5], look:'bat', ai:'erratic'},
wolf : { hp:32, dmg:2, speed:62, r:12, coin:[6,10], look:'wolf', ai:'lunge' },
bone : { hp:28, dmg:2, speed:42, r:11, coin:[8,12], look:'bone', ai:'shooter'},
magma : { hp:44, dmg:2, speed:40, r:13, coin:[10,15], look:'magma', ai:'chase', trail:true, fireproof:true },
wraith: { hp:36, dmg:2, speed:54, r:12, coin:[12,18], look:'wraith', ai:'blink' },
guardian:{ hp:210, dmg:2, speed:46, r:20, coin:[70,90], look:'guardian',ai:'miniboss', boss:true },
golem : { hp:440, dmg:3, speed:40, r:26, coin:[150,190],look:'golem', ai:'golem', boss:true },
ashen : { hp:820, dmg:3, speed:48, r:30, coin:[320,420],look:'ashen', ai:'ashen', boss:true }
};
/* ------------------------------ game state ----------------------------- */
let S = null;
function freshState(seedName){
return {
t:0, running:false, paused:false,
coins:0, kills:0, deaths:0,
inv:{ potion:0, elixir:0 },
owned:{ charm:false, greed:false, boots:false, pendant:false },
bowOwned:false, weapon:'rusty',
containers:3, hp:6,
maxStamina:100, stamina:100,
flags:{ gateSealed:false, gateAshen:false, ruinsBoss:false, citadelBoss:false, litLamps:0 },
shrine:[12,44], shrineName:'MEADOW VILLAGE',
objective:'Talk to Holloway in the village, then head east into the woods.',
invOpen:false, shopOpen:false, started:true,
startTime:performance.now(),
savedAt:null,
runeOrder:[0,1,2,3,4],
seen:[]
};
}
/* =========================================================================
AUDIO — procedural WebAudio SFX + ambient score
========================================================================= */
const SFX = (() => {
let ctx=null, master=null, mgain=null, muted=false, started=false, noiseBuf=null;
let seq=null, step=0, root=60, mode=0;
const SCALES=[[0,3,5,7,10],[0,2,3,7,8],[0,3,5,6,10],[0,2,5,7,9]];
const mtof = m => 440*Math.pow(2,(m-69)/12);
function init(){
if(ctx) return;
const AC = window.AudioContext||window.webkitAudioContext; if(!AC) return;
ctx = new AC();
master = ctx.createGain(); master.gain.value = 0.6; master.connect(ctx.destination);
mgain = ctx.createGain(); mgain.gain.value = 0.0; mgain.connect(master);
const n = ctx.createBuffer(1, ctx.sampleRate*1.2, ctx.sampleRate);
const d = n.getChannelData(0);
for(let i=0;i<d.length;i++) d[i] = Math.random()*2-1;
noiseBuf = n;
}
function resume(){ init(); if(ctx && ctx.state==='suspended') ctx.resume(); }
function setMute(m){ muted=m; if(master) master.gain.value = m?0:0.6; }
function tone(f, opt={}){
if(!ctx||muted) return;
const o=ctx.createOscillator(), g=ctx.createGain();
const t=ctx.currentTime+(opt.delay||0);
o.type=opt.type||'square'; o.frequency.setValueAtTime(f,t);
if(opt.f2) o.frequency.exponentialRampToValueAtTime(Math.max(20,opt.f2), t+(opt.dur||0.15));
const v=opt.vol==null?0.22:opt.vol;
g.gain.setValueAtTime(0.0001,t);
g.gain.exponentialRampToValueAtTime(v, t+0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t+(opt.dur||0.15));
o.connect(g); g.connect(opt.bus||master);
o.start(t); o.stop(t+(opt.dur||0.15)+0.03);
}
function noise(opt={}){
if(!ctx||muted||!noiseBuf) return;
const s=ctx.createBufferSource(); s.buffer=noiseBuf;
const f=ctx.createBiquadFilter(); f.type=opt.type||'bandpass';
const t=ctx.currentTime+(opt.delay||0);
f.frequency.setValueAtTime(opt.f1||900,t);
if(opt.f2) f.frequency.exponentialRampToValueAtTime(Math.max(60,opt.f2), t+(opt.dur||0.15));
f.Q.value=opt.q==null?1.2:opt.q;
const g=ctx.createGain(); const v=opt.vol==null?0.2:opt.vol;
g.gain.setValueAtTime(0.0001,t);
g.gain.exponentialRampToValueAtTime(v,t+0.006);
g.gain.exponentialRampToValueAtTime(0.0001,t+(opt.dur||0.15));
s.connect(f); f.connect(g); g.connect(master);
s.start(t, Math.random()*0.4); s.stop(t+(opt.dur||0.15)+0.02);
}
const api = {
resume, setMute, get muted(){ return muted; },
step(){ noise({f1:rnd(420,700), f2:220, dur:0.055, vol:0.045, type:'lowpass', q:0.8}); },
dash(){ noise({f1:1400,f2:300,dur:0.22,vol:0.11,type:'bandpass',q:1.4}); tone(220,{f2:120,type:'sawtooth',dur:0.2,vol:0.07}); },
swing(heavy){ noise({f1:heavy?1500:1100, f2:heavy?260:420, dur:heavy?0.2:0.13, vol:0.13, type:'bandpass', q:1.1});
tone(heavy?300:420,{f2:heavy?150:230,type:'triangle',dur:0.11,vol:0.07}); },
hit(){ noise({f1:1800,f2:180,dur:0.13,vol:0.17,type:'lowpass'}); tone(180,{f2:70,type:'square',dur:0.1,vol:0.13}); },
crit(){ tone(900,{f2:1500,type:'square',dur:0.09,vol:0.13}); tone(1400,{f2:400,type:'square',dur:0.16,vol:0.1,delay:0.04}); },
hurt(){ tone(300,{f2:90,type:'sawtooth',dur:0.3,vol:0.2}); noise({f1:800,f2:150,dur:0.25,vol:0.14,type:'lowpass'}); },
arrow(){ tone(700,{f2:1700,type:'triangle',dur:0.1,vol:0.13}); noise({f1:2500,f2:900,dur:0.14,vol:0.07}); },
coin(){ tone(mtof(88),{type:'square',dur:0.07,vol:0.1}); tone(mtof(95),{type:'square',dur:0.1,vol:0.09,delay:0.05}); },
pickup(){ tone(mtof(76),{type:'sine',dur:0.12,vol:0.2}); tone(mtof(83),{type:'sine',dur:0.16,vol:0.18,delay:0.08}); },
heart(){ [72,76,79,84].forEach((m,i)=>tone(mtof(m),{type:'triangle',dur:0.22,vol:0.17,delay:i*0.07})); },
upgrade(){ [64,69,72,76,81].forEach((m,i)=>tone(mtof(m),{type:'square',dur:0.3,vol:0.13,delay:i*0.06}));
[60,67].forEach((m,i)=>tone(mtof(m),{type:'triangle',dur:0.9,vol:0.1,delay:i*0.03})); },
shop(){ [72,76,79].forEach((m,i)=>tone(mtof(m),{type:'sine',dur:0.2,vol:0.15,delay:i*0.06})); },
buy(){ [79,84,88,91].forEach((m,i)=>tone(mtof(m),{type:'square',dur:0.14,vol:0.11,delay:i*0.05})); },
deny(){ tone(180,{f2:110,type:'square',dur:0.2,vol:0.15}); },
save(){ [67,71,74].forEach((m,i)=>tone(mtof(m),{type:'triangle',dur:0.5,vol:0.12,delay:i*0.0})); tone(mtof(79),{type:'sine',dur:0.9,vol:0.1}); },
savePoint(){ [69,74,78,81].forEach((m,i)=>tone(mtof(m),{type:'sine',dur:0.45,vol:0.12,delay:i*0.08})); },
boom(){ noise({f1:900,f2:60,dur:0.5,vol:0.28,type:'lowpass',q:0.6}); tone(90,{f2:40,type:'sawtooth',dur:0.45,vol:0.2}); },
gate(){ noise({f1:200,f2:1600,dur:0.6,vol:0.14,type:'bandpass',q:0.8}); [55,60,67].forEach((m,i)=>tone(mtof(m),{type:'sine',dur:0.9,vol:0.11,delay:i*0.1})); },
freeze(){ noise({f1:3000,f2:600,dur:0.35,vol:0.12,type:'highpass'}); tone(1600,{f2:600,type:'sine',dur:0.3,vol:0.1}); },
burn(){ noise({f1:600,f2:1400,dur:0.3,vol:0.09,type:'bandpass',q:0.7}); },
die(){ [64,60,55,48].forEach((m,i)=>tone(mtof(m),{type:'sawtooth',dur:0.5,vol:0.15,delay:i*0.13})); },
bossRoar(){ tone(70,{f2:38,type:'sawtooth',dur:0.9,vol:0.22}); noise({f1:300,f2:80,dur:0.9,vol:0.16,type:'lowpass'}); },
victory(){ [60,64,67,72,76,79,84].forEach((m,i)=>tone(mtof(m),{type:'square',dur:0.5,vol:0.15,delay:i*0.13})); },
rune(){ tone(mtof(72),{type:'sine',dur:0.3,vol:0.16}); tone(mtof(79),{type:'sine',dur:0.4,vol:0.12,delay:0.06}); },
bad(){ tone(150,{f2:60,type:'sawtooth',dur:0.35,vol:0.18}); noise({f1:500,f2:100,dur:0.3,vol:0.12,type:'lowpass'}); }
};
/* --- ambient score: region-rooted arpeggio + bass + soft pad --- */
function musicStart(){ if(seq||!ctx) return; mgain.gain.setTargetAtTime(0.55, ctx.currentTime, 2);
seq = setInterval(()=>{ if(!ctx||muted||!S||!S.running||S.paused) return;
const sc = SCALES[mode%SCALES.length];
const b = step%8;
if(b===0||b===4) tone(mtof(root-24),{type:'triangle',dur:0.85,vol:0.30,bus:mgain});
if(b===2||b===6) tone(mtof(root-17),{type:'triangle',dur:0.5,vol:0.20,bus:mgain});
if(Math.random()<0.82){
const n = sc[Math.floor(Math.random()*sc.length)] + (Math.random()<0.25?12:0);
tone(mtof(root+12+n),{type:'sine',dur:0.5,vol:0.12,bus:mgain});
}
if(b===0) tone(mtof(root-5),{type:'sine',dur:1.6,vol:0.07,bus:mgain});
step++;
}, 400);
}
api.setRegion = (rootNote, md) => { root=rootNote||60; mode=md||0; };
api.musicStart = musicStart;
return api;
})();
/* =========================================================================
PARTICLES
========================================================================= */
const PARTS = [];
function addPart(o){ if(PARTS.length>1400) PARTS.shift(); PARTS.push(o); }
function pSpark(x,y,n,col,opt={}){
for(let i=0;i<n;i++){
const a=opt.dir!=null? opt.dir+rnd(-(opt.spread||0.9),(opt.spread||0.9)) : rnd(0,Math.PI*2);
const sp=rnd(opt.smin||60,opt.smax||240);
addPart({x,y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,life:rnd(0.25,0.6),max:0.6,
size:rnd(2,4.4),col:col,kind:'dot',grav:opt.grav==null?60:opt.grav,drag:2.6});
}
}
function pRing(x,y,col,r,opt={}){
addPart({x,y,life:opt.life||0.4,max:opt.life||0.4,r:r||10,col:col,kind:'ring',w:opt.w||3});
}
function pText(x,y,text,col,size){ addPart({x,y,life:0.9,max:0.9,text,col,kind:'text',size:size||10,vy:-34,grav:20}); }
function pSmoke(x,y,n,col){
for(let i=0;i<n;i++) addPart({x,y,vx:rnd(-30,30),vy:rnd(-50,-10),life:rnd(0.4,0.9),max:0.9,
size:rnd(4,9),col:col,kind:'smoke',grav:-10,drag:1.4});
}
function pBurst(x,y,col,n,spd){
for(let i=0;i<n;i++){ const a=(i/n)*Math.PI*2+rnd(-0.2,0.2);
addPart({x,y,vx:Math.cos(a)*(spd||150),vy:Math.sin(a)*(spd||150),life:0.5,max:0.5,size:rnd(2,4),col,kind:'dot',grav:0,drag:3}); }
}
function updateParts(dt){
for(let i=PARTS.length-1;i>=0;i--){
const p=PARTS[i]; p.life-=dt;
if(p.life<=0){ PARTS.splice(i,1); continue; }
if(p.kind==='text'){ p.y += p.vy*dt; p.vy += (p.grav||0)*dt; continue; }
if(p.kind==='ring'){ continue; }
p.x += p.vx*dt; p.y += p.vy*dt;
if(p.grav) p.vy += p.grav*dt;
if(p.drag){ const k=Math.max(0,1-p.drag*dt); p.vx*=k; p.vy*=k; }
}
}
/* =========================================================================
ENTITY COLLISION HELPERS
========================================================================= */
function resolveAxis(e, isX, delta){
e[isX?'x':'y'] += delta;
const r=e.r;
const t0x=Math.floor((e.x-r)/TILE), t1x=Math.floor((e.x+r)/TILE);
const t0y=Math.floor((e.y-r)/TILE), t1y=Math.floor((e.y+r)/TILE);
for(let ty=t0y;ty<=t1y;ty++) for(let tx=t0x;tx<=t1x;tx++){
if(!tileSolid(tx,ty)) continue;
if(isX) e.x = delta>0 ? tx*TILE-r-0.01 : (tx+1)*TILE+r+0.01;
else e.y = delta>0 ? ty*TILE-r-0.01 : (ty+1)*TILE+r+0.01;
}
e.x = clamp(e.x, 8, WW-8); e.y = clamp(e.y, 8, WH-8);
}
function moveEnt(e, dx, dy){
if(dx) resolveAxis(e,true,dx);
if(dy) resolveAxis(e,false,dy);
}
/* =========================================================================
PLAYER
========================================================================= */
const player = {
x:0,y:0,vx:0,vy:0,r:9,face:{x:1,y:0}, aim:0,
anim:0, stepT:0, invul:0, flash:0, hurtT:0,
atk:null, atkCd:0, bowCd:0, dash:0, dashCd:0,
elemFx:null, elixirT:0, dead:false, walkAnim:0
};
function resetPlayerAt(tx,ty){
player.x=tx*TILE+16; player.y=ty*TILE+16; player.vx=player.vy=0;
player.face={x:0,y:1}; player.aim=Math.PI/2; player.invul=1.2; player.atk=null;
player.dead=false;
}
function playerSpeed(){
let s=158;
if(S.owned.boots) s+=35;
if(player.elixirT>0) s*=1.3;
if(player.inWater) s*=0.62;
return s;
}
function maxHp(){ return S.containers*2; }
function heal(n){ const m=maxHp(); const before=S.hp; S.hp=Math.min(m,S.hp+n); return S.hp-before; }
function takeDamage(amount, srcX, srcY){
if(player.invul>0 || player.dead || !S.running) return;
let dmg = amount;
if(S.owned.charm) dmg -= 1;
dmg = Math.max(1, dmg);
if(player.elixirT>0) dmg = Math.max(1, dmg-1);
S.hp -= dmg;
player.invul = 0.95; player.hurtT = 0.45;
SFX.hurt();
shake(6,0.3); hitStop(0.06);
pSpark(player.x, player.y-8, 12, '#ff5a4a', {smax:200});
pText(player.x, player.y-26, '-'+dmg, '#ff8a7a', 11);
const vig=document.getElementById('vig'); vig.style.opacity='0.85';
clearTimeout(vig._t); vig._t=setTimeout(()=>vig.style.opacity='0',170);
if(srcX!=null){ const a=Math.atan2(player.y-srcY, player.x-srcX);
player.vx=Math.cos(a)*230; player.vy=Math.sin(a)*230; }
updateHearts();
if(S.hp<=0) playerDie();
}
function playerDie(){
player.dead=true; S.deaths++; S.running=false;
SFX.die(); pBurst(player.x,player.y,'#cbb9ff',34,220); pRing(player.x,player.y,'#ff6b5e',60,{life:0.8,w:5});
const lost=Math.floor(S.coins*0.2); S.coins-=lost;
setTimeout(()=>{ show('death'); document.getElementById('deathSub').textContent =
lost>0 ? `You dropped ${lost} coins. The vale remembers: ${S.shrineName}.` : `The vale remembers: ${S.shrineName}.`; }, 900);
}
function respawn(){
S.running=true; S.paused=false; hide('death');
resetPlayerAt(S.shrine[0], S.shrine[1]);
S.hp=maxHp(); S.stamina=S.maxStamina;
player.invul=2.2;
cam.x = player.x - viewW()/2; cam.y = player.y - viewH()/2;
pBurst(player.x,player.y,'#ffe9a8',26,150);
updateHearts();
}
/* =========================================================================
ENEMIES
========================================================================= */
const enemies=[];
function spawnEnemy(kind, tx, ty, noRespawn){
const def=EDEF[kind]; if(!def) return null;
const e = {
kind, def, x:tx*TILE+16, y:ty*TILE+16, vx:0, vy:0, r:def.r,
hp:def.hp, max:def.hp, hitFlash:0, burn:0, freeze:0, dmgCd:0,
state:'idle', t:rnd(0,3), anim:rnd(0,6), dead:false, noRespawn:!!noRespawn,
aiT:rnd(0.4,1.6), boss:!!def.boss, phase:1, wind:0, dashT:0, dashX:0, dashY:0,
homeRegion:regionAtTile(tx,ty), hx:tx*TILE+16, hy:ty*TILE+16
};
enemies.push(e);
return e;
}
function spawnBoss(kind,tx,ty){
const e=spawnEnemy(kind,tx,ty,true);
if(e){
bossRef=e;
if(S && S.running){
SFX.bossRoar(); shake(12,0.9); flashScreen(0.5);
toast('⚠ '+({golem:'THE RUIN GOLEM AWAKENS',guardian:'THE FROST WARDEN STIRS',ashen:'THE WARDEN OF ASH'})[kind],'bad');
}
}
return e;
}
let bossRef=null;
function damageEnemy(e, amount, elem, crit, fromX, fromY, knock){
if(e.dead) return;
const def=e.def;
if(elem==='fire' && def.fireproof) { pText(e.x,e.y-e.r-6,'IMMUNE','#ff9b3d',10); SFX.deny(); return; }
const dmg = amount;
e.hp -= dmg; e.hitFlash=0.14;
pSpark(e.x,e.y-e.r*0.4, crit?14:8, elem==='ice'?'#bfefff':elem==='fire'?'#ffb154':'#ffe08a', {smax:210});
pText(e.x, e.y-e.r-8, (crit?'✶':'')+dmg, crit?'#ffe066':'#ffffff', crit?13:10);
if(crit) SFX.crit(); else SFX.hit();
shake(crit?6:3, 0.16);
if(knock && !e.boss){
const a=Math.atan2(e.y-fromY, e.x-fromX);
e.vx += Math.cos(a)*(knock/e.def.hp*25+40); e.vy += Math.sin(a)*(knock/e.def.hp*25+40);
}
if(elem==='fire'){ e.burn=Math.max(e.burn,2.6); SFX.burn(); }
if(elem==='ice'){ e.freeze=Math.max(e.freeze,2.0); SFX.freeze(); }
if(e.hp<=0) killEnemy(e);
}
function killEnemy(e){
e.dead=true;
const d=e.def;
const n = irnd(d.coin[0], d.coin[1]);
const mult = S.owned.greed ? 1.6 : 1;
S.kills++;
// coin drops
let left=Math.round(n*mult);
while(left>0){ const v=Math.min(left, left>=10? irnd(4,8) : left); left-=v; spawnCoin(e.x+rnd(-8,8), e.y+rnd(-8,8), v); }
pBurst(e.x,e.y,'#ffd76a',10,170);
pBurst(e.x,e.y, d.boss?'#ff9b3d':'#cbb9ff', d.boss?40:16, d.boss?260:190);
pRing(e.x,e.y, d.boss?'#ff9b3d':'#e8c26a', d.boss?90:34, {life:0.5,w:d.boss?6:3});
pSmoke(e.x,e.y, d.boss?18:6, 'rgba(30,20,40,0.7)');
if(d.boss){
shake(18,1.2); SFX.boom();
if(e.kind==='golem') onRuinGolemDown();
if(e.kind==='guardian') onGuardianDown();
if(e.kind==='ashen') onAshenDown();
}
const i=enemies.indexOf(e); if(i>=0) enemies.splice(i,1);
if(bossRef===e) bossRef=null;
}
function onGemDrop(){}
function spawnCoin(x,y,val){
drops.push({type:'coin',x,y,vx:rnd(-90,90),vy:rnd(-140,-40),val,r:7,life:0,vx2:0, bounces:0});
}
/* enemy AI */
function updateEnemies(dt){
const p=player;
for(let i=enemies.length-1;i>=0;i--){
const e=enemies[i];
if(e.dead) continue;
e.anim+=dt; e.t+=dt; e.dmgCd=Math.max(0,e.dmgCd-dt);
if(e.hitFlash>0) e.hitFlash-=dt;
if(e.burn>0){ e.burn-=dt; if(Math.random()<0.5) pSpark(e.x+rnd(-8,8), e.y+rnd(-10,4), 1, '#ff9b3d', {grav:-30, smax:60, smin:20});
e._burnTick=(e._burnTick||0)+dt; if(e._burnTick>0.45){ e._burnTick=0; e.hp-=2; pText(e.x,e.y-e.r,'2','#ffb154',9); if(e.hp<=0){ killEnemy(e); continue; } } }
if(e.freeze>0){ e.freeze-=dt; }
const dx=p.x-e.x, dy=p.y-e.y, dd=Math.hypot(dx,dy)||1;
const aggro = e.boss ? 640 : 460;
const spd = e.def.speed * (e.freeze>0?0.35:1) * (e.burn>0?0.9:1);
let ax=0, ay=0;
const ai=e.def.ai;
if(dd<aggro && !p.dead){
if(ai==='chase'){
if(e.def.trail && Math.random()<0.06) addPart({x:e.x,y:e.y+8,vx:0,vy:0,life:1.1,max:1.1,size:6,col:'#ff6a2b',kind:'flame',grav:0});
ax=dx/dd*spd; ay=dy/dd*spd;
} else if(ai==='erratic'){
const s=Math.sin(e.anim*4)*0.7;
ax=(dx/dd*Math.cos(s)-dy/dd*Math.sin(s))*spd;
ay=(dx/dd*Math.sin(s)+dy/dd*Math.cos(s))*spd;
} else if(ai==='lunge'){
if(e.state==='idle'){ ax=dx/dd*spd*0.45; ay=dy/dd*spd*0.45;
if(dd<150 && e.dmgCd<=0){ e.state='wind'; e.wind=0.34; } }
else if(e.state==='wind'){ e.wind-=dt; ax=ay=0;
if(e.wind<=0){ e.state='dash'; e.dashT=0.3; e.dashX=dx/dd; e.dashY=dy/dd; e.dmgCd=1.4; SFX.swing(false);} }
else if(e.state==='dash'){ e.dashT-=dt; ax=e.dashX*spd*3.4; ay=e.dashY*spd*3.4;
if(Math.random()<0.4) pSpark(e.x,e.y,1,'#cfd2dd',{smax:40,smin:0,grav:0});
if(e.dashT<=0) e.state='idle'; }
} else if(ai==='shooter'){
const want=210;
const k = dd<want-40? -1 : dd>want+40? 1 : 0;
ax=dx/dd*spd*k; ay=dy/dd*spd*k;
const perp = Math.sin(e.anim*1.5)*spd*0.5; ax += -dy/dd*perp; ay += dx/dd*perp;
if(e.dmgCd<=0 && dd<340){ e.dmgCd=rnd(1.5,2.3); e.state='shoot'; e.wind=0.3; }
if(e.state==='shoot'){ e.wind-=dt; if(e.wind<=0){ e.state='idle';
shootAt(e.x,e.y-6, p.x,p.y, 190, 2, 'bone'); } }
} else if(ai==='blink'){
ax=dx/dd*spd*0.6; ay=dy/dd*spd*0.6;
if(e.dmgCd<=0 && dd>90 && dd<420){ e.dmgCd=rnd(2.4,3.6);
pSpark(e.x,e.y,10,'#bfefff',{smax:150}); e.x=p.x-rnd(-70,70); e.y=p.y-rnd(-70,70);
moveEnt(e,0,0); pSpark(e.x,e.y,10,'#bfefff',{smax:150}); e.state='dash'; e.dashT=0.35; }
if(e.state==='dash'){ e.dashT-=dt; ax=dx/dd*spd*2.6; ay=dy/dd*spd*2.6; if(e.dashT<=0) e.state='idle'; }
} else if(ai==='miniboss'){
if(e.state==='idle'){ ax=dx/dd*spd; ay=dy/dd*spd;
if(e.dmgCd<=0 && dd<300){ e.state=rnd(0,1)<0.55?'wind':'shoot'; e.wind=0.6; e.dmgCd=rnd(2.2,3.0);} }
else if(e.state==='wind'){ e.wind-=dt; if(e.wind<=0){ e.state='slam'; e.wind=0.35;
const sx=e.x,sy=e.y; setTimeout(()=>{ if(!S.running) return; shockwave(sx,sy,110,2); SFX.boom(); shake(10,0.4);},10);} }
else if(e.state==='slam'){ e.wind-=dt; if(e.wind<=0) e.state='idle'; }
else if(e.state==='shoot'){ e.wind-=dt; if(e.wind<=0){ e.state='idle';
for(let k=0;k<8;k++){ const a=k/8*Math.PI*2; shootAt(e.x,e.y, e.x+Math.cos(a), e.y+Math.sin(a), 160, 2, 'ice'); } } }
} else if(ai==='golem' || ai==='ashen'){
e._ax=0; e._ay=0;
bossAI(e, dx,dy,dd,spd);
ax=e._ax; ay=e._ay;
}
} else {
// idle wander
if(Math.random()<0.01) e.wanderA=rnd(0,Math.PI*2);
ax=Math.cos(e.wanderA||0)*spd*0.3; ay=Math.sin(e.wanderA||0)*spd*0.3;
}
// separation
for(const o of enemies){ if(o===e) continue; const ox=o.x-e.x, oy=o.y-e.y; const od=Math.hypot(ox,oy);
if(od>0.1 && od<(e.r+o.r+2)){ ax-=ox/od*spd*0.7; ay-=oy/od*spd*0.7; } }
// bosses stay inside their arena (leash home)
if(e.boss && e.hx!=null){
const hx=e.hx-e.x, hy=e.hy-e.y, hd=Math.hypot(hx,hy)||1;
if(hd>380){ ax=hx/hd*spd*1.25; ay=hy/hd*spd*1.25; e.state='idle'; }
}
const acc = 6.5;
e.vx = lerp(e.vx, ax, Math.min(1,acc*dt)); e.vy = lerp(e.vy, ay, Math.min(1,acc*dt));
const isGhost = (ai==='blink' && e.state==='dash');
if(isGhost){ e.x+=e.vx*dt; e.y+=e.vy*dt; e.x=clamp(e.x,16,WW-16); e.y=clamp(e.y,16,WH-16); }
else moveEnt(e, e.vx*dt, e.vy*dt);
// contact damage
if(!p.dead && dd < e.r+p.r+1 && e.dmgCd<=0){
e.dmgCd = e.boss?1.0:0.85;
takeDamage(e.def.dmg, e.x, e.y);
if(e.dashT>0) e.dashT=0;
}
// keep far-away non-boss enemies cheap
if(!e.boss && dd>1400){ e.x += dx/dd*4; e.y += dy/dd*4; }
}
}
function bossAI(e, dx,dy,dd,spd){
const hpPct = e.hp/e.max;
if(e.kind==='golem'){
if(hpPct<0.45 && e.phase===1){ e.phase=2; e.def={...e.def, speed:e.def.speed*1.3}; SFX.bossRoar(); flashScreen(0.45); toast('The golem cracks — it moves faster!','bad'); }
if(e.state==='idle'){
e.vx=0; e.vy=0;
e._ax = dx/dd*spd*1.1; e._ay = dy/dd*spd*1.1;
if(e.dmgCd<=0 && dd<260){ e.state='wind'; e.wind=0.65; e.dmgCd = e.phase===2?1.6:2.4; }
if(e.dmgCd<=0 && Math.random()<0.008){ summonAdds(e, e.phase===2?3:2, 'bone'); e.dmgCd=2.2; }
} else if(e.state==='wind'){ e.wind-=dt; e.vx=e.vy=0;
if(e.wind<=0){ e.state='slam'; e.wind=0.4; const sx=e.x,sy=e.y;
setTimeout(()=>{ if(!S.running) return; shockwave(sx,sy,140,3); if(e.phase===2) setTimeout(()=>shockwave(sx,sy,210,2),180); SFX.boom(); shake(14,0.5);
for(let k=0;k<12;k++) pSpark(sx,sy,1,'#a08456',{smax:320,dir:rnd(0,6.28)});},10); } }
else if(e.state==='slam'){ e.wind-=dt; if(e.wind<=0) e.state='idle'; }
} else if(e.kind==='ashen'){
if(hpPct<0.5 && e.phase===1){ e.phase=2; SFX.bossRoar(); flashScreen(0.55);
toast('THE WARDEN UNBINDS THE SEAL','bad'); e.def={...e.def, speed:e.def.speed*1.25}; }
if(e.state==='idle'){
e._ax = dx/dd*spd*1.1; e._ay = dy/dd*spd*1.1;
if(e.dmgCd<=0){ const r=Math.random();
if(r<0.4){ e.state='wind'; e.wind=0.6; e.dmgCd=e.phase===2?1.3:2.0; }
else if(r<0.7){ e.state='shoot'; e.wind=0.45; e.dmgCd=e.phase===2?1.6:2.4; }
else { summonAdds(e, e.phase===2?4:2, 'magma'); e.dmgCd=2.6; } }
if(Math.random()<0.25) addPart({x:e.x+rnd(-20,20),y:e.y-10+rnd(-16,16),vx:rnd(-20,20),vy:-40,life:0.7,max:0.7,size:6,col:'#ff5a2b',kind:'flame'});
} else if(e.state==='wind'){ e.wind-=dt; if(e.wind<=0){ e.state='slam'; e.wind=0.5;
const sx=e.x,sy=e.y; setTimeout(()=>{ if(!S.running) return; shockwave(sx,sy,170,3); SFX.boom(); shake(16,0.5); },10); } }
else if(e.state==='slam'){ e.wind-=dt; if(e.wind<=0){ e.state='idle';
if(e.phase===2) for(let k=0;k<10;k++){ const a=k/10*Math.PI*2; shootAt(e.x,e.y,e.x+Math.cos(a),e.y+Math.sin(a),210,2,'fire'); } } }
else if(e.state==='shoot'){ e.wind-=dt; if(e.wind<=0){ e.state='idle';
const base=Math.atan2(dy,dx);
for(let k=-2;k<=2;k++) shootAt(e.x,e.y,e.x+Math.cos(base+k*0.24),e.y+Math.sin(base+k*0.24), 230, 2,'fire'); } }
}
}
function summonAdds(e, n, kind){
for(let i=0;i<n;i++){ const a=rnd(0,6.28), d=rnd(90,160);
const tx=Math.floor((e.x+Math.cos(a)*d)/TILE), ty=Math.floor((e.y+Math.sin(a)*d)/TILE);
if(!tileSolid(tx,ty)){ const a2=spawnEnemy(kind,tx,ty,true); pRing(tx*TILE+16,ty*TILE+16,'#cbb9ff',40,{life:0.5});
if(a2) a2.lifeOut=14; } }
SFX.bad();
}
function shockwave(x,y,radius,dmg){
pRing(x,y,'#ff9b3d',radius,{life:0.5,w:6}); pRing(x,y,'#ffd76a',radius*0.7,{life:0.35,w:3});
pSpark(x,y,20,'#ffb154',{smax:300,grav:0});
if(dist2(x,y,player.x,player.y) < radius*radius) takeDamage(dmg, x, y);
}
/* =========================================================================
PROJECTILES
========================================================================= */
const shots=[];
function shootAt(x,y,tx,ty,speed,dmg,kind){
const a=Math.atan2(ty-y,tx-x);
shots.push({x,y,vx:Math.cos(a)*speed,vy:Math.sin(a)*speed,dmg,kind,life:2.2,hostile:kind!=='arrow',a});
if(kind==='arrow') SFX.arrow();
}
function playerShoot(){
if(!S.bowOwned || player.bowCd>0 || player.dead) return;
const cost = S.owned.pendant ? Math.round(BOW.cost*0.5) : BOW.cost;
if(S.stamina<cost){ SFX.deny(); pText(player.x,player.y-24,'TIRED','#8e82b8',10); return; }
S.stamina-=cost; player.bowCd=BOW.cd;
const a=player.aim;
const sx=player.x+Math.cos(a)*14, sy=player.y+Math.sin(a)*10;
shots.push({x:sx,y:sy,vx:Math.cos(a)*BOW.speed,vy:Math.sin(a)*BOW.speed,dmg:BOW.dmg,kind:'arrow',life:BOW.life,hostile:false,a,
pierce:S.owned.pendant, hitSet:[]});
pSpark(sx,sy,4,'#bfefff',{smax:90,dir:a,spread:0.4});
player.face={x:Math.cos(a),y:Math.sin(a)};
SFX.arrow();
}
function updateShots(dt){
for(let i=shots.length-1;i>=0;i--){
const s=shots[i];
s.x+=s.vx*dt; s.y+=s.vy*dt; s.life-=dt;
const t=tileAt(Math.floor(s.x/TILE),Math.floor(s.y/TILE));
const blockedByTile = gSolid(t) && t!==T.ASH && t!==T.BRAZ; // arrows fly over lava; gates/walls block
if(s.life<=0 || blockedByTile){
if(blockedByTile){ pSpark(s.x,s.y,5,'#cfd2dd',{smax:110}); }
shots.splice(i,1); continue;
}
if(s.hostile){
if(dist2(s.x,s.y,player.x,player.y) < (s.dmg? (player.r+5)**2 : 100)){
takeDamage(s.dmg, s.x, s.y); pSpark(s.x,s.y,7,'#ff9b3d',{smax:150}); shots.splice(i,1); continue;
}
} else {
let hit=false;
for(const e of enemies){ if(e.dead) continue;
if(s.hitSet && s.hitSet.indexOf(e)>=0) continue;
if(dist2(s.x,s.y,e.x,e.y) < (e.r+6)**2){
damageEnemy(e, s.dmg, null, false, s.x-s.vx*0.05, s.y-s.vy*0.05, BOW.knock);
pSpark(s.x,s.y,7,'#bfefff',{smax:140});
if(s.pierce){ s.hitSet.push(e); } else { hit=true; }
if(hit) break;
} }
if(hit){ shots.splice(i,1); continue; }
// arrows light ember obelisks
for(const o of obelisks){ if(o.lit) continue;
if(dist2(s.x,s.y,o.x,o.y) < 20*20){ lightObelisk(o); shots.splice(i,1); hit=true; break; } }
if(hit) continue;
}
// trail
if(s.kind==='arrow' && Math.random()<0.6) addPart({x:s.x,y:s.y,vx:0,vy:0,life:0.24,max:0.24,size:2.4,col:'#bfefff',kind:'dot',grav:0});
if(s.kind==='fire' && Math.random()<0.8) addPart({x:s.x,y:s.y,vx:rnd(-20,20),vy:rnd(-20,20),life:0.3,max:0.3,size:4,col:'#ff9b3d',kind:'flame',grav:0});
}
}
/* =========================================================================
DROPS / PICKUPS
========================================================================= */
const drops=[];
function spawnDropItem(x,y,item,qty){ drops.push({type:'item',item,qty:qty||1,x,y,vx:rnd(-50,50),vy:rnd(-140,-70),r:9,life:0,bounces:0}); }
function spawnHeartDrop(x,y,val){ drops.push({type:'heart',x,y,vx:rnd(-60,60),vy:rnd(-140,-70),val:val||2,r:9,life:0,bounces:0}); }
function updateDrops(dt){
for(let i=drops.length-1;i>=0;i--){
const d=drops[i];
const near = dist2(d.x,d.y,player.x,player.y);
if(d.type==='coin' && near < 90*90){ const a=Math.atan2(player.y-d.y,player.x-d.x);
d.vx=lerp(d.vx,Math.cos(a)*300,0.16); d.vy=lerp(d.vy,Math.sin(a)*300,0.16); d.magnet=true; }
d.x+=d.vx*dt; d.y+=d.vy*dt;
if(!d.magnet){ d.vy+=420*dt; }
const t=tileAt(Math.floor(d.x/TILE),Math.floor(d.y/TILE));
if(gSolid(t)){ d.x-=d.vx*dt; d.y-=d.vy*dt; d.vx*=-0.4; d.vy*=-0.3; }
if(near < 16*16){
if(d.type==='coin'){ S.coins+=d.val; SFX.coin(); pSpark(d.x,d.y,4,'#ffd76a',{smax:90,grav:0}); pText(d.x,d.y-14,'+'+d.val,'#ffd76a',10); updateHud(); }
else if(d.type==='heart'){ if(S.hp>=maxHp() && Math.random()<0.9){ continue; }
const h=heal(d.val); if(h>0){ SFX.pickup(); pText(d.x,d.y-14,'+'+h,'#ff8a7a',11); pSpark(d.x,d.y,8,'#ff5a4a',{smax:120,grav:0}); updateHearts(); } else continue; }
else if(d.type==='item'){ grantItem(d.item, d.qty); }
drops.splice(i,1); continue;
}
if(d.life>0 && (d.life-=dt)<=0) drops.splice(i,1);
}
}
function grantItem(item, qty){
SFX.pickup();
const def=ITEMS[item];
if(def && def.type==='accessory'){
if(!S.owned[item]){ S.owned[item]=true; toast('EQUIPPED — '+def.name+' · '+def.desc,'good'); SFX.upgrade(); pRing(player.x,player.y,'#e8c26a',60,{life:0.7,w:4}); }
else { S.coins+=40; toast('Duplicate '+def.name+' → +40 coins','item'); }
} else {
const cap = def.max||9;
S.inv[item]=(S.inv[item]||0)+qty;
if(S.inv[item]>cap) S.inv[item]=cap;
toast('+'+qty+' '+def.name+' (press '+itemKey(item)+')','item');
if(!S.seen.includes(item)) S.seen.push(item);
}
pSpark(player.x,player.y-10,12,'#ffe9a8',{smax:150,grav:0,dir:-Math.PI/2,spread:1.2});
updateHud(); renderInventory();
}
/* =========================================================================
WORLD CONTENT
========================================================================= */
const chests=[], npcs=[], obelisks=[], runes=[], plates=[], blocks=[], shrines=[], signs=[], gates=[];
const BLOCK_ICO = '⛁';
function addChest(tx,ty,loot,label){
chests.push({tx,ty,x:tx*TILE+16,y:ty*TILE+16,opened:false,loot,label:label||'CHEST',r:12});
LANDMARKS.push([tx,ty]);
}
function addShrine(tx,ty,name){
shrines.push({tx,ty,x:tx*TILE+16,y:ty*TILE+16,name,r:16,glow:0});
}
function addBlock(tx,ty){ const b={tx,ty,x:tx*TILE+16,y:ty*TILE+16,r:12,tx2:tx,ty2:ty,moving:false,mx:0,my:0}; blocks.push(b); return b; }
function addPlate(tx,ty){ plates.push({tx,ty,x:tx*TILE+16,y:ty*TILE+16,pressed:false,r:10}); }
function addObelisk(tx,ty){ obelisks.push({tx,ty,x:tx*TILE+16,y:ty*TILE+16,lit:false,r:13}); }
function addRune(tx,ty,i){ runes.push({tx,ty,x:tx*TILE+16,y:ty*TILE+16,i,lit:false,flash:0,r:12}); }
function addSign(tx,ty,text){ signs.push({tx,ty,x:tx*TILE+16,y:ty*TILE+16,text,r:12}); LANDMARKS.push([tx,ty]); }
function addNpc(tx,ty,name,kind,lines){
const n={tx,ty,x:tx*TILE+16,y:ty*TILE+16,name,kind,lines,r:12,said:0,anim:0};
npcs.push(n); LANDMARKS.push([tx,ty]); return n;
}
function addGate(key){
const g=GATES[key]; const o={key,label:g.label,flag:g.flag,open:false,rx:g.x*TILE,ry:g.y*TILE,rw:g.w*TILE,rh:g.h*TILE};
gates.push(o); return o;
}
function buildContent(){
chests.length=0; npcs.length=0; obelisks.length=0; runes.length=0;
plates.length=0; blocks.length=0; shrines.length=0; signs.length=0; gates.length=0;
LANDMARKS.length=0;
/* shrines / save points */
const shrineNames=['MEADOW VILLAGE','WHISPERING WOODS','SUNKEN RUINS','EMBER CAVERNS','FROSTPEAK PASS','ASHEN CITADEL'];
SHRINES.forEach((s,i)=>{ addShrine(s[0],s[1],shrineNames[i]); LANDMARKS.push(s); });
/* meadow */
addNpc(20,44,'HOLLOWAY','shop');
addNpc(15,48,'ELDER MAREN','elder');
addSign(17,53,'↗ EAST: the Whispering Woods. Wolves hunt where the light thins.\n↑ NORTH from the woods: a Sealed Gate, humming with old magic.');
addSign(10,46,'WASD to move · SPACE to swing · SHIFT to dash.\nStamina runs dry — watch the green bar.');
addChest(26,61,[{coins:60}],'OLD COFFER');
addChest(9,62,[{item:'potion',qty:2}],'TRAVELLER\'S CACHE');
/* woods */
addChest(39,61,[{weapon:'ember'}],'SUNKEN SHRINE');
addChest(58,44,[{coins:45},{item:'potion',qty:1}],'MOSSY CHEST');
addSign(36,52,'Three stones. Three plates. The vault behind the gate holds a Heart.\nPush by walking into a stone.');
const w=PUZ.woods;
w.blocks.forEach(b=>addBlock(b[0],b[1]));
w.plates.forEach(p=>addPlate(p[0],p[1]));
addGate('sealed');
/* ruins */
addChest(72,62,[{coins:90},{item:'bomb',qty:2}],'DROWNED CHEST');
addSign(68,48,'FIVE RUNES REMEMBER AN OLDER SONG.\nHark: the mural whispers the order — read it, then strike in turn.');
addSign(70,48,'The mural reads: …the sequence is carved upon it. [Read the other slab]');
(function(){ PUZ.ruins.runes.forEach((p,i)=>addRune(p[0],p[1],i)); })();
// mural: a sign that reveals the order
const mural={tx:74,ty:43,x:74*TILE+16,y:43*TILE+16,r:12,text:'',isMural:true};
signs.push(mural); LANDMARKS.push([74,43]);
/* ember */
PUZ.ember.obelisks.forEach(o=>addObelisk(o[0],o[1]));
addChest(26,27,[{weapon:'frost'}],'FROZEN VAULT');
addChest(10,27,[{coins:130},{item:'potion',qty:2}],'MAGMA CHEST');
addChest(22,20,[{item:'bomb',qty:2},{coins:50}],'SCORCHED CHEST');
addSign(8,20,'THE COLD FORGE — beyond the root-choked causeway east.\nA lake of fire guards four singing stones. Only a flying shaft reaches them.');
/* frost */
addChest(50,28,[{coins:120},{item:'bomb',qty:3},{item:'elixir',qty:2}],'RIMED CHEST');
addChest(42,10,[{coins:70}],'SNOWBOUND CHEST');
addSign(36,16,'THE ASHEN GATE lies east beyond this pass.\nNothing mortal opens it — but a blade of frost might answer it.');
addGate('ashen');
/* citadel */
addChest(68,24,[{coins:100},{item:'potion',qty:3},{item:'elixir',qty:2}],'LAST RESERVE');
addSign(68,20,'THE WARDEN OF ASH SITS BEYOND. No more running.');
/* mobs */
spawnRegionMobs(true);
// bosses
spawnBoss('golem', 79, 60);
spawnBoss('guardian', 57, 24);
spawnBoss('ashen', 82, 21);
}
let MOB_TARGET = { meadow:6, woods:9, ruins:9, ember:9, frost:9, citadel:10 };
function freeSpawnSpots(r, n){
const spots=[]; const tries=400;
for(let k=0;k<tries && spots.length<n;k++){
const tx=irnd(r.x+2,r.x+r.w-3), ty=irnd(r.y+2,r.y+r.h-3);
if(tileAt(tx,ty)!==T.FLOOR) continue;
if(inReserved(tx,ty)) continue;
const px=tx*TILE+16, py=ty*TILE+16;
if(dist2(px,py,player.x,player.y) < 340*340 && S && S.running) continue;
spots.push([tx,ty]);
}
return spots;
}
function spawnRegionMobs(initial){
for(const r of REGIONS){
const pool=r.foes;
const n = initial ? MOB_TARGET[r.id] : 2;
const spots=freeSpawnSpots(r, n);
for(const [tx,ty] of spots) spawnEnemy(pick(pool), tx, ty);
}
}
function maintainEnemies(){
if(!S.running) return;
for(const r of REGIONS){
let alive=0;
for(const e of enemies) if(!e.boss && e.homeRegion===r.id && !e.dead) alive++;
if(alive < MOB_TARGET[r.id]){
const spots=freeSpawnSpots(r, 2);
for(const [tx,ty] of spots) spawnEnemy(pick(r.foes), tx, ty);
}
}
}
/* =========================================================================
PUZZLES
========================================================================= */
function updateBlocks(dt){
for(const b of blocks){
if(!b.moving) continue;
b.mx = lerp(b.mx,0,Math.min(1,12*dt));
b.my = lerp(b.my,0,Math.min(1,12*dt));
b.x = b.tx*TILE+16 + b.mx; b.y = b.ty*TILE+16 + b.my;
if(Math.abs(b.mx)<0.4 && Math.abs(b.my)<0.4){ b.mx=b.my=0; b.x=b.tx*TILE+16; b.y=b.ty*TILE+16; b.moving=false; }
}
}
function blockAt(tx,ty){ for(const b of blocks) if(b.tx===tx && b.ty===ty) return b; return null; }
function enemyAt(tx,ty){ for(const e of enemies){ if(e.dead) continue; if(Math.floor(e.x/TILE)===tx && Math.floor(e.y/TILE)===ty) return e; } return null; }
function tryPush(b, dx, dy){
if(b.moving) return false;
const nx=b.tx+dx, ny=b.ty+dy;
if(tileSolid(nx,ny) || blockAt(nx,ny) || enemyAt(nx,ny)) return false;
for(const c of chests) if(c.tx===nx && c.ty===ny) return false;
b.tx=nx; b.ty=ny; b.moving=true; b.mx=-dx*TILE; b.my=-dy*TILE;
pSmoke(b.x,b.y+8,4,'rgba(20,16,28,0.55)'); SFX.step();
return true;
}
function updatePuzzles(dt){
/* woods: plates + blocks + gate */
const w=PUZ.woods;
if(!w.done){
let all=true;
for(const p of plates){
const held = (w.plates.some(q=>q[0]===p.tx && q[1]===p.ty)) ? blockAt(p.tx,p.ty) : null;
const on = !!held;
if(on !== p.pressed){ p.pressed=on; if(on){ SFX.rune(); pRing(p.x,p.y,'#e8c26a',26,{life:0.4}); pSpark(p.x,p.y,8,'#ffe9a8',{smax:110,grav:-20}); } else { SFX.deny(); } }
if(!on) all=false;
}
if(all){
w.done=true; S.flags.woodsPuzzle=true;
openGateTiles(w.gate); SFX.gate(); toast('The stone gate grinds open!','good');
shake(8,0.5); flashScreen(0.25);
setObjective();
}
}
/* ruins runes */
const ru=PUZ.ruins;
for(const r of runes) if(r.flash>0) r.flash-=dt;
/* ember obelisks */
const em=PUZ.ember;
if(!em.solved && obelisks.length && obelisks.every(o=>o.lit)){
em.solved=true; SFX.upgrade(); flashScreen(0.3); shake(8,0.6);
toast('The lava stills — a vault surfaces!','good');
spawnPuzzleChest(PUZ.ember.chest[0], PUZ.ember.chest[1], [{container:true}], 'SEARED VAULT');
setObjective();
}
/* frost vents */
const fr=PUZ.frost;
for(const v of fr.vents){
const k=idx(v[0],v[1]);
if(frostTiles[k]>0){
frostTiles[k]-=dt;
if(Math.random()<0.4) addPart({x:v[0]*TILE+16+rnd(-8,8),y:v[1]*TILE+16+rnd(-8,8),vx:0,vy:-20,life:0.4,max:0.4,size:3,col:'#bfefff',kind:'dot',grav:-10});
if(frostTiles[k]<=0){ frostTiles[k]=0; setTile(v[0],v[1],T.VENT); pBurst(v[0]*TILE+16,v[1]*TILE+16,'#ff9b3d',12,120); SFX.burn(); }
}
}
if(!fr.done){
const c=chests.find(ch=>ch.tx===fr.chest[0]&&ch.ty===fr.chest[1]);
if(c && c.opened){ fr.done=true; }
}
}
function spawnPuzzleChest(tx,ty,loot,label){
// clear the tile then place a chest with birth FX
if(tileSolid(tx,ty)) setTile(tx,ty,T.FLOOR);
addChest(tx,ty,loot,label);
pRing(tx*TILE+16,ty*TILE+16,'#ffe9a8',50,{life:0.8,w:4});
pBurst(tx*TILE+16,ty*TILE+16,'#e8c26a',22,200);
}
function openGateTiles(g){
for(let j=g.y;j<g.y+g.h;j++) for(let i=g.x;i<g.x+g.w;i++) setTile(i,j,T.FLOOR);
for(const gt of gates){
if(Math.abs(gt.rx-g.x*TILE)<4 && Math.abs(gt.ry-g.y*TILE)<4){
gt.open=true;
for(let k=0;k<30;k++) addPart({x:gt.rx+rnd(0,gt.rw),y:gt.ry+rnd(0,gt.rh),vx:rnd(-40,40),vy:rnd(-140,-30),life:rnd(0.5,1.1),max:1.1,size:rnd(3,7),col:'#8fd3ff',kind:'dot',grav:120});
}
}
}
function lightObelisk(o){
o.lit=true; SFX.rune(); pRing(o.x,o.y,'#ffb154',34,{life:0.5,w:4});
pBurst(o.x,o.y,'#ff9b3d',18,170); pText(o.x,o.y-24,'LIT','#ffb154',10);
const lit=obelisks.filter(x=>x.lit).length;
if(lit<obelisks.length) toast('Obelisk lit — '+lit+'/'+obelisks.length,'item');
}
function strikeRune(r, elem){
if(PUZ.ruins.solved) return;
const step = PUZ.ruins.lit.length;
r.flash=0.3;
if(r.i === PUZ.ruins.order[step]){
r.lit=true; PUZ.ruins.lit.push(r.i);
SFX.rune(); pRing(r.x,r.y,'#9be0ff',30,{life:0.5,w:3}); pBurst(r.x,r.y,'#9be0ff',12,140);
if(PUZ.ruins.lit.length===runes.length){
PUZ.ruins.solved=true; SFX.upgrade(); flashScreen(0.3);
toast('The runes resolve — a hidden vault opens!','good');
spawnPuzzleChest(PUZ.ruins.chest[0], PUZ.ruins.chest[1], [{item:'boots'}], 'HIDDEN VAULT');
} else toast('Rune '+PUZ.ruins.lit.length+'/'+runes.length+' sings true','item');
} else {
for(const q of runes) q.lit=false;
PUZ.ruins.lit.length=0;
SFX.bad(); shake(6,0.3); pBurst(r.x,r.y,'#ff5a4a',16,150);
toast('Wrong rune — the runes darken.','bad');
}
}
function strikeVent(tx,ty){
if(tileAt(tx,ty)!==T.VENT) return;
frostTiles[idx(tx,ty)] = 6.0;
setTile(tx,ty,T.FLOOR);
SFX.freeze(); pBurst(tx*TILE+16,ty*TILE+16,'#bfefff',18,160); pRing(tx*TILE+16,ty*TILE+16,'#bfefff',30,{life:0.5,w:3});
toast('The aetherfire freezes over — 6 seconds!','item');
}
const frostTiles = new Float32Array(MW*MH);
/* =========================================================================
PROGRESSION
========================================================================= */
function grantWeapon(key){
S.weapon=key;
S.flags[key==='ember'?'emberEdge':'frostfang']=true;
SFX.upgrade(); flashScreen(0.5); shake(10,0.7);
const def=WEAPONS[key];
pRing(player.x,player.y,'#ffe9a8',70,{life:0.9,w:5});
for(let i=0;i<44;i++) addPart({x:player.x,y:player.y,vx:rnd(-260,260),vy:rnd(-320,-40),life:rnd(0.5,1.3),max:1.3,
size:rnd(2,5),col:def.elem==='fire'?'#ff9b3d':'#9ee7ff',kind:'dot',grav:140});
toast('★ WEAPON UPGRADED — '+def.name+' · '+def.dmg+' damage'+(def.elem==='fire'?' + BURN':' + FREEZE & reach'),'good');
pText(player.x,player.y-42,def.name.toUpperCase(),'#ffe9a8',13);
updateHud(); setObjective();
}
function grantBow(){
if(S.bowOwned) return;
S.bowOwned=true;
SFX.upgrade(); flashScreen(0.5);
toast('★ RANGED WEAPON — STORMBOW · press [K] / right-click to fire','good');
pRing(player.x,player.y,'#9be0ff',80,{life:1.0,w:5});
for(let i=0;i<40;i++) addPart({x:player.x,y:player.y,vx:rnd(-240,240),vy:rnd(-300,-40),life:rnd(0.4,1.2),max:1.2,
size:rnd(2,5),col:'#9be0ff',kind:'dot',grav:120});
updateHud(); setObjective();
}
function grantContainer(){
if(S.containers>=5){ S.coins+=120; toast('Already at five hearts → +120 coins','item'); return; }
S.containers++; heal(2); SFX.heart(); flashScreen(0.35);
const hd=document.querySelectorAll('.heart'); const last=hd[hd.length-1];
if(last){ last.classList.add('beat'); setTimeout(()=>last.classList.remove('beat'),520); }
pRing(player.x,player.y,'#ff5a4a',72,{life:0.9,w:5});
for(let i=0;i<34;i++) addPart({x:player.x,y:player.y,vx:rnd(-200,200),vy:rnd(-300,-40),life:rnd(0.5,1.2),max:1.2,
size:rnd(2,5),col:'#ff6b5e',kind:'dot',grav:120});
toast('♥ HEART CONTAINER — '+S.containers+'/5 (max health '+S.containers*2+')','good');
updateHearts(); updateHud(); setObjective();
}
function onRuinGolemDown(){
S.flags.ruinsBoss=true;
openGateTiles(PUZ.gateSealedTiles||GATES.sealed);
for(const g of gates) if(g.key==='sealed') g.open=true;
toast('The Ruin Golem collapses. The Sealed Gate exhales and opens.','good');
grantBow(); grantContainer();
setObjective();
}
function onGuardianDown(){
S.flags.frostWarden=true;
toast('The Frost Warden shatters — Frostpeak lies open.','good');
spawnPuzzleChest(57,26,[{coins:120},{item:'elixir',qty:2},{item:'potion',qty:2}],'WARDEN\'S HOARD');
setObjective();
}
function onAshenDown(){
S.flags.citadelBoss=true; S.running=false; winner();
}
function setObjective(){
const f=S.flags;
let o='';
if(!f.emberEdge) o='Whispering Woods (east): find the Sunken Shrine and take the Ember Edge.';
else if(!f.woodsPuzzle) o='Whispering Woods: push three stones onto the three plates to open the vault.';
else if(!f.ruinsBoss) o='Sunken Ruins (east): solve the five runes, then destroy the Ruin Golem.';
else if(!f.frostfang) o='Ember Caverns (west, past Frostpeak): shoot the 4 obelisks over the lava [K], then take the Frostfang.';
else if(!f.frostWarden) o='Frostpeak Pass: break the Aetherfire curtain with Frostfang, and slay the Frost Warden.';
else if(S.containers<5) o='One heart remains — clear Frostpeak\'s vault and Ember Caverns.';
else if(!f.citadelBoss) o='The Ashen Gate is open. Climb north-east to the Ashen Citadel.';
S.objective=o;
const el=document.getElementById('objective'); if(el) el.textContent='◆ '+o;
}
/* =========================================================================
CAMERA / SCREEN FX
========================================================================= */
const cam={x:0,y:0,sh:0,shT:0};
let timeScale=1, hitStopT=0;
function viewW(){ return canvas.width/(ZOOM*DPR); }
function viewH(){ return canvas.height/(ZOOM*DPR); }
function shake(a,d){ cam.sh=Math.max(cam.sh,a); cam.shT=Math.max(cam.shT,d); }
function hitStop(d){ hitStopT=Math.max(hitStopT,d); }
function flashScreen(a){ const f=document.getElementById('flash'); f.style.transition='none'; f.style.opacity=a;
requestAnimationFrame(()=>{ f.style.transition='opacity .45s'; f.style.opacity=0; }); }
function centerCam(){ cam.x=player.x-viewW()/2; cam.y=player.y-viewH()/2; }
function clampCam(){
cam.x = clamp(cam.x, 0, WW-viewW());
cam.y = clamp(cam.y, 0, WH-viewH());
}
function updateCam(dt){
const tx=player.x-viewW()/2, ty=player.y-viewH()/2;
cam.x=lerp(cam.x,tx,Math.min(1,9*dt));
cam.y=lerp(cam.y,ty,Math.min(1,9*dt));
if(cam.shT>0){ cam.shT-=dt; cam.sh=Math.max(0,cam.sh-dt*30); } else cam.sh=0;
clampCam();
}
/* =========================================================================
RENDER
========================================================================= */
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
let DPR=Math.min(2, window.devicePixelRatio||1);
function resize(){
DPR=Math.min(2, window.devicePixelRatio||1);
canvas.width=Math.floor(innerWidth*DPR); canvas.height=Math.floor(innerHeight*DPR);
canvas.style.width=innerWidth+'px'; canvas.style.height=innerHeight+'px';
}
addEventListener('resize', resize);
const shade = new Uint8Array(MW*MH);
function hash2(x,y){ let h=x*374761393+y*668265263; h=(h^(h>>13))*1274126177; return ((h^(h>>16))>>>0)/4294967296; }
function buildShade(){ for(let i=0;i<MW*MH;i++) shade[i]=Math.floor(hash2(i%MW, (i/MW)|0)*4); }
function drawRoomTints(){
// subtle per-region vignette tint drawn in world space
for(const r of REGIONS){
const x=r.x*TILE,y=r.y*TILE,w=r.w*TILE,h=r.h*TILE;
if(x>cam.x+viewW()||x+w<cam.x||y>cam.y+viewH()||y+h<cam.y) continue;
ctx.fillStyle=r.floor; ctx.fillRect(x,y,w,h);
}
}
function drawTiles(){
const x0=Math.max(0,Math.floor(cam.x/TILE)-1), x1=Math.min(MW-1,Math.ceil((cam.x+viewW())/TILE));
const y0=Math.max(0,Math.floor(cam.y/TILE)-1), y1=Math.min(MH-1,Math.ceil((cam.y+viewH())/TILE));
const tx=t=>Math.floor(t*TILE);
for(let ty=y0;ty<=y1;ty++){
for(let tx2=x0;tx2<=x1;tx2++){
const v=tileAt(tx2,ty), X=tx2*TILE, Y=ty*TILE;
const reg=REGIONS.find(r=>r.id===world.region[idx(tx2,ty)])||REGIONS[0];
if(v===T.FLOOR || v===T.WATER){
const s=shade[idx(tx2,ty)];
ctx.fillStyle = v===T.WATER ? '#1f3a55' : (s===1?reg.floor2:reg.floor);
ctx.fillRect(X,Y,TILE,TILE);
if(v===T.FLOOR && s===3){
ctx.globalAlpha=0.35; ctx.fillStyle=reg.accent; ctx.fillRect(X+7,Y+7,18,18); ctx.globalAlpha=1;
}
if(v===T.WATER){
const t=performance.now()/600+tx2*0.4+ty*0.7;
ctx.fillStyle='rgba(120,190,240,'+(0.10+0.09*Math.sin(t))+')';
ctx.fillRect(X,Y,TILE,TILE);
ctx.fillStyle='rgba(255,255,255,0.07)';
ctx.fillRect(X+4+Math.sin(t)*3, Y+8, 14, 2);
} else {
// grass speckle
if(s===1){ ctx.fillStyle='rgba(255,255,255,0.05)'; ctx.fillRect(X+6,Y+20,3,2); ctx.fillRect(X+22,Y+9,2,2); }
if(s===2){ ctx.fillStyle='rgba(0,0,0,0.06)'; ctx.fillRect(X+10,Y+12,6,3); }
}
} else if(v===T.WALL){
ctx.fillStyle=reg.wall; ctx.fillRect(X,Y,TILE,TILE);
ctx.fillStyle='rgba(255,255,255,0.045)'; ctx.fillRect(X,Y,TILE,3);
if(shade[idx(tx2,ty)]%2===0){ ctx.fillStyle='rgba(0,0,0,0.11)'; ctx.fillRect(X+4,Y+10,11,9); ctx.fillRect(X+18,Y+20,9,7); }
if(!gSolid(tileAt(tx2,ty-1))){ ctx.fillStyle=reg.wallTop; ctx.fillRect(X,Y,TILE,7);
ctx.fillStyle='rgba(255,255,255,0.10)'; ctx.fillRect(X,Y,TILE,2); }
ctx.fillStyle='rgba(0,0,0,0.18)'; ctx.fillRect(X,Y+TILE-2,TILE,2);
} else if(v===T.PILLAR){
const tree = (reg.id==='meadow'||reg.id==='woods');
ctx.fillStyle=reg.floor2; ctx.fillRect(X,Y,TILE,TILE);
ctx.fillStyle='rgba(0,0,0,0.22)'; ctx.beginPath(); ctx.ellipse(X+16,Y+26,12,5,0,0,6.3); ctx.fill();
if(tree){
ctx.fillStyle='#3a2a1c'; ctx.fillRect(X+14,Y+16,6,12);
ctx.fillStyle='#2c5a2c'; ctx.beginPath(); ctx.arc(X+16,Y+13,12,0,6.3); ctx.fill();
ctx.fillStyle='#3d7a38'; ctx.beginPath(); ctx.arc(X+13,Y+10,8,0,6.3); ctx.fill();
ctx.fillStyle='#4f9744'; ctx.beginPath(); ctx.arc(X+20,Y+8,5,0,6.3); ctx.fill();
} else {
ctx.fillStyle='#5a6172'; ctx.fillRect(X+6,Y+4,20,24);
ctx.fillStyle='#6d7587'; ctx.fillRect(X+6,Y+4,20,7);
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.fillRect(X+6,Y+22,20,6);
ctx.fillStyle='rgba(255,255,255,0.10)'; ctx.fillRect(X+9,Y+6,3,20);
}
} else if(v===T.CRATE){
ctx.fillStyle=reg.floor2; ctx.fillRect(X,Y,TILE,TILE);
ctx.fillStyle=reg.accent; ctx.fillRect(X+3,Y+3,26,26);
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.fillRect(X+3,Y+23,26,6);
ctx.fillStyle='rgba(255,255,255,0.10)'; ctx.fillRect(X+3,Y+3,26,4);
ctx.strokeStyle='rgba(0,0,0,0.35)'; ctx.lineWidth=2; ctx.strokeRect(X+4,Y+4,24,24);
} else if(v===T.ICE){
ctx.fillStyle=reg.floor2; ctx.fillRect(X,Y,TILE,TILE);
ctx.fillStyle='#9fc4e8'; ctx.beginPath();
ctx.moveTo(X+16,Y+2); ctx.lineTo(X+29,Y+18); ctx.lineTo(X+16,Y+30); ctx.lineTo(X+3,Y+18); ctx.closePath(); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.5)'; ctx.beginPath();
ctx.moveTo(X+16,Y+2); ctx.lineTo(X+22,Y+18); ctx.lineTo(X+16,Y+30); ctx.closePath(); ctx.fill();
ctx.fillStyle='rgba(120,200,255,0.3)'; ctx.fillRect(X+4,Y+28,24,3);
} else if(v===T.ASH){
const t=performance.now()/700+tx2+ty;
ctx.fillStyle='#2a120e'; ctx.fillRect(X,Y,TILE,TILE);
ctx.fillStyle='rgba(255,90,30,'+(0.35+0.25*Math.sin(t))+')'; ctx.fillRect(X+2,Y+2,28,28);
ctx.fillStyle='rgba(255,200,80,'+(0.5+0.35*Math.sin(t*1.7))+')'; ctx.fillRect(X+7,Y+11,18,10);
ctx.fillStyle='#ffd77a'; ctx.fillRect(X+12,Y+14,7,4);
if(Math.random()<0.012) addPart({x:X+rnd(0,TILE),y:Y+rnd(0,TILE),vx:rnd(-10,10),vy:rnd(-40,-15),life:1.1,max:1.1,size:rnd(2,5),col:'#ff9b3d',kind:'flame',grav:-12});
} else if(v===T.VENT){
ctx.fillStyle=reg.floor; ctx.fillRect(X,Y,TILE,TILE);
ctx.fillStyle='rgba(60,40,70,0.85)'; ctx.fillRect(X+4,Y+24,24,8);
for(let k=0;k<5;k++){
const fx=performance.now()/90+k*7+tx2*3;
const hh=18+Math.sin(fx)*7;
ctx.fillStyle = k%2? 'rgba(255,140,60,0.85)':'rgba(255,210,110,0.85)';
ctx.fillRect(X+6+k*5, Y+26-hh, 5, hh);
}
if(Math.random()<0.25) addPart({x:X+16+rnd(-9,9),y:Y+18,vx:rnd(-14,14),vy:rnd(-90,-30),life:0.8,max:0.8,size:rnd(2,5),col:'#ffb154',kind:'flame',grav:-30});
} else if(v===T.GATE){
const open=gates.some(g=>g.open && X>=g.rx-1 && X<g.rx+g.rw && Y>=g.ry-1 && Y<g.ry+g.rh);
ctx.fillStyle=reg.floor; ctx.fillRect(X,Y,TILE,TILE);
ctx.fillStyle='#2b2438'; ctx.fillRect(X,Y,TILE,TILE);
ctx.fillStyle='#463a5e'; ctx.fillRect(X+2,Y+2,28,28);
ctx.fillStyle='rgba(0,0,0,0.35)'; ctx.fillRect(X+2,Y+2,28,5); ctx.fillRect(X+2,Y+25,28,5);
const g=Math.sin(performance.now()/500+tx2)*0.5+0.5;
ctx.strokeStyle='rgba(150,110,255,'+(0.45+0.5*g)+')'; ctx.lineWidth=3;
ctx.beginPath(); ctx.moveTo(X+16,Y+4); ctx.lineTo(X+16,Y+28); ctx.stroke();
ctx.fillStyle='rgba(190,150,255,'+(0.5+0.4*g)+')'; ctx.fillRect(X+13,Y+13,7,7);
} else if(v===T.BRZ_OFF || v===T.BRZ_ON){
ctx.fillStyle=reg.floor; ctx.fillRect(X,Y,TILE,TILE);
ctx.fillStyle='#4a4256'; ctx.fillRect(X+8,Y+16,16,14);
ctx.fillStyle='#5d5468'; ctx.fillRect(X+6,Y+12,20,6);
if(v===T.BRZ_ON){ const g=Math.sin(performance.now()/110)*0.3+0.7;
ctx.fillStyle='rgba(255,160,60,'+g+')'; ctx.beginPath(); ctx.arc(X+16,Y+8,8*g,0,6.3); ctx.fill();
ctx.fillStyle='#ffe08a'; ctx.beginPath(); ctx.arc(X+16,Y+7,4*g,0,6.3); ctx.fill(); }
}
}
}
}
/* ----------- props ----------- */
function drawShrine(s){
const t=performance.now()/900;
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(s.x,s.y+12,18,7,0,0,6.3); ctx.fill();
ctx.fillStyle='#3b3550'; ctx.fillRect(s.x-13,s.y+4,26,9);
ctx.fillStyle='#4c4568'; ctx.fillRect(s.x-11,s.y-14,22,19);
ctx.fillStyle='#5d5480'; ctx.fillRect(s.x-11,s.y-14,22,5);
const g=0.6+0.4*Math.sin(t*3);
ctx.fillStyle='rgba(255,230,150,'+(0.35+0.3*g)+')'; ctx.beginPath(); ctx.arc(s.x,s.y-6,14*g,0,6.3); ctx.fill();
ctx.fillStyle='#bfe9ff'; ctx.beginPath(); ctx.moveTo(s.x,s.y-26-Math.sin(t*2)*2); ctx.lineTo(s.x+7,s.y-10);
ctx.lineTo(s.x,s.y-2); ctx.lineTo(s.x-7,s.y-10); ctx.closePath(); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.75)'; ctx.beginPath(); ctx.moveTo(s.x,s.y-26); ctx.lineTo(s.x+3,s.y-10); ctx.lineTo(s.x,s.y-2); ctx.closePath(); ctx.fill();
if(Math.random()<0.12) addPart({x:s.x+rnd(-12,12),y:s.y+rnd(-14,6),vx:rnd(-6,6),vy:rnd(-24,-8),life:1.3,max:1.3,size:rnd(1.5,3),col:'#bfe9ff',kind:'dot',grav:-6});
}
function drawChest(c){
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(c.x,c.y+11,14,5,0,0,6.3); ctx.fill();
ctx.fillStyle=c.opened?'#4a3a26':'#7a5a2e'; ctx.fillRect(c.x-13,c.y-6,26,17);
ctx.fillStyle=c.opened?'#6b512f':'#a37a3f'; ctx.fillRect(c.x-13,c.y-6,26,5);
ctx.fillStyle=c.opened?'#3a2c1c':'#5c4423'; ctx.fillRect(c.x-13,c.y+8,26,4);
ctx.fillStyle='#e8c26a'; ctx.fillRect(c.x-3,c.y+1,6,6);
if(!c.opened&&Math.random()<0.06) addPart({x:c.x+rnd(-10,10),y:c.y-6,vx:0,vy:-20,life:0.9,max:0.9,size:2,col:'#ffe9a8',kind:'dot',grav:-4});
if(c.opened){ ctx.fillStyle='rgba(0,0,0,0.35)'; ctx.fillRect(c.x-11,c.y-4,22,5); }
}
function drawSign(s){
ctx.fillStyle='rgba(0,0,0,0.28)'; ctx.beginPath(); ctx.ellipse(s.x,s.y+11,11,4,0,0,6.3); ctx.fill();
ctx.fillStyle='#4a3826'; ctx.fillRect(s.x-2,s.y-4,5,16);
ctx.fillStyle=s.isMural?'#6b6f86':'#7a5f38'; ctx.fillRect(s.x-12,s.y-15,25,14);
ctx.fillStyle=s.isMural?'#8d92a8':'#96794a'; ctx.fillRect(s.x-12,s.y-15,25,4);
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.fillRect(s.x-8,s.y-10,17,2); ctx.fillRect(s.x-8,s.y-6,12,2);
}
function drawPlate(p){
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.fillRect(p.tx*TILE+3,p.ty*TILE+3,26,26);
ctx.fillStyle=p.pressed?'#e8c26a':'#4e4a5e'; ctx.fillRect(p.tx*TILE+5,p.ty*TILE+5,22,22);
ctx.fillStyle=p.pressed?'#ffe9a8':'#6f6880'; ctx.fillRect(p.tx*TILE+8,p.ty*TILE+8,16,16);
ctx.fillStyle=p.pressed?'#fff3c0':'#3d394c'; ctx.fillRect(p.tx*TILE+11,p.ty*TILE+11,10,10);
if(p.pressed){ ctx.fillStyle='rgba(255,233,168,0.35)'; ctx.fillRect(p.tx*TILE+2,p.ty*TILE+2,28,28); }
}
function drawBlock(b){
const X=b.x-13,Y=b.y-13;
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(b.x,b.y+14,14,5,0,0,6.3); ctx.fill();
ctx.fillStyle='#6b6474'; ctx.fillRect(X,Y,26,26);
ctx.fillStyle='#888fa0'; ctx.fillRect(X,Y,26,6);
ctx.fillStyle='#4e4856'; ctx.fillRect(X,Y+20,26,6);
ctx.strokeStyle='rgba(0,0,0,0.35)'; ctx.lineWidth=2; ctx.strokeRect(X+1,Y+1,24,24);
ctx.fillStyle='rgba(255,255,255,0.10)'; ctx.fillRect(X+4,Y+9,4,12);
}
function drawNpc(n){
n.anim+=0.016;
const bob=Math.sin(n.anim*2)*1.2;
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(n.x,n.y+12,12,4,0,0,6.3); ctx.fill();
if(n.kind==='shop'){
ctx.fillStyle='#8a6a2e'; ctx.fillRect(n.x+12,n.y-6,16,18);
ctx.fillStyle='#b58a3f'; ctx.fillRect(n.x+12,n.y-6,16,4);
ctx.fillStyle='#e8c26a'; ctx.beginPath(); ctx.arc(n.x+20,n.y+2,3,0,6.3); ctx.fill();
ctx.fillStyle='#3f3556'; ctx.fillRect(n.x-9,n.y-6+bob,18,20);
ctx.fillStyle='#4f4470'; ctx.fillRect(n.x-10,n.y-10+bob,20,7);
ctx.fillStyle='#8a7a5a'; ctx.fillRect(n.x-5,n.y-16+bob,10,7);
ctx.fillStyle='#14101c'; ctx.fillRect(n.x-4,n.y-14+bob,8,3);
ctx.fillStyle='#ffd76a'; ctx.fillRect(n.x-8,n.y+4,16,2);
} else {
ctx.fillStyle='#4a3f6b'; ctx.fillRect(n.x-9,n.y-6+bob,18,20);
ctx.fillStyle='#d8cbb0'; ctx.fillRect(n.x-6,n.y-17+bob,12,12);
ctx.fillStyle='#e8e2d0'; ctx.fillRect(n.x-8,n.y-19+bob,16,5);
ctx.fillStyle='#14101c'; ctx.fillRect(n.x-4,n.y-12+bob,2,3); ctx.fillRect(n.x+2,n.y-12+bob,2,3);
ctx.fillStyle='#8d7ea8'; ctx.fillRect(n.x-9,n.y-2+bob,18,3);
}
}
function drawObelisk(o){
const t=performance.now()/600;
ctx.fillStyle='rgba(0,0,0,0.32)'; ctx.beginPath(); ctx.ellipse(o.x,o.y+12,13,5,0,0,6.3); ctx.fill();
ctx.fillStyle='#4a3a44'; ctx.fillRect(o.x-8,o.y-24,16,34);
ctx.fillStyle='#5f4a56'; ctx.fillRect(o.x-8,o.y-24,16,6);
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.fillRect(o.x+3,o.y-20,5,28);
if(o.lit){
const g=0.6+0.4*Math.sin(t*3+o.tx);
ctx.fillStyle='rgba(255,150,60,'+(0.3*g)+')'; ctx.beginPath(); ctx.arc(o.x,o.y-2,17,0,6.3); ctx.fill();
ctx.fillStyle='#ff8a3d'; ctx.fillRect(o.x-4,o.y-16,8,16);
ctx.fillStyle='#ffd76a'; ctx.fillRect(o.x-2,o.y-14,4,12);
if(Math.random()<0.2) addPart({x:o.x+rnd(-4,4),y:o.y-10,vx:rnd(-8,8),vy:rnd(-40,-15),life:0.7,max:0.7,size:2.5,col:'#ffb154',kind:'flame',grav:-14});
} else {
ctx.fillStyle='#7a6a78'; ctx.fillRect(o.x-4,o.y-16,8,16);
ctx.fillStyle='#a08fa0'; ctx.fillRect(o.x-2,o.y-14,4,12);
}
}
function drawRune(r){
const t=performance.now()/500;
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(r.x,r.y+12,12,4,0,0,6.3); ctx.fill();
ctx.fillStyle='#3a4456'; ctx.fillRect(r.x-9,r.y-18,18,30);
ctx.fillStyle='#4d5876'; ctx.fillRect(r.x-9,r.y-18,18,5);
const on=r.lit||r.flash>0;
const g=on? 0.7+0.3*Math.sin(t*4+r.i) : 0.25;
ctx.fillStyle=on? 'rgba(155,224,255,'+(0.35+0.4*g)+')' : 'rgba(90,110,140,0.5)';
ctx.fillRect(r.x-6,r.y-12,12,18);
ctx.fillStyle=on? '#e8fbff':'#7f8ba3';
ctx.font='bold 11px monospace'; ctx.textAlign='center';
ctx.fillText(['I','II','III','IV','V'][r.i], r.x, r.y+3);
if(on&&Math.random()<0.14) addPart({x:r.x+rnd(-6,6),y:r.y-8,vx:0,vy:-22,life:0.8,max:0.8,size:2,col:'#bfefff',kind:'dot',grav:-6});
}
function drawGate(g){
if(g.open) return;
const pulse=0.5+0.5*Math.sin(performance.now()/600);
const cx=g.rx+g.rw/2, cy=g.ry+g.rh/2;
ctx.save(); ctx.globalAlpha=0.25+pulse*0.2;
ctx.fillStyle='#9a6bff';
ctx.fillRect(g.rx-4,g.ry-4,g.rw+8,g.rh+8); ctx.restore();
ctx.strokeStyle='rgba(200,170,255,'+(0.5+pulse*0.4)+')'; ctx.lineWidth=2;
ctx.strokeRect(g.rx-3,g.ry-3,g.rw+6,g.rh+6);
ctx.fillStyle='rgba(255,255,255,0.9)'; ctx.font='9px monospace'; ctx.textAlign='center';
ctx.save(); ctx.translate(cx,cy); ctx.rotate(g.rh>g.rw? -Math.PI/2:0);
ctx.fillText(g.label, 0, 3); ctx.restore();
}
/* ----------- player ----------- */
function drawPlayer(){
const p=player;
const hurt = p.invul>0 && Math.floor(p.invul*16)%2===0;
if(hurt && !p.flash) return;
const tunic = S.weapon==='ember' ? '#a8401e' : S.weapon==='frost' ? '#2f6b93' : '#3f5bb5';
const tunicL = S.weapon==='ember' ? '#d1612b' : S.weapon==='frost' ? '#4f9cc4' : '#5b7ae0';
const moving = Math.hypot(p.vx,p.vy)>24 && !p.dash;
const bob = Math.sin(p.walkAnim*3)*1.6;
const legA = Math.sin(p.walkAnim*3)*5;
const faceX = p.face.x>=0?1:-1;
const backH = p.face.y<-0.4;
ctx.save();
ctx.translate(p.x, p.y);
// shadow
ctx.fillStyle='rgba(0,0,0,0.32)'; ctx.beginPath(); ctx.ellipse(0,11,10,4,0,0,6.3); ctx.fill();
// dash streaks
if(p.dash>0){ ctx.strokeStyle='rgba(180,220,255,0.55)'; ctx.lineWidth=2;
for(let i=0;i<3;i++){ ctx.beginPath(); ctx.moveTo(-faceX*(6+i*8),-4+i*4); ctx.lineTo(-faceX*(18+i*10),-4+i*4); ctx.stroke(); } }
ctx.scale(faceX,1);
if(p.hurtT>0 && Math.floor(p.hurtT*30)%2===0){ ctx.globalAlpha=1; }
// legs
ctx.fillStyle='#2c2a44';
ctx.fillRect(-6, 4+(moving? legA*0.4:0), 5, 9);
ctx.fillRect(1, 4-(moving? legA*0.4:0), 5, 9);
// boots tint if owned
if(S.owned.boots){ ctx.fillStyle='#c9a24a'; ctx.fillRect(-6, 11+(moving? legA*0.4:0), 5, 3); ctx.fillRect(1, 11-(moving? legA*0.4:0), 5, 3); }
// body
ctx.fillStyle=tunic; ctx.fillRect(-8, -4+bob*0.4, 16, 12);
ctx.fillStyle=tunicL; ctx.fillRect(-8, -4+bob*0.4, 16, 4);
// belt
ctx.fillStyle='#8a6a2e'; ctx.fillRect(-8, 4+bob*0.4, 16, 3);
ctx.fillStyle='#e8c26a'; ctx.fillRect(-2, 4+bob*0.4, 4, 3);
// head
const hy=-15+bob*0.5;
ctx.fillStyle='#e8c09a'; ctx.fillRect(-6, hy, 12, 11);
ctx.fillStyle='#3a2a1c'; ctx.fillRect(-7, hy-3, 14, 6);
ctx.fillRect(-7, hy+1, 3, 7);
if(!backH){ ctx.fillStyle='#14101c'; ctx.fillRect(1, hy+3, 2, 3); ctx.fillRect(5, hy+3, 2, 3);
ctx.fillStyle='rgba(255,150,150,0.35)'; ctx.fillRect(1, hy+5, 6, 2); }
else { ctx.fillStyle='#3a2a1c'; ctx.fillRect(-6,hy+2,12,6); }
// accessory hints
if(S.owned.charm){ ctx.fillStyle='#c9c9d6'; ctx.fillRect(2,-3+bob*0.4,5,5); }
if(S.owned.greed){ ctx.fillStyle='#ffd76a'; ctx.beginPath(); ctx.arc(-4,-1+bob*0.4,3,0,6.3); ctx.fill(); }
if(S.owned.pendant){ ctx.fillStyle='#9be0ff'; ctx.fillRect(-1,-2+bob*0.4,3,3); }
// ---- arms, weapon & slash ----
const wp=WEAPONS[S.weapon];
const atk = p.atk;
const k = atk ? clamp(1-atk.t/atk.dur,0,1) : 0;
const la = faceX>0 ? p.aim : Math.PI-p.aim; // aim angle in mirrored local space
const swing = atk ? lerp(-1.15, 0.95, Math.min(1,k*1.35)) : -0.35;
const wl = S.weapon==='frost'? 24 : S.weapon==='ember'? 20 : 16;
// off-hand (back arm)
ctx.fillStyle='#d8b18c'; ctx.fillRect(-10, -1+bob*0.4, 5, 9);
if(atk){
const sweep=lerp(-0.75,0.75,k);
const grad=ctx.createRadialGradient(0,-2,5,0,-2,wp.range);
grad.addColorStop(0,'rgba(255,255,255,0)');
grad.addColorStop(0.55, wp.elem==='fire'?'rgba(255,150,60,0.6)': wp.elem==='ice'?'rgba(160,230,255,0.6)':'rgba(235,242,255,0.55)');
grad.addColorStop(1,'rgba(255,255,255,0)');
ctx.save(); ctx.translate(0,-2); ctx.fillStyle=grad;
ctx.beginPath(); ctx.moveTo(0,0);
ctx.arc(0,0, wp.range*(0.78+0.22*k), la-wp.arc/2+sweep, la+wp.arc/2+sweep);
ctx.closePath(); ctx.fill(); ctx.restore();
}
ctx.save();
ctx.translate(0,-2+bob*0.4);
ctx.rotate(la+Math.PI/2+swing);
ctx.fillStyle='#e8c09a'; ctx.fillRect(-2.5,-2,5,10); // sword arm, follows the aim
ctx.translate(0,-5);
drawWeaponArt(wp, wl);
ctx.restore();
// bow on back
if(S.bowOwned){ ctx.strokeStyle='#8a6a2e'; ctx.lineWidth=2.5; ctx.beginPath();
ctx.arc(-8,-4+bob*0.4, 11, -1.2, 1.2); ctx.stroke();
ctx.strokeStyle='rgba(200,230,255,0.7)'; ctx.lineWidth=1; ctx.beginPath();
ctx.moveTo(-8+Math.cos(-1.2)*11, -4+bob*0.4+Math.sin(-1.2)*11); ctx.lineTo(-8+Math.cos(1.2)*11, -4+bob*0.4+Math.sin(1.2)*11); ctx.stroke(); }
ctx.restore();
// element trail particles
if(S.weapon!=='rusty' && moving && Math.random()<0.35){
addPart({x:p.x+rnd(-6,6),y:p.y+8,vx:rnd(-14,14),vy:rnd(-10,4),life:0.5,max:0.5,size:rnd(2,4),
col:S.weapon==='ember'?'#ff9b3d':'#bfefff',kind: S.weapon==='ember'?'flame':'dot',grav:-6});
}
// hurt flash
if(p.hurtT>0){ ctx.save(); ctx.globalAlpha=p.hurtT*0.9; ctx.fillStyle='#ffffff';
ctx.fillRect(p.x-9,p.y-20,18,32); ctx.restore(); p.hurtT-=0.016; }
// stun stars when stamina empty
}
function drawWeaponArt(wp, wl){
// grip
ctx.fillStyle='#5c4423'; ctx.fillRect(-2, -3, 4, 7);
ctx.fillStyle='#e8c26a'; ctx.fillRect(-4, -5, 8, 3);
// blade
ctx.fillStyle=wp.col; ctx.beginPath();
ctx.moveTo(-2, -4); ctx.lineTo(2,-4); ctx.lineTo(2, -4-wl); ctx.lineTo(0,-8-wl); ctx.lineTo(-2,-4-wl); ctx.closePath(); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.55)'; ctx.fillRect(-1.4, -4-wl+2, 1, wl-4);
if(wp.elem==='fire'){ ctx.fillStyle='rgba(255,140,50,0.55)'; ctx.beginPath(); ctx.arc(0,-6-wl,5,0,6.3); ctx.fill();
if(Math.random()<0.5) addPart({x:player.x+rnd(-6,6),y:player.y-16+rnd(-6,6),vx:rnd(-10,10),vy:rnd(-30,-8),life:0.5,max:0.5,size:2.5,col:'#ffb154',kind:'flame',grav:-10}); }
if(wp.elem==='ice'){ ctx.fillStyle='rgba(160,235,255,0.5)'; ctx.beginPath(); ctx.arc(0,-6-wl,5,0,6.3); ctx.fill();
if(Math.random()<0.3) addPart({x:player.x+rnd(-8,8),y:player.y-14+rnd(-6,6),vx:rnd(-8,8),vy:rnd(-16,4),life:0.6,max:0.6,size:2,col:'#bfefff',kind:'dot',grav:8}); }
}
/* ----------- enemies ----------- */
function enemyTint(e, col){
if(e.hitFlash>0) return '#ffffff';
if(e.freeze>0) return '#bfefff';
if(e.burn>0) return '#ffb154';
return col;
}
function drawEnemy(e, dt){
const t=e.anim;
ctx.save(); ctx.translate(e.x,e.y);
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(0,e.r*0.85,e.r*0.9,e.r*0.35,0,0,6.3); ctx.fill();
const look=e.def.look;
if(look==='slime'){
const sq=Math.sin(t*4)*0.14;
ctx.fillStyle=enemyTint(e,'#4fb84a');
ctx.beginPath(); ctx.ellipse(0, 2, e.r*(1+sq), e.r*(1-sq), 0,0,6.3); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.35)'; ctx.beginPath(); ctx.ellipse(-4,-3,3.4,2.4,-0.5,0,6.3); ctx.fill();
ctx.fillStyle='#122'; ctx.fillRect(-5,0,2.6,3); ctx.fillRect(3,0,2.6,3);
} else if(look==='bat'){
const flap=Math.sin(t*15);
ctx.fillStyle=enemyTint(e,'#8b5fd6');
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(-14, -4+flap*5); ctx.lineTo(-6,3); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(14, -4+flap*5); ctx.lineTo(6,3); ctx.closePath(); ctx.fill();
ctx.fillStyle=enemyTint(e,'#a97bf0'); ctx.beginPath(); ctx.arc(0,0,6.5,0,6.3); ctx.fill();
ctx.fillStyle='#ff5a5a'; ctx.fillRect(-3,-1,2,2); ctx.fillRect(1,-1,2,2);
} else if(look==='wolf'){
const wind = e.state==='wind';
const c = wind ? '#ffffff' : enemyTint(e,'#8b8f9e');
ctx.fillStyle=c; ctx.fillRect(-13,-2,26,11);
ctx.fillRect(-6,-9,15,9);
ctx.beginPath(); ctx.moveTo(-6,-9); ctx.lineTo(-10,-16); ctx.lineTo(-3,-11); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(8,-9); ctx.lineTo(12,-16); ctx.lineTo(6,-11); ctx.closePath(); ctx.fill();
ctx.fillStyle='#6d7180'; ctx.fillRect(-12,7,5,8); ctx.fillRect(6,7,5,8);
ctx.fillRect(-9,7,4,7); ctx.fillRect(1,7,4,7);
ctx.fillStyle='#ffd34d'; ctx.fillRect(1,-7,3,3); ctx.fillRect(6,-7,3,3);
ctx.fillStyle='#2a2a38'; ctx.fillRect(9,-4,6,4);
if(wind){ ctx.strokeStyle='rgba(255,80,80,0.8)'; ctx.lineWidth=2; ctx.beginPath(); ctx.arc(0,0,17,0,6.3); ctx.stroke(); }
} else if(look==='bone'){
ctx.fillStyle=enemyTint(e,'#e6e2d0');
ctx.fillRect(-6,-16,12,11);
ctx.fillStyle='#0d0b12'; ctx.fillRect(-4,-12,3,3); ctx.fillRect(2,-12,3,3);
ctx.fillRect(-2,-8,5,3);
ctx.fillStyle=enemyTint(e,'#d8d3bd');
ctx.fillRect(-6,-5,12,13);
ctx.fillStyle='#14101c'; for(let i=0;i<3;i++) ctx.fillRect(-5,-4+i*4,10,2);
ctx.fillStyle='#c9c3ad'; ctx.fillRect(-11,-4,4,12); ctx.fillRect(7,-4,4,12);
if(e.state==='shoot'){ ctx.fillStyle='#fff'; ctx.fillRect(-13,-14,3,9); }
} else if(look==='magma'){
const sq=Math.sin(t*3)*0.12;
ctx.fillStyle='#3a1a12'; ctx.beginPath(); ctx.ellipse(0,2,e.r*(1+sq),e.r*(1-sq),0,0,6.3); ctx.fill();
ctx.fillStyle=enemyTint(e,'#ff6a2b'); ctx.beginPath(); ctx.ellipse(0,3,e.r*0.72,e.r*0.66,0,0,6.3); ctx.fill();
ctx.fillStyle='#ffd76a'; ctx.beginPath(); ctx.ellipse(-2,4,3,2.4,0,0,6.3); ctx.fill();
ctx.fillStyle='#14101c'; ctx.fillRect(-6,-1,3,3); ctx.fillRect(3,-1,3,3);
if(Math.random()<0.3) addPart({x:e.x+rnd(-8,8),y:e.y-e.r+rnd(-2,2),vx:rnd(-8,8),vy:rnd(-40,-12),life:0.6,max:0.6,size:rnd(2,4),col:'#ff9b3d',kind:'flame',grav:-14});
} else if(look==='wraith'){
const float=Math.sin(t*2.4)*4;
ctx.globalAlpha=0.9;
ctx.fillStyle=enemyTint(e,'#8fd8f0');
ctx.beginPath(); ctx.moveTo(-9,-4+float); ctx.lineTo(9,-4+float); ctx.lineTo(7,9+float);
ctx.lineTo(2,4+float); ctx.lineTo(-2,11+float); ctx.lineTo(-7,5+float); ctx.closePath(); ctx.fill();
ctx.fillStyle=enemyTint(e,'#d8f6ff'); ctx.beginPath(); ctx.arc(0,-8+float,8,Math.PI,0); ctx.fill();
ctx.fillStyle='#0a2a38'; ctx.beginPath(); ctx.arc(0,-7+float,6.4,Math.PI,0); ctx.fill();
ctx.fillStyle='#9beeff'; ctx.fillRect(-4,-9+float,3,3); ctx.fillRect(1,-9+float,3,3);
ctx.globalAlpha=1;
} else if(look==='guardian'){
const g=Math.sin(t*2)*1.5;
ctx.fillStyle=enemyTint(e,'#4a6a94'); ctx.fillRect(-16,-10+g,32,28);
ctx.fillStyle=enemyTint(e,'#6d94c4'); ctx.fillRect(-16,-10+g,32,7);
ctx.fillStyle='#2c3e5c'; ctx.fillRect(-16,12+g,32,6);
ctx.fillStyle=enemyTint(e,'#7fb6e0'); ctx.beginPath(); ctx.arc(0,-14+g,11,0,6.3); ctx.fill();
ctx.fillStyle='#0d1b2a'; ctx.fillRect(-8,-18+g,16,7);
ctx.fillStyle='#9beeff'; ctx.fillRect(-6,-17+g,5,3); ctx.fillRect(1,-17+g,5,3);
ctx.fillStyle='#bfe9ff'; ctx.fillRect(-20,-24+g,6,30);
ctx.beginPath(); ctx.moveTo(-17,-24+g); ctx.lineTo(-14,-34+g); ctx.lineTo(-11,-24+g); ctx.closePath(); ctx.fill();
if(e.state==='wind'){ ctx.strokeStyle='rgba(159,220,255,0.8)'; ctx.lineWidth=3; ctx.beginPath(); ctx.arc(0,0,26+Math.sin(t*20)*3,0,6.3); ctx.stroke(); }
} else if(look==='golem'){
const g=Math.sin(t*1.6)*1.8;
ctx.fillStyle=enemyTint(e,'#6b5a3c'); ctx.fillRect(-24,-14+g,48,40);
ctx.fillStyle=enemyTint(e,'#8a7550'); ctx.fillRect(-24,-14+g,48,10);
ctx.fillStyle='#3a3024'; ctx.fillRect(-24,18+g,48,8);
ctx.fillStyle='#c9b48a'; ctx.fillRect(-8,16,16,10);
ctx.fillStyle=enemyTint(e,'#8a7550'); ctx.beginPath(); ctx.arc(0,-22+g,16,0,6.3); ctx.fill();
ctx.fillStyle='#2a2016'; ctx.fillRect(-11,-27+g,22,9);
ctx.fillStyle='#ffb154'; ctx.fillRect(-8,-25+g,6,4); ctx.fillRect(2,-25+g,6,4);
ctx.fillStyle='#7a6a48'; ctx.fillRect(-34,-18+g,10,30); ctx.fillRect(24,-18+g,10,30);
if(e.phase===2){ ctx.strokeStyle='rgba(255,120,40,'+(0.4+0.3*Math.sin(t*8))+')'; ctx.lineWidth=3; ctx.strokeRect(-25,-15+g,50,42); }
} else if(look==='ashen'){
const g=Math.sin(t*1.8)*2.4;
ctx.fillStyle='rgba(255,90,30,0.28)'; ctx.beginPath(); ctx.arc(0,0,44+g,0,6.3); ctx.fill();
ctx.fillStyle=enemyTint(e,'#4a1c1c'); ctx.fillRect(-20,-16+g,40,44);
ctx.fillStyle=enemyTint(e,'#7a2b1c'); ctx.fillRect(-20,-16+g,40,10);
ctx.fillStyle='#2a1010'; ctx.fillRect(-20,20+g,40,8);
ctx.fillStyle=enemyTint(e,'#5c2418'); ctx.beginPath(); ctx.arc(0,-24+g,15,0,6.3); ctx.fill();
ctx.fillStyle='#ffb154'; ctx.fillRect(-9,-27+g,7,4); ctx.fillRect(2,-27+g,7,4);
ctx.beginPath(); ctx.moveTo(-15,-34+g); ctx.lineTo(-9,-52+g); ctx.lineTo(-3,-34+g); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(3,-34+g); ctx.lineTo(9,-52+g); ctx.lineTo(15,-34+g); ctx.closePath(); ctx.fill();
ctx.fillStyle='#ffd76a'; ctx.fillRect(-14,-40+g,28,4);
if(Math.random()<0.5) addPart({x:e.x+rnd(-24,24),y:e.y+rnd(-24,20),vx:rnd(-14,14),vy:rnd(-50,-14),life:0.8,max:0.8,size:rnd(2,5),col:'#ff9b3d',kind:'flame',grav:-16});
}
ctx.restore();
if(e.hitFlash>0){ ctx.save(); ctx.globalAlpha=Math.min(1,e.hitFlash*4); ctx.fillStyle='#fff';
ctx.beginPath(); ctx.arc(e.x,e.y,e.r*1.05,0,6.3); ctx.fill(); ctx.restore(); }
// small hp bar for non-boss damaged foes
if(!e.boss && e.hp<e.max){
const w=e.r*2, x=e.x-w/2, y=e.y-e.r-9;
ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(x-1,y-1,w+2,5);
ctx.fillStyle='#ff5a4a'; ctx.fillRect(x,y,w*(e.hp/e.max),3);
}
}
function drawShots(){
for(const s of shots){
if(s.kind==='arrow'){
const a=Math.atan2(s.vy,s.vx);
ctx.save(); ctx.translate(s.x,s.y); ctx.rotate(a);
ctx.strokeStyle='#8a6a2e'; ctx.lineWidth=2.5; ctx.beginPath(); ctx.moveTo(-12,0); ctx.lineTo(6,0); ctx.stroke();
ctx.fillStyle='#bfefff'; ctx.beginPath(); ctx.moveTo(12,0); ctx.lineTo(4,-4); ctx.lineTo(4,4); ctx.closePath(); ctx.fill();
ctx.strokeStyle='rgba(180,240,255,0.5)'; ctx.lineWidth=1; ctx.beginPath(); ctx.moveTo(-14,0); ctx.lineTo(-20,0); ctx.stroke();
ctx.restore();
} else if(s.kind==='bone'){
ctx.save(); ctx.translate(s.x,s.y); ctx.rotate(performance.now()/90);
ctx.fillStyle='#e6e2d0'; ctx.fillRect(-7,-2,14,4); ctx.beginPath(); ctx.arc(-7,0,3,0,6.3); ctx.arc(7,0,3,0,6.3); ctx.fill();
ctx.restore();
} else if(s.kind==='fire'){
ctx.fillStyle='rgba(255,120,40,0.5)'; ctx.beginPath(); ctx.arc(s.x,s.y,9,0,6.3); ctx.fill();
ctx.fillStyle='#ffd76a'; ctx.beginPath(); ctx.arc(s.x,s.y,5,0,6.3); ctx.fill();
} else if(s.kind==='ice'){
ctx.fillStyle='rgba(160,230,255,0.7)'; ctx.beginPath(); ctx.arc(s.x,s.y,8,0,6.3); ctx.fill();
ctx.fillStyle='#e8fbff'; ctx.beginPath(); ctx.arc(s.x,s.y,4,0,6.3); ctx.fill();
}
}
}
function drawDrops(){
const bob=Math.sin(performance.now()/220)*1.6;
for(const d of drops){
if(d.type==='coin'){
const w=Math.abs(Math.cos(performance.now()/230+d.x*0.05));
ctx.fillStyle='rgba(0,0,0,0.28)'; ctx.beginPath(); ctx.ellipse(d.x,d.y+7,6,2.4,0,0,6.3); ctx.fill();
ctx.fillStyle='#8a6610'; ctx.beginPath(); ctx.ellipse(d.x,d.y,6.2*w+1,6.5,0,0,6.3); ctx.fill();
ctx.fillStyle='#ffd76a'; ctx.beginPath(); ctx.ellipse(d.x,d.y,4.6*w,5,0,0,6.3); ctx.fill();
ctx.fillStyle='#fff3c0'; ctx.beginPath(); ctx.ellipse(d.x-1.4*w,d.y-1.6,1.7*w,2,0,0,6.3); ctx.fill();
} else if(d.type==='heart'){
ctx.save(); ctx.translate(d.x,d.y+bob); ctx.scale(0.62,0.62);
ctx.fillStyle='#ff5a4a'; ctx.beginPath();
ctx.moveTo(0,8); ctx.bezierCurveTo(-13,-2,-9,-12,-3,-12); ctx.bezierCurveTo(-1,-12,0,-10,0,-9);
ctx.bezierCurveTo(0,-10,1,-12,3,-12); ctx.bezierCurveTo(9,-12,13,-2,0,8); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.5)'; ctx.beginPath(); ctx.arc(-4,-6,2.6,0,6.3); ctx.fill();
ctx.restore();
if(Math.random()<0.1) addPart({x:d.x+rnd(-6,6),y:d.y,vx:0,vy:-18,life:0.7,max:0.7,size:2,col:'#ff8a7a',kind:'dot',grav:-6});
} else if(d.type==='item'){
const def=ITEMS[d.item]||{icon:'?'};
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(d.x,d.y+9,8,3,0,0,6.3); ctx.fill();
ctx.fillStyle='#ffe9a8'; ctx.font='bold 18px monospace'; ctx.textAlign='center';
ctx.fillText(def.icon, d.x, d.y+6+bob*0.5);
}
}
}
function drawParts(){
for(const p of PARTS){
const k=Math.max(0,p.life/p.max);
if(p.kind==='ring'){
ctx.strokeStyle=p.col; ctx.globalAlpha=k*0.85; ctx.lineWidth=(p.w||3)*k;
ctx.beginPath(); ctx.arc(p.x,p.y,p.r*(1.3-k*0.5),0,6.3); ctx.stroke(); ctx.globalAlpha=1; continue;
}
if(p.kind==='text'){
ctx.globalAlpha=Math.min(1,k*1.6); ctx.fillStyle=p.col; ctx.font='bold '+p.size+'px monospace'; ctx.textAlign='center';
ctx.strokeStyle='rgba(0,0,0,0.85)'; ctx.lineWidth=3; ctx.strokeText(p.text,p.x,p.y); ctx.fillText(p.text,p.x,p.y);
ctx.globalAlpha=1; continue;
}
if(p.kind==='smoke'){ ctx.globalAlpha=k*0.4; ctx.fillStyle=p.col; ctx.beginPath(); ctx.arc(p.x,p.y,p.size*(2-k),0,6.3); ctx.fill(); ctx.globalAlpha=1; continue; }
if(p.kind==='flame'){
ctx.globalAlpha=k; ctx.fillStyle=p.col; ctx.beginPath(); ctx.arc(p.x,p.y,p.size*k,0,6.3); ctx.fill();
ctx.globalAlpha=k*0.5; ctx.fillStyle='#ffd76a'; ctx.beginPath(); ctx.arc(p.x,p.y,p.size*k*0.5,0,6.3); ctx.fill();
ctx.globalAlpha=1; continue;
}
ctx.globalAlpha=Math.min(1,k*1.8); ctx.fillStyle=p.col;
ctx.fillRect(p.x-p.size/2, p.y-p.size/2, p.size, p.size); ctx.globalAlpha=1;
}
}
/* ----------- minimap ----------- */
let mmBase=null, mmFog=null, mmFogCtx=null;
function buildMinimap(){
mmBase=document.createElement('canvas'); mmBase.width=MW*3; mmBase.height=MH*3;
const g=mmBase.getContext('2d');
g.fillStyle='#07060a'; g.fillRect(0,0,mmBase.width,mmBase.height);
for(const r of REGIONS){
g.fillStyle=r.floor; g.fillRect(r.x*3,r.y*3,r.w*3,r.h*3);
g.globalAlpha=0.8; g.fillStyle=r.accent; g.fillRect(r.x*3,r.y*3,r.w*3,r.h*3); g.globalAlpha=1;
}
for(let y=0;y<MH;y++) for(let x=0;x<MW;x++){
const v=tileAt(x,y);
if(gSolid(v) && v!==T.WATER){ g.fillStyle='rgba(6,4,12,0.85)'; g.fillRect(x*3,y*3,3,3); }
else if(v===T.WATER){ g.fillStyle='#1f3a55'; g.fillRect(x*3,y*3,3,3); }
}
mmFog=document.createElement('canvas'); mmFog.width=mmBase.width; mmFog.height=mmBase.height;
mmFogCtx=mmFog.getContext('2d');
mmFogCtx.fillStyle='rgba(4,3,8,0.74)'; mmFogCtx.fillRect(0,0,mmFog.width,mmFog.height);
}
function revealMinimap(){
if(!mmFogCtx) return;
const g=mmFogCtx;
g.globalCompositeOperation='destination-out';
const R=15*3;
const grd=g.createRadialGradient(player.x/ (TILE/3), player.y/(TILE/3), 0, player.x/(TILE/3), player.y/(TILE/3), R);
grd.addColorStop(0,'rgba(0,0,0,1)'); grd.addColorStop(0.65,'rgba(0,0,0,0.9)'); grd.addColorStop(1,'rgba(0,0,0,0)');
g.fillStyle=grd;
g.beginPath(); g.arc(player.x/(TILE/3), player.y/(TILE/3), R, 0, 6.3); g.fill();
g.globalCompositeOperation='source-over';
// region-wide reveal for the current region (so the map reads as a map)
const r=regionAtPx(player.x,player.y);
if(r){ g.globalCompositeOperation='destination-out'; g.fillStyle='rgba(0,0,0,0.55)';
g.fillRect(r.x*3,r.y*3,r.w*3,r.h*3); g.globalCompositeOperation='source-over'; }
}
function drawMinimap(){
const mm=document.getElementById('mm'), g=mm.getContext('2d');
g.setTransform(1,0,0,1,0,0);
g.clearRect(0,0,mm.width,mm.height);
const sx=mm.width/(MW*3), sy=mm.height/(MH*3);
g.save(); g.scale(sx,sy);
if(mmBase) g.drawImage(mmBase,0,0);
if(mmFog) g.drawImage(mmFog,0,0);
// markers
g.scale(1,1);
const dot=(x,y,col,rad)=>{ g.fillStyle=col; g.beginPath(); g.arc(x/(TILE/3), y/(TILE/3), rad, 0, 6.3); g.fill(); };
dot(player.x,player.y,'rgba(255,255,255,0.25)',10);
for(const s of shrines) dot(s.x,s.y,'#9be0ff',4.5);
for(const c of chests) if(!c.opened) dot(c.x,c.y,'#ffd76a',3.5);
for(const n of npcs) dot(n.x,n.y,'#ff9b3d',4);
for(const e of enemies) if(e.boss) dot(e.x,e.y,'#ff4b3e',6);
for(const o of obelisks) if(!o.lit) dot(o.x,o.y,'#ff8a3d',3);
g.restore();
// player arrow
g.save(); g.translate(player.x/(TILE/3)*sx, player.y/(TILE/3)*sy);
g.rotate(player.aim+Math.PI/2);
g.fillStyle='#ffffff'; g.beginPath(); g.moveTo(0,-6); g.lineTo(4.5,5); g.lineTo(-4.5,5); g.closePath(); g.fill();
g.restore();
}
/* ----------- boss bar / screen overlays ----------- */
function drawBossBar(){
if(!bossRef || bossRef.dead || !S.running) return;
const w=Math.min(520, canvas.width/DPR-80), h=13;
const x=(canvas.width/DPR-w)/2, y=18;
ctx.fillStyle='rgba(10,6,14,0.82)'; ctx.fillRect(x-4,y-4,w+8,h+8);
ctx.strokeStyle='#6b539b'; ctx.lineWidth=2; ctx.strokeRect(x-4,y-4,w+8,h+8);
const p=bossRef.hp/bossRef.max;
const grd=ctx.createLinearGradient(x,0,x+w,0); grd.addColorStop(0,'#ff4b3e'); grd.addColorStop(1,'#ff9b3d');
ctx.fillStyle=grd; ctx.fillRect(x,y,w*p,h);
ctx.fillStyle='rgba(255,255,255,0.18)'; ctx.fillRect(x,y,w*p,4);
ctx.fillStyle='#ffe9a8'; ctx.font='bold 10px monospace'; ctx.textAlign='center';
ctx.fillText(({golem:'THE RUIN GOLEM',guardian:'THE FROST WARDEN',ashen:'THE WARDEN OF ASH'})[bossRef.kind]||'BOSS', x+w/2, y+h-3);
if(bossRef.phase>1){ ctx.fillStyle='#ff5a4a'; ctx.font='bold 9px monospace'; ctx.textAlign='right';
ctx.fillText('ENRAGED', x+w, y-7); }
}
function drawScreenFx(){
const W=canvas.width/DPR, H=canvas.height/DPR;
// vignette
const g=ctx.createRadialGradient(W/2,H/2,Math.min(W,H)*0.35,W/2,H/2,Math.max(W,H)*0.72);
g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.55)');
ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
// low health pulse
if(S.hp<=2 && S.running){
ctx.fillStyle='rgba(255,40,40,'+(0.06+0.05*Math.sin(performance.now()/220))+')'; ctx.fillRect(0,0,W,H);
}
// top/bottom letterbox gradient
const g2=ctx.createLinearGradient(0,0,0,70); g2.addColorStop(0,'rgba(0,0,0,0.5)'); g2.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=g2; ctx.fillRect(0,0,W,70);
const g3=ctx.createLinearGradient(0,H,0,H-90); g3.addColorStop(0,'rgba(0,0,0,0.55)'); g3.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=g3; ctx.fillRect(0,H-90,W,90);
}
/* ----------- main render ----------- */
function render(){
const W=canvas.width/DPR, H=canvas.height/DPR;
ctx.setTransform(DPR,0,0,DPR,0,0);
ctx.fillStyle='#07060a'; ctx.fillRect(0,0,W,H);
const shx = cam.sh? rnd(-cam.sh,cam.sh):0, shy = cam.sh? rnd(-cam.sh,cam.sh):0;
ctx.setTransform(ZOOM*DPR,0,0,ZOOM*DPR, (-cam.x*ZOOM+shx)*DPR, (-cam.y*ZOOM+shy)*DPR);
ctx.imageSmoothingEnabled=false;
drawRoomTints();
drawTiles();
for(const g of gates) drawGate(g);
// y-sorted actor pass
const list=[];
for(const s of shrines) list.push([s.y,()=>drawShrine(s)]);
for(const c of chests) list.push([c.y,()=>drawChest(c)]);
for(const s of signs) list.push([s.y,()=>drawSign(s)]);
for(const p of plates) list.push([p.y,()=>drawPlate(p)]);
for(const b of blocks) list.push([b.y,()=>drawBlock(b)]);
for(const o of obelisks) list.push([o.y,()=>drawObelisk(o)]);
for(const r of runes) list.push([r.y,()=>drawRune(r)]);
for(const n of npcs) list.push([n.y,()=>drawNpc(n)]);
for(const e of enemies) if(!e.dead) list.push([e.y,()=>drawEnemy(e)]);
list.push([player.y,()=>drawPlayer()]);
list.sort((a,b)=>a[0]-b[0]);
for(const [,fn] of list) fn();
drawShots();
drawDrops();
drawParts();
// screen space
ctx.setTransform(DPR,0,0,DPR,0,0);
drawBossBar();
drawScreenFx();
// off-screen boss indicator
if(bossRef && !bossRef.dead && dist2(bossRef.x,bossRef.y,player.x,player.y) < 980*980){
const bx=(bossRef.x-cam.x)*ZOOM, by=(bossRef.y-cam.y)*ZOOM;
if(bx<0||by<0||bx>W||by>H){
const a=Math.atan2(bossRef.y-player.y, bossRef.x-player.x);
const cx=W/2, cy=H/2, R=Math.min(W,H)*0.36;
const px=cx+Math.cos(a)*R, py=cy+Math.sin(a)*R;
ctx.save(); ctx.translate(px,py); ctx.rotate(a);
ctx.fillStyle='rgba(255,75,62,0.9)'; ctx.beginPath(); ctx.moveTo(10,0); ctx.lineTo(-6,-7); ctx.lineTo(-6,7); ctx.closePath(); ctx.fill();
ctx.restore();
}
}
}
/* =========================================================================
HUD
========================================================================= */
const $ = id => document.getElementById(id);
function show(id){ $(id).classList.add('on'); }
function hide(id){ $(id).classList.remove('on'); }
const isOpen = id => $(id).classList.contains('on');
function heartSVG(cls){
return '<div class="heart '+cls+'"><svg viewBox="0 0 32 28">'
+ '<use href="#heartPath" class="hbg"></use>'
+ '<use href="#heartPath" class="hf" clip-path="url(#clipL)"></use>'
+ '<use href="#heartPath" class="hf hfR" clip-path="url(#clipR)"></use>'
+ '</svg></div>';
}
function updateHearts(animate){
const n=S.containers; let html='';
for(let i=0;i<n;i++){
const pts=clamp(S.hp-i*2,0,2);
html += heartSVG(pts===2?'full':pts===1?'half':'empty');
}
$('hearts').innerHTML=html;
if(animate){ const hd=document.querySelectorAll('.heart');
const idx=Math.max(0,Math.ceil(S.hp/2)-1);
if(hd[idx]){ hd[idx].classList.add('beat'); setTimeout(()=>hd[idx].classList.remove('beat'),520); } }
}
function updateHud(){
$('coinCount').textContent=S.coins;
const r=regionAtPx(player.x,player.y);
$('regionName').textContent = r? r.name : '—';
if(r && S.lastRegion!==r.id){
const first = (S.lastRegion===undefined || S.lastRegion===null);
S.lastRegion=r.id; SFX.setRegion(r.music, REGIONS.indexOf(r)%4);
if(!first) toast('❖ '+r.name,'item');
}
$('meleeIcon').textContent = S.weapon==='ember'?'🔥':S.weapon==='frost'?'❄️':'🗡️';
$('meleeName').textContent = WEAPONS[S.weapon].name+' · '+WEAPONS[S.weapon].dmg;
$('slotRanged').style.display = S.bowOwned?'flex':'none';
if(S.bowOwned){ $('slotRanged').querySelector('.wname').textContent = BOW.name+' · '+BOW.dmg; }
// gear box
const gb=$('gearbox'); let h='';
const quick=[['1','potion'],['2','elixir'],['3','bomb']];
for(const [k,it] of quick){
const have=(S.inv[it]||0);
const known = S.seen.includes(it) || have>0;
h += '<div class="slot '+(have>0?'':'dim')+'"><span class="key">'+k+'</span>'
+ '<span style="opacity:'+(known?1:0.25)+'">'+ITEMS[it].icon+'</span>'
+ (have>0? '<span class="cnt">'+have+'</span>':'') + '</div>';
}
for(const k in S.owned){
if(S.owned[k]) h += '<div class="slot" style="border-color:#e8c26a"><span title="'+ITEMS[k].name+'">'+ITEMS[k].icon+'</span></div>';
}
gb.innerHTML=h;
}
function updateStamina(){
$('staminaBar').style.width = (S.stamina/S.maxStamina*100)+'%';
$('staminaBar').style.background = S.stamina<S.maxStamina*0.25
? 'linear-gradient(#ff9a6a,#c04a2a)' : 'linear-gradient(#8ff08a,#3f9a3a)';
}
let toastCount=0;
function toast(msg, kind){
const box=$('toasts');
const d=document.createElement('div');
d.className='toast '+(kind||'');
d.textContent=msg;
box.appendChild(d);
if(box.children.length>4) box.removeChild(box.firstChild);
setTimeout(()=>{ d.style.transition='opacity .5s, transform .5s'; d.style.opacity=0; d.style.transform='translateY(-8px)';
setTimeout(()=>d.remove(),520); }, 2800);
}
function bossBar(e){ bossRef=e; }
function bossFocus(e){ bossRef=e; }
/* =========================================================================
INTERACTION
========================================================================= */
function nearestInteract(){
const cands=[];
const push=(obj,kind,label,dist)=>{
cands.push({obj,kind,label,dist});
};
for(const s of shrines){ const d=Math.hypot(s.x-player.x, s.y-player.y); if(d<52) push(s,'shrine','REST & SAVE at the Shrine',d); }
for(const c of chests){ if(c.opened) continue; const d=Math.hypot(c.x-player.x, c.y-player.y); if(d<48) push(c,'chest','OPEN '+c.label,d); }
for(const n of npcs){ const d=Math.hypot(n.x-player.x, n.y-player.y); if(d<54) push(n,'npc', n.kind==='shop'?'TRADE with Holloway':'SPEAK with '+n.name, d); }
for(const s of signs){ const d=Math.hypot(s.x-player.x, s.y-player.y); if(d<48) push(s,'sign', s.isMural?'READ THE MURAL':'READ THE SIGN', d); }
cands.sort((a,b)=>a.dist-b.dist);
return cands[0]||null;
}
function updatePrompt(){
const it = S.running && !S.paused ? nearestInteract() : null;
const el=$('prompt');
if(!it){ if(el.style.display!=='none') el.style.display='none'; S._lastInteract=null; return; }
el.style.display='block';
el.innerHTML='<b>[E]</b> '+it.label;
S._lastInteract=it;
}
function doInteract(){
const it=S._lastInteract || nearestInteract();
if(!it) return;
if(it.kind==='shrine'){
S.shrine=[it.obj.tx,it.obj.ty]; S.shrineName=it.obj.name;
S.hp=maxHp(); S.stamina=S.maxStamina;
SFX.savePoint(); SFX.save(); saveGame(); updateHearts(); updateHud();
pRing(it.obj.x,it.obj.y,'#9be0ff',70,{life:1.0,w:4});
for(let i=0;i<40;i++) addPart({x:it.obj.x+rnd(-16,16),y:it.obj.y+rnd(-20,10),vx:rnd(-30,30),vy:rnd(-160,-40),
life:rnd(0.6,1.4),max:1.4,size:rnd(2,4),col:'#bfe9ff',kind:'dot',grav:60});
toast('✦ PROGRESS SAVED — you respawn here. Health restored.','good');
} else if(it.kind==='chest'){ openChest(it.obj); }
else if(it.kind==='npc'){
if(it.obj.kind==='shop'){ openShop(); }
else {
if(!it.obj.said){ it.obj.said=1; grantItem('potion',1);
showDialog(it.obj.name, 'You must be the one the wind keeps talking about. Take this draught — you will need it.\n\nThe old songs say the vale needs five hearts and a blade of fire. Go east. Whispering Woods. Do not trust the quiet parts.');
} else showDialog(it.obj.name, 'East to the woods, north from there to the Sealed Gate. The Ruins hide a sleeping giant — you will know it when the ground leans.');
}
}
else if(it.kind==='sign'){
if(it.obj.isMural){
const seq=PUZ.ruins.order.map(i=>['I','II','III','IV','V'][i]).join(' → ');
showDialog('ANCIENT MURAL','The stone shows five runes struck in a sequence:\n\n '+seq+'\n\n'+(PUZ.ruins.solved?'(Already answered.)':'Strike them in this order. A wrong blow darkens them all.'));
} else showDialog('SIGNPOST', it.obj.text);
}
}
function showDialog(name,text){
$('dlgName').textContent=name; $('dlgText').textContent=text;
S.paused=true; show('dialog'); SFX.shop();
}
function openChest(c){
if(c.opened) return;
c.opened=true;
SFX.pickup(); pRing(c.x,c.y,'#ffe9a8',46,{life:0.7,w:3});
pSpark(c.x,c.y-4,18,'#ffe9a8',{smax:180,grav:-40});
for(const l of c.loot){
if(l.coins){ S.coins+=l.coins; pText(c.x,c.y-34,'+'+l.coins,'#ffd76a',12); }
if(l.item){
if(ITEMS[l.item].type==='accessory') grantItem(l.item,1);
else spawnDropItem(c.x,c.y,l.item,l.qty||1);
}
if(l.container) grantContainer();
if(l.weapon){
const w=WEAPONS[l.weapon];
pText(c.x,c.y-40,w.name.toUpperCase(),'#ffe9a8',13);
for(let i=0;i<30;i++) addPart({x:c.x,y:c.y-6,vx:rnd(-120,120),vy:rnd(-220,-60),life:rnd(0.6,1.3),max:1.3,
size:rnd(2,5),col:l.weapon==='ember'?'#ff9b3d':'#9ee7ff',kind:'dot',grav:110});
grantWeapon(l.weapon);
}
}
updateHud(); saveGame();
}
function useItem(kind){
if(!S.running||S.paused) return;
if((S.inv[kind]||0)<=0){ SFX.deny(); toast('No '+ITEMS[kind].name+' left','bad'); return; }
if(kind==='potion'){
if(S.hp>=maxHp()){ toast('Health already full','bad'); return; }
S.inv.potion--; heal(4); SFX.pickup();
pSpark(player.x,player.y-8,16,'#7bd86a',{smax:150,grav:-40});
pText(player.x,player.y-30,'+4','#7bd86a',12); updateHearts(true);
toast('Drank a Health Potion (+4)','good');
} else if(kind==='elixir'){
S.inv.elixir--; player.elixirT=10; S.stamina=S.maxStamina; SFX.upgrade();
pRing(player.x,player.y,'#ffd76a',50,{life:0.8,w:4});
for(let i=0;i<24;i++) addPart({x:player.x,y:player.y,vx:rnd(-160,160),vy:rnd(-220,-40),life:rnd(0.4,1.1),max:1.1,size:rnd(2,4),col:'#ffd76a',kind:'dot',grav:80});
toast('Elixir of Vigor — +30% speed, full stamina, 10s','good');
} else if(kind==='bomb'){
S.inv.bomb--;
bombs.push({x:player.x, y:player.y, vx:Math.cos(player.aim)*200, vy:Math.sin(player.aim)*200, t:1.05, r:112});
toast('Ash Bomb away!','item');
}
updateHud(); renderInventory();
}
let bombs=[];
function updateBombs(dt){
for(let i=bombs.length-1;i>=0;i--){
const b=bombs[i];
b.x+=b.vx*dt; b.y+=b.vy*dt; b.vx*=0.94; b.vy*=0.94;
if(gSolid(tileAt(Math.floor(b.x/TILE),Math.floor(b.y/TILE)))){ b.x-=b.vx*dt; b.y-=b.vy*dt; b.vx*=-0.4; b.vy*=-0.4; }
b.t-=dt;
if(b.t<=0){
SFX.boom(); shake(14,0.5); flashScreen(0.22);
pBurst(b.x,b.y,'#ff9b3d',34,300); pRing(b.x,b.y,'#ffd76a',b.r,{life:0.5,w:6}); pSmoke(b.x,b.y,16,'rgba(40,24,20,0.7)');
for(const e of enemies.slice()) if(dist2(b.x,b.y,e.x,e.y) < (b.r+e.r)**2) damageEnemy(e, 55, 'fire', true, b.x,b.y, 240);
bombs.splice(i,1);
}
}
}
function drawBombs(){
for(const b of bombs){
const pulse=0.5+0.5*Math.sin(performance.now()/50);
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(b.x,b.y+7,7,3,0,0,6.3); ctx.fill();
ctx.fillStyle='#2c2434'; ctx.beginPath(); ctx.arc(b.x,b.y,7,0,6.3); ctx.fill();
ctx.fillStyle='#ff5a2b'; ctx.beginPath(); ctx.arc(b.x,b.y,3+pulse*2,0,6.3); ctx.fill();
if(Math.random()<0.4) addPart({x:b.x,y:b.y-6,vx:rnd(-14,14),vy:rnd(-40,-14),life:0.5,max:0.5,size:2,col:'#ffd76a',kind:'flame',grav:-12});
}
}
/* =========================================================================
SHOP / INVENTORY / DIALOG UI
========================================================================= */
const SHOP_STOCK=[
{id:'potion', price:25}, {id:'elixir', price:35}, {id:'bomb', price:45},
{id:'charm', price:80}, {id:'greed', price:140}
];
let shopRestPrice=15;
function openShop(){
S.paused=true; show('shop'); renderShop(); SFX.shop();
}
function renderShop(){
const box=$('shopRows'); box.innerHTML='';
$('shopCoins').textContent=S.coins;
for(const st of SHOP_STOCK){
const def=ITEMS[st.id];
const owned = def.type==='accessory' && S.owned[st.id];
const row=document.createElement('div');
row.className='shoprow'+(owned?' sold':'');
row.innerHTML='<div class="icon">'+def.icon+'</div><div class="info"><div class="nm">'+def.name
+ (def.type==='accessory'?'<span class="badge">ACCESSORY</span>':'')
+ '</div><div class="ds">'+def.desc+'</div></div><div class="pr">'+(owned?'OWNED':st.price+' ¢')+'</div>';
const btn=document.createElement('button');
btn.className='mbtn'; btn.textContent= owned?'—':'BUY';
if(owned) btn.disabled=true;
btn.onclick=()=>buy(st,btn);
row.appendChild(btn); box.appendChild(row);
}
// rest option
const row=document.createElement('div'); row.className='shoprow';
row.innerHTML='<div class="icon">🔥</div><div class="info"><div class="nm">Hot Meal & Bedroll</div>'
+'<div class="ds">Restore all health. A minute off your feet.</div></div><div class="pr">'+shopRestPrice+' ¢</div>';
const b2=document.createElement('button'); b2.className='mbtn'; b2.textContent='REST';
b2.onclick=()=>{ if(S.coins<shopRestPrice){ SFX.deny(); return; }
S.coins-=shopRestPrice; S.hp=maxHp(); S.stamina=S.maxStamina; shopRestPrice=Math.round(shopRestPrice*1.6);
SFX.buy(); updateHearts(true); updateHud(); renderShop(); toast('Fully rested. You feel human again.','good'); };
row.appendChild(b2); box.appendChild(row);
}
function buy(st, btn){
if(S.coins<st.price){ SFX.deny(); toast('Not enough coins ('+st.price+' ¢ needed)','bad'); return; }
S.coins-=st.price; SFX.buy();
grantItem(st.id,1);
updateHud(); renderShop();
}
function renderInventory(){
const g=$('invGrid'); g.innerHTML='';
const keys=[];
for(const k of ['potion','elixir','bomb']) if(S.seen.includes(k)||(S.inv[k]||0)>0) keys.push(k);
for(const k of keys){
const def=ITEMS[k];
const c=document.createElement('div'); c.className='invcell';
c.innerHTML='<div class="ic">'+def.icon+'</div><div class="nm">'+def.name+'</div><div class="ds">'+def.desc+'</div>'
+'<div class="cnt">×'+(S.inv[k]||0)+'</div>';
const u=document.createElement('div'); u.className='use'; u.textContent='USE ['+itemKey(k)+']';
u.onclick=(e)=>{ e.stopPropagation(); useItem(k); };
c.appendChild(u); g.appendChild(c);
}
for(const k in S.owned){
const def=ITEMS[k];
const c=document.createElement('div'); c.className='invcell'+(S.owned[k]?' eq':'');
c.innerHTML='<div class="ic" style="opacity:'+(S.owned[k]?1:0.25)+'">'+def.icon+'</div>'
+'<div class="nm">'+(S.owned[k]?def.name:'???')+'</div>'
+'<div class="ds">'+(S.owned[k]?def.desc:'Not yet found.')+'</div>'
+(S.owned[k]?'<div class="cnt" style="color:#7bd86a">EQUIPPED</div>':'');
g.appendChild(c);
}
while(g.children.length<8){
const c=document.createElement('div'); c.className='invcell emptycell';
c.innerHTML='<div class="ic">·</div><div class="nm">empty</div>'; g.appendChild(c);
}
const w=WEAPONS[S.weapon];
$('invStats').innerHTML=
'<div class="stat"><span>MELEE WEAPON</span><span>'+w.name+' — '+w.dmg+' dmg'+(w.elem?' + '+w.elem.toUpperCase():'')+'</span></div>'
+'<div class="stat"><span>RANGED</span><span>'+(S.bowOwned? BOW.name+' — '+BOW.dmg+' dmg (K)':'not discovered')+'</span></div>'
+'<div class="stat"><span>HEART CONTAINERS</span><span>'+S.containers+' / 5 (max '+maxHp()+' hp at 2 per heart)</span></div>'
+'<div class="stat"><span>MOVE SPEED</span><span>'+Math.round(playerSpeed())+' (base 158)</span></div>'
+'<div class="stat"><span>COINS</span><span>'+S.coins+' ¢</span></div>'
+'<div class="stat"><span>FOES VANQUISHED</span><span>'+S.kills+'</span></div>';
}
function itemKey(k){ return k==='potion'?'1':k==='elixir'?'2':'3'; }
function openInventory(){ S.paused=true; show('inventory'); renderInventory(); SFX.shop(); }
function closeAll(){
hide('inventory'); hide('shop'); hide('dialog');
S.paused=false;
}
/* =========================================================================
SAVE / LOAD
========================================================================= */
function saveGame(){
try{
const data={
v:2, coins:S.coins, kills:S.kills, deaths:S.deaths, containers:S.containers, hp:S.hp,
inv:S.inv, owned:S.owned, weapon:S.weapon, bowOwned:S.bowOwned, flags:S.flags,
shrine:S.shrine, shrineName:S.shrineName,
elapsed:S.elapsed||0, seen:S.seen, lastRegion:S.lastRegion,
chestsOpen:chests.filter(c=>c.opened).map(c=>c.tx+','+c.ty),
obelit:obelisks.map(o=>o.lit?1:0),
savedAt:Date.now()
};
localStorage.setItem(SAVE_KEY, JSON.stringify(data));
S.savedAt=data.savedAt;
}catch(e){ console.warn('save failed',e); }
}
function hasSave(){ try{ return !!localStorage.getItem(SAVE_KEY); }catch(e){ return false; } }
function loadGame(data){
S.coins=data.coins||0; S.kills=data.kills||0; S.deaths=data.deaths||0;
S.containers=data.containers||3; S.hp=data.hp||6;
S.inv=Object.assign({potion:0,elixir:0},data.inv||{});
S.owned=Object.assign({charm:false,greed:false,boots:false,pendant:false},data.owned||{});
S.weapon=data.weapon||'rusty'; S.bowOwned=!!data.bowOwned;
S.flags=Object.assign(S.flags,data.flags||{});
S.shrine=data.shrine||S.shrine; S.shrineName=data.shrineName||S.shrineName;
S.elapsed=data.elapsed||0; S.seen=data.seen||[]; S.startTime=performance.now()-S.elapsed*1000;
// chests
const open=(data.chestsOpen||[]);
for(const c of chests){ if(open.indexOf(c.tx+','+c.ty)>=0) c.opened=true; }
// obelisks
(data.obelit||[]).forEach((v,i)=>{ if(v && obelisks[i]) obelisks[i].lit=true; });
applyFlagsToWorld();
// re-spawn solved puzzle chests that weren't opened yet
const hasChest=(x,y)=>chests.some(c=>c.tx===x&&c.ty===y);
if(S.flags.ruinsSolved && !hasChest(76,49)) spawnPuzzleChest(76,49,[{item:'boots'}],'HIDDEN VAULT');
if(S.flags.emberSolved && !hasChest(18,18)) spawnPuzzleChest(18,18,[{container:true}],'SEARED VAULT');
if(S.flags.frostWarden && !hasChest(57,26)) spawnPuzzleChest(57,26,[{coins:120},{item:'elixir',qty:2},{item:'potion',qty:2}],'WARDEN\'S HOARD');
// already-collected loot from those chests must not re-grant: mark solved states
if(S.owned.boots) PUZ.ruins.solved=true;
if(S.flags.emberSolved) PUZ.ember.solved=true;
resetPlayerAt(S.shrine[0], S.shrine[1]);
centerCam();
updateHearts(); updateHud(); setObjective();
}
function applyFlagsToWorld(){
if(S.flags.woodsPuzzle){ openGateTiles(PUZ.woods.gate); }
if(S.flags.ruinsBoss){ for(const g of gates) if(g.key==='sealed'){ g.open=true; } openGateTiles(GATES.sealed); }
if(S.flags.frostfang){ for(const g of gates) if(g.key==='ashen'){ g.open=true; } openGateTiles(GATES.ashen); }
if(S.flags.emberSolved) PUZ.ember.solved=true;
}
/* =========================================================================
INPUT
========================================================================= */
const keys={};
let mouse={x:0,y:0,down:false, rdown:false, moved:false};
addEventListener('keydown', e=>{
const k=e.key.toLowerCase();
if(['arrowup','arrowdown','arrowleft','arrowright',' ','tab'].includes(k)) e.preventDefault();
if(keys[k]) return;
keys[k]=true;
if(!S) return;
if(isOpen('title')){ return; }
if(isOpen('dialog')){
if(k==='e'||k===' '||k==='enter'||k==='escape'){ closeAll(); }
return;
}
if(isOpen('inventory')){ if(k==='tab'||k==='i'||k==='escape') closeAll(); return; }
if(isOpen('shop')){ if(k==='escape'||k==='e') closeAll(); return; }
if(isOpen('death')){ if(k==='enter'||k===' '||k==='e') respawn(); return; }
if(k==='tab'||k==='i'){ openInventory(); return; }
if(k==='e'){ doInteract(); return; }
if(k==='1') useItem('potion');
if(k==='2') useItem('elixir');
if(k==='3'||k==='b') useItem('bomb');
if(k==='m'){ SFX.setMute(!SFX.muted); $('muteBtn').textContent='[M] SOUND: '+(SFX.muted?'OFF':'ON'); }
});
addEventListener('keyup', e=>{ keys[e.key.toLowerCase()]=false; });
addEventListener('blur', ()=>{ for(const k in keys) keys[k]=false; });
addEventListener('mousemove', e=>{
const r=canvas.getBoundingClientRect();
mouse.x = cam.x + (e.clientX-r.left)/ (ZOOM*(r.width/canvas.getBoundingClientRect().width) ) / (r.width/canvas.clientWidth) * 1;
const sx=(e.clientX-r.left)*(canvas.width/r.width);
const sy=(e.clientY-r.top)*(canvas.height/r.height);
mouse.x = cam.x + sx/(ZOOM*DPR); mouse.y = cam.y + sy/(ZOOM*DPR);
mouse.moved=true;
});
canvas.addEventListener('mousedown', e=>{
if(!S||!S.running||S.paused) return;
SFX.resume();
if(e.button===0) { mouse.down=true; tryAttack(); }
if(e.button===2) { mouse.rdown=true; playerShoot(); }
e.preventDefault();
});
canvas.addEventListener('mouseup', e=>{ if(e.button===0) mouse.down=false; if(e.button===2) mouse.rdown=false; });
canvas.addEventListener('contextmenu', e=>e.preventDefault());
canvas.addEventListener('touchstart', e=>{ SFX.resume(); if(!S||!S.running) return;
const t=e.touches[0], r=canvas.getBoundingClientRect();
const sx=(t.clientX-r.left)*(canvas.width/r.width), sy=(t.clientY-r.top)*(canvas.height/r.height);
const wx=cam.x+sx/(ZOOM*DPR), wy=cam.y+sy/(ZOOM*DPR);
player.aim=Math.atan2(wy-player.y, wx-player.x); tryAttack(); }, {passive:true});
$('muteBtn').onclick=()=>{ SFX.setMute(!SFX.muted); $('muteBtn').textContent='[M] SOUND: '+(SFX.muted?'OFF':'ON'); };
/* =========================================================================
PLAYER UPDATE
========================================================================= */
function tryAttack(){
const p=player;
if(p.atkCd>0 || !S.running || S.paused) return;
const wp=WEAPONS[S.weapon];
p.atkCd=wp.cd; p.atk={t:wp.cd, dur:wp.cd, hit:false};
SFX.swing(S.weapon!=='rusty');
const a=p.aim;
const hits=[];
let any=false;
for(const e of enemies){
if(e.dead) continue;
const d=Math.hypot(e.x-p.x, e.y-p.y);
if(d > wp.range + e.r) continue;
const ea=Math.atan2(e.y-p.y, e.x-p.x);
if(Math.abs(angDiff(a, ea)) > wp.arc/2 + (e.r/Math.max(20,d))) continue;
const crit=Math.random()<0.16;
damageEnemy(e, Math.round(wp.dmg*(crit?1.6:1)), wp.elem, crit, p.x, p.y, wp.knock);
hits.push(e); any=true;
}
if(!any){
// swing hits the world: runes, vents, chests (nudge), walls
for(const r of runes){ const d=Math.hypot(r.x-p.x,r.y-p.y);
if(d<wp.range+8 && Math.abs(angDiff(a, Math.atan2(r.y-p.y,r.x-p.x)))<wp.arc/2+0.3){ strikeRune(r, wp.elem); any=true; } }
if(S.weapon==='frost'||S.weapon==='ember'){
for(const [vx,vy] of PUZ.frost.vents){
const cx=vx*TILE+16, cy=vy*TILE+16, d=Math.hypot(cx-p.x, cy-p.y);
if(tileAt(vx,vy)===T.VENT && d<wp.range+14 && Math.abs(angDiff(a, Math.atan2(cy-p.y,cx-p.x)))<wp.arc/2+0.4){
if(S.weapon==='frost'){ strikeVent(vx,vy); any=true; }
else { pText(cx,cy-14,'NEEDS FROST','#bfefff',9); }
}
}
}
if(!any){ // swipe hit the ground / wall
const hx=p.x+Math.cos(a)*wp.range*0.8, hy=p.y+Math.sin(a)*wp.range*0.8;
pSpark(hx,hy,5,wp.elem==='fire'?'#ffb154':wp.elem==='ice'?'#bfefff':'#cfd2dd',{smax:110,grav:40});
if(tileSolid(Math.floor(hx/TILE),Math.floor(hy/TILE))){
pSpark(hx,hy,7,'#8f8ba0',{smax:120,grav:120});
if(Math.random()<0.4) hitStop(0.03);
}
}
}
// interaction: nudge chests open if adjacent? no — E handles that.
}
function updatePlayer(dt){
const p=player;
if(p.dead) return;
// aim
if(mouse.moved){
p.aim=Math.atan2(mouse.y-p.y, mouse.x-p.x);
} else {
const d=Math.hypot(p.face.x,p.face.y)||1;
p.aim=Math.atan2(p.face.y/d, p.face.x/d);
}
// input dir
let ix=0, iy=0;
if(keys['a']||keys['arrowleft']) ix--;
if(keys['d']||keys['arrowright']) ix++;
if(keys['w']||keys['arrowup']) iy--;
if(keys['s']||keys['arrowdown']) iy++;
const mag=Math.hypot(ix,iy);
if(mag>0){ ix/=mag; iy/=mag; }
const spd=playerSpeed();
// dash
p.dashCd=Math.max(0,p.dashCd-dt);
if((keys['shift']||keys['shiftleft']) && p.dashCd<=0 && S.stamina>=22 && mag>0){
p.dash=0.17; p.dashCd=0.75; S.stamina-=22;
p.dashX=ix; p.dashY=iy; SFX.dash();
pSpark(p.x,p.y,10,'#bfefff',{smax:130,grav:0});
}
if(p.dash>0){
p.dash-=dt;
p.vx=p.dashX*430; p.vy=p.dashY*430;
if(Math.random()<0.7) addPart({x:p.x+rnd(-6,6),y:p.y+rnd(-8,8),vx:0,vy:0,life:0.3,max:0.3,size:3,col:'#bfefff',kind:'dot',grav:0});
} else {
const acc = mag>0? 14: 12;
p.vx=lerp(p.vx, ix*spd, Math.min(1,acc*dt));
p.vy=lerp(p.vy, iy*spd, Math.min(1,acc*dt));
}
if(mag>0){ p.face={x:ix,y:iy}; }
else if(mouse.moved){ const a=p.aim; p.face={x:Math.cos(a),y:Math.sin(a)}; }
// water check
p.inWater = tileAt(Math.floor(p.x/TILE),Math.floor(p.y/TILE))===T.WATER;
// move with block pushing
const prevX=p.x, prevY=p.y;
movePlayerAxis(true, p.vx*dt, ix, iy);
movePlayerAxis(false, p.vy*dt, ix, iy);
// footstep dust + sound
const movingDist=Math.hypot(p.x-prevX,p.y-prevY);
if(movingDist>0.4){
p.walkAnim += dt * (8 + (p.inWater? -2:0));
p.stepT += dt;
const interval = p.inWater? 0.34 : 0.29;
if(p.stepT>interval){ p.stepT=0; SFX.step();
addPart({x:p.x+rnd(-4,4),y:p.y+9,vx:rnd(-16,16),vy:rnd(-12,-2),life:0.4,max:0.4,size:rnd(2,3.4),
col:p.inWater?'rgba(150,200,240,0.6)':'rgba(150,140,120,0.5)',kind:'dot',grav:-4});
}
if(p.inWater && Math.random()<0.25) addPart({x:p.x+rnd(-6,6),y:p.y+9,vx:rnd(-20,20),vy:-30,life:0.4,max:0.4,size:2.4,col:'rgba(190,220,255,0.7)',kind:'dot',grav:40});
}
// stamina
const regen = (S.owned.boots?30:18) * (movingDist>0.4?0.72:1.35);
S.stamina=Math.min(S.maxStamina, S.stamina + regen*dt);
// timers
p.invul=Math.max(0,p.invul-dt);
p.atkCd=Math.max(0,p.atkCd-dt);
p.bowCd=Math.max(0,p.bowCd-dt);
p.hurtT=Math.max(0,p.hurtT-dt);
p.elixirT=Math.max(0,p.elixirT-dt);
if(p.atk){ p.atk.t-=dt; if(p.atk.t<=0) p.atk=null; }
// bow input
if(keys['k']||keys['f']||mouse.rdown) playerShoot();
// attack input
if(keys[' ']||keys['j']||mouse.down) tryAttack();
// hazard tiles
const here=tileAt(Math.floor(p.x/TILE),Math.floor(p.y/TILE));
if(here===T.ASH && p.invul<=0){ takeDamage(2, p.x, p.y-30); }
// region explore
if(!p._revealT) p._revealT=0;
p._revealT-=dt;
if(p._revealT<=0){ p._revealT=0.12; markExplored(); }
}
function markExplored(){
const r=8;
const tx=Math.floor(player.x/TILE), ty=Math.floor(player.y/TILE);
for(let j=ty-r;j<=ty+r;j++) for(let i=tx-r;i<=tx+r;i++){
if(!inb(i,j)) continue;
if((i-tx)**2+(j-ty)**2 > r*r) continue;
world.explored[idx(i,j)]=1;
}
updateStamina();
}
function movePlayerAxis(isX, delta, ix, iy){
const p=player;
if(!delta) return;
const before = isX? p.x : p.y;
p[isX?'x':'y'] += delta;
const r=p.r;
const t0x=Math.floor((p.x-r)/TILE), t1x=Math.floor((p.x+r)/TILE);
const t0y=Math.floor((p.y-r)/TILE), t1y=Math.floor((p.y+r)/TILE);
for(let ty=t0y;ty<=t1y;ty++) for(let tx=t0x;tx<=t1x;tx++){
if(!tileSolid(tx,ty)) continue;
if(isX) p.x = delta>0 ? tx*TILE-r-0.01 : (tx+1)*TILE+r+0.01;
else p.y = delta>0 ? ty*TILE-r-0.01 : (ty+1)*TILE+r+0.01;
}
// pushable blocks
for(const b of blocks){
if(Math.abs(b.x-p.x) > (b.r+p.r) || Math.abs(b.y-p.y) > (b.r+p.r)) continue;
const dirX = isX? Math.sign(delta) : 0, dirY = isX? 0 : Math.sign(delta);
const canPush = (isX? ix*dirX>0.3 : iy*dirY>0.3);
if(canPush && tryPush(b, dirX, dirY)){
// block moved: keep player sliding
continue;
}
// resolve collision against the block
if(isX) p.x = dirX>0 ? b.x-b.r-p.r-0.01 : b.x+b.r+p.r+0.01;
else p.y = dirY>0 ? b.y-b.r-p.r-0.01 : b.y+b.r+p.r+0.01;
if(canPush) blockBump(b);
}
p.x=clamp(p.x,10,WW-10); p.y=clamp(p.y,10,WH-10);
}
function blockBump(b){ if(Math.random()<0.2){ SFX.step(); pSpark(b.x-Math.sign(b.x-player.x)*12, b.y, 2,'#8f8ba0',{smax:60,grav:60}); } }
/* =========================================================================
MAIN LOOP
========================================================================= */
function step(dt){
S.elapsed = (performance.now()-S.startTime)/1000;
if(hitStopT>0){ hitStopT-=dt; dt*=0.18; }
updatePlayer(dt);
updateEnemies(dt);
updateShots(dt);
updateBombs(dt);
updateDrops(dt);
updateBlocks(dt);
updatePuzzles(dt);
updateParts(dt);
updateCam(dt);
// objective/music housekeeping
S._mobT=(S._mobT||0)+dt;
if(S._mobT>7){ S._mobT=0; maintainEnemies(); }
S._hudT=(S._hudT||0)+dt;
if(S._hudT>0.2){ S._hudT=0; updateHud(); }
// ashen gate opens once frostfang is taken
if(S.flags.frostfang && !S.flags.gateAshen){
S.flags.gateAshen=true; openGateTiles(GATES.ashen);
for(const g of gates) if(g.key==='ashen') g.open=true;
SFX.gate(); toast('The Ashen Gate answers the frost — it grinds open.','good'); setObjective();
}
}
function frame(now){
requestAnimationFrame(frame);
let dt=(now-lastT)/1000; lastT=now;
if(!isFinite(dt)||dt<=0) dt=0.016;
dt=Math.min(dt,0.05);
if(S && S.running && !S.paused) step(dt);
else if(S && !S.paused){ updateParts(dt); updateCam(dt); }
render();
updatePrompt();
drawMinimap();
revealMinimap();
}
let lastT=performance.now();
let mmTick=0;
/* =========================================================================
BOOT / SCREENS
========================================================================= */
function startWorld(seedFree){
enemies.length=0; shots.length=0; drops.length=0; PARTS.length=0; bombs.length=0;
buildShade();
buildWorld();
buildContent();
buildMinimap();
PUZ.ruins.order = shuffle([0,1,2,3,4]);
PUZ.ruins.lit.length=0;
frostTiles.fill(0);
}
function newGame(){
S=freshState();
startWorld();
resetPlayerAt(14, 46);
centerCam();
S.running=true; S.paused=false;
$('title').classList.remove('on');
SFX.resume(); SFX.musicStart();
SFX.setRegion(R.meadow.music,0);
updateHearts(); updateHud(); setObjective();
toast('WELCOME TO THE VALE — press [E] near anything that glimmers','item');
setTimeout(()=>toast('Tip: hollow stones can be pushed. Lit torches need FIRE.','item'), 4200);
renderInventory();
}
function continueGame(){
let data=null;
try{ data=JSON.parse(localStorage.getItem(SAVE_KEY)); }catch(e){}
S=freshState();
startWorld();
if(data) loadGame(data);
else { resetPlayerAt(14,46); centerCam(); }
S.running=true; S.paused=false;
$('title').classList.remove('on');
SFX.resume(); SFX.musicStart();
toast('✦ WELCOME BACK — '+S.shrineName,'good');
updateHearts(); updateHud(); setObjective();
renderInventory();
}
function winner(){
hide('death');
SFX.victory();
const mins=Math.floor(S.elapsed/60), secs=Math.floor(S.elapsed%60);
const found=Object.keys(S.owned).filter(k=>S.owned[k]).length + S.seen.length;
$('vicStats').innerHTML=
'<div class="stat"><span>Time in the vale</span><span>'+mins+'m '+secs+'s</span></div>'
+'<div class="stat"><span>Foes vanquished</span><span>'+S.kills+'</span></div>'
+'<div class="stat"><span>Deaths</span><span>'+S.deaths+'</span></div>'
+'<div class="stat"><span>Coins carried</span><span>'+S.coins+' ¢</span></div>'
+'<div class="stat"><span>Heart containers</span><span>'+S.containers+' / 5</span></div>'
+'<div class="stat"><span>Weapon path</span><span>Rusty Blade → '+WEAPONS[S.weapon].name+(S.bowOwned?' + Stormbow':'')+'</span></div>'
+'<div class="stat"><span>Items discovered</span><span>'+found+'</span></div>';
show('victory');
}
$('shopClose').onclick=()=>{ closeAll(); };
$('invClose').onclick=()=>{ closeAll(); };
$('btnNew').onclick=()=>{ SFX.resume(); newGame(); };
$('btnCont').onclick=()=>{ SFX.resume(); continueGame(); };
$('btnRespawn').onclick=()=>{ respawn(); };
$('btnTitle2').onclick=()=>{ hide('death'); S.running=false; S.paused=false; offerContinue(); show('title'); };
$('btnAgain').onclick=()=>{ try{ localStorage.removeItem(SAVE_KEY); }catch(e){} hide('victory'); newGame(); };
function offerContinue(){
const has=hasSave();
$('btnCont').style.display=has?'block':'none';
if(has){ try{ const d=JSON.parse(localStorage.getItem(SAVE_KEY));
const mins=Math.floor((d.elapsed||0)/60);
$('loadNote').textContent='◆ Continue from '+d.shrineName+' · '+(d.containers||3)+' hearts · '+mins+'m played';
}catch(e){ $('loadNote').textContent=''; } }
else $('loadNote').textContent='';
}
setInterval(()=>{}, 1 << 30); // (rAF drives rendering)
/* boot */
resize();
buildShade();
S=freshState(); S.running=false;
buildWorld(); buildContent(); buildMinimap();
resetPlayerAt(14,46);
centerCam();
render();
offerContinue();
updateHearts(); updateHud();
console.log('[Ashes of Aetheria] world', MW+'x'+MH, 'tiles ·', REGIONS.length, 'regions ·', enemies.length, 'foes seeded');
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"bd946e9b-eb98-4c65-8ed8-86e316547c43","tokensIn":5186554,"tokensOut":108171,"tokensTotal":5294725,"cost":0.10755641999999999,"turns":35,"toolCalls":54,"failedToolCalls":1,"timestamp":"2026-09-11T00:34:10.074Z"} -->