From 35d0b5cfb94db77568f379b52753b4ab96795cf6 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Wed, 25 Mar 2026 16:44:37 +0100 Subject: [PATCH] feat: client_web ui --- client_web/assets/style.css | 175 ++++++++++++++++++++++- client_web/src/components/board.rs | 93 ++++++++++++ client_web/src/components/game_screen.rs | 71 +++++++-- client_web/src/components/mod.rs | 2 + client_web/src/components/score_panel.rs | 25 ++++ client_web/src/trictrac/backend.rs | 105 ++++++++++++++ client_web/src/trictrac/types.rs | 4 +- 7 files changed, 457 insertions(+), 18 deletions(-) create mode 100644 client_web/src/components/board.rs create mode 100644 client_web/src/components/score_panel.rs diff --git a/client_web/assets/style.css b/client_web/assets/style.css index 74d62b0..e37f39d 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -1,9 +1,176 @@ -/* Trictrac web client — placeholder styles */ +/* ── Reset & base ──────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + body { font-family: sans-serif; + background: #c8b084; display: flex; justify-content: center; - background: #f4f0e8; - margin: 0; - padding: 1rem; + padding: 1.5rem; + min-height: 100vh; +} + +/* ── Login / Connecting screens ────────────────────────────────────── */ +.login-container { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 320px; + margin-top: 4rem; +} + +.login-container h1 { font-size: 2rem; text-align: center; margin-bottom: 0.5rem; } + +input[type="text"] { + padding: 0.5rem 0.75rem; + font-size: 1rem; + border: 1px solid #aaa; + border-radius: 4px; +} + +.error-msg { color: #c00; font-size: 0.9rem; } + +.connecting { font-size: 1.2rem; margin-top: 4rem; text-align: center; } + +/* ── Buttons ────────────────────────────────────────────────────────── */ +.btn { + padding: 0.5rem 1.25rem; + font-size: 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s; +} +.btn:disabled { opacity: 0.4; cursor: default; } +.btn-primary { background: #3a6b3a; color: #fff; } +.btn-secondary { background: #5a4a2a; color: #fff; } +.btn:not(:disabled):hover { opacity: 0.85; } + +/* ── Game container ─────────────────────────────────────────────────── */ +.game-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + width: 100%; + max-width: 900px; +} + +/* ── Score panel ────────────────────────────────────────────────────── */ +.score-panel { + display: flex; + gap: 2rem; + background: #f5edd8; + border-radius: 6px; + padding: 0.5rem 1.5rem; + font-size: 0.95rem; + box-shadow: 0 1px 4px rgba(0,0,0,0.2); +} +.score-row { display: flex; gap: 1rem; align-items: center; } +.score-name { font-weight: bold; min-width: 80px; } + +/* ── Status / action bars ───────────────────────────────────────────── */ +.status-bar { + display: flex; + gap: 1rem; + align-items: center; + font-size: 1.05rem; + font-weight: 500; +} +.dice { font-weight: bold; color: #2a4a8a; } + +.action-bar { display: flex; gap: 0.75rem; min-height: 2.5rem; } + +/* ── Board ──────────────────────────────────────────────────────────── */ +.board { + background: #2e6b2e; + border: 4px solid #1a3d1a; + border-radius: 8px; + padding: 4px; + display: flex; + flex-direction: column; + gap: 4px; + user-select: none; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); +} + +.board-row { + display: flex; + gap: 4px; +} + +.board-quarter { + display: flex; + gap: 2px; +} + +.board-bar { + width: 20px; + background: #1a3d1a; + border-radius: 3px; +} + +.board-center-bar { + height: 12px; + background: #1a3d1a; + border-radius: 3px; +} + +/* ── Fields ─────────────────────────────────────────────────────────── */ +.field { + width: 60px; + height: 110px; + background: #d4a843; + border-radius: 4px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + padding: 4px 2px; + position: relative; + transition: background 0.1s; +} + +/* Alternating field colours */ +.board-quarter .field:nth-child(odd) { background: #c49030; } +.board-quarter .field:nth-child(even) { background: #d4a843; } + +.top-row .field { justify-content: flex-start; } + +.field.clickable { cursor: pointer; } +.field.clickable:hover { background: #e8c060 !important; } +.field.selected { background: #88bb44 !important; outline: 2px solid #446622; } +.field.dest { background: #aad060 !important; } + +.field-num { + font-size: 0.65rem; + color: rgba(0,0,0,0.45); + position: absolute; + bottom: 2px; +} + +.top-row .field-num { bottom: auto; top: 2px; } + +/* ── Checkers ───────────────────────────────────────────────────────── */ +.checkers { + width: 46px; + height: 46px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + font-weight: bold; + border: 2px solid rgba(0,0,0,0.3); + box-shadow: inset 0 2px 4px rgba(255,255,255,0.3), 0 2px 4px rgba(0,0,0,0.3); +} + +.checkers.white { + background: radial-gradient(circle at 35% 35%, #ffffff, #cccccc); + color: #333; +} + +.checkers.black { + background: radial-gradient(circle at 35% 35%, #555555, #111111); + color: #eee; } diff --git a/client_web/src/components/board.rs b/client_web/src/components/board.rs new file mode 100644 index 0000000..55f306b --- /dev/null +++ b/client_web/src/components/board.rs @@ -0,0 +1,93 @@ +use futures::channel::mpsc::UnboundedSender; +use leptos::prelude::*; + +use crate::app::NetCommand; +use crate::trictrac::types::{PlayerAction, SerTurnStage, ViewState}; + +/// Field numbers in visual display order (left-to-right for each quarter). +const TOP_LEFT: [u8; 6] = [13, 14, 15, 16, 17, 18]; +const TOP_RIGHT: [u8; 6] = [19, 20, 21, 22, 23, 24]; +const BOT_LEFT: [u8; 6] = [12, 11, 10, 9, 8, 7]; +const BOT_RIGHT: [u8; 6] = [ 6, 5, 4, 3, 2, 1]; + +#[component] +pub fn Board(view_state: ViewState, player_id: u16) -> impl IntoView { + let selected: RwSignal> = RwSignal::new(None); + let cmd_tx = use_context::>() + .expect("UnboundedSender not found in context"); + + let board = view_state.board; + let is_move_stage = view_state.active_mp_player == Some(player_id) + && view_state.turn_stage == SerTurnStage::Move; + + // Build a Vec for a slice of field numbers. + // `fields_from` borrows `board`, `cmd_tx` and copies `selected`, `is_move_stage`, `player_id`. + let fields_from = |nums: &[u8]| -> Vec { + nums.iter().map(|&field_num| { + let value: i8 = board[(field_num - 1) as usize]; + let count = value.unsigned_abs(); + let checker_color = if value > 0 { "white" } else { "black" }; + let is_my_checker = if player_id == 0 { value > 0 } else { value < 0 }; + let cmd = cmd_tx.clone(); + + view! { +
selected.set(None), + Some(origin) => { + cmd.unbounded_send(NetCommand::Action( + PlayerAction::Move { from: origin, to: field_num }, + )).ok(); + selected.set(None); + } + None if is_my_checker => selected.set(Some(field_num)), + None => {} + } + } + > + {field_num} + {(count > 0).then(|| view! { + {count} + })} +
+ } + .into_any() + }) + .collect() + }; + + let top_left = fields_from(&TOP_LEFT); + let top_right = fields_from(&TOP_RIGHT); + let bot_left = fields_from(&BOT_LEFT); + let bot_right = fields_from(&BOT_RIGHT); + + view! { +
+
+
{top_left}
+
+
{top_right}
+
+
+
+
{bot_left}
+
+
{bot_right}
+
+
+ } +} diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 81915b8..78399b9 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -1,25 +1,72 @@ +use futures::channel::mpsc::UnboundedSender; use leptos::prelude::*; -use crate::app::GameUiState; -use crate::trictrac::types::SerStage; +use crate::app::{GameUiState, NetCommand}; +use crate::trictrac::types::{PlayerAction, SerStage, SerTurnStage}; + +use super::board::Board; +use super::score_panel::ScorePanel; #[component] pub fn GameScreen(state: GameUiState) -> impl IntoView { - let status = match state.view_state.stage { - SerStage::Ended => "Game over", - SerStage::PreGame => "Waiting for players…", - SerStage::InGame => match state.view_state.active_mp_player { - Some(id) if id == state.player_id => "Your turn", - Some(_) => "Opponent's turn", - None => "…", + let vs = state.view_state.clone(); + let player_id = state.player_id; + let is_my_turn = vs.active_mp_player == Some(player_id); + + let status = match &vs.stage { + 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(), + (true, SerTurnStage::HoldOrGoChoice) => "Hold or Go?".to_string(), + (true, SerTurnStage::Move) => "Your turn — move a checker".to_string(), + (true, _) => "Your turn".to_string(), + (false, _) => "Opponent's turn".to_string(), }, }; + let dice_text = if vs.dice != (0, 0) { + format!("Dice: {} & {}", vs.dice.0, vs.dice.1) + } else { + String::new() + }; + + let cmd_tx = use_context::>() + .expect("UnboundedSender not found in context"); + let cmd_tx2 = 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; + view! {
-

