diff --git a/client_web/assets/style.css b/client_web/assets/style.css index f6816c2..fceeea4 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -44,6 +44,7 @@ input[type="text"] { .btn:disabled { opacity: 0.4; cursor: default; } .btn-primary { background: #3a6b3a; color: #fff; } .btn-secondary { background: #5a4a2a; color: #fff; } +.btn-bot { background: #2a5a7a; color: #fff; } .btn:not(:disabled):hover { opacity: 0.85; } /* ── Game container ─────────────────────────────────────────────────── */ @@ -253,6 +254,45 @@ input[type="text"] { .jan-moves.hidden { display: none; } .jan-move-line { font-family: monospace; font-size: 0.8rem; color: #444; } +/* ── Game-over overlay ──────────────────────────────────────────────── */ +.game-over-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.game-over-box { + background: #f5edd8; + border-radius: 8px; + padding: 2rem 2.5rem; + text-align: center; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + gap: 1.25rem; + min-width: 260px; +} + +.game-over-box h2 { + font-size: 1.75rem; +} + +.game-over-winner { + font-size: 1.25rem; + font-weight: bold; + color: #3a6b3a; +} + +.game-over-actions { + display: flex; + gap: 0.75rem; + justify-content: center; +} + /* ── Board ──────────────────────────────────────────────────────────── */ .board { background: #2e6b2e; diff --git a/client_web/locales/en.json b/client_web/locales/en.json index 0898aee..799dbbb 100644 --- a/client_web/locales/en.json +++ b/client_web/locales/en.json @@ -35,5 +35,8 @@ "jan_contre_mezeas": "Contre mezeas", "jan_helpless_man": "Helpless man", "play_vs_bot": "Play vs Bot", - "vs_bot_label": "vs Bot" + "vs_bot_label": "vs Bot", + "you_win": "You win!", + "opp_wins": "{{ name }} wins!", + "play_again": "Play again" } diff --git a/client_web/locales/fr.json b/client_web/locales/fr.json index b41b8ed..c760968 100644 --- a/client_web/locales/fr.json +++ b/client_web/locales/fr.json @@ -35,5 +35,8 @@ "jan_contre_mezeas": "Contre mezeas", "jan_helpless_man": "Dame impuissante", "play_vs_bot": "Jouer contre le bot", - "vs_bot_label": "contre le bot" + "vs_bot_label": "contre le bot", + "you_win": "Vous gagnez !", + "opp_wins": "{{ name }} gagne !", + "play_again": "Rejouer" } diff --git a/client_web/src/app.rs b/client_web/src/app.rs index 7e605cb..ae4c22f 100644 --- a/client_web/src/app.rs +++ b/client_web/src/app.rs @@ -170,7 +170,10 @@ pub fn App() -> impl IntoView { }; if remote_config.is_none() { - run_local_bot_game(screen, &mut cmd_rx).await; + loop { + let restart = run_local_bot_game(screen, &mut cmd_rx).await; + if !restart { break; } + } screen.set(Screen::Login { error: None }); continue; } @@ -268,10 +271,11 @@ pub fn App() -> impl IntoView { } } +/// Runs one local bot game. Returns `true` if the player wants to play again. async fn run_local_bot_game( screen: RwSignal, cmd_rx: &mut futures::channel::mpsc::UnboundedReceiver, -) { +) -> bool { let mut backend = TrictracBackend::new(0); backend.player_arrival(0); backend.player_arrival(1); @@ -284,7 +288,8 @@ async fn run_local_bot_game( Some(NetCommand::Action(action)) => { backend.inform_rpc(0, action); } - _ => break, + Some(NetCommand::PlayVsBot) => return true, + _ => return false, } drain_and_update(&mut backend, &mut vs, screen); diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 51747a8..1477d19 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -101,6 +101,8 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let cmd_tx_roll = cmd_tx.clone(); let cmd_tx_go = cmd_tx.clone(); 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_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice; let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice; @@ -117,6 +119,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let room_id = state.room_id.clone(); let is_bot_game = state.is_bot_game; + // ── Game-over info ───────────────────────────────────────────────────────── + let stage_is_ended = stage == SerStage::Ended; + let winner_is_me = my_score.holes >= 12; + let opp_name_end = opp_score.name.clone(); + view! {
// ── Top bar ────────────────────────────────────────────────────── @@ -219,6 +226,33 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // ── Player score (below board) ──────────────────────────────────── + + // ── Game-over overlay ───────────────────────────────────────────── + {stage_is_ended.then(|| { + let winner_text = if winner_is_me { + t_string!(i18n, you_win).to_owned() + } else { + t_string!(i18n, opp_wins, name = opp_name_end.as_str()) + }; + view! { +
+
+

{t!(i18n, game_over)}

+

{winner_text}

+
+ + {is_bot_game.then(|| view! { + + })} +
+
+
+ } + })}
} } diff --git a/client_web/src/trictrac/backend.rs b/client_web/src/trictrac/backend.rs index 3e57935..e96f080 100644 --- a/client_web/src/trictrac/backend.rs +++ b/client_web/src/trictrac/backend.rs @@ -46,6 +46,12 @@ impl TrictracBackend { /// (MarkPoints, MarkAdvPoints). fn drive_automatic_stages(&mut self) { loop { + // Stop if the game has already ended (stage transitions to Ended but + // turn_stage may still be MarkPoints when schools_enabled=false, which + // makes consume(Mark) a no-op and would cause an infinite loop). + if self.game.stage == trictrac_store::Stage::Ended { + break; + } let player_id = self.game.active_player_id; match self.game.turn_stage { TurnStage::MarkPoints | TurnStage::MarkAdvPoints => { diff --git a/client_web/src/trictrac/bot_local.rs b/client_web/src/trictrac/bot_local.rs index 537ffcb..8941a09 100644 --- a/client_web/src/trictrac/bot_local.rs +++ b/client_web/src/trictrac/bot_local.rs @@ -1,5 +1,5 @@ use rand::prelude::IndexedRandom; -use trictrac_store::{CheckerMove, Color, GameState, MoveRules, TurnStage}; +use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage}; use crate::trictrac::types::PlayerAction; @@ -7,6 +7,9 @@ const GUEST_PLAYER_ID: u64 = 2; /// Returns the next action for the bot (mp_player 1 / guest), or None if it is not the bot's turn. pub fn bot_decide(game: &GameState) -> Option { + if game.stage == Stage::Ended { + return None; + } if game.active_player_id != GUEST_PLAYER_ID { return None; } diff --git a/store/src/game.rs b/store/src/game.rs index 57f69dd..9c5233f 100644 --- a/store/src/game.rs +++ b/store/src/game.rs @@ -890,7 +890,7 @@ impl GameState { return Err("No active player".into()); }; debug!("new hole -> {holes_count:?}"); - if holes_count > 12 { + if holes_count >= 12 { self.stage = Stage::Ended; } else { self.turn_stage = TurnStage::HoldOrGoChoice; @@ -907,7 +907,7 @@ impl GameState { let Some(holes) = self.get_active_player().map(|p| p.holes) else { return Err("No active player".into()); }; - if holes > 12 { + if holes >= 12 { self.stage = Stage::Ended; } else { self.turn_stage = if self.turn_stage == TurnStage::MarkAdvPoints { @@ -946,7 +946,7 @@ impl GameState { } else { // The player has moved, we can mark its opponent's points (which is now the current player) let new_hole = self.mark_points(self.active_player_id, self.dice_points.1); - if new_hole && self.get_active_player().map(|p| p.holes).unwrap_or(0) > 12 { + if new_hole && self.get_active_player().map(|p| p.holes).unwrap_or(0) >= 12 { self.stage = Stage::Ended; } TurnStage::RollDice