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
+37 -10
View File
@@ -111,7 +111,7 @@ test('exported sheep has working joints and renders the final idle pose after wa
assert.ok(leg.quaternion.angleTo(rest) > .01);
assert.equal(sheep.pose('idle', 0, false, 1), true, 'stopping must invalidate one frame');
assert.ok(leg.quaternion.angleTo(rest) < 1e-6);
assert.equal(sheep.pose('idle', 0, false, 1), false);
assert.equal(sheep.pose('idle', 0, false, 1), true, 'idle breathing keeps rendering');
const arm = sheep.group.getObjectByName('ArmRight')!;
const armRest = arm.quaternion.clone();
sheep.pose('wave', 1, false, 1);
@@ -121,7 +121,36 @@ test('exported sheep has working joints and renders the final idle pose after wa
sheep.dispose();
});
test('sideways and rear destinations turn the character forward and ease the camera behind it', () => {
test('idle breathing moves torso and head while feet stay planted and clones retain authored rest transforms', async () => {
const bytes = await readFile(new URL('../public/characters/red-sheep.glb', import.meta.url));
const model = await new GLTFLoader().parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), '');
const sheep = createSheep(model.scene);
const body = sheep.group.getObjectByName('Body')!, head = sheep.group.getObjectByName('Head')!;
const bodyRest = body.scale.clone(), headRest = head.position.clone();
const leg = sheep.group.getObjectByName('LegLeft')!;
const feet = leg.getWorldPosition(new Vector3());
sheep.pose('idle', 0, false, 0);
for (let i = 1; i <= 8; i++) sheep.pose('idle', 0, false, i / 10);
assert.ok(!body.scale.equals(bodyRest));
assert.ok(!head.position.equals(headRest));
assert.ok(leg.getWorldPosition(new Vector3()).distanceTo(feet) < 1e-8);
const once = body.scale.clone();
sheep.pose('idle', 0, false, .8);
assert.deepEqual(body.scale, once, 'same timestamp cannot accumulate scaling');
const remote = sheep.clone();
remote.pose('wave', 0, false, 1);
assert.deepEqual(remote.group.getObjectByName('Body')!.scale, bodyRest, 'clone does not inherit inflated rest scale');
assert.deepEqual(remote.group.getObjectByName('Head')!.position, headRest);
assert.deepEqual(body.scale, once, 'remote pose is independent');
sheep.pose('idle', 0, true, .81);
assert.ok(!body.scale.equals(bodyRest), 'breathing blends out instead of snapping');
sheep.pose('idle', 0, true, 1.2);
assert.deepEqual(body.scale, bodyRest);
assert.deepEqual(head.position, headRest);
remote.dispose(); sheep.dispose();
});
test('sideways and rear destinations turn the character without changing the chosen camera angle', () => {
for (const step of [new Vector3(1, 0, 0), new Vector3(0, 0, 1), new Vector3(-1, 0, -.01)]) {
const character = new Group();
character.rotation.y = Math.PI;
@@ -129,21 +158,19 @@ test('sideways and rear destinations turn the character forward and ease the cam
camera.rotation.set(-.2, 0, 0, 'YXZ');
const original = camera.quaternion.clone();
const initialFacing = character.quaternion.clone();
const speed = faceTravel(character, camera, step, 1 / 60);
const speed = faceTravel(character, step, 1 / 60);
assert.ok(character.quaternion.angleTo(initialFacing) <= 4 / 60 + 1e-6, 'body must turn gradually');
if (step.z === 1) assert.equal(speed, 0, 'rear destinations turn before translating');
assert.ok(camera.quaternion.angleTo(original) < .22, 'camera must ease, not snap');
for (let i = 0; i < 180; i++) faceTravel(character, camera, step, 1 / 60);
assert.ok(camera.quaternion.equals(original), 'walking preserves the chosen camera angle');
for (let i = 0; i < 180; i++) faceTravel(character, step, 1 / 60);
assert.ok(character.getWorldDirection(new Vector3()).dot(step.clone().normalize()) > .999);
const view = camera.getWorldDirection(new Vector3());
view.y = 0;
assert.ok(view.normalize().dot(step.clone().normalize()) > .999);
assert.ok(camera.quaternion.equals(original));
assert.ok(Math.abs(camera.rotation.x + .2) < 1e-6, 'preserve viewing pitch');
const settled = character.quaternion.clone();
faceTravel(character, camera, new Vector3(), 1 / 60);
faceTravel(character, new Vector3(), 1 / 60);
assert.ok(character.quaternion.angleTo(settled) < 1e-6, 'idle keeps the last facing');
const manual = camera.quaternion.clone();
faceTravel(character, camera, step.clone().negate(), 1 / 60, false);
faceTravel(character, step.clone().negate(), 1 / 60);
assert.ok(camera.quaternion.angleTo(manual) < 1e-6, 'manual looking takes priority');
}
});
+30 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Group } from 'three';
import { BoxGeometry, Group, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three';
import { createServerClock } from '../src/multiplayer/clock.ts';
import { createRemotePlayers } from '../src/multiplayer/remotePlayers.ts';
import type { Sheep } from '../src/scene/sheep.ts';
@@ -16,6 +16,35 @@ test('server clock uses round trip midpoint and monotonic elapsed time', () => {
assert.equal(clock.now(), 1001020);
});
test('companion selection picks visible players and chooses the nearest reachable unoccupied side', () => {
const scene = new Group();
const geometry = new BoxGeometry(1, 2, 1), material = new MeshBasicMaterial();
const template = { clone() {
const group = new Group(); group.add(new Mesh(geometry, material));
return { group, setEquipment() {}, pose: () => false, dispose() { group.removeFromParent(); } } as unknown as Sheep;
} } as Sheep;
const remote = createRemotePlayers(template, scene, () => 0);
const player: PlayerState = { id: 'friend', nickname: '친구', x: 0, z: 0, yaw: 0, moving: false,
emote: 'idle', equipment: { umbrella: false, raincoat: false, hood: false }, updatedAt: 1000, emoteStartedAt: 1000 };
remote.synchronize([player], 'self');
const ray = new Raycaster(new Vector3(0, 0, 5), new Vector3(0, 0, -1));
assert.equal(remote.pick(ray, 10), 'friend');
assert.equal(remote.pick(ray, 2), undefined, 'terrain in front prevents selection');
const from = new Vector3(3, 0, 0);
const right = remote.beside('friend', from, () => true)!;
assert.ok(right.position.x > 0 && right.position.z === 0);
assert.equal(right.yaw, 0);
const left = remote.beside('friend', from, point => point.x < 0)!;
assert.ok(left.position.x < 0);
assert.equal(remote.beside('friend', from, () => false), undefined);
remote.synchronize([player, { ...player, id: 'occupied', x: right.position.x }], 'self');
assert.ok(remote.beside('friend', from, () => true)!.position.x < 0);
remote.synchronize([], 'self');
assert.equal(remote.get('friend'), undefined);
assert.equal(remote.beside('friend', from, () => true), undefined);
geometry.dispose(); material.dispose();
});
test('remote snapshot reconciles membership, equipment and interpolated motion without local duplicate', () => {
const scene = new Group();
const equipment: boolean[][] = [];
+22 -2
View File
@@ -9,6 +9,26 @@ import { createFlashlight } from '../src/scene/flashlight.ts';
import { FLASHLIGHT } from '../src/constants/flashlight.ts';
import { Vector3 } from 'three';
test('idle breathing carries the coat and hood without lifting the feet or changing the umbrella grip', async () => {
const [model, umbrella, raincoat] = await Promise.all(['red-sheep', 'umbrella', 'raincoat'].map(asset));
const sheep = createSheep(model, { umbrella, raincoat });
sheep.setEquipment(true, true, true);
const body = model.getObjectByName('Body')!, head = model.getObjectByName('Head')!;
const hood = model.getObjectByName('CoatHood')!, arm = model.getObjectByName('ArmRight')!;
const bodyScale = body.scale.clone(), coatScale = raincoat.scale.clone();
const foot = model.getObjectByName('LegRight')!, footPosition = foot.getWorldPosition(new Vector3());
sheep.pose('idle', 0, false, 0);
sheep.pose('idle', 0, false, 1);
const grip = arm.quaternion.clone();
assert.ok(Math.abs(body.scale.y / bodyScale.y - raincoat.scale.y / coatScale.y) < 1e-8);
assert.ok(Math.abs(body.scale.z / bodyScale.z - raincoat.scale.z / coatScale.z) < 1e-8);
assert.ok(hood.getWorldPosition(new Vector3()).distanceTo(head.getWorldPosition(new Vector3())) < 1e-8);
sheep.pose('idle', 0, false, 2);
assert.ok(arm.quaternion.angleTo(grip) < 1e-8);
assert.ok(foot.getWorldPosition(new Vector3()).distanceTo(footPosition) < 1e-8);
sheep.dispose();
});
test('authored flashlight stays in the left grip and uses lumen power only when switched on', async () => {
const [model, umbrella, raincoat, torch] = await Promise.all(['red-sheep', 'umbrella', 'raincoat', 'flashlight'].map(asset));
const sheep = createSheep(model, { umbrella, raincoat, flashlight: torch });
@@ -60,7 +80,7 @@ test('flashlight raises and lowers smoothly, retaining the prop until the arm is
assert.equal(sheep.pose('idle', 0, false, 2), true, 'the final lowered frame is rendered');
assert.ok(arm.quaternion.angleTo(rest) < 1e-6);
assert.equal(torch.visible, false);
assert.equal(sheep.pose('idle', 0, false, 2.1), false, 'idle rendering can stop after the transition');
assert.equal(sheep.pose('idle', 0, false, 2.1), true, 'breathing continues after the equipment transition');
sheep.dispose();
});
@@ -171,6 +191,6 @@ test('umbrella follows the right hand through raising, reversal and lowering bef
assert.equal(sheep.pose('idle', 0, false, 1.2), true);
assert.ok(arm.quaternion.angleTo(rest) < 1e-6);
assert.equal(umbrella.visible, false);
assert.equal(sheep.pose('idle', 0, false, 1.3), false);
assert.equal(sheep.pose('idle', 0, false, 1.3), true);
sheep.dispose();
});
+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) {