From 8b6d4075079933c9052474d94efd03ab6ff897c5 Mon Sep 17 00:00:00 2001 From: Horoli Date: Mon, 2 Nov 2026 12:19:36 +0900 Subject: [PATCH] feat: add sheep idle scratching and minute-interval double hops --- src/constants/character.ts | 9 +++++ src/scene/sheep.ts | 70 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/constants/character.ts b/src/constants/character.ts index 5d2c8d0..18b7020 100644 --- a/src/constants/character.ts +++ b/src/constants/character.ts @@ -25,5 +25,14 @@ export const CHARACTER = { torsoDepth: 0.014, headLift: 0.018, shoulderLift: 0.008, + fidget: { + minimumWaitSeconds: 8, + maximumWaitSeconds: 15, + scratchSeconds: 2.4, + hopIntervalSeconds: 60, + hopSeconds: 1, + hopHeight: 0.085, + hopCrouch: 0.025, + }, }, } as const; diff --git a/src/scene/sheep.ts b/src/scene/sheep.ts index f78a7df..b09b32b 100644 --- a/src/scene/sheep.ts +++ b/src/scene/sheep.ts @@ -109,6 +109,7 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr .filter((part): part is Object3D => Boolean(part)) .map(part => ({ part, position: part.position.clone(), scale: part.scale.clone() })); const animateIdle = typeof matchMedia === 'undefined' || !matchMedia('(prefers-reduced-motion: reduce)').matches; + const modelRestPosition = model.position.clone(); const originals: Object3D[] = []; group.traverse(object => originals.push(object)); function bindRig(group: Group, ownsAssets: boolean): Sheep { @@ -123,7 +124,14 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr const rigFlashlight = copy(gear?.flashlight); const rigBreathingParts = breathingParts.map(rest => ({ ...rest, part: copy(rest.part)! })); const rigBody = copy(body), rigCoat = copy(gear?.raincoat); + const rigModel = copy(model)!; const breathPhase = Math.random() * Math.PI * 2; + const fidget = CHARACTER.idle.fidget; + const nextFidgetDelay = () => MathUtils.lerp(fidget.minimumWaitSeconds, fidget.maximumWaitSeconds, Math.random()); + let fidgetWait = nextFidgetDelay(); + let hopWait: number = fidget.hopIntervalSeconds; + let fidgetTime = 0; + let fidgetAction: 'scratch' | 'hop' | undefined; let idleWeight = 0; let flashlightOn = false; let flashlightPose = 0; @@ -163,6 +171,35 @@ 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 idle = animateIdle && !moving && emote === 'idle'; + rigModel.position.copy(modelRestPosition); + if (!idle) { + fidgetWait = nextFidgetDelay(); + hopWait = fidget.hopIntervalSeconds; + fidgetTime = 0; + fidgetAction = undefined; + } else { + // Do not fast-forward gestures after a background tab resumes. + const idleDelta = Math.min(delta, .1); + hopWait -= idleDelta; + if (hopWait <= 0) { + fidgetAction = 'hop'; + fidgetTime = 0; + hopWait += fidget.hopIntervalSeconds; + } else if (!fidgetAction) { + fidgetWait -= idleDelta; + if (fidgetWait <= 0) { + fidgetAction = 'scratch'; + fidgetTime = 0; + } + } else { + fidgetTime += idleDelta; + const duration = fidgetAction === 'scratch' ? fidget.scratchSeconds : fidget.hopSeconds; + if (fidgetTime >= duration) { + fidgetAction = undefined; + fidgetWait = nextFidgetDelay(); + } + } + } const previousIdleWeight = idleWeight; const idleStep = delta / CHARACTER.idle.transitionSeconds; idleWeight = idle ? Math.min(1, idleWeight + idleStep) : Math.max(0, idleWeight - idleStep); @@ -209,6 +246,39 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr joints.LegRight.rotateX(-stride); joints.ArmLeft.rotateX(-stride * .6); joints.ArmRight.rotateX(stride * .6); + } else if (fidgetAction === 'scratch') { + const blend = MathUtils.smoothstep(fidgetTime, 0, .4) + * (1 - MathUtils.smoothstep(fidgetTime, fidget.scratchSeconds - .4, fidget.scratchSeconds)); + const left = carryingUmbrella; + const arm = joints[left ? 'ArmLeft' : 'ArmRight']; + // Only scratch with an empty hand, including while equipment blends out. + if (!left || (!flashlightOn && flashlightPose === 0)) { + pointArm(left ? 'ArmLeft' : 'ArmRight', aim.set(left ? -.3 : .3, .95, .12 + Math.sin(fidgetTime * 22) * .12)); + arm.quaternion.slerp(rest[left ? 'ArmLeft' : 'ArmRight'], 1 - blend); + joints.Head.rotateZ((left ? -.1 : .1) * blend); + } + } else if (fidgetAction === 'hop') { + const progress = fidgetTime / fidget.hopSeconds * 2; + const phase = progress % 1; + const strength = progress < 1 ? 1 : .7; + // Each small hop has a planted preparation, a short flight and a soft landing. + const flight = MathUtils.clamp((phase - .24) / .48, 0, 1); + const bounce = 4 * flight * (1 - flight) * strength; + const crouch = (phase < .24 ? Math.sin(phase / .24 * Math.PI) + : phase > .72 ? Math.sin((phase - .72) / .28 * Math.PI) : 0) * strength; + rigModel.position.y += bounce * fidget.hopHeight; + 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; + } else { + part.position.y -= crouch * fidget.hopCrouch; + } + } + joints.LegLeft.rotateX(-bounce * .1); + joints.LegRight.rotateX(-bounce * .1); + joints.ArmLeft.rotateZ(-bounce * .08); + joints.ArmRight.rotateZ(bounce * .08); } // Keep the grip stable while the free arm and legs perform their own animations. if (umbrellaPose > 0) {