ReplayControls.swift (7986B)
1 import Foundation 2 import Observation 3 4 /// Step intervals for the finish-banner replay autoplay, one per speed level. 5 enum ReplayTuning { 6 /// Step interval (ms) per speed level, fastest last. 7 static let speedMs = [350, 160, 70] 8 9 /// Step interval in milliseconds for `speed` (`1...speedMs.count`). 10 static func stepMilliseconds(forSpeed speed: Int) -> Int { 11 let index = speed - 1 12 guard speedMs.indices.contains(index) else { return speedMs.last ?? 0 } 13 return speedMs[index] 14 } 15 } 16 17 /// An immutable snapshot of the scrubber at one position — exactly what 18 /// `GridView` needs to render a rewound frame, decoupled from the controls' 19 /// mutable scrub/load state. A `nil` frame means "render the live grid". 20 struct ReplayFrame: Equatable { 21 /// Each touched cell's after-state at this position; cells absent here 22 /// render blank. 23 let cells: [GridPosition: JournalCellState] 24 /// The cell the most recent step changed — the playhead — or `nil` for a 25 /// batched gesture, which has no single focus to highlight. 26 let cursor: GridPosition? 27 /// Who made that move, so the playhead can take their colour. 28 let cursorAuthorID: String? 29 /// The direction the acting player had when the step was recorded. Older 30 /// decoded replay rows may not carry this, so clue display falls back to 31 /// puzzle geometry only when the cell is unambiguous. 32 let cursorDirection: Puzzle.Direction? 33 } 34 35 /// View-model for the finish-banner replay scrubber. Loads a finished game's 36 /// merged journal (Phase 2b) once the banner appears, then exposes a scrub 37 /// position and the per-cell grid override that `GridView` renders while the 38 /// user drags back through history. 39 /// 40 /// Held as `@State` by `PuzzleView` so the position survives re-renders; it is 41 /// `.idle` (no override, live grid) until a solved game's banner triggers 42 /// `load`. 43 @MainActor 44 @Observable 45 final class ReplayControls { 46 enum Status: Equatable { 47 case idle 48 case loading 49 case ready(ReplayTimeline) 50 case waiting(missing: Int) 51 case unavailable 52 } 53 54 private(set) var status: Status = .idle 55 56 /// Bumped by `retry()` so a `.task(id:)` re-fires `load` — the only way to 57 /// re-check after a `.waiting` result without polling. 58 private(set) var reloadToken = 0 59 60 /// Scrub position in `0...timeline.count`. Resting at `count` shows the 61 /// finished grid (override is `nil`, so the live `Game` renders); dragging 62 /// left rewinds. Set to `count` when a timeline loads so the head starts 63 /// hard-right at the end of the game. 64 var position: Int = 0 65 66 /// The number of replay speed steps offered by the speed control. 67 static let maxPlaybackSpeed = 3 68 69 /// The speed to use when playback starts or resumes. 70 var selectedPlaybackSpeed: Int = 1 71 72 /// Whether the replay is actively advancing. 73 var isPlaybackActive = false 74 75 /// Seconds between autoplay steps at the current speed, or `nil` when 76 /// stopped. Faster speeds step sooner; the per-speed intervals come from 77 /// `ReplayTuning`. 78 var playbackStepInterval: Duration? { 79 guard isPlaybackActive else { return nil } 80 return .milliseconds(ReplayTuning.stepMilliseconds(forSpeed: selectedPlaybackSpeed)) 81 } 82 83 /// Advances the playback speed one notch, wrapping back to the slowest 84 /// speed after the fastest. 85 func cycleSelectedPlaybackSpeed() { 86 selectedPlaybackSpeed = selectedPlaybackSpeed >= Self.maxPlaybackSpeed ? 1 : selectedPlaybackSpeed + 1 87 } 88 89 /// Toggles autoplay without losing the selected speed, so pause/resume 90 /// preserves the user's pace. 91 func togglePlayback() { 92 isPlaybackActive.toggle() 93 } 94 95 /// Pauses autoplay — called when the user grabs the scrubber, so a manual 96 /// scrub always wins without changing the selected speed. 97 func pausePlayback() { 98 isPlaybackActive = false 99 } 100 101 /// Advances one autoplay step, looping back to the start once the head 102 /// reaches the end. A no-op when stopped or before a timeline loads. 103 func advancePlayback() { 104 guard let timeline, isPlaybackActive else { return } 105 position = position >= timeline.count ? 0 : position + 1 106 } 107 108 var timeline: ReplayTimeline? { 109 if case .ready(let timeline) = status { return timeline } 110 return nil 111 } 112 113 /// A scrubber is offered only for a ready, non-empty timeline. 114 var isScrubbable: Bool { 115 (timeline?.count ?? 0) > 0 116 } 117 118 /// The grid to render at the current position, or `nil` to show the live 119 /// finished grid (head at the far right, or nothing loaded). Recomputed per 120 /// scrub tick — `state(through:)` is O(position), trivial for one game. 121 var gridOverride: [GridPosition: JournalCellState]? { 122 guard let timeline, position < timeline.count else { return nil } 123 return timeline.state(through: position) 124 } 125 126 /// The cell changed by the most recently applied step — the replay 127 /// playhead, highlighted so the eye can follow the rewind. Tracks 128 /// `gridOverride`: `nil` at rest (head at the far right, live grid shown), 129 /// non-nil only while actively scrubbed back. 130 var cursor: GridPosition? { 131 guard let timeline, position > 0, position < timeline.count else { return nil } 132 return timeline.focus(ofStep: position - 1) 133 } 134 135 /// The author whose move the playhead highlights, used to tint it in that 136 /// author's colour so the rewind reads as each player's moves in turn. 137 /// Tracks `cursor`: `nil` whenever there is no playhead. 138 var cursorAuthorID: String? { 139 guard let timeline, position > 0, position < timeline.count else { return nil } 140 return timeline.actingAuthor(ofStep: position - 1) 141 } 142 143 /// The direction of the most recently applied single-cell step, used by 144 /// the clue bar during replay. `nil` for batched gestures, older decoded 145 /// rows, or entries that did not record a cursor direction. 146 var cursorDirection: Puzzle.Direction? { 147 guard let timeline, position > 0, position < timeline.count else { return nil } 148 return timeline.direction(ofStep: position - 1) 149 } 150 151 /// The frame `GridView` should render, bundling the grid override with the 152 /// playhead and its author. `nil` exactly when `gridOverride` is — i.e. at 153 /// rest (head hard-right), where the live finished grid shows instead. 154 var frame: ReplayFrame? { 155 guard let cells = gridOverride else { return nil } 156 return ReplayFrame( 157 cells: cells, 158 cursor: cursor, 159 cursorAuthorID: cursorAuthorID, 160 cursorDirection: cursorDirection 161 ) 162 } 163 164 /// Loads the replay via the caller's loader. Idempotent for an in-flight or 165 /// ready load; a `.waiting` / `.unavailable` result stays retryable, so 166 /// `retry()` (driven by the inbound-journal sync signal, or the manual 167 /// button) can re-check when a contributor's journal finally syncs. 168 func load(_ loader: () async -> JournalReplayResult) async { 169 switch status { 170 case .loading, .ready: 171 return 172 case .idle, .waiting, .unavailable: 173 break 174 } 175 status = .loading 176 let result = await loader() 177 switch result { 178 case .ready(let timeline): 179 status = .ready(timeline) 180 position = timeline.count 181 case .waiting(let missing): 182 status = .waiting(missing: missing) 183 case .unavailable: 184 status = .unavailable 185 } 186 } 187 188 /// Drops back to `.idle` so the next `load` re-checks. Triggered by the 189 /// inbound-journal sync signal (a contributor's journal arrived) and by the 190 /// manual "Check again" affordance. 191 func retry() { 192 status = .idle 193 isPlaybackActive = false 194 selectedPlaybackSpeed = 1 195 reloadToken += 1 196 } 197 }