diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index 3de4a9f..732c9d8 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -763,6 +763,202 @@ a:hover { text-decoration: underline; } box-shadow: 0 1px 3px rgba(0,0,0,0.25); } +/* ── Merged scoreboard (both players, above board) ──────────────────── */ +.merged-score-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.5rem 1.25rem 0.45rem; + font-size: 0.88rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + width: 100%; + border-top: 2px solid var(--ui-gold-dark); + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.score-row { + display: flex; + align-items: center; + gap: 1rem; +} + +.score-row-name { + min-width: 120px; + flex-shrink: 0; + display: flex; + align-items: baseline; + gap: 0.35rem; + overflow: hidden; +} + +.you-tag { + font-family: var(--font-ui); + font-size: 0.7rem; + color: #887766; + font-style: italic; + white-space: nowrap; + flex-shrink: 0; +} + +/* ── Jackpot points counter ─────────────────────────────────────────── */ +.pts-counter-wrap { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 72px; + flex-shrink: 0; + padding-bottom: 4px; +} + +.pts-ghost-bar-track { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: rgba(0,0,0,0.07); + border-radius: 2px; + overflow: hidden; +} + +.pts-ghost-bar-fill { + height: 100%; + background: rgba(58,107,42,0.45); + border-radius: 2px; +} + +.pts-ghost-bar-opp { + background: rgba(122,30,42,0.4); +} + +.pts-counter-row { + display: flex; + align-items: baseline; + gap: 0.1rem; +} + +.pts-counter { + font-family: var(--font-display); + font-size: 1.9rem; + font-weight: 600; + color: var(--ui-ink); + line-height: 1; + font-variant-numeric: tabular-nums; + min-width: 1.4em; + text-align: right; +} + +.pts-max { + font-family: var(--font-ui); + font-size: 0.7rem; + color: #998877; + line-height: 1; + padding-bottom: 2px; +} + +/* ── Hole pegs — larger and coloured (me = green, opp = red) ─────────── */ +.merged-score-panel .peg-track { + gap: 4px; +} + +.merged-score-panel .peg-hole { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.3); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.merged-score-panel .peg-hole.filled { + background: #5aab38; + border-color: #3a7828; + box-shadow: 0 0 5px rgba(90,171,56,0.55); +} + +.merged-score-panel .peg-hole.peg-opp.filled { + background: #c05030; + border-color: #8a3018; + box-shadow: 0 0 5px rgba(192,80,48,0.55); +} + +/* Peg pop-in animation when a new hole is scored */ +@keyframes peg-pop { + 0% { transform: scale(0.15); opacity: 0; } + 45% { transform: scale(1.55); } + 70% { transform: scale(0.88); } + 100% { transform: scale(1.0); opacity: 1; } +} + +.merged-score-panel .peg-hole.peg-new { + animation: peg-pop 0.52s cubic-bezier(0.22, 0.61, 0.36, 1) forwards; +} + +/* Thin separator between the two player rows */ +.score-row-sep { + height: 1px; + background: rgba(0,0,0,0.07); + margin: 0.05rem 0; +} + +/* ── Non-blocking hole flash (replaces old toast) ───────────────────── */ +@keyframes hole-flash-in-out { + 0% { opacity: 0; transform: translateY(-3px); } + 14% { opacity: 1; transform: translateY(0); } + 65% { opacity: 1; } + 100% { opacity: 0; transform: translateY(2px); } +} + +.hole-flash { + margin-left: auto; + flex-shrink: 0; + white-space: nowrap; + font-family: var(--font-display); + font-size: 0.88rem; + font-weight: 600; + color: var(--ui-green-accent); + letter-spacing: 0.05em; + animation: hole-flash-in-out 2.5s ease-out forwards; + pointer-events: none; +} + +.hole-flash.hole-flash-bredouille { + color: var(--ui-gold-dark); +} + +/* ── Game bottom strip — status, hints, buttons on cream ────────────── */ +.game-bottom-strip { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.55rem 1.25rem 0.65rem; + width: 100%; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + border-top: 2px solid var(--ui-gold-dark); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + min-height: 3.2rem; +} + +/* Override text colours for the parchment background context */ +.game-bottom-strip .game-status { + color: var(--ui-ink); + text-shadow: none; + padding: 0; + font-size: 1.05rem; + width: auto; +} + +.game-bottom-strip .game-sub-prompt { + color: #887766; + padding: 0; + width: auto; +} + /* ── Board + side panel ─────────────────────────────────────────────── */ .board-and-panel { position: relative; @@ -983,6 +1179,30 @@ a:hover { text-decoration: underline; } cursor: pointer; } +/* ── Inline scoring panel (in bottom strip, expands downward) ───────── */ +@keyframes scoring-expand-in { + from { opacity: 0; transform: translateY(-5px); } + to { opacity: 1; transform: translateY(0); } +} + +.scoring-panel-wrapper.scoring-panel-inline-wrap { + animation: none; + filter: none; + transform: none !important; + pointer-events: auto; + cursor: default; + width: 100%; +} + +.scoring-panel-wrapper.scoring-panel-inline-wrap .scoring-panel { + animation: scoring-expand-in 0.28s ease-out; + background: transparent; + box-shadow: none; + border-radius: 0; + border-top: 1px solid rgba(0,0,0,0.07); + padding: 0.4rem 0; +} + .scoring-panel { background: var(--ui-parchment); border-radius: 5px; diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs index 56cd39b..219ee4a 100644 --- a/clients/web/src/game/components/game_screen.rs +++ b/clients/web/src/game/components/game_screen.rs @@ -12,7 +12,7 @@ use crate::i18n::*; use crate::portal::lobby::{qr_svg, room_url}; use super::board::Board; -use super::score_panel::PlayerScorePanel; +use super::score_panel::MergedScorePanel; use super::scoring::ScoringPanel; #[component] @@ -149,10 +149,17 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // ── Scoring notifications ────────────────────────────────────────────────── let my_scored_event = state.my_scored_event.clone(); let opp_scored_event = state.opp_scored_event.clone(); - let hole_toast_info = my_scored_event - .as_ref() - .filter(|e| e.holes_gained > 0) - .map(|e| (e.holes_total, e.bredouille)); + + // Values for MergedScorePanel — extracted before events are consumed. + // Don't animate points when a hole was gained (points wrap around 12). + let my_pts_earned: u8 = my_scored_event.as_ref() + .map_or(0, |e| if e.holes_gained == 0 { e.points_earned } else { 0 }); + let opp_pts_earned: u8 = opp_scored_event.as_ref() + .map_or(0, |e| if e.holes_gained == 0 { e.points_earned } else { 0 }); + let my_holes_gained_score: u8 = my_scored_event.as_ref().map_or(0, |e| e.holes_gained); + let opp_holes_gained_score: u8 = opp_scored_event.as_ref().map_or(0, |e| e.holes_gained); + let my_bredouille_flash: bool = my_scored_event.as_ref() + .map_or(false, |e| e.bredouille && e.holes_gained > 0); let is_double_dice = dice.0 == dice.1 && dice.0 != 0; @@ -199,12 +206,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { if last_moves.is_some() { crate::game::sound::play_checker_move(); } - // Scoring: hole takes priority over plain points. + // Scoring: hole fanfare plays immediately; per-point ticks are driven by + // MergedScorePanel's counter animation so play_points_scored is not called here. if let Some(ref ev) = my_scored_event { if ev.holes_gained > 0 { crate::game::sound::play_hole_scored(); - } else { - crate::game::sound::play_points_scored(); } } @@ -281,124 +287,120 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { })} - // ── Opponent score (above board) ───────────────────────────────── - + // ── Merged scoreboard (both players, above board) ──────────────── + - // ── Status bar — full width, above board (§10b) ────────────────── -
- {move || { - if let Some(ref reason) = pause_reason { - return String::from(match reason { - PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll), - PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go), - PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move), - PauseReason::AfterOpponentPreGameRoll => t_string!(i18n, after_opponent_pre_game_roll), - }); - } - let n = staged_moves.get().len(); - if is_move_stage { - t_string!(i18n, select_move, n = n + 1) - } else { - String::from(match (&stage, is_my_turn, &turn_stage) { - (SerStage::Ended, _, _) => t_string!(i18n, game_over), - (SerStage::PreGame, _, _) | (SerStage::PreGameRoll, _, _) => t_string!(i18n, waiting_for_opponent), - (SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll), - (SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go), - (SerStage::InGame, true, _) => t_string!(i18n, your_turn), - (SerStage::InGame, false, _) => t_string!(i18n, opponent_turn), - }) - } - }} -
+ // ── Board ──────────────────────────────────────────────────────── + - // ── Contextual sub-prompt (§8a) ────────────────────────────────── - {move || { - let hint: String = if waiting_for_confirm { - t_string!(i18n, hint_continue).to_owned() - } else if is_move_stage { - t_string!(i18n, hint_move).to_owned() - } else if is_my_turn && turn_stage_for_sub == SerTurnStage::HoldOrGoChoice { - t_string!(i18n, hint_hold_or_go).to_owned() - } else { - String::new() - }; - (!hint.is_empty()).then(|| view! {

