Creature Collection
Deepseek v4 Pro · composer

Browser check: Artifact runtime error. This is a technical check, not a quality review.
Reviews & generation details
Reviews
Human review · Jul 17, 2026 · rubric 1
Historical path-based review. Association with these exact bytes is unverified.
- Visual design
- 2 / 5
- UX / feel
- 3 / 5
- Works
- 3 / 5
- Prompt spirit
- 3 / 5
- Delight
- 4 / 5
Simple graphics but extremely functional but unbalanced gameplay
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 96,701 bytes · SHA-256 a479edb77b46
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ECHOES OF THE AETHERIC VEIL</title>
<style>
/* ── css: core ── */
:root {
--bg: #0a0a12;
--surface: #12162a;
--surface2: #1a1f3a;
--gold: #d4a843;
--gold2: #f0d070;
--text: #d8d8e8;
--text2: #a0a0c0;
--solar: #f4843c;
--lunar: #7b8fce;
--verdant: #5cb870;
--storm: #c8b432;
--tidal: #4aacc8;
--void: #8b4fc0;
--primal: #c08060;
--danger: #e05050;
--heal: #50d870;
--shield: #60a0e0;
--font: 'Segoe UI', system-ui, sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
overflow: hidden;
width: 100vw;
height: 100vh;
user-select: none;
-webkit-user-select: none;
}
#app {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
canvas {
display: block;
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
image-rendering: auto;
}
#ui-layer {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 10;
}
#ui-layer > * { pointer-events: auto; }
.hud {
position: absolute;
bottom: 12px; left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 20;
}
.hud-btn {
width: 44px; height: 44px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.2);
background: rgba(18,22,42,0.85);
color: var(--text);
font-size: 18px;
cursor: pointer;
backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center;
transition: all 0.2s;
}
.hud-btn:active { background: rgba(255,255,255,0.15); transform: scale(0.92); }
.dpad {
position: absolute;
bottom: 80px; left: 20px;
display: grid;
grid-template: 1fr 1fr 1fr / 1fr 1fr 1fr;
gap: 4px;
z-index: 20;
}
.dpad-btn {
width: 40px; height: 40px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(18,22,42,0.8);
color: var(--text);
font-size: 16px;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.dpad-btn:active { background: rgba(255,255,255,0.2); }
.dpad-center { visibility: hidden; }
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(6px);
z-index: 30;
display: flex; align-items: center; justify-content: center;
}
.glass-panel {
background: rgba(18,22,42,0.92);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 16px;
padding: 24px;
backdrop-filter: blur(12px);
box-shadow: 0 8px 40px rgba(0,0,0,0.5), 0 0 80px rgba(212,168,67,0.08);
max-height: 85vh;
overflow-y: auto;
color: var(--text);
}
.glass-panel h2 {
color: var(--gold);
font-size: 1.4em;
margin-bottom: 16px;
text-align: center;
letter-spacing: 2px;
text-shadow: 0 0 20px rgba(212,168,67,0.4);
}
.glass-panel button, .glass-panel .btn {
padding: 10px 20px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.06);
color: var(--text);
cursor: pointer;
font-family: var(--font);
font-size: 0.95em;
transition: all 0.2s;
}
.glass-panel button:hover, .glass-panel .btn:hover {
background: rgba(255,255,255,0.12);
border-color: var(--gold);
}
.glass-panel button.primary {
background: rgba(212,168,67,0.2);
border-color: var(--gold);
color: var(--gold2);
}
.toast {
position: absolute;
top: 20px; left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.85);
border: 1px solid var(--gold);
color: var(--gold2);
padding: 10px 24px;
border-radius: 20px;
font-size: 0.9em;
z-index: 50;
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.2s forwards;
pointer-events: none;
}
@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(-10px); } }
@keyframes toastOut { to { opacity: 0; transform: translateX(-50%) translateY(-10px); } }
.mute-btn {
position: absolute;
top: 12px; right: 12px;
width: 36px; height: 36px;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.2);
background: rgba(18,22,42,0.7);
color: var(--text);
font-size: 16px;
cursor: pointer;
z-index: 25;
display: flex; align-items: center; justify-content: center;
}
</style>
</head>
<body>
<div id="app">
<canvas id="game-canvas"></canvas>
<div id="ui-layer">
<button class="mute-btn" id="mute-btn" title="Toggle Sound">🔊</button>
<div class="dpad" id="dpad">
<div></div><button class="dpad-btn" data-dir="up">▲</button><div></div>
<button class="dpad-btn" data-dir="left">◀</button><div class="dpad-center"></div><button class="dpad-btn" data-dir="right">▶</button>
<div></div><button class="dpad-btn" data-dir="down">▼</button><div></div>
</div>
<div class="hud" id="hud">
<button class="hud-btn" id="btn-bag" title="Bag">🎒</button>
<button class="hud-btn" id="btn-team" title="Team">👥</button>
<button class="hud-btn" id="btn-dex" title="Encyclopedia">📖</button>
<button class="hud-btn" id="btn-map" title="Map">🗺️</button>
<button class="hud-btn" id="btn-menu" title="Menu">⚙️</button>
</div>
<div id="modal-container"></div>
<div id="toast-container"></div>
</div>
</div>
<script>
'use strict';
// ── const: AFFINITY ──
// ── AFFINITY TYPES ──
const AFFINITY = {
EMBER: { id: 0, name: "Ember", icon: "🔥", color: "#f4843c", desc: "Flame and radiance. Dominates Gloom, burns bright." },
GLOOM: { id: 1, name: "Gloom", icon: "🌑", color: "#7b8fce", desc: "Shadow and frost. Chills Grove into dormancy." },
GROVE: { id: 2, name: "Grove", icon: "🌿", color: "#5cb870", desc: "Nature and growth. Overgrows the blazing Ember." },
ZEPHYR: { id: 3, name: "Zephyr", icon: "⚡", color: "#c8b432", desc: "Wind and storm. Scatters both Ember and Grove." },
TIDE: { id: 4, name: "Tide", icon: "🌊", color: "#4aacc8", desc: "Water and flow. Drowns Zephyr and erodes Gloom." },
SHADE: { id: 5, name: "Shade", icon: "🌀", color: "#8b4fc0", desc: "Void-touched essence. Holds a subtle edge over all." },
TERRA: { id: 6, name: "Terra", icon: "⛰️", color: "#c08060", desc: "Primal stone. Endures all—except the Void's whisper." }
};
// ── const: TYPE_CHART ──
// Type effectiveness: attacker affinity id → defender affinity id → multiplier
const TYPE_CHART = buildTypeChart();
// ── function: buildTypeChart ──
function buildTypeChart() {
const m = {}; for (let i = 0; i < 7; i++) { m[i] = {}; for (let j = 0; j < 7; j++) m[i][j] = 1.0; }
// Ember(0) > Gloom(1) > Grove(2) > Ember(0)
m[0][1] = 2.0; m[1][2] = 2.0; m[2][0] = 2.0;
m[1][0] = 0.5; m[2][1] = 0.5; m[0][2] = 0.5;
// Zephyr(3) > Ember(0) & Grove(2)
m[3][0] = 2.0; m[3][2] = 2.0;
m[0][3] = 0.5; m[2][3] = 0.5;
// Tide(4) > Zephyr(3) & Gloom(1)
m[4][3] = 2.0; m[4][1] = 2.0;
m[3][4] = 0.5; m[1][4] = 0.5;
// Shade(5) beats all slightly
for (let j = 0; j < 7; j++) if (j !== 5) { m[5][j] = 1.3; m[j][5] = 0.85; }
// Terra(6) resists everything but weak to Shade
for (let j = 0; j < 7; j++) { if (j === 5) { m[6][j] = 0.5; m[j][6] = 2.0; } else if (j !== 6) { m[6][j] = 0.6; m[j][6] = 0.6; } }
return m;
}
// ── function: getEffectiveness ──
function getEffectiveness(atkAff, defAff) {
return TYPE_CHART[atkAff]?.[defAff] ?? 1.0;
}
// ── const: ABILITIES ──
// ── ABILITIES (40+ unique) ──
// Each: { name, power, accuracy, affinity, ppCost, effect, effectChance, desc }
const ABILITIES = [
// Ember abilities
{ id: 0, name:"Flare Burst", power:55, acc:95, aff:0, pp:25, effect:"burn", effCh:0.25, desc:"A searing blast that may leave a lingering burn." },
{ id: 1, name:"Inferno Lash", power:70, acc:90, aff:0, pp:15, effect:"burn", effCh:0.35, desc:"A whip of fire that lashes with high burn chance." },
{ id: 2, name:"Radiant Pulse", power:45, acc:100,aff:0, pp:20, effect:"heal", effCh:0.5, desc:"Releases healing light, restoring a portion of damage dealt." },
{ id: 3, name:"Solar Fang", power:65, acc:95, aff:0, pp:18, effect:"critUp", effCh:0.3, desc:"A blazing bite with increased critical potential." },
{ id: 4, name:"Cinder Rain", power:35, acc:90, aff:0, pp:20, effect:"multi", effCh:0.4, desc:"Hits 2-5 times with falling embers." },
{ id: 5, name:"Heatwave", power:80, acc:85, aff:0, pp:10, effect:"burn", effCh:0.2, desc:"A wave of superheated air that scorches all foes." },
// Gloom abilities
{ id: 6, name:"Frost Shard", power:50, acc:95, aff:1, pp:25, effect:"frost", effCh:0.25, desc:"A crystalline shard that may slow the target." },
{ id: 7, name:"Umbral Strike", power:65, acc:90, aff:1, pp:18, effect:"defDown",effCh:0.3, desc:"A shadowy blow that can weaken defenses." },
{ id: 8, name:"Chill Grasp", power:55, acc:100,aff:1, pp:20, effect:"frost", effCh:0.35, desc:"Icy tendrils grip the foe, halving their speed." },
{ id: 9, name:"Nightmare", power:70, acc:85, aff:1, pp:12, effect:"shock", effCh:0.3, desc:"Invades the mind with terror; may cause flinch." },
{ id:10, name:"Shadow Veil", power:0, acc:100,aff:1, pp:10, effect:"barrier",effCh:1.0, desc:"Wraps the user in shadow, deflecting the next attack." },
{ id:11, name:"Bitter Rime", power:60, acc:90, aff:1, pp:15, effect:"frost", effCh:0.3, desc:"Coats the battlefield in rime, slowing the target." },
// Grove abilities
{ id:12, name:"Thorn Volley", power:45, acc:95, aff:2, pp:22, effect:"multi", effCh:0.35, desc:"Launches 2-4 barbs in rapid succession." },
{ id:13, name:"Verdant Mend", power:0, acc:100,aff:2, pp:12, effect:"heal", effCh:1.0, desc:"Calls on nature to restore a flat amount of HP." },
{ id:14, name:"Root Snare", power:50, acc:90, aff:2, pp:18, effect:"spdDown",effCh:0.4, desc:"Entangling roots that reduce the foe's speed." },
{ id:15, name:"Petal Storm", power:60, acc:95, aff:2, pp:15, effect:"regen", effCh:0.3, desc:"A whirl of petals; may grant lingering regeneration." },
{ id:16, name:"Bramble Slam", power:75, acc:85, aff:2, pp:12, effect:"defDown",effCh:0.3, desc:"A heavy thorn-laden blow." },
// Zephyr abilities
{ id:17, name:"Shock Bolt", power:50, acc:95, aff:3, pp:22, effect:"shock", effCh:0.2, desc:"A lightning spark that may paralyze briefly." },
{ id:18, name:"Gale Cutter", power:65, acc:90, aff:3, pp:18, effect:"spdUp", effCh:0.3, desc:"Slicing winds that may boost the user's speed." },
{ id:19, name:"Thunderclap", power:80, acc:80, aff:3, pp:10, effect:"shock", effCh:0.35, desc:"A deafening boom; high power, moderate accuracy." },
{ id:20, name:"Cyclone Dash", power:55, acc:100,aff:3, pp:18, effect:"priority",effCh:1.0, desc:"Always strikes first with a wind-fueled charge." },
{ id:21, name:"Storm Surge", power:70, acc:90, aff:3, pp:14, effect:"burn", effCh:0.2, desc:"Static discharge so intense it can scorch." },
// Tide abilities
{ id:22, name:"Aqua Jet", power:55, acc:95, aff:4, pp:20, effect:"priority",effCh:1.0, desc:"A swift water strike that always goes first." },
{ id:23, name:"Tidal Crash", power:75, acc:88, aff:4, pp:14, effect:"defDown",effCh:0.25, desc:"A crashing wave that can crack defenses." },
{ id:24, name:"Soothing Mist", power:0, acc:100,aff:4, pp:10, effect:"regen", effCh:1.0, desc:"A gentle mist granting the user regeneration." },
{ id:25, name:"Riptide Pull", power:50, acc:90, aff:4, pp:16, effect:"spdDown",effCh:0.35, desc:"Drags the foe under, reducing their speed." },
{ id:26, name:"Geyser Blast", power:85, acc:80, aff:4, pp:8, effect:"burn", effCh:0.2, desc:"A scalding eruption from below." },
// Shade abilities
{ id:27, name:"Void Rift", power:60, acc:90, aff:5, pp:15, effect:"shock", effCh:0.25, desc:"Tears open a rift; the void's whisper may flinch." },
{ id:28, name:"Entropic Ray", power:55, acc:95, aff:5, pp:18, effect:"defDown",effCh:0.3, desc:"A beam of unraveling energy." },
{ id:29, name:"Nullify", power:0, acc:100,aff:5, pp:8, effect:"barrier",effCh:1.0, desc:"Briefly phases out of reality, blocking one hit." },
{ id:30, name:"Dread Pulse", power:70, acc:88, aff:5, pp:12, effect:"frost", effCh:0.2, desc:"A wave of existential cold." },
{ id:31, name:"Oblivion Fang", power:80, acc:85, aff:5, pp:8, effect:"burn", effCh:0.2, desc:"Bites with the crushing weight of nothingness." },
// Terra abilities
{ id:32, name:"Boulder Toss", power:55, acc:92, aff:6, pp:20, effect:"defUp", effCh:0.3, desc:"Hurls a chunk of earth; may bolster user's defense." },
{ id:33, name:"Earthen Wall", power:0, acc:100,aff:6, pp:10, effect:"barrier",effCh:1.0, desc:"Raises a stone bulwark to deflect one attack." },
{ id:34, name:"Seismic Slam", power:75, acc:88, aff:6, pp:12, effect:"defDown",effCh:0.3, desc:"A ground-shaking impact." },
{ id:35, name:"Crystal Shard", power:60, acc:95, aff:6, pp:16, effect:"frost", effCh:0.25, desc:"Fires a razor-sharp mineral splinter." },
{ id:36, name:"Ancient Roar", power:0, acc:100,aff:6, pp:8, effect:"atkUp", effCh:1.0, desc:"A resonant bellow that raises the user's attack." },
// Universal / hybrid
{ id:37, name:"Quick Strike", power:40, acc:100,aff:-1, pp:30, effect:"priority",effCh:1.0, desc:"A rapid, affinity-neutral blow that always goes first." },
{ id:38, name:"Focus", power:0, acc:100,aff:-1, pp:15, effect:"atkUp", effCh:1.0, desc:"Sharpen concentration; raises attack sharply." },
{ id:39, name:"Mend", power:0, acc:100,aff:-1, pp:15, effect:"heal", effCh:1.0, desc:"Simple restoration magic. Heals a flat amount." },
{ id:40, name:"Shatter", power:65, acc:90, aff:-1, pp:14, effect:"defDown",effCh:0.4, desc:"A brutal blow that often cracks defenses." },
{ id:41, name:"Energize", power:0, acc:100,aff:-1, pp:12, effect:"spdUp", effCh:1.0, desc:"A surge of energy that boosts speed." },
];
// ── const: CREATURES ──
// ── CREATURES (18 base + 9 evolved = 27 total) ──
// { id, name, lore, affinities[], baseStats{hp,atk,def,spd}, learnset[[abilityId, levelReq]], evolvesTo(id|null), evoLevel, evoItem }
const CREATURES = [
// STARTERS
{ id:0, name:"Solix", lore:"A tiny spark given form, drawn to warm hearts and bright dawns.", aff:[0], stats:{hp:42,atk:52,def:40,spd:55}, learnset:[[0,1],[37,4],[2,7],[3,12]], evolvesTo:1, evoLevel:16, evoItem:null },
{ id:1, name:"Solrex", lore:"Its mane ignites into a corona; a loyal protector of kindred souls.", aff:[0,3], stats:{hp:62,atk:78,def:58,spd:80}, learnset:[[0,1],[37,4],[2,7],[3,12],[5,18],[21,24]], evolvesTo:null, evoLevel:0, evoItem:null },
{ id:2, name:"Lunix", lore:"A wisp born from moonlight, shy but fiercely protective of dusk.", aff:[1], stats:{hp:40,atk:44,def:48,spd:58}, learnset:[[6,1],[37,4],[8,7],[10,12]], evolvesTo:3, evoLevel:16, evoItem:null },
{ id:3, name:"Lunumbra", lore:"It weaves shadows into cloaks; legends say it guards the boundary of sleep.", aff:[1,5], stats:{hp:58,atk:68,def:72,spd:85}, learnset:[[6,1],[37,4],[8,7],[10,12],[27,18],[30,24]], evolvesTo:null, evoLevel:0, evoItem:null },
{ id:4, name:"Sproutail", lore:"A sprout with a curious tail-flower; it hums while digging for roots.", aff:[2], stats:{hp:48,atk:42,def:52,spd:46}, learnset:[[12,1],[37,4],[39,7],[14,12]], evolvesTo:5, evoLevel:16, evoItem:null },
{ id:5, name:"Verdantusk",lore:"A gentle giant cloaked in moss; its tusks bloom with every spring rain.", aff:[2,6], stats:{hp:72,atk:68,def:76,spd:55}, learnset:[[12,1],[37,4],[39,7],[14,12],[16,18],[32,22]], evolvesTo:null, evoLevel:0, evoItem:null },
// ZONE 1 - Suncrest Plains
{ id:6, name:"Dustwhirl", lore:"A spiraling gust given consciousness; it dances across dry plains.", aff:[3], stats:{hp:38,atk:48,def:38,spd:65}, learnset:[[17,1],[37,4],[20,8],[41,12]], evolvesTo:null, evoLevel:0, evoItem:null },
{ id:7, name:"Chirpvine", lore:"A vine-creature that mimics birdsong to lure travelers toward sunlit groves.", aff:[2], stats:{hp:50,atk:45,def:55,spd:40}, learnset:[[12,1],[39,5],[14,9],[13,14]], evolvesTo:null, evoLevel:0, evoItem:null },
// ZONE 2 - Moonveil Thicket
{ id:8, name:"Shadeling", lore:"Born from the shadow between trees; it feeds on forgotten dreams.", aff:[1], stats:{hp:36,atk:50,def:36,spd:62}, learnset:[[6,1],[37,4],[9,8],[28,13]], evolvesTo:9, evoLevel:18, evoItem:null },
{ id:9, name:"Umbrawisp", lore:"A deeper darkness emerges, silent and watchful — a sentinel of the moonveil.", aff:[1,5], stats:{hp:54,atk:72,def:52,spd:88}, learnset:[[6,1],[9,8],[28,13],[30,20],[29,24]], evolvesTo:null, evoLevel:0, evoItem:null },
{ id:10,name:"Thornhide", lore:"Its bark-like skin bristles with needles; patient as the ancient trees.", aff:[2,1], stats:{hp:55,atk:55,def:60,spd:32}, learnset:[[12,1],[16,6],[14,10],[33,16]], evolvesTo:null, evoLevel:0, evoItem:null },
// ZONE 3 - Emberpeak Ridge
{ id:11,name:"Cindermaw", lore:"A living furnace that prowls the ashen slopes, always hungry for kindling.", aff:[0], stats:{hp:45,atk:60,def:42,spd:50}, learnset:[[0,1],[1,5],[4,10],[38,15]], evolvesTo:12, evoLevel:20, evoItem:null },
{ id:12,name:"Magmorax", lore:"Molten rock flows through its veins; its roar can trigger eruptions.", aff:[0], stats:{hp:68,atk:85,def:62,spd:65}, learnset:[[1,5],[4,10],[5,16],[3,20],[34,25]], evolvesTo:null, evoLevel:0, evoItem:null },
{ id:13,name:"Stormbeak", lore:"A raptor that rides lightning; its screech heralds the coming squall.", aff:[3,0], stats:{hp:48,atk:65,def:42,spd:72}, learnset:[[17,1],[20,6],[19,12],[21,18]], evolvesTo:14, evoLevel:22, evoItem:null },
{ id:14,name:"Tempestrix",lore:"Crowned in storm-clouds, it commands the sky's fury with each wingbeat.", aff:[3,0], stats:{hp:64,atk:82,def:58,spd:95}, learnset:[[17,1],[19,12],[21,18],[5,22],[18,26]], evolvesTo:null, evoLevel:0, evoItem:null },
// ZONE 4 - Tempest Coast
{ id:15,name:"Wavehopper",lore:"Skips across cresting waves with joy; salt-spray is its perfume.", aff:[4], stats:{hp:44,atk:48,def:46,spd:60}, learnset:[[22,1],[37,4],[25,9],[24,14]], evolvesTo:16, evoLevel:18, evoItem:null },
{ id:16,name:"Tidereaver",lore:"A force of the deep tide, carving cliffs with patient, relentless power.", aff:[4,3], stats:{hp:62,atk:72,def:64,spd:78}, learnset:[[22,1],[25,9],[24,14],[23,20],[26,25]], evolvesTo:null, evoLevel:0, evoItem:null },
{ id:17,name:"Galehound", lore:"Runs with the coastal wind; its howl bends the storm's path.", aff:[3], stats:{hp:46,atk:58,def:40,spd:68}, learnset:[[17,1],[20,6],[18,10],[41,15]], evolvesTo:null, evoLevel:0, evoItem:null },
// ZONE 5 - Drowned Caverns
{ id:18,name:"Glowfin", lore:"Its fins pulse with bioluminescent rhythm, illuminating the sunless depths.", aff:[4,2], stats:{hp:50,atk:44,def:52,spd:48}, learnset:[[22,1],[24,5],[13,10],[15,15]], evolvesTo:null, evoLevel:0, evoItem:null },
{ id:19,name:"Abysswalker",lore:"A silent prowler of the benthic dark, older than the caverns themselves.", aff:[4,1], stats:{hp:52,atk:62,def:48,spd:54}, learnset:[[22,1],[7,6],[25,11],[30,17]], evolvesTo:20, evoLevel:24, evoItem:null },
{ id:20,name:"Deephulk", lore:"Crushing pressure forged this titan; its steps echo through drowned halls.", aff:[4,1], stats:{hp:78,atk:82,def:68,spd:42}, learnset:[[7,6],[25,11],[30,17],[23,22],[31,28]], evolvesTo:null, evoLevel:0, evoItem:null },
// ZONE 6 - Voidspire Citadel
{ id:21,name:"Voidling", lore:"A mote of the great emptiness, curious and terrifying in equal measure.", aff:[5], stats:{hp:41,atk:55,def:41,spd:59}, learnset:[[27,1],[37,4],[28,9],[29,14]], evolvesTo:22, evoLevel:22, evoItem:null },
{ id:22,name:"Voidreaver",lore:"It harvests silence; where it passes, even echoes die.", aff:[5], stats:{hp:60,atk:80,def:58,spd:82}, learnset:[[27,1],[28,9],[29,14],[30,20],[31,26]], evolvesTo:null, evoLevel:0, evoItem:null },
{ id:23,name:"Terragon", lore:"Carved from the world's first mountain; it has watched eons pass in stillness.", aff:[6], stats:{hp:75,atk:62,def:78,spd:30}, learnset:[[32,1],[33,6],[34,12],[36,20],[35,24]], evolvesTo:null, evoLevel:0, evoItem:null },
// Extra wild creatures
{ id:24,name:"Chillmoth", lore:"Powdered frost trails from its wings; winter follows its migration.", aff:[1,4], stats:{hp:46,atk:42,def:48,spd:56}, learnset:[[6,1],[8,7],[11,14],[24,18]], evolvesTo:null, evoLevel:0, evoItem:null },
{ id:25,name:"Saltspark", lore:"A crackling fusion of brine and lightning; it darts between tide pools.", aff:[4,3], stats:{hp:44,atk:55,def:40,spd:70}, learnset:[[17,1],[22,5],[21,11],[19,17]], evolvesTo:null, evoLevel:0, evoItem:null },
{ id:26,name:"Coralspine",lore:"Living reef-fragments fused by a single will; beautiful yet unyielding.", aff:[2,4], stats:{hp:56,atk:48,def:62,spd:34}, learnset:[[12,1],[33,6],[14,10],[15,16]], evolvesTo:null, evoLevel:0, evoItem:null },
];
// ── const: CREATURE_BY_ID ──
const CREATURE_BY_ID = {};
// ── const: ITEMS ──
// Will be populated in main
// ── ITEMS ──
const ITEMS = {
// Collection items (3 tiers)
bindShard: { id:"bindShard", name:"Bind Shard", tier:1, desc:"A small crystal sliver. Can bind weakened creatures.", catchMult:1.0, cost:50 },
bindPrism: { id:"bindPrism", name:"Bind Prism", tier:2, desc:"A cut prism that resonates with wild essence.", catchMult:1.5, cost:200 },
bindOrb: { id:"bindOrb", name:"Bind Orb", tier:3, desc:"A perfect sphere of captive light. Near-certain binding.", catchMult:2.5, cost:600 },
// Restoratives
salve: { id:"salve", name:"Glimmer Salve", tier:0, desc:"Restores 25 HP to one creature.", healFlat:25, cost:30 },
elixir: { id:"elixir", name:"Lucent Elixir",tier:0, desc:"Restores 60 HP to one creature.", healFlat:60, cost:80 },
panacea: { id:"panacea", name:"Panacea Dew", tier:0, desc:"Cures all status effects on one creature.", cureAll:true, cost:100 },
ether: { id:"ether", name:"Ether Drop", tier:0, desc:"Restores 5 PP to all abilities.", ppRestore:5, cost:120 },
// Catalysts
fireCatalyst:{ id:"fireCatalyst",name:"Emberheart Shard", tier:0, desc:"Used to transform certain Ember creatures.", catalyst:0, cost:400 },
gloomCatalyst:{ id:"gloomCatalyst",name:"Moonveil Tear", tier:0, desc:"Used to transform certain Gloom creatures.", catalyst:1, cost:400 },
groveCatalyst:{ id:"groveCatalyst",name:"Verdant Seed", tier:0, desc:"Used to transform certain Grove creatures.", catalyst:2, cost:400 },
// Field tools
repel: { id:"repel", name:"Ward Charm", tier:0, desc:"Reduces wild encounters for 100 steps.", repelSteps:100, cost:150 },
mapScroll: { id:"mapScroll", name:"Map Scroll", tier:0, desc:"Reveals the full overworld map.", cost:0 },
};
// ── const: ZONES ──
// ── ZONES ──
const ZONES = [
{ id:0, name:"Suncrest Plains", colors:["#2d1a0a","#4a2a10","#8b5e3c"], particle:"golden", encounterRate:0.15, wilds:[6,7], guardian:null, shrinePos:{x:8,y:12}, storyNode:0 },
{ id:1, name:"Moonveil Thicket", colors:["#0a0a1a","#1a1a30","#2a2a4a"], particle:"mist", encounterRate:0.20, wilds:[8,10,24], guardian:null, shrinePos:{x:3,y:7}, storyNode:1 },
{ id:2, name:"Emberpeak Ridge", colors:["#1a0600","#3a1000","#6a2010"], particle:"ember", encounterRate:0.22, wilds:[11,13], guardian:"Magmorax", shrinePos:{x:10,y:2}, storyNode:2 },
{ id:3, name:"Tempest Coast", colors:["#0a1020","#1a2840","#3a4860"], particle:"spark", encounterRate:0.20, wilds:[15,17,25],guardian:"Tempestrix",shrinePos:{x:4,y:9}, storyNode:2 },
{ id:4, name:"Drowned Caverns", colors:["#001015","#002030","#004050"], particle:"bubble", encounterRate:0.25, wilds:[18,19,26],guardian:"Deephulk", shrinePos:{x:6,y:3}, storyNode:3 },
{ id:5, name:"Voidspire Citadel",colors:["#0a0015","#1a0030","#2a0050"], particle:"void", encounterRate:0.30, wilds:[21,23], guardian:"Voidreaver",shrinePos:{x:5,y:5}, storyNode:3 },
];
// ── class: GameState ──
// ── GAME STATE ──
class GameState {
constructor() {
this.reset();
}
reset() {
this.mode = "title"; // title, overworld, battle, menu, cutscene, capture, shop
this.zone = 0;
this.playerX = 10; this.playerY = 10;
this.facing = "down"; // down, up, left, right
this.animFrame = 0; this.stepCounter = 0;
this.party = []; // array of creature instances
this.inventory = {}; // {itemId: count}
this.flags = {}; // story flags
this.defeatedGuardians = []; // zone ids where guardian beaten
this.unlockedZones = [0]; // always start with zone 0
this.repelSteps = 0;
this.totalPlayTime = 0; // seconds
this.gold = 200;
this.storyStage = 0; // 0-3
this.tutorialComplete = false;
this.capturedCreatureIds = []; // for encyclopedia tracking
this.mapRevealed = false;
this.pendingEvolutions = []; // creatures that can evolve after battle
// battle state
this.battle = null;
// menu state
this.menuStack = [];
this.lastShrineZone = 0;
this.lastShrineX = 10; this.lastShrineY = 10;
}
addItem(id, qty=1) { this.inventory[id] = (this.inventory[id]||0) + qty; }
removeItem(id, qty=1) { if (this.inventory[id]) { this.inventory[id] -= qty; if (this.inventory[id]<=0) delete this.inventory[id]; } }
hasItem(id) { return (this.inventory[id]||0) > 0; }
itemCount(id) { return this.inventory[id]||0; }
}
// ── class: Creature ──
class Creature {
constructor(speciesId, level) {
const base = CREATURES[speciesId];
this.speciesId = speciesId;
this.nickname = base.name;
this.level = level || 5;
this.affinities = [...base.aff];
this.stats = { ...base.stats };
this.ivs = { hp:0, atk:Math.floor(Math.random()*6), def:Math.floor(Math.random()*6), spd:Math.floor(Math.random()*6) };
this.evs = { hp:0, atk:0, def:0, spd:0 };
this.computeStats();
this.hp = this.maxHp;
this.maxPp = 20 + Math.floor(this.level * 0.5);
this.pp = this.maxPp;
this.status = null;
this.statusTurns = 0;
this.atkStage = 0; this.defStage = 0; this.spdStage = 0;
this.abilities = this.buildLearnset();
this.exp = 0;
this.expToNext = this.level * 20 + 30;
}
computeStats() {
const b = CREATURES[this.speciesId].stats;
this.maxHp = Math.floor((b.hp + this.ivs.hp) * 2 * this.level / 50) + this.level + 10 + this.evs.hp;
this.atk = Math.floor(((b.atk + this.ivs.atk) * 2 * this.level / 50 + 5) * this.stageMult(this.atkStage)) + this.evs.atk;
this.def = Math.floor(((b.def + this.ivs.def) * 2 * this.level / 50 + 5) * this.stageMult(this.defStage)) + this.evs.def;
this.spd = Math.floor(((b.spd + this.ivs.spd) * 2 * this.level / 50 + 5) * this.stageMult(this.spdStage)) + this.evs.spd;
}
stageMult(stage) {
if (stage >= 0) return (2+stage)/2;
return 2/(2-stage);
}
buildLearnset() {
const base = CREATURES[this.speciesId];
const known = [];
for (const [abId, lvlReq] of base.learnset) {
if (this.level >= lvlReq) {
const ab = ABILITIES.find(a => a.id === abId);
if (ab) known.push({ ...ab, currentPP: ab.pp });
}
}
while (known.length > 4) known.shift();
return known;
}
gainExp(amount) {
this.exp += amount;
while (this.exp >= this.expToNext && this.level < 50) {
this.exp -= this.expToNext;
this.level++;
this.expToNext = this.level * 20 + 30;
this.abilities = this.buildLearnset();
this.computeStats();
this.hp = this.maxHp;
this.maxPp = 20 + Math.floor(this.level * 0.5);
this.pp = this.maxPp;
}
}
takeDamage(dmg) {
if (this.status === "barrier") { this.status = null; this.statusTurns = 0; return 0; }
this.hp = Math.max(0, this.hp - dmg);
return dmg;
}
heal(amt) { this.hp = Math.min(this.maxHp, this.hp + amt); }
isAlive() { return this.hp > 0; }
primaryAffinity() { return this.affinities[0]; }
}
// ── const: STAGE_MULTS ──
const STAGE_MULTS = [2/8,2/7,2/6,2/5,2/4,2/3,2/2,3/2,4/2,5/2,6/2,7/2,8/2];
// ── const: SAVE_KEY ──
// ── SAVE SYSTEM ──
const SAVE_KEY = "echoes_aetheric_save";
// ── const: SAVE_SLOT_KEY ──
const SAVE_SLOT_KEY = "echoes_aetheric_slots";
// ── function: saveGame ──
function saveGame(G, slot) {
const data = {
zone:G.zone, px:G.playerX, py:G.playerY, facing:G.facing,
stepCounter:G.stepCounter, inventory:G.inventory, flags:G.flags,
defeatedGuardians:G.defeatedGuardians, unlockedZones:G.unlockedZones,
repelSteps:G.repelSteps, totalPlayTime:G.totalPlayTime, gold:G.gold,
storyStage:G.storyStage, tutorialComplete:G.tutorialComplete,
capturedCreatureIds:G.capturedCreatureIds, mapRevealed:G.mapRevealed,
lastShrineZone:G.lastShrineZone, lastShrineX:G.lastShrineX, lastShrineY:G.lastShrineY,
party:G.party.map(c => serializeCreature(c)),
slotVersion:1, timestamp:Date.now()
};
const key = slot ? SAVE_SLOT_KEY + slot : SAVE_KEY;
localStorage.setItem(key, JSON.stringify(data));
}
// ── function: loadGame ──
function loadGame(G, slot) {
const key = slot ? SAVE_SLOT_KEY + slot : SAVE_KEY;
const raw = localStorage.getItem(key);
if (!raw) return false;
try {
const data = JSON.parse(raw);
G.zone = data.zone; G.playerX = data.px; G.playerY = data.py; G.facing = data.facing;
G.stepCounter = data.stepCounter; G.inventory = data.inventory || {}; G.flags = data.flags || {};
G.defeatedGuardians = data.defeatedGuardians || []; G.unlockedZones = data.unlockedZones || [0];
G.repelSteps = data.repelSteps || 0; G.totalPlayTime = data.totalPlayTime || 0; G.gold = data.gold || 200;
G.storyStage = data.storyStage || 0; G.tutorialComplete = data.tutorialComplete || false;
G.capturedCreatureIds = data.capturedCreatureIds || []; G.mapRevealed = data.mapRevealed || false;
G.lastShrineZone = data.lastShrineZone || 0; G.lastShrineX = data.lastShrineX || 10; G.lastShrineY = data.lastShrineY || 10;
G.party = (data.party||[]).map(c => deserializeCreature(c));
return true;
} catch(e) { return false; }
}
// ── function: serializeCreature ──
function serializeCreature(c) {
return {
sid:c.speciesId, nick:c.nickname, lvl:c.level, ivs:c.ivs, evs:c.evs,
hp:c.hp, pp:c.pp, status:c.status, statusTurns:c.statusTurns,
atkStage:c.atkStage, defStage:c.defStage, spdStage:c.spdStage,
exp:c.exp, expToNext:c.expToNext
};
}
// ── function: deserializeCreature ──
function deserializeCreature(data) {
const c = new Creature(data.sid, data.lvl);
c.nickname = data.nick; c.ivs = data.ivs; c.evs = data.evs;
c.hp = data.hp; c.pp = data.pp; c.status = data.status; c.statusTurns = data.statusTurns;
c.atkStage = data.atkStage; c.defStage = data.defStage; c.spdStage = data.spdStage;
c.exp = data.exp; c.expToNext = data.expToNext;
c.computeStats();
return c;
}
// ── function: hasAnySave ──
function hasAnySave() {
return !!localStorage.getItem(SAVE_KEY);
}
// ── function: getSaveSlots ──
function getSaveSlots() {
const slots = [];
for (let i = 0; i < 3; i++) {
const raw = localStorage.getItem(SAVE_SLOT_KEY + i);
if (raw) try { const d = JSON.parse(raw); slots.push({slot:i, ts:d.timestamp, zone:d.zone, gold:d.gold}); } catch(e){}
}
return slots;
}
// ── function: deleteSave ──
function deleteSave(slot) {
localStorage.removeItem(slot ? SAVE_SLOT_KEY + slot : SAVE_KEY);
}
// ── class: AudioEngine ──
// ── AUDIO ENGINE ──
class AudioEngine {
constructor() { this.ctx = null; this.muted = false; this.masterGain = null; }
init() {
try {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = 0.3;
this.masterGain.connect(this.ctx.destination);
} catch(e) { this.ctx = null; }
}
resume() { if (this.ctx && this.ctx.state === "suspended") this.ctx.resume(); }
toggle() {
this.muted = !this.muted;
if (this.masterGain) this.masterGain.gain.value = this.muted ? 0 : 0.3;
return this.muted;
}
playTone(freq, dur, type="square", vol=0.15, delay=0) {
if (!this.ctx || this.muted) return;
const t = this.ctx.currentTime + delay;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type; osc.frequency.value = freq;
gain.gain.setValueAtTime(vol, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + dur);
osc.connect(gain); gain.connect(this.masterGain);
osc.start(t); osc.stop(t + dur + 0.01);
}
playChirp() { this.playTone(800,0.08,"square",0.08); this.playTone(1000,0.06,"square",0.07,0.05); }
playHit() { this.playTone(200,0.15,"sawtooth",0.12); this.playTone(80,0.2,"triangle",0.1,0.05); }
playSuperEffective() { this.playTone(600,0.1,"square",0.1); this.playTone(900,0.1,"square",0.1,0.05); this.playTone(1200,0.15,"square",0.1,0.1); }
playCapture() { this.playTone(400,0.15,"triangle",0.1); this.playTone(600,0.1,"triangle",0.08,0.08); this.playTone(800,0.2,"triangle",0.1,0.12); }
playHeal() { this.playTone(300,0.2,"sine",0.08); this.playTone(500,0.15,"sine",0.07,0.1); this.playTone(700,0.25,"sine",0.08,0.15); }
playMenuSelect() { this.playTone(440,0.06,"square",0.06); this.playTone(660,0.05,"square",0.05,0.04); }
playMenuOpen() { this.playTone(220,0.08,"triangle",0.07); this.playTone(330,0.06,"triangle",0.06,0.05); }
playLevelUp() {
this.playTone(400,0.12,"square",0.08); this.playTone(500,0.1,"square",0.07,0.08);
this.playTone(600,0.1,"square",0.07,0.12); this.playTone(800,0.2,"square",0.08,0.16);
}
playEvolve() {
for (let i = 0; i < 8; i++) {
this.playTone(300 + i*100, 0.1, "triangle", 0.06, i*0.06);
}
}
playDamage() { this.playTone(150,0.12,"sawtooth",0.1); this.playTone(100,0.18,"triangle",0.08,0.04); }
playStep() { this.playTone(60,0.04,"triangle",0.04); }
playAmbient() {
if (!this.ctx || this.muted) return;
// Gentle drone pad
const now = this.ctx.currentTime;
for (let i = 0; i < 3; i++) {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = "sine";
osc.frequency.value = [130, 196, 261][i] * 0.5;
gain.gain.setValueAtTime(0.02, now);
gain.gain.linearRampToValueAtTime(0.03, now + 2);
gain.gain.linearRampToValueAtTime(0.02, now + 4);
osc.connect(gain); gain.connect(this.masterGain);
// Loop the gain
setInterval(() => {
if (!this.ctx || this.muted) return;
const n = this.ctx.currentTime;
gain.gain.linearRampToValueAtTime(0.03, n + 2);
gain.gain.linearRampToValueAtTime(0.02, n + 4);
}, 4000);
}
}
}
// ── const: TILE_SIZE ──
// ── OVERWORLD MAP ──
// 20x15 tile maps per zone. Tiles: 0=grass, 1=wall, 2=water, 3=path, 4=shrine, 5=zoneExit, 6=storyNode, 7=guardian
const TILE_SIZE = 32;
// ── const: MAPS ──
const MAPS = {};
// ── function: buildOverworldMaps ──
function buildOverworldMaps() {
// Zone 0: Suncrest Plains (gentle open)
MAPS[0] = buildMap(20, 15, (x,y) => {
if (x===0||y===0||x===19||y===14) return 1;
if ((x===8&&y===12)) return 4;
if (x===18&&y===7) return 5;
if (x===6&&y===6) return 6;
if (y===2||y===3) { if (x>4&&x<15) return 2; }
return 0;
});
// Zone 1: Moonveil Thicket
MAPS[1] = buildMap(20, 15, (x,y) => {
if (x===0||y===0||x===19||y===14) return 1;
if (x===3&&y===7) return 4;
if (x===1&&y===7) return 5;
if (x===18&&y===3) return 5;
if (x===10&&y===10) return 6;
if ((x===5&&y>3&&y<11)||(x===10&&y>0&&y<7)||(x===15&&y>6&&y<13)) return 1;
return 0;
});
// Zone 2: Emberpeak Ridge
MAPS[2] = buildMap(20, 15, (x,y) => {
if (x===0||y===0||x===19||y===14) return 1;
if (x===10&&y===2) return 4;
if (x===1&&y===3) return 5;
if (x===18&&y===10) return 5;
if (x===14&&y===7) return 6;
if (x===10&&y===10) return 7;
if ((x===4||x===8||x===12) && y>4 && y<11) return 1;
return 0;
});
// Zone 3: Tempest Coast
MAPS[3] = buildMap(20, 15, (x,y) => {
if (x===0||y===0||x===19||y===14) return 1;
if (x===4&&y===9) return 4;
if (x===1&&y===10) return 5;
if (x===18&&y===5) return 5;
if (x===8&&y===6) return 6;
if (x===14&&y===4) return 7;
if (y===12 && x>2 && x<17) return 2;
return 0;
});
// Zone 4: Drowned Caverns
MAPS[4] = buildMap(20, 15, (x,y) => {
if (x===0||y===0||x===19||y===14) return 1;
if (x===6&&y===3) return 4;
if (x===1&&y===5) return 5;
if (x===18&&y===12) return 5;
if (x===12&&y===8) return 6;
if (x===15&&y===6) return 7;
if (y===7||y===8) { if (x>1&&x<18) return 2; }
return 0;
});
// Zone 5: Voidspire Citadel
MAPS[5] = buildMap(20, 15, (x,y) => {
if (x===0||y===0||x===19||y===14) return 1;
if (x===5&&y===5) return 4;
if (x===1&&y===12) return 5;
if (x===13&&y===7) return 7;
if (x===10&&y===5) return 6;
if ((x===7||x===12) && y>2 && y<12 && !(x===7&&y===7) && !(x===12&&y===7)) return 1;
return 0;
});
}
// ── function: buildMap ──
function buildMap(w, h, fn) {
const m = [];
for (let y = 0; y < h; y++) {
m[y] = [];
for (let x = 0; x < w; x++) m[y][x] = fn(x,y);
}
return m;
}
// ── const: ZONE_EXITS ──
// Zone connections: zoneId -> { targetZone, entryX, entryY, facing }
const ZONE_EXITS = {
"0_5": { zone:1, x:2, y:7, facing:"right" }, // from zone 0 exit at (18,7)
"1_1": { zone:0, x:17, y:7, facing:"left" }, // back
"1_5": { zone:2, x:2, y:3, facing:"right" },
"2_1": { zone:1, x:17, y:3, facing:"left" },
"2_5": { zone:3, x:2, y:10, facing:"right" },
"3_1": { zone:2, x:17, y:10, facing:"left" },
"3_5": { zone:4, x:2, y:5, facing:"right" },
"4_1": { zone:3, x:17, y:5, facing:"left" },
"4_5": { zone:5, x:2, y:12, facing:"right" },
"5_1": { zone:4, x:17, y:12, facing:"left" },
};
// ── function: renderOverworld ──
// ── OVERWORLD RENDERER ──
function renderOverworld(G) {
const zone = ZONES[G.zone];
const map = MAPS[G.zone];
if (!map) return;
// Background gradient
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, zone.colors[0]);
grad.addColorStop(0.5, zone.colors[1]);
grad.addColorStop(1, zone.colors[2]);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
// Camera centered on player
const camX = G.playerX * TILE_SIZE + TILE_SIZE/2 - W/2;
const camY = G.playerY * TILE_SIZE + TILE_SIZE/2 - H/2;
// Draw tiles
for (let y = 0; y < map.length; y++) {
for (let x = 0; x < map[0].length; x++) {
const sx = x * TILE_SIZE - camX;
const sy = y * TILE_SIZE - camY;
if (sx < -TILE_SIZE || sx > W + TILE_SIZE || sy < -TILE_SIZE || sy > H + TILE_SIZE) continue;
drawTile(G.zone, map[y][x], sx, sy);
}
}
// Draw player
const px = G.playerX * TILE_SIZE - camX;
const py = G.playerY * TILE_SIZE - camY;
drawPlayer(px, py, G);
// Draw zone name
ctx.fillStyle = "rgba(255,255,255,0.7)";
ctx.font = "bold 13px " + getComputedStyle(document.body).fontFamily;
ctx.textAlign = "left";
ctx.fillText(zone.name, 12, 24);
// Draw step counter / repel
if (G.repelSteps > 0) {
ctx.fillStyle = "rgba(100,200,255,0.6)";
ctx.fillText("Ward: " + G.repelSteps + " steps", 12, 44);
}
}
// ── function: drawTile ──
function drawTile(zoneId, tile, x, y) {
const zone = ZONES[zoneId];
switch(tile) {
case 0: // grass
ctx.fillStyle = zoneId === 0 ? "#6b8e3a" : zoneId === 1 ? "#3a4a2a" : zoneId === 2 ? "#5a3020" : zoneId === 3 ? "#4a5a5a" : zoneId === 4 ? "#1a3a4a" : "#2a1a3a";
ctx.fillRect(x, y, TILE_SIZE, TILE_SIZE);
// Grass detail
ctx.fillStyle = "rgba(255,255,255,0.03)";
ctx.fillRect(x+2, y+2, TILE_SIZE-4, TILE_SIZE-4);
break;
case 1: // wall
ctx.fillStyle = "#3a3a4a";
ctx.fillRect(x, y, TILE_SIZE, TILE_SIZE);
ctx.fillStyle = "#4a4a5a";
ctx.fillRect(x+2, y+2, TILE_SIZE-4, TILE_SIZE-4);
ctx.strokeStyle = "#5a5a6a";
ctx.strokeRect(x+3, y+3, TILE_SIZE-6, TILE_SIZE-6);
break;
case 2: // water
ctx.fillStyle = "#1a4a6a";
ctx.fillRect(x, y, TILE_SIZE, TILE_SIZE);
ctx.fillStyle = "rgba(100,180,220,0.2)";
const waveOff = Math.sin((x/10 + G.animFrame*0.05)) * 2;
ctx.fillRect(x+4, y+4+waveOff, TILE_SIZE-8, TILE_SIZE-8);
break;
case 3: // path
ctx.fillStyle = "#8a7a5a";
ctx.fillRect(x, y, TILE_SIZE, TILE_SIZE);
break;
case 4: // shrine
ctx.fillStyle = "#6b8e3a";
ctx.fillRect(x, y, TILE_SIZE, TILE_SIZE);
ctx.fillStyle = "#ffd700";
ctx.beginPath(); ctx.arc(x+TILE_SIZE/2, y+TILE_SIZE/2-2, 8, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = "#fff8dc";
ctx.beginPath(); ctx.arc(x+TILE_SIZE/2, y+TILE_SIZE/2-2, 4, 0, Math.PI*2); ctx.fill();
break;
case 5: // zone exit
ctx.fillStyle = "#2a3a5a";
ctx.fillRect(x, y, TILE_SIZE, TILE_SIZE);
ctx.fillStyle = "rgba(150,200,255,0.5)";
ctx.beginPath(); ctx.arc(x+TILE_SIZE/2, y+TILE_SIZE/2, 10, 0, Math.PI*2); ctx.fill();
break;
case 6: // story node
ctx.fillStyle = "#6b8e3a";
ctx.fillRect(x, y, TILE_SIZE, TILE_SIZE);
ctx.fillStyle = "rgba(255,200,100,0.5)";
ctx.beginPath(); ctx.arc(x+TILE_SIZE/2, y+TILE_SIZE/2, 8, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = "#ffd700"; ctx.font = "14px sans-serif"; ctx.textAlign = "center";
ctx.fillText("✦", x+TILE_SIZE/2, y+TILE_SIZE/2+5);
break;
case 7: // guardian
ctx.fillStyle = "#4a1010";
ctx.fillRect(x, y, TILE_SIZE, TILE_SIZE);
ctx.fillStyle = "rgba(255,60,60,0.6)";
ctx.beginPath(); ctx.arc(x+TILE_SIZE/2, y+TILE_SIZE/2, 12, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = "#fff"; ctx.font = "bold 16px sans-serif"; ctx.textAlign = "center";
ctx.fillText("⚠", x+TILE_SIZE/2, y+TILE_SIZE/2+6);
break;
}
// Grid lines
ctx.strokeStyle = "rgba(255,255,255,0.03)";
ctx.strokeRect(x, y, TILE_SIZE, TILE_SIZE);
}
// ── function: drawPlayer ──
function drawPlayer(x, y, G) {
// Shadow
ctx.fillStyle = "rgba(0,0,0,0.4)";
ctx.beginPath(); ctx.ellipse(x+TILE_SIZE/2, y+TILE_SIZE-2, 10, 4, 0, 0, Math.PI*2); ctx.fill();
// Body
const bob = Math.sin(G.animFrame * 0.1) * 1;
const cx = x + TILE_SIZE/2;
const cy = y + TILE_SIZE/2 + bob;
ctx.fillStyle = "#f0d070";
ctx.beginPath(); ctx.arc(cx, cy-4, 10, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = "#e0b040";
ctx.beginPath(); ctx.arc(cx, cy+2, 8, Math.PI, 0); ctx.fill();
// Eyes
ctx.fillStyle = "#222";
ctx.beginPath(); ctx.arc(cx-3, cy-5, 2, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(cx+3, cy-5, 2, 0, Math.PI*2); ctx.fill();
}
// ── let: G ──
// G is needed by drawTile for animFrame; make accessible
let G; // global state ref - set in main
// ── let: canvas ──
let canvas;
// ── let: ctx ──
let ctx;
// ── let: W ──
let W;
// ── let: H ──
let H;
// ── function: setupCanvas ──
function setupCanvas() {
canvas = document.getElementById("game-canvas");
const dpr = Math.min(window.devicePixelRatio || 1, 2);
W = Math.min(window.innerWidth, 960);
H = Math.min(window.innerHeight, 720);
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = W + "px";
canvas.style.height = H + "px";
ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// ── function: clearCanvas ──
function clearCanvas(color) {
ctx.fillStyle = color || "#0a0a12";
ctx.fillRect(0, 0, W, H);
}
// ── class: BattleState ──
class BattleState {
constructor(playerParty, enemies) {
this.playerParty = playerParty;
this.enemies = enemies;
this.activePlayerIdx = 0;
this.turnOrder = [];
this.currentTurnIdx = 0;
this.phase = "playerAction";
this.log = [];
this.battleOver = false;
this.expGain = 0;
this.goldGain = 0;
this.captured = null;
this.floatTexts = [];
this.shakeAmount = 0;
this.superEffective = false;
this.lastDamage = 0;
this.lastHitTarget = null;
this.selectedAbility = null;
this.captureAttempts = 0;
this.animTimer = 0;
this.computeTurnOrder();
}
get activePlayer() { return this.playerParty[this.activePlayerIdx]; }
get aliveEnemies() { return this.enemies.filter(e => e.isAlive()); }
get aliveParty() { return this.playerParty.filter(p => p.isAlive()); }
computeTurnOrder() {
const all = [];
for (const p of this.playerParty) if (p.isAlive()) all.push({creature:p, isPlayer:true});
for (const e of this.enemies) if (e.isAlive()) all.push({creature:e, isPlayer:false});
all.sort((a,b) => {
let spdA = a.creature.spd, spdB = b.creature.spd;
if (a.creature.status === "frost") spdA = Math.floor(spdA/2);
if (b.creature.status === "frost") spdB = Math.floor(spdB/2);
return spdB - spdA;
});
this.turnOrder = all;
this.currentTurnIdx = 0;
}
executePlayerAbility(ability) {
if (this.phase !== "playerAction") return;
this.selectedAbility = ability;
const user = this.activePlayer;
if (!user || !user.isAlive()) return;
const targets = this.aliveEnemies;
if (targets.length === 0) { this.checkBattleEnd(); return; }
const target = targets[0];
const result = useAbility(user, target, ability);
this.lastDamage = result.damage;
this.lastHitTarget = target;
this.floatTexts.push(...result.floatTexts);
this.superEffective = result.superEffective;
if (result.superEffective) this.shakeAmount = 8;
else if (result.damage > 0) this.shakeAmount = 4;
this.addLog(result.log);
this.phase = "executing";
this.animTimer = 30;
}
executeEnemyTurn(enemy) {
if (!enemy || !enemy.isAlive()) return;
const ab = enemy.abilities[Math.floor(Math.random() * enemy.abilities.length)];
const aliveP = this.aliveParty;
if (aliveP.length === 0) return;
const target = aliveP[Math.floor(Math.random() * aliveP.length)];
const result = useAbility(enemy, target, ab);
this.lastDamage = result.damage;
this.lastHitTarget = target;
this.floatTexts.push(...result.floatTexts);
this.addLog(result.log);
this.animTimer = 20;
this.phase = "executing";
}
resolveTurn() {
if (this.battleOver) return;
if (this.checkBattleEnd()) return;
while (this.currentTurnIdx < this.turnOrder.length) {
const actor = this.turnOrder[this.currentTurnIdx];
if (!actor || !actor.creature.isAlive()) {
this.currentTurnIdx++;
continue;
}
if (actor.isPlayer) {
this.phase = "playerAction";
return;
} else {
this.phase = "enemyTurn";
this.executeEnemyTurn(actor.creature);
return;
}
}
// All turns done, new round
this.applyEndOfRound();
if (this.checkBattleEnd()) return;
this.computeTurnOrder();
this.currentTurnIdx = 0;
this.resolveTurn();
}
advanceTurn() {
if (this.battleOver) return;
this.currentTurnIdx++;
if (this.currentTurnIdx >= this.turnOrder.length) {
this.applyEndOfRound();
if (this.checkBattleEnd()) return;
this.computeTurnOrder();
this.currentTurnIdx = 0;
}
this.resolveTurn();
}
applyEndOfRound() {
for (const c of [...this.playerParty, ...this.enemies]) {
if (!c.isAlive()) continue;
if (c.status === "burn") {
c.takeDamage(Math.floor(c.maxHp * 0.08));
this.floatTexts.push({text:"🔥 -"+Math.floor(c.maxHp*0.08), x:0, y:0, life:40, color:"#ff6644"});
}
if (c.status === "regen") { c.heal(Math.floor(c.maxHp * 0.06)); }
if (c.statusTurns > 0) c.statusTurns--;
if (c.statusTurns <= 0 && c.status !== "barrier") { c.status = null; c.statusTurns = 0; }
}
}
checkBattleEnd() {
if (this.aliveEnemies.length === 0) {
this.battleOver = true;
this.phase = "victory";
this.calcRewards();
return true;
}
if (this.aliveParty.length === 0) {
this.battleOver = true;
this.phase = "defeat";
return true;
}
return false;
}
calcRewards() {
let exp = 0, gold = 0;
for (const e of this.enemies) {
exp += Math.floor(e.level * 12 * (1 + Math.random() * 0.3));
gold += Math.floor(e.level * 8 + Math.random() * 20);
}
this.expGain = exp;
this.goldGain = gold;
}
addLog(msg) { this.log.push(msg); if (this.log.length > 8) this.log.shift(); }
captureAttempt(G, itemTier) {
if (this.aliveEnemies.length !== 1) return false;
const target = this.aliveEnemies[0];
const hpRatio = target.hp / target.maxHp;
const baseRate = (1 - hpRatio) * 0.6 + 0.1;
const tierMult = itemTier === 1 ? 1.0 : itemTier === 2 ? 1.5 : 2.5;
const rate = Math.min(0.9, baseRate * tierMult);
this.captureAttempts++;
const success = Math.random() < rate;
if (success) {
this.captured = target;
this.battleOver = true;
this.phase = "capture";
}
return success;
}
}
// ── function: useAbility ──
function useAbility(user, target, ability) {
const floatTexts = [];
let damage = 0;
let superEffective = false;
let log = user.nickname + " used " + ability.name + "!";
// Consume PP
ability.currentPP = (ability.currentPP || ability.pp) - 1;
if (ability.currentPP < 0) ability.currentPP = 0;
// Accuracy check
if (ability.acc < 100 && Math.random() * 100 > ability.acc) {
log += " Missed!";
return { damage:0, floatTexts:[], superEffective:false, log };
}
if (ability.power > 0) {
const atkAff = ability.aff >= 0 ? ability.aff : user.primaryAffinity();
const defAff = target.primaryAffinity();
const typeEff = getEffectiveness(atkAff, defAff);
if (typeEff > 1.2) superEffective = true;
const stab = (user.affinities.includes(atkAff) || ability.aff < 0) ? 1.25 : 1.0;
const atkStat = user.status === "burn" ? Math.floor(user.atk / 2) : user.atk;
let dmg = Math.floor(0.4 * user.level * atkStat / Math.max(1, target.def) * ability.power / 10 * typeEff * stab);
dmg += Math.floor(Math.random() * 4);
let crit = false;
const critChance = ability.effect === "critUp" ? 0.25 : 0.05;
if (Math.random() < critChance) { dmg = Math.floor(dmg * 1.5); crit = true; log += " Critical!"; }
damage = target.takeDamage(dmg);
floatTexts.push({
text: (crit ? "💥 " : "") + damage + (superEffective ? " SE!" : ""),
x: 0, y: 0, life: 45,
color: superEffective ? "#ff4444" : crit ? "#ffaa00" : "#ffffff"
});
if (superEffective) log += " Super effective!";
}
// Apply effect
if (ability.effect && ability.effCh > 0 && Math.random() < ability.effCh) {
switch(ability.effect) {
case "burn":
if (!target.status) { target.status = "burn"; target.statusTurns = 4; log += " Burned!"; floatTexts.push({text:"🔥 BURN", x:0,y:0,life:40,color:"#ff8844"}); }
break;
case "frost":
if (!target.status) { target.status = "frost"; target.statusTurns = 3; log += " Frosted!"; floatTexts.push({text:"❄️ FROST", x:0,y:0,life:40,color:"#88ccff"}); }
break;
case "shock":
if (!target.status) { target.status = "shock"; target.statusTurns = 2; log += " Shocked!"; floatTexts.push({text:"⚡ SHOCK", x:0,y:0,life:40,color:"#ffcc00"}); }
break;
case "barrier":
user.status = "barrier"; user.statusTurns = 2; log += " Barrier up!"; floatTexts.push({text:"🛡️ BARRIER", x:0,y:0,life:40,color:"#88aaff"});
break;
case "regen":
if (!user.status) { user.status = "regen"; user.statusTurns = 4; log += " Regenerating!"; floatTexts.push({text:"🌿 REGEN", x:0,y:0,life:40,color:"#66dd88"}); }
break;
case "heal":
const healAmt = ability.power === 0 ? 20 + user.level * 2 : Math.floor(ability.power * 0.5);
user.heal(healAmt);
log += " Healed " + healAmt + " HP!";
floatTexts.push({text:"+" + healAmt, x:0,y:0,life:40,color:"#66ff66"});
break;
case "atkUp":
user.atkStage = Math.min(6, user.atkStage + 2);
user.computeStats();
log += " Attack rose!";
floatTexts.push({text:"⬆ ATK", x:0,y:0,life:40,color:"#ffaa44"});
break;
case "defUp":
user.defStage = Math.min(6, user.defStage + 2);
user.computeStats();
log += " Defense rose!";
floatTexts.push({text:"⬆ DEF", x:0,y:0,life:40,color:"#4488ff"});
break;
case "spdUp":
user.spdStage = Math.min(6, user.spdStage + 2);
user.computeStats();
log += " Speed rose!";
floatTexts.push({text:"⬆ SPD", x:0,y:0,life:40,color:"#88ff44"});
break;
case "defDown":
target.defStage = Math.max(-6, target.defStage - 1);
target.computeStats();
log += " Defense fell!";
floatTexts.push({text:"⬇ DEF", x:0,y:0,life:40,color:"#ff8844"});
break;
case "spdDown":
target.spdStage = Math.max(-6, target.spdStage - 1);
target.computeStats();
log += " Speed fell!";
floatTexts.push({text:"⬇ SPD", x:0,y:0,life:40,color:"#88aaff"});
break;
case "multi":
const hits = 2 + Math.floor(Math.random() * 3);
log += " Hit " + hits + " times!";
for (let i = 1; i < hits; i++) {
const extraDmg = Math.floor(damage * 0.35);
target.takeDamage(extraDmg);
damage += extraDmg;
floatTexts.push({text:extraDmg, x:0,y:0,life:35,color:"#cccccc"});
}
break;
}
}
return { damage, floatTexts, superEffective, log };
}
// ── function: renderBattle ──
function renderBattle(BS) {
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, "#0a0a1a"); grad.addColorStop(0.6, "#151530"); grad.addColorStop(1, "#1a1a2a");
ctx.fillStyle = grad; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = "#1a2a1a"; ctx.fillRect(0, H*0.55, W, H*0.45);
ctx.save();
if (BS.shakeAmount > 0) {
const sx = (Math.random()-0.5)*BS.shakeAmount, sy = (Math.random()-0.5)*BS.shakeAmount;
ctx.translate(sx, sy);
BS.shakeAmount *= 0.85; if (BS.shakeAmount < 0.5) BS.shakeAmount = 0;
}
const enemyAreaX = W * 0.45, enemyAreaY = H * 0.15, enemySpacing = Math.min(180, W * 0.25);
for (let i = 0; i < BS.enemies.length; i++) {
const e = BS.enemies[i];
if (!e.isAlive()) continue;
drawCreatureSprite(enemyAreaX + i*enemySpacing, enemyAreaY, e, 1.2, BS);
drawHPBar(enemyAreaX + i*enemySpacing - 30, enemyAreaY + 75, 60, 6, e.hp, e.maxHp);
}
const ap = BS.activePlayer;
if (ap && ap.isAlive()) drawCreatureSprite(W*0.15, H*0.35, ap, 1.3, BS);
ctx.restore();
for (let i = BS.floatTexts.length-1; i >= 0; i--) {
const ft = BS.floatTexts[i];
ft.life--; if (ft.life <= 0) { BS.floatTexts.splice(i,1); continue; }
ctx.globalAlpha = Math.min(1, ft.life/20);
ctx.fillStyle = ft.color; ctx.font = "bold 16px sans-serif"; ctx.textAlign = "center";
ctx.fillText(ft.text, W/2, H*0.3 - (45-ft.life)*1.5);
ctx.globalAlpha = 1;
}
if (BS.log.length > 0) {
ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(W*0.05, H*0.75, W*0.5, H*0.18);
ctx.fillStyle = "#d8d8e8"; ctx.font = "13px sans-serif"; ctx.textAlign = "left";
for (let i = 0; i < Math.min(4, BS.log.length); i++) ctx.fillText(BS.log[BS.log.length-1-i], W*0.06, H*0.78+i*16);
}
if (ap) {
const piX = W*0.02, piY = H*0.55;
ctx.fillStyle = "rgba(18,22,42,0.85)"; roundRect(piX, piY, W*0.38, H*0.4, 12); ctx.fill();
ctx.fillStyle = "#d8d8e8"; ctx.font = "bold 14px sans-serif"; ctx.textAlign = "left";
ctx.fillText(ap.nickname + " Lv." + ap.level, piX+10, piY+22);
drawHPBar(piX+10, piY+30, W*0.34, 10, ap.hp, ap.maxHp);
ctx.fillStyle = "#aaa"; ctx.font = "11px sans-serif";
ctx.fillText("HP: " + ap.hp + "/" + ap.maxHp, piX+10, piY+52);
ctx.fillText("Energy: " + ap.pp + "/" + (ap.maxPp||20), piX+10, piY+66);
if (ap.status) {
ctx.fillStyle = ap.status==="burn"?"#ff6644":ap.status==="frost"?"#88ccff":ap.status==="shock"?"#ffcc00":ap.status==="regen"?"#66dd88":"#88aaff";
ctx.fillText(ap.status.toUpperCase(), piX+10, piY+80);
}
const aff = AFFINITY_ARR[ap.primaryAffinity()];
ctx.fillStyle = aff.color; ctx.fillText(aff.icon + " " + aff.name, piX+10, piY+94);
if (BS.phase === "playerAction") {
const hasPP = ap.abilities.some(a => (a.currentPP||0) > 0);
for (let i = 0; i < ap.abilities.length; i++) {
const ab = ap.abilities[i];
const bx = W*0.42 + (i%2)*W*0.28, by = H*0.62 + Math.floor(i/2)*55;
const noPP = (ab.currentPP||0) <= 0;
ctx.fillStyle = noPP ? "rgba(20,20,30,0.7)" : "rgba(30,35,60,0.9)";
roundRect(bx, by, W*0.26, 48, 8); ctx.fill();
ctx.strokeStyle = noPP ? "rgba(100,100,100,0.2)" : "rgba(255,255,255,0.2)"; ctx.stroke();
ctx.fillStyle = noPP ? "#555" : "#fff"; ctx.font = "bold 12px sans-serif";
ctx.fillText((i+1)+". "+ab.name, bx+8, by+20);
ctx.fillStyle = noPP ? "#444" : "#aaa"; ctx.font = "10px sans-serif";
ctx.fillText("PP:"+(ab.currentPP||0)+"/"+ab.pp+" | Pwr:"+ab.power, bx+8, by+36);
}
if (!hasPP) {
const bx = W*0.42, by = H*0.62 + 2*55;
ctx.fillStyle = "rgba(255,60,60,0.2)";
roundRect(bx, by, W*0.26, 48, 8); ctx.fill();
ctx.fillStyle = "#ff8888"; ctx.font = "bold 12px sans-serif";
ctx.fillText("5. Struggle", bx+8, by+20);
ctx.fillStyle = "#ff6666"; ctx.font = "10px sans-serif";
ctx.fillText("Desperate attack!", bx+8, by+36);
}
}
}
}
// ── function: drawCreatureSprite ──
function drawCreatureSprite(x, y, creature, scale, BS) {
const aff = AFFINITY_ARR[creature.primaryAffinity()];
const bob = Math.sin((BS ? BS.animTimer : 0) * 0.08 + creature.speciesId) * 3;
ctx.save();
ctx.translate(x, y + bob);
ctx.scale(scale, scale);
// Body glow
ctx.fillStyle = aff.color;
ctx.globalAlpha = 0.7;
ctx.beginPath(); ctx.arc(0, 0, 24, 0, Math.PI*2); ctx.fill();
ctx.globalAlpha = 1;
// Main body
const bodyGrad = ctx.createRadialGradient(0, -4, 2, 0, 0, 20);
bodyGrad.addColorStop(0, "#ffffff");
bodyGrad.addColorStop(0.3, aff.color);
bodyGrad.addColorStop(1, "#222");
ctx.fillStyle = bodyGrad;
ctx.beginPath();
const sid = creature.speciesId;
if (sid % 4 === 0) ctx.arc(0, 0, 20, 0, Math.PI*2);
else if (sid % 4 === 1) { ctx.moveTo(0, -22); ctx.lineTo(20, 16); ctx.lineTo(-20, 16); ctx.closePath(); }
else if (sid % 4 === 2) { ctx.moveTo(0, -24); ctx.lineTo(20, 0); ctx.lineTo(0, 24); ctx.lineTo(-20, 0); ctx.closePath(); }
else { ctx.arc(0, 0, 22, 0, Math.PI*2); }
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.3)";
ctx.lineWidth = 1.5;
ctx.stroke();
// Second affinity mark
if (creature.affinities.length > 1) {
const aff2 = AFFINITY_ARR[creature.affinities[1]];
ctx.fillStyle = aff2.color;
ctx.globalAlpha = 0.6;
ctx.beginPath(); ctx.arc(5, 8, 7, 0, Math.PI*2); ctx.fill();
ctx.globalAlpha = 1;
}
// Eyes
ctx.fillStyle = "#fff";
ctx.beginPath(); ctx.arc(-6, -4, 5, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(6, -4, 5, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = "#111";
ctx.beginPath(); ctx.arc(-5, -3, 2.5, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(7, -3, 2.5, 0, Math.PI*2); ctx.fill();
// Evolved glow
const base = CREATURES[creature.speciesId];
if (creature.level >= 16 && base.evolvesTo === null) {
ctx.strokeStyle = aff.color;
ctx.globalAlpha = 0.3 + Math.sin((BS?BS.animTimer:0)*0.1)*0.2;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(0, 0, 26, 0, Math.PI*2); ctx.stroke();
ctx.globalAlpha = 1;
}
ctx.restore();
}
// ── function: drawHPBar ──
function drawHPBar(x, y, w, h, hp, maxHp) {
const ratio = Math.max(0, hp / maxHp);
ctx.fillStyle = "rgba(0,0,0,0.6)";
roundRect(x-1, y-1, w+2, h+2, 3);
ctx.fill();
// Background
ctx.fillStyle = "#333";
roundRect(x, y, w, h, 3);
ctx.fill();
// Health
const hColor = ratio > 0.5 ? `rgb(${Math.floor((1-ratio)*2*255)},220,60)` : ratio > 0.25 ? `rgb(255,${Math.floor(ratio*4*220)},40)` : "rgb(240,60,40)";
ctx.fillStyle = hColor;
roundRect(x, y, w * ratio, h, 3);
ctx.fill();
// Shine
ctx.fillStyle = "rgba(255,255,255,0.2)";
roundRect(x, y, w*ratio, h/2, [3,3,0,0]);
ctx.fill();
}
// ── function: roundRect ──
function roundRect(x, y, w, h, r) {
if (typeof r === "number") r = [r,r,r,r];
ctx.beginPath();
ctx.moveTo(x+r[0], y);
ctx.lineTo(x+w-r[1], y);
ctx.quadraticCurveTo(x+w, y, x+w, y+r[1]);
ctx.lineTo(x+w, y+h-r[2]);
ctx.quadraticCurveTo(x+w, y+h, x+w-r[2], y+h);
ctx.lineTo(x+r[3], y+h);
ctx.quadraticCurveTo(x, y+h, x, y+h-r[3]);
ctx.lineTo(x, y+r[0]);
ctx.quadraticCurveTo(x, y, x+r[0], y);
ctx.closePath();
}
// ── function: showModal ──
// ── UI MANAGER ──
function showModal(html, onClose) {
const container = document.getElementById("modal-container");
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML = '<div class="glass-panel" style="min-width:340px;max-width:700px;">' + html + '</div>';
container.appendChild(overlay);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) { closeModal(overlay, onClose); }
});
return overlay;
}
// ── function: closeModal ──
function closeModal(overlay, onClose) {
overlay.style.opacity = "0";
overlay.style.transition = "opacity 0.2s";
setTimeout(() => { overlay.remove(); if (onClose) onClose(); }, 200);
}
// ── function: closeAllModals ──
function closeAllModals() {
document.getElementById("modal-container").innerHTML = "";
}
// ── function: showToast ──
function showToast(msg) {
const container = document.getElementById("toast-container");
const t = document.createElement("div");
t.className = "toast";
t.textContent = msg;
container.appendChild(t);
setTimeout(() => t.remove(), 2500);
}
// ── function: showBag ──
// ── BAG SCREEN ──
function showBag(G) {
let html = '<h2>🎒 Inventory</h2><div style="display:flex;flex-wrap:wrap;gap:8px;">';
const inv = G.inventory;
for (const [key, qty] of Object.entries(inv)) {
if (qty <= 0) continue;
const item = ITEMS[key];
if (!item) continue;
html += '<div style="background:rgba(255,255,255,0.05);padding:10px;border-radius:8px;width:100%;display:flex;justify-content:space-between;align-items:center;">';
html += '<div><strong>' + item.name + '</strong><br><small>' + item.desc + '</small></div>';
html += '<div style="text-align:right"><span style="color:var(--gold2)">x' + qty + '</span>';
if (item.healFlat) {
html += '<br><button class="btn" onclick="useItemBag(\''+key+'\')">Use</button>';
} else if (item.cureAll) {
html += '<br><button class="btn" onclick="useItemBag(\''+key+'\')">Use</button>';
} else if (item.ppRestore) {
html += '<br><button class="btn" onclick="useItemBag(\''+key+'\')">Use</button>';
}
html += '</div></div>';
}
if (Object.keys(inv).length === 0) html += '<p style="color:#888">Your bag is empty.</p>';
html += '</div>';
html += '<div style="margin-top:12px;text-align:center;color:var(--gold2)">💰 Gold: ' + G.gold + '</div>';
html += '<div style="text-align:center;margin-top:12px"><button class="primary" onclick="closeAllModals()">Close</button></div>';
showModal(html);
}
// ── function: useItemBag ──
function useItemBag(itemId) {
closeAllModals();
useItem(G, itemId);
}
// ── function: showTeam ──
function showTeam(G) {
let html = '<h2>👥 Team</h2><div style="display:flex;flex-direction:column;gap:8px;">';
for (let i = 0; i < G.party.length; i++) {
const c = G.party[i];
const aff = AFFINITY_ARR[c.primaryAffinity()];
html += '<div style="background:rgba(255,255,255,0.05);padding:10px;border-radius:8px;">';
html += '<strong>' + c.nickname + '</strong> <span style="color:'+aff.color+'">' + aff.icon + '</span> Lv.' + c.level;
html += '<div style="display:flex;gap:4px;margin-top:4px;">';
html += '<span style="background:#333;padding:2px 6px;border-radius:4px;font-size:11px;">HP:'+c.hp+'/'+c.maxHp+'</span>';
html += '<span style="background:#333;padding:2px 6px;border-radius:4px;font-size:11px;">ATK:'+c.atk+'</span>';
html += '<span style="background:#333;padding:2px 6px;border-radius:4px;font-size:11px;">DEF:'+c.def+'</span>';
html += '<span style="background:#333;padding:2px 6px;border-radius:4px;font-size:11px;">SPD:'+c.spd+'</span>';
html += '</div>';
if (c.status) html += '<div style="color:#ff8844;font-size:11px;">Status: ' + c.status.toUpperCase() + '</div>';
html += '<div style="font-size:11px;margin-top:4px;">Abilities: ';
for (const ab of c.abilities) {
const abAff = ab.aff >= 0 ? AFFINITY_ARR[ab.aff] : null;
html += '<span style="'+(abAff?'color:'+abAff.color:'')+'">'+ab.name+'</span> ';
}
html += '</div>';
const evo = checkEvolution(c);
if (evo) {
html += '<button class="btn" style="margin-top:4px;font-size:11px;" onclick="evolveCreature('+i+')">🌟 Transform to ' + CREATURES[evo].name + '</button>';
}
const xpPct = c.exp / c.expToNext;
html += '<div style="background:#222;height:4px;border-radius:2px;margin-top:4px;"><div style="background:var(--gold);height:100%;width:'+(xpPct*100)+'%;border-radius:2px;"></div></div>';
html += '<span style="font-size:10px;color:#888;">EXP: '+c.exp+'/'+c.expToNext+'</span>';
html += '</div>';
}
if (G.party.length === 0) html += '<p style="color:#888">No creatures in your team.</p>';
html += '</div>';
html += '<div style="text-align:center;margin-top:12px"><button class="primary" onclick="closeAllModals()">Close</button></div>';
showModal(html);
}
// ── function: checkEvolution ──
function checkEvolution(c) {
const base = CREATURES[c.speciesId];
if (base.evolvesTo && c.level >= base.evoLevel) return base.evolvesTo;
return null;
}
// ── function: evolveCreature ──
function evolveCreature(idx) {
const c = G.party[idx];
const base = CREATURES[c.speciesId];
if (!base.evolvesTo || c.level < base.evoLevel) return;
c.speciesId = base.evolvesTo;
const newBase = CREATURES[base.evolvesTo];
c.nickname = newBase.name;
c.affinities = [...newBase.aff];
const oldStats = CREATURES[base.id].stats;
const nStats = newBase.stats;
// Stat boost on evolution
c.stats = { ...nStats };
c.computeStats();
c.hp = c.maxHp;
c.abilities = c.buildLearnset();
closeAllModals();
audio.playEvolve();
showToast(c.nickname + " transformed into " + newBase.name + "! 🌟");
showTeam(G);
}
// ── function: showDex ──
function showDex(G) {
let html = '<h2>📖 Encyclopedia</h2><div style="display:flex;flex-wrap:wrap;gap:6px;">';
for (const c of CREATURES) {
const seen = G.capturedCreatureIds.includes(c.id);
const aff = AFFINITY_ARR[c.aff[0]];
html += '<div style="background:rgba(255,255,255,0.05);padding:8px;border-radius:8px;width:30%;text-align:center;">';
html += '<span style="font-size:24px;">' + (seen ? aff.icon : '❓') + '</span><br>';
html += '<span style="font-size:11px;">' + (seen ? c.name : '???' ) + '</span>';
if (seen) html += '<br><small style="color:#888;">' + c.lore + '</small>';
html += '</div>';
}
html += '</div>';
html += '<div style="text-align:center;margin-top:12px"><button class="primary" onclick="closeAllModals()">Close</button></div>';
showModal(html);
}
// ── function: showMap ──
// ── MAP ──
function showMap(G) {
let html = '<h2>🗺️ World Map</h2><div style="display:flex;flex-direction:column;gap:8px;">';
for (let i = 0; i < ZONES.length; i++) {
const z = ZONES[i];
const unlocked = G.unlockedZones.includes(i) || G.mapRevealed;
const current = G.zone === i;
html += '<div style="background:rgba(255,255,255,'+(current?'0.1':'0.04')+');padding:10px;border-radius:8px;'+(current?'border:1px solid var(--gold)':'')+'">';
html += '<strong>' + (unlocked ? z.name : '???') + '</strong>';
if (current) html += ' <span style="color:var(--gold2)">📍 You are here</span>';
if (G.defeatedGuardians.includes(i)) html += ' <span style="color:#ff6644">⚔ Guardian defeated</span>';
html += '</div>';
}
html += '</div>';
html += '<div style="text-align:center;margin-top:12px"><button class="primary" onclick="closeAllModals()">Close</button></div>';
showModal(html);
}
// ── function: showPauseMenu ──
// ── PAUSE MENU ──
function showPauseMenu(G) {
let html = '<h2>⚙️ Menu</h2>';
html += '<div style="display:flex;flex-direction:column;gap:8px;">';
html += '<button class="primary" onclick="saveAtShrine();closeAllModals()">💾 Save Game</button>';
html += '<button class="btn" onclick="closeAllModals();showShop()">🏪 Shrine Shop</button>';
html += '<button class="btn" onclick="closeAllModals()">Resume</button>';
html += '</div>';
showModal(html);
}
// ── function: showShop ──
function showShop() {
let html = '<h2>🏪 Shrine Shop</h2><div style="display:flex;flex-direction:column;gap:8px;">';
const shopItems = ["bindShard","bindPrism","bindOrb","salve","elixir","panacea","ether","repel"];
for (const id of shopItems) {
const item = ITEMS[id];
html += '<div style="display:flex;justify-content:space-between;align-items:center;padding:8px;background:rgba(255,255,255,0.05);border-radius:8px;">';
html += '<div><strong>'+item.name+'</strong><br><small>'+item.desc+'</small></div>';
html += '<div><span style="color:var(--gold2)">💰'+item.cost+'</span><br><button class="btn" onclick="buyItem(\''+id+'\')">Buy</button></div>';
html += '</div>';
}
html += '<div style="text-align:center;margin-top:8px;">💰 Your Gold: ' + G.gold + '</div>';
html += '<div style="text-align:center;margin-top:8px;"><button class="primary" onclick="closeAllModals()">Close</button></div>';
html += '</div>';
showModal(html);
}
// ── function: buyItem ──
function buyItem(itemId) {
const item = ITEMS[itemId];
if (G.gold >= item.cost) {
G.gold -= item.cost;
G.addItem(itemId);
audio.playMenuSelect();
closeAllModals();
showToast("Bought " + item.name + "!");
} else {
showToast("Not enough gold!");
}
}
// ── function: useItem ──
function useItem(G, itemId) {
const item = ITEMS[itemId];
if (!item || !G.hasItem(itemId)) return;
if (item.healFlat) {
// Heal first alive party member
const target = G.party.find(p => p.isAlive() && p.hp < p.maxHp);
if (target) {
target.heal(item.healFlat);
G.removeItem(itemId);
audio.playHeal();
showToast(target.nickname + " healed " + item.healFlat + " HP!");
} else {
showToast("No one needs healing!");
}
} else if (item.cureAll) {
const target = G.party.find(p => p.status);
if (target) {
target.status = null; target.statusTurns = 0;
G.removeItem(itemId);
audio.playHeal();
showToast(target.nickname + " cured!");
} else {
showToast("No status effects to cure!");
}
} else if (item.ppRestore) {
const target = G.party.find(p => p.isAlive());
if (target) {
target.pp = Math.min(target.maxPp||20, target.pp + item.ppRestore);
G.removeItem(itemId);
audio.playHeal();
showToast(target.nickname + " regained energy!");
}
}
}
// ── function: renderTitle ──
// ── TITLE SCREEN ──
function renderTitle(G) {
clearCanvas("#0a0a12");
// Animated background
const t = G.animFrame * 0.02;
for (let i = 0; i < 40; i++) {
const sx = (Math.sin(t + i*0.7) * 0.5 + 0.5) * W;
const sy = (Math.cos(t*0.6 + i*0.8) * 0.5 + 0.5) * H;
ctx.fillStyle = `rgba(${100+i*3},${100+i*2},${200+i*2},${0.15+Math.sin(t+i)*0.05})`;
ctx.beginPath(); ctx.arc(sx, sy, 2+Math.random()*3, 0, Math.PI*2); ctx.fill();
}
// Title
ctx.fillStyle = "#d4a843";
ctx.font = "bold 36px " + getComputedStyle(document.body).fontFamily;
ctx.textAlign = "center";
ctx.shadowColor = "rgba(212,168,67,0.6)";
ctx.shadowBlur = 20;
ctx.fillText("ECHOES OF THE", W/2, H*0.25);
ctx.fillText("AETHERIC VEIL", W/2, H*0.32);
ctx.shadowBlur = 0;
// Subtitle
ctx.fillStyle = "#a0a0c0";
ctx.font = "14px " + getComputedStyle(document.body).fontFamily;
ctx.fillText("Bind the lost echoes. Restore the veil.", W/2, H*0.4);
// Buttons
const btnY = H*0.52;
drawTitleButton("New Binding", W/2, btnY, 200, 46);
drawTitleButton("Continue", W/2, btnY + 58, 200, 46, !hasAnySave());
}
// ── function: drawTitleButton ──
function drawTitleButton(text, x, y, w, h, disabled) {
ctx.fillStyle = disabled ? "rgba(40,40,60,0.7)" : "rgba(30,35,60,0.85)";
roundRect(x-w/2, y-h/2, w, h, 12);
ctx.fill();
ctx.strokeStyle = disabled ? "rgba(100,100,100,0.3)" : "rgba(212,168,67,0.5)";
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = disabled ? "#555" : "#d8d8e8";
ctx.font = "16px " + getComputedStyle(document.body).fontFamily;
ctx.textAlign = "center";
ctx.fillText(text, x, y+6);
}
// ── function: renderCaptureScreen ──
// ── CAPTURE MINI-GAME ──
function renderCaptureScreen(BS) {
clearCanvas("#0a0a12");
if (!BS.captured && BS.captureAttempts > 0) {
// Show mini-game active
const target = BS.aliveEnemies[0];
if (!target) return;
ctx.fillStyle = "#d8d8e8";
ctx.font = "bold 20px sans-serif";
ctx.textAlign = "center";
ctx.fillText("Binding " + target.nickname + "...", W/2, H*0.3);
// Pulsing circle
const pulse = Math.sin(G.animFrame * 0.15) * 0.3 + 0.7;
ctx.strokeStyle = `rgba(200,150,255,${pulse})`;
ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(W/2, H*0.5, 40 + pulse*10, 0, Math.PI*2); ctx.stroke();
ctx.beginPath(); ctx.arc(W/2, H*0.5, 25, 0, Math.PI*2); ctx.stroke();
ctx.fillStyle = "#aaa";
ctx.font = "14px sans-serif";
ctx.fillText("Tap SPACE in rhythm...", W/2, H*0.65);
} else if (BS.captured) {
ctx.fillStyle = "#d4a843";
ctx.font = "bold 22px sans-serif";
ctx.textAlign = "center";
ctx.fillText("Bound! " + BS.captured.nickname + " joins you!", W/2, H*0.45);
}
}
// ── function: showStoryCutin ──
// ── STORY CUT-IN ──
function showStoryCutin(stage) {
const cutins = [
{ title:"The Awakening", text:"Aetherial tears scar the land. Ancient shrines flicker with fading light. You, a Veil-Keeper, must bind the scattered Echoes and restore balance. The first tear glows in Suncrest Plains.", color:"rgba(240,180,60,0.3)" },
{ title:"Shadows Gather", text:"The Moonveil Thicket writhes with unrest. A Guardian stirs in the deep. The echoes grow louder — and more desperate.", color:"rgba(120,140,200,0.3)" },
{ title:"The Peaks Tremble", text:"Emberpeak Ridge erupts with fury. Storm-wracked coasts howl. Two Guardians now block the path. Prove your bond is strong enough.", color:"rgba(220,100,40,0.3)" },
{ title:"The Void Beckons", text:"The Drowned Caverns whisper ancient secrets. Beyond them — the Voidspire Citadel, where the final anchor awaits. Every Echo you've bound now resonates as one.", color:"rgba(130,70,190,0.3)" },
];
const c = cutins[stage];
if (!c) return;
let html = '<div style="text-align:center;position:relative;">';
html += '<div style="position:absolute;inset:-24px;background:'+c.color+';border-radius:16px;z-index:-1;"></div>';
html += '<h2 style="font-size:1.6em;">' + c.title + '</h2>';
html += '<p style="margin:16px 0;line-height:1.6;">' + c.text + '</p>';
html += '<button class="primary" onclick="closeAllModals();G.mode=\'overworld\'">Continue</button>';
html += '</div>';
G.mode = "cutscene";
showModal(html);
}
// ── function: startTutorial ──
// ── TUTORIAL ──
function startTutorial(G) {
G.mode = "cutscene";
let step = 0;
const steps = [
"Welcome, Veil-Keeper! Use WASD or arrow keys to move. Try walking around.",
"Touch the glowing shrine to rest and save your progress.",
"Wild Echoes roam the land. When you encounter one, you'll enter a battle.",
"In battle, choose an ability to attack. Lower the enemy's HP to weaken it.",
"Use a Bind Shard to capture weakened Echoes. Build your team of up to 6!",
"Defeat the zone's Guardian to unlock the next region. Good luck!"
];
function showStep() {
if (step >= steps.length) { G.tutorialComplete = true; closeAllModals(); G.mode = "overworld"; return; }
let html = '<h2 style="font-size:1.3em;">📖 Guidance</h2>';
html += '<p style="margin:16px 0;line-height:1.6;">' + steps[step] + '</p>';
html += '<p style="color:#888;font-size:0.8em;">Step ' + (step+1) + '/' + steps.length + '</p>';
html += '<button class="primary" onclick="closeAllModals();G.mode=\'cutscene\';nextTutorialStep()">' + (step < steps.length-1 ? 'Next' : 'Begin!') + '</button>';
showModal(html);
step++;
}
window.nextTutorialStep = showStep;
showStep();
}
// ── function: startNewGame ──
function startNewGame() {
audio.resume();
audio.playMenuSelect();
G.reset();
G.mode = "cutscene";
G.zone = 0;
G.playerX = 10; G.playerY = 10;
G.unlockedZones = [0];
let html = '<h2>Choose Your First Echo</h2>';
html += '<p style="text-align:center;color:#aaa;margin-bottom:12px;">Select your companion:</p>';
html += '<div style="display:flex;gap:12px;justify-content:center;">';
for (const sid of [0, 2, 4]) {
const c = CREATURES[sid];
const aff = AFFINITY_ARR[c.aff[0]];
html += '<button class="btn" style="text-align:center;padding:16px;width:30%;" onclick="pickStarter('+sid+')">';
html += '<div style="font-size:32px;">'+aff.icon+'</div>';
html += '<strong>'+c.name+'</strong><br>';
html += '<small style="color:'+aff.color+'">'+aff.name+'</small><br>';
html += '<small>'+c.lore+'</small>';
html += '</button>';
}
html += '</div>';
showModal(html);
G.addItem("bindShard", 5);
G.addItem("salve", 3);
G.gold = 200;
}
// ── const: AFFINITY_ARR ──
const AFFINITY_ARR = [
{ id: 0, name: "Ember", icon: "🔥", color: "#f4843c", desc: "Flame and radiance. Dominates Gloom, burns bright." },
{ id: 1, name: "Gloom", icon: "🌑", color: "#7b8fce", desc: "Shadow and frost. Chills Grove into dormancy." },
{ id: 2, name: "Grove", icon: "🌿", color: "#5cb870", desc: "Nature and growth. Overgrows the blazing Ember." },
{ id: 3, name: "Zephyr", icon: "⚡", color: "#c8b432", desc: "Wind and storm. Scatters both Ember and Grove." },
{ id: 4, name: "Tide", icon: "🌊", color: "#4aacc8", desc: "Water and flow. Drowns Zephyr and erodes Gloom." },
{ id: 5, name: "Shade", icon: "🌀", color: "#8b4fc0", desc: "Void-touched essence. Holds a subtle edge over all." },
{ id: 6, name: "Terra", icon: "⛰️", color: "#c08060", desc: "Primal stone. Endures all—except the Void's whisper." }
];
// ── function: pickStarter ──
function pickStarter(sid) {
closeAllModals();
const c = new Creature(sid, 5);
G.party.push(c);
G.capturedCreatureIds.push(sid);
audio.playLevelUp();
showToast("You bonded with " + c.nickname + "!");
G.mode = "cutscene";
setTimeout(() => startTutorial(G), 500);
}
// ── const: STRUGGLE ──
const STRUGGLE = { id:99, name:"Struggle", power:25, acc:95, aff:-1, pp:99, currentPP:99, effect:"", effCh:0, desc:"A desperate attack used when no energy remains." };
// ── main ──
let audio;
let inputState = {};
let mouseX = 0, mouseY = 0;
function init() {
setupCanvas();
buildOverworldMaps();
for (const c of CREATURES) CREATURE_BY_ID[c.id] = c;
audio = new AudioEngine();
audio.init();
audio.playAmbient();
G = new GameState();
G.mode = "title";
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
canvas.addEventListener("click", handleCanvasClick);
canvas.addEventListener("mousemove", (e) => { mouseX = e.offsetX; mouseY = e.offsetY; });
window.addEventListener("resize", () => { setupCanvas(); });
document.getElementById("btn-bag").addEventListener("click", () => { if (G.mode==="overworld") { audio.playMenuOpen(); showBag(G); } });
document.getElementById("btn-team").addEventListener("click", () => { if (G.mode==="overworld") { audio.playMenuOpen(); showTeam(G); } });
document.getElementById("btn-dex").addEventListener("click", () => { if (G.mode==="overworld") { audio.playMenuOpen(); showDex(G); } });
document.getElementById("btn-map").addEventListener("click", () => { if (G.mode==="overworld") { audio.playMenuOpen(); showMap(G); } });
document.getElementById("btn-menu").addEventListener("click", () => { if (G.mode==="overworld") { audio.playMenuOpen(); showPauseMenu(G); } });
document.querySelectorAll(".dpad-btn").forEach(btn => {
btn.addEventListener("pointerdown", (e) => {
e.preventDefault(); const dir = btn.dataset.dir;
handleDirInput(dir);
});
});
document.getElementById("mute-btn").addEventListener("click", () => {
const muted = audio.toggle();
document.getElementById("mute-btn").textContent = muted ? "🔇" : "🔊";
});
requestAnimationFrame(gameLoop);
}
function handleKeyDown(e) {
inputState[e.key] = true;
if (G.mode === "title") {
if (e.key === "Enter") startNewGame();
if (e.key === "c" || e.key === "C") continueGame();
return;
}
if (G.mode === "overworld") {
if (e.key === "ArrowUp" || e.key === "w") handleDirInput("up");
if (e.key === "ArrowDown" || e.key === "s") handleDirInput("down");
if (e.key === "ArrowLeft" || e.key === "a") handleDirInput("left");
if (e.key === "ArrowRight" || e.key === "d") handleDirInput("right");
if (e.key === "Escape") { audio.playMenuOpen(); showPauseMenu(G); }
if (e.key === "b" || e.key === "B") { audio.playMenuOpen(); showBag(G); }
}
if (G.mode === "battle" || G.mode === "capture") {
const BS = G.battle;
if (BS && BS.phase === "playerAction") {
const ap = BS.activePlayer;
if (e.key >= "1" && e.key <= "4") {
const idx = parseInt(e.key) - 1;
if (ap && idx < ap.abilities.length && (ap.abilities[idx].currentPP||0) > 0) {
audio.playMenuSelect();
BS.executePlayerAbility(ap.abilities[idx]);
}
}
if (e.key === "5") {
const hasPP = ap && ap.abilities.some(a => (a.currentPP||0) > 0);
if (!hasPP) {
audio.playMenuSelect();
BS.executePlayerAbility(STRUGGLE);
}
}
if (e.key === " ") { tryCapture(); }
}
if (e.key === "Enter" && BS && (BS.phase === "victory" || BS.phase === "defeat" || BS.phase === "capture")) {
endBattle();
}
}
}
function handleKeyUp(e) { inputState[e.key] = false; }
function handleDirInput(dir) {
if (G.mode !== "overworld") return;
let dx = 0, dy = 0;
if (dir === "up") { dy = -1; G.facing = "up"; }
else if (dir === "down") { dy = 1; G.facing = "down"; }
else if (dir === "left") { dx = -1; G.facing = "left"; }
else if (dir === "right") { dx = 1; G.facing = "right"; }
const nx = G.playerX + dx;
const ny = G.playerY + dy;
const map = MAPS[G.zone];
if (!map || ny < 0 || ny >= map.length || nx < 0 || nx >= map[0].length) return;
if (map[ny][nx] === 1) return;
G.playerX = nx; G.playerY = ny; G.stepCounter++;
if (G.repelSteps > 0) G.repelSteps--;
audio.playStep();
const tile = map[ny][nx];
if (tile === 4) shrineInteract();
else if (tile === 5) zoneTransition();
else if (tile === 6) storyNodeInteract();
else if (tile === 7) guardianInteract();
else checkWildEncounter();
}
function handleCanvasClick(e) {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (G.mode === "title") {
if (y > H*0.50 && y < H*0.50+46 && x > W/2-100 && x < W/2+100) startNewGame();
if (y > H*0.50+58 && y < H*0.50+58+46 && x > W/2-100 && x < W/2+100) continueGame();
}
if (G.mode === "battle" && G.battle) {
const BS = G.battle;
if (BS.phase === "playerAction") {
const ap = BS.activePlayer;
if (ap) {
for (let i = 0; i < ap.abilities.length; i++) {
const bx = W*0.42 + (i%2)*W*0.28;
const by = H*0.62 + Math.floor(i/2)*55;
if (x>bx && x<bx+W*0.26 && y>by && y<by+48 && (ap.abilities[i].currentPP||0)>0) {
audio.playMenuSelect(); BS.executePlayerAbility(ap.abilities[i]); return;
}
}
const hasPP = ap.abilities.some(a => (a.currentPP||0) > 0);
if (!hasPP) {
const bx = W*0.42, by = H*0.62 + 2*55;
if (x>bx && x<bx+W*0.26 && y>by && y<by+48) {
audio.playMenuSelect(); BS.executePlayerAbility(STRUGGLE); return;
}
}
}
}
if ((BS.phase === "victory" || BS.phase === "defeat" || BS.phase === "capture") &&
x>W*0.25 && x<W*0.75 && y>H*0.35 && y<H*0.6) {
endBattle();
}
}
}
function startNewGame() {
audio.resume(); audio.playMenuSelect();
G.reset(); G.mode = "cutscene"; G.zone = 0; G.playerX = 10; G.playerY = 10; G.unlockedZones = [0];
let html = '<h2>Choose Your First Echo</h2>';
html += '<p style="text-align:center;color:#aaa;margin-bottom:12px;">Select your companion:</p>';
html += '<div style="display:flex;gap:12px;justify-content:center;">';
for (const sid of [0, 2, 4]) {
const c = CREATURES[sid]; const aff = AFFINITY_ARR[c.aff[0]];
html += '<button class="btn" style="text-align:center;padding:16px;width:30%;" onclick="pickStarter('+sid+')">';
html += '<div style="font-size:32px;">'+aff.icon+'</div><strong>'+c.name+'</strong><br>';
html += '<small style="color:'+aff.color+'">'+aff.name+'</small><br><small>'+c.lore+'</small></button>';
}
html += '</div>';
showModal(html);
G.addItem("bindShard", 5); G.addItem("salve", 3); G.gold = 200;
}
function pickStarter(sid) {
closeAllModals(); const c = new Creature(sid, 5);
G.party.push(c); G.capturedCreatureIds.push(sid);
audio.playLevelUp(); showToast("You bonded with " + c.nickname + "!");
G.mode = "cutscene"; setTimeout(() => startTutorial(G), 500);
}
function continueGame() {
if (!hasAnySave()) { showToast("No saved game found."); return; }
audio.resume(); audio.playMenuSelect();
if (loadGame(G)) {
G.mode = "overworld";
for (const c of G.party) { c.abilities = c.buildLearnset(); c.computeStats(); c.hp = Math.max(1, c.hp); }
showToast("Welcome back, Veil-Keeper.");
}
}
function checkWildEncounter() {
if (G.repelSteps > 0) return;
if (Math.random() < ZONES[G.zone].encounterRate * 0.4) startWildBattle();
}
function startWildBattle() {
const zone = ZONES[G.zone];
const wildId = zone.wilds[Math.floor(Math.random() * zone.wilds.length)];
let level = 3 + G.zone*2 + Math.floor(Math.random()*5) + G.storyStage*2;
if (G.defeatedGuardians.length >= 4) level = Math.min(50, level + 10);
const enemy = new Creature(wildId, Math.min(50, level));
G.battle = new BattleState(G.party, [enemy]);
G.mode = "battle"; audio.playMenuOpen();
G.battle.resolveTurn();
}
function startGuardianBattle(guardianName) {
const guardian = CREATURES.find(c => c.name === guardianName);
if (!guardian) return;
const enemy = new Creature(guardian.id, 12 + G.zone*6);
G.battle = new BattleState(G.party, [enemy]);
G.mode = "battle"; audio.playMenuOpen();
showToast("⚔ Guardian " + guardianName + " challenges you!");
G.battle.resolveTurn();
}
function tryCapture() {
const BS = G.battle;
if (!BS || BS.phase !== "playerAction") return;
if (BS.aliveEnemies.length !== 1) { showToast("Can only bind the last Echo!"); return; }
if (G.party.length >= 6) { showToast("Your team is full!"); return; }
let itemId = null;
if (G.hasItem("bindOrb")) itemId = "bindOrb";
else if (G.hasItem("bindPrism")) itemId = "bindPrism";
else if (G.hasItem("bindShard")) itemId = "bindShard";
else { showToast("No Bind items! Buy them at shrines."); return; }
G.removeItem(itemId); audio.playCapture();
const success = BS.captureAttempt(G, ITEMS[itemId].tier);
if (success) {
BS.captured.hp = BS.captured.maxHp; BS.captured.status = null; BS.captured.statusTurns = 0;
G.party.push(BS.captured);
if (!G.capturedCreatureIds.includes(BS.captured.speciesId)) G.capturedCreatureIds.push(BS.captured.speciesId);
showToast("Bound " + BS.captured.nickname + "! 🌟");
} else { showToast("The Echo broke free... Try weakening it more!"); BS.advanceTurn(); }
}
function endBattle() {
const BS = G.battle; if (!BS) return;
if (BS.phase === "victory") {
const aliveCount = Math.max(1, G.party.filter(p => p.isAlive()).length);
const expEach = Math.floor(BS.expGain / aliveCount);
for (const c of G.party) {
if (c.isAlive()) { c.gainExp(expEach); c.hp = Math.max(1, c.hp); }
else { c.hp = 1; c.status = null; c.statusTurns = 0; }
}
G.gold += BS.goldGain;
showToast("Victory! +" + BS.expGain + " EXP, +" + BS.goldGain + " Gold 💰");
audio.playLevelUp();
const zone = ZONES[G.zone];
if (zone.guardian) {
const gc = CREATURES.find(c => c.name === zone.guardian);
if (gc && BS.enemies.some(e => e.speciesId === gc.id) && !G.defeatedGuardians.includes(G.zone)) {
G.defeatedGuardians.push(G.zone);
if (G.zone+1 < ZONES.length) G.unlockedZones.push(G.zone+1);
G.storyStage = Math.min(3, G.defeatedGuardians.length);
showToast("Guardian defeated! New zone unlocked!"); audio.playEvolve();
setTimeout(() => showStoryCutin(G.storyStage), 1500);
}
}
for (const c of G.party) { if (checkEvolution(c)) showToast(c.nickname + " can transform! Check Team menu."); }
} else if (BS.phase === "defeat") {
showToast("Defeated... Returning to last shrine.");
G.zone = G.lastShrineZone; G.playerX = G.lastShrineX; G.playerY = G.lastShrineY;
for (const c of G.party) { c.hp = c.maxHp; c.status = null; c.statusTurns = 0; }
G.gold = Math.floor(G.gold * 0.8);
}
G.battle = null; G.mode = "overworld";
}
function shrineInteract() {
for (const c of G.party) {
c.hp = c.maxHp; c.status = null; c.statusTurns = 0;
for (const ab of c.abilities) ab.currentPP = ab.pp;
}
G.lastShrineZone = G.zone; G.lastShrineX = G.playerX; G.lastShrineY = G.playerY;
saveGame(G); audio.playHeal();
let html = '<h2>⛩️ Shrine of Respite</h2><p>Your team is healed and the game is saved.</p>';
html += '<div style="display:flex;flex-direction:column;gap:8px;">';
html += '<button class="btn" onclick="closeAllModals();showShop()">🏪 Shop</button>';
html += '<button class="primary" onclick="closeAllModals()">Rest</button></div>';
showModal(html);
}
function saveAtShrine() {
for (const c of G.party) { c.hp = c.maxHp; c.status = null; c.statusTurns = 0; }
G.lastShrineZone = G.zone; G.lastShrineX = G.playerX; G.lastShrineY = G.playerY;
saveGame(G); audio.playHeal(); showToast("Game saved! 💾");
}
function zoneTransition() {
const dest = ZONE_EXITS[G.zone + "_5"];
if (!dest) return;
if (!G.unlockedZones.includes(dest.zone) && !G.defeatedGuardians.includes(G.zone)) {
showToast("The path is blocked. Defeat the Guardian first!"); return;
}
G.zone = dest.zone; G.playerX = dest.x; G.playerY = dest.y; G.facing = dest.facing;
audio.playMenuOpen(); showToast("Entering " + ZONES[dest.zone].name);
}
function storyNodeInteract() {
const flagKey = "story_" + G.zone;
if (G.flags[flagKey]) return;
G.flags[flagKey] = true; audio.playMenuOpen();
const texts = [
"A weathered stone hums with residual aether. Echoes of an ancient Veil-Keeper linger here.",
"Carved into a moonlit tree: 'The shadow knows what light cannot bear.'",
"Scorch marks trace the ridge. Something immense passed through — recently.",
"A tide pool reflects impossible constellations. The stars here are... wrong.",
"Bioluminescent moss spells out a warning: 'The deep remembers.'",
"A monolith pulses with void energy. It whispers: 'All veils fall in time.'"
];
let html = '<h2>📜 Discovery</h2><p style="line-height:1.6;">'+texts[G.zone]+'</p>';
html += '<button class="primary" onclick="closeAllModals()">Fascinating...</button>';
showModal(html);
}
function guardianInteract() {
if (G.defeatedGuardians.includes(G.zone)) { showToast("The Guardian has already been defeated."); return; }
const zone = ZONES[G.zone]; if (!zone.guardian) return;
let html = '<h2>⚠ Guardian Ahead</h2><p>A powerful Echo blocks the path: <strong>'+zone.guardian+'</strong></p>';
html += '<p style="color:#ff6666;">Are you ready to challenge it?</p>';
html += '<button class="primary" onclick="closeAllModals();startGuardianBattle(\''+zone.guardian+'\')">⚔ Challenge</button>';
html += '<button class="btn" style="margin-left:8px;" onclick="closeAllModals()">Not yet</button>';
showModal(html);
}
function gameLoop(timestamp) {
G.animFrame++; G.totalPlayTime = Math.floor(timestamp/1000);
clearCanvas("#0a0a12");
switch(G.mode) {
case "title": renderTitle(G); break;
case "overworld": renderOverworld(G); break;
case "battle": case "capture":
if (G.battle) {
G.battle.animTimer++;
if (G.battle.phase === "executing" && G.battle.animTimer > 25 && !G.battle.battleOver) {
G.battle.animTimer = 0; G.battle.advanceTurn();
}
renderBattle(G.battle);
const BS = G.battle;
if (BS.phase === "victory") {
ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(0,0,W,H);
ctx.fillStyle = "#ffd700"; ctx.font = "bold 28px sans-serif"; ctx.textAlign = "center";
ctx.fillText("VICTORY!", W/2, H*0.38);
ctx.fillStyle = "#fff"; ctx.font = "16px sans-serif";
ctx.fillText("+"+BS.expGain+" EXP +"+BS.goldGain+" Gold", W/2, H*0.46);
ctx.fillText("Press Enter or tap to continue", W/2, H*0.55);
} else if (BS.phase === "defeat") {
ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillRect(0,0,W,H);
ctx.fillStyle = "#ff4444"; ctx.font = "bold 28px sans-serif"; ctx.textAlign = "center";
ctx.fillText("DEFEATED", W/2, H*0.42);
ctx.fillText("Press Enter or tap to continue", W/2, H*0.52);
} else if (BS.phase === "capture") {
ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.fillRect(0,0,W,H);
ctx.fillStyle = "#d4a843"; ctx.font = "bold 22px sans-serif"; ctx.textAlign = "center";
ctx.fillText("✨ Bound: " + (BS.captured?BS.captured.nickname:"???")+"!", W/2, H*0.42);
ctx.fillText("Press Enter to continue", W/2, H*0.52);
} else if (BS.phase === "playerAction") {
ctx.fillStyle = "rgba(255,255,255,0.5)"; ctx.font = "11px sans-serif"; ctx.textAlign = "center";
ctx.fillText("1-4: Ability | SPACE: Bind | Click buttons above", W/2, H*0.94);
}
}
break;
case "cutscene": renderOverworld(G); break;
}
if (G.mode === "overworld") {
ctx.fillStyle = "rgba(255,255,255,0.5)"; ctx.font = "12px sans-serif"; ctx.textAlign = "right";
ctx.fillText("💰 " + G.gold, W-12, 24);
ctx.textAlign = "left"; ctx.fillText("🎒 " + G.party.length + "/6 Echoes", 12, H-12);
}
requestAnimationFrame(gameLoop);
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4-pro","provider":"openrouter","persona":"composer","sessionId":"2d0c7256-bdab-475b-ad22-155484068619","tokensIn":3773334,"tokensOut":76553,"tokensTotal":3849887,"turns":63,"toolCalls":70,"failedToolCalls":3,"timestamp":"2026-07-14T04:51:03.396Z"} -->