← Creature Collection results

Creature Collection

muse spark 1.1 · composer

Initial view of Creature Collection

Reviews & generation details

Reviews

Human review · Jul 17, 2026 · rubric 1

Historical path-based review. Association with these exact bytes is unverified.

Visual design
3 / 5
UX / feel
3 / 5
Works
3 / 5
Prompt spirit
3 / 5
Delight
3 / 5

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 88,722 bytes · SHA-256 cfd7914ad7dc
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AESTRAL BINDING // Original Binder Saga</title>
<style>
/* ── css: base ── */
:root{
 --c0:#0e141a; --c1:#1b2a36; --glass:rgba(18,28,38,.58); --glass2:rgba(255,255,255,.08);
 --embyr:#ff6a3d; --ossra:#4dd0e1; --thryne:#7ad17a; --vael:#a8c0ff; --klyra:#ffe45e; --irid:#e8a0ff; --nyth:#8a8ea6;
 --text:#e9f1f7; --muted:#9fb3c5;
}
*{box-sizing:border-box; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;}
html,body{margin:0;padding:0;background:var(--c0);color:var(--text);overflow:hidden;width:100%;height:100%;}
#app{position:relative;width:100vw;height:100vh;overflow:hidden;}
#bgCanvas{position:absolute;inset:0;width:100%;height:100%;z-index:0;filter:blur(0px);}
.glass{backdrop-filter:blur(14px) saturate(1.3); -webkit-backdrop-filter:blur(14px) saturate(1.3); background:var(--glass); border:1px solid var(--glass2); border-radius:18px; box-shadow:0 8px 32px rgba(0,0,0,.4), inset 0 1px 0 rgba(255,255,255,.08);}
.overlay{position:absolute;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:.6s all; background: radial-gradient(120% 120% at 30% 20%, rgba(255,110,60,.15), transparent 50%), radial-gradient(120% 120% at 80% 80%, rgba(120,200,255,.15), transparent 60%), rgba(7,12,16,.88);}
.overlay.active{opacity:1;pointer-events:auto;}
.titleCard{width:min(680px,94vw); padding:36px 28px 28px; text-align:center;}
.titleSigil{width:88px;height:88px;margin:0 auto 12px;border-radius:50%;background:conic-gradient(from 0deg, var(--embyr), var(--ossra), var(--thryne), var(--vael), var(--klyra), var(--irid), var(--nyth), var(--embyr)); animation:spin 8s linear infinite; box-shadow:0 0 40px rgba(255,106,61,.4), inset 0 0 20px rgba(255,255,255,.6);}
.titleCard h1{font-size:52px;line-height:.9;letter-spacing:.18em;font-weight:900;margin:8px 0; text-shadow:0 0 20px rgba(255,255,255,.2);}
.subtitle{letter-spacing:.25em;text-transform:uppercase;color:var(--muted);font-size:11px;margin-bottom:14px;}
.loreIntro{font-size:13px;line-height:1.6;color:#c9d9e6;max-width:520px;margin:0 auto 18px;opacity:.9;}
.titleBtns{display:flex;gap:12px;justify-content:center;margin:12px 0;}
button{appearance:none;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.06);color:var(--text);padding:10px 18px;border-radius:12px;cursor:pointer;font-weight:700;letter-spacing:.06em;transition:.2s; font-size:12px;}
button:hover{transform:translateY(-1px);background:rgba(255,255,255,.12);box-shadow:0 4px 16px rgba(0,0,0,.3);}
button.primary{background:linear-gradient(180deg, rgba(255,255,255,.18), rgba(255,255,255,.04)); border-color:rgba(255,255,255,.2); box-shadow:0 0 20px rgba(255,106,61,.25);}
button:active{transform:translateY(0);}
#starterRow{display:flex;gap:10px;justify-content:center;margin-top:10px;flex-wrap:wrap;}
.starterLabel{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);margin-top:18px;}
.starter{width:150px;padding:12px;border-radius:14px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);cursor:pointer;transition:.2s;text-align:center;}
.starter:hover{transform:translateY(-3px) scale(1.02);background:rgba(255,255,255,.08);}
.starter.selected{outline:2px solid var(--embyr); box-shadow:0 0 20px rgba(255,106,61,.4);}
.starter b{display:block;margin-top:6px;font-size:13px;}
.starter i{font-style:normal;font-size:10px;color:var(--muted);display:block;margin-top:2px;}
.starterIcon{font-size:22px;display:block;}
.starterIcon.embyr{color:var(--embyr);} .starterIcon.ossra{color:var(--ossra);} .starterIcon.thryne{color:var(--thryne);}

#gameWrap{position:absolute;inset:0;z-index:2;display:flex;flex-direction:column;}
#topHud{display:flex;justify-content:space-between;align-items:center;margin:12px 12px 0;padding:10px 14px; font-size:11px; letter-spacing:.08em;}
.hudLeft,.hudRight{display:flex;gap:12px;align-items:center;}
#playArea{position:relative;flex:1;margin:12px;overflow:hidden;border-radius:22px; border:1px solid rgba(255,255,255,.08); background:#0b1116; box-shadow:inset 0 0 120px rgba(0,0,0,.6);}
#worldCanvas{width:100%;height:100%;display:block;image-rendering: crisp-edges;}
#worldFx{position:absolute;inset:0;pointer-events:none;overflow:hidden;}
#touchPad{position:absolute;left:14px;bottom:14px;display:none;flex-direction:column;align-items:center;gap:4px; z-index:6;}
#touchPad button{width:48px;height:48px;padding:0;border-radius:14px;background:rgba(20,30,40,.6);backdrop-filter:blur(8px);font-size:16px;}
#touchPad #tAct{margin-top:8px;background:rgba(255,106,61,.4);border-color:rgba(255,106,61,.5);}
#bottomLog{margin:0 12px 12px;padding:10px 14px;font-size:11px;color:#b8cde0;min-height:22px;display:flex;align-items:center;}
#battleOverlay{position:absolute;inset:0;background:rgba(6,10,14,.92);z-index:10; display:flex;flex-direction:column;}
#battleOverlay.hidden{display:none;}
#battleCanvas{width:100%;height:68%;display:block;}
#battleUI{flex:1;padding:10px;display:flex;gap:10px; flex-wrap:wrap; align-content:flex-start; overflow:auto;}
#battleUI .moveBtn{flex:1 1 46%; text-align:left; position:relative; overflow:hidden; min-height:64px;}
.moveBtn .mtag{font-size:9px;opacity:.7;letter-spacing:.12em;display:block;}
.moveBtn .mname{font-size:13px;display:block;margin-top:2px;}
.moveBtn .mmeta{font-size:10px;color:var(--muted);}
#modalRoot{position:absolute;inset:0;z-index:20;background:rgba(5,9,12,.78);backdrop-filter:blur(16px);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:.3s;}
#modalRoot.active{opacity:1;pointer-events:auto;}
.modalCard{width:min(860px,96vw);max-height:86vh;overflow:auto;padding:18px;}
.modalHead{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;}
#captureGame{position:absolute;inset:0;z-index:30;background:rgba(0,0,0,.72);display:flex;align-items:center;justify-content:center;}
#captureGame.hidden{display:none;}
.capBox{padding:22px;width:min(420px,92vw);text-align:center;}
#capTrack{position:relative;height:18px;background:rgba(255,255,255,.08);border-radius:20px;margin:18px 0;overflow:hidden;border:1px solid rgba(255,255,255,.12);}
#capZone{position:absolute;top:0;bottom:0;width:22%;left:39%;background:linear-gradient(90deg, var(--embyr), var(--klyra)); border-radius:20px; box-shadow:0 0 12px var(--embyr);}
#capCursor{position:absolute;top:-4px;width:4px;height:26px;background:white;left:0; box-shadow:0 0 8px white; border-radius:2px; transition: left .02s linear;}
#capTries{display:flex;gap:6px;justify-content:center;margin-top:8px;}
.dot{width:10px;height:10px;border-radius:50%;background:rgba(255,255,255,.15);}
.dot.hit{background:var(--thryne);box-shadow:0 0 8px var(--thryne);}
.dot.miss{background:var(--embyr);}
#cutin{position:absolute;inset:0;z-index:35;background:radial-gradient(100% 100% at 50% 50%, rgba(255,106,61,.12), rgba(0,0,0,.88)); display:flex;align-items:center;justify-content:center;}
#cutin.hidden{display:none;}
.cutinCard{width:min(560px,92vw);padding:28px;text-align:center;}
.cutinCard h2{font-size:26px;letter-spacing:.14em;margin:0 0 12px;}
.cutinCard p{font-size:13px;line-height:1.7;color:#c8d9e8;white-space:pre-line;}
#toast{position:absolute;left:50%;bottom:86px;transform:translateX(-50%);z-index:40;pointer-events:none;display:flex;flex-direction:column;gap:6px;align-items:center;}
.toastItem{background:rgba(15,25,35,.92);border:1px solid rgba(255,255,255,.12);padding:8px 14px;border-radius:12px;font-size:11px;backdrop-filter:blur(8px);animation:toastIn .5s ease, toastOut .5s ease 2.2s forwards;}
@keyframes toastIn{from{transform:translateY(20px) scale(.9);opacity:0}to{transform:none;opacity:1}}
@keyframes toastOut{to{transform:translateY(-20px) scale(.9);opacity:0}}
@keyframes spin{to{transform:rotate(360deg)}}
.particle{position:absolute;width:4px;height:4px;border-radius:50%;pointer-events:none;}
.damageFloat{position:absolute;font-weight:900;font-size:22px;text-shadow:0 2px 6px black;pointer-events:none; animation:floatUp .9s ease-out forwards;}
@keyframes floatUp{0%{transform:translateY(0) scale(1)} 20%{transform:translateY(-8px) scale(1.2)} 100%{transform:translateY(-46px) scale(.9);opacity:0}}
.barWrap{height:8px;background:rgba(0,0,0,.5);border-radius:10px;overflow:hidden;border:1px solid rgba(255,255,255,.1);}
.barFill{height:100%;transition:width .4s ease; background:linear-gradient(90deg, var(--thryne), #9cf29c);}
.hpRow{display:flex;gap:8px;align-items:center;margin:4px 0; font-size:11px;flex:1 1 100%;}
.enemyCard,.allyCard{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:8px 10px;min-width:140px;}
.allyCard.active{outline:1px solid var(--klyra); box-shadow:0 0 12px rgba(255,228,94,.25);}
.typeBadge{display:inline-block;padding:1px 6px;border-radius:20px;font-size:9px;letter-spacing:.08em;margin-left:6px;vertical-align:middle;}
.type-Embyr{background:rgba(255,106,61,.2);color:var(--embyr);border:1px solid rgba(255,106,61,.3)}
.type-Ossra{background:rgba(77,208,225,.18);color:var(--ossra);}
.type-Thryne{background:rgba(122,209,122,.18);color:var(--thryne);}
.type-Vael{background:rgba(168,192,255,.18);color:var(--vael);}
.type-Klyra{background:rgba(255,228,94,.18);color:var(--klyra);}
.type-Irid{background:rgba(232,160,255,.18);color:var(--irid);}
.type-Nyth{background:rgba(138,142,166,.18);color:var(--nyth);}
.invGrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;}
.codexGrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;}
.codexCard{padding:10px;border-radius:12px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);font-size:11px;line-height:1.5;}
.codexCard b{font-size:13px;display:block;}
.dexBar{height:4px;background:rgba(255,255,255,.1);border-radius:4px;overflow:hidden;margin-top:4px;}
.teamList{display:flex;flex-direction:column;gap:8px;}
.teamSlot{display:flex;align-items:center;gap:12px;padding:10px;border-radius:12px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);}
.teamSlot .lvl{margin-left:auto;font-size:11px;color:var(--muted);}
.mapMini{display:grid;gap:6px;}
.mapRow{display:flex;align-items:center;gap:8px;font-size:11px;}
.mapRow .dot2{width:12px;height:12px;border-radius:50%;}
@media (max-width:860px){#touchPad{display:flex}#topHud{font-size:10px} .titleCard h1{font-size:38px}}
</style>
</head>
<body>
<div id="appInner">
  <canvas id="bgCanvas"></canvas>
  <div id="titleScreen" class="overlay active">
    <div class="titleCard glass">
      <div class="titleSigil"></div>
      <h1>AESTRAL<br>BINDING</h1>
      <p class="subtitle">A Woven Codex of Vanth Creatures</p>
      <p class="loreIntro">When the world-shell cracked, seven Threads spilled: Embyr, Ossra, Thryne, Vael, Klyra, Irid, Nyth. You are a Binder.</p>
      <div class="titleBtns">
        <button id="btnNew" class="primary">New Binding</button>
        <button id="btnCont">Continue</button>
      </div>
      <div class="starterLabel">Choose your first Threadling:</div>
      <div class="starterRow" id="starterRow">
        <div class="starter" data-starter="0"><span class="starterIcon embyr">⟁</span><b>Cindrix</b><i>Embyr • Quick Pulse</i></div>
        <div class="starter" data-starter="1"><span class="starterIcon ossra">≋</span><b>Lurrin</b><i>Ossra • Tidal Mind</i></div>
        <div class="starter" data-starter="2"><span class="starterIcon thryne">❦</span><b>Brulven</b><i>Thryne • Rooted</i></div>
      </div>
    </div>
  </div>

  <div id="gameWrap">
    <div id="topHud" class="glass">
      <div class="hudLeft">
        <span id="zoneLabel">Haven • Embyr Hollow</span>
        <span id="storyStageLabel">Weave I</span>
      </div>
      <div class="hudRight">
        <span id="collectedLabel">Codex 0/27</span>
        <button id="btnMute" title="Mute">♪ ON</button>
        <button id="btnBag">Bag</button>
        <button id="btnTeam">Team</button>
        <button id="btnCodex">Lexicon</button>
        <button id="btnMap">Map</button>
      </div>
    </div>

    <div id="playArea">
      <canvas id="worldCanvas" width="960" height="640"></canvas>
      <div id="worldFx"></div>
      <div id="touchPad">
        <div class="tRow"><button data-dir="up">▲</button></div>
        <div class="tRow"><button data-dir="left">◀</button><button data-dir="down">▼</button><button data-dir="right">▶</button></div>
        <button id="tAct">◉</button>
      </div>
      <div id="battleOverlay" class="hidden">
        <canvas id="battleCanvas" width="960" height="440"></canvas>
        <div id="battleUI"></div>
      </div>
    </div>

    <div id="bottomLog" class="glass"><span id="logText">Use WASD / Arrows. Find shrines. Press ◉ near things.</span></div>
  </div>

  <div id="modalRoot" class="hidden"></div>
  <div id="captureGame" class="hidden"><div class="capBox glass"><h3>Thread Bind Rite</h3><div id="capTrack"><div id="capZone"></div><div id="capCursor"></div></div><p id="capHint">Tap / SPACE when center! </p><div id="capTries"></div></div></div>
  <div id="cutin" class="hidden"><div class="cutinCard glass"><h2 id="cutTitle"></h2><p id="cutBody"></p><button id="cutNext" class="primary">Continue</button></div></div>
  <div id="toast"></div>
</div>
<script>
'use strict';

// ── const: TYPES ──
const TYPES = ["Embyr","Ossra","Thryne","Vael","Klyra","Irid","Nyth"];

// ── const: TYPE_COLOR ──
const TYPE_COLOR = {Embyr:"#ff6a3d", Ossra:"#4dd0e1", Thryne:"#7ad17a", Vael:"#a8c0ff", Klyra:"#ffe45e", Irid:"#e8a0ff", Nyth:"#8a8ea6"};

// ── function: typeEffectiveness ──
// Chart implementation: returns multiplier attackerType vs defenderType(s)
function typeEffectiveness(atkType, defTypes){
  let m=1;
  for(let dt of defTypes){
    if(!dt) continue;
    // Triangle Embyr > Ossra > Thryne > Embyr
    if(atkType==="Embyr" && dt==="Ossra") m*=1.8;
    if(atkType==="Ossra" && dt==="Thryne") m*=1.8;
    if(atkType==="Thryne" && dt==="Embyr") m*=1.8;
    if(dt==="Embyr" && atkType==="Ossra") m*=0.6;
    if(dt==="Ossra" && atkType==="Thryne") m*=0.6;
    if(dt==="Thryne" && atkType==="Embyr") m*=0.6;
    // Vael > Embyr & Thryne
    if(atkType==="Vael" && (dt==="Embyr" || dt==="Thryne")) m*=1.8;
    if((atkType==="Embyr" || atkType==="Thryne") && dt==="Vael") m*=0.6;
    // Klyra > Vael & Ossra
    if(atkType==="Klyra" && (dt==="Vael" || dt==="Ossra")) m*=1.8;
    if((atkType==="Vael" || atkType==="Ossra") && dt==="Klyra") m*=0.6;
    // Irid beats all slightly 1.25x
    if(atkType==="Irid" && dt!=="Irid") m*=1.25;
    // Irid vs Nyth super effective 2.0 override
    if(atkType==="Irid" && dt==="Nyth") m*=1.6; // 1.25*1.6=2.0
    // Nyth resists all 0.6 except weak to Irid
    if(dt==="Nyth" && atkType!=="Irid" && atkType!=="Nyth") m*=0.6;
    if(atkType==="Nyth" && dt==="Nyth") m*=0.7;
    if(atkType==="Nyth" && dt==="Irid") m*=0.6;
  }
  return m;
}

// ── const: ABILITIES ──
const ABILITIES = [
  // Embyr
  {id:0,name:"Sear Touch",type:"Embyr",power:40,acc:100,pp:22,effect:"burn",desc:"10% burn, singes Thread"},
  {id:1,name:"Ashen Veil",type:"Embyr",power:0,acc:100,pp:14,effect:"barrier",desc:"Weave ash, block next hit"},
  {id:2,name:"Flare Rush",type:"Embyr",power:70,acc:90,pp:16,effect:"selfSpd",desc:"Dash leaves embers, +SPD"},
  {id:3,name:"Kindling Flurry",type:"Embyr",power:22,acc:92,pp:18,effect:"multihit",desc:"2-3 flits of fire"},
  {id:4,name:"Cinderfall",type:"Embyr",power:85,acc:82,pp:10,effect:"burn",desc:"Heavy ember shower"},
  {id:5,name:"Quill Burst",type:"Embyr",power:60,acc:95,pp:16,effect:"recoil10",desc:"Spiky heat, slight recoil"},
  {id:6,name:"Bloomignite",type:"Embyr",power:65,acc:100,pp:14,effect:"regen",desc:"Embyr+Thryne bloom heals"},
  // Ossra
  {id:7,name:"Tidal Slap",type:"Ossra",power:45,acc:100,pp:20,effect:"frost",desc:"15% frost slows"},
  {id:8,name:"Deep Pull",type:"Ossra",power:60,acc:92,pp:14,effect:"defdown",desc:"Lowers target DEF"},
  {id:9,name:"Aqua Mend",type:"Ossra",power:0,acc:100,pp:12,effect:"heal50",desc:"Restore 50% HP"},
  {id:10,name:"Brine Shot",type:"Ossra",power:55,acc:100,pp:18,effect:"none",desc:"Clean shot"},
  {id:11,name:"Loom Torrent",type:"Ossra",power:78,acc:86,pp:10,effect:"frost",desc:"Flood & chill"},
  {id:12,name:"Tide Turner",type:"Ossra",power:0,acc:100,pp:14,effect:"selfSpd",desc:"Redirect flow, +2 SPD stage"},
  // Thryne
  {id:13,name:"Thorn Lash",type:"Thryne",power:50,acc:100,pp:20,effect:"none",desc:"Whipping vine"},
  {id:14,name:"Spore Haze",type:"Thryne",power:0,acc:88,pp:14,effect:"shock",desc:"60% shock"},
  {id:15,name:"Root Oath",type:"Thryne",power:0,acc:100,pp:12,effect:"regen",desc:"Regen 1/8 per turn 4t"},
  {id:16,name:"Vine Bind",type:"Thryne",power:42,acc:94,pp:18,effect:"frost",desc:"Entangle halves SPD"},
  {id:17,name:"Canopy Rend",type:"Thryne",power:76,acc:86,pp:10,effect:"critup",desc:"High crit"},
  {id:18,name:"Thorn Barrage",type:"Thryne",power:18,acc:92,pp:16,effect:"multihit",desc:"3-4 thorns"},
  // Vael
  {id:19,name:"Gust Feather",type:"Vael",power:40,acc:100,pp:20,effect:"selfSpd",desc:"Light +SPD"},
  {id:20,name:"Sky Slash",type:"Vael",power:62,acc:96,pp:16,effect:"critup",desc:"Sharp wind"},
  {id:21,name:"Aerial Vault",type:"Vael",power:82,acc:84,pp:10,effect:"none",desc:"High dive"},
  {id:22,name:"Wind Wall",type:"Vael",power:0,acc:100,pp:14,effect:"barrier",desc:"Aero barrier"},
  {id:23,name:"Zephyr Feint",type:"Vael",power:32,acc:100,pp:16,effect:"priority",desc:"Always first"},
  {id:24,name:"Tempest Needle",type:"Vael",power:58,acc:100,pp:16,effect:"priority",desc:"Swift needle"},
  // Klyra
  {id:25,name:"Spark Bite",type:"Klyra",power:42,acc:100,pp:20,effect:"shock",desc:"20% shock"},
  {id:26,name:"Volt Surge",type:"Klyra",power:72,acc:90,pp:14,effect:"shock",desc:"Surge may paralyze"},
  {id:27,name:"Static Field",type:"Klyra",power:0,acc:92,pp:14,effect:"shock",desc:"Wide shock"},
  {id:28,name:"Thunderhowl",type:"Klyra",power:88,acc:80,pp:8,effect:"shock",desc:"Roaring thunder"},
  {id:29,name:"Charge Blink",type:"Klyra",power:30,acc:100,pp:18,effect:"priority",desc:"Blink first"},
  {id:30,name:"Overload",type:"Klyra",power:95,acc:78,pp:8,effect:"selfAtkDown",desc:"Huge power self -ATK"},
  // Irid
  {id:31,name:"Prism Ray",type:"Irid",power:60,acc:100,pp:16,effect:"none",desc:"Prismatic beam"},
  {id:32,name:"Iris Flicker",type:"Irid",power:52,acc:100,pp:18,effect:"critup",desc:"Flickering light"},
  {id:33,name:"Radiant Core",type:"Irid",power:0,acc:100,pp:10,effect:"healbarrier",desc:"Heal 40% + barrier"},
  {id:34,name:"Spectrum Break",type:"Irid",power:92,acc:85,pp:8,effect:"none",desc:"Shatter spectrum"},
  {id:35,name:"Lumen Crash",type:"Irid",power:100,acc:72,pp:6,effect:"selfDefDown",desc:"Crash immense"},
  // Nyth
  {id:36,name:"Null Gaze",type:"Nyth",power:0,acc:90,pp:14,effect:"atkdown",desc:"Halve ATK via dread"},
  {id:37,name:"Void Sheath",type:"Nyth",power:0,acc:100,pp:14,effect:"barrier",desc:"Void absorbs hit"},
  {id:38,name:"Drith Leech",type:"Nyth",power:50,acc:100,pp:16,effect:"healHalfDmg",desc:"Leech life"},
  {id:39,name:"Echo Fold",type:"Nyth",power:66,acc:94,pp:14,effect:"none",desc:"Folded echo"},
  {id:40,name:"Nyth Hunger",type:"Nyth",power:82,acc:86,pp:10,effect:"healHalfDmg",desc:"Devours, heals"},
  {id:41,name:"Soul Tether",type:"Nyth",power:0,acc:86,pp:12,effect:"shockburn",desc:"Shock & burn"},
  {id:42,name:"Dusk Rend",type:"Nyth",power:60,acc:100,pp:16,effect:"defdown",desc:"Rend defenses"},
];

// ── const: ITEMS ──
const ITEMS = {
  binders:[
    {id:"bind0",name:"Filament Bind",tier:1,bonus:0.7,price:25,desc:"Woven thread, basic"},
    {id:"bind1",name:"Lattice Bind",tier:2,bonus:1.15,price:90,desc:"Cross-weave stronger"},
    {id:"bind2",name:"Core Bind",tier:3,bonus:1.65,price:220,desc:"Pure core lattice"},
  ],
  heals:[
    {id:"heal0",name:"Moss Nectar",heal:50,price:20},
    {id:"heal1",name:"Aqua Vial",heal:120,price:55},
    {id:"heal2",name:"Resin Heart",heal:999,price:110,full:true},
  ],
  catalysts:[
    {id:"cat0",name:"Embyr Ember",type:"Embyr",price:80},
    {id:"cat1",name:"Ossra Tidemote",type:"Ossra",price:80},
    {id:"cat2",name:"Thryne Rootcore",type:"Thryne",price:80},
    {id:"cat3",name:"Vael Feather",type:"Vael",price:90},
    {id:"cat4",name:"Klyra Cell",type:"Klyra",price:90},
    {id:"cat5",name:"Irid Shard",type:"Irid",price:140},
    {id:"cat6",name:"Nyth Umb",type:"Nyth",price:140},
  ]
};

// ── const: CREATURES ──
const CREATURES = [
  {id:0,name:"Cindrix",types:["Embyr"], lore:"A cub that coughs cinders; its fur braids stray heat into floating sigils.", base:{hp:44,atk:62,def:38,spd:68}, growth:{hp:6,atk:5,def:4,spd:6}, learn:[[1,0],[5,1],[4,2],[11,6],[19,22]], evo:{to:18, lvl:24, cat:"cat0"}},
  {id:1,name:"Lurrin",types:["Ossra"], lore:"Axolotl-strider that carries a bowl of live brine on its back, humming tide-maps.", base:{hp:50,atk:52,def:52,spd:50}, growth:{hp:7,atk:4,def:5,spd:5}, learn:[[7,0],[10,1],[8,2],[9,12],[12,23]], evo:{to:19, lvl:24, cat:"cat1"}},
  {id:2,name:"Brulven",types:["Thryne"], lore:"Seed-golem whose bark heart opens each dawn to vent pollen memories.", base:{hp:56,atk:56,def:64,spd:38}, growth:{hp:7,atk:4,def:6,spd:3}, learn:[[13,0],[14,1],[16,2],[15,12],[17,24]], evo:{to:20, lvl:24, cat:"cat2"}},
  {id:3,name:"Zephine",types:["Vael"], lore:"Ribbon finch that never lands; it sleeps caught in slow air currents.", base:{hp:42,atk:58,def:40,spd:84}, growth:{hp:5,atk:4,def:3,spd:8}, learn:[[19,0],[20,1],[22,8],[21,20],[23,26]], evo:{to:21, lvl:26, cat:"cat3"}},
  {id:4,name:"Klyss",types:["Klyra"], lore:"Beetle that stores storm in bladder-sacs, clicking electric morse when excited.", base:{hp:46,atk:66,def:44,spd:62}, growth:{hp:5,atk:6,def:4,spd:5}, learn:[[25,0],[29,1],[26,8],[27,15],[28,28]], evo:{to:22, lvl:26, cat:"cat4"}},
  {id:5,name:"Iriden",types:["Irid"], lore:"Shardling that refracts possible futures, choosing the brightest.", base:{hp:48,atk:68,def:48,spd:70}, growth:{hp:5,atk:6,def:4,spd:6}, learn:[[31,0],[32,0],[33,10],[34,26],[35,34]], evo:null},
  {id:6,name:"Nythil",types:["Nyth"], lore:"Ink-pup with too many eyelids, turning to drink shadows when thirsty.", base:{hp:52,atk:60,def:58,spd:52}, growth:{hp:6,atk:5,def:5,spd:4}, learn:[[36,0],[38,0],[37,9],[39,18],[40,28]], evo:null},
  {id:7,name:"Flarrow",types:["Embyr","Vael"], lore:"Sparrow whose wings leave soot calligraphy after a dive.", base:{hp:40,atk:62,def:36,spd:78}, growth:{hp:4,atk:5,def:3,spd:7}, learn:[[0,0],[19,1],[2,8],[23,14],[34,30]], evo:{to:23, lvl:22, cat:null}},
  {id:8,name:"Tydrake",types:["Ossra","Thryne"], lore:"Juvenile kelpwyrm that gardens luminous algae along riverbanks.", base:{hp:58,atk:56,def:58,spd:40}, growth:{hp:7,atk:4,def:6,spd:3}, learn:[[10,0],[13,0],[8,6],[15,16],[11,26]], evo:{to:24, lvl:28, cat:"cat2"}},
  {id:9,name:"Thornid",types:["Thryne"], lore:"Ball of walking bramble, shy until it offers you a crown of thorns.", base:{hp:50,atk:64,def:50,spd:46}, growth:{hp:6,atk:5,def:4,spd:4}, learn:[[13,0],[18,1],[14,7],[16,15],[17,25]], evo:null},
  {id:10,name:"Aelwen",types:["Vael","Nyth"], lore:"Veil-moth that phases through stone, leaving cold fingerprints.", base:{hp:45,atk:58,def:45,spd:76}, growth:{hp:5,atk:4,def:4,spd:6}, learn:[[19,0],[36,0],[22,6],[42,14],[39,24]], evo:null},
  {id:11,name:"Volnix",types:["Klyra","Embyr"], lore:"Plasma hound that sneezes lightning; its bark carves glass.", base:{hp:48,atk:74,def:42,spd:60}, growth:{hp:5,atk:7,def:3,spd:5}, learn:[[25,0],[0,0],[2,10],[26,18],[30,30]], evo:{to:25, lvl:30, cat:"cat4"}},
  {id:12,name:"Myrloch",types:["Ossra"], lore:"Baby abyss serpent that mistakes lanterns for mothers.", base:{hp:62,atk:54,def:60,spd:36}, growth:{hp:8,atk:4,def:6,spd:3}, learn:[[7,0],[8,6],[10,12],[9,20],[11,30]], evo:{to:26, lvl:32, cat:null}},
  {id:13,name:"Sylvra",types:["Thryne","Vael"], lore:"Glider-cat that grows feather-moss; it sheds maps in autumn.", base:{hp:46,atk:60,def:48,spd:70}, growth:{hp:5,atk:5,def:4,spd:6}, learn:[[13,0],[19,0],[20,8],[15,14],[21,27]], evo:null},
  {id:14,name:"Korrin",types:["Klyra"], lore:"Mole with quartz quills that resonate before a storm.", base:{hp:54,atk:58,def:66,spd:34}, growth:{hp:6,atk:4,def:7,spd:3}, learn:[[25,0],[27,6],[24,10],[26,16],[28,29]], evo:null},
  {id:15,name:"Quillum",types:["Thryne","Embyr"], lore:"Porcupine that forged its quills in sunfire, still too hot to touch.", base:{hp:52,atk:70,def:56,spd:44}, growth:{hp:6,atk:6,def:5,spd:3}, learn:[[5,0],[13,0],[2,8],[6,16],[4,26]], evo:null},
  {id:16,name:"Drithor",types:["Nyth"], lore:"Floating eye wreathed in lint-shadow, blinks in directions that don't exist.", base:{hp:50,atk:72,def:42,spd:60}, growth:{hp:5,atk:7,def:3,spd:5}, learn:[[37,0],[36,0],[38,8],[40,18],[41,26]], evo:null},
  {id:17,name:"Orriax",types:["Irid"], lore:"Sentinel polyhedron that hums when truth is spoken nearby.", base:{hp:56,atk:66,def:72,spd:38}, growth:{hp:6,atk:5,def:7,spd:2}, learn:[[31,0],[33,6],[32,12],[34,22],[35,32]], evo:null},
  // Evolutions 18-26
  {id:18,name:"Cindryth",types:["Embyr","Irid"], lore:"Adult Cindrix, mane now living glass that casts warm aurora.", base:{hp:62,atk:84,def:54,spd:86}, growth:{hp:6,atk:6,def:4,spd:7}, learn:[[31,0],[2,1],[4,28],[35,34]], evo:null, transformed:true},
  {id:19,name:"Lurrinth",types:["Ossra","Vael"], lore:"Lurrin grown tall, its bowl now a tidal halo that never spills.", base:{hp:72,atk:68,def:70,spd:62}, growth:{hp:8,atk:5,def:6,spd:4}, learn:[[7,0],[21,1],[11,28],[12,30]], evo:null, transformed:true},
  {id:20,name:"Brulvex",types:["Thryne","Nyth"], lore:"Brulven bark split, revealing heartwood filled with starless night.", base:{hp:78,atk:76,def:86,spd:46}, growth:{hp:8,atk:5,def:7,spd:3}, learn:[[17,0],[15,1],[40,28],[42,30]], evo:null, transformed:true},
  {id:21,name:"Zephyris",types:["Vael","Irid"], lore:"Zephine ascended; its ribbons are now solidified wind-light.", base:{hp:56,atk:78,def:54,spd:106}, growth:{hp:5,atk:6,def:4,spd:9}, learn:[[34,0],[20,1],[24,30],[22,32]], evo:null, transformed:true},
  {id:22,name:"Klyssar",types:["Klyra","Nyth"], lore:"Klyss molted into storm knight, thunder has weight in its hands.", base:{hp:64,atk:92,def:60,spd:78}, growth:{hp:6,atk:8,def:4,spd:5}, learn:[[28,0],[30,1],[40,32],[29,33]], evo:null, transformed:true},
  {id:23,name:"Flarrix",types:["Embyr","Vael"], lore:"Flarrow comet-form, leaves a written name in sky when diving.", base:{hp:58,atk:88,def:48,spd:96}, growth:{hp:5,atk:7,def:3,spd:8}, learn:[[2,0],[21,1],[6,28],[34,32]], evo:null, transformed:true},
  {id:24,name:"Tydralor",types:["Ossra","Thryne","Irid"], lore:"Tydrake elder whose kelp forest is a library of light.", base:{hp:82,atk:74,def:78,spd:48}, growth:{hp:9,atk:5,def:7,spd:3}, learn:[[9,0],[15,1],[11,30],[34,34]], evo:null, transformed:true},
  {id:25,name:"Volnyx Alpha",types:["Klyra","Embyr","Irid"], lore:"Volnix alpha, its plasma howl tunes all nearby glass to singing.", base:{hp:68,atk:102,def:56,spd:80}, growth:{hp:6,atk:9,def:3,spd:6}, learn:[[30,0],[4,1],[28,32],[35,36]], evo:null, transformed:true},
  {id:26,name:"Myrloch Prime",types:["Ossra","Nyth"], lore:"Myrloch abyss-queen, carrying a sunless sea on its back.", base:{hp:92,atk:72,def:84,spd:42}, growth:{hp:10,atk:5,def:8,spd:2}, learn:[[11,0],[40,1],[9,32],[41,36]], evo:null, transformed:true},
];

// ── const: ZONES ──
const ZONES = [
  {id:0,name:"Embyr Hollow",types:["Embyr"], grad:["#2a1a12","#8b3a1f","#2a1e16"], rate:0.06, mobs:[0,7,15], boss:{name:"Ashen Custodian",creature:18,lvl:10}, shrineAt:{x:5,y:5}, exitAt:{x:27,y:27}, story:0},
  {id:1,name:"Ossra Basin",types:["Ossra"], grad:["#0f2a33","#1c7a8a","#102a3a"], rate:0.10, mobs:[1,8,12], boss:{name:"Loom Matriarch",creature:19,lvl:16}, shrineAt:{x:15,y:4}, exitAt:{x:28,y:14}, story:1},
  {id:2,name:"Thryne Wilds",types:["Thryne"], grad:["#1a2a14","#3a6b32","#223a22"], rate:0.12, mobs:[2,9,13], boss:{name:"Root Canticle",creature:20,lvl:22}, shrineAt:{x:8,y:22}, exitAt:{x:25,y:5}, story:1},
  {id:3,name:"Vael Peaks",types:["Vael"], grad:["#2a3a52","#7fa8d8","#c9deee"], rate:0.14, mobs:[3,10,7], boss:{name:"Howl Chieftain",creature:21,lvl:28}, shrineAt:{x:22,y:22}, exitAt:{x:4,y:13}, story:2},
  {id:4,name:"Klyra Stormfront",types:["Klyra"], grad:["#2a2a38","#6a5a2a","#2e3a4a"], rate:0.16, mobs:[4,11,14], boss:{name:"Volt Jarl",creature:22,lvl:34}, shrineAt:{x:14,y:14}, exitAt:{x:26,y:26}, story:2},
  {id:5,name:"Confluence Null",types:["Irid","Nyth"], grad:["#1a1628","#5e3a6a","#0e141a"], rate:0.20, mobs:[5,6,16,17,18,19,20], boss:{name:"Prism-Null Harbinger",creature:26,lvl:42}, shrineAt:{x:15,y:15}, exitAt:null, story:3},
];

// ── const: STORY ──
const STORY = [
  {title:"Weave I • Ignition",text:"The world-shell first cracked above Embyr Hollow.\nYou woke with a thread in your palm. Old shrines are humming.\nA Custodian guards the basin path. You must bind a companion.\n\nTUTORIAL:\n• WASD/Arrows to walk. Touch ◉ near shrine/guardian/exit.\n• Shrines heal, save, and trade.\n• Touch grass? Threads may pull you into weaving (battle)."},
  {title:"Weave II • Tide & Thorn",text:"Ossra and Thryne Threads bleed.\nBind 4 unique Vanth to stabilize the mid-weave.\nGuardians of Basin and Wilds hold anchors keeping zones from drifting."},
  {title:"Weave III • Sky & Storm",text:"High Threads: Vael and Klyra raging.\nAnchors of wind and lightning need your signet.\nCollect 6 Vanth, transform at least one via catalyst + level.\nThe Confluence opens when three anchors fall."},
  {title:"Weave IV • Prism Null",text:"All Threads knot at the Confluence.\nThe Harbinger drinks light and void.\nCollect 8 Vanth, defeat the 4th anchor, then face Null.\nYour Binding will decide if the shell reseals or sings anew."},
];

// ── const: SHOP_BASE ──
const SHOP_BASE = [
  {type:"binder", id:"bind0", qty:5},
  {type:"binder", id:"bind1", qty:3},
  {type:"heal", id:"heal0", qty:6},
  {type:"heal", id:"heal1", qty:2},
  {type:"catalyst", id:"cat0", qty:2},
  {type:"catalyst", id:"cat1", qty:2},
  {type:"catalyst", id:"cat2", qty:2},
];

// ── function: rand ──
function rand(a,b){return Math.random()*(b-a)+a}

// ── function: randi ──
function randi(a,b){return Math.floor(rand(a,b+1))}

// ── function: choice ──
function choice(arr){return arr[randi(0,arr.length-1)]}

// ── function: clamp ──
function clamp(v,a,b){return Math.max(a,Math.min(b,v))}

// ── function: lerp ──
function lerp(a,b,t){return a+(b-a)*t}

// ── function: makeId ──
function makeId(){return Math.random().toString(36).slice(2,8)}

// ── function: getAbility ──
function getAbility(id){return ABILITIES.find(x=>x.id===id)}

// ── function: getCreature ──
function getCreature(id){return CREATURES.find(x=>x.id===id)}

// ── function: toast ──
function toast(msg){ const root=document.getElementById('toast'); const d=document.createElement('div'); d.className='toastItem'; d.textContent=msg; root.appendChild(d); setTimeout(()=>d.remove(),3000); }

// ── class: AudioSys ──
class AudioSys{
  constructor(){ this.ctx=null; this.gain=null; this.muted=false; this.seq=0; this.padNodes=[]; }
  init(){ try{ const AC=window.AudioContext||window.webkitAudioContext; this.ctx=new AC(); this.gain=this.ctx.createGain(); this.gain.gain.value=0.12; this.gain.connect(this.ctx.destination); this.startPad(); }catch(e){} }
  toggle(){ this.muted=!this.muted; if(this.gain) this.gain.gain.value=this.muted?0:0.12; return this.muted; }
  startPad(){ if(!this.ctx) return; const c=this.ctx; const o1=c.createOscillator(), o2=c.createOscillator(); const g=c.createGain(); o1.type='sine'; o2.type='triangle'; o1.frequency.value=55; o2.frequency.value=110.02; g.gain.value=0.0001; const f=c.createBiquadFilter(); f.type='lowpass'; f.frequency.value=700; o1.connect(f); o2.connect(f); f.connect(g); g.connect(this.gain); o1.start(); o2.start(); this.padNodes=[o1,o2,g,f]; const lfo=()=>{ if(!this.ctx||this.muted) return; const t=c.currentTime; g.gain.linearRampToValueAtTime(0.07+Math.sin(t*0.2)*0.02, t+2); f.frequency.linearRampToValueAtTime(400+Math.sin(t*0.11)*300, t+3); o1.frequency.linearRampToValueAtTime(55+Math.sin(t*0.07)*2, t+4); }; setInterval(lfo,2000); }
  blip(freq=440, dur=0.12, type='sine', vol=0.2){ if(!this.ctx||this.muted) return; const o=this.ctx.createOscillator(), g=this.ctx.createGain(); o.type=type; o.frequency.value=freq; g.gain.value=vol; g.gain.exponentialRampToValueAtTime(0.0001,this.ctx.currentTime+dur); o.connect(g); g.connect(this.gain); o.start(); o.stop(this.ctx.currentTime+dur);}
  hit(isCrit, isSE){ this.blip(isCrit?880:320,0.2,isCrit?'square':'sine', isSE?0.28:0.16); if(isSE) setTimeout(()=>this.blip(660,0.25,'sawtooth',0.12),70); }
  pick(){ this.blip(560,0.18,'triangle',0.2); setTimeout(()=>this.blip(900,0.22,'sine',0.18),90); }
  win(){ [0,2,4,7].forEach((st,i)=>setTimeout(()=>this.blip(220*Math.pow(2,st/12),0.3,'sine',0.22), i*120)); }
}

// ── class: ParticleFx ──
class ParticleFx{
  constructor(canvas){ this.cv=canvas; this.ctx=canvas.getContext('2d'); this.parts=[]; this.w=canvas.width; this.h=canvas.height; this.zoneId=0; }
  resize(w,h){ this.w=w; this.h=h; this.cv.width=w; this.cv.height=h; }
  setZone(id){ this.zoneId=id; this.parts=[]; const z=ZONES[id]; const count= z? 70:30; for(let i=0;i<count;i++) this.parts.push({x:Math.random()*this.w,y:Math.random()*this.h, vx:rand(-0.4,0.4), vy:rand(-0.5,0.5), s:rand(1,3.6), a:rand(0.2,0.8), type:choice(z.types)}) }
  tick(){ const ctx=this.ctx; ctx.clearRect(0,0,this.w,this.h); const gradMap={Embyr:["#ff6a3d","#ffcf8a"],Ossra:["#4dd0e1","#a0f0ff"],Thryne:["#7ad17a","#e3f7a8"],Vael:["#a8c0ff","#ffffff"],Klyra:["#ffe45e","#ffffff"],Irid:["#e8a0ff","#a8c0ff"],Nyth:["#8a8ea6","#3a3a4a"]}; this.parts.forEach(p=>{ p.x+=p.vx; p.y+=p.vy; if(p.x<0)p.x=this.w; if(p.x>this.w)p.x=0; if(p.y<0)p.y=this.h; if(p.y>this.h)p.y=0; const col=gradMap[p.type]||["#fff","#fff"]; ctx.globalAlpha=p.a*0.6; ctx.fillStyle=col[0]; ctx.beginPath(); ctx.arc(p.x,p.y,p.s,0,Math.PI*2); ctx.fill(); }); ctx.globalAlpha=1; }
}

// ── function: makeMon ──
function makeMon(creatureId, level){
  const base=getCreature(creatureId);
  const hp = Math.floor(base.base.hp + base.growth.hp*level + level*2);
  const atk= Math.floor(base.base.atk + base.growth.atk*level);
  const def= Math.floor(base.base.def + base.growth.def*level);
  const spd= Math.floor(base.base.spd + base.growth.spd*level);
  const maxHp=hp;
  const moves=[];
  for(let [abId, lvl] of base.learn){ if(lvl<=level) moves.push(abId); if(moves.length>=4) break; }
  // ensure 4 moves if possible, fill with first
  while(moves.length<4 && base.learn.length> moves.length){ const next=base.learn[moves.length]; if(next) moves.push(next[0]); else break; }
  const m={uid:makeId(), cid:creatureId, level, hp, maxHp, atk, def, spd, baseAtk:atk, baseDef:def, baseSpd:spd, types:base.types.slice(), moves:moves.slice(0,4), pp:moves.slice(0,4).map(()=>{const ab=getAbility(moves[0]); return ab?ab.pp:15}), status:null, statusTurns:0, barrier:false, statMod:{atk:0,def:0,spd:0}, name:base.name};
  // compute PP
  m.pp = m.moves.map(mid=>{ const ab=getAbility(mid); return ab?ab.pp:12});
  return m;
}

// ── function: statWithMod ──
function statWithMod(base, stage){ if(stage===0) return base; const mult = stage>0 ? (1+stage*0.25) : 1/(1+Math.abs(stage)*0.25); return Math.floor(base*mult); }

// ── function: calcDamage ──
function calcDamage(attacker, defender, ability){
  const power=ability.power; if(power<=0) return {dmg:0, crit:false, eff:1};
  const lvl=attacker.level;
  const atkStat=statWithMod(attacker.atk, attacker.statMod.atk);
  const defStat=statWithMod(defender.def, defender.statMod.def);
  const typeMult=typeEffectiveness(ability.type, defender.types);
  const stab = attacker.types.includes(ability.type) ? 1.25:1;
  const isCrit = Math.random()<0.08;
  const critMult = isCrit?1.5:1;
  const rnd = rand(0.9,1.08);
  const base = (0.4 * lvl * atkStat / defStat * ability.power / 12);
  const dmg = Math.floor( (base + rand(1,4)) * typeMult * stab * critMult * rnd );
  return {dmg:Math.max(1,dmg), crit:isCrit, eff:typeMult, stab};
}

// ── class: WorldMap ──
class WorldMap{
  constructor(zoneId){
    this.zoneId=zoneId;
    const z=ZONES[zoneId];
    this.w=32; this.h=32;
    this.tiles=[];
    for(let y=0;y<this.h;y++){ this.tiles[y]=[]; for(let x=0;x<this.w;x++){ let t=0; if(Math.random()<0.08) t=1; if(x===0||y===0||x===this.w-1||y===this.h-1) t=1; this.tiles[y][x]=t; } }
    // carve walkable paths using random walk
    let cx=Math.floor(this.w/2), cy=Math.floor(this.h/2);
    for(let i=0;i<600;i++){ this.tiles[cy][cx]=0; const dir=randi(0,3); if(dir===0&&cy>1) cy--; if(dir===1&&cy<this.h-2) cy++; if(dir===2&&cx>1) cx--; if(dir===3&&cx<this.w-2) cx++; }
    // shrine
    this.tiles[z.shrineAt.y][z.shrineAt.x]=3;
    // ensure shrine surroundings clear
    for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){ const ny=z.shrineAt.y+dy, nx=z.shrineAt.x+dx; if(ny>=0&&ny<this.h&&nx>=0&&nx<this.w&&this.tiles[ny][nx]===1) this.tiles[ny][nx]=0; }
    if(z.exitAt){ this.tiles[z.exitAt.y][z.exitAt.x]=4; for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){ const ny=z.exitAt.y+dy, nx=z.exitAt.x+dx; if(ny>=0&&ny<this.h&&nx>=0&&nx<this.w&&this.tiles[ny][nx]===1) this.tiles[ny][nx]=0; } }
    if(zonesBossPos(this.zoneId)){ const bp=zonesBossPos(this.zoneId); this.tiles[bp.y][bp.x]=5; for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){ const ny=bp.y+dy, nx=bp.x+dx; if(ny>=0&&ny<this.h&&nx>=0&&nx<this.w&&this.tiles[ny][nx]===1) this.tiles[ny][nx]=0; } }
  }
  isWalkable(x,y){ if(x<0||y<0||x>=this.w||y>=this.h) return false; const t=this.tiles[y][x]; return t!==1; }
  getTile(x,y){ if(x<0||y<0||x>=this.w||y>=this.h) return 1; return this.tiles[y][x]; }
}

