Compare commits
100
Commits
e76010272c
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad2c808482 | ||
|
|
f893ac9ff7 | ||
|
|
dfbdaa3499 | ||
|
|
c29b9a43b3 | ||
|
|
447a5cfbd0 | ||
|
|
dda290bf9c | ||
|
|
5d84c42b78 | ||
|
|
a6ddafb802 | ||
|
|
a181932fb3 | ||
|
|
887da22baa | ||
|
|
5355afb23e | ||
|
|
3f2ba84a06 | ||
|
|
9637312b7c | ||
|
|
82c026628a | ||
|
|
a92b889dfa | ||
|
|
c97c2c4150 | ||
|
|
422ddde200 | ||
|
|
093eadbefd | ||
|
|
eff62bc0ed | ||
|
|
fe0621cedf | ||
|
|
30e2d9ac36 | ||
|
|
c9f573ef86 | ||
|
|
cc796d1e11 | ||
|
|
fd347e7a62 | ||
|
|
19dfafeb30 | ||
|
|
b2862e34d9 | ||
|
|
fd41db2a89 | ||
|
|
f03e6c5a7b | ||
|
|
7039938a72 | ||
|
|
45844ea800 | ||
|
|
29b516c371 | ||
|
|
5884e74d8d | ||
|
|
b06876ccc0 | ||
|
|
368d9942d2 | ||
|
|
22817945cc | ||
|
|
85bc4f5400 | ||
|
|
033a8e4a40 | ||
|
|
1d803dd219 | ||
|
|
2d412eedb5 | ||
|
|
f32aca2543 | ||
|
|
34a7cff964 | ||
|
|
82df3f2a2f | ||
|
|
4d34f9fa78 | ||
|
|
c25759d147 | ||
|
|
3568d99e6e | ||
|
|
b28da1cad3 | ||
|
|
c7ef992082 | ||
|
|
3afe78f54f | ||
|
|
762ed1a63a | ||
|
|
b0e74a5257 | ||
|
|
648e801dad | ||
|
|
ddc9d057fd | ||
|
|
e14e41461f | ||
|
|
5af7140958 | ||
|
|
a7095c37fc | ||
|
|
88d0c5bf55 | ||
|
|
d993d55576 | ||
|
|
eefcef861a | ||
|
|
23575dd516 | ||
|
|
0ecef16a4a | ||
|
|
28a2623bd1 | ||
|
|
f7a6154c68 | ||
|
|
706de4e33a | ||
|
|
5342f1c09d | ||
|
|
3ca2a16e29 | ||
|
|
7fb9edcbf0 | ||
|
|
65684e6cf5 | ||
|
|
c9acb7f228 | ||
|
|
c639218b53 | ||
|
|
e91e8d2ab7 | ||
|
|
02d2f45da2 | ||
|
|
e5b53c6332 | ||
|
|
ecfa89e383 | ||
|
|
8bd790db67 | ||
|
|
93587541ce | ||
|
|
cc80cbad06 | ||
|
|
6584578316 | ||
|
|
f6f18b4789 | ||
|
|
70334fa9e3 | ||
|
|
fbf3f5e636 | ||
|
|
9ab7ae3e60 | ||
|
|
fe57583a8f | ||
|
|
713a14c128 | ||
|
|
50531446c2 | ||
|
|
f8a19653ba | ||
|
|
59fa0e6122 | ||
|
|
21a7afa276 | ||
|
|
71ccb8732e | ||
|
|
92e1a5bec1 | ||
|
|
3e064f437b | ||
|
|
00fd395873 | ||
|
|
2beff5c75c | ||
|
|
d4819f7cc3 | ||
|
|
8bbf20f457 | ||
|
|
27e6d52cf3 | ||
|
|
deb8c91239 | ||
|
|
cedb69e55e | ||
|
|
a0412ddd88 | ||
|
|
20f722818d | ||
|
|
bf20e53c6b |
@@ -1 +1,2 @@
|
||||
!node_modules
|
||||
!debug
|
||||
@@ -17,107 +17,6 @@
|
||||
`boardgame-core`的内容可以在`framework/node_modules/boardgame-core`找到。
|
||||
这个文件夹被.gitignore忽略,查看时需要绕开这一限制。
|
||||
|
||||
## 编写GameModule
|
||||
|
||||
游戏逻辑以GameModule的形式定义:
|
||||
|
||||
```typescript
|
||||
import {createGameCommandRegistry, IGameContext} from "boardgame-core";
|
||||
|
||||
// 创建mutative游戏初始状态
|
||||
export function createInitialState(): GameState {
|
||||
//...
|
||||
}
|
||||
|
||||
// 运行游戏
|
||||
export async function start(game: IGameContext<GameState>) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// 可选
|
||||
export const registry = createGameCommandRegistry();
|
||||
```
|
||||
|
||||
使用以下步骤创建GameModule:
|
||||
|
||||
### 1. 定义状态
|
||||
|
||||
通常使用一个`regions: Record<string, Region>`和一个`parts: Record<string, Part<TMeta>>`来记录桌游物件的摆放。
|
||||
|
||||
```typescript
|
||||
import {Region} from "boardgame-core";
|
||||
|
||||
type GameState = {
|
||||
regions: {
|
||||
white: Region,
|
||||
black: Region,
|
||||
board: Region,
|
||||
},
|
||||
pieces: Record<string, Part<PartsTable['0']>>,
|
||||
currentPlayer: PlayerType,
|
||||
winner: WinnerType,
|
||||
};
|
||||
```
|
||||
|
||||
游戏的部件可以从`csv`加载。详情见`boop-game/node_modules/inline-schema/`。
|
||||
```
|
||||
/// parts.csv
|
||||
type, player, count
|
||||
string, string, number
|
||||
cat, white, 8
|
||||
cat, black, 8
|
||||
|
||||
/// parts.csv.d.ts
|
||||
type PartsTable = {
|
||||
type: string;
|
||||
player: string;
|
||||
count: number;
|
||||
}[];
|
||||
|
||||
declare const data: PartsTable;
|
||||
export default data;
|
||||
```
|
||||
|
||||
### 2. 定义流程
|
||||
|
||||
使用`async function start(game: IGameContext<GameState>)`作为入口。
|
||||
|
||||
使用`game.value`读取游戏当前状态
|
||||
```typescript
|
||||
async function gameEnd(game: IGameContext<GameState>) {
|
||||
return game.value.winner;
|
||||
}
|
||||
```
|
||||
|
||||
需要修改状态时,使用`game.produce`或`game.produceAsync`。
|
||||
|
||||
```typescript
|
||||
async function start(game: IGameContext<GameState>) {
|
||||
await game.produceAsync(state => {
|
||||
state.currentPlayer = 'white';
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
需要等待玩家交互时,使用`await game.prompt(schema, validator, player)`。
|
||||
|
||||
```typescript
|
||||
async function turn(game: IGameContext<GameState>, currentPlayer: PlayerType) {
|
||||
const {player, row, col} = await game.prompt(
|
||||
'play <player:string> <row:number> <col:number>',
|
||||
(player, row, col) => {
|
||||
if(player !== currentPlayer)
|
||||
throw `Wrong player!`
|
||||
return {player, row, col};
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 编写测试
|
||||
|
||||
使用`vitest`编写测试,测试应当使用`GameHost`来模拟游戏环境。
|
||||
|
||||
## 编写Phaser App
|
||||
|
||||
使用`framework/src/ui/PhaserBridge`来创建Phaser App。
|
||||
@@ -130,6 +29,9 @@ async function turn(game: IGameContext<GameState>, currentPlayer: PlayerType) {
|
||||
export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
|
||||
// 获取游戏状态的只读快照
|
||||
get state(): TState{}
|
||||
// 获取随机数
|
||||
get rng(): ReadonlyRNG{}
|
||||
|
||||
// 运行状态
|
||||
readonly status: ReadonlySignal<GameHostStatus>;
|
||||
|
||||
@@ -138,13 +40,14 @@ export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
|
||||
readonly activePromptPlayer: ReadonlySignal<string | null>;
|
||||
|
||||
// 玩家响应activePrompt的输入,若报错则返回string,否则返回null
|
||||
onInput(input: string): string | null {}
|
||||
// promptDef应当从game module中导出
|
||||
tryAnswerPrompt<TArgs extends any[]>(promptDef: Promptdef<TArgs>,...args: TArgs): string | null {}
|
||||
|
||||
// 添加中断,context.produceAsync会等待所有中断结束之后再继续
|
||||
addInterruption(promise: Promise<void>): void {}
|
||||
|
||||
// 开始或者重新开始游戏
|
||||
start(): Promise<TResult>{}
|
||||
start(seed?: number): Promise<TResult>{}
|
||||
|
||||
// 销毁
|
||||
dispose(): void {}
|
||||
@@ -153,3 +56,16 @@ export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
|
||||
on(event: 'start' | 'dispose', listener: () => void): () => void {}
|
||||
}
|
||||
```
|
||||
|
||||
使用`Spawner`来创建动态游戏对象:
|
||||
- 卡牌
|
||||
- 棋子
|
||||
- UI高亮提示对象
|
||||
|
||||
使用`MutableSignal`创建状态信号。
|
||||
```typescript
|
||||
import {MutableSignal} from "boardgame-core"
|
||||
|
||||
export const x = MutableSignal(0);
|
||||
x.produce(x => x + 1);
|
||||
```
|
||||
@@ -0,0 +1,107 @@
|
||||
## 编写GameModule
|
||||
|
||||
游戏逻辑以GameModule的形式定义:
|
||||
|
||||
```typescript
|
||||
import {createGameCommandRegistry, IGameContext} from "boardgame-core";
|
||||
|
||||
// 创建mutative游戏初始状态
|
||||
export function createInitialState(): GameState {
|
||||
//...
|
||||
}
|
||||
|
||||
// 运行游戏
|
||||
export async function start(game: IGameContext<GameState>) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// 可选
|
||||
export const registry = createGameCommandRegistry();
|
||||
```
|
||||
|
||||
使用以下步骤创建GameModule:
|
||||
|
||||
### 1. 定义状态
|
||||
|
||||
通常使用一个`regions: Record<string, Region>`和一个`parts: Record<string, Part<TMeta>>`来记录桌游物件的摆放。
|
||||
|
||||
```typescript
|
||||
import {Region} from "boardgame-core";
|
||||
|
||||
type GameState = {
|
||||
regions: {
|
||||
white: Region,
|
||||
black: Region,
|
||||
board: Region,
|
||||
},
|
||||
pieces: Record<string, Part<PartsTable['0']>>,
|
||||
currentPlayer: PlayerType,
|
||||
winner: WinnerType,
|
||||
};
|
||||
```
|
||||
|
||||
游戏的部件可以从`csv`加载。详情见`boop-game/node_modules/inline-schema/`。
|
||||
```
|
||||
/// parts.csv
|
||||
type, player, count
|
||||
string, string, number
|
||||
cat, white, 8
|
||||
cat, black, 8
|
||||
|
||||
/// parts.csv.d.ts
|
||||
type PartsTable = {
|
||||
type: string;
|
||||
player: string;
|
||||
count: number;
|
||||
}[];
|
||||
|
||||
declare const data: PartsTable;
|
||||
export default data;
|
||||
```
|
||||
|
||||
### 2. 定义流程
|
||||
|
||||
使用`async function start(game: IGameContext<GameState>)`作为入口。
|
||||
|
||||
使用`game.value`读取游戏当前状态
|
||||
```typescript
|
||||
async function gameEnd(game: IGameContext<GameState>) {
|
||||
return game.value.winner;
|
||||
}
|
||||
```
|
||||
|
||||
需要修改状态时,使用`game.produce`或`game.produceAsync`。
|
||||
|
||||
```typescript
|
||||
async function start(game: IGameContext<GameState>) {
|
||||
await game.produceAsync(state => {
|
||||
state.currentPlayer = 'white';
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
需要等待玩家交互时,使用`await game.prompt(promptDef, validator, player)`。
|
||||
|
||||
```typescript
|
||||
import {createPromptDef} from "boardgame-core";
|
||||
|
||||
export const prompts = {
|
||||
play: createPromptDef<[PlayerType, number, number]>('play <player> <row:number> <col:number>'),
|
||||
}
|
||||
|
||||
async function turn(game: IGameContext<GameState>, currentPlayer: PlayerType) {
|
||||
const {player, row, col} = await game.prompt(
|
||||
prompts.play,
|
||||
(player, row, col) => {
|
||||
if (player !== currentPlayer)
|
||||
throw `Wrong player!`
|
||||
return {player, row, col};
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 编写测试
|
||||
|
||||
使用`vitest`编写测试,测试应当使用`GameHost`来模拟游戏环境。
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# Game Architecture Patterns & Practices
|
||||
|
||||
> Reference guide for implementing board games using `boardgame-phaser` framework.
|
||||
> Explore `packages/onitama-game/` and `packages/sample-game/` for concrete implementations.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Games follow a layered architecture separating logic, presentation, and UI state:
|
||||
|
||||
```
|
||||
packages/my-game/
|
||||
├── src/
|
||||
│ ├── game/ # Pure game logic (re-exported from boardgame-core)
|
||||
│ ├── scenes/ # Phaser scenes (MenuScene, GameScene)
|
||||
│ ├── spawners/ # Data-driven object lifecycle
|
||||
│ ├── gameobjects/ # Phaser GameObject or Container extensions
|
||||
│ ├── state/ # UI-only reactive state
|
||||
│ ├── config.ts # Centralized layout & style constants
|
||||
│ └── ui/App.tsx # Preact root component
|
||||
```
|
||||
|
||||
| Layer | Responsibility |
|
||||
|-------|----------------|
|
||||
| `game/` | State, commands, prompts, validation (pure logic) |
|
||||
| `scenes/` | Phaser lifecycle, input handling, visual composition |
|
||||
| `spawners/` | Reactive game object spawn/update/despawn |
|
||||
| `gameobjects/` | Phaser GameObjects or containers |
|
||||
| `state/` | UI-only reactive state (selection, hover, etc.) |
|
||||
| `ui/` | Preact components bridging Phaser and DOM |
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. ReactiveScene / GameHostScene
|
||||
Extend `ReactiveScene`(`packages\framework\src\scenes\ReactiveScene.ts`) to use reactive integration features.
|
||||
- Use `this.disposables` for auto-cleanup on shutdown.
|
||||
- Use `initData` for context data passed from the `<PhaserScene/>` preact component.
|
||||
|
||||
### 2. Spawner Pattern
|
||||
Implement `Spawner<TData, TObj>` for data-driven objects.
|
||||
- `*getData()`: Yield objects that should exist.
|
||||
- `getKey()`: Unique identifier for diffing.
|
||||
- `onSpawn()`: Create Phaser objects.
|
||||
- `onUpdate()`: Handle data changes (animate if needed).
|
||||
- `onDespawn()`: Clean up with optional animation.
|
||||
- See: `packages/onitama-game/src/spawners/`
|
||||
|
||||
### 3. Reactive UI State
|
||||
Use `MutableSignal` for UI-only state, separate from game state.
|
||||
- Mutate via `.produce()`.
|
||||
- React via `effect()` from `@preact/signals-core`.
|
||||
- Clean up effects on object `'destroy'` to prevent leaks.
|
||||
- See: `packages/onitama-game/src/state/ui.ts`
|
||||
|
||||
### 4. Custom GameObjects / Containers
|
||||
Extend `Phaser.GameObjects.Container` to encapsulate visuals and state.
|
||||
- Use signals from `states/`, avoid relying on complex interal state.
|
||||
- Use `effect()` for reactive highlights/selections.
|
||||
|
||||
### 5. Tween Interruption
|
||||
Always register state-related tweens: `this.scene.addTweenInterruption(tween)`.
|
||||
Prevents visual glitches when game state changes mid-animation.
|
||||
DO NOT add interruptions for looped tweens.
|
||||
Otherwise the game logic will hang.
|
||||
|
||||
### 6. Scene Navigation
|
||||
Use `await this.sceneController.launch('SceneKey')`.
|
||||
Register scenes in `App.tsx` via `<PhaserScene>`. Pass data via `data` prop.
|
||||
|
||||
### 7. Central Config
|
||||
Use `src/config/layout.ts` for shared constants like `CELL_SIZE`, `BOARD_OFFSET`, etc.
|
||||
Use `src/config/colors.ts` for shared colors.
|
||||
Use `src/config/text.ts` for text styles.
|
||||
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Centralize Config**: Keep `CELL_SIZE`, `BOARD_OFFSET`, colors, etc., in `src/config.ts` with `as const`. Avoid magic numbers.
|
||||
- **Type Imports**: Use `import type { Foo } from 'bar'` for type-only imports.
|
||||
- **Input Handling**: Use `this.add.zone()` for grid/cell-based input zones.
|
||||
- **Cleanup**: Always dispose `effect()` on `'destroy'`. Use `this.disposables.add()` for scene-level resources.
|
||||
- *Keep files short and focused**: Each file should have a single responsibility, and not exceed 200 lines.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
| Pitfall | Solution |
|
||||
|---------|----------|
|
||||
| Duplicate constants across files | Export from single `config.ts` or shared spawner |
|
||||
| Missing tween interruptions | Always call `addTweenInterruption()` |
|
||||
| Effect memory leaks | `this.on('destroy', () => dispose())` |
|
||||
| Hardcoded magic numbers | Extract to `src/config.ts` |
|
||||
| UI state staleness | Clear related selections on state change |
|
||||
|
||||
## Testing
|
||||
Add Vitest for UI state transitions, coordinate conversions, and spawner logic.
|
||||
See `packages/framework/` for test setup examples.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Pattern | Purpose |
|
||||
|---------|---------|
|
||||
| `GameHostScene` | Connect Phaser to game host |
|
||||
| `Spawner<T, TObj>` | Reactive object lifecycle |
|
||||
| `MutableSignal` | UI-only reactive state |
|
||||
| `effect()` | React to signal changes in Phaser objects |
|
||||
| `addTweenInterruption()` | Prevent animation race conditions |
|
||||
| `sceneController.launch()` | Navigate between scenes |
|
||||
| `spawnEffect()` | Register spawners |
|
||||
| `this.disposables.add()` | Auto-cleanup resources |
|
||||
|
||||
## Related Documents
|
||||
- `AGENTS.md` — Project overview, commands, and code style
|
||||
- `docs/GameModule.md` — GameModule implementation guide
|
||||
- `packages/framework/src/` — `boardgame-phaser` source code
|
||||
- `packages/onitama-game/src/` — Complete game implementation reference
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as js from "@eslint";
|
||||
import tseslint from "typescript-eslint";
|
||||
import importPlugin from "eslint-plugin-import";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"**/dist/**",
|
||||
"**/node_modules/**",
|
||||
"**/*.d.ts",
|
||||
"**/coverage/**",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
importPlugin.flatConfigs.recommended,
|
||||
importPlugin.flatConfigs.typescript,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
ecmaVersion: 2020,
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
rules: {
|
||||
// --- Project Conventions ---
|
||||
quotes: ["error", "double", { avoidEscape: true }],
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
indent: ["error", 2, { SwitchCase: 1 }],
|
||||
|
||||
// --- TypeScript ---
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{ prefer: "type-imports" },
|
||||
],
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
|
||||
// --- Import Ordering ---
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
groups: [
|
||||
"builtin",
|
||||
"external",
|
||||
"internal",
|
||||
"parent",
|
||||
"sibling",
|
||||
"index",
|
||||
"object",
|
||||
"type",
|
||||
],
|
||||
"newlines-between": "always",
|
||||
alphabetize: { order: "asc", caseInsensitive: true },
|
||||
},
|
||||
],
|
||||
"import/no-unresolved": "off",
|
||||
"import/no-duplicates": "error",
|
||||
|
||||
// --- General ---
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
eqeqeq: ["error", "always"],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.test.ts", "**/*.spec.ts"],
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
+7
-1
@@ -6,10 +6,16 @@
|
||||
"dev": "pnpm --filter sample-game dev",
|
||||
"build": "pnpm --filter boardgame-phaser build && pnpm --filter sample-game build",
|
||||
"build:framework": "pnpm --filter boardgame-phaser build",
|
||||
"preview": "pnpm --filter sample-game preview"
|
||||
"preview": "pnpm --filter sample-game preview",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.18.0",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Re-export boop game module from boardgame-core
|
||||
* This provides a convenient import path within boop-game
|
||||
*/
|
||||
export {
|
||||
registry,
|
||||
prompts,
|
||||
start,
|
||||
createInitialState,
|
||||
type BoopState,
|
||||
type BoopPart,
|
||||
type BoopGame,
|
||||
type PlayerType,
|
||||
type PieceType,
|
||||
type WinnerType,
|
||||
type RegionType,
|
||||
} from "boardgame-core/samples/boop";
|
||||
@@ -1,258 +0,0 @@
|
||||
import {
|
||||
BOARD_SIZE,
|
||||
BoopState,
|
||||
BoopPart,
|
||||
PieceType,
|
||||
PlayerType,
|
||||
WinnerType,
|
||||
WIN_LENGTH,
|
||||
MAX_PIECES_PER_PLAYER,
|
||||
BoopGame,
|
||||
} from "./data";
|
||||
import {createGameCommandRegistry, Command, moveToRegion} from "boardgame-core";
|
||||
import {
|
||||
findPartAtPosition,
|
||||
findPartInRegion,
|
||||
getLineCandidates,
|
||||
getNeighborPositions,
|
||||
isCellOccupied,
|
||||
isInBounds
|
||||
} from "./utils";
|
||||
|
||||
export const registry = createGameCommandRegistry<BoopState>();
|
||||
|
||||
/**
|
||||
* 放置棋子到棋盘
|
||||
*/
|
||||
async function handlePlace(game: BoopGame, row: number, col: number, player: PlayerType, type: PieceType) {
|
||||
const value = game.value;
|
||||
// 从玩家supply中找到对应类型的棋子
|
||||
const part = findPartInRegion(game, player, type);
|
||||
|
||||
if (!part) {
|
||||
throw new Error(`${player} 的 supply 中没有可用的 ${type}`);
|
||||
}
|
||||
|
||||
const partId = part.id;
|
||||
|
||||
await game.produceAsync((state: BoopState) => {
|
||||
// 将棋子从supply移动到棋盘
|
||||
const part = state.pieces[partId];
|
||||
moveToRegion(part, state.regions[player], state.regions.board, [row, col]);
|
||||
});
|
||||
|
||||
return { row, col, player, type, partId };
|
||||
}
|
||||
const place = registry.register( 'place <row:number> <col:number> <player> <type>', handlePlace);
|
||||
|
||||
/**
|
||||
* 执行boop - 推动周围棋子
|
||||
*/
|
||||
async function handleBoop(game: BoopGame, row: number, col: number, type: PieceType) {
|
||||
const booped: string[] = [];
|
||||
|
||||
const toRemove = new Set<string>();
|
||||
await game.produceAsync((state: BoopState) => {
|
||||
// 按照远离放置位置的方向推动
|
||||
for (const [dr, dc] of getNeighborPositions()) {
|
||||
const nr = row + dr;
|
||||
const nc = col + dc;
|
||||
|
||||
if (!isInBounds(nr, nc)) continue;
|
||||
|
||||
// 从 state 中查找,而不是 game
|
||||
const part = findPartAtPosition(state, nr, nc);
|
||||
if (!part) continue;
|
||||
|
||||
// 小猫不能推动猫
|
||||
if (type === 'kitten' && part.type === 'cat') continue;
|
||||
|
||||
// 计算推动后的位置
|
||||
const newRow = nr + dr;
|
||||
const newCol = nc + dc;
|
||||
|
||||
// 检查新位置是否为空或在棋盘外
|
||||
if (!isInBounds(newRow, newCol)) {
|
||||
// 棋子被推出棋盘,返回玩家supply
|
||||
toRemove.add(part.id);
|
||||
booped.push(part.id);
|
||||
moveToRegion(part, state.regions.board, state.regions.board, [newRow, newCol]);
|
||||
} else if (!isCellOccupied(state, newRow, newCol)) {
|
||||
// 新位置为空,移动过去
|
||||
booped.push(part.id);
|
||||
moveToRegion(part, state.regions.board, state.regions.board, [newRow, newCol]);
|
||||
}
|
||||
// 如果新位置被占用,则不移动(两个棋子都保持原位)
|
||||
}
|
||||
});
|
||||
await game.produceAsync((state: BoopState) => {
|
||||
// 移除被吃掉的棋子
|
||||
for (const partId of toRemove) {
|
||||
const part = state.pieces[partId];
|
||||
moveToRegion(part, state.regions.board, state.regions[part.player]);
|
||||
}
|
||||
});
|
||||
|
||||
return { booped };
|
||||
}
|
||||
const boop = registry.register('boop <row:number> <col:number> <type>', handleBoop);
|
||||
|
||||
/**
|
||||
* 检查是否有玩家获胜(三个猫连线)
|
||||
*/
|
||||
async function handleCheckWin(game: BoopGame) {
|
||||
for(const line of getLineCandidates()){
|
||||
let whites = 0;
|
||||
let blacks = 0;
|
||||
for(const [row, col] of line){
|
||||
const part = findPartAtPosition(game, row, col);
|
||||
if(part?.type !== 'cat') continue;
|
||||
if (part.player === 'white') whites++;
|
||||
else blacks++;
|
||||
}
|
||||
if(whites >= WIN_LENGTH) {
|
||||
return 'white';
|
||||
}
|
||||
if(blacks >= WIN_LENGTH) {
|
||||
return 'black';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const checkWin = registry.register('check-win', handleCheckWin);
|
||||
|
||||
/**
|
||||
* 检查并执行小猫升级(三个小猫连线变成猫)
|
||||
*/
|
||||
async function handleCheckGraduates(game: BoopGame){
|
||||
const toUpgrade = new Set<string>();
|
||||
for(const line of getLineCandidates()){
|
||||
let whites = 0;
|
||||
let blacks = 0;
|
||||
for(const [row, col] of line){
|
||||
const part = findPartAtPosition(game, row, col);
|
||||
if (part?.player === 'white') whites++;
|
||||
else if(part?.player === 'black') blacks++;
|
||||
}
|
||||
const player = whites >= WIN_LENGTH ? 'white' : blacks >= WIN_LENGTH ? 'black' : null;
|
||||
if(!player) continue;
|
||||
|
||||
for(const [row, col] of line){
|
||||
const part = findPartAtPosition(game, row, col);
|
||||
part && toUpgrade.add(part.id);
|
||||
}
|
||||
}
|
||||
|
||||
await game.produceAsync((state: BoopState) => {
|
||||
for(const partId of toUpgrade){
|
||||
const part = state.pieces[partId];
|
||||
const [row, col] = part.position;
|
||||
const player = part.player;
|
||||
moveToRegion(part, state.regions.board, null);
|
||||
|
||||
const newPart = findPartInRegion(state, '', 'cat', player);
|
||||
moveToRegion(newPart || part, null, state.regions[player], [row, col]);
|
||||
}
|
||||
});
|
||||
}
|
||||
const checkGraduates = registry.register('check-graduates', handleCheckGraduates);
|
||||
|
||||
export async function start(game: BoopGame) {
|
||||
while (true) {
|
||||
const currentPlayer = game.value.currentPlayer;
|
||||
const { winner } = await turn(game, currentPlayer);
|
||||
|
||||
await game.produceAsync((state: BoopState) => {
|
||||
state.winner = winner;
|
||||
if (!state.winner) {
|
||||
state.currentPlayer = state.currentPlayer === 'white' ? 'black' : 'white';
|
||||
}
|
||||
});
|
||||
if (game.value.winner) break;
|
||||
}
|
||||
|
||||
return game.value;
|
||||
}
|
||||
|
||||
async function handleCheckFullBoard(game: BoopGame, turnPlayer: PlayerType){
|
||||
// 检查8-piece规则: 如果玩家所有8个棋子都在棋盘上且没有获胜,强制升级一个小猫
|
||||
const playerPieces = Object.values(game.value.pieces).filter(
|
||||
(p: BoopPart) => p.player === turnPlayer && p.regionId === 'board'
|
||||
);
|
||||
if(playerPieces.length < MAX_PIECES_PER_PLAYER || game.value.winner !== null){
|
||||
return;
|
||||
}
|
||||
|
||||
const partId = await game.prompt(
|
||||
'play <player> <row:number> <col:number> [type:string]',
|
||||
(player: PlayerType, row: number, col: number, type?: PieceType) => {
|
||||
if (player !== turnPlayer) {
|
||||
throw `无效的玩家: ${player},期望的是 ${turnPlayer}。`;
|
||||
}
|
||||
if (!isInBounds(row, col)) {
|
||||
throw `无效的位置: (${row}, ${col}),必须在 0 到 ${BOARD_SIZE - 1} 之间。`;
|
||||
}
|
||||
|
||||
const part = findPartAtPosition(game, row, col);
|
||||
if (!part || part.player !== turnPlayer) {
|
||||
throw `(${row}, ${col}) 位置没有 ${player} 的棋子。`;
|
||||
}
|
||||
|
||||
return part.id;
|
||||
}
|
||||
);
|
||||
|
||||
await game.produceAsync((state: BoopState) => {
|
||||
const part = state.pieces[partId];
|
||||
moveToRegion(part, state.regions.board, null);
|
||||
const cat = findPartInRegion(state, '', 'cat');
|
||||
moveToRegion(cat || part, null, state.regions[turnPlayer]);
|
||||
});
|
||||
}
|
||||
const checkFullBoard = registry.register('check-full-board', handleCheckFullBoard);
|
||||
|
||||
async function handleTurn(game: BoopGame, turnPlayer: PlayerType) {
|
||||
const {row, col, type} = await game.prompt(
|
||||
'play <player> <row:number> <col:number> [type:string]',
|
||||
(player: PlayerType, row: number, col: number, type?: PieceType) => {
|
||||
const pieceType = type === 'cat' ? 'cat' : 'kitten';
|
||||
|
||||
if (player !== turnPlayer) {
|
||||
throw `无效的玩家: ${player},期望的是 ${turnPlayer}。`;
|
||||
}
|
||||
if (!isInBounds(row, col)) {
|
||||
throw `无效的位置: (${row}, ${col}),必须在 0 到 ${BOARD_SIZE - 1} 之间。`;
|
||||
}
|
||||
if (isCellOccupied(game, row, col)) {
|
||||
throw `单元格 (${row}, ${col}) 已被占用。`;
|
||||
}
|
||||
|
||||
const found = findPartInRegion(game, player, pieceType);
|
||||
if (!found) {
|
||||
throw `${player} 的 supply 中没有 ${pieceType === 'cat' ? '大猫' : '小猫'} 了。`;
|
||||
}
|
||||
return {player, row,col,type};
|
||||
},
|
||||
game.value.currentPlayer
|
||||
);
|
||||
const pieceType = type === 'cat' ? 'cat' : 'kitten';
|
||||
|
||||
await place(game, row, col, turnPlayer, pieceType);
|
||||
await boop(game, row, col, pieceType);
|
||||
const winner = await checkWin(game);
|
||||
if(winner) return { winner: winner as WinnerType };
|
||||
|
||||
await checkGraduates(game);
|
||||
await handleCheckFullBoard(game, turnPlayer);
|
||||
return { winner: null };
|
||||
}
|
||||
const turn = registry.register('turn <player>', handleTurn);
|
||||
|
||||
export const prompts = {
|
||||
play: (player: PlayerType, row: number, col: number, type?: PieceType) => {
|
||||
if (type) {
|
||||
return `play ${player} ${row} ${col} ${type}`;
|
||||
}
|
||||
return `play ${player} ${row} ${col}`;
|
||||
},
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import parts from './parts.csv';
|
||||
import {createRegion, moveToRegion, Region} from "boardgame-core";
|
||||
import {createPartsFromTable} from "boardgame-core";
|
||||
import {Part} from "boardgame-core";
|
||||
import {IGameContext} from "boardgame-core";
|
||||
|
||||
export const BOARD_SIZE = 6;
|
||||
export const MAX_PIECES_PER_PLAYER = 8;
|
||||
export const WIN_LENGTH = 3;
|
||||
|
||||
export type PlayerType = 'white' | 'black';
|
||||
export type PieceType = 'kitten' | 'cat';
|
||||
export type WinnerType = PlayerType | 'draw' | null;
|
||||
export type RegionType = 'white' | 'black' | 'board' | '';
|
||||
export type BoopPartMeta = { player: PlayerType; type: PieceType };
|
||||
export type BoopPart = Part<BoopPartMeta>;
|
||||
|
||||
export function createInitialState() {
|
||||
const pieces = createPartsFromTable(
|
||||
parts,
|
||||
(item: {player: string, type: string}, index: number) => `${item.player}-${item.type}-${index + 1}`,
|
||||
(item: {count: number}) => item.count
|
||||
) as Record<string, BoopPart>;
|
||||
|
||||
// Initialize region childIds
|
||||
const whiteRegion = createRegion('white', []);
|
||||
const blackRegion = createRegion('black', []);
|
||||
const boardRegion = createRegion('board', [
|
||||
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
||||
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
||||
]);
|
||||
|
||||
// Populate region childIds based on piece regionId
|
||||
for (const part of Object.values(pieces)) {
|
||||
if(part.type !== 'kitten') continue;
|
||||
if (part.player === 'white' ) {
|
||||
moveToRegion(part, null, whiteRegion);
|
||||
} else if (part.player === 'black') {
|
||||
moveToRegion(part, null, blackRegion);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
regions: {
|
||||
white: whiteRegion,
|
||||
black: blackRegion,
|
||||
board: boardRegion,
|
||||
} as Record<RegionType, Region>,
|
||||
pieces,
|
||||
currentPlayer: 'white' as PlayerType,
|
||||
winner: null as WinnerType,
|
||||
};
|
||||
}
|
||||
export type BoopState = ReturnType<typeof createInitialState>;
|
||||
export type BoopGame = IGameContext<BoopState>;
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './data';
|
||||
export * from './commands';
|
||||
@@ -1,6 +0,0 @@
|
||||
type,player,count
|
||||
string,string,int
|
||||
kitten,white,8
|
||||
kitten,black,8
|
||||
cat,white,8
|
||||
cat,black,8
|
||||
|
-8
@@ -1,8 +0,0 @@
|
||||
type Table = {
|
||||
type: string;
|
||||
player: string;
|
||||
count: number;
|
||||
}[];
|
||||
|
||||
declare const data: Table;
|
||||
export default data;
|
||||
@@ -1,66 +0,0 @@
|
||||
# Boop
|
||||
|
||||
## Game Overview
|
||||
|
||||
**"boop."** is a deceptively cute, oh-so-snoozy strategy game. Players compete to place their cats on a quilted bed, pushing other pieces out of the way.
|
||||
|
||||
- **Players:** 2
|
||||
- **Ages:** 10+
|
||||
- **Play Time:** 15–20 minutes
|
||||
|
||||
## Components
|
||||
|
||||
- 1 Quilted Fabric Board (the "Bed") — 6×6 grid
|
||||
- 8 White Kittens and 8 White Cats
|
||||
- 8 Black Kittens and 8 Black Cats
|
||||
|
||||
## Objective
|
||||
|
||||
Be the first player to line up **three Cats** in a row (horizontally, vertically, or diagonally) on the 6×6 grid.
|
||||
|
||||
## Setup
|
||||
|
||||
- Each player takes their 8 Kittens into their personal supply.
|
||||
- Cats are kept off to the side until a player "graduates" their Kittens.
|
||||
- The board starts empty.
|
||||
|
||||
## How to Play
|
||||
|
||||
On your turn, perform the following steps:
|
||||
|
||||
### 1. Placing Pieces
|
||||
|
||||
Place one piece (Kitten or Cat) from your supply onto any empty space on the bed.
|
||||
|
||||
### 2. The "Boop" Mechanic
|
||||
|
||||
Placing a piece causes a **"boop."** Every piece (yours or your opponent's) in the 8 spaces immediately surrounding the piece you just played is pushed one space away from the placed piece.
|
||||
|
||||
- **Chain Reactions:** A "booped" piece does **not** cause another boop. Only the piece being *placed* triggers boops.
|
||||
- **Obstructions:** If there is a piece behind the piece being booped (i.e., the space it would be pushed into is occupied), the boop does not happen — both pieces stay put.
|
||||
- **Falling off the Bed:** If a piece is booped off the edge of the 6×6 grid, it is returned to its owner's supply.
|
||||
|
||||
### 3. Kittens vs. Cats (The Hierarchy)
|
||||
|
||||
- **Kittens** can boop other Kittens.
|
||||
- **Kittens** **cannot** boop Cats.
|
||||
- **Cats** can boop both Kittens and other Cats.
|
||||
|
||||
## Graduation (Getting Cats)
|
||||
|
||||
To win, you need Cats. You obtain Cats by lining up Kittens:
|
||||
|
||||
1. **Three in a Row:** If you line up three of your Kittens in a row (horizontally, vertically, or diagonally), they "graduate."
|
||||
2. **The Process:** Remove the three Kittens from the board and return them to the box. Add three **Cats** to your personal supply.
|
||||
3. **Multiple Rows:** If placing a piece creates multiple rows of three, you graduate all pieces involved in those rows.
|
||||
4. **The 8-Piece Rule:** If a player has all 8 of their pieces on the board (a mix of Kittens and Cats) and no one has three-in-a-row, the player must choose one of their Kittens on the board to graduate into a Cat to free up a piece.
|
||||
|
||||
## How to Win
|
||||
|
||||
A player wins immediately when they get **three Cats in a row** on the bed (horizontally, vertically, or diagonally).
|
||||
|
||||
> **Note:** If you line up three Cats during a Kitten graduation move (e.g., three Cats are moved into a row because of a Kitten being placed), you also win.
|
||||
|
||||
## Strategy Tips
|
||||
|
||||
Because every move pushes other pieces away, players must think several steps ahead to "trap" their own pieces into a row while knocking their opponent's pieces off the board or out of alignment.
|
||||
@@ -1,76 +0,0 @@
|
||||
import {
|
||||
BOARD_SIZE,
|
||||
BoopGame,
|
||||
BoopPart,
|
||||
BoopState,
|
||||
PieceType,
|
||||
PlayerType,
|
||||
RegionType,
|
||||
WIN_LENGTH
|
||||
} from "./data";
|
||||
|
||||
const DIRS = [
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[-1, 1]
|
||||
]
|
||||
type PT = [number, number];
|
||||
type Line = PT[];
|
||||
export function* getLineCandidates(){
|
||||
for(const [dx, dy] of DIRS){
|
||||
for(let x = 0; x < BOARD_SIZE; x ++)
|
||||
for(let y = 0; y < BOARD_SIZE; y ++){
|
||||
if(!isInBounds(x + dx * (WIN_LENGTH-1), y + dy * (WIN_LENGTH-1))) continue;
|
||||
const line = [];
|
||||
for(let i = 0; i < WIN_LENGTH; i ++){
|
||||
line.push([x + i * dx, y + i * dy]);
|
||||
}
|
||||
yield line as Line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查位置是否在棋盘范围内
|
||||
*/
|
||||
export function isInBounds(x: number, y: number): boolean {
|
||||
return x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE;
|
||||
}
|
||||
|
||||
export function isCellOccupied(game: BoopGame | BoopState, x: number, y: number): boolean {
|
||||
const id = `${x},${y}`;
|
||||
return getState(game).regions.board.partMap[id] !== undefined;
|
||||
}
|
||||
|
||||
export function* getNeighborPositions(x: number = 0, y: number = 0){
|
||||
for(let dx = -1; dx <= 1; dx ++)
|
||||
for(let dy = -1; dy <= 1; dy ++)
|
||||
if(dx !== 0 || dy !== 0)
|
||||
yield [x + dx, y + dy] as PT;
|
||||
}
|
||||
|
||||
export function findPartInRegion(ctx: BoopGame | BoopState, regionId: keyof BoopGame['value']['regions'], type: PieceType, player?: PlayerType): BoopPart | null {
|
||||
const state = getState(ctx);
|
||||
if(!regionId){
|
||||
return Object.values(state.pieces).find(part => match(regionId, part, type, player)) || null;
|
||||
}
|
||||
const id = state.regions[regionId].childIds.find((id: string) => match(regionId, state.pieces[id], type, player));
|
||||
return id ? state.pieces[id] || null : null;
|
||||
}
|
||||
function match(regionId: RegionType, part: BoopPart, type: PieceType, player?: PlayerType){
|
||||
return regionId === part.regionId && part.type === type && (!player || part.player === player);
|
||||
}
|
||||
|
||||
export function findPartAtPosition(ctx: BoopGame | BoopState, row: number, col: number): BoopPart | null {
|
||||
const state = getState(ctx);
|
||||
const id = state.regions.board.partMap[`${row},${col}`];
|
||||
return id ? state.pieces[id] || null : null;
|
||||
}
|
||||
|
||||
function getState(ctx: BoopGame | BoopState): BoopState {
|
||||
if('value' in ctx){
|
||||
return ctx.value;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { h } from 'preact';
|
||||
import { GameUI } from 'boardgame-phaser';
|
||||
import * as boop from './game';
|
||||
import './style.css';
|
||||
import App from "@/ui/App";
|
||||
import {GameScene} from "@/scenes/GameScene";
|
||||
|
||||
const ui = new GameUI({
|
||||
container: document.getElementById('ui-root')!,
|
||||
root: <App gameModule={boop} gameScene={GameScene}/>,
|
||||
root: <App/>,
|
||||
});
|
||||
|
||||
ui.mount();
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { BoopState, PlayerType, BoopPart } from '@/game';
|
||||
|
||||
const BOARD_SIZE = 6;
|
||||
const CELL_SIZE = 80;
|
||||
const BOARD_OFFSET = { x: 80, y: 100 };
|
||||
const BOARD_OFFSET = { x: 80, y: 80 };
|
||||
|
||||
export { BOARD_SIZE, CELL_SIZE, BOARD_OFFSET };
|
||||
|
||||
@@ -20,7 +20,7 @@ export class BoardRenderer {
|
||||
|
||||
this.turnText = this.scene.add.text(
|
||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 30,
|
||||
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 40,
|
||||
'',
|
||||
{
|
||||
fontSize: '22px',
|
||||
@@ -31,7 +31,7 @@ export class BoardRenderer {
|
||||
|
||||
this.infoText = this.scene.add.text(
|
||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 60,
|
||||
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 80,
|
||||
'点击空白处放置小猫,三个大猫连线获胜!',
|
||||
{
|
||||
fontSize: '16px',
|
||||
@@ -62,7 +62,7 @@ export class BoardRenderer {
|
||||
|
||||
g.strokePath();
|
||||
|
||||
this.scene.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y - 50, 'Boop Game', {
|
||||
this.scene.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y - 40, 'Boop Game', {
|
||||
fontSize: '32px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#1f2937',
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createPieceSpawner } from './PieceSpawner';
|
||||
import { SupplyUI } from './SupplyUI';
|
||||
import { PieceTypeSelector } from './PieceTypeSelector';
|
||||
import { WinnerOverlay } from './WinnerOverlay';
|
||||
import { StartOverlay } from './StartOverlay';
|
||||
import { ErrorOverlay } from './ErrorOverlay';
|
||||
|
||||
export class GameScene extends GameHostScene<BoopState> {
|
||||
@@ -14,7 +13,6 @@ export class GameScene extends GameHostScene<BoopState> {
|
||||
private supplyUI!: SupplyUI;
|
||||
private pieceTypeSelector!: PieceTypeSelector;
|
||||
private winnerOverlay!: WinnerOverlay;
|
||||
private startOverlay!: StartOverlay;
|
||||
private errorOverlay!: ErrorOverlay;
|
||||
|
||||
constructor() {
|
||||
@@ -28,8 +26,7 @@ export class GameScene extends GameHostScene<BoopState> {
|
||||
this.boardRenderer = new BoardRenderer(this);
|
||||
this.supplyUI = new SupplyUI(this);
|
||||
this.pieceTypeSelector = new PieceTypeSelector(this);
|
||||
this.winnerOverlay = new WinnerOverlay(this, () => this.restartGame());
|
||||
this.startOverlay = new StartOverlay(this, () => this.startGame());
|
||||
this.winnerOverlay = new WinnerOverlay(this, () => this.gameHost.start());
|
||||
this.errorOverlay = new ErrorOverlay(this);
|
||||
|
||||
// 设置棋子生成器
|
||||
@@ -43,16 +40,6 @@ export class GameScene extends GameHostScene<BoopState> {
|
||||
() => this.gameHost.status.value !== 'running' || !!this.state.winner
|
||||
);
|
||||
|
||||
// 监听游戏状态变化
|
||||
this.addEffect(() => {
|
||||
const status = this.gameHost.status.value;
|
||||
if (status === 'running') {
|
||||
this.startOverlay.hide();
|
||||
} else if (status === 'created') {
|
||||
this.startOverlay.show();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听胜负状态
|
||||
this.addEffect(() => {
|
||||
const winner = this.state.winner;
|
||||
@@ -69,12 +56,14 @@ export class GameScene extends GameHostScene<BoopState> {
|
||||
this.supplyUI.update(this.state);
|
||||
this.pieceTypeSelector.update(this.state);
|
||||
});
|
||||
|
||||
// 启动游戏
|
||||
this.gameHost.start();
|
||||
}
|
||||
|
||||
private handleCellClick(row: number, col: number): void {
|
||||
const selectedType = this.pieceTypeSelector.getSelectedType();
|
||||
const cmd = prompts.play(this.state.currentPlayer, row, col, selectedType);
|
||||
const error = this.gameHost.onInput(cmd);
|
||||
const error = this.gameHost.tryAnswerPrompt(prompts.play, this.state.currentPlayer, row, col, selectedType);
|
||||
if (error) {
|
||||
this.errorOverlay.show(error);
|
||||
}
|
||||
@@ -82,18 +71,14 @@ export class GameScene extends GameHostScene<BoopState> {
|
||||
|
||||
private handlePieceClick(row: number, col: number): void {
|
||||
// 棋盘满时,点击棋子触发升级
|
||||
const cmd = prompts.play(this.state.currentPlayer, row, col);
|
||||
const error = this.gameHost.onInput(cmd);
|
||||
const error = this.gameHost.tryAnswerPrompt(prompts.choose, this.state.currentPlayer, row, col);
|
||||
if (error) {
|
||||
this.errorOverlay.show(error);
|
||||
}
|
||||
}
|
||||
|
||||
private startGame(): void {
|
||||
this.gameHost.start();
|
||||
}
|
||||
|
||||
private restartGame(): void {
|
||||
this.gameHost.start();
|
||||
/** 跳转到菜单场景 */
|
||||
private async goToMenu(): Promise<void> {
|
||||
await this.sceneController.launch('MenuScene');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { ReactiveScene } from 'boardgame-phaser';
|
||||
import Phaser from 'phaser';
|
||||
|
||||
/** 菜单场景配置 */
|
||||
const MENU_CONFIG = {
|
||||
colors: {
|
||||
title: '#fbbf24',
|
||||
buttonText: '#1f2937',
|
||||
buttonBg: 0xfbbf24,
|
||||
buttonBgHover: 0xf59e0b,
|
||||
subtitle: '#6b7280',
|
||||
},
|
||||
fontSize: {
|
||||
title: '48px',
|
||||
button: '24px',
|
||||
subtitle: '16px',
|
||||
},
|
||||
button: {
|
||||
width: 200,
|
||||
height: 80,
|
||||
},
|
||||
positions: {
|
||||
titleY: -120,
|
||||
buttonY: 40,
|
||||
subtitleY: 160,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export class MenuScene extends ReactiveScene {
|
||||
private titleText!: Phaser.GameObjects.Text;
|
||||
private startButtonContainer!: Phaser.GameObjects.Container;
|
||||
private startButtonBg!: Phaser.GameObjects.Rectangle;
|
||||
private startButtonText!: Phaser.GameObjects.Text;
|
||||
|
||||
constructor() {
|
||||
super('MenuScene');
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
|
||||
const center = this.getCenterPosition();
|
||||
|
||||
this.createTitle(center);
|
||||
this.createStartButton(center);
|
||||
this.createSubtitle(center);
|
||||
}
|
||||
|
||||
/** 获取屏幕中心位置 */
|
||||
private getCenterPosition(): { x: number; y: number } {
|
||||
return {
|
||||
x: this.game.scale.width / 2,
|
||||
y: this.game.scale.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建标题文本 */
|
||||
private createTitle(center: { x: number; y: number }): void {
|
||||
this.titleText = this.add.text(
|
||||
center.x,
|
||||
center.y + MENU_CONFIG.positions.titleY,
|
||||
'🐱 BOOP 🐾',
|
||||
{
|
||||
fontSize: MENU_CONFIG.fontSize.title,
|
||||
fontFamily: 'Arial',
|
||||
color: MENU_CONFIG.colors.title,
|
||||
fontStyle: 'bold',
|
||||
}
|
||||
).setOrigin(0.5);
|
||||
|
||||
// 标题入场动画
|
||||
this.titleText.setScale(0);
|
||||
this.tweens.add({
|
||||
targets: this.titleText,
|
||||
scale: 1,
|
||||
duration: 600,
|
||||
ease: 'Back.easeOut',
|
||||
});
|
||||
|
||||
// 标题呼吸动画
|
||||
this.tweens.add({
|
||||
targets: this.titleText,
|
||||
scale: 1.05,
|
||||
duration: 1000,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: 'Sine.easeInOut',
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建开始按钮 */
|
||||
private createStartButton(center: { x: number; y: number }): void {
|
||||
const { button, colors } = MENU_CONFIG;
|
||||
|
||||
this.startButtonBg = this.add.rectangle(
|
||||
0,
|
||||
0,
|
||||
button.width,
|
||||
button.height,
|
||||
colors.buttonBg
|
||||
).setOrigin(0.5).setInteractive({ useHandCursor: true });
|
||||
|
||||
this.startButtonText = this.add.text(
|
||||
0,
|
||||
0,
|
||||
'Start Game',
|
||||
{
|
||||
fontSize: MENU_CONFIG.fontSize.button,
|
||||
fontFamily: 'Arial',
|
||||
color: colors.buttonText,
|
||||
fontStyle: 'bold',
|
||||
}
|
||||
).setOrigin(0.5);
|
||||
|
||||
this.startButtonContainer = this.add.container(
|
||||
center.x,
|
||||
center.y + MENU_CONFIG.positions.buttonY,
|
||||
[this.startButtonBg, this.startButtonText]
|
||||
);
|
||||
|
||||
// 按钮交互
|
||||
this.setupButtonInteraction();
|
||||
}
|
||||
|
||||
/** 设置按钮交互效果 */
|
||||
private setupButtonInteraction(): void {
|
||||
this.startButtonBg.on('pointerover', () => {
|
||||
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBgHover);
|
||||
this.tweens.add({
|
||||
targets: this.startButtonContainer,
|
||||
scale: 1.05,
|
||||
duration: 100,
|
||||
});
|
||||
});
|
||||
|
||||
this.startButtonBg.on('pointerout', () => {
|
||||
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBg);
|
||||
this.tweens.add({
|
||||
targets: this.startButtonContainer,
|
||||
scale: 1,
|
||||
duration: 100,
|
||||
});
|
||||
});
|
||||
|
||||
this.startButtonBg.on('pointerdown', () => {
|
||||
this.startGame();
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建副标题 */
|
||||
private createSubtitle(center: { x: number; y: number }): void {
|
||||
this.add.text(
|
||||
center.x,
|
||||
center.y + MENU_CONFIG.positions.subtitleY,
|
||||
'Click to start playing',
|
||||
{
|
||||
fontSize: MENU_CONFIG.fontSize.subtitle,
|
||||
fontFamily: 'Arial',
|
||||
color: MENU_CONFIG.colors.subtitle,
|
||||
}
|
||||
).setOrigin(0.5);
|
||||
}
|
||||
|
||||
/** 开始游戏 */
|
||||
private async startGame(): Promise<void> {
|
||||
await this.sceneController.launch('GameScene');
|
||||
}
|
||||
}
|
||||
@@ -11,18 +11,18 @@ export class PieceTypeSelector {
|
||||
|
||||
constructor(private scene: Phaser.Scene) {
|
||||
const boardCenterX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2;
|
||||
const selectorY = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 100;
|
||||
const selectorY = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 120;
|
||||
|
||||
this.container = this.scene.add.container(boardCenterX, selectorY);
|
||||
|
||||
const label = this.scene.add.text(-160, 0, '放置:', {
|
||||
fontSize: '18px',
|
||||
fontSize: '16px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#4b5563',
|
||||
}).setOrigin(0.5, 0.5);
|
||||
|
||||
this.kittenButton = this.createPieceButton('kitten', '🐾 小猫', -50);
|
||||
this.catButton = this.createPieceButton('cat', '🐱 大猫', 70);
|
||||
this.kittenButton = this.createPieceButton('kitten', '🐾 小猫', -40);
|
||||
this.catButton = this.createPieceButton('cat', '🐱 大猫', 80);
|
||||
|
||||
this.container.add([label, this.kittenButton, this.catButton]);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export class PieceTypeSelector {
|
||||
private createPieceButton(type: PieceType, text: string, xOffset: number): Phaser.GameObjects.Container {
|
||||
const container = this.scene.add.container(xOffset, 0);
|
||||
|
||||
const bg = this.scene.add.rectangle(0, 0, 100, 40, 0xe5e7eb)
|
||||
const bg = this.scene.add.rectangle(0, 0, 120, 40, 0xe5e7eb)
|
||||
.setStrokeStyle(2, 0x9ca3af);
|
||||
|
||||
const textObj = this.scene.add.text(0, 0, text, {
|
||||
@@ -88,10 +88,10 @@ export class PieceTypeSelector {
|
||||
|
||||
update(state: BoopState): void {
|
||||
const currentPlayer = state.currentPlayer;
|
||||
const { kittensInSupply, catsInSupply, catsOnBoard } = this.countPieces(state, currentPlayer);
|
||||
const { kittensInSupply, catsInSupply } = this.countPieces(state, currentPlayer);
|
||||
|
||||
const kittenAvailable = kittensInSupply > 0;
|
||||
const catsAvailable = catsInSupply + catsOnBoard > 0;
|
||||
const catsAvailable = catsInSupply > 0;
|
||||
|
||||
this.updateButton(
|
||||
this.kittenButton,
|
||||
@@ -104,7 +104,7 @@ export class PieceTypeSelector {
|
||||
this.catButton,
|
||||
catsAvailable,
|
||||
this.selectedType === 'cat',
|
||||
`🐱 大猫 (${catsInSupply + catsOnBoard})`
|
||||
`🐱 大猫 (${catsInSupply})`
|
||||
);
|
||||
|
||||
// 自动切换到可用类型
|
||||
|
||||
@@ -12,13 +12,13 @@ export class SupplyUI {
|
||||
|
||||
constructor(private scene: Phaser.Scene) {
|
||||
const boardCenterX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2;
|
||||
const uiY = BOARD_OFFSET.y - 20;
|
||||
const uiY = BOARD_OFFSET.y - 40;
|
||||
|
||||
// 白色玩家容器
|
||||
this.whiteContainer = this.scene.add.container(boardCenterX - 150, uiY);
|
||||
this.whiteBg = this.scene.add.rectangle(0, 0, 120, 50, 0x000000);
|
||||
this.whiteContainer = this.scene.add.container(boardCenterX - 160, uiY);
|
||||
this.whiteBg = this.scene.add.rectangle(0, 0, 120, 40, 0x000000);
|
||||
this.whiteText = this.scene.add.text(0, 0, '', {
|
||||
fontSize: '16px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#ffffff',
|
||||
align: 'center',
|
||||
@@ -27,10 +27,10 @@ export class SupplyUI {
|
||||
this.whiteContainer.setDepth(100);
|
||||
|
||||
// 黑色玩家容器
|
||||
this.blackContainer = this.scene.add.container(boardCenterX + 150, uiY);
|
||||
this.blackBg = this.scene.add.rectangle(0, 0, 120, 50, 0x333333);
|
||||
this.blackContainer = this.scene.add.container(boardCenterX + 160, uiY);
|
||||
this.blackBg = this.scene.add.rectangle(0, 0, 120, 40, 0x333333);
|
||||
this.blackText = this.scene.add.text(0, 0, '', {
|
||||
fontSize: '16px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#ffffff',
|
||||
align: 'center',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { GameScene } from './GameScene';
|
||||
export { MenuScene } from './MenuScene';
|
||||
export { BoardRenderer, BOARD_SIZE, CELL_SIZE, BOARD_OFFSET } from './BoardRenderer';
|
||||
export { createPieceSpawner } from './PieceSpawner';
|
||||
export { SupplyUI } from './SupplyUI';
|
||||
|
||||
@@ -1,38 +1,25 @@
|
||||
import {useComputed} from '@preact/signals';
|
||||
import { createGameHost, type GameModule } from 'boardgame-core';
|
||||
import Phaser from 'phaser';
|
||||
import { h } from 'preact';
|
||||
import { PhaserGame, PhaserScene } from 'boardgame-phaser';
|
||||
import {PhaserGame, PhaserScene } from 'boardgame-phaser';
|
||||
import {MenuScene} from "@/scenes/MenuScene";
|
||||
import {useMemo} from "preact/hooks";
|
||||
import * as gameModule from '../game';
|
||||
import {GameScene} from "@/scenes/GameScene";
|
||||
import {createGameHost, type GameModule} from "boardgame-core";
|
||||
import type {BoopState} from "@/game";
|
||||
|
||||
export default function App<TState extends Record<string, unknown>>(props: { gameModule: GameModule<TState>, gameScene: { new(): Phaser.Scene } }) {
|
||||
|
||||
const gameHost = useComputed(() => {
|
||||
const gameHost = createGameHost(props.gameModule);
|
||||
return { gameHost };
|
||||
});
|
||||
|
||||
const scene = useComputed(() => new props.gameScene());
|
||||
|
||||
const handleReset = async () => {
|
||||
gameHost.value.gameHost.start();
|
||||
};
|
||||
const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');
|
||||
export default function App() {
|
||||
const gameHost = useMemo(() => createGameHost(gameModule as unknown as GameModule<BoopState>), []);
|
||||
const gameScene = useMemo(() => new GameScene(), []);
|
||||
const menuScene = useMemo(() => new MenuScene(), []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 flex relative justify-center items-center">
|
||||
<PhaserGame config={{ width: 640, height: 750 }}>
|
||||
<PhaserScene sceneKey="GameScene" scene={scene.value} autoStart data={gameHost.value} />
|
||||
<PhaserGame initialScene="MenuScene" config={{ width: 640, height: 720 }}>
|
||||
<PhaserScene sceneKey="MenuScene" scene={menuScene} />
|
||||
<PhaserScene sceneKey="GameScene" scene={gameScene} data={{gameHost}}/>
|
||||
</PhaserGame>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-100 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'boardgame-phaser': path.resolve(__dirname, '../framework/src/index.ts'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,15 +1,39 @@
|
||||
// Resource management
|
||||
export { DisposableBag } from './utils';
|
||||
export type { IDisposable, DisposableItem } from './utils';
|
||||
export { DisposableBag } from "./utils";
|
||||
export type { IDisposable, DisposableItem } from "./utils";
|
||||
|
||||
// Drag & drop utilities
|
||||
export { dragDropEventEffect, DragDropEventType } from "./utils";
|
||||
export type { DragDropEvent, DragDropCallback } from "./utils";
|
||||
export { hoverEffect } from "./utils";
|
||||
|
||||
// Data-driven object spawning
|
||||
export { spawnEffect } from './spawner';
|
||||
export type { Spawner } from './spawner';
|
||||
export { spawnEffect, createSpawnUpdate } from "./spawner";
|
||||
export type { Spawner, SpawnerCallback } from "./spawner";
|
||||
|
||||
// Scene base classes
|
||||
export { GameHostScene } from './scenes';
|
||||
export type { GameHostSceneOptions } from './scenes';
|
||||
export {
|
||||
ReactiveScene,
|
||||
GameHostScene,
|
||||
FadeScene,
|
||||
FADE_SCENE_KEY,
|
||||
} from "./scenes";
|
||||
export type {
|
||||
ReactiveSceneOptions,
|
||||
ReactiveScenePhaserData,
|
||||
SceneController,
|
||||
GameHostSceneOptions,
|
||||
FadeSceneData,
|
||||
} from "./scenes";
|
||||
|
||||
// React ↔ Phaser bridge
|
||||
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig, GameUI } from './ui';
|
||||
export type { PhaserGameProps, PhaserSceneProps, GameUIOptions } from './ui';
|
||||
export {
|
||||
PhaserGame,
|
||||
PhaserScene,
|
||||
phaserContext,
|
||||
defaultPhaserConfig,
|
||||
GameUI,
|
||||
type PhaserGameContext,
|
||||
type SceneController as PhaserSceneController,
|
||||
} from "./ui";
|
||||
export type { PhaserGameProps, PhaserSceneProps, GameUIOptions } from "./ui";
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ReactiveScene } from "./ReactiveScene";
|
||||
|
||||
export interface FadeSceneData {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理淡入淡出到黑色的过渡场景
|
||||
*/
|
||||
export class FadeScene extends ReactiveScene<FadeSceneData> {
|
||||
private overlay!: Phaser.GameObjects.Rectangle;
|
||||
private isFading = false;
|
||||
|
||||
constructor() {
|
||||
super(FADE_SCENE_KEY);
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
|
||||
// 创建黑色遮罩层,覆盖整个游戏区域
|
||||
const game = this.game;
|
||||
this.overlay = this.add
|
||||
.rectangle(0, 0, game.scale.width, game.scale.height, 0x000000, 1)
|
||||
.setOrigin(0)
|
||||
.setAlpha(1)
|
||||
.setDepth(999999)
|
||||
.setInteractive({ useHandCursor: false });
|
||||
|
||||
// 防止遮罩阻挡输入
|
||||
this.overlay.disableInteractive();
|
||||
}
|
||||
|
||||
/**
|
||||
* 淡入(从黑色到透明)
|
||||
* @param duration 动画时长(毫秒)
|
||||
*/
|
||||
fadeIn(duration = 300): Promise<void> {
|
||||
return this.fadeTo(0, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* 淡出(从透明到黑色)
|
||||
* @param duration 动画时长(毫秒)
|
||||
*/
|
||||
fadeOut(duration = 300): Promise<void> {
|
||||
return this.fadeTo(1, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* 淡入淡出到指定透明度
|
||||
*/
|
||||
private fadeTo(targetAlpha: number, duration: number): Promise<void> {
|
||||
// 如果 overlay 还未初始化,直接返回 resolved promise
|
||||
if (!this.overlay) {
|
||||
console.warn("FadeScene: overlay 未初始化,跳过过渡动画");
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.isFading) {
|
||||
console.warn("FadeScene: 正在进行过渡动画");
|
||||
}
|
||||
|
||||
this.isFading = true;
|
||||
this.overlay.setAlpha(targetAlpha === 1 ? 0 : 1);
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
this.tweens.add({
|
||||
targets: this.overlay,
|
||||
alpha: targetAlpha,
|
||||
duration,
|
||||
ease: "Linear",
|
||||
onComplete: () => {
|
||||
this.isFading = false;
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 导出常量供 PhaserGame 使用
|
||||
export const FADE_SCENE_KEY = "__fade__";
|
||||
@@ -1,55 +1,43 @@
|
||||
import Phaser from 'phaser';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import type { GameHost } from 'boardgame-core';
|
||||
import { DisposableBag, type IDisposable } from '../utils';
|
||||
import { ReactiveScene } from "./ReactiveScene";
|
||||
|
||||
type CleanupFn = void | (() => void);
|
||||
import type { GameHost, GameModule } from "boardgame-core";
|
||||
|
||||
export interface GameHostSceneOptions<TState extends Record<string, unknown>> {
|
||||
gameHost: GameHost<TState>;
|
||||
export interface GameHostSceneOptions<
|
||||
TState extends Record<string, unknown>,
|
||||
TResult,
|
||||
TModule extends GameModule<TState, TResult>,
|
||||
> {
|
||||
gameHost: GameHost<TState, TResult, TModule>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export abstract class GameHostScene<TState extends Record<string, unknown>>
|
||||
extends Phaser.Scene
|
||||
implements IDisposable
|
||||
{
|
||||
protected disposables = new DisposableBag();
|
||||
private _gameHost!: GameHost<TState>;
|
||||
public get gameHost(): GameHost<TState> {
|
||||
return this._gameHost;
|
||||
export abstract class GameHostScene<
|
||||
TState extends Record<string, unknown>,
|
||||
TResult,
|
||||
TModule extends GameModule<TState, TResult>,
|
||||
> extends ReactiveScene<GameHostSceneOptions<TState, TResult, TModule>> {
|
||||
public get gameHost(): GameHost<TState, TResult, TModule> {
|
||||
const gameHost = this.initData.gameHost;
|
||||
if (!gameHost) {
|
||||
throw new Error(
|
||||
`GameHostScene (${this.scene.key}): gameHost 未提供。` +
|
||||
"确保在 PhaserScene 组件的 data 属性中传入 gameHost。",
|
||||
);
|
||||
}
|
||||
return gameHost;
|
||||
}
|
||||
|
||||
public get state(): TState {
|
||||
return this.gameHost?.state.value;
|
||||
}
|
||||
public get state(): TState {
|
||||
return this.gameHost?.state.value;
|
||||
}
|
||||
|
||||
addInterruption(promise: Promise<void>){
|
||||
this.gameHost?.addInterruption(promise);
|
||||
}
|
||||
addInterruption(promise: Promise<void>) {
|
||||
this.gameHost?.addInterruption(promise);
|
||||
}
|
||||
|
||||
addTweenInterruption(tween: Phaser.Tweens.Tween){
|
||||
this.addInterruption(new Promise(
|
||||
resolve => tween.once('complete', resolve)
|
||||
));
|
||||
}
|
||||
|
||||
init(data: GameHostSceneOptions<TState>): void {
|
||||
this._gameHost = data.gameHost;
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.events.on('shutdown', this.dispose, this);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposables.dispose();
|
||||
}
|
||||
|
||||
public addDisposable(disposable: IDisposable){
|
||||
this.disposables.add(disposable);
|
||||
}
|
||||
/** 注册响应式监听(场景关闭时自动清理) */
|
||||
public addEffect(fn: () => CleanupFn): void {
|
||||
this.disposables.add(effect(fn));
|
||||
}
|
||||
addTweenInterruption(tween: Phaser.Tweens.Tween) {
|
||||
this.addInterruption(
|
||||
new Promise((resolve) => tween.once("complete", resolve)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { type ReadonlySignal } from "@preact/signals-core";
|
||||
import { Scene } from "phaser";
|
||||
|
||||
import { DisposableBag, type IDisposable } from "../utils";
|
||||
|
||||
import type Phaser from "phaser";
|
||||
|
||||
type CleanupFn = void | (() => void);
|
||||
|
||||
// 前向声明,避免循环导入
|
||||
export interface SceneController {
|
||||
launch(sceneKey: string): Promise<void>;
|
||||
restart(): Promise<void>;
|
||||
currentScene: ReadonlySignal<string | null>;
|
||||
isTransitioning: ReadonlySignal<boolean>;
|
||||
}
|
||||
|
||||
export interface ReactiveScenePhaserData {
|
||||
phaserGame: ReadonlySignal<{ game: Phaser.Game }>;
|
||||
sceneController: SceneController;
|
||||
}
|
||||
|
||||
export interface ReactiveSceneOptions {
|
||||
key?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的响应式 Scene 基类
|
||||
* @typeparam TData - 通过 init(data) 接收的数据类型(必须包含 phaserGame 和 sceneController)
|
||||
*/
|
||||
export abstract class ReactiveScene<TData = object>
|
||||
extends Scene
|
||||
implements IDisposable
|
||||
{
|
||||
protected disposables = new DisposableBag();
|
||||
private _initData?: TData & ReactiveScenePhaserData;
|
||||
|
||||
/**
|
||||
* 获取通过 init() 注入的数据
|
||||
* 在 create() 阶段保证可用
|
||||
*/
|
||||
public get initData(): TData & ReactiveScenePhaserData {
|
||||
if (!this._initData) {
|
||||
throw new Error(
|
||||
`ReactiveScene (${this.scene.key}): initData 尚未初始化。` +
|
||||
"确保场景通过 PhaserScene 组件注册,并在 create() 阶段访问。",
|
||||
);
|
||||
}
|
||||
return this._initData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Phaser game 实例的响应式信号
|
||||
*/
|
||||
public get phaserGame(): ReadonlySignal<{ game: Phaser.Game }> {
|
||||
return this.initData.phaserGame;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取场景控制器
|
||||
*/
|
||||
public get sceneController(): SceneController {
|
||||
return this.initData.sceneController;
|
||||
}
|
||||
|
||||
constructor(key?: string) {
|
||||
super(key);
|
||||
}
|
||||
|
||||
init(data: TData & ReactiveScenePhaserData): void {
|
||||
this._initData = data;
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.events.on("shutdown", this.dispose, this);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposables.dispose();
|
||||
}
|
||||
|
||||
public addDisposable(disposable: IDisposable): void {
|
||||
this.disposables.add(disposable);
|
||||
}
|
||||
|
||||
/** 注册响应式监听(场景关闭时自动清理) */
|
||||
public addEffect(fn: () => CleanupFn): void {
|
||||
this.disposables.addEffect(fn);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,12 @@
|
||||
export { GameHostScene } from './GameHostScene';
|
||||
export type { GameHostSceneOptions } from './GameHostScene';
|
||||
export { ReactiveScene } from "./ReactiveScene";
|
||||
export type {
|
||||
ReactiveSceneOptions,
|
||||
ReactiveScenePhaserData,
|
||||
SceneController,
|
||||
} from "./ReactiveScene";
|
||||
|
||||
export { FadeScene, FADE_SCENE_KEY } from "./FadeScene";
|
||||
export type { FadeSceneData } from "./FadeScene";
|
||||
|
||||
export { GameHostScene } from "./GameHostScene";
|
||||
export type { GameHostSceneOptions } from "./GameHostScene";
|
||||
|
||||
@@ -1,54 +1,70 @@
|
||||
import Phaser from 'phaser';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { effect } from "@preact/signals-core";
|
||||
|
||||
import type Phaser from "phaser";
|
||||
|
||||
type GO = Phaser.GameObjects.GameObject;
|
||||
|
||||
export interface Spawner<TData, TObject extends GO = GO> {
|
||||
/** 数据源迭代器 */
|
||||
getData(): Iterable<TData>;
|
||||
/** 获取数据的唯一键 */
|
||||
getKey(t: TData): string;
|
||||
/** 创建新对象 */
|
||||
onSpawn(t: TData): TObject | null;
|
||||
/** 销毁旧对象 */
|
||||
onDespawn(obj: TObject, t: TData): void;
|
||||
/** 更新已有对象 */
|
||||
onUpdate(t: TData, obj: TObject): void;
|
||||
export interface SpawnerCallback<TData, TObject extends GO = GO> {
|
||||
/** 获取数据的唯一键 */
|
||||
getKey(t: TData): string;
|
||||
/** 创建新对象 */
|
||||
onSpawn(t: TData): TObject | null;
|
||||
/** 销毁旧对象 */
|
||||
onDespawn(obj: TObject, t: TData): void;
|
||||
/** 更新已有对象 */
|
||||
onUpdate(t: TData, obj: TObject): void;
|
||||
}
|
||||
|
||||
export interface Spawner<
|
||||
TData,
|
||||
TObject extends GO = GO,
|
||||
> extends SpawnerCallback<TData, TObject> {
|
||||
/** 数据源迭代器 */
|
||||
getData(): Iterable<TData>;
|
||||
}
|
||||
|
||||
export function spawnEffect<TData, TObject extends GO = GO>(
|
||||
spawner: Spawner<TData, TObject>,
|
||||
): () => void {
|
||||
const objects = new Map<string, TObject>();
|
||||
const spawnData = new Map<string, TData>();
|
||||
spawner: Spawner<TData, TObject>,
|
||||
) {
|
||||
const update = createSpawnUpdate(spawner);
|
||||
|
||||
return effect(() => {
|
||||
const current = new Set<string>();
|
||||
return effect(() => {
|
||||
update(spawner.getData());
|
||||
});
|
||||
}
|
||||
|
||||
for (const t of spawner.getData()) {
|
||||
const key = spawner.getKey(t);
|
||||
current.add(key);
|
||||
export function createSpawnUpdate<TData, TObject extends GO = GO>(
|
||||
spawner: SpawnerCallback<TData, TObject>,
|
||||
) {
|
||||
const entries = new Map<string, { object: TObject; data: TData }>();
|
||||
|
||||
const existing = objects.get(key);
|
||||
if (!existing) {
|
||||
const obj = spawner.onSpawn(t);
|
||||
if (obj) {
|
||||
spawnData.set(key, t);
|
||||
objects.set(key, obj);
|
||||
}
|
||||
} else {
|
||||
if(spawnData.get(key) === t) continue;
|
||||
spawner.onUpdate(t, existing);
|
||||
spawnData.set(key, t);
|
||||
}
|
||||
function update(data: Iterable<TData>) {
|
||||
const current = new Set<string>();
|
||||
|
||||
for (const t of data) {
|
||||
const key = spawner.getKey(t);
|
||||
current.add(key);
|
||||
|
||||
const existing = entries.get(key);
|
||||
if (!existing) {
|
||||
const obj = spawner.onSpawn(t);
|
||||
if (obj) {
|
||||
entries.set(key, { object: obj, data: t });
|
||||
}
|
||||
} else {
|
||||
if (existing.data === t) continue;
|
||||
spawner.onUpdate(t, existing.object);
|
||||
existing.data = t;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, obj] of objects) {
|
||||
if (!current.has(key)) {
|
||||
spawner.onDespawn(obj, spawnData.get(key)!);
|
||||
objects.delete(key);
|
||||
spawnData.delete(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const [key, entry] of entries) {
|
||||
if (!current.has(key)) {
|
||||
spawner.onDespawn(entry.object, entry.data);
|
||||
entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return update;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,39 @@
|
||||
import type { JSX } from "preact";
|
||||
|
||||
export interface GameUIOptions {
|
||||
container: HTMLElement;
|
||||
root: any;
|
||||
container: HTMLElement | string;
|
||||
root: JSX.Element;
|
||||
}
|
||||
|
||||
export class GameUI {
|
||||
private container: HTMLElement;
|
||||
private root: any;
|
||||
private root: JSX.Element;
|
||||
|
||||
constructor(options: GameUIOptions) {
|
||||
this.container = options.container;
|
||||
if (typeof options.container === "string") {
|
||||
const existing = document.getElementById(options.container);
|
||||
if (existing) {
|
||||
this.container = existing;
|
||||
} else {
|
||||
const newContainer = document.createElement("div");
|
||||
newContainer.id = options.container;
|
||||
document.body.appendChild(newContainer);
|
||||
this.container = newContainer;
|
||||
}
|
||||
} else {
|
||||
this.container = options.container;
|
||||
}
|
||||
this.root = options.root;
|
||||
}
|
||||
|
||||
mount(): void {
|
||||
import('preact').then(({ render }) => {
|
||||
import("preact").then(({ render }) => {
|
||||
render(this.root, this.container);
|
||||
});
|
||||
}
|
||||
|
||||
unmount(): void {
|
||||
import('preact').then(({ render }) => {
|
||||
import("preact").then(({ render }) => {
|
||||
render(null, this.container);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,75 +1,247 @@
|
||||
import Phaser from 'phaser';
|
||||
import { signal, useSignal, useSignalEffect } from '@preact/signals';
|
||||
import { createContext, h } from 'preact';
|
||||
import { useContext } from 'preact/hooks';
|
||||
import {ReadonlySignal} from "@preact/signals-core";
|
||||
import { signal, useSignal, useSignalEffect } from "@preact/signals";
|
||||
import Phaser, { AUTO, Scene } from "phaser";
|
||||
import { createContext } from "preact";
|
||||
import { useContext, useEffect, useRef } from "preact/hooks";
|
||||
|
||||
export const phaserContext = createContext<ReadonlySignal<Phaser.Game | undefined>>(signal(undefined));
|
||||
import {
|
||||
FadeScene as FadeSceneClass,
|
||||
FADE_SCENE_KEY,
|
||||
} from "../scenes/FadeScene";
|
||||
|
||||
import type { ReactiveScene } from "../scenes";
|
||||
import type { ReadonlySignal } from "@preact/signals-core";
|
||||
|
||||
export interface SceneController {
|
||||
/** 启动场景(带淡入淡出过渡) */
|
||||
launch(sceneKey: string): Promise<void>;
|
||||
/** 重新启动当前场景(带淡入淡出过渡) */
|
||||
restart(): Promise<void>;
|
||||
/** 当前活跃场景 key */
|
||||
currentScene: ReadonlySignal<string | null>;
|
||||
/** 是否正在过渡 */
|
||||
isTransitioning: ReadonlySignal<boolean>;
|
||||
}
|
||||
|
||||
export interface PhaserGameContext {
|
||||
game: Phaser.Game;
|
||||
sceneController: SceneController;
|
||||
}
|
||||
|
||||
export const phaserContext =
|
||||
createContext<ReadonlySignal<PhaserGameContext> | null>(null);
|
||||
|
||||
export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
|
||||
type: Phaser.AUTO,
|
||||
width: 560,
|
||||
height: 560,
|
||||
parent: 'phaser-container',
|
||||
backgroundColor: '#f9fafb',
|
||||
scene: [],
|
||||
type: AUTO,
|
||||
width: 560,
|
||||
height: 560,
|
||||
parent: "phaser-container",
|
||||
backgroundColor: "#f9fafb",
|
||||
scene: [],
|
||||
disableContextMenu: true,
|
||||
};
|
||||
|
||||
export interface PhaserGameProps {
|
||||
config?: Partial<Phaser.Types.Core.GameConfig>;
|
||||
children?: any;
|
||||
config?: Partial<Phaser.Types.Core.GameConfig>;
|
||||
/** 初始启动的场景 key */
|
||||
initialScene?: string;
|
||||
children?: preact.ComponentChildren;
|
||||
}
|
||||
|
||||
export function PhaserGame(props: PhaserGameProps) {
|
||||
const gameSignal = useSignal<Phaser.Game>();
|
||||
const gameSignal = useSignal<PhaserGameContext>({
|
||||
game: undefined!,
|
||||
sceneController: undefined!,
|
||||
});
|
||||
const initialSceneLaunched = useRef(false);
|
||||
|
||||
useSignalEffect(() => {
|
||||
const config: Phaser.Types.Core.GameConfig = {
|
||||
...defaultPhaserConfig,
|
||||
...props.config,
|
||||
};
|
||||
const phaserGame = new Phaser.Game(config);
|
||||
gameSignal.value = phaserGame;
|
||||
useSignalEffect(() => {
|
||||
const config: Phaser.Types.Core.GameConfig = {
|
||||
...defaultPhaserConfig,
|
||||
...props.config,
|
||||
};
|
||||
const phaserGame = new Phaser.Game(config);
|
||||
|
||||
return () => {
|
||||
gameSignal.value = undefined;
|
||||
phaserGame.destroy(true);
|
||||
};
|
||||
});
|
||||
// 添加 FadeScene 并启动它来初始化 overlay
|
||||
const fadeScene = new FadeSceneClass();
|
||||
phaserGame.scene.add(FADE_SCENE_KEY, fadeScene, true); // 改为 true 以触发 create
|
||||
|
||||
return (
|
||||
<div id="phaser-container" className="w-full h-full">
|
||||
<phaserContext.Provider value={gameSignal}>
|
||||
{props.children}
|
||||
</phaserContext.Provider>
|
||||
</div>
|
||||
);
|
||||
// 创建 SceneController
|
||||
const currentScene = signal<string | null>(null);
|
||||
const isTransitioning = signal(false);
|
||||
|
||||
const sceneController: SceneController = {
|
||||
async launch(sceneKey: string) {
|
||||
if (isTransitioning.value) {
|
||||
console.warn("SceneController: 正在进行场景切换");
|
||||
return;
|
||||
}
|
||||
|
||||
// 等待场景注册完成(最多等待 100ms)
|
||||
let retries = 0;
|
||||
while (!phaserGame.scene.getScene(sceneKey) && retries < 10) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
retries++;
|
||||
}
|
||||
|
||||
// 验证场景是否已注册
|
||||
if (!phaserGame.scene.getScene(sceneKey)) {
|
||||
console.error(`SceneController: 场景 "${sceneKey}" 未注册`);
|
||||
return;
|
||||
}
|
||||
|
||||
isTransitioning.value = true;
|
||||
const fade = phaserGame.scene.getScene(
|
||||
FADE_SCENE_KEY,
|
||||
) as FadeSceneClass;
|
||||
|
||||
// 淡出到黑色
|
||||
phaserGame.scene.bringToTop(FADE_SCENE_KEY);
|
||||
await fade.fadeOut(300);
|
||||
|
||||
// 停止当前场景
|
||||
if (currentScene.value) {
|
||||
phaserGame.scene.stop(currentScene.value);
|
||||
}
|
||||
|
||||
// 确保场景已注册后再启动
|
||||
// (场景应该已经在 PhaserScene 组件中注册)
|
||||
if (!phaserGame.scene.getScene(sceneKey)) {
|
||||
console.error(`SceneController: 场景 "${sceneKey}" 在切换时仍未注册`);
|
||||
isTransitioning.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 启动新场景
|
||||
phaserGame.scene.start(sceneKey);
|
||||
currentScene.value = sceneKey;
|
||||
|
||||
// 淡入
|
||||
await fade.fadeIn(300);
|
||||
isTransitioning.value = false;
|
||||
},
|
||||
async restart() {
|
||||
if (isTransitioning.value) {
|
||||
console.warn("SceneController: 正在进行场景切换");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentScene.value) {
|
||||
console.warn("SceneController: 没有当前场景,无法 restart");
|
||||
return;
|
||||
}
|
||||
|
||||
const sceneKey = currentScene.value;
|
||||
const scene = phaserGame.scene.getScene(sceneKey);
|
||||
|
||||
if (!scene) {
|
||||
console.error(`SceneController: 场景 "${sceneKey}" 不存在`);
|
||||
return;
|
||||
}
|
||||
|
||||
isTransitioning.value = true;
|
||||
const fade = phaserGame.scene.getScene(
|
||||
FADE_SCENE_KEY,
|
||||
) as FadeSceneClass;
|
||||
|
||||
// 淡出到黑色
|
||||
phaserGame.scene.bringToTop(FADE_SCENE_KEY);
|
||||
await fade.fadeOut(300);
|
||||
|
||||
// 重启当前场景
|
||||
scene.scene.restart();
|
||||
|
||||
// 淡入
|
||||
await fade.fadeIn(300);
|
||||
isTransitioning.value = false;
|
||||
},
|
||||
currentScene,
|
||||
isTransitioning,
|
||||
};
|
||||
|
||||
gameSignal.value = { game: phaserGame, sceneController };
|
||||
|
||||
return () => {
|
||||
gameSignal.value = { game: undefined!, sceneController: undefined! };
|
||||
initialSceneLaunched.current = false;
|
||||
phaserGame.destroy(true);
|
||||
};
|
||||
});
|
||||
|
||||
// 启动初始场景(仅一次)
|
||||
useEffect(() => {
|
||||
const ctx = gameSignal.value;
|
||||
if (
|
||||
!initialSceneLaunched.current &&
|
||||
props.initialScene &&
|
||||
ctx?.sceneController
|
||||
) {
|
||||
initialSceneLaunched.current = true;
|
||||
// 使用 microtask 确保所有子组件的场景注册已完成
|
||||
Promise.resolve().then(() => {
|
||||
ctx.sceneController.launch(props.initialScene!);
|
||||
});
|
||||
}
|
||||
}, [gameSignal.value, props.initialScene]);
|
||||
|
||||
return (
|
||||
<div id="phaser-container" className="w-full h-full">
|
||||
<phaserContext.Provider value={gameSignal}>
|
||||
{props.children}
|
||||
</phaserContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PhaserSceneProps {
|
||||
sceneKey: string;
|
||||
scene: Phaser.Scene;
|
||||
autoStart: boolean;
|
||||
data?: object;
|
||||
children?: any;
|
||||
export interface PhaserSceneProps<TData = {}> {
|
||||
sceneKey?: string;
|
||||
scene: ReactiveScene<TData> | { new (): ReactiveScene<TData> };
|
||||
data?: TData;
|
||||
children?: any;
|
||||
}
|
||||
|
||||
export const phaserSceneContext = createContext<ReadonlySignal<Phaser.Scene | undefined>>(signal(undefined));
|
||||
export function PhaserScene(props: PhaserSceneProps) {
|
||||
const context = useContext(phaserContext);
|
||||
const sceneSignal = useSignal<Phaser.Scene>();
|
||||
export const phaserSceneContext =
|
||||
createContext<ReadonlySignal<ReactiveScene> | null>(null);
|
||||
|
||||
useSignalEffect(() => {
|
||||
const game = context.value;
|
||||
if (!game) return;
|
||||
export function PhaserScene<TData = {}>(props: PhaserSceneProps<TData>) {
|
||||
const phaserGameSignal = useContext(phaserContext);
|
||||
const sceneSignal = useSignal<ReactiveScene<TData>>();
|
||||
const registered = useRef(false);
|
||||
|
||||
game.scene.add(props.sceneKey, props.scene, props.autoStart, props.data);
|
||||
sceneSignal.value = game.scene.getScene(props.sceneKey);
|
||||
return () => {
|
||||
sceneSignal.value = undefined;
|
||||
game.scene.remove(props.sceneKey);
|
||||
};
|
||||
});
|
||||
useSignalEffect(() => {
|
||||
if (!phaserGameSignal) return;
|
||||
const ctx = phaserGameSignal.value;
|
||||
if (!ctx?.game) return;
|
||||
|
||||
return <phaserSceneContext.Provider value={sceneSignal}>{props.children}</phaserSceneContext.Provider>;
|
||||
const game = ctx.game;
|
||||
|
||||
// 注册场景到 Phaser(但不启动)
|
||||
const scene =
|
||||
props.scene instanceof Scene ? props.scene : new props.scene();
|
||||
const sceneKey = props.sceneKey ?? scene.sys.settings.key;
|
||||
if (!game.scene.getScene(sceneKey)) {
|
||||
const initData = {
|
||||
...props.data,
|
||||
phaserGame: phaserGameSignal,
|
||||
sceneController: ctx.sceneController,
|
||||
};
|
||||
game.scene.add(sceneKey, props.scene, false, initData);
|
||||
}
|
||||
|
||||
sceneSignal.value = scene;
|
||||
registered.current = true;
|
||||
|
||||
return () => {
|
||||
sceneSignal.value = undefined;
|
||||
registered.current = false;
|
||||
// 不在这里移除场景,让 SceneController 管理生命周期
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<phaserSceneContext.Provider
|
||||
value={sceneSignal as ReadonlySignal<ReactiveScene>}
|
||||
>
|
||||
{props.children}
|
||||
</phaserSceneContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
export { GameUI } from './GameUI';
|
||||
export type { GameUIOptions } from './GameUI';
|
||||
export { GameUI } from "./GameUI";
|
||||
export type { GameUIOptions } from "./GameUI";
|
||||
|
||||
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig } from './PhaserBridge';
|
||||
export type { PhaserGameProps, PhaserSceneProps } from './PhaserBridge';
|
||||
export {
|
||||
PhaserGame,
|
||||
PhaserScene,
|
||||
phaserContext,
|
||||
defaultPhaserConfig,
|
||||
type PhaserGameContext,
|
||||
type SceneController,
|
||||
} from "./PhaserBridge";
|
||||
export type { PhaserGameProps, PhaserSceneProps } from "./PhaserBridge";
|
||||
|
||||
@@ -1,46 +1,52 @@
|
||||
import { effect } from "@preact/signals-core";
|
||||
|
||||
export interface IDisposable {
|
||||
dispose(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type DisposableItem = IDisposable | (() => void);
|
||||
|
||||
export class DisposableBag implements IDisposable {
|
||||
private _disposables = new Set<DisposableItem>();
|
||||
private _isDisposed = false;
|
||||
private _disposables = new Set<DisposableItem>();
|
||||
private _isDisposed = false;
|
||||
|
||||
get isDisposed(): boolean {
|
||||
return this._isDisposed;
|
||||
constructor(go?: Phaser.GameObjects.GameObject) {
|
||||
if (go) go.on("shutdown", () => this.dispose());
|
||||
}
|
||||
|
||||
get isDisposed(): boolean {
|
||||
return this._isDisposed;
|
||||
}
|
||||
|
||||
add(item: DisposableItem): void {
|
||||
if (this._isDisposed) {
|
||||
this._execute(item);
|
||||
return;
|
||||
}
|
||||
this._disposables.add(item);
|
||||
}
|
||||
|
||||
addEffect(fn: () => void) {
|
||||
this.add(effect(fn));
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
|
||||
this._isDisposed = true;
|
||||
|
||||
for (const item of this._disposables) {
|
||||
this._execute(item);
|
||||
}
|
||||
|
||||
add(item: DisposableItem): void {
|
||||
if (this._isDisposed) {
|
||||
this._execute(item);
|
||||
return;
|
||||
}
|
||||
this._disposables.add(item);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
|
||||
this._isDisposed = true;
|
||||
|
||||
for (const item of this._disposables) {
|
||||
try {
|
||||
this._execute(item);
|
||||
} catch (error) {
|
||||
console.error('Error during resource disposal:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this._disposables.clear();
|
||||
}
|
||||
|
||||
private _execute(item: DisposableItem): void {
|
||||
if (typeof item === 'function') {
|
||||
item();
|
||||
} else {
|
||||
item.dispose();
|
||||
}
|
||||
this._disposables.clear();
|
||||
}
|
||||
|
||||
private _execute(item: DisposableItem): void {
|
||||
if (typeof item === "function") {
|
||||
item();
|
||||
} else {
|
||||
item.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { DisposableBag } from "./disposable";
|
||||
|
||||
type PointerRecord = {
|
||||
id: number;
|
||||
button: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export enum DragDropEventType {
|
||||
DOWN,
|
||||
UP,
|
||||
MOVE,
|
||||
ALTBUTTON,
|
||||
}
|
||||
|
||||
export type DragDropEvent = {
|
||||
type: DragDropEventType;
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
};
|
||||
|
||||
export type DragDropCallback = (event: DragDropEvent) => void;
|
||||
|
||||
export function dragDropEventEffect(
|
||||
gameObject: Phaser.GameObjects.GameObject,
|
||||
callback: DragDropCallback,
|
||||
disposables?: DisposableBag,
|
||||
): () => void {
|
||||
let isDragging = false;
|
||||
let down: PointerRecord | null = null;
|
||||
|
||||
function onPointerDown(pointer: Phaser.Input.Pointer) {
|
||||
if (down !== null) {
|
||||
if (pointer.button === down.button) return;
|
||||
callback({
|
||||
type: DragDropEventType.ALTBUTTON,
|
||||
deltaX: pointer.x - down.x,
|
||||
deltaY: pointer.y - down.y,
|
||||
});
|
||||
return;
|
||||
}
|
||||
isDragging = true;
|
||||
down = {
|
||||
id: pointer.id,
|
||||
button: pointer.button,
|
||||
x: pointer.x,
|
||||
y: pointer.y,
|
||||
};
|
||||
|
||||
const event: DragDropEvent = {
|
||||
type: DragDropEventType.DOWN,
|
||||
deltaX: 0,
|
||||
deltaY: 0,
|
||||
};
|
||||
callback(event);
|
||||
}
|
||||
|
||||
function onPointerUp(pointer: Phaser.Input.Pointer) {
|
||||
if (
|
||||
!isDragging ||
|
||||
!down ||
|
||||
pointer.id !== down.id ||
|
||||
pointer.button !== down.button
|
||||
)
|
||||
return;
|
||||
|
||||
const deltaX = pointer.x - down.x;
|
||||
const deltaY = pointer.y - down.y;
|
||||
|
||||
isDragging = false;
|
||||
const event: DragDropEvent = {
|
||||
type: DragDropEventType.UP,
|
||||
deltaX,
|
||||
deltaY,
|
||||
};
|
||||
callback(event);
|
||||
down = null;
|
||||
}
|
||||
|
||||
function onPointerMove(pointer: Phaser.Input.Pointer) {
|
||||
if (!isDragging || !down || pointer.id !== down.id) return;
|
||||
|
||||
const deltaX = pointer.x - down.x;
|
||||
const deltaY = pointer.y - down.y;
|
||||
const event: DragDropEvent = {
|
||||
type: DragDropEventType.MOVE,
|
||||
deltaX,
|
||||
deltaY,
|
||||
};
|
||||
callback(event);
|
||||
}
|
||||
|
||||
gameObject.on("pointerdown", onPointerDown);
|
||||
gameObject.scene.input.on("pointerup", onPointerUp);
|
||||
gameObject.scene.input.on("pointermove", onPointerMove);
|
||||
|
||||
const dispose = () => {
|
||||
gameObject.off("pointerdown", onPointerDown);
|
||||
gameObject.scene.input.off("pointerup", onPointerUp);
|
||||
gameObject.scene.input.off("pointermove", onPointerMove);
|
||||
};
|
||||
|
||||
disposables?.add(dispose);
|
||||
return dispose;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
type HoverCallback = (hovering: boolean) => void;
|
||||
export function hoverEffect(
|
||||
gameObject: Phaser.GameObjects.GameObject,
|
||||
callback: HoverCallback,
|
||||
) {
|
||||
let isHovering = false;
|
||||
const onPointerOver = () => {
|
||||
if (isHovering) return;
|
||||
isHovering = true;
|
||||
callback(true);
|
||||
};
|
||||
const onPointerOut = () => {
|
||||
if (!isHovering) return;
|
||||
isHovering = false;
|
||||
callback(false);
|
||||
};
|
||||
|
||||
gameObject.on("pointerover", onPointerOver);
|
||||
gameObject.on("pointerout", onPointerOut);
|
||||
const cleanup = () => {
|
||||
gameObject.off("pointerover", onPointerOver);
|
||||
gameObject.off("pointerout", onPointerOut);
|
||||
gameObject.off("destroy", cleanup);
|
||||
};
|
||||
|
||||
gameObject.once("destroy", cleanup);
|
||||
return cleanup;
|
||||
}
|
||||
@@ -1,2 +1,9 @@
|
||||
export { DisposableBag } from './disposable';
|
||||
export type { IDisposable, DisposableItem } from './disposable';
|
||||
export { DisposableBag } from "./disposable";
|
||||
export type { IDisposable, DisposableItem } from "./disposable";
|
||||
export {
|
||||
dragDropEventEffect,
|
||||
DragDropEventType,
|
||||
type DragDropEvent,
|
||||
type DragDropCallback,
|
||||
} from "./dnd";
|
||||
export * from "./hover";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Onitama - boardgame-phaser</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="ui-root"></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "onitama-game",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@preact/signals-core": "^1.5.1",
|
||||
"boardgame-core": "link:../../../boardgame-core",
|
||||
"boardgame-phaser": "workspace:*",
|
||||
"mutative": "^1.3.0",
|
||||
"phaser": "^3.80.1",
|
||||
"preact": "^10.19.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.8.1",
|
||||
"@preact/signals": "^2.9.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Centralized configuration for Onitama game
|
||||
* All layout, style, and game constants should be defined here
|
||||
*/
|
||||
|
||||
import { Display } from "phaser";
|
||||
|
||||
// Board configuration
|
||||
export const BOARD_SIZE = 5;
|
||||
export const CELL_SIZE = 80;
|
||||
export const BOARD_OFFSET = { x: 240, y: 240 } as const;
|
||||
|
||||
// Card configuration
|
||||
export const CARD_WIDTH = 80;
|
||||
export const CARD_HEIGHT = 120;
|
||||
export const CARD_SPACING = 100;
|
||||
export const CARD_LABEL_OFFSET = 40; // Distance from board edge
|
||||
|
||||
// Menu configuration
|
||||
export const MENU_CONFIG = {
|
||||
colors: {
|
||||
title: "#1f2937",
|
||||
buttonText: "#ffffff",
|
||||
buttonBg: 0x3b82f6,
|
||||
buttonBgHover: 0x2563eb,
|
||||
subtitle: "#6b7280",
|
||||
},
|
||||
fontSize: {
|
||||
title: "48px",
|
||||
button: "24px",
|
||||
subtitle: "16px",
|
||||
},
|
||||
button: {
|
||||
width: 200,
|
||||
height: 80,
|
||||
},
|
||||
positions: {
|
||||
titleY: -120,
|
||||
buttonY: 40,
|
||||
subtitleY: 160,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// UI colors
|
||||
export const COLORS = {
|
||||
red: 0xef4444,
|
||||
black: 0x3b82f6,
|
||||
highlight: 0xfbbf24,
|
||||
highlightStroke: 0xf59e0b,
|
||||
gridLine: 0x6b7280,
|
||||
pawnStroke: 0x1f2937,
|
||||
cardBg: 0xf9fafb,
|
||||
cardStroke: 0x6b7280,
|
||||
cardCenter: 0x3b82f6,
|
||||
cardTarget: 0xef4444,
|
||||
textDark: "#1f2937",
|
||||
textGray: "#6b7280",
|
||||
textInfo: "#4b5563",
|
||||
winnerGold: "#fbbf24",
|
||||
overlayBg: 0x000000,
|
||||
menuButton: 0x6b7280,
|
||||
menuButtonHover: 0x4b5563,
|
||||
} as const;
|
||||
|
||||
// Font configuration
|
||||
export const FONTS = {
|
||||
title: {
|
||||
fontSize: "28px",
|
||||
fontFamily: "Arial",
|
||||
color: COLORS.textDark,
|
||||
},
|
||||
info: {
|
||||
fontSize: "16px",
|
||||
fontFamily: "Arial",
|
||||
color: COLORS.textInfo,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: "12px",
|
||||
fontFamily: "Arial",
|
||||
color: COLORS.textDark,
|
||||
},
|
||||
cardPlayer: {
|
||||
fontSize: "10px",
|
||||
fontFamily: "Arial",
|
||||
color: COLORS.textGray,
|
||||
},
|
||||
pawnLabel: {
|
||||
fontSize: "24px",
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff",
|
||||
},
|
||||
cardLabel: {
|
||||
fontSize: "16px",
|
||||
fontFamily: "Arial",
|
||||
},
|
||||
winner: {
|
||||
fontSize: "36px",
|
||||
fontFamily: "Arial",
|
||||
color: COLORS.winnerGold,
|
||||
},
|
||||
menuButton: {
|
||||
fontSize: "18px",
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Menu button configuration
|
||||
export const MENU_BUTTON = {
|
||||
x: 680,
|
||||
y: 40,
|
||||
width: 120,
|
||||
height: 40,
|
||||
} as const;
|
||||
|
||||
// Text positioning
|
||||
export const TEXT_POSITION = {
|
||||
titleX: 40,
|
||||
titleY: 40,
|
||||
infoX: 40,
|
||||
} as const;
|
||||
|
||||
// Animation durations (in ms)
|
||||
export const ANIMATIONS = {
|
||||
pawnSpawn: 300,
|
||||
pawnDespawn: 300,
|
||||
pawnMove: 400,
|
||||
cardSpawn: 300,
|
||||
cardDespawn: 200,
|
||||
cardMove: 600,
|
||||
cardRotate: 400,
|
||||
highlightSpawn: 250,
|
||||
highlightDespawn: 200,
|
||||
selectionFadeIn: 200,
|
||||
selectionFadeOut: 150,
|
||||
selectionPulse: 500,
|
||||
menuTitle: 600,
|
||||
buttonHover: 100,
|
||||
winnerPulse: 500,
|
||||
clickFeedback: 150,
|
||||
} as const;
|
||||
|
||||
// Grid cell size for card move visualization
|
||||
export const CARD_GRID = {
|
||||
cellSize: 14,
|
||||
gridSize: 5,
|
||||
} as const;
|
||||
|
||||
// Visual style constants
|
||||
export const VISUAL = {
|
||||
pawnRadius: CELL_SIZE / 3,
|
||||
pawnStrokeWidth: 2,
|
||||
selectionRingOffset: 5,
|
||||
selectionRingStrokeWidth: 3,
|
||||
highlightOuterRadius: CELL_SIZE / 3,
|
||||
highlightInnerRadius: CELL_SIZE / 4,
|
||||
highlightHitAreaRadius: CELL_SIZE / 3,
|
||||
cardStrokeWidth: 2,
|
||||
cardTitleOffset: 16,
|
||||
cardPlayerOffset: 16,
|
||||
cardDisabledAlpha: 0.8,
|
||||
overlayAlpha: 0.6,
|
||||
cardBackgroundDepth: -1,
|
||||
} as const;
|
||||
|
||||
// Helper function to convert board coordinates to screen coordinates
|
||||
export function boardToScreen(
|
||||
boardX: number,
|
||||
boardY: number,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: BOARD_OFFSET.x + boardX * CELL_SIZE + CELL_SIZE / 2,
|
||||
y: BOARD_OFFSET.y + (BOARD_SIZE - 1 - boardY) * CELL_SIZE + CELL_SIZE / 2,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to get board center
|
||||
export function getBoardCenter(): { x: number; y: number } {
|
||||
return {
|
||||
x: BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
y: BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to get card label position
|
||||
export function getCardLabelPosition(position: "red" | "black"): {
|
||||
x: number;
|
||||
y: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
} {
|
||||
const boardLeft = BOARD_OFFSET.x;
|
||||
const boardTop = BOARD_OFFSET.y;
|
||||
const boardBottom = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE;
|
||||
const centerX = boardLeft + (BOARD_SIZE * CELL_SIZE) / 2;
|
||||
|
||||
if (position === "red") {
|
||||
return {
|
||||
x: centerX,
|
||||
y: boardBottom + CARD_LABEL_OFFSET,
|
||||
originX: 0.5,
|
||||
originY: 0,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
x: centerX,
|
||||
y: boardTop - CARD_LABEL_OFFSET,
|
||||
originX: 0.5,
|
||||
originY: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get card position
|
||||
export function getCardPosition(
|
||||
position: "red" | "black" | "spare",
|
||||
index: number,
|
||||
): { x: number; y: number } {
|
||||
const boardLeft = BOARD_OFFSET.x;
|
||||
const boardTop = BOARD_OFFSET.y;
|
||||
const boardCenterX = boardLeft + (BOARD_SIZE * CELL_SIZE) / 2;
|
||||
const boardBottom = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE;
|
||||
const boardCenterY = boardTop + (BOARD_SIZE * CELL_SIZE) / 2;
|
||||
|
||||
if (position === "red") {
|
||||
return {
|
||||
x: boardCenterX - (index - 0.5) * CARD_SPACING,
|
||||
y: boardBottom + CARD_HEIGHT / 2 + CARD_LABEL_OFFSET,
|
||||
};
|
||||
} else if (position === "black") {
|
||||
return {
|
||||
x: boardCenterX - (index - 0.5) * CARD_SPACING,
|
||||
y: boardTop - CARD_HEIGHT / 2 - CARD_LABEL_OFFSET,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
x: boardLeft - CARD_WIDTH / 2 - CARD_LABEL_OFFSET,
|
||||
y: boardCenterY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function colorToStr(hex: number) {
|
||||
return Display.Color.ValueToColor(hex).toString();
|
||||
}
|
||||
|
||||
// Tween factory functions
|
||||
export {
|
||||
// Card tweens
|
||||
createCardMoveTween,
|
||||
createCardRotateTween,
|
||||
createCardSpawnTween,
|
||||
createCardDespawnTween,
|
||||
// Pawn tweens
|
||||
createPawnMoveTween,
|
||||
createPawnSpawnTween,
|
||||
createPawnDespawnTween,
|
||||
// Highlight tweens
|
||||
createHighlightSpawnTween,
|
||||
createHighlightDespawnTween,
|
||||
createHighlightClickFeedbackTween,
|
||||
createHighlightInnerPulseTween,
|
||||
createHighlightOuterPulseTween,
|
||||
// Selection tweens
|
||||
createSelectionFadeInTween,
|
||||
createSelectionPulseTween,
|
||||
createSelectionFadeOutTween,
|
||||
createSelectionShowTween,
|
||||
createSelectionRingPulseTween,
|
||||
createSelectionHideTween,
|
||||
// Overlay tweens
|
||||
createWinnerPulseTween,
|
||||
// Types
|
||||
type TweenScene,
|
||||
} from "@/config/tweens";
|
||||
@@ -0,0 +1,322 @@
|
||||
import type Phaser from "phaser";
|
||||
|
||||
import { ANIMATIONS } from "@/config";
|
||||
|
||||
export type TweenScene = Phaser.Scene & {
|
||||
addTweenInterruption(tween: Phaser.Tweens.Tween): void;
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Card Tweens
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function createCardMoveTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
x: number,
|
||||
y: number,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
x,
|
||||
y,
|
||||
duration: ANIMATIONS.cardMove,
|
||||
ease: "Back.easeOut",
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
export function createCardRotateTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
fromAngle: number,
|
||||
toAngle: number,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
angle: { from: fromAngle, to: toAngle },
|
||||
duration: ANIMATIONS.cardRotate,
|
||||
ease: "Back.easeOut",
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
export function createCardSpawnTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
alpha: 1,
|
||||
duration: ANIMATIONS.cardSpawn,
|
||||
ease: "Power2",
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
export function createCardDespawnTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
onComplete: () => void,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
alpha: 0,
|
||||
scale: 0.8,
|
||||
duration: ANIMATIONS.cardDespawn,
|
||||
ease: "Power2",
|
||||
onComplete,
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Pawn Tweens
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function createPawnMoveTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
x: number,
|
||||
y: number,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
x,
|
||||
y,
|
||||
duration: ANIMATIONS.pawnMove,
|
||||
ease: "Back.easeOut",
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
export function createPawnSpawnTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
scale: 1,
|
||||
duration: ANIMATIONS.pawnSpawn,
|
||||
ease: "Back.easeOut",
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
export function createPawnDespawnTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
onComplete: () => void,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
scale: 0,
|
||||
alpha: 0,
|
||||
duration: ANIMATIONS.pawnDespawn,
|
||||
ease: "Back.easeIn",
|
||||
onComplete,
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Highlight Tweens
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function createHighlightSpawnTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
alpha: 1,
|
||||
scale: 1,
|
||||
duration: ANIMATIONS.highlightSpawn,
|
||||
ease: "Back.easeOut",
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
export function createHighlightDespawnTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
onComplete: () => void,
|
||||
): Phaser.Tweens.Tween {
|
||||
const tween = scene.tweens.add({
|
||||
targets: target,
|
||||
scale: 0,
|
||||
alpha: 0,
|
||||
duration: ANIMATIONS.highlightDespawn,
|
||||
ease: "Back.easeIn",
|
||||
onComplete,
|
||||
});
|
||||
scene.addTweenInterruption(tween);
|
||||
return tween;
|
||||
}
|
||||
|
||||
export function createHighlightClickFeedbackTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
scale: 1.5,
|
||||
alpha: 0.8,
|
||||
duration: ANIMATIONS.clickFeedback,
|
||||
ease: "Power2",
|
||||
yoyo: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function createHighlightInnerPulseTween(
|
||||
scene: TweenScene,
|
||||
targets: Phaser.GameObjects.GameObject[],
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets,
|
||||
scale: 1.2,
|
||||
alpha: 0.6,
|
||||
duration: 600,
|
||||
ease: "Sine.easeInOut",
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
});
|
||||
}
|
||||
|
||||
export function createHighlightOuterPulseTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
scale: 1.3,
|
||||
alpha: 0.3,
|
||||
duration: 800,
|
||||
ease: "Sine.easeInOut",
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
delay: 200,
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Selection Tweens
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function createSelectionFadeInTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
onComplete: () => void,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
alpha: 1,
|
||||
scale: 1.05,
|
||||
duration: ANIMATIONS.selectionFadeIn,
|
||||
ease: "Power2",
|
||||
onComplete,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSelectionPulseTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
lineWidth: number,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
alpha: 0.7,
|
||||
lineWidth: lineWidth + 1,
|
||||
duration: ANIMATIONS.selectionPulse,
|
||||
ease: "Sine.easeInOut",
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSelectionFadeOutTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
onComplete: () => void,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
alpha: 0,
|
||||
scale: 0.95,
|
||||
duration: ANIMATIONS.selectionFadeOut,
|
||||
ease: "Power2",
|
||||
onComplete,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSelectionShowTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
onCompleteFadeIn: () => void,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
alpha: 0.8,
|
||||
duration: ANIMATIONS.selectionFadeIn,
|
||||
ease: "Power2",
|
||||
onComplete: onCompleteFadeIn,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSelectionRingPulseTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
scale: 1.15,
|
||||
alpha: 0.6,
|
||||
duration: ANIMATIONS.selectionPulse,
|
||||
ease: "Sine.easeInOut",
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSelectionHideTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
onComplete: () => void,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
alpha: 0,
|
||||
scale: 0.9,
|
||||
duration: ANIMATIONS.selectionFadeOut,
|
||||
ease: "Power2",
|
||||
onComplete: () => {
|
||||
target.destroy();
|
||||
onComplete();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Overlay Tweens
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function createWinnerPulseTween(
|
||||
scene: TweenScene,
|
||||
target: Phaser.GameObjects.GameObject,
|
||||
): Phaser.Tweens.Tween {
|
||||
return scene.tweens.add({
|
||||
targets: target,
|
||||
scale: 1.2,
|
||||
duration: ANIMATIONS.winnerPulse,
|
||||
yoyo: true,
|
||||
repeat: 1,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Re-export onitama game module from boardgame-core
|
||||
* This provides a convenient import path within onitama-game
|
||||
*/
|
||||
export {
|
||||
registry,
|
||||
prompts,
|
||||
start,
|
||||
createInitialState,
|
||||
initializeCards,
|
||||
getAvailableMoves,
|
||||
isValidMove,
|
||||
getCardMoveCandidates,
|
||||
createRegions,
|
||||
createCards,
|
||||
createPawns,
|
||||
createGameInfo,
|
||||
type OnitamaState,
|
||||
type OnitamaGame,
|
||||
type PlayerType,
|
||||
type Card,
|
||||
type CardData,
|
||||
type Pawn,
|
||||
type RegionType,
|
||||
} from "boardgame-core/samples/onitama";
|
||||
@@ -0,0 +1,11 @@
|
||||
import { GameUI } from "boardgame-phaser";
|
||||
|
||||
import "./style.css";
|
||||
import App from "@/ui/App";
|
||||
|
||||
const ui = new GameUI({
|
||||
container: "ui-root",
|
||||
root: <App />,
|
||||
});
|
||||
|
||||
ui.mount();
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Card } from "@/game/onitama";
|
||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
|
||||
import {
|
||||
CARD_WIDTH,
|
||||
CARD_HEIGHT,
|
||||
COLORS,
|
||||
FONTS,
|
||||
CARD_GRID,
|
||||
VISUAL,
|
||||
} from "@/config";
|
||||
|
||||
export interface CardRenderOptions {
|
||||
card: Card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for card game objects
|
||||
* Extracts visual creation logic from CardContainer
|
||||
*/
|
||||
export class CardRenderer {
|
||||
constructor(private readonly scene: OnitamaScene) {}
|
||||
|
||||
/**
|
||||
* Render card visuals into a container
|
||||
* @param container - The container to add visuals to
|
||||
* @param options - Card rendering options
|
||||
*/
|
||||
render(
|
||||
container: Phaser.GameObjects.Container,
|
||||
options: CardRenderOptions,
|
||||
): void {
|
||||
const { card } = options;
|
||||
|
||||
// Create background rectangle
|
||||
const bg = this.scene.add
|
||||
.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, COLORS.cardBg, 1)
|
||||
.setStrokeStyle(VISUAL.cardStrokeWidth, COLORS.cardStroke);
|
||||
container.add(bg);
|
||||
|
||||
// Create title text
|
||||
const title = this.scene.add
|
||||
.text(
|
||||
0,
|
||||
-CARD_HEIGHT / 2 + VISUAL.cardTitleOffset,
|
||||
card.id,
|
||||
FONTS.cardTitle,
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
container.add(title);
|
||||
|
||||
// Create move candidate grid
|
||||
this.renderMoveGrid(container, card);
|
||||
|
||||
// Create starting player text
|
||||
const playerText = this.scene.add
|
||||
.text(
|
||||
0,
|
||||
CARD_HEIGHT / 2 - VISUAL.cardPlayerOffset,
|
||||
card.startingPlayer,
|
||||
FONTS.cardPlayer,
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
container.add(playerText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the 5x5 grid showing move candidates
|
||||
*/
|
||||
private renderMoveGrid(
|
||||
container: Phaser.GameObjects.Container,
|
||||
card: Card,
|
||||
): void {
|
||||
const grid = this.scene.add.graphics();
|
||||
const { cellSize, gridSize } = CARD_GRID;
|
||||
const gridWidth = gridSize * cellSize;
|
||||
const gridHeight = gridSize * cellSize;
|
||||
const gridStartX = -gridWidth / 2;
|
||||
const gridStartY = -gridHeight / 2 + 20;
|
||||
|
||||
for (let row = 0; row < gridSize; row++) {
|
||||
for (let col = 0; col < gridSize; col++) {
|
||||
const x = gridStartX + col * cellSize;
|
||||
const y = gridStartY + row * cellSize;
|
||||
const centerX = x + cellSize / 2;
|
||||
const centerY = y + cellSize / 2;
|
||||
const radius = cellSize / 3;
|
||||
|
||||
// Center position marker
|
||||
if (row === 2 && col === 2) {
|
||||
grid.fillStyle(COLORS.cardCenter, 1);
|
||||
grid.fillCircle(centerX, centerY, radius);
|
||||
} else {
|
||||
// Check if this cell is a move candidate
|
||||
const isTarget = card.moveCandidates.some(
|
||||
(m) => m.dx === col - 2 && m.dy === 2 - row,
|
||||
);
|
||||
if (isTarget) {
|
||||
grid.fillStyle(COLORS.cardTarget, 0.6);
|
||||
grid.fillCircle(centerX, centerY, radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
container.add(grid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standalone card visual at the specified position
|
||||
* Useful for previews or temporary displays
|
||||
*/
|
||||
createStandalone(
|
||||
x: number,
|
||||
y: number,
|
||||
options: CardRenderOptions,
|
||||
): Phaser.GameObjects.Container {
|
||||
const container = this.scene.add.container(x, y);
|
||||
this.render(container, options);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
|
||||
import {
|
||||
COLORS,
|
||||
VISUAL,
|
||||
createHighlightInnerPulseTween,
|
||||
createHighlightOuterPulseTween,
|
||||
} from "@/config";
|
||||
|
||||
export interface HighlightRenderOptions {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for move target highlight visuals
|
||||
* Extracts visual creation logic from HighlightSpawner
|
||||
*/
|
||||
export class HighlightRenderer {
|
||||
constructor(private readonly scene: OnitamaScene) {}
|
||||
|
||||
/**
|
||||
* Render highlight visuals into a container
|
||||
* @param container - The container to add visuals to
|
||||
* @param options - Highlight rendering options
|
||||
*/
|
||||
render(
|
||||
container: Phaser.GameObjects.Container,
|
||||
options: HighlightRenderOptions,
|
||||
): void {
|
||||
const { x, y } = options;
|
||||
|
||||
// Set container position
|
||||
container.setPosition(x, y);
|
||||
|
||||
// Outer circle (animated pulse)
|
||||
const outerCircle = this.scene.add.circle(
|
||||
0,
|
||||
0,
|
||||
VISUAL.highlightOuterRadius,
|
||||
COLORS.black,
|
||||
0.2,
|
||||
);
|
||||
container.add(outerCircle);
|
||||
|
||||
// Inner circle
|
||||
const innerCircle = this.scene.add.circle(
|
||||
0,
|
||||
0,
|
||||
VISUAL.highlightInnerRadius,
|
||||
COLORS.black,
|
||||
0.4,
|
||||
);
|
||||
container.add(innerCircle);
|
||||
|
||||
// Store references for animation
|
||||
container.setData("outerCircle", outerCircle);
|
||||
container.setData("innerCircle", innerCircle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standalone highlight visual at the specified position
|
||||
* Useful for previews or temporary displays
|
||||
*/
|
||||
createStandalone(x: number, y: number): Phaser.GameObjects.Container {
|
||||
const container = this.scene.add.container(x, y);
|
||||
this.render(container, { x, y });
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup pulse animations for highlight circles
|
||||
* @param container - The highlight container
|
||||
*/
|
||||
setupPulseAnimations(container: Phaser.GameObjects.Container): void {
|
||||
const outerCircle = container.getData("outerCircle") as
|
||||
| Phaser.GameObjects.Arc
|
||||
| undefined;
|
||||
const innerCircle = container.getData("innerCircle") as
|
||||
| Phaser.GameObjects.Arc
|
||||
| undefined;
|
||||
|
||||
if (!outerCircle || !innerCircle) return;
|
||||
|
||||
// Inner circle pulse
|
||||
createHighlightInnerPulseTween(this.scene, [outerCircle, innerCircle]);
|
||||
|
||||
// Outer circle staggered pulse
|
||||
createHighlightOuterPulseTween(this.scene, outerCircle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
|
||||
import { CELL_SIZE, COLORS, FONTS, VISUAL } from "@/config";
|
||||
|
||||
export type PawnType = "master" | "student";
|
||||
export type PawnOwner = "red" | "black";
|
||||
|
||||
export interface PawnRenderOptions {
|
||||
owner: PawnOwner;
|
||||
type: PawnType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for pawn game objects
|
||||
* Extracts visual creation logic from PawnContainer
|
||||
*/
|
||||
export class PawnRenderer {
|
||||
constructor(private readonly scene: OnitamaScene) {}
|
||||
|
||||
/**
|
||||
* Render pawn visuals into a container
|
||||
* @param container - The container to add visuals to
|
||||
* @param options - Pawn rendering options
|
||||
*/
|
||||
render(
|
||||
container: Phaser.GameObjects.Container,
|
||||
options: PawnRenderOptions,
|
||||
): void {
|
||||
const { owner, type } = options;
|
||||
|
||||
// Create background circle
|
||||
const bgColor = owner === "red" ? COLORS.red : COLORS.black;
|
||||
const circle = this.scene.add
|
||||
.circle(0, 0, VISUAL.pawnRadius, bgColor, 1)
|
||||
.setStrokeStyle(VISUAL.pawnStrokeWidth, COLORS.pawnStroke);
|
||||
container.add(circle);
|
||||
|
||||
// Create label text
|
||||
const label = type === "master" ? "M" : "S";
|
||||
const text = this.scene.add
|
||||
.text(0, 0, label, FONTS.pawnLabel)
|
||||
.setOrigin(0.5);
|
||||
container.add(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standalone pawn visual (circle + text) at the specified position
|
||||
* Useful for previews or temporary displays
|
||||
*/
|
||||
createStandalone(
|
||||
x: number,
|
||||
y: number,
|
||||
options: PawnRenderOptions,
|
||||
): Phaser.GameObjects.Container {
|
||||
const container = this.scene.add.container(x, y);
|
||||
this.render(container, options);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { GameObjects } from "phaser";
|
||||
|
||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
|
||||
import {
|
||||
CELL_SIZE,
|
||||
COLORS,
|
||||
VISUAL,
|
||||
createSelectionShowTween,
|
||||
createSelectionRingPulseTween,
|
||||
createSelectionHideTween,
|
||||
} from "@/config";
|
||||
|
||||
export interface SelectionRenderOptions {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for pawn selection ring visuals
|
||||
* Extracts selection ring creation and animation logic from PawnContainer
|
||||
*/
|
||||
export class SelectionRenderer {
|
||||
constructor(private readonly scene: OnitamaScene) {}
|
||||
|
||||
/**
|
||||
* Create a selection ring visual
|
||||
* @param parent - The parent container or game object to add the ring to
|
||||
* @returns The selection ring game object
|
||||
*/
|
||||
create(
|
||||
parent: Phaser.GameObjects.Container | Phaser.GameObjects.GameObject,
|
||||
): Phaser.GameObjects.Arc {
|
||||
const ring = this.scene.add
|
||||
.arc(
|
||||
0,
|
||||
0,
|
||||
VISUAL.pawnRadius + VISUAL.selectionRingOffset,
|
||||
0,
|
||||
360,
|
||||
false,
|
||||
COLORS.highlight,
|
||||
0,
|
||||
)
|
||||
.setStrokeStyle(
|
||||
VISUAL.selectionRingStrokeWidth,
|
||||
COLORS.highlightStroke,
|
||||
1,
|
||||
)
|
||||
.setAlpha(0);
|
||||
|
||||
// Add to parent at index 0 (behind other visuals)
|
||||
if (parent instanceof GameObjects.Container) {
|
||||
parent.addAt(ring, 0);
|
||||
}
|
||||
|
||||
return ring;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show selection with fade-in and pulse animation
|
||||
* @param ring - The selection ring to animate
|
||||
* @returns Cleanup function to stop animations
|
||||
*/
|
||||
show(ring: Phaser.GameObjects.Arc): () => void {
|
||||
if (!ring.active) return () => {};
|
||||
|
||||
let pulseTween: Phaser.Tweens.Tween | null = null;
|
||||
const tweens = this.scene.tweens;
|
||||
|
||||
// Fade in animation
|
||||
// DO NOT add interruption here
|
||||
// otherwise the game will stop indefinitely
|
||||
createSelectionShowTween(this.scene, ring, () => {
|
||||
// Start pulse animation after fade-in completes
|
||||
pulseTween = createSelectionRingPulseTween(this.scene, ring);
|
||||
});
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
if (pulseTween) {
|
||||
pulseTween.stop();
|
||||
pulseTween = null;
|
||||
}
|
||||
tweens.killTweensOf(ring);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide selection with fade-out animation
|
||||
* @param ring - The selection ring to animate
|
||||
* @param onComplete - Callback when animation completes
|
||||
*/
|
||||
hide(ring: Phaser.GameObjects.Arc, onComplete?: () => void): void {
|
||||
if (!ring.active) {
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const tweens = this.scene.tweens;
|
||||
|
||||
// Stop any existing tweens on this ring
|
||||
tweens.killTweensOf(ring);
|
||||
|
||||
// Fade out animation
|
||||
createSelectionHideTween(this.scene, ring, () => onComplete?.());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standalone selection ring at the specified position
|
||||
* Useful for previews or temporary displays
|
||||
*/
|
||||
createStandalone(x: number, y: number): Phaser.GameObjects.Container {
|
||||
const container = this.scene.add.container(x, y);
|
||||
this.create(container);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { PawnRenderer } from "./PawnRenderer";
|
||||
export type { PawnRenderOptions, PawnType, PawnOwner } from "./PawnRenderer";
|
||||
|
||||
export { CardRenderer } from "./CardRenderer";
|
||||
export type { CardRenderOptions } from "./CardRenderer";
|
||||
|
||||
export { HighlightRenderer } from "./HighlightRenderer";
|
||||
export type { HighlightRenderOptions } from "./HighlightRenderer";
|
||||
|
||||
export { SelectionRenderer } from "./SelectionRenderer";
|
||||
export type { SelectionRenderOptions } from "./SelectionRenderer";
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
|
||||
import { MENU_CONFIG, ANIMATIONS } from "@/config";
|
||||
|
||||
export class MenuScene extends ReactiveScene {
|
||||
private titleText!: Phaser.GameObjects.Text;
|
||||
private startButtonContainer!: Phaser.GameObjects.Container;
|
||||
private startButtonBg!: Phaser.GameObjects.Rectangle;
|
||||
private startButtonText!: Phaser.GameObjects.Text;
|
||||
|
||||
constructor() {
|
||||
super("MenuScene");
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
|
||||
const center = this.getCenterPosition();
|
||||
|
||||
this.createTitle(center);
|
||||
this.createStartButton(center);
|
||||
this.createSubtitle(center);
|
||||
}
|
||||
|
||||
/** 获取屏幕中心位置 */
|
||||
private getCenterPosition(): { x: number; y: number } {
|
||||
return {
|
||||
x: this.game.scale.width / 2,
|
||||
y: this.game.scale.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建标题文本 */
|
||||
private createTitle(center: { x: number; y: number }): void {
|
||||
this.titleText = this.add
|
||||
.text(center.x, center.y + MENU_CONFIG.positions.titleY, "Onitama", {
|
||||
fontSize: MENU_CONFIG.fontSize.title,
|
||||
fontFamily: "Arial",
|
||||
color: MENU_CONFIG.colors.title,
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
// 标题入场动画
|
||||
this.titleText.setScale(0);
|
||||
this.tweens.add({
|
||||
targets: this.titleText,
|
||||
scale: 1,
|
||||
duration: ANIMATIONS.menuTitle,
|
||||
ease: "Back.easeOut",
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建开始按钮 */
|
||||
private createStartButton(center: { x: number; y: number }): void {
|
||||
const { button, colors } = MENU_CONFIG;
|
||||
|
||||
this.startButtonBg = this.add
|
||||
.rectangle(0, 0, button.width, button.height, colors.buttonBg)
|
||||
.setOrigin(0.5)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
|
||||
this.startButtonText = this.add
|
||||
.text(0, 0, "Start Game", {
|
||||
fontSize: MENU_CONFIG.fontSize.button,
|
||||
fontFamily: "Arial",
|
||||
color: colors.buttonText,
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.startButtonContainer = this.add.container(
|
||||
center.x,
|
||||
center.y + MENU_CONFIG.positions.buttonY,
|
||||
[this.startButtonBg, this.startButtonText],
|
||||
);
|
||||
|
||||
// 按钮交互
|
||||
this.setupButtonInteraction();
|
||||
}
|
||||
|
||||
/** 设置按钮交互效果 */
|
||||
private setupButtonInteraction(): void {
|
||||
this.startButtonBg.on("pointerover", () => {
|
||||
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBgHover);
|
||||
this.tweens.add({
|
||||
targets: this.startButtonContainer,
|
||||
scale: 1.05,
|
||||
duration: ANIMATIONS.buttonHover,
|
||||
});
|
||||
});
|
||||
|
||||
this.startButtonBg.on("pointerout", () => {
|
||||
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBg);
|
||||
this.tweens.add({
|
||||
targets: this.startButtonContainer,
|
||||
scale: 1,
|
||||
duration: ANIMATIONS.buttonHover,
|
||||
});
|
||||
});
|
||||
|
||||
this.startButtonBg.on("pointerdown", () => {
|
||||
this.startGame();
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建副标题 */
|
||||
private createSubtitle(center: { x: number; y: number }): void {
|
||||
this.add
|
||||
.text(
|
||||
center.x,
|
||||
center.y + MENU_CONFIG.positions.subtitleY,
|
||||
"Click to start playing",
|
||||
{
|
||||
fontSize: MENU_CONFIG.fontSize.subtitle,
|
||||
fontFamily: "Arial",
|
||||
color: MENU_CONFIG.colors.subtitle,
|
||||
},
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
}
|
||||
|
||||
/** 开始游戏 */
|
||||
private async startGame(): Promise<void> {
|
||||
await this.sceneController.launch("OnitamaScene");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { GameHostScene, spawnEffect } from "boardgame-phaser";
|
||||
|
||||
import type { OnitamaState, Pawn } from "@/game/onitama";
|
||||
import type { HighlightData } from "@/spawners/HighlightSpawner";
|
||||
import type { OnitamaUIState } from "@/state";
|
||||
import type { MutableSignal } from "boardgame-core";
|
||||
import type Phaser from "phaser";
|
||||
|
||||
import {
|
||||
COLORS,
|
||||
FONTS,
|
||||
MENU_BUTTON,
|
||||
TEXT_POSITION,
|
||||
VISUAL,
|
||||
getBoardCenter,
|
||||
getCardLabelPosition,
|
||||
colorToStr,
|
||||
createWinnerPulseTween,
|
||||
} from "@/config";
|
||||
import { prompts } from "@/game/onitama";
|
||||
import {
|
||||
PawnSpawner,
|
||||
CardSpawner,
|
||||
BOARD_OFFSET,
|
||||
CELL_SIZE,
|
||||
BOARD_SIZE,
|
||||
boardToScreen,
|
||||
HighlightSpawner,
|
||||
} from "@/spawners";
|
||||
import {
|
||||
createUIState,
|
||||
clearSelection,
|
||||
selectPiece,
|
||||
selectCard,
|
||||
} from "@/state";
|
||||
|
||||
export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||
private boardContainer!: Phaser.GameObjects.Container;
|
||||
private gridGraphics!: Phaser.GameObjects.Graphics;
|
||||
private infoText!: Phaser.GameObjects.Text;
|
||||
private winnerOverlay?: Phaser.GameObjects.Container;
|
||||
private cardLabelContainers: Map<string, Phaser.GameObjects.Text> = new Map();
|
||||
private menuButtonContainer!: Phaser.GameObjects.Container;
|
||||
private menuButtonBg!: Phaser.GameObjects.Rectangle;
|
||||
private menuButtonText!: Phaser.GameObjects.Text;
|
||||
|
||||
// UI State managed by MutableSignal
|
||||
public uiState!: MutableSignal<OnitamaUIState>;
|
||||
|
||||
constructor() {
|
||||
super("OnitamaScene");
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
|
||||
// Create UI state signal
|
||||
this.uiState = createUIState();
|
||||
|
||||
this.boardContainer = this.add.container(0, 0);
|
||||
this.gridGraphics = this.add.graphics();
|
||||
this.drawBoard();
|
||||
|
||||
// Add spawners
|
||||
this.disposables.add(spawnEffect(new PawnSpawner(this)));
|
||||
this.disposables.add(spawnEffect(new CardSpawner(this)));
|
||||
this.disposables.add(spawnEffect(new HighlightSpawner(this)));
|
||||
|
||||
// Create card labels
|
||||
this.createCardLabels();
|
||||
|
||||
// Winner overlay effect
|
||||
this.addEffect(() => {
|
||||
const winner = this.state.winner;
|
||||
if (winner) {
|
||||
this.showWinner(winner);
|
||||
} else if (this.winnerOverlay) {
|
||||
this.winnerOverlay.destroy();
|
||||
this.winnerOverlay = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
// Info text
|
||||
this.infoText = this.add.text(
|
||||
TEXT_POSITION.infoX,
|
||||
BOARD_OFFSET.y,
|
||||
"",
|
||||
FONTS.info,
|
||||
);
|
||||
|
||||
// Update info text when UI state changes
|
||||
this.addEffect(() => {
|
||||
this.updateInfoText();
|
||||
});
|
||||
|
||||
// Input handling
|
||||
this.setupInput();
|
||||
|
||||
// Menu button
|
||||
this.createMenuButton();
|
||||
|
||||
// Start the game
|
||||
this.gameHost.start();
|
||||
}
|
||||
|
||||
private createCardLabels(): void {
|
||||
// Red cards label - 棋盘下方
|
||||
const redPos = getCardLabelPosition("red");
|
||||
const redLabel = this.add
|
||||
.text(redPos.x, redPos.y, "RED", {
|
||||
...FONTS.cardLabel,
|
||||
color: colorToStr(COLORS.red),
|
||||
})
|
||||
.setOrigin(redPos.originX, redPos.originY);
|
||||
this.cardLabelContainers.set("red", redLabel);
|
||||
|
||||
// Black cards label - 棋盘上方
|
||||
const blackPos = getCardLabelPosition("black");
|
||||
const blackLabel = this.add
|
||||
.text(blackPos.x, blackPos.y, "BLACK", {
|
||||
...FONTS.cardLabel,
|
||||
color: colorToStr(COLORS.black),
|
||||
})
|
||||
.setOrigin(blackPos.originX, blackPos.originY);
|
||||
this.cardLabelContainers.set("black", blackLabel);
|
||||
}
|
||||
|
||||
private updateInfoText(): void {
|
||||
const currentPlayer = this.state.currentPlayer;
|
||||
|
||||
if (this.state.winner) {
|
||||
this.infoText.setText(`${this.state.winner} wins!`);
|
||||
} else {
|
||||
this.infoText.setText(`Turn ${this.state.turn + 1}\n\n${currentPlayer}`);
|
||||
}
|
||||
}
|
||||
|
||||
private drawBoard(): void {
|
||||
const g = this.gridGraphics;
|
||||
g.lineStyle(2, COLORS.gridLine);
|
||||
|
||||
for (let i = 0; i <= BOARD_SIZE; i++) {
|
||||
g.lineBetween(
|
||||
BOARD_OFFSET.x + i * CELL_SIZE,
|
||||
BOARD_OFFSET.y,
|
||||
BOARD_OFFSET.x + i * CELL_SIZE,
|
||||
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE,
|
||||
);
|
||||
g.lineBetween(
|
||||
BOARD_OFFSET.x,
|
||||
BOARD_OFFSET.y + i * CELL_SIZE,
|
||||
BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE,
|
||||
BOARD_OFFSET.y + i * CELL_SIZE,
|
||||
);
|
||||
}
|
||||
|
||||
g.strokePath();
|
||||
|
||||
this.add.text(
|
||||
TEXT_POSITION.titleX,
|
||||
TEXT_POSITION.titleY,
|
||||
"Onitama",
|
||||
FONTS.title,
|
||||
);
|
||||
}
|
||||
|
||||
private setupInput(): void {
|
||||
// Board cell clicks
|
||||
for (let row = 0; row < BOARD_SIZE; row++) {
|
||||
for (let col = 0; col < BOARD_SIZE; col++) {
|
||||
const pos = boardToScreen(col, row);
|
||||
|
||||
const zone = this.add
|
||||
.zone(pos.x, pos.y, CELL_SIZE, CELL_SIZE)
|
||||
.setInteractive();
|
||||
|
||||
zone.on("pointerdown", () => {
|
||||
if (this.state.winner) return;
|
||||
this.handleCellClick(col, row);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleCellClick(x: number, y: number): void {
|
||||
const pawn = this.getPawnAtPosition(x, y);
|
||||
if (pawn?.owner !== this.state.currentPlayer) {
|
||||
return;
|
||||
}
|
||||
selectPiece(this.uiState, x, y);
|
||||
}
|
||||
|
||||
public onCardClick(cardId: string): void {
|
||||
// 只能选择当前玩家的手牌
|
||||
const currentPlayer = this.state.currentPlayer;
|
||||
const playerCards =
|
||||
currentPlayer === "red" ? this.state.redCards : this.state.blackCards;
|
||||
|
||||
if (!playerCards.includes(cardId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectCard(this.uiState, cardId);
|
||||
}
|
||||
|
||||
public onHighlightClick(data: HighlightData): void {
|
||||
clearSelection(this.uiState);
|
||||
this.executeMove({
|
||||
card: data.card,
|
||||
fromX: data.fromX,
|
||||
fromY: data.fromY,
|
||||
toX: data.toX,
|
||||
toY: data.toY,
|
||||
});
|
||||
}
|
||||
|
||||
private executeMove(move: {
|
||||
card: string;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
}): void {
|
||||
const error = this.gameHost.tryAnswerPrompt(
|
||||
prompts.move,
|
||||
this.state.currentPlayer,
|
||||
move.card,
|
||||
move.fromX,
|
||||
move.fromY,
|
||||
move.toX,
|
||||
move.toY,
|
||||
);
|
||||
if (error) {
|
||||
console.warn("Invalid move:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private getPawnAtPosition(x: number, y: number): Pawn | null {
|
||||
const key = `${x},${y}`;
|
||||
const pawnId = this.state.regions.board.partMap[key];
|
||||
return pawnId ? this.state.pawns[pawnId] : null;
|
||||
}
|
||||
|
||||
/** 创建菜单按钮 */
|
||||
private createMenuButton(): void {
|
||||
this.menuButtonBg = this.add
|
||||
.rectangle(
|
||||
MENU_BUTTON.x,
|
||||
MENU_BUTTON.y,
|
||||
MENU_BUTTON.width,
|
||||
MENU_BUTTON.height,
|
||||
COLORS.menuButton,
|
||||
)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
|
||||
this.menuButtonText = this.add
|
||||
.text(MENU_BUTTON.x, MENU_BUTTON.y, "Menu", FONTS.menuButton)
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.menuButtonContainer = this.add.container(
|
||||
MENU_BUTTON.x,
|
||||
MENU_BUTTON.y,
|
||||
[this.menuButtonBg, this.menuButtonText],
|
||||
);
|
||||
|
||||
this.menuButtonBg.on("pointerover", () => {
|
||||
this.menuButtonBg.setFillStyle(COLORS.menuButtonHover);
|
||||
});
|
||||
|
||||
this.menuButtonBg.on("pointerout", () => {
|
||||
this.menuButtonBg.setFillStyle(COLORS.menuButton);
|
||||
});
|
||||
|
||||
this.menuButtonBg.on("pointerdown", () => {
|
||||
this.goToMenu();
|
||||
});
|
||||
}
|
||||
|
||||
/** 跳转到菜单场景 */
|
||||
private async goToMenu(): Promise<void> {
|
||||
await this.sceneController.launch("MenuScene");
|
||||
}
|
||||
|
||||
private showWinner(winner: string): void {
|
||||
if (this.winnerOverlay) {
|
||||
this.winnerOverlay.destroy();
|
||||
}
|
||||
|
||||
this.winnerOverlay = this.add.container();
|
||||
|
||||
const text = winner === "draw" ? "It's a draw!" : `${winner} wins!`;
|
||||
const center = getBoardCenter();
|
||||
const boardWidth = BOARD_SIZE * CELL_SIZE;
|
||||
const boardHeight = BOARD_SIZE * CELL_SIZE;
|
||||
|
||||
const bg = this.add
|
||||
.rectangle(
|
||||
center.x,
|
||||
center.y,
|
||||
boardWidth,
|
||||
boardHeight,
|
||||
COLORS.overlayBg,
|
||||
VISUAL.overlayAlpha,
|
||||
)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
|
||||
bg.on("pointerdown", () => {
|
||||
this.gameHost.start();
|
||||
});
|
||||
|
||||
this.winnerOverlay.add(bg);
|
||||
|
||||
const winText = this.add
|
||||
.text(center.x, center.y, text, FONTS.winner)
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.winnerOverlay.add(winText);
|
||||
|
||||
createWinnerPulseTween(this, winText);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { OnitamaScene } from "./OnitamaScene";
|
||||
export { MenuScene } from "./MenuScene";
|
||||
@@ -0,0 +1,304 @@
|
||||
import { effect } from "@preact/signals-core";
|
||||
|
||||
import type { Card } from "@/game/onitama";
|
||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
import type { Spawner } from "boardgame-phaser";
|
||||
|
||||
import {
|
||||
CARD_WIDTH,
|
||||
CARD_HEIGHT,
|
||||
COLORS,
|
||||
VISUAL,
|
||||
getCardPosition,
|
||||
createCardMoveTween,
|
||||
createCardRotateTween,
|
||||
createCardSpawnTween,
|
||||
createCardDespawnTween,
|
||||
createSelectionFadeInTween,
|
||||
createSelectionPulseTween,
|
||||
createSelectionFadeOutTween,
|
||||
} from "@/config";
|
||||
import { CardRenderer } from "@/renderers";
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export { CARD_WIDTH, CARD_HEIGHT } from "@/config";
|
||||
|
||||
export interface CardSpawnData {
|
||||
cardId: string;
|
||||
position: "red" | "black" | "spare";
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 继承自 Phaser.GameObjects.Container 的卡牌容器类
|
||||
* 管理卡牌视觉元素和高亮状态
|
||||
*/
|
||||
export class CardContainer extends Phaser.GameObjects.Container {
|
||||
private highlightRect: Phaser.GameObjects.Rectangle | null = null;
|
||||
private highlightTween: Phaser.Tweens.Tween | null = null;
|
||||
private _cardId: string;
|
||||
private _rotation: number = 0;
|
||||
private cardRenderer: CardRenderer;
|
||||
|
||||
constructor(
|
||||
scene: OnitamaScene,
|
||||
cardId: string,
|
||||
card: Card,
|
||||
rotation: number = 0,
|
||||
) {
|
||||
super(scene, 0, 0);
|
||||
this._cardId = cardId;
|
||||
this._rotation = rotation;
|
||||
this.cardRenderer = new CardRenderer(scene);
|
||||
|
||||
// 将容器添加到场景
|
||||
scene.add.existing(this);
|
||||
|
||||
// 应用旋转
|
||||
this.angle = rotation;
|
||||
|
||||
// 使用 CardRenderer 创建卡牌视觉
|
||||
this.cardRenderer.render(this, { card });
|
||||
|
||||
// 使卡牌可点击
|
||||
const hitArea = new Phaser.Geom.Rectangle(
|
||||
-CARD_WIDTH / 2,
|
||||
-CARD_HEIGHT / 2,
|
||||
CARD_WIDTH,
|
||||
CARD_HEIGHT,
|
||||
);
|
||||
this.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
|
||||
|
||||
// 添加场景 effect 监听高亮状态变化
|
||||
this.addHighlightEffect(scene);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新卡牌旋转(带动画)
|
||||
*/
|
||||
setRotation(rotation: number, animated: boolean = false): this {
|
||||
if (animated) {
|
||||
const currentAngle = this.angle;
|
||||
createCardRotateTween(
|
||||
this.scene as OnitamaScene,
|
||||
this,
|
||||
currentAngle,
|
||||
rotation,
|
||||
);
|
||||
} else {
|
||||
this.angle = rotation;
|
||||
}
|
||||
this._rotation = rotation;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 高亮卡牌
|
||||
*/
|
||||
highlight(color: number, lineWidth: number): void {
|
||||
if (!this.active) return;
|
||||
|
||||
if (!this.highlightRect) {
|
||||
// 创建高亮边框(初始透明)
|
||||
const rect = (this.scene as OnitamaScene).add
|
||||
.rectangle(0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0)
|
||||
.setStrokeStyle(lineWidth, color)
|
||||
.setAlpha(0)
|
||||
.setDepth(VISUAL.cardBackgroundDepth);
|
||||
this.highlightRect = rect;
|
||||
this.addAt(this.highlightRect, 0);
|
||||
|
||||
// 淡入动画
|
||||
createSelectionFadeInTween(this.scene as OnitamaScene, rect, () => {
|
||||
// 淡入完成后开始脉冲动画
|
||||
this.highlightTween = createSelectionPulseTween(
|
||||
this.scene as OnitamaScene,
|
||||
rect,
|
||||
lineWidth,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// 如果已经存在,停止当前动画并重新开始脉冲
|
||||
if (this.highlightTween) {
|
||||
this.highlightTween.stop();
|
||||
}
|
||||
this.highlightRect.setStrokeStyle(lineWidth, color);
|
||||
this.highlightTween = createSelectionPulseTween(
|
||||
this.scene as OnitamaScene,
|
||||
this.highlightRect,
|
||||
lineWidth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消高亮
|
||||
*/
|
||||
unhighlight(): void {
|
||||
if (this.highlightRect) {
|
||||
// 停止所有动画
|
||||
if (this.highlightTween) {
|
||||
this.highlightTween.stop();
|
||||
this.highlightTween = null;
|
||||
}
|
||||
this.scene.tweens.killTweensOf(this.highlightRect);
|
||||
|
||||
// 淡出动画
|
||||
createSelectionFadeOutTween(
|
||||
this.scene as OnitamaScene,
|
||||
this.highlightRect,
|
||||
() => {
|
||||
// 淡出完成后销毁矩形
|
||||
this.highlightRect?.destroy();
|
||||
this.highlightRect = null;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加高亮效果的 effect 监听
|
||||
*/
|
||||
private addHighlightEffect(scene: OnitamaScene): void {
|
||||
// 创建一个 effect 来持续监听高亮状态变化
|
||||
const dispose = effect(() => {
|
||||
if (scene.uiState.value.selectedCard === this._cardId) {
|
||||
this.highlight(COLORS.highlight, VISUAL.selectionRingStrokeWidth);
|
||||
} else {
|
||||
this.unhighlight();
|
||||
}
|
||||
});
|
||||
|
||||
// 在容器销毁时清理 effect
|
||||
this.on("destroy", () => {
|
||||
dispose();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
||||
private previousData = new Map<string, CardSpawnData>();
|
||||
private cardRenderer: CardRenderer;
|
||||
|
||||
constructor(public readonly scene: OnitamaScene) {
|
||||
this.cardRenderer = new CardRenderer(scene);
|
||||
}
|
||||
|
||||
*getData(): Iterable<CardSpawnData> {
|
||||
const state = this.scene.state;
|
||||
|
||||
// 红方卡牌
|
||||
for (let i = 0; i < state.redCards.length; i++) {
|
||||
yield { cardId: state.redCards[i], position: "red", index: i };
|
||||
}
|
||||
|
||||
// 黑方卡牌
|
||||
for (let i = 0; i < state.blackCards.length; i++) {
|
||||
yield { cardId: state.blackCards[i], position: "black", index: i };
|
||||
}
|
||||
|
||||
// 备用卡牌
|
||||
if (state.spareCard) {
|
||||
yield { cardId: state.spareCard, position: "spare", index: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
getKey(data: CardSpawnData): string {
|
||||
return data.cardId;
|
||||
}
|
||||
|
||||
private hasPositionChanged(data: CardSpawnData): boolean {
|
||||
const prev = this.previousData.get(data.cardId);
|
||||
if (!prev) return true;
|
||||
return prev.position !== data.position || prev.index !== data.index;
|
||||
}
|
||||
|
||||
onUpdate(data: CardSpawnData, obj: CardContainer): void {
|
||||
const isBlackTurn = this.scene.state.currentPlayer === "black";
|
||||
|
||||
// 检查卡牌是否需要旋转
|
||||
let targetRotation = 0;
|
||||
if (data.position === "black") {
|
||||
targetRotation = -180; // 黑方卡牌始终旋转
|
||||
} else if (data.position === "spare" && isBlackTurn) {
|
||||
targetRotation = -180; // 备用卡牌在黑方回合旋转
|
||||
}
|
||||
|
||||
if (obj.angle !== targetRotation) {
|
||||
obj.setRotation(targetRotation, true);
|
||||
}
|
||||
|
||||
// 只在位置实际变化时才播放移动动画
|
||||
if (!this.hasPositionChanged(data)) {
|
||||
this.previousData.set(data.cardId, { ...data });
|
||||
return;
|
||||
}
|
||||
|
||||
const pos = getCardPosition(data.position, data.index);
|
||||
|
||||
// 播放移动动画并添加中断
|
||||
createCardMoveTween(this.scene, obj, pos.x, pos.y);
|
||||
this.previousData.set(data.cardId, { ...data });
|
||||
}
|
||||
|
||||
onSpawn(data: CardSpawnData): CardContainer {
|
||||
const card = this.scene.state.cards[data.cardId];
|
||||
if (!card) {
|
||||
this.previousData.set(data.cardId, { ...data });
|
||||
// 返回空容器
|
||||
const emptyContainer = new CardContainer(this.scene, data.cardId, {
|
||||
id: data.cardId,
|
||||
regionId: "",
|
||||
position: [],
|
||||
moveCandidates: [],
|
||||
startingPlayer: "red",
|
||||
} as Card);
|
||||
return emptyContainer;
|
||||
}
|
||||
|
||||
// 计算初始旋转角度
|
||||
const isBlackTurn = this.scene.state.currentPlayer === "black";
|
||||
let initialRotation = 0;
|
||||
if (data.position === "black") {
|
||||
initialRotation = -180;
|
||||
} else if (data.position === "spare" && isBlackTurn) {
|
||||
initialRotation = -180;
|
||||
}
|
||||
|
||||
const container = new CardContainer(
|
||||
this.scene,
|
||||
data.cardId,
|
||||
card,
|
||||
initialRotation,
|
||||
);
|
||||
const pos = getCardPosition(data.position, data.index);
|
||||
container.x = pos.x;
|
||||
container.y = pos.y;
|
||||
|
||||
// 设置悬停效果
|
||||
container.on("pointerover", () => {
|
||||
if (this.scene.uiState.value.selectedCard !== data.cardId) {
|
||||
container.setAlpha(VISUAL.cardDisabledAlpha);
|
||||
}
|
||||
});
|
||||
|
||||
container.on("pointerout", () => {
|
||||
container.setAlpha(1);
|
||||
});
|
||||
|
||||
container.on("pointerdown", () => {
|
||||
this.scene.onCardClick(data.cardId);
|
||||
});
|
||||
|
||||
// 初始状态为透明,然后淡入
|
||||
container.setAlpha(0);
|
||||
createCardSpawnTween(this.scene, container);
|
||||
|
||||
this.previousData.set(data.cardId, { ...data });
|
||||
return container;
|
||||
}
|
||||
|
||||
onDespawn(obj: CardContainer): void {
|
||||
createCardDespawnTween(this.scene, obj, () => obj.destroy());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { getAvailableMoves } from "boardgame-core/samples/onitama";
|
||||
import { Geom } from "phaser";
|
||||
|
||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
import type { Spawner } from "boardgame-phaser";
|
||||
|
||||
import {
|
||||
boardToScreen,
|
||||
CELL_SIZE,
|
||||
VISUAL,
|
||||
createHighlightSpawnTween,
|
||||
createHighlightDespawnTween,
|
||||
createHighlightClickFeedbackTween,
|
||||
} from "@/config";
|
||||
import { HighlightRenderer } from "@/renderers";
|
||||
|
||||
export interface HighlightData {
|
||||
key: string;
|
||||
x: number;
|
||||
y: number;
|
||||
card: string;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
}
|
||||
|
||||
export class HighlightSpawner implements Spawner<
|
||||
HighlightData,
|
||||
Phaser.GameObjects.Container
|
||||
> {
|
||||
private renderer: HighlightRenderer;
|
||||
|
||||
constructor(public readonly scene: OnitamaScene) {
|
||||
this.renderer = new HighlightRenderer(scene);
|
||||
}
|
||||
|
||||
*getData(): Iterable<HighlightData> {
|
||||
const state = this.scene.state;
|
||||
const uiState = this.scene.uiState.value;
|
||||
|
||||
// 如果没有选择卡牌或棋子,不显示高亮
|
||||
if (!uiState.selectedCard || !uiState.selectedPiece) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPlayer = state.currentPlayer;
|
||||
const availableMoves = getAvailableMoves(state, currentPlayer);
|
||||
|
||||
// 过滤出符合当前选择的移动
|
||||
for (const move of availableMoves) {
|
||||
if (
|
||||
move.fromX === uiState.selectedPiece.x &&
|
||||
move.fromY === uiState.selectedPiece.y &&
|
||||
move.card === uiState.selectedCard
|
||||
) {
|
||||
const pos = boardToScreen(move.toX, move.toY);
|
||||
yield {
|
||||
key: `${move.fromX}-${move.fromY}-${move.card}-${move.toX}-${move.toY}`,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
card: move.card,
|
||||
fromX: move.fromX,
|
||||
fromY: move.fromY,
|
||||
toX: move.toX,
|
||||
toY: move.toY,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getKey(data: HighlightData): string {
|
||||
return data.key;
|
||||
}
|
||||
|
||||
onUpdate(data: HighlightData, obj: Phaser.GameObjects.Container): void {
|
||||
obj.setPosition(data.x, data.y);
|
||||
}
|
||||
|
||||
onSpawn(data: HighlightData): Phaser.GameObjects.Container {
|
||||
const container = this.scene.add.container(data.x, data.y);
|
||||
|
||||
// 使用 HighlightRenderer 创建视觉元素
|
||||
this.renderer.render(container, { x: data.x, y: data.y });
|
||||
|
||||
// 设置交互区域
|
||||
const hitArea = new Geom.Circle(0, 0, VISUAL.highlightHitAreaRadius);
|
||||
container.setInteractive(hitArea, Phaser.Geom.Circle.Contains);
|
||||
if (container.input) {
|
||||
container.input.cursor = "pointer";
|
||||
}
|
||||
|
||||
// 出现动画:从0缩放和透明淡入
|
||||
container.setAlpha(0);
|
||||
container.setScale(0);
|
||||
createHighlightSpawnTween(this.scene, container);
|
||||
|
||||
// 使用 HighlightRenderer 设置脉冲动画
|
||||
this.renderer.setupPulseAnimations(container);
|
||||
|
||||
container.on("pointerdown", () => {
|
||||
// 点击时的反馈动画
|
||||
createHighlightClickFeedbackTween(this.scene, container);
|
||||
|
||||
this.scene.onHighlightClick(data);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
onDespawn(obj: Phaser.GameObjects.Container): void {
|
||||
// 消失动画:缩小并淡出
|
||||
createHighlightDespawnTween(this.scene, obj, () => obj.destroy());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { effect } from "@preact/signals-core";
|
||||
import { GameObjects } from "phaser";
|
||||
|
||||
import type { Pawn } from "@/game/onitama";
|
||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
import type { Spawner } from "boardgame-phaser";
|
||||
|
||||
import {
|
||||
boardToScreen,
|
||||
createPawnMoveTween,
|
||||
createPawnSpawnTween,
|
||||
createPawnDespawnTween,
|
||||
} from "@/config";
|
||||
import { PawnRenderer, SelectionRenderer } from "@/renderers";
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export { CELL_SIZE, BOARD_OFFSET, BOARD_SIZE, boardToScreen } from "@/config";
|
||||
|
||||
/**
|
||||
* 继承自 Phaser.GameObjects.Container 的棋子容器类
|
||||
* 管理棋子视觉元素和选中状态
|
||||
*/
|
||||
export class PawnContainer extends GameObjects.Container {
|
||||
private selectionRing: Phaser.GameObjects.Arc | null = null;
|
||||
private _position: [number, number];
|
||||
private _owner: "red" | "black";
|
||||
private _type: "master" | "student";
|
||||
private pawnRenderer: PawnRenderer;
|
||||
private selectionRenderer: SelectionRenderer;
|
||||
|
||||
constructor(scene: OnitamaScene, pawn: Pawn) {
|
||||
super(scene, 0, 0);
|
||||
this._owner = pawn.owner;
|
||||
this._type = pawn.type;
|
||||
this._position = pawn.position as [number, number];
|
||||
this.pawnRenderer = new PawnRenderer(scene);
|
||||
this.selectionRenderer = new SelectionRenderer(scene);
|
||||
|
||||
// 将容器添加到场景
|
||||
scene.add.existing(this);
|
||||
|
||||
// 使用 PawnRenderer 创建棋子视觉
|
||||
this.pawnRenderer.render(this, { owner: this._owner, type: this._type });
|
||||
|
||||
// 添加选中状态监听
|
||||
this.addSelectionEffect(scene);
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示选中光环
|
||||
*/
|
||||
showSelection(): void {
|
||||
if (!this.active) return;
|
||||
|
||||
if (!this.selectionRing) {
|
||||
this.selectionRing = this.selectionRenderer.create(this);
|
||||
this.selectionRenderer.show(this.selectionRing);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏选中光环
|
||||
*/
|
||||
hideSelection(): void {
|
||||
if (this.selectionRing) {
|
||||
this.selectionRenderer.hide(this.selectionRing, () => {
|
||||
this.selectionRing = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加选中状态的 effect 监听
|
||||
*/
|
||||
private addSelectionEffect(scene: OnitamaScene): void {
|
||||
const dispose = effect(() => {
|
||||
const uiState = scene.uiState.value;
|
||||
const isSelected =
|
||||
uiState.selectedPiece?.x === this._position[0] &&
|
||||
uiState.selectedPiece?.y === this._position[1];
|
||||
|
||||
if (isSelected) {
|
||||
this.showSelection();
|
||||
} else {
|
||||
this.hideSelection();
|
||||
}
|
||||
});
|
||||
|
||||
this.on("destroy", () => {
|
||||
dispose();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新棋子位置
|
||||
*/
|
||||
updatePosition(
|
||||
newPosition: [number, number],
|
||||
animated: boolean = false,
|
||||
): void {
|
||||
this._position = newPosition;
|
||||
const targetPos = boardToScreen(newPosition[0], newPosition[1]);
|
||||
|
||||
if (animated) {
|
||||
createPawnMoveTween(
|
||||
this.scene as OnitamaScene,
|
||||
this,
|
||||
targetPos.x,
|
||||
targetPos.y,
|
||||
);
|
||||
} else {
|
||||
this.x = targetPos.x;
|
||||
this.y = targetPos.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PawnSpawner implements Spawner<Pawn, PawnContainer> {
|
||||
private previousPositions = new Map<string, [number, number]>();
|
||||
|
||||
constructor(public readonly scene: OnitamaScene) {}
|
||||
|
||||
*getData() {
|
||||
for (const pawn of Object.values(this.scene.state.pawns)) {
|
||||
if (pawn.regionId === "board") {
|
||||
yield pawn;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getKey(pawn: Pawn): string {
|
||||
return pawn.id;
|
||||
}
|
||||
|
||||
onUpdate(pawn: Pawn, obj: PawnContainer): void {
|
||||
const [x, y] = pawn.position;
|
||||
const prevPos = this.previousPositions.get(pawn.id);
|
||||
const hasMoved = !prevPos || prevPos[0] !== x || prevPos[1] !== y;
|
||||
|
||||
if (hasMoved) {
|
||||
obj.updatePosition([x, y], !!prevPos);
|
||||
}
|
||||
|
||||
this.previousPositions.set(pawn.id, [x, y]);
|
||||
}
|
||||
|
||||
onSpawn(pawn: Pawn): PawnContainer {
|
||||
const container = new PawnContainer(this.scene, pawn);
|
||||
|
||||
const [x, y] = pawn.position;
|
||||
const pos = boardToScreen(x, y);
|
||||
container.x = pos.x;
|
||||
container.y = pos.y;
|
||||
|
||||
this.previousPositions.set(pawn.id, [x, y]);
|
||||
|
||||
// 淡入动画
|
||||
container.setScale(0);
|
||||
createPawnSpawnTween(this.scene, container);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
onDespawn(obj: PawnContainer): void {
|
||||
// 播放消失动画并添加中断
|
||||
createPawnDespawnTween(this.scene, obj, () => obj.destroy());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export { PawnSpawner } from "./PawnSpawner";
|
||||
export { CardSpawner, type CardSpawnData } from "./CardSpawner";
|
||||
export { HighlightSpawner, type HighlightData } from "./HighlightSpawner";
|
||||
|
||||
// Re-export config constants for backward compatibility
|
||||
// New code should import these directly from '@/config'
|
||||
export {
|
||||
CELL_SIZE,
|
||||
BOARD_OFFSET,
|
||||
BOARD_SIZE,
|
||||
boardToScreen,
|
||||
CARD_WIDTH,
|
||||
CARD_HEIGHT,
|
||||
} from "@/config";
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createUIState,
|
||||
clearSelection,
|
||||
selectPiece,
|
||||
selectCard,
|
||||
createValidMoves,
|
||||
} from "./ui";
|
||||
export type { OnitamaUIState, ValidMove } from "./ui";
|
||||
@@ -0,0 +1,81 @@
|
||||
import { mutableSignal, computed } from "boardgame-core";
|
||||
import { getAvailableMoves } from "boardgame-core/samples/onitama";
|
||||
|
||||
import type { MutableSignal, ReadonlySignal } from "boardgame-core";
|
||||
import type { OnitamaState } from "boardgame-core/samples/onitama";
|
||||
|
||||
export interface ValidMove {
|
||||
card: string;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
}
|
||||
|
||||
// 先选择牌,然后选择棋子,最后选择移动
|
||||
export interface OnitamaUIState {
|
||||
selectedPiece: { x: number; y: number } | null;
|
||||
selectedCard: string | null;
|
||||
}
|
||||
|
||||
export function createUIState(): MutableSignal<OnitamaUIState> {
|
||||
return mutableSignal<OnitamaUIState>({
|
||||
selectedPiece: null,
|
||||
selectedCard: null,
|
||||
});
|
||||
}
|
||||
|
||||
export function createValidMoves(
|
||||
state: ReadonlySignal<OnitamaState>,
|
||||
ui: ReadonlySignal<OnitamaUIState>,
|
||||
) {
|
||||
return computed(() => {
|
||||
return getAvailableMoves(state.value, state.value.currentPlayer).filter(
|
||||
(move) => {
|
||||
const { selectedCard, selectedPiece } = ui.value;
|
||||
return (
|
||||
selectedPiece?.x === move.fromX &&
|
||||
selectedPiece?.y === move.fromY &&
|
||||
selectedCard === move.card
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSelection(uiState: MutableSignal<OnitamaUIState>): void {
|
||||
uiState.produce((state) => {
|
||||
state.selectedPiece = null;
|
||||
state.selectedCard = null;
|
||||
});
|
||||
}
|
||||
|
||||
export function selectPiece(
|
||||
uiState: MutableSignal<OnitamaUIState>,
|
||||
x: number,
|
||||
y: number,
|
||||
): void {
|
||||
uiState.produce((state) => {
|
||||
// 如果点击已选中的棋子,取消选择
|
||||
if (state.selectedPiece?.x === x && state.selectedPiece?.y === y) {
|
||||
state.selectedPiece = null;
|
||||
} else {
|
||||
state.selectedPiece = { x, y };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function selectCard(
|
||||
uiState: MutableSignal<OnitamaUIState>,
|
||||
card: string,
|
||||
): void {
|
||||
uiState.produce((state) => {
|
||||
// 如果点击已选中的卡牌,取消选择
|
||||
if (state.selectedCard === card) {
|
||||
state.selectedCard = null;
|
||||
} else {
|
||||
// 选择新卡牌,清除棋子选择
|
||||
state.selectedCard = card;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createGameHost, type GameModule } from "boardgame-core";
|
||||
import { PhaserGame, PhaserScene } from "boardgame-phaser";
|
||||
import { useMemo } from "preact/hooks";
|
||||
|
||||
import * as gameModule from "../game/onitama";
|
||||
|
||||
import type { OnitamaState } from "@/game/onitama";
|
||||
|
||||
import { MenuScene } from "@/scenes/MenuScene";
|
||||
import { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
|
||||
export default function App() {
|
||||
const gameHost = useMemo(
|
||||
() => createGameHost(gameModule as unknown as GameModule<OnitamaState>),
|
||||
[],
|
||||
);
|
||||
const gameScene = useMemo(() => new OnitamaScene(), []);
|
||||
const menuScene = useMemo(() => new MenuScene(), []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 flex relative justify-center items-center">
|
||||
<PhaserGame
|
||||
initialScene="MenuScene"
|
||||
config={{ width: 720, height: 840 }}
|
||||
>
|
||||
<PhaserScene sceneKey="MenuScene" scene={menuScene} />
|
||||
<PhaserScene
|
||||
sceneKey="OnitamaScene"
|
||||
scene={gameScene}
|
||||
data={{ gameHost }}
|
||||
/>
|
||||
</PhaserGame>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"noEmit": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import preact from '@preact/preset-vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [preact(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'boardgame-phaser': path.resolve(__dirname, '../framework/src/index.ts'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
# Regicide - 单人卡牌游戏
|
||||
|
||||
基于 Phaser 3 + boardgame-core 框架实现的 Regicide 合作卡牌游戏单人模式。
|
||||
|
||||
## 游戏简介
|
||||
|
||||
Regicide 是一款合作奇幻卡牌游戏,使用标准扑克牌进行游戏。玩家需要击败12个强大的敌人(4个J、4个Q、4个K)来取得胜利。
|
||||
|
||||
## 游戏规则
|
||||
|
||||
### 目标
|
||||
按任意顺序击败城堡牌堆中的所有12个敌人(J/Q/K)。
|
||||
|
||||
### 设置
|
||||
- **城堡牌堆**: 12张敌人卡(4J、4Q、4K)
|
||||
- **酒馆牌堆**: 所有数字牌(2-10)和A
|
||||
- **初始手牌**: 8张
|
||||
- **小丑牌**: 2张(用于重抽手牌)
|
||||
|
||||
### 敌人属性
|
||||
| 敌人 | HP | 反击伤害 | 分值 |
|
||||
|------|----|----------|------|
|
||||
| J | 20 | 10 | 10 |
|
||||
| Q | 30 | 15 | 15 |
|
||||
| K | 40 | 20 | 20 |
|
||||
|
||||
### 花色效果
|
||||
- **红桃 ♥**: 从弃牌堆回收卡牌到酒馆牌堆
|
||||
- **方片 ♦**: 抽取等量卡牌
|
||||
- **梅花 ♣**: 攻击力翻倍
|
||||
- **黑桃 ♠**: 减免反击伤害
|
||||
|
||||
### 游戏流程
|
||||
1. **出牌阶段**: 打出一张或多张同点数的牌(总点数≤10)
|
||||
2. **伤害结算**: 对敌人造成等同于牌面点数的伤害
|
||||
3. **反击阶段**: 如果敌人未被击败,它会反击
|
||||
4. **抵消反击**: 弃置点数总和≥反击伤害的牌
|
||||
|
||||
### 特殊规则
|
||||
- **敌人免疫**: 每个敌人免疫与其花色相同的卡牌效果
|
||||
- **组合牌**: 可打出多张同点数牌,但总点数不能超过10
|
||||
- **小丑牌**: 可弃光手牌并重新抽取8张(整局限用2次)
|
||||
|
||||
### 胜利等级
|
||||
- 🥇 **金胜利**: 未使用小丑牌获胜
|
||||
- 🥈 **银胜利**: 使用1张小丑牌获胜
|
||||
- 🥉 **铜胜利**: 使用2张小丑牌获胜
|
||||
|
||||
## 运行游戏
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 启动开发服务器
|
||||
cd packages/regicide-game
|
||||
pnpm dev
|
||||
|
||||
# 构建生产版本
|
||||
pnpm build
|
||||
|
||||
# 预览生产版本
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
packages/regicide-game/
|
||||
├── src/
|
||||
│ ├── game/
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── card-utils.ts # 卡牌工具函数
|
||||
│ │ └── regicide.ts # 游戏逻辑和命令
|
||||
│ ├── scenes/
|
||||
│ │ ├── GameScene.ts # 主游戏场景
|
||||
│ │ └── effects.ts # 特效系统
|
||||
│ └── ui/
|
||||
│ └── App.tsx # Preact UI组件
|
||||
├── index.html
|
||||
├── package.json
|
||||
└── vite.config.ts
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Phaser 3**: 游戏引擎
|
||||
- **boardgame-core**: 状态管理(Preact Signals + Mutative)
|
||||
- **boardgame-phaser**: Phaser 集成框架
|
||||
- **Preact**: UI 组件
|
||||
- **TypeScript**: 类型安全
|
||||
- **Vite**: 构建工具
|
||||
- **Tailwind CSS**: UI 样式
|
||||
|
||||
## 开发说明
|
||||
|
||||
### 命令系统
|
||||
游戏使用 boardgame-core 的命令系统:
|
||||
- `setup` - 初始化游戏
|
||||
- `play <cardId>` - 打出单张卡牌
|
||||
- `combo <cardIds>` - 打出组合牌
|
||||
- `yield` - 放弃回合
|
||||
- `counterattack <cardIds>` - 反击时弃牌
|
||||
- `useJester` - 使用小丑牌重抽
|
||||
|
||||
### 状态管理
|
||||
游戏状态使用 MutableSignal 响应式系统,通过 `game.produce()` 进行不可变更新。
|
||||
|
||||
### 场景系统
|
||||
GameScene 继承自 GameHostScene,使用 `spawnEffect` 和 `effect()` 实现响应式渲染。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Regicide - Solo Card Game</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="ui-root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "regicide-game",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@preact/signals-core": "^1.5.1",
|
||||
"boardgame-core": "link:../../../boardgame-core",
|
||||
"boardgame-phaser": "workspace:*",
|
||||
"mutative": "^1.3.0",
|
||||
"phaser": "^3.80.1",
|
||||
"preact": "^10.19.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.8.1",
|
||||
"@preact/signals": "^2.9.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
# Regicide 快速入门指南
|
||||
|
||||
## 🎯 游戏目标
|
||||
击败所有12个敌人(4个J、4个Q、4个K)
|
||||
|
||||
## 🃏 出牌规则
|
||||
|
||||
### 基本操作
|
||||
1. **点击手牌** - 出牌攻击敌人
|
||||
2. **反击时点击手牌** - 弃牌抵消反击伤害
|
||||
|
||||
### 出牌方式
|
||||
- **单张牌**: 造成牌面点数伤害
|
||||
- **组合牌**: 多张同点数牌,总伤害=点数总和(需≤10)
|
||||
- **放弃**: 不出牌,直接承受反击伤害
|
||||
|
||||
### 花色特效
|
||||
- ♥ 红桃 - 回收弃牌
|
||||
- ♦ 方片 - 抽取新牌
|
||||
- ♣ 梅花 - 伤害翻倍
|
||||
- ♠ 黑桃 - 减免反击
|
||||
|
||||
## ⚔️ 敌人信息
|
||||
|
||||
| 敌人 | 生命值 | 反击伤害 |
|
||||
|------|--------|----------|
|
||||
| J | 20 HP | 10 |
|
||||
| Q | 30 HP | 15 |
|
||||
| K | 40 HP | 20 |
|
||||
|
||||
**注意**: 每个敌人免疫与其花色相同的卡牌效果!
|
||||
|
||||
## 🃏 小丑牌
|
||||
- 可以**弃光手牌**并**重新抽取8张**
|
||||
- 整局限用**2次**
|
||||
- 使用次数影响胜利等级
|
||||
|
||||
## 🏆 胜利等级
|
||||
- 🥇 金: 未使用小丑牌
|
||||
- 🥈 银: 使用1张小丑牌
|
||||
- 🥉 铜: 使用2张小丑牌
|
||||
|
||||
## 💡 游戏提示
|
||||
1. 合理使用花色特效
|
||||
2. 保留高点数牌应对强敌
|
||||
3. 注意敌人的花色免疫
|
||||
4. 谨慎使用小丑牌
|
||||
|
||||
祝你好运,勇者!⚔️
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Regicide - Solo Card Game</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="ui-root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Card, CardSuit, CardRank, EnemyCard, JesterCard } from './types';
|
||||
|
||||
// 创建标准扑克牌组 (不含小丑)
|
||||
export function createStandardDeck(): Card[] {
|
||||
const suits: CardSuit[] = ['hearts', 'diamonds', 'clubs', 'spades'];
|
||||
const ranks: CardRank[] = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
|
||||
const deck: Card[] = [];
|
||||
|
||||
for (const suit of suits) {
|
||||
for (const rank of ranks) {
|
||||
const value = getCardValue(rank);
|
||||
deck.push({
|
||||
id: `card-${suit}-${rank}`,
|
||||
suit,
|
||||
rank,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return deck;
|
||||
}
|
||||
|
||||
// 获取卡牌点数对应的数值
|
||||
export function getCardValue(rank: CardRank): number {
|
||||
if (rank === 'jester') return 0;
|
||||
if (rank === 'A') return 1;
|
||||
if (rank === 'J') return 10;
|
||||
if (rank === 'Q') return 15;
|
||||
if (rank === 'K') return 20;
|
||||
return parseInt(rank);
|
||||
}
|
||||
|
||||
// 创建城堡牌堆 (J/Q/K 作为敌人)
|
||||
// TODO 不要全部洗混,把J/Q/K分别洗混再合并,并且J先翻出K最后翻出
|
||||
export function createCastleDeck(): EnemyCard[] {
|
||||
const suits: CardSuit[] = ['hearts', 'diamonds', 'clubs', 'spades'];
|
||||
const castleCards: EnemyCard[] = [];
|
||||
|
||||
// 4个K (40HP, 20反击)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
castleCards.push({
|
||||
id: `enemy-K-${i}`,
|
||||
suit: suits[i],
|
||||
rank: 'K' as const,
|
||||
value: 20,
|
||||
hp: 40,
|
||||
counterDamage: 20,
|
||||
immunitySuit: suits[i],
|
||||
});
|
||||
}
|
||||
|
||||
// 4个Q (30HP, 15反击)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
castleCards.push({
|
||||
id: `enemy-Q-${i}`,
|
||||
suit: suits[i],
|
||||
rank: 'Q' as const,
|
||||
value: 15,
|
||||
hp: 30,
|
||||
counterDamage: 15,
|
||||
immunitySuit: suits[i],
|
||||
});
|
||||
}
|
||||
|
||||
// 4个J (20HP, 10反击)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
castleCards.push({
|
||||
id: `enemy-J-${i}`,
|
||||
suit: suits[i],
|
||||
rank: 'J' as const,
|
||||
value: 10,
|
||||
hp: 20,
|
||||
counterDamage: 10,
|
||||
immunitySuit: suits[i],
|
||||
});
|
||||
}
|
||||
|
||||
return shuffleDeck(castleCards);
|
||||
}
|
||||
|
||||
// 创建酒馆牌堆 (数字牌 + A)
|
||||
export function createTavernDeck(): Card[] {
|
||||
const deck = createStandardDeck();
|
||||
// 过滤出数字牌和A,去除J/Q/K
|
||||
return deck.filter(card =>
|
||||
card.rank !== 'J' && card.rank !== 'Q' && card.rank !== 'K'
|
||||
);
|
||||
}
|
||||
|
||||
// 创建小丑牌
|
||||
export function createJesters(): JesterCard[] {
|
||||
return [
|
||||
{ id: 'jester-1', rank: 'jester', used: false },
|
||||
{ id: 'jester-2', rank: 'jester', used: false },
|
||||
];
|
||||
}
|
||||
|
||||
// 洗牌算法 (Fisher-Yates)
|
||||
export function shuffleDeck<T>(deck: T[]): T[] {
|
||||
const shuffled = [...deck];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
// 从牌堆顶部抽牌
|
||||
export function drawFromDeck<T>(deck: T[], count: number): T[] {
|
||||
return deck.splice(0, count);
|
||||
}
|
||||
|
||||
// 获取花色的中文名称
|
||||
export function getSuitName(suit: CardSuit | null): string {
|
||||
if (!suit) return '无花色';
|
||||
const names: Record<CardSuit, string> = {
|
||||
hearts: '红桃 ♥',
|
||||
diamonds: '方片 ♦',
|
||||
clubs: '梅花 ♣',
|
||||
spades: '黑桃 ♠',
|
||||
};
|
||||
return names[suit];
|
||||
}
|
||||
|
||||
// 获取花色的颜色
|
||||
export function getSuitColor(suit: CardSuit | null): string {
|
||||
if (!suit) return '#9ca3af'; // 灰色
|
||||
return (suit === 'hearts' || suit === 'diamonds') ? '#dc2626' : '#1f2937';
|
||||
}
|
||||
|
||||
// 获取卡牌显示文本
|
||||
export function getCardDisplay(card: Card | JesterCard): string {
|
||||
if (card.rank === 'jester') return '🃏';
|
||||
const suitSymbols: Record<CardSuit, string> = {
|
||||
hearts: '♥',
|
||||
diamonds: '♦',
|
||||
clubs: '♣',
|
||||
spades: '♠',
|
||||
};
|
||||
return `${card.rank}${suitSymbols[card.suit as CardSuit]}`;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createGameHost, GameHost } from 'boardgame-core';
|
||||
import { gameModule, createInitialState, prompts } from './regicide';
|
||||
import type { RegicideState, Card, Enemy } from './types';
|
||||
|
||||
describe('Regicide Game Module', () => {
|
||||
let gameHost: GameHost<RegicideState>;
|
||||
let gamePromise: Promise<any>;
|
||||
|
||||
beforeEach(() => {
|
||||
gameHost = createGameHost(gameModule);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// 等待游戏循环完成,然后 dispose
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
// 等待 gamePromise 结束,suppress 预期的 cancel 错误
|
||||
gamePromise?.catch(() => {});
|
||||
gameHost.dispose();
|
||||
// 等待 dispose 完成
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
});
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should create valid initial state', () => {
|
||||
const state = createInitialState();
|
||||
|
||||
expect(state.castleDeck).toBeDefined();
|
||||
expect(state.tavernDeck).toBeDefined();
|
||||
expect(state.discardPile).toBeDefined();
|
||||
expect(state.currentEnemy).toBeNull();
|
||||
expect(state.defeatedEnemies).toEqual([]);
|
||||
expect(state.hand).toEqual([]);
|
||||
expect(state.jesters).toHaveLength(2);
|
||||
expect(state.jestersUsed).toBe(0);
|
||||
expect(state.phase).toBe('playerTurn');
|
||||
expect(state.currentPlayed).toBeNull();
|
||||
expect(state.victoryLevel).toBeNull();
|
||||
expect(state.isGameOver).toBe(false);
|
||||
});
|
||||
|
||||
it('should have jesters with correct structure', () => {
|
||||
const state = createInitialState();
|
||||
|
||||
expect(state.jesters[0]).toMatchObject({
|
||||
id: 'jester-1',
|
||||
rank: 'jester',
|
||||
used: false,
|
||||
});
|
||||
expect(state.jesters[1]).toMatchObject({
|
||||
id: 'jester-2',
|
||||
rank: 'jester',
|
||||
used: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Setup Command', () => {
|
||||
it('should initialize game with setup command', async () => {
|
||||
// 开始游戏(会自动调用 setup 和主循环)
|
||||
gamePromise = gameHost.start();
|
||||
|
||||
// 等待游戏进入 prompt 状态(等待玩家出牌)
|
||||
await waitForPrompt(gameHost);
|
||||
|
||||
// 验证游戏状态
|
||||
const state = gameHost.state.value;
|
||||
expect(state.phase).toBe('playerTurn');
|
||||
expect(state.currentEnemy).not.toBeNull();
|
||||
expect(state.currentEnemy!.currentHp).toBeGreaterThan(0);
|
||||
expect(state.hand.length).toBe(8); // 初始手牌数
|
||||
expect(state.castleDeck.length).toBeGreaterThan(0);
|
||||
expect(state.tavernDeck.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should shuffle decks during setup', async () => {
|
||||
gamePromise = gameHost.start();
|
||||
await waitForPrompt(gameHost);
|
||||
|
||||
const state = gameHost.state.value;
|
||||
|
||||
// 验证牌堆已被洗牌(不应该按顺序排列)
|
||||
const firstFewCards = state.tavernDeck.slice(0, 10);
|
||||
const hasDifferentValues = firstFewCards.some(card => card.value !== firstFewCards[0].value);
|
||||
expect(hasDifferentValues).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Play Card Command', () => {
|
||||
it('should play a single card and damage enemy', async () => {
|
||||
gamePromise = gameHost.start();
|
||||
await waitForPrompt(gameHost);
|
||||
|
||||
const state = gameHost.state.value;
|
||||
const enemy = state.currentEnemy!;
|
||||
const initialHp = enemy.currentHp;
|
||||
const card = state.hand[0];
|
||||
|
||||
// 出牌 - 使用 tryAnswerPrompt 命令格式
|
||||
const error = gameHost.tryAnswerPrompt(prompts.playerAction, 'play', [card.id]);
|
||||
expect(error).toBeNull(); // 验证成功
|
||||
|
||||
// 等待下一个 prompt
|
||||
await waitForPrompt(gameHost);
|
||||
|
||||
const newState = gameHost.state.value;
|
||||
const newEnemy = newState.currentEnemy;
|
||||
|
||||
// 验证敌人受到了伤害或进入反击阶段
|
||||
if (newEnemy) {
|
||||
// HP 应该减少(至少减少了card.value)
|
||||
const damage = initialHp - newEnemy.currentHp;
|
||||
expect(damage).toBeGreaterThanOrEqual(card.value);
|
||||
}
|
||||
|
||||
// 验证卡牌从手牌移除
|
||||
expect(newState.hand.find(c => c.id === card.id)).toBeUndefined();
|
||||
expect(newState.discardPile.find(c => c.id === card.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('should fail to play card not in hand', async () => {
|
||||
gamePromise = gameHost.start();
|
||||
await waitForPrompt(gameHost);
|
||||
|
||||
const stateBefore = gameHost.state.value;
|
||||
const handSizeBefore = stateBefore.hand.length;
|
||||
|
||||
// 尝试打出不存在的牌 - 应该被 validator 拒绝
|
||||
const error = gameHost.tryAnswerPrompt(prompts.playerAction, 'play', ['non-existent-card']);
|
||||
// 错误会被 catch 并继续循环,所以返回 null
|
||||
expect(error).toBeNull();
|
||||
|
||||
// 等待下一个 prompt(因为命令失败,会再次提示)
|
||||
await waitForPrompt(gameHost);
|
||||
|
||||
const stateAfter = gameHost.state.value;
|
||||
// 验证手牌没有变化
|
||||
expect(stateAfter.hand.length).toBe(handSizeBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Counterattack Mechanism', () => {
|
||||
it('should enter counterattack phase after playing card', async () => {
|
||||
gamePromise = gameHost.start();
|
||||
await waitForPrompt(gameHost);
|
||||
|
||||
const state = gameHost.state.value;
|
||||
const enemy = state.currentEnemy!;
|
||||
|
||||
// 出一张牌造成伤害(不一定击败敌人)
|
||||
const card = state.hand[0];
|
||||
gameHost.tryAnswerPrompt(prompts.playerAction, 'play', [card.id]);
|
||||
|
||||
// 等待反击阶段或下一个 prompt
|
||||
await waitForPrompt(gameHost);
|
||||
|
||||
const counterState = gameHost.state.value;
|
||||
// 验证进入了反击阶段或敌人被击败
|
||||
expect(
|
||||
counterState.phase === 'enemyCounterattack' ||
|
||||
counterState.phase === 'enemyDefeated' ||
|
||||
counterState.isGameOver ||
|
||||
counterState.phase === 'playerTurn' // 如果敌人被立即击败
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Game End Conditions', () => {
|
||||
it('should track game state correctly', async () => {
|
||||
gamePromise = gameHost.start();
|
||||
await waitForPrompt(gameHost);
|
||||
|
||||
const state = gameHost.state.value;
|
||||
expect(state.defeatedEnemies).toHaveLength(0);
|
||||
expect(state.isGameOver).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Yield Turn', () => {
|
||||
it('should yield turn', async () => {
|
||||
gamePromise = gameHost.start();
|
||||
await waitForPrompt(gameHost);
|
||||
|
||||
// 放弃回合 - 使用 tryAnswerPrompt 命令格式
|
||||
const error = gameHost.tryAnswerPrompt(prompts.playerAction, 'yield', []);
|
||||
|
||||
// 等待处理完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const state = gameHost.state.value;
|
||||
// yield 命令可能被接受或拒绝,取决于游戏状态
|
||||
// 这里只验证游戏仍在运行
|
||||
expect(state.isGameOver).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Use Jester', () => {
|
||||
it('should use jester to redraw hand', async () => {
|
||||
gamePromise = gameHost.start();
|
||||
await waitForPrompt(gameHost);
|
||||
|
||||
const state = gameHost.state.value;
|
||||
const initialHandSize = state.hand.length;
|
||||
|
||||
// 使用小丑牌 - 使用 tryAnswerPrompt 命令格式
|
||||
const error = gameHost.tryAnswerPrompt(prompts.playerAction, 'useJester', []);
|
||||
|
||||
// 等待处理完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const newState = gameHost.state.value;
|
||||
// useJester 可能被接受或拒绝
|
||||
// 验证游戏仍在运行
|
||||
expect(newState.isGameOver).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 辅助函数
|
||||
|
||||
function waitForPrompt(gameHost: GameHost<RegicideState>): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (gameHost.activePromptSchema.value !== null) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(check, 10);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
function waitForPromptOrGameOver(gameHost: GameHost<RegicideState>): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
const state = gameHost.state.value;
|
||||
if (gameHost.activePromptSchema.value !== null || state.isGameOver) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(check, 10);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import {
|
||||
createGameCommandRegistry,
|
||||
createPromptDef,
|
||||
IGameContext,
|
||||
GameModule,
|
||||
} from 'boardgame-core';
|
||||
import type { RegicideState, Enemy, PlayedCards, Card, JesterCard, CardSuit, EnemyCard } from './types';
|
||||
import {
|
||||
createCastleDeck,
|
||||
createTavernDeck,
|
||||
createJesters,
|
||||
shuffleDeck,
|
||||
drawFromDeck,
|
||||
} from './card-utils';
|
||||
|
||||
const INITIAL_HAND_SIZE = 8;
|
||||
const MAX_COMBINED_VALUE = 10;
|
||||
|
||||
export const registry = createGameCommandRegistry<RegicideState>();
|
||||
|
||||
export function createInitialState(): RegicideState {
|
||||
return {
|
||||
castleDeck: [],
|
||||
tavernDeck: [],
|
||||
discardPile: [],
|
||||
currentEnemy: null,
|
||||
defeatedEnemies: [],
|
||||
hand: [],
|
||||
jesters: createJesters(),
|
||||
jestersUsed: 0,
|
||||
phase: 'playerTurn',
|
||||
currentPlayed: null,
|
||||
victoryLevel: null,
|
||||
isGameOver: false,
|
||||
};
|
||||
}
|
||||
|
||||
export type RegicideGame = IGameContext<RegicideState>;
|
||||
|
||||
// setup 命令 - 初始化游戏
|
||||
async function setup(game: RegicideGame) {
|
||||
const castleDeck = shuffleDeck(createCastleDeck());
|
||||
const tavernDeck = shuffleDeck(createTavernDeck());
|
||||
const jesters = createJesters();
|
||||
const initialHand = drawFromDeck(tavernDeck, INITIAL_HAND_SIZE);
|
||||
|
||||
const firstEnemyCard = castleDeck.shift();
|
||||
if (!firstEnemyCard) throw new Error('城堡牌堆为空');
|
||||
|
||||
const firstEnemy: Enemy = {
|
||||
...firstEnemyCard,
|
||||
currentHp: firstEnemyCard.hp,
|
||||
isDefeated: false,
|
||||
};
|
||||
|
||||
game.produce((state: RegicideState) => {
|
||||
state.castleDeck = castleDeck;
|
||||
state.tavernDeck = tavernDeck;
|
||||
state.discardPile = [];
|
||||
state.currentEnemy = firstEnemy;
|
||||
state.defeatedEnemies = [];
|
||||
state.hand = initialHand;
|
||||
state.jesters = jesters;
|
||||
state.jestersUsed = 0;
|
||||
state.phase = 'playerTurn';
|
||||
state.currentPlayed = null;
|
||||
state.victoryLevel = null;
|
||||
state.isGameOver = false;
|
||||
});
|
||||
|
||||
return game.value;
|
||||
}
|
||||
|
||||
registry.register('setup', setup);
|
||||
|
||||
// play <cardId> - 打出单张卡牌
|
||||
async function playCard(game: RegicideGame, cardId: string) {
|
||||
const state = game.value;
|
||||
|
||||
if (state.phase !== 'playerTurn') {
|
||||
throw new Error('不是玩家回合');
|
||||
}
|
||||
|
||||
if (!state.currentEnemy || state.currentEnemy.isDefeated) {
|
||||
throw new Error('没有活跃的敌人');
|
||||
}
|
||||
|
||||
const card = state.hand.find(c => c.id === cardId);
|
||||
if (!card) throw new Error(`卡牌 ${cardId} 不在手牌中`);
|
||||
|
||||
// 计算伤害
|
||||
let damage = card.value;
|
||||
const suits = card.suit ? [card.suit] : [];
|
||||
|
||||
if (suits.includes('clubs')) damage *= 2;
|
||||
|
||||
let isImmune = false;
|
||||
const enemy = state.currentEnemy;
|
||||
if (enemy.immunitySuit && suits.includes(enemy.immunitySuit)) {
|
||||
isImmune = true;
|
||||
damage = card.value;
|
||||
}
|
||||
|
||||
game.produce((state: RegicideState) => {
|
||||
const cardIndex = state.hand.findIndex(c => c.id === cardId);
|
||||
if (cardIndex !== -1) state.hand.splice(cardIndex, 1);
|
||||
state.discardPile.push(card);
|
||||
|
||||
// 计算最终伤害并应用到敌人
|
||||
let finalDamage = damage;
|
||||
const e = state.currentEnemy!;
|
||||
e.currentHp -= finalDamage;
|
||||
const isDefeated = e.currentHp <= 0;
|
||||
|
||||
if (isDefeated) {
|
||||
e.isDefeated = true;
|
||||
state.defeatedEnemies.push({ ...e });
|
||||
state.currentEnemy = null;
|
||||
state.phase = 'enemyDefeated';
|
||||
|
||||
if (state.defeatedEnemies.length >= 12) {
|
||||
state.isGameOver = true;
|
||||
state.victoryLevel = calculateVictoryLevel(state.jestersUsed);
|
||||
state.phase = 'gameOver';
|
||||
}
|
||||
} else {
|
||||
state.phase = 'enemyCounterattack';
|
||||
}
|
||||
|
||||
state.currentPlayed = {
|
||||
cards: [card],
|
||||
totalDamage: isImmune ? card.value : finalDamage,
|
||||
suits,
|
||||
hasJester: false,
|
||||
};
|
||||
});
|
||||
|
||||
// 从 produce 后的状态读取结果
|
||||
const finalEnemy = game.value.defeatedEnemies.find(e => e.id === enemy.id) || game.value.currentEnemy;
|
||||
const wasDefeated = game.value.defeatedEnemies.some(e => e.id === enemy.id);
|
||||
|
||||
return { damage, remainingHp: wasDefeated ? 0 : Math.max(0, (game.value.currentEnemy?.currentHp ?? 0)), isImmune, isDefeated: wasDefeated };
|
||||
}
|
||||
|
||||
registry.register('play <cardId:string>', playCard);
|
||||
|
||||
// combo <cardIds> - 打出组合牌
|
||||
async function playCombo(game: RegicideGame, cardIds: string[]) {
|
||||
const state = game.value;
|
||||
|
||||
if (state.phase !== 'playerTurn') throw new Error('不是玩家回合');
|
||||
if (cardIds.length < 2) throw new Error('组合牌至少需要2张');
|
||||
|
||||
const selectedCards: Card[] = [];
|
||||
for (const cardId of cardIds) {
|
||||
const card = state.hand.find(c => c.id === cardId);
|
||||
if (!card) throw new Error(`卡牌 ${cardId} 不在手牌中`);
|
||||
selectedCards.push(card);
|
||||
}
|
||||
|
||||
const firstRank = selectedCards[0].rank;
|
||||
if (!selectedCards.every(c => c.rank === firstRank)) {
|
||||
throw new Error('组合牌必须是相同点数');
|
||||
}
|
||||
|
||||
const totalValue = selectedCards.reduce((sum, c) => sum + c.value, 0);
|
||||
if (totalValue > MAX_COMBINED_VALUE) {
|
||||
throw new Error(`组合牌总点数不能超过 ${MAX_COMBINED_VALUE}`);
|
||||
}
|
||||
|
||||
let damage = totalValue;
|
||||
const suits = selectedCards.map(c => c.suit).filter((s): s is CardSuit => s !== null);
|
||||
|
||||
if (suits.includes('clubs')) damage *= 2;
|
||||
|
||||
let isImmune = false;
|
||||
const enemy = state.currentEnemy!;
|
||||
if (enemy.immunitySuit && suits.includes(enemy.immunitySuit)) {
|
||||
isImmune = true;
|
||||
damage = totalValue;
|
||||
}
|
||||
|
||||
game.produce((state: RegicideState) => {
|
||||
for (const card of selectedCards) {
|
||||
const cardIndex = state.hand.findIndex(c => c.id === card.id);
|
||||
if (cardIndex !== -1) state.hand.splice(cardIndex, 1);
|
||||
state.discardPile.push(card);
|
||||
}
|
||||
|
||||
// 计算最终伤害并应用到敌人
|
||||
let finalDamage = damage;
|
||||
const e = state.currentEnemy!;
|
||||
e.currentHp -= finalDamage;
|
||||
const isDefeated = e.currentHp <= 0;
|
||||
|
||||
if (isDefeated) {
|
||||
e.isDefeated = true;
|
||||
state.defeatedEnemies.push({ ...e });
|
||||
state.currentEnemy = null;
|
||||
state.phase = 'enemyDefeated';
|
||||
|
||||
if (state.defeatedEnemies.length >= 12) {
|
||||
state.isGameOver = true;
|
||||
state.victoryLevel = calculateVictoryLevel(state.jestersUsed);
|
||||
state.phase = 'gameOver';
|
||||
}
|
||||
} else {
|
||||
state.phase = 'enemyCounterattack';
|
||||
}
|
||||
|
||||
state.currentPlayed = {
|
||||
cards: selectedCards,
|
||||
totalDamage: isImmune ? totalValue : finalDamage,
|
||||
suits,
|
||||
hasJester: false,
|
||||
};
|
||||
});
|
||||
|
||||
const wasDefeated = game.value.defeatedEnemies.some(e => e.id === enemy.id);
|
||||
|
||||
return { damage, remainingHp: wasDefeated ? 0 : Math.max(0, (game.value.currentEnemy?.currentHp ?? 0)), isImmune, isDefeated: wasDefeated };
|
||||
}
|
||||
|
||||
registry.register('combo <cardIds>', playCombo);
|
||||
|
||||
// yield - 放弃回合
|
||||
async function yieldTurn(game: RegicideGame) {
|
||||
const state = game.value;
|
||||
|
||||
if (state.phase !== 'playerTurn') throw new Error('不是玩家回合');
|
||||
|
||||
game.produce((state: RegicideState) => {
|
||||
state.phase = 'enemyCounterattack';
|
||||
state.currentPlayed = {
|
||||
cards: [],
|
||||
totalDamage: 0,
|
||||
suits: [],
|
||||
hasJester: false,
|
||||
};
|
||||
});
|
||||
|
||||
return { message: '放弃回合,承受反击伤害' };
|
||||
}
|
||||
|
||||
registry.register('yield', yieldTurn);
|
||||
|
||||
// counterattack <cardIds> - 反击时弃牌
|
||||
async function counterattack(game: RegicideGame, cardIds: string[]) {
|
||||
const state = game.value;
|
||||
|
||||
if (state.phase !== 'enemyCounterattack') throw new Error('不是反击阶段');
|
||||
if (!state.currentEnemy) throw new Error('没有活跃的敌人');
|
||||
|
||||
// 空牌提交 = 认输
|
||||
if (cardIds.length === 0) {
|
||||
game.produce((state: RegicideState) => {
|
||||
state.isGameOver = true;
|
||||
state.victoryLevel = null;
|
||||
state.phase = 'gameOver';
|
||||
});
|
||||
return { success: false, required: state.currentEnemy.counterDamage, available: 0, isSurrender: true };
|
||||
}
|
||||
|
||||
const selectedCards: Card[] = [];
|
||||
for (const cardId of cardIds) {
|
||||
const card = state.hand.find(c => c.id === cardId);
|
||||
if (!card) throw new Error(`卡牌 ${cardId} 不在手牌中`);
|
||||
selectedCards.push(card);
|
||||
}
|
||||
|
||||
const totalValue = selectedCards.reduce((sum, c) => sum + c.value, 0);
|
||||
const requiredValue = state.currentEnemy.counterDamage;
|
||||
|
||||
if (totalValue < requiredValue) {
|
||||
game.produce((state: RegicideState) => {
|
||||
state.isGameOver = true;
|
||||
state.victoryLevel = null;
|
||||
state.phase = 'gameOver';
|
||||
});
|
||||
|
||||
return { success: false, required: requiredValue, available: totalValue };
|
||||
}
|
||||
|
||||
game.produce((state: RegicideState) => {
|
||||
for (const card of selectedCards) {
|
||||
const index = state.hand.findIndex(c => c.id === card.id);
|
||||
if (index !== -1) {
|
||||
state.hand.splice(index, 1);
|
||||
state.discardPile.push(card);
|
||||
}
|
||||
}
|
||||
|
||||
const cardsToDraw = Math.min(
|
||||
INITIAL_HAND_SIZE - state.hand.length,
|
||||
state.tavernDeck.length
|
||||
);
|
||||
|
||||
if (cardsToDraw > 0) {
|
||||
const newCards = drawFromDeck(state.tavernDeck, cardsToDraw);
|
||||
state.hand.push(...newCards);
|
||||
}
|
||||
|
||||
state.phase = 'playerTurn';
|
||||
state.currentPlayed = null;
|
||||
state.currentEnemy = null;
|
||||
|
||||
if (state.castleDeck.length > 0) {
|
||||
const nextEnemyCard = state.castleDeck.shift();
|
||||
if (nextEnemyCard && 'hp' in nextEnemyCard && 'counterDamage' in nextEnemyCard) {
|
||||
const nextEnemy: Enemy = {
|
||||
...(nextEnemyCard as EnemyCard),
|
||||
currentHp: (nextEnemyCard as EnemyCard).hp,
|
||||
isDefeated: false,
|
||||
};
|
||||
state.currentEnemy = nextEnemy;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, cardsUsed: totalValue, required: requiredValue };
|
||||
}
|
||||
|
||||
registry.register('counterattack <cardIds>', counterattack);
|
||||
|
||||
// useJester - 使用小丑牌重抽
|
||||
async function useJester(game: RegicideGame) {
|
||||
const state = game.value;
|
||||
|
||||
if (state.jestersUsed >= 2) throw new Error('小丑牌已用完');
|
||||
|
||||
const unusedJester = state.jesters.find(j => !j.used);
|
||||
if (!unusedJester) throw new Error('没有可用的小丑牌');
|
||||
|
||||
game.produce((state: RegicideState) => {
|
||||
state.discardPile.push(...state.hand);
|
||||
state.hand = [];
|
||||
|
||||
const cardsToDraw = Math.min(INITIAL_HAND_SIZE, state.tavernDeck.length);
|
||||
const newCards = drawFromDeck(state.tavernDeck, cardsToDraw);
|
||||
state.hand.push(...newCards);
|
||||
|
||||
const jesterIndex = state.jesters.findIndex(j => j.id === unusedJester.id);
|
||||
if (jesterIndex !== -1) {
|
||||
state.jesters[jesterIndex].used = true;
|
||||
state.jestersUsed++;
|
||||
}
|
||||
});
|
||||
|
||||
return { message: `使用小丑牌,重新抽取 ${INITIAL_HAND_SIZE} 张牌` };
|
||||
}
|
||||
|
||||
registry.register('useJester', useJester);
|
||||
|
||||
function calculateVictoryLevel(jestersUsed: number): 'gold' | 'silver' | 'bronze' {
|
||||
if (jestersUsed === 0) return 'gold';
|
||||
if (jestersUsed === 1) return 'silver';
|
||||
return 'bronze';
|
||||
}
|
||||
|
||||
|
||||
export const prompts = {
|
||||
playerAction: createPromptDef<[action: string, cardIds: string[]]>(
|
||||
'play <action:string> <cardIds:string[]>'
|
||||
),
|
||||
counterattack: createPromptDef<[cardIds: string[]]>(
|
||||
'counterattack <cardIds:string[]>'
|
||||
),
|
||||
};
|
||||
|
||||
// start 函数 - 游戏主循环
|
||||
export async function start(game: RegicideGame) {
|
||||
// 首先执行 setup
|
||||
await setup(game);
|
||||
|
||||
// 主游戏循环
|
||||
while (!game.value.isGameOver) {
|
||||
const state = game.value;
|
||||
|
||||
// 玩家回合 - 等待玩家输入任何命令
|
||||
if (state.phase === 'playerTurn' && state.currentEnemy && !state.currentEnemy.isDefeated) {
|
||||
const { action, cardIds } = await game.prompt(
|
||||
prompts.playerAction,
|
||||
(action: string, cardIds: string[]) => {
|
||||
const parts = cardIds.filter(a => a !== undefined && a !== '');
|
||||
if (parts.length === 0) {
|
||||
throw '请输入有效的命令';
|
||||
}
|
||||
return { action, cardIds: parts };
|
||||
},
|
||||
state.currentEnemy.id
|
||||
);
|
||||
|
||||
// 解析并执行命令
|
||||
try {
|
||||
if (action === 'play' && cardIds.length >= 1) {
|
||||
await playCard(game, cardIds[0]);
|
||||
} else if (action === 'combo' && cardIds.length >= 2) {
|
||||
await playCombo(game, cardIds);
|
||||
} else if (action === 'yield') {
|
||||
await yieldTurn(game);
|
||||
} else if (action === 'useJester') {
|
||||
await useJester(game);
|
||||
} else {
|
||||
// 无效命令,继续等待
|
||||
continue;
|
||||
}
|
||||
} catch (e) {
|
||||
// 命令执行失败,继续循环(玩家会再次被提示)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 敌人反击阶段
|
||||
if (state.phase === 'enemyCounterattack') {
|
||||
// 等待玩家出牌来抵消反击伤害
|
||||
if (state.currentEnemy) {
|
||||
const requiredValue = state.currentEnemy.counterDamage;
|
||||
const { cardIds } = await game.prompt(
|
||||
prompts.counterattack,
|
||||
(cardIds: string[]) => {
|
||||
// 允许空牌提交(认输)
|
||||
if (cardIds.length === 0) {
|
||||
return { cardIds: [], totalValue: 0 };
|
||||
}
|
||||
|
||||
const selectedCards: Card[] = [];
|
||||
for (const cardId of cardIds) {
|
||||
const card = state.hand.find(c => c.id === cardId);
|
||||
if (!card) {
|
||||
throw `卡牌 ${cardId} 不在手牌中`;
|
||||
}
|
||||
selectedCards.push(card);
|
||||
}
|
||||
|
||||
const totalValue = selectedCards.reduce((sum, c) => sum + c.value, 0);
|
||||
// 不再抛出错误,允许提交不足的牌,由 counterattack 处理失败逻辑
|
||||
return { cardIds, totalValue };
|
||||
},
|
||||
state.currentEnemy.id
|
||||
);
|
||||
|
||||
await counterattack(game, cardIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 敌人被击败阶段
|
||||
if (state.phase === 'enemyDefeated') {
|
||||
game.produce((state: RegicideState) => {
|
||||
// 准备迎接下一个敌人
|
||||
if (state.castleDeck.length > 0) {
|
||||
const nextEnemyCard = state.castleDeck.shift();
|
||||
if (nextEnemyCard && 'hp' in nextEnemyCard && 'counterDamage' in nextEnemyCard) {
|
||||
const nextEnemy: Enemy = {
|
||||
...(nextEnemyCard as EnemyCard),
|
||||
currentHp: (nextEnemyCard as EnemyCard).hp,
|
||||
isDefeated: false,
|
||||
};
|
||||
state.currentEnemy = nextEnemy;
|
||||
state.phase = 'playerTurn';
|
||||
} else {
|
||||
// 城堡牌堆没有有效的敌人卡牌,游戏胜利
|
||||
state.isGameOver = true;
|
||||
state.victoryLevel = calculateVictoryLevel(state.jestersUsed);
|
||||
state.currentEnemy = null;
|
||||
state.phase = 'gameOver';
|
||||
}
|
||||
} else {
|
||||
// 城堡牌堆已空,游戏胜利
|
||||
state.isGameOver = true;
|
||||
state.victoryLevel = calculateVictoryLevel(state.jestersUsed);
|
||||
state.currentEnemy = null;
|
||||
state.phase = 'gameOver';
|
||||
}
|
||||
state.currentPlayed = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return game.value.victoryLevel;
|
||||
}
|
||||
|
||||
export const gameModule: GameModule<RegicideState> = {
|
||||
registry,
|
||||
createInitialState,
|
||||
start,
|
||||
};
|
||||
|
||||
export type { RegicideState, Card, Enemy, JesterCard } from './types';
|
||||
@@ -0,0 +1,85 @@
|
||||
// 卡牌花色
|
||||
export type CardSuit = 'hearts' | 'diamonds' | 'clubs' | 'spades';
|
||||
|
||||
// 卡牌点数
|
||||
export type CardRank =
|
||||
| '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10'
|
||||
| 'J' | 'Q' | 'K' | 'A'
|
||||
| 'jester';
|
||||
|
||||
// 基础卡牌
|
||||
export interface Card {
|
||||
id: string;
|
||||
suit: CardSuit | null; // jester 没有花色
|
||||
rank: CardRank;
|
||||
value: number; // 攻击力/分数值
|
||||
}
|
||||
|
||||
// 敌人卡 (J/Q/K)
|
||||
export interface EnemyCard extends Card {
|
||||
rank: 'J' | 'Q' | 'K';
|
||||
hp: number;
|
||||
counterDamage: number;
|
||||
immunitySuit?: CardSuit; // 免疫的花色
|
||||
}
|
||||
|
||||
// 小丑牌
|
||||
export interface JesterCard {
|
||||
id: string;
|
||||
rank: 'jester';
|
||||
used: boolean;
|
||||
}
|
||||
|
||||
// 游戏中的敌人实例
|
||||
export interface Enemy extends EnemyCard {
|
||||
currentHp: number;
|
||||
isDefeated: boolean;
|
||||
}
|
||||
|
||||
// 游戏阶段
|
||||
export type GamePhase =
|
||||
| 'playerTurn' // 玩家回合 - 出牌
|
||||
| 'enemyCounterattack' // 敌人反击
|
||||
| 'enemyDefeated' // 敌人被击败
|
||||
| 'gameOver'; // 游戏结束
|
||||
|
||||
// 游戏结果
|
||||
export type VictoryLevel = 'gold' | 'silver' | 'bronze' | null;
|
||||
|
||||
// 打出的卡牌组合
|
||||
export interface PlayedCards {
|
||||
cards: Card[];
|
||||
totalDamage: number;
|
||||
suits: CardSuit[];
|
||||
hasJester: boolean;
|
||||
}
|
||||
|
||||
// Regicide 游戏状态
|
||||
export interface RegicideState {
|
||||
// 牌堆
|
||||
castleDeck: Card[]; // 敌人牌堆 (J/Q/K)
|
||||
tavernDeck: Card[]; // 酒馆牌堆 (数字牌+A)
|
||||
discardPile: Card[]; // 弃牌堆
|
||||
|
||||
// 敌人
|
||||
currentEnemy: Enemy | null;
|
||||
defeatedEnemies: Enemy[];
|
||||
|
||||
// 玩家
|
||||
hand: Card[];
|
||||
|
||||
// 小丑牌
|
||||
jesters: JesterCard[];
|
||||
jestersUsed: number;
|
||||
|
||||
// 当前回合信息
|
||||
phase: GamePhase;
|
||||
currentPlayed: PlayedCards | null;
|
||||
|
||||
// 游戏结果
|
||||
victoryLevel: VictoryLevel;
|
||||
isGameOver: boolean;
|
||||
|
||||
// 索引签名以满足 Record<string, unknown> 约束
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { h } from 'preact';
|
||||
import { GameUI } from 'boardgame-phaser';
|
||||
import { gameModule } from './game/regicide';
|
||||
import './style.css';
|
||||
import App from "@/ui/App";
|
||||
import { GameScene } from "@/scenes/GameScene";
|
||||
|
||||
const ui = new GameUI({
|
||||
container: document.getElementById('ui-root')!,
|
||||
root: <App gameModule={gameModule} gameScene={GameScene} />,
|
||||
});
|
||||
|
||||
ui.mount();
|
||||
@@ -0,0 +1,321 @@
|
||||
import Phaser from 'phaser';
|
||||
import type { RegicideState, Card, Enemy } from '@/game/types';
|
||||
import { GameHostScene } from 'boardgame-phaser';
|
||||
import { spawnEffect, type Spawner } from 'boardgame-phaser';
|
||||
import { GameUI, CardView, EnemyView, PromptDisplay } from './views';
|
||||
import { prompts } from '@/game/regicide';
|
||||
import { counterattackInfo, setGameSceneRef, deckInfo } from '@/ui/App';
|
||||
|
||||
const GAME_WIDTH = 800;
|
||||
const GAME_HEIGHT = 700;
|
||||
const CENTER_X = GAME_WIDTH / 2;
|
||||
const HAND_Y = 580;
|
||||
const CARD_SPACING = 70;
|
||||
|
||||
export class GameScene extends GameHostScene<RegicideState> {
|
||||
private gameUI!: GameUI;
|
||||
private promptDisplay!: PromptDisplay;
|
||||
// 追踪当前卡牌容器,用于点击检测
|
||||
public cardContainers: Phaser.GameObjects.Container[] = [];
|
||||
// 卡牌选择状态(反击阶段)
|
||||
private selectedCardIds: Set<string> = new Set();
|
||||
|
||||
constructor() {
|
||||
super('RegicideGameScene');
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
setGameSceneRef(this);
|
||||
|
||||
// 创建 UI 组件
|
||||
this.gameUI = new GameUI(this);
|
||||
this.promptDisplay = new PromptDisplay(this);
|
||||
|
||||
// 生成效果
|
||||
this.disposables.add(spawnEffect(new EnemySpawner(this)));
|
||||
|
||||
// 手牌管理 - 手动跟踪卡牌容器
|
||||
const cardViews = new Map<string, CardView>();
|
||||
this.addEffect(() => {
|
||||
const hand = this.state.hand;
|
||||
const handSize = hand.length;
|
||||
const spacing = Math.min(CARD_SPACING, 600 / Math.max(1, handSize));
|
||||
const startX = CENTER_X - (handSize - 1) * spacing / 2;
|
||||
|
||||
const currentIds = new Set(hand.map(c => c.id));
|
||||
|
||||
// 移除不存在的卡牌
|
||||
for (const [id, view] of cardViews) {
|
||||
if (!currentIds.has(id)) {
|
||||
this.tweens.add({
|
||||
targets: view.container,
|
||||
alpha: 0,
|
||||
scale: 0.5,
|
||||
y: view.container.y - 100,
|
||||
duration: 200,
|
||||
ease: 'Back.easeIn',
|
||||
onComplete: () => {
|
||||
const idx = this.cardContainers.indexOf(view.container);
|
||||
if (idx !== -1) this.cardContainers.splice(idx, 1);
|
||||
view.destroy();
|
||||
},
|
||||
});
|
||||
cardViews.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加或更新卡牌
|
||||
for (let i = 0; i < handSize; i++) {
|
||||
const card = hand[i];
|
||||
const x = startX + i * spacing;
|
||||
let view = cardViews.get(card.id);
|
||||
|
||||
if (!view) {
|
||||
view = new CardView(this, card, CENTER_X, HAND_Y);
|
||||
cardViews.set(card.id, view);
|
||||
this.cardContainers.push(view.container);
|
||||
|
||||
// 入场动画
|
||||
this.tweens.add({
|
||||
targets: view.container,
|
||||
x,
|
||||
y: HAND_Y,
|
||||
duration: 300,
|
||||
ease: 'Back.easeOut',
|
||||
});
|
||||
} else {
|
||||
view.setPosition(x, HAND_Y);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 状态效果
|
||||
this.addEffect(() => {
|
||||
this.gameUI.updateEnemyDisplay(this, this.state.currentEnemy);
|
||||
});
|
||||
|
||||
this.addEffect(() => {
|
||||
this.gameUI.updatePhaseText(this.state.phase);
|
||||
});
|
||||
|
||||
this.addEffect(() => {
|
||||
if (this.state.isGameOver) {
|
||||
this.gameUI.showGameOver(this, this.state.victoryLevel);
|
||||
} else {
|
||||
this.gameUI.hideGameOver();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听 activePrompt 信号
|
||||
this.addEffect(() => {
|
||||
const schema = this.gameHost.activePromptSchema?.value;
|
||||
const player = this.gameHost.activePromptPlayer?.value;
|
||||
this.promptDisplay.update(schema, player);
|
||||
});
|
||||
|
||||
// 监听阶段变化,更新反击信息
|
||||
this.addEffect(() => {
|
||||
const phase = this.state.phase;
|
||||
const enemy = this.state.currentEnemy;
|
||||
const totalValue = this.getTotalSelectedCardValue();
|
||||
const required = enemy?.counterDamage ?? 0;
|
||||
const maxHandValue = this.getMaxHandValue();
|
||||
|
||||
counterattackInfo.value = {
|
||||
phase,
|
||||
selectedCards: Array.from(this.selectedCardIds),
|
||||
totalValue,
|
||||
maxHandValue,
|
||||
requiredValue: required,
|
||||
canSubmit: this.selectedCardIds.size > 0 && totalValue >= required,
|
||||
canWin: maxHandValue >= required,
|
||||
error: null,
|
||||
};
|
||||
|
||||
if (phase !== 'enemyCounterattack') {
|
||||
this.selectedCardIds.clear();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听牌堆变化,更新牌堆信息
|
||||
this.addEffect(() => {
|
||||
const state = this.state;
|
||||
deckInfo.value = {
|
||||
castleDeck: state.castleDeck.length,
|
||||
tavernDeck: state.tavernDeck.length,
|
||||
discardPile: state.discardPile.length,
|
||||
hand: state.hand.length,
|
||||
defeatedEnemies: state.defeatedEnemies.length,
|
||||
jestersUsed: state.jestersUsed,
|
||||
};
|
||||
});
|
||||
|
||||
// 设置背景点击
|
||||
this.setupBackgroundInput();
|
||||
}
|
||||
|
||||
private setupBackgroundInput(): void {
|
||||
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
|
||||
if (this.isPointerOnCard(pointer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 点击空白区域取消选中(反击阶段)
|
||||
if (this.state.phase === 'enemyCounterattack' && this.selectedCardIds.size > 0) {
|
||||
this.selectedCardIds.clear();
|
||||
for (const container of this.cardContainers) {
|
||||
const view = container.getData('cardView') as CardView | undefined;
|
||||
if (view) view.setSelected(false, this);
|
||||
}
|
||||
this.updateCounterattackInfo();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.isGameOver) {
|
||||
this.gameHost.start();
|
||||
} else if (!this.state.currentEnemy && this.state.phase === 'playerTurn') {
|
||||
this.gameHost.start();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 公共方法:供 Preact UI 调用提交反击
|
||||
submitCounterattack(): void {
|
||||
const cardIds = Array.from(this.selectedCardIds);
|
||||
if (cardIds.length === 0) return;
|
||||
|
||||
const error = this.gameHost.tryAnswerPrompt(prompts.counterattack, cardIds);
|
||||
if (error) {
|
||||
counterattackInfo.value = {
|
||||
...counterattackInfo.value,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
this.selectedCardIds.clear();
|
||||
for (const container of this.cardContainers) {
|
||||
const view = container.getData('cardView') as CardView | undefined;
|
||||
if (view) view.setSelected(false, this);
|
||||
}
|
||||
this.updateCounterattackInfo();
|
||||
}
|
||||
|
||||
// 公共方法:反击阶段认输
|
||||
surrenderCounterattack(): void {
|
||||
// 直接提交空牌,触发游戏结束
|
||||
this.gameHost.tryAnswerPrompt(prompts.counterattack, []);
|
||||
|
||||
this.selectedCardIds.clear();
|
||||
for (const container of this.cardContainers) {
|
||||
const view = container.getData('cardView') as CardView | undefined;
|
||||
if (view) view.setSelected(false, this);
|
||||
}
|
||||
this.updateCounterattackInfo();
|
||||
}
|
||||
|
||||
private isPointerOnCard(pointer: Phaser.Input.Pointer): boolean {
|
||||
for (const container of this.cardContainers) {
|
||||
if (!container || !container.active) continue;
|
||||
const bounds = container.getBounds();
|
||||
if (
|
||||
pointer.x >= bounds.left &&
|
||||
pointer.x <= bounds.right &&
|
||||
pointer.y >= bounds.top &&
|
||||
pointer.y <= bounds.bottom
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 卡牌点击回调(反击阶段)
|
||||
onCardClicked(cardView: CardView): void {
|
||||
const card = cardView.getCard();
|
||||
if (this.selectedCardIds.has(card.id)) {
|
||||
this.selectedCardIds.delete(card.id);
|
||||
} else {
|
||||
this.selectedCardIds.add(card.id);
|
||||
}
|
||||
this.updateCounterattackInfo();
|
||||
}
|
||||
|
||||
private updateCounterattackInfo(): void {
|
||||
const enemy = this.state.currentEnemy;
|
||||
const totalValue = this.getTotalSelectedCardValue();
|
||||
const required = enemy?.counterDamage ?? 0;
|
||||
const maxHandValue = this.getMaxHandValue();
|
||||
|
||||
counterattackInfo.value = {
|
||||
phase: this.state.phase,
|
||||
selectedCards: Array.from(this.selectedCardIds),
|
||||
totalValue,
|
||||
maxHandValue,
|
||||
requiredValue: required,
|
||||
canSubmit: this.selectedCardIds.size > 0 && totalValue >= required,
|
||||
canWin: maxHandValue >= required,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
private getTotalSelectedCardValue(): number {
|
||||
let total = 0;
|
||||
for (const id of this.selectedCardIds) {
|
||||
const card = this.state.hand.find(c => c.id === id);
|
||||
if (card) total += card.value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private getMaxHandValue(): number {
|
||||
return this.state.hand.reduce((sum, c) => sum + c.value, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 敌人生成器
|
||||
class EnemySpawner implements Spawner<Enemy, Phaser.GameObjects.Container> {
|
||||
constructor(public readonly scene: GameScene) {}
|
||||
|
||||
*getData() {
|
||||
if (this.scene.state.currentEnemy && !this.scene.state.currentEnemy.isDefeated) {
|
||||
yield this.scene.state.currentEnemy;
|
||||
}
|
||||
}
|
||||
|
||||
getKey(enemy: Enemy): string {
|
||||
return enemy.id;
|
||||
}
|
||||
|
||||
onUpdate(enemy: Enemy, container: Phaser.GameObjects.Container): void {
|
||||
const view = container.getData('enemyView') as EnemyView | undefined;
|
||||
if (view) {
|
||||
view.setPosition(CENTER_X, 200);
|
||||
view.updateEnemy(enemy);
|
||||
}
|
||||
}
|
||||
|
||||
onSpawn(enemy: Enemy): Phaser.GameObjects.Container {
|
||||
const view = new EnemyView(this.scene, enemy, CENTER_X, 200);
|
||||
const container = view.container;
|
||||
container.setData('enemyView', view);
|
||||
return container;
|
||||
}
|
||||
|
||||
onDespawn(container: Phaser.GameObjects.Container): void {
|
||||
const view = container.getData('enemyView') as EnemyView | undefined;
|
||||
if (view) {
|
||||
this.scene.tweens.add({
|
||||
targets: container,
|
||||
alpha: 0,
|
||||
y: container.y - 150,
|
||||
scale: 0.3,
|
||||
duration: 400,
|
||||
ease: 'Back.easeIn',
|
||||
onComplete: () => view.destroy(),
|
||||
});
|
||||
} else {
|
||||
container.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import Phaser from 'phaser';
|
||||
|
||||
// 伤害数字特效
|
||||
export function showDamageNumber(
|
||||
scene: Phaser.Scene,
|
||||
x: number,
|
||||
y: number,
|
||||
damage: number,
|
||||
color: number = 0xef4444
|
||||
): void {
|
||||
const text = scene.add.text(x, y, `-${damage}`, {
|
||||
fontSize: '32px',
|
||||
fontFamily: 'Arial',
|
||||
color: `#${color.toString(16).padStart(6, '0')}`,
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
scene.tweens.add({
|
||||
targets: text,
|
||||
y: y - 100,
|
||||
alpha: 0,
|
||||
scale: 1.5,
|
||||
duration: 800,
|
||||
ease: 'Power2',
|
||||
onComplete: () => text.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
// 治疗数字特效
|
||||
export function showHealNumber(
|
||||
scene: Phaser.Scene,
|
||||
x: number,
|
||||
y: number,
|
||||
amount: number
|
||||
): void {
|
||||
const text = scene.add.text(x, y, `+${amount}`, {
|
||||
fontSize: '28px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#22c55e',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
scene.tweens.add({
|
||||
targets: text,
|
||||
y: y - 80,
|
||||
alpha: 0,
|
||||
duration: 800,
|
||||
ease: 'Power2',
|
||||
onComplete: () => text.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
// 卡牌飞行特效
|
||||
export function flyCardToTarget(
|
||||
scene: Phaser.Scene,
|
||||
card: Phaser.GameObjects.Container,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
duration: number = 400,
|
||||
onComplete?: () => void
|
||||
): void {
|
||||
const startX = card.x;
|
||||
const startY = card.y;
|
||||
|
||||
// 创建飞行路径
|
||||
const path = [
|
||||
{ x: startX, y: startY },
|
||||
{ x: startX + (targetX - startX) / 2, y: startY - 100 },
|
||||
{ x: targetX, y: targetY },
|
||||
];
|
||||
|
||||
scene.tweens.add({
|
||||
targets: card,
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
scale: 0.5,
|
||||
alpha: 0,
|
||||
duration,
|
||||
ease: 'Back.easeIn',
|
||||
onComplete: () => {
|
||||
if (onComplete) onComplete();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 敌人受击特效
|
||||
export function enemyHitEffect(
|
||||
scene: Phaser.Scene,
|
||||
enemyContainer: Phaser.GameObjects.Container
|
||||
): void {
|
||||
// 红色闪烁
|
||||
const flash = scene.add.rectangle(
|
||||
enemyContainer.x,
|
||||
enemyContainer.y,
|
||||
150,
|
||||
200,
|
||||
0xff0000,
|
||||
0.5
|
||||
);
|
||||
|
||||
scene.tweens.add({
|
||||
targets: flash,
|
||||
alpha: 0,
|
||||
duration: 300,
|
||||
onComplete: () => flash.destroy(),
|
||||
});
|
||||
|
||||
// 震动效果
|
||||
scene.tweens.add({
|
||||
targets: enemyContainer,
|
||||
x: enemyContainer.x + 10,
|
||||
duration: 50,
|
||||
yoyo: true,
|
||||
repeat: 3,
|
||||
});
|
||||
}
|
||||
|
||||
// 敌人死亡特效
|
||||
export function enemyDeathEffect(
|
||||
scene: Phaser.Scene,
|
||||
enemyContainer: Phaser.GameObjects.Container
|
||||
): void {
|
||||
// 粒子爆炸
|
||||
const particles = scene.add.particles(enemyContainer.x, enemyContainer.y, 'particle', {
|
||||
speed: { min: 50, max: 150 },
|
||||
angle: { min: 0, max: 360 },
|
||||
scale: { start: 1, end: 0 },
|
||||
lifespan: 1000,
|
||||
gravityY: 100,
|
||||
quantity: 20,
|
||||
emitting: true,
|
||||
});
|
||||
|
||||
// 淡出并上升
|
||||
scene.tweens.add({
|
||||
targets: enemyContainer,
|
||||
y: enemyContainer.y - 200,
|
||||
alpha: 0,
|
||||
scale: 0.5,
|
||||
duration: 800,
|
||||
ease: 'Back.easeIn',
|
||||
onComplete: () => {
|
||||
enemyContainer.destroy();
|
||||
particles.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 抽牌特效
|
||||
export function drawCardEffect(
|
||||
scene: Phaser.Scene,
|
||||
fromX: number,
|
||||
fromY: number,
|
||||
toX: number,
|
||||
toY: number
|
||||
): void {
|
||||
// 创建临时卡牌
|
||||
const tempCard = scene.add.rectangle(fromX, fromY, 60, 90, 0x3b82f6, 0.8)
|
||||
.setStrokeStyle(2, 0x60a5fa);
|
||||
|
||||
scene.tweens.add({
|
||||
targets: tempCard,
|
||||
x: toX,
|
||||
y: toY,
|
||||
duration: 300,
|
||||
ease: 'Back.easeOut',
|
||||
onComplete: () => tempCard.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
// 反击警告特效
|
||||
export function counterattackWarning(
|
||||
scene: Phaser.Scene,
|
||||
x: number,
|
||||
y: number,
|
||||
damage: number
|
||||
): void {
|
||||
const warning = scene.add.text(x, y, `⚠️ 反击伤害: ${damage}`, {
|
||||
fontSize: '24px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#ef4444',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// 闪烁动画
|
||||
scene.tweens.add({
|
||||
targets: warning,
|
||||
alpha: 0.3,
|
||||
duration: 300,
|
||||
yoyo: true,
|
||||
repeat: 2,
|
||||
});
|
||||
|
||||
// 消失
|
||||
scene.time.delayedCall(1500, () => {
|
||||
scene.tweens.add({
|
||||
targets: warning,
|
||||
alpha: 0,
|
||||
y: y - 50,
|
||||
duration: 300,
|
||||
onComplete: () => warning.destroy(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 胜利特效
|
||||
export function victoryEffect(
|
||||
scene: Phaser.Scene,
|
||||
x: number,
|
||||
y: number
|
||||
): void {
|
||||
// 烟花粒子
|
||||
for (let i = 0; i < 3; i++) {
|
||||
scene.time.delayedCall(i * 300, () => {
|
||||
const particles = scene.add.particles(
|
||||
x + (Math.random() - 0.5) * 200,
|
||||
y + (Math.random() - 0.5) * 100,
|
||||
'particle',
|
||||
{
|
||||
speed: { min: 30, max: 100 },
|
||||
angle: { min: 0, max: 360 },
|
||||
scale: { start: 1.5, end: 0 },
|
||||
lifespan: 1500,
|
||||
gravityY: 50,
|
||||
quantity: 30,
|
||||
emitting: true,
|
||||
}
|
||||
);
|
||||
|
||||
scene.time.delayedCall(1500, () => particles.destroy());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 闪烁文本
|
||||
export function createFlashingText(
|
||||
scene: Phaser.Scene,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
color: string = '#fbbf24'
|
||||
): Phaser.GameObjects.Text {
|
||||
const flashingText = scene.add.text(x, y, text, {
|
||||
fontSize: '20px',
|
||||
fontFamily: 'Arial',
|
||||
color,
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
scene.tweens.add({
|
||||
targets: flashingText,
|
||||
alpha: 0.3,
|
||||
duration: 500,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
});
|
||||
|
||||
return flashingText;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import Phaser from 'phaser';
|
||||
import type { Card } from '@/game/types';
|
||||
import { getCardDisplay, getSuitColor } from '@/game/card-utils';
|
||||
import type { GameScene } from '../GameScene';
|
||||
import { prompts } from '@/game/regicide';
|
||||
|
||||
export const CARD_WIDTH = 80;
|
||||
export const CARD_HEIGHT = 120;
|
||||
|
||||
export class CardView {
|
||||
public readonly container: Phaser.GameObjects.Container;
|
||||
private readonly bg: Phaser.GameObjects.Rectangle;
|
||||
private readonly text: Phaser.GameObjects.Text;
|
||||
private readonly valueText: Phaser.GameObjects.Text;
|
||||
private readonly card: Card;
|
||||
private isHovering = false;
|
||||
private isSelected = false;
|
||||
private baseY: number;
|
||||
private selectedBorderColor = 0xfbbf24; // 金色边框表示选中
|
||||
|
||||
constructor(scene: GameScene, card: Card, x: number, y: number) {
|
||||
this.card = card;
|
||||
this.baseY = y;
|
||||
this.container = scene.add.container(x, y);
|
||||
|
||||
// 卡牌背景
|
||||
this.bg = scene.add.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, 0xf9fafb)
|
||||
.setStrokeStyle(3, 0x6b7280);
|
||||
|
||||
// 卡牌边框颜色
|
||||
const borderColor = getSuitColor(card.suit);
|
||||
this.bg.setStrokeStyle(3, Phaser.Display.Color.HexStringToColor(borderColor).color);
|
||||
|
||||
// 卡牌文本
|
||||
const display = getCardDisplay(card);
|
||||
const color = getSuitColor(card.suit);
|
||||
this.text = scene.add.text(0, 0, display, {
|
||||
fontSize: '24px',
|
||||
fontFamily: 'Arial',
|
||||
color,
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// 卡牌数值提示
|
||||
this.valueText = scene.add.text(0, -CARD_HEIGHT / 2 + 15, `+${card.value}`, {
|
||||
fontSize: '12px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#6b7280',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.container.add([this.bg, this.valueText, this.text]);
|
||||
|
||||
// 设置交互
|
||||
this.bg.setInteractive({ useHandCursor: true });
|
||||
this.setupInteraction(scene);
|
||||
|
||||
// 出现动画
|
||||
this.container.setScale(0);
|
||||
scene.tweens.add({
|
||||
targets: this.container,
|
||||
scale: 1,
|
||||
duration: 200,
|
||||
ease: 'Back.easeOut',
|
||||
});
|
||||
}
|
||||
|
||||
setPosition(x: number, y: number): void {
|
||||
this.baseY = y;
|
||||
this.container.setPosition(x, y);
|
||||
}
|
||||
|
||||
hover(offset: number = -30): void {
|
||||
if (!this.isHovering) {
|
||||
this.isHovering = true;
|
||||
const scene = this.container.scene as Phaser.Scene;
|
||||
scene.tweens.add({
|
||||
targets: this.container,
|
||||
y: this.baseY + offset + (this.isSelected ? -15 : 0),
|
||||
duration: 100,
|
||||
ease: 'Power2',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
unhover(): void {
|
||||
if (this.isHovering) {
|
||||
this.isHovering = false;
|
||||
const scene = this.container.scene as Phaser.Scene;
|
||||
scene.tweens.add({
|
||||
targets: this.container,
|
||||
y: this.baseY + (this.isSelected ? -15 : 0),
|
||||
duration: 100,
|
||||
ease: 'Power2',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setSelected(selected: boolean, scene: GameScene): void {
|
||||
if (this.isSelected === selected) return;
|
||||
this.isSelected = selected;
|
||||
|
||||
// 更新边框颜色
|
||||
const color = selected ? this.selectedBorderColor : getSuitColor(this.card.suit);
|
||||
this.bg.setStrokeStyle(3, Phaser.Display.Color.HexStringToColor(
|
||||
selected ? '#fbbf24' : getSuitColor(this.card.suit)
|
||||
).color);
|
||||
|
||||
// 更新位置
|
||||
const targetY = this.baseY + (selected ? -15 : 0);
|
||||
scene.tweens.add({
|
||||
targets: this.container,
|
||||
y: targetY,
|
||||
duration: 100,
|
||||
ease: 'Power2',
|
||||
});
|
||||
}
|
||||
|
||||
getSelected(): boolean {
|
||||
return this.isSelected;
|
||||
}
|
||||
|
||||
getCard(): Card {
|
||||
return this.card;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.container.destroy();
|
||||
}
|
||||
|
||||
private setupInteraction(scene: GameScene): void {
|
||||
this.bg.on('pointerover', () => {
|
||||
this.hover();
|
||||
});
|
||||
|
||||
this.bg.on('pointerout', () => {
|
||||
this.unhover();
|
||||
});
|
||||
|
||||
this.bg.on('pointerdown', () => {
|
||||
if (scene.state.phase === 'playerTurn') {
|
||||
scene.gameHost.tryAnswerPrompt(prompts.playerAction, 'play', [this.card.id]);
|
||||
} else if (scene.state.phase === 'enemyCounterattack') {
|
||||
// 切换选中状态
|
||||
const newSelected = !this.isSelected;
|
||||
this.setSelected(newSelected, scene);
|
||||
// 通知 GameScene 更新选中状态
|
||||
scene.onCardClicked(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Phaser from 'phaser';
|
||||
import type { Enemy } from '@/game/types';
|
||||
import type { GameScene } from '../GameScene';
|
||||
|
||||
const ENEMY_CARD_WIDTH = 144;
|
||||
const ENEMY_CARD_HEIGHT = 180;
|
||||
|
||||
export class EnemyView {
|
||||
public readonly container: Phaser.GameObjects.Container;
|
||||
private readonly bg: Phaser.GameObjects.Rectangle;
|
||||
private readonly nameText: Phaser.GameObjects.Text;
|
||||
private readonly immunityText: Phaser.GameObjects.Text;
|
||||
private readonly counterText: Phaser.GameObjects.Text;
|
||||
private readonly hpText: Phaser.GameObjects.Text;
|
||||
private enemy: Enemy;
|
||||
|
||||
constructor(scene: GameScene, enemy: Enemy, x: number, y: number) {
|
||||
this.enemy = enemy;
|
||||
this.container = scene.add.container(x, y);
|
||||
|
||||
// 敌人卡牌背景
|
||||
this.bg = scene.add.rectangle(0, 0, ENEMY_CARD_WIDTH, ENEMY_CARD_HEIGHT, 0x1f2937)
|
||||
.setStrokeStyle(4, 0xef4444);
|
||||
|
||||
// 敌人名称
|
||||
const suitSymbols: Record<string, string> = {
|
||||
hearts: '♥',
|
||||
diamonds: '♦',
|
||||
clubs: '♣',
|
||||
spades: '♠',
|
||||
};
|
||||
const suitSymbol = enemy.suit ? suitSymbols[enemy.suit] : '';
|
||||
this.nameText = scene.add.text(0, -40, `${enemy.rank} ${suitSymbol}`, {
|
||||
fontSize: '32px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#fbbf24',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// 花色免疫提示
|
||||
const suitNames: Record<string, string> = {
|
||||
hearts: '红桃 ♥',
|
||||
diamonds: '方片 ♦',
|
||||
clubs: '梅花 ♣',
|
||||
spades: '黑桃 ♠',
|
||||
};
|
||||
const immunityText = enemy.immunitySuit
|
||||
? `🛡️ 免疫: ${suitNames[enemy.immunitySuit]}`
|
||||
: '';
|
||||
this.immunityText = scene.add.text(0, 0, immunityText, {
|
||||
fontSize: '14px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#9ca3af',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// 反击伤害提示
|
||||
this.counterText = scene.add.text(0, 30, `⚔️ 反击伤害: ${enemy.counterDamage}`, {
|
||||
fontSize: '16px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#ef4444',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// HP提示
|
||||
this.hpText = scene.add.text(0, 60, `❤️ ${enemy.hp} HP`, {
|
||||
fontSize: '16px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#22c55e',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.container.add([this.bg, this.nameText, this.immunityText, this.counterText, this.hpText]);
|
||||
|
||||
// 出现动画
|
||||
this.container.setScale(0);
|
||||
scene.tweens.add({
|
||||
targets: this.container,
|
||||
scale: 1,
|
||||
duration: 400,
|
||||
ease: 'Back.easeOut',
|
||||
});
|
||||
|
||||
// 抖动效果
|
||||
scene.tweens.add({
|
||||
targets: this.container,
|
||||
x: x + 5,
|
||||
duration: 100,
|
||||
yoyo: true,
|
||||
repeat: 3,
|
||||
});
|
||||
}
|
||||
|
||||
updateEnemy(enemy: Enemy): void {
|
||||
this.enemy = enemy;
|
||||
this.hpText.setText(`❤️ ${enemy.currentHp}/${enemy.hp} HP`);
|
||||
this.counterText.setText(`⚔️ 反击伤害: ${enemy.counterDamage}`);
|
||||
}
|
||||
|
||||
setPosition(x: number, y: number): void {
|
||||
this.container.setPosition(x, y);
|
||||
}
|
||||
|
||||
getEnemy(): Enemy {
|
||||
return this.enemy;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.container.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import Phaser from 'phaser';
|
||||
import type { Enemy, VictoryLevel } from '@/game/types';
|
||||
import type { GameScene } from '../GameScene';
|
||||
import { EnemyView } from './EnemyView';
|
||||
|
||||
const GAME_WIDTH = 800;
|
||||
const GAME_HEIGHT = 700;
|
||||
const CENTER_X = GAME_WIDTH / 2;
|
||||
const ENEMY_Y = 200;
|
||||
const BAR_WIDTH = 200;
|
||||
const BAR_HEIGHT = 20;
|
||||
|
||||
export class GameUI {
|
||||
public readonly container: Phaser.GameObjects.Container;
|
||||
private readonly hpBar: Phaser.GameObjects.Graphics;
|
||||
private readonly infoText: Phaser.GameObjects.Text;
|
||||
private readonly phaseText: Phaser.GameObjects.Text;
|
||||
private gameOverOverlay?: Phaser.GameObjects.Container;
|
||||
private enemyView?: EnemyView;
|
||||
|
||||
constructor(scene: GameScene) {
|
||||
this.container = scene.add.container(0, 0);
|
||||
|
||||
// 创建HP条
|
||||
this.hpBar = scene.add.graphics();
|
||||
|
||||
// 创建文本
|
||||
this.infoText = scene.add.text(CENTER_X, 30, '⚔️ Regicide - 击败所有12个敌人!', {
|
||||
fontSize: '22px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#fbbf24',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.phaseText = scene.add.text(CENTER_X, 60, '点击任意位置开始游戏', {
|
||||
fontSize: '16px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#9ca3af',
|
||||
}).setOrigin(0.5);
|
||||
}
|
||||
|
||||
updateEnemyDisplay(scene: GameScene, enemy: Enemy | null): void {
|
||||
this.hpBar.clear();
|
||||
|
||||
if (!enemy) {
|
||||
if (this.enemyView) {
|
||||
this.enemyView.destroy();
|
||||
this.enemyView = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新或创建敌人视图
|
||||
if (this.enemyView) {
|
||||
this.enemyView.updateEnemy(enemy);
|
||||
} else {
|
||||
this.enemyView = new EnemyView(scene, enemy, CENTER_X, ENEMY_Y);
|
||||
}
|
||||
|
||||
// 绘制HP条
|
||||
this.drawHpBar(enemy);
|
||||
}
|
||||
|
||||
updatePhaseText(phase: string): void {
|
||||
const phaseNames: Record<string, string> = {
|
||||
playerTurn: '🎯 你的回合 - 点击卡牌攻击敌人',
|
||||
enemyCounterattack: '💥 敌人反击! - 点击卡牌抵消伤害',
|
||||
enemyDefeated: '✨ 敌人被击败! 准备迎战下一个敌人',
|
||||
gameOver: '🏁 游戏结束',
|
||||
};
|
||||
this.phaseText.setText(phaseNames[phase] || phase);
|
||||
}
|
||||
|
||||
showGameOver(scene: GameScene, victoryLevel: VictoryLevel): void {
|
||||
if (this.gameOverOverlay) {
|
||||
this.gameOverOverlay.destroy();
|
||||
}
|
||||
|
||||
this.gameOverOverlay = scene.add.container();
|
||||
|
||||
// 半透明背景
|
||||
const bg = scene.add.rectangle(CENTER_X, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.85)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
|
||||
bg.on('pointerdown', () => {
|
||||
scene.gameHost.start();
|
||||
});
|
||||
|
||||
this.gameOverOverlay.add(bg);
|
||||
|
||||
// 游戏结束文本
|
||||
let resultText = '游戏结束';
|
||||
let resultColor = '#ef4444';
|
||||
|
||||
if (victoryLevel === 'gold') {
|
||||
resultText = '🥇 金胜利! 完美通关!';
|
||||
resultColor = '#fbbf24';
|
||||
} else if (victoryLevel === 'silver') {
|
||||
resultText = '🥈 银胜利! 使用1张小丑牌';
|
||||
resultColor = '#9ca3af';
|
||||
} else if (victoryLevel === 'bronze') {
|
||||
resultText = '🥉 铜胜利! 使用2张小丑牌';
|
||||
resultColor = '#b45309';
|
||||
} else {
|
||||
resultText = '💀 失败! 无法抵消反击伤害';
|
||||
}
|
||||
|
||||
const text = scene.add.text(CENTER_X, 250, resultText, {
|
||||
fontSize: '36px',
|
||||
fontFamily: 'Arial',
|
||||
color: resultColor,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.gameOverOverlay.add(text);
|
||||
|
||||
const restartText = scene.add.text(CENTER_X, 350, '点击重新开始', {
|
||||
fontSize: '24px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#6b7280',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.gameOverOverlay.add(restartText);
|
||||
|
||||
// 动画
|
||||
scene.tweens.add({
|
||||
targets: text,
|
||||
scale: 1.1,
|
||||
duration: 500,
|
||||
yoyo: true,
|
||||
repeat: 2,
|
||||
});
|
||||
}
|
||||
|
||||
hideGameOver(): void {
|
||||
if (this.gameOverOverlay) {
|
||||
this.gameOverOverlay.destroy();
|
||||
this.gameOverOverlay = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.hpBar.destroy();
|
||||
this.infoText.destroy();
|
||||
this.phaseText.destroy();
|
||||
if (this.gameOverOverlay) {
|
||||
this.gameOverOverlay.destroy();
|
||||
}
|
||||
if (this.enemyView) {
|
||||
this.enemyView.destroy();
|
||||
}
|
||||
this.container.destroy();
|
||||
}
|
||||
|
||||
private drawHpBar(enemy: Enemy): void {
|
||||
const hpPercent = enemy.currentHp / enemy.hp;
|
||||
const barX = CENTER_X - BAR_WIDTH / 2;
|
||||
const barY = ENEMY_Y + 100;
|
||||
|
||||
// 背景
|
||||
this.hpBar.fillStyle(0x374151);
|
||||
this.hpBar.fillRoundedRect(barX, barY, BAR_WIDTH, BAR_HEIGHT, 5);
|
||||
|
||||
// 前景
|
||||
const hpColor = hpPercent > 0.5 ? 0x22c55e : hpPercent > 0.25 ? 0xf59e0b : 0xef4444;
|
||||
this.hpBar.fillStyle(hpColor);
|
||||
this.hpBar.fillRoundedRect(barX, barY, BAR_WIDTH * hpPercent, BAR_HEIGHT, 5);
|
||||
|
||||
// 边框
|
||||
this.hpBar.lineStyle(2, 0x6b7280);
|
||||
this.hpBar.strokeRoundedRect(barX, barY, BAR_WIDTH, BAR_HEIGHT, 5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Phaser from 'phaser';
|
||||
import type { Card } from '@/game/types';
|
||||
import { CardView, CARD_WIDTH } from './CardView';
|
||||
import type { GameScene } from '../GameScene';
|
||||
|
||||
const HAND_Y = 580;
|
||||
const MAX_HAND_WIDTH = 600;
|
||||
const CARD_SPACING = 70;
|
||||
|
||||
export class HandContainer {
|
||||
public readonly container: Phaser.GameObjects.Container;
|
||||
private cardViews: Map<string, CardView> = new Map();
|
||||
private centerX: number;
|
||||
private handY: number;
|
||||
|
||||
constructor(scene: GameScene, centerX: number, handY: number = HAND_Y) {
|
||||
this.container = scene.add.container(0, 0);
|
||||
this.centerX = centerX;
|
||||
this.handY = handY;
|
||||
}
|
||||
|
||||
updateCards(scene: GameScene, cards: Card[]): void {
|
||||
const currentCardIds = new Set(cards.map(c => c.id));
|
||||
|
||||
// 移除不在手牌中的卡牌
|
||||
for (const [cardId, view] of this.cardViews) {
|
||||
if (!currentCardIds.has(cardId)) {
|
||||
this.animateCardExit(scene, view);
|
||||
this.cardViews.delete(cardId);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新或创建卡牌
|
||||
const handSize = cards.length;
|
||||
const spacing = Math.min(CARD_SPACING, MAX_HAND_WIDTH / handSize);
|
||||
const startX = this.centerX - (handSize - 1) * spacing / 2;
|
||||
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
const card = cards[i];
|
||||
const existingView = this.cardViews.get(card.id);
|
||||
|
||||
if (existingView) {
|
||||
// 更新位置
|
||||
const targetX = startX + i * spacing;
|
||||
const targetY = this.handY;
|
||||
existingView.setPosition(targetX, targetY);
|
||||
} else {
|
||||
// 创建新卡牌
|
||||
const x = startX + i * spacing;
|
||||
const view = new CardView(scene, card, x, this.handY);
|
||||
this.cardViews.set(card.id, view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCardView(cardId: string): CardView | undefined {
|
||||
return this.cardViews.get(cardId);
|
||||
}
|
||||
|
||||
getAllViews(): CardView[] {
|
||||
return Array.from(this.cardViews.values());
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const view of this.cardViews.values()) {
|
||||
view.destroy();
|
||||
}
|
||||
this.cardViews.clear();
|
||||
this.container.destroy();
|
||||
}
|
||||
|
||||
private animateCardExit(scene: GameScene, view: CardView): void {
|
||||
scene.tweens.add({
|
||||
targets: view.container,
|
||||
alpha: 0,
|
||||
scale: 0.5,
|
||||
y: view.container.y - 100,
|
||||
duration: 200,
|
||||
ease: 'Back.easeIn',
|
||||
onComplete: () => view.destroy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import Phaser from 'phaser';
|
||||
import type { CommandSchema } from 'boardgame-core';
|
||||
import type { GameScene } from '../GameScene';
|
||||
|
||||
const GAME_WIDTH = 800;
|
||||
const CENTER_X = GAME_WIDTH / 2;
|
||||
|
||||
export class PromptDisplay {
|
||||
public readonly container: Phaser.GameObjects.Container;
|
||||
private readonly bg: Phaser.GameObjects.Graphics;
|
||||
private readonly promptText: Phaser.GameObjects.Text;
|
||||
private readonly playerText: Phaser.GameObjects.Text;
|
||||
private isVisible = false;
|
||||
|
||||
constructor(scene: GameScene) {
|
||||
this.container = scene.add.container(CENTER_X, 450);
|
||||
this.container.setDepth(100);
|
||||
|
||||
// 创建背景
|
||||
this.bg = scene.add.graphics();
|
||||
|
||||
// 提示文本
|
||||
this.promptText = scene.add.text(0, -15, '', {
|
||||
fontSize: '18px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#fbbf24',
|
||||
fontStyle: 'bold',
|
||||
align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// 玩家文本
|
||||
this.playerText = scene.add.text(0, 15, '', {
|
||||
fontSize: '14px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#9ca3af',
|
||||
align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.container.add([this.bg, this.promptText, this.playerText]);
|
||||
this.container.setVisible(false);
|
||||
}
|
||||
|
||||
update(schema: CommandSchema | null, player: string | null): void {
|
||||
if (!schema) {
|
||||
if (this.isVisible) {
|
||||
this.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isVisible) {
|
||||
this.show();
|
||||
}
|
||||
|
||||
// 格式化 schema 显示
|
||||
const formattedSchema = this.formatSchema(schema);
|
||||
this.promptText.setText(formattedSchema);
|
||||
|
||||
if (player) {
|
||||
this.playerText.setText(`等待玩家: ${player}`);
|
||||
} else {
|
||||
this.playerText.setText('');
|
||||
}
|
||||
}
|
||||
|
||||
private show(): void {
|
||||
this.isVisible = true;
|
||||
this.container.setVisible(true);
|
||||
this.container.setScale(0);
|
||||
this.container.setAlpha(0);
|
||||
|
||||
const scene = this.container.scene as Phaser.Scene;
|
||||
scene.tweens.add({
|
||||
targets: this.container,
|
||||
scale: 1,
|
||||
alpha: 1,
|
||||
duration: 200,
|
||||
ease: 'Back.easeOut',
|
||||
});
|
||||
|
||||
this.drawBackground();
|
||||
}
|
||||
|
||||
private hide(): void {
|
||||
if (!this.isVisible) return;
|
||||
this.isVisible = false;
|
||||
|
||||
const scene = this.container.scene as Phaser.Scene;
|
||||
scene.tweens.add({
|
||||
targets: this.container,
|
||||
scale: 0.8,
|
||||
alpha: 0,
|
||||
duration: 150,
|
||||
ease: 'Power2',
|
||||
onComplete: () => this.container.setVisible(false),
|
||||
});
|
||||
}
|
||||
|
||||
private drawBackground(): void {
|
||||
this.bg.clear();
|
||||
|
||||
const padding = 20;
|
||||
const width = Math.max(
|
||||
this.promptText.width,
|
||||
this.playerText.width
|
||||
) + padding * 2;
|
||||
const height = 60;
|
||||
|
||||
// 半透明背景
|
||||
this.bg.fillStyle(0x1f2937, 0.9);
|
||||
this.bg.fillRoundedRect(-width / 2, -height / 2, width, height, 10);
|
||||
|
||||
// 边框
|
||||
this.bg.lineStyle(2, 0xfbbf24, 0.6);
|
||||
this.bg.strokeRoundedRect(-width / 2, -height / 2, width, height, 10);
|
||||
}
|
||||
|
||||
private formatSchema(schema: CommandSchema): string {
|
||||
// 格式化命令 schema 为可读字符串
|
||||
const params = schema.params.map(p => {
|
||||
return p.variadic ? `<${p.name}...>` : `<${p.name}>`;
|
||||
}).join(' ');
|
||||
|
||||
return `${schema.name} ${params}`;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.bg.destroy();
|
||||
this.promptText.destroy();
|
||||
this.playerText.destroy();
|
||||
this.container.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { CardView, CARD_WIDTH, CARD_HEIGHT } from './CardView';
|
||||
export { EnemyView } from './EnemyView';
|
||||
export { HandContainer } from './HandContainer';
|
||||
export { GameUI } from './GameUI';
|
||||
export { PromptDisplay } from './PromptDisplay';
|
||||
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background-color: #111827;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#ui-root {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#ui-root > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useComputed, signal } from '@preact/signals';
|
||||
import { createGameHost, type GameModule } from 'boardgame-core';
|
||||
import Phaser from 'phaser';
|
||||
import { h } from 'preact';
|
||||
import { PhaserGame, PhaserScene } from 'boardgame-phaser';
|
||||
|
||||
// 全局信号:从 GameScene 传递反击阶段的状态到 UI
|
||||
export const counterattackInfo = signal<{
|
||||
phase: string;
|
||||
selectedCards: string[];
|
||||
totalValue: number;
|
||||
maxHandValue: number; // 手牌最大可提供的点数
|
||||
requiredValue: number;
|
||||
canSubmit: boolean;
|
||||
canWin: boolean; // 手牌是否足以获胜
|
||||
error: string | null;
|
||||
}>({
|
||||
phase: '',
|
||||
selectedCards: [],
|
||||
totalValue: 0,
|
||||
maxHandValue: 0,
|
||||
requiredValue: 0,
|
||||
canSubmit: false,
|
||||
canWin: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// 全局信号:牌堆信息
|
||||
export const deckInfo = signal<{
|
||||
castleDeck: number; // 敌人牌堆剩余
|
||||
tavernDeck: number; // 酒馆牌堆剩余
|
||||
discardPile: number; // 弃牌堆数量
|
||||
hand: number; // 手牌数
|
||||
defeatedEnemies: number; // 已击败敌人数
|
||||
jestersUsed: number; // 已用小丑牌数
|
||||
}>({
|
||||
castleDeck: 0,
|
||||
tavernDeck: 0,
|
||||
discardPile: 0,
|
||||
hand: 0,
|
||||
defeatedEnemies: 0,
|
||||
jestersUsed: 0,
|
||||
});
|
||||
|
||||
// 存储当前场景引用(由 GameScene 在 create 时设置)
|
||||
let currentGameScene: any = null;
|
||||
export function setGameSceneRef(scene: any) {
|
||||
currentGameScene = scene;
|
||||
}
|
||||
|
||||
export default function App<TState extends Record<string, unknown>>(props: { gameModule: GameModule<TState>, gameScene: { new(): Phaser.Scene } }) {
|
||||
|
||||
const gameHost = useComputed(() => {
|
||||
const gameHost = createGameHost(props.gameModule);
|
||||
return { gameHost };
|
||||
});
|
||||
|
||||
const scene = useComputed(() => new props.gameScene());
|
||||
|
||||
const handleReset = async () => {
|
||||
const result = await gameHost.value.gameHost.start();
|
||||
console.log('Game finished!', result);
|
||||
};
|
||||
|
||||
const label = useComputed(() =>
|
||||
gameHost.value.gameHost.status.value === 'running' ? '重新开始' : '开始游戏'
|
||||
);
|
||||
|
||||
// 反击阶段提交
|
||||
const handleSubmitCounterattack = () => {
|
||||
if (currentGameScene && typeof currentGameScene.submitCounterattack === 'function') {
|
||||
currentGameScene.submitCounterattack();
|
||||
}
|
||||
};
|
||||
|
||||
// 反击阶段认输
|
||||
const handleSurrender = () => {
|
||||
if (currentGameScene && typeof currentGameScene.surrenderCounterattack === 'function') {
|
||||
currentGameScene.surrenderCounterattack();
|
||||
}
|
||||
};
|
||||
|
||||
// Phaser 画布配置
|
||||
const phaserConfig = {
|
||||
type: Phaser.AUTO,
|
||||
width: 800,
|
||||
height: 700,
|
||||
backgroundColor: '#111827',
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-screen bg-gray-900">
|
||||
{/* Phaser 游戏场景 */}
|
||||
<div class="flex-1 relative flex items-center justify-center">
|
||||
<PhaserGame config={phaserConfig}>
|
||||
<PhaserScene sceneKey="RegicideGameScene" scene={scene.value} autoStart data={gameHost.value} />
|
||||
</PhaserGame>
|
||||
</div>
|
||||
|
||||
{/* 底部控制栏 */}
|
||||
<div class="p-4 bg-gray-900 border-t border-gray-700 flex flex-col gap-3">
|
||||
{/* 牌堆信息 */}
|
||||
<div class="flex items-center justify-center gap-6 text-sm">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-lg">🏰</span>
|
||||
<span class="text-gray-400">城堡牌堆:</span>
|
||||
<span class="text-purple-400 font-bold">{deckInfo.value.castleDeck}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-lg">🍺</span>
|
||||
<span class="text-gray-400">酒馆牌堆:</span>
|
||||
<span class="text-amber-400 font-bold">{deckInfo.value.tavernDeck}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-lg">🗑️</span>
|
||||
<span class="text-gray-400">弃牌堆:</span>
|
||||
<span class="text-gray-300 font-bold">{deckInfo.value.discardPile}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-lg">🃏</span>
|
||||
<span class="text-gray-400">手牌:</span>
|
||||
<span class="text-blue-400 font-bold">{deckInfo.value.hand}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-lg">💀</span>
|
||||
<span class="text-gray-400">已击败:</span>
|
||||
<span class="text-red-400 font-bold">{deckInfo.value.defeatedEnemies}/12</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-lg">🃏</span>
|
||||
<span class="text-gray-400">小丑牌:</span>
|
||||
<span class="text-green-400 font-bold">{2 - deckInfo.value.jestersUsed}/2</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 反击阶段信息 + 按钮 */}
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-sm text-gray-400">
|
||||
⚔️ Regicide - 击败所有12个敌人
|
||||
</div>
|
||||
|
||||
{/* 反击阶段信息面板 */}
|
||||
{counterattackInfo.value.phase === 'enemyCounterattack' && (
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-sm text-gray-300">
|
||||
<span class="text-yellow-400 font-bold">💥 反击阶段</span>
|
||||
<span class="ml-2">需要: </span>
|
||||
<span class="text-red-400 font-bold">{counterattackInfo.value.requiredValue}</span>
|
||||
<span class="ml-2">| 手牌最多: </span>
|
||||
<span class={counterattackInfo.value.canWin ? 'text-green-400 font-bold' : 'text-red-400 font-bold'}>
|
||||
{counterattackInfo.value.maxHandValue}
|
||||
</span>
|
||||
</div>
|
||||
{counterattackInfo.value.selectedCards.length > 0 && (
|
||||
<div class="text-sm text-gray-300">
|
||||
<span>已选: </span>
|
||||
<span class="text-blue-400 font-bold">{counterattackInfo.value.selectedCards.length}张</span>
|
||||
<span class="ml-2">点数: </span>
|
||||
<span class={counterattackInfo.value.canSubmit ? 'text-green-400 font-bold' : 'text-orange-400 font-bold'}>
|
||||
{counterattackInfo.value.totalValue}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{counterattackInfo.value.error && (
|
||||
<div class="text-sm text-red-400">
|
||||
❌ {counterattackInfo.value.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex gap-2">
|
||||
{/* 反击确认按钮 */}
|
||||
{counterattackInfo.value.phase === 'enemyCounterattack' && counterattackInfo.value.selectedCards.length > 0 && (
|
||||
<button
|
||||
onClick={handleSubmitCounterattack}
|
||||
disabled={!counterattackInfo.value.canSubmit}
|
||||
class={`px-4 py-2 rounded font-medium transition-colors ${
|
||||
counterattackInfo.value.canSubmit
|
||||
? 'bg-green-600 text-white hover:bg-green-700'
|
||||
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
✓ 确认出牌
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 认输按钮 */}
|
||||
{counterattackInfo.value.phase === 'enemyCounterattack' && !counterattackInfo.value.canWin && (
|
||||
<button
|
||||
onClick={handleSurrender}
|
||||
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors font-medium"
|
||||
>
|
||||
🏳️ 认输
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleReset}
|
||||
class="px-6 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"noEmit": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import preact from '@preact/preset-vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [preact(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
'boardgame-phaser': resolve(__dirname, '../framework/src/index.ts'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,139 +1,19 @@
|
||||
import {
|
||||
createGameCommandRegistry, Part, createRegion,
|
||||
IGameContext, createRegionAxis, GameModule
|
||||
} from 'boardgame-core';
|
||||
|
||||
const BOARD_SIZE = 3;
|
||||
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
|
||||
const WINNING_LINES: number[][][] = [
|
||||
[[0, 0], [0, 1], [0, 2]],
|
||||
[[1, 0], [1, 1], [1, 2]],
|
||||
[[2, 0], [2, 1], [2, 2]],
|
||||
[[0, 0], [1, 0], [2, 0]],
|
||||
[[0, 1], [1, 1], [2, 1]],
|
||||
[[0, 2], [1, 2], [2, 2]],
|
||||
[[0, 0], [1, 1], [2, 2]],
|
||||
[[0, 2], [1, 1], [2, 0]],
|
||||
];
|
||||
|
||||
export type PlayerType = 'X' | 'O';
|
||||
export type WinnerType = PlayerType | 'draw' | null;
|
||||
export type TicTacToePart = Part<{ player: PlayerType }>;
|
||||
export type TicTacToeState = ReturnType<typeof createInitialState>;
|
||||
export type TicTacToeGame = IGameContext<TicTacToeState>;
|
||||
|
||||
export function createInitialState() {
|
||||
return {
|
||||
board: createRegion('board', [
|
||||
createRegionAxis('x', 0, BOARD_SIZE - 1),
|
||||
createRegionAxis('y', 0, BOARD_SIZE - 1),
|
||||
]),
|
||||
parts: {} as Record<string, TicTacToePart>,
|
||||
currentPlayer: 'X' as PlayerType,
|
||||
winner: null as WinnerType,
|
||||
turn: 0,
|
||||
};
|
||||
}
|
||||
export const registry = createGameCommandRegistry<TicTacToeState>();
|
||||
|
||||
export async function start(game: TicTacToeGame) {
|
||||
while (true) {
|
||||
const currentPlayer = game.value.currentPlayer;
|
||||
const turnNumber = game.value.turn + 1;
|
||||
const turnOutput = await turn(game, currentPlayer, turnNumber);
|
||||
|
||||
game.produce((state: TicTacToeState) => {
|
||||
state.winner = turnOutput.winner;
|
||||
if (!state.winner) {
|
||||
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
|
||||
state.turn = turnNumber;
|
||||
}
|
||||
});
|
||||
if (game.value.winner) break;
|
||||
}
|
||||
|
||||
return game.value;
|
||||
}
|
||||
|
||||
async function handleTurn(game: TicTacToeGame, turnPlayer: PlayerType, turnNumber: number) {
|
||||
const {player, row, col} = await game.prompt(
|
||||
'play <player> <row:number> <col:number>',
|
||||
(player: PlayerType, row: number, col: number) => {
|
||||
if (player !== turnPlayer) {
|
||||
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
||||
} else if (!isValidMove(row, col)) {
|
||||
throw `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
|
||||
} else if (isCellOccupied(game, row, col)) {
|
||||
throw `Cell (${row}, ${col}) is already occupied.`;
|
||||
} else {
|
||||
return { player, row, col };
|
||||
}
|
||||
},
|
||||
game.value.currentPlayer
|
||||
);
|
||||
|
||||
placePiece(game, row, col, turnPlayer);
|
||||
|
||||
const winner = checkWinner(game);
|
||||
if (winner) return { winner };
|
||||
if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType };
|
||||
|
||||
return { winner: null };
|
||||
}
|
||||
const turn = registry.register('turn <player:string> <turnNumber:int>', handleTurn);
|
||||
|
||||
function isValidMove(row: number, col: number): boolean {
|
||||
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
|
||||
}
|
||||
|
||||
export function isCellOccupied(host: TicTacToeGame, row: number, col: number): boolean {
|
||||
return host.value.board.partMap[`${row},${col}`] !== undefined;
|
||||
}
|
||||
|
||||
export function hasWinningLine(positions: number[][]): boolean {
|
||||
return WINNING_LINES.some(line =>
|
||||
line.every(([r, c]) =>
|
||||
positions.some(([pr, pc]) => pr === r && pc === c)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function checkWinner(host: TicTacToeGame): WinnerType {
|
||||
const parts = host.value.parts;
|
||||
const partsArray = Object.values(parts);
|
||||
|
||||
const xPositions = partsArray.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position);
|
||||
const oPositions = partsArray.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position);
|
||||
|
||||
if (hasWinningLine(xPositions)) return 'X';
|
||||
if (hasWinningLine(oPositions)) return 'O';
|
||||
if (partsArray.length >= MAX_TURNS) return 'draw';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function placePiece(host: TicTacToeGame, row: number, col: number, player: PlayerType) {
|
||||
const board = host.value.board;
|
||||
const moveNumber = Object.keys(host.value.parts).length + 1;
|
||||
const piece = {
|
||||
regionId: 'board', position: [row, col], player,
|
||||
id: `piece-${player}-${moveNumber}`
|
||||
};
|
||||
host.produce((state: TicTacToeState) => {
|
||||
state.parts[piece.id] = piece;
|
||||
board.childIds.push(piece.id);
|
||||
board.partMap[`${row},${col}`] = piece.id;
|
||||
});
|
||||
}
|
||||
|
||||
export const gameModule: GameModule<TicTacToeState> = {
|
||||
registry,
|
||||
createInitialState,
|
||||
start
|
||||
};
|
||||
|
||||
export const prompts = {
|
||||
play: (player: PlayerType, row: number, col: number) => {
|
||||
return `play ${player} ${row} ${col}`;
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Re-export tic-tac-toe game module from boardgame-core
|
||||
* This provides a convenient import path within sample-game
|
||||
*/
|
||||
export {
|
||||
registry,
|
||||
prompts,
|
||||
start,
|
||||
createInitialState,
|
||||
isCellOccupied,
|
||||
hasWinningLine,
|
||||
checkWinner,
|
||||
placePiece,
|
||||
type TicTacToePart,
|
||||
type TicTacToeState,
|
||||
type TicTacToeGame,
|
||||
type PlayerType,
|
||||
type WinnerType,
|
||||
} from "boardgame-core/samples/tic-tac-toe";
|
||||
@@ -1,13 +1,11 @@
|
||||
import { h } from 'preact';
|
||||
import { GameUI } from 'boardgame-phaser';
|
||||
import { gameModule } from './game/tic-tac-toe';
|
||||
import './style.css';
|
||||
import App from "@/ui/App";
|
||||
import {GameScene} from "@/scenes/GameScene";
|
||||
|
||||
const ui = new GameUI({
|
||||
container: document.getElementById('ui-root')!,
|
||||
root: <App gameModule={gameModule} gameScene={GameScene}/>,
|
||||
root: <App/>,
|
||||
});
|
||||
|
||||
ui.mount();
|
||||
|
||||
@@ -4,15 +4,39 @@ import { GameHostScene } from 'boardgame-phaser';
|
||||
import { spawnEffect, type Spawner } from 'boardgame-phaser';
|
||||
import {prompts} from "@/game/tic-tac-toe";
|
||||
|
||||
const CELL_SIZE = 120;
|
||||
const BOARD_OFFSET = { x: 100, y: 100 };
|
||||
const BOARD_SIZE = 3;
|
||||
// 棋盘配置常量
|
||||
export const BOARD_CONFIG = {
|
||||
cellSize: 120,
|
||||
boardOffset: { x: 100, y: 100 },
|
||||
boardSize: 3,
|
||||
colors: {
|
||||
grid: 0x6b7280,
|
||||
x: '#3b82f6',
|
||||
o: '#ef4444',
|
||||
title: '#1f2937',
|
||||
turn: '#4b5563',
|
||||
menuButton: 0x6b7280,
|
||||
menuButtonHover: 0x4b5563,
|
||||
overlay: 0x000000,
|
||||
winText: '#fbbf24',
|
||||
},
|
||||
fontSize: {
|
||||
title: '28px',
|
||||
turn: '20px',
|
||||
cell: '64px',
|
||||
menuButton: '18px',
|
||||
winText: '36px',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export class GameScene extends GameHostScene<TicTacToeState> {
|
||||
private boardContainer!: Phaser.GameObjects.Container;
|
||||
private gridGraphics!: Phaser.GameObjects.Graphics;
|
||||
private turnText!: Phaser.GameObjects.Text;
|
||||
private winnerOverlay?: Phaser.GameObjects.Container;
|
||||
private menuButtonContainer!: Phaser.GameObjects.Container;
|
||||
private menuButtonBg!: Phaser.GameObjects.Rectangle;
|
||||
private menuButtonText!: Phaser.GameObjects.Text;
|
||||
|
||||
constructor() {
|
||||
super('GameScene');
|
||||
@@ -23,7 +47,10 @@ export class GameScene extends GameHostScene<TicTacToeState> {
|
||||
|
||||
this.boardContainer = this.add.container(0, 0);
|
||||
this.gridGraphics = this.add.graphics();
|
||||
this.drawGrid();
|
||||
|
||||
this.createBoardVisuals();
|
||||
this.createMenuButton();
|
||||
this.createInputZones();
|
||||
|
||||
this.disposables.add(spawnEffect(new TicTacToePartSpawner(this)));
|
||||
|
||||
@@ -42,77 +69,136 @@ export class GameScene extends GameHostScene<TicTacToeState> {
|
||||
this.updateTurnText(currentPlayer);
|
||||
});
|
||||
|
||||
this.setupInput();
|
||||
this.gameHost.start();
|
||||
}
|
||||
|
||||
private isCellOccupied(row: number, col: number): boolean {
|
||||
return !!this.state.board.partMap[`${row},${col}`];
|
||||
/** 创建棋盘视觉元素(网格、标题、回合提示) */
|
||||
private createBoardVisuals(): void {
|
||||
this.drawGrid();
|
||||
|
||||
const { boardSize, cellSize, boardOffset } = BOARD_CONFIG;
|
||||
const centerX = boardOffset.x + (boardSize * cellSize) / 2;
|
||||
|
||||
this.add.text(centerX, boardOffset.y - 40, 'Tic-Tac-Toe', {
|
||||
fontSize: BOARD_CONFIG.fontSize.title,
|
||||
fontFamily: 'Arial',
|
||||
color: BOARD_CONFIG.colors.title,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.turnText = this.add.text(
|
||||
centerX,
|
||||
boardOffset.y + boardSize * cellSize + 20,
|
||||
'',
|
||||
{
|
||||
fontSize: BOARD_CONFIG.fontSize.turn,
|
||||
fontFamily: 'Arial',
|
||||
color: BOARD_CONFIG.colors.turn,
|
||||
}
|
||||
).setOrigin(0.5);
|
||||
|
||||
this.updateTurnText(this.state.currentPlayer);
|
||||
}
|
||||
|
||||
private setupInput(): void {
|
||||
for (let row = 0; row < BOARD_SIZE; row++) {
|
||||
for (let col = 0; col < BOARD_SIZE; col++) {
|
||||
const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2;
|
||||
const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2;
|
||||
/** 绘制棋盘网格 */
|
||||
private drawGrid(): void {
|
||||
const g = this.gridGraphics;
|
||||
const { boardSize, cellSize, boardOffset } = BOARD_CONFIG;
|
||||
|
||||
const zone = this.add.zone(x, y, CELL_SIZE, CELL_SIZE).setInteractive();
|
||||
g.lineStyle(3, BOARD_CONFIG.colors.grid);
|
||||
|
||||
for (let i = 1; i < boardSize; i++) {
|
||||
g.lineBetween(
|
||||
boardOffset.x + i * cellSize,
|
||||
boardOffset.y,
|
||||
boardOffset.x + i * cellSize,
|
||||
boardOffset.y + boardSize * cellSize,
|
||||
);
|
||||
g.lineBetween(
|
||||
boardOffset.x,
|
||||
boardOffset.y + i * cellSize,
|
||||
boardOffset.x + boardSize * cellSize,
|
||||
boardOffset.y + i * cellSize,
|
||||
);
|
||||
}
|
||||
|
||||
g.strokePath();
|
||||
}
|
||||
|
||||
/** 创建菜单按钮 */
|
||||
private createMenuButton(): void {
|
||||
const buttonX = this.game.scale.width - 80;
|
||||
const buttonY = 30;
|
||||
|
||||
this.menuButtonBg = this.add.rectangle(buttonX, buttonY, 120, 40, BOARD_CONFIG.colors.menuButton)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
|
||||
this.menuButtonText = this.add.text(buttonX, buttonY, 'Menu', {
|
||||
fontSize: BOARD_CONFIG.fontSize.menuButton,
|
||||
fontFamily: 'Arial',
|
||||
color: '#ffffff',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.menuButtonContainer = this.add.container(buttonX, buttonY, [
|
||||
this.menuButtonBg,
|
||||
this.menuButtonText,
|
||||
]);
|
||||
|
||||
// 按钮交互
|
||||
this.menuButtonBg.on('pointerover', () => {
|
||||
this.menuButtonBg.setFillStyle(BOARD_CONFIG.colors.menuButtonHover);
|
||||
});
|
||||
|
||||
this.menuButtonBg.on('pointerout', () => {
|
||||
this.menuButtonBg.setFillStyle(BOARD_CONFIG.colors.menuButton);
|
||||
});
|
||||
|
||||
this.menuButtonBg.on('pointerdown', () => {
|
||||
this.goToMenu();
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建输入区域 */
|
||||
private createInputZones(): void {
|
||||
const { boardSize, cellSize, boardOffset } = BOARD_CONFIG;
|
||||
|
||||
for (let row = 0; row < boardSize; row++) {
|
||||
for (let col = 0; col < boardSize; col++) {
|
||||
const x = boardOffset.x + col * cellSize + cellSize / 2;
|
||||
const y = boardOffset.y + row * cellSize + cellSize / 2;
|
||||
|
||||
const zone = this.add.zone(x, y, cellSize, cellSize).setInteractive();
|
||||
|
||||
zone.on('pointerdown', () => {
|
||||
if (this.state.winner) return;
|
||||
if (this.isCellOccupied(row, col)) return;
|
||||
|
||||
const cmd = prompts.play(this.state.currentPlayer, row, col);
|
||||
const error = this.gameHost.onInput(cmd);
|
||||
if (error) {
|
||||
console.warn('Invalid move:', error);
|
||||
}
|
||||
this.handleCellClick(row, col);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawGrid(): void {
|
||||
const g = this.gridGraphics;
|
||||
g.lineStyle(3, 0x6b7280);
|
||||
/** 处理格子点击 */
|
||||
private handleCellClick(row: number, col: number): void {
|
||||
if (this.state.winner) return;
|
||||
if (this.isCellOccupied(row, col)) return;
|
||||
|
||||
for (let i = 1; i < BOARD_SIZE; i++) {
|
||||
g.lineBetween(
|
||||
BOARD_OFFSET.x + i * CELL_SIZE,
|
||||
BOARD_OFFSET.y,
|
||||
BOARD_OFFSET.x + i * CELL_SIZE,
|
||||
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE,
|
||||
);
|
||||
g.lineBetween(
|
||||
BOARD_OFFSET.x,
|
||||
BOARD_OFFSET.y + i * CELL_SIZE,
|
||||
BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE,
|
||||
BOARD_OFFSET.y + i * CELL_SIZE,
|
||||
);
|
||||
const error = this.gameHost.tryAnswerPrompt(prompts.play, this.state.currentPlayer, row, col);
|
||||
if (error) {
|
||||
console.warn('Invalid move:', error);
|
||||
}
|
||||
|
||||
g.strokePath();
|
||||
|
||||
this.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y - 40, 'Tic-Tac-Toe', {
|
||||
fontSize: '28px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#1f2937',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.turnText = this.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 20, '', {
|
||||
fontSize: '20px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#4b5563',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.updateTurnText(this.state.currentPlayer);
|
||||
}
|
||||
|
||||
/** 检查格子是否被占用 */
|
||||
private isCellOccupied(row: number, col: number): boolean {
|
||||
return !!this.state.board.partMap[`${row},${col}`];
|
||||
}
|
||||
|
||||
/** 更新回合提示文本 */
|
||||
private updateTurnText(player: string): void {
|
||||
if (this.turnText) {
|
||||
this.turnText.setText(`${player}'s turn`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 显示获胜者 */
|
||||
private showWinner(winner: string): void {
|
||||
// 清理旧的覆盖层防止叠加
|
||||
if (this.winnerOverlay) {
|
||||
@@ -122,13 +208,16 @@ export class GameScene extends GameHostScene<TicTacToeState> {
|
||||
this.winnerOverlay = this.add.container();
|
||||
|
||||
const text = winner === 'draw' ? "It's a draw!" : `${winner} wins!`;
|
||||
const { boardSize, cellSize, boardOffset } = BOARD_CONFIG;
|
||||
const centerX = boardOffset.x + (boardSize * cellSize) / 2;
|
||||
const centerY = boardOffset.y + (boardSize * cellSize) / 2;
|
||||
|
||||
const bg = this.add.rectangle(
|
||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
BOARD_SIZE * CELL_SIZE,
|
||||
BOARD_SIZE * CELL_SIZE,
|
||||
0x000000,
|
||||
centerX,
|
||||
centerY,
|
||||
boardSize * cellSize,
|
||||
boardSize * cellSize,
|
||||
BOARD_CONFIG.colors.overlay,
|
||||
0.6,
|
||||
).setInteractive({ useHandCursor: true });
|
||||
|
||||
@@ -138,16 +227,11 @@ export class GameScene extends GameHostScene<TicTacToeState> {
|
||||
|
||||
this.winnerOverlay.add(bg);
|
||||
|
||||
const winText = this.add.text(
|
||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
text,
|
||||
{
|
||||
fontSize: '36px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#fbbf24',
|
||||
},
|
||||
).setOrigin(0.5);
|
||||
const winText = this.add.text(centerX, centerY, text, {
|
||||
fontSize: BOARD_CONFIG.fontSize.winText,
|
||||
fontFamily: 'Arial',
|
||||
color: BOARD_CONFIG.colors.winText,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.winnerOverlay.add(winText);
|
||||
|
||||
@@ -159,8 +243,14 @@ export class GameScene extends GameHostScene<TicTacToeState> {
|
||||
repeat: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/** 跳转到菜单场景 */
|
||||
private async goToMenu(): Promise<void> {
|
||||
await this.sceneController.launch('MenuScene');
|
||||
}
|
||||
}
|
||||
|
||||
/** 棋子生成器 */
|
||||
class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.Text> {
|
||||
constructor(public readonly scene: GameScene) {}
|
||||
|
||||
@@ -169,24 +259,23 @@ class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.
|
||||
yield part;
|
||||
}
|
||||
}
|
||||
|
||||
getKey(part: TicTacToePart): string {
|
||||
return part.id;
|
||||
}
|
||||
|
||||
onUpdate(part: TicTacToePart, obj: Phaser.GameObjects.Text): void {
|
||||
const [yIndex, xIndex] = part.position;
|
||||
const x = xIndex * CELL_SIZE + BOARD_OFFSET.x;
|
||||
const y = yIndex * CELL_SIZE + BOARD_OFFSET.y;
|
||||
obj.x = x + CELL_SIZE / 2;
|
||||
obj.y = y + CELL_SIZE / 2;
|
||||
this.updatePosition(part, obj);
|
||||
}
|
||||
|
||||
onSpawn(part: TicTacToePart) {
|
||||
const [yIndex, xIndex] = part.position;
|
||||
const x = xIndex * CELL_SIZE + BOARD_OFFSET.x;
|
||||
const y = yIndex * CELL_SIZE + BOARD_OFFSET.y;
|
||||
const text = this.scene.add.text(x + CELL_SIZE / 2, y + CELL_SIZE / 2, part.player, {
|
||||
fontSize: '64px',
|
||||
const { cellSize, boardOffset } = BOARD_CONFIG;
|
||||
const pos = this.calculatePosition(part.position);
|
||||
|
||||
const text = this.scene.add.text(pos.x, pos.y, part.player, {
|
||||
fontSize: BOARD_CONFIG.fontSize.cell,
|
||||
fontFamily: 'Arial',
|
||||
color: part.player === 'X' ? '#3b82f6' : '#ef4444',
|
||||
color: part.player === 'X' ? BOARD_CONFIG.colors.x : BOARD_CONFIG.colors.o,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// 添加落子动画
|
||||
@@ -210,4 +299,21 @@ class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.
|
||||
onComplete: () => obj.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
/** 计算格子的屏幕位置 */
|
||||
private calculatePosition(position: number[]): { x: number; y: number } {
|
||||
const { cellSize, boardOffset } = BOARD_CONFIG;
|
||||
const [yIndex, xIndex] = position;
|
||||
return {
|
||||
x: xIndex * cellSize + boardOffset.x + cellSize / 2,
|
||||
y: yIndex * cellSize + boardOffset.y + cellSize / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/** 更新对象位置 */
|
||||
private updatePosition(part: TicTacToePart, obj: Phaser.GameObjects.Text): void {
|
||||
const pos = this.calculatePosition(part.position);
|
||||
obj.x = pos.x;
|
||||
obj.y = pos.y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ReactiveScene } from 'boardgame-phaser';
|
||||
|
||||
/** 菜单场景配置 */
|
||||
const MENU_CONFIG = {
|
||||
colors: {
|
||||
title: '#1f2937',
|
||||
buttonText: '#ffffff',
|
||||
buttonBg: 0x3b82f6,
|
||||
buttonBgHover: 0x2563eb,
|
||||
subtitle: '#6b7280',
|
||||
},
|
||||
fontSize: {
|
||||
title: '48px',
|
||||
button: '24px',
|
||||
subtitle: '16px',
|
||||
},
|
||||
button: {
|
||||
width: 200,
|
||||
height: 60,
|
||||
},
|
||||
positions: {
|
||||
titleY: -100,
|
||||
buttonY: 40,
|
||||
subtitleY: 140,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export class MenuScene extends ReactiveScene {
|
||||
private titleText!: Phaser.GameObjects.Text;
|
||||
private startButtonContainer!: Phaser.GameObjects.Container;
|
||||
private startButtonBg!: Phaser.GameObjects.Rectangle;
|
||||
private startButtonText!: Phaser.GameObjects.Text;
|
||||
|
||||
constructor() {
|
||||
super('MenuScene');
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
|
||||
const center = this.getCenterPosition();
|
||||
|
||||
this.createTitle(center);
|
||||
this.createStartButton(center);
|
||||
this.createSubtitle(center);
|
||||
}
|
||||
|
||||
/** 获取屏幕中心位置 */
|
||||
private getCenterPosition(): { x: number; y: number } {
|
||||
return {
|
||||
x: this.game.scale.width / 2,
|
||||
y: this.game.scale.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建标题文本 */
|
||||
private createTitle(center: { x: number; y: number }): void {
|
||||
this.titleText = this.add.text(
|
||||
center.x,
|
||||
center.y + MENU_CONFIG.positions.titleY,
|
||||
'Tic-Tac-Toe',
|
||||
{
|
||||
fontSize: MENU_CONFIG.fontSize.title,
|
||||
fontFamily: 'Arial',
|
||||
color: MENU_CONFIG.colors.title,
|
||||
}
|
||||
).setOrigin(0.5);
|
||||
|
||||
// 标题入场动画
|
||||
this.titleText.setScale(0);
|
||||
this.tweens.add({
|
||||
targets: this.titleText,
|
||||
scale: 1,
|
||||
duration: 600,
|
||||
ease: 'Back.easeOut',
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建开始按钮 */
|
||||
private createStartButton(center: { x: number; y: number }): void {
|
||||
const { button, colors } = MENU_CONFIG;
|
||||
|
||||
this.startButtonBg = this.add.rectangle(
|
||||
-button.width/2,
|
||||
-button.height/2,
|
||||
button.width,
|
||||
button.height,
|
||||
colors.buttonBg
|
||||
).setOrigin(0.5).setInteractive({ useHandCursor: true });
|
||||
|
||||
this.startButtonText = this.add.text(
|
||||
-button.width/2,
|
||||
-button.height/2,
|
||||
'Start Game',
|
||||
{
|
||||
fontSize: MENU_CONFIG.fontSize.button,
|
||||
fontFamily: 'Arial',
|
||||
color: colors.buttonText,
|
||||
}
|
||||
).setOrigin(0.5);
|
||||
|
||||
this.startButtonContainer = this.add.container(
|
||||
center.x,
|
||||
center.y + MENU_CONFIG.positions.buttonY,
|
||||
[this.startButtonBg, this.startButtonText]
|
||||
);
|
||||
|
||||
// 按钮交互
|
||||
this.setupButtonInteraction();
|
||||
}
|
||||
|
||||
/** 设置按钮交互效果 */
|
||||
private setupButtonInteraction(): void {
|
||||
this.startButtonBg.on('pointerover', () => {
|
||||
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBgHover);
|
||||
this.tweens.add({
|
||||
targets: this.startButtonContainer,
|
||||
scale: 1.05,
|
||||
duration: 100,
|
||||
});
|
||||
});
|
||||
|
||||
this.startButtonBg.on('pointerout', () => {
|
||||
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBg);
|
||||
this.tweens.add({
|
||||
targets: this.startButtonContainer,
|
||||
scale: 1,
|
||||
duration: 100,
|
||||
});
|
||||
});
|
||||
|
||||
this.startButtonBg.on('pointerdown', () => {
|
||||
this.startGame();
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建副标题 */
|
||||
private createSubtitle(center: { x: number; y: number }): void {
|
||||
this.add.text(
|
||||
center.x,
|
||||
center.y + MENU_CONFIG.positions.subtitleY,
|
||||
'Click to start playing',
|
||||
{
|
||||
fontSize: MENU_CONFIG.fontSize.subtitle,
|
||||
fontFamily: 'Arial',
|
||||
color: MENU_CONFIG.colors.subtitle,
|
||||
}
|
||||
).setOrigin(0.5);
|
||||
}
|
||||
|
||||
/** 开始游戏 */
|
||||
private async startGame(): Promise<void> {
|
||||
await this.sceneController.launch('GameScene');
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,22 @@
|
||||
import {useComputed} from '@preact/signals';
|
||||
import { createGameHost, type GameModule } from 'boardgame-core';
|
||||
import Phaser from 'phaser';
|
||||
import { h } from 'preact';
|
||||
import { PhaserGame, PhaserScene } from 'boardgame-phaser';
|
||||
import { h } from 'preact';
|
||||
import {PhaserGame, PhaserScene } from 'boardgame-phaser';
|
||||
import {MenuScene} from "@/scenes/MenuScene";
|
||||
import {useMemo} from "preact/hooks";
|
||||
import * as gameModule from '../game/tic-tac-toe';
|
||||
import {GameScene} from "@/scenes/GameScene";
|
||||
import {createGameHost} from "boardgame-core";
|
||||
|
||||
export default function App<TState extends Record<string, unknown>>(props: { gameModule: GameModule<TState>, gameScene: { new(): Phaser.Scene } }) {
|
||||
|
||||
const gameHost = useComputed(() => {
|
||||
const gameHost = createGameHost(props.gameModule);
|
||||
return { gameHost };
|
||||
});
|
||||
|
||||
const scene = useComputed(() => new props.gameScene());
|
||||
|
||||
const handleReset = () => {
|
||||
gameHost.value.gameHost.start();
|
||||
};
|
||||
const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');
|
||||
export default function App() {
|
||||
const gameHost = useMemo(() => createGameHost(gameModule), []);
|
||||
const gameScene = useMemo(() => new GameScene(), []);
|
||||
const menuScene = useMemo(() => new MenuScene(), []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="p-4 bg-gray-100 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex relative justify-center items-center">
|
||||
<PhaserGame>
|
||||
<PhaserScene sceneKey="GameScene" scene={scene.value} autoStart data={gameHost.value} />
|
||||
<PhaserGame initialScene="MenuScene">
|
||||
<PhaserScene sceneKey="MenuScene" scene={menuScene} />
|
||||
<PhaserScene sceneKey="GameScene" scene={gameScene} data={{gameHost}}/>
|
||||
</PhaserGame>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'boardgame-phaser': path.resolve(__dirname, '../framework/src/index.ts'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>STS-Like Viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ui-root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "sts-like-viewer",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@preact/signals-core": "^1.5.1",
|
||||
"boardgame-core": "link:../../../boardgame-core",
|
||||
"boardgame-phaser": "workspace:*",
|
||||
"mutative": "^1.3.0",
|
||||
"phaser": "^3.80.1",
|
||||
"preact": "^10.19.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.8.1",
|
||||
"@preact/signals": "^2.9.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Centralized layout and style constants for sts-like-viewer.
|
||||
* All magic numbers should be defined here and imported where needed.
|
||||
*/
|
||||
|
||||
import { PhaserGameProps } from "boardgame-phaser";
|
||||
|
||||
// ── Map Layout ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const MAP_CONFIG = {
|
||||
/** Horizontal spacing between map layers (left-to-right) */
|
||||
LAYER_HEIGHT: 110,
|
||||
/** Vertical spacing between nodes within a layer */
|
||||
NODE_SPACING: 140,
|
||||
/** Radius of each map node circle */
|
||||
NODE_RADIUS: 28,
|
||||
/** Total number of layers in the point-crawl map */
|
||||
TOTAL_LAYERS: 10,
|
||||
/** Maximum nodes in any single layer */
|
||||
MAX_NODES_PER_LAYER: 5,
|
||||
} as const;
|
||||
|
||||
// ── Grid Inventory ──────────────────────────────────────────────────────────
|
||||
|
||||
export const GRID_CONFIG = {
|
||||
/** Default grid width (columns) */
|
||||
WIDTH: 6,
|
||||
/** Default grid height (rows) */
|
||||
HEIGHT: 4,
|
||||
/** Cell size for the standalone grid viewer */
|
||||
VIEWER_CELL_SIZE: 60,
|
||||
/** Cell size for the encounter inventory widget */
|
||||
WIDGET_CELL_SIZE: 80,
|
||||
/** Gap between grid cells (pixels) */
|
||||
GRID_GAP: 2,
|
||||
/** Empty cell background color */
|
||||
CELL_EMPTY_COLOR: 0x222233,
|
||||
/** Occupied cell background color */
|
||||
CELL_OCCUPIED_COLOR: 0x334455,
|
||||
/** Grid line color */
|
||||
GRID_LINE_COLOR: 0x555577,
|
||||
/** Title text style */
|
||||
TITLE_STYLE: {
|
||||
fontSize: "24px",
|
||||
color: "#888",
|
||||
fontStyle: "bold",
|
||||
} as const,
|
||||
/** Subtitle/hint text style */
|
||||
SUBTITLE_STYLE: {
|
||||
fontSize: "14px",
|
||||
color: "#aaaaaa",
|
||||
} as const,
|
||||
/** Item name text style */
|
||||
ITEM_NAME_STYLE: {
|
||||
fontSize: "11px",
|
||||
color: "#888",
|
||||
fontStyle: "bold",
|
||||
} as const,
|
||||
} as const;
|
||||
|
||||
// ── Shape Viewer ────────────────────────────────────────────────────────────
|
||||
|
||||
export const SHAPE_CONFIG = {
|
||||
/** Cell size for rendering item shapes */
|
||||
CELL_SIZE: 40,
|
||||
/** Number of items per row in the shape viewer grid */
|
||||
ITEMS_PER_ROW: 4,
|
||||
/** Maximum items to display at once */
|
||||
MAX_ITEMS: 12,
|
||||
/** Horizontal spacing between shape previews */
|
||||
SPACING_X: 220,
|
||||
/** Vertical spacing between shape previews */
|
||||
SPACING_Y: 140,
|
||||
} as const;
|
||||
|
||||
// ── Combat Unit Widget ──────────────────────────────────────────────────────
|
||||
|
||||
export const COMBAT_WIDGET_CONFIG = {
|
||||
/** Default widget width */
|
||||
WIDTH: 240,
|
||||
/** Default widget height */
|
||||
HEIGHT: 120,
|
||||
/** HP bar width */
|
||||
HP_BAR_WIDTH: 180,
|
||||
/** HP bar height */
|
||||
HP_BAR_HEIGHT: 16,
|
||||
/** Buff/debuff icon size */
|
||||
BUFF_ICON_SIZE: 28,
|
||||
/** Gap between buff icons */
|
||||
BUFF_ICON_GAP: 6,
|
||||
} as const;
|
||||
|
||||
// ── UI / Button ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const UI_CONFIG = {
|
||||
/** Standard button width */
|
||||
BUTTON_WIDTH: 120,
|
||||
/** Standard button height */
|
||||
BUTTON_HEIGHT: 36,
|
||||
/** Large button width */
|
||||
BUTTON_WIDTH_LARGE: 220,
|
||||
/** Large button height */
|
||||
BUTTON_HEIGHT_LARGE: 50,
|
||||
/** Button background color */
|
||||
BUTTON_BG: 0x444466,
|
||||
/** Button hover background color */
|
||||
BUTTON_BG_HOVER: 0x555588,
|
||||
/** Button border color */
|
||||
BUTTON_BORDER: 0x7777aa,
|
||||
/** Button text color */
|
||||
BUTTON_TEXT_COLOR: "#ffffff",
|
||||
/** Button font size */
|
||||
BUTTON_FONT_SIZE: "16px",
|
||||
} as const;
|
||||
|
||||
// ── Colors ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const NODE_COLORS = {
|
||||
start: 0x44aa44,
|
||||
end: 0xcc8844,
|
||||
minion: 0xcc4444,
|
||||
elite: 0xcc44cc,
|
||||
event: 0xaaaa44,
|
||||
camp: 0x44cccc,
|
||||
shop: 0x4488cc,
|
||||
curio: 0x8844cc,
|
||||
} as const;
|
||||
|
||||
export const NODE_LABELS = {
|
||||
start: "起点",
|
||||
end: "终点",
|
||||
minion: "战斗",
|
||||
elite: "精英",
|
||||
event: "事件",
|
||||
camp: "营地",
|
||||
shop: "商店",
|
||||
curio: "奇遇",
|
||||
} as const;
|
||||
|
||||
export const ITEM_COLORS = [
|
||||
0x4488cc, 0xcc8844, 0x44cc88, 0xcc4488, 0x8844cc, 0x44cccc,
|
||||
] as const;
|
||||
|
||||
// ── Positive/Negative Effects (for buff icons) ──────────────────────────────
|
||||
|
||||
export const POSITIVE_EFFECTS = new Set([
|
||||
"block",
|
||||
"strength",
|
||||
"dexterity",
|
||||
"regen",
|
||||
]);
|
||||
export const NEGATIVE_EFFECTS = new Set([
|
||||
"weak",
|
||||
"vulnerable",
|
||||
"frail",
|
||||
"poison",
|
||||
]);
|
||||
|
||||
export const CARD_CONFIG = {
|
||||
WIDTH: 140,
|
||||
HEIGHT: 200,
|
||||
CORNER_RADIUS: 8,
|
||||
} as const;
|
||||
|
||||
export const GAME_CONFIG: Phaser.Types.Core.GameConfig = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import Phaser from "phaser";
|
||||
|
||||
export type BuffData = {
|
||||
id: string;
|
||||
stacks: number;
|
||||
emoji: string;
|
||||
isPositive: boolean;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const ICON_SIZE = 28;
|
||||
const ICON_COLOR_POSITIVE = 0x44aa44;
|
||||
const ICON_COLOR_NEGATIVE = 0xaa4444;
|
||||
const TOOLTIP_WIDTH = 200;
|
||||
const TOOLTIP_PADDING = 8;
|
||||
|
||||
export class Buff extends Phaser.GameObjects.Container {
|
||||
private bg!: Phaser.GameObjects.Rectangle;
|
||||
private label!: Phaser.GameObjects.Text;
|
||||
private tooltipContainer!: Phaser.GameObjects.Container;
|
||||
private tooltipBg!: Phaser.GameObjects.Rectangle;
|
||||
private tooltipText!: Phaser.GameObjects.Text;
|
||||
|
||||
private currentData!: BuffData;
|
||||
|
||||
constructor(scene: Phaser.Scene, x: number, y: number, data: BuffData) {
|
||||
super(scene, x, y);
|
||||
scene.add.existing(this);
|
||||
|
||||
this.currentData = data;
|
||||
this.createVisuals();
|
||||
this.createTooltip();
|
||||
this.setupInteraction();
|
||||
this.updateFromData(data);
|
||||
}
|
||||
|
||||
private createVisuals(): void {
|
||||
const color = this.currentData.isPositive
|
||||
? ICON_COLOR_POSITIVE
|
||||
: ICON_COLOR_NEGATIVE;
|
||||
|
||||
this.bg = this.scene.add
|
||||
.rectangle(0, 0, ICON_SIZE, ICON_SIZE, color)
|
||||
.setStrokeStyle(1, 0xffffff);
|
||||
|
||||
this.label = this.scene.add
|
||||
.text(0, 0, "", {
|
||||
fontSize: "12px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.add([this.bg, this.label]);
|
||||
}
|
||||
|
||||
private createTooltip(): void {
|
||||
this.tooltipContainer = this.scene.add.container(0, 0).setVisible(false);
|
||||
|
||||
this.tooltipBg = this.scene.add
|
||||
.rectangle(0, 0, 0, 0, 0x000000, 0.9)
|
||||
.setStrokeStyle(1, 0xffffff);
|
||||
|
||||
this.tooltipText = this.scene.add
|
||||
.text(0, 0, "", {
|
||||
fontSize: "12px",
|
||||
color: "#ffffff",
|
||||
align: "center",
|
||||
wordWrap: { width: TOOLTIP_WIDTH - TOOLTIP_PADDING * 2 },
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.tooltipContainer.add([this.tooltipBg, this.tooltipText]);
|
||||
this.tooltipContainer.setDepth(1000);
|
||||
}
|
||||
|
||||
private setupInteraction(): void {
|
||||
this.setInteractive(
|
||||
new Phaser.Geom.Rectangle(
|
||||
-ICON_SIZE / 2,
|
||||
-ICON_SIZE / 2,
|
||||
ICON_SIZE,
|
||||
ICON_SIZE,
|
||||
),
|
||||
Phaser.Geom.Rectangle.Contains,
|
||||
);
|
||||
|
||||
this.on("pointerover", this.onPointerOver, this);
|
||||
this.on("pointerout", this.onPointerOut, this);
|
||||
}
|
||||
|
||||
private onPointerOver(): void {
|
||||
const desc = this.currentData.description ?? "";
|
||||
const content = desc;
|
||||
|
||||
this.tooltipText.setText(content);
|
||||
|
||||
const bounds = this.tooltipText.getBounds();
|
||||
const width = Math.min(bounds.width + TOOLTIP_PADDING * 2, TOOLTIP_WIDTH);
|
||||
const height = bounds.height + TOOLTIP_PADDING * 2;
|
||||
|
||||
this.tooltipBg.setDisplaySize(width, height);
|
||||
|
||||
const worldPos = this.getBounds();
|
||||
this.tooltipContainer.setPosition(
|
||||
worldPos.x + worldPos.width / 2,
|
||||
worldPos.y - height / 2 - 4,
|
||||
);
|
||||
this.tooltipContainer.setDepth(1000);
|
||||
this.scene.children.bringToTop(this.tooltipContainer);
|
||||
this.tooltipContainer.setVisible(true);
|
||||
}
|
||||
|
||||
private onPointerOut(): void {
|
||||
this.tooltipContainer.setVisible(false);
|
||||
}
|
||||
|
||||
updateFromData(data: BuffData): void {
|
||||
this.currentData = data;
|
||||
this.label.setText(`${data.emoji}${data.stacks}`);
|
||||
|
||||
const color = data.isPositive ? ICON_COLOR_POSITIVE : ICON_COLOR_NEGATIVE;
|
||||
this.bg.setFillStyle(color);
|
||||
}
|
||||
|
||||
destroy(fromScene?: boolean): void {
|
||||
this.off("pointerover", this.onPointerOver, this);
|
||||
this.off("pointerout", this.onPointerOut, this);
|
||||
this.tooltipContainer.destroy(fromScene);
|
||||
super.destroy(fromScene);
|
||||
}
|
||||
|
||||
playSpawnTween() {
|
||||
this.setScale(0);
|
||||
this.scene.tweens.add({
|
||||
targets: this,
|
||||
scale: 1,
|
||||
duration: 200,
|
||||
ease: "Back.Out",
|
||||
});
|
||||
}
|
||||
|
||||
playDespawnTween() {
|
||||
this.scene.tweens.add({
|
||||
targets: this,
|
||||
scale: 0,
|
||||
duration: 200,
|
||||
ease: "Back.In",
|
||||
onComplete: () => this.destroy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { GameHostScene } from "boardgame-phaser";
|
||||
import { BuffData, Buff } from "./Buff";
|
||||
import { SpawnerCallback } from "boardgame-phaser";
|
||||
import { CombatState } from "boardgame-core/samples/slay-the-spire-like";
|
||||
|
||||
export type BuffDataWithIndex = BuffData & { x: number };
|
||||
export class BuffSpawner implements SpawnerCallback<BuffDataWithIndex, Buff> {
|
||||
constructor(
|
||||
public scene: GameHostScene<CombatState>,
|
||||
public buffContainer: Phaser.GameObjects.Container,
|
||||
) {}
|
||||
getKey(t: BuffDataWithIndex) {
|
||||
return t.id;
|
||||
}
|
||||
onSpawn(t: BuffDataWithIndex) {
|
||||
const buff = new Buff(this.scene, t.x, 0, t);
|
||||
this.buffContainer.add(buff);
|
||||
buff.playSpawnTween();
|
||||
this.scene.addInterruption(new Promise((r) => setTimeout(r, 50)));
|
||||
return buff;
|
||||
}
|
||||
onDespawn(obj: Buff) {
|
||||
this.scene.addInterruption(new Promise((r) => setTimeout(r, 50)));
|
||||
obj.playDespawnTween();
|
||||
}
|
||||
onUpdate(t: BuffDataWithIndex, obj: Buff) {
|
||||
obj.setPosition(t.x, 0);
|
||||
obj.updateFromData(t);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import Phaser, { Tweens } from "phaser";
|
||||
import type { GameCard } from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { CARD_CONFIG } from "@/config";
|
||||
import { hoverEffect } from "boardgame-phaser";
|
||||
|
||||
export interface CardContainerOptions {
|
||||
card: GameCard;
|
||||
onClick?: (cardId: string) => void;
|
||||
playable?: boolean;
|
||||
}
|
||||
|
||||
const CARD_WIDTH = CARD_CONFIG.WIDTH;
|
||||
const CARD_HEIGHT = CARD_CONFIG.HEIGHT;
|
||||
const CORNER_RADIUS = CARD_CONFIG.CORNER_RADIUS;
|
||||
|
||||
export class CardContainer extends Phaser.GameObjects.Container {
|
||||
private bg!: Phaser.GameObjects.Rectangle;
|
||||
private nameText!: Phaser.GameObjects.Text;
|
||||
private costBg!: Phaser.GameObjects.Container;
|
||||
private costText!: Phaser.GameObjects.Text;
|
||||
private descText!: Phaser.GameObjects.Text;
|
||||
private highlight!: Phaser.GameObjects.Rectangle | null;
|
||||
private cardId: string;
|
||||
private _selected = false;
|
||||
|
||||
constructor(
|
||||
scene: Phaser.Scene,
|
||||
x: number,
|
||||
y: number,
|
||||
options: CardContainerOptions,
|
||||
) {
|
||||
super(scene, x, y);
|
||||
scene.add.existing(this);
|
||||
|
||||
this.cardId = options.card.id;
|
||||
this.createVisuals(options);
|
||||
this.setupInteractive(options);
|
||||
}
|
||||
|
||||
private createVisuals(options: CardContainerOptions): void {
|
||||
const cardData = options.card.cardData;
|
||||
const isPlayable = options.playable ?? true;
|
||||
|
||||
// Background
|
||||
const bgColor = isPlayable ? 0x2a2a3a : 0x1a1a24;
|
||||
this.bg = this.scene.add
|
||||
.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, bgColor)
|
||||
.setStrokeStyle(2, isPlayable ? 0x555577 : 0x333344);
|
||||
this.add(this.bg);
|
||||
|
||||
// Name
|
||||
this.nameText = this.scene.add
|
||||
.text(0, -CARD_HEIGHT / 2 + 24, cardData.name, {
|
||||
fontSize: "13px",
|
||||
color: isPlayable ? "#ffffff" : "#666666",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add(this.nameText);
|
||||
|
||||
// Cost badge (top-right)
|
||||
this.costBg = this.scene.add.container(
|
||||
CARD_WIDTH / 2 - 18,
|
||||
-CARD_HEIGHT / 2 + 18,
|
||||
);
|
||||
const costCircle = this.scene.add.circle(0, 0, 14, 0x3b82f6);
|
||||
this.costText = this.scene.add
|
||||
.text(0, 0, `${cardData.costCount}`, {
|
||||
fontSize: "14px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.costBg.add([costCircle, this.costText]);
|
||||
this.add(this.costBg);
|
||||
|
||||
// Description
|
||||
this.descText = this.scene.add
|
||||
.text(0, 16, cardData.desc, {
|
||||
fontSize: "10px",
|
||||
color: isPlayable ? "#aaaaaa" : "#444444",
|
||||
align: "center",
|
||||
wordWrap: { width: CARD_WIDTH - 16 },
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add(this.descText);
|
||||
|
||||
// Target indicator
|
||||
const targetLabel = (() => {
|
||||
switch (cardData.targetType) {
|
||||
case "enemy":
|
||||
return "⚔️ Enemy";
|
||||
case "enemies":
|
||||
return "⚔️ Enemies";
|
||||
case "player":
|
||||
return "🛡️ Player";
|
||||
default:
|
||||
return "❓ Unknown";
|
||||
}
|
||||
})();
|
||||
const targetText = this.scene.add
|
||||
.text(0, CARD_HEIGHT / 2 - 20, targetLabel, {
|
||||
fontSize: "10px",
|
||||
color: isPlayable ? "#8888cc" : "#444466",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add(targetText);
|
||||
}
|
||||
|
||||
private setupInteractive(options: CardContainerOptions): void {
|
||||
const hitArea = new Phaser.Geom.Rectangle(
|
||||
-CARD_WIDTH / 2,
|
||||
-CARD_HEIGHT / 2,
|
||||
CARD_WIDTH,
|
||||
CARD_HEIGHT,
|
||||
);
|
||||
this.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
|
||||
if (this.input) {
|
||||
this.input.cursor = options.playable ? "pointer" : "not-allowed";
|
||||
}
|
||||
|
||||
hoverEffect(this, (hovering) => {
|
||||
if (!options.playable) return;
|
||||
this.playHoverTween(hovering);
|
||||
});
|
||||
|
||||
this.on("pointerdown", () => {
|
||||
if (options.playable && options.onClick) {
|
||||
options.onClick(this.cardId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setSelected(selected: boolean): void {
|
||||
this._selected = selected;
|
||||
if (selected) {
|
||||
if (!this.highlight) {
|
||||
this.highlight = this.scene.add
|
||||
.rectangle(0, 0, CARD_WIDTH + 10, CARD_HEIGHT + 10, 0x000000, 0)
|
||||
.setStrokeStyle(4, 0xfbbf24)
|
||||
.setDepth(-1);
|
||||
this.addAt(this.highlight, 0);
|
||||
}
|
||||
this.highlight.setAlpha(1);
|
||||
this.scene.tweens.add({
|
||||
targets: this,
|
||||
scale: 1.05,
|
||||
duration: 100,
|
||||
ease: "Power2",
|
||||
});
|
||||
} else {
|
||||
if (this.highlight) {
|
||||
this.highlight.setAlpha(0);
|
||||
}
|
||||
this.scene.tweens.add({
|
||||
targets: this,
|
||||
scale: 1,
|
||||
duration: 100,
|
||||
ease: "Power2",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get selected(): boolean {
|
||||
return this._selected;
|
||||
}
|
||||
|
||||
playSpawnTween(delay = 0) {
|
||||
this.setAlpha(0);
|
||||
this.setScale(0.5);
|
||||
return this.scene.tweens.add({
|
||||
targets: this,
|
||||
alpha: 1,
|
||||
scale: 1,
|
||||
duration: 300,
|
||||
delay,
|
||||
ease: "Back.easeOut",
|
||||
});
|
||||
}
|
||||
|
||||
playDespawnTween(onComplete?: () => void) {
|
||||
return this.scene.tweens.add({
|
||||
targets: this,
|
||||
alpha: 0,
|
||||
scale: 0.5,
|
||||
y: this.y - 50,
|
||||
duration: 200,
|
||||
ease: "Back.easeIn",
|
||||
onComplete: () => {
|
||||
if (onComplete) onComplete();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private playHoverTween(isHovering: boolean) {
|
||||
this.scene.tweens.add({
|
||||
targets: this,
|
||||
y: isHovering ? this.y - 10 : this.y + 10,
|
||||
scale: isHovering ? 1.08 : 1,
|
||||
duration: 150,
|
||||
ease: "Power2",
|
||||
});
|
||||
this.bg.setStrokeStyle(
|
||||
isHovering ? 3 : 2,
|
||||
isHovering ? 0x88aaff : 0x555577,
|
||||
);
|
||||
}
|
||||
|
||||
destroy(fromScene?: boolean): void {
|
||||
this.highlight = null;
|
||||
super.destroy(fromScene);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { spawnEffect, type Spawner } from "boardgame-phaser";
|
||||
import type {
|
||||
CombatState,
|
||||
GameCard,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { CardContainer } from "./CardContainer";
|
||||
import { GameHostScene } from "boardgame-phaser";
|
||||
|
||||
export interface CardSpawnData {
|
||||
cardId: string;
|
||||
index: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const HAND_Y = 140;
|
||||
const CARD_SPACING = 160;
|
||||
const HAND_MARGIN = 100;
|
||||
|
||||
export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
||||
constructor(
|
||||
private scene: GameHostScene<CombatState>,
|
||||
private getState: () => CombatState,
|
||||
private onCardClick: (cardId: string) => void,
|
||||
) {}
|
||||
|
||||
*getData(): Iterable<CardSpawnData> {
|
||||
const state = this.getState();
|
||||
const handIds = state.player.deck.regions.hand.childIds;
|
||||
const total = handIds.length;
|
||||
for (let i = 0; i < total; i++) {
|
||||
yield { cardId: handIds[i], index: i, total };
|
||||
}
|
||||
}
|
||||
|
||||
getKey(data: CardSpawnData): string {
|
||||
return data.cardId;
|
||||
}
|
||||
|
||||
onSpawn(data: CardSpawnData): CardContainer {
|
||||
const state = this.getState();
|
||||
const card = state.player.deck.cards[data.cardId];
|
||||
const { x, y } = this.getCardPosition(data.index, data.total);
|
||||
|
||||
const container = new CardContainer(this.scene, x, y, {
|
||||
card: card ?? this.createFallbackCard(data.cardId),
|
||||
onClick: this.onCardClick,
|
||||
playable: true,
|
||||
});
|
||||
|
||||
container.playSpawnTween(data.index * 40);
|
||||
this.scene.addInterruption(new Promise((r) => setTimeout(r, 40)));
|
||||
return container;
|
||||
}
|
||||
|
||||
onUpdate(data: CardSpawnData, obj: CardContainer): void {
|
||||
const { x, y } = this.getCardPosition(data.index, data.total);
|
||||
if (obj.x !== x || obj.y !== y) {
|
||||
this.scene.tweens.add({
|
||||
targets: obj,
|
||||
x,
|
||||
y,
|
||||
duration: 250,
|
||||
ease: "Power2",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onDespawn(obj: CardContainer, data: CardSpawnData): void {
|
||||
obj.playDespawnTween(() => obj.destroy());
|
||||
this.scene.addInterruption(new Promise((r) => setTimeout(r, 40)));
|
||||
}
|
||||
|
||||
private getCardPosition(
|
||||
index: number,
|
||||
total: number,
|
||||
): { x: number; y: number } {
|
||||
const { width, height } = this.scene.scale;
|
||||
const spacing = Math.min(
|
||||
CARD_SPACING,
|
||||
(width - HAND_MARGIN * 2) / Math.max(1, total),
|
||||
);
|
||||
const startX = width / 2 - ((total - 1) * spacing) / 2;
|
||||
return {
|
||||
x: startX + index * spacing,
|
||||
y: height - HAND_Y,
|
||||
};
|
||||
}
|
||||
|
||||
private createFallbackCard(id: string): GameCard {
|
||||
return {
|
||||
id,
|
||||
regionId: "",
|
||||
position: [],
|
||||
cardData: {
|
||||
id,
|
||||
name: "Unknown",
|
||||
desc: "Card data missing",
|
||||
type: "item",
|
||||
costType: "none",
|
||||
costCount: 0,
|
||||
targetType: "player",
|
||||
effects: [],
|
||||
},
|
||||
itemId: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createCardSpawner(
|
||||
scene: GameHostScene<CombatState>,
|
||||
getState: () => CombatState,
|
||||
onCardClick: (cardId: string) => void,
|
||||
) {
|
||||
return spawnEffect(new CardSpawner(scene, getState, onCardClick));
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import Phaser from "phaser";
|
||||
import type {
|
||||
CombatEntity,
|
||||
CombatState,
|
||||
EffectTable,
|
||||
EnemyEntity,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { BuffData } from "./Buff";
|
||||
import { createSpawnUpdate } from "boardgame-phaser";
|
||||
import { BuffDataWithIndex, BuffSpawner } from "./BuffSpawner";
|
||||
import { GameHostScene } from "boardgame-phaser";
|
||||
|
||||
export type CombatUnitData = {
|
||||
key: string;
|
||||
entity: CombatEntity;
|
||||
name: string;
|
||||
isPlayer: boolean;
|
||||
};
|
||||
|
||||
const CONTAINER_WIDTH = 200;
|
||||
const CONTAINER_HEIGHT = 260;
|
||||
const HP_BAR_WIDTH = 160;
|
||||
const HP_BAR_HEIGHT = 16;
|
||||
const BUFF_ICON_SIZE = 28;
|
||||
const BUFF_ICON_GAP = 6;
|
||||
|
||||
export class CombatUnitContainer extends Phaser.GameObjects.Container {
|
||||
private bg!: Phaser.GameObjects.Rectangle;
|
||||
private nameText!: Phaser.GameObjects.Text;
|
||||
private hpBarBg!: Phaser.GameObjects.Rectangle;
|
||||
private hpBarFill!: Phaser.GameObjects.Rectangle;
|
||||
private hpText!: Phaser.GameObjects.Text;
|
||||
private buffContainer!: Phaser.GameObjects.Container;
|
||||
private intentText!: Phaser.GameObjects.Text | null;
|
||||
private hostScene: GameHostScene<CombatState>;
|
||||
private updateBuffs!: (buffs: Iterable<BuffData & { x: number }>) => void;
|
||||
|
||||
private currentEntity!: CombatEntity;
|
||||
private currentName: string;
|
||||
private currentIsPlayer: boolean;
|
||||
|
||||
constructor(
|
||||
scene: GameHostScene<CombatState>,
|
||||
x: number,
|
||||
y: number,
|
||||
data: CombatUnitData,
|
||||
) {
|
||||
super(scene, x, y);
|
||||
scene.add.existing(this);
|
||||
this.hostScene = scene;
|
||||
|
||||
this.currentEntity = data.entity;
|
||||
this.currentName = data.name;
|
||||
this.currentIsPlayer = data.isPlayer;
|
||||
|
||||
this.createVisuals();
|
||||
this.updateFromData(data);
|
||||
}
|
||||
|
||||
private createVisuals(): void {
|
||||
const bgColor = this.currentIsPlayer ? 0x224466 : 0x662222;
|
||||
const borderColor = this.currentIsPlayer ? 0x44aaff : 0xff4444;
|
||||
|
||||
this.bg = this.scene.add
|
||||
.rectangle(0, 0, CONTAINER_WIDTH, CONTAINER_HEIGHT, bgColor)
|
||||
.setStrokeStyle(3, borderColor);
|
||||
|
||||
this.nameText = this.scene.add
|
||||
.text(0, -CONTAINER_HEIGHT / 2 + 24, this.currentName, {
|
||||
fontSize: "18px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.hpBarBg = this.scene.add.rectangle(
|
||||
0,
|
||||
-CONTAINER_HEIGHT / 2 + 60,
|
||||
HP_BAR_WIDTH,
|
||||
HP_BAR_HEIGHT,
|
||||
0x333333,
|
||||
);
|
||||
|
||||
this.hpBarFill = this.scene.add
|
||||
.rectangle(
|
||||
-HP_BAR_WIDTH / 2,
|
||||
-CONTAINER_HEIGHT / 2 + 60,
|
||||
HP_BAR_WIDTH,
|
||||
HP_BAR_HEIGHT,
|
||||
0x22c55e,
|
||||
)
|
||||
.setOrigin(0, 0.5);
|
||||
|
||||
this.hpText = this.scene.add
|
||||
.text(0, -CONTAINER_HEIGHT / 2 + 60, "", {
|
||||
fontSize: "12px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.buffContainer = this.scene.add.container(0, CONTAINER_HEIGHT / 2 - 40);
|
||||
this.updateBuffs = createSpawnUpdate(
|
||||
new BuffSpawner(this.hostScene, this.buffContainer),
|
||||
);
|
||||
|
||||
if (!this.currentIsPlayer) {
|
||||
this.intentText = this.scene.add
|
||||
.text(0, -CONTAINER_HEIGHT / 2 + 86, "", {
|
||||
fontSize: "12px",
|
||||
color: "#ffcc88",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
} else {
|
||||
this.intentText = null;
|
||||
}
|
||||
|
||||
this.add([
|
||||
this.bg,
|
||||
this.nameText,
|
||||
this.hpBarBg,
|
||||
this.hpBarFill,
|
||||
this.hpText,
|
||||
this.buffContainer,
|
||||
...(this.intentText ? [this.intentText] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
updateFromData(data: CombatUnitData): void {
|
||||
this.currentEntity = data.entity;
|
||||
this.currentName = data.name;
|
||||
|
||||
this.nameText.setText(this.currentName);
|
||||
|
||||
const hpPercent =
|
||||
this.currentEntity.maxHp > 0
|
||||
? this.currentEntity.hp / this.currentEntity.maxHp
|
||||
: 0;
|
||||
|
||||
const fillWidth = Math.max(0, HP_BAR_WIDTH * hpPercent);
|
||||
this.hpBarFill.setDisplaySize(fillWidth, HP_BAR_HEIGHT);
|
||||
|
||||
const hpColor =
|
||||
hpPercent > 0.5 ? 0x22c55e : hpPercent > 0.25 ? 0xf59e0b : 0xef4444;
|
||||
this.hpBarFill.setFillStyle(hpColor);
|
||||
|
||||
this.hpText.setText(
|
||||
`${this.currentEntity.hp} / ${this.currentEntity.maxHp} HP`,
|
||||
);
|
||||
|
||||
this.updateBuffs(this.getBuffEntries());
|
||||
|
||||
if (!this.currentIsPlayer && this.intentText) {
|
||||
const enemyEntity = data.entity as EnemyEntity;
|
||||
const intent = enemyEntity.currentIntent;
|
||||
const intentName = intent?.id ?? "unknown";
|
||||
this.intentText.setText(`Intent: ${intentName}`);
|
||||
}
|
||||
}
|
||||
|
||||
private *getBuffEntries() {
|
||||
const entries = Object.values(this.currentEntity.effects);
|
||||
const totalWidth =
|
||||
entries.length * BUFF_ICON_SIZE + (entries.length - 1) * BUFF_ICON_GAP;
|
||||
const startX = -totalWidth / 2 + BUFF_ICON_SIZE / 2;
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const buff = entries[i];
|
||||
yield {
|
||||
...buff.data,
|
||||
stacks: buff.stacks,
|
||||
isPositive: true,
|
||||
x: startX + i * BUFF_ICON_SIZE,
|
||||
} as BuffDataWithIndex;
|
||||
}
|
||||
}
|
||||
|
||||
playSpawnEffect(): void {
|
||||
this.setScale(0);
|
||||
this.setAlpha(0);
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: this,
|
||||
scale: 1,
|
||||
alpha: 1,
|
||||
duration: 400,
|
||||
ease: "Back.easeOut",
|
||||
});
|
||||
}
|
||||
|
||||
playDamageEffect(): void {
|
||||
const flash = this.scene.add
|
||||
.rectangle(
|
||||
this.x,
|
||||
this.y,
|
||||
CONTAINER_WIDTH,
|
||||
CONTAINER_HEIGHT,
|
||||
0xff0000,
|
||||
0.4,
|
||||
)
|
||||
.setDepth(200);
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: this,
|
||||
x: this.x + 8,
|
||||
duration: 60,
|
||||
yoyo: true,
|
||||
repeat: 3,
|
||||
onComplete: () => {
|
||||
this.scene.tweens.add({
|
||||
targets: flash,
|
||||
alpha: 0,
|
||||
duration: 200,
|
||||
onComplete: () => flash.destroy(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
playHealEffect(): void {
|
||||
const healText = this.scene.add
|
||||
.text(this.x, this.y - 40, "+HP", {
|
||||
fontSize: "20px",
|
||||
color: "#22c55e",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setDepth(200);
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: healText,
|
||||
y: this.y - 80,
|
||||
alpha: 0,
|
||||
duration: 800,
|
||||
ease: "Power2",
|
||||
onComplete: () => healText.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
destroy(fromScene?: boolean): void {
|
||||
this.buffContainer.removeAll(true);
|
||||
super.destroy(fromScene);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Spawner } from "boardgame-phaser";
|
||||
import { spawnEffect } from "boardgame-phaser";
|
||||
import type { CombatState } from "boardgame-core/samples/slay-the-spire-like";
|
||||
import {
|
||||
CombatUnitContainer,
|
||||
type CombatUnitData,
|
||||
} from "./CombatUnitContainer";
|
||||
import { GameHostScene } from "boardgame-phaser";
|
||||
|
||||
export class CombatUnitSpawner implements Spawner<
|
||||
CombatUnitData,
|
||||
CombatUnitContainer
|
||||
> {
|
||||
constructor(private scene: GameHostScene<CombatState>) {}
|
||||
|
||||
*getData(): Iterable<CombatUnitData> {
|
||||
const combat = this.getCombatState();
|
||||
if (!combat) return;
|
||||
|
||||
yield {
|
||||
key: "player",
|
||||
entity: combat.player,
|
||||
name: "Player",
|
||||
isPlayer: true,
|
||||
};
|
||||
|
||||
for (let i = 0; i < combat.enemies.length; i++) {
|
||||
const enemy = combat.enemies[i];
|
||||
yield {
|
||||
key: `enemy-${i}`,
|
||||
entity: enemy,
|
||||
name: enemy.enemy.name,
|
||||
isPlayer: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getKey(t: CombatUnitData): string {
|
||||
return t.key;
|
||||
}
|
||||
|
||||
onSpawn(t: CombatUnitData): CombatUnitContainer | null {
|
||||
const { width, height } = this.scene.scale;
|
||||
const combat = this.getCombatState();
|
||||
if (!combat) return null;
|
||||
|
||||
const totalUnits = 1 + combat.enemies.length;
|
||||
const spacing = 220;
|
||||
const totalWidth = (totalUnits - 1) * spacing;
|
||||
const startX = width / 2 - totalWidth / 2;
|
||||
|
||||
let x = startX;
|
||||
if (t.key.startsWith("enemy-")) {
|
||||
const index = parseInt(t.key.replace("enemy-", ""), 10);
|
||||
x = startX + (index + 1) * spacing;
|
||||
}
|
||||
|
||||
const y = height / 2;
|
||||
|
||||
const container = new CombatUnitContainer(this.scene, x, y, t);
|
||||
container.playSpawnEffect();
|
||||
return container;
|
||||
}
|
||||
|
||||
onUpdate(t: CombatUnitData, obj: CombatUnitContainer): void {
|
||||
obj.updateFromData(t);
|
||||
}
|
||||
|
||||
onDespawn(obj: CombatUnitContainer): void {
|
||||
obj.destroy();
|
||||
}
|
||||
|
||||
private getCombatState(): CombatState | null {
|
||||
const registry = this.scene.registry;
|
||||
return (registry.get("combatState") as CombatState | undefined) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createCombatUnitSpawner(scene: GameHostScene<CombatState>) {
|
||||
return spawnEffect(new CombatUnitSpawner(scene));
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import Phaser from "phaser";
|
||||
import { dragDropEventEffect, DragDropEventType } from "boardgame-phaser";
|
||||
import { GRID_CONFIG } from "@/config";
|
||||
import {
|
||||
IDENTITY_TRANSFORM,
|
||||
ParsedShape,
|
||||
Point2D,
|
||||
Transform2D,
|
||||
transformShape,
|
||||
type GameItem,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { DisposableBag } from "../../../framework/dist";
|
||||
import { InventoryItemState } from "@/state/InventoryItemState";
|
||||
import { moveItem } from "@/state/inventory";
|
||||
import {
|
||||
InventorySurface,
|
||||
InventorySurfaceState,
|
||||
inventoryToScene,
|
||||
sceneToInventory,
|
||||
} from "@/state/inventorySurfaceState";
|
||||
|
||||
export class InventoryItemContainer extends Phaser.GameObjects.Container {
|
||||
private itemState: InventoryItemState;
|
||||
private surfaceState: InventorySurfaceState;
|
||||
private surfaces: Iterable<InventorySurface>;
|
||||
private hitArea: Point2D[] = [];
|
||||
|
||||
constructor(
|
||||
scene: Phaser.Scene,
|
||||
item: GameItem,
|
||||
surface: InventorySurface,
|
||||
surfaces: Iterable<InventorySurface>,
|
||||
) {
|
||||
super(scene, surface.gridOffsetX, surface.gridOffsetY);
|
||||
scene.add.existing(this);
|
||||
|
||||
const cellSize = surface.cellSize;
|
||||
const graphics = this.scene.add.graphics();
|
||||
graphics.setPosition(cellSize / 2, cellSize / 2);
|
||||
const label = this.scene.add.text(
|
||||
cellSize / 2,
|
||||
cellSize / 2,
|
||||
"",
|
||||
GRID_CONFIG.ITEM_NAME_STYLE,
|
||||
);
|
||||
this.add([graphics, label]);
|
||||
this.setupInteractive();
|
||||
|
||||
this.itemState = new InventoryItemState(item);
|
||||
this.surfaceState = new InventorySurfaceState(surface);
|
||||
this.surfaces = surfaces;
|
||||
|
||||
const disposables = new DisposableBag(this);
|
||||
|
||||
disposables.addEffect(() => {
|
||||
label.setText(this.itemState.name.value);
|
||||
});
|
||||
|
||||
disposables.add(this.setupDnDEffect());
|
||||
|
||||
disposables.addEffect(() => {
|
||||
graphics.clear();
|
||||
this.renderGraphics(
|
||||
graphics,
|
||||
this.surfaceState.cellSize,
|
||||
this.itemState.shape.value,
|
||||
this.itemState.color.value,
|
||||
);
|
||||
});
|
||||
|
||||
disposables.addEffect(() => {
|
||||
this.scene.tweens.add({
|
||||
targets: graphics,
|
||||
rotation: -(this.itemState.previewRotation.value * Math.PI) / 180,
|
||||
duration: 150,
|
||||
ease: "Power2",
|
||||
});
|
||||
this.hitArea = this.updateHitArea(this.itemState.previewRotation.value);
|
||||
});
|
||||
|
||||
disposables.addEffect(() => {
|
||||
this.snapBack(this.itemState.transform.value);
|
||||
this.itemState.setPreviewRotation(0);
|
||||
});
|
||||
}
|
||||
|
||||
setSurfaceItem(surface: InventorySurface, item: GameItem): void {
|
||||
this.surfaceState.setSurface(surface);
|
||||
this.itemState.setItem(item);
|
||||
}
|
||||
|
||||
private updateHitArea(value: number): Point2D[] {
|
||||
const shape = this.itemState.shape.value;
|
||||
if (!shape) return [];
|
||||
|
||||
const rotation = value;
|
||||
const cells = transformShape(shape, {
|
||||
rotation,
|
||||
offset: { x: 0, y: 0 },
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
});
|
||||
const cellSize = this.surfaceState.cellSize;
|
||||
|
||||
return cells.map((cell) => ({
|
||||
x: (cell.x - cells[0].x) * cellSize,
|
||||
y: (cell.y - cells[0].y) * cellSize,
|
||||
}));
|
||||
}
|
||||
|
||||
private renderGraphics(
|
||||
graphics: Phaser.GameObjects.Graphics,
|
||||
cellSize: number,
|
||||
shape: ParsedShape,
|
||||
itemColor: number,
|
||||
) {
|
||||
const cells = transformShape(shape, IDENTITY_TRANSFORM);
|
||||
|
||||
for (const cell of cells) {
|
||||
const localX = (cell.x - cells[0].x) * cellSize;
|
||||
const localY = (cell.y - cells[0].y) * cellSize;
|
||||
|
||||
graphics.fillStyle(itemColor);
|
||||
graphics.fillRect(
|
||||
localX + 2 - cellSize / 2,
|
||||
localY + 2 - cellSize / 2,
|
||||
cellSize - 4,
|
||||
cellSize - 4,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private setupInteractive() {
|
||||
this.setScrollFactor(0);
|
||||
this.setInteractive({
|
||||
hitArea: this,
|
||||
hitAreaCallback: (
|
||||
hitArea: InventoryItemContainer,
|
||||
x: number,
|
||||
y: number,
|
||||
) =>
|
||||
hitArea.hitArea.some(
|
||||
(cell) =>
|
||||
x >= cell.x &&
|
||||
x < cell.x + this.surfaceState.cellSize &&
|
||||
y >= cell.y &&
|
||||
y < cell.y + this.surfaceState.cellSize,
|
||||
),
|
||||
useHandCursor: true,
|
||||
} as Phaser.Types.Input.InputConfiguration);
|
||||
}
|
||||
|
||||
private setupDnDEffect() {
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
return dragDropEventEffect(this, (event) => {
|
||||
if (event.type === DragDropEventType.DOWN) {
|
||||
startX = this.x;
|
||||
startY = this.y;
|
||||
this.setAlpha(0.7);
|
||||
} else if (event.type === DragDropEventType.MOVE) {
|
||||
this.x = startX + event.deltaX;
|
||||
this.y = startY + event.deltaY;
|
||||
} else if (event.type === DragDropEventType.ALTBUTTON) {
|
||||
this.itemState.addPreviewRotation(90);
|
||||
} else if (event.type === DragDropEventType.UP) {
|
||||
const finalRotation = this.itemState.previewRotation.peek();
|
||||
const finalX = this.x;
|
||||
const finalY = this.y;
|
||||
if (!this.handleDragEnd(finalX, finalY, finalRotation)) {
|
||||
const t = this.itemState.transform.peek();
|
||||
t && this.snapBack(t);
|
||||
}
|
||||
this.setAlpha(1);
|
||||
this.itemState.setPreviewRotation(0);
|
||||
startX = startY = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleDragEnd(
|
||||
finalX: number,
|
||||
finalY: number,
|
||||
finalRotation: number,
|
||||
): boolean {
|
||||
const item = this.itemState.item;
|
||||
if (!item) return false;
|
||||
|
||||
const target = this.findDropSurface(finalX, finalY);
|
||||
if (!target) return false;
|
||||
|
||||
if (
|
||||
this.surfaceState.surface === target.surface &&
|
||||
item.transform.offset.x === target.x &&
|
||||
item.transform.offset.y === target.y &&
|
||||
item.transform.rotation === finalRotation
|
||||
)
|
||||
return false;
|
||||
|
||||
return moveItem(
|
||||
this.surfaceState.invSignal,
|
||||
target.surface.invSignal,
|
||||
item.id,
|
||||
target.x,
|
||||
target.y,
|
||||
finalRotation,
|
||||
);
|
||||
}
|
||||
|
||||
private findDropSurface(x: number, y: number) {
|
||||
for (const surface of this.surfaces) {
|
||||
const target = sceneToInventory(surface, x, y);
|
||||
if (target)
|
||||
return {
|
||||
surface,
|
||||
...target,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private snapBack(transform: Transform2D): void {
|
||||
const { x, y } = transform.offset;
|
||||
const target = inventoryToScene(this.surfaceState, x, y);
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: this,
|
||||
...target,
|
||||
duration: 150,
|
||||
ease: "Power2",
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user