motionAt function
The motion frame at elapsedMs for seeds.
ramp in [0, 1] scales the ambient motion up from a quiet idle (0) to
full liveliness (1) — the hover mode ramps it with the pointer. The frame
at elapsed 0 is always blink-free, so a paused hiblob never shows half a
blink.
Two expressions carry held loops that survive the ramp: thinking
slowly seesaws its body from side to side, and mad adds a fast
high-frequency tremor. Both scale with ramp so they still quiet down.
Implementation
MotionFrame motionAt(MotionSeeds seeds, double elapsedMs, {double ramp = 0}) {
final t = elapsedMs / 1000;
final ambient = 0.35 + 0.65 * ramp.clamp(0.0, 1.0);
final bob = math.sin(2 * math.pi * t / seeds.bobPeriod + seeds.bobPhase) *
1.5 *
ambient;
final bodyY = bob - 2.0 * ramp;
var bodyX = 0.0;
var tremor = 0.0;
switch (seeds.expression.id) {
case 'thinking':
// Seesaw: one slow traverse roughly every 6 seconds.
bodyX =
math.sin(2 * math.pi * t / 6.0 + seeds.seesawPhase) * 1.1 * ambient;
break;
case 'mad':
// Tremor: a fast, tiny shake layered over the bob.
tremor = (math.sin(2 * math.pi * t / 0.32 + seeds.tremorPhase) * 0.8 +
math.sin(2 * math.pi * t / 0.21 + seeds.tremorPhase * 1.7) *
0.4) *
ambient;
bodyX =
math.sin(2 * math.pi * t / 0.53 + seeds.tremorPhase) * 0.35 * ambient;
break;
}
final bodyScaleY = 1 +
math.sin(2 * math.pi * t / seeds.breathePeriod + seeds.breathePhase) *
0.012 *
ambient;
// Blink: one short window inside each blink period, shaped like a half
// sine so it closes and opens smoothly.
final bt = ((t + seeds.blinkOffset * seeds.blinkPeriod) % seeds.blinkPeriod) /
seeds.blinkPeriod;
const windowStart = 0.55;
const windowLength = 0.055;
var blink = 0.0;
if (bt >= windowStart && bt <= windowStart + windowLength) {
blink = math.sin((bt - windowStart) / windowLength * math.pi);
}
// Glances: a new target each glance period, approached with an ease-out in
// the first 12% of the period, so saccades snap and then hold.
final index = (t / seeds.glancePeriod).floor();
final prevIndex = index - 1;
final frac = t / seeds.glancePeriod - index;
final ease = frac < 0.12 ? _easeOutCubic(frac / 0.12) : 1.0;
double glance(String axis) {
final cur = _glanceComponent(seeds.glanceSeed, index, axis);
final prev = _glanceComponent(seeds.glanceSeed, prevIndex, axis);
return (prev + (cur - prev) * ease) * 1.8 * ambient;
}
return MotionFrame(
bodyX: bodyX,
bodyY: bodyY + tremor,
bodyScaleY: bodyScaleY,
blink: blink,
gazeX: glance('x'),
gazeY: glance('y'),
);
}