feat: add Spire Climb single-player roguelike deckbuilder

Implement a complete Slay-the-Spire inspired game with:

- **Core engine** (`SpireClimbLogic.js`): Turn-based combat state machine,
  seeded RNG, map generation, enemy AI, relic hooks, and a headless verifier
  (`verifySpireClimb.js`) that auto-plays 1,000+ combats across both classes.

- **Game scene** (`SpireClimbGame.js`): Phaser UI for class select, branching
  map, animated combat (card fly-in, health-bar drain, enemy lunge/buff FX),
  rewards, shop, events, and treasure nodes.

- **Content** (`SpireClimbData.js`): Two classes (Warrior, Rogue), 30+ cards
  with upgrades, relics, potions, 10 enemies/elites/bosses, events, and map
  tuning.

- **Art pipeline**: Procedural rendering by default with drop-in sprite sheets
  (`spireclimb-creatures.png`, `spireclimb-cards.png`) wired through
  `spireclimb-artwork.json`. Includes a Node script to regenerate the creature
  spritesheet and a full art spec (`sprites.md`).
This commit is contained in:
Brian Fertig 2026-06-26 22:05:07 -06:00
parent 5f9f7c8bd8
commit 37e4d6ba68
17 changed files with 384 additions and 80 deletions

View File

@ -15,21 +15,39 @@
import zlib from 'node:zlib'; import zlib from 'node:zlib';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
const FW = 300, FH = 300, COLS = 5, ROWS = 2; const FW = 300, FH = 300, COLS = 6, ROWS = 4;
const W = FW * COLS, H = FH * ROWS; const W = FW * COLS, H = FH * ROWS;
// id → { frame, color, shape, tier } (mirrors SpireClimbData ENEMIES placeholders) // id → { frame, color, shape, tier } (mirrors SpireClimbData ENEMIES placeholders).
// 24 frames, row-major: frames 09 Act 1, 1016 Act 2, 1723 Act 3.
const CREATURES = [ const CREATURES = [
{ id: 'jawworm', frame: 0, color: [0x9c, 0x6b, 0x3a], shape: 'worm', tier: 1 }, // —— Act 1 ——
{ id: 'cultist', frame: 1, color: [0x6a, 0x4d, 0x8a], shape: 'robed', tier: 1 }, { id: 'jawworm', frame: 0, color: [0x9c, 0x6b, 0x3a], shape: 'worm', tier: 1 },
{ id: 'louse', frame: 2, color: [0xc0, 0x53, 0x3a], shape: 'bug', tier: 1 }, { id: 'cultist', frame: 1, color: [0x6a, 0x4d, 0x8a], shape: 'robed', tier: 1 },
{ id: 'fungi', frame: 3, color: [0x4b, 0x8c, 0x5a], shape: 'shroom',tier: 1 }, { id: 'louse', frame: 2, color: [0xc0, 0x53, 0x3a], shape: 'bug', tier: 1 },
{ id: 'spikeslime', frame: 4, color: [0x4a, 0x6b, 0xbf], shape: 'spiky', tier: 1 }, { id: 'fungi', frame: 3, color: [0x4b, 0x8c, 0x5a], shape: 'shroom',tier: 1 },
{ id: 'sentry', frame: 5, color: [0x9a, 0xa7, 0xb5], shape: 'orb', tier: 2 }, { id: 'spikeslime', frame: 4, color: [0x4a, 0x6b, 0xbf], shape: 'spiky', tier: 1 },
{ id: 'gremlinnob', frame: 6, color: [0xb5, 0x50, 0x3a], shape: 'horns', tier: 2 }, { id: 'sentry', frame: 5, color: [0x9a, 0xa7, 0xb5], shape: 'orb', tier: 2 },
{ id: 'lagavulin', frame: 7, color: [0x3a, 0x7a, 0x6b], shape: 'slime', tier: 2 }, { id: 'gremlinnob', frame: 6, color: [0xb5, 0x50, 0x3a], shape: 'horns', tier: 2 },
{ id: 'guardian', frame: 8, color: [0x8a, 0x6b, 0x3a], shape: 'golem', tier: 3 }, { id: 'lagavulin', frame: 7, color: [0x3a, 0x7a, 0x6b], shape: 'slime', tier: 2 },
{ id: 'slimeboss', frame: 9, color: [0x4a, 0x6b, 0xbf], shape: 'slime', tier: 3 }, { id: 'guardian', frame: 8, color: [0x8a, 0x6b, 0x3a], shape: 'golem', tier: 3 },
{ id: 'slimeboss', frame: 9, color: [0x4a, 0x6b, 0xbf], shape: 'slime', tier: 3 },
// —— Act 2 ——
{ id: 'centurion', frame: 10, color: [0xb8, 0xa2, 0x4a], shape: 'golem', tier: 2 },
{ id: 'mystic', frame: 11, color: [0x5a, 0x8f, 0xc0], shape: 'robed', tier: 1 },
{ id: 'byrd', frame: 12, color: [0xd2, 0x9a, 0x3a], shape: 'bug', tier: 1 },
{ id: 'snecko', frame: 13, color: [0x6a, 0xae, 0x4a], shape: 'worm', tier: 2 },
{ id: 'taskmaster', frame: 14, color: [0xc0, 0x5a, 0x3a], shape: 'horns', tier: 2 },
{ id: 'bookwyrm', frame: 15, color: [0x7a, 0x4d, 0x8a], shape: 'slime', tier: 3 },
{ id: 'automaton', frame: 16, color: [0xb8, 0x90, 0x30], shape: 'golem', tier: 3 },
// —— Act 3 ——
{ id: 'darkling', frame: 17, color: [0x8a, 0x3a, 0x5a], shape: 'slime', tier: 2 },
{ id: 'spiker', frame: 18, color: [0x4a, 0x6b, 0xbf], shape: 'spiky', tier: 2 },
{ id: 'maw', frame: 19, color: [0x6a, 0x4d, 0x3a], shape: 'horns', tier: 3 },
{ id: 'wraith', frame: 20, color: [0x9a, 0x5f, 0xd0], shape: 'robed', tier: 2 },
{ id: 'nemesis', frame: 21, color: [0x9a, 0xa7, 0xb5], shape: 'horns', tier: 3 },
{ id: 'giant', frame: 22, color: [0x8a, 0x7a, 0x4a], shape: 'golem', tier: 3 },
{ id: 'awakened', frame: 23, color: [0xc9, 0x4f, 0x8a], shape: 'horns', tier: 3 },
]; ];
// RGBA pixel buffer // RGBA pixel buffer

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -31,6 +31,10 @@
"creatures": { "creatures": {
"jawworm": 0, "cultist": 1, "louse": 2, "fungi": 3, "spikeslime": 4, "jawworm": 0, "cultist": 1, "louse": 2, "fungi": 3, "spikeslime": 4,
"sentry": 5, "gremlinnob": 6, "lagavulin": 7, "sentry": 5, "gremlinnob": 6, "lagavulin": 7,
"guardian": 8, "slimeboss": 9 "guardian": 8, "slimeboss": 9,
"centurion": 10, "mystic": 11, "byrd": 12, "snecko": 13,
"taskmaster": 14, "bookwyrm": 15, "automaton": 16,
"darkling": 17, "spiker": 18, "maw": 19, "wraith": 20,
"nemesis": 21, "giant": 22, "awakened": 23
} }
} }

View File

@ -395,32 +395,178 @@ export const ENEMIES = {
], ],
ai: 'sequence', ai: 'sequence',
}, },
// ═══════════════ ACT 2 — The Sunken City (tougher) ═══════════════
// —— normal ——
centurion: {
id: 'centurion', name: 'Centurion', tier: 'normal', placeholderFrame: 22, hp: [54, 60], color: 0xb8a24a,
moves: [
{ id: 'slash', intent: 'attack', value: 14, effects: [{ op: 'damage', amount: 14 }] },
{ id: 'fullguard', intent: 'attackdefend', value: 7, block: 15, effects: [{ op: 'damage', amount: 7 }, { op: 'blockSelf', amount: 15 }] },
],
ai: 'alternate',
},
mystic: {
id: 'mystic', name: 'Mystic', tier: 'normal', placeholderFrame: 12, hp: [46, 52], color: 0x5a8fc0,
moves: [
{ id: 'channel', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'strength', amount: 2 }, { op: 'blockSelf', amount: 8 }] },
{ id: 'attack', intent: 'attack', value: 10, effects: [{ op: 'damage', amount: 10 }] },
],
ai: 'random',
},
byrd: {
id: 'byrd', name: 'Byrd', tier: 'normal', placeholderFrame: 9, hp: [26, 30], color: 0xd29a3a,
moves: [
{ id: 'peck', intent: 'attack', value: 2, times: 5, effects: [{ op: 'damage', amount: 2, times: 5 }] },
{ id: 'swoop', intent: 'attack', value: 12, effects: [{ op: 'damage', amount: 12 }] },
],
ai: 'random',
},
snecko: {
id: 'snecko', name: 'Snecko', tier: 'normal', placeholderFrame: 21, hp: [56, 62], color: 0x6aae4a,
moves: [
{ id: 'glare', intent: 'debuff', effects: [{ op: 'debuffPlayer', status: 'weak', amount: 2 }] },
{ id: 'bite', intent: 'attack', value: 15, effects: [{ op: 'damage', amount: 15 }] },
],
ai: 'alternate',
},
// —— elites ——
taskmaster: {
id: 'taskmaster', name: 'Taskmaster', tier: 'elite', placeholderFrame: 23, hp: [64, 70], color: 0xc05a3a,
moves: [
{ id: 'scourge', intent: 'attackdebuff', value: 9, effects: [{ op: 'damage', amount: 9 }, { op: 'addCardToDiscard', card: 'wound', amount: 1 }] },
{ id: 'roar', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'strength', amount: 2 }] },
{ id: 'crush', intent: 'attack', value: 16, effects: [{ op: 'damage', amount: 16 }] },
],
ai: 'random',
},
bookwyrm: {
id: 'bookwyrm', name: 'Book of Stabbing', tier: 'elite', placeholderFrame: 19, hp: [120, 128], color: 0x7a4d8a,
moves: [
{ id: 'multistab', intent: 'attack', value: 6, times: 3, effects: [{ op: 'damage', amount: 6, times: 3 }] },
{ id: 'bigbite', intent: 'attackdebuff', value: 21, effects: [{ op: 'damage', amount: 21 }, { op: 'debuffPlayer', status: 'vulnerable', amount: 2 }] },
],
ai: 'alternate',
},
// —— boss ——
automaton: {
id: 'automaton', name: 'Bronze Automaton', tier: 'boss', placeholderFrame: 13, hp: [240, 240], color: 0xb89030,
moves: [
{ id: 'boost', intent: 'defend', block: 12, effects: [{ op: 'blockSelf', amount: 12 }, { op: 'buffSelfEnemy', status: 'strength', amount: 3 }] },
{ id: 'flail', intent: 'attack', value: 7, times: 2, effects: [{ op: 'damage', amount: 7, times: 2 }] },
{ id: 'hyperbeam', intent: 'attackdebuff', value: 28, effects: [{ op: 'damage', amount: 28 }, { op: 'addCardToDiscard', card: 'dazed', amount: 2 }] },
],
ai: 'sequence',
},
// ═══════════════ ACT 3 — The Spire's Crown (deadliest) ═══════════════
// —— normal ——
darkling: {
id: 'darkling', name: 'Darkling', tier: 'normal', placeholderFrame: 5, hp: [48, 56], color: 0x8a3a5a,
moves: [
{ id: 'nip', intent: 'attack', value: 11, effects: [{ op: 'damage', amount: 11 }] },
{ id: 'harden', intent: 'attackdefend', value: 9, block: 12, effects: [{ op: 'damage', amount: 9 }, { op: 'blockSelf', amount: 12 }] },
{ id: 'regrow', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'strength', amount: 2 }, { op: 'blockSelf', amount: 6 }] },
],
ai: 'random',
},
spiker: {
id: 'spiker', name: 'Spiker', tier: 'normal', placeholderFrame: 21, hp: [44, 50], color: 0x4a6bbf,
moves: [
{ id: 'stab', intent: 'attack', value: 13, effects: [{ op: 'damage', amount: 13 }] },
{ id: 'spike', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'metallicize', amount: 3 }, { op: 'blockSelf', amount: 6 }] },
],
ai: 'alternate',
},
maw: {
id: 'maw', name: 'The Maw', tier: 'normal', placeholderFrame: 27, hp: [70, 78], color: 0x6a4d3a,
moves: [
{ id: 'roar', intent: 'debuff', effects: [{ op: 'debuffPlayer', status: 'weak', amount: 2 }, { op: 'debuffPlayer', status: 'frail', amount: 2 }] },
{ id: 'drool', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'strength', amount: 3 }] },
{ id: 'slam', intent: 'attack', value: 20, effects: [{ op: 'damage', amount: 20 }] },
],
ai: 'sequence',
},
wraith: {
id: 'wraith', name: 'Spire Wraith', tier: 'normal', placeholderFrame: 9, hp: [34, 40], color: 0x9a5fd0,
moves: [
{ id: 'scythe', intent: 'attack', value: 16, effects: [{ op: 'damage', amount: 16 }] },
{ id: 'phase', intent: 'defend', block: 14, effects: [{ op: 'blockSelf', amount: 14 }] },
],
ai: 'alternate',
},
// —— elites ——
nemesis: {
id: 'nemesis', name: 'Nemesis', tier: 'elite', placeholderFrame: 22, hp: [108, 116], color: 0x9aa7b5,
moves: [
{ id: 'scythe', intent: 'attack', value: 6, times: 3, effects: [{ op: 'damage', amount: 6, times: 3 }] },
{ id: 'scorch', intent: 'attackdebuff', value: 7, effects: [{ op: 'damage', amount: 7 }, { op: 'addCardToDiscard', card: 'burn', amount: 2 }] },
{ id: 'guard', intent: 'defend', block: 22, effects: [{ op: 'blockSelf', amount: 22 }] },
],
ai: 'random',
},
giant: {
id: 'giant', name: 'Giant Head', tier: 'elite', placeholderFrame: 13, hp: [140, 150], color: 0x8a7a4a,
moves: [
{ id: 'glare', intent: 'debuff', effects: [{ op: 'debuffPlayer', status: 'weak', amount: 3 }] },
{ id: 'grow', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'strength', amount: 4 }, { op: 'blockSelf', amount: 10 }] },
{ id: 'smash', intent: 'attack', value: 24, effects: [{ op: 'damage', amount: 24 }] },
],
ai: 'sequence',
},
// —— boss ——
awakened: {
id: 'awakened', name: 'The Awakened One', tier: 'boss', placeholderFrame: 13, hp: [300, 300], color: 0xc94f8a,
moves: [
{ id: 'ritual', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'ritual', amount: 2 }, { op: 'buffSelfEnemy', status: 'strength', amount: 2 }] },
{ id: 'slash', intent: 'attack', value: 14, effects: [{ op: 'damage', amount: 14 }] },
{ id: 'darkstrike', intent: 'attack', value: 8, times: 2, effects: [{ op: 'damage', amount: 8, times: 2 }] },
{ id: 'rebirth', intent: 'defend', block: 20, effects: [{ op: 'blockSelf', amount: 20 }, { op: 'buffSelfEnemy', status: 'strength', amount: 3 }] },
],
ai: 'sequence',
},
}; };
export function enemiesByTier(tier) { export function enemiesByTier(tier) {
return Object.values(ENEMIES).filter((e) => e.tier === tier); return Object.values(ENEMIES).filter((e) => e.tier === tier);
} }
// ── ENCOUNTER TABLES (which enemy groups appear) ────────────────────────────── // ── ENCOUNTER TABLES (per act) ────────────────────────────────────────────────
// The run climbs three acts; each act has its own enemy roster + boss, escalating
// in difficulty. encounterForNode() picks from ENCOUNTERS[run.act][tier].
export const TOTAL_ACTS = 3;
export const ACT_NAMES = {
1: 'The Crumbling Base',
2: 'The Sunken City',
3: "The Spire's Crown",
};
export const ENCOUNTERS = { export const ENCOUNTERS = {
normal: [ 1: {
['jawworm'], normal: [
['cultist'], ['jawworm'], ['cultist'], ['louse', 'louse'], ['fungi', 'fungi'],
['louse', 'louse'], ['spikeslime'], ['louse', 'spikeslime'], ['cultist', 'louse'],
['fungi', 'fungi'], ],
['spikeslime'], elite: [['gremlinnob'], ['lagavulin'], ['sentry', 'sentry', 'sentry']],
['louse', 'spikeslime'], boss: [['guardian'], ['slimeboss']],
['cultist', 'louse'], },
], 2: {
elite: [ normal: [
['gremlinnob'], ['centurion'], ['mystic', 'byrd'], ['snecko'], ['byrd', 'byrd'],
['lagavulin'], ['centurion', 'mystic'], ['snecko', 'byrd'],
['sentry', 'sentry', 'sentry'], ],
], elite: [['taskmaster'], ['bookwyrm'], ['taskmaster', 'byrd']],
boss: [ boss: [['automaton']],
['guardian'], },
['slimeboss'], 3: {
], normal: [
['darkling', 'darkling'], ['spiker', 'wraith'], ['maw'], ['wraith', 'wraith', 'wraith'],
['darkling', 'spiker'], ['maw', 'wraith'],
],
elite: [['nemesis'], ['giant'], ['nemesis', 'wraith']],
boss: [['awakened']],
},
}; };
// ── MAP / ACT TUNING ────────────────────────────────────────────────────────── // ── MAP / ACT TUNING ──────────────────────────────────────────────────────────

