Scaffold RogueHunter: roguelike deck-builder on Phaser 4 + React

Vite + TypeScript + React 19 shell hosting a Phaser 4 game canvas,
structured as a Slay the Spire-style single-player deck-builder.

Architecture - strict core/render split:
- core/ holds all game rules as pure TypeScript with zero Phaser
  imports, so the domain is deterministic and engine-agnostic:
  - cards/      data-driven card model, library, and starter deck
  - combat/     turn-based CombatEngine (energy, block, draw/discard/
                exhaust piles, enemy intents) + enemy definitions
  - run/        RunManager (deck/hp/gold) and seeded MapGenerator
  - shared/rng  seedable mulberry32 for reproducible runs
- game/ is the Phaser layer: it renders core state and forwards input
  - scenes/     Menu -> Map -> Combat -> Reward -> GameOver
  - components/ CardSprite, CombatantSprite, Button
  - events/     typed EventBus bridging combat/run state to a React HUD
  - config/     centralized scene keys, colors, fonts, layout constants

Playable loop: traverse a deterministic 10-node map, fight turn-based
card combat against enemies with telegraphed intents, pick a card
reward after each win, culminating in a boss.

Verified: npm run lint and npm run build pass.
This commit is contained in:
Nicolas Civade
2026-08-12 22:10:32 +02:00
parent 10e5e982dd
commit c55e0751e6
41 changed files with 5523 additions and 2 deletions

40
src/game/PhaserGame.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { useEffect, useRef } from "react";
import * as Phaser from "phaser";
import { scenes } from "./scenes";
import { PhaserConfig } from "./config";
const PhaserGame = () => {
const gameRef = useRef<Phaser.Game | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (gameRef.current) return;
if (!containerRef.current) return;
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
parent: containerRef.current,
backgroundColor: PhaserConfig.backgroundColor,
scene: scenes,
scale: {
mode: Phaser.Scale.RESIZE,
autoCenter: Phaser.Scale.CENTER_BOTH,
width: '100%',
height: '100%',
},
// No physics: this is a turn-based card game. Combat is resolved by
// the pure-TS CombatEngine, not by an Arcade/Matter world.
};
gameRef.current = new Phaser.Game(config);
return () => {
gameRef.current?.destroy(true);
gameRef.current = null;
};
}, []);
return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
};
export default PhaserGame;

View File

@@ -0,0 +1,38 @@
import * as Phaser from 'phaser';
import {Colors, Fonts} from "../../constant/ui.config.ts";
interface ButtonConfig {
scene: Phaser.Scene;
x: number;
y: number;
label: string;
onClick: () => void;
}
export class Button extends Phaser.GameObjects.Container {
private background: Phaser.GameObjects.Rectangle;
constructor({ scene, x, y, label, onClick }: ButtonConfig) {
super(scene, x, y);
this.background = scene.add.rectangle(0, 0, 220, 60, Colors.buttonIdle);
const text = scene.add.text(0, 0, label, {
...Fonts.button,
color: Colors.text,
}).setOrigin(0.5);
this.add([this.background, text]);
scene.add.existing(this);
this.setSize(220, 60);
this.setInteractive();
this.on('pointerover', () => this.background.setFillStyle(Colors.buttonHover));
this.on('pointerout', () => this.background.setFillStyle(Colors.buttonIdle));
this.on('pointerdown', () => {
this.background.setFillStyle(Colors.buttonActive);
onClick();
});
this.on('pointerup', () => this.background.setFillStyle(Colors.buttonHover));
}
}

View File

