Compare commits
4 Commits
10e5e982dd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fbe7ffb8b | ||
|
|
3f0c87924b | ||
|
|
5c7276cf35 | ||
|
|
c55e0751e6 |
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
25
.gitignore
vendored
Normal 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
24
Dockerfile
Normal 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;"]
|
||||
77
README.md
77
README.md
@@ -1,2 +1,75 @@
|
||||
# RogueHunter
|
||||
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
|
||||
# React + TypeScript + Vite
|
||||
|
||||
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
23
eslint.config.js
Normal 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
13
index.html
Normal 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
29
nginx.conf
Normal 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
3463
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal 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
2
src/app/App.css
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
12
src/app/App.tsx
Normal file
12
src/app/App.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import PhaserGame from "../game/PhaserGame.tsx";
|
||||
|
||||
function App() {
|
||||
|
||||
return (
|
||||
<div id="game-container">
|
||||
<PhaserGame/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
48
src/app/hooks/useGameEvents.ts
Normal file
48
src/app/hooks/useGameEvents.ts
Normal 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
18
src/app/index.css
Normal 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%;
|
||||
}
|
||||
|
||||
|
||||
0
src/app/stylesheets/game-style.css
Normal file
0
src/app/stylesheets/game-style.css
Normal file
93
src/core/cards/card-library.ts
Normal file
93
src/core/cards/card-library.ts
Normal 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',
|
||||
];
|
||||
44
src/core/cards/card.types.ts
Normal file
44
src/core/cards/card.types.ts
Normal 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;
|
||||
}
|
||||
234
src/core/combat/combat-engine.ts
Normal file
234
src/core/combat/combat-engine.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/core/combat/combat.types.ts
Normal file
65
src/core/combat/combat.types.ts
Normal 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;
|
||||
}
|
||||
53
src/core/combat/enemy-definitions.ts
Normal file
53
src/core/combat/enemy-definitions.ts
Normal 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;
|
||||
}
|
||||
58
src/core/run/MapGenerator.ts
Normal file
58
src/core/run/MapGenerator.ts
Normal 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';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/core/run/RunManager.ts
Normal file
75
src/core/run/RunManager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
src/core/run/run-map.types.ts
Normal file
15
src/core/run/run-map.types.ts
Normal 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
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 });
|
||||
}
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal 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
40
src/shared/rng.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
4
src/shared/types/index.ts
Normal file
4
src/shared/types/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Vec2 {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
25
tsconfig.app.json
Normal file
25
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
23
tsconfig.node.json
Normal file
23
tsconfig.node.json
Normal 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
27
vite.config.ts
Normal 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(),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user