Bot doteraz vedel zahodit prvu kartu novej kopky uz po BOT_MOVE_DELAY_SECONDS (0.8s), zatial co frontend zmetaciu animaciu predoslej kopky prehraval 1650ms -- karta tak "vyletela" uprostred zmetania. _run_bot_turns teraz pri vedeni novej kopky caka TRICK_SWEEP_SECONDS (1.7s), zhodne s SETTLE_MS+ COLLECT_MS v GameTable.tsx. _run_bot_turns tiez posielal player_cards PRED game_status (opacne ako human play_card handler) -- pri prechode do noveho kola tak klient dostal novu ruku skor, nez vedel, ze zacalo nove kolo, a fixne ju hned zobrazil. Poradie broadcastov je teraz zhodne s human handlerom. GameTable/Hand: ruka noveho kola sa zobrazi az po dobehnuti zmetacej animacie poslednej kopky predchadzajuceho kola (displayedHand + freeze efekt), a Hand.tsx rezervuje fixny priestor pre karty aj ked je ruka docasne prazdna, aby layout neposkakoval. tests/test_bots.py: nulovanie TRICK_SWEEP_SECONDS v setUpClass, aby testy zostali rychle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
405 lines
17 KiB
TypeScript
405 lines
17 KiB
TypeScript
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<string | null>(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<HandCards>(hand);
|
||
const lastAppliedRoundRef = useRef<number | null>(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 <p className="text-center text-green-dim pt-20 font-serif italic">Načítava sa…</p>;
|
||
}
|
||
|
||
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 <GameOver players={players} standings={standings} />;
|
||
}
|
||
|
||
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 = (
|
||
<div className="flex items-center justify-center gap-2">
|
||
<span className="inline-block w-[7px] h-[7px] rounded-full bg-gold animate-tp flex-shrink-0" />
|
||
<span className="font-serif italic text-[13px] text-gold-dim tracking-[.03em]">{bannerText}</span>
|
||
</div>
|
||
);
|
||
|
||
const opponents = players
|
||
.filter((p) => p.order !== myOrder)
|
||
.sort((a, b) => a.order - b.order);
|
||
|
||
const totalsRow = (compact: boolean) => (
|
||
<div className="flex items-center justify-between gap-2">
|
||
{opponents.map((p) => (
|
||
<div key={p.order} className="text-center">
|
||
<div className="uppercase tracking-[.1em] text-green-dim mb-0.5" style={{ fontSize: 11 }}>
|
||
{displayName(p.name)}
|
||
</div>
|
||
<div className="font-serif text-green-score leading-none" style={{ fontSize: compact ? 16 : 20 }}>
|
||
{computeTotal(standings, p.order)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div className="text-center rounded-lg px-3 py-1 bg-gold/[.06] border border-gold/[.15]">
|
||
<div className="uppercase tracking-[.1em] text-gold mb-0.5" style={{ fontSize: 11 }}>
|
||
{myPlayer.name}
|
||
</div>
|
||
<div className="font-serif font-semibold text-gold-dim leading-none" style={{ fontSize: compact ? 16 : 20 }}>
|
||
{computeTotal(standings, myOrder)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
// 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 ? (
|
||
<div style={collectAnim ? { animation: `${collectAnim} ${COLLECT_MS}ms ease-in both` } : undefined}>
|
||
<Trick stash={displayedStash} players={players} myOrder={myOrder} />
|
||
</div>
|
||
) : (
|
||
active_round_guesses !== undefined && active_player !== undefined ? (
|
||
<GuessControls
|
||
cardsInRound={cards_in_round}
|
||
guesses={active_round_guesses}
|
||
myOrder={myOrder}
|
||
activePlayer={active_player}
|
||
activePlayerName={activePlayerName}
|
||
/>
|
||
) : null
|
||
);
|
||
|
||
const topSeat = (
|
||
<div className="flex flex-col items-center gap-1.5">
|
||
<PlayerCircle name={displayName(topP?.name) || '—'} {...seatProps(topP?.order)} size={desktop ? 64 : 52} />
|
||
<FaceDownCards count={cardsInHandOf(topP?.order)} direction="row" desktop={desktop} />
|
||
</div>
|
||
);
|
||
|
||
const sideSeat = (p?: PlayerInfo) => (
|
||
<div className="flex flex-col items-center gap-1.5">
|
||
<PlayerCircle name={displayName(p?.name) || '—'} {...seatProps(p?.order)} size={desktop ? 60 : 48} />
|
||
<FaceDownCards count={cardsInHandOf(p?.order)} direction="col" desktop={desktop} />
|
||
</div>
|
||
);
|
||
|
||
const meSeat = (
|
||
<div className="flex justify-center">
|
||
<PlayerCircle name={myPlayer.name} {...seatProps(myOrder)} size={desktop ? 70 : 58} />
|
||
</div>
|
||
);
|
||
|
||
const handArea = (
|
||
<Hand hand={displayedHand} myTurn={canPlayNow} isPlayPhase={isPlayPhase} playableKeys={playableKeys} desktop={desktop} />
|
||
);
|
||
|
||
// ── DESKTOP LAYOUT ───────────────────────────────────────────────
|
||
if (desktop) {
|
||
return (
|
||
<div ref={containerRef} className="h-[100dvh] w-full overflow-hidden bg-table">
|
||
{/* Design canvas — fixed height, width spans the viewport; scaled as one
|
||
unit so the whole board (and header) zooms with the window. */}
|
||
<div
|
||
className="flex"
|
||
style={{ width: contentWidth, height: 860, transform: `scale(${scale})`, transformOrigin: 'top left' }}
|
||
>
|
||
{/* main */}
|
||
<div className="flex-1 min-w-0 flex flex-col">
|
||
{/* header */}
|
||
<div className="shrink-0 h-[58px] bg-header flex items-center gap-4 px-6 border-b border-[#14221a]">
|
||
<span className="font-serif uppercase tracking-[.14em] text-[15px] text-gold whitespace-nowrap">
|
||
Bridžik
|
||
</span>
|
||
<div className="w-px h-[22px] bg-[#1a3a22]" />
|
||
<span className="font-serif text-[12px] text-green-dim tracking-[.06em] whitespace-nowrap">
|
||
Séria {series_number + 1} · Kolo {round_number + 1}
|
||
</span>
|
||
<div className="flex-1 flex items-center justify-center">{banner}</div>
|
||
{totalsRow(true)}
|
||
<div className="w-px h-[22px] bg-[#1a3a22]" />
|
||
{canEnd && (
|
||
<button onClick={handleEnd} className="text-[11px] text-[#8a8064] hover:text-gold whitespace-nowrap">
|
||
Ukončiť
|
||
</button>
|
||
)}
|
||
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold whitespace-nowrap">
|
||
Odísť
|
||
</button>
|
||
</div>
|
||
|
||
{/* game content — players hug the edges so the felt uses full width */}
|
||
<div className="flex-1 min-h-0 flex flex-col justify-center gap-3 px-16 py-4">
|
||
{topSeat}
|
||
|
||
<div className="flex items-center justify-center gap-16">
|
||
{sideSeat(leftP)}
|
||
<div
|
||
className="flex items-center justify-center rounded-full"
|
||
style={{
|
||
width: 620,
|
||
height: 372,
|
||
background:
|
||
'radial-gradient(ellipse at 42% 38%,#306845 0%,#1e5030 38%,#122e1c 72%,#091e12 100%)',
|
||
boxShadow:
|
||
'inset 0 10px 48px rgba(0,0,0,.72),0 0 0 3px rgba(0,0,0,.55),0 0 0 6px rgba(201,168,76,.1)',
|
||
}}
|
||
>
|
||
{ovalContent}
|
||
</div>
|
||
{sideSeat(rightP)}
|
||
</div>
|
||
|
||
{meSeat}
|
||
</div>
|
||
|
||
{handArea}
|
||
</div>
|
||
|
||
{/* sidebar */}
|
||
<Standings standings={standings} guesses={standings_guesses} players={players} myOrder={myOrder} desktop />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── MOBILE LAYOUT ────────────────────────────────────────────────
|
||
return (
|
||
<div className="max-w-lg mx-auto min-h-screen flex flex-col">
|
||
{/* header */}
|
||
<div className="bg-header px-[18px] pt-[14px] pb-3 border-b border-[#14221a]">
|
||
<div className="flex items-center justify-between mb-2.5">
|
||
<span className="font-serif uppercase tracking-[.1em] text-[11px] text-gold">
|
||
Séria {series_number + 1} · Kolo {round_number + 1}
|
||
</span>
|
||
<div className="flex items-center gap-3">
|
||
{canEnd && (
|
||
<button onClick={handleEnd} className="text-[11px] text-[#6a3030] hover:text-red-400">
|
||
Ukončiť
|
||
</button>
|
||
)}
|
||
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold">
|
||
Odísť
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{totalsRow(false)}
|
||
</div>
|
||
|
||
{/* turn banner */}
|
||
<div
|
||
className="py-[9px] px-4 border-b border-[#152a1a]"
|
||
style={{ background: 'linear-gradient(90deg,#09190d,#14301e,#09190d)' }}
|
||
>
|
||
{banner}
|
||
</div>
|
||
|
||
{/* game area */}
|
||
<div className="flex-1 bg-table px-2.5 pt-2.5 pb-1.5 flex flex-col">
|
||
<div className="flex flex-col items-center mb-1.5">{topSeat}</div>
|
||
|
||
<div className="flex items-center gap-1.5 mb-2">
|
||
<div className="w-[54px] flex-shrink-0 flex justify-center">{sideSeat(leftP)}</div>
|
||
<div
|
||
className="flex-1 flex items-center justify-center rounded-full"
|
||
style={{
|
||
minHeight: 192,
|
||
background:
|
||
'radial-gradient(ellipse at 42% 38%,#306845 0%,#1e5030 38%,#122e1c 72%,#091e12 100%)',
|
||
boxShadow:
|
||
'inset 0 6px 32px rgba(0,0,0,.7),0 0 0 2px rgba(0,0,0,.5),0 0 0 4px rgba(201,168,76,.1)',
|
||
}}
|
||
>
|
||
{ovalContent}
|
||
</div>
|
||
<div className="w-[54px] flex-shrink-0 flex justify-center">{sideSeat(rightP)}</div>
|
||
</div>
|
||
|
||
<div className="mb-1.5">{meSeat}</div>
|
||
</div>
|
||
|
||
{handArea}
|
||
|
||
{/* score */}
|
||
<div className="bg-table px-3 pb-4 pt-1">
|
||
<Standings standings={standings} guesses={standings_guesses} players={players} myOrder={myOrder} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|