53b4f60da1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
332 lines
13 KiB
Swift
332 lines
13 KiB
Swift
// RenderOutfits.swift — standalone planche renderer for Mochi outfits
|
||
// Compile + run via: bash scripts/render-outfits.sh
|
||
// Output: /tmp/coucou-outfits.png
|
||
|
||
import Foundation
|
||
import SwiftUI
|
||
import AppKit
|
||
|
||
// MARK: - Stubs (substitutes for app-only types)
|
||
|
||
@MainActor
|
||
final class SoundEngine {
|
||
static let shared = SoundEngine()
|
||
var enabled: Bool = false
|
||
func play(_ name: String) {}
|
||
}
|
||
|
||
extension Notification.Name {
|
||
static let botDizzy = Notification.Name("notchBuddy.botDizzy")
|
||
static let botGreet = Notification.Name("notchBuddy.botGreet")
|
||
static let botBlink = Notification.Name("notchBuddy.botBlink")
|
||
static let botSetTgEs = Notification.Name("notchBuddy.botSetTgEs")
|
||
static let botGulp = Notification.Name("notchBuddy.botGulp")
|
||
static let botMorphTo = Notification.Name("notchBuddy.botMorphTo")
|
||
static let triggerEmote = Notification.Name("notchBuddy.triggerEmote")
|
||
static let triggerSlap = Notification.Name("notchBuddy.triggerSlap")
|
||
static let greetComplete = Notification.Name("notchBuddy.greetComplete")
|
||
static let greetingHover = Notification.Name("notchBuddy.greetingHover")
|
||
static let greetingInterrupt = Notification.Name("notchBuddy.greetingInterrupt")
|
||
static let islandAction = Notification.Name("notchBuddy.islandAction")
|
||
static let islandCollapse = Notification.Name("notchBuddy.islandCollapse")
|
||
static let hookReveal = Notification.Name("notchBuddy.hookReveal")
|
||
static let musicReveal = Notification.Name("notchBuddy.musicReveal")
|
||
static let openFullSettings = Notification.Name("notchBuddy.openFullSettings")
|
||
}
|
||
|
||
extension Color {
|
||
init(hex: String) {
|
||
let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||
let val = UInt64(h, radix: 16) ?? 0
|
||
let r = Double((val >> 16) & 0xFF) / 255
|
||
let g = Double((val >> 8) & 0xFF) / 255
|
||
let b = Double( val & 0xFF) / 255
|
||
self.init(red: r, green: g, blue: b)
|
||
}
|
||
}
|
||
|
||
// MARK: - Cell view — one outfit at one pose
|
||
|
||
struct MochiCell: View {
|
||
let outfit: Outfit
|
||
let yaw: CGFloat
|
||
let pitch: CGFloat
|
||
let tilt: CGFloat
|
||
let phys: (dx: CGFloat, dy: CGFloat)
|
||
let cellSize: CGFloat
|
||
var roll: CGFloat = 0
|
||
var presence: CGFloat = 1
|
||
|
||
var body: some View {
|
||
Canvas { context, sz in
|
||
let W = sz.width, H = sz.height
|
||
// scale=0.62, R = W * 0.62 * 0.3
|
||
let R = W * 0.62 * 0.3
|
||
let rx = R * 1.14
|
||
let ry = R * 0.88
|
||
let cx = W / 2
|
||
let cy = H / 2 + R * 0.06
|
||
let sx: CGFloat = 1, sy: CGFloat = 1
|
||
let morph: CGFloat = 0
|
||
|
||
let mH = MochiH(R: R, yaw: yaw, pitch: pitch, physDx: phys.dx, physDy: phys.dy, roll: roll)
|
||
|
||
// 1. Behind-body outfit
|
||
drawOutfitBehindStatic(
|
||
context: context, outfit: outfit, H: mH,
|
||
cx: cx, cy: cy, tilt: tilt, sx: sx, sy: sy,
|
||
roll: roll, morph: morph, isMini: false, presence: presence
|
||
)
|
||
|
||
// 2. Body (replicate BotEngine.drawBody for idle / pumpkin state)
|
||
var bCtx = context
|
||
bCtx.translateBy(x: cx, y: cy)
|
||
if tilt != 0 { bCtx.rotate(by: .radians(tilt)) }
|
||
bCtx.scaleBy(x: sx, y: sy)
|
||
let body = mochiOutfitPath(rx, ry)
|
||
|
||
// Base gradient (pumpkin-aware)
|
||
let pumpkin = outfit == .pumpkin
|
||
let cTop: Color = pumpkin ? Color(hex: "#FFA94D") : Color(red: 0.929, green: 0.929, blue: 0.937)
|
||
let cBot: Color = pumpkin ? Color(hex: "#E8590C") : Color(red: 0.769, green: 0.773, blue: 0.792)
|
||
bCtx.fill(body, with: .linearGradient(
|
||
Gradient(colors: [cTop, cBot]),
|
||
startPoint: CGPoint(x: rx * 0.7, y: -ry * 0.85),
|
||
endPoint: CGPoint(x: -rx * 0.8, y: ry * 0.9)
|
||
))
|
||
// Shadow rim
|
||
bCtx.fill(body, with: .radialGradient(
|
||
Gradient(stops: [
|
||
.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
|
||
))
|
||
// Top-left highlight
|
||
bCtx.fill(body, 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
|
||
))
|
||
|
||
// 3. Eyes (clipped to body)
|
||
var eyeBase = bCtx
|
||
eyeBase.clip(to: body)
|
||
let ink = Color(red: 0.102, green: 0.082, blue: 0.071)
|
||
for f in mEyeFrames(mH) {
|
||
guard f.visible else { continue }
|
||
var eCtx = eyeBase
|
||
eCtx.translateBy(x: f.x, y: f.y)
|
||
eCtx.scaleBy(x: f.fx, y: f.fy)
|
||
let hh = max(f.h, f.w * 0.3)
|
||
var pill = Path()
|
||
pill.addRoundedRect(
|
||
in: CGRect(x: -f.w / 2, y: -hh / 2, width: f.w, height: hh),
|
||
cornerSize: CGSize(width: min(f.w / 2, hh / 2), height: min(f.w / 2, hh / 2))
|
||
)
|
||
eCtx.fill(pill, with: .color(ink))
|
||
}
|
||
|
||
// 4. Front outfit
|
||
drawOutfitFrontStatic(
|
||
context: context, outfit: outfit, H: mH,
|
||
cx: cx, cy: cy, tilt: tilt, sx: sx, sy: sy,
|
||
roll: roll, morph: morph, isMini: false, presence: presence
|
||
)
|
||
}
|
||
.frame(width: cellSize, height: cellSize)
|
||
}
|
||
}
|
||
|
||
// MARK: - Grid view
|
||
|
||
// Outfit order matching sheet.html
|
||
private let targetOutfits: [Outfit] = [
|
||
.none, .beanie, .santaHat, .partyHat, .crown, .witchHat,
|
||
.sunglasses, .roundGlasses, .scarf, .pumpkin, .bow,
|
||
]
|
||
|
||
private struct PoseSpec {
|
||
let label: String
|
||
let yaw: CGFloat
|
||
let pitch: CGFloat
|
||
let tilt: CGFloat
|
||
let phys: (dx: CGFloat, dy: CGFloat)
|
||
let size: CGFloat
|
||
}
|
||
|
||
// Columns matching sheet.html cols array + small pill-preview column (W=190, scale=0.62; small=64)
|
||
private let poses: [PoseSpec] = [
|
||
PoseSpec(label: "L", yaw: -0.5, pitch: 0, tilt: 0, phys: (0.6, 0), size: 190),
|
||
PoseSpec(label: "front", yaw: 0, pitch: 0, tilt: 0, phys: (0, 0), size: 190),
|
||
PoseSpec(label: "R", yaw: 0.5, pitch: 0, tilt: 0, phys: (-0.6, 0), size: 190),
|
||
PoseSpec(label: "up", yaw: 0.15, pitch: 0.4, tilt: 0, phys: (0, 0.4), size: 190),
|
||
PoseSpec(label: "down", yaw: -0.2, pitch: -0.5, tilt: 0, phys: (0, -0.4), size: 190),
|
||
PoseSpec(label: "tilt", yaw: 0.3, pitch: -0.25, tilt: 0.12, phys: (-0.3, 0), size: 190),
|
||
PoseSpec(label: "mini", yaw: 0, pitch: 0, tilt: 0, phys: (0, 0), size: 64),
|
||
]
|
||
|
||
private let labelW: CGFloat = 90
|
||
private let gap: CGFloat = 6
|
||
|
||
struct OutfitGrid: View {
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: gap) {
|
||
// Column headers
|
||
HStack(spacing: gap) {
|
||
Spacer().frame(width: labelW)
|
||
ForEach(poses.indices, id: \.self) { i in
|
||
Text(poses[i].label)
|
||
.font(.system(size: 9, design: .monospaced))
|
||
.foregroundColor(.gray)
|
||
.frame(width: poses[i].size, alignment: .center)
|
||
}
|
||
}
|
||
// One row per outfit
|
||
ForEach(targetOutfits.indices, id: \.self) { oi in
|
||
let outfit = targetOutfits[oi]
|
||
HStack(spacing: gap) {
|
||
Text(outfit.rawValue)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(.white)
|
||
.frame(width: labelW, alignment: .trailing)
|
||
ForEach(poses.indices, id: \.self) { pi in
|
||
let p = poses[pi]
|
||
MochiCell(outfit: outfit, yaw: p.yaw, pitch: p.pitch,
|
||
tilt: p.tilt, phys: p.phys, cellSize: p.size)
|
||
.background(pi == poses.count - 1
|
||
? Color.black
|
||
: Color(red: 0.083, green: 0.090, blue: 0.106))
|
||
.clipShape(RoundedRectangle(cornerRadius: pi == poses.count - 1 ? 0 : 8))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(12)
|
||
.background(Color(red: 0.043, green: 0.047, blue: 0.055))
|
||
}
|
||
}
|
||
|
||
// MARK: - Roll planche
|
||
|
||
private let rollOutfits: [Outfit] = [
|
||
.none, .beanie, .santaHat, .witchHat, .crown,
|
||
.sunglasses, .roundGlasses, .scarf, .bow, .pumpkin, .bunnyEars
|
||
]
|
||
|
||
private let rollValues: [CGFloat] = [0, CGFloat.pi/3, CGFloat.pi*2/3, CGFloat.pi, CGFloat.pi*4/3, CGFloat.pi*5/3]
|
||
|
||
struct RollGrid: View {
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: gap) {
|
||
// Column headers
|
||
HStack(spacing: gap) {
|
||
Spacer().frame(width: labelW)
|
||
ForEach(rollValues.indices, id: \.self) { i in
|
||
Text(String(format: "%.2fπ", rollValues[i] / .pi))
|
||
.font(.system(size: 9, design: .monospaced))
|
||
.foregroundColor(.gray)
|
||
.frame(width: 120, alignment: .center)
|
||
}
|
||
}
|
||
ForEach(rollOutfits.indices, id: \.self) { oi in
|
||
let outfit = rollOutfits[oi]
|
||
HStack(spacing: gap) {
|
||
Text(outfit.rawValue)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(.white)
|
||
.frame(width: labelW, alignment: .trailing)
|
||
ForEach(rollValues.indices, id: \.self) { ri in
|
||
MochiCell(outfit: outfit, yaw: 0, pitch: 0, tilt: 0, phys: (0, 0),
|
||
cellSize: 120, roll: rollValues[ri])
|
||
.background(Color(red: 0.083, green: 0.090, blue: 0.106))
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(12)
|
||
.background(Color(red: 0.043, green: 0.047, blue: 0.055))
|
||
}
|
||
}
|
||
|
||
// MARK: - Transition planche
|
||
|
||
private let transitionOutfits: [Outfit] = [.witchHat, .santaHat, .bunnyEars, .scarf]
|
||
private let presenceValues: [CGFloat] = [0, 0.3, 0.6, 1]
|
||
|
||
struct TransitionGrid: View {
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: gap) {
|
||
// Column headers
|
||
HStack(spacing: gap) {
|
||
Spacer().frame(width: labelW)
|
||
ForEach(presenceValues.indices, id: \.self) { i in
|
||
Text(String(format: "p=%.1f", presenceValues[i]))
|
||
.font(.system(size: 9, design: .monospaced))
|
||
.foregroundColor(.gray)
|
||
.frame(width: 120, alignment: .center)
|
||
}
|
||
}
|
||
ForEach(transitionOutfits.indices, id: \.self) { oi in
|
||
let outfit = transitionOutfits[oi]
|
||
HStack(spacing: gap) {
|
||
Text(outfit.rawValue)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(.white)
|
||
.frame(width: labelW, alignment: .trailing)
|
||
ForEach(presenceValues.indices, id: \.self) { pi in
|
||
MochiCell(outfit: outfit, yaw: 0, pitch: 0, tilt: 0, phys: (0, 0),
|
||
cellSize: 120, presence: presenceValues[pi])
|
||
.background(Color(red: 0.083, green: 0.090, blue: 0.106))
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(12)
|
||
.background(Color(red: 0.043, green: 0.047, blue: 0.055))
|
||
}
|
||
}
|
||
|
||
// MARK: - Helper to render and save a view
|
||
|
||
@MainActor
|
||
private func renderAndSave<V: View>(_ view: V, path: String) {
|
||
let renderer = ImageRenderer(content: view)
|
||
renderer.scale = 2.0
|
||
guard let nsImage = renderer.nsImage else {
|
||
print("Error: ImageRenderer returned nil for \(path)")
|
||
return
|
||
}
|
||
guard let tiff = nsImage.tiffRepresentation,
|
||
let rep = NSBitmapImageRep(data: tiff),
|
||
let png = rep.representation(using: .png, properties: [:])
|
||
else {
|
||
print("Error: PNG conversion failed for \(path)")
|
||
return
|
||
}
|
||
do {
|
||
try png.write(to: URL(fileURLWithPath: path))
|
||
print("✓ \(path) — \(Int(nsImage.size.width))×\(Int(nsImage.size.height)) @ 2×")
|
||
} catch {
|
||
print("Error writing \(path): \(error)")
|
||
}
|
||
}
|
||
|
||
// MARK: - Entry point
|
||
|
||
@main
|
||
struct RenderOutfits {
|
||
static func main() {
|
||
MainActor.assumeIsolated {
|
||
renderAndSave(OutfitGrid(), path: "/tmp/coucou-outfits.png")
|
||
renderAndSave(RollGrid(), path: "/tmp/coucou-roll.png")
|
||
renderAndSave(TransitionGrid(), path: "/tmp/coucou-transition.png")
|
||
}
|
||
}
|
||
}
|