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:
40
src/game/PhaserGame.tsx
Normal file
40
src/game/PhaserGame.tsx
Normal 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;
|
||||
38
src/game/components/button/Button.ts
Normal file
38
src/game/components/button/Button.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
88
src/game/components/card/CardSprite.ts
Normal file
88
src/game/components/card/CardSprite.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
131
src/game/components/combatant/CombatantSprite.ts
Normal file
131
src/game/components/combatant/CombatantSprite.ts
Normal 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
4
src/game/config/index.ts
Normal 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';
|
||||
3
src/game/config/phaser.config.ts
Normal file
3
src/game/config/phaser.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const PhaserConfig = {
|
||||
backgroundColor: '#1a1420',
|
||||
} as const;
|
||||
9
src/game/config/scene-keys.ts
Normal file
9
src/game/config/scene-keys.ts
Normal 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];
|
||||
48
src/game/constant/ui.config.ts
Normal file
48
src/game/constant/ui.config.ts
Normal 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;
|
||||
54
src/game/events/EventBus.ts
Normal file
54
src/game/events/EventBus.ts
Normal 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 };
|
||||
227
src/game/scenes/combat/CombatScene.ts
Normal file
227
src/game/scenes/combat/CombatScene.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/game/scenes/gameover/GameOverScene.ts
Normal file
73
src/game/scenes/gameover/GameOverScene.ts
Normal 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
7
src/game/scenes/index.ts
Normal 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];
|
||||
121
src/game/scenes/map/MapScene.ts
Normal file
121
src/game/scenes/map/MapScene.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
88
src/game/scenes/menu/MenuScene.ts
Normal file
88
src/game/scenes/menu/MenuScene.ts
Normal 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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
120
src/game/scenes/reward/RewardScene.ts
Normal file
120
src/game/scenes/reward/RewardScene.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user