// ── function: zonesBossPos ──
function zonesBossPos(zoneId){
  const dict={0:{x:16,y:8},1:{x:24,y:22},2:{x:16,y:8},3:{x:16,y:16},4:{x:8,y:26},5:{x:16,y:6}};
  return dict[zoneId]||null;
}

// ── class: GameState ──
class GameState{
  constructor(){
    this.zone=0;
    this.maps={};
    ZONES.forEach((z,i)=>{ this.maps[i]=new WorldMap(i); });
    this.player={x:5,y:6, px:5, py:6, dir:0};
    this.team=[];
    this.bag={binders:{bind0:12,bind1:2,bind2:0}, heals:{heal0:3,heal1:1,heal2:0}, catalysts:{}, money:120};
    this.seen=new Set(); this.caught=new Set();
    this.defeatedBosses=new Set();
    this.unlockedZone=0;
    this.storyIdx=0;
    this.anchores=0;
    this.steps=0;
    this.lastShrine={zone:0,x:5,y:5};
    this.settings={muted:false};
    this.tutorial={moveDone:false,battleDone:false,bindDone:false};
    this.playTime=0;
  }
  addSeen(cid){ this.seen.add(cid); }
  addCaught(cid){ this.caught.add(cid); this.seen.add(cid); }
  hasTeamSpace(){ return this.team.length<6; }
  healTeam(){ this.team.forEach(m=>{ m.hp=m.maxHp; m.status=null; m.statusTurns=0; m.barrier=false; m.statMod={atk:0,def:0,spd:0}; m.atk=m.baseAtk; m.def=m.baseDef; m.spd=m.baseSpd; }); }
  recalcMonStats(mon){
    const base=getCreature(mon.cid);
    mon.maxHp=Math.floor(base.base.hp + base.growth.hp*mon.level + mon.level*2);
    mon.baseAtk=Math.floor(base.base.atk + base.growth.atk*mon.level);
    mon.baseDef=Math.floor(base.base.def + base.growth.def*mon.level);
    mon.baseSpd=Math.floor(base.base.spd + base.growth.spd*mon.level);
    mon.atk=statWithMod(mon.baseAtk, mon.statMod.atk);
    mon.def=statWithMod(mon.baseDef, mon.statMod.def);
    mon.spd=statWithMod(mon.baseSpd, mon.statMod.spd);
    if(mon.hp>mon.maxHp) mon.hp=mon.maxHp;
  }
  save(slot="autosave"){ localStorage.setItem("aestral_"+slot, JSON.stringify({zone:this.zone, player:this.player, team:this.team, bag:this.bag, seen:[...this.seen], caught:[...this.caught], defeated:[...this.defeatedBosses], unlocked:this.unlockedZone, storyIdx:this.storyIdx, anchores:this.anchores, lastShrine:this.lastShrine, tutorial:this.tutorial})); toast("Binding woven saved"); }
  load(slot="autosave"){ const raw=localStorage.getItem("aestral_"+slot); if(!raw) return false; try{ const d=JSON.parse(raw); this.zone=d.zone; this.player=d.player; this.team=d.team; this.bag=d.bag; this.seen=new Set(d.seen); this.caught=new Set(d.caught); this.defeatedBosses=new Set(d.defeated); this.unlockedZone=d.unlocked; this.storyIdx=d.storyIdx; this.anchores=d.anchores; this.lastShrine=d.lastShrine; this.tutorial=d.tutorial||{moveDone:true,battleDone:true,bindDone:true}; return true;}catch(e){return false;} }
}

