use std::cell::Cell; use std::collections::VecDeque; use futures::channel::mpsc::UnboundedSender; use leptos::prelude::*; use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules}; use super::die::Die; use crate::app::{GameUiState, NetCommand, PauseReason}; use crate::game::trictrac::types::{PlayerAction, PreGameRollState, SerStage, SerTurnStage}; use crate::i18n::*; use crate::portal::lobby::{qr_svg, room_url}; use super::board::Board; use super::score_panel::PlayerScorePanel; use super::scoring::ScoringPanel; #[component] pub fn GameScreen(state: GameUiState) -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().unwrap_or_else(|| RwSignal::new(None)); 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(); // Non-reactive counter so we can detect when staged_moves grows without // returning a value from the Effect (which causes a Leptos reactive loop // when the Effect also writes to the same signal it reads). let prev_staged_len = Cell::new(0usize); Effect::new(move |_| { let moves = staged_moves.get(); let n = moves.len(); // Play checker sound whenever a move is added (own moves, immediate feedback). if n > prev_staged_len.get() { crate::game::sound::play_checker_move(); } prev_staged_len.set(n); if n == 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); // Reset the counter so the next turn starts clean. prev_staged_len.set(0); } }); // ── 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. // Guard: never auto-roll during the pre-game ceremony (the ceremony overlay // has its own Roll button for PlayerAction::PreGameRoll). let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice && vs.stage != SerStage::PreGameRoll; 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(); // Only show the fallback Go button when there is no ScoringPanel showing it. let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice && state.my_scored_event.is_none(); // ── 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(); // ── Scores ───────────────────────────────────────────────────────────────── let my_score = vs.scores[player_id as usize].clone(); let opp_score = vs.scores[1 - player_id as usize].clone(); // ── Ceremony state (extracted before vs is moved into Board) ──────────────── let is_ceremony = vs.stage == SerStage::PreGameRoll; let pre_game_roll_data: Option = vs.pre_game_roll.clone(); let my_name_ceremony = my_score.name.clone(); let opp_name_ceremony = opp_score.name.clone(); let cmd_tx_ceremony = cmd_tx.clone(); // ── Scoring notifications ────────────────────────────────────────────────── let my_scored_event = state.my_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; // §6e — fields where a battue (hit) was scored; ripple animation shown there. let hit_fields: Vec = { let is_hit_jan = |jan: &Jan| { matches!( jan, Jan::TrueHitSmallJan | Jan::TrueHitBigJan | Jan::TrueHitOpponentCorner | Jan::FalseHitSmallJan | Jan::FalseHitBigJan ) }; let mut fields: Vec = vec![]; for event_opt in [&my_scored_event, &opp_scored_event] { if let Some(event) = event_opt { for entry in &event.jans { if is_hit_jan(&entry.jan) { for (m1, m2) in &entry.moves { for m in [m1, m2] { let to = m.get_to() as u8; if to != 0 && !fields.contains(&to) { fields.push(to); } } } } } } } fields }; // ── Sound effects (fire once on mount = once per state snapshot) ────────── // Dice roll: dice just appeared (no preceding moves in this snapshot). if show_dice && last_moves.is_none() { crate::game::sound::play_dice_roll(); } // Checker move: moves were committed in the preceding action. if last_moves.is_some() { crate::game::sound::play_checker_move(); } // Scoring: hole takes priority over plain points. if let Some(ref ev) = my_scored_event { if ev.holes_gained > 0 { crate::game::sound::play_hole_scored(); } else { crate::game::sound::play_points_scored(); } } // ── Capture for closures ─────────────────────────────────────────────────── let stage = vs.stage.clone(); let turn_stage = vs.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 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 my_name_end = my_score.name.clone(); let my_holes_end = my_score.holes; let opp_name_end = opp_score.name.clone(); let opp_holes_end = opp_score.holes; let share_open = RwSignal::new(false); let share_url = if !is_bot_game { room_url(&room_id) } else { String::new() }; let share_svg = if !is_bot_game { qr_svg(&share_url) } else { String::new() }; 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()) }} {move || (!is_bot_game).then(|| view! { })} {move || auth_username.get().map(|u| view! { "▶ " {u} })} {t!(i18n, quit)}
// ── Share popover ───────────────────────────────────────────────── {move || share_open.get().then(|| view! { } }