refactor: clean up

This commit is contained in:
2026-04-04 13:21:44 +08:00
parent 2b59adf000
commit 2984d8b20d
16 changed files with 152 additions and 788 deletions
+2
View File
@@ -18,6 +18,7 @@
"test:watch": "vitest"
},
"peerDependencies": {
"@preact/signals": "^1.2.0 || ^2.0.0",
"@preact/signals-core": "^1.5.1",
"boardgame-core": ">=1.0.0",
"mutative": "^1.3.0",
@@ -25,6 +26,7 @@
"preact": "^10.19.0"
},
"devDependencies": {
"@preact/signals": "^2.9.0",
"@preact/signals-core": "^1.5.1",
"boardgame-core": "link:../../../boardgame-core",
"mutative": "^1.3.0",
+12 -11
View File
@@ -1,14 +1,15 @@
export { ReactiveScene } from './scenes/ReactiveScene';
export type { ReactiveSceneOptions } from './scenes/ReactiveScene';
// Resource management
export { DisposableBag } from './utils';
export type { IDisposable, DisposableItem } from './utils';
export { bindSignal, bindGameObjectProperty, bindRegion, bindCollection } from './bindings';
export type { BindRegionOptions, BindCollectionOptions } from './bindings';
// Data-driven object spawning
export { spawnEffect } from './spawner';
export type { Spawner } from './spawner';
export { InputMapper, PromptHandler, createInputMapper, createPromptHandler } from './input';
export type { InputMapperOptions, PromptHandlerOptions } from './input';
// Scene base classes
export { GameHostScene } from './scenes';
export type { GameHostSceneOptions } from './scenes';
export { GameUI } from './ui/GameUI';
export type { GameUIOptions } from './ui/GameUI';
export { PromptDialog } from './ui/PromptDialog';
export { CommandLog } from './ui/CommandLog';
// React ↔ Phaser bridge
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig, GameUI } from './ui';
export type { PhaserGameProps, PhaserSceneProps, GameUIOptions } from './ui';
@@ -0,0 +1,40 @@
import Phaser from 'phaser';
import { effect, type ReadonlySignal } from '@preact/signals-core';
import type { GameHost } from 'boardgame-core';
import { DisposableBag, type IDisposable } from '../utils';
type CleanupFn = void | (() => void);
export interface GameHostSceneOptions<TState extends Record<string, unknown>> {
gameHost: GameHost<TState>;
}
export abstract class GameHostScene<TState extends Record<string, unknown>>
extends Phaser.Scene
implements IDisposable
{
protected disposables = new DisposableBag();
protected gameHost!: GameHost<TState>;
init(data: GameHostSceneOptions<TState>): void {
this.gameHost = data.gameHost;
}
create(): void {
this.events.on('shutdown', this.dispose, this);
}
dispose(): void {
this.disposables.dispose();
}
/** 获取当前状态的只读快照 */
protected get state(): TState {
return this.gameHost.state.value;
}
/** 注册响应式监听(场景关闭时自动清理) */
protected watch(fn: () => CleanupFn): void {
this.disposables.add(effect(fn));
}
}
+2
View File
@@ -0,0 +1,2 @@
export { GameHostScene } from './GameHostScene';
export type { GameHostSceneOptions } from './GameHostScene';
@@ -1,30 +1,44 @@
import Phaser from "phaser";
import {effect} from "@preact/signals";
import Phaser from 'phaser';
import { effect, type ReadonlySignal } from '@preact/signals-core';
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>){
export function spawnEffect<TData, TObject extends GO = GO>(
spawner: Spawner<TData, TObject>,
): () => void {
const objects = new Map<string, TObject>();
return effect(() => {
const current = new Set<string>();
for (const t of spawner.getData()) {
for (const t of spawner.getData()) {
const key = spawner.getKey(t);
current.add(key);
if (!objects.has(key)) {
const existing = objects.get(key);
if (!existing) {
const obj = spawner.onSpawn(t);
if(obj) objects.set(key, obj);
}else{
spawner.onUpdate(t, objects.get(key)!);
if (obj) {
objects.set(key, obj);
}
} else {
spawner.onUpdate(t, existing);
}
}
for (const [key, obj] of objects) {
if (!current.has(key)) {
spawner.onDespawn(obj);
@@ -32,4 +46,4 @@ export function spawnEffect<TData, TObject extends GO = GO>(spawner: Spawner<TDa
}
}
});
}
}
@@ -1,9 +1,9 @@
import Phaser from "phaser";
import {createContext, h} from "preact";
import {Signal, useSignalEffect, signal, useSignal} from "@preact/signals";
import {useContext} from "preact/hooks";
import Phaser from 'phaser';
import { signal, useSignal, useSignalEffect, type Signal } from '@preact/signals';
import { createContext, h } from 'preact';
import { useContext } from 'preact/hooks';
export const phaserContext = createContext<Signal<Phaser.Game | undefined>>(signal());
export const phaserContext = createContext<Signal<Phaser.Game | undefined>>(signal(undefined));
export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
@@ -14,38 +14,52 @@ export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
scene: [],
};
export function PhaserGame(props: { config?: Partial<Phaser.Types.Core.GameConfig>, children?: any}){
export interface PhaserGameProps {
config?: Partial<Phaser.Types.Core.GameConfig>;
children?: any;
}
export function PhaserGame(props: PhaserGameProps) {
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>
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}){
export interface PhaserSceneProps {
sceneKey: string;
scene: Phaser.Scene;
autoStart: boolean;
data?: object;
}
export function PhaserScene(props: PhaserSceneProps) {
const context = useContext(phaserContext);
useSignalEffect(() => {
const game = context.value;
if(!game) return;
if (!game) return;
game.scene.add(props.sceneKey, props.scene, props.autoStart, props.data);
return () => {
game.scene.remove(props.sceneKey);
}
};
});
return null;
}
}
+5
View File
@@ -0,0 +1,5 @@
export { GameUI } from './GameUI';
export type { GameUIOptions } from './GameUI';
export { PhaserGame, PhaserScene, phaserContext, defaultPhaserConfig } from './PhaserBridge';
export type { PhaserGameProps, PhaserSceneProps } from './PhaserBridge';
@@ -1,22 +1,17 @@
export interface IDisposable {
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);
@@ -25,9 +20,6 @@ export class DisposableBag implements IDisposable {
this._disposables.add(item);
}
/**
* Disposes all items currently in the bag and clears the collection.
*/
dispose(): void {
if (this._isDisposed) return;
@@ -37,7 +29,7 @@ export class DisposableBag implements IDisposable {
try {
this._execute(item);
} catch (error) {
console.error("Error during resource disposal:", error);
console.error('Error during resource disposal:', error);
}
}
@@ -51,4 +43,4 @@ export class DisposableBag implements IDisposable {
item.dispose();
}
}
}
}
+2
View File
@@ -0,0 +1,2 @@
export { DisposableBag } from './disposable';
export type { IDisposable, DisposableItem } from './disposable';
@@ -3,6 +3,7 @@ import {
type Part,
createRegion,
type MutableSignal,
type GameModule,
} from 'boardgame-core';
const BOARD_SIZE = 3;
@@ -40,6 +41,11 @@ export type TicTacToeState = ReturnType<typeof createInitialState>;
const registration = createGameCommandRegistry<TicTacToeState>();
export const registry = registration.registry;
export const gameModule: GameModule<TicTacToeState> = {
registry,
createInitialState,
};
registration.add('setup', async function () {
const { context } = this;
while (true) {
+2 -2
View File
@@ -1,13 +1,13 @@
import { h } from 'preact';
import { GameUI } from 'boardgame-phaser';
import * as ticTacToe from './game/tic-tac-toe';
import { gameModule } from './game/tic-tac-toe';
import './style.css';
import App from "@/ui/App";
import {GameScene} from "@/scenes/GameScene";
const ui = new GameUI({
container: document.getElementById('ui-root')!,
root: <App gameModule={ticTacToe} gameScene={GameScene}/>,
root: <App gameModule={gameModule} gameScene={GameScene}/>,
});
ui.mount();
@@ -1,26 +0,0 @@
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));
}
}
+3 -3
View File
@@ -1,8 +1,8 @@
import Phaser from 'phaser';
import type {TicTacToeState, TicTacToePart} from '@/game/tic-tac-toe';
import {ReadonlySignal} from "@preact/signals";
import {GameHostScene} from "@/scenes/GameHostScene";
import {spawnEffect, Spawner} from "@/utils/spawner";
import { GameHostScene } from 'boardgame-phaser';
import { spawnEffect, type Spawner } from 'boardgame-phaser';
import type { ReadonlySignal } from '@preact/signals-core';
import {commands} from "@/game/tic-tac-toe";
const CELL_SIZE = 120;
+11 -13
View File
@@ -1,26 +1,24 @@
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";
import { useComputed } from '@preact/signals';
import { createGameHost, type GameModule } from 'boardgame-core';
import Phaser from 'phaser';
import { h } from 'preact';
import { PhaserGame, PhaserScene } from 'boardgame-phaser';
export default function App<TState extends Record<string, unknown>>(props: { gameModule: GameModule<TState>, gameScene: { new(): Phaser.Scene } }) {
export default function App<T extends Record<string, unknown>>(props: { gameModule: GameModule<T>, gameScene: {new(): Phaser.Scene} }) {
const gameHost = useComputed(() => {
const gameHost = createGameHost(props.gameModule);
gameHost.setup('setup');
return {
gameHost
}
return { gameHost };
});
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}/>
<PhaserScene sceneKey="GameScene" scene={scene.value} autoStart data={gameHost.value} />
</PhaserGame>
</div>
</div>