diff --git a/client_web/assets/style.css b/client_web/assets/style.css index 61d8cec..fceeea4 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -54,6 +54,7 @@ input[type="text"] { align-items: center; gap: 0.75rem; width: 100%; + max-width: 900px; } /* ── Language switcher ──────────────────────────────────────────────── */ @@ -107,6 +108,7 @@ input[type="text"] { font-size: 0.9rem; box-shadow: 0 1px 4px rgba(0,0,0,0.2); width: 100%; + max-width: 900px; } .player-score-header { @@ -178,28 +180,6 @@ input[type="text"] { padding-top: 0.25rem; } -/* ── Board + side panel ─────────────────────────────────────────────── */ -.board-and-panel { - display: flex; - flex-direction: row; - align-items: flex-start; - gap: 1rem; -} - -.side-panel { - display: flex; - flex-direction: column; - gap: 0.75rem; - min-width: 160px; - padding-top: 0.25rem; -} - -.action-buttons { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - /* ── Status bar ─────────────────────────────────────────────────────── */ .status-bar { display: flex; @@ -209,7 +189,7 @@ input[type="text"] { font-weight: 500; } -/* ── Dice bar ───────────────────────────────────────────────────────── */ +/* ── Dice bars ──────────────────────────────────────────────────────── */ .dice-bar { display: flex; align-items: center; @@ -324,7 +304,6 @@ input[type="text"] { gap: 4px; user-select: none; box-shadow: 0 4px 12px rgba(0,0,0,0.4); - position: relative; } .board-row { diff --git a/client_web/src/components/board.rs b/client_web/src/components/board.rs index a60b99e..0ec3040 100644 --- a/client_web/src/components/board.rs +++ b/client_web/src/components/board.rs @@ -1,5 +1,4 @@ use leptos::prelude::*; -use trictrac_store::CheckerMove; use crate::trictrac::types::{SerTurnStage, ViewState}; @@ -36,146 +35,6 @@ fn displayed_value( val } -/// Fields whose checkers may be selected as the next origin given already-staged moves. -fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -> Vec { - let mut v: Vec = match staged.len() { - 0 => seqs.iter() - .map(|(m1, _)| m1.get_from() as u8) - .filter(|&f| f != 0) - .collect(), - 1 => { - let (f0, t0) = staged[0]; - seqs.iter() - .filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0) - .map(|(_, m2)| m2.get_from() as u8) - .filter(|&f| f != 0) - .collect() - } - _ => vec![], - }; - v.sort_unstable(); - v.dedup(); - v -} - -/// Pixel center of a board field in the SVG overlay coordinate space. -/// Geometry is derived from CSS: field 60px wide, 180px tall, board padding 4px, -/// board-row gap 4px, board-bar 20px, board-center-bar 12px. -fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> { - if f == 0 || f > 24 { - return None; - } - let (qi, right, top): (usize, bool, bool) = if is_white { - match f { - 13..=18 => (f - 13, false, true), - 19..=24 => (f - 19, true, true), - 7..=12 => (12 - f, false, false), - 1..=6 => (6 - f, true, false), - _ => return None, - } - } else { - match f { - 1..=6 => (f - 1, false, true), - 7..=12 => (f - 7, true, true), - 19..=24 => (24 - f, false, false), - 13..=18 => (18 - f, true, false), - _ => return None, - } - }; - // Left-quarter field i center x: 4 + i*62 + 30 = 34 + 62i - // Right-quarter field i center x: 4 + 370 + 4 + 20 + 4 + i*62 + 30 = 432 + 62i - let x = if right { 432.0 + qi as f32 * 62.0 } else { 34.0 + qi as f32 * 62.0 }; - // Top row center y: 4 + 90 = 94; bot row: 4 + 180 + 4 + 12 + 4 + 90 = 294 - let y = if top { 94.0 } else { 294.0 }; - Some((x, y)) -} - -/// SVG `` element drawing one arrow (shadow + gold) from `fp` to `tp`. -fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView { - let (x1, y1) = fp; - let (x2, y2) = tp; - let dx = x2 - x1; - let dy = y2 - y1; - let len = (dx * dx + dy * dy).sqrt(); - if len < 10.0 { - return view! { }.into_any(); - } - let nx = dx / len; - let ny = dy / len; - let px = -ny; - let py = nx; - - // Shrink line ends so arrows don't overlap the checker stack - let lx1 = x1 + nx * 20.0; - let ly1 = y1 + ny * 20.0; - let lx2 = x2 - nx * 15.0; - let ly2 = y2 - ny * 15.0; - - // Arrowhead triangle at (x2, y2) - let ah = 15.0_f32; - let aw = 7.0_f32; - let bx = x2 - nx * ah; - let bary = y2 - ny * ah; - let pts = format!( - "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", - x2, y2, - bx + px * aw, bary + py * aw, - bx - px * aw, bary - py * aw, - ); - let shadow_pts = format!( - "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", - x2, y2, - bx + px * (aw + 1.5), bary + py * (aw + 1.5), - bx - px * (aw + 1.5), bary - py * (aw + 1.5), - ); - - view! { - - // Drop-shadow for readability on coloured fields - - - // Gold arrow - - - - } - .into_any() -} - -/// Valid destinations for a selected origin given already-staged moves. -/// May include 0 (exit); callers handle that case. -fn valid_dests_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)], origin: u8) -> Vec { - let mut v: Vec = match staged.len() { - 0 => seqs.iter() - .filter(|(m1, _)| m1.get_from() as u8 == origin) - .map(|(m1, _)| m1.get_to() as u8) - .collect(), - 1 => { - let (f0, t0) = staged[0]; - seqs.iter() - .filter(|(m1, m2)| { - m1.get_from() as u8 == f0 - && m1.get_to() as u8 == t0 - && m2.get_from() as u8 == origin - }) - .map(|(_, m2)| m2.get_to() as u8) - .collect() - } - _ => vec![], - }; - v.sort_unstable(); - v.dedup(); - v -} - #[component] pub fn Board( view_state: ViewState, @@ -184,8 +43,6 @@ pub fn Board( selected_origin: RwSignal>, /// Moves staged so far this turn (max 2). Each entry is (from, to), 0 = empty move. staged_moves: RwSignal>, - /// All valid two-move sequences for this turn (empty when not in move stage). - valid_sequences: Vec<(CheckerMove, CheckerMove)>, ) -> impl IntoView { let board = view_state.board; let is_move_stage = view_state.active_mp_player == Some(player_id) @@ -194,98 +51,47 @@ pub fn Board( SerTurnStage::Move | SerTurnStage::HoldOrGoChoice ); let is_white = player_id == 0; - let hovered_moves = use_context::>>(); - // `valid_sequences` is cloned per field (the Vec is small; Send-safe unlike Rc). let fields_from = |nums: &[u8], is_top_row: bool| -> Vec { nums.iter() .map(|&field_num| { - // Each reactive closure gets its own owned clone — Vec<(CheckerMove,CheckerMove)> - // is Send, which Leptos requires for reactive attribute functions. - let seqs_c = valid_sequences.clone(); - let seqs_k = valid_sequences.clone(); view! {
0 } else { val < 0 }; - let can_stage = is_move_stage && staged.len() < 2; + let can_stage = is_move_stage && moves.len() < 2; let sel = selected_origin.get(); let mut cls = "field".to_string(); - - if seqs_c.is_empty() { - // No restriction (dice not rolled or not move stage) - if can_stage && (sel.is_some() || is_mine) { - cls.push_str(" clickable"); - } - if sel == Some(field_num) { cls.push_str(" selected"); } - if can_stage && sel.is_some() && sel != Some(field_num) { - cls.push_str(" dest"); - } - } else if can_stage { - if let Some(origin) = sel { - if origin == field_num { - cls.push_str(" selected clickable"); - } else { - let dests = valid_dests_for(&seqs_c, &staged, origin); - // Only highlight non-exit destinations (field 0 = exit has no tile) - if dests.iter().any(|&d| d == field_num && d != 0) { - cls.push_str(" clickable dest"); - } - } - } else { - let origins = valid_origins_for(&seqs_c, &staged); - if origins.iter().any(|&o| o == field_num) { - cls.push_str(" clickable"); - } - } + if can_stage && (sel.is_some() || is_mine) { + cls.push_str(" clickable"); + } + if sel == Some(field_num) { cls.push_str(" selected"); } + if can_stage && sel.is_some() && sel != Some(field_num) { + cls.push_str(" dest"); } - cls } on:click=move |_| { if !is_move_stage { return; } - let staged = staged_moves.get_untracked(); - if staged.len() >= 2 { return; } + if staged_moves.get_untracked().len() >= 2 { return; } + + let moves = staged_moves.get_untracked(); + let val = displayed_value(board, &moves, is_white, field_num); + let is_mine = if is_white { val > 0 } else { val < 0 }; match selected_origin.get_untracked() { Some(origin) if origin == field_num => { selected_origin.set(None); } Some(origin) => { - let valid = if seqs_k.is_empty() { - true - } else { - valid_dests_for(&seqs_k, &staged, origin) - .iter() - .any(|&d| d == field_num) - }; - if valid { - staged_moves.update(|v| v.push((origin, field_num))); - selected_origin.set(None); - } - } - None => { - if seqs_k.is_empty() { - let val = displayed_value(board, &staged, is_white, field_num); - if is_white && val > 0 || !is_white && val < 0 { - selected_origin.set(Some(field_num)); - } - } else { - let origins = valid_origins_for(&seqs_k, &staged); - if origins.iter().any(|&o| o == field_num) { - let dests = valid_dests_for(&seqs_k, &staged, field_num); - if !dests.is_empty() && dests.iter().all(|&d| d == 0) { - // All destinations are exits: auto-stage - staged_moves.update(|v| v.push((field_num, 0))); - } else { - selected_origin.set(Some(field_num)); - } - } - } + staged_moves.update(|v| v.push((origin, field_num))); + selected_origin.set(None); } + None if is_mine => selected_origin.set(Some(field_num)), + None => {} } } > @@ -338,34 +144,6 @@ pub fn Board(
{fields_from(br, false)}
- // SVG overlay: arrows for hovered jan moves - - {move || { - let Some(hm) = hovered_moves else { return vec![]; }; - let pairs = hm.get(); - if pairs.is_empty() { return vec![]; } - // Collect unique individual (from, to) moves; skip empty/exit. - let mut moves: Vec<(usize, usize)> = pairs.iter() - .flat_map(|(m1, m2)| [ - (m1.get_from(), m1.get_to()), - (m2.get_from(), m2.get_to()), - ]) - .filter(|&(f, t)| f != 0 && t != 0) - .collect(); - moves.sort_unstable(); - moves.dedup(); - moves.into_iter() - .filter_map(|(from, to)| { - let p1 = field_center(from, is_white)?; - let p2 = field_center(to, is_white)?; - Some(arrow_svg(p1, p2)) - }) - .collect() - }} - } } diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 8a43399..6c3edec 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -1,6 +1,6 @@ use futures::channel::mpsc::UnboundedSender; use leptos::prelude::*; -use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, MoveRules}; +use trictrac_store::CheckerMove; use crate::app::{GameUiState, NetCommand}; use crate::i18n::*; @@ -75,10 +75,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { SerTurnStage::Move | SerTurnStage::HoldOrGoChoice ); - // ── Hovered jan moves (shown as arrows on the board) ────────────────────── - let hovered_jan_moves: RwSignal> = RwSignal::new(vec![]); - provide_context(hovered_jan_moves); - // ── Staged move state ────────────────────────────────────────────────────── let selected_origin: RwSignal> = RwSignal::new(None); let staged_moves: RwSignal> = RwSignal::new(Vec::new()); @@ -115,27 +111,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice; let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice; - // ── Valid move sequences for this turn ───────────────────────────────────── - // Computed once per ViewState snapshot; used by Board (highlighting) and the - // empty-move button (visibility). - let valid_sequences: Vec<(CheckerMove, CheckerMove)> = if is_move_stage && dice != (0, 0) { - let mut store_board = StoreBoard::new(); - store_board.set_positions(&Color::White, vs.board); - let store_dice = StoreDice { values: dice }; - let color = if player_id == 0 { Color::White } else { Color::Black }; - let rules = MoveRules::new(&color, &store_board, store_dice); - let raw = rules.get_possible_moves_sequences(true, vec![]); - if player_id == 0 { - raw - } else { - raw.into_iter().map(|(m1, m2)| (m1.mirror(), m2.mirror())).collect() - } - } else { - vec![] - }; - // 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); @@ -182,99 +157,78 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // ── Opponent score (above board) ───────────────────────────────── - // ── Board + side panel ─────────────────────────────────────────── -
- - - // ── Side panel ─────────────────────────────────────────────── -
- // Status message -
- {move || { - let n = staged_moves.get().len(); - if is_move_stage { - t_string!(i18n, select_move, n = n + 1) - } else { - String::from(match (&stage, is_my_turn, &turn_stage) { - (SerStage::Ended, _, _) => t_string!(i18n, game_over), - (SerStage::PreGame, _, _) => t_string!(i18n, waiting_for_opponent), - (SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll), - (SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go), - (SerStage::InGame, true, _) => t_string!(i18n, your_turn), - (SerStage::InGame, false, _) => t_string!(i18n, opponent_turn), - }) - } - }} -
- - // Dice (always shown when rolled, used state depends on whose turn) - {show_dice.then(|| view! { -
- {move || { - let (d0, d1) = if is_move_stage { - matched_dice_used(&staged_moves.get(), dice) - } else { - (true, true) - }; - view! { - - - } - }} -
- })} - - // Action buttons -
- {show_roll.then(|| view! { - - })} - {show_hold_go.then(|| view! { - - })} - {move || { - // Show the empty-move button only when (0,0) is a valid - // first or second move given what has already been staged. - let staged = staged_moves.get(); - let show = is_move_stage && staged.len() < 2 && ( - valid_seqs_empty.is_empty() || match staged.len() { - 0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0), - 1 => { - let (f0, t0) = staged[0]; - valid_seqs_empty.iter() - .filter(|(m1, _)| { - m1.get_from() as u8 == f0 - && m1.get_to() as u8 == t0 - }) - .any(|(_, m2)| m2.get_from() == 0) - } - _ => false, - } - ); - show.then(|| view! { - - }) - }} -
-
+ // ── Status ─────────────────────────────────────────────────────── +
+ {move || { + let n = staged_moves.get().len(); + if is_move_stage { + t_string!(i18n, select_move, n = n + 1) + } else { + String::from(match (&stage, is_my_turn, &turn_stage) { + (SerStage::Ended, _, _) => t_string!(i18n, game_over), + (SerStage::PreGame, _, _) => t_string!(i18n, waiting_for_opponent), + (SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll), + (SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go), + (SerStage::InGame, true, _) => t_string!(i18n, your_turn), + (SerStage::InGame, false, _) => t_string!(i18n, opponent_turn), + }) + } + }}
+ // ── Opponent dice (top) ────────────────────────────────────────── + {(!is_my_turn && show_dice).then(|| view! { +
+ + +
+ })} + + // ── Board ──────────────────────────────────────────────────────── + + + // ── Player action bar (bottom) ─────────────────────────────────── + {is_my_turn.then(|| view! { +
+ {move || { + let (d0, d1) = if is_move_stage { + matched_dice_used(&staged_moves.get(), dice) + } else { + (false, false) + }; + view! { + + + } + }} + {show_roll.then(|| view! { + + })} + {show_hold_go.then(|| view! { + + })} + {is_move_stage.then(|| view! { + + })} +
+ })} + // ── Player score (below board) ──────────────────────────────────── diff --git a/client_web/src/components/score_panel.rs b/client_web/src/components/score_panel.rs index 9045008..bb57bce 100644 --- a/client_web/src/components/score_panel.rs +++ b/client_web/src/components/score_panel.rs @@ -58,9 +58,6 @@ fn jan_row(idx: usize, entry: JanEntry, expanded: RwSignal>) -> im }; 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! {
@@ -71,16 +68,6 @@ fn jan_row(idx: usize, entry: JanEntry, expanded: RwSignal>) -> im *s = if *s == Some(idx) { None } else { Some(idx) }; }); } - on:mouseenter=move |_| { - if let Some(h) = hovered { - h.set(moves_hover.clone()); - } - } - on:mouseleave=move |_| { - if let Some(h) = hovered { - h.set(vec![]); - } - } > {label} {double_tag} diff --git a/store/src/lib.rs b/store/src/lib.rs index 90fbbc0..0bc4128 100644 --- a/store/src/lib.rs +++ b/store/src/lib.rs @@ -12,7 +12,7 @@ mod error; pub use error::Error; mod board; -pub use board::{Board, CheckerMove}; +pub use board::CheckerMove; mod dice; pub use dice::{Dice, DiceRoller};