From 01fa837b84ff2a93ce01f4cf83e66806c322a063 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sat, 28 Mar 2026 17:17:01 +0100 Subject: [PATCH] feat(client_web): show holes & points progress bars --- client_web/assets/style.css | 83 ++++++++++++-- client_web/src/components/game_screen.rs | 129 +++++++-------------- client_web/src/components/mod.rs | 2 - client_web/src/components/score_panel.rs | 138 ++++++++++++++++++++--- client_web/src/trictrac/types.rs | 8 +- 5 files changed, 242 insertions(+), 118 deletions(-) diff --git a/client_web/assets/style.css b/client_web/assets/style.css index 8d26496..ab34c7e 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -70,18 +70,85 @@ input[type="text"] { cursor: pointer; } -/* ── Score panel ────────────────────────────────────────────────────── */ -.score-panel { - display: flex; - gap: 2rem; +/* ── Player score panel ─────────────────────────────────────────────── */ +.player-score-panel { background: #f5edd8; border-radius: 6px; - padding: 0.5rem 1.5rem; - font-size: 0.95rem; + padding: 0.5rem 1rem; + font-size: 0.9rem; box-shadow: 0 1px 4px rgba(0,0,0,0.2); + width: 100%; + max-width: 900px; +} + +.player-score-header { + margin-bottom: 0.3rem; +} + +.player-name { + font-weight: bold; + font-size: 1rem; +} + +.score-bars { + display: flex; + flex-direction: column; + gap: 4px; +} + +.score-bar-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.score-bar-label { + font-size: 0.8rem; + color: #555; + width: 3.5rem; + text-align: right; + flex-shrink: 0; +} + +.score-bar { + width: 140px; + height: 10px; + background: rgba(0,0,0,0.12); + border-radius: 5px; + overflow: hidden; + flex-shrink: 0; +} + +.score-bar-fill { + height: 100%; + border-radius: 5px; + transition: width 0.3s; +} + +.score-bar-points { background: #4a7a3a; } +.score-bar-holes { background: #7a4a2a; } + +.score-bar-value { + font-size: 0.8rem; + color: #444; + min-width: 2.5rem; +} + +.bredouille-badge { + font-size: 0.7rem; + font-weight: bold; + color: #fff; + background: #c07800; + border-radius: 3px; + padding: 0.05em 0.35em; + cursor: default; +} + +.player-jans { + margin-top: 0.35rem; + border-top: 1px solid rgba(0,0,0,0.1); + padding-top: 0.25rem; } -.score-row { display: flex; gap: 1rem; align-items: center; } -.score-name { font-weight: bold; min-width: 80px; } /* ── Status bar ─────────────────────────────────────────────────────── */ .status-bar { diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 236490f..6f0e192 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -1,13 +1,13 @@ use futures::channel::mpsc::UnboundedSender; use leptos::prelude::*; -use trictrac_store::{CheckerMove, Jan}; +use trictrac_store::CheckerMove; use crate::app::{GameUiState, NetCommand}; use crate::trictrac::types::{JanEntry, PlayerAction, SerStage, SerTurnStage}; use super::board::Board; use super::die::Die; -use super::score_panel::ScorePanel; +use super::score_panel::PlayerScorePanel; #[allow(dead_code)] /// Returns (d0_used, d1_used) by matching each staged move's distance to a die. @@ -34,86 +34,31 @@ fn matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) { (d0, d1) } -fn jan_label(jan: &Jan) -> &'static str { - match jan { - Jan::FilledQuarter => "Remplissage", - Jan::TrueHitSmallJan => "Battage à vrai (petit jan)", - Jan::TrueHitBigJan => "Battage à vrai (grand jan)", - Jan::TrueHitOpponentCorner => "Battage coin adverse", - Jan::FirstPlayerToExit => "Premier sorti", - Jan::SixTables => "Six tables", - Jan::TwoTables => "Deux tables", - Jan::Mezeas => "Mezeas", - Jan::FalseHitSmallJan => "Battage à faux (petit jan)", - Jan::FalseHitBigJan => "Battage à faux (grand jan)", - Jan::ContreTwoTables => "Contre deux tables", - Jan::ContreMezeas => "Contre mezeas", - Jan::HelplessMan => "Dame impuissante", - } -} - -fn format_move_pair(m1: CheckerMove, m2: CheckerMove) -> String { - let fmt = |m: CheckerMove| -> String { - let (f, t) = (m.get_from(), m.get_to()); - if f == 0 && t == 0 { - "—".to_string() - } else if t == 0 { - // exit - format!("{f}↑") - } else { - format!("{f}→{t}") - } - }; - format!("{} & {}", fmt(m1), fmt(m2)) -} - -fn jan_row(idx: usize, entry: JanEntry, expanded: RwSignal>) -> impl IntoView { - let row_class = if entry.total >= 0 { - "jan-row jan-expandable jan-positive" - } else { - "jan-row jan-expandable jan-negative" - }; - let label = jan_label(&entry.jan); - let double_tag = if entry.is_double { "double" } else { "simple" }; - let ways_tag = format!("×{}", entry.ways); - let pts_str = if entry.total >= 0 { - format!("+{}", entry.total) - } else { - format!("{}", entry.total) - }; - - let moves = entry.moves.clone(); - - view! { -
-
- {label} - {double_tag} - {ways_tag} - {pts_str} -
- { - let move_lines: Vec<_> = moves.iter() - .map(|&(m1, m2)| { - let text = format_move_pair(m1, m2); - view! {
{text}
} - }) - .collect(); - view! { -
- {move_lines} -
- } +/// Split `dice_jans` into (viewer_jans, opponent_jans). +/// Entries where the active player scores (total >= 0) go to the active player. +/// Entries where the active player loses (total < 0) go to the opponent, with signs flipped. +fn split_jans( + dice_jans: &[JanEntry], + viewer_is_active: bool, +) -> (Vec, Vec) { + let mut mine = Vec::new(); + let mut theirs = Vec::new(); + for e in dice_jans { + if viewer_is_active { + if e.total >= 0 { + mine.push(e.clone()); + } else { + theirs.push(JanEntry { total: -e.total, points_per: -e.points_per, ..e.clone() }); } -
+ } else { + if e.total >= 0 { + theirs.push(e.clone()); + } else { + mine.push(JanEntry { total: -e.total, points_per: -e.points_per, ..e.clone() }); + } + } } + (mine, theirs) } #[component] @@ -174,6 +119,13 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice; let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice; + // ── Jan split: viewer_jans / opponent_jans ───────────────────────────────── + let (my_jans, opp_jans) = split_jans(&vs.dice_jans, is_my_turn); + + // ── Scores: index = mp_player_id ────────────────────────────────────────── + let my_score = vs.scores[player_id as usize].clone(); + let opp_score = vs.scores[1 - player_id as usize].clone(); + view! {
// ── Top bar ────────────────────────────────────────────────────── @@ -182,10 +134,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { "Quit" + }>Quit
- + // ── Opponent score (above board) ───────────────────────────────── + // ── Status ───────────────────────────────────────────────────────
@@ -207,15 +160,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
})} - // ── Jan panel ──────────────────────────────────────────────────── - {(!vs.dice_jans.is_empty()).then(|| { - let expanded: RwSignal> = RwSignal::new(None); - let rows: Vec<_> = vs.dice_jans.iter().enumerate().map(|(i, entry)| { - jan_row(i, entry.clone(), expanded) - }).collect(); - view! {
{rows}
} - })} - // ── Board ──────────────────────────────────────────────────────── impl IntoView { })} })} + + // ── Player score (below board) ──────────────────────────────────── + } } diff --git a/client_web/src/components/mod.rs b/client_web/src/components/mod.rs index e03d7b2..cd5fc33 100644 --- a/client_web/src/components/mod.rs +++ b/client_web/src/components/mod.rs @@ -5,8 +5,6 @@ mod game_screen; mod login_screen; mod score_panel; -pub use die::Die; - pub use connecting_screen::ConnectingScreen; pub use game_screen::GameScreen; pub use login_screen::LoginScreen; diff --git a/client_web/src/components/score_panel.rs b/client_web/src/components/score_panel.rs index 3c73fcd..a56a299 100644 --- a/client_web/src/components/score_panel.rs +++ b/client_web/src/components/score_panel.rs @@ -1,25 +1,135 @@ use leptos::prelude::*; +use trictrac_store::{CheckerMove, Jan}; -use crate::trictrac::types::PlayerScore; +use crate::trictrac::types::{JanEntry, PlayerScore}; +fn jan_label(jan: &Jan) -> &'static str { + match jan { + Jan::FilledQuarter => "Remplissage", + Jan::TrueHitSmallJan => "Battage à vrai (petit jan)", + Jan::TrueHitBigJan => "Battage à vrai (grand jan)", + Jan::TrueHitOpponentCorner => "Battage coin adverse", + Jan::FirstPlayerToExit => "Premier sorti", + Jan::SixTables => "Six tables", + Jan::TwoTables => "Deux tables", + Jan::Mezeas => "Mezeas", + Jan::FalseHitSmallJan => "Battage à faux (petit jan)", + Jan::FalseHitBigJan => "Battage à faux (grand jan)", + Jan::ContreTwoTables => "Contre deux tables", + Jan::ContreMezeas => "Contre mezeas", + Jan::HelplessMan => "Dame impuissante", + } +} + +fn format_move_pair(m1: CheckerMove, m2: CheckerMove) -> String { + let fmt = |m: CheckerMove| -> String { + let (f, t) = (m.get_from(), m.get_to()); + if f == 0 && t == 0 { + "—".to_string() + } else if t == 0 { + format!("{f}↑") + } else { + format!("{f}→{t}") + } + }; + format!("{} & {}", fmt(m1), fmt(m2)) +} + +fn jan_row(idx: usize, entry: JanEntry, expanded: RwSignal>) -> impl IntoView { + let row_class = if entry.total >= 0 { + "jan-row jan-expandable jan-positive" + } else { + "jan-row jan-expandable jan-negative" + }; + let label = jan_label(&entry.jan); + let double_tag = if entry.is_double { "double" } else { "simple" }; + let ways_tag = format!("×{}", entry.ways); + let pts_str = if entry.total >= 0 { + format!("+{}", entry.total) + } else { + format!("{}", entry.total) + }; + + let moves = entry.moves.clone(); + + view! { +
+
+ {label} + {double_tag} + {ways_tag} + {pts_str} +
+ { + let move_lines: Vec<_> = moves.iter() + .map(|&(m1, m2)| { + let text = format_move_pair(m1, m2); + view! {
{text}
} + }) + .collect(); + view! { +
+ {move_lines} +
+ } + } +
+ } +} + +/// One player's score panel: name, progress bars (points & holes), bredouille indicator, +/// and the list of jans scored by this player in the last roll. +/// `jans` should already be filtered and sign-corrected for this player's perspective. #[component] -pub fn ScorePanel(scores: [PlayerScore; 2], player_id: u16) -> impl IntoView { - let rows: Vec<_> = scores +pub fn PlayerScorePanel(score: PlayerScore, jans: Vec, is_you: bool) -> impl IntoView { + let label = if is_you { " (vous)" } else { "" }; + let points_pct = format!("{}%", (score.points as u32 * 100 / 12).min(100)); + let holes_pct = format!("{}%", (score.holes as u32 * 100 / 12).min(100)); + let points_val = format!("{}/12", score.points); + let holes_val = format!("{}/12", score.holes); + let can_bredouille = score.can_bredouille; + + let expanded: RwSignal> = RwSignal::new(None); + let jan_rows: Vec<_> = jans .into_iter() .enumerate() - .map(|(i, score)| { - let label = if i as u16 == player_id { " (you)" } else { "" }; - view! { -
- {score.name}{label} - "Points: "{score.points} - "Holes: "{score.holes} -
- } - }) + .map(|(i, entry)| jan_row(i, entry, expanded)) .collect(); view! { -
{rows}
+
+
+ {score.name}{label} +
+
+
+ "Points" +
+
+
+ {points_val} + {can_bredouille.then(|| view! { + "B" + })} +
+
+ "Trous" +
+
+
+ {holes_val} +
+
+ {(!jan_rows.is_empty()).then(|| view! { +
{jan_rows}
+ })} +
} } diff --git a/client_web/src/trictrac/types.rs b/client_web/src/trictrac/types.rs index 2acf903..02e2675 100644 --- a/client_web/src/trictrac/types.rs +++ b/client_web/src/trictrac/types.rs @@ -68,8 +68,8 @@ impl ViewState { turn_stage: SerTurnStage::RollDice, active_mp_player: None, scores: [ - PlayerScore { name: host_name.to_string(), points: 0, holes: 0 }, - PlayerScore { name: guest_name.to_string(), points: 0, holes: 0 }, + PlayerScore { name: host_name.to_string(), points: 0, holes: 0, can_bredouille: false }, + PlayerScore { name: guest_name.to_string(), points: 0, holes: 0, can_bredouille: false }, ], dice: (0, 0), dice_jans: Vec::new(), @@ -120,8 +120,9 @@ impl ViewState { name: p.name.clone(), points: p.points, holes: p.holes, + can_bredouille: p.can_bredouille, }) - .unwrap_or_else(|| PlayerScore { name: String::new(), points: 0, holes: 0 }) + .unwrap_or_else(|| PlayerScore { name: String::new(), points: 0, holes: 0, can_bredouille: false }) }; // is_double for scoring: dice show the same value (both dice identical). @@ -176,6 +177,7 @@ pub struct PlayerScore { pub name: String, pub points: u8, pub holes: u8, + pub can_bredouille: bool, } // ── Serialisable mirrors of store enums ──────────────────────────────────────