diff --git a/frontend/src/index.css b/frontend/src/index.css index fc64c58..8b758f3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -70,3 +70,21 @@ from { opacity: 0; transform: translateX(110px) scale(0.82); } to { opacity: 1; transform: none; } } + +/* A completed trick is swept off the table towards the seat that won it. */ +@keyframes collect-bottom { + from { opacity: 1; transform: none; } + to { opacity: 0; transform: translateY(150px) scale(0.66); } +} +@keyframes collect-top { + from { opacity: 1; transform: none; } + to { opacity: 0; transform: translateY(-150px) scale(0.66); } +} +@keyframes collect-left { + from { opacity: 1; transform: none; } + to { opacity: 0; transform: translateX(-190px) scale(0.66); } +} +@keyframes collect-right { + from { opacity: 1; transform: none; } + to { opacity: 0; transform: translateX(190px) scale(0.66); } +} diff --git a/frontend/src/lib/gameRules.ts b/frontend/src/lib/gameRules.ts index 522bddf..78a6060 100644 --- a/frontend/src/lib/gameRules.ts +++ b/frontend/src/lib/gameRules.ts @@ -1,4 +1,4 @@ -import type { CardColor, Hand } from '../types'; +import type { CardColor, CardValue, Hand, StashData } from '../types'; export function computePlayable(hand: Hand, ledColor: CardColor | null): Set { const keys = Object.keys(hand); @@ -13,6 +13,35 @@ export function computePlayable(hand: Hand, ledColor: CardColor | null): Set= VALUE_ORDER.indexOf(best.value)) { + best = c; + winner = i; + } + } else if (c.color === 'HEARTS') { + best = c; + winner = i; + } + } + } + return winner; +} + /** The bid the last guesser may not make: the four bids must not sum to the * number of tricks in the round (mirrors Round.add_player_guess in the engine). * Returns null while earlier players are still guessing. */ diff --git a/frontend/src/pages/GameTable.tsx b/frontend/src/pages/GameTable.tsx index b180e01..af1951e 100644 --- a/frontend/src/pages/GameTable.tsx +++ b/frontend/src/pages/GameTable.tsx @@ -1,9 +1,9 @@ -import { useEffect, useRef, useState } from 'react'; +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 } from '../lib/gameRules'; +import { computePlayable, stashWinner } from '../lib/gameRules'; import { computeTotal } from '../lib/standings'; import { useIsDesktop } from '../lib/useIsDesktop'; import { useFitScale } from '../lib/useFitScale'; @@ -16,7 +16,12 @@ import FaceDownCards from '../components/FaceDownCards'; import GameOver from './GameOver'; import type { PlayerInfo, StashData } from '../types'; -const TRICK_LINGER_MS = 3000; +// 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(); @@ -28,26 +33,31 @@ export default function GameTable() { const gameStatus = useGameStore((s) => s.gameStatus); const hand = useGameStore((s) => s.hand); - // Hold the last completed trick visible for TRICK_LINGER_MS after it finishes. - const [lingeredStash, setLingeredStash] = useState(null); - const lingerTimer = useRef | null>(null); + // 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 timer must restart only when a *different* trick completes. + // 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 (!previousStash) return; - setLingeredStash(previousStash); - if (lingerTimer.current) clearTimeout(lingerTimer.current); - lingerTimer.current = setTimeout(() => setLingeredStash(null), TRICK_LINGER_MS); + if (!previousStashKey) return; + setCollecting(false); + const settle = setTimeout(() => setCollecting(true), SETTLE_MS); + const done = setTimeout(() => { + setCollecting(false); + setDismissedKey(previousStashKey); + }, SETTLE_MS + COLLECT_MS); return () => { - if (lingerTimer.current) clearTimeout(lingerTimer.current); + clearTimeout(settle); + clearTimeout(done); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [previousStashKey]); if (!gameStatus || !myPlayer) { @@ -73,8 +83,24 @@ export default function GameTable() { const myTurnToPlay = isPlayPhase && active_player === myOrder; const activeCards = active_stash ? Object.keys(active_stash.cards).length : 0; - const displayedStash: StashData | null = - activeCards > 0 && active_stash ? active_stash : lingeredStash ?? null; + // 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) @@ -160,7 +186,9 @@ export default function GameTable() { // Center of the oval: trick during play, guess controls during bidding. const ovalContent = isPlayPhase ? ( - +
+ +
) : ( active_round_guesses !== undefined && active_player !== undefined ? (