{status}

- // Board and score panel will be added in a subsequent step. -

"Board placeholder"

+ +
+ {status} + {(!dice_text.is_empty()).then(|| view! { {dice_text} })} +
+
+ {show_roll.then(|| view! { + + })} + {show_hold_go.then(|| view! { + + })} + {show_hold_go.then(|| { + let cmd_tx3 = use_context::>() + .expect("UnboundedSender not found in context"); + view! { + + } + })} +
+
} } diff --git a/client_web/src/components/mod.rs b/client_web/src/components/mod.rs index fbc7ee2..81962c9 100644 --- a/client_web/src/components/mod.rs +++ b/client_web/src/components/mod.rs @@ -1,6 +1,8 @@ +mod board; mod connecting_screen; mod game_screen; mod login_screen; +mod score_panel; 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 new file mode 100644 index 0000000..3c73fcd --- /dev/null +++ b/client_web/src/components/score_panel.rs @@ -0,0 +1,25 @@ +use leptos::prelude::*; + +use crate::trictrac::types::PlayerScore; + +#[component] +pub fn ScorePanel(scores: [PlayerScore; 2], player_id: u16) -> impl IntoView { + let rows: Vec<_> = scores + .into_iter() + .enumerate() + .map(|(i, score)| { + let label = if i as u16 == player_id { " (you)" } else { "" }; + view! { +
+ {score.name}{label} + "Points: "{score.points} + "Holes: "{score.holes} +
+ } + }) + .collect(); + + view! { +
{rows}
+ } +} diff --git a/client_web/src/trictrac/backend.rs b/client_web/src/trictrac/backend.rs index c2fd8fa..c527973 100644 --- a/client_web/src/trictrac/backend.rs +++ b/client_web/src/trictrac/backend.rs @@ -100,6 +100,7 @@ impl BackEndArchitecture for TrictracBackend if self.arrived[0] && self.arrived[1] && self.game.stage == trictrac_store::Stage::PreGame { let _ = self.game.consume(&GameEvent::BeginGame { goes_first: HOST_PLAYER_ID }); + self.sync_view_state(); self.commands.push(BackendCommand::ResetViewState); } else { self.broadcast_state(); @@ -195,3 +196,107 @@ impl BackEndArchitecture for TrictracBackend std::mem::take(&mut self.commands) } } + +#[cfg(test)] +mod tests { + use super::*; + use backbone_lib::traits::BackEndArchitecture; + + fn make_backend() -> TrictracBackend { + TrictracBackend::new(0) + } + + /// Helper: drain and return only Delta commands, extracting their ViewStates. + fn drain_deltas(b: &mut TrictracBackend) -> Vec { + b.drain_commands() + .into_iter() + .filter_map(|cmd| match cmd { + BackendCommand::Delta(d) => Some(d.state), + BackendCommand::ResetViewState => Some(b.view_state.clone()), + _ => None, + }) + .collect() + } + + #[test] + fn both_players_arrive_starts_game() { + let mut b = make_backend(); + b.player_arrival(0); // host + b.drain_commands(); + b.player_arrival(1); // guest + let cmds = b.drain_commands(); + + // ResetViewState should have been issued after BeginGame. + let has_reset = cmds.iter().any(|c| matches!(c, BackendCommand::ResetViewState)); + assert!(has_reset, "expected ResetViewState after both players arrive"); + + // Game should now be InGame. + use crate::trictrac::types::SerStage; + assert_eq!(b.get_view_state().stage, SerStage::InGame); + } + + #[test] + fn unknown_player_kicked() { + let mut b = make_backend(); + b.player_arrival(99); + let cmds = b.drain_commands(); + assert!(cmds.iter().any(|c| matches!(c, BackendCommand::KickPlayer { player: 99 }))); + } + + #[test] + fn roll_advances_to_move_or_hold() { + let mut b = make_backend(); + b.player_arrival(0); + b.player_arrival(1); + b.drain_commands(); + + // Host rolls (player_id 0, whose store id == HOST_PLAYER_ID == active after BeginGame). + b.inform_rpc(0, PlayerAction::Roll); + let states = drain_deltas(&mut b); + assert!(!states.is_empty(), "expected a state broadcast after roll"); + + use crate::trictrac::types::SerTurnStage; + let last = states.last().unwrap(); + assert!( + matches!(last.turn_stage, SerTurnStage::Move | SerTurnStage::HoldOrGoChoice), + "expected Move or HoldOrGoChoice after roll, got {:?}", last.turn_stage + ); + assert_eq!(last.dice, b.get_view_state().dice); + assert!(last.dice.0 >= 1 && last.dice.0 <= 6); + assert!(last.dice.1 >= 1 && last.dice.1 <= 6); + } + + #[test] + fn wrong_player_roll_ignored() { + let mut b = make_backend(); + b.player_arrival(0); + b.player_arrival(1); + b.drain_commands(); + + // Guest tries to roll when it's the host's turn. + b.inform_rpc(1, PlayerAction::Roll); + let cmds = b.drain_commands(); + assert!(cmds.is_empty(), "guest roll should be ignored when it's host's turn"); + } + + #[test] + fn departure_sets_reconnect_timer() { + let mut b = make_backend(); + b.player_arrival(0); + b.drain_commands(); + b.player_departure(0); + let cmds = b.drain_commands(); + assert!( + cmds.iter().any(|c| matches!(c, BackendCommand::SetTimer { timer_id: 0, .. })), + "expected reconnect timer after host departure" + ); + } + + #[test] + fn timer_triggers_terminate_room() { + let mut b = make_backend(); + b.timer_triggered(0); + let cmds = b.drain_commands(); + assert!(cmds.iter().any(|c| matches!(c, BackendCommand::TerminateRoom))); + } +} diff --git a/client_web/src/trictrac/types.rs b/client_web/src/trictrac/types.rs index 3c91c1b..8692268 100644 --- a/client_web/src/trictrac/types.rs +++ b/client_web/src/trictrac/types.rs @@ -125,14 +125,14 @@ pub struct PlayerScore { // ── Serialisable mirrors of store enums ────────────────────────────────────── -#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum SerStage { PreGame, InGame, Ended, } -#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum SerTurnStage { RollDice, RollWaiting,