← Creature Collection results

Creature Collection

GLM 5.2 · middle

Initial view of Creature Collection

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 124,825 bytes · SHA-256 1fb392306f2e
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>ORAVETH — Bindings of Light</title>
<style>
/* ============ RESET / BASE ============ */
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
html,body{height:100%;width:100%;overflow:hidden;background:#04060a;color:#eaf2ff;
  font-family:"Trebuchet MS","Segoe UI",system-ui,sans-serif;user-select:none;-webkit-user-select:none;}
:root{
  --bg1:#0b1220;--bg2:#04060a;--accent:#7fe3ff;--accent2:#b48cff;
  --glass:rgba(20,28,46,.55);--glass-brd:rgba(150,190,255,.22);
  --txt:#eaf2ff;--dim:#9fb4d8;--good:#7dffb0;--bad:#ff7d8d;--warn:#ffd76b;
  --shadow:0 8px 32px rgba(0,0,0,.5);
}
#app{position:fixed;inset:0;display:flex;flex-direction:column;overflow:hidden;}
.screen{position:absolute;inset:0;display:flex;flex-direction:column;}
.hidden{display:none !important;}

/* ============ TITLE ============ */
#title-screen{
  align-items:center;justify-content:center;text-align:center;
  background:radial-gradient(ellipse at 50% 30%,#15213f 0%,#070a14 55%,#02030a 100%);
  overflow:hidden;
}
#title-bg{position:absolute;inset:0;z-index:0;}
.title-wrap{position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;gap:18px;padding:24px;}
.title-glow{font-size:clamp(40px,11vw,96px);font-weight:900;letter-spacing:.18em;
  background:linear-gradient(180deg,#fff,#9fe9ff 45%,#b48cff);
  -webkit-background-clip:text;background-clip:text;color:transparent;
  text-shadow:0 0 60px rgba(127,227,255,.35);animation:pulseGlow 4s ease-in-out infinite;}
.title-sub{font-size:clamp(11px,3.4vw,18px);letter-spacing:.55em;color:var(--dim);text-transform:uppercase;margin-top:-6px;}
.title-tag{font-size:clamp(11px,3vw,15px);color:var(--dim);max-width:520px;line-height:1.7;margin-top:10px;}
@keyframes pulseGlow{0%,100%{filter:drop-shadow(0 0 14px rgba(127,227,255,.35));}50%{filter:drop-shadow(0 0 34px rgba(180,140,255,.55));}}
.title-btns{display:flex;flex-direction:column;gap:14px;margin-top:24px;width:min(280px,80vw);}

/* ============ BUTTONS ============ */
.btn{cursor:pointer;border:1px solid var(--glass-brd);border-radius:14px;padding:14px 20px;
  font-size:15px;font-weight:700;letter-spacing:.06em;color:var(--txt);
  background:linear-gradient(180deg,rgba(40,55,90,.6),rgba(16,24,42,.6));
  backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
  transition:transform .12s,box-shadow .2s,background .2s;text-transform:uppercase;position:relative;overflow:hidden;}
.btn:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(127,227,255,.18);
  background:linear-gradient(180deg,rgba(60,80,130,.7),rgba(22,32,58,.7));}
.btn:active{transform:translateY(0) scale(.97);}
.btn.primary{background:linear-gradient(180deg,#3a6cff,#1f3ea8);border-color:#6fa0ff;box-shadow:0 6px 22px rgba(60,110,255,.4);}
.btn.gold{background:linear-gradient(180deg,#ffce5a,#d99a1e);color:#241a02;border-color:#ffe79a;}
.btn.danger{background:linear-gradient(180deg,#ff5a72,#a31f33);border-color:#ff9aab;color:#fff;}
.btn.sm{padding:8px 12px;font-size:12px;border-radius:10px;}
.btn.icon{padding:9px 11px;font-size:14px;min-width:40px;}
.btn:disabled{opacity:.4;cursor:not-allowed;transform:none;box-shadow:none;}

/* ============ HUD ============ */
#hud{position:absolute;top:0;left:0;right:0;z-index:30;display:flex;justify-content:space-between;align-items:flex-start;
  padding:8px 10px;pointer-events:none;gap:8px;}
#hud>div{pointer-events:auto;}
.hud-left{display:flex;flex-direction:column;gap:6px;background:var(--glass);backdrop-filter:blur(12px);
  -webkit-backdrop-filter:blur(12px);border:1px solid var(--glass-brd);border-radius:12px;padding:7px 12px;}
.zone-name{font-size:14px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;}
.zone-sub{font-size:10px;color:var(--dim);letter-spacing:.12em;text-transform:uppercase;}
.hud-right{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end;}
.hud-info{font-size:10px;color:var(--dim);display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
.hud-info b{color:var(--warn);}
.mute-dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--good);box-shadow:0 0 8px var(--good);}
.muted .mute-dot{background:#555;box-shadow:none;}

/* ============ STAGE / CANVAS ============ */
#stage{position:absolute;inset:0;}
#stage.shake,.shake{animation:shake .34s ease;}
@keyframes shake{0%{transform:translate(0,0)}20%{transform:translate(-6px,4px)}40%{transform:translate(6px,-3px)}60%{transform:translate(-4px,2px)}80%{transform:translate(3px,-2px)}100%{transform:translate(0,0)}}
#cv{position:absolute;inset:0;width:100%;height:100%;display:block;}
#battle{position:absolute;inset:0;z-index:25;display:flex;flex-direction:column;}
#battle-cv{position:absolute;inset:0;width:100%;height:100%;}
#battle-ui{position:absolute;left:0;right:0;bottom:0;z-index:5;padding:10px;}

/* battle top bars */
.fighter-bars{position:absolute;top:0;left:0;right:0;z-index:4;display:flex;justify-content:space-between;padding:10px 12px;gap:10px;pointer-events:none;}
.fb{background:var(--glass);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--glass-brd);
  border-radius:12px;padding:8px 12px;min-width:min(200px,46vw);max-width:48vw;pointer-events:auto;}
.fb-name{font-size:13px;font-weight:800;letter-spacing:.05em;display:flex;justify-content:space-between;align-items:center;gap:8px;}
.fb-level{font-size:11px;color:var(--accent);}
.hp-shell{height:11px;background:rgba(0,0,0,.4);border-radius:7px;overflow:hidden;margin-top:6px;border:1px solid rgba(255,255,255,.1);}
.hp-fill{height:100%;width:100%;background:linear-gradient(90deg,#3dff9a,#7dffb0);transition:width .5s cubic-bezier(.3,1.2,.4,1),background .3s;}
.hp-fill.mid{background:linear-gradient(90deg,#ffd24d,#ffe07a);}
.hp-fill.low{background:linear-gradient(90deg,#ff5a6e,#ff8a9a);}
.hp-txt{font-size:10px;color:var(--dim);margin-top:3px;text-align:right;font-variant-numeric:tabular-nums;}
.energy-pips{display:flex;gap:2px;margin-top:3px;flex-wrap:wrap;}
.pip{width:7px;height:7px;border-radius:2px;background:var(--accent);box-shadow:0 0 4px var(--accent);}
.pip.empty{background:rgba(255,255,255,.12);box-shadow:none;}
.type-tag{font-size:9px;padding:1px 6px;border-radius:6px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;}
.status-chips{display:flex;gap:4px;margin-top:4px;flex-wrap:wrap;}
.chip{font-size:9px;padding:1px 6px;border-radius:6px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;}

/* battle action menu */
#action-menu{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
#move-menu{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
.move-btn{cursor:pointer;border:1px solid var(--glass-brd);border-radius:12px;padding:9px 11px;text-align:left;
  background:var(--glass);backdrop-filter:blur(10px);transition:transform .1s,background .2s;position:relative;overflow:hidden;}
.move-btn:hover{transform:translateY(-2px);background:rgba(50,70,110,.7);}
.move-btn:active{transform:scale(.97);}
.move-name{font-size:13px;font-weight:800;display:flex;justify-content:space-between;align-items:center;}
.move-meta{font-size:10px;color:var(--dim);margin-top:3px;display:flex;gap:8px;}
.menu-title{font-size:11px;color:var(--dim);letter-spacing:.12em;text-transform:uppercase;margin-bottom:6px;}

/* floating numbers */
.float-num{position:absolute;z-index:60;font-weight:900;font-size:22px;pointer-events:none;
  text-shadow:0 2px 6px rgba(0,0,0,.8);animation:floatUp 1s ease-out forwards;}
.float-num.crit{font-size:32px;color:#ffd24d;}
.float-num.dmg{color:#ff8a9a;}
.float-num.heal{color:#7dffb0;}
.float-num.miss{color:#cfd9ee;}
@keyframes floatUp{0%{transform:translateY(0) scale(.6);opacity:0;}15%{transform:translateY(-8px) scale(1.2);opacity:1;}100%{transform:translateY(-70px) scale(1);opacity:0;}}

/* crit flash overlay */
#fx-flash{position:absolute;inset:0;z-index:55;pointer-events:none;opacity:0;background:radial-gradient(circle,#fff,transparent 70%);}
#fx-flash.on{animation:flash .35s ease-out;}
@keyframes flash{0%{opacity:0;}20%{opacity:.8;}100%{opacity:0;}}

/* ============ MODALS / PANELS ============ */
.modal-scrim{position:absolute;inset:0;z-index:70;background:rgba(2,4,10,.6);backdrop-filter:blur(6px);
  -webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:14px;animation:fadeIn .25s;}
@keyframes fadeIn{from{opacity:0;}to{opacity:1;}}
.panel{background:var(--glass);backdrop-filter:blur(18px);-webkit-backdrop-filter:blur(18px);
  border:1px solid var(--glass-brd);border-radius:20px;box-shadow:var(--shadow);
  width:min(640px,96vw);max-height:88vh;display:flex;flex-direction:column;overflow:hidden;
  animation:popIn .28s cubic-bezier(.2,1.2,.4,1);}
@keyframes popIn{from{transform:scale(.9);opacity:0;}to{transform:scale(1);opacity:1;}}
.panel-head{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--glass-brd);}
.panel-title{font-size:16px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;}
.panel-body{padding:14px 18px;overflow-y:auto;-webkit-overflow-scrolling:touch;}
.panel-body::-webkit-scrollbar{width:7px;}
.panel-body::-webkit-scrollbar-thumb{background:rgba(150,190,255,.3);border-radius:4px;}

/* team list */
.team-row{display:flex;align-items:center;gap:12px;padding:10px;border-radius:14px;
  background:rgba(10,16,30,.5);border:1px solid rgba(150,190,255,.12);margin-bottom:8px;transition:background .2s;}
.team-row:hover{background:rgba(20,32,58,.7);}
.team-ava{width:46px;height:46px;border-radius:12px;flex-shrink:0;background:rgba(0,0,0,.3);}
.team-info{flex:1;min-width:0;}
.team-info .tn{font-weight:800;font-size:14px;display:flex;gap:6px;align-items:center;flex-wrap:wrap;}
.team-info .td{font-size:10px;color:var(--dim);display:flex;gap:10px;margin-top:2px;}
.team-hp{width:80px;flex-shrink:0;}
.team-hp .hp-shell{height:7px;}
.team-hp .hp-fill{height:100%;}

/* codex entry */
.codex-card{display:flex;gap:14px;padding:14px;border-radius:16px;background:rgba(10,16,30,.5);border:1px solid rgba(150,190,255,.1);margin-bottom:10px;}
.codex-card.locked{opacity:.5;filter:grayscale(.6);}
.codex-ava{width:64px;height:64px;border-radius:14px;background:rgba(0,0,0,.3);flex-shrink:0;}
.codex-name{font-weight:800;font-size:15px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;}
.codex-lore{font-size:12px;color:var(--dim);line-height:1.6;margin-top:6px;}
.codex-stats{display:flex;gap:14px;margin-top:8px;flex-wrap:wrap;font-size:11px;}
.codex-stat b{color:var(--accent);}

/* shop */
.shop-grid{display:grid;grid-template-columns:1fr;gap:8px;}
.shop-item{display:flex;justify-content:space-between;align-items:center;padding:10px 12px;border-radius:12px;
  background:rgba(10,16,30,.5);border:1px solid rgba(150,190,255,.1);}
.shop-item .si-l{display:flex;flex-direction:column;}
.shop-item .si-n{font-weight:700;font-size:13px;}
.shop-item .si-d{font-size:11px;color:var(--dim);}

/* map */
.map-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;}
.map-cell{padding:12px;border-radius:14px;background:rgba(10,16,30,.5);border:1px solid rgba(150,190,255,.12);text-align:center;cursor:default;transition:.2s;}
.map-cell.unlocked{cursor:pointer;}
.map-cell.unlocked:hover{background:rgba(30,46,82,.7);transform:translateY(-2px);}
.map-cell .mn{font-weight:800;font-size:12px;margin-top:4px;}
.map-cell .md{font-size:10px;color:var(--dim);margin-top:3px;}
.map-cell .mi{font-size:24px;}
.map-cell.current{border-color:var(--accent);box-shadow:0 0 16px rgba(127,227,255,.3);}

/* ============ DIALOGUE ============ */
#dialogue{position:absolute;left:0;right:0;bottom:0;z-index:60;padding:12px;pointer-events:none;}
.dlg-box{pointer-events:auto;background:var(--glass);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
  border:1px solid var(--glass-brd);border-radius:18px;padding:16px 20px;box-shadow:var(--shadow);
  max-width:720px;margin:0 auto;animation:slideUp .3s cubic-bezier(.2,1.2,.4,1);}
@keyframes slideUp{from{transform:translateY(30px);opacity:0;}to{transform:translateY(0);opacity:1;}}
.dlg-speaker{font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--accent);margin-bottom:6px;}
.dlg-text{font-size:14px;line-height:1.7;color:var(--txt);}
.dlg-prompt{font-size:11px;color:var(--dim);text-align:right;margin-top:8px;animation:blink 1.2s infinite;}
@keyframes blink{0%,60%{opacity:1;}80%,100%{opacity:.2;}}

/* choices */
.choice-list{display:flex;flex-direction:column;gap:8px;margin-top:10px;pointer-events:auto;}
.choice-list .btn{padding:11px 16px;}

/* ============ TOUCH PAD ============ */
#touchpad{position:absolute;left:14px;bottom:14px;z-index:35;display:none;gap:0;grid-template-columns:repeat(3,46px);grid-template-rows:repeat(3,46px);}
body.touch #touchpad{display:grid;}
body.touch .hint-desktop{display:none;}
.dpad-btn{display:flex;align-items:center;justify-content:center;font-size:22px;cursor:pointer;
  background:var(--glass);border:1px solid var(--glass-brd);border-radius:12px;backdrop-filter:blur(10px);
  -webkit-backdrop-filter:blur(10px);user-select:none;}
.dpad-btn:active{background:rgba(80,120,200,.6);transform:scale(.94);}
#dp-up{grid-column:2;grid-row:1;}#dp-left{grid-column:1;grid-row:2;}#dp-right{grid-column:3;grid-row:2;}#dp-down{grid-column:2;grid-row:3;}
.touch-actions{position:absolute;right:14px;bottom:14px;z-index:35;display:none;flex-direction:column;gap:8px;align-items:flex-end;}
body.touch .touch-actions{display:flex;}

/* ============ TOASTS ============ */
#toasts{position:absolute;top:54px;left:0;right:0;z-index:80;display:flex;flex-direction:column;align-items:center;gap:6px;pointer-events:none;padding:0 12px;}
.toast{background:var(--glass);backdrop-filter:blur(12px);border:1px solid var(--glass-brd);border-radius:12px;
  padding:8px 16px;font-size:12px;font-weight:700;box-shadow:var(--shadow);animation:toastIn .3s,toastOut .3s 2.2s forwards;max-width:90vw;text-align:center;}
.toast.good{border-color:rgba(125,255,176,.5);color:var(--good);}
.toast.bad{border-color:rgba(255,125,141,.5);color:var(--bad);}
@keyframes toastIn{from{transform:translateY(-14px);opacity:0;}to{transform:translateY(0);opacity:1;}}
@keyframes toastOut{to{transform:translateY(-14px);opacity:0;}}

/* vignette for story */
#vignette{position:absolute;inset:0;z-index:65;pointer-events:none;background:radial-gradient(ellipse at center,transparent 40%,rgba(40,10,80,.85) 100%);opacity:0;transition:opacity 1.2s;}
#vignette.on{opacity:1;}
#story-overlay{position:absolute;inset:0;z-index:66;display:none;align-items:center;justify-content:center;flex-direction:column;
  padding:30px;text-align:center;gap:18px;pointer-events:auto;background:rgba(3,4,10,.75);backdrop-filter:blur(4px);}
#story-overlay .s-title{font-size:clamp(22px,6vw,40px);font-weight:900;letter-spacing:.1em;background:linear-gradient(180deg,#fff,#9fe9ff);-webkit-background-clip:text;background-clip:text;color:transparent;}
#story-overlay .s-text{font-size:clamp(13px,3.5vw,17px);color:var(--dim);max-width:560px;line-height:1.8;white-space:pre-line;}
#story-overlay .s-cont{color:var(--accent);font-size:12px;letter-spacing:.2em;animation:blink 1.2s infinite;}

/* capture minigame */
#cap-bar{position:relative;width:min(420px,86vw);height:42px;background:rgba(0,0,0,.45);border:1px solid var(--glass-brd);border-radius:21px;overflow:hidden;margin:10px 0;}
#cap-zone{position:absolute;top:0;bottom:0;left:42%;width:16%;background:linear-gradient(90deg,rgba(125,255,176,.25),rgba(125,255,176,.45),rgba(125,255,176,.25));border-left:2px solid var(--good);border-right:2px solid var(--good);}
#cap-marker{position:absolute;top:3px;bottom:3px;width:5px;border-radius:3px;background:#fff;box-shadow:0 0 12px #fff;left:0;}

.capture-result{font-size:15px;font-weight:800;text-align:center;margin-top:8px;min-height:20px;}

.flex-row{display:flex;gap:8px;flex-wrap:wrap;}
.flex-row.center{justify-content:center;}
.spacer{flex:1;}
.small{font-size:11px;color:var(--dim);}
.center-txt{text-align:center;}
.sep{height:1px;background:var(--glass-brd);margin:10px 0;}

/* responsive tweaks */
@media(max-width:560px){
  .fb{min-width:42vw;padding:6px 9px;}
  .fb-name{font-size:12px;}
  .move-name{font-size:12px;}
  .panel-title{font-size:14px;}
}
</style>
</head>
<body>
<div id="app">

  <!-- TITLE -->
  <div id="title-screen" class="screen">
    <canvas id="title-bg"></canvas>
    <div class="title-wrap">
      <div class="title-glow">ORAVETH</div>
      <div class="title-sub">Bindings of Light</div>
      <div class="title-tag">Seven affinities drift across a fractured sky. Bind the Lumari, fell the Four Anchors, and mend the veil before the last dawn fades.</div>
      <div class="title-btns" id="title-btns"></div>
      <div class="small" style="margin-top:18px;opacity:.6">WASD / Arrows to move &middot; Tap buttons on touch devices</div>
    </div>
  </div>

  <!-- GAME -->
  <div id="game" class="screen hidden">
    <div id="stage"><canvas id="cv"></canvas></div>
    <div id="battle" class="hidden">
      <canvas id="battle-cv"></canvas>
      <div class="fighter-bars" id="fighter-bars"></div>
      <div id="fx-flash"></div>
      <div id="battle-ui"></div>
    </div>

    <div id="hud">
      <div class="hud-left">
        <div class="zone-name" id="zone-name">—</div>
        <div class="zone-sub" id="zone-sub"></div>
        <div class="hud-info" id="hud-info"></div>
      </div>
      <div class="hud-right" id="hud-right"></div>
    </div>

    <div id="touchpad">
      <div class="dpad-btn" id="dp-up">▲</div>
      <div class="dpad-btn" id="dp-left">◀</div>
      <div class="dpad-btn" id="dp-right">▶</div>
      <div class="dpad-btn" id="dp-down">▼</div>
    </div>
    <div class="touch-actions" id="touch-actions"></div>

    <div id="dialogue" class="hidden"></div>
    <div id="vignette"></div>
    <div id="story-overlay"><div class="s-title"></div><div class="s-text"></div><div class="s-cont">tap to continue</div></div>
    <div id="toasts"></div>
    <div id="overlay-root"></div>
  </div>
</div>

<script>
"use strict";
/* ====================================================================
   ORAVETH — single-file creature-collector RPG
   ==================================================================== */

/* ============ TYPES & CHART ============ */
const TYPES = ['Solis','Gale','Umber','Tide','Ember','Aegis','Void'];
const TYPE_COLORS = {
  Solis:'#ffd24d', Gale:'#7dffb0', Umber:'#9a6bff', Tide:'#4fd6e6',
  Ember:'#ff7a3d', Aegis:'#9fd9ff', Void:'#c77dff'
};
// triangle: Solis>Umber>Gale>Solis ; Tide>Solis,Gale ; Ember>Tide,Umber ; Void>all(slight), >Aegis(x2); Aegis resists all(x0.5)
function typeMult(att, def){
  if(att==='Void'){ return def==='Aegis'?2.0:1.25; }
  if(def==='Aegis') return 0.5;
  if(att==='Solis') return def==='Umber'?1.5:(def==='Gale'?0.5:1.0);
  if(att==='Umber') return def==='Gale'?1.5:(def==='Solis'?0.5:1.0);
  if(att==='Gale')  return def==='Solis'?1.5:(def==='Umber'?0.5:1.0);
  if(att==='Tide')  return (def==='Solis'||def==='Gale')?1.5:1.0;
  if(att==='Ember') return (def==='Tide'||def==='Umber')?1.5:1.0;
  if(att==='Aegis') return 1.0;
  return 1.0;
}

/* ============ ABILITIES (42) ============ */
// fields: name, type, pow, acc, pp, eff:{}, desc
const ABILITIES = {
  // Solis
  dawnlash:   {name:'Dawnlash',   type:'Solis', pow:42, acc:100, pp:30, desc:'A whip of morning light.'},
  radiantbeam:{name:'Radiantbeam',type:'Solis', pow:54, acc:100, pp:20, eff:{priority:1}, desc:'Quick as sunrise.'},
  solarflare: {name:'Solarflare', type:'Solis', pow:72, acc:90,  pp:10, eff:{burn:0.30}, desc:'Searing dawn; may burn.'},
  sunveil:    {name:'Sunveil',    type:'Solis', pow:0,  acc:100, pp:18, eff:{barrier:1,defUp:1}, desc:'Wreathe in light; deflect one hit.'},
  mirrorgleam:{name:'Mirrorgleam',type:'Solis', pow:36, acc:95,  pp:22, eff:{barrier:1}, desc:'A shielding glint.'},
  // Gale
  gustslash:  {name:'Gustslash',  type:'Gale', pow:42, acc:100, pp:30, desc:'A slicing current.'},
  zephyrburst:{name:'Zephyrburst',type:'Gale', pow:50, acc:100, pp:20, eff:{priority:1}, desc:'Outpace the wind.'},
  cyclonedrill:{name:'Cyclonedrill',type:'Gale',pow:26,acc:95,pp:18,eff:{multi:[2,3]},desc:'Strikes 2-3 times.'},
  tailwind:   {name:'Tailwind',   type:'Gale', pow:0,  acc:100, pp:18, eff:{spdUp:2}, desc:'Lift your speed sharply.'},
  hurriclaw:  {name:'Hurriclaw',  type:'Gale', pow:74, acc:85,  pp:10, eff:{frost:0.35}, desc:'A rending gale; may freeze.'},
  // Umber
  shadebite:  {name:'Shadebite',  type:'Umber', pow:42, acc:100, pp:30, desc:'A bite of living dusk.'},
  grimspike:  {name:'Grimspike',  type:'Umber', pow:60, acc:95,  pp:16, eff:{shock:0.25}, desc:'Shadow spikes; may stun.'},
  umbradrain: {name:'Umbradrain', type:'Umber', pow:50, acc:100, pp:15, eff:{lifesteal:0.5}, desc:'Drain life from the dark.'},
  nightveil:  {name:'Nightveil',  type:'Umber', pow:0,  acc:100, pp:18, eff:{atkDown:1,target:1}, desc:"Dim a foe's might."},
  eclipseclaw:{name:'Eclipseclaw',type:'Umber', pow:82, acc:85,  pp:8,  desc:'A claw forged from total night.'},
  // Tide
  splashstrike:{name:'Splashstrike',type:'Tide',pow:42,acc:100,pp:30,desc:'A brisk watery slap.'},
  tidecrash:  {name:'Tidecrash',   type:'Tide',pow:34,acc:95,pp:18,eff:{multi:[2,2]},desc:'Crashes twice.'},
  riptide:    {name:'Riptide',     type:'Tide',pow:54,acc:100,pp:14,eff:{lifesteal:0.3},desc:'Pull foe under; recover a little.'},
  mistward:   {name:'Mistward',    type:'Tide',pow:0,acc:100,pp:16,eff:{regen:3},desc:'Heal slowly over time.'},
  drowningdepth:{name:'Drowningdepth',type:'Tide',pow:80,acc:85,pp:8,eff:{frost:0.35},desc:'A crushing undertow.'},
  // Ember
  emberstrike:{name:'Emberstrike',type:'Ember',pow:42,acc:100,pp:30,desc:'A flick of cinder.'},
  cindercoil: {name:'Cindercoil', type:'Ember',pow:60,acc:95,pp:16,eff:{burn:0.30},desc:'Coiled fire; may burn.'},
  heatsear:   {name:'Heatsear',    type:'Ember',pow:54,acc:100,pp:16,eff:{defDown:1,target:1},desc:"Scorch a foe's guard."},
  flarepulse: {name:'Flarepulse',  type:'Ember',pow:74,acc:85,pp:10,eff:{burn:0.40},desc:'A pulse of open flame.'},
  infernotide:{name:'Infernotide', type:'Ember',pow:92,acc:80,pp:6,eff:{burn:0.30},desc:'A tide of wildfire.'},
  // Aegis
  crystalshard:{name:'Crystalshard',type:'Aegis',pow:42,acc:100,pp:30,desc:'A volley of glass.'},
  prismray:   {name:'Prismray',    type:'Aegis',pow:54,acc:100,pp:16,eff:{spdDown:1,target:1},desc:'Refracted light slows the foe.'},
  shatterpoint:{name:'Shatterpoint',type:'Aegis',pow:70,acc:90,pp:12,eff:{crit:2},desc:'A precise, brittle strike.'},
  bulwark:    {name:'Bulwark',     type:'Aegis',pow:0,acc:100,pp:16,eff:{barrier:1,defUp:1},desc:'Raise crystal walls.'},
  wardbreak:  {name:'Wardbreak',   type:'Aegis',pow:66,acc:95,pp:14,eff:{pierce:1},desc:'Ignores barriers.'},
  // Void
  voidnip:    {name:'Voidnip',    type:'Void',pow:42,acc:100,pp:30,desc:'A pinch of nothing.'},
  nullpulse:  {name:'Nullpulse',  type:'Void',pow:54,acc:100,pp:16,eff:{drainpp:1,target:1},desc:"Unravel a foe's focus."},
  collapse:   {name:'Collapse',   type:'Void',pow:74,acc:90,pp:10,eff:{shock:0.30},desc:'Fold space onto the foe.'},
  oblivion:   {name:'Oblivion',   type:'Void',pow:86,acc:80,pp:8,desc:'A taste of the final silence.'},
  entropy:    {name:'Entropy',    type:'Void',pow:0,acc:100,pp:14,eff:{burn:1,target:1, burnDmg:0.16},desc:'Curse the foe to waste away.'},
  // Neutral utility
  restrike:   {name:'Restrike',   type:'Neutral',pow:46,acc:100,pp:28,desc:'A solid basic blow.'},
  quickbite:  {name:'Quickbite',  type:'Neutral',pow:36,acc:100,pp:24,eff:{priority:1},desc:'Always strikes first.'},
  focuslash:  {name:'Focuslash',  type:'Neutral',pow:50,acc:100,pp:18,eff:{atkUp:1},desc:'Channel then strike; raises ATK.'},
  mend:       {name:'Mend',       type:'Neutral',pow:0,acc:100,pp:14,eff:{heal:0.45},desc:'Recover nearly half your HP.'},
  leechfang:  {name:'Leechfang',  type:'Neutral',pow:46,acc:100,pp:16,eff:{lifesteal:0.5},desc:'Bite and drink half the harm.'},
  refract:    {name:'Refract',    type:'Neutral',pow:0,acc:100,pp:14,eff:{barrier:1,regen:2},desc:'Bend harm aside; mend slowly.'},
  chargebeam: {name:'Chargebeam', type:'Neutral',pow:62,acc:95,pp:14,eff:{atkUp:1},desc:'A surge that emboldens you.'},
};
const NEUTRAL='Neutral';
function stab(abType, creaType){ return abType!==NEUTRAL && abType===creaType ? 1.25 : 1.0; }

/* ============ CREATURES (27) ============ */
// bodyType for drawing: orb/beast/bird/fish/sprite/golem/wraith ; hue 0..360
const CREATURES = {
  // ---- Zone 1: Lumenwold ----
  glintfox:    {name:'Glintfox',   type:'Solis', hue:46,  body:'beast',  base:{hp:46,atk:13,def:9,spd:15},
                lore:'It darts between sunbeams, leaving warm trails that linger at dusk.',
                learn:[[1,'dawnlash'],[5,'quickbite'],[9,'radiantbeam'],[14,'focuslash'],[20,'solarflare']],
                transform:{to:'luxenfox',lv:24,catalyst:'solislumen'}},
  verdimp:     {name:'Verdimp',    type:'Gale',  hue:130, body:'sprite', base:{hp:44,atk:11,def:11,spd:14},
                lore:'Born of the first morning gust, it hums in tones only the waking hear.',
                learn:[[1,'gustslash'],[5,'tailwind'],[10,'zephyrburst'],[15,'cyclonedrill'],[22,'hurriclaw']],
                transform:{to:'dawnsinger',lv:24,catalyst:'galeshard'}},
  solmote:     {name:'Solmote',    type:'Solis', hue:50,  body:'orb',    base:{hp:60,atk:10,def:14,spd:8},
                lore:'Each holds a splinter of the first sunrise, kept warm beneath its shell.',
                learn:[[1,'dawnlash'],[6,'sunveil'],[12,'mirrorgleam'],[18,'radiantbeam'],[25,'solarflare']],
                transform:null},

  // ---- Zone 2: Mirefen ----
  tidepip:     {name:'Tidepip',    type:'Tide',  hue:195, body:'fish',   base:{hp:50,atk:12,def:12,spd:12},
                lore:'It cups moonlit water in its palms and drinks the reflection of stars.',
                learn:[[1,'splashstrike'],[6,'mistward'],[11,'tidecrash'],[16,'riptide'],[24,'drowningdepth']],
                transform:{to:'tidekeeper',lv:26,catalyst:'tidepearl'}},
  dusknewt:    {name:'Dusknewt',   type:'Umber', hue:275, body:'beast',   base:{hp:52,atk:13,def:11,spd:11},
                lore:'Where it walks, puddles remember the dark that once filled them.',
                learn:[[1,'shadebite'],[6,'nightveil'],[11,'grimspike'],[17,'umbradrain'],[25,'eclipseclaw']],
                transform:null},
  lurkfern:    {name:'Lurkfern',   type:'Umber', hue:160, body:'sprite', base:{hp:54,atk:11,def:14,spd:9},
                lore:'Its fronds uncurl only where no lantern has ever shone.',
                learn:[[1,'shadebite'],[7,'mistward'],[12,'leechfang'],[18,'grimspike'],[26,'eclipseclaw']],
                transform:null},

  // ---- Zone 3: Cinderreach ----
  cindrat:     {name:'Cindrat',    type:'Ember', hue:25,  body:'beast',   base:{hp:48,atk:14,def:10,spd:14},
                lore:'Its pelt smolders without burning, fed by the mountain\'s old breath.',
                learn:[[1,'emberstrike'],[6,'heatsear'],[11,'cindercoil'],[17,'flarepulse'],[25,'infernotide']],
                transform:null},
  flarewing:   {name:'Flarewing',  type:'Ember', hue:20,  body:'bird',    base:{hp:46,atk:14,def:9,spd:16},
                lore:'It molts sparks each spring, seeding new fires across the ridge.',
                learn:[[1,'emberstrike'],[6,'zephyrburst'],[12,'cindercoil'],[18,'flarepulse'],[26,'infernotide']],
                transform:null},
  magmanub:    {name:'Magmanub',   type:'Ember', hue:15,  body:'golem',   base:{hp:66,atk:14,def:15,spd:7},
                lore:'A walking kiln; its core never cools below the heat of noon.',
                learn:[[1,'emberstrike'],[7,'chargebeam'],[13,'cindercoil'],[19,'flarepulse'],[28,'infernotide']],
                transform:{to:'cindral',lv:28,catalyst:'emberheart'}},

  // ---- Zone 4: Shadecrag ----
  galehorn:    {name:'Galehorn',   type:'Gale',  hue:170, body:'beast',   base:{hp:58,atk:14,def:12,spd:15},
                lore:'It races the storms and loses on purpose, for the joy of being caught.',
                learn:[[1,'gustslash'],[7,'tailwind'],[12,'cyclonedrill'],[18,'zephyrburst'],[27,'hurriclaw']],
                transform:{to:'tempestwing',lv:30,catalyst:'galeshard'}},
  obskink:     {name:'Obskink',    type:'Umber', hue:250, body:'beast',   base:{hp:56,atk:13,def:15,spd:10},
                lore:'It wears the dark like armor woven from closed eyes.',
                learn:[[1,'shadebite'],[7,'nightveil'],[13,'grimspike'],[19,'umbradrain'],[28,'eclipseclaw']],
                transform:null},
  vexclaw:     {name:'Vexclaw',    type:'Umber', hue:230, body:'beast',   base:{hp:54,atk:16,def:11,spd:13},
                lore:'Its claws leave wounds that heal into new scars of night.',
                learn:[[1,'shadebite'],[8,'focuslash'],[14,'grimspike'],[20,'umbradrain'],[29,'eclipseclaw']],
                transform:{to:'obsidira',lv:30,catalyst:'umbercore'}},

  // ---- Zone 5: Crystelium ----
  prismole:    {name:'Prismole',   type:'Aegis', hue:180, body:'beast',   base:{hp:60,atk:12,def:16,spd:9},
                lore:'It burrows through light, leaving tunnels of refracted color.',
                learn:[[1,'crystalshard'],[7,'prismray'],[13,'bulwark'],[19,'shatterpoint'],[28,'wardbreak']],
                transform:null},
  aegishell:   {name:'Aegishell',  type:'Aegis', hue:190, body:'golem',   base:{hp:70,atk:11,def:19,spd:6},
                lore:'Its shell is a single perfect crystal, grown over centuries of stillness.',
                learn:[[1,'crystalshard'],[8,'bulwark'],[14,'prismray'],[20,'shatterpoint'],[29,'wardbreak']],
                transform:{to:'crystarch',lv:32,catalyst:'aegisprism'}},
  lucentskip:  {name:'Lucentskip', type:'Aegis', hue:200, body:'sprite',  base:{hp:52,atk:13,def:14,spd:14},
                lore:'Each leap splits the air into a brief, singing rainbow.',
                learn:[[1,'crystalshard'],[7,'prismray'],[13,'shatterpoint'],[19,'zephyrburst'],[28,'wardbreak']],
                transform:null},

  // ---- Zone 6: Nullspire ----
  nullith:     {name:'Nullith',    type:'Void',  hue:280, body:'orb',     base:{hp:62,atk:15,def:13,spd:12},
                lore:'It is the shape of the silence between two stars.',
                learn:[[1,'voidnip'],[8,'nullpulse'],[14,'collapse'],[20,'entropy'],[30,'oblivion']],
                transform:{to:'nullarch',lv:34,catalyst:'voidsigil'}},
  wraithveil:  {name:'Wraithveil', type:'Void',  hue:290, body:'wraith',  base:{hp:58,atk:15,def:12,spd:14},
                lore:'It collects the echoes of things that never came to be.',
                learn:[[1,'voidnip'],[8,'entropy'],[14,'nullpulse'],[20,'collapse'],[30,'oblivion']],
                transform:{to:'eclipseveil',lv:34,catalyst:'voidsigil'}},
  hollowcrown: {name:'Hollowcrown',type:'Void',  hue:300, body:'wraith',  base:{hp:74,atk:17,def:15,spd:10},
                lore:'Its crown is a ring of suns that forgot how to rise.',
                learn:[[1,'voidnip'],[8,'nullpulse'],[15,'collapse'],[22,'entropy'],[32,'oblivion']],
                transform:null},

  // ---- TRANSFORMED (9) ----
  luxenfox:    {name:'Luxenfox',   type:'Solis', hue:48,  body:'beast',  base:{hp:64,atk:18,def:13,spd:19},
                lore:'A fox woven from steady noon; its bark turns shadow to glass.',
                learn:[[1,'dawnlash'],[1,'radiantbeam'],[1,'solarflare'],[30,'chargebeam'],[40,'infernotide']],
                transform:null},
  dawnsinger:  {name:'Dawnsinger', type:'Gale',  hue:140, body:'bird',   base:{hp:62,atk:17,def:14,spd:20},
                lore:'Its song parts the fog and calls the lost homeward.',
                learn:[[1,'gustslash'],[1,'hurriclaw'],[1,'cyclonedrill'],[32,'tailwind'],[42,'zephyrburst']],
                transform:null},
  tidekeeper:  {name:'Tidekeeper', type:'Tide',  hue:200, body:'fish',   base:{hp:74,atk:18,def:17,spd:15},
                lore:'It remembers every tide that ever was, and weighs them equally.',
                learn:[[1,'splashstrike'],[1,'drowningdepth'],[1,'riptide'],[34,'mistward'],[44,'tidecrash']],
                transform:null},
  cindral:     {name:'Cindral',    type:'Ember', hue:18,  body:'golem',  base:{hp:86,atk:20,def:20,spd:11},
                lore:'The kiln awakened; it walks on cooling lava and a patient, old anger.',
                learn:[[1,'emberstrike'],[1,'infernotide'],[1,'flarepulse'],[36,'heatsear'],[46,'chargebeam']],
                transform:null},
  tempestwing: {name:'Tempestwing',type:'Gale',  hue:175, body:'bird',   base:{hp:76,atk:19,def:16,spd:21},
                lore:'The storm it once lost to has chosen to ride upon its back.',
                learn:[[1,'gustslash'],[1,'hurriclaw'],[1,'cyclonedrill'],[38,'tailwind'],[48,'zephyrburst']],
                transform:null},
  obsidira:    {name:'Obsidira',   type:'Umber', hue:240, body:'wraith', base:{hp:74,atk:21,def:16,spd:17},
                lore:'A serpent of fractured night; coiling, it tightens the dark around the day.',
                learn:[[1,'shadebite'],[1,'eclipseclaw'],[1,'umbradrain'],[38,'nightveil'],[48,'grimspike']],
                transform:null},
  crystarch:   {name:'Crystarch',  type:'Aegis', hue:195, body:'golem',  base:{hp:96,atk:16,def:25,spd:9},
                lore:'The unbroken heart of the glass bastion, slow and utterly unyielding.',
                learn:[[1,'crystalshard'],[1,'shatterpoint'],[1,'wardbreak'],[40,'bulwark'],[50,'prismray']],
                transform:null},
  nullarch:    {name:'Nullarch',   type:'Void',  hue:285, body:'orb',    base:{hp:84,atk:22,def:18,spd:16},
                lore:'Where it floats, the world politely forgets to exist.',
                learn:[[1,'voidnip'],[1,'oblivion'],[1,'collapse'],[40,'entropy'],[50,'nullpulse']],
                transform:null},
  eclipseveil: {name:'Eclipseveil',type:'Void',  hue:295, body:'wraith', base:{hp:80,atk:22,def:17,spd:19},
                lore:'It drapes across suns and tells them it is time to rest.',
                learn:[[1,'voidnip'],[1,'oblivion'],[1,'entropy'],[40,'collapse'],[50,'nullpulse']],
                transform:null},
};

/* ============ ZONES (6) ============ */
// palette drives CSS vars; encounterTable: [species, weight, minLv, maxLv]; guardian creature
const ZONES = [
  {name:'Lumenwold', sub:'The First Meadow', storyNode:'A soft dawn spills across meadows of tall light-grass. Your binding begins here.',
   palette:{bg1:'#13261f',bg2:'#06120d',accent:'#bdf5a6',accent2:'#ffd479',particle:'petal'},
   types:['Solis','Gale'],
   encounters:[['glintfox',40,3,6],['verdimp',35,3,6],['solmote',25,4,7]],
   guardian:'verdimp', guardianLv:8, anchor:false,
   shop:{glimlamp:60, mendsap:80, repelmote:90, solislumen:300, galeshard:300}},
  {name:'Mirefen', sub:'The Drowned Hollow', storyNode:'Lantern-light sinks into black water. Something patient watches from beneath the reeds.',
   palette:{bg1:'#0a1c28',bg2:'#04101a',accent:'#5fd9e6',accent2:'#7a9cff',particle:'bubble'},
   types:['Tide','Umber'],
   encounters:[['tidepip',38,7,11],['dusknewt',32,7,11],['lurkfern',22,8,12],['glintfox',8,8,11]],
   guardian:'lurkfern', guardianLv:14, anchor:false,
   shop:{glimlamp:60, prismlamp:160, mendsap:80, revivesap:240, panacea:140, repelmote:90, tidepearl:320, umbercore:320}},
  {name:'Cinderreach', sub:'The Smoldering Ridge', storyNode:'Ash falls like warm snow. The First Anchor coils in the volcanic throat above.',
   palette:{bg1:'#1e0f0a',bg2:'#0a0403',accent:'#ff8a3d',accent2:'#ffd24d',particle:'ember'},
   types:['Ember'],
   encounters:[['cindrat',40,13,18],['flarewing',34,13,18],['magmanub',20,14,19],['galehorn',6,14,18]],
   guardian:'magmanub', guardianLv:22, anchor:true, anchorName:'Cindral, the Smoldering Anchor',
   shop:{glimlamp:60, prismlamp:160, stellarlamp:340, mendsap:80, revivesap:240, panacea:140, repelmote:90, emberheart:380}},
  {name:'Shadecrag', sub:'The Obsidian Spire', storyNode:'Glass-black peaks knife the violet sky. The Second Anchor waits atop the broken stair.',
   palette:{bg1:'#150a24',bg2:'#080414',accent:'#a87cff',accent2:'#d49bff',particle:'dust'},
   types:['Umber','Gale'],
   encounters:[['galehorn',34,20,26],['obskink',30,20,26],['vexclaw',22,21,27],['dusknewt',14,20,26]],
   guardian:'vexclaw', guardianLv:30, anchor:true, anchorName:'Obsidira, the Coiled Anchor',
   shop:{glimlamp:60, prismlamp:160, stellarlamp:340, mendsap:80, revivesap:240, panacea:140, repelmote:90, umbercore:360, galeshard:360}},
  {name:'Crystelium', sub:'The Glass Bastion', storyNode:'Walls of living crystal ring with old songs. The Third Anchor sleeps at their still center.',
   palette:{bg1:'#08181f',bg2:'#03090d',accent:'#7af0ff',accent2:'#c0d6ff',particle:'shard'},
   types:['Aegis'],
   encounters:[['prismole',38,27,33],['aegishell',26,28,34],['lucentskip',28,27,33],['flarewing',8,28,33]],
   guardian:'aegishell', guardianLv:38, anchor:true, anchorName:'Crystarch, the Unbroken Anchor',
   shop:{glimlamp:60, prismlamp:160, stellarlamp:340, mendsap:80, revivesap:240, panacea:140, repelmote:90, aegisprism:420, solislumen:340}},
  {name:'Nullspire', sub:'The Hollow Crown', storyNode:'No wind here, no warmth — only the patient absence at the world\'s crown. The Fourth Anchor rules the silence.',
   palette:{bg1:'#0a0612',bg2:'#02020a',accent:'#c07bff',accent2:'#ff6bd0',particle:'void'},
   types:['Void'],
   encounters:[['nullith',38,33,40],['wraithveil',34,33,40],['hollowcrown',20,34,41],['obskink',8,34,40]],
   guardian:'hollowcrown', guardianLv:46, anchor:true, anchorName:'Hollowcrown, the Crowned Anchor',
   shop:{glimlamp:60, prismlamp:160, stellarlamp:340, mendsap:80, revivesap:240, panacea:140, repelmote:90, voidsigil:480, escapesigil:120}},
];

/* ============ ITEMS ============ */
const ITEMS = {
  glimlamp:    {name:'Glim Lamp',  kind:'capture', quality:0.40, desc:'A simple lantern; binds weakened Lumari.'},
  prismlamp:   {name:'Prism Lamp', kind:'capture', quality:0.60, desc:'Refracted light; far surer binding.'},
  stellarlamp: {name:'Stellar Lamp',kind:'capture',quality:0.80, desc:'Starlight lantern; binds almost anything.'},
  mendsap:     {name:'Mendsap',    kind:'heal', heal:60, desc:'Restores 60 HP to one Lumari.'},
  revivesap:   {name:'Revivesap',  kind:'revive', desc:'Revives a fainted Lumari at half HP.'},
  panacea:     {name:'Panacea',    kind:'cure', desc:'Clears all status afflictions.'},
  repelmote:   {name:'Repel Mote', kind:'field', steps:60, desc:'Wards wild Lumari away for 60 steps.'},
  escapesigil: {name:'Escape Sigil',kind:'warp', desc:'Return instantly to the last shrine.'},
  solislumen:  {name:'Solislumen', kind:'catalyst', cat:'Solis', desc:'Catalyst of radiance. Transforms Solis Lumari.'},
  galeshard:   {name:'Galeshard',  kind:'catalyst', cat:'Gale', desc:'Catalyst of wind. Transforms Gale Lumari.'},
  umbercore:   {name:'Umbercore',  kind:'catalyst', cat:'Umber', desc:'Catalyst of dusk. Transforms Umber Lumari.'},
  tidepearl:   {name:'Tidepearl',  kind:'catalyst', cat:'Tide', desc:'Catalyst of water. Transforms Tide Lumari.'},
  emberheart:  {name:'Emberheart', kind:'catalyst', cat:'Ember', desc:'Catalyst of fire. Transforms Ember Lumari.'},
  aegisprism:  {name:'Aegisprism', kind:'catalyst', cat:'Aegis', desc:'Catalyst of crystal. Transforms Aegis Lumari.'},
  voidsigil:   {name:'Voidsigil',  kind:'catalyst', cat:'Void', desc:'Catalyst of null. Transforms Void Lumari.'},
};

const STARTERS = ['glintfox','verdimp','tidepip'];
const PARTY_MAX = 6;
const LEVEL_CAP = 50;
const WIN_UNIQUE = 8;   // unique species for victory
const WIN_ANCHORS = 4;  // anchors for victory

/* ============ UTILITIES ============ */
const $ = (s,r=document)=>r.querySelector(s);
const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
function el(tag, cls, html){ const e=document.createElement(tag); if(cls)e.className=cls; if(html!=null)e.innerHTML=html; return e; }
function clamp(v,a,b){return v<a?a:v>b?b:v;}
function lerp(a,b,t){return a+(b-a)*t;}
function rnd(a,b){return a+Math.random()*(b-a);}
function rndi(a,b){return Math.floor(a+Math.random()*(b-a+1));}
function pick(arr){return arr[Math.floor(Math.random()*arr.length)];}
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 uid(){return 'c'+Math.random().toString(36).slice(2,9)+Date.now().toString(36).slice(-3);}

/* ============ STAT COMPUTATION ============ */
function statFor(species, level){
  const b = CREATURES[species].base;
  return {
    maxhp: Math.floor(b.hp + level*4 + 6),
    atk:   Math.floor(b.atk + level*1.35),
    def:   Math.floor(b.def + level*1.15),
    spd:   Math.floor(b.spd + level*0.9),
  };
}
// exp needed to reach next level
function expForLevel(level){ return Math.floor(8 + level*level*4 + level*6); }

/* ============ CREATURE FACTORY ============ */
function makeCreature(species, level, opts={}){
  const def = CREATURES[species];
  if(!def){ console.error('missing species',species); species='glintfox'; }
  const c = {
    uid: uid(), species, level: clamp(level,1,LEVEL_CAP),
    affinity: def.type,
    exp: opts.exp||0,
    hp: 0, abilities:[], pp:{}, status:null, // status: {type:'burn',turns:3,...}
    boosts:{atk:0,def:0,spd:0}, barrier:0,
    catalystUsed:false,
  };
  recomputeStats(c);
  c.hp = opts.fullHp!==false ? c.maxhp : (opts.hp||0);
  // learnset up to level
  def.learn.forEach(([lv,ab])=>{ if(c.level>=lv && !c.abilities.includes(ab)) c.abilities.push(ab); });
  // ensure at least one damaging move
  if(c.abilities.length===0) c.abilities.push('restrike');
  c.abilities = c.abilities.slice(0,4);
  c.abilities.forEach(ab=>{ c.pp[ab]=ABILITIES[ab].pp; });
  return c;
}
function recomputeStats(c){
  const s = statFor(c.species, c.level);
  c.maxhp=s.maxhp; c.atk=s.atk; c.def=s.def; c.spd=s.spd;
}
function knownAbilitiesForLevel(species, level){
  const arr=[];
  CREATURES[species].learn.forEach(([lv,ab])=>{ if(level>=lv) arr.push(ab); });
  if(arr.length===0) arr.push('restrike');
  return arr;
}
// effective stat with boosts (-6..+6)
function effStat(base, stage){
  const s = clamp(stage,-6,6);
  const mul = s>=0 ? (2+s)/2 : 2/(2-s);
  return base*mul;
}
function healParty(p){ p.forEach(c=>{ recomputeStats(c); c.hp=c.maxhp; c.abilities.forEach(ab=>c.pp[ab]=ABILITIES[ab].pp); c.status=null; c.boosts={atk:0,def:0,spd:0}; c.barrier=0; }); }

/* ============ GLOBAL STATE ============ */
let S = null; // active save
const SLOT_KEY='oraveth_save_v1';

function freshState(){
  return {
    zone:0, px:2, py:7, facing:'down',
    party:[], box:[],
    inv:{glimlamp:10, mendsap:5, repelmote:1},
    seen:{}, collected:{}, // collected species counts
    credits:300,
    flags:{tutorialMove:false,tutorialBattle:false,tutorialCapture:false,tutorialShrine:false,
           anchors:{}, zonesUnlocked:[true,false,false,false,false,false],
           lastShrine:0, storyStage:0, won:false, postgame:false},
    repelSteps:0,
    muted:false,
    stepCount:0,
  };
}
function saveGame(){
  try{ localStorage.setItem(SLOT_KEY, JSON.stringify(S)); toast('Game bound to the shrine.','good'); }
  catch(e){ toast('Could not save.','bad'); }
}
function loadGame(){
  try{ const d=localStorage.getItem(SLOT_KEY); if(!d) return null; return JSON.parse(d); }
  catch(e){ return null; }
}
function hasSave(){ try{ return !!localStorage.getItem(SLOT_KEY); }catch(e){ return false; } }

function addItem(id,n=1){ S.inv[id]=(S.inv[id]||0)+n; }
function hasItem(id,n=1){ return (S.inv[id]||0)>=n; }
function useItem(id,n=1){ if(!hasItem(id,n)) return false; S.inv[id]-=n; if(S.inv[id]<=0) delete S.inv[id]; return true; }

function uniqueSpeciesCount(){
  const set={}; S.party.forEach(c=>set[c.species]=1); S.box.forEach(c=>set[c.species]=1); return Object.keys(set).length;
}
function anchorsDefeated(){ return ZONES.filter((z,i)=>z.anchor && S.flags.anchors && S.flags.anchors[i]).length; }

/* ============ AUDIO (Web Audio) ============ */
const Audio = (function(){
  let ctx=null, master=null, muted=false, padNodes=[], padGain=null, curZone=-1;
  function init(){
    if(ctx) return;
    try{
      ctx = new (window.AudioContext||window.webkitAudioContext)();
      master = ctx.createGain(); master.gain.value=0.5; master.connect(ctx.destination);
    }catch(e){ ctx=null; }
  }
  function tone(freq,dur=0.12,type='sine',gain=0.18,slideTo=null){
    if(!ctx||muted) return;
    const o=ctx.createOscillator(), g=ctx.createGain();
    o.type=type; o.frequency.value=freq;
    if(slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(20,slideTo), ctx.currentTime+dur);
    g.gain.value=0; g.gain.linearRampToValueAtTime(gain, ctx.currentTime+0.01);
    g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime+dur);
    o.connect(g); g.connect(master); o.start(); o.stop(ctx.currentTime+dur+0.02);
  }
  function chord(freqs,dur=0.3,type='triangle',gain=0.12){ freqs.forEach(f=>tone(f,dur,type,gain)); }
  function noise(dur=0.2,gain=0.08){
    if(!ctx||muted) return;
    const b=ctx.createBuffer(1, ctx.sampleRate*dur, ctx.sampleRate);
    const d=b.getChannelData(0); for(let i=0;i<d.length;i++) d[i]=(Math.random()*2-1)*(1-i/d.length);
    const s=ctx.createBufferSource(); s.buffer=b;
    const g=ctx.createGain(); g.gain.value=gain;
    const f=ctx.createBiquadFilter(); f.type='lowpass'; f.frequency.value=1200;
    s.connect(f); f.connect(g); g.connect(master); s.start();
  }
  const ZONE_PAD = [
    [196,261.6,329.6],[174.6,220,261.6],[146.8,196,233],[130.8,164.8,207.6],[110,146.8,174.6],[98,130.8,155.6]
  ];
  function setPad(zone){
    if(!ctx) return;
    curZone=zone;
    stopPad();
    if(zone<0 || muted) return;
    const freqs=ZONE_PAD[zone]||ZONE_PAD[0];
    padGain=ctx.createGain(); padGain.gain.value=0; padGain.gain.linearRampToValueAtTime(0.05,ctx.currentTime+2);
    const filt=ctx.createBiquadFilter(); filt.type='lowpass'; filt.frequency.value=700;
    padGain.connect(filt); filt.connect(master);
    freqs.forEach((f,i)=>{
      const o=ctx.createOscillator(); o.type='sine'; o.frequency.value=f;
      const lfo=ctx.createOscillator(); const lg=ctx.createGain();
      lfo.frequency.value=0.07+i*0.03; lg.gain.value=2+i; lfo.connect(lg); lg.connect(o.frequency);
      const og=ctx.createGain(); og.gain.value=0.5; o.connect(og); og.connect(padGain);
      o.start(); lfo.start(); padNodes.push(o,lfo);
    });
  }
  function stopPad(){ padNodes.forEach(n=>{try{n.stop();}catch(e){}}); padNodes=[]; if(padGain){try{padGain.disconnect();}catch(e){}} padGain=null; }
  function setMuted(m){ muted=m; if(m) stopPad(); else if(curZone>=0) setPad(curZone); }
  function isMuted(){ return muted; }
  // SFX helpers
  const sfx = {
    step:()=>tone(180,0.05,'square',0.04),
    encounter:()=>{ chord([330,440,554],0.5,'sawtooth',0.1); },
    select:()=>tone(520,0.06,'square',0.07),
    confirm:()=>{ tone(660,0.08,'square',0.08); setTimeout(()=>tone(880,0.1,'square',0.08),60); },
    back:()=>tone(300,0.07,'square',0.06),
    hit:()=>{ noise(0.12,0.12); tone(120,0.12,'sawtooth',0.1,60); },
    crit:()=>{ noise(0.18,0.18); chord([220,330],0.18,'square',0.12); },
    superEff:()=>{ chord([523,659,784],0.25,'square',0.1); noise(0.15,0.1); },
    heal:()=>{ tone(523,0.1,'sine',0.1); setTimeout(()=>tone(784,0.14,'sine',0.1),90); },
    capture:()=>{ [440,554,659,880].forEach((f,i)=>setTimeout(()=>tone(f,0.18,'triangle',0.1),i*120)); },
    victory:()=>{ [523,659,784,1046].forEach((f,i)=>setTimeout(()=>tone(f,0.3,'triangle',0.12),i*140)); },
    faint:()=>tone(220,0.5,'sawtooth',0.12,60),
    levelup:()=>{ [523,659,784,1046].forEach((f,i)=>setTimeout(()=>tone(f,0.12,'square',0.09),i*70)); },
    boss:()=>{ tone(70,0.8,'sawtooth',0.18); chord([110,146],0.8,'square',0.08); },
  };
  return { init, sfx, setPad, setMuted, isMuted, tone };
})();

/* ============ CANVAS / DPI ============ */
const cv = $('#cv'), ctx = cv.getContext('2d');
const bcv = $('#battle-cv'), bctx = bcv.getContext('2d');
const tbg = $('#title-bg'), tbgx = tbg.getContext('2d');
let DPR = Math.min(window.devicePixelRatio||1, 2);

function fitCanvas(c){
  const r=c.getBoundingClientRect();
  c.width=Math.max(1,Math.floor(r.width*DPR));
  c.height=Math.max(1,Math.floor(r.height*DPR));
}
function fitAll(){ DPR=Math.min(window.devicePixelRatio||1,2); fitCanvas(cv); fitCanvas(bcv); fitCanvas(tbg); }
window.addEventListener('resize', ()=>{ fitAll(); });

/* ============ PARTICLES (overworld ambient) ============ */
let particles=[];
function initParticles(zone){
  particles=[];
  const kind=ZONES[zone].palette.particle;
  const n = kind==='void'?60: kind==='ember'?70 : 55;
  for(let i=0;i<n;i++) particles.push(makeParticle(kind));
}
function makeParticle(kind){
  const w=cv.clientWidth, h=cv.clientHeight;
  const p={x:Math.random()*w,y:Math.random()*h,r:rnd(1,3.5),vx:rnd(-0.2,0.2),vy:rnd(0.1,0.6),a:rnd(0.2,0.9),life:rnd(0,1),kind};
  if(kind==='ember'){p.vy=rnd(-0.8,-0.3);p.r=rnd(1.5,3);}
  if(kind==='bubble'){p.vy=rnd(-0.6,-0.2);p.r=rnd(2,5);}
  if(kind==='petal'){p.vx=rnd(0.1,0.5);p.vy=rnd(0.2,0.6);p.spin=rnd(-0.05,0.05);p.rot=Math.random()*6;}
  if(kind==='void'){p.vx=rnd(-0.1,0.1);p.vy=rnd(-0.1,0.1);p.r=rnd(0.5,2.5);}
  if(kind==='shard'){p.vy=rnd(0.05,0.3);p.r=rnd(1,2.5);p.rot=Math.random()*6;p.spin=rnd(-0.03,0.03);}
  if(kind==='dust'){p.vx=rnd(-0.3,0.3);p.vy=rnd(0.05,0.3);}
  return p;
}
function drawParticles(dt){
  const w=cv.clientWidth,h=cv.clientHeight;
  const pal=ZONES[S?S.zone:0].palette;
  particles.forEach(p=>{
    p.x+=p.vx*dt*60; p.y+=p.vy*dt*60; p.life+=dt*0.3;
    if(p.rot!=null)p.rot+=p.spin*dt*60;
    if(p.y>h+10||p.y<-20||p.x<-20||p.x>w+20) Object.assign(p, makeParticle(p.kind));
    const col = pal.particle==='ember'?'255,150,60':pal.particle==='void'?'200,120,255':pal.particle==='bubble'?'120,200,255':pal.particle==='shard'?'160,230,255':pal.particle==='dust'?'180,150,230':'180,255,150';
    ctx.save();
    ctx.globalAlpha=clamp(p.a*(0.6+0.4*Math.sin(p.life*3)),0,1);
    ctx.fillStyle='rgba('+col+',1)';
    if(p.kind==='petal'){
      ctx.translate(p.x,p.y); ctx.rotate(p.rot); ctx.fillRect(-p.r,-p.r*0.6,p.r*2,p.r*1.2);
    }else if(p.kind==='shard'){
      ctx.translate(p.x,p.y); ctx.rotate(p.rot);
      ctx.beginPath(); ctx.moveTo(0,-p.r); ctx.lineTo(p.r,p.r); ctx.lineTo(-p.r,p.r); ctx.closePath(); ctx.fill();
    }else{
      ctx.shadowBlur=8; ctx.shadowColor='rgba('+col+',0.8)';
      ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,7); ctx.fill();
    }
    ctx.restore();
  });
}

/* ============ CREATURE ART (procedural) ============ */
// draw a creature centered at (x,y), scale ~1, facing 1(right)/-1(left), t for bob
function drawCreature(c, x, y, scale, facing, t, dim){
  const def = CREATURES[c.species]||CREATURES.glintfox;
  const hue=def.hue, body=def.body;
  scale = scale||1; facing=facing||1; t=t||0;
  const bob = Math.sin(t*3 + (c.uid?c.uid.length:0))*4*scale;
  ctx.save(); // NOTE: uses main ctx (overworld). Battle uses bctx via drawCreatureOn(ctx,...)
  _drawBody(ctx, def, x, y+bob, scale, facing, t, dim, c);
  ctx.restore();
}
function drawCreatureOn(g, species, x, y, scale, facing, t, dim, hpFrac){
  const def = CREATURES[species]||CREATURES.glintfox;
  const bob = Math.sin(t*3)*5*scale;
  g.save();
  // shadow
  g.fillStyle='rgba(0,0,0,0.3)'; g.beginPath(); g.ellipse(x, y+38*scale, 30*scale, 8*scale,0,0,7); g.fill();
  // aura when low hp flash
  if(hpFrac!=null){
    g.save(); g.globalAlpha=0.25+0.2*Math.sin(t*8); g.fillStyle=hpFrac<0.3?'#ff5a6e':'#7fe3ff';
    g.beginPath(); g.ellipse(x,y,46*scale,46*scale,0,0,7); g.fill(); g.restore();
  }
  _drawBody(g, def, x, y+bob, scale, facing, t, dim, null);
  g.restore();
}
function _drawBody(g, def, x, y, scale, facing, t, dim, c){
  const hue=def.hue, body=def.body;
  const dimA = dim?0.4:1;
  function h(a){return 'hsla('+hue+',70%,'+a+'%,1)';}
  function hc(a,s){return 'hsla('+hue+','+(s||70)+'%,'+a+'%,1)';}
  g.save();
  g.translate(x,y); g.scale(facing*scale, scale);
  // type aura ring
  g.save(); g.globalAlpha=0.18*dimA;
  const ringCol = TYPE_COLORS[def.type];
  g.fillStyle=ringCol; g.shadowBlur=18; g.shadowColor=ringCol;
  g.beginPath(); g.ellipse(0,4,34,34,0,0,7); g.fill(); g.restore();

  g.globalAlpha=dimA;
  const main = hc(55), dark = hc(38), light = hc(72);
  function eye(ex,ey,r){
    g.fillStyle='#0a0e1a'; g.beginPath(); g.arc(ex,ey,r,0,7); g.fill();
    g.fillStyle='#fff'; g.beginPath(); g.arc(ex+r*0.3,ey-r*0.3,r*0.4,0,7); g.fill();
  }
  if(body==='orb'){
    const grad=g.createRadialGradient(-8,-8,4,0,0,30); grad.addColorStop(0,light); grad.addColorStop(1,dark);
    g.fillStyle=grad; g.beginPath(); g.arc(0,0,28,0,7); g.fill();
    g.fillStyle=main; for(let i=0;i<6;i++){const a=i/6*6.28; g.beginPath(); g.arc(Math.cos(a)*16,Math.sin(a)*16,3,0,7); g.fill();}
    eye(-7,-2,5); eye(7,-2,5);
  } else if(body==='beast'){
    // body
    const grad=g.createLinearGradient(0,-22,0,26); grad.addColorStop(0,light); grad.addColorStop(1,dark);
    g.fillStyle=grad; g.beginPath(); g.ellipse(0,4,24,20,0,0,7); g.fill();
    // head
    g.beginPath(); g.ellipse(14,-8,13,11,0,0,7); g.fill();
    // ears
    g.fillStyle=dark; g.beginPath(); g.moveTo(8,-16); g.lineTo(6,-26); g.lineTo(14,-18); g.closePath(); g.fill();
    g.beginPath(); g.moveTo(18,-18); g.lineTo(22,-27); g.lineTo(24,-16); g.closePath(); g.fill();
    // tail
    g.fillStyle=main; g.beginPath(); g.ellipse(-22,2,9,5,-0.5,0,7); g.fill();
    eye(16,-9,4); eye(8,-9,3.5);
    // legs
    g.fillStyle=dark; g.fillRect(-12,18,6,9); g.fillRect(8,18,6,9);
  } else if(body==='bird'){
    const grad=g.createLinearGradient(0,-20,0,20); grad.addColorStop(0,light); grad.addColorStop(1,dark);
    g.fillStyle=grad; g.beginPath(); g.ellipse(0,2,18,16,0,0,7); g.fill();
    // wing
    g.fillStyle=dark; const wf=Math.sin(t*6)*0.3;
    g.save(); g.translate(-2,2); g.rotate(-0.4+wf); g.beginPath(); g.ellipse(0,0,18,7,0,0,7); g.fill(); g.restore();
    // head
    g.fillStyle=main; g.beginPath(); g.arc(14,-8,9,0,7); g.fill();
    // beak
    g.fillStyle='#ffce5a'; g.beginPath(); g.moveTo(20,-7); g.lineTo(28,-5); g.lineTo(20,-3); g.closePath(); g.fill();
    eye(16,-10,3.5);
  } else if(body==='fish'){
    const grad=g.createLinearGradient(0,-14,0,14); grad.addColorStop(0,light); grad.addColorStop(1,dark);
    g.fillStyle=grad; g.beginPath(); g.ellipse(0,0,22,13,0,0,7); g.fill();
    // tail
    g.fillStyle=main; g.beginPath(); g.moveTo(-18,0); g.lineTo(-32,-12); g.lineTo(-30,0); g.lineTo(-32,12); g.closePath(); g.fill();
    // fin
    g.fillStyle=dark; g.beginPath(); g.moveTo(0,-12); g.lineTo(6,-22); g.lineTo(12,-12); g.closePath(); g.fill();
    eye(12,-2,4);
  } else if(body==='sprite'){
    const grad=g.createRadialGradient(0,0,4,0,0,22); grad.addColorStop(0,light); grad.addColorStop(1,dark);
    g.fillStyle=grad; g.beginPath(); g.arc(0,0,18,0,7); g.fill();
    // wings
    g.globalAlpha=0.6*dimA; g.fillStyle=main;
    const wf=Math.sin(t*8)*4;
    g.beginPath(); g.ellipse(-14,-4,12,6+wf,-0.4,0,7); g.fill();
    g.beginPath(); g.ellipse(14,-4,12,6+wf,0.4,0,7); g.fill();
    g.globalAlpha=dimA;
    eye(-6,-2,3.5); eye(6,-2,3.5);
  } else if(body==='golem'){
    const grad=g.createLinearGradient(0,-24,0,24); grad.addColorStop(0,light); grad.addColorStop(1,dark);
    g.fillStyle=grad;
    // body block
    g.beginPath(); g.roundRect? g.roundRect(-20,-18,40,36,8): g.rect(-20,-18,40,36); g.fill();
    // crystal nodes
    g.fillStyle=light; g.shadowBlur=10; g.shadowColor=light;
    [[-14,-22],[0,-26],[14,-22]].forEach(p=>{ g.beginPath(); g.moveTo(p[0],p[1]); g.lineTo(p[0]-5,p[1]+8); g.lineTo(p[0]+5,p[1]+8); g.closePath(); g.fill(); });
    g.shadowBlur=0;
    eye(-8,-4,4); eye(8,-4,4);
    // arms
    g.fillStyle=dark; g.fillRect(-28,-6,8,16); g.fillRect(20,-6,8,16);
  } else { // wraith
    const grad=g.createRadialGradient(0,-6,6,0,6,28); grad.addColorStop(0,light); grad.addColorStop(1,'hsla('+hue+',60%,20%,0.2)');
    g.fillStyle=grad;
    g.beginPath(); g.moveTo(-22,22);
    g.bezierCurveTo(-26,-2,-18,-26,0,-26);
    g.bezierCurveTo(18,-26,26,-2,22,22);
    // tattered bottom
    g.lineTo(16,14); g.lineTo(10,24); g.lineTo(4,14); g.lineTo(-2,24); g.lineTo(-10,14); g.lineTo(-16,24); g.closePath(); g.fill();
    // crown (for hollowcrown/nullarch-ish)
    g.fillStyle=light; g.beginPath(); g.moveTo(-10,-26); g.lineTo(-12,-34); g.lineTo(-6,-30); g.lineTo(0,-36); g.lineTo(6,-30); g.lineTo(12,-34); g.lineTo(10,-26); g.closePath(); g.fill();
    // glowing eyes
    g.shadowBlur=12; g.shadowColor=light; g.fillStyle=light;
    g.beginPath(); g.arc(-7,-10,3,0,7); g.fill(); g.beginPath(); g.arc(7,-10,3,0,7); g.fill();
    g.shadowBlur=0;
  }
  g.restore();
}

/* title bg stars */
let titleStars=[];
function initTitleStars(){ titleStars=[]; for(let i=0;i<120;i++) titleStars.push({x:Math.random(),y:Math.random(),r:rnd(0.4,1.8),tw:Math.random()*6,p:rnd(0.3,1)}); }
function drawTitleBG(t){
  const w=tbg.clientWidth,h=tbg.clientHeight;
  tbgx.clearRect(0,0,w,h);
  // nebula
  const grd=tbgx.createRadialGradient(w*0.5,h*0.35,20,w*0.5,h*0.5,Math.max(w,h));
  grd.addColorStop(0,'rgba(90,120,220,0.18)'); grd.addColorStop(0.5,'rgba(140,80,200,0.08)'); grd.addColorStop(1,'rgba(0,0,0,0)');
  tbgx.fillStyle=grd; tbgx.fillRect(0,0,w,h);
  titleStars.forEach(s=>{
    const a=0.4+0.6*Math.abs(Math.sin(t*0.001*s.p+s.tw));
    tbgx.globalAlpha=a; tbgx.fillStyle='#cfe6ff';
    tbgx.beginPath(); tbgx.arc(s.x*w,s.y*h,s.r,0,7); tbgx.fill();
  });
  tbgx.globalAlpha=1;
}

/* ============ OVERWORLD ============ */
const TILE={GROUND:0,WALL:1,GRASS:2,SHRINE:3,TRIGGER:4,EXIT:5,DECO:6,WATER:7};
let map=null, mapsCache={};
const MAP_W=20, MAP_H=14;
const ENC_RATE=[0.12,0.14,0.15,0.15,0.14,0.16];
const POST_ENC_BOOST=1.4;

function genMap(zoneIdx){
  if(mapsCache[zoneIdx]) return mapsCache[zoneIdx];
  const rng=mulberry32(zoneIdx*9173+13);
  const t=[];
  for(let y=0;y<MAP_H;y++){ t.push(new Array(MAP_W).fill(TILE.GROUND)); }
  const z=ZONES[zoneIdx];
  // border walls
  for(let x=0;x<MAP_W;x++){t[0][x]=TILE.WALL;t[MAP_H-1][x]=TILE.WALL;}
  for(let y=0;y<MAP_H;y++){t[y][0]=TILE.WALL;t[y][MAP_W-1]=TILE.WALL;}
  // obstacle clusters (decoration + walls)
  const nObst = 8+zoneIdx*2;
  for(let i=0;i<nObst;i++){
    const cx=rndi(2,MAP_W-3), cy=rndi(2,MAP_H-3);
    const sz=rndi(1,3);
    for(let dy=-sz;dy<=sz;dy++)for(let dx=-sz;dx<=sz;dx++){
      const x=cx+dx,y=cy+dy;
      if(x>1&&x<MAP_W-2&&y>1&&y<MAP_H-2 && Math.random()<0.5) t[y][x]=Math.random()<0.3?TILE.WALL:TILE.DECO;
    }
  }
  // water pools in mirefen-like zones
  if(z.types.includes('Tide')||z.palette.particle==='bubble'){
    for(let i=0;i<3;i++){const cx=rndi(3,MAP_W-4),cy=rndi(3,MAP_H-4);
      for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){if(Math.random()<0.6)t[cy+dy][cx+dx]=TILE.WATER;}}
  }
  // grass clusters (encounter tiles)
  const nGrass = 6+zoneIdx;
  for(let i=0;i<nGrass;i++){
    const cx=rndi(2,MAP_W-3), cy=rndi(2,MAP_H-3);
    const sz=rndi(2,3);
    for(let dy=-sz;dy<=sz;dy++)for(let dx=-sz;dx<=sz;dx++){
      const x=cx+dx,y=cy+dy;
      if(x>1&&x<MAP_W-2&&y>1&&y<MAP_H-2 && t[y][x]===TILE.GROUND && Math.random()<0.7) t[y][x]=TILE.GRASS;
    }
  }
  // ensure spawn area clear
  const sx=2, sy=MAP_H-3;
  for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){ if(sx+dx>0&&sx+dx<MAP_W-1&&sy+dy>0&&sy+dy<MAP_H-1) t[sy+dy][sx+dx]=TILE.GROUND; }
  t[sy][1]=TILE.SHRINE; // shrine beside spawn
  // carve a guaranteed path from spawn toward exit (clear a corridor to avoid softlock)
  // exit at top-right
  const ex=MAP_W-2, ey=1;
  // guardian trigger near middle
  const gx=Math.floor(MAP_W/2), gy=Math.floor(MAP_H/2);
  // clear an L-path: from spawn up the left then across — but keep some grass. Just ensure trigger & exit reachable by clearing a thin path.
  function clearPath(x0,y0,x1,y1){
    let x=x0,y=y0;
    while(x!==x1){ x+=Math.sign(x1-x); if(t[y][x]===TILE.WALL||t[y][x]===TILE.DECO||t[y][x]===TILE.WATER) t[y][x]=TILE.GROUND; }
    while(y!==y1){ y+=Math.sign(y1-y); if(t[y][x]===TILE.WALL||t[y][x]===TILE.DECO||t[y][x]===TILE.WATER) t[y][x]=TILE.GROUND; }
  }
  clearPath(sx,sy,gx,gy);
  clearPath(gx,gy,ex,ey);
  // make sure grass still on path sometimes (re-seed a few grass on path for encounters) — leave as ground is fine
  t[gy][gx]=TILE.TRIGGER;
  t[ey][ex]=TILE.EXIT;
  const m={tiles:t,w:MAP_W,h:MAP_H,shrine:{x:1,y:sy},guardian:{x:gx,y:gy},exit:{x:ex,y:ey},spawn:{x:sx,y:sy}};
  mapsCache[zoneIdx]=m;
  return m;
}

function enterZone(zoneIdx, fromExit){
  S.zone=zoneIdx;
  map=genMap(zoneIdx);
  if(fromExit){ S.px=map.spawn.x; S.py=map.spawn.y; }
  S.facing='down';
  playerRender={x:S.px,y:S.py};
  // apply palette to CSS
  applyZonePalette(zoneIdx);
  initParticles(zoneIdx);
  Audio.setPad(zoneIdx);
  updateHUD();
  $('#zone-name').textContent=ZONES[zoneIdx].name;
  $('#zone-sub').textContent=ZONES[zoneIdx].sub;
}
function applyZonePalette(zoneIdx){
  const p=ZONES[zoneIdx].palette;
  const root=document.documentElement.style;
  root.setProperty('--bg1',p.bg1); root.setProperty('--bg2',p.bg2);
  root.setProperty('--accent',p.accent); root.setProperty('--accent2',p.accent2);
  document.body.style.background='radial-gradient(ellipse at 50% 20%,'+p.bg1+' 0%,'+p.bg2+' 75%)';
}

let playerRender={x:2,y:7};
let moving=false, moveFrom={x:0,y:0}, moveTo={x:0,y:0}, moveT=0;
const MOVE_DUR=150;

function canWalk(x,y){
  if(x<0||y<0||x>=map.w||y>=map.h) return false;
  const t=map.tiles[y][x];
  if(t===TILE.WALL||t===TILE.DECO||t===TILE.WATER) return false;
  // guardian trigger tile blocks unless defeated
  if(t===TILE.TRIGGER && S.flags.anchors && S.flags.anchors[S.zone]) return true; // can walk over after defeat
  return true;
}
function tryMove(dx,dy){
  if(moving || Screen.state!=='overworld') return;
  if(Dialogue.busy || Overlay.busy) return;
  S.facing = dy<0?'up':dy>0?'down':dx<0?'left':'right';
  const nx=S.px+dx, ny=S.py+dy;
  if(!canWalk(nx,ny)){ bumpFeedback(); return; }
  moving=true; moveFrom={x:S.px,y:S.py}; moveTo={x:nx,y:ny}; moveT=0;
  Audio.sfx.step();
}
function bumpFeedback(){ playerRender.bump=0.18; }

function arriveTile(){
  S.px=moveTo.x; S.py=moveTo.y; moving=false;
  S.stepCount++;
  if(S.repelSteps>0){ S.repelSteps--; }
  const tile=map.tiles[S.py][S.px];
  if(tile===TILE.SHRINE){ openShrine(); return; }
  if(tile===TILE.EXIT){ tryExit(); return; }
  if(tile===TILE.TRIGGER && !(S.flags.anchors[S.zone])){
    startBoss(); return;
  }
  // encounter
  if(tile===TILE.GRASS && S.repelSteps<=0 && S.party.some(c=>c.hp>0)){
    if(Math.random() < ENC_RATE[S.zone]*(S.flags.postgame?POST_ENC_BOOST:1)){
      triggerEncounter();
      return;
    }
  }
  // tutorial hints
  if(!S.flags.tutorialMove){ S.flags.tutorialMove=true; }
}

function triggerEncounter(){
  const z=ZONES[S.zone];
  const enemies=[];
  const spawnOne=()=>{
    const total=z.encounters.reduce((s,e)=>s+e[1],0);
    let r=Math.random()*total, pick=null;
    for(const e of z.encounters){ r-=e[1]; if(r<=0){pick=e;break;} }
    if(!pick) pick=z.encounters[0];
    const lv=rndi(pick[2],pick[3]) + (S.flags.postgame?6:0);
    return makeCreature(pick[0], lv);
  };
  enemies.push(spawnOne());
  // chance of a second foe in deeper zones / postgame
  if((S.zone>=4 || S.flags.postgame) && Math.random()<0.22){ const e2=spawnOne(); if(e2.level>1)e2.level=Math.max(2,e2.level-2); recomputeStats(e2); e2.hp=e2.maxhp; enemies.push(e2); }
  Audio.sfx.encounter();
  transitionFlash('#000', 280, ()=>startBattle(enemies, false));
}

function startBoss(){
  const z=ZONES[S.zone];
  const bossLv=z.guardianLv + (S.flags.postgame?6:0);
  // if transformed form exists & high zone, sometimes use transformed as boss flair
  const boss=makeCreature(z.guardian, bossLv);
  boss.isBoss=true;
  Audio.sfx.boss();
  dialogue(z.anchor? z.anchorName+' stirs.' : 'A Guardian bars the way!', z.anchor? z.anchorName : 'Wild Guardian', ()=>{
    transitionFlash(z.palette.accent2, 360, ()=>startBattle([boss], false, {boss:true}));
  });
}

function tryExit(){
  const z=ZONES[S.zone];
  const unlocked = !!S.flags.anchors[S.zone];
  if(!unlocked){
    toast('The way is sealed until the guardian falls.','bad'); return;
  }
  if(S.zone < ZONES.length-1){
    S.flags.zonesUnlocked[S.zone+1]=true;
    dialogue('You step through the rift into '+ZONES[S.zone+1].name+'.','The Veil', ()=>{
      enterZone(S.zone+1, true);
      showStoryIfNeeded();
    });
  } else {
    toast('The edge of Oraveth. Beyond lies only the deep void.','good');
  }
}

/* ============ OVERWORLD RENDER ============ */
let lastT=performance.now();
function overworldLoop(now){
  const dt=Math.min(0.05,(now-lastT)/1000); lastT=now;
  if(Screen.state==='overworld' || Screen.state==='title'){
    renderOverworld(now/1000, dt);
  }
  requestAnimationFrame(overworldLoop);
}
function tileSizeCalc(){
  const w=cv.clientWidth, h=cv.clientHeight;
  const pad=14;
  return Math.floor(Math.min((w-pad*2)/map.w, (h-pad*2-60)/map.h));
}
function renderOverworld(t, dt){
  if(Screen.state==='title'){ drawTitleBG(t*1000); return; }
  if(!map) return;
  if(moving){
    moveT+=dt*1000;
    if(moveT>=MOVE_DUR){ arriveTile(); }
    else {
      const k=moveT/MOVE_DUR;
      playerRender.x=lerp(moveFrom.x,moveTo.x,k);
      playerRender.y=lerp(moveFrom.y,moveTo.y,k);
    }
  }
  if(playerRender.bump){ playerRender.bump-=dt; if(playerRender.bump<0)playerRender.bump=0; }
  const w=cv.clientWidth,h=cv.clientHeight;
  const ts=tileSizeCalc();
  const offX=(w-ts*map.w)/2, offY=(h-ts*map.h)/2 - 10;
  ctx.clearRect(0,0,w,h);
  // soft grid floor
  drawParticles(dt);
  const pal=ZONES[S.zone].palette;
  // tiles
  for(let y=0;y<map.h;y++)for(let x=0;x<map.w;x++){
    drawTile(x,y,offX,offY,ts,t,pal);
  }
  // guardian figure on trigger
  const g=map.guardian;
  if(!S.flags.anchors[S.zone]){
    const cx=offX+g.x*ts+ts/2, cy=offY+g.y*ts+ts/2;
    _drawBody(ctx, CREATURES[ZONES[S.zone].guardian], cx, cy+Math.sin(t*2)*4, ts/46, 1, t, false, null);
    // exclamation
    ctx.fillStyle=ZONES[S.zone].anchor?'#ff6bd0':'#ffd24d'; ctx.font='bold '+(ts*0.5)+'px sans-serif'; ctx.textAlign='center';
    ctx.shadowBlur=10;ctx.shadowColor=ctx.fillStyle;
    ctx.fillText('!', cx, cy-ts*0.7);
    ctx.shadowBlur=0;
  }
  // exit portal
  drawExit(offX,offY,ts,t,pal);
  // player
  drawPlayer(offX,offY,ts,t);
  // minimap hint handled by HUD
}

function drawTile(x,y,offX,offY,ts,t,pal){
  const px=offX+x*ts, py=offY+y*ts;
  const tile=map.tiles[y][x];
  const pad=2;
  ctx.save();
  if(tile===TILE.WALL){
    ctx.fillStyle='rgba(0,0,0,0.35)'; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.fill();
    ctx.strokeStyle='rgba(150,190,255,0.12)'; ctx.lineWidth=1; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.stroke();
  } else if(tile===TILE.DECO){
    ctx.fillStyle='rgba(0,0,0,0.25)'; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.fill();
    // a little glow crystal/rock
    ctx.fillStyle=pal.accent; ctx.globalAlpha=0.5;
    ctx.beginPath(); ctx.arc(px+ts/2,py+ts/2,ts*0.16,0,7); ctx.fill();
    ctx.globalAlpha=1;
  } else if(tile===TILE.WATER){
    const g=ctx.createLinearGradient(px,py,px,py+ts);
    g.addColorStop(0,'rgba(80,150,200,0.4)'); g.addColorStop(1,'rgba(40,90,140,0.5)');
    ctx.fillStyle=g; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.fill();
    ctx.strokeStyle='rgba(180,230,255,0.2)'; ctx.lineWidth=1;
    ctx.beginPath(); const wy=Math.sin(t*2+x)*2; ctx.moveTo(px+ts*0.2,py+ts*0.4+wy); ctx.lineTo(px+ts*0.8,py+ts*0.4+wy); ctx.stroke();
  } else if(tile===TILE.GRASS){
    ctx.fillStyle='rgba(20,40,30,0.5)'; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.fill();
    // grass blades
    ctx.strokeStyle=pal.accent; ctx.globalAlpha=0.7; ctx.lineWidth=2;
    for(let i=0;i<4;i++){const bx=px+ts*(0.25+i*0.18); const sw=Math.sin(t*2+x+i)*2;
      ctx.beginPath(); ctx.moveTo(bx,py+ts*0.8); ctx.lineTo(bx+sw,py+ts*0.45); ctx.stroke();}
    ctx.globalAlpha=1;
  } else if(tile===TILE.SHRINE){
    ctx.fillStyle='rgba(0,0,0,0.3)'; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.fill();
    // glowing shrine pedestal
    const pulse=0.5+0.5*Math.sin(t*3);
    ctx.save(); ctx.translate(px+ts/2,py+ts/2);
    ctx.shadowBlur=16+pulse*8; ctx.shadowColor=pal.accent2;
    ctx.fillStyle=pal.accent2; ctx.globalAlpha=0.8;
    ctx.beginPath(); ctx.moveTo(0,-ts*0.3); ctx.lineTo(ts*0.22,0); ctx.lineTo(0,ts*0.3); ctx.lineTo(-ts*0.22,0); ctx.closePath(); ctx.fill();
    ctx.globalAlpha=1; ctx.shadowBlur=0;
    ctx.restore();
  } else if(tile===TILE.TRIGGER){
    ctx.fillStyle='rgba(40,10,30,0.5)'; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.fill();
    if(S.flags.anchors[S.zone]){ // cleared
      ctx.strokeStyle='rgba(125,255,176,0.3)'; ctx.lineWidth=1; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.stroke();
    }
  } else if(tile===TILE.EXIT){
    // drawn by drawExit
  } else { // ground
    ctx.fillStyle='rgba(255,255,255,0.025)'; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.fill();
    ctx.strokeStyle='rgba(150,190,255,0.06)'; ctx.lineWidth=1; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.stroke();
  }
  ctx.restore();
}
function drawExit(offX,offY,ts,t,pal){
  const e=map.exit; const px=offX+e.x*ts+ts/2, py=offY+e.y*ts+ts/2;
  const unlocked = S.flags.anchors[S.zone];
  ctx.save(); ctx.translate(px,py);
  const col=unlocked?pal.accent:'#ff5a6e';
  for(let i=0;i<3;i++){
    const r=ts*0.16+i*ts*0.12+Math.sin(t*3+i)*3;
    ctx.globalAlpha=0.6-i*0.15; ctx.strokeStyle=col; ctx.lineWidth=3;
    ctx.beginPath(); ctx.arc(0,0,r,0,7); ctx.stroke();
  }
  ctx.globalAlpha=0.9; ctx.fillStyle=col; ctx.shadowBlur=14; ctx.shadowColor=col;
  ctx.beginPath(); ctx.arc(0,0,ts*0.1,0,7); ctx.fill();
  ctx.restore();
}
function drawPlayer(offX,offY,ts,t){
  const px=offX+playerRender.x*ts+ts/2, py=offY+playerRender.y*ts+ts/2;
  const bump=playerRender.bump?Math.sin(playerRender.bump*40)*3:0;
  ctx.save(); ctx.translate(px+bump,py);
  // shadow
  ctx.fillStyle='rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.ellipse(0,ts*0.32,ts*0.22,ts*0.07,0,0,7); ctx.fill();
  // body (a robed traveler)
  const bob=Math.sin(t*5)*2;
  ctx.shadowBlur=14; ctx.shadowColor=$('#zone-name')?getCss('--accent'):'#7fe3ff';
  const ac=getCss('--accent');
  // cloak
  ctx.fillStyle=ac; ctx.globalAlpha=0.95;
  ctx.beginPath(); ctx.moveTo(0,-ts*0.3+bob);
  ctx.bezierCurveTo(ts*0.22,-ts*0.2+bob, ts*0.2,ts*0.2, 0,ts*0.28);
  ctx.bezierCurveTo(-ts*0.2,ts*0.2, -ts*0.22,-ts*0.2+bob, 0,-ts*0.3+bob); ctx.closePath(); ctx.fill();
  ctx.shadowBlur=0;
  // head/hood glow
  ctx.fillStyle='#eaf2ff'; ctx.beginPath(); ctx.arc(0,-ts*0.26+bob,ts*0.1,0,7); ctx.fill();
  // facing eye
  ctx.fillStyle=ac; ctx.shadowBlur=8; ctx.shadowColor=ac;
  ctx.beginPath(); ctx.arc(0,-ts*0.26+bob,ts*0.05,0,7); ctx.fill();
  ctx.restore();
}
function getCss(v){return getComputedStyle(document.documentElement).getPropertyValue(v).trim();}
function roundRect(x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();}

/* ============ BATTLE SYSTEM ============ */
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
const Battle={
  enemies:[], active:null, activeIdx:0, targetIdx:0,
  busy:false, ended:false, isWild:true, isBoss:false, result:null,
  particles:[], 
  // animation offsets per uid
  fx:{},
  roundParticipantUids:new Set(),
};

function startBattle(enemyArr, isWild, opts={}){
  Battle.enemies = enemyArr.map(e=>{e.boosts={atk:0,def:0,spd:0};e.barrier=0;e.status=null;return e;});
  Battle.isWild=isWild; Battle.isBoss=!!opts.boss; Battle.ended=false; Battle.result=null; Battle.busy=false;
  Battle.particles=[]; Battle.fx={};
  // pick first alive party member as active
  Battle.activeIdx = S.party.findIndex(c=>c.hp>0);
  Battle.active = S.party[Battle.activeIdx];
  Battle.active.boosts={atk:0,def:0,spd:0}; Battle.active.barrier=0; Battle.active.status=Battle.active.status||null;
  Battle.targetIdx = Battle.enemies.findIndex(e=>e.hp>0);
  Battle.roundParticipantUids=new Set([Battle.active.uid]);
  Screen.show('battle');
  $('#fighter-bars').innerHTML='';
  renderBattleBars();
  $('#battle-ui').innerHTML='';
  Audio.sfx.select();
  if(!S.flags.tutorialBattle){
    S.flags.tutorialBattle=true;
    dialogue('A wild Lumari appears! Choose FIGHT, then an ability. Lower its HP, then use a Glim Lamp to bind it.','First Encounter',()=>showActionMenu());
  } else {
    showActionMenu();
  }
}

function renderBattleBars(){
  const fb=$('#fighter-bars'); fb.innerHTML='';
  // enemies (right side)
  const er=el('div','flex-row'); er.style.justifyContent='flex-end';
  Battle.enemies.forEach((e,i)=>{
    const c=fbCard(e,true);
    c.dataset.idx=i; c.style.cursor='pointer';
    c.addEventListener('click',()=>{ if(e.hp>0 && !Battle.busy){ Battle.targetIdx=i; toast('Target: '+CREATURES[e.species].name); } });
    er.appendChild(c);
  });
  fb.appendChild(er);
  // player (left)
  const pr=el('div','flex-row'); pr.style.justifyContent='flex-start';
  pr.appendChild(fbCard(Battle.active,false));
  fb.appendChild(pr);
}
function fbCard(c, isEnemy){
  const def=CREATURES[c.species];
  const card=el('div','fb');
  const hpFrac=c.hp/c.maxhp;
  const hpCls=hpFrac>0.5?'':(hpFrac>0.25?'mid':'low');
  let chips='';
  if(c.status){ const si=STATUS_INFO[c.status.type]; if(si) chips+='<span class="chip" style="background:'+si.color+';color:#0a0e1a">'+si.label+'</span>'; }
  if(c.barrier>0) chips+='<span class="chip" style="background:#9fd9ff;color:#0a0e1a">Ward</span>';
  card.innerHTML='<div class="fb-name"><span>'+def.name+(isEnemy?'':' <span class="type-tag" style="background:'+TYPE_COLORS[def.type]+';color:#0a0e1a">'+def.type+'</span>')+'</span><span class="fb-level">Lv'+c.level+'</span></div>'
    +'<div class="hp-shell"><div class="hp-fill '+hpCls+'" style="width:'+(hpFrac*100)+'%"></div></div>'
    +'<div class="hp-txt">'+Math.max(0,Math.ceil(c.hp))+'/'+c.maxhp+'</div>'
    +'<div class="status-chips">'+chips+'</div>';
  return card;
}
function refreshBars(){ renderBattleBars(); }

const STATUS_INFO={
  burn:{label:'Burn',color:'#ff8a3d'},
  frost:{label:'Frost',color:'#7fd9ff'},
  shock:{label:'Shock',color:'#ffd24d'},
  regen:{label:'Regen',color:'#7dffb0'},
};

/* effective combat stats */
function bAtk(c){ let v=effStat(c.atk,c.boosts.atk); if(c.status&&c.status.type==='burn')v*=0.5; return v; }
function bDef(c){ return effStat(c.def,c.boosts.def); }
function bSpd(c){ let v=effStat(c.spd,c.boosts.spd); if(c.status&&c.status.type==='frost')v*=0.5; if(c.status&&c.status.type==='shock')v*=0.7; return v; }

function orderKey(act){
  let pr=0;
  if(act.kind==='move'&&act.move&&ABILITIES[act.move].eff&&ABILITIES[act.move].eff.priority) pr=1000;
  if(act.kind==='switch'||act.kind==='item'||act.kind==='capture') pr=2000; // tactical actions first-ish? keep switch normal
  if(act.kind==='switch'||act.kind==='item'||act.kind==='capture') pr=500;
  const spd = act.by==='player'? bSpd(Battle.active) : bSpd(act.who);
  return pr+spd;
}

/* ---- ACTION MENU ---- */
function showActionMenu(){
  if(Battle.ended) return;
  Battle.busy=false;
  const ui=$('#battle-ui');
  const canFight=Battle.active.hp>0;
  ui.innerHTML='';
  const wrap=el('div');
  wrap.innerHTML='<div class="menu-title">'+CREATURES[Battle.active.species].name+' &mdash; Lv'+Battle.active.level+'</div>';
  const grid=el('div','flex-row'); 
  const bFight=btn('Fight','btn primary',()=>showMoveMenu());
  const bSwitch=btn('Switch','btn',()=>showSwitchMenu(true));
  const bItem=btn('Bag','btn',()=>showBattleBag());
  const bFlee=btn('Flee','btn',()=>tryFlee());
  bFight.disabled=!canFight;
  bSwitch.disabled = S.party.filter(c=>c.hp>0).length<2;
  bFlee.disabled = Battle.isBoss;
  grid.append(bFight,bSwitch,bItem,bFlee);
  wrap.appendChild(grid);
  ui.appendChild(wrap);
}
function btn(label,cls,fn){ const b=el('button',cls,label); b.addEventListener('click',()=>{Audio.sfx.select(); if(fn)fn();}); return b; }

function showMoveMenu(){
  const ui=$('#battle-ui'); ui.innerHTML='';
  const wrap=el('div');
  wrap.innerHTML='<div class="menu-title">Choose an ability &mdash; tap a foe to target</div>';
  const grid=el('div','#move-menu'); grid.id='move-menu';
  Battle.active.abilities.forEach(ab=>{
    const A=ABILITIES[ab];
    const pp=Battle.active.pp[ab]||0;
    const b=el('div','move-btn');
    b.innerHTML='<div class="move-name"><span>'+A.name+'</span><span class="type-tag" style="background:'+(A.type===NEUTRAL?'#8aa0c8':TYPE_COLORS[A.type])+';color:#0a0e1a">'+A.type+'</span></div>'
      +'<div class="move-meta"><span>PWR '+(A.pow||'—')+'</span><span>ACC '+A.acc+'</span><span>PP '+pp+'/'+A.pp+'</span></div>';
    if(pp<=0) b.style.opacity=0.4;
    b.addEventListener('click',()=>{ if(pp<=0){toast('No energy left for that ability.','bad');return;} Audio.sfx.select(); commitPlayerMove(ab); });
    grid.appendChild(b);
  });
  const back=btn('Back','btn sm',()=>showActionMenu());
  wrap.appendChild(grid); wrap.appendChild(el('div')).appendChild(back);
  ui.appendChild(wrap);
}
function commitPlayerMove(ab){
  const tgt = Battle.enemies[Battle.targetIdx] && Battle.enemies[Battle.targetIdx].hp>0 ? Battle.targetIdx : Battle.enemies.findIndex(e=>e.hp>0);
  Battle.targetIdx = tgt<0?0:tgt;
  resolveRound({by:'player', kind:'move', move:ab, target:Battle.targetIdx});
}

function showSwitchMenu(inBattle){
  const alive=S.party.filter(c=>c.hp>0 && c!==Battle.active);
  if(alive.length===0){ toast('No other Lumari can fight.','bad'); return; }
  const ui=inBattle?$('#battle-ui'):null;
  openPanel('Switch Lumari', (body)=>{
    body.appendChild(teamListHTML(S.party, (c)=>{
      if(c.hp<=0){ toast('That Lumari has fainted.','bad'); return; }
      if(inBattle && c===Battle.active){ toast(c===Battle.active?'Already active.':''); return; }
      if(inBattle){
        closePanel();
        Audio.sfx.confirm();
        resolveRound({by:'player', kind:'switch', to:c});
      }
    }));
    if(inBattle){ const back=btn('Back','btn sm',()=>{closePanel();showActionMenu();}); body.appendChild(back); }
    else { const back=btn('Close','btn sm',()=>closePanel()); body.appendChild(back); }
  });
}

function showBattleBag(){
  openPanel('Bag', (body)=>{
    const usable=['glimlamp','prismlamp','stellarlamp','mendsap','revivesap','panacea'];
    let any=false;
    usable.forEach(id=>{
      const n=S.inv[id]||0; if(n<=0) return; any=true;
      const it=ITEMS[id];
      const row=el('div','shop-item');
      row.innerHTML='<div class="si-l"><div class="si-n">'+it.name+' ×'+n+'</div><div class="si-d">'+it.desc+'</div></div>';
      const b=btn('Use','btn sm',()=>{
        if(it.kind==='capture'){
          closePanel(); commitCapture(id);
        } else {
          useBattleItem(id, body);
        }
      });
      row.appendChild(b); body.appendChild(row);
    });
    if(!any) body.appendChild(el('div','small center-txt','Your bag has no usable items here.'));
    body.appendChild(el('div','sep'));
    body.appendChild(btn('Back','btn sm',()=>{closePanel();showActionMenu();}));
  });
}
function useBattleItem(id, body){
  const it=ITEMS[id];
  // need target party member selection for heal/revive
  if(it.kind==='heal'||it.kind==='revive'||it.kind==='cure'){
    body.innerHTML='<div class="menu-title">Use on which Lumari?</div>';
    body.appendChild(teamListHTML(S.party,(c)=>{
      const ok = it.kind==='revive'? c.hp<=0 : (c.hp>0 && (c.hp<c.maxhp || c.status));
      if(!ok){ toast('No effect on that one.','bad'); return; }
      applyHealItem(id,c); useItem(id); refreshBars(); closePanel();
      resolveRound({by:'player', kind:'item'});
      Audio.sfx.heal();
    }));
    body.appendChild(btn('Back','btn sm',()=>showBattleBag()));
  }
}
function applyHealItem(id,c){
  const it=ITEMS[id];
  if(it.kind==='heal'){ c.hp=Math.min(c.maxhp,c.hp+it.heal); toast(CREATURES[c.species].name+' recovered '+it.heal+' HP.','good'); }
  if(it.kind==='revive'){ c.hp=Math.floor(c.maxhp*0.5); toast(CREATURES[c.species].name+' stirs again!','good'); }
  if(it.kind==='cure'){ c.status=null; toast(CREATURES[c.species].name+' is cleansed.','good'); }
}

function commitCapture(itemId){
  const tgt=Battle.enemies[Battle.targetIdx];
  if(!tgt||tgt.hp<=0){ toast('No target to bind.','bad'); showActionMenu(); return; }
  resolveRound({by:'player', kind:'capture', item:itemId, target:Battle.targetIdx});
}

function tryFlee(){
  if(Battle.isBoss){ toast('You cannot flee an Anchor!','bad'); return; }
  const s=bSpd(Battle.active); let fastest=0; Battle.enemies.forEach(e=>{if(e.hp>0)fastest=Math.max(fastest,bSpd(e));});
  if(Math.random() < clamp(0.5 + (s-fastest)/200,0.25,0.95)){
    toast('You slipped away into the veil.','good'); Audio.sfx.back(); endBattle('flee');
  } else {
    toast('You couldn\'t escape!','bad'); resolveRound({by:'player', kind:'wait'});
  }
}

/* ---- AI ---- */
function aiChoose(e){
  const abl=e.abilities.filter(a=>(e.pp[a]||0)>0);
  const pool=abl.length?abl:e.abilities;
  // prefer super-effective if possible
  const playerType=Battle.active.affinity;
  let best=null,bestM=-1;
  pool.forEach(a=>{
    const A=ABILITIES[a]; if(A.pow<=0) return;
    const m=typeMult(A.type, playerType)*stab(A.type,e.affinity);
    if(m>bestM){bestM=m;best=a;}
  });
  if(best && Math.random()<0.7) return best;
  return pick(pool);
}

/* ---- RESOLVE ROUND ---- */
async function resolveRound(playerAction){
  if(Battle.ended) return;
  Battle.busy=true;
  $('#battle-ui').innerHTML='<div class="small center-txt" style="padding:14px">Resolving...</div>';
  const actions=[];
  Battle.enemies.forEach(e=>{ if(e.hp>0 && e!==playerAction && playerAction.kind!=='capture') {/*still acts*/} });
  // enemies choose
  Battle.enemies.forEach(e=>{ if(e.hp>0) actions.push({by:'enemy', who:e, kind:'move', move:aiChoose(e), target:'player'}); });
  actions.push(playerAction);
  // sort
  actions.sort((a,b)=>orderKey(b)-orderKey(a));
  for(const act of actions){
    if(Battle.ended) break;
    if(act.by==='enemy' && (act.who.hp<=0)) continue;
    if(act.by==='player' && Battle.active.hp<=0){
      await autoSwitchIfNeeded();
      if(act.kind==='move') continue;
    }
    await doAction(act);
    await sleep(180);
    if(checkBattleEnd()) break;
  }
  if(!Battle.ended){
    await endOfTurnStatus();
    checkBattleEnd();
  }
  if(!Battle.ended){
    if(Battle.active.hp<=0){ await autoSwitchIfNeeded(); }
    refreshBars();
    showActionMenu();
  }
}

async function doAction(act){
  if(act.kind==='wait') { toast(Battle.active?CREATURES[Battle.active.species].name+' braced.':''); return; }
  if(act.kind==='switch'){
    Battle.active.boosts={atk:0,def:0,spd:0}; Battle.active.barrier=0;
    Battle.activeIdx=S.party.indexOf(act.to);
    Battle.active=act.to;
    Battle.active.boosts={atk:0,def:0,spd:0};
    Battle.roundParticipantUids.add(Battle.active.uid);
    floatTxtPlayer('Switch! '+CREATURES[Battle.active.species].name+'!','#7fe3ff');
    Audio.sfx.confirm(); await sleep(500); refreshBars(); return;
  }
  if(act.kind==='capture'){ await doCapture(act); return; }
  if(act.kind==='move'){
    const attacker = act.by==='player'? Battle.active : act.who;
    const target = act.by==='player'? Battle.enemies[act.target] : Battle.active;
    if(!target || target.hp<=0){
      // retarget
      if(act.by==='player'){ const ni=Battle.enemies.findIndex(e=>e.hp>0); if(ni<0) return; act.target=ni; Battle.targetIdx=ni; return doAction(act); }
      else return;
    }
    await performMove(attacker, target, act.move, act.by==='player');
  }
}

async function performMove(attacker, target, abId, byPlayer){
  const A=ABILITIES[abId];
  if(attacker.pp) attacker.pp[abId]=Math.max(0,(attacker.pp[abId]||0)-1);
  floatTxt(attacker, byPlayer, A.name+'!', '#cfe6ff');
  await sleep(450);
  // accuracy
  const accRoll=Math.random()*100;
  // shock skip
  if(attacker.status && attacker.status.type==='shock' && Math.random()<0.3){
    floatTxt(attacker, byPlayer, 'Shocked! Turn skipped.',''); Audio.sfx.back(); await sleep(500); return;
  }
  if(accRoll>A.acc){
    floatTxt(target, !byPlayer, 'Miss!','#cfd9ee'); Audio.sfx.back(); await sleep(450); return;
  }
  // self-effect non-damaging
  if(A.pow>0){
    const hits = (A.eff&&A.eff.multi)? rndi(A.eff.multi[0],A.eff.multi[1]) : 1;
    let total=0; let lastMult=1; let anyCrit=false;
    for(let h=0;h<hits;h++){
      if(target.hp<=0) break;
      // barrier
      if(target.barrier>0 && !(A.eff&&A.eff.pierce)){
        target.barrier--; floatTxt(target,!byPlayer,'Ward deflects!','#9fd9ff'); Audio.sfx.back();
        refreshBars(); await sleep(300); return;
      }
      const res=calcDamage(attacker,target,A);
      target.hp=Math.max(0,target.hp-res.dmg);
      total+=res.dmg; lastMult=res.tmult; if(res.crit)anyCrit=true;
      animateHit(target, byPlayer, res);
      refreshBars();
      if(hits<hits-1) await sleep(180);
      if(target.hp<=0) break;
      await sleep(120);
    }
    floatTxt(target,!byPlayer, '-'+total+(hits>1?' (×'+hits+')':'') , anyCrit?'crit':'dmg');
    // lifesteal
    if(A.eff&&A.eff.lifesteal){ const heal=Math.floor(total*A.eff.lifesteal); attacker.hp=Math.min(attacker.maxhp,attacker.hp+heal); floatTxt(attacker,byPlayer,'+'+heal,'heal'); refreshBars(); }
    if(target.hp<=0){ await faintAnim(target,!byPlayer); }
  } else {
    Audio.sfx.select();
  }
  // apply effects
  applyEffects(attacker,target,A);
  refreshBars();
  await sleep(250);
}

function calcDamage(attacker,target,A){
  let atk=bAtk(attacker), def=bDef(target);
  const tm = A.type===NEUTRAL?1:typeMult(A.type,target.affinity);
  const sb = stab(A.type,attacker.affinity);
  let base = 0.4*attacker.level*(atk/Math.max(1,def));
  let dmg = base*(A.pow*0.1);
  const mult=tm*sb;
  dmg*=mult;
  let critChance = 1/16; if(A.eff&&A.eff.crit) critChance*=A.eff.crit;
  const crit = Math.random()<critChance;
  if(crit) dmg*=1.5;
  dmg*=rnd(0.85,1.0);
  dmg=Math.max(1,Math.floor(dmg+1));
  return {dmg, mult:tm, crit};
}

function applyEffects(attacker,target,A){
  const e=A.eff; if(!e) return;
  if(e.burn && Math.random()<e.burn){ target.status={type:'burn',turns:4, dmg: e.burnDmg?Math.floor(target.maxhp*e.burnDmg):Math.floor(target.maxhp*0.08)}; floatTxt(target,target===Battle.active,'Burned!',''); }
  if(e.frost && Math.random()<e.frost){ target.status={type:'frost',turns:4}; floatTxt(target,target===Battle.active,'Frosted!',''); }
  if(e.shock && Math.random()<e.shock){ target.status={type:'shock',turns:3}; floatTxt(target,target===Battle.active,'Shocked!',''); }
  if(e.regen){ attacker.status={type:'regen',turns:e.regen}; floatTxt(attacker,attacker===Battle.active,'Regenerating!',''); }
  if(e.barrier){ attacker.barrier=(attacker.barrier||0)+e.barrier; }
  if(e.atkUp){ attacker.boosts.atk=clamp(attacker.boosts.atk+e.atkUp,-6,6); floatTxt(attacker,attacker===Battle.active,'ATK rose!',''); }
  if(e.defUp){ attacker.boosts.def=clamp(attacker.boosts.def+e.defUp,-6,6); floatTxt(attacker,attacker===Battle.active,'DEF rose!',''); }
  if(e.spdUp){ attacker.boosts.spd=clamp(attacker.boosts.spd+e.spdUp,-6,6); floatTxt(attacker,attacker===Battle.active,'SPD rose!',''); }
  if(e.atkDown){ target.boosts.atk=clamp(target.boosts.atk-e.atkDown,-6,6); floatTxt(target,target===Battle.active,'ATK fell!',''); }
  if(e.defDown){ target.boosts.def=clamp(target.boosts.def-e.defDown,-6,6); floatTxt(target,target===Battle.active,'DEF fell!',''); }
  if(e.spdDown){ target.boosts.spd=clamp(target.boosts.spd-e.spdDown,-6,6); floatTxt(target,target===Battle.active,'SPD fell!',''); }
  if(e.heal){ const h=Math.floor(attacker.maxhp*e.heal); attacker.hp=Math.min(attacker.maxhp,attacker.hp+h); floatTxt(attacker,attacker===Battle.active,'+'+h,'heal'); }
  if(e.drainpp){ const k=pick(target.abilities); if(target.pp)target.pp[k]=Math.max(0,(target.pp[k]||0)-2); floatTxt(target,target===Battle.active,'Focus drained!',''); }
}

async function endOfTurnStatus(){
  const all=[Battle.active,...Battle.enemies];
  for(const c of all){
    if(c.hp<=0||!c.status) continue;
    const s=c.status;
    if(s.type==='burn'){ const d=s.dmg||Math.floor(c.maxhp*0.08); c.hp=Math.max(0,c.hp-d); floatTxt(c,c===Battle.active,'-'+d,'dmg'); refreshBars(); }
    if(s.type==='regen'){ const h=Math.floor(c.maxhp*0.1); c.hp=Math.min(c.maxhp,c.hp+h); floatTxt(c,c===Battle.active,'+'+h,'heal'); refreshBars(); }
    s.turns--; if(s.turns<=0){ floatTxt(c,c===Battle.active,CREATURES[c.species].name+' recovered.',''); c.status=null; refreshBars(); }
    if(c.hp<=0){ await faintAnim(c,c===Battle.active); }
    await sleep(120);
  }
}

function animateHit(target, byPlayer, res){
  Battle.fx[target.uid]={shake:1, flash:1};
  Audio.sfx.hit();
  if(res.mult>=1.5){ Audio.sfx.superEff(); burstParticles(target, getCss('--accent2')); }
  if(res.crit){ flashScreen(); Audio.sfx.crit(); Battle.fx[target.uid].flash=2; }
  if(res.mult>=1.5 || res.crit) shakeStage();
}
async function faintAnim(c, isPlayer){
  Battle.fx[c.uid]={faint:1};
  Audio.sfx.faint();
  floatTxt(c,isPlayer,CREATURES[c.species].name+' fainted.','');
  await sleep(700);
}

async function autoSwitchIfNeeded(){
  if(Battle.active.hp>0) return;
  if(!S.party.some(c=>c.hp>0)) return;
  await sleep(300);
  await forcedSwitchPanel();
  if(Battle.active.hp<=0){ const n=S.party.find(c=>c.hp>0); if(n){Battle.activeIdx=S.party.indexOf(n);Battle.active=n;Battle.active.boosts={atk:0,def:0,spd:0};} }
  refreshBars();
}
function forcedSwitchPanel(){
  return new Promise(resolve=>{
    const ov=el('div','modal-scrim'); const panel=el('div','panel'); panel.style.maxWidth='440px';
    panel.innerHTML='<div class="panel-head"><div class="panel-title">Send Out a Lumari</div></div>';
    const body=el('div','panel-body');
    body.appendChild(el('div','small center-txt','Your Lumari has fainted. Choose another to continue.'));
    S.party.filter(c=>c.hp>0).forEach(c=>{
      const row=el('div','team-row'); row.style.cursor='pointer'; row.innerHTML=teamRowInner(c);
      row.addEventListener('click',()=>{ Battle.activeIdx=S.party.indexOf(c); Battle.active=c; Battle.active.boosts={atk:0,def:0,spd:0}; Audio.sfx.confirm(); ov.remove(); syncOverlayBusy(); resolve(); });
      body.appendChild(row);
    });
    panel.appendChild(body); ov.appendChild(panel); $('#overlay-root').appendChild(ov);
    Overlay.busy=true;
  });
}

function checkBattleEnd(){
  if(Battle.ended) return true;
  const enemiesAlive=Battle.enemies.some(e=>e.hp>0);
  const partyAlive=S.party.some(c=>c.hp>0);
  if(!enemiesAlive){ endBattle('win'); return true; }
  if(!partyAlive){ endBattle('lose'); return true; }
  return false;
}

function endBattle(result){
  if(Battle.ended) return;
  Battle.ended=true; Battle.result=result; Battle.busy=true;
  closePanel();
  setTimeout(()=>finishBattle(result), 500);
}

async function finishBattle(result){
  if(result==='win'){
    Audio.sfx.victory();
    // exp
    const expReport=[];
    const totalExp = Battle.enemies.reduce((s,e)=>s+ (e.level*7+14+(e.isBoss?60:0)),0);
    const gainers=S.party.filter(c=>c.hp>0 || Battle.roundParticipantUids.has(c.uid));
    // give exp to all non-fainted + active participants
    S.party.forEach(c=>{ if(c.hp>0){ const g=Math.floor(totalExp/ S.party.filter(x=>x.hp>0).length); if(g>0){ gainExp(c,g); expReport.push({c,g}); } } });
    await sleep(600);
    let summary='<div class="center-txt"><b style="font-size:18px">Victory!</b></div><div class="sep"></div>';
    expReport.forEach(r=>{ summary+='<div class="small">'+CREATURES[r.c.species].name+' gained '+r.g+' resonance.</div>'; });
    summary+='<div class="sep"></div>';
    const gold = Battle.enemies.reduce((s,e)=>s+e.level*2+(e.isBoss?0:0),0);
    S.credits += gold;
    summary+='<div class="small">+'+gold+' lumens.</div>';
    if(Battle.isBoss){
      const z=ZONES[S.zone];
      S.flags.anchors[S.zone]=true;
      S.credits += 200;
      summary+='<div class="small" style="color:var(--good)">Anchor fallen! The path onward opens.<br>+200 lumens.</div>';
    }
    await dialoguePanel('Victory', summary+'<div class="small center-txt" style="margin-top:8px;opacity:.6">tap to continue</div>', true);
    refreshBars();
    exitBattleToOverworld();
    showStoryIfNeeded();
  } else if(result==='lose'){
    Audio.sfx.faint();
    await dialoguePanel('Defeated','Your team has fallen. The nearest shrine recalls you.<div class="small center-txt" style="margin-top:8px;opacity:.6">tap to continue</div>', true);
    // recall to last shrine, heal
    healParty(S.party);
    S.zone=S.flags.lastShrine; 
    enterZone(S.zone,true);
    exitBattleToOverworld();
    toast('You stir at the shrine, whole once more.','good');
  } else { // flee
    exitBattleToOverworld();
  }
}

function gainExp(c, amount){
  if(c.level>=LEVEL_CAP){ c.exp=0; return; }
  c.exp+=amount;
  let leveled=false;
  while(c.level<LEVEL_CAP && c.exp>=expForLevel(c.level)){
    c.exp-=expForLevel(c.level); c.level++; leveled=true;
    recomputeStats(c);
    // learn abilities
    CREATURES[c.species].learn.forEach(([lv,ab])=>{
      if(lv===c.level && !c.abilities.includes(ab)){
        if(c.abilities.length<4){ c.abilities.push(ab); c.pp[ab]=ABILITIES[ab].pp; toast(CREATURES[c.species].name+' learned '+ABILITIES[ab].name+'!','good'); }
        else { toast(CREATURES[c.species].name+' could learn '+ABILITIES[ab].name+' (replace in Team menu).','good'); }
      }
    });
  }
  if(leveled){ Audio.sfx.levelup(); c.hp=c.maxhp; c.abilities.forEach(ab=>{ if(c.pp[ab]!=null) c.pp[ab]=ABILITIES[ab].pp; }); }
}

function exitBattleToOverworld(){
  // clear fx
  Battle.enemies=[]; Battle.active=null; Battle.particles=[]; Battle.fx={};
  // clear transient battle state on party
  S.party.forEach(c=>{ c.status=null; c.boosts={atk:0,def:0,spd:0}; c.barrier=0; });
  Screen.show('overworld');
  $('#battle').classList.add('hidden');
  $('#battle-ui').innerHTML='';
  $('#fighter-bars').innerHTML='';
  updateHUD();
  showActionMenuIdle();
}

/* ============ CAPTURE ============ */
async function doCapture(act){
  const item=ITEMS[act.item]; const target=Battle.enemies[act.target];
  if(!target||target.hp<=0){ toast('No target.','bad'); showActionMenu(); return; }
  if(target.isBoss){ toast('An Anchor cannot be bound.','bad'); showActionMenu(); return; }
  useItem(act.item);
  // mini-game
  const bonus = await captureMinigame();
  const hpFrac = target.hp/target.maxhp;
  const hpFactor = (1-hpFrac); // 0..1
  let chance = item.quality*0.45 + hpFactor*0.4 + bonus*0.25 + (Battle.isBoss?-1:0);
  if(target.isBoss) chance = -1; // cannot capture bosses
  chance = clamp(chance, target.isBoss?0:0.05, 0.97);
  await sleep(200);
  const success = Math.random()<chance;
  if(success){
    Audio.sfx.capture();
    floatTxt(target,false,'Bound!','#7dffb0');
    await sleep(700);
    // add to party/box
    const captured=makeCreature(target.species, target.level);
    captured.hp=target.hp;
    if(S.party.length<PARTY_MAX){ S.party.push(captured); }
    else { S.box.push(captured); toast('Party full — '+CREATURES[captured.species].name+' sent to the Reserve.','good'); }
    S.seen[target.species]=true; S.collected[target.species]=(S.collected[target.species]||0)+1;
    await dialoguePanel('Bound!', '<div class="center-txt"><b style="font-size:18px;color:var(--good)">'+CREATURES[captured.species].name+'</b> has joined your bindings!</div><div class="small center-txt" style="margin-top:8px;opacity:.6">tap to continue</div>', true);
    // remove enemy
    Battle.enemies[act.target]=Object.assign(target,{hp:0});
    checkBattleEnd();
    if(!Battle.ended){ refreshBars(); showActionMenu(); }
  } else {
    Audio.sfx.back();
    floatTxt(target,false,'Broke free!','#ff7d8d');
    await sleep(700);
    toast('The Lumari broke free!','bad');
    // enemies still get to act this round? The capture consumed the player's action; remaining enemy actions continue after doCapture returns.
  }
}

function captureMinigame(){
  return new Promise(resolve=>{
    const ov=el('div','modal-scrim');
    const panel=el('div','panel'); panel.style.maxWidth='440px';
    panel.innerHTML='<div class="panel-head"><div class="panel-title">Bind the Lumari</div></div>';
    const body=el('div','panel-body center-txt');
    body.innerHTML='<div class="small">Stop the light in the green band to strengthen the binding.</div>'
      +'<div id="cap-bar"><div id="cap-zone"></div><div id="cap-marker"></div></div>'
      +'<div class="capture-result" id="cap-result"></div>';
    panel.appendChild(body);
    const foot=el('div','panel-body'); 
    const bindBtn=btn('BIND','btn primary',()=>{});
    foot.appendChild(bindBtn); foot.style.borderTop='1px solid var(--glass-brd)';
    panel.appendChild(foot);
    ov.appendChild(panel); $('#overlay-root').appendChild(ov);
    const marker=$('#cap-marker',body); let pos=0, dir=1, stopped=false, resultBonus=0;
    const speed=1.1;
    function loop(){
      if(stopped) return;
      pos+=dir*speed;
      if(pos>100){pos=100;dir=-1;} if(pos<0){pos=0;dir=1;}
      marker.style.left=pos+'%';
      requestAnimationFrame(loop);
    }
    loop();
    function finish(){
      stopped=true;
      const dist=Math.abs(pos-50);
      // zone is 42-58 (center band). bonus max when at 50.
      resultBonus = clamp(1 - dist/50, 0, 1);
      $('#cap-result',body).textContent = resultBonus>0.7?'Perfect alignment!':(resultBonus>0.4?'Good binding.':'Weak alignment.');
      $('#cap-result',body).style.color = resultBonus>0.7?'var(--good)':(resultBonus>0.4?'var(--warn)':'var(--bad)');
      Audio.sfx.confirm();
      setTimeout(()=>{ ov.remove(); resolve(resultBonus); }, 650);
    }
    bindBtn.addEventListener('click',()=>{ if(!stopped) finish(); });
  });
}

/* ============ BATTLE FX / FLOATING ============ */
function floatTxt(target, isPlayer, txt, cls){
  // place over target on battle canvas
  const r=bcv.getBoundingClientRect();
  let x,y;
  if(isPlayer){ const p=playerBattlePos(); x=r.left+p.x; y=r.top+p.y; }
  else { const idx=Battle.enemies.indexOf(target); const p=enemyBattlePos(idx<0?0:idx); x=r.left+p.x; y=r.top+p.y; }
  spawnFloat(x,y,txt,cls);
}
function floatTxtPlayer(txt,cls){ const r=bcv.getBoundingClientRect(); const p=playerBattlePos(); spawnFloat(r.left+p.x,r.top+p.y,txt,cls); }
function spawnFloat(x,y,txt,cls){
  const e=el('div','float-num '+(cls||''), txt);
  e.style.left=(x+rnd(-16,16))+'px'; e.style.top=(y+rnd(-10,10))+'px';
  document.body.appendChild(e);
  setTimeout(()=>e.remove(),1000);
}
function flashScreen(){ const f=$('#fx-flash'); f.classList.remove('on'); void f.offsetWidth; f.classList.add('on'); }
function shakeStage(){ ['#stage','#battle'].forEach(sel=>{ const e=$(sel); if(!e)return; e.classList.remove('shake'); void e.offsetWidth; e.classList.add('shake'); }); }
function burstParticles(target, col){
  const isPlayer = target===Battle.active;
  const idx = Battle.enemies.indexOf(target);
  const p = isPlayer?playerBattlePos():enemyBattlePos(idx<0?0:idx);
  for(let i=0;i<14;i++){ Battle.particles.push({x:p.x,y:p.y,vx:rnd(-3,3),vy:rnd(-4,1),r:rnd(2,5),life:1,col:col||'#fff'}); }
}
function playerBattlePos(){ const w=bcv.clientWidth,h=bcv.clientHeight; return {x:w*0.3,y:h*0.62}; }
function enemyBattlePos(i){ const w=bcv.clientWidth,h=bcv.clientHeight; const n=Math.max(1,Battle.enemies.length); return {x:w*0.72, y:h*0.34 + (i-(n-1)/2)*h*0.16}; }

/* ============ BATTLE RENDER ============ */
let bTime=0;
function renderBattle(dt){
  bTime+=dt;
  const w=bcv.clientWidth,h=bcv.clientHeight;
  bctx.clearRect(0,0,w,h);
  const pal=ZONES[S.zone].palette;
  // bg
  const g=bctx.createLinearGradient(0,0,0,h);
  g.addColorStop(0,pal.bg1); g.addColorStop(1,'#02030a');
  bctx.fillStyle=g; bctx.fillRect(0,0,w,h);
  // distant glow
  const rg=bctx.createRadialGradient(w*0.5,h*0.3,10,w*0.5,h*0.5,w*0.7);
  rg.addColorStop(0,'rgba(127,227,255,0.12)'); rg.addColorStop(1,'rgba(0,0,0,0)');
  bctx.fillStyle=rg; bctx.fillRect(0,0,w,h);
  // platforms
  function platform(x,y,col){ bctx.save(); bctx.fillStyle='rgba(0,0,0,0.3)'; bctx.beginPath(); bctx.ellipse(x,y+30,w*0.13,16,0,0,7); bctx.fill();
    bctx.strokeStyle=col; bctx.globalAlpha=0.5; bctx.lineWidth=2; bctx.beginPath(); bctx.ellipse(x,y+30,w*0.13,16,0,0,7); bctx.stroke(); bctx.restore(); }
  const pp=playerBattlePos(); platform(pp.x,pp.y,pal.accent);
  Battle.enemies.forEach((e,i)=>{ if(e.hp>0){ const ep=enemyBattlePos(i); platform(ep.x,ep.y,pal.accent2); } });
  // player creature
  if(Battle.active){
    const fx=Battle.fx[Battle.active.uid]||{};
    let ox=0,oy=0;
    if(fx.shake){ ox=rnd(-6,6)*fx.shake; }
    drawBattleCreature(Battle.active, pp.x+ox, pp.y+oy, 1, bTime, fx, true);
    if(fx.shake){ fx.shake*=0.8; if(fx.shake<0.1)delete fx.shake; }
  }
  // enemies
  Battle.enemies.forEach((e,i)=>{
    if(e.hp<=0) return;
    const fx=Battle.fx[e.uid]||{};
    let ox=0,oy=0; if(fx.shake){ox=rnd(-6,6)*fx.shake;}
    const ep=enemyBattlePos(i);
    drawBattleCreature(e, ep.x+ox, ep.y+oy, e.isBoss?1.3:1, bTime, fx, false);
    if(fx.shake){ fx.shake*=0.8; if(fx.shake<0.1)delete fx.shake; }
  });
  // particles
  Battle.particles.forEach((p,idx)=>{ p.x+=p.vx; p.y+=p.vy; p.vy+=0.2; p.life-=dt*1.5;
    bctx.save(); bctx.globalAlpha=clamp(p.life,0,1); bctx.fillStyle=p.col; bctx.shadowBlur=8; bctx.shadowColor=p.col;
    bctx.beginPath(); bctx.arc(p.x,p.y,p.r,0,7); bctx.fill(); bctx.restore(); });
  Battle.particles=Battle.particles.filter(p=>p.life>0);
}
function drawBattleCreature(c,x,y,scale,t,fx,isPlayer){
  const facing=isPlayer?1:-1;
  let alpha=1, sc=scale;
  if(fx.flash){ alpha=0.5+0.5*Math.sin(t*30); }
  if(fx.faint){ alpha=clamp((fx.faint),0,1); fx.faint-=0.02; sc=scale*(0.6+fx.faint*0.4); y+= (1-fx.faint)*20; }
  bctx.save(); bctx.globalAlpha=alpha;
  const hpFrac=c.hp/c.maxhp;
  drawCreatureOn(bctx, c.species, x, y, sc, facing, t, false, hpFrac);
  bctx.restore();
}

/* ============ SCREEN MANAGER ============ */
const Screen={state:'title',
  show(n){ this.state=n;
    $('#title-screen').classList.toggle('hidden', n!=='title');
    $('#game').classList.toggle('hidden', n==='title');
    $('#battle').classList.toggle('hidden', n!=='battle');
    $('#hud').style.display = (n==='overworld')?'':'none';
    $('#touchpad').style.display = (n==='overworld' && document.body.classList.contains('touch'))?'grid':'none';
    if(n!=='title'){ fitAll(); }
  }
};

/* ============ OVERLAY / PANEL ============ */
const Overlay={busy:false};
function openPanel(title, fillFn, opts={}){
  Overlay.busy=true;
  const ov=el('div','modal-scrim');
  const panel=el('div','panel'); if(opts.wide)panel.style.width='min(760px,96vw)';
  const head=el('div','panel-head');
  head.innerHTML='<div class="panel-title">'+title+'</div>';
  const close=btn('✕','btn icon sm',()=>closeThis());
  head.appendChild(close);
  panel.appendChild(head);
  const body=el('div','panel-body');
  panel.appendChild(body);
  ov.appendChild(panel);
  $('#overlay-root').appendChild(ov);
  let closed=false;
  function closeThis(){ if(closed)return; closed=true; ov.remove(); syncOverlayBusy(); }
  fillFn(body, closeThis);
  // close on scrim click
  ov.addEventListener('mousedown',(e)=>{ if(e.target===ov && opts.scrimClose!==false) closeThis(); });
  return closeThis;
}
function closePanel(){ const ms=$$('.modal-scrim'); if(ms.length){ ms[ms.length-1].remove(); } syncOverlayBusy(); }
function syncOverlayBusy(){ Overlay.busy = !!$('.modal-scrim'); }

/* ============ DIALOGUE ============ */
const Dialogue={busy:false};
function dialogue(text, speaker, onClose){
  Dialogue.busy=true;
  const d=$('#dialogue'); d.classList.remove('hidden');
  d.innerHTML='<div class="dlg-box"><div class="dlg-speaker">'+(speaker||'')+'</div><div class="dlg-text">'+text+'</div><div class="dlg-prompt">tap / space</div></div>';
  let done=false;
  function finish(){ if(done)return; done=true; Dialogue.busy=false; d.classList.add('hidden'); d.innerHTML=''; if(onClose)onClose(); }
  d.querySelector('.dlg-box').addEventListener('click',finish);
  Dialogue._finish=finish;
}
function dialoguePanel(title, html, awaitTap){
  return new Promise(resolve=>{
    Dialogue.busy=true;
    const ov=el('div','modal-scrim'); const panel=el('div','panel'); panel.style.maxWidth='460px';
    panel.innerHTML='<div class="panel-head"><div class="panel-title">'+title+'</div></div>';
    const body=el('div','panel-body'); body.innerHTML=html; panel.appendChild(body);
    ov.appendChild(panel); $('#overlay-root').appendChild(ov);
    let done=false;
    function finish(){ if(done)return; done=true; Dialogue.busy=false; ov.remove(); resolve(); }
    panel.addEventListener('click',finish);
    Dialogue._finish=finish;
  });
}

/* ============ TOAST ============ */
function toast(msg, cls){
  const t=el('div','toast '+(cls||''), msg);
  $('#toasts').appendChild(t);
  setTimeout(()=>t.remove(),2700);
}

/* ============ TRANSITION FLASH ============ */
function transitionFlash(color, dur, cb){
  const f=el('div'); f.style.cssText='position:absolute;inset:0;z-index:90;background:'+(color||'#000')+';opacity:0;transition:opacity '+(dur/2)+'ms;pointer-events:none;';
  $('#game').appendChild(f);
  requestAnimationFrame(()=>{ f.style.opacity='1'; });
  setTimeout(()=>{ if(cb)cb(); f.style.opacity='0'; setTimeout(()=>f.remove(),dur/2+50); }, dur/2);
}

/* ============ STORY SYSTEM ============ */
const STORY={
  intro:{title:'The Fracture', text:'Long ago the Prismatic Veil split, scattering seven affinities across Oraveth. You are a Binder — one who walks the rifts and binds the Lumari, the living shards of light.\n\nMend the veil. Fell the Four Anchors. Gather eight kindred of light before the last dawn fades.'},
  three:{title:'A Binding Deepens', text:'Three Lumari now walk with you. Their resonance steadies your step. The Anchors stir in their distant spires.'},
  firstAnchor:{title:'The Veil Thins', text:'An Anchor falls. A thread of the old fracture goes slack. Somewhere, the Hollow Crown takes notice.'},
  finale:{title:'The Last Dawn', text:'The Four Anchors lie still. Eight kindred of light ring the spire. The Veil mends, thread by thread — and Oraveth breathes again.\n\nYet the deep void hums with harder things. Free roam awaits the bold.'},
};
function showStoryIfNeeded(){
  const f=S.flags;
  // intro only on new game (handled in boot)
  if(!f.storyShown3 && uniqueSpeciesCount()>=3){ f.storyShown3=true; return showVignette(STORY.three,()=>{}); }
  if(!f.storyShownFirstAnchor && anchorsDefeated()>=1){ f.storyShownFirstAnchor=true; return showVignette(STORY.firstAnchor,()=>{}); }
  // win condition
  if(!f.won && anchorsDefeated()>=WIN_ANCHORS && uniqueSpeciesCount()>=WIN_UNIQUE){
    f.won=true; f.postgame=true;
    return showVignette(STORY.finale,()=>{ toast('Post-game unlocked: the void spawns sturdier Lumari.','good'); });
  }
}
function showVignette(s, after){
  const ov=$('#story-overlay'); ov.style.display='flex';
  ov.querySelector('.s-title').textContent=s.title;
  ov.querySelector('.s-text').textContent=s.text;
  Audio.sfx.boss();
  let done=false;
  function fin(){ if(done)return; done=true; ov.style.display='none'; after&&after(); }
  ov.onclick=fin; Dialogue._finish=fin; Dialogue.busy=true;
}

/* ============ HUD ============ */
function updateHUD(){
  const info=$('#hud-info');
  info.innerHTML='<span>◈ <b>'+S.credits+'</b> lumens</span><span>Bindings: <b>'+uniqueSpeciesCount()+'</b>/'+WIN_UNIQUE+'</span><span>Anchors: <b>'+anchorsDefeated()+'</b>/'+WIN_ANCHORS+'</span>'
    +(S.repelSteps>0?'<span>Repel: '+S.repelSteps+'</span>':'');
  // right buttons
  const r=$('#hud-right'); r.innerHTML='';
  r.appendChild(menuBtn('Team','openTeam()'));
  r.appendChild(menuBtn('Bag','openBag()'));
  r.appendChild(menuBtn('Codex','openCodex()'));
  r.appendChild(menuBtn('Map','openMap()'));
  r.appendChild(menuBtn('☰','openPause()'));
  // touch actions
  const ta=$('#touch-actions'); ta.innerHTML='';
  if(Screen.state==='overworld'){
    const a=el('button','btn sm','Menu'); a.addEventListener('click',openPause); ta.appendChild(a);
  }
}
function menuBtn(label,onclick){ const b=el('button','btn icon sm',label); b.addEventListener('click',()=>{Audio.sfx.select(); new Function(onclick)();}); return b; }
window.openTeam=openTeam; window.openBag=openBag; window.openCodex=openCodex; window.openMap=openMap; window.openPause=openPause;

/* ============ TEAM PANEL ============ */
function openTeam(){ openPanel('Your Bindings',(body)=>{
  S.party.forEach((c,i)=>{
    const row=el('div','team-row');
    row.innerHTML=teamRowInner(c);
    const acts=el('div','flex-row'); acts.style.marginTop='6px';
    const def=CREATURES[c.species];
    // transform
    if(def.transform && c.level>=def.transform.lv && !c.catalystUsed){
      const cat=def.transform.catalyst;
      const tb=btn('Transform ('+ITEMS[cat].name+')','btn sm gold');
      if(!hasItem(cat)){ tb.disabled=true; tb.title='Need '+ITEMS[cat].name; }
      else tb.addEventListener('click',()=>{ doTransform(c,def.transform); });
      acts.appendChild(tb);
    }
    // heal item quick
    if(c.hp<c.maxhp && hasItem('mendsap')){ const h=btn('Mendsap','btn sm'); h.addEventListener('click',()=>{ applyHealItem('mendsap',c); useItem('mendsap'); row.innerHTML=teamRowInner(c); updateHUD(); Audio.sfx.heal(); }); acts.appendChild(h); }
    if(c.status && hasItem('panacea')){ const p=btn('Panacea','btn sm'); p.addEventListener('click',()=>{ applyHealItem('panacea',c); useItem('panacea'); row.innerHTML=teamRowInner(c); Audio.sfx.heal(); }); acts.appendChild(p); }
    const info=el('div','spacer'); acts.appendChild(info);
    body.appendChild(row); body.appendChild(acts);
  });
  if(S.box.length){ body.appendChild(el('div','sep')); body.appendChild(el('div','menu-title','Reserve ('+S.box.length+')')); 
    S.box.forEach((c,i)=>{ const row=el('div','team-row'); row.innerHTML=teamRowInner(c);
      const sw=btn('To Party','btn sm'); if(S.party.length>=PARTY_MAX)sw.disabled=true;
      sw.addEventListener('click',()=>{ S.party.push(S.box.splice(i,1)[0]); closePanel(); openTeam(); });
      row.appendChild(sw); body.appendChild(row); });
  }
  body.appendChild(el('div','sep')); body.appendChild(btn('Close','btn sm',closePanel));
});}
function doTransform(c, tr){
  const cat=tr.catalyst;
  if(!useItem(cat)){ toast('Missing catalyst.','bad'); return; }
  c.catalystUsed=true;
  c.species=tr.to;
  c.affinity=CREATURES[tr.to].type;
  recomputeStats(c);
  // learn new abilities up to 4 (strongest available at current level)
  const avail=knownAbilitiesForLevel(tr.to, c.level);
  c.abilities=avail.slice(-4);
  c.pp={}; c.abilities.forEach(ab=>c.pp[ab]=ABILITIES[ab].pp);
  c.hp=c.maxhp; c.status=null; c.boosts={atk:0,def:0,spd:0};
  Audio.sfx.levelup();
  toast(CREATURES[c.species].name+' transformed!','good');
  closePanel(); openTeam(); updateHUD();
}
function teamRowInner(c){
  const def=CREATURES[c.species];
  const hpFrac=c.hp/c.maxhp;
  const hpCls=hpFrac>0.5?'':(hpFrac>0.25?'mid':'low');
  let chips='';
  if(c.status){const si=STATUS_INFO[c.status.type];if(si)chips+='<span class="chip" style="background:'+si.color+';color:#0a0e1a">'+si.label+'</span>';}
  return '<div class="team-ava"></div>'
    +'<div class="team-info"><div class="tn">'+def.name+' <span class="type-tag" style="background:'+TYPE_COLORS[def.type]+';color:#0a0e1a">'+def.type+'</span> <span class="small">Lv'+c.level+(c.catalystUsed?' ✦':'')+'</span></div>'
    +'<div class="td">ATK '+c.atk+' · DEF '+c.def+' · SPD '+c.spd+(chips?' · '+chips:'')+'</div></div>'
    +'<div class="team-hp"><div class="hp-shell"><div class="hp-fill '+hpCls+'" style="width:'+(hpFrac*100)+'%"></div></div><div class="small" style="text-align:right">'+Math.ceil(c.hp)+'/'+c.maxhp+'</div></div>';
}
function teamListHTML(party, onClick){
  const wrap=el('div');
  party.forEach(c=>{ const row=el('div','team-row'); row.style.cursor='pointer'; row.innerHTML=teamRowInner(c);
    row.addEventListener('click',()=>onClick(c)); wrap.appendChild(row); });
  return wrap;
}

/* ============ BAG PANEL ============ */
function openBag(){ openPanel('Satchel',(body)=>{
  let any=false;
  Object.keys(S.inv).forEach(id=>{
    const n=S.inv[id]; if(n<=0)return; const it=ITEMS[id]; if(!it)return; any=true;
    const row=el('div','shop-item');
    row.innerHTML='<div class="si-l"><div class="si-n">'+it.name+' ×'+n+'</div><div class="si-d">'+it.desc+'</div></div>';
    const use=btn('Use','btn sm');
    use.addEventListener('click',()=>useFieldItem(id,()=>{ closePanel(); openBag(); }));
    if(it.kind==='capture'||it.kind==='catalyst'){ use.textContent='—'; use.disabled=true; }
    row.appendChild(use); body.appendChild(row);
  });
  if(!any)body.appendChild(el('div','small center-txt','Your satchel is empty.'));
  body.appendChild(el('div','sep')); body.appendChild(btn('Close','btn sm',closePanel));
});}
function useFieldItem(id, after){
  const it=ITEMS[id];
  if(it.kind==='field'){ S.repelSteps=it.steps; useItem(id); toast('Wild Lumari will avoid you for '+it.steps+' steps.','good'); Audio.sfx.confirm(); after(); return; }
  if(it.kind==='warp'){ useItem(id); toast('The sigil pulls you to the shrine.','good'); enterZone(S.flags.lastShrine,true); after(); return; }
  // heal items: pick target
  openPanel('Use on which Lumari?',(b)=>{
    b.appendChild(teamListHTML(S.party,(c)=>{
      const ok=it.kind==='revive'?c.hp<=0:(c.hp>0&&(c.hp<c.maxhp||c.status));
      if(!ok){toast('No effect.','bad');return;}
      applyHealItem(id,c); useItem(id); Audio.sfx.heal(); closePanel(); after();
    }));
    b.appendChild(btn('Back','btn sm',closePanel));
  },{scrimClose:false});
}

/* ============ CODEX ============ */
function openCodex(){ openPanel('Bestiary of Light',(body)=>{
  Object.keys(CREATURES).forEach(id=>{
    const def=CREATURES[id]; const seen=!!S.seen[id]; const owned=S.collected[id]||0;
    const card=el('div','codex-card'+(seen?'':' locked'));
    const can=el('canvas','codex-ava'); can.width=64;can.height=64;
    const cx=can.getContext('2d');
    if(seen){ _drawBody(cx, def, 32,30,0.8,1,0,false,null); }
    card.appendChild(can);
    const info=el('div','div'); info.style.flex='1';
    info.innerHTML='<div class="codex-name">'+(seen?def.name:'???')+' <span class="type-tag" style="background:'+TYPE_COLORS[def.type]+';color:#0a0e1a">'+def.type+'</span></div>'
      +'<div class="codex-lore">'+(seen?def.lore:'A Lumari you have not yet encountered.')+'</div>'
      +(seen?'<div class="codex-stats"><span class="codex-stat">HP <b>'+def.base.hp+'</b></span><span class="codex-stat">ATK <b>'+def.base.atk+'</b></span><span class="codex-stat">DEF <b>'+def.base.def+'</b></span><span class="codex-stat">SPD <b>'+def.base.spd+'</b></span><span class="codex-stat">×'+(owned+(S.party.some(c=>c.species===id)||S.box.some(c=>c.species===id)?1:0))+' bound</span></div>':'');
    card.appendChild(info); body.appendChild(card);
  });
  body.appendChild(el('div','sep')); body.appendChild(btn('Close','btn sm',closePanel));
},{wide:true});}

/* ============ MAP ============ */
function openMap(){ openPanel('Worldrift Map',(body)=>{
  const grid=el('div','map-grid');
  ZONES.forEach((z,i)=>{
    const cell=el('div','map-cell'+(S.flags.zonesUnlocked[i]?' unlocked':'')+(i===S.zone?' current':''));
    const guardianDown = S.flags.anchors[i];
    cell.innerHTML='<div class="mi" style="color:'+z.palette.accent+'">◈</div><div class="mn">'+z.name+'</div><div class="md">'+z.sub+'</div>'
      +(i===S.zone?'<div class="md" style="color:var(--accent)">here</div>':'')
      +(!S.flags.zonesUnlocked[i]?'<div class="md">sealed</div>':(z.anchor?(guardianDown?'<div class="md" style="color:var(--good)">anchor fallen</div>':'<div class="md" style="color:var(--bad)">anchor stands</div>'):'<div class="md">open</div>'));
    if(S.flags.zonesUnlocked[i] && i!==S.zone){
      cell.addEventListener('click',()=>{ closePanel(); travelTo(i); });
    }
    grid.appendChild(cell);
  });
  body.appendChild(grid);
  body.appendChild(el('div','sep')); body.appendChild(btn('Close','btn sm',closePanel));
});}
function travelTo(i){
  $$('.modal-scrim').forEach(m=>m.remove()); syncOverlayBusy();
  dialogue('Travel to '+ZONES[i].name+'?','Riftstep',()=>{ enterZone(i,true); updateHUD(); });
}

/* ============ SHRINE ============ */
function openShrine(){
  S.flags.lastShrine=S.zone;
  dialogue('You touch the shrine. Rest to heal and bind your progress, or trade lumens with the wayfarer.','Shrine',()=>{
    openPanel('Wayshrine',(body)=>{
      body.appendChild(el('div','center-txt','<div class="small">'+ZONES[S.zone].storyNode+'</div>'));
      const b=el('div','flex-row center'); b.style.marginTop='12px';
      b.appendChild(btn('Rest & Save','btn primary',()=>{ healParty(S.party); S.repelSteps=0; saveGame(); Audio.sfx.heal(); closePanel(); toast('Your bindings are mended and your journey preserved.','good'); }));
      b.appendChild(btn('Wayfarer','btn',openShop));
      b.appendChild(btn('Map','btn',openMap));
      body.appendChild(b);
      body.appendChild(el('div','sep')); body.appendChild(btn('Continue','btn sm',closePanel));
    });
  });
}
function openShop(){
  openPanel('Wayfarer\'s Wares',(body)=>{
    const shop=ZONES[S.zone].shop||{};
    body.appendChild(el('div','small','You have '+S.credits+' lumens.'));
    body.appendChild(el('div','sep'));
    Object.keys(shop).forEach(id=>{
      const price=shop[id]; const it=ITEMS[id];
      const row=el('div','shop-item');
      row.innerHTML='<div class="si-l"><div class="si-n">'+it.name+' <span class="small">('+price+'◈)</span></div><div class="si-d">'+it.desc+'</div></div>';
      const buy=btn('Buy','btn sm');
      buy.addEventListener('click',()=>{ if(S.credits<price){toast('Not enough lumens.','bad');return;} S.credits-=price; addItem(id,1); Audio.sfx.confirm(); toast('Bought '+it.name+'.','good'); closePanel(); openShop(); });
      row.appendChild(buy); body.appendChild(row);
    });
    body.appendChild(el('div','sep')); body.appendChild(btn('Back','btn sm',closePanel));
  });
}

/* ============ PAUSE ============ */
function openPause(){
  openPanel('Paused',(body)=>{
    const b=el('div','flex-row center');
    b.appendChild(btn('Resume','btn primary',closePanel));
    b.appendChild(btn('Save','btn',()=>{ saveGame(); }));
    b.appendChild(btn('Mute: '+(Audio.isMuted()?'Off':'On'),'btn',()=>{ const m=!Audio.isMuted(); Audio.setMuted(m); S.muted=m; document.body.classList.toggle('muted',m); closePanel(); openPause(); }));
    b.appendChild(btn('Codex','btn',openCodex));
    body.appendChild(b);
    body.appendChild(el('div','sep'));
    const q=btn('Quit to Title','btn danger',()=>{
      if(confirm('Return to title? Your last shrine save is kept.')){ closePanel(); Screen.show('title'); Audio.setPad(-1); buildTitle(); }
    });
    body.appendChild(q);
  });
}

/* ============ TITLE / BOOT ============ */
function buildTitle(){
  const tb=$('#title-btns'); tb.innerHTML='';
  const nb=btn('New Binding','btn primary',startNewGame);
  tb.appendChild(nb);
  if(hasSave()){ const cb=btn('Continue','btn gold',continueGame); tb.appendChild(cb); }
}
function startNewGame(){
  Audio.init();
  S=freshState();
  pickStarter();
}
function pickStarter(){
  openPanel('Choose Your First Binding',(body)=>{
    body.appendChild(el('div','small center-txt','Three Lumari answer the rift. Choose one to walk with you.'));
    const grid=el('div','flex-row center'); grid.style.gap='12px'; grid.style.marginTop='10px';
    STARTERS.forEach(id=>{
      const def=CREATURES[id];
      const card=el('div'); card.style.cssText='background:rgba(10,16,30,.6);border:1px solid var(--glass-brd);border-radius:16px;padding:12px;width:150px;text-align:center;cursor:pointer;';
      const can=el('canvas'); can.width=120;can.height=100; const cxx=can.getContext('2d');
      _drawBody(cxx, def, 60,55,1.3,1,0,false,null);
      card.appendChild(can);
      card.innerHTML+='<div style="font-weight:800;margin-top:6px">'+def.name+'</div><div class="small">'+def.type+' type</div><div class="small" style="margin-top:6px;line-height:1.5">'+def.lore+'</div>';
      card.addEventListener('click',()=>{ Audio.sfx.confirm(); closePanel(); beginGameWith(id); });
      grid.appendChild(card);
    });
    body.appendChild(grid);
  },{scrimClose:false});
}
function beginGameWith(starterId){
  S.party=[makeCreature(starterId,5)];
  S.seen[starterId]=true; S.collected[starterId]=1;
  Screen.show('overworld');
  enterZone(0,false);
  S.px=2;S.py=MAP_H-3; playerRender={x:S.px,y:S.py};
  updateHUD();
  fitAll();
  showTutorialIntro();
}
function showTutorialIntro(){
  // story intro + tutorial
  Dialogue.busy=true;
  const ov=$('#story-overlay'); ov.style.display='flex';
  ov.querySelector('.s-title').textContent=STORY.intro.title;
  ov.querySelector('.s-text').textContent=STORY.intro.text;
  ov.onclick=()=>{
    ov.style.display='none'; Dialogue.busy=false;
    dialogue('Welcome to '+ZONES[0].name+'. Use WASD or arrow keys to move (or the on-screen pad). Walk into the swaying light-grass to find wild Lumari. The shrine beside you will mend your team.','A Voice in the Rift',()=>{
      S.flags.storyStage=1;
      toast('Tip: defeat the Guardian (marked “!”) to open the path onward.','good');
    });
  };
}
function continueGame(){
  Audio.init();
  const d=loadGame(); if(!d){ toast('No save found.','bad'); return; }
  S=d;
  // sanitize
  if(!S.flags) S.flags=freshState().flags;
  if(!S.flags.anchors) S.flags.anchors={};
  if(!S.flags.zonesUnlocked) S.flags.zonesUnlocked=[true,false,false,false,false,false];
  if(S.flags.lastShrine==null) S.flags.lastShrine=0;
  if(!S.inv) S.inv={glimlamp:5};
  S.party.forEach(c=>{ if(!c.pp)c.pp={}; c.abilities.forEach(ab=>{ if(c.pp[ab]==null)c.pp[ab]=ABILITIES[ab].pp; }); recomputeStats(c); if(c.hp>c.maxhp)c.hp=c.maxhp; });
  Screen.show('overworld');
  Audio.setMuted(!!S.muted); document.body.classList.toggle('muted',!!S.muted);
  enterZone(S.zone,false);
  playerRender={x:S.px,y:S.py};
  updateHUD();
  fitAll();
  toast('Welcome back, Binder.','good');
}

/* ============ INPUT ============ */
function bindInput(){
  const keymap={ArrowUp:[0,-1],KeyW:[0,-1],ArrowDown:[0,1],KeyS:[0,1],ArrowLeft:[-1,0],KeyA:[-1,0],ArrowRight:[1,0],KeyD:[1,0]};
  window.addEventListener('keydown',(e)=>{
    if(Screen.state==='title'){
      if(e.code==='Enter'||e.code==='Space'){ if(!Overlay.busy){ startNewGame(); } }
      return;
    }
    // dialogue confirm
    if(Dialogue.busy){
      if(e.code==='Enter'||e.code==='Space'){ if(Dialogue._finish)Dialogue._finish(); }
      return;
    }
    if(Overlay.busy) return;
    if(Screen.state==='overworld'){
      if(keymap[e.code]){ e.preventDefault(); tryMove(keymap[e.code][0],keymap[e.code][1]); }
      if(e.code==='Escape'){ openPause(); }
    }
  });
  // touch dpad
  const bind=(id,dx,dy)=>{ const b=$('#'+id); const h=(ev)=>{ ev.preventDefault(); tryMove(dx,dy); };
    b.addEventListener('touchstart',h,{passive:false}); b.addEventListener('mousedown',h); };
  bind('dp-up',0,-1); bind('dp-down',0,1); bind('dp-left',-1,0); bind('dp-right',1,0);
}
function showActionMenuIdle(){ /* placeholder; action menu only in battle */ }

/* ============ MASTER LOOP ============ */
function masterLoop(now){
  const dt=Math.min(0.05,(now-(masterLoop._last||now))/1000); masterLoop._last=now;
  if(Screen.state==='title'){ drawTitleBG(now); }
  else if(Screen.state==='overworld'){ if(map) renderOverworld(now/1000, dt); }
  else if(Screen.state==='battle'){ renderBattle(dt); }
  requestAnimationFrame(masterLoop);
}

/* ============ INIT ============ */
function detectTouch(){ return ('ontouchstart' in window)||navigator.maxTouchPoints>0; }
window.addEventListener('load',()=>{
  if(detectTouch()) document.body.classList.add('touch');
  fitAll(); initTitleStars(); fitCanvas(tbg);
  buildTitle();
  bindInput();
  requestAnimationFrame(masterLoop);
});
window.addEventListener('click',function once(){ Audio.init(); window.removeEventListener('click',once); });
</script>
</body>
</html>
<!-- agent-meta {"model":"glm-5.2","provider":"zai","persona":"middle","sessionId":"079a8640-8107-459a-aa58-0a95e0fc521d","tokensIn":3970831,"tokensOut":89110,"tokensTotal":4059941,"turns":53,"toolCalls":52,"failedToolCalls":0,"timestamp":"2026-07-12T22:51:04.862Z"} -->