diff --git a/CrisisViz/Sources/CrisisViz/Chapters/Ch01_Problem.swift b/CrisisViz/Sources/CrisisViz/Chapters/Ch01_Problem.swift index de17a28..74088b8 100644 --- a/CrisisViz/Sources/CrisisViz/Chapters/Ch01_Problem.swift +++ b/CrisisViz/Sources/CrisisViz/Chapters/Ch01_Problem.swift @@ -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..= 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.. = [] + 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 ?? "" + } +} diff --git a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift index efd2893..423ff90 100644 --- a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift +++ b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift @@ -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, diff --git a/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift b/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift index ef23e2b..617bca4 100644 --- a/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift +++ b/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift @@ -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) } }