chore: add tests
This commit is contained in:
@@ -13,7 +13,9 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@preact/signals-core": "^1.5.1",
|
||||
@@ -29,6 +31,7 @@
|
||||
"phaser": "^3.80.1",
|
||||
"preact": "^10.19.3",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.3.3"
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
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,199 +1,11 @@
|
||||
import Phaser from 'phaser';
|
||||
import type { IGameContext, PromptEvent } from 'boardgame-core';
|
||||
export {
|
||||
InputMapper,
|
||||
createInputMapper,
|
||||
type InputMapperOptions,
|
||||
} from './InputMapper.js';
|
||||
|
||||
// ─── InputMapper ───────────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── PromptHandler ─────────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
export {
|
||||
PromptHandler,
|
||||
createPromptHandler,
|
||||
type PromptHandlerOptions,
|
||||
} from './PromptHandler.js';
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user