Compare commits

...

4 Commits

Author SHA1 Message Date
Nicolas Civade
0fbe7ffb8b Drop Docker HEALTHCHECK (Swarm reschedule loop under Dokploy) 2026-08-13 11:55:02 +02:00
Nicolas Civade
3f0c87924b Inject React DevTools hook only in dev via Vite plugin 2026-08-13 11:27:53 +02:00
Nicolas Civade
5c7276cf35 Add Docker deployment for Dokploy (nginx static serve) 2026-08-13 11:25:31 +02:00
Nicolas Civade
c55e0751e6 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.
2026-08-12 22:10:32 +02:00
44 changed files with 5607 additions and 2 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
node_modules
dist
dist-ssr
.vite
.git
.github
.idea
.vscode
.claude
*.log
npm-debug.log*
.DS_Store
Dockerfile
.dockerignore
README.md
LICENSE

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
/.idea/

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
# ---- Build stage ----
FROM node:22-alpine AS build
WORKDIR /app
# Install dependencies against the lockfile for reproducible builds
COPY package.json package-lock.json ./
RUN npm ci
# Build the static site (tsc -b && vite build -> /app/dist)
COPY . .
RUN npm run build
# ---- Serve stage ----
FROM nginx:1.27-alpine AS runtime
# SPA-aware nginx config (history fallback + asset caching)
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Static output from the build stage
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,2 +1,75 @@
# RogueHunter # React + TypeScript + Vite
RogueHunter is a fangame of monter hunter where you battle against monsters in order to save ecosystems. Build your deck update your cards and hunt endlessly
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
Note: This will impact Vite dev & build performances.
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RogueHunter</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip text assets
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
# Hashed build assets are immutable -> cache aggressively
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA history fallback: unknown routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
# Never cache the HTML entrypoint so new deploys are picked up immediately
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
}

3463
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "roguehunter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"phaser": "^4.0.0",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@eslint/js": "^9.39.4",
"@rolldown/plugin-babel": "^0.2.2",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}

2
src/app/App.css Normal file
View File

@@ -0,0 +1,2 @@

12
src/app/App.tsx Normal file
View File

@@ -0,0 +1,12 @@
import PhaserGame from "../game/PhaserGame.tsx";
function App() {
return (
<div id="game-container">
<PhaserGame/>
</div>
);
}
export default App;

View File

@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react';
import { EventBus } from '../../game/events/EventBus';
interface CombatHud {
playerHp: number;
playerMaxHp: number;
playerBlock: number;
energy: number;
maxEnergy: number;
handCount: number;
drawCount: number;
discardCount: number;
}
const EMPTY_HUD: CombatHud = {
playerHp: 0,
playerMaxHp: 0,
playerBlock: 0,
energy: 0,
maxEnergy: 0,
handCount: 0,
drawCount: 0,
discardCount: 0,
};
/** Live combat HUD state, driven by the CombatScene via the EventBus. */
export function useCombatHud() {
const [hud, setHud] = useState<CombatHud>(EMPTY_HUD);
useEffect(() => {
return EventBus.on('combat:state-changed', setHud);
}, []);
return hud;
}
/** Current floor/encounter, driven by run + combat events. */
export function useRunProgress() {
const [floor, setFloor] = useState(1);
useEffect(() => {
const unsubNode = EventBus.on('run:node-changed', d => setFloor(d.floor));
const unsubCombat = EventBus.on('combat:started', d => setFloor(d.floor));
return () => { unsubNode(); unsubCombat(); };
}, []);
return floor;
}

18
src/app/index.css Normal file
View File

@@ -0,0 +1,18 @@
*, *::before, *::after {
box-sizing: border-box;
}
html, body, #root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
#game-container {
width: 100%;
height: 100%;
}

View File

View File

@@ -0,0 +1,93 @@
import type { CardData } from './card.types';
/**
* Central card library. All card definitions live here, keyed by id.
* Content is data — add cards by adding entries, not code paths.
*/
export const CARD_LIBRARY: Record<string, CardData> = {
strike: {
id: 'strike',
name: 'Strike',
type: 'attack',
rarity: 'starter',
cost: 1,
target: 'enemy',
description: 'Deal 6 damage.',
effect: { damage: 6 },
},
defend: {
id: 'defend',
name: 'Defend',
type: 'skill',
rarity: 'starter',
cost: 1,
target: 'self',
description: 'Gain 5 block.',
effect: { block: 5 },
},
bash: {
id: 'bash',
name: 'Bash',
type: 'attack',
rarity: 'starter',
cost: 2,
target: 'enemy',
description: 'Deal 10 damage.',
effect: { damage: 10 },
},
quick_slash: {
id: 'quick_slash',
name: 'Quick Slash',
type: 'attack',
rarity: 'common',
cost: 1,
target: 'enemy',
description: 'Deal 4 damage. Draw 1 card.',
effect: { damage: 4, draw: 1 },
},
cleave: {
id: 'cleave',
name: 'Cleave',
type: 'attack',
rarity: 'common',
cost: 1,
target: 'all-enemies',
description: 'Deal 8 damage to ALL enemies.',
effect: { damage: 8 },
},
flurry: {
id: 'flurry',
name: 'Flurry',
type: 'attack',
rarity: 'uncommon',
cost: 1,
target: 'enemy',
description: 'Deal 3 damage 3 times.',
effect: { damage: 3, hits: 3 },
},
adrenaline: {
id: 'adrenaline',
name: 'Adrenaline',
type: 'skill',
rarity: 'uncommon',
cost: 0,
target: 'none',
description: 'Gain 1 energy. Draw 2 cards. Exhaust.',
effect: { energy: 1, draw: 2 },
exhaust: true,
},
};
/** Look up a card definition by id; throws early on typos rather than failing silently. */
export function getCard(id: string): CardData {
const card = CARD_LIBRARY[id];
if (!card) throw new Error(`Unknown card id: ${id}`);
return card;
}
/** The deck every run begins with (Slay the Spire-style: 5 Strike, 4 Defend, 1 Bash). */
export const STARTER_DECK: readonly string[] = [
'strike', 'strike', 'strike', 'strike', 'strike',
'defend', 'defend', 'defend', 'defend',
'bash',
];

View File

@@ -0,0 +1,44 @@
/**
* Card model — pure data, no engine dependencies.
*
* A card is a static *definition* (from the library). At runtime, instances in a
* deck are referenced by definition id; identical copies share the same CardData.
*/
export type CardType = 'attack' | 'skill' | 'power';
export type CardRarity = 'starter' | 'common' | 'uncommon' | 'rare';
/** Where a card's effects are directed when played. */
export type TargetType = 'enemy' | 'self' | 'all-enemies' | 'none';
/**
* Declarative effect bundle applied when a card resolves. Keep this data-driven:
* new mechanics should extend this interface, not add bespoke card subclasses.
*/
export interface CardEffect {
/** Damage dealt to the target(s), before block. */
damage?: number;
/** Block granted to the player. */
block?: number;
/** Cards drawn immediately. */
draw?: number;
/** Energy gained immediately. */
energy?: number;
/** Hits — repeat the damage this many times (default 1). */
hits?: number;
}
export interface CardData {
id: string;
name: string;
type: CardType;
rarity: CardRarity;
/** Energy cost to play. */
cost: number;
target: TargetType;
/** Human-readable rules text for the card face. */
description: string;
effect: CardEffect;
/** If true, the card leaves play (exhaust pile) instead of the discard. */
exhaust?: boolean;
}

View File

@@ -0,0 +1,234 @@
import { Rng } from '../../shared/rng';
import { getCard } from '../cards/card-library';
import { getEnemyDef } from './enemy-definitions';
import type {
CombatState,
Combatant,
EnemyState,
Piles,
} from './combat.types';
const HAND_SIZE = 5;
const DEFAULT_ENERGY = 3;
/**
* CombatEngine holds no rendering concerns. It owns the rules: turn structure,
* card resolution, draw/discard/exhaust, block, enemy intents and AI.
*
* The render layer calls these methods and re-reads `state` after each one.
* Every mutating method returns the (same) state for convenience.
*/
export class CombatEngine {
readonly state: CombatState;
private rng: Rng;
constructor(params: {
seed: number;
playerHp: number;
playerMaxHp: number;
deck: readonly string[];
enemyIds: readonly string[];
}) {
this.rng = new Rng(params.seed);
const player: Combatant = {
id: 'player',
name: 'Hunter',
hp: params.playerHp,
maxHp: params.playerMaxHp,
block: 0,
};
const enemies: EnemyState[] = params.enemyIds.map((id, i) => {
const def = getEnemyDef(id);
const enemy: EnemyState = {
id: `${id}-${i}`,
name: def.name,
hp: def.maxHp,
maxHp: def.maxHp,
block: 0,
definition: def,
turn: 0,
intent: def.moves[0].intent,
};
return enemy;
});
const piles: Piles = {
draw: this.rng.shuffle([...params.deck]),
hand: [],
discard: [],
exhaust: [],
};
this.state = {
player,
enemies,
piles,
energy: DEFAULT_ENERGY,
maxEnergy: DEFAULT_ENERGY,
phase: 'player',
};
this.startPlayerTurn();
}
// ---- Turn flow -------------------------------------------------------
private startPlayerTurn(): void {
this.state.player.block = 0;
this.state.energy = this.state.maxEnergy;
this.drawCards(HAND_SIZE);
this.state.phase = 'player';
}
/** Player ends their turn: discard hand, run enemies, then start next turn. */
endTurn(): void {
if (this.state.phase !== 'player') return;
// Discard the whole hand.
this.state.piles.discard.push(...this.state.piles.hand);
this.state.piles.hand = [];
this.state.phase = 'enemy';
this.runEnemyTurn();
if (!this.isCombatOver()) {
this.startPlayerTurn();
}
}
/** True once the fight has been decided. Kept as a method so callers don't
* get spurious control-flow narrowing on `state.phase`. */
isCombatOver(): boolean {
return this.state.phase === 'won' || this.state.phase === 'lost';
}
// ---- Card play -------------------------------------------------------
/**
* Play the card at `handIndex` against `targetEnemyId` (ignored for self/none
* targets). Returns true if the card was played.
*/
playCard(handIndex: number, targetEnemyId?: string): boolean {
if (this.state.phase !== 'player') return false;
const cardId = this.state.piles.hand[handIndex];
if (cardId === undefined) return false;
const card = getCard(cardId);
if (card.cost > this.state.energy) return false;
this.state.energy -= card.cost;
// Remove from hand first so draw effects can't re-target it.
this.state.piles.hand.splice(handIndex, 1);
this.applyCardEffect(cardId, targetEnemyId);
if (card.exhaust) this.state.piles.exhaust.push(cardId);
else this.state.piles.discard.push(cardId);
this.checkWinLoss();
return true;
}
private applyCardEffect(cardId: string, targetEnemyId?: string): void {
const card = getCard(cardId);
const fx = card.effect;
if (fx.block) this.state.player.block += fx.block;
if (fx.energy) this.state.energy += fx.energy;
if (fx.draw) this.drawCards(fx.draw);
if (fx.damage) {
const hits = fx.hits ?? 1;
const victims =
card.target === 'all-enemies'
? this.state.enemies
: this.resolveTarget(targetEnemyId);
for (const enemy of victims) {
for (let h = 0; h < hits; h++) this.dealDamage(enemy, fx.damage);
}
}
this.state.enemies = this.state.enemies.filter(e => e.hp > 0);
}
private resolveTarget(targetEnemyId?: string): EnemyState[] {
const target =
this.state.enemies.find(e => e.id === targetEnemyId) ??
this.state.enemies[0];
return target ? [target] : [];
}
// ---- Enemy turn ------------------------------------------------------
private runEnemyTurn(): void {
for (const enemy of this.state.enemies) {
enemy.block = 0;
this.executeIntent(enemy);
if (this.state.player.hp <= 0) {
this.state.phase = 'lost';
return;
}
}
// Advance each enemy to its next intent for the upcoming player turn.
for (const enemy of this.state.enemies) {
enemy.turn += 1;
const moves = enemy.definition.moves;
enemy.intent = moves[enemy.turn % moves.length].intent;
}
this.checkWinLoss();
}
private executeIntent(enemy: EnemyState): void {
const intent = enemy.intent;
switch (intent.kind) {
case 'attack': {
const hits = intent.hits ?? 1;
for (let h = 0; h < hits; h++) {
this.dealDamage(this.state.player, intent.value ?? 0);
}
break;
}
case 'block':
enemy.block += 8;
break;
case 'buff':
case 'unknown':
// Placeholder for status/buff mechanics added later.
break;
}
}
// ---- Primitives ------------------------------------------------------
private dealDamage(target: Combatant, amount: number): void {
const absorbed = Math.min(target.block, amount);
target.block -= absorbed;
target.hp = Math.max(0, target.hp - (amount - absorbed));
}
private drawCards(count: number): void {
for (let i = 0; i < count; i++) {
if (this.state.piles.draw.length === 0) this.reshuffle();
const card = this.state.piles.draw.pop();
if (card === undefined) break; // deck fully exhausted
this.state.piles.hand.push(card);
}
}
private reshuffle(): void {
if (this.state.piles.discard.length === 0) return;
this.state.piles.draw = this.rng.shuffle([...this.state.piles.discard]);
this.state.piles.discard = [];
}
private checkWinLoss(): void {
if (this.state.player.hp <= 0) {
this.state.phase = 'lost';
} else if (this.state.enemies.length === 0) {
this.state.phase = 'won';
}
}
}

View File

@@ -0,0 +1,65 @@
/**
* Combat domain types — pure data, no Phaser. The engine operates on these and
* the render layer reads them. Nothing here imports the game engine.
*/
/** A participant in combat. Player and enemies share the same shape. */
export interface Combatant {
id: string;
name: string;
hp: number;
maxHp: number;
/** Temporary damage-absorbing block, cleared at the start of the owner's turn. */
block: number;
}
/** What an enemy telegraphs it will do on its next turn. */
export type IntentKind = 'attack' | 'block' | 'buff' | 'unknown';
export interface Intent {
kind: IntentKind;
/** For attack intents: damage per hit. */
value?: number;
/** For multi-hit attacks. */
hits?: number;
}
/** A scripted enemy behaviour: pick the move for the given turn index. */
export interface EnemyMove {
intent: Intent;
}
export interface EnemyDefinition {
id: string;
name: string;
maxHp: number;
/** Move pattern, cycled by turn number. Deterministic, so intents are readable. */
moves: readonly EnemyMove[];
}
/** A concrete enemy in an encounter (definition + live state + current intent). */
export interface EnemyState extends Combatant {
definition: EnemyDefinition;
intent: Intent;
/** Turn counter used to advance the move pattern. */
turn: number;
}
/** The four card piles that make up a runtime deck during combat. */
export interface Piles {
draw: string[];
hand: string[];
discard: string[];
exhaust: string[];
}
export type CombatPhase = 'player' | 'enemy' | 'won' | 'lost';
export interface CombatState {
player: Combatant;
enemies: EnemyState[];
piles: Piles;
energy: number;
maxEnergy: number;
phase: CombatPhase;
}

View File

@@ -0,0 +1,53 @@
import type { EnemyDefinition } from './combat.types';
/**
* Enemy definitions — data-driven. Move patterns cycle by turn number, so a
* player can learn and read intents. Add enemies by adding entries.
*/
export const ENEMY_DEFINITIONS: Record<string, EnemyDefinition> = {
slime: {
id: 'slime',
name: 'Slime',
maxHp: 22,
moves: [
{ intent: { kind: 'attack', value: 6 } },
{ intent: { kind: 'block' } },
],
},
cultist: {
id: 'cultist',
name: 'Cultist',
maxHp: 30,
moves: [
{ intent: { kind: 'buff' } },
{ intent: { kind: 'attack', value: 8 } },
{ intent: { kind: 'attack', value: 8 } },
],
},
jaw_worm: {
id: 'jaw_worm',
name: 'Jaw Worm',
maxHp: 42,
moves: [
{ intent: { kind: 'attack', value: 11 } },
{ intent: { kind: 'block' } },
{ intent: { kind: 'attack', value: 7 } },
],
},
brute: {
id: 'brute',
name: 'Brute',
maxHp: 54,
moves: [
{ intent: { kind: 'attack', value: 6, hits: 2 } },
{ intent: { kind: 'attack', value: 14 } },
{ intent: { kind: 'block' } },
],
},
};
export function getEnemyDef(id: string): EnemyDefinition {
const def = ENEMY_DEFINITIONS[id];
if (!def) throw new Error(`Unknown enemy id: ${id}`);
return def;
}

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';
});
}
}
}
}

View File

@@ -0,0 +1,75 @@
import { STARTER_DECK } from '../cards/card-library';
import { MapGenerator } from './MapGenerator';
import type { RunMap } from './run-map.types';
export interface RunState {
seed: number;
/** Index into the map's node list — the player's current position. */
nodeIndex: number;
gold: number;
hp: number;
maxHp: number;
/** The player's deck as an ordered list of card ids. */
deck: string[];
}
const INITIAL_HP = 60;
/**
* Owns the run-level state that persists across combats: the map, the deck,
* hp carried between fights, gold, and progression. Combat-scoped state (piles,
* energy, block) lives in CombatEngine, not here.
*/
export class RunManager {
private state: RunState;
readonly map: RunMap;
constructor(seed: number = Date.now()) {
this.map = new MapGenerator().generate(seed);
this.state = {
seed,
nodeIndex: 0,
gold: 0,
hp: INITIAL_HP,
maxHp: INITIAL_HP,
deck: [...STARTER_DECK],
};
}
get(): Readonly<RunState> {
return this.state;
}
/** The node the player is currently on. */
currentNode() {
return this.map.nodes[this.state.nodeIndex];
}
/** Advance to the next node. Returns false if the run is complete. */
advance(): boolean {
if (this.state.nodeIndex >= this.map.nodes.length - 1) return false;
this.state.nodeIndex += 1;
return true;
}
setHp(hp: number): void {
this.state.hp = Math.max(0, Math.min(this.state.maxHp, hp));
}
heal(amount: number): void {
this.setHp(this.state.hp + amount);
}
addGold(amount: number): void {
this.state.gold += amount;
}
addCard(cardId: string): void {
this.state.deck.push(cardId);
}
removeCard(cardId: string): void {
const i = this.state.deck.indexOf(cardId);
if (i >= 0) this.state.deck.splice(i, 1);
}
}

View File

@@ -0,0 +1,15 @@
/** A node on the run map — one encounter the player travels through. */
export type EncounterType = 'combat' | 'elite' | 'rest' | 'boss';
export interface MapNode {
id: string;
floor: number;
type: EncounterType;
/** Enemy definition ids to spawn for combat/elite/boss nodes. */
enemyIds: string[];
}
export interface RunMap {
seed: number;
nodes: MapNode[];
}

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 });
}
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './app/index.css'
import App from './app/App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

40
src/shared/rng.ts Normal file
View File

@@ -0,0 +1,40 @@
/**
* Deterministic, seedable RNG. Mulberry32 — simple, fast, good distribution.
* Kept engine-agnostic so both run generation and combat shuffles are reproducible
* from a single run seed.
*/
export class Rng {
private s: number;
constructor(seed: number) {
this.s = seed >>> 0;
}
/** Float in [0, 1). */
next(): number {
this.s += 0x6d2b79f5;
let t = this.s;
t = Math.imul(t ^ (t >>> 15), 1 | t);
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
/** Integer in [min, max] inclusive. */
int(min: number, max: number): number {
return min + Math.floor(this.next() * (max - min + 1));
}
/** Pick a random element. */
pick<T>(arr: readonly T[]): T {
return arr[Math.floor(this.next() * arr.length)];
}
/** In-place Fisher-Yates shuffle. Returns the same array for chaining. */
shuffle<T>(arr: T[]): T[] {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(this.next() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
}

View File

@@ -0,0 +1,4 @@
export interface Vec2 {
x: number;
y: number;
}

25
tsconfig.app.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowJs": false
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

23
tsconfig.node.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

27
vite.config.ts Normal file
View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'
// Inject the React DevTools standalone hook only during `vite` dev (serve),
// so it never ships in a production build.
const devtools = () => ({
name: 'react-devtools-dev-only',
apply: 'serve' as const,
transformIndexHtml: {
order: 'pre' as const,
handler: (html: string) =>
html.replace(
'</head>',
' <script src="http://localhost:8097"></script>\n </head>',
),
},
})
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
babel({ presets: [reactCompilerPreset()] }),
devtools(),
],
})