diff --git a/.gitignore b/.gitignore index bd6f7ae..b567e3c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ packages/sample-game/src/**/*.js packages/sample-game/src/**/*.js.map packages/sample-game/src/**/*.d.ts packages/sample-game/src/**/*.d.ts.map +/packages/regicide-game/debug diff --git a/QWEN.md b/QWEN.md new file mode 100644 index 0000000..d70804b --- /dev/null +++ b/QWEN.md @@ -0,0 +1,54 @@ +# 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忽略,查看时需要绕开这一限制。 + +## 编写游戏 + +游戏逻辑以gameModule的形式定义: + +```typescript +import {createGameCommandRegistry, IGameContext} from "boardgame-core"; + +// 创建mutative游戏初始状态 +export function createInitialState(): GameState { + //... +} + +// 运行游戏 +export async function start(game: IGameContext) { + // ... +} + +// 可选 +export const registry = createGameCommandRegistry(); +``` + +使用以下步骤创建游戏: + +### 1. 定义状态 + +游戏状态使用一个大的`mutative`对象来描述,所有的状态更新通过`game.produce`或`game.produceAsync`来实现。 + +通常使用一个`regions: Record`和一个`parts: Record>`来记录桌游物件的摆放。 + +### 2. 定义流程 + +使用`async function start(game: IGameContext)`作为入口。 + +需要等待玩家交互时,使用`await game.prompt(schema, validator, player)`。 + diff --git a/packages/boop-game/src/game/commands.ts b/packages/boop-game/src/game/commands.ts index d422a0d..15750f0 100644 --- a/packages/boop-game/src/game/commands.ts +++ b/packages/boop-game/src/game/commands.ts @@ -24,7 +24,7 @@ export const registry = createGameCommandRegistry(); /** * 放置棋子到棋盘 */ -async function place(game: BoopGame, row: number, col: number, player: PlayerType, type: PieceType) { +async function handlePlace(game: BoopGame, row: number, col: number, player: PlayerType, type: PieceType) { const value = game.value; // 从玩家supply中找到对应类型的棋子 const part = findPartInRegion(game, player, type); @@ -43,12 +43,12 @@ async function place(game: BoopGame, row: number, col: number, player: PlayerTyp return { row, col, player, type, partId }; } -const placeCommand = registry.register( 'place ', place); +const place = registry.register( 'place ', handlePlace); /** * 执行boop - 推动周围棋子 */ -async function boop(game: BoopGame, row: number, col: number, type: PieceType) { +async function handleBoop(game: BoopGame, row: number, col: number, type: PieceType) { const booped: string[] = []; const toRemove = new Set(); @@ -95,12 +95,12 @@ async function boop(game: BoopGame, row: number, col: number, type: PieceType) { return { booped }; } -const boopCommand = registry.register('boop ', boop); +const boop = registry.register('boop ', handleBoop); /** * 检查是否有玩家获胜(三个猫连线) */ -async function checkWin(game: BoopGame) { +async function handleCheckWin(game: BoopGame) { for(const line of getLineCandidates()){ let whites = 0; let blacks = 0; @@ -119,12 +119,12 @@ async function checkWin(game: BoopGame) { } return null; } -const checkWinCommand = registry.register('check-win', checkWin); +const checkWin = registry.register('check-win', handleCheckWin); /** * 检查并执行小猫升级(三个小猫连线变成猫) */ -async function checkGraduates(game: BoopGame){ +async function handleCheckGraduates(game: BoopGame){ const toUpgrade = new Set(); for(const line of getLineCandidates()){ let whites = 0; @@ -155,12 +155,12 @@ async function checkGraduates(game: BoopGame){ } }); } -const checkGraduatesCommand = registry.register('check-graduates', checkGraduates); +const checkGraduates = registry.register('check-graduates', handleCheckGraduates); -async function setup(game: BoopGame) { +export async function start(game: BoopGame) { while (true) { const currentPlayer = game.value.currentPlayer; - const { winner } = await turnCommand(game, currentPlayer); + const { winner } = await turn(game, currentPlayer); await game.produceAsync((state: BoopState) => { state.winner = winner; @@ -173,9 +173,8 @@ async function setup(game: BoopGame) { return game.value; } -registry.register('setup', setup); -async function checkFullBoard(game: BoopGame, turnPlayer: PlayerType){ +async function handleCheckFullBoard(game: BoopGame, turnPlayer: PlayerType){ // 检查8-piece规则: 如果玩家所有8个棋子都在棋盘上且没有获胜,强制升级一个小猫 const playerPieces = Object.values(game.value.pieces).filter( (p: BoopPart) => p.player === turnPlayer && p.regionId === 'board' @@ -186,8 +185,7 @@ async function checkFullBoard(game: BoopGame, turnPlayer: PlayerType){ const partId = await game.prompt( 'play [type:string]', - (command: Command) => { - const [player, row, col] = command.params as [PlayerType, number, number]; + (player: PlayerType, row: number, col: number, type?: PieceType) => { if (player !== turnPlayer) { throw `无效的玩家: ${player},期望的是 ${turnPlayer}。`; } @@ -211,12 +209,12 @@ async function checkFullBoard(game: BoopGame, turnPlayer: PlayerType){ moveToRegion(cat || part, null, state.regions[turnPlayer]); }); } +const checkFullBoard = registry.register('check-full-board', handleCheckFullBoard); -async function turn(game: BoopGame, turnPlayer: PlayerType) { +async function handleTurn(game: BoopGame, turnPlayer: PlayerType) { const {row, col, type} = await game.prompt( 'play [type:string]', - (command: Command) => { - const [player, row, col, type] = command.params as [PlayerType, number, number, PieceType?]; + (player: PlayerType, row: number, col: number, type?: PieceType) => { const pieceType = type === 'cat' ? 'cat' : 'kitten'; if (player !== turnPlayer) { @@ -239,17 +237,18 @@ async function turn(game: BoopGame, turnPlayer: PlayerType) { ); const pieceType = type === 'cat' ? 'cat' : 'kitten'; - await placeCommand(game, row, col, turnPlayer, pieceType); - await boopCommand(game, row, col, pieceType); - const winner = await checkWinCommand(game); + await place(game, row, col, turnPlayer, pieceType); + await boop(game, row, col, pieceType); + const winner = await checkWin(game); if(winner) return { winner: winner as WinnerType }; - await checkGraduatesCommand(game); - await checkFullBoard(game, turnPlayer); + await checkGraduates(game); + await handleCheckFullBoard(game, turnPlayer); return { winner: null }; } -const turnCommand = registry.register('turn ', turn); -export const commands = { +const turn = registry.register('turn ', handleTurn); + +export const prompts = { play: (player: PlayerType, row: number, col: number, type?: PieceType) => { if (type) { return `play ${player} ${row} ${col} ${type}`; diff --git a/packages/boop-game/src/scenes/GameScene.ts b/packages/boop-game/src/scenes/GameScene.ts index 58a9b1f..c0fc3f9 100644 --- a/packages/boop-game/src/scenes/GameScene.ts +++ b/packages/boop-game/src/scenes/GameScene.ts @@ -1,6 +1,6 @@ import type { BoopState } from '@/game'; import { GameHostScene } from 'boardgame-phaser'; -import { commands } from '@/game'; +import { prompts } from '@/game'; import { BoardRenderer } from './BoardRenderer'; import { createPieceSpawner } from './PieceSpawner'; import { SupplyUI } from './SupplyUI'; @@ -73,7 +73,7 @@ export class GameScene extends GameHostScene { private handleCellClick(row: number, col: number): void { const selectedType = this.pieceTypeSelector.getSelectedType(); - const cmd = commands.play(this.state.currentPlayer, row, col, selectedType); + const cmd = prompts.play(this.state.currentPlayer, row, col, selectedType); const error = this.gameHost.onInput(cmd); if (error) { this.errorOverlay.show(error); @@ -82,7 +82,7 @@ export class GameScene extends GameHostScene { private handlePieceClick(row: number, col: number): void { // 棋盘满时,点击棋子触发升级 - const cmd = commands.play(this.state.currentPlayer, row, col); + const cmd = prompts.play(this.state.currentPlayer, row, col); const error = this.gameHost.onInput(cmd); if (error) { this.errorOverlay.show(error); @@ -90,10 +90,10 @@ export class GameScene extends GameHostScene { } private startGame(): void { - this.gameHost.setup('setup'); + this.gameHost.start(); } private restartGame(): void { - this.gameHost.setup('setup'); + this.gameHost.start(); } } diff --git a/packages/boop-game/src/ui/App.tsx b/packages/boop-game/src/ui/App.tsx index 94638b6..c97c57a 100644 --- a/packages/boop-game/src/ui/App.tsx +++ b/packages/boop-game/src/ui/App.tsx @@ -14,19 +14,13 @@ export default function App>(props: { gam const scene = useComputed(() => new props.gameScene()); const handleReset = async () => { - gameHost.value.gameHost.setup('setup').then(result => { - if(!result.success) { - console.error(result.error); - }else{ - console.log('Game finished!', result.result); - } - }); + gameHost.value.gameHost.start(); }; const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start'); return (
-
+
diff --git a/packages/framework/src/bindings/index.ts b/packages/framework/src/bindings/index.ts deleted file mode 100644 index 37266a3..0000000 --- a/packages/framework/src/bindings/index.ts +++ /dev/null @@ -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( - signal: MutableSignal, - getter: (state: T) => T[K], - setter: (value: T[K]) => void, -): DisposeFn { - return effect(() => { - const val = getter(signal.value); - setter(val); - }); -} - -export function bindGameObjectProperty( - signal: Signal, - target: Phaser.GameObjects.GameObject, - prop: string, -): DisposeFn { - return effect(() => { - (target as unknown as Record)[prop] = signal.value; - }); -} - -export interface BindRegionOptions { - 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( - state: MutableSignal, - partsGetter: (state: TState) => Record>, - regionGetter: (state: TState) => Region, - options: BindRegionOptions>, - container: Phaser.GameObjects.Container, -): { cleanup: () => void; objects: Map } { - const objects = new Map(); - - 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 { - factory: (item: T) => Phaser.GameObjects.GameObject; - update?: (item: T, obj: Phaser.GameObjects.GameObject) => void; -} - -export function bindCollection( - collection: Signal>>, - options: BindCollectionOptions, - container: Phaser.GameObjects.Container, -): { cleanup: () => void; objects: Map } { - const objects = new Map(); - 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, - }; -} diff --git a/packages/framework/src/spawner/index.ts b/packages/framework/src/spawner/index.ts index 1897eb8..8b01888 100644 --- a/packages/framework/src/spawner/index.ts +++ b/packages/framework/src/spawner/index.ts @@ -1,5 +1,5 @@ import Phaser from 'phaser'; -import { effect, type ReadonlySignal } from '@preact/signals-core'; +import { effect } from '@preact/signals-core'; type GO = Phaser.GameObjects.GameObject; @@ -11,7 +11,7 @@ export interface Spawner { /** 创建新对象 */ onSpawn(t: TData): TObject | null; /** 销毁旧对象 */ - onDespawn(obj: TObject): void; + onDespawn(obj: TObject, t: TData): void; /** 更新已有对象 */ onUpdate(t: TData, obj: TObject): void; } @@ -20,6 +20,7 @@ export function spawnEffect( spawner: Spawner, ): () => void { const objects = new Map(); + const spawnData = new Map(); return effect(() => { const current = new Set(); @@ -32,17 +33,21 @@ export function spawnEffect( if (!existing) { const obj = spawner.onSpawn(t); if (obj) { + spawnData.set(key, t); objects.set(key, obj); } } else { + if(spawnData.get(key) === t) continue; spawner.onUpdate(t, existing); + spawnData.set(key, t); } } for (const [key, obj] of objects) { if (!current.has(key)) { - spawner.onDespawn(obj); + spawner.onDespawn(obj, spawnData.get(key)!); objects.delete(key); + spawnData.delete(key); } } }); diff --git a/packages/framework/src/ui/PhaserBridge.tsx b/packages/framework/src/ui/PhaserBridge.tsx index 465e230..64e80cb 100644 --- a/packages/framework/src/ui/PhaserBridge.tsx +++ b/packages/framework/src/ui/PhaserBridge.tsx @@ -1,5 +1,5 @@ import Phaser from 'phaser'; -import { signal, useSignal, useSignalEffect, type Signal } from '@preact/signals'; +import { signal, useSignal, useSignalEffect } from '@preact/signals'; import { createContext, h } from 'preact'; import { useContext } from 'preact/hooks'; import {ReadonlySignal} from "@preact/signals-core"; diff --git a/packages/sample-game/src/game/tic-tac-toe.ts b/packages/sample-game/src/game/tic-tac-toe.ts index 3cc0e0a..f0a289d 100644 --- a/packages/sample-game/src/game/tic-tac-toe.ts +++ b/packages/sample-game/src/game/tic-tac-toe.ts @@ -1,6 +1,6 @@ import { - createGameCommandRegistry, Part, createRegion, createPart, isCellOccupied as isCellOccupiedUtil, - IGameContext, Command + createGameCommandRegistry, Part, createRegion, + IGameContext, createRegionAxis, GameModule } from 'boardgame-core'; const BOARD_SIZE = 3; @@ -18,14 +18,15 @@ const WINNING_LINES: number[][][] = [ export type PlayerType = 'X' | 'O'; export type WinnerType = PlayerType | 'draw' | null; - export type TicTacToePart = Part<{ player: PlayerType }>; +export type TicTacToeState = ReturnType; +export type TicTacToeGame = IGameContext; export function createInitialState() { return { board: createRegion('board', [ - { name: 'x', min: 0, max: BOARD_SIZE - 1 }, - { name: 'y', min: 0, max: BOARD_SIZE - 1 }, + createRegionAxis('x', 0, BOARD_SIZE - 1), + createRegionAxis('y', 0, BOARD_SIZE - 1), ]), parts: {} as Record, currentPlayer: 'X' as PlayerType, @@ -33,19 +34,16 @@ export function createInitialState() { turn: 0, }; } -export type TicTacToeState = ReturnType; -export type TicTacToeGame = IGameContext; export const registry = createGameCommandRegistry(); -async function setup(game: TicTacToeGame) { +export async function start(game: TicTacToeGame) { while (true) { const currentPlayer = game.value.currentPlayer; const turnNumber = game.value.turn + 1; - const turnOutput = await turnCommand(game, currentPlayer, turnNumber); - if (!turnOutput.success) throw new Error(turnOutput.error); + const turnOutput = await turn(game, currentPlayer, turnNumber); game.produce((state: TicTacToeState) => { - state.winner = turnOutput.result.winner; + state.winner = turnOutput.winner; if (!state.winner) { state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X'; state.turn = turnNumber; @@ -56,14 +54,11 @@ async function setup(game: TicTacToeGame) { return game.value; } -registry.register('setup', setup); -async function turn(game: TicTacToeGame, turnPlayer: PlayerType, turnNumber: number) { +async function handleTurn(game: TicTacToeGame, turnPlayer: PlayerType, turnNumber: number) { const {player, row, col} = await game.prompt( 'play ', - (command: Command) => { - const [player, row, col] = command.params as [PlayerType, number, number]; - + (player: PlayerType, row: number, col: number) => { if (player !== turnPlayer) { throw `Invalid player: ${player}. Expected ${turnPlayer}.`; } else if (!isValidMove(row, col)) { @@ -85,14 +80,14 @@ async function turn(game: TicTacToeGame, turnPlayer: PlayerType, turnNumber: num return { winner: null }; } -const turnCommand = registry.register('turn ', turn); +const turn = registry.register('turn ', handleTurn); function isValidMove(row: number, col: number): boolean { return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE; } export function isCellOccupied(host: TicTacToeGame, row: number, col: number): boolean { - return isCellOccupiedUtil(host.value.parts, 'board', [row, col]); + return host.value.board.partMap[`${row},${col}`] !== undefined; } export function hasWinningLine(positions: number[][]): boolean { @@ -120,10 +115,10 @@ export function checkWinner(host: TicTacToeGame): WinnerType { export function placePiece(host: TicTacToeGame, row: number, col: number, player: PlayerType) { const board = host.value.board; const moveNumber = Object.keys(host.value.parts).length + 1; - const piece = createPart<{ player: PlayerType }>( - { regionId: 'board', position: [row, col], player }, - `piece-${player}-${moveNumber}` - ); + const piece = { + regionId: 'board', position: [row, col], player, + id: `piece-${player}-${moveNumber}` + }; host.produce((state: TicTacToeState) => { state.parts[piece.id] = piece; board.childIds.push(piece.id); @@ -131,13 +126,14 @@ export function placePiece(host: TicTacToeGame, row: number, col: number, player }); } -export const gameModule = { +export const gameModule: GameModule = { registry, createInitialState, + start }; -export const commands = { +export const prompts = { play: (player: PlayerType, row: number, col: number) => { return `play ${player} ${row} ${col}`; }, -}; +}; \ No newline at end of file diff --git a/packages/sample-game/src/scenes/GameScene.ts b/packages/sample-game/src/scenes/GameScene.ts index 8148aef..a1f2020 100644 --- a/packages/sample-game/src/scenes/GameScene.ts +++ b/packages/sample-game/src/scenes/GameScene.ts @@ -2,7 +2,7 @@ import Phaser from 'phaser'; import type {TicTacToeState, TicTacToePart} from '@/game/tic-tac-toe'; import { GameHostScene } from 'boardgame-phaser'; import { spawnEffect, type Spawner } from 'boardgame-phaser'; -import {commands} from "@/game/tic-tac-toe"; +import {prompts} from "@/game/tic-tac-toe"; const CELL_SIZE = 120; const BOARD_OFFSET = { x: 100, y: 100 }; @@ -61,7 +61,7 @@ export class GameScene extends GameHostScene { if (this.state.winner) return; if (this.isCellOccupied(row, col)) return; - const cmd = commands.play(this.state.currentPlayer, row, col); + const cmd = prompts.play(this.state.currentPlayer, row, col); const error = this.gameHost.onInput(cmd); if (error) { console.warn('Invalid move:', error); @@ -133,7 +133,7 @@ export class GameScene extends GameHostScene { ).setInteractive({ useHandCursor: true }); bg.on('pointerdown', () => { - this.gameHost.setup('setup'); + this.gameHost.start(); }); this.winnerOverlay.add(bg); diff --git a/packages/sample-game/src/ui/App.tsx b/packages/sample-game/src/ui/App.tsx index d2fe80a..ba5ba09 100644 --- a/packages/sample-game/src/ui/App.tsx +++ b/packages/sample-game/src/ui/App.tsx @@ -13,24 +13,13 @@ export default function App>(props: { gam const scene = useComputed(() => new props.gameScene()); - const handleReset = async () => { - gameHost.value.gameHost.setup('setup').then(result => { - if(!result.success) { - console.error(result.error); - }else{ - console.log('Game finished!', result.result); - } - }); + const handleReset = () => { + gameHost.value.gameHost.start(); }; const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start'); return (
-
- - - -
+
+ + + +
); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a32aa16..75a63db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,46 @@ importers: specifier: ^3.2.4 version: 3.2.4(lightningcss@1.32.0) + packages/regicide-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/sample-game: dependencies: '@preact/signals-core':