diff --git a/genSpireClimbCreatures.js b/genSpireClimbCreatures.js index f8bff29..685aee0 100644 --- a/genSpireClimbCreatures.js +++ b/genSpireClimbCreatures.js @@ -15,21 +15,39 @@ 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 = [ - { 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 }, - { id: 'fungi', frame: 3, color: [0x4b, 0x8c, 0x5a], shape: 'shroom',tier: 1 }, - { id: 'spikeslime', frame: 4, color: [0x4a, 0x6b, 0xbf], shape: 'spiky', tier: 1 }, - { id: 'sentry', frame: 5, color: [0x9a, 0xa7, 0xb5], shape: 'orb', tier: 2 }, - { id: 'gremlinnob', frame: 6, color: [0xb5, 0x50, 0x3a], shape: 'horns', tier: 2 }, - { 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 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 }, + { id: 'fungi', frame: 3, color: [0x4b, 0x8c, 0x5a], shape: 'shroom',tier: 1 }, + { id: 'spikeslime', frame: 4, color: [0x4a, 0x6b, 0xbf], shape: 'spiky', tier: 1 }, + { id: 'sentry', frame: 5, color: [0x9a, 0xa7, 0xb5], shape: 'orb', tier: 2 }, + { id: 'gremlinnob', frame: 6, color: [0xb5, 0x50, 0x3a], shape: 'horns', tier: 2 }, + { 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 diff --git a/public/assets/images/game-icons (1).png b/public/assets/images/game-icons (1).png new file mode 100644 index 0000000..5d954da Binary files /dev/null and b/public/assets/images/game-icons (1).png differ diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 04c9e70..5d954da 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index 91ea068..eee41ae 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ diff --git a/public/assets/images/spireclimb-act1.png b/public/assets/images/spireclimb-act1.png new file mode 100644 index 0000000..1a802b3 Binary files /dev/null and b/public/assets/images/spireclimb-act1.png differ diff --git a/public/assets/images/spireclimb-act2.png b/public/assets/images/spireclimb-act2.png new file mode 100644 index 0000000..f0ea2fe Binary files /dev/null and b/public/assets/images/spireclimb-act2.png differ diff --git a/public/assets/images/spireclimb-act3.png b/public/assets/images/spireclimb-act3.png new file mode 100644 index 0000000..6515f97 Binary files /dev/null and b/public/assets/images/spireclimb-act3.png differ diff --git a/public/assets/images/spireclimb-cards.png b/public/assets/images/spireclimb-cards.png index 4de3197..f917290 100644 Binary files a/public/assets/images/spireclimb-cards.png and b/public/assets/images/spireclimb-cards.png differ diff --git a/public/assets/images/spireclimb-cards.psd b/public/assets/images/spireclimb-cards.psd index 9e1186e..b557d91 100644 Binary files a/public/assets/images/spireclimb-cards.psd and b/public/assets/images/spireclimb-cards.psd differ diff --git a/public/assets/images/spireclimb-creatures.png b/public/assets/images/spireclimb-creatures.png index 9dc0c96..63c21fa 100644 Binary files a/public/assets/images/spireclimb-creatures.png and b/public/assets/images/spireclimb-creatures.png differ diff --git a/public/data/spireclimb-artwork.json b/public/data/spireclimb-artwork.json index 33e407a..b90432a 100644 --- a/public/data/spireclimb-artwork.json +++ b/public/data/spireclimb-artwork.json @@ -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 } } diff --git a/public/src/games/spireclimb/SpireClimbData.js b/public/src/games/spireclimb/SpireClimbData.js index a777a8a..18494a9 100644 --- a/public/src/games/spireclimb/SpireClimbData.js +++ b/public/src/games/spireclimb/SpireClimbData.js @@ -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 = { - normal: [ - ['jawworm'], - ['cultist'], - ['louse', 'louse'], - ['fungi', 'fungi'], - ['spikeslime'], - ['louse', 'spikeslime'], - ['cultist', 'louse'], - ], - elite: [ - ['gremlinnob'], - ['lagavulin'], - ['sentry', 'sentry', 'sentry'], - ], - boss: [ - ['guardian'], - ['slimeboss'], - ], + 1: { + normal: [ + ['jawworm'], ['cultist'], ['louse', 'louse'], ['fungi', 'fungi'], + ['spikeslime'], ['louse', 'spikeslime'], ['cultist', 'louse'], + ], + elite: [['gremlinnob'], ['lagavulin'], ['sentry', 'sentry', 'sentry']], + boss: [['guardian'], ['slimeboss']], + }, + 2: { + normal: [ + ['centurion'], ['mystic', 'byrd'], ['snecko'], ['byrd', 'byrd'], + ['centurion', 'mystic'], ['snecko', 'byrd'], + ], + 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 ────────────────────────────────────────────────────────── diff --git a/public/src/games/spireclimb/SpireClimbGame.js b/public/src/games/spireclimb/SpireClimbGame.js index 4746981..aba5f0e 100644 --- a/public/src/games/spireclimb/SpireClimbGame.js +++ b/public/src/games/spireclimb/SpireClimbGame.js @@ -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,13 +111,45 @@ export default class SpireClimbGame extends Phaser.Scene { } // ════════════════════════════════════════════════════════ helpers ══════════ - drawBackdrop() { - 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); + // 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); + 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() { @@ -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; diff --git a/public/src/games/spireclimb/SpireClimbLogic.js b/public/src/games/spireclimb/SpireClimbLogic.js index 4b53d04..48a99ad 100644 --- a/public/src/games/spireclimb/SpireClimbLogic.js +++ b/public/src/games/spireclimb/SpireClimbLogic.js @@ -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) { diff --git a/public/src/games/spireclimb/sprites.md b/public/src/games/spireclimb/sprites.md index 7c8491a..01282d2 100644 --- a/public/src/games/spireclimb/sprites.md +++ b/public/src/games/spireclimb/sprites.md @@ -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. diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index 63ae7a8..23dd10e 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -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'); diff --git a/verifySpireClimb.js b/verifySpireClimb.js index 7c3e652..cf7e42a 100644 --- a/verifySpireClimb.js +++ b/verifySpireClimb.js @@ -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,21 +75,23 @@ function runCombat(className, enemyIds, seed) { let playerWins = 0, total = 0, neverLooped = true; for (const className of ['warrior', 'rogue']) { - 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); - if (looped) neverLooped = false; - const res = isCombatOver(combat); - ok(res === 'won' || res === 'lost', `${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}`)); - if (res === 'won') { - combat.enemies.forEach((e) => ok(!e.alive, `won => all dead ${e.name}`)); - playerWins++; + for (let act = 1; act <= TOTAL_ACTS; act++) { + for (const tier of ['normal', 'elite', 'boss']) { + 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', `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}`)); + if (res === 'won') { + 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`); } +// ── 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)}%)`);