feat: initialize sunrise with 100-player shared rooms
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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);
|
||||
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);
|
||||
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('sideways and rear destinations turn the character forward and ease the camera behind it', () => {
|
||||
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, camera, 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(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(Math.abs(camera.rotation.x + .2) < 1e-6, 'preserve viewing pitch');
|
||||
const settled = character.quaternion.clone();
|
||||
faceTravel(character, camera, 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);
|
||||
assert.ok(camera.quaternion.angleTo(manual) < 1e-6, 'manual looking takes priority');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user