Creature Collection
Minimax m3 · full

Browser check: Artifact runtime error. This is a technical check, not a quality review.
Reviews & generation details
Reviews
Human review · Jul 17, 2026 · rubric 1
Historical path-based review. Association with these exact bytes is unverified.
- Visual design
- 4 / 5
- UX / feel
- 3 / 5
- Works
- 2 / 5
- Prompt spirit
- 4 / 5
- Delight
- 2 / 5
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 145,068 bytes · SHA-256 974465c76fcb
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<title>Aethelgard: Veilbound</title>
<style>
:root{
--cinder:#ff7a45; --cinder-glow:#ffb88a;
--tide:#4cc9f0; --tide-glow:#9be8ff;
--verdant:#7ee07a; --verdant-glow:#c5f0a5;
--volt:#f5e042; --volt-glow:#fff5a8;
--terra:#c08a5a; --terra-glow:#e8c79a;
--lumen:#fff5d6; --lumen-glow:#fff8e7;
--umbral:#7c5cff; --umbral-glow:#b6a4ff;
--ink:#0c0f1a; --ink-2:#161b2c; --ink-3:#202845;
--glass:rgba(20,24,40,0.55);
--glass-2:rgba(255,255,255,0.08);
--line:rgba(255,255,255,0.14);
--txt:#eef2ff; --txt-dim:#a3acd1;
--good:#7ee07a; --bad:#ff6b6b; --warn:#f5c542;
--shadow:0 6px 24px rgba(0,0,0,0.4);
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
html,body{width:100%;height:100%;overflow:hidden;background:#07091a;color:var(--txt);font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;letter-spacing:.02em;user-select:none}
body{position:relative}
#stage{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:radial-gradient(1200px 800px at 50% 30%, #1a2247 0%, #0a0d1f 60%, #050714 100%)}
#game{position:absolute;inset:0;width:100%;height:100%;display:block;image-rendering:pixelated;cursor:default}
#ui{position:absolute;inset:0;pointer-events:none}
#ui > *{pointer-events:auto}
.hud{position:absolute;left:12px;top:10px;display:flex;gap:8px;align-items:center;z-index:10}
.chip{background:var(--glass);backdrop-filter:blur(10px) saturate(140%);-webkit-backdrop-filter:blur(10px) saturate(140%);border:1px solid var(--line);border-radius:14px;padding:6px 12px;font-size:12px;color:var(--txt);box-shadow:var(--shadow);display:inline-flex;align-items:center;gap:8px}
.chip b{color:var(--lumen-glow);font-weight:600}
.chip .dot{width:8px;height:8px;border-radius:50%;background:var(--lumen);box-shadow:0 0 10px var(--lumen-glow)}
.pill{border-radius:999px}
.btn{cursor:pointer;background:linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.03));border:1px solid var(--line);color:var(--txt);font-size:14px;padding:10px 16px;border-radius:12px;transition:transform .12s ease, background .12s ease, box-shadow .12s ease;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}
.btn:hover{background:linear-gradient(180deg, rgba(255,255,255,0.18), rgba(255,255,255,0.05));transform:translateY(-1px);box-shadow:0 6px 18px rgba(0,0,0,0.35)}
.btn:active{transform:translateY(0)}
.btn.primary{background:linear-gradient(180deg, #ffd97a, #f5a142);color:#1a1407;border-color:#ffd97a;box-shadow:0 8px 22px rgba(245,180,80,0.4);font-weight:700}
.btn.primary:hover{background:linear-gradient(180deg, #ffe39a, #ffb65a)}
.btn.ghost{background:transparent;border-color:rgba(255,255,255,0.2);color:var(--txt-dim)}
.btn.danger{background:linear-gradient(180deg, #ff8a8a, #d34a4a);color:#1a0606;border-color:#ff8a8a}
.btn.tiny{padding:4px 8px;font-size:11px;border-radius:8px}
.btn-row{display:flex;gap:8px;flex-wrap:wrap}
.row{display:flex;gap:10px;align-items:center}
.col{display:flex;flex-direction:column;gap:10px}
.gap-4{gap:4px}.gap-8{gap:8px}.gap-12{gap:12px}.gap-16{gap:16px}
.center{display:flex;align-items:center;justify-content:center}
.grow{flex:1}
.scroll{overflow:auto;scrollbar-width:thin}
.scroll::-webkit-scrollbar{width:8px;height:8px}
.scroll::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.15);border-radius:4px}
.modal-back{position:absolute;inset:0;background:radial-gradient(800px 500px at 50% 50%, rgba(40,30,80,0.5), rgba(0,0,0,0.7));backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;z-index:50;animation:fade .2s ease}
@keyframes fade{from{opacity:0}to{opacity:1}}
@keyframes pop{from{transform:scale(.92);opacity:0}to{transform:scale(1);opacity:1}}
@keyframes rise{from{transform:translateY(8px);opacity:0}to{transform:translateY(0);opacity:1}}
.modal{background:linear-gradient(180deg, rgba(40,50,90,0.7), rgba(20,24,40,0.85));border:1px solid var(--line);border-radius:18px;box-shadow:0 24px 60px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.07);padding:22px;max-width:min(900px,92vw);max-height:min(82vh,720px);width:auto;animation:pop .22s cubic-bezier(.2,.9,.3,1.3);overflow:hidden;display:flex;flex-direction:column}
.modal h2{font-size:18px;letter-spacing:.06em;text-transform:uppercase;color:var(--lumen-glow);margin-bottom:4px}
.modal .sub{color:var(--txt-dim);font-size:12px;margin-bottom:14px}
.modal .body{display:flex;flex-direction:column;gap:10px;min-height:0;flex:1}
.modal .foot{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}
.tabs{display:flex;gap:4px;background:rgba(0,0,0,0.25);border:1px solid var(--line);border-radius:10px;padding:4px;margin-bottom:10px}
.tab{padding:6px 12px;border-radius:8px;font-size:12px;color:var(--txt-dim);cursor:pointer;letter-spacing:.04em;text-transform:uppercase}
.tab.active{background:linear-gradient(180deg, rgba(255,255,255,0.12), rgba(255,255,255,0.04));color:var(--lumen-glow)}
.title-screen{position:absolute;inset:0;z-index:80;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;background:radial-gradient(800px 600px at 50% 40%, #2a3260 0%, #0c0f1f 60%, #050714 100%);animation:fade .4s ease}
.title-screen h1{font-size:clamp(36px, 6vw, 64px);font-weight:300;letter-spacing:.12em;color:var(--lumen-glow);text-shadow:0 0 30px rgba(255,245,200,0.5), 0 0 60px rgba(255,245,200,0.2);margin-bottom:6px}
.title-screen h1 .accent{color:#ffd97a;font-weight:600}
.title-screen .tag{color:var(--txt-dim);font-size:13px;letter-spacing:.18em;text-transform:uppercase;margin-bottom:34px}
.title-screen .stack{display:flex;flex-direction:column;gap:10px;min-width:240px}
.title-screen .lore{position:absolute;bottom:24px;left:0;right:0;text-align:center;color:var(--txt-dim);font-size:11px;letter-spacing:.2em;text-transform:uppercase;opacity:.6}
.title-screen .stars{position:absolute;inset:0;overflow:hidden;pointer-events:none}
.star{position:absolute;width:2px;height:2px;background:#fff;border-radius:50%;opacity:.6;animation:twinkle 3s infinite ease-in-out}
@keyframes twinkle{0%,100%{opacity:.2;transform:scale(.8)}50%{opacity:1;transform:scale(1.4)}}
.dialogue{position:absolute;left:50%;bottom:20px;transform:translateX(-50%);max-width:min(720px,92vw);background:linear-gradient(180deg, rgba(20,24,40,0.85), rgba(10,14,30,0.9));border:1px solid var(--line);border-radius:14px;padding:14px 18px;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:20;animation:rise .25s ease;box-shadow:var(--shadow)}
.dialogue .who{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--lumen-glow);margin-bottom:6px}
.dialogue .what{font-size:14px;line-height:1.5}
.dialogue .next{position:absolute;right:14px;bottom:10px;color:var(--txt-dim);font-size:11px;letter-spacing:.16em}
.touch-pad{position:absolute;left:14px;bottom:14px;display:grid;grid-template-columns:repeat(3,44px);grid-template-rows:repeat(3,44px);gap:4px;z-index:15}
.touch-pad .tb{background:rgba(20,24,40,0.6);border:1px solid var(--line);border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--txt);font-size:18px;cursor:pointer;user-select:none;backdrop-filter:blur(4px)}
.touch-pad .tb:active{background:rgba(255,255,255,0.15)}
.touch-actions{position:absolute;right:14px;bottom:14px;display:flex;flex-direction:column;gap:6px;z-index:15}
.touch-actions .tb{width:64px;height:44px;background:rgba(20,24,40,0.6);border:1px solid var(--line);border-radius:10px;color:var(--txt);font-size:12px;font-weight:600;display:flex;align-items:center;justify-content:center;cursor:pointer;backdrop-filter:blur(4px)}
.touch-actions .tb:active{background:rgba(255,255,255,0.15)}
.toast{position:absolute;left:50%;top:30%;transform:translateX(-50%);background:rgba(20,24,40,0.9);border:1px solid var(--line);border-radius:12px;padding:10px 16px;font-size:13px;color:var(--lumen-glow);letter-spacing:.06em;text-transform:uppercase;z-index:60;animation:rise .25s ease, fadeout .8s ease 1.4s forwards;box-shadow:var(--shadow)}
@keyframes fadeout{to{opacity:0;transform:translate(-50%,-20px)}}
.flash{position:absolute;inset:0;pointer-events:none;z-index:30;opacity:0}
.flash.crit{background:radial-gradient(circle at 50% 50%, rgba(255,255,255,0.7), transparent 60%);animation:flashCrit .35s ease}
.flash.hit{background:radial-gradient(circle at 50% 50%, rgba(255,120,120,0.3), transparent 60%);animation:flashHit .25s ease}
.flash.heal{background:radial-gradient(circle at 50% 50%, rgba(120,255,160,0.3), transparent 60%);animation:flashHeal .35s ease}
@keyframes flashCrit{0%{opacity:0}20%{opacity:1}100%{opacity:0}}
@keyframes flashHit{0%{opacity:0}30%{opacity:1}100%{opacity:0}}
@keyframes flashHeal{0%{opacity:0}30%{opacity:1}100%{opacity:0}}
.shake{animation:shake .3s ease}
@keyframes shake{0%,100%{transform:translate(0,0)}20%{transform:translate(-4px,2px)}40%{transform:translate(4px,-2px)}60%{transform:translate(-3px,-2px)}80%{transform:translate(3px,1px)}}
.vignette{position:absolute;inset:0;pointer-events:none;z-index:5;mix-blend-mode:multiply;opacity:.65}
.bar{height:8px;background:rgba(0,0,0,0.45);border:1px solid var(--line);border-radius:6px;overflow:hidden;position:relative}
.bar > i{display:block;height:100%;background:linear-gradient(90deg, #7ee07a, #b6f0a0);transition:width .4s cubic-bezier(.2,.7,.2,1)}
.bar.warn > i{background:linear-gradient(90deg, #f5c542, #ffe28a)}
.bar.bad > i{background:linear-gradient(90deg, #ff6b6b, #ff9c9c)}
.bar.lumen > i{background:linear-gradient(90deg, #ffd97a, #fff5d6)}
.bar.energy > i{background:linear-gradient(90deg, #6cc6ff, #b6e8ff)}
.bar.exp > i{background:linear-gradient(90deg, #c9a4ff, #ecd9ff)}
.small{font-size:11px;color:var(--txt-dim)}
.swatch{display:inline-block;width:10px;height:10px;border-radius:50%;vertical-align:middle;margin-right:4px;box-shadow:0 0 8px currentColor}
.battle{position:absolute;inset:0;display:flex;flex-direction:column;z-index:40;background:linear-gradient(180deg, rgba(8,10,30,0.4), rgba(0,0,0,0.6))}
.battle-stage{flex:1;position:relative;overflow:hidden}
.battle-ui{display:flex;align-items:flex-end;gap:10px;padding:12px;background:linear-gradient(180deg, transparent, rgba(0,0,0,0.6))}
.enemy-card,.player-card{background:var(--glass);backdrop-filter:blur(10px) saturate(140%);-webkit-backdrop-filter:blur(10px) saturate(140%);border:1px solid var(--line);border-radius:14px;padding:10px 14px;box-shadow:var(--shadow);min-width:200px}
.enemy-card{align-self:flex-start;margin-left:24px}
.player-card{align-self:flex-end;margin-right:24px}
.battle-actions{position:absolute;left:0;right:0;bottom:0;padding:14px;background:linear-gradient(180deg, transparent, rgba(0,0,0,0.75));display:flex;flex-direction:column;gap:10px;align-items:center;z-index:45}
.battle-actions .menu{display:grid;grid-template-columns:repeat(2, minmax(120px, 220px));gap:8px;width:100%;max-width:520px}
.target-pick{position:absolute;inset:0;z-index:46;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(2px)}
.text-bubble{position:absolute;left:50%;top:14px;transform:translateX(-50%);background:rgba(20,24,40,0.85);border:1px solid var(--line);border-radius:10px;padding:8px 14px;font-size:13px;color:var(--lumen-glow);z-index:48;animation:rise .2s ease;max-width:80%;text-align:center}
.icon-crit{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);font-size:48px;font-weight:700;color:#ffd97a;text-shadow:0 0 30px rgba(255,180,40,0.8);z-index:49;pointer-events:none;animation:critpop .6s ease forwards}
@keyframes critpop{0%{transform:translate(-50%,-50%) scale(.3);opacity:0}30%{transform:translate(-50%,-50%) scale(1.4);opacity:1}100%{transform:translate(-50%,-50%) scale(1);opacity:0}}
.battle-sprite{position:absolute;image-rendering:pixelated;will-change:transform}
.cryst{position:absolute;width:16px;height:16px;background:radial-gradient(circle at 30% 30%, #fff, rgba(255,255,255,0) 60%);border-radius:50%;pointer-events:none;mix-blend-mode:screen}
.glyph{width:22px;height:22px;border-radius:6px;display:inline-block;vertical-align:middle;background:rgba(0,0,0,0.4);border:1px solid var(--line);position:relative;overflow:hidden}
.glyph::after{content:"";position:absolute;inset:2px;border-radius:4px;background:currentColor;opacity:.85;filter:blur(0px)}
.mini-map{position:relative;width:100%;aspect-ratio:1/1;max-width:340px;background:rgba(0,0,0,0.4);border:1px solid var(--line);border-radius:14px;overflow:hidden;margin:0 auto}
.mini-map canvas{width:100%;height:100%;display:block}
.creature-list{display:grid;grid-template-columns:repeat(auto-fill, minmax(150px, 1fr));gap:10px}
.creature-tile{background:linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));border:1px solid var(--line);border-radius:12px;padding:10px;display:flex;flex-direction:column;gap:6px;transition:transform .15s ease}
.creature-tile:hover{transform:translateY(-2px)}
.creature-tile .top{display:flex;justify-content:space-between;align-items:center;gap:8px}
.creature-tile .name{font-weight:600;font-size:13px}
.creature-tile .lvl{font-size:11px;color:var(--txt-dim)}
.creature-tile .lore{font-size:11px;color:var(--txt-dim);line-height:1.3;min-height:30px}
.creature-tile .stats{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;font-size:10px;color:var(--txt-dim)}
.creature-tile .stat b{color:var(--txt);font-weight:600}
.mini-sprite{width:42px;height:42px;background:rgba(0,0,0,0.4);border:1px solid var(--line);border-radius:10px;position:relative;overflow:hidden;flex-shrink:0}
.tooltip{position:absolute;background:rgba(10,14,30,0.95);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:11px;color:var(--txt);z-index:70;pointer-events:none;box-shadow:var(--shadow);white-space:nowrap}
.move-list{display:grid;grid-template-columns:repeat(auto-fill, minmax(170px, 1fr));gap:8px}
.move-tile{background:linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.02));border:1px solid var(--line);border-radius:10px;padding:8px 10px;cursor:pointer;transition:transform .12s, background .12s;position:relative}
.move-tile:hover{background:linear-gradient(180deg, rgba(255,255,255,0.16), rgba(255,255,255,0.04));transform:translateY(-1px)}
.move-tile.disabled{opacity:0.5;cursor:not-allowed}
.move-tile .nm{font-weight:600;font-size:13px;display:flex;justify-content:space-between;align-items:center;gap:8px}
.move-tile .pp{font-size:10px;color:var(--txt-dim);background:rgba(0,0,0,0.3);padding:2px 6px;border-radius:6px}
.move-tile .meta{font-size:10px;color:var(--txt-dim);margin-top:4px;display:flex;gap:8px;flex-wrap:wrap}
.tag{font-size:10px;padding:2px 6px;border-radius:6px;background:rgba(0,0,0,0.35);border:1px solid var(--line);color:var(--txt-dim);letter-spacing:.04em;text-transform:uppercase}
.tag.type-cinder{color:var(--cinder-glow);border-color:rgba(255,180,140,0.4)}
.tag.type-tide{color:var(--tide-glow);border-color:rgba(180,230,255,0.4)}
.tag.type-verdant{color:var(--verdant-glow);border-color:rgba(180,240,180,0.4)}
.tag.type-volt{color:var(--volt-glow);border-color:rgba(255,240,160,0.4)}
.tag.type-terra{color:var(--terra-glow);border-color:rgba(230,200,160,0.4)}
.tag.type-lumen{color:#fff5d6;border-color:rgba(255,245,200,0.5)}
.tag.type-umbral{color:var(--umbral-glow);border-color:rgba(180,160,255,0.4)}
.tag.status{color:var(--warn);border-color:rgba(245,200,80,0.4)}
.tag.buff{color:var(--good);border-color:rgba(120,230,120,0.4)}
.tag.debuff{color:var(--bad);border-color:rgba(255,140,140,0.4)}
.tag.priority{border-color:rgba(255,200,100,0.4);color:#ffd97a}
.capture-zone{position:absolute;inset:0;background:radial-gradient(500px 400px at 50% 50%, rgba(255,200,80,0.2), rgba(0,0,0,0.7));backdrop-filter:blur(3px);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:14px;z-index:50;animation:fade .2s ease}
.capture-ring{width:240px;height:240px;border-radius:50%;border:4px solid rgba(255,255,255,0.2);position:relative;display:flex;align-items:center;justify-content:center}
.capture-ring::before{content:"";position:absolute;inset:0;border-radius:50%;border:4px solid transparent;border-top-color:#ffd97a;border-right-color:#fff5d6;animation:spin 1.2s linear infinite}
.capture-ring::after{content:"";position:absolute;inset:14px;border-radius:50%;border:3px solid rgba(255,255,255,0.18);border-bottom-color:#7ee07a;animation:spin 1.6s linear infinite reverse}
@keyframes spin{to{transform:rotate(360deg)}}
.capture-target{position:absolute;width:60px;height:60px;border-radius:50%;background:radial-gradient(circle at 50% 50%, rgba(255,200,80,0.9), rgba(255,200,80,0.2) 60%, transparent);box-shadow:0 0 30px rgba(255,200,80,0.7);animation:bob 1.2s ease-in-out infinite}
@keyframes bob{0%,100%{transform:translateY(-4px)}50%{transform:translateY(4px)}}
.capture-meter{width:260px;height:18px;background:rgba(0,0,0,0.55);border:1px solid var(--line);border-radius:10px;overflow:hidden;position:relative}
.capture-meter .fill{position:absolute;left:0;top:0;bottom:0;width:0;background:linear-gradient(90deg, #ffd97a, #fff5d6);transition:width .15s ease;box-shadow:inset 0 0 12px rgba(255,255,255,0.3)}
.capture-meter .marker{position:absolute;top:-4px;bottom:-4px;width:3px;background:#fff;box-shadow:0 0 10px #fff;border-radius:2px}
.flavor{position:absolute;left:14px;bottom:14px;font-size:11px;color:var(--txt-dim);letter-spacing:.1em;text-transform:uppercase;z-index:15;background:var(--glass);border:1px solid var(--line);border-radius:10px;padding:4px 10px;backdrop-filter:blur(6px)}
#pause-menu{position:absolute;right:14px;top:54px;display:flex;flex-direction:column;gap:6px;z-index:14}
#pause-menu .btn{padding:6px 12px;font-size:12px}
.divider{height:1px;background:linear-gradient(90deg, transparent, var(--line), transparent);margin:6px 0}
.icon-btn{width:34px;height:34px;background:var(--glass);border:1px solid var(--line);border-radius:10px;color:var(--txt);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:14px;backdrop-filter:blur(6px)}
.icon-btn:hover{background:rgba(255,255,255,0.12)}
.story-vignette{position:absolute;inset:0;pointer-events:none;z-index:25;mix-blend-mode:multiply;opacity:.7;transition:opacity .6s ease}
.hidden{display:none !important}
@media (max-width:700px){
.hud{font-size:10px}
.modal{max-height:90vh;padding:16px}
.creature-list{grid-template-columns:repeat(2,1fr)}
.move-list{grid-template-columns:repeat(2,1fr)}
.touch-pad,.touch-actions{display:grid}
.battle-ui{flex-direction:column-reverse;align-items:stretch}
.enemy-card,.player-card{margin:0;min-width:0}
}
</style>
</head>
<body>
<div id="stage">
<canvas id="game" width="1280" height="720"></canvas>
<div id="ui">
<div id="hud" class="hud hidden">
<div class="chip pill"><span class="dot"></span><span id="hud-zone">Cinder Glades</span></div>
<div class="chip"><b id="hud-party">1</b>/6 party</div>
<div class="chip"><b id="hud-coins">0</b> aether</div>
<div class="chip"><b id="hud-time">—</b></div>
</div>
<div id="pause-menu" class="hidden">
<button class="btn tiny" data-act="bag">Bag</button>
<button class="btn tiny" data-act="team">Team</button>
<button class="btn tiny" data-act="pedia">Codex</button>
<button class="btn tiny" data-act="map">Map</button>
<button class="btn tiny" data-act="save">Save</button>
<button class="btn tiny" data-act="mute">Mute</button>
<button class="btn tiny" data-act="quit">Title</button>
</div>
<div id="flavor" class="flavor hidden"></div>
<div id="touch-pad" class="touch-pad hidden">
<div class="tb" data-k="up">▲</div>
<div class="tb" data-k="left">◀</div>
<div class="tb" data-k="down">▼</div>
<div class="tb" data-k="right">▶</div>
<div class="tb" data-k="act">A</div>
<div class="tb" data-k="bag">B</div>
<div class="tb" data-k="cancel">×</div>
<div class="tb" data-k="enter">↵</div>
<div class="tb" data-k="pause">‖</div>
</div>
<div id="touch-actions" class="touch-actions hidden"></div>
</div>
<div id="title-screen" class="title-screen">
<div class="stars" id="title-stars"></div>
<h1>Aethelgard<span class="accent"> · </span>Veilbound</h1>
<div class="tag">A small RPG about collecting and being chosen</div>
<div class="stack">
<button class="btn primary" id="btn-new">New Binding</button>
<button class="btn" id="btn-cont">Continue</button>
</div>
<div class="lore">WASD / Arrows · Space · E · Esc</div>
</div>
</div>
<script>
'use strict';
/* ===== CONSTANTS ===== */
const T = { CINDER:'Cinder', TIDE:'Tide', VERDANT:'Verdant', VOLT:'Volt', TERRA:'Terra', LUMEN:'Lumen', UMBRAL:'Umbral' };
const TYPE_COLORS = {
Cinder:'#ff7a45', Tide:'#4cc9f0', Verdant:'#7ee07a', Volt:'#f5e042', Terra:'#c08a5a', Lumen:'#fff5d6', Umbral:'#7c5cff'
};
const TYPE_GLOW = {
Cinder:'#ffb88a', Tide:'#9be8ff', Verdant:'#c5f0a5', Volt:'#fff5a8', Terra:'#e8c79a', Lumen:'#fff8e7', Umbral:'#b6a4ff'
};
const TYPE_CHART = {
Cinder: { Tide:2, Verdant:0.5, Volt:0.5, Terra:1, Lumen:0.5, Umbral:1 },
Tide: { Verdant:2, Cinder:0.5, Volt:1, Terra:0.5, Lumen:0.5, Umbral:1 },
Verdant: { Cinder:2, Tide:0.5, Volt:1, Terra:0.5, Lumen:0.5, Umbral:1 },
Volt: { Cinder:2, Verdant:2, Terra:0.5, Tide:1, Lumen:0.5, Umbral:1 },
Terra: { Volt:2, Tide:2, Cinder:1, Verdant:1, Lumen:0.5, Umbral:1 },
Lumen: { Cinder:1.5, Tide:1.5, Verdant:1.5, Volt:1.5, Terra:1.5, Umbral:2 },
Umbral: { Cinder:0.5, Tide:0.5, Verdant:0.5, Volt:0.5, Terra:0.5, Lumen:2 }
};
const TYPE_LIST = ['Cinder','Tide','Verdant','Volt','Terra','Lumen','Umbral'];
const TILE = 40;
const MAX_PARTY = 6;
const MAX_LEVEL = 50;
/* ===== RNG ===== */
let _seed = (Date.now() & 0xffffffff) >>> 0;
function srand(s){ _seed = s>>>0; }
function rnd(){ // mulberry32
_seed |= 0; _seed = (_seed + 0x6D2B79F5) | 0;
let t = Math.imul(_seed ^ (_seed >>> 15), 1 | _seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
function rint(a,b){ return Math.floor(rnd()*(b-a+1))+a; }
function pick(arr){ return arr[Math.floor(rnd()*arr.length)]; }
function chance(p){ return rnd() < p; }
function clamp(v,a,b){ return v<a?a:v>b?b:v; }
function lerp(a,b,t){ return a + (b-a)*t; }
/* ===== AUDIO ===== */
const Audio = (() => {
let ctx = null, master = null, musicGain = null, sfxGain = null;
let muted = false, musicNodes = [], musicTimer = 0, musicOn = false;
function init(){
if(ctx) return;
try {
ctx = new (window.AudioContext||window.webkitAudioContext)();
master = ctx.createGain(); master.gain.value = 0.5; master.connect(ctx.destination);
musicGain = ctx.createGain(); musicGain.gain.value = 0.18; musicGain.connect(master);
sfxGain = ctx.createGain(); sfxGain.gain.value = 0.6; sfxGain.connect(master);
} catch(e){ console.warn('Audio init failed',e); }
}
function ensure(){ if(ctx && ctx.state==='suspended') ctx.resume(); }
function setMuted(m){ muted=m; if(musicGain) musicGain.gain.value = m?0:0.18; }
function tone(freq, dur, type='sine', vol=0.3, slide=0){
if(muted||!ctx) return;
ensure();
const t = ctx.currentTime;
const o = ctx.createOscillator(); o.type = type; o.frequency.value = freq;
if(slide) o.frequency.setValueAtTime(freq, t); o.frequency.linearRampToValueAtTime(freq+slide, t+dur);
const g = ctx.createGain(); g.gain.value = 0; g.gain.linearRampToValueAtTime(vol, t+0.01); g.gain.exponentialRampToValueAtTime(0.0001, t+dur);
o.connect(g); g.connect(sfxGain); o.start(t); o.stop(t+dur+0.02);
}
function noise(dur, vol=0.2, hp=400){
if(muted||!ctx) return;
ensure();
const t = ctx.currentTime;
const buf = ctx.createBuffer(1, Math.floor(ctx.sampleRate*dur), ctx.sampleRate);
const d = buf.getChannelData(0);
for(let i=0;i<d.length;i++) d[i] = (rnd()*2-1) * (1 - i/d.length);
const src = ctx.createBufferSource(); src.buffer = buf;
const g = ctx.createGain(); g.gain.value = vol;
const f = ctx.createBiquadFilter(); f.type='highpass'; f.frequency.value = hp;
src.connect(f); f.connect(g); g.connect(sfxGain);
src.start(t);
}
const sfx = {
select(){ tone(660,0.06,'square',0.18); tone(880,0.06,'square',0.18,200); },
confirm(){ tone(523,0.08,'triangle',0.22); setTimeout(()=>tone(784,0.10,'triangle',0.22),60); },
back(){ tone(440,0.08,'triangle',0.18,-80); },
hit(){ noise(0.12,0.18,300); tone(180,0.18,'square',0.2,-80); },
crit(){ tone(900,0.1,'square',0.25,300); setTimeout(()=>tone(1300,0.18,'square',0.25,-200),50); },
heal(){ tone(523,0.1,'sine',0.18); setTimeout(()=>tone(659,0.12,'sine',0.18),80); setTimeout(()=>tone(784,0.18,'sine',0.18),160); },
fire(){ noise(0.2,0.18,200); tone(420,0.18,'sawtooth',0.18,80); },
water(){ tone(660,0.18,'sine',0.16,-200); },
nature(){ tone(523,0.1,'triangle',0.16); setTimeout(()=>tone(659,0.12,'triangle',0.16),60); },
volt(){ tone(1100,0.08,'square',0.2); setTimeout(()=>tone(880,0.06,'square',0.18),40); noise(0.08,0.12,800); },
terra(){ tone(140,0.18,'square',0.22,40); },
lumen(){ tone(880,0.18,'sine',0.16,300); setTimeout(()=>tone(1175,0.2,'sine',0.16),100); },
umbral(){ tone(220,0.22,'sine',0.16,-60); setTimeout(()=>tone(165,0.22,'sine',0.16),80); },
superEff(){ tone(660,0.08,'square',0.22); setTimeout(()=>tone(880,0.08,'square',0.22),60); setTimeout(()=>tone(1320,0.14,'square',0.22),120); },
notEff(){ tone(330,0.12,'triangle',0.16,-40); },
capture(){ tone(523,0.1,'sine',0.18); setTimeout(()=>tone(784,0.1,'sine',0.18),80); setTimeout(()=>tone(1046,0.2,'sine',0.18),160); },
escape(){ tone(440,0.1,'sine',0.18,-80); },
level(){ tone(523,0.08,'square',0.2); setTimeout(()=>tone(659,0.08,'square',0.2),70); setTimeout(()=>tone(784,0.08,'square',0.2),140); setTimeout(()=>tone(1046,0.2,'square',0.2),210); },
shrine(){ tone(523,0.18,'sine',0.18); setTimeout(()=>tone(659,0.18,'sine',0.18),120); setTimeout(()=>tone(784,0.22,'sine',0.18),240); }
};
/* simple chiptune loop */
const loops = {
overworld: { tempo:120, lead:[
[523,1],[659,1],[784,1],[1046,2],[784,1],[659,1],[523,2],
[587,1],[698,1],[880,1],[1046,2],[880,1],[698,1],[587,2]
], bass:[
[131,2],[165,2],[196,2],[165,2],[131,2],[165,2],[196,2],[165,2]
]},
battle: { tempo:160, lead:[
[392,0.5],[440,0.5],[494,0.5],[523,1],[392,0.5],[440,0.5],[494,0.5],[523,1],
[466,0.5],[440,0.5],[392,0.5],[370,1],[466,0.5],[440,0.5],[392,0.5],[370,1]
], bass:[
[98,1],[98,1],[98,1],[98,1],[98,1],[98,1],[98,1],[98,1]
]},
shrine: { tempo:80, lead:[
[392,2],[523,2],[587,2],[659,4],
[523,2],[587,2],[659,2],[784,4]
], bass:[
[131,4],[165,4],[196,4],[165,4]
]}
};
let curLoop = null, loopIdx = 0, loopStep = 0;
function playLoop(name){
if(musicOn && curLoop === name) return;
curLoop = name; loopIdx = 0; loopStep = 0; musicOn = !!name;
if(!name) return;
if(muted) return;
scheduleMusic();
}
function scheduleMusic(){
if(!musicOn || !curLoop || !ctx) return;
const L = loops[curLoop]; if(!L) return;
const beat = 60 / L.tempo;
// schedule a few beats ahead
const lookahead = 0.3;
while(musicTimer < ctx.currentTime + lookahead){
const stepBeat = loopStep % L.lead.length;
const l = L.lead[stepBeat];
const dur = l[1] * beat;
const o = ctx.createOscillator(); o.type='square'; o.frequency.value = l[0];
const g = ctx.createGain(); g.gain.value = 0; g.gain.linearRampToValueAtTime(0.07, musicTimer+0.01); g.gain.linearRampToValueAtTime(0, musicTimer+dur-0.02);
o.connect(g); g.connect(musicGain);
o.start(musicTimer); o.stop(musicTimer+dur);
// bass
if(loopStep % 2 === 0 && L.bass){
const b = L.bass[(loopStep/2) % L.bass.length];
const bdur = b[1] * beat;
const bo = ctx.createOscillator(); bo.type='triangle'; bo.frequency.value = b[0];
const bg = ctx.createGain(); bg.gain.value = 0; bg.gain.linearRampToValueAtTime(0.10, musicTimer+0.01); bg.gain.linearRampToValueAtTime(0, musicTimer+bdur-0.05);
bo.connect(bg); bg.connect(musicGain);
bo.start(musicTimer); bo.stop(musicTimer+bdur);
}
musicTimer += dur;
loopStep++;
}
setTimeout(scheduleMusic, 250);
}
function startMusic(name){ playLoop(name); }
function stopMusic(){ curLoop = null; musicOn = false; }
return { init, setMuted, sfx, startMusic, stopMusic, get muted(){return muted;} };
})();
/* ===== ABILITIES ===== */
/* power, accuracy, cost(PP), affinity, effect { kind, chance, ...params } */
const ABILITIES = {
// Cinder
emberFlick: { name:'Ember Flick', type:T.CINDER, power:35, acc:95, pp:18, effect:{} },
cinderJab: { name:'Cinder Jab', type:T.CINDER, power:50, acc:90, pp:12, effect:{} },
flameGlyph: { name:'Flame Glyph', type:T.CINDER, power:55, acc:100, pp:10, effect:{kind:'burn',chance:25} },
pyreRing: { name:'Pyre Ring', type:T.CINDER, power:60, acc:85, pp:8, effect:{kind:'burn',chance:40} },
phoenixDrift: { name:'Phoenix Drift', type:T.CINDER, power:75, acc:90, pp:6, effect:{kind:'recoil',pct:25} },
flareLash: { name:'Flare Lash', type:T.CINDER, power:85, acc:85, pp:5, effect:{kind:'burn',chance:30} },
// Tide
rippleToss: { name:'Ripple Toss', type:T.TIDE, power:35, acc:95, pp:18, effect:{} },
brineLash: { name:'Brine Lash', type:T.TIDE, power:50, acc:90, pp:12, effect:{} },
tideSurge: { name:'Tide Surge', type:T.TIDE, power:55, acc:100, pp:10, effect:{kind:'frost',chance:20} },
crushingDepth:{ name:'Crushing Depth',type:T.TIDE, power:70, acc:85, pp:8, effect:{kind:'frost',chance:35} },
riptide: { name:'Riptide', type:T.TIDE, power:60, acc:100, pp:8, effect:{kind:'multihit',hits:2} },
maelstrom: { name:'Maelstrom', type:T.TIDE, power:85, acc:80, pp:5, effect:{kind:'frost',chance:50} },
// Verdant
leafCut: { name:'Leaf Cut', type:T.VERDANT, power:35, acc:95, pp:18, effect:{} },
vineSnap: { name:'Vine Snap', type:T.VERDANT, power:50, acc:90, pp:12, effect:{} },
thornVolley: { name:'Thorn Volley', type:T.VERDANT, power:55, acc:100, pp:10, effect:{kind:'multihit',hits:2} },
rootHold: { name:'Root Hold', type:T.VERDANT, power:50, acc:85, pp:8, effect:{kind:'speed',chance:100,delta:-2} },
heartOfGrove: { name:'Heart of Grove',type:T.VERDANT, power:0, acc:100, pp:6, effect:{kind:'heal',pct:50} },
briarStorm: { name:'Briar Storm', type:T.VERDANT, power:85, acc:85, pp:5, effect:{kind:'speed',chance:30,delta:-1} },
// Volt
spark: { name:'Spark', type:T.VOLT, power:35, acc:95, pp:18, effect:{} },
joltingArc: { name:'Jolting Arc', type:T.VOLT, power:50, acc:90, pp:12, effect:{kind:'shock',chance:20} },
skybreak: { name:'Skybreak', type:T.VOLT, power:55, acc:100, pp:10, effect:{kind:'shock',chance:30} },
voltBurst: { name:'Volt Burst', type:T.VOLT, power:60, acc:85, pp:8, effect:{kind:'shock',chance:40} },
stormCrown: { name:'Storm Crown', type:T.VOLT, power:75, acc:90, pp:6, effect:{kind:'priority'} },
thunderPike: { name:'Thunder Pike', type:T.VOLT, power:85, acc:85, pp:5, effect:{kind:'shock',chance:50} },
// Terra
stoneThrow: { name:'Stone Throw', type:T.TERRA, power:35, acc:95, pp:18, effect:{} },
dustKick: { name:'Dust Kick', type:T.TERRA, power:50, acc:90, pp:12, effect:{} },
earthenGuard: { name:'Earthen Guard', type:T.TERRA, power:0, acc:100, pp:10, effect:{kind:'barrier'} },
crumble: { name:'Crumble', type:T.TERRA, power:55, acc:100, pp:10, effect:{kind:'defDown',chance:30,delta:-1} },
quakeStep: { name:'Quake Step', type:T.TERRA, power:70, acc:85, pp:8, effect:{kind:'defDown',chance:50,delta:-1} },
tectonicLash: { name:'Tectonic Lash', type:T.TERRA, power:85, acc:80, pp:5, effect:{kind:'recoil',pct:25} },
// Lumen
gleamPulse: { name:'Gleam Pulse', type:T.LUMEN, power:40, acc:100, pp:18, effect:{} },
dawnspear: { name:'Dawnspear', type:T.LUMEN, power:55, acc:95, pp:12, effect:{} },
solace: { name:'Solace', type:T.LUMEN, power:0, acc:100, pp:8, effect:{kind:'heal',pct:40} },
radiantVeil: { name:'Radiant Veil', type:T.LUMEN, power:0, acc:100, pp:8, effect:{kind:'regen',turns:4} },
halo: { name:'Halo', type:T.LUMEN, power:60, acc:100, pp:10, effect:{kind:'heal',pct:25} },
solarCrown: { name:'Solar Crown', type:T.LUMEN, power:85, acc:85, pp:5, effect:{kind:'heal',pct:25} },
// Umbral
shadowTap: { name:'Shadow Tap', type:T.UMBRAL, power:35, acc:100, pp:18, effect:{} },
gloomCut: { name:'Gloom Cut', type:T.UMBRAL, power:50, acc:95, pp:12, effect:{} },
voidstep: { name:'Voidstep', type:T.UMBRAL, power:0, acc:100, pp:8, effect:{kind:'priority'} },
hexSiphon: { name:'Hex Siphon', type:T.UMBRAL, power:55, acc:100, pp:10, effect:{kind:'lifesteal',pct:50} },
eclipse: { name:'Eclipse', type:T.UMBRAL, power:75, acc:90, pp:6, effect:{kind:'defDown',chance:30,delta:-1} },
nightfall: { name:'Nightfall', type:T.UMBRAL, power:85, acc:85, pp:5, effect:{kind:'speed',chance:30,delta:-2} },
// Universal
toughen: { name:'Toughen', type:null, power:0, acc:100, pp:12, effect:{kind:'defUp',chance:100,delta:1} },
focusRush: { name:'Focus Rush', type:null, power:0, acc:100, pp:10, effect:{kind:'spdUp',chance:100,delta:1} },
bulkUp: { name:'Bulk Up', type:null, power:0, acc:100, pp:8, effect:{kind:'atkUp',chance:100,delta:1} },
meditate: { name:'Meditate', type:null, power:0, acc:100, pp:6, effect:{kind:'heal',pct:33} },
broadside: { name:'Broadside', type:null, power:55, acc:100, pp:10, effect:{kind:'multihit',hits:2} },
pierceThrow: { name:'Pierce Throw', type:null, power:55, acc:95, pp:10, effect:{kind:'pierce'} }
};
const ABIL_LIST = Object.keys(ABILITIES);
const STATUS_EFFECT_KINDS = new Set(['burn','frost','shock','barrier','regen']);
/* ===== CREATURES ===== */
/* baseStats: [hp, atk, def, spd] */
/* type2 = optional secondary (very rare, only if needed) */
const CREATURES = {
/* Starters (zone 1) */
pyrlin: { name:'Pyrlin', type:T.CINDER, base:[42,52,40,55], learnset:[['emberFlick',1],['cinderJab',5],['flameGlyph',10],['toughen',15],['pyreRing',22],['focusRush',28],['flareLash',36],['phoenixDrift',44]], lore:'A ember-sprite born where a fallen star touched the Cinder Glades. Warm to the touch, but shy of strangers.', transform:{into:'infernox', atLevel:32, catalyst:'cinderStone'} },
aquave: { name:'Aquave', type:T.TIDE, base:[44,48,46,52], learnset:[['rippleToss',1],['brineLash',5],['tideSurge',10],['toughen',15],['crushingDepth',22],['bulkUp',28],['riptide',36],['maelstrom',44]], lore:'A droplet of the First Sea given eyes and curiosity. It hums lullabies to tidepools at dusk.', transform:null },
leafkin: { name:'Leafkin', type:T.VERDANT, base:[46,50,48,50], learnset:[['leafCut',1],['vineSnap',5],['rootHold',10],['meditate',15],['thornVolley',22],['toughen',28],['heartOfGrove',36],['briarStorm',44]], lore:'A seedling that sprouted from a lullaby sung into loam. Its leaves chime when wind passes through.', transform:null },
/* Zone 1: Cinder Glades */
emberpup: { name:'Emberpup', type:T.CINDER, base:[40,54,38,55], learnset:[['spark',1],['emberFlick',6],['cinderJab',12],['flameGlyph',20],['pyreRing',30],['focusRush',40]], lore:'Packs of Emberpups race through the Glades, leaving brief comet-trails in the dry grass.', transform:{into:'cindermaw', atLevel:34, catalyst:'cinderStone'} },
/* Zone 2: Tideglass Coves */
coralimp: { name:'Coralimp', type:T.TIDE, base:[38,46,52,46], learnset:[['rippleToss',1],['brineLash',6],['tideSurge',12],['thornVolley',18],['crushingDepth',28],['toughen',38]], lore:'Built by patient tides, the Coralimp watches from reef-windows with a single violet eye.', transform:null },
frostfin: { name:'Frostfin', type:T.TIDE, base:[44,52,42,52], learnset:[['rippleToss',1],['brineLash',6],['tideSurge',12],['riptide',20],['crushingDepth',30],['frostbiteCharm',38]], lore:'Where cold currents meet warm shallows, Frostfin glides like a ribbon of silver dawn.', transform:null },
/* Zone 3: Verdant Thicket */
bramblin: { name:'Bramblin', type:T.VERDANT, base:[48,50,52,42], learnset:[['leafCut',1],['vineSnap',6],['rootHold',12],['broadside',18],['briarStorm',28],['bulkUp',38]], lore:'Bramblin appear where old paths are forgotten. They remember every footstep, even yours.', transform:{into:'wildheart', atLevel:34, catalyst:'verdantMoss'} },
vinewhip: { name:'Vinewhip', type:T.VERDANT, type2:T.LUMEN, base:[42,54,40,58], learnset:[['vineSnap',1],['leafCut',6],['gleamPulse',12],['focusRush',18],['briarStorm',30],['radiantVeil',40]], lore:'Coiled around moonlit trellises, the Vinewhip measures the worth of passing travelers.', transform:null },
/* Zone 4: Volt Spires */
sparkat: { name:'Sparkat', type:T.VOLT, base:[36,48,38,64], learnset:[['spark',1],['joltingArc',6],['skybreak',12],['focusRush',18],['voltBurst',28],['thunderPike',40]], lore:'A cat with a crown of static. It naps atop spires, raining tiny sparks into clouds.', transform:{into:'stormfang', atLevel:32, catalyst:'voltCoil'} },
boltwing: { name:'Boltwing', type:T.VOLT, type2:T.LUMEN, base:[42,46,40,68], learnset:[['spark',1],['joltingArc',6],['skybreak',12],['gleamPulse',18],['stormCrown',28],['solarCrown',40]], lore:'Said to carry messages between the Spires. Its feathers leave a glow on the morning wind.', transform:null },
zapling: { name:'Zapling', type:T.VOLT, base:[40,46,44,50], learnset:[['spark',1],['joltingArc',6],['skybreak',12],['toughen',18],['voltBurst',28],['thunderPike',40]], lore:'A pond-frog that swallowed a thunder-lozenge. Its croak startles the rain.', transform:null },
/* Zone 5: Terra Crags */
pebblit: { name:'Pebblit', type:T.TERRA, base:[50,46,60,30], learnset:[['stoneThrow',1],['dustKick',6],['earthenGuard',12],['crumble',18],['toughen',28],['tectonicLash',40]], lore:'A pile of small stones that decided, one morning, to be a creature. Quiet, but firm.', transform:{into:'boulderon', atLevel:34, catalyst:'terraShard'} },
dustmite: { name:'Dustmite', type:T.TERRA, base:[36,52,38,62], learnset:[['dustKick',1],['stoneThrow',6],['crumble',12],['focusRush',18],['quakeStep',28],['tectonicLash',40]], lore:'Drifts across Crags in spirals, polishing bones it finds. It whistles an old mining song.', transform:null },
moleling: { name:'Moleling', type:T.TERRA, type2:T.UMBRAL, base:[48,48,52,40], learnset:[['stoneThrow',1],['dustKick',6],['shadowTap',12],['earthenGuard',18],['hexSiphon',28],['tectonicLash',40]], lore:'A burrowing thing that knows the names of every root beneath the Crags.', transform:null },
/* Zone 6: Lumen Sanctum */
glintling: { name:'Glintling', type:T.LUMEN, base:[40,52,40,60], learnset:[['gleamPulse',1],['dawnspear',6],['solace',12],['halo',18],['radiantVeil',28],['solarCrown',40]], lore:'A mote of pure dawn. It cannot lie, but it loves telling the truth with a smile.', transform:{into:'radiantkin', atLevel:32, catalyst:'lumenPrism'} },
lumibun: { name:'Lumibun', type:T.LUMEN, type2:T.VERDANT, base:[44,46,46,56], learnset:[['gleamPulse',1],['leafCut',6],['dawnspear',12],['meditate',18],['halo',28],['heartOfGrove',40]], lore:'A rabbit of soft starlight that mends broken pathways as it hops.', transform:null },
shadeling: { name:'Shadeling', type:T.UMBRAL, base:[40,52,38,60], learnset:[['shadowTap',1],['gloomCut',6],['voidstep',12],['hexSiphon',18],['eclipse',28],['nightfall',40]], lore:'Not a creature, exactly — more a question the dusk asks itself. The answer is gentle.', transform:{into:'voidwalker', atLevel:34, catalyst:'umbralAsh'} },
pyrelisk: { name:'Pyrelisk', type:T.CINDER, type2:T.UMBRAL, base:[46,58,42,48], learnset:[['emberFlick',1],['gloomCut',6],['cinderJab',12],['flameGlyph',18],['eclipse',28],['phoenixDrift',40]], lore:'A basilisk hatched from a coal that fell into a forgotten shrine. Its gaze is a long memory.', transform:null },
/* Hidden / post-game */
duskwraith: { name:'Duskwraith', type:T.UMBRAL, type2:T.LUMEN, base:[48,56,44,52], learnset:[['shadowTap',1],['gleamPulse',6],['eclipse',12],['halo',18],['hexSiphon',28],['solarCrown',40]], lore:'Found only where day and night argue. It walks the seam without choosing a side.', transform:null },
/* Transformed forms */
infernox: { name:'Infernox', type:T.CINDER, base:[68,84,70,80], learnset:[['flareLash',1],['phoenixDrift',1],['solarCrown',1],['toughen',20],['bulkUp',35]], lore:'Pyrlin reborn in a corona of starfire. It remembers the meadow, but no longer fits inside it.', transform:null },
cindermaw: { name:'Cindermaw', type:T.CINDER, base:[66,86,66,82], learnset:[['flareLash',1],['phoenixDrift',1],['tectonicLash',1],['focusRush',20],['bulkUp',35]], lore:'Once a pup, now a furnace with a fang. It weeps sparks when the wind is right.', transform:null },
wildheart: { name:'Wildheart', type:T.VERDANT, base:[74,76,82,68], learnset:[['briarStorm',1],['heartOfGrove',1],['tectonicLash',1],['toughen',20],['bulkUp',35]], lore:'The forest\'s memory given roots and hunger. It will outlast the trees.', transform:null },
stormfang: { name:'Stormfang', type:T.VOLT, base:[60,76,60,98], learnset:[['thunderPike',1],['stormCrown',1],['flareLash',1],['focusRush',20],['bulkUp',35]], lore:'A bolt with teeth. The sky pauses when Stormfang yawns.', transform:null },
boulderon: { name:'Boulderon', type:T.TERRA, base:[86,72,98,38], learnset:[['tectonicLash',1],['earthenGuard',1],['solarCrown',1],['toughen',20],['bulkUp',35]], lore:'A walking hill. Birds mistake its shoulders for fresh land.', transform:null },
radiantkin: { name:'Radiantkin', type:T.LUMEN, base:[64,80,68,90], learnset:[['solarCrown',1],['halo',1],['flareLash',1],['focusRush',20],['toughen',35]], lore:'A small sun with kind eyes. It hums the song the dawn first learned.', transform:null },
voidwalker: { name:'Voidwalker', type:T.UMBRAL, base:[62,82,62,90], learnset:[['nightfall',1],['eclipse',1],['voidstep',1],['focusRush',20],['bulkUp',35]], lore:'Where the Voidwalker stands, the world politely steps aside.', transform:null },
tidecaller: { name:'Tidecaller', type:T.TIDE, base:[66,70,72,80], learnset:[['maelstrom',1],['riptide',1],['solarCrown',1],['focusRush',20],['bulkUp',35]], lore:'Aquave grown into the ocean\'s memory. Where it walks, the sea follows a step behind.', transform:null },
stormbeak: { name:'Stormbeak', type:T.VOLT, type2:T.LUMEN, base:[62,70,64,96], learnset:[['thunderPike',1],['stormCrown',1],['solarCrown',1],['focusRush',20],['bulkUp',35]], lore:'Boltwing with the storm settled in its bones. It carries messages between dawns now.', transform:null }
};
const CREAT_LIST = Object.keys(CREATURES);
const STARTER_IDS = ['pyrlin','aquave','leafkin'];
/* ===== ITEMS ===== */
const ITEMS = {
wispOrb: { name:'Wisp Orb', desc:'A glass bead holding a tame ember. Use in battle to attempt a collection.', kind:'capture', power:1.0, price:50 },
gleamOrb: { name:'Gleam Orb', desc:'A silvered sphere that hums when near a creature. Improves collection odds.', kind:'capture', power:1.5, price:200 },
lattice: { name:'Aetheric Lattice', desc:'A lattice of woven starlight. Triples collection power — for the worthy.', kind:'capture', power:3.0, price:1000 },
potion: { name:'Moonpetal Tonic', desc:'Restores 40 HP to a creature.', kind:'heal', power:40, price:80 },
superPotion:{ name:'Sunroot Drop', desc:'Fully restores one creature\'s HP and clears minor ailments.', kind:'healFull', price:300 },
revive: { name:'Dawnfeather', desc:'Revives a fainted creature to half HP.', kind:'revive', power:0.5, price:400 },
cinderStone:{ name:'Cinder Stone', desc:'A petrified ember. Required to catalyze some transformations.', kind:'catalyst', price:600 },
verdantMoss:{ name:'Verdant Moss', desc:'Soft moss that hums of the forest\'s first breath.', kind:'catalyst', price:600 },
voltCoil: { name:'Volt Coil', desc:'A wire of captured lightning, neatly coiled.', kind:'catalyst', price:600 },
terraShard: { name:'Terra Shard', desc:'A fragment of the first mountain.', kind:'catalyst', price:600 },
lumenPrism: { name:'Lumen Prism', desc:'A prism that splits a single dawn into a small rainbow.', kind:'catalyst', price:800 },
umbralAsh: { name:'Umbral Ash', desc:'Cold ash that remembers the shape of what burned.', kind:'catalyst', price:800 },
repel: { name:'Repel Charm', desc:'For a short time, weaker wilds avoid you.', kind:'repel', turns:200, price:200 }
};
const ITEM_LIST = Object.keys(ITEMS);
/* ===== ZONES ===== */
/* Each zone: id, name, palette, encounter table (creature ids with weights), boss, ambient (particle params), connections (unlock to next) */
const ZONES = {
glades: {
id:'glades', name:'Cinder Glades', order:1,
gradient:['#4a1a08','#1a0a05'], accent:'#ff7a45',
ambient:{kind:'ember', count:30, colors:['#ff7a45','#ffb070','#ffd97a']},
wilds:[
{id:'pyrlin', w:30}, {id:'aquave', w:15}, {id:'leafkin', w:15},
{id:'emberpup', w:30}, {id:'vinewhip', w:10}
],
boss:{ id:'pyrelisk', level:14, story:1 },
rate: 0.18, minLv:2, maxLv:8,
next:'cove',
storyNode:'A wagon of broken starlight, half-buried in warm ash. The Veil is thin here.'
},
cove: {
id:'cove', name:'Tideglass Coves', order:2,
gradient:['#0e2a4a','#06121f'], accent:'#4cc9f0',
ambient:{kind:'bubble', count:24, colors:['#9be8ff','#4cc9f0','#fff']},
wilds:[
{id:'aquave', w:25}, {id:'pyrlin', w:10}, {id:'coralimp', w:30},
{id:'frostfin', w:20}, {id:'leafkin', w:15}
],
boss:{ id:'frostfin', level:22, story:2, evolve:true }, /* a titan frostfin */
rate: 0.18, minLv:10, maxLv:18,
next:'thicket',
storyNode:'Bells made of glass sing under the water, calling the lost home.'
},
thicket: {
id:'thicket', name:'Verdant Thicket', order:3,
gradient:['#0e2a14','#061a0a'], accent:'#7ee07a',
ambient:{kind:'spore', count:22, colors:['#c5f0a5','#7ee07a','#fff5d6']},
wilds:[
{id:'leafkin', w:25}, {id:'bramblin', w:30}, {id:'vinewhip', w:15},
{id:'coralimp', w:10}, {id:'frostfin', w:10}, {id:'aquave', w:10}
],
boss:{ id:'wildheart', level:30, story:3, boss:false }, /* a wild bramblin transform */
rate: 0.18, minLv:18, maxLv:26,
next:'spires',
storyNode:'A tree older than names, asking the wind to remember it tomorrow.'
},
spires: {
id:'spires', name:'Volt Spires', order:4,
gradient:['#1a1a30','#0a0a1a'], accent:'#f5e042',
ambient:{kind:'spark', count:30, colors:['#fff5a8','#f5e042','#fff']},
wilds:[
{id:'sparkat', w:30}, {id:'boltwing', w:20}, {id:'zapling', w:20},
{id:'vinewhip', w:10}, {id:'pebblit', w:10}, {id:'bramblin', w:10}
],
boss:{ id:'stormfang', level:38, story:3, boss:false },
rate: 0.20, minLv:26, maxLv:34,
next:'crags',
storyNode:'The Spires hum a chord older than weather. Birds refuse to land here.'
},
crags: {
id:'crags', name:'Terra Crags', order:5,
gradient:['#2a1a0a','#0a0805'], accent:'#c08a5a',
ambient:{kind:'dust', count:20, colors:['#e8c79a','#c08a5a','#fff5d6']},
wilds:[
{id:'pebblit', w:30}, {id:'dustmite', w:25}, {id:'moleling', w:15},
{id:'sparkat', w:10}, {id:'zapling', w:10}, {id:'emberpup', w:10}
],
boss:{ id:'boulderon', level:44, story:4 },
rate: 0.20, minLv:32, maxLv:42,
next:'sanctum',
storyNode:'A door carved from a single mountain. It only opens for those who have come on foot.'
},
sanctum: {
id:'sanctum', name:'Lumen Sanctum', order:6,
gradient:['#1a1430','#08051a'], accent:'#fff5d6',
ambient:{kind:'dust', count:36, colors:['#fff5d6','#ecd9ff','#fff']},
wilds:[
{id:'glintling', w:25}, {id:'lumibun', w:20}, {id:'shadeling', w:20},
{id:'duskwraith', w:10}, {id:'pyrelisk', w:10}, {id:'boltwing', w:10}, {id:'moleling', w:5}
],
boss:{ id:'voidwalker', level:50, story:4, final:true },
rate: 0.22, minLv:38, maxLv:50,
next:null,
storyNode:'The first dawn lives here, patient as a held breath. Bring it gently home.'
}
};
const ZONE_LIST = ['glades','cove','thicket','spires','crags','sanctum'];
/* ===== STAT CALC ===== */
function statFor(creatId, level, statIdx){
const c = CREATURES[creatId];
if(!c) return 1;
const base = c.base[statIdx];
const atkBonus = (statIdx===0) ? 4 : (statIdx===3) ? 1.6 : 1.4;
return Math.floor(base + (level-1) * atkBonus);
}
function makeBattleCreature(creatId, level){
const c = CREATURES[creatId];
const maxHp = statFor(creatId, level, 0);
const moves = c.learnset.filter(([k,lv])=>lv<=level).map(([k])=>k).slice(0,4);
if(!moves.length) moves.push(c.learnset[0][0]);
const abilityPp = Object.fromEntries(Object.keys(ABILITIES).map(k=>[k,0]));
for(const m of moves) abilityPp[m] = ABILITIES[m].pp;
return {
id: creatId, name:c.name, type:c.type, type2:c.type2||null,
level, exp:0, expToNext: expToNext(level),
maxHp, hp: maxHp,
atk: statFor(creatId, level, 1),
def: statFor(creatId, level, 2),
spd: statFor(creatId, level, 3),
abilityPp,
moves,
transforms: c.transform,
status: {},
atkMod: 0, defMod: 0, spdMod: 0, accMod: 0,
shielded: false, hasFled:false, faint:false
};
}
function expToNext(level){ return Math.floor(8 + level*5 + Math.pow(level,1.4)*1.5); }
function refreshPp(bc){
const learnset = CREATURES[bc.id].learnset;
bc.moves.forEach(mv => { bc.abilityPp[mv] = ABILITIES[mv].pp; });
}
function levelUp(bc){
if(bc.level >= MAX_LEVEL) return false;
bc.level++;
const newMax = statFor(bc.id, bc.level, 0);
const oldMax = bc.maxHp;
bc.maxHp = newMax;
bc.hp = Math.min(bc.maxHp, bc.hp + (newMax - oldMax));
bc.atk = statFor(bc.id, bc.level, 1);
bc.def = statFor(bc.id, bc.level, 2);
bc.spd = statFor(bc.id, bc.level, 3);
bc.expToNext = expToNext(bc.level);
// learn moves
const learnset = CREATURES[bc.id].learnset;
for(const [k,lv] of learnset){
if(lv === bc.level){
if(bc.moves.length < 4){
bc.moves.push(k); refreshPp(bc);
Game.notify(bc.name + ' learned ' + ABILITIES[k].name + '!');
} else {
Game.offerMoveReplace(bc, k);
break;
}
}
}
return true;
}
function learnableAt(creatId, level){
return CREATURES[creatId].learnset.filter(([k,lv])=>lv===level).map(([k])=>k);
}
/* ===== MAPS (tile arrays) ===== */
/* Legend: . ground, # wall, ~ water, , tall grass (encounters), S shrine, N npc, G guardian/boss, E exit (forward), R retreat, P player start */
const MAPS = {
glades: [
"################################",
"#P..........#......,~,,,......E#",
"#...........#......,~~~,,.....#",
"#..##.##....#.....,,,~~~,......#",
"#...........#....,,,,,~~......#",
"#....NN.....#..................#",
"#...........#....,,,~~~,,......#",
"#..,,,~~~...#....,~~~~~,.......#",
"#..,~~~,...##....,~~,,,........#",
"#..,,,.....##..................#",
"#...........#....SS............#",
"#....G......#....S.............#",
"#...........#....S.............#",
"#...........#..................#",
"################################"
],
cove: [
"################################",
"#P..............#...E..........#",
"#..~~~~~~~~~~~~.#..............#",
"#..~~~~..~~~~...#..............#",
"#..~~~~..~~~~...#....,~~~~~,...#",
"#........~~~~...#....,~~~~~,...#",
"#..~~~~..~~~~...#....,~~~~~,...#",
"#..~~~~..~~~~NN.#....,~~~~~,...#",
"#..~~~~..~~~~...#..............#",
"#..~~~~..~~~~...#....SS........#",
"#..~~~~..~~~~...#....S.........#",
"#........~~~~...#....S.........#",
"#..~~~~~~~~~~~~.#....G.........#",
"#...............#..............#",
"################################"
],
thicket: [
"################################",
"#P..........#........E.........#",
"#..##.##....#........##.##....#",
"#...........#........##.##....#",
"#....NN.....#........##.##....#",
"#...........#........##.##....#",
"#..,,,~~~...#........##.##....#",
"#..,~~~,...##........##.##....#",
"#..,,,.....##..................#",
"#...........#........##.##....#",
"#....SS.....#........##.##....#",
"#....S......#........##.##....#",
"#....S......#........G.........#",
"#...........#..................#",
"################################"
],
spires: [
"################################",
"#P...........#...E.............#",
"#..##.##....##..........,,,....#",
"#...........#..........,~~~,...#",
"#..NN.......#..........,,,....#",
"#...........#..........,~~~...#",
"#..,,,~~~...#..........,,,....#",
"#..,~~~,...##..........,~~~...#",
"#..,,,.....##..........,,,....#",
"#...........#..........,~~~...#",
"#....SS.....#..........,,,....#",
"#....S......#..........,~~~...#",
"#....S......#..........G.......#",
"#...........#.................#",
"################################"
],
crags: [
"################################",
"#P..........#....E.............#",
"#..##.##....#..........~~......#",
"#...........#..........~~......#",
"#..NN.......#..........~~......#",
"#...........#..........~~......#",
"#..,,,~~~...#..........~~......#",
"#..,~~~,...##..........~~......#",
"#..,,,.....##..........~~......#",
"#...........#..........~~......#",
"#....SS.....#..........~~......#",
"#....S......#..........~~......#",
"#....S......#..........G.......#",
"#...........#..........~~......#",
"################################"
],
sanctum: [
"################################",
"#P..........#....E.............#",
"#..##.##....#..................#",
"#...........#..................#",
"#..NN.......#..................#",
"#...........#..................#",
"#..,,,~~~...#..................#",
"#..,~~~,...##..................#",
"#..,,,.....##..................#",
"#...........#..................#",
"#....SS.....#..................#",
"#....S......#..................#",
"#....S......#..................#",
"#...........#....G.............#",
"################################"
]
};
/* ===== STORY ===== */
const STORY = {
1:{ who:'A Voice in the Ash', what:'A whisper from a broken star says: "Carry them gently. They are learning your name."', color:'#3a0e08' },
2:{ who:'A Voice in the Tide', what:'The sea hums back, "The Veil is thinned where you walk. You are making a path that will remember you."', color:'#0a2440' },
3:{ who:'A Voice in the Thicket', what:'The forest exhales. "Eight will choose you. Four will hold the world steady. Be patient at the door."', color:'#0a2814' },
4:{ who:'A Voice in the Dawn', what:'The Sanctum brightens. "Bring the first dawn home. It has been waiting since the morning we lost."', color:'#241240' }
};
/* ===== GAME STATE ===== */
const Game = {
state:'title', /* title | overworld | battle | dialog | menu | tutorial */
player: { x:1, y:1, dir:0, walkT:0, stepPhase:0, moveAccum:0 },
zone: 'glades',
party: [], /* active party up to 6 */
storage: [], /* boxed creatures */
codex: {}, /* id -> {seen:true, caught:true} */
bag: { potion:5, wispOrb:8, superPotion:1, revive:0, gleamOrb:0, lattice:0 },
bagKinds: ['wispOrb','gleamOrb','lattice','potion','superPotion','revive','cinderStone','verdantMoss','voltCoil','terraShard','lumenPrism','umbralAsh','repel'],
coins: 0,
repelCount: 0,
stepsInGrass: 0,
tutorialStep: 0,
tutorialFlag: false,
flags: {}, /* quest flags */
storyStage: 0,
bossDefeated: {}, /* zoneId -> true */
zoneUnlocked: { glades:true },
shrineSpawn: 'glades',
playTime: 0, /* seconds */
startTime: 0,
showPause: false,
modal: null, /* current modal name */
pendingDialog: null, /* queue of {who, what, color, onClose} */
encounterCooldown: 0,
shake: 0,
paused: false,
toasts: [],
init(){
this.bindUI();
this.bindInput();
this.start();
},
start(){
document.getElementById('title-screen')?.remove();
showTitle();
},
notify(msg, t=1800){
const el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
document.getElementById('ui').appendChild(el);
setTimeout(()=>el.remove(), t+400);
},
offerMoveReplace(bc, newKey){ /* called via dialog if 4 moves already known */
this.pendingDialog = {
who: bc.name,
what: 'Wants to learn ' + ABILITIES[newKey].name + '. Replace which move?',
choices: bc.moves.map((m,i)=>({label:'Forget ' + ABILITIES[m].name, onPick:()=>{ bc.moves[i] = newKey; refreshPp(bc); this.notify('Forgot ' + ABILITIES[m].name + '!'); this.advanceDialog(); }}))
};
this.showDialog();
},
advanceDialog(){ this.pendingDialog.choices ? this.showDialog() : this.closeDialog(); }
};
window.Game = Game;
/* ===== CANVAS / RENDER ===== */
const canvas = document.getElementById('game');
const ctx2d = canvas.getContext('2d');
let VW = 1280, VH = 720;
function resize(){
const r = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio||1, 2);
canvas.width = Math.floor(r.width*dpr);
canvas.height = Math.floor(r.height*dpr);
VW = r.width; VH = r.height;
ctx2d.setTransform(dpr,0,0,dpr,0,0);
ctx2d.imageSmoothingEnabled = false;
}
window.addEventListener('resize', resize);
resize();
/* ===== PARTICLES ===== */
const Particles = {
list: [],
spawn(x,y,opts){
const o = Object.assign({count:6,color:'#fff',life:0.4,size:2,spd:60,grav:0,spread:Math.PI*2}, opts||{});
for(let i=0;i<o.count;i++){
const a = o.spread===Math.PI*2 ? rnd()*o.spread : o.spread[0] + rnd()*(o.spread[1]-o.spread[0]);
this.list.push({
x,y,
vx: Math.cos(a)*(o.spd*(0.5+rnd()*0.6)),
vy: Math.sin(a)*(o.spd*(0.5+rnd()*0.6)),
life:o.life*(0.6+rnd()*0.6), max:o.life,
color:o.color, size:o.size*(0.6+rnd()*0.6), grav:o.grav
});
}
},
ambient:[],
setAmbient(zone){
this.ambient = [];
const z = ZONES[zone];
if(!z) return;
const am = z.ambient;
for(let i=0;i<am.count;i++){
this.ambient.push({
x: rnd()*VW, y: rnd()*VH,
vx: (rnd()-0.5)*8, vy: -4 - rnd()*8,
life: 4 + rnd()*4, max: 4+rnd()*4,
color: pick(am.colors), size: 1+rnd()*2,
kind: am.kind
});
}
},
updateAmbient(dt){
for(const p of this.ambient){
p.x += p.vx*dt; p.y += p.vy*dt;
p.life -= dt;
if(p.life<=0 || p.y<-10){
p.x = rnd()*VW; p.y = VH + rnd()*30;
p.vx = (rnd()-0.5)*8; p.vy = -4 - rnd()*8;
p.life = p.max; p.color = pick(ZONES[Game.zone].ambient.colors);
}
}
},
update(dt){
this.updateAmbient(dt);
for(let i=this.list.length-1;i>=0;i--){
const p = this.list[i];
p.x += p.vx*dt; p.y += p.vy*dt;
p.vy += (p.grav||0)*dt;
p.life -= dt;
if(p.life<=0) this.list.splice(i,1);
}
},
draw(ctx){
for(const p of this.ambient){
const a = clamp(p.life/p.max, 0, 1);
ctx.globalAlpha = a*0.6;
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(p.x,p.y,p.size,0,Math.PI*2); ctx.fill();
}
for(const p of this.list){
const a = clamp(p.life/p.max, 0, 1);
ctx.globalAlpha = a;
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(p.x,p.y,p.size*a,0,Math.PI*2); ctx.fill();
}
ctx.globalAlpha = 1;
}
};
/* ===== SAVE / LOAD ===== */
const SAVE_KEY = 'aethelgard_veilbound_save_v1';
function saveGame(slot=0){
try {
const data = {
v:1, ts:Date.now(),
player: Game.player, zone: Game.zone,
party: Game.party, storage: Game.storage,
codex: Game.codex, bag: Game.bag, coins: Game.coins,
repelCount: Game.repelCount,
flags: Game.flags, storyStage: Game.storyStage,
bossDefeated: Game.bossDefeated, zoneUnlocked: Game.zoneUnlocked,
shrineSpawn: Game.shrineSpawn, playTime: Game.playTime,
tutorialFlag: Game.tutorialFlag
};
localStorage.setItem(SAVE_KEY + '_' + slot, JSON.stringify(data));
return true;
} catch(e){ console.warn('Save failed',e); return false; }
}
function loadGame(slot=0){
try {
const raw = localStorage.getItem(SAVE_KEY + '_' + slot);
if(!raw) return null;
const data = JSON.parse(raw);
Object.assign(Game.player, data.player);
Game.zone = data.zone;
Game.party = data.party; Game.storage = data.storage;
Game.codex = data.codex; Game.bag = data.bag;
Game.coins = data.coins; Game.repelCount = data.repelCount||0;
Game.flags = data.flags||{}; Game.storyStage = data.storyStage||0;
Game.bossDefeated = data.bossDefeated||{};
Game.zoneUnlocked = data.zoneUnlocked||{glades:true};
Game.shrineSpawn = data.shrineSpawn||'glades';
Game.playTime = data.playTime||0;
Game.tutorialFlag = !!data.tutorialFlag;
return data;
} catch(e){ console.warn('Load failed',e); return null; }
}
function hasSave(){ for(let i=0;i<3;i++) if(localStorage.getItem(SAVE_KEY+'_'+i)) return true; return false; }
/* ===== INPUT ===== */
const Input = {
keys: {},
pressed: {}, /* single-frame press */
bind(){
window.addEventListener('keydown', e => {
Audio.init();
const k = e.key.toLowerCase();
if(!this.keys[k]) this.pressed[k] = true;
this.keys[k] = true;
if(['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k)) e.preventDefault();
});
window.addEventListener('keyup', e => { this.keys[e.key.toLowerCase()] = false; });
/* touch buttons */
document.querySelectorAll('#touch-pad .tb').forEach(el => {
const k = el.dataset.k;
const map = {up:'arrowup',down:'arrowdown',left:'arrowleft',right:'arrowright',act:'e',bag:'b',cancel:'escape',enter:'enter',pause:'escape'};
const key = map[k] || k;
const down = e => { e.preventDefault(); Audio.init(); if(!this.keys[key]) this.pressed[key] = true; this.keys[key] = true; };
const up = e => { e.preventDefault(); this.keys[key] = false; };
el.addEventListener('touchstart', down, {passive:false});
el.addEventListener('mousedown', down);
el.addEventListener('touchend', up, {passive:false});
el.addEventListener('mouseup', up);
el.addEventListener('touchcancel', up, {passive:false});
el.addEventListener('mouseleave', up);
});
/* click on canvas to start audio */
canvas.addEventListener('pointerdown', ()=>{ Audio.init(); });
},
consume(k){ const v = !!this.pressed[k]; this.pressed[k] = false; return v; },
isDown(k){ return !!this.keys[k]; }
};
Input.bind();
/* ===== MAP HELPERS ===== */
function getMap(zoneId){ return MAPS[zoneId]; }
function tileAt(map, x, y){
if(y<0||x<0||y>=map.length||x>=map[0].length) return '#';
return map[y][x];
}
function findFirst(map, ch){
for(let y=0;y<map.length;y++) for(let x=0;x<map[y].length;x++) if(map[y][x]===ch) return {x,y};
return null;
}
/* ===== OVERWORLD RENDER ===== */
function drawOverworld(dt){
const zone = ZONES[Game.zone];
const map = getMap(Game.zone);
/* background gradient */
const grad = ctx2d.createLinearGradient(0,0,0,VH);
grad.addColorStop(0, zone.gradient[0]);
grad.addColorStop(1, zone.gradient[1]);
ctx2d.fillStyle = grad;
ctx2d.fillRect(0,0,VW,VH);
/* soft radial accent */
const rg = ctx2d.createRadialGradient(VW/2,VH/2,40,VW/2,VH/2,Math.max(VW,VH)*0.7);
rg.addColorStop(0, zone.accent + '22');
rg.addColorStop(1, '#00000000');
ctx2d.fillStyle = rg; ctx2d.fillRect(0,0,VW,VH);
/* map area: 32x15 grid, 40px each, centered */
const mapW = map[0].length * TILE, mapH = map.length * TILE;
const ox = Math.floor((VW-mapW)/2), oy = Math.floor((VH-mapH)/2) + 20;
/* draw tiles */
for(let y=0;y<map.length;y++){
for(let x=0;x<map[y].length;x++){
const ch = map[y][x];
const px = ox + x*TILE, py = oy + y*TILE;
drawTile(ch, px, py, x, y, zone, map);
}
}
/* draw entities */
for(let y=0;y<map.length;y++){
for(let x=0;x<map[y].length;x++){
const ch = map[y][x];
const px = ox + x*TILE + TILE/2, py = oy + y*TILE + TILE/2;
if(ch==='N') drawNPC(px, py, zone);
else if(ch==='S') drawShrine(px, py, zone);
else if(ch==='G') drawGuardian(px, py, zone);
else if(ch==='E') drawExit(px, py, zone, x, y, map);
}
}
/* draw player */
const ppx = ox + Game.player.x*TILE + TILE/2;
const ppy = oy + Game.player.y*TILE + TILE/2;
drawPlayer(ppx, ppy, Game.player.dir, Game.player.stepPhase, Game.zone);
/* particles on top */
Particles.draw(ctx2d);
/* vignette */
ctx2d.fillStyle = 'rgba(0,0,0,0.25)';
ctx2d.fillRect(0,0,VW,40);
ctx2d.fillRect(0,VH-40,VW,40);
/* zone name in bottom strip */
ctx2d.fillStyle = 'rgba(0,0,0,0.5)';
ctx2d.fillRect(0, VH-40, VW, 40);
ctx2d.fillStyle = '#eef2ff';
ctx2d.font = '12px ui-sans-serif, system-ui';
ctx2d.textAlign = 'left';
ctx2d.fillText(zone.name, 14, VH-15);
ctx2d.textAlign = 'right';
if(Game.flags.tutorialActive){
ctx2d.fillStyle = '#ffd97a';
ctx2d.fillText('Tutorial: ' + (TUTORIAL[Game.tutorialStep]?.hint || ''), VW-14, VH-15);
} else {
ctx2d.fillStyle = '#a3acd1';
ctx2d.fillText('WASD/Arrows to move · E to act · Esc to pause', VW-14, VH-15);
}
/* shake */
if(Game.shake > 0){
Game.shake -= dt;
}
/* store ox/oy for game logic */
Game._ox = ox; Game._oy = oy;
}
const TUTORIAL = [
{hint:'Use WASD or arrow keys to move.'},
{hint:'Walk into tall grass to find wild creatures.'},
{hint:'In battle, choose a move. Press E or Space to confirm.'},
{hint:'Try capturing with a Wisp Orb after weakening a creature.'}
];
function drawTile(ch, x, y, tx, ty, zone, map){
const base = zone.accent;
const ctx = ctx2d;
if(ch === '#'){
/* wall: layered stone */
const g = ctx.createLinearGradient(x,y,x,y+TILE);
g.addColorStop(0, '#0a0a14');
g.addColorStop(1, '#1a1a26');
ctx.fillStyle = g; ctx.fillRect(x,y,TILE,TILE);
/* brick lines */
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
ctx.strokeRect(x+0.5,y+0.5,TILE-1,TILE-1);
ctx.beginPath();
ctx.moveTo(x, y+TILE/2); ctx.lineTo(x+TILE, y+TILE/2);
ctx.moveTo(x+TILE/2, y); ctx.lineTo(x+TILE/2, y+TILE/2);
ctx.moveTo(x+TILE/4, y+TILE/2); ctx.lineTo(x+TILE/4, y+TILE);
ctx.moveTo(x+3*TILE/4, y+TILE/2); ctx.lineTo(x+3*TILE/4, y+TILE);
ctx.stroke();
/* top highlight */
ctx.fillStyle = 'rgba(255,255,255,0.05)';
ctx.fillRect(x, y, TILE, 2);
}
else if(ch === '.'){
/* ground */
const g = ctx.createRadialGradient(x+TILE/2,y+TILE/2,4,x+TILE/2,y+TILE/2,TILE);
g.addColorStop(0, shade(base, 0.18));
g.addColorStop(1, shade(base, -0.32));
ctx.fillStyle = g; ctx.fillRect(x,y,TILE,TILE);
/* speckle */
if(((tx*7+ty*13) % 5) === 0){
ctx.fillStyle = 'rgba(255,255,255,0.05)';
ctx.fillRect(x+8+(tx*5)%24, y+6+(ty*7)%28, 2, 2);
}
}
else if(ch === ','){
/* tall grass */
const g = ctx.createLinearGradient(x,y,x,y+TILE);
g.addColorStop(0, shade(base, 0.35));
g.addColorStop(1, shade(base, -0.15));
ctx.fillStyle = g; ctx.fillRect(x,y,TILE,TILE);
/* blades */
ctx.fillStyle = shade(base, 0.55);
for(let i=0;i<6;i++){
const bx = x + 4 + (i*7 + tx) % (TILE-8);
const by = y + 16 + (i*5 + ty) % 14;
ctx.beginPath();
ctx.moveTo(bx, by+12); ctx.lineTo(bx+2, by); ctx.lineTo(bx+4, by+12); ctx.closePath();
ctx.fill();
}
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fillRect(x, y, TILE, 1);
}
else if(ch === '~'){
/* water */
const g = ctx.createLinearGradient(x,y,x,y+TILE);
g.addColorStop(0, shade(base, 0.30));
g.addColorStop(1, shade(base, -0.20));
ctx.fillStyle = g; ctx.fillRect(x,y,TILE,TILE);
/* ripples */
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1;
const t = (performance.now()/1000 + tx*0.4 + ty*0.2);
ctx.beginPath();
ctx.arc(x+TILE/2, y+TILE/2, 8 + Math.sin(t)*2, 0, Math.PI*2);
ctx.stroke();
}
else if(ch === 'S'){
/* shrine floor */
const g = ctx.createRadialGradient(x+TILE/2,y+TILE/2,2,x+TILE/2,y+TILE/2,TILE);
g.addColorStop(0, '#fff5d6');
g.addColorStop(1, shade(base, -0.4));
ctx.fillStyle = g; ctx.fillRect(x,y,TILE,TILE);
ctx.fillStyle = 'rgba(255,245,200,0.15)';
ctx.fillRect(x,y,TILE,TILE);
}
else if(ch === 'E'){
/* exit: dark portal pad */
const g = ctx.createRadialGradient(x+TILE/2,y+TILE/2,2,x+TILE/2,y+TILE/2,TILE/2);
g.addColorStop(0, '#fff5d6');
g.addColorStop(0.6, '#c9a4ff');
g.addColorStop(1, '#0a0a1a');
ctx.fillStyle = g; ctx.fillRect(x,y,TILE,TILE);
}
}
function shade(hex, amt){
/* hex like #rrggbb, amt in -1..1 */
const c = hex.replace('#','');
const r = parseInt(c.substr(0,2),16), g = parseInt(c.substr(2,2),16), b = parseInt(c.substr(4,2),16);
const f = amt<0 ? 1+amt : 1;
const a = Math.abs(amt);
const nr = Math.max(0,Math.min(255, amt<0 ? r*f : r + (255-r)*a));
const ng = Math.max(0,Math.min(255, amt<0 ? g*f : g + (255-g)*a));
const nb = Math.max(0,Math.min(255, amt<0 ? b*f : b + (255-b)*a));
return '#' + [nr,ng,nb].map(v=>Math.floor(v).toString(16).padStart(2,'0')).join('');
}
function drawPlayer(cx, cy, dir, stepPhase, zone){
const ctx = ctx2d;
ctx.save();
ctx.translate(cx, cy);
/* shadow */
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.beginPath(); ctx.ellipse(0, 14, 12, 4, 0, 0, Math.PI*2); ctx.fill();
/* bob */
const bob = Math.sin(stepPhase*Math.PI*2) * 1.5;
ctx.translate(0, bob);
/* cloak body */
ctx.fillStyle = '#3a2a4a';
ctx.beginPath();
ctx.moveTo(-10, 14);
ctx.lineTo(-12, 2);
ctx.lineTo(-6, -8);
ctx.lineTo(6, -8);
ctx.lineTo(12, 2);
ctx.lineTo(10, 14);
ctx.closePath(); ctx.fill();
/* cloak highlight */
ctx.fillStyle = '#5a3a6a';
ctx.beginPath();
ctx.moveTo(-6, -8);
ctx.lineTo(0, 0);
ctx.lineTo(6, -8);
ctx.closePath(); ctx.fill();
/* head */
ctx.fillStyle = '#f0d8b0';
ctx.beginPath(); ctx.arc(0, -14, 7, 0, Math.PI*2); ctx.fill();
/* hood/hat */
ctx.fillStyle = '#2a1a3a';
ctx.beginPath();
ctx.moveTo(-8, -16);
ctx.quadraticCurveTo(0, -28, 8, -16);
ctx.lineTo(7, -10);
ctx.lineTo(-7, -10);
ctx.closePath(); ctx.fill();
/* eyes */
ctx.fillStyle = '#fff';
if(dir === 0){ ctx.fillRect(-4, -14, 2, 2); ctx.fillRect(2, -14, 2, 2); }
else if(dir === 2){ ctx.fillRect(-3, -14, 2, 2); ctx.fillRect(3, -14, 2, 2); }
else if(dir === 3){ ctx.fillRect(-4, -14, 2, 2); ctx.fillRect(3, -14, 2, 2); }
else { ctx.fillRect(-3, -14, 2, 2); ctx.fillRect(4, -14, 2, 2); }
/* staff: depending on dir, lean */
ctx.save();
ctx.rotate((dir-1)*0.15);
ctx.fillStyle = '#6a4a3a';
ctx.fillRect(11, -22, 2, 30);
ctx.fillStyle = '#7ee0d6';
ctx.beginPath(); ctx.arc(12, -24, 3, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = 'rgba(126,224,214,0.4)';
ctx.beginPath(); ctx.arc(12, -24, 6, 0, Math.PI*2); ctx.fill();
ctx.restore();
ctx.restore();
}
function drawNPC(cx, cy, zone){
const ctx = ctx2d;
ctx.save(); ctx.translate(cx, cy);
/* soft glow */
const g = ctx.createRadialGradient(0,0,1,0,0,28);
g.addColorStop(0, '#fff5d6'); g.addColorStop(1, '#fff5d600');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0,0,28,0,Math.PI*2); ctx.fill();
/* body */
ctx.fillStyle = '#5a3a2a';
ctx.beginPath();
ctx.moveTo(-10,14); ctx.lineTo(-12,2); ctx.lineTo(-6,-8); ctx.lineTo(6,-8); ctx.lineTo(12,2); ctx.lineTo(10,14); ctx.closePath();
ctx.fill();
/* face */
ctx.fillStyle = '#f0d8b0';
ctx.beginPath(); ctx.arc(0,-14,7,0,Math.PI*2); ctx.fill();
/* question mark */
ctx.fillStyle = '#fff5d6';
ctx.font = 'bold 14px ui-sans-serif';
ctx.textAlign = 'center';
ctx.fillText('?', 0, -28);
ctx.restore();
}
function drawShrine(cx, cy, zone){
const ctx = ctx2d;
const t = performance.now()/1000;
ctx.save(); ctx.translate(cx, cy);
/* pillar */
ctx.fillStyle = '#2a2a3a';
ctx.fillRect(-3, -8, 6, 18);
/* crystal */
const s = 5 + Math.sin(t*2)*1;
ctx.fillStyle = '#fff5d6';
ctx.beginPath();
ctx.moveTo(0, -8 - s);
ctx.lineTo(s, -8);
ctx.lineTo(0, -8 + s);
ctx.lineTo(-s, -8);
ctx.closePath(); ctx.fill();
/* glow */
const g = ctx.createRadialGradient(0, -8, 1, 0, -8, 18);
g.addColorStop(0, 'rgba(255,245,200,0.6)');
g.addColorStop(1, 'rgba(255,245,200,0)');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0,-8,18,0,Math.PI*2); ctx.fill();
/* base */
ctx.fillStyle = '#3a3a4a';
ctx.fillRect(-10, 10, 20, 3);
ctx.restore();
}
function drawGuardian(cx, cy, zone){
const ctx = ctx2d;
const t = performance.now()/1000;
ctx.save(); ctx.translate(cx, cy);
/* aura */
const a = 0.5 + Math.sin(t*1.5)*0.2;
const g = ctx.createRadialGradient(0,0,4,0,0,28);
g.addColorStop(0, 'rgba(255,80,80,'+a+')');
g.addColorStop(1, 'rgba(255,80,80,0)');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0,0,28,0,Math.PI*2); ctx.fill();
/* body */
ctx.fillStyle = '#2a0a0a';
ctx.beginPath();
ctx.moveTo(-12,14); ctx.lineTo(-14,2); ctx.lineTo(-8,-10); ctx.lineTo(8,-10); ctx.lineTo(14,2); ctx.lineTo(12,14); ctx.closePath();
ctx.fill();
/* eyes */
ctx.fillStyle = '#ff5050';
ctx.shadowColor = '#ff5050'; ctx.shadowBlur = 8;
ctx.fillRect(-5, -4, 3, 3); ctx.fillRect(2, -4, 3, 3);
ctx.shadowBlur = 0;
ctx.restore();
}
function drawExit(cx, cy, zone, tx, ty, map){
const ctx = ctx2d;
const t = performance.now()/1000;
ctx.save(); ctx.translate(cx, cy);
const r = 12 + Math.sin(t*2)*2;
const g = ctx.createRadialGradient(0,0,1,0,0,r);
g.addColorStop(0, 'rgba(255,245,200,0.6)');
g.addColorStop(0.5, 'rgba(201,164,255,0.3)');
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0,0,r,0,Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff5d6';
ctx.beginPath(); ctx.arc(0,0,4,0,Math.PI*2); ctx.fill();
/* arrow */
ctx.strokeStyle = '#fff5d6';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(-6, 0); ctx.lineTo(6, 0);
ctx.moveTo(3, -3); ctx.lineTo(6, 0); ctx.lineTo(3, 3);
ctx.stroke();
ctx.restore();
}
/* ===== OVERWORLD UPDATE ===== */
function updateOverworld(dt){
if(Game.paused) return;
if(Game.modal) return;
/* decrement encounter cooldown */
if(Game.encounterCooldown > 0) Game.encounterCooldown -= dt;
if(Game.repelCount > 0) Game.repelCount -= dt;
/* movement */
let dx=0, dy=0;
if(Input.isDown('arrowup') || Input.isDown('w')){ dy=-1; Game.player.dir=0; }
else if(Input.isDown('arrowdown') || Input.isDown('s')){ dy=1; Game.player.dir=2; }
else if(Input.isDown('arrowleft') || Input.isDown('a')){ dx=-1; Game.player.dir=3; }
else if(Input.isDown('arrowright') || Input.isDown('d')){ dx=1; Game.player.dir=1; }
const speed = 5; /* tiles per sec */
if(dx||dy){
Game.player.moveAccum += dt * speed;
const steps = Math.floor(Game.player.moveAccum);
Game.player.moveAccum -= steps;
Game.player.stepPhase = (Game.player.stepPhase + dt*speed) % 1;
let nx = Game.player.x, ny = Game.player.y;
for(let s=0; s<steps; s++){
const tx = nx+dx, ty = ny+dy;
if(canWalk(tx, ty)){
nx = tx; ny = ty;
onStep(nx, ny);
} else {
break;
}
}
Game.player.x = nx; Game.player.y = ny;
} else {
Game.player.stepPhase = 0;
}
/* action */
if(Input.consume('e') || Input.consume(' ')){
Audio.sfx.select();
tryInteract();
}
/* pause */
if(Input.consume('escape')){
openPause();
}
/* bag */
if(Input.consume('b')){
openBag();
}
}
function canWalk(x, y){
const map = getMap(Game.zone);
const t = tileAt(map, x, y);
return t !== '#' && t !== '~';
}
function onStep(x, y){
const map = getMap(Game.zone);
const t = tileAt(map, x, y);
if(t === ','){
Game.stepsInGrass++;
if(Game.encounterCooldown <= 0 && Game.repelCount <= 0 && Game.party.length){
const zone = ZONES[Game.zone];
if(chance(zone.rate)){
triggerEncounter();
}
}
}
if(t === 'E'){
/* zone exit: handled via interaction (E to advance) */
}
}
function tryInteract(){
const {x, y} = Game.player;
const dirs = [[0,-1],[0,1],[-1,0],[1,0]];
for(const [dx,dy] of dirs){
const tx = x+dx, ty = y+dy;
if(tx<0||ty<0) continue;
const map = getMap(Game.zone);
const t = tileAt(map, tx, ty);
if(t === 'S'){ openShrine(); return; }
if(t === 'N'){ openNPC(tx, ty); return; }
if(t === 'G'){ openGuardian(tx, ty); return; }
if(t === 'E'){ advanceZone(); return; }
}
/* also: action on current tile */
const t = tileAt(getMap(Game.zone), x, y);
if(t === 'S'){ openShrine(); return; }
if(t === 'N'){ openNPC(x, y); return; }
if(t === 'G'){ openGuardian(x, y); return; }
if(t === 'E'){ advanceZone(); return; }
}
function advanceZone(){
const z = ZONES[Game.zone];
if(!z.next) return;
if(!Game.zoneUnlocked[z.next]){ Game.notify('A barrier of Veil-light holds you back. The Guardian of '+z.name+' must fall.'); return; }
Game.zone = z.next;
Game.player.x = 1; Game.player.y = 1;
Particles.setAmbient(Game.zone);
Audio.startMusic(z.id === 'sanctum' ? 'shrine' : 'overworld');
Game.notify('Entered '+ZONES[Game.zone].name+'!');
}
function openNPC(x, y){ showDialog({
who:'Wandering Veil-Keeper',
what: zoneTip(Game.zone),
onClose: ()=>{ Game.paused = false; }
}); Game.paused = true; }
function zoneTip(z){
switch(z){
case 'glades': return '"The Cinder Glades are warm at the knee and cool at the heart. Listen for the hum of stones."';
case 'cove': return '"The Coves will sing you a song. The first one is always a welcome, the second is a warning."';
case 'thicket': return '"Don\'t pull a vine. The vines pull back."';
case 'spires': return '"Don\'t chase the lightning. Walk under it instead."';
case 'crags': return '"A door in the mountain opens only once. Bring no fear."';
case 'sanctum': return '"The first dawn is patient. It has waited this long; it can wait for you."';
}
return '...';
}
function openShrine(){
/* heal party */
Audio.sfx.shrine();
let healed = 0;
for(const bc of Game.party){ if(bc.hp < bc.maxHp) healed += (bc.maxHp - bc.hp); bc.hp = bc.maxHp; bc.status = {}; bc.atkMod=0; bc.defMod=0; bc.spdMod=0; bc.accMod=0; }
Game.notify('Shrine restores the party.');
/* open shop + save prompt */
setTimeout(()=>{
openShop();
}, 600);
/* auto-save */
saveGame(0);
}
function openShop(){
const modal = document.createElement('div');
modal.className = 'modal-back';
modal.innerHTML = `<div class="modal" style="max-width:520px">
<h2>Shrine of the Veil</h2>
<div class="sub">Rest, purchase supplies, and continue your journey.</div>
<div class="body">
<div class="chip pill"><b>${Game.coins}</b> aether</div>
<div class="divider"></div>
<div class="small">You can buy consumables, capture items, and catalysts here.</div>
<div id="shop-list" class="col gap-8"></div>
</div>
<div class="foot">
<button class="btn tiny" data-act="save">Save</button>
<button class="btn primary" data-act="close">Continue</button>
</div>
</div>`;
document.getElementById('ui').appendChild(modal);
const list = modal.querySelector('#shop-list');
const offerings = [
{id:'potion', qty:1, price:60},
{id:'superPotion', qty:1, price:240},
{id:'revive', qty:1, price:300},
{id:'wispOrb', qty:1, price:40},
{id:'gleamOrb', qty:1, price:160},
{id:'lattice', qty:1, price:800},
{id:'repel', qty:1, price:160},
{id:'cinderStone', qty:1, price:500},
{id:'verdantMoss', qty:1, price:500},
{id:'voltCoil', qty:1, price:500},
{id:'terraShard', qty:1, price:500},
{id:'lumenPrism', qty:1, price:700},
{id:'umbralAsh', qty:1, price:700}
];
for(const o of offerings){
const it = ITEMS[o.id];
const row = document.createElement('div');
row.className = 'row';
row.style.justifyContent = 'space-between';
row.style.background = 'rgba(255,255,255,0.04)';
row.style.border = '1px solid var(--line)';
row.style.borderRadius = '10px';
row.style.padding = '6px 10px';
row.innerHTML = `<div><b>${it.name}</b><div class="small">${it.desc}</div></div>
<div class="row gap-8"><span class="small">${o.price} aether</span><button class="btn tiny">Buy</button></div>`;
row.querySelector('button').addEventListener('click', ()=>{
if(Game.coins < o.price){ Audio.sfx.notEff?.(); Game.notify('Not enough aether.'); return; }
Game.coins -= o.price;
Game.bag[o.id] = (Game.bag[o.id]||0) + 1;
Audio.sfx.confirm();
Game.notify('Acquired ' + it.name + '.');
modal.querySelector('.chip b').textContent = Game.coins;
});
list.appendChild(row);
}
modal.addEventListener('click', e => {
const a = e.target.dataset?.act;
if(a === 'close'){ Audio.sfx.confirm(); modal.remove(); Game.paused = false; }
if(a === 'save'){ saveGame(0); Game.notify('Game saved.'); Audio.sfx.confirm(); }
});
Game.paused = true;
}
function openGuardian(x, y){
const z = ZONES[Game.zone];
Audio.startMusic('battle');
Game.state = 'battle';
startBattle({ boss:true, zone:Game.zone, story:z.boss.story });
}
function triggerEncounter(){
const zone = ZONES[Game.zone];
if(!zone.wilds.length) return;
/* weighted pick */
const tot = zone.wilds.reduce((s,w)=>s+w.w,0);
let r = rnd()*tot, picked = zone.wilds[0].id;
for(const w of zone.wilds){ if(r < w.w){ picked = w.id; break; } r -= w.w; }
Game.encounterCooldown = 1.2;
Game.state = 'battle';
Audio.startMusic('battle');
const lvl = rint(zone.minLv, zone.maxLv);
startBattle({ wild:true, creatures:[{id:picked, level:lvl}], zone:Game.zone });
}
/* ===== BATTLE ===== */
const Battle = {
enemies: [],
playerActive: 0,
turnQueue: [],
turnIndex: 0,
busy: false,
log: [],
phase: 'choose', /* choose | execute | between | capture | over | switch */
result: null,
pending: null,
captureTarget: null,
bubbleText: '',
bubbleTimer: 0,
shake: 0,
flash: null,
damageNumbers: [],
switchMode: false,
crit: false,
vignetteColor: null,
vignetteTimer: 0,
start(opts){
this.enemies = [];
this.playerActive = Game.party.findIndex(c=>c.hp>0);
if(this.playerActive<0) this.playerActive = 0;
if(opts.boss){
const z = ZONES[opts.zone];
const id = z.boss.id;
const lvl = z.boss.level;
const bc = makeBattleCreature(id, lvl);
bc.boss = true;
bc.maxHp = Math.floor(bc.maxHp * 1.2);
bc.hp = bc.maxHp;
bc.atk = Math.floor(bc.atk * 1.15);
bc.def = Math.floor(bc.def * 1.1);
this.enemies.push(bc);
this.bossStory = z.boss.story;
} else {
const count = chance(0.25) ? 2 : 1;
for(const w of opts.creatures){
const bc = makeBattleCreature(w.id, w.level);
this.enemies.push(bc);
if(this.enemies.length>=count) break;
}
if(!this.enemies.length) this.enemies.push(makeBattleCreature(opts.creatures[0].id, opts.creatures[0].level));
}
/* mark codex */
for(const e of this.enemies){
if(!Game.codex[e.id]) Game.codex[e.id] = {seen:true};
}
this.turnQueue = [];
for(let i=0;i<Game.party.length;i++) if(Game.party[i].hp>0) this.turnQueue.push({side:'p', idx:i});
for(let i=0;i<this.enemies.length;i++) if(this.enemies[i].hp>0) this.turnQueue.push({side:'e', idx:i});
this.turnQueue.sort((a,b) => {
const A = a.side==='p' ? Game.party[a.idx] : this.enemies[a.idx];
const B = b.side==='p' ? Game.party[b.idx] : this.enemies[b.idx];
return B.spd - A.spd;
});
this.turnIndex = 0;
this.busy = false;
this.phase = 'between';
this.bubbleText = 'A wild encounter!';
this.bubbleTimer = 1.2;
this.damageNumbers = [];
this.result = null;
this.captureTarget = null;
this.pending = null;
this.bossStory = null;
this._endPending = false;
},
activeCreature(){
const t = this.turnQueue[this.turnIndex];
if(!t) return null;
return t.side === 'p' ? Game.party[t.idx] : this.enemies[t.idx];
},
currentMove(){ return this.pending; },
setMove(idx){
const bc = Game.party[this.playerActive];
if(!bc || !bc.moves[idx]) return;
this.pending = bc.moves[idx];
this.bubbleText = bc.name + ' used ' + ABILITIES[this.pending].name + '?';
this.bubbleTimer = 0.6;
},
switchTo(idx){
if(idx<0||idx>=Game.party.length) return;
if(idx === this.playerActive){ this.phase='choose'; return; }
if(Game.party[idx].hp<=0){ Audio.sfx.notEff?.(); return; }
/* free switch */
Game.party[this.playerActive].atkMod=0; Game.party[this.playerActive].defMod=0; Game.party[this.playerActive].spdMod=0;
this.playerActive = idx;
this.bubbleText = 'Go, '+Game.party[idx].name+'!';
this.bubbleTimer = 0.8;
Audio.sfx.confirm();
this.phase='between'; this.busy=true;
this.pending = { switch:true, idx };
setTimeout(()=>this.executeTurn(), 500);
},
executeTurn(){
const t = this.turnQueue[this.turnIndex];
if(!t){ this.checkEnd(); return; }
if(t.side === 'e'){
/* enemy AI */
const e = this.enemies[t.idx];
if(!e || e.hp<=0){ this.nextTurn(); return; }
const target = this.pickEnemyTarget();
const move = this.chooseEnemyMove(e);
this.attack(e, target, move, ()=>this.nextTurn());
} else {
/* player turn: pending set by menu */
if(this.pending && this.pending.switch){
this.nextTurn();
} else if(this.pending){
const attacker = Game.party[this.playerActive];
if(!attacker || attacker.hp<=0){ this.nextTurn(); return; }
const move = this.pending;
const target = this.pickPlayerTarget(move);
if(!target){ this.phase='choose'; return; }
this.attack(attacker, target, move, ()=>this.nextTurn());
} else {
this.phase='choose';
}
}
},
chooseEnemyMove(e){
const learnset = CREATURES[e.id].learnset.map(([k])=>k).filter(k=>ABILITIES[k] && e.abilityPp[k]>0);
const choices = learnset.length ? learnset : (e.moves || []).filter(m=>e.abilityPp[m]>0);
if(!choices.length) return null;
return pick(choices);
},
pickEnemyTarget(){
const alive = Game.party.map((c,i)=>({c,i})).filter(o=>o.c.hp>0);
if(!alive.length) return null;
return alive[0].i;
},
pickPlayerTarget(move){
/* single-target damage: pick lowest HP% enemy */
const move_ = ABILITIES[move];
if(move_.power === 0) return this.playerActive; /* self */
const alive = this.enemies.map((c,i)=>({c,i})).filter(o=>o.c.hp>0);
if(!alive.length) return 0;
alive.sort((a,b)=>a.c.hp/a.c.maxHp - b.c.hp/b.c.maxHp);
return alive[0].i;
},
attack(attacker, targetIdx, moveKey, done){
this.phase='execute'; this.busy=true;
const move = ABILITIES[moveKey];
if(!move){ done(); return; }
const isEnemy = this.turnQueue[this.turnIndex].side==='e';
const target = isEnemy ? Game.party[targetIdx] : this.enemies[targetIdx];
if(!target || target.hp<=0){ done(); return; }
attacker.abilityPp[moveKey]--;
/* accuracy check */
if(move.acc < 100 && rnd()*100 > move.acc){
this.bubbleText = attacker.name + '\'s '+move.name+' missed!';
this.bubbleTimer = 1.0;
Audio.sfx.notEff?.();
setTimeout(done, 700);
return;
}
/* priority effects */
const isPriority = move.effect && move.effect.kind === 'priority';
/* play SFX */
if(move.type) Audio.sfx[move.type.toLowerCase()]?.();
/* type matchup */
let mult = 1;
if(move.type){
mult = (TYPE_CHART[move.type] && TYPE_CHART[move.type][target.type]) || 1;
if(target.type2 && TYPE_CHART[move.type] && TYPE_CHART[move.type][target.type2] !== undefined){
const m2 = TYPE_CHART[move.type][target.type2];
mult = m2 > mult ? m2 : (mult > m2 ? mult : mult); /* keep higher */
}
}
const stab = (move.type === attacker.type || (attacker.type2 && move.type === attacker.type2)) ? 1.25 : 1;
const crit = chance(0.0625) ? 1.5 : 1;
this.crit = crit>1;
/* modifiers */
let def = Math.max(1, target.def + (target.defMod||0)*2);
let atk = Math.max(1, attacker.atk + (attacker.atkMod||0)*2);
if(target.status?.frost) atk = Math.floor(atk*0.75);
if(attacker.status?.burn) atk = Math.floor(atk*0.5);
let variance = 0.85 + rnd()*0.30;
let pierce = move.effect?.kind === 'pierce' ? 0.6 : 1;
let dmg = (0.4 * attacker.level * atk / def * mult * stab * crit * variance * pierce);
if(move.power === 0) dmg = 0;
/* multi-hit */
const hits = (move.effect?.kind === 'multihit') ? move.effect.hits : 1;
let totalDmg = 0;
let i = 0;
const doHit = () => {
if(i>=hits || target.hp<=0){ afterAll(); return; }
const t = i;
const hitDmg = Math.max(1, Math.floor(dmg));
target.hp = Math.max(0, target.hp - hitDmg);
totalDmg += hitDmg;
this.damageNumbers.push({ x: 0, y:0, value:hitDmg, type: move.type, mult, crit: crit>1, t:1.0, side: isEnemy?'p':'e', target: targetIdx });
Audio.sfx.hit();
this.shake = 0.18;
if(crit>1 && t===0){ this.flash = 'crit'; Audio.sfx.crit(); }
if(mult>1 && t===0){ this.flash = 'hit'; }
if(mult<1 && t===0){ this.flash='hit'; }
i++;
setTimeout(doHit, 220);
};
const afterAll = () => {
/* status effects */
if(target.hp>0 && move.effect){
const e = move.effect;
if(e.kind === 'burn' && chance(e.chance/100)) this.applyStatus(target, 'burn');
if(e.kind === 'frost' && chance(e.chance/100)) this.applyStatus(target, 'frost');
if(e.kind === 'shock' && chance(e.chance/100)) this.applyStatus(target, 'shock');
if(e.kind === 'barrier') this.applyStatus(target, 'barrier');
if(e.kind === 'regen') this.applyStatus(target, 'regen', e.turns);
if(e.kind === 'heal'){
const amt = Math.floor(attacker.maxHp * e.pct/100);
attacker.hp = Math.min(attacker.maxHp, attacker.hp+amt);
this.bubbleText = attacker.name + ' restored '+amt+' HP!';
this.bubbleTimer = 1.0;
Audio.sfx.heal();
this.flash='heal';
}
if(e.kind === 'atkUp'){ attacker.atkMod += e.delta; this.bubbleText = attacker.name + "'s ATK rose!"; this.bubbleTimer=0.9; }
if(e.kind === 'defUp'){ target.defMod += e.delta; this.bubbleText = target.name + "'s DEF rose!"; this.bubbleTimer=0.9; }
if(e.kind === 'defDown'){ if(chance(e.chance/100)){ target.defMod += e.delta; this.bubbleText = target.name + "'s DEF fell!"; this.bubbleTimer=0.9; } }
if(e.kind === 'spdUp'){ attacker.spdMod += e.delta; this.bubbleText = attacker.name + "'s SPD rose!"; this.bubbleTimer=0.9; }
if(e.kind === 'speed' && e.delta<0){ if(chance(e.chance/100)){ target.spdMod += e.delta; this.bubbleText = target.name + "'s SPD fell!"; this.bubbleTimer=0.9; } }
if(e.kind === 'lifesteal'){
const heal = Math.floor(totalDmg * e.pct/100);
attacker.hp = Math.min(attacker.maxHp, attacker.hp+heal);
}
if(e.kind === 'recoil'){
const rec = Math.floor(totalDmg * e.pct/100);
attacker.hp = Math.max(1, attacker.hp - rec);
this.bubbleText = attacker.name + ' is hurt by recoil!';
this.bubbleTimer = 0.8;
}
}
setTimeout(()=>{ if(target.hp<=0) this.onFaint(target); done(); }, 350);
};
setTimeout(doHit, 200);
},
applyStatus(target, kind, turns=4){
target.status = target.status || {};
if(target.status[kind]) return;
target.status[kind] = turns;
const map = {burn:'burned', frost:'frozen', shock:'shocked', barrier:'raised a barrier', regen:'is regenerating'};
this.bubbleText = target.name + ' ' + (map[kind]||'was affected') + '!';
this.bubbleTimer = 0.9;
},
onFaint(target){
Audio.sfx.notEff?.();
Particles.spawn(0,0,{count:14, color:'#fff', life:0.6, size:3, spd:120, grav:80, spread:Math.PI*2});
},
endOfTurnStatuses(actor){
/* called at start of actor's turn */
if(!actor.status) return false;
let skip = false;
for(const k in actor.status){
if(k === 'burn'){
const d = Math.max(1, Math.floor(actor.maxHp/16));
actor.hp = Math.max(0, actor.hp - d);
this.bubbleText = actor.name + ' is hurt by burn!';
this.bubbleTimer = 0.8;
}
if(k === 'frost'){
if(chance(0.25)) skip = true;
this.bubbleText = actor.name + ' is frostbitten!';
this.bubbleTimer = 0.7;
}
if(k === 'shock'){
if(chance(0.30)){ skip = true; this.bubbleText = actor.name + ' is paralyzed!'; this.bubbleTimer=0.7; }
}
if(k === 'regen'){
const d = Math.max(1, Math.floor(actor.maxHp/12));
actor.hp = Math.min(actor.maxHp, actor.hp + d);
this.bubbleText = actor.name + ' regenerates!';
this.bubbleTimer = 0.7;
}
actor.status[k]--;
if(actor.status[k] <= 0) delete actor.status[k];
}
return skip;
},
nextTurn(){
this.turnIndex++;
if(this.turnIndex >= this.turnQueue.length){
/* round end: reset turn queue */
this.turnQueue = [];
for(let i=0;i<Game.party.length;i++) if(Game.party[i].hp>0) this.turnQueue.push({side:'p', idx:i});
for(let i=0;i<this.enemies.length;i++) if(this.enemies[i].hp>0) this.turnQueue.push({side:'e', idx:i});
this.turnQueue.sort((a,b) => {
const A = a.side==='p' ? Game.party[a.idx] : this.enemies[a.idx];
const B = b.side==='p' ? Game.party[b.idx] : this.enemies[b.idx];
return B.spd - A.spd;
});
this.turnIndex = 0;
}
if(this.checkEnd()) return;
const t = this.turnQueue[this.turnIndex];
if(!t) return;
const actor = t.side==='p' ? Game.party[t.idx] : this.enemies[t.idx];
if(!actor || actor.hp<=0){ this.nextTurn(); return; }
/* if barrier, set shielded */
actor.shielded = !!actor.status?.barrier;
if(actor.shielded && t.side==='p'){
this.bubbleText = actor.name + ' is shielded!';
this.bubbleTimer = 0.6;
}
if(t.side==='p'){
this.playerActive = t.idx;
const skip = this.endOfTurnStatuses(actor);
if(skip){ this.bubbleText = actor.name + ' can\'t move!'; this.bubbleTimer = 0.8; this.pending={skip:true}; this.phase='execute'; setTimeout(()=>this.nextTurn(), 800); return; }
this.phase='choose';
this.pending=null;
this.bubbleText = 'Choose an action.';
this.bubbleTimer = 0.5;
} else {
const skip = this.endOfTurnStatuses(actor);
if(skip){ this.pending={skip:true}; setTimeout(()=>this.nextTurn(), 800); return; }
this.executeTurn();
}
},
checkEnd(){
const pAlive = Game.party.some(c=>c.hp>0);
const eAlive = this.enemies.some(e=>e.hp>0);
if(!pAlive){ this.result='lose'; this.phase='over'; return true; }
if(!eAlive){ this.result='win'; this.phase='over'; return true; }
return false;
},
onBattleEnd(){
if(this.result === 'win'){
/* exp, capture chance, codex */
for(const e of this.enemies){
const expGain = Math.floor(20 + e.level*8 + (e.boss?50:0));
for(const bc of Game.party){ if(bc.hp>0){ bc.exp += expGain; let safety=20; while(bc.exp >= bc.expToNext && safety-->0) { bc.exp -= bc.expToNext; const ok = levelUp(bc); if(!ok) { bc.exp = 0; break; } Audio.sfx.level(); Game.notify(bc.name+' reached level '+bc.level+'!'); } } }
Game.coins += 5 + e.level*2 + (e.boss?100:0);
if(e.boss){
Game.bossDefeated[Game.zone] = true;
const z = ZONES[Game.zone];
if(z.next && !Game.zoneUnlocked[z.next]){
Game.zoneUnlocked[z.next] = true;
Game.notify(z.name+' is pacified. A path opens onward.');
if(z.boss.story) triggerStory(z.boss.story);
}
if(z.boss.final){
triggerStory(4);
setTimeout(()=>triggerFinale(), 800);
}
}
}
if(!this.wasWild){
/* triggered by story */
}
} else if(this.result === 'lose'){
/* wake at last shrine, half gold */
Game.coins = Math.floor(Game.coins/2);
Game.zone = Game.shrineSpawn;
const p = findFirst(getMap(Game.zone), 'P');
if(p){ Game.player.x = p.x; Game.player.y = p.y; }
else { Game.player.x = 1; Game.player.y = 1; }
for(const bc of Game.party){ bc.hp = bc.maxHp; bc.status = {}; }
Game.notify('Woke at the last shrine.');
}
Game.state = 'overworld';
Audio.startMusic(Game.zone === 'sanctum' ? 'shrine' : 'overworld');
Particles.setAmbient(Game.zone);
}
};
function startBattle(opts){
Battle.wasWild = !!opts.wild;
Battle.start(opts);
}
/* ===== BATTLE RENDER ===== */
function drawBattleScene(ctx, t){
const zone = ZONES[Game.zone];
/* background gradient */
const grad = ctx.createLinearGradient(0,0,0,VH);
grad.addColorStop(0, zone.gradient[0]);
grad.addColorStop(1, zone.gradient[1]);
ctx.fillStyle = grad;
ctx.fillRect(0,0,VW,VH);
/* soft ground bands */
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.beginPath();
ctx.ellipse(VW*0.75, VH*0.7, 200, 36, 0, 0, Math.PI*2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(VW*0.25, VH*0.85, 220, 40, 0, 0, Math.PI*2);
ctx.fill();
/* radial highlight */
const rg = ctx.createRadialGradient(VW*0.5, VH*0.4, 50, VW*0.5, VH*0.4, Math.max(VW,VH)*0.7);
rg.addColorStop(0, zone.accent + '33');
rg.addColorStop(1, '#00000000');
ctx.fillStyle = rg; ctx.fillRect(0,0,VW,VH);
/* draw enemies */
for(let i=0;i<Battle.enemies.length;i++){
const e = Battle.enemies[i];
if(e.hp<=0) continue;
const cx = VW*0.72 - (Battle.enemies.length-1)*60 + i*120;
const cy = VH*0.45;
drawCreatureSprite(ctx, e.id, cx, cy, 1.4, e.type, e.boss ? t*2 : t*1.2, e.hp/e.maxHp, e.boss);
}
/* draw player creature */
const pc = Game.party[Battle.playerActive];
if(pc && pc.hp>0){
const cx = VW*0.28, cy = VH*0.62;
drawCreatureSprite(ctx, pc.id, cx, cy, 1.3, pc.type, -t*1.2, pc.hp/pc.maxHp, false);
}
/* particles */
Particles.draw(ctx);
/* damage numbers */
for(let i=Battle.damageNumbers.length-1;i>=0;i--){
const d = Battle.damageNumbers[i];
d.t -= 0.025;
if(d.t<=0){ Battle.damageNumbers.splice(i,1); continue; }
const x = d.side==='e' ? VW*0.72 : VW*0.28;
const y = VH*0.45 + (1-d.t)*30;
ctx.globalAlpha = d.t;
ctx.font = 'bold ' + (d.crit?28:20) + 'px ui-sans-serif';
ctx.textAlign = 'center';
if(d.crit){
ctx.fillStyle = '#ffd97a';
ctx.strokeStyle = '#000'; ctx.lineWidth = 3;
ctx.strokeText(d.value + '!', x, y);
ctx.fillText(d.value + '!', x, y);
} else {
const c = d.mult > 1 ? '#7ee07a' : d.mult < 1 ? '#a3acd1' : '#fff';
ctx.fillStyle = c;
ctx.strokeStyle = '#000'; ctx.lineWidth = 2;
ctx.strokeText(d.value, x, y);
ctx.fillText(d.value, x, y);
}
ctx.globalAlpha = 1;
}
/* bubble text */
if(Battle.bubbleText && Battle.bubbleTimer > 0){
ctx.fillStyle = 'rgba(20,24,40,0.85)';
const w = ctx.measureText(Battle.bubbleText).width + 32;
ctx.fillStyle = 'rgba(20,24,40,0.92)';
roundRect(ctx, VW/2 - w/2, 14, w, 36, 10);
ctx.fillStyle = '#fff5d6';
ctx.font = '14px ui-sans-serif';
ctx.textAlign = 'center';
ctx.fillText(Battle.bubbleText, VW/2, 38);
}
/* crit flash */
if(Battle.flash === 'crit'){
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(0,0,VW,VH);
Battle.flash = null;
} else if(Battle.flash === 'hit'){
ctx.fillStyle = 'rgba(255,200,100,0.15)';
ctx.fillRect(0,0,VW,VH);
Battle.flash = null;
} else if(Battle.flash === 'heal'){
ctx.fillStyle = 'rgba(120,255,160,0.15)';
ctx.fillRect(0,0,VW,VH);
Battle.flash = null;
}
/* shake */
if(Battle.shake > 0){
Battle.shake -= 0.04;
}
/* cards */
drawBattleCards(ctx);
/* capture overlay */
if(Battle.phase === 'capture' || Battle.captureTarget){
drawCapture(ctx, t);
}
/* turn banner */
if(Battle.phase === 'between' && !Battle.busy){
/* hint */
}
}
function drawBattleCards(ctx){
/* enemy card */
for(let i=0;i<Battle.enemies.length;i++){
const e = Battle.enemies[i];
if(e.hp<=0) continue;
const x = VW*0.55 + i*100, y = 60;
ctx.fillStyle = 'rgba(20,24,40,0.7)';
roundRect(ctx, x, y, 200, 70, 10); ctx.fill();
ctx.strokeStyle = TYPE_COLORS[e.type]; ctx.lineWidth = 2;
roundRect(ctx, x, y, 200, 70, 10); ctx.stroke();
ctx.fillStyle = '#fff'; ctx.font = 'bold 14px ui-sans-serif'; ctx.textAlign='left';
ctx.fillText(e.name + (e.boss?' ★':''), x+10, y+22);
ctx.fillStyle = '#a3acd1'; ctx.font = '10px ui-sans-serif';
ctx.fillText('Lv ' + e.level + ' · ' + e.type, x+10, y+38);
/* hp bar */
const pct = Math.max(0, e.hp/e.maxHp);
ctx.fillStyle = 'rgba(0,0,0,0.5)';
roundRect(ctx, x+10, y+44, 180, 8, 4); ctx.fill();
ctx.fillStyle = pct > 0.5 ? '#7ee07a' : pct > 0.25 ? '#f5c542' : '#ff6b6b';
roundRect(ctx, x+10, y+44, 180*pct, 8, 4); ctx.fill();
/* status icons */
if(e.status){
let sx = x+10;
for(const k in e.status){
ctx.fillStyle = '#ffd97a';
ctx.font = '9px ui-sans-serif';
ctx.fillText(k.toUpperCase().slice(0,3), sx, y+62);
sx += 30;
}
}
}
/* player card */
for(let i=0;i<Game.party.length;i++){
const bc = Game.party[i];
const x = 14 + i*100, y = VH-150;
ctx.fillStyle = 'rgba(20,24,40,0.7)';
roundRect(ctx, x, y, 92, 80, 10); ctx.fill();
if(i === Battle.playerActive){
ctx.strokeStyle = '#fff5d6'; ctx.lineWidth = 2;
roundRect(ctx, x, y, 92, 80, 10); ctx.stroke();
}
ctx.fillStyle = '#fff'; ctx.font = 'bold 11px ui-sans-serif'; ctx.textAlign='left';
ctx.fillText(bc.name.slice(0,8), x+6, y+16);
ctx.fillStyle = '#a3acd1'; ctx.font = '9px ui-sans-serif';
ctx.fillText('Lv'+bc.level, x+72, y+16);
const pct = Math.max(0, bc.hp/bc.maxHp);
ctx.fillStyle = 'rgba(0,0,0,0.5)';
roundRect(ctx, x+6, y+22, 80, 6, 3); ctx.fill();
ctx.fillStyle = bc.hp>0 ? (pct>0.5?'#7ee07a':pct>0.25?'#f5c542':'#ff6b6b') : '#444';
roundRect(ctx, x+6, y+22, 80*pct, 6, 3); ctx.fill();
ctx.fillStyle = '#a3acd1'; ctx.font='8px ui-sans-serif';
ctx.fillText(bc.hp + '/' + bc.maxHp, x+6, y+38);
/* exp */
ctx.fillStyle = 'rgba(0,0,0,0.5)';
roundRect(ctx, x+6, y+44, 80, 4, 2); ctx.fill();
ctx.fillStyle = '#c9a4ff';
roundRect(ctx, x+6, y+44, 80*(bc.exp/bc.expToNext), 4, 2); ctx.fill();
/* status */
if(bc.status){
let sx = x+6;
for(const k in bc.status){
ctx.fillStyle = '#ffd97a'; ctx.font='8px ui-sans-serif';
ctx.fillText(k.toUpperCase().slice(0,2), sx, y+58);
sx += 18;
}
}
if(bc.hp<=0){
ctx.fillStyle = 'rgba(0,0,0,0.6)';
roundRect(ctx, x, y, 92, 80, 10); ctx.fill();
ctx.fillStyle = '#ff6b6b'; ctx.font='bold 12px ui-sans-serif';
ctx.textAlign='center';
ctx.fillText('FAINT', x+46, y+45);
ctx.textAlign='left';
}
}
}
function roundRect(ctx, x, y, w, h, r){
ctx.beginPath();
ctx.moveTo(x+r,y);
ctx.lineTo(x+w-r,y); ctx.quadraticCurveTo(x+w,y,x+w,y+r);
ctx.lineTo(x+w,y+h-r); ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h);
ctx.lineTo(x+r,y+h); ctx.quadraticCurveTo(x,y+h,x,y+h-r);
ctx.lineTo(x,y+r); ctx.quadraticCurveTo(x,y,x+r,y);
ctx.closePath();
}
function drawCreatureSprite(ctx, id, cx, cy, scale, type, anim, hpPct, boss){
ctx.save();
ctx.translate(cx, cy + Math.sin(anim)*4);
const s = 28 * scale;
const c = TYPE_COLORS[type] || '#fff';
const g = TYPE_GLOW[type] || '#fff';
/* aura */
const auraR = s*1.6 + Math.sin(anim*0.5)*4;
const aura = ctx.createRadialGradient(0,0,1,0,0,auraR);
aura.addColorStop(0, c + '66');
aura.addColorStop(1, c + '00');
ctx.fillStyle = aura; ctx.beginPath(); ctx.arc(0,0,auraR,0,Math.PI*2); ctx.fill();
/* body */
ctx.fillStyle = shade(c, -0.3);
ctx.beginPath();
ctx.ellipse(0, 6, s*0.9, s*0.8, 0, 0, Math.PI*2); ctx.fill();
/* head */
ctx.fillStyle = c;
ctx.beginPath(); ctx.arc(0, -s*0.3, s*0.7, 0, Math.PI*2); ctx.fill();
/* eyes */
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(-s*0.25, -s*0.35, s*0.12, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(s*0.25, -s*0.35, s*0.12, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath(); ctx.arc(-s*0.22, -s*0.32, s*0.06, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(s*0.28, -s*0.32, s*0.06, 0, Math.PI*2); ctx.fill();
/* mouth */
ctx.strokeStyle = '#000'; ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, -s*0.1, s*0.2, 0.1*Math.PI, 0.9*Math.PI); ctx.stroke();
/* horn/spike top */
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(-s*0.2, -s*0.9);
ctx.lineTo(0, -s*1.3 - Math.abs(Math.sin(anim*0.7))*4);
ctx.lineTo(s*0.2, -s*0.9);
ctx.closePath(); ctx.fill();
/* small details by type */
if(type === 'Cinder'){
ctx.fillStyle = '#ffd97a';
for(let i=0;i<3;i++){
const a = anim*0.5 + i*Math.PI*2/3;
const fx = Math.cos(a)*s*0.5, fy = -s*0.3 + Math.sin(a*2)*s*0.2;
ctx.beginPath(); ctx.arc(fx, fy, 2, 0, Math.PI*2); ctx.fill();
}
} else if(type === 'Tide'){
ctx.fillStyle = '#9be8ff';
for(let i=0;i<3;i++){
ctx.beginPath(); ctx.arc(-s*0.6+i*s*0.6, s*0.5+Math.sin(anim+i)*2, 2, 0, Math.PI*2); ctx.fill();
}
} else if(type === 'Verdant'){
ctx.fillStyle = '#c5f0a5';
ctx.beginPath();
ctx.ellipse(-s*0.6, -s*0.6, 4, 8, 0.5, 0, Math.PI*2); ctx.fill();
ctx.beginPath();
ctx.ellipse(s*0.55, -s*0.5, 4, 8, -0.3, 0, Math.PI*2); ctx.fill();
} else if(type === 'Volt'){
ctx.strokeStyle = '#fff5a8'; ctx.lineWidth = 1.5;
for(let i=0;i<2;i++){
const x1 = (rnd()-0.5)*s*0.8, y1 = -s*0.2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x1 + (rnd()-0.5)*10, y1-8);
ctx.lineTo(x1 + (rnd()-0.5)*14, y1-4);
ctx.stroke();
}
} else if(type === 'Terra'){
ctx.fillStyle = '#e8c79a';
ctx.fillRect(-s*0.6, s*0.2, 6, 4);
ctx.fillRect(s*0.3, s*0.4, 5, 5);
} else if(type === 'Lumen'){
ctx.fillStyle = '#fff8e7';
for(let i=0;i<6;i++){
const a = anim + i*Math.PI*2/6;
const fx = Math.cos(a*2)*s*0.5, fy = Math.sin(a*2)*s*0.5;
ctx.beginPath(); ctx.arc(fx, fy, 1.5, 0, Math.PI*2); ctx.fill();
}
} else if(type === 'Umbral'){
ctx.fillStyle = 'rgba(20,10,40,0.5)';
ctx.beginPath();
ctx.arc(s*0.5, s*0.2, s*0.3, 0, Math.PI*2); ctx.fill();
}
/* boss crown */
if(boss){
ctx.fillStyle = '#ffd97a';
ctx.beginPath();
ctx.moveTo(-s*0.5, -s*0.7);
ctx.lineTo(-s*0.3, -s*1.0);
ctx.lineTo(-s*0.1, -s*0.7);
ctx.lineTo(s*0.1, -s*1.0);
ctx.lineTo(s*0.3, -s*0.7);
ctx.lineTo(s*0.5, -s*1.0);
ctx.lineTo(s*0.5, -s*0.6);
ctx.lineTo(-s*0.5, -s*0.6);
ctx.closePath(); ctx.fill();
}
/* hp bar above */
if(hpPct < 1){
ctx.fillStyle = 'rgba(0,0,0,0.6)';
roundRect(ctx, -s*0.8, -s*1.5, s*1.6, 6, 3); ctx.fill();
ctx.fillStyle = hpPct > 0.5 ? '#7ee07a' : hpPct > 0.25 ? '#f5c542' : '#ff6b6b';
roundRect(ctx, -s*0.8, -s*1.5, s*1.6*Math.max(0,hpPct), 6, 3); ctx.fill();
}
ctx.restore();
}
function drawCapture(ctx, t){
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0,0,VW,VH);
ctx.fillStyle = 'rgba(255,200,80,0.08)';
ctx.fillRect(0,0,VW,VH);
/* ring */
const cx = VW/2, cy = VH*0.42;
const r = 100;
ctx.save(); ctx.translate(cx, cy);
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 4;
ctx.beginPath(); ctx.arc(0,0,r,0,Math.PI*2); ctx.stroke();
ctx.strokeStyle = '#ffd97a';
ctx.lineWidth = 4;
for(let i=0;i<3;i++){
const a = (t*1.5 + i*Math.PI*2/3);
ctx.beginPath();
ctx.arc(0,0,r, a, a+1.2);
ctx.stroke();
}
ctx.strokeStyle = '#fff5d6';
ctx.beginPath();
ctx.arc(0,0,r, t*2, t*2+1.5);
ctx.stroke();
/* target moves in circle */
const target = Battle.captureTarget;
if(target){
const tx = Math.cos(t*1.8) * (r - 12);
const ty = Math.sin(t*1.8) * (r - 12);
ctx.fillStyle = '#fff5d6';
ctx.beginPath(); ctx.arc(tx, ty, 10, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = 'rgba(255,245,200,0.5)';
ctx.beginPath(); ctx.arc(tx, ty, 18, 0, Math.PI*2); ctx.fill();
}
ctx.restore();
/* meter */
const mY = VH*0.7;
ctx.fillStyle = 'rgba(20,24,40,0.85)';
roundRect(ctx, VW/2 - 130, mY - 12, 260, 24, 12); ctx.fill();
if(target){
const fill = target.captureFill || 0;
ctx.fillStyle = '#ffd97a';
roundRect(ctx, VW/2 - 124, mY - 6, 248 * (fill/100), 12, 6); ctx.fill();
}
ctx.fillStyle = '#fff5d6';
ctx.font = 'bold 16px ui-sans-serif';
ctx.textAlign = 'center';
ctx.fillText('TAP TO ATTEMPT COLLECTION', VW/2, mY - 22);
ctx.font = '12px ui-sans-serif';
ctx.fillStyle = '#a3acd1';
ctx.fillText('Press E, Space, or click to throw your orb', VW/2, mY + 30);
}
/* ===== BATTLE INPUT + UI ===== */
const BattleUI = {
root: null,
mode: 'main', /* main | moves | switch | bag | pick */
init(){
if(this.root) return;
const d = document.createElement('div');
d.id = 'battle-ui';
d.style.cssText = 'position:absolute;left:0;right:0;bottom:0;padding:14px;display:flex;flex-direction:column;gap:8px;align-items:center;z-index:45;pointer-events:none;';
d.innerHTML = `
<div id="battle-action-grid" style="display:grid;grid-template-columns:repeat(2,minmax(140px,220px));gap:8px;width:100%;max-width:520px;pointer-events:auto"></div>
<div id="battle-move-grid" class="move-list" style="display:none;width:100%;max-width:520px;pointer-events:auto;background:rgba(20,24,40,0.9);border:1px solid var(--line);border-radius:14px;padding:8px"></div>
`;
document.getElementById('ui').appendChild(d);
this.root = d;
},
show(){
this.init();
this.root.style.display = 'flex';
},
hide(){
if(this.root) this.root.style.display = 'none';
this.mode = 'main';
},
render(){
this.init();
const act = this.root.querySelector('#battle-action-grid');
const mov = this.root.querySelector('#battle-move-grid');
const phase = Battle.phase;
const bc = Game.party[Battle.playerActive];
if(phase === 'choose' && bc && bc.hp>0){
if(this.mode === 'main'){
act.style.display = 'grid';
mov.style.display = 'none';
act.innerHTML = `
<button class="btn primary" data-act="fight">Fight</button>
<button class="btn" data-act="bag">Bag</button>
<button class="btn" data-act="switch">Switch</button>
<button class="btn" data-act="run">${Battle.wasWild ? 'Flee' : 'Run'}</button>
`;
act.querySelectorAll('button').forEach(b => {
b.addEventListener('click', () => this.onAction(b.dataset.act));
});
}
} else if(phase === 'execute' || phase === 'over'){
act.style.display = 'none';
mov.style.display = 'none';
this.mode = 'main';
} else {
act.style.display = 'none';
/* keep mov display based on mode */
if(this.mode === 'main') mov.style.display = 'none';
}
},
showMoves(){
const act = this.root.querySelector('#battle-action-grid');
const mov = this.root.querySelector('#battle-move-grid');
act.style.display = 'none';
mov.style.display = 'grid';
this.mode = 'moves';
const bc = Game.party[Battle.playerActive];
if(!bc){ return; }
mov.innerHTML = '';
bc.moves.forEach((mv, i) => {
const a = ABILITIES[mv];
const tile = document.createElement('div');
tile.className = 'move-tile' + (bc.abilityPp[mv]<=0 ? ' disabled' : '');
const ppClass = bc.abilityPp[mv]<=0 ? 'bad' : '';
const typeTag = a.type ? `<span class="tag type-${a.type.toLowerCase()}">${a.type}</span>` : '<span class="tag">—</span>';
const effTag = a.effect && a.effect.kind ? `<span class="tag ${a.effect.kind==='heal'||a.effect.kind==='regen'||a.effect.kind==='atkUp'||a.effect.kind==='defUp'||a.effect.kind==='spdUp'?'buff':a.effect.kind==='recoil'||a.effect.kind==='defDown'||a.effect.kind==='speed'?'debuff':'status'}">${a.effect.kind}</span>` : '';
const pri = a.effect && a.effect.kind==='priority' ? '<span class="tag priority">fast</span>' : '';
tile.innerHTML = `
<div class="nm">${a.name} <span class="pp ${ppClass}">${bc.abilityPp[mv]}/${a.pp}</span></div>
<div class="meta">${a.power?`PWR ${a.power}`:'—'} · ACC ${a.acc} ${typeTag} ${effTag} ${pri}</div>
`;
tile.addEventListener('click', () => {
if(bc.abilityPp[mv]<=0) return;
Audio.sfx.confirm();
Battle.setMove(i);
Battle.phase = 'execute';
Battle.busy = true;
this.mode = 'main';
setTimeout(()=>Battle.executeTurn(), 200);
});
mov.appendChild(tile);
});
},
showSwitch(){
const act = this.root.querySelector('#battle-action-grid');
const mov = this.root.querySelector('#battle-move-grid');
act.style.display = 'none';
mov.style.display = 'grid';
this.mode = 'switch';
mov.innerHTML = '';
Game.party.forEach((bc, i) => {
const tile = document.createElement('div');
tile.className = 'move-tile' + (bc.hp<=0 ? ' disabled' : '');
tile.innerHTML = `
<div class="nm">${bc.name} <span class="pp">${bc.hp>0?`HP ${bc.hp}/${bc.maxHp}`:'FAINT'}</span></div>
<div class="meta">Lv ${bc.level} · ${bc.type}</div>
`;
tile.addEventListener('click', () => {
if(bc.hp<=0) return;
this.mode = 'main';
Battle.switchTo(i);
});
mov.appendChild(tile);
});
},
showBag(){
const act = this.root.querySelector('#battle-action-grid');
const mov = this.root.querySelector('#battle-move-grid');
act.style.display = 'none';
mov.style.display = 'grid';
this.mode = 'bag';
mov.innerHTML = '';
/* back button */
const back = document.createElement('div');
back.className = 'move-tile';
back.innerHTML = '<div class="nm">← Back</div>';
back.addEventListener('click', () => { this.mode = 'main'; this.render(); });
mov.appendChild(back);
const offer = [
{id:'potion', act:'heal', amt:40},
{id:'superPotion', act:'healFull'},
{id:'revive', act:'revive'},
{id:'wispOrb', act:'capture', power:1.0},
{id:'gleamOrb', act:'capture', power:1.5},
{id:'lattice', act:'capture', power:3.0}
];
offer.forEach(o => {
const cnt = Game.bag[o.id]||0;
if(cnt<=0) return;
const it = ITEMS[o.id];
const tile = document.createElement('div');
tile.className = 'move-tile';
tile.innerHTML = `
<div class="nm">${it.name} <span class="pp">×${cnt}</span></div>
<div class="meta">${it.desc}</div>
`;
tile.addEventListener('click', () => {
if(o.act === 'heal'){
const alive = Game.party.filter(c=>c.hp>0);
if(!alive.length) return;
pickPartyTarget('Use on which creature?', (idx) => {
Game.bag[o.id]--;
Game.party[idx].hp = Math.min(Game.party[idx].maxHp, Game.party[idx].hp + o.amt);
Game.notify(Game.party[idx].name + ' restored ' + o.amt + ' HP!');
Audio.sfx.heal();
endPlayerAction();
});
} else if(o.act === 'healFull'){
const alive = Game.party.filter(c=>c.hp>0 && c.hp < c.maxHp);
if(!alive.length) return;
pickPartyTarget('Use on which creature?', (idx) => {
Game.bag[o.id]--;
Game.party[idx].hp = Game.party[idx].maxHp;
Game.party[idx].status = {};
Game.notify(Game.party[idx].name + ' fully restored!');
Audio.sfx.heal();
endPlayerAction();
});
} else if(o.act === 'revive'){
const fainted = Game.party.filter(c=>c.hp<=0);
if(!fainted.length){ Game.notify('No fainted creatures.'); return; }
pickPartyTarget('Revive which creature?', (idx) => {
Game.bag[o.id]--;
Game.party[idx].hp = Math.floor(Game.party[idx].maxHp/2);
Game.notify(Game.party[idx].name + ' was revived!');
Audio.sfx.heal();
endPlayerAction();
});
} else if(o.act === 'capture'){
Game.bag[o.id]--;
attemptCapture(o.power, ITEMS[o.id]);
}
});
mov.appendChild(tile);
});
},
onAction(act){
Audio.sfx.select();
if(act === 'fight'){ this.showMoves(); }
else if(act === 'bag'){ this.showBag(); }
else if(act === 'switch'){ this.showSwitch(); }
else if(act === 'run'){
if(Battle.wasWild){
if(chance(0.7)){
Audio.sfx.escape();
Battle.bubbleText = 'Got away safely!';
Battle.bubbleTimer = 1.0;
Battle.phase = 'over'; Battle.result = 'flee';
setTimeout(()=>Battle.onBattleEnd(), 800);
} else {
Battle.bubbleText = 'Can\'t escape!';
Battle.bubbleTimer = 0.8;
Audio.sfx.notEff?.();
/* treat as turn: skip to next turn */
Battle.pending = { skip:true };
Battle.phase = 'execute';
setTimeout(()=>Battle.executeTurn(), 800);
}
} else {
Game.notify('You can\'t flee from this battle.');
}
}
}
};
function endPlayerAction(){
Battle.phase = 'execute';
Battle.busy = true;
Battle.pending = { skip:true };
setTimeout(()=>Battle.executeTurn(), 400);
}
function pickPartyTarget(label, onPick){
const act = document.getElementById('battle-action-grid');
const mov = document.getElementById('battle-move-grid');
act.style.display = 'none';
mov.style.display = 'grid';
mov.innerHTML = `<div class="small" style="padding:6px 10px;color:#fff5d6">${label}</div>`;
const back = document.createElement('div');
back.className = 'move-tile';
back.innerHTML = '<div class="nm">← Back</div>';
back.addEventListener('click', () => BattleUI.showBag());
mov.appendChild(back);
Game.party.forEach((bc, i) => {
const tile = document.createElement('div');
tile.className = 'move-tile' + (bc.hp<=0 && label.indexOf('Revive')<0 ? ' disabled' : '');
tile.innerHTML = `<div class="nm">${bc.name} <span class="pp">${bc.hp>0?bc.hp+'/'+bc.maxHp:'FAINT'}</span></div>`;
tile.addEventListener('click', () => {
if(bc.hp<=0 && label.indexOf('Revive')<0) return;
onPick(i);
});
mov.appendChild(tile);
});
}
/* ===== CAPTURE ===== */
function attemptCapture(itemPower, item){
if(!Battle.wasWild){ Game.notify('Cannot capture guardians this way.'); return; }
Audio.sfx.capture();
const target = Battle.enemies.find(e=>e.hp>0);
if(!target){ return; }
Battle.captureTarget = { target, itemPower, item, captureFill:0, attempts:0 };
Battle.phase = 'capture';
Battle.bubbleText = 'You throw a ' + item.name + '!';
Battle.bubbleTimer = 1.0;
}
function updateCapture(dt){
if(Battle.phase !== 'capture' || !Battle.captureTarget) return;
const t = Battle.captureTarget;
/* attempt happens when player presses E/space */
}
function performCaptureAttempt(){
if(!Battle.captureTarget) return;
const t = Battle.captureTarget;
const target = t.target;
const hpFrac = target.hp / target.maxHp;
const baseChance = 0.20 + (1 - hpFrac) * 0.6;
const chance_ = baseChance * t.itemPower;
const caught = rnd() < chance_;
t.attempts++;
/* visual: shake target */
if(caught){
Audio.sfx.capture();
Battle.bubbleText = target.name + ' was caught!';
Battle.bubbleTimer = 1.5;
/* add to party or storage */
if(Game.party.length < MAX_PARTY){
Game.party.push(target);
} else {
Game.storage.push(target);
}
Game.codex[target.id] = Game.codex[target.id] || {};
Game.codex[target.id].caught = true;
/* add to codex if any un-seen */
Battle.phase = 'over';
Battle.result = 'win'; /* mark as win but no exp/coins */
Battle.captureTarget = null;
setTimeout(()=>Battle.onBattleEnd(), 1200);
} else {
Audio.sfx.notEff?.();
Battle.bubbleText = target.name + ' broke free!';
Battle.bubbleTimer = 1.0;
if(t.attempts >= 3){
Battle.phase = 'execute';
Battle.busy = true;
Battle.pending = { skip:true };
Battle.captureTarget = null;
setTimeout(()=>Battle.executeTurn(), 600);
}
}
}
/* ===== STORY ===== */
function triggerStory(stage){
if(Game.storyStage >= stage) return;
Game.storyStage = stage;
const s = STORY[stage];
showDialog({ who:s.who, what:s.what, color:s.color, onClose:()=>{} });
}
function triggerFinale(){
showDialog({ who:'A Voice in the Dawn', what:'The world remembers you. The first dawn rests in your hand. You have done what was asked. The path is open — but the Veil still hums, and the wilds are patient.', onClose:()=>{
Game.flags.finaleDone = true;
Game.notify('Post-game: wilds grow stronger. The journey continues.');
}});
}
/* ===== DIALOG ===== */
function showDialog(opts){
Game.modal = 'dialog';
const el = document.createElement('div');
el.className = 'dialogue';
el.innerHTML = `
<div class="who">${opts.who||''}</div>
<div class="what">${opts.what||''}</div>
<div class="next">E / Space to continue</div>
`;
document.getElementById('ui').appendChild(el);
const close = () => {
el.remove();
Game.modal = null;
Audio.sfx.confirm();
if(opts.onClose) opts.onClose();
};
el.addEventListener('click', close);
const handler = (e) => {
if((e.key==='e' || e.key===' ' || e.key==='Enter') && Game.modal==='dialog'){
window.removeEventListener('keydown', handler);
close();
}
};
window.addEventListener('keydown', handler);
}
function closeDialog(){
document.querySelectorAll('.dialogue').forEach(d=>d.remove());
Game.modal = null;
}
/* ===== PAUSE / MENUS ===== */
function openPause(){
Game.showPause = !Game.showPause;
document.getElementById('pause-menu').classList.toggle('hidden', !Game.showPause);
}
function openBag(){ openMenu('bag'); }
function openTeam(){ openMenu('team'); }
function openPedia(){ openMenu('pedia'); }
function openMap(){ openMenu('map'); }
function openMenu(name){
if(Game.modal) return;
Game.modal = name;
Audio.sfx.select();
const modal = document.createElement('div');
modal.className = 'modal-back';
modal.dataset.name = name;
document.getElementById('ui').appendChild(modal);
const close = () => { modal.remove(); Game.modal = null; };
const inner = document.createElement('div');
inner.className = 'modal';
modal.appendChild(inner);
if(name === 'bag'){ buildBag(inner, close); }
else if(name === 'team'){ buildTeam(inner, close); }
else if(name === 'pedia'){ buildPedia(inner, close); }
else if(name === 'map'){ buildMap(inner, close); }
modal.addEventListener('click', e => { if(e.target === modal) close(); });
/* escape */
const handler = (e) => { if(e.key==='escape' && Game.modal===name){ window.removeEventListener('keydown', handler); close(); } };
window.addEventListener('keydown', handler);
}
function buildBag(inner, close){
inner.innerHTML = `<h2>Traveler's Pack</h2><div class="sub">Items, catalysts, and tools.</div><div class="body scroll" id="bag-body"></div><div class="foot"><button class="btn tiny" id="use-potion">Use Moonpetal Tonic</button><button class="btn" data-close>Close</button></div>`;
inner.querySelector('[data-close]').addEventListener('click', close);
const body = inner.querySelector('#bag-body');
for(const id of Game.bagKinds){
const cnt = Game.bag[id]||0;
if(cnt<=0 && !ITEMS[id]) continue;
const it = ITEMS[id];
if(!it) continue;
const row = document.createElement('div');
row.className = 'row';
row.style.justifyContent = 'space-between';
row.style.background = 'rgba(255,255,255,0.04)';
row.style.border = '1px solid var(--line)';
row.style.borderRadius = '10px';
row.style.padding = '6px 10px';
row.innerHTML = `<div><b>${it.name}</b><div class="small">${it.desc}</div></div><div class="row gap-8"><span class="small">×${cnt}</span>${it.kind==='capture'||it.kind==='heal'||it.kind==='healFull'||it.kind==='revive'?'<button class="btn tiny" data-use="'+id+'">Use</button>':''}</div>`;
body.appendChild(row);
}
body.querySelectorAll('[data-use]').forEach(b => b.addEventListener('click', () => {
const id = b.dataset.use; const it = ITEMS[id];
if((Game.bag[id]||0)<=0){ Game.notify('None left.'); return; }
if(it.kind === 'capture'){
Game.notify('Capture items are used in battle.');
return;
}
if(it.kind === 'heal' || it.kind === 'healFull'){
const alive = Game.party.filter(c=>c.hp>0 && (it.kind==='healFull' ? c.hp < c.maxHp : true));
if(!alive.length){ Game.notify('No one needs it.'); return; }
pickForMenu(alive, (idx) => {
Game.bag[id]--;
Game.party[idx].hp = Math.min(Game.party[idx].maxHp, Game.party[idx].hp + it.power);
if(it.kind === 'healFull'){ Game.party[idx].hp = Game.party[idx].maxHp; Game.party[idx].status = {}; }
Audio.sfx.heal();
Game.notify(Game.party[idx].name + ' restored.');
close(); setTimeout(()=>openBag(), 50);
});
} else if(it.kind === 'revive'){
const f = Game.party.filter(c=>c.hp<=0);
if(!f.length){ Game.notify('No fainted.'); return; }
pickForMenu(f, (idx) => {
Game.bag[id]--;
Game.party[idx].hp = Math.floor(Game.party[idx].maxHp*it.power);
Audio.sfx.heal();
Game.notify(Game.party[idx].name + ' revived.');
close(); setTimeout(()=>openBag(), 50);
});
}
}));
/* catalyst usage */
inner.querySelector('#use-potion').addEventListener('click', () => {
if((Game.bag.potion||0)<=0){ Game.notify('None left.'); return; }
const alive = Game.party.filter(c=>c.hp>0);
if(!alive.length) return;
pickForMenu(alive, (idx) => {
Game.bag.potion--;
Game.party[idx].hp = Math.min(Game.party[idx].maxHp, Game.party[idx].hp + 40);
Audio.sfx.heal();
Game.notify(Game.party[idx].name + ' restored 40 HP.');
close(); setTimeout(()=>openBag(), 50);
});
});
}
function pickForMenu(list, cb){
const m = document.createElement('div');
m.className = 'modal-back';
m.innerHTML = `<div class="modal" style="max-width:480px"><h2>Choose</h2><div class="body scroll" id="pick-body"></div><div class="foot"><button class="btn" id="cancel">Cancel</button></div></div>`;
document.getElementById('ui').appendChild(m);
const body = m.querySelector('#pick-body');
list.forEach((bc, i) => {
const realIdx = Game.party.indexOf(bc);
const t = document.createElement('div');
t.className = 'move-tile';
t.innerHTML = `<div class="nm">${bc.name} <span class="pp">${bc.hp>0?bc.hp+'/'+bc.maxHp:'FAINT'}</span></div><div class="meta">Lv ${bc.level} · ${bc.type}</div>`;
t.addEventListener('click', () => { m.remove(); cb(realIdx); });
body.appendChild(t);
});
m.querySelector('#cancel').addEventListener('click', () => m.remove());
}
function buildTeam(inner, close){
inner.innerHTML = `<h2>Your Veilbound</h2><div class="sub">Active party and storage. Click a creature to switch, transform, or reorder.</div><div class="body scroll" id="team-body"></div><div class="foot"><button class="btn" data-close>Close</button></div>`;
inner.querySelector('[data-close]').addEventListener('click', close);
const body = inner.querySelector('#team-body');
const renderOne = (bc, isStorage) => {
const t = document.createElement('div');
t.className = 'creature-tile';
const tr = CREATURES[bc.id].transform;
const canTrans = tr && !isStorage && bc.level >= tr.atLevel && (Game.bag[tr.catalyst]||0)>0 && !isStorage;
t.innerHTML = `
<div class="top">
<div>
<div class="name">${bc.name}</div>
<div class="lvl">Lv ${bc.level} · ${bc.type}${bc.type2?'/'+bc.type2:''}</div>
</div>
<div class="small">HP ${bc.hp}/${bc.maxHp}</div>
</div>
<div class="lore">${CREATURES[bc.id].lore}</div>
<div class="stats">
<div class="stat">ATK <b>${bc.atk}</b></div>
<div class="stat">DEF <b>${bc.def}</b></div>
<div class="stat">SPD <b>${bc.spd}</b></div>
</div>
<div class="bar" style="margin-top:6px"><i style="width:${100*bc.hp/bc.maxHp}%"></i></div>
<div class="bar exp" style="margin-top:4px"><i style="width:${100*bc.exp/bc.expToNext}%"></i></div>
<div class="small">EXP ${bc.exp}/${bc.expToNext}</div>
<div class="row gap-8" style="margin-top:6px;flex-wrap:wrap">
<span class="tag type-${bc.type.toLowerCase()}">${bc.type}</span>
${bc.moves.map(m=>`<span class="tag">${ABILITIES[m].name}</span>`).join('')}
</div>
${canTrans?`<button class="btn primary tiny" style="margin-top:8px" data-trans="${bc.id}">Transform using ${ITEMS[tr.catalyst].name}</button>`:''}
${!isStorage?`<button class="btn tiny" style="margin-top:6px" data-swap="${bc.id}">To Storage</button>`:''}
${isStorage?`<button class="btn tiny" style="margin-top:6px" data-recall="${bc.id}">To Party</button>`:''}
`;
t.querySelector('[data-trans]')?.addEventListener('click', () => {
const tr2 = CREATURES[bc.id].transform;
Game.bag[tr2.catalyst]--;
const newBc = makeBattleCreature(tr2.into, bc.level);
newBc.exp = bc.exp;
const i = Game.party.indexOf(bc);
Game.party[i] = newBc;
Audio.sfx.level();
Game.notify(bc.name + ' transforms into ' + newBc.name + '!');
close(); setTimeout(()=>openTeam(), 50);
});
t.querySelector('[data-swap]')?.addEventListener('click', () => {
const i = Game.party.indexOf(bc);
if(Game.party.length<=1){ Game.notify('Cannot store your only creature.'); return; }
Game.storage.push(bc);
Game.party.splice(i,1);
close(); setTimeout(()=>openTeam(), 50);
});
t.querySelector('[data-recall]')?.addEventListener('click', () => {
const i = Game.storage.indexOf(bc);
if(Game.party.length>=MAX_PARTY){ Game.notify('Party is full.'); return; }
Game.party.push(bc);
Game.storage.splice(i,1);
close(); setTimeout(()=>openTeam(), 50);
});
return t;
};
/* active */
const head = document.createElement('div');
head.innerHTML = '<div class="small" style="margin:6px 2px">Active party</div>';
body.appendChild(head);
for(const bc of Game.party) body.appendChild(renderOne(bc, false));
if(Game.storage.length){
const head2 = document.createElement('div');
head2.innerHTML = '<div class="small" style="margin:14px 2px 6px">Storage</div>';
body.appendChild(head2);
for(const bc of Game.storage) body.appendChild(renderOne(bc, true));
}
}
function buildPedia(inner, close){
inner.innerHTML = `<h2>Veil Codex</h2><div class="sub">All creatures you have seen.</div><div class="body scroll" id="pedia-body"></div><div class="foot"><button class="btn" data-close>Close</button></div>`;
inner.querySelector('[data-close]').addEventListener('click', close);
const body = inner.querySelector('#pedia-body');
const seen = Object.keys(Game.codex).filter(k=>Game.codex[k]);
/* show all known + locked */
for(const id of CREAT_LIST){
const c = CREATURES[id];
const t = document.createElement('div');
t.className = 'creature-tile';
const known = !!Game.codex[id];
t.innerHTML = `
<div class="top">
<div>
<div class="name">${known?c.name:'???'}</div>
<div class="lvl">${known?c.type:'???'}</div>
</div>
<div class="small">${known?(Game.codex[id].caught?'Caught':'Seen'):'Unknown'}</div>
</div>
<div class="lore">${known?c.lore:'You have not yet met this creature. Wander the Veil to learn more.'}</div>
<div class="row gap-8" style="margin-top:6px;flex-wrap:wrap">
${known?`<span class="tag type-${c.type.toLowerCase()}">${c.type}</span>`:''}
${known?c.base.map((s,i)=>`<span class="tag">${['HP','ATK','DEF','SPD'][i]} ${s}</span>`).join(''):''}
</div>
`;
body.appendChild(t);
}
}
function buildMap(inner, close){
inner.innerHTML = `<h2>World of Aethelgard</h2><div class="sub">Zones you've discovered.</div><div class="body scroll" id="map-body"></div><div class="foot"><button class="btn" data-close>Close</button></div>`;
inner.querySelector('[data-close]').addEventListener('click', close);
const body = inner.querySelector('#map-body');
body.innerHTML = '<div class="mini-map"><canvas id="mmc" width="320" height="320"></canvas></div>';
const c = body.querySelector('#mmc');
const cc = c.getContext('2d');
cc.fillStyle = '#0a0a1a'; cc.fillRect(0,0,320,320);
const W = 320, H = 320;
/* place zones in a hex-ish layout */
const positions = {
glades: {x:80, y:240},
cove: {x:160, y:200},
thicket: {x:240, y:240},
spires: {x:80, y:120},
crags: {x:160, y:80},
sanctum: {x:240, y:120}
};
const links = [
['glades','cove'], ['cove','thicket'], ['thicket','spires'],
['spires','crags'], ['crags','sanctum'], ['sanctum','thicket'],
['cove','spires']
];
for(const [a,b] of links){
const pa = positions[a], pb = positions[b];
if(!Game.zoneUnlocked[a] || !Game.zoneUnlocked[b]) continue;
cc.strokeStyle = '#fff5d655'; cc.lineWidth = 2;
cc.beginPath(); cc.moveTo(pa.x, pa.y); cc.lineTo(pb.x, pb.y); cc.stroke();
}
for(const id of ZONE_LIST){
const z = ZONES[id]; const p = positions[id];
const here = Game.zone === id;
const unlocked = Game.zoneUnlocked[id];
cc.fillStyle = unlocked ? z.accent : '#333';
cc.beginPath(); cc.arc(p.x, p.y, here?10:7, 0, Math.PI*2); cc.fill();
if(here){ cc.strokeStyle = '#fff'; cc.lineWidth = 2; cc.beginPath(); cc.arc(p.x, p.y, 14, 0, Math.PI*2); cc.stroke(); }
cc.fillStyle = unlocked ? '#fff' : '#777';
cc.font = '10px ui-sans-serif'; cc.textAlign = 'center';
cc.fillText(z.name, p.x, p.y+22);
}
/* legend */
const legend = document.createElement('div');
legend.style.marginTop = '10px';
legend.innerHTML = '<div class="small">Currently: <b>'+ZONES[Game.zone].name+'</b></div>'+
(Game.bossDefeated[Game.zone]?'<div class="small" style="color:#7ee07a">Guardian defeated.</div>':'<div class="small" style="color:#a3acd1">Guardian still wanders here.</div>');
body.appendChild(legend);
}
/* ===== TITLE SCREEN ===== */
function showTitle(){
Game.state = 'title';
const ts = document.getElementById('title-screen');
ts.style.display = 'flex';
document.getElementById('hud').classList.add('hidden');
document.getElementById('pause-menu').classList.add('hidden');
document.getElementById('touch-pad').classList.add('hidden');
document.getElementById('touch-actions').classList.add('hidden');
/* stars */
const sc = document.getElementById('title-stars');
sc.innerHTML = '';
for(let i=0;i<60;i++){
const s = document.createElement('div');
s.className = 'star';
s.style.left = rnd()*100 + '%';
s.style.top = rnd()*100 + '%';
s.style.animationDelay = (rnd()*3) + 's';
s.style.width = (1+rnd()*2) + 'px';
s.style.height = s.style.width;
sc.appendChild(s);
}
document.getElementById('btn-new').onclick = () => { Audio.init(); startNewGame(); };
const cont = document.getElementById('btn-cont');
if(hasSave()){
cont.classList.remove('hidden');
} else {
cont.classList.add('hidden');
}
cont.onclick = () => { Audio.init(); const data = loadGame(0); if(data) { enterOverworld(); } else Game.notify('No save found.'); };
}
function startNewGame(){
/* pick starter */
Game.state = 'pickStarter';
const ts = document.getElementById('title-screen');
ts.style.display = 'none';
const modal = document.createElement('div');
modal.className = 'modal-back';
modal.innerHTML = `<div class="modal" style="max-width:720px">
<h2>Choose a Companion</h2>
<div class="sub">A first creature to walk the Veil with you.</div>
<div class="body" id="starter-body"></div>
<div class="foot"><span class="small">Three companions, three different paths. You can catch more as you go.</span></div>
</div>`;
document.getElementById('ui').appendChild(modal);
const body = modal.querySelector('#starter-body');
body.style.display = 'grid';
body.style.gridTemplateColumns = 'repeat(3, 1fr)';
body.style.gap = '10px';
for(const id of STARTER_IDS){
const c = CREATURES[id];
const t = document.createElement('div');
t.className = 'creature-tile';
t.style.cursor = 'pointer';
t.innerHTML = `
<div class="top">
<div>
<div class="name">${c.name}</div>
<div class="lvl">Lv 5 · ${c.type}</div>
</div>
<span class="tag type-${c.type.toLowerCase()}">${c.type}</span>
</div>
<div class="lore">${c.lore}</div>
<div class="stats">
<div class="stat">HP <b>${c.base[0]+4*4}</b></div>
<div class="stat">ATK <b>${c.base[1]+4*1.4}</b></div>
<div class="stat">DEF <b>${c.base[2]+4*1.4}</b></div>
<div class="stat">SPD <b>${c.base[3]+4*1.6}</b></div>
</div>
<button class="btn primary tiny" style="margin-top:8px" data-pick="${id}">Choose</button>
`;
body.appendChild(t);
}
body.querySelectorAll('[data-pick]').forEach(b => b.addEventListener('click', () => {
const id = b.dataset.pick;
const starter = makeBattleCreature(id, 5);
Game.party = [starter];
Game.storage = [];
Game.codex = {}; Game.codex[id] = {seen:true, caught:true};
Game.bag = { potion:3, superPotion:1, wispOrb:8, gleamOrb:0, lattice:0, revive:1, repel:1 };
Game.coins = 200;
Game.repelCount = 0;
Game.stepsInGrass = 0;
Game.tutorialStep = 0;
Game.tutorialFlag = true;
Game.flags = { tutorialActive:true };
Game.storyStage = 0;
Game.bossDefeated = {};
Game.zoneUnlocked = { glades:true };
Game.shrineSpawn = 'glades';
Game.zone = 'glades';
Game.player = { x:1, y:1, dir:0, walkT:0, stepPhase:0, moveAccum:0 };
modal.remove();
saveGame(0);
triggerStory(1);
enterOverworld();
setTimeout(()=>{
showDialog({ who:'A Voice in the Ash', what:'Move with W/A/S/D or the arrow keys. Press E to act — talk, pray at a shrine, or face a guardian. Press Esc to open the menu.' });
}, 4000);
}));
}
function enterOverworld(){
Game.state = 'overworld';
document.getElementById('title-screen').style.display = 'none';
document.getElementById('hud').classList.remove('hidden');
document.getElementById('touch-pad').classList.remove('hidden');
document.getElementById('touch-actions').classList.remove('hidden');
Particles.setAmbient(Game.zone);
Audio.startMusic(Game.zone === 'sanctum' ? 'shrine' : 'overworld');
BattleUI.hide();
updateHUD();
}
function updateHUD(){
document.getElementById('hud-zone').textContent = ZONES[Game.zone].name;
document.getElementById('hud-party').textContent = Game.party.length;
document.getElementById('hud-coins').textContent = Game.coins;
const mm = Math.floor(Game.playTime/60).toString().padStart(2,'0');
const ss = Math.floor(Game.playTime%60).toString().padStart(2,'0');
document.getElementById('hud-time').textContent = mm+':'+ss;
}
/* ===== BATTLE INPUT ===== */
function updateBattle(dt){
if(Game.paused) return;
if(Battle.phase === 'over'){
/* wait for visual to finish, then return to overworld */
if(!Battle._endPending){
Battle._endPending = true;
setTimeout(()=>{
BattleUI.hide();
Battle.onBattleEnd();
Battle._endPending = false;
}, 1500);
}
return;
}
if(Battle.phase === 'capture'){
if(Input.consume('e') || Input.consume(' ')){
performCaptureAttempt();
}
return;
}
if(Battle.busy) return;
if(Battle.phase === 'choose'){
/* the BattleUI handles clicks; but we also support number keys 1-4 for moves */
for(let i=1;i<=4;i++){
if(Input.consume(''+i) || Input.consume('digit'+i)){
const bc = Game.party[Battle.playerActive];
if(bc && bc.moves[i-1] && bc.abilityPp[bc.moves[i-1]]>0){
Audio.sfx.confirm();
Battle.setMove(i-1);
Battle.phase = 'execute';
Battle.busy = true;
setTimeout(()=>Battle.executeTurn(), 200);
}
}
}
}
if(Battle.phase === 'between' && !Battle.busy){
/* advance to first turn */
Battle.busy = true;
setTimeout(()=>{
Battle.busy = false;
Battle.executeTurn();
}, 700);
}
}
/* ===== MAIN LOOP ===== */
let last = performance.now();
function loop(now){
const dt = Math.min(0.05, (now - last)/1000);
last = now;
if(Game.state !== 'title'){
Game.playTime += dt;
updateHUD();
}
/* clear with subtle background */
ctx2d.fillStyle = '#04060f';
ctx2d.fillRect(0,0,VW,VH);
/* shake */
const shake = (Game.shake > 0 ? Game.shake : 0) + (Battle.shake > 0 ? Battle.shake : 0);
ctx2d.save();
if(shake > 0){
const s = shake * 6;
ctx2d.translate((rnd()-0.5)*s, (rnd()-0.5)*s);
Game.shake = Math.max(0, Game.shake - dt*2);
Battle.shake = Math.max(0, Battle.shake - dt*2);
}
if(Game.state === 'overworld' || Game.state === 'menu'){
updateOverworld(dt);
drawOverworld(dt);
} else if(Game.state === 'battle'){
if(Battle.phase !== 'over' && Battle.phase !== 'capture' || true){
drawBattleScene(ctx2d, performance.now()/1000);
}
updateBattle(dt);
/* update bubble timer */
if(Battle.bubbleTimer > 0){ Battle.bubbleTimer -= dt; if(Battle.bubbleTimer<0){ Battle.bubbleText=''; } }
BattleUI.render();
BattleUI.show();
} else if(Game.state === 'pickStarter' || Game.state === 'title'){
/* title screen background */
const grad = ctx2d.createLinearGradient(0,0,0,VH);
grad.addColorStop(0, '#2a3260');
grad.addColorStop(1, '#0a0a1a');
ctx2d.fillStyle = grad; ctx2d.fillRect(0,0,VW,VH);
/* stars */
for(let i=0;i<60;i++){
const x = (i*97 + now/100) % VW;
const y = (i*53 + now/50) % VH;
const a = 0.3 + Math.sin(now/1000 + i)*0.4;
ctx2d.globalAlpha = a;
ctx2d.fillStyle = '#fff5d6';
ctx2d.beginPath(); ctx2d.arc(x, y, 1, 0, Math.PI*2); ctx2d.fill();
}
ctx2d.globalAlpha = 1;
}
Particles.update(dt);
ctx2d.restore();
requestAnimationFrame(loop);
}
/* ===== BIND UI / START ===== */
document.getElementById('pause-menu').addEventListener('click', e => {
const a = e.target.dataset?.act;
if(!a) return;
if(a === 'bag') openBag();
else if(a === 'team') openTeam();
else if(a === 'pedia') openPedia();
else if(a === 'map') openMap();
else if(a === 'save'){ saveGame(0); Game.notify('Game saved.'); Audio.sfx.confirm(); }
else if(a === 'mute'){ Audio.setMuted(!Audio.muted); Game.notify(Audio.muted?'Muted.':'Unmuted.'); }
else if(a === 'quit'){ showTitle(); }
});
/* hide pause menu initially */
document.getElementById('pause-menu').classList.add('hidden');
document.getElementById('hud').classList.add('hidden');
/* show title on load */
showTitle();
requestAnimationFrame(loop);
/* Ensure page doesn't scroll on keys */
window.addEventListener('keydown', e => {
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key)) e.preventDefault();
});
/* Click handlers on canvas for touch fallback */
canvas.addEventListener('click', e => {
if(Game.state === 'battle' && Battle.phase === 'capture'){
performCaptureAttempt();
}
});
</script>
</body>
</html>
<!-- agent-meta {"model":"minimax/minimax-m3","provider":"openrouter","persona":"full","sessionId":"66656dbd-0294-46ee-881d-d139d51aa732","tokensIn":12860147,"tokensOut":102660,"tokensTotal":12962807,"turns":99,"toolCalls":98,"failedToolCalls":0,"timestamp":"2026-07-12T19:47:53.434Z"} -->