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,
|
||||
text: title, alpha: world.titleAlpha)
|
||||
}
|
||||
drawPerceptionTowers(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
|
||||
// LaneRenderKit once a third chapter adopts)
|
||||
|
||||
|
|
|
|||
|
|
@ -80,10 +80,141 @@ struct Ch02_Graph: View {
|
|||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
/// 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 activeBeat: Ch00Beat? = nil
|
||||
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
|
||||
|
|
@ -152,6 +165,37 @@ enum Ch00Timeline {
|
|||
case .logsDiverge:
|
||||
w.divergeProgress = progress
|
||||
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:
|
||||
w.convergeProgress = progress
|
||||
if !isActive { w.convergeProgress = 1 }
|
||||
|
|
|
|||
|
|
@ -121,6 +121,11 @@ struct Ch01WorldState {
|
|||
var sealedMessages: Set<String> = []
|
||||
/// Each cast member's local view: messages they have fully accepted.
|
||||
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 —
|
||||
/// payload (if .readBody fired), parents list (if .readParents
|
||||
/// fired), each individual resolved parent.
|
||||
|
|
@ -448,6 +453,9 @@ enum Ch01Timeline {
|
|||
// Author "knows" their own message after sealing.
|
||||
if let msg = messages[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.
|
||||
if !isActive { w.composing = nil }
|
||||
|
|
@ -502,6 +510,9 @@ enum Ch01Timeline {
|
|||
case .acceptIntoView(let at, let mid):
|
||||
// Permanent: recipient now holds the message.
|
||||
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.
|
||||
if !isActive {
|
||||
if w.openEnvelope?.recipient == at && w.openEnvelope?.messageId == mid {
|
||||
|
|
|
|||
Loading…
Reference in a new issue