← Creature Collection results

Creature Collection

hy3 · composer

Initial view of Creature Collection

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 54,540 bytes · SHA-256 3c75aee7f31b
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Verdain — Aether Bindings</title>
<style>
/* ── css: base ── */
:root{
  --bg0:#070912; --bg1:#0d1226; --panel:rgba(20,28,54,.66);
  --panel-edge:rgba(120,160,255,.28); --ink:#e8eeff; --ink-dim:#9fb0d8;
  --accent:#6ad0ff; --accent2:#ff8ad6; --good:#5ef0a8; --bad:#ff6b6b;
  --warn:#ffcf6b; --shadow:0 8px 30px rgba(0,0,0,.45);
  --radius:16px; --font:'Segoe UI',system-ui,-apple-system,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;width:100%;overflow:hidden;background:var(--bg0);color:var(--ink);font-family:var(--font)}
#game-root{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:radial-gradient(circle at 50% 30%,var(--bg1),var(--bg0) 70%)}
canvas#game{display:block;image-rendering:auto;background:transparent;touch-action:none}
#ui{position:fixed;inset:0;pointer-events:none;z-index:10}
#ui > *{pointer-events:auto}
#vignette{position:fixed;inset:0;pointer-events:none;z-index:30;opacity:0;transition:opacity .6s ease;mix-blend-mode:screen}
#toast{position:fixed;left:50%;bottom:6%;transform:translateX(-50%);z-index:40;
  display:flex;flex-direction:column;gap:6px;align-items:center;pointer-events:none}
.toast-line{background:var(--panel);border:1px solid var(--panel-edge);backdrop-filter:blur(10px);
  padding:8px 16px;border-radius:12px;font-size:14px;color:var(--ink);box-shadow:var(--shadow);
  animation:toastIn .3s ease, toastOut .4s ease 2.2s forwards}
@keyframes toastIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
@keyframes toastOut{to{opacity:0;transform:translateY(-8px)}}
/* ── css: ui ── */
.modal{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:20;padding:20px}
.glass{background:var(--panel);backdrop-filter:blur(14px);border:1px solid var(--panel-edge);
  border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;min-width:300px;max-width:560px;
  max-height:86vh;overflow:auto;color:var(--ink)}
.glass h2{font-size:22px;margin-bottom:10px;letter-spacing:.5px}
.glass h3{font-size:16px;margin:10px 0 6px;color:var(--accent)}
.dim{color:var(--ink-dim);font-size:13px;line-height:1.5;margin:4px 0}
.btn{background:rgba(106,208,255,.16);border:1px solid var(--panel-edge);color:var(--ink);
  padding:10px 14px;border-radius:10px;cursor:pointer;font-size:14px;transition:.15s;margin:4px}
.btn:hover{background:rgba(106,208,255,.32);transform:translateY(-1px)}
.btn.big{padding:14px 22px;font-size:16px;font-weight:600}
.btn.ghost{background:transparent;border-color:rgba(255,255,255,.2)}
.row{display:flex;flex-wrap:wrap;gap:6px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.card{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.12);border-radius:12px;padding:10px}
.barwrap{background:rgba(0,0,0,.4);border-radius:8px;height:12px;overflow:hidden;margin:3px 0}
.bar{height:100%;background:linear-gradient(90deg,var(--good),var(--accent));transition:width .3s}
.bar.low{background:linear-gradient(90deg,var(--bad),var(--warn))}
.battlegrid{position:fixed;left:0;bottom:0;width:100%;display:flex;gap:10px;padding:14px;align-items:flex-end;justify-content:space-between}
.hpbox{background:var(--panel);border:1px solid var(--panel-edge);border-radius:12px;padding:8px 12px;backdrop-filter:blur(8px);min-width:200px}
.logbox{background:var(--panel);border:1px solid var(--panel-edge);border-radius:12px;padding:8px;backdrop-filter:blur(8px);max-width:380px;max-height:120px;overflow:auto;font-size:13px}
.touch{position:fixed;right:16px;bottom:16px;z-index:15;display:grid;grid-template-columns:repeat(3,52px);grid-auto-rows:52px;gap:6px}
.touch .tbtn{background:var(--panel);border:1px solid var(--panel-edge);border-radius:10px;color:var(--ink);font-size:20px;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(8px)}
.title-screen{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;z-index:25;text-align:center}
.title-screen .glow{font-size:46px;font-weight:800;letter-spacing:2px;background:linear-gradient(90deg,#6ad0ff,#ff8ad6,#fff2c4);-webkit-background-clip:text;background-clip:text;color:transparent;animation:pulse 3s ease-in-out infinite}
@keyframes pulse{0%,100%{filter:brightness(1)}50%{filter:brightness(1.4)}}
.bindbar{position:relative;height:34px;background:rgba(0,0,0,.4);border-radius:8px;margin:14px 0;overflow:hidden}
.bindzone{position:absolute;left:42%;width:16%;top:0;bottom:0;background:rgba(94,240,168,.5)}
.bindmarker{position:absolute;top:0;bottom:0;width:6px;background:#fff;box-shadow:0 0 10px #fff}
.mapgrid{display:grid;gap:6px}
.spot{width:90px;height:70px;border-radius:10px;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:12px;border:1px solid rgba(255,255,255,.15)}
.tag{display:inline-block;font-size:11px;padding:2px 8px;border-radius:20px;margin:2px;color:#0a0c1a}
</style>
</head>
<body>
<div id="game-root">
  <canvas id="game"></canvas>
  <div id="ui"></div>
  <div id="vignette"></div>
  <div id="toast"></div>
</div>
<script>
'use strict';

// ── const: TYPES ──
const TYPES = ['Verd','Pyra','Aquor','Terra','Volt','Umbra','Lumen'];

// ── function: typeMultiplier ──
// Chart (per spec):
// A=Verd, B=Pyra, C=Aquor, D=Terra, E=Volt, F=Umbra, G=Lumen
// A>B>C>A  : Verd>Pyra, Pyra>Aquor, Aquor>Verd
// D>A&C    : Terra>Verd,Aquor
// E>D&B    : Volt>Terra,Pyra
// F beats all others slightly : Umbra strong vs Verd/Pyra/Aquor/Terra/Volt (=1.5), vs Lumen weak
// G resists everything but weak to F : Lumen resists all (0.5) but loses to Umbra (2)
function typeMultiplier(atk, def){
  const A='Verd',B='Pyra',C='Aquor',D='Terra',E='Volt',F='Umbra',G='Lumen';
  if(atk===F){
    return def===G ? 0.5 : 1.5;
  }
  if(atk===G){
    return 0.5; // resists everything except Umbra
  }
  if(atk===A) return def===B?2:def===C?0.5:1;
  if(atk===B) return def===C?2:def===A?0.5:1;
  if(atk===C) return def===A?2:def===B?0.5:1;
  if(atk===D) return (def===A||def===C)?2:1;
  if(atk===E) return (def===D||def===B)?2:1;
  return 1;
}

// ── const: STAT_GROWTH ──
const STAT_GROWTH = (base, level, isHP) => {
  // stat growth up to level 50
  const lvlScale = Math.pow(level/50, 1.25);
  const hpBonus = isHP ? 0.9 : 0.42;
  return Math.max(1, Math.floor(base * (0.55 + lvlScale * hpBonus * (isHP?2.2:1)) ));
};

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

// ── const: TEAM_MAX ──
const TEAM_MAX = 6;

// ── const: TOTAL_OBJECTIVE ──
const TOTAL_OBJECTIVE = 8;

// ── const: ANCHORS_OBJECTIVE ──
// unique creatures to collect
const ANCHORS_OBJECTIVE = 4;

// ── function: expForLevel ──
function expForLevel(level){ return Math.floor(8 * Math.pow(level,1.9)); }

// ── function: expReward ──
function expReward(enemyLevel, enemyMaxHP, ourLevel){
  const rel = enemyLevel / Math.max(1,ourLevel);
  return Math.floor(18 * enemyLevel * (0.5 + rel*0.7));
}

// ── const: TYPE_INFO ──
const TYPE_INFO = {
  Verd:{name:'Verd',color:'#5ef0a8',desc:'Living sapience of the overgrowth.'},
  Pyra:{name:'Pyra',color:'#ff7a59',desc:'Embered wrath of the cinder deeps.'},
  Aquor:{name:'Aquor',color:'#5ab8ff',desc:'Tide-memory of the drowned vales.'},
  Terra:{name:'Terra',color:'#c9a05a',desc:'Bedrock will, slow and unbroken.'},
  Volt:{name:'Volt',color:'#ffe65a',desc:'Splintered lightning of the storm-choir.'},
  Umbra:{name:'Umbra',color:'#b06bff',desc:'Hunger that folds the light away.'},
  Lumen:{name:'Lumen',color:'#fff2c4',desc:'Clearest word the aether speaks.'}
};

// ── function: mkAbil ──
function mkAbil(id,name,power,acc,aff,pp,effect,desc){
  return {id,name,power:power||0,acc:acc||1,aff,pp,eff:effect||null,desc:desc||''};
}

// ── const: ABIL ──
const ABIL = {
  verd_lash:mkAbil('verd_lash','Verdlash',42,0.95,'Verd',25,null,'A whip of living vine.'),
  sap_drain:mkAbil('sap_drain','Sapdrain',35,1,'Verd',20,{kind:'drain',value:0.5},'Draws vigour from the foe.'),
  bramble_wall:mkAbil('bramble_wall','Bramblewall',0,1,'Verd',15,{kind:'barrier'},'A thorned ward for one blow.'),
  bloom_heal:mkAbil('bloom_heal','Bloomtide',0,1,'Verd',12,{kind:'heal',value:0.4},'Mends wounds with pollen.'),
  spore_frost:mkAbil('spore_frost','Sporefrost',30,0.9,'Verd',18,{kind:'frost'},'Cold spores slow the foe.'),
  thorn_volley:mkAbil('thorn_volley','Thornvolley',18,0.9,'Verd',15,{kind:'multi',hits:3},'Three needle strikes.'),
  root_slam:mkAbil('root_slam','Rootslam',60,0.85,'Verd',10,null,'Crushing rootfall.'),
  regrow:mkAbil('regrow','Regrowth',0,1,'Verd',10,{kind:'regen'},'Slow mending over turns.'),
  quick_sprout:mkAbil('quick_sprout','Sproutstep',25,1,'Verd',20,{kind:'spd',value:1.3},'Boost own speed.'),
  verd_judge:mkAbil('verd_judge','Verdjuge',70,0.8,'Verd',8,null,'Verdict of the grove.'),
  ember_bite:mkAbil('ember_bite','Emberbite',40,0.95,'Pyra',25,null,'A bite of live coal.'),
  scald_ash:mkAbil('scald_ash','Scaldbrand',45,0.9,'Pyra',18,{kind:'burn'},'Sears flesh to ash.'),
  coal_burst:mkAbil('coal_burst','Coalburst',20,1,'Pyra',15,{kind:'multi',hits:2},'Two cinder pops.'),
  forge_armor:mkAbil('forge_armor','Forgeward',0,1,'Pyra',12,{kind:'barrier'},'Cinder shield for one blow.'),
  flare_heal:mkAbil('flare_heal','Flareembrace',0,1,'Pyra',12,{kind:'heal',value:0.35},'Warmth knits the body.'),
  pyre_lance:mkAbil('pyre_lance','Pyrelance',65,0.85,'Pyra',10,null,'A lance of white-hot flame.'),
  speed_ember:mkAbil('speed_ember','Emberdart',20,1,'Pyra',20,{kind:'spd',value:1.25},'Swift ember skitter.'),
  cinder_frost:mkAbil('cinder_frost','Cinderfrost',30,0.9,'Pyra',16,{kind:'frost'},'Odd cold from ash.'),
  burn_priority:mkAbil('burn_priority','Firstflame',35,0.95,'Pyra',12,{kind:'priority'},'Strikes before all.'),
  inferno:mkAbil('inferno','Infernocall',80,0.75,'Pyra',6,{kind:'burn'},'The great devouring.'),
  tide_slap:mkAbil('tide_slap','Tideslap',40,0.95,'Aquor',25,null,'Wet, blunt shock.'),
  drown_pull:mkAbil('drown_pull','Drownpull',30,1,'Aquor',18,{kind:'drain',value:0.6},'Drags vigour under.'),
  mist_ward:mkAbil('mist_ward','Mistward',0,1,'Aquor',15,{kind:'barrier'},'A veil of cold spray.'),
  rain_heal:mkAbil('rain_heal','Rainmend',0,1,'Aquor',12,{kind:'heal',value:0.4},'Falling water soothes.'),
  frost_jab:mkAbil('frost_jab','Frostjab',45,0.9,'Aquor',18,{kind:'frost'},'A numbing strike.'),
  torrent_volley:mkAbil('torrent_volley','Torrentvolley',16,0.9,'Aquor',15,{kind:'multi',hits:3},'Streaming needles.'),
  deep_crush:mkAbil('deep_crush','Deepcrush',62,0.85,'Aquor',10,null,'Pressure of the abyss.'),
  regen_tide:mkAbil('regen_tide','Tideroots',0,1,'Aquor',10,{kind:'regen'},'Tide that mends slowly.'),
  swift_current:mkAbil('swift_current','Swiftcurrent',25,1,'Aquor',20,{kind:'spd',value:1.3},'Quick water-slide.'),
  maelstrom:mkAbil('maelstrom','Maelstromcall',78,0.75,'Aquor',6,{kind:'frost'},'The drowning spiral.'),
  stone_fist:mkAbil('stone_fist','Stonefist',42,0.95,'Terra',25,null,'Rocky blunt force.'),
  quake_stomp:mkAbil('quake_stomp','Quakestamp',58,0.85,'Terra',12,null,'The ground leaps.'),
  bedrock_ward:mkAbil('bedrock_ward','Bedrockward',0,1,'Terra',15,{kind:'barrier'},'Stone shell for one blow.'),
  terra_heal:mkAbil('terra_heal','Stonebloom',0,1,'Terra',12,{kind:'heal',value:0.35},'Moss on the stone.'),
  dust_frost:mkAbil('dust_frost','Dustfrost',28,0.95,'Terra',18,{kind:'frost'},'Dry chill in the air.'),
  gravel_volley:mkAbil('gravel_volley','Gravelvolley',18,0.9,'Terra',15,{kind:'multi',hits:2},'Skittering stones.'),
  titan_guard:mkAbil('titan_guard','Titanposture',0,1,'Terra',10,{kind:'selfbuf',value:1.4},'Raises guard (DEF).'),
  mountain_fall:mkAbil('mountain_fall','Mountainfall',75,0.8,'Terra',6,null,'A falling plateau.'),
  terra_priority:mkAbil('terra_priority','Quickstone',32,0.95,'Terra',12,{kind:'priority'},'Pre-emptive pebble.'),
  spark_jab:mkAbil('spark_jab','Sparkjab',40,0.95,'Volt',25,null,'A small discharge.'),
  shock_web:mkAbil('shock_web','Shockweb',38,0.9,'Volt',18,{kind:'shock'},'May freeze a turn.'),
  thunder_lance:mkAbil('thunder_lance','Thunderlance',62,0.85,'Volt',10,null,'Aimed bolt.'),
  static_heal:mkAbil('static_heal','Staticmend',0,1,'Volt',12,{kind:'heal',value:0.35},'Tingle knits flesh.'),
  arc_volley:mkAbil('arc_volley','Arcvolley',16,0.9,'Volt',15,{kind:'multi',hits:3},'Arc needles.'),
  volt_selfbuff:mkAbil, //removed below
  speed_surge:mkAbil('speed_surge','Surge',25,1,'Volt',20,{kind:'spd',value:1.35},'Lightning haste.'),
  chain_burn:mkAbil('chain_burn','Chainbrand',34,0.9,'Volt',16,{kind:'burn'},'Burning spark.'),
  overload:mkAbil('overload','Overloadcall',80,0.75,'Volt',6,{kind:'shock'},'The breaking grid.'),
  umbra_claw:mkAbil('umbra_claw','Umbraclaw',45,0.9,'Umbra',20,null,'A slice of folded night.'),
  void_drain:mkAbil('void_drain','Voidfeed',30,1,'Umbra',18,{kind:'drain',value:0.7},'Feasts on life.'),
  shadow_frost:mkAbil('shadow_frost','Shadowfrost',32,0.9,'Umbra',16,{kind:'frost'},'Cold from nowhere.'),
  umbra_heal:mkAbil('umbra_heal','Umbraembrace',0,1,'Umbra',12,{kind:'heal',value:0.45},'Darkness soothes.'),
  gloom_burst:mkAbil('gloom_burst','Gloomburst',22,0.9,'Umbra',15,{kind:'multi',hits:2},'Twin shadow pops.'),
  void_priority:mkAbil('void_priority','Voidstep',34,0.95,'Umbra',12,{kind:'priority'},'Strikes from the fold.'),
  umbra_ward:mkAbil('umbra_ward','Voidward',0,1,'Umbra',15,{kind:'barrier'},'Night shell for one blow.'),
  doom_call:mkAbil('doom_call','Doomcall',82,0.7,'Umbra',5,null,'The closing dark.'),
  volt_ward:mkAbil('volt_ward','Voltward',0,1,'Volt',15,{kind:'barrier'},'Coil shield for one blow.'),
  lumen_ray:mkAbil('lumen_ray','Lumenray',45,0.9,'Lumen',20,null,'Clean piercing light.'),
  radiant_heal:mkAbil('radiant_heal','Radiantmend',0,1,'Lumen',14,{kind:'heal',value:0.5},'Brightest mending.'),
  lumen_ward:mkAbil('lumen_ward','Lumenward',0,1,'Lumen',15,{kind:'barrier'},'Glass shell for one blow.'),
  lumen_regen:mkAbil('lumen_regen','Lumenroots',0,1,'Lumen',10,{kind:'regen'},'Constant soft light.'),
  flash_volley:mkAbil('flash_volley','Flashvolley',18,0.9,'Lumen',15,{kind:'multi',hits:3},'Light needles.'),
  lumen_priority:mkAbil('lumen_priority','Firstlight',35,0.95,'Lumen',12,{kind:'priority'},'Beats the dark.'),
  judge_light:mkAbil('judge_light','Judgebeam',80,0.75,'Lumen',6,null,'Final bright word.'),
  lumen_burn:mkAbil('lumen_burn','Solarbrand',36,0.9,'Lumen',16,{kind:'burn'},'Burning clarity.')
};

// ── const: CREATURES ──
const CREATURES = {
  // ---- BASE (18) ----
  mosslet:{name:'Mosslet',aff:['Verd'],base:{hp:38,atk:14,def:13,spd:14},lore:'A seed-spirit that hums when rain is near.',learnset:{1:['verd_lash'],5:['sap_drain'],10:['bramble_wall'],16:['spore_frost'],24:['root_slam'],34:['verd_judge'],40:['regrow']},transform:{to:'mosswane',level:36}},
  sparkit:{name:'Sparkit',aff:['Volt'],base:{hp:34,atk:16,def:11,spd:18},lore:'Snaps with static when startled; loves high places.',learnset:{1:['spark_jab'],5:['speed_surge'],10:['shock_web'],16:['arc_volley'],24:['thunder_lance'],34:['overload'],40:['chain_burn']},transform:{to:'voltaic',level:36}},
  cindle:{name:'Cindle',aff:['Pyra'],base:{hp:36,atk:17,def:12,spd:15},lore:'Carries an ember heart that never cools.',learnset:{1:['ember_bite'],5:['speed_ember'],10:['forge_armor'],16:['scald_ash'],24:['pyre_lance'],34:['inferno'],40:['burn_priority']},transform:{to:'ignar',level:36}},
  puddl:{name:'Puddl',aff:['Aquor'],base:{hp:40,atk:14,def:14,spd:13},lore:'A droplet with a fondness for quiet pools.',learnset:{1:['tide_slap'],5:['swift_current'],10:['mist_ward'],16:['drown_pull'],24:['deep_crush'],34:['maelstrom'],40:['regen_tide']},transform:{to:'tidelan',level:36}},
  bolder:{name:'Bolder',aff:['Terra'],base:{hp:46,atk:18,def:20,spd:8},lore:'Earthen sentinel that counts every footstep.',learnset:{1:['stone_fist'],5:['titan_guard'],10:['bedrock_ward'],16:['dust_frost'],24:['quake_stomp'],34:['mountain_fall'],40:['terra_priority']},transform:{to:'monolith',level:38}},
  gloomling:{name:'Gloomling',aff:['Umbra'],base:{hp:36,atk:17,def:12,spd:16},lore:'Slips between shadow and shape at dusk.',learnset:{1:['umbra_claw'],5:['shadow_frost'],10:['umbra_ward'],16:['void_drain'],24:['gloom_burst'],34:['doom_call'],40:['void_priority']},transform:{to:'nightmaw',level:38}},
  lumsprout:{name:'Lumsprout',aff:['Lumen'],base:{hp:35,atk:15,def:14,spd:15},lore:'Holds the first light of an unlit morning.',learnset:{1:['lumen_ray'],5:['lumen_regen'],10:['lumen_ward'],16:['flash_volley'],24:['lumen_priority'],34:['judge_light'],40:['radiant_heal']},transform:{to:'aurora',level:38}},
  brine:{name:'Brineback',aff:['Aquor','Terra'],base:{hp:48,atk:16,4:0,def:18,spd:10},lore:'Shelled drifter bearing soil and sea together.',learnset:{1:['tide_slap'],5:['stone_fist'],10:['bedrock_ward'],16:['drown_pull'],24:['gravel_volley'],34:['deep_crush'],42:['mountain_fall']},transform:null},
  emberox:{name:'Emberox',aff:['Pyra','Terra'],base:{hp:50,atk:19,def:17,spd:11},lore:'A furnace-beast bedded in warm stone.',learnset:{1:['ember_bite'],5:['stone_fist'],10:['forge_armor'],16:['scald_ash'],24:['quake_stomp'],34:['pyre_lance'],42:['inferno']},transform:null},
  wisp:{name:'Galdwisp',aff:['Lumen','Volt'],base:{hp:33,atk:16,def:12,spd:19},lore:'Floating script of a forgotten alphabet.',learnset:{1:['lumen_ray'],5:['spark_jab'],10:['speed_surge'],16:['flash_volley'],24:['lumen_priority'],34:['thunder_lance'],42:['judge_light']},transform:null},
  ferntail:{name:'Ferntail',aff:['Verd','Aquor'],base:{hp:42,atk:15,def:15,spd:14},lore:'Trails of fern that drink the marsh.',learnset:{1:['verd_lash'],5:['tide_slap'],10:['mist_ward'],16:['sap_drain'],24:['spore_frost'],34:['torrent_volley'],42:['maelstrom']},transform:null},
  ravencoil:{name:'Ravencoil',aff:['Volt','Umbra'],base:{hp:38,atk:18,def:12,spd:17},lore:'A knot of stormwire and swallowed night.',learnset:{1:['spark_jab'],5:['umbra_claw'],10:['shock_web'],16:['void_drain'],24:['arc_volley'],34:['overload'],42:['doom_call']},transform:null},
  ashtern:{name:'Ashtern',aff:['Pyra','Umbra'],base:{hp:40,atk:18,def:13,spd:14},lore:'Cinders that remember every flame they fled.',learnset:{1:['ember_bite'],5:['umbra_claw'],10:['forge_armor'],16:['scald_ash'],24:['shadow_frost'],34:['void_drain'],42:['inferno']},transform:null},
  frostilla:{name:'Frostilla',aff:['Aquor','Volt'],base:{hp:39,atk:16,def:16,spd:16},lore:'Sheaths itself in thin cold lightning.',learnset:{1:['tide_slap'],5:['spark_jab'],10:['frost_jab'],16:['arc_volley'],24:['shock_web'],34:['deep_crush'],42:['overload']},transform:null},
  stonebloom:{name:'Stonebloom',aff:['Verd','Terra'],base:{hp:47,atk:15,def:19,spd:9},lore:'Flower that roots in living cliff-face.',learnset:{1:['verd_lash'],5:['stone_fist'],10:['titan_guard'],16:['regrow'],24:['quake_stomp'],34:['root_slam'],42:['mountain_fall']},transform:null},
  sunpup:{name:'Sunpup',aff:['Lumen','Pyra'],base:{hp:37,atk:16,def:13,spd:16},lore:'A bright cub that naps in the noon.',learnset:{1:['lumen_ray'],5:['ember_bite'],10:['lumen_ward'],16:['lumen_burn'],24:['flare_heal'],34:['pyre_lance'],42:['judge_light']},transform:null},
  gloomseed:{name:'Gloomseed',aff:['Umbra','Verd'],base:{hp:40,atk:16,def:15,spd:13},lore:'A dark pod that sprouts only in silence.',learnset:{1:['verd_lash'],5:['umbra_claw'],10:['umbra_ward'],16:['sap_drain'],24:['void_drain'],34:['shadow_frost'],42:['doom_call']},transform:null},
  dawnmoth:{name:'Dawnmoth',aff:['Lumen','Aquor'],base:{hp:36,atk:15,def:14,spd:18},lore:'Wings dusted with the first colour of day.',learnset:{1:['lumen_ray'],5:['tide_slap'],10:['rain_heal'],16:['flash_volley'],24:['swift_current'],34:['maelstrom'],42:['radiant_heal']},transform:null},
  // ---- TRANSFORMED (9) ----
  mosswane:{name:'Mosswane',aff:['Verd','Terra'],base:{hp:64,atk:24,def:24,spd:16},lore:'A grove-walker with a stone-green crown.',learnset:{},transform:null},
  voltaic:{name:'Voltaic',aff:['Volt','Lumen'],base:{hp:52,atk:26,def:18,spd:30},lore:'A coil of living white-blue current.',learnset:{},transform:null},
  ignar:{name:'Ignar',aff:['Pyra','Terra'],base:{hp:62,atk:30,def:22,spd:18},lore:'A walking forge with a molten spine.',learnset:{},transform:null},
  tidelan:{name:'Tidelan',aff:['Aquor','Verd'],base:{hp:66,atk:24,def:24,dmglost:0,spd:15},lore:'A tide-spirit that wears the shore as a cloak.',learnset:{},transform:null},
  monolith:{name:'Monolith',aff:['Terra','Umbra'],base:{hp:82,atk:30,def:36,spd:10},lore:'A standing mountain that walks when wronged.',learnset:{},transform:null},
  nightmaw:{name:'Nightmaw',aff:['Umbra','Volt'],base:{hp:60,atk:32,def:20,spd:26},lore:'The shape that eats the last thought of night.',learnset:{},transform:null},
  aurora:{name:'Aurora',aff:['Lumen','Aquor'],base:{hp:58,atk:26,def:24,spd:22},lore:'A curtain of light that learns to think.',learnset:{},transform:null},
  luminarch:{name:'Luminarch',aff:['Lumen','Pyra'],base:{hp:64,atk:30,def:26,spd:24},lore:'The bright sovereign of the high air.',learnset:{},transform:null},
  voidlord:{name:'Voidlord',aff:['Umbra','Lumen'],base:{hp:78,atk:34,def:28,spd:20},lore:'Thought made of the space between stars.',learnset:{},transform:null}
};

// ── function: fixCreatureStats ──
function fixCreatureStats(){
  if(CREATURES.brine.base['4']!==undefined){ CREATURES.brine.base.atk=16; delete CREATURES.brine.base['4']; }
  if(CREATURES.tidelan.base.dmglost!==undefined){ delete CREATURES.tidelan.base.dmglost; }
}

// ── const: ZONES ──
const ZONES = [
  {id:0,name:'Lumenfield',g0:'#1a2a4a',g1:'#2e4a6e',particle:'light',encRate:0.16,
   story:'Where the first binding-light touched the soil, soft creatures learned to gather it.',
   guardian:'Tithewarden',gKey:'tidelan'},
  {id:1,name:'Cinderwold',g0:'#3a1a14',g1:'#6e3320',particle:'ember',encRate:0.18,
   story:'Ash rains soft as snow. The forges below have never gone cold.',
   guardian:'Emberox',gKey:'emberox'},
  {id:2,name:'Tidemarsh',g0:'#0e2a33',g1:'#13586b',particle:'bubble',encRate:0.19,
   story:'The drowned vales remember the shape of every creature that waded them.',
   guardian:'Brineback',gKey:'brine'},
  {id:3,name:'Stonereach',g0:'#2a241a',g1:'#5a4a2e',particle:'dust',encRate:0.17,
   story:'Old roads of bedrock where the slow will of the earth keeps watch.',
   guardian:'Monolith',gKey:'monolith'},
  {id:4,name:'Stormchoir',g0:'#1a1a3a',g1:'#3e3e8e',particle:'spark',encRate:0.2,
   story:'Above the clouds the storm-choir sings a language made only of voltage.',
   guardian:'Voltaic',gKey:'voltaic'},
  {id:5,name:'Nightfold',g0:'#0d0a1c',g1:'#2a1a4a',particle:'shadow',encRate:0.21,
   story:'The place where light is folded and kept. Few return unchanged.',
   guardian:'Nightmaw',gKey:'nightmaw'},
];

// ── const: STORY_NODES ──
const STORY_NODES = {
  0:{title:'The First Binding',text:'A wandering weaver of light finds you at the edge of Lumenfield. "Take a companion," she says, "and learn to bind the aether before it forgets us."'},
  1:{title:'The Ember Pact',text:'The Cinderwold remembers a war of flames. To pass, you must still the forge-beast that guards its heart.'},
  2:{title:'The Drowned Vow',text:'Beneath the marsh, a voice offers a bargain: bind the tide, and it will bind you in turn.'},
  3:{title:'The Bedrock Oath',text:'Stonereach does not ask. It waits. The Monolith rises only for those who have earned the earth\'s patience.'},
  4:{title:'The Voltage Choir',text:'High above, the storm names you aloud. To climb further you must answer in kind.'},
  5:{title:'The Folded Light',text:'In Nightfold the last anchor sleeps. Wake it, and the aether will be whole — or unmade.'}
};

// ── const: ANCHOR_BONUS ──
const ANCHOR_BONUS = ['maxHP','atk','def','spd','exp','collection'];

// ── const: ITEMS ──
// Items: collection (3 tiers), consumables, catalysts, field tools
const ITEMS = {
  weakbind:{name:'Glimmer Net',tier:1,kind:'bind',desc:'A fragile weave. Bind weak or wounded aether.'},
  truebind:{name:'Verdant Lariat',tier:2,kind:'bind',desc:'Braided by the grove. Holds firmer aether.'},
  aetherbind:{name:'Aether Crown',tier:3,kind:'bind',desc:'A crown that demands the aether kneel.'},
  sapdraught:{name:'Sapdraught',kind:'heal',power:40,desc:'Restores 40 HP.'},
  bathewtn:{name:'Bathwyn Tonic',kind:'heal',power:120,desc:'Restores 120 HP.'},
  lumenphial:{name:'Lumenphial',kind:'heal',power:9999,desc:'Restores all HP.'},
  revivesalt:{name:'Revive Salt',kind:'revive',desc:'Revives a fallen companion at half HP.'},
  embercatalyst:{name:'Ember Catalyst',kind:'catalyst',aff:'Pyra',desc:'Forges a flame-bound transform.'},
  aquorcatalyst:{name:'Tide Catalyst',kind:'catalyst',aff:'Aquor',desc:'Tempers a sea-bound transform.'},
  verdlower:{name:'Word-Lower',kind:'field',desc:'Reveals hidden story nodes nearby.'},
  aethermap:{name:'Aether Map',kind:'field',desc:'Marks the next unbound anchor.'}
};

// ── const: BIND_TIER_MULT ──
const BIND_TIER_MULT = [0.55,0.85, 1.25];

// ── let: _uid ──
let _uid = 1;

// ── function: makeCreature ──
function makeCreature(key, level, extras){
  const def = CREATURES[key];
  const c = {
    uid:_uid++, key, name:def.name, aff:def.aff.slice(),
    level, exp: extras&&extras.exp!=null?extras.exp:0,
    status:{}, buffs:{def:1, spd:1}, barrier:false,
  };
  computeStats(c);
  // moves
  const allMoves = collectMoves(def, level);
  c.moves = (extras&&extras.moves&&extras.moves.length) ? extras.moves : allMoves.slice(0,4);
  if(c.moves.length<1) c.moves.push(['verd_lash',25]);
  c.maxHP = c.hp = c.maxhp;
  if(extras){
    if(extras.hp!=null) c.hp = extras.hp;
    if(extras.ivs) c.ivs = extras.ivs;
  }
  return c;
}

// ── function: collectMoves ──
function collectMoves(def, level){
  const set = {};
  for(const lvl in def.learnset){
    if(+lvl <= level){ def.learnset[lvl].forEach(m=>{ set[m]=1; }); }
  }
  return Object.keys(set).map(m=>[m, 25]);
}

// ── function: computeStats ──
function computeStats(c){
  const def = CREATURES[c.key];
  const lv = c.level;
  c.maxhp = STAT_GROWTH(def.base.hp, lv, true) + 10;
  c.atk = STAT_GROWTH(def.base.atk, lv, false);
  c.def = STAT_GROWTH(def.base.def, lv, false);
  c.spd = STAT_GROWTH(def.base.spd, lv, false);
  c.maxHP = c.maxhp;
}

// ── function: calcDamage ──
function calcDamage(attacker, defender, abil, ctx){
  // (0.4 * Level * ATK / DEF * Multiplier) + small random, floored
  const defStat = Math.max(1, defender.def * (defender.buffs.def||1));
  let mult = typeMultiplier(abil.aff, primAff(defender));
  let base = 0.4 * attacker.level * attacker.atk / defStat * mult;
  base += Math.random()*3;
  const stab = (abil.aff===attacker.aff[0] || abil.aff===attacker.aff[1]) ? 1.25 : 1;
  base *= stab;
  let crit = (Math.random() < (defender.buffs && 0.08 + attacker.level*0.0008)) ? 1.6 : 1;
  base *= crit;
  base = Math.max(1, Math.floor(base));
  return {dmg:base, mult, crit:crit>1, stab:stab>1};
}

// ── function: primAff ──
function primAff(c){ return c.aff[0]; }

// ── function: applyStatus ──
function applyStatus(target, eff){
  if(!eff) return;
  if(eff.kind==='burn') target.status.burn = {turns:4, src:'burn'};
  else if(eff.kind==='frost') target.status.frost = {turns:3};
  else if(eff.kind==='shock'){ if(Math.random()<0.35) target.status.shock = {turns:2}; }
  else if(eff.kind==='barrier') target.barrier = true;
  else if(eff.kind==='regen') target.status.regen = {turns:4};
  else if(eff.kind==='spd') target.buffs.spd = Math.max(target.buffs.spd, eff.value||1.3);
  else if(eff.kind==='selfbuff') target.buffs.def = (eff.value||1.4);
  else if(eff.kind==='heal'){ target.hp = Math.min(target.maxHP, target.hp + Math.floor(target.maxHP*(eff.value||0.4))); }
  else if(eff.kind==='drain'){ /* handled in battle resolver via return value */ }
}

// ── function: tickStatus ──
function tickStatus(c){
  const log=[];
  if(c.status.burn){ c.hp = Math.max(0, c.hp - Math.max(1,Math.floor(c.maxhp*0.06))); log.push({type:'burn',who:c.uid}); if(--c.status.burn.turns<=0) delete c.status.burn; }
  if(c.status.regen){ c.hp = Math.min(c.maxHP, c.hp + Math.floor(c.maxhp*0.12)); log.push({type:'regen',who:c.uid}); if(--c.status.regen.turns<=0) delete c.status.regen; }
  if(c.status.frost){ if(--c.status.frost.turns<=0) delete c.status.frost; }
  return log;
}

// ── const: SAVE_KEY ──
const SAVE_KEY = 'verdain_save_v1';

// ── function: defaultState ──
function defaultState(){
  return {
    version:1, zoneId:0, playerX:6, playerY:5,
    team:[], bag:{}, discovered:{}, storyStage:0,
    anchorsDefeated:0, bonuses:{maxHP:0,atk:0,def:0,spd:0,exp:0,collection:0},
    clearedZones:[false,false,false,false,false,false],
    postGame:false, muted:false, tutorialDone:false,
    seenIntro:false
  };
}

// ── function: saveGame ──
function saveGame(){
  try{
    const s = JSON.parse(JSON.stringify(G));
    s.team = G.team.map(serialize);
    localStorage.setItem(SAVE_KEY, JSON.stringify(s));
    toast('Bound to the aether. Progress saved.');
  }catch(e){ toast('Save failed: '+e.message); }
}

// ── function: serialize ──
function serialize(c){
  return {key:c.key,name:c.name,aff:c.aff,level:c.level,exp:c.exp,
    moves:c.moves.map(m=>m.slice()), hp:c.hp, maxHP:c.maxhp, status:c.status};
}

// ── function: loadGame ──
function loadGame(){
  try{
    const raw = localStorage.getItem(SAVE_KEY);
    if(!raw) return false;
    const s = JSON.parse(raw);
    Object.assign(G, s);
    G.team = s.team.map(d=>makeCreature(d.key, d.level, {moves:d.moves, hp:d.hp, exp:d.exp}));
    return true;
  }catch(e){ return false; }
}

// ── function: hasSave ──
function hasSave(){ return !!localStorage.getItem(SAVE_KEY); }

// ── const: G ──
const G = defaultState();

// ── function: toast ──
function toast(msg){
  const t = document.getElementById('toast');
  const d = document.createElement('div');
  d.className='toast-line'; d.textContent=msg;
  t.appendChild(d);
  setTimeout(()=>{ if(d.parentNode) d.parentNode.removeChild(d); }, 2900);
}

// ── function: showVignette ──
function showVignette(color, strength){
  const v = document.getElementById('vignette');
  v.style.background = 'radial-gradient(circle at 50% 50%, transparent 30%, '+color+' 100%)';
  v.style.opacity = strength||0.6;
  setTimeout(()=>{ v.style.opacity=0; }, 900);
}

// ── function: clearUI ──
function clearUI(){ document.getElementById('ui').innerHTML=''; }

// ── function: el ──
function el(tag, attrs, html){
  const e=document.createElement(tag);
  if(attrs) for(const k in attrs){ if(k==='class') e.className=attrs[k]; else if(k==='text') e.textContent=attrs[k]; else e.setAttribute(k,attrs[k]); }
  if(html!=null) e.innerHTML=html;
  return e;
}

// ── const: W ──
const W = 960;

// ── const: H ──
const H = 600;

// ── let: canvas ──
let canvas = null;

// ── let: ctx ──
let ctx = null;

// ── let: particles ──
let particles = [];

// ── let: floaters ──
let floaters = [];

// ── let: shake ──
let shake = 0;

// ── let: anim ──
let anim = 0;

// ── function: initCanvas ──
function initCanvas(){
  canvas=document.getElementById('game');
  canvas.width=W; canvas.height=H;
  ctx=canvas.getContext('2d');
  fitCanvas();
  window.addEventListener('resize', fitCanvas);
}

// ── function: fitCanvas ──
function fitCanvas(){
  const r=Math.min(window.innerWidth/W, window.innerHeight/H);
  canvas.style.width=(W*r)+'px';
  canvas.style.height=(H*r)+'px';
}

// ── function: makeParticle ──
function makeParticle(zone){
  const z=ZONES[zone]||ZONES[0];
  const kinds={light:{c:'#fff2c4',s:1.2},ember:{c:'#ff7a59',s:1.4},bubble:{c:'#9fd8ff',s:1},dust:{c:'#c9a05a',s:0.8},spark:{c:'#ffe65a',s:1.6},shadow:{c:'#b06bff',s:1.2}};
  const k=z.particle; const kd=kinds[k]||kinds.light;
  return {x:Math.random()*W,y:Math.random()*H,vy:-(0.2+Math.random()*0.6),vx:(Math.random()-0.5)*0.4,c:kd.c,s:kd.s*(0.6+Math.random()), life:1};
}

// ── function: drawBackground ──
function drawBackground(zoneId){
  const z=ZONES[zoneId]||ZONES[0];
  const g=ctx.createLinearGradient(0,0,0,H);
  g.addColorStop(0,z.g0); g.addColorStop(1,z.g1);
  ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
  for(let i=0;i<4;i++){
    const r=120+((anim*8+i*60)%300);
    const x=(i*260+ (anim*10)%960)%960, y=H*0.3+Math.sin(anim*0.5+i)*40;
    const rg=ctx.createRadialGradient(x,y,0,x,y,r);
    rg.addColorStop(0,'rgba(255,255,255,0.05)'); rg.addColorStop(1,'transparent');
    ctx.fillStyle=rg; ctx.beginPath(); ctx.arc(x,y,r,0,7); ctx.fill();
  }
}

// ── function: updateParticles ──
function updateParticles(zoneId){
  for(const p of particles){
    p.y+=p.vy; p.x+=p.vx; p.life-=0.004;
    if(p.y< -10 || p.life<=0){ Object.assign(p, makeParticle(zoneId)); }
  }
}

// ── function: drawParticles ──
function drawParticles(){
  for(const p of particles){
    ctx.globalAlpha=Math.max(0,p.life)*0.7;
    ctx.fillStyle=p.c;
    ctx.beginPath(); ctx.arc(p.x,p.y,p.s,0,7); ctx.fill();
  }
  ctx.globalAlpha=1;
}

// ── function: applyShake ──
function applyShake(){ if(shake>0){ ctx.save(); ctx.translate((Math.random()-0.5)*shake,(Math.random()-0.5)*shake); shake*=0.85; if(shake<0.4) shake=0; } }

// ── function: endShake ──
function endShake(){ if(shake>0) ctx.restore(); }

// ── const: TILE ──
const TILE = 48;

// ── const: COLS ──
const COLS = Math.floor(W/TILE);

// ── const: ROWS ──
const ROWS = Math.floor(H/TILE);

// ── let: mapGrid ──
let mapGrid = [];

// ── let: zoneEntrances ──
let zoneEntrances = {};

// ── function: buildMap ──
function buildMap(zoneId){
  mapGrid=[]; zoneEntrances={};
  const cols=COLS, rows=ROWS;
  for(let y=0;y<rows;y++){ const row=[]; for(let x=0;x<cols;x++){
    let t=0;
    if(x===0||y===0||x===cols-1||y===rows-1) t=1;
    if((x+y)%7===0 && Math.random()<0.25) t=1;
    row.push(t);
  } mapGrid.push(row); }
  const sx=Math.floor(cols/2), sy=Math.floor(rows/2);
  mapGrid[sy][sx]=2;
  if(zoneId>0){ mapGrid[Math.floor(rows/2)][0]=0; mapGrid[Math.floor(rows/2)][1]=3; zoneEntrances.left=zoneId-1; }
  else { mapGrid[Math.floor(rows/2)][0]=0; }
  if(G.clearedZones[zoneId] && zoneId<5){ mapGrid[Math.floor(rows/2)][cols-2]=3; mapGrid[Math.floor(rows/2)][cols-1]=0; zoneEntrances.right=zoneId+1; }
  return mapGrid;
}

// ── function: tileAt ──
function tileAt(x,y){ if(y<0||y>=ROWS||x<0||x>=COLS) return 1; return mapGrid[y][x]; }

// ── function: moveOnMap ──
function moveOnMap(dx,dy){
  if(STATE.mode!=='world') return;
  const nx=G.playerX+dx, ny=G.playerY+dy;
  const t=tileAt(nx,ny);
  if(t===1) return;
  G.playerX=nx; G.playerY=ny;
  if(t===3){
    if(nx<=1 && zoneEntrances.left!=null){ changeZone(zoneEntrances.left); return; }
    if(nx>=COLS-2 && zoneEntrances.right!=null){ changeZone(zoneEntrances.right); return; }
  }
  if(t===2){ openShrine(); return; }
  const z=ZONES[G.zoneId];
  if(Math.random()<z.encRate){ startWildEncounter(); }
}

// ── function: changeZone ──
function changeZone(id){
  G.zoneId=id; G.playerX=Math.floor(COLS/2); G.playerY=Math.floor(rowsFix());
  buildMap(id);
  particles=spawnParticles(id,60);
  showVignette(ZONES[id].g0,0.5);
  toast('You enter '+ZONES[id].name+'.');
}

// ── function: rowsFix ──
function rowsFix(){ return ROWS/2; }

// ── const: STATE ──
const STATE = { mode:'title', busy:false, battle:null, menu:null };

// ── function: startWildEncounter ──
function startWildEncounter(){
  const z=ZONES[G.zoneId];
  const pool=Object.keys(CREATURES).filter(k=>{ const c=CREATURES[k]; return c.transform===null && !c.learnset.locked && Math.random()<1; });
  // pick wild species appropriate to zone by affinity loosely
  const lv = Math.max(2, Math.floor(3 + G.zoneId*3 + Math.random()*4));
  const key = pool[Math.floor(Math.random()*pool.length)];
  const wild = makeCreature(key, lv);
  G.discovered[key]=true;
  beginBattle([wild], false);
}

// ── function: beginBattle ──
function beginBattle(enemies, isGuardian){
  STATE.mode='battle';
  STATE.battle = {
    enemies, turnQueue:[], activeIndex:0, enemyIndex:0,
    log:[], phase:'choice', isGuardian:!!isGuardian,
    guardianZone: G.zoneId
  };
  // reset statuses/buffs for fresh battle
  G.team.forEach(c=>{ c.status={}; c.buffs={def:1,spd:1}; c.barrier=false; });
  enemies.forEach(c=>{ c.status={}; c.buffs={def:1,spd:1}; c.barrier=false; });
  showVignette('#000010',0.5);
}

// ── function: openShrine ──
function openShrine(){
  STATE.mode='shrine';
  renderShrine();
}

// ── function: drawWorld ──
function drawWorld(){
  drawBackground(G.zoneId);
  // tiles
  for(let y=0;y<ROWS;y++){ for(let x=0;x<COLS;x++){
    const t=mapGrid[y][x];
    const px=x*TILE, py=y*TILE;
    if(t===1){ ctx.fillStyle='rgba(10,12,26,0.85)'; ctx.fillRect(px,py,TILE,TILE);
      ctx.strokeStyle='rgba(120,160,255,0.15)'; ctx.strokeRect(px+2,py+2,TILE-4,TILE-4); }
    else if(t===2){ ctx.fillStyle='#6ad0ff'; ctx.globalAlpha=0.85;
      ctx.beginPath(); ctx.arc(px+TILE/2,py+TILE/2,TILE*0.3+Math.sin(anim*2)*3,0,7); ctx.fill();
      ctx.globalAlpha=1; ctx.fillStyle='#bff6ff'; ctx.font='10px sans-serif'; ctx.textAlign='center';
      ctx.fillText('SHRINE',px+TILE/2,py+TILE-4); }
    else if(t===3){ ctx.fillStyle='#5ef0a8'; ctx.globalAlpha=0.4; ctx.fillRect(px,py,TILE,TILE); ctx.globalAlpha=1; }
  }}
  // player
  const px=G.playerX*TILE+TILE/2, py=G.playerY*TILE+TILE/2;
  const bob=Math.sin(anim*3)*2;
  ctx.save();
  const grd=ctx.createRadialGradient(px,py-6+bob,2,px,py-6+bob,18);
  grd.addColorStop(0,'#fff2c4'); grd.addColorStop(1,'#ff8ad6');
  ctx.fillStyle=grd;
  ctx.beginPath(); ctx.arc(px,py-6+bob,12,0,7); ctx.fill();
  ctx.fillStyle='#e8eeff'; ctx.fillRect(px-6,py+4+bob,12,10);
  ctx.restore();
  drawParticles();
  // hud strip
  drawWorldHUD();
}

// ── function: drawWorldHUD ──
function drawWorldHUD(){
  ctx.fillStyle='rgba(10,12,26,0.55)'; ctx.fillRect(0,H-40,W,40);
  ctx.fillStyle='#e8eeff'; ctx.font='14px sans-serif'; ctx.textAlign='left';
  const anchored=G.anchorsDefeated;
  ctx.fillText('Zone: '+ZONES[G.zoneId].name+'   Team: '+G.team.length+'/6   Anchors: '+anchored+'/4   Discovered: '+Object.keys(G.discovered).length, 12, H-16);
  ctx.textAlign='right';
  ctx.fillStyle='#9fb0d8';
  ctx.fillText(G.muted?'[M] sound off':'[M] sound on', W-12, H-16);
}

// ── function: activePlayer ──
function activePlayer(){ return G.team[STATE.battle.activeIndex]; }

// ── function: firstAlive ──
function firstAlive(enemies){ return enemies.find(e=>e.hp>0); }

// ── function: buildTurnQueue ──
function buildTurnQueue(){
  const b=STATE.battle;
  const units=[];
  if(activePlayer() && activePlayer().hp>0) units.push({side:'player',c:activePlayer()});
  b.enemies.forEach((e,i)=>{ if(e.hp>0) units.push({side:'enemy',c:e,idx:i}); });
  // priority moves handled at resolve; base order by spd
  units.sort((u1,u2)=> (u2.c.spd*(u2.c.buffs.spd||1)) - (u1.c.spd*(u1.c.buffs.spd||1)));
  b.turnQueue=units;
}

// ── function: resolveMove ──
function resolveMove(attacker, defender, abil, side){
  const b=STATE.battle;
  const log=[]; let dmg=0, info={};
  if(abil.power<=0){
    applyStatus(attacker, abil.eff);
    log.push({text:attacker.name+' uses '+abil.name+'.', type:'info'});
    return {log, dmg:0};
  }
  // accuracy
  if(Math.random()>abil.acc){ log.push({text:attacker.name+"'s "+abil.name+' misses!', type:'miss'}); return {log,dmg:0}; }
  info=calcDamage(attacker, defender, abil);
  let hits = (abil.eff&&abil.eff.kind==='multi') ? abil.eff.hits : 1;
  for(let h=0; h<hits; h++){
    let d=info.dmg;
    if(defender.barrier){ defender.barrier=false; log.push({text:defender.name+' deflects the blow!', type:'barrier'}); d=0; }
    else {
      defender.hp=Math.max(0, defender.hp-d);
      log.push({text:attacker.name+' hits '+defender.name+' for '+d+'!', type:info.crit?'crit':'hit', dmg:d, who:defender.uid, crit:info.crit, mult:info.mult});
      if((abil.eff&&abil.eff.kind==='drain')){ const heal=Math.floor(d*(abil.eff.value||0.5)); attacker.hp=Math.min(attacker.maxHP, attacker.hp+heal); log.push({text:attacker.name+' drains '+heal+' HP.', type:'heal', who:attacker.uid}); }
    }
    if(defender.hp<=0){ log.push({text:defender.name+' is unbound!', type:'faint'}); break; }
  }
  if(d>0) applyStatus(defender, abil.eff);
  if((abil.eff&&abil.eff.kind==='spd')) log.push({text:attacker.name+' quickens.', type:'info'});
  if((abil.eff&&abil.eff.kind==='selfbuff')) log.push({text:attacker.name+' raises DEF.', type:'info'});
  return {log, dmg};
}

// ── function: enemyAI ──
function enemyAI(enemy){
  const usable=enemy.moves.filter(m=>m[1]>0).map(m=>ABIL[m[0]]);
  const pick = usable[Math.floor(Math.random()*usable.length)] || ABIL.verd_lash;
  // target = first alive player
  const target=G.team.find(c=>c.hp>0);
  return {abil:pick, target};
}

// ── function: startPlayerTurn ──
function startPlayerTurn(){
  const b=STATE.battle;
  if(activePlayer().hp<=0){ forceSwitch(); return; }
  b.phase='choice';
  renderBattleMenu();
}

// ── function: doPlayerMove ──
function doPlayerMove(abilId){
  const b=STATE.battle;
  if(b.phase!=='choice') return;
  const m = activePlayer().moves.find(x=>x[0]===abilId);
  if(!m || m[1]<=0){ toast('No energy for that move.'); return; }
  m[1]--;
  b.pending={side:'player', c:activePlayer(), abil:ABIL[abilId], target:firstAlive(b.enemies)};
  builderTurn();
}

// ── function: doSwitch ──
function doSwitch(idx){
  const b=STATE.battle;
  if(idx===b.activeIndex || G.team[idx].hp<=0){ return; }
  b.activeIndex=idx;
  b.pending={side:'player', c:activePlayer(), abil:null, target:null, switched:true};
  builderTurn();
}

// ── function: builderTurn ──
function builderTurn(){
  const b=STATE.battle;
  b.phase='resolving';
  buildTurnQueue();
  runQueue();
}

// ── function: runQueue ──
function runQueue(){
  const b=STATE.battle;
  if(b.turnQueue.length===0){ afterTurn(); return; }
  const u=b.turnQueue.shift();
  if(u.c.hp<=0){ runQueue(); return; }
  // shock skip
  if(u.c.status && u.c.status.shock){ delete u.c.status.shock; toast(u.c.name+' is shocked and loses its turn!'); runQueue(); return; }
  playMove(u);
}

// ── function: playMove ──
function playMove(u){
  const b=STATE.battle;
  if(u.side==='player'){
    const def = b.pending.target && b.pending.target.hp>0 ? b.pending.target : firstAlive(b.enemies);
    if(!def){ runQueue(); return; }
    const res=resolveMove(u.c, def, b.pending.abil, 'player');
    res.log.forEach(l=>pushLog(l));
    fxFor(res.log);
    setTimeout(()=>{ if(def.hp<=0 && allFainted(b.enemies)){ winBattle(); return; } runQueue(); }, 650);
  } else {
    const ai=enemyAI(u.c);
    if(!ai.target){ setTimeout(winBattle,300); return; }
    const res=resolveMove(u.c, ai.target, ai.abil, 'enemy');
    res.log.forEach(l=>pushLog(l));
    fxFor(res.log);
    setTimeout(()=>{ if(ai.target.hp<=0 && G.team.every(c=>c.hp<=0)){ loseBattle(); return; } if(activePlayer().hp<=0){ forceSwitch(); return; } runQueue(); }, 650);
  }
}

// ── function: afterTurn ──
function afterTurn(){
  const b=STATE.battle;
  // status ticks
  const all=[activePlayer(),...b.enemies].filter(c=>c&&c.hp>0);
  all.forEach(c=>tickStatus(c).forEach(l=>pushLog(l)));
  if(allFainted(b.enemies)){ winBattle(); return; }
  if(G.team.every(c=>c.hp<=0)){ loseBattle(); return; }
  startPlayerTurn();
}

// ── function: allFainted ──
function allFainted(list){ return list.every(c=>c.hp<=0); }

// ── function: pushLog ──
function pushLog(entry){
  const b=STATE.battle; if(!b) return;
  if(typeof entry==='string') b.log.push({text:entry,type:'info'});
  else b.log.push(entry);
  if(b.log.length>40) b.log.shift();
  renderBattleLog();
}

// ── function: fxFor ──
function fxFor(logs){
  if(!logs) return;
  logs.forEach(l=>{
    if(l.type==='hit'||l.type==='crit'){
      const c=findCreature(l.who);
      const pos=c?creatureScreenPos(c):null;
      if(pos){ floaters.push({x:pos.x,y:pos.y,text:''+l.dmg,life:1,crit:l.crit,mult:l.mult});
        if(l.crit) shake=Math.max(shake,7);
        if(l.mult>=2){ burst(pos.x,pos.y, '#fff2c4'); shake=Math.max(shake,5);} }
    } else if(l.type==='heal'){ const c=findCreature(l.who); const pos=c?creatureScreenPos(c):null; if(pos) floaters.push({x:pos.x,y:pos.y,text:'+',life:1,heal:true}); }
  });
}

// ── function: findCreature ──
function findCreature(uid){ const all=[activePlayer(),...STATE.battle.enemies]; return all.find(c=>c.uid===uid); }

// ── function: creatureScreenPos ──
function creatureScreenPos(c){
  // approximate: enemies on right, player on left
  const b=STATE.battle;
  if(b.enemies.includes(c)){ const i=b.enemies.indexOf(c); return {x:700, y:200 + i*20}; }
  return {x:240, y:380};
}

// ── function: burst ──
function burst(x,y,color){ for(let i=0;i<14;i++){ floaters.push({x,y,vx:(Math.random()-0.5)*4,vy:(Math.random()-0.5)*4,life:1,color, particle:true}); } }

// ── function: forceSwitch ──
function forceSwitch(){
  const b=STATE.battle;
  if(G.team.some(c=>c.hp>0)){ b.phase='switch'; renderSwitchMenu('Your companion is unbound. Choose another.'); }
  else { loseBattle(); }
}

// ── function: winBattle ──
function winBattle(){
  const b=STATE.battle;
  if(b.isGuardian){
    G.clearedZones[b.guardianZone]=true; G.anchorsDefeated++;
    const bonus=ANCHOR_BONUS[G.anchorsDefeated%ANCHOR_BONUS.length];
    applyAnchorBonus(bonus);
    showVignette('#fff2c4',0.7);
    setTimeout(()=>showStoryNode(b.guardianZone), 500);
    return;
  }
  // exp
  const levSum=b.enemies.reduce((s,e)=>s+e.level,0);
  const avg=levSum/b.enemies.length;
  G.team.forEach(c=>{ if(c.hp>0) gainExp(c, expReward(avg, 100, c.level)); });
  // wild defeated lore complete
  endBattle('Victory! The aether settles.');
}

// ── function: applyAnchorBonus ──
function applyAnchorBonus(bonus){
  G.bonuses[bonus]=(G.bonuses[bonus]||0)+ (bonus==='exp'||bonus==='collection'?0.1:0.08);
  G.team.forEach(c=>applyBonuses(c));
  toast('Anchor bound! Permanent '+bonus+' bonus gained.');
  if(G.anchorsDefeated>=4 && !G.postGame){
    G.postGame=true;
    setTimeout(()=>{ showVignette('#b06bff',0.8); toast('The aether is whole. Post-game roam unlocked: fiercer wilds await.'); }, 1200);
  }
}

// ── function: applyBonuses ──
function applyBonuses(c){
  computeStats(c);
  c.maxHP = c.maxhp + Math.floor((G.bonuses.maxHP||0)*c.level*2);
  c.atk = Math.floor(c.atk*(1+(G.bonuses.atk||0)));
  c.def = Math.floor(c.def*(1+(G.bonuses.def||0)));
  c.spd = Math.floor(c.spd*(1+(G.bonuses.spd||0)));
}

// ── function: gainExp ──
function gainExp(c, amt){
  amt = Math.floor(amt*(1+(G.bonuses.exp||0)));
  c.exp += amt;
  let leveled=false;
  while(c.level<LEVEL_CAP && c.exp>=expForLevel(c.level+1)){
    c.level++; leveled=true;
  }
  if(leveled){ computeStats(c); relearnIfNeeded(c); c.hp=Math.min(c.maxHP,c.hp+20); toast(c.name+' reached level '+c.level+'!'); }
  else computeStats(c);
}

// ── function: relearnIfNeeded ──
function relearnIfNeeded(c){
  // ensure moves array kept & learn new if space
  const def=CREATURES[c.key];
  for(const lvl in def.learnset){
    if(+lvl===c.level){ def.learnset[lvl].forEach(m=>{ if(!c.moves.find(x=>x[0]===m)){ if(c.moves.length<4) c.moves.push([m,25]); else toast(c.name+' wants to learn '+ABIL[m].name+' but has 4 moves. Use a creature screen to swap.'); } }); }
  }
}

// ── function: loseBattle ──
function loseBattle(){
  endBattle('Your companions are unbound. You wake at the last shrine.');
  // revive & send to current zone shrine
  G.team.forEach((c,i)=>{ c.hp=c.maxHP; c.status={}; c.buffs={def:1,spd:1}; });
  G.playerX=Math.floor(COLS/2); G.playerY=Math.floor(ROWS/2);
}

// ── function: endBattle ──
function endBattle(msg){
  const b=STATE.battle;
  STATE.mode='world';
  STATE.battle=null;
  G.team.forEach(c=>{ c.barrier=false; });
  toast(msg||'Battle ended.');
  setTimeout(()=>saveGame(),300);
}

// ── function: bestBindTier ──
function bestBindTier(){
  const order=['weakbind','truebind','aetherbind'];
  for(const k of order){ if((G.bag[k]||0)>0) return {key:k, tier:ITEMS[k].tier}; }
  return null;
}

// ── function: computeCaptureChance ──
function computeCaptureChance(target, tier){
  const hpFactor = 1 - (target.hp/target.maxHP);
  const base = 0.15 + hpFactor*0.7; // remaining hp lowers chance
  const tierMult = BIND_TIER_MULT[tier-1] || 0.55;
  const bonus = (G.bonuses.collection||0);
  return Math.max(0.03, Math.min(0.97, base*tierMult*(1+bonus)));
}

// ── function: bindAttempt ──
function bindAttempt(target){
  const tierInfo=bestBindTier();
  if(!tierInfo){ toast('No collection items left!'); return; }
  G.bag[tierInfo.key]--;
  if(G.bag[tierInfo.key]<=0) delete G.bag[tierInfo.key];
  openBindMinigame(target, tierInfo.tier);
}

// ── function: openBindMinigame ──
function openBindMinigame(target, tier){
  const base=computeCaptureChance(target, tier);
  // rhythm mini-game: a marker sweeps a bar; player taps when in green zone -> boosts
  const ui=document.getElementById('ui'); clearUI();
  const wrap=el('div',{class:'modal'});
  wrap.innerHTML='<div class="glass"><h2>Bind '+target.name+'</h2>'+
    '<p class="dim">Tap the pulse when it enters the bright band to strengthen your binding.</p>'+
    '<div class="bindbar"><div class="bindzone"></div><div class="bindmarker"></div></div>'+
    '<button id="bipt" class="btn big">PULSE</button>'+
    '<button id="biskip" class="btn ghost">Skip Rhythm</button>'+
    '<div id="bindmsg" class="dim"></div></div>';
  ui.appendChild(wrap);
  let pos=0, dir=1, speed=0.018+Math.random()*0.01, taps=0, quality=0.5;
  let raf;
  const marker=wrap.querySelector('.bindmarker');
  const zone=wrap.querySelector('.bindzone');
  const msg=wrap.querySelector('#bindmsg');
  function loop(){ pos+=dir*speed; if(pos>1){pos=1;dir=-1;} if(pos<0){pos=0;dir=1;}
    marker.style.left=(pos*100)+'%';
    raf=requestAnimationFrame(loop);
  }
  loop();
  function tap(){ taps++; const zoneMin=0.42, zoneMax=0.58;
    if(pos>=zoneMin&&pos<=zoneMax){ quality=Math.min(1,quality+0.18); msg.textContent='Perfect pulse! ('+Math.round(quality*100)+'%)'; }
    else { quality=Math.max(0.05,quality-0.06); msg.textContent='Off-beat... ('+Math.round(quality*100)+'%)'; }
  }
  function finish(){ cancelAnimationFrame(raf); wrap.remove(); resolveBind(target, base, quality); }
  wrap.querySelector('#bipt').onclick=tap;
  wrap.querySelector('#bipt').addEventListener('animationend',()=>{});
  wrap.querySelector('#biskip').onclick=finish;
  wrap._finish=finish;
  // allow Space to pulse
  G._bindTap=()=>{ tap(); };
}

// ── function: resolveBind ──
function resolveBind(target, base, quality){
  const chance = base * (0.6 + quality*0.8);
  if(Math.random()<chance){
    target.hp=target.maxHP; // heal when bound
    G.team.push(target);
    G.discovered[target.key]=true;
    toast('Bound! '+target.name+' joins your circle.');
    // remove from enemies
    STATE.battle.enemies = STATE.battle.enemies.filter(e=>e!==target);
    if(allFainted(STATE.battle.enemies)){ winBattle(); return; }
    clearUI();
  } else {
    G._bindTap=null;
    toast(target.name+' slipped the binding!');
    clearUI();
    startPlayerTurn();
  }
}

// ── function: drawBattle ──
function drawBattle(){
  const b=STATE.battle;
  drawBackground(G.zoneId);
  // platform
  ctx.fillStyle='rgba(10,12,26,0.4)';
  ctx.beginPath(); ctx.ellipse(700,250,120,40,0,0,7); ctx.fill();
  ctx.beginPath(); ctx.ellipse(240,420,140,46,0,0,7); ctx.fill();
  // enemies
  b.enemies.forEach((e,i)=>drawCombatant(e, 700, 230 + i*8, false, i));
  // player active
  const p=activePlayer();
  drawCombatant(p, 240, 400, true, 0);
  drawParticles();
  drawFloaters();
  drawBattleBars();
}

// ── function: drawCombatant ──
function drawCombatant(c, x, y, isPlayer, idx){
  if(!c) return;
  if(c.hp<=0) ctx.globalAlpha=0.3;
  const aff=TYPE_INFO[c.aff[0]];
  const grd=ctx.createRadialGradient(x,y-10,2,x,y,38);
  grd.addColorStop(0,'#ffffff'); grd.addColorStop(0.5, aff.color); grd.addColorStop(1,'rgba(0,0,0,0.2)');
  ctx.fillStyle=grd;
  ctx.beginPath(); ctx.arc(x,y,30,0,7); ctx.fill();
  ctx.globalAlpha=1;
  ctx.fillStyle='#e8eeff'; ctx.font='13px sans-serif'; ctx.textAlign='center';
  ctx.fillText(c.name, x, y-38);
  if(c.status.burn){ ctx.fillStyle='#ff7a59'; ctx.fillText('~burn', x, y+46); }
  if(c.status.frost){ ctx.fillStyle='#9fd8ff'; ctx.fillText('~frost', x, y+46); }
  if(c.status.regen){ ctx.fillStyle='#5ef0a8'; ctx.fillText('~regen', x, y+46); }
  if(c.barrier){ ctx.strokeStyle='#fff2c4'; ctx.lineWidth=3; ctx.beginPath(); ctx.arc(x,y,34,0,7); ctx.stroke(); }
}

// ── function: drawBattleBars ──
function drawBattleBars(){
  const b=STATE.battle;
  const p=activePlayer();
  // only show names; detailed bars in DOM
  if(p) drawMiniBar(p, 120, 460);
  b.enemies.forEach((e,i)=>drawMiniBar(e, 600, 120+i*30));
}

// ── function: drawMiniBar ──
function drawMiniBar(c, x, y){
  ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.fillRect(x,y,200,16);
  ctx.fillStyle = c.hp/c.maxHP>0.5?'#5ef0a8':c.hp/c.maxHP>0.2?'#ffcf6b':'#ff6b6b';
  ctx.fillRect(x,y,200*(c.hp/c.maxHP),16);
  ctx.strokeStyle='rgba(255,255,255,0.3)'; ctx.strokeRect(x,y,200,16);
}

// ── function: drawFloaters ──
function drawFloaters(){
  for(const f of floaters){
    if(f.particle){ f.x+=f.vx; f.y+=f.vy; f.vy+=0.15; f.life-=0.03; }
    else { f.y-=0.6; f.life-=0.02; }
    ctx.globalAlpha=Math.max(0,f.life);
    if(f.particle){ ctx.fillStyle=f.color; ctx.beginPath(); ctx.arc(f.x,f.y,3,0,7); ctx.fill(); }
    else {
      ctx.font=(f.crit?'bold 30px':'bold 22px')+' sans-serif'; ctx.textAlign='center';
      ctx.fillStyle = f.heal?'#5ef0a8':(f.crit?'#fff2c4':'#ff6b6b');
      ctx.fillText((f.crit?'CRIT ':'')+f.text, f.x, f.y);
    }
  }
  ctx.globalAlpha=1;
  floaters=floaters.filter(f=>f.life>0);
}
</script>
</body>
</html>