← Warden results

Warden

Claude haiku 5.5 · full

◈Launch the experiment

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Task text captured before this run. Effective runtime system instructions were not captured.

Generation prompt · d359a83b1d
# The Warden's Corrosion — Design Specification

**Format:** Self-contained interactive HTML experience  
**Tone:** Melancholic celestial ruin; decaying grandeur and mechanical renewal  
**Core Loop:** Explore zone → combat/solve → acquire resource/item → save/checkpoint → spend/prepare → advance.

---

## 1. Lore & Setting — *The Warden's Corrosion*

- **World:** *Aurelion’s Sunken Observatory*—a floating celestial archive suspended above a storm-locked valley. Massive gears, shattered telescopes, and moss-covered bronze line the halls.
- **Premise:** The player is the **Last Warden**, the only remaining guardian capable of reigniting the **Core Beacon** before the **Corrosion** (a creeping black-iron decay) collapses the structure.
- **Visual Identity:** Warm golds, burnt oranges, and deep slate blues. Enemies are “Corrupted Constructs” (animated armor, broken instruments, and rusted sentinels).
- **Narrative Arc:** The Warden must travel from the outer threshold through six interconnected zones, restore two ancient weapon altars, discover a hidden ranged mechanism, and activate the Beacon in the final chamber.

---

## 2. World Structure & Level Progression

The game flows through **six sequential connected zones**. The player cannot skip zones; progression is gated by keys, weapon capabilities, or solved puzzles.

| # | Zone | Purpose in Progression |
|---|------|----------------------|
| **1** | **The Ashen Threshold** (Prologue) | Movement, basic combat, first Save Point, intro to particle/audio feedback. |
| **2** | **The Rusted Archives** (Early) | First accessory/consumable discoveries, first hidden health container, money farming begins, **Shop unlocked**. |
| **3** | **The Collapsed Gallery** (Mid-Early) | **Weapon Upgrade 1** altar, environmental pressure-plate puzzle, second Save Point. |
| **4** | **The Sunken Vault** (Mid-Game / Turning Point) | **Ranged Weapon discovery** hidden behind a breakable wall or light-puzzle. This is the exact halfway point. Third Save Point. |
| **5** | **The Twin Spire** (Late) | **Weapon Upgrade 2** altar, final health container (or shop purchase confirmation), toughest combat gauntlet, fourth item discoveries. |
| **6** | **The Core Chamber** (Climax) | Final confrontation using all mechanics; resolution and Beacon restoration. |

**Save Points (Sanctuary Stones):** There are exactly **three** in-world sanctuary structures—glowing stone pedestals that the player activates.
- Stone 1: Entrance of The Ashen Threshold.
- Stone 2: Center of The Rusted Archives (after the first combat challenge).
- Stone 3: Entrance of The Twin Spire (before the final ascent).

---

## 3. Player State & Visual Progression

### Character Representation
The player avatar must visually reflect current gear:
- **Base:** Simple Warden cloak, unadorned melee stance.
- **Upgrade 1 (Ignited Edge):** Blade glows orange; walking animation includes a faint ember trail.
- **Upgrade 2 (Solar Falchion):** Blade emits golden light; wider combat stance visible; cloak edges shimmer.
- **Ranged Discovery:** Once the Aether Shard is acquired, the character’s off-hand holds a pulsing crystal; the stance shifts to a balanced dual-ready posture.
- **Equipment Accessories:** When an accessory is equipped (e.g., Warden’s Band, Shadow Cloak), a distinct visual token appears on the character model—ring, cloak hem, or shoulder clasp.

### Health System
- Represented by **heart-shaped containers** (or equivalent symbolic shapes).
- **Each container = exactly 2 HP.**
- **Start:** 3 containers (6 HP total).
- **Target:** 5 containers (10 HP total).
- **Damage Feedback:** When hit, a brief red flash surrounds the player, a damage number/shadow appears, and one heart shape empties/depletes visually. Health does not regenerate automatically; recovery requires consumables or save-point rest.

**How the 4th and 5th containers are earned:**
- **Container 4:** Hidden behind a crack-marked breakable wall in **The Collapsed Gallery**, or dropped from the Gallery’s mini-boss construct. The player must notice the visual crack pattern.
- **Container 5:** Located in **The Twin Spire** behind a ranged-weapon-only switch (requires firing the Aether Shard at a distant mechanism), OR available as the **most expensive item at the Shop** (300 gold) to ensure it is earned through deliberate economy.

---

## 4. Combat & Weapon Evolution

### Primary Weapon: The Ember Blade
Starts in the player’s possession in Zone 1.

**Upgrade 1 — Ignited Edge (Found in The Collapsed Gallery at an altar):**
- Damage type shifts from physical to **fire/ember**.
- Attack speed increases noticeably.
- Adds orange particle trail on every swing.
- Enemy hit reaction changes to a brief burn-flinch.

**Upgrade 2 — Solar Falchion (Found in The Twin Spire at an altar):**
- Damage type shifts to **light/solar**; wider arc sweep (visual range extension).
- Slower wind-up but heavier impact; triggers a brief screen-edge shimmer and deeper bass sound.
- Visual: blade emits sustained golden glow; player stance visibly widens.

*Exactly two distinct upgrades exist for the main weapon, significantly altering its abilities. No hidden third form.*

### Ranged Weapon Discovery (Mid-Game Discovery)
- **Item:** The **Aether Shard** (a crystalline projectile launcher).
- **Unlock Condition:** Found only in **The Sunken Vault (Zone 4)**—after the player reaches the halfway point of progression.
- **Acquisition:** Hidden behind a destructible wall that only shows hairline cracks, or locked behind a pressure-plate/puzzle that requires the player to use the environment. It is **not** sold in the shop and is **not** available before Zone 4.
- **Behavior:** Allows the player to fire a glowing bolt at range, useful for hitting distant switches, breaking weak barriers, and engaging enemies safely.

---

## 5. Inventory, Economy & Required Items

The player must be able to collect and manage at least **five distinct accessory or consumable items**. These are specifically named and placed:

| Item | Type | Acquisition Method | Function / Visual Cue |
|------|------|-------------------|-----------------------|
| **Warden’s Band** | Accessory | Hidden chest in **The Rusted Archives** (Zone 2) | Equipped on wrist; increases attack speed slightly. Glowing silver ring model. |
| **Vital Stem** | Consumable | Found in hidden cache **OR** purchased at Shop for 50 gold | Restores 2 full containers (4 HP). Glowing green plant-like icon in inventory. |
| **Phantom Lantern** | Accessory | Reward for solving the light-reflection puzzle in **The Sunken Vault** (Zone 4) | Reveals hidden passages and cracks in dark areas; emits soft blue glow when equipped. |
| **Shadow Cloak** | Accessory | Hidden treasure in **The Twin Spire** (Zone 5) **OR** dropped by the Spire Guardian | Reduces damage from environmental traps; dark cloth trim visible on character. |
| **Binding Seal** | Consumable | Found in broken jar in **The Twin Spire** **OR** Shop purchase (75 gold) | Temporarily freezes one enemy in place for ~3 seconds. Glowing blue seal icon. |

**Inventory Rules:** The player can hold multiple copies of consumables but only one of each accessory can be equipped at a time (accessories must be actively toggled via inventory interaction).

### Money System
- **Currency:** Golden Sigils / Silver Fragments (visually represented as small coin-like shapes).
- **Sources:** Enemy drops (every defeated construct drops 5–15 gold), hidden caches in breakable objects, puzzle-completion rewards, and a large bonus chest in Zone 5.
- **Shop Location:** A safe-zone merchant altar in **The Rusted Archives (Zone 2)**—accessible after the first combat challenge.
- **Shop Inventory:**
  - Vital Stem (50 gold) — max 3 sold.
  - Binding Seal (75 gold) — max 2 sold.
  - **Heart Container Upgrade #5** (300 gold) — only one available; requires deliberate gold accumulation.
  - Basic ammo/restock for the Aether Shard (10 gold per charge) — available only *after* Zone 4 is completed.

---

## 6. Audio Design Requirements

Every major action must have a distinct, identifiable sound.

- **Movement / Footsteps:** Soft stone-grind on bronze; muffled echo in halls; heavier clank on rusted metal grates in Archives; splash/damp echo in Sunken Vault.
- **Weapon Attacks:**
  - *Ember Blade (Base):* Sharp whoosh + crisp impact thud.
  - *Ignited Edge:* Whoosh + flame crackle + deeper, resonant thud.
  - *Solar Falchion:* Heavy wind-up + resonant clang + low bass rumble.
  - *Aether Shard (Ranged):* Sharp crystal “twang” + distant impact chime.
- **Damage Taken:** Dull body-thud + brief high-pitch dissonant chime + red flash audio cue.
- **Item / Money Pickup:** Ascending melodic chime (common items); resonant chord (rare/accessory); metallic clink (money).
- **Shop Interaction:** Low harmonic drone when near the merchant altar; coin-drop sound + bright confirmation chime on purchase; item reveal shimmer sound.

---

## 7. Visual Feedback & Particle Effects

Particles must enhance, not clutter.

- **Combat Impact:** Orange sparks on standard enemy hits; blue sparks on critical/block; red particle burst centered on player when taking damage; small ember smoke when fire-upgraded blade strikes.
- **Item Collection:** Swirling gold dust for currency; bright white glow with upward-floating motes for accessories/consumables; brief golden aura around the player body when a **new health container is acquired**.
- **Status / Upgrades:** Persistent orange glow around weapon for Upgrade 1; sustained golden halo for Upgrade 2; screen-edge shimmer for 2 seconds upon health increase or weapon altar activation.
- **Environment:** Floating dust in Archives; mist particles rising in Sunken Vault; faint golden sparkles near Sanctuary Stones.

---

## 8. Environmental Puzzle-Solving

Puzzles are physical and visual, not abstract logic gates.

- **Breakable Walls:** Marked by clear hairline cracks and darker discoloration. The base blade breaks them slowly; the Solar Falchion breaks them faster. Some hide the Aether Shard or health containers.
- **Pressure Plates:** Large bronze plates that depress under the player’s weight. Used to open timed doors or lower bridges.
- **Light / Switch Mechanisms:** In Zone 4 and 5, distant crystal switches that must be struck by the **Aether Shard** projectile. The Phantom Lantern reveals hidden light paths.
- **Timed Sequences:** A door opens only if two plates are pressed in quick succession or if an enemy is lured onto a plate.

---

## 9. Gameplay Loop (Step-by-Step Experience)

1. **Enter Zone** → Learn layout; identify breakable walls, plates, and save stone.
2. **Combat** → Use current weapon (base, then upgraded) to defeat Corrupted Constructs; manage health carefully.
3. **Explore** → Find hidden caches for the 5 required items and gold.
4. **Save** → Activate Sanctuary Stone to secure progress.
5. **Shop / Economy** → Return to the Zone 2 merchant to spend accumulated gold on Vital Stems, Binding Seals, or save for the final heart upgrade.
6. **Progression Milestone** → At Zone 3, upgrade weapon to Ignited Edge. At Zone 4, discover Aether Shard—combat and puzzle dynamics permanently expand.
7. **Late Game** → At Zone 5, earn Solar Falchion and secure the 5th heart container through exploration or economy. Use all items (Shadow Cloak, Phantom Lantern, Binding Seal) to survive the gauntlet.
8. **Climax** → Zone 6 uses every system: ranged switches, upgraded melee, full health pool, inventory consumables, and saved progress.

