refactor: simplify GameClient API using signals
Replaces the middleware-based `useClient` system with a more direct reactive API using Preact signals. - Replace `_setStatus` middleware with a `_status` signal - Replace `useClient` with `selectPrompt`, `selectStatus`, and `selectResult` - Implement `selectPrompt` to provide a `tryAnswer` function for handling prompt resolutions - Add `_result` signal to track game completion results - Simplify `setStatus` logic to manage lifecycle and state resets
This commit is contained in:
parent
20993d3b72
commit
03db1e8a06
|
|
@ -1,35 +1,31 @@
|
||||||
import { createPromptContext, PromptCall } from "@/utils/command";
|
import { createPromptContext, PromptDef } from "@/utils/command";
|
||||||
import { createMiddlewareChain } from "@/utils/middleware";
|
import { computed, effect, signal } from "@preact/signals-core";
|
||||||
import { computed } from "@preact/signals-core";
|
|
||||||
import { createGameContext, IGameContext } from "./game";
|
import { createGameContext, IGameContext } from "./game";
|
||||||
|
|
||||||
export interface GameClient<E extends {} = {}, T extends {} = {}> {
|
|
||||||
// c# calls this to generate a ReactiveProperty<T>
|
|
||||||
select<K extends keyof E>(
|
|
||||||
type: K,
|
|
||||||
id: string,
|
|
||||||
callback: (t: E[K]) => void,
|
|
||||||
): () => void;
|
|
||||||
|
|
||||||
// c# calls this to interrupt and capture async events
|
|
||||||
use<K extends keyof T>(
|
|
||||||
trigger: K,
|
|
||||||
callback: (ctx: T[K], next: () => Promise<any>) => Promise<any>,
|
|
||||||
): () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EntityMap = {};
|
|
||||||
|
|
||||||
export type ClientTriggerMap = {
|
|
||||||
status: { status: ClientStatus; result?: unknown };
|
|
||||||
prompt: PromptCall;
|
|
||||||
};
|
|
||||||
|
|
||||||
export enum ClientStatus {
|
export enum ClientStatus {
|
||||||
Idle = "idle",
|
Idle = "idle",
|
||||||
Running = "running",
|
Running = "running",
|
||||||
Disposed = "disposed",
|
Disposed = "disposed",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PromptInfo =
|
||||||
|
| {
|
||||||
|
hasPrompt: false;
|
||||||
|
player: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
hasPrompt: true;
|
||||||
|
player: string;
|
||||||
|
def: PromptDef<unknown[]>;
|
||||||
|
tryAnswer(...args: unknown[]): string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. create a client instance
|
||||||
|
* 2. setStatus("running")
|
||||||
|
* 3. select("selector", t => {}) for c# to capture game state
|
||||||
|
* 4. useClient("status" | "prompt") for c# to handle prompt / game status
|
||||||
|
*/
|
||||||
export abstract class BaseGameClient<
|
export abstract class BaseGameClient<
|
||||||
T extends Record<string, unknown> = Record<string, unknown>,
|
T extends Record<string, unknown> = Record<string, unknown>,
|
||||||
R = unknown,
|
R = unknown,
|
||||||
|
|
@ -37,33 +33,32 @@ export abstract class BaseGameClient<
|
||||||
protected _initialState: T;
|
protected _initialState: T;
|
||||||
protected _context: IGameContext<T>;
|
protected _context: IGameContext<T>;
|
||||||
protected _prompts = createPromptContext();
|
protected _prompts = createPromptContext();
|
||||||
protected _status = ClientStatus.Idle;
|
protected _status = signal(ClientStatus.Idle);
|
||||||
|
protected _result = signal(null as null | R);
|
||||||
protected _setStatus = createMiddlewareChain(
|
public get state() {
|
||||||
async (ctx: ClientTriggerMap["status"]) => {
|
return this._context.value;
|
||||||
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) {
|
constructor(is: T) {
|
||||||
this._initialState = is;
|
this._initialState = is;
|
||||||
this._context = createGameContext(this._prompts, is);
|
this._context = createGameContext(this._prompts, is);
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(status: ClientStatus) {
|
async setStatus(status: ClientStatus) {
|
||||||
this._setStatus.execute({ status });
|
this._status.value = status;
|
||||||
|
if (status === ClientStatus.Disposed) {
|
||||||
|
this._prompts.reset();
|
||||||
|
} else if (status === ClientStatus.Running) {
|
||||||
|
this._prompts.reset();
|
||||||
|
this._context._state.value = this._initialState;
|
||||||
|
this._result.value = null;
|
||||||
|
this._result.value = await this.start(this._context);
|
||||||
|
this.setStatus(ClientStatus.Idle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract start(ctx: IGameContext<T>): Promise<R>;
|
abstract start(ctx: IGameContext<T>): Promise<R>;
|
||||||
|
|
||||||
protected assignSelector<S>(selector: (t: T) => S, callback: (s: S) => void) {
|
assignSelector<S>(selector: (t: T) => S, callback: (s: S) => void) {
|
||||||
const selected = computed(() => selector(this._context.value));
|
const selected = computed(() => selector(this._context.value));
|
||||||
return selected.subscribe(callback);
|
return selected.subscribe(callback);
|
||||||
}
|
}
|
||||||
|
|
@ -77,27 +72,42 @@ export abstract class BaseGameClient<
|
||||||
return resolve;
|
return resolve;
|
||||||
}
|
}
|
||||||
|
|
||||||
useClient<K extends keyof ClientTriggerMap>(
|
selectPrompt(callback: (prompt: PromptInfo) => void) {
|
||||||
trigger: K,
|
return this._prompts.handleCall.use(async (ctx, next) => {
|
||||||
callback: (
|
function tryAnswer(...args: any[]) {
|
||||||
ctx: ClientTriggerMap[K],
|
try {
|
||||||
next: () => Promise<void>,
|
const res = ctx.validator(...args);
|
||||||
) => Promise<void>,
|
ctx.resolve(res);
|
||||||
) {
|
return null;
|
||||||
if (trigger === "prompt") {
|
} catch (reason) {
|
||||||
return this._prompts.handleCall.use(
|
if (typeof reason === "string") {
|
||||||
callback as (
|
return reason;
|
||||||
ctx: ClientTriggerMap["prompt"],
|
}
|
||||||
next: () => Promise<unknown>,
|
throw reason;
|
||||||
) => Promise<void>,
|
}
|
||||||
);
|
}
|
||||||
} else if (trigger === "status") {
|
|
||||||
return this._setStatus.use(
|
callback({
|
||||||
callback as (
|
hasPrompt: true,
|
||||||
ctx: ClientTriggerMap["status"],
|
player: ctx.player || "global",
|
||||||
next: () => Promise<void>,
|
def: ctx.def,
|
||||||
) => Promise<void>,
|
tryAnswer,
|
||||||
);
|
});
|
||||||
}
|
await next();
|
||||||
|
callback({
|
||||||
|
hasPrompt: false,
|
||||||
|
player: ctx.player || "global",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectStatus(callback: (status: ClientStatus) => void) {
|
||||||
|
return effect(() => {
|
||||||
|
callback(this._status.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectResult(callback: (status: R | null) => void) {
|
||||||
|
return effect(() => {
|
||||||
|
callback(this._result.value);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Part } from "@/core/part";
|
import { Part } from "@/core/part";
|
||||||
import { createRegion } from "@/core/region";
|
import { createRegion, Region } from "@/core/region";
|
||||||
import { createPromptDef, IGameContext } from "@/core/game";
|
import { createPromptDef, IGameContext } from "@/core/game";
|
||||||
import { BaseGameClient, GameClient } from "@/core/game-client";
|
import { BaseGameClient, GameClient } from "@/core/game-client";
|
||||||
import { createMiddlewareChain } from "@/utils/middleware";
|
import { createMiddlewareChain } from "@/utils/middleware";
|
||||||
|
|
@ -186,7 +186,7 @@ export async function placePiece(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type EntityMap = { parts: TicTacToePart };
|
type EntityMap = { parts: TicTacToePart; board: Region };
|
||||||
type TriggerMap = {
|
type TriggerMap = {
|
||||||
turn: { player: PlayerType; turn: number };
|
turn: { player: PlayerType; turn: number };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue