Files

160 lines
6.6 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<MemojiExpression> = [.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()
}
}