refactor: simplify input handling?

This commit is contained in:
hypercross 2026-04-04 00:16:30 +08:00
parent 7501b5f592
commit 3395a315a6
4 changed files with 111 additions and 91 deletions

View File

@ -1,26 +1,25 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import type { IGameContext, PromptEvent } from 'boardgame-core'; import type { IGameContext, PromptEvent } from 'boardgame-core';
export interface InputMapperOptions<TState extends Record<string, unknown>> { // ─── InputMapper ───────────────────────────────────────────────────────────
scene: Phaser.Scene;
commands: IGameContext<TState>['commands']; export interface InputMapperOptions {
activePrompt: { current: PromptEvent | null }; onSubmit: (input: string) => string | null;
onSubmitPrompt: (input: string) => string | null;
} }
export class InputMapper<TState extends Record<string, unknown>> { export class InputMapper {
private scene: Phaser.Scene; private scene: Phaser.Scene;
private commands: IGameContext<TState>['commands']; private onSubmit: (input: string) => string | null;
private activePrompt: { current: PromptEvent | null };
private onSubmitPrompt: (input: string) => string | null;
private pointerDownCallback: ((pointer: Phaser.Input.Pointer) => void) | null = 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<TState>) { constructor(scene: Phaser.Scene, options: InputMapperOptions) {
this.scene = options.scene; this.scene = scene;
this.commands = options.commands; this.onSubmit = options.onSubmit;
this.activePrompt = options.activePrompt;
this.onSubmitPrompt = options.onSubmitPrompt;
} }
mapGridClick( mapGridClick(
@ -42,9 +41,7 @@ export class InputMapper<TState extends Record<string, unknown>> {
const cmd = onCellClick(col, row); const cmd = onCellClick(col, row);
if (cmd) { if (cmd) {
// 总是尝试提交到 prompt this.onSubmit(cmd);
// 如果没有活跃 promptonSubmitPrompt 会返回错误,我们忽略它
this.onSubmitPrompt(cmd);
} }
}; };
@ -60,42 +57,61 @@ export class InputMapper<TState extends Record<string, unknown>> {
if ('setInteractive' in obj && typeof (obj as any).setInteractive === 'function') { if ('setInteractive' in obj && typeof (obj as any).setInteractive === 'function') {
const interactiveObj = obj as any; const interactiveObj = obj as any;
interactiveObj.setInteractive({ useHandCursor: true }); interactiveObj.setInteractive({ useHandCursor: true });
interactiveObj.on('pointerdown', () => {
const handler = () => {
const cmd = onClick(obj as unknown as T); const cmd = onClick(obj as unknown as T);
if (cmd) { if (cmd) {
this.commands.run(cmd); this.onSubmit(cmd);
} }
}); };
interactiveObj.on('pointerdown', handler);
this.trackedObjects.push({ obj, handler });
} }
} }
} }
destroy(): void { destroy(): void {
// Remove global pointerdown listener from mapGridClick
if (this.pointerDownCallback) { if (this.pointerDownCallback) {
this.scene.input.off('pointerdown', this.pointerDownCallback); this.scene.input.off('pointerdown', this.pointerDownCallback);
this.pointerDownCallback = null; 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<TState extends Record<string, unknown>> { export function createInputMapper(
scene: Phaser.Scene; scene: Phaser.Scene,
commands: IGameContext<TState>['commands']; options: InputMapperOptions,
): InputMapper {
return new InputMapper(scene, options);
}
// ─── PromptHandler ─────────────────────────────────────────────────────────
export interface PromptHandlerOptions {
commands: IGameContext<any>['commands'];
onPrompt: (prompt: PromptEvent) => void; onPrompt: (prompt: PromptEvent) => void;
onCancel: (reason?: string) => void; onCancel: (reason?: string) => void;
} }
export class PromptHandler<TState extends Record<string, unknown>> { export class PromptHandler {
private scene: Phaser.Scene; private commands: IGameContext<any>['commands'];
private commands: IGameContext<TState>['commands'];
private onPrompt: (prompt: PromptEvent) => void; private onPrompt: (prompt: PromptEvent) => void;
private onCancel: (reason?: string) => void; private onCancel: (reason?: string) => void;
private activePrompt: PromptEvent | null = null; private activePrompt: PromptEvent | null = null;
private isListening = false; private isListening = false;
private pendingInput: string | null = null; private pendingInput: string | null = null;
constructor(options: PromptHandlerOptions<TState>) { constructor(options: PromptHandlerOptions) {
this.scene = options.scene;
this.commands = options.commands; this.commands = options.commands;
this.onPrompt = options.onPrompt; this.onPrompt = options.onPrompt;
this.onCancel = options.onCancel; this.onCancel = options.onCancel;
@ -112,7 +128,7 @@ export class PromptHandler<TState extends Record<string, unknown>> {
this.commands.promptQueue.pop() this.commands.promptQueue.pop()
.then((promptEvent) => { .then((promptEvent) => {
this.activePrompt = promptEvent; this.activePrompt = promptEvent;
// 如果有等待的输入,自动提交 // 如果有等待的输入,自动提交
if (this.pendingInput) { if (this.pendingInput) {
const input = this.pendingInput; const input = this.pendingInput;
@ -121,10 +137,13 @@ export class PromptHandler<TState extends Record<string, unknown>> {
if (error === null) { if (error === null) {
this.activePrompt = null; this.activePrompt = null;
this.listenForPrompt(); this.listenForPrompt();
} else {
// 提交失败,把 prompt 交给 UI 显示错误
this.onPrompt(promptEvent);
} }
return; return;
} }
this.onPrompt(promptEvent); this.onPrompt(promptEvent);
}) })
.catch((reason) => { .catch((reason) => {
@ -133,16 +152,19 @@ export class PromptHandler<TState extends Record<string, unknown>> {
}); });
} }
/**
* Submit an input string to the current prompt.
* @returns null on success (input accepted), error string on validation failure
*/
submit(input: string): string | null { submit(input: string): string | null {
if (!this.activePrompt) { if (!this.activePrompt) {
// 没有活跃 prompt保存为待处理输入 // 没有活跃 prompt保存为待处理输入
this.pendingInput = input; this.pendingInput = input;
return null; // 返回 null 表示已接受,会等待 return null;
} }
const error = this.activePrompt.tryCommit(input); const error = this.activePrompt.tryCommit(input);
if (error === null) { if (error === null) {
// 提交成功,重置并监听下一个 prompt
this.activePrompt = null; this.activePrompt = null;
this.listenForPrompt(); this.listenForPrompt();
} }
@ -170,22 +192,8 @@ export class PromptHandler<TState extends Record<string, unknown>> {
} }
} }
export function createInputMapper<TState extends Record<string, unknown>>( export function createPromptHandler(
scene: Phaser.Scene, options: PromptHandlerOptions,
commands: IGameContext<TState>['commands'], ): PromptHandler {
activePrompt: { current: PromptEvent | null }, return new PromptHandler(options);
onSubmitPrompt: (input: string) => string | null,
): InputMapper<TState> {
return new InputMapper({ scene, commands, activePrompt, onSubmitPrompt });
}
export function createPromptHandler<TState extends Record<string, unknown>>(
scene: Phaser.Scene,
commands: IGameContext<TState>['commands'],
callbacks: {
onPrompt: (prompt: PromptEvent) => void;
onCancel: (reason?: string) => void;
},
): PromptHandler<TState> {
return new PromptHandler({ scene, commands, ...callbacks });
} }

View File

@ -4,7 +4,7 @@ import type { PromptEvent, CommandSchema, CommandParamSchema } from 'boardgame-c
interface PromptDialogProps { interface PromptDialogProps {
prompt: PromptEvent | null; prompt: PromptEvent | null;
onSubmit: (input: string) => void; onSubmit: (input: string) => string | null | void;
onCancel: () => 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 fieldValues = schemaToFields(prompt.schema).map(f => values[f.label] || '');
const cmdString = [prompt.schema.name, ...fieldValues].join(' '); const cmdString = [prompt.schema.name, ...fieldValues].join(' ');
const err = prompt.tryCommit(cmdString); const err = onSubmit(cmdString);
if (err) { if (err != null) {
setError(err); setError(err);
} else { } else {
onSubmit(cmdString);
setValues({}); setValues({});
setError(null); setError(null);
} }

View File

@ -12,9 +12,9 @@ const gameContext = createGameContext<TicTacToeState>(registry, createInitialSta
const commandLog = signal<Array<{ input: string; result: string; timestamp: number }>>([]); const commandLog = signal<Array<{ input: string; result: string; timestamp: number }>>([]);
// 创建 PromptHandler 用于处理 UI 层的 prompt // Single PromptHandler — the only consumer of promptQueue
let promptHandler: ReturnType<typeof createPromptHandler<TicTacToeState>> | null = null; let promptHandler: ReturnType<typeof createPromptHandler> | null = null;
const promptSignal = signal<null | Awaited<ReturnType<typeof gameContext.commands.promptQueue.pop>>>(null); const promptSignal = signal<import('boardgame-core').PromptEvent | null>(null);
// 记录命令日志的辅助函数 // 记录命令日志的辅助函数
function logCommand(input: string, result: { success: boolean; result?: unknown; error?: string }) { function logCommand(input: string, result: { success: boolean; result?: unknown; error?: string }) {
@ -60,17 +60,34 @@ function App() {
useEffect(() => { useEffect(() => {
if (phaserReady && scene) { if (phaserReady && scene) {
// 初始化 PromptHandler // Initialize the single PromptHandler
promptHandler = createPromptHandler(scene, gameContext.commands, { promptHandler = createPromptHandler({
commands: gameContext.commands,
onPrompt: (prompt) => { onPrompt: (prompt) => {
promptSignal.value = prompt; promptSignal.value = prompt;
// Also update the scene's prompt reference
scene.promptSignal.current = prompt;
}, },
onCancel: () => { onCancel: () => {
promptSignal.value = null; promptSignal.value = null;
scene.promptSignal.current = null;
}, },
}); });
promptHandler.start(); 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(() => { const dispose = gameContext.state.subscribe(() => {
setGameState({ ...gameContext.state.value }); setGameState({ ...gameContext.state.value });
@ -83,26 +100,21 @@ function App() {
return () => { return () => {
dispose(); dispose();
promptHandler?.destroy();
promptHandler = null;
}; };
} }
}, [phaserReady, scene]); }, [phaserReady, scene]);
const handlePromptSubmit = useCallback((input: string) => { const handlePromptSubmit = useCallback((input: string) => {
if (promptHandler) { if (promptHandler) {
const error = promptHandler.submit(input); promptHandler.submit(input);
if (error === null) {
logCommand(input, { success: true });
promptSignal.value = null;
} else {
logCommand(input, { success: false, error });
}
} }
}, []); }, []);
const handlePromptCancel = useCallback(() => { const handlePromptCancel = useCallback(() => {
if (promptHandler) { if (promptHandler) {
promptHandler.cancel('User cancelled'); promptHandler.cancel('User cancelled');
promptSignal.value = null;
} }
}, []); }, []);

View File

@ -1,7 +1,6 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import type { TicTacToeState, TicTacToePart, PlayerType } from '@/game/tic-tac-toe'; import type { TicTacToeState, TicTacToePart, PlayerType } from '@/game/tic-tac-toe';
import { ReactiveScene, bindRegion, createInputMapper, createPromptHandler } from 'boardgame-phaser'; import { ReactiveScene, bindRegion, createInputMapper, InputMapper } from 'boardgame-phaser';
import type { PromptEvent } from 'boardgame-core';
const CELL_SIZE = 120; const CELL_SIZE = 120;
const BOARD_OFFSET = { x: 100, y: 100 }; const BOARD_OFFSET = { x: 100, y: 100 };
@ -10,10 +9,10 @@ const BOARD_SIZE = 3;
export class GameScene extends ReactiveScene<TicTacToeState> { export class GameScene extends ReactiveScene<TicTacToeState> {
private boardContainer!: Phaser.GameObjects.Container; private boardContainer!: Phaser.GameObjects.Container;
private gridGraphics!: Phaser.GameObjects.Graphics; private gridGraphics!: Phaser.GameObjects.Graphics;
private inputMapper!: ReturnType<typeof createInputMapper<TicTacToeState>>; private inputMapper!: InputMapper;
private promptHandler!: ReturnType<typeof createPromptHandler<TicTacToeState>>;
private activePrompt: PromptEvent | null = null;
private turnText!: Phaser.GameObjects.Text; private turnText!: Phaser.GameObjects.Text;
/** Receives the active prompt from the single PromptHandler in main.tsx */
promptSignal: { current: any } = { current: null };
constructor() { constructor() {
super('GameScene'); super('GameScene');
@ -83,16 +82,14 @@ export class GameScene extends ReactiveScene<TicTacToeState> {
} }
private setupInput(): void { private setupInput(): void {
this.inputMapper = createInputMapper( this.inputMapper = createInputMapper(this, {
this, onSubmit: (cmd: string) => {
this.commands, // Delegate to the single PromptHandler via the shared commands reference.
{ current: null }, // 不再需要,保留以兼容接口 // The actual PromptHandler instance lives in main.tsx and is set up once.
(cmd: string) => { // We call through a callback that main.tsx provides via the scene's public interface.
// 使用 PromptHandler.submit() 而不是直接 tryCommit return this.submitToPrompt(cmd);
// 这样会自动处理没有活跃 prompt 时的排队逻辑
return this.promptHandler.submit(cmd);
} }
); });
this.inputMapper.mapGridClick( this.inputMapper.mapGridClick(
{ x: CELL_SIZE, y: CELL_SIZE }, { x: CELL_SIZE, y: CELL_SIZE },
@ -107,17 +104,21 @@ export class GameScene extends ReactiveScene<TicTacToeState> {
return `play ${currentPlayer} ${row} ${col}`; return `play ${currentPlayer} ${row} ${col}`;
}, },
); );
}
this.promptHandler = createPromptHandler(this, this.commands, { /**
onPrompt: (prompt) => { * Called by main.tsx to wire up the single PromptHandler's submit function.
this.activePrompt = prompt; */
}, private _submitToPrompt: ((cmd: string) => string | null) | null = null;
onCancel: () => {
this.activePrompt = 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 { private drawGrid(): void {