chore: clean up
This commit is contained in:
parent
2984d8b20d
commit
5fd2c3d208
|
|
@ -1,286 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { InputMapper, createInputMapper, type InputMapperOptions } from './InputMapper.js';
|
||||
import Phaser from 'phaser';
|
||||
|
||||
// Mock Phaser
|
||||
function createMockScene() {
|
||||
const inputEvents = new Map<string, Function[]>();
|
||||
|
||||
const mockScene = {
|
||||
input: {
|
||||
on: vi.fn((event: string, callback: Function) => {
|
||||
if (!inputEvents.has(event)) {
|
||||
inputEvents.set(event, []);
|
||||
}
|
||||
inputEvents.get(event)!.push(callback);
|
||||
}),
|
||||
off: vi.fn((event: string, callback: Function) => {
|
||||
const handlers = inputEvents.get(event);
|
||||
if (handlers) {
|
||||
const index = handlers.indexOf(callback);
|
||||
if (index > -1) {
|
||||
handlers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}),
|
||||
_emit: (event: string, ...args: any[]) => {
|
||||
const handlers = inputEvents.get(event);
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => handler(...args));
|
||||
}
|
||||
},
|
||||
},
|
||||
} as unknown as Phaser.Scene;
|
||||
|
||||
return {
|
||||
mockScene,
|
||||
inputEvents,
|
||||
emitPointerDown: (pointer: Partial<Phaser.Input.Pointer>) => {
|
||||
inputEvents.get('pointerdown')?.forEach(handler => handler(pointer));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMockGameObject() {
|
||||
const events = new Map<string, Function[]>();
|
||||
|
||||
const mockObj = {
|
||||
setInteractive: vi.fn(),
|
||||
on: vi.fn((event: string, handler: Function) => {
|
||||
if (!events.has(event)) {
|
||||
events.set(event, []);
|
||||
}
|
||||
events.get(event)!.push(handler);
|
||||
}),
|
||||
off: vi.fn((event: string, handler: Function) => {
|
||||
const handlers = events.get(event);
|
||||
if (handlers) {
|
||||
const index = handlers.indexOf(handler);
|
||||
if (index > -1) {
|
||||
handlers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}),
|
||||
_emit: (event: string, ...args: any[]) => {
|
||||
events.get(event)?.forEach(handler => handler(...args));
|
||||
},
|
||||
} as unknown as Phaser.GameObjects.GameObject;
|
||||
|
||||
return {
|
||||
mockObj,
|
||||
emitPointerDown: () => events.get('pointerdown')?.forEach(handler => handler()),
|
||||
};
|
||||
}
|
||||
|
||||
describe('InputMapper', () => {
|
||||
let options: InputMapperOptions;
|
||||
let onSubmit: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
onSubmit = vi.fn();
|
||||
options = {
|
||||
onSubmit,
|
||||
};
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create instance with options', () => {
|
||||
const { mockScene } = createMockScene();
|
||||
const mapper = new InputMapper(mockScene, options);
|
||||
expect(mapper).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createInputMapper', () => {
|
||||
it('should create instance via factory', () => {
|
||||
const { mockScene } = createMockScene();
|
||||
const mapper = createInputMapper(mockScene, options);
|
||||
expect(mapper).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapGridClick', () => {
|
||||
it('should register pointerdown listener', () => {
|
||||
const { mockScene, inputEvents } = createMockScene();
|
||||
const mapper = new InputMapper(mockScene, options);
|
||||
|
||||
mapper.mapGridClick(
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 100, y: 100 },
|
||||
{ cols: 3, rows: 3 },
|
||||
vi.fn(() => null),
|
||||
);
|
||||
|
||||
expect(mockScene.input.on).toHaveBeenCalledWith('pointerdown', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should call onSubmit with command from onCellClick', () => {
|
||||
const { mockScene, emitPointerDown } = createMockScene();
|
||||
const mapper = new InputMapper(mockScene, options);
|
||||
const onCellClick = vi.fn((col, row) => `place ${col} ${row}`);
|
||||
|
||||
mapper.mapGridClick(
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 100, y: 100 },
|
||||
{ cols: 3, rows: 3 },
|
||||
onCellClick,
|
||||
);
|
||||
|
||||
// Simulate pointer at position (120, 130)
|
||||
// Local: (120-100, 130-100) = (20, 30)
|
||||
// Cell: (floor(20/50), floor(30/50)) = (0, 0)
|
||||
emitPointerDown({ x: 120, y: 130 });
|
||||
|
||||
expect(onCellClick).toHaveBeenCalledWith(0, 0);
|
||||
expect(onSubmit).toHaveBeenCalledWith('place 0 0');
|
||||
});
|
||||
|
||||
it('should ignore clicks outside grid', () => {
|
||||
const { mockScene, emitPointerDown } = createMockScene();
|
||||
const mapper = new InputMapper(mockScene, options);
|
||||
const onCellClick = vi.fn(() => 'click');
|
||||
|
||||
mapper.mapGridClick(
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 100, y: 100 },
|
||||
{ cols: 3, rows: 3 },
|
||||
onCellClick,
|
||||
);
|
||||
|
||||
// Click outside grid (negative local coordinates)
|
||||
emitPointerDown({ x: 50, y: 50 });
|
||||
|
||||
expect(onCellClick).not.toHaveBeenCalled();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore clicks outside grid bounds', () => {
|
||||
const { mockScene, emitPointerDown } = createMockScene();
|
||||
const mapper = new InputMapper(mockScene, options);
|
||||
const onCellClick = vi.fn(() => 'click');
|
||||
|
||||
mapper.mapGridClick(
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 100, y: 100 },
|
||||
{ cols: 3, rows: 3 },
|
||||
onCellClick,
|
||||
);
|
||||
|
||||
// Click at col 5 (beyond cols 0-2)
|
||||
// Local X: 300-100 = 200, col = floor(200/50) = 4
|
||||
emitPointerDown({ x: 300, y: 120 });
|
||||
|
||||
expect(onCellClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onSubmit when onCellClick returns null', () => {
|
||||
const { mockScene, emitPointerDown } = createMockScene();
|
||||
const mapper = new InputMapper(mockScene, options);
|
||||
const onCellClick = vi.fn(() => null);
|
||||
|
||||
mapper.mapGridClick(
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 100, y: 100 },
|
||||
{ cols: 3, rows: 3 },
|
||||
onCellClick,
|
||||
);
|
||||
|
||||
emitPointerDown({ x: 120, y: 130 });
|
||||
|
||||
expect(onCellClick).toHaveBeenCalled();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapObjectClick', () => {
|
||||
it('should set game objects as interactive', () => {
|
||||
const { mockScene } = createMockScene();
|
||||
const { mockObj: obj1 } = createMockGameObject();
|
||||
const { mockObj: obj2 } = createMockGameObject();
|
||||
|
||||
const mapper = new InputMapper(mockScene, options);
|
||||
|
||||
mapper.mapObjectClick([obj1, obj2], vi.fn(() => null));
|
||||
|
||||
expect(obj1.setInteractive).toHaveBeenCalledWith({ useHandCursor: true });
|
||||
expect(obj2.setInteractive).toHaveBeenCalledWith({ useHandCursor: true });
|
||||
});
|
||||
|
||||
it('should call onSubmit when object is clicked', () => {
|
||||
const { mockScene } = createMockScene();
|
||||
const { mockObj, emitPointerDown } = createMockGameObject();
|
||||
const onClick = vi.fn((obj) => `clicked ${obj}`);
|
||||
|
||||
const mapper = new InputMapper(mockScene, options);
|
||||
|
||||
mapper.mapObjectClick([mockObj], onClick);
|
||||
|
||||
emitPointerDown();
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith(mockObj);
|
||||
expect(onSubmit).toHaveBeenCalledWith(`clicked ${mockObj}`);
|
||||
});
|
||||
|
||||
it('should skip objects without setInteractive', () => {
|
||||
const { mockScene } = createMockScene();
|
||||
const nonInteractiveObj = {} as Phaser.GameObjects.GameObject;
|
||||
|
||||
const mapper = new InputMapper(mockScene, options);
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
mapper.mapObjectClick([nonInteractiveObj], vi.fn());
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not call onSubmit when onClick returns null', () => {
|
||||
const { mockScene } = createMockScene();
|
||||
const { mockObj, emitPointerDown } = createMockGameObject();
|
||||
const onClick = vi.fn(() => null);
|
||||
|
||||
const mapper = new InputMapper(mockScene, options);
|
||||
|
||||
mapper.mapObjectClick([mockObj], onClick);
|
||||
emitPointerDown();
|
||||
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should remove pointerdown listener from mapGridClick', () => {
|
||||
const { mockScene } = createMockScene();
|
||||
const mapper = new InputMapper(mockScene, options);
|
||||
|
||||
mapper.mapGridClick(
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 100, y: 100 },
|
||||
{ cols: 3, rows: 3 },
|
||||
vi.fn(() => null),
|
||||
);
|
||||
|
||||
mapper.destroy();
|
||||
|
||||
expect(mockScene.input.off).toHaveBeenCalledWith('pointerdown', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should remove object listeners from mapObjectClick', () => {
|
||||
const { mockScene } = createMockScene();
|
||||
const { mockObj } = createMockGameObject();
|
||||
|
||||
const mapper = new InputMapper(mockScene, options);
|
||||
mapper.mapObjectClick([mockObj], vi.fn(() => null));
|
||||
mapper.destroy();
|
||||
|
||||
expect(mockObj.off).toHaveBeenCalledWith('pointerdown', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should be safe to call without any mappings', () => {
|
||||
const { mockScene } = createMockScene();
|
||||
const mapper = new InputMapper(mockScene, options);
|
||||
|
||||
expect(() => mapper.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import Phaser from 'phaser';
|
||||
|
||||
export interface InputMapperOptions {
|
||||
onSubmit: (input: string) => string | null;
|
||||
}
|
||||
|
||||
export class InputMapper {
|
||||
private scene: Phaser.Scene;
|
||||
private onSubmit: (input: string) => string | null;
|
||||
private pointerDownCallback: ((pointer: Phaser.Input.Pointer) => void) | null = null;
|
||||
/** Track interactive objects registered via mapObjectClick for cleanup */
|
||||
private trackedObjects: Array<{
|
||||
obj: Phaser.GameObjects.GameObject;
|
||||
handler: () => void;
|
||||
}> = [];
|
||||
|
||||
constructor(scene: Phaser.Scene, options: InputMapperOptions) {
|
||||
this.scene = scene;
|
||||
this.onSubmit = options.onSubmit;
|
||||
}
|
||||
|
||||
mapGridClick(
|
||||
cellSize: { x: number; y: number },
|
||||
offset: { x: number; y: number },
|
||||
gridDimensions: { cols: number; rows: number },
|
||||
onCellClick: (col: number, row: number) => string | null,
|
||||
): void {
|
||||
const pointerDown = (pointer: Phaser.Input.Pointer) => {
|
||||
const localX = pointer.x - offset.x;
|
||||
const localY = pointer.y - offset.y;
|
||||
|
||||
if (localX < 0 || localY < 0) return;
|
||||
|
||||
const col = Math.floor(localX / cellSize.x);
|
||||
const row = Math.floor(localY / cellSize.y);
|
||||
|
||||
if (col < 0 || col >= gridDimensions.cols || row < 0 || row >= gridDimensions.rows) return;
|
||||
|
||||
const cmd = onCellClick(col, row);
|
||||
if (cmd) {
|
||||
this.onSubmit(cmd);
|
||||
}
|
||||
};
|
||||
|
||||
this.pointerDownCallback = pointerDown;
|
||||
this.scene.input.on('pointerdown', pointerDown);
|
||||
}
|
||||
|
||||
mapObjectClick<T>(
|
||||
gameObjects: Phaser.GameObjects.GameObject[],
|
||||
onClick: (obj: T) => string | null,
|
||||
): void {
|
||||
for (const obj of gameObjects) {
|
||||
if ('setInteractive' in obj && typeof (obj as any).setInteractive === 'function') {
|
||||
const interactiveObj = obj as any;
|
||||
interactiveObj.setInteractive({ useHandCursor: true });
|
||||
|
||||
const handler = () => {
|
||||
const cmd = onClick(obj as unknown as T);
|
||||
if (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 function createInputMapper(
|
||||
scene: Phaser.Scene,
|
||||
options: InputMapperOptions,
|
||||
): InputMapper {
|
||||
return new InputMapper(scene, options);
|
||||
}
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { PromptHandler, type PromptHandlerOptions } from './PromptHandler.js';
|
||||
import type { PromptEvent } from 'boardgame-core';
|
||||
|
||||
// 内联 AsyncQueue 实现用于测试(避免导入 boardgame-core 内部模块)
|
||||
class AsyncQueue<T> {
|
||||
private items: T[] = [];
|
||||
private resolvers: ((value: T) => void)[] = [];
|
||||
|
||||
push(item: T): void {
|
||||
if (this.resolvers.length > 0) {
|
||||
const resolve = this.resolvers.shift()!;
|
||||
resolve(item);
|
||||
} else {
|
||||
this.items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
pushAll(items: Iterable<T>): void {
|
||||
for (const item of items) {
|
||||
this.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
async pop(): Promise<T> {
|
||||
if (this.items.length > 0) {
|
||||
return this.items.shift()!;
|
||||
}
|
||||
return new Promise<T>((resolve) => {
|
||||
this.resolvers.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.items.length - this.resolvers.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Mock types
|
||||
interface MockCommandContext {
|
||||
commands: {
|
||||
promptQueue: AsyncQueue<PromptEvent>;
|
||||
};
|
||||
}
|
||||
|
||||
function createMockPromptEvent(
|
||||
schema: any = { name: 'test', params: [] },
|
||||
tryCommitImpl?: (input: string | any) => string | null,
|
||||
): PromptEvent {
|
||||
return {
|
||||
schema,
|
||||
tryCommit: tryCommitImpl ?? vi.fn(() => null),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockCommands(): MockCommandContext['commands'] {
|
||||
return {
|
||||
promptQueue: new AsyncQueue<PromptEvent>(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('PromptHandler', () => {
|
||||
let options: PromptHandlerOptions;
|
||||
let mockCommands: MockCommandContext['commands'];
|
||||
let onPrompt: ReturnType<typeof vi.fn>;
|
||||
let onCancel: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
onPrompt = vi.fn();
|
||||
onCancel = vi.fn();
|
||||
mockCommands = createMockCommands();
|
||||
options = {
|
||||
commands: mockCommands as any,
|
||||
onPrompt,
|
||||
onCancel,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create instance with options', () => {
|
||||
const handler = new PromptHandler(options);
|
||||
expect(handler).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPromptHandler', () => {
|
||||
it('should create instance via factory', async () => {
|
||||
const { createPromptHandler } = await import('./PromptHandler.js');
|
||||
const handler = createPromptHandler(options);
|
||||
expect(handler).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('should listen for prompts when started', async () => {
|
||||
const handler = new PromptHandler(options);
|
||||
handler.start();
|
||||
|
||||
const mockEvent = createMockPromptEvent();
|
||||
mockCommands.promptQueue.push(mockEvent);
|
||||
|
||||
// Wait for async operation
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(onPrompt).toHaveBeenCalledWith(mockEvent);
|
||||
});
|
||||
|
||||
it('should not listen when not started', async () => {
|
||||
const handler = new PromptHandler(options);
|
||||
// Don't call start()
|
||||
|
||||
const mockEvent = createMockPromptEvent();
|
||||
mockCommands.promptQueue.push(mockEvent);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(onPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('submit', () => {
|
||||
it('should submit to active prompt successfully', async () => {
|
||||
const handler = new PromptHandler(options);
|
||||
handler.start();
|
||||
|
||||
const mockEvent = createMockPromptEvent();
|
||||
mockCommands.promptQueue.push(mockEvent);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
const result = handler.submit('test input');
|
||||
|
||||
expect(mockEvent.tryCommit).toHaveBeenCalledWith('test input');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error when submit fails', async () => {
|
||||
const mockEvent = createMockPromptEvent(
|
||||
{ name: 'test', params: [] },
|
||||
vi.fn(() => 'Invalid input'),
|
||||
);
|
||||
|
||||
const handler = new PromptHandler(options);
|
||||
handler.start();
|
||||
|
||||
mockCommands.promptQueue.push(mockEvent);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
const result = handler.submit('bad input');
|
||||
|
||||
expect(result).toBe('Invalid input');
|
||||
expect(onPrompt).toHaveBeenCalledWith(mockEvent);
|
||||
});
|
||||
|
||||
it('should store input as pending when no active prompt', () => {
|
||||
const handler = new PromptHandler(options);
|
||||
// Don't start, so no active prompt
|
||||
|
||||
const result = handler.submit('pending input');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should auto-submit pending input when new prompt arrives', async () => {
|
||||
const tryCommitFn = vi.fn(() => null);
|
||||
const mockEvent = createMockPromptEvent(
|
||||
{ name: 'test', params: [] },
|
||||
tryCommitFn,
|
||||
);
|
||||
|
||||
const handler = new PromptHandler(options);
|
||||
|
||||
// Submit before starting (pending input)
|
||||
handler.submit('pending input');
|
||||
|
||||
// Start and push event
|
||||
handler.start();
|
||||
mockCommands.promptQueue.push(mockEvent);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(tryCommitFn).toHaveBeenCalledWith('pending input');
|
||||
expect(onPrompt).not.toHaveBeenCalled(); // Auto-submitted, no UI needed
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('should cancel active prompt', async () => {
|
||||
const mockEvent = createMockPromptEvent();
|
||||
const handler = new PromptHandler(options);
|
||||
handler.start();
|
||||
|
||||
mockCommands.promptQueue.push(mockEvent);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
handler.cancel('user cancelled');
|
||||
|
||||
expect(mockEvent.cancel).toHaveBeenCalledWith('user cancelled');
|
||||
expect(onCancel).toHaveBeenCalledWith('user cancelled');
|
||||
});
|
||||
|
||||
it('should call onCancel without active prompt', () => {
|
||||
const handler = new PromptHandler(options);
|
||||
handler.cancel('no prompt');
|
||||
|
||||
expect(onCancel).toHaveBeenCalledWith('no prompt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop', () => {
|
||||
it('should stop listening for new prompts after stop', async () => {
|
||||
const handler = new PromptHandler(options);
|
||||
handler.start();
|
||||
|
||||
// Push first event, it should be handled
|
||||
const mockEvent1 = createMockPromptEvent();
|
||||
mockCommands.promptQueue.push(mockEvent1);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(onPrompt).toHaveBeenCalledWith(mockEvent1);
|
||||
onPrompt.mockClear();
|
||||
|
||||
// Now stop
|
||||
handler.stop();
|
||||
|
||||
// Push another event, should not be handled
|
||||
const mockEvent2 = createMockPromptEvent();
|
||||
mockCommands.promptQueue.push(mockEvent2);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// The second event should not trigger onPrompt
|
||||
// (Note: due to async nature, we check it wasn't called AFTER stop)
|
||||
expect(onPrompt).not.toHaveBeenCalledWith(mockEvent2);
|
||||
});
|
||||
|
||||
it('should cancel active prompt on stop', async () => {
|
||||
const mockEvent = createMockPromptEvent();
|
||||
const handler = new PromptHandler(options);
|
||||
handler.start();
|
||||
|
||||
mockCommands.promptQueue.push(mockEvent);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
handler.stop();
|
||||
|
||||
expect(mockEvent.cancel).toHaveBeenCalledWith('Handler stopped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should call stop', async () => {
|
||||
const mockEvent = createMockPromptEvent();
|
||||
const handler = new PromptHandler(options);
|
||||
handler.start();
|
||||
|
||||
mockCommands.promptQueue.push(mockEvent);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
handler.destroy();
|
||||
|
||||
expect(mockEvent.cancel).toHaveBeenCalledWith('Handler stopped');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
import type { IGameContext, PromptEvent } from 'boardgame-core';
|
||||
|
||||
export interface PromptHandlerOptions {
|
||||
commands: IGameContext<any>['commands'];
|
||||
onPrompt: (prompt: PromptEvent) => void;
|
||||
onCancel: (reason?: string) => void;
|
||||
}
|
||||
|
||||
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) {
|
||||
this.commands = options.commands;
|
||||
this.onPrompt = options.onPrompt;
|
||||
this.onCancel = options.onCancel;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.isListening = true;
|
||||
this.listenForPrompt();
|
||||
}
|
||||
|
||||
private listenForPrompt(): void {
|
||||
if (!this.isListening) return;
|
||||
|
||||
this.commands.promptQueue.pop()
|
||||
.then((promptEvent) => {
|
||||
this.activePrompt = promptEvent;
|
||||
|
||||
// 如果有等待的输入,自动提交
|
||||
if (this.pendingInput) {
|
||||
const input = this.pendingInput;
|
||||
this.pendingInput = null;
|
||||
const error = this.activePrompt.tryCommit(input);
|
||||
if (error === null) {
|
||||
this.activePrompt = null;
|
||||
this.listenForPrompt();
|
||||
} else {
|
||||
// 提交失败,把 prompt 交给 UI 显示错误
|
||||
this.onPrompt(promptEvent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.onPrompt(promptEvent);
|
||||
})
|
||||
.catch((reason) => {
|
||||
this.activePrompt = null;
|
||||
this.onCancel(reason?.message || 'Cancelled');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
const error = this.activePrompt.tryCommit(input);
|
||||
if (error === null) {
|
||||
this.activePrompt = null;
|
||||
this.listenForPrompt();
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
cancel(reason?: string): void {
|
||||
if (this.activePrompt) {
|
||||
this.activePrompt.cancel(reason);
|
||||
this.activePrompt = null;
|
||||
}
|
||||
this.onCancel(reason);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.isListening = false;
|
||||
if (this.activePrompt) {
|
||||
this.activePrompt.cancel('Handler stopped');
|
||||
this.activePrompt = null;
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export function createPromptHandler(
|
||||
options: PromptHandlerOptions,
|
||||
): PromptHandler {
|
||||
return new PromptHandler(options);
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
export {
|
||||
InputMapper,
|
||||
createInputMapper,
|
||||
type InputMapperOptions,
|
||||
} from './InputMapper.js';
|
||||
|
||||
export {
|
||||
PromptHandler,
|
||||
createPromptHandler,
|
||||
type PromptHandlerOptions,
|
||||
} from './PromptHandler.js';
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import Phaser from 'phaser';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import type { MutableSignal, IGameContext, CommandResult } from 'boardgame-core';
|
||||
|
||||
type DisposeFn = () => void;
|
||||
|
||||
export interface ReactiveSceneOptions<TState extends Record<string, unknown>> {
|
||||
gameContext: IGameContext<TState>;
|
||||
}
|
||||
|
||||
export abstract class ReactiveScene<TState extends Record<string, unknown>> extends Phaser.Scene {
|
||||
protected gameContext!: IGameContext<TState>;
|
||||
protected state!: MutableSignal<TState>;
|
||||
protected commands!: IGameContext<TState>['commands'];
|
||||
private effects: DisposeFn[] = [];
|
||||
|
||||
constructor(key: string) {
|
||||
super(key);
|
||||
}
|
||||
|
||||
init(data: ReactiveSceneOptions<TState>): void {
|
||||
this.gameContext = data.gameContext;
|
||||
this.state = data.gameContext.state;
|
||||
this.commands = data.gameContext.commands;
|
||||
}
|
||||
|
||||
protected watch(fn: () => void): DisposeFn {
|
||||
const e = effect(fn);
|
||||
this.effects.push(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
protected async runCommand<T = unknown>(input: string): Promise<CommandResult<T>> {
|
||||
return this.commands.run<T>(input);
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.events.on('shutdown', this.cleanupEffects, this);
|
||||
this.onStateReady(this.state.value);
|
||||
this.setupBindings();
|
||||
}
|
||||
|
||||
private cleanupEffects(): void {
|
||||
for (const e of this.effects) {
|
||||
e();
|
||||
}
|
||||
this.effects = [];
|
||||
}
|
||||
|
||||
protected abstract onStateReady(state: TState): void;
|
||||
|
||||
protected abstract setupBindings(): void;
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { h } from 'preact';
|
||||
import { Signal } from '@preact/signals-core';
|
||||
|
||||
interface CommandLogProps {
|
||||
entries: Signal<Array<{ input: string; result: string; timestamp: number }>>;
|
||||
maxEntries?: number;
|
||||
}
|
||||
|
||||
export function CommandLog({ entries, maxEntries = 50 }: CommandLogProps) {
|
||||
const displayEntries = entries.value.slice(-maxEntries).reverse();
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 text-green-400 font-mono text-xs p-3 rounded-lg overflow-y-auto max-h-48">
|
||||
{displayEntries.length === 0 ? (
|
||||
<div className="text-gray-500 italic">No commands yet</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{displayEntries.map((entry, i) => (
|
||||
<div key={entry.timestamp + '-' + i} className="flex gap-2">
|
||||
<span className="text-gray-500">
|
||||
{new Date(entry.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="text-yellow-300">> {entry.input}</span>
|
||||
<span className={entry.result.startsWith('OK') ? 'text-green-400' : 'text-red-400'}>
|
||||
{entry.result}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import { h } from 'preact';
|
||||
import { useState, useCallback } from 'preact/hooks';
|
||||
import type { PromptEvent, CommandSchema, CommandParamSchema } from 'boardgame-core';
|
||||
|
||||
interface PromptDialogProps {
|
||||
prompt: PromptEvent | null;
|
||||
onSubmit: (input: string) => string | null | void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function schemaToPlaceholder(schema: CommandSchema): string {
|
||||
const parts: string[] = [schema.name];
|
||||
for (const param of schema.params) {
|
||||
if (param.required) {
|
||||
parts.push(`<${param.name}>`);
|
||||
} else {
|
||||
parts.push(`[${param.name}]`);
|
||||
}
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function schemaToFields(schema: CommandSchema): Array<{ param: CommandParamSchema; label: string }> {
|
||||
return schema.params
|
||||
.filter(p => p.required)
|
||||
.map(p => ({ param: p, label: p.name }));
|
||||
}
|
||||
|
||||
export function PromptDialog({ prompt, onSubmit, onCancel }: PromptDialogProps) {
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!prompt) return;
|
||||
|
||||
const fieldValues = schemaToFields(prompt.schema).map(f => values[f.label] || '');
|
||||
const cmdString = [prompt.schema.name, ...fieldValues].join(' ');
|
||||
|
||||
const err = onSubmit(cmdString);
|
||||
if (err != null) {
|
||||
setError(err);
|
||||
} else {
|
||||
setValues({});
|
||||
setError(null);
|
||||
}
|
||||
}, [prompt, values, onSubmit]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onCancel();
|
||||
setValues({});
|
||||
setError(null);
|
||||
}, [onCancel]);
|
||||
|
||||
if (!prompt) return null;
|
||||
|
||||
const fields = schemaToFields(prompt.schema);
|
||||
const placeholder = schemaToPlaceholder(prompt.schema);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 min-w-[320px] max-w-md">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-800">{prompt.schema.name}</h3>
|
||||
<p className="text-sm text-gray-500 mb-4 font-mono">{placeholder}</p>
|
||||
|
||||
{fields.length > 0 ? (
|
||||
<div className="space-y-3 mb-4">
|
||||
{fields.map(({ param, label }, index) => (
|
||||
<div key={label}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={values[label] || ''}
|
||||
onInput={(e) => setValues(prev => ({ ...prev, [label]: (e.target as HTMLInputElement).value }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
|
||||
autoFocus={index === 0}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter command..."
|
||||
onInput={(e) => {
|
||||
const val = (e.target as HTMLInputElement).value;
|
||||
setValues({ _raw: val });
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 mb-3">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue