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

View File

@@ -0,0 +1,58 @@
import { Rng } from '../../shared/rng';
import { ENEMY_DEFINITIONS } from '../combat/enemy-definitions';
import type { MapNode, RunMap, EncounterType } from './run-map.types';
const NORMAL_ENEMIES = ['slime', 'cultist', 'jaw_worm'];
const ELITE_ENEMIES = ['brute'];
const BOSS_ENEMIES = ['brute'];
/**
* Generates a linear run map from a seed. Deterministic: the same seed always
* yields the same path. Replaces the old platformer DungeonSeeder.
*/
export class MapGenerator {
generate(seed: number, length = 10): RunMap {
const rng = new Rng(seed);
const nodes: MapNode[] = [];
for (let floor = 1; floor <= length; floor++) {
const type = this.pickType(floor, length, rng);
nodes.push({
id: `node-${floor}`,
floor,
type,
enemyIds: this.pickEnemies(type, floor, rng),
});
}
return { seed, nodes };
}
private pickType(floor: number, length: number, rng: Rng): EncounterType {
if (floor === length) return 'boss';
if (floor === 1) return 'combat';
if (floor % 4 === 0) return 'rest';
if (rng.next() > 0.8) return 'elite';
return 'combat';
}
private pickEnemies(type: EncounterType, floor: number, rng: Rng): string[] {
switch (type) {
case 'rest':
return [];
case 'boss':
return [rng.pick(BOSS_ENEMIES)];
case 'elite':
return [rng.pick(ELITE_ENEMIES)];
case 'combat': {
// Scale group size gently with depth.
const count = 1 + (floor > 5 && rng.next() > 0.5 ? 1 : 0);
return Array.from({ length: count }, () => {
const id = rng.pick(NORMAL_ENEMIES);
// Guard against a bad id sneaking in from future edits.
return ENEMY_DEFINITIONS[id] ? id : 'slime';
});
}
}
}
}