feat: add game host
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import { ReadonlySignal, signal, Signal } from '@preact/signals-core';
|
||||
import type { CommandSchema, CommandRegistry, CommandResult } from '@/utils/command';
|
||||
import type { MutableSignal } from '@/utils/mutable-signal';
|
||||
import { createGameContext } from './game';
|
||||
|
||||
export type GameHostStatus = 'created' | 'running' | 'disposed';
|
||||
|
||||
export interface GameHostOptions {
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
export class GameHost<TState extends Record<string, unknown>> {
|
||||
readonly state: ReadonlySignal<TState>;
|
||||
readonly commands: ReturnType<typeof createGameContext<TState>>['commands'];
|
||||
readonly status: ReadonlySignal<GameHostStatus>;
|
||||
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>;
|
||||
|
||||
private _state: MutableSignal<TState>;
|
||||
private _status: Signal<GameHostStatus>;
|
||||
private _activePromptSchema: Signal<CommandSchema | null>;
|
||||
private _createInitialState: () => TState;
|
||||
private _setupCommand: string;
|
||||
private _eventListeners: Map<'setup' | 'dispose', Set<() => void>>;
|
||||
private _isDisposed = false;
|
||||
|
||||
constructor(
|
||||
registry: CommandRegistry<MutableSignal<TState>>,
|
||||
createInitialState: () => TState,
|
||||
setupCommand: string,
|
||||
options?: GameHostOptions
|
||||
) {
|
||||
this._createInitialState = createInitialState;
|
||||
this._setupCommand = setupCommand;
|
||||
this._eventListeners = new Map();
|
||||
|
||||
const initialState = createInitialState();
|
||||
const context = createGameContext(registry, initialState);
|
||||
|
||||
this._state = context.state;
|
||||
this.commands = context.commands;
|
||||
|
||||
this._status = new Signal<GameHostStatus>('created');
|
||||
this.status = this._status;
|
||||
|
||||
this._activePromptSchema = new Signal<CommandSchema | null>(null);
|
||||
this.activePromptSchema = this._activePromptSchema;
|
||||
|
||||
this.state = this._state;
|
||||
|
||||
this._setupPromptTracking();
|
||||
|
||||
if (options?.autoStart !== false) {
|
||||
this._status.value = 'running';
|
||||
}
|
||||
}
|
||||
|
||||
private _setupPromptTracking() {
|
||||
const updateSchema = () => {
|
||||
const activePrompt = (this.commands as any)._activePrompt as { schema?: CommandSchema } | null;
|
||||
this._activePromptSchema.value = activePrompt?.schema ?? null;
|
||||
};
|
||||
|
||||
// Wrap _tryCommit to update schema after commit
|
||||
const originalTryCommit = this.commands._tryCommit.bind(this.commands);
|
||||
(this.commands as any)._tryCommit = (input: string) => {
|
||||
const result = originalTryCommit(input);
|
||||
updateSchema();
|
||||
return result;
|
||||
};
|
||||
|
||||
// Wrap _cancel to update schema after cancel
|
||||
const originalCancel = this.commands._cancel.bind(this.commands);
|
||||
(this.commands as any)._cancel = (reason?: string) => {
|
||||
originalCancel(reason);
|
||||
updateSchema();
|
||||
};
|
||||
|
||||
this.commands.on('prompt', () => {
|
||||
updateSchema();
|
||||
});
|
||||
|
||||
updateSchema();
|
||||
}
|
||||
|
||||
onInput(input: string): string | null {
|
||||
if (this._isDisposed) {
|
||||
return 'GameHost is disposed';
|
||||
}
|
||||
return this.commands._tryCommit(input);
|
||||
}
|
||||
|
||||
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: {
|
||||
registry: CommandRegistry<MutableSignal<TState>>;
|
||||
createInitialState: () => TState;
|
||||
},
|
||||
setupCommand: string,
|
||||
options?: GameHostOptions
|
||||
): GameHost<TState> {
|
||||
return new GameHost(
|
||||
module.registry,
|
||||
module.createInitialState,
|
||||
setupCommand,
|
||||
options
|
||||
);
|
||||
}
|
||||
+4
-1
@@ -66,4 +66,7 @@ export function createGameCommand<TState extends Record<string, unknown> = {} ,
|
||||
schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema,
|
||||
run,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { GameHost, createGameHost } from './game-host';
|
||||
export type { GameHostStatus, GameHostOptions } from './game-host';
|
||||
@@ -7,6 +7,9 @@
|
||||
export type { IGameContext } from './core/game';
|
||||
export { createGameContext, createGameCommandRegistry } from './core/game';
|
||||
|
||||
export type { GameHost, GameHostStatus, GameHostOptions } from './core/game-host';
|
||||
export { createGameHost } from './core/game-host';
|
||||
|
||||
export type { Part } from './core/part';
|
||||
export { flip, flipTo, roll, findPartById, isCellOccupied, getPartAtPosition, isCellOccupiedByRegion, getPartAtPositionInRegion } from './core/part';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user