mirror of
https://github.com/saymrwulf/crisis.git
synced 2026-05-14 20:37:54 +00:00
Ch00 + Ch01: perception towers at the bottom
Each player gets a Tetris-style stack at the bottom of the canvas
showing the messages they've accepted, in the order they accepted
them. As `acceptIntoView` (and `seal`, for the author) beats fire,
new blocks slide up from below and settle into place at the top of
the tower with a cubic-out ease. The block's color comes from the
message's AUTHOR, not the tower owner — so a tower mixes colors,
which is exactly what a local DAG looks like in real Crisis.
The pedagogical payoff: stack ORDER differs between players because
Carl wrote γ before β reached him. Aaron and Ben end with stacks
[α, β, γ]; Carl ends with [α, γ, β]. The asymmetry that the chapter
spends 5 minutes establishing is now permanently visible at the
bottom of the screen, in one glance.
Implementation:
- `Ch01WorldState.viewOrder: [Ch01Cast: [String]]` — set type
can't carry order, so we track an array alongside `views`.
Populated when `seal` fires (author gains own message) and when
`acceptIntoView` fires (recipient gains incoming message).
- `Ch00WorldState.towerBlocks: [Ch01Cast: [Ch00TowerBlock]]` —
abstract `tx-N` blocks with author colors, populated during the
`logsDiverge` beat in a DIFFERENT order per cast (preview of
the same asymmetry that Ch01 will demonstrate concretely).
- Tower geometry is identical between chapters: 4 columns of
width 110, gap 24, base Y at canvas.height − 110, blocks 26pt
tall with 4pt gaps. Cast name + "VIEW" header at the top of
each column. Faint dashed rails frame the empty tower so the
viewer can see the silhouette before any block has landed.
- Active block in Ch01 fades + slides in over the active beat's
progress, so the viewer sees each new arrival actually land.
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
7ac133f0d0
commit
35470aadf0
4 changed files with 264 additions and 1 deletions
|
|
@ -46,9 +46,86 @@ struct Ch01_Problem: View {
|
||||||
drawTitle(in: &context, size: size,
|
drawTitle(in: &context, size: size,
|
||||||
text: title, alpha: world.titleAlpha)
|
text: title, alpha: world.titleAlpha)
|
||||||
}
|
}
|
||||||
|
drawPerceptionTowers(in: &context, size: size, world: world)
|
||||||
drawBeatTag(in: &context, size: size, world: world)
|
drawBeatTag(in: &context, size: size, world: world)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Perception towers (Ch00 abstract preview)
|
||||||
|
|
||||||
|
/// Bottom-of-canvas towers, one per introduced cast. They fill with
|
||||||
|
/// abstract `tx-N` blocks during the `logsDiverge` beat, in a
|
||||||
|
/// DIFFERENT order per cast — a preview of the same structure that
|
||||||
|
/// Ch01 fills with real α/β/γ messages.
|
||||||
|
private func drawPerceptionTowers(
|
||||||
|
in context: inout GraphicsContext, size: CGSize,
|
||||||
|
world: Ch00WorldState
|
||||||
|
) {
|
||||||
|
let casts: [Ch01Cast] = [.aaron, .ben, .carl, .dave]
|
||||||
|
let visibleCasts = casts.filter { world.introduced.contains($0) }
|
||||||
|
guard !visibleCasts.isEmpty else { return }
|
||||||
|
|
||||||
|
let blockH: CGFloat = 26
|
||||||
|
let blockGap: CGFloat = 4
|
||||||
|
let towerH: CGFloat = 4 * (blockH + blockGap) + 28
|
||||||
|
let baseY: CGFloat = size.height - 110
|
||||||
|
let towerW: CGFloat = 110
|
||||||
|
let totalW = CGFloat(visibleCasts.count) * towerW
|
||||||
|
+ CGFloat(visibleCasts.count - 1) * 24
|
||||||
|
let startX = (size.width - totalW) / 2
|
||||||
|
|
||||||
|
for (i, cast) in visibleCasts.enumerated() {
|
||||||
|
let towerX = startX + CGFloat(i) * (towerW + 24)
|
||||||
|
let towerCenter = towerX + towerW / 2
|
||||||
|
let color = castColor(cast)
|
||||||
|
|
||||||
|
// Header
|
||||||
|
context.draw(
|
||||||
|
Text(cast.role.displayName.uppercased())
|
||||||
|
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
|
||||||
|
.foregroundColor(color.opacity(0.85)),
|
||||||
|
at: CGPoint(x: towerCenter, y: baseY - towerH + 4)
|
||||||
|
)
|
||||||
|
context.draw(
|
||||||
|
Text("VIEW")
|
||||||
|
.font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced))
|
||||||
|
.foregroundColor(.white.opacity(0.35)),
|
||||||
|
at: CGPoint(x: towerCenter, y: baseY - towerH + 18)
|
||||||
|
)
|
||||||
|
// Baseline + rails
|
||||||
|
var baseline = Path()
|
||||||
|
baseline.move(to: CGPoint(x: towerX, y: baseY))
|
||||||
|
baseline.addLine(to: CGPoint(x: towerX + towerW, y: baseY))
|
||||||
|
context.stroke(baseline, with: .color(color.opacity(0.45)),
|
||||||
|
lineWidth: 1.2)
|
||||||
|
for railX in [towerX, towerX + towerW] {
|
||||||
|
var rail = Path()
|
||||||
|
rail.move(to: CGPoint(x: railX, y: baseY))
|
||||||
|
rail.addLine(to: CGPoint(x: railX, y: baseY - towerH + 26))
|
||||||
|
context.stroke(rail, with: .color(color.opacity(0.18)),
|
||||||
|
style: StrokeStyle(lineWidth: 0.8, dash: [3, 4]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocks
|
||||||
|
let blocks = world.towerBlocks[cast] ?? []
|
||||||
|
for (j, block) in blocks.enumerated() {
|
||||||
|
let blockY = baseY - CGFloat(j + 1) * (blockH + blockGap)
|
||||||
|
let rect = CGRect(x: towerX + 6, y: blockY,
|
||||||
|
width: towerW - 12, height: blockH)
|
||||||
|
let blockColor = castColor(block.authorCast)
|
||||||
|
context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect),
|
||||||
|
with: .color(blockColor.opacity(0.88)))
|
||||||
|
context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect),
|
||||||
|
with: .color(.white.opacity(0.45)), lineWidth: 1.0)
|
||||||
|
context.draw(
|
||||||
|
Text(block.label)
|
||||||
|
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
|
||||||
|
.foregroundColor(.white),
|
||||||
|
at: CGPoint(x: rect.midX, y: rect.midY)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Lane geometry (mirrors Ch01's; will extract to a shared
|
// MARK: - Lane geometry (mirrors Ch01's; will extract to a shared
|
||||||
// LaneRenderKit once a third chapter adopts)
|
// LaneRenderKit once a third chapter adopts)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,10 +80,141 @@ struct Ch02_Graph: View {
|
||||||
drawOpenEnvelope(in: &context, size: size, env: env)
|
drawOpenEnvelope(in: &context, size: size, env: env)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. Footer: timeline position + active beat label, faint.
|
// 10. Perception towers at the bottom — one per cast, each
|
||||||
|
// stacking the messages that cast has accepted, in the
|
||||||
|
// order they arrived. Aaron and Ben end up with the same
|
||||||
|
// ordered stack (α, β, γ); Carl ends up with (α, γ, β)
|
||||||
|
// because β reached him after he'd already written γ. The
|
||||||
|
// different stack orderings make the asymmetry physically
|
||||||
|
// visible at a glance.
|
||||||
|
drawPerceptionTowers(in: &context, size: size, world: world, t: t)
|
||||||
|
|
||||||
|
// 11. Beat tag (testbed-only debug label).
|
||||||
drawFooter(in: &context, size: size, t: t, world: world)
|
drawFooter(in: &context, size: size, t: t, world: world)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Perception towers
|
||||||
|
|
||||||
|
/// Bottom-of-canvas towers. One per cast member that has been
|
||||||
|
/// introduced. Each tower shows the ordered list of messages in
|
||||||
|
/// that cast's local view. New blocks slide up into place as
|
||||||
|
/// `acceptIntoView` beats fire — the active block is at full
|
||||||
|
/// opacity once the beat completes.
|
||||||
|
private func drawPerceptionTowers(
|
||||||
|
in context: inout GraphicsContext, size: CGSize,
|
||||||
|
world: Ch01WorldState, t: Double
|
||||||
|
) {
|
||||||
|
let casts: [Ch01Cast] = [.aaron, .ben, .carl, .dave]
|
||||||
|
let visibleCasts = casts.filter { world.introduced.contains($0) }
|
||||||
|
guard !visibleCasts.isEmpty else { return }
|
||||||
|
|
||||||
|
// Tower geometry. Block size is generous so message-id labels
|
||||||
|
// stay readable. Total tower span sits in the lower band of the
|
||||||
|
// canvas, well clear of the cast lanes (lanes 0-3 are at
|
||||||
|
// y ≈ 116..450) and clear of the GlassNarration overlay
|
||||||
|
// (occupies bottom-LEFT in the live app — towers stay center).
|
||||||
|
let blockH: CGFloat = 26
|
||||||
|
let blockGap: CGFloat = 4
|
||||||
|
let towerH: CGFloat = 4 * (blockH + blockGap) + 28 // header + 4 blocks max
|
||||||
|
let baseY: CGFloat = size.height - 110
|
||||||
|
let towerW: CGFloat = 110
|
||||||
|
let totalW = CGFloat(visibleCasts.count) * towerW
|
||||||
|
+ CGFloat(visibleCasts.count - 1) * 24
|
||||||
|
let startX = (size.width - totalW) / 2
|
||||||
|
|
||||||
|
// Detect the message currently being added (if any) so its
|
||||||
|
// block can fade in over the active beat's progress.
|
||||||
|
var activeAcceptingMid: String? = nil
|
||||||
|
var activeAcceptingCast: Ch01Cast? = nil
|
||||||
|
if let active = world.activeBeat {
|
||||||
|
switch active.kind {
|
||||||
|
case .acceptIntoView(let at, let mid):
|
||||||
|
activeAcceptingMid = mid
|
||||||
|
activeAcceptingCast = at
|
||||||
|
case .seal(let mid):
|
||||||
|
activeAcceptingMid = mid
|
||||||
|
activeAcceptingCast = Ch01Timeline.messages[mid]?.author
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, cast) in visibleCasts.enumerated() {
|
||||||
|
let towerX = startX + CGFloat(i) * (towerW + 24)
|
||||||
|
let towerCenter = towerX + towerW / 2
|
||||||
|
let color = castColor(cast)
|
||||||
|
|
||||||
|
// Header: cast name above the tower.
|
||||||
|
context.draw(
|
||||||
|
Text(cast.role.displayName.uppercased())
|
||||||
|
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
|
||||||
|
.foregroundColor(color.opacity(0.85)),
|
||||||
|
at: CGPoint(x: towerCenter, y: baseY - towerH + 4)
|
||||||
|
)
|
||||||
|
// "VIEW" sub-label
|
||||||
|
context.draw(
|
||||||
|
Text("VIEW")
|
||||||
|
.font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced))
|
||||||
|
.foregroundColor(.white.opacity(0.35)),
|
||||||
|
at: CGPoint(x: towerCenter, y: baseY - towerH + 18)
|
||||||
|
)
|
||||||
|
// Tower base line — thin axis showing the floor of the stack.
|
||||||
|
var baseline = Path()
|
||||||
|
baseline.move(to: CGPoint(x: towerX, y: baseY))
|
||||||
|
baseline.addLine(to: CGPoint(x: towerX + towerW, y: baseY))
|
||||||
|
context.stroke(baseline,
|
||||||
|
with: .color(color.opacity(0.45)),
|
||||||
|
lineWidth: 1.2)
|
||||||
|
// Vertical guides — faint, so the empty tower silhouette
|
||||||
|
// is visible before any block has landed.
|
||||||
|
var leftRail = Path()
|
||||||
|
leftRail.move(to: CGPoint(x: towerX, y: baseY))
|
||||||
|
leftRail.addLine(to: CGPoint(x: towerX, y: baseY - towerH + 26))
|
||||||
|
context.stroke(leftRail, with: .color(color.opacity(0.18)),
|
||||||
|
style: StrokeStyle(lineWidth: 0.8, dash: [3, 4]))
|
||||||
|
var rightRail = Path()
|
||||||
|
rightRail.move(to: CGPoint(x: towerX + towerW, y: baseY))
|
||||||
|
rightRail.addLine(to: CGPoint(x: towerX + towerW, y: baseY - towerH + 26))
|
||||||
|
context.stroke(rightRail, with: .color(color.opacity(0.18)),
|
||||||
|
style: StrokeStyle(lineWidth: 0.8, dash: [3, 4]))
|
||||||
|
|
||||||
|
// Stack the blocks.
|
||||||
|
let order = world.viewOrder[cast] ?? []
|
||||||
|
for (j, mid) in order.enumerated() {
|
||||||
|
guard let msg = Ch01Timeline.messages[mid] else { continue }
|
||||||
|
let isActiveDrop = (activeAcceptingMid == mid && activeAcceptingCast == cast)
|
||||||
|
let dropProgress = isActiveDrop
|
||||||
|
? max(0, min(1, world.activeProgress))
|
||||||
|
: 1.0
|
||||||
|
// Easing: cubic-out so the block "settles" gracefully.
|
||||||
|
let eased = 1 - pow(1 - dropProgress, 3)
|
||||||
|
let restY = baseY - CGFloat(j + 1) * (blockH + blockGap)
|
||||||
|
// The dropping block falls from below the baseline up
|
||||||
|
// to its rest Y. Earlier blocks sit at full opacity.
|
||||||
|
let dropFrom = baseY + 30
|
||||||
|
let blockY = restY * eased + dropFrom * (1 - eased)
|
||||||
|
let alpha = 0.4 + 0.6 * eased
|
||||||
|
let rect = CGRect(x: towerX + 6, y: blockY,
|
||||||
|
width: towerW - 12, height: blockH)
|
||||||
|
let blockColor = castColor(authorOf(mid))
|
||||||
|
context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect),
|
||||||
|
with: .color(blockColor.opacity(0.88 * alpha)))
|
||||||
|
context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect),
|
||||||
|
with: .color(.white.opacity(0.45 * alpha)),
|
||||||
|
lineWidth: 1.0)
|
||||||
|
context.draw(
|
||||||
|
Text("\(mid) \(msg.hashShort)")
|
||||||
|
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
|
||||||
|
.foregroundColor(.white.opacity(alpha)),
|
||||||
|
at: CGPoint(x: rect.midX, y: rect.midY)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func authorOf(_ mid: String) -> Ch01Cast {
|
||||||
|
Ch01Timeline.messages[mid]?.author ?? .aaron
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Lane geometry
|
// MARK: - Lane geometry
|
||||||
|
|
||||||
/// Lane center Y for cast index 0..3 (Aaron/Ben/Carl/Dave) using the
|
/// Lane center Y for cast index 0..3 (Aaron/Ben/Carl/Dave) using the
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,19 @@ struct Ch00WorldState {
|
||||||
var daveOminous: Double = 0 // 0..1, red glow + warning text
|
var daveOminous: Double = 0 // 0..1, red glow + warning text
|
||||||
var activeBeat: Ch00Beat? = nil
|
var activeBeat: Ch00Beat? = nil
|
||||||
var activeProgress: Double = 0
|
var activeProgress: Double = 0
|
||||||
|
/// Per-cast tower contents — abstract preview blocks. During the
|
||||||
|
/// `logsDiverge` beat each cast accumulates a few colored blocks in
|
||||||
|
/// a DIFFERENT order, foreshadowing the asymmetry that becomes
|
||||||
|
/// concrete in Ch01.
|
||||||
|
var towerBlocks: [Ch01Cast: [Ch00TowerBlock]] = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One block in a Ch00 perception tower — abstract, no real digest.
|
||||||
|
struct Ch00TowerBlock {
|
||||||
|
let label: String
|
||||||
|
/// Whose color the block carries (so each tower mixes colors,
|
||||||
|
/// the way local logs in real Crisis would).
|
||||||
|
let authorCast: Ch01Cast
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Timeline
|
// MARK: - Timeline
|
||||||
|
|
@ -152,6 +165,37 @@ enum Ch00Timeline {
|
||||||
case .logsDiverge:
|
case .logsDiverge:
|
||||||
w.divergeProgress = progress
|
w.divergeProgress = progress
|
||||||
if !isActive { w.divergeProgress = 1 }
|
if !isActive { w.divergeProgress = 1 }
|
||||||
|
// Populate each tower with a sequence of mixed-color
|
||||||
|
// blocks in a DIFFERENT order per cast. The blocks
|
||||||
|
// appear one at a time, paced over the beat's duration,
|
||||||
|
// so the viewer can see each player's stack grow
|
||||||
|
// independently — same population, different histories.
|
||||||
|
let perTowerOrders: [Ch01Cast: [Ch00TowerBlock]] = [
|
||||||
|
.aaron: [
|
||||||
|
.init(label: "tx-1", authorCast: .aaron),
|
||||||
|
.init(label: "tx-2", authorCast: .ben),
|
||||||
|
.init(label: "tx-3", authorCast: .carl),
|
||||||
|
],
|
||||||
|
.ben: [
|
||||||
|
.init(label: "tx-2", authorCast: .ben),
|
||||||
|
.init(label: "tx-1", authorCast: .aaron),
|
||||||
|
.init(label: "tx-3", authorCast: .carl),
|
||||||
|
],
|
||||||
|
.carl: [
|
||||||
|
.init(label: "tx-3", authorCast: .carl),
|
||||||
|
.init(label: "tx-2", authorCast: .ben),
|
||||||
|
.init(label: "tx-1", authorCast: .aaron),
|
||||||
|
],
|
||||||
|
.dave: [
|
||||||
|
.init(label: "tx-4", authorCast: .dave),
|
||||||
|
.init(label: "tx-1", authorCast: .aaron),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
let perTowerProgress = isActive ? progress : 1.0
|
||||||
|
for (cast, blocks) in perTowerOrders {
|
||||||
|
let revealed = Int(Double(blocks.count) * perTowerProgress)
|
||||||
|
w.towerBlocks[cast] = Array(blocks.prefix(revealed))
|
||||||
|
}
|
||||||
case .needAgreement:
|
case .needAgreement:
|
||||||
w.convergeProgress = progress
|
w.convergeProgress = progress
|
||||||
if !isActive { w.convergeProgress = 1 }
|
if !isActive { w.convergeProgress = 1 }
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,11 @@ struct Ch01WorldState {
|
||||||
var sealedMessages: Set<String> = []
|
var sealedMessages: Set<String> = []
|
||||||
/// Each cast member's local view: messages they have fully accepted.
|
/// Each cast member's local view: messages they have fully accepted.
|
||||||
var views: [Ch01Cast: Set<String>] = [:]
|
var views: [Ch01Cast: Set<String>] = [:]
|
||||||
|
/// Same content as `views`, but in the order each cast accepted them.
|
||||||
|
/// Used to drive the "perception towers" at the bottom of the canvas
|
||||||
|
/// — Aaron sees α first, then β, then γ; Carl sees α, then γ, then β
|
||||||
|
/// (the asymmetry is visible as a different stack ordering).
|
||||||
|
var viewOrder: [Ch01Cast: [String]] = [:]
|
||||||
/// What the active recipient has read of the just-arrived envelope —
|
/// What the active recipient has read of the just-arrived envelope —
|
||||||
/// payload (if .readBody fired), parents list (if .readParents
|
/// payload (if .readBody fired), parents list (if .readParents
|
||||||
/// fired), each individual resolved parent.
|
/// fired), each individual resolved parent.
|
||||||
|
|
@ -448,6 +453,9 @@ enum Ch01Timeline {
|
||||||
// Author "knows" their own message after sealing.
|
// Author "knows" their own message after sealing.
|
||||||
if let msg = messages[mid] {
|
if let msg = messages[mid] {
|
||||||
w.views[msg.author, default: []].insert(mid)
|
w.views[msg.author, default: []].insert(mid)
|
||||||
|
if !w.viewOrder[msg.author, default: []].contains(mid) {
|
||||||
|
w.viewOrder[msg.author, default: []].append(mid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Once sealed, drop the composing state.
|
// Once sealed, drop the composing state.
|
||||||
if !isActive { w.composing = nil }
|
if !isActive { w.composing = nil }
|
||||||
|
|
@ -502,6 +510,9 @@ enum Ch01Timeline {
|
||||||
case .acceptIntoView(let at, let mid):
|
case .acceptIntoView(let at, let mid):
|
||||||
// Permanent: recipient now holds the message.
|
// Permanent: recipient now holds the message.
|
||||||
w.views[at, default: []].insert(mid)
|
w.views[at, default: []].insert(mid)
|
||||||
|
if !w.viewOrder[at, default: []].contains(mid) {
|
||||||
|
w.viewOrder[at, default: []].append(mid)
|
||||||
|
}
|
||||||
// Once accepted, the open envelope is dismissed.
|
// Once accepted, the open envelope is dismissed.
|
||||||
if !isActive {
|
if !isActive {
|
||||||
if w.openEnvelope?.recipient == at && w.openEnvelope?.messageId == mid {
|
if w.openEnvelope?.recipient == at && w.openEnvelope?.messageId == mid {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue