Files
2026-10-06 19:34:32 +03:00

332 lines
13 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.
// 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")
}
}
}