Compare commits

...

3 commits

13 changed files with 1301 additions and 435 deletions

2
Cargo.lock generated
View file

@ -1397,6 +1397,7 @@ dependencies = [
"futures", "futures",
"getrandom 0.3.4", "getrandom 0.3.4",
"gloo-storage", "gloo-storage",
"gloo-timers",
"leptos", "leptos",
"leptos_i18n", "leptos_i18n",
"rand 0.9.2", "rand 0.9.2",
@ -1404,6 +1405,7 @@ dependencies = [
"serde_json", "serde_json",
"trictrac-store", "trictrac-store",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys",
] ]
[[package]] [[package]]

View file

@ -17,9 +17,17 @@ serde_json = "1"
futures = "0.3" futures = "0.3"
rand = "0.9" rand = "0.9"
gloo-storage = "0.3" gloo-storage = "0.3"
gloo-timers = "0.3"
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
# getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues. # getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues.
# Must be a direct dependency (not just transitive) for the feature to take effect. # Must be a direct dependency (not just transitive) for the feature to take effect.
getrandom = { version = "0.3", features = ["wasm_js"] } getrandom = { version = "0.3", features = ["wasm_js"] }
web-sys = { version = "0.3", features = [
"Document",
"Element",
"HtmlElement",
"CssStyleDeclaration",
"DomTokenList",
] }

File diff suppressed because it is too large Load diff

View file

@ -48,5 +48,8 @@
"bredouille_applied": "Bredouille!", "bredouille_applied": "Bredouille!",
"hold": "Hold", "hold": "Hold",
"opp_scored_pts": "Opponent +{{ n }} pts", "opp_scored_pts": "Opponent +{{ n }} pts",
"opp_hole_made": "Opponent hole! {{ holes }}/12" "opp_hole_made": "Opponent hole! {{ holes }}/12",
"hint_move": "Click a highlighted field to move a checker",
"hint_hold_or_go": "Hold to keep points — Go to reset the setting",
"hint_continue": "Click Continue when ready"
} }

View file

@ -48,5 +48,8 @@
"bredouille_applied": "Bredouille !", "bredouille_applied": "Bredouille !",
"hold": "Tenir", "hold": "Tenir",
"opp_scored_pts": "Adversaire +{{ n }} pts", "opp_scored_pts": "Adversaire +{{ n }} pts",
"opp_hole_made": "Trou adverse ! {{ holes }}/12" "opp_hole_made": "Trou adverse ! {{ holes }}/12",
"hint_move": "Cliquez un champ surligné pour déplacer",
"hint_hold_or_go": "Tenir pour garder les points — S'en aller pour repartir",
"hint_continue": "Cliquez Continuer quand vous êtes prêt"
} }

View file

