boardgame-core/src/core/game-host.ts

167 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ReadonlySignal, Signal } from '@preact/signals-core';
import type {
CommandSchema,
CommandRegistry,
PromptEvent,
CommandRunnerContextExport,
} from '@/utils/command';
import type { MutableSignal } from '@/utils/mutable-signal';
import {createGameCommandRegistry, createGameContext, IGameContext} from './game';
export type GameHostStatus = 'created' | 'running' | 'disposed';
export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
readonly context: IGameContext<TState>;
readonly status: ReadonlySignal<GameHostStatus>;
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>;
readonly activePromptPlayer: ReadonlySignal<string | null>;
private _state: MutableSignal<TState>;
private _commands: CommandRunnerContextExport<IGameContext<TState>>;
private _start: (ctx: IGameContext<TState>) => Promise<TResult>;
private _status: Signal<GameHostStatus>;
private _activePromptSchema: Signal<CommandSchema | null>;
private _activePromptPlayer: Signal<string | null>;
private _createInitialState: () => TState;
private _eventListeners: Map<'start' | 'dispose', Set<() => void>>;
private _isDisposed = false;
constructor(
registry: CommandRegistry<IGameContext<TState>>,
createInitialState: () => TState,
start: (ctx: IGameContext<TState>) => Promise<TResult>
) {
this._createInitialState = createInitialState;
this._eventListeners = new Map();
const initialState = createInitialState();
this.context = createGameContext(registry, initialState);
this._start = start;
this._status = new Signal<GameHostStatus>('created');
this.status = this._status;
this._activePromptSchema = new Signal<CommandSchema | null>(null);
this.activePromptSchema = this._activePromptSchema;
this._activePromptPlayer = new Signal<string | null>(null);
this.activePromptPlayer = this._activePromptPlayer;
this._state = this.context._state;
this._commands = this.context._commands;
this._setupPromptTracking();
}
private _setupPromptTracking() {
let currentPromptEvent: PromptEvent | null = null;
this._commands.on('prompt', (e) => {
currentPromptEvent = e as PromptEvent;
this._activePromptSchema.value = currentPromptEvent.schema;
this._activePromptPlayer.value = currentPromptEvent.currentPlayer;
});
this._commands.on('promptEnd', () => {
currentPromptEvent = null;
this._activePromptSchema.value = null;
this._activePromptPlayer.value = null;
});
// Initial state
this._activePromptSchema.value = null;
this._activePromptPlayer.value = null;
}
onInput(input: string): string | null {
if (this._isDisposed) {
return 'GameHost is disposed';
}
return this._commands._tryCommit(input);
}
/**
* 为下一个 produceAsync 注册中断 Promise通常用于 UI 动画)。
* @see MutableSignal.addInterruption
*/
addInterruption(promise: Promise<void>): void {
this._state.addInterruption(promise);
}
/**
* 清除所有未完成的中断。
* @see MutableSignal.clearInterruptions
*/
clearInterruptions(): void {
this._state.clearInterruptions();
}
start(): Promise<TResult> {
if (this._isDisposed) {
throw new Error('GameHost is disposed');
}
this._commands._cancel();
const initialState = this._createInitialState();
this._state.value = initialState as any;
const promise = this._start(this.context);
this._status.value = 'running';
this._emitEvent('start');
return promise;
}
dispose(): void {
if (this._isDisposed) {
return;
}
this._isDisposed = true;
this._commands._cancel();
this._status.value = 'disposed';
// Emit dispose event BEFORE clearing listeners
this._emitEvent('dispose');
this._eventListeners.clear();
}
on(event: 'start' | 'dispose', listener: () => void): () => void {
if (!this._eventListeners.has(event)) {
this._eventListeners.set(event, new Set());
}
this._eventListeners.get(event)!.add(listener);
return () => {
this._eventListeners.get(event)?.delete(listener);
};
}
private _emitEvent(event: 'start' | 'dispose') {
const listeners = this._eventListeners.get(event);
if (listeners) {
for (const listener of listeners) {
listener();
}
}
}
}
export type GameModule<TState extends Record<string, unknown>, TResult=unknown> = {
registry?: CommandRegistry<IGameContext<TState>>;
createInitialState: () => TState;
start: (ctx: IGameContext<TState>) => Promise<TResult>;
}
export function createGameHost<TState extends Record<string, unknown>>(
gameModule: GameModule<TState>
): GameHost<TState> {
return new GameHost(
gameModule.registry || createGameCommandRegistry(),
gameModule.createInitialState,
gameModule.start
);
}