motionAt function

MotionFrame motionAt(
  1. MotionSeeds seeds,
  2. double elapsedMs, {
  3. double ramp = 0,
})

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'),
  );
}