feat: add subtle full-body sheep walking motion

This commit is contained in:
2026-11-02 16:49:37 +09:00
parent 0a05c94ce1
commit 4b27a8c209
5 changed files with 103 additions and 11 deletions
@@ -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.
+17
View File
@@ -18,6 +18,23 @@ export const CHARACTER = {
cameraLift: 1.15,
cameraClearance: 0.35,
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: {
periodSeconds: 3.8,
transitionSeconds: 0.3,
+52 -9
View File
@@ -107,7 +107,9 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
const body = model.getObjectByName('Body');
const breathingParts = [body, gear?.raincoat, joints.Head, joints.ArmLeft, joints.ArmRight]
.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 modelRestPosition = model.position.clone();
const originals: Object3D[] = [];
@@ -131,6 +133,10 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
let fidgetTime = 0;
let fidgetAction: 'scratch' | 'hop' | undefined;
let idleWeight = 0;
let walkWeight = 0;
const walk = CHARACTER.walk;
const torsoRotation = new Quaternion();
const coatOffset = new Vector3();
let flashlightOn = false;
let flashlightPose = 0;
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) {
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';
rigModel.position.copy(modelRestPosition);
if (!idle) {
@@ -200,10 +209,11 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
part.position.copy(rest.position);
part.scale.copy(rest.scale);
if (part === rigBody || part === rigCoat) {
part.quaternion.copy(rest.quaternion);
part.scale.y *= 1 + breath * CHARACTER.idle.torsoRise;
part.scale.z *= 1 + breath * CHARACTER.idle.torsoDepth;
// 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 {
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);
const carryingUmbrella = umbrellaOpen || umbrellaPose > 0;
const changed = idle || idleWeight > 0 || previousIdleWeight > 0
|| walkWeight > 0 || previousWalkWeight > 0
|| moving || emote !== 'idle' || previousMoving !== moving || previousEmote !== emote
|| previousFlashlightPose !== flashlightPose || flashlightPose !== Number(flashlightOn)
|| previousUmbrellaPose !== umbrellaPose || umbrellaPose !== Number(umbrellaOpen);
previousMoving = moving;
previousEmote = emote;
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') {
pointArm(carryingUmbrella ? 'ArmLeft' : 'ArmRight',
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('ArmRight', aim.set(-.68, -.22, .64));
joints.Head.rotateX(.16);
} else if (moving) {
const stride = Math.sin(time * 9) * .4;
joints.LegLeft.rotateX(stride);
joints.LegRight.rotateX(-stride);
joints.ArmLeft.rotateX(-stride * .6);
joints.ArmRight.rotateX(stride * .6);
} else if (moving || walkWeight > 0) {
const phase = time * walk.rate;
const swing = Math.sin(phase);
const amplitude = walkWeight * walk.strength;
const sway = swing * walk.sway * amplitude;
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') {
const blend = MathUtils.smoothstep(fidgetTime, 0, .4)
* (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) {
if (part === rigBody || part === rigCoat) {
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 {
part.position.y -= crouch * fidget.hopCrouch;
}
+12 -2
View File
@@ -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 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);
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.equal(sheep.pose('idle', 0, false, 1), true, 'idle breathing keeps rendering');
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');
sheep.pose('idle', 0, true, 1.2);
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();
});
+10
View File
@@ -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 () => {
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')!;
@@ -26,6 +27,15 @@ test('idle breathing carries the coat and hood without lifting the feet or chang
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();
});