mirror of
https://github.com/saymrwulf/crisis.git
synced 2026-05-14 20:37:54 +00:00
Ch00: migrate to serial timeline pattern
Same structure as Ch01: a flat list of micro-beats, each with its own
narration sentence, rendered as a pure function of timeline position.
Twelve beats over ~43.5 seconds at 1×, mapped onto Ch00's 3 scenes:
Scene 0 (16s) — title fades in, then Aaron / Ben / Carl / Dave
fade onto their lanes one at a time, then settle.
Scene 1 (14s) — "no boss" beat, then per-lane scribbles of
vertices to make "four logs, four stories" visible.
Scene 2 (13.5s) — convergence arrows from all four lanes toward a
single "ONE HISTORY" target, then a red pulse and
"⚠ ONE OF THESE WILL LIE" warning over Dave's
lane. End settle.
Replaces the old 2×2 portrait grid with the lane idiom — Ch00 now
ends in the same coordinate system Ch01 begins in (cast on lanes),
so the chapter transition is a soft focus shift, not a hard cut.
`Ch00Timeline.swift` (new) holds the beats + pure `state(at:)`.
`Ch01_Problem.swift` rewritten to render from it (324 → 280 lines,
no per-scene switch).
`SceneEngine` gets duration overrides for Ch00's 3 scenes.
`ImmersiveView.liveNarration` extended with a Ch00 case so the
narration overlay reads beat-bound text for chapters 0 and 1.
Bundled, harness 55/55 invariants, 0 audit errors, 281 PNGs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
51fe02eadb
commit
7ac133f0d0
4 changed files with 426 additions and 277 deletions
|
|
@ -1,21 +1,12 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Ch00 (chapter index 0): "Four friends, one ledger, no boss."
|
||||
/// Ch00 (chapter index 0): "Four friends. One ledger. No boss."
|
||||
///
|
||||
/// The redesigned opener. Where the original chapter showed three anonymous
|
||||
/// "Node 1/2/3" circles in a triangle, this version introduces the four
|
||||
/// named cast members — Aaron, Ben, Carl, Dave — one at a time. By the
|
||||
/// time the chapter ends, the viewer can match each name to a color and
|
||||
/// has met Dave as the Byzantine actor. Every later chapter morphs from
|
||||
/// that established cast.
|
||||
///
|
||||
/// Scene 0: Cast intro. Four portraits fade in sequentially, each holding
|
||||
/// for ~1.5 s with a personality cue. Dave arrives last with a
|
||||
/// BYZ badge so the viewer sees the trouble coming.
|
||||
/// Scene 1: Conflicting logs. Each cast member writes a different ordering
|
||||
/// of three transactions to make "no shared truth yet" concrete.
|
||||
/// Scene 2: The question. Network backdrop dims; the framing question
|
||||
/// ("HOW DO WE AGREE?") fades in.
|
||||
/// Renders from `Ch00Timeline` — same architectural pattern as Ch01:
|
||||
/// a strictly serial sequence of micro-beats, each with its own
|
||||
/// narration, drawn as a pure function of timeline position. No hard
|
||||
/// cuts; cast members appear on lanes one at a time; the screen stays
|
||||
/// uncluttered.
|
||||
struct Ch01_Problem: View {
|
||||
let sceneIndex: Int
|
||||
let localTime: Double
|
||||
|
|
@ -25,300 +16,256 @@ struct Ch01_Problem: View {
|
|||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
render(context: &context, size: size, time: localTime)
|
||||
let t = Ch00Scenes.timelineT(sceneIndex: sceneIndex,
|
||||
localTime: localTime)
|
||||
render(in: &context, size: size, t: t)
|
||||
}
|
||||
}
|
||||
|
||||
private func render(context: inout GraphicsContext, size: CGSize, time: Double) {
|
||||
guard let sim = dm.sim else {
|
||||
context.draw(Text("Loading...").foregroundColor(.white),
|
||||
at: CGPoint(x: size.width / 2, y: size.height / 2))
|
||||
return
|
||||
}
|
||||
// MARK: - Top-level
|
||||
|
||||
switch sceneIndex {
|
||||
case 0:
|
||||
renderCastIntro(context: &context, size: size, time: time)
|
||||
case 1:
|
||||
renderConflictingLogs(context: &context, size: size, time: time)
|
||||
case 2:
|
||||
renderTheQuestion(context: &context, size: size, time: time, sim: sim)
|
||||
default:
|
||||
break
|
||||
private func render(in context: inout GraphicsContext, size: CGSize, t: Double) {
|
||||
let world = Ch00Timeline.state(at: t)
|
||||
|
||||
drawIntroducedLanes(in: &context, size: size, world: world)
|
||||
drawCastFigures(in: &context, size: size, world: world, t: t)
|
||||
|
||||
if world.divergeProgress > 0 {
|
||||
drawDivergingLogs(in: &context, size: size,
|
||||
progress: world.divergeProgress)
|
||||
}
|
||||
if world.convergeProgress > 0 {
|
||||
drawConvergenceArrows(in: &context, size: size,
|
||||
progress: world.convergeProgress)
|
||||
}
|
||||
if world.daveOminous > 0 {
|
||||
drawDaveOminous(in: &context, size: size,
|
||||
progress: world.daveOminous, t: t)
|
||||
}
|
||||
if let title = world.titleText {
|
||||
drawTitle(in: &context, size: size,
|
||||
text: title, alpha: world.titleAlpha)
|
||||
}
|
||||
drawBeatTag(in: &context, size: size, world: world)
|
||||
}
|
||||
|
||||
// MARK: - Lane geometry (mirrors Ch01's; will extract to a shared
|
||||
// LaneRenderKit once a third chapter adopts)
|
||||
|
||||
private func castLaneY(_ laneIdx: Int, size: CGSize) -> CGFloat {
|
||||
let margin: CGFloat = 60
|
||||
let nodeCount: CGFloat = 7
|
||||
let laneHeight = (size.height - 2 * margin) / nodeCount
|
||||
return margin + (CGFloat(laneIdx) + 0.5) * laneHeight
|
||||
}
|
||||
|
||||
private func castPosition(cast: Ch01Cast, size: CGSize) -> CGPoint {
|
||||
let laneIdx: Int
|
||||
switch cast {
|
||||
case .aaron: laneIdx = 0
|
||||
case .ben: laneIdx = 1
|
||||
case .carl: laneIdx = 2
|
||||
case .dave: laneIdx = 3
|
||||
}
|
||||
return CGPoint(x: size.width * 0.30, y: castLaneY(laneIdx, size: size))
|
||||
}
|
||||
|
||||
private func castColor(_ cast: Ch01Cast) -> Color {
|
||||
switch cast {
|
||||
case .aaron: return Cast.coral
|
||||
case .ben: return Cast.teal
|
||||
case .carl: return Cast.amber
|
||||
case .dave: return Cast.violet
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scene 0: cast intro
|
||||
//
|
||||
// Four portraits arranged in a 2x2 grid. They appear one at a time,
|
||||
// each pinned at ~1.5 s, so the viewer has time to read the name and
|
||||
// the personality cue before the next character arrives. Dave appears
|
||||
// last and is the only one with a BYZ badge.
|
||||
// MARK: - Lanes
|
||||
|
||||
private func renderCastIntro(context: inout GraphicsContext, size: CGSize, time: Double) {
|
||||
let cx = size.width / 2
|
||||
let cy = size.height / 2
|
||||
|
||||
// 2x2 layout, generous spacing.
|
||||
let dx: CGFloat = min(size.width * 0.22, 220)
|
||||
let dy: CGFloat = min(size.height * 0.22, 180)
|
||||
let positions: [CGPoint] = [
|
||||
CGPoint(x: cx - dx, y: cy - dy), // Aaron — top left
|
||||
CGPoint(x: cx + dx, y: cy - dy), // Ben — top right
|
||||
CGPoint(x: cx - dx, y: cy + dy), // Carl — bottom left
|
||||
CGPoint(x: cx + dx, y: cy + dy), // Dave — bottom right
|
||||
]
|
||||
|
||||
let leads = Cast.leads
|
||||
let arrivalInterval: Double = 1.5
|
||||
|
||||
for (i, role) in leads.enumerated() {
|
||||
let arriveAt = Double(i) * arrivalInterval
|
||||
let appear = max(0, min(1, (time - arriveAt) / 0.6))
|
||||
if appear < 0.01 { continue }
|
||||
|
||||
drawCastPortrait(
|
||||
context: &context,
|
||||
center: positions[i],
|
||||
role: role,
|
||||
appear: appear,
|
||||
pulse: 1.0 + 0.04 * sin(time * 1.6 + Double(i) * 0.9)
|
||||
private func drawIntroducedLanes(
|
||||
in context: inout GraphicsContext, size: CGSize, world: Ch00WorldState
|
||||
) {
|
||||
let casts: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)]
|
||||
for (cast, idx) in casts where world.introduced.contains(cast) {
|
||||
let y = castLaneY(idx, size: size)
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: 36, y: y))
|
||||
path.addLine(to: CGPoint(x: size.width - 24, y: y))
|
||||
context.stroke(path,
|
||||
with: .color(castColor(cast).opacity(0.18)),
|
||||
style: StrokeStyle(lineWidth: 0.8, dash: [4, 6]))
|
||||
context.draw(
|
||||
Text(cast.role.displayName.capitalized)
|
||||
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(castColor(cast).opacity(0.75)),
|
||||
at: CGPoint(x: 24, y: y),
|
||||
anchor: .leading
|
||||
)
|
||||
}
|
||||
|
||||
// Title fades in at the start, lingers throughout.
|
||||
let titleAlpha = min(1.0, time / 0.8)
|
||||
context.draw(
|
||||
Text("Meet the cast.")
|
||||
.font(.system(size: settings.scaled(20), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.85 * titleAlpha)),
|
||||
at: CGPoint(x: cx, y: 56)
|
||||
)
|
||||
}
|
||||
|
||||
private func drawCastPortrait(
|
||||
context: inout GraphicsContext,
|
||||
center: CGPoint, role: CastRole,
|
||||
appear: Double, pulse: CGFloat
|
||||
// MARK: - Cast figures
|
||||
|
||||
private func drawCastFigures(
|
||||
in context: inout GraphicsContext, size: CGSize,
|
||||
world: Ch00WorldState, t: Double
|
||||
) {
|
||||
let nodeRadius: CGFloat = 44 * pulse
|
||||
for cast in Ch01Cast.allCases where world.introduced.contains(cast) {
|
||||
let pos = castPosition(cast: cast, size: size)
|
||||
let r: CGFloat = 30
|
||||
let color = castColor(cast)
|
||||
|
||||
// Soft glow halo.
|
||||
let glowR: CGFloat = nodeRadius * 1.9
|
||||
let glowRect = CGRect(x: center.x - glowR, y: center.y - glowR,
|
||||
width: glowR * 2, height: glowR * 2)
|
||||
context.fill(
|
||||
Circle().path(in: glowRect),
|
||||
with: .color(role.color.opacity(0.10 * appear))
|
||||
)
|
||||
|
||||
// Body circle.
|
||||
let bodyRect = CGRect(x: center.x - nodeRadius, y: center.y - nodeRadius,
|
||||
width: nodeRadius * 2, height: nodeRadius * 2)
|
||||
context.fill(
|
||||
Circle().path(in: bodyRect),
|
||||
with: .color(role.color.opacity(0.85 * appear))
|
||||
)
|
||||
context.stroke(
|
||||
Circle().path(in: bodyRect),
|
||||
with: .color(role.color.opacity(0.6 * appear)),
|
||||
lineWidth: 1.5
|
||||
)
|
||||
|
||||
// Initial inside the circle.
|
||||
let initial = String(role.displayName.prefix(1))
|
||||
context.draw(
|
||||
Text(initial)
|
||||
.font(.system(size: settings.scaled(28), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.95 * appear)),
|
||||
at: center
|
||||
)
|
||||
|
||||
// Display name beneath.
|
||||
context.draw(
|
||||
Text(role.displayName)
|
||||
.font(.system(size: settings.scaled(15), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(role.color.opacity(0.95 * appear)),
|
||||
at: CGPoint(x: center.x, y: center.y + nodeRadius + 18)
|
||||
)
|
||||
|
||||
// Personality cue.
|
||||
context.draw(
|
||||
Text(role.cue)
|
||||
.font(.system(size: settings.scaled(11), weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.62 * appear)),
|
||||
at: CGPoint(x: center.x, y: center.y + nodeRadius + 36)
|
||||
)
|
||||
|
||||
// BYZ badge for Dave.
|
||||
if role.isByzantineSlot {
|
||||
let badgeRect = CGRect(x: center.x + nodeRadius - 14, y: center.y - nodeRadius - 6,
|
||||
width: 36, height: 16)
|
||||
// Halo
|
||||
let haloR = r * 1.6
|
||||
context.fill(
|
||||
RoundedRectangle(cornerRadius: 3).path(in: badgeRect),
|
||||
with: .color(.red.opacity(0.30 * appear))
|
||||
Circle().path(in: CGRect(x: pos.x - haloR, y: pos.y - haloR,
|
||||
width: haloR * 2, height: haloR * 2)),
|
||||
with: .color(color.opacity(0.15))
|
||||
)
|
||||
context.fill(
|
||||
Circle().path(in: CGRect(x: pos.x - r, y: pos.y - r,
|
||||
width: r * 2, height: r * 2)),
|
||||
with: .color(color.opacity(0.95))
|
||||
)
|
||||
context.stroke(
|
||||
RoundedRectangle(cornerRadius: 3).path(in: badgeRect),
|
||||
with: .color(.red.opacity(0.85 * appear)),
|
||||
lineWidth: 1
|
||||
Circle().path(in: CGRect(x: pos.x - r, y: pos.y - r,
|
||||
width: r * 2, height: r * 2)),
|
||||
with: .color(.white.opacity(0.5)), lineWidth: 1.5
|
||||
)
|
||||
context.draw(
|
||||
Text("BYZ")
|
||||
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.red.opacity(0.95 * appear)),
|
||||
at: CGPoint(x: badgeRect.midX, y: badgeRect.midY)
|
||||
Text(String(cast.role.displayName.prefix(1)))
|
||||
.font(.system(size: settings.scaled(22), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white),
|
||||
at: pos
|
||||
)
|
||||
context.draw(
|
||||
Text(cast.role.displayName.uppercased())
|
||||
.font(.system(size: settings.scaled(12), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(color.opacity(0.95)),
|
||||
at: CGPoint(x: pos.x, y: pos.y + r + 14)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scene 1: conflicting logs
|
||||
//
|
||||
// Each of the four cast members writes a DIFFERENT ordering of three
|
||||
// transactions (tx-A, tx-B, tx-C), to make "everyone has their own
|
||||
// story" concrete. Aaron's order, Ben's order, Carl's order, Dave's
|
||||
// order — all internally consistent, all different.
|
||||
// MARK: - "Logs diverge" — each lane gets its own scribble of vertices
|
||||
|
||||
private func renderConflictingLogs(context: inout GraphicsContext, size: CGSize, time: Double) {
|
||||
let cx = size.width / 2
|
||||
let cy = size.height / 2
|
||||
|
||||
let dx: CGFloat = min(size.width * 0.22, 220)
|
||||
let dy: CGFloat = min(size.height * 0.22, 180)
|
||||
let positions: [CGPoint] = [
|
||||
CGPoint(x: cx - dx, y: cy - dy),
|
||||
CGPoint(x: cx + dx, y: cy - dy),
|
||||
CGPoint(x: cx - dx, y: cy + dy),
|
||||
CGPoint(x: cx + dx, y: cy + dy),
|
||||
]
|
||||
|
||||
// Four different orderings. Aaron's matches the "true" arrival
|
||||
// order tx-A → tx-B → tx-C; the others have permuted views.
|
||||
let txOrders: [[String]] = [
|
||||
["tx-A", "tx-B", "tx-C"], // Aaron
|
||||
["tx-B", "tx-A", "tx-C"], // Ben
|
||||
["tx-C", "tx-A", "tx-B"], // Carl
|
||||
["tx-B", "tx-C", "tx-A"], // Dave
|
||||
]
|
||||
let txColors: [String: Color] = [
|
||||
"tx-A": .cyan,
|
||||
"tx-B": .yellow,
|
||||
"tx-C": .pink,
|
||||
]
|
||||
let leads = Cast.leads
|
||||
|
||||
// Soft connecting lines between every pair (suggests a network).
|
||||
for i in 0..<leads.count {
|
||||
for j in (i + 1)..<leads.count {
|
||||
var line = Path()
|
||||
line.move(to: positions[i])
|
||||
line.addLine(to: positions[j])
|
||||
context.stroke(line, with: .color(.white.opacity(0.06)), lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
// Drifting message particles between random pairs (out-of-order arrival).
|
||||
let particleCount = 24
|
||||
for p in 0..<particleCount {
|
||||
let seed = Double(p * 7919)
|
||||
let fromIdx = Int(seed.truncatingRemainder(dividingBy: 4))
|
||||
let toIdx = (fromIdx + 1 + Int(seed * 0.3) % 3) % 4
|
||||
let phase = (time * 0.20 + seed * 0.071).truncatingRemainder(dividingBy: 1.0)
|
||||
let from = positions[fromIdx]
|
||||
let to = positions[toIdx]
|
||||
let px = from.x + (to.x - from.x) * phase
|
||||
let py = from.y + (to.y - from.y) * phase
|
||||
|
||||
let txKeys = ["tx-A", "tx-B", "tx-C"]
|
||||
let txKey = txKeys[Int(seed * 0.13) % 3]
|
||||
let txColor = txColors[txKey] ?? .white
|
||||
let r: CGFloat = 3
|
||||
let rect = CGRect(x: px - r, y: py - r, width: r * 2, height: r * 2)
|
||||
context.fill(
|
||||
Circle().path(in: rect),
|
||||
with: .color(txColor.opacity(0.5 + 0.4 * (1 - phase)))
|
||||
)
|
||||
}
|
||||
|
||||
// Cast portraits + each one's log of three transactions.
|
||||
for (i, role) in leads.enumerated() {
|
||||
let pos = positions[i]
|
||||
let pulse: CGFloat = 1.0 + 0.05 * sin(time * 1.6 + Double(i) * 0.9)
|
||||
drawCastPortrait(context: &context, center: pos, role: role,
|
||||
appear: 1.0, pulse: pulse)
|
||||
|
||||
let order = txOrders[i]
|
||||
let pillSpacing: CGFloat = 56
|
||||
let totalWidth: CGFloat = pillSpacing * CGFloat(order.count - 1)
|
||||
let pillY: CGFloat = pos.y < cy ? pos.y - 90 : pos.y + 90
|
||||
let revealCount = min(order.count, max(1, Int(time / 0.7)))
|
||||
|
||||
for (j, tx) in order.enumerated() {
|
||||
if j >= revealCount { continue }
|
||||
let pillX = pos.x - totalWidth / 2 + pillSpacing * CGFloat(j)
|
||||
let pillCenter = CGPoint(x: pillX, y: pillY)
|
||||
let txColor: Color = txColors[tx] ?? .white
|
||||
let pillRect = CGRect(x: pillCenter.x - 24, y: pillCenter.y - 11,
|
||||
width: 48, height: 22)
|
||||
context.fill(
|
||||
RoundedRectangle(cornerRadius: 11).path(in: pillRect),
|
||||
with: .color(txColor.opacity(0.18))
|
||||
)
|
||||
context.stroke(
|
||||
RoundedRectangle(cornerRadius: 11).path(in: pillRect),
|
||||
with: .color(txColor.opacity(0.55)),
|
||||
lineWidth: 1
|
||||
)
|
||||
context.draw(
|
||||
Text(tx)
|
||||
.font(.system(size: settings.scaled(12), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(txColor.opacity(0.95)),
|
||||
at: pillCenter
|
||||
)
|
||||
|
||||
if j < order.count - 1 && j + 1 < revealCount {
|
||||
let arrowStart = CGPoint(x: pillX + 24, y: pillY)
|
||||
let arrowEnd = CGPoint(x: pillX + pillSpacing - 24, y: pillY)
|
||||
var arrowPath = Path()
|
||||
arrowPath.move(to: arrowStart)
|
||||
arrowPath.addLine(to: arrowEnd)
|
||||
context.stroke(arrowPath, with: .color(.white.opacity(0.35)), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scene 2: the question
|
||||
//
|
||||
// Reuse the conflicting-logs backdrop dimmed to ~40% and overlay the
|
||||
// framing question. This gives Scene 2 visual continuity with Scene 1
|
||||
// (no hard cut) while elevating the question itself.
|
||||
|
||||
private func renderTheQuestion(
|
||||
context: inout GraphicsContext, size: CGSize, time: Double, sim: SimulationData
|
||||
private func drawDivergingLogs(
|
||||
in context: inout GraphicsContext, size: CGSize, progress: Double
|
||||
) {
|
||||
renderConflictingLogs(context: &context, size: size, time: time)
|
||||
let casts: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)]
|
||||
// Per-lane staggered vertex pattern. Each lane gets a small
|
||||
// sequence of dots to suggest "this is what THIS player has
|
||||
// recorded so far." Patterns are intentionally different so the
|
||||
// viewer reads them as distinct local logs.
|
||||
let patterns: [Ch01Cast: [Double]] = [
|
||||
.aaron: [0.45, 0.55, 0.62, 0.70, 0.80],
|
||||
.ben: [0.50, 0.58, 0.66, 0.78],
|
||||
.carl: [0.48, 0.60, 0.72, 0.82, 0.92],
|
||||
.dave: [0.52, 0.64, 0.74, 0.84],
|
||||
]
|
||||
for (cast, idx) in casts {
|
||||
guard let pattern = patterns[cast] else { continue }
|
||||
let y = castLaneY(idx, size: size)
|
||||
let laneStart: CGFloat = size.width * 0.42
|
||||
let laneEnd: CGFloat = size.width - 60
|
||||
let span = laneEnd - laneStart
|
||||
let revealed = Int(Double(pattern.count) * progress)
|
||||
for i in 0..<revealed {
|
||||
let x = laneStart + span * CGFloat(pattern[i])
|
||||
let r: CGFloat = 8
|
||||
context.fill(
|
||||
Circle().path(in: CGRect(x: x - r, y: y - r,
|
||||
width: r * 2, height: r * 2)),
|
||||
with: .color(castColor(cast).opacity(0.85))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dimAlpha = min(0.55, time * 0.3)
|
||||
// MARK: - "Need to agree" — arrows from each lane converging
|
||||
|
||||
private func drawConvergenceArrows(
|
||||
in context: inout GraphicsContext, size: CGSize, progress: Double
|
||||
) {
|
||||
let target = CGPoint(
|
||||
x: size.width * 0.85,
|
||||
y: (castLaneY(1, size: size) + castLaneY(2, size: size)) / 2
|
||||
)
|
||||
let haloR: CGFloat = 28 * CGFloat(0.6 + 0.4 * progress)
|
||||
context.fill(
|
||||
Path(CGRect(origin: .zero, size: size)),
|
||||
with: .color(.black.opacity(dimAlpha))
|
||||
Circle().path(in: CGRect(x: target.x - haloR, y: target.y - haloR,
|
||||
width: haloR * 2, height: haloR * 2)),
|
||||
with: .color(.green.opacity(0.18 * progress))
|
||||
)
|
||||
context.draw(
|
||||
Text("ONE HISTORY")
|
||||
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.green.opacity(0.92 * progress)),
|
||||
at: CGPoint(x: target.x, y: target.y)
|
||||
)
|
||||
|
||||
let qAlpha = min(1.0, max(0, (time - 1.0) * 0.4))
|
||||
let casts: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)]
|
||||
for (cast, idx) in casts {
|
||||
let from = CGPoint(x: size.width * 0.42, y: castLaneY(idx, size: size))
|
||||
let p = CGFloat(progress)
|
||||
let endX = from.x + (target.x - from.x) * p
|
||||
let endY = from.y + (target.y - from.y) * p
|
||||
var path = Path()
|
||||
path.move(to: from)
|
||||
path.addLine(to: CGPoint(x: endX, y: endY))
|
||||
context.stroke(path,
|
||||
with: .color(castColor(cast).opacity(0.65)),
|
||||
style: StrokeStyle(lineWidth: 1.6, dash: [4, 4]))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dave foreshadow
|
||||
|
||||
private func drawDaveOminous(
|
||||
in context: inout GraphicsContext, size: CGSize,
|
||||
progress: Double, t: Double
|
||||
) {
|
||||
let pos = castPosition(cast: .dave, size: size)
|
||||
let pulse: CGFloat = 1.0 + 0.10 * CGFloat(sin(t * 3))
|
||||
let outerR: CGFloat = 50 * pulse
|
||||
context.stroke(
|
||||
Circle().path(in: CGRect(x: pos.x - outerR, y: pos.y - outerR,
|
||||
width: outerR * 2, height: outerR * 2)),
|
||||
with: .color(.red.opacity(0.55 * progress)), lineWidth: 2.5
|
||||
)
|
||||
context.draw(
|
||||
Text("HOW DO WE ALL AGREE?")
|
||||
.font(.system(size: settings.scaled(36), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(qAlpha * 0.85)),
|
||||
Text("⚠ ONE OF THESE WILL LIE")
|
||||
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.red.opacity(0.85 * progress)),
|
||||
at: CGPoint(x: pos.x + 110, y: pos.y - 50)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Title
|
||||
|
||||
private func drawTitle(
|
||||
in context: inout GraphicsContext, size: CGSize,
|
||||
text: String, alpha: Double
|
||||
) {
|
||||
context.draw(
|
||||
Text(text)
|
||||
.font(.system(size: settings.scaled(28), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.95 * alpha)),
|
||||
at: CGPoint(x: size.width / 2, y: size.height / 2)
|
||||
)
|
||||
}
|
||||
|
||||
let subAlpha = min(1.0, max(0, (time - 2.5) * 0.4))
|
||||
// MARK: - Beat tag (dev/testbed only)
|
||||
|
||||
private func drawBeatTag(
|
||||
in context: inout GraphicsContext, size: CGSize, world: Ch00WorldState
|
||||
) {
|
||||
guard let beatId = world.activeBeat?.id else { return }
|
||||
context.draw(
|
||||
Text("NO BOSS · NO CLOCK · ONE OF US (DAVE) MIGHT BE LYING")
|
||||
.font(.system(size: settings.scaled(12), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(subAlpha * 0.55)),
|
||||
at: CGPoint(x: size.width / 2, y: size.height / 2 + 44)
|
||||
Text(beatId)
|
||||
.font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.20)),
|
||||
at: CGPoint(x: size.width - 14, y: 10),
|
||||
anchor: .trailing
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
193
CrisisViz/Sources/CrisisViz/Engine/Ch00Timeline.swift
Normal file
193
CrisisViz/Sources/CrisisViz/Engine/Ch00Timeline.swift
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Ch00 — "Four friends. One ledger. No boss."
|
||||
///
|
||||
/// The opener. Same architectural pattern as `Ch01Timeline`: a strictly
|
||||
/// serial sequence of beats, each with its own narration, rendered as a
|
||||
/// pure function of timeline position `t`.
|
||||
///
|
||||
/// Pedagogical job:
|
||||
/// 1. Plant the cast on the stage (all four lanes appear in turn).
|
||||
/// 2. Establish "no central server, no boss".
|
||||
/// 3. Show that each player keeps their own log.
|
||||
/// 4. Foreshadow that one of them will lie (Dave gets a quiet
|
||||
/// red-glow tell on his arrival).
|
||||
///
|
||||
/// The chapter ends with all four lanes visible. Ch01 will begin by
|
||||
/// dimming Ben/Carl/Dave to focus on Aaron's first message — that's a
|
||||
/// SOFT focus shift, not a hard cut.
|
||||
|
||||
// MARK: - Types (mirror the Ch01 vocabulary so a shared render kit
|
||||
// can take both later)
|
||||
|
||||
enum Ch00BeatKind {
|
||||
case title(text: String) // big title fades in
|
||||
case introduce(Ch01Cast) // a lane fades in with a cast portrait
|
||||
case settle(label: String) // quiet beat
|
||||
case logsDiverge // each lane gets its own scribble of vertices
|
||||
case needAgreement // arrows from each lane converging
|
||||
case foreshadowDave // Dave's lane pulses red, ominous
|
||||
}
|
||||
|
||||
struct Ch00Beat: Identifiable {
|
||||
let id: String
|
||||
let kind: Ch00BeatKind
|
||||
let durationSeconds: Double
|
||||
let narration: String
|
||||
var startTime: Double = 0
|
||||
var endTime: Double { startTime + durationSeconds }
|
||||
}
|
||||
|
||||
struct Ch00WorldState {
|
||||
var titleText: String? = nil
|
||||
var titleAlpha: Double = 0
|
||||
var introduced: Set<Ch01Cast> = []
|
||||
var divergeProgress: Double = 0 // 0..1, drives "logs diverge" scribble
|
||||
var convergeProgress: Double = 0 // 0..1, drives "they need to agree" arrows
|
||||
var daveOminous: Double = 0 // 0..1, red glow + warning text
|
||||
var activeBeat: Ch00Beat? = nil
|
||||
var activeProgress: Double = 0
|
||||
}
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
enum Ch00Timeline {
|
||||
static let beats: [Ch00Beat] = {
|
||||
let raw: [Ch00Beat] = [
|
||||
// Phase 1: Title
|
||||
.init(id: "title", kind: .title(text: "Four friends. One ledger. No boss."),
|
||||
durationSeconds: 4.0,
|
||||
narration: "Welcome. This app teaches Crisis — a consensus protocol for a small group of validators who must agree on history without a central authority. We start with the cast."),
|
||||
|
||||
// Phase 2: Cast intros (one at a time, in lane order)
|
||||
.init(id: "intro-aaron", kind: .introduce(.aaron),
|
||||
durationSeconds: 3.0,
|
||||
narration: "Meet Aaron. He's the first validator. His lane is the top of the canvas — every message he writes will live on this horizontal lifeline."),
|
||||
|
||||
.init(id: "intro-ben", kind: .introduce(.ben),
|
||||
durationSeconds: 3.0,
|
||||
narration: "Meet Ben. The second validator. Same idea: his lifeline is the next lane down. Each player has their own row."),
|
||||
|
||||
.init(id: "intro-carl", kind: .introduce(.carl),
|
||||
durationSeconds: 3.0,
|
||||
narration: "Meet Carl. Third validator, third lane. Three honest players so far."),
|
||||
|
||||
.init(id: "intro-dave", kind: .introduce(.dave),
|
||||
durationSeconds: 3.5,
|
||||
narration: "And Dave. Fourth validator, fourth lane. Watch this one — Dave will eventually try to lie. We'll spot him later."),
|
||||
|
||||
.init(id: "all-four-settle", kind: .settle(label: "Four lifelines"),
|
||||
durationSeconds: 3.5,
|
||||
narration: "Four lanes, four lifelines. One per validator. The whole story of Crisis plays out as marks on these four lines."),
|
||||
|
||||
// Phase 3 (scene 1): no boss, each keeps their own log
|
||||
.init(id: "no-boss", kind: .settle(label: "No boss"),
|
||||
durationSeconds: 4.5,
|
||||
narration: "There is no chairperson here. No central server, no boss who decides what happened first. Order has to emerge from the four of them talking to each other."),
|
||||
|
||||
.init(id: "logs-diverge", kind: .logsDiverge,
|
||||
durationSeconds: 6.0,
|
||||
narration: "Each player keeps their own log — only what they have personally received. Because messages travel at different speeds, they can record the same events in different orders. Right now, four logs means four different stories."),
|
||||
|
||||
// Phase 4 (scene 2): need agreement; foreshadow Dave
|
||||
.init(id: "need-agreement", kind: .needAgreement,
|
||||
durationSeconds: 5.5,
|
||||
narration: "Yet at the end of the day they all need to agree on ONE history — same events, same order, byte-for-byte. That's the problem Crisis solves."),
|
||||
|
||||
.init(id: "foreshadow-dave", kind: .foreshadowDave,
|
||||
durationSeconds: 5.0,
|
||||
narration: "There is one more twist. One of these four — Dave — is going to try to lie. Crisis has to converge anyway. We'll see how, chapter by chapter."),
|
||||
|
||||
.init(id: "let-us-begin", kind: .settle(label: "Let us begin"),
|
||||
durationSeconds: 3.0,
|
||||
narration: "Four friends. One ledger. No boss. Let's see how they pull it off."),
|
||||
]
|
||||
|
||||
var t: Double = 0
|
||||
var assigned: [Ch00Beat] = []
|
||||
for var b in raw {
|
||||
b.startTime = t
|
||||
assigned.append(b)
|
||||
t += b.durationSeconds
|
||||
}
|
||||
return assigned
|
||||
}()
|
||||
|
||||
static var totalDuration: Double {
|
||||
beats.last.map { $0.endTime } ?? 0
|
||||
}
|
||||
|
||||
static func activeBeat(at t: Double) -> Ch00Beat? {
|
||||
let clamped = max(0, min(t, totalDuration))
|
||||
return beats.first { $0.startTime <= clamped && clamped < $0.endTime }
|
||||
?? beats.last
|
||||
}
|
||||
|
||||
/// Pure-function world state at any timeline position.
|
||||
static func state(at t: Double) -> Ch00WorldState {
|
||||
var w = Ch00WorldState()
|
||||
let clamped = max(0, min(t, totalDuration))
|
||||
|
||||
for beat in beats {
|
||||
if clamped < beat.startTime { break }
|
||||
let isActive = clamped < beat.endTime
|
||||
let progress = isActive
|
||||
? max(0, min(1, (clamped - beat.startTime) / beat.durationSeconds))
|
||||
: 1.0
|
||||
|
||||
switch beat.kind {
|
||||
case .title(let txt):
|
||||
if isActive {
|
||||
w.titleText = txt
|
||||
w.titleAlpha = progress
|
||||
} else {
|
||||
// Title fades back out as soon as cast intros begin —
|
||||
// it's an opener, not a permanent label.
|
||||
w.titleText = nil
|
||||
}
|
||||
case .introduce(let cast):
|
||||
w.introduced.insert(cast)
|
||||
case .settle:
|
||||
break
|
||||
case .logsDiverge:
|
||||
w.divergeProgress = progress
|
||||
if !isActive { w.divergeProgress = 1 }
|
||||
case .needAgreement:
|
||||
w.convergeProgress = progress
|
||||
if !isActive { w.convergeProgress = 1 }
|
||||
case .foreshadowDave:
|
||||
w.daveOminous = progress
|
||||
if !isActive { w.daveOminous = 1 }
|
||||
}
|
||||
|
||||
if isActive {
|
||||
w.activeBeat = beat
|
||||
w.activeProgress = progress
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scene mapping
|
||||
|
||||
/// Ch00 has 3 scenes mapping to windows of the unified timeline.
|
||||
enum Ch00Scenes {
|
||||
static let sceneStarts: [Double] = [0, 16.0, 30.0]
|
||||
static let sceneDurations: [Double] = [16.0, 14.0, 13.5]
|
||||
|
||||
static func timelineT(sceneIndex: Int, localTime: Double) -> Double {
|
||||
let idx = max(0, min(sceneIndex, sceneStarts.count - 1))
|
||||
return sceneStarts[idx] + localTime
|
||||
}
|
||||
|
||||
static func durationFor(scene: Int) -> Double {
|
||||
let idx = max(0, min(scene, sceneDurations.count - 1))
|
||||
return sceneDurations[idx]
|
||||
}
|
||||
|
||||
static func narrationAt(sceneIndex: Int, localTime: Double) -> String {
|
||||
let t = timelineT(sceneIndex: sceneIndex, localTime: localTime)
|
||||
return Ch00Timeline.activeBeat(at: t)?.narration ?? ""
|
||||
}
|
||||
}
|
||||
|
|
@ -48,6 +48,11 @@ final class SceneEngine {
|
|||
/// runtime at 1× ≈ 326 seconds — this is intentional pedagogical
|
||||
/// slo-mo; speed it up with the speed slider.
|
||||
private static let durationOverrides: [SceneAddress: Double] = [
|
||||
// Ch00 — opener (3 scenes mapping to Ch00Timeline windows)
|
||||
SceneAddress(chapter: 0, scene: 0): 16.0,
|
||||
SceneAddress(chapter: 0, scene: 1): 14.0,
|
||||
SceneAddress(chapter: 0, scene: 2): 13.5,
|
||||
// Ch01 — gossip story (7 scenes mapping to Ch01Timeline windows)
|
||||
SceneAddress(chapter: 1, scene: 0): 69.0,
|
||||
SceneAddress(chapter: 1, scene: 1): 38.0,
|
||||
SceneAddress(chapter: 1, scene: 2): 67.5,
|
||||
|
|
|
|||
|
|
@ -155,9 +155,13 @@ struct ImmersiveView: View {
|
|||
/// every other chapter.
|
||||
private func liveNarration(localTime: Double) -> String {
|
||||
let addr = engine.address
|
||||
if addr.chapter == 1 {
|
||||
switch addr.chapter {
|
||||
case 0:
|
||||
return Ch00Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
|
||||
case 1:
|
||||
return Ch01Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
|
||||
default:
|
||||
return SceneNarrations.narration(chapter: addr.chapter, scene: addr.scene)
|
||||
}
|
||||
return SceneNarrations.narration(chapter: addr.chapter, scene: addr.scene)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue