diff --git a/client_web/assets/style.css b/client_web/assets/style.css index 8d26496..2edad91 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 { @@ -195,7 +262,7 @@ input[type="text"] { /* ── Fields ─────────────────────────────────────────────────────────── */ .field { width: 60px; - height: 110px; + height: 180px; background: #d4a843; border-radius: 4px; display: flex; @@ -228,25 +295,34 @@ input[type="text"] { .top-row .field-num { bottom: auto; top: 2px; } /* ── Checkers ───────────────────────────────────────────────────────── */ -.checkers { - width: 46px; - height: 46px; +.checker-stack { + display: flex; + flex-direction: column; + align-items: center; +} + +.checker { + width: 40px; + height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; - font-size: 1rem; + font-size: 0.8rem; font-weight: bold; border: 2px solid rgba(0,0,0,0.3); - box-shadow: inset 0 2px 4px rgba(255,255,255,0.3), 0 2px 4px rgba(0,0,0,0.3); + box-shadow: inset 0 2px 4px rgba(255,255,255,0.3), 0 1px 3px rgba(0,0,0,0.3); + flex-shrink: 0; } -.checkers.white { +.checker + .checker { margin-top: 2px; } + +.checker.white { background: radial-gradient(circle at 35% 35%, #ffffff, #cccccc); color: #333; } -.checkers.black { +.checker.black { background: radial-gradient(circle at 35% 35%, #555555, #111111); color: #eee; } diff --git a/client_web/src/components/board.rs b/client_web/src/components/board.rs index a0ae393..0ec3040 100644 --- a/client_web/src/components/board.rs +++ b/client_web/src/components/board.rs @@ -3,15 +3,15 @@ use leptos::prelude::*; use crate::trictrac::types::{SerTurnStage, ViewState}; /// Field numbers in visual display order (left-to-right for each quarter), white's perspective. -const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18]; +const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18]; const TOP_RIGHT_W: [u8; 6] = [19, 20, 21, 22, 23, 24]; -const BOT_LEFT_W: [u8; 6] = [12, 11, 10, 9, 8, 7]; -const BOT_RIGHT_W: [u8; 6] = [ 6, 5, 4, 3, 2, 1]; +const BOT_LEFT_W: [u8; 6] = [12, 11, 10, 9, 8, 7]; +const BOT_RIGHT_W: [u8; 6] = [6, 5, 4, 3, 2, 1]; /// 180° rotation of white's layout: black's pieces (field 24) appear at the bottom. -const TOP_LEFT_B: [u8; 6] = [ 1, 2, 3, 4, 5, 6]; -const TOP_RIGHT_B: [u8; 6] = [ 7, 8, 9, 10, 11, 12]; -const BOT_LEFT_B: [u8; 6] = [24, 23, 22, 21, 20, 19]; +const TOP_LEFT_B: [u8; 6] = [1, 2, 3, 4, 5, 6]; +const TOP_RIGHT_B: [u8; 6] = [7, 8, 9, 10, 11, 12]; +const BOT_LEFT_B: [u8; 6] = [24, 23, 22, 21, 20, 19]; const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13]; /// Returns the displayed board value for `field_num` after applying `staged_moves`. @@ -25,8 +25,12 @@ fn displayed_value( let mut val = base_board[(field_num - 1) as usize]; let delta: i8 = if is_white { 1 } else { -1 }; for &(from, to) in staged_moves { - if from == field_num { val -= delta; } - if to == field_num { val += delta; } + if from == field_num { + val -= delta; + } + if to == field_num { + val += delta; + } } val } @@ -42,66 +46,83 @@ pub fn Board( ) -> impl IntoView { let board = view_state.board; let is_move_stage = view_state.active_mp_player == Some(player_id) - && matches!(view_state.turn_stage, SerTurnStage::Move | SerTurnStage::HoldOrGoChoice); + && matches!( + view_state.turn_stage, + SerTurnStage::Move | SerTurnStage::HoldOrGoChoice + ); let is_white = player_id == 0; - let fields_from = |nums: &[u8]| -> Vec { - nums.iter().map(|&field_num| { - view! { -
0 } else { val < 0 }; - let can_stage = is_move_stage && moves.len() < 2; - let sel = selected_origin.get(); + let fields_from = |nums: &[u8], is_top_row: bool| -> Vec { + nums.iter() + .map(|&field_num| { + view! { +
0 } else { val < 0 }; + let can_stage = is_move_stage && moves.len() < 2; + let sel = selected_origin.get(); - let mut cls = "field".to_string(); - if can_stage && (sel.is_some() || is_mine) { - cls.push_str(" clickable"); - } - if sel == Some(field_num) { cls.push_str(" selected"); } - if can_stage && sel.is_some() && sel != Some(field_num) { - cls.push_str(" dest"); - } - cls - } - on:click=move |_| { - if !is_move_stage { return; } - if staged_moves.get_untracked().len() >= 2 { return; } - - let moves = staged_moves.get_untracked(); - let val = displayed_value(board, &moves, is_white, field_num); - let is_mine = if is_white { val > 0 } else { val < 0 }; - - match selected_origin.get_untracked() { - Some(origin) if origin == field_num => { - selected_origin.set(None); + let mut cls = "field".to_string(); + if can_stage && (sel.is_some() || is_mine) { + cls.push_str(" clickable"); } - Some(origin) => { - staged_moves.update(|v| v.push((origin, field_num))); - selected_origin.set(None); + if sel == Some(field_num) { cls.push_str(" selected"); } + if can_stage && sel.is_some() && sel != Some(field_num) { + cls.push_str(" dest"); } - None if is_mine => selected_origin.set(Some(field_num)), - None => {} + cls } - } - > - {field_num} - {move || { - let moves = staged_moves.get(); - let val = displayed_value(board, &moves, is_white, field_num); - let count = val.unsigned_abs(); - (count > 0).then(|| { - let color = if val > 0 { "white" } else { "black" }; - view! { {count} } - }) - }} -
- } - .into_any() - }) - .collect() + on:click=move |_| { + if !is_move_stage { return; } + if staged_moves.get_untracked().len() >= 2 { return; } + + let moves = staged_moves.get_untracked(); + let val = displayed_value(board, &moves, is_white, field_num); + let is_mine = if is_white { val > 0 } else { val < 0 }; + + match selected_origin.get_untracked() { + Some(origin) if origin == field_num => { + selected_origin.set(None); + } + Some(origin) => { + staged_moves.update(|v| v.push((origin, field_num))); + selected_origin.set(None); + } + None if is_mine => selected_origin.set(Some(field_num)), + None => {} + } + } + > + {field_num} + {move || { + let moves = staged_moves.get(); + let val = displayed_value(board, &moves, is_white, field_num); + let count = val.unsigned_abs(); + (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. + let outer_idx = if is_top_row { display_n - 1 } else { 0 }; + let chips: Vec = (0..display_n).map(|i| { + let label = if i == outer_idx && count >= 5 { + count.to_string() + } else { + String::new() + }; + view! { +
{label}
+ }.into_any() + }).collect(); + view! {
{chips}
} + }) + }} +
+ } + .into_any() + }) + .collect() }; let (tl, tr, bl, br) = if is_white { @@ -113,15 +134,15 @@ pub fn Board( view! {
-
{fields_from(tl)}
+
{fields_from(tl, true)}
-
{fields_from(tr)}
+
{fields_from(tr, true)}
-
{fields_from(bl)}
+
{fields_from(bl, false)}
-
{fields_from(br)}
+
{fields_from(br, false)}
} diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 6b81b70..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,77 +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 { format!("{f}↑") } // exit - 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-positive" } else { "jan-row 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 can_expand = entry.ways > 1; - let moves = entry.moves.clone(); - - view! { -
-
- {label} - {double_tag} - {ways_tag} - {pts_str} -
- {can_expand.then(|| { - 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] @@ -113,7 +67,10 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let player_id = state.player_id; let is_my_turn = vs.active_mp_player == Some(player_id); let is_move_stage = is_my_turn - && matches!(vs.turn_stage, SerTurnStage::Move | SerTurnStage::HoldOrGoChoice); + && matches!( + vs.turn_stage, + SerTurnStage::Move | SerTurnStage::HoldOrGoChoice + ); // ── Staged move state ────────────────────────────────────────────────────── let selected_origin: RwSignal> = RwSignal::new(None); @@ -141,14 +98,14 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // ── Status text ──────────────────────────────────────────────────────────── let status = match &vs.stage { - SerStage::Ended => "Game over".to_string(), + SerStage::Ended => "Game over".to_string(), SerStage::PreGame => "Waiting for opponent…".to_string(), - SerStage::InGame => match (is_my_turn, &vs.turn_stage) { - (true, SerTurnStage::RollDice) => "Your turn — roll the dice".to_string(), + SerStage::InGame => match (is_my_turn, &vs.turn_stage) { + (true, SerTurnStage::RollDice) => "Your turn — roll the dice".to_string(), (true, SerTurnStage::HoldOrGoChoice) => "Hold or Go?".to_string(), - (true, SerTurnStage::Move) => "Select move 1 of 2".to_string(), - (true, _) => "Your turn".to_string(), - (false, _) => "Opponent's turn".to_string(), + (true, SerTurnStage::Move) => "Select move 1 of 2".to_string(), + (true, _) => "Your turn".to_string(), + (false, _) => "Opponent's turn".to_string(), }, }; @@ -157,10 +114,17 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // ── Button senders ───────────────────────────────────────────────────────── let cmd_tx_roll = cmd_tx.clone(); - let cmd_tx_go = cmd_tx.clone(); + let cmd_tx_go = cmd_tx.clone(); let cmd_tx_quit = cmd_tx.clone(); - let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice; - let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice; + 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! {
@@ -170,10 +134,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { "Quit" + }>Quit
- + // ── Opponent score (above board) ───────────────────────────────── + // ── Status ───────────────────────────────────────────────────────
@@ -195,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 ────────────────────────────────────── diff --git a/store/src/game.rs b/store/src/game.rs index e4e938c..57f69dd 100644 --- a/store/src/game.rs +++ b/store/src/game.rs @@ -987,7 +987,12 @@ impl GameState { player.color, self.board, dice, player.dice_roll_count ); let points_rules = PointsRules::new(&player.color, &self.board, *dice); - Ok(points_rules.get_result_jans(player.dice_roll_count)) + let (jans, points) = points_rules.get_result_jans(player.dice_roll_count); + Ok(if player.color == Color::White { + (jans, points) + } else { + (jans.mirror(), points) + }) } /// Determines if someone has won the game