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');
}
});