@@ -0,0 +1,88 @@
import * as Phaser from 'phaser';
import { Colors, Fonts, CardLayout } from '../../config';
import type { CardData } from '../../../core/cards/card.types';
const TYPE_COLOR: Record<CardData['type'], number> = {
attack: Colors.cardAttack,
skill: Colors.cardSkill,
power: Colors.cardPower,
};
/**
* A single card in the player's hand. Presentation only — it emits a callback on
* click and reflects playability; it never mutates combat state itself.
*/
export class CardSprite extends Phaser.GameObjects.Container {
readonly handIndex: number;
private bg: Phaser.GameObjects.Rectangle;
private playable = true;
private baseY: number;
private readonly enabledColor: number;
constructor(params: {
scene: Phaser.Scene;
x: number;
y: number;
card: CardData;
handIndex: number;
onClick: (handIndex: number) => void;
}) {
const { scene, x, y, card, handIndex, onClick } = params;
super(scene, x, y);
this.handIndex = handIndex;
this.baseY = y;
this.enabledColor = TYPE_COLOR[card.type];
const { width, height } = CardLayout;
this.bg = scene.add
.rectangle(0, 0, width, height, this.enabledColor)
.setStrokeStyle(2, Colors.cardBorder);
const cost = scene.add
.circle(-width / 2 + 16, -height / 2 + 16, 14, Colors.cardBack)
.setStrokeStyle(2, Colors.energy);
const costText = scene.add
.text(-width / 2 + 16, -height / 2 + 16, String(card.cost), {
...Fonts.cardName,
color: '#ffcc44',
})
.setOrigin(0.5);
const name = scene.add
.text(0, -height / 2 + 34, card.name, {
...Fonts.cardName,
color: Colors.text,
})
.setOrigin(0.5);
const desc = scene.add
.text(0, 20, card.description, {
...Fonts.cardText,
color: Colors.text,
align: 'center',
wordWrap: { width: width - 20 },
})
.setOrigin(0.5);
this.add([this.bg, cost, costText, name, desc]);
scene.add.existing(this);
this.setSize(width, height);
this.setInteractive({ useHandCursor: true });
this.on('pointerover', () => {
if (this.playable) this.setY(this.baseY - 24);
});
this.on('pointerout', () => this.setY(this.baseY));
this.on('pointerdown', () => {
if (this.playable) onClick(this.handIndex);
});
}
setPlayable(value: boolean): void {
this.playable = value;
this.bg.setFillStyle(value ? this.enabledColor : Colors.cardDisabled);
this.setAlpha(value ? 1 : 0.6);
}
}

View File

