Files
brov-macbook/NotchBuddy/Sources/App/UploadSequenceEngine.swift
T
2026-10-06 19:34:32 +03:00

438 lines
17 KiB
Swift
Raw 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
// ============================================================
// CONSTANTS — exact mirror of upload-sequence.html
// All values in island-coordinate points (island = 640 × 176)
// ============================================================
enum USC {
static let W: Double = 640
static let ISL_H: Double = 176
static let CARD_X: Double = 10; static let CARD_Y: Double = 42
static let CARD_W: Double = 620; static let CARD_H: Double = 124; static let CARD_R: Double = 20
static let REST_X: Double = 140; static let REST_Y: Double = 104
static let D_BOX: Double = 62
static let FOLLOW_MIN: Double = 60 // CARD_X + 50
static let FOLLOW_MAX: Double = 580 // CARD_X + CARD_W - 50
static let TEXT_X: Double = 196; static let TEXT_Y: Double = 94
static let BAR_X0: Double = 46; static let BAR_X1: Double = 520; static let BAR_Y: Double = 118
static let CHOOSE_X: Double = 60; static let CHOOSE_Y: Double = 101; static let CHOOSE_D: Double = 62
static let LOCK_IN: Double = 60
static let LOCK_OUT: Double = 90
static let MOUTH_AJAR: Double = 0.20
static let MOUTH_OPEN: Double = 0.42
static let MOUTH_MAX: Double = 0.50
// Phase timestamps (t_ref, drop = 1.95)
static let T_DROP: Double = 1.95
static let T_SUCK_START: Double = 2.03
static let T_SUCK_END: Double = 2.33
static let T_CLOSE_END: Double = 2.42
static let T_CHEW1: Double = 2.60
static let T_CHEW_END: Double = 2.88
static let T_SHRINK_END: Double = 3.23
static let T_BAR_IN: Double = 3.00
static let T_PROG_START: Double = 3.25
static let DT: Double = 1.0 / 240.0
// Entry offset: gives 0.40 s of following before drop
static let ENTRY_T_REF: Double = T_DROP - 0.40 // = 1.55
}
// ============================================================
// EASING — port of reference E.out / E.in / E.inOut / E.back
// ============================================================
func usEOut(_ t: Double) -> Double { 1 - pow(1-t, 3) }
func usEIn(_ t: Double) -> Double { t*t*t }
func usEInOut(_ t: Double) -> Double { t < 0.5 ? 4*t*t*t : 1 - pow(-2*t+2,3)/2 }
func usEBack(_ t: Double) -> Double { let c1=1.70158,c3=c1+1; return 1+c3*pow(t-1,3)+c1*pow(t-1,2) }
func usSeg(_ t: Double, _ a: Double, _ b: Double) -> Double { max(0, min(1,(t-a)/(b-a))) }
func usLerp(_ a: Double, _ b: Double, _ t: Double) -> Double { a+(b-a)*t }
// Squeeze keyframes for suckEnd→chew1 phase (inline to avoid Swift @escaping issues)
func usSqueezeY(_ t: Double) -> Double {
let t0=USC.T_SUCK_END, t1=t0+0.07, t2=t0+0.20, t3=USC.T_CHEW1
if t<=t0 { return 1.06 }
if t<=t1 { return usLerp(1.06, 0.82, usEOut(usSeg(t,t0,t1))) }
if t<=t2 { return usLerp(0.82, 1.10, usEOut(usSeg(t,t1,t2))) }
if t<=t3 { return usLerp(1.10, 1.00, usEInOut(usSeg(t,t2,t3))) }
return 1.0
}
func usSqueezeX(_ t: Double) -> Double {
let t0=USC.T_SUCK_END, t1=t0+0.07, t2=t0+0.20, t3=USC.T_CHEW1
if t<=t0 { return 0.97 }
if t<=t1 { return usLerp(0.97, 1.14, usEOut(usSeg(t,t0,t1))) }
if t<=t2 { return usLerp(1.14, 0.95, usEOut(usSeg(t,t1,t2))) }
if t<=t3 { return usLerp(0.95, 1.00, usEInOut(usSeg(t,t2,t3))) }
return 1.0
}
func usProgressCurve(_ u: Double) -> Double {
if u < 0.40 { return 0.60 * usEOut(u/0.40) }
if u < 0.85 { return 0.60 + 0.32 * usEInOut((u-0.40)/0.45) }
return 0.92 + 0.08 * usEIn((u-0.85)/0.15)
}
func usProgressAt(_ t: Double, progStart: Double, progEnd: Double) -> Double {
t < progStart ? 0 : usProgressCurve(usSeg(t, progStart, progEnd))
}
// ============================================================
// SPRING — port of reference spring(s, target, response, damping, dt)
// ============================================================
struct USSpring {
var v: Double
var vel: Double = 0
mutating func step(target: Double, response: Double, damping: Double, dt: Double) {
let k = pow(2 * .pi / response, 2)
let c = 2 * damping * sqrt(k)
let a = k*(target-v) - c*vel
vel += a*dt; v += vel*dt
}
}
// ============================================================
// SIMULATION STATE — mirrors reference newSim()
// ============================================================
struct USSimState {
var t: Double = 0
var bx: USSpring = USSpring(v: USC.REST_X)
var by: USSpring = USSpring(v: USC.REST_Y)
var tilt: Double = 0
var mouth: USSpring = USSpring(v: 0)
var locked: Bool = false
var lockAt: Double = -9
var entered: Double = -9 // t_ref of zone entry; -9 = not entered
var gulp: Bool = false
var ok: Bool = false
var lastPct: Int = 0
}
// ============================================================
// FRAME DATA — output of frame(), read by UploadCanvasView
// ============================================================
enum USEyeShape { case pill, cup, content }
struct USMouthRect { var x,y,w,h: Double }
struct USFrame {
var t: Double = 0
var cursorX: Double = 600; var cursorY: Double = 280
var morph: Double = 0
var x: Double = USC.REST_X; var y: Double = USC.REST_Y; var d: Double = USC.D_BOX
var sx: Double = 1; var sy: Double = 1; var tilt: Double = 0; var hop: Double = 0
var mouth: Double = 0
var mouthRect = USMouthRect(x:0,y:0,w:0,h:0)
var eye: USEyeShape = .pill
var lookX: Double = 0; var lookY: Double = 0
var fileVisible: Bool = true; var suck: Double = 0
var zoneOver: Bool = false
var zoneAlpha: Double = 1
var textAlpha: Double = 1
var barReveal: Double = 0
var barAlpha: Double = 0
var progress: Double = 0
var flash: Double = 0
var check: Double = 0
var greenWash: Double = 0
var chooseAlpha: Double = 0
var uploadDuration: Double = 2.4
var progEnd: Double = USC.T_PROG_START + 2.4
var growStart: Double = USC.T_PROG_START + 2.4 + 0.25
var growEnd: Double = USC.T_PROG_START + 2.4 + 0.70
}
// ============================================================
// ENGINE
// ============================================================
@MainActor
final class UploadSequenceEngine {
static let shared = UploadSequenceEngine()
var uploadDuration: Double = 2.4
var progEnd: Double { USC.T_PROG_START + uploadDuration }
var growStart: Double { progEnd + 0.25 }
var growEnd: Double { progEnd + 0.70 }
private(set) var isActive: Bool = false
private var entryWallTime: Double = 0 // Date().timeIntervalSinceReferenceDate at entry
private var dropWallTime: Double? = nil
private var sim = USSimState()
// Real cursor in island coords
var cursorX: Double = 600
var cursorY: Double = 280
private var prevCursorX: Double = 600
private var prevCursorY: Double = 280
private var prevCursorTime: Double = 0
private var cursorSpeed: Double = 0
// MARK: - Session lifecycle
func enterZone(x: CGFloat, y: CGFloat) {
let now = Date().timeIntervalSinceReferenceDate
cursorX = Double(x); cursorY = Double(y)
prevCursorX = cursorX; prevCursorY = cursorY; prevCursorTime = now
cursorSpeed = 0
sim = USSimState()
sim.t = USC.ENTRY_T_REF
sim.entered = USC.ENTRY_T_REF
sim.bx = USSpring(v: USC.REST_X)
sim.by = USSpring(v: USC.REST_Y)
entryWallTime = now
dropWallTime = nil
isActive = true
}
func updateCursor(x: CGFloat, y: CGFloat) {
let now = Date().timeIntervalSinceReferenceDate
let dt = now - prevCursorTime
if dt > 0.001 {
let dx = Double(x) - prevCursorX, dy = Double(y) - prevCursorY
cursorSpeed = hypot(dx, dy) / dt
}
prevCursorX = Double(x); prevCursorY = Double(y); prevCursorTime = now
cursorX = Double(x); cursorY = Double(y)
}
func exitZone() {
// Keep engine active — island stays open per spec
}
func performDrop(uploadDuration ud: Double) {
uploadDuration = ud
let now = Date().timeIntervalSinceReferenceDate
dropWallTime = now
// Reset sim.t to T_DROP so the canonical post-drop timeline starts correctly,
// regardless of how long the user hovered. Spring state (position/velocity) is preserved.
sim.t = USC.T_DROP
}
func deactivate() {
isActive = false
dropWallTime = nil
}
// MARK: - t_ref from wall clock
func tRef(at date: Date) -> Double {
guard isActive else { return 0 }
let now = date.timeIntervalSinceReferenceDate
if let dw = dropWallTime {
return USC.T_DROP + max(0, now - dw)
}
// No cap — spring keeps stepping as long as user hovers.
// computeFrame clamps phase-sensitive outputs to pre-drop state.
let elapsed = max(0, now - entryWallTime)
return USC.ENTRY_T_REF + elapsed
}
// MARK: - Public entry point
func frame(at date: Date) -> USFrame {
guard isActive else { return USFrame() }
let t = tRef(at: date)
simulateTo(t)
return computeFrame(t: t)
}
// MARK: - Simulation
private func simulateTo(_ tTarget: Double) {
while sim.t < tTarget - 1e-10 {
let dt = min(USC.DT, tTarget - sim.t)
stepOnce(dt: dt)
sim.t += dt
}
}
private func stepOnce(dt: Double) {
let isDragging = (dropWallTime == nil)
// Horizontal follow + lock (only while dragging and in zone)
if isDragging && sim.entered >= 0 {
let dist = hypot(cursorX - sim.bx.v, (cursorY + 14) - sim.by.v)
if !sim.locked && dist < USC.LOCK_IN && cursorSpeed < 180 {
sim.locked = true; sim.lockAt = sim.t
}
if sim.locked && dist > USC.LOCK_OUT { sim.locked = false }
let tx = max(USC.FOLLOW_MIN, min(USC.FOLLOW_MAX, cursorX))
if sim.locked {
sim.bx.step(target: tx, response:0.18, damping:0.75, dt:dt)
sim.by.step(target: USC.REST_Y, response:0.18, damping:0.75, dt:dt)
} else {
sim.bx.step(target: tx, response:0.35, damping:0.70, dt:dt)
sim.by.step(target: USC.REST_Y, response:0.35, damping:0.70, dt:dt)
}
}
// Tilt
let tiltTarget = isDragging ? max(-0.18, min(0.18, sim.bx.vel * 0.0015)) : 0.0
sim.tilt = usLerp(sim.tilt, tiltTarget, 1 - pow(0.0005, dt))
// Mouth — post-drop close only after drop has actually happened
let t = sim.t
if !isDragging && t >= USC.T_SUCK_END {
sim.mouth.v = max(0, usLerp(USC.MOUTH_MAX, 0, usEIn(usSeg(t, USC.T_SUCK_END, USC.T_CLOSE_END))))
} else {
var mt = 0.0
if sim.entered >= 0 {
mt = (sim.locked || !isDragging) ? USC.MOUTH_OPEN : USC.MOUTH_AJAR
}
if !isDragging && t < USC.T_SUCK_END { mt = USC.MOUTH_MAX }
sim.mouth.step(target: mt, response:0.25, damping:0.60, dt:dt)
if sim.mouth.v < 0 { sim.mouth.v = 0 }
}
}
// MARK: - Frame computation (mirrors reference frame(t))
private func computeFrame(t: Double) -> USFrame {
var f = USFrame()
f.t = t
f.cursorX = cursorX; f.cursorY = cursorY
f.uploadDuration = uploadDuration
f.progEnd = progEnd
f.growStart = growStart
f.growEnd = growEnd
let entered = sim.entered >= 0 ? sim.entered : 1e9
let isDragging = (dropWallTime == nil)
// For all phase-based calculations, clamp t to just before T_DROP while pre-drop
// so long hovers don't accidentally trigger post-drop visuals.
let pt = isDragging ? min(t, USC.T_DROP - USC.DT) : t
// Morph: 0→1 (entry), 1→0 (shrink to ball), 0→1 (grow back to box at choose)
var morph: Double
if pt < USC.T_CHEW_END {
morph = usEBack(usSeg(pt, entered, entered + 0.38))
} else if pt < growStart {
morph = 1 - usEOut(usSeg(pt, USC.T_CHEW_END, USC.T_SHRINK_END))
} else {
morph = usEBack(usSeg(pt, growStart, growEnd))
}
morph = max(0, min(morph, 1.08))
f.morph = morph
// Position / diameter
var x = sim.bx.v, y = sim.by.v, d = USC.D_BOX
if pt >= USC.T_CHEW_END && pt < USC.T_PROG_START {
let k = usEInOut(usSeg(pt, USC.T_CHEW_END, USC.T_SHRINK_END))
x = usLerp(sim.bx.v, USC.BAR_X0, k)
y = usLerp(sim.by.v, USC.BAR_Y, k)
d = usLerp(USC.D_BOX, 14, k)
}
if pt >= USC.T_PROG_START {
let p = usProgressAt(pt, progStart: USC.T_PROG_START, progEnd: progEnd)
x = usLerp(USC.BAR_X0, USC.BAR_X1, p); y = USC.BAR_Y; d = 14
}
if pt >= progEnd {
x = USC.BAR_X1
y = USC.BAR_Y - 8 * sin(.pi * usSeg(pt, progEnd, progEnd + 0.20))
}
if pt >= growStart {
x = usLerp(USC.BAR_X1, USC.CHOOSE_X, usEInOut(usSeg(pt, growStart, growEnd)))
y = usLerp(USC.BAR_Y, USC.CHOOSE_Y, usEInOut(usSeg(pt, growStart, growEnd)))
d = usLerp(14, USC.CHOOSE_D, usEBack(usSeg(pt, growStart, growEnd)))
}
f.x = x; f.y = y; f.d = d
// Squeeze
var sx = 1.0, sy = 1.0
if pt >= USC.T_DROP && pt < USC.T_SUCK_START {
let k = usEOut(usSeg(pt, USC.T_DROP, USC.T_SUCK_START))
sy = usLerp(1, 0.92, k); sx = usLerp(1, 1.06, k)
}
if pt >= USC.T_SUCK_START && pt < USC.T_SUCK_END {
let k = usEInOut(usSeg(pt, USC.T_SUCK_START, USC.T_SUCK_END))
sy = usLerp(0.92, 1.06, k); sx = usLerp(1.06, 0.97, k)
}
if pt >= USC.T_SUCK_END && pt < USC.T_CHEW1 {
sy = usSqueezeY(pt); sx = usSqueezeX(pt)
}
if pt >= USC.T_CHEW1 && pt < USC.T_CHEW_END {
let k = ((pt-USC.T_CHEW1).truncatingRemainder(dividingBy: 0.14)) / 0.14
sy = 1 - 0.05*sin(.pi*k); sx = 1 + 0.03*sin(.pi*k)
}
if pt >= USC.T_CHEW_END && pt < USC.T_SHRINK_END {
let k = usSeg(pt, USC.T_CHEW_END, USC.T_SHRINK_END)
sy = 1 + 0.12*sin(.pi*k); sx = 1 - 0.06*sin(.pi*k)
}
if pt >= USC.T_PROG_START && pt < progEnd {
let v = (usProgressAt(pt+0.01, progStart: USC.T_PROG_START, progEnd: progEnd)
- usProgressAt(pt, progStart: USC.T_PROG_START, progEnd: progEnd)) / 0.01
let st = max(0, min(1, v*0.18))
sx = 1 + 0.25*st; sy = 1 - 0.15*st
}
if pt >= growStart && pt < growEnd {
sy = 1 + 0.06*sin(.pi * usSeg(pt, growStart, growEnd))
}
f.sx = sx; f.sy = sy; f.tilt = sim.tilt
f.hop = (sim.lockAt > 0 && isDragging)
? -5 * sin(.pi * usSeg(pt, sim.lockAt, sim.lockAt + 0.15)) : 0
f.mouth = sim.mouth.v
// Eyes
var eye: USEyeShape = .pill
if sim.locked && pt < USC.T_SUCK_END { eye = .cup }
if pt >= USC.T_SUCK_END && pt < USC.T_CHEW_END + 0.10 { eye = .content }
if pt >= progEnd && pt < growEnd + 0.30 { eye = .content }
f.eye = eye
let lkx = pt < USC.T_SUCK_END ? cursorX - x : (pt < USC.T_PROG_START ? 0.0 : 40.0)
let lky = pt < USC.T_SUCK_END ? (cursorY+10) - y : 0.0
f.lookX = max(-1, min(1, lkx/200)); f.lookY = max(-1, min(1, lky/150))
f.fileVisible = pt < USC.T_SUCK_END
f.suck = usSeg(pt, USC.T_SUCK_START, USC.T_SUCK_END)
// Content alpha values
f.zoneOver = sim.entered >= 0 && pt < USC.T_CHEW_END
f.zoneAlpha = 1 - usSeg(pt, USC.T_CHEW_END, USC.T_CHEW_END + 0.20)
f.textAlpha = f.zoneAlpha * ((x > USC.TEXT_X-40 && isDragging) ? 0.25 : 1.0)
f.barReveal = usEOut(usSeg(pt, USC.T_BAR_IN, USC.T_BAR_IN+0.25))
* (1 - usSeg(pt, growStart, growStart+0.20))
f.barAlpha = usSeg(pt, USC.T_BAR_IN+0.05, USC.T_BAR_IN+0.25)
* (1 - usSeg(pt, growStart, growStart+0.20))
f.progress = usProgressAt(pt, progStart: USC.T_PROG_START, progEnd: progEnd)
f.flash = pt >= progEnd ? sin(.pi * usSeg(pt, progEnd, progEnd+0.30)) : 0
f.check = pt >= progEnd ? usEBack(usSeg(pt, progEnd, progEnd+0.25)) : 0
let hoverGreen = f.zoneOver ? 0.22 : 0.0
var uploadGreen = 0.0
if pt >= USC.T_PROG_START {
// Grows gradually from 0→0.50 as upload progresses (tied to f.progress)
let baseGreen = f.progress * 0.50
// Brief flash burst at completion
let flashExtra = pt >= progEnd ? 0.20 * sin(.pi * usSeg(pt, progEnd, progEnd + 0.40)) : 0
// Fade out as Mochi grows back to choose position
let fadeOut = 1.0 - usSeg(pt, growEnd, growEnd + 0.60)
uploadGreen = (baseGreen + flashExtra) * fadeOut
}
f.greenWash = max(hoverGreen, uploadGreen)
f.chooseAlpha = usSeg(pt, growStart+0.15, growEnd)
// Mouth rect in island coords (used by drawFile for clipping)
let R = d / 2 / 1.04
let mc = max(0, min(morph, 1.0))
let rx = R * (1.04 - 0.04*mc)
let ry = R * (0.97 - 0.03*mc)
let mh = f.mouth * R * mc
let mw = 2*rx - 0.24*R
let mxOff = -mw/2
let myOff = -ry + 0.10*R
f.mouthRect = USMouthRect(
x: x + mxOff * sx,
y: y + f.hop + myOff * sy,
w: mw * sx,
h: mh * sy
)
return f
}
}