---

## 10. Balance & Progression Mandate

The progression curve (from 3 health containers to 5 health containers, and through two weapon upgrades) must feel earned, engaging, and proportional to the challenges presented in the environment. Ensure that the combination of exploration, combat, and inventory management creates a cohesive and fun action-adventure experience.

The curve should feel proportional: the player feels stronger precisely because they have physically found the upgrades, expanded health, mastered the ranged tool, and accumulated resources.

---

## 11. Design Mandate

The final design must prioritize balanced gameplay. The progression through weapon upgrades and health expansion must feel earned and engaging relative to the environment's challenges. The experience should emphasize discovery, tangible equipment upgrades, and highly interactive gameplay combining exploration, combat, and inventory management.

Describe all behaviors through **player-facing outcomes**: what changes visually, what sounds play, what the player can now do, and where things are located.
Inspect original source 56,730 bytes · SHA-256 7ba8a9095076
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>The Warden's Corrosion</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:radial-gradient(ellipse at center,#1d2a44 0%,#0a0f1c 70%,#05070d 100%);font-family:Georgia,'Times New Roman',serif;color:#e9d7b0;overflow:hidden}
#wrap{position:relative;width:960px;height:576px;margin:20px auto;box-shadow:0 0 50px #000c,0 0 0 2px #7a5523}
canvas{display:block;width:960px;height:576px;background:#000}
#shim{position:absolute;inset:0;pointer-events:none;opacity:0;box-shadow:inset 0 0 120px 30px rgba(255,215,120,.55);transition:opacity .4s}
#shim.on{opacity:1}
#toast{position:absolute;left:50%;top:44px;transform:translateX(-50%);background:rgba(20,14,8,.88);border:1px solid #c98a3a;padding:6px 16px;border-radius:4px;opacity:0;transition:opacity .35s;pointer-events:none;white-space:nowrap;font-size:15px;color:#ffe2a8}
#toast.on{opacity:1}
#hud{position:absolute;left:12px;top:10px;pointer-events:none;font-size:14px;text-shadow:0 1px 2px #000}
#zoneName{color:#ffc46b;font-size:15px;letter-spacing:1px}
#hearts{margin-top:4px;font-size:22px;letter-spacing:2px}
#hearts .h{color:#c2352b}#hearts .e{color:#4a2a25}
#gold{margin-top:4px;color:#ffd65a}
#items{position:absolute;right:12px;top:10px;pointer-events:none;font-size:13px;text-align:right;text-shadow:0 1px 2px #000}
#msg{position:absolute;left:0;right:0;bottom:10px;text-align:center;font-size:13px;color:#c9a46a;text-shadow:0 1px 2px #000}
.ov{position:absolute;inset:0;background:rgba(5,6,12,.84);display:flex;align-items:center;justify-content:center;z-index:5}
.ov.hidden{display:none}
.box{background:linear-gradient(#22190f,#120d09);border:2px solid #b07a35;border-radius:6px;padding:22px 28px;width:640px;max-height:520px;overflow:auto;box-shadow:0 0 30px #000;text-align:left}
.box.center{text-align:center}
h1{margin:0 0 4px;color:#ffc46b;letter-spacing:3px;font-weight:normal;font-size:34px}
h2{margin:0 0 10px;color:#ffc46b;letter-spacing:2px;font-weight:normal}
p{margin:6px 0;line-height:1.45}
.sub{color:#c9a46a;font-style:italic;margin-bottom:14px}
button{font:inherit;background:#3a2813;color:#ffd896;border:1px solid #c28a40;padding:6px 12px;border-radius:3px;cursor:pointer;margin:3px}
button:hover{background:#5a3c1a}
button:disabled{opacity:.4;cursor:not-allowed}
.row{display:flex;justify-content:space-between;align-items:center;padding:7px 0;border-bottom:1px solid #3d2c18}
.small{font-size:13px;color:#b9a27a}
kbd{background:#2a1f14;border:1px solid #6b4a24;border-radius:3px;padding:0 5px;font-family:monospace;color:#ffd896}
#death{position:absolute;inset:0;background:#000;opacity:0;pointer-events:none;transition:opacity .6s;z-index:4}
#death.on{opacity:1}
</style>
</head>
<body>
<div id="wrap">
  <canvas id="c" width="960" height="576"></canvas>
  <div id="shim"></div>
  <div id="toast"></div>
  <div id="death"></div>
  <div id="hud"><div id="zoneName"></div><div id="hearts"></div><div id="gold"></div></div>
  <div id="items"></div>
  <div id="msg"></div>
  <div id="title" class="ov">
    <div class="box center">
      <h1>The Warden's Corrosion</h1>
      <div class="sub">Aurelion's Sunken Observatory is failing. Reignite the Core Beacon.</div>
      <p>Move <kbd>WASD</kbd>/<kbd>Arrows</kbd> &nbsp; Strike <kbd>Space</kbd>/<kbd>J</kbd> &nbsp; Aether Shard <kbd>K</kbd></p>
      <p>Interact <kbd>E</kbd> &nbsp; Vital Stem <kbd>Q</kbd> &nbsp; Binding Seal <kbd>R</kbd> &nbsp; Inventory <kbd>I</kbd> &nbsp; Close <kbd>Esc</kbd></p>
      <p class="small">Each heart container holds 2 HP. Hairline cracks hide treasure. Sanctuary Stones save your progress and restore you on death.</p>
      <br>
      <button id="btnStart" style="font-size:17px;padding:9px 22px">Begin the Descent</button>
    </div>
  </div>
  <div id="panel" class="ov hidden">
    <div class="box">
      <h2 id="panelTitle"></h2>
      <div id="panelBody"></div>
      <div style="text-align:right;margin-top:12px"><button id="panelClose">Close (Esc)</button></div>
    </div>
  </div>
</div>
<script>
(()=>{
'use strict';
const cv=document.getElementById('c'),g=cv.getContext('2d');
const TS=32,VW=960,VH=576;
const $=id=>document.getElementById(id);
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const rnd=(a,b)=>a+Math.random()*(b-a);
const ri=(a,b)=>Math.floor(rnd(a,b+1));

/* ---------------- AUDIO ---------------- */
let AC=null,MG=null,droneG=null;
function initAudio(){
  if(AC)return;
  try{AC=new (window.AudioContext||window.webkitAudioContext)();}catch(e){return;}
  MG=AC.createGain();MG.gain.value=0.4;MG.connect(AC.destination);
  droneG=AC.createGain();droneG.gain.value=0;droneG.connect(MG);
  const o=AC.createOscillator();o.type='sawtooth';o.frequency.value=55;
  const f=AC.createBiquadFilter();f.type='lowpass';f.frequency.value=200;
  o.connect(f);f.connect(droneG);o.start();
  const o2=AC.createOscillator();o2.type='sine';o2.frequency.value=82.4;
  const g2=AC.createGain();g2.gain.value=0.6;o2.connect(g2);g2.connect(droneG);o2.start();
}
function tone(f,d,type,v,slide,delay){
  if(!AC)return;const t=AC.currentTime+(delay||0);
  const o=AC.createOscillator(),gn=AC.createGain();
  o.type=type||'sine';o.frequency.setValueAtTime(f,t);
  if(slide)o.frequency.exponentialRampToValueAtTime(Math.max(20,slide),t+d);
  gn.gain.setValueAtTime(v||0.15,t);gn.gain.exponentialRampToValueAtTime(0.0001,t+d);
  o.connect(gn);gn.connect(MG);o.start(t);o.stop(t+d+0.05);
}
function noise(d,v,hp,delay){
  if(!AC)return;const t=AC.currentTime+(delay||0);
  const n=Math.floor(AC.sampleRate*d),b=AC.createBuffer(1,n,AC.sampleRate),c=b.getChannelData(0);
  for(let i=0;i<n;i++)c[i]=(Math.random()*2-1)*(1-i/n);
  const s=AC.createBufferSource();s.buffer=b;
  const f=AC.createBiquadFilter();f.type='highpass';f.frequency.value=hp||600;
  const gn=AC.createGain();gn.gain.value=v||0.15;
  s.connect(f);f.connect(gn);gn.connect(MG);s.start(t);
}
const SFX={
  step(z){noise(0.03,0.05,[1400,500,900,2200,1600,500][z]||900);},
  wadeStep(){noise(0.06,0.08,300);tone(180,0.08,'sine',0.04,120);},
  swing(w){
    if(w===0){noise(0.12,0.18,1500);tone(520,0.12,'triangle',0.08,300);}
    else if(w===1){noise(0.16,0.2,900);tone(300,0.22,'sawtooth',0.06,140);tone(120,0.2,'sine',0.1,80,0.04);}
    else{tone(180,0.35,'sine',0.25,90);tone(660,0.3,'triangle',0.08,990,0.12);noise(0.2,0.1,300,0.2);}
  },
  hit(){tone(160,0.12,'square',0.12,70);noise(0.05,0.15,500);},
  burn(){noise(0.2,0.1,1200);tone(400,0.2,'sawtooth',0.04,200);},
  shatter(){noise(0.3,0.25,2000);tone(900,0.25,'triangle',0.06,300);},
  hurt(){tone(110,0.25,'sine',0.3,55);tone(1500,0.3,'sawtooth',0.04,900,0.02);},
  pick(){[660,880,1100].forEach((f,i)=>tone(f,0.25,'triangle',0.12,null,i*0.07));},
  rare(){[262,330,392,523].forEach((f,i)=>tone(f,0.9,'sine',0.09,null,i*0.03));},
  coin(){tone(1800,0.08,'square',0.05);tone(2400,0.1,'square',0.04,null,0.06);},
  buy(){tone(988,0.2,'triangle',0.12);tone(1319,0.35,'triangle',0.1,null,0.1);noise(0.1,0.08,3000,0.05);},
  shimmer(){tone(1200,1.0,'sine',0.06,2400);},
  bolt(){tone(1400,0.12,'triangle',0.12,2200);tone(2600,0.4,'sine',0.06,1800,0.14);},
  enemyShot(){tone(260,0.15,'square',0.05,140);},
  click(){tone(600,0.05,'square',0.05);},
  door(){noise(0.6,0.12,80);tone(90,0.6,'sawtooth',0.08,60);},
  plate(){tone(140,0.15,'sine',0.2,90);},
  heal(){[523,659,784].forEach((f,i)=>tone(f,0.3,'sine',0.1,null,i*0.06));},
  freeze(){tone(2000,0.4,'sine',0.08,800);noise(0.3,0.06,3500);},
  altar(){[392,523,659,784].forEach((f,i)=>tone(f,0.6,'triangle',0.1,null,i*0.1));},
  death(){tone(200,1.2,'sine',0.3,40);},
  victory(){[262,330,392,523,659,784].forEach((f,i)=>tone(f,1.4,'sine',0.1,null,i*0.25));},
  stone(){tone(440,0.8,'sine',0.1,660);tone(660,0.8,'sine',0.06,880,0.2);},
  mist(){noise(0.5,0.03,2500);}
};

/* ---------------- DATA ---------------- */
const WEAPONS=[
  {name:'Ember Blade',wind:0.06,dur:0.22,cd:0.38,range:44,arc:70,dmg:1},
  {name:'Ignited Edge',wind:0.04,dur:0.2,cd:0.22,range:50,arc:84,dmg:2},
  {name:'Solar Falchion',wind:0.3,dur:0.3,cd:0.6,range:64,arc:150,dmg:3}
];
// Zone definitions. Chars: # wall, . floor, B back exit, N next gate, T sanctuary stone, M merchant,
// X chest, j jar, c cracked wall, P/Q plates, D door, S switch, A altar, h hazard, e enemy, v sentinel,
// Z boss, O beacon
const ZDEF=[
{name:'The Ashen Threshold',sub:'Prologue',pal:['#3b3226','#463a2a','#1d2437','#c98a3a'],gate:'clear',enemies:['armor','instrument'],
 chests:[],walls:[],jars:[],map:[
"########################################",
"#......................................#",
"#..T...................................#",
"#.....e.......e.........e..............#",
"#..........####..........####..........#",
"#.....e....#..#....e.....#..#......e...#",
"#..........####..........####..........#",
"#................e.........e..........#",
"#.......e.........................e....#",
"#......................................N",
"#.........e.......................e.....#",
"#....###..........##.........###.......#",
"#....#.#...............e..........e....#",
"#....###.........##.......###..........#",
"#.....e..................e..............#",
"#..........e....................e......#",
"#......................................#",
"########################################"]},
{name:'The Rusted Archives',sub:'Early',pal:['#3a2c20','#44331f','#27304a','#d69a4a'],gate:'clear',enemies:['armor','instrument'],
 chests:['band'],walls:['gold'],jars:['stem'],shop:true,map:[
"########################################",
"#.....#...................#............#",
"#..X..#...jj..............#.....c......#",
"#.....#...................#............#",
"#.....######....e.........#....e.......#",
"#..........#.........e....#............#",
"#...e......#.........................e.#",
"#...........................#.....e....#",
"B......................................N",
"B.....T...........M...............e....#",
"#.........e.....................e......#",
"#.....###.........e.....###..........#",
"#.....#.#..................#...........#",
"#.....###..........e......###.........#",
"#..........e....................e......#",
"#....................................e.#",
"#......................................#",
"########################################"]},
{name:'The Collapsed Gallery',sub:'Mid-Early',pal:['#3c3024','#4a3a28','#262a38','#e0a040'],gate:'boss',enemies:['armor'],
 door:'plates',chests:[],walls:['heart'],jars:[],bossType:'gs',map:[
"########################################",
"#.......#................#.............#",
"#.......#....A...........#...c.........#",
"#.......D................D.......e......#",
"#.......#.......e.........#............#",
"#.P....Q#................#....e........#",
"#.......#................#............#",
"#.......#.....e.....###...#............#",
"B......................................N",
"B.........e.....................e......#",
"#...........................e......Z...#",
"#.....###..............##..............#",
"#.....#.#....e.........##.....e........#",
"#.....###.........................e....#",
"#.........e..........e.........e.......#",
"#......................................#",
"#......................................#",
"########################################"]},
{name:'The Sunken Vault',sub:'Turning Point',pal:['#1f2a3c','#27344a','#121d2c','#6fb6d8'],gate:'lit',litNeed:3,door:'lit',dark:true,enemies:['instrument','armor'],
 chests:['lantern'],walls:['shard'],jars:[],map:[
"########################################",
"#......#.......#...............#........#",
"#......#.......#.......S.......#...X....#",
"#.......c......D.......DD.......S.......#",
"#......#.......#...h.......h...#........#",
"#......#...e...#.......v.......#...e....#",
"#......#.......#...h.......h...#........#",
"#..S...........#...............S.........#",
"B......................................N",
"B.........hhh.........e.........hhh....#",
"#...............e.........e.............#",
"#.....###.........v.............e......#",
"#.....#.#..........e.........###........#",
"#.....###...............e...............#",
"#.........e..........v...........e......#",
"#...................................e..#",
"#......................................#",
"########################################"]},
{name:'The Twin Spire',sub:'Late',pal:['#4a3a24','#573f22','#2a2a3e','#f0c060'],gate:'boss',enemies:['armor','instrument'],
 door:'lit',litNeed:1,chests:['cloak','gold150'],walls:[],jars:['seal'],altar:2,bossType:'sg',map:[
"########################################",
"#......#...........#.........#.........#",
"#..S...#...T.......#.........#...X.....#",
"#......#...........#.........#.........#",
"#......#...........D.........#.........#",
"#......#...e.......#...A.....#...e.....#",
"#......#...........###########.........#",
"#.....h.......e..........h..........e..#",
"B......................................N",
"B.........hhh......e.............hhh...#",
"#.......e..........................e...#",
"#....###..........Z.....e...........X..#",
"#....#.#...e...........................#",
"#....###.................e.......j.....#",
"#..........e..........h.........e......#",
"#.............e........................#",
"#......................................#",
"########################################"]},
{name:'The Core Chamber',sub:'Climax',pal:['#2e1f2a','#3a2630','#1a1424','#ff8a3a'],gate:'none',door:'boss',enemies:['armor'],
 chests:[],walls:[],jars:[],bossType:'ch',map:[
"########################################",
"#......................................#",
"#.........##############.....##########",
"#.........#............#.....#........#",
"#.........#.....O......D.....#........#",
"#.........#............#.....#........#",
"#.........##############.....#........#",
"B......................................#",
"B......................................#",
"#.....................Z................#",
"#......................................#",
"#......................................#",
"#.........................e............#",
"#......................................#",
"#..e...................e............e.#",
"#......................................#",
"#......................................#",
"########################################"]}
];
const ITEMS={
  band:{name:"Warden's Band",kind:'acc',rare:true},
  lantern:{name:'Phantom Lantern',kind:'acc',rare:true},
  cloak:{name:'Shadow Cloak',kind:'acc',rare:true},
  stem:{name:'Vital Stem',kind:'cons'},
  seal:{name:'Binding Seal',kind:'cons'},
  gold:{name:'Golden Sigils',kind:'gold',val:25},
  gold150:{name:'Golden Sigils',kind:'gold',val:150},
  heart:{name:'Heart Container',kind:'heart'},
  shard:{name:'Aether Shard',kind:'shard'}
};
const ENEMY={
  armor:{hp:3,speed:55,r:12,dmg:1,chase:7,label:'Armored Construct'},
  instrument:{hp:2,speed:95,r:11,dmg:1,chase:6,label:'Broken Instrument'},
  sentinel:{hp:4,speed:0,r:12,dmg:1,chase:9,label:'Rusted Sentinel'},
  gs:{hp:24,speed:45,r:30,dmg:2,boss:true,label:'Gallery Sentinel'},
  sg:{hp:30,speed:70,r:22,dmg:2,boss:true,label:'Spire Guardian'},
  ch:{hp:64,speed:28,r:34,dmg:2,boss:true,label:'Corrosion Heart'}
};

/* ---------------- STATE ---------------- */
const S={
  zi:0,weapon:0,gold:0,
  owned:{band:false,lantern:false,cloak:false},
  equip:{band:false,lantern:false,cloak:false},
  stems:0,seals:0,stemsBought:0,sealsBought:0,heartBought:false,
  shard:false,charges:0,
  checkpoint:{zi:0,x:0,y:0},
  started:false,paused:false,dead:false,won:false,flash:0,shake:0,shimmerT:0,
  time:0,kills:0,bossesDown:0,ammoUnlocked:false,
  ring:0
};
const keys={};
const P={x:0,y:0,facing:{x:0,y:1},hp:6,maxHp:6,inv:0,cdT:0,walkT:0,atk:{on:false,el:0,dur:0,wind:0,hit:false,cd:0},shardCd:0,auraT:0,frozenSelf:0,lastStep:0,trapCd:0};
let Z=[];   // runtime zones
let parts=[],coins=[],projs=[],popups=[],motes=[];
let curZone=null;
let mouseSpawnTimer=0;

function toast(msg,ms){
  const t=$('toast');t.textContent=msg;t.classList.add('on');
  clearTimeout(toast._h);toast._h=setTimeout(()=>t.classList.remove('on'),ms||2600);
}
function shimmer(){S.shimmerT=2;$('shim').classList.add('on');clearTimeout(shimmer._h);shimmer._h=setTimeout(()=>$('shim').classList.remove('on'),2000);SFX.shimmer();}
function popup(x,y,txt,col){popups.push({x,y,txt,col:col||'#ffb0a0',life:1.2,max:1.2});}

/* ---------------- ZONE RUNTIME ---------------- */
function buildZone(i){
  const d=ZDEF[i];
  let rows=d.map.map(r=>r.split(''));
  const W=Math.max(...rows.map(r=>r.length));
  rows=rows.map(r=>{while(r.length<W)r.push('#');return r;});
  const z={i,def:d,grid:rows,w:W,h:rows.length,enemies:[],lit:0,pAt:null,pBt:null,doorOpen:false,bossDead:false,contents:{},visited:false,
    chestQ:[...d.chests],wallQ:[...d.walls],jarQ:[...d.jars],spawnsDone:false,wallHp:{},acc:0};
  for(let y=0;y<z.h;y++)for(let x=0;x<z.w;x++){
    const c=rows[y][x];
    const k=x+','+y;
    if(c==='X'){z.contents[k]=z.chestQ.shift()||'gold';}
    else if(c==='c'){z.contents[k]=z.wallQ.shift()||'gold';z.wallHp[k]=6;}
    else if(c==='j'){z.contents[k]=z.jarQ.shift()||'gold';}
    else if(c==='e'){rows[y][x]='.';z.enemies.push(mkEnemy(d.enemies[ri(0,d.enemies.length-1)],x,y));}
    else if(c==='v'){rows[y][x]='.';z.enemies.push(mkEnemy('sentinel',x,y));}
    else if(c==='Z'){rows[y][x]='.';z.enemies.push(mkEnemy(d.bossType,x,y));}
    else if(c==='A'){z.contents[k]=d.altar||1;}
    else if(c==='M'){}
  }
  // chest/jar fallback loads
  return z;
}
function mkEnemy(type,tx,ty){
  const s=ENEMY[type];
  return {type,x:tx*TS+TS/2,y:ty*TS+TS/2,hp:s.hp,maxHp:s.hp,r:s.r,flash:0,burn:0,burnT:0,frozen:0,kx:0,ky:0,cool:rnd(0,1),t:rnd(0,3),wx:1,wy:0,wT:0,dead:false,phase:0,charge:0,boss:!!s.boss,label:s.label,spawnT:0};
}
function ensureZones(){ if(!Z.length) Z=ZDEF.map((_,i)=>null); }
function loadZone(i,mode){
  if(!Z[i]) Z[i]=buildZone(i);
  curZone=Z[i];
  S.zi=i;
  projs=[];coins=[];popups=[];
  const z=curZone;
  z.visited=true;
  if(mode==='fromPrev'){ const p=findTile('B'); P.x=(p.x+1.2)*TS; P.y=(p.y+0.5)*TS; }
  else if(mode==='fromNext'){ const p=findTile('N'); P.x=(p.x-1.2)*TS; P.y=(p.y+0.5)*TS; }
  else if(mode==='start'){ P.x=(3.5)*TS; P.y=(3)*TS; }
  else if(mode&&mode.x!==undefined){ P.x=mode.x; P.y=mode.y; }
  P.facing={x:1,y:0};
  toast(z.def.name+(z.def.sub?' — '+z.def.sub:''),3000);
  SFX.stone();
  function d(){return z.def;}
}
function findTile(ch){
  for(let y=0;y<curZone.h;y++)for(let x=0;x<curZone.w;x++)if(curZone.grid[y][x]===ch)return {x,y};
  return {x:1,y:9};
}
const SOLID=new Set(['#','c','D','S','M','A','X','j','T','O']);
function gateOpen(z){
  const d=z.def;
  if(d.gate==='clear')return z.enemies.length===0;
  if(d.gate==='boss')return z.bossDead;
  if(d.gate==='lit')return z.lit>=d.litNeed;
  if(d.gate==='none')return false;
  return true;
}
function solidAt(tx,ty){
  const z=curZone;
  if(tx<0||ty<0||tx>=z.w||ty>=z.h)return true;
  const c=z.grid[ty][tx];
  if(c==='N')return !gateOpen(z);
  if(c==='D')return !z.doorOpen;
  if(c==='c')return true;
  return SOLID.has(c);
}
function blocked(x,y,r){
  const x0=Math.floor((x-r)/TS),x1=Math.floor((x+r-0.01)/TS);
  const y0=Math.floor((y-r)/TS),y1=Math.floor((y+r-0.01)/TS);
  for(let ty=y0;ty<=y1;ty++)for(let tx=x0;tx<=x1;tx++)if(solidAt(tx,ty))return true;
  return false;
}
function moveEnt(en,dx,dy,r){
  en.x+=dx; if(blocked(en.x,en.y,r))en.x-=dx;
  en.y+=dy; if(blocked(en.x,en.y,r))en.y-=dy;
}
function tileAt(x,y){return curZone.grid[Math.floor(y/TS)]?.[Math.floor(x/TS)];}
function setTile(tx,ty,c){curZone.grid[ty][tx]=c;}

/* ---------------- ITEMS / PROGRESSION ---------------- */
function addContainer(){
  if(P.maxHp>=10){ heal(2); return; }
  P.maxHp+=2;P.hp+=2;
  P.auraT=2.2;
  burst(P.x,P.y,30,'#ffd65a',160,0.9,3,-40);
  shimmer();SFX.rare();
  toast('Heart Container acquired! ('+P.maxHp/2+' containers)',3400);
}
function heal(n){
  const before=P.hp;P.hp=Math.min(P.maxHp,P.hp+n);
  if(P.hp>before){popup(P.x,P.y-24,'+'+(P.hp-before),'#8dffb0');burst(P.x,P.y,14,'#8dffb0',90,0.7,3,-30);SFX.heal();}
}
function grant(item){
  const it=ITEMS[item];if(!it)return;
  if(it.kind==='gold'){S.gold+=it.val;SFX.coin();popup(P.x,P.y-20,'+'+it.val+' gold','#ffd65a');
    burst(P.x,P.y,18,'#ffd65a',120,0.8,2,-10);return;}
  if(it.kind==='heart'){addContainer();return;}
  if(it.kind==='shard'){
    S.shard=true;S.charges+=8;SFX.rare();shimmer();
    burst(P.x,P.y,30,'#9fe6ff',140,1,3,-30);
    toast('Aether Shard acquired! Press K to fire.',3600);return;}
  if(it.kind==='acc'){
    S.owned[item]=true;S.equip[item]=true;SFX.rare();
    burst(P.x,P.y,30,'#ffffff',120,1.1,3,-40);
    motes.push({x:P.x,y:P.y});
    toast(it.name+' acquired and equipped. (I to manage)',3400);return;}
  if(item==='stem'){S.stems++;SFX.pick();popup(P.x,P.y-20,'Vital Stem','#8dffb0');burst(P.x,P.y,14,'#8dffb0',90,0.7,3,-30);toast('Vital Stem found (Q to use)');return;}
  if(item==='seal'){S.seals++;SFX.pick();popup(P.x,P.y-20,'Binding Seal','#8fd0ff');burst(P.x,P.y,14,'#8fd0ff',90,0.7,3,-30);toast('Binding Seal found (R to use)');return;}
  if(item==='cloak'||item==='lantern'){}
}
function grantAltar(w){
  if(S.weapon>=w){toast('The altar hums, but your weapon is already attuned.');return;}
  S.weapon=w;SFX.altar();shimmer();
  burst(P.x,P.y,40,w===1?'#ff8a3a':'#ffd65a',180,1.2,3,-20);
  toast(w===1?'Ignited Edge! Your blade now burns.':'Solar Falchion! A wide golden arc.',4000);
}
function gainCoins(x,y,total){
  const n=total>=12?3:2;
  for(let i=0;i<n;i++){coins.push({x:x+rnd(-8,8),y:y+rnd(-8,8),val:Math.max(1,Math.round(total/n)),t:rnd(0,1)});}
}

/* ---------------- PARTICLES ---------------- */
function burst(x,y,n,col,spd,life,size,grav){
  for(let i=0;i<n;i++){
    const a=rnd(0,Math.PI*2),s=rnd(0.2,1)*(spd||80);
    parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:life||0.5,max:life||0.5,col:col||'#fff',s:(size||3)*rnd(0.5,1.2),g:grav||0});
  }
}
function ember(x,y){
  parts.push({x:x+rnd(-4,4),y:y+rnd(-4,4),vx:rnd(-12,12),vy:rnd(-30,-10),life:0.7,max:0.7,col:Math.random()<0.5?'#ff9a3a':'#ffd06a',s:rnd(1.5,3),g:-10});
}
function smoke(x,y){parts.push({x,y,vx:rnd(-8,8),vy:rnd(-20,-8),life:0.9,max:0.9,col:'rgba(90,60,40,0.6)',s:rnd(4,7),g:-5,smoke:true});}
function updParts(dt){
  for(const p of parts){p.life-=dt;p.x+=p.vx*dt;p.y+=p.vy*dt;p.vy+=p.g*dt;p.vx*=0.96;p.vy*=0.96;}
  parts=parts.filter(p=>p.life>0);
  if(parts.length>500)parts.splice(0,parts.length-500);
  for(const p of popups)p.life-=dt,p.y-=24*dt;
  popups=popups.filter(p=>p.life>0);
}
function ambient(dt){
  const z=curZone;if(!z)return;
  const cam=camera();
  z.acc+=dt;
  if(z.acc<0.12)return;z.acc=0;
  const zi=S.zi;
  const x=cam.x+rnd(0,VW),y=cam.y+rnd(0,VH);
  if(zi===1||zi===0){ parts.push({x,y,vx:rnd(-6,6),vy:rnd(-4,4),life:3,max:3,col:'rgba(230,200,150,0.5)',s:1.5,g:0}); }
  else if(zi===3){ parts.push({x,y,vx:rnd(-10,10),vy:rnd(-3,3),life:4,max:4,col:'rgba(160,210,230,0.18)',s:rnd(8,14),g:0,smoke:true}); }
  else if(zi===2||zi===4){ if(Math.random()<0.5)parts.push({x,y,vx:rnd(-4,4),vy:rnd(-12,-4),life:2.2,max:2.2,col:'rgba(255,220,120,0.6)',s:1.5,g:0}); }
  else if(zi===5){ if(Math.random()<0.7)parts.push({x,y:cam.y+VH,vx:rnd(-6,6),vy:rnd(-40,-18),life:2.5,max:2.5,col:'#ff9a3a',s:2,g:0}); }
}
function camera(){
  const z=curZone;if(!z)return {x:0,y:0};
  const cx=clamp(P.x-VW/2,0,Math.max(0,z.w*TS-VW));
  const cy=clamp(P.y-VH/2,0,Math.max(0,z.h*TS-VH));
  return {x:cx,y:cy};
}
window.__camera=camera;

/* ---------------- INPUT / PANELS ---------------- */
let panelOpen=null;
const PANEL=$('panel'),PTITLE=$('panelTitle'),PBODY=$('panelBody');
function openPanel(kind,title,html){panelOpen=kind;PTITLE.textContent=title;PBODY.innerHTML=html;PANEL.classList.remove('hidden');}
function closePanel(){panelOpen=null;PANEL.classList.add('hidden');}
$('panelClose').addEventListener('click',()=>{closePanel();});
PBODY.addEventListener('click',e=>{
  const b=e.target.closest('button[data-act]');if(!b||b.disabled)return;
  const a=b.dataset.act;
  if(a==='buy')buy(b.dataset.arg);
  else if(a==='equip')toggleEquip(b.dataset.arg);
  else if(a==='useStem')useStem(true);
  else if(a==='useSeal')useSeal(true);
  else if(a==='restart')location.reload();
});
window.addEventListener('keydown',e=>{
  if(['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code))e.preventDefault();
  keys[e.code]=true;
  if(!S.started||e.repeat)return;
  if(panelOpen){
    if(panelOpen!=='victory'&&(e.code==='Escape'||e.code==='KeyI'||e.code==='KeyE'))closePanel();
    return;
  }
  if(S.dead||S.won)return;
  switch(e.code){
    case 'Space':case 'KeyJ':startAttack();break;
    case 'KeyK':fireShard();break;
    case 'KeyE':interact();break;
    case 'KeyQ':useStem(false);break;
    case 'KeyR':useSeal(false);break;
    case 'KeyI':openInventory();break;
  }
});
window.addEventListener('keyup',e=>{keys[e.code]=false;});

function useStem(fromPanel){
  if(S.stems<=0){toast('No Vital Stems.');return;}
  if(P.hp>=P.maxHp){toast('Your health is already full.');return;}
  S.stems--;heal(4);toast('Vital Stem consumed: +2 containers restored.');
  if(panelOpen==='inv')openInventory();
}
function useSeal(fromPanel){
  if(S.seals<=0){toast('No Binding Seals.');return;}
  let best=null,bd=1e9;
  for(const en of curZone.enemies){if(en.dead)continue;const d=Math.hypot(en.x-P.x,en.y-P.y);if(d<260&&d<bd){bd=d;best=en;}}
  if(!best){toast('No enemy in range to bind.');return;}
  S.seals--;best.frozen=3;SFX.freeze();
  burst(best.x,best.y,20,'#8fd0ff',110,0.9,3,0);
  toast('Binding Seal: '+best.label+' frozen for 3 seconds.');
  if(panelOpen==='inv')openInventory();
}
function toggleEquip(k){
  if(!S.owned[k])return;
  S.equip[k]=!S.equip[k];SFX.click();
  openInventory();
}

/* ---------------- ZONE FLOW ---------------- */
function goZone(i,mode){
  if(mode==='fromPrev')S.zoneDone[curZone.i]=true;
  loadZone(i,mode);
}
const ROOM=[];
S.zoneDone=[false,false,false,false,false,false];

/* ---------------- PLAYER ACTIONS ---------------- */
function startAttack(){
  if(P.atk.on||P.cdT>0||P.frozenSelf>0)return;
  const w=WEAPONS[S.weapon];
  const band=S.equip.band?0.8:1;
  P.atk={on:true,t:0,wind:w.wind,total:w.wind+w.dur,hit:false,ang:Math.atan2(P.facing.y,P.facing.x)};
  P.cdT=w.cd*band;
  SFX.swing(S.weapon);
}
function meleeHit(){
  const z=curZone,w=WEAPONS[S.weapon];
  let any=false;
  const half=w.arc*Math.PI/360;
  for(const en of z.enemies){
    if(en.dead)continue;
    const dx=en.x-P.x,dy=en.y-P.y,d=Math.hypot(dx,dy);
    if(d>w.range+en.r)continue;
    let a=Math.atan2(dy,dx)-P.atk.ang;
    while(a>Math.PI)a-=2*Math.PI;while(a<-Math.PI)a+=2*Math.PI;
    if(Math.abs(a)>half+0.25&&d>en.r+6)continue;
    any=true;
    const kx=dx/(d||1)*140,ky=dy/(d||1)*140;
    hurtEnemy(en,w.dmg,kx,ky,S.weapon>=1);
    if(S.weapon===2)shimmerSmall();
  }
  // breakables along facing
  const fx=P.facing.x,fy=P.facing.y;
  const seen=new Set();
  for(const dist of [22,36]){
    const tx=Math.floor((P.x+fx*dist)/TS),ty=Math.floor((P.y+fy*dist)/TS);
    const k=tx+','+ty;if(seen.has(k))continue;seen.add(k);
    const c=z.grid[ty]?.[tx];
    if(c==='c'){
      any=true;
      z.wallHp[k]=(z.wallHp[k]??6)-w.dmg;
      burst(tx*TS+16,ty*TS+16,6,'#c9b38a',60,0.4,2,40);
      if(z.wallHp[k]<=0)breakWall(tx,ty);
      else SFX.hit();
    }else if(c==='j'){
      any=true;breakJar(tx,ty);
    }
  }
  if(any){
    if(S.weapon===0)SFX.hit();
  }
}
function shimmerSmall(){ /* placeholder for falchion sheen */ }
function breakWall(tx,ty){
  const z=curZone,k=tx+','+ty;
  setTile(tx,ty,'.');
  const item=z.contents[k]||'gold';
  SFX.shatter();burst(tx*TS+16,ty*TS+16,28,'#d8c39a',150,0.8,3,120);
  popup(tx*TS+16,ty*TS-6,'Breakable wall!','#ffe2a8');
  grant(item);
}
function breakJar(tx,ty){
  const z=curZone,k=tx+','+ty;
  setTile(tx,ty,'.');
  SFX.pick();burst(tx*TS+16,ty*TS+16,16,'#a06a3a',100,0.6,3,80);
  grant(z.contents[k]||'gold');
}
function fireShard(){
  if(!S.shard){toast('You have no ranged weapon yet.');return;}
  if(S.charges<=0){toast('The Aether Shard is empty. Restock at the merchant (10 gold each).');return;}
  if(P.shardCd>0)return;
  P.shardCd=0.45;S.charges--;
  const a=P.facing;
  projs.push({x:P.x+a.x*16,y:P.y+a.y*16,vx:a.x*460,vy:a.y*460,life:1.1,type:'p',dmg:3});
  SFX.bolt();
  burst(P.x+a.x*16,P.y+a.y*16,6,'#9fe6ff',60,0.3,2,0);
}
function interact(){
  const z=curZone;
  let best=null,bd=1e9;
  const ptx=Math.floor(P.x/TS),pty=Math.floor(P.y/TS);
  for(let dy=-2;dy<=2;dy++)for(let dx=-2;dx<=2;dx++){
    const tx=ptx+dx,ty=pty+dy;const c=z.grid[ty]?.[tx];
    if(!c||!'TMAXjO'.includes(c))continue;
    const d=Math.hypot(tx*TS+16-P.x,ty*TS+16-P.y);
    if(d<56&&d<bd){bd=d;best={c,tx,ty};}
  }
  if(!best){toast('Nothing to interact with here.');return;}
  const k=best.tx+','+best.ty;
  if(best.c==='T'){
    S.checkpoint={zi:S.zi,x:P.x,y:P.y};
    P.hp=P.maxHp;SFX.stone();shimmer();
    burst(P.x,P.y,30,'#ffd65a',120,1.1,2,-20);
    toast('Sanctuary Stone awakened. Progress saved and wounds mended.',3600);
  }else if(best.c==='M'){openShop();}
  else if(best.c==='A'){grantAltar(z.contents[k]||1);}
  else if(best.c==='X'){
    const item=z.contents[k]||'gold';
    setTile(best.tx,best.ty,'.');
    grant(item);
  }else if(best.c==='j'){breakJar(best.tx,best.ty);}
  else if(best.c==='O'){
    if(z.bossDead)winGame();
    else toast('The Beacon stirs but cannot ignite while the Corrosion Heart lives.');
  }
}
function winGame(){
  if(S.won)return;
  S.won=true;SFX.victory();shimmer();
  const t=Math.floor(S.time);
  openPanel('victory','The Beacon Ignites',
    '<p>Light pours through the cracked dome. The Corrosion recedes into the storm, and Aurelion\'s Observatory begins to sing again.</p>'+
    '<p class="small">Time: '+Math.floor(t/60)+'m '+(t%60)+'s &nbsp;·&nbsp; Constructs felled: '+S.kills+' &nbsp;·&nbsp; Containers: '+(P.maxHp/2)+' &nbsp;·&nbsp; Weapon: '+WEAPONS[S.weapon].name+'</p>'+
    '<div style="text-align:right"><button data-act="restart">Begin Anew</button></div>');
}
function die(){
  if(S.dead)return;
  S.dead=true;SFX.death();
  $('death').classList.add('on');
  setTimeout(()=>{
    const c=S.checkpoint;
    loadZone(c.zi,{x:c.x,y:c.y});
    P.hp=P.maxHp;P.inv=1.5;P.atk={on:false};P.cdT=0;
    projs=[];
    S.dead=false;
    $('death').classList.remove('on');
    toast('You awaken at the last Sanctuary Stone.',3000);
  },1400);
}
function hurtPlayer(n,trap){
  if(P.inv>0||S.dead||S.won||!S.started)return;
  if(trap&&S.equip.cloak)n=Math.max(1,n-1);
  P.hp=Math.max(0,P.hp-n);P.inv=1.0;P.flash=0.4;S.shake=0.35;
  burst(P.x,P.y,22,'#ff3a3a',130,0.6,3,0);
  popup(P.x,P.y-26,'-'+n,'#ff6a5a');
  SFX.hurt();
  if(P.hp<=0)die();
}

/* ---------------- SHOP ---------------- */
function openShop(){
  const sh=[
    {id:'stem',label:'Vital Stem',desc:'Restores 2 containers (4 HP).',cost:50,left:3-S.stemsBought,ok:S.stemsBought<3},
    {id:'seal',label:'Binding Seal',desc:'Freezes one enemy for 3 seconds.',cost:75,left:2-S.sealsBought,ok:S.sealsBought<2},
    {id:'heart',label:'Heart Container #5',desc:'Adds a 5th container (+2 HP max).',cost:300,left:(!S.heartBought&&P.maxHp<10)?1:0,ok:!S.heartBought&&P.maxHp<10},
    {id:'ammo',label:'Aether Shard Charge',desc:'Restock 1 charge (10 gold each). Unlocked after the Sunken Vault.',cost:10,left:'∞',ok:S.shard&&S.zoneDone[3]}
  ];
  let h='<p class="small">Gold: <b style="color:#ffd65a">'+S.gold+'</b> &nbsp;·&nbsp; Ammo: '+(S.shard?S.charges:'—')+'</p>';
  for(const it of sh){
    const can=it.ok&&S.gold>=it.cost;
    h+='<div class="row"><div><b>'+it.label+'</b> — '+it.cost+' gold<br><span class="small">'+it.desc+(it.id==='heart'&&S.heartBought?' (Purchased)':'')+'</span></div>'+
      '<button data-act="buy" data-arg="'+it.id+'"'+(can?'':' disabled')+'>'+(it.ok?'Buy':'Unavailable')+'</button></div>';
  }
  if(!S.shard)h+='<p class="small">The merchant has no Aether Shard for sale.</p>';
  openPanel('shop','Merchant Altar',h);
  SFX.click();
}
function buy(id){
  const costs={stem:50,seal:75,heart:300,ammo:10};
  const c=costs[id];
  if(S.gold<c){toast('Not enough gold.');return;}
  if(id==='stem'){if(S.stemsBought>=3)return;S.stemsBought++;S.gold-=c;S.stems++;}
  else if(id==='seal'){if(S.sealsBought>=2)return;S.sealsBought++;S.gold-=c;S.seals++;}
  else if(id==='heart'){if(S.heartBought||P.maxHp>=10)return;S.heartBought=true;S.gold-=c;addContainer();}
  else if(id==='ammo'){if(!S.shard||!S.zoneDone[3])return;S.gold-=c;S.charges++;}
  SFX.buy();
  openShop();
  if(id!=='heart')toast('Purchased.');
}
/* ---------------- WORLD UPDATE ---------------- */
function plateState(){
  const z=curZone;if(z.def.door!=='plates')return;
  let pp=false,qq=false;
  const chk=(x,y)=>{
    const c=z.grid[Math.floor(y/TS)]?.[Math.floor(x/TS)];
    return c==='P'?1:c==='Q'?2:0;
  };
  const occ=[[P.x,P.y],...z.enemies.filter(e=>!e.dead).map(e=>[e.x,e.y])];
  for(const [x,y] of occ){const c=chk(x,y);if(c===1)pp=true;if(c===2)qq=true;}
  if(pp&&z.pAt===null){z.pAt=S.time;SFX.plate();}
  if(qq&&z.pBt===null){z.pBt=S.time;SFX.plate();}
  if(!pp)z.pAt=null;if(!qq)z.pBt=null;
  // latch when both plates were pressed within 4 seconds of each other
  if(pp)z.pTouchP=S.time;
  if(qq)z.pTouchQ=S.time;
  if(!z.doorOpen&&z.pTouchP!=null&&z.pTouchQ!=null&&Math.abs(z.pTouchP-z.pTouchQ)<=4&&(pp||qq)){
    z.doorOpen=true;SFX.door();toast('The gate grinds open.',2600);
  }
}
function updateDoors(){
  const z=curZone,d=z.def;
  if(z.doorOpen)return;
  if(d.door==='lit'&&z.lit>=d.litNeed){z.doorOpen=true;SFX.door();}
  if(d.door==='boss'&&z.bossDead){z.doorOpen=true;SFX.door();}
}
function interactLight(){}
function hitSwitch(tx,ty){
  const z=curZone;
  z.grid[ty][tx]='L';z.lit++;SFX.bolt();
  burst(tx*TS+16,ty*TS+16,24,'#9fe6ff',120,0.9,3,-20);
  const d=z.def;
  if(z.lit>=d.litNeed&&z.def.gate==='lit'){
    if(!z.rewardDone){z.rewardDone=true;SFX.rare();toast('All crystal switches lit. The way forward opens.',3600);}
  }else toast('Crystal switch struck: '+z.lit+'/'+d.litNeed,2200);
}
function updatePlayer(dt){
  const z=curZone;
  let mx=0,my=0;
  if(keys.KeyW||keys.ArrowUp)my-=1;if(keys.KeyS||keys.ArrowDown)my+=1;
  if(keys.KeyA||keys.ArrowLeft)mx-=1;if(keys.KeyD||keys.ArrowRight)mx+=1;
  const moving=mx||my;
  if(moving){const l=Math.hypot(mx,my);mx/=l;my/=l;P.facing={x:mx,y:my};}
  let sp=128*(P.atk.on&&P.atk.t<P.atk.wind?0.5:P.atk.on?0.7:1);
  if(S.weapon===2&&P.atk.on)sp*=0.8;
  if(P.frozenSelf>0)sp=0;
  moveEnt(P,mx*sp*dt,0,10);
  moveEnt(P,0,my*sp*dt,10);
  if(moving){
    P.walkT+=dt;
    if(P.walkT>0.34){P.walkT=0;const zi=S.zi;if(zi===3)SFX.wadeStep();else SFX.step(zi);}
    if(S.weapon>=1&&Math.random()<0.6)ember(P.x-mx*6+rnd(-4,4),P.y-my*6+rnd(0,6));
  }
  // tile effects
  const tc=tileAt(P.x,P.y);
  if(tc==='h'&&P.trapCd<=0){P.trapCd=1.2;hurtPlayer(1,true);}
  // zone transitions
  const ptx=Math.floor(P.x/TS),pty=Math.floor(P.y/TS);
  const here=z.grid[pty]?.[ptx];
  if(here==='N'&&gateOpen(z)&&S.zi<ZDEF.length-1){
    goZone(S.zi+1,'fromPrev');return;
  }
  if(here==='B'&&S.zi>0){
    goZone(S.zi-1,'fromNext');return;
  }
  // attack phases
  if(P.atk.on){
    P.atk.t+=dt;
    if(!P.atk.hit&&P.atk.t>=P.atk.wind){P.atk.hit=true;meleeHit();if(S.weapon>=1){burst(P.x+P.facing.x*30,P.y+P.facing.y*30,8,'#ff9a3a',70,0.4,2,-20);}}
    if(S.weapon===2&&P.atk.t>=P.atk.wind&&P.atk.t<P.atk.wind+0.1)shimmerBig();
    if(P.atk.t>=P.atk.total)P.atk.on=false;
  }
  P.cdT-=dt;P.inv-=dt;P.flash-=dt;P.shardCd-=dt;P.trapCd-=dt;P.auraT-=dt;
  P.frozenSelf=Math.max(0,P.frozenSelf-dt);
  // enemies touching player
  if(P.inv<=0){
    for(const en of z.enemies){
      if(en.dead||en.frozen>0)continue;
      if(en.type==='sentinel')continue;
      if(Math.hypot(en.x-P.x,en.y-P.y)<en.r+9){hurtPlayer(en.dmg,false);break;}
    }
  }
  // coins magnetism
  for(const c of coins){
    c.t+=dt;
    const dx=P.x-c.x,dy=P.y-c.y,d=Math.hypot(dx,dy);
    if(d<60){c.x+=dx/(d||1)*260*dt;c.y+=dy/(d||1)*260*dt;}
    if(d<16){S.gold+=c.val;c.dead=true;SFX.coin();}
  }
  coins=coins.filter(c=>!c.dead);
  // plates & doors
  plateState();updateDoors();
  // hints
  hintUpdate();
}
function shimmerBig(){ shimmer(); }
function hintUpdate(){
  const z=curZone;let h='';
  const ptx=Math.floor(P.x/TS),pty=Math.floor(P.y/TS);
  for(let dy=-2;dy<=2&&!h;dy++)for(let dx=-2;dx<=2&&!h;dx++){
    const c=z.grid[pty+dy]?.[ptx+dx];
    if(c==='T')h='E: Awaken the Sanctuary Stone';
    else if(c==='M')h='E: Trade with the Merchant';
    else if(c==='A')h='E: Attune the altar';
    else if(c==='X')h='E: Open chest';
    else if(c==='j')h='Strike the jar';
    else if(c==='O')h=z.bossDead?'E: Ignite the Core Beacon':'The Beacon is sealed by the Corrosion Heart';
  }
  $('msg').textContent=h||'';
}
function updateEnemies(dt){
  const z=curZone;
  for(const en of z.enemies){
    if(en.dead)continue;
    en.flash-=dt;
    if(en.burn>0){en.burn-=dt;en.burnT-=dt;if(en.burnT<=0){en.burnT=0.5;applyDamage(en,1,false);if(en.dead)break;}}
    if(en.kx||en.ky){moveEnt(en,en.kx*dt,en.ky*dt,en.r);en.kx*=0.85;en.ky*=0.85;if(Math.abs(en.kx)<4)en.kx=0;if(Math.abs(en.ky)<4)en.ky=0;}
    if(en.frozen>0){en.frozen-=dt;continue;}
    const dx=P.x-en.x,dy=P.y-en.y,d=Math.hypot(dx,dy)||1;
    const ux=dx/d,uy=dy/d;
    const def=ENEMY[en.type];
    en.t+=dt;
    if(en.type==='armor'){
      if(d<def.chase*TS){moveEnt(en,ux*def.speed*dt,0,en.r);moveEnt(en,0,uy*def.speed*dt,en.r);}
      else wander(en,dt,def.speed*0.4);
    }else if(en.type==='instrument'){
      if(d<def.chase*TS){
        const wob=Math.sin(en.t*5)*40;
        moveEnt(en,(ux*def.speed-uy*wob)*dt,0,en.r);moveEnt(en,0,(uy*def.speed+ux*wob)*dt,en.r);
      }else wander(en,dt,def.speed*0.4);
    }else if(en.type==='sentinel'){
      en.cool-=dt;
      if(en.cool<=0&&d<300){en.cool=2.2;fireAt(en.x,en.y,ux,uy,190,1);}
    }else if(en.type==='gs'||en.type==='ch'||en.type==='sg'){
      bossAI(en,dt,ux,uy,d);
    }
  }
  for(const en of z.enemies)if(en.dead&&!en.gone){en.gone=true;}
  z.enemies=z.enemies.filter(e=>!e.gone);
}
function wander(en,dt,sp){
  if(en.wT<=0){en.wT=rnd(0.8,2);const a=rnd(0,Math.PI*2);en.wx=Math.cos(a);en.wy=Math.sin(a);}
  en.wT-=dt;
  moveEnt(en,en.wx*sp*dt,0,en.r);moveEnt(en,0,en.wy*sp*dt,en.r);
}
function bossAI(en,dt,ux,uy,d){
  if(en.type==='gs'){
    if(en.phase===0){
      en.cool-=dt;
      moveEnt(en,ux*45*dt,0,en.r);moveEnt(en,0,uy*45*dt,en.r);
      if(en.cool<=0){en.phase=1;en.t=0.9;en.tx=ux;en.ty=uy;}
    }else if(en.phase===1){
      en.t-=dt;en.flash=0.1;
      if(en.t<=0){en.phase=2;en.t=0.5;SFX.hit();}
    }else{
      en.t-=dt;moveEnt(en,en.tx*280*dt,0,en.r);moveEnt(en,0,en.ty*280*dt,en.r);
      if(Math.random()<0.5)smoke(en.x,en.y);
      if(en.t<=0){en.phase=0;en.cool=2.6;}
    }
  }else if(en.type==='sg'){
    en.cool-=dt;
    moveEnt(en,ux*70*dt,0,en.r);moveEnt(en,0,uy*70*dt,en.r);
    if(en.cool<=0){
      en.cool=2.0;
      for(let k=-2;k<=2;k++){const a=Math.atan2(uy,ux)+k*0.22;fireAt(en.x,en.y,Math.cos(a),Math.sin(a),200,1);}
      SFX.enemyShot();
    }
  }else if(en.type==='ch'){
    en.cool-=dt;
    moveEnt(en,ux*30*dt,0,en.r);moveEnt(en,0,uy*30*dt,en.r);
    if(en.cool<=0){
      en.cool=2.8;
      const n=8,off=rnd(0,1);
      for(let k=0;k<n;k++){const a=k/n*Math.PI*2+off;fireAt(en.x,en.y,Math.cos(a),Math.sin(a),150,1);}
      SFX.enemyShot();
    }
  }
}
function fireAt(x,y,ux,uy,sp,dmg){
  projs.push({x,y,vx:ux*sp,vy:uy*sp,life:3,type:'e',dmg});
}
function applyDamage(en,dmg,fire){
  en.hp-=dmg;
  if(en.hp<=0&&!en.dead){killEnemy(en);}
}
function hurtEnemy(en,dmg,kx,ky,fire){
  if(en.dead)return;
  en.hp-=dmg;en.flash=0.15;en.kx=kx;en.ky=ky;
  if(fire){en.burn=2.0;en.burnT=0.5;burst(en.x,en.y,10,'#ff9a3a',110,0.5,2.4,-30);smoke(en.x,en.y);SFX.burn();}
  else{burst(en.x,en.y,8,'#ffb040',120,0.35,2,0);SFX.hit();}
  if(en.boss&&en.type==='gs')SFX.hit();
  popup(en.x,en.y-en.r-8,'-'+dmg,'#ffd08a');
  if(en.hp<=0)killEnemy(en);
}
function killEnemy(en){
  if(en.dead)return;
  en.dead=true;S.kills++;SFX.shatter();
  burst(en.x,en.y,22,'#ff8a3a',150,0.8,3,40);
  gainCoins(en.x,en.y,ri(5,15));
  if(en.boss){
    curZone.bossDead=true;S.bossesDown++;
    toast(en.label+' is undone. The way forward opens.',3600);
    shimmer();
  }
}
function updateProjs(dt){
  const z=curZone;
  for(const p of projs){
    p.life-=dt;
    p.x+=p.vx*dt;p.y+=p.vy*dt;
    if(p.life<=0){p.dead=true;continue;}
    const tx=Math.floor(p.x/TS),ty=Math.floor(p.y/TS);
    const c=z.grid[ty]?.[tx];
    if(p.type==='p'){
      if(c==='S'){hitSwitch(tx,ty);p.dead=true;continue;}
      if(c==='c'){
        const k=tx+','+ty;z.wallHp[k]=(z.wallHp[k]??6)-p.dmg;
        burst(p.x,p.y,6,'#9fe6ff',60,0.3,2,0);
        if(z.wallHp[k]<=0)breakWall(tx,ty);
        p.dead=true;continue;
      }
      if(c===undefined||SOLID.has(c)){burst(p.x,p.y,6,'#9fe6ff',60,0.3,2,0);p.dead=true;continue;}
      for(const en of z.enemies){
        if(en.dead)continue;
        if(Math.hypot(en.x-p.x,en.y-p.y)<en.r+4){hurtEnemy(en,p.dmg,p.vx*0.2,p.vy*0.2,false);p.dead=true;break;}
      }
    }else{
      if(c===undefined||SOLID.has(c)){p.dead=true;continue;}
      if(Math.hypot(P.x-p.x,P.y-p.y)<12){p.dead=true;hurtPlayer(p.dmg,false);}
    }
  }
  projs=projs.filter(p=>!p.dead);
}
function updateSystems(dt){
  updParts(dt);
  ambient(dt);
  S.time+=dt;
}

/* ---------------- RENDER ---------------- */
const TILE_CACHE={};
function shade(hex,amt){
  const n=parseInt(hex.slice(1),16);
  let r=(n>>16)+amt,gg=((n>>8)&255)+amt,b=(n&255)+amt;
  r=clamp(r,0,255);gg=clamp(gg,0,255);b=clamp(b,0,255);
  return 'rgb('+r+','+gg+','+b+')';
}
function drawTiles(cam){
  const z=curZone,pal=z.def.pal;
  const x0=Math.max(0,Math.floor(cam.x/TS)),x1=Math.min(z.w-1,Math.floor((cam.x+VW)/TS));
  const y0=Math.max(0,Math.floor(cam.y/TS)),y1=Math.min(z.h-1,Math.floor((cam.y+VH)/TS));
  for(let ty=y0;ty<=y1;ty++)for(let tx=x0;tx<=x1;tx++){
    const c=z.grid[ty][tx];
    const sx=tx*TS-cam.x,sy=ty*TS-cam.y;
    const flick=((tx*7+ty*13)%5);
    if(c==='#'){
      g.fillStyle=shade(pal[2],-6+flick*2);g.fillRect(sx,sy,TS,TS);
      g.fillStyle='rgba(0,0,0,0.25)';g.fillRect(sx,sy+TS-4,TS,4);
      g.fillStyle='rgba(120,160,190,0.06)';g.fillRect(sx+2,sy+2,TS-4,2);
      if((tx+ty)%3===0){g.fillStyle='rgba(120,140,80,0.25)';g.fillRect(sx+6,sy+10,6,3);}
    }else{
      g.fillStyle=shade(pal[0],(flick-2)*2);g.fillRect(sx,sy,TS,TS);
      g.fillStyle='rgba(0,0,0,0.18)';g.fillRect(sx,sy,TS,1);g.fillRect(sx,sy,1,TS);
      if((tx*3+ty*5)%7===0){g.fillStyle=pal[1];g.fillRect(sx+8,sy+8,10,10);}
      if(c==='B'||c==='N'){
        g.fillStyle=c==='N'?(gateOpen(z)?'rgba(255,200,90,0.5)':'rgba(120,40,30,0.7)'):'rgba(90,120,160,0.4)';
        g.fillRect(sx,sy,TS,TS);
        if(c==='N'&&!gateOpen(z)){g.fillStyle='#8a3a2a';g.fillRect(sx+6,sy+6,20,20);}
      }
    }
    if(c==='c'){
      const k=tx+','+ty;
      g.strokeStyle='#2a1c10';g.lineWidth=1;g.beginPath();
      g.moveTo(sx+4,sy+6);g.lineTo(sx+14,sy+14);g.lineTo(sx+12,sy+24);
      g.moveTo(sx+14,sy+14);g.lineTo(sx+26,sy+10);g.stroke();
      if(z.wallHp[k]<6){g.fillStyle='rgba(0,0,0,0.4)';g.fillRect(sx+2,sy+2,TS-4,TS-4);}
    }
    if(c==='S'){drawCrystal(sx+16,sy+16,'#9fe6ff',false);}
    if(c==='L'){drawCrystal(sx+16,sy+16,'#ffffff',true);}
    if(c==='D'){
      g.fillStyle=z.doorOpen?'rgba(60,40,20,0.3)':'#5a3a20';g.fillRect(sx,sy,TS,TS);
      g.fillStyle='#8a6030';g.fillRect(sx+2,sy,4,TS);g.fillRect(sx+TS-6,sy,4,TS);
      if(!z.doorOpen){g.fillStyle='#2a1a0a';for(let i=0;i<4;i++)g.fillRect(sx+4,sy+4+i*7,TS-8,2);}
    }
    if(c==='P'||c==='Q'){
      const pressed=(c==='P'?z.pAt!==null:z.pBt!==null);
      g.fillStyle=pressed?'#6a4a20':'#a8762f';g.fillRect(sx+2,sy+2+(pressed?2:0),TS-4,TS-4);
      g.strokeStyle='#e0b060';g.strokeRect(sx+5,sy+5,TS-10,TS-10);
    }
    if(c==='h'){
      g.fillStyle='rgba(60,20,40,0.5)';g.fillRect(sx,sy,TS,TS);
      g.fillStyle='rgba(255,80,50,0.4)';
      const t=S.time*2+tx;g.fillRect(sx+8+Math.sin(t)*3,sy+12,6,6);
    }
    if(c==='T'){
      g.fillStyle='rgba(255,220,130,0.16)';g.fillRect(sx-8,sy-8,TS+16,TS+16);
      g.fillStyle='#7a6a54';g.fillRect(sx+4,sy+18,24,10);
      g.fillStyle='#b0a080';g.beginPath();g.ellipse(sx+16,sy+14,11,9,0,0,Math.PI*2);g.fill();
      g.fillStyle='rgba(255,215,90,'+(0.5+0.4*Math.sin(S.time*3))+')';g.beginPath();g.arc(sx+16,sy+12,4,0,Math.PI*2);g.fill();
    }
    if(c==='M'){
      g.fillStyle='#5a4028';g.fillRect(sx+4,sy+8,24,22);
      g.fillStyle='#e0a050';g.beginPath();g.arc(sx+16,sy+8,6,0,Math.PI*2);g.fill();
      g.fillStyle='rgba(120,160,255,'+(0.3+0.2*Math.sin(S.time*2))+')';g.fillRect(sx+2,sy+2,28,28);
    }
    if(c==='A'){
      g.fillStyle='#4a3420';g.fillRect(sx+4,sy+10,24,20);
      const lit=S.weapon>=(z.contents[tx+','+ty]||1);
      g.fillStyle=lit?'rgba(255,190,70,'+(0.6+0.3*Math.sin(S.time*4))+')':'#8a6a3a';
      g.beginPath();g.arc(sx+16,sy+10,7,0,Math.PI*2);g.fill();
    }
    if(c==='X'){
      g.fillStyle='#6a4520';g.fillRect(sx+4,sy+10,24,18);
      g.fillStyle='#c09040';g.fillRect(sx+4,sy+10,24,4);g.fillRect(sx+15,sy+14,2,8);
      g.fillStyle='rgba(255,220,120,'+(0.2+0.15*Math.sin(S.time*3+tx))+')';g.fillRect(sx+6,sy+6,20,6);
    }
    if(c==='j'){
      g.fillStyle='#7a4a2a';g.beginPath();g.ellipse(sx+16,sy+18,10,12,0,0,Math.PI*2);g.fill();
      g.fillStyle='#4a2a14';g.fillRect(sx+10,sy+6,12,4);
    }
    if(c==='O'){
      const pulse=z.bossDead?1:0.3+0.1*Math.sin(S.time*2);
      g.fillStyle='rgba(255,140,60,'+pulse+')';g.beginPath();g.arc(sx+16,sy+16,22,0,Math.PI*2);g.fill();
      g.fillStyle='#e0c090';g.beginPath();g.arc(sx+16,sy+16,8,0,Math.PI*2);g.fill();
    }
  }
}
function drawCrystal(x,y,col,lit){
  const p=0.6+0.4*Math.sin(S.time*4+x);
  g.fillStyle='rgba(60,40,30,0.9)';g.fillRect(x-10,y+4,20,8);
  g.fillStyle=col;g.globalAlpha=lit?1:(0.6+p*0.4);
  g.beginPath();g.moveTo(x,y-14);g.lineTo(x+7,y);g.lineTo(x,y+6);g.lineTo(x-7,y);g.closePath();g.fill();
  g.globalAlpha=1;
  if(lit){g.fillStyle='rgba(255,255,255,0.3)';g.beginPath();g.arc(x,y,16,0,Math.PI*2);g.fill();}
}
function drawEnemy(en,cam){
  const x=en.x-cam.x,y=en.y-cam.y;
  if(x<-40||y<-40||x>VW+40||y>VH+40)return;
  const f=en.flash>0;
  g.save();g.translate(x,y);
  if(en.frozen>0){g.globalAlpha=0.85;}
  const bob=Math.sin(en.t*6)*1.5;
  if(en.type==='armor'){
    g.fillStyle='rgba(0,0,0,0.35)';g.beginPath();g.ellipse(0,en.r*0.9,en.r*0.9,4,0,0,Math.PI*2);g.fill();
    g.fillStyle=f?'#ffe0c0':'#6a6e72';g.fillRect(-en.r*0.8,-en.r+bob,en.r*1.6,en.r*1.8);
    g.fillStyle=f?'#fff':'#3a3e44';g.fillRect(-en.r*0.5,-en.r*0.4+bob,en.r,3);
    g.fillStyle='#b06a30';g.fillRect(-en.r*0.8,-en.r*0.3+bob,en.r*1.6,2);
    g.fillStyle='#8af0c0';g.fillRect(-3,-en.r*0.7+bob,6,2);
  }else if(en.type==='instrument'){
    g.fillStyle=f?'#fff':'#8a5a2a';g.beginPath();g.ellipse(0,bob,en.r,en.r*0.8,0,0,Math.PI*2);g.fill();
    g.strokeStyle='#e0c060';g.lineWidth=2;g.beginPath();g.arc(0,bob,en.r*0.5,0,Math.PI*2);g.stroke();
    g.fillStyle='#2a2a40';g.fillRect(-2,-en.r-6+bob,4,8);
    g.fillStyle='#a0c0ff';g.fillRect(-3,bob-2,6,4);
  }else if(en.type==='sentinel'){
    g.fillStyle=f?'#fff':'#7a4a2a';g.fillRect(-en.r,-en.r,en.r*2,en.r*2);
    g.fillStyle='#e0a040';g.beginPath();g.arc(0,0,4+Math.sin(S.time*5)*1,0,Math.PI*2);g.fill();
  }else{
    // bosses
    const r=en.r;
    g.fillStyle='rgba(0,0,0,0.4)';g.beginPath();g.ellipse(0,r*0.9,r,r*0.25,0,0,Math.PI*2);g.fill();
    g.fillStyle=f?'#ffe6c0':(en.type==='ch'?'#3a1830':en.type==='sg'?'#5a4030':'#5a3a2a');
    g.beginPath();g.arc(0,bob,r,0,Math.PI*2);g.fill();
    g.strokeStyle=en.type==='ch'?'#ff6a3a':'#e0a040';g.lineWidth=3;g.beginPath();g.arc(0,bob,r*0.6,0,Math.PI*2);g.stroke();
    g.fillStyle=en.type==='ch'?'#ff9a4a':'#ffd060';g.beginPath();g.arc(0,bob,r*0.2,0,Math.PI*2);g.fill();
    // hp bar
    g.fillStyle='#000';g.fillRect(-r,-r-12,r*2,5);
    g.fillStyle='#c83a2a';g.fillRect(-r+1,-r-11,(r*2-2)*(en.hp/en.maxHp),3);
  }
  if(en.burn>0){g.fillStyle='rgba(255,120,40,0.5)';g.beginPath();g.arc(0,0,en.r+4+Math.sin(S.time*20)*2,0,Math.PI*2);g.fill();}
  g.restore();
  if(en.frozen>0){g.strokeStyle='#8fd0ff';g.lineWidth=2;g.beginPath();g.arc(x,y,en.r+5,0,Math.PI*2);g.stroke();}
}
function drawProjs(cam){
  for(const p of projs){
    const x=p.x-cam.x,y=p.y-cam.y;
    if(p.type==='p'){
      g.fillStyle='rgba(159,230,255,0.4)';g.beginPath();g.arc(x,y,7,0,Math.PI*2);g.fill();
      g.fillStyle='#e8fbff';g.beginPath();g.arc(x,y,3,0,Math.PI*2);g.fill();
    }else{
      g.fillStyle='rgba(180,60,200,0.5)';g.beginPath();g.arc(x,y,6,0,Math.PI*2);g.fill();
      g.fillStyle='#ffb0ff';g.beginPath();g.arc(x,y,2.5,0,Math.PI*2);g.fill();
    }
  }
}
function drawCoins(cam){
  for(const c of coins){
    const x=c.x-cam.x,y=c.y-cam.y;
    const w=Math.abs(Math.sin(c.t*6))*5+1.5;
    g.fillStyle='#ffd65a';g.beginPath();g.ellipse(x,y,w,4,0,0,Math.PI*2);g.fill();
    g.fillStyle='rgba(255,230,140,0.5)';g.beginPath();g.arc(x+rnd(-1,1),y,6,0,Math.PI*2);g.fill();
  }
}
function drawPlayer(cam){
  const x=P.x-cam.x,y=P.y-cam.y;
  const dx=P.facing.x,dy=P.facing.y;
  const ang=Math.atan2(dy,dx);
  const bob=Math.sin(P.walkT*18)*1.5;
  g.save();g.translate(x,y);
  // invulnerability blink
  if(P.inv>0&&Math.floor(P.inv*12)%2===0)g.globalAlpha=0.5;
  // aura
  if(P.auraT>0){g.fillStyle='rgba(255,230,120,'+(0.3*P.auraT/2.2)+')';g.beginPath();g.arc(0,0,30,0,Math.PI*2);g.fill();}
  if(S.shimmerT>0)S.shimmerT-=0;
  // lantern glow
  if(S.equip.lantern){g.fillStyle='rgba(120,190,255,0.22)';g.beginPath();g.arc(0,0,38,0,Math.PI*2);g.fill();}
  // shadow
  g.fillStyle='rgba(0,0,0,0.4)';g.beginPath();g.ellipse(0,11,10,4,0,0,Math.PI*2);g.fill();
  // ember trail
  // cloak
  const cloakCol=S.equip.cloak?'#2a2238':'#4a5870';
  g.fillStyle=cloakCol;g.beginPath();g.moveTo(-8,-4+bob);g.lineTo(8,-4+bob);g.lineTo(11,10);g.lineTo(-11,10);g.closePath();g.fill();
  if(S.equip.cloak){g.fillStyle='#120a18';g.fillRect(-11,8,22,2);}
  if(S.weapon===2){g.fillStyle='rgba(255,220,100,'+(0.35+0.15*Math.sin(S.time*5))+')';g.beginPath();g.arc(0,2,15,0,Math.PI*2);g.fill();}
  // body
  g.fillStyle='#3a2a20';g.beginPath();g.arc(0,-8+bob,6,0,Math.PI*2);g.fill();
  g.fillStyle='#7a5a30';g.fillRect(-5,-3+bob,10,4);
  // off-hand crystal
  if(S.shard){
    const pulse=0.6+0.4*Math.sin(S.time*5);
    g.fillStyle='rgba(159,230,255,'+pulse*0.5+')';g.beginPath();g.arc(-11,2+bob,6,0,Math.PI*2);g.fill();
    g.fillStyle='#bff0ff';g.beginPath();g.moveTo(-11,-4+bob);g.lineTo(-8,2+bob);g.lineTo(-11,6+bob);g.lineTo(-14,2+bob);g.closePath();g.fill();
  }
  // band
  if(S.equip.band){g.fillStyle='#dfe8f0';g.fillRect(8,3,4,2);}
  // blade
  const w=WEAPONS[S.weapon];
  let swing=0;
  if(P.atk.on){const p=clamp(P.atk.t/P.atk.total,0,1);swing=(p-0.5)*(S.weapon===2?2.6:2.2);}
  g.save();g.rotate(ang+swing*0.9);
  const len=S.weapon===2?34:26;
  if(S.weapon>=1){
    const col=S.weapon===1?'#ff8a3a':'#ffd65a';
    g.fillStyle=col;g.globalAlpha=0.4;g.fillRect(10,-4,len+6,8);g.globalAlpha=1;
  }
  if(S.weapon===2){g.fillStyle='rgba(255,230,120,0.45)';g.beginPath();g.arc(0,0,len+6,-0.3,0.3);g.fill();}
  g.fillStyle=S.weapon===0?'#c7c0b0':S.weapon===1?'#ffb070':'#fff2b0';
  g.fillRect(8,-2,len,4);
  g.fillStyle='#6a4a20';g.fillRect(4,-3,6,6);
  g.restore();
  g.restore();
}
function drawBlade(){}
function drawParts(cam){
  for(const p of parts){
    const a=clamp(p.life/p.max,0,1);
    const x=p.x-cam.x,y=p.y-cam.y;
    g.globalAlpha=p.smoke?a*0.35:a;
    g.fillStyle=p.col;
    g.beginPath();g.arc(x,y,p.s*(p.smoke?(1+(1-a)*1.5):1),0,Math.PI*2);g.fill();
  }
  g.globalAlpha=1;
}
function drawPopups(cam){
  g.font='bold 14px Georgia';g.textAlign='center';
  for(const p of popups){
    g.globalAlpha=clamp(p.life/p.max*1.4,0,1);
    g.fillStyle='#000';g.fillText(p.txt,p.x-cam.x+1,p.y-cam.y+1);
    g.fillStyle=p.col;g.fillText(p.txt,p.x-cam.x,p.y-cam.y);
  }
  g.globalAlpha=1;g.textAlign='left';
}
function drawLightMask(){
  if(!curZone||!curZone.def.dark)return;
  const cam=camera();
  const r=S.equip.lantern?170:90;
  const sx=P.x-cam.x,sy=P.y-cam.y;
  g.save();
  g.globalCompositeOperation='source-over';
  g.fillStyle='rgba(0,0,8,0.72)';
  // draw dark everywhere and cut a hole
  const off=document.createElement('canvas');
  off.width=VW;off.height=VH;
  const oc=off.getContext('2d');
  oc.fillStyle='rgba(0,0,8,0.78)';oc.fillRect(0,0,VW,VH);
  oc.globalCompositeOperation='destination-out';
  const rg=oc.createRadialGradient(sx,sy,r*0.3,sx,sy,r);
  rg.addColorStop(0,'rgba(0,0,0,1)');rg.addColorStop(1,'rgba(0,0,0,0)');
  oc.fillStyle=rg;oc.beginPath();oc.arc(sx,sy,r,0,Math.PI*2);oc.fill();
  g.drawImage(off,0,0);
  g.restore();
}
function drawFog(){
  if(!curZone)return;
  const z=curZone;
  if(z.def.dark&&!S.equip.lantern){} // dark already handled
}
function render(){
  const cam=camera();
  g.fillStyle='#000';g.fillRect(0,0,VW,VH);
  if(!curZone)return;
  drawTiles(cam);
  for(const e of curZone.enemies)if(!e.dead)drawEnemy(e,cam);
  drawCoins(cam);
  drawPlayer(cam);
  drawProjs(cam);
  drawParts(cam);
  drawPopups(cam);
  drawLightMask();
  // vignette
  const gr=g.createRadialGradient(VW/2,VH/2,200,VW/2,VH/2,520);
  gr.addColorStop(0,'rgba(0,0,0,0)');gr.addColorStop(1,'rgba(10,6,14,0.6)');
  g.fillStyle=gr;g.fillRect(0,0,VW,VH);
  // damage flash
  if(P.flash>0){g.fillStyle='rgba(255,30,30,'+(P.flash*0.5)+')';g.fillRect(0,0,VW,VH);}
  if(S.shake>0){S.shake-=0;}
}
function updateHUD(){
  let h='';
  const full=Math.floor(P.hp/2),half=P.hp%2,total=P.maxHp/2;
  for(let i=0;i<total;i++){
    const f=i<full?'h':(i===full&&half?'h':'e');
    h+='<span class="'+(f==='h'?'h':'e')+'">'+(f==='h'?'♥':'♡')+'</span>';
  }
  $('hearts').innerHTML=h;
  $('gold').textContent='◎ '+S.gold+' gold';
  let it='';
  if(S.stems)it+='Vital Stem ×'+S.stems+' (Q)<br>';
  if(S.seals)it+='Binding Seal ×'+S.seals+' (R)<br>';
  if(S.shard)it+='Aether Shard '+S.charges+' (K)<br>';
  it+='Weapon: '+WEAPONS[S.weapon].name;
  $('items').innerHTML=it;
  $('zoneName').textContent=curZone?curZone.def.name:'';
}

/* ---------------- LOOP ---------------- */
let last=performance.now();
function frame(now){
  let dt=Math.min(0.05,(now-last)/1000);last=now;
  if(S.started&&!panelOpen&&!S.dead&&!S.won&&curZone){
    updatePlayer(dt);
    updateEnemies(dt);
    updateProjs(dt);
    // breaking enemy attack cooldown
    if(P.cdT>0)P.cdT-=0;
  }
  if(S.started&&curZone&&!S.won){updateSystems(dt);}
  if(S.started){updateHUD();render();}
  requestAnimationFrame(frame);
}
/* ---------------- START ---------------- */
$('btnStart').addEventListener('click',()=>{
  initAudio();
  if(AC&&AC.state==='suspended')AC.resume();
  if(droneG)droneG.gain.setTargetAtTime(0.03,AC.currentTime,1);
  $('title').classList.add('hidden');
  S.started=true;
  Z=ZDEF.map((_,i)=>null);
  loadZone(0,'start');
  S.checkpoint={zi:0,x:P.x,y:P.y};
  setTimeout(()=>toast('Find the Ember Blade\'s purpose. Press Space to strike.',3800),400);
});
requestAnimationFrame(frame);
window.__warden={S,P,get Z(){return Z},loadZone,grant,grantAltar,camera,hitSwitch,ZDEF};
function openInventory(){
  let h='<p>Hearts: <b>'+(P.hp)+'/'+P.maxHp+' HP</b> &nbsp;·&nbsp; Gold: <b style="color:#ffd65a">'+S.gold+'</b> &nbsp;·&nbsp; Weapon: <b>'+WEAPONS[S.weapon].name+'</b></p>';
  h+='<div class="row"><div><b>Vital Stem</b> ×'+S.stems+'<br><span class="small">Restores 4 HP. Also on <kbd>Q</kbd>.</span></div><button data-act="useStem">Use</button></div>';
  h+='<div class="row"><div><b>Binding Seal</b> ×'+S.seals+'<br><span class="small">Freezes nearest enemy 3s. Also on <kbd>R</kbd>.</span></div><button data-act="useSeal">Use</button></div>';
  const acc=[['band','Warden\'s Band','Faster strikes. Silver ring on wrist.'],['lantern','Phantom Lantern','Reveals dark areas. Blue glow at your side.'],['cloak','Shadow Cloak','Traps deal less damage. Dark hem on cloak.']];
  for(const [k,n,d] of acc){
    const own=S.owned[k];
    h+='<div class="row"><div><b>'+n+'</b><br><span class="small">'+(own?d:'Not yet found.')+'</span></div>'+
      (own?'<button data-act="equip" data-arg="'+k+'">'+(S.equip[k]?'Unequip':'Equip')+'</button>':'<span class="small">—</span>')+'</div>';
  }
  h+='<p class="small">Shard: '+(S.shard?'Aether Shard · '+S.charges+' charges (K)':'not yet found')+'</p>';
  openPanel('inv','Warden\'s Satchel',h);
  panelOpen='inv';
}
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"bd6388f1-c069-4c4b-8e28-6fb1a1ca6ea9","tokensIn":826056,"tokensOut":289195,"tokensTotal":1115251,"cost":0.159835025,"turns":23,"toolCalls":43,"failedToolCalls":3,"timestamp":"2026-10-07T19:48:26.383Z"} -->