@@ -0,0 +1,131 @@
import * as Phaser from 'phaser';
import { Colors, Fonts } from '../../config';
import type { Combatant, EnemyState, Intent } from '../../../core/combat/combat.types';
const BODY_W = 90;
const BODY_H = 120;
const BAR_W = 100;
const BAR_H = 12;
/**
* Renders one combatant: body, name, HP bar, block badge, and (for enemies) the
* telegraphed intent. Presentation only — it reads state, never mutates it.
* Enemies are clickable so the player can pick an attack target.
*/
export class CombatantSprite extends Phaser.GameObjects.Container {
readonly combatantId: string;
private bodyRect: Phaser.GameObjects.Rectangle;
private hpFill: Phaser.GameObjects.Rectangle;
private hpText: Phaser.GameObjects.Text;
private blockText: Phaser.GameObjects.Text;
private intentText?: Phaser.GameObjects.Text;
private maxHp: number;
constructor(params: {
scene: Phaser.Scene;
x: number;
y: number;
combatant: Combatant;
isEnemy: boolean;
onClick?: (id: string) => void;
}) {
const { scene, x, y, combatant, isEnemy, onClick } = params;
super(scene, x, y);
this.combatantId = combatant.id;
this.maxHp = combatant.maxHp;
this.bodyRect = scene.add
.rectangle(0, 0, BODY_W, BODY_H, isEnemy ? Colors.enemyTint : Colors.playerTint)
.setStrokeStyle(2, 0x000000);
const name = scene.add
.text(0, -BODY_H / 2 - 34, combatant.name, { ...Fonts.body, color: Colors.text })
.setOrigin(0.5);
// HP bar
const barY = BODY_H / 2 + 16;
scene.add.rectangle(0, barY, BAR_W, BAR_H, Colors.hpBarBg).setOrigin(0.5);
this.hpFill = scene.add
.rectangle(-BAR_W / 2, barY, BAR_W, BAR_H, Colors.hpBar)
.setOrigin(0, 0.5);
this.hpText = scene.add
.text(0, barY, '', { ...Fonts.cardText, color: Colors.text })
.setOrigin(0.5);
// Block badge (top-left of body)
this.blockText = scene.add
.text(-BODY_W / 2, -BODY_H / 2, '', { ...Fonts.stat, color: '#88bbff' })
.setOrigin(0.5);
this.add([this.bodyRect, name, this.hpFill, this.hpText, this.blockText]);
if (isEnemy) {
this.intentText = scene.add
.text(0, -BODY_H / 2 - 12, '', { ...Fonts.stat, color: Colors.intentAttack })
.setOrigin(0.5);
this.add(this.intentText);
}
scene.add.existing(this);
if (isEnemy && onClick) {
this.setSize(BODY_W, BODY_H);
this.setInteractive({ useHandCursor: true });
this.on('pointerover', () => this.bodyRect.setStrokeStyle(3, 0xffff66));
this.on('pointerout', () => this.bodyRect.setStrokeStyle(2, 0x000000));
this.on('pointerdown', () => onClick(this.combatantId));
}
this.sync(combatant);
}
/** Update visuals from current combatant state. Call after every engine step. */
sync(combatant: Combatant): void {
const ratio = Phaser.Math.Clamp(combatant.hp / this.maxHp, 0, 1);
this.hpFill.width = BAR_W * ratio;
this.hpText.setText(`${combatant.hp}/${combatant.maxHp}`);
this.blockText.setText(combatant.block > 0 ? `🛡${combatant.block}` : '');
}
/** Enemies only: render the telegraphed intent. */
syncIntent(intent: Intent): void {
if (!this.intentText) return;
this.intentText.setText(this.describeIntent(intent));
this.intentText.setColor(this.intentColor(intent));
}
private describeIntent(intent: Intent): string {
switch (intent.kind) {
case 'attack': {
const hits = intent.hits ?? 1;
const per = intent.value ?? 0;
return hits > 1 ? `${per}x${hits}` : `${per}`;
}
case 'block':
return '🛡';
case 'buff':
return '↑';
default:
return '?';
}
}
private intentColor(intent: Intent): string {
switch (intent.kind) {
case 'attack':
return Colors.intentAttack;
case 'block':
return Colors.intentBlock;
case 'buff':
return Colors.intentBuff;
default:
return Colors.textMuted;
}
}
/** Convenience for enemy sprites that carry an EnemyState. */
syncEnemy(enemy: EnemyState): void {
this.sync(enemy);
this.syncIntent(enemy.intent);
}
}

4
src/game/config/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { PhaserConfig } from './phaser.config';
export { SceneKeys } from './scene-keys';
export type { SceneKey } from './scene-keys';
export { Colors, Fonts, CardLayout } from '../constant/ui.config';

View File

@@ -0,0 +1,3 @@
export const PhaserConfig = {
backgroundColor: '#1a1420',
} as const;

View File

@@ -0,0 +1,9 @@
export const SceneKeys = {
MENU: 'MenuScene',
MAP: 'MapScene',
COMBAT: 'CombatScene',
REWARD: 'RewardScene',
GAME_OVER: 'GameOverScene',
} as const;
export type SceneKey = typeof SceneKeys[keyof typeof SceneKeys];

View File

