BroV look: swamp-zombie unibrow character, new app and menu bar icons, BroV naming in UI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,9 +79,10 @@ enum MochiConst {
|
||||
static let eyeH: CGFloat = 0.27
|
||||
static let eyeSp: CGFloat = 0.37
|
||||
static let eyeP: CGFloat = -0.12
|
||||
static let baseTop = CGColor(red: 0.929, green: 0.929, blue: 0.937, alpha: 1) // #EDEDEF
|
||||
static let baseBottom = CGColor(red: 0.769, green: 0.773, blue: 0.792, alpha: 1) // #C4C5CA
|
||||
static let ink = CGColor(red: 0.102, green: 0.082, blue: 0.071, alpha: 1) // #1A1412
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -916,9 +917,13 @@ final class BotEngine: ObservableObject {
|
||||
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 {
|
||||
@@ -1135,12 +1140,14 @@ final class BotEngine: ObservableObject {
|
||||
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 px0 = rx * (ca >= 0 ? pow(ca, expN) : -pow(-ca, expN))
|
||||
let py0 = ry * (sa >= 0 ? pow(sa, expN) : -pow(-sa, expN))
|
||||
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 {
|
||||
@@ -1205,8 +1212,8 @@ final class BotEngine: ObservableObject {
|
||||
ctx.fill(path, with: .color(Color(cgColor: bc)))
|
||||
} else {
|
||||
// Main bot: linear gradient body
|
||||
let top: Color = pumpkinColors ? Color(hex: "#FFA94D") : Color(red: 0.929, green: 0.929, blue: 0.937)
|
||||
let bot: Color = pumpkinColors ? Color(hex: "#E8590C") : Color(red: 0.769, green: 0.773, blue: 0.792)
|
||||
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),
|
||||
@@ -1307,6 +1314,9 @@ final class BotEngine: ObservableObject {
|
||||
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()
|
||||
@@ -1657,3 +1667,201 @@ extension BadgeType: Equatable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user