Creature Collection
Claude Sonnet 5 · typescript

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 171,674 bytes · SHA-256 67b62a9a2b9a
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Loombound: Threads of Virel</title>
<style>
/* ── css: base ── */
:root {
--bg-deep: #0b0c14;
--panel: rgba(20, 22, 34, 0.82);
--panel-light: rgba(255,255,255,0.06);
--accent: #ffd479;
--accent2: #7fd6ff;
--text: #f1ecff;
--text-dim: #a9a5c0;
--danger: #ff5d6c;
--good: #6bd66a;
--radius: 14px;
font-size: 16px;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg-deep); color: var(--text); font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; overflow: hidden; height: 100%; width: 100%; }
#app { position: relative; width: 100vw; height: 100vh; overflow: hidden; }
.hidden { display: none !important; }
button { font-family: inherit; cursor: pointer; }
canvas { display: block; image-rendering: pixelated; }
.screen { position: absolute; inset: 0; width: 100%; height: 100%; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 4px; }
/* ── css: title ── */
#title-screen { background: radial-gradient(ellipse at 50% 30%, #23264a 0%, #0b0c14 70%); display: flex; align-items: center; justify-content: center; }
#title-fx { position: absolute; inset: 0; width: 100%; height: 100%; }
.title-wrap { position: relative; z-index: 2; text-align: center; padding: 2rem; }
.game-title {
font-size: 4.4rem; margin: 0; letter-spacing: 0.08em;
background: linear-gradient(180deg, #fff, var(--accent) 60%, #b8862e);
-webkit-background-clip: text; background-clip: text; color: transparent;
filter: drop-shadow(0 0 26px rgba(255, 212, 121, 0.45));
font-weight: 800;
animation: titleGlow 4s ease-in-out infinite;
}
@keyframes titleGlow {
0%, 100% { filter: drop-shadow(0 0 22px rgba(255, 212, 121, 0.35)); }
50% { filter: drop-shadow(0 0 42px rgba(127, 214, 255, 0.5)); }
}
.game-sub { color: var(--text-dim); font-size: 1.3rem; letter-spacing: 0.35em; text-transform: uppercase; margin-top: -0.4rem; }
.title-buttons { display: flex; flex-direction: column; gap: 0.8rem; align-items: center; margin: 2.2rem 0 1rem; }
.menu-btn {
min-width: 240px; padding: 0.85rem 1.6rem; border-radius: 999px; border: 1px solid rgba(255,255,255,0.18);
background: var(--panel-light); color: var(--text); font-size: 1.05rem; letter-spacing: 0.04em;
backdrop-filter: blur(6px); transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
}
.menu-btn:hover { transform: translateY(-2px); background: rgba(255,255,255,0.12); }
.menu-btn.primary { background: linear-gradient(135deg, #ffd479, #ff9d5c); color: #241300; font-weight: 700; border: none; box-shadow: 0 6px 24px rgba(255, 157, 92, 0.35); }
.menu-btn.primary:hover { box-shadow: 0 8px 30px rgba(255, 157, 92, 0.55); }
.menu-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
.title-hint { color: var(--text-dim); font-size: 0.85rem; max-width: 420px; margin: 1rem auto 0; }
.icon-btn { position: absolute; top: 1rem; right: 1rem; width: 42px; height: 42px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.18); background: var(--panel-light); color: var(--text); font-size: 1.1rem; z-index: 3; }
.icon-btn.small { position: static; width: 38px; height: 38px; font-size: 1rem; }
/* ── css: starter ── */
#starter-screen { background: radial-gradient(ellipse at 50% 20%, #202545 0%, #0b0c14 75%); display: flex; align-items: center; justify-content: center; }
.starter-wrap { text-align: center; max-width: 900px; padding: 1.5rem; }
.starter-wrap h2 { font-size: 2rem; margin-bottom: 0.3rem; color: var(--accent); }
.starter-sub { color: var(--text-dim); margin-bottom: 1.6rem; }
.starter-cards { display: flex; gap: 1.2rem; justify-content: center; flex-wrap: wrap; }
.starter-card {
width: 230px; background: var(--panel); border: 1px solid rgba(255,255,255,0.1); border-radius: var(--radius);
padding: 1.1rem; transition: transform 0.2s, border-color 0.2s; backdrop-filter: blur(10px);
}
.starter-card:hover { transform: translateY(-6px); border-color: var(--accent); }
.starter-card .sc-icon { width: 90px; height: 90px; margin: 0 auto 0.6rem; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 2.4rem; }
.starter-card h3 { margin: 0.2rem 0; }
.starter-card .sc-thread { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-dim); margin-bottom: 0.5rem; }
.starter-card p { font-size: 0.85rem; color: var(--text-dim); min-height: 3.6em; }
.starter-card button { margin-top: 0.6rem; width: 100%; padding: 0.6rem; border-radius: 8px; border: none; background: linear-gradient(135deg, #ffd479, #ff9d5c); color: #241300; font-weight: 700; }
/* ── css: overworld ── */
#game-screen { background: #05060a; }
#overworld-wrap { position: relative; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
#overworld-canvas { max-width: 100%; max-height: 100%; border-radius: 0; box-shadow: 0 0 60px rgba(0,0,0,0.6) inset; }
#hud-top { position: absolute; top: 0.7rem; left: 0.7rem; right: 0.7rem; display: flex; justify-content: space-between; align-items: flex-start; z-index: 4; pointer-events: none; }
#hud-top > * { pointer-events: auto; }
#hud-zone { background: var(--panel); border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; padding: 0.5rem 0.9rem; backdrop-filter: blur(8px); font-size: 0.95rem; }
#hud-zone .hz-name { color: var(--accent); font-weight: 700; }
#hud-zone .hz-sub { color: var(--text-dim); font-size: 0.75rem; }
.hud-icons { display: flex; gap: 0.4rem; }
#touch-controls { position: absolute; bottom: 1rem; left: 0; right: 0; display: flex; justify-content: space-between; padding: 0 1rem; z-index: 4; pointer-events: none; }
#touch-controls > * { pointer-events: auto; }
.dpad { position: relative; width: 140px; height: 140px; }
.dp-btn { position: absolute; width: 46px; height: 46px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); background: rgba(20,22,34,0.7); color: var(--text); font-size: 1.1rem; }
.dp-up { top: 0; left: 47px; } .dp-down { bottom: 0; left: 47px; } .dp-left { top: 47px; left: 0; } .dp-right { top: 47px; right: 0; }
.dp-action { width: 64px; height: 64px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.2); background: linear-gradient(135deg, #ffd479, #ff9d5c); color: #241300; font-size: 1.4rem; font-weight: 700; align-self: flex-end; }
@media (min-width: 900px) { #touch-controls { display: none; } }
/* ── css: battle ── */
#battle-screen { background: #05060a; display: flex; flex-direction: column; }
#battle-canvas { width: 100%; height: 58%; }
#battle-ui { flex: 1; position: relative; background: linear-gradient(180deg, #14151f, #0a0a12); border-top: 1px solid rgba(255,255,255,0.08); padding: 0.7rem 1rem; display: flex; flex-direction: column; gap: 0.5rem; overflow: hidden; }
.log-box { flex: 1; background: rgba(0,0,0,0.35); border-radius: 10px; padding: 0.6rem 0.8rem; font-size: 0.92rem; overflow-y: auto; min-height: 60px; max-height: 110px; line-height: 1.4; }
.log-box div { margin-bottom: 2px; }
.log-box .lk-damage { color: #ff9f9f; }
.log-box .lk-crit { color: #ffd479; font-weight: 700; }
.log-box .lk-effective { color: #7fd6ff; }
.log-box .lk-heal { color: #6bd66a; }
.log-box .lk-faint { color: #ff5d6c; font-weight: 700; }
.log-box .lk-status { color: #d0aaff; }
.action-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; }
.action-grid.wide { grid-template-columns: repeat(2, 1fr); }
.act-btn { padding: 0.65rem 0.5rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.14); background: var(--panel-light); color: var(--text); font-size: 0.88rem; text-align: left; transition: transform 0.1s, background 0.1s; }
.act-btn:hover:not(:disabled) { background: rgba(255,255,255,0.14); transform: translateY(-2px); }
.act-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.act-btn .ab-name { font-weight: 700; display: block; }
.act-btn .ab-meta { font-size: 0.72rem; color: var(--text-dim); }
.action-main { display: flex; gap: 0.5rem; }
.action-main button { flex: 1; padding: 0.7rem; border-radius: 10px; border: none; font-weight: 700; }
#btn-move-fight { background: linear-gradient(135deg, #ff9d5c, #ff5d6c); color: #200; }
#btn-move-catch { background: linear-gradient(135deg, #7fd6ff, #3da9ff); color: #001a26; }
#btn-move-bag { background: linear-gradient(135deg, #ffe17a, #ffd479); color: #241300; }
#btn-move-switch { background: linear-gradient(135deg, #c9d6ff, #9baaff); color: #0d0b2a; }
#btn-move-flee { background: rgba(255,255,255,0.1); color: var(--text); }
.back-btn { background: rgba(255,255,255,0.08); color: var(--text-dim); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 0.5rem 0.9rem; align-self: flex-start; }
/* ── css: modal ── */
#modal-root { position: absolute; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.55); backdrop-filter: blur(2px); }
.modal-panel { background: var(--panel); border: 1px solid rgba(255,255,255,0.14); border-radius: var(--radius); width: min(720px, 92vw); max-height: 86vh; overflow-y: auto; padding: 1.2rem 1.4rem; backdrop-filter: blur(16px); box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
.modal-panel h2 { margin-top: 0; color: var(--accent); display: flex; justify-content: space-between; align-items: center; }
.modal-close { background: rgba(255,255,255,0.08); border: none; color: var(--text); border-radius: 50%; width: 32px; height: 32px; font-size: 1rem; }
.tab-row { display: flex; gap: 0.4rem; margin-bottom: 0.8rem; flex-wrap: wrap; }
.tab-btn { padding: 0.4rem 0.9rem; border-radius: 999px; border: 1px solid rgba(255,255,255,0.14); background: transparent; color: var(--text-dim); font-size: 0.85rem; }
.tab-btn.active { background: var(--accent); color: #241300; font-weight: 700; border-color: transparent; }
.team-row { display: flex; gap: 0.8rem; align-items: center; background: rgba(255,255,255,0.04); border-radius: 10px; padding: 0.6rem 0.8rem; margin-bottom: 0.5rem; cursor: pointer; border: 1px solid transparent; }
.team-row:hover { border-color: rgba(255,255,255,0.2); }
.team-row .tr-icon { width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.3rem; flex-shrink: 0; }
.team-row .tr-main { flex: 1; min-width: 0; }
.team-row .tr-name { font-weight: 700; }
.team-row .tr-lv { color: var(--text-dim); font-size: 0.8rem; }
.hpbar-outer { height: 8px; background: rgba(0,0,0,0.4); border-radius: 5px; overflow: hidden; margin-top: 3px; }
.hpbar-inner { height: 100%; border-radius: 5px; transition: width 0.4s ease-out; background: linear-gradient(90deg, #6bd66a, #a4e26a); }
.hpbar-inner.mid { background: linear-gradient(90deg, #ffd479, #ffb347); }
.hpbar-inner.low { background: linear-gradient(90deg, #ff5d6c, #ff8080); }
.item-row { display: flex; justify-content: space-between; align-items: center; background: rgba(255,255,255,0.04); border-radius: 10px; padding: 0.55rem 0.8rem; margin-bottom: 0.4rem; }
.item-row .ir-name { font-weight: 700; }
.item-row .ir-desc { font-size: 0.78rem; color: var(--text-dim); max-width: 380px; }
.item-row button { border-radius: 8px; border: none; padding: 0.4rem 0.7rem; background: var(--accent); color: #241300; font-weight: 700; }
.enc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.6rem; }
.enc-card { background: rgba(255,255,255,0.04); border-radius: 10px; padding: 0.6rem; text-align: center; border: 1px solid rgba(255,255,255,0.08); }
.enc-card.unseen { opacity: 0.35; }
.enc-card .ec-icon { width: 54px; height: 54px; border-radius: 50%; margin: 0 auto 0.3rem; display: flex; align-items: center; justify-content: center; font-size: 1.6rem; }
.detail-panel { display: flex; gap: 1rem; flex-wrap: wrap; }
.detail-panel .dp-icon { width: 90px; height: 90px; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 2.2rem; }
.stat-line { display: flex; justify-content: space-between; font-size: 0.85rem; padding: 2px 0; border-bottom: 1px dashed rgba(255,255,255,0.08); }
.move-pick-list { display: flex; flex-direction: column; gap: 0.3rem; }
.move-pick { display: flex; justify-content: space-between; background: rgba(255,255,255,0.05); padding: 0.5rem 0.7rem; border-radius: 8px; cursor: pointer; }
.move-pick:hover { background: rgba(255,255,255,0.12); }
.map-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.7rem; }
.map-card { border-radius: 10px; padding: 0.8rem; text-align: center; cursor: default; border: 1px solid rgba(255,255,255,0.1); }
.map-card.locked { opacity: 0.4; }
.map-card .mc-name { font-weight: 700; margin-bottom: 0.2rem; }
.map-card .mc-status { font-size: 0.75rem; color: var(--text-dim); }
/* ── css: story-toast ── */
#story-overlay { position: absolute; inset: 0; z-index: 60; display: flex; align-items: flex-end; justify-content: center; pointer-events: none; }
#story-overlay .story-vignette { position: absolute; inset: 0; background: radial-gradient(ellipse at 50% 50%, transparent 30%, var(--vg-color, #000) 130%); opacity: 0.75; }
#story-overlay .story-box { position: relative; z-index: 2; margin-bottom: 4rem; max-width: 700px; width: 90%; background: rgba(10,10,18,0.85); border: 1px solid rgba(255,255,255,0.15); border-radius: var(--radius); padding: 1.2rem 1.5rem; pointer-events: auto; backdrop-filter: blur(10px); animation: storyIn 0.4s ease-out; }
@keyframes storyIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
#story-overlay h3 { margin: 0 0 0.5rem; color: var(--accent); }
#story-overlay p { line-height: 1.5; min-height: 3em; }
#story-overlay .story-next { margin-top: 0.6rem; float: right; padding: 0.4rem 1rem; border-radius: 8px; border: none; background: var(--accent); color: #241300; font-weight: 700; }
#toast-root { position: absolute; top: 4rem; right: 1rem; z-index: 70; display: flex; flex-direction: column; gap: 0.5rem; align-items: flex-end; }
.toast { background: var(--panel); border: 1px solid rgba(255,255,255,0.15); border-radius: 10px; padding: 0.6rem 1rem; font-size: 0.88rem; backdrop-filter: blur(8px); animation: toastIn 0.3s ease-out, toastOut 0.4s ease-in 2.6s forwards; }
@keyframes toastIn { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
@keyframes toastOut { to { opacity: 0; transform: translateX(30px); } }
.shake { animation: shakeAnim 0.35s; }
@keyframes shakeAnim { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-8px); } 40% { transform: translateX(6px); } 60% { transform: translateX(-4px); } 80% { transform: translateX(3px); } }
</style>
</head>
<body>
<div id="app">
<div id="title-screen" class="screen">
<canvas id="title-fx" width="1280" height="720"></canvas>
<div class="title-wrap">
<h1 class="game-title">Loombound</h1>
<p class="game-sub">Threads of Virel</p>
<div class="title-buttons">
<button id="btn-new" class="menu-btn primary">New Binding</button>
<button id="btn-continue" class="menu-btn">Continue</button>
</div>
<p class="title-hint">A wholly original creature-binding adventure. WASD / Arrows to move.</p>
</div>
<button id="btn-mute" class="icon-btn" title="Mute audio">🔈</button>
</div>
<div id="starter-screen" class="screen hidden">
<div class="starter-wrap">
<h2>Choose your first Thread</h2>
<p class="starter-sub">The Binder sets three young creatures before you. One will walk beside you from here on.</p>
<div id="starter-cards" class="starter-cards"></div>
</div>
</div>
<div id="game-screen" class="screen hidden">
<div id="overworld-wrap">
<canvas id="overworld-canvas" width="960" height="640"></canvas>
<div id="hud-top">
<div id="hud-zone"></div>
<div class="hud-icons">
<button id="btn-team" class="icon-btn small" title="Team">🐉</button>
<button id="btn-bag" class="icon-btn small" title="Bag">🎒</button>
<button id="btn-encyc" class="icon-btn small" title="Encyclopedia">📕</button>
<button id="btn-map" class="icon-btn small" title="Map">🗺</button>
<button id="btn-pause" class="icon-btn small" title="Menu">☰</button>
<button id="btn-mute2" class="icon-btn small" title="Mute">🔈</button>
</div>
</div>
<div id="touch-controls">
<div class="dpad">
<button id="dp-up" class="dp-btn dp-up">▲</button>
<button id="dp-left" class="dp-btn dp-left">◀</button>
<button id="dp-right" class="dp-btn dp-right">▶</button>
<button id="dp-down" class="dp-btn dp-down">▼</button>
</div>
<button id="dp-action" class="dp-action">✓</button>
</div>
</div>
</div>
<div id="battle-screen" class="screen hidden">
<canvas id="battle-canvas" width="960" height="420"></canvas>
<div id="battle-ui"></div>
</div>
<div id="story-overlay" class="overlay hidden"></div>
<div id="modal-root" class="hidden"></div>
<div id="toast-root"></div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./engine/state":"src/engine/state.ts","./engine/save":"src/engine/save.ts","./engine/audio":"src/engine/audio.ts","./ui/titlefx":"src/ui/titlefx.ts","./ui/starter":"src/ui/starter.ts","./engine/overworld":"src/engine/overworld.ts","./data/zones":"src/data/zones.ts","./engine/creatureInstance":"src/engine/creatureInstance.ts","./engine/rng":"src/engine/rng.ts","./ui/menus":"src/ui/menus.ts","./ui/shrine":"src/ui/shrine.ts","./ui/toast":"src/ui/toast.ts","./ui/story":"src/ui/story.ts","./data/story":"src/data/story.ts","./ui/battleScreen":"src/ui/battleScreen.ts","./ui/modal":"src/ui/modal.ts","./data/creatures":"src/data/creatures.ts"},"src/data/abilities.ts":{"./types":"src/data/types.ts"},"src/data/creatures.ts":{"./types":"src/data/types.ts"},"src/data/zones.ts":{"./types":"src/data/types.ts"},"src/engine/creatureInstance.ts":{"../data/creatures":"src/data/creatures.ts","../data/stats":"src/data/stats.ts","../data/types":"src/data/types.ts"},"src/engine/state.ts":{"./creatureInstance":"src/engine/creatureInstance.ts","../data/zones":"src/data/zones.ts"},"src/engine/save.ts":{"./state":"src/engine/state.ts"},"src/engine/combat.ts":{"./creatureInstance":"src/engine/creatureInstance.ts","../data/abilities":"src/data/abilities.ts","../data/types":"src/data/types.ts","./rng":"src/engine/rng.ts"},"src/ui/story.ts":{"../data/story":"src/data/story.ts"},"src/engine/overworld.ts":{"../data/zones":"src/data/zones.ts","./state":"src/engine/state.ts","./particles":"src/engine/particles.ts"},"src/engine/sprites.ts":{"../data/creatures":"src/data/creatures.ts","../data/types":"src/data/types.ts"},"src/ui/helpers.ts":{"../data/creatures":"src/data/creatures.ts","../data/types":"src/data/types.ts","../engine/sprites":"src/engine/sprites.ts","../engine/creatureInstance":"src/engine/creatureInstance.ts"},"src/ui/menus.ts":{"../engine/state":"src/engine/state.ts","../data/creatures":"src/data/creatures.ts","../data/items":"src/data/items.ts","../data/abilities":"src/data/abilities.ts","./modal":"src/ui/modal.ts","./helpers":"src/ui/helpers.ts","../engine/creatureInstance":"src/engine/creatureInstance.ts","../data/zones":"src/data/zones.ts","./toast":"src/ui/toast.ts","../engine/audio":"src/engine/audio.ts"},"src/ui/shrine.ts":{"../engine/state":"src/engine/state.ts","./modal":"src/ui/modal.ts","../data/items":"src/data/items.ts","./helpers":"src/ui/helpers.ts","./toast":"src/ui/toast.ts","../engine/audio":"src/engine/audio.ts"},"src/engine/battle.ts":{"./creatureInstance":"src/engine/creatureInstance.ts","./combat":"src/engine/combat.ts","../data/abilities":"src/data/abilities.ts","../data/items":"src/data/items.ts","./state":"src/engine/state.ts","../data/creatures":"src/data/creatures.ts"},"src/ui/battleScreen.ts":{"../engine/state":"src/engine/state.ts","../engine/battle":"src/engine/battle.ts","../engine/creatureInstance":"src/engine/creatureInstance.ts","../data/abilities":"src/data/abilities.ts","../data/items":"src/data/items.ts","../engine/sprites":"src/engine/sprites.ts","../data/types":"src/data/types.ts","../engine/combat":"src/engine/combat.ts","../engine/audio":"src/engine/audio.ts","./helpers":"src/ui/helpers.ts","./modal":"src/ui/modal.ts"},"src/ui/starter.ts":{"../data/creatures":"src/data/creatures.ts","../data/types":"src/data/types.ts","./helpers":"src/ui/helpers.ts","../engine/creatureInstance":"src/engine/creatureInstance.ts","../engine/state":"src/engine/state.ts","../engine/audio":"src/engine/audio.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const state_1 = require("./engine/state");
const save_1 = require("./engine/save");
const audio_1 = require("./engine/audio");
const titlefx_1 = require("./ui/titlefx");
const starter_1 = require("./ui/starter");
const overworld_1 = require("./engine/overworld");
const zones_1 = require("./data/zones");
const creatureInstance_1 = require("./engine/creatureInstance");
const rng_1 = require("./engine/rng");
const menus_1 = require("./ui/menus");
const shrine_1 = require("./ui/shrine");
const toast_1 = require("./ui/toast");
const story_1 = require("./ui/story");
const story_2 = require("./data/story");
const battleScreen_1 = require("./ui/battleScreen");
const modal_1 = require("./ui/modal");
let state = (0, state_1.newGameState)();
let mode = 'title';
let view = null;
let lastTime = performance.now();
let moveCooldown = 0;
let tutorial = { active: false, step: 0 };
let battleScreen = null;
const keys = new Set();
function $(id) { return document.getElementById(id); }
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden'));
$(id).classList.remove('hidden');
}
// ---------------- TITLE ----------------
function initTitle() {
showScreen('title-screen');
(0, titlefx_1.startTitleFx)($('title-fx'));
(0, audio_1.startAmbient)('title');
const contBtn = $('btn-continue');
contBtn.disabled = !(0, save_1.hasSave)();
$('btn-new').addEventListener('click', () => {
(0, audio_1.unlockAudio)();
(0, audio_1.sfx)('confirm');
state = (0, state_1.newGameState)();
mode = 'starter';
showScreen('starter-screen');
(0, starter_1.renderStarterScreen)(state, onStarterChosen);
});
contBtn.addEventListener('click', () => {
(0, audio_1.unlockAudio)();
const loaded = (0, save_1.loadGame)();
if (loaded) {
state = loaded;
(0, audio_1.sfx)('confirm');
enterGame(false);
}
});
$('btn-mute').addEventListener('click', () => {
const m = !(0, audio_1.isMuted)();
(0, audio_1.setMuted)(m);
state.muted = m;
$('btn-mute').innerHTML = m ? '🔇' : '🔈';
});
}
function onStarterChosen() {
tutorial.active = true;
tutorial.step = 0;
enterGame(true);
}
// ---------------- GAME ENTRY ----------------
function enterGame(freshStart) {
mode = 'game';
showScreen('game-screen');
const canvas = $('overworld-canvas');
view = (0, overworld_1.createView)(canvas);
wireHud();
wireTouch();
if (freshStart) {
(0, story_1.showStory)((0, story_2.getStoryBeat)(0), () => (0, toast_1.showToast)('Move with WASD or the arrows. Reach the glowing ! marker.'));
}
requestAnimationFrame(loop);
}
function wireHud() {
$('btn-team').onclick = () => (0, menus_1.openTeamMenu)(state);
$('btn-bag').onclick = () => (0, menus_1.openBagMenu)(state);
$('btn-encyc').onclick = () => (0, menus_1.openEncyclopediaMenu)(state);
$('btn-map').onclick = () => (0, menus_1.openMapMenu)(state);
$('btn-pause').onclick = () => openPause();
$('btn-mute2').onclick = () => toggleMute();
}
function toggleMute() {
const m = !(0, audio_1.isMuted)();
(0, audio_1.setMuted)(m);
state.muted = m;
(0, toast_1.showToast)(m ? 'Sound muted.' : 'Sound on.');
}
function openPause() {
(0, menus_1.openPauseMenu)(state, {
onSave: () => { (0, save_1.saveGame)(state); (0, toast_1.showToast)('Binding saved.'); },
onMute: () => toggleMute(),
onQuit: () => { (0, save_1.saveGame)(state); location.reload(); },
onResume: () => { },
});
}
function wireTouch() {
const map = {
'dp-up': () => tryMove(0, -1, 'up'),
'dp-down': () => tryMove(0, 1, 'down'),
'dp-left': () => tryMove(-1, 0, 'left'),
'dp-right': () => tryMove(1, 0, 'right'),
'dp-action': () => interact(),
};
for (const [id, fn] of Object.entries(map)) {
const el = $(id);
el.addEventListener('touchstart', (e) => { e.preventDefault(); fn(); }, { passive: false });
el.addEventListener('click', () => fn());
}
}
window.addEventListener('keydown', (e) => {
keys.add(e.key.toLowerCase());
if (mode === 'game' && !(0, modal_1.isModalOpen)()) {
if (['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(e.key.toLowerCase()))
e.preventDefault();
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
interact();
}
if (e.key.toLowerCase() === 'escape')
openPause();
}
});
window.addEventListener('keyup', (e) => keys.delete(e.key.toLowerCase()));
function loop(now) {
const dt = Math.min(0.05, (now - lastTime) / 1000);
lastTime = now;
if (mode === 'game' && view) {
handleMovementInput(dt);
(0, overworld_1.drawOverworld)(view, state, dt);
updateHudZoneLabel();
}
requestAnimationFrame(loop);
}
function updateHudZoneLabel() {
const zone = (0, zones_1.getZone)(state.pos.zoneId);
$('hud-zone').innerHTML = `<div class="hz-name">${zone.name}</div><div class="hz-sub">Bound: ${state.flags.captured.length} · Guardians: ${state.flags.guardiansCleared.length}/6</div>`;
}
function handleMovementInput(dt) {
if ((0, modal_1.isModalOpen)())
return;
moveCooldown -= dt;
if (moveCooldown > 0)
return;
let dx = 0, dy = 0, facing = null;
if (keys.has('w') || keys.has('arrowup')) {
dy = -1;
facing = 'up';
}
else if (keys.has('s') || keys.has('arrowdown')) {
dy = 1;
facing = 'down';
}
else if (keys.has('a') || keys.has('arrowleft')) {
dx = -1;
facing = 'left';
}
else if (keys.has('d') || keys.has('arrowright')) {
dx = 1;
facing = 'right';
}
if (facing) {
tryMove(dx, dy, facing);
moveCooldown = 0.14;
}
}
function tryMove(dx, dy, facing) {
if (mode !== 'game' || (0, modal_1.isModalOpen)())
return;
const zone = (0, zones_1.getZone)(state.pos.zoneId);
state.pos.facing = facing;
const nx = state.pos.x + dx, ny = state.pos.y + dy;
if (!(0, overworld_1.canWalk)(zone, nx, ny))
return;
state.pos.x = nx;
state.pos.y = ny;
(0, audio_1.sfx)('step');
maybeAdvanceTutorial('move');
maybeEncounter(zone);
}
function interact() {
if (mode !== 'game' || (0, modal_1.isModalOpen)())
return;
const zone = (0, zones_1.getZone)(state.pos.zoneId);
const node = (0, overworld_1.nodeAt)(zone, state.pos.x, state.pos.y);
if (!node) {
(0, toast_1.showToast)('Nothing to interact with here.');
return;
}
if (node.kind === 'shrine') {
(0, shrine_1.openShrineMenu)(state, () => (0, save_1.saveGame)(state));
}
else if (node.kind === 'story') {
triggerZoneStory(zone);
}
else if (node.kind === 'guardian') {
startGuardianBattle(zone);
}
else if (node.kind === 'exit') {
tryAdvanceZone(zone);
}
}
function triggerZoneStory(zone) {
const idx = zones_1.ZONES.findIndex(z => z.id === zone.id);
const beat = (0, story_2.getStoryBeat)(Math.min(idx, 4));
(0, story_1.showStory)(beat, () => {
state.flags.storyStage = Math.max(state.flags.storyStage, idx);
(0, toast_1.showToast)(zone.storyNode);
});
}
function tryAdvanceZone(zone) {
const idx = zones_1.ZONES.findIndex(z => z.id === zone.id);
const next = zones_1.ZONES[idx + 1];
if (!next) {
if (state.flags.guardiansCleared.length >= zones_1.ZONES.length && state.flags.captured.length >= 8) {
(0, toast_1.showToast)('All Anchors stilled. The post-game roam is open — wild creatures grow bolder here.');
state.flags.postGame = true;
}
else {
(0, toast_1.showToast)('This is the edge of known Virel... for now.');
}
return;
}
if (!state.flags.guardiansCleared.includes(zone.id)) {
(0, toast_1.showToast)(`Bind ${zone.guardian.name} before venturing further.`);
return;
}
state.pos = { zoneId: next.id, x: next.entry.x, y: next.entry.y, facing: 'down' };
(0, toast_1.showToast)(`You arrive at ${next.name}.`);
}
// ---------------- ENCOUNTERS ----------------
function maybeEncounter(zone) {
if (state.party.length === 0)
return;
if (!state.party.some(c => !(0, creatureInstance_1.isFainted)(c)))
return;
let rate = zone.encounterRate;
if (state.flags.postGame)
rate *= 1.4;
if (!(0, rng_1.chance)(rate))
return;
const entry = (0, rng_1.weightedPick)(zone.wilds);
const lvlBonus = state.flags.postGame ? 6 : 0;
const level = (0, rng_1.rndInt)(entry.minLv, entry.maxLv) + lvlBonus;
const wild = (0, creatureInstance_1.createInstance)(entry.species, level);
(0, state_1.markSeen)(state, entry.species);
startWildBattle(wild);
}
function startWildBattle(wild) {
mode = 'battle';
showScreen('battle-screen');
battleScreen = new battleScreen_1.BattleScreen(state, [wild], 'wild', true, undefined, undefined, {
onEnd: (result) => onBattleEnd(result),
});
}
function startGuardianBattle(zone) {
if (state.flags.guardiansCleared.includes(zone.id)) {
(0, toast_1.showToast)(`${zone.guardian.name} has already been bound to peace.`);
return;
}
if (!state.party.some(c => !(0, creatureInstance_1.isFainted)(c))) {
(0, toast_1.showToast)('Your party needs to rest before challenging a Guardian!');
return;
}
const boss = (0, creatureInstance_1.createInstance)(zone.guardian.species, zone.guardian.level);
boss.nickname = zone.guardian.name.split(',')[0];
(0, toast_1.showToast)(zone.guardian.flavor);
mode = 'battle';
showScreen('battle-screen');
battleScreen = new battleScreen_1.BattleScreen(state, [boss], 'guardian', false, zone.guardian.name, zone.guardian.bonusText, {
onEnd: (result) => onGuardianBattleEnd(result, zone),
});
}
function onGuardianBattleEnd(result, zone) {
battleScreen?.destroy();
battleScreen = null;
mode = 'game';
showScreen('game-screen');
(0, audio_1.startAmbient)('overworld');
if (result === 'win') {
state.flags.guardiansCleared.push(zone.id);
applyGuardianBonus(zone);
(0, toast_1.showToast)(`${zone.guardian.name} yields! ${zone.guardian.bonusText}`);
const idx = zones_1.ZONES.findIndex(z => z.id === zone.id);
if (idx === 3 || idx === 4)
(0, story_1.showStory)((0, story_2.getStoryBeat)(idx === 3 ? 3 : 4), () => { });
checkFinalObjective();
}
else if (result === 'lose') {
handlePartyWipe();
}
updateHudZoneLabel();
}
function applyGuardianBonus(zone) {
const idx = zones_1.ZONES.findIndex(z => z.id === zone.id);
const bump = (frac) => {
state.statBonus.hp += frac;
state.statBonus.atk += 0; // hp bonus tracked separately conceptually; simplified below
};
// Simplify: grant flat % bonus applied at party heal-time via statBonus aggregate (approximation of spec's permanent bonus)
const pct = 0.05 + idx * 0.01;
for (const c of state.party) {
(0, creatureInstance_1.fullHeal)(c);
}
state.statBonus.hp += pct;
state.statBonus.atk += pct * 0.6;
state.statBonus.def += pct * 0.6;
state.statBonus.spd += pct * 0.6;
}
function checkFinalObjective() {
if (state.flags.captured.length >= 8 && state.flags.guardiansCleared.length >= 4 && !state.flags.postGame) {
(0, toast_1.showToast)('Final objective met: 8 Threads bound, 4 Anchors stilled! Free roam continues with tougher wilds.');
}
}
function onBattleEnd(result) {
battleScreen?.destroy();
battleScreen = null;
mode = 'game';
showScreen('game-screen');
(0, audio_1.startAmbient)('overworld');
if (result === 'captured') {
maybeAdvanceTutorial('capture');
checkFinalObjective();
}
else if (result === 'win') {
maybeAdvanceTutorial('battle');
}
else if (result === 'lose') {
handlePartyWipe();
}
updateHudZoneLabel();
}
function handlePartyWipe() {
for (const c of state.party)
(0, creatureInstance_1.fullHeal)(c);
if (state.lastShrine) {
state.pos = { ...state.lastShrine };
}
else {
const z = zones_1.ZONES[0];
state.pos = { zoneId: z.id, x: z.entry.x, y: z.entry.y, facing: 'down' };
}
(0, toast_1.showToast)('Your party recovers at the nearest shrine.');
}
// ---------------- TUTORIAL ----------------
function maybeAdvanceTutorial(event) {
if (!tutorial.active || state.flags.tutorialDone)
return;
if (tutorial.step === 0 && event === 'move') {
tutorial.step = 1;
(0, toast_1.showToast)('Walk into wild creatures to trigger a battle.');
}
else if (tutorial.step === 1 && event === 'battle') {
tutorial.step = 2;
(0, toast_1.showToast)('Great! Try binding a weakened wild creature with the Bind action.');
}
else if (tutorial.step === 2 && event === 'capture') {
tutorial.step = 3;
tutorial.active = false;
state.flags.tutorialDone = true;
(0, toast_1.showToast)('Tutorial complete! Explore, battle, and seek the Shrine to save.');
}
}
// ---------------- BOOTSTRAP ----------------
initTitle();
// Debug hook for automated verification only (harmless in normal play).
window.__loombound_debug = {
getState: () => state,
getMode: () => mode,
forceEncounter: (speciesId, level) => { startWildBattle((0, creatureInstance_1.createInstance)(speciesId, level)); },
forceGuardian: (zoneId) => { startGuardianBattle((0, zones_1.getZone)(zoneId)); },
};
};
// ── module: src/data/types.ts ──
__mods["src/data/types.ts"] = function (exports, require, module) {
"use strict";
// Core "Thread" (elemental affinity) system for Loombound.
// Seven Threads weave the world of Virel. Each creature carries one or two Threads.
Object.defineProperty(exports, "__esModule", { value: true });
exports.TYPE_CHART = exports.THREAD_BLURB = exports.THREAD_GLYPH = exports.THREAD_COLOR = exports.THREADS = void 0;
exports.effectivenessMultiplier = effectivenessMultiplier;
exports.effectivenessLabel = effectivenessLabel;
exports.THREADS = ['Pyre', 'Bloom', 'Undertow', 'Squall', 'Umbrage', 'Lumen', 'Bedrock'];
exports.THREAD_COLOR = {
Pyre: '#ff6a3d',
Bloom: '#6bd66a',
Undertow: '#3da9ff',
Squall: '#c9d6ff',
Umbrage: '#9b59d0',
Lumen: '#ffe17a',
Bedrock: '#b08d5a',
};
exports.THREAD_GLYPH = {
Pyre: '\u25B2',
Bloom: '\u2766',
Undertow: '\u2248',
Squall: '\u2601',
Umbrage: '\u25CF',
Lumen: '\u2600',
Bedrock: '\u25A0',
};
exports.THREAD_BLURB = {
Pyre: 'The Thread of hungry flame and sudden combustion.',
Bloom: 'The Thread of growth, sap, and quiet persistence.',
Undertow: 'The Thread of deep water and pulling currents.',
Squall: 'The Thread of storm-wind and racing pressure fronts.',
Umbrage: 'The Thread of shadow-static and unseen weight.',
Lumen: 'The Thread of raw exposed light, rare and severe.',
Bedrock: 'The Thread of old stone that has stopped listening.',
};
// Build the effectiveness matrix from the fixed mechanical spec:
// Pyre > Bloom > Undertow > Pyre
// Squall > Pyre & Undertow
// Umbrage > Squall & Bloom
// Lumen beats all others slightly
// Bedrock resists everything but is weak to Lumen
function buildChart() {
const chart = {};
for (const a of exports.THREADS) {
chart[a] = {};
for (const b of exports.THREADS)
chart[a][b] = 1;
}
const superPairs = [
['Pyre', 'Bloom'],
['Bloom', 'Undertow'],
['Undertow', 'Pyre'],
['Squall', 'Pyre'],
['Squall', 'Undertow'],
['Umbrage', 'Squall'],
['Umbrage', 'Bloom'],
];
for (const [atk, def] of superPairs) {
chart[atk][def] = 2;
chart[def][atk] = 0.5;
}
// Lumen beats all others slightly
for (const d of exports.THREADS) {
if (d === 'Lumen')
continue;
chart['Lumen'][d] = Math.max(chart['Lumen'][d], 1.5);
}
// Bedrock resists everything...
for (const a of exports.THREADS) {
if (a === 'Bedrock')
continue;
chart[a]['Bedrock'] = 0.5;
}
// ...but is weak to Lumen specifically
chart['Lumen']['Bedrock'] = 2;
return chart;
}
exports.TYPE_CHART = buildChart();
function effectivenessMultiplier(atk, defenderThreads) {
let mult = 1;
for (const d of defenderThreads)
mult *= exports.TYPE_CHART[atk][d];
return mult;
}
function effectivenessLabel(mult) {
if (mult >= 1.75)
return 'A resonant strike! Super effective!';
if (mult >= 1.2)
return 'The Thread bites deep!';
if (mult <= 0.3)
return 'The blow is badly muffled...';
if (mult <= 0.6)
return 'Not very effective...';
return null;
}
};
// ── module: src/data/abilities.ts ──
__mods["src/data/abilities.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ABILITIES = void 0;
exports.getAbility = getAbility;
exports.ABILITIES = [
// ---- Pyre ----
{ id: 'emberlash', name: 'Emberlash', thread: 'Pyre', category: 'physical', power: 40, accuracy: 1.0, cost: 20, description: 'A quick whip of flame-thread.' },
{ id: 'cinderfang', name: 'Cinderfang', thread: 'Pyre', category: 'physical', power: 55, accuracy: 0.95, cost: 18, effect: { kind: 'burn', chance: 0.25, target: 'foe' }, description: 'Biting flame that may sear the foe with Burn.' },
{ id: 'ashvolley', name: 'Ashvolley', thread: 'Pyre', category: 'special', power: 15, accuracy: 0.95, cost: 15, hits: [2, 4], description: 'A scattershot of ember cinders, striking 2-4 times.' },
{ id: 'pyreburst', name: 'Pyreburst', thread: 'Pyre', category: 'special', power: 85, accuracy: 0.9, cost: 30, effect: { kind: 'burn', chance: 0.3, target: 'foe' }, description: 'A roaring detonation of woven flame.' },
{ id: 'kindlerite', name: 'Kindle Rite', thread: 'Pyre', category: 'status', power: 0, accuracy: 1.0, cost: 15, effect: { kind: 'atkUp', chance: 1, amount: 1, target: 'self' }, description: 'Chant that stokes the caster\'s inner flame, raising ATK.' },
{ id: 'searingfang', name: 'Searing Fang', thread: 'Pyre', category: 'physical', power: 70, accuracy: 0.85, cost: 22, priority: 1, description: 'A blistering priority bite.' },
// ---- Bloom ----
{ id: 'vinewhip', name: 'Vinewhip', thread: 'Bloom', category: 'physical', power: 40, accuracy: 1.0, cost: 20, description: 'A supple lash of living vine.' },
{ id: 'sporeveil', name: 'Sporeveil', thread: 'Bloom', category: 'status', power: 0, accuracy: 1.0, cost: 15, effect: { kind: 'defUp', chance: 1, amount: 1, target: 'self' }, description: 'Releases a thickening pollen veil, raising DEF.' },
{ id: 'rootbind', name: 'Rootbind', thread: 'Bloom', category: 'physical', power: 45, accuracy: 0.9, cost: 18, effect: { kind: 'speedDown', chance: 0.4, target: 'foe' }, description: 'Roots snare the foe\'s footing, may lower SPD.' },
{ id: 'blossomsurge', name: 'Blossom Surge', thread: 'Bloom', category: 'special', power: 80, accuracy: 0.9, cost: 28, effect: { kind: 'drain', chance: 1, amount: 0.33, target: 'self' }, description: 'A bloom of energy that drains vitality back to the caster.' },
{ id: 'regrowth', name: 'Regrowth', thread: 'Bloom', category: 'status', power: 0, accuracy: 1.0, cost: 20, effect: { kind: 'heal', chance: 1, amount: 0.5, target: 'self' }, description: 'Knits wounds shut with hurried green growth.' },
{ id: 'thornlattice', name: 'Thorn Lattice', thread: 'Bloom', category: 'status', power: 0, accuracy: 1.0, cost: 25, effect: { kind: 'barrier', chance: 1, target: 'self' }, description: 'Weaves a lattice of thorns that deflects the next hit.' },
// ---- Undertow ----
{ id: 'tidesnap', name: 'Tidesnap', thread: 'Undertow', category: 'physical', power: 42, accuracy: 1.0, cost: 20, description: 'A sudden snapping current.' },
{ id: 'frostcurrent', name: 'Frost Current', thread: 'Undertow', category: 'special', power: 55, accuracy: 0.9, cost: 22, effect: { kind: 'frost', chance: 0.3, target: 'foe' }, description: 'Chills the current, may inflict Frost.' },
{ id: 'undertow_pull', name: 'Undertow Pull', thread: 'Undertow', category: 'physical', power: 70, accuracy: 0.85, cost: 25, effect: { kind: 'speedDown', chance: 0.3, target: 'foe' }, description: 'Drags the foe under a heavy current.' },
{ id: 'deepsurge', name: 'Deepsurge', thread: 'Undertow', category: 'special', power: 90, accuracy: 0.85, cost: 30, description: 'An overwhelming wall of deep water.' },
{ id: 'mistveil', name: 'Mistveil', thread: 'Undertow', category: 'status', power: 0, accuracy: 1.0, cost: 15, effect: { kind: 'cleanse', chance: 1, target: 'self' }, description: 'A cool mist that washes away status ailments.' },
{ id: 'bubblearmor', name: 'Bubble Armor', thread: 'Undertow', category: 'status', power: 0, accuracy: 1.0, cost: 18, effect: { kind: 'defUp', chance: 1, amount: 1, target: 'self' }, description: 'Wraps the caster in pressurized foam, raising DEF.' },
// ---- Squall ----
{ id: 'gustcut', name: 'Gustcut', thread: 'Squall', category: 'physical', power: 40, accuracy: 1.0, cost: 18, priority: 1, description: 'A fast slicing gust; always acts quickly.' },
{ id: 'squallslam', name: 'Squall Slam', thread: 'Squall', category: 'physical', power: 75, accuracy: 0.9, cost: 26, description: 'A slamming wall of racing wind.' },
{ id: 'gale_step', name: 'Gale Step', thread: 'Squall', category: 'status', power: 0, accuracy: 1.0, cost: 15, effect: { kind: 'speedUp', chance: 1, amount: 1, target: 'self' }, description: 'Rides the wind, raising the caster\'s SPD.' },
{ id: 'thunderline', name: 'Thunderline', thread: 'Squall', category: 'special', power: 60, accuracy: 0.9, cost: 22, effect: { kind: 'shock', chance: 0.25, target: 'foe' }, description: 'A crackling pressure-front, may Shock the foe.' },
{ id: 'cyclonevault', name: 'Cyclone Vault', thread: 'Squall', category: 'special', power: 85, accuracy: 0.85, cost: 28, description: 'Launches the foe skyward inside a vaulting cyclone.' },
{ id: 'zephyrscreen', name: 'Zephyr Screen', thread: 'Squall', category: 'status', power: 0, accuracy: 1.0, cost: 20, effect: { kind: 'barrier', chance: 1, target: 'self' }, description: 'A wind-woven screen that deflects the next hit.' },
// ---- Umbrage ----
{ id: 'shadelash', name: 'Shadelash', thread: 'Umbrage', category: 'physical', power: 42, accuracy: 1.0, cost: 20, description: 'A lash of condensed shadow-static.' },
{ id: 'duskspike', name: 'Duskspike', thread: 'Umbrage', category: 'physical', power: 60, accuracy: 0.9, cost: 22, effect: { kind: 'flinch', chance: 0.2, target: 'foe' }, description: 'A jarring spike of dark weight that may cause flinching.' },
{ id: 'hollowgaze', name: 'Hollow Gaze', thread: 'Umbrage', category: 'status', power: 0, accuracy: 0.9, cost: 18, effect: { kind: 'atkDown', chance: 1, amount: 1, target: 'foe' }, description: 'An unsettling stare that saps the foe\'s ATK.' },
{ id: 'voidrend', name: 'Voidrend', thread: 'Umbrage', category: 'special', power: 88, accuracy: 0.85, cost: 30, effect: { kind: 'recoil', chance: 1, amount: 0.1, target: 'self' }, description: 'Tears a rift of pure absence, with painful backlash.' },
{ id: 'nightcoil', name: 'Nightcoil', thread: 'Umbrage', category: 'physical', power: 50, accuracy: 0.95, cost: 20, effect: { kind: 'defDown', chance: 0.3, target: 'foe' }, description: 'Coils of shadow crush the foe\'s guard.' },
{ id: 'eclipseward', name: 'Eclipse Ward', thread: 'Umbrage', category: 'status', power: 0, accuracy: 1.0, cost: 20, effect: { kind: 'regen', chance: 1, target: 'self' }, description: 'A ward of gloom that slowly mends the caster each turn.' },
// ---- Lumen ----
{ id: 'glintbeam', name: 'Glintbeam', thread: 'Lumen', category: 'special', power: 50, accuracy: 0.95, cost: 22, description: 'A narrow, severe beam of raw light.' },
{ id: 'flareprism', name: 'Flareprism', thread: 'Lumen', category: 'special', power: 90, accuracy: 0.8, cost: 32, effect: { kind: 'critUp', chance: 1, target: 'self' }, description: 'A blinding refracted burst, prone to devastating crits.' },
{ id: 'radiantpulse', name: 'Radiant Pulse', thread: 'Lumen', category: 'special', power: 65, accuracy: 0.9, cost: 24, effect: { kind: 'burn', chance: 0.15, target: 'foe' }, description: 'A pulse of exposed light that can scorch.' },
{ id: 'dawnsplinter', name: 'Dawnsplinter', thread: 'Lumen', category: 'physical', power: 55, accuracy: 0.95, cost: 20, priority: 1, description: 'A shard of hardened light, thrown with startling speed.' },
{ id: 'haloguard', name: 'Halo Guard', thread: 'Lumen', category: 'status', power: 0, accuracy: 1.0, cost: 22, effect: { kind: 'barrier', chance: 1, target: 'self' }, description: 'A ring of light that deflects the next hit entirely.' },
// ---- Bedrock ----
{ id: 'stoneknuckle', name: 'Stoneknuckle', thread: 'Bedrock', category: 'physical', power: 45, accuracy: 1.0, cost: 18, description: 'A blunt, dependable strike of old stone.' },
{ id: 'quarryslam', name: 'Quarry Slam', thread: 'Bedrock', category: 'physical', power: 80, accuracy: 0.85, cost: 28, description: 'An earth-shaking slam of quarried weight.' },
{ id: 'strataward', name: 'Strata Ward', thread: 'Bedrock', category: 'status', power: 0, accuracy: 1.0, cost: 20, effect: { kind: 'defUp', chance: 1, amount: 2, target: 'self' }, description: 'Layers stone into a hardened bulwark, sharply raising DEF.' },
{ id: 'tremorstep', name: 'Tremorstep', thread: 'Bedrock', category: 'physical', power: 35, accuracy: 1.0, cost: 15, hits: [2, 2], description: 'Two heavy stomping tremors in quick succession.' },
{ id: 'graniteguard', name: 'Granite Guard', thread: 'Bedrock', category: 'status', power: 0, accuracy: 1.0, cost: 24, effect: { kind: 'barrier', chance: 1, target: 'self' }, description: 'Hardens the caster\'s hide to deflect a single blow.' },
{ id: 'bedrockbellow', name: 'Bedrock Bellow', thread: 'Bedrock', category: 'special', power: 70, accuracy: 0.9, cost: 24, effect: { kind: 'speedDown', chance: 0.3, target: 'foe' }, description: 'A low bellow that shakes resolve and footing alike.' },
// ---- Neutral / cross-thread utility (assigned a thread but broad flavor) ----
{ id: 'reveille', name: 'Reveille', thread: 'Squall', category: 'status', power: 0, accuracy: 1.0, cost: 10, effect: { kind: 'cleanse', chance: 1, target: 'self' }, description: 'A sharp call that clears the caster\'s head of ailments.' },
{ id: 'focusedstrike', name: 'Focused Strike', thread: 'Bedrock', category: 'physical', power: 65, accuracy: 1.0, cost: 24, description: 'A perfectly measured, unerring blow.' },
{ id: 'wildslash', name: 'Wildslash', thread: 'Pyre', category: 'physical', power: 50, accuracy: 0.8, cost: 16, effect: { kind: 'critUp', chance: 0.5, target: 'self' }, description: 'A reckless flurry that lands hard when it lands at all.' },
{ id: 'siphoncoil', name: 'Siphon Coil', thread: 'Undertow', category: 'special', power: 45, accuracy: 0.95, cost: 20, effect: { kind: 'drain', chance: 1, amount: 0.5, target: 'self' }, description: 'Siphons vitality through a coiling current.' },
{ id: 'lastembers', name: 'Last Embers', thread: 'Pyre', category: 'special', power: 100, accuracy: 0.75, cost: 35, effect: { kind: 'recoil', chance: 1, amount: 0.2, target: 'self' }, description: 'An all-or-nothing detonation of the caster\'s remaining flame.' },
{ id: 'skyfall', name: 'Skyfall', thread: 'Squall', category: 'physical', power: 95, accuracy: 0.8, cost: 30, description: 'The caster drops from a great height with crushing force.' },
{ id: 'umbraltouch', name: 'Umbral Touch', thread: 'Umbrage', category: 'physical', power: 30, accuracy: 1.0, cost: 12, effect: { kind: 'drain', chance: 1, amount: 0.5, target: 'self' }, description: 'A draining touch of cold static.' },
{ id: 'brightlance', name: 'Brightlance', thread: 'Lumen', category: 'physical', power: 60, accuracy: 0.9, cost: 24, priority: 1, description: 'A lance of solidified light, thrown before the foe can react.' },
{ id: 'sandveil_stomp', name: 'Bedrock Stomp', thread: 'Bedrock', category: 'physical', power: 55, accuracy: 0.95, cost: 20, effect: { kind: 'flinch', chance: 0.15, target: 'foe' }, description: 'A ground-cracking stomp that may stagger the foe.' },
{ id: 'verdantrenewal', name: 'Verdant Renewal', thread: 'Bloom', category: 'status', power: 0, accuracy: 1.0, cost: 26, effect: { kind: 'regen', chance: 1, target: 'self' }, description: 'Wreathes the caster in slow-growing restorative moss.' },
{ id: 'stormfront', name: 'Stormfront', thread: 'Squall', category: 'special', power: 78, accuracy: 0.85, cost: 26, effect: { kind: 'frost', chance: 0.2, target: 'foe' }, description: 'A freezing pressure front that howls in on the foe.' },
];
function getAbility(id) {
const a = exports.ABILITIES.find(x => x.id === id);
if (!a)
throw new Error('Unknown ability: ' + id);
return a;
}
};
// ── module: src/data/stats.ts ──
__mods["src/data/stats.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.LEVEL_CAP = void 0;
exports.statAtLevel = statAtLevel;
exports.expForLevel = expForLevel;
// Shared stat-scaling curve so every creature's numbers grow consistently to the level 50 cap.
function statAtLevel(base, level, isHp = false) {
const t = Math.max(1, Math.min(50, level)) / 50;
if (isHp)
return Math.floor(base * (0.5 + 0.5 * t)) + level * 2;
return Math.floor(base * (0.35 + 0.65 * t));
}
exports.LEVEL_CAP = 50;
function expForLevel(level) {
// cumulative exp required to reach `level`
return Math.floor(20 * Math.pow(level, 2.2));
}
};
// ── module: src/data/creatures.ts ──
__mods["src/data/creatures.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.STARTER_IDS = exports.SPECIES = void 0;
exports.getSpecies = getSpecies;
exports.learnsetUpTo = learnsetUpTo;
exports.SPECIES = [
// ---------------- STARTERS ----------------
{
id: 'emberkit', name: 'Emberkit', threads: ['Pyre'], isStarter: true,
lore: 'A fox-shaped cinder that hatched from a hearth left burning too long; it sneezes sparks when startled.',
base: { hp: 42, atk: 52, def: 38, spd: 55 }, captureTier: 1,
learnset: [
{ level: 1, ability: 'emberlash' }, { level: 1, ability: 'kindlerite' },
{ level: 6, ability: 'wildslash' }, { level: 12, ability: 'cinderfang' },
{ level: 20, ability: 'ashvolley' }, { level: 28, ability: 'pyreburst' },
{ level: 38, ability: 'lastembers' },
],
transformsInto: 'emberfang', transformLevel: 16,
},
{
id: 'verdling', name: 'Verdling', threads: ['Bloom'], isStarter: true,
lore: 'A seed-pod given legs by loose Bloom-thread; it hums faint harmonics that calm nearby plants.',
base: { hp: 48, atk: 45, def: 48, spd: 42 }, captureTier: 1,
learnset: [
{ level: 1, ability: 'vinewhip' }, { level: 1, ability: 'sporeveil' },
{ level: 8, ability: 'rootbind' }, { level: 14, ability: 'regrowth' },
{ level: 22, ability: 'thornlattice' }, { level: 30, ability: 'blossomsurge' },
{ level: 40, ability: 'verdantrenewal' },
],
transformsInto: 'verdstag', transformLevel: 16,
},
{
id: 'ripplet', name: 'Ripplet', threads: ['Undertow'], isStarter: true,
lore: 'A droplet-bodied wisp that never fully evaporates, said to remember every rain it has ever fallen as.',
base: { hp: 45, atk: 42, def: 44, spd: 50 }, captureTier: 1,
learnset: [
{ level: 1, ability: 'tidesnap' }, { level: 1, ability: 'mistveil' },
{ level: 7, ability: 'bubblearmor' }, { level: 13, ability: 'frostcurrent' },
{ level: 21, ability: 'siphoncoil' }, { level: 29, ability: 'undertow_pull' },
{ level: 39, ability: 'deepsurge' },
],
transformsInto: 'ripcoil', transformLevel: 16,
},
// ---------------- EARLY WILDS ----------------
{
id: 'gustling', name: 'Gustling', threads: ['Squall'],
lore: 'A tuft of storm-down that drifts against the wind out of pure stubbornness.',
base: { hp: 40, atk: 46, def: 36, spd: 62 }, captureTier: 1,
learnset: [
{ level: 1, ability: 'gustcut' }, { level: 1, ability: 'gale_step' },
{ level: 9, ability: 'reveille' }, { level: 15, ability: 'thunderline' },
{ level: 24, ability: 'squallslam' }, { level: 33, ability: 'cyclonevault' },
{ level: 42, ability: 'skyfall' },
],
transformsInto: 'gustrider', transformLevel: 18,
},
{
id: 'shadeling', name: 'Shadeling', threads: ['Umbrage'],
lore: 'A patch of shade that detached from its caster and never found its way home.',
base: { hp: 44, atk: 50, def: 40, spd: 48 }, captureTier: 1,
learnset: [
{ level: 1, ability: 'shadelash' }, { level: 1, ability: 'hollowgaze' },
{ level: 10, ability: 'umbraltouch' }, { level: 16, ability: 'duskspike' },
{ level: 25, ability: 'nightcoil' }, { level: 34, ability: 'eclipseward' },
{ level: 43, ability: 'voidrend' },
],
transformsInto: 'shadowmaw', transformLevel: 18,
},
{
id: 'pebblet', name: 'Pebblet', threads: ['Bedrock'],
lore: 'A pebble that rolled uphill once, and has been insufferably smug about it ever since.',
base: { hp: 50, atk: 40, def: 58, spd: 30 }, captureTier: 1,
learnset: [
{ level: 1, ability: 'stoneknuckle' }, { level: 1, ability: 'graniteguard' },
{ level: 11, ability: 'tremorstep' }, { level: 17, ability: 'strataward' },
{ level: 26, ability: 'sandveil_stomp' }, { level: 35, ability: 'focusedstrike' },
{ level: 44, ability: 'quarryslam' },
],
transformsInto: 'boulderkin', transformLevel: 20, transformCatalyst: 'quarry_shard',
},
// ---------------- MID WILDS (no transform) ----------------
{
id: 'squallhawk', name: 'Squallhawk', threads: ['Squall'],
lore: 'A raptor that nests inside standing storm fronts, riding them for days without landing.',
base: { hp: 52, atk: 64, def: 44, spd: 68 }, captureTier: 2,
learnset: [
{ level: 1, ability: 'gustcut' }, { level: 5, ability: 'gale_step' },
{ level: 14, ability: 'thunderline' }, { level: 24, ability: 'squallslam' },
{ level: 34, ability: 'cyclonevault' }, { level: 44, ability: 'skyfall' },
],
},
{
id: 'duskmoth', name: 'Duskmoth', threads: ['Umbrage'],
lore: 'Its wingbeats erase small sounds nearby, leaving a trailing pocket of unnatural quiet.',
base: { hp: 46, atk: 58, def: 42, spd: 60 }, captureTier: 2,
learnset: [
{ level: 1, ability: 'shadelash' }, { level: 6, ability: 'hollowgaze' },
{ level: 15, ability: 'duskspike' }, { level: 25, ability: 'nightcoil' },
{ level: 35, ability: 'eclipseward' }, { level: 45, ability: 'voidrend' },
],
},
{
id: 'cragclaw', name: 'Cragclaw', threads: ['Bedrock'],
lore: 'A crustacean of layered slate that sheds a claw-plate every winter and grows a heavier one.',
base: { hp: 60, atk: 62, def: 66, spd: 28 }, captureTier: 2,
learnset: [
{ level: 1, ability: 'stoneknuckle' }, { level: 8, ability: 'tremorstep' },
{ level: 18, ability: 'strataward' }, { level: 28, ability: 'sandveil_stomp' },
{ level: 38, ability: 'quarryslam' }, { level: 46, ability: 'focusedstrike' },
],
},
{
id: 'mossback', name: 'Mossback', threads: ['Bloom'],
lore: 'A slow tortoise-shape whose shell is a living garden; travelers once mistook it for a hillock.',
base: { hp: 64, atk: 48, def: 60, spd: 26 }, captureTier: 2,
learnset: [
{ level: 1, ability: 'vinewhip' }, { level: 7, ability: 'sporeveil' },
{ level: 17, ability: 'rootbind' }, { level: 27, ability: 'regrowth' },
{ level: 37, ability: 'thornlattice' }, { level: 47, ability: 'blossomsurge' },
],
transformsInto: 'grovebehemoth', transformLevel: 32,
},
{
id: 'brinefin', name: 'Brinefin', threads: ['Undertow'],
lore: 'A finned wanderer of brackish depths, marked with luminous scars from currents no map records.',
base: { hp: 58, atk: 56, def: 50, spd: 46 }, captureTier: 2,
learnset: [
{ level: 1, ability: 'tidesnap' }, { level: 6, ability: 'bubblearmor' },
{ level: 16, ability: 'frostcurrent' }, { level: 26, ability: 'siphoncoil' },
{ level: 36, ability: 'undertow_pull' }, { level: 46, ability: 'deepsurge' },
],
transformsInto: 'leviatide', transformLevel: 34, transformCatalyst: 'tide_sigil',
},
{
id: 'cindertusk', name: 'Cindertusk', threads: ['Pyre'],
lore: 'A boar-shaped brand of living coal; herds of them mark old migration paths with scorched earth.',
base: { hp: 62, atk: 68, def: 52, spd: 40 }, captureTier: 2,
learnset: [
{ level: 1, ability: 'emberlash' }, { level: 6, ability: 'kindlerite' },
{ level: 16, ability: 'cinderfang' }, { level: 26, ability: 'wildslash' },
{ level: 36, ability: 'ashvolley' }, { level: 46, ability: 'pyreburst' },
],
transformsInto: 'magmatusk', transformLevel: 30, transformCatalyst: 'ember_core',
},
// ---------------- DUAL-THREAD WILDS ----------------
{
id: 'emberfox', name: 'Cinderjaw Fox', threads: ['Pyre', 'Bedrock'],
lore: 'Denning in cooled lava tubes, it hoards warm stones and bites like a furnace door slamming shut.',
base: { hp: 54, atk: 66, def: 58, spd: 44 }, captureTier: 2,
learnset: [
{ level: 1, ability: 'emberlash' }, { level: 1, ability: 'stoneknuckle' },
{ level: 12, ability: 'cinderfang' }, { level: 22, ability: 'strataward' },
{ level: 32, ability: 'quarryslam' }, { level: 42, ability: 'pyreburst' },
],
},
{
id: 'thornhare', name: 'Thornhare', threads: ['Bloom', 'Squall'],
lore: 'Bounds across meadows fast enough to leave a trailing hedge of instant thorned growth.',
base: { hp: 46, atk: 60, def: 40, spd: 66 }, captureTier: 2,
learnset: [
{ level: 1, ability: 'vinewhip' }, { level: 1, ability: 'gustcut' },
{ level: 12, ability: 'rootbind' }, { level: 22, ability: 'gale_step' },
{ level: 32, ability: 'thornlattice' }, { level: 42, ability: 'cyclonevault' },
],
},
{
id: 'tidewisp', name: 'Tidewisp', threads: ['Undertow', 'Umbrage'],
lore: 'A drowned lantern-light said to guide sailors toward shortcuts that are never quite safe.',
base: { hp: 50, atk: 58, def: 46, spd: 52 }, captureTier: 2,
learnset: [
{ level: 1, ability: 'tidesnap' }, { level: 1, ability: 'shadelash' },
{ level: 13, ability: 'hollowgaze' }, { level: 23, ability: 'frostcurrent' },
{ level: 33, ability: 'nightcoil' }, { level: 43, ability: 'voidrend' },
],
},
// ---------------- RARES ----------------
{
id: 'glimmermote', name: 'Glimmermote', threads: ['Lumen'],
lore: 'A speck of exposed daylight given wings; staring at one too long leaves spots in your vision for days.',
base: { hp: 40, atk: 62, def: 34, spd: 58 }, captureTier: 3,
learnset: [
{ level: 1, ability: 'glintbeam' }, { level: 10, ability: 'dawnsplinter' },
{ level: 20, ability: 'haloguard' }, { level: 30, ability: 'radiantpulse' },
{ level: 40, ability: 'flareprism' }, { level: 48, ability: 'brightlance' },
],
},
{
id: 'sunspeck', name: 'Sunspeck', threads: ['Lumen'],
lore: 'Born when Lumen-thread pools too thickly in one place; it drifts in slow, deliberate spirals.',
base: { hp: 44, atk: 66, def: 38, spd: 50 }, captureTier: 3,
learnset: [
{ level: 1, ability: 'glintbeam' }, { level: 9, ability: 'haloguard' },
{ level: 19, ability: 'dawnsplinter' }, { level: 29, ability: 'radiantpulse' },
{ level: 39, ability: 'brightlance' }, { level: 47, ability: 'flareprism' },
],
},
{
id: 'stormcrest', name: 'Stormcrest', threads: ['Squall', 'Lumen'],
lore: 'A crowned bird said to nest only at the exact center of a lightning storm, unscathed.',
base: { hp: 56, atk: 70, def: 48, spd: 64 }, captureTier: 3,
learnset: [
{ level: 1, ability: 'gustcut' }, { level: 1, ability: 'glintbeam' },
{ level: 15, ability: 'thunderline' }, { level: 25, ability: 'dawnsplinter' },
{ level: 35, ability: 'cyclonevault' }, { level: 45, ability: 'flareprism' },
],
},
// ---------------- TRANSFORMED FORMS ----------------
{
id: 'emberfang', name: 'Emberfang', threads: ['Pyre'], transformsFrom: 'emberkit',
lore: 'The Emberkit\'s adult shape, its sneeze become a roar and its coat a banked coal that never quite cools.',
base: { hp: 60, atk: 78, def: 54, spd: 72 }, captureTier: 2,
learnset: [
{ level: 16, ability: 'searingfang' }, { level: 24, ability: 'cinderfang' },
{ level: 32, ability: 'ashvolley' }, { level: 40, ability: 'pyreburst' },
{ level: 48, ability: 'lastembers' },
],
},
{
id: 'verdstag', name: 'Verdstag', threads: ['Bloom'], transformsFrom: 'verdling',
lore: 'Antlers of braided root now crown the Verdling\'s grown shape, said to point toward water underground.',
base: { hp: 68, atk: 66, def: 66, spd: 54 }, captureTier: 2,
learnset: [
{ level: 16, ability: 'thornlattice' }, { level: 24, ability: 'rootbind' },
{ level: 32, ability: 'blossomsurge' }, { level: 40, ability: 'verdantrenewal' },
{ level: 48, ability: 'regrowth' },
],
},
{
id: 'ripcoil', name: 'Ripcoil', threads: ['Undertow'], transformsFrom: 'ripplet',
lore: 'What a Ripplet becomes once it remembers enough rain to hold a shape of its own choosing.',
base: { hp: 64, atk: 64, def: 62, spd: 66 }, captureTier: 2,
learnset: [
{ level: 16, ability: 'undertow_pull' }, { level: 24, ability: 'siphoncoil' },
{ level: 32, ability: 'deepsurge' }, { level: 40, ability: 'bubblearmor' },
{ level: 48, ability: 'mistveil' },
],
},
{
id: 'gustrider', name: 'Gustrider', threads: ['Squall'], transformsFrom: 'gustling',
lore: 'A Gustling that stopped drifting against the wind and started outrunning it entirely.',
base: { hp: 58, atk: 72, def: 50, spd: 88 }, captureTier: 2,
learnset: [
{ level: 18, ability: 'squallslam' }, { level: 26, ability: 'cyclonevault' },
{ level: 34, ability: 'skyfall' }, { level: 42, ability: 'thunderline' },
{ level: 50, ability: 'gale_step' },
],
},
{
id: 'shadowmaw', name: 'Shadowmaw', threads: ['Umbrage'], transformsFrom: 'shadeling',
lore: 'The detached shade finally found a body large enough to keep, and has no intention of giving it back.',
base: { hp: 62, atk: 82, def: 56, spd: 62 }, captureTier: 2,
learnset: [
{ level: 18, ability: 'duskspike' }, { level: 26, ability: 'nightcoil' },
{ level: 34, ability: 'voidrend' }, { level: 42, ability: 'eclipseward' },
{ level: 50, ability: 'hollowgaze' },
],
},
{
id: 'boulderkin', name: 'Boulderkin', threads: ['Bedrock'], transformsFrom: 'pebblet',
lore: 'Bound to a Quarry Shard, the smug little Pebblet swells into a walking outcrop that answers to no one.',
base: { hp: 82, atk: 62, def: 92, spd: 34 }, captureTier: 2,
learnset: [
{ level: 20, ability: 'strataward' }, { level: 28, ability: 'quarryslam' },
{ level: 36, ability: 'focusedstrike' }, { level: 44, ability: 'sandveil_stomp' },
{ level: 50, ability: 'tremorstep' },
],
},
{
id: 'magmatusk', name: 'Magmatusk', threads: ['Pyre'], transformsFrom: 'cindertusk',
lore: 'Bound with an Ember Core, the herd-marker becomes a walking landmark of its own, trailing slow lava cracks.',
base: { hp: 86, atk: 90, def: 68, spd: 46 }, captureTier: 2,
learnset: [
{ level: 30, ability: 'pyreburst' }, { level: 36, ability: 'wildslash' },
{ level: 42, ability: 'lastembers' }, { level: 48, ability: 'ashvolley' },
],
},
{
id: 'grovebehemoth', name: 'Grovebehemoth', threads: ['Bloom'], transformsFrom: 'mossback',
lore: 'A Mossback that stopped growing a garden and started being one; small shrines are sometimes built on its shell.',
base: { hp: 96, atk: 68, def: 88, spd: 30 }, captureTier: 2,
learnset: [
{ level: 32, ability: 'blossomsurge' }, { level: 38, ability: 'thornlattice' },
{ level: 44, ability: 'verdantrenewal' }, { level: 50, ability: 'regrowth' },
],
},
{
id: 'leviatide', name: 'Leviatide', threads: ['Undertow'], transformsFrom: 'brinefin',
lore: 'Bound with a Tide Sigil, the wanderer grows vast enough that its wake is mistaken for weather.',
base: { hp: 90, atk: 82, def: 74, spd: 52 }, captureTier: 2,
learnset: [
{ level: 34, ability: 'deepsurge' }, { level: 40, ability: 'undertow_pull' },
{ level: 46, ability: 'siphoncoil' }, { level: 50, ability: 'bubblearmor' },
],
},
];
function getSpecies(id) {
const s = exports.SPECIES.find(x => x.id === id);
if (!s)
throw new Error('Unknown species: ' + id);
return s;
}
function learnsetUpTo(species, level) {
return species.learnset.filter(l => l.level <= level).map(l => l.ability);
}
exports.STARTER_IDS = ['emberkit', 'verdling', 'ripplet'];
};
// ── module: src/data/items.ts ──
__mods["src/data/items.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ITEMS = void 0;
exports.getItem = getItem;
exports.ITEMS = [
{ id: 'twine_snare', name: 'Twine Snare', kind: 'capture', power: 1, price: 40, desc: 'A simple braided cord. Binds weak or wounded wild creatures.' },
{ id: 'woven_lattice', name: 'Woven Lattice', kind: 'capture', power: 2, price: 120, desc: 'A reinforced binding lattice. Reliable against most wild creatures.' },
{ id: 'sigil_cage', name: 'Sigil Cage', kind: 'capture', power: 3, price: 320, desc: 'An inscribed cage-sigil, humming with focused intent. Binds even stubborn creatures.' },
{ id: 'balm_dram', name: 'Balm Dram', kind: 'restore', power: 25, price: 30, desc: 'Restores a modest measure of HP.' },
{ id: 'balm_flask', name: 'Balm Flask', kind: 'restore', power: 60, price: 80, desc: 'Restores a generous measure of HP.' },
{ id: 'balm_reserve', name: 'Balm Reserve', kind: 'restore', power: 999, price: 220, desc: 'Fully restores a creature\'s HP.' },
{ id: 'clear_salts', name: 'Clear Salts', kind: 'restore', power: 0, price: 45, desc: 'Cleanses all status ailments from one creature.' },
{ id: 'quarry_shard', name: 'Quarry Shard', kind: 'catalyst', price: 260, desc: 'A dense fragment of old bedrock. Some creatures transform when holding it at the right level.' },
{ id: 'ember_core', name: 'Ember Core', kind: 'catalyst', price: 260, desc: 'A coal that never cools. Catalyzes transformation in creatures tied to Pyre.' },
{ id: 'tide_sigil', name: 'Tide Sigil', kind: 'catalyst', price: 260, desc: 'An inscribed sigil that hums in the presence of deep water. Catalyzes Undertow transformations.' },
{ id: 'trail_lantern', name: 'Trail Lantern', kind: 'field', price: 90, desc: 'Lights dark paths and reveals hidden shrine nodes on the map.' },
{ id: 'echo_charm', name: 'Echo Charm', kind: 'field', price: 150, desc: 'Faintly resonates near zone Guardians, drawing them toward the wearer.' },
];
function getItem(id) {
const i = exports.ITEMS.find(x => x.id === id);
if (!i)
throw new Error('Unknown item: ' + id);
return i;
}
};
// ── module: src/data/zones.ts ──
__mods["src/data/zones.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ZONES = void 0;
exports.getZone = getZone;
exports.ZONES = [
{
id: 'meadow', name: 'Verdant Hush', gradient: ['#1e3a24', '#3f6b3a'], particle: 'petal',
encounterRate: 0.12, width: 12, height: 9,
wilds: [
{ species: 'verdling', weight: 3, minLv: 2, maxLv: 4 },
{ species: 'gustling', weight: 2, minLv: 2, maxLv: 4 },
{ species: 'pebblet', weight: 2, minLv: 2, maxLv: 4 },
{ species: 'emberkit', weight: 1, minLv: 2, maxLv: 4 },
],
guardian: { name: 'Tussock, Warden of the Hush', species: 'mossback', level: 8, bonusText: 'All party creatures gain +5% max HP permanently.', flavor: 'A boulder of moss unfurls, roots cracking the old shrine stones.' },
storyNode: 'You leave the Hollow Loft with your first bound creature, unsure the Threads chose wisely.',
entry: { x: 1, y: 4 }, shrine: { x: 3, y: 4 }, guardianPos: { x: 10, y: 4 }, storyPos: { x: 6, y: 2 }, exit: { x: 11, y: 4 },
},
{
id: 'ashfen', name: 'Ashfen Reach', gradient: ['#3a1f14', '#8a3a1f'], particle: 'ember',
encounterRate: 0.14, width: 13, height: 9, unlockRequires: 'meadow',
wilds: [
{ species: 'cindertusk', weight: 3, minLv: 6, maxLv: 10 },
{ species: 'emberfox', weight: 2, minLv: 7, maxLv: 11 },
{ species: 'gustling', weight: 2, minLv: 6, maxLv: 9 },
{ species: 'shadeling', weight: 1, minLv: 6, maxLv: 9 },
],
guardian: { name: 'Cindral, Warden of the Reach', species: 'cindertusk', level: 14, bonusText: 'All Pyre-affine party creatures gain +8% ATK permanently.', flavor: 'The scorched ground splits as Cindral rises, tusks glowing like coals.' },
storyNode: 'Rumors speak of a Second Loombreak splitting the old Threads apart at Ashfen\'s heart.',
entry: { x: 1, y: 4 }, shrine: { x: 3, y: 5 }, guardianPos: { x: 11, y: 4 }, storyPos: { x: 6, y: 2 }, exit: { x: 12, y: 4 },
},
{
id: 'shorewatch', name: 'Shorewatch Strand', gradient: ['#0e2a3a', '#1f6b8a'], particle: 'bubble',
encounterRate: 0.13, width: 13, height: 10, unlockRequires: 'ashfen',
wilds: [
{ species: 'brinefin', weight: 3, minLv: 12, maxLv: 16 },
{ species: 'ripplet', weight: 2, minLv: 12, maxLv: 15 },
{ species: 'tidewisp', weight: 2, minLv: 13, maxLv: 17 },
{ species: 'squallhawk', weight: 1, minLv: 12, maxLv: 16 },
],
guardian: { name: 'Maren, Warden of the Strand', species: 'brinefin', level: 20, bonusText: 'All party creatures gain +8% SPD permanently.', flavor: 'The tide itself rears up and folds into Maren\'s luminous scars.' },
storyNode: 'A drowned lantern-light leads you to a submerged shrine the old maps refuse to name.',
entry: { x: 1, y: 5 }, shrine: { x: 3, y: 4 }, guardianPos: { x: 11, y: 5 }, storyPos: { x: 6, y: 7 }, exit: { x: 12, y: 5 },
},
{
id: 'stormrise', name: 'Stormrise Heights', gradient: ['#22243a', '#4d5b9c'], particle: 'spark',
encounterRate: 0.15, width: 14, height: 10, unlockRequires: 'shorewatch',
wilds: [
{ species: 'squallhawk', weight: 3, minLv: 18, maxLv: 23 },
{ species: 'thornhare', weight: 2, minLv: 18, maxLv: 22 },
{ species: 'stormcrest', weight: 1, minLv: 20, maxLv: 24 },
{ species: 'duskmoth', weight: 2, minLv: 18, maxLv: 22 },
],
guardian: { name: 'Kestrion, Warden of the Heights', species: 'stormcrest', level: 26, bonusText: 'All party creatures gain +8% DEF permanently.', flavor: 'Lightning gathers into a single crowned shape atop the old watch-spire.' },
storyNode: 'The First Anchor trembles. Something below Stormrise remembers being whole.',
entry: { x: 1, y: 5 }, shrine: { x: 3, y: 5 }, guardianPos: { x: 12, y: 5 }, storyPos: { x: 7, y: 2 }, exit: { x: 13, y: 5 },
},
{
id: 'duskmire', name: 'Duskmire Hollow', gradient: ['#180d24', '#4a2a63'], particle: 'ash',
encounterRate: 0.16, width: 13, height: 10, unlockRequires: 'stormrise',
wilds: [
{ species: 'duskmoth', weight: 3, minLv: 26, maxLv: 31 },
{ species: 'shadowmaw', weight: 1, minLv: 28, maxLv: 32 },
{ species: 'tidewisp', weight: 2, minLv: 26, maxLv: 30 },
{ species: 'cragclaw', weight: 2, minLv: 26, maxLv: 30 },
],
guardian: { name: 'Nocturne, Warden of the Hollow', species: 'shadowmaw', level: 34, bonusText: 'All party creatures gain +10% ATK permanently.', flavor: 'Every shadow in the hollow bends toward one impossible silhouette.' },
storyNode: 'The Second Anchor cracks open, spilling static-shadow across the mire.',
entry: { x: 1, y: 5 }, shrine: { x: 3, y: 4 }, guardianPos: { x: 11, y: 5 }, storyPos: { x: 6, y: 7 }, exit: { x: 12, y: 5 },
},
{
id: 'sunspire', name: 'Sunspire Plateau', gradient: ['#3a2e0e', '#c9a13a'], particle: 'spark',
encounterRate: 0.17, width: 14, height: 11, unlockRequires: 'duskmire',
wilds: [
{ species: 'glimmermote', weight: 3, minLv: 34, maxLv: 40 },
{ species: 'sunspeck', weight: 2, minLv: 34, maxLv: 40 },
{ species: 'cragclaw', weight: 2, minLv: 33, maxLv: 38 },
{ species: 'stormcrest', weight: 1, minLv: 35, maxLv: 41 },
],
guardian: { name: 'Solace, Last Warden of the Plateau', species: 'sunspeck', level: 42, bonusText: 'All party creatures gain +12% to every stat permanently.', flavor: 'The plateau ignites white as Solace unveils the last unbroken Thread.' },
storyNode: 'At the Plateau\'s crown, the Third and Fourth Anchors wait, fused into one final Loom.',
entry: { x: 1, y: 5 }, shrine: { x: 3, y: 5 }, guardianPos: { x: 12, y: 5 }, storyPos: { x: 7, y: 8 }, exit: { x: 13, y: 5 },
},
];
function getZone(id) {
const z = exports.ZONES.find(x => x.id === id);
if (!z)
throw new Error('Unknown zone: ' + id);
return z;
}
};
// ── module: src/data/story.ts ──
__mods["src/data/story.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.STORY_BEATS = void 0;
exports.getStoryBeat = getStoryBeat;
exports.STORY_BEATS = [
{
stage: 0, title: 'The Hollow Loft', vignette: '#3f6b3a',
lines: [
'Long ago the world of Virel was woven from seven Threads, and every living thing still carries a strand or two.',
'You wake in the Hollow Loft, a keeper-house for creatures whose Threads came loose from the world\'s weave.',
'An old Binder places a choice before you: three young creatures, three Threads. Choose one to walk beside you.',
],
},
{
stage: 1, title: 'Threads Fraying', vignette: '#8a3a1f',
lines: [
'Word reaches the Loft of a "Loombreak" — old Anchors that once stabilized the Threads are waking, unstable.',
'Four such Anchors are said to sleep beneath the zone Guardians. Wake none carelessly.',
'Your task, as a new Binder, is simple to say and hard to do: bind eight distinct Threads of life, and still the four Anchors.',
],
},
{
stage: 2, title: 'The First Anchor', vignette: '#4d5b9c',
lines: [
'At Stormrise Heights the first Anchor stirs beneath the old watch-spire, and the sky itself seems to lean toward it.',
'Guardian Kestrion holds the line, but even wardens tire. Whatever wakes here will not stay contained for long.',
],
},
{
stage: 3, title: 'The Second Anchor', vignette: '#4a2a63',
lines: [
'Duskmire Hollow has no true night, only an endless early-dusk — the mark of an Anchor bleeding shadow-static into the world.',
'Guardian Nocturne no longer answers to their old name. Something in the mire is unraveling faster than it can be rewoven.',
],
},
{
stage: 4, title: 'The Last Loom', vignette: '#c9a13a',
lines: [
'Sunspire Plateau holds the fused Third and Fourth Anchors — the last unbroken Thread of the old weave, and the last Warden.',
'Solace does not fight to win. Solace fights to be certain you are ready to hold what comes after.',
'Beyond the Plateau, the Threads wait to be rewoven — or left to come apart for good. That choice was never the Anchors\' to make.',
],
},
];
function getStoryBeat(stage) {
return exports.STORY_BEATS.find(b => b.stage === stage) ?? exports.STORY_BEATS[0];
}
};
// ── module: src/engine/rng.ts ──
__mods["src/engine/rng.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.rnd = rnd;
exports.rndInt = rndInt;
exports.chance = chance;
exports.pick = pick;
exports.weightedPick = weightedPick;
// Small deterministic-friendly RNG helpers (Math.random based; determinism not required).
function rnd() { return Math.random(); }
function rndInt(min, max) { return Math.floor(rnd() * (max - min + 1)) + min; }
function chance(p) { return rnd() < p; }
function pick(arr) { return arr[rndInt(0, arr.length - 1)]; }
function weightedPick(arr) {
const total = arr.reduce((s, x) => s + x.weight, 0);
let r = rnd() * total;
for (const x of arr) {
if (r < x.weight)
return x;
r -= x.weight;
}
return arr[arr.length - 1];
}
};
// ── module: src/engine/creatureInstance.ts ──
__mods["src/engine/creatureInstance.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.makeUid = makeUid;
exports.createInstance = createInstance;
exports.species = species;
exports.displayName = displayName;
exports.maxHp = maxHp;
exports.effAtk = effAtk;
exports.effDef = effDef;
exports.effSpd = effSpd;
exports.threads = threads;
exports.isFainted = isFainted;
exports.expToNext = expToNext;
exports.grantExp = grantExp;
exports.canTransform = canTransform;
exports.transform = transform;
exports.resetBattleVolatiles = resetBattleVolatiles;
exports.fullHeal = fullHeal;
const creatures_1 = require("../data/creatures");
const stats_1 = require("../data/stats");
let uidCounter = 1;
function makeUid() { return 'c' + (uidCounter++) + '_' + Date.now().toString(36); }
function createInstance(speciesId, level) {
const sp = (0, creatures_1.getSpecies)(speciesId);
const moves = (0, creatures_1.learnsetUpTo)(sp, level).slice(-4);
const hp = (0, stats_1.statAtLevel)(sp.base.hp, level, true);
return {
uid: makeUid(), speciesId, level, exp: 0, curHp: hp,
moves: moves.length ? moves : (0, creatures_1.learnsetUpTo)(sp, 1),
status: {}, stages: { atk: 0, def: 0, spd: 0 }, caughtAt: Date.now(),
};
}
function species(ci) { return (0, creatures_1.getSpecies)(ci.speciesId); }
function displayName(ci) {
return ci.nickname || species(ci).name;
}
function maxHp(ci) {
return (0, stats_1.statAtLevel)(species(ci).base.hp, ci.level, true);
}
function stageMultiplier(stage) {
const s = Math.max(-4, Math.min(4, stage));
return s >= 0 ? (2 + s) / 2 : 2 / (2 - s);
}
function effAtk(ci) {
const base = (0, stats_1.statAtLevel)(species(ci).base.atk, ci.level);
const staged = base * stageMultiplier(ci.stages.atk);
return ci.status.burn ? staged * 0.5 : staged;
}
function effDef(ci) {
const base = (0, stats_1.statAtLevel)(species(ci).base.def, ci.level);
return base * stageMultiplier(ci.stages.def);
}
function effSpd(ci) {
const base = (0, stats_1.statAtLevel)(species(ci).base.spd, ci.level);
const staged = base * stageMultiplier(ci.stages.spd);
return ci.status.frost ? staged * 0.5 : staged;
}
function threads(ci) { return species(ci).threads; }
function isFainted(ci) { return ci.curHp <= 0; }
function expToNext(ci) {
const need = (0, stats_1.expForLevel)(ci.level + 1) - (0, stats_1.expForLevel)(ci.level);
const cur = ci.exp - (0, stats_1.expForLevel)(ci.level);
return { cur, need };
}
// Returns true if leveled up at least once; mutates ci in place, clamping HP proportionally.
function grantExp(ci, amount) {
const levelsGained = [];
ci.exp += amount;
while (ci.level < 50 && ci.exp >= (0, stats_1.expForLevel)(ci.level + 1)) {
const prevMax = maxHp(ci);
ci.level++;
levelsGained.push(ci.level);
const newMax = maxHp(ci);
ci.curHp += Math.max(0, newMax - prevMax);
// auto-learn newest moves, keep at most 4 (drop oldest)
const learn = (0, creatures_1.learnsetUpTo)(species(ci), ci.level);
const newest = learn.slice(-4);
for (const m of newest)
if (!ci.moves.includes(m)) {
if (ci.moves.length >= 4)
ci.moves.shift();
ci.moves.push(m);
}
}
return levelsGained;
}
function canTransform(ci) {
const sp = species(ci);
if (!sp.transformsInto)
return false;
if (sp.transformLevel && ci.level < sp.transformLevel)
return false;
if (sp.transformCatalyst && ci.heldCatalyst !== sp.transformCatalyst)
return false;
return true;
}
function transform(ci) {
const sp = species(ci);
if (!canTransform(ci) || !sp.transformsInto)
return false;
const pctHp = ci.curHp / maxHp(ci);
ci.speciesId = sp.transformsInto;
ci.curHp = Math.max(1, Math.round(maxHp(ci) * pctHp));
return true;
}
function resetBattleVolatiles(ci) {
ci.stages = { atk: 0, def: 0, spd: 0 };
ci.status.barrier = false;
ci.status.shock = false;
}
function fullHeal(ci) {
ci.curHp = maxHp(ci);
ci.status = {};
ci.stages = { atk: 0, def: 0, spd: 0 };
}
};
// ── module: src/engine/state.ts ──
__mods["src/engine/state.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.newGameState = newGameState;
exports.addItem = addItem;
exports.removeItem = removeItem;
exports.itemQty = itemQty;
exports.addToParty = addToParty;
exports.firstAliveIndex = firstAliveIndex;
exports.fullHealParty = fullHealParty;
exports.markSeen = markSeen;
exports.markCaptured = markCaptured;
const creatureInstance_1 = require("./creatureInstance");
const zones_1 = require("../data/zones");
function newGameState() {
return {
party: [],
boxed: [],
inventory: [
{ itemId: 'twine_snare', qty: 5 },
{ itemId: 'balm_dram', qty: 3 },
{ itemId: 'clear_salts', qty: 2 },
],
gold: 100,
pos: { zoneId: zones_1.ZONES[0].id, x: 6, y: 4, facing: 'down' },
flags: { guardiansCleared: [], encyclopedia: [], captured: [], storyStage: 0, tutorialDone: false, postGame: false, starterChosen: false },
lastShrine: null,
muted: false,
statBonus: { hp: 0, atk: 0, def: 0, spd: 0 },
};
}
function addItem(state, itemId, qty) {
const e = state.inventory.find(i => i.itemId === itemId);
if (e)
e.qty += qty;
else
state.inventory.push({ itemId, qty });
}
function removeItem(state, itemId, qty) {
const e = state.inventory.find(i => i.itemId === itemId);
if (!e || e.qty < qty)
return false;
e.qty -= qty;
if (e.qty <= 0)
state.inventory = state.inventory.filter(i => i.itemId !== itemId);
return true;
}
function itemQty(state, itemId) {
return state.inventory.find(i => i.itemId === itemId)?.qty ?? 0;
}
function addToParty(state, ci) {
if (state.party.length < 6) {
state.party.push(ci);
return true;
}
state.boxed.push(ci);
return false;
}
function firstAliveIndex(state) {
return state.party.findIndex(c => c.curHp > 0);
}
function fullHealParty(state) {
for (const ci of state.party)
(0, creatureInstance_1.fullHeal)(ci);
}
function markSeen(state, speciesId) {
if (!state.flags.encyclopedia.includes(speciesId))
state.flags.encyclopedia.push(speciesId);
}
function markCaptured(state, speciesId) {
markSeen(state, speciesId);
if (!state.flags.captured.includes(speciesId))
state.flags.captured.push(speciesId);
}
};
// ── module: src/engine/save.ts ──
__mods["src/engine/save.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.saveGame = saveGame;
exports.loadGame = loadGame;
exports.hasSave = hasSave;
exports.clearSave = clearSave;
const SAVE_KEY = 'loombound_save_v1';
function saveGame(state) {
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(state));
}
catch (e) { /* storage unavailable — never soft-lock */ }
}
function loadGame() {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw)
return null;
return JSON.parse(raw);
}
catch (e) {
return null;
}
}
function hasSave() {
try {
return !!localStorage.getItem(SAVE_KEY);
}
catch (e) {
return false;
}
}
function clearSave() {
try {
localStorage.removeItem(SAVE_KEY);
}
catch (e) { /* ignore */ }
}
};
// ── module: src/engine/combat.ts ──
__mods["src/engine/combat.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.computeDamage = computeDamage;
exports.pickEnemyAbility = pickEnemyAbility;
exports.executeMove = executeMove;
exports.tickStatus = tickStatus;
exports.speedOrder = speedOrder;
exports.calcExpAward = calcExpAward;
exports.captureChance = captureChance;
const creatureInstance_1 = require("./creatureInstance");
const abilities_1 = require("../data/abilities");
const types_1 = require("../data/types");
const rng_1 = require("./rng");
// Core damage formula per spec: (0.4 * Level * ATK/DEF * Multiplier) + small random, floored.
function computeDamage(attacker, defender, ability) {
if (ability.power <= 0)
return { dmg: 0, crit: false, mult: 1 };
const atkStat = (0, creatureInstance_1.effAtk)(attacker);
const defStat = Math.max(1, (0, creatureInstance_1.effDef)(defender));
const atkThreads = (0, creatureInstance_1.threads)(attacker);
const stab = atkThreads.includes(ability.thread) ? 1.25 : 1;
const typeMult = (0, types_1.effectivenessMultiplier)(ability.thread, (0, creatureInstance_1.threads)(defender));
let critChance = 0.0625;
if (attacker.status && attacker._critBoost)
critChance = 0.5;
const crit = (0, rng_1.chance)(critChance);
const critMult = crit ? 1.5 : 1;
const rand = 0.85 + Math.random() * 0.15;
const powerFactor = ability.power / 40; // normalize around baseline 40 power
const raw = 0.4 * attacker.level * (atkStat / defStat) * powerFactor * stab * typeMult * critMult * rand;
const dmg = Math.max(ability.power > 0 ? 1 : 0, Math.floor(raw));
return { dmg, crit, mult: typeMult };
}
function pickEnemyAbility(enemy) {
const usable = enemy.moves.map(abilities_1.getAbility);
return usable[(0, rng_1.rndInt)(0, usable.length - 1)];
}
// Executes one ability use. Mutates attacker/defender HP & status. Returns log entries.
function executeMove(attacker, defender, abilityId, attackerLabel) {
const ability = (0, abilities_1.getAbility)(abilityId);
const log = [];
const defLabel = attackerLabel === 'player' ? 'enemy' : 'player';
const aName = attackerLabel; // used only for target routing
log.push({ text: `${attackerLabel === 'player' ? 'Your' : 'The foe\'s'} creature uses ${ability.name}!`, kind: 'info' });
if (attacker.status.shock && (0, rng_1.chance)(0.25)) {
log.push({ text: `It's Shocked and seizes up — unable to move!`, kind: 'status', target: attackerLabel });
return { log, attackerFainted: (0, creatureInstance_1.isFainted)(attacker), defenderFainted: (0, creatureInstance_1.isFainted)(defender) };
}
if (!(0, rng_1.chance)(ability.accuracy)) {
log.push({ text: `The move missed!`, kind: 'info' });
return { log, attackerFainted: (0, creatureInstance_1.isFainted)(attacker), defenderFainted: (0, creatureInstance_1.isFainted)(defender) };
}
// Barrier check on defender for damaging moves
if (ability.power > 0 && defender.status.barrier) {
defender.status.barrier = false;
log.push({ text: `The barrier deflects the blow completely!`, kind: 'status', target: defLabel });
}
else if (ability.power > 0) {
const hits = ability.hits ? (0, rng_1.rndInt)(ability.hits[0], ability.hits[1]) : 1;
let total = 0;
let anyCrit = false;
let mult = 1;
for (let i = 0; i < hits; i++) {
if ((0, creatureInstance_1.isFainted)(defender))
break;
const r = computeDamage(attacker, defender, ability);
defender.curHp = Math.max(0, defender.curHp - r.dmg);
total += r.dmg;
anyCrit = anyCrit || r.crit;
mult = r.mult;
}
const hitText = hits > 1 ? ` It hit ${hits} times for ${total} total damage!` : ` It dealt ${total} damage!`;
log.push({ text: hitText.trim(), kind: 'damage', target: defLabel, damageAmount: total, shake: total > 0 });
if (anyCrit)
log.push({ text: 'A critical strike!', kind: 'crit', flash: 'crit', target: defLabel });
const label = (0, types_1.effectivenessLabel)(mult);
if (label)
log.push({ text: label, kind: mult >= 1.2 ? 'effective' : 'info', flash: mult >= 1.75 ? 'super' : undefined, particleBurst: mult >= 1.2, target: defLabel });
if ((0, creatureInstance_1.isFainted)(defender))
log.push({ text: `${defLabel === 'player' ? 'Your' : 'The foe\'s'} creature is bound out of the fight!`, kind: 'faint', target: defLabel });
// recoil / drain tied to damage dealt
if (ability.effect && total > 0) {
applySecondaryDamageEffect(ability, attacker, defender, total, attackerLabel, log);
}
}
// status/utility effect (non damage-dependent), also applies for status moves
if (ability.effect && ability.power === 0) {
applyEffect(ability, attacker, defender, attackerLabel, defLabel, log);
}
else if (ability.effect && ability.power > 0 && !['drain', 'recoil'].includes(ability.effect.kind)) {
applyEffect(ability, attacker, defender, attackerLabel, defLabel, log);
}
return { log, attackerFainted: (0, creatureInstance_1.isFainted)(attacker), defenderFainted: (0, creatureInstance_1.isFainted)(defender) };
}
function applySecondaryDamageEffect(ability, attacker, defender, dealt, attackerLabel, log) {
const eff = ability.effect;
if (eff.kind === 'drain' && (0, rng_1.chance)(eff.chance)) {
const heal = Math.max(1, Math.floor(dealt * (eff.amount ?? 0.5)));
attacker.curHp = Math.min((0, creatureInstance_1.maxHp)(attacker), attacker.curHp + heal);
log.push({ text: `${attackerLabel === 'player' ? 'Your' : 'The foe\'s'} creature drains ${heal} HP!`, kind: 'heal', target: attackerLabel });
}
if (eff.kind === 'recoil' && (0, rng_1.chance)(eff.chance)) {
const rec = Math.max(1, Math.floor(dealt * (eff.amount ?? 0.1)));
attacker.curHp = Math.max(0, attacker.curHp - rec);
log.push({ text: `The backlash costs ${rec} HP!`, kind: 'damage', target: attackerLabel, damageAmount: rec });
}
}
function applyEffect(ability, attacker, defender, attackerLabel, defLabel, log) {
const eff = ability.effect;
if (!(0, rng_1.chance)(eff.chance))
return;
const targetIsSelf = eff.target === 'self';
const target = targetIsSelf ? attacker : defender;
const targetLabel = targetIsSelf ? attackerLabel : defLabel;
switch (eff.kind) {
case 'burn':
if (!target.status.burn && !(0, creatureInstance_1.threads)(target).includes('Pyre')) {
target.status.burn = true;
log.push({ text: `${who(targetLabel)} is set ablaze with Burn!`, kind: 'status', target: targetLabel });
}
break;
case 'frost':
if (!target.status.frost) {
target.status.frost = true;
log.push({ text: `${who(targetLabel)} is chilled with Frost!`, kind: 'status', target: targetLabel });
}
break;
case 'shock':
if (!target.status.shock) {
target.status.shock = true;
log.push({ text: `${who(targetLabel)} is jolted with Shock!`, kind: 'status', target: targetLabel });
}
break;
case 'barrier':
target.status.barrier = true;
log.push({ text: `${who(targetLabel)} raises a protective barrier!`, kind: 'status', target: targetLabel });
break;
case 'regen':
target.status.regen = true;
log.push({ text: `${who(targetLabel)} is wreathed in slow-mending energy!`, kind: 'status', target: targetLabel });
break;
case 'heal': {
const amt = Math.floor((0, creatureInstance_1.maxHp)(target) * (eff.amount ?? 0.5));
target.curHp = Math.min((0, creatureInstance_1.maxHp)(target), target.curHp + amt);
log.push({ text: `${who(targetLabel)} recovers ${amt} HP!`, kind: 'heal', target: targetLabel });
break;
}
case 'cleanse':
target.status = {};
log.push({ text: `${who(targetLabel)}'s ailments are cleansed!`, kind: 'status', target: targetLabel });
break;
case 'speedUp':
stageMod(target, 'spd', eff.amount ?? 1);
log.push({ text: `${who(targetLabel)}'s SPD rises!`, kind: 'status', target: targetLabel });
break;
case 'speedDown':
stageMod(target, 'spd', -(eff.amount ?? 1));
log.push({ text: `${who(targetLabel)}'s SPD falls!`, kind: 'status', target: targetLabel });
break;
case 'atkUp':
stageMod(target, 'atk', eff.amount ?? 1);
log.push({ text: `${who(targetLabel)}'s ATK rises!`, kind: 'status', target: targetLabel });
break;
case 'atkDown':
stageMod(target, 'atk', -(eff.amount ?? 1));
log.push({ text: `${who(targetLabel)}'s ATK falls!`, kind: 'status', target: targetLabel });
break;
case 'defUp':
stageMod(target, 'def', eff.amount ?? 1);
log.push({ text: `${who(targetLabel)}'s DEF rises!`, kind: 'status', target: targetLabel });
break;
case 'defDown':
stageMod(target, 'def', -(eff.amount ?? 1));
log.push({ text: `${who(targetLabel)}'s DEF falls!`, kind: 'status', target: targetLabel });
break;
case 'critUp':
attacker._critBoost = true;
log.push({ text: `${who(targetLabel)} focuses for a decisive blow!`, kind: 'status', target: targetLabel });
break;
case 'flinch':
log.push({ text: `${who(targetLabel)} flinches!`, kind: 'status', target: targetLabel });
break;
}
}
function who(label) { return label === 'player' ? 'Your creature' : "The foe's creature"; }
function stageMod(ci, key, amount) {
ci.stages[key] = Math.max(-4, Math.min(4, ci.stages[key] + amount));
}
// End-of-turn status ticking (burn damage, regen heal). Returns log entries.
function tickStatus(ci, label) {
const log = [];
if ((0, creatureInstance_1.isFainted)(ci))
return log;
if (ci.status.burn) {
const dmg = Math.max(1, Math.floor((0, creatureInstance_1.maxHp)(ci) * 0.08));
ci.curHp = Math.max(0, ci.curHp - dmg);
log.push({ text: `${who(label)} is hurt by Burn! (${dmg} dmg)`, kind: 'damage', target: label, damageAmount: dmg });
if ((0, creatureInstance_1.isFainted)(ci))
log.push({ text: `${who(label)} is bound out of the fight!`, kind: 'faint', target: label });
}
if (ci.status.regen && !(0, creatureInstance_1.isFainted)(ci)) {
const heal = Math.max(1, Math.floor((0, creatureInstance_1.maxHp)(ci) * 0.12));
ci.curHp = Math.min((0, creatureInstance_1.maxHp)(ci), ci.curHp + heal);
log.push({ text: `${who(label)} mends a little with Regen. (+${heal})`, kind: 'heal', target: label });
}
return log;
}
function speedOrder(a, aAbility, b, bAbility) {
const pa = aAbility?.priority ?? 0;
const pb = bAbility?.priority ?? 0;
if (pa !== pb)
return pa > pb ? 'a' : 'b';
const sa = (0, creatureInstance_1.effSpd)(a), sb = (0, creatureInstance_1.effSpd)(b);
if (sa === sb)
return (0, rng_1.chance)(0.5) ? 'a' : 'b';
return sa > sb ? 'a' : 'b';
}
// EXP awarded for defeating a foe of given level/species relative strength.
function calcExpAward(defeatedLevel, defeatedBase, winnerLevel) {
const base = 8 + defeatedLevel * 3 + defeatedBase * 0.15;
const relative = Math.max(0.5, Math.min(2, defeatedLevel / Math.max(1, winnerLevel)));
return Math.max(4, Math.round(base * relative));
}
// Capture success chance per spec: scales with target remaining HP fraction, item quality tier, and a skill factor 0-1.
function captureChance(target, itemTier, skillFactor) {
const hpFrac = target.curHp / (0, creatureInstance_1.maxHp)(target);
const hpFactor = 1 - hpFrac * 0.7; // lower HP => higher chance
const tierFactor = itemTier === 1 ? 0.35 : itemTier === 2 ? 0.6 : 0.85;
const rarityPenalty = { 1: 1, 2: 0.85, 3: 0.65 }[(0, creatureInstance_1.species)(target).captureTier];
const statusBonus = target.status.frost || target.status.shock || target.status.burn ? 1.15 : 1;
const raw = (hpFactor * 0.55 + tierFactor * 0.35) * rarityPenalty * statusBonus * (0.4 + 0.6 * skillFactor);
return Math.max(0.03, Math.min(0.98, raw));
}
};
// ── module: src/engine/audio.ts ──
__mods["src/engine/audio.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.setMuted = setMuted;
exports.isMuted = isMuted;
exports.playBlip = playBlip;
exports.sfx = sfx;
exports.startAmbient = startAmbient;
exports.stopAmbient = stopAmbient;
exports.unlockAudio = unlockAudio;
// Lightweight generative chiptune/ambient audio via Web Audio API. No external assets.
let ctx = null;
let masterGain = null;
let muted = false;
let ambientTimer = null;
let currentMood = null;
function ensureCtx() {
if (!ctx) {
ctx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = ctx.createGain();
masterGain.gain.value = 0.28;
masterGain.connect(ctx.destination);
}
return ctx;
}
function setMuted(v) {
muted = v;
if (masterGain)
masterGain.gain.value = v ? 0 : 0.28;
}
function isMuted() { return muted; }
function playBlip(freq, dur = 0.09, type = 'square', vol = 0.18) {
try {
const c = ensureCtx();
const osc = c.createOscillator();
const g = c.createGain();
osc.type = type;
osc.frequency.value = freq;
g.gain.value = vol;
g.gain.exponentialRampToValueAtTime(0.001, c.currentTime + dur);
osc.connect(g);
g.connect(masterGain);
osc.start();
osc.stop(c.currentTime + dur + 0.02);
}
catch (e) { /* audio unavailable, ignore */ }
}
function sfx(kind) {
switch (kind) {
case 'step':
playBlip(220, 0.04, 'square', 0.05);
break;
case 'menu':
playBlip(520, 0.06, 'square', 0.12);
break;
case 'confirm':
playBlip(660, 0.08, 'square', 0.16);
break;
case 'cancel':
playBlip(200, 0.08, 'sawtooth', 0.12);
break;
case 'hit':
playBlip(120, 0.1, 'square', 0.2);
break;
case 'crit':
playBlip(880, 0.12, 'sawtooth', 0.22);
break;
case 'super':
playBlip(980, 0.15, 'sawtooth', 0.24);
break;
case 'heal':
playBlip(740, 0.12, 'sine', 0.18);
break;
case 'faint':
playBlip(110, 0.3, 'sawtooth', 0.2);
break;
case 'capture_tick':
playBlip(440, 0.05, 'square', 0.15);
break;
case 'capture_success': {
playBlip(523, 0.1);
setTimeout(() => playBlip(659, 0.12), 100);
setTimeout(() => playBlip(784, 0.18), 220);
break;
}
case 'capture_fail':
playBlip(150, 0.25, 'sawtooth', 0.2);
break;
case 'levelup': {
playBlip(523, 0.08);
setTimeout(() => playBlip(659, 0.08), 90);
setTimeout(() => playBlip(880, 0.14), 180);
break;
}
case 'transform': {
playBlip(300, 0.15, 'sawtooth', 0.2);
setTimeout(() => playBlip(600, 0.15, 'sawtooth', 0.2), 150);
setTimeout(() => playBlip(1000, 0.2, 'sine', 0.22), 320);
break;
}
case 'victory': {
[523, 659, 784, 1046].forEach((f, i) => setTimeout(() => playBlip(f, 0.16, 'triangle', 0.2), i * 130));
break;
}
}
}
// Generative ambient pad loop; mood selects a scale/register.
function startAmbient(mood) {
if (currentMood === mood && ambientTimer !== null)
return;
stopAmbient();
currentMood = mood;
const scales = {
title: [220, 262, 294, 330, 392],
overworld: [196, 220, 247, 262, 294, 330],
battle: [174, 196, 233, 261, 293],
};
const scale = scales[mood];
const loop = () => {
if (muted)
return;
try {
const c = ensureCtx();
const freq = scale[Math.floor(Math.random() * scale.length)];
const osc = c.createOscillator();
const g = c.createGain();
osc.type = mood === 'battle' ? 'triangle' : 'sine';
osc.frequency.value = freq / 2;
g.gain.value = 0;
const now = c.currentTime;
const dur = mood === 'battle' ? 1.4 : 3.2;
g.gain.linearRampToValueAtTime(0.05, now + dur * 0.3);
g.gain.linearRampToValueAtTime(0, now + dur);
osc.connect(g);
g.connect(masterGain);
osc.start(now);
osc.stop(now + dur + 0.1);
}
catch (e) { /* ignore */ }
};
loop();
ambientTimer = window.setInterval(loop, mood === 'battle' ? 1500 : 2600);
}
function stopAmbient() {
if (ambientTimer !== null) {
window.clearInterval(ambientTimer);
ambientTimer = null;
}
currentMood = null;
}
function unlockAudio() {
try {
ensureCtx();
if (ctx && ctx.state === 'suspended')
ctx.resume();
}
catch (e) { /* ignore */ }
}
};
// ── module: src/ui/toast.ts ──
__mods["src/ui/toast.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.showToast = showToast;
function showToast(msg) {
const root = document.getElementById('toast-root');
if (!root)
return;
const el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
root.appendChild(el);
setTimeout(() => el.remove(), 3100);
}
};
// ── module: src/ui/story.ts ──
__mods["src/ui/story.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.showStory = showStory;
function showStory(beat, onDone) {
const overlay = document.getElementById('story-overlay');
if (!overlay) {
onDone();
return;
}
overlay.classList.remove('hidden');
overlay.style.setProperty('--vg-color', beat.vignette);
let idx = 0;
render();
function render() {
overlay.innerHTML = `
<div class="story-vignette" style="--vg-color:${beat.vignette}"></div>
<div class="story-box">
<h3>${beat.title}</h3>
<p>${beat.lines[idx]}</p>
<button class="story-next">${idx < beat.lines.length - 1 ? 'Continue' : 'Close'}</button>
</div>`;
overlay.querySelector('.story-next').addEventListener('click', () => {
idx++;
if (idx >= beat.lines.length) {
overlay.classList.add('hidden');
overlay.innerHTML = '';
onDone();
}
else
render();
});
}
}
};
// ── module: src/engine/particles.ts ──
__mods["src/engine/particles.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ParticleField = void 0;
exports.spawnAmbient = spawnAmbient;
exports.stepParticle = stepParticle;
exports.burst = burst;
function spawnAmbient(kind, w, h) {
const base = { x: Math.random() * w, y: Math.random() * h, vx: 0, vy: 0, life: 1, maxLife: 1, size: 2, color: '#fff' };
switch (kind) {
case 'petal': return { ...base, y: -10, vx: (Math.random() - 0.5) * 8, vy: 10 + Math.random() * 14, size: 3 + Math.random() * 2, color: '#bfe6a0', life: 6, maxLife: 6 };
case 'ember': return { ...base, y: h + 10, vx: (Math.random() - 0.5) * 6, vy: -(14 + Math.random() * 18), size: 2 + Math.random() * 2, color: '#ff9d5c', life: 4, maxLife: 4 };
case 'bubble': return { ...base, y: h + 10, vx: (Math.random() - 0.5) * 4, vy: -(8 + Math.random() * 10), size: 2 + Math.random() * 3, color: '#8fd7ff', life: 7, maxLife: 7 };
case 'leaf': return { ...base, y: -10, vx: (Math.random() - 0.5) * 10, vy: 8 + Math.random() * 10, size: 3, color: '#8fce6a', life: 6, maxLife: 6 };
case 'ash': return { ...base, y: -10, vx: (Math.random() - 0.5) * 4, vy: 6 + Math.random() * 8, size: 1.5 + Math.random() * 1.5, color: '#c9a6e0', life: 8, maxLife: 8 };
case 'spark': return { ...base, vx: (Math.random() - 0.5) * 20, vy: (Math.random() - 0.5) * 20, size: 1.5 + Math.random() * 1.5, color: '#ffe17a', life: 1.2, maxLife: 1.2 };
default: return { ...base, life: 3, maxLife: 3 };
}
}
function stepParticle(p, dt) {
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
}
class ParticleField {
constructor(kind, w, h) {
this.particles = [];
this.kind = kind;
this.w = w;
this.h = h;
}
setSize(w, h) { this.w = w; this.h = h; }
setKind(kind) { this.kind = kind; this.particles = []; }
update(dt) {
if (this.particles.length < 34 && Math.random() < 0.5)
this.particles.push(spawnAmbient(this.kind, this.w, this.h));
for (const p of this.particles)
stepParticle(p, dt);
this.particles = this.particles.filter(p => p.life > 0 && p.y > -20 && p.y < this.h + 20);
}
draw(ctx) {
for (const p of this.particles) {
const alpha = Math.max(0, Math.min(1, p.life / p.maxLife));
ctx.globalAlpha = alpha * 0.8;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
}
exports.ParticleField = ParticleField;
// Transient burst effect (e.g. super-effective hit) — quick radial spray.
function burst(x, y, color, count = 14) {
const arr = [];
for (let i = 0; i < count; i++) {
const ang = Math.random() * Math.PI * 2;
const spd = 30 + Math.random() * 60;
arr.push({ x, y, vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd, life: 0.6, maxLife: 0.6, size: 2 + Math.random() * 2, color });
}
return arr;
}
};
// ── module: src/engine/overworld.ts ──
__mods["src/engine/overworld.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.TILE = void 0;
exports.zoneNodes = zoneNodes;
exports.createView = createView;
exports.drawOverworld = drawOverworld;
exports.triggerShake = triggerShake;
exports.canWalk = canWalk;
exports.nodeAt = nodeAt;
const zones_1 = require("../data/zones");
const particles_1 = require("./particles");
exports.TILE = 48;
function zoneNodes(zone) {
const nodes = [
{ kind: 'shrine', x: zone.shrine.x, y: zone.shrine.y },
{ kind: 'story', x: zone.storyPos.x, y: zone.storyPos.y },
];
if (!zone.unlockRequires || true)
nodes.push({ kind: 'guardian', x: zone.guardianPos.x, y: zone.guardianPos.y });
nodes.push({ kind: 'exit', x: zone.exit.x, y: zone.exit.y });
return nodes;
}
function createView(canvas) {
const ctx = canvas.getContext('2d');
return { canvas, ctx, field: new particles_1.ParticleField('petal', canvas.width, canvas.height), animT: 0, walkFrame: 0, shakeT: 0 };
}
function tileColor(zone, x, y) {
// deterministic pseudo-noise checker for subtle tile variance
const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
const f = n - Math.floor(n);
return f > 0.82 ? 'rgba(255,255,255,0.04)' : 'transparent';
}
function drawOverworld(view, state, dt) {
const zone = (0, zones_1.getZone)(state.pos.zoneId);
const { ctx, canvas } = view;
view.animT += dt;
view.field.setSize(canvas.width, canvas.height);
if (view.field.kind !== zone.particle)
view.field.setKind(zone.particle);
view.field.update(dt);
// camera centers on player, clamped to map bounds
const viewTilesX = Math.floor(canvas.width / exports.TILE);
const viewTilesY = Math.floor(canvas.height / exports.TILE);
let camX = state.pos.x - Math.floor(viewTilesX / 2);
let camY = state.pos.y - Math.floor(viewTilesY / 2);
camX = Math.max(0, Math.min(zone.width - viewTilesX, camX));
camY = Math.max(0, Math.min(zone.height - viewTilesY, camY));
if (zone.width <= viewTilesX)
camX = -(Math.floor((viewTilesX - zone.width) / 2));
if (zone.height <= viewTilesY)
camY = -(Math.floor((viewTilesY - zone.height) / 2));
let shakeX = 0, shakeY = 0;
if (view.shakeT > 0) {
view.shakeT -= dt;
shakeX = (Math.random() - 0.5) * 8;
shakeY = (Math.random() - 0.5) * 8;
}
ctx.save();
ctx.translate(shakeX, shakeY);
// background gradient
const grad = ctx.createLinearGradient(0, 0, 0, canvas.height);
grad.addColorStop(0, zone.gradient[0]);
grad.addColorStop(1, zone.gradient[1]);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// tiles
for (let ty = 0; ty < viewTilesY + 1; ty++) {
for (let tx = 0; tx < viewTilesX + 1; tx++) {
const wx = camX + tx, wy = camY + ty;
if (wx < 0 || wy < 0 || wx >= zone.width || wy >= zone.height)
continue;
const px = tx * exports.TILE, py = ty * exports.TILE;
ctx.fillStyle = tileColor(zone, wx, wy);
ctx.fillRect(px, py, exports.TILE, exports.TILE);
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
ctx.strokeRect(px, py, exports.TILE, exports.TILE);
}
}
// nodes
for (const node of zoneNodes(zone)) {
const px = (node.x - camX) * exports.TILE + exports.TILE / 2;
const py = (node.y - camY) * exports.TILE + exports.TILE / 2;
if (px < -exports.TILE || py < -exports.TILE || px > canvas.width + exports.TILE || py > canvas.height + exports.TILE)
continue;
drawNode(ctx, node, px, py, view.animT);
}
// particles (behind player slightly, ambient)
view.field.draw(ctx);
// player sprite
const ppx = (state.pos.x - camX) * exports.TILE + exports.TILE / 2;
const ppy = (state.pos.y - camY) * exports.TILE + exports.TILE / 2;
drawPlayer(ctx, ppx, ppy, state.pos.facing, view.animT);
ctx.restore();
}
function drawNode(ctx, node, x, y, t) {
const bob = Math.sin(t * 2 + x * 0.05) * 4;
ctx.save();
ctx.translate(x, y + bob);
if (node.kind === 'shrine') {
ctx.fillStyle = 'rgba(255,212,121,0.15)';
ctx.beginPath();
ctx.arc(0, 8, 20, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#d8c48a';
ctx.fillRect(-10, -6, 20, 22);
ctx.fillStyle = '#ffd479';
ctx.beginPath();
ctx.arc(0, -14, 7, 0, Math.PI * 2);
ctx.fill();
ctx.shadowColor = '#ffd479';
ctx.shadowBlur = 14;
ctx.fill();
}
else if (node.kind === 'guardian') {
ctx.fillStyle = 'rgba(255,93,108,0.18)';
ctx.beginPath();
ctx.arc(0, 8, 24, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#7a2530';
ctx.beginPath();
ctx.moveTo(0, -22);
ctx.lineTo(16, 16);
ctx.lineTo(-16, 16);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#ffb0b0';
ctx.beginPath();
ctx.arc(-5, 0, 3, 0, Math.PI * 2);
ctx.arc(5, 0, 3, 0, Math.PI * 2);
ctx.fill();
}
else if (node.kind === 'story') {
ctx.fillStyle = 'rgba(127,214,255,0.18)';
ctx.beginPath();
ctx.arc(0, 4, 18, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#7fd6ff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 4, 10, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = '#7fd6ff';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('!', 0, 5);
}
else if (node.kind === 'exit') {
ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.beginPath();
ctx.arc(0, 4, 20, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(-8, -8);
ctx.lineTo(8, 4);
ctx.lineTo(-8, 16);
ctx.stroke();
}
ctx.restore();
}
function drawPlayer(ctx, x, y, facing, t) {
const bob = Math.sin(t * 8) * 2;
ctx.save();
ctx.translate(x, y + bob);
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.beginPath();
ctx.ellipse(0, 16, 12, 5, 0, 0, Math.PI * 2);
ctx.fill();
// cloak body
ctx.fillStyle = '#2b2f4a';
ctx.beginPath();
ctx.moveTo(-11, 14);
ctx.quadraticCurveTo(-13, -6, 0, -10);
ctx.quadraticCurveTo(13, -6, 11, 14);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#ffd479';
ctx.beginPath();
ctx.arc(0, -14, 8, 0, Math.PI * 2);
ctx.fill();
// facing indicator (small glyph offset)
ctx.fillStyle = '#7fd6ff';
let dx = 0, dy = 0;
if (facing === 'up')
dy = -20;
else if (facing === 'down')
dy = -8;
else if (facing === 'left')
dx = -12;
else
dx = 12;
ctx.beginPath();
ctx.arc(dx * 0.6, -14 + dy * 0.3, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function triggerShake(view) { view.shakeT = 0.3; }
function canWalk(zone, x, y) {
return x >= 0 && y >= 0 && x < zone.width && y < zone.height;
}
function nodeAt(zone, x, y) {
return zoneNodes(zone).find(n => n.x === x && n.y === y) ?? null;
}
};
// ── module: src/engine/sprites.ts ──
__mods["src/engine/sprites.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.drawCreature = drawCreature;
exports.glyphIcon = glyphIcon;
const types_1 = require("../data/types");
function hashStr(s) {
let h = 0;
for (let i = 0; i < s.length; i++)
h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h;
}
// Procedurally draws a distinct creature glyph for a species, no image assets.
// variant chosen from id hash: 0 blob, 1 quadruped, 2 avian, 3 serpentine, 4 crystalline, 5 insectoid
function drawCreature(ctx, sp, x, y, scale, t, flip = false) {
const h = hashStr(sp.id);
const variant = h % 6;
const c1 = types_1.THREAD_COLOR[sp.threads[0]];
const c2 = types_1.THREAD_COLOR[sp.threads[1] ?? sp.threads[0]];
const bob = Math.sin(t * 2.4 + h % 10) * 3 * scale;
ctx.save();
ctx.translate(x, y + bob);
if (flip)
ctx.scale(-1, 1);
ctx.scale(scale, scale);
// shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(0, 46, 30, 8, 0, 0, Math.PI * 2);
ctx.fill();
const grad = ctx.createLinearGradient(0, -40, 0, 40);
grad.addColorStop(0, lighten(c1, 25));
grad.addColorStop(1, c2);
switch (variant) {
case 0: { // blob
ctx.fillStyle = grad;
ctx.beginPath();
ctx.ellipse(0, 0, 32, 30 + Math.sin(t * 3) * 2, 0, 0, Math.PI * 2);
ctx.fill();
drawEyes(ctx, -10, -4, 10, -4, c2);
drawSpikes(ctx, h);
break;
}
case 1: { // quadruped
ctx.fillStyle = grad;
roundRect(ctx, -26, -14, 52, 36, 16);
ctx.fill();
ctx.fillRect(-20, 18, 10, 16);
ctx.fillRect(10, 18, 10, 16);
ctx.beginPath();
ctx.ellipse(20, -22, 16, 14, 0, 0, Math.PI * 2);
ctx.fill();
drawEyes(ctx, 24, -24, 30, -24, c2);
break;
}
case 2: { // avian
ctx.fillStyle = grad;
ctx.beginPath();
ctx.ellipse(0, 0, 20, 28, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.moveTo(-18, -6);
ctx.quadraticCurveTo(-46, -20 + Math.sin(t * 5) * 6, -20, 8);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(18, -6);
ctx.quadraticCurveTo(46, -20 + Math.cos(t * 5) * 6, 20, 8);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.ellipse(0, -26, 12, 12, 0, 0, Math.PI * 2);
ctx.fill();
drawEyes(ctx, -4, -28, 4, -28, c2);
ctx.fillStyle = '#ffd479';
ctx.beginPath();
ctx.moveTo(0, -20);
ctx.lineTo(8, -14);
ctx.lineTo(0, -12);
ctx.closePath();
ctx.fill();
break;
}
case 3: { // serpentine
ctx.strokeStyle = c1;
ctx.lineWidth = 16;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(-24, 20);
ctx.quadraticCurveTo(-10 + Math.sin(t * 3) * 8, -10, 0, -20);
ctx.quadraticCurveTo(14, -28, 22, -10);
ctx.stroke();
ctx.fillStyle = grad;
ctx.beginPath();
ctx.ellipse(22, -12, 12, 11, 0, 0, Math.PI * 2);
ctx.fill();
drawEyes(ctx, 26, -14, 30, -10, c2);
break;
}
case 4: { // crystalline
ctx.fillStyle = grad;
ctx.beginPath();
ctx.moveTo(0, -34);
ctx.lineTo(22, -6);
ctx.lineTo(14, 30);
ctx.lineTo(-14, 30);
ctx.lineTo(-22, -6);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 2;
ctx.stroke();
drawEyes(ctx, -8, -4, 8, -4, c2);
break;
}
default: { // insectoid
ctx.fillStyle = grad;
ctx.beginPath();
ctx.ellipse(0, 4, 24, 20, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(0, -22, 14, 13, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = c1;
ctx.lineWidth = 5;
for (const dx of [-16, 16]) {
ctx.beginPath();
ctx.moveTo(dx * 0.6, 10);
ctx.lineTo(dx * 1.8, 24 + Math.sin(t * 6) * 4);
ctx.stroke();
}
drawEyes(ctx, -5, -24, 5, -24, c2);
break;
}
}
ctx.restore();
function drawSpikes(cx, hh) {
if (hh % 3 !== 0)
return;
cx.fillStyle = c1;
for (let i = -2; i <= 2; i++) {
cx.beginPath();
cx.moveTo(i * 10, -26);
cx.lineTo(i * 10 - 4, -18);
cx.lineTo(i * 10 + 4, -18);
cx.closePath();
cx.fill();
}
}
}
function drawEyes(ctx, x1, y1, x2, y2, color) {
ctx.fillStyle = '#0c0c14';
ctx.beginPath();
ctx.arc(x1, y1, 4, 0, Math.PI * 2);
ctx.arc(x2, y2, 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(x1 + 1.2, y1 - 1.2, 1.3, 0, Math.PI * 2);
ctx.arc(x2 + 1.2, y2 - 1.2, 1.3, 0, Math.PI * 2);
ctx.fill();
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function lighten(hex, amt) {
const c = hex.replace('#', '');
const num = parseInt(c, 16);
let r = (num >> 16) + amt, g = ((num >> 8) & 0xff) + amt, b = (num & 0xff) + amt;
r = Math.min(255, r);
g = Math.min(255, g);
b = Math.min(255, b);
return `rgb(${r},${g},${b})`;
}
function glyphIcon(sp) {
const h = hashStr(sp.id) % 6;
return ['\u25CF', '\u25B2', '\u2601', '\u3030', '\u25C6', '\u2726'][h];
}
};
// ── module: src/ui/modal.ts ──
__mods["src/ui/modal.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.openModal = openModal;
exports.closeModal = closeModal;
exports.isModalOpen = isModalOpen;
function openModal(innerHtml) {
const root = document.getElementById('modal-root');
root.innerHTML = `<div class="modal-panel">${innerHtml}</div>`;
root.classList.remove('hidden');
return root.querySelector('.modal-panel');
}
function closeModal() {
const root = document.getElementById('modal-root');
root.classList.add('hidden');
root.innerHTML = '';
}
function isModalOpen() {
const root = document.getElementById('modal-root');
return !root.classList.contains('hidden');
}
};
// ── module: src/ui/helpers.ts ──
__mods["src/ui/helpers.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.iconHtml = iconHtml;
exports.hpBarHtml = hpBarHtml;
exports.threadTagHtml = threadTagHtml;
exports.esc = esc;
const creatures_1 = require("../data/creatures");
const types_1 = require("../data/types");
const sprites_1 = require("../engine/sprites");
const creatureInstance_1 = require("../engine/creatureInstance");
function iconHtml(speciesId, size = 44) {
const sp = (0, creatures_1.getSpecies)(speciesId);
const color = types_1.THREAD_COLOR[sp.threads[0]];
const glyph = (0, sprites_1.glyphIcon)(sp);
return `<div class="tr-icon" style="width:${size}px;height:${size}px;background:radial-gradient(circle at 35% 30%, ${lighten(color)}, ${color});color:#111;font-weight:700;">${glyph}</div>`;
}
function lighten(hex) {
const c = hex.replace('#', '');
const num = parseInt(c, 16);
let r = Math.min(255, (num >> 16) + 60), g = Math.min(255, ((num >> 8) & 0xff) + 60), b = Math.min(255, (num & 0xff) + 60);
return `rgb(${r},${g},${b})`;
}
function hpBarHtml(ci) {
const max = (0, creatureInstance_1.maxHp)(ci);
const pct = Math.max(0, Math.min(100, (ci.curHp / max) * 100));
const cls = pct <= 25 ? 'low' : pct <= 55 ? 'mid' : '';
return `<div class="hpbar-outer"><div class="hpbar-inner ${cls}" style="width:${pct}%"></div></div>
<div style="font-size:0.72rem;color:var(--text-dim);margin-top:1px;">${ci.curHp}/${max} HP</div>`;
}
function threadTagHtml(threads) {
return threads.map(t => `<span style="color:${types_1.THREAD_COLOR[t]}">${types_1.THREAD_GLYPH[t]} ${t}</span>`).join(' / ');
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
};
// ── module: src/ui/menus.ts ──
__mods["src/ui/menus.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.openTeamMenu = openTeamMenu;
exports.openCreatureDetail = openCreatureDetail;
exports.openBagMenu = openBagMenu;
exports.openEncyclopediaMenu = openEncyclopediaMenu;
exports.openMapMenu = openMapMenu;
exports.openPauseMenu = openPauseMenu;
const state_1 = require("../engine/state");
const creatures_1 = require("../data/creatures");
const items_1 = require("../data/items");
const abilities_1 = require("../data/abilities");
const modal_1 = require("./modal");
const helpers_1 = require("./helpers");
const creatureInstance_1 = require("../engine/creatureInstance");
const zones_1 = require("../data/zones");
const toast_1 = require("./toast");
const audio_1 = require("../engine/audio");
function openTeamMenu(state, opts) {
const rows = state.party.map((ci, i) => teamRowHtml(ci, i)).join('') || '<p style="color:var(--text-dim)">No creatures bound yet.</p>';
const panel = (0, modal_1.openModal)(`
<h2>Team <button class="modal-close">×</button></h2>
<div id="team-rows">${rows}</div>
`);
panel.querySelector('.modal-close').addEventListener('click', modal_1.closeModal);
state.party.forEach((ci, i) => {
panel.querySelector(`[data-team-idx="${i}"]`).addEventListener('click', () => {
if (opts?.pickMode) {
opts.onPick?.(i);
return;
}
openCreatureDetail(state, ci, i);
});
});
}
function teamRowHtml(ci, i) {
const sp = (0, creatureInstance_1.species)(ci);
return `<div class="team-row" data-team-idx="${i}">
${(0, helpers_1.iconHtml)(ci.speciesId)}
<div class="tr-main">
<div class="tr-name">${(0, helpers_1.esc)((0, creatureInstance_1.displayName)(ci))} <span class="tr-lv">Lv.${ci.level} ${ci.curHp <= 0 ? '(fainted)' : ''}</span></div>
<div style="font-size:0.75rem;color:var(--text-dim);">${(0, helpers_1.threadTagHtml)(sp.threads)}</div>
${(0, helpers_1.hpBarHtml)(ci)}
</div>
</div>`;
}
function openCreatureDetail(state, ci, idx) {
const sp = (0, creatureInstance_1.species)(ci);
const et = (0, creatureInstance_1.expToNext)(ci);
const statRows = [
['ATK', Math.round(sp.base.atk * (0.35 + 0.65 * ci.level / 50))],
['DEF', Math.round(sp.base.def * (0.35 + 0.65 * ci.level / 50))],
['SPD', Math.round(sp.base.spd * (0.35 + 0.65 * ci.level / 50))],
];
const moveRows = ci.moves.map(m => {
const a = (0, abilities_1.getAbility)(m);
return `<div class="stat-line"><span>${a.name} <small style="color:var(--text-dim)">(${a.thread})</small></span><span>PWR ${a.power || '-'} · ${Math.round(a.accuracy * 100)}%</span></div>`;
}).join('');
const canT = (0, creatureInstance_1.canTransform)(ci);
const panel = (0, modal_1.openModal)(`
<h2>${(0, helpers_1.esc)((0, creatureInstance_1.displayName)(ci))} <button class="modal-close">×</button></h2>
<div class="detail-panel">
<div class="dp-icon" style="background:rgba(255,255,255,0.08)">${(0, helpers_1.iconHtml)(ci.speciesId, 80)}</div>
<div style="flex:1;min-width:200px;">
<div>${(0, helpers_1.threadTagHtml)(sp.threads)} · Lv.${ci.level}</div>
<div style="font-size:0.85rem;color:var(--text-dim);margin:0.3rem 0;">${(0, helpers_1.esc)(sp.lore)}</div>
${(0, helpers_1.hpBarHtml)(ci)}
<div style="font-size:0.75rem;color:var(--text-dim);margin-top:4px;">EXP ${et.cur}/${et.need}</div>
<div style="margin-top:0.5rem;">${statRows.map(([k, v]) => `<div class="stat-line"><span>${k}</span><span>${v}</span></div>`).join('')}</div>
</div>
</div>
<h3 style="margin-top:1rem;margin-bottom:0.3rem;color:var(--accent);font-size:1rem;">Combat Abilities</h3>
${moveRows}
<div style="margin-top:1rem;display:flex;gap:0.5rem;flex-wrap:wrap;">
${canT ? '<button id="btn-transform" class="menu-btn primary" style="min-width:auto;">Transform!</button>' : ''}
<button id="btn-nickname" class="menu-btn" style="min-width:auto;">Rename</button>
${state.party.length > 1 ? '<button id="btn-release" class="menu-btn" style="min-width:auto;color:var(--danger);">Release</button>' : ''}
</div>
`);
panel.querySelector('.modal-close').addEventListener('click', modal_1.closeModal);
panel.querySelector('#btn-transform')?.addEventListener('click', () => {
if ((0, creatureInstance_1.transform)(ci)) {
(0, audio_1.sfx)('transform');
(0, toast_1.showToast)(`${(0, creatureInstance_1.displayName)(ci)} has transformed!`);
openCreatureDetail(state, ci, idx);
}
});
panel.querySelector('#btn-nickname')?.addEventListener('click', () => {
const name = prompt('New nickname:', (0, creatureInstance_1.displayName)(ci));
if (name && name.trim()) {
ci.nickname = name.trim().slice(0, 16);
openCreatureDetail(state, ci, idx);
}
});
panel.querySelector('#btn-release')?.addEventListener('click', () => {
if (confirm(`Release ${(0, creatureInstance_1.displayName)(ci)} back into the wild?`)) {
state.party.splice(idx, 1);
(0, modal_1.closeModal)();
(0, toast_1.showToast)('Released.');
}
});
}
function openBagMenu(state, opts) {
const kindOrder = ['capture', 'restore', 'catalyst', 'field'];
const kindLabel = { capture: 'Binding Tools', restore: 'Restoratives', catalyst: 'Catalysts', field: 'Field Tools' };
let activeKind = 'capture';
render();
function render() {
const owned = state.inventory.filter(e => (0, state_1.itemQty)(state, e.itemId) > 0 && (0, items_1.getItem)(e.itemId).kind === activeKind);
const rows = owned.map(e => {
const item = (0, items_1.getItem)(e.itemId);
return `<div class="item-row">
<div><div class="ir-name">${(0, helpers_1.esc)(item.name)} <span style="color:var(--text-dim)">x${e.qty}</span></div><div class="ir-desc">${(0, helpers_1.esc)(item.desc)}</div></div>
${opts?.battleMode && (item.kind === 'capture' || item.kind === 'restore') ? `<button data-use="${item.id}">Use</button>` : ''}
</div>`;
}).join('') || '<p style="color:var(--text-dim)">None carried.</p>';
const panel = (0, modal_1.openModal)(`
<h2>Satchel <button class="modal-close">×</button></h2>
<div class="tab-row">${kindOrder.map(k => `<button class="tab-btn ${k === activeKind ? 'active' : ''}" data-kind="${k}">${kindLabel[k]}</button>`).join('')}</div>
<div>${rows}</div>
<div style="margin-top:0.8rem;font-size:0.85rem;color:var(--text-dim);">Gold: ${state.gold}</div>
`);
panel.querySelector('.modal-close').addEventListener('click', modal_1.closeModal);
panel.querySelectorAll('[data-kind]').forEach(b => b.addEventListener('click', () => { activeKind = b.dataset.kind; render(); }));
panel.querySelectorAll('[data-use]').forEach(b => b.addEventListener('click', () => {
const id = b.dataset.use;
opts?.onUse?.(id);
}));
}
}
function openEncyclopediaMenu(state) {
const cards = creatures_1.SPECIES.filter(s => !s.transformsFrom).map(sp => {
const seen = state.flags.encyclopedia.includes(sp.id);
const caught = state.flags.captured.includes(sp.id);
return `<div class="enc-card ${seen ? '' : 'unseen'}">
<div class="ec-icon" style="background:rgba(255,255,255,0.08)">${seen ? (0, helpers_1.iconHtml)(sp.id, 54) : '<div class="tr-icon" style="width:54px;height:54px;background:rgba(255,255,255,0.05)">?</div>'}</div>
<div style="font-weight:700;font-size:0.85rem;">${seen ? (0, helpers_1.esc)(sp.name) : '???'}</div>
<div style="font-size:0.7rem;color:var(--text-dim);">${seen ? (0, helpers_1.threadTagHtml)(sp.threads) : ''}</div>
<div style="font-size:0.7rem;margin-top:2px;color:${caught ? 'var(--good)' : 'var(--text-dim)'}">${seen ? (caught ? 'Bound' : 'Sighted') : ''}</div>
</div>`;
}).join('');
const count = state.flags.captured.length;
const panel = (0, modal_1.openModal)(`
<h2>Encyclopedia <button class="modal-close">×</button></h2>
<p style="color:var(--text-dim);font-size:0.85rem;">Bound: ${count} / 8+ distinct Threads of life</p>
<div class="enc-grid">${cards}</div>
`);
panel.querySelector('.modal-close').addEventListener('click', modal_1.closeModal);
}
function openMapMenu(state, onTravel) {
const cards = zones_1.ZONES.map(z => {
const cleared = state.flags.guardiansCleared.includes(z.id);
const locked = z.unlockRequires && !state.flags.guardiansCleared.includes(z.unlockRequires);
const current = state.pos.zoneId === z.id;
return `<div class="map-card ${locked ? 'locked' : ''}" style="background:linear-gradient(135deg, ${z.gradient[0]}, ${z.gradient[1]})">
<div class="mc-name">${(0, helpers_1.esc)(z.name)}${current ? ' \u{1F4CD}' : ''}</div>
<div class="mc-status">${locked ? 'Locked' : cleared ? 'Guardian bound' : 'Guardian active'}</div>
</div>`;
}).join('');
const panel = (0, modal_1.openModal)(`
<h2>Map of Virel <button class="modal-close">×</button></h2>
<div class="map-grid">${cards}</div>
<p style="font-size:0.78rem;color:var(--text-dim);margin-top:0.8rem;">Defeat a zone's Guardian to unlock the next reach.</p>
`);
panel.querySelector('.modal-close').addEventListener('click', modal_1.closeModal);
}
function openPauseMenu(state, handlers) {
const panel = (0, modal_1.openModal)(`
<h2>Paused <button class="modal-close">×</button></h2>
<div style="display:flex;flex-direction:column;gap:0.6rem;">
<button class="menu-btn" id="pm-resume">Resume</button>
<button class="menu-btn" id="pm-save">Save Binding</button>
<button class="menu-btn" id="pm-mute">Toggle Sound</button>
<button class="menu-btn" id="pm-quit" style="color:var(--danger)">Return to Title</button>
</div>
`);
panel.querySelector('.modal-close').addEventListener('click', () => { (0, modal_1.closeModal)(); handlers.onResume(); });
panel.querySelector('#pm-resume').addEventListener('click', () => { (0, modal_1.closeModal)(); handlers.onResume(); });
panel.querySelector('#pm-save').addEventListener('click', handlers.onSave);
panel.querySelector('#pm-mute').addEventListener('click', handlers.onMute);
panel.querySelector('#pm-quit').addEventListener('click', handlers.onQuit);
}
};
// ── module: src/ui/shrine.ts ──
__mods["src/ui/shrine.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.openShrineMenu = openShrineMenu;
const state_1 = require("../engine/state");
const modal_1 = require("./modal");
const items_1 = require("../data/items");
const helpers_1 = require("./helpers");
const toast_1 = require("./toast");
const audio_1 = require("../engine/audio");
function openShrineMenu(state, onSave) {
render();
function render() {
const shopRows = items_1.ITEMS.filter(i => i.kind !== 'catalyst' || true).map(item => `
<div class="item-row">
<div><div class="ir-name">${(0, helpers_1.esc)(item.name)}</div><div class="ir-desc">${(0, helpers_1.esc)(item.desc)}</div></div>
<button data-buy="${item.id}">${item.price}g</button>
</div>`).join('');
const panel = (0, modal_1.openModal)(`
<h2>Shrine of Rest <button class="modal-close">×</button></h2>
<p style="color:var(--text-dim);font-size:0.9rem;">The old stones hum. Your party is fully mended, and this place is remembered.</p>
<div style="display:flex;gap:0.6rem;margin-bottom:1rem;">
<button class="menu-btn primary" id="sh-heal" style="min-width:auto;">Rest & Save</button>
</div>
<h3 style="color:var(--accent);font-size:1rem;">Wares (Gold: ${state.gold})</h3>
<div>${shopRows}</div>
`);
panel.querySelector('.modal-close').addEventListener('click', modal_1.closeModal);
panel.querySelector('#sh-heal').addEventListener('click', () => {
(0, state_1.fullHealParty)(state);
state.lastShrine = { ...state.pos };
onSave();
(0, audio_1.sfx)('heal');
(0, toast_1.showToast)('Your party is restored. Progress saved.');
(0, modal_1.closeModal)();
});
panel.querySelectorAll('[data-buy]').forEach(b => b.addEventListener('click', () => {
const id = b.dataset.buy;
const item = items_1.ITEMS.find(i => i.id === id);
if (state.gold < (item.price ?? 0)) {
(0, toast_1.showToast)('Not enough gold.');
return;
}
state.gold -= item.price ?? 0;
const e = state.inventory.find(i => i.itemId === id);
if (e)
e.qty++;
else
state.inventory.push({ itemId: id, qty: 1 });
(0, audio_1.sfx)('confirm');
render();
}));
}
}
};
// ── module: src/engine/battle.ts ──
__mods["src/engine/battle.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createBattle = createBattle;
exports.playerActive = playerActive;
exports.enemyActive = enemyActive;
exports.playerHasAlive = playerHasAlive;
exports.enemyHasAlive = enemyHasAlive;
exports.runTurn = runTurn;
exports.awardExpForVictory = awardExpForVictory;
exports.attemptCapture = attemptCapture;
const creatureInstance_1 = require("./creatureInstance");
const combat_1 = require("./combat");
const abilities_1 = require("../data/abilities");
const items_1 = require("../data/items");
const state_1 = require("./state");
function createBattle(playerParty, enemies, kind, canFlee = true, guardianName, guardianBonus) {
const startIdx = playerParty.findIndex(c => !(0, creatureInstance_1.isFainted)(c));
for (const c of playerParty)
(0, creatureInstance_1.resetBattleVolatiles)(c);
for (const c of enemies)
(0, creatureInstance_1.resetBattleVolatiles)(c);
return {
kind, playerParty, playerActiveIdx: startIdx < 0 ? 0 : startIdx, enemies, enemyActiveIdx: 0,
isWild: kind === 'wild', log: [], turn: 1, over: false, result: 'ongoing',
guardianName, guardianBonus, canFlee, captureLocked: kind === 'guardian',
};
}
function playerActive(b) { return b.playerParty[b.playerActiveIdx]; }
function enemyActive(b) { return b.enemies[b.enemyActiveIdx]; }
function playerHasAlive(b) { return b.playerParty.some(c => !(0, creatureInstance_1.isFainted)(c)); }
function enemyHasAlive(b) { return b.enemies.some(c => !(0, creatureInstance_1.isFainted)(c)); }
function nextAliveIdx(list, from) {
for (let i = 0; i < list.length; i++) {
if (!(0, creatureInstance_1.isFainted)(list[i]))
return i;
}
return from;
}
function runTurn(b, action, state) {
const log = [];
const fainted = [];
if (action.type === 'flee') {
if (b.canFlee) {
log.push({ text: 'You slip away from the encounter.', kind: 'info' });
b.result = 'fled';
b.over = true;
return { log, fainted, battleOver: true, outcome: 'ongoing' };
}
else {
log.push({ text: 'You cannot flee this battle!', kind: 'info' });
return { log, fainted, battleOver: false, outcome: 'ongoing' };
}
}
if (action.type === 'switch') {
const prev = b.playerActiveIdx;
b.playerActiveIdx = action.idx;
log.push({ text: `You call ${playerActive(b) === undefined ? '' : ''}your creature back and send out a new one!`, kind: 'info' });
}
if (action.type === 'pass') {
log.push({ text: 'The binding attempt used up the turn.', kind: 'info' });
}
if (action.type === 'item') {
const item = (0, items_1.getItem)(action.itemId);
if (item.kind === 'restore') {
const target = playerActive(b);
const pw = item.power ?? 0;
if (pw >= 999)
target.curHp = Math.max(target.curHp, (0, creatureInstance_1.maxHp)(target));
else if (pw === 0)
target.status = {};
else
target.curHp = Math.min((0, creatureInstance_1.maxHp)(target), target.curHp + pw);
(0, state_1.removeItem)(action.state, action.itemId, 1);
log.push({ text: `You use ${item.name}!`, kind: 'heal', target: 'player' });
}
}
// enemy acts (unless the player fled/switched only — switching still lets enemy act this turn, except first turn logic below is fine)
const pAbility = action.type === 'move' ? (0, abilities_1.getAbility)(action.abilityId) : null;
const enemyAbility = (0, combat_1.pickEnemyAbility)(enemyActive(b));
const doPlayerMove = action.type === 'move';
const order = doPlayerMove ? (0, combat_1.speedOrder)(playerActive(b), pAbility, enemyActive(b), enemyAbility) : 'b';
const acts = [];
if (doPlayerMove) {
if (order === 'a')
acts.push({ who: 'player' }, { who: 'enemy' });
else
acts.push({ who: 'enemy' }, { who: 'player' });
}
else {
acts.push({ who: 'enemy' });
}
for (const act of acts) {
if (b.over)
break;
if (!playerHasAlive(b) || !enemyHasAlive(b))
break;
if (act.who === 'player') {
if ((0, creatureInstance_1.isFainted)(playerActive(b)))
continue;
if ((0, creatureInstance_1.isFainted)(enemyActive(b)))
continue;
const outcome = (0, combat_1.executeMove)(playerActive(b), enemyActive(b), action.type === 'move' ? action.abilityId : '', 'player');
log.push(...outcome.log);
}
else {
if ((0, creatureInstance_1.isFainted)(enemyActive(b)))
continue;
if ((0, creatureInstance_1.isFainted)(playerActive(b)))
continue;
const outcome = (0, combat_1.executeMove)(enemyActive(b), playerActive(b), enemyAbility.id, 'enemy');
log.push(...outcome.log);
}
}
// end of turn status ticks
if (!(0, creatureInstance_1.isFainted)(playerActive(b)))
log.push(...(0, combat_1.tickStatus)(playerActive(b), 'player'));
if (!(0, creatureInstance_1.isFainted)(enemyActive(b)))
log.push(...(0, combat_1.tickStatus)(enemyActive(b), 'enemy'));
// handle faints
if ((0, creatureInstance_1.isFainted)(playerActive(b))) {
fainted.push({ side: 'player', needsSwitch: playerHasAlive(b) });
}
if ((0, creatureInstance_1.isFainted)(enemyActive(b))) {
fainted.push({ side: 'enemy', needsSwitch: enemyHasAlive(b) });
}
let outcome = 'ongoing';
if (!playerHasAlive(b)) {
outcome = 'lose';
b.over = true;
b.result = 'lose';
}
else if (!enemyHasAlive(b)) {
outcome = 'win';
b.over = true;
b.result = 'win';
}
b.turn++;
b.log.push(...log);
return { log, fainted, battleOver: b.over, outcome };
}
function awardExpForVictory(b, state) {
const results = [];
const aliveWinners = b.playerParty.filter(c => !(0, creatureInstance_1.isFainted)(c));
for (const enemy of b.enemies) {
const sp = (0, creatureInstance_1.species)(enemy);
const totalBase = sp.base.hp + sp.base.atk + sp.base.def + sp.base.spd;
for (const winner of aliveWinners) {
const amount = (0, combat_1.calcExpAward)(enemy.level, totalBase, winner.level) / Math.max(1, aliveWinners.length);
const levels = (0, creatureInstance_1.grantExp)(winner, Math.round(amount));
if (levels.length)
results.push({ name: (0, creatureInstance_1.species)(winner).name, levels });
}
}
return results;
}
function attemptCapture(b, itemId, skillFactor, state) {
const target = enemyActive(b);
const item = (0, items_1.getItem)(itemId);
(0, state_1.removeItem)(state, itemId, 1);
const tier = (item.power ?? 1);
const chanceVal = (0, combat_1.captureChance)(target, tier, skillFactor);
const success = Math.random() < chanceVal;
if (success) {
b.result = 'captured';
b.over = true;
(0, state_1.markCaptured)(state, target.speciesId);
(0, state_1.addToParty)(state, target);
}
return { success, chance: chanceVal };
}
};
// ── module: src/ui/battleScreen.ts ──
__mods["src/ui/battleScreen.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.BattleScreen = void 0;
const battle_1 = require("../engine/battle");
const creatureInstance_1 = require("../engine/creatureInstance");
const abilities_1 = require("../data/abilities");
const items_1 = require("../data/items");
const sprites_1 = require("../engine/sprites");
const types_1 = require("../data/types");
const audio_1 = require("../engine/audio");
const helpers_1 = require("./helpers");
const modal_1 = require("./modal");
class BattleScreen {
constructor(state, enemies, kind, canFlee, guardianName, guardianBonus, cb) {
this.animT = 0;
this.fx = { shakeSide: null, shakeT: 0, flash: null, flashT: 0, burstSide: null, burstT: 0, floaters: [] };
this.awaitingSwitch = null;
this.rafId = 0;
this.lastTime = 0;
this.busy = false;
this.state = state;
this.b = (0, battle_1.createBattle)(state.party, enemies, kind, canFlee, guardianName, guardianBonus);
this.canvas = document.getElementById('battle-canvas');
this.ctx = this.canvas.getContext('2d');
this.cb = cb;
(0, audio_1.startAmbient)('battle');
this.renderUI();
this.loop = this.loop.bind(this);
this.lastTime = performance.now();
this.rafId = requestAnimationFrame(this.loop);
}
destroy() { cancelAnimationFrame(this.rafId); }
loop(now) {
const dt = Math.min(0.05, (now - this.lastTime) / 1000);
this.lastTime = now;
this.animT += dt;
if (this.fx.shakeT > 0)
this.fx.shakeT -= dt;
if (this.fx.flashT > 0)
this.fx.flashT -= dt;
else
this.fx.flash = null;
if (this.fx.burstT > 0)
this.fx.burstT -= dt;
else
this.fx.burstSide = null;
for (const f of this.fx.floaters)
f.t += dt;
this.fx.floaters = this.fx.floaters.filter(f => f.t < f.life);
this.draw();
this.rafId = requestAnimationFrame(this.loop);
}
draw() {
const { ctx, canvas } = this;
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
const grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, '#232a4a');
grad.addColorStop(1, '#0c0d18');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
// ground ellipses
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.beginPath();
ctx.ellipse(w * 0.28, h * 0.82, 130, 26, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(w * 0.74, h * 0.42, 110, 22, 0, 0, Math.PI * 2);
ctx.fill();
const eShake = this.fx.shakeSide === 'enemy' && this.fx.shakeT > 0;
const pShake = this.fx.shakeSide === 'player' && this.fx.shakeT > 0;
const shk = (on) => on ? (Math.random() - 0.5) * 10 : 0;
// enemy
const enemy = (0, battle_1.enemyActive)(this.b);
if (!(0, creatureInstance_1.isFainted)(enemy) || this.fx.shakeT > 0) {
ctx.save();
ctx.translate(shk(eShake), shk(eShake));
(0, sprites_1.drawCreature)(ctx, (0, creatureInstance_1.species)(enemy), w * 0.74, h * 0.42, 1.5, this.animT, true);
ctx.restore();
}
// player
const pc = (0, battle_1.playerActive)(this.b);
if (pc && (!(0, creatureInstance_1.isFainted)(pc) || this.fx.shakeT > 0)) {
ctx.save();
ctx.translate(shk(pShake), shk(pShake));
(0, sprites_1.drawCreature)(ctx, (0, creatureInstance_1.species)(pc), w * 0.28, h * 0.82, 1.8, this.animT + 3, false);
ctx.restore();
}
// flash overlay
if (this.fx.flash) {
ctx.fillStyle = this.fx.flash === 'crit' ? 'rgba(255,212,121,0.35)' : 'rgba(127,214,255,0.3)';
ctx.fillRect(0, 0, w, h);
}
// particle burst
if (this.fx.burstSide) {
const bx = this.fx.burstSide === 'enemy' ? w * 0.74 : w * 0.28;
const by = this.fx.burstSide === 'enemy' ? h * 0.42 : h * 0.82;
for (let i = 0; i < 10; i++) {
const ang = (i / 10) * Math.PI * 2 + this.animT * 3;
const r = 30 + (1 - this.fx.burstT) * 60;
ctx.fillStyle = '#7fd6ff';
ctx.globalAlpha = Math.max(0, this.fx.burstT);
ctx.beginPath();
ctx.arc(bx + Math.cos(ang) * r, by + Math.sin(ang) * r, 4, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
// HP panels
this.drawHpPanel(ctx, enemy, w * 0.06, h * 0.08, 260, false);
if (pc)
this.drawHpPanel(ctx, pc, w - 300 - w * 0.06, h * 0.58, 300, true);
// floating damage numbers
for (const f of this.fx.floaters) {
const alpha = Math.max(0, 1 - f.t / f.life);
ctx.globalAlpha = alpha;
ctx.fillStyle = f.color;
ctx.font = 'bold 22px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(f.text, f.x, f.y - f.t * 40);
ctx.globalAlpha = 1;
}
}
drawHpPanel(ctx, ci, x, y, width, showExp) {
ctx.save();
ctx.fillStyle = 'rgba(10,10,20,0.75)';
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
const height = 58;
roundRectPath(ctx, x, y, width, height, 10);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.font = 'bold 15px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`${(0, creatureInstance_1.displayName)(ci)} Lv.${ci.level}`, x + 12, y + 20);
const max = (0, creatureInstance_1.maxHp)(ci);
const pct = Math.max(0, ci.curHp / max);
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fillRect(x + 12, y + 28, width - 24, 10);
ctx.fillStyle = pct <= 0.25 ? '#ff5d6c' : pct <= 0.55 ? '#ffd479' : '#6bd66a';
ctx.fillRect(x + 12, y + 28, (width - 24) * pct, 10);
ctx.font = '11px sans-serif';
ctx.fillStyle = '#ccc';
ctx.fillText(`${ci.curHp}/${max}`, x + 12, y + 50);
const statuses = Object.entries(ci.status).filter(([, v]) => v).map(([k]) => k);
if (statuses.length)
ctx.fillText(statuses.join(' '), x + width - 90, y + 50);
ctx.restore();
}
addFloater(side, text, color) {
const w = this.canvas.width, h = this.canvas.height;
const x = side === 'enemy' ? w * 0.74 : w * 0.28;
const y = side === 'enemy' ? h * 0.42 - 40 : h * 0.82 - 60;
this.fx.floaters.push({ x, y, text, color, t: 0, life: 1.1 });
}
applyLogFx(log) {
for (const entry of log) {
if (entry.shake && entry.target) {
this.fx.shakeSide = entry.target;
this.fx.shakeT = 0.3;
(0, audio_1.sfx)('hit');
}
if (entry.kind === 'damage' && entry.target && entry.damageAmount)
this.addFloater(entry.target, `-${entry.damageAmount}`, '#ff8a8a');
if (entry.kind === 'heal' && entry.target)
this.addFloater(entry.target, 'Healed', '#6bd66a');
if (entry.kind === 'crit') {
this.fx.flash = 'crit';
this.fx.flashT = 0.15;
(0, audio_1.sfx)('crit');
}
if (entry.particleBurst && entry.target) {
this.fx.burstSide = entry.target;
this.fx.burstT = 0.5;
if (entry.flash === 'super')
(0, audio_1.sfx)('super');
}
if (entry.kind === 'faint')
(0, audio_1.sfx)('faint');
}
}
renderUI() {
const ui = document.getElementById('battle-ui');
const b = this.b;
if (b.over) {
this.renderEndScreen(ui);
return;
}
const pc = (0, battle_1.playerActive)(b);
const moveButtons = pc.moves.map(mid => {
const a = (0, abilities_1.getAbility)(mid);
return `<button class="act-btn" data-move="${mid}">
<span class="ab-name" style="color:${types_1.THREAD_COLOR[a.thread]}">${(0, helpers_1.esc)(a.name)}</span>
<span class="ab-meta">PWR ${a.power || '-'} · ${Math.round(a.accuracy * 100)}% · ${a.thread}</span>
</button>`;
}).join('');
ui.innerHTML = `
<div class="log-box" id="battle-log"></div>
<div id="action-area">
<div class="action-main">
<button id="btn-move-fight">Fight</button>
${!b.captureLocked ? '<button id="btn-move-catch">Bind</button>' : ''}
<button id="btn-move-bag">Satchel</button>
<button id="btn-move-switch">Team</button>
${b.canFlee ? '<button id="btn-move-flee">Flee</button>' : ''}
</div>
</div>
`;
this.refreshLog();
ui.querySelector('#btn-move-fight').addEventListener('click', () => this.showMoves(moveButtons));
ui.querySelector('#btn-move-catch')?.addEventListener('click', () => this.showCapture());
ui.querySelector('#btn-move-bag').addEventListener('click', () => this.showBag());
ui.querySelector('#btn-move-switch').addEventListener('click', () => this.showSwitch(false));
ui.querySelector('#btn-move-flee')?.addEventListener('click', () => this.doAction({ type: 'flee' }));
}
showMoves(moveButtons) {
const area = document.getElementById('action-area');
area.innerHTML = `<div class="action-grid">${moveButtons}</div><button class="back-btn">Back</button>`;
area.querySelectorAll('[data-move]').forEach(btn => btn.addEventListener('click', () => {
const id = btn.dataset.move;
this.doAction({ type: 'move', abilityId: id });
}));
area.querySelector('.back-btn').addEventListener('click', () => this.renderUI());
}
showBag() {
const area = document.getElementById('action-area');
const usable = this.state.inventory.filter(e => { const it = (0, items_1.getItem)(e.itemId); return (it.kind === 'restore') && e.qty > 0; });
const html = usable.map(e => {
const it = (0, items_1.getItem)(e.itemId);
return `<button class="act-btn" data-item="${it.id}"><span class="ab-name">${(0, helpers_1.esc)(it.name)} x${e.qty}</span><span class="ab-meta">${(0, helpers_1.esc)(it.desc)}</span></button>`;
}).join('') || '<p style="color:var(--text-dim)">No usable items.</p>';
area.innerHTML = `<div class="action-grid">${html}</div><button class="back-btn">Back</button>`;
area.querySelectorAll('[data-item]').forEach(btn => btn.addEventListener('click', () => {
const id = btn.dataset.item;
this.doAction({ type: 'item', itemId: id, state: this.state });
}));
area.querySelector('.back-btn').addEventListener('click', () => this.renderUI());
}
showSwitch(forced) {
const area = document.getElementById('action-area');
const rows = this.state.party.map((c, i) => {
const dead = (0, creatureInstance_1.isFainted)(c);
const active = i === this.b.playerActiveIdx;
return `<button class="act-btn" data-switch="${i}" ${dead || active ? 'disabled' : ''}>
<span class="ab-name">${(0, helpers_1.esc)((0, creatureInstance_1.displayName)(c))} Lv.${c.level}</span>
<span class="ab-meta">${c.curHp}/${(0, creatureInstance_1.maxHp)(c)} HP ${dead ? '(fainted)' : active ? '(active)' : ''}</span>
</button>`;
}).join('');
area.innerHTML = `<div class="action-grid wide">${rows}</div>${forced ? '' : '<button class="back-btn">Back</button>'}`;
area.querySelectorAll('[data-switch]').forEach(btn => btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.switch, 10);
this.doAction({ type: 'switch', idx });
}));
area.querySelector('.back-btn')?.addEventListener('click', () => this.renderUI());
}
showCapture() {
const area = document.getElementById('action-area');
const tools = this.state.inventory.filter(e => (0, items_1.getItem)(e.itemId).kind === 'capture' && e.qty > 0);
const html = tools.map(e => {
const it = (0, items_1.getItem)(e.itemId);
return `<button class="act-btn" data-tool="${it.id}"><span class="ab-name">${(0, helpers_1.esc)(it.name)} x${e.qty}</span><span class="ab-meta">${(0, helpers_1.esc)(it.desc)}</span></button>`;
}).join('') || '<p style="color:var(--text-dim)">No binding tools.</p>';
area.innerHTML = `<div class="action-grid">${html}</div><button class="back-btn">Back</button>`;
area.querySelectorAll('[data-tool]').forEach(btn => btn.addEventListener('click', () => {
const id = btn.dataset.tool;
this.startCaptureMinigame(id);
}));
area.querySelector('.back-btn').addEventListener('click', () => this.renderUI());
}
startCaptureMinigame(itemId) {
// Rhythm-tap mini-game: a marker sweeps across a bar; tapping near center maximizes skillFactor.
let pos = 0, dir = 1, running = true, skill = 0.5;
const panel = (0, modal_1.openModal)(`
<h2>Binding Attempt</h2>
<p style="color:var(--text-dim);font-size:0.85rem;">Tap BIND when the marker is centered in the glowing zone!</p>
<div style="position:relative;height:34px;background:rgba(0,0,0,0.4);border-radius:8px;overflow:hidden;margin:1rem 0;">
<div style="position:absolute;left:40%;width:20%;height:100%;background:rgba(127,214,255,0.35);"></div>
<div id="cap-marker" style="position:absolute;top:2px;left:0;width:6px;height:30px;background:#ffd479;border-radius:3px;"></div>
</div>
<button id="cap-go" class="menu-btn primary">BIND!</button>
`);
const marker = panel.querySelector('#cap-marker');
let raf = 0;
const step = () => {
if (!running)
return;
pos += dir * 0.02;
if (pos >= 1) {
pos = 1;
dir = -1;
}
if (pos <= 0) {
pos = 0;
dir = 1;
}
marker.style.left = `calc(${pos * 100}% - 3px)`;
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
panel.querySelector('#cap-go').addEventListener('click', () => {
running = false;
cancelAnimationFrame(raf);
const dist = Math.abs(pos - 0.5);
skill = Math.max(0, 1 - dist * 2.6);
(0, modal_1.closeModal)();
this.resolveCapture(itemId, skill);
});
}
resolveCapture(itemId, skill) {
(0, audio_1.sfx)('capture_tick');
const target = (0, battle_1.enemyActive)(this.b);
const targetName = (0, creatureInstance_1.displayName)(target);
const { success, chance } = (0, battle_1.attemptCapture)(this.b, itemId, skill, this.state);
const ui = document.getElementById('battle-ui');
const area = document.getElementById('action-area');
area.innerHTML = `<p style="text-align:center;padding:1rem;">Attempting to bind ${(0, helpers_1.esc)(targetName)}... (${Math.round(chance * 100)}% odds)</p>`;
setTimeout(() => {
if (success) {
(0, audio_1.sfx)('capture_success');
this.pushLogLine(`${targetName} has been bound to your team!`, 'heal');
this.finishBattle('captured');
}
else {
(0, audio_1.sfx)('capture_fail');
this.pushLogLine(`The binding fails — ${targetName} breaks free!`, 'info');
this.enemyFollowupAfterFailedCapture();
}
}, 700);
}
enemyFollowupAfterFailedCapture() {
// Failed capture consumes the player's turn; enemy still acts.
this.doAction({ type: 'pass' });
}
doAction(action) {
if (this.busy || this.b.over)
return;
this.busy = true;
const preFaintedPlayer = (0, creatureInstance_1.isFainted)((0, battle_1.playerActive)(this.b));
const result = (0, battle_1.runTurn)(this.b, action, this.state);
this.applyLogFx(result.log);
this.appendLog(result.log);
if (result.outcome === 'win') {
const awards = (0, battle_1.awardExpForVictory)(this.b, this.state);
(0, audio_1.sfx)('victory');
setTimeout(() => this.finishBattle('win', awards), 600);
this.busy = false;
return;
}
if (result.outcome === 'lose') {
setTimeout(() => this.finishBattle('lose'), 600);
this.busy = false;
return;
}
// handle forced switch if player's active fainted but party still alive
const needsPlayerSwitch = (0, creatureInstance_1.isFainted)((0, battle_1.playerActive)(this.b)) && (0, battle_1.playerHasAlive)(this.b);
setTimeout(() => {
this.busy = false;
if (needsPlayerSwitch) {
this.pushLogLine('Choose your next creature!', 'info');
this.renderUIForForcedSwitch();
}
else {
this.renderUI();
}
}, 350);
}
renderUIForForcedSwitch() {
const ui = document.getElementById('battle-ui');
ui.innerHTML = `<div class="log-box" id="battle-log"></div><div id="action-area"></div>`;
this.refreshLog();
this.showSwitch(true);
}
pushLogLine(text, kind) {
this.b.log.push({ text, kind });
this.refreshLog();
}
appendLog(entries) {
this.refreshLog();
}
refreshLog() {
const box = document.getElementById('battle-log');
if (!box)
return;
box.innerHTML = this.b.log.slice(-40).map(e => `<div class="lk-${e.kind}">${(0, helpers_1.esc)(e.text)}</div>`).join('');
box.scrollTop = box.scrollHeight;
}
finishBattle(result, awards) {
this.b.result = result;
this.b.over = true;
this.renderEndScreen(document.getElementById('battle-ui'), awards);
}
renderEndScreen(ui, awards) {
let title = '';
let sub = '';
if (this.b.result === 'win') {
title = this.b.guardianName ? `${this.b.guardianName} is bound!` : 'Victory!';
sub = awards?.length ? awards.map(a => `${a.name} reached Lv.${a.levels[a.levels.length - 1]}!`).join(' ') : 'Your team stands triumphant.';
}
else if (this.b.result === 'lose') {
title = 'Your party has fallen...';
sub = 'You are carried back to the last shrine.';
}
else if (this.b.result === 'fled') {
title = 'You escaped safely.';
sub = '';
}
else if (this.b.result === 'captured') {
title = 'A new bond is formed!';
sub = 'The creature joins your team.';
}
ui.innerHTML = `
<div class="log-box" id="battle-log"></div>
<div style="text-align:center;padding:1rem;">
<h2 style="margin:0 0 0.4rem;color:var(--accent);">${(0, helpers_1.esc)(title)}</h2>
<p style="color:var(--text-dim);">${(0, helpers_1.esc)(sub)}</p>
<button id="btn-battle-continue" class="menu-btn primary" style="margin-top:0.8rem;">Continue</button>
</div>`;
this.refreshLog();
ui.querySelector('#btn-battle-continue').addEventListener('click', () => {
this.destroy();
this.cb.onEnd(this.b.result);
});
}
}
exports.BattleScreen = BattleScreen;
function roundRectPath(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
};
// ── module: src/ui/starter.ts ──
__mods["src/ui/starter.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.renderStarterScreen = renderStarterScreen;
const creatures_1 = require("../data/creatures");
const types_1 = require("../data/types");
const helpers_1 = require("./helpers");
const creatureInstance_1 = require("../engine/creatureInstance");
const state_1 = require("../engine/state");
const audio_1 = require("../engine/audio");
function renderStarterScreen(state, onChosen) {
const root = document.getElementById('starter-cards');
root.innerHTML = creatures_1.STARTER_IDS.map(id => {
const sp = (0, creatures_1.getSpecies)(id);
return `<div class="starter-card">
<div class="sc-icon" style="background:radial-gradient(circle at 35% 30%, rgba(255,255,255,0.25), ${types_1.THREAD_COLOR[sp.threads[0]]})">${types_1.THREAD_GLYPH[sp.threads[0]]}</div>
<h3>${(0, helpers_1.esc)(sp.name)}</h3>
<div class="sc-thread">${(0, helpers_1.threadTagHtml)(sp.threads)}</div>
<p>${(0, helpers_1.esc)(sp.lore)}</p>
<button data-starter="${id}">Choose</button>
</div>`;
}).join('');
root.querySelectorAll('[data-starter]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.starter;
const inst = (0, creatureInstance_1.createInstance)(id, 5);
(0, state_1.addToParty)(state, inst);
(0, state_1.markCaptured)(state, id);
state.flags.starterChosen = true;
(0, audio_1.sfx)('confirm');
onChosen();
});
});
}
};
// ── module: src/ui/titlefx.ts ──
__mods["src/ui/titlefx.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.startTitleFx = startTitleFx;
exports.stopTitleFx = stopTitleFx;
let motes = [];
let raf = 0;
function startTitleFx(canvas) {
const ctx = canvas.getContext('2d');
function resize() {
canvas.width = canvas.clientWidth || window.innerWidth;
canvas.height = canvas.clientHeight || window.innerHeight;
}
resize();
window.addEventListener('resize', resize);
motes = Array.from({ length: 70 }, () => ({
x: Math.random() * canvas.width, y: Math.random() * canvas.height,
r: 1 + Math.random() * 2.4, vy: 6 + Math.random() * 14, phase: Math.random() * Math.PI * 2,
}));
let last = performance.now();
function loop(now) {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const m of motes) {
m.y -= m.vy * dt;
m.phase += dt;
if (m.y < -10) {
m.y = canvas.height + 10;
m.x = Math.random() * canvas.width;
}
const x = m.x + Math.sin(m.phase) * 12;
const glow = 0.4 + Math.sin(m.phase * 2) * 0.3;
ctx.beginPath();
ctx.fillStyle = `rgba(255, 212, 121, ${0.25 + glow * 0.3})`;
ctx.arc(x, m.y, m.r, 0, Math.PI * 2);
ctx.fill();
}
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
}
function stopTitleFx() { cancelAnimationFrame(raf); }
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>