This commit is contained in:
parent
39b0160064
commit
2347ecd201
71 changed files with 3779 additions and 1586 deletions
277
src/game-loop/pointer-input.test.ts
Normal file
277
src/game-loop/pointer-input.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type PointerListener = (event: PointerEvent) => void;
|
||||
|
||||
const makePointerEvent = (
|
||||
type: string,
|
||||
event: Partial<PointerEvent> = {}
|
||||
): PointerEvent =>
|
||||
({
|
||||
buttons: 1,
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
isTrusted: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'mouse',
|
||||
pressure: 0.5,
|
||||
timeStamp: 100,
|
||||
type,
|
||||
...event,
|
||||
}) as PointerEvent;
|
||||
|
||||
const toPoint = (point: ArrayLike<number>): Array<number> => Array.from(point);
|
||||
|
||||
class FakeCanvas {
|
||||
public readonly capturedPointerIds: Array<number> = [];
|
||||
public readonly releasedPointerIds: Array<number> = [];
|
||||
public width = 300;
|
||||
public height = 200;
|
||||
|
||||
private readonly listeners = new Map<string, Set<PointerListener>>();
|
||||
|
||||
public addEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject
|
||||
): void {
|
||||
const listeners = this.listeners.get(type) ?? new Set<PointerListener>();
|
||||
const pointerListener =
|
||||
typeof listener === 'function'
|
||||
? listener
|
||||
: (event: Event) => listener.handleEvent(event);
|
||||
listeners.add(pointerListener as PointerListener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
public removeEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject
|
||||
): void {
|
||||
const listeners = this.listeners.get(type);
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
|
||||
listeners.delete(listener as PointerListener);
|
||||
}
|
||||
|
||||
public dispatchPointerEvent(type: string, event: Partial<PointerEvent> = {}): void {
|
||||
const pointerEvent = makePointerEvent(type, event);
|
||||
|
||||
this.listeners.get(type)?.forEach((listener) => listener(pointerEvent));
|
||||
}
|
||||
|
||||
public getBoundingClientRect(): DOMRect {
|
||||
return {
|
||||
bottom: this.height,
|
||||
height: this.height,
|
||||
left: 0,
|
||||
right: this.width,
|
||||
toJSON: () => ({}),
|
||||
top: 0,
|
||||
width: this.width,
|
||||
x: 0,
|
||||
y: 0,
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
public setPointerCapture(pointerId: number): void {
|
||||
this.capturedPointerIds.push(pointerId);
|
||||
}
|
||||
|
||||
public releasePointerCapture(pointerId: number): void {
|
||||
this.releasedPointerIds.push(pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
const makeSwipePipeline = () => ({
|
||||
addSwipeSegment: vi.fn(),
|
||||
clearSwipes: vi.fn(),
|
||||
});
|
||||
|
||||
const createPointerInput = async () => {
|
||||
const { GardenPointerInput } = await import('./pointer-input');
|
||||
const { settings: runtimeSettings } = await import('../settings');
|
||||
const canvas = new FakeCanvas();
|
||||
const audio = {
|
||||
beginGesture: vi.fn(),
|
||||
endGesture: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
touchDown: vi.fn(),
|
||||
};
|
||||
const brushPipeline = makeSwipePipeline();
|
||||
const eraserAgentPipeline = makeSwipePipeline();
|
||||
const eraserTexturePipeline = makeSwipePipeline();
|
||||
const eraserPreview = {
|
||||
isPointerInsideCanvas: vi.fn(() => true),
|
||||
setEraseMode: vi.fn(),
|
||||
setPointerHoveringCanvas: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
const onStartDrawing = vi.fn();
|
||||
const onEraseGestureEnded = vi.fn();
|
||||
const spawnStrokeAgents = vi.fn();
|
||||
const input = new GardenPointerInput({
|
||||
audio,
|
||||
brushPipeline,
|
||||
canvas: canvas as unknown as HTMLCanvasElement,
|
||||
eraserAgentPipeline,
|
||||
eraserPreview,
|
||||
eraserTexturePipeline,
|
||||
getCanvasSize: () => [canvas.width, canvas.height],
|
||||
getDevicePixelRatio: () => 1,
|
||||
getMirrorSegmentCount: () => 1,
|
||||
onEraseGestureEnded,
|
||||
onStartDrawing,
|
||||
spawnStrokeAgents,
|
||||
} as unknown as ConstructorParameters<typeof GardenPointerInput>[0]);
|
||||
|
||||
input.attach();
|
||||
|
||||
return {
|
||||
audio,
|
||||
brushPipeline,
|
||||
canvas,
|
||||
input,
|
||||
onStartDrawing,
|
||||
runtimeSettings,
|
||||
spawnStrokeAgents,
|
||||
};
|
||||
};
|
||||
|
||||
describe('GardenPointerInput drawing startup', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal('localStorage', {
|
||||
clear: vi.fn(),
|
||||
getItem: vi.fn(() => null),
|
||||
removeItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('allows pointer drawing immediately', async () => {
|
||||
const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
|
||||
await createPointerInput();
|
||||
|
||||
canvas.dispatchPointerEvent('pointerdown', { pointerId: 7 });
|
||||
canvas.dispatchPointerEvent('pointermove', {
|
||||
clientX: 60,
|
||||
clientY: 80,
|
||||
pointerId: 7,
|
||||
timeStamp: 120,
|
||||
});
|
||||
|
||||
expect(onStartDrawing).toHaveBeenCalledTimes(1);
|
||||
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
|
||||
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
|
||||
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(2);
|
||||
expect(spawnStrokeAgents).toHaveBeenCalledTimes(2);
|
||||
expect(canvas.capturedPointerIds).toEqual([7]);
|
||||
});
|
||||
|
||||
it('starts drawing from a fresh pointerdown', async () => {
|
||||
const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
|
||||
await createPointerInput();
|
||||
|
||||
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
|
||||
|
||||
expect(onStartDrawing).toHaveBeenCalledTimes(1);
|
||||
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
|
||||
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
|
||||
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1);
|
||||
expect(spawnStrokeAgents).toHaveBeenCalledTimes(1);
|
||||
expect(canvas.capturedPointerIds).toEqual([9]);
|
||||
});
|
||||
|
||||
it('flushes the delayed smoothed stroke tail on pointerup', async () => {
|
||||
const { brushPipeline, canvas } = await createPointerInput();
|
||||
|
||||
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
|
||||
canvas.dispatchPointerEvent('pointermove', {
|
||||
clientX: 60,
|
||||
clientY: 80,
|
||||
pointerId: 9,
|
||||
timeStamp: 120,
|
||||
});
|
||||
canvas.dispatchPointerEvent('pointerup', {
|
||||
clientX: 60,
|
||||
clientY: 80,
|
||||
pointerId: 9,
|
||||
timeStamp: 140,
|
||||
});
|
||||
|
||||
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(3);
|
||||
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][0])).toEqual([10, 20]);
|
||||
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][1])).toEqual([35, 50]);
|
||||
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][0])).toEqual([35, 50]);
|
||||
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][1])).toEqual([60, 80]);
|
||||
});
|
||||
|
||||
it('uses coalesced pointer samples for smoother brush segments', async () => {
|
||||
const { audio, brushPipeline, canvas, spawnStrokeAgents } =
|
||||
await createPointerInput();
|
||||
|
||||
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
|
||||
audio.stroke.mockClear();
|
||||
brushPipeline.addSwipeSegment.mockClear();
|
||||
spawnStrokeAgents.mockClear();
|
||||
|
||||
canvas.dispatchPointerEvent('pointermove', {
|
||||
clientX: 40,
|
||||
clientY: 20,
|
||||
getCoalescedEvents: () => [
|
||||
makePointerEvent('pointermove', {
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
pointerId: 9,
|
||||
timeStamp: 110,
|
||||
}),
|
||||
makePointerEvent('pointermove', {
|
||||
clientX: 30,
|
||||
clientY: 20,
|
||||
pointerId: 9,
|
||||
timeStamp: 115,
|
||||
}),
|
||||
makePointerEvent('pointermove', {
|
||||
clientX: 40,
|
||||
clientY: 20,
|
||||
pointerId: 9,
|
||||
timeStamp: 120,
|
||||
}),
|
||||
],
|
||||
pointerId: 9,
|
||||
timeStamp: 120,
|
||||
});
|
||||
|
||||
expect(audio.stroke).toHaveBeenCalledTimes(3);
|
||||
expect(spawnStrokeAgents).toHaveBeenCalledTimes(3);
|
||||
expect(brushPipeline.addSwipeSegment.mock.calls.length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it('caps curve tessellation with the brush curve resolution setting', async () => {
|
||||
const { brushPipeline, canvas, runtimeSettings } = await createPointerInput();
|
||||
runtimeSettings.brushCurveResolution = 2;
|
||||
runtimeSettings.brushSize = 1;
|
||||
|
||||
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
|
||||
canvas.dispatchPointerEvent('pointermove', {
|
||||
clientX: 10,
|
||||
clientY: 60,
|
||||
pointerId: 9,
|
||||
timeStamp: 120,
|
||||
});
|
||||
canvas.dispatchPointerEvent('pointermove', {
|
||||
clientX: 60,
|
||||
clientY: 60,
|
||||
pointerId: 9,
|
||||
timeStamp: 140,
|
||||
});
|
||||
|
||||
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue