Files
sunrise/tests/character.test.ts
T

187 lines
9.5 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Group, Mesh, MeshBasicMaterial, PerspectiveCamera, Vector3 } from 'three';
import { canPray, createEmoteState } from '../src/controls/emotes.ts';
import { faceTravel, updateThirdPersonCamera } from '../src/controls/thirdPersonCamera.ts';
import { readFile } from 'node:fs/promises';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { createSheep } from '../src/scene/sheep.ts';
test('remote sheep share GPU assets but have independent joints and safe disposal', 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);
assert.equal(typeof sheep.clone, 'function');
const remote = sheep.clone();
const localArm = sheep.group.getObjectByName('ArmRight')!;
const initial = localArm.quaternion.clone();
remote.pose('wave', 1, false, 1);
assert.ok(localArm.quaternion.equals(initial));
assert.ok(remote.group.getObjectByName('ArmRight')!.quaternion.angleTo(initial) > 1);
const localMeshes: Mesh[] = [], remoteMeshes: Mesh[] = [];
sheep.group.traverse(o => { if (o instanceof Mesh) localMeshes.push(o); });
remote.group.traverse(o => { if (o instanceof Mesh) remoteMeshes.push(o); });
let disposals = 0;
localMeshes.forEach((mesh, i) => {
assert.equal(remoteMeshes[i].geometry, mesh.geometry);
assert.equal(remoteMeshes[i].material, mesh.material);
mesh.geometry.addEventListener('dispose', () => disposals++);
});
remote.dispose();
assert.equal(disposals, 0);
sheep.dispose();
assert.ok(disposals > 0);
});
test('sheep dims with night and clouds without losing its colors after repeated updates', 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 materials = new Set<MeshBasicMaterial>();
sheep.group.traverse(object => {
if (object instanceof Mesh) {
for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
assert.ok(material instanceof MeshBasicMaterial);
materials.add(material);
}
}
});
const originals = [...materials].map(material => material.color.clone());
const brightest = [...materials].sort((a, b) => b.color.r - a.color.r)[0];
const day = brightest.color.r;
sheep.updateLighting(30, .65);
assert.ok(brightest.color.r < day);
sheep.updateLighting(-18, .65);
const night = brightest.color.r;
assert.ok(night > day * .05 && night < day * .2, 'night stays visible but substantially darker');
sheep.updateLighting(-18, .65);
assert.equal(brightest.color.r, night, 'updates must not accumulate darkening');
sheep.updateLighting(0, .65);
assert.ok(brightest.color.r > night && brightest.color.r < day);
sheep.updateLighting(30, 1);
[...materials].forEach((material, index) => assert.ok(material.color.equals(originals[index])));
sheep.dispose();
});
test('prayer is available only within five minutes of the selected sunrise', () => {
const sunrise = new Date('2026-10-30T21:50:00Z');
const at = (offset: number) => canPray(new Date(+sunrise + offset), sunrise);
assert.equal(at(-300001), false);
assert.equal(at(-300000), true);
assert.equal(at(0), true);
assert.equal(at(300000), true);
assert.equal(at(300001), false);
assert.equal(canPray(sunrise, null), false);
});
test('wave expires; movement and unavailable sunrise cancel prayer', () => {
const state = createEmoteState();
assert.equal(state.start('pray', false), false);
state.start('wave', false);
assert.equal(state.update(1, false, false), 'wave');
assert.equal(state.update(2, false, false), 'idle');
state.start('pray', true);
assert.equal(state.update(.1, false, true), 'pray');
assert.equal(state.update(.1, true, true), 'idle');
state.start('pray', true);
assert.equal(state.update(.1, false, false), 'idle');
});
test('third person camera stays behind observer and above terrain without moving observer', () => {
const anchor = new PerspectiveCamera();
anchor.position.set(10, 1.7, 20);
anchor.lookAt(11, 1.7, 20);
const original = anchor.position.clone();
const follow = anchor.clone();
updateThirdPersonCamera(follow, anchor, () => 0);
assert.ok(follow.position.x < anchor.position.x);
assert.deepEqual(anchor.position, original);
assert.ok(follow.getWorldDirection(new Vector3()).x > .5);
updateThirdPersonCamera(follow, anchor, () => 4);
assert.ok(follow.position.y >= 4.35);
});
test('exported sheep has working joints and renders the final idle pose after walking', 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 leg = sheep.group.getObjectByName('LegLeft')!;
const rest = leg.quaternion.clone();
assert.equal(sheep.pose('idle', 0, true, 1), true);
sheep.pose('idle', 0, true, 1.2);
assert.ok(leg.quaternion.angleTo(rest) > .01);
const walking = leg.quaternion.clone();
assert.equal(sheep.pose('idle', 0, false, 1.2), true, 'stopping must invalidate one frame');
assert.ok(leg.quaternion.angleTo(walking) < 1e-6, 'stopping blends instead of snapping');
sheep.pose('idle', 0, false, 4);
assert.ok(leg.quaternion.angleTo(rest) < 1e-6);
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);
assert.ok(arm.quaternion.angleTo(armRest) > 1);
sheep.pose('pray', 1, false, 1);
assert.ok(arm.quaternion.angleTo(armRest) > .5);
sheep.dispose();
});
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.ok(head.position.distanceTo(headRest) > .001, 'walking carries the head');
assert.ok(body.position.x !== 0, 'walking shifts the torso weight');
const walkingHead = head.position.clone();
sheep.pose('idle', 0, true, 1.2);
assert.deepEqual(head.position, walkingHead, 'walking offsets cannot accumulate');
sheep.pose('pray', 0, false, 1.5);
assert.deepEqual(head.position, headRest, 'emotes clear walking offsets');
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;
const camera = new PerspectiveCamera();
camera.rotation.set(-.2, 0, 0, 'YXZ');
const original = camera.quaternion.clone();
const initialFacing = character.quaternion.clone();
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.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);
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, new Vector3(), 1 / 60);
assert.ok(character.quaternion.angleTo(settled) < 1e-6, 'idle keeps the last facing');
const manual = camera.quaternion.clone();
faceTravel(character, step.clone().negate(), 1 / 60);
assert.ok(camera.quaternion.angleTo(manual) < 1e-6, 'manual looking takes priority');
}
});