@@ -0,0 +1,48 @@
export const Colors = {
text: '#ffffff',
textMuted: '#b8a8c8',
stroke: '#000000',
// Buttons
buttonIdle: 0x4a3a5a,
buttonHover: 0x6a5a7a,
buttonActive: 0x2a1a3a,
// Cards (by type)
cardAttack: 0x7a2a2a,
cardSkill: 0x2a4a7a,
cardPower: 0x7a6a2a,
cardBack: 0x3a2a4a,
cardBorder: 0xffffff,
cardDisabled: 0x333333,
// Combatants
playerTint: 0x44aa66,
enemyTint: 0xaa4455,
hpBar: 0xcc3344,
hpBarBg: 0x33161a,
blockBar: 0x4488cc,
energy: 0xffcc44,
// Intents
intentAttack: '#ff6655',
intentBlock: '#66aaff',
intentBuff: '#ffcc44',
} as const;
export const Fonts = {
title: { fontSize: '64px', fontFamily: 'Arial Black' },
heading: { fontSize: '28px', fontFamily: 'Arial Black' },
button: { fontSize: '32px', fontFamily: 'Arial' },
body: { fontSize: '18px', fontFamily: 'Arial' },
cardName: { fontSize: '16px', fontFamily: 'Arial Black' },
cardText: { fontSize: '13px', fontFamily: 'Arial' },
stat: { fontSize: '20px', fontFamily: 'Arial Black' },
} as const;
/** Shared card face dimensions, referenced by CardSprite and layout math. */
export const CardLayout = {
width: 120,
height: 168,
spacing: 12,
} as const;

View File

@@ -0,0 +1,54 @@
/**
* Typed event bus bridging the game (Phaser + core engine) and the React HUD.
* Core/Phaser emit; React hooks subscribe. Keep the map in sync with the HUD's needs.
*/
type EventMap = {
// Combat HUD
'combat:started': { enemyName: string; floor: number };
'combat:state-changed': {
playerHp: number;
playerMaxHp: number;
playerBlock: number;
energy: number;
maxEnergy: number;
handCount: number;
drawCount: number;
discardCount: number;
};
'combat:won': { floor: number };
'combat:lost': undefined;
// Run / map
'run:started': { seed: number };
'run:node-changed': { floor: number; type: string };
'run:ended': { victory: boolean; floor: number; gold: number };
};
type EventKey = keyof EventMap;
type Listener<K extends EventKey> = (data: EventMap[K]) => void;
class TypedEventEmitter {
private listeners = new Map<EventKey, Set<Listener<EventKey>>>();
on<K extends EventKey>(event: K, listener: Listener<K>): () => void {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(listener as Listener<EventKey>);
return () => this.off(event, listener);
}
off<K extends EventKey>(event: K, listener: Listener<K>): void {
this.listeners.get(event)?.delete(listener as Listener<EventKey>);
}
emit<K extends EventKey>(event: K, data?: EventMap[K]): void {
this.listeners.get(event)?.forEach(l => l(data as EventMap[EventKey]));
}
removeAll(event?: EventKey): void {
if (event) this.listeners.delete(event);
else this.listeners.clear();
}
}
export const EventBus = new TypedEventEmitter();
export type { EventMap };

View File

