Memoji character (11 expressions) on the engine's motion; Claude limit shows time to reset; ChatGPT weekly limit from Codex logs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user