Improve character controls and add idle breathing
This commit is contained in:
+37
-10
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user