From 784dc1c4f75e2cf93e2e1b6e5e0f3d714b0b2e90 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Thu, 9 Apr 2026 20:17:21 +0200 Subject: [PATCH] feat(client_web): checkers slide animation --- Cargo.lock | 2 + client_web/Cargo.toml | 8 ++ client_web/assets/style.css | 18 +++ client_web/src/app.rs | 28 ++++- client_web/src/components/board.rs | 150 ++++++++++++++++++----- client_web/src/components/game_screen.rs | 3 + client_web/src/trictrac/types.rs | 4 + 7 files changed, 180 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b19ee85..42fc19e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1397,6 +1397,7 @@ dependencies = [ "futures", "getrandom 0.3.4", "gloo-storage", + "gloo-timers", "leptos", "leptos_i18n", "rand 0.9.2", @@ -1404,6 +1405,7 @@ dependencies = [ "serde_json", "trictrac-store", "wasm-bindgen-futures", + "web-sys", ] [[package]] diff --git a/client_web/Cargo.toml b/client_web/Cargo.toml index 3e648ea..db62e44 100644 --- a/client_web/Cargo.toml +++ b/client_web/Cargo.toml @@ -17,9 +17,17 @@ serde_json = "1" futures = "0.3" rand = "0.9" gloo-storage = "0.3" +gloo-timers = "0.3" [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" # getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues. # Must be a direct dependency (not just transitive) for the feature to take effect. getrandom = { version = "0.3", features = ["wasm_js"] } +web-sys = { version = "0.3", features = [ + "Document", + "Element", + "HtmlElement", + "CssStyleDeclaration", + "DomTokenList", +] } diff --git a/client_web/assets/style.css b/client_web/assets/style.css index b8ddd05..f14aa94 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -999,6 +999,24 @@ body { letter-spacing: 0.14em; } +/* ── §4a — Checker slide animation ─────────────────────────────────── */ +@keyframes checker-slide-in { + from { transform: translate(var(--slide-dx, 0px), var(--slide-dy, 0px)); } + to { transform: none; } +} +.checker-stack.sliding { + animation: checker-slide-in 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} +/* Lift the field that owns a sliding stack above its siblings so the + checker doesn't slide under adjacent fields (isolation:isolate traps + z-index within each field's stacking context, so we must elevate the + field itself). */ +.field:has(.checker-stack.sliding) { + isolation: auto; + z-index: 10; + position: relative; +} + /* ── Checker lift on selected field (§4b) ───────────────────────────── */ .field.selected .checker-stack { transform: translateY(-5px); diff --git a/client_web/src/app.rs b/client_web/src/app.rs index 8f98559..4ae4ad1 100644 --- a/client_web/src/app.rs +++ b/client_web/src/app.rs @@ -13,6 +13,7 @@ use crate::i18n::I18nContextProvider; use crate::trictrac::backend::TrictracBackend; use crate::trictrac::bot_local::bot_decide; use crate::trictrac::types::{GameDelta, JanEntry, PlayerAction, ScoredEvent, SerTurnStage, ViewState}; +use trictrac_store::CheckerMove; use std::collections::VecDeque; @@ -35,6 +36,8 @@ pub struct GameUiState { /// Points scored by this player in the transition to this state (if any). pub my_scored_event: Option, pub opp_scored_event: Option, + /// Checker moves to animate on this render. None when board is unchanged. + pub last_moves: Option<(CheckerMove, CheckerMove)>, } /// Reason the UI is paused waiting for the player to click Continue. @@ -272,6 +275,7 @@ pub fn App() -> impl IntoView { pause_reason: None, my_scored_event: None, opp_scored_event: None, + last_moves: compute_last_moves(&prev_vs, &vs), }, pending, screen, @@ -338,6 +342,7 @@ async fn run_local_bot_game( pause_reason: None, my_scored_event: None, opp_scored_event: None, + last_moves: None, })); loop { @@ -361,6 +366,7 @@ async fn run_local_bot_game( pause_reason: None, my_scored_event: scored, opp_scored_event: opp_scored, + last_moves: compute_last_moves(&prev_vs, &vs), })); } Some(NetCommand::PlayVsBot) => return true, @@ -389,6 +395,7 @@ async fn run_local_bot_game( pause_reason: None, my_scored_event: None, opp_scored_event: None, + last_moves: compute_last_moves(&prev_vs, &vs), }, pending, screen, @@ -399,6 +406,22 @@ async fn run_local_bot_game( } } +/// Returns the checker moves to animate when the board changed between two ViewStates. +/// Returns `None` when the board is unchanged or no real moves were recorded. +fn compute_last_moves(prev: &ViewState, next: &ViewState) -> Option<(CheckerMove, CheckerMove)> { + if prev.board == next.board { + return None; + } + let (m1, m2) = next.dice_moves; + if m1 == CheckerMove::default() && m2 == CheckerMove::default() { + // Relies on the engine invariant: dice_moves is updated atomically with the board + // change in the Move event handler. Any future engine path that mutates the board + // without setting dice_moves would bypass this guard and replay stale animation. + return None; + } + Some((m1, m2)) +} + /// Computes a scoring event for `player_id` by comparing the previous and next /// ViewState. Returns `None` when no points changed for that player. fn compute_scored_event(prev: &ViewState, next: &ViewState, player_id: u16) -> Option { @@ -471,7 +494,9 @@ fn push_or_show( ..new_state.clone() }); }); - screen.set(Screen::Playing(new_state)); + // Animation belongs to the buffered confirmation step; clear it on the + // fallback live state so it doesn't fire again after the queue drains. + screen.set(Screen::Playing(GameUiState { last_moves: None, ..new_state })); } else { // No pause: show scoring directly on the live state. screen.set(Screen::Playing(GameUiState { @@ -528,6 +553,7 @@ mod tests { scores: [score(), score()], dice, dice_jans: Vec::new(), + dice_moves: (CheckerMove::default(), CheckerMove::default()), } } diff --git a/client_web/src/components/board.rs b/client_web/src/components/board.rs index bd61102..8483d1c 100644 --- a/client_web/src/components/board.rs +++ b/client_web/src/components/board.rs @@ -1,8 +1,8 @@ use leptos::prelude::*; use trictrac_store::CheckerMove; -use crate::trictrac::types::{SerTurnStage, ViewState}; use super::die::Die; +use crate::trictrac::types::{SerTurnStage, ViewState}; /// Field numbers in visual display order (left-to-right for each quarter), white's perspective. const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18]; @@ -20,17 +20,21 @@ const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13]; /// Returns true when `field_num` is the rest corner for this perspective. #[allow(dead_code)] fn is_rest_corner(field_num: u8, is_white: bool) -> bool { - if is_white { field_num == 12 } else { field_num == 13 } + if is_white { + field_num == 12 + } else { + field_num == 13 + } } /// Zone CSS class for a field number (field coordinates are always White's 1-24). fn field_zone_class(field_num: u8) -> &'static str { match field_num { - 1..=6 => "zone-petit", - 7..=12 => "zone-grand", + 1..=6 => "zone-petit", + 7..=12 => "zone-grand", 13..=18 => "zone-retour", 19..=24 => "zone-dernier", - _ => "", + _ => "", } } @@ -39,11 +43,20 @@ fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) { let mut d0 = false; let mut d1 = false; for &(from, to) in staged { - let dist = if from < to { to.saturating_sub(from) } else { from.saturating_sub(to) }; - if !d0 && dist == dice.0 { d0 = true; } - else if !d1 && dist == dice.1 { d1 = true; } - else if !d0 { d0 = true; } - else { d1 = true; } + let dist = if from < to { + to.saturating_sub(from) + } else { + from.saturating_sub(to) + }; + if !d0 && dist == dice.0 { + d0 = true; + } else if !d1 && dist == dice.1 { + d1 = true; + } else if !d0 { + d0 = true; + } else { + d1 = true; + } } (d0, d1) } @@ -72,7 +85,8 @@ fn displayed_value( /// Fields whose checkers may be selected as the next origin given already-staged moves. fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -> Vec { let mut v: Vec = match staged.len() { - 0 => seqs.iter() + 0 => seqs + .iter() .map(|(m1, _)| m1.get_from() as u8) .filter(|&f| f != 0) .collect(), @@ -103,28 +117,46 @@ fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> { match f { 13..=18 => (f - 13, false, true), 19..=24 => (f - 19, true, true), - 7..=12 => (12 - f, false, false), - 1..=6 => (6 - f, true, false), - _ => return None, + 7..=12 => (12 - f, false, false), + 1..=6 => (6 - f, true, false), + _ => return None, } } else { match f { - 1..=6 => (f - 1, false, true), - 7..=12 => (f - 7, true, true), + 1..=6 => (f - 1, false, true), + 7..=12 => (f - 7, true, true), 19..=24 => (24 - f, false, false), 13..=18 => (18 - f, true, false), - _ => return None, + _ => return None, } }; // Left-quarter field i center x: 4(pad) + i*62 + 30(half field) = 34 + 62i // Right-quarter: 4 + 370(quarter) + 4(gap) + 68(bar) + 4(gap) + i*62 + 30 = 480 + 62i - let x = if right { 480.0 + qi as f32 * 62.0 } else { 34.0 + qi as f32 * 62.0 }; + let x = if right { + 480.0 + qi as f32 * 62.0 + } else { + 34.0 + qi as f32 * 62.0 + }; // Top row triangle base (wide end) ≈ y=30; bot row triangle base ≈ y=358. // (Top base: 4pad + 4field-pad + 20half-checker ≈ 28; Bot base: 388 − 4pad − 4field-pad − 20 ≈ 360) let y = if top { 30.0 } else { 358.0 }; Some((x, y)) } +#[cfg(target_arch = "wasm32")] +fn apply_slide_animation(to_field: usize, dx: f32, dy: f32) { + use web_sys::wasm_bindgen::JsCast; + let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return }; + let selector = format!("#field-{} .checker-stack", to_field); + let Ok(Some(el)) = doc.query_selector(&selector) else { return }; + let Ok(html) = el.dyn_into::() else { return }; + let style = html.style(); + style.set_property("--slide-dx", &format!("{:.1}px", dx)).ok(); + style.set_property("--slide-dy", &format!("{:.1}px", dy)).ok(); + html.class_list().add_1("sliding").ok(); +} + + /// SVG `` element drawing one arrow (shadow + gold) from `fp` to `tp`. fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView { let (x1, y1) = fp; @@ -153,15 +185,21 @@ fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView { let bary = y2 - ny * ah; let pts = format!( "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", - x2, y2, - bx + px * aw, bary + py * aw, - bx - px * aw, bary - py * aw, + x2, + y2, + bx + px * aw, + bary + py * aw, + bx - px * aw, + bary - py * aw, ); let shadow_pts = format!( "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", - x2, y2, - bx + px * (aw + 1.5), bary + py * (aw + 1.5), - bx - px * (aw + 1.5), bary - py * (aw + 1.5), + x2, + y2, + bx + px * (aw + 1.5), + bary + py * (aw + 1.5), + bx - px * (aw + 1.5), + bary - py * (aw + 1.5), ); view! { @@ -187,9 +225,14 @@ fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView { /// Valid destinations for a selected origin given already-staged moves. /// May include 0 (exit); callers handle that case. -fn valid_dests_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)], origin: u8) -> Vec { +fn valid_dests_for( + seqs: &[(CheckerMove, CheckerMove)], + staged: &[(u8, u8)], + origin: u8, +) -> Vec { let mut v: Vec = match staged.len() { - 0 => seqs.iter() + 0 => seqs + .iter() .filter(|(m1, _)| m1.get_from() as u8 == origin) .map(|(m1, _)| m1.get_to() as u8) .collect(), @@ -222,11 +265,17 @@ pub fn Board( /// All valid two-move sequences for this turn (empty when not in move stage). valid_sequences: Vec<(CheckerMove, CheckerMove)>, /// Dice to display in the center bars; None means dice not yet rolled (cups shown upright). - #[prop(default = None)] bar_dice: Option<(u8, u8)>, + #[prop(default = None)] + bar_dice: Option<(u8, u8)>, /// Whether we're in the move stage (determines used/unused die appearance). - #[prop(default = false)] bar_is_move: bool, + #[prop(default = false)] + bar_is_move: bool, /// Whether the dice are a double (golden glow). - #[prop(default = false)] bar_is_double: bool, + #[prop(default = false)] + bar_is_double: bool, + /// Checker moves to animate on mount (None when board unchanged). + #[prop(default = None)] + last_moves: Option<(CheckerMove, CheckerMove)>, ) -> impl IntoView { let board = view_state.board; let is_move_stage = view_state.active_mp_player == Some(player_id) @@ -245,12 +294,12 @@ pub fn Board( let exit_field_test: fn(u8) -> bool; if is_white { let in_exit: i8 = board_snapshot[18..24].iter().map(|&v| v.max(0)).sum(); - let total: i8 = board_snapshot.iter().map(|&v| v.max(0)).sum(); + let total: i8 = board_snapshot.iter().map(|&v| v.max(0)).sum(); all_in_exit = total > 0 && in_exit == total; exit_field_test = |f| matches!(f, 19..=24); } else { let in_exit: i8 = board_snapshot[0..6].iter().map(|&v| (-v).max(0)).sum(); - let total: i8 = board_snapshot.iter().map(|&v| (-v).max(0)).sum(); + let total: i8 = board_snapshot.iter().map(|&v| (-v).max(0)).sum(); all_in_exit = total > 0 && in_exit == total; exit_field_test = |f| matches!(f, 1..=6); } @@ -270,6 +319,7 @@ pub fn Board( }; view! {
view! {
}.into_any(), Some(dice_vals) => { - let die_val = if die_idx == 0 { dice_vals.0 } else { dice_vals.1 }; + let die_val = if die_idx == 0 { + dice_vals.0 + } else { + dice_vals.1 + }; view! {
{move || { @@ -422,6 +476,38 @@ pub fn Board( } }; + // §4a — animate checker moves. Deferred to a macrotask so Leptos has time + // to mount the Board's field divs before we query the DOM. + Effect::new(move |_| { + let Some((m1, m2)) = last_moves else { return }; + + // Collect the (to_field, dx, dy) pairs we need before moving into spawn_local. + let mut animations: Vec<(usize, f32, f32)> = Vec::new(); + for m in [m1, m2] { + if m.get_from() == 0 && m.get_to() == 0 { continue; } + let Some((fx, fy)) = field_center(m.get_from(), is_white) else { continue }; + let Some((tx, ty)) = field_center(m.get_to(), is_white) else { continue }; + let dx = fx - tx; + let dy = fy - ty; + if dx.abs() < 1.0 && dy.abs() < 1.0 { continue; } + animations.push((m.get_to(), dx, dy)); + } + + #[cfg(target_arch = "wasm32")] + { + if let Some((to_field, dx, dy)) = animations.first().copied() { + gloo_timers::callback::Timeout::new(0, move || { + apply_slide_animation(to_field, dx, dy); + }).forget(); + } + if let Some((to_field, dx, dy)) = animations.get(1).copied() { + gloo_timers::callback::Timeout::new(300, move || { + apply_slide_animation(to_field, dx, dy); + }).forget(); + } + } + }); + let (tl, tr, bl, br) = if is_white { (&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W) } else { diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 6657d94..f5c08b8 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -119,6 +119,8 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let is_double_dice = dice.0 == dice.1 && dice.0 != 0; + let last_moves = state.last_moves; + // ── Capture for closures ─────────────────────────────────────────────────── let stage = vs.stage.clone(); let turn_stage = vs.turn_stage.clone(); @@ -214,6 +216,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { bar_dice=show_dice.then_some(dice) bar_is_move=is_move_stage bar_is_double=is_double_dice + last_moves=last_moves /> // ── Side panel (scoring panels only) ───────────────────────── diff --git a/client_web/src/trictrac/types.rs b/client_web/src/trictrac/types.rs index 2c5cdd2..82f9a2d 100644 --- a/client_web/src/trictrac/types.rs +++ b/client_web/src/trictrac/types.rs @@ -41,6 +41,8 @@ pub struct ViewState { pub dice: (u8, u8), /// Jans (scoring events) triggered by the last dice roll. pub dice_jans: Vec, + /// Last two checker moves played; default when no move has occurred yet. + pub dice_moves: (CheckerMove, CheckerMove), } /// One scoring event from a dice roll. @@ -73,6 +75,7 @@ impl ViewState { ], dice: (0, 0), dice_jans: Vec::new(), + dice_moves: (CheckerMove::default(), CheckerMove::default()), } } @@ -166,6 +169,7 @@ impl ViewState { scores: [score_for(host_store_id), score_for(guest_store_id)], dice: (gs.dice.values.0, gs.dice.values.1), dice_jans, + dice_moves: gs.dice_moves, } } }