2026-04-04 14:05:23 +08:00
|
|
|
|
import { ReadonlySignal, Signal } from '@preact/signals-core';
|
2026-04-04 23:50:46 +08:00
|
|
|
|
import type {
|
|
|
|
|
|
CommandSchema,
|
|
|
|
|
|
CommandRegistry,
|
|
|
|
|
|
PromptEvent,
|
|
|
|
|
|
CommandRunnerContextExport,
|
|
|
|
|
|
CommandResult
|
|
|
|
|
|
} from '@/utils/command';
|
2026-04-04 00:54:19 +08:00
|
|
|
|
import type { MutableSignal } from '@/utils/mutable-signal';
|
2026-04-04 20:50:17 +08:00
|
|
|
|
import {createGameContext, IGameContext} from './game';
|
2026-04-04 00:54:19 +08:00
|
|
|
|
|
|
|
|
|
|
export type GameHostStatus = 'created' | 'running' | 'disposed';
|
|
|
|
|
|
|
2026-04-04 11:06:41 +08:00
|
|
|
|
export interface GameModule<TState extends Record<string, unknown>> {
|
2026-04-04 20:50:17 +08:00
|
|
|
|
registry: CommandRegistry<IGameContext<TState>>;
|
2026-04-04 11:06:41 +08:00
|
|
|
|
createInitialState: () => TState;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 00:54:19 +08:00
|
|
|
|
export class GameHost<TState extends Record<string, unknown>> {
|
2026-04-04 20:50:17 +08:00
|
|
|
|
readonly context: IGameContext<TState>;
|
2026-04-04 00:54:19 +08:00
|
|
|
|
readonly status: ReadonlySignal<GameHostStatus>;
|
|
|
|
|
|
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>;
|
2026-04-04 10:30:00 +08:00
|
|
|
|
readonly activePromptPlayer: ReadonlySignal<string | null>;
|
2026-04-04 00:54:19 +08:00
|
|
|
|
|
|
|
|
|
|
private _state: MutableSignal<TState>;
|
2026-04-04 20:50:17 +08:00
|
|
|
|
private _commands: CommandRunnerContextExport<IGameContext<TState>>;
|
2026-04-04 00:54:19 +08:00
|
|
|
|
private _status: Signal<GameHostStatus>;
|
|
|
|
|
|
private _activePromptSchema: Signal<CommandSchema | null>;
|
2026-04-04 10:30:00 +08:00
|
|
|
|
private _activePromptPlayer: Signal<string | null>;
|
2026-04-04 00:54:19 +08:00
|
|
|
|
private _createInitialState: () => TState;
|
|
|
|
|
|
private _eventListeners: Map<'setup' | 'dispose', Set<() => void>>;
|
|
|
|
|
|
private _isDisposed = false;
|
|
|
|
|
|
|
|
|
|
|
|
constructor(
|
2026-04-04 20:50:17 +08:00
|
|
|
|
registry: CommandRegistry<IGameContext<TState>>,
|
2026-04-04 00:54:19 +08:00
|
|
|
|
createInitialState: () => TState,
|
|
|
|
|
|
) {
|
|
|
|
|
|
this._createInitialState = createInitialState;
|
|
|
|
|
|
this._eventListeners = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
const initialState = createInitialState();
|
2026-04-04 20:50:17 +08:00
|
|
|
|
this.context = createGameContext(registry, initialState);
|
2026-04-04 00:54:19 +08:00
|
|
|
|
|
|
|
|
|
|
this._status = new Signal<GameHostStatus>('created');
|
|
|
|
|
|
this.status = this._status;
|
|
|
|
|
|
|
|
|
|
|
|
this._activePromptSchema = new Signal<CommandSchema | null>(null);
|
|
|
|
|
|
this.activePromptSchema = this._activePromptSchema;
|
|
|
|
|
|
|
2026-04-04 10:30:00 +08:00
|
|
|
|
this._activePromptPlayer = new Signal<string | null>(null);
|
|
|
|
|
|
this.activePromptPlayer = this._activePromptPlayer;
|
|
|
|
|
|
|
2026-04-04 20:50:17 +08:00
|
|
|
|
this._state = this.context._state;
|
|
|
|
|
|
this._commands = this.context._commands;
|
2026-04-04 00:54:19 +08:00
|
|
|
|
|
|
|
|
|
|
this._setupPromptTracking();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private _setupPromptTracking() {
|
2026-04-04 10:30:00 +08:00
|
|
|
|
let currentPromptEvent: PromptEvent | null = null;
|
2026-04-04 00:54:19 +08:00
|
|
|
|
|
2026-04-04 20:50:17 +08:00
|
|
|
|
this._commands.on('prompt', (e) => {
|
2026-04-04 10:30:00 +08:00
|
|
|
|
currentPromptEvent = e as PromptEvent;
|
|
|
|
|
|
this._activePromptSchema.value = currentPromptEvent.schema;
|
|
|
|
|
|
this._activePromptPlayer.value = currentPromptEvent.currentPlayer;
|
2026-04-04 00:59:40 +08:00
|
|
|
|
});
|
2026-04-04 00:54:19 +08:00
|
|
|
|
|
2026-04-04 20:50:17 +08:00
|
|
|
|
this._commands.on('promptEnd', () => {
|
2026-04-04 10:30:00 +08:00
|
|
|
|
currentPromptEvent = null;
|
|
|
|
|
|
this._activePromptSchema.value = null;
|
|
|
|
|
|
this._activePromptPlayer.value = null;
|
2026-04-04 00:54:19 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-04 10:30:00 +08:00
|
|
|
|
// Initial state
|
|
|
|
|
|
this._activePromptSchema.value = null;
|
|
|
|
|
|
this._activePromptPlayer.value = null;
|
2026-04-04 00:54:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onInput(input: string): string | null {
|
|
|
|
|
|
if (this._isDisposed) {
|
|
|
|
|
|
return 'GameHost is disposed';
|
|
|
|
|
|
}
|
2026-04-04 20:50:17 +08:00
|
|
|
|
return this._commands._tryCommit(input);
|
2026-04-04 00:54:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 15:37:22 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 为下一个 produceAsync 注册中断 Promise(通常用于 UI 动画)。
|
|
|
|
|
|
* @see MutableSignal.addInterruption
|
|
|
|
|
|
*/
|
|
|
|
|
|
addInterruption(promise: Promise<void>): void {
|
|
|
|
|
|
this._state.addInterruption(promise);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清除所有未完成的中断。
|
|
|
|
|
|
* @see MutableSignal.clearInterruptions
|
|
|
|
|
|
*/
|
|
|
|
|
|
clearInterruptions(): void {
|
|
|
|
|
|
this._state.clearInterruptions();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 23:50:46 +08:00
|
|
|
|
setup(setupCommand: string): Promise<CommandResult<unknown>> {
|
2026-04-04 00:54:19 +08:00
|
|
|
|
if (this._isDisposed) {
|
|
|
|
|
|
throw new Error('GameHost is disposed');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 20:50:17 +08:00
|
|
|
|
this._commands._cancel();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
|
|
|
|
|
|
const initialState = this._createInitialState();
|
|
|
|
|
|
this._state.value = initialState as any;
|
|
|
|
|
|
|
2026-04-04 23:50:46 +08:00
|
|
|
|
const promise = this._commands.run(setupCommand);
|
2026-04-04 00:54:19 +08:00
|
|
|
|
|
|
|
|
|
|
this._status.value = 'running';
|
|
|
|
|
|
this._emitEvent('setup');
|
2026-04-04 23:50:46 +08:00
|
|
|
|
|
|
|
|
|
|
return promise;
|
2026-04-04 00:54:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
dispose(): void {
|
|
|
|
|
|
if (this._isDisposed) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this._isDisposed = true;
|
2026-04-04 20:50:17 +08:00
|
|
|
|
this._commands._cancel();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
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>>(
|
2026-04-04 11:06:41 +08:00
|
|
|
|
module: GameModule<TState>,
|
2026-04-04 00:54:19 +08:00
|
|
|
|
): GameHost<TState> {
|
|
|
|
|
|
return new GameHost(
|
|
|
|
|
|
module.registry,
|
|
|
|
|
|
module.createInitialState,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|