Compare commits
115
Commits
72e159e52b
...
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 | ||
|
|
e76010272c | ||
|
|
a53f162c03 | ||
|
|
fa104d10f2 | ||
|
|
0e7aaea7da | ||
|
|
acb2fc82ba | ||
|
|
2f0fb2bca8 | ||
|
|
c678974489 | ||
|
|
df1c0cbb81 | ||
|
|
6ebfcc6c4d | ||
|
|
449727d650 | ||
|
|
de4e83e4ea | ||
|
|
61857b8256 | ||
|
|
c85fdb604c | ||
|
|
99dc5e29e7 | ||
|
|
76687de672 |
@@ -9,3 +9,4 @@ packages/sample-game/src/**/*.js
|
|||||||
packages/sample-game/src/**/*.js.map
|
packages/sample-game/src/**/*.js.map
|
||||||
packages/sample-game/src/**/*.d.ts
|
packages/sample-game/src/**/*.d.ts
|
||||||
packages/sample-game/src/**/*.d.ts.map
|
packages/sample-game/src/**/*.d.ts.map
|
||||||
|
/packages/regicide-game/debug
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
!node_modules
|
||||||
|
!debug
|
||||||
@@ -2,42 +2,43 @@
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
A Phaser 3 framework for building web board games, built on top of `boardgame-core` (state management with Preact Signals + Mutative). Uses a pnpm monorepo with two packages:
|
A Phaser 3 framework for building web board games, built on top of `boardgame-core` (state management with Preact Signals + Mutative). Uses a pnpm monorepo with four packages:
|
||||||
|
|
||||||
- **`packages/framework`** (`boardgame-phaser`) — Reusable library: reactive scenes, signal→Phaser bindings, input/command bridge, Preact UI components
|
- **`packages/framework`** (`boardgame-phaser`) — Reusable library: reactive scenes, signal→Phaser bindings, input/command bridge, Preact UI components
|
||||||
- **`packages/sample-game`** — Demo Tic-Tac-Toe game using the framework
|
- **`packages/sample-game`** — Demo Tic-Tac-Toe game using the framework
|
||||||
|
- **`packages/boop-game`** — Boop sample game
|
||||||
|
- **`packages/regicide-game`** — Regicide game
|
||||||
|
|
||||||
## boardgame-core Usage
|
## boardgame-core Usage
|
||||||
|
|
||||||
For detailed boardgame-core API documentation and examples, see **[boardgame-core Guide](docs/boardgame-core-guide.md)**.
|
For detailed boardgame-core API documentation and examples, see `packages/framework/node_modules/boardgame-core/`
|
||||||
|
|
||||||
Key concepts:
|
Key concepts:
|
||||||
- **MutableSignal** — Reactive state container with `.value` and `.produce()`
|
- **MutableSignal** — Reactive state container with `.value` and `.produce()`
|
||||||
- **Command System** — CLI-style parsing with schema validation and prompt support
|
- **Command System** — CLI-style parsing with schema validation and prompt support
|
||||||
- **Region System** — Spatial management with `createRegion()`, `applyAlign()`, `shuffle()`, `moveToRegion()`
|
- **Region System** — Spatial management with `createRegion()`, `applyAlign()`, `shuffle()`, `moveToRegion()`
|
||||||
- **Part System** — Game pieces with `createPart()`, `createPartPool()`, `flip()`, `roll()`
|
- **Part System** — Game pieces with `createPartsFromTable()`, `flip()`, `roll()`
|
||||||
- **RNG** — Deterministic PRNG via `createRNG(seed)` for reproducible game states
|
- **RNG** — Deterministic PRNG via `createRNG(seed)` for reproducible game states
|
||||||
|
|
||||||
### Quick Example
|
### Quick Example
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { createGameCommandRegistry, createRegion, MutableSignal } from 'boardgame-core';
|
import { createGameCommandRegistry, createRegion, IGameContext } from 'boardgame-core';
|
||||||
|
|
||||||
type GameState = {
|
type GameState = {
|
||||||
board: Region;
|
board: Region;
|
||||||
parts: Part<{ player: 'X' | 'O' }>[];
|
parts: Part<{ player: 'X' | 'O' }>[];
|
||||||
currentPlayer: 'X' | 'O';
|
currentPlayer: 'X' | 'O';
|
||||||
};
|
};
|
||||||
|
type Game = IGameContext<GameState>;
|
||||||
|
|
||||||
const registration = createGameCommandRegistry<GameState>();
|
const registry = createGameCommandRegistry<GameState>();
|
||||||
export const registry = registration.registry;
|
|
||||||
|
|
||||||
registration.add('place <row:number> <col:number>', async function(cmd) {
|
registry.register('place <row:number> <col:number>', async function(game: Game, row: number, col: number) {
|
||||||
const [row, col] = cmd.params as [number, number];
|
await game.produceAsync(state => {
|
||||||
this.context.produce(state => {
|
|
||||||
state.parts.push({ id: `p-${row}-${col}`, regionId: 'board', position: [row, col], player: state.currentPlayer });
|
state.parts.push({ id: `p-${row}-${col}`, regionId: 'board', position: [row, col], player: state.currentPlayer });
|
||||||
});
|
});
|
||||||
return { success: true };
|
return true;
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -71,23 +72,20 @@ pnpm --filter sample-game typecheck # tsc --noEmit (add to scripts first)
|
|||||||
```bash
|
```bash
|
||||||
# boardgame-core is a local dependency via symlink (link:../../../boardgame-core)
|
# boardgame-core is a local dependency via symlink (link:../../../boardgame-core)
|
||||||
# After changes to boardgame-core, simply rebuild it:
|
# After changes to boardgame-core, simply rebuild it:
|
||||||
cd ../boardgame-core && pnpm build
|
cd ../boardgame-core && npm build
|
||||||
# The symlink automatically resolves to the updated dist/
|
# The symlink automatically resolves to the updated dist/
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
No test framework is configured yet. When adding tests, use **Vitest** (consistent with boardgame-core):
|
Framework and regicide-game have **Vitest** configured:
|
||||||
```bash
|
```bash
|
||||||
# In packages/framework:
|
# In packages/framework:
|
||||||
pnpm add -D vitest
|
pnpm --filter boardgame-phaser test # Run all tests
|
||||||
# Run all tests:
|
pnpm --filter boardgame-phaser test:watch # Watch mode
|
||||||
pnpm vitest run
|
|
||||||
# Run a single test file:
|
# In packages/regicide-game:
|
||||||
pnpm vitest run src/bindings/index.test.ts
|
pnpm --filter regicide-game test # Run all tests
|
||||||
# Run tests matching a pattern:
|
pnpm --filter regicide-game test:watch # Watch mode
|
||||||
pnpm vitest run -t "bindRegion"
|
|
||||||
# Watch mode:
|
|
||||||
pnpm vitest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
@@ -112,24 +110,24 @@ pnpm vitest
|
|||||||
- Use `as any` sparingly; prefer `as unknown as Record<string, unknown>` for type narrowing
|
- Use `as any` sparingly; prefer `as unknown as Record<string, unknown>` for type narrowing
|
||||||
|
|
||||||
### Naming conventions
|
### Naming conventions
|
||||||
- **Classes**: PascalCase (`ReactiveScene`, `InputMapper`, `PromptHandler`)
|
- **Classes**: PascalCase (`GameHostScene`, `PhaserGame`, `GameUI`)
|
||||||
- **Interfaces**: PascalCase with descriptive names (`ReactiveSceneOptions`, `BindRegionOptions`)
|
- **Interfaces**: PascalCase with descriptive names (`GameHostSceneOptions`, `PhaserGameProps`)
|
||||||
- **Functions**: camelCase (`bindSignal`, `createInputMapper`, `createPromptHandler`)
|
- **Functions**: camelCase (`spawnEffect`, `bindRegion`)
|
||||||
- **Constants**: UPPER_SNAKE_CASE (`CELL_SIZE`, `BOARD_OFFSET`)
|
- **Constants**: UPPER_SNAKE_CASE (`CELL_SIZE`, `BOARD_OFFSET`)
|
||||||
- **Type aliases**: PascalCase (`DisposeFn`, `CommandResult`)
|
- **Type aliases**: PascalCase (`DisposeFn`, `CommandResult`)
|
||||||
- **Factory functions**: `create*` prefix (`createInputMapper`, `createPromptHandler`)
|
- **Factory functions**: `create*` prefix (`createRegion`, `createRNG`)
|
||||||
|
|
||||||
### Architecture patterns
|
### Architecture patterns
|
||||||
- **ReactiveScene**: Abstract base class extending `Phaser.Scene`. Subclasses implement `onStateReady()` and `setupBindings()`. Effects are auto-cleaned on scene shutdown via `this.events.on('shutdown', ...)`.
|
- **GameHostScene**: Abstract base class extending `Phaser.Scene`. Subclasses implement game-specific logic. Provides `gameHost` property for state access and `addInterruption()`/`addTweenInterruption()` for async flow control. Disposables are auto-cleaned on scene shutdown via `this.events.on('shutdown', ...)`.
|
||||||
- **Bindings**: Use `effect()` from `@preact/signals-core` to reactively sync signal state to Phaser objects. Always return cleanup functions.
|
- **Spawner**: Use `spawnEffect()` for data-driven object creation with signal-based configuration.
|
||||||
- **Input/Command bridge**: Maps Phaser pointer events to `boardgame-core` command strings. Uses `create*` factory pattern.
|
|
||||||
- **UI components**: Preact functional components with hooks. Use `className` (not `class`) for CSS classes.
|
- **UI components**: Preact functional components with hooks. Use `className` (not `class`) for CSS classes.
|
||||||
|
- **Phaser Bridge**: Use `PhaserGame`, `PhaserScene`, and `GameUI` components from `boardgame-phaser` for React/Phaser integration.
|
||||||
|
|
||||||
### Error handling
|
### Error handling
|
||||||
- Command results use `{ success: true, result } | { success: false, error }` pattern
|
- Use `DisposableBag` for managing multiple disposables in scenes
|
||||||
- Scene effects are cleaned up via the `shutdown` event, not by overriding `shutdown()`
|
- Scene effects are cleaned up via the `shutdown` event
|
||||||
- Use `as any` casts only when Phaser types are incomplete (e.g., `setInteractive`)
|
- Use `as any` casts only when Phaser types are incomplete (e.g., `setInteractive`)
|
||||||
- Prompt handlers return `string | null` for validation errors
|
- Game commands return validation errors as `string | null`
|
||||||
|
|
||||||
### JSX
|
### JSX
|
||||||
- `jsxImportSource: "preact"` — no React import needed
|
- `jsxImportSource: "preact"` — no React import needed
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# boardgame-phaser
|
||||||
|
|
||||||
|
基于Phaser3/boardgame-core的游戏框架
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
项目使用pnpm monorepo管理,包含以下包:
|
||||||
|
- `framework`:通用框架
|
||||||
|
- `boop-game`:boop样例
|
||||||
|
- `sample-game`:tic tac toe样例
|
||||||
|
|
||||||
|
游戏应当使用vite构建,基于`preact/signals`进行状态管理,使用`phaser3`实现游戏功能。
|
||||||
|
|
||||||
|
## boardgame-core
|
||||||
|
|
||||||
|
项目使用`boardgame-core`进行游戏定义。
|
||||||
|
`boardgame-core`的内容可以在`framework/node_modules/boardgame-core`找到。
|
||||||
|
这个文件夹被.gitignore忽略,查看时需要绕开这一限制。
|
||||||
|
|
||||||
|
## 编写Phaser App
|
||||||
|
|
||||||
|
使用`framework/src/ui/PhaserBridge`来创建Phaser App。
|
||||||
|
|
||||||
|
使用`framework/src/scenes/GameHostScene`来创建游戏场景。
|
||||||
|
|
||||||
|
使用`GameHost`来控制游戏状态。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
|
||||||
|
// 获取游戏状态的只读快照
|
||||||
|
get state(): TState{}
|
||||||
|
// 获取随机数
|
||||||
|
get rng(): ReadonlyRNG{}
|
||||||
|
|
||||||
|
// 运行状态
|
||||||
|
readonly status: ReadonlySignal<GameHostStatus>;
|
||||||
|
|
||||||
|
// 运行中途需要玩家输入时使用
|
||||||
|
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>;
|
||||||
|
readonly activePromptPlayer: ReadonlySignal<string | null>;
|
||||||
|
|
||||||
|
// 玩家响应activePrompt的输入,若报错则返回string,否则返回null
|
||||||
|
// promptDef应当从game module中导出
|
||||||
|
tryAnswerPrompt<TArgs extends any[]>(promptDef: Promptdef<TArgs>,...args: TArgs): string | null {}
|
||||||
|
|
||||||
|
// 添加中断,context.produceAsync会等待所有中断结束之后再继续
|
||||||
|
addInterruption(promise: Promise<void>): void {}
|
||||||
|
|
||||||
|
// 开始或者重新开始游戏
|
||||||
|
start(seed?: number): Promise<TResult>{}
|
||||||
|
|
||||||
|
// 销毁
|
||||||
|
dispose(): void {}
|
||||||
|
|
||||||
|
// 事件侦听
|
||||||
|
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",
|
"dev": "pnpm --filter sample-game dev",
|
||||||
"build": "pnpm --filter boardgame-phaser build && pnpm --filter sample-game build",
|
"build": "pnpm --filter boardgame-phaser build && pnpm --filter sample-game build",
|
||||||
"build:framework": "pnpm --filter boardgame-phaser 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": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.17.0",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
|
"typescript-eslint": "^8.18.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"@preact/signals-core": "^1.5.1",
|
"@preact/signals-core": "^1.5.1",
|
||||||
"boardgame-core": "link:../../../boardgame-core",
|
"boardgame-core": "link:../../../boardgame-core",
|
||||||
"boardgame-phaser": "workspace:*",
|
"boardgame-phaser": "workspace:*",
|
||||||
|
"inline-schema": "link:../../../inline-schema",
|
||||||
"mutative": "^1.3.0",
|
"mutative": "^1.3.0",
|
||||||
"phaser": "^3.80.1",
|
"phaser": "^3.80.1",
|
||||||
"preact": "^10.19.3"
|
"preact": "^10.19.3"
|
||||||
|
|||||||
@@ -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,434 +0,0 @@
|
|||||||
import {
|
|
||||||
createGameCommandRegistry,
|
|
||||||
Part,
|
|
||||||
MutableSignal,
|
|
||||||
createRegion,
|
|
||||||
createPartPool,
|
|
||||||
createPart,
|
|
||||||
moveToRegion,
|
|
||||||
isCellOccupied as isCellOccupiedUtil,
|
|
||||||
getPartAtPosition,
|
|
||||||
applyAlign,
|
|
||||||
} from 'boardgame-core';
|
|
||||||
|
|
||||||
const BOARD_SIZE = 6;
|
|
||||||
const MAX_PIECES_PER_PLAYER = 8;
|
|
||||||
const WIN_LENGTH = 3;
|
|
||||||
|
|
||||||
export type PlayerType = 'white' | 'black';
|
|
||||||
export type PieceType = 'kitten' | 'cat';
|
|
||||||
export type WinnerType = PlayerType | 'draw' | null;
|
|
||||||
export type RegionId = 'board' | 'white-kitten' | 'white-cat' | 'black-kitten' | 'black-cat';
|
|
||||||
|
|
||||||
export type BoopPart = Part<{ player: PlayerType; pieceType: PieceType }>;
|
|
||||||
|
|
||||||
type Player = {
|
|
||||||
id: PlayerType;
|
|
||||||
kittenPool: ReturnType<typeof createPartPool<{ player: PlayerType; pieceType: PieceType }>>;
|
|
||||||
catPool: ReturnType<typeof createPartPool<{ player: PlayerType; pieceType: PieceType }>>;
|
|
||||||
graduatedCount: number; // 已毕业但未放置的大猫数量
|
|
||||||
};
|
|
||||||
|
|
||||||
type PlayerData = Record<PlayerType, Player>;
|
|
||||||
|
|
||||||
export type BoopState = {
|
|
||||||
board: ReturnType<typeof createRegion>;
|
|
||||||
pieces: Record<string, BoopPart>;
|
|
||||||
currentPlayer: PlayerType;
|
|
||||||
winner: WinnerType;
|
|
||||||
players: PlayerData;
|
|
||||||
};
|
|
||||||
|
|
||||||
function createPlayer(id: PlayerType): Player {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
kittenPool: createPartPool(
|
|
||||||
{ regionId: `${id}-kitten`, player: id, pieceType: 'kitten' as PieceType },
|
|
||||||
MAX_PIECES_PER_PLAYER,
|
|
||||||
`${id}-kitten`
|
|
||||||
),
|
|
||||||
catPool: createPartPool(
|
|
||||||
{ regionId: `${id}-cat`, player: id, pieceType: 'cat' as PieceType },
|
|
||||||
MAX_PIECES_PER_PLAYER, // 预创建 MAX_PIECES_PER_PLAYER 只猫
|
|
||||||
`${id}-cat`
|
|
||||||
),
|
|
||||||
graduatedCount: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createInitialState(): BoopState {
|
|
||||||
return {
|
|
||||||
board: createRegion('board', [
|
|
||||||
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
|
||||||
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
|
||||||
]),
|
|
||||||
pieces: {} as Record<string, BoopPart>,
|
|
||||||
currentPlayer: 'white' as PlayerType,
|
|
||||||
winner: null as WinnerType,
|
|
||||||
players: {
|
|
||||||
white: createPlayer('white'),
|
|
||||||
black: createPlayer('black'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BoopStateType = ReturnType<typeof createInitialState>;
|
|
||||||
const registration = createGameCommandRegistry<BoopStateType>();
|
|
||||||
export const registry = registration.registry;
|
|
||||||
|
|
||||||
export function getPlayer(host: MutableSignal<BoopStateType>, player: PlayerType): Player {
|
|
||||||
return host.value.players[player];
|
|
||||||
}
|
|
||||||
|
|
||||||
registration.add('setup', async function() {
|
|
||||||
const {context} = this;
|
|
||||||
while (true) {
|
|
||||||
const currentPlayer = context.value.currentPlayer;
|
|
||||||
const turnOutput = await this.run<{winner: WinnerType}>(`turn ${currentPlayer}`);
|
|
||||||
if (!turnOutput.success) throw new Error(turnOutput.error);
|
|
||||||
|
|
||||||
context.produce(state => {
|
|
||||||
state.winner = turnOutput.result.winner;
|
|
||||||
if (!state.winner) {
|
|
||||||
state.currentPlayer = state.currentPlayer === 'white' ? 'black' : 'white';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (context.value.winner) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
registration.add('turn <player>', async function(cmd) {
|
|
||||||
const [turnPlayer] = cmd.params as [PlayerType];
|
|
||||||
|
|
||||||
const playCmd = await this.prompt(
|
|
||||||
'play <player> <row:number> <col:number> [type:string]',
|
|
||||||
(command) => {
|
|
||||||
const [player, row, col, type] = command.params as [PlayerType, number, number, PieceType?];
|
|
||||||
const pieceType = type === 'cat' ? 'cat' : 'kitten';
|
|
||||||
|
|
||||||
if (player !== turnPlayer) {
|
|
||||||
return `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
|
||||||
}
|
|
||||||
if (!isValidMove(row, col)) {
|
|
||||||
return `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
|
|
||||||
}
|
|
||||||
if (isCellOccupied(this.context, row, col)) {
|
|
||||||
return `Cell (${row}, ${col}) is already occupied.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const playerData = getPlayer(this.context, player);
|
|
||||||
if (pieceType === 'kitten') {
|
|
||||||
if (playerData.kittenPool.remaining() <= 0) {
|
|
||||||
return `No kittens left in ${player}'s supply.`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Can place cat if pool has remaining OR graduatedCount > 0
|
|
||||||
const availableCats = playerData.catPool.remaining() + playerData.graduatedCount;
|
|
||||||
if (availableCats <= 0) {
|
|
||||||
return `No cats available for ${player}. Graduate some kittens first.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
this.context.value.currentPlayer
|
|
||||||
);
|
|
||||||
const [player, row, col, type] = playCmd.params as [PlayerType, number, number, PieceType?];
|
|
||||||
const pieceType = type === 'cat' ? 'cat' : 'kitten';
|
|
||||||
|
|
||||||
placePiece(this.context, row, col, turnPlayer, pieceType);
|
|
||||||
applyBoops(this.context, row, col, pieceType);
|
|
||||||
|
|
||||||
const graduatedLines = checkGraduation(this.context, turnPlayer);
|
|
||||||
if (graduatedLines.length > 0) {
|
|
||||||
processGraduation(this.context, turnPlayer, graduatedLines);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (countPiecesOnBoard(this.context, turnPlayer) >= MAX_PIECES_PER_PLAYER) {
|
|
||||||
const pieces = this.context.value.pieces;
|
|
||||||
const availableKittens = Object.values(pieces).filter(
|
|
||||||
p => p.player === turnPlayer && p.pieceType === 'kitten'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (availableKittens.length > 0) {
|
|
||||||
const graduateCmd = await this.prompt(
|
|
||||||
'graduate <row:number> <col:number>',
|
|
||||||
(command) => {
|
|
||||||
const [row, col] = command.params as [number, number];
|
|
||||||
const part = availableKittens.find(p => p.position[0] === row && p.position[1] === col);
|
|
||||||
if (!part) return `No kitten at (${row}, ${col}).`;
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
this.context.value.currentPlayer
|
|
||||||
);
|
|
||||||
const [row, col] = graduateCmd.params as [number, number];
|
|
||||||
graduatePiece(this.context, row, col, turnPlayer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const winner = checkWinner(this.context);
|
|
||||||
if (winner) return { winner };
|
|
||||||
|
|
||||||
return { winner: null };
|
|
||||||
});
|
|
||||||
|
|
||||||
function isValidMove(row: number, col: number): boolean {
|
|
||||||
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBoardRegion(host: MutableSignal<BoopStateType>) {
|
|
||||||
return host.value.board;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCellOccupied(host: MutableSignal<BoopStateType>, row: number, col: number): boolean {
|
|
||||||
return isCellOccupiedUtil(host.value.pieces, 'board', [row, col]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPartAt(host: MutableSignal<BoopStateType>, row: number, col: number): BoopPart | null {
|
|
||||||
return getPartAtPosition(host.value.pieces, 'board', [row, col]) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function placePiece(host: MutableSignal<BoopStateType>, row: number, col: number, player: PlayerType, pieceType: PieceType) {
|
|
||||||
const board = getBoardRegion(host);
|
|
||||||
const playerData = getPlayer(host, player);
|
|
||||||
|
|
||||||
let piece: BoopPart;
|
|
||||||
|
|
||||||
if (pieceType === 'kitten') {
|
|
||||||
const drawn = playerData.kittenPool.draw();
|
|
||||||
if (!drawn) throw new Error(`No kitten available in ${player}'s supply`);
|
|
||||||
piece = drawn;
|
|
||||||
moveToRegion(piece, null, board, [row, col]);
|
|
||||||
} else {
|
|
||||||
// Try to use graduated count first
|
|
||||||
if (playerData.graduatedCount > 0) {
|
|
||||||
// Create a new cat piece (graduated)
|
|
||||||
const count = playerData.catPool.remaining() + playerData.graduatedCount;
|
|
||||||
piece = createPart<{ player: PlayerType; pieceType: PieceType }>(
|
|
||||||
{ regionId: 'board', position: [row, col], player, pieceType },
|
|
||||||
`${player}-cat-graduated-${count}`
|
|
||||||
);
|
|
||||||
playerData.graduatedCount--;
|
|
||||||
} else {
|
|
||||||
const drawn = playerData.catPool.draw();
|
|
||||||
if (!drawn) throw new Error(`No cat available in ${player}'s supply`);
|
|
||||||
piece = drawn;
|
|
||||||
moveToRegion(piece, null, board, [row, col]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
host.produce(s => {
|
|
||||||
s.pieces[piece.id] = piece;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyBoops(host: MutableSignal<BoopStateType>, placedRow: number, placedCol: number, placedType: PieceType) {
|
|
||||||
const board = getBoardRegion(host);
|
|
||||||
const pieces = host.value.pieces;
|
|
||||||
const piecesArray = Object.values(pieces);
|
|
||||||
|
|
||||||
const piecesToBoop: { part: BoopPart; dr: number; dc: number }[] = [];
|
|
||||||
const piecesOffBoard: BoopPart[] = [];
|
|
||||||
|
|
||||||
for (const part of piecesArray) {
|
|
||||||
const [r, c] = part.position;
|
|
||||||
if (r === placedRow && c === placedCol) continue;
|
|
||||||
|
|
||||||
const dr = Math.sign(r - placedRow);
|
|
||||||
const dc = Math.sign(c - placedCol);
|
|
||||||
|
|
||||||
if (Math.abs(r - placedRow) <= 1 && Math.abs(c - placedCol) <= 1) {
|
|
||||||
const booperIsKitten = placedType === 'kitten';
|
|
||||||
const targetIsCat = part.pieceType === 'cat';
|
|
||||||
|
|
||||||
if (booperIsKitten && targetIsCat) continue;
|
|
||||||
|
|
||||||
piecesToBoop.push({ part, dr, dc });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
host.produce(state => {
|
|
||||||
for (const { part, dr, dc } of piecesToBoop) {
|
|
||||||
const [r, c] = part.position;
|
|
||||||
const newRow = r + dr;
|
|
||||||
const newCol = c + dc;
|
|
||||||
|
|
||||||
if (newRow < 0 || newRow >= BOARD_SIZE || newCol < 0 || newCol >= BOARD_SIZE) {
|
|
||||||
// Mark for removal
|
|
||||||
piecesOffBoard.push(part);
|
|
||||||
delete state.pieces[part.id];
|
|
||||||
state.board.childIds = state.board.childIds.filter(id => id !== part.id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCellOccupied(host, newRow, newCol)) continue;
|
|
||||||
|
|
||||||
moveToRegion(part, board, board, [newRow, newCol]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return booped pieces to pools outside of produce
|
|
||||||
for (const part of piecesOffBoard) {
|
|
||||||
const playerData = getPlayer(host, part.player);
|
|
||||||
const pool = part.pieceType === 'kitten' ? playerData.kittenPool : playerData.catPool;
|
|
||||||
pool.return(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function graduatePiece(host: MutableSignal<BoopStateType>, row: number, col: number, player: PlayerType) {
|
|
||||||
const pieces = host.value.pieces;
|
|
||||||
const part = Object.values(pieces).find(p => p.player === player && p.pieceType === 'kitten' && p.position[0] === row && p.position[1] === col);
|
|
||||||
if (!part) return;
|
|
||||||
|
|
||||||
const board = getBoardRegion(host);
|
|
||||||
const playerData = getPlayer(host, player);
|
|
||||||
|
|
||||||
host.produce(state => {
|
|
||||||
// Remove from board
|
|
||||||
delete state.pieces[part.id];
|
|
||||||
state.board.childIds = state.board.childIds.filter(id => id !== part.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return kitten to supply
|
|
||||||
playerData.kittenPool.return(part);
|
|
||||||
|
|
||||||
// Increment graduated count (available cats to place)
|
|
||||||
playerData.graduatedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removePieceFromBoard(host: MutableSignal<BoopStateType>, part: BoopPart) {
|
|
||||||
const board = getBoardRegion(host);
|
|
||||||
const playerData = getPlayer(host, part.player);
|
|
||||||
const pool = part.pieceType === 'kitten' ? playerData.kittenPool : playerData.catPool;
|
|
||||||
|
|
||||||
host.produce(state => {
|
|
||||||
delete state.pieces[part.id];
|
|
||||||
state.board.childIds = state.board.childIds.filter(id => id !== part.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
pool.return(part);
|
|
||||||
}
|
|
||||||
|
|
||||||
const DIRECTIONS: [number, number][] = [
|
|
||||||
[0, 1],
|
|
||||||
[1, 0],
|
|
||||||
[1, 1],
|
|
||||||
[1, -1],
|
|
||||||
];
|
|
||||||
|
|
||||||
export function* linesThrough(r: number, c: number): Generator<number[][]> {
|
|
||||||
for (const [dr, dc] of DIRECTIONS) {
|
|
||||||
const minStart = -(WIN_LENGTH - 1);
|
|
||||||
for (let offset = minStart; offset <= 0; offset++) {
|
|
||||||
const startR = r + offset * dr;
|
|
||||||
const startC = c + offset * dc;
|
|
||||||
const endR = startR + (WIN_LENGTH - 1) * dr;
|
|
||||||
const endC = startC + (WIN_LENGTH - 1) * dc;
|
|
||||||
|
|
||||||
if (startR < 0 || startR >= BOARD_SIZE || startC < 0 || startC >= BOARD_SIZE) continue;
|
|
||||||
if (endR < 0 || endR >= BOARD_SIZE || endC < 0 || endC >= BOARD_SIZE) continue;
|
|
||||||
|
|
||||||
const line: number[][] = [];
|
|
||||||
for (let i = 0; i < WIN_LENGTH; i++) {
|
|
||||||
line.push([startR + i * dr, startC + i * dc]);
|
|
||||||
}
|
|
||||||
yield line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function* allLines(): Generator<number[][]> {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (let r = 0; r < BOARD_SIZE; r++) {
|
|
||||||
for (let c = 0; c < BOARD_SIZE; c++) {
|
|
||||||
for (const line of linesThrough(r, c)) {
|
|
||||||
const key = line.map(p => p.join(',')).join(';');
|
|
||||||
if (!seen.has(key)) {
|
|
||||||
seen.add(key);
|
|
||||||
yield line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasWinningLine(positions: number[][]): boolean {
|
|
||||||
const posSet = new Set(positions.map(p => `${p[0]},${p[1]}`));
|
|
||||||
for (const line of allLines()) {
|
|
||||||
if (line.every(([lr, lc]) => posSet.has(`${lr},${lc}`))) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkGraduation(host: MutableSignal<BoopStateType>, player: PlayerType): number[][][] {
|
|
||||||
const pieces = host.value.pieces;
|
|
||||||
const piecesArray = Object.values(pieces);
|
|
||||||
const posSet = new Set<string>();
|
|
||||||
|
|
||||||
for (const part of piecesArray) {
|
|
||||||
if (part.player === player && part.pieceType === 'kitten') {
|
|
||||||
posSet.add(`${part.position[0]},${part.position[1]}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const winningLines: number[][][] = [];
|
|
||||||
for (const line of allLines()) {
|
|
||||||
if (line.every(([lr, lc]) => posSet.has(`${lr},${lc}`))) {
|
|
||||||
winningLines.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return winningLines;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function processGraduation(host: MutableSignal<BoopStateType>, player: PlayerType, lines: number[][][]) {
|
|
||||||
const allPositions = new Set<string>();
|
|
||||||
for (const line of lines) {
|
|
||||||
for (const [r, c] of line) {
|
|
||||||
allPositions.add(`${r},${c}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pieces = host.value.pieces;
|
|
||||||
const partsToRemove = Object.values(pieces).filter(
|
|
||||||
p => p.player === player && p.pieceType === 'kitten' && allPositions.has(`${p.position[0]},${p.position[1]}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const part of partsToRemove) {
|
|
||||||
graduatePiece(host, part.position[0], part.position[1], player);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function countPiecesOnBoard(host: MutableSignal<BoopStateType>, player: PlayerType): number {
|
|
||||||
const pieces = host.value.pieces;
|
|
||||||
return Object.values(pieces).filter(p => p.player === player).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkWinner(host: MutableSignal<BoopStateType>): WinnerType {
|
|
||||||
const pieces = host.value.pieces;
|
|
||||||
const piecesArray = Object.values(pieces);
|
|
||||||
|
|
||||||
for (const player of ['white', 'black'] as PlayerType[]) {
|
|
||||||
const positions = piecesArray
|
|
||||||
.filter(p => p.player === player && p.pieceType === 'cat')
|
|
||||||
.map(p => p.position);
|
|
||||||
if (hasWinningLine(positions)) return player;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 命令构建器
|
|
||||||
export const commands = {
|
|
||||||
play: (player: PlayerType, row: number, col: number, type?: PieceType) =>
|
|
||||||
`play ${player} ${row} ${col}${type ? ` ${type}` : ''}`,
|
|
||||||
turn: (player: PlayerType) => `turn ${player}`,
|
|
||||||
graduate: (row: number, col: number) => `graduate ${row} ${col}`,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// 导出游戏模块
|
|
||||||
export const gameModule = {
|
|
||||||
createInitialState,
|
|
||||||
registry,
|
|
||||||
commands,
|
|
||||||
};
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { GameUI } from 'boardgame-phaser';
|
import { GameUI } from 'boardgame-phaser';
|
||||||
import { gameModule } from './game/boop';
|
|
||||||
import './style.css';
|
import './style.css';
|
||||||
import App from "@/ui/App";
|
import App from "@/ui/App";
|
||||||
import {GameScene} from "@/scenes/GameScene";
|
|
||||||
|
|
||||||
const ui = new GameUI({
|
const ui = new GameUI({
|
||||||
container: document.getElementById('ui-root')!,
|
container: document.getElementById('ui-root')!,
|
||||||
root: <App gameModule={gameModule} gameScene={GameScene}/>,
|
root: <App/>,
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.mount();
|
ui.mount();
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import type { BoopState, PlayerType } from '@/game/boop';
|
import type { BoopState, PlayerType, BoopPart } from '@/game';
|
||||||
import type { ReadonlySignal } from '@preact/signals-core';
|
|
||||||
|
|
||||||
const BOARD_SIZE = 6;
|
const BOARD_SIZE = 6;
|
||||||
const CELL_SIZE = 80;
|
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 };
|
export { BOARD_SIZE, CELL_SIZE, BOARD_OFFSET };
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@ export class BoardRenderer {
|
|||||||
|
|
||||||
this.turnText = this.scene.add.text(
|
this.turnText = this.scene.add.text(
|
||||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
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',
|
fontSize: '22px',
|
||||||
@@ -32,8 +31,8 @@ export class BoardRenderer {
|
|||||||
|
|
||||||
this.infoText = this.scene.add.text(
|
this.infoText = this.scene.add.text(
|
||||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 60,
|
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 80,
|
||||||
'Click to place kitten. Cats win with 3 in a row!',
|
'点击空白处放置小猫,三个大猫连线获胜!',
|
||||||
{
|
{
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
fontFamily: 'Arial',
|
fontFamily: 'Arial',
|
||||||
@@ -63,25 +62,37 @@ export class BoardRenderer {
|
|||||||
|
|
||||||
g.strokePath();
|
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',
|
fontSize: '32px',
|
||||||
fontFamily: 'Arial',
|
fontFamily: 'Arial',
|
||||||
color: '#1f2937',
|
color: '#1f2937',
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private countPieces(state: BoopState, player: PlayerType) {
|
||||||
|
const pieces = Object.values(state.pieces);
|
||||||
|
const playerPieces = pieces.filter((p: BoopPart) => p.player === player);
|
||||||
|
|
||||||
|
const kittensInSupply = playerPieces.filter((p: BoopPart) => p.type === 'kitten' && p.regionId === player).length;
|
||||||
|
const catsInSupply = playerPieces.filter((p: BoopPart) => p.type === 'cat' && p.regionId === player).length;
|
||||||
|
const piecesOnBoard = playerPieces.filter((p: BoopPart) => p.regionId === 'board').length;
|
||||||
|
|
||||||
|
return { kittensInSupply, catsInSupply, piecesOnBoard };
|
||||||
|
}
|
||||||
|
|
||||||
updateTurnText(player: PlayerType, state: BoopState): void {
|
updateTurnText(player: PlayerType, state: BoopState): void {
|
||||||
const current = player === 'white' ? state.players.white : state.players.black;
|
const { kittensInSupply, catsInSupply } = this.countPieces(state, player);
|
||||||
const catsAvailable = current.catPool.remaining() + current.graduatedCount;
|
|
||||||
|
|
||||||
this.turnText.setText(
|
this.turnText.setText(
|
||||||
`${player.toUpperCase()}'s turn | Kittens: ${current.kittenPool.remaining()} | Cats: ${catsAvailable}`
|
`${player.toUpperCase()}'s turn | Kittens: ${kittensInSupply} | Cats: ${catsInSupply}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setupInput(
|
setupInput(
|
||||||
state: ReadonlySignal<BoopState>,
|
getState: () => BoopState,
|
||||||
onCellClick: (row: number, col: number) => void
|
onCellClick: (row: number, col: number) => void,
|
||||||
|
onPieceClick: (row: number, col: number) => void,
|
||||||
|
checkWinner: () => boolean
|
||||||
): void {
|
): void {
|
||||||
for (let row = 0; row < BOARD_SIZE; row++) {
|
for (let row = 0; row < BOARD_SIZE; row++) {
|
||||||
for (let col = 0; col < BOARD_SIZE; col++) {
|
for (let col = 0; col < BOARD_SIZE; col++) {
|
||||||
@@ -91,8 +102,13 @@ export class BoardRenderer {
|
|||||||
const zone = this.scene.add.zone(x, y, CELL_SIZE, CELL_SIZE).setInteractive();
|
const zone = this.scene.add.zone(x, y, CELL_SIZE, CELL_SIZE).setInteractive();
|
||||||
|
|
||||||
zone.on('pointerdown', () => {
|
zone.on('pointerdown', () => {
|
||||||
const isOccupied = !!state.value.board.partMap[`${row},${col}`];
|
const state = getState();
|
||||||
if (!isOccupied && !state.value.winner) {
|
const isOccupied = !!state.regions.board.partMap[`${row},${col}`];
|
||||||
|
if (checkWinner()) return;
|
||||||
|
|
||||||
|
if (isOccupied) {
|
||||||
|
onPieceClick(row, col);
|
||||||
|
} else {
|
||||||
onCellClick(row, col);
|
onCellClick(row, col);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import Phaser from 'phaser';
|
||||||
|
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
|
||||||
|
|
||||||
|
export class ErrorOverlay {
|
||||||
|
private overlay?: Phaser.GameObjects.Container;
|
||||||
|
private hideTimeout?: Phaser.Time.TimerEvent;
|
||||||
|
|
||||||
|
constructor(private scene: Phaser.Scene) {
|
||||||
|
// 初始时不显示
|
||||||
|
}
|
||||||
|
|
||||||
|
show(message: string, duration: number = 2000): void {
|
||||||
|
// 清除之前的定时器
|
||||||
|
this.hideTimeout?.remove();
|
||||||
|
|
||||||
|
// 销毁之前的 overlay
|
||||||
|
if (this.overlay) {
|
||||||
|
this.overlay.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.overlay = this.scene.add.container();
|
||||||
|
|
||||||
|
// 半透明背景(可点击关闭)
|
||||||
|
const bg = this.scene.add.rectangle(
|
||||||
|
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
|
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
|
BOARD_SIZE * CELL_SIZE + 200,
|
||||||
|
BOARD_SIZE * CELL_SIZE + 200,
|
||||||
|
0x000000,
|
||||||
|
0.4,
|
||||||
|
).setInteractive();
|
||||||
|
|
||||||
|
bg.on('pointerdown', () => {
|
||||||
|
this.hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.overlay.add(bg);
|
||||||
|
|
||||||
|
// 错误提示框(居中显示)
|
||||||
|
const centerX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2;
|
||||||
|
const centerY = BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2;
|
||||||
|
const errorBox = this.scene.add.container(centerX, centerY);
|
||||||
|
|
||||||
|
// 错误框背景
|
||||||
|
const boxBg = this.scene.add.rectangle(0, 0, 450, 100, 0xef4444, 0.95)
|
||||||
|
.setStrokeStyle(4, 0xdc2626);
|
||||||
|
|
||||||
|
// 错误图标
|
||||||
|
const iconText = this.scene.add.text(-140, 0, '❌', {
|
||||||
|
fontSize: '36px',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
// 错误文本
|
||||||
|
const errorText = this.scene.add.text(60, 0, message, {
|
||||||
|
fontSize: '22px',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: '#ffffff',
|
||||||
|
align: 'center',
|
||||||
|
wordWrap: { width: 300 },
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
errorBox.add([boxBg, iconText, errorText]);
|
||||||
|
this.overlay.add(errorBox);
|
||||||
|
|
||||||
|
// 出现动画
|
||||||
|
errorBox.setScale(0);
|
||||||
|
errorBox.setAlpha(0);
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: errorBox,
|
||||||
|
scale: 1,
|
||||||
|
alpha: 1,
|
||||||
|
duration: 300,
|
||||||
|
ease: 'Back.easeOut',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自动隐藏
|
||||||
|
this.hideTimeout = this.scene.time.delayedCall(duration, () => {
|
||||||
|
this.hide();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hide(): void {
|
||||||
|
this.hideTimeout?.remove();
|
||||||
|
this.hideTimeout = undefined;
|
||||||
|
|
||||||
|
if (this.overlay) {
|
||||||
|
const overlay = this.overlay;
|
||||||
|
// 消失动画
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: overlay,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 200,
|
||||||
|
onComplete: () => {
|
||||||
|
overlay.destroy();
|
||||||
|
this.overlay = undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.hideTimeout?.remove();
|
||||||
|
this.overlay?.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import Phaser from 'phaser';
|
import type { BoopState } from '@/game';
|
||||||
import type { BoopState, PlayerType, PieceType } from '@/game/boop';
|
|
||||||
import { GameHostScene } from 'boardgame-phaser';
|
import { GameHostScene } from 'boardgame-phaser';
|
||||||
import { commands } from '@/game/boop';
|
import { prompts } from '@/game';
|
||||||
import { BoardRenderer } from './BoardRenderer';
|
import { BoardRenderer } from './BoardRenderer';
|
||||||
import { createPieceSpawner } from './PieceSpawner';
|
import { createPieceSpawner } from './PieceSpawner';
|
||||||
import { SupplyUI } from './SupplyUI';
|
import { SupplyUI } from './SupplyUI';
|
||||||
import { PieceTypeSelector } from './PieceTypeSelector';
|
import { PieceTypeSelector } from './PieceTypeSelector';
|
||||||
import { WinnerOverlay } from './WinnerOverlay';
|
import { WinnerOverlay } from './WinnerOverlay';
|
||||||
|
import { ErrorOverlay } from './ErrorOverlay';
|
||||||
|
|
||||||
export class GameScene extends GameHostScene<BoopState> {
|
export class GameScene extends GameHostScene<BoopState> {
|
||||||
private boardRenderer!: BoardRenderer;
|
private boardRenderer!: BoardRenderer;
|
||||||
private supplyUI!: SupplyUI;
|
private supplyUI!: SupplyUI;
|
||||||
private pieceTypeSelector!: PieceTypeSelector;
|
private pieceTypeSelector!: PieceTypeSelector;
|
||||||
private winnerOverlay!: WinnerOverlay;
|
private winnerOverlay!: WinnerOverlay;
|
||||||
|
private errorOverlay!: ErrorOverlay;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('GameScene');
|
super('GameScene');
|
||||||
@@ -25,18 +26,22 @@ export class GameScene extends GameHostScene<BoopState> {
|
|||||||
this.boardRenderer = new BoardRenderer(this);
|
this.boardRenderer = new BoardRenderer(this);
|
||||||
this.supplyUI = new SupplyUI(this);
|
this.supplyUI = new SupplyUI(this);
|
||||||
this.pieceTypeSelector = new PieceTypeSelector(this);
|
this.pieceTypeSelector = new PieceTypeSelector(this);
|
||||||
this.winnerOverlay = new WinnerOverlay(this, () => this.restartGame());
|
this.winnerOverlay = new WinnerOverlay(this, () => this.gameHost.start());
|
||||||
|
this.errorOverlay = new ErrorOverlay(this);
|
||||||
|
|
||||||
// 设置棋子生成器
|
// 设置棋子生成器
|
||||||
this.disposables.add(createPieceSpawner(this));
|
this.disposables.add(createPieceSpawner(this));
|
||||||
|
|
||||||
// 设置输入处理
|
// 设置输入处理(空单元格和已有棋子统一处理)
|
||||||
this.boardRenderer.setupInput(this.gameHost.state, (row, col) => {
|
this.boardRenderer.setupInput(
|
||||||
this.handleCellClick(row, col);
|
() => this.state,
|
||||||
});
|
(row, col) => this.handleCellClick(row, col),
|
||||||
|
(row, col) => this.handlePieceClick(row, col),
|
||||||
|
() => this.gameHost.status.value !== 'running' || !!this.state.winner
|
||||||
|
);
|
||||||
|
|
||||||
// 监听状态变化
|
// 监听胜负状态
|
||||||
this.watch(() => {
|
this.addEffect(() => {
|
||||||
const winner = this.state.winner;
|
const winner = this.state.winner;
|
||||||
if (winner) {
|
if (winner) {
|
||||||
this.winnerOverlay.show(winner);
|
this.winnerOverlay.show(winner);
|
||||||
@@ -45,27 +50,35 @@ export class GameScene extends GameHostScene<BoopState> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.watch(() => {
|
this.addEffect(() => {
|
||||||
const currentPlayer = this.state.currentPlayer;
|
const currentPlayer = this.state.currentPlayer;
|
||||||
this.boardRenderer.updateTurnText(currentPlayer, this.state);
|
this.boardRenderer.updateTurnText(currentPlayer, this.state);
|
||||||
this.supplyUI.update(this.state);
|
this.supplyUI.update(this.state);
|
||||||
this.pieceTypeSelector.update(this.state);
|
this.pieceTypeSelector.update(this.state);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 设置棋子类型选择器回调
|
// 启动游戏
|
||||||
// 可以在这里添加类型改变时的额外逻辑
|
this.gameHost.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleCellClick(row: number, col: number): void {
|
private handleCellClick(row: number, col: number): void {
|
||||||
const selectedType = this.pieceTypeSelector.getSelectedType();
|
const selectedType = this.pieceTypeSelector.getSelectedType();
|
||||||
const cmd = commands.play(this.state.currentPlayer, row, col, selectedType);
|
const error = this.gameHost.tryAnswerPrompt(prompts.play, this.state.currentPlayer, row, col, selectedType);
|
||||||
const error = this.gameHost.onInput(cmd);
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.warn('Invalid move:', error);
|
this.errorOverlay.show(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private restartGame(): void {
|
private handlePieceClick(row: number, col: number): void {
|
||||||
this.gameHost.setup('setup');
|
// 棋盘满时,点击棋子触发升级
|
||||||
|
const error = this.gameHost.tryAnswerPrompt(prompts.choose, this.state.currentPlayer, row, col);
|
||||||
|
if (error) {
|
||||||
|
this.errorOverlay.show(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跳转到菜单场景 */
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import type { BoopState, BoopPart } from '@/game/boop';
|
import type { BoopState, BoopPart } from '@/game';
|
||||||
import { GameHostScene, spawnEffect, type Spawner } from 'boardgame-phaser';
|
import { GameHostScene, spawnEffect, type Spawner } from 'boardgame-phaser';
|
||||||
import { BOARD_OFFSET, CELL_SIZE } from './BoardRenderer';
|
import { BOARD_OFFSET, CELL_SIZE } from './BoardRenderer';
|
||||||
|
|
||||||
@@ -8,7 +8,8 @@ class BoopPartSpawner implements Spawner<BoopPart, Phaser.GameObjects.Container>
|
|||||||
|
|
||||||
*getData() {
|
*getData() {
|
||||||
for (const part of Object.values(this.scene.state.pieces)) {
|
for (const part of Object.values(this.scene.state.pieces)) {
|
||||||
yield part;
|
if(part.regionId === 'board')
|
||||||
|
yield part;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,13 +22,14 @@ class BoopPartSpawner implements Spawner<BoopPart, Phaser.GameObjects.Container>
|
|||||||
const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2;
|
const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2;
|
||||||
const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2;
|
const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2;
|
||||||
|
|
||||||
this.scene.tweens.add({
|
const tween = this.scene.tweens.add({
|
||||||
targets: obj,
|
targets: obj,
|
||||||
x: x,
|
x: x,
|
||||||
y: y,
|
y: y,
|
||||||
duration: 200,
|
duration: 200,
|
||||||
ease: 'Power2',
|
ease: 'Power2',
|
||||||
});
|
});
|
||||||
|
this.scene.addTweenInterruption(tween);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSpawn(part: BoopPart) {
|
onSpawn(part: BoopPart) {
|
||||||
@@ -37,7 +39,7 @@ class BoopPartSpawner implements Spawner<BoopPart, Phaser.GameObjects.Container>
|
|||||||
|
|
||||||
const container = this.scene.add.container(x, y);
|
const container = this.scene.add.container(x, y);
|
||||||
|
|
||||||
const isCat = part.pieceType === 'cat';
|
const isCat = part.type === 'cat';
|
||||||
const baseColor = part.player === 'white' ? 0xffffff : 0x333333;
|
const baseColor = part.player === 'white' ? 0xffffff : 0x333333;
|
||||||
const strokeColor = part.player === 'white' ? 0x000000 : 0xffffff;
|
const strokeColor = part.player === 'white' ? 0x000000 : 0xffffff;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import type { BoopState, PlayerType, PieceType } from '@/game/boop';
|
import type { BoopState, PlayerType, PieceType, BoopPart } from '@/game';
|
||||||
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
|
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
|
||||||
|
|
||||||
export class PieceTypeSelector {
|
export class PieceTypeSelector {
|
||||||
@@ -11,18 +11,18 @@ export class PieceTypeSelector {
|
|||||||
|
|
||||||
constructor(private scene: Phaser.Scene) {
|
constructor(private scene: Phaser.Scene) {
|
||||||
const boardCenterX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2;
|
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);
|
this.container = this.scene.add.container(boardCenterX, selectorY);
|
||||||
|
|
||||||
const label = this.scene.add.text(-160, 0, '放置:', {
|
const label = this.scene.add.text(-160, 0, '放置:', {
|
||||||
fontSize: '18px',
|
fontSize: '16px',
|
||||||
fontFamily: 'Arial',
|
fontFamily: 'Arial',
|
||||||
color: '#4b5563',
|
color: '#4b5563',
|
||||||
}).setOrigin(0.5, 0.5);
|
}).setOrigin(0.5, 0.5);
|
||||||
|
|
||||||
this.kittenButton = this.createPieceButton('kitten', '🐾 小猫', -50);
|
this.kittenButton = this.createPieceButton('kitten', '🐾 小猫', -40);
|
||||||
this.catButton = this.createPieceButton('cat', '🐱 大猫', 70);
|
this.catButton = this.createPieceButton('cat', '🐱 大猫', 80);
|
||||||
|
|
||||||
this.container.add([label, this.kittenButton, this.catButton]);
|
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 {
|
private createPieceButton(type: PieceType, text: string, xOffset: number): Phaser.GameObjects.Container {
|
||||||
const container = this.scene.add.container(xOffset, 0);
|
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);
|
.setStrokeStyle(2, 0x9ca3af);
|
||||||
|
|
||||||
const textObj = this.scene.add.text(0, 0, text, {
|
const textObj = this.scene.add.text(0, 0, text, {
|
||||||
@@ -44,6 +44,8 @@ export class PieceTypeSelector {
|
|||||||
bg.setInteractive({ useHandCursor: true });
|
bg.setInteractive({ useHandCursor: true });
|
||||||
bg.on('pointerdown', () => {
|
bg.on('pointerdown', () => {
|
||||||
this.selectedType = type;
|
this.selectedType = type;
|
||||||
|
// 立即更新按钮高亮状态
|
||||||
|
this.updateButtonsVisual();
|
||||||
this.typeChangeCallback?.(this.selectedType);
|
this.typeChangeCallback?.(this.selectedType);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,27 +56,55 @@ export class PieceTypeSelector {
|
|||||||
this.typeChangeCallback = callback;
|
this.typeChangeCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 立即更新按钮的视觉高亮状态(不依赖游戏状态变化)
|
||||||
|
*/
|
||||||
|
private updateButtonsVisual(): void {
|
||||||
|
const kittenBg = this.kittenButton.list[0] as Phaser.GameObjects.Rectangle;
|
||||||
|
const kittenText = this.kittenButton.list[1] as Phaser.GameObjects.Text;
|
||||||
|
const catBg = this.catButton.list[0] as Phaser.GameObjects.Rectangle;
|
||||||
|
const catText = this.catButton.list[1] as Phaser.GameObjects.Text;
|
||||||
|
|
||||||
|
// 更新小猫按钮
|
||||||
|
kittenBg.setFillStyle(this.selectedType === 'kitten' ? 0xfbbf24 : 0xe5e7eb);
|
||||||
|
// 更新大猫按钮
|
||||||
|
catBg.setFillStyle(this.selectedType === 'cat' ? 0xfbbf24 : 0xe5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
getSelectedType(): PieceType {
|
getSelectedType(): PieceType {
|
||||||
return this.selectedType;
|
return this.selectedType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private countPieces(state: BoopState, player: PlayerType) {
|
||||||
|
const pieces = Object.values(state.pieces);
|
||||||
|
const playerPieces = pieces.filter((p: BoopPart) => p.player === player);
|
||||||
|
|
||||||
|
const kittensInSupply = playerPieces.filter((p: BoopPart) => p.type === 'kitten' && p.regionId === player).length;
|
||||||
|
const catsInSupply = playerPieces.filter((p: BoopPart) => p.type === 'cat' && p.regionId === player).length;
|
||||||
|
const catsOnBoard = playerPieces.filter((p: BoopPart) => p.type === 'cat' && p.regionId === 'board').length;
|
||||||
|
|
||||||
|
return { kittensInSupply, catsInSupply, catsOnBoard };
|
||||||
|
}
|
||||||
|
|
||||||
update(state: BoopState): void {
|
update(state: BoopState): void {
|
||||||
const currentPlayer = state.players[state.currentPlayer];
|
const currentPlayer = state.currentPlayer;
|
||||||
const kittenAvailable = currentPlayer.kittenPool.remaining() > 0;
|
const { kittensInSupply, catsInSupply } = this.countPieces(state, currentPlayer);
|
||||||
const catsAvailable = currentPlayer.catPool.remaining() + currentPlayer.graduatedCount > 0;
|
|
||||||
|
const kittenAvailable = kittensInSupply > 0;
|
||||||
|
const catsAvailable = catsInSupply > 0;
|
||||||
|
|
||||||
this.updateButton(
|
this.updateButton(
|
||||||
this.kittenButton,
|
this.kittenButton,
|
||||||
kittenAvailable,
|
kittenAvailable,
|
||||||
this.selectedType === 'kitten',
|
this.selectedType === 'kitten',
|
||||||
`🐾 小猫 (${currentPlayer.kittenPool.remaining()})`
|
`🐾 小猫 (${kittensInSupply})`
|
||||||
);
|
);
|
||||||
|
|
||||||
this.updateButton(
|
this.updateButton(
|
||||||
this.catButton,
|
this.catButton,
|
||||||
catsAvailable,
|
catsAvailable,
|
||||||
this.selectedType === 'cat',
|
this.selectedType === 'cat',
|
||||||
`🐱 大猫 (${currentPlayer.catPool.remaining() + currentPlayer.graduatedCount})`
|
`🐱 大猫 (${catsInSupply})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 自动切换到可用类型
|
// 自动切换到可用类型
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import Phaser from 'phaser';
|
||||||
|
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
|
||||||
|
|
||||||
|
export class StartOverlay {
|
||||||
|
private overlay?: Phaser.GameObjects.Container;
|
||||||
|
private onStartCallback?: () => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private scene: Phaser.Scene,
|
||||||
|
onStart: () => void
|
||||||
|
) {
|
||||||
|
this.onStartCallback = onStart;
|
||||||
|
this.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
show(): void {
|
||||||
|
if (this.overlay) {
|
||||||
|
this.overlay.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.overlay = this.scene.add.container();
|
||||||
|
|
||||||
|
// 背景遮罩
|
||||||
|
const bg = this.scene.add.rectangle(
|
||||||
|
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
|
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
|
BOARD_SIZE * CELL_SIZE + 200,
|
||||||
|
BOARD_SIZE * CELL_SIZE + 200,
|
||||||
|
0x000000,
|
||||||
|
0.7,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.overlay.add(bg);
|
||||||
|
|
||||||
|
// 游戏标题
|
||||||
|
const titleText = this.scene.add.text(
|
||||||
|
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
|
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2 - 80,
|
||||||
|
'🐱 BOOP 🐾',
|
||||||
|
{
|
||||||
|
fontSize: '60px',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: '#fbbf24',
|
||||||
|
fontStyle: 'bold',
|
||||||
|
},
|
||||||
|
).setOrigin(0.5);
|
||||||
|
|
||||||
|
this.overlay.add(titleText);
|
||||||
|
|
||||||
|
// 游戏说明
|
||||||
|
const rulesText = this.scene.add.text(
|
||||||
|
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
|
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2 - 10,
|
||||||
|
'将小猫或大猫放在棋盘上\n推动周围的小猫\n先连成3个猫的玩家获胜!',
|
||||||
|
{
|
||||||
|
fontSize: '20px',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
align: 'center',
|
||||||
|
lineSpacing: 10,
|
||||||
|
},
|
||||||
|
).setOrigin(0.5);
|
||||||
|
|
||||||
|
this.overlay.add(rulesText);
|
||||||
|
|
||||||
|
// 开始按钮
|
||||||
|
const buttonY = BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2 + 80;
|
||||||
|
const startButton = this.scene.add.container(
|
||||||
|
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
|
buttonY
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttonBg = this.scene.add.rectangle(0, 0, 200, 60, 0xfbbf24)
|
||||||
|
.setStrokeStyle(3, 0xf59e0b)
|
||||||
|
.setInteractive({ useHandCursor: true });
|
||||||
|
|
||||||
|
const buttonText = this.scene.add.text(0, 0, '开始游戏', {
|
||||||
|
fontSize: '24px',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: '#1f2937',
|
||||||
|
fontStyle: 'bold',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
startButton.add([buttonBg, buttonText]);
|
||||||
|
this.overlay.add(startButton);
|
||||||
|
|
||||||
|
// 按钮交互效果
|
||||||
|
buttonBg.on('pointerover', () => {
|
||||||
|
buttonBg.setFillStyle(0xfcd34d);
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonBg.on('pointerout', () => {
|
||||||
|
buttonBg.setFillStyle(0xfbbf24);
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonBg.on('pointerdown', () => {
|
||||||
|
buttonBg.setFillStyle(0xf59e0b);
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonBg.on('pointerup', () => {
|
||||||
|
buttonBg.setFillStyle(0xfcd34d);
|
||||||
|
this.onStartCallback?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 标题呼吸动画
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: titleText,
|
||||||
|
scale: 1.1,
|
||||||
|
duration: 1000,
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
ease: 'Sine.easeInOut',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hide(): void {
|
||||||
|
if (this.overlay) {
|
||||||
|
this.overlay.destroy();
|
||||||
|
this.overlay = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import type { BoopState, PlayerType } from '@/game/boop';
|
import type { BoopState, PlayerType, BoopPart } from '@/game';
|
||||||
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
|
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
|
||||||
|
|
||||||
export class SupplyUI {
|
export class SupplyUI {
|
||||||
@@ -12,13 +12,13 @@ export class SupplyUI {
|
|||||||
|
|
||||||
constructor(private scene: Phaser.Scene) {
|
constructor(private scene: Phaser.Scene) {
|
||||||
const boardCenterX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2;
|
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.whiteContainer = this.scene.add.container(boardCenterX - 160, uiY);
|
||||||
this.whiteBg = this.scene.add.rectangle(0, 0, 120, 50, 0x000000);
|
this.whiteBg = this.scene.add.rectangle(0, 0, 120, 40, 0x000000);
|
||||||
this.whiteText = this.scene.add.text(0, 0, '', {
|
this.whiteText = this.scene.add.text(0, 0, '', {
|
||||||
fontSize: '16px',
|
fontSize: '12px',
|
||||||
fontFamily: 'Arial',
|
fontFamily: 'Arial',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
@@ -27,10 +27,10 @@ export class SupplyUI {
|
|||||||
this.whiteContainer.setDepth(100);
|
this.whiteContainer.setDepth(100);
|
||||||
|
|
||||||
// 黑色玩家容器
|
// 黑色玩家容器
|
||||||
this.blackContainer = this.scene.add.container(boardCenterX + 150, uiY);
|
this.blackContainer = this.scene.add.container(boardCenterX + 160, uiY);
|
||||||
this.blackBg = this.scene.add.rectangle(0, 0, 120, 50, 0x333333);
|
this.blackBg = this.scene.add.rectangle(0, 0, 120, 40, 0x333333);
|
||||||
this.blackText = this.scene.add.text(0, 0, '', {
|
this.blackText = this.scene.add.text(0, 0, '', {
|
||||||
fontSize: '16px',
|
fontSize: '12px',
|
||||||
fontFamily: 'Arial',
|
fontFamily: 'Arial',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
@@ -39,19 +39,30 @@ export class SupplyUI {
|
|||||||
this.blackContainer.setDepth(100);
|
this.blackContainer.setDepth(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(state: BoopState): void {
|
private countPieces(state: BoopState, player: PlayerType) {
|
||||||
const white = state.players.white;
|
const pieces = Object.values(state.pieces);
|
||||||
const black = state.players.black;
|
const playerPieces = pieces.filter((p: BoopPart) => p.player === player);
|
||||||
|
|
||||||
const whiteCatsAvailable = white.catPool.remaining() + white.graduatedCount;
|
const kittensInSupply = playerPieces.filter((p: BoopPart) => p.type === 'kitten' && p.regionId === player).length;
|
||||||
const blackCatsAvailable = black.catPool.remaining() + black.graduatedCount;
|
const catsInSupply = playerPieces.filter((p: BoopPart) => p.type === 'cat' && p.regionId === player).length;
|
||||||
|
const catsOnBoard = playerPieces.filter((p: BoopPart) => p.type === 'cat' && p.regionId === 'board').length;
|
||||||
|
|
||||||
|
return { kittensInSupply, catsInSupply, catsOnBoard };
|
||||||
|
}
|
||||||
|
|
||||||
|
update(state: BoopState): void {
|
||||||
|
const white = this.countPieces(state, 'white');
|
||||||
|
const black = this.countPieces(state, 'black');
|
||||||
|
|
||||||
|
const whiteCatsAvailable = white.catsInSupply + white.catsOnBoard;
|
||||||
|
const blackCatsAvailable = black.catsInSupply + black.catsOnBoard;
|
||||||
|
|
||||||
this.whiteText.setText(
|
this.whiteText.setText(
|
||||||
`⚪ WHITE\n🐾 ${white.kittenPool.remaining()} | 🐱 ${whiteCatsAvailable}`
|
`⚪ WHITE\n🐾 ${white.kittensInSupply} | 🐱 ${whiteCatsAvailable}`
|
||||||
);
|
);
|
||||||
|
|
||||||
this.blackText.setText(
|
this.blackText.setText(
|
||||||
`⚫ BLACK\n🐾 ${black.kittenPool.remaining()} | 🐱 ${blackCatsAvailable}`
|
`⚫ BLACK\n🐾 ${black.kittensInSupply} | 🐱 ${blackCatsAvailable}`
|
||||||
);
|
);
|
||||||
|
|
||||||
this.updateHighlight(state.currentPlayer);
|
this.updateHighlight(state.currentPlayer);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import type { BoopState, WinnerType } from '@/game/boop';
|
import type { BoopState, WinnerType } from '@/game';
|
||||||
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
|
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
|
||||||
|
|
||||||
export class WinnerOverlay {
|
export class WinnerOverlay {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { GameScene } from './GameScene';
|
export { GameScene } from './GameScene';
|
||||||
|
export { MenuScene } from './MenuScene';
|
||||||
export { BoardRenderer, BOARD_SIZE, CELL_SIZE, BOARD_OFFSET } from './BoardRenderer';
|
export { BoardRenderer, BOARD_SIZE, CELL_SIZE, BOARD_OFFSET } from './BoardRenderer';
|
||||||
export { createPieceSpawner } from './PieceSpawner';
|
export { createPieceSpawner } from './PieceSpawner';
|
||||||
export { SupplyUI } from './SupplyUI';
|
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 { 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 } }) {
|
export default function App() {
|
||||||
|
const gameHost = useMemo(() => createGameHost(gameModule as unknown as GameModule<BoopState>), []);
|
||||||
const gameHost = useComputed(() => {
|
const gameScene = useMemo(() => new GameScene(), []);
|
||||||
const gameHost = createGameHost(props.gameModule);
|
const menuScene = useMemo(() => new MenuScene(), []);
|
||||||
return { gameHost };
|
|
||||||
});
|
|
||||||
|
|
||||||
const scene = useComputed(() => new props.gameScene());
|
|
||||||
|
|
||||||
const handleReset = async () => {
|
|
||||||
gameHost.value.gameHost.setup('setup');
|
|
||||||
};
|
|
||||||
const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen">
|
<div className="flex flex-col h-screen">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 flex relative justify-center items-center">
|
||||||
<PhaserGame config={{ width: 640, height: 750 }}>
|
<PhaserGame initialScene="MenuScene" config={{ width: 640, height: 720 }}>
|
||||||
<PhaserScene sceneKey="GameScene" scene={scene.value} autoStart data={gameHost.value} />
|
<PhaserScene sceneKey="MenuScene" scene={menuScene} />
|
||||||
|
<PhaserScene sceneKey="GameScene" scene={gameScene} data={{gameHost}}/>
|
||||||
</PhaserGame>
|
</PhaserGame>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { defineConfig } from 'vite';
|
|||||||
import preact from '@preact/preset-vite';
|
import preact from '@preact/preset-vite';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import {csvLoader} from 'inline-schema/csv-loader/rollup';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [preact(), tailwindcss()],
|
plugins: [preact(), tailwindcss(), csvLoader()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
'boardgame-phaser': path.resolve(__dirname, '../framework/src/index.ts'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
import Phaser from 'phaser';
|
|
||||||
import { effect, type Signal } from '@preact/signals-core';
|
|
||||||
import type { MutableSignal, Region, Part } from 'boardgame-core';
|
|
||||||
|
|
||||||
type DisposeFn = () => void;
|
|
||||||
|
|
||||||
export function bindSignal<T, K extends keyof T>(
|
|
||||||
signal: MutableSignal<T>,
|
|
||||||
getter: (state: T) => T[K],
|
|
||||||
setter: (value: T[K]) => void,
|
|
||||||
): DisposeFn {
|
|
||||||
return effect(() => {
|
|
||||||
const val = getter(signal.value);
|
|
||||||
setter(val);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bindGameObjectProperty<T>(
|
|
||||||
signal: Signal<T>,
|
|
||||||
target: Phaser.GameObjects.GameObject,
|
|
||||||
prop: string,
|
|
||||||
): DisposeFn {
|
|
||||||
return effect(() => {
|
|
||||||
(target as unknown as Record<string, unknown>)[prop] = signal.value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BindRegionOptions<TPart extends Part> {
|
|
||||||
cellSize: { x: number; y: number };
|
|
||||||
offset?: { x: number; y: number };
|
|
||||||
factory: (part: TPart, position: Phaser.Math.Vector2) => Phaser.GameObjects.GameObject;
|
|
||||||
update?: (part: TPart, obj: Phaser.GameObjects.GameObject) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bindRegion<TState, TMeta>(
|
|
||||||
state: MutableSignal<TState>,
|
|
||||||
partsGetter: (state: TState) => Record<string, Part<TMeta>>,
|
|
||||||
regionGetter: (state: TState) => Region,
|
|
||||||
options: BindRegionOptions<Part<TMeta>>,
|
|
||||||
container: Phaser.GameObjects.Container,
|
|
||||||
): { cleanup: () => void; objects: Map<string, Phaser.GameObjects.GameObject> } {
|
|
||||||
const objects = new Map<string, Phaser.GameObjects.GameObject>();
|
|
||||||
|
|
||||||
const offset = options.offset ?? { x: 0, y: 0 };
|
|
||||||
|
|
||||||
const dispose = effect(function(this: { dispose: () => void }) {
|
|
||||||
const currentState = state.value;
|
|
||||||
const parts = partsGetter(currentState);
|
|
||||||
const region = regionGetter(currentState);
|
|
||||||
const currentIds = new Set(region.childIds);
|
|
||||||
|
|
||||||
// 移除不在 region 中的对象
|
|
||||||
for (const [id, obj] of objects) {
|
|
||||||
if (!currentIds.has(id)) {
|
|
||||||
obj.destroy();
|
|
||||||
objects.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步 region 中的 parts
|
|
||||||
for (const childId of region.childIds) {
|
|
||||||
const part = parts[childId];
|
|
||||||
if (!part) continue;
|
|
||||||
|
|
||||||
// 支持动态维度:取前两个维度作为 x, y
|
|
||||||
// position[0] = row (y轴), position[1] = col (x轴)
|
|
||||||
const pos = new Phaser.Math.Vector2(
|
|
||||||
(part.position[1] ?? 0) * options.cellSize.x + offset.x,
|
|
||||||
(part.position[0] ?? 0) * options.cellSize.y + offset.y,
|
|
||||||
);
|
|
||||||
|
|
||||||
let obj = objects.get(childId);
|
|
||||||
if (!obj) {
|
|
||||||
obj = options.factory(part, pos);
|
|
||||||
objects.set(childId, obj);
|
|
||||||
container.add(obj);
|
|
||||||
} else {
|
|
||||||
// 更新位置
|
|
||||||
if ('setPosition' in obj && typeof obj.setPosition === 'function') {
|
|
||||||
(obj as any).setPosition(pos.x, pos.y);
|
|
||||||
}
|
|
||||||
// 调用自定义更新函数
|
|
||||||
if (options.update) {
|
|
||||||
options.update(part, obj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
cleanup: () => {
|
|
||||||
dispose();
|
|
||||||
for (const [, obj] of objects) obj.destroy();
|
|
||||||
objects.clear();
|
|
||||||
},
|
|
||||||
objects,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BindCollectionOptions<T extends { id: string }> {
|
|
||||||
factory: (item: T) => Phaser.GameObjects.GameObject;
|
|
||||||
update?: (item: T, obj: Phaser.GameObjects.GameObject) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bindCollection<T extends { id: string }>(
|
|
||||||
collection: Signal<Record<string, MutableSignal<T>>>,
|
|
||||||
options: BindCollectionOptions<T>,
|
|
||||||
container: Phaser.GameObjects.Container,
|
|
||||||
): { cleanup: () => void; objects: Map<string, Phaser.GameObjects.GameObject> } {
|
|
||||||
const objects = new Map<string, Phaser.GameObjects.GameObject>();
|
|
||||||
const effects: DisposeFn[] = [];
|
|
||||||
|
|
||||||
function syncCollection() {
|
|
||||||
const entries = Object.entries(collection.value);
|
|
||||||
const currentIds = new Set(entries.map(([id]) => id));
|
|
||||||
|
|
||||||
for (const [id, obj] of objects) {
|
|
||||||
if (!currentIds.has(id)) {
|
|
||||||
obj.destroy();
|
|
||||||
objects.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, signal] of entries) {
|
|
||||||
let obj = objects.get(id);
|
|
||||||
if (!obj) {
|
|
||||||
obj = options.factory(signal.value);
|
|
||||||
objects.set(id, obj);
|
|
||||||
container.add(obj);
|
|
||||||
} else if (options.update) {
|
|
||||||
options.update(signal.value, obj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const e = effect(syncCollection);
|
|
||||||
effects.push(e);
|
|
||||||
|
|
||||||
return {
|
|
||||||
cleanup: () => {
|
|
||||||
for (const e of effects) e();
|
|
||||||
for (const [, obj] of objects) obj.destroy();
|
|
||||||
objects.clear();
|
|
||||||
},
|
|
||||||
objects,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,39 @@
|
|||||||
// Resource management
|
// Resource management
|
||||||
export { DisposableBag } from './utils';
|
export { DisposableBag } from "./utils";
|
||||||
export type { IDisposable, DisposableItem } 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
|
// Data-driven object spawning
|
||||||
export { spawnEffect } from './spawner';
|
export { spawnEffect, createSpawnUpdate } from "./spawner";
|
||||||
export type { Spawner } from './spawner';
|
export type { Spawner, SpawnerCallback } from "./spawner";
|
||||||
|
|
||||||
// Scene base classes
|
// Scene base classes
|
||||||
export { GameHostScene } from './scenes';
|
export {
|
||||||
export type { GameHostSceneOptions } from './scenes';
|
ReactiveScene,
|
||||||
|
GameHostScene,
|
||||||
|
FadeScene,
|
||||||
|
FADE_SCENE_KEY,
|
||||||
|
} from "./scenes";
|
||||||
|
export type {
|
||||||
|
ReactiveSceneOptions,
|
||||||
|
ReactiveScenePhaserData,
|
||||||
|
SceneController,
|
||||||
|
GameHostSceneOptions,
|
||||||
|
FadeSceneData,
|
||||||
|
} from "./scenes";
|
||||||
|
|
||||||
// React ↔ Phaser bridge
|
// React ↔ Phaser bridge
|
||||||
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig, GameUI } from './ui';
|
export {
|
||||||
export type { PhaserGameProps, PhaserSceneProps, GameUIOptions } from './ui';
|
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,50 +1,43 @@
|
|||||||
import Phaser from 'phaser';
|
import { ReactiveScene } from "./ReactiveScene";
|
||||||
import { effect, type ReadonlySignal } from '@preact/signals-core';
|
|
||||||
import type { GameHost } from 'boardgame-core';
|
|
||||||
import { DisposableBag, type IDisposable } from '../utils';
|
|
||||||
|
|
||||||
type CleanupFn = void | (() => void);
|
import type { GameHost, GameModule } from "boardgame-core";
|
||||||
|
|
||||||
export interface GameHostSceneOptions<TState extends Record<string, unknown>> {
|
export interface GameHostSceneOptions<
|
||||||
gameHost: GameHost<TState>;
|
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>>
|
export abstract class GameHostScene<
|
||||||
extends Phaser.Scene
|
TState extends Record<string, unknown>,
|
||||||
implements IDisposable
|
TResult,
|
||||||
{
|
TModule extends GameModule<TState, TResult>,
|
||||||
protected disposables = new DisposableBag();
|
> extends ReactiveScene<GameHostSceneOptions<TState, TResult, TModule>> {
|
||||||
protected gameHost!: GameHost<TState>;
|
public get gameHost(): GameHost<TState, TResult, TModule> {
|
||||||
|
const gameHost = this.initData.gameHost;
|
||||||
addInterruption(promise: Promise<void>){
|
if (!gameHost) {
|
||||||
this.gameHost?.addInterruption(promise);
|
throw new Error(
|
||||||
|
`GameHostScene (${this.scene.key}): gameHost 未提供。` +
|
||||||
|
"确保在 PhaserScene 组件的 data 属性中传入 gameHost。",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return gameHost;
|
||||||
|
}
|
||||||
|
|
||||||
addTweenInterruption(tween: Phaser.Tweens.Tween){
|
public get state(): TState {
|
||||||
this.addInterruption(new Promise(
|
return this.gameHost?.state.value;
|
||||||
resolve => tween.once('complete', resolve)
|
}
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
init(data: GameHostSceneOptions<TState>): void {
|
addInterruption(promise: Promise<void>) {
|
||||||
this.gameHost = data.gameHost;
|
this.gameHost?.addInterruption(promise);
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
addTweenInterruption(tween: Phaser.Tweens.Tween) {
|
||||||
this.events.on('shutdown', this.dispose, this);
|
this.addInterruption(
|
||||||
}
|
new Promise((resolve) => tween.once("complete", resolve)),
|
||||||
|
);
|
||||||
dispose(): void {
|
}
|
||||||
this.disposables.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取当前状态的只读快照 */
|
|
||||||
public get state(): TState {
|
|
||||||
return this.gameHost.state.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 注册响应式监听(场景关闭时自动清理) */
|
|
||||||
protected watch(fn: () => CleanupFn): void {
|
|
||||||
this.disposables.add(effect(fn));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ReactiveScene } from "./ReactiveScene";
|
||||||
export type { GameHostSceneOptions } from './GameHostScene';
|
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,49 +1,70 @@
|
|||||||
import Phaser from 'phaser';
|
import { effect } from "@preact/signals-core";
|
||||||
import { effect, type ReadonlySignal } from '@preact/signals-core';
|
|
||||||
|
import type Phaser from "phaser";
|
||||||
|
|
||||||
type GO = Phaser.GameObjects.GameObject;
|
type GO = Phaser.GameObjects.GameObject;
|
||||||
|
|
||||||
export interface Spawner<TData, TObject extends GO = GO> {
|
export interface SpawnerCallback<TData, TObject extends GO = GO> {
|
||||||
/** 数据源迭代器 */
|
/** 获取数据的唯一键 */
|
||||||
getData(): Iterable<TData>;
|
getKey(t: TData): string;
|
||||||
/** 获取数据的唯一键 */
|
/** 创建新对象 */
|
||||||
getKey(t: TData): string;
|
onSpawn(t: TData): TObject | null;
|
||||||
/** 创建新对象 */
|
/** 销毁旧对象 */
|
||||||
onSpawn(t: TData): TObject | null;
|
onDespawn(obj: TObject, t: TData): void;
|
||||||
/** 销毁旧对象 */
|
/** 更新已有对象 */
|
||||||
onDespawn(obj: TObject): void;
|
onUpdate(t: TData, obj: TObject): 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>(
|
export function spawnEffect<TData, TObject extends GO = GO>(
|
||||||
spawner: Spawner<TData, TObject>,
|
spawner: Spawner<TData, TObject>,
|
||||||
): () => void {
|
) {
|
||||||
const objects = new Map<string, TObject>();
|
const update = createSpawnUpdate(spawner);
|
||||||
|
|
||||||
return effect(() => {
|
return effect(() => {
|
||||||
const current = new Set<string>();
|
update(spawner.getData());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const t of spawner.getData()) {
|
export function createSpawnUpdate<TData, TObject extends GO = GO>(
|
||||||
const key = spawner.getKey(t);
|
spawner: SpawnerCallback<TData, TObject>,
|
||||||
current.add(key);
|
) {
|
||||||
|
const entries = new Map<string, { object: TObject; data: TData }>();
|
||||||
|
|
||||||
const existing = objects.get(key);
|
function update(data: Iterable<TData>) {
|
||||||
if (!existing) {
|
const current = new Set<string>();
|
||||||
const obj = spawner.onSpawn(t);
|
|
||||||
if (obj) {
|
for (const t of data) {
|
||||||
objects.set(key, obj);
|
const key = spawner.getKey(t);
|
||||||
}
|
current.add(key);
|
||||||
} else {
|
|
||||||
spawner.onUpdate(t, existing);
|
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) {
|
for (const [key, entry] of entries) {
|
||||||
if (!current.has(key)) {
|
if (!current.has(key)) {
|
||||||
spawner.onDespawn(obj);
|
spawner.onDespawn(entry.object, entry.data);
|
||||||
objects.delete(key);
|
entries.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
return update;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,40 @@
|
|||||||
import { h, Fragment } from 'preact';
|
import type { JSX } from "preact";
|
||||||
import { effect } from '@preact/signals-core';
|
|
||||||
|
|
||||||
type DisposeFn = () => void;
|
|
||||||
|
|
||||||
export interface GameUIOptions {
|
export interface GameUIOptions {
|
||||||
container: HTMLElement;
|
container: HTMLElement | string;
|
||||||
root: any;
|
root: JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GameUI {
|
export class GameUI {
|
||||||
private container: HTMLElement;
|
private container: HTMLElement;
|
||||||
private root: any;
|
private root: JSX.Element;
|
||||||
private effects: DisposeFn[] = [];
|
|
||||||
|
|
||||||
constructor(options: GameUIOptions) {
|
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;
|
this.root = options.root;
|
||||||
}
|
}
|
||||||
|
|
||||||
mount(): void {
|
mount(): void {
|
||||||
import('preact').then(({ render }) => {
|
import("preact").then(({ render }) => {
|
||||||
render(this.root, this.container);
|
render(this.root, this.container);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(fn: () => void): DisposeFn {
|
|
||||||
const e = effect(fn);
|
|
||||||
this.effects.push(e);
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
unmount(): void {
|
unmount(): void {
|
||||||
import('preact').then(({ render }) => {
|
import("preact").then(({ render }) => {
|
||||||
render(null, this.container);
|
render(null, this.container);
|
||||||
});
|
});
|
||||||
for (const e of this.effects) {
|
|
||||||
e();
|
|
||||||
}
|
|
||||||
this.effects = [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +1,247 @@
|
|||||||
import Phaser from 'phaser';
|
import { signal, useSignal, useSignalEffect } from "@preact/signals";
|
||||||
import { signal, useSignal, useSignalEffect, type Signal } from '@preact/signals';
|
import Phaser, { AUTO, Scene } from "phaser";
|
||||||
import { createContext, h } from 'preact';
|
import { createContext } from "preact";
|
||||||
import { useContext } from 'preact/hooks';
|
import { useContext, useEffect, useRef } from "preact/hooks";
|
||||||
import {ReadonlySignal} from "@preact/signals-core";
|
|
||||||
|
|
||||||
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 = {
|
export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
|
||||||
type: Phaser.AUTO,
|
type: AUTO,
|
||||||
width: 560,
|
width: 560,
|
||||||
height: 560,
|
height: 560,
|
||||||
parent: 'phaser-container',
|
parent: "phaser-container",
|
||||||
backgroundColor: '#f9fafb',
|
backgroundColor: "#f9fafb",
|
||||||
scene: [],
|
scene: [],
|
||||||
|
disableContextMenu: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface PhaserGameProps {
|
export interface PhaserGameProps {
|
||||||
config?: Partial<Phaser.Types.Core.GameConfig>;
|
config?: Partial<Phaser.Types.Core.GameConfig>;
|
||||||
children?: any;
|
/** 初始启动的场景 key */
|
||||||
|
initialScene?: string;
|
||||||
|
children?: preact.ComponentChildren;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PhaserGame(props: PhaserGameProps) {
|
export function PhaserGame(props: PhaserGameProps) {
|
||||||
const gameSignal = useSignal<Phaser.Game>();
|
const gameSignal = useSignal<PhaserGameContext>({
|
||||||
|
game: undefined!,
|
||||||
|
sceneController: undefined!,
|
||||||
|
});
|
||||||
|
const initialSceneLaunched = useRef(false);
|
||||||
|
|
||||||
useSignalEffect(() => {
|
useSignalEffect(() => {
|
||||||
const config: Phaser.Types.Core.GameConfig = {
|
const config: Phaser.Types.Core.GameConfig = {
|
||||||
...defaultPhaserConfig,
|
...defaultPhaserConfig,
|
||||||
...props.config,
|
...props.config,
|
||||||
};
|
};
|
||||||
const phaserGame = new Phaser.Game(config);
|
const phaserGame = new Phaser.Game(config);
|
||||||
gameSignal.value = phaserGame;
|
|
||||||
|
|
||||||
return () => {
|
// 添加 FadeScene 并启动它来初始化 overlay
|
||||||
gameSignal.value = undefined;
|
const fadeScene = new FadeSceneClass();
|
||||||
phaserGame.destroy(true);
|
phaserGame.scene.add(FADE_SCENE_KEY, fadeScene, true); // 改为 true 以触发 create
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
// 创建 SceneController
|
||||||
<div id="phaser-container" className="w-full h-full">
|
const currentScene = signal<string | null>(null);
|
||||||
<phaserContext.Provider value={gameSignal}>
|
const isTransitioning = signal(false);
|
||||||
{props.children}
|
|
||||||
</phaserContext.Provider>
|
const sceneController: SceneController = {
|
||||||
</div>
|
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 {
|
export interface PhaserSceneProps<TData = {}> {
|
||||||
sceneKey: string;
|
sceneKey?: string;
|
||||||
scene: Phaser.Scene;
|
scene: ReactiveScene<TData> | { new (): ReactiveScene<TData> };
|
||||||
autoStart: boolean;
|
data?: TData;
|
||||||
data?: object;
|
children?: any;
|
||||||
children?: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const phaserSceneContext = createContext<ReadonlySignal<Phaser.Scene | undefined>>(signal(undefined));
|
export const phaserSceneContext =
|
||||||
export function PhaserScene(props: PhaserSceneProps) {
|
createContext<ReadonlySignal<ReactiveScene> | null>(null);
|
||||||
const context = useContext(phaserContext);
|
|
||||||
const sceneSignal = useSignal<Phaser.Scene>();
|
|
||||||
|
|
||||||
useSignalEffect(() => {
|
export function PhaserScene<TData = {}>(props: PhaserSceneProps<TData>) {
|
||||||
const game = context.value;
|
const phaserGameSignal = useContext(phaserContext);
|
||||||
if (!game) return;
|
const sceneSignal = useSignal<ReactiveScene<TData>>();
|
||||||
|
const registered = useRef(false);
|
||||||
|
|
||||||
game.scene.add(props.sceneKey, props.scene, props.autoStart, props.data);
|
useSignalEffect(() => {
|
||||||
sceneSignal.value = game.scene.getScene(props.sceneKey);
|
if (!phaserGameSignal) return;
|
||||||
return () => {
|
const ctx = phaserGameSignal.value;
|
||||||
sceneSignal.value = undefined;
|
if (!ctx?.game) return;
|
||||||
game.scene.remove(props.sceneKey);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
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 { GameUI } from "./GameUI";
|
||||||
export type { GameUIOptions } from './GameUI';
|
export type { GameUIOptions } from "./GameUI";
|
||||||
|
|
||||||
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig } from './PhaserBridge';
|
export {
|
||||||
export type { PhaserGameProps, PhaserSceneProps } from './PhaserBridge';
|
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 {
|
export interface IDisposable {
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DisposableItem = IDisposable | (() => void);
|
export type DisposableItem = IDisposable | (() => void);
|
||||||
|
|
||||||
export class DisposableBag implements IDisposable {
|
export class DisposableBag implements IDisposable {
|
||||||
private _disposables = new Set<DisposableItem>();
|
private _disposables = new Set<DisposableItem>();
|
||||||
private _isDisposed = false;
|
private _isDisposed = false;
|
||||||
|
|
||||||
get isDisposed(): boolean {
|
constructor(go?: Phaser.GameObjects.GameObject) {
|
||||||
return this._isDisposed;
|
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 {
|
this._disposables.clear();
|
||||||
if (this._isDisposed) {
|
}
|
||||||
this._execute(item);
|
|
||||||
return;
|
private _execute(item: DisposableItem): void {
|
||||||
}
|
if (typeof item === "function") {
|
||||||
this._disposables.add(item);
|
item();
|
||||||
}
|
} else {
|
||||||
|
item.dispose();
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { DisposableBag } from "./disposable";
|
||||||
export type { IDisposable, DisposableItem } 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,161 +1,19 @@
|
|||||||
import {
|
/**
|
||||||
createGameCommandRegistry,
|
* Re-export tic-tac-toe game module from boardgame-core
|
||||||
type Part,
|
* This provides a convenient import path within sample-game
|
||||||
createRegion,
|
*/
|
||||||
type MutableSignal,
|
export {
|
||||||
type 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 function createInitialState() {
|
|
||||||
return {
|
|
||||||
board: createRegion('board', [
|
|
||||||
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
|
||||||
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
|
||||||
]),
|
|
||||||
parts: {} as Record<string, TicTacToePart>,
|
|
||||||
currentPlayer: 'X' as PlayerType,
|
|
||||||
winner: null as WinnerType,
|
|
||||||
turn: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export type TicTacToeState = ReturnType<typeof createInitialState>;
|
|
||||||
|
|
||||||
const registration = createGameCommandRegistry<TicTacToeState>();
|
|
||||||
export const registry = registration.registry;
|
|
||||||
|
|
||||||
export const gameModule: GameModule<TicTacToeState> = {
|
|
||||||
registry,
|
registry,
|
||||||
|
prompts,
|
||||||
|
start,
|
||||||
createInitialState,
|
createInitialState,
|
||||||
};
|
isCellOccupied,
|
||||||
|
hasWinningLine,
|
||||||
registration.add('setup', async function () {
|
checkWinner,
|
||||||
const { context } = this;
|
placePiece,
|
||||||
while (true) {
|
type TicTacToePart,
|
||||||
const currentPlayer = context.value.currentPlayer;
|
type TicTacToeState,
|
||||||
const turnNumber = context.value.turn + 1;
|
type TicTacToeGame,
|
||||||
const turnOutput = await this.run<{ winner: WinnerType }>(`turn ${currentPlayer} ${turnNumber}`);
|
type PlayerType,
|
||||||
if (!turnOutput.success) throw new Error(turnOutput.error);
|
type WinnerType,
|
||||||
|
} from "boardgame-core/samples/tic-tac-toe";
|
||||||
context.produce(state => {
|
|
||||||
state.winner = turnOutput.result.winner;
|
|
||||||
if (!state.winner) {
|
|
||||||
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
|
|
||||||
state.turn = turnNumber;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (context.value.winner) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
registration.add('reset', async function () {
|
|
||||||
const { context } = this;
|
|
||||||
context.produce(state => {
|
|
||||||
state.parts = {};
|
|
||||||
state.board.childIds = [];
|
|
||||||
state.board.partMap = {};
|
|
||||||
state.currentPlayer = 'X';
|
|
||||||
state.winner = null;
|
|
||||||
state.turn = 0;
|
|
||||||
});
|
|
||||||
// 重启主循环
|
|
||||||
return this.run('setup');
|
|
||||||
});
|
|
||||||
|
|
||||||
registration.add('turn <player> <turn:number>', async function (cmd) {
|
|
||||||
const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number];
|
|
||||||
|
|
||||||
const playCmd = await this.prompt(
|
|
||||||
'play <player> <row:number> <col:number>',
|
|
||||||
(command) => {
|
|
||||||
const [player, row, col] = command.params as [PlayerType, number, number];
|
|
||||||
|
|
||||||
if (player !== turnPlayer) {
|
|
||||||
return `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
|
||||||
}
|
|
||||||
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
|
|
||||||
return `Invalid position: (${row}, ${col}).`;
|
|
||||||
}
|
|
||||||
const state = this.context.value;
|
|
||||||
const partId = state.board.partMap[`${row},${col}`];
|
|
||||||
if (partId) {
|
|
||||||
return `Cell (${row}, ${col}) is already occupied.`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const [player, row, col] = playCmd.params as [PlayerType, number, number];
|
|
||||||
|
|
||||||
placePiece(this.context, row, col, turnPlayer);
|
|
||||||
|
|
||||||
const winner = checkWinner(this.context);
|
|
||||||
if (winner) return { winner };
|
|
||||||
if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType };
|
|
||||||
|
|
||||||
return { winner: null };
|
|
||||||
});
|
|
||||||
|
|
||||||
export function isCellOccupied(host: MutableSignal<TicTacToeState>, row: number, col: number): boolean {
|
|
||||||
return !!host.value.board.partMap[`${row},${col}`];
|
|
||||||
}
|
|
||||||
|
|
||||||
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: MutableSignal<TicTacToeState>): WinnerType {
|
|
||||||
const parts = host.value.parts;
|
|
||||||
|
|
||||||
const xPositions = Object.values(parts).filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position);
|
|
||||||
const oPositions = Object.values(parts).filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position);
|
|
||||||
|
|
||||||
if (hasWinningLine(xPositions)) return 'X';
|
|
||||||
if (hasWinningLine(oPositions)) return 'O';
|
|
||||||
if (Object.keys(parts).length >= MAX_TURNS) return 'draw';
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function placePiece(host: MutableSignal<TicTacToeState>, row: number, col: number, player: PlayerType) {
|
|
||||||
const moveNumber = Object.keys(host.value.parts).length + 1;
|
|
||||||
const piece: TicTacToePart = {
|
|
||||||
id: `piece-${player}-${moveNumber}`,
|
|
||||||
regionId: 'board',
|
|
||||||
position: [row, col],
|
|
||||||
player,
|
|
||||||
};
|
|
||||||
host.produce(state => {
|
|
||||||
state.parts[piece.id] = piece;
|
|
||||||
state.board.childIds.push(piece.id);
|
|
||||||
state.board.partMap[`${row},${col}`] = piece.id;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 命令构建器:类型安全地生成命令字符串 */
|
|
||||||
export const commands = {
|
|
||||||
play: (player: PlayerType, row: number, col: number) => `play ${player} ${row} ${col}`,
|
|
||||||
turn: (player: PlayerType, turn: number) => `turn ${player} ${turn}`,
|
|
||||||
} as const;
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { GameUI } from 'boardgame-phaser';
|
import { GameUI } from 'boardgame-phaser';
|
||||||
import { gameModule } from './game/tic-tac-toe';
|
|
||||||
import './style.css';
|
import './style.css';
|
||||||
import App from "@/ui/App";
|
import App from "@/ui/App";
|
||||||
import {GameScene} from "@/scenes/GameScene";
|
|
||||||
|
|
||||||
const ui = new GameUI({
|
const ui = new GameUI({
|
||||||
container: document.getElementById('ui-root')!,
|
container: document.getElementById('ui-root')!,
|
||||||
root: <App gameModule={gameModule} gameScene={GameScene}/>,
|
root: <App/>,
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.mount();
|
ui.mount();
|
||||||
|
|||||||
@@ -2,18 +2,41 @@ import Phaser from 'phaser';
|
|||||||
import type {TicTacToeState, TicTacToePart} from '@/game/tic-tac-toe';
|
import type {TicTacToeState, TicTacToePart} from '@/game/tic-tac-toe';
|
||||||
import { GameHostScene } from 'boardgame-phaser';
|
import { GameHostScene } from 'boardgame-phaser';
|
||||||
import { spawnEffect, type Spawner } from 'boardgame-phaser';
|
import { spawnEffect, type Spawner } from 'boardgame-phaser';
|
||||||
import type { ReadonlySignal } from '@preact/signals-core';
|
import {prompts} from "@/game/tic-tac-toe";
|
||||||
import {commands} from "@/game/tic-tac-toe";
|
|
||||||
|
|
||||||
const CELL_SIZE = 120;
|
// 棋盘配置常量
|
||||||
const BOARD_OFFSET = { x: 100, y: 100 };
|
export const BOARD_CONFIG = {
|
||||||
const BOARD_SIZE = 3;
|
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> {
|
export class GameScene extends GameHostScene<TicTacToeState> {
|
||||||
private boardContainer!: Phaser.GameObjects.Container;
|
private boardContainer!: Phaser.GameObjects.Container;
|
||||||
private gridGraphics!: Phaser.GameObjects.Graphics;
|
private gridGraphics!: Phaser.GameObjects.Graphics;
|
||||||
private turnText!: Phaser.GameObjects.Text;
|
private turnText!: Phaser.GameObjects.Text;
|
||||||
private winnerOverlay?: Phaser.GameObjects.Container;
|
private winnerOverlay?: Phaser.GameObjects.Container;
|
||||||
|
private menuButtonContainer!: Phaser.GameObjects.Container;
|
||||||
|
private menuButtonBg!: Phaser.GameObjects.Rectangle;
|
||||||
|
private menuButtonText!: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('GameScene');
|
super('GameScene');
|
||||||
@@ -24,11 +47,14 @@ export class GameScene extends GameHostScene<TicTacToeState> {
|
|||||||
|
|
||||||
this.boardContainer = this.add.container(0, 0);
|
this.boardContainer = this.add.container(0, 0);
|
||||||
this.gridGraphics = this.add.graphics();
|
this.gridGraphics = this.add.graphics();
|
||||||
this.drawGrid();
|
|
||||||
|
|
||||||
this.disposables.add(spawnEffect(new TicTacToePartSpawner(this, this.gameHost.state)));
|
this.createBoardVisuals();
|
||||||
|
this.createMenuButton();
|
||||||
|
this.createInputZones();
|
||||||
|
|
||||||
this.watch(() => {
|
this.disposables.add(spawnEffect(new TicTacToePartSpawner(this)));
|
||||||
|
|
||||||
|
this.addEffect(() => {
|
||||||
const winner = this.state.winner;
|
const winner = this.state.winner;
|
||||||
if (winner) {
|
if (winner) {
|
||||||
this.showWinner(winner);
|
this.showWinner(winner);
|
||||||
@@ -38,82 +64,141 @@ export class GameScene extends GameHostScene<TicTacToeState> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.watch(() => {
|
this.addEffect(() => {
|
||||||
const currentPlayer = this.state.currentPlayer;
|
const currentPlayer = this.state.currentPlayer;
|
||||||
this.updateTurnText(currentPlayer);
|
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++) {
|
private drawGrid(): void {
|
||||||
for (let col = 0; col < BOARD_SIZE; col++) {
|
const g = this.gridGraphics;
|
||||||
const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2;
|
const { boardSize, cellSize, boardOffset } = BOARD_CONFIG;
|
||||||
const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2;
|
|
||||||
|
|
||||||
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', () => {
|
zone.on('pointerdown', () => {
|
||||||
if (this.state.winner) return;
|
this.handleCellClick(row, col);
|
||||||
if (this.isCellOccupied(row, col)) return;
|
|
||||||
|
|
||||||
const cmd = commands.play(this.state.currentPlayer, row, col);
|
|
||||||
const error = this.gameHost.onInput(cmd);
|
|
||||||
if (error) {
|
|
||||||
console.warn('Invalid move:', error);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawGrid(): void {
|
/** 处理格子点击 */
|
||||||
const g = this.gridGraphics;
|
private handleCellClick(row: number, col: number): void {
|
||||||
g.lineStyle(3, 0x6b7280);
|
if (this.state.winner) return;
|
||||||
|
if (this.isCellOccupied(row, col)) return;
|
||||||
|
|
||||||
for (let i = 1; i < BOARD_SIZE; i++) {
|
const error = this.gameHost.tryAnswerPrompt(prompts.play, this.state.currentPlayer, row, col);
|
||||||
g.lineBetween(
|
if (error) {
|
||||||
BOARD_OFFSET.x + i * CELL_SIZE,
|
console.warn('Invalid move:', error);
|
||||||
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(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 {
|
private updateTurnText(player: string): void {
|
||||||
if (this.turnText) {
|
if (this.turnText) {
|
||||||
this.turnText.setText(`${player}'s turn`);
|
this.turnText.setText(`${player}'s turn`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 显示获胜者 */
|
||||||
private showWinner(winner: string): void {
|
private showWinner(winner: string): void {
|
||||||
// 清理旧的覆盖层防止叠加
|
// 清理旧的覆盖层防止叠加
|
||||||
if (this.winnerOverlay) {
|
if (this.winnerOverlay) {
|
||||||
@@ -123,32 +208,30 @@ export class GameScene extends GameHostScene<TicTacToeState> {
|
|||||||
this.winnerOverlay = this.add.container();
|
this.winnerOverlay = this.add.container();
|
||||||
|
|
||||||
const text = winner === 'draw' ? "It's a draw!" : `${winner} wins!`;
|
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(
|
const bg = this.add.rectangle(
|
||||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
centerX,
|
||||||
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
centerY,
|
||||||
BOARD_SIZE * CELL_SIZE,
|
boardSize * cellSize,
|
||||||
BOARD_SIZE * CELL_SIZE,
|
boardSize * cellSize,
|
||||||
0x000000,
|
BOARD_CONFIG.colors.overlay,
|
||||||
0.6,
|
0.6,
|
||||||
).setInteractive({ useHandCursor: true });
|
).setInteractive({ useHandCursor: true });
|
||||||
|
|
||||||
bg.on('pointerdown', () => {
|
bg.on('pointerdown', () => {
|
||||||
this.gameHost.setup('setup');
|
this.gameHost.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.winnerOverlay.add(bg);
|
this.winnerOverlay.add(bg);
|
||||||
|
|
||||||
const winText = this.add.text(
|
const winText = this.add.text(centerX, centerY, text, {
|
||||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
fontSize: BOARD_CONFIG.fontSize.winText,
|
||||||
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
fontFamily: 'Arial',
|
||||||
text,
|
color: BOARD_CONFIG.colors.winText,
|
||||||
{
|
}).setOrigin(0.5);
|
||||||
fontSize: '36px',
|
|
||||||
fontFamily: 'Arial',
|
|
||||||
color: '#fbbf24',
|
|
||||||
},
|
|
||||||
).setOrigin(0.5);
|
|
||||||
|
|
||||||
this.winnerOverlay.add(winText);
|
this.winnerOverlay.add(winText);
|
||||||
|
|
||||||
@@ -160,34 +243,39 @@ export class GameScene extends GameHostScene<TicTacToeState> {
|
|||||||
repeat: 1,
|
repeat: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 跳转到菜单场景 */
|
||||||
|
private async goToMenu(): Promise<void> {
|
||||||
|
await this.sceneController.launch('MenuScene');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 棋子生成器 */
|
||||||
class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.Text> {
|
class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.Text> {
|
||||||
constructor(public readonly scene: GameScene, public readonly state: ReadonlySignal<TicTacToeState>) {}
|
constructor(public readonly scene: GameScene) {}
|
||||||
|
|
||||||
*getData() {
|
*getData() {
|
||||||
for (const part of Object.values(this.state.value.parts)) {
|
for (const part of Object.values(this.scene.state.parts)) {
|
||||||
yield part;
|
yield part;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getKey(part: TicTacToePart): string {
|
getKey(part: TicTacToePart): string {
|
||||||
return part.id;
|
return part.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
onUpdate(part: TicTacToePart, obj: Phaser.GameObjects.Text): void {
|
onUpdate(part: TicTacToePart, obj: Phaser.GameObjects.Text): void {
|
||||||
const [yIndex, xIndex] = part.position;
|
this.updatePosition(part, obj);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSpawn(part: TicTacToePart) {
|
onSpawn(part: TicTacToePart) {
|
||||||
const [yIndex, xIndex] = part.position;
|
const { cellSize, boardOffset } = BOARD_CONFIG;
|
||||||
const x = xIndex * CELL_SIZE + BOARD_OFFSET.x;
|
const pos = this.calculatePosition(part.position);
|
||||||
const y = yIndex * CELL_SIZE + BOARD_OFFSET.y;
|
|
||||||
const text = this.scene.add.text(x + CELL_SIZE / 2, y + CELL_SIZE / 2, part.player, {
|
const text = this.scene.add.text(pos.x, pos.y, part.player, {
|
||||||
fontSize: '64px',
|
fontSize: BOARD_CONFIG.fontSize.cell,
|
||||||
fontFamily: 'Arial',
|
fontFamily: 'Arial',
|
||||||
color: part.player === 'X' ? '#3b82f6' : '#ef4444',
|
color: part.player === 'X' ? BOARD_CONFIG.colors.x : BOARD_CONFIG.colors.o,
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
// 添加落子动画
|
// 添加落子动画
|
||||||
@@ -211,4 +299,21 @@ class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.
|
|||||||
onComplete: () => obj.destroy(),
|
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,38 +1,24 @@
|
|||||||
import {useComputed} from '@preact/signals';
|
import { h } from 'preact';
|
||||||
import { createGameHost, type GameModule } from 'boardgame-core';
|
import {PhaserGame, PhaserScene } from 'boardgame-phaser';
|
||||||
import Phaser from 'phaser';
|
import {MenuScene} from "@/scenes/MenuScene";
|
||||||
import { h } from 'preact';
|
import {useMemo} from "preact/hooks";
|
||||||
import { PhaserGame, PhaserScene } from 'boardgame-phaser';
|
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 } }) {
|
export default function App() {
|
||||||
|
const gameHost = useMemo(() => createGameHost(gameModule), []);
|
||||||
const gameHost = useComputed(() => {
|
const gameScene = useMemo(() => new GameScene(), []);
|
||||||
const gameHost = createGameHost(props.gameModule);
|
const menuScene = useMemo(() => new MenuScene(), []);
|
||||||
return { gameHost };
|
|
||||||
});
|
|
||||||
|
|
||||||
const scene = useComputed(() => new props.gameScene());
|
|
||||||
|
|
||||||
const handleReset = async () => {
|
|
||||||
gameHost.value.gameHost.setup('setup');
|
|
||||||
};
|
|
||||||
const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen">
|
<div className="flex flex-col h-screen">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 flex relative justify-center items-center">
|
||||||
<PhaserGame>
|
<PhaserGame initialScene="MenuScene">
|
||||||
<PhaserScene sceneKey="GameScene" scene={scene.value} autoStart data={gameHost.value} />
|
<PhaserScene sceneKey="MenuScene" scene={menuScene} />
|
||||||
|
<PhaserScene sceneKey="GameScene" scene={gameScene} data={{gameHost}}/>
|
||||||
</PhaserGame>
|
</PhaserGame>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src'),
|
'@': 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user