feat(client_web): highlight valid moves
This commit is contained in:
parent
9fe79ffc7a
commit
082dc5a384
3 changed files with 171 additions and 29 deletions
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue