Creature Collection
Kimi k2.7 code · 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
- 1 / 5
- Prompt spirit
- 1 / 5
- Delight
- 1 / 5
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 96,538 bytes · SHA-256 a0d51dc6fd18
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Loom of Velmorath</title>
<style>
/* ── css: base ── */
:root {
--bg1: #0b0f19;
--bg2: #141b2d;
--card: rgba(255,255,255,0.08);
--card-b: rgba(255,255,255,0.15);
--text: #f0f4ff;
--muted: #9aa8c9;
--accent: #7ce0ff;
--accent2: #ffd36e;
--danger: #ff6b6b;
--heal: #6bffa3;
--cinder: #ff7a4a;
--tidal: #4ad0ff;
--verdant: #6bff84;
--umbral: #b47cff;
--lumin: #fff07c;
--aether: #7cfff5;
--null: #b0b8c8;
--shadow: 0 12px 32px rgba(0,0,0,0.35);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
color: var(--text);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
background: #05070a;
overflow: hidden;
touch-action: manipulation; -webkit-user-select: none; user-select: none;
}
button { font: inherit; cursor: pointer; border: none; }
.screen {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
transition: opacity .35s ease;
}
.screen[hidden] { display: none !important; }
.glass-panel, .glass-btn {
background: var(--card);
border: 1px solid var(--card-b);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-radius: 16px;
color: var(--text);
box-shadow: var(--shadow);
}
.glass-btn {
padding: 12px 24px; margin: 6px;
transition: transform .12s, background .2s, box-shadow .2s;
}
.glass-btn:hover:not(:disabled) { background: rgba(255,255,255,0.14); }
.glass-btn:active:not(:disabled) { transform: scale(0.97); }
.glass-btn:disabled { opacity: .45; cursor: default; }
.icon-btn { width: 40px; height: 40px; border-radius: 50%; background: var(--card); border: 1px solid var(--card-b); color: var(--text); }
.mute-btn { position: absolute; bottom: 18px; right: 18px; width: 48px; height: 48px; border-radius: 50%; background: var(--card); border: 1px solid var(--card-b); font-size: 22px; }
/* ── css: title ── */
#title-screen {
background: radial-gradient(circle at 50% 40%, #1a2b4d 0%, #080b14 70%);
flex-direction: column;
overflow: hidden;
}
#title-screen::before {
content: ''; position: absolute; inset: 0;
background: repeating-conic-gradient(from 0deg, rgba(124,224,255,0.05) 0deg 2deg, transparent 2deg 4deg);
animation: turn 60s linear infinite;
pointer-events: none;
}
@keyframes turn { to { transform: rotate(360deg); } }
.title-card {
position: relative;
z-index: 2;
text-align: center;
padding: 48px 56px;
max-width: 520px;
background: linear-gradient(180deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.04) 100%);
border: 1px solid rgba(255,255,255,0.18);
border-radius: 24px;
box-shadow: 0 0 80px rgba(124,224,255,0.12);
}
.title-card h1 {
font-size: 3.2rem; letter-spacing: 2px;
text-shadow: 0 0 30px rgba(124,224,255,0.5), 0 2px 4px rgba(0,0,0,0.4);
margin-bottom: 8px;
}
.subtitle { color: var(--muted); font-size: 1.1rem; margin-bottom: 28px; }
.title-buttons { display: flex; flex-direction: column; gap: 10px; }
.slot-select { margin-top: 16px; }
.slot-select p { margin-bottom: 8px; color: var(--muted); }
.small { font-size: .9rem; padding: 8px 18px; }
/* ── css: game-layout ── */
#game-screen { background: #000; align-items: stretch; justify-content: stretch; }
#game-canvas {
position: absolute; inset: 0;
width: 100%; height: 100%;
image-rendering: pixelated;
}
#ui-layer {
position: absolute; inset: 0;
pointer-events: none;
display: flex; flex-direction: column; justify-content: space-between;
}
#ui-layer > * { pointer-events: auto; }
#top-hud {
display: flex; align-items: center; gap: 12px;
padding: 10px 16px;
}
#hud-zone { font-weight: 700; color: var(--accent); text-shadow: 0 0 12px rgba(124,224,255,0.4); }
#hud-story { margin-right: auto; color: var(--muted); font-size: .9rem; }
#hud-coin { color: var(--accent2); font-weight: 700; }
#touch-controls {
display: flex; justify-content: space-between; align-items: flex-end;
padding: 14px 20px; gap: 20px;
}
.dpad { display: grid; grid-template-columns: repeat(3, 52px); grid-template-rows: repeat(3, 52px); gap: 6px; }
.dpad button { width: 52px; height: 52px; border-radius: 12px; background: var(--card); border: 1px solid var(--card-b); color: var(--text); font-size: 22px; align-self: center; justify-self: center; }
.dpad button[data-dir="up"] { grid-column: 2; grid-row: 1; }
.dpad button[data-dir="left"] { grid-column: 1; grid-row: 2; }
.dpad button[data-dir="down"] { grid-column: 2; grid-row: 3; }
.dpad button[data-dir="right"] { grid-column: 3; grid-row: 2; }
.action-pad { display: flex; gap: 12px; align-items: flex-end; }
.action-btn { width: 64px; height: 64px; border-radius: 50%; font-weight: 800; font-size: 1.4rem; background: var(--accent); color: #04222b; border: 2px solid rgba(255,255,255,0.35); box-shadow: 0 0 20px rgba(124,224,255,0.25); }
#btn-b { background: var(--danger); color: #2b0a0a; }
/* ── css: dialogue ── */
#dialogue-box {
position: absolute; bottom: 18px; left: 18px; right: 18px;
display: flex; flex-direction: column; gap: 8px;
padding: 18px 22px;
background: rgba(10,14,24,0.72);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 16px;
box-shadow: var(--shadow);
min-height: 120px;
z-index: 20;
}
#dialogue-portrait { width: 48px; height: 48px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--aether)); flex-shrink: 0; }
#dialogue-text { font-size: 1.05rem; line-height: 1.5; }
.dialogue-tip { font-size: .8rem; color: var(--muted); align-self: flex-end; }
/* ── css: battle-ui ── */
#battle-ui {
position: absolute; inset: 0;
display: flex; flex-direction: column; justify-content: space-between;
align-items: stretch;
padding: 12px;
pointer-events: none;
}
#battle-ui > * { pointer-events: auto; }
#battle-log {
position: absolute; top: 12px; left: 12px; right: 12px;
min-height: 64px; max-height: 96px;
overflow-y: auto;
padding: 10px 14px; font-size: .92rem;
background: rgba(10,14,24,0.65);
}
#battle-log p { margin: 2px 0; }
#battle-log span.crit { color: var(--accent2); font-weight: 700; }
#battle-log span.super { color: var(--danger); }
#battle-menu, #battle-target {
position: absolute; bottom: 96px; left: 12px; right: 12px;
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px;
max-height: 260px; overflow-y: auto;
}
#battle-ui .glass-btn {
position: absolute; bottom: 18px;
padding: 10px 16px; font-size: .9rem;
}
#battle-switch { left: 12px; }
#battle-bag { left: 50%; transform: translateX(-50%); }
#battle-run { right: 12px; }
.move-btn {
position: relative;
display: flex; flex-direction: column; align-items: flex-start;
padding: 10px 14px; border-radius: 12px;
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15);
color: var(--text);
}
.move-btn .move-name { font-weight: 700; }
.move-btn .move-meta { font-size: .8rem; color: var(--muted); }
.move-btn .type-dot { position: absolute; top: 8px; right: 8px; width: 14px; height: 14px; border-radius: 50%; }
.target-btn { padding: 12px; text-align: left; font-size: .95rem; }
/* ── css: modal ── */
.modal {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
z-index: 50;
background: rgba(0,0,0,0.45);
}
.modal-panel {
width: min(860px, 96vw); height: min(640px, 92vh);
display: flex; flex-direction: column;
padding: 20px;
overflow: hidden;
}
.modal-tabs {
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px;
}
.modal-tabs button {
padding: 8px 16px; border-radius: 12px;
background: rgba(255,255,255,0.08); color: var(--text); border: 1px solid var(--card-b);
}
.modal-tabs button.active { background: var(--accent); color: #04222b; }
.menu-content { flex: 1; overflow-y: auto; padding: 6px; }
#menu-close { align-self: flex-end; margin-top: 12px; }
.card {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 12px;
padding: 12px; margin-bottom: 10px;
}
.card h4 { margin-bottom: 6px; color: var(--accent); }
.card p { color: var(--muted); font-size: .92rem; }
.stat-bar { height: 10px; background: rgba(0,0,0,0.25); border-radius: 5px; overflow: hidden; margin-top: 4px; }
.stat-bar > div { height: 100%; transition: width .25s linear; }
.party-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
.affinity-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: .78rem; font-weight: 700; margin-right: 4px; }
/* ── css: overlays ── */
#collection-overlay {
position: absolute; inset: 0; z-index: 60;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.55);
}
.collection-frame { text-align: center; padding: 28px; width: min(400px, 90vw); }
.collection-frame canvas { display: block; margin: 14px auto; }
#collect-result { min-height: 24px; font-weight: 700; }
#tutorial-overlay {
position: absolute; inset: 0; z-index: 70;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.5);
}
#tutorial-card { max-width: 520px; padding: 28px; text-align: center; }
#tutorial-card h3 { margin-bottom: 12px; color: var(--accent); }
#tutorial-card p { line-height: 1.5; margin-bottom: 18px; }
#vignette {
position: absolute; inset: 0; pointer-events: none; opacity: 0;
box-shadow: inset 0 0 0 0 transparent;
transition: opacity .4s;
z-index: 30;
}
#vignette.active { opacity: 1; }
@keyframes damage-flash { 0%{opacity:1}50%{opacity:0.2}100%{opacity:1} }
.flash { animation: damage-flash .12s ease-in-out 2; }
@keyframes shake { 0%,100%{transform:translate(0,0)}20%{transform:translate(-4px,3px)}40%{transform:translate(4px,-3px)}60%{transform:translate(-3px,2px)}80%{transform:translate(3px,-2px)} }
.shake { animation: shake .22s ease-in-out; }
#toast-area { position: absolute; top: 60px; right: 14px; display: flex; flex-direction: column; gap: 6px; pointer-events: none; z-index: 40; }
.toast {
background: rgba(20,28,44,0.9); color: var(--text);
border: 1px solid var(--card-b); border-radius: 10px;
padding: 10px 16px; box-shadow: var(--shadow);
animation: slideIn .3s ease, fadeOut .4s ease 2.2s forwards;
}
@keyframes slideIn { from { transform: translateX(80px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes fadeOut { to { opacity: 0; transform: translateY(-6px); } }
/* ── css: aff-color ── */
.aff-cinder { background: var(--cinder); color: #1a0a04; }
.aff-tidal { background: var(--tidal); color: #041a22; }
.aff-verdant { background: var(--verdant); color: #051c0a; }
.aff-umbral { background: var(--umbral); color: #160a26; }
.aff-lumin { background: var(--lumin); color: #261f04; }
.aff-aether { background: var(--aether); color: #052423; }
.aff-null { background: var(--null); color: #0b0d12; }
/* ── css: battle-bottom ── */
#battle-capture { position: absolute; bottom: 18px; left: 130px; padding: 10px 16px; font-size: .9rem; }
#touch-controls { z-index: 10; }
#game-screen.battle-active #touch-controls { display: none; }
#menu-modal { z-index: 80; }
#collection-overlay { z-index: 90; }
#tutorial-overlay { z-index: 100; }
</style>
</head>
<body>
<div id="app">
<div id="title-screen" class="screen active">
<div class="title-card">
<h1>Loom of Velmorath</h1>
<p class="subtitle">Weave bonds with the Aetherlings of a broken world.</p>
<div class="title-buttons">
<button id="btn-new" class="glass-btn">New Binding</button>
<button id="btn-continue" class="glass-btn" disabled="">Continue</button>
</div>
<div id="slot-select" class="slot-select" hidden="">
<p>Choose a binding thread</p>
<button class="glass-btn slot-btn" data-slot="0">Thread 1</button>
<button class="glass-btn slot-btn" data-slot="1">Thread 2</button>
<button class="glass-btn slot-btn" data-slot="2">Thread 3</button>
<button id="slot-back" class="glass-btn small">Back</button>
</div>
</div>
<button id="btn-mute-title" class="mute-btn" aria-label="mute">🔊</button>
</div>
<div id="game-screen" class="screen" hidden="">
<canvas id="game-canvas" width="960" height="540"></canvas>
<div id="ui-layer">
<div id="top-hud">
<span id="hud-zone">Zone</span>
<span id="hud-story">Thread</span>
<span id="hud-coin">◆ 0</span>
<button id="hud-menu" class="icon-btn">☰</button>
<button id="hud-mute" class="icon-btn">🔊</button>
</div>
<div id="touch-controls">
<div class="dpad">
<button data-dir="up">▲</button>
<button data-dir="left">◀</button>
<button data-dir="down">▼</button>
<button data-dir="right">▶</button>
</div>
<div class="action-pad">
<button id="btn-a" class="action-btn">A</button>
<button id="btn-b" class="action-btn">B</button>
</div>
</div>
<div id="dialogue-box" hidden="">
<div id="dialogue-portrait"></div>
<div id="dialogue-text"></div>
<div class="dialogue-tip">▶ press A / click to continue</div>
</div>
<div id="battle-ui" hidden="">
<div id="battle-log" class="glass-panel"></div>
<div id="battle-menu" class="battle-menu"></div>
<div id="battle-target" class="battle-menu" hidden=""></div>
<button id="battle-switch" class="glass-btn">Switch</button>
<button id="battle-bag" class="glass-btn">Bag<button id="battle-capture" class="glass-btn">Bind</button></button>
<button id="battle-run" class="glass-btn">Flee</button>
</div>
<div id="menu-modal" class="modal" hidden="">
<div class="modal-panel glass-panel">
<nav class="modal-tabs">
<button data-tab="party">Party</button>
<button data-tab="bag">Bag</button>
<button data-tab="guide">Guide</button>
<button data-tab="map">Map</button>
<button data-tab="options">Options</button>
</nav>
<div id="menu-content" class="menu-content"></div>
<button id="menu-close" class="glass-btn">Close</button>
</div>
</div>
<div id="collection-overlay" hidden="">
<div class="collection-frame glass-panel">
<h2>Resonance Bind</h2>
<p>Tap or press A when the pulse overlaps the binding ring.</p>
<canvas id="collect-canvas" width="300" height="300"></canvas>
<div id="collect-result"></div>
<button id="collect-try" class="glass-btn">Bind</button>
<button id="collect-cancel" class="glass-btn">Cancel</button>
</div>
</div>
<div id="tutorial-overlay" hidden="">
<div id="tutorial-card" class="glass-panel">
<h3 id="tut-title">Welcome</h3>
<p id="tut-body"></p>
<button id="tut-next" class="glass-btn">Next</button>
</div>
</div>
<div id="vignette"></div>
<div id="toast-area"></div>
</div>
</div>
</div>
<script>
'use strict';
// ── const: AFFINITIES ──
const AFFINITIES = {
cinder: { name: 'Cinder', color: '#ff7a4a' },
tidal: { name: 'Tidal', color: '#4ad0ff' },
verdant:{ name: 'Verdant',color: '#6bff84' },
umbral: { name: 'Umbral', color: '#b47cff' },
lumin: { name: 'Lumin', color: '#fff07c' },
aether: { name: 'Aether', color: '#7cfff5' },
null: { name: 'Null', color: '#b0b8c8' }
};
// ── const: TYPE_ORDER ──
const TYPE_ORDER = ['cinder','tidal','verdant','umbral','lumin','aether','null'];
// ── function: buildTypeMatrix ──
function buildTypeMatrix() {
const m = {};
for (const a of TYPE_ORDER) m[a] = {};
function set(atk, def, mul) { m[atk][def] = mul; }
for (const a of TYPE_ORDER) for (const d of TYPE_ORDER) set(a,d,1.0);
set('cinder','tidal',1.5); set('cinder','verdant',0.5); set('cinder','umbral',0.5);
set('tidal','verdant',1.5); set('tidal','cinder',0.5); set('tidal','lumin',0.5);
set('verdant','cinder',1.5); set('verdant','tidal',0.5); set('verdant','umbral',0.5);
set('umbral','cinder',1.5); set('umbral','verdant',1.5); set('umbral','lumin',0.5);
set('lumin','umbral',1.5); set('lumin','tidal',1.5);
// Aether beats all others slightly
for (const d of TYPE_ORDER) if (d !== 'aether') set('aether', d, 1.25);
// Null resists everything; weak to Aether
for (const a of TYPE_ORDER) if (a !== 'aether' && a !== 'null') { set(a,'null',0.5); set('null',a,1.0); }
set('aether','null',1.5); set('null','aether',1.0);
set('null','null',1.0); set('aether','aether',1.0);
return m;
}
// ── const: TYPE_MATRIX ──
const TYPE_MATRIX = buildTypeMatrix();
// ── const: ZONES ──
const ZONES = [
{ id:'emberglade', name:'Emberglade', aff:'cinder', hueA:'#2b0e05', hueB:'#5c1e0b', particle:'ember', rate:0.18, boss:'Pyrelith', level:6 },
{ id:'mistwood', name:'Mistwood', aff:'verdant', hueA:'#0a1f14', hueB:'#143824', particle:'spore', rate:0.16, boss:'Thornregent', level:12 },
{ id:'deepreaches',name:'Deepreaches',aff:'tidal', hueA:'#05182b', hueB:'#0a2f52', particle:'bubble',rate:0.16, boss:'Abyssalene', level:18 },
{ id:'voidcoast', name:'Voidcoast', aff:'umbral', hueA:'#160a26', hueB:'#24113d', particle:'mote', rate:0.15, boss:'Nocturnex', level:25 },
{ id:'shimmerpeak',name:'Shimmerpeak',aff:'lumin', hueA:'#2b2405', hueB:'#52460a', particle:'gleam',rate:0.14, boss:'Solaramis', level:32 },
{ id:'aethercrux', name:'Aethercrux', aff:'aether', hueA:'#051f1e', hueB:'#0a3d3a', particle:'star', rate:0.14, boss:'Astrion Prime', level:40 }
];
// ── const: LEVEL_CAP ──
const LEVEL_CAP = 50;
// ── const: ABILITIES ──
const ABILITIES = [
// Cinder
{ id:'flame_lash', name:'Flame Lash', aff:'cinder', power:40, acc:100, cost:8, effect:'burn', chance:0.15, desc:'A scalding whip that may ignite.' },
{ id:'cinder_burst', name:'Cinder Burst', aff:'cinder', power:65, acc:95, cost:14, desc:'Detonates a core of smoldering embers.' },
{ id:'pyre_claw', name:'Pyre Claw', aff:'cinder', power:55, acc:100, cost:12, desc:'Rakes the foe with claws of solid flame.' },
{ id:'scorched_earth', name:'Scorched Earth', aff:'cinder', power:30, acc:90, cost:18, targetAll:true, effect:'burn', chance:0.25, desc:'Sears the field, burning all foes.' },
{ id:'kindle', name:'Kindle', aff:'cinder', power:0, acc:100, cost:12, effect:'self_atk_up', desc:'Builds inner heat to raise offense.' },
{ id:'inferno', name:'Inferno', aff:'cinder', power:95, acc:85, cost:28, effect:'burn', chance:0.3, desc:'A towering blaze with high ignition chance.' },
// Tidal
{ id:'water_lash', name:'Water Lash', aff:'tidal', power:40, acc:100, cost:8, desc:'A snapping torrent of pressure.' },
{ id:'bubble_veil', name:'Bubble Veil', aff:'tidal', power:0, acc:100, cost:12, effect:'self_def_up', desc:'Wraps the user in buoyant armor.' },
{ id:'tidal_slam', name:'Tidal Slam', aff:'tidal', power:75, acc:90, cost:18, desc:'A crushing wave slammed down.' },
{ id:'brine_spray', name:'Brine Spray', aff:'tidal', power:35, acc:95, cost:14, targetAll:true, desc:'Stinging saltwater hits all foes.' },
{ id:'current', name:'Current', aff:'tidal', power:50, acc:100, cost:14, priority:1, desc:'A swift surge that strikes first.' },
{ id:'deluge', name:'Deluge', aff:'tidal', power:90, acc:85, cost:26, desc:'Drowns the enemy in a flood.' },
// Verdant
{ id:'thorn_whip', name:'Thorn Whip', aff:'verdant', power:45, acc:100, cost:10, effect:'drain', chance:1, desc:'Thorned vines drain a little life.' },
{ id:'pollen_cloud', name:'Pollen Cloud', aff:'verdant', power:30, acc:95, cost:14, targetAll:true, effect:'accuracy_down', chance:0.3, desc:'Obscuring pollen clouds aim.' },
{ id:'canopy', name:'Canopy', aff:'verdant', power:0, acc:100, cost:16, effect:'self_barrier', desc:'Grows a living shield that blocks one hit.' },
{ id:'vine_lash', name:'Vine Lash', aff:'verdant', power:60, acc:100, cost:14, desc:'Slaps the foe with heavy vines.' },
{ id:'spore_burst', name:'Spore Burst', aff:'verdant', power:55, acc:90, cost:16, effect:'regen', chance:0.2, targetSelf:true, desc:'Scatters healing spores.' },
{ id:'overgrowth', name:'Overgrowth', aff:'verdant', power:85, acc:85, cost:26, desc:'A sudden explosion of entangling growth.' },
// Umbral
{ id:'shadow_claw', name:'Shadow Claw', aff:'umbral', power:50, acc:100, cost:12, desc:'Rips through shadows and flesh.' },
{ id:'night_veil', name:'Night Veil', aff:'umbral', power:0, acc:100, cost:14, effect:'self_spd_up', desc:'Melds with darkness to quicken.' },
{ id:'void_pulse', name:'Void Pulse', aff:'umbral', power:70, acc:95, cost:18, effect:'shock', chance:0.15, desc:'A pulse of oblivion that may stun.' },
{ id:'terror', name:'Terror', aff:'umbral', power:30, acc:90, cost:16, targetAll:true, effect:'atk_down', chance:0.25, desc:'Saps the will of all enemies.' },
{ id:'umbral_feast', name:'Umbral Feast', aff:'umbral', power:75, acc:90, cost:22, effect:'drain', chance:0.5, desc:'Devours light and steals vitality.' },
{ id:'eclipse', name:'Eclipse', aff:'umbral', power:100, acc:80, cost:34, desc:'A world-eating wave of dusk.' },
// Lumin
{ id:'radiant_beam', name:'Radiant Beam', aff:'lumin', power:50, acc:100, cost:12, desc:'A searing ray of pure light.' },
{ id:'halo', name:'Halo', aff:'lumin', power:0, acc:100, cost:16, effect:'self_barrier', desc:'A ring of light deflects one blow.' },
{ id:'photon_rush', name:'Photon Rush', aff:'lumin', power:55, acc:100, cost:18, priority:1, desc:'Light-speed impact.' },
{ id:'purge', name:'Purge', aff:'lumin', power:65, acc:95, cost:20, desc:'Burns away impurities; ignores lowered stats.' },
{ id:'solar_flare', name:'Solar Flare', aff:'lumin', power:90, acc:85, cost:28, effect:'burn', chance:0.2, desc:'A blinding eruption from a tiny sun.' },
{ id:'illuminate', name:'Illuminate', aff:'lumin', power:0, acc:100, cost:18, effect:'self_regen', desc:'Basks in restorative radiance.' },
// Aether
{ id:'astral_ray', name:'Astral Ray', aff:'aether', power:55, acc:100, cost:14, desc:'A focused ray of stellar matter.' },
{ id:'phase_shift', name:'Phase Shift', aff:'aether', power:0, acc:100, cost:18, effect:'self_spd_up', desc:'Shifts slightly out of phase.' },
{ id:'comet_shard', name:'Comet Shard', aff:'aether', power:80, acc:90, cost:22, desc:'Hurls a frozen shard of comet.' },
{ id:'gravity_well', name:'Gravity Well', aff:'aether', power:70, acc:85, cost:24, targetAll:true, desc:'Compresses foes under mass.' },
{ id:'nebula_heal', name:'Nebula Heal', aff:'aether', power:0, acc:100, cost:22, effect:'heal', heal:0.35, targetSelf:true, desc:'Draws life from distant nebulae.' },
{ id:'supernova', name:'Supernova', aff:'aether', power:110, acc:75, cost:38, desc:'A stellar detonation of raw origin.' },
// Null
{ id:'null_pulse', name:'Null Pulse', aff:'null', power:45, acc:100, cost:10, desc:'A flat wave of un-affinity.' },
{ id:'oblivion_grasp', name:'Oblivion Grasp', aff:'null', power:70, acc:95, cost:20, effect:'shock', chance:0.2, desc:'A crushing absence of sensation.' },
{ id:'void_anchor', name:'Void Anchor', aff:'null', power:0, acc:100, cost:16, effect:'self_def_up', desc:'Roots into nothing to harden.' },
{ id:'erase', name:'Erase', aff:'null', power:60, acc:90, cost:18, desc:'Strips a small part of the target away.' },
{ id:'singular_strike', name:'Singular Strike', aff:'null', power:80, acc:85, cost:24, desc:'A compressed point of canceled meaning.' },
{ id:'entropy', name:'Entropy', aff:'null', power:100, acc:80, cost:32, effect:'frost', chance:0.2, desc:'Spreads the cold of fading matter.' },
// Mixed / extras
{ id:'spark_trip', name:'Spark Trip', aff:'lumin', power:40, acc:100, cost:10, effect:'shock', chance:0.25, desc:'A jolt that may stun.' },
{ id:'soothing_wind', name:'Soothing Wind', aff:'verdant', power:0, acc:100, cost:16, effect:'heal', heal:0.25, targetSelf:true, desc:'Gentle currents mend wounds.' },
{ id:'frost_bite', name:'Frost Bite', aff:'tidal', power:50, acc:95, cost:14, effect:'frost', chance:0.2, desc:'Freezes the foe and slows movement.' },
{ id:'drain_life', name:'Drain Life', aff:'umbral', power:50, acc:100, cost:16, effect:'drain', chance:1, desc:'Steals vitality through shadows.' },
{ id:'fortify', name:'Fortify', aff:'cinder', power:0, acc:100, cost:14, effect:'self_def_up', desc:'Hardens the body like cooling stone.' },
{ id:'gale', name:'Gale', aff:'aether', power:55, acc:100, cost:14, effect:'speed_down', chance:0.25, desc:'Pushes the enemy back with force.' }
];
// ── function: abilityById ──
function abilityById(id) { return ABILITIES.find(a=>a.id===id) || ABILITIES[0]; }
// ── const: STATUS_INFO ──
const STATUS_INFO = {
burn: { label:'Burn', color:'#ff7a4a', tick:0.08 },
frost:{ label:'Frost', color:'#4ad0ff', spd:0.5 },
shock:{ label:'Shock', color:'#fff07c', chance:0.35 },
regen:{ label:'Regen', color:'#6bff84', tick:0.06 },
barrier:{ label:'Barrier', color:'#7cfff5' },
atk_up:{ label:'ATK↑', color:'#ff6b6b' },
def_up:{ label:'DEF↑', color:'#7ce0ff' },
spd_up:{ label:'SPD↑', color:'#fff07c' },
atk_down:{ label:'ATK↓', color:'#b0b8c8' },
accuracy_down:{ label:'ACC↓', color:'#b0b8c8' },
speed_down:{ label:'SPD↓', color:'#b0b8c8' }
};
// ── const: SPECIES ──
const SPECIES = (function(){
const s=[];
function sp(id, name, aff, stats, lore, learnset, evo) {
s.push({ id, name, affinities:Array.isArray(aff)?aff:[aff], baseStats:stats, lore, learnset, evolution:evo||null });
}
sp('spriglin', 'Spriglin', 'verdant', {hp:50,atk:55,def:45,spd:65}, 'A tiny wisp of unbound growth that leaves dew-spark footprints.', [
{lv:1,id:'thorn_whip'},{lv:3,id:'pollen_cloud'},{lv:8,id:'vine_lash'},{lv:14,id:'canopy'},{lv:22,id:'overgrowth'}
], {to:'thornregent', level:18, catalyst:'catalyst_verdant'});
sp('kindlekit', 'Kindlekit', 'cinder', {hp:48,atk:60,def:40,spd:70}, 'A playful ember-cub whose whiskers smoke when curious.', [
{lv:1,id:'flame_lash'},{lv:3,id:'kindle'},{lv:8,id:'pyre_claw'},{lv:15,id:'cinder_burst'},{lv:24,id:'inferno'}
], {to:'pyrebound', level:18, catalyst:'catalyst_cinder'});
sp('aqubbin', 'Aqubbin', 'tidal', {hp:55,atk:50,def:50,spd:55}, 'A bobbing spirit of tide pools that hums in three harmonies.', [
{lv:1,id:'water_lash'},{lv:3,id:'bubble_veil'},{lv:8,id:'brine_spray'},{lv:15,id:'tidal_slam'},{lv:24,id:'deluge'}
], {to:'aquillon', level:18, catalyst:'catalyst_tidal'});
sp('embermite', 'Embermite', 'cinder', {hp:45,atk:65,def:35,spd:75}, 'A skittering insect forged in cooling magmatic glass.', [
{lv:1,id:'flame_lash'},{lv:5,id:'fortify'},{lv:11,id:'cinder_burst'},{lv:20,id:'inferno'}
], null);
sp('rippling', 'Rippling', 'tidal', {hp:52,atk:48,def:48,spd:70}, 'A sinuous water-snake that is difficult to see head or tail.', [
{lv:1,id:'water_lash'},{lv:4,id:'frost_bite'},{lv:10,id:'current'},{lv:18,id:'tidal_slam'},{lv:26,id:'deluge'}
], {to:'torrentusk', level:20, catalyst:'catalyst_tidal'});
sp('mosscub', 'Mosscub', 'verdant', {hp:70,atk:55,def:55,spd:40}, 'A sleepy bear cub whose fur hosts a miniature garden.', [
{lv:1,id:'thorn_whip'},{lv:5,id:'soothing_wind'},{lv:12,id:'vine_lash'},{lv:21,id:'overgrowth'}
], {to:'canopytitan', level:20, catalyst:'catalyst_verdant'});
sp('shadelet', 'Shadelet', 'umbral', {hp:45,atk:58,def:42,spd:70}, 'A fluttering patch of darkness with bright, watchful eyes.', [
{lv:1,id:'shadow_claw'},{lv:4,id:'night_veil'},{lv:9,id:'void_pulse'},{lv:17,id:'terror'},{lv:25,id:'eclipse'}
], {to:'noctilisk', level:20, catalyst:'catalyst_umbral'});
sp('gleamoth', 'Gleamoth', 'lumin', {hp:50,atk:52,def:45,spd:70}, 'A lantern-moth whose wings leave trails of soft afterimages.', [
{lv:1,id:'radiant_beam'},{lv:4,id:'halo'},{lv:9,id:'spark_trip'},{lv:16,id:'purge'},{lv:25,id:'solar_flare'}
], {to:'luxalis', level:20, catalyst:'catalyst_lumin'});
sp('novaling', 'Novaling', 'aether', {hp:48,atk:54,def:42,spd:75}, 'A newborn star-given form that flickers between here and elsewhere.', [
{lv:1,id:'astral_ray'},{lv:4,id:'phase_shift'},{lv:10,id:'comet_shard'},{lv:19,id:'gravity_well'},{lv:26,id:'supernova'}
], {to:'quasarion', level:20, catalyst:'catalyst_aether'});
sp('voidlet', 'Voidlet', 'null', {hp:55,atk:52,def:60,spd:35}, 'A quiet hollow that drinks surrounding sound.', [
{lv:1,id:'null_pulse'},{lv:5,id:'void_anchor'},{lv:11,id:'erase'},{lv:18,id:'oblivion_grasp'},{lv:26,id:'singular_strike'}
], {to:'abscission', level:22, catalyst:'catalyst_null'});
sp('cinderwisp', 'Cinderwisp', 'cinder', {hp:38,atk:62,def:35,spd:80}, 'A floating ember with the temperament of a housecat.', [
{lv:1,id:'flame_lash'},{lv:6,id:'kindle'},{lv:13,id:'pyre_claw'}
], null);
sp('bubblefin', 'Bubblefin', 'tidal', {hp:65,atk:48,def:55,spd:45}, 'A round fish wrapped in a bubble of compressed brine.', [
{lv:1,id:'water_lash'},{lv:6,id:'bubble_veil'},{lv:13,id:'brine_spray'}
], null);
sp('petalfox', 'Petalfox', 'verdant', {hp:55,atk:58,def:45,spd:60}, 'A fox-kit whose tail blossoms with fragrant petals.', [
{lv:1,id:'thorn_whip'},{lv:6,id:'pollen_cloud'},{lv:14,id:'vine_lash'}
], null);
sp('gloomoth', 'Gloomoth', 'umbral', {hp:52,atk:55,def:48,spd:60}, 'A dusky moth drawn to the last light of day.', [
{lv:1,id:'shadow_claw'},{lv:6,id:'drain_life'},{lv:14,id:'night_veil'}
], null);
sp('lanternid', 'Lanternid', 'lumin', {hp:48,atk:56,def:40,spd:75}, 'A tiny jelly-like being that glows in rhythmic pulses.', [
{lv:1,id:'radiant_beam'},{lv:6,id:'illuminate'},{lv:14,id:'photon_rush'}
], null);
sp('starling', 'Starling', 'aether', {hp:45,atk:52,def:42,spd:78}, 'A crystalline bird that can fold its wings through space.', [
{lv:1,id:'astral_ray'},{lv:6,id:'gale'},{lv:14,id:'comet_shard'}
], null);
sp('nullark', 'Nullark', 'null', {hp:60,atk:50,def:65,spd:32}, 'A heavy thing of dull stone that absorbs color.', [
{lv:1,id:'null_pulse'},{lv:7,id:'void_anchor'},{lv:15,id:'erase'}
], null);
sp('fluxbug', 'Fluxbug', ['aether','cinder'], {hp:45,atk:58,def:42,spd:72}, 'A metal-shelled beetle that arcs with unstable matter.', [
{lv:1,id:'astral_ray'},{lv:5,id:'flame_lash'},{lv:12,id:'comet_shard'}
], null);
// Transformed forms (9)
sp('thornregent', 'Thornregent', ['verdant','cinder'], {hp:75,atk:88,def:72,spd:65}, 'A noble growth crowned in everlasting flame-flowers.', [
{lv:1,id:'thorn_whip'},{lv:1,id:'flame_lash'},{lv:22,id:'overgrowth'},{lv:30,id:'cinder_burst'},{lv:40,id:'scorched_earth'}
], null);
sp('pyrebound', 'Pyrebound', 'cinder', {hp:72,atk:95,def:65,spd:80}, 'A bounding fire-fox with tails of solid heat.', [
{lv:1,id:'flame_lash'},{lv:1,id:'pyre_claw'},{lv:24,id:'inferno'},{lv:34,id:'cinder_burst'},{lv:44,id:'scorched_earth'}
], null);
sp('aquillon', 'Aquillon', 'tidal', {hp:80,atk:82,def:78,spd:60}, 'A proud crested serpent that rides coastal storms.', [
{lv:1,id:'water_lash'},{lv:1,id:'current'},{lv:24,id:'deluge'},{lv:35,id:'tidal_slam'},{lv:45,id:'brine_spray'}
], null);
sp('torrentusk', 'Torrentusk', ['tidal','verdant'], {hp:85,atk:80,def:82,spd:48}, 'A river-beast whose tusks weep with living algae.', [
{lv:1,id:'water_lash'},{lv:1,id:'vine_lash'},{lv:26,id:'deluge'},{lv:36,id:'overgrowth'}
], null);
sp('canopytitan', 'Canopytitan', 'verdant', {hp:95,atk:82,def:85,spd:42}, 'A gentle giant whose back is a moving forest.', [
{lv:1,id:'vine_lash'},{lv:1,id:'soothing_wind'},{lv:22,id:'canopy'},{lv:34,id:'overgrowth'}
], null);
sp('noctilisk', 'Noctilisk', 'umbral', {hp:70,atk:90,def:62,spd:82}, 'A sleek predator that exists only in peripheral vision.', [
{lv:1,id:'shadow_claw'},{lv:1,id:'void_pulse'},{lv:26,id:'eclipse'},{lv:38,id:'umbral_feast'}
], null);
sp('luxalis', 'Luxalis', 'lumin', {hp:68,atk:78,def:62,spd:92}, 'A radiant aerial monarch with wings like lighthouse glass.', [
{lv:1,id:'radiant_beam'},{lv:1,id:'halo'},{lv:26,id:'solar_flare'},{lv:38,id:'photon_rush'}
], null);
sp('quasarion', 'Quasarion', 'aether', {hp:72,atk:86,def:60,spd:88}, 'A fully realized stellar entity barely tethered to matter.', [
{lv:1,id:'astral_ray'},{lv:1,id:'phase_shift'},{lv:26,id:'supernova'},{lv:40,id:'gravity_well'},{lv:46,id:'nebula_heal'}
], null);
sp('abscission', 'Abscission', 'null', {hp:88,atk:78,def:92,spd:44}, 'A walking absence that separates cause from effect.', [
{lv:1,id:'null_pulse'},{lv:1,id:'erase'},{lv:28,id:'singular_strike'},{lv:40,id:'entropy'}
], null);
return s;
})();
// ── const: speciesById ──
const speciesById = (id) => SPECIES.find(s=>s.id===id) || SPECIES[0];
// ── const: STARTERS ──
const STARTERS = ['spriglin','kindlekit','aqubbin'];
// ── const: WILD_TABLES ──
const WILD_TABLES = {
emberglade: ['kindlekit','embermite','cinderwisp','spriglin','petalfox'],
mistwood: ['spriglin','mosscub','petalfox','bubblefin','gloomoth'],
deepreaches: ['aqubbin','rippling','bubblefin','fluxbug','shadelet'],
voidcoast: ['shadelet','gloomoth','nullark','cinderwisp','lanternid'],
shimmerpeak: ['gleamoth','lanternid','starling','petalfox','novaling'],
aethercrux: ['novaling','starling','nullark','fluxbug','luxalis']
};
// ── const: ITEMS ──
const ITEMS = [
{ id:'bind_thread', name:'Loomthread', type:'capture', tier:1, price:120, desc:'A basic thread for binding weakened Aetherlings.' },
{ id:'bind_orb', name:'Woven Orb', type:'capture', tier:2, price:360, desc:'A resonant orb that improves binding chance.' },
{ id:'bind_prism', name:'Binding Prism', type:'capture', tier:3, price:900, desc:'A prismatic focus that binds even stubborn souls.' },
{ id:'drop_essence', name:'Essence Drop', type:'heal', power:60, price:80, desc:'Restores a moderate amount of HP.' },
{ id:'flask_essence',name:'Essence Flask', type:'heal', power:999, price:250, desc:'Restores full HP.' },
{ id:'energy_pill', name:'Charge Pill', type:'energy', power:50, price:60, desc:'Restores 50 energy.' },
{ id:'revive_fiber', name:'Revive Fiber', type:'revive', power:0.4, price:400, desc:'Revives a fallen ally with partial HP.' },
{ id:'catalyst_cinder', name:'Cinder Catalyst', type:'catalyst', price:300, desc:'Ignites growth in compatible Aetherlings.' },
{ id:'catalyst_tidal', name:'Tidal Catalyst', type:'catalyst', price:300, desc:'Ripens potential through ancient pressure.' },
{ id:'catalyst_verdant', name:'Verdant Catalyst', type:'catalyst', price:300, desc:'A seed-catalyst of verdant transformation.' },
{ id:'catalyst_umbral', name:'Umbral Catalyst', type:'catalyst', price:300, desc:'A shadowed stone that reshapes form.' },
{ id:'catalyst_lumin', name:'Lumin Catalyst', type:'catalyst', price:300, desc:'A sun-drop catalyst of radiant change.' },
{ id:'catalyst_aether', name:'Aether Catalyst', type:'catalyst', price:300, desc:'A shard of folded space that awakens stellar lineage.' },
{ id:'catalyst_null', name:'Null Catalyst', type:'catalyst', price:300, desc:'A void bead that unmakes a form into its true shape.' }
];
// ── const: itemById ──
const itemById = (id) => ITEMS.find(i=>i.id===id)||null;
// ── const: SHOP_STOCK ──
const SHOP_STOCK = ['bind_thread','bind_orb','drop_essence','flask_essence','energy_pill','revive_fiber','catalyst_cinder','catalyst_tidal','catalyst_verdant','catalyst_umbral','catalyst_lumin','catalyst_aether','catalyst_null'];
// ── const: Game ──
const Game = {
mode:'title', slot:0,
player:{zoneIndex:0,x:1,y:6,facing:'right'},
flags:{tutorial:false,bosses:[false,false,false,false,false,false],anchors:0,story:0,unique:new Set(),postGame:false},
inventory:{},
party:[], box:[], zones:[],
map:null, // {grid, W, H, tile, shrine, boss, gate, start}
options:{mute:false},
battle:null,
particles:[],
pending:[],
tutorialStep:0,
dialogueQueue:[], dialogueCallback:null,
shake:0, flash:0
};
// ── function: initInventory ──
function initInventory() {
const inv={};
for(const it of ITEMS) inv[it.id]=0;
inv['bind_thread']=5;
return inv;
}
// ── function: resetGame ──
function resetGame(slot) {
Game.mode='title'; Game.slot=slot;
Game.player={zoneIndex:0,x:1,y:6,facing:'right'};
Game.flags={tutorial:false,bosses:[false,false,false,false,false,false],anchors:0,story:0,unique:new Set(),postGame:false};
Game.inventory=initInventory();
Game.party=[]; Game.box=[]; Game.zones=ZONES.map(()=>({bossDefeated:false, shrine:false}));
Game.battle=null; Game.particles=[]; Game.pending=[]; Game.tutorialStep=0;
Game.dialogueQueue=[]; Game.dialogueCallback=null;
}
// ── function: ownedSpecies ──
function ownedSpecies() {
const set=new Set([...Game.party,...Game.box].map(c=>c.speciesId));
for(const id of Game.flags.unique) set.add(id);
return set;
}
// ── function: uniqueCount ──
function uniqueCount() { return ownedSpecies().size; }
// ── function: calcStats ──
function calcStats(speciesId, level) {
const s = speciesById(speciesId);
const hp = Math.floor(s.baseStats.hp * (1.4 + level*0.13));
const atk = Math.floor(s.baseStats.atk * (0.5 + level*0.08));
const def = Math.floor(s.baseStats.def * (0.5 + level*0.08));
const spd = Math.floor(s.baseStats.spd * (0.5 + level*0.07));
return {hp, maxHp:hp, atk, def, spd};
}
// ── function: createCreature ──
function createCreature(speciesId, level, extras={}) {
const s = speciesById(speciesId);
const stats = calcStats(speciesId, level);
const c = {
uid: extras.uid || Math.random().toString(36).slice(2,10),
speciesId, level, exp:0,
nickname: extras.nickname || s.name,
hp: stats.hp,
maxHp: stats.maxHp,
atk: stats.atk, def: stats.def, spd: stats.spd,
energy: 100, maxEnergy:100,
statuses:{}, abilities:[...s.learnset.filter(l=>l.lv<=level).map(l=>l.id)].slice(-4)
};
if(!c.abilities.length) c.abilities = s.learnset.slice(0,1).map(l=>l.id);
return c;
}
// ── function: refreshStats ──
function refreshStats(c) {
const stats = calcStats(c.speciesId, c.level);
const ratio = c.hp / c.maxHp;
c.maxHp = stats.maxHp; c.atk = stats.atk; c.def = stats.def; c.spd = stats.spd;
c.hp = Math.max(1, Math.min(c.maxHp, Math.ceil(c.maxHp * ratio)));
}
// ── function: getKnownAbilities ──
function getKnownAbilities(c) {
const s = speciesById(c.speciesId);
return s.learnset.filter(l=>l.lv<=c.level).slice(-4).map(l=>l.id);
}
// ── function: learnAbilitiesAfterLevel ──
function learnAbilitiesAfterLevel(c) {
const s = speciesById(c.speciesId);
const known = s.learnset.filter(l=>l.lv<=c.level).map(l=>l.id);
// Auto-equip newest up to 4
c.abilities = known.slice(-4);
}
// ── function: xpToNext ──
function xpToNext(level) {
return Math.floor(50 * Math.pow(level,1.45));
}
// ── function: xpGain ──
function xpGain(defeatedLevel, ownLevel, strength=1) {
const rel = defeatedLevel - ownLevel + 6;
return Math.max(5, Math.floor(30 * rel * strength / 6));
}
// ── function: gainExp ──
function gainExp(c, amount) {
if(!c || c.level>=LEVEL_CAP) return 0;
c.exp += amount; let gained=0;
while(c.level<LEVEL_CAP && c.exp >= xpToNext(c.level)) {
c.exp -= xpToNext(c.level); c.level++;
refreshStats(c); learnAbilitiesAfterLevel(c);
// fully heal on level up as reward
c.hp=c.maxHp; c.energy=c.maxEnergy;
gained++;
}
return gained;
}
// ── function: checkEvolution ──
function checkEvolution(c) {
const s = speciesById(c.speciesId);
if(!s.evolution) return null;
const ev = s.evolution;
if(c.level < ev.level) return null;
if(ev.catalyst && (Game.inventory[ev.catalyst]||0)<=0) return null;
return ev;
}
// ── function: evolveCreature ──
function evolveCreature(c) {
const s = speciesById(c.speciesId);
if(!s.evolution) return false;
const ev = s.evolution;
if(ev.catalyst) Game.inventory[ev.catalyst]--;
const oldRatio = c.hp/c.maxHp;
c.speciesId = ev.to;
refreshStats(c); learnAbilitiesAfterLevel(c);
c.hp = Math.max(1, Math.floor(c.maxHp * oldRatio));
return ev.to;
}
// ── function: generateZoneMap ──
function generateZoneMap(zoneIndex) {
const W=20, H=12, tile=48;
const grid = Array.from({length:H},()=>Array(W).fill(1)); // default grass
const start={x:1,y:Math.floor(H/2)};
const gateY=start.y;
const gate={x:W-1,y:gateY};
const boss={x:W-3,y:gateY+1};
const shrine={x:2,y:gateY};
// carve a winding path from start to near gate
let x=0, y=gateY;
for(x=0;x<W-2;x++) {
grid[y][x]=0;
if(Math.random()<0.35 && y>1 && y<H-2) {
// meander
const dy = Math.random()<0.5 ? -1 : 1;
if(grid[y+dy][x]!==0) { grid[y+dy][x]=0; }
y = y+dy;
grid[y][x]=0;
}
}
// ensure start column and last part clear
for(let yy=0;yy<H;yy++) for(let xx=0;xx<3;xx++) if(Math.abs(yy-gateY)<=2) grid[yy][xx]=0;
for(let xx=0;xx<W;xx++) if(Math.abs(xx-(W-1))<=2) grid[gateY][xx]=0;
// clear area around shrine
for(let yy=gateY-1;yy<=gateY+1;yy++) for(let xx=1;xx<=3;xx++) grid[yy>0&&yy<H?yy:gateY][xx]=0;
// entities
grid[shrine.y][shrine.x]=2;
grid[boss.y][boss.x]=3;
grid[gate.y][gate.x]=4;
return {grid,W,H,tile,start:{x:1,y:gateY},shrine,boss,gate};
}
// ── function: loadZone ──
function loadZone(zoneIndex) {
Game.player.zoneIndex = zoneIndex;
const m = generateZoneMap(zoneIndex);
Game.map = m;
// place player at start
Game.player.x = m.start.x; Game.player.y=m.start.y; Game.player.facing='right';
}
// ── function: tileAt ──
function tileAt(x,y) {
if(!Game.map) return 0;
if(y<0||y>=Game.map.H||x<0||x>=Game.map.W) return 4; // block
return Game.map.grid[y][x];
}
// ── function: canMoveTo ──
function canMoveTo(x,y) {
const t = tileAt(x,y);
if(t===4) return Game.flags.bosses[Game.player.zoneIndex]; // gate unlocked if boss defeated
return true; // all else passable
}
// ── function: movePlayer ──
function movePlayer(dx,dy) {
if(Game.mode!=='overworld') return;
if(!document.getElementById('menu-modal').hidden || !document.getElementById('collection-overlay').hidden) return;
const nx=Game.player.x+dx, ny=Game.player.y+dy;
const dir = dx>0?'right':dx<0?'left':dy>0?'down':'up';
Game.player.facing = dir;
if(!canMoveTo(nx,ny)) return;
Game.player.x = nx; Game.player.y = ny;
AudioEngine.sfx('step');
const t = tileAt(nx,ny);
if(t===2) { arriveShrine(); return; }
if(t===3) { approachBoss(); return; }
if(t===4 && Game.flags.bosses[Game.player.zoneIndex]) { changeZone(); return; }
const zone = ZONES[Game.player.zoneIndex];
if(t===1 && Math.random() < zone.rate) {
startEncounter();
}
}
// ── function: changeZone ──
function changeZone() {
const next = Game.player.zoneIndex + 1;
if(next < ZONES.length) { loadZone(next); showToast(`Entered ${ZONES[next].name}`); }
else if(Game.flags.postGame) { loadZone(0); showToast('Returned to Emberglade'); }
}
// ── function: arriveShrine ──
function arriveShrine() {
openShrineMenu();
}
// ── function: approachBoss ──
function approachBoss() {
const z = ZONES[Game.player.zoneIndex];
showDialogue([`The zone anchor ${z.boss} stirs nearby. Challenge it?`], (choice)=>{
if(choice===0) startBossBattle();
else Game.mode='overworld';
}, ['Fight','Leave']);
}
// ── function: startEncounter ──
function startEncounter() {
const z = ZONES[Game.player.zoneIndex];
const table = WILD_TABLES[z.id];
if(!table || !table.length) return;
const count = Math.random()<0.8?1:(Math.random()<0.5?2:3);
const ids=[]; for(let i=0;i<count;i++) ids.push(table[Math.floor(Math.random()*table.length)]);
let baseLv = Math.max(2, z.level - 4 + Math.floor(Math.random()*5));
if(Game.flags.postGame) baseLv += 10;
startBattle(ids, baseLv, false);
}
// ── function: startBossBattle ──
function startBossBattle() {
const z = ZONES[Game.player.zoneIndex];
startBattle([speciesIdForBoss(z.boss)], z.level+3, true);
}
// ── function: speciesIdForBoss ──
function speciesIdForBoss(bossName) {
const map={'Pyrelith':'embermite','Thornregent':'mosscub','Abyssalene':'rippling','Nocturnex':'shadelet','Solaramis':'gleamoth','Astrion Prime':'novaling'};
const id = map[bossName];
if(Math.random()<0.5) return id;
// sometimes use alternate form
const s=speciesById(id); if(s.evolution) return s.evolution.to;
return id;
}
// ── function: startBattle ──
function startBattle(enemySpeciesIds, baseLevel, isBoss) {
if(Game.party.filter(c=>c.hp>0).length===0) { showToast('Your party has fallen. Heal at a shrine.'); return; }
Game.mode='battle';
// ensure enemy levels relevant
const avg = Math.max(1, averagePartyLevel());
const level = Math.max(baseLevel, Math.round(avg + (isBoss?2:-1) + Math.floor(Math.random()*3)));
const enemies = enemySpeciesIds.map((sid,i)=> {
const e = createCreature(sid, Math.min(LEVEL_CAP, level+Math.floor(Math.random()*2)));
e.isWild = !isBoss; e.isBoss = isBoss;
return e;
});
Game.battle = {
enemies,
queue:[], index:0,
message:[],
state:'input',
selectedMove:null,
selectedActorIndex:0,
turnCount:0,
escaped:false,
log:[]
};
addBattleLog(`A ${isBoss?'zone anchor':'wild encounter'} begins!`);
openBattleScreen();
nextTurn();
}
// ── function: averagePartyLevel ──
function averagePartyLevel() {
if(!Game.party.length) return 1;
return Game.party.reduce((a,c)=>a+c.level,0)/Game.party.length;
}
// ── function: effectiveSpeed ──
function effectiveSpeed(c) {
let s = c.spd * stageMultiplier(c.statuses.spd_up||0, c.statuses.speed_down||0);
if(c.statuses.frost) s*=0.5;
return s;
}
// ── function: stageMultiplier ──
function stageMultiplier(up=0,down=0) { return 1 + up*0.3 - down*0.25; }
// ── function: effectiveAtk ──
function effectiveAtk(c) { return Math.max(1, Math.floor(c.atk * stageMultiplier(c.statuses.atk_up||0, c.statuses.atk_down||0) * (c.statuses.burn?0.5:1))); }
// ── function: effectiveDef ──
function effectiveDef(c) { return Math.max(1, Math.floor(c.def * stageMultiplier(c.statuses.def_up||0,0))); }
// ── function: buildQueue ──
function buildQueue() {
const q=[];
Game.party.forEach((c,i)=>{ if(c.hp>0) q.push({side:'ally',index:i,speed:effectiveSpeed(c)}); });
Game.battle.enemies.forEach((c,i)=>{ if(c.hp>0) q.push({side:'enemy',index:i,speed:effectiveSpeed(c)}); });
q.sort((a,b)=>b.speed-a.speed);
return q;
}
// ── function: currentActor ──
function currentActor() {
const b = Game.battle; if(b.index>=b.queue.length) return null;
return b.queue[b.index];
}
// ── function: nextTurn ──
function nextTurn() {
const b=Game.battle; if(!b) return;
if(b.queue.length===0 || b.index>=b.queue.length) {
// check end states
if(b.enemies.every(e=>e.hp<=0)) { return battleWin(); }
if(Game.party.every(c=>c.hp<=0)) { return battleLoss(); }
// apply per-turn status effects
applyTurnTicks();
b.turnCount++;
b.queue=buildQueue();
b.index=0;
}
// skip fainted or escaped
while(b.index<b.queue.length) {
const actor = currentActor();
const cr = getCreature(actor.side, actor.index);
if(!cr || cr.hp<=0) { b.index++; continue; }
if(cr.statuses.shock && Math.random()<STATUS_INFO.shock.chance) {
addBattleLog(`${cr.nickname} is shocked and cannot move!`);
b.index++; continue;
}
break;
}
if(b.index>=b.queue.length) { nextTurn(); return; }
const actor=currentActor();
if(actor.side==='ally') {
b.state='input';
b.selectedActorIndex=actor.index;
renderAbilitiesMenu();
} else {
b.state='busy';
setTimeout(enemyAction, 650);
}
}
// ── function: getCreature ──
function getCreature(side, idx) {
return side==='ally'?Game.party[idx]:Game.battle.enemies[idx];
}
// ── function: applyTurnTicks ──
function applyTurnTicks() {
for(const c of [...Game.party,...Game.battle.enemies]) {
if(c.hp<=0) continue;
if(c.statuses.burn) {
const dmg = Math.max(1, Math.floor(c.maxHp*STATUS_INFO.burn.tick));
c.hp = Math.max(1, c.hp-dmg);
addBattleLog(`${c.nickname} is hurt by burn.`);
}
if(c.statuses.regen) {
const heal = Math.max(1, Math.floor(c.maxHp*STATUS_INFO.regen.tick));
c.hp = Math.min(c.maxHp, c.hp+heal);
addBattleLog(`${c.nickname} regenerates.`);
}
}
}
// ── function: addBattleLog ──
function addBattleLog(text) {
const b=Game.battle; if(!b) return;
b.log.unshift(text); if(b.log.length>24) b.log.pop();
const el=document.getElementById('battle-log'); if(el) { el.innerHTML=b.log.slice(0,3).map(t=>`<p>${t}</p>`).join(''); }
}
// ── function: useAbility ──
function useAbility(actorIdx, abilityId, targetSide, targetIdx) {
const actor = Game.party[actorIdx];
const ab = abilityById(abilityId);
if(!actor || actor.hp<=0 || actor.energy<ab.cost) { return; }
actor.energy -= ab.cost;
Game.battle.state='busy';
if(Math.random()*100 > ab.acc) {
addBattleLog(`${actor.nickname} used ${ab.name} but missed!`);
advanceQueue(); return;
}
const targets = [];
if(ab.targetAll) {
Game.battle.enemies.forEach((e,i)=>{ if(e.hp>0) targets.push({side:'enemy',index:i,cr:e}); });
} else {
const t = getCreature(targetSide, targetIdx);
if(t && t.hp>0) targets.push({side:targetSide,index:targetIdx,cr:t});
}
if(targets.length===0) { addBattleLog('No target!'); advanceQueue(); return; }
for(const t of targets) resolveHit(actor, ab, t.cr, t.side, t.index);
// Support effects apply to first target (which is actor for self-help moves, ally for heals)
if(ab.effect && ['self_atk_up','self_def_up','self_spd_up','self_barrier','self_regen','heal','regen','barrier','atk_up','def_up','spd_up','atk_down','accuracy_down','speed_down'].includes(ab.effect)) {
applyEffect(actor, ab, targets[0].cr);
}
advanceQueue();
}
// ── function: resolveHit ──
function resolveHit(actor, ab, target, side, idx) {
let typeMul = 1;
const sp = speciesById(target.speciesId);
for(const a of sp.affinities) typeMul *= TYPE_MATRIX[ab.aff][a];
const stab = speciesById(actor.speciesId).affinities.includes(ab.aff)?1.25:1;
const crit = Math.random()<0.06 ? 1.5 : 1;
const base = Math.floor(0.4 * actor.level * effectiveAtk(actor) / effectiveDef(target) * typeMul * stab * crit);
const dmg = Math.max(1, base + 1 + Math.floor(Math.random()*3));
// barrier deflect one non-support hit and then remove
if(target.statuses.barrier && ab.power>0) {
target.statuses.barrier=false;
addBattleLog(`${target.nickname}'s barrier deflected the blow!`);
return;
}
target.hp = Math.max(0, target.hp-dmg);
// drain
if(ab.effect==='drain' && ab.power>0 && Math.random()<(ab.chance||1)) {
const heal = Math.max(1, Math.floor(dmg*0.5));
actor.hp = Math.min(actor.maxHp, actor.hp+heal);
addBattleLog(`${actor.nickname} drained ${heal} HP.`);
}
// effect chance
if(ab.effect && !['self_atk_up','self_def_up','self_spd_up','self_barrier','self_regen','heal','drain'].includes(ab.effect)) {
if(Math.random()<(ab.chance||1)) applyEffect(actor, ab, target);
}
const effText = typeMul>1?'super effective':typeMul<1?'resisted':'';
const critText = crit>1?'critical ':'';
addBattleLog(`${actor.nickname} used ${ab.name} for <span class="${crit>1?'crit':typeMul>1?'super':''}">${critText}${dmg}</span> ${effText}`);
if(typeMul>1) Game.shake=10;
if(crit>1) Game.flash=6;
if(target.hp<=0) {
addBattleLog(`${target.nickname} fell!`);
}
}
// ── function: applyEffect ──
function applyEffect(actor, ab, target) {
const fx=ab.effect;
if(fx==='burn') target.statuses.burn=true;
if(fx==='frost') target.statuses.frost=true;
if(fx==='shock') target.statuses.shock=true;
if(fx==='regen' || fx==='self_regen') target.statuses.regen=true;
if(fx==='barrier' || fx==='self_barrier') target.statuses.barrier=true;
if(fx==='atk_up' || fx==='self_atk_up') target.statuses.atk_up=(target.statuses.atk_up||0)+1;
if(fx==='def_up' || fx==='self_def_up') target.statuses.def_up=(target.statuses.def_up||0)+1;
if(fx==='spd_up' || fx==='self_spd_up') target.statuses.spd_up=(target.statuses.spd_up||0)+1;
if(fx==='atk_down') target.statuses.atk_down=(target.statuses.atk_down||0)+1;
if(fx==='accuracy_down') target.statuses.accuracy_down=(target.statuses.accuracy_down||0)+1;
if(fx==='speed_down') target.statuses.speed_down=(target.statuses.speed_down||0)+1;
if(fx==='heal' || fx==='self_regen') {
const h = Math.floor(target.maxHp*(ab.heal||0.35));
target.hp = Math.min(target.maxHp, target.hp+h);
addBattleLog(`${target.nickname} recovered ${h} HP.`);
}
}
// ── function: advanceQueue ──
function advanceQueue() {
if(!Game.battle) return;
Game.battle.index++;
setTimeout(nextTurn, 600);
}
// ── function: enemyAction ──
function enemyAction() {
const b=Game.battle;
const actor = currentActor(); if(!actor || actor.side!=='enemy') { nextTurn(); return; }
const enemy = b.enemies[actor.index];
if(enemy.hp<=0) { b.index++; nextTurn(); return; }
const abs = enemy.abilities.filter(id=>abilityById(id).cost<=enemy.energy);
const abId = abs.length?abs[Math.floor(Math.random()*abs.length)]:enemy.abilities[0];
const ab = abilityById(abId);
if(enemy.energy<ab.cost) { addBattleLog(`${enemy.nickname} is too tired to act!`); advanceQueue(); return; }
enemy.energy -= ab.cost;
if(Math.random()*100 > ab.acc) { addBattleLog(`${enemy.nickname} used ${ab.name} but missed!`); advanceQueue(); return; }
// pick target alive ally
const alive = Game.party.map((c,i)=>({c,i})).filter(x=>x.c.hp>0);
if(!alive.length) { advanceQueue(); return; }
// basic AI: target randomly
const target = alive[Math.floor(Math.random()*alive.length)];
if(ab.targetAll) {
Game.party.forEach((c,i)=>{ if(c.hp>0) resolveHit(enemy,ab,c,'ally',i); });
} else resolveHit(enemy, ab, target.c, 'ally', target.i);
if(['self_atk_up','self_def_up','self_spd_up','self_barrier','self_regen','heal'].includes(ab.effect)) applyEffect(enemy,ab,enemy);
advanceQueue();
}
// ── function: switchCreature ──
function switchCreature(fromIdx, toIdx) {
const b=Game.battle;
if(toIdx<0 || toIdx>=Game.party.length || Game.party[toIdx].hp<=0) return false;
b.selectedActorIndex = toIdx;
addBattleLog(`${Game.party[toIdx].nickname} enters the weave.`);
advanceQueue();
return true;
}
// ── function: attemptFlee ──
function attemptFlee() {
const b=Game.battle;
const allySpd = Game.party.reduce((a,c)=>a+(c.hp>0?effectiveSpeed(c):0),0);
const enemySpd = b.enemies.reduce((a,c)=>a+effectiveSpeed(c),0);
const chance = Math.min(0.9, 0.45 + (allySpd-enemySpd)*0.01);
if(Math.random()<chance) {
addBattleLog('You fled safely.');
setTimeout(closeBattle, 800);
} else {
addBattleLog('Flee failed!');
advanceQueue();
}
}
// ── function: useBattleItem ──
function useBattleItem(itemId, targetIdx) {
const item=itemById(itemId); if(!item) return;
if((Game.inventory[itemId]||0)<=0) return;
Game.inventory[itemId]--;
const t=Game.party[targetIdx];
if(item.type==='heal') { const h=item.power>=999?t.maxHp-t.hp:Math.min(item.power,t.maxHp-t.hp); t.hp+=h; addBattleLog(`${t.nickname} healed ${h} HP.`); }
if(item.type==='energy') { const e=Math.min(item.power,t.maxEnergy-t.energy); t.energy+=e; addBattleLog(`${t.nickname} restored ${e} energy.`); }
if(item.type==='revive' && t.hp<=0) { t.hp=Math.floor(t.maxHp*item.power); addBattleLog(`${t.nickname} revived!`); }
advanceQueue();
}
// ── function: battleWin ──
function battleWin() {
const b=Game.battle;
const isBoss = b.enemies[0]?.isBoss;
const highestEnemyLv = Math.max(...b.enemies.map(e=>e.level));
addBattleLog('Victory!');
let gained=false;
for(const c of Game.party) {
if(c.hp>0) {
const x = xpGain(highestEnemyLv, c.level, 1);
const lvls = gainExp(c, x);
addBattleLog(`${c.nickname} gained ${x} resonance` + (lvls?` and grew ${lvls} level(s)!`:'.'));
if(lvls) gained=true;
}
}
const reward = 25 + highestEnemyLv*4 + (isBoss?200:0);
Game.inventory.coin = (Game.inventory.coin||0)+reward;
addBattleLog(`Found ${reward} lumin.`);
if(isBoss) {
const zidx = Game.player.zoneIndex;
Game.flags.bosses[zidx]=true;
Game.flags.anchors++;
if(zidx<4) Game.flags.story = Math.min(4, Game.flags.anchors);
if(Game.flags.anchors>=4 && !Game.flags.postGame) Game.flags.postGame=true;
saveGame(Game.slot);
if(Game.flags.anchors<=4) {
showStoryCutIn(Game.flags.anchors, closeBattle);
} else {
showDialogue([`The anchor ${ZONES[zidx].boss} is sealed.`, 'The zone grows quiet.'], ()=>closeBattle());
}
} else {
setTimeout(closeBattle, 900);
}
}
// ── function: battleLoss ──
function battleLoss() {
addBattleLog('Your party was overcome...');
setTimeout(()=>{
showDialogue(['The weave returns you to the last shrine. Your companions recover.'],()=>{
// heal and return
Game.party.forEach(c=>{c.hp=c.maxHp;c.energy=c.maxEnergy;});
loadZone(Game.player.zoneIndex);
closeBattle();
});
},1200);
}
// ── function: closeBattle ──
function closeBattle() {
Game.battle=null;
closeBattleScreen();
Game.mode='overworld';
saveGame(Game.slot);
}
// ── function: openCaptureChoice ──
function openCaptureChoice() {
const b=Game.battle; if(!b) return;
const actor = Game.party[b.selectedActorIndex];
if(!actor || actor.hp<=0) return;
b.state='select';
const items = ITEMS.filter(i=>i.type==='capture' && (Game.inventory[i.id]||0)>0);
const el=document.getElementById('battle-menu');
el.innerHTML='';
if(!items.length) { el.innerHTML='<p>No binding items!</p>'; }
items.forEach(it=>{
const btn=document.createElement('button'); btn.className='move-btn';
btn.innerHTML=`<span class="move-name">${it.name}</span><span class="move-meta">Tier ${it.tier} • own ${Game.inventory[it.id]}</span>`;
btn.onclick=()=>{ startCaptureMiniGame(actor, it); };
el.appendChild(btn);
});
const back=document.createElement('button'); back.className='glass-btn'; back.textContent='Back';
back.onclick=()=>renderAbilitiesMenu();
el.appendChild(back);
}
// ── function: startCaptureMiniGame ──
function startCaptureMiniGame(actor, item) {
Game.battle.pendingItem = {actor, item};
const ov=document.getElementById('collection-overlay'); ov.hidden=false;
const result=document.getElementById('collect-result'); result.textContent='Press Bind when rings align.';
const tryBtn=document.getElementById('collect-try'); tryBtn.textContent='Bind';
tryBtn.onclick = submitCapture;
document.getElementById('collect-cancel').onclick = ()=>{ ov.hidden=true; renderAbilitiesMenu(); };
startMiniGameLoop();
}
// ── let: miniGameId ──
let miniGameId = null;
// ── const: miniGameState ──
const miniGameState = {angle:0, speed:0.02, targetAngle:0, targetWidth:0.25};
// ── function: startMiniGameLoop ──
function startMiniGameLoop() {
if(miniGameId) cancelAnimationFrame(miniGameId);
const cvs=document.getElementById('collect-canvas');
const ctx=cvs.getContext('2d');
miniGameState.angle=0; miniGameState.speed=0.03+Math.random()*0.02; miniGameState.targetAngle=Math.random()*Math.PI*2; miniGameState.targetWidth=0.35;
function frame(){
miniGameState.angle += miniGameState.speed;
miniGameState.angle %= Math.PI*2;
const cx=cvs.width/2, cy=cvs.height/2, r=90;
ctx.clearRect(0,0,cvs.width,cvs.height);
ctx.strokeStyle='rgba(124,224,255,0.45)'; ctx.lineWidth=12;
ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2); ctx.stroke();
ctx.fillStyle='rgba(124,224,255,0.22)';
ctx.beginPath(); ctx.moveTo(cx,cy); ctx.arc(cx,cy,r,miniGameState.targetAngle-miniGameState.targetWidth/2,miniGameState.targetAngle+miniGameState.targetWidth/2); ctx.closePath(); ctx.fill();
ctx.save(); ctx.translate(cx,cy); ctx.rotate(miniGameState.angle);
ctx.fillStyle='#ffd36e'; ctx.beginPath(); ctx.arc(0,-r+6,8,0,Math.PI*2); ctx.fill();
ctx.restore();
miniGameId = requestAnimationFrame(frame);
}
frame();
}
// ── function: submitCapture ──
function submitCapture() {
cancelAnimationFrame(miniGameId); miniGameId=null;
const {actor, item} = Game.battle.pendingItem || {};
if(!actor || !item) return;
let diff = Math.abs(miniGameState.angle - miniGameState.targetAngle);
diff = Math.min(diff, Math.PI*2-diff);
let multiplier = Math.max(0.4, 1.6 - diff/(miniGameState.targetWidth/2));
const tierMult = [0,1,1.35,1.9][item.tier]||1;
const targets = Game.battle.enemies.map((e,i)=>({e,i})).filter(x=>x.e.hp>0).sort((a,b)=>a.e.hp/b.e.maxHp - b.e.hp/b.e.maxHp);
const target = targets[0]; if(!target) return;
const hpRatio = target.e.hp / target.e.maxHp;
const baseCatch = 0.28;
const chance = Math.min(0.95, baseCatch * (1.6 - hpRatio) * tierMult * multiplier);
const success = Math.random() < chance;
Game.inventory[item.id]--;
const ov=document.getElementById('collection-overlay');
const res=document.getElementById('collect-result');
if(success) {
res.textContent=`Bound ${speciesById(target.e.speciesId).name}! Resonance ${(chance*100).toFixed(0)}%.`;
addCapturedCreature(target.e);
setTimeout(()=>{ ov.hidden=true; closeBattle(); }, 1200);
} else {
res.textContent=`Binding failed. Resonance ${(chance*100).toFixed(0)}%.`;
setTimeout(()=>{ ov.hidden=true; addBattleLog(`${target.e.nickname} broke free!`); advanceQueue(); }, 1000);
}
}
// ── function: addCapturedCreature ──
function addCapturedCreature(enemy) {
const captured = createCreature(enemy.speciesId, Math.max(1, enemy.level-1));
captured.hp = Math.min(captured.maxHp, Math.max(1, Math.floor(enemy.hp/enemy.maxHp * captured.maxHp)));
if(Game.party.length<6) Game.party.push(captured);
else Game.box.push(captured);
Game.flags.unique.add(enemy.speciesId);
showToast(`Captured ${captured.nickname}`);
}
// ── function: tryCapturePrompt ──
function tryCapturePrompt() {}
// ── const: AudioEngine ──
const AudioEngine = (function(){
let ctx=null, muted=false;
let track=null, nextNote=0;
function init() {
if(ctx) return;
try { ctx = new (window.AudioContext||window.webkitAudioContext)(); } catch(e){}
}
function resume() { init(); if(ctx && ctx.state==='suspended') ctx.resume(); }
function setMute(v){ muted=v; if(muted) stopAmbient(); else playAmbient(); }
function isMuted(){ return muted; }
function playTone(freq, type, duration, vol=0.1, delay=0) {
if(muted || !ctx) return;
const t0 = ctx.currentTime + delay;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type=type; osc.frequency.value=freq;
gain.gain.setValueAtTime(0,t0);
gain.gain.linearRampToValueAtTime(vol,t0+0.04);
gain.gain.exponentialRampToValueAtTime(0.001,t0+duration);
osc.connect(gain).connect(ctx.destination);
osc.start(t0); osc.stop(t0+duration);
}
function sfx(type) {
if(muted || !ctx) return;
switch(type){
case 'confirm': playTone(520,'sine',0.12,0.05); setTimeout(()=>playTone(880,'sine',0.18,0.05),80); break;
case 'cancel': playTone(320,'triangle',0.18,0.08); break;
case 'step': playTone(120,'sine',0.05,0.03); break;
case 'hit': playTone(180,'sawtooth',0.12,0.06); break;
case 'crit': playTone(660,'square',0.18,0.05); break;
case 'super': playTone(220,'sawtooth',0.25,0.08); break;
case 'capture': playTone(440,'sine',0.2,0.05); setTimeout(()=>playTone(660,'sine',0.4,0.06),150); break;
case 'win': arpeggio([440,554,659,880],0.12); break;
case 'level': playTone(740,'triangle',0.25,0.05); break;
case 'encounter': playTone(110,'sawtooth',0.4,0.08); break;
}
}
function arpeggio(freqs,dur){ freqs.forEach((f,i)=>playTone(f,'sine',dur,0.04,i*dur)); }
function playAmbient(){
stopAmbient();
if(muted || !ctx) return;
const notes=[196,220,247,262,294,330,294,262];
let i=0;
function schedule(){
if(muted||!track) return;
const when=ctx.currentTime+0.05;
const dur=1.8; const vol=0.02;
const osc=ctx.createOscillator(); const gain=ctx.createGain();
osc.type='sine'; osc.frequency.setValueAtTime(notes[i%notes.length],when);
gain.gain.setValueAtTime(0,when); gain.gain.linearRampToValueAtTime(vol,when+0.8); gain.gain.linearRampToValueAtTime(0,when+dur);
osc.connect(gain).connect(ctx.destination);
osc.start(when); osc.stop(when+dur);
i++;
track = setTimeout(schedule, 900);
}
track = setTimeout(schedule, 100);
}
function stopAmbient(){ if(track) { clearTimeout(track); track=null; } }
return {init,resume,setMute,isMuted,sfx,playAmbient,stopAmbient};
})();
// ── const: SAVE_KEY_PREFIX ──
const SAVE_KEY_PREFIX = 'loom_velmorath_save_';
// ── function: serializeState ──
function serializeState() {
return JSON.stringify({
player:Game.player,
flags:{...Game.flags, unique:Array.from(Game.flags.unique)},
inventory:Game.inventory,
party:Game.party,
box:Game.box,
zones:Game.zones,
options:Game.options
});
}
// ── function: deserializeState ──
function deserializeState(json) {
try {
const data = JSON.parse(json);
Game.player = data.player;
Game.flags = data.flags; Game.flags.unique = new Set(Game.flags.unique||[]);
Game.inventory = data.inventory; Game.party = data.party; Game.box = data.box;
Game.zones = data.zones; Game.options = data.options||{mute:false};
return true;
} catch(e){ return false; }
}
// ── function: saveGame ──
function saveGame(slot) {
try { localStorage.setItem(SAVE_KEY_PREFIX+slot, serializeState()); } catch(e){}
updateTitleButtons();
}
// ── function: loadGame ──
function loadGame(slot) {
const data = localStorage.getItem(SAVE_KEY_PREFIX+slot);
if(!data) return false;
resetGame(slot); // clear slot-specific? just so
if(!deserializeState(data)) return false;
Game.slot = slot;
updateTitleButtons();
return true;
}
// ── function: hasSave ──
function hasSave(slot) { return !!localStorage.getItem(SAVE_KEY_PREFIX+slot); }
// ── function: updateTitleButtons ──
function updateTitleButtons() {
const cont=document.getElementById('btn-continue');
if(cont) cont.disabled = ![0,1,2].some(hasSave);
}
// ── function: clearSave ──
function clearSave(slot) { localStorage.removeItem(SAVE_KEY_PREFIX+slot); updateTitleButtons(); }
// ── const: Canvas ──
const Canvas = { ctx:null, dpr:1, W:960, H:540 };
// ── function: setupCanvas ──
function setupCanvas() {
const cvs=document.getElementById('game-canvas');
function resize(){
const rect = cvs.getBoundingClientRect();
Canvas.dpr = Math.min(2, window.devicePixelRatio||1);
cvs.width = Math.floor(rect.width*Canvas.dpr);
cvs.height = Math.floor(rect.height*Canvas.dpr);
Canvas.ctx = cvs.getContext('2d');
Canvas.ctx.setTransform(Canvas.dpr,0,0,Canvas.dpr,0,0);
}
window.addEventListener('resize',resize); resize();
}
// ── function: particleColor ──
function particleColor(type) {
const z=ZONES[Game.player.zoneIndex];
if(type==='ember') return `hsl(${20+Math.random()*30},90%,${50+Math.random()*30}%)`;
if(type==='spore') return `hsl(${90+Math.random()*40},70%,${60+Math.random()*25}%)`;
if(type==='bubble') return `hsl(${190+Math.random()*30},80%,${60+Math.random()*25}%)`;
if(type==='mote') return `hsl(${260+Math.random()*50},60%,${55+Math.random()*25}%)`;
if(type==='gleam') return `hsl(${45+Math.random()*30},100%,${60+Math.random()*25}%)`;
if(type==='star') return `hsl(${170+Math.random()*40},90%,${70+Math.random()*20}%)`;
return '#ffffff';
}
// ── function: spawnParticle ──
function spawnParticle() {
const z=ZONES[Game.player.zoneIndex];
const ptype=z.particle;
Game.particles.push({
x:Math.random()*Canvas.W, y:Math.random()*Canvas.H,
vx:(Math.random()-0.5)*0.6, vy:(Math.random()-0.5)*0.6 - (ptype==='bubble'?0.4:0),
life:60+Math.random()*120, maxLife:180,
size:1+Math.random()*3,
color:particleColor(ptype),
type:ptype
});
}
// ── function: updateParticles ──
function updateParticles() {
if(Game.particles.length<60) spawnParticle();
for(let i=Game.particles.length-1;i>=0;i--){
const p=Game.particles[i];
p.x+=p.vx; p.y+=p.vy; p.life--;
if(p.life<=0 || p.x<0 || p.x>Canvas.W || p.y<0 || p.y>Canvas.H) Game.particles.splice(i,1);
}
}
// ── function: drawParticles ──
function drawParticles(ctx) {
for(const p of Game.particles){
ctx.globalAlpha = Math.max(0,p.life/p.maxLife);
ctx.fillStyle=p.color;
ctx.beginPath(); ctx.arc(p.x,p.y,p.size,0,Math.PI*2); ctx.fill();
}
ctx.globalAlpha=1;
}
// ── function: drawGradient ──
function drawGradient(ctx, colorA, colorB) {
const grad=ctx.createLinearGradient(0,0,0,Canvas.H);
grad.addColorStop(0,colorA); grad.addColorStop(1,colorB);
ctx.fillStyle=grad; ctx.fillRect(0,0,Canvas.W,Canvas.H);
}
// ── function: drawHealthBar ──
function drawHealthBar(ctx,x,y,w,h,current,max,color='#6bff84'){
const ratio=Math.max(0,Math.min(1,current/max));
ctx.fillStyle='rgba(0,0,0,0.35)'; ctx.fillRect(x,y,w,h);
ctx.fillStyle=(ratio<0.2?'#ff6b6b':ratio<0.5?'#ffd36e':color);
ctx.fillRect(x+1,y+1,(w-2)*ratio,h-2);
}
// ── function: drawCreature ──
function drawCreature(ctx,x,y,size,speciesId,flip=false, hurt=false){
const s=speciesById(speciesId);
const colors = s.affinities.map(a=>AFFINITIES[a].color);
const c1=colors[0], c2=colors[1]||colors[0];
ctx.save();
if(flip) ctx.scale(-1,1);
ctx.translate(x,y);
if(hurt){ ctx.globalAlpha=0.6; }
// body
const grd=ctx.createRadialGradient(0,-size*0.25,0,0,0,size);
grd.addColorStop(0,c1); grd.addColorStop(1,c2);
ctx.fillStyle=grd;
ctx.beginPath(); ctx.ellipse(0,0,size*0.55,size*0.7,0,0,Math.PI*2); ctx.fill();
// eyes
ctx.fillStyle='#fff';
ctx.beginPath(); ctx.arc(-size*0.18,-size*0.12,size*0.12,0,Math.PI*2);
ctx.arc(size*0.18,-size*0.12,size*0.12,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#111';
ctx.beginPath(); ctx.arc(-size*0.18,-size*0.12,size*0.05,0,Math.PI*2);
ctx.arc(size*0.18,-size*0.12,size*0.05,0,Math.PI*2); ctx.fill();
// glow
ctx.shadowColor=c1; ctx.shadowBlur=15;
ctx.strokeStyle=c1; ctx.lineWidth=3;
ctx.beginPath(); ctx.arc(0,0,size*0.7,-0.2,Math.PI+0.2); ctx.stroke();
ctx.restore();
ctx.globalAlpha=1; ctx.shadowBlur=0;
}
// ── function: statusIconList ──
function statusIconList(statuses) {
const out=[];
for(const k in statuses){ if(statuses[k]) out.push(STATUS_INFO[k]?.label||k); }
return out;
}
// ── function: renderOverworld ──
function renderOverworld() {
const ctx=Canvas.ctx; if(!ctx) return;
const z=ZONES[Game.player.zoneIndex];
drawGradient(ctx, z.hueA, z.hueB);
updateParticles(); drawParticles(ctx);
const m=Game.map; if(!m) return;
const tileW = Canvas.W / m.W;
const tileH = Canvas.H / m.H;
// tiles
for(let y=0;y<m.H;y++){
for(let x=0;x<m.W;x++){
const t=m.grid[y][x];
const px=x*tileW, py=y*tileH;
if(t===1) { // grass
ctx.fillStyle='rgba(255,255,255,0.06)';
ctx.fillRect(px+2,py+2,tileW-4,tileH-4);
} else if(t===2) { // shrine
ctx.fillStyle='rgba(124,224,255,0.35)';
ctx.shadowColor='#7ce0ff'; ctx.shadowBlur=20;
ctx.beginPath(); ctx.arc(px+tileW/2,py+tileH/2,tileW*0.35,0,Math.PI*2); ctx.fill();
ctx.shadowBlur=0;
} else if(t===3) { // boss
ctx.fillStyle='rgba(255,107,107,0.45)';
ctx.shadowColor='#ff6b6b'; ctx.shadowBlur=24;
ctx.beginPath(); ctx.arc(px+tileW/2,py+tileH/2,tileW*0.35,0,Math.PI*2); ctx.fill();
ctx.shadowBlur=0;
} else if(t===4) { // gate
ctx.strokeStyle=Game.flags.bosses[Game.player.zoneIndex]?'rgba(124,224,255,0.8)':'rgba(255,255,255,0.25)';
ctx.lineWidth=4;
ctx.beginPath(); ctx.arc(px+tileW/2,py+tileH/2,tileW*0.4,Math.PI,0); ctx.stroke();
ctx.fillStyle=ctx.strokeStyle; ctx.fillRect(px+tileW*0.15,py+tileH*0.45,tileW*0.7,4);
}
}
}
// player
const px=Game.player.x*tileW + tileW/2;
const py=Game.player.y*tileH + tileH/2;
ctx.shadowColor='#fff'; ctx.shadowBlur=18;
ctx.fillStyle='#ffffff';
ctx.beginPath(); ctx.arc(px,py,tileW*0.2,0,Math.PI*2); ctx.fill();
ctx.shadowBlur=0;
// vignette if active
drawVignette(ctx);
}
// ── function: drawVignette ──
function drawVignette(ctx) {
const v=document.getElementById('vignette');
if(!v||!v.classList.contains('active')) return;
const color = v.style.boxShadow.includes('128,224,255')||v.style.color;
// handled by CSS box-shadow, return empty
ctx.fillStyle='rgba(124,224,255,0.04)';
ctx.fillRect(0,0,Canvas.W,Canvas.H);
}
// ── function: renderBattle ──
function renderBattle() {
const ctx=Canvas.ctx; if(!ctx) return;
const z=ZONES[Game.player.zoneIndex]||ZONES[0];
drawGradient(ctx, z.hueA, '#080b14');
updateParticles(); drawParticles(ctx);
const b=Game.battle; if(!b) return;
// Draw party on left
const alive = Game.party.map((c,i)=>({c,i})).filter(x=>x.c.hp>0);
alive.forEach((x,idx)=>{
const c=x.c; const cx=140+idx*22; const cy=340+idx*30;
const active = b.state==='input' && b.selectedActorIndex===x.i;
if(active) { ctx.strokeStyle='#fff'; ctx.lineWidth=3; ctx.strokeRect(cx-70,cy-120,160,240); }
drawCreature(ctx,cx,cy,55,c.speciesId,false,c.hp<=0);
drawHealthBar(ctx,cx-45,cy+55,90,8,c.hp,c.maxHp);
ctx.fillStyle='#fff'; ctx.font='bold 12px sans-serif'; ctx.fillText(`Lv${c.level}`,cx-50,cy-60);
ctx.fillText(c.nickname,cx-50,cy-72);
const icons=statusIconList(c.statuses); if(icons.length) { ctx.fillStyle='rgba(255,255,255,0.7)'; ctx.font='10px sans-serif'; ctx.fillText(icons.join(' '),cx-50,cy+70); }
});
// Draw enemies on right
b.enemies.forEach((e,i)=>{
const cx=Canvas.W-140 - i*30; const cy=260+i*55;
drawCreature(ctx,cx,cy,65,e.speciesId,true,e.hp<=0);
drawHealthBar(ctx,cx-50,cy+70,100,10,e.hp,e.maxHp,'#ff6b6b');
ctx.fillStyle='#fff'; ctx.font='bold 13px sans-serif'; ctx.fillText(`Lv${e.level} ${e.nickname}`,cx-60,cy-75);
});
// screen shake / flash
if(Game.shake>0){ ctx.save(); const s=(Game.shake--); ctx.translate((Math.random()-0.5)*s,(Math.random()-0.5)*s); ctx.restore(); }
if(Game.flash>0){ Game.flash--; ctx.fillStyle='rgba(255,255,255,0.12)'; ctx.fillRect(0,0,Canvas.W,Canvas.H); }
}
// ── function: showScreen ──
function showScreen(name) {
document.querySelectorAll('.screen').forEach(s=>s.hidden=true);
document.getElementById(name).hidden=false;
document.getElementById('menu-modal').hidden=true;
document.getElementById('collection-overlay').hidden=true;
document.getElementById('tutorial-overlay').hidden=true;
document.getElementById('dialogue-box').hidden=true;
document.getElementById('battle-ui').hidden=true;
document.getElementById('game-screen').classList.remove('battle-active');
}
// ── function: updateHUD ──
function updateHUD() {
const z=ZONES[Game.player.zoneIndex]||ZONES[0];
const hz=document.getElementById('hud-zone'); if(hz) hz.textContent=z.name;
const hs=document.getElementById('hud-story'); if(hs) hs.textContent=`Thread ${Game.flags.story}/4`;
const hc=document.getElementById('hud-coin'); if(hc) hc.textContent=`◆ ${Game.inventory.coin||0}`;
}
// ── function: openBattleScreen ──
function openBattleScreen() {
document.getElementById('battle-ui').hidden=false;
document.getElementById('top-hud').classList.add('flash');
document.getElementById('touch-controls').hidden=true;
document.getElementById('game-screen').classList.add('battle-active');
updateHUD();
}
// ── function: closeBattleScreen ──
function closeBattleScreen() {
document.getElementById('battle-ui').hidden=true;
document.getElementById('touch-controls').hidden=false;
document.getElementById('game-screen').classList.remove('battle-active');
}
// ── function: renderAbilitiesMenu ──
function renderAbilitiesMenu() {
const b=Game.battle; if(!b) return;
const actor = Game.party[b.selectedActorIndex];
const el=document.getElementById('battle-menu');
el.innerHTML='';
const abs = getKnownAbilities(actor);
abs.forEach(aid=>{
const ab=abilityById(aid);
const btn=document.createElement('button'); btn.className='move-btn aff-'+ab.aff;
btn.innerHTML=`
<span class="move-name">${ab.name}</span>
<span class="move-meta">PWR ${ab.power||'-'} • ACC ${ab.acc} • EN ${ab.cost}</span>
<span class="type-dot" style="background:${AFFINITIES[ab.aff].color}"></span>`;
btn.onclick=()=>{
if(actor.energy<ab.cost) { addBattleLog('Not enough energy!'); return; }
if(ab.targetAll) { useAbility(b.selectedActorIndex, aid, 'enemy', 0); }
else { openBattleTargetSelection(b.selectedActorIndex, aid); }
};
el.appendChild(btn);
});
}
// ── function: openBattleTargetSelection ──
function openBattleTargetSelection(actorIdx, abilityId) {
const b=Game.battle; b.state='target';
const el=document.getElementById('battle-menu');
el.innerHTML='<p style="color:var(--muted)">Choose target:</p>';
const ab=abilityById(abilityId);
const selfHealEffects = ['heal','regen','self_regen','self_barrier','self_def_up','self_spd_up','self_atk_up'];
const targetSide = selfHealEffects.includes(ab.effect)?'ally':'enemy';
const list = targetSide==='ally'?Game.party:b.enemies;
list.forEach((c,i)=>{
if(c.hp<=0 && targetSide==='ally' && ab.effect!=='revive') return;
const btn=document.createElement('button'); btn.className='target-btn';
btn.textContent=`${c.nickname} (HP ${c.hp}/${c.maxHp})`;
btn.onclick=()=>useAbility(actorIdx, abilityId, targetSide, i);
el.appendChild(btn);
});
const back=document.createElement('button'); back.className='glass-btn'; back.textContent='Back';
back.onclick=renderAbilitiesMenu;
el.appendChild(back);
}
// ── function: openBattleSwitch ──
function openBattleSwitch() {
const b=Game.battle; b.state='select';
const el=document.getElementById('battle-menu'); el.innerHTML='<p>Switch to:</p>';
Game.party.forEach((c,i)=>{
if(i===b.selectedActorIndex || c.hp<=0) return;
const btn=document.createElement('button'); btn.className='target-btn';
btn.textContent=`${c.nickname} Lv${c.level}`;
btn.onclick=()=>switchCreature(b.selectedActorIndex,i);
el.appendChild(btn);
});
const back=document.createElement('button'); back.className='glass-btn'; back.textContent='Back';
back.onclick=renderAbilitiesMenu; el.appendChild(back);
}
// ── function: openBattleBag ──
function openBattleBag() {
const b=Game.battle; b.state='select';
const el=document.getElementById('battle-menu'); el.innerHTML='<p>Use item:</p>';
ITEMS.filter(i=>!['capture','catalyst'].includes(i.type)).forEach(it=>{
const count=Game.inventory[it.id]||0; if(count<=0) return;
const btn=document.createElement('button'); btn.className='target-btn';
btn.textContent=`${it.name} x${count}`;
btn.onclick=()=>openBattleItemTarget(it.id);
el.appendChild(btn);
});
const back=document.createElement('button'); back.className='glass-btn'; back.textContent='Back';
back.onclick=renderAbilitiesMenu; el.appendChild(back);
}
// ── function: openBattleItemTarget ──
function openBattleItemTarget(itemId) {
const item=itemById(itemId);
const el=document.getElementById('battle-menu'); el.innerHTML='<p>On whom:</p>';
Game.party.forEach((c,i)=>{
if(c.hp<=0 && item.type!=='revive') return;
const btn=document.createElement('button'); btn.className='target-btn';
btn.textContent=`${c.nickname}`;
btn.onclick=()=>useBattleItem(itemId,i);
el.appendChild(btn);
});
}
// ── function: openMenu ──
function openMenu() {
if(Game.mode==='battle') return;
AudioEngine.sfx('confirm');
const m=document.getElementById('menu-modal'); m.hidden=false;
renderMenuTab('party');
}
// ── function: closeMenu ──
function closeMenu() {
document.getElementById('menu-modal').hidden=true;
}
// ── function: renderMenuTab ──
function renderMenuTab(tab) {
document.querySelectorAll('.modal-tabs button').forEach(b=>b.classList.toggle('active', b.dataset.tab===tab));
const cont=document.getElementById('menu-content'); cont.innerHTML='';
if(tab==='party') renderPartyTab(cont);
if(tab==='bag') renderBagTab(cont);
if(tab==='guide') renderGuideTab(cont);
if(tab==='map') renderMapTab(cont);
if(tab==='options') renderOptionsTab(cont);
}
// ── function: renderPartyTab ──
function renderPartyTab(cont) {
cont.innerHTML='<h3>Active Binding (party)</h3><div class="party-grid"></div><h3 style="margin-top:18px">Reserves</h3><div id="box-grid" class="party-grid"></div>';
const grid1=cont.querySelector('.party-grid');
Game.party.forEach((c,i)=>grid1.appendChild(creatureCard(c,i,'party')));
const grid2=cont.querySelector('#box-grid');
Game.box.forEach((c,i)=>grid2.appendChild(creatureCard(c,i,'box')));
}
// ── function: creatureCard ──
function creatureCard(c,idx,where) {
const s=speciesById(c.speciesId);
const div=document.createElement('div'); div.className='card';
const aff=s.affinities.map(a=>`<span class="affinity-badge aff-${a}">${AFFINITIES[a].name}</span>`).join('');
const evo=checkEvolution(c);
div.innerHTML=`
<h4>${c.nickname} <small>Lv${c.level}</small></h4>
<p>${aff}</p>
<p>HP ${c.hp}/${c.maxHp} • EN ${c.energy}/${c.maxEnergy}<br>ATK ${c.atk} • DEF ${c.def} • SPD ${c.spd}</p>
<div class="stat-bar"><div style="width:${100*c.hp/c.maxHp}%; background:${c.hp/c.maxHp<0.2?'var(--danger)':'var(--heal)'}"></div></div>
<p>Moves: ${getKnownAbilities(c).map(abilityById).map(a=>a.name).join(', ')}</p>
${evo?`<button class="glass-btn evolve-btn" data-where="${where}" data-idx="${idx}">Transform</button>`:''}
`;
if(evo) div.querySelector('.evolve-btn').onclick=()=>{ const nid=evolveCreature(c); showToast(`${c.nickname} transformed into ${speciesById(nid).name}!`); AudioEngine.sfx('level'); renderMenuTab('party'); };
return div;
}
// ── function: renderBagTab ──
function renderBagTab(cont) {
cont.innerHTML='<h3>Inventory</h3>';
ITEMS.forEach(it=>{
const count=Game.inventory[it.id]||0;
const div=document.createElement('div'); div.className='card';
div.innerHTML=`<h4>${it.name} x${count}</h4><p>${it.desc}</p><p style="color:var(--muted)">${it.price?'Sell value 0':'Field use'}</p>`;
cont.appendChild(div);
});
}
// ── function: renderGuideTab ──
function renderGuideTab(cont) {
cont.innerHTML='<h3>Affinity Weave</h3>';
const p=document.createElement('p'); p.innerHTML='Cinder > Tidal > Verdant > Cinder. Umbral > Cinder & Verdant. Lumin > Umbral & Tidal. Aether beats others slightly. Null resists all except Aether.';
cont.appendChild(p);
const grid=document.createElement('div'); grid.className='party-grid';
SPECIES.forEach(s=>{
if(!Game.flags.unique.has(s.id)) { grid.innerHTML+=`<div class="card"><p style="color:var(--muted)">???</p></div>`; return; }
const div=document.createElement('div'); div.className='card';
const aff=s.affinities.map(a=>`<span class="affinity-badge aff-${a}">${AFFINITIES[a].name}</span>`).join('');
div.innerHTML=`<h4>${s.name}</h4>${aff}<p>${s.lore}</p>`;
grid.appendChild(div);
});
cont.appendChild(grid);
}
// ── function: renderMapTab ──
function renderMapTab(cont) {
cont.innerHTML='<h3>World Loom</h3>';
ZONES.forEach((z,i)=>{
const div=document.createElement('div'); div.className='card';
div.innerHTML=`<h4>${i+1}. ${z.name} ${i===Game.player.zoneIndex?'★':''}</h4>
<p>Anchor: ${Game.flags.bosses[i]?'Sealed':'Active'} • Loom level ~${z.level}</p>`;
cont.appendChild(div);
});
}
// ── function: renderOptionsTab ──
function renderOptionsTab(cont) {
cont.innerHTML='<h3>Options</h3>';
const muteBtn=document.createElement('button'); muteBtn.className='glass-btn';
muteBtn.textContent=Game.options.mute?'Unmute':'Mute';
muteBtn.onclick=()=>{ Game.options.mute=!Game.options.mute; AudioEngine.setMute(Game.options.mute); renderOptionsTab(cont); };
cont.appendChild(muteBtn);
const saveBtn=document.createElement('button'); saveBtn.className='glass-btn'; saveBtn.textContent='Save Now';
saveBtn.onclick=()=>{ saveGame(Game.slot); showToast('Game saved.'); };
cont.appendChild(saveBtn);
}
// ── function: openShrineMenu ──
function openShrineMenu() {
AudioEngine.sfx('confirm');
showDialogue(['You arrive at a Resonance Shrine. Rest here and restore your party?'],(choice)=>{
if(choice===0) {
Game.party.forEach(c=>{c.hp=c.maxHp; c.energy=c.maxEnergy;});
Game.zones[Game.player.zoneIndex].shrine=true;
saveGame(Game.slot);
showToast('Party healed and resonance saved.');
}
if(choice===0 || choice===1) openShop();
}, ['Rest & Save','Shop']);
}
// ── function: openShop ──
function openShop() {
const m=document.getElementById('menu-modal'); m.hidden=false;
renderShopTab();
}
// ── function: renderShopTab ──
function renderShopTab() {
const cont=document.getElementById('menu-content'); cont.innerHTML='<h3>Shrine Exchange</h3>';
SHOP_STOCK.forEach(id=>{
const it=itemById(id); const btn=document.createElement('button'); btn.className='card';
btn.style.textAlign='left'; btn.style.width='100%'; btn.style.cursor='pointer';
btn.innerHTML=`<h4>${it.name} — ${it.price} lumin</h4><p style="font-size:.85rem">${it.desc}</p>`;
btn.onclick=()=>{
if((Game.inventory.coin||0)>=it.price){ Game.inventory.coin-=it.price; Game.inventory[id]=(Game.inventory[id]||0)+1; showToast(`Bought ${it.name}`); renderShopTab(); }
else showToast('Not enough lumin.');
};
cont.appendChild(btn);
});
const back=document.createElement('button'); back.className='glass-btn'; back.textContent='Close'; back.onclick=closeMenu; cont.appendChild(back);
}
// ── function: showToast ──
function showToast(msg) {
const area=document.getElementById('toast-area'); if(!area) return;
const t=document.createElement('div'); t.className='toast'; t.textContent=msg;
area.appendChild(t); setTimeout(()=>t.remove(),2600);
}
// ── function: showDialogue ──
function showDialogue(lines, callback, choices) {
const box=document.getElementById('dialogue-box');
const txt=document.getElementById('dialogue-text');
box.hidden=false; Game.mode='dialogue';
Game.dialogueQueue = lines.slice();
Game.dialogueCallback = callback;
let choiceBtns=[];
function appendChoices() {
if(choiceBtns.length) return;
const has = Array.isArray(choices) && choices.length>0;
if(!has) return;
choiceBtns = choices.map((ch,i)=>{
const btn=document.createElement('button'); btn.className='glass-btn'; btn.textContent='❖ '+ch;
btn.onclick=()=>finish(i);
txt.appendChild(document.createElement('br'));
txt.appendChild(btn);
return btn;
});
}
function next() {
if(Game.dialogueQueue.length) {
txt.textContent = '▶ ' + Game.dialogueQueue.shift();
}
appendChoices();
}
function finish(value) {
box.hidden=true; Game.mode='overworld';
choiceBtns.forEach(b=>b.remove());
if(typeof callback==='function') callback(value);
}
Game.advanceDialogue = next;
Game.finishDialogue = finish;
next();
box.onclick = ()=>{
if(choiceBtns.length) return;
next();
if(Game.dialogueQueue.length===0) finish(null);
};
}
// ── function: startTutorial ──
function startTutorial() {
Game.mode='tutorial';
const over=document.getElementById('tutorial-overlay'); over.hidden=false;
const steps = [
{title:'Welcome to the Loom', body:'Move with WASD, arrows, or the on-screen pad. Step into grass to find Aetherlings.'},
{title:'Shrines', body:'Shrines heal your party and save your thread. Rest whenever you see a blue glow.'},
{title:'Battle', body:'In battle, pick an ability or use items. Lower an enemy\'s HP to improve binding chance.'},
{title:'Bind', body:'Use the Bind button to throw a Loomthread. Time the pulse well for best results.'},
{title:'Anchors', body:'Defeat zone anchors to open new zones. Collect eight unique forms to face the final weave.'}
];
function showStep(n) {
if(n>=steps.length) { over.hidden=true; Game.mode='overworld'; Game.flags.tutorial=true; saveGame(Game.slot); return; }
Game.tutorialStep=n;
document.getElementById('tut-title').textContent=steps[n].title;
document.getElementById('tut-body').textContent=steps[n].body;
}
document.getElementById('tut-next').onclick=()=>showStep(Game.tutorialStep+1);
showStep(0);
}
// ── function: gameLoop ──
function gameLoop() {
if(Canvas.ctx) {
if(Game.mode==='battle') renderBattle();
else renderOverworld();
}
requestAnimationFrame(gameLoop);
}
// ── function: handleActionA ──
function handleActionA() {
const dlg=document.getElementById('dialogue-box');
if(!dlg.hidden && Game.advanceDialogue) { Game.advanceDialogue(); return; }
const tut=document.getElementById('tutorial-overlay');
if(!tut.hidden) { document.getElementById('tut-next').click(); return; }
}
// ── function: handleActionB ──
function handleActionB() {
if(!document.getElementById('menu-modal').hidden){ closeMenu(); return; }
if(Game.mode==='overworld') openMenu();
}
// ── function: setupInput ──
function setupInput() {
document.addEventListener('keydown', e=>{
if(Game.mode==='battle') return; // battle uses UI clicks mostly
if(Game.mode==='dialogue') {
if(e.code==='Space'||e.code==='Enter'||e.code==='KeyA') handleActionA();
return;
}
if(Game.mode==='tutorial') { handleActionA(); return; }
if(Game.mode!=='overworld') return;
switch(e.code){
case 'ArrowUp': case 'KeyW': movePlayer(0,-1); break;
case 'ArrowDown': case 'KeyS': movePlayer(0,1); break;
case 'ArrowLeft': case 'KeyA': movePlayer(-1,0); break;
case 'ArrowRight': case 'KeyD': movePlayer(1,0); break;
case 'Space': case 'Enter': handleActionA(); break;
case 'Escape': case 'Backspace': handleActionB(); break;
}
});
// Touch controls
document.querySelectorAll('.dpad button').forEach(b=>{
b.addEventListener('click',()=>{
const d=b.dataset.dir;
if(d==='up') movePlayer(0,-1);
if(d==='down') movePlayer(0,1);
if(d==='left') movePlayer(-1,0);
if(d==='right') movePlayer(1,0);
});
});
document.getElementById('btn-a').addEventListener('click',handleActionA);
document.getElementById('btn-b').addEventListener('click',handleActionB);
document.getElementById('hud-menu').addEventListener('click',openMenu);
document.getElementById('hud-mute').addEventListener('click',toggleMute);
document.getElementById('btn-mute-title').addEventListener('click',toggleMute);
document.getElementById('menu-close').addEventListener('click',closeMenu);
document.querySelectorAll('.modal-tabs button').forEach(b=>b.addEventListener('click',()=>renderMenuTab(b.dataset.tab)));
// Battle UI buttons
document.getElementById('battle-switch').addEventListener('click',openBattleSwitch);
document.getElementById('battle-bag').addEventListener('click',openBattleBag);
document.getElementById('battle-run').addEventListener('click',attemptFlee);
document.getElementById('battle-capture').addEventListener('click',openCaptureChoice);
}
// ── function: toggleMute ──
function toggleMute() {
Game.options.mute = !Game.options.mute;
AudioEngine.setMute(Game.options.mute);
const icons=document.querySelectorAll('.icon-btn#hud-mute,.mute-btn#btn-mute-title');
document.getElementById('hud-mute').textContent = Game.options.mute?'🔇':'🔊';
document.getElementById('btn-mute-title').textContent = Game.options.mute?'🔇':'🔊';
}
// ── function: startNewGame ──
function startNewGame(slot, starterId) {
resetGame(slot);
Game.party.push(createCreature(starterId,5));
Game.party.push(createCreature(WILD_TABLES.emberglade[2],3));
Game.inventory.coin=200;
loadZone(0);
saveGame(slot);
showScreen('game-screen');
Game.mode='overworld';
AudioEngine.resume(); AudioEngine.playAmbient();
updateHUD();
if(!Game.flags.tutorial) startTutorial();
}
// ── function: continueGame ──
function continueGame(slot) {
if(!loadGame(slot)) return;
AudioEngine.resume();
showScreen('game-screen');
loadZone(Game.player.zoneIndex);
Game.mode='overworld';
AudioEngine.playAmbient();
updateHUD();
}
// ── function: initTitle ──
function initTitle() {
showScreen('title-screen'); updateTitleButtons();
const newBtn=document.getElementById('btn-new');
const contBtn=document.getElementById('btn-continue');
const slots=document.getElementById('slot-select');
newBtn.onclick=()=>{ AudioEngine.sfx('confirm'); slots.dataset.pick='new'; slots.hidden=false; };
contBtn.onclick=()=>{ AudioEngine.sfx('confirm'); slots.dataset.pick='continue'; slots.hidden=false; };
document.getElementById('slot-back').onclick=()=>{ slots.hidden=true; };
document.querySelectorAll('.slot-btn').forEach(b=>{
b.addEventListener('click',()=>{
const slot=parseInt(b.dataset.slot);
const mode = slots.dataset.pick || 'new';
if(mode==='continue') continueGame(slot);
else showStarterChoice(slot);
});
});
}
// ── function: showStarterChoice ──
function showStarterChoice(slot) {
const card=document.querySelector('.title-card'); card.innerHTML='<h2>Choose Your First Bond</h2>';
STARTERS.forEach(id=>{
const s=speciesById(id);
const btn=document.createElement('button'); btn.className='glass-btn';
btn.innerHTML=`<b>${s.name}</b><br><small>${s.affinities.map(a=>AFFINITIES[a].name).join(' ')}</small>`;
btn.onclick=()=>startNewGame(slot,id);
card.appendChild(btn);
});
const back=document.createElement('button'); back.className='glass-btn small'; back.textContent='Back';
back.onclick=()=>location.reload();
card.appendChild(back);
}
// ── const: STORYLINES ──
const STORYLINES = {
1: ['Thread one pulls free.','The Emberglade anchor unravels, and the path east grows warm again.'],
2: ['Thread two loosens.','Mistwood, Deepreaches — both have taught you the weave of life and depth.'],
3: ['Thread three trembles.','Three anchors sealed; the Loom senses your growing resonance.'],
4: ['Thread four holds.','You may now walk the Shimmerpeak and Aethercrux. The final binding waits.']
};
// ── function: showStoryCutIn ──
function showStoryCutIn(stage, callback) {
const lines = STORYLINES[stage] || ['The threads shift.'];
const v=document.getElementById('vignette');
v.style.boxShadow = `inset 0 0 160px 80px rgba(${stage===1?'255,122,74':stage===2?'107,255,132':stage===3?'74,208,255':'180,124,255'},0.35)`;
v.classList.add('active');
showDialogue(lines, ()=>{
v.classList.remove('active');
if(typeof callback==='function') callback();
});
}
// ── main ──
setupCanvas();
setupInput();
updateTitleButtons();
initTitle();
AudioEngine.init();
gameLoop();
</script>
</body>
</html>
<!-- agent-meta {"model":"moonshotai/kimi-k2.7-code","provider":"openrouter","persona":"composer","sessionId":"afe4b391-3943-4bda-9329-f099a58898f0","tokensIn":8117584,"tokensOut":75504,"tokensTotal":8193088,"turns":124,"toolCalls":123,"failedToolCalls":13,"timestamp":"2026-07-14T04:23:46.856Z"} -->