import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useGameStore } from '../store/gameStore'; import { emit } from '../lib/socket'; import { leaveGame } from '../lib/leaveGame'; import { computePlayable, stashWinner } from '../lib/gameRules'; import { computeTotal } from '../lib/standings'; import { displayName } from '../lib/names'; import { useIsDesktop } from '../lib/useIsDesktop'; import { useFitScale } from '../lib/useFitScale'; import Hand from '../components/Hand'; import GuessControls from '../components/GuessControls'; import Trick from '../components/Trick'; import Standings from '../components/Standings'; import PlayerCircle from '../components/PlayerCircle'; import FaceDownCards from '../components/FaceDownCards'; import GameOver from './GameOver'; import type { Hand as HandCards, PlayerInfo, StashData } from '../types'; // A completed trick stays face-up for SETTLE_MS (so the last card visibly joins // the pile), then is swept towards the winner over COLLECT_MS. const SETTLE_MS = 1100; const COLLECT_MS = 550; // Sweep direction by the winner's seat offset from me: 0=me(bottom) 1=left 2=top 3=right. const COLLECT_BY_OFFSET = ['collect-bottom', 'collect-left', 'collect-top', 'collect-right']; export default function GameTable() { const navigate = useNavigate(); const desktop = useIsDesktop(); // Zooms the whole desktop board to fill the window (full width + height), so // cards, circles, text and the header all scale together. Up to 2.6×. const { containerRef, scale, contentWidth } = useFitScale([desktop], 860, 2.6); const myPlayer = useGameStore((s) => s.myPlayer); const gameStatus = useGameStore((s) => s.gameStatus); const hand = useGameStore((s) => s.hand); // Once a completed trick has been swept away, its key is remembered here so it // is not shown again while we wait for the winner to lead the next trick. const [dismissedKey, setDismissedKey] = useState(null); // Turns on for the collect (fly-to-winner) phase, after the settle pause. const [collecting, setCollecting] = useState(false); const previousStash = gameStatus?.status.previous_stash ?? null; // Every game_status payload recreates the stash object, so identify the trick // by content — the timers must restart only when a *different* trick completes. const previousStashKey = previousStash ? `${previousStash.first_player}:${JSON.stringify(previousStash.cards)}` : null; // On first load of an already-running game (reconnect / restore-on-startup) a // completed trick is already present; adopt it as "already swept" so we don't // replay a stale sweep over the live board. A freshly started game has no // completed trick at this point, so its very first trick still animates. const booted = useRef(false); useEffect(() => { if (booted.current || !gameStatus) return; booted.current = true; if (previousStashKey) setDismissedKey(previousStashKey); }, [gameStatus, previousStashKey]); useEffect(() => { if (!previousStashKey) return; setCollecting(false); const settle = setTimeout(() => setCollecting(true), SETTLE_MS); const done = setTimeout(() => { setCollecting(false); setDismissedKey(previousStashKey); }, SETTLE_MS + COLLECT_MS); return () => { clearTimeout(settle); clearTimeout(done); }; }, [previousStashKey]); // A just-completed trick that hasn't been swept away yet always wins the centre // — even once the winner has already led the next trick. The engine reveals that // lead card (and, at a round boundary, the next bidding phase) the instant the // 4th card lands, so without holding the pile here the sweep would be cut off // after every trick. Reading `previousStash` synchronously (rather than a state // set in an effect) also means the pile never blinks to empty on the frame the // 4th card lands — the last card simply joins the three already there, then the // whole pile is collected before the next trick takes over. const finishing = previousStashKey !== null && previousStashKey !== dismissedKey; // The new round's dealt hand arrives (via `player_cards`) the instant the last // trick of the previous round is played, but the centre oval is still sweeping // that trick away — hold an empty hand on screen (the previous round's last card // really was just played, the engine just never broadcasts that "0 cards" beat on // its own since it deals the new round in the same step) until the sweep finishes, // so the new cards don't appear before the previous round has visibly wrapped up. // Mid-round plays (round number unchanged) still update instantly, since that's // just the player's own card leaving their hand, not a fresh deal. const [displayedHand, setDisplayedHand] = useState(hand); const lastAppliedRoundRef = useRef(gameStatus?.round_number ?? null); useEffect(() => { if (!gameStatus) return; const isNewRound = gameStatus.round_number !== lastAppliedRoundRef.current; if (isNewRound && finishing) { setDisplayedHand({}); // last card of the previous round is gone; new deal waits return; } lastAppliedRoundRef.current = gameStatus.round_number; setDisplayedHand(hand); }, [hand, gameStatus, finishing]); if (!gameStatus || !myPlayer) { return

Načítava sa…

