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>
361 lines
15 KiB
TypeScript
361 lines
15 KiB
TypeScript
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>
|
||
);
|
||
}
|