← Creature Collection results

Creature Collection

Kimi k2.7 code · composer

Initial view of Creature Collection

Browser check: Artifact runtime error. This is a technical check, not a quality review.

Reviews & generation details

Reviews

Human review · Jul 17, 2026 · rubric 1

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

Visual design
2 / 5
UX / feel
3 / 5
Works
1 / 5
Prompt spirit
1 / 5
Delight
1 / 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 96,538 bytes · SHA-256 a0d51dc6fd18
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Loom of Velmorath</title>
<style>
/* ── css: base ── */
:root {
  --bg1: #0b0f19;
  --bg2: #141b2d;
  --card: rgba(255,255,255,0.08);
  --card-b: rgba(255,255,255,0.15);
  --text: #f0f4ff;
  --muted: #9aa8c9;
  --accent: #7ce0ff;
  --accent2: #ffd36e;
  --danger: #ff6b6b;
  --heal: #6bffa3;
  --cinder: #ff7a4a;
  --tidal: #4ad0ff;
  --verdant: #6bff84;
  --umbral: #b47cff;
  --lumin: #fff07c;
  --aether: #7cfff5;
  --null: #b0b8c8;
  --shadow: 0 12px 32px rgba(0,0,0,0.35);
  font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
  color: var(--text);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
  width: 100%; height: 100%;
  background: #05070a;
  overflow: hidden;
  touch-action: manipulation; -webkit-user-select: none; user-select: none;
}
button { font: inherit; cursor: pointer; border: none; }
.screen {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  transition: opacity .35s ease;
}
.screen[hidden] { display: none !important; }
.glass-panel, .glass-btn {
  background: var(--card);
  border: 1px solid var(--card-b);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  border-radius: 16px;
  color: var(--text);
  box-shadow: var(--shadow);
}
.glass-btn {
  padding: 12px 24px; margin: 6px;
  transition: transform .12s, background .2s, box-shadow .2s;
}
.glass-btn:hover:not(:disabled) { background: rgba(255,255,255,0.14); }
.glass-btn:active:not(:disabled) { transform: scale(0.97); }
.glass-btn:disabled { opacity: .45; cursor: default; }
.icon-btn { width: 40px; height: 40px; border-radius: 50%; background: var(--card); border: 1px solid var(--card-b); color: var(--text); }
.mute-btn { position: absolute; bottom: 18px; right: 18px; width: 48px; height: 48px; border-radius: 50%; background: var(--card); border: 1px solid var(--card-b); font-size: 22px; }
/* ── css: title ── */
#title-screen {
  background: radial-gradient(circle at 50% 40%, #1a2b4d 0%, #080b14 70%);
  flex-direction: column;
  overflow: hidden;
}
#title-screen::before {
  content: ''; position: absolute; inset: 0;
  background: repeating-conic-gradient(from 0deg, rgba(124,224,255,0.05) 0deg 2deg, transparent 2deg 4deg);
  animation: turn 60s linear infinite;
  pointer-events: none;
}
@keyframes turn { to { transform: rotate(360deg); } }
.title-card {
  position: relative;
  z-index: 2;
  text-align: center;
  padding: 48px 56px;
  max-width: 520px;
  background: linear-gradient(180deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.04) 100%);
  border: 1px solid rgba(255,255,255,0.18);
  border-radius: 24px;
  box-shadow: 0 0 80px rgba(124,224,255,0.12);
}
.title-card h1 {
  font-size: 3.2rem; letter-spacing: 2px;
  text-shadow: 0 0 30px rgba(124,224,255,0.5), 0 2px 4px rgba(0,0,0,0.4);
  margin-bottom: 8px;
}
.subtitle { color: var(--muted); font-size: 1.1rem; margin-bottom: 28px; }
.title-buttons { display: flex; flex-direction: column; gap: 10px; }
.slot-select { margin-top: 16px; }
.slot-select p { margin-bottom: 8px; color: var(--muted); }
.small { font-size: .9rem; padding: 8px 18px; }
/* ── css: game-layout ── */
#game-screen { background: #000; align-items: stretch; justify-content: stretch; }
#game-canvas {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  image-rendering: pixelated;
}
#ui-layer {
  position: absolute; inset: 0;
  pointer-events: none;
  display: flex; flex-direction: column; justify-content: space-between;
}
#ui-layer > * { pointer-events: auto; }
#top-hud {
  display: flex; align-items: center; gap: 12px;
  padding: 10px 16px;
}
#hud-zone { font-weight: 700; color: var(--accent); text-shadow: 0 0 12px rgba(124,224,255,0.4); }
#hud-story { margin-right: auto; color: var(--muted); font-size: .9rem; }
#hud-coin { color: var(--accent2); font-weight: 700; }
#touch-controls {
  display: flex; justify-content: space-between; align-items: flex-end;
  padding: 14px 20px; gap: 20px;
}
.dpad { display: grid; grid-template-columns: repeat(3, 52px); grid-template-rows: repeat(3, 52px); gap: 6px; }
.dpad button { width: 52px; height: 52px; border-radius: 12px; background: var(--card); border: 1px solid var(--card-b); color: var(--text); font-size: 22px; align-self: center; justify-self: center; }
.dpad button[data-dir="up"] { grid-column: 2; grid-row: 1; }
.dpad button[data-dir="left"] { grid-column: 1; grid-row: 2; }
.dpad button[data-dir="down"] { grid-column: 2; grid-row: 3; }
.dpad button[data-dir="right"] { grid-column: 3; grid-row: 2; }
.action-pad { display: flex; gap: 12px; align-items: flex-end; }
.action-btn { width: 64px; height: 64px; border-radius: 50%; font-weight: 800; font-size: 1.4rem; background: var(--accent); color: #04222b; border: 2px solid rgba(255,255,255,0.35); box-shadow: 0 0 20px rgba(124,224,255,0.25); }
#btn-b { background: var(--danger); color: #2b0a0a; }
/* ── css: dialogue ── */
#dialogue-box {
  position: absolute; bottom: 18px; left: 18px; right: 18px;
  display: flex; flex-direction: column; gap: 8px;
  padding: 18px 22px;
  background: rgba(10,14,24,0.72);
  border: 1px solid rgba(255,255,255,0.12);
  border-radius: 16px;
  box-shadow: var(--shadow);
  min-height: 120px;
  z-index: 20;
}
#dialogue-portrait { width: 48px; height: 48px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--aether)); flex-shrink: 0; }
#dialogue-text { font-size: 1.05rem; line-height: 1.5; }
.dialogue-tip { font-size: .8rem; color: var(--muted); align-self: flex-end; }
/* ── css: battle-ui ── */
#battle-ui {
  position: absolute; inset: 0;
  display: flex; flex-direction: column; justify-content: space-between;
  align-items: stretch;
  padding: 12px;
  pointer-events: none;
}
#battle-ui > * { pointer-events: auto; }
#battle-log {
  position: absolute; top: 12px; left: 12px; right: 12px;
  min-height: 64px; max-height: 96px;
  overflow-y: auto;
  padding: 10px 14px; font-size: .92rem;
  background: rgba(10,14,24,0.65);
}
#battle-log p { margin: 2px 0; }
#battle-log span.crit { color: var(--accent2); font-weight: 700; }
#battle-log span.super { color: var(--danger); }
#battle-menu, #battle-target {
  position: absolute; bottom: 96px; left: 12px; right: 12px;
  display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px;
  max-height: 260px; overflow-y: auto;
}
#battle-ui .glass-btn {
  position: absolute; bottom: 18px;
  padding: 10px 16px; font-size: .9rem;
}
#battle-switch { left: 12px; }
#battle-bag { left: 50%; transform: translateX(-50%); }
#battle-run { right: 12px; }
.move-btn {
  position: relative;
  display: flex; flex-direction: column; align-items: flex-start;
  padding: 10px 14px; border-radius: 12px;
  background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15);
  color: var(--text);
}
.move-btn .move-name { font-weight: 700; }
.move-btn .move-meta { font-size: .8rem; color: var(--muted); }
.move-btn .type-dot { position: absolute; top: 8px; right: 8px; width: 14px; height: 14px; border-radius: 50%; }
.target-btn { padding: 12px; text-align: left; font-size: .95rem; }
/* ── css: modal ── */
.modal {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  z-index: 50;
  background: rgba(0,0,0,0.45);
}
.modal-panel {
  width: min(860px, 96vw); height: min(640px, 92vh);
  display: flex; flex-direction: column;
  padding: 20px;
  overflow: hidden;
}
.modal-tabs {
  display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px;
}
.modal-tabs button {
  padding: 8px 16px; border-radius: 12px;
  background: rgba(255,255,255,0.08); color: var(--text); border: 1px solid var(--card-b);
}
.modal-tabs button.active { background: var(--accent); color: #04222b; }
.menu-content { flex: 1; overflow-y: auto; padding: 6px; }
#menu-close { align-self: flex-end; margin-top: 12px; }
.card {
  background: rgba(255,255,255,0.06);
  border: 1px solid rgba(255,255,255,0.12);
  border-radius: 12px;
  padding: 12px; margin-bottom: 10px;
}
.card h4 { margin-bottom: 6px; color: var(--accent); }
.card p { color: var(--muted); font-size: .92rem; }
.stat-bar { height: 10px; background: rgba(0,0,0,0.25); border-radius: 5px; overflow: hidden; margin-top: 4px; }
.stat-bar > div { height: 100%; transition: width .25s linear; }
.party-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
.affinity-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: .78rem; font-weight: 700; margin-right: 4px; }
/* ── css: overlays ── */
#collection-overlay {
  position: absolute; inset: 0; z-index: 60;
  display: flex; align-items: center; justify-content: center;
  background: rgba(0,0,0,0.55);
}
.collection-frame { text-align: center; padding: 28px; width: min(400px, 90vw); }
.collection-frame canvas { display: block; margin: 14px auto; }
#collect-result { min-height: 24px; font-weight: 700; }
#tutorial-overlay {
  position: absolute; inset: 0; z-index: 70;
  display: flex; align-items: center; justify-content: center;
  background: rgba(0,0,0,0.5);
}
#tutorial-card { max-width: 520px; padding: 28px; text-align: center; }
#tutorial-card h3 { margin-bottom: 12px; color: var(--accent); }
#tutorial-card p { line-height: 1.5; margin-bottom: 18px; }
#vignette {
  position: absolute; inset: 0; pointer-events: none; opacity: 0;
  box-shadow: inset 0 0 0 0 transparent;
  transition: opacity .4s;
  z-index: 30;
}
#vignette.active { opacity: 1; }
@keyframes damage-flash { 0%{opacity:1}50%{opacity:0.2}100%{opacity:1} }
.flash { animation: damage-flash .12s ease-in-out 2; }
@keyframes shake { 0%,100%{transform:translate(0,0)}20%{transform:translate(-4px,3px)}40%{transform:translate(4px,-3px)}60%{transform:translate(-3px,2px)}80%{transform:translate(3px,-2px)} }
.shake { animation: shake .22s ease-in-out; }
#toast-area { position: absolute; top: 60px; right: 14px; display: flex; flex-direction: column; gap: 6px; pointer-events: none; z-index: 40; }
.toast {
  background: rgba(20,28,44,0.9); color: var(--text);
  border: 1px solid var(--card-b); border-radius: 10px;
  padding: 10px 16px; box-shadow: var(--shadow);
  animation: slideIn .3s ease, fadeOut .4s ease 2.2s forwards;
}
@keyframes slideIn { from { transform: translateX(80px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes fadeOut { to { opacity: 0; transform: translateY(-6px); } }
/* ── css: aff-color ── */
.aff-cinder { background: var(--cinder); color: #1a0a04; }
.aff-tidal { background: var(--tidal); color: #041a22; }
.aff-verdant { background: var(--verdant); color: #051c0a; }
.aff-umbral { background: var(--umbral); color: #160a26; }
.aff-lumin { background: var(--lumin); color: #261f04; }
.aff-aether { background: var(--aether); color: #052423; }
.aff-null { background: var(--null); color: #0b0d12; }
/* ── css: battle-bottom ── */
#battle-capture { position: absolute; bottom: 18px; left: 130px; padding: 10px 16px; font-size: .9rem; }
#touch-controls { z-index: 10; }
#game-screen.battle-active #touch-controls { display: none; }
#menu-modal { z-index: 80; }
#collection-overlay { z-index: 90; }
#tutorial-overlay { z-index: 100; }
</style>
</head>
<body>
<div id="app">
  <div id="title-screen" class="screen active">
    <div class="title-card">
      <h1>Loom of Velmorath</h1>
      <p class="subtitle">Weave bonds with the Aetherlings of a broken world.</p>
      <div class="title-buttons">
        <button id="btn-new" class="glass-btn">New Binding</button>
        <button id="btn-continue" class="glass-btn" disabled="">Continue</button>
      </div>
      <div id="slot-select" class="slot-select" hidden="">
        <p>Choose a binding thread</p>
        <button class="glass-btn slot-btn" data-slot="0">Thread 1</button>
        <button class="glass-btn slot-btn" data-slot="1">Thread 2</button>
        <button class="glass-btn slot-btn" data-slot="2">Thread 3</button>
        <button id="slot-back" class="glass-btn small">Back</button>
      </div>
    </div>
    <button id="btn-mute-title" class="mute-btn" aria-label="mute">🔊</button>
  </div>
  <div id="game-screen" class="screen" hidden="">
    <canvas id="game-canvas" width="960" height="540"></canvas>
    <div id="ui-layer">
      <div id="top-hud">
        <span id="hud-zone">Zone</span>
        <span id="hud-story">Thread</span>
        <span id="hud-coin">◆ 0</span>
        <button id="hud-menu" class="icon-btn">☰</button>
        <button id="hud-mute" class="icon-btn">🔊</button>
      </div>
      <div id="touch-controls">
        <div class="dpad">
          <button data-dir="up">▲</button>
          <button data-dir="left">◀</button>
          <button data-dir="down">▼</button>
          <button data-dir="right">▶</button>
        </div>
        <div class="action-pad">
          <button id="btn-a" class="action-btn">A</button>
          <button id="btn-b" class="action-btn">B</button>
        </div>
      </div>
      <div id="dialogue-box" hidden="">
        <div id="dialogue-portrait"></div>
        <div id="dialogue-text"></div>
        <div class="dialogue-tip">▶ press A / click to continue</div>
      </div>
      <div id="battle-ui" hidden="">
        <div id="battle-log" class="glass-panel"></div>
        <div id="battle-menu" class="battle-menu"></div>
        <div id="battle-target" class="battle-menu" hidden=""></div>
        <button id="battle-switch" class="glass-btn">Switch</button>
        <button id="battle-bag" class="glass-btn">Bag<button id="battle-capture" class="glass-btn">Bind</button></button>
        <button id="battle-run" class="glass-btn">Flee</button>
      </div>
      <div id="menu-modal" class="modal" hidden="">
        <div class="modal-panel glass-panel">
          <nav class="modal-tabs">
            <button data-tab="party">Party</button>
            <button data-tab="bag">Bag</button>
            <button data-tab="guide">Guide</button>
            <button data-tab="map">Map</button>
            <button data-tab="options">Options</button>
          </nav>
          <div id="menu-content" class="menu-content"></div>
          <button id="menu-close" class="glass-btn">Close</button>
        </div>
      </div>
      <div id="collection-overlay" hidden="">
        <div class="collection-frame glass-panel">
          <h2>Resonance Bind</h2>
          <p>Tap or press A when the pulse overlaps the binding ring.</p>
          <canvas id="collect-canvas" width="300" height="300"></canvas>
          <div id="collect-result"></div>
          <button id="collect-try" class="glass-btn">Bind</button>
          <button id="collect-cancel" class="glass-btn">Cancel</button>
        </div>
      </div>
      <div id="tutorial-overlay" hidden="">
        <div id="tutorial-card" class="glass-panel">
          <h3 id="tut-title">Welcome</h3>
          <p id="tut-body"></p>
          <button id="tut-next" class="glass-btn">Next</button>
        </div>
      </div>
      <div id="vignette"></div>
      <div id="toast-area"></div>
    </div>
  </div>
</div>
<script>
'use strict';

// ── const: AFFINITIES ──
const AFFINITIES = {
  cinder: { name: 'Cinder', color: '#ff7a4a' },
  tidal:  { name: 'Tidal',  color: '#4ad0ff' },
  verdant:{ name: 'Verdant',color: '#6bff84' },
  umbral: { name: 'Umbral', color: '#b47cff' },
  lumin:  { name: 'Lumin',  color: '#fff07c' },
  aether: { name: 'Aether', color: '#7cfff5' },
  null:   { name: 'Null',   color: '#b0b8c8' }
};

// ── const: TYPE_ORDER ──
const TYPE_ORDER = ['cinder','tidal','verdant','umbral','lumin','aether','null'];

// ── function: buildTypeMatrix ──
function buildTypeMatrix() {
  const m = {};
  for (const a of TYPE_ORDER) m[a] = {};
  function set(atk, def, mul) { m[atk][def] = mul; }
  for (const a of TYPE_ORDER) for (const d of TYPE_ORDER) set(a,d,1.0);
  set('cinder','tidal',1.5); set('cinder','verdant',0.5); set('cinder','umbral',0.5);
  set('tidal','verdant',1.5); set('tidal','cinder',0.5); set('tidal','lumin',0.5);
  set('verdant','cinder',1.5); set('verdant','tidal',0.5); set('verdant','umbral',0.5);
  set('umbral','cinder',1.5); set('umbral','verdant',1.5); set('umbral','lumin',0.5);
  set('lumin','umbral',1.5); set('lumin','tidal',1.5);
  // Aether beats all others slightly
  for (const d of TYPE_ORDER) if (d !== 'aether') set('aether', d, 1.25);
  // Null resists everything; weak to Aether
  for (const a of TYPE_ORDER) if (a !== 'aether' && a !== 'null') { set(a,'null',0.5); set('null',a,1.0); }
  set('aether','null',1.5); set('null','aether',1.0);
  set('null','null',1.0); set('aether','aether',1.0);
  return m;
}

// ── const: TYPE_MATRIX ──
const TYPE_MATRIX = buildTypeMatrix();

// ── const: ZONES ──
const ZONES = [
  { id:'emberglade', name:'Emberglade', aff:'cinder', hueA:'#2b0e05', hueB:'#5c1e0b', particle:'ember', rate:0.18, boss:'Pyrelith', level:6 },
  { id:'mistwood',   name:'Mistwood',   aff:'verdant', hueA:'#0a1f14', hueB:'#143824', particle:'spore', rate:0.16, boss:'Thornregent', level:12 },
  { id:'deepreaches',name:'Deepreaches',aff:'tidal', hueA:'#05182b', hueB:'#0a2f52', particle:'bubble',rate:0.16, boss:'Abyssalene', level:18 },
  { id:'voidcoast',  name:'Voidcoast',  aff:'umbral', hueA:'#160a26', hueB:'#24113d', particle:'mote', rate:0.15, boss:'Nocturnex', level:25 },
  { id:'shimmerpeak',name:'Shimmerpeak',aff:'lumin', hueA:'#2b2405', hueB:'#52460a', particle:'gleam',rate:0.14, boss:'Solaramis', level:32 },
  { id:'aethercrux', name:'Aethercrux', aff:'aether', hueA:'#051f1e', hueB:'#0a3d3a', particle:'star', rate:0.14, boss:'Astrion Prime', level:40 }
];

// ── const: LEVEL_CAP ──
const LEVEL_CAP = 50;

// ── const: ABILITIES ──
const ABILITIES = [
  // Cinder
  { id:'flame_lash', name:'Flame Lash', aff:'cinder', power:40, acc:100, cost:8, effect:'burn', chance:0.15, desc:'A scalding whip that may ignite.' },
  { id:'cinder_burst', name:'Cinder Burst', aff:'cinder', power:65, acc:95, cost:14, desc:'Detonates a core of smoldering embers.' },
  { id:'pyre_claw', name:'Pyre Claw', aff:'cinder', power:55, acc:100, cost:12, desc:'Rakes the foe with claws of solid flame.' },
  { id:'scorched_earth', name:'Scorched Earth', aff:'cinder', power:30, acc:90, cost:18, targetAll:true, effect:'burn', chance:0.25, desc:'Sears the field, burning all foes.' },
  { id:'kindle', name:'Kindle', aff:'cinder', power:0, acc:100, cost:12, effect:'self_atk_up', desc:'Builds inner heat to raise offense.' },
  { id:'inferno', name:'Inferno', aff:'cinder', power:95, acc:85, cost:28, effect:'burn', chance:0.3, desc:'A towering blaze with high ignition chance.' },
  // Tidal
  { id:'water_lash', name:'Water Lash', aff:'tidal', power:40, acc:100, cost:8, desc:'A snapping torrent of pressure.' },
  { id:'bubble_veil', name:'Bubble Veil', aff:'tidal', power:0, acc:100, cost:12, effect:'self_def_up', desc:'Wraps the user in buoyant armor.' },
  { id:'tidal_slam', name:'Tidal Slam', aff:'tidal', power:75, acc:90, cost:18, desc:'A crushing wave slammed down.' },
  { id:'brine_spray', name:'Brine Spray', aff:'tidal', power:35, acc:95, cost:14, targetAll:true, desc:'Stinging saltwater hits all foes.' },
  { id:'current', name:'Current', aff:'tidal', power:50, acc:100, cost:14, priority:1, desc:'A swift surge that strikes first.' },
  { id:'deluge', name:'Deluge', aff:'tidal', power:90, acc:85, cost:26, desc:'Drowns the enemy in a flood.' },
  // Verdant
  { id:'thorn_whip', name:'Thorn Whip', aff:'verdant', power:45, acc:100, cost:10, effect:'drain', chance:1, desc:'Thorned vines drain a little life.' },
  { id:'pollen_cloud', name:'Pollen Cloud', aff:'verdant', power:30, acc:95, cost:14, targetAll:true, effect:'accuracy_down', chance:0.3, desc:'Obscuring pollen clouds aim.' },
  { id:'canopy', name:'Canopy', aff:'verdant', power:0, acc:100, cost:16, effect:'self_barrier', desc:'Grows a living shield that blocks one hit.' },
  { id:'vine_lash', name:'Vine Lash', aff:'verdant', power:60, acc:100, cost:14, desc:'Slaps the foe with heavy vines.' },
  { id:'spore_burst', name:'Spore Burst', aff:'verdant', power:55, acc:90, cost:16, effect:'regen', chance:0.2, targetSelf:true, desc:'Scatters healing spores.' },
  { id:'overgrowth', name:'Overgrowth', aff:'verdant', power:85, acc:85, cost:26, desc:'A sudden explosion of entangling growth.' },
  // Umbral
  { id:'shadow_claw', name:'Shadow Claw', aff:'umbral', power:50, acc:100, cost:12, desc:'Rips through shadows and flesh.' },
  { id:'night_veil', name:'Night Veil', aff:'umbral', power:0, acc:100, cost:14, effect:'self_spd_up', desc:'Melds with darkness to quicken.' },
  { id:'void_pulse', name:'Void Pulse', aff:'umbral', power:70, acc:95, cost:18, effect:'shock', chance:0.15, desc:'A pulse of oblivion that may stun.' },
  { id:'terror', name:'Terror', aff:'umbral', power:30, acc:90, cost:16, targetAll:true, effect:'atk_down', chance:0.25, desc:'Saps the will of all enemies.' },
  { id:'umbral_feast', name:'Umbral Feast', aff:'umbral', power:75, acc:90, cost:22, effect:'drain', chance:0.5, desc:'Devours light and steals vitality.' },
  { id:'eclipse', name:'Eclipse', aff:'umbral', power:100, acc:80, cost:34, desc:'A world-eating wave of dusk.' },
  // Lumin
  { id:'radiant_beam', name:'Radiant Beam', aff:'lumin', power:50, acc:100, cost:12, desc:'A searing ray of pure light.' },
  { id:'halo', name:'Halo', aff:'lumin', power:0, acc:100, cost:16, effect:'self_barrier', desc:'A ring of light deflects one blow.' },
  { id:'photon_rush', name:'Photon Rush', aff:'lumin', power:55, acc:100, cost:18, priority:1, desc:'Light-speed impact.' },
  { id:'purge', name:'Purge', aff:'lumin', power:65, acc:95, cost:20, desc:'Burns away impurities; ignores lowered stats.' },
  { id:'solar_flare', name:'Solar Flare', aff:'lumin', power:90, acc:85, cost:28, effect:'burn', chance:0.2, desc:'A blinding eruption from a tiny sun.' },
  { id:'illuminate', name:'Illuminate', aff:'lumin', power:0, acc:100, cost:18, effect:'self_regen', desc:'Basks in restorative radiance.' },
  // Aether
  { id:'astral_ray', name:'Astral Ray', aff:'aether', power:55, acc:100, cost:14, desc:'A focused ray of stellar matter.' },
  { id:'phase_shift', name:'Phase Shift', aff:'aether', power:0, acc:100, cost:18, effect:'self_spd_up', desc:'Shifts slightly out of phase.' },
  { id:'comet_shard', name:'Comet Shard', aff:'aether', power:80, acc:90, cost:22, desc:'Hurls a frozen shard of comet.' },
  { id:'gravity_well', name:'Gravity Well', aff:'aether', power:70, acc:85, cost:24, targetAll:true, desc:'Compresses foes under mass.' },
  { id:'nebula_heal', name:'Nebula Heal', aff:'aether', power:0, acc:100, cost:22, effect:'heal', heal:0.35, targetSelf:true, desc:'Draws life from distant nebulae.' },
  { id:'supernova', name:'Supernova', aff:'aether', power:110, acc:75, cost:38, desc:'A stellar detonation of raw origin.' },
  // Null
  { id:'null_pulse', name:'Null Pulse', aff:'null', power:45, acc:100, cost:10, desc:'A flat wave of un-affinity.' },
  { id:'oblivion_grasp', name:'Oblivion Grasp', aff:'null', power:70, acc:95, cost:20, effect:'shock', chance:0.2, desc:'A crushing absence of sensation.' },
  { id:'void_anchor', name:'Void Anchor', aff:'null', power:0, acc:100, cost:16, effect:'self_def_up', desc:'Roots into nothing to harden.' },
  { id:'erase', name:'Erase', aff:'null', power:60, acc:90, cost:18, desc:'Strips a small part of the target away.' },
  { id:'singular_strike', name:'Singular Strike', aff:'null', power:80, acc:85, cost:24, desc:'A compressed point of canceled meaning.' },
  { id:'entropy', name:'Entropy', aff:'null', power:100, acc:80, cost:32, effect:'frost', chance:0.2, desc:'Spreads the cold of fading matter.' },
  // Mixed / extras
  { id:'spark_trip', name:'Spark Trip', aff:'lumin', power:40, acc:100, cost:10, effect:'shock', chance:0.25, desc:'A jolt that may stun.' },
  { id:'soothing_wind', name:'Soothing Wind', aff:'verdant', power:0, acc:100, cost:16, effect:'heal', heal:0.25, targetSelf:true, desc:'Gentle currents mend wounds.' },
  { id:'frost_bite', name:'Frost Bite', aff:'tidal', power:50, acc:95, cost:14, effect:'frost', chance:0.2, desc:'Freezes the foe and slows movement.' },
  { id:'drain_life', name:'Drain Life', aff:'umbral', power:50, acc:100, cost:16, effect:'drain', chance:1, desc:'Steals vitality through shadows.' },
  { id:'fortify', name:'Fortify', aff:'cinder', power:0, acc:100, cost:14, effect:'self_def_up', desc:'Hardens the body like cooling stone.' },
  { id:'gale', name:'Gale', aff:'aether', power:55, acc:100, cost:14, effect:'speed_down', chance:0.25, desc:'Pushes the enemy back with force.' }
];

// ── function: abilityById ──
function abilityById(id) { return ABILITIES.find(a=>a.id===id) || ABILITIES[0]; }

// ── const: STATUS_INFO ──
const STATUS_INFO = {
  burn: { label:'Burn', color:'#ff7a4a', tick:0.08 },
  frost:{ label:'Frost', color:'#4ad0ff', spd:0.5 },
  shock:{ label:'Shock', color:'#fff07c', chance:0.35 },
  regen:{ label:'Regen', color:'#6bff84', tick:0.06 },
  barrier:{ label:'Barrier', color:'#7cfff5' },
  atk_up:{ label:'ATK↑', color:'#ff6b6b' },
  def_up:{ label:'DEF↑', color:'#7ce0ff' },
  spd_up:{ label:'SPD↑', color:'#fff07c' },
  atk_down:{ label:'ATK↓', color:'#b0b8c8' },
  accuracy_down:{ label:'ACC↓', color:'#b0b8c8' },
  speed_down:{ label:'SPD↓', color:'#b0b8c8' }
};

// ── const: SPECIES ──
const SPECIES = (function(){
  const s=[];
  function sp(id, name, aff, stats, lore, learnset, evo) {
    s.push({ id, name, affinities:Array.isArray(aff)?aff:[aff], baseStats:stats, lore, learnset, evolution:evo||null });
  }
  sp('spriglin',    'Spriglin',    'verdant', {hp:50,atk:55,def:45,spd:65}, 'A tiny wisp of unbound growth that leaves dew-spark footprints.', [
    {lv:1,id:'thorn_whip'},{lv:3,id:'pollen_cloud'},{lv:8,id:'vine_lash'},{lv:14,id:'canopy'},{lv:22,id:'overgrowth'}
  ], {to:'thornregent', level:18, catalyst:'catalyst_verdant'});
  sp('kindlekit',   'Kindlekit',   'cinder',  {hp:48,atk:60,def:40,spd:70}, 'A playful ember-cub whose whiskers smoke when curious.', [
    {lv:1,id:'flame_lash'},{lv:3,id:'kindle'},{lv:8,id:'pyre_claw'},{lv:15,id:'cinder_burst'},{lv:24,id:'inferno'}
  ], {to:'pyrebound', level:18, catalyst:'catalyst_cinder'});
  sp('aqubbin',     'Aqubbin',     'tidal',   {hp:55,atk:50,def:50,spd:55}, 'A bobbing spirit of tide pools that hums in three harmonies.', [
    {lv:1,id:'water_lash'},{lv:3,id:'bubble_veil'},{lv:8,id:'brine_spray'},{lv:15,id:'tidal_slam'},{lv:24,id:'deluge'}
  ], {to:'aquillon', level:18, catalyst:'catalyst_tidal'});
  sp('embermite',   'Embermite',   'cinder',  {hp:45,atk:65,def:35,spd:75}, 'A skittering insect forged in cooling magmatic glass.', [
    {lv:1,id:'flame_lash'},{lv:5,id:'fortify'},{lv:11,id:'cinder_burst'},{lv:20,id:'inferno'}
  ], null);
  sp('rippling',    'Rippling',    'tidal',   {hp:52,atk:48,def:48,spd:70}, 'A sinuous water-snake that is difficult to see head or tail.', [
    {lv:1,id:'water_lash'},{lv:4,id:'frost_bite'},{lv:10,id:'current'},{lv:18,id:'tidal_slam'},{lv:26,id:'deluge'}
  ], {to:'torrentusk', level:20, catalyst:'catalyst_tidal'});
  sp('mosscub',     'Mosscub',     'verdant', {hp:70,atk:55,def:55,spd:40}, 'A sleepy bear cub whose fur hosts a miniature garden.', [
    {lv:1,id:'thorn_whip'},{lv:5,id:'soothing_wind'},{lv:12,id:'vine_lash'},{lv:21,id:'overgrowth'}
  ], {to:'canopytitan', level:20, catalyst:'catalyst_verdant'});
  sp('shadelet',    'Shadelet',    'umbral',  {hp:45,atk:58,def:42,spd:70}, 'A fluttering patch of darkness with bright, watchful eyes.', [
    {lv:1,id:'shadow_claw'},{lv:4,id:'night_veil'},{lv:9,id:'void_pulse'},{lv:17,id:'terror'},{lv:25,id:'eclipse'}
  ], {to:'noctilisk', level:20, catalyst:'catalyst_umbral'});
  sp('gleamoth',    'Gleamoth',    'lumin',   {hp:50,atk:52,def:45,spd:70}, 'A lantern-moth whose wings leave trails of soft afterimages.', [
    {lv:1,id:'radiant_beam'},{lv:4,id:'halo'},{lv:9,id:'spark_trip'},{lv:16,id:'purge'},{lv:25,id:'solar_flare'}
  ], {to:'luxalis', level:20, catalyst:'catalyst_lumin'});
  sp('novaling',    'Novaling',    'aether',  {hp:48,atk:54,def:42,spd:75}, 'A newborn star-given form that flickers between here and elsewhere.', [
    {lv:1,id:'astral_ray'},{lv:4,id:'phase_shift'},{lv:10,id:'comet_shard'},{lv:19,id:'gravity_well'},{lv:26,id:'supernova'}
  ], {to:'quasarion', level:20, catalyst:'catalyst_aether'});
  sp('voidlet',     'Voidlet',     'null',    {hp:55,atk:52,def:60,spd:35}, 'A quiet hollow that drinks surrounding sound.', [
    {lv:1,id:'null_pulse'},{lv:5,id:'void_anchor'},{lv:11,id:'erase'},{lv:18,id:'oblivion_grasp'},{lv:26,id:'singular_strike'}
  ], {to:'abscission', level:22, catalyst:'catalyst_null'});
  sp('cinderwisp',  'Cinderwisp',  'cinder',  {hp:38,atk:62,def:35,spd:80}, 'A floating ember with the temperament of a housecat.', [
    {lv:1,id:'flame_lash'},{lv:6,id:'kindle'},{lv:13,id:'pyre_claw'}
  ], null);
  sp('bubblefin',   'Bubblefin',   'tidal',   {hp:65,atk:48,def:55,spd:45}, 'A round fish wrapped in a bubble of compressed brine.', [
    {lv:1,id:'water_lash'},{lv:6,id:'bubble_veil'},{lv:13,id:'brine_spray'}
  ], null);
  sp('petalfox',    'Petalfox',    'verdant', {hp:55,atk:58,def:45,spd:60}, 'A fox-kit whose tail blossoms with fragrant petals.', [
    {lv:1,id:'thorn_whip'},{lv:6,id:'pollen_cloud'},{lv:14,id:'vine_lash'}
  ], null);
  sp('gloomoth',    'Gloomoth',    'umbral',  {hp:52,atk:55,def:48,spd:60}, 'A dusky moth drawn to the last light of day.', [
    {lv:1,id:'shadow_claw'},{lv:6,id:'drain_life'},{lv:14,id:'night_veil'}
  ], null);
  sp('lanternid',   'Lanternid',   'lumin',   {hp:48,atk:56,def:40,spd:75}, 'A tiny jelly-like being that glows in rhythmic pulses.', [
    {lv:1,id:'radiant_beam'},{lv:6,id:'illuminate'},{lv:14,id:'photon_rush'}
  ], null);
  sp('starling',    'Starling',    'aether',  {hp:45,atk:52,def:42,spd:78}, 'A crystalline bird that can fold its wings through space.', [
    {lv:1,id:'astral_ray'},{lv:6,id:'gale'},{lv:14,id:'comet_shard'}
  ], null);
  sp('nullark',     'Nullark',     'null',    {hp:60,atk:50,def:65,spd:32}, 'A heavy thing of dull stone that absorbs color.', [
    {lv:1,id:'null_pulse'},{lv:7,id:'void_anchor'},{lv:15,id:'erase'}
  ], null);
  sp('fluxbug',     'Fluxbug',     ['aether','cinder'], {hp:45,atk:58,def:42,spd:72}, 'A metal-shelled beetle that arcs with unstable matter.', [
    {lv:1,id:'astral_ray'},{lv:5,id:'flame_lash'},{lv:12,id:'comet_shard'}
  ], null);
  // Transformed forms (9)
  sp('thornregent', 'Thornregent', ['verdant','cinder'], {hp:75,atk:88,def:72,spd:65}, 'A noble growth crowned in everlasting flame-flowers.', [
    {lv:1,id:'thorn_whip'},{lv:1,id:'flame_lash'},{lv:22,id:'overgrowth'},{lv:30,id:'cinder_burst'},{lv:40,id:'scorched_earth'}
  ], null);
  sp('pyrebound',   'Pyrebound',   'cinder',  {hp:72,atk:95,def:65,spd:80}, 'A bounding fire-fox with tails of solid heat.', [
    {lv:1,id:'flame_lash'},{lv:1,id:'pyre_claw'},{lv:24,id:'inferno'},{lv:34,id:'cinder_burst'},{lv:44,id:'scorched_earth'}
  ], null);
  sp('aquillon',    'Aquillon',    'tidal',   {hp:80,atk:82,def:78,spd:60}, 'A proud crested serpent that rides coastal storms.', [
    {lv:1,id:'water_lash'},{lv:1,id:'current'},{lv:24,id:'deluge'},{lv:35,id:'tidal_slam'},{lv:45,id:'brine_spray'}
  ], null);
  sp('torrentusk',  'Torrentusk',  ['tidal','verdant'], {hp:85,atk:80,def:82,spd:48}, 'A river-beast whose tusks weep with living algae.', [
    {lv:1,id:'water_lash'},{lv:1,id:'vine_lash'},{lv:26,id:'deluge'},{lv:36,id:'overgrowth'}
  ], null);
  sp('canopytitan', 'Canopytitan', 'verdant', {hp:95,atk:82,def:85,spd:42}, 'A gentle giant whose back is a moving forest.', [
    {lv:1,id:'vine_lash'},{lv:1,id:'soothing_wind'},{lv:22,id:'canopy'},{lv:34,id:'overgrowth'}
  ], null);
  sp('noctilisk',   'Noctilisk',   'umbral',  {hp:70,atk:90,def:62,spd:82}, 'A sleek predator that exists only in peripheral vision.', [
    {lv:1,id:'shadow_claw'},{lv:1,id:'void_pulse'},{lv:26,id:'eclipse'},{lv:38,id:'umbral_feast'}
  ], null);
  sp('luxalis',     'Luxalis',     'lumin',   {hp:68,atk:78,def:62,spd:92}, 'A radiant aerial monarch with wings like lighthouse glass.', [
    {lv:1,id:'radiant_beam'},{lv:1,id:'halo'},{lv:26,id:'solar_flare'},{lv:38,id:'photon_rush'}
  ], null);
  sp('quasarion',   'Quasarion',   'aether',  {hp:72,atk:86,def:60,spd:88}, 'A fully realized stellar entity barely tethered to matter.', [
    {lv:1,id:'astral_ray'},{lv:1,id:'phase_shift'},{lv:26,id:'supernova'},{lv:40,id:'gravity_well'},{lv:46,id:'nebula_heal'}
  ], null);
  sp('abscission',  'Abscission',  'null',    {hp:88,atk:78,def:92,spd:44}, 'A walking absence that separates cause from effect.', [
    {lv:1,id:'null_pulse'},{lv:1,id:'erase'},{lv:28,id:'singular_strike'},{lv:40,id:'entropy'}
  ], null);
  return s;
})();

// ── const: speciesById ──
const speciesById = (id) => SPECIES.find(s=>s.id===id) || SPECIES[0];

// ── const: STARTERS ──
const STARTERS = ['spriglin','kindlekit','aqubbin'];

// ── const: WILD_TABLES ──
const WILD_TABLES = {
  emberglade:  ['kindlekit','embermite','cinderwisp','spriglin','petalfox'],
  mistwood:    ['spriglin','mosscub','petalfox','bubblefin','gloomoth'],
  deepreaches: ['aqubbin','rippling','bubblefin','fluxbug','shadelet'],
  voidcoast:   ['shadelet','gloomoth','nullark','cinderwisp','lanternid'],
  shimmerpeak: ['gleamoth','lanternid','starling','petalfox','novaling'],
  aethercrux:  ['novaling','starling','nullark','fluxbug','luxalis']
};

// ── const: ITEMS ──
const ITEMS = [
  { id:'bind_thread',  name:'Loomthread',    type:'capture', tier:1, price:120, desc:'A basic thread for binding weakened Aetherlings.' },
  { id:'bind_orb',     name:'Woven Orb',     type:'capture', tier:2, price:360, desc:'A resonant orb that improves binding chance.' },
  { id:'bind_prism',   name:'Binding Prism', type:'capture', tier:3, price:900, desc:'A prismatic focus that binds even stubborn souls.' },
  { id:'drop_essence', name:'Essence Drop',  type:'heal',    power:60,  price:80,  desc:'Restores a moderate amount of HP.' },
  { id:'flask_essence',name:'Essence Flask', type:'heal',    power:999, price:250, desc:'Restores full HP.' },
  { id:'energy_pill',  name:'Charge Pill',   type:'energy',  power:50,  price:60,  desc:'Restores 50 energy.' },
  { id:'revive_fiber', name:'Revive Fiber',  type:'revive',  power:0.4, price:400, desc:'Revives a fallen ally with partial HP.' },
  { id:'catalyst_cinder',  name:'Cinder Catalyst',  type:'catalyst', price:300, desc:'Ignites growth in compatible Aetherlings.' },
  { id:'catalyst_tidal',   name:'Tidal Catalyst',   type:'catalyst', price:300, desc:'Ripens potential through ancient pressure.' },
  { id:'catalyst_verdant', name:'Verdant Catalyst', type:'catalyst', price:300, desc:'A seed-catalyst of verdant transformation.' },
  { id:'catalyst_umbral',  name:'Umbral Catalyst',  type:'catalyst', price:300, desc:'A shadowed stone that reshapes form.' },
  { id:'catalyst_lumin',   name:'Lumin Catalyst',   type:'catalyst', price:300, desc:'A sun-drop catalyst of radiant change.' },
  { id:'catalyst_aether',  name:'Aether Catalyst',  type:'catalyst', price:300, desc:'A shard of folded space that awakens stellar lineage.' },
  { id:'catalyst_null',    name:'Null Catalyst',    type:'catalyst', price:300, desc:'A void bead that unmakes a form into its true shape.' }
];

// ── const: itemById ──
const itemById = (id) => ITEMS.find(i=>i.id===id)||null;

// ── const: SHOP_STOCK ──
const SHOP_STOCK = ['bind_thread','bind_orb','drop_essence','flask_essence','energy_pill','revive_fiber','catalyst_cinder','catalyst_tidal','catalyst_verdant','catalyst_umbral','catalyst_lumin','catalyst_aether','catalyst_null'];

// ── const: Game ──
const Game = {
  mode:'title', slot:0,
  player:{zoneIndex:0,x:1,y:6,facing:'right'},
  flags:{tutorial:false,bosses:[false,false,false,false,false,false],anchors:0,story:0,unique:new Set(),postGame:false},
  inventory:{},
  party:[], box:[], zones:[],
  map:null, // {grid, W, H, tile, shrine, boss, gate, start}
  options:{mute:false},
  battle:null,
  particles:[],
  pending:[],
  tutorialStep:0,
  dialogueQueue:[], dialogueCallback:null,
  shake:0, flash:0
};

// ── function: initInventory ──
function initInventory() {
  const inv={};
  for(const it of ITEMS) inv[it.id]=0;
  inv['bind_thread']=5;
  return inv;
}

// ── function: resetGame ──
function resetGame(slot) {
  Game.mode='title'; Game.slot=slot;
  Game.player={zoneIndex:0,x:1,y:6,facing:'right'};
  Game.flags={tutorial:false,bosses:[false,false,false,false,false,false],anchors:0,story:0,unique:new Set(),postGame:false};
  Game.inventory=initInventory();
  Game.party=[]; Game.box=[]; Game.zones=ZONES.map(()=>({bossDefeated:false, shrine:false}));
  Game.battle=null; Game.particles=[]; Game.pending=[]; Game.tutorialStep=0;
  Game.dialogueQueue=[]; Game.dialogueCallback=null;
}

// ── function: ownedSpecies ──
function ownedSpecies() {
  const set=new Set([...Game.party,...Game.box].map(c=>c.speciesId));
  for(const id of Game.flags.unique) set.add(id);
  return set;
}

// ── function: uniqueCount ──
function uniqueCount() { return ownedSpecies().size; }

// ── function: calcStats ──
function calcStats(speciesId, level) {
  const s = speciesById(speciesId);
  const hp  = Math.floor(s.baseStats.hp  * (1.4 + level*0.13));
  const atk = Math.floor(s.baseStats.atk * (0.5 + level*0.08));
  const def = Math.floor(s.baseStats.def * (0.5 + level*0.08));
  const spd = Math.floor(s.baseStats.spd * (0.5 + level*0.07));
  return {hp, maxHp:hp, atk, def, spd};
}

// ── function: createCreature ──
function createCreature(speciesId, level, extras={}) {
  const s = speciesById(speciesId);
  const stats = calcStats(speciesId, level);
  const c = {
    uid: extras.uid || Math.random().toString(36).slice(2,10),
    speciesId, level, exp:0,
    nickname: extras.nickname || s.name,
    hp: stats.hp,
    maxHp: stats.maxHp,
    atk: stats.atk, def: stats.def, spd: stats.spd,
    energy: 100, maxEnergy:100,
    statuses:{}, abilities:[...s.learnset.filter(l=>l.lv<=level).map(l=>l.id)].slice(-4)
  };
  if(!c.abilities.length) c.abilities = s.learnset.slice(0,1).map(l=>l.id);
  return c;
}

// ── function: refreshStats ──
function refreshStats(c) {
  const stats = calcStats(c.speciesId, c.level);
  const ratio = c.hp / c.maxHp;
  c.maxHp = stats.maxHp; c.atk = stats.atk; c.def = stats.def; c.spd = stats.spd;
  c.hp = Math.max(1, Math.min(c.maxHp, Math.ceil(c.maxHp * ratio)));
}

// ── function: getKnownAbilities ──
function getKnownAbilities(c) {
  const s = speciesById(c.speciesId);
  return s.learnset.filter(l=>l.lv<=c.level).slice(-4).map(l=>l.id);
}

// ── function: learnAbilitiesAfterLevel ──
function learnAbilitiesAfterLevel(c) {
  const s = speciesById(c.speciesId);
  const known = s.learnset.filter(l=>l.lv<=c.level).map(l=>l.id);
  // Auto-equip newest up to 4
  c.abilities = known.slice(-4);
}

// ── function: xpToNext ──
function xpToNext(level) {
  return Math.floor(50 * Math.pow(level,1.45));
}

// ── function: xpGain ──
function xpGain(defeatedLevel, ownLevel, strength=1) {
  const rel = defeatedLevel - ownLevel + 6;
  return Math.max(5, Math.floor(30 * rel * strength / 6));
}

// ── function: gainExp ──
function gainExp(c, amount) {
  if(!c || c.level>=LEVEL_CAP) return 0;
  c.exp += amount; let gained=0;
  while(c.level<LEVEL_CAP && c.exp >= xpToNext(c.level)) {
    c.exp -= xpToNext(c.level); c.level++;
    refreshStats(c); learnAbilitiesAfterLevel(c);
    // fully heal on level up as reward
    c.hp=c.maxHp; c.energy=c.maxEnergy;
    gained++;
  }
  return gained;
}

// ── function: checkEvolution ──
function checkEvolution(c) {
  const s = speciesById(c.speciesId);
  if(!s.evolution) return null;
  const ev = s.evolution;
  if(c.level < ev.level) return null;
  if(ev.catalyst && (Game.inventory[ev.catalyst]||0)<=0) return null;
  return ev;
}

// ── function: evolveCreature ──
function evolveCreature(c) {
  const s = speciesById(c.speciesId);
  if(!s.evolution) return false;
  const ev = s.evolution;
  if(ev.catalyst) Game.inventory[ev.catalyst]--;
  const oldRatio = c.hp/c.maxHp;
  c.speciesId = ev.to;
  refreshStats(c); learnAbilitiesAfterLevel(c);
  c.hp = Math.max(1, Math.floor(c.maxHp * oldRatio));
  return ev.to;
}

// ── function: generateZoneMap ──
function generateZoneMap(zoneIndex) {
  const W=20, H=12, tile=48;
  const grid = Array.from({length:H},()=>Array(W).fill(1)); // default grass
  const start={x:1,y:Math.floor(H/2)};
  const gateY=start.y;
  const gate={x:W-1,y:gateY};
  const boss={x:W-3,y:gateY+1};
  const shrine={x:2,y:gateY};
  // carve a winding path from start to near gate
  let x=0, y=gateY;
  for(x=0;x<W-2;x++) {
    grid[y][x]=0;
    if(Math.random()<0.35 && y>1 && y<H-2) {
      // meander
      const dy = Math.random()<0.5 ? -1 : 1;
      if(grid[y+dy][x]!==0) { grid[y+dy][x]=0; }
      y = y+dy;
      grid[y][x]=0;
    }
  }
  // ensure start column and last part clear
  for(let yy=0;yy<H;yy++) for(let xx=0;xx<3;xx++) if(Math.abs(yy-gateY)<=2) grid[yy][xx]=0;
  for(let xx=0;xx<W;xx++) if(Math.abs(xx-(W-1))<=2) grid[gateY][xx]=0;
  // clear area around shrine
  for(let yy=gateY-1;yy<=gateY+1;yy++) for(let xx=1;xx<=3;xx++) grid[yy>0&&yy<H?yy:gateY][xx]=0;
  // entities
  grid[shrine.y][shrine.x]=2;
  grid[boss.y][boss.x]=3;
  grid[gate.y][gate.x]=4;
  return {grid,W,H,tile,start:{x:1,y:gateY},shrine,boss,gate};
}

// ── function: loadZone ──
function loadZone(zoneIndex) {
  Game.player.zoneIndex = zoneIndex;
  const m = generateZoneMap(zoneIndex);
  Game.map = m;
  // place player at start
  Game.player.x = m.start.x; Game.player.y=m.start.y; Game.player.facing='right';
}

// ── function: tileAt ──
function tileAt(x,y) {
  if(!Game.map) return 0;
  if(y<0||y>=Game.map.H||x<0||x>=Game.map.W) return 4; // block
  return Game.map.grid[y][x];
}

// ── function: canMoveTo ──
function canMoveTo(x,y) {
  const t = tileAt(x,y);
  if(t===4) return Game.flags.bosses[Game.player.zoneIndex]; // gate unlocked if boss defeated
  return true; // all else passable
}

// ── function: movePlayer ──
function movePlayer(dx,dy) {
  if(Game.mode!=='overworld') return;
  if(!document.getElementById('menu-modal').hidden || !document.getElementById('collection-overlay').hidden) return;
  const nx=Game.player.x+dx, ny=Game.player.y+dy;
  const dir = dx>0?'right':dx<0?'left':dy>0?'down':'up';
  Game.player.facing = dir;
  if(!canMoveTo(nx,ny)) return;
  Game.player.x = nx; Game.player.y = ny;
  AudioEngine.sfx('step');
  const t = tileAt(nx,ny);
  if(t===2) { arriveShrine(); return; }
  if(t===3) { approachBoss(); return; }
  if(t===4 && Game.flags.bosses[Game.player.zoneIndex]) { changeZone(); return; }
  const zone = ZONES[Game.player.zoneIndex];
  if(t===1 && Math.random() < zone.rate) {
    startEncounter();
  }
}

// ── function: changeZone ──
function changeZone() {
  const next = Game.player.zoneIndex + 1;
  if(next < ZONES.length) { loadZone(next); showToast(`Entered ${ZONES[next].name}`); }
  else if(Game.flags.postGame) { loadZone(0); showToast('Returned to Emberglade'); }
}

// ── function: arriveShrine ──
function arriveShrine() {
  openShrineMenu();
}

// ── function: approachBoss ──
function approachBoss() {
  const z = ZONES[Game.player.zoneIndex];
  showDialogue([`The zone anchor ${z.boss} stirs nearby. Challenge it?`], (choice)=>{
    if(choice===0) startBossBattle();
    else Game.mode='overworld';
  }, ['Fight','Leave']);
}

// ── function: startEncounter ──
function startEncounter() {
  const z = ZONES[Game.player.zoneIndex];
  const table = WILD_TABLES[z.id];
  if(!table || !table.length) return;
  const count = Math.random()<0.8?1:(Math.random()<0.5?2:3);
  const ids=[]; for(let i=0;i<count;i++) ids.push(table[Math.floor(Math.random()*table.length)]);
  let baseLv = Math.max(2, z.level - 4 + Math.floor(Math.random()*5));
  if(Game.flags.postGame) baseLv += 10;
  startBattle(ids, baseLv, false);
}

// ── function: startBossBattle ──
function startBossBattle() {
  const z = ZONES[Game.player.zoneIndex];
  startBattle([speciesIdForBoss(z.boss)], z.level+3, true);
}

// ── function: speciesIdForBoss ──
function speciesIdForBoss(bossName) {
  const map={'Pyrelith':'embermite','Thornregent':'mosscub','Abyssalene':'rippling','Nocturnex':'shadelet','Solaramis':'gleamoth','Astrion Prime':'novaling'};
  const id = map[bossName];
  if(Math.random()<0.5) return id;
  // sometimes use alternate form
  const s=speciesById(id); if(s.evolution) return s.evolution.to;
  return id;
}

// ── function: startBattle ──
function startBattle(enemySpeciesIds, baseLevel, isBoss) {
  if(Game.party.filter(c=>c.hp>0).length===0) { showToast('Your party has fallen. Heal at a shrine.'); return; }
  Game.mode='battle';
  // ensure enemy levels relevant
  const avg = Math.max(1, averagePartyLevel());
  const level = Math.max(baseLevel, Math.round(avg + (isBoss?2:-1) + Math.floor(Math.random()*3)));
  const enemies = enemySpeciesIds.map((sid,i)=> {
    const e = createCreature(sid, Math.min(LEVEL_CAP, level+Math.floor(Math.random()*2)));
    e.isWild = !isBoss; e.isBoss = isBoss;
    return e;
  });
  Game.battle = {
    enemies,
    queue:[], index:0,
    message:[],
    state:'input',
    selectedMove:null,
    selectedActorIndex:0,
    turnCount:0,
    escaped:false,
    log:[]
  };
  addBattleLog(`A ${isBoss?'zone anchor':'wild encounter'} begins!`);
  openBattleScreen();
  nextTurn();
}

// ── function: averagePartyLevel ──
function averagePartyLevel() {
  if(!Game.party.length) return 1;
  return Game.party.reduce((a,c)=>a+c.level,0)/Game.party.length;
}

// ── function: effectiveSpeed ──
function effectiveSpeed(c) {
  let s = c.spd * stageMultiplier(c.statuses.spd_up||0, c.statuses.speed_down||0);
  if(c.statuses.frost) s*=0.5;
  return s;
}

// ── function: stageMultiplier ──
function stageMultiplier(up=0,down=0) { return 1 + up*0.3 - down*0.25; }

// ── function: effectiveAtk ──
function effectiveAtk(c) { return Math.max(1, Math.floor(c.atk * stageMultiplier(c.statuses.atk_up||0, c.statuses.atk_down||0) * (c.statuses.burn?0.5:1))); }

// ── function: effectiveDef ──
function effectiveDef(c) { return Math.max(1, Math.floor(c.def * stageMultiplier(c.statuses.def_up||0,0))); }

// ── function: buildQueue ──
function buildQueue() {
  const q=[];
  Game.party.forEach((c,i)=>{ if(c.hp>0) q.push({side:'ally',index:i,speed:effectiveSpeed(c)}); });
  Game.battle.enemies.forEach((c,i)=>{ if(c.hp>0) q.push({side:'enemy',index:i,speed:effectiveSpeed(c)}); });
  q.sort((a,b)=>b.speed-a.speed);
  return q;
}

// ── function: currentActor ──
function currentActor() {
  const b = Game.battle; if(b.index>=b.queue.length) return null;
  return b.queue[b.index];
}

// ── function: nextTurn ──
function nextTurn() {
  const b=Game.battle; if(!b) return;
  if(b.queue.length===0 || b.index>=b.queue.length) {
    // check end states
    if(b.enemies.every(e=>e.hp<=0)) { return battleWin(); }
    if(Game.party.every(c=>c.hp<=0)) { return battleLoss(); }
    // apply per-turn status effects
    applyTurnTicks();
    b.turnCount++;
    b.queue=buildQueue();
    b.index=0;
  }
  // skip fainted or escaped
  while(b.index<b.queue.length) {
    const actor = currentActor();
    const cr = getCreature(actor.side, actor.index);
    if(!cr || cr.hp<=0) { b.index++; continue; }
    if(cr.statuses.shock && Math.random()<STATUS_INFO.shock.chance) {
      addBattleLog(`${cr.nickname} is shocked and cannot move!`);
      b.index++; continue;
    }
    break;
  }
  if(b.index>=b.queue.length) { nextTurn(); return; }
  const actor=currentActor();
  if(actor.side==='ally') {
    b.state='input';
    b.selectedActorIndex=actor.index;
    renderAbilitiesMenu();
  } else {
    b.state='busy';
    setTimeout(enemyAction, 650);
  }
}

// ── function: getCreature ──
function getCreature(side, idx) {
  return side==='ally'?Game.party[idx]:Game.battle.enemies[idx];
}

// ── function: applyTurnTicks ──
function applyTurnTicks() {
  for(const c of [...Game.party,...Game.battle.enemies]) {
    if(c.hp<=0) continue;
    if(c.statuses.burn) {
      const dmg = Math.max(1, Math.floor(c.maxHp*STATUS_INFO.burn.tick));
      c.hp = Math.max(1, c.hp-dmg);
      addBattleLog(`${c.nickname} is hurt by burn.`);
    }
    if(c.statuses.regen) {
      const heal = Math.max(1, Math.floor(c.maxHp*STATUS_INFO.regen.tick));
      c.hp = Math.min(c.maxHp, c.hp+heal);
      addBattleLog(`${c.nickname} regenerates.`);
    }
  }
}

// ── function: addBattleLog ──
function addBattleLog(text) {
  const b=Game.battle; if(!b) return;
  b.log.unshift(text); if(b.log.length>24) b.log.pop();
  const el=document.getElementById('battle-log'); if(el) { el.innerHTML=b.log.slice(0,3).map(t=>`<p>${t}</p>`).join(''); }
}

// ── function: useAbility ──
function useAbility(actorIdx, abilityId, targetSide, targetIdx) {
  const actor = Game.party[actorIdx];
  const ab = abilityById(abilityId);
  if(!actor || actor.hp<=0 || actor.energy<ab.cost) { return; }
  actor.energy -= ab.cost;
  Game.battle.state='busy';
  if(Math.random()*100 > ab.acc) {
    addBattleLog(`${actor.nickname} used ${ab.name} but missed!`);
    advanceQueue(); return;
  }
  const targets = [];
  if(ab.targetAll) {
    Game.battle.enemies.forEach((e,i)=>{ if(e.hp>0) targets.push({side:'enemy',index:i,cr:e}); });
  } else {
    const t = getCreature(targetSide, targetIdx);
    if(t && t.hp>0) targets.push({side:targetSide,index:targetIdx,cr:t});
  }
  if(targets.length===0) { addBattleLog('No target!'); advanceQueue(); return; }
  for(const t of targets) resolveHit(actor, ab, t.cr, t.side, t.index);
  // Support effects apply to first target (which is actor for self-help moves, ally for heals)
  if(ab.effect && ['self_atk_up','self_def_up','self_spd_up','self_barrier','self_regen','heal','regen','barrier','atk_up','def_up','spd_up','atk_down','accuracy_down','speed_down'].includes(ab.effect)) {
    applyEffect(actor, ab, targets[0].cr);
  }
  advanceQueue();
}

// ── function: resolveHit ──
function resolveHit(actor, ab, target, side, idx) {
  let typeMul = 1;
  const sp = speciesById(target.speciesId);
  for(const a of sp.affinities) typeMul *= TYPE_MATRIX[ab.aff][a];
  const stab = speciesById(actor.speciesId).affinities.includes(ab.aff)?1.25:1;
  const crit = Math.random()<0.06 ? 1.5 : 1;
  const base = Math.floor(0.4 * actor.level * effectiveAtk(actor) / effectiveDef(target) * typeMul * stab * crit);
  const dmg = Math.max(1, base + 1 + Math.floor(Math.random()*3));
  // barrier deflect one non-support hit and then remove
  if(target.statuses.barrier && ab.power>0) {
    target.statuses.barrier=false;
    addBattleLog(`${target.nickname}'s barrier deflected the blow!`);
    return;
  }
  target.hp = Math.max(0, target.hp-dmg);
  // drain
  if(ab.effect==='drain' && ab.power>0 && Math.random()<(ab.chance||1)) {
    const heal = Math.max(1, Math.floor(dmg*0.5));
    actor.hp = Math.min(actor.maxHp, actor.hp+heal);
    addBattleLog(`${actor.nickname} drained ${heal} HP.`);
  }
  // effect chance
  if(ab.effect && !['self_atk_up','self_def_up','self_spd_up','self_barrier','self_regen','heal','drain'].includes(ab.effect)) {
    if(Math.random()<(ab.chance||1)) applyEffect(actor, ab, target);
  }
  const effText = typeMul>1?'super effective':typeMul<1?'resisted':'';
  const critText = crit>1?'critical ':'';
  addBattleLog(`${actor.nickname} used ${ab.name} for <span class="${crit>1?'crit':typeMul>1?'super':''}">${critText}${dmg}</span> ${effText}`);
  if(typeMul>1) Game.shake=10;
  if(crit>1) Game.flash=6;
  if(target.hp<=0) {
    addBattleLog(`${target.nickname} fell!`);
  }
}

// ── function: applyEffect ──
function applyEffect(actor, ab, target) {
  const fx=ab.effect;
  if(fx==='burn') target.statuses.burn=true;
  if(fx==='frost') target.statuses.frost=true;
  if(fx==='shock') target.statuses.shock=true;
  if(fx==='regen' || fx==='self_regen') target.statuses.regen=true;
  if(fx==='barrier' || fx==='self_barrier') target.statuses.barrier=true;
  if(fx==='atk_up' || fx==='self_atk_up') target.statuses.atk_up=(target.statuses.atk_up||0)+1;
  if(fx==='def_up' || fx==='self_def_up') target.statuses.def_up=(target.statuses.def_up||0)+1;
  if(fx==='spd_up' || fx==='self_spd_up') target.statuses.spd_up=(target.statuses.spd_up||0)+1;
  if(fx==='atk_down') target.statuses.atk_down=(target.statuses.atk_down||0)+1;
  if(fx==='accuracy_down') target.statuses.accuracy_down=(target.statuses.accuracy_down||0)+1;
  if(fx==='speed_down') target.statuses.speed_down=(target.statuses.speed_down||0)+1;
  if(fx==='heal' || fx==='self_regen') {
    const h = Math.floor(target.maxHp*(ab.heal||0.35));
    target.hp = Math.min(target.maxHp, target.hp+h);
    addBattleLog(`${target.nickname} recovered ${h} HP.`);
  }
}

// ── function: advanceQueue ──
function advanceQueue() {
  if(!Game.battle) return;
  Game.battle.index++;
  setTimeout(nextTurn, 600);
}

// ── function: enemyAction ──
function enemyAction() {
  const b=Game.battle;
  const actor = currentActor(); if(!actor || actor.side!=='enemy') { nextTurn(); return; }
  const enemy = b.enemies[actor.index];
  if(enemy.hp<=0) { b.index++; nextTurn(); return; }
  const abs = enemy.abilities.filter(id=>abilityById(id).cost<=enemy.energy);
  const abId = abs.length?abs[Math.floor(Math.random()*abs.length)]:enemy.abilities[0];
  const ab = abilityById(abId);
  if(enemy.energy<ab.cost) { addBattleLog(`${enemy.nickname} is too tired to act!`); advanceQueue(); return; }
  enemy.energy -= ab.cost;
  if(Math.random()*100 > ab.acc) { addBattleLog(`${enemy.nickname} used ${ab.name} but missed!`); advanceQueue(); return; }
  // pick target alive ally
  const alive = Game.party.map((c,i)=>({c,i})).filter(x=>x.c.hp>0);
  if(!alive.length) { advanceQueue(); return; }
  // basic AI: target randomly
  const target = alive[Math.floor(Math.random()*alive.length)];
  if(ab.targetAll) {
    Game.party.forEach((c,i)=>{ if(c.hp>0) resolveHit(enemy,ab,c,'ally',i); });
  } else resolveHit(enemy, ab, target.c, 'ally', target.i);
  if(['self_atk_up','self_def_up','self_spd_up','self_barrier','self_regen','heal'].includes(ab.effect)) applyEffect(enemy,ab,enemy);
  advanceQueue();
}

// ── function: switchCreature ──
function switchCreature(fromIdx, toIdx) {
  const b=Game.battle;
  if(toIdx<0 || toIdx>=Game.party.length || Game.party[toIdx].hp<=0) return false;
  b.selectedActorIndex = toIdx;
  addBattleLog(`${Game.party[toIdx].nickname} enters the weave.`);
  advanceQueue();
  return true;
}

// ── function: attemptFlee ──
function attemptFlee() {
  const b=Game.battle;
  const allySpd = Game.party.reduce((a,c)=>a+(c.hp>0?effectiveSpeed(c):0),0);
  const enemySpd = b.enemies.reduce((a,c)=>a+effectiveSpeed(c),0);
  const chance = Math.min(0.9, 0.45 + (allySpd-enemySpd)*0.01);
  if(Math.random()<chance) {
    addBattleLog('You fled safely.');
    setTimeout(closeBattle, 800);
  } else {
    addBattleLog('Flee failed!');
    advanceQueue();
  }
}

// ── function: useBattleItem ──
function useBattleItem(itemId, targetIdx) {
  const item=itemById(itemId); if(!item) return;
  if((Game.inventory[itemId]||0)<=0) return;
  Game.inventory[itemId]--;
  const t=Game.party[targetIdx];
  if(item.type==='heal') { const h=item.power>=999?t.maxHp-t.hp:Math.min(item.power,t.maxHp-t.hp); t.hp+=h; addBattleLog(`${t.nickname} healed ${h} HP.`); }
  if(item.type==='energy') { const e=Math.min(item.power,t.maxEnergy-t.energy); t.energy+=e; addBattleLog(`${t.nickname} restored ${e} energy.`); }
  if(item.type==='revive' && t.hp<=0) { t.hp=Math.floor(t.maxHp*item.power); addBattleLog(`${t.nickname} revived!`); }
  advanceQueue();
}

// ── function: battleWin ──
function battleWin() {
  const b=Game.battle;
  const isBoss = b.enemies[0]?.isBoss;
  const highestEnemyLv = Math.max(...b.enemies.map(e=>e.level));
  addBattleLog('Victory!');
  let gained=false;
  for(const c of Game.party) {
    if(c.hp>0) {
      const x = xpGain(highestEnemyLv, c.level, 1);
      const lvls = gainExp(c, x);
      addBattleLog(`${c.nickname} gained ${x} resonance` + (lvls?` and grew ${lvls} level(s)!`:'.'));
      if(lvls) gained=true;
    }
  }
  const reward = 25 + highestEnemyLv*4 + (isBoss?200:0);
  Game.inventory.coin = (Game.inventory.coin||0)+reward;
  addBattleLog(`Found ${reward} lumin.`);
  if(isBoss) {
    const zidx = Game.player.zoneIndex;
    Game.flags.bosses[zidx]=true;
    Game.flags.anchors++;
    if(zidx<4) Game.flags.story = Math.min(4, Game.flags.anchors);
    if(Game.flags.anchors>=4 && !Game.flags.postGame) Game.flags.postGame=true;
    saveGame(Game.slot);
    if(Game.flags.anchors<=4) {
      showStoryCutIn(Game.flags.anchors, closeBattle);
    } else {
      showDialogue([`The anchor ${ZONES[zidx].boss} is sealed.`, 'The zone grows quiet.'], ()=>closeBattle());
    }
  } else {
    setTimeout(closeBattle, 900);
  }
}

// ── function: battleLoss ──
function battleLoss() {
  addBattleLog('Your party was overcome...');
  setTimeout(()=>{
    showDialogue(['The weave returns you to the last shrine. Your companions recover.'],()=>{
      // heal and return
      Game.party.forEach(c=>{c.hp=c.maxHp;c.energy=c.maxEnergy;});
      loadZone(Game.player.zoneIndex);
      closeBattle();
    });
  },1200);
}

// ── function: closeBattle ──
function closeBattle() {
  Game.battle=null;
  closeBattleScreen();
  Game.mode='overworld';
  saveGame(Game.slot);
}

// ── function: openCaptureChoice ──
function openCaptureChoice() {
  const b=Game.battle; if(!b) return;
  const actor = Game.party[b.selectedActorIndex];
  if(!actor || actor.hp<=0) return;
  b.state='select';
  const items = ITEMS.filter(i=>i.type==='capture' && (Game.inventory[i.id]||0)>0);
  const el=document.getElementById('battle-menu');
  el.innerHTML='';
  if(!items.length) { el.innerHTML='<p>No binding items!</p>'; }
  items.forEach(it=>{
    const btn=document.createElement('button'); btn.className='move-btn';
    btn.innerHTML=`<span class="move-name">${it.name}</span><span class="move-meta">Tier ${it.tier} • own ${Game.inventory[it.id]}</span>`;
    btn.onclick=()=>{ startCaptureMiniGame(actor, it); };
    el.appendChild(btn);
  });
  const back=document.createElement('button'); back.className='glass-btn'; back.textContent='Back';
  back.onclick=()=>renderAbilitiesMenu();
  el.appendChild(back);
}

// ── function: startCaptureMiniGame ──
function startCaptureMiniGame(actor, item) {
  Game.battle.pendingItem = {actor, item};
  const ov=document.getElementById('collection-overlay'); ov.hidden=false;
  const result=document.getElementById('collect-result'); result.textContent='Press Bind when rings align.';
  const tryBtn=document.getElementById('collect-try'); tryBtn.textContent='Bind';
  tryBtn.onclick = submitCapture;
  document.getElementById('collect-cancel').onclick = ()=>{ ov.hidden=true; renderAbilitiesMenu(); };
  startMiniGameLoop();
}

// ── let: miniGameId ──
let miniGameId = null;

// ── const: miniGameState ──
const miniGameState = {angle:0, speed:0.02, targetAngle:0, targetWidth:0.25};

// ── function: startMiniGameLoop ──
function startMiniGameLoop() {
  if(miniGameId) cancelAnimationFrame(miniGameId);
  const cvs=document.getElementById('collect-canvas');
  const ctx=cvs.getContext('2d');
  miniGameState.angle=0; miniGameState.speed=0.03+Math.random()*0.02; miniGameState.targetAngle=Math.random()*Math.PI*2; miniGameState.targetWidth=0.35;
  function frame(){
    miniGameState.angle += miniGameState.speed;
    miniGameState.angle %= Math.PI*2;
    const cx=cvs.width/2, cy=cvs.height/2, r=90;
    ctx.clearRect(0,0,cvs.width,cvs.height);
    ctx.strokeStyle='rgba(124,224,255,0.45)'; ctx.lineWidth=12;
    ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2); ctx.stroke();
    ctx.fillStyle='rgba(124,224,255,0.22)';
    ctx.beginPath(); ctx.moveTo(cx,cy); ctx.arc(cx,cy,r,miniGameState.targetAngle-miniGameState.targetWidth/2,miniGameState.targetAngle+miniGameState.targetWidth/2); ctx.closePath(); ctx.fill();
    ctx.save(); ctx.translate(cx,cy); ctx.rotate(miniGameState.angle);
    ctx.fillStyle='#ffd36e'; ctx.beginPath(); ctx.arc(0,-r+6,8,0,Math.PI*2); ctx.fill();
    ctx.restore();
    miniGameId = requestAnimationFrame(frame);
  }
  frame();
}

// ── function: submitCapture ──
function submitCapture() {
  cancelAnimationFrame(miniGameId); miniGameId=null;
  const {actor, item} = Game.battle.pendingItem || {};
  if(!actor || !item) return;
  let diff = Math.abs(miniGameState.angle - miniGameState.targetAngle);
  diff = Math.min(diff, Math.PI*2-diff);
  let multiplier = Math.max(0.4, 1.6 - diff/(miniGameState.targetWidth/2));
  const tierMult = [0,1,1.35,1.9][item.tier]||1;
  const targets = Game.battle.enemies.map((e,i)=>({e,i})).filter(x=>x.e.hp>0).sort((a,b)=>a.e.hp/b.e.maxHp - b.e.hp/b.e.maxHp);
  const target = targets[0]; if(!target) return;
  const hpRatio = target.e.hp / target.e.maxHp;
  const baseCatch = 0.28;
  const chance = Math.min(0.95, baseCatch * (1.6 - hpRatio) * tierMult * multiplier);
  const success = Math.random() < chance;
  Game.inventory[item.id]--;
  const ov=document.getElementById('collection-overlay');
  const res=document.getElementById('collect-result');
  if(success) {
    res.textContent=`Bound ${speciesById(target.e.speciesId).name}! Resonance ${(chance*100).toFixed(0)}%.`;
    addCapturedCreature(target.e);
    setTimeout(()=>{ ov.hidden=true; closeBattle(); }, 1200);
  } else {
    res.textContent=`Binding failed. Resonance ${(chance*100).toFixed(0)}%.`;
    setTimeout(()=>{ ov.hidden=true; addBattleLog(`${target.e.nickname} broke free!`); advanceQueue(); }, 1000);
  }
}

// ── function: addCapturedCreature ──
function addCapturedCreature(enemy) {
  const captured = createCreature(enemy.speciesId, Math.max(1, enemy.level-1));
  captured.hp = Math.min(captured.maxHp, Math.max(1, Math.floor(enemy.hp/enemy.maxHp * captured.maxHp)));
  if(Game.party.length<6) Game.party.push(captured);
  else Game.box.push(captured);
  Game.flags.unique.add(enemy.speciesId);
  showToast(`Captured ${captured.nickname}`);
}

// ── function: tryCapturePrompt ──
function tryCapturePrompt() {}

// ── const: AudioEngine ──
const AudioEngine = (function(){
  let ctx=null, muted=false;
  let track=null, nextNote=0;
  function init() {
    if(ctx) return;
    try { ctx = new (window.AudioContext||window.webkitAudioContext)(); } catch(e){}
  }
  function resume() { init(); if(ctx && ctx.state==='suspended') ctx.resume(); }
  function setMute(v){ muted=v; if(muted) stopAmbient(); else playAmbient(); }
  function isMuted(){ return muted; }
  function playTone(freq, type, duration, vol=0.1, delay=0) {
    if(muted || !ctx) return;
    const t0 = ctx.currentTime + delay;
    const osc = ctx.createOscillator();
    const gain = ctx.createGain();
    osc.type=type; osc.frequency.value=freq;
    gain.gain.setValueAtTime(0,t0);
    gain.gain.linearRampToValueAtTime(vol,t0+0.04);
    gain.gain.exponentialRampToValueAtTime(0.001,t0+duration);
    osc.connect(gain).connect(ctx.destination);
    osc.start(t0); osc.stop(t0+duration);
  }
  function sfx(type) {
    if(muted || !ctx) return;
    switch(type){
      case 'confirm': playTone(520,'sine',0.12,0.05); setTimeout(()=>playTone(880,'sine',0.18,0.05),80); break;
      case 'cancel': playTone(320,'triangle',0.18,0.08); break;
      case 'step': playTone(120,'sine',0.05,0.03); break;
      case 'hit': playTone(180,'sawtooth',0.12,0.06); break;
      case 'crit': playTone(660,'square',0.18,0.05); break;
      case 'super': playTone(220,'sawtooth',0.25,0.08); break;
      case 'capture': playTone(440,'sine',0.2,0.05); setTimeout(()=>playTone(660,'sine',0.4,0.06),150); break;
      case 'win': arpeggio([440,554,659,880],0.12); break;
      case 'level': playTone(740,'triangle',0.25,0.05); break;
      case 'encounter': playTone(110,'sawtooth',0.4,0.08); break;
    }
  }
  function arpeggio(freqs,dur){ freqs.forEach((f,i)=>playTone(f,'sine',dur,0.04,i*dur)); }
  function playAmbient(){
    stopAmbient();
    if(muted || !ctx) return;
    const notes=[196,220,247,262,294,330,294,262];
    let i=0;
    function schedule(){
      if(muted||!track) return;
      const when=ctx.currentTime+0.05;
      const dur=1.8; const vol=0.02;
      const osc=ctx.createOscillator(); const gain=ctx.createGain();
      osc.type='sine'; osc.frequency.setValueAtTime(notes[i%notes.length],when);
      gain.gain.setValueAtTime(0,when); gain.gain.linearRampToValueAtTime(vol,when+0.8); gain.gain.linearRampToValueAtTime(0,when+dur);
      osc.connect(gain).connect(ctx.destination);
      osc.start(when); osc.stop(when+dur);
      i++;
      track = setTimeout(schedule, 900);
    }
    track = setTimeout(schedule, 100);
  }
  function stopAmbient(){ if(track) { clearTimeout(track); track=null; } }
  return {init,resume,setMute,isMuted,sfx,playAmbient,stopAmbient};
})();

// ── const: SAVE_KEY_PREFIX ──
const SAVE_KEY_PREFIX = 'loom_velmorath_save_';

// ── function: serializeState ──
function serializeState() {
  return JSON.stringify({
    player:Game.player,
    flags:{...Game.flags, unique:Array.from(Game.flags.unique)},
    inventory:Game.inventory,
    party:Game.party,
    box:Game.box,
    zones:Game.zones,
    options:Game.options
  });
}

// ── function: deserializeState ──
function deserializeState(json) {
  try {
    const data = JSON.parse(json);
    Game.player = data.player;
    Game.flags = data.flags; Game.flags.unique = new Set(Game.flags.unique||[]);
    Game.inventory = data.inventory; Game.party = data.party; Game.box = data.box;
    Game.zones = data.zones; Game.options = data.options||{mute:false};
    return true;
  } catch(e){ return false; }
}

// ── function: saveGame ──
function saveGame(slot) {
  try { localStorage.setItem(SAVE_KEY_PREFIX+slot, serializeState()); } catch(e){}
  updateTitleButtons();
}

// ── function: loadGame ──
function loadGame(slot) {
  const data = localStorage.getItem(SAVE_KEY_PREFIX+slot);
  if(!data) return false;
  resetGame(slot); // clear slot-specific? just so
  if(!deserializeState(data)) return false;
  Game.slot = slot;
  updateTitleButtons();
  return true;
}

// ── function: hasSave ──
function hasSave(slot) { return !!localStorage.getItem(SAVE_KEY_PREFIX+slot); }

// ── function: updateTitleButtons ──
function updateTitleButtons() {
  const cont=document.getElementById('btn-continue');
  if(cont) cont.disabled = ![0,1,2].some(hasSave);
}

// ── function: clearSave ──
function clearSave(slot) { localStorage.removeItem(SAVE_KEY_PREFIX+slot); updateTitleButtons(); }

// ── const: Canvas ──
const Canvas = { ctx:null, dpr:1, W:960, H:540 };

// ── function: setupCanvas ──
function setupCanvas() {
  const cvs=document.getElementById('game-canvas');
  function resize(){
    const rect = cvs.getBoundingClientRect();
    Canvas.dpr = Math.min(2, window.devicePixelRatio||1);
    cvs.width = Math.floor(rect.width*Canvas.dpr);
    cvs.height = Math.floor(rect.height*Canvas.dpr);
    Canvas.ctx = cvs.getContext('2d');
    Canvas.ctx.setTransform(Canvas.dpr,0,0,Canvas.dpr,0,0);
  }
  window.addEventListener('resize',resize); resize();
}

// ── function: particleColor ──
function particleColor(type) {
  const z=ZONES[Game.player.zoneIndex];
  if(type==='ember') return `hsl(${20+Math.random()*30},90%,${50+Math.random()*30}%)`;
  if(type==='spore') return `hsl(${90+Math.random()*40},70%,${60+Math.random()*25}%)`;
  if(type==='bubble') return `hsl(${190+Math.random()*30},80%,${60+Math.random()*25}%)`;
  if(type==='mote') return `hsl(${260+Math.random()*50},60%,${55+Math.random()*25}%)`;
  if(type==='gleam') return `hsl(${45+Math.random()*30},100%,${60+Math.random()*25}%)`;
  if(type==='star') return `hsl(${170+Math.random()*40},90%,${70+Math.random()*20}%)`;
  return '#ffffff';
}

// ── function: spawnParticle ──
function spawnParticle() {
  const z=ZONES[Game.player.zoneIndex];
  const ptype=z.particle;
  Game.particles.push({
    x:Math.random()*Canvas.W, y:Math.random()*Canvas.H,
    vx:(Math.random()-0.5)*0.6, vy:(Math.random()-0.5)*0.6 - (ptype==='bubble'?0.4:0),
    life:60+Math.random()*120, maxLife:180,
    size:1+Math.random()*3,
    color:particleColor(ptype),
    type:ptype
  });
}

// ── function: updateParticles ──
function updateParticles() {
  if(Game.particles.length<60) spawnParticle();
  for(let i=Game.particles.length-1;i>=0;i--){
    const p=Game.particles[i];
    p.x+=p.vx; p.y+=p.vy; p.life--;
    if(p.life<=0 || p.x<0 || p.x>Canvas.W || p.y<0 || p.y>Canvas.H) Game.particles.splice(i,1);
  }
}

// ── function: drawParticles ──
function drawParticles(ctx) {
  for(const p of Game.particles){
    ctx.globalAlpha = Math.max(0,p.life/p.maxLife);
    ctx.fillStyle=p.color;
    ctx.beginPath(); ctx.arc(p.x,p.y,p.size,0,Math.PI*2); ctx.fill();
  }
  ctx.globalAlpha=1;
}

// ── function: drawGradient ──
function drawGradient(ctx, colorA, colorB) {
  const grad=ctx.createLinearGradient(0,0,0,Canvas.H);
  grad.addColorStop(0,colorA); grad.addColorStop(1,colorB);
  ctx.fillStyle=grad; ctx.fillRect(0,0,Canvas.W,Canvas.H);
}

// ── function: drawHealthBar ──
function drawHealthBar(ctx,x,y,w,h,current,max,color='#6bff84'){
  const ratio=Math.max(0,Math.min(1,current/max));
  ctx.fillStyle='rgba(0,0,0,0.35)'; ctx.fillRect(x,y,w,h);
  ctx.fillStyle=(ratio<0.2?'#ff6b6b':ratio<0.5?'#ffd36e':color);
  ctx.fillRect(x+1,y+1,(w-2)*ratio,h-2);
}

// ── function: drawCreature ──
function drawCreature(ctx,x,y,size,speciesId,flip=false, hurt=false){
  const s=speciesById(speciesId);
  const colors = s.affinities.map(a=>AFFINITIES[a].color);
  const c1=colors[0], c2=colors[1]||colors[0];
  ctx.save();
  if(flip) ctx.scale(-1,1);
  ctx.translate(x,y);
  if(hurt){ ctx.globalAlpha=0.6; }
  // body
  const grd=ctx.createRadialGradient(0,-size*0.25,0,0,0,size);
  grd.addColorStop(0,c1); grd.addColorStop(1,c2);
  ctx.fillStyle=grd;
  ctx.beginPath(); ctx.ellipse(0,0,size*0.55,size*0.7,0,0,Math.PI*2); ctx.fill();
  // eyes
  ctx.fillStyle='#fff';
  ctx.beginPath(); ctx.arc(-size*0.18,-size*0.12,size*0.12,0,Math.PI*2);
  ctx.arc(size*0.18,-size*0.12,size*0.12,0,Math.PI*2); ctx.fill();
  ctx.fillStyle='#111';
  ctx.beginPath(); ctx.arc(-size*0.18,-size*0.12,size*0.05,0,Math.PI*2);
  ctx.arc(size*0.18,-size*0.12,size*0.05,0,Math.PI*2); ctx.fill();
  // glow
  ctx.shadowColor=c1; ctx.shadowBlur=15;
  ctx.strokeStyle=c1; ctx.lineWidth=3;
  ctx.beginPath(); ctx.arc(0,0,size*0.7,-0.2,Math.PI+0.2); ctx.stroke();
  ctx.restore();
  ctx.globalAlpha=1; ctx.shadowBlur=0;
}

// ── function: statusIconList ──
function statusIconList(statuses) {
  const out=[];
  for(const k in statuses){ if(statuses[k]) out.push(STATUS_INFO[k]?.label||k); }
  return out;
}

// ── function: renderOverworld ──
function renderOverworld() {
  const ctx=Canvas.ctx; if(!ctx) return;
  const z=ZONES[Game.player.zoneIndex];
  drawGradient(ctx, z.hueA, z.hueB);
  updateParticles(); drawParticles(ctx);
  const m=Game.map; if(!m) return;
  const tileW = Canvas.W / m.W;
  const tileH = Canvas.H / m.H;
  // tiles
  for(let y=0;y<m.H;y++){
    for(let x=0;x<m.W;x++){
      const t=m.grid[y][x];
      const px=x*tileW, py=y*tileH;
      if(t===1) { // grass
        ctx.fillStyle='rgba(255,255,255,0.06)';
        ctx.fillRect(px+2,py+2,tileW-4,tileH-4);
      } else if(t===2) { // shrine
        ctx.fillStyle='rgba(124,224,255,0.35)';
        ctx.shadowColor='#7ce0ff'; ctx.shadowBlur=20;
        ctx.beginPath(); ctx.arc(px+tileW/2,py+tileH/2,tileW*0.35,0,Math.PI*2); ctx.fill();
        ctx.shadowBlur=0;
      } else if(t===3) { // boss
        ctx.fillStyle='rgba(255,107,107,0.45)';
        ctx.shadowColor='#ff6b6b'; ctx.shadowBlur=24;
        ctx.beginPath(); ctx.arc(px+tileW/2,py+tileH/2,tileW*0.35,0,Math.PI*2); ctx.fill();
        ctx.shadowBlur=0;
      } else if(t===4) { // gate
        ctx.strokeStyle=Game.flags.bosses[Game.player.zoneIndex]?'rgba(124,224,255,0.8)':'rgba(255,255,255,0.25)';
        ctx.lineWidth=4;
        ctx.beginPath(); ctx.arc(px+tileW/2,py+tileH/2,tileW*0.4,Math.PI,0); ctx.stroke();
        ctx.fillStyle=ctx.strokeStyle; ctx.fillRect(px+tileW*0.15,py+tileH*0.45,tileW*0.7,4);
      }
    }
  }
  // player
  const px=Game.player.x*tileW + tileW/2;
  const py=Game.player.y*tileH + tileH/2;
  ctx.shadowColor='#fff'; ctx.shadowBlur=18;
  ctx.fillStyle='#ffffff';
  ctx.beginPath(); ctx.arc(px,py,tileW*0.2,0,Math.PI*2); ctx.fill();
  ctx.shadowBlur=0;
  // vignette if active
  drawVignette(ctx);
}

// ── function: drawVignette ──
function drawVignette(ctx) {
  const v=document.getElementById('vignette');
  if(!v||!v.classList.contains('active')) return;
  const color = v.style.boxShadow.includes('128,224,255')||v.style.color;
  // handled by CSS box-shadow, return empty
  ctx.fillStyle='rgba(124,224,255,0.04)';
  ctx.fillRect(0,0,Canvas.W,Canvas.H);
}

// ── function: renderBattle ──
function renderBattle() {
  const ctx=Canvas.ctx; if(!ctx) return;
  const z=ZONES[Game.player.zoneIndex]||ZONES[0];
  drawGradient(ctx, z.hueA, '#080b14');
  updateParticles(); drawParticles(ctx);
  const b=Game.battle; if(!b) return;
  // Draw party on left
  const alive = Game.party.map((c,i)=>({c,i})).filter(x=>x.c.hp>0);
  alive.forEach((x,idx)=>{
    const c=x.c; const cx=140+idx*22; const cy=340+idx*30;
    const active = b.state==='input' && b.selectedActorIndex===x.i;
    if(active) { ctx.strokeStyle='#fff'; ctx.lineWidth=3; ctx.strokeRect(cx-70,cy-120,160,240); }
    drawCreature(ctx,cx,cy,55,c.speciesId,false,c.hp<=0);
    drawHealthBar(ctx,cx-45,cy+55,90,8,c.hp,c.maxHp);
    ctx.fillStyle='#fff'; ctx.font='bold 12px sans-serif'; ctx.fillText(`Lv${c.level}`,cx-50,cy-60);
    ctx.fillText(c.nickname,cx-50,cy-72);
    const icons=statusIconList(c.statuses); if(icons.length) { ctx.fillStyle='rgba(255,255,255,0.7)'; ctx.font='10px sans-serif'; ctx.fillText(icons.join(' '),cx-50,cy+70); }
  });
  // Draw enemies on right
  b.enemies.forEach((e,i)=>{
    const cx=Canvas.W-140 - i*30; const cy=260+i*55;
    drawCreature(ctx,cx,cy,65,e.speciesId,true,e.hp<=0);
    drawHealthBar(ctx,cx-50,cy+70,100,10,e.hp,e.maxHp,'#ff6b6b');
    ctx.fillStyle='#fff'; ctx.font='bold 13px sans-serif'; ctx.fillText(`Lv${e.level} ${e.nickname}`,cx-60,cy-75);
  });
  // screen shake / flash
  if(Game.shake>0){ ctx.save(); const s=(Game.shake--); ctx.translate((Math.random()-0.5)*s,(Math.random()-0.5)*s); ctx.restore(); }
  if(Game.flash>0){ Game.flash--; ctx.fillStyle='rgba(255,255,255,0.12)'; ctx.fillRect(0,0,Canvas.W,Canvas.H); }
}

// ── function: showScreen ──
function showScreen(name) {
  document.querySelectorAll('.screen').forEach(s=>s.hidden=true);
  document.getElementById(name).hidden=false;
  document.getElementById('menu-modal').hidden=true;
  document.getElementById('collection-overlay').hidden=true;
  document.getElementById('tutorial-overlay').hidden=true;
  document.getElementById('dialogue-box').hidden=true;
  document.getElementById('battle-ui').hidden=true;
  document.getElementById('game-screen').classList.remove('battle-active');
}

// ── function: updateHUD ──
function updateHUD() {
  const z=ZONES[Game.player.zoneIndex]||ZONES[0];
  const hz=document.getElementById('hud-zone'); if(hz) hz.textContent=z.name;
  const hs=document.getElementById('hud-story'); if(hs) hs.textContent=`Thread ${Game.flags.story}/4`;
  const hc=document.getElementById('hud-coin'); if(hc) hc.textContent=`◆ ${Game.inventory.coin||0}`;
}

// ── function: openBattleScreen ──
function openBattleScreen() {
  document.getElementById('battle-ui').hidden=false;
  document.getElementById('top-hud').classList.add('flash');
  document.getElementById('touch-controls').hidden=true;
  document.getElementById('game-screen').classList.add('battle-active');
  updateHUD();
}

// ── function: closeBattleScreen ──
function closeBattleScreen() {
  document.getElementById('battle-ui').hidden=true;
  document.getElementById('touch-controls').hidden=false;
  document.getElementById('game-screen').classList.remove('battle-active');
}

// ── function: renderAbilitiesMenu ──
function renderAbilitiesMenu() {
  const b=Game.battle; if(!b) return;
  const actor = Game.party[b.selectedActorIndex];
  const el=document.getElementById('battle-menu');
  el.innerHTML='';
  const abs = getKnownAbilities(actor);
  abs.forEach(aid=>{
    const ab=abilityById(aid);
    const btn=document.createElement('button'); btn.className='move-btn aff-'+ab.aff;
    btn.innerHTML=`
      <span class="move-name">${ab.name}</span>
      <span class="move-meta">PWR ${ab.power||'-'} • ACC ${ab.acc} • EN ${ab.cost}</span>
      <span class="type-dot" style="background:${AFFINITIES[ab.aff].color}"></span>`;
    btn.onclick=()=>{
      if(actor.energy<ab.cost) { addBattleLog('Not enough energy!'); return; }
      if(ab.targetAll) { useAbility(b.selectedActorIndex, aid, 'enemy', 0); }
      else { openBattleTargetSelection(b.selectedActorIndex, aid); }
    };
    el.appendChild(btn);
  });
}

// ── function: openBattleTargetSelection ──
function openBattleTargetSelection(actorIdx, abilityId) {
  const b=Game.battle; b.state='target';
  const el=document.getElementById('battle-menu');
  el.innerHTML='<p style="color:var(--muted)">Choose target:</p>';
  const ab=abilityById(abilityId);
  const selfHealEffects = ['heal','regen','self_regen','self_barrier','self_def_up','self_spd_up','self_atk_up'];
  const targetSide = selfHealEffects.includes(ab.effect)?'ally':'enemy';
  const list = targetSide==='ally'?Game.party:b.enemies;
  list.forEach((c,i)=>{
    if(c.hp<=0 && targetSide==='ally' && ab.effect!=='revive') return;
    const btn=document.createElement('button'); btn.className='target-btn';
    btn.textContent=`${c.nickname} (HP ${c.hp}/${c.maxHp})`;
    btn.onclick=()=>useAbility(actorIdx, abilityId, targetSide, i);
    el.appendChild(btn);
  });
  const back=document.createElement('button'); back.className='glass-btn'; back.textContent='Back';
  back.onclick=renderAbilitiesMenu;
  el.appendChild(back);
}

// ── function: openBattleSwitch ──
function openBattleSwitch() {
  const b=Game.battle; b.state='select';
  const el=document.getElementById('battle-menu'); el.innerHTML='<p>Switch to:</p>';
  Game.party.forEach((c,i)=>{
    if(i===b.selectedActorIndex || c.hp<=0) return;
    const btn=document.createElement('button'); btn.className='target-btn';
    btn.textContent=`${c.nickname} Lv${c.level}`;
    btn.onclick=()=>switchCreature(b.selectedActorIndex,i);
    el.appendChild(btn);
  });
  const back=document.createElement('button'); back.className='glass-btn'; back.textContent='Back';
  back.onclick=renderAbilitiesMenu; el.appendChild(back);
}

// ── function: openBattleBag ──
function openBattleBag() {
  const b=Game.battle; b.state='select';
  const el=document.getElementById('battle-menu'); el.innerHTML='<p>Use item:</p>';
  ITEMS.filter(i=>!['capture','catalyst'].includes(i.type)).forEach(it=>{
    const count=Game.inventory[it.id]||0; if(count<=0) return;
    const btn=document.createElement('button'); btn.className='target-btn';
    btn.textContent=`${it.name} x${count}`;
    btn.onclick=()=>openBattleItemTarget(it.id);
    el.appendChild(btn);
  });
  const back=document.createElement('button'); back.className='glass-btn'; back.textContent='Back';
  back.onclick=renderAbilitiesMenu; el.appendChild(back);
}

// ── function: openBattleItemTarget ──
function openBattleItemTarget(itemId) {
  const item=itemById(itemId);
  const el=document.getElementById('battle-menu'); el.innerHTML='<p>On whom:</p>';
  Game.party.forEach((c,i)=>{
    if(c.hp<=0 && item.type!=='revive') return;
    const btn=document.createElement('button'); btn.className='target-btn';
    btn.textContent=`${c.nickname}`;
    btn.onclick=()=>useBattleItem(itemId,i);
    el.appendChild(btn);
  });
}

// ── function: openMenu ──
function openMenu() {
  if(Game.mode==='battle') return;
  AudioEngine.sfx('confirm');
  const m=document.getElementById('menu-modal'); m.hidden=false;
  renderMenuTab('party');
}

// ── function: closeMenu ──
function closeMenu() {
  document.getElementById('menu-modal').hidden=true;
}

// ── function: renderMenuTab ──
function renderMenuTab(tab) {
  document.querySelectorAll('.modal-tabs button').forEach(b=>b.classList.toggle('active', b.dataset.tab===tab));
  const cont=document.getElementById('menu-content'); cont.innerHTML='';
  if(tab==='party') renderPartyTab(cont);
  if(tab==='bag') renderBagTab(cont);
  if(tab==='guide') renderGuideTab(cont);
  if(tab==='map') renderMapTab(cont);
  if(tab==='options') renderOptionsTab(cont);
}

// ── function: renderPartyTab ──
function renderPartyTab(cont) {
  cont.innerHTML='<h3>Active Binding (party)</h3><div class="party-grid"></div><h3 style="margin-top:18px">Reserves</h3><div id="box-grid" class="party-grid"></div>';
  const grid1=cont.querySelector('.party-grid');
  Game.party.forEach((c,i)=>grid1.appendChild(creatureCard(c,i,'party')));
  const grid2=cont.querySelector('#box-grid');
  Game.box.forEach((c,i)=>grid2.appendChild(creatureCard(c,i,'box')));
}

// ── function: creatureCard ──
function creatureCard(c,idx,where) {
  const s=speciesById(c.speciesId);
  const div=document.createElement('div'); div.className='card';
  const aff=s.affinities.map(a=>`<span class="affinity-badge aff-${a}">${AFFINITIES[a].name}</span>`).join('');
  const evo=checkEvolution(c);
  div.innerHTML=`
    <h4>${c.nickname} <small>Lv${c.level}</small></h4>
    <p>${aff}</p>
    <p>HP ${c.hp}/${c.maxHp} • EN ${c.energy}/${c.maxEnergy}<br>ATK ${c.atk} • DEF ${c.def} • SPD ${c.spd}</p>
    <div class="stat-bar"><div style="width:${100*c.hp/c.maxHp}%; background:${c.hp/c.maxHp<0.2?'var(--danger)':'var(--heal)'}"></div></div>
    <p>Moves: ${getKnownAbilities(c).map(abilityById).map(a=>a.name).join(', ')}</p>
    ${evo?`<button class="glass-btn evolve-btn" data-where="${where}" data-idx="${idx}">Transform</button>`:''}
  `;
  if(evo) div.querySelector('.evolve-btn').onclick=()=>{ const nid=evolveCreature(c); showToast(`${c.nickname} transformed into ${speciesById(nid).name}!`); AudioEngine.sfx('level'); renderMenuTab('party'); };
  return div;
}

// ── function: renderBagTab ──
function renderBagTab(cont) {
  cont.innerHTML='<h3>Inventory</h3>';
  ITEMS.forEach(it=>{
    const count=Game.inventory[it.id]||0;
    const div=document.createElement('div'); div.className='card';
    div.innerHTML=`<h4>${it.name} x${count}</h4><p>${it.desc}</p><p style="color:var(--muted)">${it.price?'Sell value 0':'Field use'}</p>`;
    cont.appendChild(div);
  });
}

// ── function: renderGuideTab ──
function renderGuideTab(cont) {
  cont.innerHTML='<h3>Affinity Weave</h3>';
  const p=document.createElement('p'); p.innerHTML='Cinder > Tidal > Verdant > Cinder. Umbral > Cinder & Verdant. Lumin > Umbral & Tidal. Aether beats others slightly. Null resists all except Aether.';
  cont.appendChild(p);
  const grid=document.createElement('div'); grid.className='party-grid';
  SPECIES.forEach(s=>{
    if(!Game.flags.unique.has(s.id)) { grid.innerHTML+=`<div class="card"><p style="color:var(--muted)">???</p></div>`; return; }
    const div=document.createElement('div'); div.className='card';
    const aff=s.affinities.map(a=>`<span class="affinity-badge aff-${a}">${AFFINITIES[a].name}</span>`).join('');
    div.innerHTML=`<h4>${s.name}</h4>${aff}<p>${s.lore}</p>`;
    grid.appendChild(div);
  });
  cont.appendChild(grid);
}

// ── function: renderMapTab ──
function renderMapTab(cont) {
  cont.innerHTML='<h3>World Loom</h3>';
  ZONES.forEach((z,i)=>{
    const div=document.createElement('div'); div.className='card';
    div.innerHTML=`<h4>${i+1}. ${z.name} ${i===Game.player.zoneIndex?'★':''}</h4>
      <p>Anchor: ${Game.flags.bosses[i]?'Sealed':'Active'} • Loom level ~${z.level}</p>`;
    cont.appendChild(div);
  });
}

// ── function: renderOptionsTab ──
function renderOptionsTab(cont) {
  cont.innerHTML='<h3>Options</h3>';
  const muteBtn=document.createElement('button'); muteBtn.className='glass-btn';
  muteBtn.textContent=Game.options.mute?'Unmute':'Mute';
  muteBtn.onclick=()=>{ Game.options.mute=!Game.options.mute; AudioEngine.setMute(Game.options.mute); renderOptionsTab(cont); };
  cont.appendChild(muteBtn);
  const saveBtn=document.createElement('button'); saveBtn.className='glass-btn'; saveBtn.textContent='Save Now';
  saveBtn.onclick=()=>{ saveGame(Game.slot); showToast('Game saved.'); };
  cont.appendChild(saveBtn);
}

// ── function: openShrineMenu ──
function openShrineMenu() {
  AudioEngine.sfx('confirm');
  showDialogue(['You arrive at a Resonance Shrine. Rest here and restore your party?'],(choice)=>{
    if(choice===0) {
      Game.party.forEach(c=>{c.hp=c.maxHp; c.energy=c.maxEnergy;});
      Game.zones[Game.player.zoneIndex].shrine=true;
      saveGame(Game.slot);
      showToast('Party healed and resonance saved.');
    }
    if(choice===0 || choice===1) openShop();
  }, ['Rest & Save','Shop']);
}

// ── function: openShop ──
function openShop() {
  const m=document.getElementById('menu-modal'); m.hidden=false;
  renderShopTab();
}

// ── function: renderShopTab ──
function renderShopTab() {
  const cont=document.getElementById('menu-content'); cont.innerHTML='<h3>Shrine Exchange</h3>';
  SHOP_STOCK.forEach(id=>{
    const it=itemById(id); const btn=document.createElement('button'); btn.className='card';
    btn.style.textAlign='left'; btn.style.width='100%'; btn.style.cursor='pointer';
    btn.innerHTML=`<h4>${it.name} — ${it.price} lumin</h4><p style="font-size:.85rem">${it.desc}</p>`;
    btn.onclick=()=>{
      if((Game.inventory.coin||0)>=it.price){ Game.inventory.coin-=it.price; Game.inventory[id]=(Game.inventory[id]||0)+1; showToast(`Bought ${it.name}`); renderShopTab(); }
      else showToast('Not enough lumin.');
    };
    cont.appendChild(btn);
  });
  const back=document.createElement('button'); back.className='glass-btn'; back.textContent='Close'; back.onclick=closeMenu; cont.appendChild(back);
}

// ── function: showToast ──
function showToast(msg) {
  const area=document.getElementById('toast-area'); if(!area) return;
  const t=document.createElement('div'); t.className='toast'; t.textContent=msg;
  area.appendChild(t); setTimeout(()=>t.remove(),2600);
}

// ── function: showDialogue ──
function showDialogue(lines, callback, choices) {
  const box=document.getElementById('dialogue-box');
  const txt=document.getElementById('dialogue-text');
  box.hidden=false; Game.mode='dialogue';
  Game.dialogueQueue = lines.slice();
  Game.dialogueCallback = callback;
  let choiceBtns=[];
  function appendChoices() {
    if(choiceBtns.length) return;
    const has = Array.isArray(choices) && choices.length>0;
    if(!has) return;
    choiceBtns = choices.map((ch,i)=>{
      const btn=document.createElement('button'); btn.className='glass-btn'; btn.textContent='❖ '+ch;
      btn.onclick=()=>finish(i);
      txt.appendChild(document.createElement('br'));
      txt.appendChild(btn);
      return btn;
    });
  }
  function next() {
    if(Game.dialogueQueue.length) {
      txt.textContent = '▶ ' + Game.dialogueQueue.shift();
    }
    appendChoices();
  }
  function finish(value) {
    box.hidden=true; Game.mode='overworld';
    choiceBtns.forEach(b=>b.remove());
    if(typeof callback==='function') callback(value);
  }
  Game.advanceDialogue = next;
  Game.finishDialogue = finish;
  next();
  box.onclick = ()=>{
    if(choiceBtns.length) return;
    next();
    if(Game.dialogueQueue.length===0) finish(null);
  };
}

// ── function: startTutorial ──
function startTutorial() {
  Game.mode='tutorial';
  const over=document.getElementById('tutorial-overlay'); over.hidden=false;
  const steps = [
    {title:'Welcome to the Loom', body:'Move with WASD, arrows, or the on-screen pad. Step into grass to find Aetherlings.'},
    {title:'Shrines', body:'Shrines heal your party and save your thread. Rest whenever you see a blue glow.'},
    {title:'Battle', body:'In battle, pick an ability or use items. Lower an enemy\'s HP to improve binding chance.'},
    {title:'Bind', body:'Use the Bind button to throw a Loomthread. Time the pulse well for best results.'},
    {title:'Anchors', body:'Defeat zone anchors to open new zones. Collect eight unique forms to face the final weave.'}
  ];
  function showStep(n) {
    if(n>=steps.length) { over.hidden=true; Game.mode='overworld'; Game.flags.tutorial=true; saveGame(Game.slot); return; }
    Game.tutorialStep=n;
    document.getElementById('tut-title').textContent=steps[n].title;
    document.getElementById('tut-body').textContent=steps[n].body;
  }
  document.getElementById('tut-next').onclick=()=>showStep(Game.tutorialStep+1);
  showStep(0);
}

// ── function: gameLoop ──
function gameLoop() {
  if(Canvas.ctx) {
    if(Game.mode==='battle') renderBattle();
    else renderOverworld();
  }
  requestAnimationFrame(gameLoop);
}

// ── function: handleActionA ──
function handleActionA() {
  const dlg=document.getElementById('dialogue-box');
  if(!dlg.hidden && Game.advanceDialogue) { Game.advanceDialogue(); return; }
  const tut=document.getElementById('tutorial-overlay');
  if(!tut.hidden) { document.getElementById('tut-next').click(); return; }
}

// ── function: handleActionB ──
function handleActionB() {
  if(!document.getElementById('menu-modal').hidden){ closeMenu(); return; }
  if(Game.mode==='overworld') openMenu();
}

// ── function: setupInput ──
function setupInput() {
  document.addEventListener('keydown', e=>{
    if(Game.mode==='battle') return; // battle uses UI clicks mostly
    if(Game.mode==='dialogue') {
      if(e.code==='Space'||e.code==='Enter'||e.code==='KeyA') handleActionA();
      return;
    }
    if(Game.mode==='tutorial') { handleActionA(); return; }
    if(Game.mode!=='overworld') return;
    switch(e.code){
      case 'ArrowUp': case 'KeyW': movePlayer(0,-1); break;
      case 'ArrowDown': case 'KeyS': movePlayer(0,1); break;
      case 'ArrowLeft': case 'KeyA': movePlayer(-1,0); break;
      case 'ArrowRight': case 'KeyD': movePlayer(1,0); break;
      case 'Space': case 'Enter': handleActionA(); break;
      case 'Escape': case 'Backspace': handleActionB(); break;
    }
  });
  // Touch controls
  document.querySelectorAll('.dpad button').forEach(b=>{
    b.addEventListener('click',()=>{
      const d=b.dataset.dir;
      if(d==='up') movePlayer(0,-1);
      if(d==='down') movePlayer(0,1);
      if(d==='left') movePlayer(-1,0);
      if(d==='right') movePlayer(1,0);
    });
  });
  document.getElementById('btn-a').addEventListener('click',handleActionA);
  document.getElementById('btn-b').addEventListener('click',handleActionB);
  document.getElementById('hud-menu').addEventListener('click',openMenu);
  document.getElementById('hud-mute').addEventListener('click',toggleMute);
  document.getElementById('btn-mute-title').addEventListener('click',toggleMute);
  document.getElementById('menu-close').addEventListener('click',closeMenu);
  document.querySelectorAll('.modal-tabs button').forEach(b=>b.addEventListener('click',()=>renderMenuTab(b.dataset.tab)));
  // Battle UI buttons
  document.getElementById('battle-switch').addEventListener('click',openBattleSwitch);
  document.getElementById('battle-bag').addEventListener('click',openBattleBag);
  document.getElementById('battle-run').addEventListener('click',attemptFlee);
  document.getElementById('battle-capture').addEventListener('click',openCaptureChoice);
}

// ── function: toggleMute ──
function toggleMute() {
  Game.options.mute = !Game.options.mute;
  AudioEngine.setMute(Game.options.mute);
  const icons=document.querySelectorAll('.icon-btn#hud-mute,.mute-btn#btn-mute-title');
  document.getElementById('hud-mute').textContent = Game.options.mute?'🔇':'🔊';
  document.getElementById('btn-mute-title').textContent = Game.options.mute?'🔇':'🔊';
}

// ── function: startNewGame ──
function startNewGame(slot, starterId) {
  resetGame(slot);
  Game.party.push(createCreature(starterId,5));
  Game.party.push(createCreature(WILD_TABLES.emberglade[2],3));
  Game.inventory.coin=200;
  loadZone(0);
  saveGame(slot);
  showScreen('game-screen');
  Game.mode='overworld';
  AudioEngine.resume(); AudioEngine.playAmbient();
  updateHUD();
  if(!Game.flags.tutorial) startTutorial();
}

// ── function: continueGame ──
function continueGame(slot) {
  if(!loadGame(slot)) return;
  AudioEngine.resume();
  showScreen('game-screen');
  loadZone(Game.player.zoneIndex);
  Game.mode='overworld';
  AudioEngine.playAmbient();
  updateHUD();
}

// ── function: initTitle ──
function initTitle() {
  showScreen('title-screen'); updateTitleButtons();
  const newBtn=document.getElementById('btn-new');
  const contBtn=document.getElementById('btn-continue');
  const slots=document.getElementById('slot-select');
  newBtn.onclick=()=>{ AudioEngine.sfx('confirm'); slots.dataset.pick='new'; slots.hidden=false; };
  contBtn.onclick=()=>{ AudioEngine.sfx('confirm'); slots.dataset.pick='continue'; slots.hidden=false; };
  document.getElementById('slot-back').onclick=()=>{ slots.hidden=true; };
  document.querySelectorAll('.slot-btn').forEach(b=>{
    b.addEventListener('click',()=>{
      const slot=parseInt(b.dataset.slot);
      const mode = slots.dataset.pick || 'new';
      if(mode==='continue') continueGame(slot);
      else showStarterChoice(slot);
    });
  });
}

// ── function: showStarterChoice ──
function showStarterChoice(slot) {
  const card=document.querySelector('.title-card'); card.innerHTML='<h2>Choose Your First Bond</h2>';
  STARTERS.forEach(id=>{
    const s=speciesById(id);
    const btn=document.createElement('button'); btn.className='glass-btn';
    btn.innerHTML=`<b>${s.name}</b><br><small>${s.affinities.map(a=>AFFINITIES[a].name).join(' ')}</small>`;
    btn.onclick=()=>startNewGame(slot,id);
    card.appendChild(btn);
  });
  const back=document.createElement('button'); back.className='glass-btn small'; back.textContent='Back';
  back.onclick=()=>location.reload();
  card.appendChild(back);
}

// ── const: STORYLINES ──
const STORYLINES = {
  1: ['Thread one pulls free.','The Emberglade anchor unravels, and the path east grows warm again.'],
  2: ['Thread two loosens.','Mistwood, Deepreaches — both have taught you the weave of life and depth.'],
  3: ['Thread three trembles.','Three anchors sealed; the Loom senses your growing resonance.'],
  4: ['Thread four holds.','You may now walk the Shimmerpeak and Aethercrux. The final binding waits.']
};

// ── function: showStoryCutIn ──
function showStoryCutIn(stage, callback) {
  const lines = STORYLINES[stage] || ['The threads shift.'];
  const v=document.getElementById('vignette');
  v.style.boxShadow = `inset 0 0 160px 80px rgba(${stage===1?'255,122,74':stage===2?'107,255,132':stage===3?'74,208,255':'180,124,255'},0.35)`;
  v.classList.add('active');
  showDialogue(lines, ()=>{
    v.classList.remove('active');
    if(typeof callback==='function') callback();
  });
}

// ── main ──
setupCanvas();
setupInput();
updateTitleButtons();
initTitle();
AudioEngine.init();
gameLoop();
</script>
</body>
</html>
<!-- agent-meta {"model":"moonshotai/kimi-k2.7-code","provider":"openrouter","persona":"composer","sessionId":"afe4b391-3943-4bda-9329-f099a58898f0","tokensIn":8117584,"tokensOut":75504,"tokensTotal":8193088,"turns":124,"toolCalls":123,"failedToolCalls":13,"timestamp":"2026-07-14T04:23:46.856Z"} -->