import SwiftUI import AppKit // MARK: - Memoji character (BroV) // // The character is 11 pre-rendered expressions (Resources/memoji/*.png, 256×256 RGBA). // BotEngine still runs every animation (gaze, blink, squash, bounce, roll, emotes, // particles); here we only pick the expression for the engine's state and draw the // picture with the engine's transforms. Expression changes cross-fade in 220 ms; // blinks swap neutral → halfblink → blink → halfblink → neutral instantly. enum MemojiExpression: String, CaseIterable { case neutral, blink, halfblink, happy, thinking, question, concern, wink, speaking, focused, surprised } enum MemojiAssets { nonisolated(unsafe) private static var cache: [MemojiExpression: CGImage] = [:] private static let lock = NSLock() static func image(_ e: MemojiExpression) -> CGImage? { lock.lock(); defer { lock.unlock() } if let img = cache[e] { return img } guard let url = Bundle.main.url(forResource: e.rawValue, withExtension: "png", subdirectory: "memoji"), let src = CGImageSourceCreateWithURL(url as CFURL, nil), let img = CGImageSourceCreateImageAtIndex(src, 0, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary) else { return nil } cache[e] = img return img } /// Expression for an agent state (states.json from the kit). static func forState(_ s: BotState) -> MemojiExpression { switch s { case .idle: return .neutral case .working: return .focused case .thinking, .searching: return .thinking case .approval, .question: return .question case .error: return .concern case .finished: return .happy case .ratelimit, .dizzy: return .halfblink case .sleeping: return .blink } } /// Expression for an emote / transient eye override from the engine. static func forEye(_ e: EyeShape) -> MemojiExpression? { switch e { case .happy, .heart, .star: return .happy case .dot, .cup: return .surprised case .wink: return .wink case .line, .flat: return .concern case .closed: return .blink case .tired, .spiral: return .halfblink case .pill, .wide: return nil } } /// Faces whose eyes may be blinked by swapping in the neutral blink frames. static let blinkable: Set = [.neutral, .focused, .thinking, .question, .concern, .speaking] } /// Cross-fade bookkeeping, one per drawn character. @MainActor final class MemojiFader { private(set) var current: MemojiExpression = .neutral private(set) var previous: MemojiExpression? private var changedAt: Double = 0 static let fade: Double = 0.22 /// Returns (previous, current, progress 0…1). func step(to e: MemojiExpression, now: Double) -> (MemojiExpression?, MemojiExpression, Double) { if e != current { previous = current current = e changedAt = now } let p = min(1, (now - changedAt) / Self.fade) if p >= 1 { previous = nil } return (previous, current, p) } } extension BotEngine { /// The face to show right now, before blinking. var memojiBase: MemojiExpression { if let eye = eyeOverride, let e = MemojiAssets.forEye(eye) { return e } if isDancing && dancingLevel > 0.15 { return .happy } return MemojiAssets.forState(state) } /// Draws the Memoji with the engine's current pose. Call after `update(dt:)`. func drawMemoji(context: GraphicsContext, size: CGSize, fader: MemojiFader, opacity: Double = 1) { guard opacity > 0.01 else { return } let W = size.width let R = W * 0.3 let center = bodyCenter(size: size) // Expression + blink frames. var face = memojiBase if MemojiAssets.blinkable.contains(face) { if open < 0.35 { face = .blink } else if open < 0.8 { face = .halfblink } } let isBlinkFrame = face == .blink || face == .halfblink let now = CACurrentMediaTime() // Blinks don't cross-fade (they'd look like a dissolve, not a blink). let (prev, cur, p) = isBlinkFrame && MemojiAssets.blinkable.contains(memojiBase) ? (nil, face, 1.0) : fader.step(to: face, now: now) var c = context c.opacity = opacity c.translateBy(x: center.x, y: center.y) // Head turn: slide toward the gaze and narrow a little, like turning a sphere. c.translateBy(x: yaw * R * 0.18, y: -pitch * R * 0.12) c.rotate(by: .radians(tilt + roll)) c.scaleBy(x: sx * (1 - abs(yaw) * 0.10), y: sy * (1 - abs(pitch) * 0.06)) // Status tint as a soft glow behind the head. if tint > 0.02 { let glow = Color(red: col.0, green: col.1, blue: col.2) let gr = R * 1.45 c.fill(Path(ellipseIn: CGRect(x: -gr, y: -gr * 0.9, width: gr * 2, height: gr * 2)), with: .radialGradient(Gradient(colors: [glow.opacity(0.45 * Double(tint)), glow.opacity(0)]), center: .zero, startRadius: 0, endRadius: gr)) } let side = R * 2.9 * es.clamped(0.9, 1.3) let rect = CGRect(x: -side / 2, y: -side * 0.52, width: side, height: side) if let prev, p < 1, let img = MemojiAssets.image(prev) { var pc = c pc.opacity = opacity * (1 - p) pc.draw(Image(decorative: img, scale: 1), in: rect) } if let img = MemojiAssets.image(cur) { var cc = c cc.opacity = opacity * (prev == nil ? 1 : p) cc.draw(Image(decorative: img, scale: 1), in: rect) } } } private extension CGFloat { func clamped(_ lo: CGFloat, _ hi: CGFloat) -> CGFloat { Swift.min(hi, Swift.max(lo, self)) } } // MARK: - Plain CGContext drawing (greeting scene) enum MemojiCG { /// Draws an expression centred at `center` with height `side` into a y-down CGContext. static func draw(_ e: MemojiExpression, in ctx: CGContext, center: CGPoint, side: CGFloat, tilt: CGFloat = 0, sx: CGFloat = 1, sy: CGFloat = 1, alpha: CGFloat = 1) { guard let img = MemojiAssets.image(e), alpha > 0.01 else { return } ctx.saveGState() ctx.setAlpha(alpha) ctx.translateBy(x: center.x, y: center.y) ctx.rotate(by: tilt) ctx.scaleBy(x: sx, y: -sy) // flip: CGImage draws y-up ctx.draw(img, in: CGRect(x: -side / 2, y: -side * 0.48, width: side, height: side)) ctx.restoreGState() } }