import { test } from 'node:test'; import assert from 'node:assert/strict'; import { PerspectiveCamera, Vector3 } from 'three'; import { createFpsCameraController } from '../src/controls/fpsCameraController.ts'; import { directionFromAngles } from '../src/astronomy/direction.ts'; import { SUMMIT_TERRAIN } from '../src/constants/summitTerrain.ts'; function withController(run: (camera: PerspectiveCamera, canvas: EventTarget, picks: number[][], controller: ReturnType) => void, options: Parameters[3] = {}) { const oldWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); const oldDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); const doc = Object.assign(new EventTarget(), { querySelector: () => ({ textContent: '' }) }); const canvas = Object.assign(new EventTarget(), { ownerDocument: doc, focus() {}, blur() {}, tabIndex: 0, setPointerCapture() {}, releasePointerCapture() {}, hasPointerCapture() { return true; } }); Object.defineProperty(globalThis, 'window', { value: new EventTarget(), configurable: true }); Object.defineProperty(globalThis, 'document', { value: doc, configurable: true }); const camera = new PerspectiveCamera(); const picks: number[][] = []; const controller = createFpsCameraController(camera, canvas as unknown as HTMLCanvasElement, (x, y) => { picks.push([x, y]); return new Vector3(x, 0, y); }, options); try { run(camera, canvas, picks, controller); } finally { controller.dispose(); for (const [key, value] of [['window', oldWindow], ['document', oldDocument]] as const) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); } } } function pointer(canvas: EventTarget, type: string, x: number, y: number, pointerType = 'touch', button = 0, pointerId = 1) { canvas.dispatchEvent(Object.assign(new Event(type, { cancelable: true }), { pointerId, isPrimary: pointerId === 1, pointerType, button, clientX: x, clientY: y })); } test('direction mode stops travel and rotates only the character until done', () => { let yaw = 0; withController((camera, canvas, picks, controller) => { pointer(canvas, 'pointerdown', 20, 20); pointer(canvas, 'pointerup', 20, 20); assert.ok(controller.destination); controller.setTurning(true); assert.equal(controller.destination, null); const view = camera.quaternion.clone(); pointer(canvas, 'pointerdown', 100, 100); pointer(canvas, 'pointermove', 150, 120); pointer(canvas, 'pointerup', 150, 120); assert.notEqual(yaw, 0); assert.ok(camera.quaternion.equals(view)); assert.equal(picks.length, 1); controller.setTurning(false); const facing = yaw; pointer(canvas, 'pointerdown', 100, 100); pointer(canvas, 'pointermove', 150, 100); pointer(canvas, 'pointerup', 150, 100); assert.equal(yaw, facing); assert.ok(!camera.quaternion.equals(view)); }, { onTurn: delta => { yaw += delta; } }); }); test('pinch zoom never rotates or becomes a destination when either finger lifts', () => { let zoom = 1; withController((camera, canvas, picks) => { const view = camera.quaternion.clone(); pointer(canvas, 'pointerdown', 100, 100); pointer(canvas, 'pointerdown', 200, 100, 'touch', 0, 2); pointer(canvas, 'pointermove', 250, 100, 'touch', 0, 2); assert.ok(zoom < 1); pointer(canvas, 'pointerup', 100, 100); pointer(canvas, 'pointermove', 280, 100, 'touch', 0, 2); pointer(canvas, 'pointerup', 280, 100, 'touch', 0, 2); assert.deepEqual(picks, []); assert.ok(camera.quaternion.equals(view)); pointer(canvas, 'pointerdown', 50, 50); pointer(canvas, 'pointerup', 50, 50); assert.equal(picks.length, 1); }, { onZoom: factor => { zoom *= factor; } }); }); test('selecting a character takes priority over tap movement and blur exits turning', () => { withController((_camera, canvas, picks, controller) => { pointer(canvas, 'pointerdown', 100, 100); pointer(canvas, 'pointerup', 100, 100); assert.deepEqual(picks, []); controller.setTurning(true); window.dispatchEvent(new Event('blur')); assert.equal(controller.turning, false); }, { onSelect: () => true }); }); test('opening a menu stops walking without losing the explicit direction mode', () => { withController((_camera, canvas, _picks, controller) => { pointer(canvas, 'pointerdown', 100, 100); pointer(canvas, 'pointerup', 100, 100); assert.ok(controller.destination); canvas.dispatchEvent(new Event('blur')); assert.equal(controller.destination, null); controller.setTurning(true); canvas.dispatchEvent(new Event('blur')); assert.equal(controller.turning, true, 'Done button can receive focus before exiting'); }); }); test('touch tap and mouse right click select a destination, left click does not', () => { withController((_camera, canvas, picks) => { for (const [type, button] of [['touch', 0], ['mouse', 2], ['mouse', 0]] as const) { pointer(canvas, 'pointerdown', 100, 120, type, button); pointer(canvas, 'pointerup', 100, 120, type, button); } assert.deepEqual(picks, [[100, 120], [100, 120]]); }); }); test('drag changes the view without selecting a destination, including when returning to its start', () => { withController((camera, canvas, picks) => { pointer(canvas, 'pointerdown', 100, 100); pointer(canvas, 'pointermove', 130, 110); assert.notEqual(camera.rotation.y, 0); pointer(canvas, 'pointerup', 100, 100); assert.deepEqual(picks, []); pointer(canvas, 'pointerdown', 100, 100); pointer(canvas, 'pointercancel', 100, 100); pointer(canvas, 'pointerup', 100, 100); assert.deepEqual(picks, []); }); }); test('first horizontal drag after a southward reset preserves camera pitch', () => { withController((camera, canvas) => { camera.lookAt(new Vector3(...directionFromAngles(SUMMIT_TERRAIN.heading, SUMMIT_TERRAIN.pitch))); const before = camera.getWorldDirection(new Vector3()); pointer(canvas, 'pointerdown', 100, 100, 'mouse'); pointer(canvas, 'pointermove', 110, 100, 'mouse'); const after = camera.getWorldDirection(new Vector3()); assert.ok(Math.abs(after.y - before.y) < 1e-8); assert.ok(before.angleTo(after) > 0); assert.ok(before.angleTo(after) < 0.04); }); });