import { useEffect, 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 { 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 { 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; 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]); 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; // Once a player leads, that live trick always wins the centre. Otherwise, if a // just-completed trick hasn't been swept away yet, keep it face-up. Reading // `previousStash` synchronously (rather than a state set in an effect) 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. const finishing = previousStashKey !== null && previousStashKey !== dismissedKey && activeCards === 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; const playableKeys = myTurnToPlay && active_stash ? computePlayable(hand, active_stash.cards[String(active_stash.first_player)]?.color ?? null) : undefined; const activePlayerName = 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) => (
{p.name}
{computeTotal(standings, p.order)}
))}
{myPlayer.name}
{computeTotal(standings, myOrder)}
); // Center of the oval: trick during play, guess controls during bidding. const ovalContent = isPlayPhase ? (
) : ( 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 */}
); }