From c3363c21663c623db491900b9db4cac41ce62298 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 8 Oct 2026 17:07:49 -0700 Subject: [PATCH] Make prox eggs visibly hatch into standing creatures --- slab/menubar-swift/README.md | 5 +- .../SlabMenubar/ProxCreatureFrames.swift | 97 +++++++++++++++---- .../tests/prox-creature-preview.swift | 22 ++--- 3 files changed, 91 insertions(+), 33 deletions(-) diff --git a/slab/menubar-swift/README.md b/slab/menubar-swift/README.md index 5cd1780837..b29648ade7 100644 --- a/slab/menubar-swift/README.md +++ b/slab/menubar-swift/README.md @@ -92,7 +92,10 @@ keep their existing layouts. `ProxCreatures.swift` saves a versioned appearance under `~/.config/slab/creatures/.json`. Only observed working/rendering time counts toward growth: stirring after 30 minutes, hatchling after two hours, -familiar after eight. Sleep, closed sessions, and idle time add nothing. Every +familiar after eight. Stirring opens a jagged shell with a head and hands peeking +out; hatchlings stand free on their own feet, and familiars grow a taller torso. +These stage shapes do not depend on inference or acquired features. Sleep, +closed sessions, and idle time add nothing. Every existing prox starts as an egg on first adoption. Saves happen at most once a minute, plus births and appearance changes; a sudden shutdown can lose up to a minute of growth. diff --git a/slab/menubar-swift/Sources/SlabMenubar/ProxCreatureFrames.swift b/slab/menubar-swift/Sources/SlabMenubar/ProxCreatureFrames.swift index 8de83ae6ac..db4ed4e246 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/ProxCreatureFrames.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ProxCreatureFrames.swift @@ -51,15 +51,24 @@ enum ProxCreatureFrames { } let shellMaterial = material(shell) let accentMaterial = material(accent) + let skinMaterial = material(NSColor(calibratedHue: hue, saturation: 0.40 + unit(16) * 0.16, + brightness: 0.92, alpha: 1)) let inkMaterial = material(ink, flat: true) let creatureNode = SCNNode() scene.rootNode.addChildNode(creatureNode) let width = Float(0.72 + unit(8) * 0.12) let depth: Float = 0.68 - let height: Float = creature.stage == .egg ? 1.02 : 1.08 - let body = SCNNode(geometry: egg(width: width, height: height, depth: depth)) - body.geometry?.firstMaterial = shellMaterial - creatureNode.addChildNode(body) + let height: Float = 1.02 + let enclosed = creature.stage == .egg + let peeking = creature.stage == .stirring + let grown = creature.stage == .familiar + let face = SCNNode() + creatureNode.addChildNode(face) + let headWidth: Float = width * (grown ? 0.76 : 0.83) + let headHeight: Float = grown ? 0.51 : 0.57 + let headDepth: Float = 0.53 + let headY: Float = grown ? 0.66 : 0.49 + var hands: [SCNNode] = [] @discardableResult func oval(_ parent: SCNNode, _ position: SCNVector3, _ scale: SCNVector3, @@ -73,32 +82,76 @@ enum ProxCreatureFrames { parent.addChildNode(node) return node } + if enclosed || peeking { + let shellNode = SCNNode(geometry: egg(width: width, height: height, depth: depth, + open: peeking)) + shellNode.geometry?.firstMaterial = shellMaterial + creatureNode.addChildNode(shellNode) + } + if !enclosed { + // Hatching is anatomy, not an inferred accessory: every creature + // gets a distinct head, torso and hands even when inference is off. + face.position.y = CGFloat(headY) + oval(creatureNode, SCNVector3(0, headY, 0), + SCNVector3(headWidth, headHeight, headDepth), skinMaterial) + oval(creatureNode, SCNVector3(0, grown ? -0.28 : -0.30, -0.06), + SCNVector3(width * 0.50, grown ? 0.62 : 0.49, 0.34), skinMaterial) + oval(creatureNode, SCNVector3(0, -0.32, 0.275), + SCNVector3(width * 0.29, grown ? 0.36 : 0.26, 0.04), shellMaterial) + for sign: Float in [-1, 1] { + let arm = oval(creatureNode, + SCNVector3(sign * (peeking ? width * 0.86 : width * 0.59), peeking ? 0.09 : -0.32, 0), + SCNVector3(0.13, peeking ? 0.24 : 0.32, 0.14), skinMaterial) + arm.eulerAngles.z = CGFloat(sign * (peeking ? 0.8 : 0.45)) + hands.append(oval(creatureNode, + SCNVector3(sign * (peeking ? width : width * 0.82), peeking ? 0.16 : -0.53, 0.22), + SCNVector3(0.16, 0.15, 0.17), skinMaterial)) + if !peeking { + oval(creatureNode, SCNVector3(sign * 0.20, -0.78, 0), + SCNVector3(0.12, 0.23, 0.14), skinMaterial) + oval(creatureNode, SCNVector3(sign * 0.24, -0.96, 0.13), + SCNVector3(0.23, 0.14, 0.30), accentMaterial) + } + } + if peeking { + // A discarded cap makes the broken shell legible at badge size. + let cap = SCNNode(geometry: egg(width: width, height: height, depth: depth, open: true)) + cap.geometry?.firstMaterial = shellMaterial + cap.scale = SCNVector3(0.35, 0.32, 0.35) + cap.position = SCNVector3(-0.78, -0.87, 0.20) + cap.eulerAngles.z = 0.8 + creatureNode.addChildNode(cap) + } + } func front(_ x: Float, _ y: Float) -> Float { + if !enclosed { + return headDepth * sqrt(max(0, 1 - pow(y / headHeight, 2) - pow(x / headWidth, 2))) + } let v = y / height let taper = 1 - 0.20 * v return depth * taper * sqrt(max(0, 1 - v * v - pow(x / (width * taper), 2))) } - // A few permanent shell freckles; the center remains clear for the face. + // The same seed freckles migrate from the shell to the forehead. for i in 0..<7 { let sign: Float = i % 2 == 0 ? -1 : 1 - let x = sign * Float(0.37 + unit((i * 7) % 48) * 0.15) - let y = Float(0.40 + unit((i * 5 + 4) % 48) * 0.32) + let x = sign * Float(0.37 + unit((i * 7) % 48) * 0.15) * (enclosed ? 1 : 0.74) + let y = Float(0.40 + unit((i * 5 + 4) % 48) * 0.32) * (enclosed ? 1 : 0.58) let radius = Float(0.023 + unit((i * 3 + 9) % 48) * 0.025) - oval(creatureNode, SCNVector3(x, y, front(x, y)), + oval(face, SCNVector3(x, y, front(x, y) + 0.008), SCNVector3(radius, radius * 0.8, 0.012), accentMaterial) } var eyes: [SCNNode] = [] let eyeSpread = Float(0.22 + unit(24) * 0.07) for sign: Float in [-1, 1] { let x = sign * eyeSpread - let eye = oval(creatureNode, SCNVector3(x, 0.10, front(x, 0.10) + 0.018), + let eye = oval(face, SCNVector3(x, 0.10, front(x, 0.10) + 0.018), SCNVector3(0.075, 0.105, 0.045), inkMaterial) oval(eye, SCNVector3(-0.22, 0.30, 0.90), SCNVector3(0.25, 0.21, 0.15), material(.white, flat: true)) eyes.append(eye) } let mouth = SCNNode() - creatureNode.addChildNode(mouth) + face.addChildNode(mouth) if mood == .waiting { oval(mouth, SCNVector3(0, -0.19, front(0, -0.19) + 0.02), SCNVector3(0.044, 0.060, 0.02), inkMaterial) @@ -114,8 +167,8 @@ enum ProxCreatureFrames { switch trait.feature { case .ears: for sign: Float in [-1, 1] { - let ear = oval(creatureNode, SCNVector3(sign * 0.46, 0.97, 0), - SCNVector3(0.17, 0.39, 0.17), shellMaterial) + let ear = oval(creatureNode, SCNVector3(sign * 0.40, 1.02, 0), + SCNVector3(0.17, 0.34, 0.17), skinMaterial) ear.eulerAngles.z = CGFloat(-sign * 0.24) oval(ear, SCNVector3(0, 0.12, 0.87), SCNVector3(0.49, 0.68, 0.22), accentMaterial) } @@ -128,19 +181,19 @@ enum ProxCreatureFrames { } case .fins: for sign: Float in [-1, 1] { - let fin = oval(creatureNode, SCNVector3(sign * width, -0.23, -0.05), + let fin = oval(creatureNode, SCNVector3(sign * (enclosed ? width : width * 0.61), -0.23, -0.09), SCNVector3(0.23, 0.37, 0.12), accentMaterial) fin.eulerAngles.z = CGFloat(sign * 0.55) } case .feet: for sign: Float in [-1, 1] { - oval(creatureNode, SCNVector3(sign * 0.32, -1.01, 0.20), - SCNVector3(0.22, 0.13, 0.32), accentMaterial) + oval(creatureNode, SCNVector3(sign * 0.27, -0.99, 0.28), + SCNVector3(0.29, 0.15, 0.38), accentMaterial) } case .tail: for i in 0..<8 { let t = Float(i) / 7 - oval(creatureNode, SCNVector3(0.57 + t * 0.49, -0.62 + t * t * 0.48, -0.12), + oval(creatureNode, SCNVector3(0.31 + t * 0.65, -0.62 + t * t * 0.48, -0.12), SCNVector3(0.13 - t * 0.045, 0.13 - t * 0.045, 0.12), accentMaterial) } } @@ -171,6 +224,9 @@ enum ProxCreatureFrames { creatureNode.position.y = CGFloat(sin(angle * 2) * 0.032 * energy) creatureNode.scale = SCNVector3(1 - sin(angle) * 0.012 * energy, 1 + sin(angle) * 0.018 * energy, 1) + for (i, hand) in hands.enumerated() { + hand.eulerAngles.z = CGFloat(sin(angle + Float(i) * .pi) * 0.18 * energy) + } let blinkCenter = Float(0.70 + unit(32) * 0.18) let blink = max(0, 1 - abs(t - blinkCenter) / 0.045) let openness: Float = mood == .sleeping ? 0.12 : max(0.12, 1 - blink) @@ -182,15 +238,18 @@ enum ProxCreatureFrames { } /// Smooth egg: the upper hemisphere narrows while the base stays full. - private static func egg(width: Float, height: Float, depth: Float) -> SCNGeometry { + private static func egg(width: Float, height: Float, depth: Float, open: Bool = false) -> SCNGeometry { let rings = 28, sides = 40 var positions: [SCNVector3] = [], normals: [SCNVector3] = [] var indices: [Int32] = [] for row in 0...rings { - let theta = Float(row) / Float(rings) * .pi - let y = cos(theta), radius = sin(theta), taper = 1 - 0.20 * y for col in 0...sides { let phi = Float(col) / Float(sides) * 2 * .pi + // Clip to a zigzag rim; the shell never encloses the new head. + let rim: Float = [0.17, 0.04, -0.09, 0.04][col % 4] + let start = open ? acos(rim) : 0 + let theta = start + Float(row) / Float(rings) * (.pi - start) + let y = cos(theta), radius = sin(theta), taper = 1 - 0.20 * y positions.append(SCNVector3(width * radius * taper * cos(phi), height * y, depth * radius * taper * sin(phi))) let normal = simd_normalize(SIMD3(radius * cos(phi) / (width * taper), diff --git a/slab/menubar-swift/tests/prox-creature-preview.swift b/slab/menubar-swift/tests/prox-creature-preview.swift index e2a66da096..2e17ee5e19 100644 --- a/slab/menubar-swift/tests/prox-creature-preview.swift +++ b/slab/menubar-swift/tests/prox-creature-preview.swift @@ -12,30 +12,26 @@ enum CreaturePreview { let canvas = NSImage(size: NSSize(width: 960, height: 640)) var portraits: [(String, CGImage, NSRect)] = [] for i in 0..<8 { - var creature = ProxCreature.egg(id: "preview-\(i)", name: "miva", seed: [123, 4412, 9918, 2306][i % 4]) - if i >= 2 { creature.grow(by: 7200) } - if i >= 4 { - creature.acquire([.ears, .sprout, .fins, .tail][i - 4], provider: .local) - creature.grow(by: 7200) - creature.acquire(.feet, provider: .local) - } - let mood: ProxCreatureFrames.Mood = [.working, .waiting, .resting, .sleeping][i % 4] + var creature = ProxCreature.egg(id: "preview", name: "miva", seed: 123) + creature.grow(by: [0, 1800, 7200, 28800][i % 4]) + // Every stage must read without any inference-selected traits. + let mood: ProxCreatureFrames.Mood = .working let frames = ProxCreatureFrames.render(creature, dark: i < 4, sunHx: -0.45, sunElevation: 0.72, sunIntensity: 0.8, - mood: mood, luminous: i == 7, frameCount: i == 0 ? 72 : 1, px: 240) + mood: mood, luminous: false, frameCount: i == 1 ? 72 : 1, px: 240) guard let first = frames.first else { fatalError("Missing render \(i)") } let rect = NSRect(x: (i % 4) * 240, y: i < 4 ? 320 : 0, width: 240, height: 320) portraits.append(("\(creature.stage.rawValue) ยท \(mood.rawValue)", first, rect)) try JSONEncoder().encode(creature).write(to: directory.appendingPathComponent("character-\(i).json")) - if i == 0 { - let gif = CGImageDestinationCreateWithURL(directory.appendingPathComponent("egg.gif") as CFURL, + if i == 1 { + let gif = CGImageDestinationCreateWithURL(directory.appendingPathComponent("hatching.gif") as CFURL, UTType.gif.identifier as CFString, frames.count, nil)! CGImageDestinationSetProperties(gif, [kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFLoopCount: 0]] as CFDictionary) for frame in frames { CGImageDestinationAddImage(gif, frame, [kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFDelayTime: 4.0 / Double(frames.count)]] as CFDictionary) } - assert(CGImageDestinationFinalize(gif)) + guard CGImageDestinationFinalize(gif) else { fatalError("Could not write GIF") } } } canvas.lockFocus() @@ -56,7 +52,7 @@ enum CreaturePreview { let png = CGImageDestinationCreateWithURL(directory.appendingPathComponent("preview.png") as CFURL, UTType.png.identifier as CFString, 1, nil)! CGImageDestinationAddImage(png, cg, nil) - assert(CGImageDestinationFinalize(png)) + guard CGImageDestinationFinalize(png) else { fatalError("Could not write PNG") } print(directory.appendingPathComponent("preview.png").path) } } -- 2.51.2