//! Briscola table view: classic two-player and four-player partnership. //! //! The two variants share this view: four seats render in the usual cross //! (partners opposite), two seats stack opponent over player. The center //! holds the trick in progress, the stock pile and the face-up briscola. use sycamore::prelude::*; use crate::components::card::{card_back, card_img}; use crate::components::copy::CopyButton; use crate::model::{ hand_sort_key, is_individual_game, suit_name, GameView, PlayerView, Scores, }; use crate::pages::game::watch_link; fn player_for_seat(game: &GameView, seat: usize) -> Option { game.players.iter().find(|p| p.seat == seat).cloned() } /// Panel for one player seat (name, team, hidden card count, points). fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View { let Some(player) = player_for_seat(&game, seat) 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_badge = (!is_individual_game(&game.game_type)) .then(|| player.team.clone()) .flatten() .filter(|team| !team.is_empty()) .map(|team| view! { span(class="team-badge") { "Team " (team) } }); let points = player.points; let captured = player.captured_count; let backs = (0..player.cards_left) .map(|_| card_back("mini")) .collect::>(); view! { div(class=cls) { div(class="seat-name") { (name) (team_badge) } div(class="seat-cards") { (backs) } div(class="seat-stats") { (points) " points ยท " (captured) " cards" } } } } /// The stock pile with the face-up briscola card beside it. fn stock_view(game: &GameView) -> View { let backs = (0..game.stock_count.min(3)) .map(|_| card_back("stock-card")) .collect::>(); let trump = game .briscola_card .clone() .map(|code| card_img(code, "briscola-card")); let label = format!("{} in stock", game.stock_count); view! { div(class="stock") { div(class="stock-pile") { (backs) } (trump) span(class="stock-count") { (label) } } } } /// The cards of the trick in progress, in play order, with player names. fn trick_view(game: &GameView) -> View { if game.trick.is_empty() { return view! { p(class="table-empty") { "Empty table" } }; } let cards = game .trick .iter() .map(|tc| { let name = player_for_seat(game, tc.seat) .map(|p| p.name) .unwrap_or_default(); let card = card_img(tc.card.clone(), "table-card"); view! { div(class="trick-slot") { span(class="trick-name") { (name) } (card) } } }) .collect::>(); view! { (cards) } } /// Announcement strip for the most recently completed trick. fn last_trick_banner(game: &GameView) -> View { let Some(trick) = game.last_trick.clone() else { return view! {}; }; let name = player_for_seat(game, trick.winner) .map(|p| p.name) .unwrap_or_default(); let cards = trick .cards .iter() .map(|tc| card_img(tc.card.clone(), "move-card")) .collect::>(); view! { div(class="move-banner") { strong { (name) } span { " takes the trick " } (cards) } } } /// The main briscola table. pub fn briscola_table( game: GameView, on_hand_card: impl Fn(String) + Copy + 'static, now: Signal, ) -> View { // Own seat: the only player entry carrying a hand. Spectators have no // hand, so they watch from seat 0's perspective. let viewer_seat = game .players .iter() .find(|p| p.hand.is_some()) .map(|p| p.seat) .unwrap_or(0); let player_count = if game.player_count > 0 { game.player_count } else { game.players.len().max(1) }; let my_turn = game.your_turn == Some(true); let turn_note = if game.phase == "finished" { "Match finished".to_string() } else if game.phase == "hand_end" { "Deal 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 countdown = game.turn_deadline.as_ref().map(|deadline| { // A dynamic closure so only the ticking number re-renders. let deadline_ms = js_sys::Date::parse(deadline); view! { span(class="turn-timer") { "Auto-play in " (move || { ((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32 }) "s" } } }); let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 }); let target = game.target_score; let deal = game.hand_number; // Two-player briscola has no partnerships: show the players' names. let score_line = if is_individual_game(&game.game_type) { format!( "{} {} โ€” {} {} (target {})", game.player_name_for_team("A"), scores.a, scores.b, game.player_name_for_team("B"), target ) } else { format!("Team A {} โ€” {} Team B (target {})", scores.a, scores.b, target) }; let (grid_cls, top, left, right) = if player_count == 2 { ( "table-grid duo", seat_panel(game.clone(), (viewer_seat + 1) % 2, "top"), view! {}, view! {}, ) } else { ( "table-grid", seat_panel(game.clone(), (viewer_seat + 2) % 4, "top"), seat_panel(game.clone(), (viewer_seat + 1) % 4, "left"), seat_panel(game.clone(), (viewer_seat + 3) % 4, "right"), ) }; let viewer = player_for_seat(&game, viewer_seat); let my_points = viewer.as_ref().map(|p| p.points).unwrap_or(0); let mut hand = viewer.and_then(|p| p.hand).unwrap_or_default(); hand.sort_by_key(|c| hand_sort_key(c)); let hand_cards = hand .into_iter() .map(|code| { let card_view = card_img(code.clone(), "hand-card"); view! { button(class="hand-slot", on:click=move |_| { if my_turn { on_hand_card(code.clone()); } }) { (card_view) } } }) .collect::>(); let hint = my_turn.then(|| view! { p(class="text-secondary small text-center") { "Click a card to play it." } }); // Spectators have no hand: render the pivot seat as a regular panel so // its points stay visible where the hand would be. let bottom = if game.spectator { seat_panel(game.clone(), viewer_seat, "bottom") } else { view! { div(class="seat-bottom") { div(class="seat-stats own-stats") { (my_points) " points" } div(class="hand") { (hand_cards) } (hint) } } }; let spectator_badge = game.spectator.then(|| view! { span(class="spectator-badge") { "Spectating" } }); let watch_link = watch_link(&game.id); let trump_badge = (!game.briscola_suit.is_empty()).then(|| { let suit = suit_name(&game.briscola_suit); view! { span(class="briscola-badge") { "Briscola: " (suit) } } }); // Precomputed so the final view's closures only capture owned views. let banner = last_trick_banner(&game); let trick = trick_view(&game); let stock = stock_view(&game); view! { div(class="table-wrap") { div(class="hud") { a(href="/") { "โ† Lobby" } span { "Deal " (deal) } (trump_badge) (spectator_badge) span(class="hud-scores") { (score_line) } span(class=turn_cls) { (turn_note) } (countdown) CopyButton(text=watch_link, label="Copy watch link".to_string()) } div(class=grid_cls) { (top) (left) div(class="center") { (banner) div(class="table-cards") { (trick) } (stock) } (right) (bottom) } } } }