diff --git a/api/__init__.py b/api/__init__.py index 5ed2178..a7ea0cb 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -146,7 +146,7 @@ async def send_game_status(gid: str): status = json.loads(json.dumps(core.get_status(), cls=CardStatusEncoder)) # Use DB-backed standings so the score is correct even after a server restart # (the engine only knows rounds completed since restart). - status["standings"] = await history.get_standings(gid) + status["standings"], status["standings_guesses"] = await history.get_standings(gid) await sio.emit( "game_status", { @@ -180,9 +180,11 @@ async def send_error(sid: str, message: str): async def _mark_player_offline(game: "Game", player: "Player"): - """Mark player disconnected, delete the game if everyone left, else notify the room.""" + """Mark player disconnected. An unstarted game with nobody left is cleaned + up; a started game is kept in memory so it stays in the lobby and can be + resumed (it's torn down only by end_game).""" player.connected = False - if not any(p.connected for p in game.players): + if not any(p.connected for p in game.players) and not game.started: del games[game.gid] else: await sio.emit( @@ -212,14 +214,21 @@ async def _restore_unfinished_games(): for info in await history.get_unfinished_games(): if info["gid"] in games: continue - game = Game(info["gid"], info["name"]) - game.bridzik_core = info["core"] - game.started = True - for seat, (pid, uname) in enumerate(info["seats"]): - player = Player(None, uname, seat, pid) - player.connected = False - game.players.append(player) - games[info["gid"]] = game + _load_game_into_memory(info) + + +def _load_game_into_memory(info: dict) -> "Game": + """Postav in-memory Game z restore-info (gid/name/seats/core), hraci offline, + a vlozi ju do `games`. Pouzite pri starte aj pri obnove hry z historie.""" + game = Game(info["gid"], info["name"]) + game.bridzik_core = info["core"] + game.started = True + for seat, (pid, uname) in enumerate(info["seats"]): + player = Player(None, uname, seat, pid) + player.connected = False + game.players.append(player) + games[info["gid"]] = game + return game # --- connection lifecycle ------------------------------------------------- @@ -382,13 +391,13 @@ async def start_game(sid, gid): @sio.on("end_game") async def end_game(sid, gid): - """Host (seat 0) permanently ends a game that won't be finished. Marks it - ended in the DB (so it won't be restored) and sends everyone back to the lobby.""" + """Any seated player can permanently end a game that won't be finished -- + not just the host, so the other players aren't stuck forever if the host + abandons the game. Marks it ended in the DB (so it won't be restored) and + sends everyone back to the lobby.""" sess = sessions.get(sid) if sess is None or sess["gid"] != gid: return await send_error(sid, "Nie ste v tejto hre.") - if sess["order"] != 0: - return await send_error(sid, "Iba hostitel moze ukoncit hru.") game = games.get(gid) if game is None: return await send_error(sid, "Hra neexistuje.") @@ -471,6 +480,27 @@ async def rejoin_game(sid, gid): await broadcast_lobby() +@sio.on("restore_game") +async def restore_game(sid, gid): + """Obnov predcasne ukoncenu hru z historie spat do lobby. Smie ju vyvolat + iba hrac danej hry; v lobby sa potom objavi ako rozohrata a clenovia sa + pripoja cez `rejoin_game`.""" + account = accounts.get(sid) + if account is None: + return await send_error(sid, "Musíte byť prihlásený.") + if gid in games: + # Uz je v pamati (lobby) -- staci obnovit zoznam hier u klienta. + await sio.emit("game_restored", {"gid": gid}, to=sid) + return await sio.emit("get_games", {"games": public_games()}, to=sid) + + info = await history.reopen_game(gid, account["player_id"]) + if info is None: + return await send_error(sid, "Hru sa nepodarilo obnovit.") + _load_game_into_memory(info) + await sio.emit("game_restored", {"gid": gid}, to=sid) + await broadcast_lobby() + + # --- in-game actions (seat derived from the connection, never the client) - @sio.on("game_status") diff --git a/api/history.py b/api/history.py index a0c677d..188c0dd 100644 --- a/api/history.py +++ b/api/history.py @@ -9,10 +9,20 @@ from random import shuffle from sqlalchemy import func, or_, select -from bridzik import Bridzik, Round, Series +from bridzik import Bridzik, ROUNDS_PER_SERIES, Round, SERIES_PER_GAME, Series from db.db import async_session from db.models import Game, Guess, Player +# Naplno dohrana hra ma zapisanych SERIES_PER_GAME * ROUNDS_PER_SERIES +# dokoncenych kol -- tvar hry je definovany v bridzik.py, tu sa len cita. +FULL_GAME_ROUNDS = SERIES_PER_GAME * ROUNDS_PER_SERIES + + +def _utcnow_naive() -> datetime: + """Naive UTC `datetime` na zapis do `ended_at`. Stlpec je TIMESTAMP WITHOUT + TIME ZONE (ako `created_at`), tz-aware hodnotu by asyncpg/Postgres odmietol.""" + return datetime.now(timezone.utc).replace(tzinfo=None) + async def record_game_started(gid: str, name: str, player_ids: list[int]) -> None: """Zapise riadok Game so 4 ID hracov (podla sedadla). Idempotentne.""" @@ -75,19 +85,21 @@ async def record_completed_rounds(gid: str, core) -> None: game.round = last_series.get_last_round().round_number if core.is_completed() and game.ended_at is None: - game.ended_at = datetime.now(timezone.utc) + game.ended_at = _utcnow_naive() await session.commit() -async def get_standings(gid: str) -> list[list[list[int]]]: - """Body po seriach/kolach z DB v tvare, ktory caka frontend: standings[serie][kolo] - = [body sedadiel 0..3]. Pouziva sa v game_status, aby skore sedelo aj po restarte. +async def get_standings(gid: str) -> tuple[list[list[list[int]]], list[list[list[int]]]]: + """Body aj tipy po seriach/kolach z DB, oboje v tvare ktory caka frontend: + `[serie][kolo][sedadlo 0..3]`. Vracia dvojicu `(points, guesses)` -- tipy + su tam, aby frontend pri 0 bodoch ukazal preskrtnuty tip namiesto nuly. + Citaju sa z tych istych `Guess` riadkov, takze jeden dotaz staci. """ async with async_session() as session: game = await session.get(Game, gid) if game is None: - return [] + return [], [] seat_of = { game.player0_id: 0, game.player1_id: 1, @@ -102,18 +114,23 @@ async def get_standings(gid: str) -> list[list[list[int]]]: ) ).all() - series_map: dict[int, dict[int, list[int]]] = {} + # series_map[serie][kolo] = ([body sedadiel], [tipy sedadiel]) + series_map: dict[int, dict[int, tuple[list[int], list[int]]]] = {} for gz in rows: rounds = series_map.setdefault(gz.series_number, {}) - points = rounds.setdefault(gz.round_number, [0, 0, 0, 0]) + points, tips = rounds.setdefault(gz.round_number, ([0, 0, 0, 0], [0, 0, 0, 0])) seat = seat_of.get(gz.player_id) if seat is not None: points[seat] = gz.points + tips[seat] = gz.guess - return [ - [series_map[s][r] for r in sorted(series_map[s])] - for s in sorted(series_map) - ] + points_table: list[list[list[int]]] = [] + guesses_table: list[list[list[int]]] = [] + for s in sorted(series_map): + round_nums = sorted(series_map[s]) + points_table.append([series_map[s][r][0] for r in round_nums]) + guesses_table.append([series_map[s][r][1] for r in round_nums]) + return points_table, guesses_table async def get_player_history(player_id: int) -> list[dict]: @@ -122,17 +139,24 @@ async def get_player_history(player_id: int) -> list[dict]: stmt = ( select(Game) .where( + Game.ended_at.is_not(None), # iba ukoncene hry or_( Game.player0_id == player_id, Game.player1_id == player_id, Game.player2_id == player_id, Game.player3_id == player_id, - ) + ), ) .order_by(Game.created_at.desc()) ) games = (await session.scalars(stmt)).all() + # Pocet dokoncenych kol na hru (na rozlisenie naplno dohranej hry od + # predcasne ukoncenej) -- jeden batch dotaz pre vsetky hry hraca. + completed_rounds = await _completed_rounds_per_game( + session, [g.id for g in games] + ) + result = [] for g in games: seat_ids = [g.player0_id, g.player1_id, g.player2_id, g.player3_id] @@ -145,10 +169,13 @@ async def get_player_history(player_id: int) -> list[dict]: result.append( { "gid": g.id, + "name": g.name, "created_at": g.created_at.isoformat() if g.created_at else None, "ended_at": g.ended_at.isoformat() if g.ended_at else None, "players": [usernames[pid] for pid in seat_ids], "my_points": int(total or 0), + # True = dohrana naplno; False = predcasne ukoncena (da sa obnovit). + "completed": completed_rounds.get(g.id, 0) >= FULL_GAME_ROUNDS, } ) return result @@ -173,6 +200,7 @@ async def get_game_detail(gid: str) -> dict | None: return { "gid": game.id, + "name": game.name, "created_at": game.created_at.isoformat() if game.created_at else None, "ended_at": game.ended_at.isoformat() if game.ended_at else None, "players": [ @@ -200,6 +228,60 @@ async def _usernames_for(session, player_ids: list[int]) -> dict[int, str]: return {p.id: p.username for p in rows} +async def _completed_rounds_per_game(session, gids: list[str]) -> dict[str, int]: + """Pocet dokoncenych (series, round) kol na hru. Guess sa zapisuje len za + dohrate kola, takze pocet unikatnych dvojic = pocet dokoncenych kol.""" + if not gids: + return {} + rows = await session.execute( + select(Guess.game_id, Guess.series_number, Guess.round_number) + .where(Guess.game_id.in_(gids)) + .distinct() + ) + counts: dict[str, int] = {} + for r in rows: + counts[r.game_id] = counts.get(r.game_id, 0) + 1 + return counts + + +async def _restore_info(session, game: Game) -> dict: + """Postavi restore-payload pre jednu hru: gid, name, sedadla (player_id + + username podla poradia 0..3) a uz postaveny Bridzik na ulozenej pozicii. + Spolocny tvar pre `reopen_game` aj `get_unfinished_games`.""" + seat_ids = [game.player0_id, game.player1_id, game.player2_id, game.player3_id] + usernames = await _usernames_for(session, seat_ids) + return { + "gid": game.id, + "name": game.name, + "seats": [(pid, usernames.get(pid, "?")) for pid in seat_ids], + "core": rebuild_core(game.series, game.round), + } + + +async def reopen_game(gid: str, player_id: int) -> dict | None: + """Znovu otvori predcasne ukoncenu hru: vymaze `ended_at` a vrati info na + obnovu do pamate (rovnaky tvar ako polozka z `get_unfinished_games`). + + Vrati None, ak hra neexistuje, hrac v nej nie je, alebo uz bola dohrana + naplno (vtedy nie je co pokracovat). + """ + async with async_session() as session: + game = await session.get(Game, gid) + if game is None: + return None + seat_ids = [game.player0_id, game.player1_id, game.player2_id, game.player3_id] + if player_id not in seat_ids: + return None + counts = await _completed_rounds_per_game(session, [gid]) + if counts.get(gid, 0) >= FULL_GAME_ROUNDS: + return None # naplno dohrana hra sa neobnovuje + + game.ended_at = None + info = await _restore_info(session, game) + await session.commit() + return info + + # --- restore --------------------------------------------------------------- def rebuild_core(series_number: int, round_number: int, shuffler=shuffle) -> Bridzik: @@ -236,31 +318,14 @@ async def mark_game_ended(gid: str) -> None: async with async_session() as session: game = await session.get(Game, gid) if game is not None and game.ended_at is None: - game.ended_at = datetime.now(timezone.utc) + game.ended_at = _utcnow_naive() await session.commit() async def get_unfinished_games() -> list[dict]: - """Nedohrate hry (ended_at IS NULL) aj s obnovenym jadrom -- na obnovu pri starte. - - Vracia per hru: gid, name, sedadla (player_id + username podla poradia 0..3) - a uz postaveny Bridzik na ulozenej pozicii. - """ + """Nedohrate hry (ended_at IS NULL) aj s obnovenym jadrom -- na obnovu pri starte.""" async with async_session() as session: games = ( await session.scalars(select(Game).where(Game.ended_at.is_(None))) ).all() - - result = [] - for g in games: - seat_ids = [g.player0_id, g.player1_id, g.player2_id, g.player3_id] - usernames = await _usernames_for(session, seat_ids) - result.append( - { - "gid": g.id, - "name": g.name, - "seats": [(pid, usernames.get(pid, "?")) for pid in seat_ids], - "core": rebuild_core(g.series, g.round), - } - ) - return result + return [await _restore_info(session, g) for g in games] diff --git a/bridzik.py b/bridzik.py index cde1af3..062767e 100644 --- a/bridzik.py +++ b/bridzik.py @@ -82,6 +82,13 @@ class Card(): cards = [Card(color, value) for value in Card_values for color in Card_colors] +# Sturktura hry: kazda hra ma SERIES_PER_GAME serii, kazda seria ma +# ROUNDS_PER_SERIES kol. Jediny zdroj pravdy pre tieto cisla -- ina vrstva +# (napr. api/history.py) ich odvodzuje odtialto, nikdy si ich nevymysla sama. +SERIES_PER_GAME = 4 +ROUNDS_PER_SERIES = 8 + + class Bridzik(): def __init__(self, shuffler=shuffle): self.shuffler = shuffler @@ -124,7 +131,7 @@ class Bridzik(): return status def is_completed(self): - return len(self.series) == 4 and self.series[-1].is_completed() + return len(self.series) == SERIES_PER_GAME and self.series[-1].is_completed() def get_previous_stash(self): if len(self.series[-1].get_last_round().stashes) > 1: @@ -169,7 +176,7 @@ class Series(): self.start_new_round() def is_completed(self): - return len(self.rounds) == 8 and self.get_last_round().is_completed() + return len(self.rounds) == ROUNDS_PER_SERIES and self.get_last_round().is_completed() def get_standings(self): return [r.get_points_summary() for r in self.rounds if r.is_completed()] @@ -191,7 +198,7 @@ class Series(): class Round(): def __init__(self, round_number: int, first_player: int, cards: []=cards, shuffler=shuffle): # vyrob kopku pre toto kolo a priprav prazdne objekty - if round_number not in [i for i in range(8)]: + if round_number not in range(ROUNDS_PER_SERIES): raise BridzikException('Neplatne cislo kola.') if first_player not in [0, 1, 2, 3]: raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.') diff --git a/docker-compose.yaml b/docker-compose.yaml index 55f456d..538e1c1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,6 +1,6 @@ services: db: - image: postgres:16-alpine + image: postgres:18-alpine environment: POSTGRES_USER: bridzik POSTGRES_PASSWORD: bridzik @@ -8,7 +8,7 @@ services: ports: - "5432:5432" volumes: - - pgdata:/var/lib/postgresql/data + - pgdata:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U bridzik -d bridzik"] interval: 5s diff --git a/frontend/index.html b/frontend/index.html index 18c456e..ad96af3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,14 @@ - Bridzik + + Bridžik + + +
diff --git a/frontend/src/components/CardView.tsx b/frontend/src/components/CardView.tsx index 34c7d62..71fc8c7 100644 --- a/frontend/src/components/CardView.tsx +++ b/frontend/src/components/CardView.tsx @@ -19,61 +19,66 @@ const VALUE_LABEL: Record = { LOWER: 'J', UPPER: 'Q', KING: 'K', ACE: 'A', }; +// Per-size geometry. sm/md sit on the table, lg/xl are hand cards. +const DIMS = { + sm: { w: 38, h: 54, radius: 4, inset: 3, label: 9, suitSm: 7, suitLg: 18 }, + md: { w: 56, h: 80, radius: 6, inset: 4, label: 12, suitSm: 10, suitLg: 30 }, + lg: { w: 60, h: 84, radius: 7, inset: 5, label: 12, suitSm: 9, suitLg: 30 }, + xl: { w: 72, h: 100, radius: 8, inset: 6, label: 14, suitSm: 11, suitLg: 38 }, +} as const; + interface Props { card: Card; onClick?: () => void; disabled?: boolean; - selected?: boolean; - size?: 'sm' | 'md' | 'lg'; + /** Playable card on your turn — gold glow border + lift. */ + highlight?: boolean; + size?: keyof typeof DIMS; } -export default function CardView({ card, onClick, disabled = false, selected = false, size = 'md' }: Props) { +export default function CardView({ card, onClick, disabled = false, highlight = false, size = 'md' }: Props) { const symbol = SUIT_SYMBOL[card.color]; - const color = SUIT_COLOR[card.color]; - const label = VALUE_LABEL[card.value]; - - const dims = { - sm: { cls: 'w-10 h-14', label: 9, iconSm: 9, iconLg: 18, inset: 2 }, - md: { cls: 'w-14 h-20', label: 11, iconSm: 11, iconLg: 26, inset: 3 }, - lg: { cls: 'w-20 h-28', label: 15, iconSm: 15, iconLg: 38, inset: 4 }, - }[size]; + const color = SUIT_COLOR[card.color]; + const label = VALUE_LABEL[card.value]; + const d = DIMS[size]; const interactive = !disabled && !!onClick; + const corner = (rotated: boolean) => ( + + + {label} + + {symbol} + + ); + return ( ); } diff --git a/frontend/src/components/FaceDownCards.tsx b/frontend/src/components/FaceDownCards.tsx new file mode 100644 index 0000000..c623e72 --- /dev/null +++ b/frontend/src/components/FaceDownCards.tsx @@ -0,0 +1,43 @@ +interface Props { + /** Exact number of cards the player still holds. */ + count: number; + /** Row (top player) or column (side players). */ + direction: 'row' | 'col'; + desktop?: boolean; +} + +/** Overlapping fan of face-down cards next to an opponent's circle — one card + * per card still in their hand, so the stack shrinks as they play. */ +export default function FaceDownCards({ count, direction, desktop = false }: Props) { + const n = Math.max(0, Math.min(count, 8)); + if (n === 0) return null; + + const row = direction === 'row'; + const w = desktop ? 40 : 25; + const h = desktop ? 56 : 36; + const overlap = row ? Math.round(w * 0.45) : Math.round(h * 0.5); + + return ( +
+ {Array.from({ length: n }).map((_, i) => ( +
0 ? -overlap : 0, + marginTop: !row && i > 0 ? -overlap : 0, + zIndex: i, + background: + i % 2 === 0 + ? 'linear-gradient(150deg,#1d4a28,#0e2818)' + : 'linear-gradient(150deg,#1b4424,#0d2616)', + borderRadius: 3, + border: '1px solid rgba(201,168,76,.18)', + boxShadow: '0 2px 6px rgba(0,0,0,.5)', + }} + /> + ))} +
+ ); +} diff --git a/frontend/src/components/GuessControls.tsx b/frontend/src/components/GuessControls.tsx index 4e7897b..bc7ed1e 100644 --- a/frontend/src/components/GuessControls.tsx +++ b/frontend/src/components/GuessControls.tsx @@ -17,8 +17,9 @@ export default function GuessControls({ cardsInRound, guesses, myOrder, activePl if (!isMyTurn) { return ( -

- Caka sa na tip: {activePlayerName} +

+ Čaká sa na tip:{' '} + {activePlayerName}

); } @@ -26,8 +27,8 @@ export default function GuessControls({ cardsInRound, guesses, myOrder, activePl const options = Array.from({ length: cardsInRound + 1 }, (_, i) => i); return ( -
-

Tvoj tip (pocet kopok):

+
+

Zadaj svoj tip (počet kopiek)

{options.map((n) => { const isForbidden = n === forbidden; @@ -36,12 +37,12 @@ export default function GuessControls({ cardsInRound, guesses, myOrder, activePl key={n} disabled={isForbidden} onClick={() => emit.addGuess(n)} - title={isForbidden ? 'Zakázaná hodnota (suma = počet kopok)' : undefined} + title={isForbidden ? 'Zakázaná hodnota (súčet = počet kopiek)' : undefined} className={[ - 'w-10 h-10 rounded-lg font-bold text-lg border-2 transition-colors', + 'w-11 h-11 rounded-full font-serif text-lg border-2 transition-colors', isForbidden - ? 'border-red-700 text-red-700 opacity-40 cursor-not-allowed' - : 'border-blue-400 text-blue-200 hover:bg-blue-600 hover:border-blue-600 active:scale-95', + ? 'border-[#5a2a2a] text-[#7a4040] opacity-40 cursor-not-allowed' + : 'border-gold/50 text-gold hover:bg-gold hover:text-table hover:border-gold active:scale-95', ].join(' ')} > {n} diff --git a/frontend/src/components/Hand.tsx b/frontend/src/components/Hand.tsx index 5569a83..7135ded 100644 --- a/frontend/src/components/Hand.tsx +++ b/frontend/src/components/Hand.tsx @@ -21,30 +21,83 @@ interface Props { myTurn: boolean; isPlayPhase: boolean; playableKeys?: Set; + desktop?: boolean; } -export default function Hand({ hand, myTurn, isPlayPhase, playableKeys }: Props) { +export default function Hand({ hand, myTurn, isPlayPhase, playableKeys, desktop = false }: Props) { const groups = groupedByColor(hand); - if (groups.length === 0) return null; + const canPlay = isPlayPhase && myTurn; + + const cardProps = (key: string) => { + const legal = playableKeys === undefined || playableKeys.has(key); + const playable = canPlay && legal; + // Cards stay light by default; darken only the illegal ones, and only while + // it's actually your turn to play. + const dimmed = canPlay && !legal; + return { + card: hand[key], + highlight: playable, + disabled: dimmed, + onClick: playable ? () => emit.playCard(key) : undefined, + }; + }; + return ( -
- {groups.map(({ color, keys }) => ( -
- {keys.map((key) => { - // During guessing phase cards are visible at full opacity (just not clickable). - // Only dim cards during the play phase when they can't be played. - const disabled = isPlayPhase && (!myTurn || (playableKeys !== undefined && !playableKeys.has(key))); - return ( - emit.playCard(key)} - /> - ); - })} +
+
+
+ Tvoje karty +
+
+ + {desktop ? ( + // Desktop has room — keep cards grouped by suit, wrap if needed. +
+ {groups.map(({ color, keys }) => ( +
+ {keys.map((key) => ( + + ))} +
+ ))} +
+ ) : ( + + )} +
+ ); +} + +/** All cards in a single overlapping row that always fits the mobile width: + * small gap when few cards, partial overlap when many. */ +function MobileHand({ + groups, + cardProps, +}: { + groups: { color: CardColor; keys: string[] }[]; + cardProps: (key: string) => { card: Hand[string]; highlight: boolean; disabled: boolean; onClick?: () => void }; +}) { + const keys = groups.flatMap((g) => g.keys); + const n = keys.length; + + const CARD_W = 60; // matches CardView size "lg" + const MAX_ROW = 300; // keep within a small phone's usable width (~360px screens) + // Horizontal step between successive cards; 1 ? Math.min(CARD_W + 6, (MAX_ROW - CARD_W) / (n - 1)) : 0; + const margin = step - CARD_W; // negative → overlap, positive → gap + + return ( +
+ {keys.map((key, i) => ( +
+
))}
diff --git a/frontend/src/components/NameModal.tsx b/frontend/src/components/NameModal.tsx index fa114f3..ea71da4 100644 --- a/frontend/src/components/NameModal.tsx +++ b/frontend/src/components/NameModal.tsx @@ -26,9 +26,9 @@ export default function NameModal({ onClose }: Props) { }; return ( -
-
-

Nazov hry

+
+
+

Názov hry

setName(e.target.value)} maxLength={30} - placeholder="Napr. Vecerna partia" - className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500" + placeholder="Napr. Večerná partia" + className="bg-circle text-green-score rounded-lg px-4 py-2 border border-gold/20 outline-none focus:border-gold/60 focus:ring-1 focus:ring-gold/30 placeholder:text-green-dim/60" />
diff --git a/frontend/src/components/PlayerCircle.tsx b/frontend/src/components/PlayerCircle.tsx new file mode 100644 index 0000000..0924ee0 --- /dev/null +++ b/frontend/src/components/PlayerCircle.tsx @@ -0,0 +1,56 @@ +interface Props { + name: string; + /** Tricks won this round. */ + won: number; + /** Bid for this round (null until the player has guessed). */ + guess: number | null; + /** Whether it is this player's turn — the only state that highlights a circle. */ + active: boolean; + size?: number; +} + +export default function PlayerCircle({ name, won, guess, active, size = 52 }: Props) { + const nameFont = Math.max(9, Math.round(size * 0.17)); + const valueFont = Math.round(size * 0.32); + // Oval: width = size, height a touch shorter so it reads as an ellipse. + const height = Math.round(size * 0.78); + + return ( +
+ + {name} + + + {won} + /{guess ?? '?'} + +
+ ); +} diff --git a/frontend/src/components/RulesModal.tsx b/frontend/src/components/RulesModal.tsx index d5e37db..56773b3 100644 --- a/frontend/src/components/RulesModal.tsx +++ b/frontend/src/components/RulesModal.tsx @@ -9,17 +9,17 @@ export default function RulesModal({ onClose }: Props) { onClick={onClose} >
e.stopPropagation()} > -

Pravidlá hry Bridžik

+

Pravidlá hry Bridžik

Hrá sa s 32-kartovým balíčkom sedmových (slovenských/nemeckých) kariet.

@@ -37,7 +37,7 @@ export default function RulesModal({ onClose }: Props) {

1. Tipovanie

Každý hráč tipuje, koľko kopiek v kole získa (0 až počet kopiek).

-

Pravidlo bridžika: súčet tipov nesmie presne rovnať počtu kopiek v kole — posledný tipujúci nemôže zadať tip, ktorý by toto spôsobil.

+

Pravidlo bridžika: súčet tipov nesmie presne rovnať počtu kopiek v kole — posledný tipujúci nemôže zadať tip, ktorý by toto spôsobil.

2. Hranie kariet

Prvú kopku otvára hráč s najvyšším tipom. Každú ďalšiu otvára víťaz predchádzajúcej kopky.

@@ -58,13 +58,13 @@ export default function RulesModal({ onClose }: Props) {

Po každom kole: ak sa tip presne zhoduje s počtom získaných kopiek → 10 + tip bodov, inak 0.

-

Príklad: tipoval 3, získal 3 → 13 bodov. Tipoval 3, získal 2 → 0 bodov.

+

Príklad: tipoval 3, získal 3 → 13 bodov. Tipoval 3, získal 2 → 0 bodov.

Vyhráva hráč s najvyšším celkovým súčtom po 4 sériách.

@@ -76,8 +76,8 @@ export default function RulesModal({ onClose }: Props) { function Section({ title, children }: { title: string; children: React.ReactNode }) { return (
-

{title}

-
{children}
+

{title}

+
{children}
); } diff --git a/frontend/src/components/Standings.tsx b/frontend/src/components/Standings.tsx index 9b361cf..a804ed3 100644 --- a/frontend/src/components/Standings.tsx +++ b/frontend/src/components/Standings.tsx @@ -4,53 +4,192 @@ import { computeTotal } from '../lib/standings'; interface Props { standings: number[][][]; + /** Tips per series/round/seat, same shape as standings. */ + guesses?: number[][][]; players: PlayerInfo[]; + myOrder: number; + /** Desktop renders an always-open sidebar; mobile a collapsible panel. */ + desktop?: boolean; } -export default function Standings({ standings, players }: Props) { +export default function Standings({ standings, guesses = [], players, myOrder, desktop = false }: Props) { const [open, setOpen] = useState(false); - const sorted = [...players] - .map((p) => ({ ...p, total: computeTotal(standings, p.order) })) - .sort((a, b) => b.total - a.total); + // Player columns in seat order; the local player's column is highlighted. + const cols = [...players].sort((a, b) => a.order - b.order); + // Completed-round count before each series (running total), so each series' + // round index can be offset in a single pass instead of re-summing per row. + const seriesRoundOffsets: number[] = []; + let completedRounds = 0; + for (const s of standings) { + seriesRoundOffsets.push(completedRounds); + completedRounds += s.length; + } + // Engine: every series is exactly 8 rounds → a series with 8 entries is done, + // and gets a per-series summary row after its last round. + const ROUNDS_PER_SERIES = 8; + // Bigger, more legible type on the wide desktop sidebar; compact on mobile. + const fz = { + head: desktop ? 11 : 10, + idx: desktop ? 13 : 9, + cell: desktop ? 19 : 14, + dot: desktop ? 18 : 13, + sigma: desktop ? 13 : 9, + total: desktop ? 20 : 18, + }; + + const table = ( +
+ {/* Column headers */} +
+
+ {cols.map((p) => ( +
+ {p.name} +
+ ))} +
+
+ + {/* Completed rounds, grouped by series with a per-series summary row */} + {standings.flatMap((seriesRounds, si) => { + const priorRounds = seriesRoundOffsets[si]; + const elems = seriesRounds.map((scores, lri) => ( +
+
+ {priorRounds + lri + 1} +
+ {cols.map((p) => { + const points = scores[p.order] ?? 0; + // Failed tip (0 points) → show the struck-through tip instead of 0. + if (points === 0) { + return ( +
+ {guesses[si]?.[lri]?.[p.order] ?? 0} +
+ ); + } + return ( +
+ {points} +
+ ); + })} +
+ )); + + // After a finished series, sum its points per player. + if (seriesRounds.length === ROUNDS_PER_SERIES) { + elems.push( +
+
+ Σ{si + 1} +
+ {cols.map((p) => { + const sum = seriesRounds.reduce((a, r) => a + (r[p.order] ?? 0), 0); + return ( +
+ {sum} +
+ ); + })} +
, + ); + } + return elems; + })} + + {/* Active round placeholder */} +
+
{completedRounds + 1}
+ {cols.map((p) => ( +
·
+ ))} +
+ +
+
+ + {/* Totals */} +
+
+ Σ +
+ {cols.map((p) => ( +
+ {computeTotal(standings, p.order)} +
+ ))} +
+
+ ); + + if (desktop) { + return ( + + ); + } + + // Mobile: collapsible panel return ( -
+
- {open && ( - - - - - {standings.map((_, si) => ( - - ))} - - - - - {sorted.map((p) => ( - - - {standings.map((series, si) => { - return ( - - ); - })} - - - ))} - -
HracS{si + 1}Spolu
{p.name} - {computeTotal([series], p.order)} - {p.total}
- )} + {open && table}
); } diff --git a/frontend/src/components/Trick.tsx b/frontend/src/components/Trick.tsx index 71630e5..26c8e4b 100644 --- a/frontend/src/components/Trick.tsx +++ b/frontend/src/components/Trick.tsx @@ -1,26 +1,66 @@ -import type { StashData } from '../types'; +import type { PlayerInfo, StashData } from '../types'; import CardView from './CardView'; interface Props { stash: StashData | null; + players: PlayerInfo[]; + myOrder: number; } -export default function Trick({ stash }: Props) { - const cards = stash - ? [0, 1, 2, 3] - .map((i) => (stash.first_player + i) % 4) - .map((order) => stash.cards[String(order)]) - .filter(Boolean) +const ROTATIONS = [-3, 2, -1, 1]; +// Entry direction by seat offset from me: 0=me(bottom) 1=left 2=top 3=right. +const FLY_BY_OFFSET = ['fly-bottom', 'fly-left', 'fly-top', 'fly-right']; + +export default function Trick({ stash, players, myOrder }: Props) { + // Seat order, starting from whoever led the trick. + const playOrder = stash + ? [0, 1, 2, 3].map((i) => (stash.first_player + i) % 4) : []; + const nameFor = (order: number) => + players.find((p) => p.order === order)?.name ?? ''; + + const overlap = -16; + const slotH = 80; + + if (!stash) { + return
; + } + return ( -
-

Aktualny stich

-
- {cards.map((card, i) => ( - - ))} -
+
+ {playOrder.map((order, i) => { + const card = stash.cards[String(order)]; + // Only render cards that have actually been played — no placeholder slot + // for players still to play this trick. + if (!card) return null; + const offset = (order - myOrder + 4) % 4; + return ( +
+ + {nameFor(order)} + + {/* Outer: flies in from the player's direction. Inner: static rotation. */} +
+
+ +
+
+
+ ); + })}
); } diff --git a/frontend/src/index.css b/frontend/src/index.css index 9ecd347..fc64c58 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -2,6 +2,71 @@ @tailwind components; @tailwind utilities; -body { - @apply bg-slate-900 text-white min-h-screen; +@layer base { + html, + body, + #root { + @apply min-h-screen; + } + + /* Single global type lever: enlarges all rem-based Tailwind text (menu/list/ + auth/history screens). The game board uses fixed px + transform zoom, so it + stays pixel-precise. Bump this one value to scale the menus up or down. */ + html { + font-size: 18px; + } + + body { + @apply bg-table text-green-score font-sans; + background: + radial-gradient(ellipse at 50% -10%, rgba(48, 104, 69, 0.16), transparent 60%), + #090e0b; + background-attachment: fixed; + } + + /* Form fields keep the velvet look across the app */ + input::placeholder { + @apply text-green-dim/60; + } +} + +/* Velvet-table animations (design handoff). Declared as raw CSS so they work + both via Tailwind's animate-* utilities and inline `animation:` strings. */ +@keyframes tp { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} +@keyframes ci { + from { opacity: 0; transform: translateY(-6px) scale(0.9); } + to { opacity: 1; transform: none; } +} +@keyframes ar { + 0%, 100% { + box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.18), 0 0 18px rgba(201, 168, 76, 0.5), 0 0 42px rgba(201, 168, 76, 0.2); + } + 50% { + box-shadow: 0 0 0 5px rgba(201, 168, 76, 0.34), 0 0 32px rgba(201, 168, 76, 0.85), 0 0 56px rgba(201, 168, 76, 0.3); + } +} +@keyframes g1 { + 0%, 100% { box-shadow: 0 0 18px rgba(201, 168, 76, 0.55), 0 6px 18px rgba(0, 0, 0, 0.55); } + 50% { box-shadow: 0 0 34px rgba(201, 168, 76, 0.85), 0 6px 18px rgba(0, 0, 0, 0.55); } +} + +/* A played card slides into the centre from the direction of its player. */ +@keyframes fly-top { + from { opacity: 0; transform: translateY(-90px) scale(0.82); } + to { opacity: 1; transform: none; } +} +@keyframes fly-bottom { + from { opacity: 0; transform: translateY(90px) scale(0.82); } + to { opacity: 1; transform: none; } +} +@keyframes fly-left { + from { opacity: 0; transform: translateX(-110px) scale(0.82); } + to { opacity: 1; transform: none; } +} +@keyframes fly-right { + from { opacity: 0; transform: translateX(110px) scale(0.82); } + to { opacity: 1; transform: none; } } diff --git a/frontend/src/lib/socket.ts b/frontend/src/lib/socket.ts index 6490d23..51bbb8d 100644 --- a/frontend/src/lib/socket.ts +++ b/frontend/src/lib/socket.ts @@ -47,6 +47,8 @@ export const emit = { _pendingGid = gid; socket.emit('rejoin_game', gid); }, + // Reopen a prematurely-ended game from history back into the lobby. + restoreGame: (gid: string) => socket.emit('restore_game', gid), leaveGame: () => socket.emit('leave_game'), endGame: (gid: string) => socket.emit('end_game', gid), startGame: (gid: string) => socket.emit('start_game', gid), diff --git a/frontend/src/lib/useFitScale.ts b/frontend/src/lib/useFitScale.ts new file mode 100644 index 0000000..207cb86 --- /dev/null +++ b/frontend/src/lib/useFitScale.ts @@ -0,0 +1,45 @@ +import { useLayoutEffect, useRef, useState } from 'react'; + +/** + * Zooms the desktop board to fill the whole window. The board is a canvas of + * fixed height (`designHeight`) whose width is computed to span the viewport, + * and the scale is driven by height — so everything (cards, circles, text, + * header) grows and shrinks together while the felt always uses the full width. + * + * Returns the container ref (the viewport), the `scale` for `transform`, and + * `contentWidth` — the pre-scale canvas width (`viewportWidth / scale`) so that + * after scaling it exactly fills the viewport width. + * + * `deps` should change when the layout swaps (mobile↔desktop) so the observer + * re-attaches to the freshly rendered element. + */ +export function useFitScale(deps: unknown[] = [], designHeight = 860, maxScale = 2.6) { + const containerRef = useRef(null); + const [box, setBox] = useState({ scale: 1, contentWidth: 1280 }); + + useLayoutEffect(() => { + const el = containerRef.current; + if (!el) return; + + const measure = () => { + const availW = el.clientWidth; + const availH = el.clientHeight; + if (!availW || !availH) return; + const scale = Math.min(maxScale, availH / designHeight); + const contentWidth = availW / scale; + setBox((prev) => + Math.abs(prev.scale - scale) > 0.004 || Math.abs(prev.contentWidth - contentWidth) > 1 + ? { scale, contentWidth } + : prev, + ); + }; + + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return { containerRef, scale: box.scale, contentWidth: box.contentWidth }; +} diff --git a/frontend/src/lib/useIsDesktop.ts b/frontend/src/lib/useIsDesktop.ts new file mode 100644 index 0000000..d67f0bd --- /dev/null +++ b/frontend/src/lib/useIsDesktop.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react'; + +/** True on viewports >= 1024px — drives the desktop GameTable layout + * (score sidebar, larger oval, bigger cards). */ +export function useIsDesktop(): boolean { + const query = '(min-width: 1024px)'; + const [isDesktop, setIsDesktop] = useState( + () => typeof window !== 'undefined' && window.matchMedia(query).matches, + ); + + useEffect(() => { + const mql = window.matchMedia(query); + const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches); + mql.addEventListener('change', handler); + setIsDesktop(mql.matches); + return () => mql.removeEventListener('change', handler); + }, []); + + return isDesktop; +} diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx index 20945c0..2763b8f 100644 --- a/frontend/src/pages/Auth.tsx +++ b/frontend/src/pages/Auth.tsx @@ -2,13 +2,18 @@ import { useState } from 'react'; import { QRCodeSVG } from 'qrcode.react'; import { useGameStore } from '../store/gameStore'; import { emit } from '../lib/socket'; +import RulesModal from '../components/RulesModal'; type Mode = 'login' | 'register'; +const inputCls = + 'bg-circle text-green-score rounded-lg px-4 py-2 border border-gold/20 outline-none focus:border-gold/60 focus:ring-1 focus:ring-gold/30 placeholder:text-green-dim/60'; + export default function Auth() { const [mode, setMode] = useState('login'); const [username, setUsername] = useState(localStorage.getItem('bridzik_name') ?? ''); const [code, setCode] = useState(''); + const [showRules, setShowRules] = useState(false); const registration = useGameStore((s) => s.registration); const setRegistration = useGameStore((s) => s.setRegistration); @@ -41,28 +46,28 @@ export default function Auth() { }; return ( -
-

Bridzik

-

- Prihlas sa kodom z aplikacie (napr. Google Authenticator). +

+

Bridžik

+

+ Prihlás sa kódom z aplikácie (napr. Google Authenticator).

-
+
@@ -74,8 +79,8 @@ export default function Auth() { value={username} onChange={(e) => setUsername(e.target.value)} maxLength={40} - placeholder="Pouzivatelske meno" - className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500" + placeholder="Používateľské meno" + className={inputCls} /> setCode(e.target.value.replace(/\D/g, ''))} maxLength={6} - placeholder="6-miestny kod" - className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500 tracking-widest font-mono" + placeholder="6-miestny kód" + className={`${inputCls} tracking-widest font-mono`} /> )} @@ -104,30 +109,30 @@ export default function Auth() { value={username} onChange={(e) => setUsername(e.target.value)} maxLength={40} - placeholder="Zvol si pouzivatelske meno" - className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500" + placeholder="Zvoľ si používateľské meno" + className={inputCls} /> )} {mode === 'register' && registration && (
-

- Naskenuj QR kod do autentifikacnej aplikacie a opis aktualny kod. +

+ Naskenuj QR kód do autentifikačnej aplikácie a opíš aktuálny kód.

-
- Alebo zadaj rucne kluc: - +
+ Alebo zadaj ručne kľúč: + {registration.secret}
@@ -139,19 +144,33 @@ export default function Auth() { value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))} maxLength={6} - placeholder="6-miestny kod" - className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500 tracking-widest font-mono" + placeholder="6-miestny kód" + className={`${inputCls} tracking-widest font-mono`} />
)} + + {/* Rules are reachable before login — quiet link with the velvet divider motif. */} +
+
+ +
+
+ + {showRules && setShowRules(false)} />}
); } diff --git a/frontend/src/pages/GameList.tsx b/frontend/src/pages/GameList.tsx index ea9ade8..85753c2 100644 --- a/frontend/src/pages/GameList.tsx +++ b/frontend/src/pages/GameList.tsx @@ -24,23 +24,23 @@ export default function GameList() { }; return ( -
+
-

Bridzik

+

Bridžik

- {account?.username} - -
{games.length === 0 && ( -

Ziadne hry. Vytvor prvu!

+

Žiadne hry. Vytvor prvú!

)} {games.map((g) => { const full = g.players.length >= 4; @@ -48,24 +48,26 @@ export default function GameList() { !!account && g.players.some((p) => p.player_id === account.player_id); const canResume = g.started && isMember; const unavailable = !canResume && (full || g.started); - const label = canResume ? 'Pokracovat' : full ? 'Plna' : g.started ? 'Zacata' : 'Vstup'; + const label = canResume ? 'Pokračovať' : full ? 'Plná' : g.started ? 'Začatá' : 'Vstúp'; return (
-

{g.name}

-

- {g.players.length}/4 hracov - {g.started ? ' · zacata' : ''} +

{g.name}

+

+ {g.players.length}/4 hráčov + {g.started ? ' · začatá' : ''}

diff --git a/frontend/src/pages/GameOver.tsx b/frontend/src/pages/GameOver.tsx index b90c7b0..fd7bcfd 100644 --- a/frontend/src/pages/GameOver.tsx +++ b/frontend/src/pages/GameOver.tsx @@ -20,25 +20,27 @@ export default function GameOver({ players, standings }: Props) { const medals = ['🥇', '🥈', '🥉', '']; return ( -
-

Koniec hry!

-
+
+

Koniec hry

+
{totals.map((p, i) => (
{medals[i]} - {p.name} + {p.name}
- {p.total} + + {p.total} +
))}
diff --git a/frontend/src/pages/GameTable.tsx b/frontend/src/pages/GameTable.tsx index bcae8c6..8845c6b 100644 --- a/frontend/src/pages/GameTable.tsx +++ b/frontend/src/pages/GameTable.tsx @@ -4,17 +4,26 @@ import { useGameStore } from '../store/gameStore'; import { emit } from '../lib/socket'; import { leaveGame } from '../lib/leaveGame'; import { computePlayable } 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 { StashData } from '../types'; +import type { PlayerInfo, StashData } from '../types'; const TRICK_LINGER_MS = 3000; 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); @@ -33,11 +42,11 @@ export default function GameTable() { return () => { if (lingerTimer.current) clearTimeout(lingerTimer.current); }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [gameStatus?.status.previous_stash?.first_player, JSON.stringify(gameStatus?.status.previous_stash?.cards)]); if (!gameStatus || !myPlayer) { - return

Nacitava sa...

; + return

Načítava sa…

; } const { completed, players, series_number, round_number, cards_in_round, status } = gameStatus; @@ -47,6 +56,7 @@ export default function GameTable() { active_round_stashes, active_stash, standings = [], + standings_guesses = [], } = status; if (completed) { @@ -57,106 +67,280 @@ export default function GameTable() { const isPlayPhase = active_stash !== undefined; const myTurnToPlay = isPlayPhase && active_player === myOrder; - // Show active stash if it has cards; otherwise show the lingered completed trick. const activeCards = active_stash ? Object.keys(active_stash.cards).length : 0; - const displayedStash = activeCards > 0 ? active_stash : lingeredStash ?? undefined; + const displayedStash: StashData | null = + activeCards > 0 && active_stash ? active_stash : lingeredStash ?? null; - // Highlight only cards the engine would accept 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); + + const wonOf = (o?: number) => (o === undefined ? 0 : active_round_stashes?.[o] ?? 0); + const guessOf = (o?: number): number | null => { + if (o === undefined) return null; + const g = active_round_guesses?.[String(o)]; + return g === undefined ? null : g; + }; + const activeOf = (o?: number) => 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 ukoncit celu hru pre vsetkych?')) { + 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; - return ( -
- {/* Header */} -
-
- Seria {series_number} / Kolo {round_number + 1} - ({cards_in_round} kopok) -
-
- {myOrder === 0 && ( - - )} - -
-
+ // ── shared pieces ──────────────────────────────────────────────── + const bannerText = activeOf(myOrder) + ? isPlayPhase + ? 'Zahraj kartu' + : 'Zadaj tip' + : `${activePlayerName} ${isPlayPhase ? 'hrá' : 'tipuje'}`; - {/* Turn indicator */} -
- {active_player === myOrder ? ( - - {!isPlayPhase ? 'Zadaj svoj tip' : 'Zahraj kartu'} - - ) : ( - - Na rade: {activePlayerName} - - )} -
+ const banner = ( +
+ + {bannerText} +
+ ); - {/* Guesses summary (always shown when available) */} - {active_round_guesses && ( -
-

Tipy:

-
- {players.map((p) => { - const guess = active_round_guesses[String(p.order)]; - const wins = active_round_stashes?.[p.order] ?? 0; - return ( - - {p.name}:{' '} - {guess !== undefined ? ( - - {isPlayPhase ? `${wins}/${guess}` : guess} - - ) : ( - ? - )} - - ); - })} + const opponents = players + .filter((p) => p.order !== myOrder) + .sort((a, b) => a.order - b.order); + + const totalsRow = (compact: boolean) => ( +
+ {opponents.map((p) => ( +
+
+ {p.name} +
+
+ {computeTotal(standings, p.order)}
- )} + ))} +
+
+ {myPlayer.name} +
+
+ {computeTotal(standings, myOrder)} +
+
+
+ ); - {/* Active stash (trick) — container always visible during play phase, cards linger 3 s */} - {isPlayPhase && ( - - )} + // Center of the oval: trick during play, guess controls during bidding. + const ovalContent = isPlayPhase ? ( + + ) : ( + active_round_guesses !== undefined && active_player !== undefined ? ( + + ) : null + ); - {/* Guess phase controls */} - {!isPlayPhase && active_round_guesses !== undefined && active_player !== undefined && ( - - )} + const topSeat = ( +
+ + +
+ ); - {/* Hand */} -
-

Tvoje karty

- + const sideSeat = (p?: PlayerInfo) => ( +
+ + +
+ ); + + const meSeat = ( +
+ +
+ ); + + const handArea = ( + + ); + + // ── DESKTOP LAYOUT ─────────────────────────────────────────────── + if (desktop) { + return ( +
+ {/* Design canvas — fixed height, width spans the viewport; scaled as one + unit so the whole board (and header) zooms with the window. */} +
+ {/* main */} +
+ {/* header */} +
+ + Bridžik + +
+ + Séria {series_number + 1} · Kolo {round_number + 1} + +
{banner}
+ {totalsRow(true)} +
+ {canEnd && ( + + )} + +
+ + {/* game content — players hug the edges so the felt uses full width */} +
+ {topSeat} + +
+ {sideSeat(leftP)} +
+ {ovalContent} +
+ {sideSeat(rightP)} +
+ + {meSeat} +
+ + {handArea} +
+ + {/* sidebar */} + +
+
+ ); + } + + // ── MOBILE LAYOUT ──────────────────────────────────────────────── + return ( +
+ {/* header */} +
+
+ + Séria {series_number + 1} · Kolo {round_number + 1} + +
+ {canEnd && ( + + )} + +
+
+ {totalsRow(false)}
- {/* Standings */} - + {/* turn banner */} +
+ {banner} +
+ + {/* game area */} +
+
{topSeat}
+ +
+
{sideSeat(leftP)}
+
+ {ovalContent} +
+
{sideSeat(rightP)}
+
+ +
{meSeat}
+
+ + {handArea} + + {/* score */} +
+ +
); } diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx index 8ac3daf..c7a71a2 100644 --- a/frontend/src/pages/History.tsx +++ b/frontend/src/pages/History.tsx @@ -1,7 +1,9 @@ -import { useEffect } from 'react'; +import { Fragment, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useGameStore } from '../store/gameStore'; -import { emit } from '../lib/socket'; +import { emit, socket } from '../lib/socket'; +import { useIsDesktop } from '../lib/useIsDesktop'; +import type { GameDetail, GameDetailRound } from '../types'; function fmtDate(iso: string | null): string { if (!iso) return '—'; @@ -20,78 +22,271 @@ export default function History() { return () => setGameDetail(null); }, [setGameDetail]); + // After a prematurely-ended game is reopened, the server confirms with + // `game_restored`; jump to the lobby where it now shows as resumable. + useEffect(() => { + const onRestored = () => navigate('/'); + socket.on('game_restored', onRestored); + return () => { + socket.off('game_restored', onRestored); + }; + }, [navigate]); + // --- detail view --- if (detail) { - return ( -
- -

Detail hry

-

{fmtDate(detail.created_at)}

- -
-
- S/K - Hrac - Tip - Body -
- {detail.rounds.map((r, i) => ( -
- - {r.series_number}/{r.round_number} - - {r.username ?? `#${r.player_id}`} - {r.guess} - - {r.points} - -
- ))} -
-
- ); + return setGameDetail(null)} />; } // --- list view --- return ( -
+
-

Moja historia

-
{history.length === 0 && ( -

Zatial ziadne odohrane hry.

+

Zatiaľ žiadne odohrané hry.

)}
{history.map((g) => ( - +
+ + {g.my_points} + b. + + {!g.completed && ( + + )}
-

{g.players.join(', ')}

-

- {g.ended_at ? 'dohrana' : 'nedohrana'} -

- +
))}
); } + +const SEP = '1px solid rgba(201,168,76,.16)'; // vertical divider between players +const DESKTOP_COLS = '28px repeat(8,1fr)'; // index + 4 players × 2 series columns +const MOBILE_COLS = '28px repeat(4,1fr)'; // index + 4 players (one series stacked) + +/** Scoreboard-style detail. Desktop: 4 player columns in the header (total + * beside the name), and under each a pair of series side by side — series 1 & + * 2 on top, 3 & 4 below, separated by a blank row. Mobile: the same 4 player + * columns, but the series stack one below another. Per round a cell shows the + * points (hit bid) or the struck-through bid (missed → 0); each series ends + * with a Σ total. */ +function GameDetailView({ detail, onBack }: { detail: GameDetail; onBack: () => void }) { + const desktop = useIsDesktop(); + const seats = detail.players; // seat order 0..3 + const seatIds = seats.map((p) => p.player_id); + const totals = seatIds.map((pid) => + detail.rounds.reduce((a, r) => (r.player_id === pid ? a + r.points : a), 0), + ); + + // series -> round -> playerId -> round entry + const bySeries = useMemo(() => { + const m = new Map>>(); + for (const r of detail.rounds) { + let rounds = m.get(r.series_number); + if (!rounds) m.set(r.series_number, (rounds = new Map())); + let byPlayer = rounds.get(r.round_number); + if (!byPlayer) rounds.set(r.round_number, (byPlayer = new Map())); + byPlayer.set(r.player_id, r); + } + return m; + }, [detail.rounds]); + const seriesNums = [...bySeries.keys()].sort((a, b) => a - b); + const roundsOf = (s: number | undefined) => + s === undefined ? [] : [...(bySeries.get(s)?.keys() ?? [])]; + + const cellNode = (s: number | undefined, rn: number, seat: number) => { + const r = s === undefined ? undefined : bySeries.get(s)?.get(rn)?.get(seatIds[seat]); + if (!r) return null; + return r.won ? ( + {r.points} + ) : ( + {r.guess} + ); + }; + const seriesTotal = (s: number | undefined, seat: number) => + s === undefined + ? '' + : [...(bySeries.get(s)?.values() ?? [])].reduce( + (a, byP) => a + (byP.get(seatIds[seat])?.points ?? 0), + 0, + ); + + // Header: player names with their grand total beside the name (both layouts). + const header = ( +
+
+ {seats.map((p, c) => ( +
0 ? SEP : undefined, + }} + > + + {p.username} + + + {totals[c]} + +
+ ))} +
+ ); + + // A round row: index column + one cell per (player × series) in `cols`. + const roundRow = (rn: number, cols: (number | undefined)[]) => ( +
+
{rn + 1}
+ {seats.map((_, c) => + cols.map((s, si) => ( +
0 && si === 0 ? SEP : undefined }}> + {cellNode(s, rn, c)} +
+ )), + )} +
+ ); + + // Σ row: per-series totals for each player. + const sigmaRow = (cols: (number | undefined)[]) => ( +
+
Σ
+ {seats.map((_, c) => + cols.map((s, si) => ( +
0 && si === 0 ? SEP : undefined }} + > + {seriesTotal(s, c)} +
+ )), + )} +
+ ); + + // Tiny row telling which series each sub-column is (desktop only — always a + // fixed pair, sB may be absent for a trailing odd series). + const seriesTags = (sA: number, sB: number | undefined) => ( +
+
+ {seats.map((_, c) => ( + +
0 ? SEP : undefined }}> + {sA + 1} +
+
+ {sB !== undefined ? sB + 1 : ''} +
+
+ ))} +
+ ); + + // Desktop: pair series side by side (S1|S2, then S3|S4) with a blank row between. + const desktopBody = (() => { + const blocks: number[][] = []; + for (let i = 0; i < seriesNums.length; i += 2) blocks.push(seriesNums.slice(i, i + 2)); + return blocks.map((block, bi) => { + const [sA, sB] = [block[0], block[1]]; + const roundNums = [...new Set([...roundsOf(sA), ...roundsOf(sB)])].sort((a, b) => a - b); + return ( + + {seriesTags(sA, sB)} + {roundNums.map((rn) => roundRow(rn, [sA, sB]))} + {sigmaRow([sA, sB])} + {bi < blocks.length - 1 &&
} + + ); + }); + })(); + + // Mobile: one series per block, stacked vertically, 4 player columns each. + const mobileBody = seriesNums.map((s, si) => { + const roundNums = roundsOf(s).sort((a, b) => a - b); + return ( + +
+ Séria {s + 1} +
+ {roundNums.map((rn) => roundRow(rn, [s]))} + {sigmaRow([s])} + {si < seriesNums.length - 1 &&
} + + ); + }); + + return ( +
+ +

{detail.name || 'Detail hry'}

+

{fmtDate(detail.created_at)}

+ + {/* Desktop packs 8 columns → allow horizontal scroll on narrow widths; + mobile uses only 4 columns and fits the phone, so no scroll. */} +
+
+ {header} + {desktop ? desktopBody : mobileBody} +
+
+
+ ); +} diff --git a/frontend/src/pages/Lobby.tsx b/frontend/src/pages/Lobby.tsx index 4c0d3c6..16d5e20 100644 --- a/frontend/src/pages/Lobby.tsx +++ b/frontend/src/pages/Lobby.tsx @@ -25,37 +25,37 @@ export default function Lobby() { }; return ( -
+
-

{game?.name ?? 'Hra'}

-
-
+
-

Kod hry

-

{gid}

+

Kód hry

+

{gid}

-
+
{[0, 1, 2, 3].map((order) => { const p = players.find((pl) => pl.order === order); return (
- - {p ? '✓' : '○'} + + {p ? '✦' : '○'} - - {p ? `${p.name}${myPlayer?.order === p.order ? ' (ty)' : ''}` : 'Caka sa...'} + + {p ? `${p.name}${myPlayer?.order === p.order ? ' (ty)' : ''}` : 'Čaká sa…'}
); @@ -65,9 +65,9 @@ export default function Lobby() {
); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a3aacda..527f86a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -32,6 +32,9 @@ export interface GameStatusDetail { active_stash?: StashData; previous_stash?: StashData; standings: number[][][]; + /** Tips per series/round/seat, same shape as standings. Used to show the + * struck-through tip in place of 0 when a tip failed. */ + standings_guesses?: number[][][]; } export interface GameStatusPayload { @@ -70,10 +73,13 @@ export interface Registration { export interface HistoryGame { gid: string; + name: string; created_at: string | null; ended_at: string | null; players: string[]; my_points: number; + /** True = dohraná naplno; false = predčasne ukončená (dá sa obnoviť do lobby). */ + completed: boolean; } export interface GameDetailRound { @@ -88,6 +94,7 @@ export interface GameDetailRound { export interface GameDetail { gid: string; + name: string; created_at: string | null; ended_at: string | null; players: { player_id: number; username: string }[]; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 07e9a2e..329735c 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,6 +1,63 @@ /** @type {import('tailwindcss').Config} */ export default { content: ['./index.html', './src/**/*.{ts,tsx}'], - theme: { extend: {} }, + theme: { + extend: { + colors: { + // Velvet table palette (design handoff: "01 — Sametový stôl") + table: '#090e0b', + header: '#070c09', + circle: '#0c1a0f', + 'circle-active': '#0e2015', + gold: '#c9a84c', + 'gold-bright': '#f0d060', + 'gold-dim': '#e8c14a', + // Secondary text is warm cream (not green) for legibility on the dark + // table — the green is reserved for structure (felt, opponent cards). + 'green-dim': '#9c906c', + 'green-score': '#d8cba6', + 'green-circle': '#c2b58c', + }, + fontFamily: { + serif: ['"Playfair Display"', 'Georgia', 'serif'], + sans: ['"DM Sans"', 'system-ui', 'sans-serif'], + }, + keyframes: { + // turn-pulse — blinking dot / placeholder slot + tp: { '0%,100%': { opacity: '1' }, '50%': { opacity: '.5' } }, + // card-in — card lands on the table + ci: { + from: { opacity: '0', transform: 'translateY(-6px) scale(.9)' }, + to: { opacity: '1', transform: 'none' }, + }, + // active-ring — glowing gold ring around the active player circle + ar: { + '0%,100%': { + boxShadow: + '0 0 0 3px rgba(201,168,76,.18),0 0 18px rgba(201,168,76,.5),0 0 42px rgba(201,168,76,.2)', + }, + '50%': { + boxShadow: + '0 0 0 5px rgba(201,168,76,.34),0 0 32px rgba(201,168,76,.85),0 0 56px rgba(201,168,76,.3)', + }, + }, + // glow-1 — gold glow border on a playable card + g1: { + '0%,100%': { + boxShadow: '0 0 18px rgba(201,168,76,.55),0 6px 18px rgba(0,0,0,.55)', + }, + '50%': { + boxShadow: '0 0 34px rgba(201,168,76,.85),0 6px 18px rgba(0,0,0,.55)', + }, + }, + }, + animation: { + tp: 'tp 1.8s ease-in-out infinite', + ci: 'ci .3s ease both', + ar: 'ar 2.2s ease-in-out infinite', + g1: 'g1 2.2s ease-in-out infinite', + }, + }, + }, plugins: [], }; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ba68e27..2790480 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -25,6 +25,13 @@ export default defineConfig({ ], server: { host: true, + // Docker bind mounts on Windows/macOS don't forward native FS events into the + // container, so Vite's watcher never fires and HMR appears "stuck". Polling the + // mounted files makes hot reload work without restarting the container. + watch: { + usePolling: true, + interval: 200, + }, proxy: { '/socket.io': { // Local dev defaults to localhost; docker-compose sets VITE_BACKEND_URL diff --git a/tests_history.py b/tests_history.py index 53d408b..20f55b5 100644 --- a/tests_history.py +++ b/tests_history.py @@ -143,15 +143,41 @@ class HistoryCase(unittest.TestCase): run(history.mark_game_ended(gid)) self.assertFalse(any(g["gid"] == gid for g in run(history.get_unfinished_games()))) + def test_reopen_prematurely_ended_game(self): + ids = self._make_players() + gid = str(uuid.uuid4()) + run(history.record_game_started(gid, "Vzdana", ids)) + run(history.record_completed_rounds(gid, make_core(completed=False))) + run(history.mark_game_ended(gid)) + + # V historii sa ukazuje ako predcasne ukoncena (nie naplno dohrana). + rows = run(history.get_player_history(ids[0])) + mine = next(g for g in rows if g["gid"] == gid) + self.assertFalse(mine["completed"]) + + # Cudzi hrac ju obnovit nemoze. + outsider = self._make_players(1)[0] + self.assertIsNone(run(history.reopen_game(gid, outsider))) + + # Clen ju obnovi -> ended_at sa zmaze a hra je zas medzi nedohratymi. + info = run(history.reopen_game(gid, ids[0])) + self.assertIsNotNone(info) + self.assertEqual(len(info["seats"]), 4) + self.assertTrue(any(g["gid"] == gid for g in run(history.get_unfinished_games()))) + # A teda uz nie je v historii (zobrazuju sa iba ukoncene hry). + self.assertFalse(any(g["gid"] == gid for g in run(history.get_player_history(ids[0])))) + def test_standings_from_db(self): ids = self._make_players() gid = str(uuid.uuid4()) run(history.record_game_started(gid, "Test", ids)) run(history.record_completed_rounds(gid, make_core())) - standings = run(history.get_standings(gid)) - # 1 seria, 1 kolo, body podla sedadiel zo stubu [12, 0, 10, 11]. + standings, guesses = run(history.get_standings(gid)) + # 1 seria, 1 kolo. Body podla sedadiel zo stubu [12, 0, 10, 11], + # tipy zo stubu {0: 2, 1: 1, 2: 0, 3: 1}. self.assertEqual(standings, [[[12, 0, 10, 11]]]) + self.assertEqual(guesses, [[[2, 1, 0, 1]]]) def test_player_history(self): ids = self._make_players()