diff --git a/board_anim.sx b/board_anim.sx new file mode 100644 index 0000000..0b10e7a --- /dev/null +++ b/board_anim.sx @@ -0,0 +1,186 @@ +// 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). +AnimMove :: struct { + legal: bool; + a: Cell; + b: Cell; + pre: [BOARD_CELLS]Gem; + rounds: List(AnimRound); + final: [BOARD_CELLS]Gem; +} + +// 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; + + // 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; + 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; + + init :: (self: *BoardAnim) { + self.active = false; + self.elapsed = 0.0; + self.move.legal = false; + self.move.rounds = List(AnimRound).{}; + } + + begin :: (self: *BoardAnim, m: AnimMove) { + self.move = m; + self.elapsed = 0.0; + self.active = true; + } + + // 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 } + } +} diff --git a/board_view.sx b/board_view.sx index 68a8fb5..6300a83 100644 --- a/board_view.sx +++ b/board_view.sx @@ -17,6 +17,7 @@ #import "modules/ui/font.sx"; #import "board.sx"; #import "board_layout.sx"; +#import "board_anim.sx"; #import "swipe.sx"; // Fraction of a cell each gem occupies; the remainder is margin so a gem sits @@ -169,6 +170,7 @@ BoardView :: struct { assets: *BoardAssets; sel: *BoardSelection; drag: *DragInput; + anim: *BoardAnim; safe: EdgeInsets; // Where the grid sits + the touch↔cell mapping. Recomputed each render / @@ -178,6 +180,160 @@ BoardView :: struct { compute_layout :: (self: *BoardView, frame: Frame) { self.layout.compute(frame, self.safe); } + + // Draw gem `gem_index`'s sprite-sheet column into `gf`. + draw_gem :: (self: *BoardView, ctx: *RenderContext, gf: Frame, gem_index: s64) { + uv := self.assets.gem_uv(gem_index); + ctx.add_image_uv(gf, self.assets.gems_tex, uv.uv_min, uv.uv_max); + } + + // Frame for a gem at a (possibly fractional) board position, inset inside its + // cell. Fractional col/row is how the swap-slide and fall animations place a + // gem partway between cells. + gem_frame :: (self: *BoardView, fcol: f32, frow: f32, inset: f32, dim: f32) -> Frame { + Frame.make( + self.layout.origin.x + fcol * self.layout.cell_size + inset, + self.layout.origin.y + frow * self.layout.cell_size + inset, + dim, + dim + ) + } + + // Frame for a gem shrunk by `scale` about its cell centre — the clear + // scale-out. At scale 0 the gem is a zero-size frame (gone). + gem_frame_scaled :: (self: *BoardView, col: s64, row: s64, dim: f32, scale: f32) -> Frame { + cs := self.layout.cell_size; + cx := self.layout.origin.x + cast(f32) col * cs + cs * 0.5; + cy := self.layout.origin.y + cast(f32) row * cs + cs * 0.5; + d := dim * scale; + Frame.make(cx - d * 0.5, cy - d * 0.5, d, d) + } + + // Settled-board gems: one sprite per non-empty cell at its cell frame. Used + // whenever no move is animating. + render_gems :: (self: *BoardView, ctx: *RenderContext, inset: f32, dim: f32) { + for 0..BOARD_ROWS: (row) { + for 0..BOARD_COLS: (col) { + g := self.board.at(col, row); + if g != .empty { + gf := self.gem_frame(cast(f32) col, cast(f32) row, inset, dim); + self.draw_gem(ctx, gf, cast(s64) g); + } + } + } + } + + // Play the active slice of the move timeline. Gem motion is clipped to the + // grid so refilled gems slide in from behind the top edge rather than + // overlapping the HUD band above the board. + render_anim :: (self: *BoardView, ctx: *RenderContext, inset: f32, dim: f32) { + ph := self.anim.phase(); + cs := self.layout.cell_size; + grid := Frame.make( + self.layout.origin.x, self.layout.origin.y, + cs * cast(f32) BOARD_COLS, cs * cast(f32) BOARD_ROWS + ); + ctx.push_clip(grid); + + mv := @self.anim.move; + if ph.kind == .swap { + self.render_swap(ctx, mv, inset, dim, ph.t); + } else if ph.kind == .clear { + rd := @mv.rounds.items[ph.round]; + self.render_clear(ctx, rd, inset, dim, ph.t); + } else if ph.kind == .fall { + rd := @mv.rounds.items[ph.round]; + self.render_fall(ctx, rd, inset, dim, ph.t); + } else { + // Settled tail of the timeline — draw the final (model) board. tick() + // normally clears `active` before this is reached, so it is the seam + // safety net rather than a frame the player typically sees. + for 0..BOARD_CELLS: (i) { + g := mv.final[i]; + if g != .empty { + gf := self.gem_frame(cast(f32) (i % BOARD_COLS), cast(f32) (i / BOARD_COLS), inset, dim); + self.draw_gem(ctx, gf, cast(s64) g); + } + } + } + + ctx.pop_clip(); + } + + // Swap segment: the board sits still (pre-swap) except the two swapped gems, + // which slide between their cells. A legal swap slides fully (a→b, b→a); an + // illegal one pings out to the neighbour and back, ending where it started. + render_swap :: (self: *BoardView, ctx: *RenderContext, mv: *AnimMove, inset: f32, dim: f32, t: f32) { + ai := Board.idx(mv.a.col, mv.a.row); + bi := Board.idx(mv.b.col, mv.b.row); + + for 0..BOARD_CELLS: (i) { + if i == ai or i == bi { continue; } + g := mv.pre[i]; + if g != .empty { + gf := self.gem_frame(cast(f32) (i % BOARD_COLS), cast(f32) (i / BOARD_COLS), inset, dim); + self.draw_gem(ctx, gf, cast(s64) g); + } + } + + p : f32 = ---; + if mv.legal { + p = ease_out_cubic(t); + } else if t < 0.5 { + p = ease_out_cubic(t * 2.0); + } else { + p = ease_out_cubic((1.0 - t) * 2.0); + } + + afc := cast(f32) mv.a.col; afr := cast(f32) mv.a.row; + bfc := cast(f32) mv.b.col; bfr := cast(f32) mv.b.row; + + ga := mv.pre[ai]; + if ga != .empty { + gf := self.gem_frame(afc + (bfc - afc) * p, afr + (bfr - afr) * p, inset, dim); + self.draw_gem(ctx, gf, cast(s64) ga); + } + gb := mv.pre[bi]; + if gb != .empty { + gf := self.gem_frame(bfc + (afc - bfc) * p, bfr + (afr - bfr) * p, inset, dim); + self.draw_gem(ctx, gf, cast(s64) gb); + } + } + + // Clear segment: matched gems shrink toward nothing; the rest hold position. + render_clear :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, inset: f32, dim: f32, t: f32) { + shrink := 1.0 - ease_in_quad(t); + if shrink < 0.0 { shrink = 0.0; } + for 0..BOARD_CELLS: (i) { + g := rd.before[i]; + if g == .empty { continue; } + col := i % BOARD_COLS; + row := i / BOARD_COLS; + if rd.matched.cells[i] { + gf := self.gem_frame_scaled(col, row, dim, shrink); + self.draw_gem(ctx, gf, cast(s64) g); + } else { + gf := self.gem_frame(cast(f32) col, cast(f32) row, inset, dim); + self.draw_gem(ctx, gf, cast(s64) g); + } + } + } + + // Fall segment: every gem of the round's settled board slides from its source + // row (above the board for refills) down to its destination cell. + render_fall :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, inset: f32, dim: f32, t: f32) { + te := ease_out_cubic(t); + for 0..BOARD_CELLS: (i) { + g := rd.after[i]; + if g == .empty { continue; } + col := i % BOARD_COLS; + drow := i / BOARD_COLS; + src := rd.src[i]; + cur_row := cast(f32) src + (cast(f32) drow - cast(f32) src) * te; + gf := self.gem_frame(cast(f32) col, cur_row, inset, dim); + self.draw_gem(ctx, gf, cast(s64) g); + } + } } impl View for BoardView { @@ -197,28 +353,25 @@ impl View for BoardView { ctx.add_image(frame, self.assets.bg_tex); } - // 2. One cell tile per board cell, then its gem sampled by index column. + // 2. One cell tile per board cell — the static grid, never animated. gem_inset := self.layout.cell_size * (1.0 - GEM_FILL_FRAC) * 0.5; gem_dim := self.layout.cell_size * GEM_FILL_FRAC; - for 0..BOARD_ROWS: (row) { - for 0..BOARD_COLS: (col) { - cf := self.layout.cell_frame(col, row); - - if self.assets.cell_tex != 0 { - ctx.add_image(cf, self.assets.cell_tex); + if self.assets.cell_tex != 0 { + for 0..BOARD_ROWS: (row) { + for 0..BOARD_COLS: (col) { + ctx.add_image(self.layout.cell_frame(col, row), self.assets.cell_tex); } + } + } - g := self.board.at(col, row); - if g != .empty and self.assets.gems_tex != 0 { - uv := self.assets.gem_uv(cast(s64) g); - gf := Frame.make( - cf.origin.x + gem_inset, - cf.origin.y + gem_inset, - gem_dim, - gem_dim - ); - ctx.add_image_uv(gf, self.assets.gems_tex, uv.uv_min, uv.uv_max); - } + // 2b. Gems: while a move is animating, play its swap/clear/fall timeline; + // otherwise draw the settled model board. The timeline ends exactly on + // the model state, so the seam back to the static path is invisible. + if self.assets.gems_tex != 0 { + if self.anim != null and self.anim.active { + self.render_anim(ctx, gem_inset, gem_dim); + } else { + self.render_gems(ctx, gem_inset, gem_dim); } } @@ -254,8 +407,12 @@ impl View for BoardView { if !self.drag.active { return false; } start := self.drag.start; self.drag.clear(); + // Ignore swipes while a move is still animating so two timelines + // never overlap; the model is already settled by then either way. + if self.anim != null and self.anim.active { return true; } if intent := swipe_intent(@self.layout, start, d.position) { - commit_swap(self.board, intent.a, intent.b); + mv := plan_and_commit(self.board, intent.a, intent.b); + if self.anim != null { self.anim.begin(mv); } self.sel.clear(); } else { if hit := self.layout.point_to_cell(start) { diff --git a/goldens/p6_anim_after.png b/goldens/p6_anim_after.png new file mode 100644 index 0000000..7a173b7 Binary files /dev/null and b/goldens/p6_anim_after.png differ diff --git a/goldens/p6_anim_clear.png b/goldens/p6_anim_clear.png new file mode 100644 index 0000000..91ef841 Binary files /dev/null and b/goldens/p6_anim_clear.png differ diff --git a/goldens/p6_anim_fall.png b/goldens/p6_anim_fall.png new file mode 100644 index 0000000..14ac2c8 Binary files /dev/null and b/goldens/p6_anim_fall.png differ diff --git a/goldens/p6_anim_swap.png b/goldens/p6_anim_swap.png new file mode 100644 index 0000000..a8866e2 Binary files /dev/null and b/goldens/p6_anim_swap.png differ diff --git a/main.sx b/main.sx index f42600d..a647e46 100644 --- a/main.sx +++ b/main.sx @@ -15,6 +15,7 @@ #import "modules/platform/uikit.sx"; #import "board.sx"; #import "board_view.sx"; +#import "board_anim.sx"; #run configure_build(); @@ -24,6 +25,7 @@ BOARD_SEED :: 1337; g_plat : Platform = ---; g_pipeline : *UIPipeline = ---; +g_delta_time : f32 = 0.016; g_viewport_w : f32 = 800.0; g_viewport_h : f32 = 600.0; g_safe_insets : EdgeInsets = .{}; @@ -49,14 +51,20 @@ g_sel : *BoardSelection = null; // so the drag start must persist between them. g_drag : *DragInput = null; +// In-flight move animation (P6.1). Heap-allocated for the same reason: a swipe +// begins the swap/clear/fall timeline, which then plays out over many subsequent +// frames, so the timeline state must persist across BoardView's per-frame rebuild. +g_anim : *BoardAnim = null; + // Rebuilt each frame inside the pipeline's arena; carries the current safe-area // insets so the grid stays inside the notch / home-indicator region. build_ui :: () -> View { - BoardView.{ board = g_board, assets = g_assets, sel = g_sel, drag = g_drag, safe = g_safe_insets } + BoardView.{ board = g_board, assets = g_assets, sel = g_sel, drag = g_drag, anim = g_anim, safe = g_safe_insets } } frame :: () { fc := g_plat.begin_frame(); + g_delta_time = fc.delta_time; g_viewport_w = fc.viewport_w; g_viewport_h = fc.viewport_h; g_safe_insets = g_plat.safe_insets(); @@ -76,6 +84,10 @@ frame :: () { g_pipeline.dispatch_event(ev); } + // Advance the in-flight move animation by this frame's delta before rendering, + // so the board view draws the timeline slice for the current wall-clock time. + if g_anim != null { g_anim.tick(g_delta_time); } + inline if OS == .ios { // Lazy-attach Metal once -[SxAppDelegate didFinishLaunching:] has // installed the SxMetalView and its bounds have been measured; both can @@ -152,6 +164,9 @@ main :: () -> void { g_drag = xx context.allocator.alloc(size_of(DragInput)); g_drag.init(); + g_anim = xx context.allocator.alloc(size_of(BoardAnim)); + g_anim.init(); + g_pipeline.set_body(closure(build_ui)); g_plat.run_frame_loop(closure(frame)); diff --git a/tests/anim_plan.sx b/tests/anim_plan.sx new file mode 100644 index 0000000..05c4da0 --- /dev/null +++ b/tests/anim_plan.sx @@ -0,0 +1,117 @@ +// Animation-layer determinism guard (P6.1): prove the swap/clear/fall animation +// timeline is PURELY VISUAL — it never changes the model's result. `plan_and_commit` +// commits the move on the real board (authoritative) AND records the visual +// timeline on a value-copy; this test asserts, on the SAME seed the app renders +// (SEED 1337): +// - the board `plan_and_commit` leaves is byte-for-byte identical to an +// independent `commit_swap` of the same move, with the same score + moves; +// - the recorded timeline ENDS on that exact state: `move.final` equals the +// model board, the rounds are contiguous (round 0 starts on the swapped board, +// each later round starts on the prior round's settled board), and the last +// round's `after` equals `final`; +// - an illegal swap records no rounds and leaves the board untouched. +// No rendering — it calls exactly what BoardView.handle_event calls. Links headless +// like tests/swipe_commit.sx; avoids tests/test.sx (its trace.sx pulls in a second +// `Frame` that collides with the UI one). Failure is a non-zero exit code. +#import "modules/std.sx"; +#import "board.sx"; +#import "board_anim.sx"; + +SEED :: 1337; + +boards_equal :: (x: *Board, y: *Board) -> bool { + for 0..BOARD_CELLS: (i) { + if !(x.cells[i] == y.cells[i]) { return false; } + } + true +} + +main :: () -> s32 { + fails : s64 = 0; + + // ── Legal swap: plan == model, timeline ends on the model ─────────────── + // (5,4)->(6,4): brings R into (5,4), completing R,R,R across cols 3-5 of row + // 4 — the same legal swap tests/swipe_commit.sx commits. + print("== legal swap: plan matches model ==\n"); + a := Cell.{ col = 5, row = 4 }; + b := Cell.{ col = 6, row = 4 }; + + bm : Board = ---; + bm.init(SEED); + mvm := commit_swap(@bm, a, b); + + ba : Board = ---; + ba.init(SEED); + move := plan_and_commit(@ba, a, b); + + print("model: legal {} depth {} score {} moves {}\n", + mvm.legal, mvm.cascade.depth, bm.score, bm.moves_made); + print("plan: legal {} rounds {} score {} moves {}\n", + move.legal, move.rounds.len, ba.score, ba.moves_made); + + if !move.legal { fails += 1; } + if !boards_equal(@ba, @bm) { fails += 1; } // committed board == model + if ba.score != bm.score { fails += 1; } + if ba.moves_made != bm.moves_made { fails += 1; } + if move.rounds.len != mvm.cascade.depth { fails += 1; } + + // move.final equals the model board. + final_eq := true; + for 0..BOARD_CELLS: (i) { + if !(move.final[i] == bm.cells[i]) { final_eq = false; } + } + if !final_eq { fails += 1; } + print("final==model {}\n", final_eq); + + // Timeline contiguity: round 0 starts on the swapped pre board; each later + // round starts on the previous round's settled board; final == last after. + contiguous := true; + if move.rounds.len > 0 { + ai := Board.idx(a.col, a.row); + bi := Board.idx(b.col, b.row); + r0 := @move.rounds.items[0]; + for 0..BOARD_CELLS: (i) { + expect : Gem = move.pre[i]; + if i == ai { expect = move.pre[bi]; } + else if i == bi { expect = move.pre[ai]; } + if !(r0.before[i] == expect) { contiguous = false; } + } + for 1..move.rounds.len: (k) { + prev := @move.rounds.items[k - 1]; + cur := @move.rounds.items[k]; + for 0..BOARD_CELLS: (i) { + if !(cur.before[i] == prev.after[i]) { contiguous = false; } + } + } + last := @move.rounds.items[move.rounds.len - 1]; + for 0..BOARD_CELLS: (i) { + if !(last.after[i] == move.final[i]) { contiguous = false; } + } + } + if !contiguous { fails += 1; } + print("contiguous {}\n", contiguous); + out("final board:\n"); + out(board_dump(@bm)); + + // ── Illegal swap: no timeline, board untouched ────────────────────────── + // (0,0)->(1,0): two reds → no match. plan_and_commit must leave the board + // exactly as it was, spend no move, and record zero rounds. + print("== illegal swap: untouched ==\n"); + bi2 : Board = ---; + bi2.init(SEED); + pre2 : Board = bi2; + mi := plan_and_commit(@bi2, Cell.{ col = 0, row = 0 }, Cell.{ col = 1, row = 0 }); + print("legal {} rounds {} score {} moves {}\n", mi.legal, mi.rounds.len, bi2.score, bi2.moves_made); + if mi.legal { fails += 1; } + if mi.rounds.len != 0 { fails += 1; } + if !boards_equal(@pre2, @bi2) { fails += 1; } + if bi2.score != 0 { fails += 1; } + if bi2.moves_made != 0 { fails += 1; } + + if fails == 0 { + print("ok: animation layer leaves the model result unchanged\n"); + return 0; + } + print("FAIL: {} anim-determinism checks failed\n", fails); + return 1; +} diff --git a/tests/expected/anim_plan.exit b/tests/expected/anim_plan.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/anim_plan.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/anim_plan.stdout b/tests/expected/anim_plan.stdout new file mode 100644 index 0000000..ba20f94 --- /dev/null +++ b/tests/expected/anim_plan.stdout @@ -0,0 +1,17 @@ +== legal swap: plan matches model == +model: legal true depth 1 score 30 moves 1 +plan: legal true rounds 1 score 30 moves 1 +final==model true +contiguous true +final board: +RRPBORRG +PGPPOGRO +YYBOPRYB +GBYBYRGP +OGBYRGOY +BYRRPRBG +YOYYROBB +OROBPPRB +== illegal swap: untouched == +legal false rounds 0 score 0 moves 0 +ok: animation layer leaves the model result unchanged