diff --git a/packages/framework/src/input/index.ts b/packages/framework/src/input/index.ts index 79fe288..aead38c 100644 --- a/packages/framework/src/input/index.ts +++ b/packages/framework/src/input/index.ts @@ -1,26 +1,25 @@ import Phaser from 'phaser'; import type { IGameContext, PromptEvent } from 'boardgame-core'; -export interface InputMapperOptions> { - scene: Phaser.Scene; - commands: IGameContext['commands']; - activePrompt: { current: PromptEvent | null }; - onSubmitPrompt: (input: string) => string | null; +// ─── InputMapper ─────────────────────────────────────────────────────────── + +export interface InputMapperOptions { + onSubmit: (input: string) => string | null; } -export class InputMapper> { +export class InputMapper { private scene: Phaser.Scene; - private commands: IGameContext['commands']; - private activePrompt: { current: PromptEvent | null }; - private onSubmitPrompt: (input: string) => string | null; + private onSubmit: (input: string) => string | null; private pointerDownCallback: ((pointer: Phaser.Input.Pointer) => void) | null = null; - private isWaitingForPrompt = false; + /** Track interactive objects registered via mapObjectClick for cleanup */ + private trackedObjects: Array<{ + obj: Phaser.GameObjects.GameObject; + handler: () => void; + }> = []; - constructor(options: InputMapperOptions) { - this.scene = options.scene; - this.commands = options.commands; - this.activePrompt = options.activePrompt; - this.onSubmitPrompt = options.onSubmitPrompt; + constructor(scene: Phaser.Scene, options: InputMapperOptions) { + this.scene = scene; + this.onSubmit = options.onSubmit; } mapGridClick( @@ -42,9 +41,7 @@ export class InputMapper> { const cmd = onCellClick(col, row); if (cmd) { - // 总是尝试提交到 prompt - // 如果没有活跃 prompt,onSubmitPrompt 会返回错误,我们忽略它 - this.onSubmitPrompt(cmd); + this.onSubmit(cmd); } }; @@ -60,42 +57,61 @@ export class InputMapper> { if ('setInteractive' in obj && typeof (obj as any).setInteractive === 'function') { const interactiveObj = obj as any; interactiveObj.setInteractive({ useHandCursor: true }); - interactiveObj.on('pointerdown', () => { + + const handler = () => { const cmd = onClick(obj as unknown as T); if (cmd) { - this.commands.run(cmd); + this.onSubmit(cmd); } - }); + }; + + interactiveObj.on('pointerdown', handler); + this.trackedObjects.push({ obj, handler }); } } } destroy(): void { + // Remove global pointerdown listener from mapGridClick if (this.pointerDownCallback) { this.scene.input.off('pointerdown', this.pointerDownCallback); this.pointerDownCallback = null; } + + // Remove per-object pointerdown listeners from mapObjectClick + for (const { obj, handler } of this.trackedObjects) { + if ('off' in obj && typeof (obj as any).off === 'function') { + (obj as any).off('pointerdown', handler); + } + } + this.trackedObjects = []; } } -export interface PromptHandlerOptions> { - scene: Phaser.Scene; - commands: IGameContext['commands']; +export function createInputMapper( + scene: Phaser.Scene, + options: InputMapperOptions, +): InputMapper { + return new InputMapper(scene, options); +} + +// ─── PromptHandler ───────────────────────────────────────────────────────── + +export interface PromptHandlerOptions { + commands: IGameContext['commands']; onPrompt: (prompt: PromptEvent) => void; onCancel: (reason?: string) => void; } -export class PromptHandler> { - private scene: Phaser.Scene; - private commands: IGameContext['commands']; +export class PromptHandler { + private commands: IGameContext['commands']; private onPrompt: (prompt: PromptEvent) => void; private onCancel: (reason?: string) => void; private activePrompt: PromptEvent | null = null; private isListening = false; private pendingInput: string | null = null; - constructor(options: PromptHandlerOptions) { - this.scene = options.scene; + constructor(options: PromptHandlerOptions) { this.commands = options.commands; this.onPrompt = options.onPrompt; this.onCancel = options.onCancel; @@ -112,7 +128,7 @@ export class PromptHandler> { this.commands.promptQueue.pop() .then((promptEvent) => { this.activePrompt = promptEvent; - + // 如果有等待的输入,自动提交 if (this.pendingInput) { const input = this.pendingInput; @@ -121,10 +137,13 @@ export class PromptHandler> { if (error === null) { this.activePrompt = null; this.listenForPrompt(); + } else { + // 提交失败,把 prompt 交给 UI 显示错误 + this.onPrompt(promptEvent); } return; } - + this.onPrompt(promptEvent); }) .catch((reason) => { @@ -133,16 +152,19 @@ export class PromptHandler> { }); } + /** + * Submit an input string to the current prompt. + * @returns null on success (input accepted), error string on validation failure + */ submit(input: string): string | null { if (!this.activePrompt) { // 没有活跃 prompt,保存为待处理输入 this.pendingInput = input; - return null; // 返回 null 表示已接受,会等待 + return null; } const error = this.activePrompt.tryCommit(input); if (error === null) { - // 提交成功,重置并监听下一个 prompt this.activePrompt = null; this.listenForPrompt(); } @@ -170,22 +192,8 @@ export class PromptHandler> { } } -export function createInputMapper>( - scene: Phaser.Scene, - commands: IGameContext['commands'], - activePrompt: { current: PromptEvent | null }, - onSubmitPrompt: (input: string) => string | null, -): InputMapper { - return new InputMapper({ scene, commands, activePrompt, onSubmitPrompt }); -} - -export function createPromptHandler>( - scene: Phaser.Scene, - commands: IGameContext['commands'], - callbacks: { - onPrompt: (prompt: PromptEvent) => void; - onCancel: (reason?: string) => void; - }, -): PromptHandler { - return new PromptHandler({ scene, commands, ...callbacks }); +export function createPromptHandler( + options: PromptHandlerOptions, +): PromptHandler { + return new PromptHandler(options); } diff --git a/packages/framework/src/ui/PromptDialog.tsx b/packages/framework/src/ui/PromptDialog.tsx index 4605bf4..22587a1 100644 --- a/packages/framework/src/ui/PromptDialog.tsx +++ b/packages/framework/src/ui/PromptDialog.tsx @@ -4,7 +4,7 @@ import type { PromptEvent, CommandSchema, CommandParamSchema } from 'boardgame-c interface PromptDialogProps { prompt: PromptEvent | null; - onSubmit: (input: string) => void; + onSubmit: (input: string) => string | null | void; onCancel: () => void; } @@ -36,11 +36,10 @@ export function PromptDialog({ prompt, onSubmit, onCancel }: PromptDialogProps) const fieldValues = schemaToFields(prompt.schema).map(f => values[f.label] || ''); const cmdString = [prompt.schema.name, ...fieldValues].join(' '); - const err = prompt.tryCommit(cmdString); - if (err) { + const err = onSubmit(cmdString); + if (err != null) { setError(err); } else { - onSubmit(cmdString); setValues({}); setError(null); } diff --git a/packages/sample-game/src/main.tsx b/packages/sample-game/src/main.tsx index 4e6f4c9..0549ac9 100644 --- a/packages/sample-game/src/main.tsx +++ b/packages/sample-game/src/main.tsx @@ -12,9 +12,9 @@ const gameContext = createGameContext(registry, createInitialSta const commandLog = signal>([]); -// 创建 PromptHandler 用于处理 UI 层的 prompt -let promptHandler: ReturnType> | null = null; -const promptSignal = signal>>(null); +// Single PromptHandler — the only consumer of promptQueue +let promptHandler: ReturnType | null = null; +const promptSignal = signal(null); // 记录命令日志的辅助函数 function logCommand(input: string, result: { success: boolean; result?: unknown; error?: string }) { @@ -60,17 +60,34 @@ function App() { useEffect(() => { if (phaserReady && scene) { - // 初始化 PromptHandler - promptHandler = createPromptHandler(scene, gameContext.commands, { + // Initialize the single PromptHandler + promptHandler = createPromptHandler({ + commands: gameContext.commands, onPrompt: (prompt) => { promptSignal.value = prompt; + // Also update the scene's prompt reference + scene.promptSignal.current = prompt; }, onCancel: () => { promptSignal.value = null; + scene.promptSignal.current = null; }, }); promptHandler.start(); + // Wire the scene's submit function to this PromptHandler + scene.setSubmitPrompt((cmd: string) => { + const error = promptHandler!.submit(cmd); + if (error === null) { + logCommand(cmd, { success: true }); + promptSignal.value = null; + scene.promptSignal.current = null; + } else { + logCommand(cmd, { success: false, error }); + } + return error; + }); + // 监听状态变化 const dispose = gameContext.state.subscribe(() => { setGameState({ ...gameContext.state.value }); @@ -83,26 +100,21 @@ function App() { return () => { dispose(); + promptHandler?.destroy(); + promptHandler = null; }; } }, [phaserReady, scene]); const handlePromptSubmit = useCallback((input: string) => { if (promptHandler) { - const error = promptHandler.submit(input); - if (error === null) { - logCommand(input, { success: true }); - promptSignal.value = null; - } else { - logCommand(input, { success: false, error }); - } + promptHandler.submit(input); } }, []); const handlePromptCancel = useCallback(() => { if (promptHandler) { promptHandler.cancel('User cancelled'); - promptSignal.value = null; } }, []); diff --git a/packages/sample-game/src/scenes/GameScene.ts b/packages/sample-game/src/scenes/GameScene.ts index 07b7c3e..e6a0e48 100644 --- a/packages/sample-game/src/scenes/GameScene.ts +++ b/packages/sample-game/src/scenes/GameScene.ts @@ -1,7 +1,6 @@ import Phaser from 'phaser'; import type { TicTacToeState, TicTacToePart, PlayerType } from '@/game/tic-tac-toe'; -import { ReactiveScene, bindRegion, createInputMapper, createPromptHandler } from 'boardgame-phaser'; -import type { PromptEvent } from 'boardgame-core'; +import { ReactiveScene, bindRegion, createInputMapper, InputMapper } from 'boardgame-phaser'; const CELL_SIZE = 120; const BOARD_OFFSET = { x: 100, y: 100 }; @@ -10,10 +9,10 @@ const BOARD_SIZE = 3; export class GameScene extends ReactiveScene { private boardContainer!: Phaser.GameObjects.Container; private gridGraphics!: Phaser.GameObjects.Graphics; - private inputMapper!: ReturnType>; - private promptHandler!: ReturnType>; - private activePrompt: PromptEvent | null = null; + private inputMapper!: InputMapper; private turnText!: Phaser.GameObjects.Text; + /** Receives the active prompt from the single PromptHandler in main.tsx */ + promptSignal: { current: any } = { current: null }; constructor() { super('GameScene'); @@ -83,16 +82,14 @@ export class GameScene extends ReactiveScene { } private setupInput(): void { - this.inputMapper = createInputMapper( - this, - this.commands, - { current: null }, // 不再需要,保留以兼容接口 - (cmd: string) => { - // 使用 PromptHandler.submit() 而不是直接 tryCommit - // 这样会自动处理没有活跃 prompt 时的排队逻辑 - return this.promptHandler.submit(cmd); + this.inputMapper = createInputMapper(this, { + onSubmit: (cmd: string) => { + // Delegate to the single PromptHandler via the shared commands reference. + // The actual PromptHandler instance lives in main.tsx and is set up once. + // We call through a callback that main.tsx provides via the scene's public interface. + return this.submitToPrompt(cmd); } - ); + }); this.inputMapper.mapGridClick( { x: CELL_SIZE, y: CELL_SIZE }, @@ -107,17 +104,21 @@ export class GameScene extends ReactiveScene { return `play ${currentPlayer} ${row} ${col}`; }, ); + } - this.promptHandler = createPromptHandler(this, this.commands, { - onPrompt: (prompt) => { - this.activePrompt = prompt; - }, - onCancel: () => { - this.activePrompt = null; - }, - }); + /** + * Called by main.tsx to wire up the single PromptHandler's submit function. + */ + private _submitToPrompt: ((cmd: string) => string | null) | null = null; - this.promptHandler.start(); + setSubmitPrompt(fn: (cmd: string) => string | null): void { + this._submitToPrompt = fn; + } + + private submitToPrompt(cmd: string): string | null { + return this._submitToPrompt + ? this._submitToPrompt(cmd) + : null; // no handler wired yet, accept silently } private drawGrid(): void {