diff --git a/client_web/assets/style.css b/client_web/assets/style.css index fceeea4..61d8cec 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -54,7 +54,6 @@ input[type="text"] { align-items: center; gap: 0.75rem; width: 100%; - max-width: 900px; } /* ── Language switcher ──────────────────────────────────────────────── */ @@ -108,7 +107,6 @@ 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 { @@ -180,6 +178,28 @@ 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; @@ -189,7 +209,7 @@ input[type="text"] { font-weight: 500; } -/* ── Dice bars ──────────────────────────────────────────────────────── */ +/* ── Dice bar ───────────────────────────────────────────────────────── */ .dice-bar { display: flex; align-items: center; @@ -304,6 +324,7 @@ 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 0ec3040..a60b99e 100644 --- a/client_web/src/components/board.rs +++ b/client_web/src/components/board.rs @@ -1,4 +1,5 @@ use leptos::prelude::*; +use trictrac_store::CheckerMove; use crate::trictrac::types::{SerTurnStage, ViewState}; @@ -35,6 +36,146 @@ 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, @@ -43,6 +184,8 @@ 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) @@ -51,47 +194,98 @@ 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 && moves.len() < 2; + let can_stage = is_move_stage && staged.len() < 2; let sel = selected_origin.get(); let mut cls = "field".to_string(); - 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"); + + 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"); + } + } } + cls } on:click=move |_| { if !is_move_stage { 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 }; + let staged = staged_moves.get_untracked(); + if staged.len() >= 2 { return; } match selected_origin.get_untracked() { Some(origin) if origin == field_num => { selected_origin.set(None); } Some(origin) => { - staged_moves.update(|v| v.push((origin, field_num))); - selected_origin.set(None); + 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)); + } + } + } } - None if is_mine => selected_origin.set(Some(field_num)), - None => {} } } > @@ -144,6 +338,34 @@ 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 6c3edec..8a43399 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::CheckerMove; +use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, MoveRules}; use crate::app::{GameUiState, NetCommand}; use crate::i18n::*; @@ -75,6 +75,10 @@ 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()); @@ -111,6 +115,27 @@ 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); @@ -157,77 +182,98 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // ── Opponent score (above board) ───────────────────────────────── - // ── 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), - }) - } - }} -
+ // ── Board + side panel ─────────────────────────────────────────── +
+ - // ── 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! { - + }} +
+ + // 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! { + + }) + }} +
- })} + // ── Player score (below board) ──────────────────────────────────── diff --git a/client_web/src/components/score_panel.rs b/client_web/src/components/score_panel.rs index bb57bce..9045008 100644 --- a/client_web/src/components/score_panel.rs +++ b/client_web/src/components/score_panel.rs @@ -58,6 +58,9 @@ 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! {
@@ -68,6 +71,16 @@ 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 0bc4128..90fbbc0 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::CheckerMove; +pub use board::{Board, CheckerMove}; mod dice; pub use dice::{Dice, DiceRoller};