// ── class: Battle ──
class Battle{
  constructor(state, opts){
    this.state=state;
    this.isWild=opts.isWild;
    this.isBoss=opts.isBoss||false;
    this.enemyMons=opts.enemies.map(e=>makeMon(e.cid,e.lvl));
    this.allyMons=state.team.map(m=>{ const copy=JSON.parse(JSON.stringify(m));
      copy.statMod={atk:0,def:0,spd:0}; copy.barrier=false; copy.status=null; copy.statusTurns=0;
      copy.atk=copy.baseAtk; copy.def=copy.baseDef; copy.spd=copy.baseSpd; copy._uid=m.uid; copy._orig=m;
      return copy; });
    if(this.allyMons.length===0) throw new Error("no team");
    this.activeIdx=0;
    this.turnQueue=[];
    this.log=[];
    this.ended=false;
    this.result=null;
    this.captureAttempts=0;
    this.buildQueue();
  }
  buildQueue(){
    const all=[...this.allyMons.filter(m=>m.hp>0).map(m=>({m, side:"ally"})), ...this.enemyMons.filter(m=>m.hp>0).map(m=>({m, side:"enemy"}))];
    all.sort((a,b)=> (statWithMod(b.m.spd,b.m.statMod.spd) - statWithMod(a.m.spd,a.m.statMod.spd)));
    this.turnQueue=all;
    this.queuePos=0;
  }
  getActiveAlly(){ return this.allyMons[this.activeIdx]; }
  getCurrentTurn(){ if(this.turnQueue.length===0) this.buildQueue(); if(this.queuePos>=this.turnQueue.length){ this.buildQueue(); this.queuePos=0; } return this.turnQueue[this.queuePos]; }
  nextTurn(){ this.queuePos++; if(this.queuePos>=this.turnQueue.length){ this.buildQueue(); this.queuePos=0; }
    for(let i=0;i<10;i++){ const ct=this.getCurrentTurn(); if(!ct) break; if(ct.m.hp<=0){ this.queuePos++; if(this.queuePos>=this.turnQueue.length){ this.buildQueue(); this.queuePos=0; } } else break; }
  }
  isAllEnemiesDown(){ return this.enemyMons.every(m=>m.hp<=0); }
  isAllAlliesDown(){ return this.allyMons.every(m=>m.hp<=0); }
  applyStatusTick(mon){
    if(!mon.status) return null;
    if(mon.status==="burn"){ const dmg=Math.floor(mon.maxHp*0.07); mon.hp=Math.max(0,mon.hp-dmg); this.log.push(`${mon.name} burns -${dmg}`); }
    if(mon.status==="regen"){ const heal=Math.floor(mon.maxHp*0.10); mon.hp=Math.min(mon.maxHp, mon.hp+heal); this.log.push(`${mon.name} regen +${heal}`); }
    if(mon.status==="frost"){ /* spd already debuffed */ }
    mon.statusTurns--; if(mon.statusTurns<=0){ this.log.push(`${mon.name} ${mon.status} faded`); if(mon.status==="frost"){ mon.statMod.spd=Math.min(6, mon.statMod.spd+1); } mon.status=null; }
    return mon.hp<=0?"faint":null;
  }
  tryApplyEffect(attacker, defender, abil){
    const r=Math.random(); const eff=abil.effect;
    if(eff==="burn" && r<0.22){ defender.status="burn"; defender.statusTurns=3; this.log.push(`${defender.name} burnt!`); }
    if(eff==="frost" && r<0.26){ defender.status="frost"; defender.statusTurns=2; defender.statMod.spd=Math.max(-6, defender.statMod.spd-1); this.log.push(`${defender.name} frosted!`); }
    if(eff==="shock" && r<0.32){ defender.status="shock"; defender.statusTurns=2; this.log.push(`${defender.name} shocked!`); }
    if(eff==="barrier"){ attacker.barrier=true; this.log.push(`${attacker.name} barrier!`); }
    if(eff==="heal50"){ const h=Math.floor(attacker.maxHp*0.5); attacker.hp=Math.min(attacker.maxHp, attacker.hp+h); this.log.push(`${attacker.name} healed +${h}`); }
    if(eff==="regen"){ attacker.status="regen"; attacker.statusTurns=4; this.log.push(`${attacker.name} regen!`); }
    if(eff==="defdown" && r<0.7){ defender.statMod.def=Math.max(-6, defender.statMod.def-1); this.log.push(`${defender.name} DEF Fell!`); }
    if(eff==="atkdown" && r<0.7){ defender.statMod.atk=Math.max(-6, defender.statMod.atk-1); this.log.push(`${defender.name} ATK Fell!`); }
    if(eff==="selfSpd"){ attacker.statMod.spd=Math.min(6, attacker.statMod.spd+1); this.log.push(`${attacker.name} SPD up!`); }
    if(eff==="healbarrier"){ const h=Math.floor(attacker.maxHp*0.4); attacker.hp=Math.min(attacker.maxHp, attacker.hp+h); attacker.barrier=true; this.log.push(`${attacker.name} radiant barrier!`); }
    if(eff==="shockburn" && r<0.5){ const pick=Math.random()<0.5?"burn":"shock"; defender.status=pick; defender.statusTurns=2; this.log.push(`${defender.name} ${pick}ed!`); }
    if(eff==="selfAtkDown"){ attacker.statMod.atk=Math.max(-6, attacker.statMod.atk-1); }
    if(eff==="selfDefDown"){ attacker.statMod.def=Math.max(-6, attacker.statMod.def-1); }
    if(eff==="critup"){ attacker._critBoost=true; }
  }
  performMove(attacker, defender, abilityIdx){
    // status tick before move
    const resTick=this.applyStatusTick(attacker);
    if(resTick==="faint"){ return {ok:true, dmg:0, faint:true}; }
    const abil=getAbility(attacker.moves[abilityIdx]);
    if(!abil) return {ok:false, msg:"No move"};
    if(attacker.pp[abilityIdx]<=0) return {ok:false, msg:"No PP"};
    attacker.pp[abilityIdx]--;
    if(Math.random()*100>abil.acc){ this.log.push(`${attacker.name} missed ${abil.name}!`); return {ok:true, dmg:0, miss:true}; }
    if(attacker.status==="shock" && Math.random()<0.35){ this.log.push(`${attacker.name} shocked! can't move`); return {ok:true, dmg:0, shockSkip:true}; }
    if(defender.barrier){ defender.barrier=false; this.log.push(`${defender.name}'s barrier broke!`); return {ok:true,dmg:0, barrier:true}; }
    const calc=calcDamage(attacker, defender, abil);
    if(attacker._critBoost){ calc.crit=Math.random()<0.25; if(calc.crit) calc.dmg=Math.floor(calc.dmg*1.4); attacker._critBoost=false; }
    let dmg=calc.dmg;
    if(abil.effect==="multihit"){ const hits=randi(2,4); dmg=0; for(let i=0;i<hits;i++){ const c=calcDamage(attacker,defender,abil); dmg+=c.dmg; } this.log.push(`${abil.name} x${hits}!`); }
    if(abil.effect==="recoil10"){ const rec=Math.floor(dmg*0.12); attacker.hp=Math.max(0,attacker.hp-rec); this.log.push(`${attacker.name} recoil -${rec}`); }
    if(attacker.status==="burn" && abil.power>0){ dmg=Math.floor(dmg*0.55); }
    defender.hp=Math.max(0,defender.hp-dmg);
    if(abil.effect==="healHalfDmg"){ const heal=Math.floor(dmg*0.5); attacker.hp=Math.min(attacker.maxHp, attacker.hp+heal); this.log.push(`${attacker.name} leeched +${heal}`); }
    this.tryApplyEffect(attacker, defender, abil);
    return {ok:true, dmg, crit:calc.crit, eff:calc.eff, ability:abil};
  }
  expReward(enemyMon){ const base=20+enemyMon.level*8; const rel=Math.max(0.5, enemyMon.level - this.getActiveAlly().level*0.1); return Math.floor(base*rel); }
}

