Add React frontend and clean up legacy HTTP backend
This commit is contained in:
+5
-7
@@ -1,8 +1,6 @@
|
||||
__pycache__/
|
||||
.flaskenv
|
||||
/.vscode
|
||||
/logs
|
||||
/env-bridzik-dev
|
||||
/.venv
|
||||
*.png
|
||||
*.idea
|
||||
*.pyc
|
||||
.venv/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
|
||||
+28
-21
@@ -160,6 +160,19 @@ async def send_error(sid: str, message: str):
|
||||
await sio.emit("error", {"error": message}, to=sid)
|
||||
|
||||
|
||||
async def _mark_player_offline(game: "Game", player: "Player"):
|
||||
"""Mark player disconnected, delete the game if everyone left, else notify the room."""
|
||||
player.connected = False
|
||||
if not any(p.connected for p in game.players):
|
||||
del games[game.gid]
|
||||
else:
|
||||
await sio.emit(
|
||||
"player_connection",
|
||||
{"order": player.order, "connected": False},
|
||||
room=game.gid,
|
||||
)
|
||||
|
||||
|
||||
def _active_game(sid: str) -> "tuple[Game, dict] | None":
|
||||
"""Resolve the started game and seat for a connection, or None."""
|
||||
sess = sessions.get(sid)
|
||||
@@ -181,20 +194,12 @@ async def connect(sid, environ, auth=None):
|
||||
|
||||
@sio.event
|
||||
async def disconnect(sid):
|
||||
sessions.pop(sid, None)
|
||||
game = next((g for g in games.values() if g.player_by_sid(sid)), None)
|
||||
sess = sessions.pop(sid, None)
|
||||
game = games.get(sess["gid"]) if sess else None
|
||||
if game is not None:
|
||||
player = game.player_by_sid(sid)
|
||||
player.connected = False
|
||||
if not any(p.connected for p in game.players):
|
||||
# Everybody left — drop the game so it can't leak memory forever.
|
||||
del games[game.gid]
|
||||
else:
|
||||
await sio.emit(
|
||||
"player_connection",
|
||||
{"order": player.order, "connected": False},
|
||||
room=game.gid,
|
||||
)
|
||||
if player is not None:
|
||||
await _mark_player_offline(game, player)
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
@@ -256,14 +261,7 @@ async def leave_game(sid):
|
||||
# Game in progress: keep the seat (reconnect via token still works),
|
||||
# just mark the player offline.
|
||||
if player is not None:
|
||||
player.connected = False
|
||||
await sio.emit(
|
||||
"player_connection",
|
||||
{"order": player.order, "connected": False},
|
||||
room=game.gid,
|
||||
)
|
||||
if not any(p.connected for p in game.players):
|
||||
del games[game.gid]
|
||||
await _mark_player_offline(game, player)
|
||||
else:
|
||||
# Not started yet: free the seat entirely.
|
||||
if player is not None:
|
||||
@@ -275,6 +273,11 @@ async def leave_game(sid):
|
||||
|
||||
@sio.on("start_game")
|
||||
async def start_game(sid, gid):
|
||||
sess = sessions.get(sid)
|
||||
if sess is None or sess["gid"] != gid:
|
||||
return await send_error(sid, "Nie ste v tejto hre.")
|
||||
if sess["order"] != 0:
|
||||
return await send_error(sid, "Iba hostitel moze spustit hru.")
|
||||
game = games.get(gid)
|
||||
if game is None:
|
||||
return await send_error(sid, "Hra neexistuje.")
|
||||
@@ -299,6 +302,9 @@ async def reconnect_to_game(sid, gid, token):
|
||||
if player is None:
|
||||
return await send_error(sid, "Neplatny token pre pripojenie.")
|
||||
|
||||
old_sid = player.sid
|
||||
if old_sid != sid:
|
||||
sessions.pop(old_sid, None)
|
||||
player.sid = sid
|
||||
player.connected = True
|
||||
sessions[sid] = {"gid": gid, "order": player.order}
|
||||
@@ -371,4 +377,5 @@ async def play_card(sid, card_key):
|
||||
except BridzikException as exc:
|
||||
return await send_error(sid, str(exc))
|
||||
await send_game_status(game.gid)
|
||||
await send_player_cards(game.gid, sess["order"], sid)
|
||||
for player in game.players:
|
||||
await send_player_cards(game.gid, player.order, player.sid)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import SubmitField, IntegerField, RadioField, StringField
|
||||
from wtforms.validators import DataRequired, NumberRange
|
||||
|
||||
class GuessForm(FlaskForm):
|
||||
guess = IntegerField('Tip', validators=[DataRequired(message='Zadaj tip'), NumberRange(min=0, max=8)])
|
||||
submit = SubmitField('Zadaj tip')
|
||||
|
||||
def __init__(self, max_guess: int = 8, *args, **kwargs):
|
||||
super(GuessForm, self).__init__(*args, **kwargs)
|
||||
self.max_guess = max_guess
|
||||
|
||||
class PlayForm(FlaskForm):
|
||||
card = RadioField('Vyber kartu', validators=[DataRequired(message='Musíš vybrať kartu')])
|
||||
submit = SubmitField('Zahraj')
|
||||
|
||||
class AdminForm(FlaskForm):
|
||||
player0 = StringField('0', validators=[DataRequired()])
|
||||
player1 = StringField('1', validators=[DataRequired()])
|
||||
player2 = StringField('2', validators=[DataRequired()])
|
||||
player3 = StringField('3', validators=[DataRequired()])
|
||||
submit = SubmitField()
|
||||
@@ -1,85 +0,0 @@
|
||||
from api import app, bridzikInstance
|
||||
from bridzik import Card, Card_colors, Card_values, BridzikException
|
||||
import json
|
||||
from flask import render_template, url_for, flash, redirect
|
||||
from api.forms import GuessForm, PlayForm, AdminForm
|
||||
from api.utils import get_points_sums, sort_card_list
|
||||
|
||||
players = [
|
||||
'Jakub',
|
||||
'Timo',
|
||||
'Katka',
|
||||
'Ondrej'
|
||||
]
|
||||
|
||||
@app.route('/bridzik_api/get_status/<id>')
|
||||
def get_status(id: int):
|
||||
return json.dumps(bridzikInstance.get_status(int(id)), cls=Card.JSONEncoder)
|
||||
|
||||
@app.route('/bridzik/<player>/status')
|
||||
def status(player):
|
||||
player = int(player)
|
||||
game_status = bridzikInstance.get_status(player)
|
||||
action = None
|
||||
form = None
|
||||
player_cards = sort_card_list(bridzikInstance.series[-1].get_last_round().player_cards[player])
|
||||
game_status['player_cards'] = [str(c) for c in player_cards]
|
||||
points_sums = get_points_sums(game_status['standings'])
|
||||
if bridzikInstance.is_completed() or bridzikInstance.series[-1].get_last_round().get_active_player() != player:
|
||||
pass
|
||||
elif not bridzikInstance.series[-1].get_last_round().is_guessing_completed():
|
||||
form = GuessForm(max_guess= 8 - bridzikInstance.series[-1].get_last_round().round_number)
|
||||
action = 'guess'
|
||||
else:
|
||||
form = PlayForm()
|
||||
form.card.choices = [(str(c), str(c)) for c in player_cards]
|
||||
action = 'play'
|
||||
return render_template(
|
||||
'status.html', status=game_status, player=player, action=action,
|
||||
form=form, players=players, points_sums=points_sums
|
||||
)
|
||||
|
||||
@app.route('/bridzik/<player>/guess', methods=['POST'])
|
||||
def guess(player):
|
||||
player = int(player)
|
||||
form = GuessForm()
|
||||
try:
|
||||
bridzikInstance.add_player_guess(player, int(form.guess.data))
|
||||
except BridzikException:
|
||||
flash('Nie je možné zadať tip.')
|
||||
return redirect(url_for('status', player=player))
|
||||
|
||||
@app.route('/bridzik/<player>/play_card', methods=['POST'])
|
||||
def play_card(player):
|
||||
player = int(player)
|
||||
player_cards = bridzikInstance.series[-1].get_last_round().player_cards[player]
|
||||
form = PlayForm()
|
||||
form.card.choices = [(str(c), str(c)) for c in player_cards]
|
||||
color, value = form.card.data.split('_')
|
||||
try:
|
||||
card = Card(Card_colors[color], Card_values[value])
|
||||
except KeyError:
|
||||
flash('Chyba. Opakuj pokus znovu.')
|
||||
|
||||
try:
|
||||
bridzikInstance.play_card(player, card)
|
||||
except BridzikException:
|
||||
flash('Nie je možné zahrať kartu.')
|
||||
return redirect(url_for('status', player=player))
|
||||
|
||||
|
||||
@app.route('/bridzik/admin', methods=['GET', 'POST'])
|
||||
def admin():
|
||||
form = AdminForm()
|
||||
if form.validate_on_submit():
|
||||
players[0] = form.player0.data
|
||||
players[1] = form.player1.data
|
||||
players[2] = form.player2.data
|
||||
players[3] = form.player3.data
|
||||
return redirect(url_for('admin'))
|
||||
else:
|
||||
form.player0.data = players[0]
|
||||
form.player1.data = players[1]
|
||||
form.player2.data = players[2]
|
||||
form.player3.data = players[3]
|
||||
return render_template('admin.html', form=form)
|
||||
@@ -1,51 +0,0 @@
|
||||
#header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#header>div {
|
||||
padding: 10px;
|
||||
border: 2px solid grey;
|
||||
}
|
||||
|
||||
.state_row_header {
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.points {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 250px;
|
||||
grid-template-rows: 1fr;
|
||||
grid-gap: 10px;
|
||||
}
|
||||
|
||||
.card_display {
|
||||
width: 82px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
#points_summary_row {
|
||||
border-top: 4px double black;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.active_player_highlight {
|
||||
background: grey;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.player_name_highlight {
|
||||
background: grey;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.standings_round_final_row {
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<form action="{{ url_for('guess', player=player) }}" method="post">
|
||||
{{ form.hidden_tag() }}
|
||||
<p>
|
||||
{{ form.guess.label }}:
|
||||
{{ form.guess(size=15, autocomplete="off") }}
|
||||
{% for error in form.guess.errors %}
|
||||
<span style="color: red;">[{{ error }}]</span>
|
||||
{% endfor %}
|
||||
{{ form.submit() }}
|
||||
</p>
|
||||
</form>
|
||||
@@ -1,39 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Bridžik admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<form action="" method="post">
|
||||
{{ form.hidden_tag() }}
|
||||
<p>
|
||||
{{ form.player0.label }}:
|
||||
{{ form.player0(size=15) }}
|
||||
{% for error in form.player0.errors %}
|
||||
<span style="color: red;">[{{ error }}]</span>
|
||||
{% endfor %}
|
||||
</p>
|
||||
<p>
|
||||
{{ form.player1.label }}:
|
||||
{{ form.player1(size=15) }}
|
||||
{% for error in form.player1.errors %}
|
||||
<span style="color: red;">[{{ error }}]</span>
|
||||
{% endfor %}
|
||||
</p>
|
||||
<p>
|
||||
{{ form.player2.label }}:
|
||||
{{ form.player2(size=15) }}
|
||||
{% for error in form.player2.errors %}
|
||||
<span style="color: red;">[{{ error }}]</span>
|
||||
{% endfor %}
|
||||
</p>
|
||||
<p>
|
||||
{{ form.player3.label }}:
|
||||
{{ form.player3(size=15) }}
|
||||
{% for error in form.player3.errors %}
|
||||
<span style="color: red;">[{{ error }}]</span>
|
||||
{% endfor %}
|
||||
</p>
|
||||
<p>{{ form.submit() }}</p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,176 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Bridžik</title>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
{% if not action%}
|
||||
<meta http-equiv="refresh" content="2">
|
||||
{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<ul>
|
||||
{% for message in messages %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<hr>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% if 'active_player' not in status %}
|
||||
<h1>Hra sa skončila.</h1>
|
||||
{% else %}
|
||||
<div id="wrapper">
|
||||
<div id="table">
|
||||
<div id="header">
|
||||
<div {% if player == status['active_player'] %}class="active_player_highlight"{% endif %}>
|
||||
<p id="active_player" >Na ťahu je: {{ players[status['active_player']] }}</p>
|
||||
</div>
|
||||
|
||||
<div id="active_round_guesses">
|
||||
<p></p>
|
||||
<table style="width: auto;">
|
||||
<thead>
|
||||
<th></th>
|
||||
<th class="state_column_header">{{ players[0] }}</th>
|
||||
<th class="state_column_header">{{ players[1] }}</th>
|
||||
<th class="state_column_header">{{ players[2] }}</th>
|
||||
<th class="state_column_header">{{ players[3] }}</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="state_row_header">Tipy v tomto kole:</th>
|
||||
{% for player in range(4) %}
|
||||
<td class="points">{{ status['active_round_guesses'][player] }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
|
||||
{% if status['active_round_stashes'] %}
|
||||
<tr>
|
||||
<th class="state_row_header">Kôpky v tomto kole:</th>
|
||||
{% for player in status['active_round_stashes'] %}
|
||||
<td class="points">{{ player }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
{% if action == 'guess' %}
|
||||
<h1>Zadaj tip:</h1>
|
||||
{% include "_guess_form.html" %}
|
||||
{% endif %}
|
||||
|
||||
{% if status['active_stash'] %}
|
||||
<h1>Aktuálna kôpka:</h1>
|
||||
<table style="width: auto;">
|
||||
{% for player in range(status['active_stash']['first_player'], status['active_stash']['first_player'] + 4) %}
|
||||
<th>{{ players[player % 4] }}</th>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
{% for player in range(status['active_stash']['first_player'], status['active_stash']['first_player'] + 4) %}
|
||||
<td class="card_display">
|
||||
{% if status['active_stash']['cards'][player % 4] %}
|
||||
<img src="/static/cards/{{ status['active_stash']['cards'][player % 4] }}.png" width="80" height="auto">
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
{% if action != 'play' %}
|
||||
<h1>Moje karty:</h1>
|
||||
<table id="player_hand">
|
||||
{% for card in status['player_cards'] %}
|
||||
<td>
|
||||
<img src="/static/cards/{{ card }}.png" width="80" height="auto">
|
||||
</td>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
{% else %}
|
||||
<h1>{{ form.card.label }}:</h1>
|
||||
<form action="{{ url_for('play_card', player=player) }}" method="post">
|
||||
{{ form.hidden_tag() }}
|
||||
<p>
|
||||
<table style="width: auto">
|
||||
{% for card in status['player_cards'] %}
|
||||
<td>
|
||||
<img src="/static/cards/{{ card }}.png" width="80" height="auto">
|
||||
</td>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
{% for card in status['player_cards'] %}
|
||||
<td>
|
||||
<input id="card-{{ loop.index }}" name="card" type="radio" value="{{ card }}">
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</table>
|
||||
{% for error in form.card.errors %}
|
||||
<span style="color: red;">[{{ error }}]</span>
|
||||
{% endfor %}
|
||||
{{ form.submit() }}
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if status['previous_stash'] %}
|
||||
<h1>Predchádzajúca kôpka:</h1>
|
||||
<table style="width: auto;">
|
||||
{% for player in range(status['previous_stash']['first_player'], status['previous_stash']['first_player'] + 4) %}
|
||||
<th>{{ players[player % 4] }}</th>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
{% for player in range(status['previous_stash']['first_player'], status['previous_stash']['first_player'] + 4) %}
|
||||
<td class="card_display">
|
||||
{% if status['previous_stash']['cards'][player % 4] %}
|
||||
<img src="/static/cards/{{ status['previous_stash']['cards'][player % 4] }}.png" width="80" height="auto">
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<div id="standings">
|
||||
<h1>Výsledky</h1>
|
||||
<table style="width: auto;">
|
||||
<tr>
|
||||
<th>{{ players[0] }}</th>
|
||||
<th>{{ players[1] }}</th>
|
||||
<th>{{ players[2] }}</th>
|
||||
<th>{{ players[3] }}</th>
|
||||
</tr>
|
||||
{% for series in status['standings'] %}
|
||||
{% if series %}
|
||||
{% for round in series %}
|
||||
{% if round %}
|
||||
<tr {% if loop.index == 7 %}class="standings_round_final_row"{% endif %} >
|
||||
{% for player in round %}
|
||||
<td class="points">{{ player }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<tr id="points_summary_row">
|
||||
{% for player in points_sums %}
|
||||
<td class="points">{{ player }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,23 +0,0 @@
|
||||
from bridzik import Card_colors
|
||||
|
||||
def get_points_sums(standings: []):
|
||||
sums = [0] * 4
|
||||
for series in standings:
|
||||
for round in series:
|
||||
for player, points in enumerate(round):
|
||||
sums[player] += points
|
||||
return sums
|
||||
|
||||
def sort_card_list(input_card_set: []) -> []:
|
||||
color_paritions = [
|
||||
[c for c in input_card_set if c.color == Card_colors['HEARTS']],
|
||||
[c for c in input_card_set if c.color == Card_colors['LEAVES']],
|
||||
[c for c in input_card_set if c.color == Card_colors['ACORNS']],
|
||||
[c for c in input_card_set if c.color == Card_colors['BELLS']]
|
||||
|
||||
]
|
||||
output_list = []
|
||||
for color_list in color_paritions:
|
||||
color_list.sort(key=lambda a : a.value)
|
||||
output_list.extend(color_list)
|
||||
return output_list
|
||||
+13
-2
@@ -1,7 +1,18 @@
|
||||
services:
|
||||
api:
|
||||
backend:
|
||||
build: .
|
||||
ports:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
- ./:/app
|
||||
- ./:/app
|
||||
|
||||
frontend:
|
||||
image: node:22-alpine
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
ports:
|
||||
- "5173:5173"
|
||||
command: sh -c "npm install && npm run dev -- --host"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="sk">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bridzik</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+7115
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "bridzik-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-pwa": "^0.20.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 852 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,70 @@
|
||||
import { useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom';
|
||||
import { useGameStore } from './store/gameStore';
|
||||
import { socket, emit } from './lib/socket';
|
||||
import type { MyPlayer } from './types';
|
||||
import GameList from './pages/GameList';
|
||||
import Lobby from './pages/Lobby';
|
||||
import GameTable from './pages/GameTable';
|
||||
|
||||
function AppInner() {
|
||||
const navigate = useNavigate();
|
||||
const myPlayer = useGameStore((s) => s.myPlayer);
|
||||
const gameStatus = useGameStore((s) => s.gameStatus);
|
||||
const error = useGameStore((s) => s.error);
|
||||
const clearError = useGameStore((s) => s.clearError);
|
||||
|
||||
// Reconnect on every socket connect event (handles auto-reconnects after network drops)
|
||||
useEffect(() => {
|
||||
const doReconnect = () => {
|
||||
const saved = localStorage.getItem('bridzik_player');
|
||||
if (!saved) return;
|
||||
const player = JSON.parse(saved) as MyPlayer;
|
||||
emit.reconnectToGame(player.gid, player.token);
|
||||
};
|
||||
socket.on('connect', doReconnect);
|
||||
if (socket.connected) doReconnect();
|
||||
return () => { socket.off('connect', doReconnect); };
|
||||
}, []);
|
||||
|
||||
// Single authoritative navigation: derive target from store state
|
||||
const targetRoute = myPlayer
|
||||
? gameStatus ? `/game/${gameStatus.gid}` : `/lobby/${myPlayer.gid}`
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetRoute) return;
|
||||
navigate(targetRoute, { replace: true });
|
||||
}, [targetRoute, navigate]);
|
||||
|
||||
// Auto-dismiss errors after 4 s
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
const t = setTimeout(clearError, 4000);
|
||||
return () => clearTimeout(t);
|
||||
}, [error, clearError]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50 bg-red-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm max-w-xs text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Routes>
|
||||
<Route path="/" element={<GameList />} />
|
||||
<Route path="/lobby/:gid" element={<Lobby />} />
|
||||
<Route path="/game/:gid" element={<GameTable />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppInner />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Card } from '../types';
|
||||
|
||||
const SUIT_SYMBOL: Record<string, string> = {
|
||||
HEARTS: '♥',
|
||||
LEAVES: '♠',
|
||||
ACORNS: '♣',
|
||||
BELLS: '♦',
|
||||
};
|
||||
|
||||
const SUIT_COLOR: Record<string, string> = {
|
||||
HEARTS: '#c40000',
|
||||
LEAVES: '#1e7a1e',
|
||||
ACORNS: '#b87a00',
|
||||
BELLS: '#0087b8',
|
||||
};
|
||||
|
||||
const VALUE_LABEL: Record<string, string> = {
|
||||
C7: 'VII', C8: 'VIII', C9: 'IX', C10: 'X',
|
||||
LOWER: 'J', UPPER: 'Q', KING: 'K', ACE: 'A',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
selected?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export default function CardView({ card, onClick, disabled = false, selected = false, size = 'md' }: Props) {
|
||||
const symbol = SUIT_SYMBOL[card.color];
|
||||
const color = SUIT_COLOR[card.color];
|
||||
const label = VALUE_LABEL[card.value];
|
||||
|
||||
const dims = {
|
||||
sm: { cls: 'w-10 h-14', label: 9, iconSm: 9, iconLg: 18, inset: 2 },
|
||||
md: { cls: 'w-14 h-20', label: 11, iconSm: 11, iconLg: 26, inset: 3 },
|
||||
lg: { cls: 'w-20 h-28', label: 15, iconSm: 15, iconLg: 38, inset: 4 },
|
||||
}[size];
|
||||
|
||||
const interactive = !disabled && !!onClick;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
dims.cls,
|
||||
'relative bg-white border rounded-md shadow-sm transition-transform overflow-hidden',
|
||||
selected && !disabled ? 'border-yellow-400 -translate-y-2' : 'border-gray-300',
|
||||
disabled ? 'opacity-50 cursor-default' : '',
|
||||
interactive ? 'hover:-translate-y-1 cursor-pointer active:scale-95' : 'cursor-default',
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
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 style={{ color, fontSize: dims.iconLg, 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>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { emit } from '../lib/socket';
|
||||
|
||||
interface Props {
|
||||
cardsInRound: number;
|
||||
guesses: Record<string, number>;
|
||||
myOrder: number;
|
||||
activePlayer: number;
|
||||
activePlayerName: string;
|
||||
}
|
||||
|
||||
export default function GuessControls({ cardsInRound, guesses, myOrder, activePlayer, activePlayerName }: Props) {
|
||||
const guessCount = Object.keys(guesses).length;
|
||||
const isMyTurn = activePlayer === myOrder;
|
||||
const isLastToGuess = guessCount === 3;
|
||||
const alreadySum = Object.values(guesses).reduce((a, b) => a + b, 0);
|
||||
const forbidden = isLastToGuess ? cardsInRound - alreadySum : -1;
|
||||
|
||||
if (!isMyTurn) {
|
||||
return (
|
||||
<p className="text-gray-400 text-sm text-center py-2">
|
||||
Caka sa na tip: <span className="text-white font-semibold">{activePlayerName}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const options = Array.from({ length: cardsInRound + 1 }, (_, i) => i);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-2">
|
||||
<p className="text-sm text-gray-300">Tvoj tip (pocet kopok):</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{options.map((n) => {
|
||||
const isForbidden = n === forbidden;
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
disabled={isForbidden}
|
||||
onClick={() => emit.addGuess(n)}
|
||||
title={isForbidden ? 'Zakázaná hodnota (suma = počet kopok)' : undefined}
|
||||
className={[
|
||||
'w-10 h-10 rounded-lg font-bold text-lg border-2 transition-colors',
|
||||
isForbidden
|
||||
? 'border-red-700 text-red-700 opacity-40 cursor-not-allowed'
|
||||
: 'border-blue-400 text-blue-200 hover:bg-blue-600 hover:border-blue-600 active:scale-95',
|
||||
].join(' ')}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { CardColor, Hand } from '../types';
|
||||
import CardView from './CardView';
|
||||
import { emit } from '../lib/socket';
|
||||
|
||||
const COLOR_ORDER: CardColor[] = ['HEARTS', 'LEAVES', 'ACORNS', 'BELLS'];
|
||||
const VALUE_ORDER = ['C7', 'C8', 'C9', 'C10', 'LOWER', 'UPPER', 'KING', 'ACE'];
|
||||
|
||||
function groupedByColor(hand: Hand): { color: CardColor; keys: string[] }[] {
|
||||
return COLOR_ORDER
|
||||
.map((color) => ({
|
||||
color,
|
||||
keys: Object.keys(hand)
|
||||
.filter((k) => hand[k].color === color)
|
||||
.sort((a, b) => VALUE_ORDER.indexOf(hand[a].value) - VALUE_ORDER.indexOf(hand[b].value)),
|
||||
}))
|
||||
.filter((g) => g.keys.length > 0);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
hand: Hand;
|
||||
myTurn: boolean;
|
||||
isPlayPhase: boolean;
|
||||
playableKeys?: Set<string>;
|
||||
}
|
||||
|
||||
export default function Hand({ hand, myTurn, isPlayPhase, playableKeys }: Props) {
|
||||
const groups = groupedByColor(hand);
|
||||
|
||||
if (groups.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 justify-center py-2">
|
||||
{groups.map(({ color, keys }) => (
|
||||
<div key={color} className="flex gap-1">
|
||||
{keys.map((key) => {
|
||||
// During guessing phase cards are visible at full opacity (just not clickable).
|
||||
// Only dim cards during the play phase when they can't be played.
|
||||
const disabled = isPlayPhase && (!myTurn || (playableKeys !== undefined && !playableKeys.has(key)));
|
||||
return (
|
||||
<CardView
|
||||
key={key}
|
||||
card={hand[key]}
|
||||
disabled={disabled}
|
||||
onClick={() => emit.playCard(key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import { emit } from '../lib/socket';
|
||||
|
||||
interface Props {
|
||||
mode: 'create' | 'join';
|
||||
gid?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function NameModal({ mode, gid, onClose }: Props) {
|
||||
const [name, setName] = useState(localStorage.getItem('bridzik_name') ?? '');
|
||||
const myPlayer = useGameStore((s) => s.myPlayer);
|
||||
|
||||
// Close modal once we have a player identity
|
||||
useEffect(() => {
|
||||
if (myPlayer) onClose();
|
||||
}, [myPlayer, onClose]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
localStorage.setItem('bridzik_name', trimmed);
|
||||
|
||||
if (mode === 'join' && gid) {
|
||||
// emit.registerPlayer sets _pendingGid so the listener can resolve gid
|
||||
emit.registerPlayer(gid, trimmed);
|
||||
} else {
|
||||
// emit.createGame stores the name; the socket listener auto-chains registerPlayer
|
||||
emit.createGame(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 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">
|
||||
<h2 className="text-lg font-bold mb-4 text-white">Zadaj svoje meno</h2>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={20}
|
||||
placeholder="Tvoje meno"
|
||||
className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg text-gray-400 hover:text-white"
|
||||
>
|
||||
Zrusit
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim()}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-semibold disabled:opacity-40"
|
||||
>
|
||||
Potvrdit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function RulesModal({ onClose }: Props) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-black/70 p-4 overflow-y-auto"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative bg-slate-900 rounded-2xl w-full max-w-lg my-6 p-6 text-sm leading-relaxed"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-white text-xl leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<h1 className="text-xl font-bold mb-4">Pravidlá hry Bridžik</h1>
|
||||
|
||||
<Section title="Karty">
|
||||
<p>Hrá sa s <b>32-kartovým balíčkom</b> sedmových (slovenských/nemeckých) kariet.</p>
|
||||
<p className="mt-2"><b>Farby:</b> červeň (♥), zeleň (♠), žaluď (♣), guľa (♦)</p>
|
||||
<p className="mt-1 text-red-400 font-semibold">Červeň je vždy tromf (adut) — prebíja každú inú farbu.</p>
|
||||
<p className="mt-2"><b>Hodnoty</b> od najnižšej: VII · VIII · IX · X · J · Q · K · A</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Štruktúra hry">
|
||||
<p>4 hráči · 4 série · 8 kôl v sérii</p>
|
||||
<p className="mt-1">V každom kole dostane každý hráč <b>8 − číslo_kola</b> kariet (8 až 1).</p>
|
||||
<p className="mt-1">Sériu otvára hráč s rovnakým číslom ako séria. Každé ďalšie kolo posúva začínajúceho hráča o jedného.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Priebeh kola">
|
||||
<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 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="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="font-semibold mt-3">Povinnosť priznať farbu:</p>
|
||||
<ol className="mt-1 list-decimal list-inside space-y-1">
|
||||
<li>Máš farbu vynesenej karty → <b>musíš ju zahrať.</b></li>
|
||||
<li>Nemáš ju, ale máš červeň → <b>musíš zahrať červeň.</b></li>
|
||||
<li>Nemáš ani jedno → môžeš zahrať <b>ľubovoľnú</b> kartu.</li>
|
||||
</ol>
|
||||
|
||||
<p className="font-semibold mt-3">Víťaz kopky:</p>
|
||||
<ul className="mt-1 list-disc list-inside space-y-1">
|
||||
<li>Ak padla červeň → vyhráva <b>najvyššia červeň.</b></li>
|
||||
<li>Ak nie → vyhráva <b>najvyššia karta vynesenej farby.</b></li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<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 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-2">Vyhráva hráč s najvyšším celkovým súčtom po 4 sériách.</p>
|
||||
</Section>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-4 w-full py-2.5 rounded-xl bg-slate-700 hover:bg-slate-600 font-semibold"
|
||||
>
|
||||
Zavrieť
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h2 className="font-bold text-base text-green-400 mb-1">{title}</h2>
|
||||
<div className="text-gray-200">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import type { PlayerInfo } from '../types';
|
||||
import { computeTotal } from '../lib/standings';
|
||||
|
||||
interface Props {
|
||||
standings: number[][][];
|
||||
players: PlayerInfo[];
|
||||
}
|
||||
|
||||
export default function Standings({ standings, players }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const sorted = [...players]
|
||||
.map((p) => ({ ...p, total: computeTotal(standings, p.order) }))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800 rounded-xl overflow-hidden">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<span>Skore</span>
|
||||
<span>{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { StashData } from '../types';
|
||||
import CardView from './CardView';
|
||||
|
||||
interface Props {
|
||||
stash: StashData | null;
|
||||
}
|
||||
|
||||
export default function Trick({ stash }: Props) {
|
||||
const cards = stash
|
||||
? [0, 1, 2, 3]
|
||||
.map((i) => (stash.first_player + i) % 4)
|
||||
.map((order) => stash.cards[String(order)])
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="bg-green-900/60 rounded-xl p-4">
|
||||
<p className="text-xs text-green-300 mb-3 text-center">Aktualny stich</p>
|
||||
<div className="flex gap-3 justify-center min-h-28">
|
||||
{cards.map((card, i) => (
|
||||
<CardView key={i} card={card} size="lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-slate-900 text-white min-h-screen;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { CardColor, Hand } from '../types';
|
||||
|
||||
export function computePlayable(hand: Hand, ledColor: CardColor | null): Set<string> {
|
||||
const keys = Object.keys(hand);
|
||||
if (!ledColor) return new Set(keys);
|
||||
|
||||
const ledKeys = keys.filter((k) => hand[k].color === ledColor);
|
||||
if (ledKeys.length > 0) return new Set(ledKeys);
|
||||
|
||||
const heartKeys = keys.filter((k) => hand[k].color === 'HEARTS');
|
||||
if (heartKeys.length > 0) return new Set(heartKeys);
|
||||
|
||||
return new Set(keys);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NavigateFunction } from 'react-router-dom';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import { emit } from './socket';
|
||||
|
||||
export function leaveGame(navigate: NavigateFunction) {
|
||||
emit.leaveGame();
|
||||
useGameStore.getState().reset();
|
||||
localStorage.removeItem('bridzik_player');
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { io } from 'socket.io-client';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import type { GameInfo, GameStatusPayload, Hand, MyPlayer } from '../types';
|
||||
|
||||
export const socket = io({ autoConnect: false });
|
||||
|
||||
// Module-level state for the create→register chain and gid resolution on register_player
|
||||
let _pendingGid: string | null = null;
|
||||
let _createName: string | null = null;
|
||||
|
||||
export const emit = {
|
||||
createGame: (name: string) => {
|
||||
_createName = name;
|
||||
socket.emit('create_game', name);
|
||||
},
|
||||
registerPlayer: (gid: string, name: string) => {
|
||||
_pendingGid = gid;
|
||||
socket.emit('register_player', gid, name);
|
||||
},
|
||||
leaveGame: () => socket.emit('leave_game'),
|
||||
startGame: (gid: string) => socket.emit('start_game', gid),
|
||||
reconnectToGame: (gid: string, token: string) => socket.emit('reconnect_to_game', gid, token),
|
||||
gameStatus: () => socket.emit('game_status'),
|
||||
playerCards: () => socket.emit('player_cards'),
|
||||
addGuess: (guess: number) => socket.emit('add_guess', guess),
|
||||
playCard: (cardKey: string) => socket.emit('play_card', cardKey),
|
||||
};
|
||||
|
||||
export function setupSocketListeners() {
|
||||
socket.on('get_games', ({ games }: { games: GameInfo[] }) => {
|
||||
useGameStore.getState().setGames(games);
|
||||
});
|
||||
|
||||
socket.on('create_game', ({ gid }: { gid: string }) => {
|
||||
// Auto-chain: register into the just-created game using the stored name
|
||||
if (_createName !== null) {
|
||||
emit.registerPlayer(gid, _createName);
|
||||
_createName = null;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(
|
||||
'register_player',
|
||||
({ player, token }: { player: { order: number; name: string }; token: string }) => {
|
||||
const saved = localStorage.getItem('bridzik_player');
|
||||
const gid = _pendingGid ?? (saved ? (JSON.parse(saved) as MyPlayer).gid : '');
|
||||
_pendingGid = null;
|
||||
const myPlayer: MyPlayer = { ...player, token, gid };
|
||||
useGameStore.getState().setMyPlayer(myPlayer);
|
||||
localStorage.setItem('bridzik_player', JSON.stringify(myPlayer));
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('game_status', (payload: GameStatusPayload) => {
|
||||
useGameStore.getState().setGameStatus(payload);
|
||||
});
|
||||
|
||||
socket.on('player_cards', ({ cards }: { cards: Hand }) => {
|
||||
useGameStore.getState().setHand(cards);
|
||||
});
|
||||
|
||||
socket.on('player_connection', ({ order, connected }: { order: number; connected: boolean }) => {
|
||||
useGameStore.getState().updatePlayerConnection(order, connected);
|
||||
});
|
||||
|
||||
socket.on('error', ({ error }: { error: string }) => {
|
||||
useGameStore.getState().setError(error);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function computeTotal(standings: number[][][], playerOrder: number): number {
|
||||
return standings.flat().reduce((sum, round) => sum + (round[playerOrder] ?? 0), 0);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import { setupSocketListeners, socket } from './lib/socket';
|
||||
|
||||
setupSocketListeners();
|
||||
socket.connect();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import NameModal from '../components/NameModal';
|
||||
import RulesModal from '../components/RulesModal';
|
||||
|
||||
type ModalState = { mode: 'create' } | { mode: 'join'; gid: string } | null;
|
||||
|
||||
export default function GameList() {
|
||||
const games = useGameStore((s) => s.games);
|
||||
const [modal, setModal] = useState<ModalState>(null);
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-4 pt-8">
|
||||
<h1 className="text-2xl font-bold text-center mb-6 tracking-wide">Bridzik</h1>
|
||||
|
||||
<div className="flex flex-col gap-3 mb-6">
|
||||
{games.length === 0 && (
|
||||
<p className="text-center text-gray-500 py-4">Ziadne hry. Vytvor prvu!</p>
|
||||
)}
|
||||
{games.map((g) => {
|
||||
const full = g.players.length >= 4;
|
||||
const unavailable = full || g.started;
|
||||
return (
|
||||
<div
|
||||
key={g.gid}
|
||||
className="flex items-center justify-between bg-slate-800 rounded-xl px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold">{g.name}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{g.players.length}/4 hracov
|
||||
{g.started ? ' · zacata' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
disabled={unavailable}
|
||||
onClick={() => setModal({ mode: 'join', gid: g.gid })}
|
||||
className="px-4 py-1.5 rounded-lg text-sm font-semibold bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-default"
|
||||
>
|
||||
{full ? 'Plna' : g.started ? 'Zacata' : 'Vstup'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setModal({ mode: 'create' })}
|
||||
className="w-full py-3 rounded-xl bg-green-700 hover:bg-green-600 font-bold text-lg"
|
||||
>
|
||||
+ Vytvorit novu hru
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowRules(true)}
|
||||
className="w-full py-2 rounded-xl bg-slate-700 hover:bg-slate-600 text-sm text-gray-300"
|
||||
>
|
||||
Pravidlá hry
|
||||
</button>
|
||||
|
||||
{showRules && <RulesModal onClose={() => setShowRules(false)} />}
|
||||
|
||||
{modal && (
|
||||
<NameModal
|
||||
mode={modal.mode}
|
||||
gid={modal.mode === 'join' ? modal.gid : undefined}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { PlayerInfo } from '../types';
|
||||
import { computeTotal } from '../lib/standings';
|
||||
import { leaveGame } from '../lib/leaveGame';
|
||||
|
||||
interface Props {
|
||||
players: PlayerInfo[];
|
||||
standings: number[][][];
|
||||
}
|
||||
|
||||
export default function GameOver({ players, standings }: Props) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const totals = players
|
||||
.map((p) => ({ ...p, total: computeTotal(standings, p.order) }))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
const handleLeave = () => leaveGame(navigate);
|
||||
|
||||
const medals = ['🥇', '🥈', '🥉', ''];
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-4 pt-12 flex flex-col items-center gap-6">
|
||||
<h1 className="text-3xl font-bold">Koniec hry!</h1>
|
||||
<div className="bg-slate-800 rounded-2xl w-full overflow-hidden">
|
||||
{totals.map((p, i) => (
|
||||
<div
|
||||
key={p.order}
|
||||
className="flex items-center justify-between px-5 py-3 border-b border-slate-700 last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl w-8">{medals[i]}</span>
|
||||
<span className="font-semibold">{p.name}</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-yellow-300">{p.total}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLeave}
|
||||
className="w-full py-3 rounded-xl bg-blue-600 hover:bg-blue-500 font-bold text-lg"
|
||||
>
|
||||
Domov
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import { leaveGame } from '../lib/leaveGame';
|
||||
import { computePlayable } from '../lib/gameRules';
|
||||
import Hand from '../components/Hand';
|
||||
import GuessControls from '../components/GuessControls';
|
||||
import Trick from '../components/Trick';
|
||||
import Standings from '../components/Standings';
|
||||
import GameOver from './GameOver';
|
||||
import type { StashData } from '../types';
|
||||
|
||||
const TRICK_LINGER_MS = 3000;
|
||||
|
||||
export default function GameTable() {
|
||||
const navigate = useNavigate();
|
||||
const myPlayer = useGameStore((s) => s.myPlayer);
|
||||
const gameStatus = useGameStore((s) => s.gameStatus);
|
||||
const hand = useGameStore((s) => s.hand);
|
||||
|
||||
// Hold the last completed trick visible for TRICK_LINGER_MS after it finishes.
|
||||
const [lingeredStash, setLingeredStash] = useState<StashData | null>(null);
|
||||
const lingerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const previousStash = gameStatus?.status.previous_stash ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!previousStash) return;
|
||||
setLingeredStash(previousStash);
|
||||
if (lingerTimer.current) clearTimeout(lingerTimer.current);
|
||||
lingerTimer.current = setTimeout(() => setLingeredStash(null), TRICK_LINGER_MS);
|
||||
return () => {
|
||||
if (lingerTimer.current) clearTimeout(lingerTimer.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gameStatus?.status.previous_stash?.first_player, JSON.stringify(gameStatus?.status.previous_stash?.cards)]);
|
||||
|
||||
if (!gameStatus || !myPlayer) {
|
||||
return <p className="text-center text-gray-400 pt-20">Nacitava sa...</p>;
|
||||
}
|
||||
|
||||
const { completed, players, series_number, round_number, cards_in_round, status } = gameStatus;
|
||||
const {
|
||||
active_player,
|
||||
active_round_guesses,
|
||||
active_round_stashes,
|
||||
active_stash,
|
||||
standings = [],
|
||||
} = status;
|
||||
|
||||
if (completed) {
|
||||
return <GameOver players={players} standings={standings} />;
|
||||
}
|
||||
|
||||
const myOrder = myPlayer.order;
|
||||
const isPlayPhase = active_stash !== undefined;
|
||||
const myTurnToPlay = isPlayPhase && active_player === myOrder;
|
||||
|
||||
// Show active stash if it has cards; otherwise show the lingered completed trick.
|
||||
const activeCards = active_stash ? Object.keys(active_stash.cards).length : 0;
|
||||
const displayedStash = activeCards > 0 ? active_stash : lingeredStash ?? undefined;
|
||||
|
||||
// Highlight only cards the engine would accept
|
||||
const playableKeys = myTurnToPlay && active_stash
|
||||
? computePlayable(hand, active_stash.cards[String(active_stash.first_player)]?.color ?? null)
|
||||
: undefined;
|
||||
|
||||
const activePlayerName = players.find((p) => p.order === active_player)?.name ?? '';
|
||||
|
||||
const handleLeave = () => leaveGame(navigate);
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto p-3 flex flex-col gap-3 pb-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-400">
|
||||
<span>Seria {series_number} / Kolo {round_number + 1}</span>
|
||||
<span className="ml-2 text-gray-500">({cards_in_round} kopok)</span>
|
||||
</div>
|
||||
<button onClick={handleLeave} className="text-xs text-gray-500 hover:text-gray-300">
|
||||
Odist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Turn indicator */}
|
||||
<div className="bg-slate-800/60 rounded-lg px-3 py-2 text-sm text-center">
|
||||
{active_player === myOrder ? (
|
||||
<span className="text-yellow-300 font-semibold">
|
||||
{!isPlayPhase ? 'Zadaj svoj tip' : 'Zahraj kartu'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">
|
||||
Na rade: <span className="text-white">{activePlayerName}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Guesses summary (always shown when available) */}
|
||||
{active_round_guesses && (
|
||||
<div className="bg-slate-800/60 rounded-lg px-3 py-2">
|
||||
<p className="text-xs text-gray-400 mb-1">Tipy:</p>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{players.map((p) => {
|
||||
const guess = active_round_guesses[String(p.order)];
|
||||
const wins = active_round_stashes?.[p.order] ?? 0;
|
||||
return (
|
||||
<span key={p.order} className="text-sm">
|
||||
<span className="text-gray-300">{p.name}:</span>{' '}
|
||||
{guess !== undefined ? (
|
||||
<span className="text-white font-semibold">
|
||||
{isPlayPhase ? `${wins}/${guess}` : guess}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500">?</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active stash (trick) — container always visible during play phase, cards linger 3 s */}
|
||||
{isPlayPhase && (
|
||||
<Trick stash={displayedStash ?? null} />
|
||||
)}
|
||||
|
||||
{/* Guess phase controls */}
|
||||
{!isPlayPhase && active_round_guesses !== undefined && active_player !== undefined && (
|
||||
<GuessControls
|
||||
cardsInRound={cards_in_round}
|
||||
guesses={active_round_guesses}
|
||||
myOrder={myOrder}
|
||||
activePlayer={active_player}
|
||||
activePlayerName={activePlayerName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hand */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1 text-center">Tvoje karty</p>
|
||||
<Hand hand={hand} myTurn={myTurnToPlay} isPlayPhase={isPlayPhase} playableKeys={playableKeys} />
|
||||
</div>
|
||||
|
||||
{/* Standings */}
|
||||
<Standings standings={standings} players={players} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import { emit } from '../lib/socket';
|
||||
|
||||
export default function Lobby() {
|
||||
const { gid } = useParams<{ gid: string }>();
|
||||
const navigate = useNavigate();
|
||||
const myPlayer = useGameStore((s) => s.myPlayer);
|
||||
const games = useGameStore((s) => s.games);
|
||||
|
||||
const game = games.find((g) => g.gid === gid);
|
||||
const players = game?.players ?? [];
|
||||
const isHost = myPlayer?.order === 0;
|
||||
const canStart = players.length === 4 && isHost;
|
||||
|
||||
const handleLeave = () => {
|
||||
emit.leaveGame();
|
||||
useGameStore.getState().reset();
|
||||
localStorage.removeItem('bridzik_player');
|
||||
navigate('/', { replace: true });
|
||||
};
|
||||
|
||||
const handleCopyCode = () => {
|
||||
if (gid) navigator.clipboard.writeText(gid);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-4 pt-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-xl font-bold">{game?.name ?? 'Hra'}</h1>
|
||||
<button onClick={handleLeave} className="text-sm text-gray-400 hover:text-white">
|
||||
Odist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800 rounded-xl p-4 mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">Kod hry</p>
|
||||
<p className="font-mono text-sm text-gray-200 break-all">{gid}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopyCode}
|
||||
className="ml-3 px-3 py-1 rounded-lg text-sm bg-slate-700 hover:bg-slate-600"
|
||||
>
|
||||
Kopirovat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800 rounded-xl p-4 mb-6 flex flex-col gap-3">
|
||||
{[0, 1, 2, 3].map((order) => {
|
||||
const p = players.find((pl) => pl.order === order);
|
||||
return (
|
||||
<div key={order} className="flex items-center gap-3">
|
||||
<span className={`text-lg ${p ? 'text-green-400' : 'text-gray-600'}`}>
|
||||
{p ? '✓' : '○'}
|
||||
</span>
|
||||
<span className={p ? 'text-white' : 'text-gray-500 italic'}>
|
||||
{p ? `${p.name}${myPlayer?.order === p.order ? ' (ty)' : ''}` : 'Caka sa...'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={!canStart}
|
||||
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"
|
||||
>
|
||||
{isHost ? (canStart ? 'Zacat hru' : `Caka sa na hracov (${players.length}/4)`) : 'Caka sa na hosta...'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { create } from 'zustand';
|
||||
import type { GameInfo, GameStatusPayload, Hand, MyPlayer } from '../types';
|
||||
|
||||
interface GameStore {
|
||||
games: GameInfo[];
|
||||
myPlayer: MyPlayer | null;
|
||||
gameStatus: GameStatusPayload | null;
|
||||
hand: Hand;
|
||||
error: string | null;
|
||||
|
||||
setGames: (games: GameInfo[]) => void;
|
||||
setMyPlayer: (player: MyPlayer | null) => void;
|
||||
setGameStatus: (status: GameStatusPayload) => void;
|
||||
setHand: (hand: Hand) => void;
|
||||
setError: (error: string | null) => void;
|
||||
clearError: () => void;
|
||||
updatePlayerConnection: (order: number, connected: boolean) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useGameStore = create<GameStore>((set) => ({
|
||||
games: [],
|
||||
myPlayer: null,
|
||||
gameStatus: null,
|
||||
hand: {},
|
||||
error: null,
|
||||
|
||||
setGames: (games) => set({ games }),
|
||||
setMyPlayer: (myPlayer) => set({ myPlayer }),
|
||||
setGameStatus: (gameStatus) => set({ gameStatus }),
|
||||
setHand: (hand) => set({ hand }),
|
||||
setError: (error) => set({ error }),
|
||||
clearError: () => set({ error: null }),
|
||||
updatePlayerConnection: (order, connected) =>
|
||||
set((state) => ({
|
||||
gameStatus: state.gameStatus
|
||||
? {
|
||||
...state.gameStatus,
|
||||
players: state.gameStatus.players.map((p) =>
|
||||
p.order === order ? { ...p, connected } : p
|
||||
),
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
reset: () => set({ myPlayer: null, gameStatus: null, hand: {}, error: null }),
|
||||
}));
|
||||
@@ -0,0 +1,53 @@
|
||||
export type CardColor = 'HEARTS' | 'LEAVES' | 'ACORNS' | 'BELLS';
|
||||
export type CardValue = 'C7' | 'C8' | 'C9' | 'C10' | 'LOWER' | 'UPPER' | 'KING' | 'ACE';
|
||||
|
||||
export interface Card {
|
||||
color: CardColor;
|
||||
value: CardValue;
|
||||
}
|
||||
|
||||
export interface PlayerInfo {
|
||||
order: number;
|
||||
name: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface MyPlayer {
|
||||
order: number;
|
||||
name: string;
|
||||
token: string;
|
||||
gid: string;
|
||||
}
|
||||
|
||||
export interface StashData {
|
||||
first_player: number;
|
||||
cards: Record<string, Card>;
|
||||
}
|
||||
|
||||
export interface GameStatusDetail {
|
||||
active_player?: number;
|
||||
active_round_guesses?: Record<string, number>;
|
||||
active_round_stashes?: number[];
|
||||
active_stash?: StashData;
|
||||
previous_stash?: StashData;
|
||||
standings: number[][][];
|
||||
}
|
||||
|
||||
export interface GameStatusPayload {
|
||||
gid: string;
|
||||
completed: boolean;
|
||||
players: PlayerInfo[];
|
||||
series_number: number;
|
||||
round_number: number;
|
||||
cards_in_round: number;
|
||||
status: GameStatusDetail;
|
||||
}
|
||||
|
||||
export interface GameInfo {
|
||||
gid: string;
|
||||
name: string;
|
||||
started: boolean;
|
||||
players: PlayerInfo[];
|
||||
}
|
||||
|
||||
export type Hand = Record<string, Card>;
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'Bridzik',
|
||||
short_name: 'Bridzik',
|
||||
theme_color: '#1e3a5f',
|
||||
background_color: '#0f172a',
|
||||
display: 'standalone',
|
||||
icons: [
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
host: true,
|
||||
proxy: {
|
||||
'/socket.io': {
|
||||
target: 'http://backend:5000',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user