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:
saymrwulf 2026-05-07 00:17:57 +02:00
parent 51fe02eadb
commit 7ac133f0d0
4 changed files with 426 additions and 277 deletions

View file

@ -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
)
}
}

View 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 ?? ""
}
}

View file

@ -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,

View file

@ -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)
}
}