Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotAnimator.swift
Peter Steinberger 79b1fdfa39 feat(mac): mascot accessory pack — nightcap, grad cap, hat-tip exit (#108882)
* feat(mac): mascot accessory pack — nightcap, grad cap, hat-tip exit

New accessory channel for the shared mascot: sleepy (and auto-dozing)
Clawd wears a floppy blue nightcap; the final onboarding page tops the
celebration with a graduation cap whose tassel swings with the body;
and leaving the working mood plays a hat-tip — claw to the brim, small
bow, hard hat lifts away. Headwear drawing moves to its own canvas
extension file.

* fix(mac): one hat at a time and no phantom hat tips

The working hard hat (including its hat-tip exit) suppresses any mood-
or consumer-requested accessory until it is fully gone, and the hat-tip
plays only when the hat actually seated — a working state cancelled
mid-don exits through the normal entrance instead of synthesizing a
full hat just to lift it.

* chore(i18n): refresh native inventory lines for mascot view
2026-07-16 09:14:21 -07:00

772 lines
32 KiB
Swift

import Foundation
import SwiftUI
/// High-level emotional state for the animated mascot. Callers describe *how
/// the mascot should feel*; the animator owns how that feeling looks, including
/// the transition gesture played when a mood is entered.
public enum OpenClawMascotMood: String, CaseIterable, Equatable, Sendable {
/// Site-parity float/wiggle plus randomized blinks, glances, and rare quirks.
case idle
/// Looks around a lot — searching (gateway discovery, permission list).
case curious
/// Eyes drift up and scan, antennae twitch asymmetrically — work in flight.
case thinking
/// Hard-hat construction loop — hammering claw, impact sparks, periodic brow wipes (setup/install work in flight).
case working
/// Perky antennae, soft smile, happy-squint eyes.
case happy
/// One big jump-and-claw-raise entrance, then sparkly happy loop.
case celebrating
/// Droopy antennae, dim glow, downward gaze, periodic sighs.
case sad
/// Heavy lids, slow float, drifting z's; periodic yawns.
case sleepy
/// Subdued idle that watches the content below (compact chat hero).
case attentive
}
/// Ambient particle overlay drawn around the mascot for one pose frame.
enum OpenClawMascotEffect: Equatable {
case none
case sparkles
case hearts
case zzz
case sparks
case sweat
}
/// Drives the mascot's behavior over time: per-mood base loops, randomized
/// micro-behaviors (blinks, glances, claw snaps, rare quirks), one-shot
/// gestures, and interaction Easter eggs (click reactions, dizzy spiral,
/// auto-sleep). Not thread-safe by design — SwiftUI drives it from the view
/// body on the main thread only.
final class OpenClawMascotAnimator {
/// Deterministic xorshift64* so tests can seed the behavior schedule.
struct SeededGenerator: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) {
self.state = seed == 0 ? 0x9E37_79B9_7F4A_7C15 : seed
}
mutating func next() -> UInt64 {
self.state ^= self.state >> 12
self.state ^= self.state << 25
self.state ^= self.state >> 27
return self.state &* 2_685_821_657_736_338_717
}
}
private(set) var mood: OpenClawMascotMood = .idle
private var rng: SeededGenerator
private var startTime: TimeInterval?
private var lastPoseTime: TimeInterval = 0
// One-shot gesture playback (single slot; quirks wait for a free slot).
private var activeGesture: OpenClawMascotGesture?
private var activeGestureStart: TimeInterval = 0
private var pendingGesture: OpenClawMascotGesture?
private var pendingGestureAt: TimeInterval = 0
// Randomized micro-behavior schedule.
private var nextBlinkAt: TimeInterval = 0
private var pendingDoubleBlink = false
private var blinkStarts: [TimeInterval] = []
private var nextGlanceAt: TimeInterval = 0
private var gazeHoldUntil: TimeInterval = 0
private var gazeTarget: CGSize = .zero
private var currentGaze: CGSize = .zero
private var nextClawSnapAt: TimeInterval = 0
private var nextQuirkAt: TimeInterval = 0
private var nextMoodBeatAt: TimeInterval = 0
private var lastClickReaction: OpenClawMascotGesture?
// Interaction state.
private var pointerTarget: CGSize?
private var recentTapTimes: [TimeInterval] = []
private var dizzyStart: TimeInterval = 0
private var dizzyUntil: TimeInterval = 0
private var dizzyRecoveryQueued = false
// Explicit consumer headwear overrides the mood-owned accessory channel.
private var requestedAccessory: OpenClawMascotAccessory = .none
private var accessorySetAt: TimeInterval = 0
/// Hard-hat amount from the last rendered frame; gates the hat-tip exit.
private var lastHardHat: CGFloat = 0
// Auto-sleep: idle mascots doze off and need a click to wake up. Night
// owls (23:00-05:00 local) nod off about twice as fast. Only mascots with
// a tap handler may sleep — a non-interactive view has no wake path and
// would stay asleep forever.
private let allowsAutoSleep: Bool
private var lastInteractionAt: TimeInterval = 0
private var sleepAfter: TimeInterval = 60
private let nightOwl: Bool
init(seed: UInt64? = nil, hourOfDay: Int? = nil, allowsAutoSleep: Bool = false) {
self.rng = SeededGenerator(seed: seed ?? UInt64.random(in: 1...UInt64.max))
self.allowsAutoSleep = allowsAutoSleep
let hour = hourOfDay ?? Calendar.current.component(.hour, from: Date())
self.nightOwl = hour >= 23 || hour < 5
}
// MARK: - Inputs
func setMood(_ mood: OpenClawMascotMood, at time: TimeInterval) {
guard mood != self.mood else { return }
let wasWorking = self.mood == .working
self.mood = mood
self.lastInteractionAt = time
self.rescheduleMoodBeat(at: time)
// Tip only a hat that actually seated: a working state cancelled
// mid-don would otherwise synthesize a full hard hat just to lift it.
if wasWorking, self.lastHardHat >= 0.9 {
self.startGesture(.hatTip, at: time)
} else if let entrance = Self.entranceGesture(for: mood) {
self.startGesture(entrance, at: time)
}
}
func setAccessory(_ accessory: OpenClawMascotAccessory, at time: TimeInterval) {
guard accessory != self.requestedAccessory else { return }
self.requestedAccessory = accessory
self.accessorySetAt = time
}
/// Pointer direction from the view center, roughly unit-scaled; nil when
/// the pointer leaves. Eyes track it, and presence defers auto-sleep —
/// but hovering never wakes a sleeping mascot; that takes a click.
func setPointerTarget(_ direction: CGSize?, at time: TimeInterval) {
self.pointerTarget = direction.map { dir in
CGSize(width: dir.width.clamped(to: -1...1), height: dir.height.clamped(to: -1...1))
}
if direction != nil, !self.isDozing(at: time) {
self.lastInteractionAt = time
}
}
/// Click Easter-egg ladder: single clicks pick a playful reaction, a
/// quick burst of affection earns blushing hearts, and relentless
/// clicking makes the poor thing dizzy.
func handleTap(at time: TimeInterval) {
let wasDozing = self.isDozing(at: time)
self.lastInteractionAt = time
self.sleepAfter = self.randomSleepDelay()
if wasDozing {
self.startGesture(.startle, at: time)
return
}
self.recentTapTimes.append(time)
self.recentTapTimes.removeAll { time - $0 > 3.0 }
let burst = self.recentTapTimes.count
if time < self.dizzyUntil || burst >= 6 {
if time >= self.dizzyUntil {
self.dizzyStart = time
}
self.dizzyUntil = max(self.dizzyUntil, time + 2.4)
self.dizzyRecoveryQueued = true
self.activeGesture = nil
return
}
if burst >= 3 {
self.startGesture(.heartBurst, at: time)
return
}
var reactions: [OpenClawMascotGesture] = [.hop, .wave, .wink]
if let last = self.lastClickReaction {
reactions.removeAll { $0 == last }
}
let reaction = reactions.randomElement(using: &self.rng) ?? .hop
self.lastClickReaction = reaction
self.startGesture(reaction, at: time)
}
// MARK: - Pose
func pose(at time: TimeInterval) -> OpenClawMascotPose {
if self.startTime == nil {
self.begin(at: time)
}
let dt = (time - self.lastPoseTime).clamped(to: 0...0.1)
self.lastPoseTime = time
self.advanceSchedules(at: time)
let dozing = self.isDozing(at: time)
// Auto-doze reuses the sleepy base pose, including its nightcap.
let effectiveMood: OpenClawMascotMood = dozing ? .sleepy : self.mood
var pose = self.basePose(for: effectiveMood, at: time)
self.applyGaze(&pose, mood: effectiveMood, at: time, dt: dt)
self.applyBlinks(&pose, at: time)
self.applyDizzy(&pose, at: time)
if let gesture = self.activeGesture {
let progress = (time - self.activeGestureStart) / gesture.duration
if progress >= 1 {
self.activeGesture = nil
} else {
gesture.apply(to: &pose, progress: CGFloat(progress))
}
}
if self.requestedAccessory != .none {
pose.accessory = self.requestedAccessory
let progress = CGFloat((time - self.accessorySetAt) / 0.5).clamped(to: 0...1)
pose.accessoryAmount = OpenClawMascotGesture.easeOut(progress)
}
// One hat at a time: the working hard hat (including its hat-tip exit)
// owns the crown; any mood- or consumer-requested accessory waits
// until the hard hat is fully gone.
if pose.hardHat > 0.01 {
pose.accessoryAmount = 0
}
pose.clampChannels()
self.lastHardHat = pose.hardHat
return pose
}
// MARK: - Lifecycle
private func begin(at time: TimeInterval) {
self.startTime = time
self.lastPoseTime = time
self.lastInteractionAt = time
self.sleepAfter = self.randomSleepDelay()
self.nextBlinkAt = time + self.random(in: 0.8...2.4)
self.nextGlanceAt = time + self.random(in: 1.5...4.0)
self.nextClawSnapAt = time + self.random(in: 2.0...5.0)
self.nextQuirkAt = time + self.random(in: 9...18)
self.rescheduleMoodBeat(at: time)
// Say hello: a little wave shortly after first appearing.
if self.mood == .idle || self.mood == .curious || self.mood == .happy {
self.pendingGesture = .wave
self.pendingGestureAt = time + 0.9
}
}
private func isDozing(at time: TimeInterval) -> Bool {
self.allowsAutoSleep && self.mood == .idle &&
time - self.lastInteractionAt > self.sleepAfter
}
private func randomSleepDelay() -> TimeInterval {
let base = self.random(in: 45...80)
return self.nightOwl ? base * 0.55 : base
}
private func random(in range: ClosedRange<Double>) -> Double {
Double.random(in: range, using: &self.rng)
}
// MARK: - Schedules
private func advanceSchedules(at time: TimeInterval) {
if time >= self.nextBlinkAt {
self.blinkStarts.append(time)
if self.pendingDoubleBlink {
self.pendingDoubleBlink = false
self.nextBlinkAt = time + self.blinkInterval()
} else if self.random(in: 0...1) < 0.14 {
self.pendingDoubleBlink = true
self.nextBlinkAt = time + 0.34
} else {
self.nextBlinkAt = time + self.blinkInterval()
}
}
self.blinkStarts.removeAll { time - $0 > OpenClawMascotGesture.blinkDuration }
if time >= self.nextGlanceAt {
self.gazeTarget = self.randomGlanceTarget()
self.gazeHoldUntil = time + self.random(in: 0.7...1.9)
self.nextGlanceAt = self.gazeHoldUntil + self.glanceInterval()
} else if time >= self.gazeHoldUntil {
self.gazeTarget = .zero
}
let dozing = self.isDozing(at: time)
if time >= self.nextClawSnapAt {
if self.activeGesture == nil, !dozing, self.mood != .sad, self.mood != .working,
time >= self.dizzyUntil
{
self.startGesture(.clawSnap, at: time)
}
self.nextClawSnapAt = time + self.random(in: 4...9)
}
if time >= self.nextQuirkAt {
if self.activeGesture == nil, !dozing, time >= self.dizzyUntil, self.quirkEligible {
self.startGesture(self.randomQuirk(), at: time)
}
self.nextQuirkAt = time + self.random(in: self.mood == .curious ? 7...14 : 9...18)
}
if time >= self.nextMoodBeatAt {
if self.activeGesture == nil, time >= self.dizzyUntil {
if self.mood == .sad {
self.startGesture(.sigh, at: time)
} else if dozing || self.mood == .sleepy {
self.startGesture(.yawn, at: time)
} else if self.mood == .working {
self.startGesture(.wipeBrow, at: time)
}
}
self.rescheduleMoodBeat(at: time)
}
// Fires once the gesture slot frees up rather than dropping the clip.
if let pending = self.pendingGesture, time >= self.pendingGestureAt,
self.activeGesture == nil, !dozing
{
self.pendingGesture = nil
self.startGesture(pending, at: time)
}
if self.dizzyRecoveryQueued, time >= self.dizzyUntil {
self.dizzyRecoveryQueued = false
self.startGesture(.shake, at: time)
}
}
private var quirkEligible: Bool {
switch self.mood {
case .idle, .curious, .happy, .attentive: true
case .thinking, .working, .celebrating, .sad, .sleepy: false
}
}
private func randomQuirk() -> OpenClawMascotGesture {
// Weighted grab bag; the sneeze stays rare so it keeps surprising.
let roll = self.random(in: 0...11)
switch roll {
case ..<3: return .wink
case ..<6: return .peek
case ..<8: return .antennaZap
case ..<10: return .hop
default: return .sneeze
}
}
private func blinkInterval() -> TimeInterval {
self.mood == .attentive ? self.random(in: 1.8...4.0) : self.random(in: 2.2...5.5)
}
private func glanceInterval() -> TimeInterval {
switch self.mood {
case .curious: self.random(in: 1.6...4.0)
case .thinking: self.random(in: 1.2...3.0)
default: self.random(in: 3.0...8.0)
}
}
private func randomGlanceTarget() -> CGSize {
let magnitude = self.random(in: 0.5...1.0)
let angle = self.random(in: 0...(2 * .pi))
// Bias horizontal: sideways glances read better than straight up/down.
return CGSize(width: cos(angle) * magnitude, height: sin(angle) * magnitude * 0.6)
}
private func rescheduleMoodBeat(at time: TimeInterval) {
self.nextMoodBeatAt = time + self.random(in: 6...12)
}
private func startGesture(_ gesture: OpenClawMascotGesture, at time: TimeInterval) {
self.activeGesture = gesture
self.activeGestureStart = time
}
private static func entranceGesture(for mood: OpenClawMascotMood) -> OpenClawMascotGesture? {
switch mood {
case .happy: .hop
case .celebrating: .celebrate
case .sad: .sigh
case .sleepy: .yawn
case .working: .donHardHat
case .idle, .curious, .thinking, .attentive: nil
}
}
// MARK: - Pose layers
/// Per-mood base loop. `.idle` keeps the exact site-parity waves (float 4s,
/// antenna ±3° at 2s); other moods reshape speed, depth, and expression.
private func basePose(for mood: OpenClawMascotMood, at time: TimeInterval) -> OpenClawMascotPose {
var pose = OpenClawMascotPose()
switch mood {
case .idle:
pose.floatOffset = -4.8 * (1 - cos(2 * .pi * Self.cyclePhase(time, period: 4)))
pose.antennaDegrees = -3 * sin(2 * .pi * Self.cyclePhase(time, period: 2))
case .curious:
pose.floatOffset = -4.2 * (1 - cos(2 * .pi * Self.cyclePhase(time, period: 3.4)))
pose.antennaDegrees = -4 * sin(2 * .pi * Self.cyclePhase(time, period: 1.7))
pose.bodyTilt = 1.6 * sin(2 * .pi * Self.cyclePhase(time, period: 5.2))
case .thinking:
pose.floatOffset = -3.2 * (1 - cos(2 * .pi * Self.cyclePhase(time, period: 5)))
// Antennae twitch out of phase — visible "processing".
pose.antennaDegrees = -5 * sin(2 * .pi * Self.cyclePhase(time, period: 1.3))
pose.bodyTilt = 2 * sin(2 * .pi * Self.cyclePhase(time, period: 6))
pose.eyeGlowOpacity = 0.9 + 0.1 * sin(2 * .pi * Self.cyclePhase(time, period: 0.8))
case .working:
let phase = Self.cyclePhase(time, period: 0.95)
if phase < 0.05 {
pose.rightClawDegrees = -6
} else if phase < 0.60 {
pose.rightClawDegrees = -6 - 28 * OpenClawMascotGesture.easeInOut((phase - 0.05) / 0.55)
} else if phase < 0.72 {
let strike = ((phase - 0.60) / 0.12).clamped(to: 0...1)
pose.rightClawDegrees = -34 + 46 * strike * strike
} else {
pose.rightClawDegrees = 12 - 18 * OpenClawMascotGesture.easeInOut((phase - 0.72) / 0.28)
}
pose.leftClawDegrees = 4 + 2 * sin(2 * .pi * phase)
let impact = OpenClawMascotGesture.bell(((phase - 0.72) / 0.14).clamped(to: 0...1))
pose.floatOffset = -2 * (1 - cos(2 * .pi * Self.cyclePhase(time, period: 3.8))) + 0.8 * impact
pose.bodyStretch = 1 - 0.03 * impact
pose.bodyTilt = 2.2 + 0.6 * sin(2 * .pi * Self.cyclePhase(time, period: 5))
if phase >= 0.72 {
let recoil = ((phase - 0.72) / 0.28).clamped(to: 0...1)
pose.antennaDegrees = 6 * (1 - recoil) * sin(recoil * 3 * .pi)
}
pose.leftEyeOpenness = 0.85
pose.rightEyeOpenness = 0.85
pose.mouthCurve = 0.18
pose.hardHat = 1
pose.effect = .sparks
let strikePhase = (phase - 0.72).truncatingRemainder(dividingBy: 1)
pose.effectPhase = strikePhase < 0 ? strikePhase + 1 : strikePhase
case .happy:
pose.floatOffset = -6 * (1 - cos(2 * .pi * Self.cyclePhase(time, period: 3)))
pose.antennaDegrees = -4.5 * sin(2 * .pi * Self.cyclePhase(time, period: 1.6))
pose.mouthCurve = 0.55 + 0.1 * sin(2 * .pi * Self.cyclePhase(time, period: 3))
pose.happyEyes = 0.35
case .celebrating:
let hop = abs(sin(2 * .pi * Self.cyclePhase(time, period: 1.6)))
pose.floatOffset = -9 * hop
pose.bodyStretch = 1 + 0.03 * hop
pose.antennaDegrees = -6 * sin(2 * .pi * Self.cyclePhase(time, period: 0.8))
let clawWave = sin(2 * .pi * Self.cyclePhase(time, period: 0.9))
pose.leftClawDegrees = 20 + 8 * clawWave
pose.rightClawDegrees = -20 + 8 * clawWave
pose.mouthCurve = 0.9
pose.mouthOpen = 0.35
pose.happyEyes = 0.7
pose.glowScale = 1.1
pose.effect = .sparkles
pose.effectPhase = Self.cyclePhase(time, period: 2.2)
case .sad:
pose.floatOffset = -2.4 * (1 - cos(2 * .pi * Self.cyclePhase(time, period: 5.5)))
pose.antennaDegrees = -1.5 * sin(2 * .pi * Self.cyclePhase(time, period: 3))
pose.antennaDroop = 0.75
pose.mouthCurve = -0.55
pose.eyeGlowOpacity = 0.6
case .sleepy:
pose.floatOffset = -2 * (1 - cos(2 * .pi * Self.cyclePhase(time, period: 6)))
pose.antennaDroop = 0.35
pose.leftEyeOpenness = 0.22 + 0.08 * sin(2 * .pi * Self.cyclePhase(time, period: 3))
pose.rightEyeOpenness = pose.leftEyeOpenness
pose.eyeGlowOpacity = 0.5
pose.mouthRound = 0.15
// Slow head-bob as it nods off.
pose.bodyTilt = 2.5 * sin(2 * .pi * Self.cyclePhase(time, period: 6))
pose.accessory = .nightcap
pose.accessoryAmount = 1
pose.effect = .zzz
pose.effectPhase = Self.cyclePhase(time, period: 3)
case .attentive:
pose.floatOffset = -3 * (1 - cos(2 * .pi * Self.cyclePhase(time, period: 4)))
pose.antennaDegrees = -2.5 * sin(2 * .pi * Self.cyclePhase(time, period: 2))
pose.mouthCurve = 0.25
}
return pose
}
private func applyGaze(
_ pose: inout OpenClawMascotPose,
mood: OpenClawMascotMood,
at time: TimeInterval,
dt: TimeInterval)
{
var target = self.pointerTarget ?? self.gazeTarget
switch mood {
case .thinking:
// Pondering: eyes drift up and slowly scan side to side.
if self.pointerTarget == nil {
target = CGSize(
width: 0.4 * sin(2 * .pi * Self.cyclePhase(time, period: 3.8)),
height: -0.55)
}
case .working:
if self.pointerTarget == nil {
target = CGSize(
width: 0.55 + 0.04 * sin(2 * .pi * Self.cyclePhase(time, period: 4.6)),
height: 0.45 + 0.02 * cos(2 * .pi * Self.cyclePhase(time, period: 3.9)))
}
case .attentive:
if self.pointerTarget == nil {
target = CGSize(width: target.width * 0.5, height: 0.35)
}
case .sad:
if self.pointerTarget == nil {
target = CGSize(width: target.width * 0.3, height: 0.5)
}
case .sleepy:
target = CGSize(width: 0, height: 0.4)
case .idle, .curious, .happy, .celebrating:
break
}
let blend = 1 - exp(-dt * 9)
self.currentGaze.width += (target.width - self.currentGaze.width) * blend
self.currentGaze.height += (target.height - self.currentGaze.height) * blend
pose.gaze = self.currentGaze
}
private func applyBlinks(_ pose: inout OpenClawMascotPose, at time: TimeInterval) {
guard pose.happyEyes < 0.6 else { return }
for start in self.blinkStarts {
let progress = (time - start) / OpenClawMascotGesture.blinkDuration
guard progress >= 0, progress <= 1 else { continue }
let closure = OpenClawMascotGesture.bell(CGFloat(progress))
pose.leftEyeOpenness = min(pose.leftEyeOpenness, 1 - closure)
pose.rightEyeOpenness = min(pose.rightEyeOpenness, 1 - closure)
pose.eyeGlowOpacity *= max(0.3, 1 - closure)
}
}
private func applyDizzy(_ pose: inout OpenClawMascotPose, at time: TimeInterval) {
guard time < self.dizzyUntil else { return }
let remaining = self.dizzyUntil - time
// Ramp in from the first dizzy tap, out toward the recovery shake;
// extra taps extend `dizzyUntil` without restarting the ramp-in.
let ramp = min((time - self.dizzyStart) / 0.25, remaining / 0.4).clamped(to: 0...1)
pose.dizzy = ramp
pose.dizzyPhase = Self.cyclePhase(time, period: 0.55)
pose.bodyTilt += 4.5 * ramp * sin(2 * .pi * Self.cyclePhase(time, period: 0.85))
pose.antennaDegrees += 6 * ramp * sin(2 * .pi * Self.cyclePhase(time, period: 0.45))
pose.mouthRound = max(pose.mouthRound, 0.3 * ramp)
pose.happyEyes = 0
pose.mouthCurve = min(pose.mouthCurve, 0)
pose.gaze = .zero
}
static func cyclePhase(_ time: TimeInterval, period: TimeInterval) -> CGFloat {
let normalized = (time / period).truncatingRemainder(dividingBy: 1)
return CGFloat(normalized < 0 ? normalized + 1 : normalized)
}
}
/// One-shot animation clips layered over the mood base pose.
enum OpenClawMascotGesture: Equatable {
case wave
case hop
case wink
case celebrate
case heartBurst
case peek
case sneeze
case antennaZap
case sigh
case yawn
case startle
case shake
case clawSnap
case donHardHat
case wipeBrow
case hatTip
static let blinkDuration: TimeInterval = 0.16
var duration: TimeInterval {
switch self {
case .wave: 1.5
case .hop: 0.7
case .wink: 0.9
case .celebrate: 2.4
case .heartBurst: 2.0
case .peek: 1.9
case .sneeze: 1.5
case .antennaZap: 1.0
case .sigh: 1.8
case .yawn: 2.0
case .startle: 0.8
case .shake: 0.8
case .clawSnap: 0.6
case .donHardHat: 1.0
case .wipeBrow: 2.0
case .hatTip: 0.9
}
}
func apply(to pose: inout OpenClawMascotPose, progress p: CGFloat) {
switch self {
case .wave:
let raised = Self.plateau(p, attack: 0.18, release: 0.82)
pose.rightClawDegrees += raised * (-28 + 9 * sin(p * 6 * .pi))
pose.bodyTilt += -2 * raised
pose.mouthCurve = max(pose.mouthCurve, 0.5 * raised)
case .hop:
let air = Self.bell(((p - 0.2) / 0.6).clamped(to: 0...1))
pose.floatOffset += -9 * air
pose.bodyStretch += 0.045 * air - 0.1 * Self.bell((p / 0.2).clamped(to: 0...1))
- 0.06 * Self.bell(((p - 0.82) / 0.18).clamped(to: 0...1))
pose.mouthCurve = max(pose.mouthCurve, 0.4 * air)
case .wink:
let closure = Self.plateau(p, attack: 0.3, release: 0.72)
pose.rightEyeOpenness = min(pose.rightEyeOpenness, 1 - closure)
pose.mouthCurve = max(pose.mouthCurve, 0.5 * closure)
pose.bodyTilt += 1.5 * closure
case .celebrate:
let env = Self.plateau(p, attack: 0.12, release: 0.88)
let hops = abs(sin(p * 4 * .pi))
pose.floatOffset += -11 * hops * env
pose.bodyStretch += 0.035 * hops * env
pose.leftClawDegrees += 38 * env
pose.rightClawDegrees += -38 * env
pose.happyEyes = max(pose.happyEyes, env)
pose.mouthCurve = max(pose.mouthCurve, env)
pose.mouthOpen = max(pose.mouthOpen, 0.6 * Self.bell(p))
pose.antennaDroop = 0
pose.glowScale = max(pose.glowScale, 1 + 0.2 * env)
pose.effect = .sparkles
pose.effectPhase = p
case .heartBurst:
let env = Self.plateau(p, attack: 0.15, release: 0.85)
pose.blush = max(pose.blush, 0.9 * env)
pose.happyEyes = max(pose.happyEyes, 0.8 * env)
pose.mouthCurve = max(pose.mouthCurve, 0.8 * env)
pose.bodyTilt += 2 * env * sin(p * 4 * .pi)
pose.effect = .hearts
pose.effectPhase = p
case .peek:
// Lean left, then right, like checking who's offscreen.
let left = Self.plateau((p / 0.5).clamped(to: 0...1), attack: 0.3, release: 0.7)
let right = Self.plateau(((p - 0.5) / 0.5).clamped(to: 0...1), attack: 0.3, release: 0.7)
pose.bodyTilt += -6 * left + 6 * right
pose.gaze = CGSize(width: -1.1 * left + 1.1 * right, height: -0.1)
pose.glowScale = max(pose.glowScale, 1.1)
case .sneeze:
if p < 0.42 {
let inhale = OpenClawMascotGesture.easeInOut(p / 0.42)
pose.bodyStretch += 0.04 * inhale
pose.gaze = CGSize(width: 0, height: -0.8 * inhale)
pose.mouthRound = max(pose.mouthRound, 0.55 * inhale)
pose.antennaDegrees += 8 * inhale
} else if p < 0.58 {
let burst = Self.bell((p - 0.42) / 0.16)
pose.bodyStretch -= 0.13 * burst
pose.leftEyeOpenness = 0
pose.rightEyeOpenness = 0
pose.antennaDroop = max(pose.antennaDroop, 0.9 * burst)
pose.bodyTilt += 3 * burst
} else {
let recover = 1 - OpenClawMascotGesture.easeInOut((p - 0.58) / 0.42)
pose.antennaDroop = max(pose.antennaDroop, 0.5 * recover)
pose.eyeGlowOpacity *= 1 - 0.4 * recover
pose.mouthRound = max(pose.mouthRound, 0.2 * recover)
}
case .antennaZap:
let env = 1 - p
pose.antennaDegrees += 5 * env * sin(p * 12 * .pi)
pose.glowScale = max(pose.glowScale, 1 + 0.55 * Self.bell(p))
pose.eyeGlowOpacity = 1
case .sigh:
let rise = OpenClawMascotGesture.easeInOut((p / 0.3).clamped(to: 0...1))
let fall = OpenClawMascotGesture.easeInOut(((p - 0.3) / 0.45).clamped(to: 0...1))
pose.bodyStretch += 0.025 * rise - 0.08 * fall * (1 - ((p - 0.85) / 0.15).clamped(to: 0...1))
pose.gaze = CGSize(width: pose.gaze.width, height: 0.5 * fall)
pose.antennaDroop = min(1, pose.antennaDroop + 0.15 * fall)
case .yawn:
let openess = Self.plateau(p, attack: 0.3, release: 0.75)
pose.mouthRound = max(pose.mouthRound, 0.9 * openess)
pose.leftEyeOpenness = min(pose.leftEyeOpenness, 1 - 0.9 * openess)
pose.rightEyeOpenness = min(pose.rightEyeOpenness, 1 - 0.9 * openess)
pose.bodyStretch += 0.03 * openess
pose.bodyTilt += -2 * openess
case .startle:
let jolt = Self.bell((p / 0.4).clamped(to: 0...1))
pose.floatOffset += -5 * jolt
pose.bodyStretch += 0.05 * jolt
pose.glowScale = max(pose.glowScale, 1 + 0.4 * jolt)
pose.leftEyeOpenness = 1
pose.rightEyeOpenness = 1
pose.antennaDroop = 0
pose.antennaDegrees = 0
pose.gaze = .zero
case .shake:
pose.bodyTilt += 5 * (1 - p) * sin(p * 6 * .pi)
pose.gaze = .zero
case .clawSnap:
// The site's 4s-cycle snap, compressed into a schedulable clip:
// snap to -8° and back, right claw trailing slightly.
pose.leftClawDegrees += -8 * Self.bell((p / 0.7).clamped(to: 0...1))
pose.rightClawDegrees += -8 * Self.bell(((p - 0.25) / 0.7).clamped(to: 0...1))
case .donHardHat:
let drop = Self.easeInOut((p / 0.55).clamped(to: 0...1))
pose.hardHat = min(pose.hardHat, drop)
if p < 0.55 {
pose.gaze = CGSize(width: 0, height: -0.9 * (1 - p))
}
pose.bodyStretch -= 0.04 * Self.bell(((p - 0.5) / 0.2).clamped(to: 0...1))
let ready = Self.bell(((p - 0.7) / 0.3).clamped(to: 0...1))
pose.leftClawDegrees += -8 * ready
pose.rightClawDegrees += 8 * ready
case .wipeBrow:
let env = Self.plateau(p, attack: 0.2, release: 0.8)
pose.leftClawDegrees *= 1 - env
pose.rightClawDegrees *= 1 - env
pose.leftClawDegrees += 38 * env * (0.9 + 0.1 * sin(p * 5 * .pi))
pose.bodyTilt *= 1 - env
pose.bodyStretch += 0.02 * env
pose.happyEyes = max(pose.happyEyes, 0.7 * env)
pose.mouthCurve = max(pose.mouthCurve, 0.5 * env)
pose.gaze = CGSize(width: pose.gaze.width * (1 - env), height: pose.gaze.height * (1 - env))
pose.effect = .sweat
pose.effectPhase = p
case .hatTip:
let reach = Self.plateau(p, attack: 0.22, release: 0.82)
let brimAngle = -33 + 3 * sin(p * 4 * .pi)
pose.rightClawDegrees = pose.rightClawDegrees * (1 - reach) + brimAngle * reach
let bow = Self.bell(p)
pose.bodyTilt += 3 * bow
pose.bodyStretch -= 0.02 * bow
// Non-working base poses have no hard hat; this clip keeps it seated until the lift-away.
pose.hardHat = max(pose.hardHat, 1 - Self.easeInOut((p - 0.55) / 0.45))
}
}
// MARK: - Easing
static func easeInOut(_ t: CGFloat) -> CGFloat {
let clamped = t.clamped(to: 0...1)
return clamped * clamped * (3 - 2 * clamped)
}
static func easeOut(_ t: CGFloat) -> CGFloat {
let remaining = 1 - t.clamped(to: 0...1)
return 1 - remaining * remaining * remaining
}
/// Smooth 0→1→0 bump peaking at 0.5.
static func bell(_ t: CGFloat) -> CGFloat {
let clamped = t.clamped(to: 0...1)
return self.easeInOut(clamped < 0.5 ? clamped * 2 : (1 - clamped) * 2)
}
/// Ease in until `attack`, hold at 1, ease out after `release`.
static func plateau(_ t: CGFloat, attack: CGFloat, release: CGFloat) -> CGFloat {
let clamped = t.clamped(to: 0...1)
if clamped < attack {
return self.easeInOut(clamped / attack)
}
if clamped > release {
return self.easeInOut((1 - clamped) / (1 - release))
}
return 1
}
}
extension Comparable {
func clamped(to range: ClosedRange<Self>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
}
}