From 0e53d086d42edb733c9508e2327372fcc6119369 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Fri, 27 Mar 2026 11:43:21 +0100 Subject: [PATCH] feat(client_web): dice with dots --- client_web/assets/style.css | 28 +++- client_web/src/components/die.rs | 32 +++++ client_web/src/components/game_screen.rs | 168 ++++++++++++----------- client_web/src/components/mod.rs | 3 + 4 files changed, 150 insertions(+), 81 deletions(-) create mode 100644 client_web/src/components/die.rs diff --git a/client_web/assets/style.css b/client_web/assets/style.css index b8f53b2..45b703c 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -83,7 +83,7 @@ input[type="text"] { .score-row { display: flex; gap: 1rem; align-items: center; } .score-name { font-weight: bold; min-width: 80px; } -/* ── Status / action bars ───────────────────────────────────────────── */ +/* ── Status bar ─────────────────────────────────────────────────────── */ .status-bar { display: flex; gap: 1rem; @@ -91,10 +91,30 @@ input[type="text"] { font-size: 1.05rem; font-weight: 500; } -.dice { font-weight: bold; color: #2a4a8a; } -.dice-used { opacity: 0.35; text-decoration: line-through; } -.action-bar { display: flex; gap: 0.75rem; min-height: 2.5rem; } +/* ── Dice bars ──────────────────────────────────────────────────────── */ +.dice-bar { + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* ── Die face (SVG) ─────────────────────────────────────────────────── */ +.die-face rect { + fill: #fffff0; + stroke: #2a1a00; + stroke-width: 2; +} +.die-face circle { + fill: #1a0a00; +} +.die-face.die-used rect { + fill: #d8d4c8; + stroke: #8a7a60; +} +.die-face.die-used circle { + fill: #8a7a60; +} /* ── Jan panel ──────────────────────────────────────────────────────── */ .jan-panel { diff --git a/client_web/src/components/die.rs b/client_web/src/components/die.rs new file mode 100644 index 0000000..1f83ea9 --- /dev/null +++ b/client_web/src/components/die.rs @@ -0,0 +1,32 @@ +use leptos::prelude::*; + +/// (cx, cy) positions for dots on a 48×48 die face. +fn dot_positions(value: u8) -> &'static [(&'static str, &'static str)] { + match value { + 1 => &[("24", "24")], + 2 => &[("35", "13"), ("13", "35")], + 3 => &[("35", "13"), ("24", "24"), ("13", "35")], + 4 => &[("13", "13"), ("35", "13"), ("13", "35"), ("35", "35")], + 5 => &[("13", "13"), ("35", "13"), ("24", "24"), ("13", "35"), ("35", "35")], + 6 => &[("13", "13"), ("35", "13"), ("13", "24"), ("35", "24"), ("13", "35"), ("35", "35")], + _ => &[], + } +} + +/// A single die face rendered as SVG. +/// `value` 1–6 shows dots; 0 shows an empty face (not-yet-rolled). +/// `used` dims the die. +#[component] +pub fn Die(value: u8, used: bool) -> impl IntoView { + let cls = if used { "die-face die-used" } else { "die-face" }; + let dots: Vec = dot_positions(value) + .iter() + .map(|&(cx, cy)| view! { }.into_any()) + .collect(); + view! { + + + {dots} + + } +} diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 770ed42..322ebae 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -6,6 +6,7 @@ use crate::app::{GameUiState, NetCommand}; use crate::trictrac::types::{PlayerAction, SerStage, SerTurnStage}; use super::board::Board; +use super::die::Die; use super::score_panel::ScorePanel; #[allow(dead_code)] @@ -15,7 +16,11 @@ fn matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) { let mut d0 = false; let mut d1 = false; for &(from, to) in staged { - let dist = to.saturating_sub(from); // 0 for empty/same-field moves + let dist = if from < to { + to.saturating_sub(from) + } else { + from.saturating_sub(to) + }; if !d0 && dist == dice.0 { d0 = true; } else if !d1 && dist == dice.1 { @@ -31,19 +36,19 @@ fn matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) { fn jan_label(jan: &Jan) -> &'static str { match jan { - Jan::FilledQuarter => "Rempli", - Jan::TrueHitSmallJan => "Atteinte vraie (petit jan)", - Jan::TrueHitBigJan => "Atteinte vraie (grand jan)", - Jan::TrueHitOpponentCorner => "Atteinte coin adverse", - Jan::FirstPlayerToExit => "Premier sorti", - Jan::SixTables => "Six tables", - Jan::TwoTables => "Deux tables", - Jan::Mezeas => "Mezeas", - Jan::FalseHitSmallJan => "Faux (petit jan)", - Jan::FalseHitBigJan => "Faux (grand jan)", - Jan::ContreTwoTables => "Contre deux tables", - Jan::ContreMezeas => "Contre mezeas", - Jan::HelplessMan => "Homme en route", + 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", } } @@ -53,16 +58,12 @@ 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); let staged_moves: RwSignal> = RwSignal::new(Vec::new()); - // When both move slots are filled, send the action to the backend. let cmd_tx = use_context::>() .expect("UnboundedSender not found in context"); let cmd_tx_effect = cmd_tx.clone(); @@ -85,35 +86,41 @@ 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(), }, }; let dice = vs.dice; + let show_dice = dice != (0, 0); - // ── Action bar buttons ───────────────────────────────────────────────────── - let cmd_tx2 = cmd_tx.clone(); + // ── Button senders ───────────────────────────────────────────────────────── + let cmd_tx_roll = 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; view! {
+ // ── Top bar ──────────────────────────────────────────────────────
- {state.room_id} + Room: {state.room_id} "Quit"
+ + + // ── Status ───────────────────────────────────────────────────────
{move || { if is_move_stage { @@ -123,57 +130,21 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { status.clone() } }} - {(dice != (0, 0)).then(|| view! { - "Dice: " - {dice.0} - " & " - {dice.1} - })} -
-
- {show_roll.then(|| view! { - - })} - {show_hold_go.then(|| view! { - - })} - {is_move_stage.then(|| view! { - - })}
+ + // ── Opponent dice (top) ────────────────────────────────────────── + {(!is_my_turn && show_dice).then(|| view! { +
+ + +
+ })} + + // ── Jan panel ──────────────────────────────────────────────────── {(!vs.dice_jans.is_empty()).then(|| { let rows: Vec<_> = vs.dice_jans.iter().map(|(jan, pts)| { let label = jan_label(jan); - let pts_str = if *pts >= 0 { - format!("+{}", pts) - } else { - format!("{}", pts) - }; + let pts_str = if *pts >= 0 { format!("+{}", pts) } else { format!("{}", pts) }; let row_class = if *pts >= 0 { "jan-row jan-positive" } else { "jan-row jan-negative" }; view! {
@@ -184,12 +155,55 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { }).collect(); view! {
{rows}
} })} + + // ── Board ──────────────────────────────────────────────────────── + + // ── Player action bar (bottom) ─────────────────────────────────── + {is_my_turn.then(|| view! { +
+ // Dice (reactive greying as moves are staged) + {move || { + let (d0, d1) = if is_move_stage { + matched_dice_used(&staged_moves.get(), dice) + } else { + (false, false) + }; + view! { + + + } + }} + // Roll button (shown next to the dice during RollDice stage) + {show_roll.then(|| view! { + + })} + // Go button (HoldOrGoChoice) + {show_hold_go.then(|| view! { + + })} + // Empty move button + {is_move_stage.then(|| view! { + + })} +
+ })}
} } diff --git a/client_web/src/components/mod.rs b/client_web/src/components/mod.rs index 81962c9..e03d7b2 100644 --- a/client_web/src/components/mod.rs +++ b/client_web/src/components/mod.rs @@ -1,9 +1,12 @@ mod board; mod connecting_screen; +mod die; 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;