Creature Collection
Minimax m3 · middle

Reviews & generation details
Reviews
Human review · Jul 17, 2026 · rubric 1
Historical path-based review. Association with these exact bytes is unverified.
The reviewer marked this result as broken. It remains in the archive.
- Visual design
- Unscored
- UX / feel
- Unscored
- Works
- Unscored
- Prompt spirit
- Unscored
- Delight
- Unscored
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 170,374 bytes · SHA-256 aab2376b95bf
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Aethermere: Whispers of the Hollow</title>
<style>
:root{
--bg-0:#07060f;
--bg-1:#0f0c1f;
--bg-2:#1a1530;
--bg-3:#241b40;
--ink-0:#e8e3ff;
--ink-1:#b6a8e0;
--ink-2:#7a6ba8;
--lumen:#7fffe5;
--lumen-dim:#3fb5a0;
--cinder:#ff8a5b;
--flora:#7cd66a;
--tidal:#5ec3ff;
--storm:#cba6ff;
--crag:#e0c891;
--shade:#a48cff;
--crimson:#ff5a7a;
--gold:#ffd76b;
--good:#7cf2a8;
--bad:#ff6e6e;
--glass: rgba(20, 16, 40, 0.62);
--glass-strong: rgba(14, 10, 28, 0.85);
--glass-border: rgba(180, 160, 240, 0.18);
--glass-border-bright: rgba(180, 200, 255, 0.32);
--shadow-soft: 0 12px 40px rgba(0,0,0,0.45);
--r-sm:8px; --r-md:14px; --r-lg:22px; --r-xl:32px;
--f-display: 'Segoe UI', 'SF Pro Display', system-ui, -apple-system, sans-serif;
--f-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
html,body{width:100%;height:100%;overflow:hidden;background:#000;color:var(--ink-0);font-family:var(--f-display);user-select:none;-webkit-user-select:none}
#app{position:relative;width:100vw;height:100vh;overflow:hidden;background:radial-gradient(ellipse at center, var(--bg-2) 0%, var(--bg-0) 75%)}
#game-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;image-rendering:auto}
#ui-layer{position:absolute;inset:0;pointer-events:none;z-index:5}
#ui-layer > *{pointer-events:auto}
/* === Title screen === */
#title-screen{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:radial-gradient(ellipse at 50% 30%, #2a1f4f 0%, #0a0617 70%);z-index:50;overflow:hidden}
#title-screen::before{content:'';position:absolute;inset:0;background:
radial-gradient(2px 2px at 20% 30%, var(--lumen), transparent),
radial-gradient(1px 1px at 70% 60%, #fff, transparent),
radial-gradient(1.5px 1.5px at 40% 80%, var(--storm), transparent),
radial-gradient(1px 1px at 85% 20%, #fff, transparent),
radial-gradient(2px 2px at 55% 45%, var(--cinder), transparent),
radial-gradient(1px 1px at 15% 65%, #fff, transparent),
radial-gradient(1.5px 1.5px at 90% 75%, var(--tidal), transparent);opacity:.7;animation:stars-twinkle 6s ease-in-out infinite}
@keyframes stars-twinkle{0%,100%{opacity:.5}50%{opacity:.9}}
#title-content{position:relative;text-align:center;z-index:2;padding:20px}
.title-mark{font-size:clamp(38px,7vw,72px);font-weight:200;letter-spacing:.18em;color:transparent;background:linear-gradient(135deg, var(--lumen) 0%, var(--storm) 50%, var(--cinder) 100%);-webkit-background-clip:text;background-clip:text;margin-bottom:6px;text-shadow:0 0 40px rgba(127,255,229,.3)}
.title-sub{font-size:clamp(12px,1.6vw,16px);letter-spacing:.4em;color:var(--ink-1);margin-bottom:48px;font-weight:300}
.title-tagline{font-size:clamp(11px,1.4vw,14px);color:var(--ink-2);font-style:italic;margin-bottom:36px;max-width:520px;line-height:1.6}
.title-btn{display:block;margin:10px auto;padding:14px 38px;min-width:240px;background:linear-gradient(135deg, rgba(127,255,229,.12), rgba(196,166,255,.12));border:1px solid var(--glass-border-bright);color:var(--ink-0);font-family:var(--f-display);font-size:15px;letter-spacing:.18em;border-radius:var(--r-md);cursor:pointer;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);transition:all .25s ease;font-weight:500}
.title-btn:hover{background:linear-gradient(135deg, rgba(127,255,229,.25), rgba(196,166,255,.25));border-color:var(--lumen);transform:translateY(-2px);box-shadow:0 8px 24px rgba(127,255,229,.25)}
.title-btn:active{transform:translateY(0)}
.title-btn.primary{background:linear-gradient(135deg, var(--lumen), var(--storm));color:#0a0617;font-weight:600;border:none}
.title-btn.primary:hover{box-shadow:0 8px 32px rgba(127,255,229,.5)}
.title-credits{position:absolute;bottom:18px;left:0;right:0;text-align:center;font-size:10px;color:var(--ink-2);letter-spacing:.3em}
/* === HUD overlay === */
#hud{position:absolute;top:0;left:0;right:0;padding:14px 18px;display:flex;justify-content:space-between;align-items:center;pointer-events:none;z-index:10;background:linear-gradient(180deg, rgba(7,6,15,.55) 0%, transparent 100%)}
#hud > *{pointer-events:auto}
.hud-zone{font-size:12px;letter-spacing:.25em;color:var(--ink-1);text-transform:uppercase}
.hud-zone strong{color:var(--lumen);font-weight:500;display:block;font-size:15px;letter-spacing:.12em}
.hud-right{display:flex;gap:10px}
.hud-btn{width:42px;height:42px;border-radius:50%;background:var(--glass);border:1px solid var(--glass-border);color:var(--ink-0);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:18px;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);transition:all .2s}
.hud-btn:hover{background:rgba(40,30,70,.85);border-color:var(--lumen);transform:scale(1.06)}
/* === Touch controls === */
#touch-controls{position:absolute;bottom:20px;left:20px;width:160px;height:160px;display:none;z-index:12;touch-action:none}
.touch-pad{position:relative;width:100%;height:100%;border-radius:50%;background:rgba(20,16,40,.5);border:1px solid var(--glass-border);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}
.touch-btn{position:absolute;width:46px;height:46px;border-radius:50%;background:rgba(127,255,229,.15);border:1px solid var(--lumen-dim);color:var(--lumen);font-size:22px;display:flex;align-items:center;justify-content:center;cursor:pointer}
.touch-btn.up{top:6px;left:50%;transform:translateX(-50%)}
.touch-btn.down{bottom:6px;left:50%;transform:translateX(-50%)}
.touch-btn.left{left:6px;top:50%;transform:translateY(-50%)}
.touch-btn.right{right:6px;top:50%;transform:translateY(-50%)}
/* === Modal === */
.modal-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.55);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);z-index:40;display:flex;align-items:center;justify-content:center;padding:20px;animation:fadein .2s ease}
@keyframes fadein{from{opacity:0}to{opacity:1}}
.modal{background:var(--glass-strong);border:1px solid var(--glass-border-bright);border-radius:var(--r-xl);padding:24px;max-width:min(92vw, 720px);max-height:90vh;overflow-y:auto;box-shadow:var(--shadow-soft);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);position:relative;animation:rise .3s cubic-bezier(.2,.8,.3,1)}
@keyframes rise{from{opacity:0;transform:translateY(20px) scale(.96)}to{opacity:1;transform:translateY(0) scale(1)}}
.modal.wide{max-width:min(94vw, 920px)}
.modal h2{font-size:20px;letter-spacing:.15em;color:var(--lumen);font-weight:500;margin-bottom:18px;text-transform:uppercase}
.modal h3{font-size:14px;letter-spacing:.12em;color:var(--ink-1);margin:14px 0 8px;text-transform:uppercase}
.modal-close{position:absolute;top:14px;right:14px;width:34px;height:34px;border-radius:50%;background:transparent;border:1px solid var(--glass-border);color:var(--ink-1);cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center;transition:all .2s}
.modal-close:hover{background:rgba(255,90,122,.2);border-color:var(--crimson);color:var(--crimson)}
/* === Dialogue / cut-in === */
#dialogue-box{position:absolute;bottom:30px;left:50%;transform:translateX(-50%);width:min(92vw, 720px);background:var(--glass-strong);border:1px solid var(--glass-border-bright);border-radius:var(--r-lg);padding:20px 24px;z-index:30;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);display:none;animation:rise .3s ease}
#dialogue-box.show{display:block}
.dialogue-speaker{font-size:11px;letter-spacing:.3em;color:var(--lumen);text-transform:uppercase;margin-bottom:6px}
.dialogue-text{font-size:15px;line-height:1.6;color:var(--ink-0);min-height:48px}
.dialogue-text::after{content:'▌';color:var(--lumen);animation:blink 1s infinite;margin-left:2px}
@keyframes blink{0%,49%{opacity:1}50%,100%{opacity:0}}
.dialogue-hint{font-size:10px;color:var(--ink-2);letter-spacing:.2em;text-align:right;margin-top:10px}
/* === Battle screen === */
#battle-screen{position:absolute;inset:0;display:none;z-index:25;background:linear-gradient(180deg, #1a0f2e 0%, #2a1a4a 50%, #1f1438 100%);overflow:hidden}
#battle-screen.show{display:flex;flex-direction:column}
#battle-canvas{position:absolute;inset:0;width:100%;height:100%}
.battle-bottom{position:absolute;bottom:0;left:0;right:0;background:linear-gradient(180deg, transparent, rgba(7,6,15,.85) 30%);padding:20px;z-index:3}
#battle-info{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:14px;gap:14px;flex-wrap:wrap}
.battle-enemy-info{flex:1;min-width:0}
.battle-party-info{flex:1;min-width:0;display:flex;flex-direction:column;align-items:flex-end;gap:6px}
.battle-name{font-size:13px;letter-spacing:.15em;color:var(--ink-1);text-transform:uppercase;margin-bottom:4px}
.battle-name strong{color:var(--ink-0);font-weight:600;letter-spacing:.1em}
.battle-level{font-size:11px;color:var(--lumen);margin-left:8px;letter-spacing:.1em}
.hp-bar-wrap{width:100%;max-width:280px;height:14px;background:rgba(0,0,0,.6);border:1px solid var(--glass-border);border-radius:7px;overflow:hidden;position:relative}
.hp-bar{height:100%;background:linear-gradient(90deg, #4af2a4, #2ad080);transition:width .55s cubic-bezier(.2,.8,.3,1);border-radius:7px;position:relative;overflow:hidden}
.hp-bar.low{background:linear-gradient(90deg, #ffb45a, #ff7e3a)}
.hp-bar.critical{background:linear-gradient(90deg, #ff5a7a, #ff3050);animation:hpflash 1s infinite}
@keyframes hpflash{0%,100%{opacity:1}50%{opacity:.6}}
.hp-bar::after{content:'';position:absolute;top:0;left:0;right:0;height:50%;background:linear-gradient(180deg, rgba(255,255,255,.25), transparent)}
.hp-text{font-size:11px;color:var(--ink-1);margin-top:3px;letter-spacing:.05em;font-family:var(--f-mono)}
.battle-menu{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;max-width:520px;margin:0 auto}
.battle-btn{padding:12px 8px;background:var(--glass);border:1px solid var(--glass-border);border-radius:var(--r-md);color:var(--ink-0);font-family:var(--f-display);font-size:13px;letter-spacing:.1em;cursor:pointer;transition:all .2s;text-transform:uppercase;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);position:relative;overflow:hidden}
.battle-btn:hover:not(:disabled){background:rgba(127,255,229,.18);border-color:var(--lumen);transform:translateY(-2px)}
.battle-btn:disabled{opacity:.4;cursor:not-allowed}
.battle-btn .pp{font-size:10px;color:var(--lumen);display:block;margin-top:3px;letter-spacing:.05em}
#battle-message{position:absolute;top:30%;left:50%;transform:translate(-50%,-50%);text-align:center;font-size:18px;color:var(--ink-0);text-shadow:0 2px 12px rgba(0,0,0,.8);z-index:4;pointer-events:none;letter-spacing:.1em;line-height:1.5;max-width:80vw;animation:msgpop .4s ease}
@keyframes msgpop{from{opacity:0;transform:translate(-50%,-30%) scale(.85)}to{opacity:1;transform:translate(-50%,-50%) scale(1)}}
.dmg-num{position:absolute;font-weight:700;font-size:24px;color:var(--crimson);text-shadow:0 2px 8px rgba(0,0,0,.9);pointer-events:none;z-index:5;letter-spacing:.05em;animation:dmgfly 1.2s ease-out forwards}
.dmg-num.crit{color:var(--gold);font-size:32px}
.dmg-num.heal{color:var(--good)}
.dmg-num.miss{color:var(--ink-2);font-size:18px}
.dmg-num.se{color:var(--gold);font-size:30px}
@keyframes dmgfly{0%{opacity:0;transform:translateY(20px) scale(.5)}20%{opacity:1;transform:translateY(0) scale(1.2)}100%{opacity:0;transform:translateY(-60px) scale(.9)}}
/* === Capture minigame === */
#capture-modal{position:absolute;inset:0;background:rgba(0,0,0,.7);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:35;display:none;align-items:center;justify-content:center;flex-direction:column}
#capture-modal.show{display:flex}
#capture-rings{position:relative;width:240px;height:240px}
.capture-ring{position:absolute;inset:0;border-radius:50%;border:4px solid;display:flex;align-items:center;justify-content:center;font-size:14px;color:var(--ink-0);font-weight:600;letter-spacing:.1em;cursor:pointer;transition:transform .05s}
.capture-ring.outer{border-color:var(--lumen);transform:scale(var(--ring-scale, 1))}
.capture-ring.inner{border-color:var(--gold);transform:scale(calc(var(--ring-scale, 1) * 0.6))}
.capture-ring.hit{background:rgba(127,255,229,.4);transform:scale(.95)}
#capture-hint{margin-top:20px;color:var(--ink-1);font-size:13px;letter-spacing:.15em;text-align:center;line-height:1.6}
#capture-timer{width:200px;height:6px;background:rgba(0,0,0,.5);border-radius:3px;margin-top:12px;overflow:hidden}
#capture-timer-fill{height:100%;background:linear-gradient(90deg, var(--lumen), var(--gold));transition:width .1s linear;width:100%}
/* === Menu screens === */
.menu-tabs{display:flex;gap:6px;margin-bottom:18px;flex-wrap:wrap;border-bottom:1px solid var(--glass-border);padding-bottom:12px}
.menu-tab{padding:8px 16px;background:transparent;border:1px solid transparent;border-radius:var(--r-sm);color:var(--ink-1);cursor:pointer;font-size:12px;letter-spacing:.15em;text-transform:uppercase;transition:all .2s}
.menu-tab:hover{color:var(--ink-0)}
.menu-tab.active{background:rgba(127,255,229,.15);border-color:var(--lumen-dim);color:var(--lumen)}
/* Party grid */
.party-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(160px, 1fr));gap:12px}
.party-card{background:var(--glass);border:1px solid var(--glass-border);border-radius:var(--r-md);padding:14px;transition:all .2s;cursor:pointer;position:relative;overflow:hidden}
.party-card:hover{border-color:var(--lumen);transform:translateY(-2px)}
.party-card.active{border-color:var(--lumen);box-shadow:0 0 24px rgba(127,255,229,.2)}
.party-card.fainted{opacity:.5;filter:grayscale(.6)}
.party-card .pc-name{font-size:14px;color:var(--ink-0);font-weight:600;letter-spacing:.05em}
.party-card .pc-lvl{font-size:11px;color:var(--lumen);margin-left:6px}
.party-card .pc-type{font-size:10px;color:var(--ink-1);letter-spacing:.1em;margin-top:2px;text-transform:uppercase}
.party-card .pc-mini{width:64px;height:64px;margin:8px auto;display:block}
.party-card .pc-hp{height:6px;background:rgba(0,0,0,.5);border-radius:3px;overflow:hidden;margin-top:8px}
.party-card .pc-hp-fill{height:100%;background:linear-gradient(90deg, #4af2a4, #2ad080);transition:width .3s}
/* Bestiary */
.bestiary-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(110px, 1fr));gap:10px}
.bestiary-card{background:var(--glass);border:1px solid var(--glass-border);border-radius:var(--r-md);padding:10px;text-align:center;transition:all .2s;cursor:default}
.bestiary-card.seen{border-color:var(--glass-border-bright)}
.bestiary-card.caught{border-color:var(--lumen);box-shadow:0 0 12px rgba(127,255,229,.15)}
.bestiary-card.unknown{opacity:.4;filter:grayscale(1)}
.bestiary-card .bc-mini{width:60px;height:60px;margin:0 auto 6px;display:block}
.bestiary-card .bc-name{font-size:11px;color:var(--ink-0);font-weight:500;letter-spacing:.05em}
.bestiary-card.unknown .bc-name{color:var(--ink-2)}
/* Items list */
.items-list{display:flex;flex-direction:column;gap:6px;max-height:60vh;overflow-y:auto;padding-right:6px}
.item-row{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:var(--glass);border:1px solid var(--glass-border);border-radius:var(--r-md);transition:all .2s;cursor:default}
.item-row:hover{border-color:var(--lumen-dim)}
.item-name{font-size:13px;color:var(--ink-0)}
.item-desc{font-size:10px;color:var(--ink-2);margin-top:2px;letter-spacing:.05em}
.item-qty{font-family:var(--f-mono);color:var(--lumen);font-size:13px}
/* Map */
.zone-tabs{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}
.zone-tab{padding:8px 12px;background:var(--glass);border:1px solid var(--glass-border);border-radius:var(--r-sm);color:var(--ink-1);cursor:pointer;font-size:11px;letter-spacing:.1em;transition:all .2s}
.zone-tab.active{background:rgba(127,255,229,.18);border-color:var(--lumen);color:var(--lumen)}
.zone-tab.locked{opacity:.4;cursor:not-allowed}
.map-canvas-wrap{width:100%;aspect-ratio:16/10;background:rgba(0,0,0,.3);border:1px solid var(--glass-border);border-radius:var(--r-md);position:relative;overflow:hidden}
#map-canvas{width:100%;height:100%;display:block}
/* Starter select */
.starter-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));gap:14px;margin-top:18px}
.starter-card{background:var(--glass);border:1px solid var(--glass-border);border-radius:var(--r-lg);padding:18px;text-align:center;cursor:pointer;transition:all .25s}
.starter-card:hover{border-color:var(--lumen);transform:translateY(-4px);box-shadow:0 12px 32px rgba(127,255,229,.2)}
.starter-card .sc-art{width:120px;height:120px;margin:0 auto 10px;display:block}
.starter-card .sc-name{font-size:16px;color:var(--ink-0);font-weight:600;letter-spacing:.05em}
.starter-card .sc-type{font-size:11px;color:var(--lumen);letter-spacing:.15em;margin:4px 0;text-transform:uppercase}
.starter-card .sc-lore{font-size:12px;color:var(--ink-1);line-height:1.5;margin-top:8px;font-style:italic}
/* Floating damage on overworld */
.ow-pop{position:absolute;font-size:11px;color:var(--ink-1);letter-spacing:.1em;background:rgba(0,0,0,.6);padding:3px 8px;border-radius:10px;pointer-events:none;z-index:8;animation:owpop 1.4s ease-out forwards;white-space:nowrap}
@keyframes owpop{0%{opacity:0;transform:translateY(0) scale(.8)}20%{opacity:1;transform:translateY(-10px) scale(1)}100%{opacity:0;transform:translateY(-50px) scale(.9)}}
/* Scrollbars */
::-webkit-scrollbar{width:8px;height:8px}
::-webkit-scrollbar-track{background:rgba(0,0,0,.2);border-radius:4px}
::-webkit-scrollbar-thumb{background:var(--ink-2);border-radius:4px}
::-webkit-scrollbar-thumb:hover{background:var(--lumen-dim)}
/* Tooltip */
.tooltip{position:relative}
.tooltip::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--bg-1);color:var(--ink-0);padding:5px 10px;border-radius:6px;font-size:11px;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .2s;border:1px solid var(--glass-border);z-index:100;letter-spacing:.05em}
.tooltip:hover::after{opacity:1}
/* Status badges */
.status-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;letter-spacing:.1em;margin-right:4px;background:rgba(127,255,229,.15);color:var(--lumen);border:1px solid var(--lumen-dim);text-transform:uppercase}
.status-badge.burn{background:rgba(255,90,90,.15);color:#ff8a8a;border-color:#ff6868}
.status-badge.frost{background:rgba(120,200,255,.15);color:#9cdcff;border-color:#6cc4ff}
.status-badge.shock{background:rgba(255,234,120,.15);color:#ffea78;border-color:#ffd86e}
.status-badge.regen{background:rgba(124,242,168,.15);color:#7cf2a8;border-color:#5fdb90}
.status-badge.barrier{background:rgba(127,255,229,.15);color:var(--lumen);border-color:var(--lumen)}
/* Toast */
#toast{position:absolute;top:80px;left:50%;transform:translateX(-50%);background:var(--glass-strong);border:1px solid var(--glass-border-bright);padding:10px 18px;border-radius:var(--r-md);font-size:13px;letter-spacing:.1em;color:var(--lumen);z-index:60;opacity:0;transition:opacity .3s, transform .3s;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);box-shadow:var(--shadow-soft);pointer-events:none}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
#toast.hide{transform:translateX(-50%) translateY(-20px)}
/* Choice modal */
.choice-list{display:flex;flex-direction:column;gap:8px}
.choice-btn{padding:14px 18px;background:var(--glass);border:1px solid var(--glass-border);border-radius:var(--r-md);color:var(--ink-0);text-align:left;cursor:pointer;transition:all .2s}
.choice-btn:hover{background:rgba(127,255,229,.15);border-color:var(--lumen);transform:translateX(4px)}
.choice-btn .cb-cost{color:var(--lumen);font-size:11px;letter-spacing:.1em;margin-top:4px;display:block}
/* Story vignette */
.vignette{position:absolute;inset:0;z-index:45;display:none;align-items:center;justify-content:center;padding:30px;pointer-events:none}
.vignette.show{display:flex;animation:vig-in .8s ease}
@keyframes vig-in{from{opacity:0}to{opacity:1}}
.vignette-text{max-width:600px;text-align:center;font-size:18px;line-height:1.8;color:var(--ink-0);text-shadow:0 2px 20px rgba(0,0,0,.9);letter-spacing:.05em;padding:30px;background:rgba(0,0,0,.4);border-radius:var(--r-lg);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);border:1px solid var(--glass-border)}
.vignette-text .vt-stage{display:block;font-size:11px;letter-spacing:.4em;color:var(--lumen);margin-bottom:14px;text-transform:uppercase}
@media (max-width: 720px){
.battle-menu{grid-template-columns:repeat(2,1fr);max-width:none}
#touch-controls{display:block}
.modal{max-height:85vh;padding:18px}
.hud-zone{font-size:10px}
.hud-zone strong{font-size:12px}
.title-mark{font-size:36px}
.vignette-text{font-size:14px}
}
@media (max-height: 600px){
#touch-controls{display:none}
}
</style>
</head>
<body>
<div id="app">
<canvas id="game-canvas"></canvas>
<div id="ui-layer"></div>
</div>
<script>
/* ============================================================
AETHERMERE: WHISPERS OF THE HOLLOW
An original creature-collecting tale
============================================================ */
'use strict';
// ---------- Utilities ----------
const rand = (min, max) => Math.random() * (max - min) + min;
const randInt = (min, max) => Math.floor(rand(min, max + 1));
const choice = arr => arr[randInt(0, arr.length - 1)];
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
const fmt = n => Math.floor(n).toString().padStart(4, ' ');
// ---------- Type System ----------
const TYPES = ['Cinder', 'Flora', 'Tidal', 'Storm', 'Crag', 'Lumen', 'Shade'];
// Type chart per spec:
// Type A > B > C > A (A=Cinder, B=Flora, C=Tidal)
// D > A & C (D=Storm)
// E > D & B (E=Crag)
// F beats all slightly (F=Lumen)
// G resists all, weak to F (G=Shade)
const TYPE_CHART = {
Cinder: { Flora: 2, Tidal: 0.5, Storm: 0.5, Crag: 1, Lumen: 1, Shade: 1 },
Flora: { Tidal: 2, Cinder: 0.5, Crag: 0.5, Storm: 1, Lumen: 1, Shade: 1 },
Tidal: { Cinder: 2, Flora: 0.5, Storm: 0.5, Crag: 1, Lumen: 1, Shade: 1 },
Storm: { Cinder: 2, Tidal: 2, Crag: 0.5, Flora: 1, Lumen: 1, Shade: 1 },
Crag: { Storm: 2, Flora: 2, Cinder: 1, Tidal: 1, Lumen: 1, Shade: 1 },
Lumen: { Cinder: 1.4, Flora: 1.4, Tidal: 1.4, Storm: 1.4, Crag: 1.4, Shade: 2 },
Shade: { Cinder: 0.5, Flora: 0.5, Tidal: 0.5, Storm: 0.5, Crag: 0.5, Lumen: 0.5 }
};
const TYPE_COLORS = {
Cinder: '#ff8a5b', Flora: '#7cd66a', Tidal: '#5ec3ff',
Storm: '#cba6ff', Crag: '#e0c891', Lumen: '#7fffe5', Shade: '#a48cff'
};
// ---------- Abilities (40+) ----------
const ABILITIES = {
emberpaw: { name: 'Emberpaw', type: 'Cinder', power: 40, acc: 100, pp: 20, cost: 5, fx: null, desc: 'A flickering claw of cinder.' },
flamewake: { name: 'Flamewake', type: 'Cinder', power: 70, acc: 95, pp: 12, cost: 12, fx: 'burn', chance: 0.25, desc: 'Scorches the foe. May burn.' },
pyrepulse: { name: 'Pyre Pulse', type: 'Cinder', power: 60, acc: 100, pp: 14, cost: 10, fx: 'crit', desc: 'Searing flare with high crit rate.' },
ashveil: { name: 'Ash Veil', type: 'Cinder', power: 0, acc: 100, pp: 18, cost: 8, fx: 'defup', desc: 'Shrouds in ash. Raises DEF.' },
searstorm: { name: 'Searstorm', type: 'Cinder', power: 80, acc: 85, pp: 8, cost: 16, fx: 'burn', chance: 0.4, desc: 'Storm of embers. May burn.' },
cinderfang: { name: 'Cinderfang', type: 'Cinder', power: 50, acc: 100, pp: 16, cost: 7, fx: null, desc: 'Fanged strike wreathed in cinder.' },
vinewhip: { name: 'Vinewhip', type: 'Flora', power: 40, acc: 100, pp: 20, cost: 5, fx: null, desc: 'A lashing vine tendril.' },
bloomburst: { name: 'Bloom Burst', type: 'Flora', power: 65, acc: 100, pp: 12, cost: 11, fx: 'regen', desc: 'Floral surge. Heals user slightly.' },
thornvolley: { name: 'Thorn Volley',type: 'Flora', power: 25, acc: 95, pp: 14, cost: 9, fx: 'multihit', hits: 3, desc: 'Three thorn strikes.' },
petalstorm: { name: 'Petalstorm', type: 'Flora', power: 75, acc: 90, pp: 10, cost: 14, fx: null, desc: 'A whirling storm of razor petals.' },
rootmend: { name: 'Root Mend', type: 'Flora', power: 0, acc: 100, pp: 12, cost: 10, fx: 'heal', amt: 0.5, desc: 'Restores half max HP.' },
barkplate: { name: 'Bark Plate', type: 'Flora', power: 0, acc: 100, pp: 18, cost: 8, fx: 'defup', desc: 'Hardens bark. Raises DEF.' },
tidesplash: { name: 'Tidesplash', type: 'Tidal', power: 40, acc: 100, pp: 20, cost: 5, fx: null, desc: 'A crashing wave.' },
hydrolance: { name: 'Hydro Lance', type: 'Tidal', power: 70, acc: 95, pp: 12, cost: 12, fx: null, desc: 'Piercing jet of water.' },
brinesnare: { name: 'Brine Snare', type: 'Tidal', power: 50, acc: 100, pp: 16, cost: 8, fx: 'frost', chance: 0.3, desc: 'Icy brine. May frost.' },
riptide: { name: 'Riptide', type: 'Tidal', power: 55, acc: 100, pp: 14, cost: 10, fx: 'multihit', hits: 2, desc: 'Two surging waves.' },
aquaveil: { name: 'Aqua Veil', type: 'Tidal', power: 0, acc: 100, pp: 14, cost: 9, fx: 'heal', amt: 0.4, desc: 'A soothing mist. Heals HP.' },
deepdrain: { name: 'Deepdrain', type: 'Tidal', power: 45, acc: 100, pp: 12, cost: 9, fx: 'leech', desc: 'Drains foe life.' },
sparkshot: { name: 'Sparkshot', type: 'Storm', power: 40, acc: 100, pp: 20, cost: 5, fx: null, desc: 'A quick jolt.' },
thunder: { name: 'Thunderclap', type: 'Storm', power: 75, acc: 90, pp: 10, cost: 14, fx: 'shock', chance: 0.3, desc: 'Crackling bolt. May shock.' },
voltrush: { name: 'Volt Rush', type: 'Storm', power: 35, acc: 100, pp: 14, cost: 8, fx: 'priority', desc: 'Strikes first with electric speed.' },
tempest: { name: 'Tempest', type: 'Storm', power: 80, acc: 85, pp: 8, cost: 16, fx: 'shock', chance: 0.2, desc: 'Raging storm. May shock.' },
galvanic: { name: 'Galvanic', type: 'Storm', power: 0, acc: 100, pp: 18, cost: 8, fx: 'speedup', desc: 'Raises SPD.' },
staticshell: { name: 'Static Shell',type: 'Storm', power: 0, acc: 100, pp: 16, cost: 7, fx: 'barrier', desc: 'Erects a barrier. One free hit.' },
pebblepunch: { name: 'Pebblepunch', type: 'Crag', power: 40, acc: 100, pp: 20, cost: 5, fx: null, desc: 'A small but firm strike.' },
stonefall: { name: 'Stonefall', type: 'Crag', power: 70, acc: 95, pp: 12, cost: 12, fx: null, desc: 'Crushing rockfall.' },
crystallance:{ name: 'Crystal Lance',type:'Crag', power: 85, acc: 90, pp: 8, cost: 16, fx: null, desc: 'A piercing crystal spear.' },
boulderbash: { name: 'Boulder Bash',type: 'Crag', power: 95, acc: 80, pp: 6, cost: 20, fx: 'selfslow', desc: 'Massive slam. User loses SPD.' },
graniteskin: { name: 'Granite Skin',type: 'Crag', power: 0, acc: 100, pp: 18, cost: 8, fx: 'defup', desc: 'Stoneskin. Raises DEF.' },
quakestomp: { name: 'Quake Stomp', type: 'Crag', power: 75, acc: 100, pp: 10, cost: 14, fx: null, desc: 'Shatters the ground beneath all.' },
glimmer: { name: 'Glimmer', type: 'Lumen', power: 50, acc: 100, pp: 16, cost: 7, fx: null, desc: 'A radiant beam of light.' },
photonlance: { name: 'Photon Lance',type: 'Lumen', power: 80, acc: 95, pp: 10, cost: 14, fx: null, desc: 'A piercing lance of pure light.' },
spectrum: { name: 'Spectrum', type: 'Lumen', power: 75, acc: 100, pp: 12, cost: 12, fx: 'crit', desc: 'Refracted beam with high crit.' },
refract: { name: 'Refract', type: 'Lumen', power: 0, acc: 100, pp: 16, cost: 8, fx: 'barrier', desc: 'Bends light. One free hit.' },
brightheal: { name: 'Bright Mend', type: 'Lumen', power: 0, acc: 100, pp: 12, cost: 10, fx: 'heal', amt: 0.5, desc: 'Warm light. Heals HP.' },
auroraveil: { name: 'Aurora Veil', type: 'Lumen', power: 0, acc: 100, pp: 10, cost: 12, fx: 'allup', desc: 'Raises ATK, DEF, SPD.' },
umbralash: { name: 'Umbralash', type: 'Shade', power: 50, acc: 100, pp: 16, cost: 7, fx: null, desc: 'A claw wreathed in shadow.' },
souldrain: { name: 'Soul Drain', type: 'Shade', power: 45, acc: 100, pp: 12, cost: 9, fx: 'leech', desc: 'Steals foe life essence.' },
voidpulse: { name: 'Void Pulse', type: 'Shade', power: 70, acc: 95, pp: 12, cost: 12, fx: null, desc: 'A pulse of nothingness.' },
shadowmist: { name: 'Shadowmist', type: 'Shade', power: 0, acc: 100, pp: 16, cost: 8, fx: 'barrier', desc: 'Vanishes. One free hit.' },
eclipse: { name: 'Eclipse', type: 'Shade', power: 90, acc: 85, pp: 6, cost: 18, fx: 'defup', desc: 'Dark eclipse. High power.' },
hexbind: { name: 'Hexbind', type: 'Shade', power: 55, acc: 95, pp: 14, cost: 10, fx: 'curse', desc: 'A draining hex. Damages over time.' },
tackle: { name: 'Tackle', type: null, power: 35, acc: 100, pp: 25, cost: 4, fx: null, desc: 'A basic charge.' },
bite: { name: 'Bite', type: null, power: 50, acc: 100, pp: 18, cost: 6, fx: null, desc: 'A snapping bite.' },
quickstrike: { name: 'Quickstrike', type: null, power: 30, acc: 100, pp: 16, cost: 6, fx: 'priority', desc: 'Strikes first.' },
focus: { name: 'Focus', type: null, power: 0, acc: 100, pp: 20, cost: 6, fx: 'critup', desc: 'Raises crit rate next turn.' }
};
const ABILITY_IDS = Object.keys(ABILITIES);
console.log('Abilities loaded:', ABILITY_IDS.length);
// ---------- Creature Catalog (18 base + 9 transformed = 27) ----------
// Each creature: id, name, types[], base stats, learnset, lore, art (drawing fn), evolves
const CREATURES = {
// Starters (zone 1)
cinderkin: {
id: 'cinderkin', name: 'Cinderkin', types: ['Cinder'],
base: { hp: 38, atk: 47, def: 38, spd: 42 },
learnset: { 1: ['tackle', 'emberpaw'], 6: ['cinderfang'], 12: ['flamewake'], 20: ['pyrepulse'], 30: ['searstorm'] },
lore: 'A small ember sprite that flickers when it dreams.',
evolves: { to: 'volcoynx', at: 22, item: 'embercatalyst' },
shape: 'cinder'
},
lumipup: {
id: 'lumipup', name: 'Lumipup', types: ['Lumen'],
base: { hp: 40, atk: 38, def: 42, spd: 50 },
learnset: { 1: ['tackle', 'glimmer'], 6: ['auroraveil'], 12: ['photonlance'], 20: ['spectrum'], 30: ['brightheal'] },
lore: 'A pale fox that leaves a trail of fading starlight.',
evolves: { to: 'astralswan', at: 22, item: 'lumencatalyst' },
shape: 'lumen'
},
aqualing: {
id: 'aqualing', name: 'Aqualing', types: ['Tidal'],
base: { hp: 42, atk: 42, def: 44, spd: 40 },
learnset: { 1: ['tackle', 'tidesplash'], 6: ['aquaveil'], 12: ['hydrolance'], 20: ['riptide'], 30: ['brinesnare'] },
lore: 'A glassy tadpole that hums in harmony with the tide.',
evolves: { to: 'tidewarden', at: 22, item: 'tidalcatalyst' },
shape: 'tidal'
},
// Zone 1 Embergrove
thornkin: {
id: 'thornkin', name: 'Thornkin', types: ['Flora'],
base: { hp: 44, atk: 48, def: 46, spd: 36 },
learnset: { 1: ['tackle', 'vinewhip'], 8: ['thornvolley'], 16: ['barkplate'], 24: ['petalstorm'] },
lore: 'A walking thicket with a laugh like snapping twigs.',
evolves: { to: 'forestlord', at: 26, item: 'floracatalyst' },
shape: 'flora'
},
pebblump: {
id: 'pebblump', name: 'Pebblump', types: ['Crag'],
base: { hp: 50, atk: 44, def: 56, spd: 28 },
learnset: { 1: ['tackle', 'pebblepunch'], 8: ['graniteskin'], 16: ['stonefall'], 24: ['boulderbash'] },
lore: 'A rolling stone that only stops when it hears music.',
evolves: { to: 'stonearch', at: 28, item: 'cragcatalyst' },
shape: 'crag'
},
sparkimp: {
id: 'sparkimp', name: 'Sparkimp', types: ['Storm'],
base: { hp: 36, atk: 42, def: 34, spd: 52 },
learnset: { 1: ['sparkshot', 'quickstrike'], 8: ['voltrush'], 16: ['galvanic'], 24: ['thunder'] },
lore: 'A jittery sprite that hums in 5/4 time.',
evolves: null, shape: 'storm'
},
// Zone 2 Tidalcoast
coralyn: {
id: 'coralyn', name: 'Coralyn', types: ['Tidal', 'Flora'],
base: { hp: 46, atk: 44, def: 50, spd: 36 },
learnset: { 1: ['tidesplash', 'vinewhip'], 9: ['bloomburst'], 18: ['riptide'], 27: ['petalstorm'] },
lore: 'A coral that sings when the moon is waning.',
evolves: { to: 'reeflord', at: 28, item: 'tidalcatalyst' },
shape: 'coral'
},
brinewraith: {
id: 'brinewraith', name: 'Brinewraith', types: ['Tidal', 'Shade'],
base: { hp: 42, atk: 50, def: 38, spd: 46 },
learnset: { 1: ['tidesplash', 'umbralash'], 9: ['souldrain'], 18: ['hydrolance'], 27: ['voidpulse'] },
lore: 'A drowned thing that has forgotten its name.',
evolves: null, shape: 'wraith'
},
// Zone 3 Voltfields
voltwing: {
id: 'voltwing', name: 'Voltwing', types: ['Storm', 'Lumen'],
base: { hp: 42, atk: 48, def: 40, spd: 54 },
learnset: { 1: ['sparkshot', 'glimmer'], 9: ['voltrush'], 18: ['photonlance'], 27: ['tempest'] },
lore: 'A stormbird whose feathers hum with static.',
evolves: null, shape: 'wing'
},
forgebeetle: {
id: 'forgebeetle', name: 'Forgebeetle', types: ['Cinder', 'Crag'],
base: { hp: 52, atk: 52, def: 54, spd: 26 },
learnset: { 1: ['emberpaw', 'pebblepunch'], 9: ['stonefall'], 18: ['flamewake'], 27: ['crystallance'] },
lore: 'A beetle of magma and iron that beats its wings slowly.',
evolves: null, shape: 'beetle'
},
// Zone 4 Lumenglass
crystoid: {
id: 'crystoid', name: 'Crystoid', types: ['Lumen', 'Crag'],
base: { hp: 44, atk: 46, def: 56, spd: 38 },
learnset: { 1: ['glimmer', 'pebblepunch'], 9: ['refract'], 18: ['photonlance'], 27: ['crystallance'] },
lore: 'A living geode that chimes when struck.',
evolves: { to: 'prismcrystal', at: 30, item: 'lumencatalyst' },
shape: 'crystal'
},
specter: {
id: 'specter', name: 'Specter', types: ['Shade', 'Lumen'],
base: { hp: 40, atk: 48, def: 38, spd: 50 },
learnset: { 1: ['umbralash', 'glimmer'], 9: ['shadowmist'], 18: ['voidpulse'], 27: ['photonlance'] },
lore: 'A wisp that cannot decide if it is a star or a memory.',
evolves: null, shape: 'specter'
},
// Zone 5 Prismpeaks
prismoth: {
id: 'prismoth', name: 'Prismoth', types: ['Lumen'],
base: { hp: 38, atk: 50, def: 36, spd: 56 },
learnset: { 1: ['glimmer', 'quickstrike'], 9: ['spectrum'], 18: ['auroraveil'], 27: ['photonlance'] },
lore: 'A moth whose wings hold every color of dawn.',
evolves: null, shape: 'moth'
},
// Zone 6 Shadehollow
umbraclaw: {
id: 'umbraclaw', name: 'Umbraclaw', types: ['Shade'],
base: { hp: 44, atk: 56, def: 38, spd: 50 },
learnset: { 1: ['umbralash', 'bite'], 9: ['shadowmist'], 18: ['eclipse'], 27: ['voidpulse'] },
lore: 'A clawed terror that walks only where no lantern burns.',
evolves: { to: 'shadequeen', at: 30, item: 'shadecatalyst' },
shape: 'umbra'
},
hollowkin: {
id: 'hollowkin', name: 'Hollowkin', types: ['Shade', 'Crag'],
base: { hp: 50, atk: 50, def: 52, spd: 32 },
learnset: { 1: ['umbralash', 'pebblepunch'], 9: ['hexbind'], 18: ['stonefall'], 27: ['eclipse'] },
lore: 'A sentinel carved by no hand, left in a forgotten hall.',
evolves: null, shape: 'golem'
},
// Extra
mossback: {
id: 'mossback', name: 'Mossback', types: ['Flora', 'Crag'],
base: { hp: 54, atk: 42, def: 56, spd: 28 },
learnset: { 1: ['vinewhip', 'pebblepunch'], 9: ['barkplate'], 18: ['bloomburst'], 27: ['quakestomp'] },
lore: 'A patient grazer with a forest on its shoulders.',
evolves: null, shape: 'mossback'
},
drizztip: {
id: 'drizztip', name: 'Drizztip', types: ['Tidal', 'Storm'],
base: { hp: 40, atk: 44, def: 38, spd: 48 },
learnset: { 1: ['tidesplash', 'sparkshot'], 9: ['riptide'], 18: ['voltrush'], 27: ['thunder'] },
lore: 'A seahorse with lightning running through its mane.',
evolves: null, shape: 'seahorse'
},
ashlung: {
id: 'ashlung', name: 'Ashlung', types: ['Cinder', 'Shade'],
base: { hp: 46, atk: 54, def: 40, spd: 42 },
learnset: { 1: ['emberpaw', 'umbralash'], 9: ['souldrain'], 18: ['flamewake'], 27: ['eclipse'] },
lore: 'A wolf that hunts by the embers of extinguished hearths.',
evolves: null, shape: 'wolf'
},
// Transformations (9)
volcoynx: {
id: 'volcoynx', name: 'Volcoynx', types: ['Cinder', 'Storm'],
base: { hp: 60, atk: 72, def: 56, spd: 76 },
learnset: { 22: ['searstorm', 'voltrush'], 30: ['tempest'], 40: ['eclipse'] },
lore: 'A cinder-eyed lynx that sparks when it purrs.',
isTransformation: true, evolves: null, shape: 'cinder'
},
astralswan: {
id: 'astralswan', name: 'Astralswan', types: ['Lumen', 'Shade'],
base: { hp: 64, atk: 60, def: 64, spd: 80 },
learnset: { 22: ['photonlance', 'shadowmist'], 30: ['spectrum'], 40: ['eclipse'] },
lore: 'A swan of light that swims through the night sky.',
isTransformation: true, evolves: null, shape: 'lumen'
},
tidewarden: {
id: 'tidewarden', name: 'Tidewarden', types: ['Tidal', 'Crag'],
base: { hp: 70, atk: 66, def: 72, spd: 60 },
learnset: { 22: ['hydrolance', 'quakestomp'], 30: ['boulderbash'], 40: ['crystallance'] },
lore: 'A reef-turtle whose shell holds a thousand tides.',
isTransformation: true, evolves: null, shape: 'tidal'
},
forestlord: {
id: 'forestlord', name: 'Forestlord', types: ['Flora', 'Crag'],
base: { hp: 78, atk: 70, def: 76, spd: 50 },
learnset: { 26: ['petalstorm', 'quakestomp'], 34: ['crystallance'], 42: ['boulderbash'] },
lore: 'A walking wood with mountains on its brow.',
isTransformation: true, evolves: null, shape: 'flora'
},
stonearch: {
id: 'stonearch', name: 'Stonearch', types: ['Crag', 'Lumen'],
base: { hp: 84, atk: 70, def: 88, spd: 42 },
learnset: { 28: ['crystallance', 'photonlance'], 36: ['boulderbash'], 44: ['spectrum'] },
lore: 'A glowing gate carved by patient centuries.',
isTransformation: true, evolves: null, shape: 'crag'
},
reeflord: {
id: 'reeflord', name: 'Reeflord', types: ['Tidal', 'Flora'],
base: { hp: 72, atk: 66, def: 78, spd: 58 },
learnset: { 28: ['riptide', 'bloomburst'], 36: ['petalstorm'], 44: ['hydrolance'] },
lore: 'A coral mind the size of a city block.',
isTransformation: true, evolves: null, shape: 'coral'
},
thunderhead: {
id: 'thunderhead', name: 'Thunderhead', types: ['Storm', 'Lumen'],
base: { hp: 70, atk: 78, def: 64, spd: 88 },
learnset: { 30: ['tempest', 'photonlance'], 38: ['eclipse'], 46: ['spectrum'] },
lore: 'A small god that walks only when the sky is bruised.',
isTransformation: true, evolves: null, shape: 'storm'
},
prismcrystal: {
id: 'prismcrystal', name: 'Prismcrystal', types: ['Lumen', 'Shade'],
base: { hp: 68, atk: 72, def: 82, spd: 70 },
learnset: { 30: ['spectrum', 'voidpulse'], 38: ['auroraveil'], 46: ['eclipse'] },
lore: 'A geometry of light and absence that thinks in seven colors.',
isTransformation: true, evolves: null, shape: 'crystal'
},
shadequeen: {
id: 'shadequeen', name: 'Shadequeen', types: ['Shade'],
base: { hp: 74, atk: 86, def: 64, spd: 84 },
learnset: { 30: ['eclipse', 'souldrain'], 38: ['voidpulse'], 46: ['hexbind'] },
lore: 'A monarch of dusk; the lanterns dim when she passes.',
isTransformation: true, evolves: null, shape: 'umbra'
}
};
const CREATURE_IDS = Object.keys(CREATURES);
const STARTERS = ['cinderkin', 'lumipup', 'aqualing'];
console.log('Creatures loaded:', CREATURE_IDS.length);
// ---------- Items ----------
const ITEMS = {
// Collection items (3 tiers)
wrappingshard: { name: 'Wrapping Shard', type: 'collection', tier: 1, desc: 'A splinter of woven light. Catches weakened foes.', catchBonus: 0.18, price: 50 },
wovenlattice: { name: 'Woven Lattice', type: 'collection', tier: 2, desc: 'A lattice of folded light. Steadier bond.', catchBonus: 0.32, price: 200 },
echoframe: { name: 'Echo Frame', type: 'collection', tier: 3, desc: 'A ring that holds a small space. Strong bond.', catchBonus: 0.5, price: 800 },
// Restoration
mossdrop: { name: 'Mossdrop', type: 'heal', amt: 0.3, desc: 'Heals 30% of max HP.', price: 30 },
dawnvial: { name: 'Dawn Vial', type: 'heal', amt: 0.6, desc: 'Heals 60% of max HP.', price: 90 },
elixirlume: { name: 'Elixir of Lume', type: 'heal', amt: 1.0, desc: 'Fully restores HP.', price: 250 },
wakebell: { name: 'Wakebell', type: 'revive', amt: 0.5, desc: 'Revives a fainted creature, half HP.', price: 200 },
// Catalysts
embercatalyst: { name: 'Ember Catalyst', type: 'catalyst', for: 'Cinder', desc: 'Catalyst for Cinder evolutions.', price: 500 },
lumencatalyst: { name: 'Lumen Catalyst', type: 'catalyst', for: 'Lumen', desc: 'Catalyst for Lumen evolutions.', price: 500 },
tidalcatalyst: { name: 'Tidal Catalyst', type: 'catalyst', for: 'Tidal', desc: 'Catalyst for Tidal evolutions.', price: 500 },
floracatalyst: { name: 'Flora Catalyst', type: 'catalyst', for: 'Flora', desc: 'Catalyst for Flora evolutions.', price: 500 },
cragcatalyst: { name: 'Crag Catalyst', type: 'catalyst', for: 'Crag', desc: 'Catalyst for Crag evolutions.', price: 500 },
shadecatalyst: { name: 'Shade Catalyst', type: 'catalyst', for: 'Shade', desc: 'Catalyst for Shade evolutions.', price: 500 },
prismcatalyst: { name: 'Prism Catalyst', type: 'universal', desc: 'Universal evolution catalyst.', price: 2000 },
// Field
lurepebble: { name: 'Lure Pebble', type: 'field', desc: 'Raises wild encounter rate briefly.', price: 80 },
wardchime: { name: 'Ward Chime', type: 'field', desc: 'Lowers encounter rate briefly.', price: 80 }
};
// ---------- Zones ----------
// 6 zones with tile-based overworlds
const ZONES = [
{
id: 0, name: 'Embergrove', tagline: 'Where the first embers still remember the sun.',
bgGradient: ['#3a0f0f', '#1f0820', '#0d0410'],
accent: '#ff8a5b', particle: 'ember',
encounterPool: [
{ c: 'cinderkin', w: 30 }, { c: 'thornkin', w: 30 },
{ c: 'pebblump', w: 20 }, { c: 'sparkimp', w: 15 },
{ c: 'mossback', w: 5 }
],
encounterRate: 0.10, levelRange: [3, 7],
map: 'embergrove', anchor: { c: 'ashlung', level: 10, name: 'The Hearthwarden' },
story: 'Embergrove',
storyNode: 'The first grove burns in hues of remembered daybreak. A path of soft cinder leads to a shrine where a small flame sits patient as a held breath.'
},
{
id: 1, name: 'Tidalcoast', tagline: 'A shoreline that hums to itself between the waves.',
bgGradient: ['#0a2240', '#102a4a', '#04101e'],
accent: '#5ec3ff', particle: 'bubble',
encounterPool: [
{ c: 'aqualing', w: 30 }, { c: 'coralyn', w: 25 },
{ c: 'brinewraith', w: 15 }, { c: 'drizztip', w: 15 },
{ c: 'thornkin', w: 10 }, { c: 'mossback', w: 5 }
],
encounterRate: 0.10, levelRange: [8, 13],
map: 'tidalcoast', anchor: { c: 'brinewraith', level: 15, name: 'The Drowning Bell' },
story: 'Tidalcoast',
storyNode: 'The water here sings in three-part harmony. At the cliff, a bell made of glass and kelp marks the boundary between the still and the moving.'
},
{
id: 2, name: 'Voltfields', tagline: 'Grass that listens for thunder, sky that remembers lightning.',
bgGradient: ['#1f1240', '#160a30', '#0a0418'],
accent: '#cba6ff', particle: 'spark',
encounterPool: [
{ c: 'sparkimp', w: 25 }, { c: 'voltwing', w: 25 },
{ c: 'forgebeetle', w: 20 }, { c: 'drizztip', w: 15 },
{ c: 'cinderkin', w: 10 }, { c: 'brinewraith', w: 5 }
],
encounterRate: 0.11, levelRange: [14, 19],
map: 'voltfields', anchor: { c: 'voltwing', level: 21, name: 'The Storm-Hollow' },
story: 'Voltfields',
storyNode: 'The lightning here does not strike. It visits. Beneath each bolt is a small pool where sky-colored fish will one day surface.'
},
{
id: 3, name: 'Lumenglass', tagline: 'A garden of frozen music, lit from within.',
bgGradient: ['#0f2a3a', '#0a1a30', '#040a18'],
accent: '#7fffe5', particle: 'dust',
encounterPool: [
{ c: 'crystoid', w: 30 }, { c: 'prismoth', w: 25 },
{ c: 'specter', w: 20 }, { c: 'voltwing', w: 15 },
{ c: 'forgebeetle', w: 10 }
],
encounterRate: 0.11, levelRange: [20, 26],
map: 'lumenglass', anchor: null,
story: 'Lumenglass',
storyNode: 'No sun crosses this sky. Instead, every shard of glass remembers a different dawn, and the air tastes faintly of oranges.'
},
{
id: 4, name: 'Prismpeaks', tagline: 'Where the mountains are made of folded light.',
bgGradient: ['#3a1850', '#1f0c34', '#0a0418'],
accent: '#7fffe5', particle: 'crystal',
encounterPool: [
{ c: 'prismoth', w: 30 }, { c: 'crystoid', w: 25 },
{ c: 'specter', w: 20 }, { c: 'voltwing', w: 15 },
{ c: 'umbraclaw', w: 10 }
],
encounterRate: 0.12, levelRange: [27, 33],
map: 'prismpeaks', anchor: { c: 'prismoth', level: 32, name: 'The Refracting Throne' },
story: 'Prismpeaks',
storyNode: 'Each peak is a frozen chord. Climbers who listen find that one of them always sings the song they were humming as a child.'
},
{
id: 5, name: 'Shadehollow', tagline: 'The final garden, where the lanterns do not go.',
bgGradient: ['#100820', '#08041a', '#02020a'],
accent: '#a48cff', particle: 'shade',
encounterPool: [
{ c: 'umbraclaw', w: 30 }, { c: 'hollowkin', w: 25 },
{ c: 'specter', w: 20 }, { c: 'prismoth', w: 15 },
{ c: 'ashlung', w: 10 }
],
encounterRate: 0.13, levelRange: [34, 44],
map: 'shadehollow', anchor: { c: 'umbraclaw', level: 42, name: 'The Hollow Monarch' },
story: 'Shadehollow',
storyNode: 'In the deepest hall, a throne of broken lanterns awaits a guest. The hollows are polite. They simply never sleep.'
}
];
// ---------- Zone Map Data (tile-based) ----------
// Tiles: 0=path, 1=grass, 2=water, 3=tree, 4=rock, 5=shrine, 6=anchor, 7=brush, 8=wall, 9=portal
// 20 wide x 15 tall
function buildZoneMap(zoneId) {
// We'll generate zone maps programmatically with consistent features
const W = 20, H = 15;
const map = [];
for (let y = 0; y < H; y++) {
const row = [];
for (let x = 0; x < W; x++) {
row.push(1); // default grass
}
map.push(row);
}
// Borders
for (let x = 0; x < W; x++) { map[0][x] = 8; map[H-1][x] = 8; }
for (let y = 0; y < H; y++) { map[y][0] = 8; map[y][W-1] = 8; }
// Generate a meandering path
let cx = 1, cy = Math.floor(H/2);
map[cy][cx] = 0;
while (cx < W-2) {
const dir = Math.random();
if (dir < 0.5) { cx++; map[cy][cx] = 0; }
else if (dir < 0.75 && cy > 2) { cy--; map[cy][cx] = 0; }
else if (cy < H-3) { cy++; map[cy][cx] = 0; }
if (Math.random() < 0.15 && cx < W-3) { cx++; map[cy][cx] = 0; }
}
// Zone-specific features
if (zoneId === 0) { // Embergrove - cinder trees
for (let i = 0; i < 18; i++) {
const x = randInt(2, W-3), y = randInt(1, H-2);
if (map[y][x] === 1) map[y][x] = 3;
}
for (let i = 0; i < 6; i++) {
const x = randInt(2, W-3), y = randInt(1, H-2);
if (map[y][x] === 1) map[y][x] = 4;
}
} else if (zoneId === 1) { // Tidalcoast - water
for (let y = 2; y < 6; y++) {
for (let x = 1; x < W-1; x++) {
if (Math.random() < 0.5 && map[y][x] === 1) map[y][x] = 2;
}
}
for (let i = 0; i < 10; i++) {
const x = randInt(2, W-3), y = randInt(7, H-2);
if (map[y][x] === 1) map[y][x] = 3;
}
} else if (zoneId === 2) { // Voltfields - rocks + grass
for (let i = 0; i < 15; i++) {
const x = randInt(2, W-3), y = randInt(1, H-2);
if (map[y][x] === 1) map[y][x] = 4;
}
for (let i = 0; i < 12; i++) {
const x = randInt(2, W-3), y = randInt(1, H-2);
if (map[y][x] === 1) map[y][x] = 3;
}
} else if (zoneId === 3) { // Lumenglass - crystal trees
for (let i = 0; i < 12; i++) {
const x = randInt(2, W-3), y = randInt(1, H-2);
if (map[y][x] === 1) map[y][x] = 3;
}
for (let i = 0; i < 8; i++) {
const x = randInt(2, W-3), y = randInt(1, H-2);
if (map[y][x] === 1) map[y][x] = 4;
}
} else if (zoneId === 4) { // Prismpeaks - mountains
for (let i = 0; i < 20; i++) {
const x = randInt(2, W-3), y = randInt(1, H-2);
if (map[y][x] === 1) map[y][x] = 4;
}
} else if (zoneId === 5) { // Shadehollow - shades
for (let i = 0; i < 22; i++) {
const x = randInt(2, W-3), y = randInt(1, H-2);
if (map[y][x] === 1) map[y][x] = 7;
}
}
// Find a path cell for player start
let startX = 1, startY = Math.floor(H/2);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (map[y][x] === 0) { startX = x; startY = y; y = H; break; }
}
}
// Place shrine (always near center, on path)
let shrineX = Math.floor(W/2), shrineY = Math.floor(H/2);
for (let r = 0; r < 8; r++) {
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
const x = shrineX + dx, y = shrineY + dy;
if (x > 1 && x < W-1 && y > 1 && y < H-1 && map[y][x] === 0) {
map[y][x] = 5; shrineX = x; shrineY = y; r = 99; dy = 99; dx = 99;
}
}
}
// Place portal at start (for zone travel)
let portalX = 1, portalY = Math.floor(H/2);
for (let dy = -2; dy <= 2; dy++) {
const y = Math.floor(H/2) + dy;
if (y > 0 && y < H-1 && map[y][1] === 0) { portalY = y; break; }
}
map[portalY][1] = 9;
// Place anchor (boss) at end of path
let anchorX = W-2, anchorY = Math.floor(H/2);
map[anchorY][anchorX] = 6;
return { tiles: map, w: W, h: H, startX, startY, shrineX, shrineY, anchorX, anchorY, portalX, portalY };
}
console.log('Zones loaded:', ZONES.length);
// ---------- Audio System (Web Audio API) ----------
const Audio = (() => {
let ctx = null;
let masterGain = null;
let musicGain = null;
let sfxGain = null;
let musicNodes = [];
let musicInterval = null;
let muted = false;
function init() {
if (ctx) return;
try {
ctx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
musicGain = ctx.createGain();
musicGain.gain.value = 0.18;
musicGain.connect(masterGain);
sfxGain = ctx.createGain();
sfxGain.gain.value = 0.32;
sfxGain.connect(masterGain);
} catch (e) {
console.warn('Audio init failed', e);
}
}
function resume() {
if (ctx && ctx.state === 'suspended') ctx.resume();
}
function setMuted(m) {
muted = m;
if (masterGain) masterGain.gain.value = m ? 0 : 1;
}
// Play a single tone
function tone(freq, dur, type = 'sine', vol = 0.4, dest = sfxGain) {
if (!ctx || muted) return;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = type;
osc.frequency.value = freq;
g.gain.setValueAtTime(0, ctx.currentTime);
g.gain.linearRampToValueAtTime(vol, ctx.currentTime + 0.01);
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur);
osc.connect(g);
g.connect(dest);
osc.start();
osc.stop(ctx.currentTime + dur + 0.05);
}
// SFX
const sfx = {
hit: () => { tone(180, 0.12, 'square', 0.3); tone(140, 0.08, 'sawtooth', 0.2); },
crit: () => { tone(420, 0.1, 'square', 0.4); setTimeout(() => tone(280, 0.15, 'square', 0.35), 50); },
super: () => { tone(520, 0.12, 'triangle', 0.4); setTimeout(() => tone(680, 0.15, 'sine', 0.4), 80); },
miss: () => { tone(220, 0.2, 'sine', 0.2); },
heal: () => { tone(660, 0.2, 'sine', 0.3); setTimeout(() => tone(880, 0.25, 'sine', 0.3), 100); },
capture: () => { tone(440, 0.15, 'sine', 0.4); setTimeout(() => tone(660, 0.15, 'sine', 0.4), 100); setTimeout(() => tone(880, 0.3, 'sine', 0.4), 200); },
capturefail: () => { tone(330, 0.2, 'square', 0.3); setTimeout(() => tone(220, 0.3, 'square', 0.3), 150); },
levelup: () => { [523, 659, 784, 1047].forEach((f, i) => setTimeout(() => tone(f, 0.18, 'sine', 0.4), i * 90)); },
menu: () => { tone(800, 0.06, 'square', 0.25); },
confirm: () => { tone(660, 0.08, 'sine', 0.35); setTimeout(() => tone(880, 0.1, 'sine', 0.35), 60); },
cancel: () => { tone(330, 0.1, 'square', 0.25); setTimeout(() => tone(220, 0.1, 'square', 0.25), 80); },
victory: () => { [523, 659, 784, 1047, 1319].forEach((f, i) => setTimeout(() => tone(f, 0.2, 'sine', 0.4), i * 80)); },
fanf: () => { [392, 523, 659, 784, 1047].forEach((f, i) => setTimeout(() => tone(f, 0.18, 'triangle', 0.45), i * 110)); },
encounter: () => { tone(110, 0.15, 'square', 0.3); setTimeout(() => tone(70, 0.3, 'square', 0.4), 100); },
burn: () => { tone(220, 0.15, 'sawtooth', 0.25); },
shock: () => { tone(1200, 0.08, 'square', 0.3); setTimeout(() => tone(800, 0.08, 'square', 0.3), 60); },
step: () => { tone(140 + Math.random() * 20, 0.04, 'square', 0.12); }
};
// Music: simple looping melody
const melodies = {
// Note frequencies
overworld: {
bpm: 95,
lead: [
// A simple, hopeful melody
{n:'E4',d:0.5},{n:'G4',d:0.5},{n:'A4',d:0.5},{n:'B4',d:1.0},
{n:'A4',d:0.5},{n:'G4',d:0.5},{n:'E4',d:1.0},
{n:'D4',d:0.5},{n:'E4',d:0.5},{n:'G4',d:0.5},{n:'A4',d:1.0},
{n:'G4',d:0.5},{n:'E4',d:0.5},{n:'D4',d:1.5}
],
pad: [ // Bass line
{n:'A2',d:2.0},{n:'E3',d:2.0},{n:'F3',d:2.0},{n:'D3',d:2.0}
]
},
battle: {
bpm: 130,
lead: [
{n:'E4',d:0.25},{n:'E4',d:0.25},{n:'G4',d:0.5},
{n:'E4',d:0.25},{n:'A4',d:0.5},{n:'G4',d:0.5},
{n:'E4',d:0.25},{n:'E4',d:0.25},{n:'B4',d:0.5},
{n:'A4',d:0.5},{n:'G4',d:0.5},{n:'E4',d:0.5}
],
pad: [
{n:'A2',d:1.0},{n:'A2',d:1.0},{n:'E3',d:1.0},{n:'E3',d:1.0}
]
},
title: {
bpm: 75,
lead: [
{n:'A4',d:1.0},{n:'E5',d:1.0},{n:'D5',d:1.0},{n:'C5',d:1.0},
{n:'B4',d:1.0},{n:'A4',d:2.0}
],
pad: [
{n:'A2',d:4.0},{n:'E3',d:4.0}
]
}
};
const NOTE_FREQS = {
'C2':65.41,'D2':73.42,'E2':82.41,'F2':87.31,'G2':98.00,'A2':110.00,'B2':123.47,
'C3':130.81,'D3':146.83,'E3':164.81,'F3':174.61,'G3':196.00,'A3':220.00,'B3':246.94,
'C4':261.63,'D4':293.66,'E4':329.63,'F4':349.23,'G4':392.00,'A4':440.00,'B4':493.88,
'C5':523.25,'D5':587.33,'E5':659.25,'F5':698.46,'G5':783.99,'A5':880.00,'B5':987.77,
'C6':1046.50,'D6':1174.66,'E6':1318.51
};
let currentMusic = null;
let musicTime = 0;
let musicTimeouts = [];
function stopMusic() {
musicTimeouts.forEach(t => clearTimeout(t));
musicTimeouts = [];
if (musicInterval) clearInterval(musicInterval);
musicInterval = null;
}
function playMusic(name) {
if (!ctx) return;
if (currentMusic === name) return;
stopMusic();
currentMusic = name;
const m = melodies[name];
if (!m) return;
const beatMs = 60000 / m.bpm;
function scheduleMelody(melody, type, vol) {
let t = 0;
melody.forEach(note => {
const freq = NOTE_FREQS[note.n];
if (!freq) { t += note.d; return; }
const to = setTimeout(() => {
if (muted) return;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = type;
osc.frequency.value = freq;
g.gain.setValueAtTime(0, ctx.currentTime);
g.gain.linearRampToValueAtTime(vol, ctx.currentTime + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + note.d * (beatMs/1000) * 0.9);
osc.connect(g);
g.connect(musicGain);
osc.start();
osc.stop(ctx.currentTime + note.d * (beatMs/1000));
}, t * beatMs);
musicTimeouts.push(to);
t += note.d;
});
return t * beatMs;
}
// Lead
const leadDur = scheduleMelody(m.lead, 'triangle', 0.18);
// Pad (slower)
setTimeout(() => scheduleMelody(m.pad, 'sine', 0.10), 100);
// Loop
const loopDur = Math.max(leadDur, 4000);
const loopId = setInterval(() => {
if (currentMusic !== name) return;
scheduleMelody(m.lead, 'triangle', 0.18);
scheduleMelody(m.pad, 'sine', 0.10);
}, loopDur);
musicInterval = loopId;
}
return { init, resume, setMuted, sfx, playMusic, stopMusic, tone, isMuted: () => muted };
})();
// ---------- Game State ----------
const Game = {
state: 'title', // title, starter, tutorial, exploration, battle, dialogue, menu, gameover, victory
party: [], // 6 creatures
storage: [], // caught creatures not in party (max 30)
inventory: {},
gold: 100,
seen: new Set(),
caught: new Set(),
currentZone: 0,
unlockedZones: 1,
playerX: 1,
playerY: 7,
playerDir: 'down',
playerStep: 0,
zoneMap: null,
defeatedAnchors: new Set(),
stepsTaken: 0,
totalEncounters: 0,
totalCaught: 0,
storyStage: 0, // 0-3
tutorialStep: 0,
awaitingInput: false,
hasSave: false,
activeCreature: 0, // index in party
activeBattle: null,
menuOpen: false,
muted: false,
paused: false,
transitions: { fade: 0, dir: 0 },
postGame: false,
finalVictory: false
};
const MAX_LEVEL = 50;
const MAX_PARTY = 6;
// Create a creature instance from species
function makeCreature(speciesId, level = 5) {
const s = CREATURES[speciesId];
if (!s) return null;
const inst = {
id: Math.random().toString(36).slice(2, 9),
species: speciesId,
level: level,
exp: 0,
hp: 0, maxHp: 0,
atk: 0, def: 0, spd: 0,
types: [...s.types],
abilityIds: [], // 4 abilities
pp: {}, // ability id -> current pp
status: null, // 'burn' | 'frost' | 'shock' | 'barrier' | 'regen' | 'curse'
statMods: { atk: 0, def: 0, spd: 0 },
name: s.name,
transformations: 0
};
recalcStats(inst);
// Pick 4 abilities
const known = getKnownAbilities(speciesId, level);
inst.abilityIds = known.slice(-4);
if (inst.abilityIds.length < 4) {
// pad with tackle
while (inst.abilityIds.length < 4) inst.abilityIds.push('tackle');
}
// Init PP
for (const ab of inst.abilityIds) {
inst.pp[ab] = ABILITIES[ab]?.pp ?? 10;
}
inst.maxHp = inst.hp;
return inst;
}
function recalcStats(inst) {
const s = CREATURES[inst.species];
if (!s) return;
const lvl = inst.level;
inst.maxHp = Math.floor(s.base.hp + lvl * 2.4 + Math.pow(lvl, 1.2) * 1.1);
inst.atk = Math.floor(s.base.atk + lvl * 1.6);
inst.def = Math.floor(s.base.def + lvl * 1.5);
inst.spd = Math.floor(s.base.spd + lvl * 1.4);
inst.hp = inst.maxHp;
}
function getKnownAbilities(speciesId, level) {
const s = CREATURES[speciesId];
const known = [];
const levels = Object.keys(s.learnset).map(Number).sort((a,b) => a-b);
for (const lv of levels) {
if (lv <= level) {
for (const ab of s.learnset[lv]) {
if (!known.includes(ab)) known.push(ab);
}
}
}
return known;
}
function addExp(inst, amount) {
if (inst.level >= MAX_LEVEL) return { leveled: false, learned: null, evo: null };
inst.exp += amount;
const need = expToNext(inst.level);
let result = { leveled: false, learned: null, evo: null };
while (inst.exp >= expToNext(inst.level) && inst.level < MAX_LEVEL) {
inst.exp -= expToNext(inst.level);
inst.level++;
recalcStats(inst);
result.leveled = true;
// Learn new ability
const s = CREATURES[inst.species];
if (s.learnset[inst.level]) {
for (const ab of s.learnset[inst.level]) {
if (!inst.abilityIds.includes(ab) && inst.abilityIds.length < 4) {
inst.abilityIds.push(ab);
result.learned = ab;
break;
}
}
}
// Check evolution
if (s.evolves && inst.level >= s.evolves.at && !result.evo) {
// Will be triggered separately with catalyst
}
}
return result;
}
function expToNext(level) {
// Smooth curve
return Math.floor(8 + Math.pow(level, 1.45) * 4.5);
}
// ---------- Save / Load ----------
function saveGame() {
try {
const data = {
party: Game.party,
storage: Game.storage,
inventory: Game.inventory,
gold: Game.gold,
seen: Array.from(Game.seen),
caught: Array.from(Game.caught),
currentZone: Game.currentZone,
unlockedZones: Game.unlockedZones,
playerX: Game.playerX,
playerY: Game.playerY,
defeatedAnchors: Array.from(Game.defeatedAnchors),
stepsTaken: Game.stepsTaken,
totalEncounters: Game.totalEncounters,
totalCaught: Game.totalCaught,
storyStage: Game.storyStage,
timestamp: Date.now()
};
localStorage.setItem('aethermere_save_1', JSON.stringify(data));
Game.hasSave = true;
return true;
} catch (e) { console.warn('Save failed', e); return false; }
}
function loadGame() {
try {
const raw = localStorage.getItem('aethermere_save_1');
if (!raw) return null;
const data = JSON.parse(raw);
Game.party = data.party || [];
Game.storage = data.storage || [];
Game.inventory = data.inventory || {};
Game.gold = data.gold || 0;
Game.seen = new Set(data.seen || []);
Game.caught = new Set(data.caught || []);
Game.currentZone = data.currentZone || 0;
Game.unlockedZones = data.unlockedZones || 1;
Game.playerX = data.playerX ?? 1;
Game.playerY = data.playerY ?? 7;
Game.defeatedAnchors = new Set(data.defeatedAnchors || []);
Game.stepsTaken = data.stepsTaken || 0;
Game.totalEncounters = data.totalEncounters || 0;
Game.totalCaught = data.totalCaught || 0;
Game.storyStage = data.storyStage || 0;
Game.hasSave = true;
return data;
} catch (e) { console.warn('Load failed', e); return null; }
}
function hasSave() {
try { return !!localStorage.getItem('aethermere_save_1'); } catch (e) { return false; }
}
function deleteSave() {
try { localStorage.removeItem('aethermere_save_1'); Game.hasSave = false; } catch (e) {}
}
// ---------- Inventory init ----------
function initInventory() {
const inv = {};
inv['wrappingshard'] = 5;
inv['mossdrop'] = 3;
Game.inventory = inv;
}
// ---------- Canvas / Renderer ----------
const canvas = document.getElementById('game-canvas');
const ctx = canvas.getContext('2d');
let dpr = Math.max(1, window.devicePixelRatio || 1);
let viewW = 0, viewH = 0;
function resize() {
dpr = Math.max(1, window.devicePixelRatio || 1);
const rect = canvas.getBoundingClientRect();
viewW = rect.width; viewH = rect.height;
canvas.width = viewW * dpr;
canvas.height = viewH * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();
// Tile rendering
let TILE_SIZE = 48;
function getTileSize() {
// Fit to view
const tw = Math.floor(viewW / 20);
const th = Math.floor(viewH / 15);
return Math.max(20, Math.min(tw, th));
}
// ---------- Particle System ----------
const particles = [];
function addParticle(p) { particles.push({ ...p, life: p.life ?? 1, age: 0 }); }
function updateParticles(dt) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.age += dt;
if (p.age >= p.life) { particles.splice(i, 1); continue; }
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vx *= 0.98;
p.vy *= 0.98;
if (p.gravity) p.vy += p.gravity * dt;
}
}
function drawParticles() {
for (const p of particles) {
const t = p.age / p.life;
const alpha = p.alpha ?? (1 - t);
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.fillStyle = p.color;
if (p.glow) { ctx.shadowColor = p.color; ctx.shadowBlur = p.glow; }
ctx.beginPath();
const size = (p.size ?? 4) * (p.shrink ? 1 - t : 1) * (p.grow ? 0.5 + t * 0.5 : 1);
ctx.arc(p.x, p.y, Math.max(0.5, size), 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
function emitZoneParticles(zone) {
const z = ZONES[zone];
if (!z) return;
const w = viewW, h = viewH;
const pType = z.particle;
const colorMap = {
ember: ['#ff8a5b', '#ff6e3a', '#ffae7f'],
bubble: ['#5ec3ff', '#9cdcff', '#bce5ff'],
spark: ['#cba6ff', '#e1c8ff', '#7fffe5'],
dust: ['#7fffe5', '#bce5ff', '#ffffff'],
crystal: ['#7fffe5', '#cba6ff', '#e1c8ff'],
shade: ['#a48cff', '#7a6ba8', '#3a2860']
};
const colors = colorMap[pType] || ['#7fffe5'];
// Add ambient particles
if (Math.random() < 0.4) {
addParticle({
x: rand(0, w), y: rand(0, h),
vx: rand(-8, 8), vy: pType === 'bubble' ? rand(-20, -5) : rand(-12, -2),
color: pick(colors),
size: rand(1.5, 4), life: rand(3, 6), alpha: 0.5, glow: 6
});
}
}
// ---------- Overworld Rendering ----------
const camera = { x: 0, y: 0, targetX: 0, targetY: 0 };
function drawOverworld(dt) {
const z = ZONES[Game.currentZone];
const map = Game.zoneMap;
if (!z || !map) return;
// Background gradient
const grad = ctx.createLinearGradient(0, 0, 0, viewH);
grad.addColorStop(0, z.bgGradient[0]);
grad.addColorStop(0.5, z.bgGradient[1]);
grad.addColorStop(1, z.bgGradient[2]);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, viewW, viewH);
TILE_SIZE = getTileSize();
const offsetX = (viewW - map.w * TILE_SIZE) / 2 - camera.x;
const offsetY = (viewH - map.h * TILE_SIZE) / 2 - camera.y;
// Emit ambient particles
emitZoneParticles(Game.currentZone);
// Draw tiles
for (let y = 0; y < map.h; y++) {
for (let x = 0; x < map.w; x++) {
const t = map.tiles[y][x];
const sx = offsetX + x * TILE_SIZE;
const sy = offsetY + y * TILE_SIZE;
drawTile(t, sx, sy, TILE_SIZE, z);
}
}
// Draw player
const px = offsetX + Game.playerX * TILE_SIZE;
const py = offsetY + Game.playerY * TILE_SIZE;
drawPlayer(px, py, TILE_SIZE);
// Vignette
const vg = ctx.createRadialGradient(viewW/2, viewH/2, viewW*0.3, viewW/2, viewH/2, viewW*0.7);
vg.addColorStop(0, 'transparent');
vg.addColorStop(1, 'rgba(0,0,0,0.55)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, viewW, viewH);
drawParticles();
// Pause overlay
if (Game.paused) {
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, 0, viewW, viewH);
}
}
function drawTile(type, x, y, s, zone) {
const cx = x + s/2, cy = y + s/2;
const t = performance.now() / 1000;
switch (type) {
case 0: // path
ctx.fillStyle = '#3a3050';
ctx.fillRect(x, y, s, s);
ctx.fillStyle = '#4a3e60';
ctx.fillRect(x + 2, y + 2, s - 4, s - 4);
break;
case 1: // grass
ctx.fillStyle = '#1f2a1a';
ctx.fillRect(x, y, s, s);
ctx.fillStyle = '#2a3a22';
for (let i = 0; i < 3; i++) {
const gx = x + ((i * 17 + 3) % s);
const gy = y + ((i * 23 + 5) % s);
ctx.fillRect(gx, gy, 2, 4);
}
// Subtle color variation per zone
if (zone.accent) {
ctx.fillStyle = zone.accent + '22';
ctx.fillRect(x, y, s, s);
}
break;
case 2: // water
ctx.fillStyle = '#0e2a48';
ctx.fillRect(x, y, s, s);
ctx.fillStyle = '#1a3e64';
const wo = Math.sin(t * 2 + x * 0.1 + y * 0.1) * 2;
ctx.fillRect(x, y + s*0.4 + wo, s, 2);
ctx.fillRect(x, y + s*0.7 - wo, s, 2);
ctx.fillStyle = 'rgba(127,255,229,0.15)';
ctx.fillRect(x, y + s*0.3, s, 1);
break;
case 3: // tree
ctx.fillStyle = '#1f2a1a';
ctx.fillRect(x, y, s, s);
ctx.fillStyle = '#3a2818';
ctx.fillRect(x + s*0.4, y + s*0.5, s*0.2, s*0.5);
ctx.fillStyle = zone.accent || '#2a4a28';
ctx.beginPath();
ctx.arc(x + s*0.5, y + s*0.4, s*0.4, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = (zone.accent || '#3a6a3a') + '88';
ctx.beginPath();
ctx.arc(x + s*0.5, y + s*0.35, s*0.3, 0, Math.PI*2);
ctx.fill();
break;
case 4: // rock
ctx.fillStyle = '#1a1a24';
ctx.fillRect(x, y, s, s);
ctx.fillStyle = '#4a4a5a';
ctx.beginPath();
ctx.moveTo(x + s*0.2, y + s*0.7);
ctx.lineTo(x + s*0.4, y + s*0.3);
ctx.lineTo(x + s*0.6, y + s*0.4);
ctx.lineTo(x + s*0.85, y + s*0.65);
ctx.lineTo(x + s*0.7, y + s*0.85);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#6a6a7a';
ctx.beginPath();
ctx.moveTo(x + s*0.4, y + s*0.3);
ctx.lineTo(x + s*0.6, y + s*0.4);
ctx.lineTo(x + s*0.5, y + s*0.55);
ctx.closePath();
ctx.fill();
break;
case 5: // shrine
ctx.fillStyle = '#1a1428';
ctx.fillRect(x, y, s, s);
// base
ctx.fillStyle = '#3a3060';
ctx.fillRect(x + s*0.2, y + s*0.55, s*0.6, s*0.35);
// pillar
ctx.fillStyle = '#5a4a8a';
ctx.fillRect(x + s*0.35, y + s*0.3, s*0.3, s*0.3);
// flame
const pulse = 0.7 + Math.sin(t * 3) * 0.3;
ctx.fillStyle = `rgba(127,255,229,${pulse})`;
ctx.shadowColor = '#7fffe5'; ctx.shadowBlur = 16;
ctx.beginPath();
ctx.arc(x + s*0.5, y + s*0.28, s*0.08 * pulse, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
break;
case 6: // anchor (boss)
ctx.fillStyle = '#1a0814';
ctx.fillRect(x, y, s, s);
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.fillRect(x, y, s, s);
const glow = 0.5 + Math.sin(t * 2) * 0.5;
ctx.fillStyle = `rgba(164,90,255,${glow})`;
ctx.shadowColor = '#a45aff'; ctx.shadowBlur = 20;
ctx.beginPath();
ctx.arc(x + s*0.5, y + s*0.5, s*0.35, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = '#1a0a28';
ctx.beginPath();
ctx.arc(x + s*0.5, y + s*0.5, s*0.18, 0, Math.PI*2);
ctx.fill();
// Marker
ctx.fillStyle = '#fff';
ctx.font = `${s*0.25}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillText('▲', x + s*0.5, y + s*0.6);
break;
case 7: // brush (shades)
ctx.fillStyle = '#0e0820';
ctx.fillRect(x, y, s, s);
ctx.fillStyle = 'rgba(164,140,255,0.3)';
for (let i = 0; i < 5; i++) {
const bx = x + (i * 7 + 2) % s;
const by = y + (i * 11 + 3) % s;
const bo = Math.sin(t * 1.5 + i) * 2;
ctx.fillRect(bx, by + bo, 3, 3);
}
break;
case 9: // portal
ctx.fillStyle = '#1a1428';
ctx.fillRect(x, y, s, s);
const rot = t * 1.2;
ctx.save();
ctx.translate(x + s/2, y + s/2);
ctx.rotate(rot);
ctx.strokeStyle = '#7fffe5';
ctx.lineWidth = 2;
ctx.shadowColor = '#7fffe5'; ctx.shadowBlur = 12;
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.ellipse(0, 0, s*0.35 - i*4, s*0.12 - i*2, 0, 0, Math.PI*2);
ctx.stroke();
}
ctx.shadowBlur = 0;
ctx.fillStyle = '#7fffe5';
ctx.beginPath();
ctx.arc(0, 0, 3, 0, Math.PI*2);
ctx.fill();
ctx.restore();
break;
case 8: // wall
ctx.fillStyle = '#0a0414';
ctx.fillRect(x, y, s, s);
break;
}
}
function drawPlayer(x, y, s) {
const cx = x + s/2, cy = y + s/2;
const t = performance.now() / 100;
const bob = Math.sin(t) * (Game.playerStep > 0 ? 2 : 0);
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.beginPath();
ctx.ellipse(cx, y + s*0.85, s*0.25, s*0.08, 0, 0, Math.PI*2);
ctx.fill();
// Body
ctx.fillStyle = '#5a4a8a';
ctx.beginPath();
ctx.arc(cx, cy - s*0.1 + bob, s*0.22, 0, Math.PI*2);
ctx.fill();
// Cloak
ctx.fillStyle = '#a48cff';
ctx.beginPath();
ctx.moveTo(cx - s*0.2, cy + bob);
ctx.lineTo(cx, cy + s*0.32 + bob);
ctx.lineTo(cx + s*0.2, cy + bob);
ctx.closePath();
ctx.fill();
// Hood highlight
ctx.fillStyle = '#cba6ff';
ctx.beginPath();
ctx.arc(cx, cy - s*0.18 + bob, s*0.12, 0, Math.PI*2);
ctx.fill();
// Glow
ctx.shadowColor = '#7fffe5'; ctx.shadowBlur = 12;
ctx.fillStyle = '#7fffe5';
ctx.beginPath();
ctx.arc(cx, cy - s*0.15 + bob, 2, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
}
// ---------- Creature Art Drawing ----------
// Each creature has a procedural drawing function
function drawCreatureArt(ctx, species, x, y, size, palette) {
const fn = ART[species] || ART.default;
fn(ctx, x, y, size, palette);
}
const ART = {
default: (ctx, x, y, s) => {
ctx.fillStyle = '#888';
ctx.beginPath();
ctx.arc(x, y, s*0.3, 0, Math.PI*2);
ctx.fill();
},
cinder: (ctx, x, y, s) => {
const t = performance.now() / 600;
const c1 = '#ff6e3a', c2 = '#ffae7f', c3 = '#7fffe5';
// Body
ctx.fillStyle = c1;
ctx.beginPath();
ctx.ellipse(x, y, s*0.35, s*0.3, 0, 0, Math.PI*2);
ctx.fill();
// Head
ctx.beginPath();
ctx.arc(x, y - s*0.2, s*0.22, 0, Math.PI*2);
ctx.fill();
// Ears (fox)
ctx.fillStyle = c2;
ctx.beginPath();
ctx.moveTo(x - s*0.2, y - s*0.3);
ctx.lineTo(x - s*0.1, y - s*0.5);
ctx.lineTo(x - s*0.05, y - s*0.3);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(x + s*0.2, y - s*0.3);
ctx.lineTo(x + s*0.1, y - s*0.5);
ctx.lineTo(x + s*0.05, y - s*0.3);
ctx.closePath(); ctx.fill();
// Eyes
ctx.fillStyle = c3;
ctx.shadowColor = c3; ctx.shadowBlur = 8;
ctx.beginPath();
ctx.arc(x - s*0.08, y - s*0.22, 3, 0, Math.PI*2);
ctx.arc(x + s*0.08, y - s*0.22, 3, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
// Ember sparks
for (let i = 0; i < 4; i++) {
const sx = x + Math.sin(t + i * 1.7) * s*0.4;
const sy = y + Math.cos(t * 1.3 + i) * s*0.4;
ctx.fillStyle = c2;
ctx.beginPath();
ctx.arc(sx, sy, 2, 0, Math.PI*2);
ctx.fill();
}
},
lumen: (ctx, x, y, s) => {
const t = performance.now() / 800;
const c1 = '#7fffe5', c2 = '#bce5ff', c3 = '#fff';
// Body
ctx.fillStyle = c1;
ctx.beginPath();
ctx.ellipse(x, y, s*0.32, s*0.26, 0, 0, Math.PI*2);
ctx.fill();
// Head
ctx.beginPath();
ctx.arc(x, y - s*0.18, s*0.2, 0, Math.PI*2);
ctx.fill();
// Ears (long, like a fennec)
ctx.fillStyle = c2;
ctx.beginPath();
ctx.moveTo(x - s*0.15, y - s*0.25);
ctx.lineTo(x - s*0.25, y - s*0.5);
ctx.lineTo(x - s*0.05, y - s*0.3);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(x + s*0.15, y - s*0.25);
ctx.lineTo(x + s*0.25, y - s*0.5);
ctx.lineTo(x + s*0.05, y - s*0.3);
ctx.closePath(); ctx.fill();
// Eyes
ctx.fillStyle = c3;
ctx.shadowColor = c1; ctx.shadowBlur = 10;
ctx.beginPath();
ctx.arc(x - s*0.07, y - s*0.2, 3, 0, Math.PI*2);
ctx.arc(x + s*0.07, y - s*0.2, 3, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
// Star trail
for (let i = 0; i < 6; i++) {
const a = t + i * (Math.PI*2/6);
const sx = x + Math.cos(a) * s*0.4;
const sy = y + Math.sin(a) * s*0.4;
ctx.fillStyle = c2;
ctx.globalAlpha = 0.6 + Math.sin(t * 2 + i) * 0.4;
ctx.beginPath();
ctx.arc(sx, sy, 1.5, 0, Math.PI*2);
ctx.fill();
}
ctx.globalAlpha = 1;
},
tidal: (ctx, x, y, s) => {
const t = performance.now() / 500;
const c1 = '#5ec3ff', c2 = '#9cdcff', c3 = '#0a2240';
// Body (round)
ctx.fillStyle = c1;
ctx.beginPath();
ctx.arc(x, y, s*0.32, 0, Math.PI*2);
ctx.fill();
// Tail (curl)
ctx.strokeStyle = c1; ctx.lineWidth = s*0.1;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x + s*0.2, y + s*0.05);
ctx.quadraticCurveTo(x + s*0.45, y - s*0.1, x + s*0.3, y - s*0.3);
ctx.stroke();
// Eyes
ctx.fillStyle = c3;
ctx.beginPath();
ctx.arc(x - s*0.1, y - s*0.05, 3, 0, Math.PI*2);
ctx.arc(x + s*0.1, y - s*0.05, 3, 0, Math.PI*2);
ctx.fill();
// Bubbles
for (let i = 0; i < 3; i++) {
const bx = x - s*0.3 + i * s*0.2;
const by = y - s*0.4 - Math.sin(t * 2 + i) * 4;
ctx.strokeStyle = c2; ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(bx, by, 3, 0, Math.PI*2);
ctx.stroke();
}
},
flora: (ctx, x, y, s) => {
const t = performance.now() / 700;
const c1 = '#7cd66a', c2 = '#a8e090', c3 = '#5a3a18';
// Body (round, leafy)
ctx.fillStyle = c1;
ctx.beginPath();
ctx.arc(x, y, s*0.32, 0, Math.PI*2);
ctx.fill();
// Leaves around
for (let i = 0; i < 6; i++) {
const a = i * Math.PI*2/6 + t * 0.1;
const lx = x + Math.cos(a) * s*0.3;
const ly = y + Math.sin(a) * s*0.3;
ctx.fillStyle = c2;
ctx.beginPath();
ctx.ellipse(lx, ly, s*0.12, s*0.06, a, 0, Math.PI*2);
ctx.fill();
}
// Eyes
ctx.fillStyle = c3;
ctx.beginPath();
ctx.arc(x - s*0.1, y - s*0.05, 3, 0, Math.PI*2);
ctx.arc(x + s*0.1, y - s*0.05, 3, 0, Math.PI*2);
ctx.fill();
// Flower on top
ctx.fillStyle = '#ffae7f';
for (let i = 0; i < 5; i++) {
const a = i * Math.PI*2/5;
const fx = x + Math.cos(a) * s*0.12;
const fy = y - s*0.35 + Math.sin(a) * s*0.06;
ctx.beginPath();
ctx.arc(fx, fy, 4, 0, Math.PI*2);
ctx.fill();
}
ctx.fillStyle = '#ffd76b';
ctx.beginPath();
ctx.arc(x, y - s*0.35, 3, 0, Math.PI*2);
ctx.fill();
},
storm: (ctx, x, y, s) => {
const t = performance.now() / 300;
const c1 = '#cba6ff', c2 = '#7fffe5', c3 = '#3a2860';
// Body (angular)
ctx.fillStyle = c1;
ctx.beginPath();
ctx.moveTo(x - s*0.3, y + s*0.1);
ctx.lineTo(x - s*0.15, y - s*0.3);
ctx.lineTo(x + s*0.15, y - s*0.3);
ctx.lineTo(x + s*0.3, y + s*0.1);
ctx.lineTo(x + s*0.15, y + s*0.3);
ctx.lineTo(x - s*0.15, y + s*0.3);
ctx.closePath();
ctx.fill();
// Eye
ctx.fillStyle = c2;
ctx.shadowColor = c2; ctx.shadowBlur = 8;
ctx.beginPath();
ctx.arc(x, y - s*0.05, 4, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
// Sparks
for (let i = 0; i < 3; i++) {
const a = t + i * 2;
const sx = x + Math.cos(a) * s*0.4;
const sy = y + Math.sin(a) * s*0.4;
ctx.strokeStyle = c2; ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(sx - 3, sy); ctx.lineTo(sx + 3, sy);
ctx.moveTo(sx, sy - 3); ctx.lineTo(sx, sy + 3);
ctx.stroke();
}
},
crag: (ctx, x, y, s) => {
const t = performance.now() / 1000;
const c1 = '#e0c891', c2 = '#a08860', c3 = '#5a4830';
// Body (chunky)
ctx.fillStyle = c1;
ctx.beginPath();
ctx.ellipse(x, y + s*0.05, s*0.36, s*0.28, 0, 0, Math.PI*2);
ctx.fill();
// Head
ctx.beginPath();
ctx.arc(x, y - s*0.2, s*0.2, 0, Math.PI*2);
ctx.fill();
// Eyes
ctx.fillStyle = c3;
ctx.beginPath();
ctx.arc(x - s*0.08, y - s*0.22, 2.5, 0, Math.PI*2);
ctx.arc(x + s*0.08, y - s*0.22, 2.5, 0, Math.PI*2);
ctx.fill();
// Crystal on top
ctx.fillStyle = c2;
ctx.beginPath();
ctx.moveTo(x - s*0.1, y - s*0.35);
ctx.lineTo(x, y - s*0.5);
ctx.lineTo(x + s*0.1, y - s*0.35);
ctx.closePath();
ctx.fill();
// Body markings
ctx.fillStyle = c2;
ctx.fillRect(x - s*0.2, y, s*0.4, 2);
ctx.fillRect(x - s*0.15, y + s*0.1, s*0.3, 2);
},
shade: (ctx, x, y, s) => {
const t = performance.now() / 700;
const c1 = '#a48cff', c2 = '#3a2860', c3 = '#7fffe5';
// Body
ctx.fillStyle = c1;
ctx.beginPath();
ctx.ellipse(x, y, s*0.32, s*0.28, 0, 0, Math.PI*2);
ctx.fill();
// Head
ctx.beginPath();
ctx.arc(x, y - s*0.18, s*0.2, 0, Math.PI*2);
ctx.fill();
// Eyes
ctx.fillStyle = c3;
ctx.shadowColor = c3; ctx.shadowBlur = 8;
ctx.beginPath();
ctx.arc(x - s*0.08, y - s*0.2, 3, 0, Math.PI*2);
ctx.arc(x + s*0.08, y - s*0.2, 3, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
// Tail (flickering)
ctx.strokeStyle = c1; ctx.lineWidth = s*0.08;
ctx.lineCap = 'round';
const tw = Math.sin(t) * 5;
ctx.beginPath();
ctx.moveTo(x - s*0.25, y + s*0.1);
ctx.quadraticCurveTo(x - s*0.4, y - s*0.1 + tw, x - s*0.3, y - s*0.35);
ctx.stroke();
},
coral: (ctx, x, y, s) => {
const t = performance.now() / 1000;
const c1 = '#ff8aaa', c2 = '#5ec3ff', c3 = '#7cd66a';
// Coral body
ctx.fillStyle = c1;
ctx.beginPath();
ctx.arc(x, y, s*0.3, 0, Math.PI*2);
ctx.fill();
// Branches
for (let i = 0; i < 5; i++) {
const a = i * Math.PI*2/5 - Math.PI/2;
const bx = x + Math.cos(a) * s*0.3;
const by = y + Math.sin(a) * s*0.3;
ctx.fillStyle = c1;
ctx.beginPath();
ctx.moveTo(bx, by);
ctx.lineTo(bx + Math.cos(a) * s*0.15, by + Math.sin(a) * s*0.15);
ctx.lineTo(bx + Math.cos(a + 0.3) * s*0.15, by + Math.sin(a + 0.3) * s*0.15);
ctx.closePath();
ctx.fill();
}
// Eyes
ctx.fillStyle = c2;
ctx.beginPath();
ctx.arc(x - s*0.08, y - s*0.05, 3, 0, Math.PI*2);
ctx.arc(x + s*0.08, y - s*0.05, 3, 0, Math.PI*2);
ctx.fill();
// Sparkles
ctx.fillStyle = c3;
for (let i = 0; i < 3; i++) {
const a = t + i * 2.1;
const sx = x + Math.cos(a) * s*0.45;
const sy = y + Math.sin(a) * s*0.45;
ctx.beginPath();
ctx.arc(sx, sy, 1.5, 0, Math.PI*2);
ctx.fill();
}
},
wraith: (ctx, x, y, s) => {
const t = performance.now() / 500;
const c1 = '#5ec3ff', c2 = '#a48cff', c3 = '#fff';
// Wisp body
ctx.fillStyle = c1;
ctx.globalAlpha = 0.6;
ctx.beginPath();
ctx.ellipse(x, y, s*0.28, s*0.35, 0, 0, Math.PI*2);
ctx.fill();
ctx.globalAlpha = 0.8;
// Wisps trailing
for (let i = 0; i < 3; i++) {
const wy = y + s*0.2 + i * s*0.1;
const w = s*0.15 - i * s*0.04;
ctx.fillStyle = i % 2 ? c1 : c2;
ctx.beginPath();
ctx.ellipse(x + Math.sin(t + i) * 3, wy, w, s*0.06, 0, 0, Math.PI*2);
ctx.fill();
}
ctx.globalAlpha = 1;
// Eyes
ctx.fillStyle = c3;
ctx.shadowColor = c3; ctx.shadowBlur = 8;
ctx.beginPath();
ctx.arc(x - s*0.08, y - s*0.1, 2.5, 0, Math.PI*2);
ctx.arc(x + s*0.08, y - s*0.1, 2.5, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
},
wing: (ctx, x, y, s) => {
const t = performance.now() / 200;
const c1 = '#cba6ff', c2 = '#7fffe5', c3 = '#5a3a8a';
const flap = Math.sin(t) * 8;
// Body
ctx.fillStyle = c1;
ctx.beginPath();
ctx.ellipse(x, y, s*0.2, s*0.3, 0, 0, Math.PI*2);
ctx.fill();
// Wings
ctx.fillStyle = c3;
ctx.beginPath();
ctx.moveTo(x - s*0.15, y);
ctx.quadraticCurveTo(x - s*0.4, y - s*0.2 + flap, x - s*0.3, y + s*0.1 + flap);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(x + s*0.15, y);
ctx.quadraticCurveTo(x + s*0.4, y - s*0.2 + flap, x + s*0.3, y + s*0.1 + flap);
ctx.closePath(); ctx.fill();
// Eye
ctx.fillStyle = c2;
ctx.shadowColor = c2; ctx.shadowBlur = 6;
ctx.beginPath();
ctx.arc(x, y - s*0.1, 3, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
},
beetle: (ctx, x, y, s) => {
const t = performance.now() / 400;
const c1 = '#5a3a18', c2 = '#ff8a5b', c3 = '#3a2810';
// Shell
ctx.fillStyle = c1;
ctx.beginPath();
ctx.ellipse(x, y, s*0.32, s*0.28, 0, 0, Math.PI*2);
ctx.fill();
// Carapace split
ctx.strokeStyle = c3; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, y - s*0.28);
ctx.lineTo(x, y + s*0.28);
ctx.stroke();
// Glowing cracks
ctx.strokeStyle = c2; ctx.lineWidth = 1.5;
ctx.shadowColor = c2; ctx.shadowBlur = 6;
ctx.beginPath();
ctx.moveTo(x - s*0.15, y - s*0.15);
ctx.lineTo(x - s*0.05, y);
ctx.lineTo(x - s*0.15, y + s*0.15);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + s*0.15, y - s*0.15);
ctx.lineTo(x + s*0.05, y);
ctx.lineTo(x + s*0.15, y + s*0.15);
ctx.stroke();
ctx.shadowBlur = 0;
// Legs
ctx.strokeStyle = c3; ctx.lineWidth = s*0.06;
ctx.beginPath();
ctx.moveTo(x - s*0.3, y); ctx.lineTo(x - s*0.45, y - s*0.1);
ctx.moveTo(x + s*0.3, y); ctx.lineTo(x + s*0.45, y - s*0.1);
ctx.moveTo(x - s*0.3, y + s*0.1); ctx.lineTo(x - s*0.45, y + s*0.2);
ctx.moveTo(x + s*0.3, y + s*0.1); ctx.lineTo(x + s*0.45, y + s*0.2);
ctx.stroke();
},
crystal: (ctx, x, y, s) => {
const t = performance.now() / 1000;
const c1 = '#7fffe5', c2 = '#5ec3ff', c3 = '#3a6a8a';
// Crystal body
ctx.fillStyle = c1;
ctx.beginPath();
ctx.moveTo(x, y - s*0.35);
ctx.lineTo(x + s*0.3, y - s*0.1);
ctx.lineTo(x + s*0.25, y + s*0.25);
ctx.lineTo(x - s*0.25, y + s*0.25);
ctx.lineTo(x - s*0.3, y - s*0.1);
ctx.closePath();
ctx.fill();
// Inner shine
ctx.fillStyle = c2;
ctx.globalAlpha = 0.5 + Math.sin(t) * 0.3;
ctx.beginPath();
ctx.moveTo(x, y - s*0.25);
ctx.lineTo(x + s*0.15, y);
ctx.lineTo(x, y + s*0.15);
ctx.lineTo(x - s*0.15, y);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
// Edge
ctx.strokeStyle = c3; ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x, y - s*0.35);
ctx.lineTo(x + s*0.3, y - s*0.1);
ctx.lineTo(x + s*0.25, y + s*0.25);
ctx.lineTo(x - s*0.25, y + s*0.25);
ctx.lineTo(x - s*0.3, y - s*0.1);
ctx.closePath();
ctx.stroke();
// Sparkles
for (let i = 0; i < 3; i++) {
const a = t * 2 + i * 2;
const sx = x + Math.cos(a) * s*0.4;
const sy = y + Math.sin(a) * s*0.4;
ctx.fillStyle = c1;
ctx.beginPath();
ctx.arc(sx, sy, 1.5, 0, Math.PI*2);
ctx.fill();
}
},
specter: (ctx, x, y, s) => {
const t = performance.now() / 600;
const c1 = '#a48cff', c2 = '#7fffe5', c3 = '#fff';
// Ghost
ctx.fillStyle = c1;
ctx.globalAlpha = 0.7;
ctx.beginPath();
ctx.moveTo(x - s*0.25, y + s*0.1);
ctx.quadraticCurveTo(x - s*0.3, y - s*0.3, x, y - s*0.3);
ctx.quadraticCurveTo(x + s*0.3, y - s*0.3, x + s*0.25, y + s*0.1);
// Wavy bottom
for (let i = 0; i < 5; i++) {
const wx = x - s*0.25 + i * s*0.1;
const wy = y + s*0.1 + Math.sin(t * 2 + i) * 4 + s*0.1;
ctx.lineTo(wx + s*0.05, wy);
}
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
// Eyes
ctx.fillStyle = c2;
ctx.shadowColor = c2; ctx.shadowBlur = 8;
ctx.beginPath();
ctx.arc(x - s*0.1, y - s*0.05, 3, 0, Math.PI*2);
ctx.arc(x + s*0.1, y - s*0.05, 3, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
},
moth: (ctx, x, y, s) => {
const t = performance.now() / 250;
const c1 = '#7fffe5', c2 = '#cba6ff', c3 = '#ff6ba6';
// Body
ctx.fillStyle = '#5a3a8a';
ctx.beginPath();
ctx.ellipse(x, y, s*0.1, s*0.3, 0, 0, Math.PI*2);
ctx.fill();
// Wings
const flap = Math.sin(t) * 5;
ctx.fillStyle = c1;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.quadraticCurveTo(x - s*0.4, y - s*0.2 + flap, x - s*0.3, y + s*0.2);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(x, y);
ctx.quadraticCurveTo(x + s*0.4, y - s*0.2 + flap, x + s*0.3, y + s*0.2);
ctx.closePath(); ctx.fill();
// Patterns
ctx.fillStyle = c2;
ctx.beginPath();
ctx.arc(x - s*0.2, y - s*0.05 + flap/2, s*0.05, 0, Math.PI*2);
ctx.arc(x + s*0.2, y - s*0.05 + flap/2, s*0.05, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = c3;
ctx.beginPath();
ctx.arc(x - s*0.25, y + s*0.05 + flap/2, s*0.04, 0, Math.PI*2);
ctx.arc(x + s*0.25, y + s*0.05 + flap/2, s*0.04, 0, Math.PI*2);
ctx.fill();
},
umbra: (ctx, x, y, s) => {
const t = performance.now() / 400;
const c1 = '#a48cff', c2 = '#3a2860', c3 = '#ff5a7a';
// Body (large)
ctx.fillStyle = c1;
ctx.beginPath();
ctx.ellipse(x, y, s*0.35, s*0.32, 0, 0, Math.PI*2);
ctx.fill();
// Claws
ctx.fillStyle = c2;
for (let side = -1; side <= 1; side += 2) {
for (let i = 0; i < 3; i++) {
const a = -Math.PI/2 + side * (i - 1) * 0.3;
const cx = x + Math.cos(a) * s*0.32;
const cy = y + Math.sin(a) * s*0.32;
const tx = x + Math.cos(a) * s*0.45;
const ty = y + Math.sin(a) * s*0.45;
ctx.strokeStyle = c2; ctx.lineWidth = s*0.05;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(tx, ty);
ctx.stroke();
}
}
// Eyes
ctx.fillStyle = c3;
ctx.shadowColor = c3; ctx.shadowBlur = 10;
ctx.beginPath();
ctx.arc(x - s*0.1, y - s*0.1, 3, 0, Math.PI*2);
ctx.arc(x + s*0.1, y - s*0.1, 3, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
// Ears
ctx.fillStyle = c1;
ctx.beginPath();
ctx.moveTo(x - s*0.2, y - s*0.25);
ctx.lineTo(x - s*0.1, y - s*0.4);
ctx.lineTo(x - s*0.05, y - s*0.25);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(x + s*0.2, y - s*0.25);
ctx.lineTo(x + s*0.1, y - s*0.4);
ctx.lineTo(x + s*0.05, y - s*0.25);
ctx.closePath(); ctx.fill();
},
golem: (ctx, x, y, s) => {
const t = performance.now() / 800;
const c1 = '#4a4060', c2 = '#2a2440', c3 = '#a48cff';
// Blocky body
ctx.fillStyle = c1;
ctx.fillRect(x - s*0.3, y - s*0.1, s*0.6, s*0.4);
// Head
ctx.fillRect(x - s*0.2, y - s*0.3, s*0.4, s*0.2);
// Cracks (glow)
ctx.strokeStyle = c3; ctx.lineWidth = 1.5;
ctx.shadowColor = c3; ctx.shadowBlur = 6;
ctx.beginPath();
ctx.moveTo(x - s*0.15, y - s*0.05);
ctx.lineTo(x, y + s*0.05);
ctx.lineTo(x + s*0.15, y - s*0.05);
ctx.moveTo(x - s*0.2, y + s*0.15);
ctx.lineTo(x - s*0.05, y + s*0.25);
ctx.stroke();
ctx.shadowBlur = 0;
// Eyes
ctx.fillStyle = c3;
ctx.shadowColor = c3; ctx.shadowBlur = 8;
ctx.fillRect(x - s*0.12, y - s*0.22, 4, 4);
ctx.fillRect(x + s*0.08, y - s*0.22, 4, 4);
ctx.shadowBlur = 0;
},
mossback: (ctx, x, y, s) => {
const t = performance.now() / 800;
const c1 = '#7cd66a', c2 = '#5a8a4a', c3 = '#4a3a2a';
// Tortoise shell
ctx.fillStyle = c3;
ctx.beginPath();
ctx.ellipse(x, y, s*0.35, s*0.25, 0, 0, Math.PI*2);
ctx.fill();
// Moss
for (let i = 0; i < 8; i++) {
const a = i * Math.PI*2/8;
const r = s*0.3;
const mx = x + Math.cos(a) * r * 0.7;
const my = y + Math.sin(a) * r * 0.5;
ctx.fillStyle = i % 2 ? c1 : c2;
ctx.beginPath();
ctx.arc(mx, my, s*0.08, 0, Math.PI*2);
ctx.fill();
}
// Head
ctx.fillStyle = c1;
ctx.beginPath();
ctx.arc(x - s*0.3, y, s*0.12, 0, Math.PI*2);
ctx.fill();
// Eye
ctx.fillStyle = c3;
ctx.beginPath();
ctx.arc(x - s*0.32, y - s*0.03, 2, 0, Math.PI*2);
ctx.fill();
},
seahorse: (ctx, x, y, s) => {
const t = performance.now() / 500;
const c1 = '#5ec3ff', c2 = '#cba6ff', c3 = '#fff';
// Seahorse body
ctx.fillStyle = c1;
ctx.beginPath();
ctx.moveTo(x, y - s*0.35);
ctx.quadraticCurveTo(x + s*0.15, y - s*0.2, x + s*0.1, y + s*0.1);
ctx.quadraticCurveTo(x + s*0.05, y + s*0.3, x - s*0.15, y + s*0.25);
ctx.quadraticCurveTo(x - s*0.2, y, x - s*0.1, y - s*0.1);
ctx.quadraticCurveTo(x, y - s*0.2, x, y - s*0.35);
ctx.fill();
// Mane (lightning)
ctx.strokeStyle = c2; ctx.lineWidth = 1.5;
ctx.shadowColor = c2; ctx.shadowBlur = 4;
const bolt = Math.sin(t * 3) * 2;
ctx.beginPath();
ctx.moveTo(x - s*0.05, y - s*0.35);
ctx.lineTo(x + s*0.05 + bolt, y - s*0.25);
ctx.lineTo(x - s*0.05, y - s*0.15);
ctx.stroke();
ctx.shadowBlur = 0;
// Eye
ctx.fillStyle = c3;
ctx.beginPath();
ctx.arc(x - s*0.02, y - s*0.2, 2, 0, Math.PI*2);
ctx.fill();
},
wolf: (ctx, x, y, s) => {
const t = performance.now() / 500;
const c1 = '#3a2020', c2 = '#ff8a5b', c3 = '#ff5a3a';
// Body
ctx.fillStyle = c1;
ctx.beginPath();
ctx.ellipse(x, y, s*0.35, s*0.25, 0, 0, Math.PI*2);
ctx.fill();
// Head
ctx.beginPath();
ctx.arc(x - s*0.2, y - s*0.1, s*0.18, 0, Math.PI*2);
ctx.fill();
// Ears
ctx.beginPath();
ctx.moveTo(x - s*0.32, y - s*0.25);
ctx.lineTo(x - s*0.25, y - s*0.4);
ctx.lineTo(x - s*0.2, y - s*0.22);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(x - s*0.1, y - s*0.22);
ctx.lineTo(x - s*0.05, y - s*0.4);
ctx.lineTo(x, y - s*0.25);
ctx.closePath(); ctx.fill();
// Ember eyes
ctx.fillStyle = c2;
ctx.shadowColor = c2; ctx.shadowBlur = 8;
ctx.beginPath();
ctx.arc(x - s*0.25, y - s*0.12, 2.5, 0, Math.PI*2);
ctx.arc(x - s*0.15, y - s*0.12, 2.5, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
// Tail
ctx.fillStyle = c1;
ctx.beginPath();
ctx.moveTo(x + s*0.3, y);
ctx.quadraticCurveTo(x + s*0.45, y - s*0.1, x + s*0.4, y - s*0.3);
ctx.lineTo(x + s*0.3, y - s*0.2);
ctx.closePath(); ctx.fill();
}
};
// ---------- Battle System ----------
const Battle = {
active: false,
enemies: [],
party: [],
activeIdx: 0,
turnQueue: [],
turnIdx: 0,
log: [],
state: 'menu', // menu, anim, message, done, capture
message: '',
shake: 0,
flashAlpha: 0,
flashColor: '#fff',
bgOffset: 0,
particles: [],
selectedAbility: -1,
capturedThisBattle: false,
expAwarded: 0,
awaitingNext: false,
start(encounter) {
this.active = true;
this.capturedThisBattle = false;
this.expAwarded = 0;
this.state = 'menu';
this.shake = 0;
this.flashAlpha = 0;
this.particles = [];
this.log = [];
this.selectedAbility = -1;
this.awaitingNext = false;
// Build party copies
this.party = Game.party.map(c => ({
...c, hp: c.hp, maxHp: c.maxHp, status: null, statMods: { atk: 0, def: 0, spd: 0 },
barrier: false, abilityIds: [...c.abilityIds]
}));
this.activeIdx = this.party.findIndex(c => c.hp > 0);
if (this.activeIdx < 0) this.activeIdx = 0;
// Build enemies
this.enemies = encounter.enemies.map(e => {
const inst = makeCreature(e.species, e.level);
inst.isAnchor = e.isAnchor || false;
inst.shakeT = 0;
inst.faintT = 0;
return inst;
});
// Auto select first ability
this.selectedAbility = 0;
// Build turn queue
this.buildTurnQueue();
Audio.playMusic('battle');
this.message = `A wild ${this.enemies[0].name} appears!`;
this.addLog(this.message);
Audio.sfx.encounter();
},
buildTurnQueue() {
const all = [];
this.party.forEach((c, i) => {
if (c.hp > 0) all.push({ kind: 'player', idx: i, spd: c.spd, acted: false });
});
this.enemies.forEach((e, i) => {
if (e.hp > 0) all.push({ kind: 'enemy', idx: i, spd: e.spd, acted: false });
});
const effSpd = (a) => {
const c = a.kind === 'player' ? this.party[a.idx] : this.enemies[a.idx];
let s = c.spd;
if (c.status === 'frost') s *= 0.5;
return s;
};
all.sort((a, b) => effSpd(b) - effSpd(a));
this.turnQueue = all;
this.turnIdx = 0;
},
currentActor() {
return this.turnQueue[this.turnIdx];
},
addLog(msg) {
this.log.push(msg);
this.message = msg;
},
async playerAction(action) {
if (this.state !== 'menu') return;
this.state = 'anim';
Audio.sfx.confirm();
if (action === 'fight') {
// Show ability select
this.state = 'ability';
return;
} else if (action === 'bag') {
this.state = 'bag';
return;
} else if (action === 'party') {
this.state = 'party';
return;
} else if (action === 'run') {
// Mark actor as acted
const actor = this.currentActor();
if (actor) actor.acted = true;
if (this.enemies.some(e => e.isAnchor)) {
this.addLog('You cannot flee from a zone anchor!');
this.state = 'message';
setTimeout(() => this.nextTurn(), 1200);
return;
}
if (Math.random() < 0.7) {
this.addLog('You fled the battle.');
this.state = 'message';
setTimeout(() => this.end(false), 1200);
return;
} else {
this.addLog("Couldn't escape!");
this.state = 'message';
setTimeout(() => this.nextTurn(), 1500);
return;
}
}
},
async useAbility(abilId) {
if (this.state !== 'ability') return;
const actor = this.party[this.activeIdx];
const ab = ABILITIES[abilId];
if (!ab) return;
if (!actor.pp) actor.pp = {};
if ((actor.pp[abilId] ?? 0) <= 0) {
this.addLog(`${actor.name} has no energy left for ${ab.name}!`);
Audio.sfx.cancel();
this.state = 'message';
setTimeout(() => this.nextTurn(), 1000);
return;
}
this.state = 'anim';
actor.pp[abilId]--;
Audio.sfx.confirm();
this.addLog(`${actor.name} uses ${ab.name}!`);
await this.delay(600);
await this.executeMove(actor, ab, this.enemies[0], 'enemy', 0);
this.afterAction();
},
useItem(itemId) {
if (this.state !== 'bag') return;
const it = ITEMS[itemId];
if (!it || !Game.inventory[itemId] || Game.inventory[itemId] <= 0) return;
if (it.type === 'collection') {
Game.inventory[itemId]--;
this.state = 'capture';
UI.openCaptureModal(this.enemies[0], itemId);
return;
} else if (it.type === 'heal') {
Game.inventory[itemId]--;
const actor = this.party[this.activeIdx];
const heal = Math.floor(actor.maxHp * it.amt);
actor.hp = Math.min(actor.maxHp, actor.hp + heal);
this.addLog(`${actor.name} restored ${heal} HP.`);
Audio.sfx.heal();
// Particles
for (let i = 0; i < 12; i++) {
const a = rand(0, Math.PI*2);
this.spawnParticle(actor.x || viewW*0.25, actor.y || viewH*0.6, a, '#7cf2a8');
}
this.afterAction();
} else if (it.type === 'revive') {
// Find fainted
const target = this.party.find(c => c.hp <= 0);
if (!target) { this.addLog('No one needs reviving.'); this.state = 'menu'; return; }
Game.inventory[itemId]--;
target.hp = Math.floor(target.maxHp * it.amt);
this.addLog(`${target.name} was revived!`);
Audio.sfx.heal();
this.afterAction();
}
},
switchParty(idx) {
if (this.state !== 'party') return;
if (idx === this.activeIdx) { this.state = 'menu'; return; }
const c = this.party[idx];
if (c.hp <= 0) { this.addLog(`${c.name} is unable to battle.`); return; }
this.activeIdx = idx;
this.addLog(`Go, ${c.name}!`);
Audio.sfx.confirm();
this.afterAction();
},
afterAction() {
// Mark actor as acted
const actor = this.currentActor();
if (actor) actor.acted = true;
this.state = 'message';
setTimeout(() => this.nextTurn(), 900);
},
nextTurn() {
// End battle check
if (this.party.every(c => c.hp <= 0)) {
this.addLog('Your party has fallen...');
this.state = 'message';
setTimeout(() => this.handleDefeat(), 1500);
return;
}
if (this.enemies.every(e => e.hp <= 0)) {
this.addLog('Victory!');
this.state = 'message';
Audio.sfx.victory();
setTimeout(() => this.handleVictory(), 1200);
return;
}
// Advance to next actor
this.turnIdx++;
while (this.turnIdx < this.turnQueue.length) {
const a = this.turnQueue[this.turnIdx];
if (a.acted) { this.turnIdx++; continue; }
if (a.kind === 'player') {
if (this.party[a.idx].hp <= 0) { a.acted = true; this.turnIdx++; continue; }
// Player turn
this.activeIdx = a.idx;
this.state = 'menu';
return;
} else {
if (this.enemies[a.idx].hp <= 0) { a.acted = true; this.turnIdx++; continue; }
// Enemy turn
this.enemyTurn(a.idx);
return;
}
}
// All acted - new round
this.turnQueue.forEach(a => a.acted = false);
this.turnIdx = 0;
// Apply end-of-round effects
this.applyEndOfRound();
// Re-sort for new turn order
this.buildTurnQueue();
this.nextTurn();
},
applyEndOfRound() {
for (const c of [...this.party, ...this.enemies]) {
if (c.hp <= 0) continue;
// Burn
if (c.status === 'burn') {
const dmg = Math.max(1, Math.floor(c.maxHp * 0.06));
c.hp = Math.max(0, c.hp - dmg);
this.addLog(`${c.name} is hurt by burn. (-${dmg})`);
}
// Regen
if (c.status === 'regen') {
const heal = Math.max(1, Math.floor(c.maxHp * 0.08));
c.hp = Math.min(c.maxHp, c.hp + heal);
this.addLog(`${c.name} regenerated ${heal} HP.`);
}
// Curse
if (c.status === 'curse') {
const dmg = Math.max(1, Math.floor(c.maxHp * 0.05));
c.hp = Math.max(0, c.hp - dmg);
this.addLog(`${c.name} suffers the curse. (-${dmg})`);
}
}
},
async enemyTurn(idx) {
this.state = 'anim';
const e = this.enemies[idx];
// Pick a random ability
const abId = pick(e.abilityIds);
const ab = ABILITIES[abId];
// Pick a target - first alive
const alive = this.party.filter(c => c.hp > 0);
if (alive.length === 0) { this.nextTurn(); return; }
const target = alive[0];
this.addLog(`${e.name} uses ${ab.name}!`);
await this.delay(600);
await this.executeMove(e, ab, target, 'player', 0);
this.afterAction();
},
async executeMove(attacker, abil, target, side, targetIdx) {
if (!abil) return;
// Accuracy check
if (Math.random() * 100 > abil.acc) {
this.addLog(`${attacker.name}'s attack missed!`);
Audio.sfx.miss();
this.spawnMissText(target);
return;
}
// Status: shock - chance to skip
if (attacker.status === 'shock' && Math.random() < 0.3) {
this.addLog(`${attacker.name} is shocked and cannot move!`);
Audio.sfx.shock();
this.spawnEffect(attacker.x || viewW*0.5, attacker.y || viewH*0.5, '#ffea78');
return;
}
// Barrier
if (target.status === 'barrier') {
target.status = null;
this.addLog(`${target.name}'s barrier deflected the attack!`);
this.spawnEffect(target.x || viewW*0.5, target.y || viewH*0.5, '#7fffe5');
return;
}
// Damage calc
let mult = 1;
if (abil.type) {
// Check both attacker types
for (const atkType of attacker.types || []) {
const t = TYPE_CHART[atkType] || {};
if (t[target.types[0]] !== undefined) mult *= t[target.types[0]];
if (target.types[1] && t[target.types[1]] !== undefined) mult *= t[target.types[1]];
}
}
const isCrit = Math.random() < 0.0625 || abil.fx === 'crit';
const stab = (abil.type && attacker.types.includes(abil.type)) ? 1.25 : 1;
const level = attacker.level;
const atk = attacker.atk * (1 + (attacker.statMods?.atk || 0));
const def = target.def * (1 + (target.statMods?.def || 0));
const burnHalf = attacker.status === 'burn' ? 0.5 : 1;
// Damage formula: (level*0.4 + 3) * power/35 * atk/def * multipliers
const baseScalar = level * 0.4 + 3;
const powerFactor = abil.power / 35;
const ratio = atk / Math.max(1, def);
let dmg = baseScalar * powerFactor * ratio * mult * stab * burnHalf;
if (isCrit) dmg *= 1.5;
dmg = Math.floor(dmg) + randInt(2, 4);
if (dmg < 1 && abil.power > 0) dmg = 1;
if (mult >= 2) {
this.addLog(`It's super effective!`);
Audio.sfx.super();
} else if (mult < 0.75) {
this.addLog(`It's not very effective...`);
}
if (isCrit) { this.addLog(`A critical hit!`); Audio.sfx.crit(); this.shake = 12; }
if (abil.fx === 'priority') this.addLog(`${attacker.name} moves first!`);
// Animate
await this.animateAttack(attacker, target, abil);
if (dmg > 0) {
target.hp = Math.max(0, target.hp - dmg);
this.spawnDamageText(target, dmg, isCrit, mult >= 2);
Audio.sfx.hit();
this.shake = Math.max(this.shake, isCrit ? 14 : 6);
if (mult >= 2) {
// Burst particles
for (let i = 0; i < 14; i++) {
const a = rand(0, Math.PI*2);
this.spawnParticle(target.x || viewW*0.5, target.y || viewH*0.5, a, '#ffd76b', 5);
}
}
}
// Multi-hit
if (abil.fx === 'multihit' && abil.hits) {
for (let h = 1; h < abil.hits; h++) {
await this.delay(200);
if (target.hp <= 0) break;
const subDmg = Math.floor(dmg * 0.6);
target.hp = Math.max(0, target.hp - subDmg);
this.addLog(`Hit ${h+1}!`);
this.spawnDamageText(target, subDmg, false, false, true);
Audio.sfx.hit();
this.shake = Math.max(this.shake, 4);
}
}
// Apply special effects
await this.applyEffect(attacker, target, abil, side);
},
async applyEffect(attacker, target, abil, side) {
if (!abil.fx) return;
const r = Math.random();
switch (abil.fx) {
case 'burn':
if (target.status == null && (abil.chance == null || r < abil.chance)) {
target.status = 'burn';
this.addLog(`${target.name} was burned!`);
this.spawnEffect(target.x || viewW*0.5, target.y || viewH*0.5, '#ff6e3a');
Audio.sfx.burn();
}
break;
case 'frost':
if (target.status == null && (abil.chance == null || r < abil.chance)) {
target.status = 'frost';
this.addLog(`${target.name} was frostbitten!`);
this.spawnEffect(target.x || viewW*0.5, target.y || viewH*0.5, '#9cdcff');
}
break;
case 'shock':
if (target.status == null && (abil.chance == null || r < abil.chance)) {
target.status = 'shock';
this.addLog(`${target.name} was shocked!`);
this.spawnEffect(target.x || viewW*0.5, target.y || viewH*0.5, '#ffea78');
Audio.sfx.shock();
}
break;
case 'regen':
attacker.status = 'regen';
this.addLog(`${attacker.name} is regenerating.`);
this.spawnEffect(attacker.x || viewW*0.5, attacker.y || viewH*0.5, '#7cf2a8');
break;
case 'heal':
if (attacker.hp > 0) {
const amt = Math.floor(attacker.maxHp * (abil.amt || 0.4));
attacker.hp = Math.min(attacker.maxHp, attacker.hp + amt);
this.addLog(`${attacker.name} restored ${amt} HP.`);
this.spawnEffect(attacker.x || viewW*0.5, attacker.y || viewH*0.5, '#7cf2a8');
Audio.sfx.heal();
}
break;
case 'leech':
if (attacker.hp > 0 && target.hp > 0) {
const steal = Math.max(1, Math.floor(dmg * 0.5));
attacker.hp = Math.min(attacker.maxHp, attacker.hp + steal);
this.addLog(`${attacker.name} drained ${steal} HP.`);
}
break;
case 'defup':
attacker.statMods.def = (attacker.statMods.def || 0) + 1;
this.addLog(`${attacker.name}'s DEF rose.`);
this.spawnEffect(attacker.x || viewW*0.5, attacker.y || viewH*0.5, '#a48cff');
break;
case 'speedup':
attacker.statMods.spd = (attacker.statMods.spd || 0) + 1;
this.addLog(`${attacker.name}'s SPD rose.`);
this.spawnEffect(attacker.x || viewW*0.5, attacker.y || viewH*0.5, '#5ec3ff');
break;
case 'critup':
attacker.statMods.crit = (attacker.statMods.crit || 0) + 1;
this.addLog(`${attacker.name} is focusing.`);
break;
case 'barrier':
target.status = 'barrier';
this.addLog(`${target.name} raised a barrier.`);
this.spawnEffect(target.x || viewW*0.5, target.y || viewH*0.5, '#7fffe5');
break;
case 'curse':
target.status = 'curse';
this.addLog(`${target.name} is cursed.`);
this.spawnEffect(target.x || viewW*0.5, target.y || viewH*0.5, '#a48cff');
break;
case 'selfslow':
attacker.statMods.spd = (attacker.statMods.spd || 0) - 1;
this.addLog(`${attacker.name}'s SPD fell.`);
break;
case 'allup':
attacker.statMods.atk = (attacker.statMods.atk || 0) + 1;
attacker.statMods.def = (attacker.statMods.def || 0) + 1;
attacker.statMods.spd = (attacker.statMods.spd || 0) + 1;
this.addLog(`${attacker.name}'s stats rose.`);
this.spawnEffect(attacker.x || viewW*0.5, attacker.y || viewH*0.5, '#ffd76b');
break;
}
},
async animateAttack(attacker, target, abil) {
// Type-based particle effects
const type = abil.type;
const colorMap = {
Cinder: '#ff8a5b', Flora: '#7cd66a', Tidal: '#5ec3ff',
Storm: '#cba6ff', Crag: '#e0c891', Lumen: '#7fffe5', Shade: '#a48cff'
};
const c = type ? colorMap[type] : '#fff';
// Atk lunge
attacker.lunge = 1;
await this.delay(150);
attacker.lunge = 0;
// Effect particles at target
for (let i = 0; i < 8; i++) {
const a = rand(0, Math.PI*2);
this.spawnParticle(target.x || viewW*0.5, target.y || viewH*0.5, a, c, 4);
}
},
delay(ms) { return new Promise(r => setTimeout(r, ms)); },
spawnParticle(x, y, angle, color, speed = 100) {
this.particles.push({
x, y, age: 0, life: rand(0.5, 1),
vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
color, size: rand(2, 5), gravity: 80
});
},
spawnDamageText(target, dmg, isCrit, isSE, isMulti = false) {
const el = document.createElement('div');
el.className = 'dmg-num' + (isCrit ? ' crit' : '') + (isSE ? ' se' : '');
el.textContent = (isCrit ? '✦ ' : '') + '-' + dmg + (isMulti ? '!' : '');
el.style.left = ((target.x || viewW*0.5) + rand(-30, 30)) + 'px';
el.style.top = ((target.y || viewH*0.5) - 30) + 'px';
document.getElementById('ui-layer').appendChild(el);
setTimeout(() => el.remove(), 1300);
},
spawnMissText(target) {
const el = document.createElement('div');
el.className = 'dmg-num miss';
el.textContent = 'MISS';
el.style.left = ((target.x || viewW*0.5)) + 'px';
el.style.top = ((target.y || viewH*0.5) - 30) + 'px';
document.getElementById('ui-layer').appendChild(el);
setTimeout(() => el.remove(), 1300);
},
spawnEffect(x, y, color) {
for (let i = 0; i < 10; i++) {
const a = rand(0, Math.PI*2);
this.particles.push({
x, y, age: 0, life: rand(0.6, 1.2),
vx: Math.cos(a) * 80, vy: Math.sin(a) * 80,
color, size: rand(3, 6), gravity: 0
});
}
},
handleVictory() {
// Calculate EXP
let totalExp = 0;
let totalGold = 0;
for (const e of this.enemies) {
const level = e.level;
totalExp += Math.floor(8 + level * 3 + level * level * 0.1);
totalGold += Math.floor(5 + level * 2 + (e.isAnchor ? 200 : 0));
}
this.expAwarded = totalExp;
// Gold
Game.gold += totalGold;
this.addLog(`You gained ${totalGold} gold.`);
// Award to active
const active = Game.party[this.activeIdx];
if (active && active.hp > 0) {
const res = addExp(active, totalExp);
this.addLog(`${active.name} gained ${totalExp} EXP.`);
if (res.leveled) {
this.addLog(`${active.name} grew to level ${active.level}!`);
Audio.sfx.levelup();
}
if (res.learned) {
this.addLog(`${active.name} learned ${ABILITIES[res.learned].name}!`);
if (!active.abilityIds.includes(res.learned)) active.abilityIds.push(res.learned);
}
}
// Distribute some to others
const perEach = Math.floor(totalExp * 0.3);
for (let i = 0; i < Game.party.length; i++) {
if (i === this.activeIdx) continue;
const c = Game.party[i];
if (c && c.hp > 0) addExp(c, perEach);
}
// Mark as seen
for (const e of this.enemies) {
Game.seen.add(e.species);
if (e.isAnchor) {
Game.defeatedAnchors.add(`${Game.currentZone}_${e.species}_${e.level}`);
// Unlock next zone
Game.unlockedZones = Math.max(Game.unlockedZones, Game.currentZone + 2);
Audio.sfx.fanf();
this.addLog('A new path opens...');
}
}
// Update party HP, PP, status
for (let i = 0; i < Game.party.length; i++) {
if (Game.party[i] && this.party[i]) {
Game.party[i].hp = this.party[i].hp;
if (this.party[i].pp) Game.party[i].pp = {...this.party[i].pp};
if (this.party[i].status) Game.party[i].status = this.party[i].status;
}
}
// Story progress
updateStoryProgress();
// Save
saveGame();
this.end(true);
},
handleDefeat() {
// Wipe to last shrine
Game.gold = Math.floor(Game.gold * 0.5);
// Heal party
for (const c of Game.party) {
if (c) c.hp = 1;
}
saveGame();
this.end(false);
},
end(victory) {
this.active = false;
Game.state = 'exploration';
Audio.playMusic('overworld');
hideBattleUI();
// Remove any lingering battle message
document.getElementById('battle-message')?.remove();
}
};
function updateStoryProgress() {
// Stage 0: tutorial done
// Stage 1: first anchor defeated
// Stage 2: 2 anchors defeated
// Stage 3: 4 anchors defeated
const defeated = Game.defeatedAnchors.size;
let newStage = 0;
if (Game.storyStage === 0 && defeated >= 1) newStage = 1;
if (Game.storyStage === 1 && defeated >= 2) newStage = 2;
if (Game.storyStage < 3 && defeated >= 4) newStage = 3;
if (newStage > Game.storyStage) {
Game.storyStage = newStage;
UI.showStory(newStage);
}
// Post-game: harder spawns
Game.postGame = defeated >= 4;
}
// Check victory condition: 8+ unique creatures caught
function checkVictoryCondition() {
if (Game.caught.size >= 8 && !Game.finalVictory) {
Game.finalVictory = true;
setTimeout(() => {
UI.showDialogue('Wisp', 'You have bound eight creatures. The lanterns along the Hollow have begun to hum together. The path that was hidden is no longer hidden. Walk well, Binder.', () => {});
}, 500);
}
}
// ---------- Battle Renderer ----------
function drawBattle(dt) {
// Sky / background per zone
const z = ZONES[Game.currentZone] || ZONES[0];
const grad = ctx.createLinearGradient(0, 0, 0, viewH);
grad.addColorStop(0, z.bgGradient[0]);
grad.addColorStop(0.6, z.bgGradient[1]);
grad.addColorStop(1, z.bgGradient[2]);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, viewW, viewH);
// Floor
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fillRect(0, viewH*0.65, viewW, viewH*0.35);
// Platforms
drawPlatform(viewW*0.78, viewH*0.55, 80); // enemy
drawPlatform(viewW*0.25, viewH*0.7, 110); // player
// Set positions
for (let i = 0; i < Battle.enemies.length; i++) {
const e = Battle.enemies[i];
e.x = viewW*0.78 - i * 30;
e.y = viewH*0.45 - i * 15;
}
if (Battle.party[Battle.activeIdx]) {
Battle.party[Battle.activeIdx].x = viewW*0.25;
Battle.party[Battle.activeIdx].y = viewH*0.6;
}
// Draw enemies
for (let i = 0; i < Battle.enemies.length; i++) {
const e = Battle.enemies[i];
if (e.hp <= 0) continue;
const sx = e.lunge ? e.x - 20 : e.x;
const sy = e.y;
const size = e.isAnchor ? 90 : 70;
if (e.shakeT > 0) {
e.shakeT -= dt;
}
const shx = e.shakeT > 0 ? rand(-3, 3) : 0;
drawCreatureArt(ctx, e.species, sx + shx, sy, size);
// HP bar
if (i === 0 || Battle.enemies.length === 1) {
drawBattleHpBar(sx - 60, sy - size*0.7, 120, 8, e.hp, e.maxHp, e.name, e.level, e.isAnchor);
}
}
// Draw active party
const p = Battle.party[Battle.activeIdx];
if (p && p.hp > 0) {
const sx = p.lunge ? p.x + 20 : p.x;
drawCreatureArt(ctx, p.species, sx, p.y, 100);
drawBattleHpBar(sx - 70, p.y - 80, 140, 12, p.hp, p.maxHp, p.name, p.level, false, true);
// Status badges
if (p.status) {
ctx.font = '11px sans-serif';
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.fillText('[' + p.status.toUpperCase() + ']', sx, p.y - 90);
}
}
// Particles
for (let i = Battle.particles.length - 1; i >= 0; i--) {
const p = Battle.particles[i];
p.age += dt;
if (p.age >= p.life) { Battle.particles.splice(i, 1); continue; }
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vx *= 0.95; p.vy *= 0.95;
if (p.gravity) p.vy += p.gravity * dt;
const t = p.age / p.life;
ctx.save();
ctx.globalAlpha = 1 - t;
ctx.fillStyle = p.color;
ctx.shadowColor = p.color; ctx.shadowBlur = 6;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * (1 - t * 0.5), 0, Math.PI*2);
ctx.fill();
ctx.restore();
}
// Flash
if (Battle.flashAlpha > 0) {
ctx.fillStyle = Battle.flashColor;
ctx.globalAlpha = Battle.flashAlpha;
ctx.fillRect(0, 0, viewW, viewH);
ctx.globalAlpha = 1;
Battle.flashAlpha -= dt * 3;
}
// Shake
if (Battle.shake > 0) {
Battle.shake -= dt * 30;
}
}
function drawPlatform(x, y, w) {
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.beginPath();
ctx.ellipse(x, y, w, 12, 0, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = 'rgba(127,255,229,0.15)';
ctx.beginPath();
ctx.ellipse(x, y, w*0.7, 6, 0, 0, Math.PI*2);
ctx.fill();
}
function drawBattleHpBar(x, y, w, h, hp, max, name, level, big, isPlayer) {
const pct = Math.max(0, hp / max);
// Backdrop
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(x - 1, y - 1, w + 2, h + 2);
ctx.fillStyle = '#1a1530';
ctx.fillRect(x, y, w, h);
// Fill
let color = '#4af2a4';
if (pct < 0.5) color = '#ffb45a';
if (pct < 0.2) color = '#ff5a7a';
ctx.fillStyle = color;
ctx.fillRect(x, y, w * pct, h);
// Name
if (name) {
ctx.font = 'bold 12px sans-serif';
ctx.fillStyle = '#fff';
ctx.textAlign = 'left';
ctx.fillText(name + (level != null ? ' L' + level : ''), x, y - 4);
}
if (isPlayer) {
ctx.font = '10px monospace';
ctx.fillStyle = '#b6a8e0';
ctx.textAlign = 'right';
ctx.fillText(Math.floor(hp) + '/' + Math.floor(max), x + w, y + h + 12);
}
}
// ---------- UI System ----------
const UI = {
currentMenu: null,
// Open a modal
openModal(html, wide = false) {
this.closeModal();
const bd = document.createElement('div');
bd.className = 'modal-backdrop';
bd.id = 'modal-bd';
const m = document.createElement('div');
m.className = 'modal' + (wide ? ' wide' : '');
m.id = 'modal-content';
m.innerHTML = html + '<button class="modal-close" data-action="close">✕</button>';
bd.appendChild(m);
document.getElementById('ui-layer').appendChild(bd);
bd.addEventListener('click', e => {
if (e.target === bd) this.closeModal();
});
m.querySelector('.modal-close').addEventListener('click', () => this.closeModal());
return m;
},
closeModal() {
const bd = document.getElementById('modal-bd');
if (bd) bd.remove();
this.currentMenu = null;
},
showToast(text, duration = 2000) {
let t = document.getElementById('toast');
if (!t) {
t = document.createElement('div');
t.id = 'toast';
document.getElementById('ui-layer').appendChild(t);
}
t.textContent = text;
t.classList.add('show');
t.classList.remove('hide');
clearTimeout(this._toastT);
this._toastT = setTimeout(() => {
t.classList.add('hide');
t.classList.remove('show');
}, duration);
},
showDialogue(speaker, text, onDone) {
this.closeModal();
if (this._dlg) this._dlg.remove();
const prevState = Game.state;
Game.state = 'dialogue';
this._dlg = document.createElement('div');
this._dlg.id = 'dialogue-box';
this._dlg.className = 'show';
this._dlg.innerHTML = `<div class="dialogue-speaker">${speaker}</div><div class="dialogue-text"></div><div class="dialogue-hint">▼ tap or press E to continue</div>`;
document.getElementById('ui-layer').appendChild(this._dlg);
this._typeText(text, () => {
// Wait for click or E
const handler = () => {
this._dlg.removeEventListener('click', handler);
this._dlg.remove();
if (Game.state === 'dialogue') Game.state = prevState || 'exploration';
if (onDone) onDone();
};
this._dlg.addEventListener('click', handler);
const keyHandler = (e) => {
if (e.key.toLowerCase() === 'e' || e.key === ' ' || e.key === 'Enter') {
window.removeEventListener('keydown', keyHandler);
handler();
}
};
window.addEventListener('keydown', keyHandler);
});
},
_typeText(text, onDone) {
const el = this._dlg.querySelector('.dialogue-text');
let i = 0;
el.textContent = '';
const tick = () => {
if (i < text.length) {
el.textContent += text[i++];
setTimeout(tick, 22);
} else {
if (onDone) onDone();
}
};
tick();
},
showVignette(stage) {
const stages = {
1: { tag: 'Chapter I', text: 'A child walked the Embergrove with a lantern made of foxfire. The first anchor stirred. The lantern did not go out.' },
2: { tag: 'Chapter II', text: 'Two of the four anchors have fallen. The path to Lumenglass shimmers, and the binder hears a new chord beneath the dust.' },
3: { tag: 'Chapter III', text: 'The hollows are quiet now. The lanterns light themselves. Somewhere, a door unlocks from the inside.' }
};
const data = stages[stage];
if (!data) return;
const v = document.createElement('div');
v.className = 'vignette show';
v.innerHTML = `<div class="vignette-text"><span class="vt-stage">${data.tag}</span>${data.text}</div>`;
document.getElementById('ui-layer').appendChild(v);
Audio.sfx.fanf();
setTimeout(() => {
v.style.transition = 'opacity 1s';
v.style.opacity = '0';
setTimeout(() => v.remove(), 1000);
}, 4500);
},
showStory(stage) { this.showVignette(stage); },
// Capture modal
openCaptureModal(creature, itemId) {
const tier = ITEMS[itemId].tier;
const baseChance = 0.3 + (creature.hp / creature.maxHp < 0.2 ? 0.2 : 0) + ITEMS[itemId].catchBonus + tier * 0.05;
const html = `
<div id="capture-modal" class="show">
<div id="capture-rings">
<div class="capture-ring outer"></div>
<div class="capture-ring inner"></div>
</div>
<div id="capture-hint">
Tap the rings as they pass through the center!<br>
<span style="color:var(--lumen)">${creature.name}</span> · HP ${Math.floor(creature.hp)}/${creature.maxHp}
</div>
<div id="capture-timer"><div id="capture-timer-fill"></div></div>
</div>
`;
document.getElementById('ui-layer').insertAdjacentHTML('beforeend', html);
this._captureState = { tier, baseChance, creature, itemId, hits: 0, total: 0, time: 4.0, ring: 1, lastRingChange: 0 };
// Shrinking ring animation
let scale = 1.5;
let innerScale = 0.4;
let shrinking = true;
let lastT = performance.now();
const updateRing = () => {
const now = performance.now();
const dt = (now - lastT) / 1000;
lastT = now;
if (shrinking) {
scale -= 0.6 * dt;
if (scale < 0.5) { shrinking = false; }
} else {
scale += 0.6 * dt;
if (scale > 1.5) { shrinking = true; }
}
document.querySelector('.capture-ring.outer').style.setProperty('--ring-scale', scale);
};
this._captureInterval = setInterval(updateRing, 16);
this._captureTimer = setInterval(() => {
this._captureState.time -= 0.1;
document.getElementById('capture-timer-fill').style.width = (this._captureState.time / 4.0 * 100) + '%';
if (this._captureState.time <= 0) this.finishCapture();
}, 100);
// Click handlers
const rings = document.querySelectorAll('.capture-ring');
rings.forEach(ring => {
ring.addEventListener('click', () => {
if (this._captureState.time <= 0) return;
const isOuter = ring.classList.contains('outer');
const scaleVal = parseFloat(getComputedStyle(ring).getPropertyValue('--ring-scale'));
// Hit if scale is between 0.9 and 1.1
const hit = scaleVal > 0.85 && scaleVal < 1.15;
ring.classList.add('hit');
setTimeout(() => ring.classList.remove('hit'), 100);
this._captureState.hits += hit ? 1 : 0;
this._captureState.total += 1;
Audio.sfx.confirm();
// Update hint
document.getElementById('capture-hint').innerHTML = `Hits: ${this._captureState.hits}/${this._captureState.total}`;
});
});
},
finishCapture() {
clearInterval(this._captureInterval);
clearInterval(this._captureTimer);
const m = document.getElementById('capture-modal');
if (m) m.remove();
const s = this._captureState;
if (s.hits === 0) s.hits = 0;
const accuracy = s.total > 0 ? s.hits / s.total : 0;
const hpFactor = 1 - (s.creature.hp / s.creature.maxHp);
const finalChance = s.baseChance + accuracy * 0.3 + hpFactor * 0.2;
const success = Math.random() < finalChance;
if (success) {
// Captured!
const species = s.creature.species;
Game.seen.add(species);
Game.caught.add(species);
const c = makeCreature(species, s.creature.level);
c.hp = c.maxHp;
// Add to storage
if (Game.party.length < MAX_PARTY) {
Game.party.push(c);
} else {
Game.storage.push(c);
}
Battle.capturedThisBattle = true;
Audio.sfx.capture();
this.showToast(`✨ ${s.creature.name} was bound!`);
Battle.addLog(`${s.creature.name} was bound to your party!`);
// Set enemy hp to 0
s.creature.hp = 0;
// Mark all enemies for removal
for (const e of Battle.enemies) e.hp = 0;
// Animate
this.captureBurst(s.creature);
const isFirstCatch = Game.totalCaught === 0;
Game.totalCaught++;
checkVictoryCondition();
setTimeout(() => {
Battle.end(true);
saveGame();
if (isFirstCatch) {
setTimeout(() => {
UI.showDialogue('Wisp', 'You have made your first binding. The creature will follow you now. Rest at the shrine when you are tired, and seek the anchor at the world\'s edge.', () => {});
}, 800);
}
}, 1800);
} else {
Audio.sfx.capturefail();
this.showToast('The bond broke loose!');
Battle.addLog('The bond failed!');
// Use a turn
Battle.afterAction();
}
},
captureBurst(c) {
const center = { x: viewW*0.78, y: viewH*0.45 };
for (let i = 0; i < 40; i++) {
const a = (i / 40) * Math.PI * 2;
Battle.particles.push({
x: center.x, y: center.y, age: 0, life: 1.2,
vx: Math.cos(a) * 200, vy: Math.sin(a) * 200,
color: '#7fffe5', size: 4, gravity: 0
});
}
},
// Open specific menus
openPartyMenu(onSelect) {
this.closeModal();
const html = `
<h2>Your Party</h2>
<div class="party-grid" id="party-grid"></div>
`;
const m = this.openModal(html, true);
const grid = m.querySelector('#party-grid');
Game.party.forEach((c, i) => {
const card = document.createElement('div');
card.className = 'party-card' + (i === Game.activeCreature ? ' active' : '') + (c.hp <= 0 ? ' fainted' : '');
const canvas = document.createElement('canvas');
canvas.className = 'pc-mini';
canvas.width = 64; canvas.height = 64;
card.appendChild(canvas);
const name = document.createElement('div');
name.className = 'pc-name';
name.textContent = c.name + (CREATURES[c.species].isTransformation ? ' ★' : '');
card.appendChild(name);
const lvl = document.createElement('span');
lvl.className = 'pc-lvl';
lvl.textContent = 'Lv' + c.level;
name.appendChild(lvl);
const type = document.createElement('div');
type.className = 'pc-type';
type.textContent = CREATURES[c.species].types.join(' / ');
card.appendChild(type);
const hp = document.createElement('div');
hp.className = 'pc-hp';
const fill = document.createElement('div');
fill.className = 'pc-hp-fill';
fill.style.width = (c.hp / c.maxHp * 100) + '%';
hp.appendChild(fill);
card.appendChild(hp);
// Draw art
const cctx = canvas.getContext('2d');
drawCreatureArt(cctx, c.species, 32, 32, 28);
card.addEventListener('click', () => {
if (onSelect) onSelect(i);
else {
// Open detail
this.openCreatureDetail(i);
}
});
grid.appendChild(card);
});
},
openCreatureDetail(idx) {
const c = Game.party[idx];
const s = CREATURES[c.species];
const html = `
<h2>${c.name}</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:start">
<div>
<canvas id="cd-art" width="180" height="180" style="background:rgba(0,0,0,.3);border-radius:14px;display:block;margin:0 auto"></canvas>
<p style="color:var(--ink-1);font-style:italic;margin-top:12px;font-size:13px;line-height:1.5">${s.lore}</p>
</div>
<div>
<h3>Profile</h3>
<div style="display:grid;grid-template-columns:auto 1fr;gap:6px 14px;font-size:13px">
<span style="color:var(--ink-1)">Type</span><span style="color:var(--lumen)">${s.types.join(' / ')}</span>
<span style="color:var(--ink-1)">Level</span><span>${c.level} / ${MAX_LEVEL}</span>
<span style="color:var(--ink-1)">EXP</span><span>${c.exp} / ${expToNext(c.level)}</span>
</div>
<h3>Stats</h3>
<div style="display:grid;grid-template-columns:auto 1fr;gap:6px 14px;font-size:13px">
<span style="color:var(--ink-1)">HP</span><span>${c.hp} / ${c.maxHp}</span>
<span style="color:var(--ink-1)">ATK</span><span>${c.atk}</span>
<span style="color:var(--ink-1)">DEF</span><span>${c.def}</span>
<span style="color:var(--ink-1)">SPD</span><span>${c.spd}</span>
</div>
<h3>Abilities</h3>
<div style="display:flex;flex-direction:column;gap:6px">
${c.abilityIds.map(ab => {
const a = ABILITIES[ab];
if (!a) return '';
return `<div style="display:flex;justify-content:space-between;padding:8px 10px;background:rgba(0,0,0,.3);border-radius:8px;font-size:12px">
<span><span style="color:${TYPE_COLORS[a.type]||'#fff'}">${a.name}</span><br><span style="color:var(--ink-2);font-size:10px">${a.desc}</span></span>
<span style="color:var(--ink-1);font-family:var(--f-mono)">PWR ${a.power}</span>
</div>`;
}).join('')}
</div>
${s.evolves && c.level >= s.evolves.at ? `
<h3>Evolution</h3>
<div style="padding:12px;background:rgba(127,255,229,.1);border:1px solid var(--lumen-dim);border-radius:8px;font-size:12px">
<div>Can transform into <strong style="color:var(--lumen)">${CREATURES[s.evolves.to].name}</strong></div>
<div style="color:var(--ink-1);margin-top:6px">Requires: <strong>${ITEMS[s.evolves.item]?.name || s.evolves.item}</strong></div>
<button class="battle-btn" data-evo="${idx}" style="margin-top:8px;width:100%">Transform</button>
</div>
` : ''}
</div>
</div>
`;
const m = this.openModal(html, true);
const ac = m.querySelector('#cd-art');
const actx = ac.getContext('2d');
drawCreatureArt(actx, c.species, 90, 90, 80);
// Evo button
const evoBtn = m.querySelector('[data-evo]');
if (evoBtn) {
evoBtn.addEventListener('click', () => {
const item = CREATURES[c.species].evolves.item;
if (Game.inventory[item] && Game.inventory[item] > 0) {
Game.inventory[item]--;
this.evolveCreature(idx);
this.closeModal();
} else {
this.showToast(`You need ${ITEMS[item]?.name || item}.`);
}
});
}
},
evolveCreature(idx) {
const c = Game.party[idx];
const s = CREATURES[c.species];
const target = s.evolves.to;
const targetS = CREATURES[target];
this.showDialogue('The Hollow', `${c.name} trembles. The light gathers. A new shape emerges.`, () => {
const oldName = c.name;
c.species = target;
c.name = targetS.name;
c.types = [...targetS.types];
// Re-learn abilities
const known = getKnownAbilities(target, c.level);
c.abilityIds = known.slice(-4);
if (c.abilityIds.length < 4) while (c.abilityIds.length < 4) c.abilityIds.push('tackle');
recalcStats(c);
c.hp = c.maxHp;
c.transformations = (c.transformations || 0) + 1;
Audio.sfx.fanf();
this.showToast(`✨ ${oldName} became ${c.name}!`);
// Burst
for (let i = 0; i < 30; i++) {
const a = rand(0, Math.PI*2);
Battle.particles.push({
x: viewW/2, y: viewH/2, age: 0, life: 1.5,
vx: Math.cos(a) * 200, vy: Math.sin(a) * 200,
color: '#7fffe5', size: 4, gravity: 0
});
}
});
},
openBagMenu() {
this.closeModal();
const items = Object.entries(Game.inventory).filter(([k, v]) => v > 0);
const html = `
<h2>Inventory</h2>
<div style="margin-bottom:12px;color:var(--ink-1);font-size:12px">Gold: <span style="color:var(--gold)">${Game.gold}</span> ◆</div>
<div class="items-list" id="items-list"></div>
`;
const m = this.openModal(html);
const list = m.querySelector('#items-list');
if (items.length === 0) {
list.innerHTML = '<div style="color:var(--ink-2);text-align:center;padding:20px">No items.</div>';
}
items.forEach(([id, qty]) => {
const it = ITEMS[id];
if (!it) return;
const row = document.createElement('div');
row.className = 'item-row';
row.innerHTML = `<div>
<div class="item-name">${it.name}</div>
<div class="item-desc">${it.desc}</div>
</div><div class="item-qty">×${qty}</div>`;
list.appendChild(row);
});
},
openBestiary() {
this.closeModal();
const html = `<h2>Bestiary</h2>
<div style="color:var(--ink-1);font-size:12px;margin-bottom:14px">Discovered: ${Game.caught.size} / ${CREATURE_IDS.length}</div>
<div class="bestiary-grid" id="bestiary-grid"></div>`;
const m = this.openModal(html, true);
const grid = m.querySelector('#bestiary-grid');
// Group: base creatures first, then transformations
const ordered = [...CREATURE_IDS].sort((a, b) => {
const sa = CREATURES[a], sb = CREATURES[b];
return (sa.isTransformation ? 1 : 0) - (sb.isTransformation ? 1 : 0);
});
for (const id of ordered) {
const s = CREATURES[id];
const known = Game.seen.has(id);
const caught = Game.caught.has(id);
const card = document.createElement('div');
card.className = 'bestiary-card ' + (caught ? 'caught' : known ? 'seen' : 'unknown');
const canvas = document.createElement('canvas');
canvas.className = 'bc-mini';
canvas.width = 60; canvas.height = 60;
card.appendChild(canvas);
const name = document.createElement('div');
name.className = 'bc-name';
name.textContent = known ? s.name : '???';
card.appendChild(name);
if (known) {
const cctx = canvas.getContext('2d');
drawCreatureArt(cctx, id, 30, 30, 26);
card.addEventListener('click', () => {
this.openBestiaryDetail(id);
});
} else {
const cctx = canvas.getContext('2d');
cctx.fillStyle = '#444';
cctx.beginPath();
cctx.arc(30, 30, 20, 0, Math.PI*2);
cctx.fill();
cctx.fillStyle = '#222';
cctx.font = '20px sans-serif';
cctx.textAlign = 'center';
cctx.fillText('?', 30, 38);
}
grid.appendChild(card);
}
},
openBestiaryDetail(id) {
const s = CREATURES[id];
const caught = Game.caught.has(id);
const html = `
<h2>${s.name}${s.isTransformation ? ' ★' : ''}</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px">
<div>
<canvas id="bd-art" width="160" height="160" style="background:rgba(0,0,0,.3);border-radius:14px;display:block;margin:0 auto"></canvas>
<p style="color:var(--ink-1);font-style:italic;margin-top:12px;font-size:13px;line-height:1.5">${s.lore}</p>
</div>
<div>
<h3>Profile</h3>
<div style="display:grid;grid-template-columns:auto 1fr;gap:6px 14px;font-size:13px">
<span style="color:var(--ink-1)">Type</span><span style="color:var(--lumen)">${s.types.join(' / ')}</span>
<span style="color:var(--ink-1)">Form</span><span>${s.isTransformation ? 'Transformed' : 'Base'}</span>
<span style="color:var(--ink-1)">Status</span><span style="color:${caught?'var(--good)':'var(--gold)'}">${caught ? 'Bound' : (Game.seen.has(id) ? 'Encountered' : 'Unknown')}</span>
</div>
<h3>Base Stats</h3>
<div style="display:grid;grid-template-columns:auto 1fr;gap:6px 14px;font-size:13px">
<span style="color:var(--ink-1)">HP</span><span>${s.base.hp}</span>
<span style="color:var(--ink-1)">ATK</span><span>${s.base.atk}</span>
<span style="color:var(--ink-1)">DEF</span><span>${s.base.def}</span>
<span style="color:var(--ink-1)">SPD</span><span>${s.base.spd}</span>
</div>
</div>
</div>
`;
const m = this.openModal(html);
const ac = m.querySelector('#bd-art');
const actx = ac.getContext('2d');
drawCreatureArt(actx, id, 80, 80, 70);
},
openMap() {
this.closeModal();
const html = `<h2>World Map</h2>
<div class="zone-tabs" id="zone-tabs"></div>
<div class="map-canvas-wrap"><canvas id="map-canvas"></canvas></div>
<p style="color:var(--ink-1);font-size:13px;margin-top:14px;line-height:1.6" id="map-desc"></p>`;
const m = this.openModal(html, true);
const tabs = m.querySelector('#zone-tabs');
const desc = m.querySelector('#map-desc');
let active = Game.currentZone;
const drawMapZone = (idx) => {
const cnv = m.querySelector('#map-canvas');
const c = cnv.getContext('2d');
const w = cnv.width = cnv.offsetWidth, h = cnv.height = cnv.offsetHeight;
const z = ZONES[idx];
const grad = c.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, z.bgGradient[0]);
grad.addColorStop(1, z.bgGradient[2]);
c.fillStyle = grad;
c.fillRect(0, 0, w, h);
// Draw a stylized map
const isUnlocked = idx < Game.unlockedZones;
c.fillStyle = isUnlocked ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.4)';
c.fillRect(10, 10, w-20, h-20);
// Place markers
const mx = w*0.5, my = h*0.45;
c.fillStyle = isUnlocked ? z.accent : '#555';
c.beginPath();
c.arc(mx, my, 30, 0, Math.PI*2);
c.fill();
c.fillStyle = '#fff';
c.font = 'bold 14px sans-serif';
c.textAlign = 'center';
c.fillText(z.name, mx, my+5);
// Zone #
c.font = '11px sans-serif';
c.fillStyle = '#888';
c.fillText('Zone ' + (idx+1), mx, my+22);
// Anchor
if (z.anchor) {
const defeated = Game.defeatedAnchors.has(`${idx}_${z.anchor.c}_${z.anchor.level}`);
c.fillStyle = defeated ? '#7cf2a8' : (isUnlocked ? '#ff5a7a' : '#444');
c.beginPath();
c.arc(mx + 80, my - 30, 10, 0, Math.PI*2);
c.fill();
c.fillStyle = '#fff';
c.font = '9px sans-serif';
c.fillText(defeated ? '✓' : 'A', mx + 80, my - 27);
}
// Lock icon
if (!isUnlocked) {
c.fillStyle = '#888';
c.font = '24px sans-serif';
c.fillText('🔒', w-40, 40);
}
// Connections
if (idx < 5) {
c.strokeStyle = isUnlocked ? 'rgba(127,255,229,0.5)' : 'rgba(255,255,255,0.1)';
c.lineWidth = 2;
c.setLineDash([5, 5]);
c.beginPath();
c.moveTo(mx + 30, my);
c.lineTo(w - 30, h/2);
c.stroke();
c.setLineDash([]);
}
desc.textContent = z.tagline + (z.anchor ? ` — Anchor: ${z.anchor.name}` : ' — No anchor here.');
};
for (let i = 0; i < ZONES.length; i++) {
const t = document.createElement('div');
t.className = 'zone-tab' + (i === active ? ' active' : '') + (i >= Game.unlockedZones ? ' locked' : '');
t.textContent = ZONES[i].name;
t.addEventListener('click', () => {
if (i >= Game.unlockedZones) return;
m.querySelectorAll('.zone-tab').forEach(x => x.classList.remove('active'));
t.classList.add('active');
drawMapZone(i);
});
tabs.appendChild(t);
}
drawMapZone(active);
},
openShrine() {
this.closeModal();
const html = `<h2>Shrine of ${ZONES[Game.currentZone].name}</h2>
<p style="color:var(--ink-1);font-size:13px;line-height:1.6">A small flame burns here. The lanterns do not speak, but the wind carries memory.</p>
<div class="choice-list" style="margin-top:18px">
<button class="choice-btn" data-act="heal">Rest by the flame
<span class="cb-cost">Heal all party to full HP</span>
</button>
<button class="choice-btn" data-act="shop">Browse the wandering merchant
<span class="cb-cost">Spend gold for supplies</span>
</button>
<button class="choice-btn" data-act="save">Commune with the shrine
<span class="cb-cost">Save your progress</span>
</button>
<button class="choice-btn" data-act="evolve">Evolution chamber
<span class="cb-cost">Transform eligible creatures</span>
</button>
</div>`;
const m = this.openModal(html);
m.querySelectorAll('[data-act]').forEach(b => {
b.addEventListener('click', () => {
const act = b.dataset.act;
if (act === 'heal') {
for (const c of Game.party) {
if (!c) continue;
c.hp = c.maxHp;
// Restore PP
if (!c.pp) c.pp = {};
for (const ab of c.abilityIds) {
c.pp[ab] = ABILITIES[ab]?.pp ?? 10;
}
// Clear status
c.status = null;
c.statMods = { atk: 0, def: 0, spd: 0 };
}
Audio.sfx.heal();
this.showToast('Your party feels restored.');
saveGame();
this.closeModal();
} else if (act === 'save') {
saveGame();
this.showToast('The shrine remembers.');
Audio.sfx.confirm();
} else if (act === 'evolve') {
this.openEvolutionList();
} else if (act === 'shop') {
this.openShop();
}
});
});
},
openEvolutionList() {
this.closeModal();
const eligible = [];
for (let i = 0; i < Game.party.length; i++) {
const c = Game.party[i];
if (!c) continue;
const s = CREATURES[c.species];
if (s.evolves && c.level >= s.evolves.at) {
eligible.push({ idx: i, c, item: s.evolves.item, target: s.evolves.to });
}
}
if (eligible.length === 0) {
this.openModal('<h2>Evolution Chamber</h2><p style="color:var(--ink-1)">No creatures are ready to transform yet. Train them further.</p>');
return;
}
let html = '<h2>Evolution Chamber</h2><div class="choice-list">';
for (const e of eligible) {
const has = Game.inventory[e.item] && Game.inventory[e.item] > 0;
html += `<button class="choice-btn" data-evo="${e.idx}">
<strong>${e.c.name}</strong> → <span style="color:var(--lumen)">${CREATURES[e.target].name}</span>
<span class="cb-cost">${ITEMS[e.item]?.name || e.item} ${has ? '✓' : '✗ (need ' + (Game.inventory[e.item] || 0) + ')'}</span>
</button>`;
}
html += '</div>';
const m = this.openModal(html);
m.querySelectorAll('[data-evo]').forEach(b => {
b.addEventListener('click', () => {
const idx = parseInt(b.dataset.evo);
const c = Game.party[idx];
const s = CREATURES[c.species];
if (Game.inventory[s.evolves.item] && Game.inventory[s.evolves.item] > 0) {
Game.inventory[s.evolves.item]--;
this.evolveCreature(idx);
this.closeModal();
} else {
this.showToast('You lack the catalyst.');
}
});
});
},
openShop() {
this.closeModal();
const stock = [
{ id: 'wrappingshard', qty: 3 }, { id: 'wovenlattice', qty: 2 },
{ id: 'mossdrop', qty: 5 }, { id: 'dawnvial', qty: 3 }, { id: 'elixirlume', qty: 1 },
{ id: 'wakebell', qty: 1 }, { id: 'lurepebble', qty: 2 }, { id: 'wardchime', qty: 2 },
{ id: 'embercatalyst', qty: 1 }, { id: 'lumencatalyst', qty: 1 },
{ id: 'tidalcatalyst', qty: 1 }, { id: 'floracatalyst', qty: 1 },
{ id: 'cragcatalyst', qty: 1 }, { id: 'shadecatalyst', qty: 1 },
{ id: 'prismcatalyst', qty: 1 }
];
let html = `<h2>Wandering Merchant</h2>
<p style="color:var(--gold);font-size:14px;margin-bottom:14px">Gold: ${Game.gold} ◆</p>
<div class="choice-list">`;
for (const s of stock) {
const it = ITEMS[s.id];
if (!it || !it.price) continue;
html += `<button class="choice-btn" data-buy="${s.id}">
<strong>${it.name}</strong> — <span style="color:var(--gold)">${it.price}◆</span>
<span class="cb-cost">${it.desc}</span>
</button>`;
}
html += '</div>';
const m = this.openModal(html);
m.querySelectorAll('[data-buy]').forEach(b => {
b.addEventListener('click', () => {
const id = b.dataset.buy;
const it = ITEMS[id];
if (Game.gold >= it.price) {
Game.gold -= it.price;
Game.inventory[id] = (Game.inventory[id] || 0) + 1;
Audio.sfx.confirm();
this.showToast(`Bought ${it.name}.`);
this.openShop(); // refresh
} else {
this.showToast('Not enough gold.');
Audio.sfx.cancel();
}
});
});
},
openStarterSelect() {
this.closeModal();
const html = `<h2>Choose Your First Companion</h2>
<p style="color:var(--ink-1);font-size:13px;line-height:1.6">The lantern calls for a partner. Each path sings in a different key.</p>
<div class="starter-grid" id="starter-grid"></div>`;
const m = this.openModal(html, true);
const grid = m.querySelector('#starter-grid');
for (const id of STARTERS) {
const s = CREATURES[id];
const card = document.createElement('div');
card.className = 'starter-card';
const canvas = document.createElement('canvas');
canvas.className = 'sc-art';
canvas.width = 120; canvas.height = 120;
card.appendChild(canvas);
const cctx = canvas.getContext('2d');
drawCreatureArt(cctx, id, 60, 60, 56);
const name = document.createElement('div');
name.className = 'sc-name';
name.textContent = s.name;
card.appendChild(name);
const type = document.createElement('div');
type.className = 'sc-type';
type.textContent = s.types.join(' / ');
card.appendChild(type);
const lore = document.createElement('div');
lore.className = 'sc-lore';
lore.textContent = s.lore;
card.appendChild(lore);
card.addEventListener('click', () => {
const c = makeCreature(id, 5);
Game.party = [c];
Game.seen.add(id);
Game.caught.add(id);
initInventory();
saveGame();
Game.state = 'tutorial';
this.closeModal();
startTutorial();
});
grid.appendChild(card);
}
}
};
// ---------- Exploration Logic ----------
const Exploration = {
encounterSteps: 0,
tryMove(dx, dy) {
if (Game.state !== 'exploration' || Game.menuOpen || Game.paused) return;
const m = Game.zoneMap;
const nx = Game.playerX + dx;
const ny = Game.playerY + dy;
if (nx < 0 || ny < 0 || nx >= m.w || ny >= m.h) return;
const t = m.tiles[ny][nx];
if (t === 8 || t === 3 || t === 4 || t === 2) return; // walls/blocks/water
Game.playerX = nx; Game.playerY = ny;
Game.stepsTaken++;
Game.playerStep = 6;
if (dx > 0) Game.playerDir = 'right';
else if (dx < 0) Game.playerDir = 'left';
else if (dy < 0) Game.playerDir = 'up';
else if (dy > 0) Game.playerDir = 'down';
Audio.sfx.step();
// Check tile
if (t === 5) {
// Shrine
UI.openShrine();
return;
} else if (t === 6) {
// Anchor
this.startAnchor();
return;
} else if (t === 9) {
// Portal
this.openPortal();
return;
}
// Random encounter
const z = ZONES[Game.currentZone];
if (t === 1 || t === 0 || t === 7) {
// Lower rate on path
const rate = z.encounterRate * (t === 0 ? 0.5 : 1);
this.encounterSteps++;
if (this.encounterSteps >= 8 && Math.random() < rate) {
this.encounterSteps = 0;
this.startWildEncounter();
}
}
},
startWildEncounter() {
const z = ZONES[Game.currentZone];
// Pick a creature
const total = z.encounterPool.reduce((s, p) => s + p.w, 0);
let r = Math.random() * total;
let species = z.encounterPool[0].c;
for (const p of z.encounterPool) {
r -= p.w;
if (r <= 0) { species = p.c; break; }
}
let lvl = randInt(z.levelRange[0], z.levelRange[1]);
// Post-game: harder spawns (higher levels)
if (Game.postGame) {
lvl = Math.min(MAX_LEVEL, lvl + 8 + Math.floor(Math.random() * 5));
}
const isFirst = Game.totalEncounters === 0;
Game.state = 'battle';
Battle.start({ enemies: [{ species, level: lvl, isAnchor: false }] });
showBattleUI();
Game.totalEncounters++;
if (isFirst) {
setTimeout(() => {
UI.showDialogue('Wisp', 'A wild creature appears! Press 1 to choose Fight, or click the button. Pick an ability. The stronger its type advantage, the more it hurts.', () => {
// do nothing, battle continues
});
}, 1500);
}
},
startAnchor() {
const z = ZONES[Game.currentZone];
if (!z.anchor) return;
if (Game.defeatedAnchors.has(`${Game.currentZone}_${z.anchor.c}_${z.anchor.level}`)) {
UI.showToast('The anchor has been quieted. The path is open.');
return;
}
UI.showDialogue('The Anchor', z.anchor.name + ' stirs. The hollows gather close.', () => {
Game.state = 'battle';
Battle.start({ enemies: [{ species: z.anchor.c, level: z.anchor.level, isAnchor: true }] });
showBattleUI();
Game.totalEncounters++;
});
},
openPortal() {
// Build travel choices
const unlocked = [];
for (let i = 0; i < ZONES.length; i++) {
if (i < Game.unlockedZones) unlocked.push(i);
}
let html = `<h2>Path of the Hollow</h2>
<p style="color:var(--ink-1);font-size:13px;line-height:1.6">A small rift in the air, humming with a color you've seen before.</p>
<div class="choice-list" style="margin-top:14px">`;
for (const idx of unlocked) {
const z = ZONES[idx];
const cur = idx === Game.currentZone;
html += `<button class="choice-btn" data-zone="${idx}" ${cur?'disabled style="opacity:.5"':''}>
<strong style="color:${z.accent}">${z.name}</strong>
<span class="cb-cost">${cur ? 'Current zone' : z.tagline}</span>
</button>`;
}
html += `</div>`;
const m = UI.openModal(html);
m.querySelectorAll('[data-zone]').forEach(b => {
b.addEventListener('click', () => {
const idx = parseInt(b.dataset.zone);
if (idx === Game.currentZone) return;
Game.currentZone = idx;
Game.zoneMap = buildZoneMap(idx);
Game.playerX = Game.zoneMap.startX;
Game.playerY = Game.zoneMap.startY;
Exploration.encounterSteps = 0;
updateHUD();
saveGame();
UI.closeModal();
Audio.sfx.confirm();
UI.showToast(`Arrived at ${ZONES[idx].name}.`);
});
});
}
};
// ---------- Battle UI ----------
function showBattleUI() {
// Hide exploration HUD, show battle
document.getElementById('hud')?.style.setProperty('display', 'none');
document.getElementById('touch-controls')?.style.setProperty('display', 'none');
UI.closeModal();
// Build battle UI
const ui = document.getElementById('ui-layer');
let battle = document.getElementById('battle-screen');
if (!battle) {
battle = document.createElement('div');
battle.id = 'battle-screen';
battle.innerHTML = `<div class="battle-bottom">
<div id="battle-info"></div>
<div id="battle-menu-area"></div>
</div>`;
ui.appendChild(battle);
}
battle.classList.add('show');
renderBattleUI();
}
function hideBattleUI() {
const b = document.getElementById('battle-screen');
if (b) b.classList.remove('show');
document.getElementById('hud')?.style.removeProperty('display');
if ('ontouchstart' in window) document.getElementById('touch-controls')?.style.removeProperty('display');
}
function renderBattleUI() {
const info = document.getElementById('battle-info');
if (!info) return;
// Enemy info
const e = Battle.enemies[0];
if (e) {
info.innerHTML = `
<div class="battle-enemy-info">
<div class="battle-name"><strong>${e.name}</strong><span class="battle-level">Lv${e.level}</span></div>
<div class="hp-bar-wrap"><div class="hp-bar ${e.hp/e.maxHp<0.2?'critical':e.hp/e.maxHp<0.5?'low':''}" style="width:${(e.hp/e.maxHp*100)}%"></div></div>
<div class="hp-text">${Math.floor(e.hp)} / ${e.maxHp}</div>
</div>
<div class="battle-party-info" id="party-info"></div>
`;
const pi = info.querySelector('#party-info');
Battle.party.forEach((c, i) => {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;max-width:280px;justify-content:flex-end;cursor:pointer';
row.innerHTML = `
<span style="font-size:12px;${i===Battle.activeIdx?'color:var(--lumen)':'color:var(--ink-1)'};min-width:80px;text-align:right">${c.name} Lv${c.level}</span>
<div class="hp-bar-wrap" style="width:120px"><div class="hp-bar ${c.hp/c.maxHp<0.2?'critical':c.hp/c.maxHp<0.5?'low':''}" style="width:${(c.hp/c.maxHp*100)}%"></div></div>
`;
row.addEventListener('click', () => switchParty(i));
pi.appendChild(row);
});
}
// Menu
const menu = document.getElementById('battle-menu-area');
renderBattleMenu(menu);
}
function renderBattleMenu(menu) {
if (!menu) return;
if (Battle.state === 'menu') {
menu.innerHTML = `<div class="battle-menu">
<button class="battle-btn" data-act="fight">⚔ Fight</button>
<button class="battle-btn" data-act="bag">🎒 Bag</button>
<button class="battle-btn" data-act="party">✦ Party</button>
<button class="battle-btn" data-act="run">⤴ Run</button>
</div>`;
menu.querySelectorAll('[data-act]').forEach(b => {
b.addEventListener('click', () => Battle.playerAction(b.dataset.act));
});
} else if (Battle.state === 'ability') {
const p = Battle.party[Battle.activeIdx];
const abilities = p.abilityIds;
menu.innerHTML = `<div class="battle-menu" style="grid-template-columns:repeat(2,1fr);max-width:none">
${abilities.map((ab, i) => {
const a = ABILITIES[ab];
const cur = p.pp?.[ab] ?? 0;
const max = a.pp;
const noPP = cur <= 0;
return `<button class="battle-btn" data-ab="${ab}" ${noPP?'disabled':''}>${a.name}<span class="pp">PWR ${a.power} · ${cur}/${max}</span></button>`;
}).join('')}
<button class="battle-btn" data-ab="back" style="grid-column:span 2;background:rgba(255,90,122,.1)">← Back</button>
</div>`;
menu.querySelectorAll('[data-ab]').forEach(b => {
b.addEventListener('click', () => {
if (b.dataset.ab === 'back') { Battle.state = 'menu'; renderBattleMenu(menu); }
else Battle.useAbility(b.dataset.ab);
});
});
} else if (Battle.state === 'bag') {
const items = Object.entries(Game.inventory).filter(([k, v]) => v > 0 && (ITEMS[k]?.type === 'collection' || ITEMS[k]?.type === 'heal' || ITEMS[k]?.type === 'revive'));
menu.innerHTML = `<div class="battle-menu" style="grid-template-columns:1fr;max-width:280px;margin:0 auto">
${items.length === 0 ? '<div style="color:var(--ink-2);text-align:center;padding:12px">No usable items.</div>' : ''}
${items.map(([id, qty]) => {
const it = ITEMS[id];
return `<button class="battle-btn" data-item="${id}">${it.name}<span class="pp">×${qty}</span></button>`;
}).join('')}
<button class="battle-btn" data-item="back" style="background:rgba(255,90,122,.1)">← Back</button>
</div>`;
menu.querySelectorAll('[data-item]').forEach(b => {
b.addEventListener('click', () => {
if (b.dataset.item === 'back') { Battle.state = 'menu'; renderBattleMenu(menu); }
else Battle.useItem(b.dataset.item);
});
});
} else if (Battle.state === 'party') {
menu.innerHTML = `<div class="battle-menu" style="grid-template-columns:1fr;max-width:340px;margin:0 auto">
${Battle.party.map((c, i) => `
<button class="battle-btn" data-p="${i}" ${c.hp<=0?'disabled':''} ${i===Battle.activeIdx?'style="border-color:var(--lumen)"':''}>
${c.name} Lv${c.level} — ${Math.floor(c.hp)}/${c.maxHp} HP ${i===Battle.activeIdx?'(active)':''}
</button>
`).join('')}
<button class="battle-btn" data-p="back" style="background:rgba(255,90,122,.1)">← Back</button>
</div>`;
menu.querySelectorAll('[data-p]').forEach(b => {
b.addEventListener('click', () => {
if (b.dataset.p === 'back') { Battle.state = 'menu'; renderBattleMenu(menu); }
else Battle.switchParty(parseInt(b.dataset.p));
});
});
} else {
// Anim/message - hide menu
menu.innerHTML = '';
}
}
function switchParty(idx) {
if (Battle.state !== 'menu') return;
if (idx === Battle.activeIdx) return;
if (Battle.party[idx].hp <= 0) return;
Battle.activeIdx = idx;
Battle.addLog(`Switched to ${Battle.party[idx].name}!`);
Battle.afterAction();
renderBattleUI();
}
function updateBattleUI() {
renderBattleUI();
// Floating message
if (Battle.message) {
let msgEl = document.getElementById('battle-message');
if (!msgEl) {
msgEl = document.createElement('div');
msgEl.id = 'battle-message';
document.getElementById('ui-layer').appendChild(msgEl);
}
msgEl.innerHTML = Battle.message;
}
}
// ---------- HUD ----------
function buildHUD() {
if (document.getElementById('hud')) {
updateHUD();
return;
}
const ui = document.getElementById('ui-layer');
ui.innerHTML = `
<div id="hud">
<div class="hud-zone">Zone ${Game.currentZone+1}<strong>${ZONES[Game.currentZone]?.name || 'Unknown'}</strong></div>
<div class="hud-right">
<button class="hud-btn tooltip" data-tip="Party" id="btn-party">✦</button>
<button class="hud-btn tooltip" data-tip="Bag" id="btn-bag">▣</button>
<button class="hud-btn tooltip" data-tip="Bestiary" id="btn-bestiary">◉</button>
<button class="hud-btn tooltip" data-tip="Map" id="btn-map">◈</button>
<button class="hud-btn tooltip" data-tip="Save" id="btn-save">⊕</button>
<button class="hud-btn tooltip" data-tip="Sound" id="btn-sound">♪</button>
<button class="hud-btn tooltip" data-tip="Menu" id="btn-menu">≡</button>
</div>
</div>
<div id="touch-controls">
<div class="touch-pad">
<div class="touch-btn up" data-dir="up">▲</div>
<div class="touch-btn down" data-dir="down">▼</div>
<div class="touch-btn left" data-dir="left">◀</div>
<div class="touch-btn right" data-dir="right">▶</div>
</div>
</div>
`;
document.getElementById('btn-party').addEventListener('click', () => UI.openPartyMenu());
document.getElementById('btn-bag').addEventListener('click', () => UI.openBagMenu());
document.getElementById('btn-bestiary').addEventListener('click', () => UI.openBestiary());
document.getElementById('btn-map').addEventListener('click', () => UI.openMap());
document.getElementById('btn-save').addEventListener('click', () => { saveGame(); UI.showToast('Progress saved.'); Audio.sfx.confirm(); });
document.getElementById('btn-sound').addEventListener('click', () => {
Game.muted = !Game.muted;
Audio.setMuted(Game.muted);
document.getElementById('btn-sound').textContent = Game.muted ? '✕' : '♪';
document.getElementById('btn-sound').style.color = Game.muted ? 'var(--bad)' : '';
Audio.sfx.menu();
});
document.getElementById('btn-menu').addEventListener('click', () => {
if (UI.currentMenu === 'pause') UI.closeModal();
else openPauseMenu();
});
document.querySelectorAll('.touch-btn').forEach(b => {
b.addEventListener('touchstart', e => { e.preventDefault(); const d = b.dataset.dir; moveFromDir(d); });
b.addEventListener('mousedown', e => { e.preventDefault(); const d = b.dataset.dir; moveFromDir(d); });
});
}
function openPauseMenu() {
UI.currentMenu = 'pause';
const html = `<h2>Menu</h2>
<div class="choice-list">
<button class="choice-btn" data-pm="resume">Resume
<span class="cb-cost">Close this menu</span>
</button>
<button class="choice-btn" data-pm="save">Save
<span class="cb-cost">Write your progress</span>
</button>
<button class="choice-btn" data-pm="load">Title Screen
<span class="cb-cost">Return to title (saves automatically)</span>
</button>
<button class="choice-btn" data-pm="erase">Erase Save
<span class="cb-cost">Start a new binding</span>
</button>
</div>`;
const m = UI.openModal(html);
m.querySelectorAll('[data-pm]').forEach(b => {
b.addEventListener('click', () => {
const act = b.dataset.pm;
if (act === 'resume') { UI.closeModal(); }
else if (act === 'save') { saveGame(); UI.showToast('Saved.'); Audio.sfx.confirm(); UI.closeModal(); }
else if (act === 'load') { saveGame(); UI.closeModal(); showTitle(); }
else if (act === 'erase') {
if (confirm('Erase your save? This cannot be undone.')) {
deleteSave();
UI.closeModal();
showTitle();
}
}
});
});
}
function moveFromDir(d) {
if (d === 'up') Exploration.tryMove(0, -1);
else if (d === 'down') Exploration.tryMove(0, 1);
else if (d === 'left') Exploration.tryMove(-1, 0);
else if (d === 'right') Exploration.tryMove(1, 0);
}
function updateHUD() {
const z = ZONES[Game.currentZone];
const hudZone = document.querySelector('.hud-zone');
if (hudZone && z) {
hudZone.innerHTML = `Zone ${Game.currentZone+1}<strong>${z.name}</strong>`;
}
}
// ---------- Tutorial ----------
function startTutorial() {
Game.state = 'tutorial';
Game.tutorialStep = 0;
buildHUD();
Game.zoneMap = buildZoneMap(0);
Game.playerX = Game.zoneMap.startX;
Game.playerY = Game.zoneMap.startY;
Audio.playMusic('overworld');
UI.showDialogue('Wisp', 'Welcome to Aethermere, Binder. The lantern hears you, but the world is still asleep. Try moving with the WASD or arrow keys.', () => {
Game.tutorialStep = 1;
Game.state = 'exploration';
});
}
// ---------- Title Screen ----------
function showTitle() {
Game.state = 'title';
Game.hasSave = hasSave();
document.getElementById('ui-layer').innerHTML = `
<div id="title-screen">
<div id="title-content">
<div class="title-mark">AETHERMERE</div>
<div class="title-sub">Whispers of the Hollow</div>
<div class="title-tagline">A world broken into color. Six zones. Four anchors. One lantern in your palm.</div>
<button class="title-btn primary" id="btn-new">✦ New Binding</button>
${Game.hasSave ? '<button class="title-btn" id="btn-cont">◈ Continue</button>' : ''}
<button class="title-btn" id="btn-erase" style="font-size:11px;opacity:.5;margin-top:6px">Erase Save</button>
</div>
<div class="title-credits">v1.0 · Original creation · No external assets</div>
</div>
`;
Audio.stopMusic();
Audio.playMusic('title');
document.getElementById('btn-new').addEventListener('click', () => {
Audio.resume();
Audio.sfx.confirm();
UI.openStarterSelect();
});
if (Game.hasSave) {
document.getElementById('btn-cont').addEventListener('click', () => {
Audio.resume();
Audio.sfx.confirm();
const data = loadGame();
if (data) {
Game.zoneMap = buildZoneMap(Game.currentZone);
// Validate player position
const m = Game.zoneMap;
if (Game.playerX < 0 || Game.playerY < 0 || Game.playerX >= m.w || Game.playerY >= m.h) {
Game.playerX = m.startX; Game.playerY = m.startY;
}
const t = m.tiles[Game.playerY] && m.tiles[Game.playerX] ? m.tiles[Game.playerY][Game.playerX] : null;
if (t === null || t === undefined || t === 8 || t === 3 || t === 4 || t === 2) {
Game.playerX = m.startX; Game.playerY = m.startY;
}
// Ensure all creatures have pp
for (const c of Game.party) {
if (!c) continue;
if (!c.pp) c.pp = {};
for (const ab of c.abilityIds) {
if (c.pp[ab] == null) c.pp[ab] = ABILITIES[ab]?.pp ?? 10;
}
}
buildHUD();
Game.state = 'exploration';
Audio.playMusic('overworld');
UI.showToast('Welcome back, Binder.');
}
});
}
document.getElementById('btn-erase')?.addEventListener('click', () => {
if (confirm('Erase your save? This cannot be undone.')) {
deleteSave();
showTitle();
}
});
}
// ---------- Input ----------
const keys = {};
window.addEventListener('keydown', e => {
const k = e.key.toLowerCase();
keys[k] = true;
if (Game.state === 'exploration' || Game.state === 'tutorial') {
if (k === 'w' || k === 'arrowup') Exploration.tryMove(0, -1);
else if (k === 's' || k === 'arrowdown') Exploration.tryMove(0, 1);
else if (k === 'a' || k === 'arrowleft') Exploration.tryMove(-1, 0);
else if (k === 'd' || k === 'arrowright') Exploration.tryMove(1, 0);
else if (k === 'e') {
// Interact with tile
const m = Game.zoneMap;
const t = m.tiles[Game.playerY][Game.playerX];
if (t === 5) UI.openShrine();
else if (t === 6) Exploration.startAnchor();
else if (t === 9) Exploration.openPortal();
}
else if (k === 'escape') {
if (UI.currentMenu === 'pause') UI.closeModal();
else openPauseMenu();
}
} else if (Game.state === 'battle' || Battle.active) {
if (k === '1') Battle.playerAction('fight');
else if (k === '2') Battle.playerAction('bag');
else if (k === '3') Battle.playerAction('party');
else if (k === '4') Battle.playerAction('run');
else if (k === 'escape') {
if (Battle.state === 'ability') Battle.state = 'menu';
else if (Battle.state === 'bag') Battle.state = 'menu';
else if (Battle.state === 'party') Battle.state = 'menu';
renderBattleUI();
}
} else if (k === 'enter' || k === ' ') {
if (Game.state === 'title') {
document.getElementById('btn-new')?.click();
}
}
});
window.addEventListener('keyup', e => { keys[e.key.toLowerCase()] = false; });
// ---------- Main Loop ----------
let lastTime = 0;
let uiUpdateAccum = 0;
function loop(time) {
const dt = Math.min(0.05, (time - lastTime) / 1000 || 0);
lastTime = time;
// Update
if (Game.state === 'exploration' || Game.state === 'tutorial') {
if (Game.playerStep > 0) Game.playerStep -= dt * 30;
}
if (Game.state === 'battle' && Battle.active) {
if (Battle.shake > 0) Battle.shake = Math.max(0, Battle.shake - dt * 30);
// Render UI less frequently to avoid flicker
}
updateParticles(dt);
// Draw
ctx.save();
let shakeX = 0, shakeY = 0;
if (Game.state === 'battle' && Battle.shake > 0) {
shakeX = (Math.random() - 0.5) * Battle.shake;
shakeY = (Math.random() - 0.5) * Battle.shake;
}
ctx.translate(shakeX, shakeY);
if (Game.state === 'title') {
// Animated bg
const t = time / 1000;
const grad = ctx.createLinearGradient(0, 0, 0, viewH);
grad.addColorStop(0, `hsl(${(t*10)%360}, 30%, 15%)`);
grad.addColorStop(1, '#07060f');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, viewW, viewH);
// Stars
for (let i = 0; i < 80; i++) {
const x = (i * 137.5 + t * 5) % viewW;
const y = (i * 71.3) % viewH;
const s = (Math.sin(t + i) * 0.5 + 0.5) * 2;
ctx.fillStyle = `rgba(255,255,255,${0.4 + Math.sin(t*2 + i)*0.3})`;
ctx.beginPath();
ctx.arc(x, y, s, 0, Math.PI*2);
ctx.fill();
}
} else if (Game.state === 'battle' && Battle.active) {
drawBattle(dt);
} else if (Game.state === 'exploration' || Game.state === 'tutorial') {
drawOverworld(dt);
}
ctx.restore();
// Update battle UI periodically
if (Game.state === 'battle' && Battle.active) {
uiUpdateAccum += dt;
if (uiUpdateAccum > 0.15) {
uiUpdateAccum = 0;
updateBattleUI();
}
}
requestAnimationFrame(loop);
}
// ---------- Init ----------
function init() {
resize();
Audio.init();
showTitle();
requestAnimationFrame(loop);
}
// Resize handling
window.addEventListener('orientationchange', () => setTimeout(resize, 100));
window.addEventListener('resize', resize);
// First user gesture to enable audio
const enableAudio = () => {
Audio.resume();
window.removeEventListener('click', enableAudio);
window.removeEventListener('touchstart', enableAudio);
window.removeEventListener('keydown', enableAudio);
};
window.addEventListener('click', enableAudio);
window.addEventListener('touchstart', enableAudio);
window.addEventListener('keydown', enableAudio);
// Start
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"minimax/minimax-m3","provider":"openrouter","persona":"middle","sessionId":"009aa85d-fae4-4d0d-9021-9d1ac26253b8","tokensIn":6168736,"tokensOut":95858,"tokensTotal":6264594,"turns":80,"toolCalls":79,"failedToolCalls":1,"timestamp":"2026-07-12T21:22:14.341Z"} -->