; } const { completed, players, series_number, round_number, cards_in_round, status } = gameStatus; const { active_player, active_round_guesses, active_round_stashes, active_stash, standings = [], standings_guesses = [], } = status; if (completed) { return ; } const myOrder = myPlayer.order; const isPlayPhase = active_stash !== undefined; const myTurnToPlay = isPlayPhase && active_player === myOrder; const activeCards = active_stash ? Object.keys(active_stash.cards).length : 0; const displayedStash: StashData | null = finishing ? previousStash : activeCards > 0 && active_stash ? active_stash : null; // During the collect phase, sweep the pile towards whoever won it. const collectAnim = collecting && finishing && previousStash ? COLLECT_BY_OFFSET[(stashWinner(previousStash) - myOrder + 4) % 4] : null; // Block play until the previous trick's sweep animation has finished — otherwise // the winner could lead the next card while the pile is still visibly clearing. const canPlayNow = myTurnToPlay && !finishing; const playableKeys = canPlayNow && active_stash ? computePlayable(displayedHand, active_stash.cards[String(active_stash.first_player)]?.color ?? null) : undefined; const activePlayerName = displayName(players.find((p) => p.order === active_player)?.name); // Seat mapping relative to "Ty": left / across / right. const seat = (offset: number): PlayerInfo | undefined => players.find((p) => p.order === (myOrder + offset) % 4); const leftP = seat(1); const topP = seat(2); const rightP = seat(3); // Live round state of one seat, shaped as PlayerCircle props. const seatProps = (o?: number) => ({ won: o === undefined ? 0 : active_round_stashes?.[o] ?? 0, guess: (o === undefined ? null : active_round_guesses?.[String(o)] ?? null) as number | null, active: o !== undefined && active_player === o, }); // Exact cards still in a player's hand: started with cards_in_round, lost one // per completed trick, minus one more if they've already played this trick. const completedTricks = (active_round_stashes ?? []).reduce((a, b) => a + b, 0); const cardsInHandOf = (o?: number) => { if (o === undefined) return 0; const playedCurrent = active_stash?.cards[String(o)] ? 1 : 0; return Math.max(0, cards_in_round - completedTricks - playedCurrent); }; const handleLeave = () => leaveGame(navigate); const handleEnd = () => { if (window.confirm('Naozaj ukončiť celú hru pre všetkých?')) { emit.endGame(gameStatus.gid); } }; // The host can always end the game; other players only when the host is // currently offline, so an abandoned game isn't stuck forever waiting for // a host who won't come back, but it isn't open to casual misuse otherwise. const hostConnected = players.find((p) => p.order === 0)?.connected ?? false; const canEnd = myOrder === 0 || !hostConnected; // ── shared pieces ──────────────────────────────────────────────── const bannerText = active_player === myOrder ? isPlayPhase ? 'Zahraj kartu' : 'Zadaj tip' : `${activePlayerName} ${isPlayPhase ? 'hrá' : 'tipuje'}`; const banner = (
{bannerText}
); const opponents = players .filter((p) => p.order !== myOrder) .sort((a, b) => a.order - b.order); const totalsRow = (compact: boolean) => (
{opponents.map((p) => (
{displayName(p.name)}
{computeTotal(standings, p.order)}
))}
{myPlayer.name}
{computeTotal(standings, myOrder)}
); // Center of the oval: trick during play, guess controls during bidding. // `finishing` also keeps the trick on screen while the round's *last* stash is // swept away: the engine advances to the next round's bidding the instant the // 4th card lands, so `isPlayPhase` flips to false immediately — without this, // that final trick would vanish straight into the guess controls with no sweep. const ovalContent = isPlayPhase || finishing ? (
) : ( active_round_guesses !== undefined && active_player !== undefined ? ( ) : null ); const topSeat = (
); const sideSeat = (p?: PlayerInfo) => (
); const meSeat = (
); const handArea = ( ); // ── DESKTOP LAYOUT ─────────────────────────────────────────────── if (desktop) { return (
{/* Design canvas — fixed height, width spans the viewport; scaled as one unit so the whole board (and header) zooms with the window. */}
{/* main */}
{/* header */}
Bridžik
Séria {series_number + 1} · Kolo {round_number + 1}
{banner}
{totalsRow(true)}
{canEnd && ( )}
{/* game content — players hug the edges so the felt uses full width */}
{topSeat}
{sideSeat(leftP)}
{ovalContent}
{sideSeat(rightP)}
{meSeat}
{handArea}
{/* sidebar */}
); } // ── MOBILE LAYOUT ──────────────────────────────────────────────── return (
{/* header */}
Séria {series_number + 1} · Kolo {round_number + 1}
{canEnd && ( )}
{totalsRow(false)}
{/* turn banner */}
{banner}
{/* game area */}
{topSeat}
{sideSeat(leftP)}
{ovalContent}
{sideSeat(rightP)}
{meSeat}
{handArea} {/* score */}
); }