Files
tavolo/web/src/pages/game.rs
T
woggioni 12fa3f5aae
CI / Build and push docker image (push) Failing after 39s
Rebuild the web UI on Bootstrap 5 with the dark felt theme
The lobby was hand-rolled CSS and looked plain. Vendor Bootstrap 5.3
(MIT) under web/vendor so builds stay offline, load it in dark mode and
bridge the felt/gold palette onto Bootstrap's CSS variables in a new
theme.css; style.css now only owns the game table, cards and scoring
summary.

Rebuild the lobby, waiting room and shared chrome with Bootstrap
components: a shared navbar, creation form as cards with schema-driven
controls, roster as a list group with seat/team badges, a seats progress
bar and a copy-to-clipboard button on the join code. History,
leaderboard, connection/error states and the 404 page use cards, tables
and buttons too; the play table itself is unchanged.

The copy feedback timer is cancelled when the waiting room is rebuilt by
another player joining, fixing a disposed-signal panic that froze the
page mid-match.
2026-09-21 23:40:44 +08:00

713 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Live game page: table view over the websocket.
use std::cell::Cell;
use std::cell::RefCell;
use std::rc::Rc;
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::components::summary::{hand_summary_modal, summary_rows};
use crate::components::toast::toast;
use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage};
use crate::ws::{self, GameSocket};
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
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::<Vec<_>>();
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::<Vec<_>>();
let scopa = mv.scopa.then(|| view! { span(class="scopa-badge") { "Scopa!" } });
view! {
div(class="move-banner") {
strong { (name) }
span { " " (action) " " }
(played)
(captured)
(scopa)
}
}
}
/// Signals shared by the websocket connection and its reconnect attempts.
#[derive(Clone, Copy)]
struct ConnCtx {
socket: Signal<Option<GameSocket>>,
game: Signal<Option<GameView>>,
over: Signal<Option<(Scores, Option<String>)>>,
error: Signal<Option<String>>,
closed: Signal<bool>,
/// Reconnect attempts exhausted; only a manual retry resumes.
gave_up: Signal<bool>,
/// The server closed the connection deliberately (auth or game gone);
/// retrying is pointless.
fatal: Signal<bool>,
attempts: Signal<u32>,
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
selected: Signal<Option<String>>,
}
/// Reconnect attempts: 1s, 2s, 4s, … capped at 30s, at most this many.
const MAX_RECONNECT_ATTEMPTS: u32 = 10;
fn backoff_ms(attempt: u32) -> u32 {
(1000u32 << attempt.min(5)).min(30_000)
}
/// Connect the game websocket, wiring state updates and reconnects.
///
/// The server pushes a full state snapshot on connect, so a reconnect is
/// also a resync: no client-side state merging is needed.
fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
let on_message = {
let alive = alive.clone();
move |msg: ServerMessage| {
if !alive.get() {
// The page is unmounted; its signals are disposed.
return;
}
match msg {
ServerMessage::State { game: g } => {
ctx.capture_choice.set(None);
ctx.selected.set(None);
// A received state proves the (re)connection works.
ctx.attempts.set(0);
ctx.gave_up.set(false);
ctx.closed.set(false);
ctx.game.set(Some(g));
}
ServerMessage::GameOver { scores, winner } => {
ctx.over.set(Some((scores, winner)))
}
ServerMessage::Error { message, .. } => ctx.error.set(Some(message)),
}
}
};
let on_close = {
let id = id.clone();
let alive = alive.clone();
move |code: Option<u16>| {
if !alive.get() {
return;
}
ctx.closed.set(true);
match code {
Some(4401) => {
ctx.fatal.set(true);
ctx.error
.set(Some("Session expired — please log in again.".to_string()));
}
Some(4403) | Some(4404) => {
ctx.fatal.set(true);
ctx.error
.set(Some("This game is no longer available.".to_string()));
}
_ => schedule_retry(id.clone(), ctx, alive.clone()),
}
}
};
match ws::connect(&id, on_message, on_close) {
Some(s) => ctx.socket.set(Some(s)),
// WebSocket::open failed synchronously: treat as a transient loss.
None if alive.get() => {
ctx.closed.set(true);
schedule_retry(id, ctx, alive);
}
None => {}
}
}
/// Retry `start_connect` with exponential backoff, unless we gave up.
fn schedule_retry(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
let attempt = ctx.attempts.get();
if attempt >= MAX_RECONNECT_ATTEMPTS {
ctx.gave_up.set(true);
return;
}
ctx.attempts.set(attempt + 1);
gloo_timers::callback::Timeout::new(backoff_ms(attempt), move || {
if alive.get() {
start_connect(id, ctx, alive);
}
})
.forget();
}
/// Slim banner shown over the table while the socket is down.
fn conn_banner(
closed: bool,
gave_up: bool,
fatal: bool,
has_game: bool,
reconnect: Rc<dyn Fn()>,
) -> View {
if !closed || !has_game {
return view! {};
}
if fatal {
view! {
div(
class="alert alert-warning d-flex align-items-center justify-content-center \
gap-3 mx-3 mt-3 mb-0"
) {
"Connection closed by the server."
a(class="alert-link", href="/") { "Back to lobby" }
}
}
} else if gave_up {
view! {
div(
class="alert alert-warning d-flex align-items-center justify-content-center \
gap-3 mx-3 mt-3 mb-0"
) {
"Connection lost."
button(
class="btn btn-sm btn-warning",
on:click=move |_| reconnect(),
) {
"Retry now"
}
a(class="alert-link", href="/") { "Back to lobby" }
}
}
} else {
view! {
div(class="alert alert-warning text-center mx-3 mt-3 mb-0") {
"Connection lost — reconnecting…"
}
}
}
}
#[component(inline_props)]
pub fn GamePage(id: String) -> View {
let game = create_signal(Option::<GameView>::None);
let error = create_signal(Option::<String>::None);
let capture_choice = create_signal(Option::<(String, Vec<Vec<String>>)>::None);
let selected = create_signal(Option::<String>::None);
let over = create_signal(Option::<(Scores, Option<String>)>::None);
let closed = create_signal(false);
let gave_up = create_signal(false);
let fatal = create_signal(false);
let attempts = create_signal(0u32);
let socket = create_signal(Option::<GameSocket>::None);
// Ticking clock driving the hand-end countdown display.
let now = create_signal(js_sys::Date::now());
let ticker = gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now()));
// Stops the ticker and any pending reconnect once the page unmounts.
let alive = Rc::new(Cell::new(true));
on_cleanup({
let alive = alive.clone();
move || {
alive.set(false);
drop(ticker);
}
});
let id = Rc::new(id);
let ctx = ConnCtx {
socket,
game,
over,
error,
closed,
gave_up,
fatal,
attempts,
capture_choice,
selected,
};
start_connect(id.clone(), ctx, alive.clone());
let reconnect: Rc<dyn Fn()> = Rc::new(move || {
ctx.attempts.set(0);
ctx.gave_up.set(false);
ctx.closed.set(false);
start_connect(id.clone(), ctx, alive.clone());
});
// Clicking a card in the player's own hand.
let on_hand_card = move |code: String| {
if closed.get() {
// A dead socket would swallow the play silently.
return;
}
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))),
}
};
let reconnect_banner = reconnect.clone();
view! {
div(class="game-page") {
(toast(error))
(move || conn_banner(
closed.get(),
gave_up.get(),
fatal.get(),
game.get_clone().is_some(),
reconnect_banner.clone(),
))
(move || match game.get_clone() {
None => {
if fatal.get() {
view! {
div(class="status-card") {
div(class="card") {
div(class="card-body p-4") {
p { "Connection closed." }
a(class="btn btn-warning", href="/") { "Back to lobby" }
}
}
}
}
} else if gave_up.get() {
let reconnect = reconnect.clone();
view! {
div(class="status-card") {
div(class="card") {
div(class="card-body p-4") {
p { "Connection lost." }
button(
class="btn btn-warning me-2",
on:click=move |_| reconnect(),
) {
"Retry now"
}
a(class="btn btn-outline-light", href="/") {
"Back to lobby"
}
}
}
}
}
} else {
let status = if closed.get() {
"Connection lost — reconnecting…"
} else {
"Connecting to the game…"
};
view! {
div(class="status-card") {
div(class="card") {
div(class="card-body p-4") {
div(class="spinner-border text-warning mb-3", role="status") {
span(class="visually-hidden") { "Loading…" }
}
p { (status) }
a(class="btn btn-outline-light", href="/") {
"Back to lobby"
}
}
}
}
}
}
}
Some(g) if g.phase == "lobby" => view! { LobbyView(game=g) },
// The live table is scopone-specific; other games get a
// fallback until they ship their own play view.
Some(g) if g.game_type == "scopone_scientifico" => {
table_view(g, on_hand_card, selected, now)
}
Some(g) => unsupported_game_view(g),
})
(move || capture_choice.get_clone().map(|(card, options)| {
capture_picker(card, options, socket, capture_choice)
}))
(move || match game.get_clone() {
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
hand_summary_modal(g, socket, now)
}
_ => view! {},
})
(move || game_over_view(over.get_clone(), game.get_clone()))
}
}
}
/// Lobby view while waiting for the remaining players. The seat count
/// comes from the payload (`seats_open`), so games with other player
/// counts render correctly.
#[component(inline_props)]
fn LobbyView(game: GameView) -> View {
let seats_open = game
.seats_open
.unwrap_or_else(|| 4usize.saturating_sub(game.players.len()));
let join_code = game.join_code.clone();
let copied = create_signal(false);
// Held so the reset timer is cancelled if the waiting room is rebuilt
// (e.g. another player joins) while the "Copied!" feedback is showing:
// firing it afterwards would set a disposed signal and panic.
let reset_timer: Rc<RefCell<Option<gloo_timers::callback::Timeout>>> =
Rc::new(RefCell::new(None));
on_cleanup({
let reset_timer = reset_timer.clone();
move || {
reset_timer.borrow_mut().take();
}
});
let copy = {
let join_code = join_code.clone();
move |_| {
if let Some(win) = web_sys::window() {
let _ = win.navigator().clipboard().write_text(&join_code);
}
copied.set(true);
*reset_timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(1500, move || {
copied.set(false);
}));
}
};
let players = game
.players
.iter()
.map(|p| {
let name = p.name.clone();
let seat = p.seat;
let team = p.team.clone();
view! {
li(class="list-group-item d-flex justify-content-between align-items-center") {
strong { (name) }
span {
span(class="badge text-bg-secondary me-2") { "Seat " (seat) }
span(class="badge text-bg-info") { "Team " (team) }
}
}
}
})
.collect::<Vec<_>>();
let open_seats = (0..seats_open)
.map(|_| view! {
li(class="list-group-item seat-open") { "Waiting for a player…" }
})
.collect::<Vec<_>>();
let seated = game.players.len();
let total = seated + seats_open;
let percent = if total == 0 {
0
} else {
seated * 100 / total
};
let percent_style = format!("width: {percent}%");
view! {
div(class="container page-shell py-4") {
div(class="card status-card") {
div(class="card-body p-4") {
h1(class="h4 mb-3") { "Waiting for players" }
p(class="text-secondary") { "Share this join code with your friends:" }
p(class="code-display") { (join_code) }
div(class="d-grid mb-4") {
button(class="btn btn-outline-light", on:click=copy) {
(move || if copied.get() { "Copied!" } else { "Copy code" })
}
}
ul(class="list-group list-group-flush text-start mb-3") {
(players)
(open_seats)
}
div(
class="progress mb-2",
role="progressbar",
aria-valuenow=percent.to_string(),
aria-valuemin="0",
aria-valuemax="100",
) {
div(class="progress-fill", style=percent_style) {}
}
p(class="text-secondary small") {
(seated) " of " (total) " players seated"
}
a(class="btn btn-warning", href="/") { "Back to lobby" }
}
}
}
}
}
/// Fallback for live games the web client has no play view for yet.
fn unsupported_game_view(game: GameView) -> View {
let game_type = game.game_type.clone();
view! {
div(class="status-card") {
div(class="card") {
div(class="card-body p-4") {
h1(class="h4 mb-3") { "Unsupported game" }
p(class="text-secondary") {
"Live play for “" (game_type) "” isn't supported in the web client yet."
}
a(class="btn btn-warning", href="/") { "Back to lobby" }
}
}
}
}
}
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
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<Option<String>>,
now: Signal<f64>,
) -> 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 game.phase == "hand_end" {
"Hand 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 table_cards = game
.table
.iter()
.map(|c| card_img(c.clone(), "table-card"))
.collect::<Vec<_>>();
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 viewer = player_for_seat(&game, viewer_seat);
let my_captured = viewer.as_ref().map(|p| p.captured_count).unwrap_or(0);
let my_scope = viewer.as_ref().map(|p| p.scope).unwrap_or(0);
let hand = viewer.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::<Vec<_>>();
let hint = my_turn.then(|| view! {
p(class="text-secondary small text-center") {
"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) }
(countdown)
}
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="seat-stats own-stats") {
(my_captured) " captured · " (my_scope) " scope"
}
div(class="hand") { (hand_cards) }
(hint)
}
}
}
}
}
/// Popup listing the legal captures for a selected card.
fn capture_picker(
card: String,
options: Vec<Vec<String>>,
socket: Signal<Option<GameSocket>>,
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
) -> 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::<Vec<_>>();
view! {
button(class="capture-option", on:click=move |_| {
send_play(socket, played_card.clone(), Some(capture.clone()));
capture_choice.set(None);
}) { (cards) }
}
})
.collect::<Vec<_>>();
view! {
div(class="overlay") {
div(class="picker") {
h3 { (title) }
div(class="capture-options") { (option_views) }
button(class="btn btn-outline-light", on:click=move |_| capture_choice.set(None)) {
"Cancel"
}
}
}
}
}
/// End-of-match overlay (scopone's team score; other games render
/// nothing until they ship their own play view).
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
if let Some(g) = &game {
if g.game_type != "scopone_scientifico" {
return view! {};
}
}
let result = over.or_else(|| {
game.clone()
.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);
// Explain the final hand's scoring before the result.
let final_summary = game
.and_then(|g| g.last_hand)
.map(|s| {
let rows = summary_rows(s);
view! { (rows) }
});
view! {
div(class="overlay") {
div(class="picker summary-panel") {
h2 { "Match over" }
(final_summary)
p(class="final-score") { (line) }
div(class="gameover-actions") {
a(class="btn btn-warning", href="/") { "Back to lobby" }
a(class="btn btn-outline-light", href="/history") { "My matches" }
}
}
}
}
}
}
}