From c0409d6121e02692091e4e3dfe85f79e4271f25a Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Fri, 10 Apr 2026 19:37:31 +0200 Subject: [PATCH] feat(client_web): hit animations --- client_web/assets/style.css | 23 +++++++++++++++ client_web/src/components/board.rs | 34 ++++++++++++++++++++-- client_web/src/components/game_screen.rs | 36 +++++++++++++++++++++++- 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/client_web/assets/style.css b/client_web/assets/style.css index 39d7fc8..5edde6e 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -827,6 +827,29 @@ body { animation: exit-glow 2s ease-in-out infinite; } +/* ── §6c — Jan hover field highlight ────────────────────────────────── */ +.field.jan-hovered { + --fc: rgba(190, 140, 35, 0.8) !important; +} + +/* ── §6e — Hit (battue) ripple ──────────────────────────────────────── */ +@keyframes hit-ripple { + from { transform: translate(-50%, -50%) scale(0.4); opacity: 0.9; } + to { transform: translate(-50%, -50%) scale(2.2); opacity: 0; } +} +.hit-ripple { + position: absolute; + left: 50%; + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid rgba(200, 164, 72, 0.9); + pointer-events: none; + animation: hit-ripple 0.5s ease-out forwards; +} +.hit-ripple-top { top: 26px; } +.hit-ripple-bot { bottom: 26px; } + /* ── Interactive states — after .corner to take visual priority ─────── */ .field.clickable { cursor: pointer; diff --git a/client_web/src/components/board.rs b/client_web/src/components/board.rs index e9d1308..5a82b13 100644 --- a/client_web/src/components/board.rs +++ b/client_web/src/components/board.rs @@ -263,6 +263,9 @@ pub fn Board( /// Checker moves to animate on mount (None when board unchanged). #[prop(default = None)] last_moves: Option<(CheckerMove, CheckerMove)>, + /// Fields where a hit (battue) was scored this turn — show ripple animation. + #[prop(default = vec![])] + hit_fields: Vec, ) -> impl IntoView { let board = view_state.board; let is_move_stage = view_state.active_mp_player == Some(player_id) @@ -318,6 +321,8 @@ pub fn Board( (dx.abs() >= 1.0 || dy.abs() >= 1.0).then_some((dx, dy)) }) }); + // §6e — ripple on hit fields (battue). + let is_hit_field = hit_fields.contains(&field_num); view! {
0).then(|| { + // §6e — ripple on hit (battue) fields; must be inside the + // reactive closure so Leptos uses the same direct rendering + // path as .arriving (avoids node-move that resets animation). + let ripple = is_hit_field.then(|| { + let cls = if is_top_row { "hit-ripple hit-ripple-top" } else { "hit-ripple hit-ripple-bot" }; + view! {
}.into_any() + }); + let stack = (count > 0).then(|| { let color = if val > 0 { "white" } else { "black" }; let display_n = (count as usize).min(4); // outermost index: last for top rows, first for bottom rows. @@ -448,8 +475,9 @@ pub fn Board(
{label}
}.into_any() }).collect(); - view! {
{chips}
} - }) + view! {
{chips}
}.into_any() + }); + (ripple, stack) }}
} diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index f5c08b8..2fa14c3 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -2,7 +2,7 @@ use std::collections::VecDeque; use futures::channel::mpsc::UnboundedSender; use leptos::prelude::*; -use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, MoveRules}; +use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules}; use crate::app::{GameUiState, NetCommand, PauseReason}; use crate::i18n::*; @@ -121,6 +121,39 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let last_moves = state.last_moves; + // §6e — fields where a battue (hit) was scored; ripple animation shown there. + let hit_fields: Vec = { + let is_hit_jan = |jan: &Jan| matches!( + jan, + Jan::TrueHitSmallJan + | Jan::TrueHitBigJan + | Jan::TrueHitOpponentCorner + | Jan::FalseHitSmallJan + | Jan::FalseHitBigJan + ); + let mut fields: Vec = vec![]; + for event_opt in [&my_scored_event, &opp_scored_event] { + if let Some(event) = event_opt { + for entry in &event.jans { + if is_hit_jan(&entry.jan) { + for (m1, m2) in &entry.moves { + for m in [m1, m2] { + let to = m.get_to() as u8; + if to != 0 && !fields.contains(&to) { + fields.push(to); + } + } + } + } + } + } + } + if !fields.is_empty() { + leptos::logging::log!("[6e] hit_fields = {:?}", fields); + } + fields + }; + // ── Capture for closures ─────────────────────────────────────────────────── let stage = vs.stage.clone(); let turn_stage = vs.turn_stage.clone(); @@ -217,6 +250,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { bar_is_move=is_move_stage bar_is_double=is_double_dice last_moves=last_moves + hit_fields=hit_fields /> // ── Side panel (scoring panels only) ─────────────────────────