From f9ebefba29a0b4a29a2389694f84ed7c95014b8a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 01:01:26 -0700 Subject: [PATCH] feat(macos): onboarding mascot reacts with moods, randomized idle behavior, and Easter eggs (#104255) * 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 --- apps/.i18n/native-source.json | 70 +- apps/ios/Sources/Design/OpenClawBrand.swift | 4 +- .../Design/OpenClawProComponents.swift | 4 +- .../Design/SettingsProTabSections.swift | 2 +- .../Gateway/GatewayQuickSetupSheet.swift | 2 +- .../Onboarding/OnboardingWizardSteps.swift | 4 +- .../Sources/OpenClaw/AboutSettings.swift | 36 +- apps/macos/Sources/OpenClaw/Onboarding.swift | 1 + .../OpenClaw/OnboardingView+Layout.swift | 2 +- .../Sources/OpenClaw/OnboardingWidgets.swift | 114 ++- .../OnboardingMascotMoodTests.swift | 54 ++ .../OpenClawMascotAnimator.swift | 653 ++++++++++++++++++ .../OpenClawChatUI/OpenClawMascotView.swift | 479 +++++++++++-- .../OpenClawMascotAnimatorTests.swift | 177 +++++ 14 files changed, 1471 insertions(+), 131 deletions(-) create mode 100644 apps/macos/Tests/OpenClawIPCTests/OnboardingMascotMoodTests.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotAnimator.swift create mode 100644 apps/shared/OpenClawKit/Tests/OpenClawKitTests/OpenClawMascotAnimatorTests.swift diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 472f9fa25b91..9ce541479344 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -9635,7 +9635,7 @@ }, { "kind": "ui-modifier", - "line": 600, + "line": 602, "path": "apps/ios/Sources/Design/OpenClawProComponents.swift", "source": "OpenClaw", "surface": "apple", @@ -9643,7 +9643,7 @@ }, { "kind": "ui-modifier", - "line": 628, + "line": 630, "path": "apps/ios/Sources/Design/OpenClawProComponents.swift", "source": "Gateway \\(self.title)", "surface": "apple", @@ -9651,7 +9651,7 @@ }, { "kind": "conditional-branch", - "line": 633, + "line": 635, "path": "apps/ios/Sources/Design/OpenClawProComponents.swift", "source": "Online", "surface": "apple", @@ -9659,7 +9659,7 @@ }, { "kind": "conditional-branch", - "line": 635, + "line": 637, "path": "apps/ios/Sources/Design/OpenClawProComponents.swift", "source": "Connecting", "surface": "apple", @@ -9667,7 +9667,7 @@ }, { "kind": "conditional-branch", - "line": 637, + "line": 639, "path": "apps/ios/Sources/Design/OpenClawProComponents.swift", "source": "Attention", "surface": "apple", @@ -9675,7 +9675,7 @@ }, { "kind": "conditional-branch", - "line": 639, + "line": 641, "path": "apps/ios/Sources/Design/OpenClawProComponents.swift", "source": "Offline", "surface": "apple", @@ -14937,17 +14937,9 @@ "surface": "apple", "id": "native.apple.096fb24013ac5455" }, - { - "kind": "ui-modifier", - "line": 32, - "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", - "source": "OpenClaw on GitHub", - "surface": "apple", - "id": "native.apple.6fdc607c6178ba31" - }, { "kind": "ui-call", - "line": 40, + "line": 32, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "OpenClaw", "surface": "apple", @@ -14955,7 +14947,7 @@ }, { "kind": "ui-call", - "line": 44, + "line": 36, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Menu bar companion for notifications, screenshots, and privileged agent actions.", "surface": "apple", @@ -14963,7 +14955,7 @@ }, { "kind": "ui-named-argument", - "line": 53, + "line": 45, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Website", "surface": "apple", @@ -14971,7 +14963,7 @@ }, { "kind": "ui-named-argument", - "line": 54, + "line": 46, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Docs", "surface": "apple", @@ -14979,7 +14971,7 @@ }, { "kind": "ui-named-argument", - "line": 57, + "line": 49, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "GitHub", "surface": "apple", @@ -14987,7 +14979,7 @@ }, { "kind": "ui-named-argument", - "line": 61, + "line": 53, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Discord", "surface": "apple", @@ -14995,7 +14987,7 @@ }, { "kind": "ui-call", - "line": 74, + "line": 66, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Check for updates automatically", "surface": "apple", @@ -15003,7 +14995,7 @@ }, { "kind": "ui-call", - "line": 78, + "line": 70, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Check for Updates…", "surface": "apple", @@ -15011,7 +15003,7 @@ }, { "kind": "ui-call", - "line": 81, + "line": 73, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Updates unavailable in this build.", "surface": "apple", @@ -15019,7 +15011,7 @@ }, { "kind": "ui-call", - "line": 87, + "line": 79, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "© 2026 OpenClaw Foundation — MIT License.", "surface": "apple", @@ -15027,7 +15019,7 @@ }, { "kind": "ui-call", - "line": 166, + "line": 158, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Copy full commit hash", "surface": "apple", @@ -15035,7 +15027,7 @@ }, { "kind": "ui-call", - "line": 170, + "line": 162, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Copy build info", "surface": "apple", @@ -15043,7 +15035,7 @@ }, { "kind": "ui-call", - "line": 176, + "line": 168, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Copy Commit", "surface": "apple", @@ -15051,7 +15043,7 @@ }, { "kind": "ui-call", - "line": 180, + "line": 172, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Copy Build Info", "surface": "apple", @@ -15059,7 +15051,7 @@ }, { "kind": "ui-call", - "line": 196, + "line": 188, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Unavailable", "surface": "apple", @@ -15067,7 +15059,7 @@ }, { "kind": "ui-call", - "line": 212, + "line": 204, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Version \\(version), commit \\(commit), built \\(built), timestamp \\(timestamp)", "surface": "apple", @@ -15075,7 +15067,7 @@ }, { "kind": "ui-call", - "line": 215, + "line": 207, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Version \\(version), commit \\(commit), build date unavailable", "surface": "apple", @@ -15083,7 +15075,7 @@ }, { "kind": "ui-call", - "line": 218, + "line": 210, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Version \\(version), commit unavailable, built \\(built), timestamp \\(timestamp)", "surface": "apple", @@ -15091,7 +15083,7 @@ }, { "kind": "ui-call", - "line": 220, + "line": 212, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Version \\(version), commit unavailable, build date unavailable", "surface": "apple", @@ -18947,7 +18939,7 @@ }, { "kind": "conditional-branch", - "line": 236, + "line": 237, "path": "apps/macos/Sources/OpenClaw/Onboarding.swift", "source": "Finish", "surface": "apple", @@ -18955,7 +18947,7 @@ }, { "kind": "conditional-branch", - "line": 236, + "line": 237, "path": "apps/macos/Sources/OpenClaw/Onboarding.swift", "source": "Next", "surface": "apple", @@ -23505,6 +23497,14 @@ "surface": "apple", "id": "native.apple.8d0606ab1a2c0f44" }, + { + "kind": "ui-call", + "line": 547, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotView.swift", + "source": "z", + "surface": "apple", + "id": "native.apple.f74d150ce05205dd" + }, { "kind": "ui-modifier", "line": 39, diff --git a/apps/ios/Sources/Design/OpenClawBrand.swift b/apps/ios/Sources/Design/OpenClawBrand.swift index f1857b99b2d1..7565fe379c9b 100644 --- a/apps/ios/Sources/Design/OpenClawBrand.swift +++ b/apps/ios/Sources/Design/OpenClawBrand.swift @@ -210,9 +210,11 @@ enum OpenClawBrand { struct OpenClawActivationGlyph: View { let size: CGFloat + /// Opt-in tap Easter eggs; leave off when the glyph sits inside a control. + var interactive = false var body: some View { - OpenClawMascotView(floats: false) + OpenClawMascotView(floats: false, interactive: self.interactive) .frame(width: self.size, height: self.size) .shadow( color: OpenClawBrand.activationGlow.opacity(0.18), diff --git a/apps/ios/Sources/Design/OpenClawProComponents.swift b/apps/ios/Sources/Design/OpenClawProComponents.swift index d2ea62c2f032..973b6478670b 100644 --- a/apps/ios/Sources/Design/OpenClawProComponents.swift +++ b/apps/ios/Sources/Design/OpenClawProComponents.swift @@ -592,9 +592,11 @@ struct ProValuePill: View { struct OpenClawProMark: View { var size: CGFloat = 42 var shadowRadius: CGFloat = 10 + /// Opt-in tap Easter eggs; leave off when the mark sits inside a control. + var interactive = false var body: some View { - OpenClawMascotView() + OpenClawMascotView(interactive: self.interactive) .frame(width: self.size, height: self.size) .shadow(color: OpenClawBrand.accent.opacity(0.18), radius: self.shadowRadius, y: self.shadowRadius / 3) .accessibilityLabel("OpenClaw") diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index 1b6098848cd0..1497e0d6a5cc 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -694,7 +694,7 @@ extension SettingsProTab { Group { Section { VStack(spacing: 12) { - OpenClawProMark(size: 96, shadowRadius: 18) + OpenClawProMark(size: 96, shadowRadius: 18, interactive: true) .accessibilityHidden(true) VStack(spacing: 2) { Text("OpenClaw") diff --git a/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift b/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift index b4c27f0e2394..42c304cce90c 100644 --- a/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift +++ b/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift @@ -211,7 +211,7 @@ private struct GatewayQuickSetupHeader: View { var body: some View { VStack(alignment: .leading, spacing: 14) { ZStack(alignment: .bottomTrailing) { - OpenClawActivationGlyph(size: 70) + OpenClawActivationGlyph(size: 70, interactive: true) .shadow(color: OpenClawBrand.activationGlow.opacity(0.18), radius: 10, x: 0, y: 5) Image(systemName: "antenna.radiowaves.left.and.right") diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift b/apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift index d76af02962a4..7cb156d55f66 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift @@ -27,7 +27,7 @@ private struct OnboardingActivationCanvas: View { private struct OnboardingHeroGlyph: View { var body: some View { - OpenClawActivationGlyph(size: 78) + OpenClawActivationGlyph(size: 78, interactive: true) } } @@ -361,7 +361,7 @@ struct OnboardingSuccessStep: View { Spacer(minLength: 54) ZStack(alignment: .bottomTrailing) { - OpenClawActivationGlyph(size: 86) + OpenClawActivationGlyph(size: 86, interactive: true) .shadow(color: OpenClawBrand.activationGlow.opacity(0.18), radius: 12, x: 0, y: 6) Image(systemName: "checkmark") diff --git a/apps/macos/Sources/OpenClaw/AboutSettings.swift b/apps/macos/Sources/OpenClaw/AboutSettings.swift index d5b96ec9d518..d8d0283e71d2 100644 --- a/apps/macos/Sources/OpenClaw/AboutSettings.swift +++ b/apps/macos/Sources/OpenClaw/AboutSettings.swift @@ -12,29 +12,21 @@ struct AboutSettings: View { var body: some View { VStack(spacing: 8) { - Button { - if let url = URL(string: "https://github.com/openclaw/openclaw") { - NSWorkspace.shared.open(url) + // Hero treatment from openclaw.ai: coral silhouette glow at 10% of + // size, teal glow at 15% plus scale 1.1 on hover. Clicks go to the + // mascot's Easter eggs; the GitHub link lives in the row set below. + OpenClawMascotView(interactive: true) + .frame(width: 160, height: 160) + .shadow( + color: OpenClawMascotView.heroGlowColor( + for: self.colorScheme, + hovering: self.iconHover), + radius: self.iconHover ? 24 : 16) + .scaleEffect(self.iconHover ? 1.1 : 1.0) + .pointingHandCursor() + .onHover { hover in + withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { self.iconHover = hover } } - } label: { - // Hero treatment from openclaw.ai: coral silhouette glow at 10% of - // size, teal glow at 15% plus scale 1.1 on hover. - OpenClawMascotView() - .frame(width: 160, height: 160) - .shadow( - color: OpenClawMascotView.heroGlowColor( - for: self.colorScheme, - hovering: self.iconHover), - radius: self.iconHover ? 24 : 16) - .scaleEffect(self.iconHover ? 1.1 : 1.0) - } - .buttonStyle(.plain) - .accessibilityLabel("OpenClaw on GitHub") - .focusable(false) - .pointingHandCursor() - .onHover { hover in - withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { self.iconHover = hover } - } VStack(spacing: 3) { Text("OpenClaw") diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift index b1642f991b0c..aa9144a4f22b 100644 --- a/apps/macos/Sources/OpenClaw/Onboarding.swift +++ b/apps/macos/Sources/OpenClaw/Onboarding.swift @@ -149,6 +149,7 @@ struct OnboardingView: View { let cliPageIndex = 2 let aiPageIndex = 3 let onboardingChatPageIndex = 8 + let readyPageIndex = 9 let permissionsPageIndex = 5 diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift index 5f9f6a3aa25b..f13194b638ea 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift @@ -7,7 +7,7 @@ extension OnboardingView { let contentHeight = self.contentHeight(for: windowGeometry.size.height) VStack(spacing: 0) { // Chat-heavy pages shrink the mascot so the content gets the room. - GlowingOpenClawIcon(size: self.heroSize) + GlowingOpenClawIcon(size: self.heroSize, mood: self.mascotMood) .offset(y: self.usesCompactHero ? 4 : 10) .frame(height: self.heroFrameHeight) .animation(.spring(response: 0.45, dampingFraction: 0.85), value: self.usesCompactHero) diff --git a/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift b/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift index 95a1fe97b069..05b803692fd4 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift @@ -1,22 +1,132 @@ import OpenClawChatUI +import OpenClawIPC import SwiftUI /// Onboarding hero mascot with the openclaw.ai hero treatment: the animated /// mascot plus its coral silhouette glow (drop-shadow at ~10% of size). +/// Interactive: it reacts to clicks and its eyes follow the pointer. struct GlowingOpenClawIcon: View { @Environment(\.colorScheme) private var colorScheme let size: CGFloat + let mood: OpenClawMascotMood - init(size: CGFloat = 148) { + init(size: CGFloat = 148, mood: OpenClawMascotMood = .idle) { self.size = size + self.mood = mood } var body: some View { - OpenClawMascotView() + OpenClawMascotView(mood: self.mood, interactive: true) .frame(width: self.size, height: self.size) .shadow( color: OpenClawMascotView.heroGlowColor(for: self.colorScheme), radius: self.size * 0.1) } } + +extension OnboardingView { + /// Onboarding page classes the mascot reacts to. + enum MascotPage { + case welcome + case connection + case cli + case ai + case permissions + case chat + case ready + } + + /// Flow state the mascot mood is derived from. + struct MascotMoodSnapshot { + var page: MascotPage + var installingCLI = false + var cliInstalled = false + var cliStatusKnown = false + var aiPhase: OnboardingAISetupModel.Phase = .idle + var aiBusy = false + var aiFailed = false + var remoteProbeState: RemoteOnboardingProbeState = .idle + var allPermissionsGranted = false + } + + /// The hero mascot mirrors what setup is doing: curious while choosing, + /// thinking while work is in flight, sad on failures, celebrating once + /// the AI answers and on the final page. + var mascotMood: OpenClawMascotMood { + Self.mascotMood(for: MascotMoodSnapshot( + page: self.mascotPage, + installingCLI: self.installingCLI, + cliInstalled: self.cliInstalled, + cliStatusKnown: self.cliStatusKnown, + aiPhase: self.aiSetup.phase, + aiBusy: self.aiSetup.isBusy, + aiFailed: Self.aiSetupLooksFailed(self.aiSetup), + remoteProbeState: self.remoteProbeState, + allPermissionsGranted: Capability.importanceOrdered + .allSatisfy { self.permissionMonitor.status[$0] ?? false })) + } + + private var mascotPage: MascotPage { + switch self.activePageIndex { + case self.connectionPageIndex: .connection + case self.cliPageIndex: .cli + case self.aiPageIndex: .ai + case self.permissionsPageIndex: .permissions + case self.onboardingChatPageIndex: .chat + case self.readyPageIndex: .ready + default: .welcome + } + } + + static func aiSetupLooksFailed(_ aiSetup: OnboardingAISetupModel) -> Bool { + guard !aiSetup.connected else { return false } + let candidateFailed = aiSetup.statuses.values.contains { status in + if case .failed = status { return true } + return false + } + return aiSetup.detectError != nil || + aiSetup.exhaustedAutoCandidates || + aiSetup.manualError != nil || + candidateFailed + } + + static func mascotMood(for snapshot: MascotMoodSnapshot) -> OpenClawMascotMood { + switch snapshot.page { + case .welcome: + .idle + case .connection: + switch snapshot.remoteProbeState { + case .checking: .thinking + case .failed: .sad + case .ok: .happy + case .idle: .curious + } + case .cli: + if snapshot.cliInstalled { + .happy + } else if snapshot.cliStatusKnown, !snapshot.installingCLI { + // Mirrors the page's install-failed card. + .sad + } else { + .thinking + } + case .ai: + if snapshot.aiPhase == .connected { + .celebrating + } else if snapshot.aiBusy { + .thinking + } else if snapshot.aiFailed { + .sad + } else { + .curious + } + case .permissions: + snapshot.allPermissionsGranted ? .happy : .curious + case .chat: + .attentive + case .ready: + .celebrating + } + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingMascotMoodTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingMascotMoodTests.swift new file mode 100644 index 000000000000..c08d270be578 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingMascotMoodTests.swift @@ -0,0 +1,54 @@ +import OpenClawChatUI +import Testing +@testable import OpenClaw + +@MainActor +struct OnboardingMascotMoodTests { + private func mood(_ snapshot: OnboardingView.MascotMoodSnapshot) -> OpenClawMascotMood { + OnboardingView.mascotMood(for: snapshot) + } + + @Test func `welcome page idles`() { + #expect(self.mood(.init(page: .welcome)) == .idle) + } + + @Test func `connection page follows probe`() { + #expect(self.mood(.init(page: .connection)) == .curious) + #expect(self.mood(.init(page: .connection, remoteProbeState: .checking)) == .thinking) + #expect(self.mood(.init(page: .connection, remoteProbeState: .failed("no route"))) == .sad) + #expect(self.mood(.init( + page: .connection, + remoteProbeState: .ok(RemoteGatewayProbeSuccess(authSource: nil)))) == .happy) + } + + @Test func `cli page tracks install lifecycle`() { + #expect(self.mood(.init(page: .cli, installingCLI: true)) == .thinking) + #expect(self.mood(.init(page: .cli)) == .thinking, "status probe still deciding") + #expect(self.mood(.init(page: .cli, cliStatusKnown: true)) == .sad, "install failed") + #expect(self.mood(.init(page: .cli, cliInstalled: true, cliStatusKnown: true)) == .happy) + } + + @Test func `ai page celebrates thinks and mourns`() { + #expect(self.mood(.init(page: .ai)) == .curious) + #expect(self.mood(.init(page: .ai, aiPhase: .testing, aiBusy: true)) == .thinking) + #expect(self.mood(.init(page: .ai, aiFailed: true)) == .sad) + #expect(self.mood(.init(page: .ai, aiPhase: .connected)) == .celebrating) + #expect( + self.mood(.init(page: .ai, aiPhase: .connected, aiFailed: true)) == .celebrating, + "a live connection outranks stale failures") + } + + @Test func `permissions page warms up when everything is granted`() { + #expect(self.mood(.init(page: .permissions)) == .curious) + #expect(self.mood(.init(page: .permissions, allPermissionsGranted: true)) == .happy) + } + + @Test func `chat and ready pages`() { + #expect(self.mood(.init(page: .chat)) == .attentive) + #expect(self.mood(.init(page: .ready)) == .celebrating) + } + + @Test func `fresh AI setup model does not look failed`() { + #expect(!OnboardingView.aiSetupLooksFailed(OnboardingAISetupModel())) + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotAnimator.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotAnimator.swift new file mode 100644 index 000000000000..973d43e793a1 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotAnimator.swift @@ -0,0 +1,653 @@ +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.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 { + min(max(self, range.lowerBound), range.upperBound) + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotView.swift index 0edade2eae31..903ca13da381 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotView.swift @@ -5,35 +5,84 @@ import SwiftUI /// animate like the openclaw.ai hero mark; the bundled PNG asset cannot. /// Styling (palette, glow colors, float depth) follows the openclaw.ai hero /// (`src/pages/index.astro` + `Layout.astro` theme variables). +/// +/// Beyond the site's loop, an `OpenClawMascotAnimator` layers on moods +/// (thinking, celebrating, sad, …), randomized micro-behaviors, and — when +/// `interactive` — click Easter eggs. Eyes follow the pointer on hover. public struct OpenClawMascotView: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.colorScheme) private var colorScheme + @State private var animator: OpenClawMascotAnimator private let floats: Bool + private let mood: OpenClawMascotMood + private let interactive: Bool - public init(floats: Bool = true) { + /// - Parameters: + /// - floats: allow whole-body vertical travel (float loop, hops). Turn + /// off in tight layouts; bounces then show as squash-and-stretch only. + /// - mood: emotional state; transitions play an entrance gesture. + /// - interactive: enables click reactions and auto-sleep (waking takes + /// a click). Off by default so the mascot never swallows taps meant + /// for an enclosing control. + public init( + floats: Bool = true, + mood: OpenClawMascotMood = .idle, + interactive: Bool = false) + { self.floats = floats + self.mood = mood + self.interactive = interactive + self._animator = State(initialValue: OpenClawMascotAnimator(allowsAutoSleep: interactive)) } public var body: some View { let palette = OpenClawMascotPalette.forScheme(self.colorScheme) if self.reduceMotion { - OpenClawMascotCanvas(pose: .still, palette: palette) + OpenClawMascotCanvas(pose: .staticPose(for: self.mood), palette: palette) } else { - TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in - let pose = OpenClawMascotPose.at(time: timeline.date.timeIntervalSinceReferenceDate) - // Float translates the whole canvas like the site floats the hero - // container; drawing the offset inside the canvas would clip the - // antennae (art starts at y~5 of 120) at the -9.6 float peak. - GeometryReader { proxy in - OpenClawMascotCanvas(pose: pose, palette: palette) - .offset( - y: self.floats - ? pose.floatOffset * min(proxy.size.width, proxy.size.height) / 120 - : 0) - } + self.animatedMascot(palette: palette) + } + } + + @ViewBuilder + private func animatedMascot(palette: OpenClawMascotPalette) -> some View { + let core = TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in + let pose = self.animator.pose(at: timeline.date.timeIntervalSinceReferenceDate) + // Float translates the whole canvas like the site floats the hero + // container; drawing the offset inside the canvas would clip the + // antennae (art starts at y~5 of 120) at the float/hop peak. + GeometryReader { proxy in + OpenClawMascotCanvas(pose: pose, palette: palette) + .offset( + y: self.floats + ? pose.floatOffset * min(proxy.size.width, proxy.size.height) / 120 + : 0) + .pointerTracking(size: proxy.size) { direction in + self.animator.setPointerTarget(direction, at: self.now()) + } } } + .onChange(of: self.mood) { _, newMood in + self.animator.setMood(newMood, at: self.now()) + } + .onAppear { + self.animator.setMood(self.mood, at: self.now()) + } + + if self.interactive { + core + .contentShape(Rectangle()) + .onTapGesture { + self.animator.handleTap(at: self.now()) + } + } else { + core + } + } + + private func now() -> TimeInterval { + Date().timeIntervalSinceReferenceDate } /// openclaw.ai hero drop-shadow color (`--logo-glow` / `--logo-glow-hover`). @@ -49,6 +98,31 @@ public struct OpenClawMascotView: View { } } +extension View { + /// Eye-tracking hover support where pointers exist; no-op elsewhere. + @ViewBuilder + fileprivate func pointerTracking( + size: CGSize, + onMove: @escaping (CGSize?) -> Void) -> some View + { + #if os(iOS) || os(macOS) + self.onContinuousHover(coordinateSpace: .local) { phase in + switch phase { + case let .active(point): + guard size.width > 0, size.height > 0 else { return } + onMove(CGSize( + width: (point.x - size.width / 2) / (size.width / 2), + height: (point.y - size.height / 2) / (size.height / 2))) + case .ended: + onMove(nil) + } + } + #else + self + #endif + } +} + /// Body/antenna colors from the openclaw.ai theme variables: `:root` (dark) /// and `html[data-theme='light']` in `Layout.astro`. Eye colors are fixed in /// the site markup and shared by both themes. @@ -72,60 +146,101 @@ struct OpenClawMascotPalette: Equatable { } } -/// Part transforms for one animation frame. Mirrors the openclaw.ai CSS -/// keyframes: float 4s, antenna wiggle 2s, eye blink 3s, claw snap 4s with -/// the right claw trailing by 0.2s. +/// Part transforms and expression channels for one animation frame, produced +/// by `OpenClawMascotAnimator` (or `staticPose` under Reduce Motion) and +/// consumed by `OpenClawMascotCanvas`. struct OpenClawMascotPose: Equatable { var floatOffset: CGFloat = 0 var antennaDegrees: CGFloat = 0 + /// 0..1: antennae fold outward and down (sadness, sneeze flop). + var antennaDroop: CGFloat = 0 var leftClawDegrees: CGFloat = 0 var rightClawDegrees: CGFloat = 0 var eyeGlowOpacity: CGFloat = 1 + var glowScale: CGFloat = 1 + var leftEyeOpenness: CGFloat = 1 + var rightEyeOpenness: CGFloat = 1 + /// 0..1: morph eyes into happy ∩ arcs. + var happyEyes: CGFloat = 0 + /// Unit-ish look direction; drawn as a small eye/glow shift. + var gaze: CGSize = .zero + /// -1 frown … +1 smile. + var mouthCurve: CGFloat = 0 + /// 0..1 open grin depth (takes over from `mouthCurve`). + var mouthOpen: CGFloat = 0 + /// 0..1 surprised/yawning "o" (takes over from both mouth channels). + var mouthRound: CGFloat = 0 + var blush: CGFloat = 0 + /// Degrees around the canvas center. + var bodyTilt: CGFloat = 0 + /// Vertical squash-and-stretch about the feet; x compensates slightly. + var bodyStretch: CGFloat = 1 + /// 0..1: glow dots orbit instead of shining — too many clicks. + var dizzy: CGFloat = 0 + var dizzyPhase: CGFloat = 0 + var effect: OpenClawMascotEffect = .none + var effectPhase: CGFloat = 0 static let still = OpenClawMascotPose() - static func at(time: TimeInterval) -> OpenClawMascotPose { - // Float depth matches the hero: -8px on a 100px mark = 8% of the 120 box. - OpenClawMascotPose( - floatOffset: -4.8 * (1 - cos(2 * .pi * self.cyclePhase(time, period: 4))), - antennaDegrees: -3 * sin(2 * .pi * self.cyclePhase(time, period: 2)), - leftClawDegrees: self.clawSnapDegrees(phase: self.cyclePhase(time, period: 4)), - rightClawDegrees: self.clawSnapDegrees(phase: self.cyclePhase(time - 0.2, period: 4)), - eyeGlowOpacity: self.blinkOpacity(phase: self.cyclePhase(time, period: 3))) - } - - private static func cyclePhase(_ time: TimeInterval, period: TimeInterval) -> CGFloat { - let normalized = (time / period).truncatingRemainder(dividingBy: 1) - return CGFloat(normalized < 0 ? normalized + 1 : normalized) - } - - private static func clawSnapDegrees(phase: CGFloat) -> CGFloat { - // 0deg until 85%, snap to -8deg at 90%, back to 0deg at 95%, hold. - if phase < 0.85 || phase >= 0.95 { - return 0 + /// Motionless expression per mood for Reduce Motion users. + static func staticPose(for mood: OpenClawMascotMood) -> OpenClawMascotPose { + var pose = OpenClawMascotPose() + switch mood { + case .idle, .curious, .attentive: + break + case .thinking: + pose.gaze = CGSize(width: 0.3, height: -0.5) + case .happy: + pose.mouthCurve = 0.6 + pose.happyEyes = 0.4 + case .celebrating: + pose.mouthCurve = 0.9 + pose.mouthOpen = 0.4 + pose.happyEyes = 0.8 + pose.leftClawDegrees = 30 + pose.rightClawDegrees = -30 + case .sad: + pose.antennaDroop = 0.75 + pose.mouthCurve = -0.55 + pose.eyeGlowOpacity = 0.6 + pose.gaze = CGSize(width: 0, height: 0.5) + case .sleepy: + pose.leftEyeOpenness = 0.25 + pose.rightEyeOpenness = 0.25 + pose.eyeGlowOpacity = 0.5 + pose.antennaDroop = 0.35 } - if phase < 0.9 { - return -8 * self.easeInOut((phase - 0.85) / 0.05) - } - return -8 * (1 - self.easeInOut((phase - 0.9) / 0.05)) + return pose } - private static func blinkOpacity(phase: CGFloat) -> CGFloat { - // Full glow until 90%, dip to 0.3 at 95%, recover by 100%. - if phase < 0.9 { - return 1 - } - let dip = phase < 0.95 ? self.easeInOut((phase - 0.9) / 0.05) : 1 - self.easeInOut((phase - 0.95) / 0.05) - return 1 - 0.7 * dip - } - - private static func easeInOut(_ t: CGFloat) -> CGFloat { - let clamped = min(max(t, 0), 1) - return clamped * clamped * (3 - 2 * clamped) + /// Keeps every channel inside the range the canvas can draw without + /// clipping the 120x120 art box (claws touch x=0/120, antennae y~5). + mutating func clampChannels() { + self.floatOffset = self.floatOffset.clamped(to: -12...2) + self.antennaDegrees = self.antennaDegrees.clamped(to: -14...14) + self.antennaDroop = self.antennaDroop.clamped(to: 0...1) + self.leftClawDegrees = self.leftClawDegrees.clamped(to: -45...45) + self.rightClawDegrees = self.rightClawDegrees.clamped(to: -45...45) + self.eyeGlowOpacity = self.eyeGlowOpacity.clamped(to: 0...1) + self.glowScale = self.glowScale.clamped(to: 0.5...1.6) + self.leftEyeOpenness = self.leftEyeOpenness.clamped(to: 0...1) + self.rightEyeOpenness = self.rightEyeOpenness.clamped(to: 0...1) + self.happyEyes = self.happyEyes.clamped(to: 0...1) + self.gaze.width = self.gaze.width.clamped(to: -1.2...1.2) + self.gaze.height = self.gaze.height.clamped(to: -1.2...1.2) + self.mouthCurve = self.mouthCurve.clamped(to: -1...1) + self.mouthOpen = self.mouthOpen.clamped(to: 0...1) + self.mouthRound = self.mouthRound.clamped(to: 0...1) + self.blush = self.blush.clamped(to: 0...1) + self.bodyTilt = self.bodyTilt.clamped(to: -8...8) + self.bodyStretch = self.bodyStretch.clamped(to: 0.86...1.05) + self.dizzy = self.dizzy.clamped(to: 0...1) } } -private struct OpenClawMascotCanvas: View { +/// Internal (not private) so tests and render harnesses can draw exact poses. +struct OpenClawMascotCanvas: View { let pose: OpenClawMascotPose let palette: OpenClawMascotPalette @@ -139,11 +254,15 @@ private struct OpenClawMascotCanvas: View { // Geometry below is the favicon.svg path data in its native 120x120 space. private static let eyeColor = Color(red: 5 / 255, green: 8 / 255, blue: 16 / 255) private static let eyeGlowColor = Color(red: 0, green: 229 / 255, blue: 204 / 255) + private static let blushColor = Color(red: 1, green: 0.62, blue: 0.68) + private static let heartColor = Color(red: 1, green: 0.45, blue: 0.55) // Rotation pivots: claws hinge on their body-facing edge, antennae on their own center. private static let leftClawPivot = CGPoint(x: 26, y: 53) private static let rightClawPivot = CGPoint(x: 94, y: 53) private static let leftAntennaPivot = CGPoint(x: 37.5, y: 11) private static let rightAntennaPivot = CGPoint(x: 82.5, y: 11) + private static let leftEyeCenter = CGPoint(x: 45, y: 35) + private static let rightEyeCenter = CGPoint(x: 75, y: 35) private static let bodyPath: Path = { var path = Path() @@ -206,10 +325,24 @@ private struct OpenClawMascotCanvas: View { let scale = min(size.width, size.height) / 120 context.scaleBy(x: scale, y: scale) + // Squash-and-stretch about the feet (y=110); x compensates slightly to + // conserve volume without pushing the claws past the 0/120 box edges. + if pose.bodyStretch != 1 { + let stretchX = (1 + (1 - pose.bodyStretch) * 0.5).clamped(to: 0.97...1.03) + context.translateBy(x: 60, y: 110) + context.scaleBy(x: stretchX, y: pose.bodyStretch) + context.translateBy(x: -60, y: -110) + } + if pose.bodyTilt != 0 { + context.translateBy(x: 60, y: 60) + context.rotate(by: .degrees(pose.bodyTilt)) + context.translateBy(x: -60, y: -60) + } + // Site antennae: stroke-width 2, `--coral-bright`. let antennaStroke = StrokeStyle(lineWidth: 2, lineCap: .round) - // Same paint order as favicon.svg: body, claws, antennae, eyes. + // Same paint order as favicon.svg: body, claws, antennae, then face. context.fill(self.bodyPath, with: self.gradient(for: self.bodyPath, palette: palette)) self.drawRotated(context: context, degrees: pose.leftClawDegrees, pivot: self.leftClawPivot) { $0.fill(self.leftClawPath, with: self.gradient(for: self.leftClawPath, palette: palette)) @@ -217,25 +350,241 @@ private struct OpenClawMascotCanvas: View { self.drawRotated(context: context, degrees: pose.rightClawDegrees, pivot: self.rightClawPivot) { $0.fill(self.rightClawPath, with: self.gradient(for: self.rightClawPath, palette: palette)) } - self.drawRotated(context: context, degrees: pose.antennaDegrees, pivot: self.leftAntennaPivot) { - $0.stroke(self.leftAntennaPath, with: .color(palette.antenna), style: antennaStroke) + // Droop folds each antenna down around its base (where it meets the + // head) — rotating around the stroke center reads startled, not sad — + // and damps the wiggle, which keeps its usual center pivot. + let wiggle = pose.antennaDegrees * (1 - pose.antennaDroop) + self.drawRotated( + context: context, + degrees: -pose.antennaDroop * 40, + pivot: CGPoint(x: 45, y: 15)) + { + self.drawRotated(context: $0, degrees: wiggle, pivot: self.leftAntennaPivot) { + $0.stroke(self.leftAntennaPath, with: .color(palette.antenna), style: antennaStroke) + } } - self.drawRotated(context: context, degrees: pose.antennaDegrees, pivot: self.rightAntennaPivot) { - $0.stroke(self.rightAntennaPath, with: .color(palette.antenna), style: antennaStroke) + self.drawRotated( + context: context, + degrees: pose.antennaDroop * 40, + pivot: CGPoint(x: 75, y: 15)) + { + self.drawRotated(context: $0, degrees: wiggle, pivot: self.rightAntennaPivot) { + $0.stroke(self.rightAntennaPath, with: .color(palette.antenna), style: antennaStroke) + } } - context.fill(Path(ellipseIn: CGRect(x: 39, y: 29, width: 12, height: 12)), with: .color(self.eyeColor)) - context.fill(Path(ellipseIn: CGRect(x: 69, y: 29, width: 12, height: 12)), with: .color(self.eyeColor)) + self.drawBlush(context: context, pose: pose) + self.drawEye(context: context, center: self.leftEyeCenter, openness: pose.leftEyeOpenness, pose: pose) + self.drawEye(context: context, center: self.rightEyeCenter, openness: pose.rightEyeOpenness, pose: pose) + self.drawMouth(context: context, pose: pose) + self.drawEffect(context: context, pose: pose, palette: palette) + } + + private static func drawEye( + context: GraphicsContext, + center: CGPoint, + openness: CGFloat, + pose: OpenClawMascotPose) + { + let shifted = CGPoint( + x: center.x + pose.gaze.width * 2.0, + y: center.y + pose.gaze.height * 1.5) + + // Open eye: the upper lid closes downward, so the visible sliver + // keeps its lower edge as openness shrinks. The ellipse also collapses + // while the happy arc fades in, so the crossfade reads as one morph. + if pose.happyEyes < 1 { + let height = max(1.2, 12 * openness * (1 - 0.6 * pose.happyEyes)) + let rect = CGRect( + x: shifted.x - 6, + y: shifted.y - 6 + (12 - height) * 0.65, + width: 12, + height: height) + var eyeContext = context + eyeContext.opacity = Double(1 - pose.happyEyes) + eyeContext.fill(Path(ellipseIn: rect), with: .color(self.eyeColor)) + } + + // Happy eyes: ∩ arcs replace the open ellipse. + if pose.happyEyes > 0 { + var arc = Path() + arc.move(to: CGPoint(x: shifted.x - 6, y: shifted.y + 2)) + arc.addQuadCurve( + to: CGPoint(x: shifted.x + 6, y: shifted.y + 2), + control: CGPoint(x: shifted.x, y: shifted.y - 5.5)) + var arcContext = context + arcContext.opacity = Double(pose.happyEyes) + arcContext.stroke( + arc, + with: .color(self.eyeColor), + style: StrokeStyle(lineWidth: 2.6, lineCap: .round)) + } + + if pose.dizzy > 0 { + // Orbiting dot — the classic seeing-stars spiral. + let angle = pose.dizzyPhase * 2 * .pi + (center.x > 60 ? .pi : 0) + let dot = CGPoint( + x: shifted.x + cos(angle) * 3.4, + y: shifted.y + sin(angle) * 2.6) + var dizzyContext = context + dizzyContext.opacity = Double(pose.dizzy) + dizzyContext.fill( + Path(ellipseIn: CGRect(x: dot.x - 1.8, y: dot.y - 1.8, width: 3.6, height: 3.6)), + with: .color(self.eyeGlowColor)) + } + + let glowVisibility = Double( + pose.eyeGlowOpacity * openness * (1 - pose.happyEyes) * (1 - pose.dizzy)) + guard glowVisibility > 0.01 else { return } + let glowRadius = 2 * pose.glowScale + let glowCenter = CGPoint( + x: shifted.x + 1 + pose.gaze.width * 1.2, + y: shifted.y - 1 + pose.gaze.height * 0.9) var glowContext = context - glowContext.opacity = pose.eyeGlowOpacity + glowContext.opacity = glowVisibility glowContext.fill( - Path(ellipseIn: CGRect(x: 44, y: 32, width: 4, height: 4)), - with: .color(self.eyeGlowColor)) - glowContext.fill( - Path(ellipseIn: CGRect(x: 74, y: 32, width: 4, height: 4)), + Path(ellipseIn: CGRect( + x: glowCenter.x - glowRadius, + y: glowCenter.y - glowRadius, + width: glowRadius * 2, + height: glowRadius * 2)), with: .color(self.eyeGlowColor)) } + private static func drawMouth(context: GraphicsContext, pose: OpenClawMascotPose) { + if pose.mouthRound > 0.05 { + let rx = 1 + 3.2 * pose.mouthRound + let ry = 1 + 4.2 * pose.mouthRound + context.fill( + Path(ellipseIn: CGRect(x: 60 - rx, y: 51 - ry, width: rx * 2, height: ry * 2)), + with: .color(self.eyeColor)) + return + } + if pose.mouthOpen > 0.05 { + var grin = Path() + grin.move(to: CGPoint(x: 52.5, y: 48.5)) + grin.addQuadCurve( + to: CGPoint(x: 67.5, y: 48.5), + control: CGPoint(x: 60, y: 48.5 + 14 * pose.mouthOpen)) + grin.closeSubpath() + context.fill(grin, with: .color(self.eyeColor)) + return + } + guard abs(pose.mouthCurve) > 0.05 else { return } + var curve = Path() + curve.move(to: CGPoint(x: 52.5, y: 49)) + curve.addQuadCurve( + to: CGPoint(x: 67.5, y: 49), + control: CGPoint(x: 60, y: 49 + 8 * pose.mouthCurve)) + context.stroke( + curve, + with: .color(self.eyeColor), + style: StrokeStyle(lineWidth: 2.2, lineCap: .round)) + } + + private static func drawBlush(context: GraphicsContext, pose: OpenClawMascotPose) { + guard pose.blush > 0.02 else { return } + var blushContext = context + blushContext.opacity = Double(pose.blush * 0.55) + for x: CGFloat in [37, 83] { + blushContext.fill( + Path(ellipseIn: CGRect(x: x - 4.5, y: 42.5, width: 9, height: 5)), + with: .color(self.blushColor)) + } + } + + private static func drawEffect( + context: GraphicsContext, + pose: OpenClawMascotPose, + palette: OpenClawMascotPalette) + { + switch pose.effect { + case .none: + return + case .sparkles: + for index in 0..<6 { + let phase = (pose.effectPhase + CGFloat(index) * 0.37) + .truncatingRemainder(dividingBy: 1) + let alpha = OpenClawMascotGesture.bell(phase) + guard alpha > 0.05 else { continue } + // Fan the stars across the upper hemisphere. + let angle = CGFloat.pi + CGFloat.pi * (CGFloat(index) + 0.5) / 6 + let center = CGPoint( + x: 60 + cos(angle) * (50 + CGFloat(index % 3) * 4), + y: 55 + sin(angle) * (40 + CGFloat((index * 5) % 3) * 4)) + var starContext = context + starContext.opacity = Double(alpha) + starContext.fill( + self.sparklePath(center: center, size: 2.5 + 2 * alpha), + with: .color(index.isMultiple(of: 2) ? self.eyeGlowColor : palette.antenna)) + } + case .hearts: + for index in 0..<3 { + let phase = (pose.effectPhase * 1.15 + CGFloat(index) / 3) + .truncatingRemainder(dividingBy: 1) + let alpha = phase < 0.15 ? phase / 0.15 : 1 - (phase - 0.15) / 0.85 + guard alpha > 0.05 else { continue } + // Rise from the shoulders outward so they never cover the eyes. + let center = CGPoint( + x: 60 + CGFloat(index - 1) * 26 + 4 * sin(phase * 2 * .pi + CGFloat(index)), + y: 30 - 28 * phase) + var heartContext = context + heartContext.opacity = Double(alpha) + heartContext.fill( + self.heartPath(center: center, size: 4.5 + CGFloat(index % 2) * 1.5), + with: .color(self.heartColor)) + } + case .zzz: + for index in 0..<3 { + let phase = (pose.effectPhase + CGFloat(index) * 0.33) + .truncatingRemainder(dividingBy: 1) + let alpha = phase < 0.2 ? phase / 0.2 : 1 - (phase - 0.2) / 0.8 + guard alpha > 0.05 else { continue } + let position = CGPoint( + x: 86 + 14 * phase + 2 * sin(phase * 4 * .pi), + y: 24 - 20 * phase) + var text = context.resolve( + Text("z") + .font(.system(size: 6 + 4 * phase, weight: .bold, design: .rounded))) + text.shading = .color(self.eyeGlowColor) + var zContext = context + zContext.opacity = Double(alpha * 0.9) + zContext.draw(text, at: position) + } + } + } + + /// Four-point sparkle: concave diamond built from quad curves. + private static func sparklePath(center: CGPoint, size: CGFloat) -> Path { + var path = Path() + path.move(to: CGPoint(x: center.x, y: center.y - size)) + for (dx, dy) in [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)] { + path.addQuadCurve( + to: CGPoint(x: center.x + size * dx, y: center.y + size * dy), + control: center) + } + path.closeSubpath() + return path + } + + /// Small heart: two lobes plus a point, forgiving at 4-6px sizes. + private static func heartPath(center: CGPoint, size: CGFloat) -> Path { + var path = Path() + let lobeRadius = size * 0.4 + for side: CGFloat in [-0.35, 0.35] { + path.addEllipse(in: CGRect( + x: center.x + side * size - lobeRadius, + y: center.y - size * 0.25 - lobeRadius, + width: lobeRadius * 2, + height: lobeRadius * 2)) + } + path.move(to: CGPoint(x: center.x - size * 0.7, y: center.y - size * 0.05)) + path.addLine(to: CGPoint(x: center.x + size * 0.7, y: center.y - size * 0.05)) + path.addLine(to: CGPoint(x: center.x, y: center.y + size * 0.75)) + path.closeSubpath() + return path + } + /// SVG gradients default to objectBoundingBox units, so the body and each /// claw span the full top-left -> bottom-right ramp across their own bounds; /// one canvas-wide gradient would leave the claws nearly flat-colored. diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/OpenClawMascotAnimatorTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/OpenClawMascotAnimatorTests.swift new file mode 100644 index 000000000000..1b4f2cf82ef0 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/OpenClawMascotAnimatorTests.swift @@ -0,0 +1,177 @@ +import Foundation +import Testing +@testable import OpenClawChatUI + +struct OpenClawMascotAnimatorTests { + private func makeAnimator(seed: UInt64 = 7, interactive: Bool = false) -> OpenClawMascotAnimator { + OpenClawMascotAnimator(seed: seed, hourOfDay: 12, allowsAutoSleep: interactive) + } + + @Test func `poses stay inside drawable bounds for every mood`() { + for mood in OpenClawMascotMood.allCases { + let animator = self.makeAnimator() + _ = animator.pose(at: 0) + animator.setMood(mood, at: 0) + var time: TimeInterval = 0 + while time < 30 { + let pose = animator.pose(at: time) + #expect(pose.floatOffset.isFinite, "\(mood)") + #expect((-12...2).contains(pose.floatOffset), "\(mood)") + #expect((0.86...1.05).contains(pose.bodyStretch), "\(mood)") + #expect((-8...8).contains(pose.bodyTilt), "\(mood)") + #expect((0...1).contains(pose.leftEyeOpenness), "\(mood)") + #expect((0...1).contains(pose.rightEyeOpenness), "\(mood)") + #expect((0...1).contains(pose.eyeGlowOpacity), "\(mood)") + #expect((-45...45).contains(pose.leftClawDegrees), "\(mood)") + #expect((-45...45).contains(pose.rightClawDegrees), "\(mood)") + #expect(abs(pose.gaze.width) <= 1.2 && abs(pose.gaze.height) <= 1.2, "\(mood)") + time += 1.0 / 30 + } + } + } + + @Test func `idle blinks are occasional not constant`() { + let animator = self.makeAnimator() + var minOpenness: CGFloat = 1 + var opennessSum: CGFloat = 0 + var samples = 0 + var time: TimeInterval = 0 + while time < 12 { + let pose = animator.pose(at: time) + minOpenness = min(minOpenness, pose.leftEyeOpenness) + opennessSum += pose.leftEyeOpenness + samples += 1 + time += 1.0 / 30 + } + #expect(minOpenness < 0.5, "expected at least one blink within 12s") + #expect(opennessSum / CGFloat(samples) > 0.8, "eyes should be mostly open") + } + + @Test func `celebrating entrance raises claws`() { + let animator = self.makeAnimator() + _ = animator.pose(at: 0) + animator.setMood(.celebrating, at: 1) + var raised = false + var time: TimeInterval = 1 + while time < 2.5 { + let pose = animator.pose(at: time) + if pose.leftClawDegrees > 15, pose.rightClawDegrees < -15 { + raised = true + } + time += 1.0 / 30 + } + #expect(raised) + } + + @Test func `sad mood droops and frowns`() { + let animator = self.makeAnimator() + _ = animator.pose(at: 0) + animator.setMood(.sad, at: 1) + let pose = animator.pose(at: 5) + #expect(pose.antennaDroop > 0.5) + #expect(pose.mouthCurve < 0) + #expect(pose.eyeGlowOpacity < 0.9) + } + + @Test func `affection taps trigger hearts`() { + let animator = self.makeAnimator() + _ = animator.pose(at: 0) + animator.handleTap(at: 1.0) + animator.handleTap(at: 1.3) + animator.handleTap(at: 1.6) + let pose = animator.pose(at: 2.2) + #expect(pose.effect == .hearts) + #expect(pose.blush > 0) + } + + @Test func `rapid taps make dizzy then recover`() { + let animator = self.makeAnimator() + _ = animator.pose(at: 0) + for index in 0..<7 { + animator.handleTap(at: 1.0 + Double(index) * 0.2) + } + let dizzyPose = animator.pose(at: 3.0) + #expect(dizzyPose.dizzy > 0.5) + let recoveredPose = animator.pose(at: 8.0) + #expect(recoveredPose.dizzy == 0) + } + + @Test func `interactive idle mascot dozes off and wakes on tap`() { + let animator = self.makeAnimator(interactive: true) + _ = animator.pose(at: 0) + // Max auto-sleep delay is 80s; 200s is safely asleep for any seed. + let sleeping = animator.pose(at: 200) + #expect(sleeping.leftEyeOpenness < 0.5) + #expect(sleeping.effect == .zzz) + animator.handleTap(at: 201) + let awake = animator.pose(at: 201.05) + #expect(awake.effect != .zzz) + #expect(awake.leftEyeOpenness > 0.5) + } + + @Test func `hovering does not wake a sleeping mascot`() { + let animator = self.makeAnimator(interactive: true) + _ = animator.pose(at: 0) + _ = animator.pose(at: 200) + animator.setPointerTarget(CGSize(width: 1, height: 0), at: 200.1) + let pose = animator.pose(at: 200.2) + #expect(pose.effect == .zzz) + } + + @Test func `non-interactive mascot never sleeps — it has no wake path`() { + let animator = self.makeAnimator(interactive: false) + _ = animator.pose(at: 0) + let pose = animator.pose(at: 500) + #expect(pose.effect != .zzz) + #expect(pose.leftEyeOpenness > 0.5) + } + + @Test func `pointer target steers gaze`() { + let animator = self.makeAnimator() + _ = animator.pose(at: 0) + animator.setPointerTarget(CGSize(width: 1, height: 0), at: 0.1) + var time: TimeInterval = 0.1 + while time < 2 { + _ = animator.pose(at: time) + time += 1.0 / 30 + } + let pose = animator.pose(at: 2) + #expect(pose.gaze.width > 0.6) + } + + @Test func `same seed produces identical behavior`() { + let first = self.makeAnimator(seed: 42) + let second = self.makeAnimator(seed: 42) + var time: TimeInterval = 0 + while time < 10 { + #expect(first.pose(at: time) == second.pose(at: time)) + time += 1.0 / 30 + } + } + + @Test func `static poses carry the mood signature`() { + let sad = OpenClawMascotPose.staticPose(for: .sad) + #expect(sad.antennaDroop > 0) + #expect(sad.mouthCurve < 0) + let celebrating = OpenClawMascotPose.staticPose(for: .celebrating) + #expect(celebrating.leftClawDegrees > 0) + #expect(celebrating.mouthCurve > 0) + let idle = OpenClawMascotPose.staticPose(for: .idle) + #expect(idle == .still) + } + + @Test func `clamp channels bounds every channel`() { + var pose = OpenClawMascotPose() + pose.floatOffset = -100 + pose.bodyStretch = 3 + pose.bodyTilt = -90 + pose.leftClawDegrees = 400 + pose.gaze = CGSize(width: 9, height: -9) + pose.clampChannels() + #expect(pose.floatOffset == -12) + #expect(pose.bodyStretch == 1.05) + #expect(pose.bodyTilt == -8) + #expect(pose.leftClawDegrees == 45) + #expect(pose.gaze == CGSize(width: 1.2, height: -1.2)) + } +}