feat(client_web): highlight valid moves

This commit is contained in:
Henri Bourcereau 2026-04-01 21:42:08 +02:00
parent 9fe79ffc7a
commit 082dc5a384
3 changed files with 171 additions and 29 deletions

View file

@ -1,4 +1,5 @@
use leptos::prelude::*; use leptos::prelude::*;
use trictrac_store::CheckerMove;
use crate::trictrac::types::{SerTurnStage, ViewState}; use crate::trictrac::types::{SerTurnStage, ViewState};
@ -35,6 +36,54 @@ fn displayed_value(
val val
} }
/// Fields whose checkers may be selected as the next origin given already-staged moves.
fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -> Vec<u8> {
let mut v: Vec<u8> = match staged.len() {
0 => seqs.iter()
.map(|(m1, _)| m1.get_from() as u8)
.filter(|&f| f != 0)
.collect(),
1 => {
let (f0, t0) = staged[0];
seqs.iter()
.filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0)
.map(|(_, m2)| m2.get_from() as u8)
.filter(|&f| f != 0)
.collect()
}
_ => vec![],
};
v.sort_unstable();
v.dedup();
v
}
/// Valid destinations for a selected origin given already-staged moves.
/// May include 0 (exit); callers handle that case.
fn valid_dests_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)], origin: u8) -> Vec<u8> {
let mut v: Vec<u8> = match staged.len() {
0 => seqs.iter()
.filter(|(m1, _)| m1.get_from() as u8 == origin)
.map(|(m1, _)| m1.get_to() as u8)
.collect(),
1 => {
let (f0, t0) = staged[0];
seqs.iter()
.filter(|(m1, m2)| {
m1.get_from() as u8 == f0
&& m1.get_to() as u8 == t0
&& m2.get_from() as u8 == origin
})
.map(|(_, m2)| m2.get_to() as u8)
.collect()
}
_ => vec![],
};
v.sort_unstable();
v.dedup();
v
}
#[component] #[component]
pub fn Board( pub fn Board(
view_state: ViewState, view_state: ViewState,
@ -43,6 +92,8 @@ pub fn Board(
selected_origin: RwSignal<Option<u8>>, selected_origin: RwSignal<Option<u8>>,
/// Moves staged so far this turn (max 2). Each entry is (from, to), 0 = empty move. /// Moves staged so far this turn (max 2). Each entry is (from, to), 0 = empty move.
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).
valid_sequences: Vec<(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)
@ -52,19 +103,27 @@ pub fn Board(
); );
let is_white = player_id == 0; let is_white = player_id == 0;
// `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()
.map(|&field_num| { .map(|&field_num| {
// Each reactive closure gets its own owned clone — Vec<(CheckerMove,CheckerMove)>
// is Send, which Leptos requires for reactive attribute functions.
let seqs_c = valid_sequences.clone();
let seqs_k = valid_sequences.clone();
view! { view! {
<div <div
class=move || { class=move || {
let moves = staged_moves.get(); let staged = staged_moves.get();
let val = displayed_value(board, &moves, is_white, field_num); let val = displayed_value(board, &staged, is_white, field_num);
let is_mine = if is_white { val > 0 } else { val < 0 }; let is_mine = if is_white { val > 0 } else { val < 0 };
let can_stage = is_move_stage && moves.len() < 2; let can_stage = is_move_stage && staged.len() < 2;
let sel = selected_origin.get(); let sel = selected_origin.get();
let mut cls = "field".to_string(); let mut cls = "field".to_string();
if seqs_c.is_empty() {
// No restriction (dice not rolled or not move stage)
if can_stage && (sel.is_some() || is_mine) { if can_stage && (sel.is_some() || is_mine) {
cls.push_str(" clickable"); cls.push_str(" clickable");
} }
@ -72,26 +131,68 @@ pub fn Board(
if can_stage && sel.is_some() && sel != Some(field_num) { if can_stage && sel.is_some() && sel != Some(field_num) {
cls.push_str(" dest"); cls.push_str(" dest");
} }
} else if can_stage {
if let Some(origin) = sel {
if origin == field_num {
cls.push_str(" selected clickable");
} else {
let dests = valid_dests_for(&seqs_c, &staged, origin);
// Only highlight non-exit destinations (field 0 = exit has no tile)
if dests.iter().any(|&d| d == field_num && d != 0) {
cls.push_str(" clickable dest");
}
}
} else {
let origins = valid_origins_for(&seqs_c, &staged);
if origins.iter().any(|&o| o == field_num) {
cls.push_str(" clickable");
}
}
}
cls cls
} }
on:click=move |_| { on:click=move |_| {
if !is_move_stage { return; } if !is_move_stage { return; }
if staged_moves.get_untracked().len() >= 2 { return; } let staged = staged_moves.get_untracked();
if staged.len() >= 2 { return; }
let moves = staged_moves.get_untracked();
let val = displayed_value(board, &moves, is_white, field_num);
let is_mine = if is_white { val > 0 } else { val < 0 };
match selected_origin.get_untracked() { match selected_origin.get_untracked() {
Some(origin) if origin == field_num => { Some(origin) if origin == field_num => {
selected_origin.set(None); selected_origin.set(None);
} }
Some(origin) => { Some(origin) => {
let valid = if seqs_k.is_empty() {
true
} else {
valid_dests_for(&seqs_k, &staged, origin)
.iter()
.any(|&d| d == field_num)
};
if valid {
staged_moves.update(|v| v.push((origin, field_num))); staged_moves.update(|v| v.push((origin, field_num)));
selected_origin.set(None); selected_origin.set(None);
} }
None if is_mine => selected_origin.set(Some(field_num)), }
None => {} None => {
if seqs_k.is_empty() {
let val = displayed_value(board, &staged, is_white, field_num);
if is_white && val > 0 || !is_white && val < 0 {
selected_origin.set(Some(field_num));
}
} else {
let origins = valid_origins_for(&seqs_k, &staged);
if origins.iter().any(|&o| o == field_num) {
let dests = valid_dests_for(&seqs_k, &staged, field_num);
if !dests.is_empty() && dests.iter().all(|&d| d == 0) {
// All destinations are exits: auto-stage
staged_moves.update(|v| v.push((field_num, 0)));
} else {
selected_origin.set(Some(field_num));
}
}
}
}
} }
} }
> >

View file

@ -1,6 +1,6 @@
use futures::channel::mpsc::UnboundedSender; use futures::channel::mpsc::UnboundedSender;
use leptos::prelude::*; use leptos::prelude::*;
use trictrac_store::CheckerMove; use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, MoveRules};
use crate::app::{GameUiState, NetCommand}; use crate::app::{GameUiState, NetCommand};
use crate::i18n::*; use crate::i18n::*;
@ -111,6 +111,27 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice; let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice;
let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice; let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice;
// ── Valid move sequences for this turn ─────────────────────────────────────
// Computed once per ViewState snapshot; used by Board (highlighting) and the
// empty-move button (visibility).
let valid_sequences: Vec<(CheckerMove, CheckerMove)> = if is_move_stage && dice != (0, 0) {
let mut store_board = StoreBoard::new();
store_board.set_positions(&Color::White, vs.board);
let store_dice = StoreDice { values: dice };
let color = if player_id == 0 { Color::White } else { Color::Black };
let rules = MoveRules::new(&color, &store_board, store_dice);
let raw = rules.get_possible_moves_sequences(true, vec![]);
if player_id == 0 {
raw
} else {
raw.into_iter().map(|(m1, m2)| (m1.mirror(), m2.mirror())).collect()
}
} else {
vec![]
};
// Clone for the empty-move button reactive closure (Board consumes the original).
let valid_seqs_empty = valid_sequences.clone();
// ── Jan split: viewer_jans / opponent_jans ───────────────────────────────── // ── Jan split: viewer_jans / opponent_jans ─────────────────────────────────
let (my_jans, opp_jans) = split_jans(&vs.dice_jans, is_my_turn && !show_roll); let (my_jans, opp_jans) = split_jans(&vs.dice_jans, is_my_turn && !show_roll);
@ -164,6 +185,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
player_id=player_id player_id=player_id
selected_origin=selected_origin selected_origin=selected_origin
staged_moves=staged_moves staged_moves=staged_moves
valid_sequences=valid_sequences
/> />
// ── Side panel ─────────────────────────────────────────────── // ── Side panel ───────────────────────────────────────────────
@ -216,16 +238,35 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
cmd_tx_go.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok(); cmd_tx_go.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
}>{t!(i18n, go)}</button> }>{t!(i18n, go)}</button>
})} })}
{is_move_stage.then(|| view! { {move || {
// Show the empty-move button only when (0,0) is a valid
// first or second move given what has already been staged.
let staged = staged_moves.get();
let show = is_move_stage && staged.len() < 2 && (
valid_seqs_empty.is_empty() || match staged.len() {
0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0),
1 => {
let (f0, t0) = staged[0];
valid_seqs_empty.iter()
.filter(|(m1, _)| {
m1.get_from() as u8 == f0
&& m1.get_to() as u8 == t0
})
.any(|(_, m2)| m2.get_from() == 0)
}
_ => false,
}
);
show.then(|| view! {
<button <button
class="btn btn-secondary" class="btn btn-secondary"
disabled=move || 2 <= staged_moves.get().len()
on:click=move |_| { on:click=move |_| {
selected_origin.set(None); selected_origin.set(None);
staged_moves.update(|v| v.push((0, 0))); staged_moves.update(|v| v.push((0, 0)));
} }
>{t!(i18n, empty_move)}</button> >{t!(i18n, empty_move)}</button>
})} })
}}
</div> </div>
</div> </div>
</div> </div>

View file

@ -12,7 +12,7 @@ mod error;
pub use error::Error; pub use error::Error;
mod board; mod board;
pub use board::CheckerMove; pub use board::{Board, CheckerMove};
mod dice; mod dice;
pub use dice::{Dice, DiceRoller}; pub use dice::{Dice, DiceRoller};