Files
brov-macbook/NotchBuddy/Sources/CoucouKit/MochiStill.swift
T
2026-10-06 19:34:32 +03:00

90 lines
3.4 KiB
Swift

import SwiftUI
/// Mochi in a fixed pose: one frame of BotEngine, no animation, no timer.
/// For surfaces that can't animate (widgets, lists on the iPhone). The Mac
/// island keeps its animated BotCanvasView / MiniBotCanvasView.
struct MochiStill: View {
var state: BotState = .idle
/// Overrides the state's eyes (e.g. .closed for a sleeping agent).
var eye: EyeShape? = nil
/// Body color; white by default, like the mockups.
var bodyHex: String = "#FFFFFF"
var showBadge: Bool = true
/// Where he looks and how his eyes are, for a still that isn't always the same.
var pose: MochiPose = .neutral
var body: some View {
Canvas { context, size in
let engine = BotEngine()
engine.isMini = true
engine.bodyColor = cgColorFromHex(bodyHex)
engine.state = state
engine.cfg = BotStates[state]!
engine.yaw = pose.yaw
engine.pitch = pose.pitch
engine.tilt = pose.tilt
engine.open = pose.open
if let eye = eye ?? pose.eye {
engine.permanentEye = eye
engine.eyeOverride = eye
engine.eyeOverrideUntil = .greatestFiniteMagnitude
}
engine.draw(context: context, size: size)
if showBadge, let badge = engine.cfg.badge {
engine.badge = badge
engine.badgeS = 1
engine.drawHandsAndExtras(context: context, size: size)
}
}
.aspectRatio(1, contentMode: .fit)
}
}
/// One frame of the little life the Mac's mini Mochi have: looking around,
/// blinking, a happy squint. Widgets can't animate, so each refresh of the
/// timeline picks a new pose per Mochi; the system cross-fades between them.
struct MochiPose: Equatable, Sendable {
var yaw: CGFloat = 0
var pitch: CGFloat = 0
var tilt: CGFloat = 0
var open: CGFloat = 1
var eye: EyeShape? = nil
static let neutral = MochiPose()
static let idlePoses: [MochiPose] = [
MochiPose(),
MochiPose(yaw: -0.45),
MochiPose(yaw: 0.45),
MochiPose(yaw: -0.3, pitch: -0.25),
MochiPose(yaw: 0.35, pitch: 0.2, tilt: 0.08),
MochiPose(pitch: -0.3, tilt: -0.06),
MochiPose(eye: .closed), // blink
MochiPose(yaw: 0.2, eye: .happy),
MochiPose(yaw: -0.5, tilt: -0.1),
MochiPose(yaw: 0.15, pitch: 0.25),
]
/// A pose that differs per Mochi and per tick, the same every time for a
/// given pair (so a widget redraw doesn't jump).
static func idle(id: String, tick: Int) -> MochiPose {
var hash: UInt64 = 5381
for byte in id.utf8 { hash = (hash &* 33) &+ UInt64(byte) }
hash = (hash &* 31) &+ UInt64(truncatingIfNeeded: tick &* 7919)
hash ^= hash >> 13
return idlePoses[Int(hash % UInt64(idlePoses.count))]
}
}
extension Color {
/// Background for a white Mochi on an agent's color. Very light colors
/// (VS Code's is near-white) would hide him, so they get a dark gray tile.
static func mochiTile(hex: String) -> Color {
guard let c = cgColorFromHex(hex), let comps = c.components, comps.count >= 3 else {
return Color(hex: hex)
}
let luminance = 0.2126 * comps[0] + 0.7152 * comps[1] + 0.0722 * comps[2]
return luminance > 0.7 ? Color(white: 0.32) : Color(hex: hex)
}
}