@@ -0,0 +1,227 @@
import * as Phaser from 'phaser';
import { SceneKeys, Colors, Fonts, CardLayout } from '../../config';
import { EventBus } from '../../events/EventBus';
import { Button } from '../../components/button/Button';
import { CardSprite } from '../../components/card/CardSprite';
import { CombatantSprite } from '../../components/combatant/CombatantSprite';
import { CombatEngine } from '../../../core/combat/combat-engine';
import { getCard } from '../../../core/cards/card-library';
/** Data passed into the scene when an encounter starts. */
export interface CombatSceneData {
seed: number;
floor: number;
playerHp: number;
playerMaxHp: number;
deck: string[];
enemyIds: string[];
}
/**
* The card-combat scene. Owns a CombatEngine instance and renders its state.
* All rules live in the engine; this scene handles input, layout, and redraw.
*/
export class CombatScene extends Phaser.Scene {
private engine!: CombatEngine;
private sceneData!: CombatSceneData;
private playerSprite!: CombatantSprite;
private enemySprites = new Map<string, CombatantSprite>();
private cardSprites: CardSprite[] = [];
private energyText!: Phaser.GameObjects.Text;
private selectedTarget?: string;
constructor() {
super({ key: SceneKeys.COMBAT });
}
init(data: CombatSceneData): void {
this.sceneData = data;
}
create(): void {
this.engine = new CombatEngine({
seed: this.sceneData.seed,
playerHp: this.sceneData.playerHp,
playerMaxHp: this.sceneData.playerMaxHp,
deck: this.sceneData.deck,
enemyIds: this.sceneData.enemyIds,
});
this.buildStaticUi();
this.buildCombatants();
this.redraw();
EventBus.emit('combat:started', {
enemyName: this.engine.state.enemies[0]?.name ?? 'Enemy',
floor: this.sceneData.floor,
});
const onResize = () => this.relayout();
this.scale.on('resize', onResize);
this.events.once('shutdown', () => this.scale.off('resize', onResize));
}
// ---- Layout ----------------------------------------------------------
private buildStaticUi(): void {
const w = this.scale.width;
this.add.text(w / 2, 30, `Floor ${this.sceneData.floor}`, {
...Fonts.heading,
color: Colors.text,
}).setOrigin(0.5);
this.energyText = this.add.text(60, this.scale.height - 90, '', {
...Fonts.stat,
color: '#ffcc44',
}).setOrigin(0.5);
new Button({
scene: this,
x: w - 110,
y: this.scale.height - 90,
label: 'END TURN',
onClick: () => this.onEndTurn(),
});
}
private buildCombatants(): void {
const w = this.scale.width;
const midY = this.scale.height * 0.4;
this.playerSprite = new CombatantSprite({
scene: this,
x: w * 0.22,
y: midY,
combatant: this.engine.state.player,
isEnemy: false,
});
this.engine.state.enemies.forEach((enemy, i) => {
const sprite = new CombatantSprite({
scene: this,
x: w * 0.62 + i * 160,
y: midY,
combatant: enemy,
isEnemy: true,
onClick: id => this.onSelectTarget(id),
});
sprite.syncEnemy(enemy);
this.enemySprites.set(enemy.id, sprite);
});
this.selectedTarget = this.engine.state.enemies[0]?.id;
}
private relayout(): void {
// Simple approach: rebuild the scene on resize. Combat state persists in
// the engine, so this is safe and keeps layout math in one place.
this.scene.restart(this.sceneData);
}
// ---- Input handlers --------------------------------------------------
private onSelectTarget(id: string): void {
this.selectedTarget = id;
this.redraw();
}
private onPlayCard(handIndex: number): void {
const played = this.engine.playCard(handIndex, this.selectedTarget);
if (!played) return;
// A killed target may have been the selection — re-point at a survivor.
if (!this.engine.state.enemies.some(e => e.id === this.selectedTarget)) {
this.selectedTarget = this.engine.state.enemies[0]?.id;
}
this.redraw();
this.checkEndState();
}
private onEndTurn(): void {
this.engine.endTurn();
this.redraw();
this.checkEndState();
}
// ---- Redraw ----------------------------------------------------------
private redraw(): void {
const s = this.engine.state;
this.playerSprite.sync(s.player);
// Prune sprites for dead enemies, sync the rest.
for (const [id, sprite] of this.enemySprites) {
const enemy = s.enemies.find(e => e.id === id);
if (!enemy) {
sprite.destroy();
this.enemySprites.delete(id);
} else {
sprite.syncEnemy(enemy);
}
}
this.energyText.setText(`${s.energy}/${s.maxEnergy}`);
this.drawHand();
this.emitHud();
}
private drawHand(): void {
this.cardSprites.forEach(c => c.destroy());
this.cardSprites = [];
const s = this.engine.state;
const { width, spacing } = CardLayout;
const step = width + spacing;
const total = s.piles.hand.length;
const startX = this.scale.width / 2 - ((total - 1) * step) / 2;
const y = this.scale.height - 110;
s.piles.hand.forEach((cardId, i) => {
const card = getCard(cardId);
const sprite = new CardSprite({
scene: this,
x: startX + i * step,
y,
card,
handIndex: i,
onClick: idx => this.onPlayCard(idx),
});
sprite.setPlayable(s.phase === 'player' && card.cost <= s.energy);
this.cardSprites.push(sprite);
});
}
private emitHud(): void {
const s = this.engine.state;
EventBus.emit('combat:state-changed', {
playerHp: s.player.hp,
playerMaxHp: s.player.maxHp,
playerBlock: s.player.block,
energy: s.energy,
maxEnergy: s.maxEnergy,
handCount: s.piles.hand.length,
drawCount: s.piles.draw.length,
discardCount: s.piles.discard.length,
});
}
// ---- Win / loss ------------------------------------------------------
private checkEndState(): void {
const phase = this.engine.state.phase;
if (phase === 'won') {
EventBus.emit('combat:won', { floor: this.sceneData.floor });
this.scene.start(SceneKeys.REWARD, {
floor: this.sceneData.floor,
playerHp: this.engine.state.player.hp,
});
} else if (phase === 'lost') {
EventBus.emit('combat:lost');
this.scene.start(SceneKeys.GAME_OVER, { floor: this.sceneData.floor });
}
}
}

View File

@@ -0,0 +1,73 @@
import * as Phaser from 'phaser';
import { SceneKeys, Colors, Fonts } from '../../config';
import { Button } from '../../components/button/Button';
interface GameOverData {
floor?: number;
victory?: boolean;
}
export class GameOverScene extends Phaser.Scene {
private floor = 1;
private victory = false;
constructor() {
super({ key: SceneKeys.GAME_OVER });
}
init(data: GameOverData): void {
this.floor = data.floor ?? 1;
this.victory = data.victory ?? false;
}
create(): void {
this.layout();
const onResize = () => this.layout();
this.scale.on('resize', onResize);
this.events.once('shutdown', () => this.scale.off('resize', onResize));
}
private layout(): void {
this.children.removeAll(true);
const cx = this.scale.width / 2;
const cy = this.scale.height / 2;
this.add.text(cx, cy - 140, this.victory ? 'RUN COMPLETE' : 'DEFEATED', {
...Fonts.title,
color: this.victory ? '#66cc88' : '#ff4444',
stroke: Colors.stroke,
strokeThickness: 6,
}).setOrigin(0.5);
this.add.text(cx, cy - 60,
this.victory
? `You conquered all ${this.floor} floors.`
: `You fell on floor ${this.floor}.`,
{ ...Fonts.button, color: Colors.text, stroke: Colors.stroke, strokeThickness: 3 },
).setOrigin(0.5);
new Button({
scene: this,
x: cx,
y: cy + 40,
label: 'NEW RUN',
onClick: () => {
this.registry.remove('run');
this.scene.start(SceneKeys.MAP);
},
});
new Button({
scene: this,
x: cx,
y: cy + 120,
label: 'MAIN MENU',
onClick: () => {
this.registry.remove('run');
this.scene.start(SceneKeys.MENU);
},
});
}
}

7
src/game/scenes/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import { MenuScene } from './menu/MenuScene';
import { MapScene } from './map/MapScene';
import { CombatScene } from './combat/CombatScene';
import { RewardScene } from './reward/RewardScene';
import { GameOverScene } from './gameover/GameOverScene';
export const scenes = [MenuScene, MapScene, CombatScene, RewardScene, GameOverScene];

View File

