Improve character controls and add idle breathing

This commit is contained in:
2026-11-02 11:56:28 +09:00
parent 0d58170211
commit 21b0bb7e98
15 changed files with 458 additions and 65 deletions
+75 -5
View File
@@ -5,7 +5,9 @@ import { createFpsCameraController } from '../src/controls/fpsCameraController.t
import { directionFromAngles } from '../src/astronomy/direction.ts';
import { SUMMIT_TERRAIN } from '../src/constants/summitTerrain.ts';
function withController(run: (camera: PerspectiveCamera, canvas: EventTarget, picks: number[][]) => void) {
function withController(run: (camera: PerspectiveCamera, canvas: EventTarget, picks: number[][],
controller: ReturnType<typeof createFpsCameraController>) => void,
options: Parameters<typeof createFpsCameraController>[3] = {}) {
const oldWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const oldDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const doc = Object.assign(new EventTarget(), { querySelector: () => ({ textContent: '' }) });
@@ -16,8 +18,8 @@ function withController(run: (camera: PerspectiveCamera, canvas: EventTarget, pi
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); });
try { run(camera, canvas, picks); }
(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) {
@@ -26,11 +28,79 @@ function withController(run: (camera: PerspectiveCamera, canvas: EventTarget, pi
}
}
}
function pointer(canvas: EventTarget, type: string, x: number, y: number, pointerType = 'touch', button = 0) {
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: 1, isPrimary: true, pointerType, button, clientX: x, clientY: y }));
{ 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) {