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.
59 lines
2.0 KiB
TypeScript
59 lines
2.0 KiB
TypeScript
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';
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|