feat(client_web): checkers slide animation

This commit is contained in:
Henri Bourcereau 2026-04-09 20:17:21 +02:00
parent e7c0a390e3
commit 784dc1c4f7
7 changed files with 180 additions and 33 deletions

2
Cargo.lock generated
View file

@ -1397,6 +1397,7 @@ dependencies = [
"futures", "futures",
"getrandom 0.3.4", "getrandom 0.3.4",
"gloo-storage", "gloo-storage",
"gloo-timers",
"leptos", "leptos",
"leptos_i18n", "leptos_i18n",
"rand 0.9.2", "rand 0.9.2",
@ -1404,6 +1405,7 @@ dependencies = [
"serde_json", "serde_json",
"trictrac-store", "trictrac-store",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys",
] ]
[[package]] [[package]]

View file

@ -17,9 +17,17 @@ serde_json = "1"
futures = "0.3" futures = "0.3"
rand = "0.9" rand = "0.9"
gloo-storage = "0.3" gloo-storage = "0.3"
gloo-timers = "0.3"
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
# getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues. # 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. # Must be a direct dependency (not just transitive) for the feature to take effect.
getrandom = { version = "0.3", features = ["wasm_js"] } getrandom = { version = "0.3", features = ["wasm_js"] }
web-sys = { version = "0.3", features = [
"Document",
"Element",
"HtmlElement",
"CssStyleDeclaration",
"DomTokenList",
] }

View file

@ -999,6 +999,24 @@ body {
letter-spacing: 0.14em; 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) ───────────────────────────── */ /* ── Checker lift on selected field (§4b) ───────────────────────────── */
.field.selected .checker-stack { .field.selected .checker-stack {
transform: translateY(-5px); transform: translateY(-5px);

View file

@ -13,6 +13,7 @@ use crate::i18n::I18nContextProvider;
use crate::trictrac::backend::TrictracBackend; use crate::trictrac::backend::TrictracBackend;
use crate::trictrac::bot_local::bot_decide; use crate::trictrac::bot_local::bot_decide;
use crate::trictrac::types::{GameDelta, JanEntry, PlayerAction, ScoredEvent, SerTurnStage, ViewState}; use crate::trictrac::types::{GameDelta, JanEntry, PlayerAction, ScoredEvent, SerTurnStage, ViewState};
use trictrac_store::CheckerMove;
use std::collections::VecDeque; use std::collections::VecDeque;
@ -35,6 +36,8 @@ pub struct GameUiState {
/// Points scored by this player in the transition to this state (if any). /// Points scored by this player in the transition to this state (if any).
pub my_scored_event: Option<ScoredEvent>, pub my_scored_event: Option<ScoredEvent>,
pub opp_scored_event: Option<ScoredEvent>, pub opp_scored_event: Option<ScoredEvent>,
/// 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. /// Reason the UI is paused waiting for the player to click Continue.
@ -272,6 +275,7 @@ pub fn App() -> impl IntoView {
pause_reason: None, pause_reason: None,
my_scored_event: None, my_scored_event: None,
opp_scored_event: None, opp_scored_event: None,
last_moves: compute_last_moves(&prev_vs, &vs),
}, },
pending, pending,
screen, screen,
@ -338,6 +342,7 @@ async fn run_local_bot_game(
pause_reason: None, pause_reason: None,
my_scored_event: None, my_scored_event: None,
opp_scored_event: None, opp_scored_event: None,
last_moves: None,
})); }));
loop { loop {
@ -361,6 +366,7 @@ async fn run_local_bot_game(
pause_reason: None, pause_reason: None,
my_scored_event: scored, my_scored_event: scored,
opp_scored_event: opp_scored, opp_scored_event: opp_scored,
last_moves: compute_last_moves(&prev_vs, &vs),
})); }));
} }
Some(NetCommand::PlayVsBot) => return true, Some(NetCommand::PlayVsBot) => return true,
@ -389,6 +395,7 @@ async fn run_local_bot_game(
pause_reason: None, pause_reason: None,
my_scored_event: None, my_scored_event: None,
opp_scored_event: None, opp_scored_event: None,
last_moves: compute_last_moves(&prev_vs, &vs),
}, },
pending, pending,
screen, 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 /// Computes a scoring event for `player_id` by comparing the previous and next
/// ViewState. Returns `None` when no points changed for that player. /// ViewState. Returns `None` when no points changed for that player.
fn compute_scored_event(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<ScoredEvent> { fn compute_scored_event(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<ScoredEvent> {
@ -471,7 +494,9 @@ fn push_or_show(
..new_state.clone() ..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 { } else {
// No pause: show scoring directly on the live state. // No pause: show scoring directly on the live state.
screen.set(Screen::Playing(GameUiState { screen.set(Screen::Playing(GameUiState {
@ -528,6 +553,7 @@ mod tests {
scores: [score(), score()], scores: [score(), score()],
dice, dice,
dice_jans: Vec::new(), dice_jans: Vec::new(),
dice_moves: (CheckerMove::default(), CheckerMove::default()),
} }
} }

View file

@ -1,8 +1,8 @@
use leptos::prelude::*; use leptos::prelude::*;
use trictrac_store::CheckerMove; use trictrac_store::CheckerMove;
use crate::trictrac::types::{SerTurnStage, ViewState};
use super::die::Die; 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. /// 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]; 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. /// Returns true when `field_num` is the rest corner for this perspective.
#[allow(dead_code)] #[allow(dead_code)]
fn is_rest_corner(field_num: u8, is_white: bool) -> bool { 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). /// Zone CSS class for a field number (field coordinates are always White's 1-24).
fn field_zone_class(field_num: u8) -> &'static str { fn field_zone_class(field_num: u8) -> &'static str {
match field_num { match field_num {
1..=6 => "zone-petit", 1..=6 => "zone-petit",
7..=12 => "zone-grand", 7..=12 => "zone-grand",
13..=18 => "zone-retour", 13..=18 => "zone-retour",
19..=24 => "zone-dernier", 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 d0 = false;
let mut d1 = false; let mut d1 = false;
for &(from, to) in staged { for &(from, to) in staged {
let dist = if from < to { to.saturating_sub(from) } else { from.saturating_sub(to) }; let dist = if from < to {
if !d0 && dist == dice.0 { d0 = true; } to.saturating_sub(from)
else if !d1 && dist == dice.1 { d1 = true; } } else {
else if !d0 { d0 = true; } from.saturating_sub(to)
else { d1 = true; } };
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) (d0, d1)
} }
@ -72,7 +85,8 @@ fn displayed_value(
/// Fields whose checkers may be selected as the next origin given already-staged moves. /// 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<u8> { fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -> Vec<u8> {
let mut v: Vec<u8> = match staged.len() { let mut v: Vec<u8> = match staged.len() {
0 => seqs.iter() 0 => seqs
.iter()
.map(|(m1, _)| m1.get_from() as u8) .map(|(m1, _)| m1.get_from() as u8)
.filter(|&f| f != 0) .filter(|&f| f != 0)
.collect(), .collect(),
@ -103,28 +117,46 @@ fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> {
match f { match f {
13..=18 => (f - 13, false, true), 13..=18 => (f - 13, false, true),
19..=24 => (f - 19, true, true), 19..=24 => (f - 19, true, true),
7..=12 => (12 - f, false, false), 7..=12 => (12 - f, false, false),
1..=6 => (6 - f, true, false), 1..=6 => (6 - f, true, false),
_ => return None, _ => return None,
} }
} else { } else {
match f { match f {
1..=6 => (f - 1, false, true), 1..=6 => (f - 1, false, true),
7..=12 => (f - 7, true, true), 7..=12 => (f - 7, true, true),
19..=24 => (24 - f, false, false), 19..=24 => (24 - f, false, false),
13..=18 => (18 - f, true, 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 // 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 // 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 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) // (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 }; let y = if top { 30.0 } else { 358.0 };
Some((x, y)) 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::<web_sys::HtmlElement>() 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 `<g>` element drawing one arrow (shadow + gold) from `fp` to `tp`. /// SVG `<g>` element drawing one arrow (shadow + gold) from `fp` to `tp`.
fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView { fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView {
let (x1, y1) = fp; let (x1, y1) = fp;
@ -153,15 +185,21 @@ fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView {
let bary = y2 - ny * ah; let bary = y2 - ny * ah;
let pts = format!( let pts = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
x2, y2, x2,
bx + px * aw, bary + py * aw, y2,
bx - px * aw, bary - py * aw, bx + px * aw,
bary + py * aw,
bx - px * aw,
bary - py * aw,
); );
let shadow_pts = format!( let shadow_pts = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
x2, y2, x2,
bx + px * (aw + 1.5), bary + py * (aw + 1.5), y2,
bx - px * (aw + 1.5), bary - py * (aw + 1.5), bx + px * (aw + 1.5),
bary + py * (aw + 1.5),
bx - px * (aw + 1.5),
bary - py * (aw + 1.5),
); );
view! { 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. /// Valid destinations for a selected origin given already-staged moves.
/// May include 0 (exit); callers handle that case. /// May include 0 (exit); callers handle that case.
fn valid_dests_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)], origin: u8) -> Vec<u8> { fn valid_dests_for(
seqs: &[(CheckerMove, CheckerMove)],
staged: &[(u8, u8)],
origin: u8,
) -> Vec<u8> {
let mut v: Vec<u8> = match staged.len() { let mut v: Vec<u8> = match staged.len() {
0 => seqs.iter() 0 => seqs
.iter()
.filter(|(m1, _)| m1.get_from() as u8 == origin) .filter(|(m1, _)| m1.get_from() as u8 == origin)
.map(|(m1, _)| m1.get_to() as u8) .map(|(m1, _)| m1.get_to() as u8)
.collect(), .collect(),
@ -222,11 +265,17 @@ pub fn Board(
/// All valid two-move sequences for this turn (empty when not in move stage). /// All valid two-move sequences for this turn (empty when not in move stage).
valid_sequences: Vec<(CheckerMove, CheckerMove)>, valid_sequences: Vec<(CheckerMove, CheckerMove)>,
/// Dice to display in the center bars; None means dice not yet rolled (cups shown upright). /// 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). /// 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). /// 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 { ) -> impl IntoView {
let board = view_state.board; let board = view_state.board;
let is_move_stage = view_state.active_mp_player == Some(player_id) 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; let exit_field_test: fn(u8) -> bool;
if is_white { if is_white {
let in_exit: i8 = board_snapshot[18..24].iter().map(|&v| v.max(0)).sum(); 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; all_in_exit = total > 0 && in_exit == total;
exit_field_test = |f| matches!(f, 19..=24); exit_field_test = |f| matches!(f, 19..=24);
} else { } else {
let in_exit: i8 = board_snapshot[0..6].iter().map(|&v| (-v).max(0)).sum(); 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; all_in_exit = total > 0 && in_exit == total;
exit_field_test = |f| matches!(f, 1..=6); exit_field_test = |f| matches!(f, 1..=6);
} }
@ -270,6 +319,7 @@ pub fn Board(
}; };
view! { view! {
<div <div
id={format!("field-{field_num}")}
title=corner_title title=corner_title
class=move || { class=move || {
let staged = staged_moves.get(); let staged = staged_moves.get();
@ -402,7 +452,11 @@ pub fn Board(
match bar_dice { match bar_dice {
None => view! { <div class="bar-die-slot"></div> }.into_any(), None => view! { <div class="bar-die-slot"></div> }.into_any(),
Some(dice_vals) => { 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! { view! {
<div class="bar-die-slot"> <div class="bar-die-slot">
{move || { {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 { let (tl, tr, bl, br) = if is_white {
(&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W) (&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W)
} else { } else {

View file

@ -119,6 +119,8 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let is_double_dice = dice.0 == dice.1 && dice.0 != 0; let is_double_dice = dice.0 == dice.1 && dice.0 != 0;
let last_moves = state.last_moves;
// ── Capture for closures ─────────────────────────────────────────────────── // ── Capture for closures ───────────────────────────────────────────────────
let stage = vs.stage.clone(); let stage = vs.stage.clone();
let turn_stage = vs.turn_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_dice=show_dice.then_some(dice)
bar_is_move=is_move_stage bar_is_move=is_move_stage
bar_is_double=is_double_dice bar_is_double=is_double_dice
last_moves=last_moves
/> />
// ── Side panel (scoring panels only) ───────────────────────── // ── Side panel (scoring panels only) ─────────────────────────

View file

@ -41,6 +41,8 @@ pub struct ViewState {
pub dice: (u8, u8), pub dice: (u8, u8),
/// Jans (scoring events) triggered by the last dice roll. /// Jans (scoring events) triggered by the last dice roll.
pub dice_jans: Vec<JanEntry>, pub dice_jans: Vec<JanEntry>,
/// Last two checker moves played; default when no move has occurred yet.
pub dice_moves: (CheckerMove, CheckerMove),
} }
/// One scoring event from a dice roll. /// One scoring event from a dice roll.
@ -73,6 +75,7 @@ impl ViewState {
], ],
dice: (0, 0), dice: (0, 0),
dice_jans: Vec::new(), 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)], scores: [score_for(host_store_id), score_for(guest_store_id)],
dice: (gs.dice.values.0, gs.dice.values.1), dice: (gs.dice.values.0, gs.dice.values.1),
dice_jans, dice_jans,
dice_moves: gs.dice_moves,
} }
} }
} }