diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index d00a72a..428d693 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -52,6 +52,8 @@ body { width: 1.2em; height: 1.2em; color: var(--ui-parchment); + vertical-align: -0.25em; + margin-right: 0.7em; } /* ── Site navigation ─────────────────────────────────────────────── */ @@ -1857,6 +1859,14 @@ a:hover { text-decoration: underline; } font-style: italic; } +.ceremony-result { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 600; + color: var(--ui-gold-dark); + letter-spacing: 0.04em; +} + /* ── Nickname modal (anonymous player name chooser) ─────────────────── */ .nickname-backdrop { position: fixed; diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json index 6899303..03ba37c 100644 --- a/clients/web/locales/en.json +++ b/clients/web/locales/en.json @@ -15,6 +15,15 @@ "roll_dice": "Roll dice", "go": "Go", "empty_move": "Empty move", + "cancel_move": "Cancel move", + "debug_section": "Debug", + "take_snapshot": "Take snapshot", + "snapshot_copied": "Copied!", + "replay_snapshot": "Replay snapshot", + "replay_paste_hint": "Paste a snapshot JSON to start a bot game from that position.", + "replay_start": "Start", + "replay_invalid_state": "Invalid snapshot — paste the JSON copied by Take snapshot.", + "cancel": "Cancel", "you_suffix": " (you)", "points_label": "Points", "holes_label": "Holes", @@ -46,6 +55,8 @@ "pre_game_roll_title": "Who goes first?", "pre_game_roll_btn": "Roll", "pre_game_roll_tie": "Tie! Roll again", + "toss_you_first": "You go first!", + "toss_opp_first": "{{ name }} goes first!", "pre_game_roll_your_die": "Your die", "pre_game_roll_opp_die": "Opponent's die", "continue_btn": "Continue", diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json index 7ef3257..aae9c52 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -1,6 +1,6 @@ { "room_name_placeholder": "Nom de la salle", - "create_room": "Créer une salle", + "create_room": "Inviter un adversaire", "join_room": "Rejoindre", "connecting": "Connexion en cours…", "game_over": "Partie terminée", @@ -15,6 +15,15 @@ "roll_dice": "Lancer les dés", "go": "S'en aller", "empty_move": "Mouvement impossible", + "cancel_move": "Annuler le déplacement", + "debug_section": "Debug", + "take_snapshot": "Prendre un instantané", + "snapshot_copied": "Copié !", + "replay_snapshot": "Rejouer un instantané", + "replay_paste_hint": "Collez un instantané JSON pour démarrer une partie contre le bot depuis cette position.", + "replay_start": "Démarrer", + "replay_invalid_state": "Instantané invalide — collez le JSON copié par « Prendre un instantané ».", + "cancel": "Annuler", "you_suffix": " (vous)", "points_label": "Points", "holes_label": "Trous", @@ -36,8 +45,8 @@ "jan_helpless_man": "Dame impuissante", "play_vs_bot": "Jouer contre le bot", "vs_bot_label": "contre le bot", - "you_win": "Vous avez gagné !", - "opp_wins": "{{ name }} gagne !", + "you_win": "Vous avez gagné !", + "opp_wins": "{{ name }} a gagné !", "play_again": "Rejouer", "after_opponent_roll": "L'adversaire a lancé les dés", "after_opponent_go": "L'adversaire s'en va", @@ -46,6 +55,8 @@ "pre_game_roll_title": "Qui joue en premier ?", "pre_game_roll_btn": "Lancer", "pre_game_roll_tie": "Égalité ! Relancez", + "toss_you_first": "Vous commencez !", + "toss_opp_first": "{{ name }} commence !", "pre_game_roll_your_die": "Votre dé", "pre_game_roll_opp_die": "Dé adverse", "continue_btn": "Continuer", @@ -119,7 +130,7 @@ "copy_link": "Copier le lien", "link_copied": "Copié !", "scan_qr": "ou scannez le QR code", - "join_code_label": "Rejoindre par code", + "join_code_label": "Rejoindre avec un code", "join_code_placeholder": "Code de la salle", "share_btn": "Partager", "nickname_modal_title": "Choisissez votre pseudo", diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index 521a0c1..3819b61 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -15,6 +15,7 @@ use crate::api; use crate::game::components::{ConnectingScreen, GameScreen}; use crate::game::session::{ compute_last_moves, patch_player_name, push_or_show, run_local_bot_game, + run_local_bot_game_with_backend, }; use crate::game::trictrac::backend::TrictracBackend; use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState}; @@ -83,6 +84,8 @@ pub enum NetCommand { host_state: Option>, }, PlayVsBot, + /// Start a bot game with the board/score position from a previously taken snapshot. + ReplaySnapshot(ViewState), Action(PlayerAction), Disconnect, } @@ -190,9 +193,14 @@ pub fn App() -> impl IntoView { spawn_local(async move { loop { + let mut snapshot_init: Option = None; let remote_config: Option<(RoomConfig, bool)> = loop { match cmd_rx.next().await { Some(NetCommand::PlayVsBot) => break None, + Some(NetCommand::ReplaySnapshot(vs)) => { + snapshot_init = Some(vs); + break None; + } Some(NetCommand::CreateRoom { room }) => { break Some(( RoomConfig { @@ -251,8 +259,23 @@ pub fn App() -> impl IntoView { .or_else(|| anon_nickname.get_untracked()) .unwrap_or_else(|| untrack(|| t_string!(i18n, anonymous_name).to_string())); loop { - let restart = - run_local_bot_game(screen, &mut cmd_rx, pending, player_name.clone()).await; + let restart = match snapshot_init.take() { + Some(vs) => { + let backend = TrictracBackend::from_view_state(vs, &player_name); + run_local_bot_game_with_backend( + screen, + &mut cmd_rx, + pending, + player_name.clone(), + backend, + ) + .await + } + None => { + run_local_bot_game(screen, &mut cmd_rx, pending, player_name.clone()) + .await + } + }; if !restart { break; } @@ -446,8 +469,14 @@ fn SiteHamburger() -> impl IntoView { .expect("cmd_tx not found in context"); let sidebar_open = RwSignal::new(false); + let snapshot_copied = RwSignal::new(false); + let replay_open = RwSignal::new(false); + let replay_text = RwSignal::new(String::new()); + let replay_error = RwSignal::new(false); let cmd_tx_newgame = cmd_tx.clone(); + let cmd_tx_snapshot = cmd_tx.clone(); + let cmd_tx_replay = cmd_tx.clone(); view! { // ── Hamburger button (☰ → ✕ animation) ─────────────────────────────── @@ -543,6 +572,100 @@ fn SiteHamburger() -> impl IntoView { }.into_any(), }} + + // ── Debug section ───────────────────────────────────────────────── +
+ {t!(i18n, debug_section)} + + // "Take snapshot" — only visible while a game is in progress + {move || { + let Screen::Playing(ref state) = screen.get() else { return None; }; + let vs = state.view_state.clone(); + let tx = cmd_tx_snapshot.clone(); + Some(view! { + + }) + }} + + // "Replay snapshot" — always visible + +
+ + + // ── Replay snapshot modal ───────────────────────────────────────────── +
+
+

{t!(i18n, replay_snapshot)}

+

+ {t!(i18n, replay_paste_hint)} +

+