// ── function: renderOverworld ──
function renderOverworld(state, worldCanvas, cam){
  const ctx=worldCanvas.getContext('2d');
  const z=ZONES[state.zone];
  const map=state.maps[state.zone];
  const w=worldCanvas.clientWidth, h=worldCanvas.clientHeight;
  const grad=ctx.createLinearGradient(0,0,0,h);
  grad.addColorStop(0,z.grad[0]); grad.addColorStop(0.5,z.grad[1]); grad.addColorStop(1,z.grad[2]);
  ctx.fillStyle=grad; ctx.fillRect(0,0,w,h);
  const tileSize=42;
  const offX = -cam.x * tileSize + w/2;
  const offY = -cam.y * tileSize + h/2;
  ctx.save();
  ctx.translate(offX, offY);
  for(let y=0;y<map.h;y++) for(let x=0;x<map.w;x++){
    const t=map.getTile(x,y);
    const px=x*tileSize, py=y*tileSize;
    if(t===0){
      ctx.fillStyle= ( (x+y)%2===0 ? "rgba(255,255,255,.04)" : "rgba(255,255,255,.02)" );
      ctx.fillRect(px+1,py+1,tileSize-2,tileSize-2);
    } else if(t===1){
      ctx.fillStyle="rgba(0,0,0,.55)"; ctx.fillRect(px,py,tileSize,tileSize);
      ctx.fillStyle=z.grad[1]; ctx.globalAlpha=0.22; ctx.fillRect(px+2,py+2,tileSize-4,tileSize-4); ctx.globalAlpha=1;
    } else if(t===3){
      ctx.fillStyle="rgba(255,228,94,.15)"; ctx.fillRect(px-2,py-2,tileSize+4,tileSize+4);
      ctx.fillStyle="#ffe45e"; ctx.beginPath(); ctx.arc(px+tileSize/2, py+tileSize/2, 12,0,Math.PI*2); ctx.fill();
      ctx.fillStyle="#fff"; ctx.font="bold 14px monospace"; ctx.textAlign="center"; ctx.fillText("♁",px+tileSize/2, py+tileSize/2+5);
    } else if(t===4){
      ctx.fillStyle="rgba(168,192,255,.25)"; ctx.fillRect(px-2,py-2,tileSize+4,tileSize+4);
      ctx.strokeStyle="#a8c0ff"; ctx.lineWidth=2; ctx.strokeRect(px+4,py+4,tileSize-8,tileSize-8);
      ctx.fillStyle="#a8c0ff"; ctx.font="10px monospace"; ctx.textAlign="center"; ctx.fillText("EXIT",px+tileSize/2, py+tileSize/2+3);
    } else if(t===5){
      ctx.fillStyle="rgba(255,106,61,.22)"; ctx.fillRect(px-4,py-4,tileSize+8,tileSize+8);
      ctx.fillStyle="#ff6a3d"; ctx.beginPath(); ctx.arc(px+tileSize/2, py+tileSize/2, 16,0,Math.PI*2); ctx.fill();
      ctx.fillStyle="#fff"; ctx.font="14px monospace"; ctx.textAlign="center"; ctx.fillText("⚑",px+tileSize/2, py+tileSize/2+5);
    }
  }
  const ppx=state.player.px*tileSize + tileSize/2, ppy=state.player.py*tileSize+tileSize/2;
  ctx.shadowColor="#ffe45e"; ctx.shadowBlur=18; ctx.fillStyle="#fff"; ctx.beginPath(); ctx.arc(ppx,ppy,10,0,Math.PI*2); ctx.fill(); ctx.shadowBlur=0;
  ctx.fillStyle="#0e141a"; ctx.font="bold 12px monospace"; ctx.textAlign="center"; ctx.fillText("◐",ppx,ppy+4);
  ctx.restore();
  const vg=ctx.createRadialGradient(w/2,h/2,w*0.2,w/2,h/2,w*0.9);
  vg.addColorStop(0,"rgba(0,0,0,0)"); vg.addColorStop(1,"rgba(0,0,0,.55)");
  ctx.fillStyle=vg; ctx.fillRect(0,0,w,h);
}

// ── function: renderBattleFrame ──
function renderBattleFrame(battle, battleCanvas, frame){
  const ctx=battleCanvas.getContext('2d');
  const w=battleCanvas.clientWidth, h=battleCanvas.clientHeight;
  const zone=ZONES[window.gameState? window.gameState.zone:0];
  const g=ctx.createLinearGradient(0,0,0,h);
  g.addColorStop(0, zone.grad[0]); g.addColorStop(1, zone.grad[2]);
  ctx.fillStyle=g; ctx.fillRect(0,0,w,h);
  const enemies=battle.enemyMons;
  enemies.forEach((em,i)=>{
    const x = w*0.68 + (i- (enemies.length-1)/2)*120;
    const y = 110;
    const pct=em.hp/em.maxHp;
    ctx.fillStyle="rgba(0,0,0,.35)"; ctx.beginPath(); ctx.ellipse(x,y+52, 38,10,0,0,Math.PI*2); ctx.fill();
    ctx.fillStyle=TYPE_COLOR[em.types[0]]||"#fff"; ctx.globalAlpha=0.9; ctx.beginPath(); ctx.arc(x,y,36,0,Math.PI*2); ctx.fill(); ctx.globalAlpha=1;
    ctx.fillStyle="#0e141a"; ctx.font="bold 10px monospace"; ctx.textAlign="center"; ctx.fillText(em.name.slice(0,8),x,y+4);
    if(em.barrier){ ctx.strokeStyle="#ffe45e"; ctx.lineWidth=3; ctx.beginPath(); ctx.arc(x,y,42,0,Math.PI*2); ctx.stroke(); }
    if(em.status){ ctx.fillStyle="#fff"; ctx.font="9px monospace"; ctx.fillText(em.status.toUpperCase(), x, y-44); }
    ctx.fillStyle="rgba(0,0,0,.5)"; ctx.fillRect(x-34,y+54,68,6);
    ctx.fillStyle=pct>0.5? "#7ad17a" : pct>0.25? "#ffe45e" : "#ff6a3d"; ctx.fillRect(x-34,y+54,68*pct,6);
  });
  const active=battle.allyMons[battle.activeIdx];
  if(active){
    const x=w*0.22, y=250;
    ctx.fillStyle="rgba(0,0,0,.35)"; ctx.beginPath(); ctx.ellipse(x,y+52,42,12,0,0,Math.PI*2); ctx.fill();
    ctx.fillStyle=TYPE_COLOR[active.types[0]]||"#fff"; ctx.beginPath(); ctx.arc(x,y,42,0,Math.PI*2); ctx.fill();
    ctx.fillStyle="#0e141a"; ctx.font="bold 12px monospace"; ctx.textAlign="center"; ctx.fillText(active.name.slice(0,9),x,y+4);
    if(active.barrier){ ctx.strokeStyle="#ffe45e"; ctx.lineWidth=3; ctx.beginPath(); ctx.arc(x,y,48,0,Math.PI*2); ctx.stroke(); }
    if(active.status){ ctx.fillStyle="#fff"; ctx.font="9px monospace"; ctx.fillText(active.status.toUpperCase(), x, y-48); }
    ctx.fillStyle="rgba(0,0,0,.6)"; ctx.fillRect(x-44,y+58,88,9);
    ctx.fillStyle="#7ad17a"; ctx.fillRect(x-44,y+58,88*(active.hp/active.maxHp),9);
    // status bar
    ctx.fillStyle="rgba(255,255,255,.08)"; ctx.fillRect(x-44,y+70,88,3);
  }
  ctx.fillStyle="rgba(255,255,255,.04)"; for(let yy=0;yy<h;yy+=24){ ctx.fillRect(0,yy,w,1); }
}

// ── function: floatingDamage ──
function floatingDamage(parent, x,y,text, crit, eff){
  const d=document.createElement('div');
  d.className='damageFloat';
  d.style.left=x+'px'; d.style.top=y+'px';
  d.style.color= crit ? '#ffe45e' : eff>1.1 ? '#ff6a3d' : eff<0.9 ? '#9fb3c5' : '#e9f1f7';
  d.textContent=text + (crit?' CRIT!': eff>1.1?' SE!': eff<0.9?' RES':'' );
  parent.appendChild(d); setTimeout(()=>d.remove(),900);
}

// ── function: openModal ──
function openModal(html){
  const root=document.getElementById('modalRoot');
  root.innerHTML=`<div class="modalCard glass"><div class="modalHead"><h3 style="margin:0">${html.title||" "}</h3><button id="modalClose">✕</button></div><div class="modalBody">${html.body||""}</div></div>`;
  root.classList.add('active');
  document.getElementById('modalClose').onclick=()=>{ root.classList.remove('active'); };
  if(html.onMount) html.onMount(root);
}

// ── function: closeModal ──
function closeModal(){ document.getElementById('modalRoot').classList.remove('active'); }

// ── function: buildTeamModal ──
function buildTeamModal(state, onSwitch){
  const body=`<div class="teamList">${state.team.map((m,i)=>`
    <div class="teamSlot" data-i="${i}">
      <span class="typeBadge type-${m.types[0]}">${m.types[0]}</span>
      <b>${m.name}</b> Lv${m.level}
      <div class="barWrap" style="width:120px"><div class="barFill" style="width:${Math.round(m.hp/m.maxHp*100)}%"></div></div>
      <span>${m.hp}/${m.maxHp}</span>
      <span class="lvl">${m.moves.map(id=>getAbility(id)?.name||"").join(" • ")}</span>
      <button data-act="switch" data-i="${i}">Set Active</button>
      <button data-act="release" data-i="${i}">Release</button>
    </div>`).join("")}</div>
    <p style="font-size:11px;color:#9fb3c5">Transform eligible: level + catalyst. Check Lexicon for needs.</p>`;
  openModal({title:`Team • ${state.team.length}/6 — Collected ${state.caught.size}/27`, body,
    onMount(root){
      root.querySelectorAll('[data-act="switch"]').forEach(b=>b.onclick=()=>{ const idx=+b.dataset.i; const first=state.team[0]; state.team[0]=state.team[idx]; state.team[idx]=first; toast("Lead switched"); if(onSwitch) onSwitch(); root.querySelector('.modalBody').innerHTML=buildTeamModalInner(state); });
      root.querySelectorAll('[data-act="release"]').forEach(b=>b.onclick=()=>{ const idx=+b.dataset.i; if(state.team.length<=1){ toast("Keep one Vanth"); return;} state.team.splice(idx,1); toast("Released to winds"); closeModal(); });
    }});
}

// ── function: buildTeamModalInner ──
function buildTeamModalInner(state){ return state.team.map((m,i)=>`
    <div class="teamSlot" data-i="${i}">
      <span class="typeBadge type-${m.types[0]}">${m.types[0]}</span>
      <b>${m.name}</b> Lv${m.level}
      <div class="barWrap" style="width:120px"><div class="barFill" style="width:${Math.round(m.hp/m.maxHp*100)}%"></div></div>
      <span>${m.hp}/${m.maxHp}</span>
      <span class="lvl">${m.moves.map(id=>getAbility(id)?.name||"").join(" • ")}</span>
    </div>`).join(""); }

// ── function: buildBagModal ──
function buildBagModal(state){
  const b=state.bag;
  const binderCounts=Object.entries(b.binders).map(([k,v])=>{ const it=ITEMS.binders.find(x=>x.id===k); return `<div class="codexCard"><b>${it.name} x${v}</b><span>${it.desc} • Tier ${it.tier}</span><div class="dexBar"><div style="width:${v*12}% ;height:100%;background:var(--klyra)"></div></div></div>`}).join("");
  const healCounts=Object.entries(b.heals).map(([k,v])=>{ const it=ITEMS.heals.find(x=>x.id===k); return `<div class="codexCard"><b>${it.name} x${v}</b><span>Heal ${it.heal===999?"FULL":it.heal} — ${it.price}g</span></div>`}).join("");
  const catCounts=Object.entries(b.catalysts).map(([k,v])=>{ const it=ITEMS.catalysts.find(x=>x.id===k); return `<div class="codexCard"><b>${it.name} x${v}</b><span>${it.type}</span></div>`}).join("");
  const catAll=ITEMS.catalysts.map(it=>`<div class="codexCard"><b>${it.name}</b><span>${it.type} catalyst</span></div>`).join("");
  const body=`<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:8px"><span>Gold: ${b.money}◍</span><button id="btnHealTeam">Moss Nectar heal (if owned)</button></div>
  <h4>Binders</h4><div class="invGrid">${binderCounts||"empty"}</div>
  <h4>Heals</h4><div class="invGrid">${healCounts||"empty"}</div>
  <h4>Catalysts</h4><div class="invGrid">${catCounts||catAll||"empty"}</div>`;
  openModal({title:"Bag • Thread Supplies", body, onMount(r){ r.querySelector('#btnHealTeam').onclick=()=>{ const has=Object.entries(state.bag.heals).find(([k,v])=>v>0); if(!has){ toast("No heal items"); return; } const key=has[0]; const healIt=ITEMS.heals.find(x=>x.id===key); const target=state.team.find(m=>m.hp<m.maxHp); if(!target){ toast("Team full"); return;} if(healIt.full) target.hp=target.maxHp; else target.hp=Math.min(target.maxHp, target.hp+healIt.heal); state.bag.heals[key]--; if(state.bag.heals[key]<=0) delete state.bag.heals[key]; state.recalcMonStats(target); toast(`${target.name} healed`); closeModal(); } }});
}

// ── function: buildLexiconModal ──
function buildLexiconModal(state){
  const body=`<div class="codexGrid">${CREATURES.map(c=>{
    const seen=state.seen.has(c.id); const caught=state.caught.has(c.id);
    const ev=c.evo? `→ ${getCreature(c.evo.to)?.name||"?"} Lv${c.evo.lvl}${c.evo.cat? " + "+ (ITEMS.catalysts.find(x=>x.id===c.evo.cat)?.name||"cat"): ""}`:"";
    return `<div class="codexCard" style="opacity:${seen?1:0.35}"><b>${seen? c.name: "???"} <span class="typeBadge type-${c.types[0]}">${c.types[0]}</span></b><span>${seen? c.lore : "Unseen threadling"}</span><span>Base ${c.base.hp}/${c.base.atk}/${c.base.def}/${c.base.spd}</span><span style="font-size:10px;color:var(--muted)">${ev}</span><span style="margin-top:4px;color:${caught?"var(--thryne)":"var(--muted)"}">${caught?"BOUND":""} ${seen && !caught?"Seen":""}</span></div>`;
  }).join("")}</div>`;
  openModal({title:`Lexicon • Saw ${state.seen.size} / 27, Bound ${state.caught.size}`, body});
}

