feat: add subtle full-body sheep walking motion
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
# Sheep walk implementation plan
|
||||||
|
|
||||||
|
**Goal:** Apply the approved comparison-preview gait with strength 0.4 (the supplied slider position), then commit.
|
||||||
|
**Architecture:** Extend the existing procedural pose in `src/scene/sheep.ts`; keep tuning in `src/constants/character.ts`. Preserve gear grips and independent cloned rigs. No model or movement API changes.
|
||||||
|
**Tech stack:** TypeScript, Three.js, existing Node tests.
|
||||||
|
|
||||||
|
- [x] Update existing character checks for gradual start/stop, torso/head motion, and authored rest restoration.
|
||||||
|
- [x] Add preview gait constants, damp walking weight, restore animated transforms per frame, and carry the coat around the torso pivot.
|
||||||
|
- [x] Run character/gear tests and production build; inspect visual output and review the diff.
|
||||||
|
- [x] Commit only the related production changes, tests, and plan.
|
||||||
|
|
||||||
|
Validation: 142 tests passed, 1 skipped; production build passed. Browser rendering checked with and without rain gear and flashlight; no page errors. Review found the shared original torso position could influence cloned coats; a failing independence assertion reproduced it, and using the authored torso position fixed it.
|
||||||
@@ -18,6 +18,23 @@ export const CHARACTER = {
|
|||||||
cameraLift: 1.15,
|
cameraLift: 1.15,
|
||||||
cameraClearance: 0.35,
|
cameraClearance: 0.35,
|
||||||
turnSpeed: 4,
|
turnSpeed: 4,
|
||||||
|
walk: {
|
||||||
|
strength: 0.4,
|
||||||
|
rate: 9,
|
||||||
|
stride: 0.36,
|
||||||
|
transitionSeconds: 0.25,
|
||||||
|
sway: 0.026,
|
||||||
|
rise: 0.018,
|
||||||
|
roll: 0.035,
|
||||||
|
twist: 0.055,
|
||||||
|
headLag: 0.45,
|
||||||
|
headFollow: 0.65,
|
||||||
|
headRoll: 0.4,
|
||||||
|
headNod: 0.018,
|
||||||
|
armLag: 0.18,
|
||||||
|
armSwing: 0.65,
|
||||||
|
footLift: 0.035,
|
||||||
|
},
|
||||||
idle: {
|
idle: {
|
||||||
periodSeconds: 3.8,
|
periodSeconds: 3.8,
|
||||||
transitionSeconds: 0.3,
|
transitionSeconds: 0.3,
|
||||||
|
|||||||
+52
-9
@@ -107,7 +107,9 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
const body = model.getObjectByName('Body');
|
const body = model.getObjectByName('Body');
|
||||||
const breathingParts = [body, gear?.raincoat, joints.Head, joints.ArmLeft, joints.ArmRight]
|
const breathingParts = [body, gear?.raincoat, joints.Head, joints.ArmLeft, joints.ArmRight]
|
||||||
.filter((part): part is Object3D => Boolean(part))
|
.filter((part): part is Object3D => Boolean(part))
|
||||||
.map(part => ({ part, position: part.position.clone(), scale: part.scale.clone() }));
|
.map(part => ({ part, position: part.position.clone(), scale: part.scale.clone(), quaternion: part.quaternion.clone() }));
|
||||||
|
const legPositions = [joints.LegLeft.position.clone(), joints.LegRight.position.clone()];
|
||||||
|
const bodyRestPosition = body?.position.clone();
|
||||||
const animateIdle = typeof matchMedia === 'undefined' || !matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const animateIdle = typeof matchMedia === 'undefined' || !matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
const modelRestPosition = model.position.clone();
|
const modelRestPosition = model.position.clone();
|
||||||
const originals: Object3D[] = [];
|
const originals: Object3D[] = [];
|
||||||
@@ -131,6 +133,10 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
let fidgetTime = 0;
|
let fidgetTime = 0;
|
||||||
let fidgetAction: 'scratch' | 'hop' | undefined;
|
let fidgetAction: 'scratch' | 'hop' | undefined;
|
||||||
let idleWeight = 0;
|
let idleWeight = 0;
|
||||||
|
let walkWeight = 0;
|
||||||
|
const walk = CHARACTER.walk;
|
||||||
|
const torsoRotation = new Quaternion();
|
||||||
|
const coatOffset = new Vector3();
|
||||||
let flashlightOn = false;
|
let flashlightOn = false;
|
||||||
let flashlightPose = 0;
|
let flashlightPose = 0;
|
||||||
let previousPoseTime: number | undefined;
|
let previousPoseTime: number | undefined;
|
||||||
@@ -168,6 +174,9 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
},
|
},
|
||||||
pose(emote: Emote, elapsed: number, moving: boolean, time: number) {
|
pose(emote: Emote, elapsed: number, moving: boolean, time: number) {
|
||||||
const delta = Math.max(0, time - (previousPoseTime ?? time));
|
const delta = Math.max(0, time - (previousPoseTime ?? time));
|
||||||
|
const previousWalkWeight = walkWeight;
|
||||||
|
walkWeight = emote === 'idle' ? MathUtils.damp(walkWeight, Number(moving), 1 / walk.transitionSeconds, delta) : 0;
|
||||||
|
if (!moving && walkWeight < .001) walkWeight = 0;
|
||||||
const idle = animateIdle && !moving && emote === 'idle';
|
const idle = animateIdle && !moving && emote === 'idle';
|
||||||
rigModel.position.copy(modelRestPosition);
|
rigModel.position.copy(modelRestPosition);
|
||||||
if (!idle) {
|
if (!idle) {
|
||||||
@@ -200,10 +209,11 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
part.position.copy(rest.position);
|
part.position.copy(rest.position);
|
||||||
part.scale.copy(rest.scale);
|
part.scale.copy(rest.scale);
|
||||||
if (part === rigBody || part === rigCoat) {
|
if (part === rigBody || part === rigCoat) {
|
||||||
|
part.quaternion.copy(rest.quaternion);
|
||||||
part.scale.y *= 1 + breath * CHARACTER.idle.torsoRise;
|
part.scale.y *= 1 + breath * CHARACTER.idle.torsoRise;
|
||||||
part.scale.z *= 1 + breath * CHARACTER.idle.torsoDepth;
|
part.scale.z *= 1 + breath * CHARACTER.idle.torsoDepth;
|
||||||
// The coat is authored in model coordinates; expand about the torso's pivot.
|
// The coat is authored in model coordinates; expand about the torso's pivot.
|
||||||
if (part === rigCoat && body) part.position.y += -body.position.y * breath * CHARACTER.idle.torsoRise;
|
if (part === rigCoat && bodyRestPosition) part.position.y += -bodyRestPosition.y * breath * CHARACTER.idle.torsoRise;
|
||||||
} else {
|
} else {
|
||||||
part.position.y += breath * (part === joints.Head ? CHARACTER.idle.headLift : CHARACTER.idle.shoulderLift);
|
part.position.y += breath * (part === joints.Head ? CHARACTER.idle.headLift : CHARACTER.idle.shoulderLift);
|
||||||
}
|
}
|
||||||
@@ -217,12 +227,15 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
umbrellaPose = umbrellaOpen ? Math.min(1, umbrellaPose + umbrellaStep) : Math.max(0, umbrellaPose - umbrellaStep);
|
umbrellaPose = umbrellaOpen ? Math.min(1, umbrellaPose + umbrellaStep) : Math.max(0, umbrellaPose - umbrellaStep);
|
||||||
const carryingUmbrella = umbrellaOpen || umbrellaPose > 0;
|
const carryingUmbrella = umbrellaOpen || umbrellaPose > 0;
|
||||||
const changed = idle || idleWeight > 0 || previousIdleWeight > 0
|
const changed = idle || idleWeight > 0 || previousIdleWeight > 0
|
||||||
|
|| walkWeight > 0 || previousWalkWeight > 0
|
||||||
|| moving || emote !== 'idle' || previousMoving !== moving || previousEmote !== emote
|
|| moving || emote !== 'idle' || previousMoving !== moving || previousEmote !== emote
|
||||||
|| previousFlashlightPose !== flashlightPose || flashlightPose !== Number(flashlightOn)
|
|| previousFlashlightPose !== flashlightPose || flashlightPose !== Number(flashlightOn)
|
||||||
|| previousUmbrellaPose !== umbrellaPose || umbrellaPose !== Number(umbrellaOpen);
|
|| previousUmbrellaPose !== umbrellaPose || umbrellaPose !== Number(umbrellaOpen);
|
||||||
previousMoving = moving;
|
previousMoving = moving;
|
||||||
previousEmote = emote;
|
previousEmote = emote;
|
||||||
for (const [name, joint] of Object.entries(joints)) joint.quaternion.copy(rest[name]);
|
for (const [name, joint] of Object.entries(joints)) joint.quaternion.copy(rest[name]);
|
||||||
|
joints.LegLeft.position.copy(legPositions[0]);
|
||||||
|
joints.LegRight.position.copy(legPositions[1]);
|
||||||
if (emote === 'wave') {
|
if (emote === 'wave') {
|
||||||
pointArm(carryingUmbrella ? 'ArmLeft' : 'ArmRight',
|
pointArm(carryingUmbrella ? 'ArmLeft' : 'ArmRight',
|
||||||
aim.set(carryingUmbrella ? -1 : 1, .35 + Math.sin(elapsed * 12) * .3, -.15));
|
aim.set(carryingUmbrella ? -1 : 1, .35 + Math.sin(elapsed * 12) * .3, -.15));
|
||||||
@@ -230,12 +243,42 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
pointArm('ArmLeft', aim.set(.68, -.22, .64));
|
pointArm('ArmLeft', aim.set(.68, -.22, .64));
|
||||||
pointArm('ArmRight', aim.set(-.68, -.22, .64));
|
pointArm('ArmRight', aim.set(-.68, -.22, .64));
|
||||||
joints.Head.rotateX(.16);
|
joints.Head.rotateX(.16);
|
||||||
} else if (moving) {
|
} else if (moving || walkWeight > 0) {
|
||||||
const stride = Math.sin(time * 9) * .4;
|
const phase = time * walk.rate;
|
||||||
joints.LegLeft.rotateX(stride);
|
const swing = Math.sin(phase);
|
||||||
joints.LegRight.rotateX(-stride);
|
const amplitude = walkWeight * walk.strength;
|
||||||
joints.ArmLeft.rotateX(-stride * .6);
|
const sway = swing * walk.sway * amplitude;
|
||||||
joints.ArmRight.rotateX(stride * .6);
|
const rise = (1 - Math.cos(phase * 2)) * walk.rise * amplitude;
|
||||||
|
if (rigBody) {
|
||||||
|
rigBody.position.x += sway;
|
||||||
|
rigBody.position.y += rise;
|
||||||
|
const bodyRest = rigBreathingParts.find(({ part }) => part === rigBody)!;
|
||||||
|
torsoRotation.copy(bodyRest.quaternion).invert();
|
||||||
|
rigBody.rotateZ(-swing * walk.roll * amplitude);
|
||||||
|
rigBody.rotateY(swing * walk.twist * amplitude);
|
||||||
|
torsoRotation.premultiply(rigBody.quaternion);
|
||||||
|
if (rigCoat && bodyRestPosition) {
|
||||||
|
// The coat is authored at the model origin; rotate it about the torso pivot.
|
||||||
|
coatOffset.copy(rigCoat.position).sub(bodyRestPosition).applyQuaternion(torsoRotation);
|
||||||
|
rigCoat.position.copy(coatOffset).add(bodyRestPosition);
|
||||||
|
rigCoat.position.x += sway;
|
||||||
|
rigCoat.position.y += rise;
|
||||||
|
rigCoat.quaternion.premultiply(torsoRotation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [side, sign] of [['Left', 1], ['Right', -1]] as const) {
|
||||||
|
const leg = joints[`Leg${side}`], arm = joints[`Arm${side}`];
|
||||||
|
leg.rotateX(swing * sign * walk.stride * walkWeight);
|
||||||
|
leg.position.y += Math.max(0, -Math.cos(phase) * sign) ** 2 * walk.footLift * amplitude;
|
||||||
|
arm.position.x += sway;
|
||||||
|
arm.position.y += rise;
|
||||||
|
arm.rotateX(-Math.sin(phase - walk.armLag) * sign * walk.stride * walk.armSwing * walkWeight);
|
||||||
|
arm.rotateZ(-swing * walk.roll * amplitude);
|
||||||
|
}
|
||||||
|
joints.Head.position.x += Math.sin(phase - walk.headLag) * walk.sway * walk.headFollow * amplitude;
|
||||||
|
joints.Head.position.y += (1 - Math.cos(phase * 2 - walk.headLag)) * walk.rise * walk.headFollow * amplitude;
|
||||||
|
joints.Head.rotateZ(Math.sin(phase - walk.headLag) * walk.roll * walk.headRoll * amplitude);
|
||||||
|
joints.Head.rotateX(Math.sin(phase * 2 - walk.headLag) * walk.headNod * amplitude);
|
||||||
} else if (fidgetAction === 'scratch') {
|
} else if (fidgetAction === 'scratch') {
|
||||||
const blend = MathUtils.smoothstep(fidgetTime, 0, .4)
|
const blend = MathUtils.smoothstep(fidgetTime, 0, .4)
|
||||||
* (1 - MathUtils.smoothstep(fidgetTime, fidget.scratchSeconds - .4, fidget.scratchSeconds));
|
* (1 - MathUtils.smoothstep(fidgetTime, fidget.scratchSeconds - .4, fidget.scratchSeconds));
|
||||||
@@ -260,7 +303,7 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
for (const { part } of rigBreathingParts) {
|
for (const { part } of rigBreathingParts) {
|
||||||
if (part === rigBody || part === rigCoat) {
|
if (part === rigBody || part === rigCoat) {
|
||||||
part.scale.y *= 1 - crouch * fidget.hopCrouch;
|
part.scale.y *= 1 - crouch * fidget.hopCrouch;
|
||||||
if (part === rigCoat && body) part.position.y += body.position.y * crouch * fidget.hopCrouch;
|
if (part === rigCoat && bodyRestPosition) part.position.y += bodyRestPosition.y * crouch * fidget.hopCrouch;
|
||||||
} else {
|
} else {
|
||||||
part.position.y -= crouch * fidget.hopCrouch;
|
part.position.y -= crouch * fidget.hopCrouch;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -108,8 +108,12 @@ test('exported sheep has working joints and renders the final idle pose after wa
|
|||||||
const leg = sheep.group.getObjectByName('LegLeft')!;
|
const leg = sheep.group.getObjectByName('LegLeft')!;
|
||||||
const rest = leg.quaternion.clone();
|
const rest = leg.quaternion.clone();
|
||||||
assert.equal(sheep.pose('idle', 0, true, 1), true);
|
assert.equal(sheep.pose('idle', 0, true, 1), true);
|
||||||
|
sheep.pose('idle', 0, true, 1.2);
|
||||||
assert.ok(leg.quaternion.angleTo(rest) > .01);
|
assert.ok(leg.quaternion.angleTo(rest) > .01);
|
||||||
assert.equal(sheep.pose('idle', 0, false, 1), true, 'stopping must invalidate one frame');
|
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.ok(leg.quaternion.angleTo(rest) < 1e-6);
|
||||||
assert.equal(sheep.pose('idle', 0, false, 1), true, 'idle breathing keeps rendering');
|
assert.equal(sheep.pose('idle', 0, false, 1), true, 'idle breathing keeps rendering');
|
||||||
const arm = sheep.group.getObjectByName('ArmRight')!;
|
const arm = sheep.group.getObjectByName('ArmRight')!;
|
||||||
@@ -146,7 +150,13 @@ test('idle breathing moves torso and head while feet stay planted and clones ret
|
|||||||
assert.ok(!body.scale.equals(bodyRest), 'breathing blends out instead of snapping');
|
assert.ok(!body.scale.equals(bodyRest), 'breathing blends out instead of snapping');
|
||||||
sheep.pose('idle', 0, true, 1.2);
|
sheep.pose('idle', 0, true, 1.2);
|
||||||
assert.deepEqual(body.scale, bodyRest);
|
assert.deepEqual(body.scale, bodyRest);
|
||||||
assert.deepEqual(head.position, headRest);
|
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();
|
remote.dispose(); sheep.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Vector3 } from 'three';
|
|||||||
|
|
||||||
test('idle breathing carries the coat and hood without lifting the feet or changing the umbrella grip', async () => {
|
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));
|
const [model, umbrella, raincoat] = await Promise.all(['red-sheep', 'umbrella', 'raincoat'].map(asset));
|
||||||
|
raincoat.name = 'TestRaincoat';
|
||||||
const sheep = createSheep(model, { umbrella, raincoat });
|
const sheep = createSheep(model, { umbrella, raincoat });
|
||||||
sheep.setEquipment(true, true, true);
|
sheep.setEquipment(true, true, true);
|
||||||
const body = model.getObjectByName('Body')!, head = model.getObjectByName('Head')!;
|
const body = model.getObjectByName('Body')!, head = model.getObjectByName('Head')!;
|
||||||
@@ -26,6 +27,15 @@ test('idle breathing carries the coat and hood without lifting the feet or chang
|
|||||||
sheep.pose('idle', 0, false, 2);
|
sheep.pose('idle', 0, false, 2);
|
||||||
assert.ok(arm.quaternion.angleTo(grip) < 1e-8);
|
assert.ok(arm.quaternion.angleTo(grip) < 1e-8);
|
||||||
assert.ok(foot.getWorldPosition(new Vector3()).distanceTo(footPosition) < 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();
|
sheep.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user