feat(client_web): update UI/UX
This commit is contained in:
parent
1a24e7c960
commit
5d32b7082d
9 changed files with 1160 additions and 423 deletions
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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ use leptos::prelude::*;
|
||||||
use trictrac_store::CheckerMove;
|
use trictrac_store::CheckerMove;
|
||||||
|
|
||||||
use crate::trictrac::types::{SerTurnStage, ViewState};
|
use crate::trictrac::types::{SerTurnStage, ViewState};
|
||||||
|
use super::die::Die;
|
||||||
|
|
||||||
/// 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.
|
||||||
const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18];
|
const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18];
|
||||||
|
|
@ -15,6 +16,38 @@ 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(
|
||||||
|
|
@ -59,8 +92,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;
|
||||||
|
|
@ -69,24 +103,25 @@ fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> {
|
||||||
match f {
|
match f {
|
||||||
13..=18 => (f - 13, false, true),
|
13..=18 => (f - 13, false, true),
|
||||||
19..=24 => (f - 19, true, true),
|
19..=24 => (f - 19, true, true),
|
||||||
7..=12 => (12 - f, false, false),
|
7..=12 => (12 - f, false, false),
|
||||||
1..=6 => (6 - f, true, false),
|
1..=6 => (6 - f, true, false),
|
||||||
_ => return None,
|
_ => return None,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match f {
|
match f {
|
||||||
1..=6 => (f - 1, false, true),
|
1..=6 => (f - 1, false, true),
|
||||||
7..=12 => (f - 7, true, true),
|
7..=12 => (f - 7, true, true),
|
||||||
19..=24 => (24 - f, false, false),
|
19..=24 => (24 - f, false, false),
|
||||||
13..=18 => (18 - f, true, false),
|
13..=18 => (18 - f, true, false),
|
||||||
_ => 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 { 480.0 + qi as f32 * 62.0 } else { 34.0 + qi as f32 * 62.0 };
|
||||||
// Top row center y: 4 + 90 = 94; bot row: 4 + 180 + 4 + 12 + 4 + 90 = 294
|
// Top row triangle base (wide end) ≈ y=30; bot row triangle base ≈ y=358.
|
||||||
let y = if top { 94.0 } else { 294.0 };
|
// (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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,6 +221,12 @@ 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,
|
||||||
) -> 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 +237,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 +263,14 @@ 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
|
||||||
|
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 +278,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,53 +397,105 @@ pub fn Board(
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Bar content: cup (always) + die (when bar_dice is Some) ──────────────
|
||||||
|
// die_idx 0 = top bar, 1 = bottom bar.
|
||||||
|
let bar_content = move |die_idx: u8| -> AnyView {
|
||||||
|
let poured = bar_dice.is_some();
|
||||||
|
let cup_cls = if poured { "bar-cup cup-poured" } else { "bar-cup" };
|
||||||
|
view! {
|
||||||
|
<div class="bar-cup-slot">
|
||||||
|
<div class=cup_cls></div>
|
||||||
|
{bar_dice.map(|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>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
};
|
||||||
|
|
||||||
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! {
|
||||||
<div class="board">
|
// board-wrapper keeps zone labels outside .board so the SVG overlay
|
||||||
<div class="board-row top-row">
|
// inside .board stays correctly positioned (position:absolute top:0 left:0
|
||||||
<div class="board-quarter">{fields_from(tl, true)}</div>
|
// is relative to .board, not the wrapper).
|
||||||
<div class="board-bar"></div>
|
<div class="board-wrapper">
|
||||||
<div class="board-quarter">{fields_from(tr, true)}</div>
|
<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>
|
||||||
<div class="board-center-bar"></div>
|
<div class="board">
|
||||||
<div class="board-row bot-row">
|
<div class="board-row top-row">
|
||||||
<div class="board-quarter">{fields_from(bl, false)}</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(br, false)}</div>
|
<div class="board-quarter">{fields_from(tr, true)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="board-center-bar"></div>
|
||||||
|
<div class="board-row bot-row">
|
||||||
|
<div class="board-quarter">{fields_from(bl, false)}</div>
|
||||||
|
<div class="board-bar">{bar_content(1)}</div>
|
||||||
|
<div class="board-quarter">{fields_from(br, false)}</div>
|
||||||
|
</div>
|
||||||
|
// SVG overlay: arrows for hovered jan moves
|
||||||
|
<svg
|
||||||
|
width="824" height="388"
|
||||||
|
style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible"
|
||||||
|
>
|
||||||
|
{move || {
|
||||||
|
let Some(hm) = hovered_moves else { return vec![]; };
|
||||||
|
let pairs = hm.get();
|
||||||
|
if pairs.is_empty() { return vec![]; }
|
||||||
|
// Collect unique individual (from, to) moves; skip empty/exit.
|
||||||
|
let mut moves: Vec<(usize, usize)> = pairs.iter()
|
||||||
|
.flat_map(|(m1, m2)| [
|
||||||
|
(m1.get_from(), m1.get_to()),
|
||||||
|
(m2.get_from(), m2.get_to()),
|
||||||
|
])
|
||||||
|
.filter(|&(f, t)| f != 0 && t != 0)
|
||||||
|
.collect();
|
||||||
|
moves.sort_unstable();
|
||||||
|
moves.dedup();
|
||||||
|
moves.into_iter()
|
||||||
|
.filter_map(|(from, to)| {
|
||||||
|
let p1 = field_center(from, is_white)?;
|
||||||
|
let p2 = field_center(to, is_white)?;
|
||||||
|
Some(arrow_svg(p1, p2))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}}
|
||||||
|
</svg>
|
||||||
|
</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>
|
||||||
// SVG overlay: arrows for hovered jan moves
|
|
||||||
<svg
|
|
||||||
width="776" height="388"
|
|
||||||
style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible"
|
|
||||||
>
|
|
||||||
{move || {
|
|
||||||
let Some(hm) = hovered_moves else { return vec![]; };
|
|
||||||
let pairs = hm.get();
|
|
||||||
if pairs.is_empty() { return vec![]; }
|
|
||||||
// Collect unique individual (from, to) moves; skip empty/exit.
|
|
||||||
let mut moves: Vec<(usize, usize)> = pairs.iter()
|
|
||||||
.flat_map(|(m1, m2)| [
|
|
||||||
(m1.get_from(), m1.get_to()),
|
|
||||||
(m2.get_from(), m2.get_to()),
|
|
||||||
])
|
|
||||||
.filter(|&(f, t)| f != 0 && t != 0)
|
|
||||||
.collect();
|
|
||||||
moves.sort_unstable();
|
|
||||||
moves.dedup();
|
|
||||||
moves.into_iter()
|
|
||||||
.filter_map(|(from, to)| {
|
|
||||||
let p1 = field_center(from, is_white)?;
|
|
||||||
let p2 = field_center(to, is_white)?;
|
|
||||||
Some(arrow_svg(p1, p2))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}}
|
|
||||||
</svg>
|
|
||||||
</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,27 @@ 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;
|
||||||
|
|
||||||
// ── 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,6 +163,46 @@ 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 />
|
||||||
|
|
||||||
|
// ── Status bar — full width, above board (§10b) ──────────────────
|
||||||
|
<div class="game-status">
|
||||||
|
{move || {
|
||||||
|
if let Some(ref reason) = pause_reason {
|
||||||
|
return String::from(match reason {
|
||||||
|
PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll),
|
||||||
|
PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go),
|
||||||
|
PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let n = staged_moves.get().len();
|
||||||
|
if is_move_stage {
|
||||||
|
t_string!(i18n, select_move, n = n + 1)
|
||||||
|
} else {
|
||||||
|
String::from(match (&stage, is_my_turn, &turn_stage) {
|
||||||
|
(SerStage::Ended, _, _) => t_string!(i18n, game_over),
|
||||||
|
(SerStage::PreGame, _, _) => t_string!(i18n, waiting_for_opponent),
|
||||||
|
(SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll),
|
||||||
|
(SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go),
|
||||||
|
(SerStage::InGame, true, _) => t_string!(i18n, your_turn),
|
||||||
|
(SerStage::InGame, false, _) => t_string!(i18n, opponent_turn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ── 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 ───────────────────────────────────────────
|
// ── Board + side panel ───────────────────────────────────────────
|
||||||
<div class="board-and-panel">
|
<div class="board-and-panel">
|
||||||
<Board
|
<Board
|
||||||
|
|
@ -188,107 +211,66 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
selected_origin=selected_origin
|
selected_origin=selected_origin
|
||||||
staged_moves=staged_moves
|
staged_moves=staged_moves
|
||||||
valid_sequences=valid_sequences
|
valid_sequences=valid_sequences
|
||||||
|
bar_dice=show_dice.then_some(dice)
|
||||||
|
bar_is_move=is_move_stage
|
||||||
|
bar_is_double=is_double_dice
|
||||||
/>
|
/>
|
||||||
|
|
||||||
// ── Side panel ───────────────────────────────────────────────
|
// ── Side panel (scoring panels only) ─────────────────────────
|
||||||
<div class="side-panel">
|
<div class="side-panel">
|
||||||
// Status message
|
|
||||||
<div class="status-bar">
|
|
||||||
<span>{move || {
|
|
||||||
if let Some(ref reason) = pause_reason {
|
|
||||||
return String::from(match reason {
|
|
||||||
PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll),
|
|
||||||
PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go),
|
|
||||||
PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let n = staged_moves.get().len();
|
|
||||||
if is_move_stage {
|
|
||||||
t_string!(i18n, select_move, n = n + 1)
|
|
||||||
} else {
|
|
||||||
String::from(match (&stage, is_my_turn, &turn_stage) {
|
|
||||||
(SerStage::Ended, _, _) => t_string!(i18n, game_over),
|
|
||||||
(SerStage::PreGame, _, _) => t_string!(i18n, waiting_for_opponent),
|
|
||||||
(SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll),
|
|
||||||
(SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go),
|
|
||||||
(SerStage::InGame, true, _) => t_string!(i18n, your_turn),
|
|
||||||
(SerStage::InGame, false, _) => t_string!(i18n, opponent_turn),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}</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>
|
|
||||||
})}
|
|
||||||
|
|
||||||
// Scoring notifications (own then opponent)
|
|
||||||
{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 />
|
||||||
})}
|
})}
|
||||||
|
|
||||||
// Action buttons
|
|
||||||
<div class="action-buttons">
|
|
||||||
{waiting_for_confirm.then(|| view! {
|
|
||||||
<button class="btn btn-primary" on:click=move |_| {
|
|
||||||
pending.update(|q| { q.pop_front(); });
|
|
||||||
}>{t!(i18n, continue_btn)}</button>
|
|
||||||
})}
|
|
||||||
// Fallback Go button when no scoring panel (e.g. after reconnect)
|
|
||||||
{show_hold_go.then(|| view! {
|
|
||||||
<button class="btn btn-primary" on:click=move |_| {
|
|
||||||
cmd_tx_go.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
|
|
||||||
}>{t!(i18n, go)}</button>
|
|
||||||
})}
|
|
||||||
{move || {
|
|
||||||
// Show the empty-move button only when (0,0) is a valid
|
|
||||||
// first or second move given what has already been staged.
|
|
||||||
let staged = staged_moves.get();
|
|
||||||
let show = is_move_stage && staged.len() < 2 && (
|
|
||||||
valid_seqs_empty.is_empty() || match staged.len() {
|
|
||||||
0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0),
|
|
||||||
1 => {
|
|
||||||
let (f0, t0) = staged[0];
|
|
||||||
valid_seqs_empty.iter()
|
|
||||||
.filter(|(m1, _)| {
|
|
||||||
m1.get_from() as u8 == f0
|
|
||||||
&& m1.get_to() as u8 == t0
|
|
||||||
})
|
|
||||||
.any(|(_, m2)| m2.get_from() == 0)
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
show.then(|| view! {
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary"
|
|
||||||
on:click=move |_| {
|
|
||||||
selected_origin.set(None);
|
|
||||||
staged_moves.update(|v| v.push((0, 0)));
|
|
||||||
}
|
|
||||||
>{t!(i18n, empty_move)}</button>
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
// ── Action buttons below board (§10c) ────────────────────────────
|
||||||
|
<div class="board-actions">
|
||||||
|
{waiting_for_confirm.then(|| view! {
|
||||||
|
<button class="btn btn-primary" on:click=move |_| {
|
||||||
|
pending.update(|q| { q.pop_front(); });
|
||||||
|
}>{t!(i18n, continue_btn)}</button>
|
||||||
|
})}
|
||||||
|
// Fallback Go button when no scoring panel (e.g. after reconnect)
|
||||||
|
{show_hold_go.then(|| view! {
|
||||||
|
<button class="btn btn-primary" on:click=move |_| {
|
||||||
|
cmd_tx_go.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
|
||||||
|
}>{t!(i18n, go)}</button>
|
||||||
|
})}
|
||||||
|
{move || {
|
||||||
|
// Show the empty-move button only when (0,0) is a valid
|
||||||
|
// first or second move given what has already been staged.
|
||||||
|
let staged = staged_moves.get();
|
||||||
|
let show = is_move_stage && staged.len() < 2 && (
|
||||||
|
valid_seqs_empty.is_empty() || match staged.len() {
|
||||||
|
0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0),
|
||||||
|
1 => {
|
||||||
|
let (f0, t0) = staged[0];
|
||||||
|
valid_seqs_empty.iter()
|
||||||
|
.filter(|(m1, _)| {
|
||||||
|
m1.get_from() as u8 == f0
|
||||||
|
&& m1.get_to() as u8 == t0
|
||||||
|
})
|
||||||
|
.any(|(_, m2)| m2.get_from() == 0)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
show.then(|| view! {
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
on:click=move |_| {
|
||||||
|
selected_origin.set(None);
|
||||||
|
staged_moves.update(|v| v.push((0, 0)));
|
||||||
|
}
|
||||||
|
>{t!(i18n, empty_move)}</button>
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</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 +286,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 +307,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,61 +17,81 @@ 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">
|
||||||
<div class="lang-switcher">
|
// ── Decorative board header ─────────────────────────────────────
|
||||||
<button
|
<div class="login-card-header">
|
||||||
class:lang-active=move || i18n.get_locale() == Locale::en
|
<div class="login-board-stripe"></div>
|
||||||
on:click=move |_| i18n.set_locale(Locale::en)
|
|
||||||
>"EN"</button>
|
|
||||||
<button
|
|
||||||
class:lang-active=move || i18n.get_locale() == Locale::fr
|
|
||||||
on:click=move |_| i18n.set_locale(Locale::fr)
|
|
||||||
>"FR"</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1>"Trictrac"</h1>
|
// ── Card body ──────────────────────────────────────────────────
|
||||||
|
<div class="login-card-body">
|
||||||
|
<div class="login-lang-switcher">
|
||||||
|
<div class="lang-switcher">
|
||||||
|
<button
|
||||||
|
class:lang-active=move || i18n.get_locale() == Locale::en
|
||||||
|
on:click=move |_| i18n.set_locale(Locale::en)
|
||||||
|
>"EN"</button>
|
||||||
|
<button
|
||||||
|
class:lang-active=move || i18n.get_locale() == Locale::fr
|
||||||
|
on:click=move |_| i18n.set_locale(Locale::fr)
|
||||||
|
>"FR"</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error.map(|err| view! { <p class="error-msg">{err}</p> })}
|
<h1 class="login-title">"Trictrac"</h1>
|
||||||
|
<p class="login-subtitle">
|
||||||
|
<em>"Jeu de trictrac"</em>
|
||||||
|
" — "
|
||||||
|
<em>"XVIII" <sup>"e"</sup> " siècle"</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
<input
|
<div class="login-ornament">"✦"</div>
|
||||||
type="text"
|
|
||||||
placeholder=move || t_string!(i18n, room_name_placeholder)
|
|
||||||
prop:value=move || room_name.get()
|
|
||||||
on:input=move |ev| set_room_name.set(event_target_value(&ev))
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
{error.map(|err| view! { <p class="error-msg">{err}</p> })}
|
||||||
class="btn btn-primary"
|
|
||||||
disabled=move || room_name.get().is_empty()
|
|
||||||
on:click=move |_| {
|
|
||||||
cmd_tx_create
|
|
||||||
.unbounded_send(NetCommand::CreateRoom { room: room_name.get() })
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t!(i18n, create_room)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<input
|
||||||
class="btn btn-secondary"
|
class="login-input"
|
||||||
disabled=move || room_name.get().is_empty()
|
type="text"
|
||||||
on:click=move |_| {
|
placeholder=move || t_string!(i18n, room_name_placeholder)
|
||||||
cmd_tx_join
|
prop:value=move || room_name.get()
|
||||||
.unbounded_send(NetCommand::JoinRoom { room: room_name.get() })
|
on:input=move |ev| set_room_name.set(event_target_value(&ev))
|
||||||
.ok();
|
/>
|
||||||
}
|
|
||||||
>
|
|
||||||
{t!(i18n, join_room)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<div class="login-actions">
|
||||||
class="btn btn-bot"
|
<button
|
||||||
on:click=move |_| {
|
class="login-btn login-btn-primary"
|
||||||
cmd_tx_bot.unbounded_send(NetCommand::PlayVsBot).ok();
|
disabled=move || room_name.get().is_empty()
|
||||||
}
|
on:click=move |_| {
|
||||||
>
|
cmd_tx_create
|
||||||
{t!(i18n, play_vs_bot)}
|
.unbounded_send(NetCommand::CreateRoom { room: room_name.get() })
|
||||||
</button>
|
.ok();
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t!(i18n, create_room)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="login-btn login-btn-secondary"
|
||||||
|
disabled=move || room_name.get().is_empty()
|
||||||
|
on:click=move |_| {
|
||||||
|
cmd_tx_join
|
||||||
|
.unbounded_send(NetCommand::JoinRoom { room: room_name.get() })
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t!(i18n, join_room)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="login-btn login-btn-bot"
|
||||||
|
on:click=move |_| {
|
||||||
|
cmd_tx_bot.unbounded_send(NetCommand::PlayVsBot).ok();
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t!(i18n, play_vs_bot)}
|
||||||
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -86,17 +86,22 @@ 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! {
|
||||||
{t!(i18n, hold)}
|
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
|
||||||
</button>
|
<button class="btn btn-secondary" on:click=move |_| {
|
||||||
<button class="btn btn-primary" on:click=move |_| {
|
dismissed.set(true);
|
||||||
cmd_tx.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
|
}>
|
||||||
}>
|
{t!(i18n, hold)}
|
||||||
{t!(i18n, go)}
|
</button>
|
||||||
</button>
|
<button class="btn btn-primary" on:click=move |_| {
|
||||||
</div>
|
cmd_tx.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
|
||||||
|
}>
|
||||||
|
{t!(i18n, go)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue