Compare commits

...

5 Commits

Author SHA1 Message Date
hypercross 00fd395873 fix: more issues with ui 2026-04-08 11:06:34 +08:00
hypercross 2beff5c75c fix: fix ui bugs 2026-04-08 08:57:59 +08:00
hypercross d4819f7cc3 feat: more onitama goodness 2026-04-07 17:46:37 +08:00
hypercross 8bbf20f457 feat: onitama 2026-04-07 16:33:41 +08:00
hypercross 27e6d52cf3 docs: update docs 2026-04-07 16:29:08 +08:00
18 changed files with 1097 additions and 108 deletions

125
QWEN.md
View File

@ -17,113 +17,6 @@
`boardgame-core`的内容可以在`framework/node_modules/boardgame-core`找到。
这个文件夹被.gitignore忽略查看时需要绕开这一限制。
## 编写GameModule
游戏逻辑以GameModule的形式定义
```typescript
import {createGameCommandRegistry, IGameContext} from "boardgame-core";
// 创建mutative游戏初始状态
export function createInitialState(): GameState {
//...
}
// 运行游戏
export async function start(game: IGameContext<GameState>) {
// ...
}
// 可选
export const registry = createGameCommandRegistry();
```
使用以下步骤创建GameModule
### 1. 定义状态
通常使用一个`regions: Record<string, Region>`和一个`parts: Record<string, Part<TMeta>>`来记录桌游物件的摆放。
```typescript
import {Region} from "boardgame-core";
type GameState = {
regions: {
white: Region,
black: Region,
board: Region,
},
pieces: Record<string, Part<PartsTable['0']>>,
currentPlayer: PlayerType,
winner: WinnerType,
};
```
游戏的部件可以从`csv`加载。详情见`boop-game/node_modules/inline-schema/`。
```
/// parts.csv
type, player, count
string, string, number
cat, white, 8
cat, black, 8
/// parts.csv.d.ts
type PartsTable = {
type: string;
player: string;
count: number;
}[];
declare const data: PartsTable;
export default data;
```
### 2. 定义流程
使用`async function start(game: IGameContext<GameState>)`作为入口。
使用`game.value`读取游戏当前状态
```typescript
async function gameEnd(game: IGameContext<GameState>) {
return game.value.winner;
}
```
需要修改状态时,使用`game.produce`或`game.produceAsync`。
```typescript
async function start(game: IGameContext<GameState>) {
await game.produceAsync(state => {
state.currentPlayer = 'white';
});
}
```
需要等待玩家交互时,使用`await game.prompt(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`来模拟游戏环境。
## 编写Phaser App
使用`framework/src/ui/PhaserBridge`来创建Phaser App。
@ -136,6 +29,9 @@ async function turn(game: IGameContext<GameState>, currentPlayer: PlayerType) {
export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
// 获取游戏状态的只读快照
get state(): TState{}
// 获取随机数
get rng(): ReadonlyRNG{}
// 运行状态
readonly status: ReadonlySignal<GameHostStatus>;
@ -151,7 +47,7 @@ export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
addInterruption(promise: Promise<void>): void {}
// 开始或者重新开始游戏
start(): Promise<TResult>{}
start(seed?: number): Promise<TResult>{}
// 销毁
dispose(): void {}
@ -159,4 +55,17 @@ export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
// 事件侦听
on(event: 'start' | 'dispose', listener: () => void): () => void {}
}
```
使用`Spawner`来创建动态游戏对象:
- 卡牌
- 棋子
- UI高亮提示对象
使用`MutableSignal`创建状态信号。
```typescript
import {MutableSignal} from "boardgame-core"
export const x = MutableSignal(0);
x.produce(x => x + 1);
```

107
docs/GameModule.md Normal file
View File

@ -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`来模拟游戏环境。

View File

@ -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>

View File

@ -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"
}
}

View File

@ -0,0 +1 @@
export * from "boardgame-core/samples/onitama";

View File

@ -0,0 +1,13 @@
import { h } from 'preact';
import { GameUI } from 'boardgame-phaser';
import * as gameModule from './game/onitama';
import './style.css';
import App from "@/ui/App";
import {OnitamaScene} from "@/scenes/OnitamaScene";
const ui = new GameUI({
container: document.getElementById('ui-root')!,
root: <App gameModule={gameModule} gameScene={OnitamaScene}/>,
});
ui.mount();

View File

@ -0,0 +1,287 @@
import Phaser from 'phaser';
import type { OnitamaState, Pawn } from '@/game/onitama';
import {getAvailableMoves, prompts} from '@/game/onitama';
import { GameHostScene } from 'boardgame-phaser';
import { spawnEffect } from 'boardgame-phaser';
import type { MutableSignal } from 'boardgame-core';
import {
PawnSpawner, CardSpawner, BOARD_OFFSET, CELL_SIZE, CARD_WIDTH, CARD_HEIGHT, boardToScreen, BOARD_SIZE,
HighlightSpawner
} from '@/spawners';
import type { HighlightData } from '@/spawners/HighlightSpawner';
import {createUIState, clearSelection, selectPiece, selectCard, createValidMoves} from '@/state';
import type { OnitamaUIState, ValidMove } 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();
// 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(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 30,
'',
{
fontSize: '20px',
fontFamily: 'Arial',
color: '#4b5563',
}
).setOrigin(0.5);
// Update info text when UI state changes
this.addEffect(() => {
this.updateInfoText();
});
// Input handling
this.setupInput();
}
private createCardLabels(): void {
const boardLeft = BOARD_OFFSET.x;
const boardTop = BOARD_OFFSET.y;
const boardRight = BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE;
// Red cards label
const redLabel = this.add.text(
boardLeft - CARD_WIDTH - 60 + CARD_WIDTH / 2,
boardTop + 50 + 2 * (CARD_HEIGHT + 15) + 10,
"RED's Cards",
{
fontSize: '16px',
fontFamily: 'Arial',
color: '#ef4444',
}
).setOrigin(0.5, 0);
this.cardLabelContainers.set('red', redLabel);
// Black cards label
const blackLabel = this.add.text(
boardRight + 60 + CARD_WIDTH / 2,
boardTop + 50 + 2 * (CARD_HEIGHT + 15) + 10,
"BLACK's Cards",
{
fontSize: '16px',
fontFamily: 'Arial',
color: '#3b82f6',
}
).setOrigin(0.5, 0);
this.cardLabelContainers.set('black', blackLabel);
// Spare card label
const boardCenterX = boardLeft + (BOARD_SIZE * CELL_SIZE) / 2;
const spareLabel = this.add.text(
boardCenterX,
boardTop - 50,
'Spare Card',
{
fontSize: '16px',
fontFamily: 'Arial',
color: '#6b7280',
}
).setOrigin(0.5, 0);
this.cardLabelContainers.set('spare', spareLabel);
}
private updateInfoText(): void {
const currentPlayer = this.state.currentPlayer;
const selectedCard = this.uiState.value.selectedCard;
const selectedPiece = this.uiState.value.selectedPiece;
if (this.state.winner) {
this.infoText.setText(`${this.state.winner} wins!`);
} else if (!selectedCard) {
this.infoText.setText(`${currentPlayer}'s turn - Select a card first`);
} else if (!selectedPiece) {
this.infoText.setText(`Card: ${selectedCard} - Select a piece to move`);
} else {
this.infoText.setText(`${currentPlayer}'s turn (Turn ${this.state.turn + 1})`);
}
}
private drawBoard(): void {
const g = this.gridGraphics;
g.lineStyle(2, 0x6b7280);
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(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y - 40,
'Onitama',
{
fontSize: '28px',
fontFamily: 'Arial',
color: '#1f2937',
}
).setOrigin(0.5);
}
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 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 bg = this.add.rectangle(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_SIZE * CELL_SIZE,
BOARD_SIZE * CELL_SIZE,
0x000000,
0.6
).setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => {
this.gameHost.start();
});
this.winnerOverlay.add(bg);
const winText = this.add.text(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
text,
{
fontSize: '36px',
fontFamily: 'Arial',
color: '#fbbf24',
}
).setOrigin(0.5);
this.winnerOverlay.add(winText);
this.tweens.add({
targets: winText,
scale: 1.2,
duration: 500,
yoyo: true,
repeat: 1,
});
}
}

View File

@ -0,0 +1,242 @@
import Phaser from 'phaser';
import type { Card } from '@/game/onitama';
import type { Spawner } from 'boardgame-phaser';
import type { OnitamaScene } from '@/scenes/OnitamaScene';
import { BOARD_OFFSET, CELL_SIZE } from './PawnSpawner';
import {effect} from "@preact/signals-core";
export const CARD_WIDTH = 100;
export const CARD_HEIGHT = 140;
const BOARD_SIZE = 5;
export interface CardSpawnData {
cardId: string;
position: 'red' | 'black' | 'spare';
index: number;
}
export class CardSpawner implements Spawner<CardSpawnData, Phaser.GameObjects.Container> {
private previousData = new Map<string, CardSpawnData>();
constructor(public readonly scene: OnitamaScene) {}
*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 };
}
// 备用卡牌
yield { cardId: state.spareCard, position: 'spare', index: 0 };
}
getKey(data: CardSpawnData): string {
return data.cardId;
}
private getCardPosition(data: CardSpawnData): { x: number, y: number } {
const boardLeft = BOARD_OFFSET.x;
const boardTop = BOARD_OFFSET.y;
const boardRight = BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE;
const boardCenterX = boardLeft + (BOARD_SIZE * CELL_SIZE) / 2;
if (data.position === 'red') {
return {
x: boardLeft - CARD_WIDTH - 60 + 60,
y: boardTop + 80 + data.index * (CARD_HEIGHT + 15),
};
} else if (data.position === 'black') {
return {
x: boardRight + 60 + 40,
y: boardTop + 80 + data.index * (CARD_HEIGHT + 15),
};
} else {
return {
x: boardCenterX,
y: boardTop - CARD_HEIGHT - 20,
};
}
}
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: Phaser.GameObjects.Container): void {
// 只在位置实际变化时才播放移动动画
if (!this.hasPositionChanged(data)) {
this.previousData.set(data.cardId, { ...data });
return;
}
const pos = this.getCardPosition(data);
// 播放移动动画并添加中断
const tween = this.scene.tweens.add({
targets: obj,
x: pos.x,
y: pos.y,
duration: 350,
ease: 'Back.easeOut',
});
this.scene.addTweenInterruption(tween);
this.previousData.set(data.cardId, { ...data });
}
private highlightCard(container: Phaser.GameObjects.Container, color: number, lineWidth: number): void {
// 检查是否已经有高亮边框
let highlight = container.list.find(
child => child instanceof Phaser.GameObjects.Rectangle && child.getData('isHighlight')
) as Phaser.GameObjects.Rectangle;
if (!highlight) {
// 创建高亮边框
highlight = this.scene.add.rectangle(0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0)
.setStrokeStyle(lineWidth, color)
.setData('isHighlight', true);
container.addAt(highlight, 0);
} else {
// 更新现有高亮边框
highlight.setStrokeStyle(lineWidth, color);
highlight.setAlpha(1);
}
}
private unhighlightCard(container: Phaser.GameObjects.Container): void {
const highlight = container.list.find(
child => child instanceof Phaser.GameObjects.Rectangle && child.getData('isHighlight')
) as Phaser.GameObjects.Rectangle;
if (highlight) {
highlight.setAlpha(0);
}
}
onSpawn(data: CardSpawnData): Phaser.GameObjects.Container {
const card = this.scene.state.cards[data.cardId];
if (!card) {
this.previousData.set(data.cardId, { ...data });
return this.scene.add.container(0, 0);
}
const container = this.scene.add.container(0, 0);
const pos = this.getCardPosition(data);
container.x = pos.x;
container.y = pos.y;
// 创建卡牌视觉
const cardVisual = this.createCardVisual(card);
container.add(cardVisual);
// 使卡牌可点击(设置矩形点击区域)
const hitArea = new Phaser.Geom.Rectangle(-CARD_WIDTH / 2, -CARD_HEIGHT / 2, CARD_WIDTH, CARD_HEIGHT);
container.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
// 悬停效果
container.on('pointerover', () => {
if (this.scene.uiState.value.selectedCard !== data.cardId) {
container.setAlpha(0.8);
}
});
container.on('pointerout', () => {
container.setAlpha(1);
});
container.on('pointerdown', () => {
this.scene.onCardClick(data.cardId);
});
// 初始状态为透明,然后淡入
container.setAlpha(0);
const tween = this.scene.tweens.add({
targets: container,
alpha: 1,
duration: 300,
ease: 'Power2',
});
this.scene.addTweenInterruption(tween);
container.once('destroy', effect(() => {
if(this.scene.uiState.value.selectedCard === data.cardId)
this.highlightCard(container, 0xfbbf24, 3);
else
this.unhighlightCard(container);
}));
this.previousData.set(data.cardId, { ...data });
return container;
}
onDespawn(obj: Phaser.GameObjects.Container): void {
const tween = this.scene.tweens.add({
targets: obj,
alpha: 0,
scale: 0.8,
duration: 200,
ease: 'Power2',
onComplete: () => obj.destroy(),
});
this.scene.addTweenInterruption(tween);
}
private createCardVisual(card: Card): Phaser.GameObjects.Container {
const container = this.scene.add.container(0, 0);
const bg = this.scene.add.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, 0xf9fafb, 1)
.setStrokeStyle(2, 0x6b7280);
container.add(bg);
const title = this.scene.add.text(0, -CARD_HEIGHT / 2 + 15, card.id, {
fontSize: '12px',
fontFamily: 'Arial',
color: '#1f2937',
}).setOrigin(0.5);
container.add(title);
const grid = this.scene.add.graphics();
const cellSize = 16;
const gridWidth = 5 * cellSize;
const gridHeight = 5 * cellSize;
const gridStartX = -gridWidth / 2;
const gridStartY = -gridHeight / 2 + 30;
for (let row = 0; row < 5; row++) {
for (let col = 0; col < 5; col++) {
const x = gridStartX + col * cellSize;
const y = gridStartY + row * cellSize;
if (row === 2 && col === 2) {
grid.fillStyle(0x3b82f6, 1);
grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3);
} else {
const isTarget = card.moveCandidates.some(m => m.dx === col - 2 && m.dy === 2 - row);
if (isTarget) {
grid.fillStyle(0xef4444, 0.6);
grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3);
}
}
}
}
container.add(grid);
const playerText = this.scene.add.text(0, CARD_HEIGHT / 2 - 15, card.startingPlayer, {
fontSize: '10px',
fontFamily: 'Arial',
color: '#6b7280',
}).setOrigin(0.5);
container.add(playerText);
return container;
}
}

