From 6f27f742fe7064874ed0cb02b27b8218edeab3c3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 6 Dec 2025 00:49:30 +0100 Subject: [PATCH] feat(mac): add critter ear/leg wiggles --- apps/macos/Sources/Clawdis/AppMain.swift | 76 +++++++++++++++++++++--- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/apps/macos/Sources/Clawdis/AppMain.swift b/apps/macos/Sources/Clawdis/AppMain.swift index 905f98c10..c733da57b 100644 --- a/apps/macos/Sources/Clawdis/AppMain.swift +++ b/apps/macos/Sources/Clawdis/AppMain.swift @@ -404,10 +404,18 @@ private struct CritterStatusLabel: View { @State private var wiggleAngle: Double = 0 @State private var wiggleOffset: CGFloat = 0 @State private var nextWiggle = Date().addingTimeInterval(Double.random(in: 6.5 ... 14)) + @State private var legWiggle: CGFloat = 0 + @State private var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0 ... 11.0)) + @State private var earWiggle: CGFloat = 0 + @State private var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0 ... 14.0)) private let ticker = Timer.publish(every: 0.35, on: .main, in: .common).autoconnect() var body: some View { - Image(nsImage: CritterIconRenderer.makeIcon(blink: blinkAmount)) + Image(nsImage: CritterIconRenderer.makeIcon( + blink: blinkAmount, + legWiggle: legWiggle, + earWiggle: earWiggle + )) .renderingMode(.template) .frame(width: 18, height: 16) .rotationEffect(.degrees(wiggleAngle), anchor: .center) @@ -429,6 +437,16 @@ private struct CritterStatusLabel: View { wiggle() nextWiggle = now.addingTimeInterval(Double.random(in: 6.5 ... 14)) } + + if now >= nextLegWiggle { + wiggleLegs() + nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0 ... 11.0)) + } + + if now >= nextEarWiggle { + wiggleEars() + nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0 ... 14.0)) + } } .onChange(of: isPaused) { _, paused in if paused { @@ -436,6 +454,8 @@ private struct CritterStatusLabel: View { } else { nextBlink = Date().addingTimeInterval(Double.random(in: 1.5 ... 3.5)) nextWiggle = Date().addingTimeInterval(Double.random(in: 4.5 ... 9.5)) + nextLegWiggle = Date().addingTimeInterval(Double.random(in: 4.0 ... 8.0)) + nextEarWiggle = Date().addingTimeInterval(Double.random(in: 5.5 ... 10.5)) } } } @@ -444,6 +464,8 @@ private struct CritterStatusLabel: View { blinkAmount = 0 wiggleAngle = 0 wiggleOffset = 0 + legWiggle = 0 + earWiggle = 0 } private func blink() { @@ -467,12 +489,32 @@ private struct CritterStatusLabel: View { } } } + + private func wiggleLegs() { + let target = CGFloat.random(in: 0.35 ... 0.9) + withAnimation(.easeInOut(duration: 0.14)) { + legWiggle = target + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { + withAnimation(.easeOut(duration: 0.18)) { legWiggle = 0 } + } + } + + private func wiggleEars() { + let target = CGFloat.random(in: -1.2 ... 1.2) + withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { + earWiggle = target + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) { + withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { earWiggle = 0 } + } + } } enum CritterIconRenderer { private static let size = NSSize(width: 18, height: 16) - static func makeIcon(blink: CGFloat) -> NSImage { + static func makeIcon(blink: CGFloat, legWiggle: CGFloat = 0, earWiggle: CGFloat = 0) -> NSImage { let image = NSImage(size: size) image.lockFocus() defer { image.unlockFocus() } @@ -489,7 +531,7 @@ enum CritterIconRenderer { let bodyCorner = w * 0.09 let earW = w * 0.22 - let earH = bodyH * 0.66 + let earH = bodyH * 0.66 * (1 - 0.08 * abs(earWiggle)) let earCorner = earW * 0.24 let legW = w * 0.11 @@ -497,7 +539,8 @@ enum CritterIconRenderer { let legSpacing = w * 0.085 let legsWidth = 4 * legW + 3 * legSpacing let legStartX = (w - legsWidth) / 2 - let legY = bodyY - legH + h * 0.05 + let legLift = legH * 0.35 * legWiggle + let legYBase = bodyY - legH + h * 0.05 let eyeOpen = max(0.05, 1 - blink) let eyeW = bodyW * 0.2 @@ -509,13 +552,28 @@ enum CritterIconRenderer { // Body ctx.addPath(CGPath(roundedRect: CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH), cornerWidth: bodyCorner, cornerHeight: bodyCorner, transform: nil)) - // Ears - ctx.addPath(CGPath(roundedRect: CGRect(x: bodyX - earW * 0.55, y: bodyY + bodyH * 0.08, width: earW, height: earH), cornerWidth: earCorner, cornerHeight: earCorner, transform: nil)) - ctx.addPath(CGPath(roundedRect: CGRect(x: bodyX + bodyW - earW * 0.45, y: bodyY + bodyH * 0.08, width: earW, height: earH), cornerWidth: earCorner, cornerHeight: earCorner, transform: nil)) + // Ears (tiny wiggle) + ctx.addPath(CGPath(roundedRect: CGRect( + x: bodyX - earW * 0.55 + earWiggle, + y: bodyY + bodyH * 0.08 + earWiggle * 0.4, + width: earW, + height: earH), + cornerWidth: earCorner, + cornerHeight: earCorner, + transform: nil)) + ctx.addPath(CGPath(roundedRect: CGRect( + x: bodyX + bodyW - earW * 0.45 - earWiggle, + y: bodyY + bodyH * 0.08 - earWiggle * 0.4, + width: earW, + height: earH), + cornerWidth: earCorner, + cornerHeight: earCorner, + transform: nil)) // Legs for i in 0 ..< 4 { let x = legStartX + CGFloat(i) * (legW + legSpacing) - let rect = CGRect(x: x, y: legY, width: legW, height: legH) + let lift = (i % 2 == 0 ? legLift : -legLift) + let rect = CGRect(x: x, y: legYBase + lift, width: legW, height: legH * (1 - 0.12 * legWiggle)) ctx.addPath(CGPath(roundedRect: rect, cornerWidth: legW * 0.34, cornerHeight: legW * 0.34, transform: nil)) } ctx.fillPath() @@ -1080,7 +1138,7 @@ final class OnboardingController { let hosting = NSHostingController(rootView: OnboardingView()) let window = NSWindow(contentViewController: hosting) window.title = "Welcome to Clawdis" - window.setContentSize(NSSize(width: 640, height: 520)) + window.setContentSize(NSSize(width: 540, height: 420)) window.styleMask = [.titled, .closable] window.center() window.makeKeyAndOrderFront(nil)