//! Live game page: table view over the websocket. use sycamore::prelude::*; use crate::components::card::{card_back, card_img}; use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage}; use crate::ws::{self, GameSocket}; fn send_play(socket: Signal>, card: String, capture: Option>) { if let Some(s) = socket.get_clone() { s.play(&card, capture); } } /// Panel for one player seat (name, team, hidden card count, stats). fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View { let Some(player) = game.players.iter().find(|p| p.seat == seat).cloned() else { return view! {}; }; let active = game.phase == "playing" && game.turn == seat; let cls = format!( "seat seat-{position}{}", if active { " active" } else { "" } ); let name = player.name.clone(); let team = player.team.clone(); let captured = player.captured_count; let scope = player.scope; let backs = (0..player.cards_left) .map(|_| card_back("mini")) .collect::>(); view! { div(class=cls) { div(class="seat-name") { (name) span(class="team-badge") { "Team " (team) } } div(class="seat-cards") { (backs) } div(class="seat-stats") { (captured) " captured · " (scope) " scope" } } } } /// The last-move announcement strip. fn move_banner(mv: MoveView) -> View { let name = mv.name.clone(); let action = if mv.captured.is_empty() { "played" } else { "capturing" }; let played = card_img(mv.card.clone(), "move-card"); let captured = mv .captured .iter() .map(|c| card_img(c.clone(), "move-card")) .collect::>(); let scopa = mv.scopa.then(|| view! { span(class="scopa-badge") { "Scopa!" } }); view! { div(class="move-banner") { strong { (name) } span { " " (action) " " } (played) (captured) (scopa) } } } #[component(inline_props)] pub fn GamePage(id: String) -> View { let game = create_signal(Option::::None); let error = create_signal(Option::::None); let capture_choice = create_signal(Option::<(String, Vec>)>::None); let selected = create_signal(Option::::None); let over = create_signal(Option::<(Scores, Option)>::None); let closed = create_signal(false); let socket = create_signal(Option::::None); { let on_message = move |msg: ServerMessage| match msg { ServerMessage::State { game: g } => { capture_choice.set(None); selected.set(None); game.set(Some(g)); } ServerMessage::GameOver { scores, winner } => { over.set(Some((scores, winner))); } ServerMessage::Error { message, .. } => error.set(Some(message)), }; let on_close = move || closed.set(true); match ws::connect(&id, on_message, on_close) { Some(s) => socket.set(Some(s)), None => error.set(Some("Could not connect to the game".to_string())), } } // Clicking a card in the player's own hand. let on_hand_card = move |code: String| { let Some(g) = game.get_clone() else { return }; if g.your_turn != Some(true) { return; } let options = g .legal_moves .as_ref() .and_then(|m| m.get(&code)) .cloned(); match options { None => send_play(socket, code, None), Some(mut opts) if opts.len() == 1 => { send_play(socket, code, Some(opts.remove(0))) } Some(opts) => capture_choice.set(Some((code, opts))), } }; view! { div(class="game-page") { (move || error.get_clone().map(|e| view! { div(class="toast") { (e) } })) (move || match game.get_clone() { None => { let status = if closed.get() { "Connection closed." } else { "Connecting to the game…" }; view! { div(class="panel status-panel") { p { (status) } p { a(href="/") { "Back to lobby" } } } } } Some(g) if g.phase == "lobby" => lobby_view(g), Some(g) => table_view(g, on_hand_card, selected), }) (move || capture_choice.get_clone().map(|(card, options)| { capture_picker(card, options, socket, capture_choice) })) (move || game_over_view(over.get_clone(), game.get_clone())) } } } /// Lobby view while waiting for the fourth player. fn lobby_view(game: GameView) -> View { let seats_open = 4usize.saturating_sub(game.players.len()); let join_code = game.join_code.clone(); let players = game .players .iter() .map(|p| { let name = p.name.clone(); let seat = p.seat; let team = p.team.clone(); view! { li { strong { (name) } span { " (seat " (seat) ", team " (team) ")" } } } }) .collect::>(); view! { div(class="panel status-panel") { h2 { "Waiting for players" } p { "Share this join code:" } p(class="join-code") { (join_code) } ul(class="roster") { (players) } p { (seats_open) " seat(s) still open" } p { a(href="/") { "Back to lobby" } } } } } fn player_for_seat(game: &GameView, seat: usize) -> Option { game.players.iter().find(|p| p.seat == seat).cloned() } /// The main table view. fn table_view( game: GameView, on_hand_card: impl Fn(String) + Copy + 'static, selected: Signal>, ) -> View { // Own seat: the only player entry carrying a hand. let viewer_seat = game .players .iter() .find(|p| p.hand.is_some()) .map(|p| p.seat) .unwrap_or(0); let left = seat_panel(game.clone(), (viewer_seat + 1) % 4, "left"); let top = seat_panel(game.clone(), (viewer_seat + 2) % 4, "top"); let right = seat_panel(game.clone(), (viewer_seat + 3) % 4, "right"); let my_turn = game.your_turn == Some(true); let turn_note = if game.phase == "finished" { "Match finished".to_string() } else if my_turn { "Your turn".to_string() } else { let name = player_for_seat(&game, game.turn) .map(|p| p.name) .unwrap_or_default(); format!("{name}'s turn") }; let turn_cls = if my_turn { "turn-note you" } else { "turn-note" }; let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 }); let table_cards = game .table .iter() .map(|c| card_img(c.clone(), "table-card")) .collect::>(); let empty_table = game.table.is_empty().then(|| view! { p(class="table-empty") { "Empty table" } }); let banner = game.last_move.clone().map(move_banner); let hand_number = game.hand_number; let target_score = game.target_score; let hand = player_for_seat(&game, viewer_seat) .and_then(|p| p.hand) .unwrap_or_default(); let current_selection = selected.get_clone(); let hand_cards = hand .into_iter() .map(|code| { let is_selected = current_selection.as_deref() == Some(code.as_str()); let cls = if is_selected { "hand-slot selected" } else { "hand-slot" }; let card_view = card_img(code.clone(), "hand-card"); view! { button(class=cls, on:click=move |_| { if my_turn { on_hand_card(code.clone()); } }) { (card_view) } } }) .collect::>(); let hint = my_turn.then(|| view! { p(class="hint") { "Click a card to play it. If it can capture in several ways you " "will be asked to choose." } }); view! { div(class="table-wrap") { div(class="hud") { a(href="/") { "← Lobby" } span { "Hand " (hand_number) } span(class="hud-scores") { "Team A " (scores.a) " — " (scores.b) " Team B (target " (target_score) ")" } span(class=turn_cls) { (turn_note) } } div(class="table-grid") { (top) (left) div(class="center") { (banner) div(class="table-cards") { (table_cards) (empty_table) } } (right) div(class="seat-bottom") { div(class="hand") { (hand_cards) } (hint) } } } } } /// Popup listing the legal captures for a selected card. fn capture_picker( card: String, options: Vec>, socket: Signal>, capture_choice: Signal>)>>, ) -> View { let title = format!("Capture with {}", card_label(&card)); let option_views = options .into_iter() .map(|capture| { let played_card = card.clone(); let cards = capture .iter() .map(|c| card_img(c.clone(), "mini")) .collect::>(); view! { button(class="capture-option", on:click=move |_| { send_play(socket, played_card.clone(), Some(capture.clone())); capture_choice.set(None); }) { (cards) } } }) .collect::>(); view! { div(class="overlay") { div(class="picker") { h3 { (title) } div(class="capture-options") { (option_views) } button(class="button", on:click=move |_| capture_choice.set(None)) { "Cancel" } } } } } /// End-of-match overlay. fn game_over_view(over: Option<(Scores, Option)>, game: Option) -> View { let result = over.or_else(|| { game.filter(|g| g.phase == "finished") .map(|g| (g.scores.unwrap_or(Scores { a: 0, b: 0 }), g.winner)) }); match result { None => view! {}, Some((scores, winner)) => { let winner = winner.unwrap_or_else(|| "?".to_string()); let line = format!("Team {winner} wins {} – {}", scores.a, scores.b); view! { div(class="overlay") { div(class="picker") { h2 { "Match over" } p { (line) } div(class="gameover-actions") { a(class="button primary", href="/") { "Back to lobby" } a(class="button", href="/history") { "My matches" } } } } } } } }