The call picker stacked the four 120px cards vertically, making the
dialog about 780px tall, and the overlay centered its content with no
scroll fallback. On typical laptop window heights that pushed the title
and options off-screen (the heading sat at y=-110 on a 560px viewport),
leaving what looked like a free-floating selection with no explanation
and no way to scroll.
Make dialogs scroll instead of clipping: the overlay scrolls and the
panel centers with `margin: auto`, which keeps every modal (the capture
picker and the scoring summaries too) centered when it fits and
reachable when it does not. Lay the call options out in a compact grid
(four across, 2x2 on phones) with 84px cards, name the won rank in the
title ("You won with the Re - name the card") and label each option
"Partner" or "Plays alone" so the choice reads at a glance.
530 lines
17 KiB
Rust
530 lines
17 KiB
Rust
//! Briscola chiamata table: five seats, the calling auction and the
|
||
//! secret-partner reveal.
|
||
//!
|
||
//! The auction starts with the player after the dealer: each player bids
|
||
//! a lower rank than the standing bid (A > 3 > K > H > J > 7 > 6 > 5 >
|
||
//! 4 > 2) or passes, and once the standing bid is the two the bidding
|
||
//! continues on points. The last bidder names a card of the won rank:
|
||
//! its holder is the secret partner, or the caller plays alone when it
|
||
//! is one of their own cards, and its suit is the trump. The partner's
|
||
//! identity is only revealed when the called card is played.
|
||
use sycamore::prelude::*;
|
||
|
||
use crate::components::card::{card_back, card_img};
|
||
use crate::components::copy::CopyButton;
|
||
use crate::model::{
|
||
card_label, hand_sort_key, rank_name, suit_name, AuctionBid, GameView, PlayerView,
|
||
};
|
||
use crate::pages::game::watch_link;
|
||
use crate::ws::GameSocket;
|
||
|
||
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
|
||
game.players.iter().find(|p| p.seat == seat).cloned()
|
||
}
|
||
|
||
fn player_name(game: &GameView, seat: usize) -> String {
|
||
player_for_seat(game, seat)
|
||
.map(|p| p.name)
|
||
.unwrap_or_else(|| format!("Seat {seat}"))
|
||
}
|
||
|
||
/// Human label of one auction bid.
|
||
fn bid_label(bid: &AuctionBid) -> String {
|
||
if bid.rank == 2 {
|
||
format!("2 at {} pts", bid.points)
|
||
} else {
|
||
rank_name(bid.rank)
|
||
}
|
||
}
|
||
|
||
/// Panel for one player seat (name, revealed role, hidden cards, 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();
|
||
// Role labels only appear once the server makes them public.
|
||
let role_badge = player
|
||
.team
|
||
.clone()
|
||
.filter(|role| !role.is_empty())
|
||
.map(|role| view! { span(class="team-badge role-badge") { (role) } });
|
||
let points = player.points;
|
||
let captured = player.captured_count;
|
||
let backs = (0..player.cards_left)
|
||
.map(|_| card_back("mini"))
|
||
.collect::<Vec<_>>();
|
||
view! {
|
||
div(class=cls) {
|
||
div(class="seat-name") {
|
||
(name)
|
||
(role_badge)
|
||
}
|
||
div(class="seat-cards") { (backs) }
|
||
div(class="seat-stats") {
|
||
(points) " points · " (captured) " cards"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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_name(game, tc.seat);
|
||
let card = card_img(tc.card.clone(), "table-card");
|
||
view! {
|
||
div(class="trick-slot") {
|
||
span(class="trick-name") { (name) }
|
||
(card)
|
||
}
|
||
}
|
||
})
|
||
.collect::<Vec<_>>();
|
||
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_name(game, trick.winner);
|
||
let cards = trick
|
||
.cards
|
||
.iter()
|
||
.map(|tc| card_img(tc.card.clone(), "move-card"))
|
||
.collect::<Vec<_>>();
|
||
view! {
|
||
div(class="move-banner") {
|
||
strong { (name) }
|
||
span { " takes the trick " }
|
||
(cards)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// "Patta — the deal is replayed" after a 60-60 deal.
|
||
fn patta_banner(game: &GameView) -> View {
|
||
let patta = game
|
||
.last_deal
|
||
.as_ref()
|
||
.is_some_and(|deal| deal.patta);
|
||
if !patta || game.phase != "auction" {
|
||
return view! {};
|
||
}
|
||
view! {
|
||
div(class="move-banner patta-banner") {
|
||
strong { "Patta!" }
|
||
span { " 60–60 — the deal is replayed." }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Announcement of the partner (or the solo caller) once the called card
|
||
/// has hit the table.
|
||
fn reveal_banner(game: &GameView) -> View {
|
||
if !game.called_played || game.phase == "auction" || game.phase == "calling" {
|
||
return view! {};
|
||
}
|
||
let caller = game
|
||
.caller
|
||
.map(|seat| player_name(game, seat))
|
||
.unwrap_or_default();
|
||
let text = if game.solo {
|
||
format!("{caller} is playing alone")
|
||
} else {
|
||
let partner = game
|
||
.partner
|
||
.map(|seat| player_name(game, seat))
|
||
.unwrap_or_default();
|
||
format!("{partner} is the partner of {caller}")
|
||
};
|
||
view! {
|
||
div(class="move-banner reveal-banner") {
|
||
span(class="reveal-text") { (text) }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The calling auction panel: standing bid, trail and, on turn, the bids.
|
||
fn auction_panel(
|
||
game: &GameView,
|
||
on_bid_rank: impl Fn(i32) + Copy + 'static,
|
||
on_bid_points: impl Fn(i32) + Copy + 'static,
|
||
on_pass: impl Fn() + Copy + 'static,
|
||
) -> View {
|
||
let Some(auction) = game.auction.clone() else {
|
||
return view! {};
|
||
};
|
||
let standing = match &auction.current {
|
||
Some(bid) => format!(
|
||
"Standing bid: {} — {}",
|
||
player_name(game, bid.seat),
|
||
bid_label(bid)
|
||
),
|
||
None => "No bids yet".to_string(),
|
||
};
|
||
let waiting_for = player_name(game, auction.turn);
|
||
let history = auction
|
||
.history
|
||
.iter()
|
||
.map(|bid| {
|
||
let text = format!("{}: {}", player_name(game, bid.seat), bid_label(bid));
|
||
view! { span(class="auction-chip") { (text) } }
|
||
})
|
||
.collect::<Vec<_>>();
|
||
|
||
let actions = if game.your_turn == Some(true) {
|
||
let legal = game.legal_bids.clone();
|
||
let (rank_buttons, point_buttons) = match legal {
|
||
Some(bids) => {
|
||
let ranks = bids
|
||
.ranks
|
||
.iter()
|
||
.map(|rank| {
|
||
let rank = *rank;
|
||
let name = rank_name(rank);
|
||
view! {
|
||
button(
|
||
class="bid-button",
|
||
on:click=move |_| on_bid_rank(rank),
|
||
) { (name) }
|
||
}
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let points = match (bids.points_min, bids.points_max) {
|
||
(Some(min), Some(max)) => (0..=2)
|
||
.map(|step| min + 2 * step)
|
||
.filter(|points| *points <= max)
|
||
.map(|points| {
|
||
view! {
|
||
button(
|
||
class="bid-button points",
|
||
on:click=move |_| on_bid_points(points),
|
||
) { (points) " pts" }
|
||
}
|
||
})
|
||
.collect::<Vec<_>>(),
|
||
_ => Vec::new(),
|
||
};
|
||
(ranks, points)
|
||
}
|
||
None => (Vec::new(), Vec::new()),
|
||
};
|
||
view! {
|
||
div(class="bid-options") {
|
||
(rank_buttons)
|
||
(point_buttons)
|
||
button(class="pass-button", on:click=move |_| on_pass()) { "Pass" }
|
||
}
|
||
}
|
||
} else {
|
||
view! {
|
||
p(class="text-secondary small mb-0") {
|
||
"Waiting for " (waiting_for) " to bid…"
|
||
}
|
||
}
|
||
};
|
||
|
||
view! {
|
||
div(class="auction-panel") {
|
||
div(class="auction-standing") { (standing) }
|
||
div(class="auction-history") { (history) }
|
||
(actions)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The main briscola chiamata table.
|
||
pub fn chiamata_table(
|
||
game: GameView,
|
||
on_hand_card: impl Fn(String) + Copy + 'static,
|
||
on_bid_rank: impl Fn(i32) + Copy + 'static,
|
||
on_bid_points: impl Fn(i32) + Copy + 'static,
|
||
on_pass: impl Fn() + Copy + 'static,
|
||
now: Signal<f64>,
|
||
) -> 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 my_turn = game.your_turn == Some(true);
|
||
let can_play = game.phase == "playing" && my_turn;
|
||
|
||
let turn_note = if game.phase == "finished" {
|
||
"Match finished".to_string()
|
||
} else if game.phase == "auction" {
|
||
if my_turn {
|
||
"Your bid".to_string()
|
||
} else {
|
||
let name = game
|
||
.auction
|
||
.as_ref()
|
||
.map(|a| player_name(&game, a.turn))
|
||
.unwrap_or_default();
|
||
format!("{name} to bid")
|
||
}
|
||
} else if game.phase == "calling" {
|
||
if my_turn {
|
||
"Name the called card".to_string()
|
||
} else {
|
||
let name = game
|
||
.caller
|
||
.map(|seat| player_name(&game, seat))
|
||
.unwrap_or_default();
|
||
format!("{name} is calling")
|
||
}
|
||
} else if my_turn {
|
||
"Your turn".to_string()
|
||
} else {
|
||
format!("{}'s turn", player_name(&game, game.turn))
|
||
};
|
||
let turn_cls = if my_turn { "turn-note you" } else { "turn-note" };
|
||
|
||
let deadline = if game.phase == "auction" {
|
||
game.auction_deadline.clone()
|
||
} else if game.phase == "calling" {
|
||
game.call_deadline.clone()
|
||
} else {
|
||
game.turn_deadline.clone()
|
||
};
|
||
let countdown = deadline.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 in "
|
||
(move || {
|
||
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
|
||
})
|
||
"s"
|
||
}
|
||
}
|
||
});
|
||
|
||
let score_line = match game.scores {
|
||
Some(scores) => {
|
||
let (side_a, side_b) = game.chiamata_sides();
|
||
format!(
|
||
"{side_a} {} — {} {side_b} (target {})",
|
||
scores.a, scores.b, game.declared_target
|
||
)
|
||
}
|
||
// Before the called card is played the side totals would leak the
|
||
// partner's identity; only the target is shown.
|
||
None => format!("Target {}", game.declared_target),
|
||
};
|
||
|
||
// Four opponents around the table: next player on the left, then
|
||
// clockwise up to the player before us on the right.
|
||
let left = seat_panel(game.clone(), (viewer_seat + 1) % 5, "left");
|
||
let top_left = seat_panel(game.clone(), (viewer_seat + 2) % 5, "tl");
|
||
let top_right = seat_panel(game.clone(), (viewer_seat + 3) % 5, "tr");
|
||
let right = seat_panel(game.clone(), (viewer_seat + 4) % 5, "right");
|
||
|
||
let patta = patta_banner(&game);
|
||
let reveal = reveal_banner(&game);
|
||
let center_content = match game.phase.as_str() {
|
||
"auction" => auction_panel(&game, on_bid_rank, on_bid_points, on_pass),
|
||
"calling" => {
|
||
let caller = game
|
||
.caller
|
||
.map(|seat| player_name(&game, seat))
|
||
.unwrap_or_default();
|
||
view! {
|
||
div(class="auction-panel") {
|
||
div(class="auction-standing") { (caller) " won the auction" }
|
||
p(class="text-secondary small mb-0") {
|
||
"Choosing the called card…"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
_ => {
|
||
let banner = last_trick_banner(&game);
|
||
let trick = trick_view(&game);
|
||
view! {
|
||
(banner)
|
||
div(class="table-cards") { (trick) }
|
||
}
|
||
}
|
||
};
|
||
|
||
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");
|
||
let click_code = code.clone();
|
||
view! {
|
||
button(class="hand-slot", on:click=move |_| {
|
||
if can_play {
|
||
on_hand_card(click_code.clone());
|
||
}
|
||
}) { (card_view) }
|
||
}
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let hint = if can_play {
|
||
Some(view! {
|
||
p(class="text-secondary small text-center") {
|
||
"Click a card to play it."
|
||
}
|
||
})
|
||
} else if game.phase == "auction" {
|
||
Some(view! {
|
||
p(class="text-secondary small text-center") {
|
||
"Bid or pass, then play the cards."
|
||
}
|
||
})
|
||
} else if game.phase == "calling" && my_turn {
|
||
Some(view! {
|
||
p(class="text-secondary small text-center") {
|
||
"Name a card: its holder will be your secret partner."
|
||
}
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// 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) } }
|
||
});
|
||
let called_badge = game.called_card.clone().map(|code| {
|
||
let label = card_label(&code);
|
||
view! { span(class="called-badge") { "Chiamata: " (label) } }
|
||
});
|
||
let deal = game.hand_number;
|
||
|
||
view! {
|
||
div(class="table-wrap") {
|
||
div(class="hud") {
|
||
a(href="/") { "← Lobby" }
|
||
span { "Deal " (deal) }
|
||
(trump_badge)
|
||
(called_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="table-grid cinque") {
|
||
(top_left)
|
||
(top_right)
|
||
(left)
|
||
div(class="center") {
|
||
(patta)
|
||
(reveal)
|
||
(center_content)
|
||
}
|
||
(right)
|
||
(bottom)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Overlay for the auction winner: name the called card.
|
||
///
|
||
/// The winning bid fixes the rank, so the choice is which of the four
|
||
/// suits to call: its holder becomes the secret partner, unless the
|
||
/// caller holds it too — then that option means playing alone.
|
||
pub fn call_picker(game: GameView, socket: Signal<Option<GameSocket>>) -> View {
|
||
let Some(cards) = game.callable_cards.clone().filter(|cards| !cards.is_empty()) else {
|
||
return view! {};
|
||
};
|
||
let rank = cards
|
||
.first()
|
||
.and_then(|code| code.get(..2))
|
||
.and_then(|rank| rank.parse::<i32>().ok())
|
||
.map(rank_name)
|
||
.unwrap_or_default();
|
||
let title = if rank.is_empty() {
|
||
"Name the called card".to_string()
|
||
} else {
|
||
format!("You won with the {rank} — name the card")
|
||
};
|
||
let own_hand = game
|
||
.players
|
||
.iter()
|
||
.find(|p| p.hand.is_some())
|
||
.and_then(|p| p.hand.clone())
|
||
.unwrap_or_default();
|
||
let options = cards
|
||
.into_iter()
|
||
.map(|code| {
|
||
let held = own_hand.contains(&code);
|
||
let label = card_label(&code);
|
||
let img = card_img(code.clone(), "call-card");
|
||
let note = if held {
|
||
view! { span(class="call-note solo") { "Plays alone" } }
|
||
} else {
|
||
view! { span(class="call-note partner") { "Partner" } }
|
||
};
|
||
let click_code = code.clone();
|
||
view! {
|
||
button(class="call-option", on:click=move |_| {
|
||
if let Some(s) = socket.get_clone() {
|
||
s.call_card(&click_code);
|
||
}
|
||
}) {
|
||
(img)
|
||
span(class="call-label") { (label) }
|
||
(note)
|
||
}
|
||
}
|
||
})
|
||
.collect::<Vec<_>>();
|
||
view! {
|
||
div(class="overlay") {
|
||
div(class="picker call-picker") {
|
||
h3 { (title) }
|
||
p(class="text-secondary small") {
|
||
"Its holder becomes your secret partner; name one of your "
|
||
"own cards to play alone."
|
||
}
|
||
div(class="call-options") { (options) }
|
||
}
|
||
}
|
||
}
|
||
}
|