View File

@ -5,7 +5,7 @@ import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { api } from '../../services/api.js'; import { api } from '../../services/api.js';
import { import {
CLASSES, CARDS, RELICS, POTIONS, STATUS, ENEMIES, EVENTS, ACT, CLASSES, CARDS, RELICS, POTIONS, STATUS, ENEMIES, EVENTS, ACT, ACT_NAMES, TOTAL_ACTS,
} from './SpireClimbData.js'; } from './SpireClimbData.js';
import { import {
newRun, availableNodes, enterNode, nodeById, encounterForNode, newRun, availableNodes, enterNode, nodeById, encounterForNode,
@ -73,7 +73,7 @@ export default class SpireClimbGame extends Phaser.Scene {
this.handLayer = this.add.container(0, 0).setDepth(40); this.handLayer = this.add.container(0, 0).setDepth(40);
this.fxLayer = this.add.container(0, 0).setDepth(80); this.fxLayer = this.add.container(0, 0).setDepth(80);
this.drawBackdrop(); this._bgKey = undefined;
this.renderView(); this.renderView();
} }
@ -111,13 +111,45 @@ export default class SpireClimbGame extends Phaser.Scene {
} }
// ════════════════════════════════════════════════════════ helpers ══════════ // ════════════════════════════════════════════════════════ helpers ══════════
drawBackdrop() { // The backdrop is per-act: spireclimb-act{N}.png when present, else a gradient.
const g = this.add.graphics(); // Class-select / game-over keep the plain gradient. Only redraws when the
g.fillGradientStyle(C.bgTop, C.bgTop, C.bg, C.bg, 1); // resolved background actually changes (act change or entering/leaving a run).
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); updateBackdrop() {
// faint vignette const key = this.backdropKey();
g.fillStyle(0x000000, 0.25); g.fillRect(0, 0, GAME_WIDTH, 8); if (key === this._bgKey) return;
this.bgLayer.add(g); this._bgKey = key;
this.bgLayer.removeAll(true);
if (key) {
const img = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, key);
img.setScale(Math.max(GAME_WIDTH / img.width, GAME_HEIGHT / img.height)); // cover-fit
this.bgLayer.add(img);
const ov = this.add.graphics();
ov.fillStyle(0x0a0810, 0.32); ov.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); // darken for legibility
this.bgLayer.add(ov);
} else {
const g = this.add.graphics();
g.fillGradientStyle(C.bgTop, C.bgTop, C.bg, C.bg, 1);
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
g.fillStyle(0x000000, 0.25); g.fillRect(0, 0, GAME_WIDTH, 8);
this.bgLayer.add(g);
}
}
// Resolves the act background texture key, lazily loading it so future
// spireclimb-act{N}.png drop-ins work with no code change. Returns null (→
// gradient) when there's no run, on class-select/game-over, or no art yet.
backdropKey() {
if (!this.run || this.view === 'classselect' || this.view === 'gameover') return null;
const key = `spireclimb-act${this.run.act}`;
if (this.textures.exists(key)) return key;
this._bgTried = this._bgTried || {};
if (!this._bgTried[key]) {
this._bgTried[key] = true;
this.load.image(key, `/assets/images/${key}.png`);
this.load.once(`filecomplete-image-${key}`, () => { this._bgKey = '__force'; this.updateBackdrop(); });
this.load.start();
}
return null; // gradient until/unless it loads
} }
clearView() { clearView() {
@ -136,6 +168,7 @@ export default class SpireClimbGame extends Phaser.Scene {
} }
renderView() { renderView() {
this.updateBackdrop();
this.clearView(); this.clearView();
switch (this.view) { switch (this.view) {
case 'classselect': return this.renderClassSelect(); case 'classselect': return this.renderClassSelect();
@ -244,7 +277,8 @@ export default class SpireClimbGame extends Phaser.Scene {
renderMap() { renderMap() {
this.renderRunHud(); this.renderRunHud();
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
this.text(cx, 96, `The Spire — Floor ${this.run.floor}/${this.run.map.rows}`, 36, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 }); this.text(cx, 90, `Act ${this.run.act} / ${TOTAL_ACTS}${ACT_NAMES[this.run.act]}`, 36, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 });
this.text(cx, 128, `Floor ${this.run.floor}/${this.run.map.rows}`, 22, C.muted, { ox: 0.5, oy: 0.5 });
const grid = this.run.map.grid; const grid = this.run.map.grid;
const rows = this.run.map.rows; const rows = this.run.map.rows;
@ -1098,6 +1132,8 @@ export default class SpireClimbGame extends Phaser.Scene {
} }
this.pendingReward = outcome.rewards; this.pendingReward = outcome.rewards;
this.rewardTaken = { card: false, potion: false, relic: false }; this.rewardTaken = { card: false, potion: false, relic: false };
// Boss cleared but more acts to climb → flag the act transition for the map.
this._actCleared = !!outcome.actCleared;
this.setView('reward'); this.setView('reward');
} }
@ -1105,7 +1141,9 @@ export default class SpireClimbGame extends Phaser.Scene {
renderReward() { renderReward() {
this.renderRunHud(); this.renderRunHud();
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
this.text(cx, 110, 'Victory!', 56, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 }); const title = this._actCleared ? `Act ${this.run.act - 1} Cleared!` : 'Victory!';
this.text(cx, 110, title, 56, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 });
if (this._actCleared) this.text(cx, 158, `Ahead: Act ${this.run.act}${ACT_NAMES[this.run.act]}`, 24, C.muted, { ox: 0.5, oy: 0.5 });
const rw = this.pendingReward; const rw = this.pendingReward;
let y = 210; let y = 210;
@ -1130,12 +1168,13 @@ export default class SpireClimbGame extends Phaser.Scene {
if (!this.rewardTaken.card && rw.cards.length) { if (!this.rewardTaken.card && rw.cards.length) {
this.text(cx, y + 6, 'Add a card to your deck:', 26, C.ink, { ox: 0.5, oy: 0.5 }); y += 50; this.text(cx, y + 6, 'Add a card to your deck:', 26, C.ink, { ox: 0.5, oy: 0.5 }); y += 50;
const n = rw.cards.length; const n = rw.cards.length;
const gap = 200; const startX = cx - (n - 1) * gap / 2; const cardScale = 1.4;
const gap = 300; const startX = cx - (n - 1) * gap / 2;
rw.cards.forEach((cr, i) => { rw.cards.forEach((cr, i) => {
const inst = { uid: -1 - i, id: cr.id, upgraded: cr.upgraded }; const inst = { uid: -1 - i, id: cr.id, upgraded: cr.upgraded };
this.makeCardSprite(startX + i * gap, y + 130, inst, 0.92, { playable: true, onClick: () => { addCardToDeck(this.run, cr.id, cr.upgraded); this.rewardTaken.card = true; this.sfx(SFX.CARD_PLACE); this.renderView(); } }); this.makeCardSprite(startX + i * gap, y + 175, inst, cardScale, { playable: true, onClick: () => { addCardToDeck(this.run, cr.id, cr.upgraded); this.rewardTaken.card = true; this.sfx(SFX.CARD_PLACE); this.renderView(); } });
}); });
const skip = new Button(this, cx, y + 300, 'Skip card', () => { this.rewardTaken.card = true; this.renderView(); }, { width: 240, height: 50, variant: 'ghost' }); const skip = new Button(this, cx, y + 380, 'Skip card', () => { this.rewardTaken.card = true; this.renderView(); }, { width: 240, height: 50, variant: 'ghost' });
this.add2(skip); this.add2(skip);
} }
@ -1146,6 +1185,7 @@ export default class SpireClimbGame extends Phaser.Scene {
leaveReward() { leaveReward() {
this._goldCollected = false; this._goldCollected = false;
this.pendingReward = null; this.pendingReward = null;
if (this._actCleared) { this.eventToast = `Act ${this.run.act}${ACT_NAMES[this.run.act]}`; this._actCleared = false; }
this.setView('map'); this.setView('map');
} }
@ -1322,7 +1362,7 @@ export default class SpireClimbGame extends Phaser.Scene {
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
const win = this.run.victory; const win = this.run.victory;
this.text(cx, 240, win ? 'THE SPIRE IS YOURS' : 'YOU DIED', 72, win ? C.gold : '#c24040', { font: 'Righteous', ox: 0.5, oy: 0.5 }); this.text(cx, 240, win ? 'THE SPIRE IS YOURS' : 'YOU DIED', 72, win ? C.gold : '#c24040', { font: 'Righteous', ox: 0.5, oy: 0.5 });
this.text(cx, 340, win ? 'You climbed Spire Climb and slew its boss.' : `You fell on floor ${this.run.floor}.`, 28, C.muted, { ox: 0.5, oy: 0.5 }); this.text(cx, 340, win ? `You conquered all ${TOTAL_ACTS} acts of the Spire.` : `You fell in Act ${this.run.act}, on floor ${this.run.floor}.`, 28, C.muted, { ox: 0.5, oy: 0.5 });
this.text(cx, 410, `Cards in deck: ${this.run.deck.length} Relics: ${this.run.relics.length} Gold: ${this.run.gold}`, 24, C.ink, { ox: 0.5, oy: 0.5 }); this.text(cx, 410, `Cards in deck: ${this.run.deck.length} Relics: ${this.run.relics.length} Gold: ${this.run.gold}`, 24, C.ink, { ox: 0.5, oy: 0.5 });
const again = new Button(this, cx - 180, 560, 'New Run', () => { this.init({ game: this.gameDef }); this.clearView(); this.renderView(); }, { width: 300, height: 76 }); const again = new Button(this, cx - 180, 560, 'New Run', () => { this.init({ game: this.gameDef }); this.clearView(); this.renderView(); }, { width: 300, height: 76 });
const menu = new Button(this, cx + 180, 560, 'Back to Menu', () => this.scene.start('GameMenu'), { width: 300, height: 76, variant: 'ghost' }); const menu = new Button(this, cx + 180, 560, 'Back to Menu', () => this.scene.start('GameMenu'), { width: 300, height: 76, variant: 'ghost' });
@ -1333,7 +1373,8 @@ export default class SpireClimbGame extends Phaser.Scene {
try { try {
api.post('/history/single-player', { api.post('/history/single-player', {
game: 'spireclimb', won: victory, game: 'spireclimb', won: victory,
score: this.run.floor, detail: { className: this.run.className, floor: this.run.floor }, score: (this.run.act - 1) * this.run.map.rows + this.run.floor,
detail: { className: this.run.className, act: this.run.act, floor: this.run.floor },
}).catch(() => {}); }).catch(() => {});
} catch (_) {} } catch (_) {}
} }
@ -1347,7 +1388,7 @@ export default class SpireClimbGame extends Phaser.Scene {
this.add2(g); this.add2(g);
this.renderBar(40, 36, 240, 26, this.run.hp, this.run.maxHp, this.run.hp <= this.run.maxHp * 0.3 ? C.hpLow : C.hp); this.renderBar(40, 36, 240, 26, this.run.hp, this.run.maxHp, this.run.hp <= this.run.maxHp * 0.3 ? C.hpLow : C.hp);
this.text(320, 36, `${this.run.gold} g`, 26, C.gold, { ox: 0, oy: 0 }); this.text(320, 36, `${this.run.gold} g`, 26, C.gold, { ox: 0, oy: 0 });
this.text(GAME_WIDTH / 2, 49, `${CLASSES[this.run.className].name} · Floor ${this.run.floor}`, 22, C.muted, { ox: 0.5, oy: 0.5 }); this.text(GAME_WIDTH / 2, 49, `${CLASSES[this.run.className].name} · Act ${this.run.act} · Floor ${this.run.floor}`, 22, C.muted, { ox: 0.5, oy: 0.5 });
// relics // relics
this.run.relics.forEach((rid, i) => { this.run.relics.forEach((rid, i) => {
const x = GAME_WIDTH - 60 - i * 52, y = 48; const x = GAME_WIDTH - 60 - i * 52, y = 48;

View File

@ -7,7 +7,7 @@
import { import {
CLASSES, CARDS, RELICS, POTIONS, POTION_IDS, ENEMIES, ENCOUNTERS, EVENTS, CLASSES, CARDS, RELICS, POTIONS, POTION_IDS, ENEMIES, ENCOUNTERS, EVENTS,
ACT, cardPoolFor, relicPool, ACT, TOTAL_ACTS, cardPoolFor, relicPool,
} from './SpireClimbData.js'; } from './SpireClimbData.js';
// ── RNG (mulberry32, seedable & serializable) ──────────────────────────────── // ── RNG (mulberry32, seedable & serializable) ────────────────────────────────
@ -169,10 +169,11 @@ export function enterNode(run, nodeId) {
// ── COMBAT ─────────────────────────────────────────────────────────────────── // ── COMBAT ───────────────────────────────────────────────────────────────────
export function encounterForNode(run, node, rng) { export function encounterForNode(run, node, rng) {
const act = ENCOUNTERS[run.act] || ENCOUNTERS[1];
let table; let table;
if (node.type === 'boss') table = ENCOUNTERS.boss; if (node.type === 'boss') table = act.boss;
else if (node.type === 'elite') table = ENCOUNTERS.elite; else if (node.type === 'elite') table = act.elite;
else table = ENCOUNTERS.normal; else table = act.normal;
return rng.pick(table); return rng.pick(table);
} }
@ -645,8 +646,23 @@ export function settleCombat(combat, node, rng) {
return { result: 'lost' }; return { result: 'lost' };
} }
// won // won
if (node && node.type === 'boss') { run.finished = true; run.victory = true; } const rewards = rollRewards(run, node, rng);
return { result: 'won', rewards: rollRewards(run, node, rng) }; let actCleared = false;
if (node && node.type === 'boss') {
if (run.act >= TOTAL_ACTS) { run.finished = true; run.victory = true; } // final boss → run won
else { advanceAct(run, rng); actCleared = true; } // climb to the next act
}
return { result: 'won', rewards, actCleared };
}
// Move the run into its next act: fresh map, back to floor 0. Keeps hp, deck,
// gold, relics, potions.
export function advanceAct(run, rng) {
run.act += 1;
run.map = generateMap(rng);
run.currentNodeId = null;
run.floor = 0;
run.visited = [];
} }
export function rollRewards(run, node, rng) { export function rollRewards(run, node, rng) {

View File

@ -21,9 +21,9 @@ left-to-right then down to the next row.
| | | | | |
|---|---| |---|---|
| **Path** | `public/assets/images/spireclimb-creatures.png` | | **Path** | `public/assets/images/spireclimb-creatures.png` |
| **Sheet size** | **1500 × 600 px** | | **Sheet size** | **1800 × 1200 px** |
| **Frame size** | **300 × 300 px** | | **Frame size** | **300 × 300 px** |
| **Layout** | 5 columns × 2 rows = 10 frames | | **Layout** | 6 columns × 4 rows = 24 frames |
| **Status** | ✅ Placeholder ships now (regenerate with `node genSpireClimbCreatures.js`) | | **Status** | ✅ Placeholder ships now (regenerate with `node genSpireClimbCreatures.js`) |
| **JSON** | `creatureSheet` (path already set) + `creatures` map | | **JSON** | `creatureSheet` (path already set) + `creatures` map |
@ -43,11 +43,26 @@ on the battlefield cleanly — but an opaque square also works.
| 5 | `sentry` | Sentry | elite | mechanical orb | | 5 | `sentry` | Sentry | elite | mechanical orb |
| 6 | `gremlinnob` | Gremlin Nob | elite | big horned brute | | 6 | `gremlinnob` | Gremlin Nob | elite | big horned brute |
| 7 | `lagavulin` | Lagavulin | elite | armored sleeper | | 7 | `lagavulin` | Lagavulin | elite | armored sleeper |
| 8 | `guardian` | The Guardian | **boss** | huge construct | | 8 | `guardian` | The Guardian | **boss** (Act 1) | huge construct |
| 9 | `slimeboss` | Slime Boss | **boss** | giant slime | | 9 | `slimeboss` | Slime Boss | **boss** (Act 1 alt) | giant slime |
| 10 | `centurion` | Centurion | elite-ish normal (Act 2) | armored |
| 11 | `mystic` | Mystic | normal (Act 2) | caster, buffs/heals |
| 12 | `byrd` | Byrd | normal (Act 2) | flurry flyer |
| 13 | `snecko` | Snecko | normal (Act 2) | applies Weak |
| 14 | `taskmaster` | Taskmaster | elite (Act 2) | shuffles Wounds |
| 15 | `bookwyrm` | Book of Stabbing | elite (Act 2) | multi-hit |
| 16 | `automaton` | Bronze Automaton | **boss** (Act 2) | hyperbeam |
| 17 | `darkling` | Darkling | normal (Act 3) | |
| 18 | `spiker` | Spiker | normal (Act 3) | gains block |
| 19 | `maw` | The Maw | normal (Act 3) | Weak+Frail, big slam |
| 20 | `wraith` | Spire Wraith | normal (Act 3) | fast |
| 21 | `nemesis` | Nemesis | elite (Act 3) | burn cards |
| 22 | `giant` | Giant Head | elite (Act 3) | huge |
| 23 | `awakened` | The Awakened One | **boss** (Act 3, final) | Ritual + Strength |
> Tip: bosses read better a little bigger/more detailed since they fill more of > Frames 09 = Act 1, 1016 = Act 2, 1723 = Act 3. Bosses (8/9, 16, 23) read
> the screen. Keep the same 300×300 cell — the scale-to-fit handles the rest. > better a little bigger/more detailed. Keep the same 300×300 cell — scale-to-fit
> handles the rest.
--- ---
@ -135,9 +150,24 @@ into an **existing** sheet rather than a new file.
--- ---
## 4. Act backgrounds — `spireclimb-act{N}.png`
| | |
|---|---|
| **Path** | `public/assets/images/spireclimb-act1.png`, `…-act2.png`, `…-act3.png` |
| **Size** | **1920 × 1080 px** (full canvas; cover-fit, so 16:9 fills exactly) |
| **Status** | Act 1 ✅ shipped. Acts 23 are drop-in. |
Shown behind every in-run view of that act (map + combat + reward/shop/etc.),
under a ~32% dark overlay for UI legibility. Class-select and game-over keep the
plain gradient. Drop in `spireclimb-act2.png` / `spireclimb-act3.png` with the
same name and they load automatically (act 1 is preloaded; later acts lazy-load
on first entry) — no code change needed. Missing acts just fall back to the
gradient.
## Quick checklist ## Quick checklist
- [ ] `spireclimb-creatures.png` — 1500×600, 10 × (300×300). *(placeholder exists; replace to taste)* - [ ] `spireclimb-creatures.png` — 1800×1200, 24 × (300×300). *(placeholder exists; replace to taste)*
- [ ] `spireclimb-cards.png` — 250×160 frames, 34 cards; then set `cardSheet.path` in `spireclimb-artwork.json`. - [ ] `spireclimb-cards.png` — 250×160 frames, 34 cards; then set `cardSheet.path` in `spireclimb-artwork.json`.
- [ ] `game-icons.png` frame 74 — 44×44 menu icon. - [ ] `game-icons.png` frame 74 — 44×44 menu icon.

View File

@ -73,6 +73,9 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('katamino', '/data/katamino.json'); this.load.json('katamino', '/data/katamino.json');
this.load.json('bookwork', '/data/bookwork.json'); this.load.json('bookwork', '/data/bookwork.json');
this.load.json('spireclimb-artwork', '/data/spireclimb-artwork.json'); this.load.json('spireclimb-artwork', '/data/spireclimb-artwork.json');
this.load.image('spireclimb-act1', '/assets/images/spireclimb-act1.png');
this.load.image('spireclimb-act2', '/assets/images/spireclimb-act2.png');
this.load.image('spireclimb-act3', '/assets/images/spireclimb-act3.png');
this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3'); this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3');
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3'); this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');

View File

@ -10,7 +10,7 @@ import {
rollRewards, generateShop, rollCardChoices, rollRelic, addCardToDeck, rollRewards, generateShop, rollCardChoices, rollRelic, addCardToDeck,
upgradeCardInDeck, addRelic, resolvedCard, playableHand, nodeById, upgradeCardInDeck, addRelic, resolvedCard, playableHand, nodeById,
} from './public/src/games/spireclimb/SpireClimbLogic.js'; } from './public/src/games/spireclimb/SpireClimbLogic.js';
import { CLASSES, CARDS, ENEMIES, ENCOUNTERS, ACT } from './public/src/games/spireclimb/SpireClimbData.js'; import { CLASSES, CARDS, ENEMIES, ENCOUNTERS, ACT, TOTAL_ACTS, ACT_NAMES } from './public/src/games/spireclimb/SpireClimbData.js';
let pass = 0, fail = 0; let pass = 0, fail = 0;
const fails = []; const fails = [];
@ -31,9 +31,13 @@ for (const [eid, e] of Object.entries(ENEMIES)) {
ok(e.hp[0] <= e.hp[1], `enemy ${eid} hp range ordered`); ok(e.hp[0] <= e.hp[1], `enemy ${eid} hp range ordered`);
ok(Number.isInteger(e.placeholderFrame), `enemy ${eid} has placeholderFrame`); ok(Number.isInteger(e.placeholderFrame), `enemy ${eid} has placeholderFrame`);
} }
for (const tier of ['normal', 'elite', 'boss']) { ok(ACT_NAMES && Object.keys(ACT_NAMES).length >= TOTAL_ACTS, 'act names defined for every act');
ok(ENCOUNTERS[tier].length > 0, `encounter table ${tier} non-empty`); for (let act = 1; act <= TOTAL_ACTS; act++) {
for (const grp of ENCOUNTERS[tier]) for (const id of grp) ok(!!ENEMIES[id], `encounter enemy ${id} exists`); ok(!!ENCOUNTERS[act], `act ${act} encounter table exists`);
for (const tier of ['normal', 'elite', 'boss']) {
ok(ENCOUNTERS[act][tier].length > 0, `act ${act} ${tier} non-empty`);
for (const grp of ENCOUNTERS[act][tier]) for (const id of grp) ok(!!ENEMIES[id], `act ${act} encounter enemy ${id} exists`);
}
} }
// ── 2. map generation ── // ── 2. map generation ──
@ -71,21 +75,23 @@ function runCombat(className, enemyIds, seed) {
let playerWins = 0, total = 0, neverLooped = true; let playerWins = 0, total = 0, neverLooped = true;
for (const className of ['warrior', 'rogue']) { for (const className of ['warrior', 'rogue']) {
for (const tier of ['normal', 'elite', 'boss']) { for (let act = 1; act <= TOTAL_ACTS; act++) {
for (const grp of ENCOUNTERS[tier]) { for (const tier of ['normal', 'elite', 'boss']) {
for (let s = 0; s < 12; s++) { for (const grp of ENCOUNTERS[act][tier]) {
const { combat, looped } = runCombat(className, grp, s + 1); for (let s = 0; s < 6; s++) {
if (looped) neverLooped = false; const { combat, looped } = runCombat(className, grp, s + 1 + act * 100);
const res = isCombatOver(combat); if (looped) neverLooped = false;
ok(res === 'won' || res === 'lost', `${className} vs ${grp.join('+')} seed ${s} resolved (${res})`); const res = isCombatOver(combat);
// hp invariants ok(res === 'won' || res === 'lost', `act${act} ${className} vs ${grp.join('+')} seed ${s} resolved (${res})`);
ok(combat.player.hp <= combat.player.maxHp, `${className} hp <= max (${grp.join('+')})`); // hp invariants
combat.enemies.forEach((e) => ok(e.hp <= e.maxHp, `enemy hp<=max ${e.name}`)); ok(combat.player.hp <= combat.player.maxHp, `${className} hp <= max (${grp.join('+')})`);
if (res === 'won') { combat.enemies.forEach((e) => ok(e.hp <= e.maxHp, `enemy hp<=max ${e.name}`));
combat.enemies.forEach((e) => ok(!e.alive, `won => all dead ${e.name}`)); if (res === 'won') {
playerWins++; combat.enemies.forEach((e) => ok(!e.alive, `won => all dead ${e.name}`));
playerWins++;
}
total++;
} }
total++;
} }
} }
} }
@ -130,6 +136,46 @@ for (const cls of Object.values(CLASSES)) {
ok(cls.startingDeck.length >= 10, `${cls.id} starter deck size`); ok(cls.startingDeck.length >= 10, `${cls.id} starter deck size`);
} }
// ── 7. three-act progression: clearing each boss advances acts; final boss wins ──
{
const run = newRun('warrior', 555);
ok(run.act === 1, 'run starts in act 1');
const startDeckLen = run.deck.length;
for (let act = 1; act <= TOTAL_ACTS; act++) {
ok(run.act === act, `run is in act ${act}`);
ok(ENCOUNTERS[act].boss.length > 0, `act ${act} boss table non-empty`);
const rng = makeRng(act * 13 + 1);
run.hp = run.maxHp;
const combat = startCombat(run, ENCOUNTERS[act].boss[0], act * 7 + 1);
combat.enemies.forEach((e) => { e.hp = 0; e.alive = false; }); // force a boss kill
combat.phase = 'won';
const out = settleCombat(combat, { type: 'boss' }, rng);
ok(out.result === 'won', `act ${act} boss settle = won`);
ok(Array.isArray(out.rewards.cards) && out.rewards.cards.length > 0, `act ${act} boss gives card reward`);
if (act < TOTAL_ACTS) {
ok(out.actCleared === true, `act ${act} boss flags actCleared`);
ok(run.finished === false, `act ${act} cleared but run not finished`);
ok(run.act === act + 1, `advanced into act ${act + 1}`);
ok(run.currentNodeId === null && run.floor === 0, `act ${act + 1} starts at a fresh map`);
ok(run.map.grid[run.map.rows - 1][0].type === 'boss', `act ${act + 1} map tops out in a boss`);
} else {
ok(run.finished === true && run.victory === true, 'final boss → run victory');
}
}
ok(run.deck.length >= startDeckLen, 'deck persists across acts');
}
// every enemy referenced by an encounter has creature art mapped (or a placeholder)
import { readFileSync } from 'node:fs';
{
const art = JSON.parse(readFileSync('./public/data/spireclimb-artwork.json', 'utf8'));
const used = new Set();
for (let act = 1; act <= TOTAL_ACTS; act++)
for (const tier of ['normal', 'elite', 'boss'])
for (const grp of ENCOUNTERS[act][tier]) for (const id of grp) used.add(id);
for (const id of used) ok(art.creatures[id] != null, `creature art frame mapped for ${id}`);
}
// ── report ── // ── report ──
console.log(`\nSpire Climb verification`); console.log(`\nSpire Climb verification`);
console.log(` combats auto-played: ${total} (player win rate ${(100 * playerWins / total).toFixed(0)}%)`); console.log(` combats auto-played: ${total} (player win rate ${(100 * playerWins / total).toFixed(0)}%)`);