Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotView.swift
2026-07-11 23:51:21 -07:00

613 lines
26 KiB
Swift

import SwiftUI
/// Animated OpenClaw mascot. Redraws the canonical 120x120 vector from
/// `ui/public/favicon.svg` so individual parts (claws, antennae, eyes) can
/// 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
/// - 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: .staticPose(for: self.mood), palette: palette)
} else {
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`).
/// Pair with a shadow radius of ~10% of the mascot size (15% while hovering)
/// to match the site's `drop-shadow(0 0 20px)` on a 100px mark.
public static func heroGlowColor(for colorScheme: ColorScheme, hovering: Bool = false) -> Color {
switch (colorScheme, hovering) {
case (.light, false): Color(red: 239 / 255, green: 75 / 255, blue: 88 / 255).opacity(0.2)
case (.light, true): Color(red: 0, green: 143 / 255, blue: 135 / 255).opacity(0.35)
case (_, false): Color(red: 1, green: 77 / 255, blue: 77 / 255).opacity(0.4)
case (_, true): Color(red: 0, green: 229 / 255, blue: 204 / 255).opacity(0.6)
}
}
}
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.
struct OpenClawMascotPalette: Equatable {
let gradientTop: Color
let gradientBottom: Color
let antenna: Color
static let dark = OpenClawMascotPalette(
gradientTop: Color(red: 1, green: 77 / 255, blue: 77 / 255),
gradientBottom: Color(red: 153 / 255, green: 27 / 255, blue: 27 / 255),
antenna: Color(red: 1, green: 77 / 255, blue: 77 / 255))
static let light = OpenClawMascotPalette(
gradientTop: Color(red: 255 / 255, green: 112 / 255, blue: 121 / 255),
gradientBottom: Color(red: 234 / 255, green: 76 / 255, blue: 89 / 255),
antenna: Color(red: 239 / 255, green: 75 / 255, blue: 88 / 255))
static func forScheme(_ colorScheme: ColorScheme) -> OpenClawMascotPalette {
colorScheme == .light ? .light : .dark
}
}
/// 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
/// 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
}
return pose
}
/// 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)
}
}
/// Internal (not private) so tests and render harnesses can draw exact poses.
struct OpenClawMascotCanvas: View {
let pose: OpenClawMascotPose
let palette: OpenClawMascotPalette
var body: some View {
Canvas { context, size in
Self.draw(context: &context, size: size, pose: self.pose, palette: self.palette)
}
.accessibilityHidden(true)
}
// 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()
path.move(to: CGPoint(x: 60, y: 10))
path.addCurve(to: CGPoint(x: 15, y: 55), control1: CGPoint(x: 30, y: 10), control2: CGPoint(x: 15, y: 35))
path.addCurve(to: CGPoint(x: 45, y: 100), control1: CGPoint(x: 15, y: 75), control2: CGPoint(x: 30, y: 95))
path.addLine(to: CGPoint(x: 45, y: 110))
path.addLine(to: CGPoint(x: 55, y: 110))
path.addLine(to: CGPoint(x: 55, y: 100))
path.addCurve(to: CGPoint(x: 65, y: 100), control1: CGPoint(x: 55, y: 100), control2: CGPoint(x: 60, y: 102))
path.addLine(to: CGPoint(x: 65, y: 110))
path.addLine(to: CGPoint(x: 75, y: 110))
path.addLine(to: CGPoint(x: 75, y: 100))
path.addCurve(to: CGPoint(x: 105, y: 55), control1: CGPoint(x: 90, y: 95), control2: CGPoint(x: 105, y: 75))
path.addCurve(to: CGPoint(x: 60, y: 10), control1: CGPoint(x: 105, y: 35), control2: CGPoint(x: 90, y: 10))
path.closeSubpath()
return path
}()
private static let leftClawPath: Path = {
var path = Path()
path.move(to: CGPoint(x: 20, y: 45))
path.addCurve(to: CGPoint(x: 5, y: 60), control1: CGPoint(x: 5, y: 40), control2: CGPoint(x: 0, y: 50))
path.addCurve(to: CGPoint(x: 25, y: 55), control1: CGPoint(x: 10, y: 70), control2: CGPoint(x: 20, y: 65))
path.addCurve(to: CGPoint(x: 20, y: 45), control1: CGPoint(x: 28, y: 48), control2: CGPoint(x: 25, y: 45))
path.closeSubpath()
return path
}()
private static let rightClawPath: Path = {
var path = Path()
path.move(to: CGPoint(x: 100, y: 45))
path.addCurve(to: CGPoint(x: 115, y: 60), control1: CGPoint(x: 115, y: 40), control2: CGPoint(x: 120, y: 50))
path.addCurve(to: CGPoint(x: 95, y: 55), control1: CGPoint(x: 110, y: 70), control2: CGPoint(x: 100, y: 65))
path.addCurve(to: CGPoint(x: 100, y: 45), control1: CGPoint(x: 92, y: 48), control2: CGPoint(x: 95, y: 45))
path.closeSubpath()
return path
}()
private static let leftAntennaPath: Path = {
var path = Path()
path.move(to: CGPoint(x: 45, y: 15))
path.addQuadCurve(to: CGPoint(x: 30, y: 8), control: CGPoint(x: 35, y: 5))
return path
}()
private static let rightAntennaPath: Path = {
var path = Path()
path.move(to: CGPoint(x: 75, y: 15))
path.addQuadCurve(to: CGPoint(x: 90, y: 8), control: CGPoint(x: 85, y: 5))
return path
}()
private static func draw(
context: inout GraphicsContext,
size: CGSize,
pose: OpenClawMascotPose,
palette: OpenClawMascotPalette)
{
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, 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))
}
self.drawRotated(context: context, degrees: pose.rightClawDegrees, pivot: self.rightClawPivot) {
$0.fill(self.rightClawPath, with: self.gradient(for: self.rightClawPath, palette: palette))
}
// 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.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)
}
}
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 = glowVisibility
glowContext.fill(
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.
private static func gradient(
for path: Path,
palette: OpenClawMascotPalette) -> GraphicsContext.Shading
{
let box = path.boundingRect
return .linearGradient(
Gradient(colors: [palette.gradientTop, palette.gradientBottom]),
startPoint: box.origin,
endPoint: CGPoint(x: box.maxX, y: box.maxY))
}
private static func drawRotated(
context: GraphicsContext,
degrees: CGFloat,
pivot: CGPoint,
draw: (inout GraphicsContext) -> Void)
{
var rotated = context
rotated.translateBy(x: pivot.x, y: pivot.y)
rotated.rotate(by: .degrees(degrees))
rotated.translateBy(x: -pivot.x, y: -pivot.y)
draw(&rotated)
}
}