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`).
|
|
@ -15,11 +15,13 @@
|
|||
import zlib from 'node:zlib';
|
||||
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;
|
||||
|
||||
// id → { frame, color, shape, tier } (mirrors SpireClimbData ENEMIES placeholders)
|
||||
// id → { frame, color, shape, tier } (mirrors SpireClimbData ENEMIES placeholders).
|
||||
// 24 frames, row-major: frames 0–9 Act 1, 10–16 Act 2, 17–23 Act 3.
|
||||
const CREATURES = [
|
||||
// —— Act 1 ——
|
||||
{ id: 'jawworm', frame: 0, color: [0x9c, 0x6b, 0x3a], shape: 'worm', tier: 1 },
|
||||
{ id: 'cultist', frame: 1, color: [0x6a, 0x4d, 0x8a], shape: 'robed', tier: 1 },
|
||||
{ id: 'louse', frame: 2, color: [0xc0, 0x53, 0x3a], shape: 'bug', tier: 1 },
|
||||
|
|
@ -30,6 +32,22 @@ const CREATURES = [
|
|||
{ id: 'lagavulin', frame: 7, color: [0x3a, 0x7a, 0x6b], shape: 'slime', tier: 2 },
|
||||
{ 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
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 273 KiB After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 57 KiB |
|
|
@ -31,6 +31,10 @@
|
|||
"creatures": {
|
||||
"jawworm": 0, "cultist": 1, "louse": 2, "fungi": 3, "spikeslime": 4,
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -395,32 +395,178 @@ export const ENEMIES = {
|
|||
],
|
||||
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) {
|
||||
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 = {
|
||||
1: {
|
||||
normal: [
|
||||
['jawworm'],
|
||||
['cultist'],
|
||||
['louse', 'louse'],
|
||||
['fungi', 'fungi'],
|
||||
['spikeslime'],
|
||||
['louse', 'spikeslime'],
|
||||
['cultist', 'louse'],
|
||||
['jawworm'], ['cultist'], ['louse', 'louse'], ['fungi', 'fungi'],
|
||||
['spikeslime'], ['louse', 'spikeslime'], ['cultist', 'louse'],
|
||||
],
|
||||
elite: [
|
||||
['gremlinnob'],
|
||||
['lagavulin'],
|
||||
['sentry', 'sentry', 'sentry'],
|
||||
elite: [['gremlinnob'], ['lagavulin'], ['sentry', 'sentry', 'sentry']],
|
||||
boss: [['guardian'], ['slimeboss']],
|
||||
},
|
||||
2: {
|
||||
normal: [
|
||||
['centurion'], ['mystic', 'byrd'], ['snecko'], ['byrd', 'byrd'],
|
||||
['centurion', 'mystic'], ['snecko', 'byrd'],
|
||||
],
|
||||
boss: [
|
||||
['guardian'],
|
||||
['slimeboss'],
|
||||
elite: [['taskmaster'], ['bookwyrm'], ['taskmaster', 'byrd']],
|
||||
boss: [['automaton']],
|
||||
},
|
||||
3: {
|
||||
normal: [
|
||||
['darkling', 'darkling'], ['spiker', 'wraith'], ['maw'], ['wraith', 'wraith', 'wraith'],
|
||||
['darkling', 'spiker'], ['maw', 'wraith'],
|
||||
],
|
||||
elite: [['nemesis'], ['giant'], ['nemesis', 'wraith']],
|
||||
boss: [['awakened']],
|
||||
},
|
||||
};
|
||||
|
||||
// ── MAP / ACT TUNING ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { playSound, SFX } from '../../ui/Sounds.js';
|
|||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import {
|
||||
CLASSES, CARDS, RELICS, POTIONS, STATUS, ENEMIES, EVENTS, ACT,
|
||||
CLASSES, CARDS, RELICS, POTIONS, STATUS, ENEMIES, EVENTS, ACT, ACT_NAMES, TOTAL_ACTS,
|
||||
} from './SpireClimbData.js';
|
||||
import {
|
||||
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.fxLayer = this.add.container(0, 0).setDepth(80);
|
||||
|
||||
this.drawBackdrop();
|
||||
this._bgKey = undefined;
|
||||
this.renderView();
|
||||
}
|
||||
|
||||
|
|
@ -111,14 +111,46 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════ helpers ══════════
|
||||
drawBackdrop() {
|
||||
// The backdrop is per-act: spireclimb-act{N}.png when present, else a gradient.
|
||||
// Class-select / game-over keep the plain gradient. Only redraws when the
|
||||
// resolved background actually changes (act change or entering/leaving a run).
|
||||
updateBackdrop() {
|
||||
const key = this.backdropKey();
|
||||
if (key === this._bgKey) return;
|
||||
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);
|
||||
// faint vignette
|
||||
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() {
|
||||
// Visual reset only — does NOT touch targeting state, so re-rendering the
|
||||
|
|
@ -136,6 +168,7 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
renderView() {
|
||||
this.updateBackdrop();
|
||||
this.clearView();
|
||||
switch (this.view) {
|
||||
case 'classselect': return this.renderClassSelect();
|
||||
|
|
@ -244,7 +277,8 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
renderMap() {
|
||||
this.renderRunHud();
|
||||
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 rows = this.run.map.rows;
|
||||
|
|
@ -1098,6 +1132,8 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
}
|
||||
this.pendingReward = outcome.rewards;
|
||||
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');
|
||||
}
|
||||
|
||||
|
|
@ -1105,7 +1141,9 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
renderReward() {
|
||||
this.renderRunHud();
|
||||
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;
|
||||
let y = 210;
|
||||
|
||||
|
|
@ -1130,12 +1168,13 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
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;
|
||||
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) => {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -1146,6 +1185,7 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
leaveReward() {
|
||||
this._goldCollected = false;
|
||||
this.pendingReward = null;
|
||||
if (this._actCleared) { this.eventToast = `Act ${this.run.act} — ${ACT_NAMES[this.run.act]}`; this._actCleared = false; }
|
||||
this.setView('map');
|
||||
}
|
||||
|
||||
|
|
@ -1322,7 +1362,7 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
const cx = GAME_WIDTH / 2;
|
||||
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, 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 });
|
||||
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' });
|
||||
|
|
@ -1333,7 +1373,8 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
try {
|
||||
api.post('/history/single-player', {
|
||||
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 (_) {}
|
||||
}
|
||||
|
|
@ -1347,7 +1388,7 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
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.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
|
||||
this.run.relics.forEach((rid, i) => {
|
||||
const x = GAME_WIDTH - 60 - i * 52, y = 48;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import {
|
||||
CLASSES, CARDS, RELICS, POTIONS, POTION_IDS, ENEMIES, ENCOUNTERS, EVENTS,
|
||||
ACT, cardPoolFor, relicPool,
|
||||
ACT, TOTAL_ACTS, cardPoolFor, relicPool,
|
||||
} from './SpireClimbData.js';
|
||||
|
||||
// ── RNG (mulberry32, seedable & serializable) ────────────────────────────────
|
||||
|
|
@ -169,10 +169,11 @@ export function enterNode(run, nodeId) {
|
|||
|
||||
// ── COMBAT ───────────────────────────────────────────────────────────────────
|
||||
export function encounterForNode(run, node, rng) {
|
||||
const act = ENCOUNTERS[run.act] || ENCOUNTERS[1];
|
||||
let table;
|
||||
if (node.type === 'boss') table = ENCOUNTERS.boss;
|
||||
else if (node.type === 'elite') table = ENCOUNTERS.elite;
|
||||
else table = ENCOUNTERS.normal;
|
||||
if (node.type === 'boss') table = act.boss;
|
||||
else if (node.type === 'elite') table = act.elite;
|
||||
else table = act.normal;
|
||||
return rng.pick(table);
|
||||
}
|
||||
|
||||
|
|
@ -645,8 +646,23 @@ export function settleCombat(combat, node, rng) {
|
|||
return { result: 'lost' };
|
||||
}
|
||||
// won
|
||||
if (node && node.type === 'boss') { run.finished = true; run.victory = true; }
|
||||
return { result: 'won', rewards: rollRewards(run, node, rng) };
|
||||
const 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) {
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ left-to-right then down to the next row.
|
|||
| | |
|
||||
|---|---|
|
||||
| **Path** | `public/assets/images/spireclimb-creatures.png` |
|
||||
| **Sheet size** | **1500 × 600 px** |
|
||||
| **Sheet size** | **1800 × 1200 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`) |
|
||||
| **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 |
|
||||
| 6 | `gremlinnob` | Gremlin Nob | elite | big horned brute |
|
||||
| 7 | `lagavulin` | Lagavulin | elite | armored sleeper |
|
||||
| 8 | `guardian` | The Guardian | **boss** | huge construct |
|
||||
| 9 | `slimeboss` | Slime Boss | **boss** | giant slime |
|
||||
| 8 | `guardian` | The Guardian | **boss** (Act 1) | huge construct |
|
||||
| 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
|
||||
> the screen. Keep the same 300×300 cell — the scale-to-fit handles the rest.
|
||||
> Frames 0–9 = Act 1, 10–16 = Act 2, 17–23 = Act 3. Bosses (8/9, 16, 23) read
|
||||
> 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 2–3 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
|
||||
|
||||
- [ ] `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`.
|
||||
- [ ] `game-icons.png` frame 74 — 44×44 menu icon.
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
this.load.json('katamino', '/data/katamino.json');
|
||||
this.load.json('bookwork', '/data/bookwork.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-sink', '/assets/fx/water-sink.mp3');
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
rollRewards, generateShop, rollCardChoices, rollRelic, addCardToDeck,
|
||||
upgradeCardInDeck, addRelic, resolvedCard, playableHand, nodeById,
|
||||
} 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;
|
||||
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(Number.isInteger(e.placeholderFrame), `enemy ${eid} has placeholderFrame`);
|
||||
}
|
||||
for (const tier of ['normal', 'elite', 'boss']) {
|
||||
ok(ENCOUNTERS[tier].length > 0, `encounter table ${tier} non-empty`);
|
||||
for (const grp of ENCOUNTERS[tier]) for (const id of grp) ok(!!ENEMIES[id], `encounter enemy ${id} exists`);
|
||||
ok(ACT_NAMES && Object.keys(ACT_NAMES).length >= TOTAL_ACTS, 'act names defined for every act');
|
||||
for (let act = 1; act <= TOTAL_ACTS; act++) {
|
||||
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 ──
|
||||
|
|
@ -71,13 +75,14 @@ function runCombat(className, enemyIds, seed) {
|
|||
|
||||
let playerWins = 0, total = 0, neverLooped = true;
|
||||
for (const className of ['warrior', 'rogue']) {
|
||||
for (let act = 1; act <= TOTAL_ACTS; act++) {
|
||||
for (const tier of ['normal', 'elite', 'boss']) {
|
||||
for (const grp of ENCOUNTERS[tier]) {
|
||||
for (let s = 0; s < 12; s++) {
|
||||
const { combat, looped } = runCombat(className, grp, s + 1);
|
||||
for (const grp of ENCOUNTERS[act][tier]) {
|
||||
for (let s = 0; s < 6; s++) {
|
||||
const { combat, looped } = runCombat(className, grp, s + 1 + act * 100);
|
||||
if (looped) neverLooped = false;
|
||||
const res = isCombatOver(combat);
|
||||
ok(res === 'won' || res === 'lost', `${className} vs ${grp.join('+')} seed ${s} resolved (${res})`);
|
||||
ok(res === 'won' || res === 'lost', `act${act} ${className} vs ${grp.join('+')} seed ${s} resolved (${res})`);
|
||||
// hp invariants
|
||||
ok(combat.player.hp <= combat.player.maxHp, `${className} hp <= max (${grp.join('+')})`);
|
||||
combat.enemies.forEach((e) => ok(e.hp <= e.maxHp, `enemy hp<=max ${e.name}`));
|
||||
|
|
@ -89,6 +94,7 @@ for (const className of ['warrior', 'rogue']) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ok(neverLooped, 'no combat hit the turn-guard (possible infinite loop)');
|
||||
ok(playerWins > 0, 'player wins at least some auto-played combats');
|
||||
|
|
@ -130,6 +136,46 @@ for (const cls of Object.values(CLASSES)) {
|
|||
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 ──
|
||||
console.log(`\nSpire Climb verification`);
|
||||
console.log(` combats auto-played: ${total} (player win rate ${(100 * playerWins / total).toFixed(0)}%)`);
|
||||
|
|
|
|||