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.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 { min(max(self, range.lowerBound), range.upperBound) } }