ReplayController.swift (8066B)
1 import Foundation 2 import Observation 3 4 /// Tunable knobs for the finish-banner replay autoplay, persisted in 5 /// `UserDefaults` and editable from the Debugging menu. Reads fall back to the 6 /// shipping defaults whenever a key is unset, so an empty store behaves exactly 7 /// as it did before these became adjustable. 8 enum ReplayTuning { 9 enum Key { 10 static let speedMs = ["replay.speed1Ms", "replay.speed2Ms", "replay.speed3Ms"] 11 static let offOpacity = "replay.offOpacity" 12 } 13 14 /// Default step interval (ms) per speed level, fastest last. 15 static let defaultSpeedMs = [350, 160, 70] 16 /// Default contrast of an unlit ("off") fast-forward arrow. 17 static let defaultOffOpacity = 0.3 18 19 /// Step interval in milliseconds for `speed` (`1...defaultSpeedMs.count`), 20 /// from the store or the matching default when unset (stored `0`). 21 static func stepMilliseconds(forSpeed speed: Int) -> Int { 22 let index = speed - 1 23 guard defaultSpeedMs.indices.contains(index) else { return defaultSpeedMs.last ?? 0 } 24 let stored = UserDefaults.standard.integer(forKey: Key.speedMs[index]) 25 return stored > 0 ? stored : defaultSpeedMs[index] 26 } 27 28 /// Contrast of an unlit arrow, from the store or the default when unset. 29 static var offOpacity: Double { 30 UserDefaults.standard.object(forKey: Key.offOpacity) as? Double ?? defaultOffOpacity 31 } 32 } 33 34 /// An immutable snapshot of the scrubber at one position — exactly what 35 /// `GridView` needs to render a rewound frame, decoupled from the controller's 36 /// mutable scrub/load state. A `nil` frame means "render the live grid". 37 struct ReplayFrame: Equatable { 38 /// Each touched cell's after-state at this position; cells absent here 39 /// render blank. 40 let cells: [GridPosition: JournalCellState] 41 /// The cell the most recent step changed — the playhead — or `nil` for a 42 /// batched gesture, which has no single focus to highlight. 43 let cursor: GridPosition? 44 /// Who made that move, so the playhead can take their colour. 45 let cursorAuthorID: String? 46 } 47 48 /// View-model for the finish-banner replay scrubber. Loads a finished game's 49 /// merged journal (Phase 2b) once the banner appears, then exposes a scrub 50 /// position and the per-cell grid override that `GridView` renders while the 51 /// user drags back through history. 52 /// 53 /// Held as `@State` by `PuzzleView` so the position survives re-renders; it is 54 /// `.idle` (no override, live grid) until a solved game's banner triggers 55 /// `load`. 56 @MainActor 57 @Observable 58 final class ReplayController { 59 enum Status: Equatable { 60 case idle 61 case loading 62 case ready(ReplayTimeline) 63 case waiting(missing: Int) 64 case unavailable 65 } 66 67 private(set) var status: Status = .idle 68 69 /// Bumped by `retry()` so a `.task(id:)` re-fires `load` — the only way to 70 /// re-check after a `.waiting` result without polling. 71 private(set) var reloadToken = 0 72 73 /// Scrub position in `0...timeline.count`. Resting at `count` shows the 74 /// finished grid (override is `nil`, so the live `Game` renders); dragging 75 /// left rewinds. Set to `count` when a timeline loads so the head starts 76 /// hard-right at the end of the game. 77 var position: Int = 0 78 79 /// The number of speed steps the fast-forward control cycles through, and 80 /// the number of arrows it draws. 81 static let maxPlaybackSpeed = 3 82 83 /// Autoplay speed: `0` is stopped; `1...maxPlaybackSpeed` advance the 84 /// position automatically, faster at each step. The fast-forward control 85 /// cycles `0 → 1 → … → max → 0`, filling one more arrow per step, and the 86 /// user grabbing the scrubber resets it to `0`. 87 var playbackSpeed: Int = 0 88 89 /// Seconds between autoplay steps at the current speed, or `nil` when 90 /// stopped. Faster speeds step sooner; the per-speed intervals come from 91 /// `ReplayTuning` so they can be tuned live from the Debugging menu. 92 var playbackStepInterval: Duration? { 93 guard playbackSpeed > 0 else { return nil } 94 return .milliseconds(ReplayTuning.stepMilliseconds(forSpeed: playbackSpeed)) 95 } 96 97 /// Advances the fast-forward control one notch, wrapping past the top back 98 /// to stopped. 99 func cyclePlaybackSpeed() { 100 playbackSpeed = (playbackSpeed + 1) % (Self.maxPlaybackSpeed + 1) 101 } 102 103 /// Stops autoplay — called when the user grabs the scrubber, so a manual 104 /// scrub always wins and the arrows fall back to off. 105 func stopPlayback() { 106 playbackSpeed = 0 107 } 108 109 /// Advances one autoplay step, looping back to the start once the head 110 /// reaches the end. A no-op when stopped or before a timeline loads. 111 func advancePlayback() { 112 guard let timeline, playbackSpeed > 0 else { return } 113 position = position >= timeline.count ? 0 : position + 1 114 } 115 116 var timeline: ReplayTimeline? { 117 if case .ready(let timeline) = status { return timeline } 118 return nil 119 } 120 121 /// A scrubber is offered only for a ready, non-empty timeline. 122 var isScrubbable: Bool { 123 (timeline?.count ?? 0) > 0 124 } 125 126 /// The grid to render at the current position, or `nil` to show the live 127 /// finished grid (head at the far right, or nothing loaded). Recomputed per 128 /// scrub tick — `state(through:)` is O(position), trivial for one game. 129 var gridOverride: [GridPosition: JournalCellState]? { 130 guard let timeline, position < timeline.count else { return nil } 131 return timeline.state(through: position) 132 } 133 134 /// The cell changed by the most recently applied step — the replay 135 /// playhead, highlighted so the eye can follow the rewind. Tracks 136 /// `gridOverride`: `nil` at rest (head at the far right, live grid shown), 137 /// non-nil only while actively scrubbed back. 138 var cursor: GridPosition? { 139 guard let timeline, position > 0, position < timeline.count else { return nil } 140 return timeline.focus(ofStep: position - 1) 141 } 142 143 /// The author whose move the playhead highlights, used to tint it in that 144 /// author's colour so the rewind reads as each player's moves in turn. 145 /// Tracks `cursor`: `nil` whenever there is no playhead. 146 var cursorAuthorID: String? { 147 guard let timeline, position > 0, position < timeline.count else { return nil } 148 return timeline.actingAuthor(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(cells: cells, cursor: cursor, cursorAuthorID: cursorAuthorID) 157 } 158 159 /// Loads the replay via the caller's loader. Idempotent for an in-flight or 160 /// ready load; a `.waiting` / `.unavailable` result stays retryable, so 161 /// `retry()` (driven by the inbound-journal sync signal, or the manual 162 /// button) can re-check when a contributor's journal finally syncs. 163 func load(_ loader: () async -> JournalReplayResult) async { 164 switch status { 165 case .loading, .ready: 166 return 167 case .idle, .waiting, .unavailable: 168 break 169 } 170 status = .loading 171 let result = await loader() 172 switch result { 173 case .ready(let timeline): 174 status = .ready(timeline) 175 position = timeline.count 176 case .waiting(let missing): 177 status = .waiting(missing: missing) 178 case .unavailable: 179 status = .unavailable 180 } 181 } 182 183 /// Drops back to `.idle` so the next `load` re-checks. Triggered by the 184 /// inbound-journal sync signal (a contributor's journal arrived) and by the 185 /// manual "Check again" affordance. 186 func retry() { 187 status = .idle 188 playbackSpeed = 0 189 reloadToken += 1 190 } 191 }