View File

@ -0,0 +1,92 @@
import Phaser from 'phaser';
import type { Spawner } from 'boardgame-phaser';
import type { OnitamaScene } from '@/scenes/OnitamaScene';
import {getAvailableMoves} from "boardgame-core/samples/onitama";
import {boardToScreen, CELL_SIZE} from './PawnSpawner';
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> {
constructor(public readonly scene: OnitamaScene) {}
*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);
// 过滤出符合当前选择的移动
const filteredMoves = availableMoves.filter(move =>
move.fromX === uiState.selectedPiece!.x &&
move.fromY === uiState.selectedPiece!.y &&
move.card === uiState.selectedCard
);
for(const move of filteredMoves){
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);
const circle = this.scene.add.circle(
0,
0,
CELL_SIZE / 3,
0x3b82f6,
0.3
);
container.add(circle);
const hitArea = new Phaser.Geom.Circle(0, 0, CELL_SIZE / 3);
container.setInteractive(hitArea, Phaser.Geom.Circle.Contains);
if (container.input) {
container.input.cursor = 'pointer';
}
container.on('pointerdown', () => {
this.scene.onHighlightClick(data);
});
return container;
}
onDespawn(obj: Phaser.GameObjects.Container): void {
obj.destroy();
}
}

View File

@ -0,0 +1,110 @@
import Phaser from 'phaser';
import type { Pawn } from '@/game/onitama';
import type { Spawner } from 'boardgame-phaser';
import type { OnitamaScene } from '@/scenes/OnitamaScene';
export const CELL_SIZE = 80;
export const BOARD_OFFSET = { x: 200, y: 180 };
export const BOARD_SIZE = 5;
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,
};
}
export class PawnSpawner implements Spawner<Pawn, Phaser.GameObjects.Container> {
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: Phaser.GameObjects.Container): void {
const [x, y] = pawn.position;
const prevPos = this.previousPositions.get(pawn.id);
const hasMoved = !prevPos || prevPos[0] !== x || prevPos[1] !== y;
if (hasMoved && prevPos) {
// 播放移动动画并添加中断
const targetPos = boardToScreen(x, y);
const tween = this.scene.tweens.add({
targets: obj,
x: targetPos.x,
y: targetPos.y,
duration: 400,
ease: 'Back.easeOut',
});
this.scene.addTweenInterruption(tween);
} else if (!prevPos) {
// 初次生成,直接设置位置
const pos = boardToScreen(x, y);
obj.x = pos.x;
obj.y = pos.y;
}
this.previousPositions.set(pawn.id, [x, y]);
}
onSpawn(pawn: Pawn) {
const container = this.scene.add.container(0, 0);
const bgColor = pawn.owner === 'red' ? 0xef4444 : 0x3b82f6;
const circle = this.scene.add.circle(0, 0, CELL_SIZE / 3, bgColor, 1)
.setStrokeStyle(2, 0x1f2937);
container.add(circle);
const label = pawn.type === 'master' ? 'M' : 'S';
const text = this.scene.add.text(0, 0, label, {
fontSize: '24px',
fontFamily: 'Arial',
color: '#ffffff',
}).setOrigin(0.5);
container.add(text);
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);
this.scene.tweens.add({
targets: container,
scale: 1,
duration: 300,
ease: 'Back.easeOut',
});
return container;
}
onDespawn(obj: Phaser.GameObjects.Container) {
// 播放消失动画并添加中断
const tween = this.scene.tweens.add({
targets: obj,
scale: 0,
alpha: 0,
y: obj.y - 30,
duration: 300,
ease: 'Back.easeIn',
onComplete: () => obj.destroy(),
});
this.scene.addTweenInterruption(tween);
}
}

