// Board motion animation (P6.1) — a PURELY VISUAL timeline the view plays over // one player move. The logical model (commit_swap / resolve) stays authoritative: // `plan_and_commit` commits the move on the real board exactly as before, then // replays the SAME operations on a value-copy of the pre-move board to record the // per-step geometry (the swap, each cascade round's matched cells, and each // round's per-column fall provenance). Because the copy starts from the identical // cells AND RNG state and runs the identical primitives, its recorded `final` // board equals the model's settled board gem-for-gem — the animation only ever // ends ON the already-decided result, never changes it. // // Per-gem idle/select/clear gem animations (P6.3) and score popups / particle FX // (P6.2) are NOT here; this step animates board MOTION only: swap slide, matched // scale-out, and collapse/refill fall. #import "modules/std.sx"; #import "modules/math"; #import "modules/ui/types.sx"; #import "board.sx"; #import "board_layout.sx"; // Short, frame-timed durations (seconds) for each timeline segment. Driven by // the frame loop's delta_time, so they are wall-clock, framerate-independent. SWAP_ANIM_DUR :f32: 0.16; CLEAR_ANIM_DUR :f32: 0.14; FALL_ANIM_DUR :f32: 0.22; // Easing helpers. Slide/fall decelerate into place (ease-out cubic); the clear // scale-out accelerates as it shrinks (ease-in quad). ease_out_cubic :: (t: f32) -> f32 { u := t - 1.0; u * u * u + 1.0 } ease_in_quad :: (t: f32) -> f32 { t * t } // One recorded cascade round. `before` is the board at the round's start (the // swapped board for round 0, the previous round's `after` otherwise — never has // holes). `matched` flags the cells cleared this round (they scale out). `src` // maps each destination cell to the SOURCE ROW its gem falls from within the same // column: a non-negative row for a surviving gem that slides down, or a NEGATIVE // row (above the board) for a freshly-refilled gem dropping in from the top. // `after` is the board once this round has cleared, collapsed, and refilled. AnimRound :: struct { before: [BOARD_CELLS]Gem; matched: MatchMask; src: [BOARD_CELLS]s64; after: [BOARD_CELLS]Gem; } // The full recorded timeline of one move. `legal` mirrors the model's decision: // a legal swap has >=1 round and `final` is the settled board; an illegal swap // has zero rounds, `pre == final`, and the view plays a slide-and-return. `a`/`b` // are the swapped cells; `pre` is the board before the swap (the slide's start). // `awarded` carries the model's own payout for this move (cascade.awarded) so the // score-popup FX (P6.2) shows the real number without re-deriving any scoring. AnimMove :: struct { legal: bool; a: Cell; b: Cell; pre: [BOARD_CELLS]Gem; rounds: List(AnimRound); final: [BOARD_CELLS]Gem; awarded: s64; } // Commit the player's swap authoritatively AND record its visual timeline. The // real board is mutated by `commit_swap` exactly as the non-animated path did; // the recording runs on a separate value-copy taken BEFORE the commit, so it // replays the identical cells + RNG stream and its `final` equals `board.cells`. plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove { move : AnimMove = ---; move.a = a; move.b = b; move.rounds = List(AnimRound).{}; move.pre = board.cells; move.awarded = 0; // Snapshot the entire model state (cells + RNG + score + moves) before the // commit so the replay below is bit-identical to what commit_swap does. scratch : Board = board.*; mv := commit_swap(board, a, b); move.legal = mv.legal; move.awarded = mv.cascade.awarded; if !mv.legal { move.final = board.cells; return move; } swap(@scratch, a, b); while true { m := find_matches(@scratch); if m.count() == 0 { break; } round : AnimRound = ---; round.before = scratch.cells; round.matched = m; clear_cells(@scratch, @m); // Fall provenance, read off the just-cleared (holed) board — mirrors // `collapse`'s packing exactly: scanning a column bottom-to-top, each // surviving gem lands at the descending write cursor `w`, so dest row `w` // came from source row `r`. The rows left above the survivors (0..w) are // refilled, so they drop in from above: a dest row `j` there starts at // `j - n_refill`, i.e. stacked just off the top edge. for 0..BOARD_COLS: (col) { w := BOARD_ROWS - 1; r := BOARD_ROWS - 1; while r >= 0 { if scratch.at(col, r) != .empty { round.src[Board.idx(col, w)] = r; w -= 1; } r -= 1; } n_refill := w + 1; j := 0; while j <= w { round.src[Board.idx(col, j)] = j - n_refill; j += 1; } } collapse(@scratch); refill(@scratch); round.after = scratch.cells; move.rounds.append(round); } move.final = scratch.cells; move } // Which segment of the timeline is playing, and the local 0..1 progress within // it. `round` indexes `AnimMove.rounds` for clear/fall. AnimPhaseKind :: enum { swap; clear; fall; done; } AnimPhase :: struct { kind: AnimPhaseKind; round: s64; t: f32; } // Live timeline state for the in-flight move. Heap-allocated (like BoardSelection // / DragInput) so it survives BoardView's per-frame rebuild; `tick` advances it // by the frame's delta_time and the view reads `phase` to render the right slice. BoardAnim :: struct { active: bool; elapsed: f32; move: AnimMove; // Highest 1-based cascade round whose ascending combo cue has already played, // so the frame loop's per-round SFX is edge-triggered: a round's cue fires once, // when its clear begins, never re-fired every frame. Reset whenever a move // (re)starts; advanced by the frame loop as rounds clear. cascade_fired: s64; init :: (self: *BoardAnim) { self.active = false; self.elapsed = 0.0; self.move.legal = false; self.move.rounds = List(AnimRound).{}; self.cascade_fired = 0; } begin :: (self: *BoardAnim, m: AnimMove) { self.move = m; self.elapsed = 0.0; self.active = true; self.cascade_fired = 0; } // Total wall-clock length: the swap segment plus a clear+fall pair per round. total :: (self: *BoardAnim) -> f32 { SWAP_ANIM_DUR + cast(f32) self.move.rounds.len * (CLEAR_ANIM_DUR + FALL_ANIM_DUR) } tick :: (self: *BoardAnim, dt: f32) { if !self.active { return; } self.elapsed += dt; if self.elapsed >= self.total() { self.active = false; } } // Resolve `elapsed` to the active segment by walking swap → (clear, fall)*. phase :: (self: *BoardAnim) -> AnimPhase { e := self.elapsed; if e < SWAP_ANIM_DUR { return AnimPhase.{ kind = .swap, round = 0, t = e / SWAP_ANIM_DUR }; } e -= SWAP_ANIM_DUR; for 0..self.move.rounds.len: (k) { if e < CLEAR_ANIM_DUR { return AnimPhase.{ kind = .clear, round = k, t = e / CLEAR_ANIM_DUR }; } e -= CLEAR_ANIM_DUR; if e < FALL_ANIM_DUR { return AnimPhase.{ kind = .fall, round = k, t = e / FALL_ANIM_DUR }; } e -= FALL_ANIM_DUR; } AnimPhase.{ kind = .done, round = 0, t = 1.0 } } } // Per-round cascade-cue timing (P10.10): how many cascade rounds have BEGUN their // clear (pop) by `elapsed`, on the SAME swap→(clear,fall)* timeline `phase` walks. // Round k (0-based) starts clearing at SWAP_ANIM_DUR + k*(CLEAR_ANIM_DUR + // FALL_ANIM_DUR), so this is the count of rounds whose ascending combo cue should // have fired by now (clamped to the move's round count). The frame loop diffs it // against `BoardAnim.cascade_fired` to play one cue per newly-cleared round. Pure + // headless so the per-round playback is snapshot-testable without audio. cascade_rounds_started :: (elapsed: f32, num_rounds: s64) -> s64 { if num_rounds <= 0 { return 0; } if elapsed < SWAP_ANIM_DUR { return 0; } seg := CLEAR_ANIM_DUR + FALL_ANIM_DUR; started := cast(s64) ((elapsed - SWAP_ANIM_DUR) / seg) + 1; if started > num_rounds { return num_rounds; } started } // Input gate: the board accepts a new swipe/tap gesture only when no move // animation is in flight. The view checks this at gesture START (mouse_down), // not at commit (mouse_up), so a gesture begun while a timeline is playing never // latches a drag and so cannot commit when the animation later settles. Input // resumes once `tick` clears `active` at the end of the timeline. A null anim // (no animation layer wired) always accepts. accepts_input :: (anim: *BoardAnim) -> bool { anim == null or !anim.active }