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 type { IGameContext, PromptEvent } from 'boardgame-core';
export interface InputMapperOptions<TState extends Record<string, unknown>> {
scene: Phaser.Scene;
commands: IGameContext<TState>['commands'];
activePrompt: { current: PromptEvent | null };
onSubmitPrompt: (input: string) => string | null;
// ─── InputMapper ───────────────────────────────────────────────────────────
export interface InputMapperOptions {
onSubmit: (input: string) => string | null;
}
export class InputMapper<TState extends Record<string, unknown>> {
export class InputMapper {
private scene: Phaser.Scene;
private commands: IGameContext<TState>['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<TState>) {
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<TState extends Record<string, unknown>> {
const cmd = onCellClick(col, row);
if (cmd) {
// 总是尝试提交到 prompt
// 如果没有活跃 promptonSubmitPrompt 会返回错误,我们忽略它
this.onSubmitPrompt(cmd);
this.onSubmit(cmd);
}
};
@ -60,42 +57,61 @@ export class InputMapper<TState extends Record<string, unknown>> {
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<TState extends Record<string, unknown>> {
scene: Phaser.Scene;
commands: IGameContext<TState>['commands'];
export function createInputMapper(
scene: Phaser.Scene,
options: InputMapperOptions,
): InputMapper {
return new InputMapper(scene, options);
}
// ─── PromptHandler ─────────────────────────────────────────────────────────
export interface PromptHandlerOptions {
commands: IGameContext<any>['commands'];
onPrompt: (prompt: PromptEvent) => void;
onCancel: (reason?: string) => void;
}
export class PromptHandler<TState extends Record<string, unknown>> {
private scene: Phaser.Scene;
private commands: IGameContext<TState>['commands'];
export class PromptHandler {
private commands: IGameContext<any>['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<TState>) {
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<TState extends Record<string, unknown>> {
this.commands.promptQueue.pop()
.then((promptEvent) => {
this.activePrompt = promptEvent;
// 如果有等待的输入,自动提交
if (this.pendingInput) {
const input = this.pendingInput;
@ -121,10 +137,13 @@ export class PromptHandler<TState extends Record<string, unknown>> {
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<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 {
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<TState extends Record<string, unknown>> {
}
}
export function createInputMapper<TState extends Record<string, unknown>>(
scene: Phaser.Scene,
commands: IGameContext<TState>['commands'],
activePrompt: { current: PromptEvent | null },
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 });
export function createPromptHandler(
options: PromptHandlerOptions,
): PromptHandler {
return new PromptHandler(options);
}

View File

@ -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);
}

View File

@ -12,9 +12,9 @@ const gameContext = createGameContext<TicTacToeState>(registry, createInitialSta
const commandLog = signal<Array<{ input: string; result: string; timestamp: number }>>([]);
// 创建 PromptHandler 用于处理 UI 层的 prompt
let promptHandler: ReturnType<typeof createPromptHandler<TicTacToeState>> | null = null;
const promptSignal = signal<null | Awaited<ReturnType<typeof gameContext.commands.promptQueue.pop>>>(null);
// Single PromptHandler — the only consumer of promptQueue
let promptHandler: ReturnType<typeof createPromptHandler> | null = null;
const promptSignal = signal<import('boardgame-core').PromptEvent | null>(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;
}
}, []);

View File

@ -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<TicTacToeState> {
private boardContainer!: Phaser.GameObjects.Container;
private gridGraphics!: Phaser.GameObjects.Graphics;
private inputMapper!: ReturnType<typeof createInputMapper<TicTacToeState>>;
private promptHandler!: ReturnType<typeof createPromptHandler<TicTacToeState>>;
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<TicTacToeState> {
}
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<TicTacToeState> {
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 {