View File

@ -0,0 +1,3 @@
export { PawnSpawner, CELL_SIZE, BOARD_OFFSET, BOARD_SIZE, boardToScreen } from './PawnSpawner';
export { CardSpawner, CARD_WIDTH, CARD_HEIGHT, type CardSpawnData } from './CardSpawner';
export { HighlightSpawner, type HighlightData } from './HighlightSpawner';

View File

@ -0,0 +1,2 @@
export { createUIState, clearSelection, selectPiece, selectCard, createValidMoves } from './ui';
export type { OnitamaUIState, ValidMove } from './ui';

View File

@ -0,0 +1,70 @@
import { MutableSignal, mutableSignal, computed, ReadonlySignal } from 'boardgame-core';
import {getAvailableMoves, 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;
}
});
}

View File

@ -0,0 +1 @@
@import "tailwindcss";

View File

@ -0,0 +1,43 @@
import {useComputed} from '@preact/signals';
import { createGameHost } from 'boardgame-core';
import Phaser from 'phaser';
import { h } from 'preact';
import { PhaserGame, PhaserScene } from 'boardgame-phaser';
export default function App(props: { gameModule: any, gameScene: { new(): Phaser.Scene } }) {
const gameHost = useComputed(() => {
const gameHost = createGameHost(props.gameModule);
return { gameHost };
});
const scene = useComputed(() => new props.gameScene());
const handleReset = () => {
gameHost.value.gameHost.start();
};
const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');
const phaserConfig: Partial<Phaser.Types.Core.GameConfig> = {
width: 800,
height: 700,
};
return (
<div className="flex flex-col h-screen">
<div className="p-4 bg-gray-100 border-t border-gray-200">
<button
onClick={handleReset}
className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
>
{label}
</button>
</div>
<div className="flex-1 flex relative justify-center items-center">
<PhaserGame config={phaserConfig}>
<PhaserScene sceneKey="OnitamaScene" scene={scene.value} autoStart data={gameHost.value} />
</PhaserGame>
</div>
</div>
);
}

View File

@ -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/**/*"]
}

View File

@ -0,0 +1,13 @@
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'),
},
},
});

View File

@ -88,6 +88,46 @@ importers:
specifier: ^3.2.4
version: 3.2.4(lightningcss@1.32.0)
packages/onitama-game:
dependencies:
'@preact/signals-core':
specifier: ^1.5.1
version: 1.14.1
boardgame-core:
specifier: link:../../../boardgame-core
version: link:../../../boardgame-core
boardgame-phaser:
specifier: workspace:*
version: link:../framework
mutative:
specifier: ^1.3.0
version: 1.3.0
phaser:
specifier: ^3.80.1
version: 3.90.0
preact:
specifier: ^10.19.3
version: 10.29.0
devDependencies:
'@preact/preset-vite':
specifier: ^2.8.1
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(lightningcss@1.32.0))
'@preact/signals':
specifier: ^2.9.0
version: 2.9.0(preact@10.29.0)
'@tailwindcss/vite':
specifier: ^4.0.0
version: 4.2.2(vite@5.4.21(lightningcss@1.32.0))
tailwindcss:
specifier: ^4.0.0
version: 4.2.2
typescript:
specifier: ^5.3.3
version: 5.9.3
vite:
specifier: ^5.1.0
version: 5.4.21(lightningcss@1.32.0)
packages/regicide-game:
dependencies:
'@preact/signals-core':