From 20993d3b729532a99e9ea8426829222a447d864e Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 25 Apr 2026 17:31:15 +0800 Subject: [PATCH] feat: add BaseGameClient for external integration Introduces `BaseGameClient` to allow external environments (like C#) to interact with the game engine. It provides mechanisms for: - Reactive state selection via `select` - Async event interception via `use` - Lifecycle management and status tracking - Interruption handling for async game loops Updated `middleware.ts` to allow unregistering middleware via the returned function from `use`. --- src/core/game-client.ts | 103 +++++++++++++++++++++++++++++++++++++ src/index.ts | 2 + src/samples/tic-tac-toe.ts | 61 ++++++++++++++++++++++ src/utils/middleware.ts | 20 +++++-- 4 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 src/core/game-client.ts diff --git a/src/core/game-client.ts b/src/core/game-client.ts new file mode 100644 index 0000000..0c487c4 --- /dev/null +++ b/src/core/game-client.ts @@ -0,0 +1,103 @@ +import { createPromptContext, PromptCall } from "@/utils/command"; +import { createMiddlewareChain } from "@/utils/middleware"; +import { computed } from "@preact/signals-core"; +import { createGameContext, IGameContext } from "./game"; + +export interface GameClient { + // c# calls this to generate a ReactiveProperty + select( + type: K, + id: string, + callback: (t: E[K]) => void, + ): () => void; + + // c# calls this to interrupt and capture async events + use( + trigger: K, + callback: (ctx: T[K], next: () => Promise) => Promise, + ): () => void; +} + +export type EntityMap = {}; + +export type ClientTriggerMap = { + status: { status: ClientStatus; result?: unknown }; + prompt: PromptCall; +}; + +export enum ClientStatus { + Idle = "idle", + Running = "running", + Disposed = "disposed", +} +export abstract class BaseGameClient< + T extends Record = Record, + R = unknown, +> { + protected _initialState: T; + protected _context: IGameContext; + protected _prompts = createPromptContext(); + protected _status = ClientStatus.Idle; + + protected _setStatus = createMiddlewareChain( + async (ctx: ClientTriggerMap["status"]) => { + this._status = ctx.status; + if (this._status === ClientStatus.Disposed) { + this._prompts.reset(); + } else if (this._status === ClientStatus.Running) { + this._prompts.reset(); + this._context._state.value = this._initialState; + const r = await this.start(this._context); + this._setStatus.execute({ status: ClientStatus.Idle, result: r }); + } + }, + ); + constructor(is: T) { + this._initialState = is; + this._context = createGameContext(this._prompts, is); + } + + setStatus(status: ClientStatus) { + this._setStatus.execute({ status }); + } + + abstract start(ctx: IGameContext): Promise; + + protected assignSelector(selector: (t: T) => S, callback: (s: S) => void) { + const selected = computed(() => selector(this._context.value)); + return selected.subscribe(callback); + } + + addInterruption() { + let resolve!: () => void; + const promise = new Promise((resolver) => { + resolve = resolver; + }); + this._context._state.addInterruption(promise); + return resolve; + } + + useClient( + trigger: K, + callback: ( + ctx: ClientTriggerMap[K], + next: () => Promise, + ) => Promise, + ) { + if (trigger === "prompt") { + return this._prompts.handleCall.use( + callback as ( + ctx: ClientTriggerMap["prompt"], + next: () => Promise, + ) => Promise, + ); + } else if (trigger === "status") { + return this._setStatus.use( + callback as ( + ctx: ClientTriggerMap["status"], + next: () => Promise, + ) => Promise, + ); + } + } +} diff --git a/src/index.ts b/src/index.ts index 2d411d1..52cd397 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,8 @@ export { moveToRegion, } from "./core/region"; +export * from "./core/game-client"; + // Utils export type { Command, diff --git a/src/samples/tic-tac-toe.ts b/src/samples/tic-tac-toe.ts index ffa94d6..2176cde 100644 --- a/src/samples/tic-tac-toe.ts +++ b/src/samples/tic-tac-toe.ts @@ -1,6 +1,8 @@ import { Part } from "@/core/part"; import { createRegion } from "@/core/region"; import { createPromptDef, IGameContext } from "@/core/game"; +import { BaseGameClient, GameClient } from "@/core/game-client"; +import { createMiddlewareChain } from "@/utils/middleware"; const BOARD_SIZE = 3; const MAX_TURNS = BOARD_SIZE * BOARD_SIZE; @@ -183,3 +185,62 @@ export async function placePiece( board.partMap[`${row},${col}`] = piece.id; }); } + +type EntityMap = { parts: TicTacToePart }; +type TriggerMap = { + turn: { player: PlayerType; turn: number }; +}; +export class Client + extends BaseGameClient + implements GameClient +{ + private onTurn = createMiddlewareChain( + async (ctx: { player: PlayerType; turn: number }) => { + return await turn(this._context, ctx.player, ctx.turn); + }, + ); + + constructor() { + super(createInitialState()); + } + + async start(game: IGameContext): Promise { + while (true) { + const currentPlayer = game.value.currentPlayer; + const turnNumber = game.value.turn + 1; + const turnOutput = await this.onTurn.execute({ + player: currentPlayer, + turn: turnNumber, + }); + + await game.produceAsync((state) => { + state.winner = turnOutput.winner; + if (!state.winner) { + state.currentPlayer = state.currentPlayer === "X" ? "O" : "X"; + state.turn = turnNumber; + } + }); + if (game.value.winner) break; + } + + return game.value; + } + select( + type: K, + id: string, + callback: (t: EntityMap[K]) => void, + ): () => void { + if (type === "parts") + return this.assignSelector((state) => state.parts[id], callback); + return function () {}; + } + use( + trigger: K, + callback: (ctx: TriggerMap[K], next: () => Promise) => Promise, + ): () => void { + if (trigger === "turn") { + return this.onTurn.use(callback); + } + return function () {}; + } +} diff --git a/src/utils/middleware.ts b/src/utils/middleware.ts index 5b415ec..6bd512d 100644 --- a/src/utils/middleware.ts +++ b/src/utils/middleware.ts @@ -1,28 +1,38 @@ type Middleware = ( context: TContext, - next: () => Promise + next: () => Promise, ) => Promise; export type MiddlewareChain = { - use: (middleware: Middleware) => void; + use: (middleware: Middleware) => () => void; execute: (context: TContext) => Promise; }; -export function createMiddlewareChain( - fallback?: (context: TContext) => Promise +export function createMiddlewareChain< + TContext extends object, + TReturn = TContext, +>( + fallback?: (context: TContext) => Promise, ): MiddlewareChain { const middlewares: Middleware[] = []; return { use(middleware: Middleware) { middlewares.push(middleware); + + return function () { + const index = middlewares.indexOf(middleware); + if (index !== -1) { + middlewares.splice(index, 1); + } + }; }, async execute(context: TContext) { let index = 0; async function dispatch(ctx: TContext): Promise { if (index >= middlewares.length) { - return fallback ? fallback(ctx) : ctx as unknown as TReturn; + return fallback ? fallback(ctx) : (ctx as unknown as TReturn); } const current = middlewares[index++]; return current(ctx, () => dispatch(ctx));