- }
- })
- }
-}
-
-/// Renders the full-screen game overlay, but only when the current route is "/".
-/// This lets the user navigate to profile/account pages while a game is running.
-#[component]
-fn GameOverlay(
- pending: RwSignal>,
- screen: RwSignal,
-) -> impl IntoView {
- let location = use_location();
-
- // Memoize the front of the pending queue so that pushing a new item to the back
- // does not re-mount GameScreen (and replay dice animation/sound) when the displayed
- // state (the front) hasn't changed.
- let pending_front = Memo::new(move |_| pending.with(|q| q.front().cloned()));
-
- move || {
- if location.pathname.get() != "/" {
- return view! {}.into_any();
- }
- if let Some(state) = pending_front.get() {
- return view! {
-
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::game::session::infer_pause_reason;
- use crate::game::trictrac::types::{PlayerScore, SerStage, SerTurnStage};
-
- fn score() -> PlayerScore {
- PlayerScore {
- name: String::new(),
- points: 0,
- holes: 0,
- can_bredouille: false,
- }
- }
-
- fn vs(dice: (u8, u8), turn_stage: SerTurnStage, active: Option) -> ViewState {
- ViewState {
- board: [0i8; 24],
- stage: SerStage::InGame,
- turn_stage,
- active_mp_player: active,
- scores: [score(), score()],
- dice,
- dice_jans: Vec::new(),
- dice_moves: (CheckerMove::default(), CheckerMove::default()),
- pre_game_roll: None,
- }
- }
-
- #[test]
- fn dice_change_is_after_roll() {
- let prev = vs((0, 0), SerTurnStage::RollDice, Some(1));
- let next = vs((3, 5), SerTurnStage::Move, Some(1));
- assert_eq!(
- infer_pause_reason(&prev, &next, 0),
- Some(PauseReason::AfterOpponentRoll)
- );
- }
-
- #[test]
- fn hold_to_move_is_after_go() {
- let prev = vs((3, 5), SerTurnStage::HoldOrGoChoice, Some(1));
- let next = vs((3, 5), SerTurnStage::Move, Some(1));
- assert_eq!(
- infer_pause_reason(&prev, &next, 0),
- Some(PauseReason::AfterOpponentGo)
- );
- }
-
- #[test]
- fn turn_switch_is_after_move() {
- let prev = vs((3, 5), SerTurnStage::Move, Some(1));
- let next = vs((3, 5), SerTurnStage::RollDice, Some(0));
- assert_eq!(
- infer_pause_reason(&prev, &next, 0),
- Some(PauseReason::AfterOpponentMove)
- );
- }
-
- #[test]
- fn own_action_returns_none() {
- let prev = vs((0, 0), SerTurnStage::RollDice, Some(0));
- let next = vs((2, 4), SerTurnStage::Move, Some(0));
- assert_eq!(infer_pause_reason(&prev, &next, 0), None);
- }
-
- #[test]
- fn no_active_player_returns_none() {
- let mut prev = vs((0, 0), SerTurnStage::RollDice, None);
- prev.stage = SerStage::PreGame;
- let mut next = prev.clone();
- next.active_mp_player = Some(0);
- assert_eq!(infer_pause_reason(&prev, &next, 0), None);
- }
-}
diff --git a/clients/web/src/game/components/board.rs b/clients/web/src/game/components/board.rs
deleted file mode 100644
index c1a12c6..0000000
--- a/clients/web/src/game/components/board.rs
+++ /dev/null
@@ -1,862 +0,0 @@
-use leptos::prelude::*;
-use trictrac_store::CheckerMove;
-
-use super::die::Die;
-use crate::game::trictrac::types::{SerTurnStage, ViewState};
-
-/// Field numbers in visual display order (left-to-right for each quarter), white's perspective.
-const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18];
-const TOP_RIGHT_W: [u8; 6] = [19, 20, 21, 22, 23, 24];
-const BOT_LEFT_W: [u8; 6] = [12, 11, 10, 9, 8, 7];
-const BOT_RIGHT_W: [u8; 6] = [6, 5, 4, 3, 2, 1];
-
-/// 180° rotation of white's layout: black's pieces (field 24) appear at the bottom.
-const TOP_LEFT_B: [u8; 6] = [1, 2, 3, 4, 5, 6];
-const TOP_RIGHT_B: [u8; 6] = [7, 8, 9, 10, 11, 12];
-const BOT_LEFT_B: [u8; 6] = [24, 23, 22, 21, 20, 19];
-const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13];
-
-/// The rest corner is field 12 (White) or field 13 (Black) in the store's coordinate system.
-/// Returns true when `field_num` is the rest corner for this perspective.
-#[allow(dead_code)]
-fn is_rest_corner(field_num: u8, is_white: bool) -> bool {
- if is_white {
- field_num == 12
- } else {
- field_num == 13
- }
-}
-
-/// Zone CSS class for a field number (field coordinates are always White's 1-24).
-fn field_zone_class(field_num: u8) -> &'static str {
- match field_num {
- 1..=6 => "zone-petit",
- 7..=12 => "zone-grand",
- 13..=18 => "zone-opponent",
- 19..=24 => "zone-retour",
- _ => "",
- }
-}
-
-/// Returns (d0_used, d1_used) for the bar dice display.
-pub(crate) fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
- let mut d0 = false;
- let mut d1 = false;
- for &(from, to) in staged {
- let dist = if to == 0 {
- if from > 18 {
- (25 as u8).saturating_sub(from)
- } else {
- from.saturating_sub(0)
- }
- } else if from < to {
- to.saturating_sub(from)
- } else {
- from.saturating_sub(to)
- };
- if !d0 && dist == dice.0 {
- d0 = true;
- } else if !d1 && dist == dice.1 {
- d1 = true;
- } else if !d0 && dist <= dice.0 && dice.0 <= dice.1 {
- d0 = true;
- } else {
- d1 = true;
- }
- }
- (d0, d1)
-}
-
-/// Returns the displayed board value for `field_num` after applying `staged_moves`.
-/// Field numbers are always in white's coordinate system (1–24).
-fn displayed_value(
- base_board: [i8; 24],
- staged_moves: &[(u8, u8)],
- is_white: bool,
- field_num: u8,
-) -> i8 {
- let mut val = base_board[(field_num - 1) as usize];
- let delta: i8 = if is_white { 1 } else { -1 };
- for &(from, to) in staged_moves {
- if from == field_num {
- val -= delta;
- }
- if to == field_num {
- val += delta;
- }
- }
- 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: field 60×180px, board padding 4px, row gap 4px, bar 5px, center-bar 12px.
-/// Quarter width: 6×60 + 5×2(inter-field gap) = 370px. Board total: 761px.
-/// With triangular flèches, arrows target the WIDE BASE of each triangle —
-/// that is where the checker stack actually sits.
-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(pad) + i*62 + 30(half field) = 34 + 62i
- // Right-quarter: 4 + 370(quarter) + 4(gap) + 5(bar) + 4(gap) + i*62 + 30 = 417 + 62i
- let x = if right {
- 417.0 + qi as f32 * 62.0
- } else {
- 34.0 + qi as f32 * 62.0
- };
- // Top row triangle base (wide end) ≈ y=30; bot row triangle base ≈ y=358.
- // (Top base: 4pad + 4field-pad + 20half-checker ≈ 28; Bot base: 388 − 4pad − 4field-pad − 20 ≈ 360)
- let y = if top { 30.0 } else { 358.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
-}
-
-/// In free-mode: all fields that own a checker (after staged moves applied).
-fn free_mode_origins_for(board: [i8; 24], staged: &[(u8, u8)], is_white: bool) -> Vec {
- (1u8..=24)
- .filter(|&f| {
- let v = displayed_value(board, staged, is_white, f);
- if is_white {
- v > 0
- } else {
- v < 0
- }
- })
- .collect()
-}
-
-/// In free-mode: destinations reachable from `origin` by the remaining die value,
-/// excluding fields occupied by opponent checkers.
-fn free_mode_dests_for(
- board: [i8; 24],
- staged: &[(u8, u8)],
- origin: u8,
- dice: (u8, u8),
- is_white: bool,
- all_in_exit: bool,
-) -> Vec {
- let to_use: Vec = match staged.len() {
- 0 => {
- if dice.0 == dice.1 {
- vec![dice.0]
- } else {
- vec![dice.0, dice.1]
- }
- }
- 1 => {
- let &(f0, t0) = &staged[0];
- if t0 == 0 {
- // First move was an exit — can't reliably infer die, offer both
- if dice.0 == dice.1 {
- vec![dice.0]
- } else {
- vec![dice.0, dice.1]
- }
- } else {
- let dist: u8 = if is_white {
- t0.saturating_sub(f0)
- } else {
- f0.saturating_sub(t0)
- };
- if dice.0 == dice.1 {
- vec![dice.0]
- } else if dist == dice.0 {
- vec![dice.1]
- } else {
- vec![dice.0]
- }
- }
- }
- _ => return vec![],
- };
-
- let opp_present = |f: u8| -> bool {
- let v = displayed_value(board, staged, is_white, f);
- if is_white {
- v < 0
- } else {
- v > 0
- }
- };
-
- let mut dests = vec![];
- for die in to_use {
- if die == 0 {
- continue;
- }
- let dest: i16 = if is_white {
- origin as i16 + die as i16
- } else {
- origin as i16 - die as i16
- };
- if dest >= 1 && dest <= 24 {
- let d = dest as u8;
- if !opp_present(d) {
- if d == 13 && is_white && displayed_value(board, staged, is_white, 12) < 2 {
- // prise de coin par puissance for white
- dests.push(12)
- } else if d == 12 && !is_white && displayed_value(board, staged, is_white, 13) > -2
- {
- // prise de coin par puissance for black
- dests.push(13)
- } else {
- dests.push(d);
- }
- }
- } else if all_in_exit {
- dests.push(0); // exit
- }
- }
- dests.sort_unstable();
- dests.dedup();
- dests
-}
-
-#[component]
-pub fn Board(
- view_state: ViewState,
- player_id: u16,
- /// Pending origin selection (first click of a move pair).
- 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)>,
- /// Dice to display in the center bars; None means dice not yet rolled (cups shown upright).
- #[prop(default = None)]
- bar_dice: Option<(u8, u8)>,
- /// Whether we're in the move stage (determines used/unused die appearance).
- #[prop(default = false)]
- bar_is_move: bool,
- #[prop(default = false)] is_my_turn: bool,
- /// Whether the dice are a double (golden glow).
- #[prop(default = false)]
- bar_is_double: bool,
- /// Checker moves to animate on mount (None when board unchanged).
- #[prop(default = None)]
- last_moves: Option<(CheckerMove, CheckerMove)>,
- /// Fields where a hit (battue) was scored this turn — show ripple animation.
- #[prop(default = vec![])]
- hit_fields: Vec,
- /// Suppress dice animation (echo screen shown after a pending confirm was dismissed).
- #[prop(default = false)]
- suppress_dice_anim: bool,
- /// When true, any field with own checkers is selectable as origin; destinations
- /// are computed from dice arithmetic rather than from pre-validated sequences.
- #[prop(default = RwSignal::new(false))]
- free_mode: RwSignal,
-) -> impl IntoView {
- let board = view_state.board;
- let vs_dice = view_state.dice;
- let white_points = view_state.scores[0].points;
- let white_can_bredouille = view_state.scores[0].can_bredouille;
- let black_points = view_state.scores[1].points;
- let black_can_bredouille = view_state.scores[1].can_bredouille;
- let is_move_stage = view_state.active_mp_player == Some(player_id)
- && matches!(
- view_state.turn_stage,
- SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
- );
- // True when ANY player is in the Move/HoldOrGoChoice stage — i.e., dice are fresh for the active player.
- let active_is_move_stage = matches!(
- view_state.turn_stage,
- SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
- );
- let is_white = player_id == 0;
- let hovered_moves = use_context::>>();
-
- // Exit-eligible: all the player's checkers are in their last jan.
- // White last jan = fields 19-24 (board indices 18-23, positive values).
- // Black last jan = fields 1-6 (board indices 0-5, negative values).
- let board_snapshot = view_state.board;
- let all_in_exit: bool;
- let exit_field_test: fn(u8) -> bool;
- if is_white {
- let in_exit: i8 = board_snapshot[18..24].iter().map(|&v| v.max(0)).sum();
- let total: i8 = board_snapshot.iter().map(|&v| v.max(0)).sum();
- all_in_exit = total > 0 && in_exit == total;
- exit_field_test = |f| matches!(f, 19..=24);
- } else {
- let in_exit: i8 = board_snapshot[0..6].iter().map(|&v| (-v).max(0)).sum();
- let total: i8 = board_snapshot.iter().map(|&v| (-v).max(0)).sum();
- all_in_exit = total > 0 && in_exit == total;
- exit_field_test = |f| matches!(f, 1..=6);
- }
-
- // Sequences clone for the reactive exit button (show/hide + class + click).
- let seqs_exit = valid_sequences.clone();
-
- // `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();
- let corner_title = if is_rest_corner(field_num, is_white) {
- Some("Coin de repos — must enter and leave with 2 checkers")
- } else {
- None
- };
- // §4a — slide delta for the arriving checker at this field.
- // Computed once per field at render time; Option<(f32,f32)> is Copy.
- let slide_delta: Option<(f32, f32)> = last_moves.and_then(|(m1, m2)| {
- [m1, m2].iter().find_map(|m| {
- if m.get_to() != field_num as usize || m.get_from() == m.get_to() {
- return None;
- }
- let (fx, fy) = field_center(m.get_from(), is_white)?;
- let (tx, ty) = field_center(m.get_to(), is_white)?;
- let dx = fx - tx;
- let dy = fy - ty;
- (dx.abs() >= 1.0 || dy.abs() >= 1.0).then_some((dx, dy))
- })
- });
- // §6e — ripple on hit fields (battue).
- let is_hit_field = hit_fields.contains(&field_num);
- view! {
-
0 } else { val < 0 };
- let can_stage = is_move_stage && staged.len() < 2;
- let sel = selected_origin.get();
-
- let mut cls = format!("field {}", field_zone_class(field_num));
- let is_white_pt = field_num >= 1 && field_num <= white_points;
- let is_black_pt = black_points > 0 && field_num >= 25 - black_points;
- if is_white_pt {
- cls.push_str(if white_can_bredouille { " point-bredouille" } else { " point-no-bredouille" });
- } else if is_black_pt {
- cls.push_str(if black_can_bredouille { " point-bredouille" } else { " point-no-bredouille" });
- }
- if is_rest_corner(field_num, is_white) {
- cls.push_str(" corner");
- // Pulse when the corner can be reached this turn
- if !seqs_c.is_empty() && seqs_c.iter().any(|(m1, m2)| {
- m1.get_to() as u8 == field_num
- || m2.get_to() as u8 == field_num
- }) {
- cls.push_str(" corner-available");
- }
- }
- if is_rest_corner(field_num, !is_white) {
- cls.push_str(" corner");
- }
- if all_in_exit && exit_field_test(field_num) {
- cls.push_str(" exit-eligible");
- }
-
- if seqs_c.is_empty() && !is_move_stage {
- // 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 && free_mode.get() {
- // Free-play mode: highlight based on dice arithmetic
- if let Some(origin) = sel {
- if origin == field_num {
- cls.push_str(" selected clickable");
- } else {
- let dests = free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit);
- if dests.iter().any(|&d| d == field_num && d != 0) {
- cls.push_str(" clickable dest");
- }
- }
- } else {
- let origins = free_mode_origins_for(board, &staged, is_white);
- if origins.iter().any(|&o| o == field_num) {
- cls.push_str(" clickable");
- }
- }
- } 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");
- }
- }
- }
-
- // §6c: highlight fields touched by the hovered jan
- if let Some(hm) = hovered_moves {
- let pairs = hm.get();
- let f = field_num as usize;
- let highlighted = pairs.iter().any(|(m1, m2)| {
- (m1.get_from() != 0 && m1.get_from() == f)
- || (m1.get_to() != 0 && m1.get_to() == f)
- || (m2.get_from() != 0 && m2.get_from() == f)
- || (m2.get_to() != 0 && m2.get_to() == f)
- });
- if highlighted {
- cls.push_str(" jan-hovered");
- }
- }
-
- cls
- }
- on:click=move |_| {
- if !is_move_stage { return; }
- let staged = staged_moves.get_untracked();
- if staged.len() >= 2 { return; }
-
- if free_mode.get_untracked() {
- match selected_origin.get_untracked() {
- Some(origin) if origin == field_num => {
- selected_origin.set(None);
- }
- Some(origin) => {
- let dests = free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit);
- if dests.iter().any(|&d| d == field_num) {
- staged_moves.update(|v| v.push((origin, field_num)));
- selected_origin.set(None);
- }
- }
- None => {
- let origins = free_mode_origins_for(board, &staged, is_white);
- if origins.iter().any(|&o| o == field_num) {
- selected_origin.set(Some(field_num));
- }
- }
- }
- } else {
- 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) {
- selected_origin.set(Some(field_num));
- }
- }
- }
- }
- }
- }
- >
- {field_num}
- {move || {
- let moves = staged_moves.get();
- let val = displayed_value(board, &moves, is_white, field_num);
- let count = val.unsigned_abs();
- // §6e — ripple on hit (battue) fields; must be inside the
- // reactive closure so Leptos uses the same direct rendering
- // path as .arriving (avoids node-move that resets animation).
- let ripple = is_hit_field.then(|| {
- let cls = if is_top_row { "hit-ripple hit-ripple-top" } else { "hit-ripple hit-ripple-bot" };
- view! { }.into_any()
- });
- let stack = (count > 0).then(|| {
- let color = if val > 0 { "white" } else { "black" };
- let display_n = (count as usize).min(4);
- // outermost index: last for top rows, first for bottom rows.
- let outer_idx = if is_top_row { display_n - 1 } else { 0 };
- let chips: Vec = (0..display_n).map(|i| {
- let label = if i == outer_idx && count >= 5 {
- count.to_string()
- } else {
- String::new()
- };
- if i == outer_idx {
- if let Some((dx, dy)) = slide_delta {
- return view! {
-
{label}
- }.into_any();
- }
- }
- view! {
-
{label}
- }.into_any()
- }).collect();
- view! {
{chips}
}.into_any()
- });
- (ripple, stack)
- }}
-
- }
- .into_any()
- })
- .collect()
- };
-
- // ── Bar content: die in the center bar (die_idx 0 = top bar, 1 = bottom bar) ──
- let bar_content = move |die_idx: u8| -> AnyView {
- match bar_dice {
- None => view! { }.into_any(),
- Some(dice_vals) => {
- let die_val = if die_idx == 0 {
- dice_vals.0
- } else {
- dice_vals.1
- };
- view! {
-
- {move || {
- let staged = staged_moves.get();
- let (u0, u1) = if bar_is_move {
- bar_matched_dice_used(&staged, dice_vals)
- } else if is_my_turn {
- (true, true)
- } else if active_is_move_stage && !suppress_dice_anim {
- // Opponent has fresh dice in their Move stage (first view).
- (false, false)
- } else {
- // Dice are old: either from the previous turn (opponent not yet
- // rolled) or this is the echo screen after a pending confirm.
- (true, true)
- };
- let used = if die_idx == 0 { u0 } else { u1 };
- view! { }
- }}
-
- }
- .into_any()
- }
- }
- };
-
- let (tl, tr, bl, br) = if is_white {
- (&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W)
- } else {
- (&TOP_LEFT_B, &TOP_RIGHT_B, &BOT_LEFT_B, &BOT_RIGHT_B)
- };
-
- view! {
- // board-wrapper keeps zone labels outside .board so the SVG overlay
- // inside .board stays correctly positioned (position:absolute top:0 left:0
- // is relative to .board, not the wrapper).
-
-
-
-
{fields_from(tl, true)}
-
{bar_content(0)}
-
{fields_from(tr, true)}
-
-
-
-
{fields_from(bl, false)}
-
{bar_content(1)}
-
{fields_from(br, false)}
-
- // SVG overlay: arrows for hovered jan moves
-
- // Exit sign: circle+arrow outside the board, next to the last exit field.
- // White exits to the right (top-right quarter); Black exits to the left (top-left).
- {move || {
- // Recompute on every staged_moves change: the exit button must appear
- // even when the initial board has a checker outside the exit zone,
- // because the first move can bring all checkers in (e.g. 15→21, 19→exit).
- let staged = staged_moves.get();
- let show = is_move_stage && if free_mode.get() {
- // In free mode show exit button whenever all checkers are in exit zone
- all_in_exit && staged.len() < 2
- } else {
- match staged.len() {
- 0 => seqs_exit.iter().any(|(m1, m2)| m1.get_to() == 0 || m2.get_to() == 0),
- 1 => {
- let (f0, t0) = staged[0];
- seqs_exit.iter()
- .filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0)
- .any(|(_, m2)| m2.get_to() == 0)
- }
- _ => false,
- }
- };
- show.then(|| {
- let seqs_exit_cls = seqs_exit.clone();
- let seqs_exit_click = seqs_exit.clone();
- let (pos_style, line_x1, line_x2, head_pts): (&str, &str, &str, &str) =
- if is_white {
- (
- "position:absolute;right:-60px;top:15px;width:50px;height:50px",
- "10", "31", "23,17 32,25 23,33",
- )
- } else {
- (
- "position:absolute;left:-60px;top:15px;width:50px;height:50px",
- "40", "19", "27,17 18,25 27,33",
- )
- };
- view! {
-
- }
-}
-
-/// Scoring detail panel, shown to the right of the hole counter in the merged
-/// score panel area.
-///
-/// Lifecycle:
-/// 1. Mounts expanded — shows all jan details and draws board arrows.
-/// 2. After 3.4 s the arrows clear and the panel auto-minimises to a small "+"
-/// button (unless Hold/Go buttons are still needed).
-/// 3. The "+" / "−" buttons let the player toggle between states at any time.
-#[component]
-pub fn ScoringPanel(
- event: ScoredEvent,
- turn_stage: SerTurnStage,
- #[prop(default = false)] is_opponent: bool,
-) -> impl IntoView {
- let i18n = use_i18n();
- let cmd_tx = use_context::>()
- .expect("UnboundedSender not found in context");
-
- let points_earned = event.points_earned;
- let holes_gained = event.holes_gained;
- let holes_total = event.holes_total;
- let bredouille = event.bredouille;
- let show_hold_go = !is_opponent && turn_stage == SerTurnStage::HoldOrGoChoice;
- let panel_class = if is_opponent {
- "scoring-panel scoring-panel-opp"
- } else {
- "scoring-panel"
- };
-
- // minimized: starts false (expanded)
- let minimized = RwSignal::new(false);
-
- // Collect all moves from all jans for automatic arrow display.
- let all_moves: Vec<(CheckerMove, CheckerMove)> = event
- .jans
- .iter()
- .flat_map(|e| e.moves.iter().cloned())
- .collect();
- let all_moves_auto = all_moves.clone();
- let all_moves_expand = all_moves.clone();
- let all_moves_enter = all_moves.clone();
-
- let hovered_ctx = use_context::>>();
- let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();
-
- // On mount: show all this event's moves as board arrows immediately,
- // then after 3.4 s slide to peek and clear the arrows.
- //
- // Two important constraints:
- // 1. The initial hm.set() must be deferred (spawn_local, not sync in body)
- // to avoid writing a reactive signal mid-render while Board reads it —
- // that triggers Leptos's cycle guard → `unreachable` WASM panic.
- // 2. The cancellation flag must be Rc>, NOT RwSignal.
- // RwSignal is a NodeId into Leptos's arena; the arena slot is freed
- // when ScoringPanel's owner drops (on every GameScreen remount). If the
- // 3.4 s future outlives the component and calls is_alive.get_untracked()
- // on a freed slot, that also panics with `unreachable`. Rc>
- // is reference-counted outside the arena and stays valid for as long as
- // the future holds onto it.
- #[cfg(target_arch = "wasm32")]
- if let Some(hm) = hovered_ctx {
- let is_alive = Arc::new(AtomicBool::new(true));
- let is_alive_cleanup = is_alive.clone();
- // on_cleanup requires Send + Sync; Arc satisfies both.
- on_cleanup(move || is_alive_cleanup.store(false, Ordering::Relaxed));
-
- spawn_local(async move {
- // Show arrows (runs in the next microtask, after render settles).
- hm.set(all_moves);
-
- TimeoutFuture::new(3_400).await;
- // Guard: component may have been destroyed while we were waiting.
- // is_alive was set to false by on_cleanup, which runs before Leptos
- // frees the signal arena slots — so peeked is still valid iff this
- // returns true.
- if !is_alive.load(Ordering::Relaxed) {
- return;
- }
- hm.set(vec![]);
- });
- }
-
- view! {
-
- // "+" expand button — shown only when minimised (CSS hides it otherwise).
-
-
- // Full panel — hidden when minimised via CSS.
-
-
-
- {move || if is_opponent {
- t_string!(i18n, opp_scored_pts, n = points_earned)
- } else {
- t_string!(i18n, scored_pts, n = points_earned)
- }}
-