refactor: update api
This commit is contained in:
parent
0e7aaea7da
commit
fa104d10f2
|
|
@ -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,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<GameState>) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可选
|
||||||
|
export const registry = createGameCommandRegistry();
|
||||||
|
```
|
||||||
|
|
||||||
|
使用以下步骤创建游戏:
|
||||||
|
|
||||||
|
### 1. 定义状态
|
||||||
|
|
||||||
|
游戏状态使用一个大的`mutative`对象来描述,所有的状态更新通过`game.produce`或`game.produceAsync`来实现。
|
||||||
|
|
||||||
|
通常使用一个`regions: Record<string, Region>`和一个`parts: Record<string, Part<TMeta>>`来记录桌游物件的摆放。
|
||||||
|
|
||||||
|
### 2. 定义流程
|
||||||
|
|
||||||
|
使用`async function start(game: IGameContext<GameState>)`作为入口。
|
||||||
|
|
||||||
|
需要等待玩家交互时,使用`await game.prompt(schema, validator, player)`。
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@ export const registry = createGameCommandRegistry<BoopState>();
|
||||||
/**
|
/**
|
||||||
* 放置棋子到棋盘
|
* 放置棋子到棋盘
|
||||||
*/
|
*/
|
||||||
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;
|
const value = game.value;
|
||||||
// 从玩家supply中找到对应类型的棋子
|
// 从玩家supply中找到对应类型的棋子
|
||||||
const part = findPartInRegion(game, player, type);
|
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 };
|
return { row, col, player, type, partId };
|
||||||
}
|
}
|
||||||
const placeCommand = registry.register( 'place <row:number> <col:number> <player> <type>', place);
|
const place = registry.register( 'place <row:number> <col:number> <player> <type>', handlePlace);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行boop - 推动周围棋子
|
* 执行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 booped: string[] = [];
|
||||||
|
|
||||||
const toRemove = new Set<string>();
|
const toRemove = new Set<string>();
|
||||||
|
|
@ -95,12 +95,12 @@ async function boop(game: BoopGame, row: number, col: number, type: PieceType) {
|
||||||
|
|
||||||
return { booped };
|
return { booped };
|
||||||
}
|
}
|
||||||
const boopCommand = registry.register('boop <row:number> <col:number> <type>', boop);
|
const boop = registry.register('boop <row:number> <col:number> <type>', handleBoop);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查是否有玩家获胜(三个猫连线)
|
* 检查是否有玩家获胜(三个猫连线)
|
||||||
*/
|
*/
|
||||||
async function checkWin(game: BoopGame) {
|
async function handleCheckWin(game: BoopGame) {
|
||||||
for(const line of getLineCandidates()){
|
for(const line of getLineCandidates()){
|
||||||
let whites = 0;
|
let whites = 0;
|
||||||
let blacks = 0;
|
let blacks = 0;
|
||||||
|
|
@ -119,12 +119,12 @@ async function checkWin(game: BoopGame) {
|
||||||
}
|
}
|
||||||
return null;
|
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<string>();
|
const toUpgrade = new Set<string>();
|
||||||
for(const line of getLineCandidates()){
|
for(const line of getLineCandidates()){
|
||||||
let whites = 0;
|
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) {
|
while (true) {
|
||||||
const currentPlayer = game.value.currentPlayer;
|
const currentPlayer = game.value.currentPlayer;
|
||||||
const { winner } = await turnCommand(game, currentPlayer);
|
const { winner } = await turn(game, currentPlayer);
|
||||||
|
|
||||||
await game.produceAsync((state: BoopState) => {
|
await game.produceAsync((state: BoopState) => {
|
||||||
state.winner = winner;
|
state.winner = winner;
|
||||||
|
|
@ -173,9 +173,8 @@ async function setup(game: BoopGame) {
|
||||||
|
|
||||||
return game.value;
|
return game.value;
|
||||||
}
|
}
|
||||||
registry.register('setup', setup);
|
|
||||||
|
|
||||||
async function checkFullBoard(game: BoopGame, turnPlayer: PlayerType){
|
async function handleCheckFullBoard(game: BoopGame, turnPlayer: PlayerType){
|
||||||
// 检查8-piece规则: 如果玩家所有8个棋子都在棋盘上且没有获胜,强制升级一个小猫
|
// 检查8-piece规则: 如果玩家所有8个棋子都在棋盘上且没有获胜,强制升级一个小猫
|
||||||
const playerPieces = Object.values(game.value.pieces).filter(
|
const playerPieces = Object.values(game.value.pieces).filter(
|
||||||
(p: BoopPart) => p.player === turnPlayer && p.regionId === 'board'
|
(p: BoopPart) => p.player === turnPlayer && p.regionId === 'board'
|
||||||
|
|
@ -186,8 +185,7 @@ async function checkFullBoard(game: BoopGame, turnPlayer: PlayerType){
|
||||||
|
|
||||||
const partId = await game.prompt(
|
const partId = await game.prompt(
|
||||||
'play <player> <row:number> <col:number> [type:string]',
|
'play <player> <row:number> <col:number> [type:string]',
|
||||||
(command: Command) => {
|
(player: PlayerType, row: number, col: number, type?: PieceType) => {
|
||||||
const [player, row, col] = command.params as [PlayerType, number, number];
|
|
||||||
if (player !== turnPlayer) {
|
if (player !== turnPlayer) {
|
||||||
throw `无效的玩家: ${player},期望的是 ${turnPlayer}。`;
|
throw `无效的玩家: ${player},期望的是 ${turnPlayer}。`;
|
||||||
}
|
}
|
||||||
|
|
@ -211,12 +209,12 @@ async function checkFullBoard(game: BoopGame, turnPlayer: PlayerType){
|
||||||
moveToRegion(cat || part, null, state.regions[turnPlayer]);
|
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(
|
const {row, col, type} = await game.prompt(
|
||||||
'play <player> <row:number> <col:number> [type:string]',
|
'play <player> <row:number> <col:number> [type:string]',
|
||||||
(command: Command) => {
|
(player: PlayerType, row: number, col: number, type?: PieceType) => {
|
||||||
const [player, row, col, type] = command.params as [PlayerType, number, number, PieceType?];
|
|
||||||
const pieceType = type === 'cat' ? 'cat' : 'kitten';
|
const pieceType = type === 'cat' ? 'cat' : 'kitten';
|
||||||
|
|
||||||
if (player !== turnPlayer) {
|
if (player !== turnPlayer) {
|
||||||
|
|
@ -239,17 +237,18 @@ async function turn(game: BoopGame, turnPlayer: PlayerType) {
|
||||||
);
|
);
|
||||||
const pieceType = type === 'cat' ? 'cat' : 'kitten';
|
const pieceType = type === 'cat' ? 'cat' : 'kitten';
|
||||||
|
|
||||||
await placeCommand(game, row, col, turnPlayer, pieceType);
|
await place(game, row, col, turnPlayer, pieceType);
|
||||||
await boopCommand(game, row, col, pieceType);
|
await boop(game, row, col, pieceType);
|
||||||
const winner = await checkWinCommand(game);
|
const winner = await checkWin(game);
|
||||||
if(winner) return { winner: winner as WinnerType };
|
if(winner) return { winner: winner as WinnerType };
|
||||||
|
|
||||||
await checkGraduatesCommand(game);
|
await checkGraduates(game);
|
||||||
await checkFullBoard(game, turnPlayer);
|
await handleCheckFullBoard(game, turnPlayer);
|
||||||
return { winner: null };
|
return { winner: null };
|
||||||
}
|
}
|
||||||
const turnCommand = registry.register('turn <player>', turn);
|
const turn = registry.register('turn <player>', handleTurn);
|
||||||
export const commands = {
|
|
||||||
|
export const prompts = {
|
||||||
play: (player: PlayerType, row: number, col: number, type?: PieceType) => {
|
play: (player: PlayerType, row: number, col: number, type?: PieceType) => {
|
||||||
if (type) {
|
if (type) {
|
||||||
return `play ${player} ${row} ${col} ${type}`;
|
return `play ${player} ${row} ${col} ${type}`;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { BoopState } from '@/game';
|
import type { BoopState } from '@/game';
|
||||||
import { GameHostScene } from 'boardgame-phaser';
|
import { GameHostScene } from 'boardgame-phaser';
|
||||||
import { commands } from '@/game';
|
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';
|
||||||
|
|
@ -73,7 +73,7 @@ export class GameScene extends GameHostScene<BoopState> {
|
||||||
|
|
||||||
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 cmd = prompts.play(this.state.currentPlayer, row, col, selectedType);
|
||||||
const error = this.gameHost.onInput(cmd);
|
const error = this.gameHost.onInput(cmd);
|
||||||
if (error) {
|
if (error) {
|
||||||
this.errorOverlay.show(error);
|
this.errorOverlay.show(error);
|
||||||
|
|
@ -82,7 +82,7 @@ export class GameScene extends GameHostScene<BoopState> {
|
||||||
|
|
||||||
private handlePieceClick(row: number, col: number): void {
|
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);
|
const error = this.gameHost.onInput(cmd);
|
||||||
if (error) {
|
if (error) {
|
||||||
this.errorOverlay.show(error);
|
this.errorOverlay.show(error);
|
||||||
|
|
@ -90,10 +90,10 @@ export class GameScene extends GameHostScene<BoopState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private startGame(): void {
|
private startGame(): void {
|
||||||
this.gameHost.setup('setup');
|
this.gameHost.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private restartGame(): void {
|
private restartGame(): void {
|
||||||
this.gameHost.setup('setup');
|
this.gameHost.start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,19 +14,13 @@ export default function App<TState extends Record<string, unknown>>(props: { gam
|
||||||
const scene = useComputed(() => new props.gameScene());
|
const scene = useComputed(() => new props.gameScene());
|
||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
gameHost.value.gameHost.setup('setup').then(result => {
|
gameHost.value.gameHost.start();
|
||||||
if(!result.success) {
|
|
||||||
console.error(result.error);
|
|
||||||
}else{
|
|
||||||
console.log('Game finished!', result.result);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');
|
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 config={{ width: 640, height: 750 }}>
|
||||||
<PhaserScene sceneKey="GameScene" scene={scene.value} autoStart data={gameHost.value} />
|
<PhaserScene sceneKey="GameScene" scene={scene.value} autoStart data={gameHost.value} />
|
||||||
</PhaserGame>
|
</PhaserGame>
|
||||||
|
|
|
||||||
|
|
@ -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,5 +1,5 @@
|
||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { effect, type ReadonlySignal } from '@preact/signals-core';
|
import { effect } from '@preact/signals-core';
|
||||||
|
|
||||||
type GO = Phaser.GameObjects.GameObject;
|
type GO = Phaser.GameObjects.GameObject;
|
||||||
|
|
||||||
|
|
@ -11,7 +11,7 @@ export interface Spawner<TData, TObject extends GO = GO> {
|
||||||
/** 创建新对象 */
|
/** 创建新对象 */
|
||||||
onSpawn(t: TData): TObject | null;
|
onSpawn(t: TData): TObject | null;
|
||||||
/** 销毁旧对象 */
|
/** 销毁旧对象 */
|
||||||
onDespawn(obj: TObject): void;
|
onDespawn(obj: TObject, t: TData): void;
|
||||||
/** 更新已有对象 */
|
/** 更新已有对象 */
|
||||||
onUpdate(t: TData, obj: TObject): void;
|
onUpdate(t: TData, obj: TObject): void;
|
||||||
}
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ export function spawnEffect<TData, TObject extends GO = GO>(
|
||||||
spawner: Spawner<TData, TObject>,
|
spawner: Spawner<TData, TObject>,
|
||||||
): () => void {
|
): () => void {
|
||||||
const objects = new Map<string, TObject>();
|
const objects = new Map<string, TObject>();
|
||||||
|
const spawnData = new Map<string, TData>();
|
||||||
|
|
||||||
return effect(() => {
|
return effect(() => {
|
||||||
const current = new Set<string>();
|
const current = new Set<string>();
|
||||||
|
|
@ -32,17 +33,21 @@ export function spawnEffect<TData, TObject extends GO = GO>(
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const obj = spawner.onSpawn(t);
|
const obj = spawner.onSpawn(t);
|
||||||
if (obj) {
|
if (obj) {
|
||||||
|
spawnData.set(key, t);
|
||||||
objects.set(key, obj);
|
objects.set(key, obj);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if(spawnData.get(key) === t) continue;
|
||||||
spawner.onUpdate(t, existing);
|
spawner.onUpdate(t, existing);
|
||||||
|
spawnData.set(key, t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, obj] of objects) {
|
for (const [key, obj] of objects) {
|
||||||
if (!current.has(key)) {
|
if (!current.has(key)) {
|
||||||
spawner.onDespawn(obj);
|
spawner.onDespawn(obj, spawnData.get(key)!);
|
||||||
objects.delete(key);
|
objects.delete(key);
|
||||||
|
spawnData.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Phaser from 'phaser';
|
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 { createContext, h } from 'preact';
|
||||||
import { useContext } from 'preact/hooks';
|
import { useContext } from 'preact/hooks';
|
||||||
import {ReadonlySignal} from "@preact/signals-core";
|
import {ReadonlySignal} from "@preact/signals-core";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import {
|
import {
|
||||||
createGameCommandRegistry, Part, createRegion, createPart, isCellOccupied as isCellOccupiedUtil,
|
createGameCommandRegistry, Part, createRegion,
|
||||||
IGameContext, Command
|
IGameContext, createRegionAxis, GameModule
|
||||||
} from 'boardgame-core';
|
} from 'boardgame-core';
|
||||||
|
|
||||||
const BOARD_SIZE = 3;
|
const BOARD_SIZE = 3;
|
||||||
|
|
@ -18,14 +18,15 @@ const WINNING_LINES: number[][][] = [
|
||||||
|
|
||||||
export type PlayerType = 'X' | 'O';
|
export type PlayerType = 'X' | 'O';
|
||||||
export type WinnerType = PlayerType | 'draw' | null;
|
export type WinnerType = PlayerType | 'draw' | null;
|
||||||
|
|
||||||
export type TicTacToePart = Part<{ player: PlayerType }>;
|
export type TicTacToePart = Part<{ player: PlayerType }>;
|
||||||
|
export type TicTacToeState = ReturnType<typeof createInitialState>;
|
||||||
|
export type TicTacToeGame = IGameContext<TicTacToeState>;
|
||||||
|
|
||||||
export function createInitialState() {
|
export function createInitialState() {
|
||||||
return {
|
return {
|
||||||
board: createRegion('board', [
|
board: createRegion('board', [
|
||||||
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
createRegionAxis('x', 0, BOARD_SIZE - 1),
|
||||||
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
createRegionAxis('y', 0, BOARD_SIZE - 1),
|
||||||
]),
|
]),
|
||||||
parts: {} as Record<string, TicTacToePart>,
|
parts: {} as Record<string, TicTacToePart>,
|
||||||
currentPlayer: 'X' as PlayerType,
|
currentPlayer: 'X' as PlayerType,
|
||||||
|
|
@ -33,19 +34,16 @@ export function createInitialState() {
|
||||||
turn: 0,
|
turn: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export type TicTacToeState = ReturnType<typeof createInitialState>;
|
|
||||||
export type TicTacToeGame = IGameContext<TicTacToeState>;
|
|
||||||
export const registry = createGameCommandRegistry<TicTacToeState>();
|
export const registry = createGameCommandRegistry<TicTacToeState>();
|
||||||
|
|
||||||
async function setup(game: TicTacToeGame) {
|
export async function start(game: TicTacToeGame) {
|
||||||
while (true) {
|
while (true) {
|
||||||
const currentPlayer = game.value.currentPlayer;
|
const currentPlayer = game.value.currentPlayer;
|
||||||
const turnNumber = game.value.turn + 1;
|
const turnNumber = game.value.turn + 1;
|
||||||
const turnOutput = await turnCommand(game, currentPlayer, turnNumber);
|
const turnOutput = await turn(game, currentPlayer, turnNumber);
|
||||||
if (!turnOutput.success) throw new Error(turnOutput.error);
|
|
||||||
|
|
||||||
game.produce((state: TicTacToeState) => {
|
game.produce((state: TicTacToeState) => {
|
||||||
state.winner = turnOutput.result.winner;
|
state.winner = turnOutput.winner;
|
||||||
if (!state.winner) {
|
if (!state.winner) {
|
||||||
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
|
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
|
||||||
state.turn = turnNumber;
|
state.turn = turnNumber;
|
||||||
|
|
@ -56,14 +54,11 @@ async function setup(game: TicTacToeGame) {
|
||||||
|
|
||||||
return game.value;
|
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(
|
const {player, row, col} = await game.prompt(
|
||||||
'play <player> <row:number> <col:number>',
|
'play <player> <row:number> <col:number>',
|
||||||
(command: Command) => {
|
(player: PlayerType, row: number, col: number) => {
|
||||||
const [player, row, col] = command.params as [PlayerType, number, number];
|
|
||||||
|
|
||||||
if (player !== turnPlayer) {
|
if (player !== turnPlayer) {
|
||||||
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
||||||
} else if (!isValidMove(row, col)) {
|
} else if (!isValidMove(row, col)) {
|
||||||
|
|
@ -85,14 +80,14 @@ async function turn(game: TicTacToeGame, turnPlayer: PlayerType, turnNumber: num
|
||||||
|
|
||||||
return { winner: null };
|
return { winner: null };
|
||||||
}
|
}
|
||||||
const turnCommand = registry.register('turn <player:string> <turnNumber:int>', turn);
|
const turn = registry.register('turn <player:string> <turnNumber:int>', handleTurn);
|
||||||
|
|
||||||
function isValidMove(row: number, col: number): boolean {
|
function isValidMove(row: number, col: number): boolean {
|
||||||
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
|
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 {
|
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 {
|
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) {
|
export function placePiece(host: TicTacToeGame, row: number, col: number, player: PlayerType) {
|
||||||
const board = host.value.board;
|
const board = host.value.board;
|
||||||
const moveNumber = Object.keys(host.value.parts).length + 1;
|
const moveNumber = Object.keys(host.value.parts).length + 1;
|
||||||
const piece = createPart<{ player: PlayerType }>(
|
const piece = {
|
||||||
{ regionId: 'board', position: [row, col], player },
|
regionId: 'board', position: [row, col], player,
|
||||||
`piece-${player}-${moveNumber}`
|
id: `piece-${player}-${moveNumber}`
|
||||||
);
|
};
|
||||||
host.produce((state: TicTacToeState) => {
|
host.produce((state: TicTacToeState) => {
|
||||||
state.parts[piece.id] = piece;
|
state.parts[piece.id] = piece;
|
||||||
board.childIds.push(piece.id);
|
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<TicTacToeState> = {
|
||||||
registry,
|
registry,
|
||||||
createInitialState,
|
createInitialState,
|
||||||
|
start
|
||||||
};
|
};
|
||||||
|
|
||||||
export const commands = {
|
export const prompts = {
|
||||||
play: (player: PlayerType, row: number, col: number) => {
|
play: (player: PlayerType, row: number, col: number) => {
|
||||||
return `play ${player} ${row} ${col}`;
|
return `play ${player} ${row} ${col}`;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -2,7 +2,7 @@ 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 {commands} from "@/game/tic-tac-toe";
|
import {prompts} from "@/game/tic-tac-toe";
|
||||||
|
|
||||||
const CELL_SIZE = 120;
|
const CELL_SIZE = 120;
|
||||||
const BOARD_OFFSET = { x: 100, y: 100 };
|
const BOARD_OFFSET = { x: 100, y: 100 };
|
||||||
|
|
@ -61,7 +61,7 @@ export class GameScene extends GameHostScene<TicTacToeState> {
|
||||||
if (this.state.winner) return;
|
if (this.state.winner) return;
|
||||||
if (this.isCellOccupied(row, col)) 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);
|
const error = this.gameHost.onInput(cmd);
|
||||||
if (error) {
|
if (error) {
|
||||||
console.warn('Invalid move:', error);
|
console.warn('Invalid move:', error);
|
||||||
|
|
@ -133,7 +133,7 @@ export class GameScene extends GameHostScene<TicTacToeState> {
|
||||||
).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);
|
||||||
|
|
|
||||||
|
|
@ -13,24 +13,13 @@ export default function App<TState extends Record<string, unknown>>(props: { gam
|
||||||
|
|
||||||
const scene = useComputed(() => new props.gameScene());
|
const scene = useComputed(() => new props.gameScene());
|
||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = () => {
|
||||||
gameHost.value.gameHost.setup('setup').then(result => {
|
gameHost.value.gameHost.start();
|
||||||
if(!result.success) {
|
|
||||||
console.error(result.error);
|
|
||||||
}else{
|
|
||||||
console.log('Game finished!', result.result);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');
|
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">
|
|
||||||
<PhaserGame>
|
|
||||||
<PhaserScene sceneKey="GameScene" scene={scene.value} autoStart data={gameHost.value} />
|
|
||||||
</PhaserGame>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-gray-100 border-t border-gray-200">
|
<div className="p-4 bg-gray-100 border-t border-gray-200">
|
||||||
<button
|
<button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
|
|
@ -39,6 +28,11 @@ export default function App<TState extends Record<string, unknown>>(props: { gam
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 flex relative justify-center items-center">
|
||||||
|
<PhaserGame>
|
||||||
|
<PhaserScene sceneKey="GameScene" scene={scene.value} autoStart data={gameHost.value} />
|
||||||
|
</PhaserGame>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,46 @@ importers:
|
||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.4(lightningcss@1.32.0)
|
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:
|
packages/sample-game:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@preact/signals-core':
|
'@preact/signals-core':
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue