Apply velvet-table redesign, fix game lifecycle and history bugs
Frontend: - Dark green/gold "velvet table" visual redesign across the whole app (Auth, Lobby, GameList, GameTable, History, GameOver, modals), with Playfair Display/DM Sans typography and a centralized Tailwind palette. - Desktop game table fit-scales to fill the window; mobile gets overlapping hand/trick layouts and larger touch-friendly cards. - Standings sidebar now groups completed rounds by series with a per-series subtotal row, struck-through tips on missed bids. - History page rewritten into a scoreboard-style detail view (player totals beside names, series grouped 2-up on desktop / stacked on mobile) and gained game names, completed/abandoned status, and a button to reopen a prematurely-ended game back into the lobby. Backend: - Fix started games being deleted from memory (and vanishing from everyone's lobby) when all players disconnect; only `end_game` tears down a started game now. - Fix a crash writing a timezone-aware datetime into the naive `ended_at` Postgres column. - Add `reopen_game`/`restore_game` to un-end a prematurely-ended game from history and resume it from the lobby. - Let any seated player end an abandoned game once the host is offline, not just the host, so the game isn't stuck forever. - Expose SERIES_PER_GAME/ROUNDS_PER_SERIES as named constants on the engine so the persistence layer derives game-completion rules from bridzik.py instead of re-encoding them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+45
-15
@@ -146,7 +146,7 @@ async def send_game_status(gid: str):
|
|||||||
status = json.loads(json.dumps(core.get_status(), cls=CardStatusEncoder))
|
status = json.loads(json.dumps(core.get_status(), cls=CardStatusEncoder))
|
||||||
# Use DB-backed standings so the score is correct even after a server restart
|
# Use DB-backed standings so the score is correct even after a server restart
|
||||||
# (the engine only knows rounds completed since 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(
|
await sio.emit(
|
||||||
"game_status",
|
"game_status",
|
||||||
{
|
{
|
||||||
@@ -180,9 +180,11 @@ async def send_error(sid: str, message: str):
|
|||||||
|
|
||||||
|
|
||||||
async def _mark_player_offline(game: "Game", player: "Player"):
|
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
|
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]
|
del games[game.gid]
|
||||||
else:
|
else:
|
||||||
await sio.emit(
|
await sio.emit(
|
||||||
@@ -212,14 +214,21 @@ async def _restore_unfinished_games():
|
|||||||
for info in await history.get_unfinished_games():
|
for info in await history.get_unfinished_games():
|
||||||
if info["gid"] in games:
|
if info["gid"] in games:
|
||||||
continue
|
continue
|
||||||
game = Game(info["gid"], info["name"])
|
_load_game_into_memory(info)
|
||||||
game.bridzik_core = info["core"]
|
|
||||||
game.started = True
|
|
||||||
for seat, (pid, uname) in enumerate(info["seats"]):
|
def _load_game_into_memory(info: dict) -> "Game":
|
||||||
player = Player(None, uname, seat, pid)
|
"""Postav in-memory Game z restore-info (gid/name/seats/core), hraci offline,
|
||||||
player.connected = False
|
a vlozi ju do `games`. Pouzite pri starte aj pri obnove hry z historie."""
|
||||||
game.players.append(player)
|
game = Game(info["gid"], info["name"])
|
||||||
games[info["gid"]] = game
|
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 -------------------------------------------------
|
# --- connection lifecycle -------------------------------------------------
|
||||||
@@ -382,13 +391,13 @@ async def start_game(sid, gid):
|
|||||||
|
|
||||||
@sio.on("end_game")
|
@sio.on("end_game")
|
||||||
async def end_game(sid, gid):
|
async def end_game(sid, gid):
|
||||||
"""Host (seat 0) permanently ends a game that won't be finished. Marks it
|
"""Any seated player can permanently end a game that won't be finished --
|
||||||
ended in the DB (so it won't be restored) and sends everyone back to the lobby."""
|
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)
|
sess = sessions.get(sid)
|
||||||
if sess is None or sess["gid"] != gid:
|
if sess is None or sess["gid"] != gid:
|
||||||
return await send_error(sid, "Nie ste v tejto hre.")
|
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)
|
game = games.get(gid)
|
||||||
if game is None:
|
if game is None:
|
||||||
return await send_error(sid, "Hra neexistuje.")
|
return await send_error(sid, "Hra neexistuje.")
|
||||||
@@ -471,6 +480,27 @@ async def rejoin_game(sid, gid):
|
|||||||
await broadcast_lobby()
|
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) -
|
# --- in-game actions (seat derived from the connection, never the client) -
|
||||||
|
|
||||||
@sio.on("game_status")
|
@sio.on("game_status")
|
||||||
|
|||||||
+98
-33
@@ -9,10 +9,20 @@ from random import shuffle
|
|||||||
|
|
||||||
from sqlalchemy import func, or_, select
|
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.db import async_session
|
||||||
from db.models import Game, Guess, Player
|
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:
|
async def record_game_started(gid: str, name: str, player_ids: list[int]) -> None:
|
||||||
"""Zapise riadok Game so 4 ID hracov (podla sedadla). Idempotentne."""
|
"""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
|
game.round = last_series.get_last_round().round_number
|
||||||
|
|
||||||
if core.is_completed() and game.ended_at is None:
|
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()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def get_standings(gid: str) -> list[list[list[int]]]:
|
async def get_standings(gid: str) -> tuple[list[list[list[int]]], list[list[list[int]]]]:
|
||||||
"""Body po seriach/kolach z DB v tvare, ktory caka frontend: standings[serie][kolo]
|
"""Body aj tipy po seriach/kolach z DB, oboje v tvare ktory caka frontend:
|
||||||
= [body sedadiel 0..3]. Pouziva sa v game_status, aby skore sedelo aj po restarte.
|
`[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:
|
async with async_session() as session:
|
||||||
game = await session.get(Game, gid)
|
game = await session.get(Game, gid)
|
||||||
if game is None:
|
if game is None:
|
||||||
return []
|
return [], []
|
||||||
seat_of = {
|
seat_of = {
|
||||||
game.player0_id: 0,
|
game.player0_id: 0,
|
||||||
game.player1_id: 1,
|
game.player1_id: 1,
|
||||||
@@ -102,18 +114,23 @@ async def get_standings(gid: str) -> list[list[list[int]]]:
|
|||||||
)
|
)
|
||||||
).all()
|
).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:
|
for gz in rows:
|
||||||
rounds = series_map.setdefault(gz.series_number, {})
|
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)
|
seat = seat_of.get(gz.player_id)
|
||||||
if seat is not None:
|
if seat is not None:
|
||||||
points[seat] = gz.points
|
points[seat] = gz.points
|
||||||
|
tips[seat] = gz.guess
|
||||||
|
|
||||||
return [
|
points_table: list[list[list[int]]] = []
|
||||||
[series_map[s][r] for r in sorted(series_map[s])]
|
guesses_table: list[list[list[int]]] = []
|
||||||
for s in sorted(series_map)
|
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]:
|
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 = (
|
stmt = (
|
||||||
select(Game)
|
select(Game)
|
||||||
.where(
|
.where(
|
||||||
|
Game.ended_at.is_not(None), # iba ukoncene hry
|
||||||
or_(
|
or_(
|
||||||
Game.player0_id == player_id,
|
Game.player0_id == player_id,
|
||||||
Game.player1_id == player_id,
|
Game.player1_id == player_id,
|
||||||
Game.player2_id == player_id,
|
Game.player2_id == player_id,
|
||||||
Game.player3_id == player_id,
|
Game.player3_id == player_id,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
.order_by(Game.created_at.desc())
|
.order_by(Game.created_at.desc())
|
||||||
)
|
)
|
||||||
games = (await session.scalars(stmt)).all()
|
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 = []
|
result = []
|
||||||
for g in games:
|
for g in games:
|
||||||
seat_ids = [g.player0_id, g.player1_id, g.player2_id, g.player3_id]
|
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(
|
result.append(
|
||||||
{
|
{
|
||||||
"gid": g.id,
|
"gid": g.id,
|
||||||
|
"name": g.name,
|
||||||
"created_at": g.created_at.isoformat() if g.created_at else None,
|
"created_at": g.created_at.isoformat() if g.created_at else None,
|
||||||
"ended_at": g.ended_at.isoformat() if g.ended_at else None,
|
"ended_at": g.ended_at.isoformat() if g.ended_at else None,
|
||||||
"players": [usernames[pid] for pid in seat_ids],
|
"players": [usernames[pid] for pid in seat_ids],
|
||||||
"my_points": int(total or 0),
|
"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
|
return result
|
||||||
@@ -173,6 +200,7 @@ async def get_game_detail(gid: str) -> dict | None:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"gid": game.id,
|
"gid": game.id,
|
||||||
|
"name": game.name,
|
||||||
"created_at": game.created_at.isoformat() if game.created_at else None,
|
"created_at": game.created_at.isoformat() if game.created_at else None,
|
||||||
"ended_at": game.ended_at.isoformat() if game.ended_at else None,
|
"ended_at": game.ended_at.isoformat() if game.ended_at else None,
|
||||||
"players": [
|
"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}
|
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 ---------------------------------------------------------------
|
# --- restore ---------------------------------------------------------------
|
||||||
|
|
||||||
def rebuild_core(series_number: int, round_number: int, shuffler=shuffle) -> Bridzik:
|
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:
|
async with async_session() as session:
|
||||||
game = await session.get(Game, gid)
|
game = await session.get(Game, gid)
|
||||||
if game is not None and game.ended_at is None:
|
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()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def get_unfinished_games() -> list[dict]:
|
async def get_unfinished_games() -> list[dict]:
|
||||||
"""Nedohrate hry (ended_at IS NULL) aj s obnovenym jadrom -- na obnovu pri starte.
|
"""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.
|
|
||||||
"""
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
games = (
|
games = (
|
||||||
await session.scalars(select(Game).where(Game.ended_at.is_(None)))
|
await session.scalars(select(Game).where(Game.ended_at.is_(None)))
|
||||||
).all()
|
).all()
|
||||||
|
return [await _restore_info(session, g) for g in games]
|
||||||
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
|
|
||||||
|
|||||||
+10
-3
@@ -82,6 +82,13 @@ class Card():
|
|||||||
cards = [Card(color, value) for value in Card_values for color in Card_colors]
|
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():
|
class Bridzik():
|
||||||
def __init__(self, shuffler=shuffle):
|
def __init__(self, shuffler=shuffle):
|
||||||
self.shuffler = shuffler
|
self.shuffler = shuffler
|
||||||
@@ -124,7 +131,7 @@ class Bridzik():
|
|||||||
return status
|
return status
|
||||||
|
|
||||||
def is_completed(self):
|
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):
|
def get_previous_stash(self):
|
||||||
if len(self.series[-1].get_last_round().stashes) > 1:
|
if len(self.series[-1].get_last_round().stashes) > 1:
|
||||||
@@ -169,7 +176,7 @@ class Series():
|
|||||||
self.start_new_round()
|
self.start_new_round()
|
||||||
|
|
||||||
def is_completed(self):
|
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):
|
def get_standings(self):
|
||||||
return [r.get_points_summary() for r in self.rounds if r.is_completed()]
|
return [r.get_points_summary() for r in self.rounds if r.is_completed()]
|
||||||
@@ -191,7 +198,7 @@ class Series():
|
|||||||
class Round():
|
class Round():
|
||||||
def __init__(self, round_number: int, first_player: int, cards: []=cards, shuffler=shuffle):
|
def __init__(self, round_number: int, first_player: int, cards: []=cards, shuffler=shuffle):
|
||||||
# vyrob kopku pre toto kolo a priprav prazdne objekty
|
# 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.')
|
raise BridzikException('Neplatne cislo kola.')
|
||||||
if first_player not in [0, 1, 2, 3]:
|
if first_player not in [0, 1, 2, 3]:
|
||||||
raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.')
|
raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.')
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:18-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: bridzik
|
POSTGRES_USER: bridzik
|
||||||
POSTGRES_PASSWORD: bridzik
|
POSTGRES_PASSWORD: bridzik
|
||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U bridzik -d bridzik"]
|
test: ["CMD-SHELL", "pg_isready -U bridzik -d bridzik"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
|
|||||||
+8
-1
@@ -3,7 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Bridzik</title>
|
<meta name="theme-color" content="#090e0b" />
|
||||||
|
<title>Bridžik</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;1,400&family=DM+Sans:wght@300;400;500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -19,61 +19,66 @@ const VALUE_LABEL: Record<string, string> = {
|
|||||||
LOWER: 'J', UPPER: 'Q', KING: 'K', ACE: 'A',
|
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 {
|
interface Props {
|
||||||
card: Card;
|
card: Card;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
selected?: boolean;
|
/** Playable card on your turn — gold glow border + lift. */
|
||||||
size?: 'sm' | 'md' | 'lg';
|
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 symbol = SUIT_SYMBOL[card.color];
|
||||||
const color = SUIT_COLOR[card.color];
|
const color = SUIT_COLOR[card.color];
|
||||||
const label = VALUE_LABEL[card.value];
|
const label = VALUE_LABEL[card.value];
|
||||||
|
const d = DIMS[size];
|
||||||
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 interactive = !disabled && !!onClick;
|
const interactive = !disabled && !!onClick;
|
||||||
|
|
||||||
|
const corner = (rotated: boolean) => (
|
||||||
|
<span
|
||||||
|
className="absolute flex flex-col items-center leading-none"
|
||||||
|
style={
|
||||||
|
rotated
|
||||||
|
? { bottom: d.inset, right: d.inset, transform: 'rotate(180deg)' }
|
||||||
|
: { top: d.inset, left: d.inset }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span style={{ color, fontSize: d.label, fontFamily: 'Georgia,serif', fontWeight: 700, lineHeight: 1 }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span style={{ color, fontSize: d.suitSm, lineHeight: 1.2 }}>{symbol}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled || !onClick}
|
||||||
|
style={{ width: d.w, height: d.h, borderRadius: d.radius }}
|
||||||
className={[
|
className={[
|
||||||
dims.cls,
|
'relative bg-white overflow-hidden flex-none transition-transform',
|
||||||
'relative bg-white border rounded-md shadow-sm transition-transform overflow-hidden',
|
highlight
|
||||||
selected && !disabled ? 'border-yellow-400 -translate-y-2' : 'border-gray-300',
|
? 'border-2 border-gold animate-g1 -translate-y-2'
|
||||||
disabled ? 'opacity-50 cursor-default' : '',
|
: 'border border-[#ddd8d0] shadow-[0_2px_8px_rgba(0,0,0,.35)]',
|
||||||
interactive ? 'hover:-translate-y-1 cursor-pointer active:scale-95' : 'cursor-default',
|
disabled && !highlight ? 'opacity-[.35]' : '',
|
||||||
|
interactive ? 'cursor-pointer hover:-translate-y-1 active:scale-95' : 'cursor-default',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<span
|
{corner(false)}
|
||||||
className="absolute pointer-events-none rounded"
|
|
||||||
style={{ inset: dims.inset, border: '0.5px solid #f0ece0' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span className="absolute top-1 left-1 flex flex-col items-center" style={{ gap: 1 }}>
|
|
||||||
<span style={{ color, fontSize: dims.label, fontFamily: 'Georgia,serif', fontWeight: 700, lineHeight: 1 }}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span style={{ color, fontSize: dims.iconSm, lineHeight: 1 }}>{symbol}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="absolute inset-0 flex items-center justify-center">
|
<span className="absolute inset-0 flex items-center justify-center">
|
||||||
<span style={{ color, fontSize: dims.iconLg, lineHeight: 1 }}>{symbol}</span>
|
<span style={{ color, fontSize: d.suitLg, lineHeight: 1 }}>{symbol}</span>
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="absolute bottom-1 right-1 flex flex-col items-center rotate-180" style={{ gap: 1 }}>
|
|
||||||
<span style={{ color, fontSize: dims.label, fontFamily: 'Georgia,serif', fontWeight: 700, lineHeight: 1 }}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span style={{ color, fontSize: dims.iconSm, lineHeight: 1 }}>{symbol}</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
{corner(true)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex" style={{ flexDirection: row ? 'row' : 'column' }}>
|
||||||
|
{Array.from({ length: n }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
marginLeft: row && 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)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,8 +17,9 @@ export default function GuessControls({ cardsInRound, guesses, myOrder, activePl
|
|||||||
|
|
||||||
if (!isMyTurn) {
|
if (!isMyTurn) {
|
||||||
return (
|
return (
|
||||||
<p className="text-gray-400 text-sm text-center py-2">
|
<p className="text-center text-sm text-green-dim py-3">
|
||||||
Caka sa na tip: <span className="text-white font-semibold">{activePlayerName}</span>
|
Čaká sa na tip:{' '}
|
||||||
|
<span className="font-serif text-gold">{activePlayerName}</span>
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -26,8 +27,8 @@ export default function GuessControls({ cardsInRound, guesses, myOrder, activePl
|
|||||||
const options = Array.from({ length: cardsInRound + 1 }, (_, i) => i);
|
const options = Array.from({ length: cardsInRound + 1 }, (_, i) => i);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-2 py-2">
|
<div className="flex flex-col items-center gap-3 py-3">
|
||||||
<p className="text-sm text-gray-300">Tvoj tip (pocet kopok):</p>
|
<p className="font-serif italic text-gold-dim text-[14px]">Zadaj svoj tip (počet kopiek)</p>
|
||||||
<div className="flex flex-wrap gap-2 justify-center">
|
<div className="flex flex-wrap gap-2 justify-center">
|
||||||
{options.map((n) => {
|
{options.map((n) => {
|
||||||
const isForbidden = n === forbidden;
|
const isForbidden = n === forbidden;
|
||||||
@@ -36,12 +37,12 @@ export default function GuessControls({ cardsInRound, guesses, myOrder, activePl
|
|||||||
key={n}
|
key={n}
|
||||||
disabled={isForbidden}
|
disabled={isForbidden}
|
||||||
onClick={() => emit.addGuess(n)}
|
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={[
|
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
|
isForbidden
|
||||||
? 'border-red-700 text-red-700 opacity-40 cursor-not-allowed'
|
? 'border-[#5a2a2a] text-[#7a4040] opacity-40 cursor-not-allowed'
|
||||||
: 'border-blue-400 text-blue-200 hover:bg-blue-600 hover:border-blue-600 active:scale-95',
|
: 'border-gold/50 text-gold hover:bg-gold hover:text-table hover:border-gold active:scale-95',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{n}
|
{n}
|
||||||
|
|||||||
@@ -21,30 +21,83 @@ interface Props {
|
|||||||
myTurn: boolean;
|
myTurn: boolean;
|
||||||
isPlayPhase: boolean;
|
isPlayPhase: boolean;
|
||||||
playableKeys?: Set<string>;
|
playableKeys?: Set<string>;
|
||||||
|
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);
|
const groups = groupedByColor(hand);
|
||||||
|
|
||||||
if (groups.length === 0) return null;
|
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 (
|
return (
|
||||||
<div className="flex flex-wrap gap-3 justify-center py-2">
|
<div className="bg-header border-t border-[#111a13] px-4 pt-3 pb-7">
|
||||||
{groups.map(({ color, keys }) => (
|
<div className="flex items-center justify-center gap-2 mb-3">
|
||||||
<div key={color} className="flex gap-1">
|
<div className="h-px flex-1 max-w-[80px] bg-gradient-to-r from-transparent to-gold/20" />
|
||||||
{keys.map((key) => {
|
<span className="uppercase tracking-[.13em] text-[9px] text-green-dim">Tvoje karty</span>
|
||||||
// During guessing phase cards are visible at full opacity (just not clickable).
|
<div className="h-px flex-1 max-w-[80px] bg-gradient-to-l from-transparent to-gold/20" />
|
||||||
// Only dim cards during the play phase when they can't be played.
|
</div>
|
||||||
const disabled = isPlayPhase && (!myTurn || (playableKeys !== undefined && !playableKeys.has(key)));
|
|
||||||
return (
|
{desktop ? (
|
||||||
<CardView
|
// Desktop has room — keep cards grouped by suit, wrap if needed.
|
||||||
key={key}
|
<div className="flex flex-wrap gap-3 justify-center items-end">
|
||||||
card={hand[key]}
|
{groups.map(({ color, keys }) => (
|
||||||
disabled={disabled}
|
<div key={color} className="flex gap-1 items-end">
|
||||||
onClick={() => emit.playCard(key)}
|
{keys.map((key) => (
|
||||||
/>
|
<CardView key={key} size="xl" {...cardProps(key)} />
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MobileHand groups={groups} cardProps={cardProps} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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; <CARD_W means they overlap.
|
||||||
|
const step = n > 1 ? Math.min(CARD_W + 6, (MAX_ROW - CARD_W) / (n - 1)) : 0;
|
||||||
|
const margin = step - CARD_W; // negative → overlap, positive → gap
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-end">
|
||||||
|
{keys.map((key, i) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="relative"
|
||||||
|
// Playable cards lift up — keep them above their neighbours.
|
||||||
|
style={{ marginLeft: i === 0 ? 0 : margin, zIndex: cardProps(key).highlight ? 100 + i : i }}
|
||||||
|
>
|
||||||
|
<CardView size="lg" {...cardProps(key)} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ export default function NameModal({ onClose }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-40 p-4">
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-40 p-4">
|
||||||
<div className="bg-slate-800 rounded-2xl p-6 w-full max-w-sm shadow-xl">
|
<div className="bg-header border border-[#142018] rounded-2xl p-6 w-full max-w-sm shadow-[0_28px_88px_rgba(0,0,0,.65)]">
|
||||||
<h2 className="text-lg font-bold mb-4 text-white">Nazov hry</h2>
|
<h2 className="font-serif text-xl mb-4 text-gold">Názov hry</h2>
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -36,23 +36,23 @@ export default function NameModal({ onClose }: Props) {
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
placeholder="Napr. Vecerna partia"
|
placeholder="Napr. Večerná partia"
|
||||||
className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500"
|
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"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 rounded-lg text-gray-400 hover:text-white"
|
className="px-4 py-2 rounded-lg text-green-dim hover:text-gold"
|
||||||
>
|
>
|
||||||
Zrusit
|
Zrušiť
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!name.trim()}
|
disabled={!name.trim()}
|
||||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-semibold disabled:opacity-40"
|
className="px-4 py-2 rounded-lg bg-gold text-table font-serif font-semibold disabled:opacity-40 hover:bg-gold-bright transition-colors"
|
||||||
>
|
>
|
||||||
Vytvorit
|
Vytvoriť
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
// Only the active player is highlighted (gold ring + glow) — colors come
|
||||||
|
// from the velvet-table palette tokens (tailwind.config.js), not literals.
|
||||||
|
className={`flex flex-col items-center justify-center rounded-[50%] ${
|
||||||
|
active ? 'bg-circle-active border-2 border-gold' : 'bg-circle border-[1.5px] border-gold/20'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height,
|
||||||
|
boxShadow: '0 2px 10px rgba(0,0,0,.45)',
|
||||||
|
animation: active ? 'ar 2.2s ease-in-out infinite' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`uppercase leading-tight text-center ${active ? 'text-gold' : 'text-green-circle'}`}
|
||||||
|
style={{
|
||||||
|
fontFamily: '"DM Sans",sans-serif',
|
||||||
|
fontSize: nameFont,
|
||||||
|
letterSpacing: '.09em',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`leading-none ${active ? 'text-gold-bright' : 'text-gold'}`}
|
||||||
|
style={{
|
||||||
|
fontFamily: '"Playfair Display",serif',
|
||||||
|
fontSize: valueFont,
|
||||||
|
fontWeight: active ? 700 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{won}
|
||||||
|
<span style={{ fontSize: valueFont * 0.6, color: '#b0a585' }}>/{guess ?? '?'}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,17 +9,17 @@ export default function RulesModal({ onClose }: Props) {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative bg-slate-900 rounded-2xl w-full max-w-lg my-6 p-6 text-sm leading-relaxed"
|
className="relative bg-header border border-[#142018] rounded-2xl w-full max-w-lg my-6 p-6 text-sm leading-relaxed text-green-score shadow-[0_28px_88px_rgba(0,0,0,.65)]"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute top-4 right-4 text-gray-400 hover:text-white text-xl leading-none"
|
className="absolute top-4 right-4 text-green-dim hover:text-gold text-xl leading-none"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 className="text-xl font-bold mb-4">Pravidlá hry Bridžik</h1>
|
<h1 className="font-serif text-2xl text-gold mb-4">Pravidlá hry Bridžik</h1>
|
||||||
|
|
||||||
<Section title="Karty">
|
<Section title="Karty">
|
||||||
<p>Hrá sa s <b>32-kartovým balíčkom</b> sedmových (slovenských/nemeckých) kariet.</p>
|
<p>Hrá sa s <b>32-kartovým balíčkom</b> sedmových (slovenských/nemeckých) kariet.</p>
|
||||||
@@ -37,7 +37,7 @@ export default function RulesModal({ onClose }: Props) {
|
|||||||
<Section title="Priebeh kola">
|
<Section title="Priebeh kola">
|
||||||
<p className="font-semibold">1. Tipovanie</p>
|
<p className="font-semibold">1. Tipovanie</p>
|
||||||
<p className="mt-1">Každý hráč tipuje, koľko kopiek v kole získa (0 až počet kopiek).</p>
|
<p className="mt-1">Každý hráč tipuje, koľko kopiek v kole získa (0 až počet kopiek).</p>
|
||||||
<p className="mt-1 text-yellow-300">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.</p>
|
<p className="mt-1 text-gold-dim">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.</p>
|
||||||
|
|
||||||
<p className="font-semibold mt-3">2. Hranie kariet</p>
|
<p className="font-semibold mt-3">2. Hranie kariet</p>
|
||||||
<p className="mt-1">Prvú kopku otvára hráč s <b>najvyšším tipom</b>. Každú ďalšiu otvára víťaz predchádzajúcej kopky.</p>
|
<p className="mt-1">Prvú kopku otvára hráč s <b>najvyšším tipom</b>. Každú ďalšiu otvára víťaz predchádzajúcej kopky.</p>
|
||||||
@@ -58,13 +58,13 @@ export default function RulesModal({ onClose }: Props) {
|
|||||||
|
|
||||||
<Section title="Bodovanie">
|
<Section title="Bodovanie">
|
||||||
<p>Po každom kole: ak sa tip <b>presne zhoduje</b> s počtom získaných kopiek → <b>10 + tip</b> bodov, inak <b>0</b>.</p>
|
<p>Po každom kole: ak sa tip <b>presne zhoduje</b> s počtom získaných kopiek → <b>10 + tip</b> bodov, inak <b>0</b>.</p>
|
||||||
<p className="mt-1 text-gray-400">Príklad: tipoval 3, získal 3 → 13 bodov. Tipoval 3, získal 2 → 0 bodov.</p>
|
<p className="mt-1 text-green-dim">Príklad: tipoval 3, získal 3 → 13 bodov. Tipoval 3, získal 2 → 0 bodov.</p>
|
||||||
<p className="mt-2">Vyhráva hráč s najvyšším celkovým súčtom po 4 sériách.</p>
|
<p className="mt-2">Vyhráva hráč s najvyšším celkovým súčtom po 4 sériách.</p>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="mt-4 w-full py-2.5 rounded-xl bg-slate-700 hover:bg-slate-600 font-semibold"
|
className="mt-4 w-full py-2.5 rounded-xl border border-gold/30 text-gold hover:bg-gold hover:text-table font-serif font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
Zavrieť
|
Zavrieť
|
||||||
</button>
|
</button>
|
||||||
@@ -76,8 +76,8 @@ export default function RulesModal({ onClose }: Props) {
|
|||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h2 className="font-bold text-base text-green-400 mb-1">{title}</h2>
|
<h2 className="font-serif text-base text-gold mb-1">{title}</h2>
|
||||||
<div className="text-gray-200">{children}</div>
|
<div className="text-green-score">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,53 +4,192 @@ import { computeTotal } from '../lib/standings';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
standings: number[][][];
|
standings: number[][][];
|
||||||
|
/** Tips per series/round/seat, same shape as standings. */
|
||||||
|
guesses?: number[][][];
|
||||||
players: PlayerInfo[];
|
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 [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const sorted = [...players]
|
// Player columns in seat order; the local player's column is highlighted.
|
||||||
.map((p) => ({ ...p, total: computeTotal(standings, p.order) }))
|
const cols = [...players].sort((a, b) => a.order - b.order);
|
||||||
.sort((a, b) => b.total - a.total);
|
// 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 = (
|
||||||
|
<div className="flex-1 flex flex-col px-3 pt-3 pb-4">
|
||||||
|
{/* Column headers */}
|
||||||
|
<div
|
||||||
|
className="grid items-end mb-1"
|
||||||
|
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
{cols.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.order}
|
||||||
|
className={`text-center uppercase tracking-[.09em] truncate ${
|
||||||
|
p.order === myOrder ? 'text-gold' : 'text-green-dim'
|
||||||
|
}`}
|
||||||
|
style={{ fontSize: fz.head }}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-gold/10 mb-1" />
|
||||||
|
|
||||||
|
{/* 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) => (
|
||||||
|
<div
|
||||||
|
key={`r-${si}-${lri}`}
|
||||||
|
className="grid items-center py-1 border-b border-gold/[.05]"
|
||||||
|
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
|
||||||
|
>
|
||||||
|
<div className="text-center text-[#7a7252]" style={{ fontSize: fz.idx }}>
|
||||||
|
{priorRounds + lri + 1}
|
||||||
|
</div>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={p.order}
|
||||||
|
className="text-center font-serif leading-none line-through"
|
||||||
|
style={{ fontSize: fz.cell, color: '#7a6e4a' }}
|
||||||
|
>
|
||||||
|
{guesses[si]?.[lri]?.[p.order] ?? 0}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.order}
|
||||||
|
className="text-center font-serif leading-none"
|
||||||
|
style={{ fontSize: fz.cell, color: p.order === myOrder ? '#f0dca8' : '#c8bb95' }}
|
||||||
|
>
|
||||||
|
{points}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
// After a finished series, sum its points per player.
|
||||||
|
if (seriesRounds.length === ROUNDS_PER_SERIES) {
|
||||||
|
elems.push(
|
||||||
|
<div
|
||||||
|
key={`s-${si}`}
|
||||||
|
className="grid items-center py-1 my-0.5 rounded bg-gold/[.07]"
|
||||||
|
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
|
||||||
|
>
|
||||||
|
<div className="text-center font-serif text-gold" style={{ fontSize: fz.sigma }}>
|
||||||
|
Σ{si + 1}
|
||||||
|
</div>
|
||||||
|
{cols.map((p) => {
|
||||||
|
const sum = seriesRounds.reduce((a, r) => a + (r[p.order] ?? 0), 0);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.order}
|
||||||
|
className={`text-center font-serif leading-none ${
|
||||||
|
p.order === myOrder ? 'text-gold-dim' : 'text-green-score'
|
||||||
|
}`}
|
||||||
|
style={{ fontSize: fz.cell, fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{sum}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return elems;
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Active round placeholder */}
|
||||||
|
<div
|
||||||
|
className="grid items-center py-1 rounded mt-0.5 bg-gold/[.04]"
|
||||||
|
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
|
||||||
|
>
|
||||||
|
<div className="text-center font-medium text-gold" style={{ fontSize: fz.idx }}>{completedRounds + 1}</div>
|
||||||
|
{cols.map((p) => (
|
||||||
|
<div key={p.order} className="text-center text-[#7a7252]" style={{ fontSize: fz.dot }}>·</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-2" />
|
||||||
|
<div className="h-px bg-gold/20 mb-2" />
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div
|
||||||
|
className="grid items-center py-0.5"
|
||||||
|
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
|
||||||
|
>
|
||||||
|
<div className="text-center uppercase tracking-[.08em] text-green-dim" style={{ fontSize: fz.sigma }}>
|
||||||
|
Σ
|
||||||
|
</div>
|
||||||
|
{cols.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.order}
|
||||||
|
className={`text-center font-serif leading-none ${
|
||||||
|
p.order === myOrder ? 'text-gold-dim' : 'text-[#c8bb95]'
|
||||||
|
}`}
|
||||||
|
style={{ fontSize: fz.total, fontWeight: p.order === myOrder ? 700 : 600 }}
|
||||||
|
>
|
||||||
|
{computeTotal(standings, p.order)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (desktop) {
|
||||||
|
return (
|
||||||
|
<aside className="w-[268px] flex-shrink-0 bg-header border-l border-[#142018] flex flex-col">
|
||||||
|
<div className="h-[58px] flex items-center gap-2 px-5 border-b border-[#14221a]">
|
||||||
|
<span className="font-serif uppercase tracking-[.12em] text-[13px] text-gold">Skóre</span>
|
||||||
|
</div>
|
||||||
|
{table}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile: collapsible panel
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-800 rounded-xl overflow-hidden">
|
<div className="bg-header/80 border border-[#142018] rounded-xl overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
className="w-full flex justify-between items-center px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-slate-700"
|
className="w-full flex justify-between items-center px-4 py-2 font-serif uppercase tracking-[.12em] text-[12px] text-gold"
|
||||||
>
|
>
|
||||||
<span>Skore</span>
|
<span>Skóre</span>
|
||||||
<span>{open ? '▲' : '▼'}</span>
|
<span className="text-green-dim">{open ? '▲' : '▼'}</span>
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && table}
|
||||||
<table className="w-full text-sm text-center">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-gray-400 border-b border-slate-700">
|
|
||||||
<th className="py-1 px-2 text-left">Hrac</th>
|
|
||||||
{standings.map((_, si) => (
|
|
||||||
<th key={si} className="py-1 px-2">S{si + 1}</th>
|
|
||||||
))}
|
|
||||||
<th className="py-1 px-2">Spolu</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sorted.map((p) => (
|
|
||||||
<tr key={p.order} className="border-b border-slate-700/50">
|
|
||||||
<td className="py-1 px-2 text-left text-gray-200">{p.name}</td>
|
|
||||||
{standings.map((series, si) => {
|
|
||||||
return (
|
|
||||||
<td key={si} className="py-1 px-2 text-gray-300">
|
|
||||||
{computeTotal([series], p.order)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<td className="py-1 px-2 font-bold text-white">{p.total}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,66 @@
|
|||||||
import type { StashData } from '../types';
|
import type { PlayerInfo, StashData } from '../types';
|
||||||
import CardView from './CardView';
|
import CardView from './CardView';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
stash: StashData | null;
|
stash: StashData | null;
|
||||||
|
players: PlayerInfo[];
|
||||||
|
myOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Trick({ stash }: Props) {
|
const ROTATIONS = [-3, 2, -1, 1];
|
||||||
const cards = stash
|
// Entry direction by seat offset from me: 0=me(bottom) 1=left 2=top 3=right.
|
||||||
? [0, 1, 2, 3]
|
const FLY_BY_OFFSET = ['fly-bottom', 'fly-left', 'fly-top', 'fly-right'];
|
||||||
.map((i) => (stash.first_player + i) % 4)
|
|
||||||
.map((order) => stash.cards[String(order)])
|
export default function Trick({ stash, players, myOrder }: Props) {
|
||||||
.filter(Boolean)
|
// 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 <div className="flex items-center justify-center" style={{ minHeight: slotH + 14 }} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-green-900/60 rounded-xl p-4">
|
<div className="flex items-center justify-center">
|
||||||
<p className="text-xs text-green-300 mb-3 text-center">Aktualny stich</p>
|
{playOrder.map((order, i) => {
|
||||||
<div className="flex gap-3 justify-center min-h-28">
|
const card = stash.cards[String(order)];
|
||||||
{cards.map((card, i) => (
|
// Only render cards that have actually been played — no placeholder slot
|
||||||
<CardView key={i} card={card} size="lg" />
|
// for players still to play this trick.
|
||||||
))}
|
if (!card) return null;
|
||||||
</div>
|
const offset = (order - myOrder + 4) % 4;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={order}
|
||||||
|
className="relative flex flex-col items-center"
|
||||||
|
style={{ marginLeft: i === 0 ? 0 : overlap, zIndex: i + 1 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="uppercase text-center"
|
||||||
|
style={{
|
||||||
|
fontSize: 8,
|
||||||
|
letterSpacing: '.05em',
|
||||||
|
marginBottom: 3,
|
||||||
|
color: 'rgba(216,203,166,.72)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nameFor(order)}
|
||||||
|
</span>
|
||||||
|
{/* Outer: flies in from the player's direction. Inner: static rotation. */}
|
||||||
|
<div style={{ animation: `${FLY_BY_OFFSET[offset]} .42s cubic-bezier(.2,.7,.3,1) both` }}>
|
||||||
|
<div style={{ transform: `rotate(${ROTATIONS[i]}deg)` }}>
|
||||||
|
<CardView card={card} size="md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-2
@@ -2,6 +2,71 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
body {
|
@layer base {
|
||||||
@apply bg-slate-900 text-white min-h-screen;
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export const emit = {
|
|||||||
_pendingGid = gid;
|
_pendingGid = gid;
|
||||||
socket.emit('rejoin_game', 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'),
|
leaveGame: () => socket.emit('leave_game'),
|
||||||
endGame: (gid: string) => socket.emit('end_game', gid),
|
endGame: (gid: string) => socket.emit('end_game', gid),
|
||||||
startGame: (gid: string) => socket.emit('start_game', gid),
|
startGame: (gid: string) => socket.emit('start_game', gid),
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+49
-30
@@ -2,13 +2,18 @@ import { useState } from 'react';
|
|||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { useGameStore } from '../store/gameStore';
|
import { useGameStore } from '../store/gameStore';
|
||||||
import { emit } from '../lib/socket';
|
import { emit } from '../lib/socket';
|
||||||
|
import RulesModal from '../components/RulesModal';
|
||||||
|
|
||||||
type Mode = 'login' | 'register';
|
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() {
|
export default function Auth() {
|
||||||
const [mode, setMode] = useState<Mode>('login');
|
const [mode, setMode] = useState<Mode>('login');
|
||||||
const [username, setUsername] = useState(localStorage.getItem('bridzik_name') ?? '');
|
const [username, setUsername] = useState(localStorage.getItem('bridzik_name') ?? '');
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
|
const [showRules, setShowRules] = useState(false);
|
||||||
const registration = useGameStore((s) => s.registration);
|
const registration = useGameStore((s) => s.registration);
|
||||||
const setRegistration = useGameStore((s) => s.setRegistration);
|
const setRegistration = useGameStore((s) => s.setRegistration);
|
||||||
|
|
||||||
@@ -41,28 +46,28 @@ export default function Auth() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-sm mx-auto p-4 pt-10">
|
<div className="max-w-sm mx-auto p-4 pt-12 min-h-screen">
|
||||||
<h1 className="text-2xl font-bold text-center mb-2 tracking-wide">Bridzik</h1>
|
<h1 className="font-serif text-4xl text-center text-gold tracking-wide mb-1">Bridžik</h1>
|
||||||
<p className="text-center text-gray-400 text-sm mb-6">
|
<p className="text-center text-green-dim text-sm mb-7">
|
||||||
Prihlas sa kodom z aplikacie (napr. Google Authenticator).
|
Prihlás sa kódom z aplikácie (napr. Google Authenticator).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex mb-6 rounded-xl overflow-hidden border border-slate-700">
|
<div className="flex mb-6 rounded-xl overflow-hidden border border-gold/20">
|
||||||
<button
|
<button
|
||||||
onClick={() => switchMode('login')}
|
onClick={() => switchMode('login')}
|
||||||
className={`flex-1 py-2 text-sm font-semibold ${
|
className={`flex-1 py-2 text-sm font-serif tracking-wide ${
|
||||||
mode === 'login' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-gray-400'
|
mode === 'login' ? 'bg-gold text-table' : 'bg-header text-green-dim'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Prihlasenie
|
Prihlásenie
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => switchMode('register')}
|
onClick={() => switchMode('register')}
|
||||||
className={`flex-1 py-2 text-sm font-semibold ${
|
className={`flex-1 py-2 text-sm font-serif tracking-wide ${
|
||||||
mode === 'register' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-gray-400'
|
mode === 'register' ? 'bg-gold text-table' : 'bg-header text-green-dim'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Registracia
|
Registrácia
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -74,8 +79,8 @@ export default function Auth() {
|
|||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
maxLength={40}
|
maxLength={40}
|
||||||
placeholder="Pouzivatelske meno"
|
placeholder="Používateľské meno"
|
||||||
className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500"
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -83,15 +88,15 @@ export default function Auth() {
|
|||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
placeholder="6-miestny kod"
|
placeholder="6-miestny kód"
|
||||||
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"
|
className={`${inputCls} tracking-widest font-mono`}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!username.trim() || code.trim().length < 6}
|
disabled={!username.trim() || code.trim().length < 6}
|
||||||
className="py-3 rounded-xl bg-blue-600 hover:bg-blue-500 font-bold disabled:opacity-40"
|
className="py-3 rounded-xl bg-gold text-table font-serif font-semibold hover:bg-gold-bright disabled:opacity-40 transition-colors"
|
||||||
>
|
>
|
||||||
Prihlasit
|
Prihlásiť
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -104,30 +109,30 @@ export default function Auth() {
|
|||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
maxLength={40}
|
maxLength={40}
|
||||||
placeholder="Zvol si pouzivatelske meno"
|
placeholder="Zvoľ si používateľské meno"
|
||||||
className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500"
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!username.trim()}
|
disabled={!username.trim()}
|
||||||
className="py-3 rounded-xl bg-green-700 hover:bg-green-600 font-bold disabled:opacity-40"
|
className="py-3 rounded-xl bg-gold text-table font-serif font-semibold hover:bg-gold-bright disabled:opacity-40 transition-colors"
|
||||||
>
|
>
|
||||||
Vytvorit ucet
|
Vytvoriť účet
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === 'register' && registration && (
|
{mode === 'register' && registration && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-sm text-gray-300">
|
<p className="text-sm text-green-score">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-white rounded-xl p-4 flex justify-center">
|
<div className="bg-white rounded-xl p-4 flex justify-center">
|
||||||
<QRCodeSVG value={registration.otpauth_uri} size={176} />
|
<QRCodeSVG value={registration.otpauth_uri} size={176} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400 text-center">
|
<div className="text-xs text-green-dim text-center">
|
||||||
Alebo zadaj rucne kluc:
|
Alebo zadaj ručne kľúč:
|
||||||
<span className="block font-mono text-gray-200 break-all mt-1">
|
<span className="block font-mono text-green-score break-all mt-1">
|
||||||
{registration.secret}
|
{registration.secret}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,19 +144,33 @@ export default function Auth() {
|
|||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
placeholder="6-miestny kod"
|
placeholder="6-miestny kód"
|
||||||
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"
|
className={`${inputCls} tracking-widest font-mono`}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={code.trim().length < 6}
|
disabled={code.trim().length < 6}
|
||||||
className="py-3 rounded-xl bg-blue-600 hover:bg-blue-500 font-bold disabled:opacity-40"
|
className="py-3 rounded-xl bg-gold text-table font-serif font-semibold hover:bg-gold-bright disabled:opacity-40 transition-colors"
|
||||||
>
|
>
|
||||||
Potvrdit a prihlasit
|
Potvrdiť a prihlásiť
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Rules are reachable before login — quiet link with the velvet divider motif. */}
|
||||||
|
<div className="mt-10 flex items-center gap-3">
|
||||||
|
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-gold/20" />
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRules(true)}
|
||||||
|
className="uppercase tracking-[.13em] text-[10px] text-green-dim hover:text-gold transition-colors"
|
||||||
|
>
|
||||||
|
Pravidlá hry
|
||||||
|
</button>
|
||||||
|
<div className="h-px flex-1 bg-gradient-to-l from-transparent to-gold/20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showRules && <RulesModal onClose={() => setShowRules(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,23 +24,23 @@ export default function GameList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto p-4 pt-8">
|
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold tracking-wide">Bridzik</h1>
|
<h1 className="font-serif text-2xl text-gold tracking-wide">Bridžik</h1>
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<span className="text-gray-400">{account?.username}</span>
|
<span className="text-green-dim">{account?.username}</span>
|
||||||
<button onClick={() => navigate('/history')} className="text-blue-400 hover:text-blue-300">
|
<button onClick={() => navigate('/history')} className="text-gold hover:text-gold-bright">
|
||||||
Historia
|
História
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleLogout} className="text-gray-400 hover:text-white">
|
<button onClick={handleLogout} className="text-green-dim hover:text-gold">
|
||||||
Odhlasit
|
Odhlásiť
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 mb-6">
|
<div className="flex flex-col gap-3 mb-6">
|
||||||
{games.length === 0 && (
|
{games.length === 0 && (
|
||||||
<p className="text-center text-gray-500 py-4">Ziadne hry. Vytvor prvu!</p>
|
<p className="text-center text-green-dim py-4">Žiadne hry. Vytvor prvú!</p>
|
||||||
)}
|
)}
|
||||||
{games.map((g) => {
|
{games.map((g) => {
|
||||||
const full = g.players.length >= 4;
|
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);
|
!!account && g.players.some((p) => p.player_id === account.player_id);
|
||||||
const canResume = g.started && isMember;
|
const canResume = g.started && isMember;
|
||||||
const unavailable = !canResume && (full || g.started);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={g.gid}
|
key={g.gid}
|
||||||
className="flex items-center justify-between bg-slate-800 rounded-xl px-4 py-3"
|
className="flex items-center justify-between bg-header border border-[#142018] rounded-xl px-4 py-3"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">{g.name}</p>
|
<p className="font-serif text-green-score">{g.name}</p>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-green-dim">
|
||||||
{g.players.length}/4 hracov
|
{g.players.length}/4 hráčov
|
||||||
{g.started ? ' · zacata' : ''}
|
{g.started ? ' · začatá' : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
disabled={unavailable}
|
disabled={unavailable}
|
||||||
onClick={() => (canResume ? emit.rejoinGame(g.gid) : emit.registerPlayer(g.gid))}
|
onClick={() => (canResume ? emit.rejoinGame(g.gid) : emit.registerPlayer(g.gid))}
|
||||||
className={`px-4 py-1.5 rounded-lg text-sm font-semibold disabled:opacity-40 disabled:cursor-default ${
|
className={`px-4 py-1.5 rounded-lg text-sm font-serif font-semibold disabled:opacity-40 disabled:cursor-default transition-colors ${
|
||||||
canResume ? 'bg-green-700 hover:bg-green-600' : 'bg-blue-600 hover:bg-blue-500'
|
canResume
|
||||||
|
? 'bg-gold text-table hover:bg-gold-bright'
|
||||||
|
: 'border border-gold/40 text-gold hover:bg-gold hover:text-table'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -77,14 +79,14 @@ export default function GameList() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
onClick={() => setShowCreate(true)}
|
||||||
className="w-full py-3 rounded-xl bg-green-700 hover:bg-green-600 font-bold text-lg mb-3"
|
className="w-full py-2 rounded-xl bg-gold text-table font-serif font-semibold text-base mb-3 hover:bg-gold-bright transition-colors"
|
||||||
>
|
>
|
||||||
+ Vytvorit novu hru
|
Vytvoriť novú hru
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRules(true)}
|
onClick={() => setShowRules(true)}
|
||||||
className="w-full py-2 rounded-xl bg-slate-700 hover:bg-slate-600 text-sm text-gray-300"
|
className="w-full py-2 rounded-xl border border-gold/20 text-sm text-green-score hover:border-gold/50 transition-colors"
|
||||||
>
|
>
|
||||||
Pravidlá hry
|
Pravidlá hry
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -20,25 +20,27 @@ export default function GameOver({ players, standings }: Props) {
|
|||||||
const medals = ['🥇', '🥈', '🥉', ''];
|
const medals = ['🥇', '🥈', '🥉', ''];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto p-4 pt-12 flex flex-col items-center gap-6">
|
<div className="max-w-md mx-auto p-4 pt-12 flex flex-col items-center gap-6 min-h-screen">
|
||||||
<h1 className="text-3xl font-bold">Koniec hry!</h1>
|
<h1 className="font-serif text-3xl text-gold tracking-wide">Koniec hry</h1>
|
||||||
<div className="bg-slate-800 rounded-2xl w-full overflow-hidden">
|
<div className="w-full rounded-2xl overflow-hidden bg-header border border-[#142018]">
|
||||||
{totals.map((p, i) => (
|
{totals.map((p, i) => (
|
||||||
<div
|
<div
|
||||||
key={p.order}
|
key={p.order}
|
||||||
className="flex items-center justify-between px-5 py-3 border-b border-slate-700 last:border-0"
|
className="flex items-center justify-between px-5 py-3 border-b border-gold/[.08] last:border-0"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-2xl w-8">{medals[i]}</span>
|
<span className="text-2xl w-8">{medals[i]}</span>
|
||||||
<span className="font-semibold">{p.name}</span>
|
<span className="font-serif text-green-score">{p.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xl font-bold text-yellow-300">{p.total}</span>
|
<span className={`font-serif text-xl ${i === 0 ? 'text-gold-bright' : 'text-gold'}`}>
|
||||||
|
{p.total}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleLeave}
|
onClick={handleLeave}
|
||||||
className="w-full py-3 rounded-xl bg-blue-600 hover:bg-blue-500 font-bold text-lg"
|
className="w-full py-3 rounded-xl bg-gold text-table font-serif font-semibold text-lg hover:bg-gold-bright transition-colors"
|
||||||
>
|
>
|
||||||
Domov
|
Domov
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,17 +4,26 @@ 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 } from '../lib/gameRules';
|
||||||
|
import { computeTotal } from '../lib/standings';
|
||||||
|
import { useIsDesktop } from '../lib/useIsDesktop';
|
||||||
|
import { useFitScale } from '../lib/useFitScale';
|
||||||
import Hand from '../components/Hand';
|
import Hand from '../components/Hand';
|
||||||
import GuessControls from '../components/GuessControls';
|
import GuessControls from '../components/GuessControls';
|
||||||
import Trick from '../components/Trick';
|
import Trick from '../components/Trick';
|
||||||
import Standings from '../components/Standings';
|
import Standings from '../components/Standings';
|
||||||
|
import PlayerCircle from '../components/PlayerCircle';
|
||||||
|
import FaceDownCards from '../components/FaceDownCards';
|
||||||
import GameOver from './GameOver';
|
import GameOver from './GameOver';
|
||||||
import type { StashData } from '../types';
|
import type { PlayerInfo, StashData } from '../types';
|
||||||
|
|
||||||
const TRICK_LINGER_MS = 3000;
|
const TRICK_LINGER_MS = 3000;
|
||||||
|
|
||||||
export default function GameTable() {
|
export default function GameTable() {
|
||||||
const navigate = useNavigate();
|
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 myPlayer = useGameStore((s) => s.myPlayer);
|
||||||
const gameStatus = useGameStore((s) => s.gameStatus);
|
const gameStatus = useGameStore((s) => s.gameStatus);
|
||||||
const hand = useGameStore((s) => s.hand);
|
const hand = useGameStore((s) => s.hand);
|
||||||
@@ -33,11 +42,11 @@ export default function GameTable() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (lingerTimer.current) clearTimeout(lingerTimer.current);
|
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)]);
|
}, [gameStatus?.status.previous_stash?.first_player, JSON.stringify(gameStatus?.status.previous_stash?.cards)]);
|
||||||
|
|
||||||
if (!gameStatus || !myPlayer) {
|
if (!gameStatus || !myPlayer) {
|
||||||
return <p className="text-center text-gray-400 pt-20">Nacitava sa...</p>;
|
return <p className="text-center text-green-dim pt-20 font-serif italic">Načítava sa…</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { completed, players, series_number, round_number, cards_in_round, status } = gameStatus;
|
const { completed, players, series_number, round_number, cards_in_round, status } = gameStatus;
|
||||||
@@ -47,6 +56,7 @@ export default function GameTable() {
|
|||||||
active_round_stashes,
|
active_round_stashes,
|
||||||
active_stash,
|
active_stash,
|
||||||
standings = [],
|
standings = [],
|
||||||
|
standings_guesses = [],
|
||||||
} = status;
|
} = status;
|
||||||
|
|
||||||
if (completed) {
|
if (completed) {
|
||||||
@@ -57,106 +67,280 @@ export default function GameTable() {
|
|||||||
const isPlayPhase = active_stash !== undefined;
|
const isPlayPhase = active_stash !== undefined;
|
||||||
const myTurnToPlay = isPlayPhase && active_player === myOrder;
|
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 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
|
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)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const activePlayerName = players.find((p) => p.order === active_player)?.name ?? '';
|
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 handleLeave = () => leaveGame(navigate);
|
||||||
const handleEnd = () => {
|
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);
|
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 (
|
// ── shared pieces ────────────────────────────────────────────────
|
||||||
<div className="max-w-lg mx-auto p-3 flex flex-col gap-3 pb-6">
|
const bannerText = activeOf(myOrder)
|
||||||
{/* Header */}
|
? isPlayPhase
|
||||||
<div className="flex items-center justify-between">
|
? 'Zahraj kartu'
|
||||||
<div className="text-sm text-gray-400">
|
: 'Zadaj tip'
|
||||||
<span>Seria {series_number} / Kolo {round_number + 1}</span>
|
: `${activePlayerName} ${isPlayPhase ? 'hrá' : 'tipuje'}`;
|
||||||
<span className="ml-2 text-gray-500">({cards_in_round} kopok)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{myOrder === 0 && (
|
|
||||||
<button onClick={handleEnd} className="text-xs text-red-400 hover:text-red-300">
|
|
||||||
Ukoncit hru
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button onClick={handleLeave} className="text-xs text-gray-500 hover:text-gray-300">
|
|
||||||
Odist
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Turn indicator */}
|
const banner = (
|
||||||
<div className="bg-slate-800/60 rounded-lg px-3 py-2 text-sm text-center">
|
<div className="flex items-center justify-center gap-2">
|
||||||
{active_player === myOrder ? (
|
<span className="inline-block w-[7px] h-[7px] rounded-full bg-gold animate-tp flex-shrink-0" />
|
||||||
<span className="text-yellow-300 font-semibold">
|
<span className="font-serif italic text-[13px] text-gold-dim tracking-[.03em]">{bannerText}</span>
|
||||||
{!isPlayPhase ? 'Zadaj svoj tip' : 'Zahraj kartu'}
|
</div>
|
||||||
</span>
|
);
|
||||||
) : (
|
|
||||||
<span className="text-gray-400">
|
|
||||||
Na rade: <span className="text-white">{activePlayerName}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Guesses summary (always shown when available) */}
|
const opponents = players
|
||||||
{active_round_guesses && (
|
.filter((p) => p.order !== myOrder)
|
||||||
<div className="bg-slate-800/60 rounded-lg px-3 py-2">
|
.sort((a, b) => a.order - b.order);
|
||||||
<p className="text-xs text-gray-400 mb-1">Tipy:</p>
|
|
||||||
<div className="flex gap-3 flex-wrap">
|
const totalsRow = (compact: boolean) => (
|
||||||
{players.map((p) => {
|
<div className="flex items-center justify-between gap-2">
|
||||||
const guess = active_round_guesses[String(p.order)];
|
{opponents.map((p) => (
|
||||||
const wins = active_round_stashes?.[p.order] ?? 0;
|
<div key={p.order} className="text-center">
|
||||||
return (
|
<div className="uppercase tracking-[.1em] text-green-dim mb-0.5" style={{ fontSize: 11 }}>
|
||||||
<span key={p.order} className="text-sm">
|
{p.name}
|
||||||
<span className="text-gray-300">{p.name}:</span>{' '}
|
</div>
|
||||||
{guess !== undefined ? (
|
<div className="font-serif text-green-score leading-none" style={{ fontSize: compact ? 16 : 20 }}>
|
||||||
<span className="text-white font-semibold">
|
{computeTotal(standings, p.order)}
|
||||||
{isPlayPhase ? `${wins}/${guess}` : guess}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-500">?</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
|
<div className="text-center rounded-lg px-3 py-1 bg-gold/[.06] border border-gold/[.15]">
|
||||||
|
<div className="uppercase tracking-[.1em] text-gold mb-0.5" style={{ fontSize: 11 }}>
|
||||||
|
{myPlayer.name}
|
||||||
|
</div>
|
||||||
|
<div className="font-serif font-semibold text-gold-dim leading-none" style={{ fontSize: compact ? 16 : 20 }}>
|
||||||
|
{computeTotal(standings, myOrder)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{/* Active stash (trick) — container always visible during play phase, cards linger 3 s */}
|
// Center of the oval: trick during play, guess controls during bidding.
|
||||||
{isPlayPhase && (
|
const ovalContent = isPlayPhase ? (
|
||||||
<Trick stash={displayedStash ?? null} />
|
<Trick stash={displayedStash} players={players} myOrder={myOrder} />
|
||||||
)}
|
) : (
|
||||||
|
active_round_guesses !== undefined && active_player !== undefined ? (
|
||||||
|
<GuessControls
|
||||||
|
cardsInRound={cards_in_round}
|
||||||
|
guesses={active_round_guesses}
|
||||||
|
myOrder={myOrder}
|
||||||
|
activePlayer={active_player}
|
||||||
|
activePlayerName={activePlayerName}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
);
|
||||||
|
|
||||||
{/* Guess phase controls */}
|
const topSeat = (
|
||||||
{!isPlayPhase && active_round_guesses !== undefined && active_player !== undefined && (
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
<GuessControls
|
<PlayerCircle
|
||||||
cardsInRound={cards_in_round}
|
name={topP?.name ?? '—'}
|
||||||
guesses={active_round_guesses}
|
won={wonOf(topP?.order)}
|
||||||
myOrder={myOrder}
|
guess={guessOf(topP?.order)}
|
||||||
activePlayer={active_player}
|
active={activeOf(topP?.order)}
|
||||||
activePlayerName={activePlayerName}
|
size={desktop ? 64 : 52}
|
||||||
/>
|
/>
|
||||||
)}
|
<FaceDownCards count={cardsInHandOf(topP?.order)} direction="row" desktop={desktop} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{/* Hand */}
|
const sideSeat = (p?: PlayerInfo) => (
|
||||||
<div>
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
<p className="text-xs text-gray-400 mb-1 text-center">Tvoje karty</p>
|
<PlayerCircle
|
||||||
<Hand hand={hand} myTurn={myTurnToPlay} isPlayPhase={isPlayPhase} playableKeys={playableKeys} />
|
name={p?.name ?? '—'}
|
||||||
|
won={wonOf(p?.order)}
|
||||||
|
guess={guessOf(p?.order)}
|
||||||
|
active={activeOf(p?.order)}
|
||||||
|
size={desktop ? 60 : 48}
|
||||||
|
/>
|
||||||
|
<FaceDownCards count={cardsInHandOf(p?.order)} direction="col" desktop={desktop} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const meSeat = (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<PlayerCircle
|
||||||
|
name={myPlayer.name}
|
||||||
|
won={wonOf(myOrder)}
|
||||||
|
guess={guessOf(myOrder)}
|
||||||
|
active={activeOf(myOrder)}
|
||||||
|
size={desktop ? 70 : 58}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const handArea = (
|
||||||
|
<Hand hand={hand} myTurn={myTurnToPlay} isPlayPhase={isPlayPhase} playableKeys={playableKeys} desktop={desktop} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── DESKTOP LAYOUT ───────────────────────────────────────────────
|
||||||
|
if (desktop) {
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="h-[100dvh] w-full overflow-hidden bg-table">
|
||||||
|
{/* Design canvas — fixed height, width spans the viewport; scaled as one
|
||||||
|
unit so the whole board (and header) zooms with the window. */}
|
||||||
|
<div
|
||||||
|
className="flex"
|
||||||
|
style={{ width: contentWidth, height: 860, transform: `scale(${scale})`, transformOrigin: 'top left' }}
|
||||||
|
>
|
||||||
|
{/* main */}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
|
{/* header */}
|
||||||
|
<div className="shrink-0 h-[58px] bg-header flex items-center gap-4 px-6 border-b border-[#14221a]">
|
||||||
|
<span className="font-serif uppercase tracking-[.14em] text-[15px] text-gold whitespace-nowrap">
|
||||||
|
Bridžik
|
||||||
|
</span>
|
||||||
|
<div className="w-px h-[22px] bg-[#1a3a22]" />
|
||||||
|
<span className="font-serif text-[12px] text-green-dim tracking-[.06em] whitespace-nowrap">
|
||||||
|
Séria {series_number + 1} · Kolo {round_number + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 flex items-center justify-center">{banner}</div>
|
||||||
|
{totalsRow(true)}
|
||||||
|
<div className="w-px h-[22px] bg-[#1a3a22]" />
|
||||||
|
{canEnd && (
|
||||||
|
<button onClick={handleEnd} className="text-[11px] text-[#8a8064] hover:text-gold whitespace-nowrap">
|
||||||
|
Ukončiť
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold whitespace-nowrap">
|
||||||
|
Odísť
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* game content — players hug the edges so the felt uses full width */}
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col justify-center gap-3 px-16 py-4">
|
||||||
|
{topSeat}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-16">
|
||||||
|
{sideSeat(leftP)}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center rounded-full"
|
||||||
|
style={{
|
||||||
|
width: 620,
|
||||||
|
height: 372,
|
||||||
|
background:
|
||||||
|
'radial-gradient(ellipse at 42% 38%,#306845 0%,#1e5030 38%,#122e1c 72%,#091e12 100%)',
|
||||||
|
boxShadow:
|
||||||
|
'inset 0 10px 48px rgba(0,0,0,.72),0 0 0 3px rgba(0,0,0,.55),0 0 0 6px rgba(201,168,76,.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ovalContent}
|
||||||
|
</div>
|
||||||
|
{sideSeat(rightP)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meSeat}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{handArea}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* sidebar */}
|
||||||
|
<Standings standings={standings} guesses={standings_guesses} players={players} myOrder={myOrder} desktop />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MOBILE LAYOUT ────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto min-h-screen flex flex-col">
|
||||||
|
{/* header */}
|
||||||
|
<div className="bg-header px-[18px] pt-[14px] pb-3 border-b border-[#14221a]">
|
||||||
|
<div className="flex items-center justify-between mb-2.5">
|
||||||
|
<span className="font-serif uppercase tracking-[.1em] text-[11px] text-gold">
|
||||||
|
Séria {series_number + 1} · Kolo {round_number + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{canEnd && (
|
||||||
|
<button onClick={handleEnd} className="text-[11px] text-[#6a3030] hover:text-red-400">
|
||||||
|
Ukončiť
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold">
|
||||||
|
Odísť
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{totalsRow(false)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Standings */}
|
{/* turn banner */}
|
||||||
<Standings standings={standings} players={players} />
|
<div
|
||||||
|
className="py-[9px] px-4 border-b border-[#152a1a]"
|
||||||
|
style={{ background: 'linear-gradient(90deg,#09190d,#14301e,#09190d)' }}
|
||||||
|
>
|
||||||
|
{banner}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* game area */}
|
||||||
|
<div className="flex-1 bg-table px-2.5 pt-2.5 pb-1.5 flex flex-col">
|
||||||
|
<div className="flex flex-col items-center mb-1.5">{topSeat}</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
<div className="w-[54px] flex-shrink-0 flex justify-center">{sideSeat(leftP)}</div>
|
||||||
|
<div
|
||||||
|
className="flex-1 flex items-center justify-center rounded-full"
|
||||||
|
style={{
|
||||||
|
minHeight: 192,
|
||||||
|
background:
|
||||||
|
'radial-gradient(ellipse at 42% 38%,#306845 0%,#1e5030 38%,#122e1c 72%,#091e12 100%)',
|
||||||
|
boxShadow:
|
||||||
|
'inset 0 6px 32px rgba(0,0,0,.7),0 0 0 2px rgba(0,0,0,.5),0 0 0 4px rgba(201,168,76,.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ovalContent}
|
||||||
|
</div>
|
||||||
|
<div className="w-[54px] flex-shrink-0 flex justify-center">{sideSeat(rightP)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-1.5">{meSeat}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{handArea}
|
||||||
|
|
||||||
|
{/* score */}
|
||||||
|
<div className="bg-table px-3 pb-4 pt-1">
|
||||||
|
<Standings standings={standings} guesses={standings_guesses} players={players} myOrder={myOrder} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+249
-54
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect } from 'react';
|
import { Fragment, useEffect, useMemo } 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, socket } from '../lib/socket';
|
||||||
|
import { useIsDesktop } from '../lib/useIsDesktop';
|
||||||
|
import type { GameDetail, GameDetailRound } from '../types';
|
||||||
|
|
||||||
function fmtDate(iso: string | null): string {
|
function fmtDate(iso: string | null): string {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
@@ -20,78 +22,271 @@ export default function History() {
|
|||||||
return () => setGameDetail(null);
|
return () => setGameDetail(null);
|
||||||
}, [setGameDetail]);
|
}, [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 ---
|
// --- detail view ---
|
||||||
if (detail) {
|
if (detail) {
|
||||||
return (
|
return <GameDetailView detail={detail} onBack={() => setGameDetail(null)} />;
|
||||||
<div className="max-w-md mx-auto p-4 pt-8">
|
|
||||||
<button
|
|
||||||
onClick={() => setGameDetail(null)}
|
|
||||||
className="text-sm text-gray-400 hover:text-white mb-4"
|
|
||||||
>
|
|
||||||
← Spat na zoznam
|
|
||||||
</button>
|
|
||||||
<h1 className="text-xl font-bold mb-1">Detail hry</h1>
|
|
||||||
<p className="text-xs text-gray-400 mb-4">{fmtDate(detail.created_at)}</p>
|
|
||||||
|
|
||||||
<div className="bg-slate-800 rounded-xl overflow-hidden text-sm">
|
|
||||||
<div className="grid grid-cols-[auto_1fr_auto_auto] gap-2 px-3 py-2 text-xs text-gray-400 border-b border-slate-700">
|
|
||||||
<span>S/K</span>
|
|
||||||
<span>Hrac</span>
|
|
||||||
<span className="text-right">Tip</span>
|
|
||||||
<span className="text-right">Body</span>
|
|
||||||
</div>
|
|
||||||
{detail.rounds.map((r, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="grid grid-cols-[auto_1fr_auto_auto] gap-2 px-3 py-1.5 border-b border-slate-700/50 last:border-0"
|
|
||||||
>
|
|
||||||
<span className="text-gray-500">
|
|
||||||
{r.series_number}/{r.round_number}
|
|
||||||
</span>
|
|
||||||
<span className="truncate">{r.username ?? `#${r.player_id}`}</span>
|
|
||||||
<span className="text-right font-mono">{r.guess}</span>
|
|
||||||
<span className={`text-right font-mono ${r.won ? 'text-green-400' : 'text-gray-500'}`}>
|
|
||||||
{r.points}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- list view ---
|
// --- list view ---
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto p-4 pt-8">
|
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-xl font-bold">Moja historia</h1>
|
<h1 className="font-serif text-2xl text-gold">Moja história</h1>
|
||||||
<button onClick={() => navigate('/')} className="text-sm text-gray-400 hover:text-white">
|
<button onClick={() => navigate('/')} className="text-sm text-green-dim hover:text-gold">
|
||||||
Spat
|
Späť
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{history.length === 0 && (
|
{history.length === 0 && (
|
||||||
<p className="text-center text-gray-500 py-6">Zatial ziadne odohrane hry.</p>
|
<p className="text-center text-green-dim py-6">Zatiaľ žiadne odohrané hry.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{history.map((g) => (
|
{history.map((g) => (
|
||||||
<button
|
<div
|
||||||
key={g.gid}
|
key={g.gid}
|
||||||
onClick={() => emit.getGameDetail(g.gid)}
|
className="flex items-stretch bg-header border border-[#142018] rounded-xl overflow-hidden"
|
||||||
className="text-left bg-slate-800 hover:bg-slate-700 rounded-xl px-4 py-3"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<button
|
||||||
<span className="text-sm text-gray-200">{fmtDate(g.created_at)}</span>
|
onClick={() => emit.getGameDetail(g.gid)}
|
||||||
<span className="text-sm font-semibold text-green-400">{g.my_points} b.</span>
|
className="flex-1 min-w-0 text-left px-4 py-3 hover:bg-white/[.02] transition-colors"
|
||||||
|
>
|
||||||
|
<p className="font-serif text-green-score truncate">{g.name || 'Hra'}</p>
|
||||||
|
<p className="text-xs text-green-dim mt-1 truncate">{g.players.join(', ')}</p>
|
||||||
|
<p className="text-xs text-[#7a7058] mt-0.5">
|
||||||
|
{fmtDate(g.created_at)} · {g.completed ? 'dohraná' : 'predčasne ukončená'}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col items-end justify-center gap-2 py-3 pl-2 pr-3">
|
||||||
|
<span className="text-base font-serif text-gold whitespace-nowrap leading-none">
|
||||||
|
{g.my_points}
|
||||||
|
<span className="text-[11px] text-green-dim ml-0.5">b.</span>
|
||||||
|
</span>
|
||||||
|
{!g.completed && (
|
||||||
|
<button
|
||||||
|
onClick={() => emit.restoreGame(g.gid)}
|
||||||
|
className="px-4 py-1.5 rounded-lg text-sm font-serif font-semibold bg-gold text-table hover:bg-gold-bright transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Obnoviť
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 mt-1 truncate">{g.players.join(', ')}</p>
|
</div>
|
||||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
|
||||||
{g.ended_at ? 'dohrana' : 'nedohrana'}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<number, Map<number, Map<number, GameDetailRound>>>();
|
||||||
|
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 ? (
|
||||||
|
<span className="font-serif" style={{ fontSize: 14, color: '#c8bb95' }}>{r.points}</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-serif line-through" style={{ fontSize: 14, color: '#7a6e4a' }}>{r.guess}</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
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 = (
|
||||||
|
<div
|
||||||
|
className="border-b border-gold/[.18]"
|
||||||
|
style={{ display: 'grid', gridTemplateColumns: MOBILE_COLS, padding: '9px 8px' }}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
{seats.map((p, c) => (
|
||||||
|
<div
|
||||||
|
key={c}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 6,
|
||||||
|
borderLeft: c > 0 ? SEP : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="uppercase text-gold" style={{ letterSpacing: '.06em', fontSize: 11 }}>
|
||||||
|
{p.username}
|
||||||
|
</span>
|
||||||
|
<span className="font-serif text-gold-dim" style={{ fontWeight: 700, fontSize: 16 }}>
|
||||||
|
{totals[c]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// A round row: index column + one cell per (player × series) in `cols`.
|
||||||
|
const roundRow = (rn: number, cols: (number | undefined)[]) => (
|
||||||
|
<div
|
||||||
|
key={rn}
|
||||||
|
className="border-b border-gold/[.04]"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: desktop ? DESKTOP_COLS : MOBILE_COLS,
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '3px 8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', fontSize: 10, color: '#7a7252' }}>{rn + 1}</div>
|
||||||
|
{seats.map((_, c) =>
|
||||||
|
cols.map((s, si) => (
|
||||||
|
<div key={`${c}-${si}`} style={{ textAlign: 'center', borderLeft: c > 0 && si === 0 ? SEP : undefined }}>
|
||||||
|
{cellNode(s, rn, c)}
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Σ row: per-series totals for each player.
|
||||||
|
const sigmaRow = (cols: (number | undefined)[]) => (
|
||||||
|
<div
|
||||||
|
className="bg-gold/[.08] border-b border-gold/[.14]"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: desktop ? DESKTOP_COLS : MOBILE_COLS,
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '5px 8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-green-dim" style={{ textAlign: 'center', fontSize: 11 }}>Σ</div>
|
||||||
|
{seats.map((_, c) =>
|
||||||
|
cols.map((s, si) => (
|
||||||
|
<div
|
||||||
|
key={`${c}-${si}`}
|
||||||
|
className="font-serif text-green-score"
|
||||||
|
style={{ textAlign: 'center', fontWeight: 600, fontSize: 14, borderLeft: c > 0 && si === 0 ? SEP : undefined }}
|
||||||
|
>
|
||||||
|
{seriesTotal(s, c)}
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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) => (
|
||||||
|
<div
|
||||||
|
className="border-b border-gold/10"
|
||||||
|
style={{ display: 'grid', gridTemplateColumns: DESKTOP_COLS, padding: '5px 8px 3px' }}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
{seats.map((_, c) => (
|
||||||
|
<Fragment key={c}>
|
||||||
|
<div style={{ textAlign: 'center', fontSize: 10, color: '#8a8064', borderLeft: c > 0 ? SEP : undefined }}>
|
||||||
|
{sA + 1}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', fontSize: 10, color: '#8a8064' }}>
|
||||||
|
{sB !== undefined ? sB + 1 : ''}
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Fragment key={bi}>
|
||||||
|
{seriesTags(sA, sB)}
|
||||||
|
{roundNums.map((rn) => roundRow(rn, [sA, sB]))}
|
||||||
|
{sigmaRow([sA, sB])}
|
||||||
|
{bi < blocks.length - 1 && <div style={{ height: 12 }} />}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Fragment key={s}>
|
||||||
|
<div style={{ padding: '7px 8px 3px', fontSize: 10, textTransform: 'uppercase', letterSpacing: '.09em', color: '#8a8064' }}>
|
||||||
|
Séria {s + 1}
|
||||||
|
</div>
|
||||||
|
{roundNums.map((rn) => roundRow(rn, [s]))}
|
||||||
|
{sigmaRow([s])}
|
||||||
|
{si < seriesNums.length - 1 && <div style={{ height: 12 }} />}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto p-4 pt-8 min-h-screen">
|
||||||
|
<button onClick={onBack} className="text-sm text-green-dim hover:text-gold mb-4">
|
||||||
|
← Späť na zoznam
|
||||||
|
</button>
|
||||||
|
<h1 className="font-serif text-2xl text-gold mb-1 truncate">{detail.name || 'Detail hry'}</h1>
|
||||||
|
<p className="text-xs text-green-dim mb-4">{fmtDate(detail.created_at)}</p>
|
||||||
|
|
||||||
|
{/* Desktop packs 8 columns → allow horizontal scroll on narrow widths;
|
||||||
|
mobile uses only 4 columns and fits the phone, so no scroll. */}
|
||||||
|
<div className={`bg-header border border-[#142018] rounded-xl ${desktop ? 'overflow-x-auto' : ''}`}>
|
||||||
|
<div style={desktop ? { minWidth: 440 } : undefined}>
|
||||||
|
{header}
|
||||||
|
{desktop ? desktopBody : mobileBody}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,37 +25,37 @@ export default function Lobby() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto p-4 pt-8">
|
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-xl font-bold">{game?.name ?? 'Hra'}</h1>
|
<h1 className="font-serif text-2xl text-gold">{game?.name ?? 'Hra'}</h1>
|
||||||
<button onClick={handleLeave} className="text-sm text-gray-400 hover:text-white">
|
<button onClick={handleLeave} className="text-sm text-green-dim hover:text-gold">
|
||||||
Odist
|
Odísť
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-slate-800 rounded-xl p-4 mb-4 flex items-center justify-between">
|
<div className="bg-header border border-[#142018] rounded-xl p-4 mb-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-400 mb-1">Kod hry</p>
|
<p className="text-xs uppercase tracking-[.1em] text-green-dim mb-1">Kód hry</p>
|
||||||
<p className="font-mono text-sm text-gray-200 break-all">{gid}</p>
|
<p className="font-mono text-sm text-green-score break-all">{gid}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleCopyCode}
|
onClick={handleCopyCode}
|
||||||
className="ml-3 px-3 py-1 rounded-lg text-sm bg-slate-700 hover:bg-slate-600"
|
className="ml-3 px-3 py-1 rounded-lg text-sm border border-gold/30 text-gold hover:bg-gold hover:text-table transition-colors"
|
||||||
>
|
>
|
||||||
Kopirovat
|
Kopírovať
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-slate-800 rounded-xl p-4 mb-6 flex flex-col gap-3">
|
<div className="bg-header border border-[#142018] rounded-xl p-4 mb-6 flex flex-col gap-3">
|
||||||
{[0, 1, 2, 3].map((order) => {
|
{[0, 1, 2, 3].map((order) => {
|
||||||
const p = players.find((pl) => pl.order === order);
|
const p = players.find((pl) => pl.order === order);
|
||||||
return (
|
return (
|
||||||
<div key={order} className="flex items-center gap-3">
|
<div key={order} className="flex items-center gap-3">
|
||||||
<span className={`text-lg ${p ? 'text-green-400' : 'text-gray-600'}`}>
|
<span className={`text-lg ${p ? 'text-gold' : 'text-[#7a7058]'}`}>
|
||||||
{p ? '✓' : '○'}
|
{p ? '✦' : '○'}
|
||||||
</span>
|
</span>
|
||||||
<span className={p ? 'text-white' : 'text-gray-500 italic'}>
|
<span className={p ? 'font-serif text-green-score' : 'text-green-dim italic'}>
|
||||||
{p ? `${p.name}${myPlayer?.order === p.order ? ' (ty)' : ''}` : 'Caka sa...'}
|
{p ? `${p.name}${myPlayer?.order === p.order ? ' (ty)' : ''}` : 'Čaká sa…'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -65,9 +65,9 @@ export default function Lobby() {
|
|||||||
<button
|
<button
|
||||||
disabled={!canStart}
|
disabled={!canStart}
|
||||||
onClick={() => gid && emit.startGame(gid)}
|
onClick={() => gid && emit.startGame(gid)}
|
||||||
className="w-full py-3 rounded-xl bg-green-700 hover:bg-green-600 font-bold text-lg disabled:opacity-40 disabled:cursor-default"
|
className="w-full py-3 rounded-xl bg-gold text-table font-serif font-semibold text-lg disabled:opacity-40 disabled:cursor-default hover:bg-gold-bright transition-colors"
|
||||||
>
|
>
|
||||||
{isHost ? (canStart ? 'Zacat hru' : `Caka sa na hracov (${players.length}/4)`) : 'Caka sa na hosta...'}
|
{isHost ? (canStart ? 'Začať hru' : `Čaká sa na hráčov (${players.length}/4)`) : 'Čaká sa na hostiteľa…'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export interface GameStatusDetail {
|
|||||||
active_stash?: StashData;
|
active_stash?: StashData;
|
||||||
previous_stash?: StashData;
|
previous_stash?: StashData;
|
||||||
standings: number[][][];
|
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 {
|
export interface GameStatusPayload {
|
||||||
@@ -70,10 +73,13 @@ export interface Registration {
|
|||||||
|
|
||||||
export interface HistoryGame {
|
export interface HistoryGame {
|
||||||
gid: string;
|
gid: string;
|
||||||
|
name: string;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
ended_at: string | null;
|
ended_at: string | null;
|
||||||
players: string[];
|
players: string[];
|
||||||
my_points: number;
|
my_points: number;
|
||||||
|
/** True = dohraná naplno; false = predčasne ukončená (dá sa obnoviť do lobby). */
|
||||||
|
completed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameDetailRound {
|
export interface GameDetailRound {
|
||||||
@@ -88,6 +94,7 @@ export interface GameDetailRound {
|
|||||||
|
|
||||||
export interface GameDetail {
|
export interface GameDetail {
|
||||||
gid: string;
|
gid: string;
|
||||||
|
name: string;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
ended_at: string | null;
|
ended_at: string | null;
|
||||||
players: { player_id: number; username: string }[];
|
players: { player_id: number; username: string }[];
|
||||||
|
|||||||
@@ -1,6 +1,63 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
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: [],
|
plugins: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
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: {
|
proxy: {
|
||||||
'/socket.io': {
|
'/socket.io': {
|
||||||
// Local dev defaults to localhost; docker-compose sets VITE_BACKEND_URL
|
// Local dev defaults to localhost; docker-compose sets VITE_BACKEND_URL
|
||||||
|
|||||||
+28
-2
@@ -143,15 +143,41 @@ class HistoryCase(unittest.TestCase):
|
|||||||
run(history.mark_game_ended(gid))
|
run(history.mark_game_ended(gid))
|
||||||
self.assertFalse(any(g["gid"] == gid for g in run(history.get_unfinished_games())))
|
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):
|
def test_standings_from_db(self):
|
||||||
ids = self._make_players()
|
ids = self._make_players()
|
||||||
gid = str(uuid.uuid4())
|
gid = str(uuid.uuid4())
|
||||||
run(history.record_game_started(gid, "Test", ids))
|
run(history.record_game_started(gid, "Test", ids))
|
||||||
run(history.record_completed_rounds(gid, make_core()))
|
run(history.record_completed_rounds(gid, make_core()))
|
||||||
|
|
||||||
standings = run(history.get_standings(gid))
|
standings, guesses = run(history.get_standings(gid))
|
||||||
# 1 seria, 1 kolo, body podla sedadiel zo stubu [12, 0, 10, 11].
|
# 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(standings, [[[12, 0, 10, 11]]])
|
||||||
|
self.assertEqual(guesses, [[[2, 1, 0, 1]]])
|
||||||
|
|
||||||
def test_player_history(self):
|
def test_player_history(self):
|
||||||
ids = self._make_players()
|
ids = self._make_players()
|
||||||
|
|||||||
Reference in New Issue
Block a user