Creature Collection
Claude haiku 5.5 · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · e09f6c9a99
DIRECTIVE Build a complete, fully playable, beautiful HTML game. No external libraries, images, fonts, or assets. The game must be an original IP, legally and thematically distinct from any existing franchise. Invent all lore, worldbuilding, creature names, type names, move names, item names, and thematic terminology from scratch. Do not reuse, adapt, or hint at real-world IP, anime, or existing game franchises. Keep the setting/theme open (fantasy, sci-fi, abstract, mythic, etc.) as long as it supports the mechanical framework and remains internally consistent. MECHANICAL SPECIFICATIONS Preserve exact mathematical relationships, system architecture, and progression structure below. Terminology for these systems must be invented. Exploration & Progression 6 distinct zones on a tile-based overworld (WASD/arrows + touch buttons). Each zone has unique background gradient, ambient particle effects, wild encounter rates, and a story node. Wild encounters → turn-based battle → chance to capture with a collection item. Rest at shrines (heal party, save, shop). Defeat zone Guardians/bosses to unlock the next zone and earn permanent bonuses. Build a team of up to 6 creatures. Level them, teach combat abilities, and transform them via a level + item catalyst. Final objective: collect 8 unique creatures and defeat 4 zone anchors. Post-game free roam with harder wild spawns. 4-stage story progression with light narrative cut-ins (text + color vignette). Combat System Party of up to 6 vs. 1–3 enemies. Stats per creature: Level, HP, ATK, DEF, SPD, Affinity (1 of 7 types), 4 combat abilities. Type Chart: Type A > Type B > Type C > Type A Type D > Type A & Type C Type E > Type D & Type B Type F beats all others slightly Type G resists everything but is weak to Type F Turn order = SPD order. On turn: choose ability or switch creature. Resolve move → apply status → next turn. Abilities: Each has power, accuracy, affinity, PP/Energy cost, and one special effect (burn, frost, speed boost, heal, multi-hit, priority, etc.). Minimum 40 unique abilities. Damage Formula: (0.4 × Level × ATK / DEF × Multiplier) + small random, floored sensibly. Critical hits apply. STAB = +25% if ability affinity matches creature affinity. Status Effects: Burn (DoT + half ATK), Frost (halve SPD), Shock (chance to skip turn), Barrier (one-hit deflect), Regen, etc. Victory/Collection: EXP scales with defeated level & relative strength. Collection success scales with target remaining HP, collection item quality, and a short rhythm-tap or percentage mini-game. Content Volume & Progression 18 base creatures + 9 transformed forms = 27 total unique creatures. Each creature requires: original name, short lore blurb, 1–2 affinities, base stats, level-up ability learnset, transformation condition (level range + optional catalyst). Starter trio guidance (optional): provide 3 distinct starting options with different affinities. Inventory: collection items (3 tiers), restoration consumables, catalysts, field tools. Level cap: 50. Stat growth and ability proficiency scale per level. Save system: localStorage (auto-save at shrines + manual). Continue from last shrine. Multiple slots optional. UI polish: animated health bars, floating damage numbers, crit flashes, soft screen shakes, particle bursts on super-effective hits, glassmorphism modal menus, pause/bag/team/encyclopedia/map screens. LORE & NAMING INSTRUCTIONS Invent all worldbuilding, creature concepts, type names, ability names, item names, and lore phrases from scratch. Maintain internal consistency in naming conventions, thematic tone, and world logic. Avoid real-world references, trademarked terms, or genre clichés. Prioritize originality. Each creature’s lore blurb should be 1–2 sentences, evocative but mechanically neutral. The 7-type chart, damage formula, progression structure, and UI/UX requirements are fixed. Everything else is creative freedom. TECHNICAL & DELIVERY CONSTRAINTS Canvas for overworld and battle scenes; pure DOM/CSS for menus, taxonomy, dialogue. CSS custom properties for palette, smooth ambient animations, performant JS/CSS particle system, high-DPI friendly. No copyrighted music; generate simple Web Audio API chiptune/ambient loops or quiet generative pads. Include mute toggle. Robust error-free state management. Game must never soft-lock. Start with a short interactive tutorial teaching movement, first battle, and first collection. Title screen on load: luminous design with “New Binding” and “Continue” buttons. Everything required for a satisfying 60–90 minute playthrough must be present and functional. FINAL INSTRUCTION Begin coding now. Prioritize mechanical fidelity, delightful feedback, and original worldbuilding. Make the systems deep enough to feel rewarding, the creatures mechanically distinct, and the visual/audio feedback immersive. Invent every name, type, ability, and lore phrase from scratch.
Inspect original source 92,694 bytes · SHA-256 0c0bff04e989
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Tessaline</title>
<style>
:root{
--ink:#eef0ff;--mute:#9aa3c7;--glass:rgba(20,24,50,.56);--glass2:rgba(255,255,255,.06);
--edge:rgba(170,190,255,.24);--acc:#8fe3ff;--gold:#ffd27a;--bad:#ff6f8a;--good:#8dffb0;
--vigA:rgba(143,227,255,.35);--vigB:rgba(0,0,0,0);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:#05060d;color:var(--ink);font-family:system-ui,"Segoe UI",Helvetica,Arial,sans-serif;overflow:hidden;user-select:none;-webkit-user-select:none;touch-action:none}
.scr{position:fixed;inset:0;display:none}
.scr.on{display:block}
#title{background:radial-gradient(ellipse at 50% 30%,#2a2360 0%,#0d0f2a 55%,#05060d 100%)}
#titleCanvas,#world,#bcanvas{position:absolute;inset:0;width:100%;height:100%;display:block}
.tbox{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);text-align:center;z-index:2;width:min(620px,94vw)}
.wordmark{font-size:clamp(46px,9vw,96px);font-weight:800;letter-spacing:.12em;background:linear-gradient(90deg,#8fe3ff,#fff,#ffd27a,#ff9ad5,#8fe3ff);background-size:300% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:shimmer 7s linear infinite;filter:drop-shadow(0 0 24px rgba(143,227,255,.35))}
@keyframes shimmer{to{background-position:300% 0}}
.tag{color:var(--mute);letter-spacing:.32em;font-size:12px;text-transform:uppercase;margin:12px 0 44px}
.btnrow{display:flex;gap:14px;justify-content:center;flex-wrap:wrap}
.btn{padding:12px 26px;border-radius:14px;border:1px solid var(--edge);background:linear-gradient(180deg,rgba(255,255,255,.16),rgba(255,255,255,.04));color:var(--ink);font:600 15px system-ui,sans-serif;cursor:pointer;backdrop-filter:blur(10px);transition:transform .15s,box-shadow .2s,opacity .2s;letter-spacing:.02em}
.btn:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 8px 30px rgba(143,227,255,.25)}
.btn:disabled{opacity:.38;cursor:default}
.btn.sm{padding:6px 12px;font-size:13px;border-radius:10px}
.btn.pri{border-color:rgba(143,227,255,.7);box-shadow:0 0 18px rgba(143,227,255,.2)}
.btn.warn{border-color:rgba(255,111,138,.6)}
.glass{background:var(--glass);border:1px solid var(--edge);backdrop-filter:blur(16px) saturate(1.3);-webkit-backdrop-filter:blur(16px) saturate(1.3);box-shadow:0 24px 70px rgba(0,0,0,.5);border-radius:18px}
#game{background:#000}
#hud{position:absolute;left:12px;top:12px;z-index:5;padding:8px 14px;font-size:13px;display:flex;gap:14px;align-items:center;pointer-events:none}
#hud b{color:var(--gold);font-weight:700}
#hud .zn{color:var(--acc);font-weight:700;letter-spacing:.06em}
#hudBtns{position:absolute;right:12px;top:12px;z-index:5;display:flex;gap:8px}
#hint{position:absolute;left:50%;top:64px;transform:translateX(-50%);z-index:6;padding:9px 18px;font-size:14px;display:none;pointer-events:none;text-align:center;max-width:90vw}
#dialog{position:absolute;left:50%;bottom:26px;transform:translateX(-50%);z-index:30;width:min(780px,94vw);padding:18px 22px 16px;display:none;min-height:110px}
#dialog .who{color:var(--acc);font-weight:700;font-size:13px;letter-spacing:.1em;text-transform:uppercase;margin-bottom:6px}
#dialog .txt{font-size:17px;line-height:1.5;min-height:48px}
#dialog .nx{position:absolute;right:18px;bottom:10px;color:var(--mute);font-size:12px;animation:blink 1s infinite}
@keyframes blink{50%{opacity:.2}}
#menu{position:absolute;inset:0;display:none;align-items:center;justify-content:center;background:rgba(3,4,12,.42);z-index:20;padding:12px}
#menu.on{display:flex}
.panel{width:min(900px,96vw);max-height:90vh;display:flex;flex-direction:column;padding:18px 20px;animation:pop .25s ease-out}
@keyframes pop{from{transform:scale(.96);opacity:0}to{transform:none;opacity:1}}
.phead{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
.phead h2{font-size:20px;letter-spacing:.08em;color:var(--acc)}
.pbody{overflow:auto;flex:1;padding-right:4px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.row{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:var(--glass2);border:1px solid transparent;cursor:pointer;transition:background .15s,border-color .15s}
.row:hover{background:rgba(255,255,255,.1)}
.row.sel{border-color:var(--acc);background:rgba(143,227,255,.12)}
.row.dim{opacity:.45}
.row .nm{font-weight:700}
.row .sub{font-size:12px;color:var(--mute)}
.row .sp{margin-left:auto;color:var(--gold);font-weight:600;font-size:13px;white-space:nowrap}
.tag2{display:inline-block;font-size:11px;padding:2px 8px;border-radius:99px;margin-right:4px;color:#0b0d1a;font-weight:700}
.bar{height:8px;border-radius:6px;background:rgba(255,255,255,.12);overflow:hidden;margin-top:5px}
.bar i{display:block;height:100%;border-radius:6px;background:linear-gradient(90deg,#7dffb4,#dfffe9);transition:width .3s}
.bar.xp i{background:linear-gradient(90deg,#8fe3ff,#c9f4ff);height:5px}
#cutin{position:fixed;inset:0;z-index:50;display:none;align-items:center;justify-content:center;background:radial-gradient(ellipse at center,var(--vigB) 0%,rgba(0,0,0,.92) 80%);text-align:center;padding:30px}
#cutin.on{display:flex;animation:fadein .6s}
#cutin .ct{max-width:680px;font-size:clamp(18px,2.6vw,26px);line-height:1.6;font-weight:300;text-shadow:0 0 22px var(--vigA)}
#cutin .cn{color:var(--acc);letter-spacing:.3em;font-size:12px;text-transform:uppercase;margin-bottom:18px}
#cutin .ccont{margin-top:26px;color:var(--mute);font-size:12px;animation:blink 1.2s infinite}
#cutin::before{content:"";position:absolute;inset:0;background:radial-gradient(ellipse at center,transparent 40%,var(--vigA) 100%);opacity:.6;pointer-events:none}
@keyframes fadein{from{opacity:0}to{opacity:1}}
#battle{background:#000}
#bpanel{position:absolute;left:0;right:0;bottom:0;z-index:6;padding:12px;display:flex;gap:12px;align-items:stretch;flex-wrap:wrap;justify-content:center}
#blog{flex:1 1 280px;min-height:96px;padding:14px 16px;font-size:16px;line-height:1.5;display:flex;align-items:center}
#bcmd{flex:1 1 360px;display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:10px}
#bcmd .btn{text-align:left;padding:10px 12px;font-size:14px;border-radius:12px}
#bcmd .btn small{display:block;font-weight:400;color:var(--mute);font-size:11px;margin-top:2px}
#bcmd .btn .ty{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--acc);margin-right:7px}
#btlStage{position:absolute;inset:0;will-change:transform}
#capGame{position:absolute;inset:0;z-index:40;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.5)}
#capGame.on{display:flex}
#capBox{width:min(520px,92vw);padding:22px;text-align:center}
#capTrack{position:relative;height:38px;border-radius:12px;background:rgba(255,255,255,.08);margin:22px 0;overflow:hidden;border:1px solid var(--edge)}
#capZone{position:absolute;top:0;bottom:0;background:linear-gradient(90deg,rgba(141,255,176,.2),rgba(141,255,176,.7),rgba(141,255,176,.2))}
#capMark{position:absolute;top:-6px;bottom:-6px;width:6px;border-radius:4px;background:#fff;box-shadow:0 0 14px #fff}
#toast{position:absolute;left:50%;top:18px;transform:translateX(-50%);z-index:60;padding:10px 18px;font-size:14px;pointer-events:none;opacity:0;transition:opacity .4s;white-space:nowrap}
#toast.on{opacity:1}
#fade{position:fixed;inset:0;background:#000;opacity:0;pointer-events:none;transition:opacity .35s;z-index:70}
#touch{position:absolute;left:0;right:0;bottom:12px;z-index:25;display:flex;justify-content:space-between;padding:0 16px;pointer-events:none;opacity:.8}
#touch .pad{display:grid;grid-template-columns:repeat(3,54px);grid-template-rows:repeat(3,54px);gap:6px;pointer-events:auto}
#touch .pad button,#touch .acts button{background:rgba(255,255,255,.1);border:1px solid var(--edge);color:var(--ink);border-radius:14px;font-size:15px;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);pointer-events:auto;touch-action:none}
#touch .acts{display:flex;gap:10px;align-items:flex-end;pointer-events:auto}
#touch .acts button{width:64px;height:54px}
.hidden{display:none!important}
.kv{font-size:13px;color:var(--mute);line-height:1.7}
.kv b{color:var(--ink)}
.sbar{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
.slot{padding:14px;border-radius:14px}
input,textarea{font:inherit}
@media (max-width:640px){#bcmd{grid-template-columns:1fr 1fr}.grid2{grid-template-columns:1fr}#dialog .txt{font-size:15px}}
</style>
</head>
<body>
<!-- TITLE -->
<div id="title" class="scr on">
<canvas id="titleCanvas"></canvas>
<div class="tbox">
<div class="wordmark">TESSALINE</div>
<div class="tag">a world that sings back</div>
<div class="btnrow">
<button class="btn pri" id="btnNew">New Binding</button>
<button class="btn" id="btnContinue">Continue</button>
</div>
<div style="margin-top:18px"><button class="btn sm" id="btnTitleMute">Sound: on</button></div>
</div>
</div>
<!-- GAME -->
<div id="game" class="scr">
<canvas id="world"></canvas>
<div id="hud" class="glass"></div>
<div id="hudBtns">
<button class="btn sm" id="btnMenu">Menu</button>
<button class="btn sm" id="btnMute">Sound</button>
</div>
<div id="hint" class="glass"></div>
<div id="dialog" class="glass">
<div class="who" id="dlgWho"></div>
<div class="txt" id="dlgTxt"></div>
<div class="nx">▸ continue</div>
</div>
<div id="menu"><div class="panel glass" id="menuPanel"></div></div>
<div id="touch">
<div class="pad">
<span></span><button data-k="ArrowUp">▲</button><span></span>
<button data-k="ArrowLeft">◀</button><button data-k="Space">●</button><button data-k="ArrowRight">▶</button>
<span></span><button data-k="ArrowDown">▼</button><span></span>
</div>
<div class="acts">
<button data-k="Escape">Menu</button>
<button data-k="Enter">Act</button>
</div>
</div>
</div>
<!-- BATTLE -->
<div id="battle" class="scr">
<div id="btlStage"><canvas id="bcanvas"></canvas></div>
<div id="bpanel">
<div id="blog" class="glass"><span id="blogTxt"></span></div>
<div id="bcmd" class="glass"></div>
</div>
<div id="capGame">
<div id="capBox" class="glass">
<div class="phead" style="justify-content:center"><h2 style="font-size:17px;color:var(--acc)">Resonance Ring</h2></div>
<div class="kv">Stop the marker inside the glowing band. A steadier hand binds more deeply.</div>
<div id="capTrack"><div id="capZone"></div><div id="capMark"></div></div>
<button class="btn pri" id="capPress">Strike the Ring</button>
</div>
</div>
</div>
<div id="cutin"><div style="position:relative;z-index:1"><div class="cn" id="ciName"></div><div class="ct" id="ciText"></div><div class="ccont">tap or press Enter</div></div></div>
<div id="toast" class="glass"></div>
<div id="fade"></div>
<script>
'use strict';
/* ========== UTIL ========== */
const $=id=>document.getElementById(id);
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const rnd=(a,b)=>a+Math.random()*(b-a);
const ri=(a,b)=>Math.floor(a+Math.random()*(b-a+1));
const pick=a=>a[Math.floor(Math.random()*a.length)];
const lerp=(a,b,t)=>a+(b-a)*t;
function hashN(x,y,s){let h=(x*374761393+y*668265263+s*982451653)|0;h=(h^(h>>>13))*1274126177|0;return ((h^(h>>>16))>>>0)/4294967295;}
/* ========== TYPES ========== */
const TYPES={
A:{n:'Pyrel',c:'#ff8a4c'}, B:{n:'Aquon',c:'#4cc9ff'}, C:{n:'Verdox',c:'#86f56e'},
D:{n:'Nyxal',c:'#9b7bff'}, E:{n:'Vaelis',c:'#ffe66a'}, F:{n:'Chronal',c:'#ff7ad9'}, G:{n:'Ysolt',c:'#d6dcff'}
};
const BEATS={A:['B'],B:['C'],C:['A'],D:['A','C'],E:['D','B']};
// single attacker-vs-defender multiplier
function tmul1(a,d){
if(a==='F') return d==='G'?2:1.25;
if(d==='G') return 0.5;
if(BEATS[a]&&BEATS[a].includes(d)) return 2;
if(BEATS[d]&&BEATS[d].includes(a)) return 0.5;
return 1;
}
function tmul(a,defTypes){let m=1;for(const d of defTypes)m*=tmul1(a,d);return m;}
function effText(m){return m>=2?'It was super effective!':m<=0.5?'It was not very effective...':m>1?'A sharp strike!':'';}
/* ========== MOVES ==========
[name, type, power, accuracy, pp, effectCode, effectValue] */
const MOVES={
cindershot:['Cinder Shot','A',40,100,30,'none',0],
flare:['Flarebrand','A',55,95,20,'burn',25],
ashwhirl:['Ashwhirl','A',65,85,12,'none',0],
kindle:['Kindle Step','A',30,100,25,'haste',0],
pyreFist:['Pyre Fist','A',75,80,10,'crit',0],
emberRain:['Ember Rain','A',35,90,15,'multi',4],
brineJet:['Brine Jet','B',50,95,20,'none',0],
tideLash:['Undertow','B',40,100,25,'lowerDef',0],
frostVein:['Frost Vein','B',45,95,15,'frost',25],
bubbles:['Bubble Barrage','B',25,100,20,'multi',5],
mistVeil:['Mist Veil','B',0,100,20,'barrier',0],
pearl:['Pearl Mend','B',0,100,12,'heal',50],
thorn:['Thorn Lash','C',50,95,20,'none',0],
spore:['Static Pollen','C',0,90,15,'shock',70],
rootBind:['Root Bind','C',35,100,20,'lowerSpd',0],
bloom:['Bloom Mend','C',0,100,10,'heal',50],
vine:['Snap Ahead','C',70,85,10,'prio',0],
seedRegen:['Seed Regen','C',0,100,12,'regen',0],
shade:['Shade Claw','D',55,95,18,'crit',0],
hush:['Hush Bite','D',45,100,20,'drain',50],
nightVeil:['Night Veil','D',0,100,15,'barrier',0],
dread:['Dread Pulse','D',60,90,12,'lowerAtk',0],
gloam:['Gloam Wave','D',30,100,25,'shock',20],
eclipse:['Eclipse Strike','D',85,75,8,'none',0],
lux:['Lux Beam','E',60,95,18,'none',0],
glint:['Glint Shard','E',35,100,25,'prio',0],
prism:['Prism Lance','E',80,85,8,'crit',0],
aureate:['Aureate Mend','E',0,100,10,'heal',50],
flashBind:['Flash Bind','E',20,100,20,'shock',35],
halo:['Halo Ward','E',0,100,15,'barrier',0],
tick:['Tick Snap','F',45,100,20,'prio',0],
rewind:['Rewind Pulse','F',0,100,10,'heal',60],
timeFrac:['Time Fracture','F',75,90,10,'frost',30],
epoch:['Epoch Swing','F',65,90,12,'lowerDef',0],
hourglass:['Hourglass Sand','F',0,100,15,'haste',0],
stillness:['Stillness','F',0,100,12,'regen',0],
shove:['Gravel Shove','G',40,100,35,'none',0],
quiet:['Quiet Touch','G',40,100,30,'none',0],
stare:['Hollow Stare','G',0,100,20,'lowerAtk',0],
sepulchre:['Sepulchral Bite','G',65,90,12,'drain',50],
mirror:['Mirror Skin','G',0,100,15,'barrier',0],
hymn:['Ashen Hymn','G',25,100,25,'multi',3],
null:['Null Tide','G',90,80,6,'none',0],
cleave:['Cleave Down','G',60,95,15,'lowerDef',0],
gale:['Gale Wing','G',50,90,20,'prio',0],
thornCrown:['Crown of Thorns','C',60,90,12,'drain',40],
lumenPulse:['Lumen Pulse','E',45,100,20,'shock',15]
};
/* ========== STATUS ========== */
const STATUS={burn:['Burned','#ff8a4c'],frost:['Frosted','#8fe3ff'],shock:['Shocked','#ffe66a'],regen:['Mending','#86f56e'],haste:['Hastened','#ffd27a']};
/* ========== CREATURES ==========
base species: id, n(name), t(types), s[hp,atk,def,spd], lr(learnset [lvl,move]), lore */
const SPECIES={
cinder:{n:'Cinderwisp',t:['A'],s:[40,44,30,48],lr:[[1,'cindershot'],[1,'shove'],[6,'kindle'],[10,'flare'],[15,'emberRain'],[21,'ashwhirl'],[28,'pyreFist']],lore:'A flicker of warm dust that remembers the hearth it was born from. It burns brightest when someone is listening.'},
tide:{n:'Tidemoth',t:['B'],s:[42,36,40,44],lr:[[1,'brineJet'],[1,'mistVeil'],[7,'tideLash'],[12,'bubbles'],[18,'frostVein'],[25,'pearl']],lore:'A moth of salt mist that drifts along coastlines, folding its wings around the lost.'},
sprig:{n:'Sprigling',t:['C'],s:[46,34,42,36],lr:[[1,'thorn'],[1,'bloom'],[6,'rootBind'],[11,'seedRegen'],[16,'vine'],[23,'spore']],lore:'A seed that woke early and never regretted it. Its roots hum softly in the dark.'},
mossboar:{n:'Mossboar',t:['C'],s:[60,46,52,24],lr:[[1,'thorn'],[1,'shove'],[8,'rootBind'],[14,'bloom'],[20,'vine'],[28,'spore']],lore:'A stout grazer whose back is a garden. It rests where the rain pools.'},
brinehorn:{n:'Brinehorn',t:['B'],s:[50,50,46,30],lr:[[1,'brineJet'],[1,'shove'],[9,'tideLash'],[13,'bubbles'],[19,'frostVein'],[27,'pearl']],lore:'A crest-headed grazer that lowers its horn into tidal pools to listen for storms.'},
pyralisk:{n:'Pyralisk',t:['A'],s:[44,52,40,40],lr:[[1,'cindershot'],[1,'shove'],[8,'flare'],[14,'pyreFist'],[20,'emberRain'],[26,'ashwhirl']],lore:'A lizard-shaped kiln that cools slowly in the wind and wakes in the heat of combat.'},
dusklet:{n:'Dusklet',t:['D'],s:[34,40,34,46],lr:[[1,'shade'],[1,'hush'],[7,'nightVeil'],[12,'dread'],[17,'gloam'],[24,'eclipse']],lore:'A small night-thing that hides at the edges of lanterns and gathers their lost sparks.'},
hollowmaw:{n:'Hollowmaw',t:['D'],s:[54,50,44,34],lr:[[1,'hush'],[1,'shade'],[10,'dread'],[15,'nightVeil'],[21,'gloam'],[30,'eclipse']],lore:'Its jaw opens onto a room that is always a little darker than the one you stand in.'},
glimmerkit:{n:'Glimmerkit',t:['E'],s:[36,38,34,50],lr:[[1,'lux'],[1,'glint'],[6,'flashBind'],[11,'halo'],[16,'aureate'],[22,'prism']],lore:'A quick, luminous fox drawn in light by a patient and curious hand.'},
auralynx:{n:'Auralynx',t:['E'],s:[48,48,44,46],lr:[[1,'lux'],[1,'glint'],[9,'flashBind'],[14,'halo'],[19,'prism'],[28,'aureate']],lore:'Its ruff rings like a struck bell whenever a sky begins to clear.'},
chronoram:{n:'Chronoram',t:['F'],s:[44,44,46,40],lr:[[1,'tick'],[1,'epoch'],[7,'rewind'],[13,'hourglass'],[18,'timeFrac'],[26,'stillness']],lore:'A ram of ticking gears that charges backward in the moment before a blow lands.'},
hourwhelp:{n:'Hourwhelp',t:['F'],s:[36,40,38,58],lr:[[1,'tick'],[1,'glint'],[5,'hourglass'],[10,'timeFrac'],[16,'epoch'],[23,'rewind']],lore:'Newly hatched, it measures the rest of its life in slightly uneven heartbeats.'},
ysoltmote:{n:'Ysoltmote',t:['G'],s:[46,42,50,38],lr:[[1,'quiet'],[1,'stare'],[8,'mirror'],[14,'hymn'],[20,'sepulchre'],[26,'null']],lore:'A moth made of the pause between two notes, quiet enough to be mistaken for absence.'},
stillwraith:{n:'Stillwraith',t:['G'],s:[40,46,40,44],lr:[[1,'quiet'],[1,'stare'],[7,'hymn'],[12,'mirror'],[18,'sepulchre'],[25,'cleave']],lore:'It walks the empty halls of a bell that never rang, listening for the first note.'},
ashgrazer:{n:'Ashgrazer',t:['A'],s:[50,46,44,38],lr:[[1,'cindershot'],[1,'thorn'],[9,'flare'],[12,'kindle'],[18,'pyreFist'],[25,'ashwhirl']],lore:'It nibbles embers from dead hearths and leaves a thin grey dusting behind.'},
gloomfin:{n:'Gloomfin',t:['B','D'],s:[46,44,42,42],lr:[[1,'brineJet'],[1,'hush'],[8,'bubbles'],[13,'nightVeil'],[19,'frostVein'],[26,'dread']],lore:'A fish that swims between tide and midnight, leaving a trail of cold ink.'},
thornbell:{n:'Thornbell',t:['C','E'],s:[44,42,46,44],lr:[[1,'thorn'],[1,'lux'],[9,'rootBind'],[14,'flashBind'],[20,'bloom'],[27,'prism']],lore:'Its blossoms ring when pollinators pass, and the bees are always polite about it.'},
riftgull:{n:'Riftgull',t:['F','A'],s:[40,48,38,52],lr:[[1,'tick'],[1,'flare'],[10,'epoch'],[15,'timeFrac'],[22,'emberRain'],[30,'hourglass']],lore:'A bird that flies a little ahead of its own shadow, so every landing is already remembered.'},
// transformed forms
cindraxis:{n:'Cindraxis',t:['A'],s:[62,62,50,60],lr:[[1,'cindershot'],[1,'flare'],[1,'shove'],[30,'pyreFist'],[36,'emberRain']],lore:'Its kiln-ribs open like a bellows. Where it treads, grass remembers being ash.',xf:{from:'cinder',lv:[16,30],cat:'emberCore'}},
tidespire:{n:'Tidespire',t:['B'],s:[60,58,60,58],lr:[[1,'brineJet'],[1,'mistVeil'],[30,'frostVein'],[36,'bubbles'],[40,'pearl']],lore:'A moth that became a tower of tide. Its wings now pull weather toward the shore.',xf:{from:'tide',lv:[18,32],cat:'pearlHeart'}},
verdalon:{n:'Verdalon',t:['C'],s:[66,54,62,46],lr:[[1,'thorn'],[1,'rootBind'],[30,'vine'],[36,'seedRegen'],[42,'thornCrown']],lore:'The seed grew into a patient guardian whose crown has never once been pruned.',xf:{from:'sprig',lv:[18,32],cat:'rootCrown'}},
nocturael:{n:'Nocturael',t:['D'],s:[52,62,50,60],lr:[[1,'shade'],[1,'hush'],[30,'gloam'],[36,'eclipse'],[42,'nightVeil']],lore:'The small night-thing learned to open its own lantern, and the dark began to bow.',xf:{from:'dusklet',lv:[22,34],cat:'duskEye'}},
lysor:{n:'Lysor',t:['E'],s:[58,58,50,70],lr:[[1,'lux'],[1,'glint'],[30,'prism'],[36,'halo'],[42,'lumenPulse']],lore:'A fox of pure, unhurried light that sings whenever a bell is rung in another world.',xf:{from:'glimmerkit',lv:[22,36],cat:'sunlace'}},
epochurn:{n:'Epochurn',t:['F'],s:[58,64,60,50],lr:[[1,'tick'],[1,'epoch'],[30,'timeFrac'],[36,'hourglass'],[44,'stillness']],lore:'The ram that charged backward now carries an entire afternoon on its horns.',xf:{from:'chronoram',lv:[26,40],cat:'chronoGear'}},
ysoltrine:{n:'Ysoltrine',t:['G'],s:[60,66,60,54],lr:[[1,'quiet'],[1,'stare'],[30,'sepulchre'],[36,'mirror'],[44,'null']],lore:'The stillness of the bell finally rang, and its echo is a mirror for everything that hears it.',xf:{from:'stillwraith',lv:[26,40],cat:'voidHymnal'}},
barkmantle:{n:'Barkmantle',t:['C'],s:[84,58,70,30],lr:[[1,'thorn'],[1,'shove'],[30,'rootBind'],[36,'bloom'],[42,'seedRegen']],lore:'A grazer whose garden grew into a grove. Travelers now stop to rest upon its back.',xf:{from:'mossboar',lv:[24,38],cat:'barkBeacon'}},
paradoxgale:{n:'Paradoxgale',t:['F','A'],s:[54,62,48,80],lr:[[1,'tick'],[1,'flare'],[30,'timeFrac'],[36,'emberRain'],[44,'hourglass']],lore:'A bird that now lands before it takes off, and whose shadow sometimes arrives first.',xf:{from:'riftgull',lv:[30,44],cat:'paradoxFeather'}}
};
const BASE_IDS=Object.keys(SPECIES).filter(k=>!SPECIES[k].xf);
const XF_IDS=Object.keys(SPECIES).filter(k=>SPECIES[k].xf);
const ALL_IDS=Object.keys(SPECIES);
const XF_OF={};for(const k of XF_IDS)XF_OF[SPECIES[k].xf.from]=k;
/* ========== ITEMS ========== */
const ITEMS={
shard:{n:'Glass Shard',k:'capture',tier:1,cost:25,d:'A brittle lens that snares a weakened creature. Tier I.'},
bead:{n:'Resonant Bead',k:'capture',tier:2,cost:90,d:'Hums in tune with wild hearts. Tier II.'},
sphere:{n:'Lattice Sphere',k:'capture',tier:3,cost:260,d:'A cage of woven silence. Tier III.'},
draught:{n:'Mend Draught',k:'heal',v:40,cost:30,d:'Restores 40 HP.'},
elixir:{n:'Vital Elixir',k:'heal',v:120,cost:110,d:'Restores 120 HP.'},
tonic:{n:'Revival Tonic',k:'revive',v:0.5,cost:150,d:'Revives a fainted creature to half HP.'},
salve:{n:'Clear Salve',k:'cure',cost:40,d:'Removes any status condition.'},
chime:{n:'Lure Chime',k:'field',cost:60,d:'Doubles wild encounters for 200 steps.'},
feather:{n:'Gale Feather',k:'flee',cost:50,d:'Escapes any wild battle.'},
emberCore:{n:'Ember Core',k:'cat',cost:400,zone:2,d:'Catalyst. Awakens Cinderwisp into Cindraxis.'},
pearlHeart:{n:'Pearl Heart',k:'cat',cost:400,zone:1,d:'Catalyst. Awakens Tidemoth into Tidespire.'},
rootCrown:{n:'Root Crown',k:'cat',cost:400,zone:0,d:'Catalyst. Awakens Sprigling into Verdalon.'},
duskEye:{n:'Dusk Eye',k:'cat',cost:400,zone:3,d:'Catalyst. Awakens Dusklet into Nocturael.'},
sunlace:{n:'Sunlace',k:'cat',cost:400,zone:4,d:'Catalyst. Awakens Glimmerkit into Lysor.'},
chronoGear:{n:'Chrono Gear',k:'cat',cost:400,zone:5,d:'Catalyst. Awakens Chronoram into Epochurn.'},
voidHymnal:{n:'Void Hymnal',k:'cat',cost:400,zone:5,d:'Catalyst. Awakens Stillwraith into Ysoltrine.'},
barkBeacon:{n:'Bark Beacon',k:'cat',cost:400,zone:1,d:'Catalyst. Awakens Mossboar into Barkmantle.'},
paradoxFeather:{n:'Paradox Feather',k:'cat',cost:400,zone:5,d:'Catalyst. Awakens Riftgull into Paradoxgale.'}
};
const SHOP_ORDER=['shard','bead','sphere','draught','elixir','tonic','salve','chime','feather','emberCore','pearlHeart','rootCrown','duskEye','sunlace','chronoGear','voidHymnal','barkBeacon','paradoxFeather'];
/* ========== ZONES ==========
pal: [top,bottom,accent]; pt: particle kind; rate: encounter base; spawn: [species,weight,lvMin,lvMax] */
const ZONES=[
{n:'Hollowfern Meadows',pal:['#18402f','#07140f','#9cf5a8'],pt:'spore',rate:0.07,
spawn:[['sprig',4,3,5],['mossboar',3,3,5],['cinder',2,3,5],['dusklet',1,3,4]],
guard:{n:'Warden Oru',lv:6,team:[['mossboar',6],['sprig',6]],lore:'The first warden guards the fern-gate with a slow, patient smile.'}, anchor:false,
drops:['rootCrown','barkBeacon'], msg:'Ferns whisper as you enter.'},
{n:'Saltglass Shores',pal:['#103c5e','#060f1f','#8fe3ff'],pt:'bubble',rate:0.08,
spawn:[['tide',3,7,9],['brinehorn',3,7,9],['glimmerkit',2,7,9],['sprig',1,7,8]],
guard:{n:'Warden Ilse',lv:11,team:[['brinehorn',11],['tide',10],['glimmerkit',10]],lore:'She keeps the tide-tally, counting every wave that has ever broken.'},anchor:false,
drops:['pearlHeart','barkBeacon'], msg:'Salt crystals chime underfoot.'},
{n:'Cinderlace Ridge',pal:['#4a1a12','#140605','#ffb27a'],pt:'ember',rate:0.09,
spawn:[['pyralisk',3,13,16],['cinder',2,13,16],['ashgrazer',3,13,16],['dusklet',1,13,15]],
guard:{n:'Anchor Vesk',lv:17,team:[['pyralisk',18],['ashgrazer',18],['cinder',17]],lore:'An anchor of smoldering iron. Defeating it steadies the ridge.'},anchor:true,
drops:['emberCore'], msg:'Heat shimmers over the ridge.'},
{n:'Nocturne Hollows',pal:['#2a1656','#080413','#c9a8ff'],pt:'mote',rate:0.1,
spawn:[['dusklet',3,20,23],['hollowmaw',2,20,23],['gloomfin',3,20,23],['stillwraith',1,21,23]],
guard:{n:'Anchor Thessaly',lv:24,team:[['hollowmaw',24],['gloomfin',24],['stillwraith',23]],lore:'A hollow choir of shadows that hums one long, low note.'},anchor:true,
drops:['duskEye'], msg:'The hollows swallow your footsteps.'},
{n:'Lumenreach Spires',pal:['#5a4a10','#120e03','#fff0a0'],pt:'spark',rate:0.11,
spawn:[['glimmerkit',3,27,30],['auralynx',2,27,30],['thornbell',3,27,30],['chronoram',1,28,30]],
guard:{n:'Anchor Ophel',lv:32,team:[['auralynx',32],['thornbell',31],['glimmerkit',31]],lore:'A spire-bright sentinel whose halo has not dimmed in a thousand dawns.'},anchor:true,
drops:['sunlace'], msg:'Light pools in the spires like honey.'},
{n:'Chronoskein Wastes',pal:['#4a1a40','#0d0410','#ff9ae6'],pt:'gear',rate:0.12,
spawn:[['chronoram',3,36,39],['hourwhelp',3,36,39],['riftgull',2,36,39],['ysoltmote',2,37,40]],
guard:{n:'Anchor Mirrenna',lv:40,team:[['riftgull',40],['chronoram',40],['ysoltmote',39]],lore:'She is the last anchor, and she remembers every version of you that failed.'},anchor:true,
drops:['chronoGear','voidHymnal','paradoxFeather'], msg:'Time frays in long silver threads.'}
];
/* ========== STORY ========== */
const STORY=[
{at:'start',name:'Prologue',text:'The world of Tessaline is a lattice of songs. Seven resonances hold it together, and each one has gone quiet. You are a Binder, and the old bells have asked you to listen.'},
{at:'zone2',name:'Stage II',text:'The tide-warden fell, and for a moment the sea hummed a clear note. But the anchors of the ridge and hollow are dimming. Something is unwinding the threads between the zones.'},
{at:'zone4',name:'Stage III',text:'Three anchors have sung their last. Every creature you have bound grows steadier, as if they remember a world before the fracture. The last anchor waits where time frays.'},
{at:'final',name:'Stage IV',text:'Mirrenna stood alone among the silver threads. "Every version of this world ends here, unless one listener stays." You chose to stay. The lattice rang, and the world began to sing again.'}
];
/* ========== AUDIO ========== */
const AU={ctx:null,master:null,muted:false,zone:0};
const ROOTS=[146.83,164.81,130.81,110.0,196.0,123.47];
const PROG=[[0,4,7],[9,12,16],[5,9,12],[7,11,14]];
const PROG_MIN=[[0,3,7],[8,11,15],[5,8,12],[7,11,14]];
function audioInit(){
if(AU.ctx) return;
try{
const AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;
AU.ctx=new AC(); AU.master=AU.ctx.createGain(); AU.master.gain.value=AU.muted?0:0.22; AU.master.connect(AU.ctx.destination);
}catch(e){AU.ctx=null;}
}
function setMuted(m){
AU.muted=m;
if(AU.master) AU.master.gain.setTargetAtTime(m?0:0.22,AU.ctx.currentTime,0.05);
const lbl=m?'Sound: off':'Sound: on';
$('btnMute').textContent=lbl; $('btnTitleMute').textContent=lbl;
try{localStorage.setItem('tessaline.mute',m?'1':'0');}catch(e){}
}
function fq(root,semi){return root*Math.pow(2,semi/12);}
function note(freq,t,dur,type,vol,dest){
const o=AU.ctx.createOscillator(), g=AU.ctx.createGain();
o.type=type; o.frequency.value=freq;
g.gain.setValueAtTime(0.0001,t);
g.gain.exponentialRampToValueAtTime(vol,t+Math.min(0.3,dur*0.3));
g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
o.connect(g); g.connect(dest||AU.master); o.start(t); o.stop(t+dur+0.05);
}
function sfxTone(freq,dur,type,vol,slide,delay){
if(!AU.ctx||AU.muted) return;
const t=AU.ctx.currentTime+(delay||0);
const o=AU.ctx.createOscillator(), g=AU.ctx.createGain();
o.type=type||'square'; o.frequency.setValueAtTime(freq,t);
if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(20,slide),t+dur);
g.gain.setValueAtTime(vol||0.15,t); g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
o.connect(g); g.connect(AU.master); o.start(t); o.stop(t+dur+0.02);
}
const SFX={
hit:()=>sfxTone(300,0.12,'square',0.12,90),
crit:()=>{sfxTone(520,0.1,'sawtooth',0.1,160);sfxTone(880,0.14,'triangle',0.12,1200,0.06);},
eff:()=>{sfxTone(660,0.12,'triangle',0.12,990);sfxTone(990,0.18,'triangle',0.1,1320,0.09);},
ui:()=>sfxTone(780,0.05,'triangle',0.07),
faint:()=>sfxTone(400,0.6,'sawtooth',0.1,60),
cap:()=>[0,4,7,12].forEach((s,i)=>sfxTone(440*Math.pow(2,s/12),0.22,'triangle',0.12,null,i*0.09)),
lvl:()=>[0,4,7,12,16].forEach((s,i)=>sfxTone(523*Math.pow(2,s/12),0.2,'triangle',0.1,null,i*0.08)),
fail:()=>sfxTone(220,0.25,'sawtooth',0.1,110),
heal:()=>[0,4,7].forEach((s,i)=>sfxTone(660*Math.pow(2,s/12),0.25,'sine',0.11,null,i*0.07)),
step:()=>sfxTone(180,0.03,'triangle',0.035),
bind:()=>sfxTone(990,0.3,'sine',0.1,1500)
};
function padChord(zi,ci){
if(!AU.ctx||AU.muted) return;
const t=AU.ctx.currentTime+0.05, root=ROOTS[zi%6];
const prog=(zi===0||zi===1||zi===4)?PROG:PROG_MIN;
const ch=prog[ci%4];
const lp=AU.ctx.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=(zi===3||zi===5)?700:1100;
const g=AU.ctx.createGain(); g.gain.value=1; lp.connect(g); g.connect(AU.master);
const dur=4.2;
ch.forEach(s=>{ note(fq(root,s-12),t,dur,'triangle',0.045,lp); note(fq(root,s-12)*1.004,t,dur,'sine',0.025,lp); });
setTimeout(()=>{try{g.disconnect();lp.disconnect();}catch(e){}},(dur+0.8)*1000);
}
function padMelody(zi,ci){
if(!AU.ctx||AU.muted) return;
if(Math.random()>0.55) return;
const root=ROOTS[zi%6], prog=(zi===0||zi===1||zi===4)?PROG:PROG_MIN, ch=prog[ci%4];
const s=pick(ch)+12+pick([0,12]);
note(fq(root,s),AU.ctx.currentTime+0.02,0.9,'sine',0.028);
}
let padCi=0;
setInterval(()=>{ if(AU.ctx&&!AU.muted){ padChord(AU.zone,padCi++); } },4200);
setInterval(()=>{ if(AU.ctx&&!AU.muted){ padMelody(AU.zone,padCi); } },700);
/* ========== SAVE ========== */
const SAVE_KEY='tessaline.save.v1';
function freshState(){
return {started:false,party:[],box:[],bag:{shard:5,draught:3,bead:2,salve:1,feather:1},gold:200,
caught:{},seen:{},bonus:{hp:1,atk:1,def:1,spd:1},stories:{},shrine:{x:3,y:10},px:3,py:10,
steps:0,post:false,ended:false,tut:0,lure:0,zone:0,wins:0,
beat:[false,false,false,false,false,false]};
}
let G=freshState();
function persist(manual){
try{ localStorage.setItem(SAVE_KEY,JSON.stringify(G)); if(manual) toast('Progress recorded at the shrine.'); return true; }
catch(e){ toast('Could not record progress.'); return false; }
}
function readSave(){
try{
const s=JSON.parse(localStorage.getItem(SAVE_KEY)||'null');
if(s&&s.started&&Array.isArray(s.party)&&s.party.length) return s;
}catch(e){}
return null;
}
/* ========== CREATURE MODEL ==========
creature = {id, lv, xp, hp, maxHp, moves:[{k,pp,max}]}; battle-only state lives in a WeakMap */
const BST=new WeakMap();
function bst(c){
let b=BST.get(c);
if(!b){ b={burn:false,frost:false,shock:false,barrier:false,regen:0,haste:0,mods:{atk:1,def:1,spd:1},skip:false}; BST.set(c,b); }
return b;
}
function calcStat(id,lv,k){
const b=SPECIES[id].s, i={hp:0,atk:1,def:2,spd:3}[k];
const f=k==='spd'?(1+lv/40):(1+lv/25);
return Math.max(1,Math.floor(b[i]*f));
}
function playerStat(c,k){ return Math.floor(calcStat(c.id,c.lv,k)*(G.bonus[k]||1)); }
function expNext(lv){ return Math.floor(lv*lv*5)+15; }
function addMove(c,k){
if(c.moves.some(m=>m.k===k)) return false;
const mv=MOVES[k]; c.moves.push({k,pp:mv[4],max:mv[4]});
if(c.moves.length>4) c.moves.shift();
return true;
}
function mkCreature(id,lv){
const c={id,lv,xp:0,hp:1,maxHp:1,moves:[]};
for(const [l,m] of SPECIES[id].lr) if(l<=lv) addMove(c,m);
c.maxHp=calcStat(id,lv,'hp'); c.hp=c.maxHp;
return c;
}
function healPct(c,p){ c.hp=Math.min(c.maxHp,c.hp+Math.ceil(c.maxHp*p)); }
// returns {lv, learned:[], levels:n}
function addXp(c,n){
const out={learned:[],levels:0,from:c.lv};
c.xp+=n;
while(c.lv<50 && c.xp>=expNext(c.lv)){
c.xp-=expNext(c.lv); c.lv++; out.levels++;
const oldMax=c.maxHp; c.maxHp=calcStat(c.id,c.lv,'hp'); c.hp=Math.min(c.maxHp,c.hp+(c.maxHp-oldMax));
for(const [l,m] of SPECIES[c.id].lr) if(l===c.lv){ if(addMove(c,m)) out.learned.push(MOVES[m][0]); }
}
if(c.lv>=50) c.xp=0;
return out;
}
function isFainted(c){return c.hp<=0;}
function name(c){return SPECIES[c.id].n;}
function xfReady(c){ const xf=XF_OF[c.id]; if(!xf) return null; const s=SPECIES[xf].xf; if(c.lv<s.lv[0]) return null; return {to:xf,cat:s.cat,need:s.lv}; }
function partyCount(){return G.party.length;}
function aliveParty(){return G.party.filter(c=>!isFainted(c));}
function bagCount(k){return G.bag[k]||0;}
function bagAdd(k,n){ G.bag[k]=(G.bag[k]||0)+(n||1); }
function bagTake(k){ if(!G.bag[k]) return false; G.bag[k]--; if(G.bag[k]<=0) delete G.bag[k]; return true; }
function markSeen(id){ G.seen[id]=true; }
function markCaught(id){ G.caught[id]=true; }
function uniqueCaught(){ return Object.keys(G.caught).filter(k=>SPECIES[k]).length; }
function anchorsBeaten(){ return ZONES.filter((z,i)=>z.anchor&&G.beat[i]).length; }
function finalObjectiveMet(){ return uniqueCaught()>=8 && anchorsBeaten()>=4; }
/* ========== PARTICLES ========== */
const PART=[];
function burst(x,y,n,col,spd,life,size,grav){
for(let i=0;i<n&&PART.length<700;i++){
const a=Math.random()*Math.PI*2, s=spd*(0.4+Math.random()*0.8);
PART.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:life*(0.6+Math.random()*0.5),max:life,col,sz:size*(0.5+Math.random()),g:grav||0,sp:0});
}
}
function updParts(dt){
for(let i=PART.length-1;i>=0;i--){
const p=PART[i]; p.life-=dt; if(p.life<=0){PART.splice(i,1);continue;}
p.vy+=p.g*dt; p.x+=p.vx*dt; p.y+=p.vy*dt; p.vx*=0.98;
}
}
function drawParts(c){
for(const p of PART){
const a=clamp(p.life/p.max,0,1);
c.globalAlpha=a; c.fillStyle=p.col;
c.beginPath(); c.arc(p.x,p.y,p.sz*(0.4+0.6*a),0,6.283); c.fill();
}
c.globalAlpha=1;
}
/* ambient zone particles (screen space) */
const AMB=[];
const AMB_KIND={spore:['#b6ffc4'],bubble:['#bfefff'],ember:['#ffb070','#ff8a4c'],mote:['#d8b8ff'],spark:['#fff6b0'],gear:['#ffc2ee']};
function ambSpawn(kind,w,h){
const cols=AMB_KIND[kind]; const isEm=kind==='ember'||kind==='spark';
AMB.push({x:Math.random()*w,y:isEm?h+10:Math.random()*h,vx:rnd(-8,8),vy:kind==='ember'?rnd(-40,-18):kind==='bubble'?rnd(-30,-12):rnd(-12,6),
r:rnd(1.2,3.2),life:0,max:rnd(4,9),col:pick(cols),ph:Math.random()*6});
if(AMB.length>140) AMB.shift();
}
function updAmb(dt,w,h,kind){
if(Math.random()<dt*(kind==='mote'?3:9)) ambSpawn(kind,w,h);
for(let i=AMB.length-1;i>=0;i--){
const p=AMB[i]; p.life+=dt; p.x+=(p.vx+Math.sin(p.life*1.3+p.ph)*10)*dt; p.y+=p.vy*dt;
if(p.life>p.max||p.y<-20||p.y>h+20) AMB.splice(i,1);
}
}
function drawAmb(c){
for(const p of AMB){
const a=Math.min(1,p.life*1.5)*Math.max(0,1-(p.life/p.max))*0.7;
c.globalAlpha=a; c.fillStyle=p.col; c.beginPath(); c.arc(p.x,p.y,p.r,0,6.283); c.fill();
}
c.globalAlpha=1;
}
/* ========== CREATURE SPRITES ==========
Procedural: body hue from affinity, accessory from primary type. */
function drawCreature(c,id,x,y,size,flip,t,alpha){
const sp=SPECIES[id], ty=sp.t[0], col=TYPES[ty].c, sc=size/100;
const seed=ALL_IDS.indexOf(id)+1, bob=Math.sin((t||0)*2.2+seed)*2*sc;
c.save(); c.translate(x,y+bob); c.scale(flip?-sc:sc,sc); c.globalAlpha=alpha==null?1:alpha;
// glow
const gg=c.createRadialGradient(0,0,10,0,0,70); gg.addColorStop(0,col+'aa'); gg.addColorStop(1,col+'00');
c.fillStyle=gg; c.beginPath(); c.arc(0,0,70,0,6.283); c.fill();
// accessories behind body
c.fillStyle=col;
if(ty==='B'){ c.beginPath(); c.moveTo(-30,-5); c.quadraticCurveTo(-70,-30,-62,15); c.quadraticCurveTo(-45,8,-30,15); c.fill();
c.beginPath(); c.moveTo(30,-5); c.quadraticCurveTo(70,-30,62,15); c.quadraticCurveTo(45,8,30,15); c.fill(); }
if(ty==='D'){ c.fillStyle='#2a1d44'; c.beginPath(); c.moveTo(-26,-28); c.lineTo(-38,-62); c.lineTo(-12,-40); c.fill();
c.beginPath(); c.moveTo(26,-28); c.lineTo(38,-62); c.lineTo(12,-40); c.fill(); c.fillStyle=col; }
if(ty==='F'){ c.strokeStyle=col; c.lineWidth=5; c.beginPath(); c.arc(0,-6,48,0,6.283); c.stroke();
c.strokeStyle='#fff'; c.lineWidth=2; c.beginPath(); c.moveTo(0,-6); c.lineTo(Math.cos(t*2+seed)*22,-6+Math.sin(t*2+seed)*22); c.stroke(); c.fillStyle=col; }
// body
const bg=c.createRadialGradient(-10,-12,6,0,0,46); bg.addColorStop(0,'#ffffff'); bg.addColorStop(0.25,col); bg.addColorStop(1,'#0b0c1c');
c.fillStyle=bg; c.beginPath();
if(ty==='G'){ c.ellipse(0,0,36,30,0,0,6.283); }
else c.ellipse(0,2,38,33+((seed%3)*2),0,0,6.283);
c.fill();
if(ty==='G'){ c.globalAlpha*=0.5; c.beginPath(); for(let i=0;i<=12;i++){const xx=-36+i*6; c.lineTo(xx,28+Math.sin(t*4+i)*4);} c.lineTo(36,50); c.lineTo(-36,50); c.fillStyle=col; c.fill(); c.globalAlpha/=0.5; }
if(ty==='A'){ c.fillStyle=col; for(let i=-2;i<=2;i++){ c.beginPath(); c.moveTo(i*10-6,-26); c.lineTo(i*10,-46-Math.abs(i)*3-Math.sin(t*9+i)*3); c.lineTo(i*10+6,-26); c.fill(); } }
if(ty==='C'){ c.fillStyle='#8fff9a'; for(let i=0;i<4;i++){ c.save(); c.rotate(-0.9+i*0.6); c.beginPath(); c.ellipse(0,-44,6,16,0,0,6.283); c.fill(); c.restore(); } }
if(ty==='E'){ c.strokeStyle='#fff3a8'; c.lineWidth=4; c.beginPath(); c.ellipse(0,-40,40,10+Math.sin(t*3)*1.5,0,0,6.283); c.stroke(); }
// face
c.fillStyle='#0a0b18';
c.beginPath(); c.arc(-13,-2,5.5,0,6.283); c.arc(13,-2,5.5,0,6.283); c.fill();
c.fillStyle='#fff'; c.beginPath(); c.arc(-11,-4,1.8,0,6.283); c.arc(15,-4,1.8,0,6.283); c.fill();
c.strokeStyle='#0a0b18'; c.lineWidth=2.4; c.beginPath(); c.arc(0,12,6,0.2,2.9); c.stroke();
if(SPECIES[id].xf){ c.strokeStyle='#fff'; c.globalAlpha*=0.7; c.lineWidth=2; c.beginPath(); c.arc(0,0,52+Math.sin(t*3)*3,0,6.283); c.stroke(); }
c.restore();
}
/* ========== OVERWORLD MAP ========== */
const MW=48,MH=30,ZW=16,ZH=15;
const ZC=[0,1,2,2,1,0], ZR=[0,0,0,1,1,1];
const T_GRASS=0,T_PATH=1,T_ROCK=2,T_WATER=3,T_SHRINE=4,T_GUARD=5,T_TREE=6,T_GATE=7,T_NODE=8;
const SPEC_LOC=[{shrine:[3,3],node:[12,4],guard:[8,11]},{shrine:[4,3],node:[11,5],guard:[8,11]},{shrine:[3,4],node:[12,3],guard:[8,11]},
{shrine:[4,4],node:[11,4],guard:[8,11]},{shrine:[3,3],node:[12,5],guard:[8,11]},{shrine:[4,4],node:[12,4],guard:[8,11]}];
const GCOL=[['#2d6b47','#1b4530','#3f8a5a'],['#2d7d94','#1a5264','#4fb0c4'],['#86452f','#5a2a1d','#a85c3e'],['#4a3580','#2c1f55','#6a50a8'],['#86742e','#5c4c1e','#b59a48'],['#7a3370','#4a1d46','#a04f92']];
let W=new Uint8Array(MW*MH);
const TILE_PAL={};
function zoneOfTile(x,y){const c=Math.floor(x/ZW),r=Math.floor(y/ZH);for(let z=0;z<6;z++)if(ZC[z]===c&&ZR[z]===r)return z;return 0;}
function genWorld(){
W=new Uint8Array(MW*MH);
for(let y=0;y<MH;y++)for(let x=0;x<MW;x++){
const z=zoneOfTile(x,y), col=ZC[z], row=ZR[z], lx=x-col*ZW, ly=y-row*ZH;
let t=T_GRASS; const r=hashN(x,y,z+3);
if(r<0.09) t=T_TREE; else if(r<0.13) t=T_ROCK;
if(hashN(x>>2,y>>2,z+9)>0.84 && lx>1&&lx<14&&ly>1&&ly<13) t=T_WATER;
if(ly===7||lx===8) t=T_PATH;
if(lx===0||lx===15){
const oc=lx===0?col-1:col+1;
t=(oc<0||oc>2)?T_ROCK:(ly===7?T_GATE:T_ROCK);
}
if(ly===0||ly===14){
const or=ly===0?row-1:row+1;
t=(or<0||or>1)?T_ROCK:(lx===8?T_GATE:T_ROCK);
}
W[y*MW+x]=t;
}
for(let z=0;z<6;z++){
const s=SPEC_LOC[z], ox=ZC[z]*ZW, oy=ZR[z]*ZH;
const put=(lx,ly,t)=>{ W[(oy+ly)*MW+ox+lx]=t; };
const shr=s.shrine, nd=s.node, gd=s.guard;
for(let ly=shr[1];ly<=7;ly++) put(shr[0],ly,T_PATH);
for(let ly=nd[1];ly<=7;ly++) put(nd[0],ly,T_PATH);
for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){ if(!(dx===0&&dy===0)){ put(shr[0]+dx,shr[1]+dy,T_PATH); put(nd[0]+dx,nd[1]+dy,T_PATH);} }
for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){ put(gd[0]+dx,gd[1]+dy,T_PATH); }
put(shr[0],shr[1],T_SHRINE); put(nd[0],nd[1],T_NODE); put(gd[0],gd[1],T_GUARD);
if(z===0){ put(3,10,T_PATH); put(3,9,T_PATH); put(3,8,T_PATH); put(4,10,T_PATH); }
}
}
function accessible(z){ return z===0 || !!G.beat[z-1]; }
// zone index on the far side of a gate tile (or -1)
function gateZone(x,y){
const z=zoneOfTile(x,y), lx=x-ZC[z]*ZW, ly=y-ZR[z]*ZH;
let tc=ZC[z], tr=ZR[z];
if(lx===15) tc++; else if(lx===0) tc--; else if(ly===14) tr++; else if(ly===0) tr--; else return -1;
for(let i=0;i<6;i++) if(ZC[i]===tc&&ZR[i]===tr) return i;
return -1;
}
const SOLID=new Set([T_ROCK,T_WATER,T_SHRINE,T_GUARD,T_TREE,T_NODE]);
function tileAt(x,y){ if(x<0||y<0||x>=MW||y>=MH) return -1; return W[y*MW+x]; }
function canEnter(x,y){
const t=tileAt(x,y); if(t<0||SOLID.has(t)) return false;
if(t===T_GATE){ const g=gateZone(x,y); return g>=0&&accessible(g); }
return true;
}
function gateMsg(x,y){
const g=gateZone(x,y); const prev=Math.max(0,g-1);
return 'A sealed gate hums. Silence the warden of '+ZONES[prev].n+' to open the way.';
}
/* draw */
function drawTile(c,t,x,y,ts,z,tm){
const gc=GCOL[z], sx=x*ts, sy=y*ts;
if(t===T_WATER){
const g=c.createLinearGradient(sx,sy,sx,sy+ts); g.addColorStop(0,gc[1]); g.addColorStop(1,'#0a1a2a');
c.fillStyle=g; c.fillRect(sx,sy,ts,ts);
c.strokeStyle='rgba(180,230,255,.25)'; c.lineWidth=1.5; c.beginPath();
const off=Math.sin(tm*2+x+y)*3; c.moveTo(sx+4,sy+ts*0.4+off); c.quadraticCurveTo(sx+ts*0.5,sy+ts*0.3+off,sx+ts-4,sy+ts*0.4+off); c.stroke(); return;
}
const base=(t===T_PATH||t===T_SHRINE||t===T_NODE||t===T_GUARD)?gc[2]:((x+y)%2?gc[0]:gc[1]);
c.fillStyle=(t===T_PATH)?'#7a6b4e':base; c.fillRect(sx,sy,ts+1,ts+1);
if(t===T_PATH){ c.fillStyle='rgba(255,255,255,.05)'; c.fillRect(sx+ts*0.2,sy+ts*0.3,ts*0.2,ts*0.1); c.fillRect(sx+ts*0.6,sy+ts*0.7,ts*0.18,ts*0.1); return; }
if(t===T_GRASS){ c.fillStyle='rgba(180,255,190,.10)'; for(let i=0;i<2;i++){ const hx=sx+ts*(0.2+0.6*hashN(x,y,i+20)), hy=sy+ts*(0.3+0.5*hashN(x,y,i+30)); c.fillRect(hx,hy,2,ts*0.12);} return; }
if(t===T_TREE){ c.fillStyle='#2a1a0e'; c.fillRect(sx+ts*0.44,sy+ts*0.55,ts*0.12,ts*0.4);
c.fillStyle='#0c3a22'; c.beginPath(); c.arc(sx+ts*0.5,sy+ts*0.42,ts*0.38,0,6.283); c.fill();
c.fillStyle=gc[2]+'55'; c.beginPath(); c.arc(sx+ts*0.42,sy+ts*0.34,ts*0.14,0,6.283); c.fill(); return; }
if(t===T_ROCK){ c.fillStyle='#4a4f66'; c.beginPath(); c.moveTo(sx+ts*0.1,sy+ts*0.9); c.lineTo(sx+ts*0.25,sy+ts*0.35); c.lineTo(sx+ts*0.55,sy+ts*0.2); c.lineTo(sx+ts*0.9,sy+ts*0.85); c.closePath(); c.fill();
c.fillStyle='#8a90aa'; c.beginPath(); c.moveTo(sx+ts*0.25,sy+ts*0.35); c.lineTo(sx+ts*0.55,sy+ts*0.2); c.lineTo(sx+ts*0.45,sy+ts*0.5); c.fill(); return; }
if(t===T_GATE){
const gz=gateZone(x,y), open=gz>=0&&accessible(gz);
const glow=open?'rgba(160,255,200,':'rgba(255,120,150,';
c.fillStyle='#1a1a30'; c.fillRect(sx,sy,ts,ts);
c.strokeStyle=glow+(0.55+0.35*Math.sin(tm*3))+')'; c.lineWidth=3;
c.beginPath(); c.arc(sx+ts/2,sy+ts/2,ts*0.36,0,6.283); c.stroke(); return;
}
if(t===T_SHRINE){
c.fillStyle='#5b5f7e'; c.fillRect(sx+ts*0.3,sy+ts*0.55,ts*0.4,ts*0.35);
c.fillStyle='#a5abc8'; c.fillRect(sx+ts*0.25,sy+ts*0.48,ts*0.5,ts*0.1);
const pulse=0.6+0.4*Math.sin(tm*2.5);
c.fillStyle='rgba(143,227,255,'+(0.35*pulse)+')'; c.beginPath(); c.arc(sx+ts/2,sy+ts*0.35,ts*0.22,0,6.283); c.fill();
c.fillStyle='#e6fbff'; c.beginPath(); c.arc(sx+ts/2,sy+ts*0.35,ts*0.1,0,6.283); c.fill(); return;
}
if(t===T_NODE){
const b=Math.sin(tm*2+x)*2;
c.fillStyle='#3a2f5c'; c.fillRect(sx+ts*0.4,sy+ts*0.6,ts*0.2,ts*0.3);
c.fillStyle=TILE_PAL.gold||'#ffd27a'; c.font='bold '+Math.floor(ts*0.5)+'px system-ui';
c.textAlign='center'; c.textBaseline='middle'; c.fillText('✦',sx+ts/2,sy+ts*0.4+b); return;
}
if(t===T_GUARD){
const gd=SPEC_LOC[z].guard; const dead=G.beat[z];
c.fillStyle=dead?'#333':'#2a2440'; c.beginPath(); c.moveTo(sx+ts*0.2,sy+ts*0.9); c.lineTo(sx+ts*0.3,sy+ts*0.3); c.lineTo(sx+ts*0.5,sy+ts*0.1); c.lineTo(sx+ts*0.7,sy+ts*0.3); c.lineTo(sx+ts*0.8,sy+ts*0.9); c.closePath(); c.fill();
if(!dead){ c.shadowColor=GCOL[z][2]; c.shadowBlur=14; c.fillStyle=GCOL[z][2]; c.beginPath(); c.arc(sx+ts/2,sy+ts*0.4,ts*0.12,0,6.283); c.fill(); c.shadowBlur=0; }
}
}
function drawOverworld(){
const cv=$('world'), c=wctx, w=OWV.w, h=OWV.h;
const ts=OWV.ts;
c.setTransform(OWV.dpr,0,0,OWV.dpr,0,0);
c.fillStyle='#000'; c.fillRect(0,0,w,h);
const px=OW.fx*ts, py=OW.fy*ts;
let camX=px+ts/2-w/2, camY=py+ts/2-h/2;
camX=MW*ts<w?-(w-MW*ts)/2:clamp(camX,0,MW*ts-w);
camY=MH*ts<h?-(h-MH*ts)/2:clamp(camY,0,MH*ts-h);
OW.camX=camX; OW.camY=camY;
c.save(); c.translate(-Math.round(camX),-Math.round(camY));
const x0=Math.max(0,Math.floor(camX/ts)-1), x1=Math.min(MW-1,Math.floor((camX+w)/ts)+1);
const y0=Math.max(0,Math.floor(camY/ts)-1), y1=Math.min(MH-1,Math.floor((camY+h)/ts)+1);
for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++){
const t=W[y*MW+x], z=zoneOfTile(x,y);
drawTile(c,t,x,y,ts,z,OW.time);
}
// player token
const cx=px+ts/2, cy=py+ts/2-2;
const lead=G.party[0];
if(lead){ c.save(); c.translate(cx,cy); drawCreature(c,lead.id,0,0,ts*0.38,false,OW.time,0.95); c.restore(); }
else{ c.fillStyle='#fff'; c.beginPath(); c.arc(cx,cy,ts*0.3,0,6.283); c.fill(); }
c.fillStyle='#0a0b18'; c.font='bold '+Math.floor(ts*0.28)+'px system-ui'; c.textAlign='center';
c.fillStyle='rgba(255,255,255,.9)'; c.textBaseline='middle';
c.restore();
drawParts(c);
// zone vignette
const z=OW.zone, pal=ZONES[z].pal;
const vg=c.createRadialGradient(w/2,h/2,Math.min(w,h)*0.35,w/2,h/2,Math.max(w,h)*0.75);
vg.addColorStop(0,'rgba(0,0,0,0)'); vg.addColorStop(1,'rgba(0,0,0,.6)');
c.fillStyle=vg; c.fillRect(0,0,w,h);
c.fillStyle=pal[2]+'14'; c.fillRect(0,0,w,h);
if(OW.zoneFlash>0){ c.fillStyle=pal[2]+Math.floor(OW.zoneFlash*120).toString(16).padStart(2,'0'); c.fillRect(0,0,w,h); }
}
/* ========== ENGINE ========== */
let wctx=null,bctx=null,tctx=null,MODE='title';
const KEYS={};
const DIRS={ArrowUp:[0,-1],KeyW:[0,-1],ArrowDown:[0,1],KeyS:[0,1],ArrowLeft:[-1,0],KeyA:[-1,0],ArrowRight:[1,0],KeyD:[1,0]};
const OWV={w:1,h:1,dpr:1,ts:56};
const OW={rx:3,ry:10,fx:3,fy:10,dx:0,dy:1,zone:0,zoneFlash:0,time:0,camX:0,camY:0,cool:0,gateCool:0};
let DLG=null, CUT=null; const CUTQ=[];
const NODE_TXT=[
'The stone is cold, then warm. A voice like wind across a bottle says: "Choose one heart to carry the first note."',
'The stone hums a salt-bright chord. "The ridge keeps its heat by memory. Bring the warmth back to the shore."',
'Ash drifts down from a carved ledge. "Quiet as a bell that never rang. Keep listening."',
'The shadows bend around the stone. "Hollowing is what happens when no one calls back. Call back."',
'Light pools in a basin of gold. "Every spire is a sentence. Finish it before the hour ends."',
'Silver threads loop around the stone. "This is the place where every version of this world waits."'
];
function resize(){
const dpr=Math.min(2,window.devicePixelRatio||1);
OWV.dpr=dpr; OWV.w=window.innerWidth; OWV.h=window.innerHeight;
OWV.ts=Math.round(clamp(Math.min(OWV.w/14,(OWV.h-120)/9),40,80));
for(const id of ['world','bcanvas','titleCanvas']){
const cv=$(id); cv.width=Math.floor(window.innerWidth*dpr); cv.height=Math.floor(window.innerHeight*dpr);
}
if(MODE==='battle' && typeof battleLayout==='function') battleLayout();
}
function toast(msg){
const el=$('toast'); el.textContent=msg; el.classList.add('on');
clearTimeout(toast._t); toast._t=setTimeout(()=>el.classList.remove('on'),2300);
}
function setHint(msg){ const el=$('hint'); if(!msg){el.style.display='none';return;} el.textContent=msg; el.style.display='block'; }
function updateHint(){
if(MODE!=='ow') return setHint(null);
if(G.tut===0) setHint('Move with WASD or the arrow keys. Reach the ✦ Binding Stone on the far path.');
else if(G.tut===1) setHint('Walk through the green grass to meet wild creatures.');
else setHint(null);
}
function updateHud(){
const z=ZONES[OW.zone];
$('hud').innerHTML='<span class="zn">'+z.n+'</span><span>Gold <b>'+G.gold+'</b></span><span>Binds <b>'+uniqueCaught()+'/8</b></span><span>Anchors <b>'+anchorsBeaten()+'/4</b></span>'+(G.post?'<span style="color:var(--gold)">Free Roam</span>':'');
}
/* cut-ins */
function showCut(name,text,color,cb){ CUTQ.push({name,text,color,cb}); if(!CUT) nextCut(); }
function nextCut(){
CUT=CUTQ.shift()||null;
const el=$('cutin');
if(!CUT){ el.classList.remove('on'); return; }
if(CUT.color) el.style.setProperty('--vigA',CUT.color);
$('ciName').textContent=CUT.name; $('ciText').textContent=CUT.text; el.classList.add('on');
el.style.animation='none'; void el.offsetWidth; el.style.animation='';
}
function advanceCut(){
if(!CUT) return;
const done=CUT; CUT=null;
if(CUTQ.length) nextCut(); else $('cutin').classList.remove('on');
if(done.cb) done.cb();
}
/* dialogue: lines = [[who,text],...] */
function say(lines,cb){ DLG={L:lines,i:0,cb,ch:0}; $('dialog').style.display='block'; renderDlg(); }
function renderDlg(){
const L=DLG.L[DLG.i];
$('dlgWho').textContent=L[0]; $('dlgTxt').textContent=L[1].slice(0,Math.floor(DLG.ch));
}
function dlgAdvance(){
if(!DLG) return;
const L=DLG.L[DLG.i];
if(DLG.ch<L[1].length){ DLG.ch=L[1].length; renderDlg(); return; }
DLG.i++;
if(DLG.i>=DLG.L.length){
const cb=DLG.cb; DLG=null; $('dialog').style.display='none'; if(cb) cb();
} else { DLG.ch=0; renderDlg(); }
}
function sayRaw(lines){ return new Promise(res=>say(lines,res)); }
/* ========== OVERWORLD LOGIC ========== */
function onZone(z){
OW.zone=z; G.zone=z; AU.zone=z; OW.zoneFlash=1;
toast(ZONES[z].n+' — '+ZONES[z].msg);
updateHud();
}
function tryStep(dx,dy){
OW.dx=dx; OW.dy=dy;
const nx=OW.rx+dx, ny=OW.ry+dy;
if(!canEnter(nx,ny)){
if(tileAt(nx,ny)===T_GATE){
if(OW.gateCool<=0){ toast(gateMsg(nx,ny)); OW.gateCool=2.5; }
}
OW.cool=0.12; return false;
}
OW.rx=nx; OW.ry=ny; OW.cool=0.14;
G.px=nx; G.py=ny; G.steps++;
if(G.lure>0) G.lure--;
const z=zoneOfTile(nx,ny);
if(z!==OW.zone) onZone(z);
SFX.step();
if(tileAt(nx,ny)===T_GRASS) maybeEncounter();
return true;
}
function maybeEncounter(){
const z=ZONES[OW.zone];
const rate=z.rate*(G.lure>0?2:1)*(G.post?1.4:1);
const force=G.tut===1;
if(force||Math.random()<rate) startWild(OW.zone,force);
}
function pickSpawn(z){
const tbl=ZONES[z].spawn; let tot=0; for(const s of tbl) tot+=s[1];
let r=Math.random()*tot;
for(const s of tbl){ r-=s[1]; if(r<=0) return s; }
return tbl[0];
}
function startWild(z,force){
const s=pickSpawn(z);
let lv=ri(s[2],s[3]);
if(G.post) lv+=ri(3,6);
const foe=mkCreature(s[0],lv);
if(force) foe.hp=Math.max(1,Math.floor(foe.maxHp*0.45));
startBattle({kind:'wild',foes:[foe],zone:z,tut:force});
}
function facingTile(){ return {x:OW.rx+OW.dx,y:OW.ry+OW.dy,t:tileAt(OW.rx+OW.dx,OW.ry+OW.dy)}; }
function interact(){
if(MODE!=='ow'||DLG||CUT||MENU_OPEN) return;
const f=facingTile();
if(f.t===T_SHRINE) return openShrine();
if(f.t===T_NODE) return nodeTalk(zoneOfTile(f.x,f.y));
if(f.t===T_GUARD) return guardTalk(zoneOfTile(f.x,f.y));
toast('Nothing answers here... yet.');
}
function nodeTalk(z){
if(z===0 && G.party.length===0) return openStarter();
if(z===0 && G.tut===0){ G.tut=1; updateHint(); }
say([['Binding Stone',NODE_TXT[z]]],()=>{
if(G.stories['node'+z]===undefined){ G.stories['node'+z]=true; }
});
}
function guardTalk(z){
const Z=ZONES[z];
if(!accessible(z)){ toast('The way is sealed.'); return; }
if(G.beat[z]){ say([[Z.guard.n,'The way is open. Walk on, Binder.']]); return; }
const first=[[Z.guard.n,Z.guard.lore]];
say(first,()=>{
const foes=Z.guard.team.map(([id,lv])=>mkCreature(id,lv));
startBattle({kind:'guard',foes,zone:z});
});
}
function startNewGame(){
G=freshState(); G.started=true; G.tut=0;
audioInit();
OW.rx=G.px; OW.ry=G.py; OW.fx=G.px; OW.fy=G.py; OW.zone=0; AU.zone=0;
enterOW();
showCut(STORY[0].name,STORY[0].text,ZONES[0].pal[2],()=>{
say([
['Chronicler','Binder, the first bell is on the far path. Its keeper will ask for a heart to carry the note.'],
['Chronicler','Move with WASD or the arrow keys. Press Space or the Act button to speak with things.']
],()=>updateHint());
});
}
function continueGame(){
const s=readSave();
if(!s){ toast('No record found. Begin a New Binding.'); return; }
G=Object.assign(freshState(),s);
audioInit();
const sp=G.shrine||{x:3,y:10};
G.px=sp.x; G.py=sp.y;
OW.rx=G.px; OW.ry=G.py; OW.fx=G.px; OW.fy=G.py; OW.zone=zoneOfTile(G.px,G.py); AU.zone=OW.zone;
enterOW();
updateHint();
}
function enterOW(){
MODE='ow';
$('title').classList.remove('on'); $('game').classList.add('on'); $('battle').classList.remove('on');
OW.zone=zoneOfTile(OW.rx,OW.ry); AU.zone=OW.zone;
updateHud(); updateHint();
if(G.tut>=2 && OW.zone===0) setHint(null);
}
function toTitle(){
MODE='title'; DLG=null; CUT=null; CUTQ.length=0; $('cutin').classList.remove('on');
$('dialog').style.display='none'; setHint(null); closeMenu();
$('game').classList.remove('on'); $('battle').classList.remove('on'); $('title').classList.add('on');
$('btnContinue').disabled=!readSave();
}
/* keys */
function anyModal(){ return !!(DLG||CUT||MENU_OPEN); }
function onKeyDown(e){
const k=e.code;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(k)) e.preventDefault();
audioInit(); if(AU.ctx&&AU.ctx.state==='suspended') AU.ctx.resume();
if(k==='KeyM'){ setMuted(!AU.muted); return; }
if(MODE==='title') return;
if(CUT){ if(k==='Space'||k==='Enter'||k==='Escape') advanceCut(); return; }
if(MENU_OPEN){ menuKey(k); return; }
if(DLG){ if(k==='Space'||k==='Enter'||k==='Escape') dlgAdvance(); return; }
if(MODE==='battle'){ battleKey(k); return; }
if(k in DIRS){ KEYS[k]=true; return; }
if(k==='Space'||k==='Enter') interact();
else if(k==='Escape') openPause();
}
function onKeyUp(e){ KEYS[e.code]=false; }
function onPress(k){ // touch / virtual buttons
audioInit(); if(AU.ctx&&AU.ctx.state==='suspended') AU.ctx.resume();
if(MODE==='title') return;
if(CUT){ advanceCut(); return; }
if(MENU_OPEN){ menuKey(k); return; }
if(DLG){ dlgAdvance(); return; }
if(MODE==='battle'){ battleKey(k); return; }
if(k==='Space'||k==='Enter') interact();
else if(k==='Escape') openPause();
}
/* game loop */
let lastT=performance.now();
function updOW(dt){
OW.cool=Math.max(0,OW.cool-dt); OW.gateCool=Math.max(0,OW.gateCool-dt);
const k=Math.min(1,dt*16);
OW.fx+=(OW.rx-OW.fx)*k; OW.fy+=(OW.ry-OW.fy)*k;
if(Math.abs(OW.rx-OW.fx)<0.01) OW.fx=OW.rx;
if(Math.abs(OW.ry-OW.fy)<0.01) OW.fy=OW.ry;
if(!anyModal() && OW.cool<=0){
for(const kk in DIRS) if(KEYS[kk]){ const d=DIRS[kk]; tryStep(d[0],d[1]); break; }
}
OW.zoneFlash=Math.max(0,OW.zoneFlash-dt*0.8);
updAmb(dt,OWV.w,OWV.h,ZONES[OW.zone].pt);
updParts(dt);
}
function frame(now){
const dt=Math.min(0.05,(now-lastT)/1000); lastT=now;
OW.time+=dt;
try{
if(MODE==='title'){ drawTitle(dt); }
else if(MODE==='ow'){
updOW(dt); drawOverworld(); drawAmb(wctx); wctx.globalAlpha=1;
if(DLG&&DLG.ch<DLG.L[DLG.i][1].length){ DLG.ch=Math.min(DLG.L[DLG.i][1].length,DLG.ch+dt*48); renderDlg(); }
} else if(MODE==='battle'){ battleFrame(dt); }
}catch(err){ console.error(err); }
requestAnimationFrame(frame);
}
/* ========== BATTLE ========== */
let B=null, FLOATS=[], MENU_OPEN=false, MENU_FORCED=false;
const pendingPost=[];
const STRUGGLE=['Strain','G',40,100,0,'none',0];
function curP(){return G.party[B.pi];}
function curF(){return B.foes[B.fi];}
function Q(t,d){B.q.push({f:()=>setLog(t),d:d==null?900:d});}
function QF(f,d){B.q.push({f,d:d==null?300:d});}
function QFront(arr){B.q.unshift(...arr);}
function runQ(after){B.phase='anim';B.after=after||afterCheck;}
function setLog(t){$('blogTxt').textContent=t;}
function statOf(c,k){
const isP=G.party.includes(c);
const base=isP?playerStat(c,k):calcStat(c.id,c.lv,k);
const b=bst(c); let m=b.mods[k]||1;
if(k==='atk'&&b.burn) m*=0.5;
if(k==='spd'&&b.frost) m*=0.5;
return Math.max(1,Math.floor(base*m));
}
function floatTxt(who,txt,col){FLOATS.push({who,txt,col,t:0,ox:rnd(-16,16)});}
function bres(){return B.w<700?250:190;}
function bpos(who){
const w=B.w,h=B.h,r=bres(),s=clamp(Math.min(w,h)*0.26,80,170);
return who==='p'?{x:w*0.26,y:(h-r)*0.74,s}:{x:w*0.74,y:(h-r)*0.4,s:s*1.05};
}
function prioOf(c,mi){const s=c.moves[mi]; if(!s) return 0; return MOVES[s.k][5]==='prio'?1:0;}
function canAct(){return B&&B.phase==='menu';}
function mkBtn(html,fn,dis,cls){const b=document.createElement('button');b.className='btn'+(cls?' '+cls:'');b.innerHTML=html;b.disabled=!!dis;b.onclick=fn;return b;}
function rowEl(o){
const d=document.createElement('div');
d.className='row'+(o.dis?' dim':'')+(o.sel?' sel':'');
d.innerHTML='<div style="flex:1;min-width:0"><div class="nm"></div><div class="sub"></div></div><div class="sp"></div>';
d.querySelector('.nm').innerHTML=o.nm||''; d.querySelector('.sub').innerHTML=o.sub||''; d.querySelector('.sp').textContent=o.sp||'';
if(!o.dis&&o.onclick) d.onclick=o.onclick;
return d;
}
function barHtml(frac){return '<div class="bar"><i style="width:'+Math.round(clamp(frac,0,1)*100)+'%"></i></div>';}
function startBattle(o){
const pi=G.party.findIndex(c=>c.hp>0);
if(pi<0){ toast('Your creatures need rest at a shrine.'); return; }
B={kind:o.kind,foes:o.foes,zone:o.zone,tut:!!o.tut,pi,fi:0,q:[],wait:0.3,phase:'anim',after:null,
cap:null,capItem:null,shake:0,flash:0,dp:{},w:OWV.w,h:OWV.h,dpr:OWV.dpr,ended:false};
FLOATS=[];
B.foes.forEach(f=>markSeen(f.id));
MODE='battle';
$('game').classList.remove('on'); $('battle').classList.add('on');
placeMenu(true);
const f0=B.foes[0];
if(B.kind==='guard'){ Q(ZONES[B.zone].guard.n+' sends out '+name(f0)+'!',1000); }
else Q('A wild '+name(f0)+' appeared!',1000);
Q('Go, '+name(curP())+'!',800);
if(B.tut) Q('Tip: wear the wild one down with a strike, then Bind it with a Glass Shard.',1600);
runQ(afterCheck);
}
function aiPick(f){
const p=curP(); let best=-1,bs=-1e9;
f.moves.forEach((s,i)=>{
if(s.pp<=0) return;
const m=MOVES[s.k]; let sc;
if(m[2]>0) sc=m[2]*tmul(m[1],SPECIES[p.id].t)*(SPECIES[f.id].t.includes(m[1])?1.25:1)*(m[3]/100);
else if(m[5]==='heal') sc=f.hp/f.maxHp<0.5?110:4;
else if(m[5]==='barrier'||m[5]==='regen'||m[5]==='haste') sc=22;
else sc=18;
sc*=(0.85+Math.random()*0.3);
if(sc>bs){bs=sc;best=i;}
});
return best;
}
function stepMove(side,mi){
const user=side==='p'?curP():curF(), target=side==='p'?curF():curP();
if(!user||!target||user.hp<=0||target.hp<=0) return;
const slot=mi>=0?user.moves[mi]:null;
let m;
if(slot&&slot.pp>0){slot.pp--; m=MOVES[slot.k];} else m=STRUGGLE;
const S=[];
const st=(f,d)=>S.push({f,d:d==null?650:d});
const tx=(t,d)=>S.push({f:()=>setLog(t),d:d==null?800:d});
const ub=bst(user), tb=bst(target), un=name(user), tn=name(target);
const tSide=side==='p'?'f':'p';
const eff=m[5], ev=m[6], hitOK=Math.random()*100<m[3];
tx(un+' used '+m[0]+'!',650);
if(ub.shock&&Math.random()<0.25){ tx(un+' is numbed by the shock and cannot move!'); }
else{
let dealt=0;
if(m[2]>0){
if(!hitOK) tx('It missed!');
else{
const hits=eff==='multi'?ri(2,Math.max(2,ev-1)):1;
const tm=tmul(m[1],SPECIES[target.id].t);
const stab=SPECIES[user.id].t.includes(m[1])?1.25:1;
for(let h=0;h<hits;h++){
if(tb.barrier){ tb.barrier=false; st(()=>{ setLog(tn+"'s barrier deflected the strike!"); floatTxt(tSide,'Deflected','#8fe3ff'); },700); break; }
if(target.hp<=0) break;
const crit=Math.random()<(eff==='crit'?0.25:0.0625);
const A=statOf(user,'atk'), D=statOf(target,'def');
const dmg=Math.max(tm>0?1:0,Math.floor(0.4*user.lv*A/D*(m[2]/10*tm*stab*(crit?1.5:1))+ri(0,2)));
dealt+=dmg;
st(()=>{
if(target.hp<=0) return;
target.hp=Math.max(0,target.hp-dmg);
floatTxt(tSide,'-'+dmg,crit?'#ffd27a':'#ffffff');
B.shake=Math.max(B.shake,crit?0.6:(tm>=2?0.4:0.2));
if(crit){ B.flash=0.35; setLog('A critical strike!'); SFX.crit(); }
else if(tm>=2) SFX.eff(); else SFX.hit();
const pos=bpos(tSide);
burst(pos.x,pos.y-pos.s*0.2,tm>=2?36:(crit?24:12),TYPES[m[1]].c,tm>=2?360:220,0.8,4,260);
},620);
}
if(hits>1) st(()=>setLog('Struck '+hits+' times!'),300);
const et=effText(tm); if(et) tx(et,500);
}
} else if(!hitOK) tx('It missed!');
else if(eff==='heal'){
st(()=>{ const amt=Math.ceil(user.maxHp*ev/100); const b=user.hp; user.hp=Math.min(user.maxHp,user.hp+amt); floatTxt(side,'+'+(user.hp-b),'#8dffb0'); SFX.heal(); burst(bpos(side).x,bpos(side).y,18,'#8dffb0',180,0.9,3,-40); setLog(un+' mended.'); },600);
}
else if(eff==='barrier'){ st(()=>{ ub.barrier=true; setLog(un+' raised a barrier!'); SFX.ui(); },600); }
else if(eff==='regen'){ st(()=>{ ub.regen=true; setLog(un+' began to mend slowly.'); },600); }
else if(eff==='haste'){ st(()=>{ ub.mods.spd=Math.min(2,ub.mods.spd*1.5); setLog(un+' surged with speed!'); SFX.ui(); },600); }
if(eff==='drain'){ st(()=>{ const amt=Math.floor(dealt*ev/100); if(user.hp>0&&amt>0){ user.hp=Math.min(user.maxHp,user.hp+amt); floatTxt(side,'+'+amt,'#8dffb0'); } },500); }
if(eff.startsWith('lower')&&hitOK){
const key=eff.slice(5).toLowerCase(), nm={atk:'attack',def:'defense',spd:'speed'}[key];
st(()=>{ if(target.hp>0){ tb.mods[key]=Math.max(0.3,tb.mods[key]*0.75); setLog(tn+"'s "+nm+' fell!'); } },600);
}
if(['burn','frost','shock'].includes(eff)&&hitOK&&Math.random()*100<ev){
st(()=>{
if(target.hp<=0) return;
if(tb.burn||tb.frost||tb.shock){ setLog(tn+' is already afflicted.'); return; }
if(eff==='burn'){tb.burn=true; setLog(tn+' was burned!');}
else if(eff==='frost'){tb.frost=true; setLog(tn+' was frosted!');}
else {tb.shock=true; setLog(tn+' was shocked!');}
SFX.ui();
},600);
}
}
st(()=>{ if(target.hp<=0){ setLog(tn+' fainted!'); SFX.faint(); } },800);
B.q.unshift(...S);
}
function endTurn(){
const steps=[];
for(const side of ['p','f']){
const c=side==='p'?curP():curF(); if(!c) continue; const b=bst(c);
if(b.burn) steps.push({f:()=>{ if(c.hp>0){ const d=Math.max(1,Math.floor(c.maxHp/16)); c.hp=Math.max(0,c.hp-d); floatTxt(side,'-'+d,'#ff8a4c'); setLog(name(c)+' is hurt by its burn.'); SFX.hit(); } },d:600});
if(b.regen) steps.push({f:()=>{ if(c.hp>0&&c.hp<c.maxHp){ const d=Math.max(1,Math.floor(c.maxHp/12)); c.hp=Math.min(c.maxHp,c.hp+d); floatTxt(side,'+'+d,'#8dffb0'); setLog(name(c)+' mends.'); } },d:500});
}
QFront(steps);
}
function foeTurn(){ QF(()=>{ const f=curF(); if(f&&f.hp>0&&curP().hp>0) stepMove('f',aiPick(f)); },200); }
function doMove(mi){
const p=curP(), f=curF(), fmi=aiPick(f);
const pp=prioOf(p,mi), fp=prioOf(f,fmi);
const ps=statOf(p,'spd'), fs=statOf(f,'spd');
const foeFirst=fp>pp||(fp===pp&&(fs>ps||(fs===ps&&Math.random()<0.5)));
const order=foeFirst?['f','p']:['p','f'];
for(const s of order) QF(()=>{ if(s==='p') stepMove('p',mi); else stepMove('f',fmi); },200);
QF(endTurn,200);
runQ(afterCheck);
}
function doSwitch(i,voluntary){
const old=curP(); if(old) BST.delete(old);
B.pi=i; B.dp.p=null;
const nw=curP();
Q((voluntary&&old?'Come back, '+name(old)+'! ':'')+'Go, '+name(nw)+'!',800);
if(voluntary) foeTurn();
QF(endTurn,200);
runQ(afterCheck);
}
function useItem(key){
const it=ITEMS[key], c=curP();
if(!bagTake(key)) return;
if(it.k==='heal'){ QF(()=>{ const b=c.hp; c.hp=Math.min(c.maxHp,c.hp+it.v); floatTxt('p','+'+(c.hp-b),'#8dffb0'); SFX.heal(); setLog(name(c)+' recovered '+(c.hp-b)+' HP.'); },700); }
else if(it.k==='cure'){ QF(()=>{ const b=bst(c); b.burn=b.frost=b.shock=false; setLog(name(c)+' was cleansed.'); SFX.heal(); },600); }
foeTurn(); QF(endTurn,200); runQ(afterCheck);
}
function useFlee(){
bagTake('feather');
B.phase='anim';
Q('A gale lifts you away.',800);
QF(()=>leaveBattle('fled'),400);
runQ(null);
}
function tryRun(){
if(!canAct()) return;
$('bcmd').innerHTML='';
const ch=clamp(0.45+(statOf(curP(),'spd')-statOf(curF(),'spd'))*0.02,0.15,0.95);
Q('You attempt to withdraw...',700);
if(Math.random()<ch){ Q('Got away safely!',800); QF(()=>leaveBattle('fled'),400); runQ(null); }
else { Q("Couldn't get away!",700); foeTurn(); QF(endTurn,200); runQ(afterCheck); }
}
function startCapture(key){
const it=ITEMS[key]; B.phase='cap'; B.capItem=key;
const w=[0.26,0.18,0.12][it.tier-1];
B.cap={w,c:rnd(0.3,0.7),p:Math.random(),dir:1,sp:0.9+it.tier*0.35};
$('capZone').style.left=((B.cap.c-w/2)*100)+'%'; $('capZone').style.width=(w*100)+'%';
$('capPress').disabled=false;
$('capGame').classList.add('on');
setLog('Strike while the marker sits inside the glowing band.');
}
function capPressed(){
if(!B||B.phase!=='cap') return;
$('capPress').disabled=true;
const cp=B.cap, d=Math.abs(cp.p-cp.c), inside=d<=cp.w/2;
const q=inside?0.6+0.4*(1-d/(cp.w/2)):0.15;
$('capGame').classList.remove('on');
resolveCapture(q);
}
function resolveCapture(q){
const key=B.capItem, it=ITEMS[key], f=curF(), p=curP();
bagTake(key);
const hpF=f.hp/f.maxHp, base=[0,0.7,0.85,1.0][it.tier];
const lvAdj=clamp(1.1-(f.lv-p.lv)*0.03,0.5,1.15);
const chance=clamp(base*(0.4+0.6*(1-hpF))*q*lvAdj,0.03,0.97);
Q('You loose a '+it.n+' around '+name(f)+'...',900);
if(Math.random()<chance){
QF(()=>{
SFX.cap(); BST.delete(f); f.hp=Math.max(1,f.hp);
if(G.party.length<6) G.party.push(f); else G.box.push(f);
markCaught(f.id); setLog(name(f)+' was bound!'); B.captured=true;
},900);
QF(()=>leaveBattle('caught'),1300);
runQ(null);
} else {
Q('It broke free!',700);
foeTurn(); QF(endTurn,200); runQ(afterCheck);
}
}
function pickSwitch(forced){
openMenu(forced?'Choose the next creature':'Switch creature',body=>{
G.party.forEach((c,i)=>{
const dead=c.hp<=0, cur=i===B.pi;
body.appendChild(rowEl({nm:name(c)+' <span class="sub">Lv '+c.lv+'</span>',sub:barHtml(c.hp/c.maxHp)+'<span>'+(dead?'Fainted':c.hp+' / '+c.maxHp)+'</span>',
sp:cur?'Active':'',dis:dead||cur,onclick:()=>{ closeMenu(true); doSwitch(i,!forced); }}));
});
},forced);
}
function openBindMenu(){
if(!B||B.kind!=='wild'){ toast('Only wild hearts can be bound.'); return; }
const caps=Object.keys(G.bag).filter(k=>ITEMS[k].k==='capture'&&G.bag[k]>0);
if(!caps.length){ toast('No lattice items. Visit a shrine shop.'); return; }
openMenu('Bind — choose a lattice item',body=>{
caps.forEach(k=>body.appendChild(rowEl({nm:ITEMS[k].n,sub:ITEMS[k].d,sp:'× '+G.bag[k],onclick:()=>{ closeMenu(true); startCapture(k); }})));
});
}
function openBag(){
if(!canAct()) return;
openMenu('Bag',body=>{
const keys=Object.keys(G.bag).filter(k=>G.bag[k]>0&&ITEMS[k].k!=='capture'&&ITEMS[k].k!=='cat');
if(!keys.length) body.innerHTML='<div class="kv">Nothing useful remains in your bag.</div>';
keys.forEach(k=>{
const it=ITEMS[k]; const ok=['heal','cure','flee'].includes(it.k)&&!(it.k==='flee'&&(B.kind!=='wild'||B.tut));
body.appendChild(rowEl({nm:it.n,sub:it.d,sp:'× '+G.bag[k],dis:!ok,onclick:()=>{
closeMenu(true);
if(it.k==='flee') useFlee(); else if(canAct()){ $('bcmd').innerHTML=''; useItem(k); }
}}));
});
});
}
function afterCheck(){
if(!B) return;
const f=curF(), p=curP();
if(f.hp<=0){
const nx=B.foes.findIndex(x=>x.hp>0);
if(nx>=0){ B.fi=nx; B.dp.f=null; const nf=B.foes[nx];
Q((B.kind==='guard'?ZONES[B.zone].guard.n+' sends out ':'')+name(nf)+'!',900);
runQ(afterCheck); return; }
return victory();
}
if(p.hp<=0){
if(G.party.some(c=>c.hp>0)) return pickSwitch(true);
return whiteout();
}
B.phase='menu'; B.after=null; buildCommands();
}
function buildCommands(){
const box=$('bcmd'); box.innerHTML='';
const p=curP(), f=curF();
setLog('What will '+name(p)+' do?');
p.moves.forEach((s,i)=>{
const m=MOVES[s.k];
box.appendChild(mkBtn('<span class="ty">'+TYPES[m[1]].n+'</span>'+m[0]+'<small>PP '+s.pp+'/'+s.max+(m[2]?' · Power '+m[2]:' · Status')+'</small>',()=>{ if(!canAct()) return; box.innerHTML=''; doMove(i); },s.pp<=0&&false));
});
const hasCap=Object.keys(G.bag).some(k=>ITEMS[k].k==='capture'&&G.bag[k]>0);
box.appendChild(mkBtn('Switch<small>Call another creature</small>',()=>{ if(canAct()) pickSwitch(false); }));
box.appendChild(mkBtn('Bag<small>Potions and cures</small>',()=>openBag()));
box.appendChild(mkBtn('Bind<small>'+(B.kind==='wild'?(hasCap?'Lattice items':'None in bag'):'Guards cannot be bound')+'</small>',()=>{ if(canAct()) openBindMenu(); },B.kind!=='wild'||!hasCap,'pri'));
box.appendChild(mkBtn('Withdraw<small>'+(B.kind!=='wild'?'Cannot leave a guard':(B.tut?'Not during training':'Try to escape'))+'</small>',()=>tryRun(),B.kind!=='wild'||B.tut));
}
function victory(){
if(B.ended) return; B.ended=true; B.phase='anim';
let xp=0,gold=0;
for(const f of B.foes){
const rel=clamp(f.lv/Math.max(1,curP().lv),0.6,1.6);
xp+=Math.floor(f.lv*7*rel)+6; gold+=f.lv*3+ri(2,8);
}
if(B.kind==='guard'){ xp=Math.floor(xp*1.5); gold=Math.floor(gold*1.8)+60; }
Q('Victory! Gold recovered: '+gold+'.',900);
QF(()=>{ G.gold+=gold; },0);
G.party.forEach((c,i)=>{
if(c.hp<=0) return;
const n=i===B.pi?xp:Math.floor(xp*0.35);
QF(()=>{
const r=addXp(c,n);
const msgs=[{f:()=>setLog(name(c)+' gained '+n+' EXP.'),d:800}];
if(r.levels) msgs.push({f:()=>{ setLog(name(c)+' grew to Lv '+c.lv+'!'); SFX.lvl(); },d:1000});
r.learned.forEach(mn=>msgs.push({f:()=>setLog(name(c)+' learned '+mn+'!'),d:900}));
QFront(msgs);
},0);
});
if(B.kind==='guard') QF(()=>guardWon(B.zone),200);
QF(()=>leaveBattle('won'),1000);
runQ(null);
}
function guardWon(z){
G.beat[z]=true;
const KS=['atk','def','spd','hp'], k=KS[z%4];
G.bonus[k]=+(G.bonus[k]*1.06).toFixed(3);
ZONES[z].drops.forEach(d=>bagAdd(d,1));
const cutMap={1:1,4:2,5:3};
const names=ZONES[z].drops.map(d=>ITEMS[d].n).join(', ');
pendingPost.push(()=>{
const after=()=>say([[ZONES[z].guard.n,'The lattice loosens its grip on the '+ZONES[z].n+'.'],['System','Permanent bonus: '+k.toUpperCase()+' +6% for your Binders. Received: '+names+'.']]);
if(cutMap[z]!==undefined){ const s=STORY[cutMap[z]]; showCut(s.name,s.text,ZONES[z].pal[2],after); }
else after();
});
}
function checkEnding(){
if(G.ended||!finalObjectiveMet()) return;
G.ended=true;
showCut('Epilogue','Eight voices and four anchors. The lattice sings a full chord again. The world is yours to wander, and the wilds have grown bolder.',ZONES[5].pal[2],()=>{
G.post=true; toast('Free Roam unlocked. Wild creatures grow stronger.'); updateHud();
});
}
function whiteout(){
B.phase='anim';
Q('Your party collapses. A shrine hums and calls you back.',1400);
QF(()=>{
G.party.forEach(c=>{ c.hp=c.maxHp; c.moves.forEach(m=>m.pp=m.max); });
G.gold=Math.floor(G.gold/2);
G.px=G.shrine.x; G.py=G.shrine.y; OW.rx=G.px; OW.ry=G.py; OW.fx=G.px; OW.fy=G.py;
pendingPost.push(()=>toast('Half your gold was swept away by the tide.'));
},600);
QF(()=>leaveBattle('white'),300);
runQ(null);
}
function leaveBattle(res){
const fs=B?B.foes:[], tut=B&&B.tut;
G.party.forEach(c=>BST.delete(c)); fs.forEach(f=>BST.delete(f));
B=null; MODE='ow'; FLOATS=[];
closeMenu(true);
$('battle').classList.remove('on'); $('game').classList.add('on'); $('cutin').classList.remove('on');
placeMenu(false);
if(tut) G.tut=2;
OW.fx=OW.rx; OW.fy=OW.ry;
enterOW();
const list=pendingPost.splice(0); list.forEach(fn=>fn());
if(res==='caught'&&G.tut===2) toast('Bound! Your first creature has joined the party.');
checkEnding();
if(res==='white'&&G.party.length===0) toast('Your party is empty.');
}
function battleFrame(dt){
if(!B) return;
if(B.wait>0) B.wait-=dt;
else if(B.q.length){ const s=B.q.shift(); B.wait=s.d/1000; s.f(); return; }
else if(B.phase==='anim'){ B.phase='idle'; const cb=B.after; B.after=null; if(cb) cb(); return; }
if(!B) return;
if(B.phase==='cap'){
const cp=B.cap; cp.p+=cp.dir*dt*cp.sp;
if(cp.p>=1){cp.p=1;cp.dir=-1;} if(cp.p<=0){cp.p=0;cp.dir=1;}
$('capMark').style.left=(cp.p*100)+'%';
}
for(const who of ['p','f']){
const c=who==='p'?curP():curF(); const t=c.hp/c.maxHp;
if(B.dp[who]==null) B.dp[who]=t; else B.dp[who]+=(t-B.dp[who])*Math.min(1,dt*5);
}
B.shake=Math.max(0,B.shake-dt*2.2); B.flash=Math.max(0,B.flash-dt*2.5);
FLOATS=FLOATS.filter(f=>(f.t+=dt)<1.2);
updAmb(dt,B.w,B.h,ZONES[B.zone].pt); updParts(dt);
drawBattle();
}
function rr(c,x,y,w,h,r){c.beginPath();c.moveTo(x+r,y);c.arcTo(x+w,y,x+w,y+h,r);c.arcTo(x+w,y+h,x,y+h,r);c.arcTo(x,y+h,x,y,r);c.arcTo(x,y,x+w,y,r);c.closePath();}
function drawInfo(c,x,y,who,cr,isP){
const W=Math.min(270,B.w-24), H=isP?78:62;
rr(c,x,y,W,H,14); c.fillStyle='rgba(16,20,44,0.74)'; c.fill(); c.strokeStyle='rgba(170,190,255,.3)'; c.lineWidth=1; c.stroke();
c.textAlign='left'; c.textBaseline='alphabetic';
c.fillStyle='#eef0ff'; c.font='700 15px system-ui,sans-serif'; c.fillText(name(cr),x+12,y+20);
c.textAlign='right'; c.fillStyle='#ffd27a'; c.font='600 13px system-ui,sans-serif'; c.fillText('Lv '+cr.lv,x+W-12,y+20);
c.textAlign='left';
let tx=x+12;
for(const t of SPECIES[cr.id].t){
c.fillStyle=TYPES[t].c; rr(c,tx,y+27,48,15,7); c.fill();
c.fillStyle='#0b0d1a'; c.font='700 9px system-ui,sans-serif'; c.textAlign='center'; c.fillText(TYPES[t].n.toUpperCase(),tx+24,y+38); tx+=54;
}
c.textAlign='left';
const frac=clamp(B.dp[who]!=null?B.dp[who]:cr.hp/cr.maxHp,0,1);
const by=y+(isP?50:44), bw=W-24;
rr(c,x+12,by,bw,9,4); c.fillStyle='rgba(255,255,255,.12)'; c.fill();
const col=frac>0.5?'#7dffb4':frac>0.2?'#ffd27a':'#ff6f8a';
if(frac>0) { rr(c,x+12,by,Math.max(4,bw*frac),9,4); c.fillStyle=col; c.fill(); }
if(isP){ c.fillStyle='#9aa3c7'; c.font='11px system-ui,sans-serif'; c.textAlign='right'; c.fillText(Math.max(0,cr.hp)+' / '+cr.maxHp,x+W-12,y+H-7); c.textAlign='left'; }
const b=bst(cr), st=[]; if(b.burn)st.push('Burned'); if(b.frost)st.push('Frosted'); if(b.shock)st.push('Shocked'); if(b.barrier)st.push('Barrier'); if(b.regen)st.push('Mending');
if(st.length){ c.fillStyle='#8fe3ff'; c.font='10px system-ui,sans-serif'; c.fillText(st.join(' · '),x+12,y+H-7); }
}
function drawBattle(){
const c=bctx, dpr=B.dpr, w=B.w, h=B.h, pal=ZONES[B.zone].pal;
c.setTransform(dpr,0,0,dpr,0,0);
const g=c.createLinearGradient(0,0,0,h); g.addColorStop(0,pal[0]); g.addColorStop(1,pal[1]);
c.fillStyle=g; c.fillRect(0,0,w,h);
const rg=c.createRadialGradient(w*0.7,h*0.25,10,w*0.7,h*0.25,Math.max(w,h)*0.6);
rg.addColorStop(0,pal[2]+'40'); rg.addColorStop(1,'rgba(0,0,0,0)'); c.fillStyle=rg; c.fillRect(0,0,w,h);
drawAmb(c);
c.save();
if(B.shake>0) c.translate(rnd(-1,1)*B.shake*9,rnd(-1,1)*B.shake*9);
for(const who of ['p','f']){ const p=bpos(who); c.fillStyle='rgba(0,0,0,.35)'; c.beginPath(); c.ellipse(p.x,p.y+p.s*0.42,p.s*0.6,p.s*0.16,0,0,6.283); c.fill(); }
const f=curF(), p=curP();
{ const pos=bpos('f'); const dead=f.hp<=0; c.save(); if(dead){c.globalAlpha=0.25;} drawCreature(c,f.id,pos.x,pos.y+(dead?20:0),pos.s,true,OW.time,dead?0.25:1); c.restore(); }
{ const pos=bpos('p'); const dead=p.hp<=0; drawCreature(c,p.id,pos.x,pos.y+(dead?20:0),pos.s,false,OW.time,dead?0.25:1); }
c.restore();
if(B.flash>0){ c.fillStyle='rgba(255,255,255,'+(B.flash*0.6)+')'; c.fillRect(0,0,w,h); }
drawParts(c);
for(const fl of FLOATS){
const pos=bpos(fl.who), a=clamp(1-fl.t/1.2,0,1);
c.globalAlpha=a; c.font='800 '+(w<700?20:26)+'px system-ui,sans-serif'; c.textAlign='center';
c.shadowColor='rgba(0,0,0,.75)'; c.shadowBlur=8; c.fillStyle=fl.col;
c.fillText(fl.txt,pos.x+fl.ox,pos.y-pos.s*0.55-fl.t*80);
}
c.shadowBlur=0; c.globalAlpha=1;
drawInfo(c,14,14,'f',f,false);
drawInfo(c,w-14-Math.min(270,w-24),Math.max(120,h-314),'p',p,true);
}
/* ========== MENUS ========== */
const TABS=[['team','Team'],['bag','Bag'],['dex','Encyclopedia'],['map','Map'],['save','Record & Sound']];
function openMenu(title,fill,forced){
MENU_OPEN=true; MENU_FORCED=!!forced;
const p=$('menuPanel'); p.innerHTML='';
const hd=document.createElement('div'); hd.className='phead';
const h=document.createElement('h2'); h.textContent=title; hd.appendChild(h);
if(!forced) hd.appendChild(mkBtn('Close',()=>closeMenu(),false,'sm'));
p.appendChild(hd);
const body=document.createElement('div'); body.className='pbody'; p.appendChild(body);
fill(body);
$('menu').classList.add('on');
}
function closeMenu(force){
if(!force&&MENU_FORCED) return;
MENU_OPEN=false; MENU_FORCED=false; $('menu').classList.remove('on');
}
function menuKey(k){ if(k==='Escape'&&!MENU_FORCED) closeMenu(); }
function placeMenu(inBattle){ $('btnMenu').style.visibility=inBattle?'hidden':'visible'; }
function typeTags(id){ return SPECIES[id].t.map(t=>'<span class="tag2" style="background:'+TYPES[t].c+'">'+TYPES[t].n+'</span>').join(''); }
function tabBar(cur){
const d=document.createElement('div'); d.style.cssText='display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px';
for(const [k,l] of TABS) d.appendChild(mkBtn(l,()=>openTab(k),false,'sm'+(k===cur?' pri':'')));
return d;
}
function backBtn(fn){ return mkBtn('← Back',fn,false,'sm'); }
function xfInfo(id){
if(SPECIES[id].xf){ const s=SPECIES[id].xf; return 'Awakened from '+SPECIES[s.from].n+' at Lv '+s.lv[0]+'–'+s.lv[1]+' with a '+ITEMS[s.cat].n+'.'; }
if(XF_OF[id]){ const x=SPECIES[XF_OF[id]].xf; return 'Can awaken into '+SPECIES[XF_OF[id]].n+' at Lv '+x.lv[0]+'–'+x.lv[1]+' with a '+ITEMS[x.cat].n+'.'; }
return '';
}
function openPause(){ if(MODE!=='ow') return; openTab('team'); }
function openTab(k){
if(k==='team') return tabTeam();
if(k==='bag') return tabBag();
if(k==='dex') return tabDex();
if(k==='map') return tabMap();
if(k==='save') return tabSave();
}
function tabTeam(){
openMenu('Team & Binders',body=>{
body.appendChild(tabBar('team'));
G.party.forEach((c,i)=>{
const xp=Math.round(c.xp/expNext(c.lv)*100);
body.appendChild(rowEl({nm:name(c)+' <span class="sub">Lv '+c.lv+'</span>'+(i===0?' <span class="sub" style="color:var(--acc)">· Lead</span>':''),
sub:typeTags(c.id)+barHtml(c.hp/c.maxHp)+'<div class="bar xp"><i style="width:'+xp+'%"></i></div>',
sp:c.hp<=0?'Fainted':c.hp+' / '+c.maxHp, onclick:()=>tabCreature(i)}));
});
body.insertAdjacentHTML('beforeend','<div class="kv" style="margin:12px 0 6px">Box · '+G.box.length+' stored</div>');
G.box.forEach((c,j)=>{
body.appendChild(rowEl({nm:name(c)+' <span class="sub">Lv '+c.lv+'</span>',sub:typeTags(c.id),
sp:G.party.length<6?'Swap in':'Party full',dis:G.party.length>=6,
onclick:()=>{ const x=G.box.splice(j,1)[0]; G.party.push(x); tabTeam(); }}));
});
});
}
function tabCreature(i){
const c=G.party[i]; if(!c){ return tabTeam(); }
openMenu(name(c),body=>{
body.appendChild(backBtn(tabTeam));
const sp=SPECIES[c.id];
const info=document.createElement('div'); info.className='kv'; info.style.marginTop='10px';
info.innerHTML=typeTags(c.id)+'<div style="margin:8px 0">'+sp.lore+'</div>'+
'<div>Level <b>'+c.lv+'</b> · EXP '+c.xp+' / '+expNext(c.lv)+'</div>'+
'<div>HP <b>'+c.hp+'/'+c.maxHp+'</b> · ATK <b>'+playerStat(c,'atk')+'</b> · DEF <b>'+playerStat(c,'def')+'</b> · SPD <b>'+playerStat(c,'spd')+'</b></div>'+
(XF_OF[c.id]||SPECIES[c.id].xf?'<div style="color:var(--acc);margin-top:6px">'+xfInfo(c.id)+'</div>':'');
body.appendChild(info);
const mv=document.createElement('div'); mv.style.marginTop='12px';
mv.innerHTML='<div class="kv"><b>Abilities</b></div>';
c.moves.forEach(s=>{ const m=MOVES[s.k]; mv.appendChild(rowEl({nm:m[0],sub:'<span class="tag2" style="background:'+TYPES[m[1]].c+'">'+TYPES[m[1]].n+'</span>'+(m[2]?'Power '+m[2]+' · Accuracy '+m[3]:'Status')+' · '+m[5],sp:'PP '+s.pp+'/'+s.max})); });
const up=SPECIES[c.id].lr.filter(([l])=>l>c.lv).slice(0,4);
if(up.length){ mv.insertAdjacentHTML('beforeend','<div class="kv" style="margin-top:10px">Ahead: '+up.map(([l,m])=>'Lv '+l+' '+MOVES[m][0]).join(' · ')+'</div>'); }
body.appendChild(mv);
const acts=document.createElement('div'); acts.className='btnrow'; acts.style.cssText='margin-top:14px;justify-content:flex-start;flex-wrap:wrap;gap:8px';
if(i>0) acts.appendChild(mkBtn('Lead the party',()=>{ G.party.splice(i,1); G.party.unshift(c); tabTeam(); },false,'sm'));
if(G.party.length>1) acts.appendChild(mkBtn('Store in box',()=>{ G.party.splice(i,1); G.box.push(c); tabTeam(); },false,'sm'));
const x=XF_OF[c.id];
if(x){
const need=SPECIES[x].xf, cat=need.cat, ready=c.lv>=need.lv[0], have=bagCount(cat)>0;
acts.appendChild(mkBtn('Awaken into '+SPECIES[x].n+' ('+ITEMS[cat].n+')',()=>{
if(!ready||!have){ toast(!ready?'Needs Lv '+need.lv[0]+'.':'Needs a '+ITEMS[cat].n+'.'); return; }
if(evolve(c,x)){ SFX.lvl(); burst(W_CENTER_X(),W_CENTER_Y(),80,TYPES[SPECIES[x].t[0]].c,260,1.2,5,0); toast(SPECIES[x].n+' has awakened!'); tabCreature(i); }
},!ready||!have,'pri'));
}
body.appendChild(acts);
});
}
function W_CENTER_X(){return OWV.w/2;} function W_CENTER_Y(){return OWV.h/2;}
function evolve(c,to){
const cat=SPECIES[to].xf.cat; if(!bagTake(cat)) return false;
const fr=c.hp/c.maxHp; c.id=to; c.maxHp=calcStat(to,c.lv,'hp'); c.hp=Math.max(1,Math.round(c.maxHp*fr));
for(const [l,m] of SPECIES[to].lr) if(l<=c.lv) addMove(c,m);
markCaught(to); return true;
}
function tabBag(){
openMenu('Bag',body=>{
body.appendChild(tabBar('bag'));
const ks=Object.keys(G.bag).filter(k=>G.bag[k]>0&&ITEMS[k]).sort();
if(!ks.length) body.insertAdjacentHTML('beforeend','<div class="kv">Your bag is light. Win battles or visit a shrine market.</div>');
ks.forEach(k=>{
const it=ITEMS[k], usable=['heal','revive','cure','field'].includes(it.k);
body.appendChild(rowEl({nm:it.n,sub:it.d+(usable?'':' <span class="sub">(battle or catalyst use)</span>'),sp:'× '+G.bag[k],dis:!usable,onclick:()=>useOutOfBattle(k)}));
});
});
}
function applyItem(c,it){
if(it.k==='heal'){ const b=c.hp; c.hp=Math.min(c.maxHp,c.hp+it.v); toast(name(c)+' recovered '+(c.hp-b)+' HP.'); }
else if(it.k==='revive'){ c.hp=Math.max(1,Math.floor(c.maxHp*it.v)); toast(name(c)+' rose again.'); }
else if(it.k==='cure'){ const b=bst(c); b.burn=b.frost=b.shock=false; toast(name(c)+' was cleansed.'); }
SFX.heal();
}
function useOutOfBattle(k){
const it=ITEMS[k];
if(it.k==='field'){ if(!bagTake(k)) return; G.lure=200; toast('The Lure Chime rings. Wild encounters are richer for a while.'); closeMenu(true); return; }
openMenu('Use '+it.n+' on…',body=>{
body.appendChild(backBtn(tabBag));
G.party.forEach(c=>{
const ok=it.k==='revive'?c.hp<=0:it.k==='heal'?(c.hp>0&&c.hp<c.maxHp):it.k==='cure'?!!(bst(c).burn||bst(c).frost||bst(c).shock):false;
body.appendChild(rowEl({nm:name(c),sub:barHtml(c.hp/c.maxHp)+'<span>'+Math.max(0,c.hp)+' / '+c.maxHp+'</span>',dis:!ok,onclick:()=>{
if(!bagTake(k)) return; applyItem(c,it); tabBag(); }}));
});
});
}
function tabDex(){
openMenu('Encyclopedia · '+uniqueCaught()+' / 8 bound',body=>{
body.appendChild(tabBar('dex'));
body.insertAdjacentHTML('beforeend','<div class="kv" style="margin-bottom:8px">Bind eight distinct creatures and clear four anchors to end the lattice silence.</div>');
const grid=document.createElement('div'); grid.className='grid2'; body.appendChild(grid);
for(const id of ALL_IDS){
const got=!!G.caught[id], known=got||!!G.seen[id];
grid.appendChild(rowEl({nm:known?SPECIES[id].n:'???',sub:got?typeTags(id):(known?'Glimpsed in the wilds':'Unknown'),sp:got?'Bound':(SPECIES[id].xf?'Awakening':''),dis:!known,onclick:()=>tabDexDetail(id)}));
}
});
}
function tabDexDetail(id){
const sp=SPECIES[id];
openMenu(sp.n,body=>{
body.appendChild(backBtn(tabDex));
const d=document.createElement('div'); d.className='kv'; d.style.marginTop='10px';
d.innerHTML=typeTags(id)+'<div style="margin:8px 0">'+sp.lore+'</div>'+
'<div>Base · HP '+sp.s[0]+' · ATK '+sp.s[1]+' · DEF '+sp.s[2]+' · SPD '+sp.s[3]+'</div>'+
(xfInfo(id)?'<div style="color:var(--acc);margin-top:6px">'+xfInfo(id)+'</div>':'');
body.appendChild(d);
const l=document.createElement('div'); l.className='kv'; l.style.marginTop='10px';
l.innerHTML='<b>Learnset</b><br>'+sp.lr.map(([lv,m])=>'Lv '+lv+' — '+MOVES[m][0]).join('<br>');
body.appendChild(l);
});
}
function tabMap(){
openMenu('Lattice Map',body=>{
body.appendChild(tabBar('map'));
ZONES.forEach((z,i)=>{
const st=G.beat[i]?'Cleared':(accessible(i)?(i===OW.zone?'You are here':'Open'):'Sealed');
body.appendChild(rowEl({nm:z.n+(z.anchor?' <span class="sub">· Anchor</span>':''),sub:z.msg+'<br>Warden: '+z.guard.n,sp:st}));
});
body.insertAdjacentHTML('beforeend','<div class="kv" style="margin-top:12px">Anchors cleared <b>'+anchorsBeaten()+'/4</b> · Bound <b>'+uniqueCaught()+'/8</b>'+(G.post?' · <b>Free Roam</b>':'')+'</div>');
});
}
function tabSave(){
openMenu('Record & Sound',body=>{
body.appendChild(tabBar('save'));
body.appendChild(rowEl({nm:'Record progress',sub:'Writes party, bag, gold and map progress to this browser.',onclick:()=>persist(true)}));
body.appendChild(rowEl({nm:AU.muted?'Sound is off':'Sound is on',sub:'Toggle the ambient pad and effects (M also works).',onclick:()=>{ setMuted(!AU.muted); tabSave(); }}));
body.appendChild(rowEl({nm:'Return to title',sub:'Progress is recorded first.',onclick:()=>{ persist(false); toTitle(); }}));
});
}
/* shrine, market, starter */
function openShrine(){
G.shrine={x:OW.rx,y:OW.ry};
openMenu('Shrine of '+ZONES[OW.zone].n,body=>{
body.appendChild(rowEl({nm:'Rest & mend',sub:'Restore every creature, refill Energy, and record progress.',onclick:()=>{
G.party.forEach(c=>{ c.hp=c.maxHp; c.moves.forEach(m=>m.pp=m.max); });
persist(false); SFX.heal(); burst(OWV.w/2,OWV.h/2,40,'#8fe3ff',200,1,4,0);
closeMenu(true); toast('Your party is whole. Progress recorded.');
}}));
body.appendChild(rowEl({nm:'Market',sub:'Trade gold for lattice items and catalysts.',onclick:()=>openShop()}));
body.appendChild(rowEl({nm:'Record progress',sub:'Save to this browser.',onclick:()=>persist(true)}));
body.appendChild(rowEl({nm:'Team & Binders',sub:'Review, reorder, and awaken creatures.',onclick:()=>tabTeam()}));
body.appendChild(rowEl({nm:'Leave',sub:'',onclick:()=>closeMenu(true)}));
});
}
function openShop(){
openMenu('Shrine Market · '+G.gold+' gold',body=>{
body.appendChild(backBtn(openShrineMenuAgain));
SHOP_ORDER.forEach(k=>{
const it=ITEMS[k]; const locked=it.zone!==undefined&&!accessible(it.zone);
body.appendChild(rowEl({nm:it.n,sub:it.d+(locked?' <span class="sub">(Reach '+ZONES[it.zone].n+' first)</span>':''),sp:it.cost+'g · '+bagCount(k)+' owned',dis:locked||G.gold<it.cost,onclick:()=>{
if(G.gold<it.cost) return; G.gold-=it.cost; bagAdd(k,1); SFX.ui(); updateHud(); openShop(); }}));
});
});
}
function openShrineMenuAgain(){ openShrine(); }
function openStarter(){
say([['Binding Stone','Three hearts stir beneath the stone. Choose one to walk beside you.']],()=>{
openMenu('Choose your first heart',body=>{
[['cinder','Ember-born and quick to kindle a fight.'],['tide','Salt-winged, steady through long exchanges.'],['sprig','Root-hardy, slow to anger, quick to mend.']].forEach(([id,desc])=>{
body.appendChild(rowEl({nm:name(mkCreature(id,5)),sub:typeTags(id)+' '+desc,sp:'Lv 5',onclick:()=>{
closeMenu(true); G.party.push(mkCreature(id,5)); markCaught(id); markSeen(id); SFX.bind();
G.tut=1; updateHint(); updateHud();
say([['Binding Stone',name(G.party[0])+' hums in answer. Walk into the tall grass to find a wild heart.']]);
}}));
});
},true);
});
}
/* title */
function drawTitle(dt){
const dpr=OWV.dpr, w=window.innerWidth, h=window.innerHeight, c=tctx;
c.setTransform(dpr,0,0,dpr,0,0);
c.clearRect(0,0,w,h);
const t=OW.time;
c.save(); c.translate(w/2,h*0.42);
for(let i=0;i<4;i++){
const r=90+i*48+Math.sin(t*0.8+i)*6;
c.beginPath(); c.arc(0,0,r,0,6.283); c.strokeStyle='rgba(143,227,255,'+(0.13-i*0.02)+')'; c.lineWidth=1.2; c.stroke();
for(let k=0;k<6;k++){ const a=t*(0.2+i*0.07)+k*Math.PI/3+i; c.beginPath(); c.arc(Math.cos(a)*r,Math.sin(a)*r,2.2,0,6.283); c.fillStyle='rgba(255,230,160,.6)'; c.fill(); }
}
c.restore();
updAmb(dt,w,h,'mote'); drawAmb(c);
}
/* battle keys/layout */
function battleKey(k){
if(!B) return;
if(B.phase==='cap'){ if(k==='Space'||k==='Enter') capPressed(); return; }
if(B.phase!=='menu') return;
const idx={Digit1:0,Digit2:1,Digit3:2,Digit4:3}[k];
if(idx!==undefined){ const p=curP(); if(p&&p.moves[idx]){ $('bcmd').innerHTML=''; doMove(idx); } }
}
function battleLayout(){ if(!B) return; B.w=window.innerWidth; B.h=window.innerHeight; B.dpr=OWV.dpr; }
/* ========== BOOT ========== */
function boot(){
wctx=$('world').getContext('2d');
bctx=$('bcanvas').getContext('2d');
tctx=$('titleCanvas').getContext('2d');
genWorld();
resize();
window.addEventListener('resize',resize);
let saved=null; try{ saved=localStorage.getItem('tessaline.mute'); }catch(e){}
AU.muted=saved==='1'; $('btnMute').textContent=AU.muted?'Sound: off':'Sound: on'; $('btnTitleMute').textContent=AU.muted?'Sound: off':'Sound: on';
$('btnNew').onclick=()=>{ audioInit(); SFX.ui(); startNewGame(); };
$('btnContinue').onclick=()=>{ audioInit(); SFX.ui(); continueGame(); };
$('btnContinue').disabled=!readSave();
const muteFn=()=>{ audioInit(); setMuted(!AU.muted); };
$('btnMute').onclick=muteFn; $('btnTitleMute').onclick=muteFn;
$('btnMenu').onclick=()=>{ if(MODE==='ow'&&!anyModal()) openPause(); };
$('capPress').onclick=()=>capPressed();
$('dialog').onclick=()=>dlgAdvance();
$('cutin').onclick=()=>advanceCut();
window.addEventListener('keydown',onKeyDown);
window.addEventListener('keyup',onKeyUp);
window.addEventListener('blur',()=>{ for(const k in KEYS) KEYS[k]=false; });
document.querySelectorAll('#touch [data-k]').forEach(b=>{
const k=b.dataset.k;
const isDir=k in DIRS;
b.addEventListener('pointerdown',e=>{
e.preventDefault(); b.setPointerCapture&&b.setPointerCapture(e.pointerId);
if(isDir){ KEYS[k]=true; audioInit(); if(AU.ctx&&AU.ctx.state==='suspended') AU.ctx.resume(); }
else onPress(k==='Escape'?'Escape':(k==='Enter'?'Enter':'Space'));
});
const up=()=>{ if(isDir) KEYS[k]=false; };
b.addEventListener('pointerup',up); b.addEventListener('pointercancel',up); b.addEventListener('pointerleave',up);
});
// ensure key-hold mapping is consistent with direction keys
requestAnimationFrame(t=>{ lastT=t; requestAnimationFrame(frame); });
toTitle();
}
window.addEventListener('DOMContentLoaded',boot);
if(document.readyState!=='loading') boot();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"6d798578-6079-469c-bfd9-395ff2071fd5","tokensIn":1597705,"tokensOut":242682,"tokensTotal":1840387,"cost":0.22293580499999996,"turns":19,"toolCalls":25,"failedToolCalls":0,"timestamp":"2026-10-08T04:59:44.113Z"} -->