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:
@@ -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); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user