From 4b27a8c209064532c1612b29b5df24719e8d2d62 Mon Sep 17 00:00:00 2001 From: Horoli Date: Mon, 2 Nov 2026 16:49:37 +0900 Subject: [PATCH] feat: add subtle full-body sheep walking motion --- .../plans/2026-11-02-sheep-walk.md | 12 ++++ src/constants/character.ts | 17 ++++++ src/scene/sheep.ts | 61 ++++++++++++++++--- tests/character.test.ts | 14 ++++- tests/rain-gear.test.ts | 10 +++ 5 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 docs/superpowers/plans/2026-11-02-sheep-walk.md diff --git a/docs/superpowers/plans/2026-11-02-sheep-walk.md b/docs/superpowers/plans/2026-11-02-sheep-walk.md new file mode 100644 index 0000000..e7cc0a5 --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-sheep-walk.md @@ -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. diff --git a/src/constants/character.ts b/src/constants/character.ts index 35f9bbd..fb49464 100644 --- a/src/constants/character.ts +++ b/src/constants/character.ts @@ -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, diff --git a/src/scene/sheep.ts b/src/scene/sheep.ts index 2d0f02b..8daf5e5 100644 --- a/src/scene/sheep.ts +++ b/src/scene/sheep.ts @@ -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; } diff --git a/tests/character.test.ts b/tests/character.test.ts index dfbf21d..cf8dd65 100644 --- a/tests/character.test.ts +++ b/tests/character.test.ts @@ -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(); }); diff --git a/tests/rain-gear.test.ts b/tests/rain-gear.test.ts index 4d1ba6e..78063cb 100644 --- a/tests/rain-gear.test.ts +++ b/tests/rain-gear.test.ts @@ -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(); });