use std::collections::VecDeque; use futures::channel::mpsc::UnboundedSender; use leptos::prelude::*; use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, MoveRules}; use crate::app::{GameUiState, NetCommand, PauseReason}; use crate::i18n::*; use crate::trictrac::types::{JanEntry, PlayerAction, SerStage, SerTurnStage}; use super::board::Board; use super::die::Die; use super::score_panel::PlayerScorePanel; #[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) } /// Split `dice_jans` into (viewer_jans, opponent_jans). fn split_jans(dice_jans: &[JanEntry], viewer_is_active: bool) -> (Vec, Vec) { let mut mine = Vec::new(); let mut theirs = Vec::new(); for e in dice_jans { if viewer_is_active { if e.total >= 0 { mine.push(e.clone()); } else { theirs.push(JanEntry { total: -e.total, points_per: -e.points_per, ..e.clone() }); } } else if e.total >= 0 { theirs.push(e.clone()); } else { mine.push(JanEntry { total: -e.total, points_per: -e.points_per, ..e.clone() }); } } (mine, theirs) } #[component] pub fn GameScreen(state: GameUiState) -> impl IntoView { let i18n = use_i18n(); let vs = state.view_state.clone(); let player_id = state.player_id; let is_my_turn = vs.active_mp_player == Some(player_id); let is_move_stage = is_my_turn && matches!( vs.turn_stage, SerTurnStage::Move | SerTurnStage::HoldOrGoChoice ); let waiting_for_confirm = state.waiting_for_confirm; let pause_reason = state.pause_reason.clone(); // ── Hovered jan moves (shown as arrows on the board) ────────────────────── let hovered_jan_moves: RwSignal> = RwSignal::new(vec![]); provide_context(hovered_jan_moves); // ── Staged move state ────────────────────────────────────────────────────── let selected_origin: RwSignal> = RwSignal::new(None); let staged_moves: RwSignal> = RwSignal::new(Vec::new()); let cmd_tx = use_context::>() .expect("UnboundedSender not found in context"); let pending = use_context::>>() .expect("pending not found in context"); let cmd_tx_effect = cmd_tx.clone(); Effect::new(move |_| { let moves = staged_moves.get(); if moves.len() == 2 { let to_cm = |&(from, to): &(u8, u8)| { CheckerMove::new(from as usize, to as usize).unwrap_or_default() }; cmd_tx_effect .unbounded_send(NetCommand::Action(PlayerAction::Move( to_cm(&moves[0]), to_cm(&moves[1]), ))) .ok(); staged_moves.set(vec![]); selected_origin.set(None); } }); // ── Auto-roll effect ───────────────────────────────────────────────────── // GameScreen is fully re-mounted on every ViewState update (state is a // plain prop, not a signal), so this effect fires exactly once per // RollDice phase entry and will not double-send. // Guard: suppressed while waiting_for_confirm — the AfterOpponentMove // buffered state shows the human's RollDice turn but the auto-roll must // wait until the buffer is drained and the live screen state is shown. let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice; if show_roll && !waiting_for_confirm { let cmd_tx_auto = cmd_tx.clone(); Effect::new(move |_| { cmd_tx_auto.unbounded_send(NetCommand::Action(PlayerAction::Roll)).ok(); }); } let dice = vs.dice; let show_dice = dice != (0, 0); // ── Button senders ───────────────────────────────────────────────────────── let cmd_tx_go = cmd_tx.clone(); let cmd_tx_quit = cmd_tx.clone(); let cmd_tx_end_quit = cmd_tx.clone(); let cmd_tx_end_replay = cmd_tx.clone(); 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 ───────────────────────────────── let (my_jans, opp_jans) = split_jans(&vs.dice_jans, is_my_turn && !show_roll); // ── Scores ───────────────────────────────────────────────────────────────── let my_score = vs.scores[player_id as usize].clone(); let opp_score = vs.scores[1 - player_id as usize].clone(); // ── Capture for closures ─────────────────────────────────────────────────── let stage = vs.stage.clone(); let turn_stage = vs.turn_stage.clone(); let room_id = state.room_id.clone(); let is_bot_game = state.is_bot_game; // ── Game-over info ───────────────────────────────────────────────────────── let stage_is_ended = stage == SerStage::Ended; let winner_is_me = my_score.holes >= 12; let opp_name_end = opp_score.name.clone(); view! {
// ── Top bar ──────────────────────────────────────────────────────
{move || if is_bot_game { t_string!(i18n, vs_bot_label).to_owned() } else { t_string!(i18n, room_label, id = room_id.as_str()) }}
{t!(i18n, quit)}
// ── Opponent score (above board) ───────────────────────────────── // ── Board + side panel ───────────────────────────────────────────
// ── Side panel ───────────────────────────────────────────────
// Status message
{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), }) } }}
// Dice (always shown when rolled, used state depends on whose turn) {show_dice.then(|| view! {
{move || { let (d0, d1) = if is_move_stage { matched_dice_used(&staged_moves.get(), dice) } else { (true, true) }; view! { } }}
})} // Action buttons
{waiting_for_confirm.then(|| view! { })} {show_hold_go.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! { }) }}
// ── Player score (below board) ──────────────────────────────────── // ── Game-over overlay ───────────────────────────────────────────── {stage_is_ended.then(|| { let winner_text = if winner_is_me { t_string!(i18n, you_win).to_owned() } else { t_string!(i18n, opp_wins, name = opp_name_end.as_str()) }; view! {

{t!(i18n, game_over)}

{winner_text}

{is_bot_game.then(|| view! { })}
} })}
} }