feat: add sheep idle scratching and minute-interval double hops

This commit is contained in:
2026-11-02 12:19:36 +09:00
parent 21b0bb7e98
commit 8b6d407507
2 changed files with 79 additions and 0 deletions
+9
View File
@@ -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;
+70
View File
@@ -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) {