// ── function: buildMapModal ──
function buildMapModal(state){
  const body=`<div class="mapMini">
    ${ZONES.map((z,i)=>`<div class="mapRow"><div class="dot2" style="background:${TYPE_COLOR[z.types[0]]||"#fff"}"></div><b>${z.name}</b><span>— ${i<=state.unlockedZone? (state.defeatedBosses.has(i)? "Anchor broken":"Explorable"):"Locked"}</span><span>• ${state.zone===i?"YOU":""}</span></div>`).join("")}
    <div style="margin-top:12px;font-size:11px;line-height:1.6;color:#9fb3c5">Type Chart: Embyr > Ossra > Thryne > Embyr • Vael > Embyr,Thryne • Klyra > Vael,Ossra • Irid > all 1.25× • Nyth resists 0.6× but weak to Irid 2.0× • STAB 1.25× • Burn half ATK, Frost half SPD, Shock skip, Barrier blocks once</div>
  </div>`;
  openModal({title:"Woven Map • Thread Topology", body});
}

// ── function: buildShrineModal ──
function buildShrineModal(state, world){
  const shop=SHOP_BASE;
  const body=`
   <p>Resin shrine hums. Your Vanth rest.</p>
   <div style="display:flex;gap:8px;margin:8px 0"><button id="shrineRest" class="primary">Rest & Save</button><button id="shrineBuy">Shop</button><button id="shrineEvo">Transform</button></div>
   <div id="shrineShop" style="display:none"><div class="invGrid">${shop.map(s=>{
     const src = s.type==="binder"? ITEMS.binders.find(x=>x.id===s.id) : s.type==="heal"? ITEMS.heals.find(x=>x.id===s.id) : ITEMS.catalysts.find(x=>x.id===s.id);
     return `<div class="codexCard"><b>${src.name}</b><span>${src.price}◍</span><button data-buy="${s.type}|${s.id}">Buy</button></div>`;
   }).join("")}</div></div>
   <div id="shrineEvoBox" style="display:none"><p style="font-size:11px;color:#9fb3c5">If level enough + catalyst, select Vanth to ascend.</p><div class="teamList">${state.team.map((m,i)=>{
     const base=getCreature(m.cid); const evo=base.evo; let ok=false; let reason=""; if(evo){ if(m.level>=evo.lvl) { if(!evo.cat || (state.bag.catalysts[evo.cat]||0)>0) ok=true; else reason="need catalyst"; } else reason=`Lv${evo.lvl} need`; } else reason="No transform"; const target=getCreature(evo?.to);
     return `<div class="teamSlot"><b>${m.name} Lv${m.level}</b><span>${evo? `→ ${target?.name} ${reason}`: reason}</span>${ok? `<button data-evo="${i}">Transform</button>`:""}</div>`;
   }).join("")}</div></div>`;
  openModal({title:`Shrine of Braiding • ${ZONES[state.zone].name}`, body,
    onMount(root){
      root.querySelector('#shrineRest').onclick=()=>{ state.healTeam(); state.lastShrine={zone:state.zone,x:state.player.x,y:state.player.y}; state.save(); toast("Healed & Woven Saved"); closeModal(); };
      root.querySelector('#shrineBuy').onclick=()=>{ const box=root.querySelector('#shrineShop'); box.style.display= box.style.display==="none"?"block":"none"; };
      root.querySelector('#shrineEvo').onclick=()=>{ const box=root.querySelector('#shrineEvoBox'); box.style.display= box.style.display==="none"?"block":"none"; };
      root.querySelectorAll('[data-buy]').forEach(b=>b.onclick=()=>{ const [type,id]=b.dataset.buy.split("|"); const src = type==="binder"? ITEMS.binders.find(x=>x.id===id) : type==="heal"? ITEMS.heals.find(x=>x.id===id) : ITEMS.catalysts.find(x=>x.id===id); if(state.bag.money < src.price){ toast("Not enough gold"); return;} state.bag.money-=src.price; if(type==="binder") state.bag.binders[id]=(state.bag.binders[id]||0)+1; if(type==="heal") state.bag.heals[id]=(state.bag.heals[id]||0)+1; if(type==="catalyst") state.bag.catalysts[id]=(state.bag.catalysts[id]||0)+1; toast(`Bought ${src.name}`); });
      root.querySelectorAll('[data-evo]').forEach(b=>b.onclick=()=>{ const i=+b.dataset.evo; const mon=state.team[i]; const base=getCreature(mon.cid); const evo=base.evo; if(!evo) return; if(evo.cat){ state.bag.catalysts[evo.cat]--; if(state.bag.catalysts[evo.cat]<=0) delete state.bag.catalysts[evo.cat]; } const newMon=makeMon(evo.to, mon.level); // keep HP ratio
        const ratio=mon.hp/mon.maxHp; newMon.hp=Math.floor(newMon.maxHp*ratio); newMon.uid=mon.uid; state.team[i]=newMon; state.addCaught(evo.to); toast(`${base.name} became ${newMon.name}!`); closeModal(); });
    }});
}

// ── let: captureResolve ──
let captureResolve=null;

// ── function: startCaptureMinigame ──
function startCaptureMinigame(targetMon, binderId){
  return new Promise(resolve=>{
    const root=document.getElementById('captureGame');
    root.classList.remove('hidden');
    const track=document.getElementById('capTrack');
    const cursor=document.getElementById('capCursor');
    const zoneEl=document.getElementById('capZone');
    const triesEl=document.getElementById('capTries');
    const binder=ITEMS.binders.find(b=>b.id===binderId);
    const hpPct=targetMon.hp/targetMon.maxHp;
    // success scales with HP (lower better), binder tier, and mini-game
    const baseChance = (1-hpPct)*0.55 + binder.bonus*0.18;
    const neededHits = binder.tier===1?2: binder.tier===2?3:4;
    const zoneW = binder.tier===1?18: binder.tier===2?24:30; // bigger zone easier
    zoneEl.style.width=zoneW+"%";
    zoneEl.style.left= (50-zoneW/2)+"%";
    let cursorPos=0; let dir=1; let hits=0; let tries=0; let successes=0; let failed=false;
    triesEl.innerHTML="";
    for(let i=0;i<neededHits;i++){ const d=document.createElement('div'); d.className='dot'; triesEl.appendChild(d); }
    const dots=[...triesEl.children];
    let anim=null; let speed=2.2 + binder.tier*0.3 - hpPct*0.8;
    function frame(){
      cursorPos+=dir*speed;
      if(cursorPos>100){ cursorPos=100; dir=-1; }
      if(cursorPos<0){ cursorPos=0; dir=1; }
      cursor.style.left=cursorPos+"%";
      anim=requestAnimationFrame(frame);
    }
    anim=requestAnimationFrame(frame);
    function attempt(){
      const zLeft=parseFloat(zoneEl.style.left), zW=parseFloat(zoneEl.style.width);
      const inZone = cursorPos>=zLeft && cursorPos<=zLeft+zW;
      if(inZone){ dots[successes].classList.add('hit'); successes++; if(window.gameAudio) window.gameAudio.pick(); }
      else { dots[successes].classList.add('miss'); failed=true; }
      tries++;
      if(successes>=neededHits){
        cancelAnimationFrame(anim);
        root.classList.add('hidden');
        const finalChance = baseChance + successes*0.12;
        // roll
        const roll=Math.random();
        const success = roll < finalChance;
        resolve({success, finalChance, hits:successes});
        return;
      }
      if(tries>=neededHits){
        cancelAnimationFrame(anim);
        root.classList.add('hidden');
        resolve({success:false, finalChance:baseChance, hits:successes});
        return;
      }
    }
    const onKey=e=>{ if(e.code==="Space"){ attempt(); }};
    const onClick=()=>attempt();
    const prevDown=track.onclick; track.onclick=onClick;
    window.addEventListener('keydown', onKey, {once:false});
    const cleanup=()=>{ window.removeEventListener('keydown', onKey); track.onclick=null; };
    // override resolve to cleanup
    const origResolve=resolve;
    resolve=(...args)=>{ cleanup(); cancelAnimationFrame(anim); root.classList.add('hidden'); origResolve(...args); };
    // also touch
    root.addEventListener('click', (ev)=>{ if(ev.target.id==="captureGame"||ev.target.closest(".capBox")) attempt(); }, {once:false});
    // allow timeout 14s auto fail
    setTimeout(()=>{ if(root.classList.contains('hidden')===false){ cancelAnimationFrame(anim); root.classList.add('hidden'); cleanup(); origResolve({success:false, finalChance:baseChance, hits:successes}); } },14000);
  });
}

// ── function: showCutin ──
function showCutin(idx, onDone){
  const data=STORY[idx]||STORY[0];
  const cut=document.getElementById('cutin');
  document.getElementById('cutTitle').textContent=data.title;
  document.getElementById('cutBody').textContent=data.text;
  cut.classList.remove('hidden');
  document.getElementById('cutNext').onclick=()=>{ cut.classList.add('hidden'); if(onDone) onDone(); };
}

// ── function: attemptWildEncounter ──
function attemptWildEncounter(state){
  const zone=ZONES[state.zone];
  const map=state.maps[state.zone];
  // increase steps and roll
  state.steps++;
  let rate=zone.rate;
  if(state.zone===5) rate*=1.4; // post-game harder
  if(Math.random()>rate) return null;
  // pick mob
  const pool=zone.mobs;
  const cid=choice(pool);
  const lvl= Math.max(2, ZONES[state.zone].boss.lvl - randi(2,6) + randi(0, state.zone*2));
  return {cid, lvl};
}

// ── function: startBattle ──
function startBattle(state, enemies, isBoss, onFinish){
  const overlay=document.getElementById('battleOverlay');
  const battleCan=document.getElementById('battleCanvas');
  const battleUI=document.getElementById('battleUI');
  overlay.classList.remove('hidden');
  const battle=new Battle(state,{enemies, isWild:!isBoss, isBoss});
  window.currentBattle=battle;
  let selectingMove=true;
  function renderUI(){
    battleUI.innerHTML="";
    const active=battle.getActiveAlly();
    if(!active) return;
    // team status row
    const teamRow=document.createElement('div'); teamRow.className='hpRow';
    battle.allyMons.forEach((m,i)=>{
      const d=document.createElement('div'); d.className='allyCard'+(i===battle.activeIdx?' active':'');
      d.innerHTML=`<b>${m.name} Lv${m.level}</b> <span class="typeBadge type-${m.types[0]}">${m.types[0]}</span><div class="barWrap"><div class="barFill" style="width:${Math.round(m.hp/m.maxHp*100)}%"></div></div><span>${m.hp}/${m.maxHp}</span>`;
      teamRow.appendChild(d);
    });
    battleUI.appendChild(teamRow);
    // moves
    active.moves.forEach((mid,idx)=>{
      const ab=getAbility(mid);
      const btn=document.createElement('button'); btn.className='moveBtn';
      btn.innerHTML=`<span class="mtag"><span class="typeBadge type-${ab.type}">${ab.type}</span> • PP ${active.pp[idx]}/${ab.pp}</span><span class="mname">${ab.name}</span><span class="mmeta">${ab.power? ab.power+" POW • "+ab.acc+"%":" — "} • ${ab.desc}</span>`;
      btn.disabled= active.pp[idx]<=0;
      btn.onclick=()=>playerAction(idx);
      battleUI.appendChild(btn);
    });
    const switchBtn=document.createElement('button'); switchBtn.className='moveBtn'; switchBtn.innerHTML=`<span class="mname">Switch Vanth</span><span class="mmeta">Swap active braiding</span>`; switchBtn.onclick=()=>openTeamSwitch();
    battleUI.appendChild(switchBtn);
    const bindRow=document.createElement('div'); bindRow.className='hpRow';
    Object.entries(state.bag.binders).forEach(([bid,cnt])=>{
      if(cnt<=0) return; const it=ITEMS.binders.find(x=>x.id===bid); const b=document.createElement('button'); b.textContent=`Bind ${it.name} x${cnt}`; b.onclick=()=>tryBind(bid); bindRow.appendChild(b);
    });
    battleUI.appendChild(bindRow);
  }
  function openTeamSwitch(){
    const list=document.createElement('div'); list.className='teamList';
    battle.allyMons.forEach((m,i)=>{
      const row=document.createElement('div'); row.className='teamSlot';
      row.innerHTML=`<b>${m.name} Lv${m.level}</b> ${m.hp}/${m.maxHp} <button data-i="${i}">Switch</button>`;
      row.querySelector('button').onclick=()=>{ if(i===battle.activeIdx){ toast("Already active"); return;} if(m.hp<=0){ toast("Fainted"); return;} battle.activeIdx=i; battle.buildQueue(); toast(`${m.name} in!`); renderBattleFrame(battle,battleCan); renderUI(); enemyTurnIfNeeded(); };
      list.appendChild(row);
    });
    openModal({title:"Switch", body:list.outerHTML, onMount(r){ r.querySelectorAll('button[data-i]').forEach(b=>{ b.onclick=()=>{ const i=+b.dataset.i; if(i===battle.activeIdx) return; const m=battle.allyMons[i]; if(m.hp<=0) return; battle.activeIdx=i; battle.buildQueue(); toast(`${m.name} braided in!`); closeModal(); renderBattleFrame(battle,battleCan); renderUI(); enemyTurnIfNeeded(); }; }); }});
  }
  function enemyAI(enemyMon){
    const aliveAllies=battle.allyMons.filter(m=>m.hp>0); const tgt=choice(aliveAllies) || battle.getActiveAlly();
    const moveIdx=randi(0, enemyMon.moves.length-1);
    return {target:tgt, moveIdx};
  }
  function logAndToast(msg){ battle.log.push(msg); document.getElementById('logText').textContent=msg; }

  function checkEnd(){
    if(battle.isAllEnemiesDown()){
      // victory
      const expTotal=battle.enemyMons.reduce((s,m)=>s+(20+m.level*10),0);
      state.team.forEach((tm, idx)=>{
        const allyBattle=battle.allyMons.find(am=>am._uid===tm.uid);
        if(!allyBattle) return;
        const expGain=Math.floor(expTotal / battle.allyMons.length * (allyBattle.hp>0?1:0.3));
        gainExp(tm, expGain, state);
      });
      state.team.forEach(m=>{ const bm=battle.allyMons.find(b=>b._uid===m.uid); if(bm){ m.hp=bm.hp; } });
      // gold
      const gold= battle.isBoss? randi(60,120) : randi(8,22);
      state.bag.money+=gold;
      toast(`Won! +${gold}◍`);
      if(window.gameAudio) window.gameAudio.win();
      if(isBoss){
        const bossZone=state.zone;
        state.defeatedBosses.add(bossZone);
        if(bossZone<5){ state.unlockedZone=Math.max(state.unlockedZone, bossZone+1); state.anchores++; }
        // check story progression
        if(state.anchores===1 && state.storyIdx===0){ state.storyIdx=1; showCutin(1); }
        if(state.anchores===2 && state.storyIdx===1){ state.storyIdx=2; showCutin(2); }
        if(state.anchores>=4){ state.storyIdx=3; }
        // final win?
        if(bossZone===5 && state.caught.size>=8){
          showCutin(3,()=>{
            openModal({title:"Shell Mended", body:`<p>You have braided ${state.caught.size} Vanth and broken ${state.anchores} anchors. The Confluence stabilizes. Post-game: harder wilds, seek all 27. Continue free roam?</p><button id="freeBtn" class="primary">Free Roam</button>`, onMount(r){ r.querySelector('#freeBtn').onclick=()=>{ closeModal(); }; }});
          });
        }
      }
      battle.ended=true; overlay.classList.add('hidden'); state.save();
      if(onFinish) onFinish(true);
      return true;
    }
    if(battle.isAllAlliesDown()){
      toast("All Vanth fainted! Returning to shrine.");
      state.team.forEach(m=>m.hp=1);
      // warp to shrine
      const ls=state.lastShrine; state.zone=ls.zone; state.player.x=ls.x; state.player.y=ls.y; state.player.px=ls.x; state.player.py=ls.y;
      battle.ended=true; overlay.classList.add('hidden'); if(onFinish) onFinish(false);
      return true;
    }
    return false;
  }

  async function tryBind(bid){
    if(!state.bag.binders[bid]||state.bag.binders[bid]<=0){ toast("No binders"); return; }
    const target=battle.enemyMons[0]; // capture first
    if(target.hp> target.maxHp*0.5 && Math.random()<0.4){ toast("Weaken it first!"); }
    state.bag.binders[bid]--; if(state.bag.binders[bid]<=0) delete state.bag.binders[bid];
    const res=await startCaptureMinigame(target, bid);
    if(res.success){
      toast(`Bound ${target.name}!`);
      if(state.hasTeamSpace()){ const newMon=makeMon(target.cid, target.level); state.team.push(newMon); state.addCaught(target.cid); }
      else { state.addCaught(target.cid); toast(`${target.name} stored as Thread Echo`); }
      battle.enemyMons=battle.enemyMons.filter(m=>m!==target);
      if(window.gameAudio) window.gameAudio.pick();
      if(checkEnd()) return;
      renderBattleFrame(battle,battleCan); renderUI();
    } else {
      toast(`Bind slipped (${Math.round(res.finalChance*100)}% chance, ${res.hits} hits)`);
      renderUI();
    }
  }

  function gainExp(mon, amount, gs){
    mon.levelExp=(mon.levelExp||0)+amount;
    let need=mon.level*22+40;
    while((mon.levelExp||0)>=need && mon.level<50){
      mon.levelExp-=need;
      mon.level++;
      gs.recalcMonStats(mon);
      mon.hp=mon.maxHp;
      toast(`${mon.name} is now Lv${mon.level}!`);
      const base=getCreature(mon.cid);
      const learn=base.learn.find(l=>l[1]===mon.level);
      if(learn){ const ab=getAbility(learn[0]); if(!mon.moves.includes(learn[0])){ if(mon.moves.length<4) mon.moves.push(learn[0]); else { mon.moves[3]=learn[0]; } gs.recalcMonStats(mon); toast(`${mon.name} learned ${ab.name}!`); } }
      need=mon.level*22+40;
    }
  }

  function playerAction(moveIdx){
    if(battle.ended) return;
    const ct=battle.getCurrentTurn();
    if(!ct || ct.side!=="ally"){ toast("Not your turn"); return; }
    const attacker=ct.m; const defender=battle.enemyMons.find(m=>m.hp>0);
    if(!defender){ if(checkEnd()) return; }
    const res=battle.performMove(attacker, defender, moveIdx);
    if(!res.ok){ toast(res.msg||"Cannot"); return; }
    // visual
    const rect=battleCan.getBoundingClientRect();
    const eX=rect.width*0.68, eY= rect.height*0.35;
    if(res.dmg>0){
      floatingDamage(battleCan.parentElement, eX+rand(-20,20), eY+rand(-20,20), `-${res.dmg}`, res.crit, res.eff );
      if(window.gameAudio) window.gameAudio.hit(res.crit, res.eff>1.1);
      // screen shake
      battleCan.style.transform=`translate(${rand(-4,4)}px,${rand(-4,4)}px)`; setTimeout(()=>battleCan.style.transform="",80);
    }
    renderBattleFrame(battle,battleCan); renderUI();
    if(defender.hp<=0){ logAndToast(`${defender.name} thread faded!`); }
    if(checkEnd()) return;
    battle.nextTurn();
    setTimeout(()=>enemyTurnIfNeeded(), 500);
  }

  function enemyTurnIfNeeded(){
    if(battle.ended) return;
    let ct=battle.getCurrentTurn();
    if(!ct) { battle.buildQueue(); ct=battle.getCurrentTurn(); }
    if(ct.side==="ally"){ renderUI(); return; }
    // enemy turn
    const enemyMon=ct.m;
    const allyTgt=battle.getActiveAlly();
    const chosen=enemyMon.moves[randi(0,enemyMon.moves.length-1)];
    const moveIdx=enemyMon.moves.indexOf(chosen);
    const abil=getAbility(chosen);
    if(!allyTgt || allyTgt.hp<=0){ battle.nextTurn(); enemyTurnIfNeeded(); return; }
    const res=battle.performMove(enemyMon, allyTgt, moveIdx);
    const rect=battleCan.getBoundingClientRect(); const aX=rect.width*0.22, aY=rect.height*0.62;
    if(res.dmg>0){
      floatingDamage(battleCan.parentElement, aX+rand(-20,20), aY+rand(-20,20), `-${res.dmg}`, res.crit, res.eff);
      if(window.gameAudio) window.gameAudio.hit(res.crit, res.eff>1.1);
      battleCan.style.transform=`translate(${rand(-4,4)}px,${rand(-4,4)}px)`; setTimeout(()=>battleCan.style.transform="",80);
    }
    // sync back to state.ally hp
    const orig=battle.allyMons.find(m=>m===allyTgt);
    if(orig && orig._orig) orig._orig.hp=Math.max(0, orig.hp);
    else { const stMon=state.team.find(m=>m.uid===orig._uid); if(stMon) stMon.hp=orig.hp; }
    renderBattleFrame(battle,battleCan); renderUI();
    if(allyTgt.hp<=0){ toast(`${allyTgt.name} fainted!`); const alive=battle.allyMons.filter(m=>m.hp>0); if(alive.length>0){ battle.activeIdx=battle.allyMons.indexOf(alive[0]); battle.buildQueue(); } }
    if(checkEnd()) return;
    battle.nextTurn();
    const next=battle.getCurrentTurn();
    if(next.side==="enemy") setTimeout(()=>enemyTurnIfNeeded(), 650);
    else renderUI();
  }

  renderBattleFrame(battle,battleCan,0);
  renderUI();
  enemyTurnIfNeededCheck();
  function enemyTurnIfNeededCheck(){ const ct=battle.getCurrentTurn(); if(ct.side==="enemy") enemyTurnIfNeeded(); }
}

// ── main ──
(() => {
  const worldCanvas=document.getElementById('worldCanvas');
  const bgCanvas=document.getElementById('bgCanvas');
  const battleCanvas=document.getElementById('battleCanvas');
  const logEl=document.getElementById('logText');
  const zoneLabel=document.getElementById('zoneLabel');
  const storyLabel=document.getElementById('storyStageLabel');
  const collectedLabel=document.getElementById('collectedLabel');
  const titleScreen=document.getElementById('titleScreen');
  const touchPad=document.getElementById('touchPad');
  const btnNew=document.getElementById('btnNew');
  const btnCont=document.getElementById('btnCont');
  const btnMute=document.getElementById('btnMute');
  const btnBag=document.getElementById('btnBag');
  const btnTeam=document.getElementById('btnTeam');
  const btnCodex=document.getElementById('btnCodex');
  const btnMap=document.getElementById('btnMap');
  const tAct=document.getElementById('tAct');

  const state=new GameState();
  window.gameState=state;
  const audio=new AudioSys();
  window.gameAudio=audio;
  let bgFx=new ParticleFx(bgCanvas);
  function resizeAll(){
    const dpr=Math.min(window.devicePixelRatio||1,2);
    [bgCanvas, worldCanvas, battleCanvas].forEach(cv=>{
      const rect=cv.getBoundingClientRect();
      cv.width=rect.width*dpr; cv.height=rect.height*dpr;
      cv.getContext('2d').setTransform(dpr,0,0,dpr,0,0);
    });
    bgFx.resize(bgCanvas.clientWidth, bgCanvas.clientHeight);
  }
  window.addEventListener('resize', resizeAll);
  resizeAll();
  bgFx.setZone(0);
  let last=time=>{};

  let cam={x:5,y:6};
  let moving={up:false,down:false,left:false,right:false};
  let moveCooldown=0;
  let selectedStarter=0;
  document.querySelectorAll('.starter').forEach(el=>{
    el.onclick=()=>{
      document.querySelectorAll('.starter').forEach(e=>e.classList.remove('selected'));
      el.classList.add('selected');
      selectedStarter=+el.dataset.starter;
    };
  });
  document.querySelector('.starter[data-starter="0"]').classList.add('selected');

  function newGame(){
    state.zone=0;
    state.maps={}; ZONES.forEach((z,i)=> state.maps[i]=new WorldMap(i));
    state.player={x:5,y:6, px:5, py:6, dir:0};
    state.bag={binders:{bind0:12,bind1:2,bind2:0}, heals:{heal0:3,heal1:1,heal2:0}, catalysts:{}, money:120};
    state.team=[]; state.seen=new Set(); state.caught=new Set(); state.defeatedBosses=new Set(); state.unlockedZone=0; state.storyIdx=0; state.anchores=0;
    state.lastShrine={zone:0,x:5,y:5};
    state.tutorial={moveDone:false,battleDone:false,bindDone:false};
    const starterIds=[0,1,2];
    const starterPick=starterIds[selectedStarter];
    const mon=makeMon(starterPick,5);
    // give starter second move quickly
    if(mon.moves.length<2) mon.moves.push(getCreature(starterPick).learn[1]?.[0]||0);
    state.team.push(mon); state.addCaught(starterPick);
    state.save();
    startPlay();
  }

  function startPlay(){
    titleScreen.classList.remove('active');
    audio.init();
    // show tutorial cutin if first time
    if(state.storyIdx===0 && state.anchores===0){
      showCutin(0);
    }
    updateHud();
    requestAnimationFrame(loop);
  }

  function updateHud(){
    zoneLabel.textContent=`${ZONES[state.zone].name} • ${state.unlockedZone>=state.zone?"Unlocked":"Locked"}`;
    const story=STORY[state.storyIdx]||STORY[0];
    storyLabel.textContent=story.title.split("•")[0];
    collectedLabel.textContent=`Codex ${state.caught.size}/27 • Anchors ${state.anchores}/4`;
  }

  function tryInteract(){
    const map=state.maps[state.zone];
    const px=Math.round(state.player.px), py=Math.round(state.player.py);
    const dirs=[[0,0],[0,-1],[0,1],[-1,0],[1,0]];
    for(let [dx,dy] of dirs){
      const x=px+dx, y=py+dy;
      const t=map.getTile(x,y);
      if(t===3){
        buildShrineModal(state);
        return;
      }
      if(t===5){
        const zone=ZONES[state.zone];
        const boss=zone.boss;
        // check if already defeated
        if(state.defeatedBosses.has(state.zone)){
          toast("Anchor already broken, resonance quiet");
          return;
        }
        // ask battle
        openModal({title:`Guardian: ${boss.name}`, body:`<p>This anchor is guarded by ${getCreature(boss.creature).name} Lv${boss.lvl}. Bind will be tested. You need ${state.zone<4? state.anchores>=state.zone?"":"at least "+state.zone+" anchors broken":""}</p><button id="fightBoss" class="primary">Weave Challenge</button>`, onMount(r){
          r.querySelector('#fightBoss').onclick=()=>{ closeModal(); const enemies=[{cid:boss.creature, lvl:boss.lvl}]; // add minions for later zones
            if(state.zone>=2) enemies.push({cid: choice(ZONES[state.zone].mobs), lvl: boss.lvl-2});
            if(state.zone>=4) enemies.push({cid: choice(ZONES[state.zone].mobs), lvl: boss.lvl-3});
            startBattle(state, enemies, true, (won)=>{
              if(won){ updateHud(); bgFx.setZone(state.zone); toast(`Anchor ${state.zone+1} broken!`); if(state.unlockedZone<=state.zone && state.zone<5){ state.unlockedZone=state.zone+1; } state.save(); }
            });
          };
        }});
        return;
      }
      if(t===4){
        if(state.zone>=state.unlockedZone && state.zone<5 && !state.defeatedBosses.has(state.zone) && state.zone!==0){
          toast(`The exit is sealed by ${ZONES[state.zone].name} anchor. Defeat guardian.`);
          return;
        }
        const nextZone=state.zone+1;
        if(nextZone>5){ toast("You are at the Confluence edge. No further."); return; }
        if(nextZone>state.unlockedZone){ toast("Thread too tangled, unlock via guardians"); return; }
        state.zone=nextZone;
        const entry=ZONES[nextZone].shrineAt; state.player.x=entry.x; state.player.y=entry.y; state.player.px=entry.x; state.player.py=entry.y;
        cam.x=state.player.px; cam.y=state.player.py;
        bgFx.setZone(state.zone);
        toast(`Entered ${ZONES[state.zone].name}`);
        updateHud(); state.save();
        return;
      }
    }
    toast("Wind whispers, but nothing to braid here");
  }

  function loop(ts){
    const dt=16;
    // player smooth lerp
    state.player.px = lerp(state.player.px, state.player.x, 0.18);
    state.player.py = lerp(state.player.py, state.player.y, 0.18);
    cam.x = lerp(cam.x, state.player.px, 0.10);
    cam.y = lerp(cam.y, state.player.py, 0.10);

    // input movement
    if(moveCooldown<=0){
      let nx=state.player.x, ny=state.player.y;
      if(moving.up) ny--; else if(moving.down) ny++; else if(moving.left) nx--; else if(moving.right) nx++;
      if(nx!==state.player.x || ny!==state.player.y){
        const map=state.maps[state.zone];
        if(map.isWalkable(nx,ny)){
          state.player.x=nx; state.player.y=ny;
          moveCooldown=7; // ticks
          // wild encounter check
          const enc=attemptWildEncounter(state);
          if(enc){
            // don't encounter right after shrine? small mercy
            const distToShrine= Math.abs(nx - ZONES[state.zone].shrineAt.x) + Math.abs(ny - ZONES[state.zone].shrineAt.y);
            if(distToShrine>3){
              state.addSeen(enc.cid);
              // start battle
              moveCooldown=30;
              toast(`Wild ${getCreature(enc.cid).name} Lv${enc.lvl} emerges!`);
              const count = state.zone>=3 && Math.random()<0.35 ? 2 : 1;
              const enemies=[{cid:enc.cid, lvl:enc.lvl}];
              if(count===2) enemies.push({cid: choice(ZONES[state.zone].mobs), lvl: Math.max(1,enc.lvl-1)});
              startBattle(state, enemies, false, (won)=>{
                if(won){
                  if(!state.tutorial.battleDone){ state.tutorial.battleDone=true; toast("Tutorial: Use Binders from battle menu to catch!"); }
                }
              });
            }
          }
          if(!state.tutorial.moveDone && state.steps>18){ state.tutorial.moveDone=true; toast("Tutorial: Press ◉ near shrine (gold circle) to rest."); }
        }
      }
    } else moveCooldown--;

    renderOverworld(state, worldCanvas, cam);
    bgFx.tick();
    updateHud();
    // continue loop if no battle? battle overlay hidden check
    const battleOverlay=document.getElementById('battleOverlay');
    if(battleOverlay.classList.contains('hidden')){
      requestAnimationFrame(loop);
    } else {
      // still animate bgFx but not overworld loop double? keep looping battle canvas
      requestAnimationFrame(()=>{ bgFx.tick(); if(!battleOverlay.classList.contains('hidden')) requestAnimationFrame(loop); else requestAnimationFrame(loop); });
    }
  }

  // input
  window.addEventListener('keydown', e=>{
    if(e.code==="ArrowUp"||e.code==="KeyW") moving.up=true;
    if(e.code==="ArrowDown"||e.code==="KeyS") moving.down=true;
    if(e.code==="ArrowLeft"||e.code==="KeyA") moving.left=true;
    if(e.code==="ArrowRight"||e.code==="KeyD") moving.right=true;
    if(e.code==="Space"||e.code==="Enter"){ e.preventDefault(); tryInteract(); }
  });
  window.addEventListener('keyup', e=>{
    if(e.code==="ArrowUp"||e.code==="KeyW") moving.up=false;
    if(e.code==="ArrowDown"||e.code==="KeyS") moving.down=false;
    if(e.code==="ArrowLeft"||e.code==="KeyA") moving.left=false;
    if(e.code==="ArrowRight"||e.code==="KeyD") moving.right=false;
  });
  // touch
  document.querySelectorAll('#touchPad [data-dir]').forEach(btn=>{
    const dir=btn.dataset.dir;
    const start=()=>{ moving[dir]=true; };
    const end=()=>{ moving[dir]=false; };
    btn.addEventListener('touchstart', e=>{ e.preventDefault(); start(); }, {passive:false});
    btn.addEventListener('touchend', e=>{ e.preventDefault(); end(); }, {passive:false});
    btn.addEventListener('mousedown', start);
    btn.addEventListener('mouseup', end);
    btn.addEventListener('mouseleave', end);
  });
  tAct.addEventListener('click', tryInteract);
  tAct.addEventListener('touchstart', e=>{ e.preventDefault(); tryInteract(); }, {passive:false});

  btnNew.onclick=newGame;
  btnCont.onclick=()=>{
    if(state.load()){ toast("Continued binding"); bgFx.setZone(state.zone); startPlay(); }
    else { toast("No save found, starting new"); newGame(); }
  };
  // auto-load continue visibility
  try{ if(localStorage.getItem("aestral_autosave")) btnCont.style.display="inline-block"; else btnCont.style.opacity="0.5"; }catch(e){}

  btnMute.onclick=()=>{ const muted=audio.toggle(); btnMute.textContent=muted? "♪ OFF":"♪ ON"; };
  btnBag.onclick=()=>buildBagModal(state);
  btnTeam.onclick=()=>buildTeamModal(state);
  btnCodex.onclick=()=>buildLexiconModal(state);
  btnMap.onclick=()=>buildMapModal(state);

  // initial bg
  resizeAll();
  bgFx.setZone(0);
  function bgLoop(){ bgFx.tick(); requestAnimationFrame(bgLoop); } bgLoop();

  // tutorial slight hints
  logEl.textContent="Choose a starter, then press New Binding. WASD to wander. Shrines are gold. Guardians are red.";
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.1","provider":"openrouter","persona":"composer","sessionId":"6445c041-e9d6-419f-943d-c39c0bb16c03","tokensIn":863972,"tokensOut":40662,"tokensTotal":904634,"turns":26,"toolCalls":25,"failedToolCalls":1,"timestamp":"2026-07-17T04:23:47.147Z"} -->