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>
This commit is contained in:
tim
2026-07-07 10:59:42 +02:00
co-authored by Claude Opus 4.8
parent 1fbba5a7e1
commit fb90737944
3 changed files with 92 additions and 17 deletions
+18
View File
@@ -70,3 +70,21 @@
from { opacity: 0; transform: translateX(110px) scale(0.82); } from { opacity: 0; transform: translateX(110px) scale(0.82); }
to { opacity: 1; transform: none; } 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); }
}
+30 -1
View File
@@ -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<string> { export function computePlayable(hand: Hand, ledColor: CardColor | null): Set<string> {
const keys = Object.keys(hand); const keys = Object.keys(hand);
@@ -13,6 +13,35 @@ export function computePlayable(hand: Hand, ledColor: CardColor | null): Set<str
return new Set(keys); return new Set(keys);
} }
const VALUE_ORDER: CardValue[] = ['C7', 'C8', 'C9', 'C10', 'LOWER', 'UPPER', 'KING', 'ACE'];
/** Seat that wins a completed 4-card trick — mirrors Stash.get_winner in the
* engine: HEARTS (červeň) is the permanent trump, otherwise the highest card
* of the led colour wins. Safe to call on a partial stash (returns the current
* leader among the cards played so far). */
export function stashWinner(stash: StashData): number {
const led = stash.cards[String(stash.first_player)];
if (!led) return stash.first_player;
let winner = stash.first_player;
let best = led;
for (let i = 0; i < 4; i++) {
const c = stash.cards[String(i)];
if (!c) continue;
if (c.color === led.color || c.color === 'HEARTS') {
if (c.color === best.color) {
if (VALUE_ORDER.indexOf(c.value) >= 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 /** 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). * number of tricks in the round (mirrors Round.add_player_guess in the engine).
* Returns null while earlier players are still guessing. */ * Returns null while earlier players are still guessing. */
+44 -16
View File
@@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useGameStore } from '../store/gameStore'; import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket'; import { emit } from '../lib/socket';
import { leaveGame } from '../lib/leaveGame'; import { leaveGame } from '../lib/leaveGame';
import { computePlayable } from '../lib/gameRules'; import { computePlayable, stashWinner } from '../lib/gameRules';
import { computeTotal } from '../lib/standings'; import { computeTotal } from '../lib/standings';
import { useIsDesktop } from '../lib/useIsDesktop'; import { useIsDesktop } from '../lib/useIsDesktop';
import { useFitScale } from '../lib/useFitScale'; import { useFitScale } from '../lib/useFitScale';
@@ -16,7 +16,12 @@ import FaceDownCards from '../components/FaceDownCards';
import GameOver from './GameOver'; import GameOver from './GameOver';
import type { PlayerInfo, StashData } from '../types'; 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() { export default function GameTable() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -28,26 +33,31 @@ export default function GameTable() {
const gameStatus = useGameStore((s) => s.gameStatus); const gameStatus = useGameStore((s) => s.gameStatus);
const hand = useGameStore((s) => s.hand); const hand = useGameStore((s) => s.hand);
// Hold the last completed trick visible for TRICK_LINGER_MS after it finishes. // Once a completed trick has been swept away, its key is remembered here so it
const [lingeredStash, setLingeredStash] = useState<StashData | null>(null); // is not shown again while we wait for the winner to lead the next trick.
const lingerTimer = useRef<ReturnType<typeof setTimeout> | null>(null); 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; const previousStash = gameStatus?.status.previous_stash ?? null;
// Every game_status payload recreates the stash object, so identify the trick // 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 const previousStashKey = previousStash
? `${previousStash.first_player}:${JSON.stringify(previousStash.cards)}` ? `${previousStash.first_player}:${JSON.stringify(previousStash.cards)}`
: null; : null;
useEffect(() => { useEffect(() => {
if (!previousStash) return; if (!previousStashKey) return;
setLingeredStash(previousStash); setCollecting(false);
if (lingerTimer.current) clearTimeout(lingerTimer.current); const settle = setTimeout(() => setCollecting(true), SETTLE_MS);
lingerTimer.current = setTimeout(() => setLingeredStash(null), TRICK_LINGER_MS); const done = setTimeout(() => {
setCollecting(false);
setDismissedKey(previousStashKey);
}, SETTLE_MS + COLLECT_MS);
return () => { return () => {
if (lingerTimer.current) clearTimeout(lingerTimer.current); clearTimeout(settle);
clearTimeout(done);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previousStashKey]); }, [previousStashKey]);
if (!gameStatus || !myPlayer) { if (!gameStatus || !myPlayer) {
@@ -73,8 +83,24 @@ export default function GameTable() {
const myTurnToPlay = isPlayPhase && active_player === myOrder; const myTurnToPlay = isPlayPhase && active_player === myOrder;
const activeCards = active_stash ? Object.keys(active_stash.cards).length : 0; const activeCards = active_stash ? Object.keys(active_stash.cards).length : 0;
const displayedStash: StashData | null = // Once a player leads, that live trick always wins the centre. Otherwise, if a
activeCards > 0 && active_stash ? active_stash : lingeredStash ?? null; // 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 const playableKeys = myTurnToPlay && active_stash
? computePlayable(hand, active_stash.cards[String(active_stash.first_player)]?.color ?? null) ? 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. // Center of the oval: trick during play, guess controls during bidding.
const ovalContent = isPlayPhase ? ( const ovalContent = isPlayPhase ? (
<Trick stash={displayedStash} players={players} myOrder={myOrder} /> <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 ? ( active_round_guesses !== undefined && active_player !== undefined ? (
<GuessControls <GuessControls