refactor: human effort to reorg the code
This commit is contained in:
parent
e8c98d3ad3
commit
f83e031c9a
|
|
@ -71,7 +71,8 @@ registration.add('reset', async function () {
|
||||||
state.winner = null;
|
state.winner = null;
|
||||||
state.turn = 0;
|
state.turn = 0;
|
||||||
});
|
});
|
||||||
return { success: true };
|
// 重启主循环
|
||||||
|
return this.run('setup');
|
||||||
});
|
});
|
||||||
|
|
||||||
registration.add('turn <player> <turn:number>', async function (cmd) {
|
registration.add('turn <player> <turn:number>', async function (cmd) {
|
||||||
|
|
@ -146,3 +147,9 @@ export function placePiece(host: MutableSignal<TicTacToeState>, row: number, col
|
||||||
state.board.partMap[`${row},${col}`] = 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,167 +1,13 @@
|
||||||
import { h, render } from 'preact';
|
import { h } from 'preact';
|
||||||
import { signal } from '@preact/signals-core';
|
import { GameUI } from 'boardgame-phaser';
|
||||||
import { useEffect, useState, useCallback } from 'preact/hooks';
|
import * as ticTacToe from './game/tic-tac-toe';
|
||||||
import Phaser from 'phaser';
|
|
||||||
import { createGameHost } from 'boardgame-core';
|
|
||||||
import { GameUI, PromptDialog, CommandLog } from 'boardgame-phaser';
|
|
||||||
import { createInitialState, registry, type TicTacToeState } from './game/tic-tac-toe';
|
|
||||||
import { GameScene } from './scenes/GameScene';
|
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
import App from "@/ui/App";
|
||||||
// 创建 GameHost 实例,自动管理状态和 prompt
|
import {GameScene} from "@/scenes/GameScene";
|
||||||
const gameHost = createGameHost(
|
|
||||||
{ registry, createInitialState },
|
|
||||||
'setup',
|
|
||||||
{ autoStart: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
const commandLog = signal<Array<{ input: string; result: string; timestamp: number }>>([]);
|
|
||||||
|
|
||||||
// 记录命令日志的辅助函数
|
|
||||||
function logCommand(input: string, result: { success: boolean; result?: unknown; error?: string }) {
|
|
||||||
commandLog.value = [
|
|
||||||
...commandLog.value,
|
|
||||||
{
|
|
||||||
input,
|
|
||||||
result: result.success ? `OK: ${JSON.stringify(result.result)}` : `ERR: ${result.error}`,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [phaserReady, setPhaserReady] = useState(false);
|
|
||||||
const [game, setGame] = useState<Phaser.Game | null>(null);
|
|
||||||
const [scene, setScene] = useState<GameScene | null>(null);
|
|
||||||
const [gameState, setGameState] = useState<TicTacToeState | null>(null);
|
|
||||||
const [promptSchema, setPromptSchema] = useState<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const phaserConfig: Phaser.Types.Core.GameConfig = {
|
|
||||||
type: Phaser.AUTO,
|
|
||||||
width: 560,
|
|
||||||
height: 560,
|
|
||||||
parent: 'phaser-container',
|
|
||||||
backgroundColor: '#f9fafb',
|
|
||||||
scene: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const phaserGame = new Phaser.Game(phaserConfig);
|
|
||||||
// 通过 init 传递 gameHost
|
|
||||||
const gameScene = new GameScene();
|
|
||||||
phaserGame.scene.add('GameScene', gameScene, true, { gameHost });
|
|
||||||
|
|
||||||
setGame(phaserGame);
|
|
||||||
setScene(gameScene);
|
|
||||||
setPhaserReady(true);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
gameHost.dispose();
|
|
||||||
phaserGame.destroy(true);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (phaserReady && scene) {
|
|
||||||
// 监听 prompt 状态变化
|
|
||||||
const disposePromptSchema = gameHost.activePromptSchema.subscribe((schema) => {
|
|
||||||
setPromptSchema(schema);
|
|
||||||
scene.promptSchema.current = schema;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听状态变化
|
|
||||||
const disposeState = gameHost.state.subscribe(() => {
|
|
||||||
setGameState(gameHost.state.value as TicTacToeState);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 运行游戏设置
|
|
||||||
gameHost.setup('setup').then(() => {
|
|
||||||
logCommand('setup', { success: true });
|
|
||||||
}).catch(err => {
|
|
||||||
logCommand('setup', { success: false, error: err.message });
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposePromptSchema();
|
|
||||||
disposeState();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [phaserReady, scene]);
|
|
||||||
|
|
||||||
const handlePromptSubmit = useCallback((input: string) => {
|
|
||||||
const error = gameHost.onInput(input);
|
|
||||||
if (error === null) {
|
|
||||||
logCommand(input, { success: true });
|
|
||||||
setPromptSchema(null);
|
|
||||||
if (scene) {
|
|
||||||
scene.promptSchema.current = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logCommand(input, { success: false, error });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePromptCancel = useCallback(() => {
|
|
||||||
gameHost.commands._cancel('User cancelled');
|
|
||||||
setPromptSchema(null);
|
|
||||||
if (scene) {
|
|
||||||
scene.promptSchema.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
|
||||||
gameHost.commands.run('reset').then(result => {
|
|
||||||
logCommand('reset', result);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-screen">
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<div id="phaser-container" className="w-full h-full" />
|
|
||||||
|
|
||||||
{/* 游戏状态显示 */}
|
|
||||||
{gameState && !gameState.winner && (
|
|
||||||
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-white px-4 py-2 rounded-lg shadow-lg z-10">
|
|
||||||
<span className="text-lg font-semibold text-gray-800">
|
|
||||||
{gameState.currentPlayer}'s Turn
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{gameState?.winner && (
|
|
||||||
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-white px-4 py-2 rounded-lg shadow-lg z-10">
|
|
||||||
<span className="text-lg font-semibold text-yellow-600">
|
|
||||||
{gameState.winner === 'draw' ? "It's a Draw!" : `${gameState.winner} Wins!`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PromptDialog
|
|
||||||
prompt={promptSchema ? { schema: promptSchema, tryCommit: () => null, cancel: () => {} } : null}
|
|
||||||
onSubmit={handlePromptSubmit}
|
|
||||||
onCancel={handlePromptCancel}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-gray-100 border-t">
|
|
||||||
<div className="flex justify-between items-center mb-2">
|
|
||||||
<span className="text-sm font-medium text-gray-700">Command Log</span>
|
|
||||||
<button
|
|
||||||
className="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
|
||||||
onClick={handleReset}
|
|
||||||
>
|
|
||||||
Reset Game
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<CommandLog entries={commandLog} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ui = new GameUI({
|
const ui = new GameUI({
|
||||||
container: document.getElementById('ui-root')!,
|
container: document.getElementById('ui-root')!,
|
||||||
root: <App />,
|
root: <App gameModule={ticTacToe} gameScene={GameScene}/>,
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.mount();
|
ui.mount();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import {DisposableBag, IDisposable} from "@/utils/disposable";
|
||||||
|
import type {GameHost} from "../../../../../boardgame-core/src";
|
||||||
|
import {effect} from "@preact/signals";
|
||||||
|
|
||||||
|
export abstract class GameHostScene<T extends Record<string,unknown>> extends Phaser.Scene implements IDisposable{
|
||||||
|
protected disposables = new DisposableBag();
|
||||||
|
|
||||||
|
protected gameHost!: GameHost<T>;
|
||||||
|
|
||||||
|
init(data: { gameHost: GameHost<T> }): void {
|
||||||
|
this.gameHost = data.gameHost;
|
||||||
|
}
|
||||||
|
create(){
|
||||||
|
this.events.on('shutdown', this.dispose, this);
|
||||||
|
}
|
||||||
|
dispose() {
|
||||||
|
this.disposables.dispose();
|
||||||
|
}
|
||||||
|
public get state(): T {
|
||||||
|
return this.gameHost.state.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected watch(fn: () => void | (()=>void)){
|
||||||
|
this.disposables.add(effect(fn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,126 +1,53 @@
|
||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import type { TicTacToeState, TicTacToePart, PlayerType } from '@/game/tic-tac-toe';
|
import type {TicTacToeState, TicTacToePart} from '@/game/tic-tac-toe';
|
||||||
import type { GameHost, CommandSchema, IGameContext, MutableSignal } from 'boardgame-core';
|
import {ReadonlySignal} from "@preact/signals";
|
||||||
import { ReactiveScene, bindRegion, createInputMapper, InputMapper } from 'boardgame-phaser';
|
import {GameHostScene} from "@/scenes/GameHostScene";
|
||||||
|
import {spawnEffect, Spawner} from "@/utils/spawner";
|
||||||
|
|
||||||
const CELL_SIZE = 120;
|
const CELL_SIZE = 120;
|
||||||
const BOARD_OFFSET = { x: 100, y: 100 };
|
const BOARD_OFFSET = { x: 100, y: 100 };
|
||||||
const BOARD_SIZE = 3;
|
const BOARD_SIZE = 3;
|
||||||
|
|
||||||
export class GameScene extends ReactiveScene<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 inputMapper!: InputMapper;
|
|
||||||
private turnText!: Phaser.GameObjects.Text;
|
private turnText!: Phaser.GameObjects.Text;
|
||||||
/** Receives the active prompt schema from main.tsx */
|
private winnerOverlay?: Phaser.GameObjects.Container;
|
||||||
promptSchema: { current: CommandSchema | null } = { current: null };
|
|
||||||
/** GameHost instance passed from main.tsx */
|
|
||||||
private gameHost!: GameHost<TicTacToeState>;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('GameScene');
|
super('GameScene');
|
||||||
}
|
}
|
||||||
|
|
||||||
init(data: { gameHost: GameHost<TicTacToeState> } | { gameContext: IGameContext<TicTacToeState> }): void {
|
|
||||||
if ('gameHost' in data) {
|
|
||||||
this.gameHost = data.gameHost;
|
|
||||||
// Create a compatible gameContext from GameHost
|
|
||||||
this.gameContext = {
|
|
||||||
state: this.gameHost.state as MutableSignal<TicTacToeState>,
|
|
||||||
commands: this.gameHost.commands,
|
|
||||||
} as IGameContext<TicTacToeState>;
|
|
||||||
this.state = this.gameContext.state;
|
|
||||||
this.commands = this.gameContext.commands;
|
|
||||||
} else {
|
|
||||||
// Fallback for direct gameContext passing
|
|
||||||
super.init(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onStateReady(_state: TicTacToeState): void {
|
|
||||||
}
|
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
super.create();
|
super.create();
|
||||||
|
|
||||||
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.drawGrid();
|
||||||
|
|
||||||
|
this.disposables.add(spawnEffect(new TicTacToePartSpawner(this, this.gameHost.state)));
|
||||||
|
|
||||||
this.watch(() => {
|
this.watch(() => {
|
||||||
const winner = this.state.value.winner;
|
const winner = this.state.winner;
|
||||||
if (winner) {
|
if (winner) {
|
||||||
this.showWinner(winner);
|
this.showWinner(winner);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.watch(() => {
|
this.watch(() => {
|
||||||
const currentPlayer = this.state.value.currentPlayer;
|
const currentPlayer = this.state.currentPlayer;
|
||||||
this.updateTurnText(currentPlayer);
|
this.updateTurnText(currentPlayer);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setupInput();
|
this.setupInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected setupBindings(): void {
|
|
||||||
bindRegion<TicTacToeState, { player: PlayerType }>(
|
|
||||||
this.state,
|
|
||||||
(state) => state.parts,
|
|
||||||
(state) => state.board,
|
|
||||||
{
|
|
||||||
cellSize: { x: CELL_SIZE, y: CELL_SIZE },
|
|
||||||
offset: BOARD_OFFSET,
|
|
||||||
factory: (part, pos: Phaser.Math.Vector2) => {
|
|
||||||
const text = this.add.text(pos.x + CELL_SIZE / 2, pos.y + CELL_SIZE / 2, part.player, {
|
|
||||||
fontSize: '64px',
|
|
||||||
fontFamily: 'Arial',
|
|
||||||
color: part.player === 'X' ? '#3b82f6' : '#ef4444',
|
|
||||||
}).setOrigin(0.5);
|
|
||||||
|
|
||||||
// 添加落子动画
|
|
||||||
text.setScale(0);
|
|
||||||
this.tweens.add({
|
|
||||||
targets: text,
|
|
||||||
scale: 1,
|
|
||||||
duration: 200,
|
|
||||||
ease: 'Back.easeOut',
|
|
||||||
});
|
|
||||||
|
|
||||||
return text;
|
|
||||||
},
|
|
||||||
update: (part, obj) => {
|
|
||||||
// 可以在这里更新部件的视觉状态
|
|
||||||
},
|
|
||||||
},
|
|
||||||
this.boardContainer,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isCellOccupied(row: number, col: number): boolean {
|
private isCellOccupied(row: number, col: number): boolean {
|
||||||
const state = this.state.value;
|
return !!this.state.board.partMap[`${row},${col}`];
|
||||||
return !!state.board.partMap[`${row},${col}`];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupInput(): void {
|
private setupInput(): void {
|
||||||
this.inputMapper = createInputMapper(this, {
|
// todo
|
||||||
onSubmit: (cmd: string) => {
|
|
||||||
// Directly submit to GameHost
|
|
||||||
return this.gameHost.onInput(cmd);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.inputMapper.mapGridClick(
|
|
||||||
{ x: CELL_SIZE, y: CELL_SIZE },
|
|
||||||
BOARD_OFFSET,
|
|
||||||
{ cols: BOARD_SIZE, rows: BOARD_SIZE },
|
|
||||||
(col, row) => {
|
|
||||||
if (this.state.value.winner) return null;
|
|
||||||
|
|
||||||
const currentPlayer = this.state.value.currentPlayer;
|
|
||||||
if (this.isCellOccupied(row, col)) return null;
|
|
||||||
|
|
||||||
return `play ${currentPlayer} ${row} ${col}`;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawGrid(): void {
|
private drawGrid(): void {
|
||||||
|
|
@ -156,7 +83,7 @@ export class GameScene extends ReactiveScene<TicTacToeState> {
|
||||||
color: '#4b5563',
|
color: '#4b5563',
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
this.updateTurnText(this.state.value.currentPlayer);
|
this.updateTurnText(this.state.currentPlayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateTurnText(player: string): void {
|
private updateTurnText(player: string): void {
|
||||||
|
|
@ -166,8 +93,16 @@ export class GameScene extends ReactiveScene<TicTacToeState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private showWinner(winner: string): void {
|
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 text = winner === 'draw' ? "It's a draw!" : `${winner} wins!`;
|
||||||
|
|
||||||
|
this.winnerOverlay.add(
|
||||||
this.add.rectangle(
|
this.add.rectangle(
|
||||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
|
|
@ -175,6 +110,7 @@ export class GameScene extends ReactiveScene<TicTacToeState> {
|
||||||
BOARD_SIZE * CELL_SIZE,
|
BOARD_SIZE * CELL_SIZE,
|
||||||
0x000000,
|
0x000000,
|
||||||
0.6,
|
0.6,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const winText = this.add.text(
|
const winText = this.add.text(
|
||||||
|
|
@ -188,6 +124,8 @@ export class GameScene extends ReactiveScene<TicTacToeState> {
|
||||||
},
|
},
|
||||||
).setOrigin(0.5);
|
).setOrigin(0.5);
|
||||||
|
|
||||||
|
this.winnerOverlay.add(winText);
|
||||||
|
|
||||||
this.tweens.add({
|
this.tweens.add({
|
||||||
targets: winText,
|
targets: winText,
|
||||||
scale: 1.2,
|
scale: 1.2,
|
||||||
|
|
@ -197,3 +135,48 @@ export class GameScene extends ReactiveScene<TicTacToeState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.Text> {
|
||||||
|
constructor(public readonly scene: GameScene, public readonly state: ReadonlySignal<TicTacToeState>) {}
|
||||||
|
|
||||||
|
*getData() {
|
||||||
|
for (const part of Object.values(this.state.value.parts)) {
|
||||||
|
yield part;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getKey(part: TicTacToePart): string {
|
||||||
|
return part.id;
|
||||||
|
}
|
||||||
|
onUpdate(part: TicTacToePart, obj: Phaser.GameObjects.Text): void {
|
||||||
|
const [xIndex, yIndex] = part.position;
|
||||||
|
const x = xIndex * CELL_SIZE + BOARD_OFFSET.x;
|
||||||
|
const y = yIndex * CELL_SIZE + BOARD_OFFSET.y;
|
||||||
|
obj.x = x;
|
||||||
|
obj.y = y;
|
||||||
|
}
|
||||||
|
onSpawn(part: TicTacToePart) {
|
||||||
|
const [xIndex, yIndex] = part.position;
|
||||||
|
const x = xIndex * CELL_SIZE + BOARD_OFFSET.x;
|
||||||
|
const y = yIndex * CELL_SIZE + BOARD_OFFSET.y;
|
||||||
|
const text = this.scene.add.text(x + CELL_SIZE / 2, y + CELL_SIZE / 2, part.player, {
|
||||||
|
fontSize: '64px',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: part.player === 'X' ? '#3b82f6' : '#ef4444',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
// 添加落子动画
|
||||||
|
text.setScale(0);
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: text,
|
||||||
|
scale: 1,
|
||||||
|
duration: 200,
|
||||||
|
ease: 'Back.easeOut',
|
||||||
|
});
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDespawn(obj: Phaser.GameObjects.Text) {
|
||||||
|
obj.removedFromScene();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import {useComputed } from '@preact/signals';
|
||||||
|
import { createGameHost, GameModule } from "boardgame-core";
|
||||||
|
import Phaser from "phaser";
|
||||||
|
import {h} from "preact";
|
||||||
|
import {PhaserGame, PhaserScene} from "@/ui/PhaserGame";
|
||||||
|
|
||||||
|
export default function App<T extends Record<string, unknown>>(props: { gameModule: GameModule<T>, gameScene: {new(): Phaser.Scene} }) {
|
||||||
|
|
||||||
|
const gameHost = useComputed(() => {
|
||||||
|
return {
|
||||||
|
gameHost: createGameHost(props.gameModule, 'setup')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const scene = useComputed(() => new props.gameScene());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import Phaser from "phaser";
|
||||||
|
import {createContext, h} from "preact";
|
||||||
|
import {Signal, useSignalEffect, signal, useSignal} from "@preact/signals";
|
||||||
|
import {useContext} from "preact/hooks";
|
||||||
|
|
||||||
|
export const phaserContext = createContext<Signal<Phaser.Game | undefined>>(signal());
|
||||||
|
|
||||||
|
export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
|
||||||
|
type: Phaser.AUTO,
|
||||||
|
width: 560,
|
||||||
|
height: 560,
|
||||||
|
parent: 'phaser-container',
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
scene: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PhaserGame(props: { config?: Partial<Phaser.Types.Core.GameConfig>, children?: any}){
|
||||||
|
|
||||||
|
const gameSignal = useSignal<Phaser.Game>();
|
||||||
|
|
||||||
|
useSignalEffect(() => {
|
||||||
|
const phaserGame = new Phaser.Game(props.config || defaultPhaserConfig);
|
||||||
|
gameSignal.value = phaserGame;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
gameSignal.value = undefined;
|
||||||
|
phaserGame.destroy(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div id="phaser-container" className="w-full h-full" >
|
||||||
|
<phaserContext.Provider value={gameSignal}>
|
||||||
|
{props.children}
|
||||||
|
</phaserContext.Provider>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhaserScene(props: { sceneKey: string, scene: Phaser.Scene, autoStart: boolean, data?: object}){
|
||||||
|
const context = useContext(phaserContext);
|
||||||
|
useSignalEffect(() => {
|
||||||
|
const game = context.value;
|
||||||
|
if(!game) return;
|
||||||
|
|
||||||
|
game.scene.add(props.sceneKey, props.scene, props.autoStart, props.data);
|
||||||
|
return () => {
|
||||||
|
game.scene.remove(props.sceneKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
export interface IDisposable {
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
export type DisposableItem = IDisposable | (() => void);
|
||||||
|
|
||||||
|
export class DisposableBag implements IDisposable {
|
||||||
|
private _disposables = new Set<DisposableItem>();
|
||||||
|
private _isDisposed = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the bag has already been disposed.
|
||||||
|
*/
|
||||||
|
get isDisposed(): boolean {
|
||||||
|
return this._isDisposed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a disposable or a cleanup function to the bag.
|
||||||
|
*/
|
||||||
|
add(item: DisposableItem): void {
|
||||||
|
if (this._isDisposed) {
|
||||||
|
this._execute(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._disposables.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposes all items currently in the bag and clears the collection.
|
||||||
|
*/
|
||||||
|
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,35 @@
|
||||||
|
import Phaser from "phaser";
|
||||||
|
import {effect} from "@preact/signals";
|
||||||
|
|
||||||
|
type GO = Phaser.GameObjects.GameObject;
|
||||||
|
export interface Spawner<TData, TObject extends GO = GO> {
|
||||||
|
getData(): Iterable<TData>;
|
||||||
|
getKey(t: TData): string;
|
||||||
|
onSpawn(t: TData): TObject | null;
|
||||||
|
onDespawn(obj: TObject): void;
|
||||||
|
onUpdate(t: TData, obj: TObject): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spawnEffect<TData, TObject extends GO = GO>(spawner: Spawner<TData, TObject>){
|
||||||
|
const objects = new Map<string, TObject>();
|
||||||
|
return effect(() => {
|
||||||
|
const current = new Set<string>();
|
||||||
|
for (const t of spawner.getData()) {
|
||||||
|
const key = spawner.getKey(t);
|
||||||
|
current.add(key);
|
||||||
|
if (!objects.has(key)) {
|
||||||
|
const obj = spawner.onSpawn(t);
|
||||||
|
if(obj) objects.set(key, obj);
|
||||||
|
}else{
|
||||||
|
spawner.onUpdate(t, objects.get(key)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, obj] of objects) {
|
||||||
|
if (!current.has(key)) {
|
||||||
|
spawner.onDespawn(obj);
|
||||||
|
objects.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue