Aethelgard
Deepseek v4.1 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 158,123 bytes · SHA-256 518c04b20f4d
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aethelgard — Resonance Chronicles</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Press+Start+2P&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
:root{
--bg:#07060d;
--panel:rgba(20,18,34,.62);
--panel2:rgba(30,26,50,.72);
--stroke:rgba(255,255,255,.09);
--txt:#eceaf8;
--dim:#9c98b8;
--acc:#8b5cf6;
--acc2:#22d3ee;
--fh:'Press Start 2P','Courier New',monospace;
--fb:'Inter',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
}
html,body{height:100%;width:100%}
body{
margin:0;background:var(--bg);color:var(--txt);font-family:var(--fb);
overflow:hidden;user-select:none;
}
#bgfx{
position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none;
background:
radial-gradient(1100px 780px at 12% 8%, rgba(139,92,246,.26), transparent 62%),
radial-gradient(900px 700px at 92% 84%, rgba(34,211,238,.17), transparent 62%),
radial-gradient(760px 620px at 62% 26%, rgba(255,122,41,.11), transparent 64%),
linear-gradient(158deg,#08070f,#0e0c1a 46%,#08070f);
}
#bgfx .orb{position:absolute;border-radius:50%;filter:blur(70px);opacity:.42;animation:orbFloat 22s ease-in-out infinite}
#bgfx .orb.o1{width:420px;height:420px;left:-90px;top:-80px;background:#7c3aed}
#bgfx .orb.o2{width:380px;height:380px;right:-70px;bottom:-90px;background:#06b6d4;animation-delay:-7s}
#bgfx .orb.o3{width:300px;height:300px;left:52%;top:58%;background:#f97316;opacity:.22;animation-delay:-13s}
@keyframes orbFloat{0%,100%{transform:translate3d(0,0,0) scale(1)}33%{transform:translate3d(46px,-38px,0) scale(1.12)}66%{transform:translate3d(-34px,32px,0) scale(.94)}}
#bgfx .grid{position:absolute;inset:-2px;opacity:.16;
background-image:linear-gradient(rgba(255,255,255,.08) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.08) 1px,transparent 1px);
background-size:52px 52px;
mask-image:radial-gradient(circle at 50% 45%, #000 0%, transparent 78%);
-webkit-mask-image:radial-gradient(circle at 50% 45%, #000 0%, transparent 78%);}
#fxflash{position:fixed;inset:0;z-index:60;pointer-events:none;background:#fff;opacity:0;transition:opacity .12s}
#vignette{position:fixed;inset:0;z-index:55;pointer-events:none;box-shadow:inset 0 0 160px 30px rgba(0,0,0,.75)}
#app{position:relative;z-index:10;width:100vw;height:100vh;overflow:hidden}
.screen{
position:absolute;inset:0;opacity:0;transform:translateX(46px) scale(.985);
pointer-events:none;transition:opacity .38s cubic-bezier(.4,0,.2,1),transform .38s cubic-bezier(.4,0,.2,1);
display:flex;flex-direction:column;
}
.screen.active{opacity:1;transform:none;pointer-events:auto;z-index:20}
.screen.leaving{opacity:0;transform:translateX(-46px) scale(.985);pointer-events:none;z-index:10}
.glass{
background:var(--panel);
backdrop-filter:blur(16px) saturate(140%);
-webkit-backdrop-filter:blur(16px) saturate(140%);
border:1px solid var(--stroke);
border-radius:18px;
box-shadow:0 18px 44px -22px rgba(0,0,0,.95), inset 0 1px 0 rgba(255,255,255,.05);
}
.glass-strong{background:var(--panel2)}
.hd{font-family:var(--fh);letter-spacing:.5px}
/* ---------- Type theming ---------- */
.t-Pyro{--el:#ff7a29;--el2:#ffc247;--elrgb:255,122,41}
.t-Hydro{--el:#38bdf8;--el2:#3b82f6;--elrgb:56,189,248}
.t-Flora{--el:#4ade80;--el2:#15803d;--elrgb:74,222,128}
.t-Zephyr{--el:#a5f3fc;--el2:#38bdf8;--elrgb:165,243,252}
.t-Earth{--el:#f59e0b;--el2:#92400e;--elrgb:245,158,11}
.t-Volt{--el:#fde047;--el2:#eab308;--elrgb:253,224,71}
.t-Cosmos{--el:#c084fc;--el2:#7c3aed;--elrgb:192,132,252}
.type-badge{
--el:#8b5cf6;--el2:#6d28d9;--elrgb:139,92,246;
display:inline-flex;align-items:center;gap:5px;
font-size:9px;font-weight:800;letter-spacing:.13em;text-transform:uppercase;
padding:3px 9px;border-radius:999px;
color:var(--el);
background:rgba(var(--elrgb),.13);
border:1px solid rgba(var(--elrgb),.5);
box-shadow:0 0 14px -2px rgba(var(--elrgb),.55), inset 0 0 12px -6px rgba(var(--elrgb),.7);
white-space:nowrap;
}
.type-badge.t-Pyro{--el:#ff7a29;--el2:#ffc247;--elrgb:255,122,41}
.type-badge.t-Hydro{--el:#38bdf8;--el2:#3b82f6;--elrgb:56,189,248}
.type-badge.t-Flora{--el:#4ade80;--el2:#15803d;--elrgb:74,222,128}
.type-badge.t-Zephyr{--el:#a5f3fc;--el2:#38bdf8;--elrgb:165,243,252}
.type-badge.t-Earth{--el:#f59e0b;--el2:#92400e;--elrgb:245,158,11}
.type-badge.t-Volt{--el:#fde047;--el2:#eab308;--elrgb:253,224,71}
.type-badge.t-Cosmos{--el:#c084fc;--el2:#7c3aed;--elrgb:192,132,252}
.type-badge.sm{font-size:8px;padding:2px 7px}
/* ---------- Buttons ---------- */
.btn{
position:relative;font-family:var(--fb);font-weight:700;font-size:13px;
color:var(--txt);background:rgba(255,255,255,.05);
border:1px solid var(--stroke);border-radius:12px;
padding:11px 18px;cursor:pointer;
transition:transform .12s ease, background .18s ease, box-shadow .18s ease, border-color .18s;
display:inline-flex;align-items:center;justify-content:center;gap:8px;
overflow:hidden;
}
.btn:hover{background:rgba(139,92,246,.18);border-color:rgba(139,92,246,.6);transform:translateY(-2px);box-shadow:0 10px 26px -12px rgba(139,92,246,.9)}
.btn:active{transform:translateY(0) scale(.98)}
.btn:disabled{opacity:.38;cursor:not-allowed;transform:none!important;box-shadow:none}
.btn.primary{background:linear-gradient(135deg,rgba(139,92,246,.85),rgba(34,211,238,.65));border-color:rgba(180,160,255,.6);box-shadow:0 14px 34px -16px rgba(139,92,246,1)}
.btn.primary:hover{background:linear-gradient(135deg,rgba(160,118,255,.95),rgba(56,220,250,.8))}
.btn.ghost{background:transparent}
.btn.danger:hover{background:rgba(239,68,68,.2);border-color:rgba(239,68,68,.65);box-shadow:0 10px 26px -12px rgba(239,68,68,.9)}
.btn.xl{font-size:15px;padding:15px 40px;border-radius:14px}
.btn.sm{font-size:11px;padding:7px 12px;border-radius:9px}
.btn.wide{width:100%}
.btn.el{border-color:rgba(var(--elrgb,.139,92,246),.55);background:rgba(var(--elrgb),.10)}
.btn.el:hover{background:rgba(var(--elrgb),.22);box-shadow:0 10px 26px -12px rgba(var(--elrgb),.95)}
.icon-btn{width:40px;height:40px;border-radius:12px;display:grid;place-items:center;cursor:pointer;border:1px solid var(--stroke);background:rgba(255,255,255,.045);transition:all .16s}
.icon-btn:hover{background:rgba(139,92,246,.22);border-color:rgba(139,92,246,.6);transform:translateY(-2px)}
/* ---------- Bars ---------- */
.bar{position:relative;height:9px;border-radius:99px;background:rgba(0,0,0,.55);overflow:hidden;border:1px solid rgba(255,255,255,.07);box-shadow:inset 0 1px 3px rgba(0,0,0,.7)}
.bar>i{display:block;height:100%;width:100%;border-radius:99px;transition:width .55s cubic-bezier(.3,1.4,.5,1), background .4s}
.bar.hp>i{background:linear-gradient(90deg,#22c55e,#86efac)}
.bar.hp.mid>i{background:linear-gradient(90deg,#eab308,#fde047)}
.bar.hp.low>i{background:linear-gradient(90deg,#dc2626,#f87171)}
.bar.hp.low>i{animation:lowPulse 1s infinite}
@keyframes lowPulse{0%,100%{filter:brightness(1)}50%{filter:brightness(1.55)}}
.bar.xp{height:6px}
.bar.xp>i{background:linear-gradient(90deg,#22d3ee,#8b5cf6)}
.bar.thin{height:5px}
/* ---------- Title ---------- */
.title-wrap{margin:auto;text-align:center;padding:26px;max-width:920px;width:100%}
.game-title{
font-family:var(--fh);font-size:clamp(28px,7.2vw,72px);line-height:1.28;
background:linear-gradient(180deg,#fff 8%,#c8b6ff 46%,#22d3ee 100%);
-webkit-background-clip:text;background-clip:text;color:transparent;
filter:drop-shadow(0 0 34px rgba(139,92,246,.75));
animation:titleGlow 4.5s ease-in-out infinite;
}
.game-title span{display:inline-block;animation:letterPop .7s cubic-bezier(.2,1.5,.4,1) backwards}
@keyframes letterPop{from{opacity:0;transform:translateY(26px) rotate(-8deg) scale(.7)}to{opacity:1;transform:none}}
@keyframes titleGlow{0%,100%{filter:drop-shadow(0 0 26px rgba(139,92,246,.6))}50%{filter:drop-shadow(0 0 48px rgba(34,211,238,.7))}}
.subtitle{font-size:clamp(10px,1.7vw,13px);letter-spacing:.42em;text-transform:uppercase;color:#b3a9e0;margin-top:20px}
.tagline{margin:22px auto 0;color:var(--dim);font-size:14px;max-width:560px;line-height:1.75}
.title-orb{width:132px;height:132px;margin:0 auto 26px;position:relative}
.title-orb .ring{position:absolute;inset:0;border-radius:50%;border:2px solid rgba(139,92,246,.45);animation:spin 9s linear infinite}
.title-orb .ring:nth-child(2){inset:16px;border-color:rgba(34,211,238,.5);animation-duration:6s;animation-direction:reverse;border-style:dashed}
.title-orb .ring:nth-child(3){inset:32px;border-color:rgba(255,122,41,.5);animation-duration:13s}
.title-orb .core{position:absolute;inset:46px;border-radius:50%;background:radial-gradient(circle at 35% 30%,#fff,#a78bfa 45%,#4c1d95);box-shadow:0 0 44px 10px rgba(139,92,246,.75);animation:corePulse 2.6s ease-in-out infinite}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes corePulse{0%,100%{transform:scale(1)}50%{transform:scale(1.14)}}
.menu-col{display:flex;flex-direction:column;gap:12px;width:min(330px,88vw);margin:36px auto 0}
/* ---------- Layout shells ---------- */
.page{display:flex;flex-direction:column;height:100%;padding:14px;gap:12px}
.topbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.brand{font-family:var(--fh);font-size:13px;background:linear-gradient(90deg,#c4b5fd,#67e8f9);-webkit-background-clip:text;background-clip:text;color:transparent}
.hud-right{display:flex;align-items:center;gap:9px;flex-wrap:wrap}
.pill{display:inline-flex;align-items:center;gap:7px;padding:7px 13px;border-radius:99px;font-size:12px;font-weight:700;background:rgba(255,255,255,.05);border:1px solid var(--stroke)}
.pill.shard{border-color:rgba(34,211,238,.45);color:#7de3f7;box-shadow:0 0 18px -6px rgba(34,211,238,.85)}
/* ---------- Map ---------- */
.map-layout{flex:1;display:grid;grid-template-columns:284px 1fr;gap:12px;min-height:0}
.zone-info{padding:16px;display:flex;flex-direction:column;gap:12px;overflow:auto}
.zone-info h2{font-family:var(--fh);font-size:14px;margin:0;line-height:1.7}
.zone-info p{margin:0;font-size:12.5px;color:var(--dim);line-height:1.72}
.zone-item{display:flex;gap:10px;align-items:center;padding:9px 11px;border-radius:12px;border:1px solid var(--stroke);background:rgba(255,255,255,.035);cursor:pointer;transition:all .16s}
.zone-item:hover{background:rgba(139,92,246,.16);transform:translateX(3px)}
.zone-item.locked{opacity:.42;cursor:not-allowed;filter:grayscale(.6)}
.zone-item.curr{border-color:rgba(var(--elrgb,139,92,246),.7);background:rgba(var(--elrgb,139,92,246),.14);box-shadow:0 0 22px -8px rgba(var(--elrgb,139,92,246),.9)}
.zone-dot{width:11px;height:11px;border-radius:50%;background:var(--el,#8b5cf6);box-shadow:0 0 12px 2px rgba(var(--elrgb,139,92,246),.85);flex:0 0 auto}
.zone-item small{display:block;font-size:10.5px;color:var(--dim);margin-top:2px}
.map-canvas{position:relative;overflow:hidden;min-height:0;background:linear-gradient(180deg,rgba(10,9,20,.5),rgba(14,12,26,.72))}
#mapPaths{position:absolute;inset:0;width:100%;height:100%}
.node{
position:absolute;transform:translate(-50%,-50%);
width:76px;height:76px;border-radius:50%;display:grid;place-items:center;cursor:pointer;
background:radial-gradient(circle at 34% 28%, rgba(255,255,255,.14), rgba(255,255,255,.03) 65%);
border:1.5px solid rgba(255,255,255,.16);
transition:transform .18s cubic-bezier(.3,1.6,.5,1),box-shadow .2s,border-color .2s;
animation:none;
}
.node:hover{transform:translate(-50%,-50%) scale(1.13)}
.node .nicon{font-size:26px;filter:drop-shadow(0 2px 5px rgba(0,0,0,.8))}
.node .nlabel{position:absolute;bottom:-19px;font-size:9.5px;font-weight:700;letter-spacing:.05em;color:#cfc9ea;white-space:nowrap;text-shadow:0 2px 6px #000}
.node.locked{opacity:.34;cursor:not-allowed;filter:grayscale(.9)}
.node.locked:hover{transform:translate(-50%,-50%)}
.node.avail{border-color:rgba(var(--elrgb,139,92,246),.75);box-shadow:0 0 0 5px rgba(var(--elrgb,139,92,246),.13),0 0 30px -6px rgba(var(--elrgb,139,92,246),.95);animation:nodePulse 2.2s ease-in-out infinite}
@keyframes nodePulse{0%,100%{box-shadow:0 0 0 5px rgba(var(--elrgb,139,92,246),.13),0 0 26px -8px rgba(var(--elrgb,139,92,246),.9)}50%{box-shadow:0 0 0 11px rgba(var(--elrgb,139,92,246),.05),0 0 42px -4px rgba(var(--elrgb,139,92,246),1)}}
.node.done{opacity:.72;border-color:rgba(74,222,128,.5)}
.node.boss{border-width:2.5px}
.node.boss::after{content:'';position:absolute;inset:-11px;border-radius:50%;border:1px dashed rgba(239,68,68,.55);animation:spin 14s linear infinite}
.node.here::before{content:'';position:absolute;width:14px;height:14px;border-radius:50%;background:#fff;box-shadow:0 0 18px 6px rgba(255,255,255,.9);animation:corePulse 1.6s infinite}
.map-info{position:absolute;left:12px;bottom:12px;max-width:330px;padding:13px 15px;border-radius:14px;font-size:12.5px;line-height:1.6;color:var(--dim)}
/* ---------- Party chips / bottom bar ---------- */
.bottombar{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
.party-strip{display:flex;gap:10px;flex-wrap:wrap}
.pchip{display:flex;align-items:center;gap:9px;padding:7px 12px 7px 7px;border-radius:14px;border:1px solid var(--stroke);background:rgba(255,255,255,.04);min-width:172px;transition:all .16s}
.pchip:hover{border-color:rgba(var(--elrgb,139,92,246),.6);box-shadow:0 0 20px -8px rgba(var(--elrgb,139,92,246),.9)}
.pchip.fainted{opacity:.5;filter:grayscale(.5)}
.pchip svg{width:38px;height:38px;flex:0 0 auto}
.pchip .pmeta{flex:1;min-width:0}
.pchip .pname{font-size:11.5px;font-weight:700;display:flex;justify-content:space-between;gap:6px}
.pchip .pname span{color:var(--dim);font-weight:600;font-size:10px}
/* ---------- Battle ---------- */
#screen-battle{padding:0}
.battle-wrap{position:relative;flex:1;display:flex;flex-direction:column;min-height:0}
.arena{
position:relative;flex:1;min-height:0;overflow:hidden;
background:
radial-gradient(760px 440px at 70% 22%, rgba(var(--aelrgb,139,92,246),.24), transparent 66%),
radial-gradient(700px 500px at 22% 88%, rgba(34,211,238,.14), transparent 68%),
linear-gradient(180deg,#0b0a16,#120f22 55%,#0a0914);
}
.arena::after{content:'';position:absolute;left:0;right:0;bottom:0;height:46%;background:linear-gradient(180deg,transparent,rgba(0,0,0,.45))}
.arena-grid{position:absolute;inset:0;opacity:.13;background-image:linear-gradient(rgba(255,255,255,.16) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.16) 1px,transparent 1px);background-size:46px 46px;mask-image:linear-gradient(180deg,transparent,#000 40%,#000);-webkit-mask-image:linear-gradient(180deg,transparent,#000 40%,#000)}
.platform{position:absolute;border-radius:50%;background:radial-gradient(ellipse at center, rgba(var(--elrgb,139,92,246),.4), transparent 70%);filter:blur(1px)}
.slot{position:absolute;display:flex;flex-direction:column;gap:10px}
.slot.enemy{right:8%;top:6%;align-items:flex-end}
.slot.player{left:8%;bottom:7%;align-items:flex-start}
.sprite-wrap{position:relative;filter:drop-shadow(0 16px 26px rgba(0,0,0,.8))}
.sprite-wrap svg{display:block}
.sprite-wrap.bob{animation:bobA 2.6s ease-in-out infinite}
.sprite-wrap.enemy-bob{animation:bobB 2.9s ease-in-out infinite}
@keyframes bobA{0%,100%{transform:translateY(0)}50%{transform:translateY(-11px)}}
@keyframes bobB{0%,100%{transform:translateY(0) scaleX(-1)}50%{transform:translateY(-13px) scaleX(-1)}}
.sprite-wrap.hitflash{animation:hitFlash .34s ease-out}
@keyframes hitFlash{0%{filter:brightness(4) saturate(0) drop-shadow(0 0 26px #fff);transform:translateX(-9px) scale(1.06)}60%{filter:brightness(1.6);transform:translateX(6px)}100%{filter:none;transform:none}}
.sprite-wrap.fainted{animation:faintDrop .7s cubic-bezier(.6,0,.9,.4) forwards}
@keyframes faintDrop{to{transform:translateY(76px) rotate(16deg) scale(.72);opacity:0}}
.sprite-wrap.charge{animation:chargeUp .6s ease-in-out}
@keyframes chargeUp{0%,100%{transform:none}50%{transform:translateY(-14px) scale(1.13);filter:brightness(1.7) drop-shadow(0 0 30px #fff)}}
.info-panel{min-width:266px;padding:12px 14px;border-radius:15px}
.info-panel .row1{display:flex;justify-content:space-between;align-items:center;gap:10px}
.info-panel .nm{font-weight:800;font-size:14px;letter-spacing:.02em}
.info-panel .lv{font-size:10px;font-weight:800;color:#cfc9ea;background:rgba(255,255,255,.08);padding:2px 8px;border-radius:99px;border:1px solid var(--stroke)}
.info-panel .hpline{display:flex;align-items:center;gap:8px;margin-top:9px}
.info-panel .hpnum{font-size:10.5px;color:var(--dim);font-weight:700;min-width:52px;text-align:right;font-variant-numeric:tabular-nums}
.info-panel .xpline{margin-top:7px}
.status-chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:7px;min-height:0}
.schip{font-size:8.5px;font-weight:800;letter-spacing:.09em;text-transform:uppercase;padding:2.5px 7px;border-radius:99px;border:1px solid currentColor}
.schip.burn{color:#fb923c;background:rgba(251,146,60,.13)}
.schip.venom{color:#a3e635;background:rgba(163,230,53,.13)}
.schip.chill{color:#7dd3fc;background:rgba(125,211,252,.13)}
.schip.stun{color:#fde047;background:rgba(253,224,71,.13)}
.schip.buff{color:#c4b5fd;background:rgba(196,181,253,.13)}
.party-dots{display:flex;gap:5px;margin-top:8px}
.pdot{width:9px;height:9px;border-radius:50%;background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.22)}
.pdot.alive{background:#22c55e;box-shadow:0 0 9px rgba(34,197,94,.9)}
.pdot.dead{background:#7f1d1d}
.pdot.act{outline:2px solid #fff;outline-offset:1px}
.battle-bottom{display:grid;grid-template-columns:1fr minmax(300px,432px);gap:12px;padding:12px;height:238px;flex:0 0 auto}
.logbox{padding:15px 18px;font-size:14.5px;line-height:1.7;overflow:auto;position:relative}
.logbox .cursor{display:inline-block;width:9px;height:16px;background:#c4b5fd;vertical-align:-2px;animation:blink .8s steps(1) infinite}
@keyframes blink{0%,50%{opacity:1}51%,100%{opacity:0}}
.logbox .past{color:var(--dim);font-size:12.5px;line-height:1.65}
.menu-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:9px;padding:12px;align-content:start;overflow:auto}
.movebtn{text-align:left;display:flex;flex-direction:column;gap:3px;padding:10px 13px;border-radius:12px;border:1px solid rgba(var(--elrgb,139,92,246),.42);background:rgba(var(--elrgb,139,92,246),.09);cursor:pointer;transition:all .16s;color:var(--txt)}
.movebtn:hover{background:rgba(var(--elrgb,139,92,246),.22);transform:translateY(-2px);box-shadow:0 12px 26px -14px rgba(var(--elrgb,139,92,246),1)}
.movebtn:disabled{opacity:.4;cursor:not-allowed;transform:none;box-shadow:none}
.movebtn .mn{font-weight:800;font-size:12.5px;display:flex;align-items:center;gap:6px;justify-content:space-between}
.movebtn .ms{font-size:10px;color:var(--dim);display:flex;gap:9px;font-weight:600;font-variant-numeric:tabular-nums}
.battle-hint{grid-column:1/-1;font-size:11px;color:var(--dim);text-align:center;padding-top:2px}
.float-dmg{position:absolute;font-family:var(--fh);font-size:19px;pointer-events:none;z-index:40;animation:dmgFloat 1.15s cubic-bezier(.2,.9,.3,1) forwards;text-shadow:0 3px 10px rgba(0,0,0,.9), 0 0 18px currentColor}
@keyframes dmgFloat{0%{opacity:0;transform:translate(-50%,10px) scale(.5)}22%{opacity:1;transform:translate(-50%,-14px) scale(1.22)}100%{opacity:0;transform:translate(-50%,-72px) scale(.95)}}
.shake{animation:shakeAll .5s cubic-bezier(.36,.07,.19,.97)}
@keyframes shakeAll{10%,90%{transform:translate3d(-7px,2px,0)}20%,80%{transform:translate3d(9px,-3px,0)}30%,50%,70%{transform:translate3d(-13px,4px,0)}40%,60%{transform:translate3d(13px,-4px,0)}}
.sparks{position:absolute;inset:0;pointer-events:none;z-index:39;overflow:hidden}
.spark{position:absolute;width:8px;height:8px;border-radius:50%;animation:sparkFly .7s ease-out forwards}
@keyframes sparkFly{0%{opacity:1;transform:translate(0,0) scale(1)}100%{opacity:0;transform:translate(var(--dx),var(--dy)) scale(.2)}}
.orb-proj{position:absolute;width:30px;height:30px;border-radius:50%;z-index:38;pointer-events:none;box-shadow:0 0 26px 6px currentColor}
.drift{position:absolute;width:6px;height:6px;border-radius:50%;pointer-events:none;animation:driftUp 2.4s linear infinite;opacity:.75}
@keyframes driftUp{0%{transform:translateY(0) scale(.6);opacity:0}18%{opacity:.9}100%{transform:translateY(-72px) scale(1.15);opacity:0}}
/* prism capture animation */
.prism-anim{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:45;pointer-events:none}
.prism-anim svg{animation:prismThrow .8s cubic-bezier(.2,1.4,.4,1) forwards}
@keyframes prismThrow{0%{transform:translate(230px,190px) scale(.4) rotate(-160deg);opacity:0}55%{opacity:1}100%{transform:translate(0,0) scale(1) rotate(0)}}
.prism-anim.shakeit svg{animation:prismShake .42s ease-in-out infinite}
@keyframes prismShake{0%,100%{transform:rotate(-13deg)}50%{transform:rotate(13deg)}}
.prism-anim.caught svg{animation:prismCaught .9s ease-out forwards}
@keyframes prismCaught{0%{filter:brightness(1)}30%{filter:brightness(3.2) drop-shadow(0 0 34px #fff)}100%{filter:brightness(1) drop-shadow(0 0 26px rgba(255,255,255,.9))}}
.burst{position:absolute;left:50%;top:50%;width:20px;height:20px;border-radius:50%;transform:translate(-50%,-50%);border:3px solid rgba(255,255,255,.9);animation:burstOut .85s ease-out forwards;z-index:44}
@keyframes burstOut{0%{width:20px;height:20px;opacity:1}100%{width:460px;height:460px;opacity:0}}
/* ---------- Dex ---------- */
.scroll-area{flex:1;overflow:auto;min-height:0}
.dex-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(158px,1fr));gap:12px;padding:2px}
.dex-card{padding:12px;border-radius:16px;cursor:pointer;transition:transform .18s,box-shadow .2s,border-color .2s;position:relative;overflow:hidden;text-align:center;border:1px solid var(--stroke);background:var(--panel)}
.dex-card:hover{transform:translateY(-5px);border-color:rgba(var(--elrgb,139,92,246),.75);box-shadow:0 20px 40px -20px rgba(var(--elrgb,139,92,246),1)}
.dex-card.unknown{filter:grayscale(1);opacity:.42}
.dex-card .nm{font-size:12.5px;font-weight:800;margin-top:4px}
.dex-card .no{position:absolute;top:8px;left:10px;font-size:9.5px;font-weight:800;color:var(--dim);letter-spacing:.06em}
.dex-card .tick{position:absolute;top:7px;right:8px;font-size:11px}
.dex-card svg{width:96px;height:96px;margin:4px auto 0}
.detail-split{display:grid;grid-template-columns:1fr 316px;gap:12px;flex:1;min-height:0}
.stat-row{display:flex;align-items:center;gap:10px;font-size:11.5px;font-weight:700;padding:3.5px 0}
.stat-row .lbl{width:44px;color:var(--dim);font-size:10.5px;letter-spacing:.06em}
.stat-row .val{width:34px;text-align:right;font-variant-numeric:tabular-nums}
.stat-row .track{flex:1;height:6px;border-radius:99px;background:rgba(0,0,0,.5);overflow:hidden}
.stat-row .track i{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,var(--el,#8b5cf6),var(--el2,#22d3ee))}
.movelist{display:flex;flex-direction:column;gap:7px}
.moverow{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11.5px;padding:8px 11px;border-radius:10px;background:rgba(255,255,255,.04);border:1px solid var(--stroke)}
.moverow.locked{opacity:.4}
/* ---------- Shop / Party ---------- */
.shop-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
.shopcard{padding:15px;border-radius:16px;display:flex;flex-direction:column;gap:9px}
.shopcard .ic{width:48px;height:48px;border-radius:13px;display:grid;place-items:center;font-size:24px;background:rgba(139,92,246,.16);border:1px solid rgba(139,92,246,.4)}
.shopcard h3{margin:0;font-size:14px;font-weight:800}
.shopcard p{margin:0;font-size:11.5px;color:var(--dim);line-height:1.6;flex:1}
.party-slot{padding:14px;border-radius:16px;display:flex;gap:14px;align-items:center}
.party-slot svg{width:88px;height:88px;flex:0 0 auto}
.party-slot .body{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}
.big-empty{display:grid;place-items:center;height:100%;color:var(--dim);text-align:center;font-size:13px;line-height:2}
/* ---------- Modal / toast ---------- */
#modal-root{position:fixed;inset:0;z-index:70;display:none;place-items:center;background:rgba(4,3,10,.72);backdrop-filter:blur(7px);padding:18px}
#modal-root.on{display:grid}
.modal{width:min(600px,94vw);max-height:88vh;overflow:auto;padding:22px;border-radius:20px;animation:popIn .32s cubic-bezier(.2,1.4,.4,1)}
@keyframes popIn{from{opacity:0;transform:translateY(24px) scale(.94)}to{opacity:1;transform:none}}
.modal h2{font-family:var(--fh);font-size:13px;margin:0 0 14px;line-height:1.8}
.modal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:18px;flex-wrap:wrap}
.dialog-line{font-size:14.5px;line-height:1.75;margin:0 0 11px;color:#ded9f5}
.dialog-line b{color:#c4b5fd}
#toast-root{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);z-index:80;display:flex;flex-direction:column;gap:9px;align-items:center;pointer-events:none}
.toast{padding:11px 20px;border-radius:99px;font-size:12.5px;font-weight:700;background:rgba(20,18,34,.94);border:1px solid rgba(139,92,246,.55);box-shadow:0 14px 34px -12px rgba(139,92,246,1);animation:toastIn .35s cubic-bezier(.2,1.5,.4,1),toastOut .4s ease-in 2.3s forwards}
@keyframes toastIn{from{opacity:0;transform:translateY(20px) scale(.9)}to{opacity:1;transform:none}}
@keyframes toastOut{to{opacity:0;transform:translateY(-14px) scale(.94)}}
.lvlup-banner{position:absolute;left:50%;top:34%;transform:translateX(-50%);z-index:50;font-family:var(--fh);font-size:15px;padding:14px 26px;border-radius:14px;background:rgba(20,18,34,.9);border:1px solid rgba(253,224,71,.7);color:#fde047;box-shadow:0 0 44px -6px rgba(253,224,71,.8);animation:lvlIn .45s cubic-bezier(.2,1.5,.4,1),lvlOut .5s ease-in 1.7s forwards;text-align:center;line-height:1.9}
@keyframes lvlIn{from{opacity:0;transform:translate(-50%,22px) scale(.85)}to{opacity:1;transform:translate(-50%,0) scale(1)}}
@keyframes lvlOut{to{opacity:0;transform:translate(-50%,-20px)}}
::-webkit-scrollbar{width:9px;height:9px}
::-webkit-scrollbar-track{background:rgba(255,255,255,.03)}
::-webkit-scrollbar-thumb{background:rgba(139,92,246,.42);border-radius:99px}
::-webkit-scrollbar-thumb:hover{background:rgba(139,92,246,.7)}
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.flex{display:flex}.between{justify-content:space-between}.center{align-items:center}.gap8{gap:8px}.gap12{gap:12px}
.wrap{flex-wrap:wrap}.mt8{margin-top:8px}.mt14{margin-top:14px}
.muted{color:var(--dim)}
.small{font-size:11.5px}
.grow{flex:1;min-height:0}
h3.sect{font-family:var(--fh);font-size:11px;margin:0;color:#c4b5fd;letter-spacing:.05em}
@media (max-width:1020px){
.map-layout{grid-template-columns:1fr;grid-template-rows:auto 1fr}
.zone-info{max-height:180px}
.battle-bottom{grid-template-columns:1fr;height:auto}
.detail-split{grid-template-columns:1fr}
}
@media (max-width:640px){
.page{padding:9px;gap:9px}
.node{width:60px;height:60px}.node .nicon{font-size:21px}
.info-panel{min-width:200px;padding:9px 11px}
.slot.enemy{top:3%;right:2%}.slot.player{left:2%;bottom:3%}
.logbox{font-size:13px;padding:11px}
.battle-bottom{height:200px}
.brand{font-size:10px}
.pchip{min-width:0;flex:1;padding:5px 8px 5px 5px;gap:6px}
.pchip svg{width:30px;height:30px}
.pchip .pname{font-size:10.5px}
.pchip .pname span{font-size:9px}
.party-strip{gap:6px;width:100%}
.bottombar{gap:6px}
.menu-grid{grid-template-columns:1fr 1fr;padding:9px;gap:8px}
.battle-bottom{grid-template-columns:1fr;height:auto;flex:0 0 auto;max-height:46vh;padding:9px;overflow:auto}
.logbox{min-height:76px;max-height:120px;font-size:12.5px}
.arena{flex:1;min-height:150px}
.movebtn{padding:8px 10px}
.movebtn .ms{font-size:9px;gap:6px}
.map-info{max-width:58%;font-size:11px;padding:9px 11px}
.zone-info h2{font-size:12px}
}
</style>
</head>
<body>
<div id="bgfx"><div class="orb o1"></div><div class="orb o2"></div><div class="orb o3"></div><div class="grid"></div></div>
<div id="vignette"></div>
<div id="fxflash"></div>
<div id="app">
<!-- ================= TITLE ================= -->
<section id="screen-title" class="screen active">
<div class="title-wrap">
<div class="title-orb"><div class="ring"></div><div class="ring"></div><div class="ring"></div><div class="core"></div></div>
<h1 class="game-title hd" id="titleText">AETHELGARD</h1>
<div class="subtitle">Resonance Chronicles</div>
<p class="tagline">The frequencies of the world tremble. Capture the Resonants, tune your harmony, and climb toward the Apex Pillar.</p>
<div class="menu-col">
<button class="btn primary xl" id="btnContinue">Continue</button>
<button class="btn xl" id="btnNewGame">New Game</button>
<div class="grid-2">
<button class="btn" id="btnTitleDex">Conductor's Dex</button>
<button class="btn" id="btnTitleHelp">How to Play</button>
</div>
<button class="btn danger ghost sm" id="btnReset">Reset Progress</button>
</div>
<div class="muted small" style="margin-top:26px" id="titleSaveInfo"></div>
</div>
</section>
<!-- ================= STARTER ================= -->
<section id="screen-starter" class="screen">
<div class="page">
<div class="topbar">
<div>
<div class="brand">CHOOSE YOUR FIRST RESONANT</div>
<div class="muted small" style="margin-top:6px">Professor Harmonia hands you a single Aether Prism. Which frequency answers your call?</div>
</div>
<button class="btn sm" id="btnStarterBack">Back</button>
</div>
<div class="scroll-area">
<div class="shop-grid" id="starterGrid" style="grid-template-columns:repeat(auto-fill,minmax(232px,1fr))"></div>
</div>
</div>
</section>
<!-- ================= MAP ================= -->
<section id="screen-map" class="screen">
<div class="page">
<div class="topbar">
<div class="brand">AETHELGARD · <span id="zoneNameTop">THE VERDANT CANOPY</span></div>
<div class="hud-right">
<div class="pill shard"><span style="font-size:14px">◆</span><span id="hudShards">0</span></div>
<div class="pill"><span style="color:#c4b5fd">⬢</span> <span id="hudBadges">0</span>/4 Sigils</div>
<button class="icon-btn" id="btnOpenDex" title="Conductor's Dex">📖</button>
<button class="icon-btn" id="btnOpenParty" title="Team">⚗</button>
<button class="icon-btn" id="btnOpenShop" title="Camp Shop">🛒</button>
<button class="icon-btn" id="btnOpenMenu" title="Menu">☰</button>
</div>
</div>
<div class="map-layout">
<aside class="zone-info glass">
<h3 class="sect">ZONE SELECT</h3>
<div id="zoneList" style="display:flex;flex-direction:column;gap:8px"></div>
<div style="height:1px;background:rgba(255,255,255,.07)"></div>
<h3 class="sect">ZONE BRIEF</h3>
<h2 id="zoneName"></h2>
<p id="zoneDesc"></p>
<div id="zoneMeta" class="small muted" style="line-height:1.9"></div>
</aside>
<div class="map-canvas glass">
<svg id="mapPaths" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
<div id="mapNodes" style="position:absolute;inset:0"></div>
<div class="map-info glass" id="mapInfo">Select a node on the trail. Reach the Gatekeeper to unlock the next zone.</div>
</div>
</div>
<div class="bottombar">
<div class="party-strip" id="mapParty"></div>
<div class="flex gap8 wrap">
<button class="btn sm" id="btnHealHint">⛲ Springs heal your team</button>
</div>
</div>
</div>
</section>
<!-- ================= BATTLE ================= -->
<section id="screen-battle" class="screen">
<div class="battle-wrap">
<div class="arena" id="arena">
<div class="arena-grid"></div>
<div class="platform" id="platEnemy" style="right:2%;top:44%;width:270px;height:70px"></div>
<div class="platform" id="platPlayer" style="left:2%;bottom:1%;width:300px;height:78px"></div>
<div class="slot enemy" id="slotEnemy">
<div class="info-panel glass" id="enemyPanel"></div>
<div class="sprite-wrap enemy-bob" id="enemySprite"></div>
</div>
<div class="slot player" id="slotPlayer">
<div class="sprite-wrap bob" id="playerSprite"></div>
<div class="info-panel glass" id="playerPanel"></div>
</div>
<div class="sparks" id="sparks"></div>
</div>
<div class="battle-bottom">
<div class="logbox glass" id="logbox"></div>
<div class="glass menu-grid" id="battleMenu"></div>
</div>
</div>
</section>
<!-- ================= DEX ================= -->
<section id="screen-dex" class="screen">
<div class="page">
<div class="topbar">
<div>
<div class="brand">CONDUCTOR'S DEX</div>
<div class="muted small" style="margin-top:6px" id="dexCount">0 / 18 recorded</div>
</div>
<div class="hud-right">
<input id="dexSearch" placeholder="Search Resonants…" style="background:rgba(255,255,255,.05);border:1px solid var(--stroke);border-radius:11px;padding:10px 14px;color:var(--txt);font-family:var(--fb);font-size:12.5px;outline:none;width:190px">
<select id="dexFilter" style="background:rgba(255,255,255,.05);border:1px solid var(--stroke);border-radius:11px;padding:10px 12px;color:var(--txt);font-family:var(--fb);font-size:12.5px;outline:none">
<option value="all">All Types</option>
<option value="Pyro">Pyro</option><option value="Hydro">Hydro</option><option value="Flora">Flora</option>
<option value="Zephyr">Zephyr</option><option value="Earth">Earth</option><option value="Volt">Volt</option>
</select>
<button class="btn sm" id="btnDexBack">Back</button>
</div>
</div>
<div class="scroll-area">
<div class="dex-grid" id="dexGrid"></div>
</div>
</div>
</section>
<!-- ================= PARTY ================= -->
<section id="screen-party" class="screen">
<div class="page">
<div class="topbar">
<div>
<div class="brand">HARMONIC TEAM</div>
<div class="muted small" style="margin-top:6px">Up to 3 Resonants resonate at once. Extra captures rest in the prism vault.</div>
</div>
<div class="hud-right">
<div class="pill shard"><span style="font-size:13px">◆</span><span id="partyShards">0</span></div>
<button class="btn sm" id="btnPartyBack">Back</button>
</div>
</div>
<div class="scroll-area">
<div style="display:flex;flex-direction:column;gap:12px" id="partyList"></div>
<h3 class="sect" style="margin:22px 0 10px">PRISM VAULT</h3>
<div style="display:flex;flex-direction:column;gap:12px" id="boxList"></div>
</div>
</div>
</section>
<!-- ================= SHOP ================= -->
<section id="screen-shop" class="screen">
<div class="page">
<div class="topbar">
<div>
<div class="brand">CAMP SHOP</div>
<div class="muted small" style="margin-top:6px">Field Quartermaster Orrin trades in Aether Shards.</div>
</div>
<div class="hud-right">
<div class="pill shard"><span style="font-size:13px">◆</span><span id="shopShards">0</span></div>
<button class="btn sm" id="btnShopBack">Back</button>
</div>
</div>
<div class="scroll-area">
<div class="shop-grid" id="shopGrid"></div>
<h3 class="sect" style="margin:24px 0 10px">YOUR SATCHEL</h3>
<div class="glass" style="padding:16px;display:flex;gap:20px;flex-wrap:wrap" id="satchelList"></div>
</div>
</div>
</section>
<div id="modal-root"><div class="modal glass" id="modalBox"></div></div>
<div id="toast-root"></div>
</div>
<script>
/* =========================================================================
AETHELGARD — RESONANCE CHRONICLES
Single-file monster-collector RPG. All systems authored inline below.
========================================================================= */
'use strict';
/* ------------------------------ UTILITIES ------------------------------ */
const $ = (s, r) => (r || document).querySelector(s);
const $$ = (s, r) => Array.from((r || document).querySelectorAll(s));
const rnd = (a, b) => Math.random() * (b - a) + a;
const rndInt = (a, b) => Math.floor(Math.random() * (b - a + 1)) + a;
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const sleep = ms => new Promise(r => setTimeout(r, ms));
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const fmt = n => n.toLocaleString('en-US');
/* ============================ AUDIO SYNTHESIZER ========================= */
class SynthManager {
constructor() {
this.ctx = null; this.master = null; this.enabled = true; this.volume = 0.35; this.noiseBuf = null;
}
init() {
if (this.ctx) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) { this.enabled = false; return; }
this.ctx = new AC();
this.master = this.ctx.createGain();
this.master.gain.value = this.volume;
const comp = this.ctx.createDynamicsCompressor();
this.master.connect(comp); comp.connect(this.ctx.destination);
// pre-baked white noise buffer for percussive hits
const len = Math.floor(this.ctx.sampleRate * 0.8);
const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
this.noiseBuf = buf;
}
resume() {
this.init();
if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
}
setVolume(v) { this.volume = v; if (this.master) this.master.gain.value = v; }
/* single oscillator blip */
tone(o) {
if (!this.enabled) return; this.init(); if (!this.ctx) return;
const t0 = this.ctx.currentTime + (o.delay || 0);
const osc = this.ctx.createOscillator();
const g = this.ctx.createGain();
osc.type = o.type || 'square';
osc.frequency.setValueAtTime(o.freq, t0);
if (o.slideTo) osc.frequency.exponentialRampToValueAtTime(Math.max(20, o.slideTo), t0 + (o.dur || 0.1));
if (o.detune) osc.detune.value = o.detune;
const atk = o.attack != null ? o.attack : 0.006;
const vol = o.vol != null ? o.vol : 0.3;
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol, t0 + atk);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + (o.dur || 0.1));
let node = osc;
if (o.filter) {
const f = this.ctx.createBiquadFilter();
f.type = o.filterType || 'lowpass'; f.frequency.value = o.filter;
node.connect(f); node = f;
}
node.connect(g); g.connect(this.master);
osc.start(t0); osc.stop(t0 + (o.dur || 0.1) + 0.05);
}
/* pitched noise burst */
noise(o) {
if (!this.enabled) return; this.init(); if (!this.ctx) return;
const t0 = this.ctx.currentTime + (o.delay || 0);
const src = this.ctx.createBufferSource(); src.buffer = this.noiseBuf;
const f = this.ctx.createBiquadFilter();
f.type = o.type || 'bandpass'; f.frequency.value = o.freq || 900; f.Q.value = o.q || 1.2;
const g = this.ctx.createGain();
const dur = o.dur || 0.16, vol = o.vol != null ? o.vol : 0.28;
g.gain.setValueAtTime(vol, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
if (o.sweep) f.frequency.exponentialRampToValueAtTime(Math.max(60, o.sweep), t0 + dur);
src.connect(f); f.connect(g); g.connect(this.master);
src.start(t0); src.stop(t0 + dur + 0.04);
}
seq(notes, base) {
notes.forEach((n, i) => {
if (n === null) return;
this.tone({
freq: typeof n === 'number' ? n : n[0], dur: (n[1] != null ? n[1] : 0.09),
type: base.type || 'square', vol: base.vol != null ? base.vol : 0.26, delay: i * (base.step || 0.08)
});
});
}
hover() { this.tone({ freq: 1180, dur: 0.035, type: 'triangle', vol: 0.10 }); }
select() { this.tone({ freq: 780, slideTo: 1560, dur: 0.09, type: 'square', vol: 0.2 }); }
back() { this.tone({ freq: 620, slideTo: 300, dur: 0.09, type: 'square', vol: 0.18 }); }
open() { this.seq([[520, 0.05], [780, 0.05], [1040, 0.09]], { type: 'triangle', step: 0.05, vol: 0.18 }); }
hit() { this.noise({ freq: 700, sweep: 180, dur: 0.16, vol: 0.3 }); this.tone({ freq: 220, slideTo: 90, dur: 0.13, type: 'square', vol: 0.24 }); }
superHit() {
this.noise({ freq: 1800, sweep: 220, dur: 0.34, vol: 0.36 });
this.tone({ freq: 420, slideTo: 120, dur: 0.32, type: 'sawtooth', vol: 0.26 });
this.tone({ freq: 840, slideTo: 240, dur: 0.3, type: 'square', vol: 0.16, delay: 0.05 });
}
crit() { this.noise({ freq: 2400, sweep: 300, dur: 0.4, vol: 0.4 }); this.tone({ freq: 1400, slideTo: 200, dur: 0.34, type: 'sawtooth', vol: 0.24 }); }
weak() { this.tone({ freq: 260, slideTo: 200, dur: 0.14, type: 'triangle', vol: 0.18 }); }
levelUp() { this.seq([659, 880, 1046, 1318], { type: 'square', step: 0.075, vol: 0.24 }); }
evolve() { this.seq([440, 554, 659, 880, 1108, 1318], { type: 'triangle', step: 0.09, vol: 0.22 }); this.tone({ freq: 1760, dur: 0.6, type: 'sine', vol: 0.14, delay: 0.5 }); }
captureShake() { this.tone({ freq: 300, dur: 0.06, type: 'square', vol: 0.16 }); }
captureSuccess() { this.seq([784, 988, 1318, 1568, 2093], { type: 'square', step: 0.085, vol: 0.24 }); }
captureFail() { this.tone({ freq: 400, slideTo: 150, dur: 0.3, type: 'sawtooth', vol: 0.2 }); }
heal() { this.seq([[880, 0.1], [1108, 0.1], [1318, 0.18]], { type: 'sine', step: 0.08, vol: 0.2 }); }
faint() { this.tone({ freq: 520, slideTo: 60, dur: 0.65, type: 'sawtooth', vol: 0.22 }); }
shard() { this.seq([[1568, 0.05], [2093, 0.09]], { type: 'triangle', step: 0.05, vol: 0.16 }); }
error() { this.tone({ freq: 180, dur: 0.14, type: 'square', vol: 0.2 }); }
bossFanfare() {
this.seq([392, 392, 523, 659, 587, 698], { type: 'square', step: 0.12, vol: 0.24 });
}
}
const Synth = new SynthManager();
/* ============================ ELEMENT CHART ============================= */
const TYPES = ['Pyro', 'Hydro', 'Flora', 'Zephyr', 'Earth', 'Volt', 'Cosmos'];
/* strong[attacker] = list of types it hits hard; weak[attacker] = types it is weak against */
const STRONG = { Pyro: ['Flora'], Hydro: ['Pyro'], Flora: ['Hydro'], Zephyr: ['Earth'], Earth: ['Volt'], Volt: ['Zephyr'], Cosmos: [] };
const WEAK = { Pyro: ['Hydro'], Hydro: ['Flora'], Flora: ['Pyro'], Zephyr: ['Volt'], Earth: ['Zephyr'], Volt: ['Earth'], Cosmos: [] };
function typeMod(attackType, defendType) {
if (attackType === 'Cosmos' || defendType === 'Cosmos') return 1;
if ((STRONG[attackType] || []).includes(defendType)) return 1.6;
if ((WEAK[attackType] || []).includes(defendType)) return 0.6;
return 1;
}
function typeMultText(m) { return m > 1.05 ? "It's super effective!" : (m < 0.95 ? "It's not very effective…" : ''); }
/* ============================== MOVE DATA =============================== */
/* effect: {status, chance} | {buff:{stat,mult}} | {heal:frac} | {drain:frac} | {recoil:frac}
All 18 roster species draw from their family move-line. */
const MOVES = {
/* --- PYRO LINE --- */
ember_tap: { name: 'Ember Tap', power: 38, acc: 96, element: 'Pyro', unlock: 1, desc: 'A snapping spark of heat.' },
flare_fang: { name: 'Flare Fang', power: 56, acc: 92, element: 'Pyro', unlock: 4, desc: 'Burning bite. May inflict Burn.', effect: { status: 'burn', chance: 0.25 } },
cinder_rush: { name: 'Cinder Rush', power: 74, acc: 88, element: 'Pyro', unlock: 9, desc: 'A scorching full-body charge.' },
scorch_howl: { name: 'Scorch Howl', power: 0, acc: 100, element: 'Pyro', unlock: 12, desc: 'Howl to sharpen Attack sharply.', effect: { buff: { stat: 'atk', mult: 1.55 } } },
inferno_lance: { name: 'Inferno Lance', power: 96, acc: 82, element: 'Pyro', unlock: 15, desc: 'A piercing spear of white flame.' },
phoenix_wrath: { name: 'Phoenix Wrath', power: 124, acc: 76, element: 'Pyro', unlock: 20, desc: 'Reborn fury of the sun. Often burns.', effect: { status: 'burn', chance: 0.35 } },
/* --- HYDRO LINE --- */
aqua_jet: { name: 'Aqua Jet', power: 38, acc: 96, element: 'Hydro', unlock: 1, desc: 'A quick pressurized squirt.' },
bubble_burst: { name: 'Bubble Burst', power: 56, acc: 92, element: 'Hydro', unlock: 4, desc: 'Bursting bubbles. May Chill.', effect: { status: 'chill', chance: 0.25 } },
tidal_slam: { name: 'Tidal Slam', power: 76, acc: 88, element: 'Hydro', unlock: 9, desc: 'A crashing wall of water.' },
mist_veil: { name: 'Mist Veil', power: 0, acc: 100, element: 'Hydro', unlock: 12, desc: 'Cloak in mist; Defense rises.', effect: { buff: { stat: 'def', mult: 1.55 } } },
abyssal_crush: { name: 'Abyssal Crush', power: 97, acc: 82, element: 'Hydro', unlock: 15, desc: 'Pressure of the deep dark.' },
leviathan_roar: { name: "Leviathan Roar", power: 124, acc: 76, element: 'Hydro', unlock: 20, desc: 'A world-shaking depth-cry.' },
/* --- FLORA LINE --- */
vine_whip: { name: 'Vine Whip', power: 38, acc: 96, element: 'Flora', unlock: 1, desc: 'A lashing green tendril.' },
seed_bomb: { name: 'Seed Bomb', power: 56, acc: 92, element: 'Flora', unlock: 4, desc: 'Explosive hardened seeds.' },
thorn_barrage: { name: 'Thorn Barrage', power: 76, acc: 88, element: 'Flora', unlock: 9, desc: 'A rain of barbs. May poison.', effect: { status: 'venom', chance: 0.3 } },
photosynth: { name: 'Photosynth', power: 0, acc: 100, element: 'Flora', unlock: 12, desc: 'Absorb light to restore 30% HP.', effect: { heal: 0.3 } },
bloom_crash: { name: 'Bloom Crash', power: 97, acc: 82, element: 'Flora', unlock: 15, desc: 'A colossal blossom slams down.' },
worldroot: { name: 'Worldroot Eruption', power: 126, acc: 76, element: 'Flora', unlock: 20, desc: 'Roots of the world tear upward.', effect: { drain: 0.3 } },
/* --- VOLT LINE --- */
static_zap: { name: 'Static Zap', power: 38, acc: 96, element: 'Volt', unlock: 1, desc: 'A jabbing arc of static.' },
volt_fang: { name: 'Volt Fang', power: 56, acc: 92, element: 'Volt', unlock: 4, desc: 'Electrified bite. May Stun.', effect: { status: 'stun', chance: 0.2 } },
arc_bolt: { name: 'Arc Bolt', power: 77, acc: 88, element: 'Volt', unlock: 9, desc: 'A searing split of lightning.' },
overcharge: { name: 'Overcharge', power: 0, acc: 100, element: 'Volt', unlock: 12, desc: 'Supercharge: Attack up sharply.', effect: { buff: { stat: 'atk', mult: 1.6 } } },
thunder_crash: { name: 'Thunder Crash', power: 98, acc: 82, element: 'Volt', unlock: 15, desc: 'A hammer-blow of thunder.' },
storm_cataclysm:{ name: 'Storm Cataclysm',power: 126, acc: 76, element: 'Volt', unlock: 20, desc: 'The sky splits wide open.', effect: { status: 'stun', chance: 0.25 } },
/* --- ZEPHYR LINE --- */
gust_slash: { name: 'Gust Slash', power: 38, acc: 96, element: 'Zephyr', unlock: 1, desc: 'A razor crescent of wind.' },
feather_dart: { name: 'Feather Dart', power: 56, acc: 92, element: 'Zephyr', unlock: 4, desc: 'Blades of hardened feather.' },
cyclone_kick: { name: 'Cyclone Kick', power: 77, acc: 88, element: 'Zephyr', unlock: 9, desc: 'A spinning gale-assisted kick.' },
tailwind: { name: 'Tailwind', power: 0, acc: 100, element: 'Zephyr', unlock: 12, desc: 'Ride the current: Speed rises.', effect: { buff: { stat: 'spd', mult: 1.6 } } },
sky_sovereign: { name: 'Sky Sovereign', power: 98, acc: 82, element: 'Zephyr', unlock: 15, desc: 'Dive from beyond the clouds.' },
tempest_requiem:{ name: 'Tempest Requiem',power: 126, acc: 76, element: 'Zephyr', unlock: 20, desc: 'A mournful, unstoppable hurricane.' },
/* --- EARTH LINE --- */
pebble_toss: { name: 'Pebble Toss', power: 38, acc: 96, element: 'Earth', unlock: 1, desc: 'A hurtling stone.' },
stone_edge: { name: 'Stone Edge', power: 57, acc: 92, element: 'Earth', unlock: 4, desc: 'A jagged stone blade.' },
quake_stomp: { name: 'Quake Stomp', power: 78, acc: 88, element: 'Earth', unlock: 9, desc: 'The ground buckles beneath.' },
iron_hide: { name: 'Iron Hide', power: 0, acc: 100, element: 'Earth', unlock: 12, desc: 'Harden the shell; Defense up.', effect: { buff: { stat: 'def', mult: 1.6 } } },
tectonic_slam: { name: 'Tectonic Slam', power: 99, acc: 82, element: 'Earth', unlock: 15, desc: 'A continental-weight impact.' },
continental_rupture: { name: 'Continental Rupture', power: 128, acc: 76, element: 'Earth', unlock: 20, desc: 'Fault lines answer the call.' },
/* --- COSMOS (final encounter only) --- */
void_pulse: { name: 'Void Pulse', power: 44, acc: 96, element: 'Cosmos', unlock: 1, desc: 'A ripple of hollow space.' },
starfall: { name: 'Starfall', power: 78, acc: 90, element: 'Cosmos', unlock: 1, desc: 'Burnished stars rain down.' },
entropy_wave: { name: 'Entropy Wave', power: 104, acc: 84, element: 'Cosmos', unlock: 1, desc: 'Order unspools where it passes.' },
cosmic_collapse:{ name: 'Cosmic Collapse',power: 132, acc: 76, element: 'Cosmos', unlock: 1, desc: 'The space between moments folds.' },
/* fallback */
struggle: { name: 'Struggle', power: 32, acc: 100, element: 'Cosmos', unlock: 1, desc: 'A desperate flailing strike.' }
};
const MOVE_LINES = {
Pyro: ['ember_tap', 'flare_fang', 'cinder_rush', 'scorch_howl', 'inferno_lance', 'phoenix_wrath'],
Hydro: ['aqua_jet', 'bubble_burst', 'tidal_slam', 'mist_veil', 'abyssal_crush', 'leviathan_roar'],
Flora: ['vine_whip', 'seed_bomb', 'thorn_barrage', 'photosynth', 'bloom_crash', 'worldroot'],
Volt: ['static_zap', 'volt_fang', 'arc_bolt', 'overcharge', 'thunder_crash', 'storm_cataclysm'],
Zephyr: ['gust_slash', 'feather_dart', 'cyclone_kick', 'tailwind', 'sky_sovereign', 'tempest_requiem'],
Earth: ['pebble_toss', 'stone_edge', 'quake_stomp', 'iron_hide', 'tectonic_slam', 'continental_rupture'],
Cosmos: ['void_pulse', 'starfall', 'entropy_wave', 'cosmic_collapse']
};
/* ========================= RESONANT DATABASE (18) ======================== */
const DEX = [
/* ---- PYRO FAMILY ---- */
{ key:'emberkit', num:1, name:'Emberkit', type:'Pyro', tier:1, evoLevel:8, evolvesTo:'pyrowolf',
base:{hp:44,atk:54,def:38,spd:56}, xpYield:62, shape:'pyro', flavor:'A cinder-furred pup that naps in hearth ash. Its tail flickers when it is happy.',
lore:'Born from the first spark struck against flint, Emberkits travel in yipping packs through warm grass.' },
{ key:'pyrowolf', num:2, name:'Pyrowolf', type:'Pyro', tier:2, evoLevel:15, evolvesTo:'ignisaur',
base:{hp:64,atk:76,def:56,spd:78}, xpYield:148, shape:'pyro', flavor:'Its mane burns hotter the angrier it gets. Hunters follow its glow at dusk.',
lore:'A pack-leader frequency. Pyrowolves can ignite their own pawprints to mark territory.' },
{ key:'ignisaur', num:3, name:'Ignisaur', type:'Pyro', tier:3, evoLevel:null, evolvesTo:null,
base:{hp:88,atk:106,def:78,spd:96}, xpYield:330, shape:'pyro', flavor:'A living forge. The plates along its spine cool into obsidian glass as it walks.',
lore:'Ancient texts call it the Walking Sun. Its roar has been recorded melting the snow off peaks.' },
/* ---- HYDRO FAMILY ---- */
{ key:'droplet', num:4, name:'Droplet', type:'Hydro', tier:1, evoLevel:8, evolvesTo:'geoxus',
base:{hp:48,atk:48,def:44,spd:50}, xpYield:62, shape:'hydro', flavor:'A palm-sized water spirit that hums when it rains.',
lore:'Droplets are said to be tears of the first river, given curious little legs.' },
{ key:'geoxus', num:5, name:'Geoxus', type:'Hydro', tier:2, evoLevel:15, evolvesTo:'neptunian',
base:{hp:70,atk:68,def:66,spd:66}, xpYield:150, shape:'hydro', flavor:'Its shell is a living tide-pool, home to tiny luminous shrimp.',
lore:'Geoxus migrates with the moon, hauling entire ecosystems in its carapace.' },
{ key:'neptunian', num:6, name:'Neptunian', type:'Hydro', tier:3, evoLevel:null, evolvesTo:null,
base:{hp:98,atk:94,def:92,spd:86}, xpYield:334, shape:'hydro', flavor:'Its body is translucent deep-sea glass; you can watch its heart glow.',
lore:'Sovereign of trench and current. Sailors speak of it calming storms with a single note.' },
/* ---- FLORA FAMILY ---- */
{ key:'seedling', num:7, name:'Seedling', type:'Flora', tier:1, evoLevel:8, evolvesTo:'thornback',
base:{hp:50,atk:50,def:46,spd:44}, xpYield:62, shape:'flora', flavor:'A restless sprout that follows sunlight like a compass needle.',
lore:'Gardeners keep Seedlings as living barometers — they droop before rain.' },
{ key:'thornback', num:8, name:'Thornback', type:'Flora', tier:2, evoLevel:15, evolvesTo:'bloomgoliath',
base:{hp:74,atk:72,def:70,spd:58}, xpYield:152, shape:'flora', flavor:'Each thorn carries a different pollen. Its smell is dizzying up close.',
lore:'Thornbacks wall off wild groves, growing denser rings with every season.' },
{ key:'bloomgoliath', num:9, name:'Bloomgoliath', type:'Flora', tier:3, evoLevel:null, evolvesTo:null,
base:{hp:106,atk:96,def:94,spd:70}, xpYield:338, shape:'flora', flavor:'Its crown blooms once every hundred years — for exactly one night.',
lore:'A walking forest. Entire villages shelter beneath a resting Bloomgoliath.' },
/* ---- VOLT FAMILY ---- */
{ key:'sparky', num:10, name:'Sparky', type:'Volt', tier:1, evoLevel:8, evolvesTo:'boltsaber',
base:{hp:42,atk:56,def:36,spd:62}, xpYield:66, shape:'volt', flavor:'It stores static in its cheek pouches and discharges when startled.',
lore:'Sparkies nest inside old transmission towers, feeding on residual current.' },
{ key:'boltsaber', num:11, name:'Boltsaber', type:'Volt', tier:2, evoLevel:15, evolvesTo:'stormlord',
base:{hp:60,atk:80,def:52,spd:88}, xpYield:154, shape:'volt', flavor:'It sharpens its fangs on lightning rods. The taste of copper excites it.',
lore:'Boltsabers duel above storm clouds, their arcs visible from neighboring valleys.' },
{ key:'stormlord', num:12, name:'Stormlord', type:'Volt', tier:3, evoLevel:null, evolvesTo:null,
base:{hp:82,atk:112,def:72,spd:120}, xpYield:342, shape:'volt', flavor:'Thunderstorms gather where it sleeps, waiting for its command.',
lore:'The Obsidian Crags were scorched into glass by a single Stormlord argument.' },
/* ---- ZEPHYR FAMILY ---- */
{ key:'gusty', num:13, name:'Gusty', type:'Zephyr', tier:1, evoLevel:8, evolvesTo:'slipstream',
base:{hp:41,atk:52,def:36,spd:66}, xpYield:66, shape:'zephyr', flavor:'A feather-light spirit that refuses to touch the ground.',
lore:'Gusties ride thermals for weeks, sleeping with wings half-spread.' },
{ key:'slipstream', num:14, name:'Slipstream', type:'Zephyr', tier:2, evoLevel:15, evolvesTo:'zephyrus',
base:{hp:58,atk:74,def:52,spd:96}, xpYield:154, shape:'zephyr', flavor:'It leaves a faint silver trail wherever it dives.',
lore:'Conductors say a Slipstream can outrun its own echo.' },
{ key:'zephyrus', num:15, name:'Zephyrus', type:'Zephyr', tier:3, evoLevel:null, evolvesTo:null,
base:{hp:80,atk:102,def:70,spd:134}, xpYield:342, shape:'zephyr', flavor:'Four wings, no sound. It moves in the pause between breaths.',
lore:'The Apex Pillar was chosen as its roost — and no Conductor has ever disputed it.' },
/* ---- EARTH FAMILY ---- */
{ key:'pebble', num:16, name:'Pebble', type:'Earth', tier:1, evoLevel:8, evolvesTo:'obsidian',
base:{hp:54,atk:50,def:58,spd:34}, xpYield:66, shape:'earth', flavor:'It curls into a perfect sphere and pretends to be scenery.',
lore:'Pebbles are patient. Some have waited in the same riverbed for decades.' },
{ key:'obsidian', num:17, name:'Obsidian', type:'Earth', tier:2, evoLevel:15, evolvesTo:'terrasunder',
base:{hp:76,atk:72,def:84,spd:44}, xpYield:156, shape:'earth', flavor:'Volcanic glass fused with stone. Its edges never dull.',
lore:'Forged in the Obsidian Crags, these Resonants carry a shard of the mountain itself.' },
{ key:'terrasunder', num:18, name:'Terrasunder', type:'Earth', tier:3, evoLevel:null, evolvesTo:null,
base:{hp:110,atk:98,def:118,spd:56}, xpYield:346, shape:'earth', flavor:'Its footfalls register on seismographs two valleys away.',
lore:'Terrasunders walk alone, and mountains are said to make way for them.' }
];
const DEX_BY_KEY = {};
DEX.forEach(s => DEX_BY_KEY[s.key] = s);
/* Final boss signature (Cosmos - not part of the 18-record roster dex) */
const ASTRALON = { key:'astralon', num:19, name:'Astralon', type:'Cosmos', tier:3, evoLevel:null, evolvesTo:null,
base:{hp:132,atk:118,def:104,spd:112}, xpYield:600, shape:'cosmos', boss:true,
flavor:'A Resonant that predates the elements — it is the silence the frequencies sing into.',
lore:'Astralon has no family line. It simply is.' };
const ALL_SPECIES = DEX.concat([ASTRALON]);
ALL_SPECIES.forEach(s => DEX_BY_KEY[s.key] = s);
/* ============================== ITEMS ================================== */
const ITEMS = {
prism_basic: { name:'Harmonizer Prism', icon:'🔷', price:120, mult:1.0, kind:'prism', desc:'Standard catch matrix. Reliable, unremarkable.' },
prism_hyper: { name:'Hyper-Conductive Prism', icon:'💠', price:420, mult:2.0, kind:'prism', desc:'Tuned to open-closed frequency gaps. Doubles catch rate.' },
prism_master:{ name:'Apex Prism', icon:'🔶', price:1400, mult:4.2, kind:'prism', desc:'Lab-grade matrix. Nearly always harmonizes.' },
restorer: { name:'Core Restorer', icon:'🧪', price:150, kind:'heal', frac:0.5, desc:'Restores 50% of a Resonant\'s maximum HP.' },
restorer_max:{ name:'Full Core Restorer', icon:'⚗️', price:520, kind:'heal', frac:1.0, desc:'Restores a Resonant to full HP.' },
elixir: { name:'Resonance Elixir', icon:'🫧', price:200, kind:'status', desc:'Clears all status conditions from a Resonant.' },
reviver: { name:'Resonant Requiem', icon:'🕯️', price:800, kind:'revive', desc:'Revives a fainted Resonant to half HP.' },
charm: { name:'Lucky Resonance Charm', icon:'🍀', price:340, kind:'buff', desc:'A one-battle charm: +1 permanent tweak to your active Resonant\'s Speed.', once:true }
};
const SHOP_ORDER = ['prism_basic', 'prism_hyper', 'prism_master', 'restorer', 'restorer_max', 'elixir', 'reviver', 'charm'];
/* ============================== ZONES =================================== */
const ZONES = [
{
id:0, name:'The Verdant Canopy', short:'VERDANT CANOPY', accent:'#4ade80', rgb:'74,222,128',
type:'Flora', types:['Flora','Zephyr'], minLv:2, maxLv:5,
desc:'A cathedral of roots where sunlight falls in slow green curtains. Zephyr currents thread the branches.',
nodes:[
{ t:'wild', x:14, y:80, label:'Whispering Grass' },
{ t:'cache', x:29, y:64, label:'Root Cache' },
{ t:'wild', x:45, y:74, label:'Mossy Trail' },
{ t:'trainer',x:60, y:58, label:'Conductor Bram' },
{ t:'spring', x:74, y:70, label:'Dew Spring' },
{ t:'wild', x:86, y:46, label:'High Branches' },
{ t:'wild', x:62, y:30, label:'Fern Hollow' },
{ t:'boss', x:32, y:20, label:'Gatekeeper Sylva' }
],
pool:[['seedling',2,5],['gusty',2,5],['emberkit',2,4],['droplet',2,5],['pebble',3,5],['thornback',5,5]]
},
{
id:1, name:'The Sunken Grotto', short:'SUNKEN GROTTO', accent:'#38bdf8', rgb:'56,189,248',
type:'Hydro', types:['Hydro','Earth'], minLv:5, maxLv:10,
desc:'Cold chambers of drowned stone. Every drip echoes twice, and something large is always breathing.',
nodes:[
{ t:'wild', x:16, y:74, label:'Tidal Mouth' },
{ t:'wild', x:30, y:58, label:'Dripstone Hall' },
{ t:'cache', x:44, y:68, label:'Drowned Satchel' },
{ t:'trainer',x:58, y:52, label:'Diver Kel' },
{ t:'spring', x:72, y:64, label:'Warm Vent' },
{ t:'wild', x:86, y:44, label:'Echo Basin' },
{ t:'camp', x:64, y:26, label:'Dry Camp' },
{ t:'boss', x:34, y:18, label:'Gatekeeper Marin' }
],
pool:[['droplet',5,9],['geoxus',8,10],['pebble',5,9],['obsidian',9,10],['seedling',5,8],['gusty',6,9]]
},
{
id:2, name:'The Obsidian Crags', short:'OBSIDIAN CRAGS', accent:'#f59e0b', rgb:'245,158,11',
type:'Pyro', types:['Pyro','Volt'], minLv:10, maxLv:16,
desc:'Shattered black glass under a hot ash sky. Lightning walks the ridgelines like a caged animal.',
nodes:[
{ t:'wild', x:15, y:78, label:'Ash Slope' },
{ t:'cache', x:31, y:62, label:'Fused Coffer' },
{ t:'wild', x:47, y:72, label:'Glass Ridge' },
{ t:'trainer',x:63, y:56, label:'Conductor Rask' },
{ t:'spring', x:78, y:66, label:'Steam Fissure' },
{ t:'wild', x:88, y:42, label:'Thunder Shelf' },
{ t:'wild', x:60, y:28, label:'Caldera Edge' },
{ t:'boss', x:30, y:18, label:'Gatekeeper Volkan' }
],
pool:[['emberkit',10,14],['pyrowolf',12,16],['sparky',10,14],['boltsaber',13,16],['obsidian',10,14],['terrasunder',16,16]]
},
{
id:3, name:'The Apex Pillar', short:'APEX PILLAR', accent:'#c084fc', rgb:'192,132,252',
type:'Cosmos', types:['Cosmos'], minLv:16, maxLv:24,
desc:'A monolith of humming crystal that pierces the clouds. Here the frequencies stop being separate things.',
nodes:[
{ t:'wild', x:18, y:76, label:'Windward Steps' },
{ t:'trainer',x:36, y:62, label:'Conductor Vell' },
{ t:'cache', x:54, y:72, label:'Star Cache' },
{ t:'spring', x:72, y:60, label:'Resonance Well' },
{ t:'trainer',x:86, y:42, label:'Conductor Nyx' },
{ t:'wild', x:64, y:30, label:'Crown Ledge' },
{ t:'cache', x:42, y:26, label:'Vault of Echoes' },
{ t:'boss', x:22, y:16, label:'Sovereign Aetherion' }
],
pool:[['ignisaur',18,22],['neptunian',18,22],['bloomgoliath',18,22],['stormlord',18,22],['zephyrus',18,22],['terrasunder',18,22]]
}
];
/* =============================== BOSSES ================================ */
const BOSSES = {
0: {
name:'Warden Sylva', title:'Gatekeeper of the Verdant Canopy', sprite:'flora', accent:'#4ade80',
team:[['thornback',9],['gusty',9]],
intro:[
'The canopy folds aside. A Conductor in bark-armoured robes plants her staff in the moss.',
'"Halt, wanderer. This grove grows only for those who can hear it."',
'"Show me a frequency worth listening to. <b>Resonate with me!</b>"'
],
mid:['"Your harmony is brittle! Deepen it!"','"The roots remember every beat you strike."'],
win:[
'Sylva lowers her staff, and the vines uncoil from the path ahead.',
'"…You heard it. The low note under the wind. Go — the Sunken Grotto waits below."',
'She presses a humming sigil into your palm. <b>Verdant Sigil obtained!</b>'
],
lose:['"Your rhythm scattered the moment I pushed. Breathe, then try again."'],
reward:{ shards:600, item:'prism_hyper', qty:3 }
},
1: {
name:'Tidecaller Marin', title:'Gatekeeper of the Sunken Grotto', sprite:'hydro', accent:'#38bdf8',
team:[['geoxus',14],['obsidian',14],['droplet',13]],
intro:[
'Water shears apart like a curtain. Marin stands on a stone that should not be above the waterline.',
'"You carry a green sigil. Good. That means Sylva let you pass — she is stingy."',
'"Down here the pressure decides everything. <b>Let us see what you are made of.</b>"'
],
mid:['"The deep does not negotiate!"','"Feel that? The whole grotto is listening to you."'],
win:[
'Marin surfaces with a grin, hair plastered to her forehead.',
'"Beautiful. Salt and lightning in the same chord. You will need it up on the Crags."',
'The water parts, revealing a stair of dry stone. <b>Tidal Sigil obtained!</b>'
],
lose:['"Crushed flat. Go dry off and come back with a better arrangement."'],
reward:{ shards:1200, item:'restorer_max', qty:4 }
},
2: {
name:'Forgemaster Volkan', title:'Gatekeeper of the Obsidian Crags', sprite:'volt', accent:'#f59e0b',
team:[['boltsaber',20],['pyrowolf',20],['obsidian',19]],
intro:[
'Heat rolls off a figure hammering a rod of lightning into shape on a bare anvil of glass.',
'"Another one climbing. Everyone climbs. Nobody comes back down the same."',
'"I am the last thing standing between you and the Pillar. <b>Strike, or be struck.</b>"'
],
mid:['"HA! That had teeth! Again!"','"Every scar is a bar of music, child."'],
win:[
'Volkan laughs — a sound like a rockslide — and tosses you the glowing rod.',
'"You have a spine of forged steel. The Pillar will test more than that."',
'"Aetherion does not fight to win. He fights to <i>know</i> you." <b>Ember Sigil obtained!</b>'
],
lose:['"Not hot enough. Cool down, tune up, come back."'],
reward:{ shards:2400, item:'prism_master', qty:2 }
},
3: {
name:'Sovereign Aetherion', title:'Sovereign of the Apex Pillar', sprite:'cosmos', accent:'#c084fc',
team:[['stormlord',25],['zephyrus',25],['astralon',28]],
intro:[
'The summit is silent in a way that has weight. Aetherion stands with his back to you, watching the stars move.',
'"Four frequencies. Six sigils. A long road for a single question."',
'"I have waited a very long time for a Conductor who collects silence as well as sound."',
'"Do not be nervous. <b>Astralon, greet them.</b>"'
],
mid:['"Closer. Listen past the noise."','"You are almost holding the whole chord at once."'],
win:[
'Astralon dissolves into quiet motes of light, and Aetherion finally turns around.',
'"There it is. The note underneath all six."',
'"You did not defeat me — you <i>harmonized</i> with me. That is the whole secret. It always was."',
'He hands you a prism with no facets, and the sky answers. <b>You are the Apex Conductor.</b>'
],
lose:['"Not yet. But the shape of it is in you. Return when the chord is whole."'],
reward:{ shards:6000, item:'prism_master', qty:5 }
}
};
/* =========================== STARTER OPTIONS ============================ */
const STARTERS = ['emberkit', 'droplet', 'seedling', 'sparky', 'gusty', 'pebble'];
/* ========================= NAME / FLAVOR TABLES ======================== */
const TRAINER_LINES = {
win:['"Good resonance. Cleaner than mine."','"Ha! You tuned that perfectly."','"Take the shards — you earned the tempo."'],
lose:['"Not bad. But not yet."','"Your second note wobbled."','"Come back when your team breathes together."'],
intro:['"A fellow Conductor!"','"Let us tune against each other."','"Three Resonants, three frequencies — let\'s hear yours."']
};
/* ============================ SPRITE ENGINE ============================= */
const TYPE_COLORS = {
Pyro: { a:'#ffa04d', b:'#c2410c', c:'#ffe0a3', glow:'#ff7a29', dark:'#2a0f06' },
Hydro: { a:'#7dd3fc', b:'#1d4ed8', c:'#e0f2fe', glow:'#38bdf8', dark:'#062036' },
Flora: { a:'#a3e635', b:'#15803d', c:'#ecfccb', glow:'#4ade80', dark:'#08240f' },
Zephyr: { a:'#e0fbff', b:'#0284c7', c:'#ffffff', glow:'#a5f3fc', dark:'#06283a' },
Earth: { a:'#e0b072', b:'#78350f', c:'#fde8c4', glow:'#f59e0b', dark:'#2a1607' },
Volt: { a:'#fef08a', b:'#a16207', c:'#fffef2', glow:'#fde047', dark:'#2c2103' },
Cosmos: { a:'#e2c9ff', b:'#6d28d9', c:'#fdf4ff', glow:'#c084fc', dark:'#1e0b33' }
};
let spriteUid = 0;
function eyePair(cx, cy, dx, r, dark, glow, ang) {
const a = ang || 0;
return `<g transform="rotate(${a} ${cx} ${cy})">
<circle cx="${cx - dx}" cy="${cy}" r="${(r * 2.1).toFixed(1)}" fill="${glow}" opacity=".22"/>
<circle cx="${cx + dx}" cy="${cy}" r="${(r * 2.1).toFixed(1)}" fill="${glow}" opacity=".22"/>
<ellipse cx="${cx - dx}" cy="${cy}" rx="${r.toFixed(1)}" ry="${(r * 1.18).toFixed(1)}" fill="${dark}"/>
<ellipse cx="${cx + dx}" cy="${cy}" rx="${r.toFixed(1)}" ry="${(r * 1.18).toFixed(1)}" fill="${dark}"/>
<circle cx="${cx - dx + r * 0.34}" cy="${cy - r * 0.4}" r="${(r * 0.4).toFixed(1)}" fill="#fff"/>
<circle cx="${cx + dx + r * 0.34}" cy="${cy - r * 0.4}" r="${(r * 0.4).toFixed(1)}" fill="#fff"/>
<circle cx="${cx - dx + r * 0.34}" cy="${cy - r * 0.4}" r="${(r * 0.18).toFixed(1)}" fill="#fff" opacity=".9"/>
</g>`;
}
const SHAPES = {
/* -------------------------- PYRO -------------------------- */
pyro(p, t, id) {
const B = `url(#${id}b)`, C = `url(#${id}c)`;
let s = '';
s += `<path d="M72,74 C94,72 100,44 80,38 C92,58 86,70 70,66 Z" fill="${B}"/>`;
s += `<path d="M78,68 C92,64 94,50 84,48 C88,58 84,64 76,62 Z" fill="${p.c}" opacity=".75"/>`;
s += `<ellipse cx="34" cy="86" rx="9" ry="6" fill="${p.b}"/><ellipse cx="68" cy="86" rx="9" ry="6" fill="${p.b}"/>`;
s += `<ellipse cx="50" cy="66" rx="27" ry="20" fill="${B}"/>`;
s += `<ellipse cx="50" cy="75" rx="16" ry="10" fill="${C}" opacity=".5"/>`;
s += `<path d="M33,28 L26,3 L48,20 Z" fill="${B}"/><path d="M67,28 L74,3 L52,20 Z" fill="${B}"/>`;
s += `<path d="M34,26 L30,10 L44,21 Z" fill="${p.c}" opacity=".55"/><path d="M66,26 L70,10 L56,21 Z" fill="${p.c}" opacity=".55"/>`;
s += `<circle cx="50" cy="38" r="21" fill="${B}"/>`;
s += `<ellipse cx="50" cy="47" rx="11" ry="7.5" fill="${C}" opacity=".72"/>`;
s += eyePair(50, 35, 8.5, 4.2, p.dark, p.glow);
s += `<path d="M46.5,42.5 L53.5,42.5 L50,47 Z" fill="${p.dark}"/>`;
s += `<path d="M50,47 L50,51 M50,51 L46,53 M50,51 L54,53" stroke="${p.dark}" stroke-width="1.5" stroke-linecap="round" fill="none" opacity=".8"/>`;
if (t >= 2) {
s += `<path d="M28,52 L18,44 L30,45 L22,33 L34,41 Z" fill="${p.c}" opacity=".92"/>`;
s += `<path d="M72,52 L82,44 L70,45 L78,33 L66,41 Z" fill="${p.c}" opacity=".92"/>`;
}
if (t >= 3) {
s += `<path d="M44,19 L50,2 L56,19 Z" fill="${p.c}"/>`;
s += `<path d="M62,54 L78,44 L70,58 Z" fill="${p.c}" opacity=".9"/>`;
s += `<path d="M33,58 L19,50 L29,64 Z" fill="${p.c}" opacity=".9"/>`;
s += `<circle cx="50" cy="6" r="2.6" fill="#fff" opacity=".9"/>`;
}
return s;
},
/* -------------------------- HYDRO -------------------------- */
hydro(p, t, id) {
const B = `url(#${id}b)`, C = `url(#${id}c)`;
let s = '';
s += `<path d="M78,62 C95,50 99,74 84,86 C89,72 84,64 73,68 Z" fill="${B}" opacity=".96"/>`;
s += `<path d="M50,30 C58,12 72,14 76,28 C65,21 57,26 55,40 Z" fill="${p.c}" opacity=".8"/>`;
s += `<path d="M50,26 C74,44 85,58 85,69 A35,27 0 0 1 15,69 C15,58 26,44 50,26 Z" fill="${B}"/>`;
s += `<path d="M30,73 A20,15 0 0 0 70,73 A20,17 0 0 1 30,73 Z" fill="${C}" opacity=".5"/>`;
s += `<path d="M17,70 C5,66 3,78 15,82 Z" fill="${B}"/>`;
s += `<path d="M83,70 C95,66 97,78 85,82 Z" fill="${B}"/>`;
s += eyePair(50, 57, 14, 4.8, p.dark, p.glow);
s += `<path d="M40,68 Q50,76 60,68" fill="none" stroke="${p.dark}" stroke-width="1.9" stroke-linecap="round" opacity=".72"/>`;
s += `<ellipse cx="42" cy="34" rx="5" ry="3" fill="#fff" opacity=".38"/>`;
if (t >= 2) {
s += `<circle cx="22" cy="42" r="3.6" fill="${p.c}" opacity=".8"/><circle cx="30" cy="26" r="2.3" fill="${p.c}" opacity=".7"/><circle cx="80" cy="36" r="2.8" fill="${p.c}" opacity=".7"/>`;
s += `<path d="M50,28 L44,38 L56,38 Z" fill="${p.c}" opacity=".8"/>`;
}
if (t >= 3) {
s += `<path d="M50,24 C40,6 62,0 70,10 C56,11 50,17 50,24 Z" fill="${p.c}" opacity=".9"/>`;
s += `<circle cx="50" cy="20" r="5" fill="${p.c}"/><circle cx="50" cy="20" r="2.4" fill="#fff" opacity=".9"/>`;
s += `<circle cx="34" cy="50" r="2.4" fill="${p.c}" opacity=".6"/><circle cx="68" cy="52" r="2.2" fill="${p.c}" opacity=".6"/>`;
}
return s;
},
/* -------------------------- FLORA -------------------------- */
flora(p, t, id) {
const B = `url(#${id}b)`, C = `url(#${id}c)`;
let s = '';
s += `<path d="M50,26 C42,8 58,-2 70,6 C56,10 50,16 50,26 Z" fill="#4ade80"/>`;
s += `<path d="M50,26 C58,8 42,-2 30,6 C44,10 50,16 50,26 Z" fill="#22c55e"/>`;
s += `<ellipse cx="34" cy="86" rx="10" ry="6" fill="${p.b}"/><ellipse cx="66" cy="86" rx="10" ry="6" fill="${p.b}"/>`;
s += `<ellipse cx="50" cy="66" rx="28" ry="21" fill="${B}"/>`;
s += `<ellipse cx="50" cy="74" rx="18" ry="12" fill="${C}" opacity=".45"/>`;
s += `<circle cx="50" cy="38" r="21" fill="${B}"/>`;
s += `<circle cx="50" cy="22" r="7" fill="#4ade80"/><circle cx="50" cy="22" r="3" fill="#d9f99d"/>`;
s += eyePair(50, 38, 8.8, 4.4, p.dark, p.glow);
s += `<path d="M44,49 Q50,54 56,49" fill="none" stroke="${p.dark}" stroke-width="1.9" stroke-linecap="round" opacity=".75"/>`;
s += `<circle cx="34" cy="49" r="3.2" fill="#86efac" opacity=".75"/><circle cx="66" cy="49" r="3.2" fill="#86efac" opacity=".75"/>`;
if (t >= 2) {
s += `<path d="M22,62 C10,56 8,68 20,74 Z" fill="#22c55e"/><path d="M78,62 C90,56 92,68 80,74 Z" fill="#22c55e"/>`;
s += `<path d="M28,44 L18,38 L30,40 Z" fill="#4ade80" opacity=".85"/><path d="M72,44 L82,38 L70,40 Z" fill="#4ade80" opacity=".85"/>`;
}
if (t >= 3) {
for (let i = 0; i < 7; i++) {
const a = -160 + i * 26;
s += `<g transform="rotate(${a} 50 20)"><ellipse cx="50" cy="4" rx="5" ry="9" fill="#fda4af" opacity=".92"/></g>`;
}
s += `<circle cx="50" cy="20" r="6" fill="#fde047"/><circle cx="50" cy="20" r="3" fill="#fff8dc"/>`;
s += `<path d="M30,70 C20,66 18,78 30,84 Z" fill="#22c55e"/><path d="M70,70 C80,66 82,78 70,84 Z" fill="#22c55e"/>`;
}
return s;
},
/* -------------------------- VOLT -------------------------- */
volt(p, t, id) {
const B = `url(#${id}b)`, C = `url(#${id}c)`;
let s = '';
s += `<path d="M74,74 L96,60 L84,74 L99,79 L74,84 Z" fill="${p.c}" opacity=".95"/>`;
s += `<ellipse cx="34" cy="86" rx="9" ry="6" fill="${p.b}"/><ellipse cx="68" cy="86" rx="9" ry="6" fill="${p.b}"/>`;
s += `<ellipse cx="50" cy="66" rx="26" ry="19" fill="${B}"/>`;
s += `<ellipse cx="50" cy="74" rx="15" ry="9" fill="${C}" opacity=".45"/>`;
s += `<path d="M33,29 L21,6 L30,25 L42,14 Z" fill="${B}"/>`;
s += `<path d="M67,29 L79,6 L70,25 L58,14 Z" fill="${B}"/>`;
s += `<circle cx="50" cy="38" r="20" fill="${B}"/>`;
s += `<path d="M50,26 L45,37 L52,36 L46,48" fill="none" stroke="${p.c}" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" opacity=".95"/>`;
s += eyePair(50, 39, 8.2, 4.1, p.dark, p.glow);
s += `<circle cx="30" cy="50" r="5.2" fill="${p.c}" opacity=".82"/><circle cx="70" cy="50" r="5.2" fill="${p.c}" opacity=".82"/>`;
s += `<path d="M43,50 Q50,55 57,50" fill="none" stroke="${p.dark}" stroke-width="1.8" stroke-linecap="round" opacity=".7"/>`;
if (t >= 2) {
s += `<path d="M26,58 L14,52 L24,64 Z" fill="${p.c}" opacity=".9"/><path d="M74,58 L86,52 L76,64 Z" fill="${p.c}" opacity=".9"/>`;
s += `<path d="M64,44 L72,38 L66,46 Z" fill="${p.c}"/>`;
}
if (t >= 3) {
s += `<path d="M50,16 L44,4 L52,12 L58,2" fill="none" stroke="${p.c}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>`;
s += `<path d="M62,54 L78,44 L68,58 Z" fill="${p.c}" opacity=".95"/>`;
s += `<path d="M34,60 L18,52 L30,66 Z" fill="${p.c}" opacity=".95"/>`;
}
return s;
},
/* -------------------------- ZEPHYR -------------------------- */
zephyr(p, t, id) {
const B = `url(#${id}b)`, C = `url(#${id}c)`;
const wing = t >= 3 ? 1.16 : 1;
let s = '';
s += `<g transform="translate(50 62) scale(${wing}) translate(-50 -62)">`;
s += `<path d="M30,58 C6,50 -2,26 12,20 C16,42 26,52 40,56 Z" fill="${B}"/>`;
s += `<path d="M70,58 C94,50 102,26 88,20 C84,42 74,52 60,56 Z" fill="${B}"/>`;
s += `<path d="M14,26 C20,44 28,51 40,55" fill="none" stroke="${p.c}" stroke-width="1.4" opacity=".6"/>`;
s += `<path d="M86,26 C80,44 72,51 60,55" fill="none" stroke="${p.c}" stroke-width="1.4" opacity=".6"/>`;
s += `</g>`;
s += `<path d="M44,78 L40,94 L50,84 L60,94 L56,78 Z" fill="${p.c}" opacity=".85"/>`;
s += `<ellipse cx="50" cy="64" rx="21" ry="20" fill="${B}"/>`;
s += `<ellipse cx="50" cy="72" rx="12" ry="10" fill="${C}" opacity=".5"/>`;
s += `<circle cx="50" cy="38" r="18" fill="${B}"/>`;
s += `<path d="M36,30 C30,18 40,12 50,16 C42,20 38,25 36,30 Z" fill="${p.c}" opacity=".8"/>`;
s += `<path d="M64,30 C70,18 60,12 50,16 C58,20 62,25 64,30 Z" fill="${p.c}" opacity=".8"/>`;
s += eyePair(50, 35, 7.6, 4, p.dark, p.glow);
s += `<path d="M50,41 L62,46 L50,50 Z" fill="#fbbf24"/>`;
s += `<path d="M50,44 L58,46 L50,47.5 Z" fill="#f59e0b"/>`;
if (t >= 2) {
s += `<path d="M28,54 C18,48 16,58 26,62 Z" fill="${p.c}" opacity=".8"/><path d="M72,54 C82,48 84,58 74,62 Z" fill="${p.c}" opacity=".8"/>`;
}
if (t >= 3) {
s += `<path d="M50,20 L46,6 L50,12 L54,6 Z" fill="${p.c}"/>`;
s += `<circle cx="50" cy="8" r="2.6" fill="#fff" opacity=".9"/>`;
s += `<path d="M22,70 C12,74 14,84 26,80 Z" fill="${p.c}" opacity=".7"/><path d="M78,70 C88,74 86,84 74,80 Z" fill="${p.c}" opacity=".7"/>`;
}
return s;
},
/* -------------------------- EARTH -------------------------- */
earth(p, t, id) {
const B = `url(#${id}b)`, C = `url(#${id}c)`;
let s = '';
s += `<ellipse cx="24" cy="70" rx="9" ry="16" fill="${p.b}" transform="rotate(-14 24 70)"/>`;
s += `<ellipse cx="76" cy="70" rx="9" ry="16" fill="${p.b}" transform="rotate(14 76 70)"/>`;
s += `<Path d="M0,0" fill="none"/>`;
s += `<path d="M26,88 L20,54 L32,38 L50,32 L68,38 L80,54 L74,88 Z" fill="${B}"/>`;
s += `<path d="M50,32 L68,38 L80,54 L74,88 L50,88 Z" fill="#000" opacity=".14"/>`;
s += `<path d="M34,54 L50,46 L66,54 L60,80 L40,80 Z" fill="${C}" opacity=".28"/>`;
s += `<path d="M24,52 L12,32 L34,42 Z" fill="${p.c}"/><path d="M76,52 L88,32 L66,42 Z" fill="${p.c}"/>`;
s += `<path d="M36,42 L33,18 L50,10 L67,18 L64,42 Z" fill="${B}"/>`;
s += `<path d="M50,10 L67,18 L64,42 L50,42 Z" fill="#000" opacity=".13"/>`;
s += `<path d="M38,30 L34,20 L48,15 Z" fill="${p.c}" opacity=".8"/>`;
s += `<rect x="40" y="26" width="7.5" height="5" rx="1.6" fill="${p.glow}"/><rect x="52.5" y="26" width="7.5" height="5" rx="1.6" fill="${p.glow}"/>`;
s += `<rect x="40" y="26" width="7.5" height="5" rx="1.6" fill="${p.glow}" opacity=".5" filter="url(#${id}f)"/><rect x="52.5" y="26" width="7.5" height="5" rx="1.6" fill="${p.glow}" opacity=".5" filter="url(#${id}f)"/>`;
s += `<path d="M40,38 L50,38 L50,44" fill="none" stroke="${p.dark}" stroke-width="1.7" stroke-linecap="round" opacity=".6"/>`;
if (t >= 2) {
s += `<path d="M50,20 L54,28 L46,28 Z" fill="${p.c}"/>`;
s += `<path d="M30,64 L38,72 L30,80" fill="none" stroke="${p.dark}" stroke-width="1.6" opacity=".5"/>`;
}
if (t >= 3) {
s += `<path d="M50,2 L56,14 L44,14 Z" fill="${p.c}"/>`;
s += `<path d="M62,34 L72,22 L70,38 Z" fill="${p.c}"/><path d="M38,34 L28,22 L30,38 Z" fill="${p.c}"/>`;
s += `<circle cx="50" cy="66" r="7" fill="${p.glow}" opacity=".85"/><circle cx="50" cy="66" r="3.4" fill="#fff" opacity=".9"/>`;
}
return s;
},
/* -------------------------- COSMOS -------------------------- */
cosmos(p, t, id) {
const B = `url(#${id}b)`, C = `url(#${id}c)`;
let s = '';
s += `<ellipse cx="50" cy="52" rx="44" ry="16" fill="none" stroke="${p.glow}" stroke-width="1.6" opacity=".65" transform="rotate(-18 50 52)"/>`;
s += `<ellipse cx="50" cy="52" rx="36" ry="12" fill="none" stroke="#fff" stroke-width="1" opacity=".4" transform="rotate(22 50 52)"/>`;
s += `<path d="M50,8 L78,30 L78,72 L50,94 L22,72 L22,30 Z" fill="${B}"/>`;
s += `<path d="M50,8 L78,30 L78,72 L50,94 Z" fill="#000" opacity=".17"/>`;
s += `<path d="M50,20 L68,34 L68,68 L50,82 L32,68 L32,34 Z" fill="${C}" opacity=".22"/>`;
s += `<circle cx="50" cy="52" r="14" fill="${p.glow}" opacity=".9"/><circle cx="50" cy="52" r="7" fill="#fff"/>`;
s += `<circle cx="50" cy="52" r="22" fill="none" stroke="${p.c}" stroke-width="1.2" opacity=".7"/>`;
s += eyePair(50, 40, 11, 4.4, '#1a0530', p.glow);
for (let i = 0; i < 6; i++) {
const a = (i * 60) * Math.PI / 180;
s += `<circle cx="${(50 + Math.cos(a) * 40).toFixed(1)}" cy="${(52 + Math.sin(a) * 30).toFixed(1)}" r="2.6" fill="#fff" opacity=".85"/>`;
}
return s;
}
};
function spriteSVG(sp, size, opts) {
size = size || 110; opts = opts || {};
const p = TYPE_COLORS[sp.type] || TYPE_COLORS.Cosmos;
const t = sp.tier || 1;
const id = 'sp' + (++spriteUid);
const inner = SHAPES[sp.shape] ? SHAPES[sp.shape](p, t, id) : SHAPES.cosmos(p, t, id);
const sc = t === 1 ? 0.9 : (t === 2 ? 1.0 : 1.09);
const wrap = `<g transform="translate(50,64) scale(${sc}) translate(-50,-64)">${inner}</g>`;
return `<svg viewBox="0 0 100 100" width="${size}" height="${size}" style="overflow:visible" class="${opts.cls || ''}">
<defs>
<linearGradient id="${id}b" x1="0" y1="0" x2="0.35" y2="1">
<stop offset="0" stop-color="${p.a}"/><stop offset="1" stop-color="${p.b}"/>
</linearGradient>
<linearGradient id="${id}c" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity=".95"/><stop offset="1" stop-color="${p.c}" stop-opacity=".55"/>
</linearGradient>
<radialGradient id="${id}g">
<stop offset="0" stop-color="${p.glow}" stop-opacity=".45"/>
<stop offset="0.6" stop-color="${p.glow}" stop-opacity=".14"/>
<stop offset="1" stop-color="${p.glow}" stop-opacity="0"/>
</radialGradient>
<filter id="${id}f" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.4"/>
</filter>
</defs>
<ellipse cx="50" cy="52" rx="48" ry="46" fill="url(#${id}g)"/>
<ellipse cx="50" cy="92" rx="24" ry="5.5" fill="#000" opacity=".38"/>
${wrap}
</svg>`;
}
function prismSVG(size, color, rgb) {
size = size || 78; color = color || '#a78bfa'; rgb = rgb || '167,139,250';
return `<svg viewBox="0 0 100 100" width="${size}" height="${size}">
<defs>
<linearGradient id="prg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity=".95"/><stop offset=".5" stop-color="${color}"/><stop offset="1" stop-color="#1e1b4b"/>
</linearGradient>
<filter id="prf" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="3.4"/></filter>
</defs>
<circle cx="50" cy="50" r="34" fill="${color}" opacity=".35" filter="url(#prf)"/>
<path d="M50,4 L86,50 L50,96 L14,50 Z" fill="url(#prg)" stroke="rgba(255,255,255,.85)" stroke-width="2"/>
<path d="M50,4 L50,96 M14,50 L86,50" stroke="rgba(255,255,255,.5)" stroke-width="1.4"/>
<path d="M50,4 L86,50 L50,50 Z" fill="#fff" opacity=".28"/>
<circle cx="50" cy="50" r="9" fill="#fff" opacity=".85"/>
</svg>`;
}
function shardSVG(size, color, rgb) {
size = size || 30; color = color || '#38bdf8';
return `<svg viewBox="0 0 100 100" width="${size}" height="${size}">
<circle cx="50" cy="52" r="30" fill="${color}" opacity=".35" filter="blur(4px)"/>
<path d="M50,6 L84,44 L62,94 L38,94 L16,44 Z" fill="${color}" stroke="rgba(255,255,255,.8)" stroke-width="3"/>
<path d="M50,6 L50,94 M16,44 L84,44" stroke="rgba(255,255,255,.55)" stroke-width="2.4"/>
<path d="M50,6 L84,44 L50,44 Z" fill="#fff" opacity=".32"/>
</svg>`;
}
/* =========================== GAME STATE ================================= */
const SAVE_KEY = 'aethelgard.resonance.save.v1';
let S = null;
let uidCounter = 0;
let activeZone = 0;
function defaultState() {
return {
version: 1,
started: Date.now(),
lastPlayed: Date.now(),
shards: 320,
items: { prism_basic: 6, prism_hyper: 1, prism_master: 0, restorer: 3, restorer_max: 0, elixir: 1, reviver: 0 },
party: [],
vault: [],
zonesUnlocked: 1,
cleared: {},
visited: { 'z0-n0': true },
seen: {},
caught: {},
sigils: 0,
steps: 0,
wildWins: 0,
trainerWins: 0,
playtime: 0,
settings: { sfx: true, speed: 1 }
};
}
function saveGame() {
if (!S) return;
S.lastPlayed = Date.now();
try { localStorage.setItem(SAVE_KEY, JSON.stringify(S)); } catch (e) { /* storage unavailable */ }
}
function loadGame() {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return null;
const o = JSON.parse(raw);
if (!o || !Array.isArray(o.party) || o.party.length === 0) return null;
return o;
} catch (e) { return null; }
}
function hasSave() { return !!loadGame(); }
/* ------------------------- MONSTER INSTANCES --------------------------- */
function statsFor(sp, level) {
return {
maxHP: Math.floor(sp.base.hp * 2 * level / 100) + level * 2 + 10,
atk: Math.floor(sp.base.atk * 2 * level / 100) + 5,
def: Math.floor(sp.base.def * 2 * level / 100) + 5,
spd: Math.floor(sp.base.spd * 2 * level / 100) + 5
};
}
function movesFor(sp, level) {
const line = MOVE_LINES[sp.type] || MOVE_LINES.Cosmos;
const avail = line.filter(k => MOVES[k].unlock <= level);
const list = avail.slice(-4);
return list.length ? list : ['struggle'];
}
function makeMon(key, level) {
const sp = DEX_BY_KEY[key];
level = clamp(level, 1, 60);
const st = statsFor(sp, level);
const m = {
uid: 'm' + (++uidCounter) + '_' + Math.random().toString(36).slice(2, 6),
key: key, level: level, xp: 0,
status: null, curHP: st.maxHP, atk: st.atk, def: st.def, spd: st.spd, maxHP: st.maxHP,
moveCache: movesFor(sp, level)
};
return m;
}
function recalcMon(m) {
const sp = DEX_BY_KEY[m.key];
const st = statsFor(sp, m.level);
const oldMax = m.maxHP || st.maxHP;
m.maxHP = st.maxHP; m.atk = st.atk; m.def = st.def; m.spd = st.spd;
m.curHP = clamp(m.curHP + (m.maxHP - oldMax), 0, m.maxHP);
if (m.curHP <= 0) m.curHP = 0;
m.moveCache = movesFor(sp, m.level);
return m;
}
function spOf(m) { return DEX_BY_KEY[m.key]; }
function xpNeeded(level) { return Math.floor(6 * level * level + 16 * level); }
function isFainted(m) { return m.curHP <= 0; }
function healMon(m, frac) {
const amount = frac >= 1 ? m.maxHP : Math.ceil(m.maxHP * frac);
const before = m.curHP;
m.curHP = clamp(m.curHP + amount, 0, m.maxHP);
return m.curHP - before;
}
function teamAlive(team) { return team.some(m => !isFainted(m)); }
/* ============================== ITEM LOGIC ============================= */
function giveItem(key, qty) {
qty = qty || 1;
S.items[key] = (S.items[key] || 0) + qty;
saveGame();
}
function takeItem(key, qty) {
qty = qty || 1;
if ((S.items[key] || 0) < qty) return false;
S.items[key] -= qty;
if (S.items[key] <= 0) delete S.items[key];
saveGame();
return true;
}
function itemCount(key) { return S.items[key] || 0; }
/* ============================= UI HELPERS ============================== */
let currentScreen = 'screen-title';
function showScreen(id, keepHistory) {
if (id === currentScreen) return;
const cur = document.getElementById(currentScreen);
const next = document.getElementById(id);
if (!next) return;
if (cur) { cur.classList.remove('active'); cur.classList.add('leaving'); setTimeout(() => cur.classList.remove('leaving'), 400); }
next.classList.add('active');
currentScreen = id;
Synth.open();
}
function toast(msg, color) {
const el = document.createElement('div');
el.className = 'toast';
if (color) { el.style.borderColor = color; el.style.boxShadow = '0 14px 34px -12px ' + color; }
el.innerHTML = msg;
$('#toast-root').appendChild(el);
setTimeout(() => el.remove(), 2750);
}
function openModal(html) {
$('#modalBox').innerHTML = html;
$('#modal-root').classList.add('on');
Synth.select();
}
function closeModal() {
$('#modal-root').classList.remove('on');
Synth.back();
}
function flashScreen(color, dur) {
const f = $('#fxflash');
f.style.background = color || '#fff';
f.style.transition = 'none'; f.style.opacity = '0.85';
requestAnimationFrame(() => { f.style.transition = 'opacity ' + (dur || 300) + 'ms ease-out'; f.style.opacity = '0'; });
}
function statusChipsHTML(m) {
if (!m.status) return '';
const labels = { burn: 'Burned', venom: 'Venomed', chill: 'Chilled', stun: 'Stunned', atkUp: 'ATK+', defUp: 'DEF+', spdUp: 'SPD+' };
return Object.keys(m.status).filter(k => m.status[k]).map(k =>
`<span class="schip ${k.replace('Up', '').toLowerCase()}">${k.includes('Up') ? labels[k] : labels[k]}</span>`).join('');
}
function hpClass(m) {
const r = m.curHP / m.maxHP;
return r <= 0.22 ? 'low' : (r <= 0.5 ? 'mid' : '');
}
function barHTML(m) {
return `<div class="bar hp ${hpClass(m)}"><i style="width:${(100 * m.curHP / m.maxHP).toFixed(1)}%"></i></div>`;
}
function typeBadge(t, sm) { return `<span class="type-badge t-${t} ${sm ? 'sm' : ''}">${t}</span>`; }
/* ============================== XP / LEVELS ============================ */
function applyXP(mon, amount) {
const events = [];
mon.xp += amount;
let guard = 0;
while (mon.xp >= xpNeeded(mon.level) && mon.level < 60 && guard++ < 30) {
mon.xp -= xpNeeded(mon.level);
mon.level++;
recalcMon(mon);
events.push({ type: 'level', level: mon.level });
const sp = spOf(mon);
if (sp.evoLevel && mon.level >= sp.evoLevel && sp.evolvesTo) {
mon.key = sp.evolvesTo;
recalcMon(mon);
events.push({ type: 'evolve', into: DEX_BY_KEY[mon.key].name, key: mon.key });
}
}
if (mon.level >= 60) mon.xp = 0;
saveGame();
return events;
}
function xpGainFor(enemyMon, isTrainer) {
const sp = spOf(enemyMon);
const g = Math.floor(sp.xpYield * enemyMon.level / 4.5);
return Math.max(4, Math.floor(g * (isTrainer ? 1.45 : 1)));
}
function markSeen(key) { if (!S.seen[key]) { S.seen[key] = 1; saveGame(); } }
function markCaught(key) { S.seen[key] = 1; S.caught[key] = 1; saveGame(); }
/* ============================ HUD UPDATES ============================== */
function updateHUD() {
if (!S) return;
$('#hudShards').textContent = fmt(S.shards);
$('#hudBadges').textContent = S.sigils;
$('#partyShards').textContent = fmt(S.shards);
$('#shopShards').textContent = fmt(S.shards);
}
function statusLabel(m) {
if (!m.status) return '';
const k = Object.keys(m.status).filter(x => m.status[x] && typeof m.status[x] === 'number')[0];
return k ? ' · ' + k.toUpperCase() : '';
}
function partyStripHTML() {
return S.party.map(m => {
const sp = spOf(m);
return `<div class="pchip glass t-${sp.type} ${isFainted(m) ? 'fainted' : ''}" data-mid="${m.uid}">
${spriteSVG(sp, 38)}
<div class="pmeta">
<div class="pname"><b>${esc(sp.name)}</b><span>Lv ${m.level}</span></div>
<div style="margin-top:4px">${barHTML(m)}</div>
<div class="small muted" style="margin-top:3px;font-size:10px">${m.curHP}/${m.maxHP}${statusLabel(m)}</div>
</div>
</div>`;
}).join('');
}
/* =========================== ZONE / MAP VIEW ============================ */
function nodeKey(z, i) { return 'z' + z + '-n' + i; }
function zoneUnlocked(z) { return z < S.zonesUnlocked; }
function nodeState(z, i) {
const k = nodeKey(z, i);
if (S.cleared[k]) return 'done';
if (i === 0) return 'avail';
if (S.cleared[nodeKey(z, i - 1)]) return 'avail';
return 'locked';
}
function isRepeatable(node) { return node.t === 'wild'; }
function nodeIcon(t) {
return ({ wild: '🌿', cache: '📦', trainer: '⚔️', spring: '⛲', boss: '👑', camp: '🏕️' })[t] || '❔';
}
function nodeKind(t) {
return ({ wild: 'Wild Encounter', cache: 'Mystery Cache', trainer: 'Wandering Conductor', spring: 'Healing Spring', boss: 'Zone Gatekeeper', camp: 'Camp' })[t] || 'Unknown';
}
function renderZoneList() {
$('#zoneList').innerHTML = ZONES.map((z, i) => {
const un = zoneUnlocked(i);
const done = !!S.cleared[nodeKey(i, z.nodes.length - 1)];
return `<div class="zone-item t-${z.type} ${un ? '' : 'locked'} ${i === activeZone ? 'curr' : ''}" data-zone="${i}">
<div class="zone-dot"></div>
<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:700">${esc(z.name)}</div>
<small>Lv ${z.minLv}–${z.maxLv} · ${un ? (done ? '★ Sigil earned' : 'Explored') : '🔒 Locked'}</small>
</div>
</div>`;
}).join('');
$$('#zoneList .zone-item').forEach(el => {
el.onmouseenter = () => Synth.hover();
el.onclick = () => {
const z = +el.dataset.zone;
if (!zoneUnlocked(z)) { Synth.error(); toast('That zone is sealed. Defeat the previous Gatekeeper.'); return; }
activeZone = z; saveGame(); renderMap(); Synth.select();
};
});
}
function renderMap() {
const z = ZONES[activeZone];
document.documentElement.style.setProperty('--elrgb', z.rgb);
$('#zoneNameTop').textContent = z.short;
$('#zoneName').textContent = z.name;
$('#zoneDesc').textContent = z.desc;
const cleared = z.nodes.filter((n, i) => S.cleared[nodeKey(activeZone, i)]).length;
$('#zoneMeta').innerHTML = `
Dominant Frequencies: <b style="color:${z.accent}">${z.types.join(' / ')}</b><br>
Recommended Level: <b>${z.minLv} – ${z.maxLv}</b><br>
Trail Progress: <b>${cleared}/${z.nodes.length} nodes</b><br>
Team Ready: <b>${S.party.filter(m => !isFainted(m)).length}/${S.party.length}</b>`;
renderZoneList();
updateHUD();
$('#mapParty').innerHTML = partyStripHTML() || '<div class="muted small">No Resonants yet.</div>';
$$('#mapParty .pchip').forEach(el => {
el.onmouseenter = () => Synth.hover();
el.onclick = () => { openPartyScreen(); };
});
/* trail lines */
const pts = z.nodes.map(n => [n.x, n.y]);
let d = '';
pts.forEach((p, i) => { d += (i === 0 ? 'M' : 'L') + p[0] + ',' + p[1] + ' '; });
let svg = `<path d="${d}" fill="none" stroke="rgba(255,255,255,.10)" stroke-width="1.1" stroke-dasharray="1.6 1.9" stroke-linecap="round" vector-effect="non-scaling-stroke"/>`;
const reached = pts.length;
let done = '';
for (let i = 0; i < reached - 1; i++) {
if (S.cleared[nodeKey(activeZone, i)]) done += `M${pts[i][0]},${pts[i][1]} L${pts[i + 1][0]},${pts[i + 1][1]} `;
}
if (done) svg += `<path d="${done}" fill="none" stroke="${z.accent}" stroke-width="2.2" stroke-linecap="round" opacity=".85" vector-effect="non-scaling-stroke" style="filter:drop-shadow(0 0 6px ${z.accent})"/>`;
$('#mapPaths').innerHTML = svg;
/* nodes */
$('#mapNodes').innerHTML = z.nodes.map((n, i) => {
const st = nodeState(activeZone, i);
return `<div class="node t-${z.type} ${st} ${n.t === 'boss' ? 'boss' : ''}" data-i="${i}" style="left:${n.x}%;top:${n.y}%">
<span class="nicon">${nodeIcon(n.t)}</span>
<span class="nlabel">${esc(n.label)}</span>
</div>`;
}).join('');
$$('#mapNodes .node').forEach(el => {
const i = +el.dataset.i;
const n = z.nodes[i];
el.onmouseenter = () => {
Synth.hover();
const st = nodeState(activeZone, i);
$('#mapInfo').innerHTML = `<b style="color:${z.accent}">${esc(n.label)}</b> · <span class="muted">${nodeKind(n.t)}</span><br>
${st === 'locked' ? 'Sealed until the previous node is cleared.' : st === 'done' ? 'Cleared. ' + (isRepeatable(n) ? 'You may search again anytime.' : 'Nothing left here but echoes.') : 'Ready to approach.'}`;
};
el.onclick = () => enterNode(i);
});
$('#mapInfo').innerHTML = `<b style="color:${z.accent}">${esc(z.name)}</b> — ${z.nodes.length} nodes on the trail. Hover a node for details, click to approach.`;
}
function currentZoneNodeIndex() {
const z = ZONES[activeZone];
for (let i = 0; i < z.nodes.length; i++) {
const st = nodeState(activeZone, i);
if (st === 'avail' && !S.cleared[nodeKey(activeZone, i)]) return i;
}
return -1;
}
/* ============================== NODE FLOW ============================== */
function enterNode(i) {
const z = ZONES[activeZone];
const n = z.nodes[i];
const st = nodeState(activeZone, i);
if (st === 'locked') { Synth.error(); toast('That path is sealed. Clear the node before it.'); return; }
S.visited[nodeKey(activeZone, i)] = true;
Synth.select();
if (n.t === 'wild') {
const lvl = clamp(rndInt(z.minLv, z.maxLv), 2, 60);
startWildEncounter(activeZone, lvl, i);
} else if (n.t === 'cache') {
if (S.cleared[nodeKey(activeZone, i)]) { Synth.error(); toast('This cache is already empty.'); return; }
openCache(activeZone, i, n);
} else if (n.t === 'spring') {
if (S.cleared[nodeKey(activeZone, i)]) { Synth.error(); toast('The spring already gave what it had.'); return; }
healSpring(activeZone, i);
} else if (n.t === 'trainer') {
if (S.cleared[nodeKey(activeZone, i)]) { Synth.error(); toast('They have moved on down the trail.'); return; }
startTrainerBattle(activeZone, i);
} else if (n.t === 'camp') {
openShopScreen();
} else if (n.t === 'boss') {
if (S.cleared[nodeKey(activeZone, i)]) { toast('The Gatekeeper has already yielded this zone.'); return; }
startBossBattle(activeZone);
}
}
function randomWild(zoneIdx, lvlOverride) {
const z = ZONES[zoneIdx];
const entry = pick(z.pool);
const key = entry[0];
let lv = lvlOverride != null ? lvlOverride : rndInt(entry[1], entry[2]);
lv = clamp(lv, z.minLv, z.maxLv + (zoneIdx === 3 ? 6 : 1));
return makeMon(key, lv);
}
function randomTrainerTeam(zoneIdx) {
const z = ZONES[zoneIdx];
const n = zoneIdx === 0 ? 2 : (zoneIdx === 3 ? 3 : 3);
const team = [];
const used = [];
for (let i = 0; i < n; i++) {
let entry = pick(z.pool);
let guard = 0;
while (used.includes(entry[0]) && guard++ < 12) entry = pick(z.pool);
used.push(entry[0]);
team.push(makeMon(entry[0], clamp(rndInt(z.minLv + 1, z.maxLv), 2, 60)));
}
return team;
}
function markCleared(zoneIdx, i) {
S.cleared[nodeKey(zoneIdx, i)] = true;
saveGame();
}
function openCache(zoneIdx, i, n) {
Synth.select();
const roll = Math.random();
let rewardHTML = '', give = () => { };
if (roll < 0.42) {
const amt = rndInt(120, 320) * (zoneIdx + 1);
S.shards += amt;
give = () => { };
rewardHTML = `<div style="text-align:center">${shardSVG(70)}<h2 style="margin-top:10px">◆ ${fmt(amt)} AETHER SHARDS</h2></div>`;
Synth.shard();
} else if (roll < 0.72) {
const it = pick(['prism_basic', 'restorer', 'elixir', 'prism_hyper']);
const qty = rndInt(1, 3);
giveItem(it, qty);
rewardHTML = `<div style="text-align:center"><div style="font-size:56px">${ITEMS[it].icon}</div><h2 style="margin-top:10px">${qty}× ${ITEMS[it].name}</h2></div>`;
Synth.shard();
} else if (roll < 0.9) {
const it = pick(['prism_hyper', 'restorer_max', 'reviver']);
giveItem(it, 1);
rewardHTML = `<div style="text-align:center"><div style="font-size:56px">${ITEMS[it].icon}</div><h2 style="margin-top:10px">1× ${ITEMS[it].name}</h2><p class="muted small" style="margin-top:8px">A rare find, still humming.</p></div>`;
Synth.captureSuccess();
} else {
const amt = rndInt(500, 900) * (zoneIdx + 1);
S.shards += amt;
rewardHTML = `<div style="text-align:center">${shardSVG(80)}<h2 style="margin-top:10px">RICH VEIN! ◆ ${fmt(amt)}</h2><p class="muted small" style="margin-top:8px">You found a motherlode of Aether Shards.</p></div>`;
Synth.captureSuccess();
}
markCleared(zoneIdx, i);
updateHUD();
openModal(`<h2>MYSTERY CACHE</h2>
<p class="dialog-line">You pry open the sealed container marked <b>${esc(n.label)}</b>…</p>
${rewardHTML}
<div class="modal-actions"><button class="btn primary" onclick="closeModal();renderMap();">Collect</button></div>`);
}
function healSpring(zoneIdx, i) {
let healed = 0, cured = 0;
S.party.concat(S.vault).forEach(m => {
if (m.curHP < m.maxHP) { healMon(m, 1); healed++; }
if (m.status) { m.status = null; cured++; }
});
markCleared(zoneIdx, i);
saveGame(); updateHUD(); renderMap();
Synth.heal();
openModal(`<h2>HEALING SPRING</h2>
<div style="text-align:center;font-size:52px">⛲</div>
<p class="dialog-line">Warm resonance-soaked water spills over your Resonants. Their frequencies steady.</p>
<p class="dialog-line">All Resonants restored to <b>full HP</b>. ${cured ? cured + ' status condition(s) cleansed.' : ''}</p>
<div class="modal-actions"><button class="btn primary" onclick="closeModal();">Continue</button></div>`);
}
async function startTrainerBattle(zoneIdx, i) {
const z = ZONES[zoneIdx];
const team = randomTrainerTeam(zoneIdx);
const name = pick(['Bram', 'Kel', 'Rask', 'Vell', 'Nyx', 'Ondine', 'Perrin', 'Ilyra']) + ' of ' + z.short.split(' ').map(w => w[0] + w.slice(1).toLowerCase()).join(' ');
const intro = [pick(TRAINER_LINES.intro), `"${name}" — they offer a hand, then step back three paces.`];
await runBattle({
kind: 'trainer',
title: name,
trainerName: name,
team: team,
accent: z.accent,
intro: intro,
loseLines: [pick(TRAINER_LINES.lose)],
winLines: [pick(TRAINER_LINES.win)],
reward: { shards: Math.floor(rndInt(180, 300) * (zoneIdx + 1) * 0.9) + 60 },
onVictory: () => { S.trainerWins++; markCleared(zoneIdx, i); }
});
}
async function startBossBattle(zoneIdx) {
const b = BOSSES[zoneIdx];
Synth.bossFanfare();
const team = b.team.map(([k, lv]) => makeMon(k, lv));
await runBattle({
kind: 'boss',
title: b.name + ' — ' + b.title,
trainerName: b.name,
team: team,
accent: b.accent,
intro: b.intro,
midLines: b.mid,
winLines: b.win,
loseLines: b.lose,
reward: b.reward,
bossIndex: zoneIdx,
onVictory: () => {
if (!S.cleared[nodeKey(zoneIdx, ZONES[zoneIdx].nodes.length - 1)]) {
S.sigils++;
}
markCleared(zoneIdx, ZONES[zoneIdx].nodes.length - 1);
S.zonesUnlocked = Math.max(S.zonesUnlocked, Math.min(4, zoneIdx + 2));
}
});
}
async function startWildEncounter(zoneIdx, lvl, nodeIdx) {
const mon = randomWild(zoneIdx, lvl);
const z = ZONES[zoneIdx];
await runBattle({
kind: 'wild',
title: 'A wild ' + spOf(mon).name + ' appeared!',
team: [mon],
accent: z.accent,
intro: [],
onVictory: () => { S.wildWins++; if (nodeIdx != null && nodeState(zoneIdx, nodeIdx) === 'avail') markCleared(zoneIdx, nodeIdx); }
});
}
/* ============================== PARTY VIEW ============================= */
function monCardHTML(m, opts) {
opts = opts || {};
const sp = spOf(m);
const st = statsFor(sp, m.level);
const need = xpNeeded(m.level);
return `<div class="party-slot glass t-${sp.type}" data-uid="${m.uid}">
${spriteSVG(sp, 88)}
<div class="body">
<div class="flex between center gap8">
<div>
<div style="font-weight:800;font-size:15px">${esc(sp.name)} <span class="muted small">#${String(sp.num).padStart(2, '0')}</span></div>
<div class="flex gap8 center mt8" style="margin-top:5px">${typeBadge(sp.type)}<span class="small muted">Lv ${m.level}</span>${m.status ? statusChipsHTML(m) : ''}</div>
</div>
<div class="bar hp ${hpClass(m)}" style="width:132px">${''}<i style="width:${(100 * m.curHP / m.maxHP).toFixed(1)}%"></i></div>
</div>
<div class="small muted">HP ${m.curHP} / ${m.maxHP}</div>
<div class="bar xp"><i style="width:${(100 * m.xp / need).toFixed(1)}%"></i></div>
<div class="small muted" style="display:flex;gap:12px;flex-wrap:wrap;font-variant-numeric:tabular-nums">
<span>ATK <b style="color:#fca5a5">${st.atk}</b></span>
<span>DEF <b style="color:#93c5fd">${st.def}</b></span>
<span>SPD <b style="color:#86efac">${st.spd}</b></span>
<span>XP ${m.xp}/${need}</span>
</div>
<div class="flex gap8 wrap">${m.moveCache.map(k => `<span class="type-badge t-${MOVES[k].element} sm">${MOVES[k].name}</span>`).join('')}</div>
<div class="flex gap8 wrap">${opts.actions || ''}</div>
</div>
</div>`;
}
let partyReturn = 'screen-map';
function openPartyScreen(from) {
partyReturn = from || currentScreen;
renderPartyScreen();
showScreen('screen-party');
}
function renderPartyScreen() {
updateHUD();
const actions = (idx) => {
if (S.party.length <= 1) return '';
return `<button class="btn sm" data-act="promote" data-idx="${idx}">Set as Lead</button>`;
};
$('#partyList').innerHTML = S.party.length ? S.party.map((m, i) => monCardHTML(m, { actions: actions(i) })).join('')
: '<div class="big-empty">Your harmonic team is empty. Return to the world and capture a Resonant.</div>';
$('#boxList').innerHTML = S.vault.length ? S.vault.map(m => monCardHTML(m)).join('')
: '<div class="muted small">The prism vault is empty. Captures beyond three team members are stored here.</div>';
$$('#partyList [data-act]').forEach(b => b.onclick = () => {
const idx = +b.dataset.idx;
const [m] = S.party.splice(idx, 1);
S.party.unshift(m);
saveGame(); renderPartyScreen(); Synth.select(); toast('Lead Resonant updated.');
});
}
/* =============================== DEX VIEW ============================== */
function openDexScreen(from) {
partyReturn = from || currentScreen;
renderDex();
showScreen('screen-dex');
}
function renderDex() {
const q = ($('#dexSearch').value || '').toLowerCase();
const f = $('#dexFilter').value;
const list = DEX.filter(s => {
if (f !== 'all' && s.type !== f) return false;
if (q && !s.name.toLowerCase().includes(q)) return false;
return true;
});
const caught = Object.keys(S.caught).length;
$('#dexCount').textContent = caught + ' / 18 recorded';
$('#dexGrid').innerHTML = list.map(s => {
const seen = !!S.seen[s.key], got = !!S.caught[s.key];
return `<div class="dex-card t-${s.type} ${seen ? '' : 'unknown'}" data-key="${s.key}">
<div class="no">#${String(s.num).padStart(2, '0')}</div>
<div class="tick">${got ? '✅' : (seen ? '👁' : '')}</div>
${spriteSVG(s, 96)}
<div class="nm">${seen ? esc(s.name) : '?????'}</div>
<div class="mt8">${typeBadge(s.type, true)}</div>
<div class="small muted" style="margin-top:6px">${seen ? (s.tier === 1 ? 'Base form' : s.tier === 2 ? 'Evolves at Lv ' + s.evoLevel : 'Final form') : 'Undiscovered'}</div>
</div>`;
}).join('');
$$('#dexGrid .dex-card').forEach(c => {
c.onmouseenter = () => Synth.hover();
c.onclick = () => showDexDetail(c.dataset.key);
});
}
function showDexDetail(key) {
const s = DEX_BY_KEY[key];
const seen = !!S.seen[key];
Synth.select();
if (!seen) {
openModal(`<h2>RESONANT #${String(s.num).padStart(2, '0')}</h2>
<div style="text-align:center;font-size:60px">🔒</div>
<p class="dialog-line">No record yet. Encounter this Resonant in the wild to fill the entry.</p>
<div class="modal-actions"><button class="btn" onclick="closeModal()">Close</button></div>`);
return;
}
const maxStat = 145;
const row = (lbl, v, cap) => `<div class="stat-row"><span class="lbl">${lbl}</span>
<div class="track"><i style="width:${clamp(100 * v / (cap || maxStat), 4, 100)}%"></i></div><span class="val">${v}</span></div>`;
const line = MOVE_LINES[s.type];
openModal(`<h2>RESONANT #${String(s.num).padStart(2, '0')} · ${esc(s.name).toUpperCase()}</h2>
<div class="flex gap12 center wrap" style="align-items:flex-start">
<div class="t-${s.type}">${spriteSVG(s, 140)}</div>
<div style="flex:1;min-width:220px">
<div class="flex gap8 wrap center">${typeBadge(s.type)}<span class="small muted">${s.tier === 1 ? 'Base form' : s.tier === 2 ? 'Stage 2' : 'Final form'}</span>${S.caught[key] ? '<span class="small" style="color:#4ade80">✅ Captured</span>' : ''}</div>
<p class="dialog-line" style="margin-top:10px">${esc(s.flavor)}</p>
<p class="muted small" style="line-height:1.7">${esc(s.lore)}</p>
</div>
</div>
<h3 class="sect" style="margin:16px 0 6px">BASE STATS</h3>
${row('HP', s.base.hp, 140)}${row('ATK', s.base.atk, 130)}${row('DEF', s.base.def, 130)}${row('SPD', s.base.spd, 140)}
<h3 class="sect" style="margin:16px 0 6px">MOVE LINE</h3>
<div class="movelist">${line.map(k => {
const mv = MOVES[k];
return `<div class="moverow"><div><b>${mv.name}</b> ${typeBadge(mv.element, true)}</div>
<div class="small muted" style="display:flex;gap:12px;font-variant-numeric:tabular-nums"><span>PWR ${mv.power || '—'}</span><span>ACC ${mv.acc}</span><span>Lv ${mv.unlock}</span></div></div>`;
}).join('')}</div>
<p class="muted small" style="margin-top:12px">${s.evoLevel ? `Evolves into <b>${esc(DEX_BY_KEY[s.evolvesTo].name)}</b> at level ${s.evoLevel}.` : 'Final evolutionary stage.'}</p>
<div class="modal-actions"><button class="btn primary" onclick="closeModal()">Close</button></div>`);
}
/* ============================== SHOP VIEW ============================== */
function openShopScreen(from) {
partyReturn = from || currentScreen;
renderShop();
showScreen('screen-shop');
}
function renderShop() {
updateHUD();
$('#shopGrid').innerHTML = SHOP_ORDER.map(key => {
const it = ITEMS[key];
return `<div class="shopcard glass">
<div class="flex between center">
<div class="ic">${it.icon}</div>
<div class="pill shard"><span style="font-size:12px">◆</span>${fmt(it.price)}</div>
</div>
<h3>${it.name}</h3>
<p>${it.desc}</p>
<div class="flex between center gap8">
<span class="small muted">Owned: <b style="color:#fff">${itemCount(key)}</b></span>
<button class="btn sm primary" data-buy="${key}">Buy 1</button>
</div>
<button class="btn sm" data-buy5="${key}">Buy 5</button>
</div>`;
}).join('');
$$('[data-buy]').forEach(b => b.onclick = () => buy(b.dataset.buy, 1));
$$('[data-buy5]').forEach(b => b.onclick = () => buy(b.dataset.buy5, 5));
$('#satchelList').innerHTML = SHOP_ORDER.filter(k => itemCount(k) > 0).map(k =>
`<div class="flex gap8 center"><span style="font-size:22px">${ITEMS[k].icon}</span>
<div><div style="font-weight:700;font-size:12.5px">${ITEMS[k].name}</div>
<div class="small muted">×${itemCount(k)}</div></div></div>`).join('') ||
'<div class="muted small">Your satchel is empty. Buy supplies above.</div>';
}
function buy(key, qty) {
const it = ITEMS[key];
const cost = it.price * qty;
if (S.shards < cost) { Synth.error(); toast('Not enough Aether Shards. You need ◆' + fmt(cost - S.shards) + ' more.'); return; }
S.shards -= cost;
giveItem(key, qty);
updateHUD(); renderShop();
Synth.shard();
toast(`Purchased ${qty}× ${it.name}`);
}
/* ============================= BATTLE ENGINE =========================== */
const B = {
on: false, kind: 'wild', cfg: null, team: [], eIdx: 0, pIdx: 0,
result: null, busy: false, resolveChoice: null, turn: 0,
midSaid: false, accent: '#8b5cf6'
};
function hexToRgb(hex) {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex || '#8b5cf6');
return m ? `${parseInt(m[1], 16)},${parseInt(m[2], 16)},${parseInt(m[3], 16)}` : '139,92,246';
}
const pActive = () => S.party[B.pIdx];
const eActive = () => B.team[B.eIdx];
function buffOf(m, stat) { return (m.buffs && m.buffs[stat]) || 1; }
function chipsOf(m) {
let out = '';
const names = { burn: 'Burned', venom: 'Venomed', chill: 'Chilled', stun: 'Stunned' };
['burn', 'venom', 'chill', 'stun'].forEach(k => {
if (m.status && m.status[k]) out += `<span class="schip ${k}">${names[k]}</span>`;
});
if (m.buffs) {
if (m.buffs.atk > 1.01) out += '<span class="schip buff">ATK ▲</span>';
if (m.buffs.def > 1.01) out += '<span class="schip buff">DEF ▲</span>';
if (m.buffs.spd > 1.01) out += '<span class="schip buff">SPD ▲</span>';
}
return out;
}
function resetBattleTemps(m) {
m.buffs = { atk: 1, def: 1, spd: 1 };
m.status = m.status || {};
}
/* --------------------------- rendering --------------------------------- */
function infoPanelHTML(m, isEnemy) {
const sp = spOf(m);
const need = xpNeeded(m.level);
return `<div class="row1">
<div class="nm t-${sp.type}" style="color:#fff">${esc(sp.name)}</div>
<div class="lv">Lv ${m.level}</div>
</div>
<div class="hpline">${barHTML(m)}<span class="hpnum">${m.curHP}/${m.maxHP}</span></div>
${isEnemy ? '' : `<div class="xpline">${`<div class="bar xp"><i style="width:${(100 * m.xp / need).toFixed(1)}%"></i></div>`}
<div class="small muted" style="margin-top:4px;font-size:10px">XP ${m.xp} / ${need}</div></div>`}
<div class="status-chips">${chipsOf(m)}</div>
<div class="party-dots">${B.team.map((t, i) => `<div class="pdot ${isEnemy ? (i === B.eIdx ? 'act' : '') + (t.curHP > 0 ? ' alive' : ' dead') : ''}"></div>`).join('')}</div>`;
}
function playerPanelHTML(m) {
const sp = spOf(m);
const need = xpNeeded(m.level);
return `<div class="row1">
<div class="nm">${esc(sp.name)}</div>
<div class="lv">Lv ${m.level}</div>
</div>
<div class="hpline">${barHTML(m)}<span class="hpnum">${m.curHP}/${m.maxHP}</span></div>
<div class="xpline"><div class="bar xp"><i style="width:${(100 * m.xp / need).toFixed(1)}%"></i></div></div>
<div class="status-chips">${chipsOf(m)}</div>
<div class="party-dots">${S.party.map((t, i) => `<div class="pdot ${i === B.pIdx ? 'act' : ''} ${t.curHP > 0 ? 'alive' : 'dead'}"></div>`).join('')}</div>`;
}
function renderBattleUI(full) {
const e = eActive(), p = pActive();
const eSP = spOf(e), pSP = spOf(p);
document.documentElement.style.setProperty('--aelrgb', hexToRgb(B.accent));
$('#enemyPanel').className = 'info-panel glass t-' + eSP.type;
$('#enemyPanel').innerHTML = `<div class="row1">
<div class="nm">${esc(eSP.name)}</div><div class="lv">Lv ${e.level}</div></div>
<div class="hpline">${barHTML(e)}<span class="hpnum">${e.curHP}/${e.maxHP}</span></div>
<div class="status-chips">${chipsOf(e)}</div>
<div class="party-dots">${B.team.map((t, i) => `<div class="pdot ${i === B.eIdx ? 'act' : ''} ${t.curHP > 0 ? 'alive' : 'dead'}"></div>`).join('')}</div>`;
$('#playerPanel').className = 'info-panel glass t-' + pSP.type;
$('#playerPanel').innerHTML = playerPanelHTML(p);
if (full) {
$('#enemySprite').innerHTML = spriteSVG(eSP, 158);
$('#enemySprite').className = 'sprite-wrap enemy-bob t-' + eSP.type;
$('#playerSprite').innerHTML = spriteSVG(pSP, 178);
$('#playerSprite').className = 'sprite-wrap bob t-' + pSP.type;
const eC = `radial-gradient(ellipse at center, rgba(${hexToRgb(TYPE_COLORS[eSP.type].glow)},.45), transparent 70%)`;
const pC = `radial-gradient(ellipse at center, rgba(${hexToRgb(TYPE_COLORS[pSP.type].glow)},.45), transparent 70%)`;
$('#platEnemy').style.background = eC;
$('#platPlayer').style.background = pC;
spawnDrift($('#enemySprite'), TYPE_COLORS[eSP.type].glow);
spawnDrift($('#playerSprite'), TYPE_COLORS[pSP.type].glow);
}
}
function swapSprites() {
const e = eActive(), p = pActive();
$('#enemySprite').innerHTML = spriteSVG(spOf(e), 158);
$('#playerSprite').innerHTML = spriteSVG(spOf(p), 178);
$('#platEnemy').style.background = `radial-gradient(ellipse at center, rgba(${hexToRgb(TYPE_COLORS[spOf(e).type].glow)},.45), transparent 70%)`;
$('#platPlayer').style.background = `radial-gradient(ellipse at center, rgba(${hexToRgb(TYPE_COLORS[spOf(p).type].glow)},.45), transparent 70%)`;
}
function spawnDrift(el, color) {
const wrap = el;
if (!wrap) return;
for (let i = 0; i < 7; i++) {
const d = document.createElement('div');
d.className = 'drift';
d.style.background = color;
d.style.left = (10 + Math.random() * 80) + '%';
d.style.top = (40 + Math.random() * 55) + '%';
d.style.animationDelay = (Math.random() * 1.6).toFixed(2) + 's';
d.style.animationDuration = (1.8 + Math.random() * 1.4).toFixed(2) + 's';
d.style.boxShadow = '0 0 10px 3px ' + color;
wrap.appendChild(d);
}
}
function floatText(el, text, color, size) {
const arena = $('#arena'); if (!arena || !el) return;
const a = arena.getBoundingClientRect(), r = el.getBoundingClientRect();
const d = document.createElement('div');
d.className = 'float-dmg';
d.textContent = text;
d.style.color = color || '#fff';
if (size) d.style.fontSize = size + 'px';
d.style.left = (r.left - a.left + r.width / 2) + 'px';
d.style.top = (r.top - a.top + r.height * 0.14) + 'px';
arena.appendChild(d);
setTimeout(() => d.remove(), 1250);
}
function sparkBurst(el, color, count) {
const arena = $('#arena'); if (!arena || !el) return;
const a = arena.getBoundingClientRect(), r = el.getBoundingClientRect();
const cx = r.left - a.left + r.width / 2, cy = r.top - a.top + r.height / 2;
for (let i = 0; i < (count || 14); i++) {
const s = document.createElement('div');
s.className = 'spark';
const ang = Math.random() * Math.PI * 2, dist = 40 + Math.random() * 110;
s.style.setProperty('--dx', (Math.cos(ang) * dist).toFixed(0) + 'px');
s.style.setProperty('--dy', (Math.sin(ang) * dist).toFixed(0) + 'px');
s.style.left = cx + 'px'; s.style.top = cy + 'px';
s.style.background = color || '#fff';
s.style.boxShadow = '0 0 12px 4px ' + (color || '#fff');
s.style.width = s.style.height = (4 + Math.random() * 7).toFixed(1) + 'px';
arena.appendChild(s);
setTimeout(() => s.remove(), 800);
}
}
function shakeArena() {
const a = $('#arena');
a.classList.remove('shake');
void a.offsetWidth;
a.classList.add('shake');
setTimeout(() => a.classList.remove('shake'), 520);
}
function hitFlash(el, cls) {
if (!el) return;
el.classList.remove('hitflash', 'charge', 'fainted');
void el.offsetWidth;
el.classList.add(cls || 'hitflash');
}
/* ------------------------------ message -------------------------------- */
async function say(text, cls) {
const el = $('#logbox');
const past = el.querySelector('.past');
const pastHTML = past ? past.outerHTML : '';
el.innerHTML = pastHTML + `<div class="line ${cls || ''}">${text}<span class="cursor"></span></div>`;
el.scrollTop = 0;
const spd = (S.settings && S.settings.speed) || 1;
const dur = spd >= 3 ? 60 : Math.min(1500, (240 + String(text).length * 13)) / spd;
await sleep(dur);
}
function archive(line) {
const el = $('#logbox');
el.innerHTML = `<div class="past">${line}</div>`;
}
/* ------------------------------- menu ---------------------------------- */
function setMenu(html) { $('#battleMenu').innerHTML = html; }
function choose(x) {
if (!B.resolveChoice || B.busy) return;
const r = B.resolveChoice;
B.resolveChoice = null;
B.busy = true;
setMenu('');
r(x);
}
function waitChoice() {
B.busy = false;
return new Promise(res => { B.resolveChoice = res; });
}
function mainMenu() {
const p = pActive();
setMenu(`
<button class="movebtn" data-act="attack"><span class="mn">⚔️ Attack</span><span class="ms">Choose one of ${p.moveCache.length} moves</span></button>
<button class="movebtn" data-act="prisms"><span class="mn">🔷 Aether Prisms</span><span class="ms">${B.kind === 'wild' ? 'Attempt to capture' : 'Wild Resonants only'}</span></button>
<button class="movebtn" data-act="exchange"><span class="mn">🔁 Exchange</span><span class="ms">Swap active Resonant</span></button>
<button class="movebtn" data-act="inventory"><span class="mn">🎒 Inventory</span><span class="ms">Use a satchel item</span></button>
${B.kind === 'wild' ? '<button class="movebtn" data-act="flee" style="grid-column:1/-1"><span class="mn">🏃 Retreat</span><span class="ms">Attempt to leave the encounter</span></button>' : ''}
<div class="battle-hint">Turn ${B.turn} · ${spOf(p).name} Lv ${p.level}</div>`);
$$('#battleMenu [data-act]').forEach(b => {
b.onmouseenter = () => Synth.hover();
b.onclick = () => {
const a = b.dataset.act;
Synth.select();
if (a === 'attack') moveMenu();
else if (a === 'prisms') prismMenu();
else if (a === 'exchange') exchangeMenu();
else if (a === 'inventory') inventoryMenu();
else if (a === 'flee') choose({ kind: 'flee' });
};
});
}
function backButton() { return `<button class="movebtn" data-back="1" style="grid-column:1/-1"><span class="mn">↩ Back</span><span class="ms">Return to the action menu</span></button>`; }
function wireBack() { $$('#battleMenu [data-back]').forEach(b => b.onmouseenter = () => Synth.hover(), b.onclick = () => { Synth.back(); mainMenu(); }); }
function moveMenu() {
const p = pActive();
setMenu(p.moveCache.map(k => {
const m = MOVES[k];
const eff = typeMod(m.element, spOf(eActive()).type);
const tag = eff > 1.05 ? '<span style="color:#4ade80">▲</span>' : eff < 0.95 ? '<span style="color:#f87171">▼</span>' : '';
return `<button class="movebtn t-${m.element}" data-move="${k}">
<span class="mn"><span class="t-${m.element}">${m.name}</span>${tag}</span>
<span class="ms"><span>PWR ${m.power || '—'}</span><span>ACC ${m.acc}</span><span>${m.element}</span></span>
</button>`;
}).join('') + backButton());
$$('#battleMenu [data-move]').forEach(b => {
b.onmouseenter = () => Synth.hover();
b.onclick = () => { Synth.select(); choose({ kind: 'attack', move: b.dataset.move }); };
});
wireBack();
}
function prismMenu() {
const keys = ['prism_basic', 'prism_hyper', 'prism_master'].filter(k => itemCount(k) > 0);
if (!keys.length) {
setMenu(`<div class="battle-hint" style="font-size:13px;color:#fca5a5">You have no Aether Prisms left. Visit the Camp Shop.</div>` + backButton());
wireBack(); return;
}
setMenu(keys.map(k => {
const it = ITEMS[k];
return `<button class="movebtn" data-prism="${k}">
<span class="mn">${it.icon} ${it.name}</span>
<span class="ms"><span>Owned ${itemCount(k)}</span><span>×${it.mult.toFixed(1)} rate</span></span></button>`;
}).join('') + backButton());
$$('#battleMenu [data-prism]').forEach(b => {
b.onmouseenter = () => Synth.hover();
b.onclick = () => { Synth.select(); choose({ kind: 'capture', prism: b.dataset.prism }); };
});
wireBack();
}
function exchangeMenu() {
const list = S.party.map((m, i) => ({ m, i })).filter(o => o.i !== B.pIdx && !isFainted(o.m));
if (!list.length) {
setMenu(`<div class="battle-hint" style="font-size:13px;color:#fca5a5">No conscious Resonants to exchange with.</div>` + backButton());
wireBack(); return;
}
setMenu(list.map(o => {
const sp = spOf(o.m);
return `<button class="movebtn t-${sp.type}" data-sw="${o.i}">
<span class="mn">${esc(sp.name)}</span>
<span class="ms"><span>Lv ${o.m.level}</span><span>HP ${o.m.curHP}/${o.m.maxHP}</span></span></button>`;
}).join('') + backButton());
$$('#battleMenu [data-sw]').forEach(b => {
b.onmouseenter = () => Synth.hover();
b.onclick = () => { Synth.select(); choose({ kind: 'switch', idx: +b.dataset.sw }); };
});
wireBack();
}
function inventoryMenu() {
const keys = ['restorer', 'restorer_max', 'elixir', 'reviver', 'charm'].filter(k => itemCount(k) > 0);
if (!keys.length) {
setMenu(`<div class="battle-hint" style="font-size:13px;color:#fca5a5">Your satchel is empty.</div>` + backButton());
wireBack(); return;
}
setMenu(keys.map(k => {
const it = ITEMS[k];
return `<button class="movebtn" data-item="${k}">
<span class="mn">${it.icon} ${it.name}</span>
<span class="ms"><span>Owned ${itemCount(k)}</span><span>${it.kind}</span></span></button>`;
}).join('') + backButton());
$$('#battleMenu [data-item]').forEach(b => {
b.onmouseenter = () => Synth.hover();
b.onclick = () => { Synth.select(); choose({ kind: 'item', item: b.dataset.item }); };
});
wireBack();
}
function targetMenu(itemKey) {
const alive = S.party.some(m => !isFainted(m));
const list = (ITEMS[itemKey].kind === 'revive')
? S.party.map((m, i) => ({ m, i })).filter(o => isFainted(o.m))
: S.party.map((m, i) => ({ m, i })).filter(o => !isFainted(o.m));
if (!list.length) { Synth.error(); toast('No valid target for that item.'); mainMenu(); return; }
setMenu(list.map(o => {
const sp = spOf(o.m);
return `<button class="movebtn t-${sp.type}" data-target="${o.i}">
<span class="mn">${esc(sp.name)}</span>
<span class="ms"><span>Lv ${o.m.level}</span><span>HP ${o.m.curHP}/${o.m.maxHP}</span></span></button>`;
}).join('') + backButton());
$$('#battleMenu [data-target]').forEach(b => {
b.onmouseenter = () => Synth.hover();
b.onclick = () => { Synth.select(); choose({ kind: 'item', item: itemKey, targetIdx: +b.dataset.target }); };
});
$$('#battleMenu [data-back]').forEach(b => b.onclick = () => { Synth.back(); inventoryMenu(); });
$$('#battleMenu [data-back]').forEach(b => b.onmouseenter = () => Synth.hover());
}
/* ---------------------------- combat math ------------------------------ */
function calcDamage(att, def, move) {
const atkStat = att.atk * buffOf(att, 'atk');
const defStat = Math.max(1, def.def * buffOf(def, 'def'));
const base = ((2 * att.level / 5 + 2) * move.power * (atkStat / defStat)) / 50 + 2;
const tm = typeMod(move.element, spOf(def).type);
const crit = Math.random() < 0.1;
const variance = rnd(0.85, 1.0);
const dmg = Math.max(1, Math.floor(base * tm * variance * (crit ? 1.5 : 1)));
return { dmg, tm, crit };
}
async function dealDamage(att, def, move, attIsPlayer) {
const attEl = attIsPlayer ? $('#playerSprite') : $('#enemySprite');
const defEl = attIsPlayer ? $('#enemySprite') : $('#playerSprite');
const moveColor = TYPE_COLORS[move.element].glow;
await say(`<b>${esc(spOf(att).name)}</b> used <b>${move.name}</b>!`);
hitFlash(attEl, 'charge');
await sleep(230);
const { dmg, tm, crit } = calcDamage(att, def, move);
const missed = Math.random() * 100 > move.acc;
if (missed) {
Synth.weak();
await say(`…but the resonance scattered — it missed!`);
return { missed: true };
}
sparkBurst(defEl, moveColor, tm > 1.05 ? 24 : 13);
hitFlash(defEl, 'hitflash');
if (tm > 1.05) Synth.superHit(); else if (tm < 0.95) Synth.weak(); else Synth.hit();
if (crit) Synth.crit();
if (crit || tm > 1.05) { shakeArena(); flashScreen(moveColor, crit ? 380 : 240); }
floatText(defEl, String(dmg), crit ? '#fde047' : (tm > 1.05 ? '#4ade80' : '#fff'), crit ? 26 : 19);
def.curHP = Math.max(0, def.curHP - dmg);
if (crit) await say(`A critical resonance! <b>${dmg}</b> damage!`, 'crit');
else await say(`It dealt <b>${dmg}</b> damage.`);
const mult = typeMultText(tm);
if (mult) await say(mult);
/* effects */
if (move.effect) await applyMoveEffect(att, def, move, attIsPlayer);
if (def.curHP <= 0) {
Synth.faint();
hitFlash(defEl, 'fainted');
await sleep(560);
await say(`<b>${esc(spOf(def).name)}</b> fainted!`);
}
renderBattleUI(false);
return { missed: false, dmg };
}
async function applyMoveEffect(att, def, move, attIsPlayer) {
const e = move.effect;
if (e.status && def.curHP > 0 && Math.random() < (e.chance || 0.2)) {
if (!def.status) def.status = {};
def.status[e.status] = 3 + rndInt(0, 2);
Synth.noise({ freq: 1400, dur: 0.24, vol: 0.2 });
await say(`<b>${esc(spOf(def).name)}</b> is now <b>${({ burn: 'burned', venom: 'venomed', chill: 'chilled', stun: 'stunned' })[e.status]}</b>!`);
spawnDrift(attIsPlayer ? $('#enemySprite') : $('#playerSprite'), e.status === 'burn' ? '#fb923c' : e.status === 'venom' ? '#a3e635' : e.status === 'stun' ? '#fde047' : '#7dd3fc');
}
if (e.buff) {
att.buffs = att.buffs || { atk: 1, def: 1, spd: 1 };
att.buffs[e.buff.stat] = Math.max(att.buffs[e.buff.stat], e.buff.mult);
Synth.levelUp();
floatText(attIsPlayer ? $('#playerSprite') : $('#enemySprite'), e.buff.stat.toUpperCase() + ' ▲', '#c4b5fd', 15);
await say(`<b>${esc(spOf(att).name)}</b>'s ${e.buff.stat.toUpperCase()} rose sharply!`);
}
if (e.heal) {
const amt = Math.ceil(att.maxHP * e.heal);
const before = att.curHP;
att.curHP = clamp(att.curHP + amt, 0, att.maxHP);
Synth.heal();
floatText(attIsPlayer ? $('#playerSprite') : $('#enemySprite'), '+' + (att.curHP - before), '#86efac', 18);
await say(`<b>${esc(spOf(att).name)}</b> restored <b>${att.curHP - before}</b> HP.`);
}
if (e.drain && def.curHP >= 0) {
const amt = Math.ceil(att.maxHP * e.drain);
const before = att.curHP;
att.curHP = clamp(att.curHP + amt, 0, att.maxHP);
Synth.heal();
if (att.curHP > before) {
floatText(attIsPlayer ? $('#playerSprite') : $('#enemySprite'), '+' + (att.curHP - before), '#86efac', 18);
await say(`<b>${esc(spOf(att).name)}</b> drained the resonance, restoring <b>${att.curHP - before}</b> HP.`);
}
}
}
/* --------------------------- enemy behaviour --------------------------- */
function enemyBestMove(e, p) {
const options = e.moveCache.map(k => MOVES[k]);
const offensive = options.filter(m => m.power > 0);
const support = options.filter(m => m.power === 0);
const hurt = e.curHP / e.maxHP < 0.35;
const healMove = support.find(m => m.effect && m.effect.heal);
if (hurt && healMove && Math.random() < 0.6) return options.indexOf(healMove) >= 0 ? options[options.indexOf(healMove)] : healMove;
const buffMove = support.find(m => m.effect && m.effect.buff);
if (!hurt && buffMove && Math.random() < 0.28) return buffMove;
if (!offensive.length) return options[0] || MOVES.struggle;
if (Math.random() < 0.2) return pick(offensive);
let best = offensive[0], bestScore = -1;
offensive.forEach(m => {
const tm = typeMod(m.element, spOf(p).type);
const score = m.power * m.acc / 100 * tm * m.power / 50;
if (score > bestScore) { bestScore = score; best = m; }
});
return best;
}
async function enemyTurn() {
const e = eActive(), p = pActive();
if (isFainted(e) || isFainted(p)) return;
if (e.status && e.status.stun && Math.random() < 0.3) {
Synth.weak();
await say(`<b>${esc(spOf(e).name)}</b> is paralysed and cannot move!`);
return;
}
const move = enemyBestMove(e, p);
await dealDamage(e, p, move, false);
}
async function runStatusTicks() {
const list = [{ m: pActive(), isP: true }, { m: eActive(), isP: false }];
for (const { m, isP } of list) {
if (isFainted(m) || !m.status) continue;
if (m.status.burn) {
const d = Math.max(1, Math.floor(m.maxHP / 16));
m.curHP = Math.max(0, m.curHP - d);
Synth.noise({ freq: 400, dur: 0.2, vol: 0.18 });
floatText(isP ? $('#playerSprite') : $('#enemySprite'), '-' + d, '#fb923c', 16);
await say(`<b>${esc(spOf(m).name)}</b> is hurt by its burn! (-${d} HP)`);
}
if (m.status.venom) {
const d = Math.max(1, Math.floor(m.maxHP / 12));
m.curHP = Math.max(0, m.curHP - d);
Synth.noise({ freq: 300, dur: 0.2, vol: 0.18 });
floatText(isP ? $('#playerSprite') : $('#enemySprite'), '-' + d, '#a3e635', 16);
await say(`<b>${esc(spOf(m).name)}</b> is seared by venom! (-${d} HP)`);
}
['burn', 'venom', 'chill', 'stun'].forEach(k => {
if (m.status && m.status[k] != null) {
m.status[k] = (typeof m.status[k] === 'number' ? m.status[k] - 1 : m.status[k]);
if (m.status[k] <= 0) delete m.status[k];
}
});
if (m.curHP <= 0) {
Synth.faint();
hitFlash(isP ? $('#playerSprite') : $('#enemySprite'), 'fainted');
await sleep(480);
await say(`<b>${esc(spOf(m).name)}</b> succumbed to its condition!`);
}
renderBattleUI(false);
}
}
/* ------------------------- capture sequence ---------------------------- */
async function attemptCapture(prismKey) {
if (B.kind !== 'wild') {
Synth.error();
await say(`A Conductor's Resonant cannot be captured. The prism rejects the foreign harmony.`);
return false;
}
if (!takeItem(prismKey, 1)) { Synth.error(); return false; }
const e = eActive();
const sp = spOf(e);
const prism = prismSVG(74, TYPE_COLORS[sp.type].glow, hexToRgb(TYPE_COLORS[sp.type].glow));
let rate = 0.15 + (1 - (2 * e.curHP) / (3 * e.maxHP)) * 0.75;
rate *= ITEMS[prismKey].mult;
if (e.status && Object.keys(e.status).length) rate *= 1.35;
rate *= [1, 0.85, 0.62][sp.tier - 1] || 1;
rate = clamp(rate, 0.03, 0.96);
await say(`You hurl the <b>${ITEMS[prismKey].name}</b>!`);
const arena = $('#arena');
const holder = document.createElement('div');
holder.className = 'prism-anim';
holder.innerHTML = prism;
holder.style.left = '76%';
holder.style.top = '38%';
arena.appendChild(holder);
Synth.tone({ freq: 900, slideTo: 1500, dur: 0.2, type: 'triangle', vol: 0.2 });
await sleep(820);
holder.style.animation = 'none';
const burst = document.createElement('div');
burst.className = 'burst';
burst.style.left = '76%'; burst.style.top = '38%';
arena.appendChild(burst);
hitFlash($('#enemySprite'), 'charge');
e._captureIfCatch = true;
$('#enemySprite').style.opacity = '0';
setTimeout(() => burst.remove(), 900);
await sleep(320);
const caught = Math.random() < rate;
const shakes = caught ? 3 : rndInt(1, 3);
holder.classList.add('shakeit');
for (let i = 0; i < shakes; i++) {
Synth.captureShake();
await sleep(430);
}
holder.classList.remove('shakeit');
if (caught) {
holder.classList.add('caught');
for (let i = 0; i < 3; i++) {
const d = document.createElement('div');
d.className = 'burst';
d.style.left = '76%'; d.style.top = '38%';
d.style.borderColor = TYPE_COLORS[sp.type].glow;
arena.appendChild(d);
setTimeout(() => d.remove(), 900);
await sleep(150);
}
Synth.captureSuccess();
flashScreen(TYPE_COLORS[sp.type].glow, 420);
await say(`<b>${esc(sp.name)}</b> was harmonized! The prism holds its frequency safely.`, 'crit');
markCaught(e.key);
e.curHP = Math.max(1, e.curHP);
addToRoster(e);
B.result = 'caught';
setTimeout(() => holder.remove(), 900);
return true;
} else {
Synth.captureFail();
$('#enemySprite').style.opacity = '';
hitFlash($('#enemySprite'), 'hitflash');
await say(`The prism shattered — <b>${esc(sp.name)}</b> broke free!`);
holder.remove();
return false;
}
}
function addToRoster(mon) {
mon.buffs = { atk: 1, def: 1, spd: 1 };
if (S.party.length < 3) {
S.party.push(mon);
toast(`<b>${esc(spOf(mon).name)}</b> joined your harmonic team!`, TYPE_COLORS[spOf(mon).type].glow);
} else {
S.vault.push(mon);
toast(`<b>${esc(spOf(mon).name)}</b> was sent to the prism vault.`, TYPE_COLORS[spOf(mon).type].glow);
}
saveGame();
}
/* ---------------------------- turn resolution -------------------------- */
async function playerAttack(moveKey) {
const p = pActive(), e = eActive();
if (p.status && p.status.stun && Math.random() < 0.3) {
Synth.weak();
await say(`<b>${esc(spOf(p).name)}</b> is paralysed and cannot move!`);
return;
}
await dealDamage(p, e, MOVES[moveKey], true);
}
async function doSwitch(idx) {
const old = pActive();
B.pIdx = idx;
const now = pActive();
Synth.open();
floatText($('#playerSprite'), 'EXCHANGE!', '#c4b5fd', 14);
await say(`<b>${esc(spOf(old).name)}</b>, fall back! <b>${esc(spOf(now).name)}</b>, resonate!`);
resetBattleTemps(now);
spawnDrift($('#playerSprite'), TYPE_COLORS[spOf(now).type].glow);
renderBattleUI(true);
$('#playerSprite').style.opacity = '';
}
async function useItemOn(itemKey, idx) {
const it = ITEMS[itemKey];
const target = S.party[idx] || pActive();
if (!takeItem(itemKey, 1)) { Synth.error(); await say(`You have no ${it.name} left.`); return; }
Synth.heal();
if (it.kind === 'heal') {
const healed = healMon(target, it.frac);
floatText(target === pActive() ? $('#playerSprite') : $('#playerSprite'), '+' + healed, '#86efac', 18);
await say(`You used <b>${it.name}</b>. <b>${esc(spOf(target).name)}</b> recovered <b>${healed}</b> HP.`);
} else if (it.kind === 'status') {
target.status = {};
await say(`You used <b>${it.name}</b>. <b>${esc(spOf(target).name)}</b>'s frequency is clean again.`);
} else if (it.kind === 'revive') {
target.curHP = Math.max(1, Math.floor(target.maxHP / 2));
if (target.status) target.status = {};
await say(`You used <b>${it.name}</b>. <b>${esc(spOf(target).name)}</b> resonates once more!`);
} else if (it.kind === 'buff') {
const t = pActive();
t.spd += 1;
await say(`You used <b>${it.name}</b>. <b>${esc(spOf(t).name)}</b>'s Speed permanently rose by 1!`);
}
renderBattleUI(true);
}
async function fleeAttempt() {
const p = pActive(), e = eActive();
const chance = clamp(0.45 + (p.spd - e.spd) / 120, 0.2, 0.95);
if (Math.random() < chance) {
Synth.back();
await say('You slipped away on a backdraft of resonance.');
B.result = 'flee';
} else {
Synth.error();
await say('You could not break the resonance lock!');
}
}
async function forceSwitchMenu() {
const list = S.party.map((m, i) => ({ m, i })).filter(o => !isFainted(o.m));
if (!list.length) return null;
setMenu(`<div class="battle-hint" style="grid-column:1/-1;font-size:13px;color:#fca5a5">Your active Resonant has fainted — send out another!</div>` +
list.map(o => {
const sp = spOf(o.m);
return `<button class="movebtn t-${sp.type}" data-forcesw="${o.i}">
<span class="mn">${esc(sp.name)}</span>
<span class="ms"><span>Lv ${o.m.level}</span><span>HP ${o.m.curHP}/${o.m.maxHP}</span></span></button>`;
}).join(''));
const res = await new Promise(r => {
$$('#battleMenu [data-forcesw]').forEach(b => {
b.onmouseenter = () => Synth.hover();
b.onclick = () => { Synth.select(); r(+b.dataset.forcesw); };
});
});
setMenu('');
return res;
}
async function resolveTurn(choice) {
B.turn++;
const p = pActive(), e = eActive();
const priority = k => (k === 'attack' ? 1 : 2);
const order = [];
order.push({ who: 'player', prio: priority(choice.kind) });
order.push({ who: 'enemy', prio: 1 });
if (order[0].prio === order[1].prio) {
const pSpd = p.spd * buffOf(p, 'spd') * (p.status && p.status.chill ? 0.5 : 1);
const eSpd = e.spd * buffOf(e, 'spd') * (e.status && e.status.chill ? 0.5 : 1);
if (eSpd > pSpd) order.reverse();
}
for (const step of order) {
if (B.result) break;
if (step.who === 'player') {
if (isFainted(pActive())) continue;
if (choice.kind === 'attack') await playerAttack(choice.move);
else if (choice.kind === 'capture') await attemptCapture(choice.prism);
else if (choice.kind === 'switch') await doSwitch(choice.idx);
else if (choice.kind === 'item') await useItemOn(choice.item, choice.targetIdx);
else if (choice.kind === 'flee') await fleeAttempt();
} else {
if (!isFainted(eActive()) && !isFainted(pActive())) {
if (B.kind === 'boss' && !B.midSaid && eActive().curHP / eActive().maxHP < 0.5 && B.cfg.midLines) {
B.midSaid = true;
await say(pick(B.cfg.midLines));
}
await enemyTurn();
}
}
if (B.result) break;
/* faint resolution */
if (isFainted(eActive())) {
await handleEnemyFaint();
if (B.result) break;
}
if (isFainted(pActive())) {
const ok = await handlePlayerFaint();
if (!ok) break;
}
}
if (B.result) return;
await runStatusTicks();
if (isFainted(eActive())) { await handleEnemyFaint(); if (B.result) return; }
if (isFainted(pActive())) { await handlePlayerFaint(); if (B.result) return; }
}
async function handleEnemyFaint() {
const e = eActive();
markSeen(e.key);
await awardXP(e);
if (B.eIdx + 1 < B.team.length) {
B.eIdx++;
resetBattleTemps(eActive());
renderBattleUI(true);
const nm = B.cfg && B.cfg.trainerName;
if (nm) await say(`<b>${esc(nm)}</b> sends out <b>${esc(spOf(eActive()).name)}</b>!`);
else await say(`<b>${esc(spOf(eActive()).name)}</b> enters the field!`);
Synth.open();
} else {
B.result = 'win';
}
}
async function handlePlayerFaint() {
if (!teamAlive(S.party)) {
B.result = 'lose';
return false;
}
const idx = await forceSwitchMenu();
if (idx == null) { B.result = 'lose'; return false; }
B.pIdx = idx;
resetBattleTemps(pActive());
renderBattleUI(true);
await say(`Go, <b>${esc(spOf(pActive()).name)}</b>!`);
return true;
}
async function awardXP(enemyMon) {
const p = pActive();
if (isFainted(p)) return;
const gain = xpGainFor(enemyMon, B.kind !== 'wild');
const evts = applyXP(p, gain);
await say(`<b>${esc(spOf(p).name)}</b> gained <b>${gain}</b> XP.`);
S.party.forEach(m => { if (m !== p && !isFainted(m)) applyXP(m, Math.floor(gain * 0.45)); });
for (const ev of evts) {
if (ev.type === 'level') {
Synth.levelUp();
banner(`LEVEL UP! Lv ${ev.level}`);
await say(`<b>${esc(spOf(p).name)}</b> reached <b>level ${ev.level}</b>!`, 'crit');
await sleep(700);
} else if (ev.type === 'evolve') {
Synth.evolve();
flashScreen('#fff', 700);
banner('EVOLUTION!');
await say(`What? <b>${esc(spOf(p).name)}</b> is evolving!`);
await sleep(500);
hitFlash($('#playerSprite'), 'charge');
renderBattleUI(true);
await sleep(900);
await say(`It evolved into <b>${esc(ev.into)}</b>!`, 'crit');
markSeen(p.key);
markCaught(p.key);
}
}
renderBattleUI(false);
}
function banner(text) {
const d = document.createElement('div');
d.className = 'lvlup-banner';
d.innerHTML = text;
$('#arena').appendChild(d);
setTimeout(() => d.remove(), 2300);
}
/* ------------------------------ main loop ------------------------------ */
async function runBattle(cfg) {
if (B.on) return;
if (!teamAlive(S.party)) {
S.party.forEach(m => { healMon(m, 1); m.status = {}; });
toast('Your exhausted team nursed itself back at camp.');
}
B.on = true; B.cfg = cfg; B.kind = cfg.kind; B.team = cfg.team;
B.eIdx = 0; B.result = null; B.busy = true; B.turn = 0; B.midSaid = false;
B.accent = cfg.accent || '#8b5cf6';
B.pIdx = Math.max(0, S.party.findIndex(m => !isFainted(m)));
S.party.forEach(resetBattleTemps);
B.team.forEach(resetBattleTemps);
markSeen(eActive().key);
$('#sparks').innerHTML = '';
showScreen('screen-battle');
$('#logbox').innerHTML = '<div class="past">The air thickens with resonance…</div>';
setMenu('<div class="battle-hint" style="font-size:13px">⌁ Tuning frequencies… ⌁</div>');
renderBattleUI(true);
if (cfg.kind === 'wild') {
Synth.noise({ freq: 1200, sweep: 300, dur: 0.4, vol: 0.3 });
await say(`A wild <b>${esc(spOf(eActive()).name)}</b> (Lv ${eActive().level}) appeared!`, 'crit');
} else {
Synth.bossFanfare();
for (const line of (cfg.intro || [])) await say(line);
await say(`<b>${esc(cfg.trainerName)}</b> challenges you to a ${cfg.team.length}-on-3 resonance duel!`);
}
await say(`Go, <b>${esc(spOf(pActive()).name)}</b>!`);
spawnDrift($('#playerSprite'), TYPE_COLORS[spOf(pActive()).type].glow);
renderBattleUI(false);
while (!B.result) {
mainMenu();
const choice = await waitChoice();
if (choice.kind === 'prisms' || choice.kind === 'exchange' || choice.kind === 'inventory') continue;
if (choice.kind === 'item') {
if (!ITEMS[choice.item] || itemCount(choice.item) <= 0) { continue; }
if (choice.targetIdx == null) { targetMenu(choice.item); continue; }
}
B.busy = true;
archive(`Turn ${B.turn + 1} — ${esc(spOf(pActive()).name)} Lv ${pActive().level} vs ${esc(spOf(eActive()).name)} Lv ${eActive().level}`);
await resolveTurn(choice);
B.busy = false;
}
await finishBattle();
}
async function finishBattle() {
const cfg = B.cfg;
setMenu('');
const r = B.result;
if (r === 'lose') {
Synth.faint();
for (const l of (cfg.loseLines || [])) await say(l);
await say('Your team is carried back to camp. The trail will still be here.');
const lost = Math.floor(S.shards * 0.1);
S.shards = Math.max(0, S.shards - lost);
S.party.forEach(m => { m.curHP = Math.max(1, Math.floor(m.maxHP * 0.6)); if (m.status) m.status = {}; });
saveGame();
toast('You blacked out. Lost ◆' + fmt(lost) + ' in the retreat.');
} else if (r === 'win') {
Synth.levelUp();
if (cfg.kind !== 'wild') {
for (const l of (cfg.winLines || [])) await say(l);
} else {
await say('The wild resonance settles. You won!');
}
if (cfg.reward) {
if (cfg.reward.shards) { S.shards += cfg.reward.shards; Synth.shard(); toast('Earned ◆' + fmt(cfg.reward.shards) + '!'); }
if (cfg.reward.item) { giveItem(cfg.reward.item, cfg.reward.qty || 1); toast('Received ' + (cfg.reward.qty || 1) + '× ' + ITEMS[cfg.reward.item].name + '!'); }
}
if (cfg.onVictory) cfg.onVictory();
saveGame();
} else if (r === 'caught') {
await say('The new Resonant hums softly inside its prism.');
if (cfg.onVictory) cfg.onVictory();
saveGame();
} else if (r === 'flee') {
await say('You retreated safely.');
}
await sleep(320);
const finale = cfg.kind === 'boss' && B.cfg.bossIndex === 3 && r === 'win';
B.on = false; B.result = null; B.team = []; B.cfg = null;
S.party.forEach(m => { if (m.status) m.status = {}; });
saveGame();
if (finale) { showScreen('screen-map'); renderMap(); showVictory(); return; }
showScreen('screen-map');
renderMap();
}
/* ============================ TITLE / MENU ============================= */
function playtimeText() {
const t = (S && S.playtime) || 0;
const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60;
return (h ? h + 'h ' : '') + m + 'm ' + s + 's';
}
function refreshTitle() {
const save = loadGame();
const btn = $('#btnContinue');
if (save) {
btn.classList.add('primary');
btn.disabled = false;
const lead = save.party[0] ? DEX_BY_KEY[save.party[0].key].name : '—';
$('#titleSaveInfo').innerHTML = `Saved run · Lead <b style="color:#c4b5fd">${esc(lead)}</b> Lv ${save.party[0] ? save.party[0].level : '?'} · <b style="color:#7de3f7">◆${fmt(save.shards)}</b> · <b>${save.sigils}/4</b> sigils · ${Math.floor(save.playtime / 60)}m played`;
} else {
btn.classList.remove('primary');
btn.disabled = true;
$('#titleSaveInfo').textContent = 'No saved run found — begin a new journey.';
}
}
function renderStarters() {
$('#starterGrid').innerHTML = STARTERS.map(k => {
const s = DEX_BY_KEY[k];
const st = statsFor(s, 5);
return `<div class="shopcard glass t-${s.type}">
<div style="text-align:center">${spriteSVG(s, 108)}</div>
<div style="text-align:center"><h3>${esc(s.name)}</h3>
<div class="mt8">${typeBadge(s.type)}</div></div>
<p>${esc(s.flavor)}</p>
<div class="small muted" style="font-variant-numeric:tabular-nums">HP ${st.maxHP} · ATK ${st.atk} · DEF ${st.def} · SPD ${st.spd}<br>
Evolves at Lv ${s.evoLevel} into ${esc(DEX_BY_KEY[s.evolvesTo].name)}</div>
<button class="btn primary wide" data-starter="${k}">Choose ${esc(s.name)}</button>
</div>`;
}).join('');
$$('[data-starter]').forEach(b => {
b.onmouseenter = () => Synth.hover();
b.onclick = () => beginJourney(b.dataset.starter);
});
}
function beginJourney(key) {
S = defaultState();
const starter = makeMon(key, 5);
S.party.push(starter);
markCaught(key);
S.seen[key] = 1;
saveGame();
Synth.captureSuccess();
activeZone = 0;
renderMap();
showScreen('screen-map');
const sp = DEX_BY_KEY[key];
openModal(`<h2>PROFESSOR HARMONIA</h2>
<p class="dialog-line">"Ah — <b>${esc(sp.name)}</b>. A warm frequency. I can hear it from here."</p>
<p class="dialog-line">"Aethelgard is stitched together by six elemental frequencies. When they clash, spirits form — we call them <b>Resonants</b>."</p>
<p class="dialog-line">"You carry three Aether Prisms and a satchel of supplies. Walk the trail, capture what answers you, and climb toward the <b>Apex Pillar</b>."</p>
<p class="dialog-line">"Clear each zone's <b>Gatekeeper</b> to open the next. And mind the springs — they mend everything."</p>
<div class="modal-actions">
<button class="btn" onclick="closeModal();showHelp();">How to Play</button>
<button class="btn primary" onclick="closeModal();">Set off</button>
</div>`);
}
function showHelp() {
openModal(`<h2>HOW TO PLAY</h2>
<p class="dialog-line"><b>Explore.</b> Click nodes on a zone trail. 🌿 Wild encounters, 📦 Mystery caches, ⚔️ Wandering Conductors, ⛲ Healing springs, 👑 Zone Gatekeepers.</p>
<p class="dialog-line"><b>Battle.</b> Turn-based 1v1. Speed decides who moves first; switching and items always resolve first.</p>
<p class="dialog-line"><b>Type chart.</b> Pyro ▸ Flora ▸ Hydro ▸ Pyro · Zephyr ▸ Earth ▸ Volt ▸ Zephyr · Cosmos is neutral.</p>
<p class="dialog-line"><b>Capture.</b> Weaken a wild Resonant first — the lower its HP and the rarer the prism, the better your odds. Status conditions help too.</p>
<p class="dialog-line"><b>Grow.</b> Resonants evolve at level 8 and level 15, unlocking stronger moves as they level.</p>
<p class="dialog-line"><b>Save.</b> Progress is stored automatically on every change.</p>
<div class="modal-actions"><button class="btn primary" onclick="closeModal()">Understood</button></div>`);
}
function showMenuModal() {
Synth.open();
openModal(`<h2>CONDUCTOR MENU</h2>
<div class="grid-2">
<div class="glass" style="padding:14px">
<div class="small muted">SIGILS</div><div style="font-size:22px;font-weight:800;color:#c4b5fd">${S.sigils}/4</div>
<div class="small muted" style="margin-top:10px">AETHER SHARDS</div><div style="font-size:22px;font-weight:800;color:#7de3f7">◆ ${fmt(S.shards)}</div>
</div>
<div class="glass" style="padding:14px">
<div class="small muted">WILD WINS</div><div style="font-size:22px;font-weight:800">${S.wildWins}</div>
<div class="small muted" style="margin-top:10px">CONDUCTOR WINS</div><div style="font-size:22px;font-weight:800">${S.trainerWins}</div>
</div>
</div>
<div class="small muted" style="margin:14px 0 4px">TIME PLAYED · ${playtimeText()} · DEX ${Object.keys(S.caught).length}/18</div>
<div class="modal-actions" style="justify-content:flex-start">
<button class="btn" onclick="closeModal();openPartyScreen('screen-map')">⚗ Team</button>
<button class="btn" onclick="closeModal();openDexScreen('screen-map')">📖 Dex</button>
<button class="btn" onclick="closeModal();openShopScreen('screen-map')">🛒 Shop</button>
<button class="btn" id="btnToggleSfx">🔊 Sound: ${S.settings.sfx ? 'On' : 'Off'}</button>
<button class="btn" id="btnToggleSpeed">⏩ Text speed: ${S.settings.speed === 1 ? 'Normal' : S.settings.speed === 2 ? 'Fast' : 'Instant'}</button>
<button class="btn" onclick="closeModal();saveGame();refreshTitle();showScreen('screen-title')">🏠 Save & Title</button>
<button class="btn danger" onclick="confirmReset()">⚠ Reset Progress</button>
</div>`);
$('#btnToggleSfx').onclick = () => {
S.settings.sfx = !S.settings.sfx;
Synth.enabled = S.settings.sfx;
Synth.setVolume(S.settings.sfx ? 0.35 : 0);
saveGame();
showMenuModal();
};
$('#btnToggleSpeed').onclick = () => {
S.settings.speed = S.settings.speed === 1 ? 2 : (S.settings.speed === 2 ? 3 : 1);
saveGame();
showMenuModal();
};
}
function confirmReset() {
openModal(`<h2>RESET PROGRESS</h2>
<p class="dialog-line">This erases your team, your shards, your sigils and every dex entry. <b>This cannot be undone.</b></p>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn danger" id="btnDoReset">Erase everything</button>
</div>`);
$('#btnDoReset').onclick = () => {
try { localStorage.removeItem(SAVE_KEY); } catch (e) { }
S = null;
B.on = false;
closeModal();
refreshTitle();
showScreen('screen-title');
toast('Progress erased. A clean frequency.');
};
}
function showVictory() {
Synth.captureSuccess();
openModal(`<h2>APEX CONDUCTOR</h2>
<div style="text-align:center">${prismSVG(96, '#c084fc', '192,132,252')}</div>
<p class="dialog-line">The six frequencies settle into a single, quiet chord. Below you, Aethelgard hums in tune.</p>
<p class="dialog-line">You defeated every Gatekeeper, harmonized with the Apex Sovereign, and recorded <b>${Object.keys(S.caught).length}/18</b> Resonants in the Conductor's Dex.</p>
<p class="dialog-line">The trail is still open. Wilds still wander, and prisms still fill.</p>
<div class="small muted" style="margin:10px 0">Time played · ${playtimeText()} · Wild wins · ${S.wildWins} · Conductor wins · ${S.trainerWins}</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal();showMenuModal()">Conductor Menu</button>
<button class="btn primary" onclick="closeModal()">Keep exploring</button>
</div>`);
}
/* ================================ INIT ================================= */
let lastHoverTarget = null;
function wireGlobal() {
document.addEventListener('mouseover', e => {
const t = e.target.closest ? e.target.closest('.btn,.icon-btn,.node,.dex-card,.movebtn,.zone-item,.shopcard,.party-slot') : null;
if (t && t !== lastHoverTarget && !t.disabled) { lastHoverTarget = t; Synth.resume(); Synth.hover(); }
if (!t) lastHoverTarget = null;
});
const unlock = () => { Synth.resume(); };
window.addEventListener('pointerdown', unlock, { once: true });
window.addEventListener('keydown', unlock, { once: true });
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { if ($('#modal-root').classList.contains('on')) closeModal(); }
if (e.key === 'm' || e.key === 'M') {
S.settings.sfx = !S.settings.sfx;
Synth.enabled = S.settings.sfx;
Synth.setVolume(S.settings.sfx ? 0.35 : 0);
saveGame(); toast('Sound ' + (S.settings.sfx ? 'on' : 'off'));
}
});
/* title screen */
$('#btnNewGame').onclick = () => { Synth.select(); renderStarters(); showScreen('screen-starter'); };
$('#btnContinue').onclick = () => {
const data = loadGame();
if (!data) { Synth.error(); toast('No saved run found.'); return; }
S = data;
S.items = S.items || {};
S.settings = S.settings || { sfx: true, speed: 1 };
S.seen = S.seen || {}; S.caught = S.caught || {}; S.vault = S.vault || [];
S.cleared = S.cleared || {}; S.visited = S.visited || {};
S.party.forEach(m => { if (!m.moveCache) recalcMon(m); m.buffs = { atk: 1, def: 1, spd: 1 }; });
Synth.enabled = !!S.settings.sfx; Synth.setVolume(S.settings.sfx ? 0.35 : 0);
activeZone = clamp(S.zonesUnlocked - 1, 0, 3);
renderMap();
showScreen('screen-map');
Synth.captureSuccess();
toast('Welcome back, Conductor.');
};
$('#btnTitleDex').onclick = () => {
if (!S) { S = defaultState(); S.__temp = true; }
renderDex(); showScreen('screen-dex');
};
$('#btnTitleHelp').onclick = () => { Synth.select(); showHelp(); };
$('#btnReset').onclick = () => { Synth.select(); confirmReset(); };
$('#btnStarterBack').onclick = () => { Synth.back(); refreshTitle(); showScreen('screen-title'); };
/* map nav */
$('#btnOpenDex').onclick = () => openDexScreen('screen-map');
$('#btnOpenParty').onclick = () => openPartyScreen('screen-map');
$('#btnOpenShop').onclick = () => openShopScreen('screen-map');
$('#btnOpenMenu').onclick = () => showMenuModal();
$('#btnHealHint').onclick = () => { Synth.select(); toast('⛲ Springs fully heal and cleanse your whole roster.'); };
/* sub screens */
$('#btnDexBack').onclick = () => { Synth.back(); if (S && S.__temp) { S = null; showScreen('screen-title'); } else showScreen(partyReturn || 'screen-map'); };
$('#btnPartyBack').onclick = () => { Synth.back(); showScreen(partyReturn || 'screen-map'); };
$('#btnShopBack').onclick = () => { Synth.back(); showScreen(partyReturn || 'screen-map'); };
$('#dexSearch').oninput = () => renderDex();
$('#dexFilter').onchange = () => renderDex();
$('#modal-root').onclick = e => { if (e.target.id === 'modal-root') closeModal(); };
/* title letters animation */
const tt = $('#titleText');
tt.innerHTML = tt.textContent.split('').map((c, i) => `<span style="animation-delay:${(i * 0.055).toFixed(2)}s">${c}</span>`).join('');
}
function boot() {
wireGlobal();
refreshTitle();
/* periodic autosave + playtime */
setInterval(() => {
if (S && !S.__temp && currentScreen !== 'screen-title') {
S.playtime++;
if (S.playtime % 15 === 0) saveGame();
}
}, 1000);
window.addEventListener('beforeunload', () => { if (S && !S.__temp) saveGame(); });
/* pre-render idle screens so they are ready */
console.log('%cAethelgard ready', 'color:#c084fc;font-weight:bold', '— 18 Resonants, 4 zones, 4 Gatekeepers.');
}
boot();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"631a6372-e266-4af9-afc1-bf1d2d596ccb","tokensIn":5721196,"tokensOut":98422,"tokensTotal":5819618,"cost":0.08921570399999999,"turns":45,"toolCalls":44,"failedToolCalls":0,"timestamp":"2026-09-11T00:51:22.487Z"} -->