mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 17:01:45 +00:00
* feat(macos): mascot moods, randomized idle behavior, and onboarding Easter eggs * feat(apps): opt About window and iOS hero marks into mascot Easter eggs * chore(i18n): sync native app i18n inventory
654 lines
26 KiB
Swift
654 lines
26 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
|
|
/// 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
|
|
}
|
|
|
|
/// 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
|
|
|
|
// 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 }
|
|
self.mood = mood
|
|
self.lastInteractionAt = time
|
|
self.rescheduleMoodBeat(at: time)
|
|
if let entrance = Self.entranceGesture(for: mood) {
|
|
self.startGesture(entrance, at: 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)
|
|
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))
|
|
}
|
|
}
|
|
|
|
pose.clampChannels()
|
|
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, 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)
|
|
}
|
|
}
|
|
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, .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 .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 .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.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 .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
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
// MARK: - Easing
|
|
|
|
static func easeInOut(_ t: CGFloat) -> CGFloat {
|
|
let clamped = t.clamped(to: 0...1)
|
|
return clamped * clamped * (3 - 2 * clamped)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|