Files
bridzik/frontend/src/pages/GameTable.tsx
T
timandClaude Opus 4.8 fb90737944 Trik: posledna karta sa prida bez blikania, kopka sa zmetie k vitazovi
Doteraz sa pri hodeni 4. karty cela kopka na frame stratila (displayedStash
padol na lingeredStash nastaveny az v useEffekte) a nasledne sa vsetky karty
znovu vlietli. Teraz sa previous_stash cita synchronne, takze posledna karta
len pribudne k trom uz leziacim, a potom sa cela kopka odsunie animaciou
smerom k sedadlu, ktore kopku vyhralo (stashWinner podla pravidiel enginu).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 10:59:42 +02:00

361 lines
15 KiB
TypeScript
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.
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<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;
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 <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;
// 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 = (
<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 }}>
{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.
const ovalContent = isPlayPhase ? (
<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={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={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={hand} myTurn={myTurnToPlay} 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>
);
}