@@ -0,0 +1,121 @@
import * as Phaser from 'phaser';
import { SceneKeys, Colors, Fonts } from '../../config';
import { EventBus } from '../../events/EventBus';
import { Button } from '../../components/button/Button';
import { RunManager } from '../../../core/run/RunManager';
import type { CombatSceneData } from '../combat/CombatScene';
/**
* The run map. Owns the RunManager (the source of truth for deck/hp/progression)
* and dispatches each node to the appropriate scene. The RunManager is stashed on
* the registry so combat/reward scenes can read and mutate run state.
*/
export class MapScene extends Phaser.Scene {
private run!: RunManager;
constructor() {
super({ key: SceneKeys.MAP });
}
init(data: { run?: RunManager }): void {
// Reuse an in-progress run when returning from a reward, else start fresh.
this.run = data.run ?? this.registry.get('run') ?? new RunManager();
this.registry.set('run', this.run);
}
create(): void {
if (this.run.get().nodeIndex === 0) {
EventBus.emit('run:started', { seed: this.run.get().seed });
}
this.layout();
const onResize = () => this.layout();
this.scale.on('resize', onResize);
this.events.once('shutdown', () => this.scale.off('resize', onResize));
}
private layout(): void {
this.children.removeAll(true);
const cx = this.scale.width / 2;
const node = this.run.currentNode();
const state = this.run.get();
EventBus.emit('run:node-changed', { floor: node.floor, type: node.type });
this.add.text(cx, 80, 'THE PATH', {
...Fonts.title,
color: Colors.text,
stroke: Colors.stroke,
strokeThickness: 6,
}).setOrigin(0.5);
this.add.text(cx, 160,
`Floor ${node.floor} / ${this.run.map.nodes.length} • HP ${state.hp}/${state.maxHp} • Gold ${state.gold}`,
{ ...Fonts.body, color: Colors.textMuted },
).setOrigin(0.5);
const label = this.nodeLabel(node.type);
this.add.text(cx, 260, label, {
...Fonts.heading,
color: Colors.text,
}).setOrigin(0.5);
new Button({
scene: this,
x: cx,
y: 360,
label: node.type === 'rest' ? 'REST' : 'ENTER',
onClick: () => this.enterNode(),
});
}
private nodeLabel(type: string): string {
switch (type) {
case 'combat': return '⚔ Enemy Encounter';
case 'elite': return '☠ Elite Fight';
case 'boss': return '👑 Boss';
case 'rest': return '🔥 Rest Site (heal 30%)';
default: return type;
}
}
private enterNode(): void {
const node = this.run.currentNode();
const state = this.run.get();
if (node.type === 'rest') {
this.run.heal(Math.floor(state.maxHp * 0.3));
this.proceed();
return;
}
const combatData: CombatSceneData = {
seed: state.seed + node.floor,
floor: node.floor,
playerHp: state.hp,
playerMaxHp: state.maxHp,
deck: [...state.deck],
enemyIds: [...node.enemyIds],
};
this.scene.start(SceneKeys.COMBAT, combatData);
}
/** Advance to the next node, or end the run if the map is complete. */
private proceed(): void {
const advanced = this.run.advance();
if (!advanced) {
EventBus.emit('run:ended', {
victory: true,
floor: this.run.currentNode().floor,
gold: this.run.get().gold,
});
this.scene.start(SceneKeys.GAME_OVER, {
floor: this.run.currentNode().floor,
victory: true,
});
return;
}
this.layout();
}
}

View File

@@ -0,0 +1,88 @@
import * as Phaser from 'phaser';
import { Button } from '../../components/button/Button.ts';
import { Colors, Fonts, SceneKeys } from '../../config';
export class MenuScene extends Phaser.Scene {
private titleTween?: Phaser.Tweens.Tween;
constructor() {
super({ key: SceneKeys.MENU });
}
preload() {
this.load.image('logo', '/logo.png');
}
create() {
this.layout();
const onResize = () => this.layout();
this.scale.on('resize', onResize);
this.events.once('shutdown', () => this.scale.off('resize', onResize));
}
private layout() {
this.titleTween?.remove();
this.children.removeAll(true);
const cx = this.scale.width / 2;
const cy = this.scale.height / 2;
this.createTitle(cx, cy);
this.createLogo(cx, cy);
this.createButtons(cx, cy);
}
private createTitle(cx: number, cy: number) {
const title = this.add.text(cx, cy - 200, 'ROGUE HUNTER', {
...Fonts.title,
color: Colors.text,
stroke: Colors.stroke,
strokeThickness: 6,
align: 'center',
}).setOrigin(0.5);
this.titleTween = this.tweens.add({
targets: title,
scale: { from: 0.8, to: 1.1 },
duration: 2000,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
});
}
private createLogo(cx: number, cy: number) {
this.add.image(cx, cy - 100, 'logo').setScale(0.1);
}
private createButtons(cx: number, cy: number) {
new Button({
scene: this,
x: cx,
y: cy + 50,
label: 'START GAME',
onClick: () => {
// Clear any prior run so a new deck/map is generated.
this.registry.remove('run');
this.scene.start(SceneKeys.MAP);
},
});
new Button({
scene: this,
x: cx,
y: cy + 130,
label: 'OPTIONS',
onClick: () => console.log('Options clicked'),
});
new Button({
scene: this,
x: cx,
y: cy + 210,
label: 'CREDITS',
onClick: () => console.log('Credits clicked'),
});
}
}