{hint}

}) - }} - - // ── Board + side panel ─────────────────────────────────────────── -
- - - // ── Side panel (scoring panels only) ───────────────────────── -
- {my_scored_event.map(|event| view! { - - })} - {opp_scored_event.map(|event| view! { - - })} -
-
- - // ── Action buttons below board (§10c) ──────────────────────────── -
- {waiting_for_confirm.then(|| view! { - - })} - // Fallback Go button when no scoring panel (e.g. after reconnect) - {show_hold_go.then(|| view! { - - })} - {move || { - // Show the empty-move button only when (0,0) is a valid - // first or second move given what has already been staged. - let staged = staged_moves.get(); - let show = is_move_stage && staged.len() < 2 && ( - valid_seqs_empty.is_empty() || match staged.len() { - 0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0), - 1 => { - let (f0, t0) = staged[0]; - valid_seqs_empty.iter() - .filter(|(m1, _)| { - m1.get_from() as u8 == f0 - && m1.get_to() as u8 == t0 - }) - .any(|(_, m2)| m2.get_from() == 0) - } - _ => false, + // ── Status, hints, and actions — cream strip below board (§10b/c) ─ +
+
+ {move || { + if let Some(ref reason) = pause_reason { + return String::from(match reason { + PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll), + PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go), + PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move), + PauseReason::AfterOpponentPreGameRoll => t_string!(i18n, after_opponent_pre_game_roll), + }); } - ); - show.then(|| view! { - - }) + let n = staged_moves.get().len(); + if is_move_stage { + t_string!(i18n, select_move, n = n + 1) + } else { + String::from(match (&stage, is_my_turn, &turn_stage) { + (SerStage::Ended, _, _) => t_string!(i18n, game_over), + (SerStage::PreGame, _, _) | (SerStage::PreGameRoll, _, _) => t_string!(i18n, waiting_for_opponent), + (SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll), + (SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go), + (SerStage::InGame, true, _) => t_string!(i18n, your_turn), + (SerStage::InGame, false, _) => t_string!(i18n, opponent_turn), + }) + } + }} +
+ {move || { + let hint: String = if waiting_for_confirm { + t_string!(i18n, hint_continue).to_owned() + } else if is_move_stage { + t_string!(i18n, hint_move).to_owned() + } else if is_my_turn && turn_stage_for_sub == SerTurnStage::HoldOrGoChoice { + t_string!(i18n, hint_hold_or_go).to_owned() + } else { + String::new() + }; + (!hint.is_empty()).then(|| view! {

{hint}

}) }} +
+ {waiting_for_confirm.then(|| view! { + + })} + // Fallback Go button when no scoring panel (e.g. after reconnect) + {show_hold_go.then(|| view! { + + })} + {move || { + let staged = staged_moves.get(); + let show = is_move_stage && staged.len() < 2 && ( + valid_seqs_empty.is_empty() || match staged.len() { + 0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0), + 1 => { + let (f0, t0) = staged[0]; + valid_seqs_empty.iter() + .filter(|(m1, _)| { + m1.get_from() as u8 == f0 + && m1.get_to() as u8 == t0 + }) + .any(|(_, m2)| m2.get_from() == 0) + } + _ => false, + } + ); + show.then(|| view! { + + }) + }} +
+ // ── Scoring detail panels — expand downward in the strip ────── + {my_scored_event.map(|event| view! { + + })} + {opp_scored_event.map(|event| view! { + + })}
- // ── Player score (below board) ──────────────────────────────────── - - // ── Pre-game ceremony overlay ───────────────────────────────────── {is_ceremony.then(|| { let pgr = pre_game_roll_data.unwrap_or(PreGameRollState { @@ -483,16 +485,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { } })} - // ── Hole toast (§6a) — board-centered overlay when a hole is won ── - {hole_toast_info.map(|(holes_total, bredouille)| view! { -
-
"Trou !"
-
{format!("{holes_total} / 12")}
- {bredouille.then(|| view! { -
"× 2 bredouille"
- })} -
- })}
} } diff --git a/clients/web/src/game/components/score_panel.rs b/clients/web/src/game/components/score_panel.rs index 2ce14ef..35dda0f 100644 --- a/clients/web/src/game/components/score_panel.rs +++ b/clients/web/src/game/components/score_panel.rs @@ -1,5 +1,11 @@ use leptos::prelude::*; use trictrac_store::Jan; +#[cfg(target_arch = "wasm32")] +use gloo_timers::future::TimeoutFuture; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_futures::spawn_local; +#[cfg(target_arch = "wasm32")] +use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; use crate::i18n::*; use crate::game::trictrac::types::PlayerScore; @@ -23,47 +29,193 @@ pub fn jan_label(jan: &Jan) -> String { } } +/// Merged scoreboard showing both players above the board. +/// +/// - Two stacked rows for a clear race-to-12 visual comparison. +/// - Points shown as an animated jackpot counter (ticks up on each new point). +/// - Hole pegs are larger and use green (me) / red (opponent) instead of gold. +/// - When a hole is gained, the new peg pops in and a brief non-blocking label +/// appears instead of the old blocking toast popup. #[component] -pub fn PlayerScorePanel(score: PlayerScore, is_you: bool) -> impl IntoView { +pub fn MergedScorePanel( + my_score: PlayerScore, + opp_score: PlayerScore, + /// Points just earned this turn; 0 = no animation. Set to 0 when a hole + /// was gained (points wrap around 12, counter stays at end value). + #[prop(default = 0)] my_points_earned: u8, + #[prop(default = 0)] opp_points_earned: u8, + /// Non-zero when a new hole was just scored (triggers peg-pop animation). + #[prop(default = 0)] my_holes_gained: u8, + #[prop(default = 0)] opp_holes_gained: u8, + /// True when my hole was scored under bredouille (shows ×2 in the flash). + #[prop(default = false)] my_bredouille: bool, +) -> impl IntoView { let i18n = use_i18n(); - let points_pct = format!("{}%", (score.points as u32 * 100 / 12).min(100)); - let points_val = format!("{}/12", score.points); - let holes = score.holes; - let can_bredouille = score.can_bredouille; + // ── Points counter signals ────────────────────────────────────────────── + // When no hole was gained: start from (current - earned) and tick up. + // When a hole was gained: points wrapped around 12, so skip the animation. + // On non-WASM there is no animation; start directly at the final value. + // Suppress the unused-variable warning for animation-only params. + #[cfg(not(target_arch = "wasm32"))] + let _ = (my_points_earned, opp_points_earned); + #[cfg(not(target_arch = "wasm32"))] + let my_pts_start = my_score.points; + #[cfg(target_arch = "wasm32")] + let my_pts_start = if my_holes_gained == 0 { + my_score.points.saturating_sub(my_points_earned) + } else { + my_score.points + }; + let my_displayed_pts: RwSignal = RwSignal::new(my_pts_start); - // 12 peg holes; filled up to `holes` - let pegs: Vec = (1u8..=12) + #[cfg(not(target_arch = "wasm32"))] + let opp_pts_start = opp_score.points; + #[cfg(target_arch = "wasm32")] + let opp_pts_start = if opp_holes_gained == 0 { + opp_score.points.saturating_sub(opp_points_earned) + } else { + opp_score.points + }; + let opp_displayed_pts: RwSignal = RwSignal::new(opp_pts_start); + + // ── Jackpot counter animation (WASM only) ─────────────────────────────── + #[cfg(target_arch = "wasm32")] + { + let my_pts_end = my_score.points; + if my_pts_start < my_pts_end { + let is_alive = Arc::new(AtomicBool::new(true)); + let alive_c = is_alive.clone(); + on_cleanup(move || alive_c.store(false, Ordering::Relaxed)); + spawn_local(async move { + for p in (my_pts_start + 1)..=my_pts_end { + TimeoutFuture::new(200).await; + if !is_alive.load(Ordering::Relaxed) { return; } + my_displayed_pts.set(p); + crate::game::sound::play_points_tick(); + } + }); + } + let opp_pts_end = opp_score.points; + if opp_pts_start < opp_pts_end { + let is_alive = Arc::new(AtomicBool::new(true)); + let alive_c = is_alive.clone(); + on_cleanup(move || alive_c.store(false, Ordering::Relaxed)); + spawn_local(async move { + for p in (opp_pts_start + 1)..=opp_pts_end { + TimeoutFuture::new(200).await; + if !is_alive.load(Ordering::Relaxed) { return; } + opp_displayed_pts.set(p); + } + }); + } + } + + // ── Ghost bar widths (show the end value immediately — static reference) ─ + let my_bar_style = format!("width:{}%", (my_score.points as u32 * 100 / 12).min(100)); + let opp_bar_style = format!("width:{}%", (opp_score.points as u32 * 100 / 12).min(100)); + + // ── Hole peg tracks ───────────────────────────────────────────────────── + let my_holes = my_score.holes; + let opp_holes = opp_score.holes; + + let my_pegs: Vec = (1u8..=12) .map(|i| { - let cls = if i <= holes { "peg-hole filled" } else { "peg-hole" }; - view! {
}.into_any() + let filled = i <= my_holes; + let is_new = filled && i == my_holes && my_holes_gained > 0; + view! { +
+
+ }.into_any() }) .collect(); + let opp_pegs: Vec = (1u8..=12) + .map(|i| { + let filled = i <= opp_holes; + let is_new = filled && i == opp_holes && opp_holes_gained > 0; + view! { +
+
+ }.into_any() + }) + .collect(); + + let my_name = my_score.name.clone(); + let opp_name = opp_score.name.clone(); + let my_can_bredouille = my_score.can_bredouille; + let opp_can_bredouille = opp_score.can_bredouille; + view! { -
-
- - {score.name} - {is_you.then(|| t!(i18n, you_suffix))} - -
-
-
- {t!(i18n, points_label)} -
-
+
+ + // ── My player row ─────────────────────────────────────────── +
+
+ {my_name} + {t!(i18n, you_suffix)} +
+
+
+
+
+
+ {move || my_displayed_pts.get()} + "/12"
- {points_val} - {can_bredouille.then(|| view! { - "B" - })}
-
- {t!(i18n, holes_label)} -
{pegs}
- {format!("{holes}/12")} +
{my_pegs}
+ {my_can_bredouille.then(|| view! { + + "B" + + })} + // Flash sits in the free space to the right of the pegs. + // margin-left:auto keeps it right-aligned inside the flex row + // without adding a new row, so the board never shifts down. + {(my_holes_gained > 0).then(|| { + let label = if my_bredouille { + format!("Trou {} · ×2 bredouille", my_holes) + } else { + format!("Trou {}", my_holes) + }; + view! { +
+ {label} +
+ } + })} +
+ +
+ + // ── Opponent row ──────────────────────────────────────────── +
+
+ {opp_name}
+
+
+
+
+
+ {move || opp_displayed_pts.get()} + "/12" +
+
+
{opp_pegs}
+ {opp_can_bredouille.then(|| view! { + + "B" + + })}
} diff --git a/clients/web/src/game/components/scoring.rs b/clients/web/src/game/components/scoring.rs index d1966be..69e1ca1 100644 --- a/clients/web/src/game/components/scoring.rs +++ b/clients/web/src/game/components/scoring.rs @@ -53,6 +53,9 @@ pub fn ScoringPanel( event: ScoredEvent, turn_stage: SerTurnStage, #[prop(default = false)] is_opponent: bool, + /// When true the panel renders inline in the bottom strip instead of as + /// a right-side overlay: no slide-in animation, no peek/reveal behaviour. + #[prop(default = false)] inline: bool, ) -> impl IntoView { let i18n = use_i18n(); let cmd_tx = use_context::>() @@ -120,31 +123,35 @@ pub fn ScoringPanel( return; } hm.set(vec![]); - peeked.set(true); + // Inline panels are always visible; only the overlay variant peeks. + if !inline { + peeked.set(true); + } }); } let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect(); view! { - // ── Outer wrapper: owns the slide / peek / reveal animation ─────── - // pointer-events are on by default (parent .side-panel sets none, - // and .scoring-panel-wrapper overrides back to auto in CSS). + // ── Outer wrapper: overlay mode has slide/peek/reveal animation; + // inline mode suppresses all of that via scoring-panel-inline-wrap.