Aethelgard
GLM 5.3 Flash · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 80,392 bytes · SHA-256 2e4dc9deadd3
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AETHERGARD — Resonants of Aethelgard</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Press+Start+2P&display=swap" rel="stylesheet">
<style>
:root{
--bg0:#0b0a1a; --bg1:#141228; --glass:rgba(30,27,60,.55); --glass2:rgba(45,40,85,.45);
--line:rgba(160,150,255,.18); --txt:#e8e6ff; --dim:#9d97c9;
--pyro:#ff6b35; --hydro:#38bdf8; --flora:#4ade80; --zephyr:#a5f3fc; --earth:#d9a066; --volt:#fde047; --cosmos:#c084fc;
}
*{box-sizing:border-box; -webkit-tap-highlight-color:transparent}
html,body{height:100%}
body{
margin:0; font-family:'Inter',system-ui,sans-serif; color:var(--txt);
background:
radial-gradient(1200px 700px at 15% -10%, rgba(124,58,237,.28), transparent 60%),
radial-gradient(1000px 600px at 110% 20%, rgba(14,165,233,.18), transparent 55%),
radial-gradient(900px 900px at 50% 120%, rgba(236,72,153,.12), transparent 60%),
linear-gradient(180deg,var(--bg0),var(--bg1) 60%, #0d0b20);
background-attachment:fixed; overflow-x:hidden;
}
.font-pixel{font-family:'Press Start 2P',monospace}
#starfield{position:fixed; inset:0; z-index:0; pointer-events:none; opacity:.6}
.glass{
background:var(--glass); backdrop-filter:blur(14px) saturate(1.3); -webkit-backdrop-filter:blur(14px) saturate(1.3);
border:1px solid var(--line); border-radius:1rem;
box-shadow:0 8px 32px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.07);
}
.glass-soft{background:var(--glass2); backdrop-filter:blur(10px); border:1px solid var(--line); border-radius:.85rem}
/* ---------- screens ---------- */
.screen{position:absolute; inset:0; display:flex; flex-direction:column; opacity:0; transform:translateX(40px) scale(.985); pointer-events:none;
transition:opacity .38s ease, transform .38s cubic-bezier(.22,.9,.3,1.2); overflow-y:auto; overflow-x:hidden; padding:1rem;}
.screen.active{opacity:1; transform:none; pointer-events:auto; z-index:2}
.screen.exit-left{transform:translateX(-40px) scale(.985)}
#app{position:relative; width:100%; max-width:1100px; margin:0 auto; height:100dvh; min-height:640px}
@media (min-width:900px){ #app{height:min(94dvh,860px); margin-top:2.5dvh} }
/* ---------- element glows ---------- */
.elg-pyro{--el:var(--pyro)} .elg-hydro{--el:var(--hydro)} .elg-flora{--el:var(--flora)}
.elg-zephyr{--el:var(--zephyr)} .elg-earth{--el:var(--earth)} .elg-volt{--el:var(--volt)} .elg-cosmos{--el:var(--cosmos)}
.el-border{border:1px solid color-mix(in srgb, var(--el) 55%, transparent); box-shadow:0 0 14px color-mix(in srgb, var(--el) 30%, transparent), inset 0 0 18px color-mix(in srgb, var(--el) 8%, transparent)}
.el-text{color:var(--el)}
.el-chip{background:color-mix(in srgb, var(--el) 18%, transparent); border:1px solid color-mix(in srgb, var(--el) 55%, transparent); color:var(--el)}
/* ---------- buttons ---------- */
.btn{position:relative; display:inline-flex; align-items:center; justify-content:center; gap:.5rem; font-weight:600; border-radius:.75rem;
padding:.7rem 1.15rem; border:1px solid var(--line); background:rgba(60,55,110,.35); color:var(--txt); cursor:pointer;
transition:transform .12s ease, box-shadow .2s ease, background .2s ease, border-color .2s; user-select:none;}
.btn:hover{transform:translateY(-2px); background:rgba(90,82,160,.45); border-color:rgba(180,170,255,.45); box-shadow:0 6px 20px rgba(120,90,255,.25)}
.btn:active{transform:translateY(0) scale(.97)}
.btn:disabled{opacity:.4; cursor:not-allowed; transform:none; box-shadow:none}
.btn-primary{background:linear-gradient(135deg, rgba(139,92,246,.55), rgba(59,130,246,.45)); border-color:rgba(167,139,250,.6)}
.btn-primary:hover{box-shadow:0 8px 26px rgba(139,92,246,.45)}
.btn-danger{background:rgba(220,38,38,.28); border-color:rgba(248,113,113,.5)}
.btn-danger:hover{background:rgba(220,38,38,.45)}
/* ---------- hp / xp bars ---------- */
.bar{height:12px; border-radius:99px; background:rgba(0,0,0,.45); overflow:hidden; border:1px solid rgba(255,255,255,.12); position:relative}
.bar>i{display:block; height:100%; border-radius:99px; transition:width .55s cubic-bezier(.3,.8,.3,1), background-color .4s;
background:linear-gradient(90deg,#22c55e,#4ade80); box-shadow:0 0 10px rgba(74,222,128,.6)}
.bar.hp-mid>i{background:linear-gradient(90deg,#eab308,#facc15); box-shadow:0 0 10px rgba(250,204,21,.6)}
.bar.hp-low>i{background:linear-gradient(90deg,#dc2626,#f87171); box-shadow:0 0 12px rgba(248,113,113,.7)}
.bar.xp>i{background:linear-gradient(90deg,#8b5cf6,#38bdf8); box-shadow:0 0 8px rgba(139,92,246,.7)}
.bar.xp{height:7px}
/* ---------- battle ---------- */
.sprite-bob{animation:bob 2.6s ease-in-out infinite}
@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-9px)}}
.sprite-bob.fast{animation-duration:1.6s}
@keyframes shakeX{0%,100%{transform:translateX(0)}15%{transform:translateX(-14px)}30%{transform:translateX(12px)}45%{transform:translateX(-9px)}60%{transform:translateX(7px)}80%{transform:translateX(-3px)}}
.shake{animation:shakeX .5s ease}
@keyframes hitFlash{0%{filter:brightness(1)}30%{filter:brightness(3) saturate(0)}60%{filter:brightness(1.6)}100%{filter:brightness(1)}}
.hitflash{animation:hitFlash .45s ease}
@keyframes faintDrop{to{transform:translateY(60px) rotate(12deg); opacity:0}}
.faint{animation:faintDrop .7s ease forwards}
@keyframes popIn{from{transform:scale(.5); opacity:0}60%{transform:scale(1.08)}to{transform:scale(1); opacity:1}}
.pop-in{animation:popIn .4s cubic-bezier(.3,1.4,.5,1)}
@keyframes floatUp{from{transform:translateY(0); opacity:1}to{transform:translateY(-46px); opacity:0}}
.float-num{animation:floatUp 1s ease forwards; font-weight:800; text-shadow:0 2px 8px rgba(0,0,0,.7)}
@keyframes pulseGlow{0%,100%{box-shadow:0 0 12px color-mix(in srgb, var(--el) 35%, transparent)}50%{box-shadow:0 0 26px color-mix(in srgb, var(--el) 70%, transparent)}}
.pulse-el{animation:pulseGlow 2s ease-in-out infinite}
/* particles */
.pfield{position:absolute; inset:0; overflow:hidden; pointer-events:none}
.particle{position:absolute; width:7px; height:7px; border-radius:50%; opacity:.75; filter:blur(.5px); animation:drift linear infinite}
@keyframes drift{from{transform:translateY(20px) translateX(0) rotate(0)}to{transform:translateY(-220px) translateX(28px) rotate(220deg); opacity:0}}
/* ---------- map nodes ---------- */
.node{position:absolute; transform:translate(-50%,-50%); width:58px; height:58px; border-radius:50%; display:flex; align-items:center; justify-content:center;
cursor:pointer; font-size:22px; transition:transform .15s ease, box-shadow .2s; background:var(--glass); border:1px solid var(--line)}
.node:hover{transform:translate(-50%,-50%) scale(1.14)}
.node.done{opacity:.45; filter:grayscale(.8)}
.node.locked{opacity:.35; cursor:not-allowed; filter:grayscale(1)}
.node.current{box-shadow:0 0 0 4px rgba(167,139,250,.35), 0 0 24px rgba(167,139,250,.6); border-color:rgba(196,181,253,.8)}
.node .nlab{position:absolute; top:105%; white-space:nowrap; font-size:.62rem; color:var(--dim); background:rgba(10,8,25,.8); padding:2px 7px; border-radius:6px; border:1px solid var(--line)}
/* ---------- dex / misc ---------- */
.dexcard{cursor:pointer; transition:transform .15s, box-shadow .2s}
.dexcard:hover{transform:translateY(-4px) scale(1.02)}
.dexcard.unknown{filter:grayscale(1) brightness(.55)}
.dexcard.unknown .spr{opacity:.25; filter:blur(3px)}
.toast{position:fixed; left:50%; bottom:26px; transform:translateX(-50%) translateY(20px); opacity:0; z-index:90;
transition:all .3s ease; padding:.7rem 1.2rem; font-weight:600; max-width:90vw}
.toast.show{opacity:1; transform:translateX(-50%) translateY(0)}
.modal-back{position:fixed; inset:0; background:rgba(5,4,15,.7); backdrop-filter:blur(6px); z-index:80; display:flex; align-items:center; justify-content:center; padding:1rem; opacity:0; pointer-events:none; transition:opacity .25s}
.modal-back.show{opacity:1; pointer-events:auto}
.modal-back .glass{transform:scale(.92); transition:transform .25s}
.modal-back.show .glass{transform:scale(1)}
.spr{display:inline-block}
.scroll-y{overflow-y:auto; scrollbar-width:thin; scrollbar-color:rgba(160,150,255,.35) transparent}
.scroll-y::-webkit-scrollbar{width:8px}
.scroll-y::-webkit-scrollbar-thumb{background:rgba(160,150,255,.3); border-radius:99px}
#battle-log{min-height:86px; max-height:110px}
.logline{animation:popIn .25s ease}
.tabbtn{padding:.5rem .9rem; border-radius:.7rem; font-size:.8rem; font-weight:600; cursor:pointer; border:1px solid transparent; color:var(--dim); transition:all .2s}
.tabbtn.on{background:rgba(139,92,246,.3); border-color:rgba(167,139,250,.55); color:#fff; box-shadow:0 0 14px rgba(139,92,246,.3)}
.movebtn{width:100%; text-align:left; display:flex; justify-content:space-between; align-items:center; gap:.5rem}
.movebtn:hover{border-color:color-mix(in srgb, var(--el) 70%, transparent)}
.title-glow{text-shadow:0 0 24px rgba(167,139,250,.8), 0 0 60px rgba(139,92,246,.4)}
.vignette::after{content:''; position:absolute; inset:0; border-radius:inherit; pointer-events:none;
background:radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.45));}
</style>
</head>
<body>
<canvas id="starfield"></canvas>
<div id="app">
<!-- ============ MAIN MENU ============ -->
<div id="screen-menu" class="screen active items-center justify-center">
<div class="text-center max-w-lg w-full px-4">
<div class="mb-2 text-[10px] tracking-[.4em] text-violet-300/80 font-pixel">AETHELGARD</div>
<h1 class="font-pixel text-3xl sm:text-4xl title-glow text-violet-200 leading-relaxed">AETHER<span class="text-fuchsia-400">PRISM</span></h1>
<p class="mt-4 text-sm text-[color:var(--dim)] leading-relaxed">Tune the frequencies. Capture the Resonants.<br>Become the Grand Conductor.</p>
<div class="glass p-6 mt-8 flex flex-col gap-3">
<button id="btn-continue" class="btn btn-primary text-base">▶ Continue Journey</button>
<button id="btn-newgame" class="btn text-base">✦ New Journey</button>
<div class="grid grid-cols-2 gap-3 mt-1">
<button id="btn-menu-dex" class="btn text-sm">📖 Conductors Dex</button>
<button id="btn-reset" class="btn btn-danger text-sm">⟲ Reset Progress</button>
</div>
</div>
<p class="mt-4 text-[11px] text-[color:var(--dim)]/70">Progress saves automatically to this device.</p>
</div>
</div>
<!-- ============ OVERWORLD MAP ============ -->
<div id="screen-map" class="screen">
<div class="flex items-center justify-between gap-2 flex-wrap mb-3">
<div class="glass px-4 py-2 flex items-center gap-3">
<span class="text-lg">💎</span>
<span class="font-bold text-cyan-300" id="hud-shards">0</span>
<span class="text-xs text-[color:var(--dim)]">Aether Shards</span>
</div>
<div class="flex gap-2 flex-wrap">
<button class="btn text-xs" data-nav="party">🎒 Party</button>
<button class="btn text-xs" data-nav="shop">🛒 Camp Shop</button>
<button class="btn text-xs" data-nav="dex">📖 Dex</button>
<button class="btn text-xs" data-nav="menu">⏸ Menu</button>
</div>
</div>
<div class="flex gap-2 mb-3 flex-wrap" id="zone-tabs"></div>
<div class="glass flex-1 min-h-[380px] relative vignette overflow-hidden" id="map-canvas">
<div class="pfield" id="map-particles"></div>
<div id="zone-title" class="absolute top-3 left-0 right-0 text-center font-pixel text-[11px] text-violet-200/90 z-10"></div>
<div id="zone-desc" class="absolute top-9 left-0 right-0 text-center text-[11px] text-[color:var(--dim)] z-10 px-4"></div>
<div id="node-layer" class="absolute inset-0"></div>
<svg id="trail-svg" class="absolute inset-0 w-full h-full pointer-events-none" preserveAspectRatio="none"></svg>
</div>
<div id="map-party" class="flex gap-2 mt-3 flex-wrap"></div>
</div>
<!-- ============ BATTLE ============ -->
<div id="screen-battle" class="screen">
<div class="flex-1 flex flex-col gap-3 min-h-0">
<!-- enemy -->
<div class="glass elg-pyro p-3 relative overflow-hidden" id="enemy-panel">
<div class="pfield" id="enemy-particles"></div>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span id="enemy-name" class="font-bold"></span>
<span id="enemy-type" class="text-[10px] px-2 py-.5 rounded-full el-chip"></span>
<span id="enemy-lv" class="text-xs text-[color:var(--dim)]"></span>
</div>
<div class="bar mt-2 w-full max-w-[260px]" id="enemy-hpbar"><i style="width:100%"></i></div>
<div id="enemy-status" class="text-[10px] mt-1 text-fuchsia-300"></div>
</div>
<div id="enemy-sprite" class="spr w-24 h-24 sm:w-32 sm:h-32 relative z-10"></div>
</div>
</div>
<!-- arena message -->
<div class="glass px-4 py-2 text-sm text-center" id="battle-banner">A wild presence stirs…</div>
<!-- player -->
<div class="glass elg-pyro p-3 relative overflow-hidden" id="player-panel">
<div class="pfield" id="player-particles"></div>
<div class="flex items-end justify-between gap-3">
<div id="player-sprite" class="spr w-24 h-24 sm:w-32 sm:h-32 relative z-10"></div>
<div class="flex-1 min-w-0 text-right">
<div class="flex items-center justify-end gap-2 flex-wrap">
<span id="player-name" class="font-bold"></span>
<span id="player-type" class="text-[10px] px-2 py-.5 rounded-full el-chip"></span>
<span id="player-lv" class="text-xs text-[color:var(--dim)]"></span>
</div>
<div class="bar mt-2 w-full max-w-[260px] ml-auto" id="player-hpbar"><i style="width:100%"></i></div>
<div class="text-[10px] mt-1 text-[color:var(--dim)]"><span id="player-hptext"></span></div>
<div class="bar xp mt-1 w-full max-w-[260px] ml-auto" id="player-xpbar"><i style="width:0%"></i></div>
<div id="player-status" class="text-[10px] mt-1 text-fuchsia-300"></div>
</div>
</div>
</div>
<!-- actions -->
<div class="glass p-3" id="battle-actions">
<div id="action-main" class="grid grid-cols-2 sm:grid-cols-4 gap-2">
<button class="btn" data-act="attack">⚔ Attack</button>
<button class="btn" data-act="prism">💠 Prisms</button>
<button class="btn" data-act="swap">🔄 Exchange</button>
<button class="btn" data-act="bag">🎒 Inventory</button>
</div>
<div id="action-sub" class="hidden"></div>
<button id="action-back" class="btn text-xs mt-2 hidden">← Back</button>
</div>
</div>
</div>
<!-- ============ DEX ============ -->
<div id="screen-dex" class="screen">
<div class="flex items-center justify-between mb-3">
<h2 class="font-pixel text-sm text-violet-200">CONDUCTORS DEX</h2>
<div class="flex gap-2 items-center">
<span class="text-xs text-[color:var(--dim)]" id="dex-count"></span>
<button class="btn text-xs" data-nav="back">← Back</button>
</div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 scroll-y flex-1 pr-1" id="dex-grid"></div>
</div>
<!-- ============ SHOP ============ -->
<div id="screen-shop" class="screen">
<div class="flex items-center justify-between mb-3">
<h2 class="font-pixel text-sm text-violet-200">CAMP SHOP</h2>
<div class="flex gap-2 items-center">
<span class="glass px-3 py-1 text-sm">💎 <b class="text-cyan-300" id="shop-shards">0</b></span>
<button class="btn text-xs" data-nav="back">← Back</button>
</div>
</div>
<div class="grid sm:grid-cols-2 gap-3 flex-1 content-start scroll-y pr-1" id="shop-grid"></div>
</div>
<!-- ============ PARTY ============ -->
<div id="screen-party" class="screen">
<div class="flex items-center justify-between mb-3">
<h2 class="font-pixel text-sm text-violet-200">YOUR RESONANTS</h2>
<button class="btn text-xs" data-nav="back">← Back</button>
</div>
<div class="grid sm:grid-cols-2 gap-3 flex-1 content-start scroll-y pr-1" id="party-grid"></div>
</div>
</div>
<div id="toast" class="toast glass"></div>
<div id="modal" class="modal-back"><div class="glass p-6 max-w-md w-full mx-4" id="modal-box"></div></div>
<script>
/* =====================================================================
AETHERPRISM — single-file monster-collection RPG
===================================================================== */
'use strict';
/* ---------------- Synth Manager (Web Audio 8-bit) ---------------- */
class Synth {
constructor(){ this.ctx=null; this.muted=false; }
ensure(){ if(!this.ctx){ try{ this.ctx=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){} } if(this.ctx&&this.ctx.state==='suspended') this.ctx.resume(); return !!this.ctx; }
tone(freq,dur,type='square',vol=.15,slideTo=null,delay=0){
if(this.muted||!this.ensure())return;
const t=this.ctx.currentTime+delay, o=this.ctx.createOscillator(), g=this.ctx.createGain();
o.type=type; o.frequency.setValueAtTime(freq,t);
if(slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(30,slideTo),t+dur);
g.gain.setValueAtTime(vol,t); g.gain.exponentialRampToValueAtTime(.0001,t+dur);
o.connect(g); g.connect(this.ctx.destination); o.start(t); o.stop(t+dur+.02);
}
noise(dur=.15,vol=.12,delay=0){
if(this.muted||!this.ensure())return;
const t=this.ctx.currentTime+delay, len=this.ctx.sampleRate*dur, buf=this.ctx.createBuffer(1,len,this.ctx.sampleRate), d=buf.getChannelData(0);
for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*(1-i/len);
const s=this.ctx.createBufferSource(); s.buffer=buf;
const g=this.ctx.createGain(); g.gain.setValueAtTime(vol,t); g.gain.exponentialRampToValueAtTime(.0001,t+dur);
s.connect(g); g.connect(this.ctx.destination); s.start(t);
}
hover(){ this.tone(520,.05,'square',.05); }
select(){ this.tone(660,.07,'square',.09); this.tone(880,.09,'square',.08,null,.06); }
back(){ this.tone(440,.08,'square',.08,null,0); this.tone(300,.1,'square',.07,null,.06); }
hitPhys(){ this.noise(.12,.16); this.tone(160,.1,'sawtooth',.12,80); }
hitElem(){ this.tone(880,.12,'square',.1,220); this.noise(.15,.1); }
superEff(){ this.tone(200,.25,'sawtooth',.14,900); this.noise(.3,.16); this.tone(1200,.2,'square',.1,300,.08); }
weak(){ this.tone(300,.12,'triangle',.09,140); }
levelUp(){ [523,659,784,1047].forEach((f,i)=>this.tone(f,.14,'square',.1,null,i*.1)); }
capture(){ [392,523,659,784,1047].forEach((f,i)=>this.tone(f,.16,'triangle',.12,null,i*.11)); }
fail(){ this.tone(220,.2,'sawtooth',.1,110); }
heal(){ this.tone(523,.1,'sine',.1); this.tone(784,.14,'sine',.1,null,.09); }
evolve(){ [262,330,392,523,659,784].forEach((f,i)=>this.tone(f,.18,'square',.1,null,i*.09)); }
boss(){ this.tone(98,.5,'sawtooth',.14,60); this.tone(147,.5,'sawtooth',.1,90,.1); }
}
const SFX=new Synth();
/* ---------------- Elements ---------------- */
const ELEMENTS={
pyro:{name:'Pyro', color:'#ff6b35', icon:'🔥', strong:['flora'], weak:['hydro','pyro']},
hydro:{name:'Hydro', color:'#38bdf8', icon:'💧', strong:['pyro'], weak:['flora','hydro']},
flora:{name:'Flora', color:'#4ade80', icon:'🌿', strong:['hydro'], weak:['pyro','flora']},
zephyr:{name:'Zephyr', color:'#a5f3fc', icon:'🌪️', strong:['earth'], weak:['volt']},
earth:{name:'Earth', color:'#d9a066', icon:'⛰️', strong:['volt'], weak:['zephyr']},
volt:{name:'Volt', color:'#fde047', icon:'⚡', strong:['zephyr'], weak:['earth']},
cosmos:{name:'Cosmos', color:'#c084fc', icon:'✦', strong:[], weak:[]},
};
function typeMod(atk,def){
const a=ELEMENTS[atk]; if(!a) return 1;
if(a.strong.includes(def)) return 2;
if(a.weak.includes(def)) return .5;
return 1;
}
/* ---------------- Move Database ---------------- */
const MOVES={
ember_spark:{name:'Ember Spark',pow:35,acc:100,el:'pyro',desc:'A flicker of living flame.'},
flame_fang:{name:'Flame Fang',pow:50,acc:95,el:'pyro',desc:'Bites with superheated jaws.'},
cinder_storm:{name:'Cinder Storm',pow:70,acc:85,el:'pyro',desc:'A whirlwind of burning ash.'},
solar_lance:{name:'Solar Lance',pow:90,acc:80,el:'pyro',desc:'A spear of condensed starfire.'},
bubble_jet:{name:'Bubble Jet',pow:35,acc:100,el:'hydro',desc:'Pressurized water pellets.'},
tide_crash:{name:'Tide Crash',pow:50,acc:95,el:'hydro',desc:'A slamming wall of water.'},
abyss_pulse:{name:'Abyss Pulse',pow:70,acc:85,el:'hydro',desc:'A deep-pressure shockwave.'},
maelstrom:{name:'Maelstrom',pow:90,acc:80,el:'hydro',desc:'A devouring ocean vortex.'},
leaf_dart:{name:'Leaf Dart',pow:35,acc:100,el:'flora',desc:'Razor leaves on the wind.'},
vine_lash:{name:'Vine Lash',pow:50,acc:95,el:'flora',desc:'Thorned whips strike out.'},
spore_burst:{name:'Spore Burst',pow:70,acc:85,el:'flora',desc:'An explosive pollen cloud.'},
world_bloom:{name:'World Bloom',pow:90,acc:80,el:'flora',desc:'Nature erupts in fury.'},
gust_cut:{name:'Gust Cut',pow:35,acc:100,el:'zephyr',desc:'A blade of thin air.'},
wing_slam:{name:'Wing Slam',pow:50,acc:95,el:'zephyr',desc:'A downstroke of pure force.'},
cyclone:{name:'Cyclone',pow:70,acc:85,el:'zephyr',desc:'A spinning sky funnel.'},
heaven_gale:{name:'Heaven Gale',pow:90,acc:80,el:'zephyr',desc:'The sky itself attacks.'},
pebble_shot:{name:'Pebble Shot',pow:35,acc:100,el:'earth',desc:'Fires jagged stones.'},
boulder_roll:{name:'Boulder Roll',pow:50,acc:95,el:'earth',desc:'A crushing rolling mass.'},
quake_line:{name:'Quake Line',pow:70,acc:85,el:'earth',desc:'The ground splits open.'},
tectonix:{name:'Tectonix',pow:90,acc:80,el:'earth',desc:'A continental upheaval.'},
static_nip:{name:'Static Nip',pow:35,acc:100,el:'volt',desc:'A crackling little bite.'},
arc_bolt:{name:'Arc Bolt',pow:50,acc:95,el:'volt',desc:'Lightning arcs to the foe.'},
ion_storm:{name:'Ion Storm',pow:70,acc:85,el:'volt',desc:'A shower of charged plasma.'},
thunder_verdict:{name:'Thunder Verdict',pow:90,acc:80,el:'volt',desc:'The sky passes judgment.'},
star_dust:{name:'Star Dust',pow:40,acc:100,el:'cosmos',desc:'Shimmering void motes.'},
phase_slip:{name:'Phase Slip',pow:55,acc:95,el:'cosmos',desc:'Strikes from between moments.'},
void_lance:{name:'Void Lance',pow:75,acc:85,el:'cosmos',desc:'A spike of nothingness.'},
harmonic_nova:{name:'Harmonic Nova',pow:95,acc:75,el:'cosmos',desc:'All frequencies at once.'},
tackle:{name:'Tackle',pow:30,acc:100,el:'cosmos',desc:'A straightforward body blow.'},
};
/* ---------------- Sprite templates ---------------- */
function grad(id,c1,c2){ return `<defs><linearGradient id="${id}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${c1}"/><stop offset="1" stop-color="${c2}"/></linearGradient></defs>`; }
const SPRITE_TEMPLATES={
quad:(c)=>`${grad('g1',c[0],c[1])}
<ellipse cx="50" cy="72" rx="26" ry="17" fill="url(#g1)"/>
<ellipse cx="72" cy="52" rx="16" ry="14" fill="url(#g1)"/>
<polygon points="60,42 66,24 74,40" fill="url(#g1)"/><polygon points="78,40 86,26 88,44" fill="url(#g1)"/>
<path d="M26 68 Q8 60 12 44 Q20 54 28 56" fill="url(#g1)"/>
<rect x="34" y="82" width="7" height="14" rx="3" fill="${c[1]}"/><rect x="58" y="82" width="7" height="14" rx="3" fill="${c[1]}"/>
<circle cx="68" cy="50" r="3.4" fill="#fff"/><circle cx="69.5" cy="50" r="1.8" fill="#111"/>
<path d="M78 58 Q86 60 84 66" stroke="${c[2]}" stroke-width="2.5" fill="none" stroke-linecap="round"/>`,
blob:(c)=>`${grad('g1',c[0],c[1])}
<path d="M50 20 C78 20 88 46 86 66 C84 84 70 92 50 92 C30 92 16 84 14 66 C12 46 22 20 50 20 Z" fill="url(#g1)"/>
<circle cx="38" cy="52" r="5" fill="#fff"/><circle cx="62" cy="52" r="5" fill="#fff"/>
<circle cx="39" cy="53" r="2.6" fill="#111"/><circle cx="61" cy="53" r="2.6" fill="#111"/>
<path d="M42 68 Q50 76 58 68" stroke="#111" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<circle cx="26" cy="66" r="6" fill="${c[2]}" opacity=".8"/><circle cx="74" cy="66" r="6" fill="${c[2]}" opacity=".8"/>`,
serpent:(c)=>`${grad('g1',c[0],c[1])}
<path d="M20 88 Q34 70 26 54 Q20 40 38 34 Q58 28 62 44 Q66 58 52 62 Q40 66 46 78 Q50 88 66 88" fill="none" stroke="url(#g1)" stroke-width="16" stroke-linecap="round"/>
<circle cx="66" cy="86" r="11" fill="url(#g1)"/>
<circle cx="62" cy="84" r="3" fill="#fff"/><circle cx="63" cy="84" r="1.6" fill="#111"/>
<path d="M74 90 L82 92 L74 94" fill="${c[2]}"/>
<polygon points="58,74 66,70 64,80" fill="${c[2]}"/>`,
bird:(c)=>`${grad('g1',c[0],c[1])}
<ellipse cx="52" cy="58" rx="20" ry="24" fill="url(#g1)"/>
<circle cx="52" cy="30" r="13" fill="url(#g1)"/>
<polygon points="40,26 22,20 38,34" fill="${c[2]}"/><polygon points="64,26 82,20 66,34" fill="${c[2]}"/>
<polygon points="52,32 60,36 52,40" fill="${c[2]}"/>
<circle cx="47" cy="28" r="3" fill="#fff"/><circle cx="48" cy="28" r="1.6" fill="#111"/>
<path d="M30 58 Q10 52 8 38 Q24 44 32 50 Z" fill="${c[2]}" opacity=".85"/>
<path d="M74 58 Q92 52 94 38 Q80 44 72 50 Z" fill="${c[2]}" opacity=".85"/>
<path d="M46 82 L44 94 M58 82 L60 94" stroke="${c[2]}" stroke-width="3" stroke-linecap="round"/>`,
golem:(c)=>`${grad('g1',c[0],c[1])}
<rect x="30" y="42" width="40" height="40" rx="8" fill="url(#g1)"/>
<rect x="36" y="20" width="28" height="24" rx="6" fill="url(#g1)"/>
<rect x="14" y="46" width="14" height="26" rx="5" fill="${c[1]}"/><rect x="72" y="46" width="14" height="26" rx="5" fill="${c[1]}"/>
<rect x="36" y="82" width="11" height="14" rx="4" fill="${c[1]}"/><rect x="53" y="82" width="11" height="14" rx="4" fill="${c[1]}"/>
<circle cx="44" cy="32" r="3.4" fill="${c[2]}"/><circle cx="56" cy="32" r="3.4" fill="${c[2]}"/>
<polygon points="50,52 60,62 50,72 40,62" fill="${c[2]}"/>
<path d="M34 46 L30 30 M66 46 L70 30" stroke="${c[2]}" stroke-width="3" stroke-linecap="round"/>`,
spark:(c)=>`${grad('g1',c[0],c[1])}
<polygon points="50,14 62,40 88,44 66,60 74,88 50,72 26,88 34,60 12,44 38,40" fill="url(#g1)" stroke="${c[2]}" stroke-width="2"/>
<circle cx="44" cy="48" r="4" fill="#fff"/><circle cx="56" cy="48" r="4" fill="#fff"/>
<circle cx="45" cy="49" r="2" fill="#111"/><circle cx="55" cy="49" r="2" fill="#111"/>
<path d="M42 60 Q50 66 58 60" stroke="#111" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<path d="M20 20 L26 28 M84 20 L78 28" stroke="${c[2]}" stroke-width="2.5" stroke-linecap="round"/>`,
};
/* ---------------- Resonant Database (18) ---------------- */
const RESONANTS=[
{id:'emberkit', name:'Emberkit', el:'pyro', tier:1, evo:'pyrowolf', evoLv:8, base:{hp:44,atk:52,def:40,spd:58}, shape:'quad', colors:['#ff8c42','#c2410c','#fde047'], catch:190,
learn:[['tackle',1],['ember_spark',1],['flame_fang',6],['cinder_storm',12],['solar_lance',16]]},
{id:'pyrowolf', name:'Pyrowolf', el:'pyro', tier:2, evo:'ignisaur', evoLv:15, base:{hp:60,atk:72,def:54,spd:74}, shape:'quad', colors:['#f97316','#7c2d12','#fbbf24'], catch:90,
learn:[['tackle',1],['ember_spark',1],['flame_fang',1],['cinder_storm',12],['solar_lance',17]]},
{id:'ignisaur', name:'Ignisaur', el:'pyro', tier:3, evo:null, evoLv:null, base:{hp:80,atk:96,def:70,spd:88}, shape:'quad', colors:['#ea580c','#431407','#f97316'], catch:45,
learn:[['tackle',1],['ember_spark',1],['flame_fang',1],['cinder_storm',1],['solar_lance',18],['harmonic_nova',24]]},
{id:'droplet', name:'Droplet', el:'hydro', tier:1, evo:'geoxus', evoLv:8, base:{hp:48,atk:44,def:52,spd:50}, shape:'blob', colors:['#7dd3fc','#0369a1','#38bdf8'], catch:190,
learn:[['tackle',1],['bubble_jet',1],['tide_crash',6],['abyss_pulse',12],['maelstrom',16]]},
{id:'geoxus', name:'Geoxus', el:'hydro', tier:2, evo:'neptunian', evoLv:15, base:{hp:64,atk:60,def:70,spd:58}, shape:'serpent', colors:['#38bdf8','#075985','#7dd3fc'], catch:90,
learn:[['tackle',1],['bubble_jet',1],['tide_crash',1],['abyss_pulse',12],['maelstrom',17]]},
{id:'neptunian', name:'Neptunian', el:'hydro', tier:3, evo:null, evoLv:null, base:{hp:88,atk:78,def:92,spd:70}, shape:'serpent', colors:['#0ea5e9','#082f49','#22d3ee'], catch:45,
learn:[['tackle',1],['bubble_jet',1],['tide_crash',1],['abyss_pulse',1],['maelstrom',18],['harmonic_nova',24]]},
{id:'seedling', name:'Seedling', el:'flora', tier:1, evo:'thornback', evoLv:8, base:{hp:50,atk:48,def:50,spd:44}, shape:'blob', colors:['#86efac','#15803d','#4ade80'], catch:190,
learn:[['tackle',1],['leaf_dart',1],['vine_lash',6],['spore_burst',12],['world_bloom',16]]},
{id:'thornback', name:'Thornback', el:'flora', tier:2, evo:'bloomgoliath', evoLv:15, base:{hp:68,atk:64,def:66,spd:52}, shape:'golem', colors:['#4ade80','#166534','#a3e635'], catch:90,
learn:[['tackle',1],['leaf_dart',1],['vine_lash',1],['spore_burst',12],['world_bloom',17]]},
{id:'bloomgoliath', name:'Bloomgoliath', el:'flora', tier:3, evo:null, evoLv:null, base:{hp:94,atk:82,def:86,spd:60}, shape:'golem', colors:['#22c55e','#14532d','#84cc16'], catch:45,
learn:[['tackle',1],['leaf_dart',1],['vine_lash',1],['spore_burst',1],['world_bloom',18],['harmonic_nova',24]]},
{id:'sparky', name:'Sparky', el:'volt', tier:1, evo:'boltsaber', evoLv:8, base:{hp:42,atk:54,def:38,spd:64}, shape:'spark', colors:['#fde047','#a16207','#facc15'], catch:190,
learn:[['tackle',1],['static_nip',1],['arc_bolt',6],['ion_storm',12],['thunder_verdict',16]]},
{id:'boltsaber', name:'Boltsaber', el:'volt', tier:2, evo:'stormlord', evoLv:15, base:{hp:58,atk:74,def:52,spd:82}, shape:'bird', colors:['#facc15','#854d0e','#fef08a'], catch:90,
learn:[['tackle',1],['static_nip',1],['arc_bolt',1],['ion_storm',12],['thunder_verdict',17]]},
{id:'stormlord', name:'Stormlord', el:'volt', tier:3, evo:null, evoLv:null, base:{hp:78,atk:98,def:68,spd:100}, shape:'bird', colors:['#eab308','#713f12','#fde047'], catch:45,
learn:[['tackle',1],['static_nip',1],['arc_bolt',1],['ion_storm',1],['thunder_verdict',18],['harmonic_nova',24]]},
{id:'gusty', name:'Gusty', el:'zephyr', tier:1, evo:'slipstream', evoLv:8, base:{hp:44,atk:46,def:42,spd:62}, shape:'bird', colors:['#bae6fd','#0369a1','#e0f2fe'], catch:190,
learn:[['tackle',1],['gust_cut',1],['wing_slam',6],['cyclone',12],['heaven_gale',16]]},
{id:'slipstream', name:'Slipstream', el:'zephyr', tier:2, evo:'zephyrus', evoLv:15, base:{hp:58,atk:64,def:56,spd:80}, shape:'serpent', colors:['#a5f3fc','#0e7490','#cffafe'], catch:90,
learn:[['tackle',1],['gust_cut',1],['wing_slam',1],['cyclone',12],['heaven_gale',17]]},
{id:'zephyrus', name:'Zephyrus', el:'zephyr', tier:3, evo:null, evoLv:null, base:{hp:76,atk:84,def:72,spd:98}, shape:'bird', colors:['#67e8f9','#155e75','#ecfeff'], catch:45,
learn:[['tackle',1],['gust_cut',1],['wing_slam',1],['cyclone',1],['heaven_gale',18],['harmonic_nova',24]]},
{id:'pebble', name:'Pebble', el:'earth', tier:1, evo:'obsidian', evoLv:8, base:{hp:54,atk:50,def:58,spd:32}, shape:'golem', colors:['#d6b28a','#78350f','#d9a066'], catch:190,
learn:[['tackle',1],['pebble_shot',1],['boulder_roll',6],['quake_line',12],['tectonix',16]]},
{id:'obsidian', name:'Obsidian', el:'earth', tier:2, evo:'terrasunder', evoLv:15, base:{hp:70,atk:68,def:78,spd:40}, shape:'golem', colors:['#57534e','#1c1917','#a78bfa'], catch:90,
learn:[['tackle',1],['pebble_shot',1],['boulder_roll',1],['quake_line',12],['tectonix',17]]},
{id:'terrasunder', name:'Terrasunder', el:'earth', tier:3, evo:null, evoLv:null, base:{hp:96,atk:88,def:100,spd:48}, shape:'golem', colors:['#44403c','#0c0a09','#f59e0b'], catch:45,
learn:[['tackle',1],['pebble_shot',1],['boulder_roll',1],['quake_line',1],['tectonix',18],['harmonic_nova',24]]},
];
const RES_BY_ID={}; RESONANTS.forEach(r=>RES_BY_ID[r.id]=r);
function spriteSVG(id,cls=''){
const r=RES_BY_ID[id]; if(!r) return '';
const body=SPRITE_TEMPLATES[r.shape](r.colors);
const aura={pyro:'#ff6b35',hydro:'#38bdf8',flora:'#4ade80',zephyr:'#a5f3fc',earth:'#d9a066',volt:'#fde047',cosmos:'#c084fc'}[r.el];
return `<svg viewBox="0 0 100 100" class="w-full h-full" xmlns="http://www.w3.org/2000/svg">
<defs><radialGradient id="aura-${id}" cx=".5" cy=".55" r=".55"><stop offset="0" stop-color="${aura}" stop-opacity=".35"/><stop offset="1" stop-color="${aura}" stop-opacity="0"/></radialGradient></defs>
<ellipse cx="50" cy="94" rx="30" ry="6" fill="rgba(0,0,0,.4)"/>
<circle cx="50" cy="55" r="46" fill="url(#aura-${id})"/>
${body}</svg>`;
}
/* ---------------- Items ---------------- */
const ITEMS={
prism:{name:'Harmonizer Prism', icon:'💠', desc:'A basic Aether Prism. Standard harmonic capture.', price:120, consumable:true},
hyperprism:{name:'Hyper-Conductive Prism', icon:'🔮', desc:'High-fidelity matrix. Greatly boosts capture rate.', price:400, consumable:true},
elixir:{name:'Core Restorer', icon:'🧪', desc:'Restores 50% of a Resonant\'s max HP.', price:150, consumable:true},
cleanser:{name:'Resonance Elixir', icon:'✨', desc:'Clears all status conditions.', price:180, consumable:true},
booster:{name:'Prism Booster', icon:'⚡', desc:'Next Prism throw this battle has +40% capture power.', price:250, consumable:true},
};
/* ---------------- Zones & Node Map ---------------- */
const ZONES=[
{id:'canopy', name:'The Verdant Canopy', desc:'Sun-dappled frequencies hum through ancient boughs.', el:'flora', lv:[1,5],
pool:['seedling','seedling','gusty','sparky','droplet'],
boss:{name:'Sylvan Warden Mira', title:'Warden of the Canopy', sprite:'thornback',
intro:'Mira: "The Canopy answers only to balanced harmonies, young Conductor. Show me yours!"',
mid:'Mira: "Your resonance deepens… impressive. But feel the forest\'s true weight!"',
lose:'Mira: "The boughs bow to you. The Sunken Grotto lies beyond — Brann will test your tide."',
win:'Mira: "You are not yet ready. Return when your Resonants sing louder."',
team:[['thornback',8],['gusty',7],['bloomgoliath',10]]}},
{id:'grotto', name:'The Sunken Grotto', desc:'Dripping echoes carry deep-water wavelengths.', el:'hydro', lv:[5,10],
pool:['droplet','droplet','pebble','geoxus','seedling'],
boss:{name:'Tidecaller Brann', title:'Voice of the Deep', sprite:'geoxus',
intro:'Brann: "Few conductors hear the Grotto\'s low frequencies. Can you feel them, or only fear them?"',
mid:'Brann: "The pressure rises! Let us see if your harmony holds beneath the weight of the deep!"',
lose:'Brann: "The tide recedes before your strength. The Obsidian Crags burn ahead — Kael waits there."',
win:'Brann: "Your song drowns in the dark. Surface, and do not return until you can breathe water."',
team:[['geoxus',11],['obsidian',10],['neptunian',13]]}},
{id:'crags', name:'The Obsidian Crags', desc:'Molten veins pulse with violent, crackling energy.', el:'pyro', lv:[10,15],
pool:['emberkit','sparky','pyrowolf','boltsaber','obsidian'],
boss:{name:'Cinderfist Kael', title:'The Blazing Fist', sprite:'pyrowolf',
intro:'Kael: "No tricks. No prisms. Just fire against fire. SHOW ME YOUR RESOLVE!"',
mid:'Kael: "HAH! Now THIS is a frequency I can respect! BURN BRIGHTER!"',
lose:'Kael: "Magnificent! The Apex Pillar stands above. Grand Conductor Lyra awaits the worthy."',
win:'Kael: "Ash. That is all ambition becomes without power. Climb when you have earned the summit."',
team:[['pyrowolf',14],['ignisaur',15],['stormlord',16]]}},
{id:'apex', name:'The Apex Pillar', desc:'Where every frequency in Aethelgard converges into one chord.', el:'cosmos', lv:[15,22],
pool:['boltsaber','slipstream','geoxus','pyrowolf','thornback','obsidian'],
boss:{name:'Grand Conductor Lyra', title:'Keeper of the Final Chord', sprite:'zephyrus',
intro:'Lyra: "I have listened to Aethelgard for forty years, waiting for a chord worth conducting. Prove the wait was not wasted."',
mid:'Lyra: "YES! Do you hear it? The Pillar itself is singing with you! The finale awaits!"',
lose:'Lyra: "The Final Chord is yours to claim. Aethelgard has a new Grand Conductor. Thank you… for the music."',
win:'Lyra: "Discordant. Unfinished. The Pillar rejects you — as do I. Listen to the world before you dare conduct it."',
team:[['zephyrus',18],['terrasunder',18],['ignisaur',19],['neptunian',19],['stormlord',21]]}},
];
const ZONE_BY_ID={}; ZONES.forEach(z=>ZONE_BY_ID[z.id]=z);
function defaultNodes(zoneId){
const layouts={
canopy:[[10,72,'grass','Tall Grass'],[26,50,'grass','Tall Grass'],[40,70,'cache','Mystery Cache'],[54,44,'npc','Wandering Conductor'],[68,66,'spring','Healing Spring'],[82,40,'grass','Deep Thicket'],[92,22,'boss','Gatekeeper']],
grotto:[[8,30,'grass','Fungal Hollow'],[22,55,'cache','Sunken Cache'],[36,32,'grass','Tidepools'],[50,60,'npc','Wandering Conductor'],[64,36,'spring','Thermal Vent'],[78,58,'grass','Black Lagoon'],[92,30,'boss','Gatekeeper']],
crags:[[8,60,'grass','Ember Fields'],[24,38,'cache','Crag Cache'],[38,62,'grass','Ashen Ridge'],[52,40,'npc','Wandering Conductor'],[66,64,'spring','Magma Spring'],[80,40,'grass','Storm Shelf'],[92,26,'boss','Gatekeeper']],
apex:[[10,78,'grass','Windward Steps'],[26,58,'cache','Star Cache'],[42,72,'grass','Cloud Terrace'],[58,52,'npc','Elite Conductor'],[74,66,'spring','Pillar Sanctum'],[90,34,'boss','Final Chord']],
};
return layouts[zoneId].map((n,i)=>({id:zoneId+'_'+i, x:n[0], y:n[1], type:n[2], label:n[3]}));
}
/* ---------------- NPC trainers for encounter nodes ---------------- */
const NPC_TRAINERS=[
{name:'Conductor Fen', quip:'"My frequencies are perfectly tuned!"', team:[['seedling',3],['sparky',4]]},
{name:'Conductor Odessa', quip:'"Feel the rhythm of battle!"', team:[['droplet',4],['gusty',5]]},
{name:'Conductor Bramble', quip:'"Nature always wins the long game."', team:[['pebble',5],['thornback',6]]},
{name:'Conductor Volt', quip:'"ZAP! Sorry — force of habit."', team:[['sparky',6],['boltsaber',8]]},
{name:'Conductor Sela', quip:'"The wind told me I\'d win."', team:[['gusty',7],['slipstream',9]]},
{name:'Conductor Rook', quip:'"Solid as stone. Stubborn too."', team:[['obsidian',9],['emberkit',8]]},
{name:'Conductor Vex', quip:'"I conduct chaos, personally."', team:[['pyrowolf',10],['geoxus',11]]},
{name:'Conductor Halcyon', quip:'"Balance in all frequencies."', team:[['thornback',11],['boltsaber',12],['slipstream',12]]},
{name:'Elite Conductor Wren', quip:'"The Pillar filters out the unworthy."', team:[['geoxus',14],['pyrowolf',15],['zephyrus',16]]},
{name:'Elite Conductor Dain', quip:'"Let\'s make some noise."', team:[['terrasunder',15],['ignisaur',16]]},
{name:'Elite Conductor Sable', quip:'"Silence is also a frequency."', team:[['neptunian',16],['stormlord',17]]},
{name:'Elite Conductor Mira', quip:'"Rematch? With pleasure."', team:[['bloomgoliath',16],['neptunian',17]]},
];
/* ---------------- Player State ---------------- */
const SAVE_KEY='aetherprism_save_v1';
let P=null; // player profile
let currentZone='canopy';
function newPlayer(){
return {
shards:300, items:{prism:5, hyperprism:0, elixir:2, cleanser:1, booster:0},
party:[], storage:[], zonesUnlocked:['canopy'], clearedBosses:[], completedNodes:[],
dexSeen:[], dexCaught:[], nextUid:1, battlesWon:0,
};
}
function saveGame(){ try{ localStorage.setItem(SAVE_KEY, JSON.stringify(P)); }catch(e){} }
function loadGame(){ try{ const s=localStorage.getItem(SAVE_KEY); return s?JSON.parse(s):null; }catch(e){ return null; } }
/* ---------------- Monster factory & math ---------------- */
let UID=1;
function makeMon(id, level){
const b=RES_BY_ID[id];
const m={
uid:P.nextUid++, id, level,
xp:level*level*level*0 + Math.floor(Math.pow(level,3)*0.6),
status:null,
moves:b.learn.filter(l=>l[1]<=level).map(l=>l[0]).slice(-4),
};
recalc(m); m.hp=m.maxhp;
return m;
}
function recalc(m){
const b=RES_BY_ID[m.id];
m.maxhp=Math.floor(b.base.hp*2*m.level/100)+m.level+10;
m.atk=Math.floor(b.base.atk*2*m.level/100)+5;
m.def=Math.floor(b.base.def*2*m.level/100)+5;
m.spd=Math.floor(b.base.spd*2*m.level/100)+5;
}
function xpToNext(level){ return Math.pow(level+1,3); }
function healMon(m){ m.hp=m.maxhp; m.status=null; }
function healParty(){ P.party.forEach(healMon); }
function activeParty(){ return P.party.filter(m=>m.active!==false).slice(0,3); }
function ensureActiveDefaults(){ let n=0; P.party.forEach(m=>{ if(m.active!==false && n<3) n++; else if(n>=3) m.active=false; }); }
function gainXP(mon, amount){
const msgs=[];
mon.xp+=amount;
while(mon.xp>=xpToNext(mon.level) && mon.level<50){
mon.level++;
const old={atk:mon.atk,def:mon.def,spd:mon.spd,maxhp:mon.maxhp};
recalc(mon); mon.hp=Math.min(mon.maxhp, mon.hp+(mon.maxhp-old.maxhp));
SFX.levelUp();
msgs.push(`${monName(mon)} grew to Level ${mon.level}!`);
const b=RES_BY_ID[mon.id];
b.learn.forEach(l=>{ if(l[1]===mon.level && !mon.moves.includes(l[0])){
if(mon.moves.length>=4) mon.moves.shift();
mon.moves.push(l[0]); msgs.push(`${monName(mon)} learned ${MOVES[l[0]].name}!`);
}});
if(b.evo && mon.level>=b.evoLv){
const oldName=monName(mon);
mon.id=b.evo; recalc(mon); mon.hp=mon.maxhp;
SFX.evolve();
msgs.push(`✦ ${oldName} resonated and evolved into ${RES_BY_ID[mon.id].name}!`);
if(!P.dexCaught.includes(mon.id)) P.dexCaught.push(mon.id);
if(!P.dexSeen.includes(mon.id)) P.dexSeen.push(mon.id);
}
}
return msgs;
}
function monName(m){ return RES_BY_ID[m.id].name; }
function monEl(m){ return RES_BY_ID[m.id].el; }
/* ---------------- UI helpers ---------------- */
const $=s=>document.querySelector(s);
const $$=s=>Array.from(document.querySelectorAll(s));
const wait=ms=>new Promise(r=>setTimeout(r,ms));
const rnd=(a,b)=>a+Math.random()*(b-a);
const ri=(a,b)=>Math.floor(rnd(a,b+1));
const pick=arr=>arr[Math.floor(Math.random()*arr.length)];
let currentScreen='menu';
function showScreen(name){
SFX.select();
$$('.screen').forEach(s=>{ s.classList.remove('active'); s.classList.toggle('exit-left', s.id==='screen-'+currentScreen); });
const el=$('#screen-'+name);
if(el){ el.classList.remove('exit-left'); el.classList.add('active'); }
currentScreen=name;
if(name==='map') renderMap();
if(name==='dex') renderDex();
if(name==='shop') renderShop();
if(name==='party') renderParty();
if(name==='menu') renderMenu();
}
let toastTimer=null;
function toast(msg){
const t=$('#toast'); t.textContent=msg; t.classList.add('show');
clearTimeout(toastTimer); toastTimer=setTimeout(()=>t.classList.remove('show'),2400);
}
function showModal(html, buttons){
return new Promise(res=>{
const box=$('#modal-box');
box.innerHTML=html+`<div class="flex gap-2 justify-end mt-4 flex-wrap" id="modal-btns"></div>`;
const btns=$('#modal-btns', box)||box.querySelector('#modal-btns');
buttons.forEach(b=>{
const el=document.createElement('button');
el.className='btn '+(b.danger?'btn-danger ':'')+(b.primary?'btn-primary ':'')+'text-sm';
el.textContent=b.label;
el.onclick=()=>{ SFX.select(); $('#modal').classList.remove('show'); res(b.value); };
btns.appendChild(el);
});
$('#modal').classList.add('show');
});
}
function updateHUD(){
$('#hud-shards').textContent=P.shards;
const shop=$('#shop-shards'); if(shop) shop.textContent=P.shards;
}
function elementChip(el){ return `<span class="text-[10px] px-2 py-0.5 rounded-full el-chip elg-${el}">${ELEMENTS[el].icon} ${ELEMENTS[el].name}</span>`; }
/* ---------------- Starfield backdrop ---------------- */
(function(){
const c=$('#starfield'), ctx=c.getContext('2d'); let stars=[];
function resize(){ c.width=innerWidth; c.height=innerHeight;
stars=Array.from({length:130},()=>({x:Math.random()*c.width,y:Math.random()*c.height,r:Math.random()*1.6+.3,s:Math.random()*.25+.05,t:Math.random()*6.28}));
}
addEventListener('resize',resize); resize();
(function loop(t){
ctx.clearRect(0,0,c.width,c.height);
stars.forEach(s=>{ s.y-=s.s; if(s.y<-4){s.y=c.height+4;s.x=Math.random()*c.width;}
const a=.35+.35*Math.sin(t/900+s.t);
ctx.fillStyle=`rgba(190,180,255,${a})`; ctx.beginPath(); ctx.arc(s.x,s.y,s.r,0,6.28); ctx.fill(); });
requestAnimationFrame(loop);
})(0);
})();
/* ---------------- Ambient particles per element ---------------- */
function spawnParticles(containerId, el, count=14){
const box=document.getElementById(containerId); if(!box) return;
box.innerHTML='';
const color=ELEMENTS[el]?ELEMENTS[el].color:'#c084fc';
for(let i=0;i<count;i++){
const p=document.createElement('div'); p.className='particle';
p.style.left=rnd(2,96)+'%'; p.style.bottom=rnd(-10,40)+'%';
p.style.background=color; p.style.boxShadow=`0 0 8px ${color}`;
p.style.animationDuration=rnd(3,7)+'s'; p.style.animationDelay=rnd(0,5)+'s';
if(Math.random()<.4){ p.style.width='4px'; p.style.height='4px'; }
box.appendChild(p);
}
}
/* ---------------- Map rendering ---------------- */
function zoneNodeState(zone){
const nodes=defaultNodes(zone.id);
return nodes.map(n=>{
const done=P.completedNodes.includes(n.id);
const isBoss=n.type==='boss';
const unlocked=!isBoss || P.zonesUnlocked.includes(zone.id);
return {...n, done, unlocked};
});
}
function renderMap(){
updateHUD();
const tabs=$('#zone-tabs'); tabs.innerHTML='';
ZONES.forEach(z=>{
const unlocked=P.zonesUnlocked.includes(z.id);
const b=document.createElement('button');
b.className='tabbtn elg-'+z.el+(currentZone===z.id?' on':'');
b.innerHTML=`${ELEMENTS[z.el].icon} ${z.name}${unlocked?'':' 🔒'}`;
b.disabled=!unlocked;
b.onmouseenter=()=>SFX.hover();
b.onclick=()=>{ if(!unlocked){toast('Defeat the previous Gatekeeper first!'); SFX.fail(); return;} currentZone=z.id; renderMap(); };
tabs.appendChild(b);
});
const z=ZONE_BY_ID[currentZone];
$('#zone-title').textContent=z.name.toUpperCase();
$('#zone-desc').textContent=z.desc+` · Resonant Lv ${z.lv[0]}–${z.lv[1]}`;
const layer=$('#node-layer'); layer.innerHTML='';
const svg=$('#trail-svg');
const nodes=zoneNodeState(z);
let pts=[];
nodes.forEach(n=>{
pts.push([n.x,n.y]);
const d=document.createElement('div');
d.className='node'+(n.done?' done':'')+(!n.unlocked?' locked':'');
const isCurrent=nodes.findIndex(x=>!x.done)===nodes.indexOf(n);
if(isCurrent && !n.done) d.classList.add('current');
const icons={grass:'🌿', cache:'🎁', npc:'🧙', spring:'⛲', boss:'👑'};
d.innerHTML=icons[n.type]+`<span class="nlab">${n.label}</span>`;
d.style.left=n.x+'%'; d.style.top=n.y+'%';
if(n.type==='boss') d.classList.add('elg-'+z.el,'pulse-el');
d.onmouseenter=()=>SFX.hover();
d.onclick=()=>enterNode(z,n);
layer.appendChild(d);
});
svg.setAttribute('viewBox','0 0 100 100');
svg.innerHTML=`<polyline points="${pts.map(p=>p.join(',')).join(' ')}" fill="none" stroke="rgba(167,139,250,.35)" stroke-width="0.8" stroke-dasharray="2 2" stroke-linecap="round"/>`;
spawnParticles('map-particles', z.el, 16);
// party strip
const strip=$('#map-party'); strip.innerHTML='';
P.party.forEach(m=>{
const b=RES_BY_ID[m.id];
const d=document.createElement('div');
d.className='glass-soft elg-'+b.el+' px-3 py-2 flex items-center gap-2 cursor-pointer'+(m.active===false?' opacity-50':'');
d.innerHTML=`<div class="spr w-9 h-9">${spriteSVG(m.id)}</div>
<div><div class="text-xs font-bold">${b.name} ${m.active===false?'<span class="text-[9px] text-[color:var(--dim)]">(bench)</span>':''}</div>
<div class="bar w-20 mt-1 ${m.hp/m.maxhp<.25?'hp-low':m.hp/m.maxhp<.55?'hp-mid':''}"><i style="width:${100*m.hp/m.maxhp}%"></i></div></div>
<span class="text-[10px] text-[color:var(--dim)]">Lv${m.level}</span>`;
d.onclick=()=>showScreen('party');
strip.appendChild(d);
});
}
/* ---------------- Node interactions ---------------- */
async function enterNode(zone, node){
if(node.done && node.type!=='spring'){ toast('This node has already been cleared.'); SFX.fail(); return; }
SFX.select();
if(node.type==='grass'){ await startBattle({kind:'wild', zone}); }
else if(node.type==='cache'){ await openCache(zone, node); }
else if(node.type==='npc'){ const t=pick(NPC_TRAINERS); await startBattle({kind:'trainer', trainer:{...t, team:t.team.map(t2=>makeMon(t2[0],t2[1]))}}); }
else if(node.type==='spring'){ await useSpring(node); }
else if(node.type==='boss'){ await bossBattle(zone, node); }
saveGame(); renderMap();
}
async function openCache(zone, node){
const shardGain=ri(60,160)+zone.lv[0]*8;
const itemPool=['prism','prism','elixir','cleanser','hyperprism','booster'];
const item=pick(itemPool);
P.shards+=shardGain; P.items[item]=(P.items[item]||0)+1;
SFX.capture();
await showModal(`<div class="text-center"><div class="text-5xl mb-3">🎁</div>
<h3 class="font-pixel text-xs text-amber-300 mb-3">MYSTERY CACHE</h3>
<p class="text-sm">You found <b class="text-cyan-300">${shardGain} Aether Shards</b><br>and <b>${ITEMS[item].icon} ${ITEMS[item].name}</b>!</p></div>`,
[{label:'Excellent!', value:1, primary:true}]);
if(!P.completedNodes.includes(node.id)) P.completedNodes.push(node.id);
}
async function useSpring(node){
healParty(); SFX.heal();
await showModal(`<div class="text-center"><div class="text-5xl mb-3">⛲</div>
<h3 class="font-pixel text-xs text-sky-300 mb-3">HEALING SPRING</h3>
<p class="text-sm">Pure harmonic waters restore your entire party.<br><b class="text-emerald-300">All Resonants fully healed!</b></p></div>`,
[{label:'Thank the waters', value:1, primary:true}]);
}
/* =====================================================================
BATTLE ENGINE
===================================================================== */
let B=null;
function battleBanner(txt){ $('#battle-banner').innerHTML=txt; }
function setPanel(elClass, mon){
const b=RES_BY_ID[mon.id];
const pct=100*Math.max(0,mon.hp)/mon.maxhp;
const bar=$(elClass+'-hpbar');
bar.className='bar mt-2 '+(pct<25?'hp-low':pct<55?'hp-mid':'');
bar.querySelector('i').style.width=pct+'%';
$(elClass+'-name').textContent=b.name;
$(elClass+'-lv').textContent='Lv '+mon.level;
const chip=$(elClass+'-type');
chip.className='text-[10px] px-2 py-0.5 rounded-full el-chip elg-'+b.el;
chip.textContent=ELEMENTS[b.el].icon+' '+ELEMENTS[b.el].name;
const st=$(elClass+'-status');
st.textContent=mon.status?({burn:'🔥 BURN',sap:'🌿 SAP',stun:'⚡ STUN'})[mon.status]:'';
const panel=$(elClass+'-panel');
panel.className='glass p-3 relative overflow-hidden elg-'+b.el;
}
function renderBattle(){
setPanel('#enemy', B.enemy);
setPanel('#player', pMon());
const pm=pMon();
$('#player-sprite').innerHTML=spriteSVG(pm.id);
$('#enemy-sprite').innerHTML=spriteSVG(B.enemy.id);
$('#player-sprite').firstChild.classList.add('sprite-bob');
$('#enemy-sprite').firstChild.classList.add('sprite-bob','fast');
$('#player-hptext').textContent=`${Math.max(0,pm.hp)} / ${pm.maxhp} HP`;
const xpPct=100*(pm.xp - Math.pow(pm.level,3)*0.6 >=0 ? 0:0);
const cur=pm.xp, prev=Math.pow(pm.level,3)*0.6, next=xpToNext(pm.level);
$('#player-xpbar').querySelector('i').style.width=Math.min(100,100*(cur-prev)/(next-prev))+'%';
spawnParticles('enemy-particles', monEl(B.enemy), 10);
spawnParticles('player-particles', monEl(pm), 10);
}
function pMon(){ return activeParty()[B.pIdx]; }
function floatNum(side, txt, color){
const host=$(side==='enemy'?'#enemy-sprite':'#player-sprite');
const d=document.createElement('div');
d.className='float-num absolute left-1/2 top-2 -translate-x-1/2 text-lg z-20';
d.style.color=color||'#fff'; d.textContent=txt;
host.style.position='relative'; host.appendChild(d);
setTimeout(()=>d.remove(),1000);
}
function flashAndShake(side, crit){
const spr=$(side==='enemy'?'#enemy-sprite':'#player-sprite');
spr.classList.remove('hitflash'); void spr.offsetWidth; spr.classList.add('hitflash');
if(crit||side==='player'){ const pnl=$(side==='enemy'?'#enemy-panel':'#player-panel');
pnl.classList.remove('shake'); void pnl.offsetWidth; pnl.classList.add('shake'); }
}
function showMainActions(){ $('#action-main').classList.remove('hidden'); $('#action-sub').classList.add('hidden'); $('#action-back').classList.add('hidden'); }
function showSubActions(html){ $('#action-main').classList.add('hidden'); const sub=$('#action-sub'); sub.classList.remove('hidden'); sub.innerHTML=html; $('#action-back').classList.remove('hidden'); }
async function startBattle(opts){
B={kind:opts.kind, zone:opts.zone||null, trainer:opts.trainer||null, tIdx:0, pIdx:0, booster:false, over:false, busy:true, node:opts.node||null};
if(opts.kind==='wild'){
const z=opts.zone;
B.enemy=makeMon(pick(z.pool), ri(z.lv[0], z.lv[1]));
if(!P.dexSeen.includes(B.enemy.id)) P.dexSeen.push(B.enemy.id);
battleBanner(`A wild <b>${monName(B.enemy)}</b> (Lv ${B.enemy.level}) resonates from the frequencies!`);
} else {
B.enemy=B.trainer.team[0];
if(!P.dexSeen.includes(B.enemy.id)) P.dexSeen.push(B.enemy.id);
battleBanner(`<b>${B.trainer.name}</b>: ${B.trainer.quip}`);
if(B.kind==='boss') SFX.boss();
}
const first=activeParty().findIndex(m=>m.hp>0);
if(first<0){ toast('Your whole party has fainted!'); healParty(); }
B.pIdx=Math.max(0,first);
showScreen('battle');
renderBattle();
await wait(900);
B.busy=false; showMainActions();
}
/* ---------- damage ---------- */
function calcDamage(att, def, move){
const m=MOVES[move];
const eff=typeMod(m.el, monEl(def));
let atk=att.atk;
if(att.status==='burn' && m.el!=='cosmos') atk*=0.75;
const stab = monEl(att)===m.el ? 1.25 : 1;
const crit=Math.random()<0.10 ? 1.5 : 1;
const base=(((2*att.level/5+2)*m.pow*(atk/def.def))/50+2);
const dmg=Math.max(1, Math.floor(base*eff*stab*crit*rnd(0.85,1.00)));
return {dmg, eff, crit:crit>1};
}
function tryStatus(target, moveEl){
if(target.status || Math.random()>0.15) return null;
const map={pyro:'burn', volt:'stun', flora:'sap'};
const s=map[moveEl]; if(!s) return null;
target.status=s; return s;
}
async function endOfTurnStatus(mon, side){
if(mon.status==='burn'||mon.status==='sap'){
const frac=mon.status==='burn'?16:12;
const d=Math.max(1,Math.floor(mon.maxhp/frac));
mon.hp=Math.max(0,mon.hp-d);
floatNum(side,'-'+d,'#f87171');
battleBanner(`${monName(mon)} is hurt by ${mon.status==='burn'?'its burn':'the sapping spores'}!`);
renderBattle(); await wait(650);
if(mon.hp<=0) await handleFaint(side);
}
}
async function handleFaint(side){
const mon = side==='enemy'?B.enemy:pMon();
const spr=$(side==='enemy'?'#enemy-sprite':'#player-sprite');
spr.firstChild.classList.add('faint');
SFX.fail();
battleBanner(`${monName(mon)} fainted!`);
await wait(800);
if(side==='enemy'){
// reward & next enemy
const isTrainer=B.kind!=='wild';
const xp=Math.floor(B.enemy.level*14*(isTrainer?1.4:1));
const shards=Math.floor(B.enemy.level*(isTrainer?11:7)+ri(0,10));
P.shards+=shards; P.battlesWon++;
battleBanner(`${monName(B.enemy)} is defeated! +${xp} XP, +${shards} 💎`);
const pm=pMon();
const msgs=gainXP(pm, xp);
renderBattle(); await wait(700);
for(const msg of msgs){ battleBanner('✦ '+msg); SFX.levelUp(); await wait(850); renderBattle(); }
// trainer next mon?
if(isTrainer){
B.tIdx++;
const next=B.trainer.team[B.tIdx];
if(next){
if(!P.dexSeen.includes(next.id)) P.dexSeen.push(next.id);
B.enemy=next;
if(B.kind==='boss' && B.tIdx===B.trainer.team.length-1){
const bd=ZONE_BY_ID[B.zone.id].boss;
battleBanner(`<b>${bd.name}</b>: ${bd.mid}`); SFX.boss();
} else battleBanner(`<b>${B.trainer.name}</b> sends out <b>${monName(next)}</b>!`);
renderBattle(); await wait(1000);
B.busy=false; showMainActions(); return false;
}
// trainer defeated
await trainerDefeated();
return true;
}
await wildDefeated();
return true;
} else {
const next=activeParty().findIndex(m=>m.hp>0);
if(next<0){ await playerDefeated(); return true; }
B.pIdx=next;
battleBanner(`Go, <b>${monName(pMon())}</b>!`);
renderBattle(); await wait(800);
B.busy=false; showMainActions(); return false;
}
}
async function wildDefeated(){
B.over=true;
if(B.node && !P.completedNodes.includes(B.node.id)) P.completedNodes.push(B.node.id);
saveGame();
await wait(400);
endBattle(`Victory! The wild resonance fades. You return to the trail.`);
}
async function trainerDefeated(){
B.over=true;
if(B.node && !P.completedNodes.includes(B.node.id)) P.completedNodes.push(B.node.id);
const bonus=ri(120,260);
P.shards+=bonus;
SFX.capture();
if(B.kind==='boss'){
const bd=ZONE_BY_ID[B.zone.id].boss;
if(!P.clearedBosses.includes(B.zone.id)) P.clearedBosses.push(B.zone.id);
const zIdx=ZONES.findIndex(z=>z.id===B.zone.id);
const nextZone=ZONES[zIdx+1];
if(nextZone && !P.zonesUnlocked.includes(nextZone.id)) P.zonesUnlocked.push(nextZone.id);
saveGame();
const isFinal=B.zone.id==='apex';
await showModal(`<div class="text-center">
<div class="text-5xl mb-3">${isFinal?'👑':'🏆'}</div>
<h3 class="font-pixel text-xs text-amber-300 mb-3">${isFinal?'THE FINAL CHORD RESOLVES':'GATEKEEPER DEFEATED'}</h3>
<p class="text-sm italic leading-relaxed">"${bd.lose}"</p>
<p class="text-xs text-cyan-300 mt-3">+${bonus} bonus Aether Shards</p>
${isFinal?'<p class="text-xs text-violet-300 mt-3">You are the Grand Conductor of Aethelgard. The world hums your name — keep exploring, catching, and evolving!</p>'
:`<p class="text-xs text-emerald-300 mt-3">${nextZone?nextZone.name+' is now unlocked!':''}</p>`}</div>`,
[{label:isFinal?'Encore!':'Onward!', value:1, primary:true}]);
} else {
saveGame();
await showModal(`<div class="text-center"><div class="text-4xl mb-2">🏆</div>
<h3 class="font-pixel text-xs text-amber-300 mb-2">BATTLE WON</h3>
<p class="text-sm">${B.trainer.name} is defeated!<br>Bonus: <b class="text-cyan-300">${bonus} Aether Shards</b></p></div>`,
[{label:'Continue', value:1, primary:true}]);
}
endBattle('You return to the trail victorious.');
}
async function playerDefeated(){
B.over=true;
battleBanner('All your Resonants have fainted…');
await wait(800);
let extra='';
if(B.kind==='boss'){
const bd=ZONE_BY_ID[B.zone.id].boss;
extra=`<p class="text-sm italic leading-relaxed mt-2">"${bd.win}"</p>`;
}
await showModal(`<div class="text-center"><div class="text-4xl mb-2">💫</div>
<h3 class="font-pixel text-xs text-rose-300 mb-2">RESONANCE LOST</h3>
<p class="text-sm">You retreat to the nearest spring.<br>Your party is restored — but the node remains unclaimed.</p>${extra}</div>`,
[{label:'Recover', value:1, primary:true}]);
healParty(); saveGame();
endBattle('');
}
function endBattle(msg){
B=null;
showScreen('map');
if(msg) toast(msg);
}
/* ---------- player actions ---------- */
async function playerMove(moveId){
if(B.busy||B.over) return;
B.busy=true; showMainActions();
const pm=pMon(), em=B.enemy;
const mv=MOVES[moveId];
// stun check
if(pm.status==='stun' && Math.random()<0.25){
battleBanner(`${monName(pm)} is stunned and can't move!`); SFX.weak();
await wait(900);
} else {
battleBanner(`${monName(pm)} uses <b class="el-text elg-${mv.el}">${mv.name}</b>!`);
await wait(500);
if(Math.random()*100>mv.acc){
battleBanner(`…but it missed!`); SFX.weak(); await wait(800);
} else {
const {dmg,eff,crit}=calcDamage(pm,em,moveId);
em.hp=Math.max(0,em.hp-dmg);
if(eff>1){ SFX.superEff(); battleBanner(`It's harmonically SUPER EFFECTIVE!`); }
else if(eff<1){ SFX.weak(); battleBanner(`It's not very resonant…`); }
else if(mv.el==='cosmos') SFX.hitElem(); else SFX.hitPhys();
flashAndShake('enemy',crit);
floatNum('enemy','-'+dmg, crit?'#fde047':eff>1?'#f87171':'#fff');
if(crit) battleBanner(`A CRITICAL frequency strike! ${dmg} damage!`);
renderBattle();
const st=tryStatus(em, mv.el);
if(st){ battleBanner(`${monName(em)} is afflicted with ${st.toUpperCase()}!`); renderBattle(); }
await wait(900);
if(em.hp<=0){ const done=await handleFaint('enemy'); if(done||!B) return; if(B.over) return; B.busy=false; return; }
}
}
await enemyTurn();
}
async function enemyTurn(){
if(!B||B.over) return;
const em=B.enemy, pm=pMon();
if(em.status==='stun' && Math.random()<0.25){
battleBanner(`Wild ${monName(em)} is stunned and can't move!`); SFX.weak();
await wait(900);
} else {
const moveId=pick(em.moves);
const mv=MOVES[moveId];
battleBanner(`${monName(em)} uses <b class="el-text elg-${mv.el}">${mv.name}</b>!`);
await wait(500);
if(Math.random()*100>mv.acc){
battleBanner(`…but it missed!`); await wait(800);
} else {
const {dmg,eff,crit}=calcDamage(em,pm,moveId);
pm.hp=Math.max(0,pm.hp-dmg);
if(eff>1){ SFX.superEff(); battleBanner(`${monName(pm)} takes massive resonance damage!`); }
else if(eff<1){ SFX.weak(); }
else SFX.hitPhys();
flashAndShake('player',crit);
floatNum('player','-'+dmg, crit?'#fde047':'#f87171');
renderBattle();
const st=tryStatus(pm, mv.el);
if(st){ battleBanner(`${monName(pm)} is afflicted with ${st.toUpperCase()}!`); renderBattle(); }
await wait(900);
if(pm.hp<=0){ const done=await handleFaint('player'); if(done||!B) return; if(B.over) return; B.busy=false; return; }
}
}
await endOfTurnStatus(em,'enemy'); if(!B||B.over) return;
await endOfTurnStatus(pm,'player'); if(!B||B.over) return;
B.busy=false; showMainActions();
battleBanner('What will <b>'+monName(pMon())+'</b> do?');
}
/* ---------- prisms (capture) ---------- */
function captureChance(mon, prismKey){
const b=RES_BY_ID[mon.id];
const hpFactor=(3*mon.maxhp-2*mon.hp)/(3*mon.maxhp);
const tier=prismKey==='hyperprism'?1.9:1.0;
const status=mon.status?1.35:1.0;
const boost=B&&B.booster?1.4:1.0;
return Math.min(0.95, hpFactor*(b.catch/255)*tier*status*boost);
}
async function throwPrism(prismKey){
if(B.busy||B.over) return;
if(B.kind!=='wild'){ toast('You can\'t capture another Conductor\'s Resonant!'); SFX.fail(); return; }
if((P.items[prismKey]||0)<=0){ toast('None left! Visit the Camp Shop.'); SFX.fail(); return; }
B.busy=true; showMainActions();
P.items[prismKey]--;
const em=B.enemy;
battleBanner(`You hurl a ${ITEMS[prismKey].name} at ${monName(em)}!`);
SFX.select();
await wait(700);
const chance=captureChance(em,prismKey);
B.booster=false;
if(Math.random()<chance){
SFX.capture();
battleBanner(`✦ The prism harmonizes! <b>${monName(em)}</b> was captured!`);
em.status=null;
P.dexCaught.push(em.id);
if(P.party.length<6){ P.party.push(em); toast(`${monName(em)} joined your party!`); }
else { P.storage.push(em); toast(`${monName(em)} was sent to storage (party full).`); }
if(B.node && !P.completedNodes.includes(B.node.id)) P.completedNodes.push(B.node.id);
saveGame();
await wait(1200);
endBattle(`${monName(em)} now resonates with you!`);
} else {
SFX.fail();
battleBanner(`The prism shatters… ${monName(em)} broke free! (${Math.round(chance*100)}% harmonic match)`);
await wait(1000);
await enemyTurn();
}
}
/* ---------- items in battle ---------- */
async function useBattleItem(key){
if(B.busy||B.over) return;
if((P.items[key]||0)<=0){ toast('None left!'); SFX.fail(); return; }
const pm=pMon();
if(key==='elixir'){
if(pm.hp>=pm.maxhp){ toast(pm.name+' is already at full HP!'); SFX.fail(); return; }
B.busy=true; showMainActions();
P.items[key]--;
const heal=Math.floor(pm.maxhp*0.5);
pm.hp=Math.min(pm.maxhp,pm.hp+heal);
SFX.heal(); floatNum('player','+'+heal,'#4ade80');
battleBanner(`${monName(pm)} restores ${heal} HP with the Core Restorer!`);
renderBattle(); await wait(900);
await enemyTurn();
} else if(key==='cleanser'){
if(!pm.status){ toast('No status to cleanse!'); SFX.fail(); return; }
B.busy=true; showMainActions();
P.items[key]--; pm.status=null;
SFX.heal(); battleBanner(`${monName(pm)}'s resonance is purified!`);
renderBattle(); await wait(900);
await enemyTurn();
} else if(key==='booster'){
B.busy=true; showMainActions();
P.items[key]--; B.booster=true;
SFX.select(); battleBanner('Prism Booster armed! Your next throw is supercharged.');
await wait(900);
await enemyTurn();
} else if(key==='prism'||key==='hyperprism'){
await throwPrism(key);
}
}
/* ---------- swap ---------- */
async function swapTo(idx){
if(B.busy||B.over) return;
const list=activeParty();
if(list[idx]===pMon()){ toast('Already in battle!'); return; }
if(list[idx].hp<=0){ toast('That Resonant has fainted!'); SFX.fail(); return; }
B.busy=true; showMainActions();
battleBanner(`You exchange frequencies… Go, <b>${monName(list[idx])}</b>!`);
SFX.select();
B.pIdx=idx;
renderBattle(); await wait(900);
await enemyTurn();
}
/* ---------- action menus ---------- */
function openMoves(){
SFX.select();
const pm=pMon();
let html='<div class="grid sm:grid-cols-2 gap-2">';
pm.moves.forEach(mid=>{
const m=MOVES[mid];
html+=`<button class="btn movebtn elg-${m.el}" data-move="${mid}">
<span><b>${m.name}</b><br><span class="text-[10px] text-[color:var(--dim)]">${m.desc}</span></span>
<span class="text-right text-xs"><span class="el-text elg-${m.el}">${ELEMENTS[m.el].icon} ${ELEMENTS[m.el].name}</span><br>PWR ${m.pow} · ${m.acc}%</span></button>`;
});
html+='</div>';
showSubActions(html);
$$('#action-sub [data-move]').forEach(b=>b.onclick=()=>playerMove(b.dataset.move));
}
function openPrisms(){
SFX.select();
let html='<div class="grid sm:grid-cols-2 gap-2">';
['prism','hyperprism'].forEach(k=>{
html+=`<button class="btn movebtn" data-item="${k}" ${(P.items[k]||0)<=0?'disabled':''}>
<span><b>${ITEMS[k].icon} ${ITEMS[k].name}</b><br><span class="text-[10px] text-[color:var(--dim)]">Owned: ${P.items[k]||0}</span></span>
<span class="text-xs text-cyan-300">${B.kind==='wild'?'~'+Math.round(captureChance(B.enemy,k)*100)+'% match':'—'}</span></button>`;
});
html+='</div>';
showSubActions(html);
$$('#action-sub [data-item]').forEach(b=>b.onclick=()=>throwPrism(b.dataset.item));
}
function openBag(){
SFX.select();
const keys=['elixir','cleanser','booster','prism','hyperprism'];
let html='<div class="grid sm:grid-cols-2 gap-2">';
keys.forEach(k=>{
html+=`<button class="btn movebtn" data-item="${k}" ${(P.items[k]||0)<=0?'disabled':''}>
<span><b>${ITEMS[k].icon} ${ITEMS[k].name}</b><br><span class="text-[10px] text-[color:var(--dim)]">${ITEMS[k].desc}</span></span>
<span class="text-xs text-cyan-300">×${P.items[k]||0}</span></button>`;
});
html+='</div>';
showSubActions(html);
$$('#action-sub [data-item]').forEach(b=>b.onclick=()=>useBattleItem(b.dataset.item));
}
function openSwap(){
SFX.select();
const list=activeParty();
let html='<div class="grid sm:grid-cols-3 gap-2">';
list.forEach((m,i)=>{
const b=RES_BY_ID[m.id];
const pct=100*m.hp/m.maxhp;
html+=`<button class="btn movebtn elg-${b.el}" data-idx="${i}" ${m.hp<=0?'disabled':''}>
<span class="flex items-center gap-2"><span class="spr w-8 h-8">${spriteSVG(m.id)}</span>
<span class="text-xs"><b>${b.name}</b><br>Lv ${m.level}</span></span>
<span class="text-[10px] text-right">${m.hp}/${m.maxhp} HP<br>${m===pMon()?'<b class="text-emerald-300">ACTIVE</b>':m.hp<=0?'FAINTED':''}</span></button>`;
});
html+='</div>';
showSubActions(html);
$$('#action-sub [data-idx]').forEach(b=>b.onclick=()=>swapTo(+b.dataset.idx));
}
/* ---------- boss battle ---------- */
async function bossBattle(zone, node){
const bd=zone.boss;
const trainer={name:bd.name, quip:bd.intro, team:bd.team.map(t=>makeMon(t[0],t[1]))};
await showModal(`<div class="text-center">
<div class="text-5xl mb-3">👑</div>
<h3 class="font-pixel text-xs text-amber-300 mb-2">GATEKEEPER</h3>
<p class="text-sm italic leading-relaxed">"${bd.intro}"</p>
<p class="text-xs text-[color:var(--dim)] mt-2">${bd.name} — ${bd.title}<br>Team of ${bd.team.length} · Lv ${bd.team.map(t=>t[1]).join(', ')}</p></div>`,
[{label:'Accept the Challenge', value:1, primary:true},{label:'Not Yet', value:0}]);
await startBattle({kind:'boss', zone, node, trainer});
}
/* =====================================================================
DEX / SHOP / PARTY / MENU
===================================================================== */
function renderDex(){
const grid=$('#dex-grid'); grid.innerHTML='';
const caught=P.dexCaught.length;
$('#dex-count').textContent=`${caught} / ${RESONANTS.length} captured · ${P.dexSeen.length} observed`;
RESONANTS.forEach(r=>{
const seen=P.dexSeen.includes(r.id), isCaught=P.dexCaught.includes(r.id);
const card=document.createElement('div');
card.className='glass p-3 dexcard elg-'+r.el+(seen?'':' unknown');
card.innerHTML=`
<div class="flex items-center justify-between mb-1">
<span class="text-[10px] text-[color:var(--dim)]">#${String(RESONANTS.indexOf(r)+1).padStart(3,'0')}</span>
<span class="text-xs">${isCaught?'💠':seen?'👁':'?'}</span>
</div>
<div class="spr w-20 h-20 mx-auto">${spriteSVG(r.id)}</div>
<div class="text-center mt-1 text-sm font-bold">${seen?r.name:'???'}</div>
<div class="text-center mt-1">${seen?elementChip(r.el):''}</div>
${seen?`<div class="text-[10px] text-[color:var(--dim)] mt-2 grid grid-cols-4 gap-1 text-center">
<span>HP ${r.base.hp}</span><span>ATK ${r.base.atk}</span><span>DEF ${r.base.def}</span><span>SPD ${r.base.spd}</span></div>
<div class="text-[10px] text-[color:var(--dim)] mt-1 text-center">${r.evo?('Evolves → '+RES_BY_ID[r.evo].name+' @ Lv '+r.evoLv):'Final form'}</div>`:''}`;
card.onclick=()=>{ if(!seen){ SFX.fail(); return; } SFX.select();
showModal(`<div class="text-center">
<div class="spr w-32 h-32 mx-auto elg-${r.el}">${spriteSVG(r.id)}</div>
<h3 class="font-pixel text-sm mt-2 el-text elg-${r.el}">${r.name}</h3>
<div class="mt-2">${elementChip(r.el)} <span class="text-xs text-[color:var(--dim)]">Tier ${r.tier}</span></div>
<div class="text-xs mt-3 grid grid-cols-2 gap-1 text-left max-w-[240px] mx-auto">
<span>❤ HP ${r.base.hp}</span><span>⚔ ATK ${r.base.atk}</span><span>🛡 DEF ${r.base.def}</span><span>💨 SPD ${r.base.spd}</span></div>
<div class="text-xs mt-3 text-left">
<div class="font-bold text-violet-300 mb-1">Movepool</div>
${r.learn.map(l=>`<div class="flex justify-between text-[11px]"><span>${MOVES[l[0]].name}</span><span class="text-[color:var(--dim)]">Lv${l[1]} · ${MOVES[l[0]].pow}pwr</span></div>`).join('')}
</div>
<div class="text-[11px] text-[color:var(--dim)] mt-2">${r.evo?'Evolves into '+RES_BY_ID[r.evo].name+' at Lv '+r.evoLv+'.':'This Resonant has reached its final frequency.'}</div>
</div>`, [{label:'Close', value:1, primary:true}]);
};
grid.appendChild(card);
});
}
function renderShop(){
updateHUD();
const grid=$('#shop-grid'); grid.innerHTML='';
Object.entries(ITEMS).forEach(([k,it])=>{
const card=document.createElement('div');
card.className='glass p-4 flex items-center gap-3';
card.innerHTML=`<div class="text-3xl">${it.icon}</div>
<div class="flex-1 min-w-0">
<div class="font-bold text-sm">${it.name}</div>
<div class="text-[11px] text-[color:var(--dim)]">${it.desc}</div>
<div class="text-[11px] mt-1">Owned: <b>${P.items[k]||0}</b></div>
</div>
<button class="btn text-xs ${P.shards>=it.price?'btn-primary':''}" data-buy="${k}" ${P.shards<it.price?'disabled':''}>💎 ${it.price}</button>`;
grid.appendChild(card);
});
$$('#shop-grid [data-buy]').forEach(b=>b.onclick=()=>{
const k=b.dataset.buy, it=ITEMS[k];
if(P.shards<it.price){ SFX.fail(); return; }
P.shards-=it.price; P.items[k]=(P.items[k]||0)+1;
SFX.capture(); toast(`Purchased ${it.name}!`); saveGame(); renderShop();
});
}
function renderParty(){
const grid=$('#party-grid'); grid.innerHTML='';
if(P.party.length===0){ grid.innerHTML='<div class="glass p-6 text-center text-sm text-[color:var(--dim)]">No Resonants yet — venture into the tall grass!</div>'; return; }
P.party.forEach((m,i)=>{
const b=RES_BY_ID[m.id];
const pct=100*m.hp/m.maxhp;
const next=xpToNext(m.level), prev=Math.pow(m.level,3)*0.6;
const card=document.createElement('div');
card.className='glass p-4 elg-'+b.el;
card.innerHTML=`
<div class="flex gap-3 items-center">
<div class="spr w-20 h-20">${spriteSVG(m.id)}</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<b>${b.name}</b> ${elementChip(b.el)}
${m.active===false?'<span class="text-[9px] px-2 py-0.5 rounded-full bg-slate-700/60 text-slate-300">BENCH</span>':'<span class="text-[9px] px-2 py-0.5 rounded-full el-chip elg-'+b.el+'">ACTIVE</span>'}
</div>
<div class="text-[11px] text-[color:var(--dim)] mt-1">Lv ${m.level} · ATK ${m.atk} · DEF ${m.def} · SPD ${m.spd}</div>
<div class="bar mt-2 ${pct<25?'hp-low':pct<55?'hp-mid':''}"><i style="width:${pct}%"></i></div>
<div class="text-[10px] text-[color:var(--dim)] mt-1">${m.hp}/${m.maxhp} HP</div>
<div class="bar xp mt-1"><i style="width:${Math.min(100,100*(m.xp-prev)/(next-prev))}%"></i></div>
<div class="text-[10px] text-[color:var(--dim)] mt-0.5">XP ${m.xp} / ${next}</div>
${m.status?`<div class="text-[10px] text-fuchsia-300 mt-1">Status: ${m.status.toUpperCase()}</div>`:''}
</div>
</div>
<div class="text-[11px] mt-2 flex flex-wrap gap-1">
${m.moves.map(mid=>`<span class="px-2 py-0.5 rounded-md text-[10px] el-chip elg-${MOVES[mid].el}">${MOVES[mid].name} ${MOVES[mid].pow}p</span>`).join('')}
</div>
<div class="flex gap-2 mt-3">
<button class="btn text-xs flex-1" data-heal="${i}" ${P.items.elixir>0 && m.hp<m.maxhp?'':'disabled'}>🧪 Elixir (${P.items.elixir||0})</button>
<button class="btn text-xs flex-1" data-toggle="${i}">${m.active===false?'➕ Set Active':'➖ To Bench'}</button>
</div>`;
grid.appendChild(card);
});
$$('#party-grid [data-heal]').forEach(b=>b.onclick=()=>{
const m=P.party[+b.dataset.heal];
if((P.items.elixir||0)<=0||m.hp>=m.maxhp) return;
P.items.elixir--;
const heal=Math.floor(m.maxhp*0.5); m.hp=Math.min(m.maxhp,m.hp+heal);
SFX.heal(); toast(`${monName(m)} restored ${heal} HP!`); saveGame(); renderParty();
});
$$('#party-grid [data-toggle]').forEach(b=>b.onclick=()=>{
const m=P.party[+b.dataset.toggle];
if(m.active===false){
const actives=P.party.filter(x=>x.active!==false).length;
if(actives>=3){ toast('Active party is full (max 3)!'); SFX.fail(); return; }
m.active=true; SFX.select();
} else {
if(P.party.filter(x=>x.active!==false).length<=1){ toast('You need at least one active Resonant!'); SFX.fail(); return; }
m.active=false; SFX.back();
}
saveGame(); renderParty();
});
}
function renderMenu(){
$('#btn-continue').style.display=P&&P.party.length?'':'none';
}
/* ---------------- New game / starter ---------------- */
const STARTERS=['emberkit','droplet','seedling','sparky','gusty','pebble'];
async function chooseStarter(){
return new Promise(res=>{
const box=$('#modal-box');
box.innerHTML=`<div class="text-center">
<h3 class="font-pixel text-xs text-violet-200 mb-2">CHOOSE YOUR FIRST RESONANT</h3>
<p class="text-xs text-[color:var(--dim)] mb-4">Six frequencies answer your tuning fork. One will become your partner.</p>
<div class="grid grid-cols-3 gap-2" id="starter-grid"></div></div>`;
const g=box.querySelector('#starter-grid');
STARTERS.forEach(id=>{
const r=RES_BY_ID[id];
const d=document.createElement('button');
d.className='glass-soft p-2 elg-'+r.el+' dexcard';
d.innerHTML=`<div class="spr w-14 h-14 mx-auto">${spriteSVG(id)}</div>
<div class="text-[11px] font-bold mt-1">${r.name}</div>
<div class="text-[9px] el-text elg-${r.el}">${ELEMENTS[r.el].icon} ${ELEMENTS[r.el].name}</div>`;
d.onmouseenter=()=>SFX.hover();
d.onclick=()=>{ SFX.capture(); $('#modal').classList.remove('show'); res(id); };
g.appendChild(d);
});
$('#modal').classList.add('show');
});
}
async function newGame(){
P=newPlayer();
const starter=await chooseStarter();
const mon=makeMon(starter,5);
mon.active=true;
P.party.push(mon);
P.dexSeen.push(starter); P.dexCaught.push(starter);
saveGame();
toast(`${RES_BY_ID[starter].name} joins your journey!`);
currentZone='canopy';
showScreen('map');
}
/* ---------------- Wiring ---------------- */
function wire(){
$('#btn-newgame').onclick=async()=>{ SFX.select();
if(loadGame()){ const ok=await showModal('<p class="text-sm text-center">Starting a new journey will <b class="text-rose-300">overwrite your existing save</b>. Continue?</p>',
[{label:'Overwrite', value:1, danger:true},{label:'Cancel', value:0}]);
if(!ok) return; }
newGame();
};
$('#btn-continue').onclick=()=>{ SFX.select(); const s=loadGame(); if(s){ P=s; ensureActiveDefaults(); showScreen('map'); } else { toast('No save found — start a new journey!'); SFX.fail(); } };
$('#btn-menu-dex').onclick=()=>{ if(!P){ P=loadGame()||newPlayer(); } showScreen('dex'); };
$('#btn-reset').onclick=async()=>{ SFX.select();
const ok=await showModal('<p class="text-sm text-center">Erase <b class="text-rose-300">all progress</b> permanently?</p>',
[{label:'Erase Everything', value:1, danger:true},{label:'Keep Progress', value:0}]);
if(ok){ localStorage.removeItem(SAVE_KEY); P=null; toast('Progress erased.'); renderMenu(); }
};
$$('[data-nav]').forEach(b=>b.onclick=()=>{
const t=b.dataset.nav; SFX.select();
if(t==='menu'){ showScreen('menu'); return; }
if(t==='back'){ showScreen(P&&P.party.length?'map':'menu'); return; }
showScreen(t);
});
$('#action-back').onclick=()=>{ SFX.back(); if(B&&!B.busy) showMainActions(); };
$$('#action-main [data-act]').forEach(b=>{
b.onmouseenter=()=>SFX.hover();
b.onclick=()=>{ if(!B||B.busy||B.over) return;
const a=b.dataset.act;
if(a==='attack') openMoves();
else if(a==='prism') openPrisms();
else if(a==='swap') openSwap();
else if(a==='bag') openBag();
};
});
// generic hover sound on buttons
document.addEventListener('mouseover',e=>{ if(e.target.closest('.btn,.tabbtn,.node')) SFX.hover(); }, {passive:true});
// unlock audio on first interaction
const unlock=()=>{ SFX.ensure(); document.removeEventListener('pointerdown',unlock); };
document.addEventListener('pointerdown',unlock);
}
/* ---------------- Boot ---------------- */
function boot(){
wire();
P=loadGame();
renderMenu();
if(P){ ensureActiveDefaults(); $('#btn-continue').textContent='▶ Continue — Lv '+Math.max(...P.party.map(m=>m.level),1)+' partner'; }
}
boot();
</script>
</body>
</html>
<!-- agent-meta {"model":"z-ai/glm-5.3-flash","provider":"openrouter","persona":"full","sessionId":"d9c0bc50-10c5-4dd3-93f6-99edee702bdf","tokensIn":1656604,"tokensOut":38093,"tokensTotal":1694697,"cost":0.04673786499999999,"turns":37,"toolCalls":37,"failedToolCalls":0,"timestamp":"2026-08-28T04:53:53.445Z"} -->