feat(web client): take & replay game snapshot
This commit is contained in:
parent
a82169fbe5
commit
fbc6a3c432
5 changed files with 239 additions and 3 deletions
|
|
@ -16,6 +16,14 @@
|
||||||
"go": "Go",
|
"go": "Go",
|
||||||
"empty_move": "Empty move",
|
"empty_move": "Empty move",
|
||||||
"cancel_move": "Cancel 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)",
|
"you_suffix": " (you)",
|
||||||
"points_label": "Points",
|
"points_label": "Points",
|
||||||
"holes_label": "Holes",
|
"holes_label": "Holes",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,14 @@
|
||||||
"go": "S'en aller",
|
"go": "S'en aller",
|
||||||
"empty_move": "Mouvement impossible",
|
"empty_move": "Mouvement impossible",
|
||||||
"cancel_move": "Annuler le déplacement",
|
"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)",
|
"you_suffix": " (vous)",
|
||||||
"points_label": "Points",
|
"points_label": "Points",
|
||||||
"holes_label": "Trous",
|
"holes_label": "Trous",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ use crate::api;
|
||||||
use crate::game::components::{ConnectingScreen, GameScreen};
|
use crate::game::components::{ConnectingScreen, GameScreen};
|
||||||
use crate::game::session::{
|
use crate::game::session::{
|
||||||
compute_last_moves, patch_player_name, push_or_show, run_local_bot_game,
|
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::backend::TrictracBackend;
|
||||||
use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState};
|
use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState};
|
||||||
|
|
@ -83,6 +84,8 @@ pub enum NetCommand {
|
||||||
host_state: Option<Vec<u8>>,
|
host_state: Option<Vec<u8>>,
|
||||||
},
|
},
|
||||||
PlayVsBot,
|
PlayVsBot,
|
||||||
|
/// Start a bot game with the board/score position from a previously taken snapshot.
|
||||||
|
ReplaySnapshot(ViewState),
|
||||||
Action(PlayerAction),
|
Action(PlayerAction),
|
||||||
Disconnect,
|
Disconnect,
|
||||||
}
|
}
|
||||||
|
|
@ -190,9 +193,14 @@ pub fn App() -> impl IntoView {
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
loop {
|
loop {
|
||||||
|
let mut snapshot_init: Option<ViewState> = None;
|
||||||
let remote_config: Option<(RoomConfig, bool)> = loop {
|
let remote_config: Option<(RoomConfig, bool)> = loop {
|
||||||
match cmd_rx.next().await {
|
match cmd_rx.next().await {
|
||||||
Some(NetCommand::PlayVsBot) => break None,
|
Some(NetCommand::PlayVsBot) => break None,
|
||||||
|
Some(NetCommand::ReplaySnapshot(vs)) => {
|
||||||
|
snapshot_init = Some(vs);
|
||||||
|
break None;
|
||||||
|
}
|
||||||
Some(NetCommand::CreateRoom { room }) => {
|
Some(NetCommand::CreateRoom { room }) => {
|
||||||
break Some((
|
break Some((
|
||||||
RoomConfig {
|
RoomConfig {
|
||||||
|
|
@ -251,8 +259,23 @@ pub fn App() -> impl IntoView {
|
||||||
.or_else(|| anon_nickname.get_untracked())
|
.or_else(|| anon_nickname.get_untracked())
|
||||||
.unwrap_or_else(|| untrack(|| t_string!(i18n, anonymous_name).to_string()));
|
.unwrap_or_else(|| untrack(|| t_string!(i18n, anonymous_name).to_string()));
|
||||||
loop {
|
loop {
|
||||||
let restart =
|
let restart = match snapshot_init.take() {
|
||||||
run_local_bot_game(screen, &mut cmd_rx, pending, player_name.clone()).await;
|
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 {
|
if !restart {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -446,8 +469,14 @@ fn SiteHamburger() -> impl IntoView {
|
||||||
.expect("cmd_tx not found in context");
|
.expect("cmd_tx not found in context");
|
||||||
|
|
||||||
let sidebar_open = RwSignal::new(false);
|
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_newgame = cmd_tx.clone();
|
||||||
|
let cmd_tx_snapshot = cmd_tx.clone();
|
||||||
|
let cmd_tx_replay = cmd_tx.clone();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
// ── Hamburger button (☰ → ✕ animation) ───────────────────────────────
|
// ── Hamburger button (☰ → ✕ animation) ───────────────────────────────
|
||||||
|
|
@ -543,6 +572,100 @@ fn SiteHamburger() -> impl IntoView {
|
||||||
}.into_any(),
|
}.into_any(),
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
// ── Debug section ─────────────────────────────────────────────────
|
||||||
|
<div class="game-sidebar-section" style="flex-direction:column;gap:0.4rem">
|
||||||
|
<span class="game-sidebar-label">{t!(i18n, debug_section)}</span>
|
||||||
|
|
||||||
|
// "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! {
|
||||||
|
<button class="game-sidebar-btn" on:click=move |_| {
|
||||||
|
if let Ok(json) = serde_json::to_string(&vs) {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let json_c = json.clone();
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Some(cb) = web_sys::window()
|
||||||
|
.map(|w| w.navigator().clipboard())
|
||||||
|
{
|
||||||
|
let _ = wasm_bindgen_futures::JsFuture::from(
|
||||||
|
cb.write_text(&json_c),
|
||||||
|
).await;
|
||||||
|
snapshot_copied.set(true);
|
||||||
|
gloo_timers::future::TimeoutFuture::new(2000).await;
|
||||||
|
snapshot_copied.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let _ = tx; // suppress unused warning on non-wasm
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
{move || if snapshot_copied.get() {
|
||||||
|
t_string!(i18n, snapshot_copied).to_owned()
|
||||||
|
} else {
|
||||||
|
t_string!(i18n, take_snapshot).to_owned()
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
|
||||||
|
// "Replay snapshot" — always visible
|
||||||
|
<button class="game-sidebar-btn" on:click=move |_| {
|
||||||
|
replay_text.set(String::new());
|
||||||
|
replay_error.set(false);
|
||||||
|
replay_open.set(true);
|
||||||
|
sidebar_open.set(false);
|
||||||
|
}>{t!(i18n, replay_snapshot)}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ── Replay snapshot modal ─────────────────────────────────────────────
|
||||||
|
<div class="ceremony-overlay" style="z-index:300"
|
||||||
|
style:display=move || if replay_open.get() { "" } else { "none" }
|
||||||
|
on:click=move |_| replay_open.set(false)>
|
||||||
|
<div class="ceremony-box" style="min-width:340px;max-width:480px;width:90vw"
|
||||||
|
on:click=|e| e.stop_propagation()>
|
||||||
|
<h2 style="font-size:1.3rem">{t!(i18n, replay_snapshot)}</h2>
|
||||||
|
<p class="game-sub-prompt" style="margin:0;text-align:center">
|
||||||
|
{t!(i18n, replay_paste_hint)}
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
style="width:100%;min-height:120px;background:rgba(0,0,0,0.25);border:1px solid rgba(200,164,72,0.35);border-radius:4px;color:var(--ui-parchment);font-family:var(--font-ui);font-size:0.75rem;padding:0.5rem;resize:vertical;box-sizing:border-box"
|
||||||
|
placeholder="{ \"board\": [...], ... }"
|
||||||
|
prop:value=move || replay_text.get()
|
||||||
|
on:input=move |e| {
|
||||||
|
use leptos::prelude::event_target_value;
|
||||||
|
replay_text.set(event_target_value(&e));
|
||||||
|
replay_error.set(false);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{move || replay_error.get().then(|| view! {
|
||||||
|
<p style="color:var(--ui-red-accent);font-size:0.8rem;margin:0">
|
||||||
|
{t!(i18n, replay_invalid_state)}
|
||||||
|
</p>
|
||||||
|
})}
|
||||||
|
<div style="display:flex;gap:0.75rem;justify-content:center">
|
||||||
|
<button class="btn btn-secondary" on:click=move |_| replay_open.set(false)>
|
||||||
|
{t!(i18n, cancel)}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" on:click=move |_| {
|
||||||
|
let text = replay_text.get_untracked();
|
||||||
|
match serde_json::from_str::<ViewState>(&text) {
|
||||||
|
Ok(vs) => {
|
||||||
|
cmd_tx_replay
|
||||||
|
.unbounded_send(NetCommand::ReplaySnapshot(vs))
|
||||||
|
.ok();
|
||||||
|
replay_open.set(false);
|
||||||
|
}
|
||||||
|
Err(_) => replay_error.set(true),
|
||||||
|
}
|
||||||
|
}>{t!(i18n, replay_start)}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,44 @@ pub async fn run_local_bot_game(
|
||||||
suppress_dice_anim: false,
|
suppress_dice_anim: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
run_local_bot_game_loop(screen, cmd_rx, pending, player_name, backend, vs).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a bot game from a pre-built backend and initial ViewState (used for snapshot replay).
|
||||||
|
/// Returns `true` if the player wants to play again.
|
||||||
|
pub async fn run_local_bot_game_with_backend(
|
||||||
|
screen: RwSignal<Screen>,
|
||||||
|
cmd_rx: &mut mpsc::UnboundedReceiver<NetCommand>,
|
||||||
|
pending: RwSignal<VecDeque<GameUiState>>,
|
||||||
|
player_name: String,
|
||||||
|
backend: TrictracBackend,
|
||||||
|
) -> bool {
|
||||||
|
let mut vs = backend.get_view_state().clone();
|
||||||
|
patch_bot_names(&mut vs, &player_name);
|
||||||
|
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,
|
||||||
|
last_moves: None,
|
||||||
|
suppress_dice_anim: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
run_local_bot_game_loop(screen, cmd_rx, pending, player_name, backend, vs).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_local_bot_game_loop(
|
||||||
|
screen: RwSignal<Screen>,
|
||||||
|
cmd_rx: &mut mpsc::UnboundedReceiver<NetCommand>,
|
||||||
|
pending: RwSignal<VecDeque<GameUiState>>,
|
||||||
|
player_name: String,
|
||||||
|
mut backend: TrictracBackend,
|
||||||
|
mut vs: ViewState,
|
||||||
|
) -> bool {
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
loop {
|
loop {
|
||||||
match cmd_rx.next().await {
|
match cmd_rx.next().await {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
|
use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
|
||||||
use trictrac_store::{Dice, DiceRoller, GameEvent, GameState, TurnStage};
|
use trictrac_store::{Color, Dice, DiceRoller, GameEvent, GameState, Player, Stage, TurnStage};
|
||||||
|
|
||||||
use super::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, SerTurnStage, ViewState};
|
use super::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, SerTurnStage, ViewState};
|
||||||
|
|
||||||
|
|
@ -130,6 +130,65 @@ impl TrictracBackend {
|
||||||
pub fn get_game(&self) -> &GameState {
|
pub fn get_game(&self) -> &GameState {
|
||||||
&self.game
|
&self.game
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a backend pre-loaded with the given `ViewState` snapshot so a bot
|
||||||
|
/// game can resume from an arbitrary position (debug feature).
|
||||||
|
pub fn from_view_state(vs: ViewState, player_name: &str) -> Self {
|
||||||
|
let mut game = GameState::new(false);
|
||||||
|
|
||||||
|
game.board.set_positions(&Color::White, vs.board);
|
||||||
|
|
||||||
|
game.stage = match vs.stage {
|
||||||
|
SerStage::InGame => Stage::InGame,
|
||||||
|
SerStage::Ended => Stage::Ended,
|
||||||
|
_ => Stage::InGame,
|
||||||
|
};
|
||||||
|
|
||||||
|
game.turn_stage = match vs.turn_stage {
|
||||||
|
SerTurnStage::RollDice => TurnStage::RollDice,
|
||||||
|
SerTurnStage::RollWaiting => TurnStage::RollWaiting,
|
||||||
|
SerTurnStage::MarkPoints => TurnStage::MarkPoints,
|
||||||
|
SerTurnStage::HoldOrGoChoice => TurnStage::HoldOrGoChoice,
|
||||||
|
SerTurnStage::Move => TurnStage::Move,
|
||||||
|
SerTurnStage::MarkAdvPoints => TurnStage::MarkAdvPoints,
|
||||||
|
};
|
||||||
|
|
||||||
|
game.dice = Dice { values: vs.dice };
|
||||||
|
|
||||||
|
game.active_player_id = match vs.active_mp_player {
|
||||||
|
Some(0) => HOST_PLAYER_ID,
|
||||||
|
Some(1) => GUEST_PLAYER_ID,
|
||||||
|
_ => HOST_PLAYER_ID,
|
||||||
|
};
|
||||||
|
|
||||||
|
let build_player = |score: &crate::game::trictrac::types::PlayerScore,
|
||||||
|
color: Color|
|
||||||
|
-> Player {
|
||||||
|
let mut p = Player::new(score.name.clone(), color);
|
||||||
|
p.points = score.points;
|
||||||
|
p.holes = score.holes;
|
||||||
|
p.can_bredouille = score.can_bredouille;
|
||||||
|
p
|
||||||
|
};
|
||||||
|
|
||||||
|
game.players.insert(HOST_PLAYER_ID, build_player(&vs.scores[0], Color::White));
|
||||||
|
game.players.insert(GUEST_PLAYER_ID, build_player(&vs.scores[1], Color::Black));
|
||||||
|
|
||||||
|
let mut view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
|
||||||
|
view_state.scores[0].name = player_name.to_string();
|
||||||
|
view_state.scores[1].name = "Bot".to_string();
|
||||||
|
|
||||||
|
TrictracBackend {
|
||||||
|
game,
|
||||||
|
dice_roller: DiceRoller::default(),
|
||||||
|
commands: Vec::new(),
|
||||||
|
view_state,
|
||||||
|
arrived: [true, true],
|
||||||
|
pre_game_dice: [None; 2],
|
||||||
|
tie_count: 0,
|
||||||
|
ceremony_started: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend {
|
impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue