207 lines
10 KiB
TypeScript
207 lines
10 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
import { createRainGearState } from '../src/controls/rainGear.ts';
|
|
import { createEmoteState } from '../src/controls/emotes.ts';
|
|
import { createSheep } from '../src/scene/sheep.ts';
|
|
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));
|
|
raincoat.name = 'TestRaincoat';
|
|
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);
|
|
const remote = sheep.clone();
|
|
remote.pose('idle', 0, false, 0);
|
|
remote.pose('idle', 0, false, 1);
|
|
const remoteCoat = remote.group.getObjectByName('TestRaincoat')!;
|
|
const remoteCoatPosition = remoteCoat.position.clone();
|
|
sheep.pose('idle', 0, true, 3);
|
|
remote.pose('idle', 0, false, 1);
|
|
assert.deepEqual(remoteCoat.position, remoteCoatPosition, 'local walking cannot change a remote coat pivot');
|
|
remote.dispose();
|
|
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 });
|
|
const beam = createFlashlight(sheep.group);
|
|
assert.equal(torch.parent, model.getObjectByName('ArmLeft'));
|
|
assert.equal(torch.visible, false);
|
|
assert.equal(beam.light.visible, false);
|
|
assert.equal(beam.light.intensity, FLASHLIGHT.lumens / (2 * Math.PI * (1 - Math.cos(FLASHLIGHT.halfAngle))));
|
|
sheep.setEquipment(true, false, false, true);
|
|
sheep.pose('idle', 0, true, 1);
|
|
sheep.pose('idle', 0, true, 2);
|
|
beam.update(true);
|
|
assert.equal(torch.visible, true);
|
|
const direction = new Vector3().subVectors(beam.light.target.position, beam.light.position).normalize();
|
|
assert.ok(direction.z > .9 && direction.y < 0, 'beam points forward and slightly down');
|
|
const before = beam.light.position.clone();
|
|
sheep.group.position.x += 3;
|
|
beam.update(true);
|
|
assert.ok(Math.abs(beam.light.position.x-before.x-3) < .001);
|
|
sheep.setEquipment(false, false, false, false);
|
|
beam.update(false);
|
|
sheep.pose('idle', 0, false, 3);
|
|
assert.equal(torch.visible, false);
|
|
assert.equal(beam.light.visible, false);
|
|
beam.dispose(); sheep.dispose();
|
|
});
|
|
|
|
test('flashlight raises and lowers smoothly, retaining the prop until the arm is down', async () => {
|
|
const [model, umbrella, raincoat, torch] = await Promise.all(['red-sheep', 'umbrella', 'raincoat', 'flashlight'].map(asset));
|
|
const sheep = createSheep(model, { umbrella, raincoat, flashlight: torch });
|
|
const arm = model.getObjectByName('ArmLeft')!;
|
|
sheep.pose('idle', 0, false, 0);
|
|
const rest = arm.quaternion.clone();
|
|
sheep.setEquipment(false, false, false, true);
|
|
assert.equal(sheep.pose('idle', 0, false, .1), true, 'idle rendering continues during the transition');
|
|
const raising = arm.quaternion.clone();
|
|
sheep.pose('idle', 0, false, 1);
|
|
const held = arm.quaternion.clone();
|
|
assert.ok(raising.angleTo(rest) > .01 && raising.angleTo(held) > .01);
|
|
sheep.setEquipment(false, false, false, false);
|
|
assert.equal(torch.visible, true, 'the prop must not vanish before the lowering animation');
|
|
assert.equal(sheep.pose('idle', 0, false, 1.1), true);
|
|
const lowering = arm.quaternion.clone();
|
|
assert.ok(lowering.angleTo(held) > .01 && lowering.angleTo(rest) > .01);
|
|
sheep.setEquipment(false, false, false, true);
|
|
sheep.pose('idle', 0, false, 1.1);
|
|
assert.ok(arm.quaternion.angleTo(lowering) < 1e-6, 'reversing mid-animation does not snap');
|
|
sheep.setEquipment(false, false, false, false);
|
|
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), true, 'breathing continues after the equipment transition');
|
|
sheep.dispose();
|
|
});
|
|
|
|
test('rain equipment is independent of emotes, remains in snow, and clears in dry weather', () => {
|
|
const gear = createRainGearState();
|
|
const emote = createEmoteState();
|
|
assert.equal(gear.toggle('umbrella'), false);
|
|
gear.updateWeather(1, 0);
|
|
assert.ok(gear.raincoat && gear.hood, 'rainy arrival starts with coat and hood');
|
|
gear.toggle('raincoat');
|
|
assert.equal(gear.toggle('hood'), false, 'hood requires the raincoat');
|
|
assert.ok(gear.toggle('umbrella'));
|
|
assert.ok(gear.toggle('raincoat'));
|
|
assert.ok(gear.toggle('hood'));
|
|
assert.ok(gear.hood);
|
|
emote.start('wave', false); emote.update(4, true, false);
|
|
assert.equal(emote.current, 'idle');
|
|
assert.ok(gear.umbrella && gear.raincoat);
|
|
emote.start('pray', true);
|
|
gear.updateWeather(0, .3);
|
|
assert.ok(gear.umbrella && gear.raincoat);
|
|
gear.toggle('umbrella');
|
|
assert.ok(!gear.umbrella && gear.raincoat);
|
|
gear.toggle('raincoat');
|
|
assert.equal(gear.hood, false);
|
|
gear.updateWeather(0, 0);
|
|
assert.ok(!gear.available && !gear.umbrella && !gear.raincoat);
|
|
});
|
|
|
|
test('arrival equipment waits for known weather and never overrides a later manual choice', () => {
|
|
const gear = createRainGearState();
|
|
gear.updateWeather(null, null);
|
|
gear.updateWeather(2, 0);
|
|
assert.ok(gear.raincoat && gear.hood && !gear.umbrella);
|
|
gear.toggle('hood');
|
|
gear.updateWeather(3, 0);
|
|
assert.ok(gear.raincoat && !gear.hood);
|
|
gear.toggle('raincoat');
|
|
gear.updateWeather(4, 0);
|
|
assert.ok(!gear.raincoat && !gear.hood);
|
|
const dryArrival = createRainGearState();
|
|
dryArrival.updateWeather(0, 0);
|
|
dryArrival.updateWeather(2, 0);
|
|
assert.ok(!dryArrival.raincoat && !dryArrival.hood, 'only arrival gets the default outfit');
|
|
});
|
|
|
|
async function asset(name: string) {
|
|
const bytes = await readFile(new URL(`../public/characters/${name}.glb`, import.meta.url));
|
|
return (await new GLTFLoader().parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), '')).scene;
|
|
}
|
|
|
|
test('Blender gear follows the sheep joints and keeps the umbrella grip through walking, waving and prayer', async () => {
|
|
const [model, umbrella, raincoat] = await Promise.all(['red-sheep', 'umbrella', 'raincoat'].map(asset));
|
|
const sheep = createSheep(model, { umbrella, raincoat });
|
|
const right = model.getObjectByName('ArmRight')!;
|
|
const left = model.getObjectByName('ArmLeft')!;
|
|
const sleeve = model.getObjectByName('CoatArmRight')!;
|
|
const hood = model.getObjectByName('CoatHood')!;
|
|
const folded = model.getObjectByName('CoatFoldedHood')!;
|
|
assert.equal(hood.parent, model.getObjectByName('Head'));
|
|
assert.equal(sleeve.parent, right);
|
|
assert.equal(umbrella.visible, false);
|
|
sheep.setEquipment(true, true);
|
|
assert.ok(!hood.visible && folded.visible);
|
|
sheep.setEquipment(true, true, true);
|
|
assert.ok(hood.visible && !folded.visible);
|
|
sheep.pose('idle', 0, true, 1);
|
|
sheep.pose('idle', 0, true, 2);
|
|
const grip = right.quaternion.clone();
|
|
const leftWalking = left.quaternion.clone();
|
|
sheep.pose('wave', .2, false, 2);
|
|
assert.ok(right.quaternion.angleTo(grip) < 1e-6);
|
|
assert.ok(left.quaternion.angleTo(leftWalking) > .5);
|
|
sheep.pose('pray', .2, false, 2);
|
|
assert.ok(right.quaternion.angleTo(grip) < 1e-6);
|
|
assert.ok(umbrella.visible && raincoat.visible && sleeve.visible);
|
|
sheep.setEquipment(false, false);
|
|
sheep.pose('idle', 0, false, 3);
|
|
assert.ok(!umbrella.visible && !raincoat.visible && !sleeve.visible);
|
|
assert.equal(hood.visible, false);
|
|
sheep.dispose();
|
|
});
|
|
|
|
test('umbrella follows the right hand through raising, reversal and lowering before hiding', async () => {
|
|
const [model, umbrella, raincoat] = await Promise.all(['red-sheep', 'umbrella', 'raincoat'].map(asset));
|
|
const sheep = createSheep(model, { umbrella, raincoat });
|
|
const arm = model.getObjectByName('ArmRight')!;
|
|
assert.ok(umbrella.parent === arm, 'umbrella is attached to the right hand');
|
|
sheep.pose('idle', 0, false, 0);
|
|
const rest = arm.quaternion.clone();
|
|
sheep.setEquipment(true, false);
|
|
assert.equal(sheep.pose('idle', 0, false, .225), true);
|
|
const halfway = arm.quaternion.clone();
|
|
const canopyHalfway = umbrella.getWorldPosition(new Vector3());
|
|
sheep.setEquipment(true, false); // Repeated network updates must not restart the transition.
|
|
sheep.pose('idle', 0, false, .45);
|
|
const held = arm.quaternion.clone();
|
|
assert.ok(halfway.angleTo(rest) > .01 && halfway.angleTo(held) > .01);
|
|
assert.ok(canopyHalfway.distanceTo(umbrella.getWorldPosition(new Vector3())) > .01);
|
|
sheep.setEquipment(false, false);
|
|
assert.equal(umbrella.visible, true);
|
|
assert.equal(sheep.pose('idle', 0, false, .675), true);
|
|
assert.ok(arm.quaternion.angleTo(halfway) < 1e-6);
|
|
sheep.setEquipment(true, false);
|
|
sheep.pose('idle', 0, false, .675);
|
|
assert.ok(arm.quaternion.angleTo(halfway) < 1e-6, 'reversal preserves the current pose');
|
|
sheep.setEquipment(false, false);
|
|
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), true);
|
|
sheep.dispose();
|
|
});
|