crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }