Creature Collection
GLM 5.2 · middle

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 124,825 bytes · SHA-256 1fb392306f2e
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>ORAVETH — Bindings of Light</title>
<style>
/* ============ RESET / BASE ============ */
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
html,body{height:100%;width:100%;overflow:hidden;background:#04060a;color:#eaf2ff;
font-family:"Trebuchet MS","Segoe UI",system-ui,sans-serif;user-select:none;-webkit-user-select:none;}
:root{
--bg1:#0b1220;--bg2:#04060a;--accent:#7fe3ff;--accent2:#b48cff;
--glass:rgba(20,28,46,.55);--glass-brd:rgba(150,190,255,.22);
--txt:#eaf2ff;--dim:#9fb4d8;--good:#7dffb0;--bad:#ff7d8d;--warn:#ffd76b;
--shadow:0 8px 32px rgba(0,0,0,.5);
}
#app{position:fixed;inset:0;display:flex;flex-direction:column;overflow:hidden;}
.screen{position:absolute;inset:0;display:flex;flex-direction:column;}
.hidden{display:none !important;}
/* ============ TITLE ============ */
#title-screen{
align-items:center;justify-content:center;text-align:center;
background:radial-gradient(ellipse at 50% 30%,#15213f 0%,#070a14 55%,#02030a 100%);
overflow:hidden;
}
#title-bg{position:absolute;inset:0;z-index:0;}
.title-wrap{position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;gap:18px;padding:24px;}
.title-glow{font-size:clamp(40px,11vw,96px);font-weight:900;letter-spacing:.18em;
background:linear-gradient(180deg,#fff,#9fe9ff 45%,#b48cff);
-webkit-background-clip:text;background-clip:text;color:transparent;
text-shadow:0 0 60px rgba(127,227,255,.35);animation:pulseGlow 4s ease-in-out infinite;}
.title-sub{font-size:clamp(11px,3.4vw,18px);letter-spacing:.55em;color:var(--dim);text-transform:uppercase;margin-top:-6px;}
.title-tag{font-size:clamp(11px,3vw,15px);color:var(--dim);max-width:520px;line-height:1.7;margin-top:10px;}
@keyframes pulseGlow{0%,100%{filter:drop-shadow(0 0 14px rgba(127,227,255,.35));}50%{filter:drop-shadow(0 0 34px rgba(180,140,255,.55));}}
.title-btns{display:flex;flex-direction:column;gap:14px;margin-top:24px;width:min(280px,80vw);}
/* ============ BUTTONS ============ */
.btn{cursor:pointer;border:1px solid var(--glass-brd);border-radius:14px;padding:14px 20px;
font-size:15px;font-weight:700;letter-spacing:.06em;color:var(--txt);
background:linear-gradient(180deg,rgba(40,55,90,.6),rgba(16,24,42,.6));
backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
transition:transform .12s,box-shadow .2s,background .2s;text-transform:uppercase;position:relative;overflow:hidden;}
.btn:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(127,227,255,.18);
background:linear-gradient(180deg,rgba(60,80,130,.7),rgba(22,32,58,.7));}
.btn:active{transform:translateY(0) scale(.97);}
.btn.primary{background:linear-gradient(180deg,#3a6cff,#1f3ea8);border-color:#6fa0ff;box-shadow:0 6px 22px rgba(60,110,255,.4);}
.btn.gold{background:linear-gradient(180deg,#ffce5a,#d99a1e);color:#241a02;border-color:#ffe79a;}
.btn.danger{background:linear-gradient(180deg,#ff5a72,#a31f33);border-color:#ff9aab;color:#fff;}
.btn.sm{padding:8px 12px;font-size:12px;border-radius:10px;}
.btn.icon{padding:9px 11px;font-size:14px;min-width:40px;}
.btn:disabled{opacity:.4;cursor:not-allowed;transform:none;box-shadow:none;}
/* ============ HUD ============ */
#hud{position:absolute;top:0;left:0;right:0;z-index:30;display:flex;justify-content:space-between;align-items:flex-start;
padding:8px 10px;pointer-events:none;gap:8px;}
#hud>div{pointer-events:auto;}
.hud-left{display:flex;flex-direction:column;gap:6px;background:var(--glass);backdrop-filter:blur(12px);
-webkit-backdrop-filter:blur(12px);border:1px solid var(--glass-brd);border-radius:12px;padding:7px 12px;}
.zone-name{font-size:14px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;}
.zone-sub{font-size:10px;color:var(--dim);letter-spacing:.12em;text-transform:uppercase;}
.hud-right{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end;}
.hud-info{font-size:10px;color:var(--dim);display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
.hud-info b{color:var(--warn);}
.mute-dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--good);box-shadow:0 0 8px var(--good);}
.muted .mute-dot{background:#555;box-shadow:none;}
/* ============ STAGE / CANVAS ============ */
#stage{position:absolute;inset:0;}
#stage.shake,.shake{animation:shake .34s ease;}
@keyframes shake{0%{transform:translate(0,0)}20%{transform:translate(-6px,4px)}40%{transform:translate(6px,-3px)}60%{transform:translate(-4px,2px)}80%{transform:translate(3px,-2px)}100%{transform:translate(0,0)}}
#cv{position:absolute;inset:0;width:100%;height:100%;display:block;}
#battle{position:absolute;inset:0;z-index:25;display:flex;flex-direction:column;}
#battle-cv{position:absolute;inset:0;width:100%;height:100%;}
#battle-ui{position:absolute;left:0;right:0;bottom:0;z-index:5;padding:10px;}
/* battle top bars */
.fighter-bars{position:absolute;top:0;left:0;right:0;z-index:4;display:flex;justify-content:space-between;padding:10px 12px;gap:10px;pointer-events:none;}
.fb{background:var(--glass);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--glass-brd);
border-radius:12px;padding:8px 12px;min-width:min(200px,46vw);max-width:48vw;pointer-events:auto;}
.fb-name{font-size:13px;font-weight:800;letter-spacing:.05em;display:flex;justify-content:space-between;align-items:center;gap:8px;}
.fb-level{font-size:11px;color:var(--accent);}
.hp-shell{height:11px;background:rgba(0,0,0,.4);border-radius:7px;overflow:hidden;margin-top:6px;border:1px solid rgba(255,255,255,.1);}
.hp-fill{height:100%;width:100%;background:linear-gradient(90deg,#3dff9a,#7dffb0);transition:width .5s cubic-bezier(.3,1.2,.4,1),background .3s;}
.hp-fill.mid{background:linear-gradient(90deg,#ffd24d,#ffe07a);}
.hp-fill.low{background:linear-gradient(90deg,#ff5a6e,#ff8a9a);}
.hp-txt{font-size:10px;color:var(--dim);margin-top:3px;text-align:right;font-variant-numeric:tabular-nums;}
.energy-pips{display:flex;gap:2px;margin-top:3px;flex-wrap:wrap;}
.pip{width:7px;height:7px;border-radius:2px;background:var(--accent);box-shadow:0 0 4px var(--accent);}
.pip.empty{background:rgba(255,255,255,.12);box-shadow:none;}
.type-tag{font-size:9px;padding:1px 6px;border-radius:6px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;}
.status-chips{display:flex;gap:4px;margin-top:4px;flex-wrap:wrap;}
.chip{font-size:9px;padding:1px 6px;border-radius:6px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;}
/* battle action menu */
#action-menu{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
#move-menu{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
.move-btn{cursor:pointer;border:1px solid var(--glass-brd);border-radius:12px;padding:9px 11px;text-align:left;
background:var(--glass);backdrop-filter:blur(10px);transition:transform .1s,background .2s;position:relative;overflow:hidden;}
.move-btn:hover{transform:translateY(-2px);background:rgba(50,70,110,.7);}
.move-btn:active{transform:scale(.97);}
.move-name{font-size:13px;font-weight:800;display:flex;justify-content:space-between;align-items:center;}
.move-meta{font-size:10px;color:var(--dim);margin-top:3px;display:flex;gap:8px;}
.menu-title{font-size:11px;color:var(--dim);letter-spacing:.12em;text-transform:uppercase;margin-bottom:6px;}
/* floating numbers */
.float-num{position:absolute;z-index:60;font-weight:900;font-size:22px;pointer-events:none;
text-shadow:0 2px 6px rgba(0,0,0,.8);animation:floatUp 1s ease-out forwards;}
.float-num.crit{font-size:32px;color:#ffd24d;}
.float-num.dmg{color:#ff8a9a;}
.float-num.heal{color:#7dffb0;}
.float-num.miss{color:#cfd9ee;}
@keyframes floatUp{0%{transform:translateY(0) scale(.6);opacity:0;}15%{transform:translateY(-8px) scale(1.2);opacity:1;}100%{transform:translateY(-70px) scale(1);opacity:0;}}
/* crit flash overlay */
#fx-flash{position:absolute;inset:0;z-index:55;pointer-events:none;opacity:0;background:radial-gradient(circle,#fff,transparent 70%);}
#fx-flash.on{animation:flash .35s ease-out;}
@keyframes flash{0%{opacity:0;}20%{opacity:.8;}100%{opacity:0;}}
/* ============ MODALS / PANELS ============ */
.modal-scrim{position:absolute;inset:0;z-index:70;background:rgba(2,4,10,.6);backdrop-filter:blur(6px);
-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:14px;animation:fadeIn .25s;}
@keyframes fadeIn{from{opacity:0;}to{opacity:1;}}
.panel{background:var(--glass);backdrop-filter:blur(18px);-webkit-backdrop-filter:blur(18px);
border:1px solid var(--glass-brd);border-radius:20px;box-shadow:var(--shadow);
width:min(640px,96vw);max-height:88vh;display:flex;flex-direction:column;overflow:hidden;
animation:popIn .28s cubic-bezier(.2,1.2,.4,1);}
@keyframes popIn{from{transform:scale(.9);opacity:0;}to{transform:scale(1);opacity:1;}}
.panel-head{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--glass-brd);}
.panel-title{font-size:16px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;}
.panel-body{padding:14px 18px;overflow-y:auto;-webkit-overflow-scrolling:touch;}
.panel-body::-webkit-scrollbar{width:7px;}
.panel-body::-webkit-scrollbar-thumb{background:rgba(150,190,255,.3);border-radius:4px;}
/* team list */
.team-row{display:flex;align-items:center;gap:12px;padding:10px;border-radius:14px;
background:rgba(10,16,30,.5);border:1px solid rgba(150,190,255,.12);margin-bottom:8px;transition:background .2s;}
.team-row:hover{background:rgba(20,32,58,.7);}
.team-ava{width:46px;height:46px;border-radius:12px;flex-shrink:0;background:rgba(0,0,0,.3);}
.team-info{flex:1;min-width:0;}
.team-info .tn{font-weight:800;font-size:14px;display:flex;gap:6px;align-items:center;flex-wrap:wrap;}
.team-info .td{font-size:10px;color:var(--dim);display:flex;gap:10px;margin-top:2px;}
.team-hp{width:80px;flex-shrink:0;}
.team-hp .hp-shell{height:7px;}
.team-hp .hp-fill{height:100%;}
/* codex entry */
.codex-card{display:flex;gap:14px;padding:14px;border-radius:16px;background:rgba(10,16,30,.5);border:1px solid rgba(150,190,255,.1);margin-bottom:10px;}
.codex-card.locked{opacity:.5;filter:grayscale(.6);}
.codex-ava{width:64px;height:64px;border-radius:14px;background:rgba(0,0,0,.3);flex-shrink:0;}
.codex-name{font-weight:800;font-size:15px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;}
.codex-lore{font-size:12px;color:var(--dim);line-height:1.6;margin-top:6px;}
.codex-stats{display:flex;gap:14px;margin-top:8px;flex-wrap:wrap;font-size:11px;}
.codex-stat b{color:var(--accent);}
/* shop */
.shop-grid{display:grid;grid-template-columns:1fr;gap:8px;}
.shop-item{display:flex;justify-content:space-between;align-items:center;padding:10px 12px;border-radius:12px;
background:rgba(10,16,30,.5);border:1px solid rgba(150,190,255,.1);}
.shop-item .si-l{display:flex;flex-direction:column;}
.shop-item .si-n{font-weight:700;font-size:13px;}
.shop-item .si-d{font-size:11px;color:var(--dim);}
/* map */
.map-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;}
.map-cell{padding:12px;border-radius:14px;background:rgba(10,16,30,.5);border:1px solid rgba(150,190,255,.12);text-align:center;cursor:default;transition:.2s;}
.map-cell.unlocked{cursor:pointer;}
.map-cell.unlocked:hover{background:rgba(30,46,82,.7);transform:translateY(-2px);}
.map-cell .mn{font-weight:800;font-size:12px;margin-top:4px;}
.map-cell .md{font-size:10px;color:var(--dim);margin-top:3px;}
.map-cell .mi{font-size:24px;}
.map-cell.current{border-color:var(--accent);box-shadow:0 0 16px rgba(127,227,255,.3);}
/* ============ DIALOGUE ============ */
#dialogue{position:absolute;left:0;right:0;bottom:0;z-index:60;padding:12px;pointer-events:none;}
.dlg-box{pointer-events:auto;background:var(--glass);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
border:1px solid var(--glass-brd);border-radius:18px;padding:16px 20px;box-shadow:var(--shadow);
max-width:720px;margin:0 auto;animation:slideUp .3s cubic-bezier(.2,1.2,.4,1);}
@keyframes slideUp{from{transform:translateY(30px);opacity:0;}to{transform:translateY(0);opacity:1;}}
.dlg-speaker{font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--accent);margin-bottom:6px;}
.dlg-text{font-size:14px;line-height:1.7;color:var(--txt);}
.dlg-prompt{font-size:11px;color:var(--dim);text-align:right;margin-top:8px;animation:blink 1.2s infinite;}
@keyframes blink{0%,60%{opacity:1;}80%,100%{opacity:.2;}}
/* choices */
.choice-list{display:flex;flex-direction:column;gap:8px;margin-top:10px;pointer-events:auto;}
.choice-list .btn{padding:11px 16px;}
/* ============ TOUCH PAD ============ */
#touchpad{position:absolute;left:14px;bottom:14px;z-index:35;display:none;gap:0;grid-template-columns:repeat(3,46px);grid-template-rows:repeat(3,46px);}
body.touch #touchpad{display:grid;}
body.touch .hint-desktop{display:none;}
.dpad-btn{display:flex;align-items:center;justify-content:center;font-size:22px;cursor:pointer;
background:var(--glass);border:1px solid var(--glass-brd);border-radius:12px;backdrop-filter:blur(10px);
-webkit-backdrop-filter:blur(10px);user-select:none;}
.dpad-btn:active{background:rgba(80,120,200,.6);transform:scale(.94);}
#dp-up{grid-column:2;grid-row:1;}#dp-left{grid-column:1;grid-row:2;}#dp-right{grid-column:3;grid-row:2;}#dp-down{grid-column:2;grid-row:3;}
.touch-actions{position:absolute;right:14px;bottom:14px;z-index:35;display:none;flex-direction:column;gap:8px;align-items:flex-end;}
body.touch .touch-actions{display:flex;}
/* ============ TOASTS ============ */
#toasts{position:absolute;top:54px;left:0;right:0;z-index:80;display:flex;flex-direction:column;align-items:center;gap:6px;pointer-events:none;padding:0 12px;}
.toast{background:var(--glass);backdrop-filter:blur(12px);border:1px solid var(--glass-brd);border-radius:12px;
padding:8px 16px;font-size:12px;font-weight:700;box-shadow:var(--shadow);animation:toastIn .3s,toastOut .3s 2.2s forwards;max-width:90vw;text-align:center;}
.toast.good{border-color:rgba(125,255,176,.5);color:var(--good);}
.toast.bad{border-color:rgba(255,125,141,.5);color:var(--bad);}
@keyframes toastIn{from{transform:translateY(-14px);opacity:0;}to{transform:translateY(0);opacity:1;}}
@keyframes toastOut{to{transform:translateY(-14px);opacity:0;}}
/* vignette for story */
#vignette{position:absolute;inset:0;z-index:65;pointer-events:none;background:radial-gradient(ellipse at center,transparent 40%,rgba(40,10,80,.85) 100%);opacity:0;transition:opacity 1.2s;}
#vignette.on{opacity:1;}
#story-overlay{position:absolute;inset:0;z-index:66;display:none;align-items:center;justify-content:center;flex-direction:column;
padding:30px;text-align:center;gap:18px;pointer-events:auto;background:rgba(3,4,10,.75);backdrop-filter:blur(4px);}
#story-overlay .s-title{font-size:clamp(22px,6vw,40px);font-weight:900;letter-spacing:.1em;background:linear-gradient(180deg,#fff,#9fe9ff);-webkit-background-clip:text;background-clip:text;color:transparent;}
#story-overlay .s-text{font-size:clamp(13px,3.5vw,17px);color:var(--dim);max-width:560px;line-height:1.8;white-space:pre-line;}
#story-overlay .s-cont{color:var(--accent);font-size:12px;letter-spacing:.2em;animation:blink 1.2s infinite;}
/* capture minigame */
#cap-bar{position:relative;width:min(420px,86vw);height:42px;background:rgba(0,0,0,.45);border:1px solid var(--glass-brd);border-radius:21px;overflow:hidden;margin:10px 0;}
#cap-zone{position:absolute;top:0;bottom:0;left:42%;width:16%;background:linear-gradient(90deg,rgba(125,255,176,.25),rgba(125,255,176,.45),rgba(125,255,176,.25));border-left:2px solid var(--good);border-right:2px solid var(--good);}
#cap-marker{position:absolute;top:3px;bottom:3px;width:5px;border-radius:3px;background:#fff;box-shadow:0 0 12px #fff;left:0;}
.capture-result{font-size:15px;font-weight:800;text-align:center;margin-top:8px;min-height:20px;}
.flex-row{display:flex;gap:8px;flex-wrap:wrap;}
.flex-row.center{justify-content:center;}
.spacer{flex:1;}
.small{font-size:11px;color:var(--dim);}
.center-txt{text-align:center;}
.sep{height:1px;background:var(--glass-brd);margin:10px 0;}
/* responsive tweaks */
@media(max-width:560px){
.fb{min-width:42vw;padding:6px 9px;}
.fb-name{font-size:12px;}
.move-name{font-size:12px;}
.panel-title{font-size:14px;}
}
</style>
</head>
<body>
<div id="app">
<!-- TITLE -->
<div id="title-screen" class="screen">
<canvas id="title-bg"></canvas>
<div class="title-wrap">
<div class="title-glow">ORAVETH</div>
<div class="title-sub">Bindings of Light</div>
<div class="title-tag">Seven affinities drift across a fractured sky. Bind the Lumari, fell the Four Anchors, and mend the veil before the last dawn fades.</div>
<div class="title-btns" id="title-btns"></div>
<div class="small" style="margin-top:18px;opacity:.6">WASD / Arrows to move · Tap buttons on touch devices</div>
</div>
</div>
<!-- GAME -->
<div id="game" class="screen hidden">
<div id="stage"><canvas id="cv"></canvas></div>
<div id="battle" class="hidden">
<canvas id="battle-cv"></canvas>
<div class="fighter-bars" id="fighter-bars"></div>
<div id="fx-flash"></div>
<div id="battle-ui"></div>
</div>
<div id="hud">
<div class="hud-left">
<div class="zone-name" id="zone-name">—</div>
<div class="zone-sub" id="zone-sub"></div>
<div class="hud-info" id="hud-info"></div>
</div>
<div class="hud-right" id="hud-right"></div>
</div>
<div id="touchpad">
<div class="dpad-btn" id="dp-up">▲</div>
<div class="dpad-btn" id="dp-left">◀</div>
<div class="dpad-btn" id="dp-right">▶</div>
<div class="dpad-btn" id="dp-down">▼</div>
</div>
<div class="touch-actions" id="touch-actions"></div>
<div id="dialogue" class="hidden"></div>
<div id="vignette"></div>
<div id="story-overlay"><div class="s-title"></div><div class="s-text"></div><div class="s-cont">tap to continue</div></div>
<div id="toasts"></div>
<div id="overlay-root"></div>
</div>
</div>
<script>
"use strict";
/* ====================================================================
ORAVETH — single-file creature-collector RPG
==================================================================== */
/* ============ TYPES & CHART ============ */
const TYPES = ['Solis','Gale','Umber','Tide','Ember','Aegis','Void'];
const TYPE_COLORS = {
Solis:'#ffd24d', Gale:'#7dffb0', Umber:'#9a6bff', Tide:'#4fd6e6',
Ember:'#ff7a3d', Aegis:'#9fd9ff', Void:'#c77dff'
};
// triangle: Solis>Umber>Gale>Solis ; Tide>Solis,Gale ; Ember>Tide,Umber ; Void>all(slight), >Aegis(x2); Aegis resists all(x0.5)
function typeMult(att, def){
if(att==='Void'){ return def==='Aegis'?2.0:1.25; }
if(def==='Aegis') return 0.5;
if(att==='Solis') return def==='Umber'?1.5:(def==='Gale'?0.5:1.0);
if(att==='Umber') return def==='Gale'?1.5:(def==='Solis'?0.5:1.0);
if(att==='Gale') return def==='Solis'?1.5:(def==='Umber'?0.5:1.0);
if(att==='Tide') return (def==='Solis'||def==='Gale')?1.5:1.0;
if(att==='Ember') return (def==='Tide'||def==='Umber')?1.5:1.0;
if(att==='Aegis') return 1.0;
return 1.0;
}
/* ============ ABILITIES (42) ============ */
// fields: name, type, pow, acc, pp, eff:{}, desc
const ABILITIES = {
// Solis
dawnlash: {name:'Dawnlash', type:'Solis', pow:42, acc:100, pp:30, desc:'A whip of morning light.'},
radiantbeam:{name:'Radiantbeam',type:'Solis', pow:54, acc:100, pp:20, eff:{priority:1}, desc:'Quick as sunrise.'},
solarflare: {name:'Solarflare', type:'Solis', pow:72, acc:90, pp:10, eff:{burn:0.30}, desc:'Searing dawn; may burn.'},
sunveil: {name:'Sunveil', type:'Solis', pow:0, acc:100, pp:18, eff:{barrier:1,defUp:1}, desc:'Wreathe in light; deflect one hit.'},
mirrorgleam:{name:'Mirrorgleam',type:'Solis', pow:36, acc:95, pp:22, eff:{barrier:1}, desc:'A shielding glint.'},
// Gale
gustslash: {name:'Gustslash', type:'Gale', pow:42, acc:100, pp:30, desc:'A slicing current.'},
zephyrburst:{name:'Zephyrburst',type:'Gale', pow:50, acc:100, pp:20, eff:{priority:1}, desc:'Outpace the wind.'},
cyclonedrill:{name:'Cyclonedrill',type:'Gale',pow:26,acc:95,pp:18,eff:{multi:[2,3]},desc:'Strikes 2-3 times.'},
tailwind: {name:'Tailwind', type:'Gale', pow:0, acc:100, pp:18, eff:{spdUp:2}, desc:'Lift your speed sharply.'},
hurriclaw: {name:'Hurriclaw', type:'Gale', pow:74, acc:85, pp:10, eff:{frost:0.35}, desc:'A rending gale; may freeze.'},
// Umber
shadebite: {name:'Shadebite', type:'Umber', pow:42, acc:100, pp:30, desc:'A bite of living dusk.'},
grimspike: {name:'Grimspike', type:'Umber', pow:60, acc:95, pp:16, eff:{shock:0.25}, desc:'Shadow spikes; may stun.'},
umbradrain: {name:'Umbradrain', type:'Umber', pow:50, acc:100, pp:15, eff:{lifesteal:0.5}, desc:'Drain life from the dark.'},
nightveil: {name:'Nightveil', type:'Umber', pow:0, acc:100, pp:18, eff:{atkDown:1,target:1}, desc:"Dim a foe's might."},
eclipseclaw:{name:'Eclipseclaw',type:'Umber', pow:82, acc:85, pp:8, desc:'A claw forged from total night.'},
// Tide
splashstrike:{name:'Splashstrike',type:'Tide',pow:42,acc:100,pp:30,desc:'A brisk watery slap.'},
tidecrash: {name:'Tidecrash', type:'Tide',pow:34,acc:95,pp:18,eff:{multi:[2,2]},desc:'Crashes twice.'},
riptide: {name:'Riptide', type:'Tide',pow:54,acc:100,pp:14,eff:{lifesteal:0.3},desc:'Pull foe under; recover a little.'},
mistward: {name:'Mistward', type:'Tide',pow:0,acc:100,pp:16,eff:{regen:3},desc:'Heal slowly over time.'},
drowningdepth:{name:'Drowningdepth',type:'Tide',pow:80,acc:85,pp:8,eff:{frost:0.35},desc:'A crushing undertow.'},
// Ember
emberstrike:{name:'Emberstrike',type:'Ember',pow:42,acc:100,pp:30,desc:'A flick of cinder.'},
cindercoil: {name:'Cindercoil', type:'Ember',pow:60,acc:95,pp:16,eff:{burn:0.30},desc:'Coiled fire; may burn.'},
heatsear: {name:'Heatsear', type:'Ember',pow:54,acc:100,pp:16,eff:{defDown:1,target:1},desc:"Scorch a foe's guard."},
flarepulse: {name:'Flarepulse', type:'Ember',pow:74,acc:85,pp:10,eff:{burn:0.40},desc:'A pulse of open flame.'},
infernotide:{name:'Infernotide', type:'Ember',pow:92,acc:80,pp:6,eff:{burn:0.30},desc:'A tide of wildfire.'},
// Aegis
crystalshard:{name:'Crystalshard',type:'Aegis',pow:42,acc:100,pp:30,desc:'A volley of glass.'},
prismray: {name:'Prismray', type:'Aegis',pow:54,acc:100,pp:16,eff:{spdDown:1,target:1},desc:'Refracted light slows the foe.'},
shatterpoint:{name:'Shatterpoint',type:'Aegis',pow:70,acc:90,pp:12,eff:{crit:2},desc:'A precise, brittle strike.'},
bulwark: {name:'Bulwark', type:'Aegis',pow:0,acc:100,pp:16,eff:{barrier:1,defUp:1},desc:'Raise crystal walls.'},
wardbreak: {name:'Wardbreak', type:'Aegis',pow:66,acc:95,pp:14,eff:{pierce:1},desc:'Ignores barriers.'},
// Void
voidnip: {name:'Voidnip', type:'Void',pow:42,acc:100,pp:30,desc:'A pinch of nothing.'},
nullpulse: {name:'Nullpulse', type:'Void',pow:54,acc:100,pp:16,eff:{drainpp:1,target:1},desc:"Unravel a foe's focus."},
collapse: {name:'Collapse', type:'Void',pow:74,acc:90,pp:10,eff:{shock:0.30},desc:'Fold space onto the foe.'},
oblivion: {name:'Oblivion', type:'Void',pow:86,acc:80,pp:8,desc:'A taste of the final silence.'},
entropy: {name:'Entropy', type:'Void',pow:0,acc:100,pp:14,eff:{burn:1,target:1, burnDmg:0.16},desc:'Curse the foe to waste away.'},
// Neutral utility
restrike: {name:'Restrike', type:'Neutral',pow:46,acc:100,pp:28,desc:'A solid basic blow.'},
quickbite: {name:'Quickbite', type:'Neutral',pow:36,acc:100,pp:24,eff:{priority:1},desc:'Always strikes first.'},
focuslash: {name:'Focuslash', type:'Neutral',pow:50,acc:100,pp:18,eff:{atkUp:1},desc:'Channel then strike; raises ATK.'},
mend: {name:'Mend', type:'Neutral',pow:0,acc:100,pp:14,eff:{heal:0.45},desc:'Recover nearly half your HP.'},
leechfang: {name:'Leechfang', type:'Neutral',pow:46,acc:100,pp:16,eff:{lifesteal:0.5},desc:'Bite and drink half the harm.'},
refract: {name:'Refract', type:'Neutral',pow:0,acc:100,pp:14,eff:{barrier:1,regen:2},desc:'Bend harm aside; mend slowly.'},
chargebeam: {name:'Chargebeam', type:'Neutral',pow:62,acc:95,pp:14,eff:{atkUp:1},desc:'A surge that emboldens you.'},
};
const NEUTRAL='Neutral';
function stab(abType, creaType){ return abType!==NEUTRAL && abType===creaType ? 1.25 : 1.0; }
/* ============ CREATURES (27) ============ */
// bodyType for drawing: orb/beast/bird/fish/sprite/golem/wraith ; hue 0..360
const CREATURES = {
// ---- Zone 1: Lumenwold ----
glintfox: {name:'Glintfox', type:'Solis', hue:46, body:'beast', base:{hp:46,atk:13,def:9,spd:15},
lore:'It darts between sunbeams, leaving warm trails that linger at dusk.',
learn:[[1,'dawnlash'],[5,'quickbite'],[9,'radiantbeam'],[14,'focuslash'],[20,'solarflare']],
transform:{to:'luxenfox',lv:24,catalyst:'solislumen'}},
verdimp: {name:'Verdimp', type:'Gale', hue:130, body:'sprite', base:{hp:44,atk:11,def:11,spd:14},
lore:'Born of the first morning gust, it hums in tones only the waking hear.',
learn:[[1,'gustslash'],[5,'tailwind'],[10,'zephyrburst'],[15,'cyclonedrill'],[22,'hurriclaw']],
transform:{to:'dawnsinger',lv:24,catalyst:'galeshard'}},
solmote: {name:'Solmote', type:'Solis', hue:50, body:'orb', base:{hp:60,atk:10,def:14,spd:8},
lore:'Each holds a splinter of the first sunrise, kept warm beneath its shell.',
learn:[[1,'dawnlash'],[6,'sunveil'],[12,'mirrorgleam'],[18,'radiantbeam'],[25,'solarflare']],
transform:null},
// ---- Zone 2: Mirefen ----
tidepip: {name:'Tidepip', type:'Tide', hue:195, body:'fish', base:{hp:50,atk:12,def:12,spd:12},
lore:'It cups moonlit water in its palms and drinks the reflection of stars.',
learn:[[1,'splashstrike'],[6,'mistward'],[11,'tidecrash'],[16,'riptide'],[24,'drowningdepth']],
transform:{to:'tidekeeper',lv:26,catalyst:'tidepearl'}},
dusknewt: {name:'Dusknewt', type:'Umber', hue:275, body:'beast', base:{hp:52,atk:13,def:11,spd:11},
lore:'Where it walks, puddles remember the dark that once filled them.',
learn:[[1,'shadebite'],[6,'nightveil'],[11,'grimspike'],[17,'umbradrain'],[25,'eclipseclaw']],
transform:null},
lurkfern: {name:'Lurkfern', type:'Umber', hue:160, body:'sprite', base:{hp:54,atk:11,def:14,spd:9},
lore:'Its fronds uncurl only where no lantern has ever shone.',
learn:[[1,'shadebite'],[7,'mistward'],[12,'leechfang'],[18,'grimspike'],[26,'eclipseclaw']],
transform:null},
// ---- Zone 3: Cinderreach ----
cindrat: {name:'Cindrat', type:'Ember', hue:25, body:'beast', base:{hp:48,atk:14,def:10,spd:14},
lore:'Its pelt smolders without burning, fed by the mountain\'s old breath.',
learn:[[1,'emberstrike'],[6,'heatsear'],[11,'cindercoil'],[17,'flarepulse'],[25,'infernotide']],
transform:null},
flarewing: {name:'Flarewing', type:'Ember', hue:20, body:'bird', base:{hp:46,atk:14,def:9,spd:16},
lore:'It molts sparks each spring, seeding new fires across the ridge.',
learn:[[1,'emberstrike'],[6,'zephyrburst'],[12,'cindercoil'],[18,'flarepulse'],[26,'infernotide']],
transform:null},
magmanub: {name:'Magmanub', type:'Ember', hue:15, body:'golem', base:{hp:66,atk:14,def:15,spd:7},
lore:'A walking kiln; its core never cools below the heat of noon.',
learn:[[1,'emberstrike'],[7,'chargebeam'],[13,'cindercoil'],[19,'flarepulse'],[28,'infernotide']],
transform:{to:'cindral',lv:28,catalyst:'emberheart'}},
// ---- Zone 4: Shadecrag ----
galehorn: {name:'Galehorn', type:'Gale', hue:170, body:'beast', base:{hp:58,atk:14,def:12,spd:15},
lore:'It races the storms and loses on purpose, for the joy of being caught.',
learn:[[1,'gustslash'],[7,'tailwind'],[12,'cyclonedrill'],[18,'zephyrburst'],[27,'hurriclaw']],
transform:{to:'tempestwing',lv:30,catalyst:'galeshard'}},
obskink: {name:'Obskink', type:'Umber', hue:250, body:'beast', base:{hp:56,atk:13,def:15,spd:10},
lore:'It wears the dark like armor woven from closed eyes.',
learn:[[1,'shadebite'],[7,'nightveil'],[13,'grimspike'],[19,'umbradrain'],[28,'eclipseclaw']],
transform:null},
vexclaw: {name:'Vexclaw', type:'Umber', hue:230, body:'beast', base:{hp:54,atk:16,def:11,spd:13},
lore:'Its claws leave wounds that heal into new scars of night.',
learn:[[1,'shadebite'],[8,'focuslash'],[14,'grimspike'],[20,'umbradrain'],[29,'eclipseclaw']],
transform:{to:'obsidira',lv:30,catalyst:'umbercore'}},
// ---- Zone 5: Crystelium ----
prismole: {name:'Prismole', type:'Aegis', hue:180, body:'beast', base:{hp:60,atk:12,def:16,spd:9},
lore:'It burrows through light, leaving tunnels of refracted color.',
learn:[[1,'crystalshard'],[7,'prismray'],[13,'bulwark'],[19,'shatterpoint'],[28,'wardbreak']],
transform:null},
aegishell: {name:'Aegishell', type:'Aegis', hue:190, body:'golem', base:{hp:70,atk:11,def:19,spd:6},
lore:'Its shell is a single perfect crystal, grown over centuries of stillness.',
learn:[[1,'crystalshard'],[8,'bulwark'],[14,'prismray'],[20,'shatterpoint'],[29,'wardbreak']],
transform:{to:'crystarch',lv:32,catalyst:'aegisprism'}},
lucentskip: {name:'Lucentskip', type:'Aegis', hue:200, body:'sprite', base:{hp:52,atk:13,def:14,spd:14},
lore:'Each leap splits the air into a brief, singing rainbow.',
learn:[[1,'crystalshard'],[7,'prismray'],[13,'shatterpoint'],[19,'zephyrburst'],[28,'wardbreak']],
transform:null},
// ---- Zone 6: Nullspire ----
nullith: {name:'Nullith', type:'Void', hue:280, body:'orb', base:{hp:62,atk:15,def:13,spd:12},
lore:'It is the shape of the silence between two stars.',
learn:[[1,'voidnip'],[8,'nullpulse'],[14,'collapse'],[20,'entropy'],[30,'oblivion']],
transform:{to:'nullarch',lv:34,catalyst:'voidsigil'}},
wraithveil: {name:'Wraithveil', type:'Void', hue:290, body:'wraith', base:{hp:58,atk:15,def:12,spd:14},
lore:'It collects the echoes of things that never came to be.',
learn:[[1,'voidnip'],[8,'entropy'],[14,'nullpulse'],[20,'collapse'],[30,'oblivion']],
transform:{to:'eclipseveil',lv:34,catalyst:'voidsigil'}},
hollowcrown: {name:'Hollowcrown',type:'Void', hue:300, body:'wraith', base:{hp:74,atk:17,def:15,spd:10},
lore:'Its crown is a ring of suns that forgot how to rise.',
learn:[[1,'voidnip'],[8,'nullpulse'],[15,'collapse'],[22,'entropy'],[32,'oblivion']],
transform:null},
// ---- TRANSFORMED (9) ----
luxenfox: {name:'Luxenfox', type:'Solis', hue:48, body:'beast', base:{hp:64,atk:18,def:13,spd:19},
lore:'A fox woven from steady noon; its bark turns shadow to glass.',
learn:[[1,'dawnlash'],[1,'radiantbeam'],[1,'solarflare'],[30,'chargebeam'],[40,'infernotide']],
transform:null},
dawnsinger: {name:'Dawnsinger', type:'Gale', hue:140, body:'bird', base:{hp:62,atk:17,def:14,spd:20},
lore:'Its song parts the fog and calls the lost homeward.',
learn:[[1,'gustslash'],[1,'hurriclaw'],[1,'cyclonedrill'],[32,'tailwind'],[42,'zephyrburst']],
transform:null},
tidekeeper: {name:'Tidekeeper', type:'Tide', hue:200, body:'fish', base:{hp:74,atk:18,def:17,spd:15},
lore:'It remembers every tide that ever was, and weighs them equally.',
learn:[[1,'splashstrike'],[1,'drowningdepth'],[1,'riptide'],[34,'mistward'],[44,'tidecrash']],
transform:null},
cindral: {name:'Cindral', type:'Ember', hue:18, body:'golem', base:{hp:86,atk:20,def:20,spd:11},
lore:'The kiln awakened; it walks on cooling lava and a patient, old anger.',
learn:[[1,'emberstrike'],[1,'infernotide'],[1,'flarepulse'],[36,'heatsear'],[46,'chargebeam']],
transform:null},
tempestwing: {name:'Tempestwing',type:'Gale', hue:175, body:'bird', base:{hp:76,atk:19,def:16,spd:21},
lore:'The storm it once lost to has chosen to ride upon its back.',
learn:[[1,'gustslash'],[1,'hurriclaw'],[1,'cyclonedrill'],[38,'tailwind'],[48,'zephyrburst']],
transform:null},
obsidira: {name:'Obsidira', type:'Umber', hue:240, body:'wraith', base:{hp:74,atk:21,def:16,spd:17},
lore:'A serpent of fractured night; coiling, it tightens the dark around the day.',
learn:[[1,'shadebite'],[1,'eclipseclaw'],[1,'umbradrain'],[38,'nightveil'],[48,'grimspike']],
transform:null},
crystarch: {name:'Crystarch', type:'Aegis', hue:195, body:'golem', base:{hp:96,atk:16,def:25,spd:9},
lore:'The unbroken heart of the glass bastion, slow and utterly unyielding.',
learn:[[1,'crystalshard'],[1,'shatterpoint'],[1,'wardbreak'],[40,'bulwark'],[50,'prismray']],
transform:null},
nullarch: {name:'Nullarch', type:'Void', hue:285, body:'orb', base:{hp:84,atk:22,def:18,spd:16},
lore:'Where it floats, the world politely forgets to exist.',
learn:[[1,'voidnip'],[1,'oblivion'],[1,'collapse'],[40,'entropy'],[50,'nullpulse']],
transform:null},
eclipseveil: {name:'Eclipseveil',type:'Void', hue:295, body:'wraith', base:{hp:80,atk:22,def:17,spd:19},
lore:'It drapes across suns and tells them it is time to rest.',
learn:[[1,'voidnip'],[1,'oblivion'],[1,'entropy'],[40,'collapse'],[50,'nullpulse']],
transform:null},
};
/* ============ ZONES (6) ============ */
// palette drives CSS vars; encounterTable: [species, weight, minLv, maxLv]; guardian creature
const ZONES = [
{name:'Lumenwold', sub:'The First Meadow', storyNode:'A soft dawn spills across meadows of tall light-grass. Your binding begins here.',
palette:{bg1:'#13261f',bg2:'#06120d',accent:'#bdf5a6',accent2:'#ffd479',particle:'petal'},
types:['Solis','Gale'],
encounters:[['glintfox',40,3,6],['verdimp',35,3,6],['solmote',25,4,7]],
guardian:'verdimp', guardianLv:8, anchor:false,
shop:{glimlamp:60, mendsap:80, repelmote:90, solislumen:300, galeshard:300}},
{name:'Mirefen', sub:'The Drowned Hollow', storyNode:'Lantern-light sinks into black water. Something patient watches from beneath the reeds.',
palette:{bg1:'#0a1c28',bg2:'#04101a',accent:'#5fd9e6',accent2:'#7a9cff',particle:'bubble'},
types:['Tide','Umber'],
encounters:[['tidepip',38,7,11],['dusknewt',32,7,11],['lurkfern',22,8,12],['glintfox',8,8,11]],
guardian:'lurkfern', guardianLv:14, anchor:false,
shop:{glimlamp:60, prismlamp:160, mendsap:80, revivesap:240, panacea:140, repelmote:90, tidepearl:320, umbercore:320}},
{name:'Cinderreach', sub:'The Smoldering Ridge', storyNode:'Ash falls like warm snow. The First Anchor coils in the volcanic throat above.',
palette:{bg1:'#1e0f0a',bg2:'#0a0403',accent:'#ff8a3d',accent2:'#ffd24d',particle:'ember'},
types:['Ember'],
encounters:[['cindrat',40,13,18],['flarewing',34,13,18],['magmanub',20,14,19],['galehorn',6,14,18]],
guardian:'magmanub', guardianLv:22, anchor:true, anchorName:'Cindral, the Smoldering Anchor',
shop:{glimlamp:60, prismlamp:160, stellarlamp:340, mendsap:80, revivesap:240, panacea:140, repelmote:90, emberheart:380}},
{name:'Shadecrag', sub:'The Obsidian Spire', storyNode:'Glass-black peaks knife the violet sky. The Second Anchor waits atop the broken stair.',
palette:{bg1:'#150a24',bg2:'#080414',accent:'#a87cff',accent2:'#d49bff',particle:'dust'},
types:['Umber','Gale'],
encounters:[['galehorn',34,20,26],['obskink',30,20,26],['vexclaw',22,21,27],['dusknewt',14,20,26]],
guardian:'vexclaw', guardianLv:30, anchor:true, anchorName:'Obsidira, the Coiled Anchor',
shop:{glimlamp:60, prismlamp:160, stellarlamp:340, mendsap:80, revivesap:240, panacea:140, repelmote:90, umbercore:360, galeshard:360}},
{name:'Crystelium', sub:'The Glass Bastion', storyNode:'Walls of living crystal ring with old songs. The Third Anchor sleeps at their still center.',
palette:{bg1:'#08181f',bg2:'#03090d',accent:'#7af0ff',accent2:'#c0d6ff',particle:'shard'},
types:['Aegis'],
encounters:[['prismole',38,27,33],['aegishell',26,28,34],['lucentskip',28,27,33],['flarewing',8,28,33]],
guardian:'aegishell', guardianLv:38, anchor:true, anchorName:'Crystarch, the Unbroken Anchor',
shop:{glimlamp:60, prismlamp:160, stellarlamp:340, mendsap:80, revivesap:240, panacea:140, repelmote:90, aegisprism:420, solislumen:340}},
{name:'Nullspire', sub:'The Hollow Crown', storyNode:'No wind here, no warmth — only the patient absence at the world\'s crown. The Fourth Anchor rules the silence.',
palette:{bg1:'#0a0612',bg2:'#02020a',accent:'#c07bff',accent2:'#ff6bd0',particle:'void'},
types:['Void'],
encounters:[['nullith',38,33,40],['wraithveil',34,33,40],['hollowcrown',20,34,41],['obskink',8,34,40]],
guardian:'hollowcrown', guardianLv:46, anchor:true, anchorName:'Hollowcrown, the Crowned Anchor',
shop:{glimlamp:60, prismlamp:160, stellarlamp:340, mendsap:80, revivesap:240, panacea:140, repelmote:90, voidsigil:480, escapesigil:120}},
];
/* ============ ITEMS ============ */
const ITEMS = {
glimlamp: {name:'Glim Lamp', kind:'capture', quality:0.40, desc:'A simple lantern; binds weakened Lumari.'},
prismlamp: {name:'Prism Lamp', kind:'capture', quality:0.60, desc:'Refracted light; far surer binding.'},
stellarlamp: {name:'Stellar Lamp',kind:'capture',quality:0.80, desc:'Starlight lantern; binds almost anything.'},
mendsap: {name:'Mendsap', kind:'heal', heal:60, desc:'Restores 60 HP to one Lumari.'},
revivesap: {name:'Revivesap', kind:'revive', desc:'Revives a fainted Lumari at half HP.'},
panacea: {name:'Panacea', kind:'cure', desc:'Clears all status afflictions.'},
repelmote: {name:'Repel Mote', kind:'field', steps:60, desc:'Wards wild Lumari away for 60 steps.'},
escapesigil: {name:'Escape Sigil',kind:'warp', desc:'Return instantly to the last shrine.'},
solislumen: {name:'Solislumen', kind:'catalyst', cat:'Solis', desc:'Catalyst of radiance. Transforms Solis Lumari.'},
galeshard: {name:'Galeshard', kind:'catalyst', cat:'Gale', desc:'Catalyst of wind. Transforms Gale Lumari.'},
umbercore: {name:'Umbercore', kind:'catalyst', cat:'Umber', desc:'Catalyst of dusk. Transforms Umber Lumari.'},
tidepearl: {name:'Tidepearl', kind:'catalyst', cat:'Tide', desc:'Catalyst of water. Transforms Tide Lumari.'},
emberheart: {name:'Emberheart', kind:'catalyst', cat:'Ember', desc:'Catalyst of fire. Transforms Ember Lumari.'},
aegisprism: {name:'Aegisprism', kind:'catalyst', cat:'Aegis', desc:'Catalyst of crystal. Transforms Aegis Lumari.'},
voidsigil: {name:'Voidsigil', kind:'catalyst', cat:'Void', desc:'Catalyst of null. Transforms Void Lumari.'},
};
const STARTERS = ['glintfox','verdimp','tidepip'];
const PARTY_MAX = 6;
const LEVEL_CAP = 50;
const WIN_UNIQUE = 8; // unique species for victory
const WIN_ANCHORS = 4; // anchors for victory
/* ============ UTILITIES ============ */
const $ = (s,r=document)=>r.querySelector(s);
const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
function el(tag, cls, html){ const e=document.createElement(tag); if(cls)e.className=cls; if(html!=null)e.innerHTML=html; return e; }
function clamp(v,a,b){return v<a?a:v>b?b:v;}
function lerp(a,b,t){return a+(b-a)*t;}
function rnd(a,b){return a+Math.random()*(b-a);}
function rndi(a,b){return Math.floor(a+Math.random()*(b-a+1));}
function pick(arr){return arr[Math.floor(Math.random()*arr.length)];}
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
function uid(){return 'c'+Math.random().toString(36).slice(2,9)+Date.now().toString(36).slice(-3);}
/* ============ STAT COMPUTATION ============ */
function statFor(species, level){
const b = CREATURES[species].base;
return {
maxhp: Math.floor(b.hp + level*4 + 6),
atk: Math.floor(b.atk + level*1.35),
def: Math.floor(b.def + level*1.15),
spd: Math.floor(b.spd + level*0.9),
};
}
// exp needed to reach next level
function expForLevel(level){ return Math.floor(8 + level*level*4 + level*6); }
/* ============ CREATURE FACTORY ============ */
function makeCreature(species, level, opts={}){
const def = CREATURES[species];
if(!def){ console.error('missing species',species); species='glintfox'; }
const c = {
uid: uid(), species, level: clamp(level,1,LEVEL_CAP),
affinity: def.type,
exp: opts.exp||0,
hp: 0, abilities:[], pp:{}, status:null, // status: {type:'burn',turns:3,...}
boosts:{atk:0,def:0,spd:0}, barrier:0,
catalystUsed:false,
};
recomputeStats(c);
c.hp = opts.fullHp!==false ? c.maxhp : (opts.hp||0);
// learnset up to level
def.learn.forEach(([lv,ab])=>{ if(c.level>=lv && !c.abilities.includes(ab)) c.abilities.push(ab); });
// ensure at least one damaging move
if(c.abilities.length===0) c.abilities.push('restrike');
c.abilities = c.abilities.slice(0,4);
c.abilities.forEach(ab=>{ c.pp[ab]=ABILITIES[ab].pp; });
return c;
}
function recomputeStats(c){
const s = statFor(c.species, c.level);
c.maxhp=s.maxhp; c.atk=s.atk; c.def=s.def; c.spd=s.spd;
}
function knownAbilitiesForLevel(species, level){
const arr=[];
CREATURES[species].learn.forEach(([lv,ab])=>{ if(level>=lv) arr.push(ab); });
if(arr.length===0) arr.push('restrike');
return arr;
}
// effective stat with boosts (-6..+6)
function effStat(base, stage){
const s = clamp(stage,-6,6);
const mul = s>=0 ? (2+s)/2 : 2/(2-s);
return base*mul;
}
function healParty(p){ p.forEach(c=>{ recomputeStats(c); c.hp=c.maxhp; c.abilities.forEach(ab=>c.pp[ab]=ABILITIES[ab].pp); c.status=null; c.boosts={atk:0,def:0,spd:0}; c.barrier=0; }); }
/* ============ GLOBAL STATE ============ */
let S = null; // active save
const SLOT_KEY='oraveth_save_v1';
function freshState(){
return {
zone:0, px:2, py:7, facing:'down',
party:[], box:[],
inv:{glimlamp:10, mendsap:5, repelmote:1},
seen:{}, collected:{}, // collected species counts
credits:300,
flags:{tutorialMove:false,tutorialBattle:false,tutorialCapture:false,tutorialShrine:false,
anchors:{}, zonesUnlocked:[true,false,false,false,false,false],
lastShrine:0, storyStage:0, won:false, postgame:false},
repelSteps:0,
muted:false,
stepCount:0,
};
}
function saveGame(){
try{ localStorage.setItem(SLOT_KEY, JSON.stringify(S)); toast('Game bound to the shrine.','good'); }
catch(e){ toast('Could not save.','bad'); }
}
function loadGame(){
try{ const d=localStorage.getItem(SLOT_KEY); if(!d) return null; return JSON.parse(d); }
catch(e){ return null; }
}
function hasSave(){ try{ return !!localStorage.getItem(SLOT_KEY); }catch(e){ return false; } }
function addItem(id,n=1){ S.inv[id]=(S.inv[id]||0)+n; }
function hasItem(id,n=1){ return (S.inv[id]||0)>=n; }
function useItem(id,n=1){ if(!hasItem(id,n)) return false; S.inv[id]-=n; if(S.inv[id]<=0) delete S.inv[id]; return true; }
function uniqueSpeciesCount(){
const set={}; S.party.forEach(c=>set[c.species]=1); S.box.forEach(c=>set[c.species]=1); return Object.keys(set).length;
}
function anchorsDefeated(){ return ZONES.filter((z,i)=>z.anchor && S.flags.anchors && S.flags.anchors[i]).length; }
/* ============ AUDIO (Web Audio) ============ */
const Audio = (function(){
let ctx=null, master=null, muted=false, padNodes=[], padGain=null, curZone=-1;
function init(){
if(ctx) return;
try{
ctx = new (window.AudioContext||window.webkitAudioContext)();
master = ctx.createGain(); master.gain.value=0.5; master.connect(ctx.destination);
}catch(e){ ctx=null; }
}
function tone(freq,dur=0.12,type='sine',gain=0.18,slideTo=null){
if(!ctx||muted) return;
const o=ctx.createOscillator(), g=ctx.createGain();
o.type=type; o.frequency.value=freq;
if(slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(20,slideTo), ctx.currentTime+dur);
g.gain.value=0; g.gain.linearRampToValueAtTime(gain, ctx.currentTime+0.01);
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime+dur);
o.connect(g); g.connect(master); o.start(); o.stop(ctx.currentTime+dur+0.02);
}
function chord(freqs,dur=0.3,type='triangle',gain=0.12){ freqs.forEach(f=>tone(f,dur,type,gain)); }
function noise(dur=0.2,gain=0.08){
if(!ctx||muted) return;
const b=ctx.createBuffer(1, ctx.sampleRate*dur, ctx.sampleRate);
const d=b.getChannelData(0); for(let i=0;i<d.length;i++) d[i]=(Math.random()*2-1)*(1-i/d.length);
const s=ctx.createBufferSource(); s.buffer=b;
const g=ctx.createGain(); g.gain.value=gain;
const f=ctx.createBiquadFilter(); f.type='lowpass'; f.frequency.value=1200;
s.connect(f); f.connect(g); g.connect(master); s.start();
}
const ZONE_PAD = [
[196,261.6,329.6],[174.6,220,261.6],[146.8,196,233],[130.8,164.8,207.6],[110,146.8,174.6],[98,130.8,155.6]
];
function setPad(zone){
if(!ctx) return;
curZone=zone;
stopPad();
if(zone<0 || muted) return;
const freqs=ZONE_PAD[zone]||ZONE_PAD[0];
padGain=ctx.createGain(); padGain.gain.value=0; padGain.gain.linearRampToValueAtTime(0.05,ctx.currentTime+2);
const filt=ctx.createBiquadFilter(); filt.type='lowpass'; filt.frequency.value=700;
padGain.connect(filt); filt.connect(master);
freqs.forEach((f,i)=>{
const o=ctx.createOscillator(); o.type='sine'; o.frequency.value=f;
const lfo=ctx.createOscillator(); const lg=ctx.createGain();
lfo.frequency.value=0.07+i*0.03; lg.gain.value=2+i; lfo.connect(lg); lg.connect(o.frequency);
const og=ctx.createGain(); og.gain.value=0.5; o.connect(og); og.connect(padGain);
o.start(); lfo.start(); padNodes.push(o,lfo);
});
}
function stopPad(){ padNodes.forEach(n=>{try{n.stop();}catch(e){}}); padNodes=[]; if(padGain){try{padGain.disconnect();}catch(e){}} padGain=null; }
function setMuted(m){ muted=m; if(m) stopPad(); else if(curZone>=0) setPad(curZone); }
function isMuted(){ return muted; }
// SFX helpers
const sfx = {
step:()=>tone(180,0.05,'square',0.04),
encounter:()=>{ chord([330,440,554],0.5,'sawtooth',0.1); },
select:()=>tone(520,0.06,'square',0.07),
confirm:()=>{ tone(660,0.08,'square',0.08); setTimeout(()=>tone(880,0.1,'square',0.08),60); },
back:()=>tone(300,0.07,'square',0.06),
hit:()=>{ noise(0.12,0.12); tone(120,0.12,'sawtooth',0.1,60); },
crit:()=>{ noise(0.18,0.18); chord([220,330],0.18,'square',0.12); },
superEff:()=>{ chord([523,659,784],0.25,'square',0.1); noise(0.15,0.1); },
heal:()=>{ tone(523,0.1,'sine',0.1); setTimeout(()=>tone(784,0.14,'sine',0.1),90); },
capture:()=>{ [440,554,659,880].forEach((f,i)=>setTimeout(()=>tone(f,0.18,'triangle',0.1),i*120)); },
victory:()=>{ [523,659,784,1046].forEach((f,i)=>setTimeout(()=>tone(f,0.3,'triangle',0.12),i*140)); },
faint:()=>tone(220,0.5,'sawtooth',0.12,60),
levelup:()=>{ [523,659,784,1046].forEach((f,i)=>setTimeout(()=>tone(f,0.12,'square',0.09),i*70)); },
boss:()=>{ tone(70,0.8,'sawtooth',0.18); chord([110,146],0.8,'square',0.08); },
};
return { init, sfx, setPad, setMuted, isMuted, tone };
})();
/* ============ CANVAS / DPI ============ */
const cv = $('#cv'), ctx = cv.getContext('2d');
const bcv = $('#battle-cv'), bctx = bcv.getContext('2d');
const tbg = $('#title-bg'), tbgx = tbg.getContext('2d');
let DPR = Math.min(window.devicePixelRatio||1, 2);
function fitCanvas(c){
const r=c.getBoundingClientRect();
c.width=Math.max(1,Math.floor(r.width*DPR));
c.height=Math.max(1,Math.floor(r.height*DPR));
}
function fitAll(){ DPR=Math.min(window.devicePixelRatio||1,2); fitCanvas(cv); fitCanvas(bcv); fitCanvas(tbg); }
window.addEventListener('resize', ()=>{ fitAll(); });
/* ============ PARTICLES (overworld ambient) ============ */
let particles=[];
function initParticles(zone){
particles=[];
const kind=ZONES[zone].palette.particle;
const n = kind==='void'?60: kind==='ember'?70 : 55;
for(let i=0;i<n;i++) particles.push(makeParticle(kind));
}
function makeParticle(kind){
const w=cv.clientWidth, h=cv.clientHeight;
const p={x:Math.random()*w,y:Math.random()*h,r:rnd(1,3.5),vx:rnd(-0.2,0.2),vy:rnd(0.1,0.6),a:rnd(0.2,0.9),life:rnd(0,1),kind};
if(kind==='ember'){p.vy=rnd(-0.8,-0.3);p.r=rnd(1.5,3);}
if(kind==='bubble'){p.vy=rnd(-0.6,-0.2);p.r=rnd(2,5);}
if(kind==='petal'){p.vx=rnd(0.1,0.5);p.vy=rnd(0.2,0.6);p.spin=rnd(-0.05,0.05);p.rot=Math.random()*6;}
if(kind==='void'){p.vx=rnd(-0.1,0.1);p.vy=rnd(-0.1,0.1);p.r=rnd(0.5,2.5);}
if(kind==='shard'){p.vy=rnd(0.05,0.3);p.r=rnd(1,2.5);p.rot=Math.random()*6;p.spin=rnd(-0.03,0.03);}
if(kind==='dust'){p.vx=rnd(-0.3,0.3);p.vy=rnd(0.05,0.3);}
return p;
}
function drawParticles(dt){
const w=cv.clientWidth,h=cv.clientHeight;
const pal=ZONES[S?S.zone:0].palette;
particles.forEach(p=>{
p.x+=p.vx*dt*60; p.y+=p.vy*dt*60; p.life+=dt*0.3;
if(p.rot!=null)p.rot+=p.spin*dt*60;
if(p.y>h+10||p.y<-20||p.x<-20||p.x>w+20) Object.assign(p, makeParticle(p.kind));
const col = pal.particle==='ember'?'255,150,60':pal.particle==='void'?'200,120,255':pal.particle==='bubble'?'120,200,255':pal.particle==='shard'?'160,230,255':pal.particle==='dust'?'180,150,230':'180,255,150';
ctx.save();
ctx.globalAlpha=clamp(p.a*(0.6+0.4*Math.sin(p.life*3)),0,1);
ctx.fillStyle='rgba('+col+',1)';
if(p.kind==='petal'){
ctx.translate(p.x,p.y); ctx.rotate(p.rot); ctx.fillRect(-p.r,-p.r*0.6,p.r*2,p.r*1.2);
}else if(p.kind==='shard'){
ctx.translate(p.x,p.y); ctx.rotate(p.rot);
ctx.beginPath(); ctx.moveTo(0,-p.r); ctx.lineTo(p.r,p.r); ctx.lineTo(-p.r,p.r); ctx.closePath(); ctx.fill();
}else{
ctx.shadowBlur=8; ctx.shadowColor='rgba('+col+',0.8)';
ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,7); ctx.fill();
}
ctx.restore();
});
}
/* ============ CREATURE ART (procedural) ============ */
// draw a creature centered at (x,y), scale ~1, facing 1(right)/-1(left), t for bob
function drawCreature(c, x, y, scale, facing, t, dim){
const def = CREATURES[c.species]||CREATURES.glintfox;
const hue=def.hue, body=def.body;
scale = scale||1; facing=facing||1; t=t||0;
const bob = Math.sin(t*3 + (c.uid?c.uid.length:0))*4*scale;
ctx.save(); // NOTE: uses main ctx (overworld). Battle uses bctx via drawCreatureOn(ctx,...)
_drawBody(ctx, def, x, y+bob, scale, facing, t, dim, c);
ctx.restore();
}
function drawCreatureOn(g, species, x, y, scale, facing, t, dim, hpFrac){
const def = CREATURES[species]||CREATURES.glintfox;
const bob = Math.sin(t*3)*5*scale;
g.save();
// shadow
g.fillStyle='rgba(0,0,0,0.3)'; g.beginPath(); g.ellipse(x, y+38*scale, 30*scale, 8*scale,0,0,7); g.fill();
// aura when low hp flash
if(hpFrac!=null){
g.save(); g.globalAlpha=0.25+0.2*Math.sin(t*8); g.fillStyle=hpFrac<0.3?'#ff5a6e':'#7fe3ff';
g.beginPath(); g.ellipse(x,y,46*scale,46*scale,0,0,7); g.fill(); g.restore();
}
_drawBody(g, def, x, y+bob, scale, facing, t, dim, null);
g.restore();
}
function _drawBody(g, def, x, y, scale, facing, t, dim, c){
const hue=def.hue, body=def.body;
const dimA = dim?0.4:1;
function h(a){return 'hsla('+hue+',70%,'+a+'%,1)';}
function hc(a,s){return 'hsla('+hue+','+(s||70)+'%,'+a+'%,1)';}
g.save();
g.translate(x,y); g.scale(facing*scale, scale);
// type aura ring
g.save(); g.globalAlpha=0.18*dimA;
const ringCol = TYPE_COLORS[def.type];
g.fillStyle=ringCol; g.shadowBlur=18; g.shadowColor=ringCol;
g.beginPath(); g.ellipse(0,4,34,34,0,0,7); g.fill(); g.restore();
g.globalAlpha=dimA;
const main = hc(55), dark = hc(38), light = hc(72);
function eye(ex,ey,r){
g.fillStyle='#0a0e1a'; g.beginPath(); g.arc(ex,ey,r,0,7); g.fill();
g.fillStyle='#fff'; g.beginPath(); g.arc(ex+r*0.3,ey-r*0.3,r*0.4,0,7); g.fill();
}
if(body==='orb'){
const grad=g.createRadialGradient(-8,-8,4,0,0,30); grad.addColorStop(0,light); grad.addColorStop(1,dark);
g.fillStyle=grad; g.beginPath(); g.arc(0,0,28,0,7); g.fill();
g.fillStyle=main; for(let i=0;i<6;i++){const a=i/6*6.28; g.beginPath(); g.arc(Math.cos(a)*16,Math.sin(a)*16,3,0,7); g.fill();}
eye(-7,-2,5); eye(7,-2,5);
} else if(body==='beast'){
// body
const grad=g.createLinearGradient(0,-22,0,26); grad.addColorStop(0,light); grad.addColorStop(1,dark);
g.fillStyle=grad; g.beginPath(); g.ellipse(0,4,24,20,0,0,7); g.fill();
// head
g.beginPath(); g.ellipse(14,-8,13,11,0,0,7); g.fill();
// ears
g.fillStyle=dark; g.beginPath(); g.moveTo(8,-16); g.lineTo(6,-26); g.lineTo(14,-18); g.closePath(); g.fill();
g.beginPath(); g.moveTo(18,-18); g.lineTo(22,-27); g.lineTo(24,-16); g.closePath(); g.fill();
// tail
g.fillStyle=main; g.beginPath(); g.ellipse(-22,2,9,5,-0.5,0,7); g.fill();
eye(16,-9,4); eye(8,-9,3.5);
// legs
g.fillStyle=dark; g.fillRect(-12,18,6,9); g.fillRect(8,18,6,9);
} else if(body==='bird'){
const grad=g.createLinearGradient(0,-20,0,20); grad.addColorStop(0,light); grad.addColorStop(1,dark);
g.fillStyle=grad; g.beginPath(); g.ellipse(0,2,18,16,0,0,7); g.fill();
// wing
g.fillStyle=dark; const wf=Math.sin(t*6)*0.3;
g.save(); g.translate(-2,2); g.rotate(-0.4+wf); g.beginPath(); g.ellipse(0,0,18,7,0,0,7); g.fill(); g.restore();
// head
g.fillStyle=main; g.beginPath(); g.arc(14,-8,9,0,7); g.fill();
// beak
g.fillStyle='#ffce5a'; g.beginPath(); g.moveTo(20,-7); g.lineTo(28,-5); g.lineTo(20,-3); g.closePath(); g.fill();
eye(16,-10,3.5);
} else if(body==='fish'){
const grad=g.createLinearGradient(0,-14,0,14); grad.addColorStop(0,light); grad.addColorStop(1,dark);
g.fillStyle=grad; g.beginPath(); g.ellipse(0,0,22,13,0,0,7); g.fill();
// tail
g.fillStyle=main; g.beginPath(); g.moveTo(-18,0); g.lineTo(-32,-12); g.lineTo(-30,0); g.lineTo(-32,12); g.closePath(); g.fill();
// fin
g.fillStyle=dark; g.beginPath(); g.moveTo(0,-12); g.lineTo(6,-22); g.lineTo(12,-12); g.closePath(); g.fill();
eye(12,-2,4);
} else if(body==='sprite'){
const grad=g.createRadialGradient(0,0,4,0,0,22); grad.addColorStop(0,light); grad.addColorStop(1,dark);
g.fillStyle=grad; g.beginPath(); g.arc(0,0,18,0,7); g.fill();
// wings
g.globalAlpha=0.6*dimA; g.fillStyle=main;
const wf=Math.sin(t*8)*4;
g.beginPath(); g.ellipse(-14,-4,12,6+wf,-0.4,0,7); g.fill();
g.beginPath(); g.ellipse(14,-4,12,6+wf,0.4,0,7); g.fill();
g.globalAlpha=dimA;
eye(-6,-2,3.5); eye(6,-2,3.5);
} else if(body==='golem'){
const grad=g.createLinearGradient(0,-24,0,24); grad.addColorStop(0,light); grad.addColorStop(1,dark);
g.fillStyle=grad;
// body block
g.beginPath(); g.roundRect? g.roundRect(-20,-18,40,36,8): g.rect(-20,-18,40,36); g.fill();
// crystal nodes
g.fillStyle=light; g.shadowBlur=10; g.shadowColor=light;
[[-14,-22],[0,-26],[14,-22]].forEach(p=>{ g.beginPath(); g.moveTo(p[0],p[1]); g.lineTo(p[0]-5,p[1]+8); g.lineTo(p[0]+5,p[1]+8); g.closePath(); g.fill(); });
g.shadowBlur=0;
eye(-8,-4,4); eye(8,-4,4);
// arms
g.fillStyle=dark; g.fillRect(-28,-6,8,16); g.fillRect(20,-6,8,16);
} else { // wraith
const grad=g.createRadialGradient(0,-6,6,0,6,28); grad.addColorStop(0,light); grad.addColorStop(1,'hsla('+hue+',60%,20%,0.2)');
g.fillStyle=grad;
g.beginPath(); g.moveTo(-22,22);
g.bezierCurveTo(-26,-2,-18,-26,0,-26);
g.bezierCurveTo(18,-26,26,-2,22,22);
// tattered bottom
g.lineTo(16,14); g.lineTo(10,24); g.lineTo(4,14); g.lineTo(-2,24); g.lineTo(-10,14); g.lineTo(-16,24); g.closePath(); g.fill();
// crown (for hollowcrown/nullarch-ish)
g.fillStyle=light; g.beginPath(); g.moveTo(-10,-26); g.lineTo(-12,-34); g.lineTo(-6,-30); g.lineTo(0,-36); g.lineTo(6,-30); g.lineTo(12,-34); g.lineTo(10,-26); g.closePath(); g.fill();
// glowing eyes
g.shadowBlur=12; g.shadowColor=light; g.fillStyle=light;
g.beginPath(); g.arc(-7,-10,3,0,7); g.fill(); g.beginPath(); g.arc(7,-10,3,0,7); g.fill();
g.shadowBlur=0;
}
g.restore();
}
/* title bg stars */
let titleStars=[];
function initTitleStars(){ titleStars=[]; for(let i=0;i<120;i++) titleStars.push({x:Math.random(),y:Math.random(),r:rnd(0.4,1.8),tw:Math.random()*6,p:rnd(0.3,1)}); }
function drawTitleBG(t){
const w=tbg.clientWidth,h=tbg.clientHeight;
tbgx.clearRect(0,0,w,h);
// nebula
const grd=tbgx.createRadialGradient(w*0.5,h*0.35,20,w*0.5,h*0.5,Math.max(w,h));
grd.addColorStop(0,'rgba(90,120,220,0.18)'); grd.addColorStop(0.5,'rgba(140,80,200,0.08)'); grd.addColorStop(1,'rgba(0,0,0,0)');
tbgx.fillStyle=grd; tbgx.fillRect(0,0,w,h);
titleStars.forEach(s=>{
const a=0.4+0.6*Math.abs(Math.sin(t*0.001*s.p+s.tw));
tbgx.globalAlpha=a; tbgx.fillStyle='#cfe6ff';
tbgx.beginPath(); tbgx.arc(s.x*w,s.y*h,s.r,0,7); tbgx.fill();
});
tbgx.globalAlpha=1;
}
/* ============ OVERWORLD ============ */
const TILE={GROUND:0,WALL:1,GRASS:2,SHRINE:3,TRIGGER:4,EXIT:5,DECO:6,WATER:7};
let map=null, mapsCache={};
const MAP_W=20, MAP_H=14;
const ENC_RATE=[0.12,0.14,0.15,0.15,0.14,0.16];
const POST_ENC_BOOST=1.4;
function genMap(zoneIdx){
if(mapsCache[zoneIdx]) return mapsCache[zoneIdx];
const rng=mulberry32(zoneIdx*9173+13);
const t=[];
for(let y=0;y<MAP_H;y++){ t.push(new Array(MAP_W).fill(TILE.GROUND)); }
const z=ZONES[zoneIdx];
// border walls
for(let x=0;x<MAP_W;x++){t[0][x]=TILE.WALL;t[MAP_H-1][x]=TILE.WALL;}
for(let y=0;y<MAP_H;y++){t[y][0]=TILE.WALL;t[y][MAP_W-1]=TILE.WALL;}
// obstacle clusters (decoration + walls)
const nObst = 8+zoneIdx*2;
for(let i=0;i<nObst;i++){
const cx=rndi(2,MAP_W-3), cy=rndi(2,MAP_H-3);
const sz=rndi(1,3);
for(let dy=-sz;dy<=sz;dy++)for(let dx=-sz;dx<=sz;dx++){
const x=cx+dx,y=cy+dy;
if(x>1&&x<MAP_W-2&&y>1&&y<MAP_H-2 && Math.random()<0.5) t[y][x]=Math.random()<0.3?TILE.WALL:TILE.DECO;
}
}
// water pools in mirefen-like zones
if(z.types.includes('Tide')||z.palette.particle==='bubble'){
for(let i=0;i<3;i++){const cx=rndi(3,MAP_W-4),cy=rndi(3,MAP_H-4);
for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){if(Math.random()<0.6)t[cy+dy][cx+dx]=TILE.WATER;}}
}
// grass clusters (encounter tiles)
const nGrass = 6+zoneIdx;
for(let i=0;i<nGrass;i++){
const cx=rndi(2,MAP_W-3), cy=rndi(2,MAP_H-3);
const sz=rndi(2,3);
for(let dy=-sz;dy<=sz;dy++)for(let dx=-sz;dx<=sz;dx++){
const x=cx+dx,y=cy+dy;
if(x>1&&x<MAP_W-2&&y>1&&y<MAP_H-2 && t[y][x]===TILE.GROUND && Math.random()<0.7) t[y][x]=TILE.GRASS;
}
}
// ensure spawn area clear
const sx=2, sy=MAP_H-3;
for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){ if(sx+dx>0&&sx+dx<MAP_W-1&&sy+dy>0&&sy+dy<MAP_H-1) t[sy+dy][sx+dx]=TILE.GROUND; }
t[sy][1]=TILE.SHRINE; // shrine beside spawn
// carve a guaranteed path from spawn toward exit (clear a corridor to avoid softlock)
// exit at top-right
const ex=MAP_W-2, ey=1;
// guardian trigger near middle
const gx=Math.floor(MAP_W/2), gy=Math.floor(MAP_H/2);
// clear an L-path: from spawn up the left then across — but keep some grass. Just ensure trigger & exit reachable by clearing a thin path.
function clearPath(x0,y0,x1,y1){
let x=x0,y=y0;
while(x!==x1){ x+=Math.sign(x1-x); if(t[y][x]===TILE.WALL||t[y][x]===TILE.DECO||t[y][x]===TILE.WATER) t[y][x]=TILE.GROUND; }
while(y!==y1){ y+=Math.sign(y1-y); if(t[y][x]===TILE.WALL||t[y][x]===TILE.DECO||t[y][x]===TILE.WATER) t[y][x]=TILE.GROUND; }
}
clearPath(sx,sy,gx,gy);
clearPath(gx,gy,ex,ey);
// make sure grass still on path sometimes (re-seed a few grass on path for encounters) — leave as ground is fine
t[gy][gx]=TILE.TRIGGER;
t[ey][ex]=TILE.EXIT;
const m={tiles:t,w:MAP_W,h:MAP_H,shrine:{x:1,y:sy},guardian:{x:gx,y:gy},exit:{x:ex,y:ey},spawn:{x:sx,y:sy}};
mapsCache[zoneIdx]=m;
return m;
}
function enterZone(zoneIdx, fromExit){
S.zone=zoneIdx;
map=genMap(zoneIdx);
if(fromExit){ S.px=map.spawn.x; S.py=map.spawn.y; }
S.facing='down';
playerRender={x:S.px,y:S.py};
// apply palette to CSS
applyZonePalette(zoneIdx);
initParticles(zoneIdx);
Audio.setPad(zoneIdx);
updateHUD();
$('#zone-name').textContent=ZONES[zoneIdx].name;
$('#zone-sub').textContent=ZONES[zoneIdx].sub;
}
function applyZonePalette(zoneIdx){
const p=ZONES[zoneIdx].palette;
const root=document.documentElement.style;
root.setProperty('--bg1',p.bg1); root.setProperty('--bg2',p.bg2);
root.setProperty('--accent',p.accent); root.setProperty('--accent2',p.accent2);
document.body.style.background='radial-gradient(ellipse at 50% 20%,'+p.bg1+' 0%,'+p.bg2+' 75%)';
}
let playerRender={x:2,y:7};
let moving=false, moveFrom={x:0,y:0}, moveTo={x:0,y:0}, moveT=0;
const MOVE_DUR=150;
function canWalk(x,y){
if(x<0||y<0||x>=map.w||y>=map.h) return false;
const t=map.tiles[y][x];
if(t===TILE.WALL||t===TILE.DECO||t===TILE.WATER) return false;
// guardian trigger tile blocks unless defeated
if(t===TILE.TRIGGER && S.flags.anchors && S.flags.anchors[S.zone]) return true; // can walk over after defeat
return true;
}
function tryMove(dx,dy){
if(moving || Screen.state!=='overworld') return;
if(Dialogue.busy || Overlay.busy) return;
S.facing = dy<0?'up':dy>0?'down':dx<0?'left':'right';
const nx=S.px+dx, ny=S.py+dy;
if(!canWalk(nx,ny)){ bumpFeedback(); return; }
moving=true; moveFrom={x:S.px,y:S.py}; moveTo={x:nx,y:ny}; moveT=0;
Audio.sfx.step();
}
function bumpFeedback(){ playerRender.bump=0.18; }
function arriveTile(){
S.px=moveTo.x; S.py=moveTo.y; moving=false;
S.stepCount++;
if(S.repelSteps>0){ S.repelSteps--; }
const tile=map.tiles[S.py][S.px];
if(tile===TILE.SHRINE){ openShrine(); return; }
if(tile===TILE.EXIT){ tryExit(); return; }
if(tile===TILE.TRIGGER && !(S.flags.anchors[S.zone])){
startBoss(); return;
}
// encounter
if(tile===TILE.GRASS && S.repelSteps<=0 && S.party.some(c=>c.hp>0)){
if(Math.random() < ENC_RATE[S.zone]*(S.flags.postgame?POST_ENC_BOOST:1)){
triggerEncounter();
return;
}
}
// tutorial hints
if(!S.flags.tutorialMove){ S.flags.tutorialMove=true; }
}
function triggerEncounter(){
const z=ZONES[S.zone];
const enemies=[];
const spawnOne=()=>{
const total=z.encounters.reduce((s,e)=>s+e[1],0);
let r=Math.random()*total, pick=null;
for(const e of z.encounters){ r-=e[1]; if(r<=0){pick=e;break;} }
if(!pick) pick=z.encounters[0];
const lv=rndi(pick[2],pick[3]) + (S.flags.postgame?6:0);
return makeCreature(pick[0], lv);
};
enemies.push(spawnOne());
// chance of a second foe in deeper zones / postgame
if((S.zone>=4 || S.flags.postgame) && Math.random()<0.22){ const e2=spawnOne(); if(e2.level>1)e2.level=Math.max(2,e2.level-2); recomputeStats(e2); e2.hp=e2.maxhp; enemies.push(e2); }
Audio.sfx.encounter();
transitionFlash('#000', 280, ()=>startBattle(enemies, false));
}
function startBoss(){
const z=ZONES[S.zone];
const bossLv=z.guardianLv + (S.flags.postgame?6:0);
// if transformed form exists & high zone, sometimes use transformed as boss flair
const boss=makeCreature(z.guardian, bossLv);
boss.isBoss=true;
Audio.sfx.boss();
dialogue(z.anchor? z.anchorName+' stirs.' : 'A Guardian bars the way!', z.anchor? z.anchorName : 'Wild Guardian', ()=>{
transitionFlash(z.palette.accent2, 360, ()=>startBattle([boss], false, {boss:true}));
});
}
function tryExit(){
const z=ZONES[S.zone];
const unlocked = !!S.flags.anchors[S.zone];
if(!unlocked){
toast('The way is sealed until the guardian falls.','bad'); return;
}
if(S.zone < ZONES.length-1){
S.flags.zonesUnlocked[S.zone+1]=true;
dialogue('You step through the rift into '+ZONES[S.zone+1].name+'.','The Veil', ()=>{
enterZone(S.zone+1, true);
showStoryIfNeeded();
});
} else {
toast('The edge of Oraveth. Beyond lies only the deep void.','good');
}
}
/* ============ OVERWORLD RENDER ============ */
let lastT=performance.now();
function overworldLoop(now){
const dt=Math.min(0.05,(now-lastT)/1000); lastT=now;
if(Screen.state==='overworld' || Screen.state==='title'){
renderOverworld(now/1000, dt);
}
requestAnimationFrame(overworldLoop);
}
function tileSizeCalc(){
const w=cv.clientWidth, h=cv.clientHeight;
const pad=14;
return Math.floor(Math.min((w-pad*2)/map.w, (h-pad*2-60)/map.h));
}
function renderOverworld(t, dt){
if(Screen.state==='title'){ drawTitleBG(t*1000); return; }
if(!map) return;
if(moving){
moveT+=dt*1000;
if(moveT>=MOVE_DUR){ arriveTile(); }
else {
const k=moveT/MOVE_DUR;
playerRender.x=lerp(moveFrom.x,moveTo.x,k);
playerRender.y=lerp(moveFrom.y,moveTo.y,k);
}
}
if(playerRender.bump){ playerRender.bump-=dt; if(playerRender.bump<0)playerRender.bump=0; }
const w=cv.clientWidth,h=cv.clientHeight;
const ts=tileSizeCalc();
const offX=(w-ts*map.w)/2, offY=(h-ts*map.h)/2 - 10;
ctx.clearRect(0,0,w,h);
// soft grid floor
drawParticles(dt);
const pal=ZONES[S.zone].palette;
// tiles
for(let y=0;y<map.h;y++)for(let x=0;x<map.w;x++){
drawTile(x,y,offX,offY,ts,t,pal);
}
// guardian figure on trigger
const g=map.guardian;
if(!S.flags.anchors[S.zone]){
const cx=offX+g.x*ts+ts/2, cy=offY+g.y*ts+ts/2;
_drawBody(ctx, CREATURES[ZONES[S.zone].guardian], cx, cy+Math.sin(t*2)*4, ts/46, 1, t, false, null);
// exclamation
ctx.fillStyle=ZONES[S.zone].anchor?'#ff6bd0':'#ffd24d'; ctx.font='bold '+(ts*0.5)+'px sans-serif'; ctx.textAlign='center';
ctx.shadowBlur=10;ctx.shadowColor=ctx.fillStyle;
ctx.fillText('!', cx, cy-ts*0.7);
ctx.shadowBlur=0;
}
// exit portal
drawExit(offX,offY,ts,t,pal);
// player
drawPlayer(offX,offY,ts,t);
// minimap hint handled by HUD
}
function drawTile(x,y,offX,offY,ts,t,pal){
const px=offX+x*ts, py=offY+y*ts;
const tile=map.tiles[y][x];
const pad=2;
ctx.save();
if(tile===TILE.WALL){
ctx.fillStyle='rgba(0,0,0,0.35)'; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.fill();
ctx.strokeStyle='rgba(150,190,255,0.12)'; ctx.lineWidth=1; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.stroke();
} else if(tile===TILE.DECO){
ctx.fillStyle='rgba(0,0,0,0.25)'; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.fill();
// a little glow crystal/rock
ctx.fillStyle=pal.accent; ctx.globalAlpha=0.5;
ctx.beginPath(); ctx.arc(px+ts/2,py+ts/2,ts*0.16,0,7); ctx.fill();
ctx.globalAlpha=1;
} else if(tile===TILE.WATER){
const g=ctx.createLinearGradient(px,py,px,py+ts);
g.addColorStop(0,'rgba(80,150,200,0.4)'); g.addColorStop(1,'rgba(40,90,140,0.5)');
ctx.fillStyle=g; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.fill();
ctx.strokeStyle='rgba(180,230,255,0.2)'; ctx.lineWidth=1;
ctx.beginPath(); const wy=Math.sin(t*2+x)*2; ctx.moveTo(px+ts*0.2,py+ts*0.4+wy); ctx.lineTo(px+ts*0.8,py+ts*0.4+wy); ctx.stroke();
} else if(tile===TILE.GRASS){
ctx.fillStyle='rgba(20,40,30,0.5)'; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.fill();
// grass blades
ctx.strokeStyle=pal.accent; ctx.globalAlpha=0.7; ctx.lineWidth=2;
for(let i=0;i<4;i++){const bx=px+ts*(0.25+i*0.18); const sw=Math.sin(t*2+x+i)*2;
ctx.beginPath(); ctx.moveTo(bx,py+ts*0.8); ctx.lineTo(bx+sw,py+ts*0.45); ctx.stroke();}
ctx.globalAlpha=1;
} else if(tile===TILE.SHRINE){
ctx.fillStyle='rgba(0,0,0,0.3)'; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.fill();
// glowing shrine pedestal
const pulse=0.5+0.5*Math.sin(t*3);
ctx.save(); ctx.translate(px+ts/2,py+ts/2);
ctx.shadowBlur=16+pulse*8; ctx.shadowColor=pal.accent2;
ctx.fillStyle=pal.accent2; ctx.globalAlpha=0.8;
ctx.beginPath(); ctx.moveTo(0,-ts*0.3); ctx.lineTo(ts*0.22,0); ctx.lineTo(0,ts*0.3); ctx.lineTo(-ts*0.22,0); ctx.closePath(); ctx.fill();
ctx.globalAlpha=1; ctx.shadowBlur=0;
ctx.restore();
} else if(tile===TILE.TRIGGER){
ctx.fillStyle='rgba(40,10,30,0.5)'; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.fill();
if(S.flags.anchors[S.zone]){ // cleared
ctx.strokeStyle='rgba(125,255,176,0.3)'; ctx.lineWidth=1; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.stroke();
}
} else if(tile===TILE.EXIT){
// drawn by drawExit
} else { // ground
ctx.fillStyle='rgba(255,255,255,0.025)'; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.fill();
ctx.strokeStyle='rgba(150,190,255,0.06)'; ctx.lineWidth=1; roundRect(px+pad,py+pad,ts-pad*2,ts-pad*2,5); ctx.stroke();
}
ctx.restore();
}
function drawExit(offX,offY,ts,t,pal){
const e=map.exit; const px=offX+e.x*ts+ts/2, py=offY+e.y*ts+ts/2;
const unlocked = S.flags.anchors[S.zone];
ctx.save(); ctx.translate(px,py);
const col=unlocked?pal.accent:'#ff5a6e';
for(let i=0;i<3;i++){
const r=ts*0.16+i*ts*0.12+Math.sin(t*3+i)*3;
ctx.globalAlpha=0.6-i*0.15; ctx.strokeStyle=col; ctx.lineWidth=3;
ctx.beginPath(); ctx.arc(0,0,r,0,7); ctx.stroke();
}
ctx.globalAlpha=0.9; ctx.fillStyle=col; ctx.shadowBlur=14; ctx.shadowColor=col;
ctx.beginPath(); ctx.arc(0,0,ts*0.1,0,7); ctx.fill();
ctx.restore();
}
function drawPlayer(offX,offY,ts,t){
const px=offX+playerRender.x*ts+ts/2, py=offY+playerRender.y*ts+ts/2;
const bump=playerRender.bump?Math.sin(playerRender.bump*40)*3:0;
ctx.save(); ctx.translate(px+bump,py);
// shadow
ctx.fillStyle='rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.ellipse(0,ts*0.32,ts*0.22,ts*0.07,0,0,7); ctx.fill();
// body (a robed traveler)
const bob=Math.sin(t*5)*2;
ctx.shadowBlur=14; ctx.shadowColor=$('#zone-name')?getCss('--accent'):'#7fe3ff';
const ac=getCss('--accent');
// cloak
ctx.fillStyle=ac; ctx.globalAlpha=0.95;
ctx.beginPath(); ctx.moveTo(0,-ts*0.3+bob);
ctx.bezierCurveTo(ts*0.22,-ts*0.2+bob, ts*0.2,ts*0.2, 0,ts*0.28);
ctx.bezierCurveTo(-ts*0.2,ts*0.2, -ts*0.22,-ts*0.2+bob, 0,-ts*0.3+bob); ctx.closePath(); ctx.fill();
ctx.shadowBlur=0;
// head/hood glow
ctx.fillStyle='#eaf2ff'; ctx.beginPath(); ctx.arc(0,-ts*0.26+bob,ts*0.1,0,7); ctx.fill();
// facing eye
ctx.fillStyle=ac; ctx.shadowBlur=8; ctx.shadowColor=ac;
ctx.beginPath(); ctx.arc(0,-ts*0.26+bob,ts*0.05,0,7); ctx.fill();
ctx.restore();
}
function getCss(v){return getComputedStyle(document.documentElement).getPropertyValue(v).trim();}
function roundRect(x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();}
/* ============ BATTLE SYSTEM ============ */
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
const Battle={
enemies:[], active:null, activeIdx:0, targetIdx:0,
busy:false, ended:false, isWild:true, isBoss:false, result:null,
particles:[],
// animation offsets per uid
fx:{},
roundParticipantUids:new Set(),
};
function startBattle(enemyArr, isWild, opts={}){
Battle.enemies = enemyArr.map(e=>{e.boosts={atk:0,def:0,spd:0};e.barrier=0;e.status=null;return e;});
Battle.isWild=isWild; Battle.isBoss=!!opts.boss; Battle.ended=false; Battle.result=null; Battle.busy=false;
Battle.particles=[]; Battle.fx={};
// pick first alive party member as active
Battle.activeIdx = S.party.findIndex(c=>c.hp>0);
Battle.active = S.party[Battle.activeIdx];
Battle.active.boosts={atk:0,def:0,spd:0}; Battle.active.barrier=0; Battle.active.status=Battle.active.status||null;
Battle.targetIdx = Battle.enemies.findIndex(e=>e.hp>0);
Battle.roundParticipantUids=new Set([Battle.active.uid]);
Screen.show('battle');
$('#fighter-bars').innerHTML='';
renderBattleBars();
$('#battle-ui').innerHTML='';
Audio.sfx.select();
if(!S.flags.tutorialBattle){
S.flags.tutorialBattle=true;
dialogue('A wild Lumari appears! Choose FIGHT, then an ability. Lower its HP, then use a Glim Lamp to bind it.','First Encounter',()=>showActionMenu());
} else {
showActionMenu();
}
}
function renderBattleBars(){
const fb=$('#fighter-bars'); fb.innerHTML='';
// enemies (right side)
const er=el('div','flex-row'); er.style.justifyContent='flex-end';
Battle.enemies.forEach((e,i)=>{
const c=fbCard(e,true);
c.dataset.idx=i; c.style.cursor='pointer';
c.addEventListener('click',()=>{ if(e.hp>0 && !Battle.busy){ Battle.targetIdx=i; toast('Target: '+CREATURES[e.species].name); } });
er.appendChild(c);
});
fb.appendChild(er);
// player (left)
const pr=el('div','flex-row'); pr.style.justifyContent='flex-start';
pr.appendChild(fbCard(Battle.active,false));
fb.appendChild(pr);
}
function fbCard(c, isEnemy){
const def=CREATURES[c.species];
const card=el('div','fb');
const hpFrac=c.hp/c.maxhp;
const hpCls=hpFrac>0.5?'':(hpFrac>0.25?'mid':'low');
let chips='';
if(c.status){ const si=STATUS_INFO[c.status.type]; if(si) chips+='<span class="chip" style="background:'+si.color+';color:#0a0e1a">'+si.label+'</span>'; }
if(c.barrier>0) chips+='<span class="chip" style="background:#9fd9ff;color:#0a0e1a">Ward</span>';
card.innerHTML='<div class="fb-name"><span>'+def.name+(isEnemy?'':' <span class="type-tag" style="background:'+TYPE_COLORS[def.type]+';color:#0a0e1a">'+def.type+'</span>')+'</span><span class="fb-level">Lv'+c.level+'</span></div>'
+'<div class="hp-shell"><div class="hp-fill '+hpCls+'" style="width:'+(hpFrac*100)+'%"></div></div>'
+'<div class="hp-txt">'+Math.max(0,Math.ceil(c.hp))+'/'+c.maxhp+'</div>'
+'<div class="status-chips">'+chips+'</div>';
return card;
}
function refreshBars(){ renderBattleBars(); }
const STATUS_INFO={
burn:{label:'Burn',color:'#ff8a3d'},
frost:{label:'Frost',color:'#7fd9ff'},
shock:{label:'Shock',color:'#ffd24d'},
regen:{label:'Regen',color:'#7dffb0'},
};
/* effective combat stats */
function bAtk(c){ let v=effStat(c.atk,c.boosts.atk); if(c.status&&c.status.type==='burn')v*=0.5; return v; }
function bDef(c){ return effStat(c.def,c.boosts.def); }
function bSpd(c){ let v=effStat(c.spd,c.boosts.spd); if(c.status&&c.status.type==='frost')v*=0.5; if(c.status&&c.status.type==='shock')v*=0.7; return v; }
function orderKey(act){
let pr=0;
if(act.kind==='move'&&act.move&&ABILITIES[act.move].eff&&ABILITIES[act.move].eff.priority) pr=1000;
if(act.kind==='switch'||act.kind==='item'||act.kind==='capture') pr=2000; // tactical actions first-ish? keep switch normal
if(act.kind==='switch'||act.kind==='item'||act.kind==='capture') pr=500;
const spd = act.by==='player'? bSpd(Battle.active) : bSpd(act.who);
return pr+spd;
}
/* ---- ACTION MENU ---- */
function showActionMenu(){
if(Battle.ended) return;
Battle.busy=false;
const ui=$('#battle-ui');
const canFight=Battle.active.hp>0;
ui.innerHTML='';
const wrap=el('div');
wrap.innerHTML='<div class="menu-title">'+CREATURES[Battle.active.species].name+' — Lv'+Battle.active.level+'</div>';
const grid=el('div','flex-row');
const bFight=btn('Fight','btn primary',()=>showMoveMenu());
const bSwitch=btn('Switch','btn',()=>showSwitchMenu(true));
const bItem=btn('Bag','btn',()=>showBattleBag());
const bFlee=btn('Flee','btn',()=>tryFlee());
bFight.disabled=!canFight;
bSwitch.disabled = S.party.filter(c=>c.hp>0).length<2;
bFlee.disabled = Battle.isBoss;
grid.append(bFight,bSwitch,bItem,bFlee);
wrap.appendChild(grid);
ui.appendChild(wrap);
}
function btn(label,cls,fn){ const b=el('button',cls,label); b.addEventListener('click',()=>{Audio.sfx.select(); if(fn)fn();}); return b; }
function showMoveMenu(){
const ui=$('#battle-ui'); ui.innerHTML='';
const wrap=el('div');
wrap.innerHTML='<div class="menu-title">Choose an ability — tap a foe to target</div>';
const grid=el('div','#move-menu'); grid.id='move-menu';
Battle.active.abilities.forEach(ab=>{
const A=ABILITIES[ab];
const pp=Battle.active.pp[ab]||0;
const b=el('div','move-btn');
b.innerHTML='<div class="move-name"><span>'+A.name+'</span><span class="type-tag" style="background:'+(A.type===NEUTRAL?'#8aa0c8':TYPE_COLORS[A.type])+';color:#0a0e1a">'+A.type+'</span></div>'
+'<div class="move-meta"><span>PWR '+(A.pow||'—')+'</span><span>ACC '+A.acc+'</span><span>PP '+pp+'/'+A.pp+'</span></div>';
if(pp<=0) b.style.opacity=0.4;
b.addEventListener('click',()=>{ if(pp<=0){toast('No energy left for that ability.','bad');return;} Audio.sfx.select(); commitPlayerMove(ab); });
grid.appendChild(b);
});
const back=btn('Back','btn sm',()=>showActionMenu());
wrap.appendChild(grid); wrap.appendChild(el('div')).appendChild(back);
ui.appendChild(wrap);
}
function commitPlayerMove(ab){
const tgt = Battle.enemies[Battle.targetIdx] && Battle.enemies[Battle.targetIdx].hp>0 ? Battle.targetIdx : Battle.enemies.findIndex(e=>e.hp>0);
Battle.targetIdx = tgt<0?0:tgt;
resolveRound({by:'player', kind:'move', move:ab, target:Battle.targetIdx});
}
function showSwitchMenu(inBattle){
const alive=S.party.filter(c=>c.hp>0 && c!==Battle.active);
if(alive.length===0){ toast('No other Lumari can fight.','bad'); return; }
const ui=inBattle?$('#battle-ui'):null;
openPanel('Switch Lumari', (body)=>{
body.appendChild(teamListHTML(S.party, (c)=>{
if(c.hp<=0){ toast('That Lumari has fainted.','bad'); return; }
if(inBattle && c===Battle.active){ toast(c===Battle.active?'Already active.':''); return; }
if(inBattle){
closePanel();
Audio.sfx.confirm();
resolveRound({by:'player', kind:'switch', to:c});
}
}));
if(inBattle){ const back=btn('Back','btn sm',()=>{closePanel();showActionMenu();}); body.appendChild(back); }
else { const back=btn('Close','btn sm',()=>closePanel()); body.appendChild(back); }
});
}
function showBattleBag(){
openPanel('Bag', (body)=>{
const usable=['glimlamp','prismlamp','stellarlamp','mendsap','revivesap','panacea'];
let any=false;
usable.forEach(id=>{
const n=S.inv[id]||0; if(n<=0) return; any=true;
const it=ITEMS[id];
const row=el('div','shop-item');
row.innerHTML='<div class="si-l"><div class="si-n">'+it.name+' ×'+n+'</div><div class="si-d">'+it.desc+'</div></div>';
const b=btn('Use','btn sm',()=>{
if(it.kind==='capture'){
closePanel(); commitCapture(id);
} else {
useBattleItem(id, body);
}
});
row.appendChild(b); body.appendChild(row);
});
if(!any) body.appendChild(el('div','small center-txt','Your bag has no usable items here.'));
body.appendChild(el('div','sep'));
body.appendChild(btn('Back','btn sm',()=>{closePanel();showActionMenu();}));
});
}
function useBattleItem(id, body){
const it=ITEMS[id];
// need target party member selection for heal/revive
if(it.kind==='heal'||it.kind==='revive'||it.kind==='cure'){
body.innerHTML='<div class="menu-title">Use on which Lumari?</div>';
body.appendChild(teamListHTML(S.party,(c)=>{
const ok = it.kind==='revive'? c.hp<=0 : (c.hp>0 && (c.hp<c.maxhp || c.status));
if(!ok){ toast('No effect on that one.','bad'); return; }
applyHealItem(id,c); useItem(id); refreshBars(); closePanel();
resolveRound({by:'player', kind:'item'});
Audio.sfx.heal();
}));
body.appendChild(btn('Back','btn sm',()=>showBattleBag()));
}
}
function applyHealItem(id,c){
const it=ITEMS[id];
if(it.kind==='heal'){ c.hp=Math.min(c.maxhp,c.hp+it.heal); toast(CREATURES[c.species].name+' recovered '+it.heal+' HP.','good'); }
if(it.kind==='revive'){ c.hp=Math.floor(c.maxhp*0.5); toast(CREATURES[c.species].name+' stirs again!','good'); }
if(it.kind==='cure'){ c.status=null; toast(CREATURES[c.species].name+' is cleansed.','good'); }
}
function commitCapture(itemId){
const tgt=Battle.enemies[Battle.targetIdx];
if(!tgt||tgt.hp<=0){ toast('No target to bind.','bad'); showActionMenu(); return; }
resolveRound({by:'player', kind:'capture', item:itemId, target:Battle.targetIdx});
}
function tryFlee(){
if(Battle.isBoss){ toast('You cannot flee an Anchor!','bad'); return; }
const s=bSpd(Battle.active); let fastest=0; Battle.enemies.forEach(e=>{if(e.hp>0)fastest=Math.max(fastest,bSpd(e));});
if(Math.random() < clamp(0.5 + (s-fastest)/200,0.25,0.95)){
toast('You slipped away into the veil.','good'); Audio.sfx.back(); endBattle('flee');
} else {
toast('You couldn\'t escape!','bad'); resolveRound({by:'player', kind:'wait'});
}
}
/* ---- AI ---- */
function aiChoose(e){
const abl=e.abilities.filter(a=>(e.pp[a]||0)>0);
const pool=abl.length?abl:e.abilities;
// prefer super-effective if possible
const playerType=Battle.active.affinity;
let best=null,bestM=-1;
pool.forEach(a=>{
const A=ABILITIES[a]; if(A.pow<=0) return;
const m=typeMult(A.type, playerType)*stab(A.type,e.affinity);
if(m>bestM){bestM=m;best=a;}
});
if(best && Math.random()<0.7) return best;
return pick(pool);
}
/* ---- RESOLVE ROUND ---- */
async function resolveRound(playerAction){
if(Battle.ended) return;
Battle.busy=true;
$('#battle-ui').innerHTML='<div class="small center-txt" style="padding:14px">Resolving...</div>';
const actions=[];
Battle.enemies.forEach(e=>{ if(e.hp>0 && e!==playerAction && playerAction.kind!=='capture') {/*still acts*/} });
// enemies choose
Battle.enemies.forEach(e=>{ if(e.hp>0) actions.push({by:'enemy', who:e, kind:'move', move:aiChoose(e), target:'player'}); });
actions.push(playerAction);
// sort
actions.sort((a,b)=>orderKey(b)-orderKey(a));
for(const act of actions){
if(Battle.ended) break;
if(act.by==='enemy' && (act.who.hp<=0)) continue;
if(act.by==='player' && Battle.active.hp<=0){
await autoSwitchIfNeeded();
if(act.kind==='move') continue;
}
await doAction(act);
await sleep(180);
if(checkBattleEnd()) break;
}
if(!Battle.ended){
await endOfTurnStatus();
checkBattleEnd();
}
if(!Battle.ended){
if(Battle.active.hp<=0){ await autoSwitchIfNeeded(); }
refreshBars();
showActionMenu();
}
}
async function doAction(act){
if(act.kind==='wait') { toast(Battle.active?CREATURES[Battle.active.species].name+' braced.':''); return; }
if(act.kind==='switch'){
Battle.active.boosts={atk:0,def:0,spd:0}; Battle.active.barrier=0;
Battle.activeIdx=S.party.indexOf(act.to);
Battle.active=act.to;
Battle.active.boosts={atk:0,def:0,spd:0};
Battle.roundParticipantUids.add(Battle.active.uid);
floatTxtPlayer('Switch! '+CREATURES[Battle.active.species].name+'!','#7fe3ff');
Audio.sfx.confirm(); await sleep(500); refreshBars(); return;
}
if(act.kind==='capture'){ await doCapture(act); return; }
if(act.kind==='move'){
const attacker = act.by==='player'? Battle.active : act.who;
const target = act.by==='player'? Battle.enemies[act.target] : Battle.active;
if(!target || target.hp<=0){
// retarget
if(act.by==='player'){ const ni=Battle.enemies.findIndex(e=>e.hp>0); if(ni<0) return; act.target=ni; Battle.targetIdx=ni; return doAction(act); }
else return;
}
await performMove(attacker, target, act.move, act.by==='player');
}
}
async function performMove(attacker, target, abId, byPlayer){
const A=ABILITIES[abId];
if(attacker.pp) attacker.pp[abId]=Math.max(0,(attacker.pp[abId]||0)-1);
floatTxt(attacker, byPlayer, A.name+'!', '#cfe6ff');
await sleep(450);
// accuracy
const accRoll=Math.random()*100;
// shock skip
if(attacker.status && attacker.status.type==='shock' && Math.random()<0.3){
floatTxt(attacker, byPlayer, 'Shocked! Turn skipped.',''); Audio.sfx.back(); await sleep(500); return;
}
if(accRoll>A.acc){
floatTxt(target, !byPlayer, 'Miss!','#cfd9ee'); Audio.sfx.back(); await sleep(450); return;
}
// self-effect non-damaging
if(A.pow>0){
const hits = (A.eff&&A.eff.multi)? rndi(A.eff.multi[0],A.eff.multi[1]) : 1;
let total=0; let lastMult=1; let anyCrit=false;
for(let h=0;h<hits;h++){
if(target.hp<=0) break;
// barrier
if(target.barrier>0 && !(A.eff&&A.eff.pierce)){
target.barrier--; floatTxt(target,!byPlayer,'Ward deflects!','#9fd9ff'); Audio.sfx.back();
refreshBars(); await sleep(300); return;
}
const res=calcDamage(attacker,target,A);
target.hp=Math.max(0,target.hp-res.dmg);
total+=res.dmg; lastMult=res.tmult; if(res.crit)anyCrit=true;
animateHit(target, byPlayer, res);
refreshBars();
if(hits<hits-1) await sleep(180);
if(target.hp<=0) break;
await sleep(120);
}
floatTxt(target,!byPlayer, '-'+total+(hits>1?' (×'+hits+')':'') , anyCrit?'crit':'dmg');
// lifesteal
if(A.eff&&A.eff.lifesteal){ const heal=Math.floor(total*A.eff.lifesteal); attacker.hp=Math.min(attacker.maxhp,attacker.hp+heal); floatTxt(attacker,byPlayer,'+'+heal,'heal'); refreshBars(); }
if(target.hp<=0){ await faintAnim(target,!byPlayer); }
} else {
Audio.sfx.select();
}
// apply effects
applyEffects(attacker,target,A);
refreshBars();
await sleep(250);
}
function calcDamage(attacker,target,A){
let atk=bAtk(attacker), def=bDef(target);
const tm = A.type===NEUTRAL?1:typeMult(A.type,target.affinity);
const sb = stab(A.type,attacker.affinity);
let base = 0.4*attacker.level*(atk/Math.max(1,def));
let dmg = base*(A.pow*0.1);
const mult=tm*sb;
dmg*=mult;
let critChance = 1/16; if(A.eff&&A.eff.crit) critChance*=A.eff.crit;
const crit = Math.random()<critChance;
if(crit) dmg*=1.5;
dmg*=rnd(0.85,1.0);
dmg=Math.max(1,Math.floor(dmg+1));
return {dmg, mult:tm, crit};
}
function applyEffects(attacker,target,A){
const e=A.eff; if(!e) return;
if(e.burn && Math.random()<e.burn){ target.status={type:'burn',turns:4, dmg: e.burnDmg?Math.floor(target.maxhp*e.burnDmg):Math.floor(target.maxhp*0.08)}; floatTxt(target,target===Battle.active,'Burned!',''); }
if(e.frost && Math.random()<e.frost){ target.status={type:'frost',turns:4}; floatTxt(target,target===Battle.active,'Frosted!',''); }
if(e.shock && Math.random()<e.shock){ target.status={type:'shock',turns:3}; floatTxt(target,target===Battle.active,'Shocked!',''); }
if(e.regen){ attacker.status={type:'regen',turns:e.regen}; floatTxt(attacker,attacker===Battle.active,'Regenerating!',''); }
if(e.barrier){ attacker.barrier=(attacker.barrier||0)+e.barrier; }
if(e.atkUp){ attacker.boosts.atk=clamp(attacker.boosts.atk+e.atkUp,-6,6); floatTxt(attacker,attacker===Battle.active,'ATK rose!',''); }
if(e.defUp){ attacker.boosts.def=clamp(attacker.boosts.def+e.defUp,-6,6); floatTxt(attacker,attacker===Battle.active,'DEF rose!',''); }
if(e.spdUp){ attacker.boosts.spd=clamp(attacker.boosts.spd+e.spdUp,-6,6); floatTxt(attacker,attacker===Battle.active,'SPD rose!',''); }
if(e.atkDown){ target.boosts.atk=clamp(target.boosts.atk-e.atkDown,-6,6); floatTxt(target,target===Battle.active,'ATK fell!',''); }
if(e.defDown){ target.boosts.def=clamp(target.boosts.def-e.defDown,-6,6); floatTxt(target,target===Battle.active,'DEF fell!',''); }
if(e.spdDown){ target.boosts.spd=clamp(target.boosts.spd-e.spdDown,-6,6); floatTxt(target,target===Battle.active,'SPD fell!',''); }
if(e.heal){ const h=Math.floor(attacker.maxhp*e.heal); attacker.hp=Math.min(attacker.maxhp,attacker.hp+h); floatTxt(attacker,attacker===Battle.active,'+'+h,'heal'); }
if(e.drainpp){ const k=pick(target.abilities); if(target.pp)target.pp[k]=Math.max(0,(target.pp[k]||0)-2); floatTxt(target,target===Battle.active,'Focus drained!',''); }
}
async function endOfTurnStatus(){
const all=[Battle.active,...Battle.enemies];
for(const c of all){
if(c.hp<=0||!c.status) continue;
const s=c.status;
if(s.type==='burn'){ const d=s.dmg||Math.floor(c.maxhp*0.08); c.hp=Math.max(0,c.hp-d); floatTxt(c,c===Battle.active,'-'+d,'dmg'); refreshBars(); }
if(s.type==='regen'){ const h=Math.floor(c.maxhp*0.1); c.hp=Math.min(c.maxhp,c.hp+h); floatTxt(c,c===Battle.active,'+'+h,'heal'); refreshBars(); }
s.turns--; if(s.turns<=0){ floatTxt(c,c===Battle.active,CREATURES[c.species].name+' recovered.',''); c.status=null; refreshBars(); }
if(c.hp<=0){ await faintAnim(c,c===Battle.active); }
await sleep(120);
}
}
function animateHit(target, byPlayer, res){
Battle.fx[target.uid]={shake:1, flash:1};
Audio.sfx.hit();
if(res.mult>=1.5){ Audio.sfx.superEff(); burstParticles(target, getCss('--accent2')); }
if(res.crit){ flashScreen(); Audio.sfx.crit(); Battle.fx[target.uid].flash=2; }
if(res.mult>=1.5 || res.crit) shakeStage();
}
async function faintAnim(c, isPlayer){
Battle.fx[c.uid]={faint:1};
Audio.sfx.faint();
floatTxt(c,isPlayer,CREATURES[c.species].name+' fainted.','');
await sleep(700);
}
async function autoSwitchIfNeeded(){
if(Battle.active.hp>0) return;
if(!S.party.some(c=>c.hp>0)) return;
await sleep(300);
await forcedSwitchPanel();
if(Battle.active.hp<=0){ const n=S.party.find(c=>c.hp>0); if(n){Battle.activeIdx=S.party.indexOf(n);Battle.active=n;Battle.active.boosts={atk:0,def:0,spd:0};} }
refreshBars();
}
function forcedSwitchPanel(){
return new Promise(resolve=>{
const ov=el('div','modal-scrim'); const panel=el('div','panel'); panel.style.maxWidth='440px';
panel.innerHTML='<div class="panel-head"><div class="panel-title">Send Out a Lumari</div></div>';
const body=el('div','panel-body');
body.appendChild(el('div','small center-txt','Your Lumari has fainted. Choose another to continue.'));
S.party.filter(c=>c.hp>0).forEach(c=>{
const row=el('div','team-row'); row.style.cursor='pointer'; row.innerHTML=teamRowInner(c);
row.addEventListener('click',()=>{ Battle.activeIdx=S.party.indexOf(c); Battle.active=c; Battle.active.boosts={atk:0,def:0,spd:0}; Audio.sfx.confirm(); ov.remove(); syncOverlayBusy(); resolve(); });
body.appendChild(row);
});
panel.appendChild(body); ov.appendChild(panel); $('#overlay-root').appendChild(ov);
Overlay.busy=true;
});
}
function checkBattleEnd(){
if(Battle.ended) return true;
const enemiesAlive=Battle.enemies.some(e=>e.hp>0);
const partyAlive=S.party.some(c=>c.hp>0);
if(!enemiesAlive){ endBattle('win'); return true; }
if(!partyAlive){ endBattle('lose'); return true; }
return false;
}
function endBattle(result){
if(Battle.ended) return;
Battle.ended=true; Battle.result=result; Battle.busy=true;
closePanel();
setTimeout(()=>finishBattle(result), 500);
}
async function finishBattle(result){
if(result==='win'){
Audio.sfx.victory();
// exp
const expReport=[];
const totalExp = Battle.enemies.reduce((s,e)=>s+ (e.level*7+14+(e.isBoss?60:0)),0);
const gainers=S.party.filter(c=>c.hp>0 || Battle.roundParticipantUids.has(c.uid));
// give exp to all non-fainted + active participants
S.party.forEach(c=>{ if(c.hp>0){ const g=Math.floor(totalExp/ S.party.filter(x=>x.hp>0).length); if(g>0){ gainExp(c,g); expReport.push({c,g}); } } });
await sleep(600);
let summary='<div class="center-txt"><b style="font-size:18px">Victory!</b></div><div class="sep"></div>';
expReport.forEach(r=>{ summary+='<div class="small">'+CREATURES[r.c.species].name+' gained '+r.g+' resonance.</div>'; });
summary+='<div class="sep"></div>';
const gold = Battle.enemies.reduce((s,e)=>s+e.level*2+(e.isBoss?0:0),0);
S.credits += gold;
summary+='<div class="small">+'+gold+' lumens.</div>';
if(Battle.isBoss){
const z=ZONES[S.zone];
S.flags.anchors[S.zone]=true;
S.credits += 200;
summary+='<div class="small" style="color:var(--good)">Anchor fallen! The path onward opens.<br>+200 lumens.</div>';
}
await dialoguePanel('Victory', summary+'<div class="small center-txt" style="margin-top:8px;opacity:.6">tap to continue</div>', true);
refreshBars();
exitBattleToOverworld();
showStoryIfNeeded();
} else if(result==='lose'){
Audio.sfx.faint();
await dialoguePanel('Defeated','Your team has fallen. The nearest shrine recalls you.<div class="small center-txt" style="margin-top:8px;opacity:.6">tap to continue</div>', true);
// recall to last shrine, heal
healParty(S.party);
S.zone=S.flags.lastShrine;
enterZone(S.zone,true);
exitBattleToOverworld();
toast('You stir at the shrine, whole once more.','good');
} else { // flee
exitBattleToOverworld();
}
}
function gainExp(c, amount){
if(c.level>=LEVEL_CAP){ c.exp=0; return; }
c.exp+=amount;
let leveled=false;
while(c.level<LEVEL_CAP && c.exp>=expForLevel(c.level)){
c.exp-=expForLevel(c.level); c.level++; leveled=true;
recomputeStats(c);
// learn abilities
CREATURES[c.species].learn.forEach(([lv,ab])=>{
if(lv===c.level && !c.abilities.includes(ab)){
if(c.abilities.length<4){ c.abilities.push(ab); c.pp[ab]=ABILITIES[ab].pp; toast(CREATURES[c.species].name+' learned '+ABILITIES[ab].name+'!','good'); }
else { toast(CREATURES[c.species].name+' could learn '+ABILITIES[ab].name+' (replace in Team menu).','good'); }
}
});
}
if(leveled){ Audio.sfx.levelup(); c.hp=c.maxhp; c.abilities.forEach(ab=>{ if(c.pp[ab]!=null) c.pp[ab]=ABILITIES[ab].pp; }); }
}
function exitBattleToOverworld(){
// clear fx
Battle.enemies=[]; Battle.active=null; Battle.particles=[]; Battle.fx={};
// clear transient battle state on party
S.party.forEach(c=>{ c.status=null; c.boosts={atk:0,def:0,spd:0}; c.barrier=0; });
Screen.show('overworld');
$('#battle').classList.add('hidden');
$('#battle-ui').innerHTML='';
$('#fighter-bars').innerHTML='';
updateHUD();
showActionMenuIdle();
}
/* ============ CAPTURE ============ */
async function doCapture(act){
const item=ITEMS[act.item]; const target=Battle.enemies[act.target];
if(!target||target.hp<=0){ toast('No target.','bad'); showActionMenu(); return; }
if(target.isBoss){ toast('An Anchor cannot be bound.','bad'); showActionMenu(); return; }
useItem(act.item);
// mini-game
const bonus = await captureMinigame();
const hpFrac = target.hp/target.maxhp;
const hpFactor = (1-hpFrac); // 0..1
let chance = item.quality*0.45 + hpFactor*0.4 + bonus*0.25 + (Battle.isBoss?-1:0);
if(target.isBoss) chance = -1; // cannot capture bosses
chance = clamp(chance, target.isBoss?0:0.05, 0.97);
await sleep(200);
const success = Math.random()<chance;
if(success){
Audio.sfx.capture();
floatTxt(target,false,'Bound!','#7dffb0');
await sleep(700);
// add to party/box
const captured=makeCreature(target.species, target.level);
captured.hp=target.hp;
if(S.party.length<PARTY_MAX){ S.party.push(captured); }
else { S.box.push(captured); toast('Party full — '+CREATURES[captured.species].name+' sent to the Reserve.','good'); }
S.seen[target.species]=true; S.collected[target.species]=(S.collected[target.species]||0)+1;
await dialoguePanel('Bound!', '<div class="center-txt"><b style="font-size:18px;color:var(--good)">'+CREATURES[captured.species].name+'</b> has joined your bindings!</div><div class="small center-txt" style="margin-top:8px;opacity:.6">tap to continue</div>', true);
// remove enemy
Battle.enemies[act.target]=Object.assign(target,{hp:0});
checkBattleEnd();
if(!Battle.ended){ refreshBars(); showActionMenu(); }
} else {
Audio.sfx.back();
floatTxt(target,false,'Broke free!','#ff7d8d');
await sleep(700);
toast('The Lumari broke free!','bad');
// enemies still get to act this round? The capture consumed the player's action; remaining enemy actions continue after doCapture returns.
}
}
function captureMinigame(){
return new Promise(resolve=>{
const ov=el('div','modal-scrim');
const panel=el('div','panel'); panel.style.maxWidth='440px';
panel.innerHTML='<div class="panel-head"><div class="panel-title">Bind the Lumari</div></div>';
const body=el('div','panel-body center-txt');
body.innerHTML='<div class="small">Stop the light in the green band to strengthen the binding.</div>'
+'<div id="cap-bar"><div id="cap-zone"></div><div id="cap-marker"></div></div>'
+'<div class="capture-result" id="cap-result"></div>';
panel.appendChild(body);
const foot=el('div','panel-body');
const bindBtn=btn('BIND','btn primary',()=>{});
foot.appendChild(bindBtn); foot.style.borderTop='1px solid var(--glass-brd)';
panel.appendChild(foot);
ov.appendChild(panel); $('#overlay-root').appendChild(ov);
const marker=$('#cap-marker',body); let pos=0, dir=1, stopped=false, resultBonus=0;
const speed=1.1;
function loop(){
if(stopped) return;
pos+=dir*speed;
if(pos>100){pos=100;dir=-1;} if(pos<0){pos=0;dir=1;}
marker.style.left=pos+'%';
requestAnimationFrame(loop);
}
loop();
function finish(){
stopped=true;
const dist=Math.abs(pos-50);
// zone is 42-58 (center band). bonus max when at 50.
resultBonus = clamp(1 - dist/50, 0, 1);
$('#cap-result',body).textContent = resultBonus>0.7?'Perfect alignment!':(resultBonus>0.4?'Good binding.':'Weak alignment.');
$('#cap-result',body).style.color = resultBonus>0.7?'var(--good)':(resultBonus>0.4?'var(--warn)':'var(--bad)');
Audio.sfx.confirm();
setTimeout(()=>{ ov.remove(); resolve(resultBonus); }, 650);
}
bindBtn.addEventListener('click',()=>{ if(!stopped) finish(); });
});
}
/* ============ BATTLE FX / FLOATING ============ */
function floatTxt(target, isPlayer, txt, cls){
// place over target on battle canvas
const r=bcv.getBoundingClientRect();
let x,y;
if(isPlayer){ const p=playerBattlePos(); x=r.left+p.x; y=r.top+p.y; }
else { const idx=Battle.enemies.indexOf(target); const p=enemyBattlePos(idx<0?0:idx); x=r.left+p.x; y=r.top+p.y; }
spawnFloat(x,y,txt,cls);
}
function floatTxtPlayer(txt,cls){ const r=bcv.getBoundingClientRect(); const p=playerBattlePos(); spawnFloat(r.left+p.x,r.top+p.y,txt,cls); }
function spawnFloat(x,y,txt,cls){
const e=el('div','float-num '+(cls||''), txt);
e.style.left=(x+rnd(-16,16))+'px'; e.style.top=(y+rnd(-10,10))+'px';
document.body.appendChild(e);
setTimeout(()=>e.remove(),1000);
}
function flashScreen(){ const f=$('#fx-flash'); f.classList.remove('on'); void f.offsetWidth; f.classList.add('on'); }
function shakeStage(){ ['#stage','#battle'].forEach(sel=>{ const e=$(sel); if(!e)return; e.classList.remove('shake'); void e.offsetWidth; e.classList.add('shake'); }); }
function burstParticles(target, col){
const isPlayer = target===Battle.active;
const idx = Battle.enemies.indexOf(target);
const p = isPlayer?playerBattlePos():enemyBattlePos(idx<0?0:idx);
for(let i=0;i<14;i++){ Battle.particles.push({x:p.x,y:p.y,vx:rnd(-3,3),vy:rnd(-4,1),r:rnd(2,5),life:1,col:col||'#fff'}); }
}
function playerBattlePos(){ const w=bcv.clientWidth,h=bcv.clientHeight; return {x:w*0.3,y:h*0.62}; }
function enemyBattlePos(i){ const w=bcv.clientWidth,h=bcv.clientHeight; const n=Math.max(1,Battle.enemies.length); return {x:w*0.72, y:h*0.34 + (i-(n-1)/2)*h*0.16}; }
/* ============ BATTLE RENDER ============ */
let bTime=0;
function renderBattle(dt){
bTime+=dt;
const w=bcv.clientWidth,h=bcv.clientHeight;
bctx.clearRect(0,0,w,h);
const pal=ZONES[S.zone].palette;
// bg
const g=bctx.createLinearGradient(0,0,0,h);
g.addColorStop(0,pal.bg1); g.addColorStop(1,'#02030a');
bctx.fillStyle=g; bctx.fillRect(0,0,w,h);
// distant glow
const rg=bctx.createRadialGradient(w*0.5,h*0.3,10,w*0.5,h*0.5,w*0.7);
rg.addColorStop(0,'rgba(127,227,255,0.12)'); rg.addColorStop(1,'rgba(0,0,0,0)');
bctx.fillStyle=rg; bctx.fillRect(0,0,w,h);
// platforms
function platform(x,y,col){ bctx.save(); bctx.fillStyle='rgba(0,0,0,0.3)'; bctx.beginPath(); bctx.ellipse(x,y+30,w*0.13,16,0,0,7); bctx.fill();
bctx.strokeStyle=col; bctx.globalAlpha=0.5; bctx.lineWidth=2; bctx.beginPath(); bctx.ellipse(x,y+30,w*0.13,16,0,0,7); bctx.stroke(); bctx.restore(); }
const pp=playerBattlePos(); platform(pp.x,pp.y,pal.accent);
Battle.enemies.forEach((e,i)=>{ if(e.hp>0){ const ep=enemyBattlePos(i); platform(ep.x,ep.y,pal.accent2); } });
// player creature
if(Battle.active){
const fx=Battle.fx[Battle.active.uid]||{};
let ox=0,oy=0;
if(fx.shake){ ox=rnd(-6,6)*fx.shake; }
drawBattleCreature(Battle.active, pp.x+ox, pp.y+oy, 1, bTime, fx, true);
if(fx.shake){ fx.shake*=0.8; if(fx.shake<0.1)delete fx.shake; }
}
// enemies
Battle.enemies.forEach((e,i)=>{
if(e.hp<=0) return;
const fx=Battle.fx[e.uid]||{};
let ox=0,oy=0; if(fx.shake){ox=rnd(-6,6)*fx.shake;}
const ep=enemyBattlePos(i);
drawBattleCreature(e, ep.x+ox, ep.y+oy, e.isBoss?1.3:1, bTime, fx, false);
if(fx.shake){ fx.shake*=0.8; if(fx.shake<0.1)delete fx.shake; }
});
// particles
Battle.particles.forEach((p,idx)=>{ p.x+=p.vx; p.y+=p.vy; p.vy+=0.2; p.life-=dt*1.5;
bctx.save(); bctx.globalAlpha=clamp(p.life,0,1); bctx.fillStyle=p.col; bctx.shadowBlur=8; bctx.shadowColor=p.col;
bctx.beginPath(); bctx.arc(p.x,p.y,p.r,0,7); bctx.fill(); bctx.restore(); });
Battle.particles=Battle.particles.filter(p=>p.life>0);
}
function drawBattleCreature(c,x,y,scale,t,fx,isPlayer){
const facing=isPlayer?1:-1;
let alpha=1, sc=scale;
if(fx.flash){ alpha=0.5+0.5*Math.sin(t*30); }
if(fx.faint){ alpha=clamp((fx.faint),0,1); fx.faint-=0.02; sc=scale*(0.6+fx.faint*0.4); y+= (1-fx.faint)*20; }
bctx.save(); bctx.globalAlpha=alpha;
const hpFrac=c.hp/c.maxhp;
drawCreatureOn(bctx, c.species, x, y, sc, facing, t, false, hpFrac);
bctx.restore();
}
/* ============ SCREEN MANAGER ============ */
const Screen={state:'title',
show(n){ this.state=n;
$('#title-screen').classList.toggle('hidden', n!=='title');
$('#game').classList.toggle('hidden', n==='title');
$('#battle').classList.toggle('hidden', n!=='battle');
$('#hud').style.display = (n==='overworld')?'':'none';
$('#touchpad').style.display = (n==='overworld' && document.body.classList.contains('touch'))?'grid':'none';
if(n!=='title'){ fitAll(); }
}
};
/* ============ OVERLAY / PANEL ============ */
const Overlay={busy:false};
function openPanel(title, fillFn, opts={}){
Overlay.busy=true;
const ov=el('div','modal-scrim');
const panel=el('div','panel'); if(opts.wide)panel.style.width='min(760px,96vw)';
const head=el('div','panel-head');
head.innerHTML='<div class="panel-title">'+title+'</div>';
const close=btn('✕','btn icon sm',()=>closeThis());
head.appendChild(close);
panel.appendChild(head);
const body=el('div','panel-body');
panel.appendChild(body);
ov.appendChild(panel);
$('#overlay-root').appendChild(ov);
let closed=false;
function closeThis(){ if(closed)return; closed=true; ov.remove(); syncOverlayBusy(); }
fillFn(body, closeThis);
// close on scrim click
ov.addEventListener('mousedown',(e)=>{ if(e.target===ov && opts.scrimClose!==false) closeThis(); });
return closeThis;
}
function closePanel(){ const ms=$$('.modal-scrim'); if(ms.length){ ms[ms.length-1].remove(); } syncOverlayBusy(); }
function syncOverlayBusy(){ Overlay.busy = !!$('.modal-scrim'); }
/* ============ DIALOGUE ============ */
const Dialogue={busy:false};
function dialogue(text, speaker, onClose){
Dialogue.busy=true;
const d=$('#dialogue'); d.classList.remove('hidden');
d.innerHTML='<div class="dlg-box"><div class="dlg-speaker">'+(speaker||'')+'</div><div class="dlg-text">'+text+'</div><div class="dlg-prompt">tap / space</div></div>';
let done=false;
function finish(){ if(done)return; done=true; Dialogue.busy=false; d.classList.add('hidden'); d.innerHTML=''; if(onClose)onClose(); }
d.querySelector('.dlg-box').addEventListener('click',finish);
Dialogue._finish=finish;
}
function dialoguePanel(title, html, awaitTap){
return new Promise(resolve=>{
Dialogue.busy=true;
const ov=el('div','modal-scrim'); const panel=el('div','panel'); panel.style.maxWidth='460px';
panel.innerHTML='<div class="panel-head"><div class="panel-title">'+title+'</div></div>';
const body=el('div','panel-body'); body.innerHTML=html; panel.appendChild(body);
ov.appendChild(panel); $('#overlay-root').appendChild(ov);
let done=false;
function finish(){ if(done)return; done=true; Dialogue.busy=false; ov.remove(); resolve(); }
panel.addEventListener('click',finish);
Dialogue._finish=finish;
});
}
/* ============ TOAST ============ */
function toast(msg, cls){
const t=el('div','toast '+(cls||''), msg);
$('#toasts').appendChild(t);
setTimeout(()=>t.remove(),2700);
}
/* ============ TRANSITION FLASH ============ */
function transitionFlash(color, dur, cb){
const f=el('div'); f.style.cssText='position:absolute;inset:0;z-index:90;background:'+(color||'#000')+';opacity:0;transition:opacity '+(dur/2)+'ms;pointer-events:none;';
$('#game').appendChild(f);
requestAnimationFrame(()=>{ f.style.opacity='1'; });
setTimeout(()=>{ if(cb)cb(); f.style.opacity='0'; setTimeout(()=>f.remove(),dur/2+50); }, dur/2);
}
/* ============ STORY SYSTEM ============ */
const STORY={
intro:{title:'The Fracture', text:'Long ago the Prismatic Veil split, scattering seven affinities across Oraveth. You are a Binder — one who walks the rifts and binds the Lumari, the living shards of light.\n\nMend the veil. Fell the Four Anchors. Gather eight kindred of light before the last dawn fades.'},
three:{title:'A Binding Deepens', text:'Three Lumari now walk with you. Their resonance steadies your step. The Anchors stir in their distant spires.'},
firstAnchor:{title:'The Veil Thins', text:'An Anchor falls. A thread of the old fracture goes slack. Somewhere, the Hollow Crown takes notice.'},
finale:{title:'The Last Dawn', text:'The Four Anchors lie still. Eight kindred of light ring the spire. The Veil mends, thread by thread — and Oraveth breathes again.\n\nYet the deep void hums with harder things. Free roam awaits the bold.'},
};
function showStoryIfNeeded(){
const f=S.flags;
// intro only on new game (handled in boot)
if(!f.storyShown3 && uniqueSpeciesCount()>=3){ f.storyShown3=true; return showVignette(STORY.three,()=>{}); }
if(!f.storyShownFirstAnchor && anchorsDefeated()>=1){ f.storyShownFirstAnchor=true; return showVignette(STORY.firstAnchor,()=>{}); }
// win condition
if(!f.won && anchorsDefeated()>=WIN_ANCHORS && uniqueSpeciesCount()>=WIN_UNIQUE){
f.won=true; f.postgame=true;
return showVignette(STORY.finale,()=>{ toast('Post-game unlocked: the void spawns sturdier Lumari.','good'); });
}
}
function showVignette(s, after){
const ov=$('#story-overlay'); ov.style.display='flex';
ov.querySelector('.s-title').textContent=s.title;
ov.querySelector('.s-text').textContent=s.text;
Audio.sfx.boss();
let done=false;
function fin(){ if(done)return; done=true; ov.style.display='none'; after&&after(); }
ov.onclick=fin; Dialogue._finish=fin; Dialogue.busy=true;
}
/* ============ HUD ============ */
function updateHUD(){
const info=$('#hud-info');
info.innerHTML='<span>◈ <b>'+S.credits+'</b> lumens</span><span>Bindings: <b>'+uniqueSpeciesCount()+'</b>/'+WIN_UNIQUE+'</span><span>Anchors: <b>'+anchorsDefeated()+'</b>/'+WIN_ANCHORS+'</span>'
+(S.repelSteps>0?'<span>Repel: '+S.repelSteps+'</span>':'');
// right buttons
const r=$('#hud-right'); r.innerHTML='';
r.appendChild(menuBtn('Team','openTeam()'));
r.appendChild(menuBtn('Bag','openBag()'));
r.appendChild(menuBtn('Codex','openCodex()'));
r.appendChild(menuBtn('Map','openMap()'));
r.appendChild(menuBtn('☰','openPause()'));
// touch actions
const ta=$('#touch-actions'); ta.innerHTML='';
if(Screen.state==='overworld'){
const a=el('button','btn sm','Menu'); a.addEventListener('click',openPause); ta.appendChild(a);
}
}
function menuBtn(label,onclick){ const b=el('button','btn icon sm',label); b.addEventListener('click',()=>{Audio.sfx.select(); new Function(onclick)();}); return b; }
window.openTeam=openTeam; window.openBag=openBag; window.openCodex=openCodex; window.openMap=openMap; window.openPause=openPause;
/* ============ TEAM PANEL ============ */
function openTeam(){ openPanel('Your Bindings',(body)=>{
S.party.forEach((c,i)=>{
const row=el('div','team-row');
row.innerHTML=teamRowInner(c);
const acts=el('div','flex-row'); acts.style.marginTop='6px';
const def=CREATURES[c.species];
// transform
if(def.transform && c.level>=def.transform.lv && !c.catalystUsed){
const cat=def.transform.catalyst;
const tb=btn('Transform ('+ITEMS[cat].name+')','btn sm gold');
if(!hasItem(cat)){ tb.disabled=true; tb.title='Need '+ITEMS[cat].name; }
else tb.addEventListener('click',()=>{ doTransform(c,def.transform); });
acts.appendChild(tb);
}
// heal item quick
if(c.hp<c.maxhp && hasItem('mendsap')){ const h=btn('Mendsap','btn sm'); h.addEventListener('click',()=>{ applyHealItem('mendsap',c); useItem('mendsap'); row.innerHTML=teamRowInner(c); updateHUD(); Audio.sfx.heal(); }); acts.appendChild(h); }
if(c.status && hasItem('panacea')){ const p=btn('Panacea','btn sm'); p.addEventListener('click',()=>{ applyHealItem('panacea',c); useItem('panacea'); row.innerHTML=teamRowInner(c); Audio.sfx.heal(); }); acts.appendChild(p); }
const info=el('div','spacer'); acts.appendChild(info);
body.appendChild(row); body.appendChild(acts);
});
if(S.box.length){ body.appendChild(el('div','sep')); body.appendChild(el('div','menu-title','Reserve ('+S.box.length+')'));
S.box.forEach((c,i)=>{ const row=el('div','team-row'); row.innerHTML=teamRowInner(c);
const sw=btn('To Party','btn sm'); if(S.party.length>=PARTY_MAX)sw.disabled=true;
sw.addEventListener('click',()=>{ S.party.push(S.box.splice(i,1)[0]); closePanel(); openTeam(); });
row.appendChild(sw); body.appendChild(row); });
}
body.appendChild(el('div','sep')); body.appendChild(btn('Close','btn sm',closePanel));
});}
function doTransform(c, tr){
const cat=tr.catalyst;
if(!useItem(cat)){ toast('Missing catalyst.','bad'); return; }
c.catalystUsed=true;
c.species=tr.to;
c.affinity=CREATURES[tr.to].type;
recomputeStats(c);
// learn new abilities up to 4 (strongest available at current level)
const avail=knownAbilitiesForLevel(tr.to, c.level);
c.abilities=avail.slice(-4);
c.pp={}; c.abilities.forEach(ab=>c.pp[ab]=ABILITIES[ab].pp);
c.hp=c.maxhp; c.status=null; c.boosts={atk:0,def:0,spd:0};
Audio.sfx.levelup();
toast(CREATURES[c.species].name+' transformed!','good');
closePanel(); openTeam(); updateHUD();
}
function teamRowInner(c){
const def=CREATURES[c.species];
const hpFrac=c.hp/c.maxhp;
const hpCls=hpFrac>0.5?'':(hpFrac>0.25?'mid':'low');
let chips='';
if(c.status){const si=STATUS_INFO[c.status.type];if(si)chips+='<span class="chip" style="background:'+si.color+';color:#0a0e1a">'+si.label+'</span>';}
return '<div class="team-ava"></div>'
+'<div class="team-info"><div class="tn">'+def.name+' <span class="type-tag" style="background:'+TYPE_COLORS[def.type]+';color:#0a0e1a">'+def.type+'</span> <span class="small">Lv'+c.level+(c.catalystUsed?' ✦':'')+'</span></div>'
+'<div class="td">ATK '+c.atk+' · DEF '+c.def+' · SPD '+c.spd+(chips?' · '+chips:'')+'</div></div>'
+'<div class="team-hp"><div class="hp-shell"><div class="hp-fill '+hpCls+'" style="width:'+(hpFrac*100)+'%"></div></div><div class="small" style="text-align:right">'+Math.ceil(c.hp)+'/'+c.maxhp+'</div></div>';
}
function teamListHTML(party, onClick){
const wrap=el('div');
party.forEach(c=>{ const row=el('div','team-row'); row.style.cursor='pointer'; row.innerHTML=teamRowInner(c);
row.addEventListener('click',()=>onClick(c)); wrap.appendChild(row); });
return wrap;
}
/* ============ BAG PANEL ============ */
function openBag(){ openPanel('Satchel',(body)=>{
let any=false;
Object.keys(S.inv).forEach(id=>{
const n=S.inv[id]; if(n<=0)return; const it=ITEMS[id]; if(!it)return; any=true;
const row=el('div','shop-item');
row.innerHTML='<div class="si-l"><div class="si-n">'+it.name+' ×'+n+'</div><div class="si-d">'+it.desc+'</div></div>';
const use=btn('Use','btn sm');
use.addEventListener('click',()=>useFieldItem(id,()=>{ closePanel(); openBag(); }));
if(it.kind==='capture'||it.kind==='catalyst'){ use.textContent='—'; use.disabled=true; }
row.appendChild(use); body.appendChild(row);
});
if(!any)body.appendChild(el('div','small center-txt','Your satchel is empty.'));
body.appendChild(el('div','sep')); body.appendChild(btn('Close','btn sm',closePanel));
});}
function useFieldItem(id, after){
const it=ITEMS[id];
if(it.kind==='field'){ S.repelSteps=it.steps; useItem(id); toast('Wild Lumari will avoid you for '+it.steps+' steps.','good'); Audio.sfx.confirm(); after(); return; }
if(it.kind==='warp'){ useItem(id); toast('The sigil pulls you to the shrine.','good'); enterZone(S.flags.lastShrine,true); after(); return; }
// heal items: pick target
openPanel('Use on which Lumari?',(b)=>{
b.appendChild(teamListHTML(S.party,(c)=>{
const ok=it.kind==='revive'?c.hp<=0:(c.hp>0&&(c.hp<c.maxhp||c.status));
if(!ok){toast('No effect.','bad');return;}
applyHealItem(id,c); useItem(id); Audio.sfx.heal(); closePanel(); after();
}));
b.appendChild(btn('Back','btn sm',closePanel));
},{scrimClose:false});
}
/* ============ CODEX ============ */
function openCodex(){ openPanel('Bestiary of Light',(body)=>{
Object.keys(CREATURES).forEach(id=>{
const def=CREATURES[id]; const seen=!!S.seen[id]; const owned=S.collected[id]||0;
const card=el('div','codex-card'+(seen?'':' locked'));
const can=el('canvas','codex-ava'); can.width=64;can.height=64;
const cx=can.getContext('2d');
if(seen){ _drawBody(cx, def, 32,30,0.8,1,0,false,null); }
card.appendChild(can);
const info=el('div','div'); info.style.flex='1';
info.innerHTML='<div class="codex-name">'+(seen?def.name:'???')+' <span class="type-tag" style="background:'+TYPE_COLORS[def.type]+';color:#0a0e1a">'+def.type+'</span></div>'
+'<div class="codex-lore">'+(seen?def.lore:'A Lumari you have not yet encountered.')+'</div>'
+(seen?'<div class="codex-stats"><span class="codex-stat">HP <b>'+def.base.hp+'</b></span><span class="codex-stat">ATK <b>'+def.base.atk+'</b></span><span class="codex-stat">DEF <b>'+def.base.def+'</b></span><span class="codex-stat">SPD <b>'+def.base.spd+'</b></span><span class="codex-stat">×'+(owned+(S.party.some(c=>c.species===id)||S.box.some(c=>c.species===id)?1:0))+' bound</span></div>':'');
card.appendChild(info); body.appendChild(card);
});
body.appendChild(el('div','sep')); body.appendChild(btn('Close','btn sm',closePanel));
},{wide:true});}
/* ============ MAP ============ */
function openMap(){ openPanel('Worldrift Map',(body)=>{
const grid=el('div','map-grid');
ZONES.forEach((z,i)=>{
const cell=el('div','map-cell'+(S.flags.zonesUnlocked[i]?' unlocked':'')+(i===S.zone?' current':''));
const guardianDown = S.flags.anchors[i];
cell.innerHTML='<div class="mi" style="color:'+z.palette.accent+'">◈</div><div class="mn">'+z.name+'</div><div class="md">'+z.sub+'</div>'
+(i===S.zone?'<div class="md" style="color:var(--accent)">here</div>':'')
+(!S.flags.zonesUnlocked[i]?'<div class="md">sealed</div>':(z.anchor?(guardianDown?'<div class="md" style="color:var(--good)">anchor fallen</div>':'<div class="md" style="color:var(--bad)">anchor stands</div>'):'<div class="md">open</div>'));
if(S.flags.zonesUnlocked[i] && i!==S.zone){
cell.addEventListener('click',()=>{ closePanel(); travelTo(i); });
}
grid.appendChild(cell);
});
body.appendChild(grid);
body.appendChild(el('div','sep')); body.appendChild(btn('Close','btn sm',closePanel));
});}
function travelTo(i){
$$('.modal-scrim').forEach(m=>m.remove()); syncOverlayBusy();
dialogue('Travel to '+ZONES[i].name+'?','Riftstep',()=>{ enterZone(i,true); updateHUD(); });
}
/* ============ SHRINE ============ */
function openShrine(){
S.flags.lastShrine=S.zone;
dialogue('You touch the shrine. Rest to heal and bind your progress, or trade lumens with the wayfarer.','Shrine',()=>{
openPanel('Wayshrine',(body)=>{
body.appendChild(el('div','center-txt','<div class="small">'+ZONES[S.zone].storyNode+'</div>'));
const b=el('div','flex-row center'); b.style.marginTop='12px';
b.appendChild(btn('Rest & Save','btn primary',()=>{ healParty(S.party); S.repelSteps=0; saveGame(); Audio.sfx.heal(); closePanel(); toast('Your bindings are mended and your journey preserved.','good'); }));
b.appendChild(btn('Wayfarer','btn',openShop));
b.appendChild(btn('Map','btn',openMap));
body.appendChild(b);
body.appendChild(el('div','sep')); body.appendChild(btn('Continue','btn sm',closePanel));
});
});
}
function openShop(){
openPanel('Wayfarer\'s Wares',(body)=>{
const shop=ZONES[S.zone].shop||{};
body.appendChild(el('div','small','You have '+S.credits+' lumens.'));
body.appendChild(el('div','sep'));
Object.keys(shop).forEach(id=>{
const price=shop[id]; const it=ITEMS[id];
const row=el('div','shop-item');
row.innerHTML='<div class="si-l"><div class="si-n">'+it.name+' <span class="small">('+price+'◈)</span></div><div class="si-d">'+it.desc+'</div></div>';
const buy=btn('Buy','btn sm');
buy.addEventListener('click',()=>{ if(S.credits<price){toast('Not enough lumens.','bad');return;} S.credits-=price; addItem(id,1); Audio.sfx.confirm(); toast('Bought '+it.name+'.','good'); closePanel(); openShop(); });
row.appendChild(buy); body.appendChild(row);
});
body.appendChild(el('div','sep')); body.appendChild(btn('Back','btn sm',closePanel));
});
}
/* ============ PAUSE ============ */
function openPause(){
openPanel('Paused',(body)=>{
const b=el('div','flex-row center');
b.appendChild(btn('Resume','btn primary',closePanel));
b.appendChild(btn('Save','btn',()=>{ saveGame(); }));
b.appendChild(btn('Mute: '+(Audio.isMuted()?'Off':'On'),'btn',()=>{ const m=!Audio.isMuted(); Audio.setMuted(m); S.muted=m; document.body.classList.toggle('muted',m); closePanel(); openPause(); }));
b.appendChild(btn('Codex','btn',openCodex));
body.appendChild(b);
body.appendChild(el('div','sep'));
const q=btn('Quit to Title','btn danger',()=>{
if(confirm('Return to title? Your last shrine save is kept.')){ closePanel(); Screen.show('title'); Audio.setPad(-1); buildTitle(); }
});
body.appendChild(q);
});
}
/* ============ TITLE / BOOT ============ */
function buildTitle(){
const tb=$('#title-btns'); tb.innerHTML='';
const nb=btn('New Binding','btn primary',startNewGame);
tb.appendChild(nb);
if(hasSave()){ const cb=btn('Continue','btn gold',continueGame); tb.appendChild(cb); }
}
function startNewGame(){
Audio.init();
S=freshState();
pickStarter();
}
function pickStarter(){
openPanel('Choose Your First Binding',(body)=>{
body.appendChild(el('div','small center-txt','Three Lumari answer the rift. Choose one to walk with you.'));
const grid=el('div','flex-row center'); grid.style.gap='12px'; grid.style.marginTop='10px';
STARTERS.forEach(id=>{
const def=CREATURES[id];
const card=el('div'); card.style.cssText='background:rgba(10,16,30,.6);border:1px solid var(--glass-brd);border-radius:16px;padding:12px;width:150px;text-align:center;cursor:pointer;';
const can=el('canvas'); can.width=120;can.height=100; const cxx=can.getContext('2d');
_drawBody(cxx, def, 60,55,1.3,1,0,false,null);
card.appendChild(can);
card.innerHTML+='<div style="font-weight:800;margin-top:6px">'+def.name+'</div><div class="small">'+def.type+' type</div><div class="small" style="margin-top:6px;line-height:1.5">'+def.lore+'</div>';
card.addEventListener('click',()=>{ Audio.sfx.confirm(); closePanel(); beginGameWith(id); });
grid.appendChild(card);
});
body.appendChild(grid);
},{scrimClose:false});
}
function beginGameWith(starterId){
S.party=[makeCreature(starterId,5)];
S.seen[starterId]=true; S.collected[starterId]=1;
Screen.show('overworld');
enterZone(0,false);
S.px=2;S.py=MAP_H-3; playerRender={x:S.px,y:S.py};
updateHUD();
fitAll();
showTutorialIntro();
}
function showTutorialIntro(){
// story intro + tutorial
Dialogue.busy=true;
const ov=$('#story-overlay'); ov.style.display='flex';
ov.querySelector('.s-title').textContent=STORY.intro.title;
ov.querySelector('.s-text').textContent=STORY.intro.text;
ov.onclick=()=>{
ov.style.display='none'; Dialogue.busy=false;
dialogue('Welcome to '+ZONES[0].name+'. Use WASD or arrow keys to move (or the on-screen pad). Walk into the swaying light-grass to find wild Lumari. The shrine beside you will mend your team.','A Voice in the Rift',()=>{
S.flags.storyStage=1;
toast('Tip: defeat the Guardian (marked “!”) to open the path onward.','good');
});
};
}
function continueGame(){
Audio.init();
const d=loadGame(); if(!d){ toast('No save found.','bad'); return; }
S=d;
// sanitize
if(!S.flags) S.flags=freshState().flags;
if(!S.flags.anchors) S.flags.anchors={};
if(!S.flags.zonesUnlocked) S.flags.zonesUnlocked=[true,false,false,false,false,false];
if(S.flags.lastShrine==null) S.flags.lastShrine=0;
if(!S.inv) S.inv={glimlamp:5};
S.party.forEach(c=>{ if(!c.pp)c.pp={}; c.abilities.forEach(ab=>{ if(c.pp[ab]==null)c.pp[ab]=ABILITIES[ab].pp; }); recomputeStats(c); if(c.hp>c.maxhp)c.hp=c.maxhp; });
Screen.show('overworld');
Audio.setMuted(!!S.muted); document.body.classList.toggle('muted',!!S.muted);
enterZone(S.zone,false);
playerRender={x:S.px,y:S.py};
updateHUD();
fitAll();
toast('Welcome back, Binder.','good');
}
/* ============ INPUT ============ */
function bindInput(){
const keymap={ArrowUp:[0,-1],KeyW:[0,-1],ArrowDown:[0,1],KeyS:[0,1],ArrowLeft:[-1,0],KeyA:[-1,0],ArrowRight:[1,0],KeyD:[1,0]};
window.addEventListener('keydown',(e)=>{
if(Screen.state==='title'){
if(e.code==='Enter'||e.code==='Space'){ if(!Overlay.busy){ startNewGame(); } }
return;
}
// dialogue confirm
if(Dialogue.busy){
if(e.code==='Enter'||e.code==='Space'){ if(Dialogue._finish)Dialogue._finish(); }
return;
}
if(Overlay.busy) return;
if(Screen.state==='overworld'){
if(keymap[e.code]){ e.preventDefault(); tryMove(keymap[e.code][0],keymap[e.code][1]); }
if(e.code==='Escape'){ openPause(); }
}
});
// touch dpad
const bind=(id,dx,dy)=>{ const b=$('#'+id); const h=(ev)=>{ ev.preventDefault(); tryMove(dx,dy); };
b.addEventListener('touchstart',h,{passive:false}); b.addEventListener('mousedown',h); };
bind('dp-up',0,-1); bind('dp-down',0,1); bind('dp-left',-1,0); bind('dp-right',1,0);
}
function showActionMenuIdle(){ /* placeholder; action menu only in battle */ }
/* ============ MASTER LOOP ============ */
function masterLoop(now){
const dt=Math.min(0.05,(now-(masterLoop._last||now))/1000); masterLoop._last=now;
if(Screen.state==='title'){ drawTitleBG(now); }
else if(Screen.state==='overworld'){ if(map) renderOverworld(now/1000, dt); }
else if(Screen.state==='battle'){ renderBattle(dt); }
requestAnimationFrame(masterLoop);
}
/* ============ INIT ============ */
function detectTouch(){ return ('ontouchstart' in window)||navigator.maxTouchPoints>0; }
window.addEventListener('load',()=>{
if(detectTouch()) document.body.classList.add('touch');
fitAll(); initTitleStars(); fitCanvas(tbg);
buildTitle();
bindInput();
requestAnimationFrame(masterLoop);
});
window.addEventListener('click',function once(){ Audio.init(); window.removeEventListener('click',once); });
</script>
</body>
</html>
<!-- agent-meta {"model":"glm-5.2","provider":"zai","persona":"middle","sessionId":"079a8640-8107-459a-aa58-0a95e0fc521d","tokensIn":3970831,"tokensOut":89110,"tokensTotal":4059941,"turns":53,"toolCalls":52,"failedToolCalls":0,"timestamp":"2026-07-12T22:51:04.862Z"} -->