Creature Collection
ox alpha · full

Browser check: Artifact runtime error. This is a technical check, not a quality review.
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 82,057 bytes · SHA-256 8cee2f539c53
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>SUNDERVALE — Binders of the Hollow Light</title>
<style>
:root{
--ink:#0b0e1a; --panel:rgba(16,20,38,.72); --glass:rgba(255,255,255,.08);
--line:rgba(140,170,255,.25); --txt:#dfe6ff; --dim:#8b96c2;
--gold:#ffd77a; --hp:#5ee08a; --hplow:#ffb35c; --hpcrit:#ff5c7a;
--pyre:#ff7a4d; --brine:#4dc3ff; --loam:#8fd05a; --zephyr:#a9e8ff;
--lumen:#ffe27a; --umbra:#c07aff; --aegis:#9fb3c8;
font-size:16px;
}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
html,body{height:100%;overflow:hidden;background:var(--ink);}
body{font-family:'Segoe UI',system-ui,-apple-system,sans-serif;color:var(--txt);user-select:none;}
canvas#stage{position:absolute;inset:0;width:100%;height:100%;display:block;}
.hidden{display:none!important;}
/* ---------- Title ---------- */
#title{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:60;
background:radial-gradient(1200px 700px at 50% 30%, #1b2350 0%, #0b0e1a 70%);overflow:hidden;}
#title h1{font-size:clamp(2.6rem,8vw,5rem);letter-spacing:.35em;font-weight:800;text-align:center;
background:linear-gradient(180deg,#fff,#ffd77a 55%,#ff9a5c);-webkit-background-clip:text;background-clip:text;color:transparent;
filter:drop-shadow(0 0 28px rgba(255,190,110,.45));animation:titleGlow 4s ease-in-out infinite;}
@keyframes titleGlow{50%{filter:drop-shadow(0 0 46px rgba(255,210,130,.7));}}
#title .sub{margin:.6em 0 2em;color:var(--dim);letter-spacing:.3em;font-size:.8rem;text-transform:uppercase;}
.tbtn{font:inherit;cursor:pointer;margin:.45em;padding:.8em 3.2em;border-radius:999px;border:1px solid var(--line);
background:linear-gradient(160deg,rgba(255,255,255,.12),rgba(255,255,255,.03));color:var(--txt);
font-size:1.05rem;letter-spacing:.18em;text-transform:uppercase;backdrop-filter:blur(8px);transition:.25s;}
.tbtn:hover{transform:translateY(-2px);box-shadow:0 8px 30px rgba(120,150,255,.25);border-color:var(--gold);color:#fff;}
.tbtn:disabled{opacity:.35;cursor:default;transform:none;box-shadow:none;}
#titleStars{position:absolute;inset:0;pointer-events:none;}
/* ---------- HUD ---------- */
#hud{position:absolute;top:0;left:0;right:0;display:flex;justify-content:space-between;align-items:flex-start;
padding:10px 14px;z-index:20;pointer-events:none;}
.hudcard{pointer-events:auto;background:var(--panel);border:1px solid var(--line);border-radius:14px;
padding:8px 14px;backdrop-filter:blur(10px);font-size:.85rem;box-shadow:0 4px 24px rgba(0,0,0,.4);}
#zoneName{font-weight:700;letter-spacing:.12em;color:var(--gold);}
#hudBtns{display:flex;gap:8px;pointer-events:auto;}
.iconbtn{width:42px;height:42px;border-radius:12px;border:1px solid var(--line);background:var(--panel);
color:var(--txt);font-size:1.15rem;cursor:pointer;backdrop-filter:blur(8px);transition:.2s;}
.iconbtn:hover{border-color:var(--gold);transform:translateY(-1px);}
/* touch controls */
#touch{position:absolute;bottom:14px;left:14px;z-index:20;width:150px;height:150px;display:none;}
body.touchmode #touch{display:block;}
.tbtn-pad{position:absolute;width:48px;height:48px;border-radius:14px;border:1px solid var(--line);background:var(--panel);
color:var(--txt);font-size:1.2rem;display:flex;align-items:center;justify-content:center;cursor:pointer;}
#touch .up{top:0;left:51px}#touch .dn{bottom:0;left:51px}#touch .lf{left:0;top:51px}#touch .rt{right:0;top:51px}
/* ---------- Dialogue ---------- */
#dlg{position:absolute;left:50%;bottom:26px;transform:translateX(-50%);width:min(720px,92vw);z-index:40;
background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:18px 22px;backdrop-filter:blur(14px);
box-shadow:0 10px 50px rgba(0,0,0,.55);cursor:pointer;}
#dlgSpeaker{color:var(--gold);font-weight:700;letter-spacing:.14em;font-size:.8rem;text-transform:uppercase;margin-bottom:6px;}
#dlgText{line-height:1.55;font-size:.98rem;min-height:3em;}
#dlgHint{text-align:right;color:var(--dim);font-size:.75rem;margin-top:6px;animation:pulse 1.4s infinite;}
@keyframes pulse{50%{opacity:.35}}
/* vignette cut-ins */
#vignette{position:absolute;inset:0;z-index:55;pointer-events:none;opacity:0;transition:opacity 1s;
display:flex;align-items:center;justify-content:center;}
#vignette.show{opacity:1;}
#vignetteInner{max-width:min(600px,88vw);text-align:center;background:rgba(8,10,24,.82);border:1px solid var(--line);
border-radius:20px;padding:30px 34px;backdrop-filter:blur(12px);}
#vigTitle{font-size:1.5rem;letter-spacing:.2em;color:var(--gold);margin-bottom:12px;text-transform:uppercase;}
#vigBody{line-height:1.6;color:var(--txt);}
/* ---------- Modals ---------- */
.modal{position:absolute;inset:0;z-index:45;display:flex;align-items:center;justify-content:center;background:rgba(5,7,16,.6);}
.mcard{width:min(860px,94vw);max-height:88vh;overflow-y:auto;background:var(--panel);border:1px solid var(--line);
border-radius:22px;padding:22px 26px;backdrop-filter:blur(18px);box-shadow:0 20px 80px rgba(0,0,0,.6);}
.mhead{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;}
.mhead h2{font-size:1.15rem;letter-spacing:.22em;text-transform:uppercase;color:var(--gold);}
.xbtn{background:none;border:1px solid var(--line);color:var(--txt);border-radius:10px;width:34px;height:34px;cursor:pointer;font-size:1rem;}
.tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
.tab{padding:.5em 1.2em;border-radius:999px;border:1px solid var(--line);background:rgba(255,255,255,.04);
cursor:pointer;font-size:.85rem;transition:.2s;}
.tab.on{background:rgba(255,215,122,.18);border-color:var(--gold);color:var(--gold);}
.menurow{display:flex;flex-direction:column;gap:10px;}
.bigbtn{padding:.85em 1.4em;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.05);
color:var(--txt);cursor:pointer;font-size:1rem;text-align:left;transition:.2s;font-family:inherit;}
.bigbtn:hover:not(:disabled){background:rgba(255,215,122,.14);border-color:var(--gold);transform:translateX(4px);}
.bigbtn:disabled{opacity:.35;cursor:default;}
/* creature cards */
.crow{display:flex;gap:12px;align-items:center;background:rgba(255,255,255,.045);border:1px solid var(--line);
border-radius:16px;padding:10px 14px;margin-bottom:10px;cursor:pointer;transition:.2s;flex-wrap:wrap;}
.crow:hover{border-color:var(--gold);}
.crow.active{border-color:var(--gold);box-shadow:0 0 18px rgba(255,215,122,.2);}
.crow.fainted{opacity:.5;}
.cportrait{width:56px;height:56px;border-radius:50%;flex-shrink:0;box-shadow:0 0 16px rgba(0,0,0,.5) inset, 0 0 12px currentColor;}
.cinfo{flex:1;min-width:180px;}
.cname{font-weight:700;display:flex;gap:8px;align-items:center;flex-wrap:wrap;}
.clvl{color:var(--dim);font-size:.8rem;font-weight:400;}
.chpbar{height:9px;border-radius:99px;background:rgba(0,0,0,.45);overflow:hidden;margin-top:6px;}
.chpfill{height:100%;border-radius:99px;background:var(--hp);transition:width .6s cubic-bezier(.3,1,.4,1);}
.ctag{font-size:.68rem;padding:2px 9px;border-radius:99px;border:1px solid currentColor;letter-spacing:.08em;}
.cmeta{font-size:.78rem;color:var(--dim);margin-top:4px;}
.hpnum{font-size:.78rem;color:var(--dim);}
/* battle */
#battleUI{position:absolute;inset:0;z-index:30;pointer-events:none;}
.bplate{position:absolute;background:var(--panel);border:1px solid var(--line);border-radius:16px;
backdrop-filter:blur(12px);padding:10px 16px;min-width:230px;box-shadow:0 6px 30px rgba(0,0,0,.5);pointer-events:auto;}
#enemyPlate{top:18px;left:18px;}
#allyPlate{bottom:150px;right:18px;}
.bpname{font-weight:700;display:flex;justify-content:space-between;gap:12px;align-items:center;}
.bpname .ctag{font-size:.62rem;}
.bhpbar{height:10px;border-radius:99px;background:rgba(0,0,0,.5);margin-top:7px;overflow:hidden;}
.bhpfill{height:100%;background:var(--hp);border-radius:99px;transition:width .55s cubic-bezier(.3,1,.4,1);}
.bmeta{display:flex;justify-content:space-between;font-size:.74rem;color:var(--dim);margin-top:4px;}
.enbar{height:5px;border-radius:99px;background:rgba(0,0,0,.5);margin-top:5px;overflow:hidden;}
.enfill{height:100%;background:#7ad7ff;border-radius:99px;transition:width .4s;}
#battleMenu{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(180deg,transparent,rgba(8,10,24,.92) 30%);
padding:70px 18px 16px;display:flex;gap:12px;justify-content:center;flex-wrap:wrap;pointer-events:auto;}
.bmove{min-width:150px;max-width:220px;padding:.7em .9em;border-radius:14px;border:1px solid var(--line);
background:rgba(255,255,255,.06);color:var(--txt);cursor:pointer;text-align:left;transition:.15s;font-family:inherit;backdrop-filter:blur(6px);}
.bmove:hover:not(:disabled){transform:translateY(-3px);border-color:var(--gold);}
.bmove:disabled{opacity:.35;cursor:default;}
.bmove .mvname{font-weight:600;font-size:.9rem;display:flex;justify-content:space-between;gap:8px;}
.bmove .mvmeta{font-size:.7rem;color:var(--dim);margin-top:3px;}
#battleLog{position:absolute;top:18px;left:50%;transform:translateX(-50%);width:min(560px,86vw);text-align:center;
z-index:31;pointer-events:none;}
.logline{display:inline-block;background:rgba(8,10,24,.85);border:1px solid var(--line);border-radius:12px;
padding:8px 20px;font-size:.92rem;animation:logIn .3s ease;backdrop-filter:blur(8px);}
@keyframes logIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1}}
.dmgfloat{position:absolute;z-index:35;font-weight:900;font-size:1.6rem;pointer-events:none;
animation:dfloat 1s ease-out forwards;text-shadow:0 2px 8px rgba(0,0,0,.8);}
@keyframes dfloat{0%{opacity:0;transform:translateY(10px) scale(.6)}15%{opacity:1;transform:translateY(0) scale(1.15)}
100%{opacity:0;transform:translateY(-58px) scale(1)}}
.statuschip{font-size:.6rem;padding:1px 7px;border-radius:99px;margin-left:4px;vertical-align:middle;}
/* capture mini-game */
#captureUI{position:absolute;inset:0;z-index:50;display:flex;flex-direction:column;align-items:center;justify-content:center;
background:rgba(5,7,16,.72);backdrop-filter:blur(4px);}
#capTitle{font-size:1.2rem;letter-spacing:.2em;color:var(--gold);margin-bottom:8px;text-transform:uppercase;}
#capBarWrap{width:min(520px,88vw);height:34px;border-radius:99px;border:1px solid var(--line);background:rgba(0,0,0,.5);
position:relative;overflow:hidden;margin:14px 0;}
#capZone{position:absolute;top:0;bottom:0;background:rgba(94,224,138,.35);border-left:1px solid var(--hp);border-right:1px solid var(--hp);}
#capMark{position:absolute;top:-4px;bottom:-4px;width:8px;border-radius:4px;background:#fff;box-shadow:0 0 12px #fff;}
#capTaps{color:var(--dim);font-size:.85rem;letter-spacing:.15em;}
#capMsg{margin-top:10px;font-size:.95rem;min-height:1.4em;}
/* toast */
#toast{position:absolute;top:70px;left:50%;transform:translateX(-50%);z-index:58;pointer-events:none;}
.toastmsg{background:rgba(8,10,24,.9);border:1px solid var(--gold);color:var(--gold);border-radius:12px;
padding:8px 22px;margin-bottom:8px;font-size:.9rem;animation:tIn .3s ease;letter-spacing:.05em;backdrop-filter:blur(8px);}
@keyframes tIn{from{opacity:0;transform:translateY(-10px)}to{opacity:1}}
/* dex grid */
.dexgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;}
.dexcard{background:rgba(255,255,255,.045);border:1px solid var(--line);border-radius:16px;padding:12px;}
.dexcard.unseen{opacity:.4;filter:grayscale(1);}
.dexcard .cname{margin-bottom:4px;}
.dexlore{font-size:.78rem;color:var(--dim);line-height:1.45;margin-top:6px;font-style:italic;}
/* map */
#mapGrid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;}
.zonecell{border:1px solid var(--line);border-radius:14px;padding:12px;text-align:center;font-size:.85rem;transition:.2s;}
.zonecell.cur{border-color:var(--gold);color:var(--gold);}
.zonecell.locked{opacity:.4;}
.zonecell.done{border-color:var(--hp);}
::-webkit-scrollbar{width:8px}::-webkit-scrollbar-thumb{background:rgba(140,170,255,.3);border-radius:8px}
@media(max-width:640px){.bplate{min-width:170px;padding:8px 10px}#allyPlate{bottom:190px}}
</style>
</head>
<body>
<canvas id="stage"></canvas>
<div id="hud" class="hidden">
<div class="hudcard"><div id="zoneName">—</div><div id="zoneSub" style="color:var(--dim);font-size:.72rem"></div></div>
<div id="hudBtns">
<button class="iconbtn" id="btnPause" title="Menu">☰</button>
<button class="iconbtn" id="btnMute" title="Sound">🔊</button>
</div>
</div>
<div id="touch">
<div class="tbtn-pad up" data-dir="up">▲</div><div class="tbtn-pad dn" data-dir="down">▼</div>
<div class="tbtn-pad lf" data-dir="left">◀</div><div class="tbtn-pad rt" data-dir="right">▶</div>
</div>
<div id="title">
<canvas id="titleStars"></canvas>
<h1>SUNDERVALE</h1>
<div class="sub">Binders of the Hollow Light</div>
<button class="tbtn" id="btnNew">New Binding</button>
<button class="tbtn" id="btnContinue">Continue</button>
<button class="tbtn hidden" id="btnSlot2">Continue — Slot II</button>
</div>
<div id="dlg" class="hidden"><div id="dlgSpeaker"></div><div id="dlgText"></div><div id="dlgHint">click / space ▸</div></div>
<div id="vignette"><div id="vignetteInner"><div id="vigTitle"></div><div id="vigBody"></div></div></div>
<div id="toast"></div>
<!-- Battle DOM -->
<div id="battleUI" class="hidden">
<div id="battleLog"></div>
<div class="bplate" id="enemyPlate"></div>
<div class="bplate" id="allyPlate"></div>
<div id="battleMenu"></div>
</div>
<div id="captureUI" class="hidden">
<div id="capTitle">Binding Rite</div>
<div id="capInstr" style="color:var(--dim);font-size:.85rem">Tap SPACE (or click) when the mark crosses the light — 3 times</div>
<div id="capBarWrap"><div id="capZone"></div><div id="capMark"></div></div>
<div id="capTaps">◆ ◇ ◇</div>
<div id="capMsg"></div>
</div>
<div id="modalRoot"></div>
<script>
"use strict";
/* ============================================================
SUNDERVALE — original creature-binding RPG. All IP original.
============================================================ */
/* ---------- Types ---------- */
const TYPES = {
Pyre: {c:'#ff7a4d'}, Brine:{c:'#4dc3ff'}, Loam: {c:'#8fd05a'},
Zephyr:{c:'#a9e8ff'}, Lumen:{c:'#ffe27a'}, Umbra:{c:'#c07aff'}, Aegis:{c:'#9fb3c8'}
};
const ATK_CHART = { // Pyre>Brine>Loam>Pyre ; Zephyr>Pyre&Loam ; Lumen>Zephyr&Brine
Pyre:{Brine:2,Loam:.5,Pyre:.5}, Brine:{Loam:2,Pyre:.5,Brine:.5}, Loam:{Pyre:2,Brine:.5,Loam:.5},
Zephyr:{Pyre:2,Loam:2,Zephyr:.5,Lumen:.5}, Lumen:{Zephyr:2,Brine:2,Lumen:.5,Umbra:.5},
Umbra:{Lumen:2,Aegis:.75}, Aegis:{Umbra:.75}
};
function typeMult(atk,def){
if(def==='Aegis') return atk==='Umbra'?1.9:(atk==='Aegis'?.6:.55); // Ward resists all, fears Umbra
if(atk==='Umbra'&&def!=='Umbra') return 1.25; // Umbra beats all slightly
return (ATK_CHART[atk]&&ATK_CHART[atk][def])||1;
}
function multLabel(m){return m>=1.9?'Devastating!':m>1.25?'Superb!':m<0.6?'Resisted…':m<=0.75?'Faded…':'';}
/* ---------- Abilities (48) ---------- */
// fx keys: burn,frost,shock,barrier,regen,heal%,drain,recoil,multi:[n],prio,crit,atkDown,defDown,spdUp,spdDown,selfHeal%
const MOVES = {};
function mv(id,name,type,pow,acc,cost,fx,desc){MOVES[id]={id,name,type,pow,acc,cost,fx:fx||{},desc};}
mv('esnap','Ember Snap','Pyre',40,100,4,{burn:.12},'May scorch the foe.');
mv('ashveil','Ash Veil','Pyre',0,999,4,{selfDef:1},'Raises own guard.');
mv('clance','Cinder Lance','Pyre',70,95,7,{burn:.18},'A piercing ember spear.');
mv('srupture','Solar Rupture','Pyre',95,90,11,{burn:.22},'Detonates stored sunlight.');
mv('pwaltz','Pyre Waltz','Pyre',55,100,6,{crit:2},'Dancing flames find weak points.');
mv('efan','Ember Fan','Pyre',24,95,7,{multi:[3]},'Strikes 3 times.');
mv('ssignet','Scorch Signet','Pyre',0,90,4,{atkDown:1},'Brands the foe, sapping strength.');
mv('msurge','Magma Surge','Pyre',85,90,10,{recoil:.15},'Costs the user some vitality.');
mv('tidecut','Tidecut','Brine',45,100,4,{},'A blade of dark water.');
mv('bcoil','Brine Coil','Brine',0,999,5,{regen:1},'Wraps the user in restoring brine.');
mv('uslam','Undertow Slam','Brine',75,90,8,{frost:.15},'Chills to the marrow.');
mv('amaw','Abyssal Maw','Brine',100,85,13,{frost:.18},'Bites with trench-cold teeth.');
mv('sspray','Salt Spray','Brine',35,100,3,{prio:1},'Always strikes first.');
mv('hbite','Hoarfrost Bite','Brine',60,100,7,{frost:.3},'Freezing fangs slow the foe.');
mv('mirrorfrost','Mirrorfrost','Brine',0,999,5,{barrier:1},'Ice mirror deflects one blow.');
mv('tcrash','Tidal Crash','Brine',92,85,11,{recoil:.12},'Crashes down at a cost.');
mv('lfist','Loam Fist','Loam',45,100,4,{},'A fist of packed earth.');
mv('rootbind','Rootbind','Loam',0,95,5,{spdDown:1},'Roots entangle the foe.');
mv('brush','Boulder Rush','Loam',70,90,7,{},'An unstoppable rolling stone.');
mv('qheart','Quakeheart','Loam',95,85,12,{},'The ground remembers violence.');
mv('sporedrift','Sporedrift','Loam',30,100,4,{atkDown:.5},'Drifting spores dull fury.');
mv('svolley','Shard Volley','Loam',25,90,8,{multi:[2,4]},'Strikes 2–4 times.');
mv('lifebloom','Lifebloom','Loam',0,999,8,{heal:.45},'Blooming moss knits wounds.');
mv('zslash','Zephyr Slash','Zephyr',45,100,4,{prio:1},'A cut that arrives before the wind.');
mv('windlance','Windlance','Zephyr',65,95,6,{spdUp:1},'Quickens the user.');
mv('tcall','Tempest Call','Zephyr',90,85,11,{},'Summons a screaming gale.');
mv('fguard','Featherguard','Zephyr',0,999,5,{barrier:1},'A whirl of feathers turns one blow.');
mv('psnare','Sparksnare','Zephyr',40,100,4,{shock:.25},'A crackling snare may stun.');
mv('ccyclone','Cyclone Peck','Zephyr',20,95,7,{multi:[3]},'Strikes 3 times.');
mv('lray','Lumen Ray','Lumen',45,100,4,{},'A thread of pure dawnlight.');
mv('pflare','Prism Flare','Lumen',80,90,9,{shock:.15},'Refracted light stuns nerves.');
mv('radiance','Radiance','Lumen',0,999,8,{heal:.4},'Warm light restores vitality.');
mv('jglow','Judgment Glow','Lumen',105,85,14,{},'The sky passes sentence.');
mv('starlance','Starlance','Lumen',55,100,6,{crit:2},'Falls true and hard.');
mv('iburst','Ion Burst','Lumen',70,95,8,{shock:.2},'Arcing charge may stun.');
mv('gstep','Glimmer Step','Lumen',0,999,4,{prio:1,spdUp:1},'Vanishes, then reappears renewed.');
mv('uclaw','Umbra Claw','Umbra',50,100,5,{crit:2},'Claws of quiet night.');
mv('nightfall','Nightfall','Umbra',0,95,6,{defDown:1},'Gloom erodes armor.');
mv('efang','Eclipse Fang','Umbra',85,90,10,{drain:.5},'Drinks half the harm it deals.');
mv('vhowl','Void Howl','Umbra',100,80,13,{},'A howl from beneath the world.');
mv('gcelerity','Grim Celerity','Umbra',40,100,3,{prio:1},'Death is always early.');
mv('redge','Ruinous Edge','Umbra',75,95,9,{},'A blade honed on endings.');
mv('awall','Aegis Wall','Aegis',0,999,5,{barrier:1,selfDef:1},'Stone bulwark; deflects one blow.');
mv('bbash','Bulwark Bash','Aegis',55,100,6,{},'Slams with shield-weight.');
mv('sring','Sanctum Ring','Aegis',0,999,7,{regen:1},'Halo of mending calm.');
mv('tpress','Titan Press','Aegis',90,85,11,{},'Mountains fall slowly, but they fall.');
mv('sfall','Stonefall','Aegis',40,90,7,{multi:[2]},'Strikes twice.');
mv('vcoil','Voltaic Coil','Umbra',60,95,7,{shock:.3},'Coiled storm-magic may stun.');
/* ---------- Creatures ---------- */
// base: hp, atk, def, spd ; learn:[[lvl,move]..]; tr:{lvl,item,id,name} transform
function cr(id,name,types,color,base,learn,tr,lore){
CREATURES[id]={id,name,types,color,base,learn,tr,lore};
}
const CREATURES={};
cr('cindercub','Cindercub',['Pyre'],'#ff7a4d',{hp:44,atk:52,def:40,spd:50},
[[1,'esnap'],[5,'pwaltz'],[9,'efan'],[14,'clance'],[20,'ssignet'],[26,'msurge'],[32,'srupture']],
{lvl:28,item:'cinderheart',id:'pyrelash',name:'Pyrelash'},
'A cub born in the ash-falls of Cinderpeak. Its fur smolders gently when it dreams.');
cr('pyrelash','Pyrelash',['Pyre'],'#ff5230',{hp:70,atk:84,def:62,spd:76},
[[28,'clance'],[34,'msurge'],[40,'srupture']],
null,'Its mane is a slow fire that has never gone out. Old binders say it carries a piece of the first sunrise.');
cr('mirelet','Mirelet',['Brine'],'#4dc3ff',{hp:46,atk:46,def:48,spd:44},
[[1,'tidecut'],[5,'sspray'],[10,'bcoil'],[15,'hbite'],[21,'uslam'],[27,'mirrorfrost'],[33,'amaw'],[38,'tcrash']],
{lvl:30,item:'tidevein',id:'tidewrath',name:'Tidewrath'},
'It drifts through Murkmire like a thought through sleep. Fishers leave it pearls as toll.');
cr('tidewrath','Tidewrath',['Brine'],'#2fa0e0',{hp:74,atk:78,def:72,spd:66},
[[30,'uslam'],[36,'tcrash'],[42,'amaw']],
null,'Where Tidewrath surfaces, the marsh holds its breath. It wears a crown of drowned moonlight.');
cr('mossling','Mossling',['Loam'],'#8fd05a',{hp:52,atk:44,def:54,spd:38},
[[1,'lfist'],[6,'sporedrift'],[11,'rootbind'],[16,'brush'],[22,'lifebloom'],[28,'svolley'],[34,'qheart']],
{lvl:29,item:'verdantcore',id:'thornmaw',name:'Thornmaw'},
'A walking hedge with a patient heart. It naps for weeks and wakes hungry for sunlight.');
cr('thornmaw','Thornmaw',['Loam'],'#5ea832',{hp:86,atk:74,def:82,spd:52},
[[29,'brush'],[35,'qheart'],[41,'svolley']],
null,'A fortress of thorns that blooms once a century. The bloom is beautiful; do not approach it.');
cr('whifflit','Whifflit',['Zephyr'],'#a9e8ff',{hp:40,atk:46,def:38,spd:62},
[[1,'zslash'],[6,'psnare'],[12,'windlance'],[18,'ccyclone'],[24,'fguard'],[30,'tcall']],
{lvl:31,item:'galesigil',id:'stormcrest',name:'Stormcrest'},
'Born where two winds argue. Whifflits race the horizon for sport and almost always win.');
cr('stormcrest','Stormcrest',['Zephyr'],'#7cd0f0',{hp:64,atk:80,def:58,spd:92},
[[31,'windlance'],[37,'tcall'],[43,'ccyclone']],
null,'Its wings carve canals through clouds. Sailors of the Vale navigate by its wake.');
cr('lumkin','Lumkin',['Lumen'],'#ffe27a',{hp:42,atk:50,def:42,spd:52},
[[1,'lray'],[6,'starlance'],[12,'gstep'],[18,'pflare'],[24,'radiance'],[31,'iburst'],[37,'jglow']],
{lvl:32,item:'dawnshard',id:'seraphlux',name:'Seraphlux'},
'A mote of morning that chose to grow eyes. It hums lullabies to lost travelers.');
cr('seraphlux','Seraphlux',['Lumen'],'#ffd042',{hp:68,atk:86,def:64,spd:74},
[[32,'iburst'],[38,'jglow'],[44,'starlance']],
null,'Six wings of folded daylight. To meet one is to be forgiven something.');
cr('shadeling','Shadeling',['Umbra'],'#c07aff',{hp:44,atk:54,def:40,spd:56},
[[1,'uclaw'],[6,'gcelerity'],[12,'nightfall'],[18,'redge'],[24,'vcoil'],[30,'efang'],[36,'vhowl']],
{lvl:33,item:'duskshard',id:'voidmane',name:'Voidmane'},
'It is the shadow a candle throws when no one watches. Playful, in the way of riddles.');
cr('voidmane','Voidmane',['Umbra'],'#9a4de0',{hp:70,atk:90,def:60,spd:80},
[[33,'redge'],[39,'efang'],[45,'vhowl']],
null,'A predator stitched from eclipse-light. Its roar tastes of cold iron and secrets.');
cr('gravelim','Gravelim',['Aegis'],'#9fb3c8',{hp:58,atk:42,def:62,spd:30},
[[1,'bbash'],[7,'awall'],[13,'sring'],[19,'sfall'],[26,'tpress']],
{lvl:34,item:'titancore',id:'bastionaut',name:'Bastionaut'},
'A pebble that refused to stay small. Gravelims guard shrine doors for centuries unpaid.');
cr('bastionaut','Bastionaut',['Aegis'],'#7d93ab',{hp:96,atk:66,def:98,spd:40},
[[34,'sfall'],[40,'tpress'],[46,'bbash']],
null,'Living rampart of the old Vale-kings. Whole gardens have been grown upon its shoulders.');
cr('emberox','Emberox',['Pyre','Zephyr'],'#ff9a5c',{hp:50,atk:56,def:44,spd:58},
[[1,'esnap'],[7,'zslash'],[13,'pwaltz'],[19,'clance'],[25,'windlance'],[31,'msurge'],[37,'tcall']],
null,'A horned grazer whose breath feeds its own heat-shimmer. Herds paint the dusk orange.');
cr('bramblehorn','Bramblehorn',['Loam','Pyre'],'#a8c060',{hp:62,atk:58,def:58,spd:42},
[[1,'lfist'],[8,'sporedrift'],[15,'brush'],[22,'ssignet'],[29,'lifebloom'],[36,'qheart']],
null,'Its antlers are living bramble; songbirds nest in them unbothered. It tolerates this with grace.');
cr('coralid','Coralid',['Brine','Loam'],'#63d0b0',{hp:54,atk:46,def:60,spd:36},
[[1,'tidecut'],[8,'lfist'],[14,'bcoil'],[20,'uslam'],[27,'rootbind'],[33,'amaw']],
null,'Reef-grown and slow-speaking. Coralids remember every tide since the Sundering.');
cr('reefknight','Reefknight',['Brine','Loam'],'#3aa88a',{hp:80,atk:66,def:80,spd:46},
[[1,'bbash'],[10,'tidecut'],[18,'brush'],[26,'mirrorfrost'],[34,'qheart'],[42,'tpress']],
null,'Armor of fused coral, oath of still water. It kneels only to the drowned moon.');
cr('sparkmoth','Sparkmoth',['Lumen','Umbra'],'#e8c0ff',{hp:40,atk:52,def:36,spd:64},
[[1,'lray'],[7,'gcelerity'],[13,'starlance'],[19,'pflare'],[25,'vcoil'],[31,'iburst']],
null,'Wings of dust and static. It is drawn to lanterns out of lonely curiosity.');
cr('gleamoth','Gleamoth',['Lumen','Umbra'],'#cba0f5',{hp:60,atk:78,def:52,spd:84},
[[1,'uclaw'],[12,'gstep'],[22,'redge'],[32,'jglow'],[42,'iburst']],
null,'A twilight aristocrat. Gleamoths duel at dusk over nothing, elegantly.');
cr('duskrat','Duskrat',['Umbra','Aegis'],'#8a86b8',{hp:48,atk:50,def:52,spd:50},
[[1,'uclaw'],[8,'bbash'],[14,'nightfall'],[20,'awall'],[27,'redge'],[34,'efang']],
null,'Sews its coat from dropped shadows. Surprisingly dependable in a crisis.');
cr('umbratine','Umbratine',['Umbra','Aegis'],'#6a6699',{hp:74,atk:72,def:76,spd:60},
[[1,'bbash'],[14,'redge'],[24,'awall'],[34,'vhowl'],[44,'tpress']],
null,'A knight-errant of the Gloamwood. Its oaths outlive the people who heard them.');
cr('glacilisk','Glacilisk',['Brine','Aegis'],'#a8dcE8'.toLowerCase(),{hp:56,atk:48,def:58,spd:44},
[[1,'hbite'],[8,'sspray'],[15,'mirrorfrost'],[22,'uslam'],[29,'sring'],[36,'amaw']],
null,'Its blood runs at the temperature of forgiveness. Very cold, in other words.');
cr('permafort','Permafort',['Brine','Aegis'],'#7fc4d8',{hp:88,atk:64,def:88,spd:42},
[[1,'awall'],[16,'hbite'],[26,'tcrash'],[36,'sfall'],[46,'tpress']],
null,'A glacier that decided to walk. It arrives somewhere new once per era.');
cr('wispurr','Wispurr',['Lumen','Zephyr'],'#fff0b8',{hp:38,atk:48,def:36,spd:66},
[[1,'lray'],[7,'zslash'],[13,'gstep'],[19,'starlance'],[26,'fguard'],[33,'jglow']],
null,'A purring curl of windlit fluff. Wispurrs adopt travelers rather than the reverse.');
cr('luminarch','Luminarch',['Lumen','Zephyr'],'#ffe89a',{hp:62,atk:76,def:54,spd:90},
[[1,'gstep'],[14,'ccyclone'],[24,'pflare'],[34,'tcall'],[44,'jglow']],
null,'It archives every sunrise in rings of pale feathers. Nothing it blesses stays lost.');
cr('ashwyrm','Ashwyrm',['Pyre','Umbra'],'#d06a50',{hp:52,atk:60,def:46,spd:52},
[[1,'esnap'],[8,'uclaw'],[15,'clance'],[22,'nightfall'],[29,'msurge'],[36,'vhowl']],
{lvl:36,item:'stormsigil',id:'pyroclast',name:'Pyroclast'},
'Hatched in a lightning-lit cinder field. Ashwyrms singe the air when they yawn.');
cr('pyroclast','Pyroclast',['Pyre','Umbra'],'#ff4a3a',{hp:78,atk:94,def:64,spd:74},
[[36,'msurge'],[42,'vhowl'],[48,'srupture']],
null,'The last breath of a burnt star, given scales. Pyroclast sleeps only after great victories.');
/* ---------- Items ---------- */
const ITEMS={
bindstone:{name:'Bindstone',kind:'capture',tier:1,desc:'A humming crystal for binding wild creatures.',price:120},
runestone:{name:'Runed Bindstone',kind:'capture',tier:1.4,desc:'Etched with older, hungrier script.',price:300},
astrastone:{name:'Astral Bindstone',kind:'capture',tier:1.9,desc:'Cut from a fallen star\'s heart.',price:700},
dewvial:{name:'Dewvial',kind:'heal',amt:60,desc:'Restores 60 HP to one creature.',price:80},
amberflask:{name:'Amberflask',kind:'heal',amt:150,desc:'Restores 150 HP to one creature.',price:220},
revivalbloom:{name:'Revivalbloom',kind:'revive',desc:'Revives a fainted creature at half HP.',price:400},
glimmerdraft:{name:'Glimmerdraft',kind:'energy',desc:'Fully restores all abilities of one creature.',price:180},
waycompass:{name:'Waycompass',kind:'repel',steps:60,desc:'Wild creatures avoid you for ~60 steps.',price:150},
cinderheart:{name:'Cinderheart',kind:'catalyst',desc:'Catalyst: attunes certain creatures.',price:900},
tidevein:{name:'Tidevein Pearl',kind:'catalyst',desc:'Catalyst: attunes certain creatures.',price:900},
verdantcore:{name:'Verdantcore Seed',kind:'catalyst',desc:'Catalyst: attunes certain creatures.',price:900},
galesigil:{name:'Galesigil Feather',kind:'catalyst',desc:'Catalyst: attunes certain creatures.',price:900},
dawnshard:{name:'Dawnshard',kind:'catalyst',desc:'Catalyst: attunes certain creatures.',price:900},
duskshard:{name:'Duskshard',kind:'catalyst',desc:'Catalyst: attunes certain creatures.',price:900},
titancore:{name:'Titancore Slab',kind:'catalyst',desc:'Catalyst: attunes certain creatures.',price:900},
stormsigil:{name:'Stormsigil',kind:'catalyst',desc:'Catalyst: attunes certain creatures.',price:1200}
};
/* ---------- Zones ---------- */
const ZONES=[
{id:0,name:'Emberfen Meadow',sub:'Where every binding begins',
grad:['#2c4a2e','#16281c'],part:'leaf',pc:'#a8d060',enc:.14,lvl:[3,6],
story:'The Meadow hums. Somewhere north, the Hollow Light waits.',
guardian:null,
shop:['bindstone','bindstone','dewvial','waycompass']},
{id:1,name:'Murkmire Marsh',sub:'Slow water, older grudges',
grad:['#1e3a44','#101d26'],part:'mist',pc:'#6fc3d8',enc:.17,lvl:[7,11],
story:'The Marsh keeps what the river forgets.',
guardian:{name:'Marshwarden Oduun',team:[['coralid',12],['mirelet',11]],bonus:'atkp',blurb:'Oduun tends the drowned lanterns. "Prove your binding," she says, "and pass."',
win:'The lanterns flare green. The path to the Ascent opens.'},anchor:true,
shop:['bindstone','runestone','dewvial','amberflask','revivalbloom']},
{id:2,name:'Cinderpeak Ascent',sub:'The mountain remembers fire',
grad:['#3a2020','#1a0f10'],part:'ember',pc:'#ff8a50',enc:.18,lvl:[14,19],
story:'Heat rises from cracks older than maps.',
guardian:{name:'Kindler Vash',team:[['emberox',20],['ashwyrm',21]],bonus:'hpp',blurb:'Vash warms his hands on the air itself. "The Peak crowns only the stubborn."',
win:'The Peak exhales. A stair of glass opens toward the sky.'},anchor:true,
shop:['runestone','amberflask','dewvial','revivalbloom','glimmerdraft','cinderheart','verdantcore']},
{id:3,name:'Skysunder Terrace',sub:'Steps cut for larger feet',
grad:['#20304e','#0e1428'],part:'star',pc:'#bcd8ff',enc:.18,lvl:[22,28],
story:'Above the cloud-line, the wind speaks in complete sentences.',
guardian:{name:'Skywarden Isolde',team:[['whifflit',27],['wispurr',28],['stormcrest',29]],bonus:'catchp',blurb:'Isolde has not touched ground in nine years. "Dance with my flock, binder."',
win:'The flock parts like a curtain. The Gloamwood yawns below.'},anchor:true,
shop:['runestone','astrastone','amberflask','revivalbloom','glimmerdraft','galesigil','dawnshard']},
{id:4,name:'Gloamwood Depths',sub:'Where light goes to rest',
grad:['#241a3a','#0d0918'],part:'spore',pc:'#b08aff',enc:.2,lvl:[30,36],
story:'The trees drink the dusk and grow thoughtful.',
guardian:{name:'Hollow Knight Sable',team:[['duskrat',35],['umbratine',37]],bonus:'atkp',blurb:'Sable\'s armor drinks the gloom. "One anchor remains between you and the Gate."',
win:'Sable kneels. Far off, the Auroric Gate begins to glow.'},anchor:true,
shop:['astrastone','amberflask','revivalbloom','glimmerdraft','duskshard','titancore','stormsigil','tidevein']},
{id:5,name:'The Auroric Gate',sub:'Endings and beginnings share a door',
grad:['#3a2450','#141024'],part:'aurora',pc:'#ffd77a',enc:.22,lvl:[38,44],
story:'The Gate stirs. The Hollow Light gathers behind it.',
guardian:{name:'The Hollow Light',team:[['pyroclast',44],['seraphlux',43],['voidmane',44]],final:true,
blurb:'Not a creature — a hunger wearing radiance. It speaks with your own voice.',
win:'The Light unravels into ten thousand motes. The Vale breathes. You are the Binder of the Hollow Light now.'},
shop:['astrastone','astrastone','amberflask','revivalbloom','glimmerdraft']}
];
/* ---------- Story beats (4 stages) ---------- */
const STORY=[
{t:'I. The Ember Given',b:'You wake at the meadow shrine with a warm crystal in your palm and no memory of receiving it. A voice like distant bells: "Bind, little spark. The Hollow Light is eating the world politely."'},
{t:'II. Anchors of the Vale',b:'Four anchors hold the Hollow Light outside the Gate. Marsh, Peak, Sky, Gloam — each guarded by a warden who chose duty over freedom long ago.'},
{t:'III. The Long Gloom',b:'Half the anchors stand broken by your hand — freed, not destroyed. The wardens bow as you pass. The Gloamwood grows quiet, listening.'},
{t:'IV. The Door',b:'All four anchors are yours. The Auroric Gate opens inward, the way doors do when something inside wants out. You go in anyway. That was always the job.'}
];
/* ============ Core state & helpers ============ */
const $=s=>document.querySelector(s);
const rnd=(a,b)=>a+Math.random()*(b-a);
const ri=(a,b)=>Math.floor(rnd(a,b+1));
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const pick=a=>a[Math.floor(Math.random()*a.length)];
function toast(msg){const d=document.createElement('div');d.className='toastmsg';d.textContent=msg;
$('#toast').appendChild(d);setTimeout(()=>{d.style.opacity=0;d.style.transition='opacity .5s';setTimeout(()=>d.remove(),500)},2200);}
const LEVEL_CAP=50;
function statAt(base,lvl,mult){return Math.floor(base*mult*(1+(lvl-1)*0.055));}
function hpAt(base,lvl){return Math.floor(base*2.2*(1+(lvl-1)*0.075));}
function expToLvl(lvl){return Math.floor(12*Math.pow(lvl,1.7));}
function makeCreature(id,lvl){
const c=CREATURES[id];
const mon={uid:Math.random().toString(36).slice(2),id,name:c.name,types:c.types.slice(),lvl,
maxhp:hpAt(c.base.hp,lvl),hp:hpAt(c.base.hp,lvl),
atk:statAt(c.base.atk,lvl,1),def:statAt(c.base.def,lvl,1),spd:statAt(c.base.spd,lvl,1),
exp:0,energy:{},transed:false};
c.learn.forEach(([l,m])=>{if(l<=lvl)mon.energy[m]=MOVES[m].cost*4;});
mon.moves=c.learn.filter(l=>l[0]<=lvl).map(l=>l[1]);
return mon;
}
function creatureMoves(mon){return mon.moves.map(m=>MOVES[m]);}
function healAll(party){party.forEach(m=>{m.hp=m.maxhp;Object.keys(MOVES).forEach(k=>{if(m.moves.includes(k))m.energy[k]=MOVES[k].cost*4;});});}
/* ---------- Save ---------- */
const SAVE_KEY='sundervale_save_v1';
let G=null; // game state
function newGame(){
G={zone:0,px:8,py:8,dir:'down',steps:0,party:[],box:[],bag:{bindstone:3,dewvial:3},
gold:500,seen:new Set(),caught:new Set(),anchors:0,story:0,postgame:false,repel:0,flags:{},slot:1};
}
function saveGame(slot){ if(!G)return;
const s={...G,seen:[...G.seen],caught:[...G.caught]};
localStorage.setItem(SAVE_KEY+(slot||G.slot||1),JSON.stringify(s)); }
function loadGame(slot){
try{const raw=localStorage.getItem(SAVE_KEY+slot);if(!raw)return false;
const s=JSON.parse(raw);s.seen=new Set(s.seen);s.caught=new Set(s.caught);s.slot=slot||1;G=s;return true;
}catch(e){return false;}
}
function hasSave(slot){return !!localStorage.getItem(SAVE_KEY+slot);}
/* ---------- Audio (WebAudio generative) ---------- */
const AU={ctx:null,on:true,padTimer:null,curZone:-1};
function auInit(){ if(AU.ctx)return;
try{AU.ctx=new (window.AudioContext||window.webkitAudioContext)();AU.master=AU.ctx.createGain();
AU.master.gain.value=.16;AU.master.connect(AU.ctx.destination);}catch(e){}
}
const ZONE_SCALES=[[220,261.6,293.7,349.2,392],[196,233,261.6,311,349.2],[174.6,207.7,233,277.2,311],
[246.9,293.7,329.6,392,440],[164.8,196,220,261.6,293.7],[261.6,311,349.2,415.3,466.2]];
function padNote(freq,dur,type){
if(!AU.ctx||!AU.on)return; const t=AU.ctx.currentTime;
const o=AU.ctx.createOscillator(),g=AU.ctx.createGain(),f=AU.ctx.createBiquadFilter();
o.type=type||'sine';o.frequency.value=freq;f.type='lowpass';f.frequency.value=900;
g.gain.setValueAtTime(0,t);g.gain.linearRampToValueAtTime(.5,t+dur*.3);g.gain.linearRampToValueAtTime(0,t+dur);
o.connect(f);f.connect(g);g.connect(AU.master);o.start(t);o.stop(t+dur+.05);
}
function startPad(zone){
auInit(); if(AU.curZone===zone&&AU.padTimer)return; AU.curZone=zone;
clearInterval(AU.padTimer); if(!AU.ctx)return;
const sc=ZONE_SCALES[clamp(zone,0,5)];
let i=0;
AU.padTimer=setInterval(()=>{ if(!AU.on)return;
padNote(sc[i%sc.length]*(i%4<2?1:2),3.5,'triangle');
if(Math.random()<.4)padNote(sc[ri(0,sc.length-1)]/2,4,'sine');
i++;
},1600);
}
function sfx(kind){
if(!AU.ctx||!AU.on)return; const t=AU.ctx.currentTime,o=AU.ctx.createOscillator(),g=AU.ctx.createGain();
o.connect(g);g.connect(AU.master);
const P={hit:[['square',180,60,.18]],crit:[['square',300,90,.22]],heal:[['sine',520,880,.25]],
cap:[['triangle',440,660,.2]],fail:[['sawtooth',200,80,.25]],ui:[['sine',700,700,.06]],
step:[['sine',300,260,.03]],win:[['triangle',330,660,.4]]}[kind];
if(!P)return;
const [w,f1,f2,d]=P[0];
o.type=w;o.frequency.setValueAtTime(f1,t);o.frequency.exponentialRampToValueAtTime(Math.max(f2,20),t+d);
g.gain.setValueAtTime(.35,t);g.gain.exponentialRampToValueAtTime(.001,t+d);
o.start(t);o.stop(t+d+.02);
}
/* ---------- Particles ---------- */
const parts=[];
function spawnPart(x,y,c,n,spd){
for(let i=0;i<(n||10);i++){const a=rnd(0,Math.PI*2),s=rnd(.4,(spd||3));
parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-1,life:rnd(.5,1.1),c,sz:rnd(2,5)});}
}
function updParts(dt){
for(let i=parts.length-1;i>=0;i--){const p=parts[i];p.x+=p.vx*dt*60;p.y+=p.vy*dt*60;p.vy+=.04;p.life-=dt;
if(p.life<=0)parts.splice(i,1);}
}
/* ---------- Canvas setup ---------- */
const cv=$('#stage'),cx=cv.getContext('2d');
let VW=innerWidth,VH=innerHeight,DPR=Math.min(devicePixelRatio||1,2);
function resize(){VW=innerWidth;VH=innerHeight;DPR=Math.min(devicePixelRatio||1,2);
cv.width=VW*DPR;cv.height=VH*DPR;cv.style.width=VW+'px';cv.style.height=VH+'px';cx.setTransform(DPR,0,0,DPR,0,0);}
addEventListener('resize',resize);resize();
/* title starfield */
(function(){const tc=$('#titleStars'),tx=tc.getContext('2d');let stars=[];
function tsize(){tc.width=innerWidth;tc.height=innerHeight;stars=[];for(let i=0;i<140;i++)stars.push({x:Math.random()*tc.width,y:Math.random()*tc.height,r:Math.random()*1.6+.3,tw:Math.random()*6});}
tsize();addEventListener('resize',tsize);
setInterval(()=>{if($('#title').classList.contains('hidden'))return;
tx.clearRect(0,0,tc.width,tc.height);const t=Date.now()/1000;
stars.forEach(s=>{tx.globalAlpha=.3+.7*Math.abs(Math.sin(t+s.tw));tx.fillStyle='#cfe0ff';
tx.beginPath();tx.arc(s.x,s.y,s.r,0,7);tx.fill();});
tx.globalAlpha=1;},50);
})();
/* ============ Overworld ============ */
const TS=40, MAPW=44, MAPH=32;
let worldMaps={};
function seededRng(seed){let s=seed;return()=>{s=(s*1103515245+12345)&0x7fffffff;return s/0x7fffffff;};}
function genMap(zone){
const r=seededRng(1000+zone*77);
const m=[]; for(let y=0;y<MAPH;y++){m.push([]);for(let x=0;x<MAPW;x++){
let t=0;
if(x<2||y<2||x>=MAPW-2||y>=MAPH-2)t=1;
else if(r()<.09)t=(r()<.4?7:1); // rocks/trees
else if(r()<.05)t=2; // water
else if(r()<.22)t=5; // tall grass
m[y].push(t);
}}
// clear spawn & paths
const cx0=Math.floor(MAPW/2);
for(let y=2;y<MAPH-2;y++)for(let dx=-3;dx<=3;dx++)if(m[y]&&m[y][cx0+dx]!==undefined&&m[y][cx0+dx]!==2)m[y][cx0+dx]=(y%4===0?3:0);
// shrine near center-south
m[MAPH-6][cx0]=4;
// guardian spot north-center
m[5][cx0]=8;
// gates at top & bottom
m[2][cx0]=9;
if(MAPH-3<MAPH-2)m[MAPH-3][cx0]=9;
// carve around key tiles
[[MAPH-6,cx0],[5,cx0],[2,cx0]].forEach(([y,x])=>{for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){
const yy=y+dy,xx=x+dx;if(yy>1&&yy<MAPH-2&&xx>1&&xx<MAPW-2&&m[yy][xx]===2)m[yy][xx]=0;}});
return m;
}
function mapFor(z){if(!worldMaps[z])worldMaps[z]=genMap(z);return worldMaps[z];}
const keys={};
addEventListener('keydown',e=>{
keys[e.key.toLowerCase()]=true;
if(e.key===' '){e.preventDefault();onSpace();}
if(['arrowup','arrowdown','arrowleft','arrowright'].includes(e.key.toLowerCase()))e.preventDefault();
if(e.key.toLowerCase()==='escape'&&MODE==='world')openPause();
if(e.key.toLowerCase()==='m'&&MODE==='world'&&!anyModal())openPause();
});
addEventListener('keyup',e=>keys[e.key.toLowerCase()]=false);
// touch pad
document.querySelectorAll('#touch .tbtn-pad').forEach(b=>{
const d=b.dataset.dir;
const on=e=>{e.preventDefault();keys[{up:'arrowup',down:'arrowdown',left:'arrowleft',right:'arrowright'}[d]]=true;};
const off=e=>{e.preventDefault();keys[{up:'arrowup',down:'arrowdown',left:'arrowleft',right:'arrowright'}[d]]=false;};
b.addEventListener('touchstart',on);b.addEventListener('touchend',off);
b.addEventListener('mousedown',on);b.addEventListener('mouseup',off);b.addEventListener('mouseleave',off);
});
if('ontouchstart' in window)document.body.classList.add('touchmode');
const WALKABLE=new Set([0,3,4,5]);
let MODE='title'; // title | world | battle | dialog | modal
let camShake=0;
function tryMove(dx,dy){
if(MODE!=='world'||!G)return;
G.dir=dx<0?'left':dx>0?'right':dy<0?'up':'down';
const nx=G.px+dx,ny=G.py+dy,m=mapFor(G.zone);
const t=m[ny]&&m[ny][nx];
if(t===undefined)return;
if(t===9){ // gate between zones
if(dy===-1){
if(G.zone>=5){toast('The Gate hums behind you.');return;}
const nz=G.zone+1;
if(ZONES[G.zone].anchor&&!G.flags['boss'+G.zone]){toast('Sealed. Free this zone\'s anchor first.');return;}
transitionZone(nz,true);
} else if(dy===1&&G.zone>0){transitionZone(G.zone-1,false);}
return;
}
if(t===8){interactGuardian();return;}
if(!WALKABLE.has(t))return;
G.px=nx;G.py=ny;G.steps++;
if(G.repel>0)G.repel--;
sfx('step');
if(t===4){interactShrine();return;}
if(t===5&&Math.random()<ZONES[G.zone].enc&&(G.repel<=0)){
startWildBattle();
}
}
function canEnterZone(z){return z===0||!!G.flags['boss'+(z-1)];}
function interactShrine(){
MODE='dialog'; healAll(G.party); G.flags.lastShrineZone=G.zone; saveGame();
dialogue('Wayshrine','The shrine\'s cool light knits your party back together. Progress saved. The keeper\'s stall is open.',()=>{
openShop(); MODE='world';
});
}
function interactGuardian(){
const z=ZONES[G.zone];
if(!z.guardian){toast('An empty plinth. Its guardian walked into legend long ago.');return;}
if(G.flags['boss'+G.zone]){toast('This anchor already stands freed.');return;}
MODE='dialog';
dialogue(z.guardian.name,z.guardian.blurb,()=>{startBattle({kind:'boss'});});
}
function transitionZone(nz,atSouth){
G.zone=nz; G.py = atSouth?MAPH-4:3; G.px=Math.floor(MAPW/2)+ri(-1,1);
$('#zoneName').textContent=ZONES[nz].name; $('#zoneSub').textContent=ZONES[nz].sub;
startPad(nz);
parts.length=0;
checkStory();
toast(ZONES[nz].name);
}
function checkStory(){
const anchors=G.anchors;
let st=0;
if(anchors>=4)st=3;else if(anchors>=2)st=2;else if(anchors>=1)st=1;
if(st>G.story){G.story=st;showVignette(STORY[st]);}
else if(G.story===0&&G.party.length>0&&!G.flags.story0){G.flags.story0=1;showVignette(STORY[0]);}
}
function showVignette(s){
MODE='dialog';
const v=$('#vignette');$('#vigTitle').textContent=s.t;$('#vigBody').textContent=s.b;
v.classList.add('show');
setTimeout(()=>{ v.classList.remove('show');
setTimeout(()=>{MODE='world';},600);
},4200);
}
/* ---------- Dialogue queue ---------- */
let dlgQueue=[],dlgCb=null;
function dialogue(speaker,text,cb){
MODE='dialog';dlgQueue.push({speaker,text,cb});nextDlg();
}
function nextDlg(){
const d=$('#dlg');
if(!dlgQueue.length){d.classList.add('hidden');if(dlgCb){const c=dlgCb;dlgCb=null;c();}return;}
const {speaker,text,cb}=dlgQueue.shift();
dlgCb=cb||null;
$('#dlgSpeaker').textContent=speaker;$('#dlgText').textContent=text;
d.classList.remove('hidden');
}
$('#dlg').addEventListener('click',()=>{if(MODE==='dialog'){sfx('ui');nextDlg();}});
function onSpace(){ auInit();
if(MODE==='dialog'){nextDlg();return;}
if(capGame.active){capTap();return;}
}
/* ---------- Overworld render ---------- */
function tileColor(t,z){
switch(t){
case 0:return z===2?'#4a3030':z===4?'#241c38':'#2e4630';
case 1:return z===2?'#2a1a18':z===4?'#171126':'#1d3020';
case 2:return '#274a66';
case 3:return z===2?'#5a4038':'#4a4436';
case 4:return '#ffd77a';
case 5:return z===2?'#5a3528':z===4?'#332450':'#3a5a34';
case 7:return '#555f6a';
case 8:return '#c07aff';
case 9:return '#ffe89a';
} return '#000';
}
function drawWorld(dt,time){
const z=ZONES[G.zone],m=mapFor(G.zone);
const g=cx.createLinearGradient(0,0,0,VH);g.addColorStop(0,z.grad[0]);g.addColorStop(1,z.grad[1]);
cx.fillStyle=g;cx.fillRect(0,0,VW,VH);
const ox=VW/2-(G.px+.5)*TS+(camShake>0?rnd(-camShake,camShake):0),
oy=VH/2-(G.py+.5)*TS+(camShake>0?rnd(-camShake,camShake):0);
const x0=Math.max(0,Math.floor(-ox/TS)),x1=Math.min(MAPW,Math.ceil((VW-ox)/TS)),
y0=Math.max(0,Math.floor(-oy/TS)),y1=Math.min(MAPH,Math.ceil((VH-oy)/TS));
for(let y=y0;y<y1;y++)for(let x=x0;x<x1;x++){
const t=m[y][x];cx.fillStyle=tileColor(t,G.zone);
cx.fillRect(ox+x*TS,oy+y*TS,TS,TS);
if(t===1){cx.fillStyle='rgba(0,0,0,.25)';cx.beginPath();cx.arc(ox+x*TS+TS/2,oy+y*TS+TS*.62,TS*.36,0,7);cx.fill();
cx.fillStyle=z===4?'#2c2050':'#234a28';cx.beginPath();cx.arc(ox+x*TS+TS/2,oy+y*TS+TS*.45,TS*.32,0,7);cx.fill();}
if(t===2){cx.fillStyle='rgba(255,255,255,.06)';cx.fillRect(ox+x*TS,oy+y*TS+((time*20)%TS)/2%TS,TS,3);}
if(t===5){cx.fillStyle='rgba(0,0,0,.18)';for(let k=0;k<3;k++)cx.fillRect(ox+x*TS+6+k*11,oy+y*TS+14+Math.sin(time*2+x+k)*2,3,16);}
if(t===4){cx.fillStyle='#ffd77a';cx.shadowColor='#ffd77a';cx.shadowBlur=18;
cx.beginPath();cx.arc(ox+x*TS+TS/2,oy+y*TS+TS/2,10+Math.sin(time*3)*2,0,7);cx.fill();cx.shadowBlur=0;}
if(t===8){cx.fillStyle='#c07aff';cx.shadowColor='#c07aff';cx.shadowBlur=14;
cx.fillRect(ox+x*TS+12,oy+y*TS+8,16,26);cx.shadowBlur=0;}
if(t===9){cx.fillStyle='#ffe89a';cx.shadowColor='#ffe89a';cx.shadowBlur=G.flags['boss'+G.zone]?22:0;
cx.globalAlpha=G.flags['boss'+G.zone]?.9:.28;
cx.fillRect(ox+x*TS+4,oy+y*TS+2,TS-8,TS-4);cx.globalAlpha=1;cx.shadowBlur=0;}
}
// player
const px=ox+G.px*TS+TS/2,py=oy+G.py*TS+TS/2,bob=Math.sin(time*8)*(isMoving()?2:0);
cx.fillStyle='rgba(0,0,0,.35)';cx.beginPath();cx.ellipse(px,py+13,11,4,0,0,7);cx.fill();
cx.fillStyle='#ffd77a';cx.shadowColor='#ffce6a';cx.shadowBlur=12;
cx.beginPath();cx.arc(px,py-6+bob,9,0,7);cx.fill();cx.shadowBlur=0;
cx.fillStyle='#fff';cx.beginPath();cx.arc(px,py+6+bob,6,0,7);cx.fill();
// particles ambient
if(Math.random()<.15)spawnPart(rnd(0,VW),rnd(0,VH),z.pc,1,.3);
updParts(dt);
parts.forEach(p=>{cx.globalAlpha=clamp(p.life,0,1)*.8;cx.fillStyle=p.c;
cx.beginPath();cx.arc(p.x,p.y,p.sz,0,7);cx.fill();});
cx.globalAlpha=1;
}
function isMoving(){return Object.keys(keys).some(k=>keys[k]&&['w','a','s','d','arrowup','arrowdown','arrowleft','arrowright'].includes(k));}
function pollInput(dt){
if(MODE!=='world')return;
const sp=6.5*dt; moveAcc+=sp;
while(moveAcc>=1){moveAcc-=1;
if(keys['w']||keys['arrowup'])tryMove(0,-1);
else if(keys['s']||keys['arrowdown'])tryMove(0,1);
else if(keys['a']||keys['arrowleft'])tryMove(-1,0);
else if(keys['d']||keys['arrowright'])tryMove(1,0);
else break;
}
}
let moveAcc=0;
/* ============ Battle ============ */
let B=null; // battle state
function firstAlive(){return G.party.find(m=>m.hp>0);}
function startWildBattle(){
const z=ZONES[G.zone];
const n=Math.random()<.22?2:1;
const foes=[];
const FORMS=new Set(Object.values(CREATURES).filter(c=>c.tr).map(c=>c.tr.id));
for(let i=0;i<n;i++){
const lvl=ri(z.lvl[0],z.lvl[1])+(G.postgame?8:0);
let pool=Object.keys(CREATURES).filter(id=>!FORMS.has(id)); // wild are base forms
// bias toward zone-flavored types
const flavor={0:['cindercub','mossling','whifflit','lumkin','emberox'],1:['mirelet','coralid','mossling','duskrat','glacilisk'],
2:['cindercub','ashwyrm','emberox','gravelim','bramblehorn'],3:['whifflit','wispurr','sparkmoth','lumkin','emberox'],
4:['shadeling','duskrat','sparkmoth','duskrat','glacilisk'],5:['ashwyrm','shadeling','gleamoth','wispurr','glacilisk']}[G.zone];
const id=pick(Math.random()<.75?flavor:pool);
foes.push(makeCreature(id,lvl));
}
startBattle({kind:'wild',foes});
}
function startBattle(opt){
MODE='battle'; B={foes:opt.foes.map(f=>({...f})),kind:opt.kind,
activeFoe:0,activeAlly:null,turn:0,over:false,busy:false};
G.seen.add(B.foes[0].id);
const a=firstAlive();B.activeAlly=G.party.indexOf(a);
$('#hud').classList.add('hidden');
$('#battleUI').classList.remove('hidden');
renderBattleUI();
blog(B.kind==='boss'?'The anchor stirs — '+ZONES[G.zone].guardian.name+' blocks your path!':
'Wild '+B.foes[0].name+(B.foes.length>1?' & company':'')+' appeared!');
setTimeout(()=>blog('Go, '+a.name+'!'),900);
}
function blog(t){
const l=$('#battleLog');l.innerHTML='';
const d=document.createElement('div');d.className='logline';d.textContent=t;l.appendChild(d);
}
function statusChips(mon){
let h='';
(mon.st||[]).forEach(s=>{const col={Burn:'var(--pyre)',Frost:'#9adfff',Shock:'#ffe27a',Regen:'var(--hp)'}[s.k]||'#fff';
h+=`<span class="statuschip" style="background:${col};color:#111">${s.k}${s.t?'('+s.t+')':''}</span>`;});
return h;
}
function renderBattleUI(){
const f=B.foes[B.activeFoe],a=G.party[B.activeAlly];
const hpCol=m=>m.hp/m.maxhp>.5?'var(--hp)':m.hp/m.maxhp>.22?'var(--hplow)':'var(--hpcrit)';
$('#enemyPlate').innerHTML=`<div class="bpname"><span>${f.name}</span>
<span class="ctag" style="color:${TYPES[f.types[0]].c}">${f.types.join('/')}</span></div>
<div class="bhpbar"><div class="bhpfill" style="width:${f.hp/f.maxhp*100}%;background:${hpCol(f)}"></div></div>
<div class="bmeta"><span>Lv ${f.lvl}</span><span>${Math.max(0,f.hp)}/${f.maxhp}</span></div>
<div style="margin-top:4px">${statusChips(f)}</div>`;
$('#allyPlate').innerHTML=`<div class="bpname"><span>${a.name}</span>
<span class="ctag" style="color:${TYPES[a.types[0]].c}">${a.types.join('/')}</span></div>
<div class="bhpbar"><div class="bhpfill" style="width:${a.hp/a.maxhp*100}%;background:${hpCol(a)}"></div></div>
<div class="bmeta"><span>Lv ${a.lvl}</span><span>${Math.max(0,a.hp)}/${a.maxhp}</span></div>
<div class="enbar"><div class="enfill" style="width:${energyPct(a)}%"></div></div>
<div style="margin-top:4px">${statusChips(a)}</div>`;
renderBattleMenu();
}
function energyPct(m){const ms=m.moves.map(k=>m.energy[k]/(MOVES[k].cost*4));return clamp(ms.reduce((s,v)=>s+v,0)/ms.length*100,0,100);}
function renderBattleMenu(){
const menu=$('#battleMenu');menu.innerHTML='';
creatureMoves(G.party[B.activeAlly]).forEach(mv=>{
const b=document.createElement('button');b.className='bmove';
const canUse=(G.party[B.activeAlly].energy[mv.id]||0)>=mv.cost;
b.disabled=!canUse||B.busy||B.over;
b.innerHTML=`<div class="mvname"><span>${mv.name}</span><span style="color:#7ad7ff">${mv.cost}</span></div>
<div class="mvmeta"><span style="color:${TYPES[mv.type].c}">${mv.type}</span> · P${mv.pow||'—'} · ${mv.acc}%</div>`;
b.onclick=()=>playerMove(mv);
menu.appendChild(b);
});
const sw=document.createElement('button');sw.className='bmove';sw.textContent='⟲ Switch';
sw.disabled=B.busy||B.over;sw.onclick=openSwitchModal;menu.appendChild(sw);
const cap=document.createElement('button');cap.className='bmove';cap.textContent='✦ Bind';
const hasStone=Object.entries(G.bag).some(([k,v])=>v>0&&ITEMS[k].kind==='capture');
cap.disabled=B.busy||B.over||B.kind==='boss'||!hasStone;
cap.title=hasStone?'Attempt a binding rite':'No Bindstones — buy them at shrines';
cap.onclick=openBindModal;menu.appendChild(cap);
const bag=document.createElement('button');bag.className='bmove';bag.textContent='🎒 Bag';
bag.disabled=B.busy||B.over;bag.onclick=openBagModal;menu.appendChild(bag);
const run=document.createElement('button');run.className='bmove';run.textContent='↩ Flee';
run.disabled=B.busy||B.over||B.kind==='boss';
run.onclick=()=>{if(Math.random()<.6){endBattle('fled');}else{blog('Couldn\'t escape!');enemyFreeTurn();}};
menu.appendChild(run);
}
/* damage */
function calcDamage(att,def,mv){
const eff=typeMult(mv.type,def.types[0])*typeMult(mv.type,def.types[1]||def.types[0]);
const stab=att.types.includes(mv.type)?1.25:1;
const critC=(mv.fx.crit||1)*0.0625*(att.spd/60);
const crit=Math.random()<critC?1.6:1;
let atk=att.atk;
if(att.st&&att.st.some(s=>s.k==='Burn'))atk*=.5;
const base=0.4*att.lvl*atk/Math.max(1,def.def)*(mv.pow||0)/12;
const dmg=Math.max(1,Math.floor(base*eff*stab*crit*rnd(.85,1)));
return {dmg,eff,crit:crit>1};
}
function floatDmg(elId,txt,color){
const el=$(elId),r=el.getBoundingClientRect();
const d=document.createElement('div');d.className='dmgfloat';d.textContent=txt;d.style.color=color||'#fff';
d.style.left=(r.left+r.width/2+rnd(-20,20))+'px';d.style.top=(r.top+r.height/2)+'px';
document.body.appendChild(d);setTimeout(()=>d.remove(),1000);
}
function applyStatus(target,k,dur){
target.st=target.st||[];
if(target.st.some(s=>s.k===k))return false;
target.st.push({k,t:dur});return true;
}
function tickStatus(mon,isAlly){
let msgs=[];if(!mon.st)return msgs;
mon.st=mon.st.filter(s=>{
if(s.k==='Burn'){const d=Math.max(1,Math.floor(mon.maxhp*.06));mon.hp-=d;
msgs.push(`${mon.name} sears for ${d}.`);}
if(s.k==='Regen'){const d=Math.max(1,Math.floor(mon.maxhp*.08));mon.hp=Math.min(mon.maxhp,mon.hp+d);
msgs.push(`${mon.name} mends.`);}
if(s.k==='Frost'&&Math.random()<.25){msgs.push(`${mon.name} is frozen stiff!`);return 'skip';}
s.t=(s.t||3)-1;return s.t>0;
});
return msgs;
}
function statusBlocked(mon){return mon.st&&mon.st.some(s=>s.k==='Shock')&&Math.random()<.35;}
function barrierOf(mon){if(!mon.st)return false;const i=mon.st.findIndex(s=>s.k==='Barrier');
if(i>=0){mon.st.splice(i,1);return true;}return false;}
async function doMove(user,target,mv,isAlly){
B.busy=true;renderBattleMenu();
blog(`${user.name} uses ${mv.name}!`);
await wait(650);
// energy
user.energy[mv.id]=Math.max(0,(user.energy[mv.id]||0)-mv.cost);
if(Math.random()*100>mv.acc){blog('…but it missed.');await wait(600);B.busy=false;renderBattleUI();return;}
if(mv.pow){
const hits=mv.fx.multi?ri(mv.fx.multi[0],mv.fx.multi[1]):1;
let total=0,lastEff=1,anyCrit=false;
for(let i=0;i<hits;i++){
if(target.hp<=0)break;
if(barrierOf(target)){blog(`${target.name}'s barrier deflects the blow!`);await wait(500);break;}
const r=calcDamage(user,target,mv);total+=r.dmg;lastEff=r.eff;anyCrit=anyCrit||r.crit;
target.hp=Math.max(0,target.hp-r.dmg);
camShake=r.crit?10:4;setTimeout(()=>camShake=0,180);
floatDmg(isAlly?'#enemyPlate':'#allyPlate',r.dmg,r.crit?'#ffd77a':r.eff>1.25?'#ff7a4d':'#fff');
spawnPart(VW*(isAlly?.72:.28),VH*.45,TYPES[mv.type].c,r.eff>1.25?18:8,4);
sfx(r.crit?'crit':'hit');
renderBattleUI();
await wait(hits>1?260:420);
}
if(total>0){
const lbl=multLabel(lastEff);if(lbl)blog(lbl);
if(anyCrit&&hits===1)blog('A critical strike!');
if(mv.fx.drain){user.hp=Math.min(user.maxhp,user.hp+Math.floor(total*mv.fx.drain));blog(`${user.name} drinks the harm.`);}
if(mv.fx.recoil){user.hp=Math.max(0,user.hp-Math.floor(total*mv.fx.recoil));blog(`${user.name} is hurt by backlash.`);}
}
await wait(400);
} else await wait(250);
// effects
const fx=mv.fx;
if(fx.burn&&target.hp>0&&applyStatus(target,'Burn',4))blog(`${target.name} is burning!`);
if(fx.frost&&target.hp>0&&applyStatus(target,'Frost',4))blog(`${target.name} is frostbound!`);
if(fx.shock&&target.hp>0&&applyStatus(target,'Shock',3))blog(`${target.name} crackles with shock!`);
if(fx.barrier){applyStatus(user,'Barrier',99);blog(`${user.name} raises a barrier!`);}
if(fx.selfDef){user.stDefMul=(user.stDefMul||1)+.25;blog(`${user.name} guards up.`);}
if(fx.atkDown&&target.hp>0){target.stAtkMul=Math.max(.5,(target.stAtkMul||1)-.25);blog(`${target.name}'s strength fades.`);}
if(fx.spdUp){user.stSpdMul=(user.stSpdMul||1)+.3;blog(`${user.name} quickens!`);}
if(fx.spdDown&&target.hp>0){target.stSpdMul=Math.max(.4,(target.stSpdMul||1)-.3);blog(`${target.name} slows.`);}
if(fx.heal){user.hp=Math.min(user.maxhp,user.hp+Math.floor(user.maxhp*fx.heal));sfx('heal');blog(`${user.name} blooms with light.`);}
if(fx.regen)if(applyStatus(user,'Regen',4))blog(`${user.name} is wrapped in renewal.`);
renderBattleUI();
B.busy=false;
}
function effSpd(m){return m.spd*(m.stSpdMul||1)/(m.st&&m.st.some(s=>s.k==='Frost')?2:1);}
const wait=ms=>new Promise(r=>setTimeout(r,ms));
function playerMove(mv){
if(B.busy||B.over)return;
const ally=G.party[B.activeAlly],foe=B.foes[B.activeFoe];
resolveTurn({ally,mv});
}
async function enemyTurnAction(){
const foe=B.foes[B.activeFoe],ally=G.party[B.activeAlly];
const usable=creatureMoves(foe).filter(m=>(foe.energy[m.id]||0)>=m.cost);
if(!usable.length){blog(`${foe.name} flails wearily…`);await wait(600);return;}
// weight by effectiveness
let best=null,bw=0;
usable.forEach(m=>{const w=(m.pow||30)*typeMult(m.type,ally.types[0])+rnd(0,40);if(w>bw){bw=w;best=m;}});
await doMove(foe,ally,best,false);
}
async function resolveTurn(playerAct){
if(B.busy||B.over)return;
B.busy=true;renderBattleMenu();
const ally=G.party[B.activeAlly],foe=B.foes[B.activeFoe];
const foeMove=(()=>{const us=creatureMoves(foe).filter(m=>(foe.energy[m.id]||0)>=m.cost);
if(!us.length)return null;let best=null,bw=-1;
us.forEach(m=>{const w=(m.pow||30)*typeMult(m.type,ally.types[0])+rnd(0,40);if(w>bw){bw=w;best=m;}});return best;})();
const prioA=playerAct.mv?(playerAct.mv.fx.prio||0):-99;
const prioE=foeMove?(foeMove.fx.prio||0):-99;
const allyFirst=prioA>prioE||(prioA===prioE&&effSpd(ally)>=effSpd(foe));
const acts=allyFirst?
[{who:'ally',fn:()=>execPlayer(playerAct)},{who:'foe',fn:enemyTurnAction}]:
[{who:'foe',fn:enemyTurnAction},{who:'ally',fn:()=>execPlayer(playerAct)}];
for(const act of acts){
if(B.over)break;
const cur=act.who==='ally'?ally:foe;
if(cur.hp<=0)continue;
if(statusBlocked(cur)){blog(`${cur.name} is stunned by shock!`);await wait(700);continue;}
const sk=cur.st?cur.st.find(s=>s.k==='Frost'):null;
if(sk&&Math.random()<.25){blog(`${cur.name} shivers, unable to move!`);await wait(700);continue;}
await act.fn();
if(await checkFaints())return;
}
// end-of-turn statuses
[ally,foe].forEach(async m=>{if(m.hp>0){tickStatus(m);}});
renderBattleUI();
if(await checkFaints())return;
B.busy=false;renderBattleMenu();
}
async function execPlayer(act){
if(act.mv){await doMove(ally(),foe(),act.mv,true);}
else if(act.switchTo!==undefined){
blog(`Come back, ${ally().name}! Go, ${G.party[act.switchTo].name}!`);
B.activeAlly=act.switchTo;renderBattleUI();await wait(500);
}
else if(act.item){await useBattleItem(act.item);}
}
function ally(){return G.party[B.activeAlly];}
function foe(){return B.foes[B.activeFoe];}
async function checkFaints(){
// foe faints
while(B.foes[B.activeFoe]&&B.foes[B.activeFoe].hp<=0){
const f=B.foes[B.activeFoe];
blog(`${f.name} is unbound!`);
grantExp(f);
await wait(900);
B.foes.splice(B.activeFoe,1);
if(!B.foes.length){await victory();return true;}
B.activeFoe=0;blog(`${B.foes[0].name} takes the field!`);renderBattleUI();await wait(700);
}
// ally faints
if(ally().hp<=0){
blog(`${ally().name} falls!`);
await wait(800);
if(!firstAlive()){await defeat();return true;}
openForcedSwitch();
return true; // pause flow until switch chosen
}
return false;
}
function grantExp(f){
G.caught.has(f.id)||G.seen.add(f.id);
G.party.forEach(m=>{
if(m.hp<=0)return;
const gain=Math.floor((f.lvl*7+10)*(f.lvl/m.lvl+.5));
m.exp+=gain;
blog(`${m.name} gains ${gain} exp.`);
while(m.exp>=expToLvl(m.lvl)&&m.lvl<LEVEL_CAP){
m.exp-=expToLvl(m.lvl);m.lvl++;
const c=CREATURES[m.id];
m.maxhp=hpAt(c.base.hp,m.lvl);m.hp=Math.min(m.maxhp,m.hp+Math.floor(m.maxhp*.2));
m.atk=statAt(c.base.atk,m.lvl,1);m.def=statAt(c.base.def,m.lvl,1);m.spd=statAt(c.base.spd,m.lvl,1);
c.learn.forEach(([l,mid])=>{if(l===m.lvl&&!m.moves.includes(mid)){
m.moves.push(mid);m.energy[mid]=MOVES[mid].cost*4;blog(`${m.name} learned ${MOVES[mid].name}!`);}});
}
});
}
async function victory(){
B.over=true;sfx('win');
const goldGain=B.kind==='boss'?ri(300,500):ri(20,60);
G.gold+=goldGain;
blog(`Victory! +${goldGain} glimmer.`);
await wait(1200);
if(B.kind==='boss'){
const z=ZONES[G.zone],g=z.guardian;
G.flags['boss'+G.zone]=1;
if(z.anchor){G.anchors++;
const bonuses={atkp:()=>G.flags.atkp=(G.flags.atkp||0)+.08,hpp:()=>G.flags.hpp=(G.flags.hpp||0)+.1,
catchp:()=>G.flags.catchp=(G.flags.catchp||0)+.12};
bonuses[g.bonus]&&bonuses[g.bonus]();
toast('Anchor freed! Permanent boon earned.');
}
if(g.final){G.postgame=true;G.flags.won=1;}
endBattle('won');
dialogue(g.name,g.win,()=>{checkStory();if(G.postgame)showVignette({t:'✦ The Vale Reborn ✦',b:'The Hollow Light scatters into harmless motes that now drift through every zone. Wild creatures grow fiercer — the Vale tests its new Binder. Free roam awaits.'});MODE='world';});
return;
}
endBattle('won');
}
async function defeat(){
B.over=true;
blog('Your party is spent… you retreat to the last shrine.');
await wait(1400);
endBattle('lost');
healAll(G.party);
transitionZone(Math.min(G.zone, G.flags.lastShrineZone??0),false);
G.gold=Math.floor(G.gold*.8);
toast('You awaken at the wayshrine.');
MODE='world';
}
function endBattle(how){
$('#battleUI').classList.add('hidden');
$('#captureUI').classList.add('hidden');
B=null;MODE='world';
$('#hud').classList.remove('hidden');
if(G.flags.won&&!G.postgameNoted){}
}
function enemyFreeTurn(){resolveTurn({skip:true});}
/* ============ Capture mini-game ============ */
function openSwitchModal(){ if(!B)return;
let html=mhead('Switch creature');
G.party.forEach((m,i)=>{if(m.hp>0&&i!==B.activeAlly)html+=creatureRow(m,false,`battleSwitch(${i})`,'');});
openModal(html);
}
function openBindModal(){
if(!B)return;
let html=mhead('Binding Rite')+`<div style="color:var(--dim);font-size:.8rem;margin-bottom:10px">Lower HP and a true rhythm improve your odds.</div><div class="menurow">`;
Object.entries(G.bag).filter(([k,v])=>v>0&&ITEMS[k].kind==='capture').forEach(([k,v])=>{
const it=ITEMS[k];
html+=`<div class="crow" onclick="useBindstone('${k}')"><div class="cinfo">
<div class="cname">${it.name} ×${v}</div><div class="cmeta">${it.desc} · potency ×${it.tier}</div></div></div>`;
});
openModal(html+'</div>');
}
function useBindstone(k){
if(!B||(G.bag[k]||0)<=0)return;
G.bag[k]--;capGame.tier=ITEMS[k].tier;
closeModal();
openCapture(foe());
}
const capGame={active:false,taps:0,hits:0,pos:0,dir:1,speed:1.4,zoneW:0,zoneX:0,bonus:1,mon:null,tier:1};
function openCapture(mon){
capGame.active=true;capGame.taps=0;capGame.hits=0;capGame.pos=0;capGame.dir=1;
capGame.mon=mon;capGame.bonus=1;
capGame.speed=1.1+mon.lvl*0.02;
const zw=clamp(30-mon.lvl*.35,10,26);capGame.zoneW=zw;capGame.zoneX=rnd(15,85-zw);
$('#capZone').style.left=capGame.zoneX+'%';$('#capZone').style.width=zw+'%';
$('#capTaps').textContent='◆ ◇ ◇';$('#capMsg').textContent='';
$('#captureUI').classList.remove('hidden');
MODE='capture';
requestAnimationFrame(capLoop);
}
function capLoop(){
if(!capGame.active)return;
capGame.pos+=capGame.dir*capGame.speed;
if(capGame.pos>100){capGame.pos=100;capGame.dir=-1;}
if(capGame.pos<0){capGame.pos=0;capGame.dir=1;}
$('#capMark').style.left=capGame.pos+'%';
requestAnimationFrame(capLoop);
}
function capTap(){
if(!capGame.active)return;
const inZone=capGame.pos>=capGame.zoneX&&capGame.pos<=capGame.zoneX+capGame.zoneW;
if(inZone){capGame.hits++;capGame.bonus+=.28;sfx('cap');
$('#capMsg').textContent='The stone drinks deep! ('+capGame.hits+'/3)';
}else{sfx('fail');capGame.bonus=Math.max(.5,capGame.bonus-.15);
$('#capMsg').textContent='The stone slips…';}
capGame.taps++;
$('#capTaps').textContent=['◆','◇','◇'].map((c,i)=>i<capGame.hits?'◆':'◇').join(' ');
if(capGame.hits>=3){finishCapture(true);return;}
if(capGame.taps>=6){finishCapture(false);return;}
}
$('#captureUI').addEventListener('click',()=>capTap());
function finishCapture(success){
capGame.active=false;
$('#captureUI').classList.add('hidden');
const mon=capGame.mon;
const hpFactor=1-(mon.hp/mon.maxhp)*.75;
const base=.28*capGame.tier*hpFactor*capGame.bonus*(1+(G.flags.catchp||0));
const chance=clamp(base,.05,.95);
const roll=Math.random();
MODE='battle';
if(success&&roll<chance){
sfx('cap');
blog(`${mon.name} was bound! The crystal goes quiet.`);
G.caught.add(mon.id);G.seen.add(mon.id);
mon.st=[];mon.stAtkMul=1;mon.stSpdMul=1;mon.stDefMul=1;
if(G.party.length<6){G.party.push(mon);toast(mon.name+' joined your party!');}
else{G.box.push(mon);toast(mon.name+' sent to the Reserve.');}
setTimeout(()=>{checkDexMilestone();endBattle('caught');},1200);
}else{
blog(mon.name+' broke free!');
setTimeout(async()=>{await checkFaints();if(B&&!B.over){B.busy=false;renderBattleMenu();}},1000);
}
}
function checkDexMilestone(){
if(G.caught.size>=8&&!G.flags.dex8){G.flags.dex8=1;
showVignette({t:'✦ Eightfold Binding ✦',b:'Eight creatures answer your call. The Vale stirs — the anchors weaken. Only the wardens remain between you and the Gate.'});}
}
/* ============ Modals ============ */
const modalRoot=$('#modalRoot');
function closeModal(){modalRoot.innerHTML='';if(MODE==='modal')MODE=prevMode;resumeFromModal();}
let prevMode='world',resumeFromModal=()=>{};
function openModal(html,onClose){
prevMode=MODE==='modal'?prevMode:MODE;
if(MODE!=='modal')MODE='modal';
resumeFromModal=()=>{if(onClose)onClose();};
modalRoot.innerHTML=`<div class="modal"><div class="mcard">${html}</div></div>`;
modalRoot.querySelector('.modal').addEventListener('click',e=>{if(e.target.classList.contains('modal'))closeModal();});
}
function mhead(title){return `<div class="mhead"><h2>${title}</h2><button class="xbtn" onclick="closeModal()">✕</button></div>`;}
function openPause(){
if(MODE!=='world')return;
openModal(mhead('Sundervale')+
`<div style="color:var(--dim);font-size:.8rem;margin-bottom:12px">Glimmer: <b style="color:var(--gold)">${G.gold}</b> · Party ${G.party.length}/6 · Bound ${G.caught.size}/27 · Anchors ${G.anchors}/4</div>
<div class="menurow">
<button class="bigbtn" onclick="openTeam()">🐾 Party & Attunement</button>
<button class="bigbtn" onclick="openBag()">🎒 Satchel</button>
<button class="bigbtn" onclick="openDex()">📖 Bestiary</button>
<button class="bigbtn" onclick="openMap()">🗺 Vale Map</button>
<button class="bigbtn" onclick="saveGame();toast('Progress saved.');">💾 Save</button>
<button class="bigbtn" onclick="closeModal()">↩ Return</button>
</div>`);
}
$('#btnPause').onclick=()=>{if(MODE==='world')openPause();else if(MODE==='modal')closeModal();};
$('#btnMute').onclick=()=>{AU.on=!AU.on;$('#btnMute').textContent=AU.on?'🔊':'🔇';};
function creatureRow(m,active,onclick,extra){
const c=CREATURES[m.id];
return `<div class="crow ${active?'active':''} ${m.hp<=0?'fainted':''}" ${onclick?`onclick="${onclick}"`:''}>
<div class="cportrait" style="background:radial-gradient(circle at 35% 30%, #fff3, transparent 60%), ${c.color};color:${c.color}"></div>
<div class="cinfo">
<div class="cname">${m.name} <span class="clvl">Lv ${m.lvl}</span>
${m.types.map(t=>`<span class="ctag" style="color:${TYPES[t].c}">${t}</span>`).join('')}</div>
<div class="chpbar"><div class="chpfill" style="width:${clamp(m.hp/m.maxhp*100,0,100)}%;background:${m.hp/m.maxhp>.5?'var(--hp)':m.hp/m.maxhp>.22?'var(--hplow)':'var(--hpcrit)'}"></div></div>
<div class="cmeta">HP ${Math.max(0,m.hp)}/${m.maxhp} · ATK ${m.atk} · DEF ${m.def} · SPD ${m.spd} · EXP ${m.exp}/${expToLvl(m.lvl)}</div>
<div class="cmeta">${m.moves.map(k=>`<span style="color:${TYPES[MOVES[k].type].c}">${MOVES[k].name}</span>`).join(' · ')}</div>
${extra||''}
</div></div>`;
}
function openTeam(fromBattle){
let html=mhead('Party')+'<div class="menurow">';
G.party.forEach((m,i)=>{
const c=CREATURES[m.id];
let extra='';
if(c.tr&&!m.transed&&m.lvl>=c.tr.lvl&&(G.bag[c.tr.item]||0)>0){
extra=`<button class="bigbtn" style="margin-top:8px" onclick="attune(${i})">✦ Attune with ${ITEMS[c.tr.item].name} → ${c.tr.name}</button>`;
}else if(c.tr&&!m.transed){
extra=`<div class="cmeta" style="color:var(--dim)">Attunement: Lv ${c.tr.lvl} + ${ITEMS[c.tr.item].name}</div>`;
}
html+=creatureRow(m,i===B?.activeAlly&&fromBattle,fromBattle?`battleSwitch(${i})`:'',extra);
});
if(G.box.length)html+=`<div style="margin-top:14px;color:var(--gold);font-size:.85rem">— Reserve —</div>`+
G.box.map((m,i)=>creatureRow(m,false,G.party.length<6?`withdrawBox(${i})`:'','')).join('');
openModal(html);
}
function withdrawBox(i){const m=G.box.splice(i,1)[0];G.party.push(m);toast(m.name+' joins the party.');openTeam();}
function attune(i){
const m=G.party[i],c=CREATURES[m.id];
if(!c.tr||m.transed||m.lvl<c.tr.lvl||(G.bag[c.tr.item]||0)<=0)return;
G.bag[c.tr.item]--;m.transed=true;m.id=c.tr.id;m.name=c.tr.name;m.types=CREATURES[m.id].types.slice();
const nc=CREATURES[m.id];
m.maxhp=hpAt(nc.base.hp,m.lvl);m.hp=m.maxhp;m.atk=statAt(nc.base.atk,m.lvl,1);
m.def=statAt(nc.base.def,m.lvl,1);m.spd=statAt(nc.base.spd,m.lvl,1);
nc.learn.forEach(([l,mid])=>{if(l<=m.lvl&&!m.moves.includes(mid)){m.moves.push(mid);m.energy[mid]=MOVES[mid].cost*4;}});
if(m.moves.length>4)m.moves=m.moves.slice(-4);
G.caught.add(m.id);
sfx('cap');toast(`${m.name} transcends into its true form!`);
openTeam();
}
function battleSwitch(i){
if(!B||G.party[i].hp<=0||i===B.activeAlly)return;
closeModal();
resolveTurn({switchTo:i});
}
function openForcedSwitch(){
let html=mhead('Choose your next creature');
G.party.forEach((m,i)=>{if(m.hp>0)html+=creatureRow(m,false,`forcedSwitch(${i})`,'');});
openModal(html);
}
function forcedSwitch(i){
if(G.party[i].hp<=0)return;
B.activeAlly=i;closeModal();
blog(`Go, ${G.party[i].name}!`);
B.busy=false;renderBattleUI();
}
/* bag */
function openBag(fromBattle){
let html=mhead('Satchel')+`<div style="color:var(--dim);font-size:.8rem;margin-bottom:10px">Glimmer: <b style="color:var(--gold)">${G.gold}</b></div><div class="menurow">`;
const entries=Object.entries(G.bag).filter(([k,v])=>v>0);
if(!entries.length)html+='<div style="color:var(--dim)">Empty. The shrines sell supplies.</div>';
entries.forEach(([k,v])=>{
const it=ITEMS[k];
let act='';
if(fromBattle&&['heal','revive','energy'].includes(it.kind))act=`<button class="bigbtn" style="max-width:220px" onclick="useBattleItem('${k}')">Use</button>`;
else if(!fromBattle&&['heal','revive','energy'].includes(it.kind))act=`<button class="bigbtn" style="max-width:220px" onclick="useFieldItem('${k}')">Use</button>`;
html+=`<div class="crow"><div class="cinfo"><div class="cname">${it.name} ×${v}</div>
<div class="cmeta">${it.desc}</div></div>${act}</div>`;
});
openModal(html);
}
function openBagModal(){openBag(true);}
function useBattleItem(k){
const it=ITEMS[k];const m=ally();
if(it.kind==='heal'){if(m.hp>=m.maxhp){toast('Already hale.');return;}
m.hp=Math.min(m.maxhp,m.hp+it.amt);blog(`${m.name} restores ${it.amt} HP.`);}
if(it.kind==='revive'){const f=G.party.find(p=>p.hp<=0);if(!f){toast('No fainted allies.');return;}
f.hp=Math.floor(f.maxhp/2);blog(`${f.name} revives!`);}
if(it.kind==='energy'){m.moves.forEach(k2=>m.energy[k2]=MOVES[k2].cost*4);blog(`${m.name}'s abilities surge back.`);}
G.bag[k]--;closeModal();resolveTurn({item:k});
}
async function useBattleItemOld(){}
function useFieldItem(k){
const it=ITEMS[k];
if(it.kind==='repel'){G.repel=it.steps;G.bag[k]--;toast('Wild creatures keep their distance… ('+it.steps+' steps)');openBag();return;}
if(it.kind==='heal'){openHealPick(m=>{m.hp=Math.min(m.maxhp,m.hp+it.amt);G.bag[k]--;toast(m.name+' restored.');openBag();});return;}
if(it.kind==='revive'){openHealPick(m=>{m.hp=Math.floor(m.maxhp/2);G.bag[k]--;toast(m.name+' revives!');openBag();},true);return;}
if(it.kind==='energy'){openHealPick(m=>{m.moves.forEach(k2=>m.energy[k2]=MOVES[k2].cost*4);G.bag[k]--;toast(m.name+' refreshed.');openBag();});return;}
}
function openHealPick(cb,revive){
let html=mhead('Choose a creature');
G.party.forEach((m,i)=>{
const ok=revive?m.hp<=0:m.hp>0;
if(ok)html+=creatureRow(m,false,`healPick(${i})`,'');
});
window._healCb=cb;
openModal(html);
}
function healPick(i){const cb=window._healCb;closeModal();cb&&cb(G.party[i]);}
/* shop */
function openShop(){
const z=ZONES[G.zone];
let html=mhead(z.name+' — Shrine Keeper')+`<div style="color:var(--dim);font-size:.8rem;margin-bottom:10px">Glimmer: <b style="color:var(--gold)">${G.gold}</b></div><div class="menurow">`;
z.shop.forEach(k=>{
const it=ITEMS[k];
html+=`<div class="crow"><div class="cinfo"><div class="cname">${it.name} <span class="clvl">${it.price} glimmer</span></div>
<div class="cmeta">${it.desc}</div></div>
<button class="bigbtn" style="max-width:140px" ${G.gold<it.price?'disabled':''} onclick="buyItem('${k}')">Buy</button></div>`;
});
html+='</div>';
openModal(html,()=>{MODE='world';});
}
function buyItem(k){
const it=ITEMS[k];if(G.gold<it.price)return;
G.gold-=it.price;G.bag[k]=(G.bag[k]||0)+1;sfx('ui');toast('Bought '+it.name+'.');openShop();
}
/* dex */
function openDex(){
let html=mhead('Bestiary of the Vale')+`<div style="color:var(--dim);font-size:.8rem;margin-bottom:12px">Bound ${G.caught.size}/27 · Witnessed ${G.seen.size}/27</div><div class="dexgrid">`;
Object.values(CREATURES).forEach(c=>{
const seen=G.seen.has(c.id)||G.caught.has(c.id),caught=G.caught.has(c.id);
html+=`<div class="dexcard ${seen?'':'unseen'}">
<div class="cname"><span class="cportrait" style="display:inline-block;width:26px;height:26px;vertical-align:middle;margin-right:6px;background:${c.color}"></span>
${seen?c.name:'?????'} ${caught?'<span style="color:var(--gold)">◆</span>':''}</div>
<div>${c.types.map(t=>`<span class="ctag" style="color:${TYPES[t].c}">${t}</span>`).join(' ')}</div>
<div class="dexlore">${seen?c.lore:'Its story waits beyond the veil.'}</div>
${c.tr?`<div class="cmeta" style="margin-top:6px;color:var(--dim)">Attunes at Lv ${c.tr.lvl}${G.seen.has(c.tr.id)?' → '+c.tr.name:''}</div>`:''}
</div>`;
});
openModal(html+'</div>');
}
/* map */
function openMap(){
let html=mhead('Vale Map')+`<div id="mapGrid">`;
ZONES.forEach((z,i)=>{
const unlocked=i===0||G.flags['boss'+(i-1)];
const cls=i===G.zone?'cur':!unlocked?'locked':G.flags['boss'+i]?'done':'';
html+=`<div class="zonecell ${cls}"><b>${z.name}</b><br><span style="font-size:.72rem;color:var(--dim)">${z.sub}</span><br>
<span style="font-size:.7rem">${G.flags['boss'+i]?'✦ Anchor freed':unlocked?'Open':'Sealed'}</span></div>`;
});
openModal(html+'</div>');
}
/* ============ Battle scene rendering ============ */
function hashNum(s){let h=0;for(let i=0;i<s.length;i++)h=(h*31+s.charCodeAt(i))>>>0;return h;}
function drawCreature(mon,x,y,scale,time,facing){
const c=CREATURES[mon.id],h=hashNum(mon.id);
const bob=Math.sin(time*3+h%10)*4;
const s=scale*(1+(h%20)/100);
cx.save();cx.translate(x,y+bob);if(facing)cx.scale(-1,1);
// shadow
cx.fillStyle='rgba(0,0,0,.35)';cx.beginPath();cx.ellipse(0,s*.62,s*.55,s*.14,0,0,7);cx.fill();
// aura
cx.shadowColor=c.color;cx.shadowBlur=26;
// wings/ears variants
if(h%3===0){cx.fillStyle=c.color;cx.globalAlpha=.75;
cx.beginPath();cx.moveTo(-s*.5,-s*.15);cx.quadraticCurveTo(-s*1.05,-s*.55,-s*.55,-s*.5);
cx.quadraticCurveTo(-s*.7,-s*.25,-s*.45,-s*.05);cx.fill();cx.globalAlpha=1;}
if(h%3===1){cx.fillStyle=c.color;
cx.beginPath();cx.moveTo(-s*.28,-s*.42);cx.lineTo(-s*.42,-s*.85);cx.lineTo(-s*.08,-s*.52);cx.fill();
cx.beginPath();cx.moveTo(s*.28,-s*.42);cx.lineTo(s*.42,-s*.85);cx.lineTo(s*.08,-s*.52);cx.fill();}
if(h%3===2){cx.strokeStyle=c.color;cx.lineWidth=s*.09;
for(let i=-1;i<=1;i++){cx.beginPath();cx.arc(i*s*.22,-s*.72,s*.16,Math.PI,0);cx.stroke();}}
// body
cx.fillStyle=c.color;
cx.beginPath();cx.ellipse(0,0,s*.48,s*.55,0,0,7);cx.fill();
cx.shadowBlur=0;
// belly
cx.fillStyle='rgba(255,255,255,.25)';
cx.beginPath();cx.ellipse(0,s*.12,s*.3,s*.34,0,0,7);cx.fill();
// eyes
cx.fillStyle='#141824';
cx.beginPath();cx.arc(-s*.16,-s*.12,s*.07,0,7);cx.fill();
cx.beginPath();cx.arc(s*.16,-s*.12,s*.07,0,7);cx.fill();
cx.fillStyle='#fff';
cx.beginPath();cx.arc(-s*.14,-s*.15,s*.025,0,7);cx.fill();
cx.beginPath();cx.arc(s*.18,-s*.15,s*.025,0,7);cx.fill();
// mouth
cx.strokeStyle='#141824';cx.lineWidth=s*.03;
cx.beginPath();cx.arc(0,s*.02,s*.1,.2,Math.PI-.2);cx.stroke();
cx.restore();
}
function drawBattle(time){
const z=ZONES[G.zone];
const g=cx.createLinearGradient(0,0,0,VH);g.addColorStop(0,z.grad[0]);g.addColorStop(1,z.grad[1]);
cx.fillStyle=g;cx.fillRect(0,0,VW,VH);
// ground platforms
cx.fillStyle='rgba(255,255,255,.06)';
cx.beginPath();cx.ellipse(VW*.74,VH*.68,190,54,0,0,7);cx.fill();
cx.beginPath();cx.ellipse(VW*.26,VH*.44,150,40,0,0,7);cx.fill();
const shx=camShake>0?rnd(-camShake,camShake):0,shy=camShake>0?rnd(-camShake,camShake):0;
cx.save();cx.translate(shx,shy);
B.foes.forEach((f,i)=>{
const n=B.foes.length,xx=VW*.74+(i-(n-1)/2)*180,yy=VH*.6;
if(f.hp>0)drawCreature(f,xx,yy,Math.min(70,46+f.lvl),time,true);
});
const a=G.party[B.activeAlly];
if(a&&a.hp>0)drawCreature(a,VW*.27,VH*.56,Math.min(84,50+a.lvl),time,false);
cx.restore();
updParts(1/60);
parts.forEach(p=>{cx.globalAlpha=clamp(p.life,0,1)*.9;cx.fillStyle=p.c;
cx.beginPath();cx.arc(p.x,p.y,p.sz,0,7);cx.fill();});
cx.globalAlpha=1;
}
/* ============ Tutorial & starters ============ */
const STARTERS=['cindercub','mirelet','mossling'];
function chooseStarter(){
MODE='modal';
let html=mhead('The Wayshrine Speaks')+
`<div style="line-height:1.6;margin-bottom:14px">Three crystals warm in your palm. Each holds a creature that chose you back.<br>
<span style="color:var(--dim);font-size:.82rem">Pyre burns Brine · Brine wears Loam · Loam breaks Pyre</span></div><div class="menurow">`;
STARTERS.forEach(id=>{
const c=CREATURES[id];
html+=`<div class="crow" onclick="takeStarter('${id}')">
<div class="cportrait" style="background:radial-gradient(circle at 35% 30%, #fff3, transparent 60%), ${c.color}"></div>
<div class="cinfo"><div class="cname">${c.name} ${c.types.map(t=>`<span class="ctag" style="color:${TYPES[t].c}">${t}</span>`).join(' ')}</div>
<div class="dexlore">${c.lore}</div></div></div>`;
});
openModal(html+'</div>');
}
function takeStarter(id){
closeModal();
const m=makeCreature(id,5);
G.party.push(m);G.caught.add(id);G.seen.add(id);
G.bag.bindstone=(G.bag.bindstone||0)+5;G.bag.dewvial=(G.bag.dewvial||0)+3;
sfx('cap');
$('#hud').classList.remove('hidden');
$('#zoneName').textContent=ZONES[0].name;$('#zoneSub').textContent=ZONES[0].sub;
startPad(0);
dialogue('Wayshrine',`${m.name} is bound to you now. Move with WASD or arrow keys${'ontouchstart' in window?' or the pad':' — or the pad below'}.`,()=>{
dialogue('Wayshrine','Tall grass hides wild creatures. Walk into it — your first binding awaits.',()=>{
MODE='world';checkStory();
setTimeout(tutorialBattle,600);
});
});
}
function tutorialBattle(){
G.flags.tut=1;
dialogue('???','Movement learned. Now — something small stirs ahead, curious and unbound.',()=>{
startBattle({kind:'wild',foes:[makeCreature('wispurr',3)]});
setTimeout(()=>toast('Weaken it, then choose Bind from the Bag!'),1200);
});
}
/* ============ Main loop ============ */
let lastT=performance.now(),started=false;
function frame(now){
const dt=Math.min(.05,(now-lastT)/1000);lastT=now;
const time=now/1000;
pollInput(dt);
cx.clearRect(0,0,VW,VH);
if(MODE==='battle'&&B){drawBattle(time);}
else if(G&&(MODE==='world'||MODE==='dialog'||MODE==='modal'||MODE==='capture')){drawWorld(dt,time);}
else{cx.fillStyle='#0b0e1a';cx.fillRect(0,0,VW,VH);}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
/* ============ Boot ============ */
function beginWorld(){
$('#title').classList.add('hidden');
started=true;
$('#hud').classList.remove('hidden');
$('#zoneName').textContent=ZONES[G.zone].name;$('#zoneSub').textContent=ZONES[G.zone].sub;
startPad(G.zone);auInit();
MODE='world';
}
$('#btnNew').onclick=()=>{auInit();sfx('ui');newGame();beginWorld();
dialogue('The Vale','Long ago the Hollow Light was caged behind the Auroric Gate. Four anchors held the lock. Three have gone dark. You carry the fourth — whether you mean to or not.',()=>{chooseStarter();});};
if(!hasSave(1))$('#btnContinue').disabled=true;
$('#btnContinue').onclick=()=>{auInit();sfx('ui');
if(loadGame(1)){beginWorld();healAll(G.party);toast('Welcome back, Binder.');}
else toast('No binding found.');};
</script>
</body>
</html>
<!-- agent-meta {"model":"stealth/ox-alpha","provider":"openrouter","persona":"full","sessionId":"9a8080dd-3d5a-4486-860d-7a80245de8d5","tokensIn":4468146,"tokensOut":42604,"tokensTotal":4510750,"cost":0,"turns":57,"toolCalls":61,"failedToolCalls":0,"timestamp":"2026-08-21T19:49:28.705Z"} -->