refactor: simplify input handling?
This commit is contained in:
parent
7501b5f592
commit
3395a315a6
|
|
@ -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
|
||||
// 如果没有活跃 prompt,onSubmitPrompt 会返回错误,我们忽略它
|
||||
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;
|
||||
|
|
@ -121,6 +137,9 @@ export class PromptHandler<TState extends Record<string, unknown>> {
|
|||
if (error === null) {
|
||||
this.activePrompt = null;
|
||||
this.listenForPrompt();
|
||||
} else {
|
||||
// 提交失败,把 prompt 交给 UI 显示错误
|
||||
this.onPrompt(promptEvent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue