From 3b9a1277d84b12afe0b038f7c81ac0ad0e74df4d Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Tue, 7 Apr 2026 21:32:35 +0200 Subject: [PATCH] feat(client_web): add messages on points gains --- client_web/assets/style.css | 53 +++++++++ client_web/locales/en.json | 8 +- client_web/locales/fr.json | 8 +- client_web/src/app.rs | 136 ++++++++++++++++++----- client_web/src/components/game_screen.rs | 56 ++++------ client_web/src/components/mod.rs | 1 + client_web/src/components/score_panel.rs | 99 +---------------- client_web/src/components/scoring.rs | 103 +++++++++++++++++ client_web/src/trictrac/types.rs | 17 +++ 9 files changed, 320 insertions(+), 161 deletions(-) create mode 100644 client_web/src/components/scoring.rs diff --git a/client_web/assets/style.css b/client_web/assets/style.css index 61d8cec..8b48dbc 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -313,6 +313,59 @@ input[type="text"] { justify-content: center; } +/* ── Scoring notification panel ────────────────────────────────────── */ +.scoring-panel { + background: #f0ead0; + border-radius: 6px; + padding: 0.4rem 0.75rem; + font-size: 0.85rem; + box-shadow: 0 1px 4px rgba(0,0,0,0.15); + border-left: 3px solid #4a7a3a; + display: flex; + flex-direction: column; + gap: 3px; +} + +.scoring-total { + font-weight: bold; + font-size: 0.95rem; + color: #1a5c1a; +} + +.scoring-jan-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 1px 2px; + border-radius: 3px; + cursor: default; +} + +.scoring-jan-row:hover { background: rgba(0,0,0,0.06); } + +.scoring-panel-opp { + border-left-color: #7a5a3a; +} + +.scoring-panel-opp .scoring-total { color: #7a4a2a; } + +.scoring-hole { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: bold; + color: #7a4a2a; + margin-top: 2px; + padding-top: 3px; + border-top: 1px solid rgba(0,0,0,0.1); +} + +.hold-go-buttons { + display: flex; + gap: 0.5rem; + margin-top: 4px; +} + /* ── Board ──────────────────────────────────────────────────────────── */ .board { background: #2e6b2e; diff --git a/client_web/locales/en.json b/client_web/locales/en.json index e5058ef..1fbad6d 100644 --- a/client_web/locales/en.json +++ b/client_web/locales/en.json @@ -42,5 +42,11 @@ "after_opponent_roll": "Opponent rolled", "after_opponent_go": "Opponent chose to continue", "after_opponent_move": "Opponent moved — your turn", - "continue_btn": "Continue" + "continue_btn": "Continue", + "scored_pts": "+{{ n }} pts", + "hole_made": "Hole! {{ holes }}/12", + "bredouille_applied": "Bredouille!", + "hold": "Hold", + "opp_scored_pts": "Opponent +{{ n }} pts", + "opp_hole_made": "Opponent hole! {{ holes }}/12" } diff --git a/client_web/locales/fr.json b/client_web/locales/fr.json index 5d27d02..09ab69b 100644 --- a/client_web/locales/fr.json +++ b/client_web/locales/fr.json @@ -42,5 +42,11 @@ "after_opponent_roll": "L'adversaire a lancé les dés", "after_opponent_go": "L'adversaire s'en va", "after_opponent_move": "L'adversaire a joué — à vous", - "continue_btn": "Continuer" + "continue_btn": "Continuer", + "scored_pts": "+{{ n }} pts", + "hole_made": "Trou ! {{ holes }}/12", + "bredouille_applied": "Bredouille !", + "hold": "Tenir", + "opp_scored_pts": "Adversaire +{{ n }} pts", + "opp_hole_made": "Trou adverse ! {{ holes }}/12" } diff --git a/client_web/src/app.rs b/client_web/src/app.rs index 1c04891..8f98559 100644 --- a/client_web/src/app.rs +++ b/client_web/src/app.rs @@ -12,7 +12,7 @@ use crate::components::{ConnectingScreen, GameScreen, LoginScreen}; use crate::i18n::I18nContextProvider; use crate::trictrac::backend::TrictracBackend; use crate::trictrac::bot_local::bot_decide; -use crate::trictrac::types::{GameDelta, PlayerAction, SerTurnStage, ViewState}; +use crate::trictrac::types::{GameDelta, JanEntry, PlayerAction, ScoredEvent, SerTurnStage, ViewState}; use std::collections::VecDeque; @@ -32,6 +32,9 @@ pub struct GameUiState { pub waiting_for_confirm: bool, /// Why we are paused — drives the status-bar message in GameScreen. pub pause_reason: Option, + /// Points scored by this player in the transition to this state (if any). + pub my_scored_event: Option, + pub opp_scored_event: Option, } /// Reason the UI is paused waiting for the player to click Continue. @@ -267,6 +270,8 @@ pub fn App() -> impl IntoView { is_bot_game: false, waiting_for_confirm: false, pause_reason: None, + my_scored_event: None, + opp_scored_event: None, }, pending, screen, @@ -317,19 +322,51 @@ async fn run_local_bot_game( backend.player_arrival(1); let mut vs = ViewState::default_with_names("You", "Bot"); - drain_and_update(&mut backend, &mut vs, screen); + for cmd in backend.drain_commands() { + match cmd { + BackendCommand::ResetViewState => { vs = backend.get_view_state().clone(); } + BackendCommand::Delta(delta) => { vs.apply_delta(&delta); } + _ => {} + } + } + screen.set(Screen::Playing(GameUiState { + view_state: vs.clone(), + player_id: 0, + room_id: String::new(), + is_bot_game: true, + waiting_for_confirm: false, + pause_reason: None, + my_scored_event: None, + opp_scored_event: None, + })); loop { match cmd_rx.next().await { Some(NetCommand::Action(action)) => { + let prev_vs = vs.clone(); backend.inform_rpc(0, action); + for cmd in backend.drain_commands() { + if let BackendCommand::Delta(delta) = cmd { + vs.apply_delta(&delta); + } + } + let scored = compute_scored_event(&prev_vs, &vs, 0); + let opp_scored = compute_scored_event(&prev_vs, &vs, 1); + screen.set(Screen::Playing(GameUiState { + view_state: vs.clone(), + player_id: 0, + room_id: String::new(), + is_bot_game: true, + waiting_for_confirm: false, + pause_reason: None, + my_scored_event: scored, + opp_scored_event: opp_scored, + })); } Some(NetCommand::PlayVsBot) => return true, _ => return false, } - drain_and_update(&mut backend, &mut vs, screen); - loop { match bot_decide(backend.get_game()) { None => break, @@ -350,6 +387,8 @@ async fn run_local_bot_game( is_bot_game: true, waiting_for_confirm: false, pause_reason: None, + my_scored_event: None, + opp_scored_event: None, }, pending, screen, @@ -360,6 +399,55 @@ async fn run_local_bot_game( } } +/// Computes a scoring event for `player_id` by comparing the previous and next +/// ViewState. Returns `None` when no points changed for that player. +fn compute_scored_event(prev: &ViewState, next: &ViewState, player_id: u16) -> Option { + let prev_score = &prev.scores[player_id as usize]; + let next_score = &next.scores[player_id as usize]; + + let holes_gained = next_score.holes.saturating_sub(prev_score.holes); + if holes_gained == 0 && prev_score.points == next_score.points { + return None; + } + + let bredouille = holes_gained > 0 && prev_score.can_bredouille; + + // Determine which dice_jans are "mine" depending on who was the active roller. + let my_jans: Vec = if next.active_mp_player == Some(player_id) + && prev.active_mp_player == Some(player_id) + { + // My own roll: positive totals are mine. + next.dice_jans.iter().filter(|e| e.total > 0).cloned().collect() + } else if next.active_mp_player == Some(player_id) + && prev.active_mp_player != Some(player_id) + { + // Opponent just moved: negative totals (their penalty) are scored for me. + next.dice_jans + .iter() + .filter(|e| e.total < 0) + .map(|e| JanEntry { total: -e.total, points_per: -e.points_per, ..e.clone() }) + .collect() + } else { + return None; + }; + + let points_earned: u8 = my_jans + .iter() + .fold(0u8, |acc, e| acc.saturating_add(e.total.unsigned_abs())); + + if points_earned == 0 && holes_gained == 0 { + return None; + } + + Some(ScoredEvent { + points_earned, + holes_gained, + holes_total: next_score.holes, + bredouille, + jans: my_jans, + }) +} + /// Either queues the state as a buffered confirmation step (when the transition /// warrants a pause) or shows it immediately. Always updates `screen` to the /// live state so the UI falls through to the right content once pending drains. @@ -369,16 +457,29 @@ fn push_or_show( pending: RwSignal>, screen: RwSignal, ) { + let scored = compute_scored_event(prev_vs, &new_state.view_state, new_state.player_id); + let opp_scored = compute_scored_event(prev_vs, &new_state.view_state, 1 - new_state.player_id); + if let Some(reason) = infer_pause_reason(prev_vs, &new_state.view_state, new_state.player_id) { + // Scoring notifications go on the buffered (paused) state only. pending.update(|q| { q.push_back(GameUiState { waiting_for_confirm: true, pause_reason: Some(reason), + my_scored_event: scored, + opp_scored_event: opp_scored, ..new_state.clone() }); }); + screen.set(Screen::Playing(new_state)); + } else { + // No pause: show scoring directly on the live state. + screen.set(Screen::Playing(GameUiState { + my_scored_event: scored, + opp_scored_event: opp_scored, + ..new_state + })); } - screen.set(Screen::Playing(new_state)); } /// Compares the previous and next ViewState to decide whether the transition @@ -408,31 +509,6 @@ fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) -> Opt None } -fn drain_and_update( - backend: &mut TrictracBackend, - vs: &mut ViewState, - screen: RwSignal, -) { - for cmd in backend.drain_commands() { - match cmd { - BackendCommand::ResetViewState => { - *vs = backend.get_view_state().clone(); - } - BackendCommand::Delta(delta) => { - vs.apply_delta(&delta); - } - _ => {} - } - } - screen.set(Screen::Playing(GameUiState { - view_state: vs.clone(), - player_id: 0, - room_id: String::new(), - is_bot_game: true, - waiting_for_confirm: false, - pause_reason: None, - })); -} #[cfg(test)] mod tests { diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 1855560..642b79c 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -6,11 +6,12 @@ use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, use crate::app::{GameUiState, NetCommand, PauseReason}; use crate::i18n::*; -use crate::trictrac::types::{JanEntry, PlayerAction, SerStage, SerTurnStage}; +use crate::trictrac::types::{PlayerAction, SerStage, SerTurnStage}; use super::board::Board; use super::die::Die; use super::score_panel::PlayerScorePanel; +use super::scoring::ScoringPanel; #[allow(dead_code)] /// Returns (d0_used, d1_used) by matching each staged move's distance to a die. @@ -36,33 +37,6 @@ fn matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) { (d0, d1) } -/// Split `dice_jans` into (viewer_jans, opponent_jans). -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] pub fn GameScreen(state: GameUiState) -> impl IntoView { @@ -132,7 +106,10 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let cmd_tx_quit = cmd_tx.clone(); let cmd_tx_end_quit = cmd_tx.clone(); let cmd_tx_end_replay = cmd_tx.clone(); - let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice; + // Only show the fallback Go button when there is no ScoringPanel showing it. + let show_hold_go = is_my_turn + && vs.turn_stage == SerTurnStage::HoldOrGoChoice + && state.my_scored_event.is_none(); // ── Valid move sequences for this turn ───────────────────────────────────── // Computed once per ViewState snapshot; used by Board (highlighting) and the @@ -155,16 +132,18 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // Clone for the empty-move button reactive closure (Board consumes the original). let valid_seqs_empty = valid_sequences.clone(); - // ── Jan split: viewer_jans / opponent_jans ───────────────────────────────── - let (my_jans, opp_jans) = split_jans(&vs.dice_jans, is_my_turn && !show_roll); - // ── Scores ───────────────────────────────────────────────────────────────── let my_score = vs.scores[player_id as usize].clone(); let opp_score = vs.scores[1 - player_id as usize].clone(); + // ── Scoring notifications ────────────────────────────────────────────────── + let my_scored_event = state.my_scored_event.clone(); + let opp_scored_event = state.opp_scored_event.clone(); + // ── Capture for closures ─────────────────────────────────────────────────── let stage = vs.stage.clone(); let turn_stage = vs.turn_stage.clone(); + let turn_stage_for_panel = turn_stage.clone(); let room_id = state.room_id.clone(); let is_bot_game = state.is_bot_game; @@ -199,7 +178,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // ── Opponent score (above board) ───────────────────────────────── - + // ── Board + side panel ───────────────────────────────────────────
@@ -256,6 +235,14 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
})} + // Scoring notifications (own then opponent) + {my_scored_event.map(|event| view! { + + })} + {opp_scored_event.map(|event| view! { + + })} + // Action buttons
{waiting_for_confirm.then(|| view! { @@ -263,6 +250,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { pending.update(|q| { q.pop_front(); }); }>{t!(i18n, continue_btn)} })} + // Fallback Go button when no scoring panel (e.g. after reconnect) {show_hold_go.then(|| view! {
// ── Player score (below board) ──────────────────────────────────── - + // ── Game-over overlay ───────────────────────────────────────────── {stage_is_ended.then(|| { diff --git a/client_web/src/components/mod.rs b/client_web/src/components/mod.rs index cd5fc33..7ae2fcb 100644 --- a/client_web/src/components/mod.rs +++ b/client_web/src/components/mod.rs @@ -4,6 +4,7 @@ mod die; mod game_screen; mod login_screen; mod score_panel; +mod scoring; pub use connecting_screen::ConnectingScreen; pub use game_screen::GameScreen; diff --git a/client_web/src/components/score_panel.rs b/client_web/src/components/score_panel.rs index 9045008..c3c9123 100644 --- a/client_web/src/components/score_panel.rs +++ b/client_web/src/components/score_panel.rs @@ -1,10 +1,10 @@ use leptos::prelude::*; -use trictrac_store::{CheckerMove, Jan}; +use trictrac_store::Jan; use crate::i18n::*; -use crate::trictrac::types::{JanEntry, PlayerScore}; +use crate::trictrac::types::PlayerScore; -fn jan_label(jan: &Jan) -> String { +pub fn jan_label(jan: &Jan) -> String { let i18n = use_i18n(); match jan { Jan::FilledQuarter => t_string!(i18n, jan_filled_quarter).to_owned(), @@ -23,89 +23,8 @@ fn jan_label(jan: &Jan) -> String { } } -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 i18n = use_i18n(); - 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 { - t_string!(i18n, jan_double).to_owned() - } else { - t_string!(i18n, jan_simple).to_owned() - }; - 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(); - let moves_hover = entry.moves.clone(); - // RwSignal is Copy so it can be captured by both closures independently. - let hovered = use_context::>>(); - - 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} -
- } - } -
- } -} - #[component] -pub fn PlayerScorePanel(score: PlayerScore, jans: Vec, is_you: bool) -> impl IntoView { +pub fn PlayerScorePanel(score: PlayerScore, is_you: bool) -> impl IntoView { let i18n = use_i18n(); let points_pct = format!("{}%", (score.points as u32 * 100 / 12).min(100)); @@ -114,13 +33,6 @@ pub fn PlayerScorePanel(score: PlayerScore, jans: Vec, is_you: bool) - 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, entry)| jan_row(i, entry, expanded)) - .collect(); - view! {
@@ -148,9 +60,6 @@ pub fn PlayerScorePanel(score: PlayerScore, jans: Vec, is_you: bool) - {holes_val}
- {(!jan_rows.is_empty()).then(|| view! { -
{jan_rows}
- })} } } diff --git a/client_web/src/components/scoring.rs b/client_web/src/components/scoring.rs new file mode 100644 index 0000000..420cb9b --- /dev/null +++ b/client_web/src/components/scoring.rs @@ -0,0 +1,103 @@ +use futures::channel::mpsc::UnboundedSender; +use leptos::prelude::*; +use trictrac_store::CheckerMove; + +use crate::app::NetCommand; +use crate::i18n::*; +use crate::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage}; + +use super::score_panel::jan_label; + +fn scoring_jan_row(entry: JanEntry) -> impl IntoView { + let i18n = use_i18n(); + let hovered = use_context::>>(); + let label = jan_label(&entry.jan); + let double_tag = if entry.is_double { + t_string!(i18n, jan_double).to_owned() + } else { + t_string!(i18n, jan_simple).to_owned() + }; + let ways_tag = format!("×{}", entry.ways); + let pts_str = format!("+{}", entry.total); + let moves_hover = entry.moves.clone(); + + view! { +
+ {label} + {double_tag} + {ways_tag} + {pts_str} +
+ } +} + +#[component] +pub fn ScoringPanel( + event: ScoredEvent, + turn_stage: SerTurnStage, + #[prop(default = false)] is_opponent: bool, +) -> impl IntoView { + let i18n = use_i18n(); + let cmd_tx = use_context::>() + .expect("UnboundedSender not found in context"); + + let points_earned = event.points_earned; + let holes_gained = event.holes_gained; + let holes_total = event.holes_total; + let bredouille = event.bredouille; + let show_hold_go = !is_opponent && turn_stage == SerTurnStage::HoldOrGoChoice; + let panel_class = if is_opponent { "scoring-panel scoring-panel-opp" } else { "scoring-panel" }; + + let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect(); + + view! { +
+
+ {move || if is_opponent { + t_string!(i18n, opp_scored_pts, n = points_earned) + } else { + t_string!(i18n, scored_pts, n = points_earned) + }} +
+ {jan_rows} + {(holes_gained > 0).then(|| view! { +
+ {move || if is_opponent { + t_string!(i18n, opp_hole_made, holes = holes_total) + } else { + t_string!(i18n, hole_made, holes = holes_total) + }} + {bredouille.then(|| view! { + + {move || t_string!(i18n, bredouille_applied)} + + })} +
+ })} + {show_hold_go.then(|| view! { +
+ + +
+ })} +
+ } +} diff --git a/client_web/src/trictrac/types.rs b/client_web/src/trictrac/types.rs index 02e2675..2c5cdd2 100644 --- a/client_web/src/trictrac/types.rs +++ b/client_web/src/trictrac/types.rs @@ -170,6 +170,23 @@ impl ViewState { } } +// ── Scored event (notification) ────────────────────────────────────────── + +/// Points scored in a single scoring event, used for the notification panel. +#[derive(Clone, PartialEq)] +pub struct ScoredEvent { + /// Raw points earned (sum of jan values; before hole wrapping). + pub points_earned: u8, + /// Number of holes gained (0 = no hole). + pub holes_gained: u8, + /// Total holes after this event. + pub holes_total: u8, + /// Was bredouille active when the hole was made (doubles hole count)? + pub bredouille: bool, + /// Contributing jans from this player's perspective (totals always positive). + pub jans: Vec, +} + // ── Score snapshot ──────────────────────────────────────────────────────────── #[derive(Clone, PartialEq, Serialize, Deserialize)]