Files
2026-10-06 19:53:47 +03:00

1868 lines
79 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 Foundation
import CoreGraphics
import QuartzCore
import SwiftUI
// MARK: - Easing functions (same as prototype: E.out, E.inOut, E.back, E.lin)
enum Ease {
static func out(_ t: CGFloat) -> CGFloat { 1 - pow(1 - t, 3) }
static func inOut(_ t: CGFloat) -> CGFloat { t < 0.5 ? 4*t*t*t : 1 - pow(-2*t+2, 3)/2 }
static func back(_ t: CGFloat) -> CGFloat { let c1: CGFloat = 1.7; let c3 = c1+1; return 1+c3*pow(t-1,3)+c1*pow(t-1,2) }
static func lin(_ t: CGFloat) -> CGFloat { t }
}
// MARK: - Tween key: [target, duration_ms, easing]
struct TweenKey {
let target: CGFloat
let duration: CGFloat // milliseconds
let ease: (CGFloat) -> CGFloat
}
struct Tween {
let property: String
var keys: [TweenKey]
var keyIndex: Int = 0
var from: CGFloat
var startTime: Double // CACurrentMediaTime() * 1000
var onComplete: (() -> Void)? = nil
}
// MARK: - Particle
struct Particle {
enum ParticleType { case heart, star, spark, sweat, z }
var type: ParticleType
var x, y, vx, vy: CGFloat
var age: Double // seconds
var life: Double
var rot: CGFloat
var size: CGFloat
}
// MARK: - Bot state config (mirrors STATES in prototype)
struct BotStateCfg {
let color: CGColor
let tint: CGFloat
let eye: EyeShape
let badge: BadgeType?
let badgeColor: CGColor
let glow: CGColor
let glowOpacity: CGFloat
let bounces: Bool
let scans: Bool
let breathes: Bool
let zz: Bool
let sweat: Bool
let look: CGPoint? // fixed look direction
let tilt: CGFloat
let sound: String?
}
enum EyeShape: String {
case pill, wide, dot, line, flat, happy, closed, spiral, heart, star, tired, wink, cup
}
enum BadgeType {
case dots(CGColor)
case bang(CGColor)
case question(CGColor)
case dot(CGColor)
}
// MARK: - Mochi track constants (from PISTES.mochi)
enum MochiConst {
static let eyeW: CGFloat = 0.25
static let eyeH: CGFloat = 0.27
static let eyeSp: CGFloat = 0.37
static let eyeP: CGFloat = -0.12
// BroV: swamp-zombie skin and dried-blood ink.
static let baseTop = CGColor(red: 0.659, green: 0.749, blue: 0.416, alpha: 1) // #A8BF6A
static let baseBottom = CGColor(red: 0.357, green: 0.451, blue: 0.251, alpha: 1) // #5B7340
static let ink = CGColor(red: 0.165, green: 0.039, blue: 0.039, alpha: 1) // #2A0A0A
static let miniInk = CGColor(red: 0.063, green: 0.075, blue: 0.102, alpha: 1) // #10131A
}
// MARK: - Bot state configs
let BotStates: [BotState: BotStateCfg] = [
.idle: BotStateCfg(
color: CGColor(red:0.902,green:0.914,blue:0.933,alpha:1), tint:0,
eye:.pill, badge:nil,
badgeColor: .white, glow: CGColor(red:1,green:1,blue:1,alpha:0.35), glowOpacity:0.25,
bounces:false, scans:false, breathes:false, zz:false, sweat:false,
look:nil, tilt:0, sound:nil),
.working: BotStateCfg(
color: CGColor(red:0.231,green:0.620,blue:1,alpha:1), tint:0.72,
eye:.pill, badge:.dots(CGColor(red:0.231,green:0.620,blue:1,alpha:1)),
badgeColor: CGColor(red:0.231,green:0.620,blue:1,alpha:1),
glow: CGColor(red:0.231,green:0.620,blue:1,alpha:1), glowOpacity:0.55,
bounces:false, scans:false, breathes:false, zz:false, sweat:false,
look:nil, tilt:0, sound:"work"),
.thinking: BotStateCfg(
color: CGColor(red:0.545,green:0.361,blue:0.965,alpha:1), tint:0.72,
eye:.pill, badge:.dots(CGColor(red:0.545,green:0.361,blue:0.965,alpha:1)),
badgeColor: CGColor(red:0.545,green:0.361,blue:0.965,alpha:1),
glow: CGColor(red:0.545,green:0.361,blue:0.965,alpha:1), glowOpacity:0.5,
bounces:false, scans:false, breathes:false, zz:false, sweat:false,
look: CGPoint(x:0.55, y:0.55), tilt:0, sound:"think"),
.searching: BotStateCfg(
color: CGColor(red:0.388,green:0.396,blue:0.949,alpha:1), tint:0.72,
eye:.pill, badge:.dots(CGColor(red:0.388,green:0.396,blue:0.949,alpha:1)),
badgeColor: CGColor(red:0.388,green:0.396,blue:0.949,alpha:1),
glow: CGColor(red:0.388,green:0.396,blue:0.949,alpha:1), glowOpacity:0.55,
bounces:false, scans:true, breathes:false, zz:false, sweat:false,
look:nil, tilt:0, sound:"search"),
.approval: BotStateCfg(
color: CGColor(red:0.961,green:0.647,blue:0.141,alpha:1), tint:0.78,
eye:.wide, badge:.bang(CGColor(red:0.961,green:0.647,blue:0.141,alpha:1)),
badgeColor: CGColor(red:0.961,green:0.647,blue:0.141,alpha:1),
glow: CGColor(red:0.961,green:0.647,blue:0.141,alpha:1), glowOpacity:0.6,
bounces:true, scans:false, breathes:false, zz:false, sweat:false,
look:nil, tilt:0, sound:"approval"),
.question: BotStateCfg(
color: CGColor(red:0.133,green:0.827,blue:0.933,alpha:1), tint:0.75,
eye:.pill, badge:.question(CGColor(red:0.133,green:0.827,blue:0.933,alpha:1)),
badgeColor: CGColor(red:0.133,green:0.827,blue:0.933,alpha:1),
glow: CGColor(red:0.133,green:0.827,blue:0.933,alpha:1), glowOpacity:0.55,
bounces:false, scans:false, breathes:false, zz:false, sweat:false,
look:nil, tilt:0.17, sound:"question"),
.error: BotStateCfg(
color: CGColor(red:0.957,green:0.314,blue:0.369,alpha:1), tint:0.78,
eye:.flat, badge:.dot(CGColor(red:0.957,green:0.314,blue:0.369,alpha:1)),
badgeColor: CGColor(red:0.957,green:0.314,blue:0.369,alpha:1),
glow: CGColor(red:0.957,green:0.314,blue:0.369,alpha:1), glowOpacity:0.55,
bounces:false, scans:false, breathes:false, zz:false, sweat:false,
look:nil, tilt:0, sound:"error"),
.finished: BotStateCfg(
color: CGColor(red:0.204,green:0.831,blue:0.600,alpha:1), tint:0.35,
eye:.happy, badge:.dot(CGColor(red:0.204,green:0.831,blue:0.600,alpha:1)),
badgeColor: CGColor(red:0.204,green:0.831,blue:0.600,alpha:1),
glow: CGColor(red:0.204,green:0.831,blue:0.600,alpha:1), glowOpacity:0.5,
bounces:false, scans:false, breathes:false, zz:false, sweat:false,
look:nil, tilt:0, sound:"finish"),
.ratelimit: BotStateCfg(
color: CGColor(red:0.984,green:0.573,blue:0.235,alpha:1), tint:0.72,
eye:.tired, badge:.dot(CGColor(red:0.984,green:0.573,blue:0.235,alpha:1)),
badgeColor: CGColor(red:0.984,green:0.573,blue:0.235,alpha:1),
glow: CGColor(red:0.984,green:0.573,blue:0.235,alpha:1), glowOpacity:0.45,
bounces:false, scans:false, breathes:false, zz:false, sweat:true,
look:nil, tilt:0, sound:"rate"),
.sleeping: BotStateCfg(
color: CGColor(red:0.580,green:0.635,blue:0.722,alpha:1), tint:0.32,
eye:.closed, badge:nil,
badgeColor: .white,
glow: CGColor(red:0.580,green:0.635,blue:0.722,alpha:1), glowOpacity:0.2,
bounces:false, scans:false, breathes:true, zz:true, sweat:false,
look:nil, tilt:0, sound:"sleep"),
.dizzy: BotStateCfg(
color: CGColor(red:0.957,green:0.447,blue:0.714,alpha:1), tint:0.7,
eye:.spiral, badge:nil,
badgeColor: .white,
glow: CGColor(red:0.957,green:0.447,blue:0.714,alpha:1), glowOpacity:0.55,
bounces:false, scans:false, breathes:false, zz:false, sweat:false,
look:nil, tilt:0, sound:"dizzy"),
]
// MARK: - Bot engine
@MainActor
final class BotEngine: ObservableObject {
var isMini: Bool = false
var bodyColor: CGColor? = nil // override for mini bots
// Animation state (mirrors prototype 's' object)
var yaw: CGFloat = 0
var pitch: CGFloat = 0
var roll: CGFloat = 0
var rollTurns: CGFloat = 1
var tilt: CGFloat = 0
var open: CGFloat = 1 // eye open amount
var sx: CGFloat = 1 // scale X
var sy: CGFloat = 1 // scale Y
var oy: CGFloat = 0 // offset Y (bounce)
var ox: CGFloat = 0 // offset X (shake)
var tint: CGFloat = 0
var morph: CGFloat = 0 // morph to rect (for upload bucket)
var hands: CGFloat = 0
var blush: CGFloat = 0
var outfit: Outfit = .none
var es: CGFloat = 1 // eye scale
var badgeS: CGFloat = 0 // badge scale
var outfitPresence: CGFloat = 0 // 0=hidden, 1=fully visible (animated)
private var outfitTarget: Outfit = .none
// Physical spring (hat/pompom lag) — updated in update()
var physDx: CGFloat = 0 // horizontal lag (-1..1)
var physDy: CGFloat = 0 // vertical lag (-1..1)
private var physVx: CGFloat = 0
private var physVy: CGFloat = 0
private var prevYaw: CGFloat = 0
private var prevOy: CGFloat = 0
private var prevRoll: CGFloat = 0
// Targets
var tgYaw: CGFloat = 0
var tgPitch: CGFloat = 0
var tgTilt: CGFloat = 0
var tgSy: CGFloat = 1
var tgSx: CGFloat = 1
var tgEs: CGFloat = 1 // eye-scale target (hover love: 1.08, normal: 1)
// Particle canvas overhang (extra canvas height at top for hearts to fly into)
var particleOverhang: CGFloat = 0
// Mouth spring (fraction of R: 0=closed, 0.20=hover, 0.42=open, 0.50=overopen)
var slotH: CGFloat = 0 // current height (fraction of R)
var slotHTarget: CGFloat = 0 // spring target
var slotHVel: CGFloat = 0 // spring velocity (fraction of R / s)
var isChewing: Bool = false // true for ~800ms after gulp swallow
// Color (animated)
var col: (CGFloat, CGFloat, CGFloat) = (0.902, 0.914, 0.933) // idle
var colT: (CGFloat, CGFloat, CGFloat) = (0.902, 0.914, 0.933)
// State
var state: BotState = .idle
var cfg: BotStateCfg = BotStates[.idle]!
// Eye override (emote)
var eyeOverride: EyeShape? = nil
var eyeOverrideUntil: Double = 0 // CACurrentMediaTime()
var permanentEye: EyeShape? = nil // restored after temporary emote/blink expires
var permanentEmote: BotEmote? = nil // stored so doMiniBehaviorLoop can switch on it
var miniNextBehavior: Double = 0 // CACurrentMediaTime() of next periodic mini action
// Badge animation
var badge: BadgeType? = nil
var badgeKey: String = "none"
var badgeToken: Int = 0
// Tweens (keyed by property name)
var tweens: [String: Tween] = [:]
var locks: Set<String> = []
// Particles
var particles: [Particle] = []
// Look target
var lookX: CGFloat = 0
var lookY: CGFloat = 0
// Timing
var lastTime: Double = CACurrentMediaTime()
var t0: Double = CACurrentMediaTime() - Double.random(in: 0...5)
var nextBlink: Double = CACurrentMediaTime() + 1.5 + Double.random(in: 0...2)
var waveUntil: Double = 0
var waveStart: Double = 0 // CACurrentMediaTime() when wave animation began
var greetToken: Int = 0 // incremented to invalidate stale greet closures
var lastAmbient: Double = 0
// Slap tracking (for dizzy on 3 slaps)
var slapTimes: [Double] = []
// Dancing (Apple Music)
var isDancing: Bool = false
var dancingLevel: CGFloat = 0 // 0→1 over 0.3s, 1→0 over 0.5s
// Mini wandering look (random, ignores mouse)
var miniLookTarget: CGPoint = .zero
var miniLookNextTime: Double = 0
// MARK: - Public API
func setState(_ newState: BotState, force: Bool = false) {
guard state != newState || force else { return }
let prev = state
state = newState
cfg = BotStates[newState]!
colT = cgColorToTuple(cfg.color)
setTarget(key: "tint", value: cfg.tint)
setTarget(key: "tilt", value: cfg.tilt)
setBadge(cfg.badge)
switch newState {
case .finished:
doRoll(duration: 950, turns: 1)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.emit(.spark, count: 5)
}
case .error:
anim("ox", keys: [
TweenKey(target: 0.08, duration: 50, ease: Ease.out),
TweenKey(target: -0.08, duration: 70, ease: Ease.inOut),
TweenKey(target: 0.05, duration: 70, ease: Ease.inOut),
TweenKey(target: 0, duration: 90, ease: Ease.out),
])
case .approval:
anim("oy", keys: [
TweenKey(target: -0.2, duration: 150, ease: Ease.out),
TweenKey(target: 0, duration: 300, ease: Ease.back),
])
case .dizzy:
doRoll(duration: 1300, turns: 2)
case .question:
blink()
case .ratelimit:
emit(.sweat, count: 1)
default:
if prev != .idle || newState != .idle { blink() }
}
}
func setBadge(_ b: BadgeType?) {
let key = badgeString(b)
guard key != badgeKey else { return }
badgeKey = key
let tok = badgeToken + 1
badgeToken = tok
anim("badgeS", keys: [TweenKey(target: 0, duration: 90, ease: Ease.inOut)])
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self, tok == self.badgeToken else { return }
self.badge = b
if b != nil {
self.anim("badgeS", keys: [TweenKey(target: 1, duration: 280, ease: Ease.back)])
}
}
}
func blink() {
guard !locks.contains("open") else { return }
anim("open", keys: [
TweenKey(target: 0.06, duration: 70, ease: Ease.inOut),
TweenKey(target: 1, duration: 130, ease: Ease.out),
])
}
func squash() {
physVy += 0.6
anim("sy", keys: [
TweenKey(target: 0.78, duration: 70, ease: Ease.out),
TweenKey(target: 1.1, duration: 130, ease: Ease.out),
TweenKey(target: 1, duration: 170, ease: Ease.inOut),
])
anim("sx", keys: [
TweenKey(target: 1.16, duration: 70, ease: Ease.out),
TweenKey(target: 0.95, duration: 130, ease: Ease.out),
TweenKey(target: 1, duration: 170, ease: Ease.inOut),
])
}
// MARK: - Gulp (mailbox swallow)
func gulp() {
// Open mouth wide for the swallow, then close during chewing
slotHTarget = 0.42
DispatchQueue.main.asyncAfter(deadline: .now() + 0.46) { [weak self] in
self?.slotHTarget = 0
self?.isChewing = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.80) { [weak self] in
self?.isChewing = false
}
}
anim("sy", keys: [
TweenKey(target: 0.78, duration: 80, ease: Ease.out),
TweenKey(target: 1.18, duration: 130, ease: Ease.out),
TweenKey(target: 1, duration: 220, ease: Ease.back),
])
anim("sx", keys: [
TweenKey(target: 1.28, duration: 80, ease: Ease.out),
TweenKey(target: 0.92, duration: 130, ease: Ease.out),
TweenKey(target: 1, duration: 220, ease: Ease.back),
])
blink()
}
// MARK: - Slap (dizzy mechanic)
func slap() {
interruptGreet()
guard state != .dizzy else { return }
let now = CACurrentMediaTime()
slapTimes = slapTimes.filter { now - $0 < 1.7 }
slapTimes.append(now)
SoundEngine.shared.play("slap")
squash()
physVy -= 1.2
physVx += Bool.random() ? 0.7 : -0.7
if slapTimes.count >= 3 {
slapTimes = []
NotificationCenter.default.post(name: .botDizzy, object: nil)
} else {
// Annoyed: line eyes for 800ms, annoyed sound after 60ms delay
eyeOverride = .line
eyeOverrideUntil = now + 0.8
DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) {
SoundEngine.shared.play("annoyed")
}
}
}
// MARK: - Dancing
func setDancing(_ dancing: Bool) {
guard isDancing != dancing else { return }
isDancing = dancing
}
// MARK: - Mini periodic behavior loop
func doMiniBehaviorLoop() {
switch permanentEmote {
case .happy:
// Little jump + squash
guard !locks.contains("oy") else {
miniNextBehavior = CACurrentMediaTime() + 0.4
return
}
anim("oy", keys: [
TweenKey(target: -0.30, duration: 120, ease: Ease.out),
TweenKey(target: 0.03, duration: 200, ease: Ease.inOut),
TweenKey(target: 0, duration: 160, ease: Ease.back),
])
anim("sy", keys: [
TweenKey(target: 0.82, duration: 80, ease: Ease.out),
TweenKey(target: 1.18, duration: 130, ease: Ease.out),
TweenKey(target: 0.88, duration: 160, ease: Ease.inOut),
TweenKey(target: 1, duration: 200, ease: Ease.back),
])
anim("sx", keys: [
TweenKey(target: 1.15, duration: 80, ease: Ease.out),
TweenKey(target: 0.88, duration: 130, ease: Ease.out),
TweenKey(target: 1.06, duration: 160, ease: Ease.inOut),
TweenKey(target: 1, duration: 200, ease: Ease.back),
])
miniNextBehavior = CACurrentMediaTime() + 2.2 + Double.random(in: 0...1.2)
case .annoyed:
// Rapid head shake
guard !locks.contains("yaw") else {
miniNextBehavior = CACurrentMediaTime() + 0.5
return
}
anim("yaw", keys: [
TweenKey(target: -0.65, duration: 50, ease: Ease.out),
TweenKey(target: 0.65, duration: 90, ease: Ease.inOut),
TweenKey(target: -0.5, duration: 80, ease: Ease.inOut),
TweenKey(target: 0.4, duration: 75, ease: Ease.inOut),
TweenKey(target: -0.2, duration: 70, ease: Ease.inOut),
TweenKey(target: 0, duration: 140, ease: Ease.out),
])
miniNextBehavior = CACurrentMediaTime() + 3.0 + Double.random(in: 0...2.5)
case .wink:
// Brief wink: eye closes, head tilts slightly
let now2 = CACurrentMediaTime()
eyeOverride = .wink
eyeOverrideUntil = now2 + 0.55
anim("tilt", keys: [
TweenKey(target: 0.13, duration: 100, ease: Ease.out),
TweenKey(target: 0.13, duration: 320, ease: Ease.lin),
TweenKey(target: 0, duration: 200, ease: Ease.inOut),
])
miniNextBehavior = CACurrentMediaTime() + 2.2 + Double.random(in: 0...2.0)
case .love:
// Emit hearts + gentle sway
emit(.heart, count: 2)
anim("tilt", keys: [
TweenKey(target: -0.1, duration: 180, ease: Ease.out),
TweenKey(target: 0.1, duration: 340, ease: Ease.inOut),
TweenKey(target: 0, duration: 220, ease: Ease.inOut),
])
miniNextBehavior = CACurrentMediaTime() + 2.6 + Double.random(in: 0...1.5)
default:
miniNextBehavior = CACurrentMediaTime() + 3.0 + Double.random(in: 0...2.0)
}
}
func doRoll(duration: CGFloat, turns: CGFloat) {
roll = 0
rollTurns = turns
anim("roll", keys: [TweenKey(target: .pi * 2 * turns, duration: duration, ease: Ease.inOut)]) { [weak self] in
self?.roll = 0
self?.squash()
}
}
func setOutfit(_ newOutfit: Outfit, animated: Bool = true) {
guard newOutfit != outfitTarget else { return }
outfitTarget = newOutfit
tweens.removeValue(forKey: "outfitPresence")
locks.remove("outfitPresence")
if !animated {
outfit = newOutfit
outfitPresence = newOutfit != .none ? 1 : 0
} else if newOutfit == .none {
// Exit: fade out then clear outfit
anim("outfitPresence", keys: [TweenKey(target: 0, duration: 180, ease: Ease.inOut)]) { [weak self] in
self?.outfit = .none
}
} else if outfit == .none {
// Enter: set outfit then animate in (Ease.back applied to position offsets in drawing code)
outfit = newOutfit
outfitPresence = 0
anim("outfitPresence", keys: [TweenKey(target: 1, duration: 350, ease: Ease.inOut)]) { [weak self] in
self?.squash()
}
} else {
// Change: exit old, set new, enter
anim("outfitPresence", keys: [TweenKey(target: 0, duration: 180, ease: Ease.inOut)]) { [weak self] in
guard let self else { return }
self.outfit = newOutfit
self.anim("outfitPresence", keys: [TweenKey(target: 1, duration: 350, ease: Ease.inOut)]) { [weak self] in
self?.squash()
}
}
}
}
func greet() {
let now = CACurrentMediaTime()
greetToken += 1
let tok = greetToken
waveStart = now + 0.45 // wave begins at 0.45s
waveUntil = now + 1.55 // wave ends at 1.55s
physVx += 0.2
// 0s: happy eyes for full greeting (2s — no gap, no flicker)
eyeOverride = .happy
eyeOverrideUntil = now + 2.0
anim("oy", keys: [
TweenKey(target: -0.06, duration: 220, ease: Ease.out),
TweenKey(target: 0.0, duration: 220, ease: Ease.back),
])
// 0.25s: hands out + body squash + sound
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
guard let self, self.greetToken == tok else { return }
self.anim("hands", keys: [TweenKey(target: 1, duration: 280, ease: Ease.out)])
self.anim("sy", keys: [
TweenKey(target: 0.95, duration: 100, ease: Ease.out),
TweenKey(target: 1.0, duration: 260, ease: Ease.back),
])
self.anim("sx", keys: [
TweenKey(target: 1.04, duration: 100, ease: Ease.out),
TweenKey(target: 1.0, duration: 260, ease: Ease.back),
])
SoundEngine.shared.play("greet")
}
// 0.55s: first blink
DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) { [weak self] in
guard let self, self.greetToken == tok else { return }
self.blink()
}
// 1.50s: second blink
DispatchQueue.main.asyncAfter(deadline: .now() + 1.50) { [weak self] in
guard let self, self.greetToken == tok else { return }
self.blink()
}
// 1.55s: retract hands
DispatchQueue.main.asyncAfter(deadline: .now() + 1.55) { [weak self] in
guard let self, self.greetToken == tok else { return }
self.waveUntil = 0
self.anim("hands", keys: [TweenKey(target: 0, duration: 200, ease: Ease.inOut)])
}
// 1.75s: brief happy eyes then back to normal
DispatchQueue.main.asyncAfter(deadline: .now() + 1.75) { [weak self] in
guard let self, self.greetToken == tok else { return }
self.eyeOverride = .happy
self.eyeOverrideUntil = CACurrentMediaTime() + 0.30
}
}
/// Immediately interrupts an in-progress greeting (hands retract in 150 ms).
func interruptGreet() {
guard hands > 0.01 || CACurrentMediaTime() < waveUntil else { return }
greetToken += 1 // invalidate any pending closures
waveUntil = 0
waveStart = 0
anim("hands", keys: [TweenKey(target: 0, duration: 150, ease: Ease.inOut)])
}
/// Sets a permanent eye expression that survives blinks and transient emotes.
func setPermanentEmote(_ emote: BotEmote?) {
permanentEmote = emote
// .wink fires periodically — don't freeze the eye (normal between winks)
if emote == .wink {
miniNextBehavior = CACurrentMediaTime() + Double.random(in: 0.8...2.5)
return
}
permanentEye = emote.map { emoteEyeShape($0) }
if let eye = permanentEye {
eyeOverride = eye
eyeOverrideUntil = .greatestFiniteMagnitude
} else {
if eyeOverrideUntil == .greatestFiniteMagnitude {
eyeOverride = nil
eyeOverrideUntil = 0
}
}
// Stagger first periodic behavior so bots don't all fire at once
miniNextBehavior = CACurrentMediaTime() + Double.random(in: 0.8...2.5)
}
func triggerEmote(_ emote: BotEmote, duration: Double = 1.8, silent: Bool = false) {
let now = CACurrentMediaTime()
eyeOverride = emoteEyeShape(emote)
eyeOverrideUntil = now + duration
switch emote {
case .love:
anim("blush", keys: [
TweenKey(target: 1, duration: 300, ease: Ease.out),
TweenKey(target: 1, duration: CGFloat((duration - 0.6) * 1000), ease: Ease.lin),
TweenKey(target: 0, duration: 300, ease: Ease.inOut),
])
emit(.heart, count: 4)
anim("oy", keys: [
TweenKey(target: -0.1, duration: 160, ease: Ease.out),
TweenKey(target: 0, duration: 300, ease: Ease.back),
])
case .surprised:
anim("oy", keys: [
TweenKey(target: -0.3, duration: 140, ease: Ease.out),
TweenKey(target: 0, duration: 380, ease: Ease.back),
])
anim("es", keys: [
TweenKey(target: 1.25, duration: 120, ease: Ease.out),
TweenKey(target: 1, duration: 500, ease: Ease.inOut),
])
case .proud:
squash()
emit(.star, count: 5)
anim("tilt", keys: [
TweenKey(target: -0.14, duration: 220, ease: Ease.out),
TweenKey(target: -0.14, duration: CGFloat((duration - 0.5) * 1000), ease: Ease.lin),
TweenKey(target: 0, duration: 280, ease: Ease.inOut),
])
anim("blush", keys: [
TweenKey(target: 0.7, duration: 250, ease: Ease.out),
TweenKey(target: 0.7, duration: CGFloat((duration - 0.5) * 1000), ease: Ease.lin),
TweenKey(target: 0, duration: 300, ease: Ease.inOut),
])
case .wink:
anim("tilt", keys: [
TweenKey(target: 0.12, duration: 160, ease: Ease.out),
TweenKey(target: 0.12, duration: CGFloat((duration - 0.4) * 1000), ease: Ease.lin),
TweenKey(target: 0, duration: 240, ease: Ease.inOut),
])
case .yawn:
anim("sy", keys: [
TweenKey(target: 1.12, duration: 500, ease: Ease.inOut),
TweenKey(target: 1, duration: 500, ease: Ease.inOut),
])
anim("sx", keys: [
TweenKey(target: 0.94, duration: 500, ease: Ease.inOut),
TweenKey(target: 1, duration: 500, ease: Ease.inOut),
])
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in
self?.eyeOverride = .closed
self?.emit(.z, count: 2)
}
case .happy:
anim("blush", keys: [
TweenKey(target: 0.6, duration: 200, ease: Ease.out),
TweenKey(target: 0, duration: 600, ease: Ease.inOut),
])
case .annoyed:
eyeOverride = .line
eyeOverrideUntil = now + 0.8
DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) {
SoundEngine.shared.play("annoyed")
}
}
}
func emit(_ type: Particle.ParticleType, count: Int) {
for i in 0..<count {
let isZ = type == .z
let p = Particle(
type: type,
x: (CGFloat.random(in: -0.5...0.5)) * 0.9 + (isZ ? 0.55 : 0),
y: -0.7 - CGFloat.random(in: 0...0.2),
vx: CGFloat.random(in: -0.5...0.5) * 0.35 + (isZ ? 0.18 : 0),
vy: -(0.45 + CGFloat.random(in: 0...0.35)),
age: -Double(i) * 0.14,
life: 1.3 + Double.random(in: 0...0.5),
rot: CGFloat.random(in: 0...(.pi * 2)),
size: 0.15 + CGFloat.random(in: 0...0.08)
)
particles.append(p)
}
}
// MARK: - Update (called every frame from TimelineView)
func update(dt: Double) {
let now = CACurrentMediaTime()
let dtCG = CGFloat(dt)
// Process tweens
for key in tweens.keys {
guard var tw = tweens[key] else { continue }
let k = tw.keys[tw.keyIndex]
let elapsed = now * 1000 - tw.startTime
let p = min(1, max(0, CGFloat(elapsed) / k.duration))
let val = tw.from + (k.target - tw.from) * k.ease(p)
setProperty(key, value: val)
if p >= 1 {
tw.from = k.target
tw.keyIndex += 1
tw.startTime = now * 1000
if tw.keyIndex >= tw.keys.count {
tweens.removeValue(forKey: key)
locks.remove(key)
tw.onComplete?()
} else {
tweens[key] = tw
}
} else {
tweens[key] = tw
}
}
// Compute look targets
let t = CGFloat(now - t0)
var ty: CGFloat = lookX * 0.62
var tp: CGFloat = lookY * 0.5
if let fixedLook = cfg.look {
ty = ty * 0.35 + fixedLook.x * 0.55
tp = tp * 0.3 + fixedLook.y * 0.5
}
if cfg.scans {
ty = sin(t * 2.6) * 0.6
tp = -0.06
}
if state == .sleeping { ty = 0; tp = -0.14 }
if state == .dizzy { ty = sin(t * 9) * 0.25 }
// Mini bots: override look with random wandering (never follows mouse)
if isMini && cfg.look == nil && !cfg.scans && state != .sleeping && state != .dizzy {
if now > miniLookNextTime {
miniLookTarget = CGPoint(
x: CGFloat.random(in: -0.88...0.88),
y: CGFloat.random(in: -0.55...0.45)
)
miniLookNextTime = now + Double.random(in: 0.5...2.0)
}
ty = miniLookTarget.x * 0.62
tp = miniLookTarget.y * 0.5
}
tgYaw = ty
tgPitch = tp
tgTilt = cfg.tilt
// Body sway during greeting wave
if now > waveStart && now < waveUntil {
let wt = CGFloat(now - waveStart)
tgTilt = -0.06 + sin(2 * .pi * 1.2 * wt) * 0.07
}
let bounce = cfg.bounces ? -abs(sin(t * 5.2)) * 0.07 : CGFloat(0)
// oy tween can override if not locked
if !locks.contains("oy") { oy += (bounce - oy) * CGFloat(1 - pow(0.0008, dt)) }
if cfg.breathes {
let amp: CGFloat = isMini ? 0.07 : 0.035
tgSy = 1 + sin(t * 1.8) * amp
tgSx = 1 - sin(t * 1.8) * amp * 0.57
} else if isMini {
// Subtle idle pulse (unique phase per engine via t0)
tgSy = 1 + sin(t * 2.2) * 0.04
tgSx = 1 - sin(t * 2.2) * 0.02
} else {
tgSy = 1; tgSx = 1
}
// Mini bots: periodic dramatic behaviors
if isMini && now > miniNextBehavior {
doMiniBehaviorLoop()
}
// Smooth look
let kLook = CGFloat(1 - pow(0.0025, dt))
let kGen = CGFloat(1 - pow(0.0008, dt))
if !locks.contains("yaw") { yaw += (tgYaw - yaw) * kLook }
if !locks.contains("pitch") { pitch += (tgPitch - pitch) * kLook }
if !locks.contains("tilt") { tilt += (tgTilt - tilt) * kGen }
if !locks.contains("sy") { sy += (tgSy - sy) * kGen }
if !locks.contains("sx") { sx += (tgSx - sx) * kGen }
if !locks.contains("es") { es += (tgEs - es) * kGen }
// Animate color
col = mixColor(col, colT, 1 - pow(0.002, dt))
// Blink
if now > nextBlink {
if state != .sleeping && state != .dizzy {
blink()
if Double.random(in: 0...1) < 0.22 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.23) { [weak self] in self?.blink() }
}
}
nextBlink = now + 2.2 + Double.random(in: 0...3.2)
}
// Clear expired eye override (restore permanent if set)
if eyeOverride != nil && now > eyeOverrideUntil {
eyeOverride = permanentEye
if permanentEye != nil { eyeOverrideUntil = .greatestFiniteMagnitude }
}
// Ambient particles
if now - lastAmbient > 1.3 {
lastAmbient = now
if cfg.zz { emit(.z, count: 1) } // ZZZ works for mini too
if !isMini && cfg.sweat && Double.random(in: 0...1) < 0.5 { emit(.sweat, count: 1) }
}
// Age particles
for i in particles.indices { particles[i].age += dt }
particles.removeAll { $0.age >= $0.life }
// Mouth slot spring — ω₀ ≈ 25 rad/s (T=0.25s), ζ=0.6 (underdamped, slight clack)
let slotOmega: CGFloat = 2 * .pi / 0.25
let slotZeta: CGFloat = 0.6
let slotAcc = slotOmega * slotOmega * (slotHTarget - slotH)
- 2 * slotZeta * slotOmega * slotHVel
slotHVel += slotAcc * dtCG
slotH = max(0, slotH + slotHVel * dtCG)
// Dance level: fade in 0.3s, out 0.5s
let dancingTarget: CGFloat = isDancing ? 1 : 0
if dancingLevel < dancingTarget {
dancingLevel = min(dancingTarget, dancingLevel + CGFloat(dt) / 0.3)
} else if dancingLevel > dancingTarget {
dancingLevel = max(dancingTarget, dancingLevel - CGFloat(dt) / 0.5)
}
// Phys spring for hat/pompom lag
let yawVel = (yaw - prevYaw) / CGFloat(dt)
let oyVel = (oy - prevOy) / CGFloat(dt)
let rollVel = (roll - prevRoll) / CGFloat(dt)
prevYaw = yaw; prevOy = oy; prevRoll = roll
// Centrifugal physDx impulse when rigidly rolling with an outfit
let centrifugal: CGFloat = (outfit != .none && outfitPresence > 0.05) ? rollVel * 0.18 : 0
let tDx = max(-1, min(1, -yawVel * 0.35 - tilt * 2 + centrifugal))
let tDy = max(-1, min(1, oyVel * 0.50))
let stiff: CGFloat = 60, damp: CGFloat = 9
physVx += (stiff * (tDx - physDx) - damp * physVx) * dtCG
physVy += (stiff * (tDy - physDy) - damp * physVy) * dtCG
physDx += physVx * dtCG
physDy += physVy * dtCG
lastTime = now
}
// MARK: - Dance transform
/// Applies a 112-BPM dance bounce/sway around the bottom of the body.
/// Call this on a copy of the GraphicsContext before the three draw passes.
func applyDance(_ ctx: inout GraphicsContext, size: CGSize) {
guard dancingLevel > 0.001 else { return }
let W = size.width, H = size.height, R = W * 0.3
let px = W / 2 + ox * R
let py = H / 2 + particleOverhang / 2 + oy * R + R * 0.06 + R * 0.88
let beat = CGFloat(CACurrentMediaTime()) * 112 / 60
let hop = abs(sin(.pi * beat)), land = pow(1 - hop, 6), l = dancingLevel
ctx.translateBy(x: px + 0.08 * R * sin(.pi * beat) * l, y: py - 0.20 * R * hop * l)
ctx.rotate(by: .radians(0.10 * sin(.pi * beat) * l))
ctx.scaleBy(x: 1 + 0.045 * land * l, y: 1 - 0.06 * land * l)
ctx.translateBy(x: -px, y: -py)
}
// MARK: - Draw
func draw(context: GraphicsContext, size: CGSize) {
let W = size.width
let H = size.height
let R = W * 0.3
let rx = R * 1.14
let ry = R * 0.88
let cx = W / 2 + ox * R
// particleOverhang shifts the bot body down in canvas coords so hearts can fly into
// the extended canvas above without clipping (BotPlacement compensates with position offset)
let cy = H / 2 + particleOverhang / 2 + oy * R + R * 0.06
var ctx = context
ctx.translateBy(x: cx, y: cy)
if tilt != 0 { ctx.rotate(by: .radians(tilt)) }
ctx.scaleBy(x: sx, y: sy)
// Body path (superellipse for Mochi, morph to rect for upload)
let bodyPath = mochiPath(rx: rx, ry: ry, morph: morph, R: R)
// Body fill
drawBody(ctx: &ctx, path: bodyPath, R: R, rx: rx, ry: ry,
pumpkinColors: outfit == .pumpkin && !isMini)
// Blush — always shows a floor proportional to tint (prototype behaviour)
let blushVal = max(blush, tint * 0.5) * (1 - morph)
if blushVal > 0.01 {
drawBlush(ctx: &ctx, path: bodyPath, rx: rx, ry: ry, R: R, blush: blushVal)
}
if showsBroVFace, R > 11 { drawBroVSkin(ctx: ctx, path: bodyPath, R: R, rx: rx, ry: ry) }
// Eyes
drawEyes(ctx: &ctx, path: bodyPath, R: R, rx: rx, ry: ry)
if showsBroVFace, R > 11 { drawBroVFace(ctx: ctx, path: bodyPath, R: R, rx: rx, ry: ry) }
// Mouth hole — dark pill cutout inside the box face
// Spec: left/right margins 0.10R, top margin 0.08R from box top (-0.94R)
if morph > 0.05 {
let hW = R * 1.80 * morph // hole width = box width (2×1.0R) − 2×0.10R margin
let hH = slotH * R * morph // hole height (spring-animated, scaled by morph)
let hX = -hW / 2
// Hole Y: box top is -R*0.94 at morph=1, lerped from -R*0.88 at morph=0
let boxTop = -R * (0.88 + 0.06 * morph)
let hY = boxTop + R * 0.08 * morph // top margin scales with morph
var boxCtx = ctx
boxCtx.clip(to: bodyPath) // everything clipped inside body
// Top rim — 1pt white 55% line at box top edge
var rim = Path()
rim.move(to: CGPoint(x: -R * 0.90 * morph, y: boxTop + 1))
rim.addLine(to: CGPoint(x: R * 0.90 * morph, y: boxTop + 1))
boxCtx.stroke(rim, with: .color(Color.white.opacity(0.55 * Double(morph))),
style: StrokeStyle(lineWidth: 1, lineCap: .round))
// Hole interior — only draw if visibly open
if hH > 0.8 {
let hR = min(hW / 2, hH / 2) // fully rounded when hH < hW (pill shape)
var hole = Path()
hole.addRoundedRect(in: CGRect(x: hX, y: hY, width: hW, height: hH),
cornerSize: CGSize(width: hR, height: hR))
boxCtx.fill(hole, with: .linearGradient(
Gradient(colors: [Color(red: 0.027, green: 0.031, blue: 0.039),
Color(red: 0.063, green: 0.075, blue: 0.102)]),
startPoint: CGPoint(x: 0, y: hY),
endPoint: CGPoint(x: 0, y: hY + hH)
))
// Bottom lip — 1pt white 28% highlight
if hH > 4 {
let lipR = min(hR, (hW - 2) / 2)
var lip = Path()
lip.move(to: CGPoint(x: hX + lipR, y: hY + hH - 0.5))
lip.addLine(to: CGPoint(x: hX + hW - lipR, y: hY + hH - 0.5))
boxCtx.stroke(lip, with: .color(Color.white.opacity(0.28 * Double(morph))),
style: StrokeStyle(lineWidth: 1, lineCap: .round))
}
}
}
// Reset transform for hands, badge, particles which need world coords
// (We'll pass world-space cx/cy to these helpers)
}
// MARK: - Draw hands behind body (called before draw() so hands appear under Mochi)
func drawHandsBehind(context: GraphicsContext, size: CGSize) {
guard hands > 0.01, !isMini else { return }
let W = size.width, H = size.height
let R = W * 0.3
// Only draw hands when Mochi is large enough to be meaningful (not compact/peek)
guard R > 14 else { return }
let rx = R * 1.14
let ry = R * 0.88
let cx = W / 2 + ox * R
let cy = H / 2 + particleOverhang / 2 + oy * R + R * 0.06
let now = CACurrentMediaTime()
let bodyH = 2 * ry // full body height
// Hand ellipse half-dims: 0.30×bodyH wide, 0.26×bodyH tall (scaled by hands 0→1)
let hew = 0.30 * ry * hands // half-width
let heh = 0.26 * ry * hands // half-height
// Body half-dims with current squash scale
let hwB = rx * sx
let hhB = ry * sy
let isWaving = now >= waveStart && waveStart > 0 && now < waveUntil
for sd in [-1.0, 1.0] {
var localX: CGFloat
var localY: CGFloat
var handRot: CGFloat = 0
if sd > 0 && isWaving {
// Right hand: rise to wave position over first 180ms, then oscillate
let wt = CGFloat(now - waveStart)
let rise = min(1.0, wt / 0.18)
let riseEased: CGFloat = 1 - pow(1 - rise, 3) // easeOut cubic
// Rest position is lower-side; wave position is upper-side (at eye height)
let restX: CGFloat = hwB * 1.08
let restY: CGFloat = hhB * 0.70
let oscX = cos(13 * wt) * 0.06 * bodyH
let oscY = -sin(13 * wt) * 0.14 * bodyH
let waveX: CGFloat = hwB * 1.10 + oscX
let waveY: CGFloat = -hhB * 0.15 + oscY
localX = restX + (waveX - restX) * riseEased
localY = restY + (waveY - restY) * riseEased
handRot = (-0.5 + sin(13 * wt) * 0.35) * riseEased
} else if sd < 0 && isWaving {
// Left hand: gentle sway at rest position
let wt = CGFloat(now - waveStart)
localX = -hwB * 1.08
localY = hhB * 0.70 + sin(6 * wt) * 0.04 * bodyH
} else {
// Rest: lower-side, clearly peeking behind body bottom
localX = CGFloat(sd) * hwB * 1.08
localY = hhB * 0.70
}
// Apply body tilt to get world position
let cosT = cos(tilt), sinT = sin(tilt)
let worldX = cx + cosT * localX - sinT * localY
let worldY = cy + sinT * localX + cosT * localY
// Draw
var handCtx = context
handCtx.translateBy(x: worldX, y: worldY)
if handRot != 0 { handCtx.rotate(by: .radians(handRot)) }
let handRect = CGRect(x: -hew, y: -heh, width: hew * 2, height: heh * 2)
var handPath = Path()
handPath.addEllipse(in: handRect)
// Fill with body material (same gradient as body)
if let bc = bodyColor {
let c0 = mix3(cgColorToTuple(bc), (1, 1, 1), 0.35)
let c1 = cgColorToTuple(bc)
handCtx.fill(handPath, with: .linearGradient(
Gradient(colors: [colorFromTuple(c0), colorFromTuple(c1)]),
startPoint: CGPoint(x: hew * 0.7, y: -heh * 0.85),
endPoint: CGPoint(x: -hew * 0.8, y: heh * 0.9)
))
} else {
let c0 = cgColorToTuple(MochiConst.baseTop)
let c1 = cgColorToTuple(MochiConst.baseBottom)
handCtx.fill(handPath, with: .linearGradient(
Gradient(colors: [colorFromTuple(c0), colorFromTuple(c1)]),
startPoint: CGPoint(x: hew * 0.7, y: -heh * 0.85),
endPoint: CGPoint(x: -hew * 0.8, y: heh * 0.9)
))
}
// Subtle separation border — rgba(0,0,0,0.08) 1pt
handCtx.stroke(handPath, with: .color(Color.black.opacity(0.08)), lineWidth: 1)
}
}
func drawHandsAndExtras(context: GraphicsContext, size: CGSize) {
let W = size.width
let H = size.height
let R = W * 0.3
let rx = R * 1.14
let ry = R * 0.88
let cx = W / 2 + ox * R
let cy = H / 2 + particleOverhang / 2 + oy * R + R * 0.06
// Badge — hidden while morphing to mailbox
if let badge = badge, badgeS > 0.01, morph < 0.25 {
drawBadge(context: context, size: size, badge: badge, R: R, rx: rx, ry: ry, cx: cx, cy: cy)
}
// Particles
drawParticles(context: context, size: size, R: R, cx: cx, cy: cy)
}
func drawOutfitBehind(context: GraphicsContext, size: CGSize) {
guard outfit != .none, !isMini else { return }
let W = size.width, H = size.height, R = W * 0.3
let cx = W / 2 + ox * R
let cy = H / 2 + particleOverhang / 2 + oy * R + R * 0.06
// Rigid roll: accessories see roll=0 (they rotate with the body via context transform)
let outfitRoll: CGFloat = outfitPresence > 0.05 ? 0 : roll
let mH = MochiH(R: R, yaw: yaw, pitch: pitch, physDx: physDx, physDy: physDy, roll: outfitRoll)
drawOutfitBehindStatic(context: context, outfit: outfit, H: mH,
cx: cx, cy: cy, tilt: tilt, sx: sx, sy: sy,
roll: outfitRoll, morph: morph, isMini: isMini,
presence: outfitPresence, rollTurns: rollTurns)
}
func drawOutfitFront(context: GraphicsContext, size: CGSize) {
guard outfit != .none, !isMini else { return }
let W = size.width, H = size.height, R = W * 0.3
let cx = W / 2 + ox * R
let cy = H / 2 + particleOverhang / 2 + oy * R + R * 0.06
// Rigid roll: accessories see roll=0 (they rotate with the body via context transform)
let outfitRoll: CGFloat = outfitPresence > 0.05 ? 0 : roll
let mH = MochiH(R: R, yaw: yaw, pitch: pitch, physDx: physDx, physDy: physDy, roll: outfitRoll)
drawOutfitFrontStatic(context: context, outfit: outfit, H: mH,
cx: cx, cy: cy, tilt: tilt, sx: sx, sy: sy,
roll: outfitRoll, morph: morph, isMini: isMini,
presence: outfitPresence, rollTurns: rollTurns)
}
/// World-space center of Mochi's body (used by BotCanvasView for rigid-roll transform)
func bodyCenter(size: CGSize) -> CGPoint {
let W = size.width, R = W * 0.3
let cx = W / 2 + ox * R
let cy = size.height / 2 + particleOverhang / 2 + oy * R + R * 0.06
return CGPoint(x: cx, y: cy)
}
var outfitFollowsRoll: Bool {
switch outfit {
case .sunglasses, .roundGlasses, .bow, .scarf, .pumpkin: return true
default: return false
}
}
// MARK: - Private draw helpers
private func mochiPath(rx: CGFloat, ry: CGFloat, morph: CGFloat, R: CGFloat) -> Path {
let n = 72
let expN: CGFloat = 2.0 / 2.7
// Target mailbox dims (spec: 1.0R wide, 0.94R tall, 0.42R corner radius)
let tw = R * 1.0
let th = R * 0.94
let tr = R * 0.42
let lumpTime = CGFloat(CACurrentMediaTime())
var path = Path()
for i in 0...n {
let a = CGFloat(i) / CGFloat(n) * .pi * 2
let ca = cos(a), sa = sin(a)
let lump = broVLump(angle: a, time: lumpTime)
let px0 = rx * lump * (ca >= 0 ? pow(ca, expN) : -pow(-ca, expN))
let py0 = ry * lump * (sa >= 0 ? pow(sa, expN) : -pow(-sa, expN))
let px: CGFloat
let py: CGFloat
if morph < 0.005 {
px = px0; py = py0
} else {
let rr = rrPoint(ca: ca, sa: sa, W: tw, H: th, cr: tr)
px = lerp(px0, rr.x, morph)
py = lerp(py0, rr.y, morph)
}
if i == 0 { path.move(to: CGPoint(x: px, y: py)) }
else { path.addLine(to: CGPoint(x: px, y: py)) }
}
path.closeSubpath()
return path
}
/// Ray-rounded-rect intersection: find the point on the rounded rect boundary in direction (ca, sa).
private func rrPoint(ca: CGFloat, sa: CGFloat, W: CGFloat, H: CGFloat, cr: CGFloat) -> CGPoint {
let eps: CGFloat = 1e-6
let kx: CGFloat = ca >= 0 ? 1 : -1
let ky: CGFloat = sa >= 0 ? 1 : -1
let cx = kx * (W - cr)
let cy = ky * (H - cr)
// Try corner arc
let dot = ca * cx + sa * cy
let disc = dot * dot - (cx*cx + cy*cy - cr*cr)
if disc >= 0 {
let t = dot + sqrt(disc)
if t > eps {
let px = ca * t, py = sa * t
if abs(px) >= W - cr - eps && abs(py) >= H - cr - eps {
return CGPoint(x: px, y: py)
}
}
}
// Horizontal edge |y| = H
if abs(sa) > eps {
let t = (ky * H) / sa
if t > eps {
let x = ca * t
if abs(x) <= W - cr + eps { return CGPoint(x: x, y: ky * H) }
}
}
// Vertical edge |x| = W
if abs(ca) > eps {
let t = (kx * W) / ca
if t > eps {
let y = sa * t
if abs(y) <= H - cr + eps { return CGPoint(x: kx * W, y: y) }
}
}
return CGPoint(x: kx * W, y: ky * H)
}
private func drawBody(ctx: inout GraphicsContext, path: Path, R: CGFloat, rx: CGFloat, ry: CGFloat,
pumpkinColors: Bool = false) {
if let bc = bodyColor, !pumpkinColors {
// Mini bots: flat solid fill — no gradient, no reflection, no highlight
ctx.fill(path, with: .color(Color(cgColor: bc)))
} else {
// Main bot: linear gradient body
let top: Color = pumpkinColors ? Color(hex: "#FFA94D") : Color(cgColor: MochiConst.baseTop)
let bot: Color = pumpkinColors ? Color(hex: "#E8590C") : Color(cgColor: MochiConst.baseBottom)
ctx.fill(path, with: .linearGradient(
Gradient(colors: [top, bot]),
startPoint: CGPoint(x: rx*0.7, y: -ry*0.85),
endPoint: CGPoint(x: -rx*0.8, y: ry*0.9)
))
// State tint — fades out as morph increases (mailbox has no tint)
let effectiveTint = tint * (1 - morph)
if effectiveTint > 0.01 {
let tc = colorFromTuple(col)
ctx.fill(path, with: .linearGradient(
Gradient(stops: [
.init(color: tc.opacity(Double(0.72 * effectiveTint)), location: 0),
.init(color: tc.opacity(0), location: 1)
]),
startPoint: CGPoint(x: 0, y: ry),
endPoint: CGPoint(x: 0, y: -ry)
))
}
// Shadow rim
ctx.fill(path, with: .radialGradient(
Gradient(stops: [
.init(color: .clear, location: 0),
.init(color: .clear, location: 0.6),
.init(color: Color.black.opacity(0.2), location: 1)
]),
center: .zero, startRadius: R*0.15, endRadius: R*1.25
))
// Highlight
ctx.fill(path, with: .radialGradient(
Gradient(stops: [
.init(color: Color.white.opacity(0.55), location: 0),
.init(color: .clear, location: 1)
]),
center: CGPoint(x: rx*0.34, y: -ry*0.46),
startRadius: 0,
endRadius: R*0.42
))
}
}
private func drawBlush(ctx: inout GraphicsContext, path: Path, rx: CGFloat, ry: CGFloat, R: CGFloat, blush: CGFloat) {
ctx.clip(to: path)
let yOffset = sin(yaw) * rx * 0.8
for sd in [-1.0, 1.0] {
let bx = CGFloat(sd) * rx * 0.55 + yOffset
let by = ry * 0.2
var ellipse = Path()
ellipse.addEllipse(in: CGRect(x: bx - R*0.17, y: by - R*0.1, width: R*0.34, height: R*0.2))
ctx.fill(ellipse, with: .color(Color(red: 1, green: 0.471, blue: 0.588, opacity: Double(0.5 * blush))))
}
}
private func drawEyes(ctx: inout GraphicsContext, path: Path, R: CGFloat, rx: CGFloat, ry: CGFloat) {
var shape = eyeOverride ?? cfg.eye
// Dance: happy eyes in calm states
if isDancing && dancingLevel > 0.15 && !isMini && (state == .idle || state == .finished) {
shape = .happy
}
// In box mode: cup eyes when file over box (slotHTarget set), happy arcs while chewing
if morph > 0.5 {
if isChewing { shape = .happy }
else if slotHTarget > 0.05 || slotH > 0.10 { shape = .cup }
}
ctx.clip(to: path)
// Build eye frames: use mEyeFrames for position/foreshortening, but keep roll in eyePitch
// When an outfit is rigidly rotating, the whole body turns — eyes do NOT add roll
let rigidRoll = outfit != .none && outfitPresence > 0.05
let mH = MochiH(R: R, yaw: yaw, pitch: pitch)
for f in mEyeFrames(mH) {
// Re-derive pitch with roll for the roll-through effect (illusion, outfit=none only)
var eyePitch = MochiConst.eyeP + pitch + (rigidRoll ? 0 : roll)
eyePitch = ((eyePitch + .pi).truncatingRemainder(dividingBy: .pi*2) + .pi*2).truncatingRemainder(dividingBy: .pi*2) - .pi
let cp = cos(eyePitch)
let eyeYaw = f.sd * MochiConst.eyeSp + yaw
guard cos(eyeYaw) * cp > 0.04 else { continue }
let ey = f.y + (morph > 0 ? ry * 0.14 * morph : 0)
let fx = lerp(f.fx, 1, morph * 0.7)
let fy = lerp(f.fy, 1, morph * 0.7)
let eyeMult: CGFloat = isMini ? 1.9 : 1.0
let ew = R * MochiConst.eyeW * es * eyeMult
let eh = R * MochiConst.eyeH * es * eyeMult
var eyeCtx = ctx
eyeCtx.translateBy(x: f.x, y: ey)
eyeCtx.scaleBy(x: fx, y: fy)
drawEyeShape(ctx: &eyeCtx, shape: shape, w: ew, h: eh, open: open, sd: f.sd, R: R)
}
}
private func drawEyeShape(ctx: inout GraphicsContext, shape: EyeShape, w: CGFloat, h: CGFloat, open: CGFloat, sd: CGFloat, R: CGFloat) {
let ink = isMini ? Color(cgColor: MochiConst.miniInk) : Color(cgColor: MochiConst.ink)
let now = CGFloat(CACurrentMediaTime())
switch shape {
case .wide:
drawEyeShape(ctx: &ctx, shape: .pill, w: w*1.16, h: h*1.12, open: open, sd: sd, R: R)
case .pill where showsBroVFace:
drawBroVEye(ctx: &ctx, w: w, h: h, open: open, sd: sd, ink: ink)
case .pill:
let hh = max(h * open, w * 0.3)
var p = Path()
p.addRoundedRect(in: CGRect(x: -w/2, y: -hh/2, width: w, height: hh),
cornerSize: CGSize(width: min(w/2, hh/2), height: min(w/2, hh/2)))
ctx.fill(p, with: .color(ink))
case .dot:
var p = Path()
p.addEllipse(in: CGRect(x: -w*0.45, y: -w*0.45, width: w*0.9, height: w*0.9))
ctx.fill(p, with: .color(ink))
case .line:
ctx.rotate(by: .radians(-sd * 0.2))
var p = Path()
p.addRoundedRect(in: CGRect(x: -w*0.78, y: -w*0.21, width: w*1.56, height: w*0.42),
cornerSize: CGSize(width: w*0.21, height: w*0.21))
ctx.fill(p, with: .color(ink))
case .flat:
var p = Path()
p.addRoundedRect(in: CGRect(x: -w*0.72, y: -w*0.2, width: w*1.44, height: w*0.4),
cornerSize: CGSize(width: w*0.2, height: w*0.2))
ctx.fill(p, with: .color(ink))
case .happy:
var p = Path()
p.addArc(center: CGPoint(x: 0, y: h*0.18), radius: w*0.82,
startAngle: .degrees(180 + 12), endAngle: .degrees(180 - 12), clockwise: true)
ctx.stroke(p, with: .color(ink), style: StrokeStyle(lineWidth: w*0.5, lineCap: .round))
case .closed:
var p = Path()
p.addArc(center: CGPoint(x: 0, y: -h*0.08), radius: w*0.78,
startAngle: .degrees(15), endAngle: .degrees(165), clockwise: false)
ctx.stroke(p, with: .color(ink), style: StrokeStyle(lineWidth: w*0.36, lineCap: .round))
case .spiral:
var p = Path()
var a: CGFloat = 0
while a < 4.4 * .pi {
let r = w * 0.06 + a * w * 0.058
let aa = a + now * 9 * sd
let px = cos(aa) * r
let py = sin(aa) * r
if a == 0 { p.move(to: CGPoint(x: px, y: py)) }
else { p.addLine(to: CGPoint(x: px, y: py)) }
a += 0.2
}
ctx.stroke(p, with: .color(ink), style: StrokeStyle(lineWidth: w*0.22, lineCap: .round))
case .heart:
let heartPath = heartShape(size: w * 1.2)
ctx.fill(heartPath, with: .color(Color(hex: "#FF4D6D")))
case .star:
ctx.rotate(by: .radians(now * 1.5 * sd))
let starPath = starShape(outer: w * 1.05, inner: w * 0.46)
ctx.fill(starPath, with: .color(Color(hex: "#F7B32B")))
case .tired:
var p1 = Path()
p1.addRoundedRect(in: CGRect(x: -w/2, y: -h*0.02, width: w, height: h*0.38),
cornerSize: CGSize(width: w/2, height: w/2))
ctx.fill(p1, with: .color(ink))
var p2 = Path()
p2.addRoundedRect(in: CGRect(x: -w*0.62, y: -h*0.1, width: w*1.24, height: w*0.22),
cornerSize: CGSize(width: w*0.11, height: w*0.11))
ctx.fill(p2, with: .color(ink))
case .wink:
if sd < 0 {
let hh = max(h * open, w * 0.3)
var p = Path()
p.addRoundedRect(in: CGRect(x: -w/2, y: -hh/2, width: w, height: hh),
cornerSize: CGSize(width: min(w/2,hh/2), height: min(w/2,hh/2)))
ctx.fill(p, with: .color(ink))
} else {
var p = Path()
p.addArc(center: CGPoint(x: 0, y: h*0.18), radius: w*0.82,
startAngle: .degrees(180+12), endAngle: .degrees(180-12), clockwise: true)
ctx.stroke(p, with: .color(ink), style: StrokeStyle(lineWidth: w*0.5, lineCap: .round))
}
case .cup:
// Flat top, rounded bottom corners (like a cup / U-shape)
let hh = max(h * open, w * 0.3)
let cr = min(w / 2, hh / 2) // bottom corner radius
var p = Path()
p.move(to: CGPoint(x: -w/2, y: -hh/2))
p.addLine(to: CGPoint(x: w/2, y: -hh/2))
p.addLine(to: CGPoint(x: w/2, y: hh/2 - cr))
p.addQuadCurve(to: CGPoint(x: w/2 - cr, y: hh/2),
control: CGPoint(x: w/2, y: hh/2))
p.addLine(to: CGPoint(x: -w/2 + cr, y: hh/2))
p.addQuadCurve(to: CGPoint(x: -w/2, y: hh/2 - cr),
control: CGPoint(x: -w/2, y: hh/2))
p.closeSubpath()
ctx.fill(p, with: .color(ink))
}
}
private func drawBadge(context: GraphicsContext, size: CGSize, badge: BadgeType, R: CGFloat, rx: CGFloat, ry: CGFloat, cx: CGFloat, cy: CGFloat) {
let bs = badgeS * (isMini ? 1.25 : 1)
let bx = cx - R * 0.72 * sx
let by = cy - R * 0.72 * sy
var ctx = context
ctx.translateBy(x: bx, y: by)
ctx.scaleBy(x: bs, y: bs)
let now = CGFloat(CACurrentMediaTime())
switch badge {
case .dots(let col):
if isMini {
// Mini: animated pulsing dot
let phase = (now * 2.4).truncatingRemainder(dividingBy: 1)
let dotR = R * 0.22 * (1 + 0.25 * sin(phase * .pi * 2))
var outer = Path()
outer.addEllipse(in: CGRect(x: -R*0.2, y: -R*0.2, width: R*0.4, height: R*0.4))
ctx.fill(outer, with: .color(.black))
var dot = Path()
dot.addEllipse(in: CGRect(x: -dotR, y: -dotR, width: dotR*2, height: dotR*2))
ctx.fill(dot, with: .color(Color(cgColor: col)))
} else {
// Pill badge with animated dots (prototype style)
let pw: CGFloat = R * 0.72
let ph: CGFloat = R * 0.36
var pill = Path()
pill.addRoundedRect(in: CGRect(x: -pw/2, y: -ph/2, width: pw, height: ph),
cornerSize: CGSize(width: ph/2, height: ph/2))
ctx.fill(pill, with: .color(Color(cgColor: col)))
for i in 0..<3 {
let phase = ((now * 2.4 - CGFloat(i) * 0.22).truncatingRemainder(dividingBy: 1) + 1).truncatingRemainder(dividingBy: 1)
let dotR = R * 0.055 * (1 + 0.4 * max(0, sin(phase * .pi * 2)))
var dot = Path()
dot.addEllipse(in: CGRect(x: (CGFloat(i)-1)*R*0.18 - dotR, y: -dotR, width: dotR*2, height: dotR*2))
ctx.fill(dot, with: .color(.white))
}
}
case .bang(let col), .question(let col):
var ring = Path()
ring.addEllipse(in: CGRect(x: -R*0.3, y: -R*0.3, width: R*0.6, height: R*0.6))
ctx.fill(ring, with: .color(.black))
var inner = Path()
inner.addEllipse(in: CGRect(x: -R*0.23, y: -R*0.23, width: R*0.46, height: R*0.46))
ctx.fill(inner, with: .color(Color(cgColor: col)))
if !isMini {
let text = badge == .bang(col) ? "!" : "?"
ctx.draw(Text(text).font(.system(size: R*0.32, weight: .black)).foregroundColor(.white),
at: CGPoint(x: 0, y: R*0.02))
}
case .dot(let col):
var outer = Path()
outer.addEllipse(in: CGRect(x: -R*0.2, y: -R*0.2, width: R*0.4, height: R*0.4))
ctx.fill(outer, with: .color(.black))
var inner = Path()
inner.addEllipse(in: CGRect(x: -R*0.135, y: -R*0.135, width: R*0.27, height: R*0.27))
ctx.fill(inner, with: .color(Color(cgColor: col)))
}
}
private func drawParticles(context: GraphicsContext, size: CGSize, R: CGFloat, cx: CGFloat, cy: CGFloat) {
for p in particles {
guard p.age > 0 else { continue }
let k = CGFloat(p.age / p.life)
let a = k < 0.2 ? k / 0.2 : 1 - (k - 0.2) / 0.8
let px = cx + (p.x + p.vx * CGFloat(p.age)) * R * 1.3
let py = cy + (p.y + p.vy * CGFloat(p.age)) * R * 1.3
let sz = R * p.size * (1 + k * 0.4)
var pctx = context
pctx.translateBy(x: px, y: py)
pctx.opacity = Double(min(max(a, 0), 1))
switch p.type {
case .heart:
pctx.rotate(by: .radians(sin(CGFloat(p.age) * 6) * 0.3))
pctx.fill(heartShape(size: sz), with: .color(Color(hex: "#FF4D6D")))
case .star:
pctx.rotate(by: .radians(p.rot + CGFloat(p.age) * 2))
pctx.fill(starShape(outer: sz, inner: sz*0.45), with: .color(Color(hex: "#F7B32B")))
case .spark:
pctx.rotate(by: .radians(p.rot))
pctx.fill(starShape(outer: sz*0.8, inner: sz*0.18), with: .color(.white))
case .sweat:
var drop = Path()
drop.move(to: CGPoint(x: 0, y: -sz))
drop.addQuadCurve(to: CGPoint(x: 0, y: sz*0.6), control: CGPoint(x: sz*0.8, y: sz*0.2))
drop.addQuadCurve(to: CGPoint(x: 0, y: -sz), control: CGPoint(x: -sz*0.8, y: sz*0.2))
pctx.fill(drop, with: .color(Color(hex: "#7CC7FF")))
case .z:
pctx.draw(Text("z").font(.system(size: sz*1.9, weight: .bold)).foregroundColor(Color(red: 0.82, green: 0.86, blue: 0.92)),
at: .zero)
}
}
}
// MARK: - Tween helpers
func anim(_ key: String, keys: [TweenKey], onComplete: (() -> Void)? = nil) {
let current = getProperty(key)
tweens[key] = Tween(property: key, keys: keys, keyIndex: 0,
from: current, startTime: CACurrentMediaTime() * 1000,
onComplete: onComplete)
locks.insert(key)
}
private func setTarget(key: String, value: CGFloat) {
guard !locks.contains(key) else { return }
switch key {
case "tint": tint += (value - tint) // immediate target, smoothed in update
case "tilt": tgTilt = value
default: break
}
}
private func setProperty(_ key: String, value: CGFloat) {
switch key {
case "yaw": yaw = value
case "pitch": pitch = value
case "roll": roll = value
case "tilt": tilt = value
case "open": open = value
case "sx": sx = value
case "sy": sy = value
case "oy": oy = value
case "ox": ox = value
case "tint": tint = value
case "morph": morph = value
case "hands": hands = value
case "blush": blush = value
case "es": es = value
case "badgeS": badgeS = value
case "outfitPresence": outfitPresence = value
default: break
}
}
private func getProperty(_ key: String) -> CGFloat {
switch key {
case "yaw": return yaw
case "pitch": return pitch
case "roll": return roll
case "tilt": return tilt
case "open": return open
case "sx": return sx
case "sy": return sy
case "oy": return oy
case "ox": return ox
case "tint": return tint
case "morph": return morph
case "hands": return hands
case "blush": return blush
case "es": return es
case "badgeS": return badgeS
case "outfitPresence": return outfitPresence
default: return 0
}
}
}
// MARK: - Math helpers
func lerp(_ a: CGFloat, _ b: CGFloat, _ t: CGFloat) -> CGFloat { a + (b-a) * t }
private func clamp(_ v: CGFloat, _ lo: CGFloat, _ hi: CGFloat) -> CGFloat { max(lo, min(hi, v)) }
private func cgColorToTuple(_ c: CGColor) -> (CGFloat, CGFloat, CGFloat) {
guard let comps = c.components, comps.count >= 3 else { return (1,1,1) }
return (comps[0], comps[1], comps[2])
}
private func mix3(_ a: (CGFloat,CGFloat,CGFloat), _ b: (CGFloat,CGFloat,CGFloat), _ t: CGFloat) -> (CGFloat,CGFloat,CGFloat) {
(lerp(a.0,b.0,t), lerp(a.1,b.1,t), lerp(a.2,b.2,t))
}
private func mixColor(_ a: (CGFloat,CGFloat,CGFloat), _ b: (CGFloat,CGFloat,CGFloat), _ t: CGFloat) -> (CGFloat,CGFloat,CGFloat) {
mix3(a, b, t)
}
private func colorFromTuple(_ t: (CGFloat,CGFloat,CGFloat)) -> Color {
Color(red: Double(t.0), green: Double(t.1), blue: Double(t.2))
}
private func badgeString(_ b: BadgeType?) -> String {
guard let b else { return "none" }
func hex(_ c: CGColor) -> String {
guard let k = c.components, k.count >= 3 else { return "?" }
return "\(Int(k[0]*255)).\(Int(k[1]*255)).\(Int(k[2]*255))"
}
switch b {
case .dots(let c): return "dots-\(hex(c))"
case .bang(let c): return "bang-\(hex(c))"
case .question(let c): return "q-\(hex(c))"
case .dot(let c): return "dot-\(hex(c))"
}
}
private func emoteEyeShape(_ e: BotEmote) -> EyeShape {
switch e {
case .love: return .heart
case .surprised: return .dot
case .proud: return .star
case .wink: return .wink
case .yawn: return .tired
case .happy: return .happy
case .annoyed: return .line
}
}
// MARK: - Shape helpers
private func heartShape(size s: CGFloat) -> Path {
var p = Path()
p.move(to: CGPoint(x: 0, y: s * 0.38))
p.addCurve(to: CGPoint(x: 0, y: -s * 0.38),
control1: CGPoint(x: -s * 1.05, y: -s * 0.15),
control2: CGPoint(x: -s * 0.5, y: -s * 0.95))
p.addCurve(to: CGPoint(x: 0, y: s * 0.38),
control1: CGPoint(x: s * 0.5, y: -s * 0.95),
control2: CGPoint(x: s * 1.05, y: -s * 0.15))
p.closeSubpath()
return p
}
private func starShape(outer ro: CGFloat, inner ri: CGFloat) -> Path {
var p = Path()
for i in 0..<10 {
let r = i.isMultiple(of: 2) ? ro : ri
let a = -.pi/2 + CGFloat(i) * .pi/5
let pt = CGPoint(x: cos(a) * r, y: sin(a) * r)
if i == 0 { p.move(to: pt) } else { p.addLine(to: pt) }
}
p.closeSubpath()
return p
}
// Equatable for BadgeType (needed for comparing)
extension BadgeType: Equatable {
static func == (lhs: BadgeType, rhs: BadgeType) -> Bool {
switch (lhs, rhs) {
case (.dots, .dots): return true
case (.bang, .bang): return true
case (.question, .question): return true
case (.dot, .dot): return true
default: return false
}
}
}
// MARK: - BroV face
//
// BroV ("бровь") is a lumpy swamp-zombie blob: one huge hairy unibrow, a bloodshot
// mismatched pair of eyes, a toothy grin with missing teeth, drool, warts and a
// stitched scar. Everything rides on the same engine (blink, gaze, emotes, states);
// mini bots and the upload box keep the plain look.
extension BotEngine {
/// Main bot only, and not while morphing into the upload box.
var showsBroVFace: Bool { !isMini && bodyColor == nil && morph < 0.3 && outfit != .pumpkin }
/// Slow, breathing bumps on the outline (main bot only).
func broVLump(angle a: CGFloat, time t: CGFloat) -> CGFloat {
guard !isMini, bodyColor == nil else { return 1 }
let k = 1 - min(1, morph * 2)
let bumps = 0.045 * sin(3 * a + t * 1.1)
+ 0.030 * sin(5 * a - t * 0.7 + 1.3)
+ 0.018 * sin(9 * a + 2.1)
return 1 + bumps * k
}
/// Warts and the stitched scar (under the eyes).
func drawBroVSkin(ctx: GraphicsContext, path: Path, R: CGFloat, rx: CGFloat, ry: CGFloat) {
var c = ctx
c.clip(to: path)
let shift = sin(yaw) * rx * 0.8
let a = 1 - morph / 0.3
// Warts: darker bumps with a tiny highlight.
let warts: [(CGFloat, CGFloat, CGFloat)] = [(-0.78, 0.30, 0.085), (0.70, 0.52, 0.065), (-0.30, 0.68, 0.05), (0.85, -0.10, 0.045)]
for (wx, wy, wr) in warts {
let p = CGPoint(x: wx * rx + shift, y: wy * ry)
var e = Path(); e.addEllipse(in: CGRect(x: p.x - wr * R, y: p.y - wr * R, width: wr * 2 * R, height: wr * 2 * R))
c.fill(e, with: .color(Color(hex: "#3F5229").opacity(0.85 * Double(a))))
var h = Path(); h.addEllipse(in: CGRect(x: p.x - wr * R * 0.35, y: p.y - wr * R * 0.6, width: wr * R * 0.5, height: wr * R * 0.4))
c.fill(h, with: .color(Color.white.opacity(0.25 * Double(a))))
}
// Stitched scar across the upper right of the head.
let s0 = CGPoint(x: rx * 0.20 + shift, y: -ry * 0.78)
let s1 = CGPoint(x: rx * 0.72 + shift, y: -ry * 0.40)
var scar = Path(); scar.move(to: s0)
scar.addQuadCurve(to: s1, control: CGPoint(x: rx * 0.55 + shift, y: -ry * 0.75))
c.stroke(scar, with: .color(Color(hex: "#6B2B2B").opacity(Double(a))), style: StrokeStyle(lineWidth: R * 0.05, lineCap: .round))
for i in 1...4 {
let t = CGFloat(i) / 5
let mx = (1 - t) * (1 - t) * s0.x + 2 * (1 - t) * t * (rx * 0.55 + shift) + t * t * s1.x
let my = (1 - t) * (1 - t) * s0.y + 2 * (1 - t) * t * (-ry * 0.75) + t * t * s1.y
var st = Path()
st.move(to: CGPoint(x: mx - R * 0.06, y: my - R * 0.07))
st.addLine(to: CGPoint(x: mx + R * 0.06, y: my + R * 0.07))
c.stroke(st, with: .color(Color(hex: "#2A0A0A").opacity(0.8 * Double(a))), style: StrokeStyle(lineWidth: R * 0.025, lineCap: .round))
}
}
/// Bloodshot sclera + small twitchy pupil. The left eye is bigger.
func drawBroVEye(ctx: inout GraphicsContext, w: CGFloat, h: CGFloat, open: CGFloat, sd: CGFloat, ink: Color) {
let big: CGFloat = sd < 0 ? 1.28 : 0.92
let sw = w * 1.75 * big
let sh = max(h * 1.45 * big * open, w * 0.25)
let sclera = Path(ellipseIn: CGRect(x: -sw / 2, y: -sh / 2, width: sw, height: sh))
ctx.fill(sclera, with: .radialGradient(
Gradient(colors: [Color(hex: "#F4EFC8"), Color(hex: "#E2C98E"), Color(hex: "#C9785F")]),
center: .zero, startRadius: 0, endRadius: sw * 0.6))
guard open > 0.25 else {
ctx.stroke(sclera, with: .color(ink), lineWidth: w * 0.12)
return
}
var inner = ctx
inner.clip(to: sclera)
// Veins from the edge toward the pupil.
for i in 0..<6 {
let ang = CGFloat(i) * 1.05 + sd * 0.4
var v = Path()
let r0 = sw * 0.52, r1 = sw * 0.2
v.move(to: CGPoint(x: cos(ang) * r0, y: sin(ang) * r0 * sh / sw))
v.addQuadCurve(to: CGPoint(x: cos(ang + 0.15) * r1, y: sin(ang + 0.15) * r1 * sh / sw),
control: CGPoint(x: cos(ang - 0.25) * sw * 0.36, y: sin(ang - 0.25) * sw * 0.36 * sh / sw))
inner.stroke(v, with: .color(Color(hex: "#C2182B").opacity(0.75)), lineWidth: max(0.6, w * 0.06))
}
// Pupil with a jittery twitch.
let t = CGFloat(CACurrentMediaTime())
let jx = sin(t * 23 + sd * 3) * w * 0.04
let jy = cos(t * 19 + sd) * w * 0.03
let pr = w * 0.42 * (sd < 0 ? 0.8 : 1.15)
inner.fill(Path(ellipseIn: CGRect(x: -pr + jx, y: -pr + jy, width: pr * 2, height: pr * 2)), with: .color(ink))
inner.fill(Path(ellipseIn: CGRect(x: -pr * 0.3 + jx + pr * 0.35, y: -pr * 0.3 + jy - pr * 0.35, width: pr * 0.5, height: pr * 0.5)),
with: .color(Color.white.opacity(0.85)))
ctx.stroke(sclera, with: .color(Color(hex: "#3A1010").opacity(0.7)), lineWidth: max(0.8, w * 0.07))
}
/// Unibrow above the eyes and the toothy mouth below.
func drawBroVFace(ctx: GraphicsContext, path: Path, R: CGFloat, rx: CGFloat, ry: CGFloat) {
var c = ctx
c.clip(to: path)
let alpha = Double(1 - morph / 0.3)
let shift = sin(yaw) * rx * 0.8
let t = CGFloat(CACurrentMediaTime())
let eyeY = -sin(MochiConst.eyeP + pitch) * ry
let ink = Color(cgColor: MochiConst.ink).opacity(alpha)
// ── Unibrow ───────────────────────────────────────────────
// Shape by mood: angry V, worried ∧, raised on questions, droopy asleep.
let mid: CGFloat // center offset (positive = lower)
let tilt: CGFloat // extra raise of the right end
switch state {
case .approval, .error: mid = 0.10; tilt = 0
case .ratelimit: mid = -0.08; tilt = 0
case .question, .searching: mid = 0; tilt = -0.14
case .sleeping: mid = -0.02; tilt = 0.06
case .dizzy: mid = sin(t * 6) * 0.08; tilt = cos(t * 5) * 0.08
default: mid = 0.02 + sin(t * 1.7) * 0.015; tilt = 0
}
let by = eyeY - R * 0.42
let half = R * 0.78
let L = CGPoint(x: -half + shift, y: by - R * 0.04)
let M = CGPoint(x: shift, y: by + mid * R)
let Rt = CGPoint(x: half + shift, y: by - R * 0.04 + tilt * R)
var brow = Path()
brow.move(to: L)
brow.addQuadCurve(to: M, control: CGPoint(x: (L.x + M.x) / 2, y: min(L.y, M.y) - R * 0.07))
brow.addQuadCurve(to: Rt, control: CGPoint(x: (M.x + Rt.x) / 2, y: min(M.y, Rt.y) - R * 0.07))
c.stroke(brow, with: .color(ink), style: StrokeStyle(lineWidth: R * 0.17, lineCap: .round, lineJoin: .round))
// Hairs sticking out.
for i in 0..<22 {
let u = CGFloat(i) / 21
let p = u < 0.5
? quad(L, CGPoint(x: (L.x + M.x) / 2, y: min(L.y, M.y) - R * 0.07), M, u * 2)
: quad(M, CGPoint(x: (M.x + Rt.x) / 2, y: min(M.y, Rt.y) - R * 0.07), Rt, (u - 0.5) * 2)
let ang = -CGFloat.pi / 2 + sin(CGFloat(i) * 2.7) * 0.7
let len = R * (0.10 + 0.05 * abs(sin(CGFloat(i) * 1.9)))
var hair = Path()
hair.move(to: p)
hair.addLine(to: CGPoint(x: p.x + cos(ang) * len, y: p.y + sin(ang) * len))
c.stroke(hair, with: .color(ink), style: StrokeStyle(lineWidth: max(0.7, R * 0.025), lineCap: .round))
}
// ── Mouth ─────────────────────────────────────────────────
let my = eyeY + R * 0.42
let mw: CGFloat
let mh: CGFloat
switch state {
case .approval: mw = R * 0.95; mh = R * 0.34 // screaming
case .error: mw = R * 0.70; mh = R * 0.10
case .finished: mw = R * 1.00; mh = R * 0.26
case .sleeping: mw = R * 0.30; mh = R * 0.14
case .working, .thinking, .searching:
mw = R * 0.80; mh = R * (0.12 + 0.05 * abs(sin(t * 7))) // chattering
default: mw = R * 0.88; mh = R * 0.20
}
let mx = shift
var mouth = Path()
mouth.move(to: CGPoint(x: mx - mw / 2, y: my - mh * 0.2))
mouth.addQuadCurve(to: CGPoint(x: mx + mw / 2, y: my - mh * 0.2), control: CGPoint(x: mx, y: my + mh * 0.25))
mouth.addQuadCurve(to: CGPoint(x: mx - mw / 2, y: my - mh * 0.2), control: CGPoint(x: mx, y: my + mh * 1.6))
mouth.closeSubpath()
c.fill(mouth, with: .color(Color(hex: "#3A0505").opacity(alpha)))
var teethCtx = c
teethCtx.clip(to: mouth)
let teeth = 7
for i in 0..<teeth where i != 2 && i != 5 { // missing teeth
let tx = mx - mw / 2 + mw * (CGFloat(i) + 0.5) / CGFloat(teeth)
let tw = mw / CGFloat(teeth) * 0.9
let ty = my - mh * 0.2 + (abs(tx - mx) / (mw / 2)) * -mh * 0.05 + mh * 0.12
var tooth = Path()
tooth.move(to: CGPoint(x: tx - tw / 2, y: ty - mh))
tooth.addLine(to: CGPoint(x: tx + tw / 2, y: ty - mh))
tooth.addLine(to: CGPoint(x: tx + tw * 0.1, y: ty + mh * 0.32))
tooth.closeSubpath()
teethCtx.fill(tooth, with: .color(Color(hex: i == 4 ? "#C9B458" : "#EDE6C8").opacity(alpha)))
}
c.stroke(mouth, with: .color(ink), lineWidth: max(0.8, R * 0.03))
// Drool from the left corner: grows, stretches, drops, repeats.
if state != .error {
let cycle = (t * 0.45).truncatingRemainder(dividingBy: 1)
let len = R * 0.35 * cycle
let dx = mx - mw * 0.32
let dy = my + mh * 0.25
var drool = Path()
drool.move(to: CGPoint(x: dx - R * 0.03, y: dy))
drool.addQuadCurve(to: CGPoint(x: dx, y: dy + len), control: CGPoint(x: dx - R * 0.02, y: dy + len * 0.6))
drool.addQuadCurve(to: CGPoint(x: dx + R * 0.03, y: dy), control: CGPoint(x: dx + R * 0.02, y: dy + len * 0.6))
ctx.fill(drool, with: .color(Color(hex: "#C8E6B0").opacity(0.75 * alpha)))
let dr = R * (0.035 + 0.03 * cycle)
ctx.fill(Path(ellipseIn: CGRect(x: dx - dr, y: dy + len - dr * 0.6, width: dr * 2, height: dr * 2.2)),
with: .color(Color(hex: "#C8E6B0").opacity(0.8 * alpha)))
}
}
private func quad(_ a: CGPoint, _ c: CGPoint, _ b: CGPoint, _ t: CGFloat) -> CGPoint {
let u = 1 - t
return CGPoint(x: u * u * a.x + 2 * u * t * c.x + t * t * b.x,
y: u * u * a.y + 2 * u * t * c.y + t * t * b.y)
}
}