diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index a129c91..35d4eee 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -705,6 +705,10 @@ a:hover { text-decoration: underline; } font-size: 1.05rem; color: var(--ui-ink); letter-spacing: 0.02em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } .score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; } @@ -808,7 +812,7 @@ a:hover { text-decoration: underline; } } .score-row-name { - min-width: 120px; + width: 120px; flex-shrink: 0; display: flex; align-items: baseline; @@ -1522,13 +1526,72 @@ a:hover { text-decoration: underline; } .field.clickable { cursor: pointer; - --fc: #8fc840 !important; } -.field.clickable:hover { --fc: #74aa28 !important; } +.field.clickable:hover { + --fc: rgba(200,170,50,0.18) !important; +} .field.selected { - --fc: #5a8a18 !important; - outline: 2px solid rgba(255,255,255,0.3); - outline-offset: -2px; + /* natural triangle color; tab is the indicator */ +} + +/* ── Tab indicators: small markers at the field's wide base ──────── */ +/* Bot-row: tabs hang below; top-row: tabs hang above. */ +/* The tab sits at ≈ -6px which lands on the board's wooden rail. */ + +.field.clickable::after, +.field.selected::after { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 22px; + height: 8px; + pointer-events: none; + z-index: 2; +} + +.bot-row .field.clickable::after, +.bot-row .field.selected::after { + bottom: -6px; + top: auto; + border-radius: 0 0 10px 10px; +} +.top-row .field.clickable::after, +.top-row .field.selected::after { + top: -6px; + bottom: auto; + border-radius: 10px 10px 0 0; +} + +/* Possible origin: hollow gold outline */ +.field.clickable:not(.dest):not(.selected)::after { + background: rgba(210,170,30,0.15); + border: 1.5px solid rgba(210,170,30,0.75); + box-shadow: 0 0 4px rgba(210,170,30,0.3); +} + +/* Selected origin: filled amber, breathing glow */ +.field.selected::after { + background: linear-gradient(to bottom, #e8b020, #c07808); + border: 1px solid rgba(255,225,65,0.55); + animation: tab-pulse 1.2s ease-in-out infinite; +} + +@keyframes tab-pulse { + 0%, 100% { box-shadow: 0 0 5px rgba(220,155,15,0.55), 0 0 2px rgba(255,220,50,0.3); } + 50% { box-shadow: 0 0 13px rgba(240,178,22,0.88), 0 0 6px rgba(255,230,60,0.6); } +} + +/* Valid destination: soft ivory/pearl */ +.field.clickable.dest:not(.selected)::after { + background: rgba(240,230,205,0.88); + border: 1.5px solid rgba(190,165,105,0.65); + box-shadow: 0 0 3px rgba(190,165,105,0.2); +} +.field.clickable.dest:not(.selected):hover::after { + background: rgba(228,210,162,0.95); + border-color: rgba(210,175,40,0.72); + box-shadow: 0 0 7px rgba(210,175,40,0.42); } .field-num { 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 b5f6c8f..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::>>(); @@ -377,7 +385,7 @@ pub fn Board( cls.push_str(" exit-eligible"); } - if seqs_c.is_empty() { + if seqs_c.is_empty() && !is_move_stage { // No restriction (dice not rolled or not move stage) if can_stage && (sel.is_some() || is_mine) { cls.push_str(" clickable"); @@ -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 {