mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-13 17:07:40 +00:00
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
This commit is contained in:
committed by
GitHub
parent
8cd092620d
commit
f9ebefba29
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -27,7 +27,7 @@ private struct OnboardingActivationCanvas<Content: View>: 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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -149,6 +149,7 @@ struct OnboardingView: View {
|
||||
let cliPageIndex = 2
|
||||
let aiPageIndex = 3
|
||||
let onboardingChatPageIndex = 8
|
||||
let readyPageIndex = 9
|
||||
|
||||
let permissionsPageIndex = 5
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user