@ -13,6 +13,7 @@ use crate::i18n::I18nContextProvider;
use crate::trictrac::backend::TrictracBackend; use crate::trictrac::backend::TrictracBackend;
use crate::trictrac::bot_local::bot_decide; use crate::trictrac::bot_local::bot_decide;
use crate::trictrac::types::{GameDelta, JanEntry, PlayerAction, ScoredEvent, SerTurnStage, ViewState}; use crate::trictrac::types::{GameDelta, JanEntry, PlayerAction, ScoredEvent, SerTurnStage, ViewState};
use trictrac_store::CheckerMove;
use std::collections::VecDeque; use std::collections::VecDeque;
@ -35,6 +36,8 @@ pub struct GameUiState {
/// Points scored by this player in the transition to this state (if any). /// Points scored by this player in the transition to this state (if any).
pub my_scored_event: Option<ScoredEvent>, pub my_scored_event: Option<ScoredEvent>,
pub opp_scored_event: Option<ScoredEvent>, pub opp_scored_event: Option<ScoredEvent>,
/// Checker moves to animate on this render. None when board is unchanged.
pub last_moves: Option<(CheckerMove, CheckerMove)>,
} }
/// Reason the UI is paused waiting for the player to click Continue. /// Reason the UI is paused waiting for the player to click Continue.
@ -272,6 +275,7 @@ pub fn App() -> impl IntoView {
pause_reason: None, pause_reason: None,
my_scored_event: None, my_scored_event: None,
opp_scored_event: None, opp_scored_event: None,
last_moves: compute_last_moves(&prev_vs, &vs),
}, },
pending, pending,
screen, screen,
@ -338,6 +342,7 @@ async fn run_local_bot_game(
pause_reason: None, pause_reason: None,
my_scored_event: None, my_scored_event: None,
opp_scored_event: None, opp_scored_event: None,
last_moves: None,
})); }));
loop { loop {
@ -361,6 +366,7 @@ async fn run_local_bot_game(
pause_reason: None, pause_reason: None,
my_scored_event: scored, my_scored_event: scored,
opp_scored_event: opp_scored, opp_scored_event: opp_scored,
last_moves: compute_last_moves(&prev_vs, &vs),
})); }));
} }
Some(NetCommand::PlayVsBot) => return true, Some(NetCommand::PlayVsBot) => return true,
@ -389,6 +395,7 @@ async fn run_local_bot_game(
pause_reason: None, pause_reason: None,
my_scored_event: None, my_scored_event: None,
opp_scored_event: None, opp_scored_event: None,
last_moves: compute_last_moves(&prev_vs, &vs),
}, },
pending, pending,
screen, screen,
@ -399,6 +406,22 @@ async fn run_local_bot_game(
} }
} }
/// Returns the checker moves to animate when the board changed between two ViewStates.
/// Returns `None` when the board is unchanged or no real moves were recorded.
fn compute_last_moves(prev: &ViewState, next: &ViewState) -> Option<(CheckerMove, CheckerMove)> {
if prev.board == next.board {
return None;
}
let (m1, m2) = next.dice_moves;
if m1 == CheckerMove::default() && m2 == CheckerMove::default() {
// Relies on the engine invariant: dice_moves is updated atomically with the board
// change in the Move event handler. Any future engine path that mutates the board
// without setting dice_moves would bypass this guard and replay stale animation.
return None;
}
Some((m1, m2))
}
/// Computes a scoring event for `player_id` by comparing the previous and next /// Computes a scoring event for `player_id` by comparing the previous and next
/// ViewState. Returns `None` when no points changed for that player. /// ViewState. Returns `None` when no points changed for that player.
fn compute_scored_event(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<ScoredEvent> { fn compute_scored_event(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<ScoredEvent> {
@ -471,7 +494,9 @@ fn push_or_show(
..new_state.clone() ..new_state.clone()
}); });
}); });
screen.set(Screen::Playing(new_state)); // Animation belongs to the buffered confirmation step; clear it on the
// fallback live state so it doesn't fire again after the queue drains.
screen.set(Screen::Playing(GameUiState { last_moves: None, ..new_state }));
} else { } else {
// No pause: show scoring directly on the live state. // No pause: show scoring directly on the live state.
screen.set(Screen::Playing(GameUiState { screen.set(Screen::Playing(GameUiState {
@ -528,6 +553,7 @@ mod tests {
scores: [score(), score()], scores: [score(), score()],
dice, dice,
dice_jans: Vec::new(), dice_jans: Vec::new(),
dice_moves: (CheckerMove::default(), CheckerMove::default()),
} }
} }

View file

@ -1,6 +1,7 @@
use leptos::prelude::*; use leptos::prelude::*;
use trictrac_store::CheckerMove; use trictrac_store::CheckerMove;
use super::die::Die;
use crate::trictrac::types::{SerTurnStage, ViewState}; use crate::trictrac::types::{SerTurnStage, ViewState};
/// Field numbers in visual display order (left-to-right for each quarter), white's perspective. /// Field numbers in visual display order (left-to-right for each quarter), white's perspective.
@ -15,6 +16,51 @@ 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_LEFT_B: [u8; 6] = [24, 23, 22, 21, 20, 19];
const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13]; 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-retour",
19..=24 => "zone-dernier",
_ => "",
}
}
/// Returns (d0_used, d1_used) for the bar dice display.
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 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 {
d0 = true;
} else {
d1 = true;
}
}
(d0, d1)
}
/// Returns the displayed board value for `field_num` after applying `staged_moves`. /// Returns the displayed board value for `field_num` after applying `staged_moves`.
/// Field numbers are always in white's coordinate system (124). /// Field numbers are always in white's coordinate system (124).
fn displayed_value( fn displayed_value(
@ -39,7 +85,8 @@ fn displayed_value(
/// Fields whose checkers may be selected as the next origin given already-staged moves. /// 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<u8> { fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -> Vec<u8> {
let mut v: Vec<u8> = match staged.len() { let mut v: Vec<u8> = match staged.len() {
0 => seqs.iter() 0 => seqs
.iter()
.map(|(m1, _)| m1.get_from() as u8) .map(|(m1, _)| m1.get_from() as u8)
.filter(|&f| f != 0) .filter(|&f| f != 0)
.collect(), .collect(),
@ -59,8 +106,9 @@ fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -
} }
/// Pixel center of a board field in the SVG overlay coordinate space. /// 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, /// Geometry: field 60×180px, board padding 4px, gap 4px, bar 20px, center-bar 12px.
/// board-row gap 4px, board-bar 20px, board-center-bar 12px. /// 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)> { fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> {
if f == 0 || f > 24 { if f == 0 || f > 24 {
return None; return None;
@ -82,14 +130,33 @@ fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> {
_ => return None, _ => return None,
} }
}; };
// Left-quarter field i center x: 4 + i*62 + 30 = 34 + 62i // Left-quarter field i center x: 4(pad) + i*62 + 30(half field) = 34 + 62i
// Right-quarter field i center x: 4 + 370 + 4 + 20 + 4 + i*62 + 30 = 432 + 62i // Right-quarter: 4 + 370(quarter) + 4(gap) + 68(bar) + 4(gap) + i*62 + 30 = 480 + 62i
let x = if right { 432.0 + qi as f32 * 62.0 } else { 34.0 + qi as f32 * 62.0 }; let x = if right {
// Top row center y: 4 + 90 = 94; bot row: 4 + 180 + 4 + 12 + 4 + 90 = 294 480.0 + qi as f32 * 62.0
let y = if top { 94.0 } else { 294.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)) Some((x, y))
} }
#[cfg(target_arch = "wasm32")]
fn apply_slide_animation(to_field: usize, dx: f32, dy: f32) {
use web_sys::wasm_bindgen::JsCast;
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
let selector = format!("#field-{} .checker-stack", to_field);
let Ok(Some(el)) = doc.query_selector(&selector) else { return };
let Ok(html) = el.dyn_into::<web_sys::HtmlElement>() else { return };
let style = html.style();
style.set_property("--slide-dx", &format!("{:.1}px", dx)).ok();
style.set_property("--slide-dy", &format!("{:.1}px", dy)).ok();
html.class_list().add_1("sliding").ok();
}
/// SVG `<g>` element drawing one arrow (shadow + gold) from `fp` to `tp`. /// SVG `<g>` element drawing one arrow (shadow + gold) from `fp` to `tp`.
fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView { fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView {
let (x1, y1) = fp; let (x1, y1) = fp;
@ -118,15 +185,21 @@ fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView {
let bary = y2 - ny * ah; let bary = y2 - ny * ah;
let pts = format!( let pts = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
x2, y2, x2,
bx + px * aw, bary + py * aw, y2,
bx - px * aw, bary - py * aw, bx + px * aw,
bary + py * aw,
bx - px * aw,
bary - py * aw,
); );
let shadow_pts = format!( let shadow_pts = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
x2, y2, x2,
bx + px * (aw + 1.5), bary + py * (aw + 1.5), y2,
bx - px * (aw + 1.5), bary - py * (aw + 1.5), bx + px * (aw + 1.5),
bary + py * (aw + 1.5),
bx - px * (aw + 1.5),
bary - py * (aw + 1.5),
); );
view! { view! {
@ -152,9 +225,14 @@ fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView {
/// Valid destinations for a selected origin given already-staged moves. /// Valid destinations for a selected origin given already-staged moves.
/// May include 0 (exit); callers handle that case. /// May include 0 (exit); callers handle that case.
fn valid_dests_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)], origin: u8) -> Vec<u8> { fn valid_dests_for(
seqs: &[(CheckerMove, CheckerMove)],
staged: &[(u8, u8)],
origin: u8,
) -> Vec<u8> {
let mut v: Vec<u8> = match staged.len() { let mut v: Vec<u8> = match staged.len() {
0 => seqs.iter() 0 => seqs
.iter()
.filter(|(m1, _)| m1.get_from() as u8 == origin) .filter(|(m1, _)| m1.get_from() as u8 == origin)
.map(|(m1, _)| m1.get_to() as u8) .map(|(m1, _)| m1.get_to() as u8)
.collect(), .collect(),
@ -186,6 +264,18 @@ pub fn Board(
staged_moves: RwSignal<Vec<(u8, u8)>>, staged_moves: RwSignal<Vec<(u8, u8)>>,
/// All valid two-move sequences for this turn (empty when not in move stage). /// All valid two-move sequences for this turn (empty when not in move stage).
valid_sequences: Vec<(CheckerMove, CheckerMove)>, 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,
/// 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)>,
) -> impl IntoView { ) -> impl IntoView {
let board = view_state.board; let board = view_state.board;
let is_move_stage = view_state.active_mp_player == Some(player_id) let is_move_stage = view_state.active_mp_player == Some(player_id)
@ -196,6 +286,24 @@ pub fn Board(
let is_white = player_id == 0; let is_white = player_id == 0;
let hovered_moves = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>(); let hovered_moves = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
// Exit-eligible (§8c): 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);
}
// `valid_sequences` is cloned per field (the Vec is small; Send-safe unlike Rc). // `valid_sequences` is cloned per field (the Vec is small; Send-safe unlike Rc).
let fields_from = |nums: &[u8], is_top_row: bool| -> Vec<AnyView> { let fields_from = |nums: &[u8], is_top_row: bool| -> Vec<AnyView> {
nums.iter() nums.iter()
@ -204,8 +312,15 @@ pub fn Board(
// is Send, which Leptos requires for reactive attribute functions. // is Send, which Leptos requires for reactive attribute functions.
let seqs_c = valid_sequences.clone(); let seqs_c = valid_sequences.clone();
let seqs_k = 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
};
view! { view! {
<div <div
id={format!("field-{field_num}")}
title=corner_title
class=move || { class=move || {
let staged = staged_moves.get(); let staged = staged_moves.get();
let val = displayed_value(board, &staged, is_white, field_num); let val = displayed_value(board, &staged, is_white, field_num);
@ -213,7 +328,20 @@ pub fn Board(
let can_stage = is_move_stage && staged.len() < 2; let can_stage = is_move_stage && staged.len() < 2;
let sel = selected_origin.get(); let sel = selected_origin.get();
let mut cls = "field".to_string(); let mut cls = format!("field {}", field_zone_class(field_num));
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 all_in_exit && exit_field_test(field_num) {
cls.push_str(" exit-eligible");
}
if seqs_c.is_empty() { if seqs_c.is_empty() {
// No restriction (dice not rolled or not move stage) // No restriction (dice not rolled or not move stage)
@ -319,28 +447,105 @@ pub fn Board(
.collect() .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! { <div class="bar-die-slot"></div> }.into_any(),
Some(dice_vals) => {
let die_val = if die_idx == 0 {
dice_vals.0
} else {
dice_vals.1
};
view! {
<div class="bar-die-slot">
{move || {
let staged = staged_moves.get();
let (u0, u1) = if bar_is_move {
bar_matched_dice_used(&staged, dice_vals)
} else {
(true, true)
};
let used = if die_idx == 0 { u0 } else { u1 };
view! { <Die value=die_val used=used is_double=bar_is_double /> }
}}
</div>
}
.into_any()
}
}
};
// §4a — animate checker moves. Deferred to a macrotask so Leptos has time
// to mount the Board's field divs before we query the DOM.
Effect::new(move |_| {
let Some((m1, m2)) = last_moves else { return };
// Collect the (to_field, dx, dy) pairs we need before moving into spawn_local.
let mut animations: Vec<(usize, f32, f32)> = Vec::new();
for m in [m1, m2] {
if m.get_from() == 0 && m.get_to() == 0 { continue; }
let Some((fx, fy)) = field_center(m.get_from(), is_white) else { continue };
let Some((tx, ty)) = field_center(m.get_to(), is_white) else { continue };
let dx = fx - tx;
let dy = fy - ty;
if dx.abs() < 1.0 && dy.abs() < 1.0 { continue; }
animations.push((m.get_to(), dx, dy));
}
#[cfg(target_arch = "wasm32")]
{
if let Some((to_field, dx, dy)) = animations.first().copied() {
gloo_timers::callback::Timeout::new(0, move || {
apply_slide_animation(to_field, dx, dy);
}).forget();
}
if let Some((to_field, dx, dy)) = animations.get(1).copied() {
gloo_timers::callback::Timeout::new(300, move || {
apply_slide_animation(to_field, dx, dy);
}).forget();
}
}
});
let (tl, tr, bl, br) = if is_white { let (tl, tr, bl, br) = if is_white {
(&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W) (&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W)
} else { } else {
(&TOP_LEFT_B, &TOP_RIGHT_B, &BOT_LEFT_B, &BOT_RIGHT_B) (&TOP_LEFT_B, &TOP_RIGHT_B, &BOT_LEFT_B, &BOT_RIGHT_B)
}; };
// Zone label pairs (top-left, top-right, bot-left, bot-right) per perspective.
let (label_tl, label_tr, label_bl, label_br) = if is_white {
("jan de retour", "dernier jan", "grand jan", "petit jan")
} else {
("petit jan", "grand jan", "dernier jan", "jan de retour")
};
view! { 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).
<div class="board-wrapper">
<div class="zone-labels-row">
<div class="zone-label zone-label-quarter">{label_tl}</div>
<div class="zone-label zone-label-bar"></div>
<div class="zone-label zone-label-quarter">{label_tr}</div>
</div>
<div class="board"> <div class="board">
<div class="board-row top-row"> <div class="board-row top-row">
<div class="board-quarter">{fields_from(tl, true)}</div> <div class="board-quarter">{fields_from(tl, true)}</div>
<div class="board-bar"></div> <div class="board-bar">{bar_content(0)}</div>
<div class="board-quarter">{fields_from(tr, true)}</div> <div class="board-quarter">{fields_from(tr, true)}</div>
</div> </div>
<div class="board-center-bar"></div> <div class="board-center-bar"></div>
<div class="board-row bot-row"> <div class="board-row bot-row">
<div class="board-quarter">{fields_from(bl, false)}</div> <div class="board-quarter">{fields_from(bl, false)}</div>
<div class="board-bar"></div> <div class="board-bar">{bar_content(1)}</div>
<div class="board-quarter">{fields_from(br, false)}</div> <div class="board-quarter">{fields_from(br, false)}</div>
</div> </div>
// SVG overlay: arrows for hovered jan moves // SVG overlay: arrows for hovered jan moves
<svg <svg
width="776" height="388" width="824" height="388"
style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible" style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible"
> >
{move || { {move || {
@ -367,5 +572,11 @@ pub fn Board(
}} }}
</svg> </svg>
</div> </div>
<div class="zone-labels-row">
<div class="zone-label zone-label-quarter">{label_bl}</div>
<div class="zone-label zone-label-bar"></div>
<div class="zone-label zone-label-quarter">{label_br}</div>
</div>
</div>
} }
} }

View file

@ -16,9 +16,21 @@ fn dot_positions(value: u8) -> &'static [(&'static str, &'static str)] {
/// A single die face rendered as SVG. /// A single die face rendered as SVG.
/// `value` 16 shows dots; 0 shows an empty face (not-yet-rolled). /// `value` 16 shows dots; 0 shows an empty face (not-yet-rolled).
/// `used` dims the die. /// `used` dims the die.
/// `is_double` applies a golden glow (both dice same value).
#[component] #[component]
pub fn Die(value: u8, used: bool) -> impl IntoView { pub fn Die(
let cls = if used { "die-face die-used" } else { "die-face" }; value: u8,
used: bool,
#[prop(default = false)] is_double: bool,
) -> impl IntoView {
let mut cls = if used {
"die-face die-used".to_string()
} else {
"die-face".to_string()
};
if is_double && !used {
cls.push_str(" die-double");
}
let dots: Vec<AnyView> = dot_positions(value) let dots: Vec<AnyView> = dot_positions(value)
.iter() .iter()
.map(|&(cx, cy)| view! { <circle cx=cx cy=cy r="4.5" /> }.into_any()) .map(|&(cx, cy)| view! { <circle cx=cx cy=cy r="4.5" /> }.into_any())

View file

@ -9,35 +9,9 @@ use crate::i18n::*;
use crate::trictrac::types::{PlayerAction, SerStage, SerTurnStage}; use crate::trictrac::types::{PlayerAction, SerStage, SerTurnStage};
use super::board::Board; use super::board::Board;
use super::die::Die;
use super::score_panel::PlayerScorePanel; use super::score_panel::PlayerScorePanel;
use super::scoring::ScoringPanel; use super::scoring::ScoringPanel;
#[allow(dead_code)]
/// Returns (d0_used, d1_used) by matching each staged move's distance to a die.
fn 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 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 {
d0 = true;
} else {
d1 = true;
}
}
(d0, d1)
}
#[component] #[component]
pub fn GameScreen(state: GameUiState) -> impl IntoView { pub fn GameScreen(state: GameUiState) -> impl IntoView {
let i18n = use_i18n(); let i18n = use_i18n();
@ -139,18 +113,29 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
// ── Scoring notifications ────────────────────────────────────────────────── // ── Scoring notifications ──────────────────────────────────────────────────
let my_scored_event = state.my_scored_event.clone(); let my_scored_event = state.my_scored_event.clone();
let opp_scored_event = state.opp_scored_event.clone(); let opp_scored_event = state.opp_scored_event.clone();
let hole_toast_info = my_scored_event.as_ref()
.filter(|e| e.holes_gained > 0)
.map(|e| (e.holes_total, e.bredouille));
let is_double_dice = dice.0 == dice.1 && dice.0 != 0;
let last_moves = state.last_moves;
// ── Capture for closures ─────────────────────────────────────────────────── // ── Capture for closures ───────────────────────────────────────────────────
let stage = vs.stage.clone(); let stage = vs.stage.clone();
let turn_stage = vs.turn_stage.clone(); let turn_stage = vs.turn_stage.clone();
let turn_stage_for_panel = turn_stage.clone(); let turn_stage_for_panel = turn_stage.clone();
let turn_stage_for_sub = turn_stage.clone();
let room_id = state.room_id.clone(); let room_id = state.room_id.clone();
let is_bot_game = state.is_bot_game; let is_bot_game = state.is_bot_game;
// ── Game-over info ───────────────────────────────────────────────────────── // ── Game-over info ─────────────────────────────────────────────────────────
let stage_is_ended = stage == SerStage::Ended; let stage_is_ended = stage == SerStage::Ended;
let winner_is_me = my_score.holes >= 12; let winner_is_me = my_score.holes >= 12;
let my_name_end = my_score.name.clone();
let my_holes_end = my_score.holes;
let opp_name_end = opp_score.name.clone(); let opp_name_end = opp_score.name.clone();
let opp_holes_end = opp_score.holes;
view! { view! {
<div class="game-container"> <div class="game-container">
@ -180,21 +165,9 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
// ── Opponent score (above board) ───────────────────────────────── // ── Opponent score (above board) ─────────────────────────────────
<PlayerScorePanel score=opp_score is_you=false /> <PlayerScorePanel score=opp_score is_you=false />
// ── Board + side panel ─────────────────────────────────────────── // ── Status bar — full width, above board (§10b) ──────────────────
<div class="board-and-panel"> <div class="game-status">
<Board {move || {
view_state=vs
player_id=player_id
selected_origin=selected_origin
staged_moves=staged_moves
valid_sequences=valid_sequences
/>
// ── Side panel ───────────────────────────────────────────────
<div class="side-panel">
// Status message
<div class="status-bar">
<span>{move || {
if let Some(ref reason) = pause_reason { if let Some(ref reason) = pause_reason {
return String::from(match reason { return String::from(match reason {
PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll), PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll),
@ -215,36 +188,50 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
(SerStage::InGame, false, _) => t_string!(i18n, opponent_turn), (SerStage::InGame, false, _) => t_string!(i18n, opponent_turn),
}) })
} }
}}</span>
</div>
// Dice (always shown when rolled, used state depends on whose turn)
{show_dice.then(|| view! {
<div class="dice-bar">
{move || {
let (d0, d1) = if is_move_stage {
matched_dice_used(&staged_moves.get(), dice)
} else {
(true, true)
};
view! {
<Die value=dice.0 used=d0 />
<Die value=dice.1 used=d1 />
}
}} }}
</div> </div>
})}
// Scoring notifications (own then opponent) // ── Contextual sub-prompt (§8a) ──────────────────────────────────
{move || {
let hint: String = if waiting_for_confirm {
t_string!(i18n, hint_continue).to_owned()
} else if is_move_stage {
t_string!(i18n, hint_move).to_owned()
} else if is_my_turn && turn_stage_for_sub == SerTurnStage::HoldOrGoChoice {
t_string!(i18n, hint_hold_or_go).to_owned()
} else {
String::new()
};
(!hint.is_empty()).then(|| view! { <p class="game-sub-prompt">{hint}</p> })
}}
// ── Board + side panel ───────────────────────────────────────────
<div class="board-and-panel">
<Board
view_state=vs
player_id=player_id
selected_origin=selected_origin
staged_moves=staged_moves
valid_sequences=valid_sequences
bar_dice=show_dice.then_some(dice)
bar_is_move=is_move_stage
bar_is_double=is_double_dice
last_moves=last_moves
/>
// ── Side panel (scoring panels only) ─────────────────────────
<div class="side-panel">
{my_scored_event.map(|event| view! { {my_scored_event.map(|event| view! {
<ScoringPanel event=event turn_stage=turn_stage_for_panel /> <ScoringPanel event=event turn_stage=turn_stage_for_panel />
})} })}
{opp_scored_event.map(|event| view! { {opp_scored_event.map(|event| view! {
<ScoringPanel event=event turn_stage=SerTurnStage::RollDice is_opponent=true /> <ScoringPanel event=event turn_stage=SerTurnStage::RollDice is_opponent=true />
})} })}
</div>
</div>
// Action buttons // ── Action buttons below board (§10c) ────────────────────────────
<div class="action-buttons"> <div class="board-actions">
{waiting_for_confirm.then(|| view! { {waiting_for_confirm.then(|| view! {
<button class="btn btn-primary" on:click=move |_| { <button class="btn btn-primary" on:click=move |_| {
pending.update(|q| { q.pop_front(); }); pending.update(|q| { q.pop_front(); });
@ -286,8 +273,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
}) })
}} }}
</div> </div>
</div>
</div>
// ── Player score (below board) ──────────────────────────────────── // ── Player score (below board) ────────────────────────────────────
<PlayerScorePanel score=my_score is_you=true /> <PlayerScorePanel score=my_score is_you=true />
@ -304,6 +289,13 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
<div class="game-over-box"> <div class="game-over-box">
<h2>{t!(i18n, game_over)}</h2> <h2>{t!(i18n, game_over)}</h2>
<p class="game-over-winner">{winner_text}</p> <p class="game-over-winner">{winner_text}</p>
<div class="game-over-score">
<span class="game-over-score-name">{my_name_end}</span>
<span class="game-over-score-nums">
{format!("{my_holes_end}{opp_holes_end}")}
</span>
<span class="game-over-score-name">{opp_name_end.clone()}</span>
</div>
<div class="game-over-actions"> <div class="game-over-actions">
<button class="btn btn-secondary" on:click=move |_| { <button class="btn btn-secondary" on:click=move |_| {
cmd_tx_end_quit.unbounded_send(NetCommand::Disconnect).ok(); cmd_tx_end_quit.unbounded_send(NetCommand::Disconnect).ok();
@ -318,6 +310,17 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
</div> </div>
} }
})} })}
// ── Hole toast (§6a) — board-centered overlay when a hole is won ──
{hole_toast_info.map(|(holes_total, bredouille)| view! {
<div class="hole-toast" class:hole-toast-bredouille=bredouille>
<div class="hole-toast-title">"Trou !"</div>
<div class="hole-toast-count">{format!("{holes_total} / 12")}</div>
{bredouille.then(|| view! {
<div class="hole-toast-bredouille">"× 2 bredouille"</div>
})}
</div>
})}
</div> </div>
} }
} }

View file

@ -17,7 +17,15 @@ pub fn LoginScreen(error: Option<String>) -> impl IntoView {
let cmd_tx_bot = cmd_tx; let cmd_tx_bot = cmd_tx;
view! { view! {
<div class="login-container"> <div class="login-card">
// ── Decorative board header ─────────────────────────────────────
<div class="login-card-header">
<div class="login-board-stripe"></div>
</div>
// ── Card body ──────────────────────────────────────────────────
<div class="login-card-body">
<div class="login-lang-switcher">
<div class="lang-switcher"> <div class="lang-switcher">
<button <button
class:lang-active=move || i18n.get_locale() == Locale::en class:lang-active=move || i18n.get_locale() == Locale::en
@ -28,20 +36,30 @@ pub fn LoginScreen(error: Option<String>) -> impl IntoView {
on:click=move |_| i18n.set_locale(Locale::fr) on:click=move |_| i18n.set_locale(Locale::fr)
>"FR"</button> >"FR"</button>
</div> </div>
</div>
<h1>"Trictrac"</h1> <h1 class="login-title">"Trictrac"</h1>
<p class="login-subtitle">
<em>"Jeu de trictrac"</em>
""
<em>"XVIII" <sup>"e"</sup> " siècle"</em>
</p>
<div class="login-ornament">""</div>
{error.map(|err| view! { <p class="error-msg">{err}</p> })} {error.map(|err| view! { <p class="error-msg">{err}</p> })}
<input <input
class="login-input"
type="text" type="text"
placeholder=move || t_string!(i18n, room_name_placeholder) placeholder=move || t_string!(i18n, room_name_placeholder)
prop:value=move || room_name.get() prop:value=move || room_name.get()
on:input=move |ev| set_room_name.set(event_target_value(&ev)) on:input=move |ev| set_room_name.set(event_target_value(&ev))
/> />
<div class="login-actions">
<button <button
class="btn btn-primary" class="login-btn login-btn-primary"
disabled=move || room_name.get().is_empty() disabled=move || room_name.get().is_empty()
on:click=move |_| { on:click=move |_| {
cmd_tx_create cmd_tx_create
@ -53,7 +71,7 @@ pub fn LoginScreen(error: Option<String>) -> impl IntoView {
</button> </button>
<button <button
class="btn btn-secondary" class="login-btn login-btn-secondary"
disabled=move || room_name.get().is_empty() disabled=move || room_name.get().is_empty()
on:click=move |_| { on:click=move |_| {
cmd_tx_join cmd_tx_join
@ -65,7 +83,7 @@ pub fn LoginScreen(error: Option<String>) -> impl IntoView {
</button> </button>
<button <button
class="btn btn-bot" class="login-btn login-btn-bot"
on:click=move |_| { on:click=move |_| {
cmd_tx_bot.unbounded_send(NetCommand::PlayVsBot).ok(); cmd_tx_bot.unbounded_send(NetCommand::PlayVsBot).ok();
} }
@ -73,5 +91,7 @@ pub fn LoginScreen(error: Option<String>) -> impl IntoView {
{t!(i18n, play_vs_bot)} {t!(i18n, play_vs_bot)}
</button> </button>
</div> </div>
</div>
</div>
} }
} }

View file

@ -28,11 +28,18 @@ pub fn PlayerScorePanel(score: PlayerScore, is_you: bool) -> impl IntoView {
let i18n = use_i18n(); let i18n = use_i18n();
let points_pct = format!("{}%", (score.points as u32 * 100 / 12).min(100)); let points_pct = format!("{}%", (score.points as u32 * 100 / 12).min(100));
let holes_pct = format!("{}%", (score.holes as u32 * 100 / 12).min(100));
let points_val = format!("{}/12", score.points); let points_val = format!("{}/12", score.points);
let holes_val = format!("{}/12", score.holes); let holes = score.holes;
let can_bredouille = score.can_bredouille; let can_bredouille = score.can_bredouille;
// 12 peg holes; filled up to `holes`
let pegs: Vec<AnyView> = (1u8..=12)
.map(|i| {
let cls = if i <= holes { "peg-hole filled" } else { "peg-hole" };
view! { <div class=cls></div> }.into_any()
})
.collect();
view! { view! {
<div class="player-score-panel"> <div class="player-score-panel">
<div class="player-score-header"> <div class="player-score-header">
@ -54,10 +61,8 @@ pub fn PlayerScorePanel(score: PlayerScore, is_you: bool) -> impl IntoView {
</div> </div>
<div class="score-bar-row"> <div class="score-bar-row">
<span class="score-bar-label">{t!(i18n, holes_label)}</span> <span class="score-bar-label">{t!(i18n, holes_label)}</span>
<div class="score-bar"> <div class="peg-track">{pegs}</div>
<div class="score-bar-fill score-bar-holes" style=format!("width:{holes_pct}")></div> <span class="score-bar-value">{format!("{holes}/12")}</span>
</div>
<span class="score-bar-value">{holes_val}</span>
</div> </div>
</div> </div>
</div> </div>

View file

@ -11,12 +11,8 @@ use super::score_panel::jan_label;
fn scoring_jan_row(entry: JanEntry) -> impl IntoView { fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
let i18n = use_i18n(); let i18n = use_i18n();
let hovered = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>(); let hovered = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
let label = jan_label(&entry.jan); let jan = entry.jan;
let double_tag = if entry.is_double { let is_double = entry.is_double;
t_string!(i18n, jan_double).to_owned()
} else {
t_string!(i18n, jan_simple).to_owned()
};
let ways_tag = format!("×{}", entry.ways); let ways_tag = format!("×{}", entry.ways);
let pts_str = format!("+{}", entry.total); let pts_str = format!("+{}", entry.total);
let moves_hover = entry.moves.clone(); let moves_hover = entry.moves.clone();
@ -35,8 +31,12 @@ fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
} }
} }
> >
<span class="jan-label">{label}</span> <span class="jan-label">{move || jan_label(&jan)}</span>
<span class="jan-tag">{double_tag}</span> <span class="jan-tag">{move || if is_double {
t_string!(i18n, jan_double).to_owned()
} else {
t_string!(i18n, jan_simple).to_owned()
}}</span>
<span class="jan-tag">{ways_tag}</span> <span class="jan-tag">{ways_tag}</span>
<span class="jan-pts">{pts_str}</span> <span class="jan-pts">{pts_str}</span>
</div> </div>
@ -86,9 +86,13 @@ pub fn ScoringPanel(
})} })}
</div> </div>
})} })}
{show_hold_go.then(|| view! { {show_hold_go.then(|| {
<div class="hold-go-buttons"> let dismissed = RwSignal::new(false);
<button class="btn btn-secondary"> view! {
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
<button class="btn btn-secondary" on:click=move |_| {
dismissed.set(true);
}>
{t!(i18n, hold)} {t!(i18n, hold)}
</button> </button>
<button class="btn btn-primary" on:click=move |_| { <button class="btn btn-primary" on:click=move |_| {
@ -97,6 +101,7 @@ pub fn ScoringPanel(
{t!(i18n, go)} {t!(i18n, go)}
</button> </button>
</div> </div>
}
})} })}
</div> </div>
} }

View file

@ -41,6 +41,8 @@ pub struct ViewState {
pub dice: (u8, u8), pub dice: (u8, u8),
/// Jans (scoring events) triggered by the last dice roll. /// Jans (scoring events) triggered by the last dice roll.
pub dice_jans: Vec<JanEntry>, pub dice_jans: Vec<JanEntry>,
/// Last two checker moves played; default when no move has occurred yet.
pub dice_moves: (CheckerMove, CheckerMove),
} }
/// One scoring event from a dice roll. /// One scoring event from a dice roll.
@ -73,6 +75,7 @@ impl ViewState {
], ],
dice: (0, 0), dice: (0, 0),
dice_jans: Vec::new(), dice_jans: Vec::new(),
dice_moves: (CheckerMove::default(), CheckerMove::default()),
} }
} }
@ -166,6 +169,7 @@ impl ViewState {
scores: [score_for(host_store_id), score_for(guest_store_id)], scores: [score_for(host_store_id), score_for(guest_store_id)],
dice: (gs.dice.values.0, gs.dice.values.1), dice: (gs.dice.values.0, gs.dice.values.1),
dice_jans, dice_jans,
dice_moves: gs.dice_moves,
} }
} }
} }