View File

@@ -0,0 +1,120 @@
import * as Phaser from 'phaser';
import { SceneKeys, Colors, Fonts, CardLayout } from '../../config';
import { Button } from '../../components/button/Button';
import { CardSprite } from '../../components/card/CardSprite';
import { RunManager } from '../../../core/run/RunManager';
import { Rng } from '../../../shared/rng';
import { CARD_LIBRARY, getCard } from '../../../core/cards/card-library';
interface RewardData {
floor: number;
playerHp: number;
}
const GOLD_PER_WIN = 15;
/** Cards eligible as rewards (exclude starter-only cards). */
const REWARD_POOL = Object.values(CARD_LIBRARY)
.filter(c => c.rarity !== 'starter')
.map(c => c.id);
/**
* Post-combat reward. Applies hp/gold to the run, then offers a card choice —
* this is the deck-building step. Choosing (or skipping) returns to the map.
*/
export class RewardScene extends Phaser.Scene {
private run!: RunManager;
private sceneData!: RewardData;
private offered: string[] = [];
constructor() {
super({ key: SceneKeys.REWARD });
}
init(data: RewardData): void {
this.sceneData = data;
this.run = this.registry.get('run') as RunManager;
}
create(): void {
// Persist combat outcome into the run.
this.run.setHp(this.sceneData.playerHp);
this.run.addGold(GOLD_PER_WIN);
// Roll three distinct card offers deterministically from the run seed.
const rng = new Rng(this.run.get().seed + this.sceneData.floor * 31);
this.offered = this.rollOffers(rng, 3);
this.layout();
const onResize = () => this.layout();
this.scale.on('resize', onResize);
this.events.once('shutdown', () => this.scale.off('resize', onResize));
}
private rollOffers(rng: Rng, count: number): string[] {
const pool = [...REWARD_POOL];
rng.shuffle(pool);
return pool.slice(0, Math.min(count, pool.length));
}
private layout(): void {
this.children.removeAll(true);
const cx = this.scale.width / 2;
this.add.text(cx, 70, 'VICTORY', {
...Fonts.title,
color: '#66cc88',
stroke: Colors.stroke,
strokeThickness: 6,
}).setOrigin(0.5);
this.add.text(cx, 140, `+${GOLD_PER_WIN} gold • Choose a card to add`, {
...Fonts.body,
color: Colors.textMuted,
}).setOrigin(0.5);
const { width, spacing } = CardLayout;
const step = width + spacing * 2;
const startX = cx - ((this.offered.length - 1) * step) / 2;
const y = this.scale.height / 2;
this.offered.forEach((cardId, i) => {
new CardSprite({
scene: this,
x: startX + i * step,
y,
card: getCard(cardId),
handIndex: i,
onClick: idx => this.pickCard(this.offered[idx]),
});
});
new Button({
scene: this,
x: cx,
y: this.scale.height - 90,
label: 'SKIP',
onClick: () => this.returnToMap(),
});
}
private pickCard(cardId: string): void {
this.run.addCard(cardId);
this.returnToMap();
}
private returnToMap(): void {
// Advance past the node we just cleared, then hand control back to the map.
const advanced = this.run.advance();
if (!advanced) {
// That was the final node (boss) — the run is won.
this.scene.start(SceneKeys.GAME_OVER, {
floor: this.run.currentNode().floor,
victory: true,
});
return;
}
this.scene.start(SceneKeys.MAP, { run: this.run });
}
}