Compare commits
3 commits
5d32b7082d
...
784dc1c4f7
| Author | SHA1 | Date | |
|---|---|---|---|
| 784dc1c4f7 | |||
| e7c0a390e3 | |||
| cdadb26f14 |
13 changed files with 1301 additions and 435 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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]]
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (1–24).
|
/// Field numbers are always in white's coordinate system (1–24).
|
||||||
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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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` 1–6 shows dots; 0 shows an empty face (not-yet-rolled).
|
/// `value` 1–6 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())
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue