feat(actors): build articulated dog v2
This commit is contained in:
@@ -11,12 +11,12 @@ import {
|
||||
buildCrow,
|
||||
buildDog,
|
||||
buildHumanoid,
|
||||
dogPoseStateForSpeed,
|
||||
disposeCrow,
|
||||
disposeDog,
|
||||
disposeHumanoid,
|
||||
poseCrow,
|
||||
poseDogAttention,
|
||||
poseDogWalk,
|
||||
poseDog,
|
||||
poseHumanoid,
|
||||
type CrowRig,
|
||||
type DogRig,
|
||||
@@ -200,7 +200,7 @@ export function createSceneActor(options: SceneActorOptions): SceneActor {
|
||||
let faceTexture: THREE.Texture | null = null;
|
||||
let disposed = false;
|
||||
|
||||
function sync(): void {
|
||||
function sync(elapsedSeconds = 0, previousSpeedMps?: number): void {
|
||||
const state = controller.state();
|
||||
root.position.set(
|
||||
origin.x + state.x * scale,
|
||||
@@ -217,8 +217,18 @@ export function createSceneActor(options: SceneActorOptions): SceneActor {
|
||||
if (actor.kind === "humanoid") {
|
||||
poseHumanoid(actor.rig, { walkPhase: state.posePhase, stride: state.poseAmount * 0.68 });
|
||||
} else if (actor.kind === "dog") {
|
||||
poseDogWalk(actor.rig, state.posePhase, state.poseAmount * 0.68);
|
||||
poseDogAttention(actor.rig, 0, state.posePhase * 0.7);
|
||||
const dogState = dogPoseStateForSpeed(state.speedMps);
|
||||
const acceleration = elapsedSeconds > 0 && previousSpeedMps !== undefined
|
||||
? THREE.MathUtils.clamp((state.speedMps - previousSpeedMps) / elapsedSeconds / 5, -1, 1)
|
||||
: 0;
|
||||
poseDog(actor.rig, {
|
||||
state: dogState,
|
||||
phase: state.posePhase,
|
||||
amount: dogState === "idle" ? 1 : THREE.MathUtils.clamp(0.38 + state.poseAmount * 0.62, 0, 1),
|
||||
turn: desired.turn,
|
||||
acceleration,
|
||||
attentionYaw: -desired.turn * 0.08,
|
||||
});
|
||||
} else if (state.mode === "flight") {
|
||||
// The controller names the aerodynamic state; the rig owns how that
|
||||
// state bends shoulder, elbow, wrist, tail and feet.
|
||||
@@ -267,9 +277,10 @@ export function createSceneActor(options: SceneActorOptions): SceneActor {
|
||||
tick(elapsedSeconds) {
|
||||
if (!disposed && enabled) {
|
||||
const beforeKind = controller.state().kind;
|
||||
const previousSpeedMps = controller.state().speedMps;
|
||||
controller.tick(elapsedSeconds, desired);
|
||||
if (controller.state().kind !== beforeKind) replaceActor(controller.state().kind);
|
||||
else sync();
|
||||
else sync(Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0, previousSpeedMps);
|
||||
clearEdges();
|
||||
}
|
||||
return controller.snapshot();
|
||||
|
||||
+315
-84
@@ -1,4 +1,10 @@
|
||||
/** A compact, friendly anonymous office dog with a poseable head, legs and tail. */
|
||||
/**
|
||||
* A metre-scale, original procedural dog built for readable third-person motion.
|
||||
*
|
||||
* The hierarchy intentionally separates shoulder/hip, upper leg, lower leg and
|
||||
* paw. Presentation can therefore distinguish a planted walk, diagonal trot and
|
||||
* gathered run without changing renderer-independent actor or replay state.
|
||||
*/
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
actorMesh,
|
||||
@@ -8,7 +14,27 @@ import {
|
||||
type ActorRigBase,
|
||||
} from "./common.ts";
|
||||
|
||||
export const DOG_METRICS = { length: 0.76, shoulderHeight: 0.46, height: 0.74 } as const;
|
||||
export const DOG_METRICS = {
|
||||
length: 1.25,
|
||||
shoulderHeight: 0.54,
|
||||
height: 0.94,
|
||||
} as const;
|
||||
|
||||
export type DogPoseState = "idle" | "attention" | "walk" | "trot" | "run";
|
||||
|
||||
/** Renderer-only values. All numbers are clamped and the pose is deterministic. */
|
||||
export interface DogPose {
|
||||
state: DogPoseState;
|
||||
phase?: number;
|
||||
/** Overall pose intensity, from still (0) to full expression (1). */
|
||||
amount?: number;
|
||||
/** Signed turn/lean request, -1 left to 1 right. */
|
||||
turn?: number;
|
||||
/** Signed start/stop impulse, -1 braking to 1 accelerating. */
|
||||
acceleration?: number;
|
||||
attentionYaw?: number;
|
||||
attentionPitch?: number;
|
||||
}
|
||||
|
||||
export interface DogMaterials {
|
||||
coat: THREE.Material;
|
||||
@@ -24,12 +50,26 @@ export interface DogBuildOptions {
|
||||
collarColor?: THREE.ColorRepresentation;
|
||||
}
|
||||
|
||||
export interface DogLegJoints {
|
||||
upper: THREE.Group;
|
||||
lower: THREE.Group;
|
||||
paw: THREE.Group;
|
||||
}
|
||||
|
||||
export interface DogJoints {
|
||||
body: THREE.Group;
|
||||
chest: THREE.Group;
|
||||
neck: THREE.Group;
|
||||
head: THREE.Group;
|
||||
earLeft: THREE.Group;
|
||||
earRight: THREE.Group;
|
||||
tail: THREE.Group;
|
||||
tailTip: THREE.Group;
|
||||
frontLeft: DogLegJoints;
|
||||
frontRight: DogLegJoints;
|
||||
rearLeft: DogLegJoints;
|
||||
rearRight: DogLegJoints;
|
||||
/** Backwards-compatible aliases for the four original one-piece leg joints. */
|
||||
legFrontLeft: THREE.Group;
|
||||
legFrontRight: THREE.Group;
|
||||
legRearLeft: THREE.Group;
|
||||
@@ -42,145 +82,336 @@ export interface DogRig extends ActorRigBase {
|
||||
|
||||
export function createDogMaterials(options: DogBuildOptions = {}): DogMaterials {
|
||||
return {
|
||||
coat: new THREE.MeshStandardMaterial({ name: "dog.coat", color: options.coatColor ?? 0x9a673d, roughness: 0.92 }),
|
||||
markings: new THREE.MeshStandardMaterial({ name: "dog.markings", color: options.markingsColor ?? 0xe4c9a2, roughness: 0.95 }),
|
||||
nose: new THREE.MeshStandardMaterial({ name: "dog.nose", color: 0x151719, roughness: 0.72 }),
|
||||
collar: new THREE.MeshStandardMaterial({ name: "dog.collar", color: options.collarColor ?? 0x2d92a7, roughness: 0.55 }),
|
||||
coat: new THREE.MeshStandardMaterial({
|
||||
name: "dog.coat",
|
||||
color: options.coatColor ?? 0x282b2d,
|
||||
roughness: 0.84,
|
||||
metalness: 0.02,
|
||||
}),
|
||||
markings: new THREE.MeshStandardMaterial({
|
||||
name: "dog.markings",
|
||||
color: options.markingsColor ?? 0xc7ad88,
|
||||
roughness: 0.92,
|
||||
}),
|
||||
nose: new THREE.MeshStandardMaterial({ name: "dog.nose", color: 0x0b0d0e, roughness: 0.62 }),
|
||||
collar: new THREE.MeshStandardMaterial({
|
||||
name: "dog.collar",
|
||||
color: options.collarColor ?? 0xd69a35,
|
||||
roughness: 0.48,
|
||||
metalness: 0.08,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function leg(root: THREE.Group, name: string): DogLegJoints {
|
||||
return {
|
||||
upper: requireGroup(root, `dog.leg.${name}.upper`),
|
||||
lower: requireGroup(root, `dog.leg.${name}.lower`),
|
||||
paw: requireGroup(root, `dog.leg.${name}.paw`),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDog(root: THREE.Group, ownsMaterials: boolean): DogRig {
|
||||
const frontLeft = leg(root, "front.left");
|
||||
const frontRight = leg(root, "front.right");
|
||||
const rearLeft = leg(root, "rear.left");
|
||||
const rearRight = leg(root, "rear.right");
|
||||
return {
|
||||
root,
|
||||
ownsMaterials,
|
||||
joints: {
|
||||
body: requireGroup(root, "dog.body"),
|
||||
chest: requireGroup(root, "dog.chest"),
|
||||
neck: requireGroup(root, "dog.neck"),
|
||||
head: requireGroup(root, "dog.head"),
|
||||
earLeft: requireGroup(root, "dog.ear.left"),
|
||||
earRight: requireGroup(root, "dog.ear.right"),
|
||||
tail: requireGroup(root, "dog.tail"),
|
||||
legFrontLeft: requireGroup(root, "dog.leg.front.left"),
|
||||
legFrontRight: requireGroup(root, "dog.leg.front.right"),
|
||||
legRearLeft: requireGroup(root, "dog.leg.rear.left"),
|
||||
legRearRight: requireGroup(root, "dog.leg.rear.right"),
|
||||
tailTip: requireGroup(root, "dog.tail.tip"),
|
||||
frontLeft,
|
||||
frontRight,
|
||||
rearLeft,
|
||||
rearRight,
|
||||
legFrontLeft: frontLeft.upper,
|
||||
legFrontRight: frontRight.upper,
|
||||
legRearLeft: rearLeft.upper,
|
||||
legRearRight: rearRight.upper,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function addLeg(
|
||||
body: THREE.Group,
|
||||
m: DogMaterials,
|
||||
fore: "front" | "rear",
|
||||
side: "left" | "right",
|
||||
x: number,
|
||||
z: number,
|
||||
): void {
|
||||
const name = `${fore}.${side}`;
|
||||
const upperLength = fore === "front" ? 0.16 : 0.17;
|
||||
const lowerLength = fore === "front" ? 0.18 : 0.17;
|
||||
const upper = namedGroup(`dog.leg.${name}.upper`, [x, fore === "front" ? -0.055 : -0.035, z]);
|
||||
body.add(upper);
|
||||
upper.add(actorMesh(`dog.leg.${name}.upper-mesh`, new THREE.CapsuleGeometry(0.047, upperLength - 0.08, 3, 7), m.coat, {
|
||||
position: [0, -upperLength * 0.5, 0],
|
||||
scale: fore === "front" ? [0.9, 1, 0.9] : [1.15, 1.04, 1.08],
|
||||
}));
|
||||
|
||||
const lower = namedGroup(`dog.leg.${name}.lower`, [0, -upperLength, 0]);
|
||||
upper.add(lower);
|
||||
lower.add(actorMesh(`dog.leg.${name}.lower-mesh`, new THREE.CapsuleGeometry(0.039, lowerLength - 0.07, 3, 7), m.coat, {
|
||||
position: [0, -lowerLength * 0.5, 0],
|
||||
scale: fore === "front" ? [0.9, 1, 0.88] : [1, 1, 0.9],
|
||||
}));
|
||||
|
||||
const paw = namedGroup(`dog.leg.${name}.paw`, [0, -lowerLength, 0]);
|
||||
lower.add(paw);
|
||||
paw.add(actorMesh(`dog.paw.${name}`, new THREE.SphereGeometry(0.058, 8, 6), m.markings, {
|
||||
position: [0, -0.018, -0.026],
|
||||
scale: [0.88, 0.46, 1.38],
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildDog(options: DogBuildOptions = {}): DogRig {
|
||||
const m = options.materials ?? createDogMaterials(options);
|
||||
const root = namedGroup("dog");
|
||||
root.userData.kind = "actor";
|
||||
root.userData.actorType = "anonymous-dog";
|
||||
root.userData.forwardAxis = "-Z";
|
||||
const body = namedGroup("dog.body", [0, 0.39, 0.04]);
|
||||
root.userData.rigVersion = 2;
|
||||
root.userData.pose = "idle" satisfies DogPoseState;
|
||||
|
||||
const body = namedGroup("dog.body", [0, 0.425, 0.045]);
|
||||
root.add(body);
|
||||
body.add(
|
||||
actorMesh("dog.torso", new THREE.CapsuleGeometry(0.18, 0.34, 5, 10), m.coat, {
|
||||
actorMesh("dog.torso", new THREE.CapsuleGeometry(0.205, 0.43, 5, 11), m.coat, {
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
scale: [0.82, 1, 0.92],
|
||||
scale: [0.82, 1, 0.9],
|
||||
}),
|
||||
actorMesh("dog.chest", new THREE.SphereGeometry(0.185, 12, 9), m.markings, {
|
||||
position: [0, 0.01, -0.17],
|
||||
scale: [0.72, 1, 0.56],
|
||||
actorMesh("dog.loin", new THREE.SphereGeometry(0.19, 11, 8), m.coat, {
|
||||
position: [0, -0.025, 0.245],
|
||||
scale: [0.78, 0.8, 1.05],
|
||||
}),
|
||||
actorMesh("dog.shoulder-mass", new THREE.SphereGeometry(0.205, 11, 8), m.coat, {
|
||||
position: [0, 0.015, -0.205],
|
||||
scale: [0.92, 1.12, 0.78],
|
||||
}),
|
||||
actorMesh("dog.hip-mass", new THREE.SphereGeometry(0.195, 11, 8), m.coat, {
|
||||
position: [0, 0.01, 0.255],
|
||||
scale: [0.9, 1.04, 0.82],
|
||||
}),
|
||||
);
|
||||
|
||||
const head = namedGroup("dog.head", [0, 0.16, -0.32]);
|
||||
body.add(head);
|
||||
head.add(
|
||||
actorMesh("dog.skull", new THREE.SphereGeometry(0.16, 12, 10), m.coat, { scale: [0.82, 0.92, 0.88] }),
|
||||
actorMesh("dog.muzzle", new THREE.SphereGeometry(0.105, 12, 8), m.markings, {
|
||||
position: [0, -0.045, -0.13],
|
||||
scale: [0.8, 0.62, 1],
|
||||
const chest = namedGroup("dog.chest", [0, 0.01, -0.225]);
|
||||
body.add(chest);
|
||||
chest.add(actorMesh("dog.chest-marking", new THREE.SphereGeometry(0.175, 11, 8), m.markings, {
|
||||
position: [0, -0.035, -0.045],
|
||||
scale: [0.62, 1.04, 0.52],
|
||||
}));
|
||||
|
||||
const neck = namedGroup("dog.neck", [0, 0.105, -0.31]);
|
||||
body.add(neck);
|
||||
neck.rotation.x = -0.24;
|
||||
neck.add(
|
||||
actorMesh("dog.neck-mesh", new THREE.CapsuleGeometry(0.125, 0.13, 4, 9), m.coat, {
|
||||
position: [0, 0.085, -0.018],
|
||||
scale: [0.92, 1, 0.92],
|
||||
}),
|
||||
actorMesh("dog.nose", new THREE.SphereGeometry(0.045, 10, 7), m.nose, {
|
||||
position: [0, -0.035, -0.224],
|
||||
scale: [1.15, 0.72, 0.7],
|
||||
}),
|
||||
actorMesh("dog.collar", new THREE.TorusGeometry(0.118, 0.016, 6, 18), m.collar, {
|
||||
position: [0, -0.11, 0.1],
|
||||
actorMesh("dog.collar", new THREE.TorusGeometry(0.128, 0.014, 6, 18), m.collar, {
|
||||
position: [0, 0.035, -0.005],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
scale: [1, 0.82, 1],
|
||||
scale: [1, 0.9, 1],
|
||||
}),
|
||||
actorMesh("dog.tag", new THREE.OctahedronGeometry(0.032, 0), m.collar, {
|
||||
position: [0, -0.005, -0.135],
|
||||
rotation: [0.2, 0, Math.PI / 4],
|
||||
}),
|
||||
);
|
||||
|
||||
const head = namedGroup("dog.head", [0, 0.17, -0.045]);
|
||||
neck.add(head);
|
||||
head.add(
|
||||
actorMesh("dog.skull", new THREE.SphereGeometry(0.155, 12, 9), m.coat, {
|
||||
scale: [0.8, 0.94, 0.92],
|
||||
}),
|
||||
actorMesh("dog.brow", new THREE.SphereGeometry(0.12, 10, 7), m.coat, {
|
||||
position: [0, 0.035, -0.1],
|
||||
scale: [0.9, 0.55, 0.72],
|
||||
}),
|
||||
actorMesh("dog.muzzle", new THREE.SphereGeometry(0.105, 11, 8), m.markings, {
|
||||
position: [0, -0.05, -0.145],
|
||||
scale: [0.8, 0.62, 1.15],
|
||||
}),
|
||||
actorMesh("dog.nose", new THREE.SphereGeometry(0.044, 9, 6), m.nose, {
|
||||
position: [0, -0.035, -0.247],
|
||||
scale: [1.18, 0.72, 0.72],
|
||||
}),
|
||||
);
|
||||
// Slightly proud of the skull so the chase camera gets a readable gaze
|
||||
// instead of a blank mask. The tiny warm catchlights remain visible against
|
||||
// every supported coat without introducing another material or texture.
|
||||
for (const side of [-1, 1] as const) {
|
||||
const word = side < 0 ? "left" : "right";
|
||||
head.add(
|
||||
actorMesh(`dog.eye.${word}`, new THREE.SphereGeometry(0.023, 8, 6), m.nose, {
|
||||
position: [side * 0.075, 0.025, -0.128],
|
||||
scale: [0.85, 1, 0.58],
|
||||
actorMesh(`dog.eye.${word}`, new THREE.SphereGeometry(0.022, 8, 6), m.nose, {
|
||||
position: [side * 0.073, 0.025, -0.128],
|
||||
scale: [0.82, 1, 0.62],
|
||||
receiveShadow: false,
|
||||
}),
|
||||
actorMesh(`dog.eye-catchlight.${word}`, new THREE.SphereGeometry(0.006, 6, 4), m.markings, {
|
||||
position: [side * 0.079, 0.031, -0.145],
|
||||
actorMesh(`dog.eye-catchlight.${word}`, new THREE.SphereGeometry(0.0055, 6, 4), m.markings, {
|
||||
position: [side * 0.076, 0.031, -0.145],
|
||||
receiveShadow: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const side of [-1, 1] as const) {
|
||||
const word = side < 0 ? "left" : "right";
|
||||
const ear = namedGroup(`dog.ear.${word}`, [side * 0.1, 0.105, -0.015]);
|
||||
const ear = namedGroup(`dog.ear.${word}`, [side * 0.105, 0.095, -0.005]);
|
||||
head.add(ear);
|
||||
ear.add(
|
||||
actorMesh(`dog.ear-flap.${word}`, new THREE.ConeGeometry(0.075, 0.19, 5), m.coat, {
|
||||
position: [side * 0.015, -0.065, 0.02],
|
||||
rotation: [0.18, 0, side * 0.28],
|
||||
}),
|
||||
);
|
||||
ear.add(actorMesh(`dog.ear-flap.${word}`, new THREE.ConeGeometry(0.073, 0.19, 5), m.coat, {
|
||||
position: [side * 0.012, 0.06, 0.015],
|
||||
rotation: [0.08, 0, side * -0.18],
|
||||
scale: [0.88, 1, 0.72],
|
||||
}));
|
||||
}
|
||||
|
||||
for (const z of [-0.2, 0.22] as const) {
|
||||
for (const side of [-1, 1] as const) {
|
||||
const fore = z < 0 ? "front" : "rear";
|
||||
const word = side < 0 ? "left" : "right";
|
||||
const leg = namedGroup(`dog.leg.${fore}.${word}`, [side * 0.125, -0.1, z]);
|
||||
body.add(leg);
|
||||
leg.add(
|
||||
actorMesh(`dog.leg-mesh.${fore}.${word}`, new THREE.CapsuleGeometry(0.046, 0.19, 3, 7), m.coat, {
|
||||
position: [0, -0.135, 0],
|
||||
}),
|
||||
actorMesh(`dog.paw.${fore}.${word}`, new THREE.SphereGeometry(0.06, 8, 6), m.markings, {
|
||||
position: [0, -0.29, -0.022],
|
||||
scale: [0.84, 0.48, 1.18],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
addLeg(body, m, "front", "left", -0.14, -0.22);
|
||||
addLeg(body, m, "front", "right", 0.14, -0.22);
|
||||
addLeg(body, m, "rear", "left", -0.145, 0.245);
|
||||
addLeg(body, m, "rear", "right", 0.145, 0.245);
|
||||
|
||||
const tail = namedGroup("dog.tail", [0, 0.03, 0.31]);
|
||||
const tail = namedGroup("dog.tail", [0, 0.065, 0.37]);
|
||||
body.add(tail);
|
||||
tail.rotation.x = 0.68;
|
||||
tail.add(
|
||||
actorMesh("dog.tail-mesh", new THREE.CapsuleGeometry(0.04, 0.25, 4, 8), m.coat, {
|
||||
position: [0, 0.15, 0],
|
||||
}),
|
||||
);
|
||||
return resolveDog(root, !options.materials);
|
||||
tail.add(actorMesh("dog.tail.base-mesh", new THREE.CapsuleGeometry(0.046, 0.17, 4, 8), m.coat, {
|
||||
position: [0, 0.115, 0],
|
||||
scale: [1.05, 1, 1.05],
|
||||
}));
|
||||
const tailTip = namedGroup("dog.tail.tip", [0, 0.21, 0]);
|
||||
tail.add(tailTip);
|
||||
tailTip.add(actorMesh("dog.tail.tip-mesh", new THREE.CapsuleGeometry(0.034, 0.15, 4, 8), m.coat, {
|
||||
position: [0, 0.095, 0],
|
||||
scale: [0.9, 1, 0.9],
|
||||
}));
|
||||
|
||||
const rig = resolveDog(root, !options.materials);
|
||||
poseDog(rig, { state: "idle", phase: 0, amount: 1 });
|
||||
return rig;
|
||||
}
|
||||
|
||||
export function cloneDog(source: DogRig): DogRig {
|
||||
return resolveDog(source.root.clone(true), false);
|
||||
}
|
||||
|
||||
export function poseDogWalk(rig: DogRig, phase: number, amount = 0.55): void {
|
||||
const swing = Math.sin(phase) * THREE.MathUtils.clamp(amount, 0, 0.8);
|
||||
rig.joints.legFrontLeft.rotation.x = swing;
|
||||
rig.joints.legRearRight.rotation.x = swing;
|
||||
rig.joints.legFrontRight.rotation.x = -swing;
|
||||
rig.joints.legRearLeft.rotation.x = -swing;
|
||||
rig.joints.body.position.y = 0.39 + Math.abs(Math.cos(phase)) * Math.abs(swing) * 0.018;
|
||||
/** A consistent state choice for local and network presentation adapters. */
|
||||
export function dogPoseStateForSpeed(speedMps: number): DogPoseState {
|
||||
const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0;
|
||||
if (speed < 0.08) return "idle";
|
||||
if (speed < 1.15) return "walk";
|
||||
if (speed < 2.65) return "trot";
|
||||
return "run";
|
||||
}
|
||||
|
||||
function finite(value: number | undefined, fallback = 0): number {
|
||||
return value === undefined || !Number.isFinite(value) ? fallback : value;
|
||||
}
|
||||
|
||||
function setLeg(leg: DogLegJoints, upper: number, bend: number, toeLift: number): void {
|
||||
leg.upper.rotation.set(upper, 0, 0);
|
||||
leg.lower.rotation.set(bend, 0, 0);
|
||||
leg.paw.rotation.set(-(upper + bend) * 0.64 - toeLift, 0, 0);
|
||||
}
|
||||
|
||||
/** Apply one complete pose. Repeated identical inputs produce identical transforms. */
|
||||
export function poseDog(rig: DogRig, pose: DogPose): void {
|
||||
const state: DogPoseState = ["idle", "attention", "walk", "trot", "run"].includes(pose.state)
|
||||
? pose.state
|
||||
: "idle";
|
||||
const phase = finite(pose.phase);
|
||||
const amount = THREE.MathUtils.clamp(finite(pose.amount, 1), 0, 1);
|
||||
const turn = THREE.MathUtils.clamp(finite(pose.turn), -1, 1);
|
||||
const acceleration = THREE.MathUtils.clamp(finite(pose.acceleration), -1, 1);
|
||||
const lookYaw = THREE.MathUtils.clamp(finite(pose.attentionYaw), -0.9, 0.9);
|
||||
const lookPitch = THREE.MathUtils.clamp(finite(pose.attentionPitch), -0.45, 0.4);
|
||||
const locomoting = state === "walk" || state === "trot" || state === "run";
|
||||
const breath = Math.sin(phase * 0.48);
|
||||
const cycle = Math.sin(phase);
|
||||
const opposite = Math.sin(phase + Math.PI);
|
||||
const liftLeft = locomoting ? Math.max(0, Math.sin(phase + Math.PI * 0.18)) : 0;
|
||||
const liftRight = locomoting ? Math.max(0, Math.sin(phase + Math.PI * 1.18)) : 0;
|
||||
|
||||
let stride = 0;
|
||||
let bend = 0;
|
||||
let bob = breath * 0.003;
|
||||
if (state === "walk") {
|
||||
stride = 0.38 * amount;
|
||||
bend = 0.3 * amount;
|
||||
bob += Math.abs(Math.cos(phase)) * 0.006 * amount;
|
||||
} else if (state === "trot") {
|
||||
stride = 0.58 * amount;
|
||||
bend = 0.46 * amount;
|
||||
bob += Math.abs(Math.cos(phase)) * 0.015 * amount;
|
||||
} else if (state === "run") {
|
||||
stride = 0.76 * amount;
|
||||
bend = 0.66 * amount;
|
||||
bob += Math.sin(phase * 2) * 0.018 * amount;
|
||||
}
|
||||
|
||||
if (state === "run") {
|
||||
const frontLeft = Math.sin(phase + 0.18);
|
||||
const frontRight = Math.sin(phase - 0.18);
|
||||
const rearLeft = Math.sin(phase + Math.PI + 0.2);
|
||||
const rearRight = Math.sin(phase + Math.PI - 0.2);
|
||||
setLeg(rig.joints.frontLeft, frontLeft * stride, -Math.max(0, -frontLeft) * bend, liftLeft * 0.16);
|
||||
setLeg(rig.joints.frontRight, frontRight * stride, -Math.max(0, -frontRight) * bend, liftRight * 0.16);
|
||||
setLeg(rig.joints.rearLeft, rearLeft * stride, Math.max(0, rearLeft) * bend, liftRight * 0.13);
|
||||
setLeg(rig.joints.rearRight, rearRight * stride, Math.max(0, rearRight) * bend, liftLeft * 0.13);
|
||||
} else {
|
||||
setLeg(rig.joints.frontLeft, cycle * stride, -liftLeft * bend, liftLeft * 0.1);
|
||||
setLeg(rig.joints.frontRight, opposite * stride, -liftRight * bend, liftRight * 0.1);
|
||||
setLeg(rig.joints.rearLeft, opposite * stride, liftRight * bend * 0.84, liftRight * 0.08);
|
||||
setLeg(rig.joints.rearRight, cycle * stride, liftLeft * bend * 0.84, liftLeft * 0.08);
|
||||
}
|
||||
|
||||
const locomotionAmount = locomoting ? amount : 0;
|
||||
rig.joints.body.position.set(0, 0.425 + bob, 0.045);
|
||||
rig.joints.body.rotation.set(-acceleration * 0.09 + (state === "run" ? -0.035 : 0), 0, -turn * 0.12 * locomotionAmount);
|
||||
rig.joints.chest.scale.set(1, 1 + breath * (locomoting ? 0.012 : 0.026), 1);
|
||||
rig.joints.neck.rotation.set(-0.24 + acceleration * 0.035, turn * 0.035, turn * 0.04);
|
||||
rig.joints.head.rotation.set(
|
||||
-0.055 + lookPitch - Math.abs(turn) * 0.035 + (state === "attention" ? -0.08 : 0),
|
||||
lookYaw - turn * 0.08,
|
||||
turn * 0.045,
|
||||
);
|
||||
|
||||
const attention = state === "attention" ? 1 : 0;
|
||||
rig.joints.earLeft.rotation.set(0.02 + attention * 0.08, 0, 0.07 + attention * 0.08);
|
||||
rig.joints.earRight.rotation.set(-0.01, 0, -0.07 + attention * 0.035);
|
||||
const wag = Math.sin(phase * (locomoting ? 0.85 : 1.3)) * (locomoting ? 0.16 : 0.38) * amount;
|
||||
rig.joints.tail.rotation.set(0.72 - locomotionAmount * 0.12, 0, wag - turn * 0.12);
|
||||
rig.joints.tailTip.rotation.set(-0.16, 0, wag * 0.7);
|
||||
|
||||
rig.root.userData.pose = state;
|
||||
rig.root.userData.posePhase = phase;
|
||||
rig.root.userData.poseAmount = amount;
|
||||
rig.root.userData.turnLean = turn;
|
||||
rig.root.userData.acceleration = acceleration;
|
||||
}
|
||||
|
||||
/** Legacy gait API retained for existing integrations and downstream users. */
|
||||
export function poseDogWalk(rig: DogRig, phase: number, amount = 0.55): void {
|
||||
const intensity = THREE.MathUtils.clamp(finite(amount), 0, 1);
|
||||
poseDog(rig, {
|
||||
state: intensity < 0.04 ? "idle" : intensity < 0.52 ? "walk" : "trot",
|
||||
phase,
|
||||
amount: intensity,
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy additive attention API: deliberately leaves the active gait untouched. */
|
||||
export function poseDogAttention(rig: DogRig, lookYaw: number, tailPhase: number): void {
|
||||
rig.joints.head.rotation.y = THREE.MathUtils.clamp(lookYaw, -0.9, 0.9);
|
||||
const yaw = THREE.MathUtils.clamp(finite(lookYaw), -0.9, 0.9);
|
||||
const phase = finite(tailPhase);
|
||||
rig.joints.head.rotation.y = yaw;
|
||||
rig.joints.head.rotation.x = -0.08;
|
||||
rig.joints.earLeft.rotation.z = 0.08;
|
||||
rig.joints.earRight.rotation.z = -0.08;
|
||||
rig.joints.tail.rotation.z = Math.sin(tailPhase) * 0.72;
|
||||
rig.joints.earLeft.rotation.z = 0.13;
|
||||
rig.joints.earRight.rotation.z = -0.105;
|
||||
rig.joints.tail.rotation.z = Math.sin(phase) * 0.34;
|
||||
rig.joints.tailTip.rotation.z = Math.sin(phase + 0.35) * 0.22;
|
||||
}
|
||||
|
||||
export function disposeDog(rig: DogRig, options: { disposeMaterials?: boolean } = {}): void {
|
||||
|
||||
@@ -18,11 +18,16 @@ export {
|
||||
cloneDog,
|
||||
createDogMaterials,
|
||||
disposeDog,
|
||||
dogPoseStateForSpeed,
|
||||
poseDog,
|
||||
poseDogAttention,
|
||||
poseDogWalk,
|
||||
type DogBuildOptions,
|
||||
type DogJoints,
|
||||
type DogLegJoints,
|
||||
type DogMaterials,
|
||||
type DogPose,
|
||||
type DogPoseState,
|
||||
type DogRig,
|
||||
} from "./dog.ts";
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
buildDog,
|
||||
dogPoseStateForSpeed,
|
||||
disposeDog,
|
||||
poseDogAttention,
|
||||
poseDogWalk,
|
||||
poseDog,
|
||||
type DogRig,
|
||||
} from "../assets/actors/dog.ts";
|
||||
import {
|
||||
@@ -118,6 +118,9 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
|
||||
let enabled = options.active ?? false;
|
||||
let desired: WalkerAction = { x: 0, z: 0 };
|
||||
let gait = 0;
|
||||
let presentationSeconds = 0;
|
||||
let previousSpeed = 0;
|
||||
const previousFacing = new THREE.Vector2(0, -1);
|
||||
let faceTexture: THREE.Texture | null = null;
|
||||
let disposed = false;
|
||||
const root = new THREE.Group();
|
||||
@@ -135,16 +138,40 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
|
||||
view.position.copy(root.position);
|
||||
root.rotation.y = Math.atan2(-state.facing.x, -state.facing.z);
|
||||
|
||||
const speed = elapsedSeconds > 0 ? travelled / elapsedSeconds : 0;
|
||||
const speed = elapsedSeconds > 0 ? Math.max(0, travelled / elapsedSeconds) : 0;
|
||||
const targetGait = enabled && speed > 1e-5 ? Math.min(1, speed / (options.speed ?? 1.6)) : 0;
|
||||
gait += (targetGait - gait) * Math.min(1, elapsedSeconds / GAIT_EASE_SECONDS);
|
||||
const phase = (state.distance / STRIDE_METRES) * Math.PI * 2;
|
||||
presentationSeconds += elapsedSeconds;
|
||||
const phase = speed > 1e-5
|
||||
? (state.distance / STRIDE_METRES) * Math.PI * 2
|
||||
: presentationSeconds * 2.2;
|
||||
const facingDot = THREE.MathUtils.clamp(
|
||||
previousFacing.x * state.facing.x + previousFacing.y * state.facing.z,
|
||||
-1,
|
||||
1,
|
||||
);
|
||||
const facingCross = previousFacing.x * state.facing.z - previousFacing.y * state.facing.x;
|
||||
const turn = elapsedSeconds > 0
|
||||
? THREE.MathUtils.clamp(Math.atan2(facingCross, facingDot) / elapsedSeconds / 4, -1, 1)
|
||||
: 0;
|
||||
const acceleration = elapsedSeconds > 0
|
||||
? THREE.MathUtils.clamp((speed - previousSpeed) / elapsedSeconds / 5, -1, 1)
|
||||
: 0;
|
||||
if (actor.kind === "humanoid") {
|
||||
poseHumanoid(actor.rig, { walkPhase: phase, stride: gait * 0.62 });
|
||||
} else {
|
||||
poseDogWalk(actor.rig, phase, gait * 0.62);
|
||||
poseDogAttention(actor.rig, 0, state.distance * 5);
|
||||
const dogState = dogPoseStateForSpeed(speed * Math.max(gait, 0.2));
|
||||
poseDog(actor.rig, {
|
||||
state: dogState,
|
||||
phase,
|
||||
amount: dogState === "idle" ? 1 : THREE.MathUtils.clamp(0.38 + gait * 0.62, 0, 1),
|
||||
turn,
|
||||
acceleration,
|
||||
attentionYaw: -turn * 0.08,
|
||||
});
|
||||
}
|
||||
previousSpeed = speed;
|
||||
previousFacing.set(state.facing.x, state.facing.z);
|
||||
}
|
||||
|
||||
function snapshot(): OfficeWalkerState {
|
||||
@@ -202,6 +229,9 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
|
||||
const state = controller.reset(spawn);
|
||||
desired = { x: 0, z: 0 };
|
||||
gait = 0;
|
||||
presentationSeconds = 0;
|
||||
previousSpeed = 0;
|
||||
previousFacing.set(state.facing.x, state.facing.z);
|
||||
sync(state);
|
||||
return snapshot();
|
||||
},
|
||||
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
disposeCrow,
|
||||
disposeDog,
|
||||
disposeHumanoid,
|
||||
dogPoseStateForSpeed,
|
||||
poseCrowFlight,
|
||||
poseDogAttention,
|
||||
poseDogWalk,
|
||||
poseDog,
|
||||
poseHumanoid,
|
||||
type CrowRig,
|
||||
type DogRig,
|
||||
@@ -208,12 +208,15 @@ function animateActor(
|
||||
stride: THREE.MathUtils.clamp(speed / 3.2, 0, 0.68),
|
||||
});
|
||||
} else if (visual.kind === "dog") {
|
||||
poseDogWalk(
|
||||
visual.rig,
|
||||
seconds * Math.max(1.4, speed * 7.2),
|
||||
THREE.MathUtils.clamp(speed / 4, 0, 0.68),
|
||||
);
|
||||
poseDogAttention(visual.rig, 0, seconds * 5);
|
||||
const state = dogPoseStateForSpeed(speed);
|
||||
const turn = THREE.MathUtils.clamp(sample.velocity.yawDegPerSec / 160, -1, 1);
|
||||
poseDog(visual.rig, {
|
||||
state,
|
||||
phase: seconds * Math.max(1.4, speed * 7.2),
|
||||
amount: state === "idle" ? 1 : THREE.MathUtils.clamp(0.38 + speed / 4.2, 0.38, 1),
|
||||
turn,
|
||||
attentionYaw: -turn * 0.08,
|
||||
});
|
||||
} else {
|
||||
poseCrowFlight(
|
||||
visual.rig,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
DOG_METRICS,
|
||||
buildDog,
|
||||
cloneDog,
|
||||
disposeDog,
|
||||
dogPoseStateForSpeed,
|
||||
poseDog,
|
||||
poseDogAttention,
|
||||
type DogRig,
|
||||
} from "../assets/actors/index.ts";
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const result: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) result.push(object);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function transformSnapshot(rig: DogRig): readonly number[][] {
|
||||
const result: number[][] = [];
|
||||
rig.root.traverse((object) => {
|
||||
result.push([
|
||||
...object.position.toArray(),
|
||||
object.rotation.x,
|
||||
object.rotation.y,
|
||||
object.rotation.z,
|
||||
...object.scale.toArray(),
|
||||
]);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("procedural dog v2", () => {
|
||||
it("builds a floor-centred articulated silhouette within the peer/mobile budget", () => {
|
||||
const rig = buildDog();
|
||||
assert.equal(rig.root.userData.rigVersion, 2);
|
||||
for (const leg of [rig.joints.frontLeft, rig.joints.frontRight, rig.joints.rearLeft, rig.joints.rearRight]) {
|
||||
assert.equal(leg.lower.parent, leg.upper);
|
||||
assert.equal(leg.paw.parent, leg.lower);
|
||||
}
|
||||
for (const name of [
|
||||
"dog.shoulder-mass",
|
||||
"dog.hip-mass",
|
||||
"dog.neck",
|
||||
"dog.chest",
|
||||
"dog.muzzle",
|
||||
"dog.collar",
|
||||
"dog.tag",
|
||||
"dog.tail.tip",
|
||||
]) assert.ok(rig.root.getObjectByName(name), `missing ${name}`);
|
||||
|
||||
const box = new THREE.Box3().setFromObject(rig.root);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
assert.ok(box.min.y > -0.02, `floor ${box.min.y}`);
|
||||
assert.ok(Math.abs(size.y - DOG_METRICS.height) < 0.08, `height ${size.y}`);
|
||||
assert.ok(Math.abs(size.z - DOG_METRICS.length) < 0.08, `length ${size.z}`);
|
||||
const allMeshes = meshes(rig.root);
|
||||
const triangles = allMeshes.reduce((sum, mesh) => {
|
||||
const geometry = mesh.geometry;
|
||||
return sum + (geometry.index?.count ?? geometry.getAttribute("position").count) / 3;
|
||||
}, 0);
|
||||
assert.ok(allMeshes.length <= 34, `${allMeshes.length} meshes`);
|
||||
assert.ok(triangles <= 3_600, `${triangles} triangles`);
|
||||
disposeDog(rig);
|
||||
});
|
||||
|
||||
it("separates idle, attention, walk, trot, run, turn and start/stop poses", () => {
|
||||
const rig = buildDog();
|
||||
poseDog(rig, { state: "idle", phase: Math.PI / 2, amount: 1 });
|
||||
const idleChest = rig.joints.chest.scale.y;
|
||||
assert.equal(rig.root.userData.pose, "idle");
|
||||
assert.equal(rig.joints.frontLeft.upper.rotation.x, 0);
|
||||
|
||||
poseDog(rig, { state: "attention", phase: Math.PI / 2, amount: 1, attentionYaw: 0.35 });
|
||||
assert.equal(rig.root.userData.pose, "attention");
|
||||
assert.equal(rig.joints.head.rotation.y, 0.35);
|
||||
assert.notEqual(rig.joints.earLeft.rotation.z, -rig.joints.earRight.rotation.z);
|
||||
|
||||
poseDog(rig, { state: "walk", phase: Math.PI / 2, amount: 1 });
|
||||
const walkStride = Math.abs(rig.joints.frontLeft.upper.rotation.x);
|
||||
assert.ok(walkStride > 0.3);
|
||||
assert.equal(rig.joints.frontLeft.upper.rotation.x, rig.joints.rearRight.upper.rotation.x);
|
||||
assert.equal(rig.joints.frontRight.upper.rotation.x, rig.joints.rearLeft.upper.rotation.x);
|
||||
assert.notEqual(rig.joints.chest.scale.y, idleChest);
|
||||
|
||||
poseDog(rig, { state: "trot", phase: Math.PI / 2, amount: 1 });
|
||||
const trotStride = Math.abs(rig.joints.frontLeft.upper.rotation.x);
|
||||
assert.ok(trotStride > walkStride);
|
||||
|
||||
poseDog(rig, { state: "run", phase: 0.8, amount: 1 });
|
||||
assert.equal(rig.root.userData.pose, "run");
|
||||
assert.notEqual(rig.joints.frontLeft.upper.rotation.x, rig.joints.frontRight.upper.rotation.x,
|
||||
"a gathered run offsets the front pair rather than using a rigid scissor");
|
||||
|
||||
poseDog(rig, { state: "trot", phase: 0.7, amount: 1, turn: 0.8, acceleration: 0.7 });
|
||||
assert.ok(rig.joints.body.rotation.z < -0.09, "turn banks through the chest");
|
||||
assert.ok(rig.joints.body.rotation.x < -0.05, "starting lowers the chest into motion");
|
||||
poseDog(rig, { state: "idle", phase: 0.7, amount: 1, acceleration: -0.8 });
|
||||
assert.ok(rig.joints.body.rotation.x > 0.06, "stopping braces the chest");
|
||||
disposeDog(rig);
|
||||
});
|
||||
|
||||
it("selects gait thresholds and repeats an exact pose without accumulating transforms", () => {
|
||||
assert.equal(dogPoseStateForSpeed(Number.NaN), "idle");
|
||||
assert.equal(dogPoseStateForSpeed(0.07), "idle");
|
||||
assert.equal(dogPoseStateForSpeed(0.5), "walk");
|
||||
assert.equal(dogPoseStateForSpeed(1.5), "trot");
|
||||
assert.equal(dogPoseStateForSpeed(3), "run");
|
||||
|
||||
const rig = buildDog();
|
||||
const pose = {
|
||||
state: "trot" as const,
|
||||
phase: 2.17,
|
||||
amount: 0.83,
|
||||
turn: -0.42,
|
||||
acceleration: -0.3,
|
||||
attentionYaw: 0.11,
|
||||
attentionPitch: -0.08,
|
||||
};
|
||||
poseDog(rig, pose);
|
||||
const first = transformSnapshot(rig);
|
||||
poseDog(rig, pose);
|
||||
assert.deepEqual(transformSnapshot(rig), first);
|
||||
poseDogAttention(rig, 0.2, 1.7);
|
||||
const attention = transformSnapshot(rig);
|
||||
poseDogAttention(rig, 0.2, 1.7);
|
||||
assert.deepEqual(transformSnapshot(rig), attention, "legacy attention remains idempotent");
|
||||
disposeDog(rig);
|
||||
});
|
||||
|
||||
it("clones shared resources into independent joints and respects material ownership", () => {
|
||||
const source = buildDog();
|
||||
const clone = cloneDog(source);
|
||||
const sourceMeshes = meshes(source.root);
|
||||
const cloneMeshes = meshes(clone.root);
|
||||
assert.equal(cloneMeshes.length, sourceMeshes.length);
|
||||
for (let index = 0; index < sourceMeshes.length; index += 1) {
|
||||
assert.equal(cloneMeshes[index]!.geometry, sourceMeshes[index]!.geometry);
|
||||
assert.equal(cloneMeshes[index]!.material, sourceMeshes[index]!.material);
|
||||
}
|
||||
poseDog(clone, { state: "run", phase: 1.3, amount: 1 });
|
||||
assert.notEqual(clone.joints.frontLeft.upper.rotation.x, source.joints.frontLeft.upper.rotation.x);
|
||||
assert.equal(clone.ownsMaterials, false);
|
||||
|
||||
const shared = sourceMeshes[0]!.material as THREE.Material;
|
||||
let disposals = 0;
|
||||
shared.addEventListener("dispose", () => disposals++);
|
||||
const external = { coat: shared, markings: shared, nose: shared, collar: shared };
|
||||
const externalRig = buildDog({ materials: external });
|
||||
disposeDog(externalRig);
|
||||
assert.equal(disposals, 0);
|
||||
disposeDog(source);
|
||||
assert.equal(disposals, 1);
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,38 @@ describe("office walker actor adapter", () => {
|
||||
actor.dispose();
|
||||
});
|
||||
|
||||
it("presents deterministic dog start, office turn, trot and stop semantics", () => {
|
||||
const actor = createOfficeWalker(makePlan(), {
|
||||
levelId: "ground",
|
||||
position: { x: 4, z: 4 },
|
||||
actor: { kind: "anonymous-dog" },
|
||||
speed: 1.6,
|
||||
fixedStep: 0.1,
|
||||
active: true,
|
||||
});
|
||||
const dog = actor.root.getObjectByName("dog") as THREE.Group;
|
||||
assert.equal(dog.userData.pose, "idle");
|
||||
|
||||
actor.setAction({ x: 0, z: -1 });
|
||||
actor.tick(0.1);
|
||||
assert.equal(dog.userData.pose, "walk", "eased first step does not pop directly into a trot");
|
||||
assert.equal(dog.userData.acceleration, 1);
|
||||
actor.tick(0.1);
|
||||
assert.equal(dog.userData.pose, "trot");
|
||||
|
||||
actor.setAction({ x: 1, z: 0 });
|
||||
actor.tick(0.1);
|
||||
assert.ok(Math.abs(Number(dog.userData.turnLean)) > 0.9);
|
||||
const body = dog.getObjectByName("dog.body") as THREE.Group;
|
||||
assert.notEqual(body.rotation.z, 0);
|
||||
|
||||
actor.setAction({ x: 0, z: 0 });
|
||||
actor.tick(0.1);
|
||||
assert.equal(dog.userData.pose, "idle");
|
||||
assert.equal(dog.userData.acceleration, -1);
|
||||
actor.dispose();
|
||||
});
|
||||
|
||||
it("uses resolved door gaps while retaining wall collision and reset state", () => {
|
||||
const plan = makePlan([{
|
||||
id: "divider",
|
||||
|
||||
@@ -95,6 +95,31 @@ describe("playable city scene actor", () => {
|
||||
actor.dispose();
|
||||
});
|
||||
|
||||
it("maps the unchanged dog controller snapshot to trot, run, turn and stop presentation", () => {
|
||||
const actor = createSceneActor(options({ kind: "dog", active: true }));
|
||||
const dog = actor.root.getObjectByName("dog") as THREE.Group;
|
||||
assert.equal(dog.userData.pose, "idle");
|
||||
|
||||
actor.setActions({ forward: 1, turn: 0.6 });
|
||||
actor.tick(0.1);
|
||||
assert.equal(actor.state().speedMps, 2.125);
|
||||
assert.equal(dog.userData.pose, "trot");
|
||||
assert.equal(dog.userData.turnLean, 0.6);
|
||||
assert.equal(dog.userData.acceleration, 1);
|
||||
|
||||
actor.setActions({ forward: 1, sprint: true });
|
||||
actor.tick(0.1);
|
||||
assert.equal(actor.state().speedMps, 5.625);
|
||||
assert.equal(dog.userData.pose, "run");
|
||||
|
||||
actor.setActions({ forward: 0, sprint: false });
|
||||
actor.tick(0.1);
|
||||
assert.equal(actor.state().speedMps, 0);
|
||||
assert.equal(dog.userData.pose, "idle");
|
||||
assert.equal(dog.userData.acceleration, -1);
|
||||
actor.dispose();
|
||||
});
|
||||
|
||||
it("keeps its stable root and identity while replacing procedural rigs", () => {
|
||||
const actor = createSceneActor(options({ active: true }));
|
||||
const root = actor.root;
|
||||
|
||||
@@ -123,6 +123,21 @@ describe("remote scene peers", () => {
|
||||
peers.dispose();
|
||||
});
|
||||
|
||||
it("derives deterministic dog gait and turn lean from the existing peer snapshot", () => {
|
||||
const peers = fixture();
|
||||
const snapshot = actor("dog", "dog", 1, 1_000, 0);
|
||||
snapshot.velocity.xMps = 3.2;
|
||||
snapshot.velocity.yawDegPerSec = -80;
|
||||
assert.equal(peers.upsert(snapshot), true);
|
||||
assert.equal(peers.tick(1_000), 1);
|
||||
const dog = peers.root.children[0]?.getObjectByName("dog") as THREE.Group;
|
||||
assert.equal(dog.userData.pose, "run");
|
||||
assert.equal(dog.userData.turnLean, -0.5);
|
||||
const body = dog.getObjectByName("dog.body") as THREE.Group;
|
||||
assert.ok(body.rotation.z > 0);
|
||||
peers.dispose();
|
||||
});
|
||||
|
||||
it("projects geographic vehicles onto caller terrain and renders a generic black EV", () => {
|
||||
const peers = fixture();
|
||||
const snapshot = vehicle(1, 1_000, 34);
|
||||
|
||||
Reference in New Issue
Block a user