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

159 lines
5.1 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 {createGameContext, IGameContext} from './game';
export type GameHostStatus = 'created' | 'running' | 'disposed';
export interface GameModule<TState extends Record<string, unknown>> {
registry: CommandRegistry<IGameContext<TState>>;
createInitialState: () => TState;
}
export class GameHost<TState extends Record<string, 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 _status: Signal<GameHostStatus>;
private _activePromptSchema: Signal<CommandSchema | null>;
private _activePromptPlayer: Signal<string | null>;
private _createInitialState: () => TState;
private _eventListeners: Map<'setup' | 'dispose', Set<() => void>>;
private _isDisposed = false;
constructor(
registry: CommandRegistry<IGameContext<TState>>,
createInitialState: () => TState,
) {
this._createInitialState = createInitialState;
this._eventListeners = new Map();
const initialState = createInitialState();
this.context = createGameContext(registry, initialState);
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();
}
async setup(setupCommand: string): Promise<void> {
if (this._isDisposed) {
throw new Error('GameHost is disposed');
}
this._commands._cancel();
const initialState = this._createInitialState();
this._state.value = initialState as any;
// Start the setup command but don't wait for it to complete
// The command will run in the background and prompt for input
this._commands.run(setupCommand).catch(() => {
// Command may be cancelled or fail, which is expected
});
this._status.value = 'running';
this._emitEvent('setup');
}
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: 'setup' | '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: 'setup' | 'dispose') {
const listeners = this._eventListeners.get(event);
if (listeners) {
for (const listener of listeners) {
listener();
}
}
}
}
export function createGameHost<TState extends Record<string, unknown>>(
module: GameModule<TState>,
): GameHost<TState> {
return new GameHost(
module.registry,
module.createInitialState,
);
}