Trik: zmetanie po kazdom stichu, ovalne meno, dohrata hra mizne z lobby
Zmetacia animacia doteraz dobehla len na konci kola/serie (dlha pauza) -- mid-round ju hned prerusila dalsia zahrata karta, lebo `finishing` zavisela na activeCards === 0. Teraz drzi kopku v strede az do konca animacie a zablokuje hranie karty, kym sa nezmetie; boot-guard zabrani falosnej animacii pri reconnecte na uz rozohratu hru. PlayerCircle: ovalne oramovanie mena teraz rastie s dlzkou mena (fit-content + strop + ellipsis) namiesto pevnej sirky. api: hra dohrata do konca (4 serie) sa hned vytrati z lobby zoznamu, namiesto toho aby visela navzdy ako "Začatá"/"Pokračovať" aj ked uz ma v DB ended_at. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+13
-1
@@ -214,7 +214,13 @@ class CardStatusEncoder(JSONEncoder):
|
|||||||
|
|
||||||
|
|
||||||
def public_games() -> list:
|
def public_games() -> list:
|
||||||
"""Public lobby view — no sids, no reconnect tokens."""
|
"""Public lobby view — no sids, no reconnect tokens.
|
||||||
|
|
||||||
|
A game that finished naturally (all 4 series played out) stays in the
|
||||||
|
`games` dict for reconnect purposes (e.g. a reload while still on the
|
||||||
|
GameOver screen), but it has nothing left to offer the lobby — drop it
|
||||||
|
here rather than have it linger forever as a "started"/resumable entry.
|
||||||
|
"""
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"gid": g.gid,
|
"gid": g.gid,
|
||||||
@@ -231,6 +237,7 @@ def public_games() -> list:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
for g in games.values()
|
for g in games.values()
|
||||||
|
if g.bridzik_core is None or not g.bridzik_core.is_completed()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -704,6 +711,11 @@ async def play_card(sid, card_key):
|
|||||||
await send_game_status(game.gid)
|
await send_game_status(game.gid)
|
||||||
for player in game.players:
|
for player in game.players:
|
||||||
await send_player_cards(game.gid, player.order, player.sid)
|
await send_player_cards(game.gid, player.order, player.sid)
|
||||||
|
# A naturally-finished game (all 4 series played out) has nothing left to
|
||||||
|
# offer the lobby -- refresh the list so it drops out immediately instead
|
||||||
|
# of lingering as "started"/resumable until someone happens to leave it.
|
||||||
|
if core.is_completed():
|
||||||
|
await broadcast_lobby()
|
||||||
|
|
||||||
|
|
||||||
# --- history (read-only) --------------------------------------------------
|
# --- history (read-only) --------------------------------------------------
|
||||||
|
|||||||
@@ -12,25 +12,34 @@ interface Props {
|
|||||||
export default function PlayerCircle({ name, won, guess, active, size = 52 }: Props) {
|
export default function PlayerCircle({ name, won, guess, active, size = 52 }: Props) {
|
||||||
const nameFont = Math.max(9, Math.round(size * 0.17));
|
const nameFont = Math.max(9, Math.round(size * 0.17));
|
||||||
const valueFont = Math.round(size * 0.32);
|
const valueFont = Math.round(size * 0.32);
|
||||||
// Oval: width = size, height a touch shorter so it reads as an ellipse.
|
// Oval: height a touch shorter than size so it reads as an ellipse. Width
|
||||||
|
// starts at `size` (a circle for short names) but grows with the name via
|
||||||
|
// fit-content + padding, up to a cap beyond which the name is ellipsised
|
||||||
|
// rather than wrapping (wrapping would break the fixed height/oval shape).
|
||||||
const height = Math.round(size * 0.78);
|
const height = Math.round(size * 0.78);
|
||||||
|
const hPad = Math.round(size * 0.16);
|
||||||
|
const maxWidth = Math.round(size * 2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
// Only the active player is highlighted (gold ring + glow) — colors come
|
// Only the active player is highlighted (gold ring + glow) — colors come
|
||||||
// from the velvet-table palette tokens (tailwind.config.js), not literals.
|
// from the velvet-table palette tokens (tailwind.config.js), not literals.
|
||||||
className={`flex flex-col items-center justify-center rounded-[50%] ${
|
className={`flex flex-col items-center justify-center rounded-full ${
|
||||||
active ? 'bg-circle-active border-2 border-gold' : 'bg-circle border-[1.5px] border-gold/20'
|
active ? 'bg-circle-active border-2 border-gold' : 'bg-circle border-[1.5px] border-gold/20'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
width: size,
|
width: 'fit-content',
|
||||||
|
minWidth: size,
|
||||||
|
maxWidth,
|
||||||
height,
|
height,
|
||||||
|
paddingLeft: hPad,
|
||||||
|
paddingRight: hPad,
|
||||||
boxShadow: '0 2px 10px rgba(0,0,0,.45)',
|
boxShadow: '0 2px 10px rgba(0,0,0,.45)',
|
||||||
animation: active ? 'ar 2.2s ease-in-out infinite' : undefined,
|
animation: active ? 'ar 2.2s ease-in-out infinite' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`uppercase leading-tight text-center ${active ? 'text-gold' : 'text-green-circle'}`}
|
className={`uppercase leading-tight text-center overflow-hidden text-ellipsis whitespace-nowrap max-w-full ${active ? 'text-gold' : 'text-green-circle'}`}
|
||||||
style={{
|
style={{
|
||||||
fontFamily: '"DM Sans",sans-serif',
|
fontFamily: '"DM Sans",sans-serif',
|
||||||
fontSize: nameFont,
|
fontSize: nameFont,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, 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';
|
||||||
@@ -46,6 +46,17 @@ export default function GameTable() {
|
|||||||
? `${previousStash.first_player}:${JSON.stringify(previousStash.cards)}`
|
? `${previousStash.first_player}:${JSON.stringify(previousStash.cards)}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// On first load of an already-running game (reconnect / restore-on-startup) a
|
||||||
|
// completed trick is already present; adopt it as "already swept" so we don't
|
||||||
|
// replay a stale sweep over the live board. A freshly started game has no
|
||||||
|
// completed trick at this point, so its very first trick still animates.
|
||||||
|
const booted = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (booted.current || !gameStatus) return;
|
||||||
|
booted.current = true;
|
||||||
|
if (previousStashKey) setDismissedKey(previousStashKey);
|
||||||
|
}, [gameStatus, previousStashKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!previousStashKey) return;
|
if (!previousStashKey) return;
|
||||||
setCollecting(false);
|
setCollecting(false);
|
||||||
@@ -83,13 +94,15 @@ 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;
|
||||||
// Once a player leads, that live trick always wins the centre. Otherwise, if a
|
// A just-completed trick that hasn't been swept away yet always wins the centre
|
||||||
// just-completed trick hasn't been swept away yet, keep it face-up. Reading
|
// — even once the winner has already led the next trick. The engine reveals that
|
||||||
// `previousStash` synchronously (rather than a state set in an effect) means
|
// lead card (and, at a round boundary, the next bidding phase) the instant the
|
||||||
// the pile never blinks to empty on the frame the 4th card lands — the last
|
// 4th card lands, so without holding the pile here the sweep would be cut off
|
||||||
// card simply joins the three already there, then the whole pile is collected.
|
// after every trick. Reading `previousStash` synchronously (rather than a state
|
||||||
const finishing =
|
// set in an effect) also means the pile never blinks to empty on the frame the
|
||||||
previousStashKey !== null && previousStashKey !== dismissedKey && activeCards === 0;
|
// 4th card lands — the last card simply joins the three already there, then the
|
||||||
|
// whole pile is collected before the next trick takes over.
|
||||||
|
const finishing = previousStashKey !== null && previousStashKey !== dismissedKey;
|
||||||
const displayedStash: StashData | null = finishing
|
const displayedStash: StashData | null = finishing
|
||||||
? previousStash
|
? previousStash
|
||||||
: activeCards > 0 && active_stash
|
: activeCards > 0 && active_stash
|
||||||
@@ -102,7 +115,11 @@ export default function GameTable() {
|
|||||||
? COLLECT_BY_OFFSET[(stashWinner(previousStash) - myOrder + 4) % 4]
|
? COLLECT_BY_OFFSET[(stashWinner(previousStash) - myOrder + 4) % 4]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const playableKeys = myTurnToPlay && active_stash
|
// Block play until the previous trick's sweep animation has finished — otherwise
|
||||||
|
// the winner could lead the next card while the pile is still visibly clearing.
|
||||||
|
const canPlayNow = myTurnToPlay && !finishing;
|
||||||
|
|
||||||
|
const playableKeys = canPlayNow && 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)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -185,7 +202,11 @@ 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 ? (
|
// `finishing` also keeps the trick on screen while the round's *last* stash is
|
||||||
|
// swept away: the engine advances to the next round's bidding the instant the
|
||||||
|
// 4th card lands, so `isPlayPhase` flips to false immediately — without this,
|
||||||
|
// that final trick would vanish straight into the guess controls with no sweep.
|
||||||
|
const ovalContent = isPlayPhase || finishing ? (
|
||||||
<div style={collectAnim ? { animation: `${collectAnim} ${COLLECT_MS}ms ease-in both` } : undefined}>
|
<div style={collectAnim ? { animation: `${collectAnim} ${COLLECT_MS}ms ease-in both` } : undefined}>
|
||||||
<Trick stash={displayedStash} players={players} myOrder={myOrder} />
|
<Trick stash={displayedStash} players={players} myOrder={myOrder} />
|
||||||
</div>
|
</div>
|
||||||
@@ -222,7 +243,7 @@ export default function GameTable() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handArea = (
|
const handArea = (
|
||||||
<Hand hand={hand} myTurn={myTurnToPlay} isPlayPhase={isPlayPhase} playableKeys={playableKeys} desktop={desktop} />
|
<Hand hand={hand} myTurn={canPlayNow} isPlayPhase={isPlayPhase} playableKeys={playableKeys} desktop={desktop} />
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── DESKTOP LAYOUT ───────────────────────────────────────────────
|
// ── DESKTOP LAYOUT ───────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user