Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccfb9394e0 | |||
| b5c1ba4867 | |||
| d179d9d582 | |||
| db1cc58f3a | |||
| 1d2b6dc5de |
Generated
+1
@@ -7331,6 +7331,7 @@ dependencies = [
|
||||
"card_game",
|
||||
"klondike",
|
||||
"proptest",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
@@ -37,11 +37,6 @@ fn load_settings() -> Settings {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Build the Bevy app without entering the event loop.
|
||||
pub fn build_app(sync_provider: Box<dyn SyncProvider + Send + Sync>) -> App {
|
||||
build_app_with_settings(load_settings(), sync_provider)
|
||||
}
|
||||
|
||||
/// App entry point — configures runtime services, builds, and runs the app.
|
||||
///
|
||||
/// Called from both the desktop `bin` target's `main` shim and (on
|
||||
|
||||
@@ -16,6 +16,11 @@ serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
klondike = { workspace = true }
|
||||
card_game = { workspace = true }
|
||||
# Deliberately NOT the workspace rand (0.9): this pins the exact dep the
|
||||
# upstream `klondike` crate uses, so `SeedableRng`/`SliceRandom` resolve
|
||||
# against the same crate version as `klondike::Rng` and Spider deals go
|
||||
# through the identical shuffle stack as Klondike deals.
|
||||
rand = { version = "0.10.1", default-features = false, features = ["std_rng"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -13,7 +13,7 @@ pub mod spider;
|
||||
// re-exported — they are only used internally (in `klondike_adapter.rs` and
|
||||
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
||||
// appear in any public method signature.
|
||||
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
|
||||
pub use card_game::{Card, Deck, Rank, SolveError, Suit};
|
||||
pub use klondike::{
|
||||
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
|
||||
};
|
||||
@@ -25,8 +25,8 @@ pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, So
|
||||
// Spider rules (second `card_game::Game` implementation; engine UI is a
|
||||
// later phase — nothing outside solitaire_core consumes these yet).
|
||||
pub use spider::{
|
||||
SpiderConfig, SpiderGame, SpiderGameState, SpiderInstruction, SpiderScoring, SpiderStats,
|
||||
SpiderSuits,
|
||||
RunLength, Spider, SpiderConfig, SpiderGameState, SpiderInstruction, SpiderIter, SpiderMove,
|
||||
SpiderScoring, SpiderStats, SpiderSuits, SpiderTableau,
|
||||
};
|
||||
|
||||
/// All four foundation slots, in slot order.
|
||||
|
||||
+397
-187
@@ -22,11 +22,10 @@
|
||||
//!
|
||||
//! ## Determinism
|
||||
//!
|
||||
//! Deals are seeded with an inline SplitMix64 + Fisher–Yates shuffle:
|
||||
//! `solitaire_core` has no `rand` dependency (and adding one needs
|
||||
//! explicit approval), so Spider's seed space is deliberately
|
||||
//! self-contained rather than shared with Klondike's `StdRng` seeds.
|
||||
//! The same seed + suit count always produces the same deal.
|
||||
//! Deals are seeded exactly like upstream Klondike: [`Rng`] is the same
|
||||
//! `rand::rngs::StdRng` alias `klondike` exports, seeded through
|
||||
//! `SeedableRng::seed_from_u64` and applied with a `SliceRandom`
|
||||
//! shuffle. The same seed + suit count always produces the same deal.
|
||||
//!
|
||||
//! ## Card identity caveat (engine integration, later phase)
|
||||
//!
|
||||
@@ -43,6 +42,10 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::MoveError;
|
||||
|
||||
/// The RNG Spider deals with — the same alias upstream `klondike`
|
||||
/// exports, so both games share one shuffle stack.
|
||||
pub type Rng = rand::rngs::StdRng;
|
||||
|
||||
/// Number of tableau piles.
|
||||
pub const SPIDER_TABLEAUS: usize = 10;
|
||||
/// Total cards in play (two decks).
|
||||
@@ -146,24 +149,212 @@ impl SpiderStats {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instruction
|
||||
// Piles, run lengths, and instructions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One of the ten Spider tableau piles, in layout order.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SpiderTableau {
|
||||
/// Leftmost pile (opens with 6 cards).
|
||||
Tableau1,
|
||||
/// Second pile (opens with 6 cards).
|
||||
Tableau2,
|
||||
/// Third pile (opens with 6 cards).
|
||||
Tableau3,
|
||||
/// Fourth pile (opens with 6 cards).
|
||||
Tableau4,
|
||||
/// Fifth pile (opens with 5 cards).
|
||||
Tableau5,
|
||||
/// Sixth pile (opens with 5 cards).
|
||||
Tableau6,
|
||||
/// Seventh pile (opens with 5 cards).
|
||||
Tableau7,
|
||||
/// Eighth pile (opens with 5 cards).
|
||||
Tableau8,
|
||||
/// Ninth pile (opens with 5 cards).
|
||||
Tableau9,
|
||||
/// Rightmost pile (opens with 5 cards).
|
||||
Tableau10,
|
||||
}
|
||||
|
||||
impl SpiderTableau {
|
||||
/// All ten piles, in layout order — the canonical iteration source,
|
||||
/// following `Suit::SUITS` / `Rank::RANKS` upstream.
|
||||
pub const ALL: [Self; SPIDER_TABLEAUS] = [
|
||||
Self::Tableau1,
|
||||
Self::Tableau2,
|
||||
Self::Tableau3,
|
||||
Self::Tableau4,
|
||||
Self::Tableau5,
|
||||
Self::Tableau6,
|
||||
Self::Tableau7,
|
||||
Self::Tableau8,
|
||||
Self::Tableau9,
|
||||
Self::Tableau10,
|
||||
];
|
||||
const ITER_BEGIN: Self = Self::Tableau1;
|
||||
const fn next(self) -> Option<Self> {
|
||||
use SpiderTableau::*;
|
||||
Some(match self {
|
||||
Tableau1 => Tableau2,
|
||||
Tableau2 => Tableau3,
|
||||
Tableau3 => Tableau4,
|
||||
Tableau4 => Tableau5,
|
||||
Tableau5 => Tableau6,
|
||||
Tableau6 => Tableau7,
|
||||
Tableau7 => Tableau8,
|
||||
Tableau8 => Tableau9,
|
||||
Tableau9 => Tableau10,
|
||||
Tableau10 => return None,
|
||||
})
|
||||
}
|
||||
/// Zero-based position in layout order.
|
||||
const fn index(self) -> usize {
|
||||
self as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// How many cards a [`SpiderMove`] picks up — at most 13, since a
|
||||
/// movable run is same-suit strictly-descending.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum RunLength {
|
||||
/// A single card.
|
||||
Run1 = 1,
|
||||
/// The top 2 cards.
|
||||
Run2 = 2,
|
||||
/// The top 3 cards.
|
||||
Run3 = 3,
|
||||
/// The top 4 cards.
|
||||
Run4 = 4,
|
||||
/// The top 5 cards.
|
||||
Run5 = 5,
|
||||
/// The top 6 cards.
|
||||
Run6 = 6,
|
||||
/// The top 7 cards.
|
||||
Run7 = 7,
|
||||
/// The top 8 cards.
|
||||
Run8 = 8,
|
||||
/// The top 9 cards.
|
||||
Run9 = 9,
|
||||
/// The top 10 cards.
|
||||
Run10 = 10,
|
||||
/// The top 11 cards.
|
||||
Run11 = 11,
|
||||
/// The top 12 cards.
|
||||
Run12 = 12,
|
||||
/// A full K→A run.
|
||||
Run13 = 13,
|
||||
}
|
||||
|
||||
impl RunLength {
|
||||
const ITER_BEGIN: Self = Self::Run1;
|
||||
const fn next(self) -> Option<Self> {
|
||||
use RunLength::*;
|
||||
Some(match self {
|
||||
Run1 => Run2,
|
||||
Run2 => Run3,
|
||||
Run3 => Run4,
|
||||
Run4 => Run5,
|
||||
Run5 => Run6,
|
||||
Run6 => Run7,
|
||||
Run7 => Run8,
|
||||
Run8 => Run9,
|
||||
Run9 => Run10,
|
||||
Run10 => Run11,
|
||||
Run11 => Run12,
|
||||
Run12 => Run13,
|
||||
Run13 => return None,
|
||||
})
|
||||
}
|
||||
/// Number of cards in the run.
|
||||
pub const fn len(self) -> usize {
|
||||
self as usize
|
||||
}
|
||||
/// True only for [`Self::Run1`]; provided because clippy expects an
|
||||
/// `is_empty` alongside `len`, and a run is never actually empty.
|
||||
pub const fn is_empty(self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the top [`RunLength`] cards (a same-suit descending run) from
|
||||
/// one pile to another.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct SpiderMove {
|
||||
/// Source pile.
|
||||
pub from: SpiderTableau,
|
||||
/// Number of cards picked up from the top of `from`.
|
||||
pub run: RunLength,
|
||||
/// Destination pile.
|
||||
pub to: SpiderTableau,
|
||||
}
|
||||
|
||||
impl SpiderMove {
|
||||
const ITER_BEGIN: Self = Self {
|
||||
from: SpiderTableau::ITER_BEGIN,
|
||||
run: RunLength::ITER_BEGIN,
|
||||
to: SpiderTableau::ITER_BEGIN,
|
||||
};
|
||||
const fn next(self) -> Option<Self> {
|
||||
let Self { from, run, to } = self;
|
||||
if let Some(to) = to.next() {
|
||||
return Some(Self { from, run, to });
|
||||
}
|
||||
let to = SpiderTableau::ITER_BEGIN;
|
||||
if let Some(run) = run.next() {
|
||||
return Some(Self { from, run, to });
|
||||
}
|
||||
let run = RunLength::ITER_BEGIN;
|
||||
if let Some(from) = from.next() {
|
||||
return Some(Self { from, run, to });
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// One atomic Spider action (the `Game::Instruction` type).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SpiderInstruction {
|
||||
/// Move a run between tableau piles.
|
||||
Move(SpiderMove),
|
||||
/// Deal one card from the stock onto every tableau pile.
|
||||
Deal,
|
||||
/// Move the top `count` face-up cards (a same-suit descending run)
|
||||
/// from pile `from` to pile `to`. Pile indices are `0..10`.
|
||||
Move {
|
||||
/// Source pile index.
|
||||
from: u8,
|
||||
/// Destination pile index.
|
||||
to: u8,
|
||||
/// Number of cards in the moved run (≥ 1).
|
||||
count: u8,
|
||||
},
|
||||
}
|
||||
|
||||
impl SpiderInstruction {
|
||||
const ITER_BEGIN: Self = Self::Move(SpiderMove::ITER_BEGIN);
|
||||
const fn next(self) -> Option<Self> {
|
||||
Some(match self {
|
||||
Self::Move(spider_move) => match spider_move.next() {
|
||||
Some(spider_move) => Self::Move(spider_move),
|
||||
None => Self::Deal,
|
||||
},
|
||||
Self::Deal => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Exhaustive walk of the Spider instruction space, mirroring
|
||||
/// `klondike::KlondikeIter`.
|
||||
pub struct SpiderIter {
|
||||
instruction: Option<SpiderInstruction>,
|
||||
}
|
||||
|
||||
impl SpiderIter {
|
||||
const fn new() -> Self {
|
||||
Self {
|
||||
instruction: Some(SpiderInstruction::ITER_BEGIN),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for SpiderIter {
|
||||
type Item = SpiderInstruction;
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let instruction = self.instruction;
|
||||
self.instruction = instruction?.next();
|
||||
instruction
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -174,45 +365,22 @@ pub enum SpiderInstruction {
|
||||
/// of completed runs. Everything else (undo, score bookkeeping) lives
|
||||
/// in the wrapping [`Session`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct SpiderGame {
|
||||
pub struct Spider {
|
||||
tableaus: [Pile<MAX_FACE_DOWN, SPIDER_DECK_SIZE>; SPIDER_TABLEAUS],
|
||||
stock: Stack<STOCK_SIZE>,
|
||||
completed_runs: u8,
|
||||
}
|
||||
|
||||
/// SplitMix64 step — small, well-known, and dependency-free. Spider
|
||||
/// only needs a deterministic shuffle, not cryptographic quality.
|
||||
const fn splitmix64(state: u64) -> (u64, u64) {
|
||||
let state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
(state, z ^ (z >> 31))
|
||||
}
|
||||
|
||||
/// In-place Fisher–Yates driven by SplitMix64. The modulo bias is
|
||||
/// astronomically small for n ≤ 104 and irrelevant for gameplay.
|
||||
fn shuffle(cards: &mut [Card], seed: u64) {
|
||||
let mut state = seed;
|
||||
for i in (1..cards.len()).rev() {
|
||||
let (next_state, r) = splitmix64(state);
|
||||
state = next_state;
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let j = (r % (i as u64 + 1)) as usize;
|
||||
cards.swap(i, j);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the 104-card Spider deck for a suit-count difficulty.
|
||||
///
|
||||
/// Deck ids spread copies apart where possible (`Deck1..Deck4`), but
|
||||
/// 1- and 2-suit games necessarily contain identical `Card` values —
|
||||
/// see the module docs.
|
||||
fn build_deck(suits: SpiderSuits) -> Vec<Card> {
|
||||
fn build_deck(suits: SpiderSuits) -> Stack<SPIDER_DECK_SIZE> {
|
||||
let suit_set = suits.suits();
|
||||
let copies = SPIDER_DECK_SIZE / (suit_set.len() * RUN_LEN);
|
||||
let decks = [Deck::Deck1, Deck::Deck2, Deck::Deck3, Deck::Deck4];
|
||||
let mut cards = Vec::with_capacity(SPIDER_DECK_SIZE);
|
||||
let mut cards = Stack::new();
|
||||
for copy in 0..copies {
|
||||
for &suit in suit_set {
|
||||
for rank in Rank::RANKS {
|
||||
@@ -223,15 +391,24 @@ fn build_deck(suits: SpiderSuits) -> Vec<Card> {
|
||||
cards
|
||||
}
|
||||
|
||||
impl SpiderGame {
|
||||
impl Spider {
|
||||
/// Deals a new seeded game at the given suit difficulty.
|
||||
pub fn with_seed(seed: u64, suits: SpiderSuits) -> Self {
|
||||
use rand::SeedableRng;
|
||||
let mut rng = Rng::seed_from_u64(seed);
|
||||
Self::with_rng(&mut rng, suits)
|
||||
}
|
||||
|
||||
/// Deals a new game from a caller-supplied RNG.
|
||||
pub fn with_rng(rng: &mut Rng, suits: SpiderSuits) -> Self {
|
||||
// shuffle a new two-deck spread
|
||||
let mut deck = build_deck(suits);
|
||||
shuffle(&mut deck, seed);
|
||||
use rand::seq::SliceRandom;
|
||||
deck.shuffle(rng);
|
||||
let mut cards = deck.into_iter();
|
||||
|
||||
let tableaus = core::array::from_fn(|index| {
|
||||
// Piles 0–3 open with 5 face-down cards, piles 4–9 with 4;
|
||||
// Piles 1–4 open with 5 face-down cards, piles 5–10 with 4;
|
||||
// one face-up card lands on each afterwards.
|
||||
let down_count = if index < 4 { 5 } else { 4 };
|
||||
let stack: Stack<MAX_FACE_DOWN> = cards.by_ref().take(down_count).collect();
|
||||
@@ -250,22 +427,24 @@ impl SpiderGame {
|
||||
}
|
||||
}
|
||||
|
||||
/// Face-up cards of pile `index` (bottom → top). Empty slice for
|
||||
/// out-of-range indices.
|
||||
pub fn tableau_face_up(&self, index: usize) -> &[Card] {
|
||||
self.tableaus.get(index).map_or(&[], |pile| pile.face_up())
|
||||
/// Face-up cards of a pile (bottom → top).
|
||||
pub fn tableau_face_up_cards(&self, tableau: SpiderTableau) -> &[Card] {
|
||||
self.tableaus[tableau.index()].face_up()
|
||||
}
|
||||
|
||||
/// Face-down cards of pile `index` (bottom → top).
|
||||
pub fn tableau_face_down(&self, index: usize) -> &[Card] {
|
||||
self.tableaus
|
||||
.get(index)
|
||||
.map_or(&[], |pile| pile.face_down())
|
||||
/// Face-down cards of a pile (bottom → top).
|
||||
pub fn tableau_face_down_cards(&self, tableau: SpiderTableau) -> &[Card] {
|
||||
self.tableaus[tableau.index()].face_down()
|
||||
}
|
||||
|
||||
/// Cards remaining in the stock.
|
||||
pub fn stock_len(&self) -> usize {
|
||||
self.stock.len()
|
||||
/// Topmost face-up card of a pile.
|
||||
pub fn tableau_top_card(&self, tableau: SpiderTableau) -> Option<&Card> {
|
||||
self.tableaus[tableau.index()].face_up().last()
|
||||
}
|
||||
|
||||
/// The stock (deals come off the top).
|
||||
pub const fn stock(&self) -> &Stack<STOCK_SIZE> {
|
||||
&self.stock
|
||||
}
|
||||
|
||||
/// Completed K→A runs removed from play so far.
|
||||
@@ -273,19 +452,16 @@ impl SpiderGame {
|
||||
self.completed_runs
|
||||
}
|
||||
|
||||
/// Length of the longest movable run on top of pile `index`: the
|
||||
/// maximal same-suit, strictly-descending face-up suffix.
|
||||
fn movable_run_len(&self, index: usize) -> usize {
|
||||
let Some(pile) = self.tableaus.get(index) else {
|
||||
return 0;
|
||||
};
|
||||
let up = pile.face_up();
|
||||
/// Length of the longest movable run on top of a pile: the maximal
|
||||
/// same-suit, strictly-descending face-up suffix.
|
||||
fn movable_run_len(&self, tableau: SpiderTableau) -> usize {
|
||||
let up = self.tableau_face_up_cards(tableau);
|
||||
let mut len = usize::from(!up.is_empty());
|
||||
while len < up.len() {
|
||||
let above = &up[up.len() - len];
|
||||
let below = &up[up.len() - len - 1];
|
||||
let descends =
|
||||
below.suit() == above.suit() && below.rank() as u8 == above.rank() as u8 + 1;
|
||||
below.suit() == above.suit() && above.rank().checked_add(1) == Some(below.rank());
|
||||
if !descends {
|
||||
break;
|
||||
}
|
||||
@@ -294,27 +470,27 @@ impl SpiderGame {
|
||||
len
|
||||
}
|
||||
|
||||
/// Whether `Move { from, to, count }` is legal in this position.
|
||||
fn is_move_valid(&self, from: u8, to: u8, count: u8) -> bool {
|
||||
let (from, to, count) = (from as usize, to as usize, count as usize);
|
||||
if from == to || from >= SPIDER_TABLEAUS || to >= SPIDER_TABLEAUS || count == 0 {
|
||||
/// Whether a run move is legal in this position.
|
||||
fn is_move_valid(&self, spider_move: SpiderMove) -> bool {
|
||||
let SpiderMove { from, run, to } = spider_move;
|
||||
if from == to {
|
||||
return false;
|
||||
}
|
||||
if count > self.movable_run_len(from) {
|
||||
if run.len() > self.movable_run_len(from) {
|
||||
return false;
|
||||
}
|
||||
let src_up = self.tableaus[from].face_up();
|
||||
// Bottom card of the moved run; `count <= movable_run_len <=
|
||||
// src_up.len()` guarantees the index is in range.
|
||||
let Some(moved_bottom) = src_up.get(src_up.len() - count) else {
|
||||
let src_up = self.tableau_face_up_cards(from);
|
||||
// Bottom card of the moved run; `run.len() <= movable_run_len
|
||||
// <= src_up.len()` guarantees the index is in range.
|
||||
let Some(moved_bottom) = src_up.get(src_up.len() - run.len()) else {
|
||||
return false;
|
||||
};
|
||||
match self.tableaus[to].face_up().last() {
|
||||
match self.tableau_top_card(to) {
|
||||
// Build down regardless of suit.
|
||||
Some(dest_top) => dest_top.rank() as u8 == moved_bottom.rank() as u8 + 1,
|
||||
Some(dest_top) => moved_bottom.rank().checked_add(1) == Some(dest_top.rank()),
|
||||
// Empty pile accepts anything (face-down remnant can't
|
||||
// exist without a face-up card — Pile flips eagerly).
|
||||
None => self.tableaus[to].is_empty(),
|
||||
None => self.tableaus[to.index()].is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,22 +499,24 @@ impl SpiderGame {
|
||||
!self.stock.is_empty() && self.tableaus.iter().all(|pile| !pile.is_empty())
|
||||
}
|
||||
|
||||
/// Removes a completed K→A same-suit run from the top of pile
|
||||
/// `index`, if one is present. Returns `true` when a run was
|
||||
/// removed (and the next face-down card, if any, was flipped).
|
||||
fn sweep_completed_run(&mut self, index: usize) -> bool {
|
||||
let Some(pile) = self.tableaus.get_mut(index) else {
|
||||
return false;
|
||||
};
|
||||
/// Removes a completed K→A same-suit run from the top of a pile,
|
||||
/// if one is present. Returns `true` when a run was removed (and
|
||||
/// the next face-down card, if any, was flipped).
|
||||
fn sweep_completed_run(&mut self, tableau: SpiderTableau) -> bool {
|
||||
let pile = &mut self.tableaus[tableau.index()];
|
||||
let up = pile.face_up();
|
||||
if up.len() < RUN_LEN {
|
||||
return false;
|
||||
}
|
||||
let run = &up[up.len() - RUN_LEN..];
|
||||
let suit = run[0].suit();
|
||||
let is_complete = run.iter().enumerate().all(|(offset, card)| {
|
||||
card.suit() == suit && card.rank() as u8 == (RUN_LEN - offset) as u8
|
||||
});
|
||||
// The run sits bottom-first (K → A); reversed, it must read
|
||||
// exactly Ace → King in one suit.
|
||||
let is_complete = run
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(Rank::RANKS)
|
||||
.all(|(card, rank)| card.suit() == suit && card.rank() == rank);
|
||||
if !is_complete {
|
||||
return false;
|
||||
}
|
||||
@@ -348,7 +526,7 @@ impl SpiderGame {
|
||||
}
|
||||
}
|
||||
|
||||
impl Game for SpiderGame {
|
||||
impl Game for Spider {
|
||||
type Score = i32;
|
||||
type Stats = SpiderStats;
|
||||
type Config = SpiderConfig;
|
||||
@@ -365,34 +543,16 @@ impl Game for SpiderGame {
|
||||
&self,
|
||||
config: &Self::Config,
|
||||
) -> impl Iterator<Item = Self::Instruction> + use<> {
|
||||
let mut out = Vec::new();
|
||||
if self.is_deal_valid() {
|
||||
out.push(SpiderInstruction::Deal);
|
||||
}
|
||||
for from in 0..SPIDER_TABLEAUS {
|
||||
let max_run = self.movable_run_len(from);
|
||||
for count in 1..=max_run {
|
||||
for to in 0..SPIDER_TABLEAUS {
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let instruction = SpiderInstruction::Move {
|
||||
from: from as u8,
|
||||
to: to as u8,
|
||||
count: count as u8,
|
||||
};
|
||||
if self.is_move_valid(from as u8, to as u8, count as u8) {
|
||||
out.push(instruction);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = config;
|
||||
out.into_iter()
|
||||
let state = self.clone();
|
||||
let config = config.clone();
|
||||
SpiderIter::new()
|
||||
.filter(move |&instruction| state.is_instruction_valid(&config, instruction))
|
||||
}
|
||||
|
||||
fn is_instruction_valid(&self, _config: &Self::Config, instruction: Self::Instruction) -> bool {
|
||||
match instruction {
|
||||
SpiderInstruction::Deal => self.is_deal_valid(),
|
||||
SpiderInstruction::Move { from, to, count } => self.is_move_valid(from, to, count),
|
||||
SpiderInstruction::Move(spider_move) => self.is_move_valid(spider_move),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,24 +573,24 @@ impl Game for SpiderGame {
|
||||
match instruction {
|
||||
SpiderInstruction::Deal => {
|
||||
stats.deals += 1;
|
||||
for index in 0..SPIDER_TABLEAUS {
|
||||
for tableau in SpiderTableau::ALL {
|
||||
match self.stock.pop() {
|
||||
Some(card) => self.tableaus[index].push(card),
|
||||
Some(card) => self.tableaus[tableau.index()].push(card),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
for index in 0..SPIDER_TABLEAUS {
|
||||
if self.sweep_completed_run(index) {
|
||||
for tableau in SpiderTableau::ALL {
|
||||
if self.sweep_completed_run(tableau) {
|
||||
self.completed_runs += 1;
|
||||
stats.runs_completed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
SpiderInstruction::Move { from, to, count } => {
|
||||
let (from, to, count) = (from as usize, to as usize, count as usize);
|
||||
let src_len = self.tableaus[from].face_up().len();
|
||||
let (cards, _flipped) = self.tableaus[from].take_range_flip_up(src_len - count..);
|
||||
self.tableaus[to].extend(cards);
|
||||
SpiderInstruction::Move(SpiderMove { from, run, to }) => {
|
||||
let src_len = self.tableaus[from.index()].face_up().len();
|
||||
let (cards, _flipped) =
|
||||
self.tableaus[from.index()].take_range_flip_up(src_len - run.len()..);
|
||||
self.tableaus[to.index()].extend(cards);
|
||||
if self.sweep_completed_run(to) {
|
||||
self.completed_runs += 1;
|
||||
stats.runs_completed += 1;
|
||||
@@ -455,7 +615,7 @@ impl Game for SpiderGame {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SpiderGameState {
|
||||
seed: u64,
|
||||
session: Session<SpiderGame>,
|
||||
session: Session<Spider>,
|
||||
}
|
||||
|
||||
impl SpiderGameState {
|
||||
@@ -478,7 +638,7 @@ impl SpiderGameState {
|
||||
};
|
||||
Self {
|
||||
seed,
|
||||
session: Session::new(SpiderGame::with_seed(seed, suits), config),
|
||||
session: Session::new(Spider::with_seed(seed, suits), config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,10 +653,18 @@ impl SpiderGameState {
|
||||
}
|
||||
|
||||
/// Current position (read-only).
|
||||
pub fn game(&self) -> &SpiderGame {
|
||||
pub fn game(&self) -> &Spider {
|
||||
self.session.state().state()
|
||||
}
|
||||
|
||||
/// The underlying upstream session, for solver access and replay —
|
||||
/// the same escape hatch [`crate::game_state::GameState::session`]
|
||||
/// provides (`session().solve()` honours the budgets configured in
|
||||
/// [`SessionConfig`]).
|
||||
pub const fn session(&self) -> &Session<Spider> {
|
||||
&self.session
|
||||
}
|
||||
|
||||
/// In-play score, clamped at 0 like the Klondike wrapper.
|
||||
pub fn score(&self) -> i32 {
|
||||
self.session
|
||||
@@ -563,7 +731,7 @@ impl SpiderGameState {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl SpiderGame {
|
||||
impl Spider {
|
||||
/// Builds an arbitrary position for tests: per-pile
|
||||
/// `(face_down, face_up)` card lists plus stock and completed-run
|
||||
/// count. No card-count invariants are enforced — stacked
|
||||
@@ -592,7 +760,7 @@ impl SpiderGame {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl SpiderGameState {
|
||||
/// Wraps an arbitrary position in a fresh session (empty history).
|
||||
pub fn from_test_game(game: SpiderGame, suits: SpiderSuits) -> Self {
|
||||
pub fn from_test_game(game: Spider, suits: SpiderSuits) -> Self {
|
||||
let config = SessionConfig {
|
||||
inner: SpiderConfig {
|
||||
suits,
|
||||
@@ -614,6 +782,8 @@ impl SpiderGameState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RunLength::*;
|
||||
use super::SpiderTableau::*;
|
||||
use super::*;
|
||||
|
||||
/// `Card::new` shorthand for stacked positions.
|
||||
@@ -621,6 +791,11 @@ mod tests {
|
||||
Card::new(Deck::Deck1, suit, rank)
|
||||
}
|
||||
|
||||
/// `SpiderMove` shorthand for rule tests.
|
||||
const fn mv(from: SpiderTableau, run: RunLength, to: SpiderTableau) -> SpiderMove {
|
||||
SpiderMove { from, run, to }
|
||||
}
|
||||
|
||||
/// K→A same-suit run, bottom (King) first — the face-up order a
|
||||
/// completed run occupies on a pile.
|
||||
fn full_run(suit: Suit) -> Vec<Card> {
|
||||
@@ -643,21 +818,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn opening_deal_shape_is_4x6_6x5_with_50_in_stock() {
|
||||
let game = SpiderGame::with_seed(42, SpiderSuits::Four);
|
||||
for index in 0..SPIDER_TABLEAUS {
|
||||
let game = Spider::with_seed(42, SpiderSuits::Four);
|
||||
for (index, tableau) in SpiderTableau::ALL.into_iter().enumerate() {
|
||||
let expected_down = if index < 4 { 5 } else { 4 };
|
||||
assert_eq!(game.tableau_face_down(index).len(), expected_down);
|
||||
assert_eq!(game.tableau_face_up(index).len(), 1, "one card face-up");
|
||||
assert_eq!(game.tableau_face_down_cards(tableau).len(), expected_down);
|
||||
assert_eq!(
|
||||
game.tableau_face_up_cards(tableau).len(),
|
||||
1,
|
||||
"one card face-up"
|
||||
);
|
||||
}
|
||||
assert_eq!(game.stock_len(), STOCK_SIZE);
|
||||
assert_eq!(game.stock().len(), STOCK_SIZE);
|
||||
assert_eq!(game.completed_runs(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_seed_same_deal_different_seed_different_deal() {
|
||||
let a = SpiderGame::with_seed(7, SpiderSuits::Four);
|
||||
let b = SpiderGame::with_seed(7, SpiderSuits::Four);
|
||||
let c = SpiderGame::with_seed(8, SpiderSuits::Four);
|
||||
let a = Spider::with_seed(7, SpiderSuits::Four);
|
||||
let b = Spider::with_seed(7, SpiderSuits::Four);
|
||||
let c = Spider::with_seed(8, SpiderSuits::Four);
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
}
|
||||
@@ -691,15 +870,21 @@ mod tests {
|
||||
let mut layout = empty_layout();
|
||||
layout[0].1 = vec![card(Suit::Hearts, Rank::Five)];
|
||||
layout[1].1 = vec![card(Suit::Spades, Rank::Six)];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(game.is_move_valid(0, 1, 1), "5♥ onto 6♠ is legal");
|
||||
assert!(!game.is_move_valid(1, 0, 1), "6♠ onto 5♥ is not");
|
||||
let game = Spider::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(
|
||||
game.is_move_valid(mv(Tableau1, Run1, Tableau2)),
|
||||
"5♥ onto 6♠ is legal"
|
||||
);
|
||||
assert!(
|
||||
!game.is_move_valid(mv(Tableau2, Run1, Tableau1)),
|
||||
"6♠ onto 5♥ is not"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_same_suit_runs_are_movable_as_a_group() {
|
||||
let mut layout = empty_layout();
|
||||
// Pile 0: 7♠ 6♠ (movable run of 2). Pile 1: 7♥ 6♠ (mixed).
|
||||
// Pile 1: 7♠ 6♠ (movable run of 2). Pile 2: 7♥ 6♠ (mixed).
|
||||
layout[0].1 = vec![
|
||||
card(Suit::Spades, Rank::Seven),
|
||||
card(Suit::Spades, Rank::Six),
|
||||
@@ -711,10 +896,19 @@ mod tests {
|
||||
// Destinations: 8♣ (for the pair), 7♦ (for a lone six).
|
||||
layout[2].1 = vec![card(Suit::Clubs, Rank::Eight)];
|
||||
layout[3].1 = vec![card(Suit::Diamonds, Rank::Seven)];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(game.is_move_valid(0, 2, 2), "same-suit pair moves");
|
||||
assert!(!game.is_move_valid(1, 2, 2), "mixed-suit pair does not");
|
||||
assert!(game.is_move_valid(1, 3, 1), "its top card alone does");
|
||||
let game = Spider::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(
|
||||
game.is_move_valid(mv(Tableau1, Run2, Tableau3)),
|
||||
"same-suit pair moves"
|
||||
);
|
||||
assert!(
|
||||
!game.is_move_valid(mv(Tableau2, Run2, Tableau3)),
|
||||
"mixed-suit pair does not"
|
||||
);
|
||||
assert!(
|
||||
game.is_move_valid(mv(Tableau2, Run1, Tableau4)),
|
||||
"its top card alone does"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -724,27 +918,40 @@ mod tests {
|
||||
card(Suit::Spades, Rank::Nine),
|
||||
card(Suit::Spades, Rank::Eight),
|
||||
];
|
||||
// Pile 1 deliberately left empty.
|
||||
// Pile 2 deliberately left empty.
|
||||
layout[2].1 = vec![card(Suit::Hearts, Rank::Nine)];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(game.is_move_valid(0, 1, 2), "run onto empty pile");
|
||||
assert!(game.is_move_valid(2, 1, 1), "single onto empty pile");
|
||||
let game = Spider::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(
|
||||
!game.is_move_valid(0, 2, 2),
|
||||
game.is_move_valid(mv(Tableau1, Run2, Tableau2)),
|
||||
"run onto empty pile"
|
||||
);
|
||||
assert!(
|
||||
game.is_move_valid(mv(Tableau3, Run1, Tableau2)),
|
||||
"single onto empty pile"
|
||||
);
|
||||
assert!(
|
||||
!game.is_move_valid(mv(Tableau1, Run2, Tableau3)),
|
||||
"9♠8♠ cannot land on 9♥ (needs a 10)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_moves_rejected_out_of_range_zero_count_self_move() {
|
||||
fn invalid_moves_rejected_self_move_and_overlong_run() {
|
||||
// Out-of-range piles and zero-card runs are unrepresentable in
|
||||
// `SpiderMove` — the enums only span legal values — so the only
|
||||
// structurally invalid shapes left are self-moves and runs
|
||||
// longer than the movable suffix.
|
||||
let mut layout = empty_layout();
|
||||
layout[0].1 = vec![card(Suit::Spades, Rank::Five)];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(!game.is_move_valid(0, 0, 1), "self-move");
|
||||
assert!(!game.is_move_valid(0, 1, 0), "zero count");
|
||||
assert!(!game.is_move_valid(0, 10, 1), "destination out of range");
|
||||
assert!(!game.is_move_valid(10, 0, 1), "source out of range");
|
||||
assert!(!game.is_move_valid(0, 1, 2), "count exceeds run");
|
||||
let game = Spider::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(
|
||||
!game.is_move_valid(mv(Tableau1, Run1, Tableau1)),
|
||||
"self-move"
|
||||
);
|
||||
assert!(
|
||||
!game.is_move_valid(mv(Tableau1, Run2, Tableau2)),
|
||||
"count exceeds run"
|
||||
);
|
||||
}
|
||||
|
||||
// -- dealing from stock -------------------------------------------------
|
||||
@@ -752,31 +959,35 @@ mod tests {
|
||||
#[test]
|
||||
fn deal_requires_stock_and_no_empty_pile() {
|
||||
let stock = vec![card(Suit::Spades, Rank::Ace); 10];
|
||||
let game = SpiderGame::from_test_layout(junk_layout(), stock.clone(), 0);
|
||||
let game = Spider::from_test_layout(junk_layout(), stock.clone(), 0);
|
||||
assert!(game.is_deal_valid());
|
||||
|
||||
let mut with_gap = junk_layout();
|
||||
with_gap[3].1.clear();
|
||||
let game = SpiderGame::from_test_layout(with_gap, stock, 0);
|
||||
let game = Spider::from_test_layout(with_gap, stock, 0);
|
||||
assert!(!game.is_deal_valid(), "empty pile blocks the deal");
|
||||
|
||||
let game = SpiderGame::from_test_layout(junk_layout(), Vec::new(), 0);
|
||||
let game = Spider::from_test_layout(junk_layout(), Vec::new(), 0);
|
||||
assert!(!game.is_deal_valid(), "empty stock blocks the deal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_puts_one_card_on_every_pile() {
|
||||
let mut state = SpiderGameState::new_with_suits(3, SpiderSuits::Two);
|
||||
let before: Vec<usize> = (0..SPIDER_TABLEAUS)
|
||||
.map(|i| state.game().tableau_face_up(i).len())
|
||||
let before: Vec<usize> = SpiderTableau::ALL
|
||||
.into_iter()
|
||||
.map(|tableau| state.game().tableau_face_up_cards(tableau).len())
|
||||
.collect();
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Deal)
|
||||
.expect("deal is legal on a fresh game");
|
||||
for (index, previous) in before.iter().enumerate() {
|
||||
assert_eq!(state.game().tableau_face_up(index).len(), previous + 1);
|
||||
for (tableau, previous) in SpiderTableau::ALL.into_iter().zip(before) {
|
||||
assert_eq!(
|
||||
state.game().tableau_face_up_cards(tableau).len(),
|
||||
previous + 1
|
||||
);
|
||||
}
|
||||
assert_eq!(state.game().stock_len(), STOCK_SIZE - SPIDER_TABLEAUS);
|
||||
assert_eq!(state.game().stock().len(), STOCK_SIZE - SPIDER_TABLEAUS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -787,7 +998,7 @@ mod tests {
|
||||
.apply_instruction(SpiderInstruction::Deal)
|
||||
.expect("five deals must all be legal on untouched piles");
|
||||
}
|
||||
assert_eq!(state.game().stock_len(), 0);
|
||||
assert_eq!(state.game().stock().len(), 0);
|
||||
assert!(matches!(
|
||||
state.apply_instruction(SpiderInstruction::Deal),
|
||||
Err(MoveError::RuleViolation(_))
|
||||
@@ -799,30 +1010,26 @@ mod tests {
|
||||
#[test]
|
||||
fn completing_a_run_removes_it_and_flips_the_card_beneath() {
|
||||
let mut layout = empty_layout();
|
||||
// Pile 0: one face-down card under K..2 of spades; the ace
|
||||
// arrives from pile 1.
|
||||
// Pile 1: one face-down card under K..2 of spades; the ace
|
||||
// arrives from pile 2.
|
||||
let mut run = full_run(Suit::Spades);
|
||||
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
|
||||
layout[0] = (vec![card(Suit::Hearts, Rank::Nine)], run);
|
||||
layout[1].1 = vec![ace];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
let game = Spider::from_test_layout(layout, Vec::new(), 0);
|
||||
let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
|
||||
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Move {
|
||||
from: 1,
|
||||
to: 0,
|
||||
count: 1,
|
||||
})
|
||||
.apply_instruction(SpiderInstruction::Move(mv(Tableau2, Run1, Tableau1)))
|
||||
.expect("ace onto two completes the run");
|
||||
|
||||
assert_eq!(state.game().completed_runs(), 1);
|
||||
assert_eq!(
|
||||
state.game().tableau_face_up(0),
|
||||
state.game().tableau_face_up_cards(Tableau1),
|
||||
&[card(Suit::Hearts, Rank::Nine)],
|
||||
"run removed and the buried card flipped face-up"
|
||||
);
|
||||
assert!(state.game().tableau_face_up(1).is_empty());
|
||||
assert!(state.game().tableau_face_up_cards(Tableau2).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -832,16 +1039,12 @@ mod tests {
|
||||
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
|
||||
layout[0].1 = run;
|
||||
layout[1].1 = vec![ace];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), TOTAL_RUNS - 1);
|
||||
let game = Spider::from_test_layout(layout, Vec::new(), TOTAL_RUNS - 1);
|
||||
let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
|
||||
|
||||
assert!(!state.is_won());
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Move {
|
||||
from: 1,
|
||||
to: 0,
|
||||
count: 1,
|
||||
})
|
||||
.apply_instruction(SpiderInstruction::Move(mv(Tableau2, Run1, Tableau1)))
|
||||
.expect("winning move is legal");
|
||||
assert!(state.is_won());
|
||||
assert!(matches!(
|
||||
@@ -882,11 +1085,7 @@ mod tests {
|
||||
#[test]
|
||||
fn rule_violation_surfaces_move_error() {
|
||||
let mut state = SpiderGameState::new_with_suits(9, SpiderSuits::One);
|
||||
let result = state.apply_instruction(SpiderInstruction::Move {
|
||||
from: 0,
|
||||
to: 0,
|
||||
count: 1,
|
||||
});
|
||||
let result = state.apply_instruction(SpiderInstruction::Move(mv(Tableau1, Run1, Tableau1)));
|
||||
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
|
||||
}
|
||||
|
||||
@@ -905,6 +1104,13 @@ mod tests {
|
||||
assert!(state.game().is_instruction_valid(&config, instruction));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_iter_walks_the_full_space_once() {
|
||||
// 10 sources × 13 run lengths × 10 destinations, plus Deal.
|
||||
let count = SpiderIter::new().count();
|
||||
assert_eq!(count, SPIDER_TABLEAUS * RUN_LEN * SPIDER_TABLEAUS + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -915,11 +1121,15 @@ mod proptests {
|
||||
/// Total cards across tableaus + stock + removed runs must always
|
||||
/// equal 104, and every generated instruction must validate — for
|
||||
/// any seed, difficulty, and random walk through legal moves.
|
||||
fn card_conservation(game: &SpiderGame) -> usize {
|
||||
let on_piles: usize = (0..SPIDER_TABLEAUS)
|
||||
.map(|i| game.tableau_face_up(i).len() + game.tableau_face_down(i).len())
|
||||
fn card_conservation(game: &Spider) -> usize {
|
||||
let on_piles: usize = SpiderTableau::ALL
|
||||
.into_iter()
|
||||
.map(|tableau| {
|
||||
game.tableau_face_up_cards(tableau).len()
|
||||
+ game.tableau_face_down_cards(tableau).len()
|
||||
})
|
||||
.sum();
|
||||
on_piles + game.stock_len() + usize::from(game.completed_runs()) * RUN_LEN
|
||||
on_piles + game.stock().len() + usize::from(game.completed_runs()) * RUN_LEN
|
||||
}
|
||||
|
||||
proptest! {
|
||||
|
||||
@@ -105,10 +105,9 @@ pub use stats::{StatsExt, StatsSnapshot};
|
||||
pub mod storage;
|
||||
pub use storage::{
|
||||
TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at,
|
||||
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats,
|
||||
load_stats_from, load_time_attack_session_from, load_time_attack_session_from_at,
|
||||
save_game_state_to, save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
|
||||
time_attack_session_path, time_attack_session_with_now,
|
||||
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats_from,
|
||||
load_time_attack_session_from, save_game_state_to, save_stats_to, save_time_attack_session_to,
|
||||
stats_file_path, time_attack_session_path,
|
||||
};
|
||||
|
||||
pub mod achievements;
|
||||
@@ -136,11 +135,9 @@ pub use difficulty_seeds::{DifficultySeeds, seeds_for};
|
||||
|
||||
pub mod settings;
|
||||
pub use settings::{
|
||||
AnimSpeed, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
|
||||
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
|
||||
TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP,
|
||||
TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry,
|
||||
load_settings_from, save_settings_to, settings_file_path,
|
||||
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
|
||||
TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry, load_settings_from,
|
||||
save_settings_to, settings_file_path,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
@@ -152,9 +149,7 @@ mod android_keystore;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod auth_tokens;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use auth_tokens::{
|
||||
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
|
||||
};
|
||||
pub use auth_tokens::{TokenError, delete_tokens, store_tokens};
|
||||
|
||||
pub mod sync_client;
|
||||
pub use sync_client::LocalOnlyProvider;
|
||||
|
||||
@@ -46,22 +46,6 @@ pub fn save_stats_to(path: &Path, stats: &StatsSnapshot) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load stats from the platform default path. Returns default if the path
|
||||
/// is unavailable or the file is missing/corrupt.
|
||||
pub fn load_stats() -> StatsSnapshot {
|
||||
stats_file_path()
|
||||
.map(|p| load_stats_from(&p))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Save stats to the platform default path. Returns an error if the platform
|
||||
/// data dir is unavailable or the write fails.
|
||||
pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
|
||||
let path = stats_file_path()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable"))?;
|
||||
save_stats_to(&path, stats)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-progress game state
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -245,18 +229,6 @@ pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience helper for callers that want to stamp a session with the
|
||||
/// current wall-clock time. Equivalent to constructing the struct
|
||||
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
|
||||
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
|
||||
let now = Utc::now().timestamp().max(0) as u64;
|
||||
TimeAttackSession {
|
||||
remaining_secs,
|
||||
wins,
|
||||
saved_at_unix_secs: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner helper: delete `*.tmp` entries inside `dir`.
|
||||
///
|
||||
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
||||
|
||||
@@ -252,10 +252,17 @@ fn advance_card_anims(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Keep the winit loop awake at full frame rate while slides (including
|
||||
// staggered deals still in their delay phase) are in flight — required
|
||||
// for Android's reactive_low_power focused_mode.
|
||||
if !anims.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
if anim.delay > 0.0 {
|
||||
@@ -558,10 +565,17 @@ fn drive_toast_display(
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut queue: ResMut<ToastQueue>,
|
||||
mut active: ResMut<ActiveToast>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Keep the loop ticking while a toast is displayed or queued so the
|
||||
// countdown advances and the despawn frame isn't held hostage by
|
||||
// Android's reactive_low_power wake ceiling.
|
||||
if active.entity.is_some() || !queue.0.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
|
||||
// Tick down the active toast timer.
|
||||
@@ -593,9 +607,9 @@ pub enum ToastVariant {
|
||||
/// Neutral system message — teal border. Default for `InfoToastEvent`,
|
||||
/// settings volume notifications, and the auto-complete announcement.
|
||||
Info,
|
||||
/// Caution / penalty — gold border. Currently unused by an in-engine
|
||||
/// event; kept so future warning-flavoured toasts have a slot.
|
||||
#[allow(dead_code)]
|
||||
/// Caution / penalty — gold border. Used by [`handle_warning_toast`]
|
||||
/// for `WarningToastEvent` messages (daily-challenge expiry, sync,
|
||||
/// theme-store, and leaderboard warnings).
|
||||
Warning,
|
||||
/// Failure / rejected action — pink border. Used by
|
||||
/// [`handle_move_rejected_toast`] for illegal-placement
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Sound-effect playback via `kira`.
|
||||
//!
|
||||
//! Loads five embedded WAVs (`include_bytes!`) at startup and plays them in
|
||||
//! response to gameplay events:
|
||||
//! Loads seven embedded WAVs (`include_bytes!`) at startup — six SFX plus
|
||||
//! the ambient loop — and plays them in response to gameplay events:
|
||||
//!
|
||||
//! | Event | Sound |
|
||||
//! |---|---|
|
||||
@@ -10,6 +10,7 @@
|
||||
//! | `MoveRejectedEvent` | `card_invalid.wav` |
|
||||
//! | `NewGameRequestEvent` | `card_deal.wav` |
|
||||
//! | `GameWonEvent` | `win_fanfare.wav` |
|
||||
//! | `FoundationCompletedEvent` | `foundation_complete.wav` |
|
||||
//!
|
||||
//! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very
|
||||
//! low volume (0.05 amplitude) routed through `music_track`.
|
||||
@@ -38,7 +39,7 @@ use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
|
||||
const RECYCLE_VOLUME: f64 = 0.5;
|
||||
|
||||
/// Volume amplitude for the ambient music loop placeholder.
|
||||
/// Volume amplitude for the ambient music loop.
|
||||
const AMBIENT_VOLUME: f64 = 0.05;
|
||||
|
||||
/// Converts a linear amplitude (0.0–1.0+) to the `Decibels` type used by
|
||||
@@ -101,7 +102,7 @@ pub struct MuteState {
|
||||
pub music_muted: bool,
|
||||
}
|
||||
|
||||
/// Plays sound effects and background music via `bevy_kira_audio`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
|
||||
/// Plays sound effects and background music via `kira`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
|
||||
pub struct AudioPlugin;
|
||||
|
||||
impl Plugin for AudioPlugin {
|
||||
|
||||
@@ -147,6 +147,7 @@ fn drive_auto_complete(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut moves: MessageWriter<MoveRequestEvent>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if !state.active {
|
||||
return;
|
||||
@@ -154,6 +155,10 @@ fn drive_auto_complete(
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Keepalive: the step-interval cooldown only advances on frames that
|
||||
// actually run, so keep the winit loop awake for the whole burst under
|
||||
// Android's reactive_low_power focused_mode.
|
||||
redraw.write(RequestRedraw);
|
||||
|
||||
state.cooldown -= time.delta_secs();
|
||||
if state.cooldown > 0.0 {
|
||||
|
||||
@@ -18,11 +18,6 @@
|
||||
//! The sine term is 0 at `t = 0` and `t = 1` and peaks at `t = 0.5`, so the
|
||||
//! card "floats up" in the middle of its travel and lands at its correct rest z.
|
||||
//!
|
||||
//! # Retargeting
|
||||
//!
|
||||
//! When a card is redirected mid-flight, call [`retarget_animation`]. It reads
|
||||
//! the current interpolated position so the card never snaps.
|
||||
//!
|
||||
//! # Coexistence with `CardAnim`
|
||||
//!
|
||||
//! `CardAnimation` and the legacy `CardAnim` can coexist in the same world but
|
||||
@@ -33,6 +28,7 @@
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::RequestRedraw;
|
||||
|
||||
use super::curves::{MotionCurve, sample_curve};
|
||||
use super::timing::compute_duration;
|
||||
@@ -122,8 +118,6 @@ impl CardAnimation {
|
||||
}
|
||||
|
||||
/// Returns the current interpolated XY position without advancing time.
|
||||
///
|
||||
/// Used by [`retarget_animation`] to read mid-flight position cleanly.
|
||||
pub fn current_xy(&self) -> Vec2 {
|
||||
if self.duration <= 0.0 {
|
||||
return self.end;
|
||||
@@ -134,90 +128,6 @@ impl CardAnimation {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retarget helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Redirects a card to a new destination without snapping or interrupting motion.
|
||||
///
|
||||
/// Reads the card's current interpolated position (from a live [`CardAnimation`]
|
||||
/// if present, or from `Transform` if stationary) and starts a fresh
|
||||
/// [`CardAnimation`] from that position. Duration is recalculated from the
|
||||
/// remaining distance so short paths stay quick.
|
||||
///
|
||||
/// # Velocity continuity
|
||||
///
|
||||
/// When a card is mid-flight, the new animation starts with a small positive
|
||||
/// `elapsed` offset (`carry`) derived from how far through the current animation
|
||||
/// the card is. This preserves a sense of forward momentum: the new curve does
|
||||
/// not restart from zero velocity, avoiding a visible "lurch" when the target
|
||||
/// changes rapidly.
|
||||
///
|
||||
/// The carry is deliberately small (≤ 10 % of the new duration) so that it
|
||||
/// never causes a visible position jump — the card's start position is still
|
||||
/// read from the current transform.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Inside a system that decides to move a card to a new target:
|
||||
/// let (entity, transform, anim) = cards.get(card_entity)?;
|
||||
/// retarget_animation(
|
||||
/// &mut commands,
|
||||
/// entity,
|
||||
/// anim, // Option<&CardAnimation>
|
||||
/// transform,
|
||||
/// Vec2::new(400.0, 200.0),
|
||||
/// resting_z,
|
||||
/// MotionCurve::SmoothSnap,
|
||||
/// );
|
||||
/// ```
|
||||
pub fn retarget_animation(
|
||||
commands: &mut Commands,
|
||||
entity: Entity,
|
||||
current_anim: Option<&CardAnimation>,
|
||||
transform: &Transform,
|
||||
new_end: Vec2,
|
||||
new_end_z: f32,
|
||||
curve: MotionCurve,
|
||||
) {
|
||||
let (current_xy, current_z, momentum_carry) = match current_anim {
|
||||
Some(anim) if anim.duration > 0.0 => {
|
||||
// Estimate how far into the current animation we are and carry
|
||||
// a small fraction of that progress into the new animation.
|
||||
// This avoids restarting from zero velocity and makes the motion
|
||||
// feel continuous when the target changes mid-flight.
|
||||
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
|
||||
// Cap at 10 % of the new animation so there's no visible jump.
|
||||
let carry = (t * 0.12).min(0.10);
|
||||
(anim.current_xy(), transform.translation.z, carry)
|
||||
}
|
||||
_ => (
|
||||
transform.translation.truncate(),
|
||||
transform.translation.z,
|
||||
0.0,
|
||||
),
|
||||
};
|
||||
|
||||
let distance = current_xy.distance(new_end);
|
||||
let duration = compute_duration(distance);
|
||||
|
||||
commands.entity(entity).insert(CardAnimation {
|
||||
start: current_xy,
|
||||
end: new_end,
|
||||
// Start slightly into the new animation to carry forward momentum.
|
||||
elapsed: momentum_carry * duration,
|
||||
duration,
|
||||
curve,
|
||||
delay: 0.0,
|
||||
start_z: current_z,
|
||||
end_z: new_end_z,
|
||||
z_lift: 8.0,
|
||||
scale_start: 1.0,
|
||||
scale_end: 1.0,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -232,10 +142,18 @@ pub(crate) fn advance_card_animations(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Keep the winit event loop awake while any animation (including one
|
||||
// still in its delay phase) needs per-frame ticks. Without this,
|
||||
// Android's reactive_low_power focused_mode only wakes at its 100 ms
|
||||
// ceiling and card slides render at ~10 fps.
|
||||
if !q.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
|
||||
for (entity, mut transform, mut anim) in &mut q {
|
||||
|
||||
@@ -31,28 +31,6 @@
|
||||
//! ));
|
||||
//! ```
|
||||
//!
|
||||
//! Retarget a card mid-flight:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use solitaire_engine::card_animation::retarget_animation;
|
||||
//!
|
||||
//! fn handle_drop(
|
||||
//! mut commands: Commands,
|
||||
//! q: Query<(Entity, &Transform, Option<&CardAnimation>), With<CardEntity>>,
|
||||
//! ) {
|
||||
//! let (entity, transform, anim) = q.get(card_entity).unwrap();
|
||||
//! retarget_animation(
|
||||
//! &mut commands,
|
||||
//! entity,
|
||||
//! anim,
|
||||
//! transform,
|
||||
//! new_target_xy,
|
||||
//! new_target_z,
|
||||
//! MotionCurve::SmoothSnap,
|
||||
//! );
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Win cascade with `Expressive` curve
|
||||
//!
|
||||
//! The existing `AnimationPlugin` drives the win cascade with `CardAnim`
|
||||
@@ -80,7 +58,7 @@ pub mod interaction;
|
||||
pub mod timing;
|
||||
pub mod tuning;
|
||||
|
||||
pub use animation::{CardAnimation, retarget_animation, win_scatter_targets};
|
||||
pub use animation::{CardAnimation, win_scatter_targets};
|
||||
pub use chain::AnimationChain;
|
||||
pub use curves::{MotionCurve, sample_curve};
|
||||
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
|
||||
@@ -307,6 +285,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test for the v0.40.0 Android animation-lag bug: commit
|
||||
/// 38e4c03 switched Android to `reactive_low_power` focused_mode on the
|
||||
/// premise that animation systems write `RequestRedraw` while active,
|
||||
/// but the writers were never added — card slides rendered at the 100 ms
|
||||
/// wake ceiling (~10 fps). Active animations MUST emit `RequestRedraw`
|
||||
/// every frame; an idle board must not.
|
||||
#[test]
|
||||
fn active_card_animation_requests_redraw() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(CardAnimationPlugin);
|
||||
|
||||
// Idle board: no redraw requests.
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<Messages<RequestRedraw>>().is_empty(),
|
||||
"no RequestRedraw expected while no animation is active"
|
||||
);
|
||||
|
||||
app.world_mut().spawn((
|
||||
Transform::from_translation(Vec3::ZERO),
|
||||
CardAnimation {
|
||||
start: Vec2::ZERO,
|
||||
end: Vec2::new(100.0, 0.0),
|
||||
elapsed: 0.0,
|
||||
duration: 1.0,
|
||||
curve: MotionCurve::Responsive,
|
||||
delay: 0.0,
|
||||
start_z: 0.0,
|
||||
end_z: 0.0,
|
||||
z_lift: 0.0,
|
||||
scale_start: 1.0,
|
||||
scale_end: 1.0,
|
||||
},
|
||||
));
|
||||
app.update();
|
||||
assert!(
|
||||
!app.world().resource::<Messages<RequestRedraw>>().is_empty(),
|
||||
"an active CardAnimation must write RequestRedraw each frame to \
|
||||
sustain the reactive render loop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_animation_instant_snaps_on_zero_duration() {
|
||||
let mut app = App::new();
|
||||
|
||||
@@ -300,15 +300,6 @@ pub struct ForfeitEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ForfeitRequestEvent;
|
||||
|
||||
/// Fired when the player clicks "Scan for new themes" in Settings.
|
||||
///
|
||||
/// Consumed by `handle_scan_themes` in `SettingsPlugin`, which scans
|
||||
/// `user_theme_dir()` for `.zip` files, calls `import_theme()` on each
|
||||
/// unrecognised archive, refreshes [`crate::theme::ThemeRegistry`], and
|
||||
/// fires [`InfoToastEvent`] messages to report results.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ScanThemesRequestEvent;
|
||||
|
||||
/// Fired when the player requests a hint (H key). Carries the source card ID
|
||||
/// and destination pile for visual highlighting.
|
||||
///
|
||||
|
||||
@@ -276,10 +276,16 @@ fn tick_shake_anim(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Sustain full-rate frames for the shake under Android's
|
||||
// reactive_low_power focused_mode.
|
||||
if !anims.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
anim.elapsed += dt;
|
||||
@@ -356,10 +362,16 @@ fn tick_settle_anim(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Sustain full-rate frames for the settle bounce under Android's
|
||||
// reactive_low_power focused_mode.
|
||||
if !anims.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
anim.elapsed += dt;
|
||||
@@ -581,10 +593,16 @@ fn tick_foundation_flourish(
|
||||
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
|
||||
Without<FoundationFlourish>,
|
||||
>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Sustain full-rate frames for the flourish under Android's
|
||||
// reactive_low_power focused_mode.
|
||||
if !card_anims.is_empty() || !marker_anims.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
|
||||
// Advance the King's scale pulse.
|
||||
|
||||
@@ -22,7 +22,7 @@ use std::collections::HashMap;
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::input::touch::{TouchInput, TouchPhase, Touches};
|
||||
use bevy::math::{Vec2, Vec3};
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
@@ -1806,10 +1806,5 @@ pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile)> {
|
||||
all_hints(game).into_iter().next()
|
||||
}
|
||||
|
||||
// `Vec3` is referenced only via the `DRAG_Z` constant; keep the import silenced
|
||||
// when the compiler can't see it used.
|
||||
#[allow(dead_code)]
|
||||
const _VEC3_REFERENCED: Option<Vec3> = None;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -84,8 +84,8 @@ pub use card_animation::{
|
||||
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
|
||||
DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer,
|
||||
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, WIN_CASCADE_INTERVAL_SECS,
|
||||
WinCascadePlugin, cascade_delay, compute_duration, micro_vary, retarget_animation,
|
||||
sample_curve, win_scatter_targets,
|
||||
WinCascadePlugin, cascade_delay, compute_duration, micro_vary, sample_curve,
|
||||
win_scatter_targets,
|
||||
};
|
||||
pub use card_plugin::{
|
||||
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::*;
|
||||
@@ -27,960 +25,6 @@ pub(crate) struct ReplayScrubKeyHold {
|
||||
pub(crate) right_held_secs: f32,
|
||||
}
|
||||
|
||||
/// Marker on the keybind-hint footer row at the bottom edge of the
|
||||
/// banner. Carries two `Text` children: a vim-style mode indicator
|
||||
/// (`▌ NORMAL │ replay`) on the left and the keybind hint
|
||||
/// (`[SPACE] pause/resume`) on the right. 1 px top border in
|
||||
/// [`BORDER_SUBTLE`] separates it from the notch-label row above.
|
||||
///
|
||||
/// Surfaces the existing Space-key accelerator visually so the
|
||||
/// UI-first contract from CLAUDE.md §3.3 (every player action has
|
||||
/// a visible UI control) holds for keyboard accelerators too.
|
||||
/// Future commits that wire ESC for stop or ← / → for scrub will
|
||||
/// extend the right-hand text in lockstep — the footer always
|
||||
/// reflects what's actually wired, never aspirational.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayKeybindFooter;
|
||||
|
||||
/// Marker on the bottom-edge **Move Log** panel — a separate root
|
||||
/// UI entity (not a child of the banner) that sits anchored to the
|
||||
/// viewport's bottom edge. Carries a header (`▌ MOVE LOG · N/M`)
|
||||
/// plus a row showing the most-recently-applied move.
|
||||
///
|
||||
/// Spawned by `spawn_overlay` alongside the banner and the
|
||||
/// floating progress chip; despawned by `react_to_state_change`
|
||||
/// on the same `Playing → Inactive` transition. Same lifecycle
|
||||
/// pattern as `ReplayFloatingProgressChip` — a sibling root, not
|
||||
/// a banner child, because it lives at a different screen anchor.
|
||||
///
|
||||
/// First slice of the move-log mockup at
|
||||
/// `docs/ui-mockups/replay-overlay-mobile.html` § "Move Log Card".
|
||||
/// Subsequent commits add prev/next rows and scrolling.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayMoveLogPanel;
|
||||
|
||||
/// Marker on the move-log panel's header `Text`. Carries
|
||||
/// `▌ MOVE LOG · N/M` while a replay is playing; the
|
||||
/// `update_move_log_header` system repaints it as the cursor
|
||||
/// advances.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayMoveLogHeader;
|
||||
|
||||
/// Marker on the move-log panel's active-row `Text`. Carries the
|
||||
/// most-recently-applied move's text (`47 │ waste → tableau 5`)
|
||||
/// when `cursor > 0`; empty when no moves have been applied yet
|
||||
/// (initial spawn) or in `Completed`/`Inactive` states. The
|
||||
/// `update_move_log_active_row` system repaints it as the cursor
|
||||
/// advances.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayMoveLogActiveRow;
|
||||
|
||||
/// Marker on a "previous move" row above the active row.
|
||||
/// `offset` is the 1-based distance backwards from the active
|
||||
/// row: `offset = 1` is the move applied just before the active
|
||||
/// one (e.g. cursor=47 → row reads "46 │ ..."), `offset = 2` is
|
||||
/// the one before that, and so on. Up to [`MOVE_LOG_PREV_ROWS`]
|
||||
/// rows render above the active row.
|
||||
///
|
||||
/// Empty text when there isn't enough history (`offset >= cursor`,
|
||||
/// e.g. cursor=1 has no prev rows; cursor=2 has only the
|
||||
/// `offset = 1` row populated).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayMoveLogPrevRow {
|
||||
/// Distance backwards from the active row (1-based).
|
||||
pub offset: u8,
|
||||
}
|
||||
|
||||
/// Marker on a "next move" row below the active row. `offset`
|
||||
/// is the 1-based distance forward from the active row:
|
||||
/// `offset = 1` is the move that will apply next
|
||||
/// (`replay.moves[cursor]`, displayed as `cursor + 1`),
|
||||
/// `offset = 2` is the one after that, and so on. Up to
|
||||
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
|
||||
///
|
||||
/// Empty text when there isn't enough remaining replay
|
||||
/// (`cursor + offset - 1 >= moves.len()`, e.g. cursor=99 of
|
||||
/// a 100-move replay shows offset 1 but offset 2 stays empty).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayMoveLogNextRow {
|
||||
/// Distance forward from the active row (1-based).
|
||||
pub offset: u8,
|
||||
}
|
||||
|
||||
/// Marker added to every top-level entity spawned by [`spawn_overlay`].
|
||||
/// `react_to_state_change` uses a single `Query<Entity, With<DespawnWithReplay>>`
|
||||
/// to despawn all of them, rather than keeping a separate query per
|
||||
/// entity type. Future sibling overlay surfaces just need this marker
|
||||
/// at spawn time — no changes to the despawn logic required.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct DespawnWithReplay;
|
||||
|
||||
/// Marker on the mini-tableau preview panel root. A right-edge-anchored
|
||||
/// panel that shows a compact summary of the live game state during
|
||||
/// replay: the four foundation tops and the stock / waste heads.
|
||||
/// Spawned as a sibling root entity (same lifecycle pattern as
|
||||
/// [`ReplayOverlayMoveLogPanel`]) at `right: 0`, `top: MINI_TABLEAU_TOP_OFFSET`.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayMiniTableauPanel;
|
||||
|
||||
/// Marker on the foundations row `Text` inside the mini-tableau panel.
|
||||
/// Carries `F: A♠ 7♥ 5♦ K♣` (or `--` for empty slots); repainted by
|
||||
/// `update_mini_tableau` whenever [`GameStateResource`] changes.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayMiniTableauFoundations;
|
||||
|
||||
/// Marker on the stock/waste row `Text` inside the mini-tableau panel.
|
||||
/// Carries `STK:14 WST:7♥`; repainted by `update_mini_tableau` whenever
|
||||
/// [`GameStateResource`] changes.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayMiniTableauStockWaste;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bevy plugin that registers every system needed to drive the replay
|
||||
/// overlay's lifecycle.
|
||||
///
|
||||
/// The plugin is independent of [`crate::replay_playback::ReplayPlaybackPlugin`]
|
||||
/// — it only reads the shared `ReplayPlaybackState` resource. Tests insert
|
||||
/// the resource manually and exercise the overlay in isolation.
|
||||
pub struct ReplayOverlayPlugin;
|
||||
|
||||
impl Plugin for ReplayOverlayPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// The systems are ordered so that, on a single frame:
|
||||
// 1. The state-watcher spawns or despawns the overlay if the
|
||||
// `ReplayPlaybackState` resource changed.
|
||||
// 2. The completion-text update swaps the banner label when the
|
||||
// state is `Completed`.
|
||||
// 3. The progress-text update writes the latest "Move N of M".
|
||||
// 4. The Stop-button click handler reads `Interaction::Pressed`
|
||||
// and calls `stop_replay_playback` (which mutates the state).
|
||||
// Putting Stop last means a click in frame N is observed by
|
||||
// `react_to_state_change` in frame N+1, which then despawns the
|
||||
// overlay in response — a clean state-driven loop.
|
||||
// Step-button handler dispatches into the same canonical move
|
||||
// / draw events that the tick loop fires. Register them
|
||||
// defensively here so this plugin can run under
|
||||
// `MinimalPlugins` without the playback plugin attached;
|
||||
// `add_message` is idempotent so the duplicate registration
|
||||
// in production (alongside `replay_playback`) is harmless.
|
||||
app.init_resource::<ReplayScrubKeyHold>()
|
||||
.add_message::<MoveRequestEvent>()
|
||||
.add_message::<DrawRequestEvent>()
|
||||
.add_message::<UndoRequestEvent>()
|
||||
.add_message::<StateChangedEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
react_to_state_change,
|
||||
update_banner_label,
|
||||
update_progress_text,
|
||||
update_floating_progress_chip,
|
||||
update_scrub_fill,
|
||||
update_move_log_header,
|
||||
update_move_log_active_row,
|
||||
update_move_log_prev_rows,
|
||||
update_move_log_next_rows,
|
||||
update_mini_tableau_foundations,
|
||||
update_mini_tableau_stock_waste,
|
||||
update_pause_button_label,
|
||||
handle_pause_button,
|
||||
handle_step_button,
|
||||
handle_pause_keyboard,
|
||||
handle_stop_keyboard,
|
||||
handle_arrow_keyboard,
|
||||
handle_stop_button,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Reads [`ReplayPlaybackState`] every time the resource changes and either
|
||||
/// spawns or despawns the overlay accordingly. Treats the resource as the
|
||||
/// single source of truth — the spawn / despawn decision is derived from
|
||||
/// `is_playing() || is_completed()` rather than tracking previous-state
|
||||
/// transitions explicitly, which keeps the system stateless.
|
||||
pub(crate) fn react_to_state_change(
|
||||
mut commands: Commands,
|
||||
state: Res<ReplayPlaybackState>,
|
||||
roots: Query<Entity, With<ReplayOverlayRoot>>,
|
||||
despawnable: Query<Entity, With<DespawnWithReplay>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
|
||||
let should_be_visible = state.is_playing() || state.is_completed();
|
||||
let already_spawned = roots.iter().next().is_some();
|
||||
|
||||
if should_be_visible && !already_spawned {
|
||||
spawn_overlay(&mut commands, font_res.as_deref(), &state);
|
||||
} else if !should_be_visible && already_spawned {
|
||||
// Despawn all sibling root entities in one loop — every entity
|
||||
// spawned by `spawn_overlay` carries `DespawnWithReplay` for
|
||||
// exactly this purpose.
|
||||
for entity in &despawnable {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
// The `should_be_visible && already_spawned` branch is a no-op here —
|
||||
// the per-frame text update systems below repaint the banner label
|
||||
// and progress readout in place without a respawn.
|
||||
}
|
||||
|
||||
/// Spawns the banner — a flex-row Node anchored to the top edge of the
|
||||
/// window with three children: the "▌ replay" / "▌ replay complete" label,
|
||||
/// the centred progress text, and the right-aligned Stop button.
|
||||
pub(crate) fn spawn_overlay(
|
||||
commands: &mut Commands,
|
||||
font_res: Option<&FontResource>,
|
||||
state: &ReplayPlaybackState,
|
||||
) {
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
// Clone for the floating chip spawn that runs *after* the
|
||||
// banner's `.with_children(|banner| { ... })` closure consumes
|
||||
// the original `font_handle`. Cheap — Bevy's `Handle<Font>` is
|
||||
// `Arc`-backed, the clone bumps a refcount.
|
||||
let font_handle_for_floating = font_handle.clone();
|
||||
// Second clone for the scrub-bar label row and keybind footer
|
||||
// inside the outer banner closure. The inner top-row closure
|
||||
// consumes the original `font_handle` for the progress-chip
|
||||
// text, so by the time the outer closure reaches the
|
||||
// label-row / footer spawns the original is gone.
|
||||
// `font_handle_for_labels` is `.clone()`'d (never moved) inside
|
||||
// the labels closure, so it's still alive for the footer
|
||||
// spawn afterwards — single shared clone covers both.
|
||||
let font_handle_for_labels = font_handle.clone();
|
||||
// Third clone for the move-log panel — a separate root
|
||||
// entity spawned after the banner closure closes. Mirrors the
|
||||
// floating-chip clone reasoning.
|
||||
let font_handle_for_move_log = font_handle.clone();
|
||||
// Fourth clone for the mini-tableau preview panel.
|
||||
let font_handle_for_mini_tableau = font_handle.clone();
|
||||
|
||||
let banner_label = if state.is_completed() {
|
||||
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
|
||||
} else {
|
||||
"\u{258C} replay" // ▌
|
||||
};
|
||||
let progress_label = format_progress(state);
|
||||
|
||||
// Tableau dim layer — full-screen scrim at z = Z_REPLAY_DIM (= 54).
|
||||
// Spawned first so it sits behind the banner (z=55) and move-log (z=55)
|
||||
// in the UI stacking context. World-space sprites (cards, badges) are
|
||||
// always below any UI node, so the dim layer darkens the entire
|
||||
// gameplay scene without needing to touch card_plugin. No Interaction
|
||||
// component — purely visual.
|
||||
commands.spawn((
|
||||
ReplayTableauDimLayer,
|
||||
DespawnWithReplay,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
top: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, TABLEAU_DIM_ALPHA)),
|
||||
ZIndex(Z_REPLAY_DIM),
|
||||
GlobalZIndex(Z_REPLAY_DIM),
|
||||
));
|
||||
|
||||
let banner_bg = Color::srgba(
|
||||
BG_ELEVATED_HI.to_srgba().red,
|
||||
BG_ELEVATED_HI.to_srgba().green,
|
||||
BG_ELEVATED_HI.to_srgba().blue,
|
||||
BANNER_ALPHA,
|
||||
);
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
ReplayOverlayRoot,
|
||||
DespawnWithReplay,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
top: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(BANNER_HEIGHT),
|
||||
// Column outer so the content row sits above the 1px
|
||||
// scrub bar at the bottom edge.
|
||||
flex_direction: FlexDirection::Column,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(banner_bg),
|
||||
// Pin the banner to its z layer in both the local and the
|
||||
// global stacking context — `GlobalZIndex` matters because
|
||||
// the overlay is a top-level Node (no parent), and Bevy 0.18
|
||||
// has historically had subtle stacking-context drift here.
|
||||
ZIndex(Z_REPLAY_OVERLAY),
|
||||
GlobalZIndex(Z_REPLAY_OVERLAY),
|
||||
))
|
||||
.with_children(|banner| {
|
||||
// Top row: the existing content (label / progress / Stop).
|
||||
banner
|
||||
.spawn(Node {
|
||||
flex_grow: 1.0,
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
justify_content: JustifyContent::SpaceBetween,
|
||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
||||
column_gap: VAL_SPACE_4,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
// Left: column with the accent "▌ replay" headline
|
||||
// above and a small `GAME #YYYY-DDD` caption below.
|
||||
// The caption mirrors the mockup's right-anchored
|
||||
// game identifier but stays visually grouped with
|
||||
// the headline so the two pieces of "this is a
|
||||
// replay of game X" read as a single unit.
|
||||
row.spawn(Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
align_items: AlignItems::FlexStart,
|
||||
row_gap: Val::Px(2.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|left| {
|
||||
left.spawn((
|
||||
ReplayOverlayBannerText,
|
||||
Text::new(banner_label),
|
||||
TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_HEADLINE,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
left.spawn((
|
||||
ReplayOverlayGameCaption,
|
||||
Text::new(format_game_caption(state).unwrap_or_default()),
|
||||
TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
|
||||
// Centre: progress readout, wrapped in a 1 px
|
||||
// ACCENT_PRIMARY-bordered chip so it reads as a
|
||||
// discrete callout rather than free-floating
|
||||
// text. No fill — the Terminal aesthetic gets
|
||||
// depth from borders + tonal layering, not
|
||||
// shadows. The marker stays on the inner Text so
|
||||
// `update_progress_text` keeps working unchanged.
|
||||
row.spawn((
|
||||
Node {
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||
..default()
|
||||
},
|
||||
BorderColor::all(ACCENT_PRIMARY),
|
||||
))
|
||||
.with_children(|chip| {
|
||||
chip.spawn((
|
||||
ReplayOverlayProgressText,
|
||||
Text::new(progress_label),
|
||||
TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
});
|
||||
|
||||
// Right: Stop button. Tertiary variant — the
|
||||
// action is available but not the loudest element
|
||||
// in the banner; the "Replay" primary accent owns
|
||||
// that slot. `spawn_modal_button` gives us hover /
|
||||
// press paint and focus rings for free via the
|
||||
// existing `UiModalPlugin` paint system.
|
||||
row.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
})
|
||||
.with_children(|wrap| {
|
||||
// Pause / Resume label is set from the current
|
||||
// state so a freshly-spawned overlay (which
|
||||
// currently always starts unpaused) reads
|
||||
// "Pause". `update_pause_button_label`
|
||||
// repaints it whenever the state changes.
|
||||
spawn_modal_button(
|
||||
wrap,
|
||||
ReplayPauseButton,
|
||||
pause_button_label(state),
|
||||
None,
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
wrap,
|
||||
ReplayStepButton,
|
||||
"Step",
|
||||
None,
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
wrap,
|
||||
ReplayStopButton,
|
||||
"Stop",
|
||||
None,
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Bottom edge: 1px-tall scrub bar. Track in `BORDER_SUBTLE`,
|
||||
// fill in `ACCENT_PRIMARY`. The fill width is rewritten by
|
||||
// [`update_scrub_fill`] every tick the cursor advances.
|
||||
// Initial fill width matches the spawn-time progress so the
|
||||
// first-frame paint already reflects state instead of
|
||||
// popping from 0 → cursor on the first tick.
|
||||
let initial_scrub_pct = scrub_pct(state);
|
||||
let win_pct = win_move_marker_pct(state);
|
||||
banner
|
||||
.spawn((
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(1.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BORDER_SUBTLE),
|
||||
// HC marker: bumps the 1 px track from #505050
|
||||
// → #a0a0a0 under high-contrast mode. The track
|
||||
// paints via BackgroundColor (it's a 1 px Node,
|
||||
// not a border on a wider container) so the
|
||||
// BorderColor-targeting HighContrastBorder marker
|
||||
// doesn't apply — HighContrastBackground is the
|
||||
// parallel primitive for this case.
|
||||
HighContrastBackground::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|track| {
|
||||
track.spawn((
|
||||
ReplayOverlayScrubFill,
|
||||
Node {
|
||||
width: Val::Percent(initial_scrub_pct),
|
||||
height: Val::Percent(100.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(ACCENT_PRIMARY),
|
||||
));
|
||||
// WIN MOVE marker — small green tick anchored at
|
||||
// `win_move_index / total`. Spawned only when the
|
||||
// active replay carries the field; older replays
|
||||
// pre-dating `win_move_index` simply don't get a
|
||||
// marker. Centered vertically on the 1px track via
|
||||
// a 3px-tall node offset 1px above the track top so
|
||||
// 1px sits above and 1px below the track line.
|
||||
if let Some(pct) = win_pct {
|
||||
track.spawn((
|
||||
ReplayOverlayWinMoveMarker,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(pct),
|
||||
top: Val::Px(-1.0),
|
||||
width: Val::Px(2.0),
|
||||
height: Val::Px(3.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(STATE_SUCCESS),
|
||||
// HC bump: lime → brighter lime so the win
|
||||
// marker reads clearly above the bumped
|
||||
// notch ticks (BORDER_SUBTLE_HC gray) under
|
||||
// high-contrast mode.
|
||||
HighContrastBackground::with_hc(STATE_SUCCESS, STATE_SUCCESS_HC),
|
||||
));
|
||||
}
|
||||
// Fixed quarter-mark notches: five 1px vertical
|
||||
// ticks at 0 / 25 / 50 / 75 / 100 % that give the
|
||||
// player visual anchor points without needing to
|
||||
// mentally bisect the bar. Painted in
|
||||
// BORDER_SUBTLE — same colour as the unfilled
|
||||
// track — so visibility comes from extending past
|
||||
// the 1px track height (5px tall, anchored 2px
|
||||
// above the track top) rather than colour
|
||||
// contrast. Spawned *after* the WIN MOVE marker
|
||||
// so a notch and the marker landing on the same
|
||||
// percentage paint the marker on top.
|
||||
for pct in scrub_notch_positions() {
|
||||
track.spawn((
|
||||
ReplayOverlayScrubNotch,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(pct),
|
||||
top: Val::Px(-2.0),
|
||||
width: Val::Px(1.0),
|
||||
height: Val::Px(5.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BORDER_SUBTLE),
|
||||
// Same HC-paint reasoning as the track
|
||||
// above: 5 px tall × 1 px wide tick mark
|
||||
// paints via BackgroundColor, so
|
||||
// HighContrastBackground (not -Border) is
|
||||
// the right marker.
|
||||
HighContrastBackground::with_default(BORDER_SUBTLE),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
// Third banner row: percentage labels (`0%` / `25%` /
|
||||
// `50%` / `75%` / `100%`) under each scrub-bar notch.
|
||||
// Sibling of (not child of) the 1px track because labels
|
||||
// need their own vertical real estate (TYPE_CAPTION text
|
||||
// doesn't fit inside a 1px container). Position math:
|
||||
// track Node has `Val::Percent(p)` referencing the
|
||||
// banner's full width; this label row also has the
|
||||
// banner's full width, so labels at the same
|
||||
// percentages line up vertically with their notches.
|
||||
let labels = scrub_notch_labels();
|
||||
let positions = scrub_notch_positions();
|
||||
banner
|
||||
.spawn(Node {
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(SCRUB_LABEL_ROW_HEIGHT),
|
||||
position_type: PositionType::Relative,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
for (i, (label, pct)) in labels.iter().zip(positions.iter()).enumerate() {
|
||||
// Endpoints flush to the row's edges; middle
|
||||
// three labels use the `translateX(-50%)`
|
||||
// pattern for Bevy 0.18 UI: a fixed-width
|
||||
// container is placed at `left: Percent(pct)`
|
||||
// then shifted left by half its own width via
|
||||
// `margin.left: Px(-SCRUB_LABEL_CENTER_WIDTH/2)`.
|
||||
// `Justify::Center` renders the text centred
|
||||
// within the container so the text's visual
|
||||
// centre coincides with the notch line.
|
||||
let (node, justify) = if i == 0 {
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(2.0),
|
||||
left: Val::Px(0.0),
|
||||
..default()
|
||||
},
|
||||
Justify::Left,
|
||||
)
|
||||
} else if i == labels.len() - 1 {
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(2.0),
|
||||
right: Val::Px(0.0),
|
||||
..default()
|
||||
},
|
||||
Justify::Right,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(2.0),
|
||||
left: Val::Percent(*pct),
|
||||
width: Val::Px(SCRUB_LABEL_CENTER_WIDTH),
|
||||
margin: UiRect {
|
||||
left: Val::Px(-SCRUB_LABEL_CENTER_WIDTH / 2.0),
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
},
|
||||
Justify::Center,
|
||||
)
|
||||
};
|
||||
row.spawn((
|
||||
ReplayOverlayScrubNotchLabel,
|
||||
node,
|
||||
Text::new(*label),
|
||||
TextLayout::new_with_justify(justify),
|
||||
TextFont {
|
||||
font: font_handle_for_labels.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
// TEXT_SECONDARY keeps the subdued visual
|
||||
// hierarchy (caption, not headline) while
|
||||
// staying readable against BG_ELEVATED_HI.
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
// Fourth banner row: keybind-hint footer. Vim-style
|
||||
// mode line on the left (`▌ NORMAL │ replay`), keybind
|
||||
// hint on the right (`[SPACE] pause/resume`), 1px top
|
||||
// border in BORDER_SUBTLE separating it from the
|
||||
// labels row above. Surfaces the existing Space
|
||||
// accelerator visually so CLAUDE.md §3.3's UI-first
|
||||
// contract holds for keyboard accelerators too.
|
||||
banner
|
||||
.spawn((
|
||||
ReplayOverlayKeybindFooter,
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(KEYBIND_FOOTER_HEIGHT),
|
||||
flex_direction: FlexDirection::Row,
|
||||
justify_content: JustifyContent::SpaceBetween,
|
||||
align_items: AlignItems::Center,
|
||||
padding: UiRect::horizontal(VAL_SPACE_4),
|
||||
border: UiRect::top(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
// Marker for `apply_high_contrast_borders`: bumps
|
||||
// the 1 px top border from BORDER_SUBTLE (#505050)
|
||||
// to BORDER_SUBTLE_HC (#a0a0a0) when
|
||||
// `Settings::high_contrast_mode` is on. Without
|
||||
// this the footer reads as floating loose under
|
||||
// HC because the border that visually anchors it
|
||||
// to the labels row above is near-invisible.
|
||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|footer| {
|
||||
footer.spawn((
|
||||
Text::new(keybind_footer_mode_text()),
|
||||
TextFont {
|
||||
font: font_handle_for_labels.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
if SHOW_KEYBOARD_ACCELERATORS {
|
||||
footer.spawn((
|
||||
Text::new(keybind_footer_hint_text()),
|
||||
TextFont {
|
||||
font: font_handle_for_labels.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Floating progress chip — a 2D world-space `Text2d` rendered
|
||||
// above the destination pile of the most-recently-applied move.
|
||||
// Sibling of (not child of) the banner overlay because it lives
|
||||
// in world-space coordinates, not the UI tree. Spawned hidden;
|
||||
// `update_floating_progress_chip` shows + positions it on the
|
||||
// first frame the cursor advances past 0. Lifecycle matches
|
||||
// the banner overlay — `react_to_state_change` despawns both
|
||||
// when the replay state transitions back to `Inactive`.
|
||||
commands.spawn((
|
||||
ReplayFloatingProgressChip,
|
||||
DespawnWithReplay,
|
||||
Text2d::new(format_progress(state)),
|
||||
TextFont {
|
||||
font: font_handle_for_floating,
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_PRIMARY),
|
||||
// High Z keeps the chip above every card stack
|
||||
// (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular cards
|
||||
// stack to the low double digits at most).
|
||||
Transform::from_xyz(0.0, 0.0, 100.0),
|
||||
Visibility::Hidden,
|
||||
));
|
||||
|
||||
// Move-log panel — a separate root UI entity anchored to the
|
||||
// viewport's bottom edge. Carries a `▌ MOVE LOG · N/M` header
|
||||
// plus a row showing the most-recently-applied move.
|
||||
// Sibling-of-banner pattern (not a banner child) because the
|
||||
// panel lives at a different screen anchor and has its own
|
||||
// spawn/despawn lifecycle synced via `react_to_state_change`.
|
||||
let banner_bg = Color::srgba(
|
||||
BG_ELEVATED_HI.to_srgba().red,
|
||||
BG_ELEVATED_HI.to_srgba().green,
|
||||
BG_ELEVATED_HI.to_srgba().blue,
|
||||
BANNER_ALPHA,
|
||||
);
|
||||
commands
|
||||
.spawn((
|
||||
ReplayOverlayMoveLogPanel,
|
||||
DespawnWithReplay,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
bottom: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(MOVE_LOG_PANEL_HEIGHT),
|
||||
flex_direction: FlexDirection::Column,
|
||||
align_items: AlignItems::FlexStart,
|
||||
justify_content: JustifyContent::Center,
|
||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
||||
row_gap: VAL_SPACE_1,
|
||||
border: UiRect::top(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(banner_bg),
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
// Same z-stack rationale as the banner — above gameplay,
|
||||
// below modals.
|
||||
ZIndex(Z_REPLAY_OVERLAY),
|
||||
GlobalZIndex(Z_REPLAY_OVERLAY),
|
||||
// HC marker so the top border bumps under HC mode.
|
||||
// Without it the panel reads as floating loose because
|
||||
// the border that anchors it to the gameplay area above
|
||||
// is near-invisible at #505050.
|
||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|panel| {
|
||||
// Header row: `▌ MOVE LOG · N/M` in ACCENT_PRIMARY for
|
||||
// the cursor-block prefix consistency with the banner
|
||||
// headline.
|
||||
panel.spawn((
|
||||
ReplayOverlayMoveLogHeader,
|
||||
Text::new(format_move_log_header(state)),
|
||||
TextFont {
|
||||
font: font_handle_for_move_log.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
// Prev rows — render above the active row in display
|
||||
// order (oldest first), so the active row sits at the
|
||||
// bottom of the visible window. Spawn from
|
||||
// MOVE_LOG_PREV_ROWS down to 1 (offset 2, then 1) so
|
||||
// the highest-offset (oldest) row is topmost in the
|
||||
// panel's flex column. Each carries
|
||||
// ReplayOverlayMoveLogPrevRow { offset } — the
|
||||
// per-frame system reads `offset` and recomputes the
|
||||
// text on cursor advance. Painted in TEXT_SECONDARY
|
||||
// so the active row stands out from context rows.
|
||||
for offset in (1..=MOVE_LOG_PREV_ROWS as u8).rev() {
|
||||
panel.spawn((
|
||||
ReplayOverlayMoveLogPrevRow { offset },
|
||||
Text::new(format_kth_recent_row(state, offset as usize + 1)),
|
||||
TextFont {
|
||||
font: font_handle_for_move_log.clone(),
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
// Active move row. Wrapped in a Node with an
|
||||
// ACCENT_PRIMARY background so the row reads as
|
||||
// "current focus" — the player can scan vertically
|
||||
// and the highlighted row is the move that just
|
||||
// applied. Empty text at spawn time when cursor=0;
|
||||
// the per-frame update system populates it as the
|
||||
// cursor advances. Text colour is TEXT_PRIMARY_HC
|
||||
// (near-white) for contrast against the brick-red
|
||||
// background — same trick as the modal-button
|
||||
// primary-variant paint.
|
||||
panel
|
||||
.spawn((
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(ACCENT_PRIMARY),
|
||||
))
|
||||
.with_children(|active| {
|
||||
active.spawn((
|
||||
ReplayOverlayMoveLogActiveRow,
|
||||
Text::new(format_active_move_row(state)),
|
||||
TextFont {
|
||||
font: font_handle_for_move_log.clone(),
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_PRIMARY_HC),
|
||||
));
|
||||
});
|
||||
// Next rows — render below the active row in display
|
||||
// order (offset 1 directly below active, then offset
|
||||
// 2). Same TEXT_SECONDARY de-emphasis as prev rows so
|
||||
// the active row stays the focal point. Empty text
|
||||
// late in the replay (when cursor + offset exceeds
|
||||
// moves.len()) — the panel under-fills gracefully.
|
||||
for offset in 1..=MOVE_LOG_NEXT_ROWS as u8 {
|
||||
panel.spawn((
|
||||
ReplayOverlayMoveLogNextRow { offset },
|
||||
Text::new(format_kth_next_row(state, offset as usize)),
|
||||
TextFont {
|
||||
font: font_handle_for_move_log.clone(),
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
// Mini-tableau preview panel — right-edge anchor, just below the banner.
|
||||
// Compact two-row readout: foundation tops then stock/waste head.
|
||||
// Sibling-of-banner pattern (separate root entity, own spawn/despawn).
|
||||
let banner_bg = Color::srgba(
|
||||
BG_ELEVATED_HI.to_srgba().red,
|
||||
BG_ELEVATED_HI.to_srgba().green,
|
||||
BG_ELEVATED_HI.to_srgba().blue,
|
||||
BANNER_ALPHA,
|
||||
);
|
||||
commands
|
||||
.spawn((
|
||||
ReplayMiniTableauPanel,
|
||||
DespawnWithReplay,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: Val::Px(0.0),
|
||||
top: Val::Px(MINI_TABLEAU_TOP_OFFSET),
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
||||
flex_direction: FlexDirection::Column,
|
||||
align_items: AlignItems::FlexStart,
|
||||
row_gap: VAL_SPACE_1,
|
||||
border: UiRect::left(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(banner_bg),
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
ZIndex(Z_REPLAY_OVERLAY),
|
||||
GlobalZIndex(Z_REPLAY_OVERLAY),
|
||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|panel| {
|
||||
panel.spawn((
|
||||
Text::new("\u{258C} BOARD"),
|
||||
TextFont {
|
||||
font: font_handle_for_mini_tableau.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
panel.spawn((
|
||||
ReplayMiniTableauFoundations,
|
||||
Text::new("F: -- -- -- --"),
|
||||
TextFont {
|
||||
font: font_handle_for_mini_tableau.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
panel.spawn((
|
||||
ReplayMiniTableauStockWaste,
|
||||
Text::new("STK:-- WST:--"),
|
||||
TextFont {
|
||||
font: font_handle_for_mini_tableau,
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Pure helper — returns the scrub-fill width as a percentage of the
|
||||
/// track for the given playback state. `Completed` reads as 100 %;
|
||||
/// `Inactive` and `Playing` with no progress read as 0 %.
|
||||
pub(crate) fn scrub_pct(state: &ReplayPlaybackState) -> f32 {
|
||||
if state.is_completed() {
|
||||
return 100.0;
|
||||
}
|
||||
match state.progress() {
|
||||
Some((_, 0)) | None => 0.0,
|
||||
Some((cursor, total)) => {
|
||||
let frac = (cursor as f32 / total as f32).clamp(0.0, 1.0);
|
||||
frac * 100.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — returns the fixed scrub-bar notch positions as
|
||||
/// percentages along the track. Five evenly-spaced notches at the
|
||||
/// quarter-marks: `[0, 25, 50, 75, 100]`. Function (rather than
|
||||
/// const) so the unit-test surface is obvious and a future
|
||||
/// regression — e.g. someone simplifying to three notches — fails
|
||||
/// at the helper test rather than at visual review.
|
||||
pub(crate) fn scrub_notch_positions() -> [f32; 5] {
|
||||
[0.0, 25.0, 50.0, 75.0, 100.0]
|
||||
}
|
||||
|
||||
/// Pure helper — returns the percentage-label text for each notch,
|
||||
/// in left-to-right order. Paired with [`scrub_notch_positions`] so
|
||||
/// `labels[i]` belongs at `positions[i]`. Lifted to a function for
|
||||
/// the same reason as the positions helper: a clean unit-test
|
||||
/// surface that fails at a regression (e.g. someone simplifying
|
||||
/// `100%` → `MAX`) rather than at visual review.
|
||||
pub(crate) fn scrub_notch_labels() -> [&'static str; 5] {
|
||||
["0%", "25%", "50%", "75%", "100%"]
|
||||
}
|
||||
|
||||
/// Pure helper — returns the vim-style mode indicator text shown on
|
||||
/// the left side of the keybind-hint footer row. `▌ NORMAL │ replay`
|
||||
/// matches the `▌replay.tsx` motif from the splash boot-screen and
|
||||
/// the screen-takeover mockup. The cursor block (`▌`) matches the
|
||||
/// banner-label prefix; "NORMAL" is the vim mode (mockup parity);
|
||||
/// "replay" identifies the surface.
|
||||
pub(crate) fn keybind_footer_mode_text() -> &'static str {
|
||||
"\u{258C} NORMAL \u{2502} replay" // ▌ NORMAL │ replay
|
||||
}
|
||||
|
||||
/// Pure helper — returns the keybind-hint text shown on the right
|
||||
/// side of the keybind-hint footer row. Lists only the keys that
|
||||
/// are *actually wired* today: the Space accelerator for
|
||||
/// pause/resume, the ESC accelerator for stop, and the ← / →
|
||||
/// accelerators for paused single-move stepping. The footer never
|
||||
/// lists unimplemented keybinds (would lie to users).
|
||||
pub(crate) fn keybind_footer_hint_text() -> &'static str {
|
||||
if SHOW_KEYBOARD_ACCELERATORS {
|
||||
"[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — returns the WIN MOVE marker's left-edge position as
|
||||
/// a percentage of the scrub track, or `None` when no marker should
|
||||
/// be drawn.
|
||||
///
|
||||
/// `None` is returned in any of these cases:
|
||||
/// - The state isn't `Playing` (no replay attached).
|
||||
/// - The replay's `win_move_index` is `None` (older replay loaded
|
||||
/// from disk pre-dating the field).
|
||||
/// - The replay's move list is empty (shouldn't happen for real wins,
|
||||
/// but guards the divide-by-zero).
|
||||
///
|
||||
/// The percentage clamps to `[0, 100]` so a malformed
|
||||
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
|
||||
/// position the marker outside the track.
|
||||
pub(crate) fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
|
||||
let ReplayPlaybackState::Playing { replay, .. } = state else {
|
||||
return None;
|
||||
};
|
||||
let idx = replay.win_move_index?;
|
||||
let total = replay.moves.len();
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
let frac = (idx as f32 / total as f32).clamp(0.0, 1.0);
|
||||
Some(frac * 100.0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Playback-control button handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1649,62 +1649,62 @@ function __wbg_get_imports() {
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 62028, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 62031, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7477, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7472, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7475, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7473, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7476, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
|
||||
return ret;
|
||||
},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -228,11 +228,6 @@ impl ReplayPlayer {
|
||||
pub fn step_idx(&self) -> usize {
|
||||
self.step_idx
|
||||
}
|
||||
|
||||
/// Returns `true` once every move has been applied.
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.step_idx >= self.moves.len()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user