diff --git a/client_web/assets/style.css b/client_web/assets/style.css index 2edad91..8d26496 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -70,85 +70,18 @@ input[type="text"] { cursor: pointer; } -/* ── Player score panel ─────────────────────────────────────────────── */ -.player-score-panel { +/* ── Score panel ────────────────────────────────────────────────────── */ +.score-panel { + display: flex; + gap: 2rem; background: #f5edd8; border-radius: 6px; - padding: 0.5rem 1rem; - font-size: 0.9rem; + padding: 0.5rem 1.5rem; + font-size: 0.95rem; 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 { @@ -262,7 +195,7 @@ input[type="text"] { /* ── Fields ─────────────────────────────────────────────────────────── */ .field { width: 60px; - height: 180px; + height: 110px; background: #d4a843; border-radius: 4px; display: flex; @@ -295,34 +228,25 @@ input[type="text"] { .top-row .field-num { bottom: auto; top: 2px; } /* ── Checkers ───────────────────────────────────────────────────────── */ -.checker-stack { - display: flex; - flex-direction: column; - align-items: center; -} - -.checker { - width: 40px; - height: 40px; +.checkers { + width: 46px; + height: 46px; border-radius: 50%; display: flex; align-items: center; justify-content: center; - font-size: 0.8rem; + font-size: 1rem; 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 1px 3px rgba(0,0,0,0.3); - flex-shrink: 0; + box-shadow: inset 0 2px 4px rgba(255,255,255,0.3), 0 2px 4px rgba(0,0,0,0.3); } -.checker + .checker { margin-top: 2px; } - -.checker.white { +.checkers.white { background: radial-gradient(circle at 35% 35%, #ffffff, #cccccc); color: #333; } -.checker.black { +.checkers.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 0ec3040..a0ae393 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,12 +25,8 @@ 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 } @@ -46,83 +42,66 @@ 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], 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 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 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 + let mut cls = "field".to_string(); + if can_stage && (sel.is_some() || is_mine) { + cls.push_str(" clickable"); } - 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 => {} - } + if sel == Some(field_num) { cls.push_str(" selected"); } + if can_stage && sel.is_some() && sel != Some(field_num) { + cls.push_str(" dest"); } - > - {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() + 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); + } + 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" }; + view! { {count} } + }) + }} +
+ } + .into_any() + }) + .collect() }; let (tl, tr, bl, br) = if is_white { @@ -134,15 +113,15 @@ pub fn Board( view! {
-
{fields_from(tl, true)}
+
{fields_from(tl)}
-
{fields_from(tr, true)}
+
{fields_from(tr)}
-
{fields_from(bl, false)}
+
{fields_from(bl)}
-
{fields_from(br, false)}
+
{fields_from(br)}
} diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 6f0e192..6b81b70 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; +use trictrac_store::{CheckerMove, Jan}; 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::PlayerScorePanel; +use super::score_panel::ScorePanel; #[allow(dead_code)] /// Returns (d0_used, d1_used) by matching each staged move's distance to a die. @@ -34,31 +34,77 @@ fn matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) { (d0, d1) } -/// 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() }); - } - } +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} +
+ } + })} +
} - (mine, theirs) } #[component] @@ -67,10 +113,7 @@ 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); @@ -98,14 +141,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(), }, }; @@ -114,17 +157,10 @@ 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; - - // ── 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(); + let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice; + let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice; view! {
@@ -134,11 +170,10 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { Quit + }>"Quit"
- // ── Opponent score (above board) ───────────────────────────────── - + // ── Status ───────────────────────────────────────────────────────
@@ -160,6 +195,15 @@ 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 cd5fc33..e03d7b2 100644 --- a/client_web/src/components/mod.rs +++ b/client_web/src/components/mod.rs @@ -5,6 +5,8 @@ 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 a56a299..3c73fcd 100644 --- a/client_web/src/components/score_panel.rs +++ b/client_web/src/components/score_panel.rs @@ -1,135 +1,25 @@ use leptos::prelude::*; -use trictrac_store::{CheckerMove, Jan}; -use crate::trictrac::types::{JanEntry, PlayerScore}; +use crate::trictrac::types::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 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 +pub fn ScorePanel(scores: [PlayerScore; 2], player_id: u16) -> impl IntoView { + let rows: Vec<_> = scores .into_iter() .enumerate() - .map(|(i, entry)| jan_row(i, entry, expanded)) + .map(|(i, score)| { + let label = if i as u16 == player_id { " (you)" } else { "" }; + view! { +
+ {score.name}{label} + "Points: "{score.points} + "Holes: "{score.holes} +
+ } + }) .collect(); view! { -
-
- {score.name}{label} -
-
-
- "Points" -
-
-
- {points_val} - {can_bredouille.then(|| view! { - "B" - })} -
-
- "Trous" -
-
-
- {holes_val} -
-
- {(!jan_rows.is_empty()).then(|| view! { -
{jan_rows}
- })} -
+
{rows}
} } diff --git a/client_web/src/trictrac/types.rs b/client_web/src/trictrac/types.rs index 02e2675..2acf903 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, can_bredouille: false }, - PlayerScore { name: guest_name.to_string(), points: 0, holes: 0, can_bredouille: false }, + PlayerScore { name: host_name.to_string(), points: 0, holes: 0 }, + PlayerScore { name: guest_name.to_string(), points: 0, holes: 0 }, ], dice: (0, 0), dice_jans: Vec::new(), @@ -120,9 +120,8 @@ 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, can_bredouille: false }) + .unwrap_or_else(|| PlayerScore { name: String::new(), points: 0, holes: 0 }) }; // is_double for scoring: dice show the same value (both dice identical). @@ -177,7 +176,6 @@ 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 57f69dd..e4e938c 100644 --- a/store/src/game.rs +++ b/store/src/game.rs @@ -987,12 +987,7 @@ impl GameState { player.color, self.board, dice, player.dice_roll_count ); let points_rules = PointsRules::new(&player.color, &self.board, *dice); - let (jans, points) = points_rules.get_result_jans(player.dice_roll_count); - Ok(if player.color == Color::White { - (jans, points) - } else { - (jans.mirror(), points) - }) + Ok(points_rules.get_result_jans(player.dice_roll_count)) } /// Determines if someone has won the game