From 8f40304f41586cddf1ac7dc782323af5692866eb Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Tue, 5 May 2026 17:49:00 +0200 Subject: [PATCH] fix(web client): show dice animation & sound only once --- clients/web/src/app.rs | 13 ++++++++++--- clients/web/src/game/components/board.rs | 15 ++++++++++++++- clients/web/src/game/components/game_screen.rs | 12 ++++++++++-- clients/web/src/game/session.rs | 4 ++++ 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index de8d55f..85ee622 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -45,6 +45,9 @@ pub struct GameUiState { pub my_scored_event: Option, pub opp_scored_event: Option, pub last_moves: Option<(CheckerMove, CheckerMove)>, + /// True on the echo screen state set alongside a pending item — suppresses dice + /// roll animation and sound since they already played on the pending screen. + pub suppress_dice_anim: bool, } /// Reason the UI is paused waiting for the player to click Continue. @@ -352,6 +355,7 @@ pub fn App() -> impl IntoView { my_scored_event: None, opp_scored_event: None, last_moves: compute_last_moves(&prev_vs, &vs, is_own_move), + suppress_dice_anim: false, }, pending, screen, @@ -402,13 +406,16 @@ fn GameOverlay( ) -> impl IntoView { let location = use_location(); + // Memoize the front of the pending queue so that pushing a new item to the back + // does not re-mount GameScreen (and replay dice animation/sound) when the displayed + // state (the front) hasn't changed. + let pending_front = Memo::new(move |_| pending.with(|q| q.front().cloned())); + move || { if location.pathname.get() != "/" { return view! {}.into_any(); } - let q = pending.get(); - let front = q.front().cloned(); - if let Some(state) = front { + if let Some(state) = pending_front.get() { return view! {
} diff --git a/clients/web/src/game/components/board.rs b/clients/web/src/game/components/board.rs index 6be1ba7..02bee6a 100644 --- a/clients/web/src/game/components/board.rs +++ b/clients/web/src/game/components/board.rs @@ -272,6 +272,9 @@ pub fn Board( /// Fields where a hit (battue) was scored this turn — show ripple animation. #[prop(default = vec![])] hit_fields: Vec, + /// Suppress dice animation (echo screen shown after a pending confirm was dismissed). + #[prop(default = false)] + suppress_dice_anim: bool, ) -> impl IntoView { let board = view_state.board; let white_points = view_state.scores[0].points; @@ -283,6 +286,11 @@ pub fn Board( view_state.turn_stage, SerTurnStage::Move | SerTurnStage::HoldOrGoChoice ); + // True when ANY player is in the Move/HoldOrGoChoice stage — i.e., dice are fresh for the active player. + let active_is_move_stage = matches!( + view_state.turn_stage, + SerTurnStage::Move | SerTurnStage::HoldOrGoChoice + ); let is_white = player_id == 0; let hovered_moves = use_context::>>(); @@ -533,8 +541,13 @@ pub fn Board( bar_matched_dice_used(&staged, dice_vals) } else if is_my_turn { (true, true) - } else { + } else if active_is_move_stage && !suppress_dice_anim { + // Opponent has fresh dice in their Move stage (first view). (false, false) + } else { + // Dice are old: either from the previous turn (opponent not yet + // rolled) or this is the echo screen after a pending confirm. + (true, true) }; let used = if die_idx == 0 { u0 } else { u1 }; view! { } diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs index d55284f..108208a 100644 --- a/clients/web/src/game/components/game_screen.rs +++ b/clients/web/src/game/components/game_screen.rs @@ -29,6 +29,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { ); let waiting_for_confirm = state.waiting_for_confirm; let pause_reason = state.pause_reason.clone(); + let suppress_dice_anim = state.suppress_dice_anim; // ── Hovered jan moves (shown as arrows on the board) ────────────────────── let hovered_jan_moves: RwSignal> = RwSignal::new(vec![]); @@ -206,8 +207,14 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { }; // ── Sound effects (fire once on mount = once per state snapshot) ────────── - // Dice roll: dice just appeared (no preceding moves in this snapshot). - if show_dice && last_moves.is_none() { + // Dice roll: dice are fresh for the currently active player (Move stage means + // someone just rolled). Skipped on turn-switch states where the old dice linger + // in RollDice/MarkPoints stage before the opponent has rolled. + let active_is_move_stage = matches!( + vs.turn_stage, + SerTurnStage::Move | SerTurnStage::HoldOrGoChoice + ); + if show_dice && last_moves.is_none() && active_is_move_stage && !suppress_dice_anim { crate::game::sound::play_dice_roll(); } // Checker move: moves were committed in the preceding action. @@ -328,6 +335,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { bar_is_double=is_double_dice last_moves=last_moves hit_fields=hit_fields + suppress_dice_anim=suppress_dice_anim /> // ── Status, hints, and actions — cream strip below board ─ diff --git a/clients/web/src/game/session.rs b/clients/web/src/game/session.rs index df603ec..b3704ec 100644 --- a/clients/web/src/game/session.rs +++ b/clients/web/src/game/session.rs @@ -47,6 +47,7 @@ pub async fn run_local_bot_game( my_scored_event: None, opp_scored_event: None, last_moves: None, + suppress_dice_anim: false, })); use futures::StreamExt; @@ -73,6 +74,7 @@ pub async fn run_local_bot_game( my_scored_event: scored, opp_scored_event: opp_scored, last_moves: compute_last_moves(&prev_vs, &vs, true), + suppress_dice_anim: false, })); } Some(NetCommand::PlayVsBot) => return true, @@ -102,6 +104,7 @@ pub async fn run_local_bot_game( my_scored_event: None, opp_scored_event: None, last_moves: compute_last_moves(&delta_prev_vs, &vs, false), + suppress_dice_anim: false, }, pending, screen, @@ -220,6 +223,7 @@ pub fn push_or_show( }); screen.set(Screen::Playing(GameUiState { last_moves: None, + suppress_dice_anim: true, ..new_state })); } else {