feat: skryté multiplayerové minihry přes websocket
CI / Generate TypeScript types (push) Canceled after 7s
CI / Server unit tests (push) Canceled after 0s
CI / Build server (push) Canceled after 0s
CI / Build client (push) Canceled after 0s
CI / Playwright E2E tests (push) Canceled after 0s
CI / Build and push Docker image (push) Canceled after 0s
CI / Notify (push) Canceled after 0s
CI / Generate TypeScript types (push) Canceled after 7s
CI / Server unit tests (push) Canceled after 0s
CI / Build server (push) Canceled after 0s
CI / Build client (push) Canceled after 0s
CI / Playwright E2E tests (push) Canceled after 0s
CI / Build and push Docker image (push) Canceled after 0s
CI / Notify (push) Canceled after 0s
Minihry se otevírají trojklikem na logo a napsáním kódu hry
("help" vypíše seznam do konzole prohlížeče):
- reakce: reakční hra, 3 kola, pořadí podle součtu časů
- snake: had pro více hráčů, simulaci drží server
- tetris: battle Tetris s odpadními řádky a lokálním tréninkem
- pong: Pong 1v1, ovládání myší i klávesami, bot se 4 obtížnostmi
- kresleni: kreslení a hádání jídel z dnešních menu
Ikony her se řídí zvoleným vzhledem. Stav her je v paměti serveru,
předpokládá se jedna replika.
This commit is contained in:
28 files changed
+5050
-2
No files matched your search
@@ -0,0 +1,145 @@
|
|||||||
|
// Pozvánka Kreslení nad pozvánkami ostatních her, aby se nepřekrývaly
|
||||||
|
.duel-invite.draw-invite {
|
||||||
|
bottom: 224px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-game-modal {
|
||||||
|
.draw-topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-hint {
|
||||||
|
// Mezery mezi slovy v masce se nesmí slít, jinak nejde poznat počet slov
|
||||||
|
white-space: pre;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-word strong {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-timer.urgent {
|
||||||
|
color: #ef4444;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-canvas-col {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
touch-action: none;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-tools {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-color {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(127, 127, 127, 0.5);
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
outline: 3px solid var(--luncher-primary, #198754);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-size {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(127, 127, 127, 0.5);
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
outline: 3px solid var(--luncher-primary, #198754);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-sep {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-side {
|
||||||
|
flex: 0 0 260px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-scores {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guessed {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-chat {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid rgba(127, 127, 127, 0.35);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
.system {
|
||||||
|
color: #22c55e;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-players {
|
||||||
|
columns: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, Form, Modal, Table } from "react-bootstrap";
|
||||||
|
import { socket } from "../context/socket";
|
||||||
|
import { useAuth } from "../context/auth";
|
||||||
|
import { getToken } from "../Utils";
|
||||||
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
|
import "./DrawingGame.scss";
|
||||||
|
|
||||||
|
// Typy musí odpovídat server/src/drawingGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
||||||
|
type DrawPhase = 'idle' | 'lobby' | 'drawing' | 'reveal' | 'finished';
|
||||||
|
type DrawPlayer = { login: string, score: number, guessed: boolean, place?: number };
|
||||||
|
type ChatMessage = { login?: string, text: string, system?: boolean };
|
||||||
|
type Stroke = { color: string, size: number, points: [number, number][], start: boolean };
|
||||||
|
type DrawState = {
|
||||||
|
phase: DrawPhase,
|
||||||
|
host?: string,
|
||||||
|
players: DrawPlayer[],
|
||||||
|
drawer?: string,
|
||||||
|
round: number,
|
||||||
|
totalRounds: number,
|
||||||
|
hint?: string,
|
||||||
|
answer?: string,
|
||||||
|
remainingMs?: number,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
minPlayers: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPEN_EVENT = 'luncher:open-drawing';
|
||||||
|
|
||||||
|
/** Otevře okno Kreslení. */
|
||||||
|
export const openDrawingGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
|
||||||
|
|
||||||
|
const CANVAS_W = 800;
|
||||||
|
const CANVAS_H = 600;
|
||||||
|
/** Souřadnice tahů jsou 0–1000 v obou osách, nezávisle na velikosti plátna. */
|
||||||
|
const COORD_MAX = 1000;
|
||||||
|
const COLORS = ['#111827', '#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#a855f7', '#92400e', '#ffffff'];
|
||||||
|
const SIZES = [3, 8, 18];
|
||||||
|
/** Jak často se rozkreslený tah posílá ostatním (ms). */
|
||||||
|
const FLUSH_MS = 40;
|
||||||
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
|
|
||||||
|
function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke) {
|
||||||
|
const sx = CANVAS_W / COORD_MAX;
|
||||||
|
const sy = CANVAS_H / COORD_MAX;
|
||||||
|
ctx.strokeStyle = stroke.color;
|
||||||
|
ctx.fillStyle = stroke.color;
|
||||||
|
ctx.lineWidth = stroke.size;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
const [first, ...rest] = stroke.points;
|
||||||
|
if (rest.length === 0) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(first[0] * sx, first[1] * sy, stroke.size / 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(first[0] * sx, first[1] * sy);
|
||||||
|
rest.forEach(([x, y]) => ctx.lineTo(x * sx, y * sy));
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCanvas(ctx: CanvasRenderingContext2D) {
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kreslení — jeden kreslí jídlo z dnešních menu, ostatní hádají v chatu. */
|
||||||
|
export default function DrawingGame() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const login = auth?.login;
|
||||||
|
const [state, setState] = useState<DrawState | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [word, setWord] = useState<string | null>(null);
|
||||||
|
const [color, setColor] = useState(COLORS[0]);
|
||||||
|
const [size, setSize] = useState(SIZES[1]);
|
||||||
|
const [guess, setGuess] = useState('');
|
||||||
|
const [secondsLeft, setSecondsLeft] = useState(0);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const chatRef = useRef<HTMLDivElement>(null);
|
||||||
|
const prevPhase = useRef<DrawPhase>('idle');
|
||||||
|
/** Rozkreslený tah: body čekající na odeslání a poslední odeslaný bod pro navázání. */
|
||||||
|
const pending = useRef<{ points: [number, number][], start: boolean } | null>(null);
|
||||||
|
const drawingRef = useRef(false);
|
||||||
|
const icon = useGameIcon('drawing');
|
||||||
|
|
||||||
|
const send = useCallback((event: string, extra?: object) => {
|
||||||
|
setError(null);
|
||||||
|
socket.emit(event, { token: getToken(), ...extra });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ctx = () => canvasRef.current?.getContext('2d') ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onState = (s: DrawState) => setState(s);
|
||||||
|
const onError = (msg: string) => setError(msg);
|
||||||
|
const onOpen = () => setOpen(true);
|
||||||
|
const sync = () => socket.emit('draw:sync');
|
||||||
|
socket.on('draw:state', onState);
|
||||||
|
socket.on('draw:error', onError);
|
||||||
|
socket.on('connect', sync);
|
||||||
|
window.addEventListener(OPEN_EVENT, onOpen);
|
||||||
|
sync();
|
||||||
|
return () => {
|
||||||
|
socket.off('draw:state', onState);
|
||||||
|
socket.off('draw:error', onError);
|
||||||
|
socket.off('connect', sync);
|
||||||
|
window.removeEventListener(OPEN_EVENT, onOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isDrawer = !!login && state?.phase === 'drawing' && state.drawer === login;
|
||||||
|
const isDrawerRef = useRef(isDrawer);
|
||||||
|
isDrawerRef.current = isDrawer;
|
||||||
|
|
||||||
|
// Kresba a slovo chodí jen klientům s otevřeným oknem
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const watch = () => socket.emit('draw:watch', { token: getToken() });
|
||||||
|
const onStrokes = (strokes: Stroke[]) => {
|
||||||
|
const c = ctx();
|
||||||
|
if (!c) return;
|
||||||
|
clearCanvas(c);
|
||||||
|
strokes.forEach(s => drawStroke(c, s));
|
||||||
|
};
|
||||||
|
// Vlastní tahy kreslíř vykresluje hned lokálně
|
||||||
|
const onStroke = (s: Stroke) => {
|
||||||
|
const c = ctx();
|
||||||
|
if (c && !isDrawerRef.current) drawStroke(c, s);
|
||||||
|
};
|
||||||
|
const onClear = () => {
|
||||||
|
const c = ctx();
|
||||||
|
if (c) clearCanvas(c);
|
||||||
|
};
|
||||||
|
const onWord = (w: string) => setWord(w);
|
||||||
|
socket.on('draw:strokes', onStrokes);
|
||||||
|
socket.on('draw:stroke', onStroke);
|
||||||
|
socket.on('draw:clear', onClear);
|
||||||
|
socket.on('draw:word', onWord);
|
||||||
|
socket.on('connect', watch);
|
||||||
|
watch();
|
||||||
|
return () => {
|
||||||
|
socket.off('draw:strokes', onStrokes);
|
||||||
|
socket.off('draw:stroke', onStroke);
|
||||||
|
socket.off('draw:clear', onClear);
|
||||||
|
socket.off('draw:word', onWord);
|
||||||
|
socket.off('connect', watch);
|
||||||
|
socket.emit('draw:unwatch');
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
|
||||||
|
const phase = state?.phase ?? 'idle';
|
||||||
|
const inGame = phase === 'drawing' || phase === 'reveal';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevPhase.current === 'lobby' && phase === 'drawing' && isPlayer) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
prevPhase.current = phase;
|
||||||
|
}, [phase, isPlayer]);
|
||||||
|
|
||||||
|
// Slovo patří jen k aktuálnímu kolu
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDrawer) setWord(null);
|
||||||
|
}, [isDrawer, state?.round]);
|
||||||
|
|
||||||
|
// Plátno se po vykreslení (nové kolo, otevření okna) vyplní bílou a znovu vyžádá kresba
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && inGame) {
|
||||||
|
const c = ctx();
|
||||||
|
if (c) clearCanvas(c);
|
||||||
|
socket.emit('draw:watch', { token: getToken() });
|
||||||
|
}
|
||||||
|
}, [open, inGame]);
|
||||||
|
|
||||||
|
// Odpočet kola počítaný lokálně od posledního stavu ze serveru
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'drawing' || state?.remainingMs === undefined) {
|
||||||
|
setSecondsLeft(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const endsAt = Date.now() + state.remainingMs;
|
||||||
|
const update = () => setSecondsLeft(Math.max(0, Math.ceil((endsAt - Date.now()) / 1000)));
|
||||||
|
update();
|
||||||
|
const id = window.setInterval(update, 250);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [phase, state?.remainingMs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight });
|
||||||
|
}, [state?.messages.length]);
|
||||||
|
|
||||||
|
// Průběžné odesílání rozkresleného tahu
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDrawer) return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
const p = pending.current;
|
||||||
|
if (!p || p.points.length === 0 || (p.points.length === 1 && !p.start)) return;
|
||||||
|
send('draw:stroke', { color, size, points: p.points, start: p.start });
|
||||||
|
// Další dávka začne posledním bodem, aby čára navazovala
|
||||||
|
pending.current = drawingRef.current ? { points: [p.points[p.points.length - 1]], start: false } : null;
|
||||||
|
}, FLUSH_MS);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [isDrawer, color, size, send]);
|
||||||
|
|
||||||
|
const toCoords = (e: React.PointerEvent<HTMLCanvasElement>): [number, number] => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = Math.round(((e.clientX - rect.left) / rect.width) * COORD_MAX);
|
||||||
|
const y = Math.round(((e.clientY - rect.top) / rect.height) * COORD_MAX);
|
||||||
|
return [Math.min(COORD_MAX, Math.max(0, x)), Math.min(COORD_MAX, Math.max(0, y))];
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!isDrawer) return;
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
drawingRef.current = true;
|
||||||
|
const point = toCoords(e);
|
||||||
|
pending.current = { points: [point], start: true };
|
||||||
|
const c = ctx();
|
||||||
|
if (c) drawStroke(c, { color, size, points: [point], start: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!isDrawer || !drawingRef.current || !pending.current) return;
|
||||||
|
const point = toCoords(e);
|
||||||
|
const prev = pending.current.points[pending.current.points.length - 1];
|
||||||
|
if (prev && prev[0] === point[0] && prev[1] === point[1]) return;
|
||||||
|
pending.current.points.push(point);
|
||||||
|
const c = ctx();
|
||||||
|
if (c && prev) drawStroke(c, { color, size, points: [prev, point], start: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = () => {
|
||||||
|
drawingRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitGuess = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!guess.trim()) return;
|
||||||
|
send('draw:guess', { text: guess });
|
||||||
|
setGuess('');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!login) return null;
|
||||||
|
|
||||||
|
const me = state?.players.find(p => p.login === login);
|
||||||
|
const canChat = isPlayer && !isDrawer && !(phase === 'drawing' && me?.guessed);
|
||||||
|
const ranked = [...(state?.players ?? [])].sort((a, b) => b.score - a.score);
|
||||||
|
const showInvite = !open && phase === 'lobby' && !isPlayer;
|
||||||
|
const showReturn = !open && isPlayer && inGame;
|
||||||
|
|
||||||
|
const renderTopBar = () => {
|
||||||
|
if (phase === 'reveal' || phase === 'finished') {
|
||||||
|
return <div className="draw-topbar">Bylo to: <strong>{state?.answer}</strong></div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="draw-topbar">
|
||||||
|
<span>Kolo {state?.round}/{state?.totalRounds} · kreslí <strong>{state?.drawer}</strong></span>
|
||||||
|
{isDrawer
|
||||||
|
? <span className="draw-word">Nakreslete: <strong>{word ?? '…'}</strong></span>
|
||||||
|
: <span className="draw-hint">{state?.hint}</span>}
|
||||||
|
<span className={secondsLeft <= 10 ? 'draw-timer urgent' : 'draw-timer'}>⏱ {secondsLeft} s</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGame = () => (
|
||||||
|
<>
|
||||||
|
{renderTopBar()}
|
||||||
|
<div className="draw-layout">
|
||||||
|
<div className="draw-canvas-col">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`draw-canvas ${isDrawer ? 'active' : ''}`}
|
||||||
|
width={CANVAS_W}
|
||||||
|
height={CANVAS_H}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerUp}
|
||||||
|
/>
|
||||||
|
{isDrawer && (
|
||||||
|
<div className="draw-tools">
|
||||||
|
{COLORS.map(c => (
|
||||||
|
<button key={c} className={`draw-color ${c === color ? 'selected' : ''}`}
|
||||||
|
style={{ background: c }} onClick={() => setColor(c)} aria-label={`Barva ${c}`} />
|
||||||
|
))}
|
||||||
|
<span className="draw-sep" />
|
||||||
|
{SIZES.map(s => (
|
||||||
|
<button key={s} className={`draw-size ${s === size ? 'selected' : ''}`} onClick={() => setSize(s)}
|
||||||
|
aria-label={`Tloušťka ${s}`}>
|
||||||
|
<span style={{ width: s, height: s }} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="draw-sep" />
|
||||||
|
<Button size="sm" variant="outline-secondary" onClick={() => send('draw:clear')}>Smazat</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="draw-side">
|
||||||
|
<ul className="draw-scores">
|
||||||
|
{ranked.map(p => (
|
||||||
|
<li key={p.login} className={p.guessed && phase === 'drawing' ? 'guessed' : undefined}>
|
||||||
|
<span>{p.login === state?.drawer && phase === 'drawing' ? '✏️ ' : ''}{p.login}{p.login === login && ' (vy)'}</span>
|
||||||
|
<strong>{p.score}</strong>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{renderChat()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderChat = () => (
|
||||||
|
<>
|
||||||
|
<div className="draw-chat" ref={chatRef}>
|
||||||
|
{state?.messages.map((m, i) => (
|
||||||
|
<div key={i} className={m.system ? 'system' : undefined}>
|
||||||
|
{m.login && <strong>{m.login}: </strong>}{m.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Form onSubmit={submitGuess}>
|
||||||
|
<Form.Control
|
||||||
|
value={guess}
|
||||||
|
onChange={e => setGuess(e.target.value)}
|
||||||
|
placeholder={isDrawer ? 'Kreslíte…' : canChat ? 'Váš tip…' : 'Uhodli jste ✔'}
|
||||||
|
disabled={!canChat}
|
||||||
|
maxLength={100}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBody = () => {
|
||||||
|
switch (phase) {
|
||||||
|
case 'idle':
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
Jeden kreslí jídlo z dnešních menu, ostatní hádají v chatu. Stačí trefit hlavní slovo
|
||||||
|
(např. „guláš"), diakritika nevadí. Čím dřív uhodnete, tím víc bodů; kreslíř dostává body
|
||||||
|
za každého, kdo uhodl. Každý kreslí jednou.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
case 'lobby':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
|
||||||
|
<ul className="draw-players">
|
||||||
|
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
|
||||||
|
</ul>
|
||||||
|
{renderChat()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'drawing':
|
||||||
|
case 'reveal':
|
||||||
|
return renderGame();
|
||||||
|
case 'finished':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table size="sm">
|
||||||
|
<thead><tr><th>#</th><th>Hráč</th><th>Body</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{ranked.map((p, i) => (
|
||||||
|
<tr key={p.login}>
|
||||||
|
<td>{i < 3 ? MEDALS[i] : i + 1}</td>
|
||||||
|
<td>{p.login}{p.login === login && ' (vy)'}</td>
|
||||||
|
<td>{p.score}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
const buttons = [];
|
||||||
|
if (phase === 'idle' || phase === 'finished') {
|
||||||
|
buttons.push(<Button key="new" onClick={() => send('draw:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
||||||
|
}
|
||||||
|
if (phase === 'lobby') {
|
||||||
|
if (!isPlayer) {
|
||||||
|
buttons.push(<Button key="join" onClick={() => send('draw:join')}>Připojit se</Button>);
|
||||||
|
} else {
|
||||||
|
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('draw:leave')}>
|
||||||
|
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
||||||
|
</Button>);
|
||||||
|
if (login === state?.host) {
|
||||||
|
buttons.push(<Button key="start" onClick={() => send('draw:start')}
|
||||||
|
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 2)}>Spustit hru</Button>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
||||||
|
return buttons;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showInvite && (
|
||||||
|
<button className="duel-invite draw-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} <strong>{state?.host}</strong> zve na kreslení — připojit se
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showReturn && (
|
||||||
|
<button className="duel-invite draw-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} Kreslení běží — zpět do hry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="drawing-game-modal">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>{icon} Kreslení</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{renderBody()}
|
||||||
|
{error && <div className="text-danger mt-2">{error}</div>}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,12 @@ import { useEffect, useState } from "react";
|
|||||||
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import SettingsModal from "./modals/SettingsModal";
|
import SettingsModal from "./modals/SettingsModal";
|
||||||
|
import ReactionDuel from "./ReactionDuel";
|
||||||
|
import SnakeGame from "./SnakeGame";
|
||||||
|
import TetrisGame from "./TetrisGame";
|
||||||
|
import PongGame from "./PongGame";
|
||||||
|
import DrawingGame from "./DrawingGame";
|
||||||
|
import { useMinigameSecret } from "../hooks/useMinigameSecret";
|
||||||
import { useSettings, ThemePreference } from "../context/settings";
|
import { useSettings, ThemePreference } from "../context/settings";
|
||||||
import HuePicker from "./HuePicker";
|
import HuePicker from "./HuePicker";
|
||||||
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
||||||
@@ -39,6 +45,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
const effectiveDark = settings?.effectiveDark ?? false;
|
const effectiveDark = settings?.effectiveDark ?? false;
|
||||||
|
const minigameSecret = useMinigameSecret("/");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth?.login) return;
|
if (!auth?.login) return;
|
||||||
@@ -147,7 +154,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <Navbar variant='dark' expand="lg">
|
return <Navbar variant='dark' expand="lg">
|
||||||
<Navbar.Brand href="/">Luncher</Navbar.Brand>
|
<Navbar.Brand href="/" onClick={minigameSecret.onLogoClick} className={minigameSecret.armed ? "duel-armed" : undefined}>Luncher</Navbar.Brand>
|
||||||
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||||
<Navbar.Collapse id="basic-navbar-nav">
|
<Navbar.Collapse id="basic-navbar-nav">
|
||||||
<Nav className="nav">
|
<Nav className="nav">
|
||||||
@@ -195,6 +202,11 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
</NavDropdown>
|
</NavDropdown>
|
||||||
</Nav>
|
</Nav>
|
||||||
</Navbar.Collapse>
|
</Navbar.Collapse>
|
||||||
|
<ReactionDuel />
|
||||||
|
<SnakeGame />
|
||||||
|
<TetrisGame />
|
||||||
|
<PongGame />
|
||||||
|
<DrawingGame />
|
||||||
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
||||||
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
||||||
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Pozvánka Pongu nad pozvánkami ostatních her, aby se nepřekrývaly
|
||||||
|
.duel-invite.pong-invite {
|
||||||
|
bottom: 172px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pong-game-modal {
|
||||||
|
.pong-scoreboard {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 24px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pong-board-wrap {
|
||||||
|
position: relative;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pong-board {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pong-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pong-bot-picker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pong-status {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, Form, Modal } from "react-bootstrap";
|
||||||
|
import { socket } from "../context/socket";
|
||||||
|
import { useAuth } from "../context/auth";
|
||||||
|
import { getToken } from "../Utils";
|
||||||
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
|
import "./PongGame.scss";
|
||||||
|
|
||||||
|
// Typy musí odpovídat server/src/pongGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
||||||
|
type PongPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
|
||||||
|
type BotLevel = 'easy' | 'medium' | 'hard' | 'impossible';
|
||||||
|
type PongPlayer = { login: string, side: 0 | 1, y: number, score: number, bot?: BotLevel };
|
||||||
|
type PongState = {
|
||||||
|
phase: PongPhase,
|
||||||
|
host?: string,
|
||||||
|
players: PongPlayer[],
|
||||||
|
ball: { x: number, y: number },
|
||||||
|
countdown?: number,
|
||||||
|
winner?: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPEN_EVENT = 'luncher:open-pong';
|
||||||
|
|
||||||
|
/** Otevře okno Pongu. */
|
||||||
|
export const openPongGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
|
||||||
|
|
||||||
|
// Rozměry musí odpovídat serveru
|
||||||
|
const PADDLE_HEIGHT = 90;
|
||||||
|
const PADDLE_WIDTH = 12;
|
||||||
|
const PADDLE_MARGIN = 20;
|
||||||
|
const BALL_SIZE = 12;
|
||||||
|
const SIDE_COLORS = ['#60a5fa', '#f472b6'];
|
||||||
|
|
||||||
|
/** Jak často se posílá poloha myši (ms). */
|
||||||
|
const AIM_SEND_MS = 30;
|
||||||
|
const BOT_LEVELS: { value: BotLevel, label: string }[] = [
|
||||||
|
{ value: 'easy', label: 'Lehký' },
|
||||||
|
{ value: 'medium', label: 'Střední' },
|
||||||
|
{ value: 'hard', label: 'Těžký' },
|
||||||
|
{ value: 'impossible', label: 'Nemožný' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const UP_KEYS = new Set(['ArrowUp', 'KeyW']);
|
||||||
|
const DOWN_KEYS = new Set(['ArrowDown', 'KeyS']);
|
||||||
|
|
||||||
|
function draw(canvas: HTMLCanvasElement, state: PongState, me?: string) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
const scale = canvas.width / state.width;
|
||||||
|
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
||||||
|
ctx.fillStyle = '#111827';
|
||||||
|
ctx.fillRect(0, 0, state.width, state.height);
|
||||||
|
// Středová čára
|
||||||
|
ctx.fillStyle = '#374151';
|
||||||
|
for (let y = 0; y < state.height; y += 30) ctx.fillRect(state.width / 2 - 2, y + 5, 4, 18);
|
||||||
|
for (const p of state.players) {
|
||||||
|
const x = p.side === 0 ? PADDLE_MARGIN : state.width - PADDLE_MARGIN - PADDLE_WIDTH;
|
||||||
|
ctx.fillStyle = SIDE_COLORS[p.side];
|
||||||
|
ctx.fillRect(x, p.y - PADDLE_HEIGHT / 2, PADDLE_WIDTH, PADDLE_HEIGHT);
|
||||||
|
if (p.login === me) {
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(x - 1, p.y - PADDLE_HEIGHT / 2 - 1, PADDLE_WIDTH + 2, PADDLE_HEIGHT + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.phase === 'playing') {
|
||||||
|
ctx.fillStyle = '#f9fafb';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(state.ball.x, state.ball.y, BALL_SIZE / 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pong 1v1 — fyziku drží server, klient posílá jen směr pálky. */
|
||||||
|
export default function PongGame() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const login = auth?.login;
|
||||||
|
const [state, setState] = useState<PongState | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const prevPhase = useRef<PongPhase>('idle');
|
||||||
|
const icon = useGameIcon('pong');
|
||||||
|
const [botLevel, setBotLevel] = useState<BotLevel>('medium');
|
||||||
|
/** Výška hřiště pro přepočet myši — přes ref, ať se efekt nerestartuje každým snímkem. */
|
||||||
|
const fieldHeight = useRef(500);
|
||||||
|
fieldHeight.current = state?.height ?? fieldHeight.current;
|
||||||
|
|
||||||
|
const send = useCallback((event: string, extra?: object) => {
|
||||||
|
setError(null);
|
||||||
|
socket.emit(event, { token: getToken(), ...extra });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onState = (s: PongState) => setState(s);
|
||||||
|
const onError = (msg: string) => setError(msg);
|
||||||
|
const onOpen = () => setOpen(true);
|
||||||
|
const sync = () => socket.emit('pong:sync');
|
||||||
|
socket.on('pong:state', onState);
|
||||||
|
socket.on('pong:error', onError);
|
||||||
|
socket.on('connect', sync);
|
||||||
|
window.addEventListener(OPEN_EVENT, onOpen);
|
||||||
|
sync();
|
||||||
|
return () => {
|
||||||
|
socket.off('pong:state', onState);
|
||||||
|
socket.off('pong:error', onError);
|
||||||
|
socket.off('connect', sync);
|
||||||
|
window.removeEventListener(OPEN_EVENT, onOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Snímky hry dostávají jen klienti s otevřeným oknem
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const watch = () => socket.emit('pong:watch');
|
||||||
|
watch();
|
||||||
|
socket.on('connect', watch);
|
||||||
|
return () => {
|
||||||
|
socket.off('connect', watch);
|
||||||
|
socket.emit('pong:unwatch');
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
|
||||||
|
const phase = state?.phase ?? 'idle';
|
||||||
|
const inGame = phase === 'countdown' || phase === 'playing';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevPhase.current === 'lobby' && phase === 'countdown' && isPlayer) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
prevPhase.current = phase;
|
||||||
|
}, [phase, isPlayer]);
|
||||||
|
|
||||||
|
// Ovládání: drží se klávesa → pálka jede, puštění → stojí
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !inGame || !isPlayer) return;
|
||||||
|
const pressed = new Set<string>();
|
||||||
|
const update = () => {
|
||||||
|
const up = [...pressed].some(k => UP_KEYS.has(k));
|
||||||
|
const down = [...pressed].some(k => DOWN_KEYS.has(k));
|
||||||
|
send('pong:input', { input: up === down ? 'stop' : up ? 'up' : 'down' });
|
||||||
|
};
|
||||||
|
const onDown = (e: KeyboardEvent) => {
|
||||||
|
if (!UP_KEYS.has(e.code) && !DOWN_KEYS.has(e.code)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.repeat || pressed.has(e.code)) return;
|
||||||
|
pressed.add(e.code);
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
const onUp = (e: KeyboardEvent) => {
|
||||||
|
if (!pressed.delete(e.code)) return;
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
const onBlur = () => {
|
||||||
|
pressed.clear();
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onDown);
|
||||||
|
window.addEventListener('keyup', onUp);
|
||||||
|
window.addEventListener('blur', onBlur);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onDown);
|
||||||
|
window.removeEventListener('keyup', onUp);
|
||||||
|
window.removeEventListener('blur', onBlur);
|
||||||
|
};
|
||||||
|
}, [open, inGame, isPlayer, send]);
|
||||||
|
|
||||||
|
// Ovládání myší: pálka sleduje výšku kurzoru nad hřištěm (kdekoli v okně, mimo hřiště se ořízne)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !inGame || !isPlayer) return;
|
||||||
|
let latest: number | undefined;
|
||||||
|
let sent: number | undefined;
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const height = fieldHeight.current;
|
||||||
|
const y = ((e.clientY - rect.top) / rect.height) * height;
|
||||||
|
latest = Math.round(Math.min(height, Math.max(0, y)));
|
||||||
|
};
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
if (latest === undefined || latest === sent) return;
|
||||||
|
sent = latest;
|
||||||
|
send('pong:aim', { y: latest });
|
||||||
|
}, AIM_SEND_MS);
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(id);
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
};
|
||||||
|
}, [open, inGame, isPlayer, send]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canvasRef.current && state && phase !== 'idle' && phase !== 'lobby') {
|
||||||
|
draw(canvasRef.current, state, login);
|
||||||
|
}
|
||||||
|
}, [state, phase, login]);
|
||||||
|
|
||||||
|
if (!login) return null;
|
||||||
|
|
||||||
|
const left = state?.players.find(p => p.side === 0);
|
||||||
|
const right = state?.players.find(p => p.side === 1);
|
||||||
|
const showInvite = !open && phase === 'lobby' && !isPlayer && (state?.players.length ?? 0) < 2;
|
||||||
|
const showReturn = !open && isPlayer && inGame;
|
||||||
|
|
||||||
|
const renderBoard = () => state && (
|
||||||
|
<>
|
||||||
|
<div className="pong-scoreboard">
|
||||||
|
<span style={{ color: SIDE_COLORS[0] }}>{left?.login}</span>
|
||||||
|
<strong>{left?.score ?? 0} : {right?.score ?? 0}</strong>
|
||||||
|
<span style={{ color: SIDE_COLORS[1] }}>{right?.login}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pong-board-wrap">
|
||||||
|
<canvas ref={canvasRef} className="pong-board" width={state.width} height={state.height} />
|
||||||
|
{phase === 'countdown' && <div className="pong-overlay">{state.countdown}</div>}
|
||||||
|
{phase === 'finished' && <div className="pong-overlay small">🏆 {state.winner}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="pong-status">{isPlayer ? 'Myš, nebo ↑↓ / W S' : 'Sledujete zápas'}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBody = () => {
|
||||||
|
switch (phase) {
|
||||||
|
case 'idle':
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
Klasický Pong pro dva. Ovládání myší (pálka sleduje kurzor) nebo šipkami / W S, hraje se
|
||||||
|
do 5 bodů. Když není s kým hrát, přidejte si v lobby bota. Ostatní se mohou dívat.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
case 'lobby':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>Lobby založil(a) <strong>{state?.host}</strong>.</p>
|
||||||
|
<ul>{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}</ul>
|
||||||
|
{(state?.players.length ?? 0) < 2 && <p className="text-muted">Čeká se na soupeře…</p>}
|
||||||
|
{login === state?.host && (state?.players.length ?? 0) < 2 && (
|
||||||
|
<div className="pong-bot-picker">
|
||||||
|
<span>Nebo hrát proti botovi:</span>
|
||||||
|
<Form.Select size="sm" value={botLevel} onChange={e => setBotLevel(e.target.value as BotLevel)}>
|
||||||
|
{BOT_LEVELS.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
|
||||||
|
</Form.Select>
|
||||||
|
<Button size="sm" variant="outline-primary" onClick={() => send('pong:addBot', { level: botLevel })}>
|
||||||
|
Přidat bota
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{login === state?.host && state?.players.some(p => p.bot) && (
|
||||||
|
<Button size="sm" variant="outline-secondary" onClick={() => send('pong:removeBot')}>Odebrat bota</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return renderBoard();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
const buttons = [];
|
||||||
|
if (phase === 'idle' || phase === 'finished') {
|
||||||
|
buttons.push(<Button key="new" onClick={() => send('pong:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
||||||
|
}
|
||||||
|
if (phase === 'lobby') {
|
||||||
|
if (!isPlayer) {
|
||||||
|
if ((state?.players.length ?? 0) < 2) {
|
||||||
|
buttons.push(<Button key="join" onClick={() => send('pong:join')}>Připojit se</Button>);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('pong:leave')}>
|
||||||
|
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
||||||
|
</Button>);
|
||||||
|
if (login === state?.host) {
|
||||||
|
buttons.push(<Button key="start" onClick={() => send('pong:start')}
|
||||||
|
disabled={(state?.players.length ?? 0) < 2}>Spustit hru</Button>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
||||||
|
return buttons;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showInvite && (
|
||||||
|
<button className="duel-invite pong-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} <strong>{state?.host}</strong> hledá soupeře na Pong — připojit se
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showReturn && (
|
||||||
|
<button className="duel-invite pong-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} Pong běží — zpět do hry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="pong-game-modal">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>{icon} Pong</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{renderBody()}
|
||||||
|
{error && <div className="text-danger mt-2">{error}</div>}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
.duel-invite {
|
||||||
|
position: fixed;
|
||||||
|
left: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 1040;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--luncher-primary, #198754);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
animation: duel-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes duel-pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.04); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-duel-modal {
|
||||||
|
.duel-arena {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 280px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fff;
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
transition: background-color 0.05s;
|
||||||
|
|
||||||
|
&.waiting { background: #b02a37; }
|
||||||
|
&.go { background: #198754; }
|
||||||
|
&.done { background: #495057; }
|
||||||
|
&.early { background: #fd7e14; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-round {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-signal {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-hint {
|
||||||
|
opacity: 0.85;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-players {
|
||||||
|
columns: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-podium {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-place {
|
||||||
|
flex: 0 1 160px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
|
||||||
|
// Vítěz uprostřed a nejvýš, jako na stupních vítězů
|
||||||
|
&.place-1 { order: 2; padding-top: 28px; }
|
||||||
|
&.place-2 { order: 1; }
|
||||||
|
&.place-3 { order: 3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-medal {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-place-name {
|
||||||
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-place-time {
|
||||||
|
opacity: 0.75;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-last {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-results .duel-loser td {
|
||||||
|
background: rgba(220, 53, 69, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nenápadné potvrzení, že logo přijalo sérii kliknutí a čeká na tajné slovo
|
||||||
|
.navbar-brand.duel-armed {
|
||||||
|
display: inline-block;
|
||||||
|
animation: duel-wiggle 0.35s ease-in-out 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes duel-wiggle {
|
||||||
|
0%, 100% { transform: rotate(0); }
|
||||||
|
25% { transform: rotate(-4deg); }
|
||||||
|
75% { transform: rotate(4deg); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, Modal, Table } from "react-bootstrap";
|
||||||
|
import { socket } from "../context/socket";
|
||||||
|
import { useAuth } from "../context/auth";
|
||||||
|
import { getToken } from "../Utils";
|
||||||
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
|
import "./ReactionDuel.scss";
|
||||||
|
|
||||||
|
// Typy musí odpovídat server/src/reactionDuel.ts (stav chodí přes websocket, ne přes OpenAPI)
|
||||||
|
type DuelPhase = 'idle' | 'lobby' | 'waiting' | 'go' | 'roundResult' | 'finished';
|
||||||
|
type RoundResult = { ms: number | null, falseStart?: boolean };
|
||||||
|
type DuelPlayer = { login: string, rounds: RoundResult[], total: number };
|
||||||
|
type DuelState = {
|
||||||
|
phase: DuelPhase,
|
||||||
|
host?: string,
|
||||||
|
round: number,
|
||||||
|
totalRounds: number,
|
||||||
|
players: DuelPlayer[],
|
||||||
|
loser?: string,
|
||||||
|
winner?: string,
|
||||||
|
minPlayers: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPEN_EVENT = 'luncher:open-duel';
|
||||||
|
|
||||||
|
/** Otevře okno reakční hry. */
|
||||||
|
export const openReactionDuel = () => window.dispatchEvent(new Event(OPEN_EVENT));
|
||||||
|
|
||||||
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
|
|
||||||
|
const formatResult = (r?: RoundResult) => {
|
||||||
|
if (!r) return '—';
|
||||||
|
if (r.falseStart) return 'předčasně';
|
||||||
|
if (r.ms == null) return 'nekliknuto';
|
||||||
|
return `${r.ms} ms`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Reakční hra — lobby, kola a výsledky; časování drží server. */
|
||||||
|
export default function ReactionDuel() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const login = auth?.login;
|
||||||
|
const [state, setState] = useState<DuelState | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
/** Kolo, ve kterém už tento klient kliknul (a zda předčasně). */
|
||||||
|
const [myClick, setMyClick] = useState<{ round: number, early: boolean } | null>(null);
|
||||||
|
const prevPhase = useRef<DuelPhase>('idle');
|
||||||
|
const icon = useGameIcon('reaction');
|
||||||
|
|
||||||
|
const send = useCallback((event: string) => {
|
||||||
|
setError(null);
|
||||||
|
socket.emit(event, { token: getToken() });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onState = (s: DuelState) => setState(s);
|
||||||
|
const onError = (msg: string) => setError(msg);
|
||||||
|
const onOpen = () => setOpen(true);
|
||||||
|
const sync = () => socket.emit('duel:sync');
|
||||||
|
socket.on('duel:state', onState);
|
||||||
|
socket.on('duel:error', onError);
|
||||||
|
socket.on('connect', sync);
|
||||||
|
window.addEventListener(OPEN_EVENT, onOpen);
|
||||||
|
sync();
|
||||||
|
return () => {
|
||||||
|
socket.off('duel:state', onState);
|
||||||
|
socket.off('duel:error', onError);
|
||||||
|
socket.off('connect', sync);
|
||||||
|
window.removeEventListener(OPEN_EVENT, onOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
|
||||||
|
const phase = state?.phase ?? 'idle';
|
||||||
|
const inRound = phase === 'waiting' || phase === 'go';
|
||||||
|
|
||||||
|
// Hráčům se okno otevře samo, jakmile hra začne
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevPhase.current === 'lobby' && phase === 'waiting' && isPlayer) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
prevPhase.current = phase;
|
||||||
|
}, [phase, isPlayer]);
|
||||||
|
|
||||||
|
const click = useCallback(() => {
|
||||||
|
if (!state || !isPlayer || !inRound || myClick?.round === state.round) return;
|
||||||
|
setMyClick({ round: state.round, early: state.phase === 'waiting' });
|
||||||
|
send('duel:click');
|
||||||
|
}, [state, isPlayer, inRound, myClick, send]);
|
||||||
|
|
||||||
|
// Mezerník funguje stejně jako kliknutí
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !inRound) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [open, inRound, click]);
|
||||||
|
|
||||||
|
if (!login) return null;
|
||||||
|
|
||||||
|
const showInvite = !open && phase === 'lobby' && !isPlayer;
|
||||||
|
const showReturn = !open && isPlayer && phase !== 'finished';
|
||||||
|
const clickedThisRound = myClick?.round === state?.round;
|
||||||
|
const ranked = [...(state?.players ?? [])].sort((a, b) => a.total - b.total);
|
||||||
|
|
||||||
|
const renderArena = () => {
|
||||||
|
if (!state) return null;
|
||||||
|
let cls = 'waiting';
|
||||||
|
let text = 'Počkej…';
|
||||||
|
if (phase === 'go') {
|
||||||
|
cls = 'go';
|
||||||
|
text = 'TEĎ!';
|
||||||
|
}
|
||||||
|
if (isPlayer && clickedThisRound) {
|
||||||
|
cls = myClick?.early ? 'early' : 'done';
|
||||||
|
text = myClick?.early ? 'Předčasný start! 😬' : 'Kliknuto ✔';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`duel-arena ${cls}`}
|
||||||
|
onPointerDown={click}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="duel-round">Kolo {state.round}/{state.totalRounds}</div>
|
||||||
|
<div className="duel-signal">{text}</div>
|
||||||
|
{isPlayer
|
||||||
|
? <div className="duel-hint">Klikněte nebo zmáčkněte mezerník, až zezelená.</div>
|
||||||
|
: <div className="duel-hint">Sledujete hru.</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderResults = (final: boolean) => (
|
||||||
|
<Table size="sm" className="duel-results">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Hráč</th>
|
||||||
|
{Array.from({ length: state?.totalRounds ?? 0 }, (_, i) => <th key={i}>{i + 1}. kolo</th>)}
|
||||||
|
<th>Celkem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ranked.map((p, i) => (
|
||||||
|
<tr key={p.login} className={final && p.login === state?.loser ? 'duel-loser' : undefined}>
|
||||||
|
<td>{final && i < 3 ? MEDALS[i] : i + 1}</td>
|
||||||
|
<td>{p.login}{p.login === login && ' (vy)'}</td>
|
||||||
|
{Array.from({ length: state?.totalRounds ?? 0 }, (_, r) => <td key={r}>{formatResult(p.rounds[r])}</td>)}
|
||||||
|
<td><strong>{p.rounds.length ? `${p.total} ms` : '—'}</strong></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBody = () => {
|
||||||
|
switch (phase) {
|
||||||
|
case 'idle':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Hra má {state?.totalRounds ?? 3} kola — po náhodné chvíli se plocha rozsvítí zeleně
|
||||||
|
a počítá se, jak rychle kliknete. Předčasný start nebo nekliknutí znamená trestné 2 s.
|
||||||
|
Vyhrává nejnižší součet časů.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted">Po založení lobby dostanou ostatní online pozvánku.</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'lobby':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
|
||||||
|
<ul className="duel-players">
|
||||||
|
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
|
||||||
|
</ul>
|
||||||
|
{state && state.players.length < state.minPlayers &&
|
||||||
|
<p className="text-muted">Čeká se na další hráče…</p>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'waiting':
|
||||||
|
case 'go':
|
||||||
|
return renderArena();
|
||||||
|
case 'roundResult':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>Kolo {state?.round}/{state?.totalRounds} skončilo, další za chvíli…</p>
|
||||||
|
{renderResults(false)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'finished':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="duel-podium">
|
||||||
|
{ranked.slice(0, 3).map((p, i) => (
|
||||||
|
<div key={p.login} className={`duel-place place-${i + 1}`}>
|
||||||
|
<div className="duel-medal">{MEDALS[i]}</div>
|
||||||
|
<div className="duel-place-name">{p.login}</div>
|
||||||
|
<div className="duel-place-time">{p.total} ms</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{state?.loser && ranked.length > 3 &&
|
||||||
|
<p className="duel-last">🐢 Poslední: <strong>{state.loser}</strong></p>}
|
||||||
|
{renderResults(true)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
const buttons = [];
|
||||||
|
if (phase === 'idle' || phase === 'finished') {
|
||||||
|
buttons.push(<Button key="new" onClick={() => send('duel:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
||||||
|
}
|
||||||
|
if (phase === 'lobby') {
|
||||||
|
if (!isPlayer) {
|
||||||
|
buttons.push(<Button key="join" onClick={() => send('duel:join')}>Připojit se</Button>);
|
||||||
|
} else {
|
||||||
|
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('duel:leave')}>
|
||||||
|
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
||||||
|
</Button>);
|
||||||
|
if (login === state?.host) {
|
||||||
|
buttons.push(<Button key="start" onClick={() => send('duel:start')}
|
||||||
|
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 2)}>Spustit hru</Button>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
||||||
|
return buttons;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showInvite && (
|
||||||
|
<button className="duel-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} <strong>{state?.host}</strong> zve na reakční hru — připojit se
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showReturn && (
|
||||||
|
<button className="duel-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} Reakční hra běží — zpět do hry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Modal show={open} onHide={() => setOpen(false)} size="lg" className="reaction-duel-modal">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>{icon} Reakční hra</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{renderBody()}
|
||||||
|
{error && <div className="text-danger mt-2">{error}</div>}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Pozvánka hada nad pozvánkou reakční hry, aby se nepřekrývaly
|
||||||
|
.duel-invite.snake-invite {
|
||||||
|
bottom: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-game-modal {
|
||||||
|
.snake-board-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 768px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-board {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-status {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-scores {
|
||||||
|
max-width: 768px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-dead td {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-players {
|
||||||
|
columns: 2;
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, Modal, Table } from "react-bootstrap";
|
||||||
|
import { socket } from "../context/socket";
|
||||||
|
import { useAuth } from "../context/auth";
|
||||||
|
import { getToken } from "../Utils";
|
||||||
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
|
import "./SnakeGame.scss";
|
||||||
|
|
||||||
|
// Typy musí odpovídat server/src/snakeGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
||||||
|
type Cell = [number, number];
|
||||||
|
type Direction = 'up' | 'down' | 'left' | 'right';
|
||||||
|
type SnakePhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
|
||||||
|
type SnakePlayer = { login: string, color: number, body: Cell[], alive: boolean, score: number, place?: number };
|
||||||
|
type SnakeState = {
|
||||||
|
phase: SnakePhase,
|
||||||
|
host?: string,
|
||||||
|
players: SnakePlayer[],
|
||||||
|
food: Cell[],
|
||||||
|
tick: number,
|
||||||
|
maxTicks: number,
|
||||||
|
countdown?: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
minPlayers: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPEN_EVENT = 'luncher:open-snake';
|
||||||
|
|
||||||
|
/** Otevře okno hada. */
|
||||||
|
export const openSnakeGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
|
||||||
|
|
||||||
|
const COLORS = ['#4ade80', '#60a5fa', '#f472b6', '#facc15', '#a78bfa', '#fb923c', '#2dd4bf', '#f87171'];
|
||||||
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
|
const TICK_MS = 120;
|
||||||
|
|
||||||
|
const KEY_DIRECTIONS: Record<string, Direction> = {
|
||||||
|
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
|
||||||
|
KeyW: 'up', KeyS: 'down', KeyA: 'left', KeyD: 'right',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Vykreslí hrací plochu do canvasu. */
|
||||||
|
function draw(canvas: HTMLCanvasElement, state: SnakeState, me?: string) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
const cell = canvas.width / state.width;
|
||||||
|
ctx.fillStyle = '#111827';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = '#1f2937';
|
||||||
|
for (let x = 0; x < state.width; x++) {
|
||||||
|
for (let y = 0; y < state.height; y++) {
|
||||||
|
if ((x + y) % 2 === 0) ctx.fillRect(x * cell, y * cell, cell, cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [x, y] of state.food) {
|
||||||
|
ctx.fillStyle = '#ef4444';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc((x + 0.5) * cell, (y + 0.5) * cell, cell * 0.35, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
for (const p of state.players) {
|
||||||
|
const color = COLORS[p.color % COLORS.length];
|
||||||
|
// Tělo tlumeně, hlava se kreslí zvlášť až nakonec
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.globalAlpha = 0.65;
|
||||||
|
p.body.slice(1).forEach(([x, y]) => ctx.fillRect(x * cell + 2, y * cell + 2, cell - 4, cell - 4));
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
if (p.body[0]) drawHead(ctx, p.body, cell, color, p.login === me);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hlava: plná barva, zaoblená, přes okraj políčka a s očima ve směru pohybu. */
|
||||||
|
function drawHead(ctx: CanvasRenderingContext2D, body: Cell[], cell: number, color: string, mine: boolean) {
|
||||||
|
const [x, y] = body[0];
|
||||||
|
const neck = body[1];
|
||||||
|
const dx = neck ? Math.sign(x - neck[0]) : 1;
|
||||||
|
const dy = neck ? Math.sign(y - neck[1]) : 0;
|
||||||
|
const cx = (x + 0.5) * cell;
|
||||||
|
const cy = (y + 0.5) * cell;
|
||||||
|
const size = cell * 1.1;
|
||||||
|
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(cx - size / 2, cy - size / 2, size, size, cell * 0.35);
|
||||||
|
ctx.fill();
|
||||||
|
// Vlastní had má bílý obrys hlavy, ostatní tmavý — hlava se tak odliší od těla i pozadí
|
||||||
|
ctx.strokeStyle = mine ? '#fff' : '#0b0f19';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Oči: posunuté ve směru pohybu, rozložené kolmo na něj
|
||||||
|
const forward = cell * 0.18;
|
||||||
|
const side = cell * 0.22;
|
||||||
|
for (const s of [-1, 1]) {
|
||||||
|
const ex = cx + dx * forward + -dy * side * s;
|
||||||
|
const ey = cy + dy * forward + dx * side * s;
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(ex, ey, cell * 0.14, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(ex + dx * cell * 0.05, ey + dy * cell * 0.05, cell * 0.07, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multiplayerový had — lobby, hrací plocha a výsledky; simulaci drží server. */
|
||||||
|
export default function SnakeGame() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const login = auth?.login;
|
||||||
|
const [state, setState] = useState<SnakeState | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const prevPhase = useRef<SnakePhase>('idle');
|
||||||
|
const icon = useGameIcon('snake');
|
||||||
|
|
||||||
|
const send = useCallback((event: string, extra?: object) => {
|
||||||
|
setError(null);
|
||||||
|
socket.emit(event, { token: getToken(), ...extra });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onState = (s: SnakeState) => setState(s);
|
||||||
|
const onError = (msg: string) => setError(msg);
|
||||||
|
const onOpen = () => setOpen(true);
|
||||||
|
const sync = () => socket.emit('snake:sync');
|
||||||
|
socket.on('snake:state', onState);
|
||||||
|
socket.on('snake:error', onError);
|
||||||
|
socket.on('connect', sync);
|
||||||
|
window.addEventListener(OPEN_EVENT, onOpen);
|
||||||
|
sync();
|
||||||
|
return () => {
|
||||||
|
socket.off('snake:state', onState);
|
||||||
|
socket.off('snake:error', onError);
|
||||||
|
socket.off('connect', sync);
|
||||||
|
window.removeEventListener(OPEN_EVENT, onOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Průběžné snímky hry dostávají jen klienti s otevřeným oknem
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const watch = () => socket.emit('snake:watch');
|
||||||
|
watch();
|
||||||
|
socket.on('connect', watch);
|
||||||
|
return () => {
|
||||||
|
socket.off('connect', watch);
|
||||||
|
socket.emit('snake:unwatch');
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
|
||||||
|
const phase = state?.phase ?? 'idle';
|
||||||
|
const inGame = phase === 'countdown' || phase === 'playing';
|
||||||
|
|
||||||
|
// Hráčům se okno otevře samo, jakmile hra začne
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevPhase.current === 'lobby' && phase === 'countdown' && isPlayer) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
prevPhase.current = phase;
|
||||||
|
}, [phase, isPlayer]);
|
||||||
|
|
||||||
|
// Ovládání šipkami a WSAD
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !inGame || !isPlayer) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const dir = KEY_DIRECTIONS[e.code];
|
||||||
|
if (!dir) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (!e.repeat) send('snake:turn', { dir });
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [open, inGame, isPlayer, send]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canvasRef.current && state && (inGame || phase === 'finished')) {
|
||||||
|
draw(canvasRef.current, state, login);
|
||||||
|
}
|
||||||
|
}, [state, inGame, phase, login]);
|
||||||
|
|
||||||
|
if (!login) return null;
|
||||||
|
|
||||||
|
const showInvite = !open && phase === 'lobby' && !isPlayer;
|
||||||
|
const showReturn = !open && isPlayer && inGame;
|
||||||
|
const ranked = [...(state?.players ?? [])].sort((a, b) => (a.place ?? 99) - (b.place ?? 99) || b.score - a.score);
|
||||||
|
const secondsLeft = state ? Math.max(0, Math.ceil((state.maxTicks - state.tick) * TICK_MS / 1000)) : 0;
|
||||||
|
|
||||||
|
const renderBoard = () => state && (
|
||||||
|
<div className="snake-board-wrap">
|
||||||
|
<canvas ref={canvasRef} className="snake-board" width={state.width * 24} height={state.height * 24} />
|
||||||
|
{phase === 'countdown' && <div className="snake-overlay">{state.countdown}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderScores = () => (
|
||||||
|
<Table size="sm" className="snake-scores">
|
||||||
|
<thead>
|
||||||
|
<tr><th>#</th><th>Hráč</th><th>Jídla</th><th>Délka</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ranked.map((p, i) => (
|
||||||
|
<tr key={p.login} className={p.alive || phase !== 'playing' ? undefined : 'snake-dead'}>
|
||||||
|
<td>{phase === 'finished' && i < 3 ? MEDALS[i] : (phase === 'finished' ? i + 1 : '')}</td>
|
||||||
|
<td>
|
||||||
|
<span className="snake-dot" style={{ background: COLORS[p.color % COLORS.length] }} />
|
||||||
|
{p.login}{p.login === login && ' (vy)'}{!p.alive && inGame && ' 💀'}
|
||||||
|
</td>
|
||||||
|
<td>{p.score}</td>
|
||||||
|
<td>{p.body.length || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBody = () => {
|
||||||
|
switch (phase) {
|
||||||
|
case 'idle':
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
Klasický had pro více hráčů. Ovládání šipkami nebo WSAD. Jezte jídlo a vyhněte se zdem,
|
||||||
|
sobě i ostatním — čelní srážka zabije oba. Vyhrává ten, kdo přežije nejdéle;
|
||||||
|
hra trvá nejvýš 3 minuty. Hrát se dá i sólo.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
case 'lobby':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
|
||||||
|
<ul className="snake-players">
|
||||||
|
{state?.players.map(p => (
|
||||||
|
<li key={p.login}>
|
||||||
|
<span className="snake-dot" style={{ background: COLORS[p.color % COLORS.length] }} />
|
||||||
|
{p.login}{p.login === state.host && ' 👑'}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'countdown':
|
||||||
|
case 'playing':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderBoard()}
|
||||||
|
<div className="snake-status">
|
||||||
|
{isPlayer ? 'Šipky / WSAD' : 'Sledujete hru'} · zbývá {secondsLeft} s
|
||||||
|
</div>
|
||||||
|
{renderScores()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'finished':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderBoard()}
|
||||||
|
{renderScores()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
const buttons = [];
|
||||||
|
if (phase === 'idle' || phase === 'finished') {
|
||||||
|
buttons.push(<Button key="new" onClick={() => send('snake:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
||||||
|
}
|
||||||
|
if (phase === 'lobby') {
|
||||||
|
if (!isPlayer) {
|
||||||
|
buttons.push(<Button key="join" onClick={() => send('snake:join')}>Připojit se</Button>);
|
||||||
|
} else {
|
||||||
|
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('snake:leave')}>
|
||||||
|
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
||||||
|
</Button>);
|
||||||
|
if (login === state?.host) {
|
||||||
|
buttons.push(<Button key="start" onClick={() => send('snake:start')}
|
||||||
|
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 1)}>Spustit hru</Button>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
||||||
|
return buttons;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showInvite && (
|
||||||
|
<button className="duel-invite snake-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} <strong>{state?.host}</strong> zve na hada — připojit se
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showReturn && (
|
||||||
|
<button className="duel-invite snake-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} Had běží — zpět do hry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="snake-game-modal">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>{icon} Had</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{renderBody()}
|
||||||
|
{error && <div className="text-danger mt-2">{error}</div>}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Pozvánka Tetrisu nad pozvánkami ostatních her, aby se nepřekrývaly
|
||||||
|
.duel-invite.tetris-invite {
|
||||||
|
bottom: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-game-modal {
|
||||||
|
.tetris-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 24px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-own {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-board-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-board {
|
||||||
|
display: block;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-flash {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fca5a5;
|
||||||
|
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-side {
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-value {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-help {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-opponents {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 440px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-mini {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dead canvas {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-mini-name {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
max-width: 90px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-mini-score {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-players {
|
||||||
|
columns: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, Modal, Table } from "react-bootstrap";
|
||||||
|
import { socket } from "../context/socket";
|
||||||
|
import { useAuth } from "../context/auth";
|
||||||
|
import { getToken } from "../Utils";
|
||||||
|
import { TetrisSession, WIDTH, HEIGHT, pieceCells, Piece } from "../games/tetrisEngine";
|
||||||
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
|
import "./TetrisGame.scss";
|
||||||
|
|
||||||
|
// Typy musí odpovídat server/src/tetrisGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
||||||
|
type TetrisPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
|
||||||
|
type TetrisPlayer = { login: string, board: string, score: number, lines: number, alive: boolean, place?: number };
|
||||||
|
type TetrisState = {
|
||||||
|
phase: TetrisPhase,
|
||||||
|
host?: string,
|
||||||
|
players: TetrisPlayer[],
|
||||||
|
seed?: number,
|
||||||
|
countdown?: number,
|
||||||
|
minPlayers: number,
|
||||||
|
};
|
||||||
|
type GarbageEvent = { target: string, from: string, lines: number };
|
||||||
|
|
||||||
|
const OPEN_EVENT = 'luncher:open-tetris';
|
||||||
|
|
||||||
|
/** Otevře okno Tetrisu. */
|
||||||
|
export const openTetrisGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
|
||||||
|
|
||||||
|
/** Barvy kostek I, O, T, S, Z, J, L a odpadu (index = hodnota buňky). */
|
||||||
|
const COLORS = ['', '#22d3ee', '#facc15', '#a855f7', '#22c55e', '#ef4444', '#3b82f6', '#f97316', '#6b7280'];
|
||||||
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
|
const CELL = 26;
|
||||||
|
const MINI_CELL = 9;
|
||||||
|
/** Jak často se soupeřům posílá deska s padající kostkou (ms). */
|
||||||
|
const SNAPSHOT_MS = 250;
|
||||||
|
/**
|
||||||
|
* Deska se posílá i časovačem — běží i v neaktivním tabu (na rozdíl od requestAnimationFrame),
|
||||||
|
* takže server pozná, že hráč pořád hraje. Musí být výrazně kratší než PLAYER_TIMEOUT_MS na serveru.
|
||||||
|
*/
|
||||||
|
const HEARTBEAT_MS = 2000;
|
||||||
|
|
||||||
|
const KEYS: Record<string, 'left' | 'right' | 'down' | 'rotate' | 'rotateBack' | 'drop'> = {
|
||||||
|
ArrowLeft: 'left', KeyA: 'left',
|
||||||
|
ArrowRight: 'right', KeyD: 'right',
|
||||||
|
ArrowDown: 'down', KeyS: 'down',
|
||||||
|
ArrowUp: 'rotate', KeyW: 'rotate', KeyX: 'rotate',
|
||||||
|
KeyZ: 'rotateBack', KeyY: 'rotateBack',
|
||||||
|
Space: 'drop',
|
||||||
|
};
|
||||||
|
|
||||||
|
function drawCell(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, value: number) {
|
||||||
|
ctx.fillStyle = COLORS[value];
|
||||||
|
ctx.fillRect(x * size + 1, y * size + 1, size - 2, size - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vykreslí desku zadanou řetězcem (soupeři) nebo polem (vlastní hra). */
|
||||||
|
function drawBoard(canvas: HTMLCanvasElement, cells: ArrayLike<number | string>, size: number, piece?: Piece | null, ghost?: Piece | null) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.fillStyle = '#111827';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
for (let i = 0; i < WIDTH * HEIGHT; i++) {
|
||||||
|
const v = Number(cells[i]);
|
||||||
|
if (v) drawCell(ctx, i % WIDTH, Math.floor(i / WIDTH), size, v);
|
||||||
|
}
|
||||||
|
if (ghost) {
|
||||||
|
ctx.globalAlpha = 0.25;
|
||||||
|
pieceCells(ghost).forEach(([x, y]) => y >= 0 && drawCell(ctx, x, y, size, ghost.type + 1));
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
if (piece) {
|
||||||
|
pieceCells(piece).forEach(([x, y]) => y >= 0 && drawCell(ctx, x, y, size, piece.type + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Náhled příští kostky. */
|
||||||
|
function drawNext(canvas: HTMLCanvasElement, type: number) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const cells = pieceCells({ type, rot: 0, x: 0, y: 0 });
|
||||||
|
const minX = Math.min(...cells.map(c => c[0]));
|
||||||
|
const minY = Math.min(...cells.map(c => c[1]));
|
||||||
|
cells.forEach(([x, y]) => drawCell(ctx, x - minX + 0.5, y - minY + 0.5, 20, type + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Malá deska soupeře. */
|
||||||
|
function MiniBoard({ player }: { player: TetrisPlayer }) {
|
||||||
|
const ref = useRef<HTMLCanvasElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) drawBoard(ref.current, player.board, MINI_CELL);
|
||||||
|
}, [player.board]);
|
||||||
|
return (
|
||||||
|
<div className={`tetris-mini ${player.alive ? '' : 'dead'}`}>
|
||||||
|
<canvas ref={ref} width={WIDTH * MINI_CELL} height={HEIGHT * MINI_CELL} />
|
||||||
|
<div className="tetris-mini-name">{player.login}{!player.alive && ' 💀'}</div>
|
||||||
|
<div className="tetris-mini-score">{player.score}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multiplayerový Tetris (battle) — vlastní deska běží v prohlížeči, server koordinuje hru. */
|
||||||
|
export default function TetrisGame() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const login = auth?.login;
|
||||||
|
const [state, setState] = useState<TetrisState | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [flash, setFlash] = useState<string | null>(null);
|
||||||
|
/** Překreslení postranních údajů (skóre, řádky) po změně v session. */
|
||||||
|
const [, setVersion] = useState(0);
|
||||||
|
/** Trénink: hra jen v prohlížeči, bez serveru a soupeřů. */
|
||||||
|
const [practice, setPractice] = useState(false);
|
||||||
|
const sessionRef = useRef<TetrisSession | null>(null);
|
||||||
|
const sessionSeed = useRef<number | undefined>(undefined);
|
||||||
|
const boardRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const nextRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const prevPhase = useRef<TetrisPhase>('idle');
|
||||||
|
const icon = useGameIcon('tetris');
|
||||||
|
|
||||||
|
const send = useCallback((event: string, extra?: object) => {
|
||||||
|
setError(null);
|
||||||
|
socket.emit(event, { token: getToken(), ...extra });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendSnapshot = useCallback(() => {
|
||||||
|
const s = sessionRef.current;
|
||||||
|
if (s) send('tetris:update', { board: s.snapshot(), score: s.score, lines: s.lines });
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onState = (s: TetrisState) => setState(s);
|
||||||
|
const onError = (msg: string) => setError(msg);
|
||||||
|
const onOpen = () => setOpen(true);
|
||||||
|
const sync = () => socket.emit('tetris:sync');
|
||||||
|
socket.on('tetris:state', onState);
|
||||||
|
socket.on('tetris:error', onError);
|
||||||
|
socket.on('connect', sync);
|
||||||
|
window.addEventListener(OPEN_EVENT, onOpen);
|
||||||
|
sync();
|
||||||
|
return () => {
|
||||||
|
socket.off('tetris:state', onState);
|
||||||
|
socket.off('tetris:error', onError);
|
||||||
|
socket.off('connect', sync);
|
||||||
|
window.removeEventListener(OPEN_EVENT, onOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Průběžné desky soupeřů a útoky dostávají jen klienti s otevřeným oknem
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const watch = () => socket.emit('tetris:watch');
|
||||||
|
watch();
|
||||||
|
socket.on('connect', watch);
|
||||||
|
return () => {
|
||||||
|
socket.off('connect', watch);
|
||||||
|
socket.emit('tetris:unwatch');
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onGarbage = (e: GarbageEvent) => {
|
||||||
|
if (e.target !== login || !sessionRef.current?.alive) return;
|
||||||
|
sessionRef.current.receiveGarbage(e.lines);
|
||||||
|
setFlash(`⚠️ +${e.lines} od ${e.from}`);
|
||||||
|
window.setTimeout(() => setFlash(null), 1500);
|
||||||
|
};
|
||||||
|
socket.on('tetris:garbage', onGarbage);
|
||||||
|
return () => { socket.off('tetris:garbage', onGarbage); };
|
||||||
|
}, [login]);
|
||||||
|
|
||||||
|
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
|
||||||
|
const phase = state?.phase ?? 'idle';
|
||||||
|
const inGame = phase === 'countdown' || phase === 'playing';
|
||||||
|
|
||||||
|
// Hráčům se okno otevře samo, jakmile hra začne
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevPhase.current === 'lobby' && phase === 'countdown' && isPlayer) {
|
||||||
|
setPractice(false);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
prevPhase.current = phase;
|
||||||
|
}, [phase, isPlayer]);
|
||||||
|
|
||||||
|
// Start vlastní hry se seedem od serveru
|
||||||
|
useEffect(() => {
|
||||||
|
if (practice || phase !== 'playing' || !isPlayer || state?.seed === undefined || sessionSeed.current === state.seed) return;
|
||||||
|
sessionSeed.current = state.seed;
|
||||||
|
sessionRef.current = new TetrisSession(state.seed, {
|
||||||
|
onLock: cleared => {
|
||||||
|
if (cleared >= 2) send('tetris:cleared', { count: cleared });
|
||||||
|
sendSnapshot();
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
},
|
||||||
|
onDeath: () => {
|
||||||
|
sendSnapshot();
|
||||||
|
send('tetris:died');
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
}, [practice, phase, isPlayer, state?.seed, send, sendSnapshot]);
|
||||||
|
|
||||||
|
// Herní smyčka: gravitace, vykreslení a průběžné posílání desky (trénink se při zavřeném okně pozastaví)
|
||||||
|
useEffect(() => {
|
||||||
|
if (practice ? !open : phase !== 'playing') return;
|
||||||
|
if (!sessionRef.current) return;
|
||||||
|
let raf = 0;
|
||||||
|
let lastFall = performance.now();
|
||||||
|
let lastSnapshot = 0;
|
||||||
|
const loop = (now: number) => {
|
||||||
|
const s = sessionRef.current;
|
||||||
|
if (!s) return;
|
||||||
|
if (s.alive && now - lastFall >= s.gravityMs) {
|
||||||
|
s.tick();
|
||||||
|
lastFall = now;
|
||||||
|
}
|
||||||
|
if (!practice && s.alive && now - lastSnapshot >= SNAPSHOT_MS) {
|
||||||
|
sendSnapshot();
|
||||||
|
lastSnapshot = now;
|
||||||
|
}
|
||||||
|
if (boardRef.current) drawBoard(boardRef.current, s.board, CELL, s.active, s.ghost);
|
||||||
|
if (nextRef.current) drawNext(nextRef.current, s.nextType);
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [practice, phase, open, sendSnapshot, state?.seed]);
|
||||||
|
|
||||||
|
// Heartbeat pro server (viz HEARTBEAT_MS)
|
||||||
|
useEffect(() => {
|
||||||
|
if (practice || phase !== 'playing' || !isPlayer) return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
if (sessionRef.current?.alive) sendSnapshot();
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [practice, phase, isPlayer, sendSnapshot]);
|
||||||
|
|
||||||
|
// Ovládání
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !(practice || (phase === 'playing' && isPlayer))) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const action = KEYS[e.code];
|
||||||
|
const s = sessionRef.current;
|
||||||
|
if (!action || !s) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (!s.alive) return;
|
||||||
|
switch (action) {
|
||||||
|
case 'left': s.move(-1); break;
|
||||||
|
case 'right': s.move(1); break;
|
||||||
|
case 'down': s.softDrop(); break;
|
||||||
|
case 'rotate': if (!e.repeat) s.rotate(1); break;
|
||||||
|
case 'rotateBack': if (!e.repeat) s.rotate(-1); break;
|
||||||
|
case 'drop': if (!e.repeat) s.hardDrop(); break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [open, practice, phase, isPlayer]);
|
||||||
|
|
||||||
|
// Po skončení hry se session zahodí, ať se příště začíná načisto
|
||||||
|
useEffect(() => {
|
||||||
|
if (!practice && (phase === 'lobby' || phase === 'idle')) {
|
||||||
|
sessionRef.current = null;
|
||||||
|
sessionSeed.current = undefined;
|
||||||
|
}
|
||||||
|
}, [practice, phase]);
|
||||||
|
|
||||||
|
const startPractice = useCallback(() => {
|
||||||
|
sessionSeed.current = undefined;
|
||||||
|
sessionRef.current = new TetrisSession(Math.floor(Math.random() * 2 ** 31), {
|
||||||
|
onLock: () => setVersion(v => v + 1),
|
||||||
|
onDeath: () => setVersion(v => v + 1),
|
||||||
|
});
|
||||||
|
setPractice(true);
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopPractice = useCallback(() => {
|
||||||
|
sessionRef.current = null;
|
||||||
|
setPractice(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!login) return null;
|
||||||
|
|
||||||
|
const session = sessionRef.current;
|
||||||
|
const showInvite = !open && phase === 'lobby' && !isPlayer;
|
||||||
|
const showReturn = !open && isPlayer && inGame;
|
||||||
|
const others = practice ? [] : (state?.players ?? []).filter(p => !(isPlayer && p.login === login));
|
||||||
|
const ranked = [...(state?.players ?? [])].sort((a, b) => (a.place ?? 99) - (b.place ?? 99));
|
||||||
|
|
||||||
|
const renderPlaying = () => (
|
||||||
|
<div className="tetris-layout">
|
||||||
|
{(isPlayer || practice) && (
|
||||||
|
<div className="tetris-own">
|
||||||
|
<div className="tetris-board-wrap">
|
||||||
|
<canvas ref={boardRef} className="tetris-board" width={WIDTH * CELL} height={HEIGHT * CELL} />
|
||||||
|
{!practice && phase === 'countdown' && <div className="tetris-overlay">{state?.countdown}</div>}
|
||||||
|
{session && !session.alive && <div className="tetris-overlay small">Konec 💀</div>}
|
||||||
|
{flash && <div className="tetris-flash">{flash}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="tetris-side">
|
||||||
|
<div className="tetris-label">Další</div>
|
||||||
|
<canvas ref={nextRef} width={100} height={60} />
|
||||||
|
<div className="tetris-label">Skóre</div>
|
||||||
|
<div className="tetris-value">{session?.score ?? 0}</div>
|
||||||
|
<div className="tetris-label">Řádky</div>
|
||||||
|
<div className="tetris-value">{session?.lines ?? 0}</div>
|
||||||
|
<div className="tetris-label">Úroveň</div>
|
||||||
|
<div className="tetris-value">{session?.level ?? 0}</div>
|
||||||
|
<div className="tetris-help">
|
||||||
|
←→ / AD posun<br />↑ / W otočit<br />↓ / S dolů<br />mezerník shodit
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{others.length > 0 && (
|
||||||
|
<div className="tetris-opponents">
|
||||||
|
{others.map(p => <MiniBoard key={p.login} player={p} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBody = () => {
|
||||||
|
if (practice) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="text-muted text-center">🎯 Trénink — hra jen pro vás, nikdo ji nevidí.</p>
|
||||||
|
{renderPlaying()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
switch (phase) {
|
||||||
|
case 'idle':
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
Tetris pro více hráčů. Všichni dostávají stejné kostky; kdo smaže 2, 3 nebo 4 řádky naráz,
|
||||||
|
pošle náhodnému soupeři 1, 2 nebo 4 odpadní řádky. Vyhrává poslední hráč ve hře.
|
||||||
|
Hrát se dá i sólo, nebo si kdykoli zatrénovat jen pro sebe.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
case 'lobby':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
|
||||||
|
<ul className="tetris-players">
|
||||||
|
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'countdown':
|
||||||
|
case 'playing':
|
||||||
|
return renderPlaying();
|
||||||
|
case 'finished':
|
||||||
|
return (
|
||||||
|
<Table size="sm" className="tetris-results">
|
||||||
|
<thead>
|
||||||
|
<tr><th>#</th><th>Hráč</th><th>Skóre</th><th>Řádky</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ranked.map((p, i) => (
|
||||||
|
<tr key={p.login}>
|
||||||
|
<td>{i < 3 ? MEDALS[i] : i + 1}</td>
|
||||||
|
<td>{p.login}{p.login === login && ' (vy)'}</td>
|
||||||
|
<td>{p.score}</td>
|
||||||
|
<td>{p.lines}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
const buttons = [];
|
||||||
|
if (practice) {
|
||||||
|
if (session && !session.alive) {
|
||||||
|
buttons.push(<Button key="again" onClick={startPractice}>Znovu</Button>);
|
||||||
|
}
|
||||||
|
buttons.push(<Button key="stop" variant="outline-secondary" onClick={stopPractice}>Ukončit trénink</Button>);
|
||||||
|
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
||||||
|
return buttons;
|
||||||
|
}
|
||||||
|
// Trénink jde spustit, kdykoli zrovna nehrajete online hru
|
||||||
|
if (!(isPlayer && (phase === 'lobby' || inGame))) {
|
||||||
|
buttons.push(<Button key="practice" variant="outline-primary" onClick={startPractice}>Trénink</Button>);
|
||||||
|
}
|
||||||
|
if (phase === 'idle' || phase === 'finished') {
|
||||||
|
buttons.push(<Button key="new" onClick={() => send('tetris:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
||||||
|
}
|
||||||
|
if (phase === 'lobby') {
|
||||||
|
if (!isPlayer) {
|
||||||
|
buttons.push(<Button key="join" onClick={() => send('tetris:join')}>Připojit se</Button>);
|
||||||
|
} else {
|
||||||
|
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('tetris:leave')}>
|
||||||
|
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
||||||
|
</Button>);
|
||||||
|
if (login === state?.host) {
|
||||||
|
buttons.push(<Button key="start" onClick={() => send('tetris:start')}
|
||||||
|
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 1)}>Spustit hru</Button>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
||||||
|
return buttons;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showInvite && (
|
||||||
|
<button className="duel-invite tetris-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} <strong>{state?.host}</strong> zve na Tetris — připojit se
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showReturn && (
|
||||||
|
<button className="duel-invite tetris-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} Tetris běží — zpět do hry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="tetris-game-modal">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>{icon} Tetris</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{renderBody()}
|
||||||
|
{error && <div className="text-danger mt-2">{error}</div>}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Herní logika Tetrisu bez Reactu — deska, kostky, rotace s odrazem od stěn,
|
||||||
|
* mazání řádků a odpadní řádky od soupeřů.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const WIDTH = 10;
|
||||||
|
export const HEIGHT = 20;
|
||||||
|
/** Hodnota buňky odpadního řádku (1–7 jsou kostky). */
|
||||||
|
export const GARBAGE = 8;
|
||||||
|
|
||||||
|
type Point = [number, number];
|
||||||
|
|
||||||
|
/** Kostky I, O, T, S, Z, J, L ve výchozí rotaci a velikost jejich čtverce pro rotaci. */
|
||||||
|
const SHAPES: { cells: Point[], box: number }[] = [
|
||||||
|
{ cells: [[0, 1], [1, 1], [2, 1], [3, 1]], box: 4 },
|
||||||
|
{ cells: [[0, 0], [1, 0], [0, 1], [1, 1]], box: 2 },
|
||||||
|
{ cells: [[1, 0], [0, 1], [1, 1], [2, 1]], box: 3 },
|
||||||
|
{ cells: [[1, 0], [2, 0], [0, 1], [1, 1]], box: 3 },
|
||||||
|
{ cells: [[0, 0], [1, 0], [1, 1], [2, 1]], box: 3 },
|
||||||
|
{ cells: [[0, 0], [0, 1], [1, 1], [2, 1]], box: 3 },
|
||||||
|
{ cells: [[2, 0], [0, 1], [1, 1], [2, 1]], box: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Předpočítané rotace (po směru hodinových ručiček) pro každou kostku. */
|
||||||
|
const ROTATIONS: Point[][][] = SHAPES.map(({ cells, box }) => {
|
||||||
|
const result = [cells];
|
||||||
|
for (let r = 1; r < 4; r++) {
|
||||||
|
result.push(result[r - 1].map(([x, y]) => [box - 1 - y, x] as Point));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Posuny zkoušené při rotaci, když kostka narazí (zjednodušený wall kick). */
|
||||||
|
const KICKS: Point[] = [[0, 0], [-1, 0], [1, 0], [0, -1], [-2, 0], [2, 0]];
|
||||||
|
|
||||||
|
/** Body za smazání 0–4 řádků naráz (násobí se úrovní). */
|
||||||
|
const LINE_SCORES = [0, 100, 300, 500, 800];
|
||||||
|
|
||||||
|
export type Piece = { type: number, rot: number, x: number, y: number };
|
||||||
|
|
||||||
|
/** Deterministický generátor (mulberry32) — stejný seed = stejné pořadí kostek u všech. */
|
||||||
|
function mulberry32(seed: number) {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x6D2B79F5) >>> 0;
|
||||||
|
let t = a;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pieceCells = (p: Piece): Point[] => ROTATIONS[p.type][p.rot].map(([x, y]) => [p.x + x, p.y + y]);
|
||||||
|
|
||||||
|
export type SessionCallbacks = {
|
||||||
|
/** Kostka dopadla; `cleared` = počet smazaných řádků. */
|
||||||
|
onLock: (cleared: number) => void,
|
||||||
|
onDeath: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Jedna rozehraná hra jednoho hráče. */
|
||||||
|
export class TetrisSession {
|
||||||
|
board: number[] = new Array(WIDTH * HEIGHT).fill(0);
|
||||||
|
active: Piece | null = null;
|
||||||
|
nextType = 0;
|
||||||
|
score = 0;
|
||||||
|
lines = 0;
|
||||||
|
alive = true;
|
||||||
|
private random: () => number;
|
||||||
|
private bag: number[] = [];
|
||||||
|
private pendingGarbage = 0;
|
||||||
|
|
||||||
|
constructor(seed: number, private readonly callbacks: SessionCallbacks) {
|
||||||
|
this.random = mulberry32(seed);
|
||||||
|
this.nextType = this.draw();
|
||||||
|
this.spawn();
|
||||||
|
}
|
||||||
|
|
||||||
|
get level() {
|
||||||
|
return Math.floor(this.lines / 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interval gravitace v ms — s úrovní zrychluje. */
|
||||||
|
get gravityMs() {
|
||||||
|
return Math.max(80, 800 - this.level * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pozice "stínu" — kam by kostka dopadla. */
|
||||||
|
get ghost(): Piece | null {
|
||||||
|
if (!this.active) return null;
|
||||||
|
let p = this.active;
|
||||||
|
while (this.fits({ ...p, y: p.y + 1 })) p = { ...p, y: p.y + 1 };
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
tick() {
|
||||||
|
if (!this.alive || !this.active) return;
|
||||||
|
if (!this.tryMove(0, 1)) this.lock();
|
||||||
|
}
|
||||||
|
|
||||||
|
move(dx: number) {
|
||||||
|
if (this.alive) this.tryMove(dx, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
softDrop() {
|
||||||
|
if (!this.alive) return;
|
||||||
|
if (this.tryMove(0, 1)) this.score += 1;
|
||||||
|
else this.lock();
|
||||||
|
}
|
||||||
|
|
||||||
|
hardDrop() {
|
||||||
|
if (!this.alive || !this.active) return;
|
||||||
|
let dropped = 0;
|
||||||
|
while (this.tryMove(0, 1)) dropped++;
|
||||||
|
this.score += dropped * 2;
|
||||||
|
this.lock();
|
||||||
|
}
|
||||||
|
|
||||||
|
rotate(dir: 1 | -1) {
|
||||||
|
if (!this.alive || !this.active) return;
|
||||||
|
const rot = (this.active.rot + dir + 4) % 4;
|
||||||
|
for (const [kx, ky] of KICKS) {
|
||||||
|
const candidate = { ...this.active, rot, x: this.active.x + kx, y: this.active.y + ky };
|
||||||
|
if (this.fits(candidate)) {
|
||||||
|
this.active = candidate;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Odpadní řádky se přidají až po dopadu aktuální kostky (jako v klasickém battle módu). */
|
||||||
|
receiveGarbage(lines: number) {
|
||||||
|
this.pendingGarbage += lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deska včetně padající kostky (pro soupeře), znaky 0–8. */
|
||||||
|
snapshot(): string {
|
||||||
|
const cells = [...this.board];
|
||||||
|
if (this.active) {
|
||||||
|
for (const [x, y] of pieceCells(this.active)) {
|
||||||
|
if (y >= 0) cells[y * WIDTH + x] = this.active.type + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cells.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
private draw(): number {
|
||||||
|
if (this.bag.length === 0) {
|
||||||
|
this.bag = [0, 1, 2, 3, 4, 5, 6];
|
||||||
|
for (let i = this.bag.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(this.random() * (i + 1));
|
||||||
|
[this.bag[i], this.bag[j]] = [this.bag[j], this.bag[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.bag.pop()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawn() {
|
||||||
|
const type = this.nextType;
|
||||||
|
this.nextType = this.draw();
|
||||||
|
const piece: Piece = { type, rot: 0, x: type === 1 ? 4 : 3, y: type === 0 ? -1 : 0 };
|
||||||
|
if (!this.fits(piece)) {
|
||||||
|
this.active = null;
|
||||||
|
this.die();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.active = piece;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fits(p: Piece) {
|
||||||
|
return pieceCells(p).every(([x, y]) =>
|
||||||
|
x >= 0 && x < WIDTH && y < HEIGHT && (y < 0 || this.board[y * WIDTH + x] === 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryMove(dx: number, dy: number) {
|
||||||
|
if (!this.active) return false;
|
||||||
|
const candidate = { ...this.active, x: this.active.x + dx, y: this.active.y + dy };
|
||||||
|
if (!this.fits(candidate)) return false;
|
||||||
|
this.active = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private lock() {
|
||||||
|
if (!this.active) return;
|
||||||
|
const cells = pieceCells(this.active);
|
||||||
|
// Kostka zamčená nad horním okrajem = konec hry
|
||||||
|
if (cells.some(([, y]) => y < 0)) {
|
||||||
|
this.die();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [x, y] of cells) this.board[y * WIDTH + x] = this.active.type + 1;
|
||||||
|
this.active = null;
|
||||||
|
|
||||||
|
const cleared = this.clearLines();
|
||||||
|
if (cleared > 0) {
|
||||||
|
this.score += LINE_SCORES[cleared] * (this.level + 1);
|
||||||
|
this.lines += cleared;
|
||||||
|
}
|
||||||
|
this.callbacks.onLock(cleared);
|
||||||
|
if (this.pendingGarbage > 0) {
|
||||||
|
const overflow = this.addGarbage(this.pendingGarbage);
|
||||||
|
this.pendingGarbage = 0;
|
||||||
|
if (overflow) {
|
||||||
|
this.die();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.spawn();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearLines(): number {
|
||||||
|
const rows: number[][] = [];
|
||||||
|
for (let y = 0; y < HEIGHT; y++) rows.push(this.board.slice(y * WIDTH, (y + 1) * WIDTH));
|
||||||
|
const kept = rows.filter(row => row.some(c => c === 0));
|
||||||
|
const cleared = HEIGHT - kept.length;
|
||||||
|
if (cleared > 0) {
|
||||||
|
const empty = Array.from({ length: cleared }, () => new Array(WIDTH).fill(0));
|
||||||
|
this.board = [...empty, ...kept].flat();
|
||||||
|
}
|
||||||
|
return cleared;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Přidá odpadní řádky zespodu s jednou dírou; vrátí true, pokud deska přetekla. */
|
||||||
|
private addGarbage(lines: number): boolean {
|
||||||
|
const overflow = this.board.slice(0, lines * WIDTH).some(c => c !== 0);
|
||||||
|
const hole = Math.floor(Math.random() * WIDTH);
|
||||||
|
const garbageRows = Array.from({ length: lines }, () =>
|
||||||
|
Array.from({ length: WIDTH }, (_, x) => x === hole ? 0 : GARBAGE));
|
||||||
|
this.board = [...this.board.slice(lines * WIDTH), ...garbageRows.flat()].slice(-WIDTH * HEIGHT);
|
||||||
|
return overflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private die() {
|
||||||
|
if (!this.alive) return;
|
||||||
|
this.alive = false;
|
||||||
|
this.callbacks.onDeath();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { StyleTheme, useSettings } from "../context/settings";
|
||||||
|
|
||||||
|
export type GameId = 'reaction' | 'snake' | 'tetris' | 'pong' | 'drawing';
|
||||||
|
|
||||||
|
/** Ikony miniher podle vizuálního stylu aplikace. */
|
||||||
|
const ICONS: Record<StyleTheme, Record<GameId, string>> = {
|
||||||
|
simple: { reaction: '⚡', snake: '🐍', tetris: '🧱', pong: '🏓', drawing: '🎨' },
|
||||||
|
// Dostihy: startovní vlajka, kůň místo hada, mrkve jako kostky, žokej, trofej
|
||||||
|
umamusume: { reaction: '🏁', snake: '🐎', tetris: '🥕', pong: '🏇', drawing: '🏆' },
|
||||||
|
// Duha, jednorožec, drahokamy, balónky a paleta
|
||||||
|
mlp: { reaction: '🌈', snake: '🦄', tetris: '💎', pong: '🎈', drawing: '🖌️' },
|
||||||
|
// Pekelná korespondence: plamen, rudá nit, lampion, slaměná panenka, svitek
|
||||||
|
hellgirl: { reaction: '🔥', snake: '🪢', tetris: '🏮', pong: '🪆', drawing: '📜' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Styl z <html data-luncher-theme> — pro místa mimo React kontext nastavení. */
|
||||||
|
function currentTheme(): StyleTheme {
|
||||||
|
const theme = document.documentElement.getAttribute('data-luncher-theme') as StyleTheme | null;
|
||||||
|
return theme && theme in ICONS ? theme : 'simple';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ikona hry pro aktuální styl (mimo React, např. výpis do konzole). */
|
||||||
|
export const gameIcon = (game: GameId, theme: StyleTheme = currentTheme()) => ICONS[theme]?.[game] ?? ICONS.simple[game];
|
||||||
|
|
||||||
|
/** Ikona hry pro aktuální styl; stránky bez nastavení (statistiky) použijí atribut na <html>. */
|
||||||
|
export function useGameIcon(game: GameId) {
|
||||||
|
const settings = useSettings();
|
||||||
|
return gameIcon(game, settings?.styleTheme ?? currentTheme());
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { openReactionDuel } from "../components/ReactionDuel";
|
||||||
|
import { openSnakeGame } from "../components/SnakeGame";
|
||||||
|
import { openTetrisGame } from "../components/TetrisGame";
|
||||||
|
import { openPongGame } from "../components/PongGame";
|
||||||
|
import { openDrawingGame } from "../components/DrawingGame";
|
||||||
|
import { GameId, gameIcon } from "../games/themeIcons";
|
||||||
|
|
||||||
|
type Minigame = {
|
||||||
|
id: GameId,
|
||||||
|
/** Kód, který se napíše po sérii kliknutí na logo. */
|
||||||
|
code: string,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
open: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Skryté minihry dostupné přes logo. Novou hru stačí přidat sem. */
|
||||||
|
const MINIGAMES: Minigame[] = [
|
||||||
|
{
|
||||||
|
id: 'reaction',
|
||||||
|
code: 'reakce',
|
||||||
|
name: 'Reakční hra',
|
||||||
|
description: 'Kdo klikne nejrychleji? Hraje se s kolegy online, 3 kola.',
|
||||||
|
open: openReactionDuel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snake',
|
||||||
|
code: 'snake',
|
||||||
|
name: 'Had',
|
||||||
|
description: 'Had pro více hráčů, ovládání šipkami nebo WSAD. Vyhrává poslední přeživší.',
|
||||||
|
open: openSnakeGame,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tetris',
|
||||||
|
code: 'tetris',
|
||||||
|
name: 'Tetris',
|
||||||
|
description: 'Tetris pro více hráčů — smazané řádky posílají soupeřům odpad.',
|
||||||
|
open: openTetrisGame,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pong',
|
||||||
|
code: 'pong',
|
||||||
|
name: 'Pong',
|
||||||
|
description: 'Pong pro dva, ovládání šipkami nebo W/S. Hraje se do 5 bodů.',
|
||||||
|
open: openPongGame,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'drawing',
|
||||||
|
code: 'kresleni',
|
||||||
|
name: 'Kreslení',
|
||||||
|
description: 'Jeden kreslí jídlo z dnešních menu, ostatní hádají.',
|
||||||
|
open: openDrawingGame,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const HELP_CODE = 'help';
|
||||||
|
const SECRET_CLICKS = 3;
|
||||||
|
/** Max. pauza mezi kliknutími na logo, aby se počítala do série (ms). */
|
||||||
|
const SECRET_CLICK_GAP_MS = 400;
|
||||||
|
/** Jak dlouho po sérii kliknutí lze napsat kód (ms). */
|
||||||
|
const SECRET_ARM_MS = 8000;
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log('%c🎮 Luncher minihry', 'font-size: 16px; font-weight: bold');
|
||||||
|
console.log('Klikněte 3× na logo a napište kód hry:');
|
||||||
|
console.table(MINIGAMES.map(g => ({ kód: g.code, hra: `${gameIcon(g.id)} ${g.name}`, popis: g.description })));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skrytý vstup do miniher: 3× rychle kliknout na logo a pak napsat kód hry,
|
||||||
|
* případně "help" pro výpis her do konzole prohlížeče.
|
||||||
|
* Jedno či dvě kliknutí se chovají jako běžný odkaz (jen s malým zpožděním).
|
||||||
|
*/
|
||||||
|
export function useMinigameSecret(href: string) {
|
||||||
|
const [armed, setArmed] = useState(false);
|
||||||
|
const clicks = useRef(0);
|
||||||
|
const timer = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
const onLogoClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||||
|
// Otevření v novém tabu apod. necháme prohlížeči
|
||||||
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
window.clearTimeout(timer.current);
|
||||||
|
clicks.current++;
|
||||||
|
if (clicks.current >= SECRET_CLICKS) {
|
||||||
|
clicks.current = 0;
|
||||||
|
setArmed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer.current = window.setTimeout(() => {
|
||||||
|
clicks.current = 0;
|
||||||
|
window.location.assign(href);
|
||||||
|
}, SECRET_CLICK_GAP_MS);
|
||||||
|
}, [href]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!armed) return;
|
||||||
|
const maxLength = Math.max(HELP_CODE.length, ...MINIGAMES.map(g => g.code.length));
|
||||||
|
let typed = '';
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('input, textarea, select, [contenteditable="true"]') || e.key.length !== 1) return;
|
||||||
|
typed = (typed + e.key.toLowerCase()).slice(-maxLength);
|
||||||
|
if (typed.endsWith(HELP_CODE)) {
|
||||||
|
setArmed(false);
|
||||||
|
printHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const game = MINIGAMES.find(g => typed.endsWith(g.code));
|
||||||
|
if (game) {
|
||||||
|
setArmed(false);
|
||||||
|
game.open();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const disarm = window.setTimeout(() => setArmed(false), SECRET_ARM_MS);
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(disarm);
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [armed]);
|
||||||
|
|
||||||
|
useEffect(() => () => window.clearTimeout(timer.current), []);
|
||||||
|
|
||||||
|
return { armed, onLogoClick };
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
[
|
[
|
||||||
"Možnost v nastavení úplně vypnout poletující motýlky"
|
"Možnost v nastavení úplně vypnout poletující motýlky",
|
||||||
|
"Nové skryté minihry pro hraní s kolegy – reakční hra, had, Tetris (i s tréninkem), Pong (ovládání myší, i proti botovi) a kreslení jídel z dnešního menu. Zkuste je najít 😉"
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import { Server } from "socket.io";
|
||||||
|
import { Clock, defaultClock, GameError, gameHandler, loginFromPayload, minPlayersForEnv } from "./minigames";
|
||||||
|
import { getActiveRestaurants, getRestaurantMenu } from "./service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kreslení — jeden hráč kreslí jídlo z dnešních menu, ostatní hádají v chatu.
|
||||||
|
*
|
||||||
|
* Každý hráč jednou kreslí. Hádá se klíčové slovo jídla (např. "guláš" u
|
||||||
|
* "Plzeňský hovězí guláš"), diakritika ani drobné koncovky nevadí. Kdo uhodne dřív,
|
||||||
|
* dostane víc bodů; kreslíř dostává body za každého, kdo uhodl.
|
||||||
|
*
|
||||||
|
* Hádané slovo dostává jen kreslíř (osobní místnost), ostatním chodí maska s nápovědou.
|
||||||
|
* Stav je v paměti, předpokládá jednu repliku.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ROUND_MS = 80_000;
|
||||||
|
export const REVEAL_MS = 5000;
|
||||||
|
export const MAX_PLAYERS = 10;
|
||||||
|
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
|
export const GUESS_MAX_POINTS = 100;
|
||||||
|
export const GUESS_MIN_POINTS = 20;
|
||||||
|
export const DRAWER_POINTS_PER_GUESS = 25;
|
||||||
|
/** Podíly času kola, kdy se v nápovědě odkryje další písmeno. */
|
||||||
|
const HINT_REVEALS = [0.4, 0.7];
|
||||||
|
const MAX_MESSAGES = 30;
|
||||||
|
const MAX_MESSAGE_LENGTH = 100;
|
||||||
|
/** Pojistka proti nekonečné kresbě (součet bodů všech tahů v kole). */
|
||||||
|
const MAX_POINTS_PER_ROUND = 20_000;
|
||||||
|
export const WATCH_ROOM = 'draw-watchers';
|
||||||
|
export const userRoom = (login: string) => `draw-user:${login}`;
|
||||||
|
|
||||||
|
/** Záložní slova, když dnešní menu nejsou k dispozici (víkend, výpadek). */
|
||||||
|
export const FALLBACK_WORDS = [
|
||||||
|
'pizza', 'guláš', 'řízek', 'svíčková', 'burger', 'palačinky', 'knedlíky', 'špagety', 'sushi', 'kebab',
|
||||||
|
'salát', 'hranolky', 'párek', 'koláč', 'dort', 'bramboráky', 'rizoto', 'lasagne', 'tacos', 'vepřo knedlo zelo',
|
||||||
|
];
|
||||||
|
|
||||||
|
export type DrawPhase = 'idle' | 'lobby' | 'drawing' | 'reveal' | 'finished';
|
||||||
|
|
||||||
|
export type DrawPlayer = { login: string, score: number, guessed: boolean, place?: number };
|
||||||
|
export type ChatMessage = { login?: string, text: string, system?: boolean };
|
||||||
|
/** Tah štětcem; souřadnice 0–1000 nezávisle na velikosti plátna. */
|
||||||
|
export type Stroke = { color: string, size: number, points: [number, number][], start: boolean };
|
||||||
|
|
||||||
|
export type DrawState = {
|
||||||
|
phase: DrawPhase,
|
||||||
|
host?: string,
|
||||||
|
players: DrawPlayer[],
|
||||||
|
drawer?: string,
|
||||||
|
round: number,
|
||||||
|
totalRounds: number,
|
||||||
|
/** Maska hádaného jídla, např. "g _ _ _ š". */
|
||||||
|
hint?: string,
|
||||||
|
/** Řešení — jen ve fázi reveal a finished. */
|
||||||
|
answer?: string,
|
||||||
|
/** Zbývající čas kola (ms) v okamžiku odeslání. */
|
||||||
|
remainingMs?: number,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
minPlayers: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Word = { display: string, keywords: string[] };
|
||||||
|
|
||||||
|
/** Malá písmena bez diakritiky a nadbytečných mezer. */
|
||||||
|
export const normalize = (s: string) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Z názvu jídla v menu udělá hádatelné slovo: bez alergenů, jen hlavní část
|
||||||
|
* (před čárkou a přílohou "s/se ..."), klíčová slova bez přídavných jmen.
|
||||||
|
*/
|
||||||
|
export function toWord(dish: string): Word {
|
||||||
|
let display = dish.replace(/\([^)]*\)/g, '').split(',')[0].split(/\s+(?:s|se|a|na)\s+/i)[0].replace(/\s+/g, ' ').trim();
|
||||||
|
const words = display.split(' ');
|
||||||
|
if (display.length > 30) display = words.slice(0, 3).join(' ');
|
||||||
|
// Přídavná jména v češtině končí obvykle na -ý/-á/-é/-í ("Plzeňský hovězí guláš" → guláš)
|
||||||
|
let keywords = display.split(' ').filter(w => w.length >= 4 && !/[ýáéí]$/i.test(w)).map(normalize);
|
||||||
|
if (keywords.length === 0) keywords = display.split(' ').filter(w => w.length >= 3).map(normalize);
|
||||||
|
if (keywords.length === 0) keywords = [normalize(display)];
|
||||||
|
return { display, keywords };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uhodl hráč? Stačí jedno klíčové slovo; tolerují se koncovky (knedlík ~ knedlíky). */
|
||||||
|
export function isCorrectGuess(guess: string, word: Word): boolean {
|
||||||
|
const g = normalize(guess);
|
||||||
|
if (!g) return false;
|
||||||
|
if (g === normalize(word.display)) return true;
|
||||||
|
return g.split(' ').some(gw => word.keywords.some(kw => {
|
||||||
|
if (gw === kw) return true;
|
||||||
|
const need = Math.max(4, kw.length - 2);
|
||||||
|
return gw.length >= need && kw.slice(0, need) === gw.slice(0, need);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DrawingGame {
|
||||||
|
private phase: DrawPhase = 'idle';
|
||||||
|
private host?: string;
|
||||||
|
private players: DrawPlayer[] = [];
|
||||||
|
private order: string[] = [];
|
||||||
|
private round = 0;
|
||||||
|
private word?: Word;
|
||||||
|
private words: Word[] = [];
|
||||||
|
private revealed = new Set<number>();
|
||||||
|
private roundEndsAt = 0;
|
||||||
|
private messages: ChatMessage[] = [];
|
||||||
|
private strokes: Stroke[] = [];
|
||||||
|
private strokePoints = 0;
|
||||||
|
private starting = false;
|
||||||
|
private timer: unknown;
|
||||||
|
private hintTimers: unknown[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly broadcastAll: (state: DrawState) => void,
|
||||||
|
private readonly sendWord: (login: string, word: string) => void,
|
||||||
|
private readonly relayStroke: (stroke: Stroke) => void,
|
||||||
|
private readonly relayClear: () => void,
|
||||||
|
private readonly loadDishes: () => Promise<string[]>,
|
||||||
|
private readonly minPlayers = 2,
|
||||||
|
private readonly clock: Clock = defaultClock,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
get drawer(): string | undefined {
|
||||||
|
return this.phase === 'drawing' || this.phase === 'reveal' ? this.order[this.round - 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): DrawState {
|
||||||
|
const showAnswer = this.phase === 'reveal' || this.phase === 'finished';
|
||||||
|
return {
|
||||||
|
phase: this.phase,
|
||||||
|
host: this.host,
|
||||||
|
players: this.players.map(p => ({ ...p })),
|
||||||
|
drawer: this.drawer,
|
||||||
|
round: this.round,
|
||||||
|
totalRounds: this.order.length,
|
||||||
|
hint: this.phase === 'drawing' ? this.hint() : undefined,
|
||||||
|
answer: showAnswer ? this.word?.display : undefined,
|
||||||
|
remainingMs: this.phase === 'drawing' ? Math.max(0, this.roundEndsAt - this.clock.now()) : undefined,
|
||||||
|
messages: [...this.messages],
|
||||||
|
minPlayers: this.minPlayers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getStrokes(): Stroke[] {
|
||||||
|
return this.strokes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hádané slovo pro daného hráče (jen kreslíř během kola). */
|
||||||
|
wordFor(login: string): string | undefined {
|
||||||
|
return this.phase === 'drawing' && login === this.drawer ? this.word?.display : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
join(login: string) {
|
||||||
|
if (this.phase === 'idle' || this.phase === 'finished') {
|
||||||
|
this.reset();
|
||||||
|
this.phase = 'lobby';
|
||||||
|
this.host = login;
|
||||||
|
this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
|
||||||
|
} else if (this.phase !== 'lobby') {
|
||||||
|
throw new GameError('Hra už běží, počkejte na další.');
|
||||||
|
}
|
||||||
|
if (this.players.some(p => p.login === login)) return;
|
||||||
|
if (this.players.length >= MAX_PLAYERS) {
|
||||||
|
throw new GameError('Lobby je plné.');
|
||||||
|
}
|
||||||
|
this.players.push({ login, score: 0, guessed: false });
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
leave(login: string) {
|
||||||
|
if (this.phase !== 'lobby' || !this.players.some(p => p.login === login)) return;
|
||||||
|
if (login === this.host) {
|
||||||
|
this.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.players = this.players.filter(p => p.login !== login);
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(login: string) {
|
||||||
|
if (this.phase !== 'lobby' || this.starting) {
|
||||||
|
throw new GameError('Není otevřené žádné lobby.');
|
||||||
|
}
|
||||||
|
if (login !== this.host) {
|
||||||
|
throw new GameError('Hru může spustit jen ten, kdo lobby založil.');
|
||||||
|
}
|
||||||
|
if (this.players.length < this.minPlayers) {
|
||||||
|
throw new GameError(`Ke hře je potřeba alespoň ${this.minPlayers} hráčů.`);
|
||||||
|
}
|
||||||
|
this.starting = true;
|
||||||
|
let dishes: string[] = [];
|
||||||
|
try {
|
||||||
|
dishes = await this.loadDishes();
|
||||||
|
} catch {
|
||||||
|
// Menu nejsou k dispozici — hraje se se záložními slovy
|
||||||
|
} finally {
|
||||||
|
this.starting = false;
|
||||||
|
}
|
||||||
|
if (this.phase !== 'lobby') return;
|
||||||
|
const unique = [...new Map(dishes.map(toWord).map(w => [normalize(w.display), w])).values()];
|
||||||
|
// Dnešní jídla mají přednost; když dojdou, doplní se záložní slova (viz nextRound)
|
||||||
|
this.words = unique.length > 0 ? unique : FALLBACK_WORDS.map(toWord);
|
||||||
|
this.order = this.players.map(p => p.login);
|
||||||
|
this.round = 0;
|
||||||
|
this.nextRound();
|
||||||
|
}
|
||||||
|
|
||||||
|
stroke(login: string, payload: unknown) {
|
||||||
|
if (this.phase !== 'drawing' || login !== this.drawer) return;
|
||||||
|
const { color, size, points, start } = (payload ?? {}) as Partial<Stroke>;
|
||||||
|
if (typeof color !== 'string' || !/^#[0-9a-f]{6}$/i.test(color)) return;
|
||||||
|
if (typeof size !== 'number' || size < 1 || size > 40) return;
|
||||||
|
if (!Array.isArray(points) || points.length === 0 || points.length > 200) return;
|
||||||
|
if (!points.every(p => Array.isArray(p) && p.length === 2 && p.every(v => Number.isFinite(v) && v >= 0 && v <= 1000))) return;
|
||||||
|
if (this.strokePoints + points.length > MAX_POINTS_PER_ROUND) return;
|
||||||
|
const stroke: Stroke = { color, size, points: points.map(([x, y]) => [Math.round(x), Math.round(y)]), start: !!start };
|
||||||
|
this.strokes.push(stroke);
|
||||||
|
this.strokePoints += points.length;
|
||||||
|
this.relayStroke(stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(login: string) {
|
||||||
|
if (this.phase !== 'drawing' || login !== this.drawer) return;
|
||||||
|
this.strokes = [];
|
||||||
|
this.strokePoints = 0;
|
||||||
|
this.relayClear();
|
||||||
|
}
|
||||||
|
|
||||||
|
guess(login: string, text: unknown) {
|
||||||
|
if (typeof text !== 'string') return;
|
||||||
|
const message = text.trim().slice(0, MAX_MESSAGE_LENGTH);
|
||||||
|
const player = this.players.find(p => p.login === login);
|
||||||
|
if (!message || !player) return;
|
||||||
|
if (this.phase !== 'drawing') {
|
||||||
|
this.pushMessage({ login, text: message });
|
||||||
|
this.emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Kreslíř a ti, kdo už uhodli, by mohli prozradit řešení
|
||||||
|
if (login === this.drawer || player.guessed || !this.word) return;
|
||||||
|
if (isCorrectGuess(message, this.word)) {
|
||||||
|
const share = Math.max(0, this.roundEndsAt - this.clock.now()) / ROUND_MS;
|
||||||
|
player.guessed = true;
|
||||||
|
player.score += Math.max(GUESS_MIN_POINTS, Math.round(GUESS_MAX_POINTS * share));
|
||||||
|
const drawer = this.players.find(p => p.login === this.drawer);
|
||||||
|
if (drawer) drawer.score += DRAWER_POINTS_PER_GUESS;
|
||||||
|
this.pushMessage({ text: `${login} uhodl(a)! 🎉`, system: true });
|
||||||
|
const guessers = this.players.filter(p => p.login !== this.drawer);
|
||||||
|
if (guessers.every(p => p.guessed)) {
|
||||||
|
this.endRound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.pushMessage({ login, text: message });
|
||||||
|
}
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextRound() {
|
||||||
|
this.round++;
|
||||||
|
if (this.round > this.order.length) {
|
||||||
|
this.finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = Math.floor(this.clock.random() * this.words.length);
|
||||||
|
this.word = this.words.splice(index, 1)[0] ?? toWord(FALLBACK_WORDS[0]);
|
||||||
|
if (this.words.length === 0) this.words = FALLBACK_WORDS.map(toWord);
|
||||||
|
this.revealed.clear();
|
||||||
|
this.strokes = [];
|
||||||
|
this.strokePoints = 0;
|
||||||
|
this.players.forEach(p => { p.guessed = false; });
|
||||||
|
this.phase = 'drawing';
|
||||||
|
this.roundEndsAt = this.clock.now() + ROUND_MS;
|
||||||
|
this.pushMessage({ text: `Kreslí ${this.drawer}.`, system: true });
|
||||||
|
this.relayClear();
|
||||||
|
this.schedule(() => this.endRound(), ROUND_MS);
|
||||||
|
this.hintTimers = HINT_REVEALS.map(share => this.clock.setTimeout(() => this.revealLetter(), ROUND_MS * share));
|
||||||
|
this.emit();
|
||||||
|
this.sendWord(this.drawer!, this.word.display);
|
||||||
|
}
|
||||||
|
|
||||||
|
private revealLetter() {
|
||||||
|
if (this.phase !== 'drawing' || !this.word) return;
|
||||||
|
const hidden = [...this.word.display].map((c, i) => i).filter(i => this.word!.display[i] !== ' ' && !this.revealed.has(i));
|
||||||
|
// Poslední písmeno nikdy neodkrýváme
|
||||||
|
if (hidden.length <= 1) return;
|
||||||
|
this.revealed.add(hidden[Math.floor(this.clock.random() * hidden.length)]);
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private hint(): string {
|
||||||
|
if (!this.word) return '';
|
||||||
|
return [...this.word.display].map((c, i) => c === ' ' ? ' ' : this.revealed.has(i) ? c : '_').join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private endRound() {
|
||||||
|
this.clearHintTimers();
|
||||||
|
this.phase = 'reveal';
|
||||||
|
this.pushMessage({ text: `Bylo to: ${this.word?.display}`, system: true });
|
||||||
|
this.schedule(() => this.nextRound(), REVEAL_MS);
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish() {
|
||||||
|
const ranked = [...this.players].sort((a, b) => b.score - a.score);
|
||||||
|
ranked.forEach((p, i) => { p.place = i + 1; });
|
||||||
|
this.phase = 'finished';
|
||||||
|
this.clearTimer();
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private pushMessage(message: ChatMessage) {
|
||||||
|
this.messages.push(message);
|
||||||
|
if (this.messages.length > MAX_MESSAGES) this.messages.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancel() {
|
||||||
|
this.reset();
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset() {
|
||||||
|
this.clearTimer();
|
||||||
|
this.clearHintTimers();
|
||||||
|
this.phase = 'idle';
|
||||||
|
this.host = undefined;
|
||||||
|
this.players = [];
|
||||||
|
this.order = [];
|
||||||
|
this.round = 0;
|
||||||
|
this.word = undefined;
|
||||||
|
this.messages = [];
|
||||||
|
this.strokes = [];
|
||||||
|
this.strokePoints = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(fn: () => void, ms: number) {
|
||||||
|
this.clearTimer();
|
||||||
|
this.timer = this.clock.setTimeout(() => {
|
||||||
|
this.timer = undefined;
|
||||||
|
fn();
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer() {
|
||||||
|
if (this.timer !== undefined) {
|
||||||
|
this.clock.clearTimeout(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearHintTimers() {
|
||||||
|
this.hintTimers.forEach(t => this.clock.clearTimeout(t));
|
||||||
|
this.hintTimers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit() {
|
||||||
|
this.broadcastAll(this.getState());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Názvy dnešních jídel (bez polévek) ze všech aktivních podniků. */
|
||||||
|
async function loadTodaysDishes(): Promise<string[]> {
|
||||||
|
const menus = await Promise.all(getActiveRestaurants().map(r => getRestaurantMenu(r).catch(() => undefined)));
|
||||||
|
return menus.flatMap(m => m?.food ?? []).filter(f => !f.isSoup && f.name).map(f => f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zaregistruje websocket handlery Kreslení. */
|
||||||
|
export function registerDrawingGame(io: Server) {
|
||||||
|
const game = new DrawingGame(
|
||||||
|
state => io.emit('draw:state', state),
|
||||||
|
(login, word) => io.to(userRoom(login)).emit('draw:word', word),
|
||||||
|
stroke => io.to(WATCH_ROOM).emit('draw:stroke', stroke),
|
||||||
|
() => io.to(WATCH_ROOM).emit('draw:clear'),
|
||||||
|
loadTodaysDishes,
|
||||||
|
minPlayersForEnv(2),
|
||||||
|
);
|
||||||
|
|
||||||
|
io.on('connection', socket => {
|
||||||
|
const handle = (action: (login: string, payload: unknown) => void | Promise<void>) => gameHandler(socket, 'draw:error', action);
|
||||||
|
socket.on('draw:sync', () => socket.emit('draw:state', game.getState()));
|
||||||
|
// Sledování vyžaduje přihlášení — kreslíř si tak po znovupřipojení vyzvedne své slovo
|
||||||
|
socket.on('draw:watch', payload => {
|
||||||
|
let login: string | undefined;
|
||||||
|
try { login = loginFromPayload(payload); } catch { /* sleduje anonymně */ }
|
||||||
|
socket.join(WATCH_ROOM);
|
||||||
|
if (login) socket.join(userRoom(login));
|
||||||
|
socket.emit('draw:state', game.getState());
|
||||||
|
socket.emit('draw:strokes', game.getStrokes());
|
||||||
|
const word = login ? game.wordFor(login) : undefined;
|
||||||
|
if (word) socket.emit('draw:word', word);
|
||||||
|
});
|
||||||
|
socket.on('draw:unwatch', () => {
|
||||||
|
socket.leave(WATCH_ROOM);
|
||||||
|
[...socket.rooms].filter(r => r.startsWith('draw-user:')).forEach(r => socket.leave(r));
|
||||||
|
});
|
||||||
|
socket.on('draw:join', handle(login => game.join(login)));
|
||||||
|
socket.on('draw:leave', handle(login => game.leave(login)));
|
||||||
|
socket.on('draw:start', handle(login => game.start(login)));
|
||||||
|
socket.on('draw:stroke', handle((login, payload) => game.stroke(login, payload)));
|
||||||
|
socket.on('draw:clear', handle(login => game.clear(login)));
|
||||||
|
socket.on('draw:guess', handle((login, payload) => game.guess(login, (payload as { text?: unknown })?.text)));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Socket } from "socket.io";
|
||||||
|
import { getLogin } from "./auth";
|
||||||
|
|
||||||
|
/** Sdílené pomůcky pro websocketové minihry (reakční hra, had). */
|
||||||
|
|
||||||
|
/** Chyba určená hráči — posílá se zpět klientovi, nic se neloguje. */
|
||||||
|
export class GameError extends Error { }
|
||||||
|
|
||||||
|
/** Hodiny a náhoda vyčleněné kvůli testům. */
|
||||||
|
export type Clock = {
|
||||||
|
now: () => number,
|
||||||
|
setTimeout: (fn: () => void, ms: number) => unknown,
|
||||||
|
clearTimeout: (handle: unknown) => void,
|
||||||
|
random: () => number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultClock: Clock = {
|
||||||
|
now: () => Date.now(),
|
||||||
|
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
||||||
|
clearTimeout: handle => clearTimeout(handle as NodeJS.Timeout),
|
||||||
|
random: () => Math.random(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ověří token z eventu a vrátí login, jinak vyhodí chybu. */
|
||||||
|
export function loginFromPayload(payload: unknown): string {
|
||||||
|
const token = (payload as { token?: unknown } | undefined)?.token;
|
||||||
|
if (typeof token !== 'string') {
|
||||||
|
throw new GameError('Chybí přihlášení.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const login = getLogin(token);
|
||||||
|
if (!login) throw new Error();
|
||||||
|
return login;
|
||||||
|
} catch {
|
||||||
|
throw new GameError('Neplatné přihlášení.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vytvoří handler websocket eventu, který ověří přihlášení a herní chyby
|
||||||
|
* pošle zpět jen danému klientovi jako `errorEvent`.
|
||||||
|
*/
|
||||||
|
export function gameHandler(socket: Socket, errorEvent: string, action: (login: string, payload: unknown) => void | Promise<void>) {
|
||||||
|
const onError = (e: unknown) => {
|
||||||
|
if (e instanceof GameError) {
|
||||||
|
socket.emit(errorEvent, e.message);
|
||||||
|
} else {
|
||||||
|
console.error(`Chyba minihry (${errorEvent})`, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (payload: unknown) => {
|
||||||
|
try {
|
||||||
|
const result = action(loginFromPayload(payload), payload);
|
||||||
|
if (result instanceof Promise) result.catch(onError);
|
||||||
|
} catch (e) {
|
||||||
|
onError(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimální počet hráčů — lokálně lze hrát i sám. */
|
||||||
|
export const minPlayersForEnv = (prod: number) => process.env.NODE_ENV === 'development' ? 1 : prod;
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import { Server } from "socket.io";
|
||||||
|
import { Clock, defaultClock, GameError, gameHandler } from "./minigames";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pong 1v1 přes websocket.
|
||||||
|
*
|
||||||
|
* Server drží fyziku (míček, pálky, skóre) a posílá snímky sledujícím ~30× za sekundu;
|
||||||
|
* hráči posílají jen směr pálky (nahoru / dolů / stát). Kdo první dá {@link WIN_SCORE}
|
||||||
|
* bodů, vyhrává. Ostatní se mohou dívat. Stav je v paměti, předpokládá jednu repliku.
|
||||||
|
*
|
||||||
|
* Pálku lze ovládat klávesami (směr) i myší (cílová výška — pálka k ní rychle dojede).
|
||||||
|
* Zakladatel lobby může místo soupeře přidat bota s nastavitelnou obtížností.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const FIELD_WIDTH = 800;
|
||||||
|
export const FIELD_HEIGHT = 500;
|
||||||
|
export const PADDLE_HEIGHT = 90;
|
||||||
|
export const PADDLE_WIDTH = 12;
|
||||||
|
/** Vzdálenost pálky od kraje hřiště. */
|
||||||
|
export const PADDLE_MARGIN = 20;
|
||||||
|
export const BALL_SIZE = 12;
|
||||||
|
export const TICK_MS = 33;
|
||||||
|
/** Rychlosti v jednotkách hřiště za sekundu. */
|
||||||
|
export const PADDLE_SPEED = 420;
|
||||||
|
/** Rychlost pálky při ovládání myší — rychlé, ale ne okamžité přeskočení. */
|
||||||
|
export const MOUSE_PADDLE_SPEED = 1600;
|
||||||
|
export const BALL_START_SPEED = 330;
|
||||||
|
export const BALL_MAX_SPEED = 900;
|
||||||
|
/** Každý odpal míček zrychlí o tento násobek. */
|
||||||
|
export const BALL_SPEEDUP = 1.07;
|
||||||
|
/** Nejostřejší úhel odrazu od pálky (rad). */
|
||||||
|
const MAX_BOUNCE_ANGLE = Math.PI / 3.2;
|
||||||
|
export const WIN_SCORE = 5;
|
||||||
|
export const COUNTDOWN_SECONDS = 3;
|
||||||
|
/** Pauza po gólu, než se míček znovu rozehraje (ms). */
|
||||||
|
export const SERVE_PAUSE_MS = 1000;
|
||||||
|
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
|
export const WATCH_ROOM = 'pong-watchers';
|
||||||
|
|
||||||
|
export type PongPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
|
||||||
|
export type BotLevel = 'easy' | 'medium' | 'hard' | 'impossible';
|
||||||
|
|
||||||
|
type BotProfile = {
|
||||||
|
label: string,
|
||||||
|
/** Rychlost pálky bota. */
|
||||||
|
speed: number,
|
||||||
|
/** Jak často bot přehodnocuje, kam jet (ms) — simuluje reakční dobu. */
|
||||||
|
reactMs: number,
|
||||||
|
/** Max. odchylka míření (± jednotek hřiště). */
|
||||||
|
error: number,
|
||||||
|
/** Počítá, kam míček dopadne (včetně odrazů), nebo jen sleduje jeho výšku. */
|
||||||
|
predict: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BOT_PROFILES: Record<BotLevel, BotProfile> = {
|
||||||
|
easy: { label: 'Lehký', speed: 250, reactMs: 320, error: 70, predict: false },
|
||||||
|
medium: { label: 'Střední', speed: 340, reactMs: 200, error: 40, predict: true },
|
||||||
|
hard: { label: 'Těžký', speed: 450, reactMs: 110, error: 20, predict: true },
|
||||||
|
impossible: { label: 'Nemožný', speed: 900, reactMs: TICK_MS, error: 0, predict: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Jméno bota — s emoji, aby nekolidovalo s přihlašovacími jmény. */
|
||||||
|
export const botLogin = (level: BotLevel) => `🤖 Bot (${BOT_PROFILES[level].label})`;
|
||||||
|
export type PaddleInput = 'up' | 'down' | 'stop';
|
||||||
|
|
||||||
|
export type PongPlayer = {
|
||||||
|
login: string,
|
||||||
|
/** 0 = levá pálka, 1 = pravá. */
|
||||||
|
side: 0 | 1,
|
||||||
|
/** Svislá pozice středu pálky. */
|
||||||
|
y: number,
|
||||||
|
score: number,
|
||||||
|
/** Obtížnost, pokud jde o bota. */
|
||||||
|
bot?: BotLevel,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PongState = {
|
||||||
|
phase: PongPhase,
|
||||||
|
host?: string,
|
||||||
|
players: PongPlayer[],
|
||||||
|
ball: { x: number, y: number },
|
||||||
|
countdown?: number,
|
||||||
|
winner?: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
type InternalPlayer = PongPlayer & {
|
||||||
|
input: PaddleInput,
|
||||||
|
/** Cílová výška při ovládání myší (přebíjí klávesy, dokud se nezmáčkne šipka). */
|
||||||
|
target?: number,
|
||||||
|
/** Kam bot míří a kdy se znovu rozhodne. */
|
||||||
|
botAim?: number,
|
||||||
|
botThinkAt?: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
|
||||||
|
|
||||||
|
export class PongGame {
|
||||||
|
private phase: PongPhase = 'idle';
|
||||||
|
private host?: string;
|
||||||
|
private players: InternalPlayer[] = [];
|
||||||
|
private ball = { x: FIELD_WIDTH / 2, y: FIELD_HEIGHT / 2, vx: 0, vy: 0 };
|
||||||
|
private countdown = 0;
|
||||||
|
private winner?: string;
|
||||||
|
/** Míček čeká na rozehrání po gólu. */
|
||||||
|
private serveAt = 0;
|
||||||
|
private lastTick = 0;
|
||||||
|
private timer: unknown;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly broadcastAll: (state: PongState) => void,
|
||||||
|
private readonly broadcastWatchers: (state: PongState) => void,
|
||||||
|
private readonly clock: Clock = defaultClock,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
getState(): PongState {
|
||||||
|
return {
|
||||||
|
phase: this.phase,
|
||||||
|
host: this.host,
|
||||||
|
players: this.players.map(p => ({ login: p.login, side: p.side, y: Math.round(p.y), score: p.score, bot: p.bot })),
|
||||||
|
ball: { x: Math.round(this.ball.x), y: Math.round(this.ball.y) },
|
||||||
|
countdown: this.phase === 'countdown' ? this.countdown : undefined,
|
||||||
|
winner: this.winner,
|
||||||
|
width: FIELD_WIDTH,
|
||||||
|
height: FIELD_HEIGHT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
join(login: string) {
|
||||||
|
if (this.phase === 'idle' || this.phase === 'finished') {
|
||||||
|
this.reset();
|
||||||
|
this.phase = 'lobby';
|
||||||
|
this.host = login;
|
||||||
|
this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
|
||||||
|
} else if (this.phase !== 'lobby') {
|
||||||
|
throw new GameError('Hra už běží, počkejte na další.');
|
||||||
|
}
|
||||||
|
if (this.players.some(p => p.login === login)) return;
|
||||||
|
if (this.players.length >= 2) {
|
||||||
|
throw new GameError('Pong je jen pro dva hráče.');
|
||||||
|
}
|
||||||
|
this.players.push({ login, side: this.players.length as 0 | 1, y: FIELD_HEIGHT / 2, score: 0, input: 'stop' });
|
||||||
|
this.emitAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zakladatel přidá do lobby bota místo druhého hráče. */
|
||||||
|
addBot(login: string, level: unknown) {
|
||||||
|
if (this.phase !== 'lobby') {
|
||||||
|
throw new GameError('Není otevřené žádné lobby.');
|
||||||
|
}
|
||||||
|
if (login !== this.host) {
|
||||||
|
throw new GameError('Bota může přidat jen ten, kdo lobby založil.');
|
||||||
|
}
|
||||||
|
if (typeof level !== 'string' || !(level in BOT_PROFILES)) {
|
||||||
|
throw new GameError('Neznámá obtížnost bota.');
|
||||||
|
}
|
||||||
|
if (this.players.length >= 2) {
|
||||||
|
throw new GameError('Pong je jen pro dva hráče.');
|
||||||
|
}
|
||||||
|
const bot = level as BotLevel;
|
||||||
|
this.players.push({ login: botLogin(bot), side: 1, y: FIELD_HEIGHT / 2, score: 0, input: 'stop', bot });
|
||||||
|
this.emitAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zakladatel odebere bota z lobby. */
|
||||||
|
removeBot(login: string) {
|
||||||
|
if (this.phase !== 'lobby' || login !== this.host) return;
|
||||||
|
this.players = this.players.filter(p => !p.bot);
|
||||||
|
this.emitAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
leave(login: string) {
|
||||||
|
if (this.phase !== 'lobby' || !this.players.some(p => p.login === login)) return;
|
||||||
|
if (login === this.host) {
|
||||||
|
this.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.players = this.players.filter(p => p.login !== login);
|
||||||
|
this.emitAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
start(login: string) {
|
||||||
|
if (this.phase !== 'lobby') {
|
||||||
|
throw new GameError('Není otevřené žádné lobby.');
|
||||||
|
}
|
||||||
|
if (login !== this.host) {
|
||||||
|
throw new GameError('Hru může spustit jen ten, kdo lobby založil.');
|
||||||
|
}
|
||||||
|
if (this.players.length < 2) {
|
||||||
|
throw new GameError('Na Pong jsou potřeba dva hráči.');
|
||||||
|
}
|
||||||
|
this.players.forEach(p => {
|
||||||
|
p.y = FIELD_HEIGHT / 2;
|
||||||
|
p.score = 0;
|
||||||
|
p.input = 'stop';
|
||||||
|
p.target = undefined;
|
||||||
|
p.botAim = undefined;
|
||||||
|
p.botThinkAt = undefined;
|
||||||
|
});
|
||||||
|
this.phase = 'countdown';
|
||||||
|
this.countdown = COUNTDOWN_SECONDS;
|
||||||
|
this.emitAll();
|
||||||
|
this.schedule(() => this.countdownStep(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
input(login: string, input: unknown) {
|
||||||
|
const p = this.players.find(x => x.login === login);
|
||||||
|
if (!p || p.bot || (input !== 'up' && input !== 'down' && input !== 'stop')) return;
|
||||||
|
p.input = input;
|
||||||
|
// Šipka přebíjí myš; puštění klávesy myš neruší
|
||||||
|
if (input !== 'stop') p.target = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ovládání myší: pálka jede rychle k zadané výšce. */
|
||||||
|
aim(login: string, y: unknown) {
|
||||||
|
const p = this.players.find(x => x.login === login);
|
||||||
|
if (!p || p.bot || typeof y !== 'number' || !Number.isFinite(y)) return;
|
||||||
|
p.target = clamp(y, 0, FIELD_HEIGHT);
|
||||||
|
p.input = 'stop';
|
||||||
|
}
|
||||||
|
|
||||||
|
private countdownStep() {
|
||||||
|
this.countdown--;
|
||||||
|
if (this.countdown > 0) {
|
||||||
|
this.emitAll();
|
||||||
|
this.schedule(() => this.countdownStep(), 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.phase = 'playing';
|
||||||
|
this.serve(Math.round(this.clock.random()) as 0 | 1);
|
||||||
|
this.lastTick = this.clock.now();
|
||||||
|
this.emitAll();
|
||||||
|
this.schedule(() => this.step(), TICK_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Míček do středu, po pauze letí k dané straně pod náhodným úhlem. */
|
||||||
|
private serve(towards: 0 | 1) {
|
||||||
|
const angle = (this.clock.random() - 0.5) * (Math.PI / 3);
|
||||||
|
const dir = towards === 0 ? -1 : 1;
|
||||||
|
this.ball = {
|
||||||
|
x: FIELD_WIDTH / 2,
|
||||||
|
y: FIELD_HEIGHT / 2,
|
||||||
|
vx: dir * BALL_START_SPEED * Math.cos(angle),
|
||||||
|
vy: BALL_START_SPEED * Math.sin(angle),
|
||||||
|
};
|
||||||
|
this.serveAt = this.clock.now() + SERVE_PAUSE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private step() {
|
||||||
|
const now = this.clock.now();
|
||||||
|
// Skutečně uplynulý čas, ať zpožděný tick nezpomalí hru (max. 100 ms proti skokům)
|
||||||
|
const dt = Math.min(0.1, (now - this.lastTick) / 1000);
|
||||||
|
this.lastTick = now;
|
||||||
|
|
||||||
|
for (const p of this.players) {
|
||||||
|
if (p.bot) {
|
||||||
|
this.moveBot(p, now, dt);
|
||||||
|
} else if (p.target !== undefined) {
|
||||||
|
p.y = this.approach(p.y, p.target, MOUSE_PADDLE_SPEED * dt);
|
||||||
|
} else {
|
||||||
|
const dir = p.input === 'up' ? -1 : p.input === 'down' ? 1 : 0;
|
||||||
|
p.y = clamp(p.y + dir * PADDLE_SPEED * dt, PADDLE_HEIGHT / 2, FIELD_HEIGHT - PADDLE_HEIGHT / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now >= this.serveAt) {
|
||||||
|
this.moveBall(dt);
|
||||||
|
}
|
||||||
|
if (this.phase !== 'playing') return;
|
||||||
|
this.emitWatchers();
|
||||||
|
this.schedule(() => this.step(), TICK_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Posune pálku k cíli nejvýš o `maxStep`, v mezích hřiště. */
|
||||||
|
private approach(y: number, target: number, maxStep: number) {
|
||||||
|
const goal = clamp(target, PADDLE_HEIGHT / 2, FIELD_HEIGHT - PADDLE_HEIGHT / 2);
|
||||||
|
return Math.abs(goal - y) <= maxStep ? goal : y + Math.sign(goal - y) * maxStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bot se v intervalu reakční doby rozhodne, kam jet, a jede tam svou rychlostí. */
|
||||||
|
private moveBot(p: InternalPlayer, now: number, dt: number) {
|
||||||
|
const profile = BOT_PROFILES[p.bot!];
|
||||||
|
if (p.botThinkAt === undefined || now >= p.botThinkAt) {
|
||||||
|
p.botThinkAt = now + profile.reactMs;
|
||||||
|
const b = this.ball;
|
||||||
|
const incoming = p.side === 0 ? b.vx < 0 : b.vx > 0;
|
||||||
|
let aim: number;
|
||||||
|
if (!incoming || now < this.serveAt) {
|
||||||
|
// Míček letí pryč — vrátit se doprostřed
|
||||||
|
aim = FIELD_HEIGHT / 2;
|
||||||
|
} else if (profile.predict) {
|
||||||
|
aim = this.predictY(p.side);
|
||||||
|
} else {
|
||||||
|
aim = b.y;
|
||||||
|
}
|
||||||
|
p.botAim = aim + (this.clock.random() * 2 - 1) * profile.error;
|
||||||
|
}
|
||||||
|
p.y = this.approach(p.y, p.botAim ?? FIELD_HEIGHT / 2, profile.speed * dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Výška, ve které míček dorazí k pálce dané strany (s odrazy od horní a dolní stěny). */
|
||||||
|
private predictY(side: 0 | 1): number {
|
||||||
|
const b = this.ball;
|
||||||
|
const r = BALL_SIZE / 2;
|
||||||
|
const faceX = side === 0 ? PADDLE_MARGIN + PADDLE_WIDTH + r : FIELD_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH - r;
|
||||||
|
const t = (faceX - b.x) / b.vx;
|
||||||
|
if (!Number.isFinite(t) || t < 0) return b.y;
|
||||||
|
// Odrazy "rozbalíme": pohyb v pásmu [r, H - r] je periodický s periodou 2 × výška pásma
|
||||||
|
const span = FIELD_HEIGHT - 2 * r;
|
||||||
|
const period = 2 * span;
|
||||||
|
let y = ((b.y + b.vy * t - r) % period + period) % period;
|
||||||
|
if (y > span) y = period - y;
|
||||||
|
return y + r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private moveBall(dt: number) {
|
||||||
|
const b = this.ball;
|
||||||
|
b.x += b.vx * dt;
|
||||||
|
b.y += b.vy * dt;
|
||||||
|
const r = BALL_SIZE / 2;
|
||||||
|
|
||||||
|
if (b.y - r < 0) { b.y = r; b.vy = Math.abs(b.vy); }
|
||||||
|
if (b.y + r > FIELD_HEIGHT) { b.y = FIELD_HEIGHT - r; b.vy = -Math.abs(b.vy); }
|
||||||
|
|
||||||
|
for (const p of this.players) {
|
||||||
|
const faceX = p.side === 0 ? PADDLE_MARGIN + PADDLE_WIDTH : FIELD_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH;
|
||||||
|
const movingTowards = p.side === 0 ? b.vx < 0 : b.vx > 0;
|
||||||
|
const reached = p.side === 0 ? b.x - r <= faceX : b.x + r >= faceX;
|
||||||
|
const behind = p.side === 0 ? b.x + r < PADDLE_MARGIN : b.x - r > FIELD_WIDTH - PADDLE_MARGIN;
|
||||||
|
if (!movingTowards || !reached || behind) continue;
|
||||||
|
const offset = (b.y - p.y) / (PADDLE_HEIGHT / 2 + r);
|
||||||
|
if (Math.abs(offset) > 1) continue;
|
||||||
|
// Úhel odrazu podle místa zásahu na pálce, rychlost postupně roste
|
||||||
|
const speed = Math.min(BALL_MAX_SPEED, Math.hypot(b.vx, b.vy) * BALL_SPEEDUP);
|
||||||
|
const angle = offset * MAX_BOUNCE_ANGLE;
|
||||||
|
b.vx = (p.side === 0 ? 1 : -1) * speed * Math.cos(angle);
|
||||||
|
b.vy = speed * Math.sin(angle);
|
||||||
|
b.x = p.side === 0 ? faceX + r : faceX - r;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b.x < 0) this.point(1);
|
||||||
|
else if (b.x > FIELD_WIDTH) this.point(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private point(side: 0 | 1) {
|
||||||
|
const scorer = this.players.find(p => p.side === side);
|
||||||
|
if (!scorer) return;
|
||||||
|
scorer.score++;
|
||||||
|
if (scorer.score >= WIN_SCORE) {
|
||||||
|
this.winner = scorer.login;
|
||||||
|
this.phase = 'finished';
|
||||||
|
this.clearTimer();
|
||||||
|
this.emitAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Podává se k tomu, kdo bod ztratil
|
||||||
|
this.serve(side === 0 ? 1 : 0);
|
||||||
|
this.emitAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancel() {
|
||||||
|
this.reset();
|
||||||
|
this.emitAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset() {
|
||||||
|
this.clearTimer();
|
||||||
|
this.phase = 'idle';
|
||||||
|
this.host = undefined;
|
||||||
|
this.players = [];
|
||||||
|
this.winner = undefined;
|
||||||
|
this.ball = { x: FIELD_WIDTH / 2, y: FIELD_HEIGHT / 2, vx: 0, vy: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(fn: () => void, ms: number) {
|
||||||
|
this.clearTimer();
|
||||||
|
this.timer = this.clock.setTimeout(() => {
|
||||||
|
this.timer = undefined;
|
||||||
|
fn();
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer() {
|
||||||
|
if (this.timer !== undefined) {
|
||||||
|
this.clock.clearTimeout(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitAll() {
|
||||||
|
this.broadcastAll(this.getState());
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitWatchers() {
|
||||||
|
this.broadcastWatchers(this.getState());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pro testy: nastaví míček na konkrétní pozici a rychlost. */
|
||||||
|
setBallForTest(x: number, y: number, vx: number, vy: number) {
|
||||||
|
this.ball = { x, y, vx, vy };
|
||||||
|
this.serveAt = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zaregistruje websocket handlery Pongu. */
|
||||||
|
export function registerPongGame(io: Server) {
|
||||||
|
const game = new PongGame(
|
||||||
|
state => io.emit('pong:state', state),
|
||||||
|
state => io.to(WATCH_ROOM).emit('pong:state', state),
|
||||||
|
);
|
||||||
|
|
||||||
|
io.on('connection', socket => {
|
||||||
|
const handle = (action: (login: string, payload: unknown) => void) => gameHandler(socket, 'pong:error', action);
|
||||||
|
socket.on('pong:sync', () => socket.emit('pong:state', game.getState()));
|
||||||
|
socket.on('pong:watch', () => {
|
||||||
|
socket.join(WATCH_ROOM);
|
||||||
|
socket.emit('pong:state', game.getState());
|
||||||
|
});
|
||||||
|
socket.on('pong:unwatch', () => socket.leave(WATCH_ROOM));
|
||||||
|
socket.on('pong:join', handle(login => game.join(login)));
|
||||||
|
socket.on('pong:leave', handle(login => game.leave(login)));
|
||||||
|
socket.on('pong:start', handle(login => game.start(login)));
|
||||||
|
socket.on('pong:input', handle((login, payload) => game.input(login, (payload as { input?: unknown })?.input)));
|
||||||
|
socket.on('pong:aim', handle((login, payload) => game.aim(login, (payload as { y?: unknown })?.y)));
|
||||||
|
socket.on('pong:addBot', handle((login, payload) => game.addBot(login, (payload as { level?: unknown })?.level)));
|
||||||
|
socket.on('pong:removeBot', handle(login => game.removeBot(login)));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { Server } from "socket.io";
|
||||||
|
import { Clock, defaultClock, GameError, gameHandler, minPlayersForEnv } from "./minigames";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reakční hra — krátká multiplayerová hra přes websocket.
|
||||||
|
*
|
||||||
|
* Hráči se přidají do lobby, pak proběhnou {@link ROUNDS} kola: server po náhodné
|
||||||
|
* prodlevě vyhlásí "TEĎ!" a měří čas od vyhlášení do přijetí kliknutí. Kdo klikne
|
||||||
|
* před vyhlášením (předčasný start) nebo neklikne vůbec, dostane trestný čas.
|
||||||
|
* Pořadí určuje součet časů — nejnižší vyhrává.
|
||||||
|
*
|
||||||
|
* Veškeré časování drží server, klient jen posílá kliknutí. Stav je v paměti procesu,
|
||||||
|
* takže hra předpokládá jednu repliku serveru (broadcast přes Redis adapter funguje,
|
||||||
|
* ale akce z jiné repliky by stav neviděly).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ROUNDS = 3;
|
||||||
|
/** Trestný čas za předčasný start nebo nekliknutí (ms). */
|
||||||
|
export const PENALTY_MS = 2000;
|
||||||
|
/** Jak dlouho po vyhlášení se čeká na kliknutí (ms). */
|
||||||
|
export const CLICK_WINDOW_MS = 2000;
|
||||||
|
export const MIN_DELAY_MS = 2000;
|
||||||
|
export const MAX_DELAY_MS = 6000;
|
||||||
|
/** Pauza mezi koly pro zobrazení výsledku kola (ms). */
|
||||||
|
export const ROUND_PAUSE_MS = 2500;
|
||||||
|
/** Neodstartované lobby se po této době zruší (ms). */
|
||||||
|
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
|
export const MAX_PLAYERS = 12;
|
||||||
|
|
||||||
|
export type DuelPhase = 'idle' | 'lobby' | 'waiting' | 'go' | 'roundResult' | 'finished';
|
||||||
|
|
||||||
|
export type RoundResult = {
|
||||||
|
/** Reakční čas v ms, nebo null při předčasném startu / nekliknutí. */
|
||||||
|
ms: number | null,
|
||||||
|
falseStart?: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DuelPlayer = {
|
||||||
|
login: string,
|
||||||
|
/** Výsledky jednotlivých kol. */
|
||||||
|
rounds: RoundResult[],
|
||||||
|
/** Součet časů včetně trestů. */
|
||||||
|
total: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DuelState = {
|
||||||
|
phase: DuelPhase,
|
||||||
|
host?: string,
|
||||||
|
round: number,
|
||||||
|
totalRounds: number,
|
||||||
|
players: DuelPlayer[],
|
||||||
|
/** Login posledního hráče (jen ve fázi finished). */
|
||||||
|
loser?: string,
|
||||||
|
/** Login nejrychlejšího hráče celé hry (jen ve fázi finished). */
|
||||||
|
winner?: string,
|
||||||
|
minPlayers: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ReactionDuel {
|
||||||
|
private phase: DuelPhase = 'idle';
|
||||||
|
private host?: string;
|
||||||
|
private round = 0;
|
||||||
|
private players = new Map<string, DuelPlayer>();
|
||||||
|
/** Výsledky aktuálního kola. */
|
||||||
|
private current = new Map<string, RoundResult>();
|
||||||
|
private goAt = 0;
|
||||||
|
private timer: unknown;
|
||||||
|
private loser?: string;
|
||||||
|
private winner?: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly broadcast: (state: DuelState) => void,
|
||||||
|
private readonly minPlayers = 2,
|
||||||
|
private readonly clock: Clock = defaultClock,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
getState(): DuelState {
|
||||||
|
return {
|
||||||
|
phase: this.phase,
|
||||||
|
host: this.host,
|
||||||
|
round: this.round,
|
||||||
|
totalRounds: ROUNDS,
|
||||||
|
players: [...this.players.values()].map(p => ({ ...p, rounds: [...p.rounds] })),
|
||||||
|
loser: this.loser,
|
||||||
|
winner: this.winner,
|
||||||
|
minPlayers: this.minPlayers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Přidá hráče; pokud žádná hra neběží, založí nové lobby. */
|
||||||
|
join(login: string) {
|
||||||
|
if (this.phase === 'idle' || this.phase === 'finished') {
|
||||||
|
this.reset();
|
||||||
|
this.phase = 'lobby';
|
||||||
|
this.host = login;
|
||||||
|
this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
|
||||||
|
} else if (this.phase !== 'lobby') {
|
||||||
|
throw new GameError('Hra už běží, počkejte na další.');
|
||||||
|
}
|
||||||
|
if (!this.players.has(login)) {
|
||||||
|
if (this.players.size >= MAX_PLAYERS) {
|
||||||
|
throw new GameError('Lobby je plné.');
|
||||||
|
}
|
||||||
|
this.players.set(login, { login, rounds: [], total: 0 });
|
||||||
|
}
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Odebere hráče z lobby; odchodem zakladatele se lobby ruší. */
|
||||||
|
leave(login: string) {
|
||||||
|
if (this.phase !== 'lobby' || !this.players.has(login)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.players.delete(login);
|
||||||
|
if (login === this.host || this.players.size === 0) {
|
||||||
|
this.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spustí hru, smí jen zakladatel lobby. */
|
||||||
|
start(login: string) {
|
||||||
|
if (this.phase !== 'lobby') {
|
||||||
|
throw new GameError('Není otevřené žádné lobby.');
|
||||||
|
}
|
||||||
|
if (login !== this.host) {
|
||||||
|
throw new GameError('Hru může spustit jen ten, kdo lobby založil.');
|
||||||
|
}
|
||||||
|
if (this.players.size < this.minPlayers) {
|
||||||
|
throw new GameError(`Ke hře je potřeba alespoň ${this.minPlayers} hráčů.`);
|
||||||
|
}
|
||||||
|
this.nextRound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zpracuje kliknutí hráče. */
|
||||||
|
click(login: string) {
|
||||||
|
if (!this.players.has(login) || this.current.has(login)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.phase === 'waiting') {
|
||||||
|
this.current.set(login, { ms: null, falseStart: true });
|
||||||
|
} else if (this.phase === 'go') {
|
||||||
|
this.current.set(login, { ms: Math.max(0, this.clock.now() - this.goAt) });
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.current.size === this.players.size && this.phase === 'go') {
|
||||||
|
this.endRound();
|
||||||
|
} else {
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextRound() {
|
||||||
|
this.round++;
|
||||||
|
this.current.clear();
|
||||||
|
this.phase = 'waiting';
|
||||||
|
const delay = MIN_DELAY_MS + Math.floor(this.clock.random() * (MAX_DELAY_MS - MIN_DELAY_MS));
|
||||||
|
this.schedule(() => this.go(), delay);
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private go() {
|
||||||
|
this.phase = 'go';
|
||||||
|
this.goAt = this.clock.now();
|
||||||
|
if (this.current.size === this.players.size) {
|
||||||
|
// Všichni odstartovali předčasně, není na co čekat
|
||||||
|
this.endRound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.schedule(() => this.endRound(), CLICK_WINDOW_MS);
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private endRound() {
|
||||||
|
for (const player of this.players.values()) {
|
||||||
|
const result = this.current.get(player.login) ?? { ms: null };
|
||||||
|
player.rounds.push(result);
|
||||||
|
player.total += result.ms ?? PENALTY_MS;
|
||||||
|
}
|
||||||
|
this.current.clear();
|
||||||
|
if (this.round >= ROUNDS) {
|
||||||
|
this.finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.phase = 'roundResult';
|
||||||
|
this.schedule(() => this.nextRound(), ROUND_PAUSE_MS);
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish() {
|
||||||
|
const ranked = [...this.players.values()].sort((a, b) => a.total - b.total);
|
||||||
|
this.winner = ranked[0]?.login;
|
||||||
|
this.loser = ranked.length > 1 ? ranked[ranked.length - 1].login : undefined;
|
||||||
|
this.phase = 'finished';
|
||||||
|
this.clearTimer();
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancel() {
|
||||||
|
this.reset();
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset() {
|
||||||
|
this.clearTimer();
|
||||||
|
this.phase = 'idle';
|
||||||
|
this.host = undefined;
|
||||||
|
this.round = 0;
|
||||||
|
this.players.clear();
|
||||||
|
this.current.clear();
|
||||||
|
this.loser = undefined;
|
||||||
|
this.winner = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(fn: () => void, ms: number) {
|
||||||
|
this.clearTimer();
|
||||||
|
this.timer = this.clock.setTimeout(() => {
|
||||||
|
this.timer = undefined;
|
||||||
|
fn();
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer() {
|
||||||
|
if (this.timer !== undefined) {
|
||||||
|
this.clock.clearTimeout(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rozešle stav všem klientům; výsledky kola se zveřejní až po jeho skončení. */
|
||||||
|
private emit() {
|
||||||
|
this.broadcast(this.getState());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zaregistruje websocket handlery reakčního duelu. */
|
||||||
|
export function registerReactionDuel(io: Server) {
|
||||||
|
const duel = new ReactionDuel(state => io.emit('duel:state', state), minPlayersForEnv(2));
|
||||||
|
|
||||||
|
io.on('connection', socket => {
|
||||||
|
const handle = (action: (login: string) => void) => gameHandler(socket, 'duel:error', action);
|
||||||
|
socket.on('duel:sync', () => socket.emit('duel:state', duel.getState()));
|
||||||
|
socket.on('duel:join', handle(login => duel.join(login)));
|
||||||
|
socket.on('duel:leave', handle(login => duel.leave(login)));
|
||||||
|
socket.on('duel:start', handle(login => duel.start(login)));
|
||||||
|
socket.on('duel:click', handle(login => duel.click(login)));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import { Server } from "socket.io";
|
||||||
|
import { Clock, defaultClock, GameError, gameHandler } from "./minigames";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multiplayerový had přes websocket.
|
||||||
|
*
|
||||||
|
* Server drží celou simulaci (tick smyčka, kolize, jídlo), klienti posílají jen směr.
|
||||||
|
* Had umírá nárazem do zdi, do sebe nebo do jiného hada (čelní srážka zabije oba).
|
||||||
|
* Pořadí: kdo přežije déle, je výš; při stejném okamžiku smrti rozhoduje délka.
|
||||||
|
* Hra končí, když zbyde poslední živý had (sólo hra až smrtí), nebo po {@link MAX_TICKS}.
|
||||||
|
*
|
||||||
|
* Průběžné snímky chodí jen do místnosti {@link WATCH_ROOM} (klienti s otevřeným oknem),
|
||||||
|
* ostatním jen změny lobby kvůli pozvánce. Stav je v paměti, předpokládá jednu repliku.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const WIDTH = 32;
|
||||||
|
export const HEIGHT = 20;
|
||||||
|
export const TICK_MS = 120;
|
||||||
|
/** Maximální délka hry (~3 minuty). */
|
||||||
|
export const MAX_TICKS = Math.round(3 * 60 * 1000 / TICK_MS);
|
||||||
|
export const COUNTDOWN_SECONDS = 3;
|
||||||
|
export const START_LENGTH = 3;
|
||||||
|
export const MAX_PLAYERS = 8;
|
||||||
|
/** Neodstartované lobby se po této době zruší (ms). */
|
||||||
|
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
|
/** Kolik změn směru si server pamatuje dopředu (rychlé zatáčky v jednom ticku). */
|
||||||
|
const MAX_QUEUED_TURNS = 2;
|
||||||
|
export const WATCH_ROOM = 'snake-watchers';
|
||||||
|
|
||||||
|
export type Direction = 'up' | 'down' | 'left' | 'right';
|
||||||
|
export type Cell = [number, number];
|
||||||
|
export type SnakePhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
|
||||||
|
|
||||||
|
export type SnakePlayer = {
|
||||||
|
login: string,
|
||||||
|
/** Index barvy hada (klient si mapuje na paletu). */
|
||||||
|
color: number,
|
||||||
|
/** Tělo od hlavy po ocas. */
|
||||||
|
body: Cell[],
|
||||||
|
alive: boolean,
|
||||||
|
/** Počet snědených jídel. */
|
||||||
|
score: number,
|
||||||
|
/** Konečné umístění (jen ve fázi finished). */
|
||||||
|
place?: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SnakeState = {
|
||||||
|
phase: SnakePhase,
|
||||||
|
host?: string,
|
||||||
|
players: SnakePlayer[],
|
||||||
|
food: Cell[],
|
||||||
|
tick: number,
|
||||||
|
maxTicks: number,
|
||||||
|
/** Zbývající sekundy odpočtu (ve fázi countdown). */
|
||||||
|
countdown?: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
minPlayers: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
type InternalPlayer = SnakePlayer & {
|
||||||
|
dir: Direction,
|
||||||
|
queue: Direction[],
|
||||||
|
deathTick?: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DELTAS: Record<Direction, Cell> = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
|
||||||
|
const OPPOSITE: Record<Direction, Direction> = { up: 'down', down: 'up', left: 'right', right: 'left' };
|
||||||
|
const isDirection = (d: unknown): d is Direction => typeof d === 'string' && d in DELTAS;
|
||||||
|
const key = ([x, y]: Cell) => `${x},${y}`;
|
||||||
|
|
||||||
|
export class SnakeGame {
|
||||||
|
private phase: SnakePhase = 'idle';
|
||||||
|
private host?: string;
|
||||||
|
private players = new Map<string, InternalPlayer>();
|
||||||
|
private food: Cell[] = [];
|
||||||
|
private tickNo = 0;
|
||||||
|
private countdown = 0;
|
||||||
|
private timer: unknown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param broadcastAll stav pro všechny (změny lobby a fáze)
|
||||||
|
* @param broadcastWatchers průběžné snímky jen pro klienty se otevřenou hrou
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private readonly broadcastAll: (state: SnakeState) => void,
|
||||||
|
private readonly broadcastWatchers: (state: SnakeState) => void,
|
||||||
|
private readonly minPlayers = 1,
|
||||||
|
private readonly clock: Clock = defaultClock,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
getState(): SnakeState {
|
||||||
|
return {
|
||||||
|
phase: this.phase,
|
||||||
|
host: this.host,
|
||||||
|
players: [...this.players.values()].map(p => ({
|
||||||
|
login: p.login,
|
||||||
|
color: p.color,
|
||||||
|
body: p.body.map(c => [c[0], c[1]] as Cell),
|
||||||
|
alive: p.alive,
|
||||||
|
score: p.score,
|
||||||
|
place: p.place,
|
||||||
|
})),
|
||||||
|
food: this.food.map(c => [c[0], c[1]] as Cell),
|
||||||
|
tick: this.tickNo,
|
||||||
|
maxTicks: MAX_TICKS,
|
||||||
|
countdown: this.phase === 'countdown' ? this.countdown : undefined,
|
||||||
|
width: WIDTH,
|
||||||
|
height: HEIGHT,
|
||||||
|
minPlayers: this.minPlayers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
join(login: string) {
|
||||||
|
if (this.phase === 'idle' || this.phase === 'finished') {
|
||||||
|
this.reset();
|
||||||
|
this.phase = 'lobby';
|
||||||
|
this.host = login;
|
||||||
|
this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
|
||||||
|
} else if (this.phase !== 'lobby') {
|
||||||
|
throw new GameError('Hra už běží, počkejte na další.');
|
||||||
|
}
|
||||||
|
if (!this.players.has(login)) {
|
||||||
|
if (this.players.size >= MAX_PLAYERS) {
|
||||||
|
throw new GameError('Lobby je plné.');
|
||||||
|
}
|
||||||
|
const used = new Set([...this.players.values()].map(p => p.color));
|
||||||
|
let color = 0;
|
||||||
|
while (used.has(color)) color++;
|
||||||
|
this.players.set(login, { login, color, body: [], alive: true, score: 0, dir: 'right', queue: [] });
|
||||||
|
}
|
||||||
|
this.emitAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
leave(login: string) {
|
||||||
|
if (this.phase !== 'lobby' || !this.players.has(login)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.players.delete(login);
|
||||||
|
if (login === this.host || this.players.size === 0) {
|
||||||
|
this.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emitAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
start(login: string) {
|
||||||
|
if (this.phase !== 'lobby') {
|
||||||
|
throw new GameError('Není otevřené žádné lobby.');
|
||||||
|
}
|
||||||
|
if (login !== this.host) {
|
||||||
|
throw new GameError('Hru může spustit jen ten, kdo lobby založil.');
|
||||||
|
}
|
||||||
|
if (this.players.size < this.minPlayers) {
|
||||||
|
throw new GameError(`Ke hře je potřeba alespoň ${this.minPlayers} hráčů.`);
|
||||||
|
}
|
||||||
|
this.spawn();
|
||||||
|
this.phase = 'countdown';
|
||||||
|
this.countdown = COUNTDOWN_SECONDS;
|
||||||
|
this.emitAll();
|
||||||
|
this.schedule(() => this.countdownStep(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zařadí změnu směru; obrat o 180° a opakování stejného směru se ignorují. */
|
||||||
|
turn(login: string, dir: unknown) {
|
||||||
|
const p = this.players.get(login);
|
||||||
|
if (!p || !p.alive || !isDirection(dir) || (this.phase !== 'playing' && this.phase !== 'countdown')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const last = p.queue.length ? p.queue[p.queue.length - 1] : p.dir;
|
||||||
|
if (dir === last || dir === OPPOSITE[last] || p.queue.length >= MAX_QUEUED_TURNS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
p.queue.push(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rozmístí hady do řádků, střídavě zleva doprava a zprava doleva. */
|
||||||
|
private spawn() {
|
||||||
|
const list = [...this.players.values()];
|
||||||
|
list.forEach((p, i) => {
|
||||||
|
const y = Math.floor((i + 1) * HEIGHT / (list.length + 1));
|
||||||
|
const fromLeft = i % 2 === 0;
|
||||||
|
const headX = fromLeft ? 2 + START_LENGTH : WIDTH - 3 - START_LENGTH;
|
||||||
|
p.dir = fromLeft ? 'right' : 'left';
|
||||||
|
p.body = Array.from({ length: START_LENGTH }, (_, k) => [fromLeft ? headX - k : headX + k, y] as Cell);
|
||||||
|
p.alive = true;
|
||||||
|
p.score = 0;
|
||||||
|
p.queue = [];
|
||||||
|
p.place = undefined;
|
||||||
|
p.deathTick = undefined;
|
||||||
|
});
|
||||||
|
this.food = [];
|
||||||
|
this.tickNo = 0;
|
||||||
|
this.refillFood();
|
||||||
|
}
|
||||||
|
|
||||||
|
private countdownStep() {
|
||||||
|
this.countdown--;
|
||||||
|
if (this.countdown > 0) {
|
||||||
|
this.emitAll();
|
||||||
|
this.schedule(() => this.countdownStep(), 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.phase = 'playing';
|
||||||
|
this.emitAll();
|
||||||
|
this.schedule(() => this.step(), TICK_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jeden krok simulace. */
|
||||||
|
private step() {
|
||||||
|
this.tickNo++;
|
||||||
|
const alive = [...this.players.values()].filter(p => p.alive);
|
||||||
|
const foodKeys = new Set(this.food.map(key));
|
||||||
|
const eaten = new Set<string>();
|
||||||
|
|
||||||
|
// Pohyb: nová hlava, ocas se odebere, pokud had nejí
|
||||||
|
for (const p of alive) {
|
||||||
|
const next = p.queue.shift();
|
||||||
|
if (next) p.dir = next;
|
||||||
|
const [dx, dy] = DELTAS[p.dir];
|
||||||
|
const head: Cell = [p.body[0][0] + dx, p.body[0][1] + dy];
|
||||||
|
p.body.unshift(head);
|
||||||
|
if (foodKeys.has(key(head))) {
|
||||||
|
eaten.add(key(head));
|
||||||
|
p.score++;
|
||||||
|
} else {
|
||||||
|
p.body.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kolize se vyhodnotí až po pohybu všech, aby nezáleželo na pořadí hráčů
|
||||||
|
const occupied = new Map<string, number>();
|
||||||
|
for (const p of alive) {
|
||||||
|
for (const c of p.body) occupied.set(key(c), (occupied.get(key(c)) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
for (const p of alive) {
|
||||||
|
const [x, y] = p.body[0];
|
||||||
|
const outside = x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT;
|
||||||
|
if (outside || (occupied.get(key(p.body[0])) ?? 0) > 1) {
|
||||||
|
p.alive = false;
|
||||||
|
p.deathTick = this.tickNo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const p of alive) {
|
||||||
|
if (!p.alive) p.body = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.food = this.food.filter(c => !eaten.has(key(c)));
|
||||||
|
this.refillFood();
|
||||||
|
|
||||||
|
const stillAlive = alive.filter(p => p.alive).length;
|
||||||
|
const multiplayer = this.players.size > 1;
|
||||||
|
if (stillAlive === 0 || (multiplayer && stillAlive <= 1) || this.tickNo >= MAX_TICKS) {
|
||||||
|
this.finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emitWatchers();
|
||||||
|
this.schedule(() => this.step(), TICK_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Na hrací ploše je tolik jídel, kolik je hráčů. */
|
||||||
|
private refillFood() {
|
||||||
|
const taken = new Set<string>(this.food.map(key));
|
||||||
|
for (const p of this.players.values()) p.body.forEach(c => taken.add(key(c)));
|
||||||
|
const target = Math.max(1, this.players.size);
|
||||||
|
let attempts = 0;
|
||||||
|
while (this.food.length < target && attempts++ < 1000) {
|
||||||
|
const cell: Cell = [Math.floor(this.clock.random() * WIDTH), Math.floor(this.clock.random() * HEIGHT)];
|
||||||
|
if (!taken.has(key(cell))) {
|
||||||
|
taken.add(key(cell));
|
||||||
|
this.food.push(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish() {
|
||||||
|
const ranked = [...this.players.values()].sort((a, b) =>
|
||||||
|
(b.deathTick ?? Infinity) - (a.deathTick ?? Infinity) || b.score - a.score);
|
||||||
|
ranked.forEach((p, i) => { p.place = i + 1; });
|
||||||
|
this.phase = 'finished';
|
||||||
|
this.clearTimer();
|
||||||
|
this.emitAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancel() {
|
||||||
|
this.reset();
|
||||||
|
this.emitAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset() {
|
||||||
|
this.clearTimer();
|
||||||
|
this.phase = 'idle';
|
||||||
|
this.host = undefined;
|
||||||
|
this.players.clear();
|
||||||
|
this.food = [];
|
||||||
|
this.tickNo = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(fn: () => void, ms: number) {
|
||||||
|
this.clearTimer();
|
||||||
|
this.timer = this.clock.setTimeout(() => {
|
||||||
|
this.timer = undefined;
|
||||||
|
fn();
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer() {
|
||||||
|
if (this.timer !== undefined) {
|
||||||
|
this.clock.clearTimeout(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitAll() {
|
||||||
|
this.broadcastAll(this.getState());
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitWatchers() {
|
||||||
|
this.broadcastWatchers(this.getState());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zaregistruje websocket handlery hada. */
|
||||||
|
export function registerSnakeGame(io: Server) {
|
||||||
|
const game = new SnakeGame(
|
||||||
|
state => io.emit('snake:state', state),
|
||||||
|
state => io.to(WATCH_ROOM).emit('snake:state', state),
|
||||||
|
1, // had se dá hrát i sólo
|
||||||
|
);
|
||||||
|
|
||||||
|
io.on('connection', socket => {
|
||||||
|
const handle = (action: (login: string, payload: unknown) => void) => gameHandler(socket, 'snake:error', action);
|
||||||
|
socket.on('snake:sync', () => socket.emit('snake:state', game.getState()));
|
||||||
|
socket.on('snake:watch', () => {
|
||||||
|
socket.join(WATCH_ROOM);
|
||||||
|
socket.emit('snake:state', game.getState());
|
||||||
|
});
|
||||||
|
socket.on('snake:unwatch', () => socket.leave(WATCH_ROOM));
|
||||||
|
socket.on('snake:join', handle(login => game.join(login)));
|
||||||
|
socket.on('snake:leave', handle(login => game.leave(login)));
|
||||||
|
socket.on('snake:start', handle(login => game.start(login)));
|
||||||
|
socket.on('snake:turn', handle((login, payload) => game.turn(login, (payload as { dir?: unknown })?.dir)));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { DrawingGame, DrawState, Stroke, toWord, isCorrectGuess, ROUND_MS, REVEAL_MS, GUESS_MAX_POINTS, DRAWER_POINTS_PER_GUESS, FALLBACK_WORDS } from '../drawingGame';
|
||||||
|
import { GameError } from '../minigames';
|
||||||
|
import { fakeClock } from './helpers/fakeClock';
|
||||||
|
|
||||||
|
describe('kreslení — slova z menu', () => {
|
||||||
|
test.each([
|
||||||
|
['Plzeňský hovězí guláš s kynutým knedlíkem (1, 3, 7)', 'Plzeňský hovězí guláš', ['gulas']],
|
||||||
|
['Tortilla wrap s trhaným vepřovým masem, slaninou a čedarem', 'Tortilla wrap', ['tortilla', 'wrap']],
|
||||||
|
['Čevabčiči, brambory, cibule, hořčice (1, 3, 7)', 'Čevabčiči', ['cevabcici']],
|
||||||
|
['Sýrová pizza, čerstvá bazalka (1, 7)', 'Sýrová pizza', ['pizza']],
|
||||||
|
['Medailonky z panenky se slaninovým kroupotem', 'Medailonky z panenky', ['medailonky', 'panenky']],
|
||||||
|
])('%s', (dish, display, keywords) => {
|
||||||
|
expect(toWord(dish)).toEqual({ display, keywords });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hádání toleruje diakritiku, velikost písmen a koncovky', () => {
|
||||||
|
const gulas = toWord('Plzeňský hovězí guláš');
|
||||||
|
expect(isCorrectGuess('gulas', gulas)).toBe(true);
|
||||||
|
expect(isCorrectGuess(' GULÁŠ ', gulas)).toBe(true);
|
||||||
|
expect(isCorrectGuess('je to guláš?', gulas)).toBe(true);
|
||||||
|
expect(isCorrectGuess('hovězí', gulas)).toBe(false);
|
||||||
|
expect(isCorrectGuess('gul', gulas)).toBe(false);
|
||||||
|
expect(isCorrectGuess('knedliky', toWord('Knedlík'))).toBe(true);
|
||||||
|
expect(isCorrectGuess('knedliky', toWord('Bramborový knedlík'))).toBe(true);
|
||||||
|
expect(isCorrectGuess('pizzu', toWord('Sýrová pizza'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function setup(dishes: string[] = ['Plzeňský hovězí guláš', 'Sýrová pizza', 'Smažený řízek']) {
|
||||||
|
const clock = fakeClock();
|
||||||
|
const states: DrawState[] = [];
|
||||||
|
const words: { login: string, word: string }[] = [];
|
||||||
|
const strokes: Stroke[] = [];
|
||||||
|
let clears = 0;
|
||||||
|
const game = new DrawingGame(
|
||||||
|
s => states.push(s),
|
||||||
|
(login, word) => words.push({ login, word }),
|
||||||
|
s => strokes.push(s),
|
||||||
|
() => { clears++; },
|
||||||
|
async () => dishes,
|
||||||
|
2,
|
||||||
|
clock,
|
||||||
|
);
|
||||||
|
const last = () => states[states.length - 1];
|
||||||
|
const player = (login: string) => last().players.find(p => p.login === login)!;
|
||||||
|
const startGame = async (...logins: string[]) => {
|
||||||
|
logins.forEach(l => game.join(l));
|
||||||
|
await game.start(logins[0]);
|
||||||
|
};
|
||||||
|
return { game, clock, states, words, strokes, last, player, startGame, clears: () => clears };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('kreslení — průběh hry', () => {
|
||||||
|
test('slovo dostane jen kreslíř, ostatní vidí masku', async () => {
|
||||||
|
const ctx = setup();
|
||||||
|
await ctx.startGame('alice', 'bob');
|
||||||
|
const state = ctx.last();
|
||||||
|
expect(state).toMatchObject({ phase: 'drawing', drawer: 'alice', round: 1, totalRounds: 2 });
|
||||||
|
expect(ctx.words).toHaveLength(1);
|
||||||
|
expect(ctx.words[0].login).toBe('alice');
|
||||||
|
expect(JSON.stringify(ctx.states)).not.toContain(ctx.words[0].word);
|
||||||
|
expect(state.hint).toMatch(/^[_ ]+$/);
|
||||||
|
expect(ctx.game.wordFor('bob')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('správný tip dá body hádajícímu i kreslíři a ukončí kolo, když uhodli všichni', async () => {
|
||||||
|
const ctx = setup(['Plzeňský hovězí guláš', 'Plzeňský hovězí guláš']);
|
||||||
|
await ctx.startGame('alice', 'bob');
|
||||||
|
ctx.game.guess('bob', 'svíčková');
|
||||||
|
expect(ctx.last().messages.at(-1)).toEqual({ login: 'bob', text: 'svíčková' });
|
||||||
|
ctx.game.guess('bob', 'guláš');
|
||||||
|
expect(ctx.player('bob').score).toBe(GUESS_MAX_POINTS);
|
||||||
|
expect(ctx.player('alice').score).toBe(DRAWER_POINTS_PER_GUESS);
|
||||||
|
expect(ctx.last()).toMatchObject({ phase: 'reveal', answer: 'Plzeňský hovězí guláš' });
|
||||||
|
ctx.clock.advance(REVEAL_MS);
|
||||||
|
expect(ctx.last()).toMatchObject({ phase: 'drawing', drawer: 'bob', round: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kreslíř a ti, kdo už uhodli, nemohou psát do chatu', async () => {
|
||||||
|
const ctx = setup(['Plzeňský hovězí guláš', 'Plzeňský hovězí guláš']);
|
||||||
|
await ctx.startGame('alice', 'bob', 'carol');
|
||||||
|
const before = ctx.last().messages.length;
|
||||||
|
ctx.game.guess('alice', 'je to guláš');
|
||||||
|
ctx.game.guess('bob', 'guláš');
|
||||||
|
ctx.game.guess('bob', 'guláš s knedlíkem!');
|
||||||
|
const texts = ctx.last().messages.slice(before).map(m => m.text);
|
||||||
|
expect(texts).toEqual(['bob uhodl(a)! 🎉']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pozdější tip má méně bodů a nápověda postupně odkrývá písmena', async () => {
|
||||||
|
const ctx = setup(['Plzeňský hovězí guláš', 'Plzeňský hovězí guláš']);
|
||||||
|
await ctx.startGame('alice', 'bob', 'carol');
|
||||||
|
ctx.clock.advance(ROUND_MS * 0.75);
|
||||||
|
expect(ctx.last().hint!.replace(/[_ ]/g, '')).toHaveLength(2);
|
||||||
|
ctx.game.guess('bob', 'gulas');
|
||||||
|
expect(ctx.player('bob').score).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('po vypršení času kolo skončí a po všech kolech hra', async () => {
|
||||||
|
const ctx = setup();
|
||||||
|
await ctx.startGame('alice', 'bob');
|
||||||
|
ctx.clock.advance(ROUND_MS);
|
||||||
|
expect(ctx.last().phase).toBe('reveal');
|
||||||
|
ctx.clock.advance(REVEAL_MS + ROUND_MS + REVEAL_MS);
|
||||||
|
expect(ctx.last().phase).toBe('finished');
|
||||||
|
expect(ctx.last().players.every(p => p.place !== undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kreslit smí jen kreslíř a neplatné tahy se zahodí', async () => {
|
||||||
|
const ctx = setup();
|
||||||
|
await ctx.startGame('alice', 'bob');
|
||||||
|
const stroke = { color: '#ff0000', size: 4, points: [[10, 10], [20, 20]], start: true };
|
||||||
|
ctx.game.stroke('bob', stroke);
|
||||||
|
ctx.game.stroke('alice', { ...stroke, color: 'red' });
|
||||||
|
ctx.game.stroke('alice', { ...stroke, points: [[5000, 1]] });
|
||||||
|
expect(ctx.strokes).toHaveLength(0);
|
||||||
|
ctx.game.stroke('alice', stroke);
|
||||||
|
expect(ctx.strokes).toHaveLength(1);
|
||||||
|
expect(ctx.game.getStrokes()).toHaveLength(1);
|
||||||
|
ctx.game.clear('alice');
|
||||||
|
expect(ctx.game.getStrokes()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bez menu se hraje se záložními slovy', async () => {
|
||||||
|
const ctx = setup([]);
|
||||||
|
await ctx.startGame('alice', 'bob');
|
||||||
|
expect(FALLBACK_WORDS.map(w => toWord(w).display)).toContain(ctx.words[0].word);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hru spustí jen zakladatel s dostatkem hráčů', async () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.game.join('alice');
|
||||||
|
await expect(ctx.game.start('alice')).rejects.toThrow(GameError);
|
||||||
|
ctx.game.join('bob');
|
||||||
|
await expect(ctx.game.start('bob')).rejects.toThrow(GameError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/** Ruční hodiny — časovače se spouští jen přes advance(). */
|
||||||
|
export function fakeClock(random: () => number = () => 0) {
|
||||||
|
let now = 1_000_000;
|
||||||
|
let timers: { at: number, fn: () => void, id: number }[] = [];
|
||||||
|
let nextId = 1;
|
||||||
|
return {
|
||||||
|
now: () => now,
|
||||||
|
random,
|
||||||
|
setTimeout: (fn: () => void, ms: number) => {
|
||||||
|
const id = nextId++;
|
||||||
|
timers.push({ at: now + ms, fn, id });
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
clearTimeout: (id: unknown) => { timers = timers.filter(t => t.id !== id); },
|
||||||
|
advance(ms: number) {
|
||||||
|
const target = now + ms;
|
||||||
|
for (;;) {
|
||||||
|
const due = timers.filter(t => t.at <= target).sort((a, b) => a.at - b.at)[0];
|
||||||
|
if (!due) break;
|
||||||
|
timers = timers.filter(t => t !== due);
|
||||||
|
now = due.at;
|
||||||
|
due.fn();
|
||||||
|
}
|
||||||
|
now = target;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { PongGame, PongState, TICK_MS, COUNTDOWN_SECONDS, FIELD_HEIGHT, PADDLE_MARGIN, PADDLE_WIDTH, PADDLE_HEIGHT, WIN_SCORE, SERVE_PAUSE_MS, botLogin, BotLevel } from '../pongGame';
|
||||||
|
import { GameError } from '../minigames';
|
||||||
|
import { fakeClock } from './helpers/fakeClock';
|
||||||
|
|
||||||
|
/** Deterministická pseudonáhoda, ať se boti nechovají pokaždé stejně jako s konstantou. */
|
||||||
|
function lcg(seed = 1) {
|
||||||
|
let x = seed;
|
||||||
|
return () => (x = (x * 1103515245 + 12345) % 2147483648) / 2147483648;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(random?: () => number) {
|
||||||
|
const clock = fakeClock(random);
|
||||||
|
const states: PongState[] = [];
|
||||||
|
const game = new PongGame(s => states.push(s), s => states.push(s), clock);
|
||||||
|
const last = () => states[states.length - 1];
|
||||||
|
const player = (login: string) => last().players.find(p => p.login === login)!;
|
||||||
|
const startGame = () => {
|
||||||
|
game.join('alice');
|
||||||
|
game.join('bob');
|
||||||
|
game.start('alice');
|
||||||
|
clock.advance(COUNTDOWN_SECONDS * 1000);
|
||||||
|
};
|
||||||
|
const ticks = (n: number) => clock.advance(n * TICK_MS);
|
||||||
|
return { game, clock, last, player, startGame, ticks };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('pong', () => {
|
||||||
|
test('je jen pro dva hráče a hru spouští zakladatel', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.game.join('alice');
|
||||||
|
expect(() => ctx.game.start('alice')).toThrow(GameError);
|
||||||
|
ctx.game.join('bob');
|
||||||
|
expect(() => ctx.game.join('carol')).toThrow(GameError);
|
||||||
|
expect(() => ctx.game.start('bob')).toThrow(GameError);
|
||||||
|
ctx.game.start('alice');
|
||||||
|
expect(ctx.last().phase).toBe('countdown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pálka se hýbe podle vstupu a nevyjede z hřiště', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame();
|
||||||
|
ctx.game.input('alice', 'up');
|
||||||
|
ctx.ticks(10);
|
||||||
|
expect(ctx.player('alice').y).toBeLessThan(FIELD_HEIGHT / 2);
|
||||||
|
ctx.ticks(100);
|
||||||
|
expect(ctx.player('alice').y).toBe(45);
|
||||||
|
ctx.game.input('alice', 'stop');
|
||||||
|
const y = ctx.player('alice').y;
|
||||||
|
ctx.ticks(5);
|
||||||
|
expect(ctx.player('alice').y).toBe(y);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('míček se od pálky odrazí', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame();
|
||||||
|
const face = PADDLE_MARGIN + PADDLE_WIDTH;
|
||||||
|
ctx.game.setBallForTest(face + 20, FIELD_HEIGHT / 2, -400, 0);
|
||||||
|
ctx.ticks(3);
|
||||||
|
expect(ctx.last().ball.x).toBeGreaterThan(face);
|
||||||
|
expect(ctx.player('bob').score).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minutý míček je bod pro soupeře a hra končí na vítězné skóre', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame();
|
||||||
|
for (let i = 0; i < WIN_SCORE; i++) {
|
||||||
|
// Míček u horního okraje, kam pálka uprostřed nedosáhne
|
||||||
|
ctx.game.setBallForTest(60, 10, -600, 0);
|
||||||
|
ctx.ticks(10);
|
||||||
|
if (i < WIN_SCORE - 1) {
|
||||||
|
expect(ctx.player('bob').score).toBe(i + 1);
|
||||||
|
ctx.clock.advance(SERVE_PAUSE_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(ctx.last()).toMatchObject({ phase: 'finished', winner: 'bob' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('myš: pálka rychle dojede k cílové výšce a šipka myš přebije', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame();
|
||||||
|
ctx.game.aim('alice', 0);
|
||||||
|
ctx.ticks(5);
|
||||||
|
expect(ctx.player('alice').y).toBe(PADDLE_HEIGHT / 2);
|
||||||
|
ctx.game.input('alice', 'down');
|
||||||
|
ctx.ticks(5);
|
||||||
|
expect(ctx.player('alice').y).toBeGreaterThan(PADDLE_HEIGHT / 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bota přidá jen zakladatel a lze ho odebrat', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.game.join('alice');
|
||||||
|
expect(() => ctx.game.addBot('bob', 'easy')).toThrow(GameError);
|
||||||
|
expect(() => ctx.game.addBot('alice', 'superhard')).toThrow(GameError);
|
||||||
|
ctx.game.addBot('alice', 'hard');
|
||||||
|
expect(ctx.last().players.map(p => p.login)).toEqual(['alice', botLogin('hard')]);
|
||||||
|
ctx.game.removeBot('alice');
|
||||||
|
expect(ctx.last().players).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Odehraje zápas člověka, který myší dokonale sleduje míček, proti botovi. */
|
||||||
|
function playAgainstBot(level: BotLevel, maxSeconds: number) {
|
||||||
|
const ctx = setup(lcg(7));
|
||||||
|
ctx.game.join('alice');
|
||||||
|
ctx.game.addBot('alice', level);
|
||||||
|
ctx.game.start('alice');
|
||||||
|
ctx.clock.advance(COUNTDOWN_SECONDS * 1000);
|
||||||
|
for (let t = 0; t < maxSeconds * 1000 && ctx.last().phase === 'playing'; t += TICK_MS) {
|
||||||
|
ctx.game.aim('alice', ctx.last().ball.y);
|
||||||
|
ctx.ticks(1);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('lehký bot proti dokonalému hráči prohraje', () => {
|
||||||
|
const ctx = playAgainstBot('easy', 300);
|
||||||
|
expect(ctx.last()).toMatchObject({ phase: 'finished', winner: 'alice' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nemožný bot neztratí ani bod', () => {
|
||||||
|
const ctx = playAgainstBot('impossible', 120);
|
||||||
|
expect(ctx.player('alice').score).toBe(0);
|
||||||
|
expect(ctx.player(botLogin('impossible')).score).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { ReactionDuel, DuelState, PENALTY_MS, CLICK_WINDOW_MS, ROUND_PAUSE_MS, MIN_DELAY_MS, LOBBY_TIMEOUT_MS } from '../reactionDuel';
|
||||||
|
import { GameError } from '../minigames';
|
||||||
|
import { fakeClock } from './helpers/fakeClock';
|
||||||
|
|
||||||
|
function setup(minPlayers = 2) {
|
||||||
|
const clock = fakeClock();
|
||||||
|
const states: DuelState[] = [];
|
||||||
|
const duel = new ReactionDuel(s => states.push(s), minPlayers, clock);
|
||||||
|
return { duel, clock, last: () => states[states.length - 1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dohraje jedno kolo: počká na vyhlášení a nechá hráče kliknout s danými časy. */
|
||||||
|
function playRound(ctx: ReturnType<typeof setup>, reactions: Record<string, number>) {
|
||||||
|
ctx.clock.advance(MIN_DELAY_MS);
|
||||||
|
expect(ctx.last().phase).toBe('go');
|
||||||
|
const sorted = Object.entries(reactions).sort((a, b) => a[1] - b[1]);
|
||||||
|
let elapsed = 0;
|
||||||
|
for (const [login, ms] of sorted) {
|
||||||
|
ctx.clock.advance(ms - elapsed);
|
||||||
|
elapsed = ms;
|
||||||
|
ctx.duel.click(login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('reakční hra', () => {
|
||||||
|
test('první hráč zakládá lobby a je hostitel', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.duel.join('alice');
|
||||||
|
ctx.duel.join('bob');
|
||||||
|
expect(ctx.last()).toMatchObject({ phase: 'lobby', host: 'alice' });
|
||||||
|
expect(ctx.last().players.map(p => p.login)).toEqual(['alice', 'bob']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hru může spustit jen hostitel a s dostatkem hráčů', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.duel.join('alice');
|
||||||
|
expect(() => ctx.duel.start('alice')).toThrow(GameError);
|
||||||
|
ctx.duel.join('bob');
|
||||||
|
expect(() => ctx.duel.start('bob')).toThrow(GameError);
|
||||||
|
ctx.duel.start('alice');
|
||||||
|
expect(ctx.last()).toMatchObject({ phase: 'waiting', round: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('do běžící hry se nelze připojit', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.duel.join('alice');
|
||||||
|
ctx.duel.join('bob');
|
||||||
|
ctx.duel.start('alice');
|
||||||
|
expect(() => ctx.duel.join('carol')).toThrow(GameError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vyhrává nejnižší součet časů, nejpomalejší je poslední', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.duel.join('alice');
|
||||||
|
ctx.duel.join('bob');
|
||||||
|
ctx.duel.start('alice');
|
||||||
|
playRound(ctx, { alice: 200, bob: 300 });
|
||||||
|
expect(ctx.last()).toMatchObject({ phase: 'roundResult', round: 1 });
|
||||||
|
ctx.clock.advance(ROUND_PAUSE_MS);
|
||||||
|
playRound(ctx, { alice: 250, bob: 220 });
|
||||||
|
ctx.clock.advance(ROUND_PAUSE_MS);
|
||||||
|
playRound(ctx, { alice: 210, bob: 260 });
|
||||||
|
|
||||||
|
const state = ctx.last();
|
||||||
|
expect(state.phase).toBe('finished');
|
||||||
|
expect(state.players.find(p => p.login === 'alice')?.total).toBe(660);
|
||||||
|
expect(state.players.find(p => p.login === 'bob')?.total).toBe(780);
|
||||||
|
expect(state.winner).toBe('alice');
|
||||||
|
expect(state.loser).toBe('bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('předčasný start a nekliknutí znamenají trestný čas', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.duel.join('alice');
|
||||||
|
ctx.duel.join('bob');
|
||||||
|
ctx.duel.start('alice');
|
||||||
|
ctx.duel.click('alice'); // před vyhlášením
|
||||||
|
ctx.clock.advance(MIN_DELAY_MS);
|
||||||
|
ctx.clock.advance(CLICK_WINDOW_MS); // bob neklikne
|
||||||
|
const state = ctx.last();
|
||||||
|
expect(state.phase).toBe('roundResult');
|
||||||
|
expect(state.players.find(p => p.login === 'alice')?.rounds[0]).toEqual({ ms: null, falseStart: true });
|
||||||
|
expect(state.players.find(p => p.login === 'bob')?.rounds[0]).toEqual({ ms: null });
|
||||||
|
expect(state.players.every(p => p.total === PENALTY_MS)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odchodem hostitele se lobby ruší', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.duel.join('alice');
|
||||||
|
ctx.duel.join('bob');
|
||||||
|
ctx.duel.leave('alice');
|
||||||
|
expect(ctx.last()).toMatchObject({ phase: 'idle', players: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neodstartované lobby po čase vyprší', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.duel.join('alice');
|
||||||
|
ctx.clock.advance(LOBBY_TIMEOUT_MS);
|
||||||
|
expect(ctx.last().phase).toBe('idle');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { SnakeGame, SnakeState, TICK_MS, COUNTDOWN_SECONDS, WIDTH, HEIGHT } from '../snakeGame';
|
||||||
|
import { GameError } from '../minigames';
|
||||||
|
import { fakeClock } from './helpers/fakeClock';
|
||||||
|
|
||||||
|
/** Náhoda vracející zadané hodnoty a pak nuly (jídlo pak padá do rohu [0,0]). */
|
||||||
|
const sequence = (...values: number[]) => () => values.length ? values.shift()! : 0;
|
||||||
|
|
||||||
|
/** Hodnoty náhody, které umístí jídlo na danou buňku. */
|
||||||
|
const foodAt = (x: number, y: number) => [x / WIDTH, y / HEIGHT];
|
||||||
|
|
||||||
|
function setup(random?: () => number) {
|
||||||
|
const clock = fakeClock(random);
|
||||||
|
const states: SnakeState[] = [];
|
||||||
|
const game = new SnakeGame(s => states.push(s), s => states.push(s), 1, clock);
|
||||||
|
const last = () => states[states.length - 1];
|
||||||
|
const player = (login: string) => last().players.find(p => p.login === login)!;
|
||||||
|
const startGame = (...logins: string[]) => {
|
||||||
|
logins.forEach(l => game.join(l));
|
||||||
|
game.start(logins[0]);
|
||||||
|
clock.advance(COUNTDOWN_SECONDS * 1000);
|
||||||
|
};
|
||||||
|
const ticks = (n: number) => clock.advance(n * TICK_MS);
|
||||||
|
return { game, clock, last, player, startGame, ticks };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('had', () => {
|
||||||
|
test('po odpočtu začne hra a had se posouvá o políčko za tick', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame('alice');
|
||||||
|
expect(ctx.last().phase).toBe('playing');
|
||||||
|
expect(ctx.player('alice').body).toEqual([[5, 10], [4, 10], [3, 10]]);
|
||||||
|
ctx.ticks(2);
|
||||||
|
expect(ctx.player('alice').body).toEqual([[7, 10], [6, 10], [5, 10]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zatáčí podle směru a obrat o 180° ignoruje', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame('alice');
|
||||||
|
ctx.game.turn('alice', 'left'); // obrat — ignoruje se
|
||||||
|
ctx.ticks(1);
|
||||||
|
expect(ctx.player('alice').body[0]).toEqual([6, 10]);
|
||||||
|
ctx.game.turn('alice', 'up');
|
||||||
|
ctx.ticks(1);
|
||||||
|
expect(ctx.player('alice').body[0]).toEqual([6, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nesmyslný směr se ignoruje', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame('alice');
|
||||||
|
ctx.game.turn('alice', 'diagonal');
|
||||||
|
ctx.ticks(1);
|
||||||
|
expect(ctx.player('alice').body[0]).toEqual([6, 10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sněděné jídlo prodlouží hada a přidá bod', () => {
|
||||||
|
const ctx = setup(sequence(...foodAt(7, 10)));
|
||||||
|
ctx.startGame('alice');
|
||||||
|
expect(ctx.last().food).toEqual([[7, 10]]);
|
||||||
|
ctx.ticks(2);
|
||||||
|
expect(ctx.player('alice').score).toBe(1);
|
||||||
|
expect(ctx.player('alice').body).toHaveLength(4);
|
||||||
|
expect(ctx.last().food).not.toContainEqual([7, 10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('náraz do zdi ukončí sólo hru', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame('alice');
|
||||||
|
ctx.ticks(WIDTH);
|
||||||
|
expect(ctx.last().phase).toBe('finished');
|
||||||
|
expect(ctx.player('alice')).toMatchObject({ alive: false, place: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ve hře více hráčů vyhrává poslední přeživší', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame('alice', 'bob');
|
||||||
|
// alice startuje v řádku 6, nahoru do zdi dorazí v 7. ticku
|
||||||
|
ctx.game.turn('alice', 'up');
|
||||||
|
ctx.ticks(7);
|
||||||
|
expect(ctx.last().phase).toBe('finished');
|
||||||
|
expect(ctx.player('bob')).toMatchObject({ alive: true, place: 1 });
|
||||||
|
expect(ctx.player('alice')).toMatchObject({ alive: false, place: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('čelní srážka zabije oba hady', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame('alice', 'bob');
|
||||||
|
// alice (5,6)→ doprava, bob (26,13)← doleva; po 7 tickách alice zatočí dolů
|
||||||
|
// a po dalších 7 se obě hlavy potkají na (12,13)
|
||||||
|
ctx.ticks(7);
|
||||||
|
ctx.game.turn('alice', 'down');
|
||||||
|
ctx.ticks(7);
|
||||||
|
const state = ctx.last();
|
||||||
|
expect(state.phase).toBe('finished');
|
||||||
|
expect(state.players.every(p => !p.alive)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('do běžící hry se nelze připojit', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame('alice');
|
||||||
|
expect(() => ctx.game.join('bob')).toThrow(GameError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { TetrisGame, TetrisState, GarbageEvent, COUNTDOWN_SECONDS, MIN_ATTACK_GAP_MS, WATCH_FLUSH_MS, BOARD_WIDTH, BOARD_HEIGHT, PLAYER_TIMEOUT_MS } from '../tetrisGame';
|
||||||
|
import { GameError } from '../minigames';
|
||||||
|
import { fakeClock } from './helpers/fakeClock';
|
||||||
|
|
||||||
|
const BOARD = '0'.repeat(BOARD_WIDTH * (BOARD_HEIGHT - 1)) + '1'.repeat(BOARD_WIDTH);
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const clock = fakeClock();
|
||||||
|
const all: TetrisState[] = [];
|
||||||
|
const watched: TetrisState[] = [];
|
||||||
|
const garbage: GarbageEvent[] = [];
|
||||||
|
const game = new TetrisGame(s => all.push(s), s => watched.push(s), e => garbage.push(e), 1, clock);
|
||||||
|
const last = () => all[all.length - 1];
|
||||||
|
const startGame = (...logins: string[]) => {
|
||||||
|
logins.forEach(l => game.join(l));
|
||||||
|
game.start(logins[0]);
|
||||||
|
clock.advance(COUNTDOWN_SECONDS * 1000);
|
||||||
|
};
|
||||||
|
return { game, clock, all, watched, garbage, last, startGame };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('tetris', () => {
|
||||||
|
test('po odpočtu začne hra se společným seedem', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame('alice', 'bob');
|
||||||
|
expect(ctx.last()).toMatchObject({ phase: 'playing' });
|
||||||
|
expect(typeof ctx.last().seed).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desky se sledujícím posílají sloučeně', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame('alice', 'bob');
|
||||||
|
ctx.game.update('alice', { board: BOARD, score: 100, lines: 1 });
|
||||||
|
ctx.game.update('bob', { board: BOARD, score: 40, lines: 0 });
|
||||||
|
expect(ctx.watched).toHaveLength(0);
|
||||||
|
ctx.clock.advance(WATCH_FLUSH_MS);
|
||||||
|
expect(ctx.watched).toHaveLength(1);
|
||||||
|
expect(ctx.watched[0].players.find(p => p.login === 'alice')).toMatchObject({ board: BOARD, score: 100, lines: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neplatná deska se zahodí', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame('alice');
|
||||||
|
ctx.game.update('alice', { board: 'x'.repeat(200), score: 5 });
|
||||||
|
ctx.game.update('alice', { board: '0'.repeat(10) });
|
||||||
|
ctx.clock.advance(WATCH_FLUSH_MS);
|
||||||
|
expect(ctx.watched).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('smazání více řádků pošle odpad soupeři, jeden řádek nic', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame('alice', 'bob');
|
||||||
|
ctx.game.cleared('alice', 1);
|
||||||
|
expect(ctx.garbage).toHaveLength(0);
|
||||||
|
ctx.game.cleared('alice', 4);
|
||||||
|
expect(ctx.garbage).toEqual([{ target: 'bob', from: 'alice', lines: 4 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('útoky jsou omezené frekvencí a velikostí', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame('alice', 'bob');
|
||||||
|
ctx.game.cleared('alice', 2);
|
||||||
|
ctx.game.cleared('alice', 2); // příliš brzy
|
||||||
|
ctx.game.cleared('alice', 99); // nesmysl
|
||||||
|
expect(ctx.garbage).toHaveLength(1);
|
||||||
|
ctx.clock.advance(MIN_ATTACK_GAP_MS);
|
||||||
|
ctx.game.cleared('alice', 3);
|
||||||
|
expect(ctx.garbage.map(g => g.lines)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('poslední hráč ve hře vyhrává', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame('alice', 'bob', 'carol');
|
||||||
|
ctx.game.died('bob');
|
||||||
|
expect(ctx.last().phase).toBe('playing');
|
||||||
|
ctx.game.died('alice');
|
||||||
|
const state = ctx.last();
|
||||||
|
expect(state.phase).toBe('finished');
|
||||||
|
const place = (l: string) => state.players.find(p => p.login === l)?.place;
|
||||||
|
expect([place('carol'), place('alice'), place('bob')]).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mrtvý hráč neútočí a nedostává odpad', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame('alice', 'bob', 'carol');
|
||||||
|
ctx.game.died('bob');
|
||||||
|
ctx.game.cleared('bob', 4);
|
||||||
|
expect(ctx.garbage).toHaveLength(0);
|
||||||
|
ctx.game.cleared('alice', 4);
|
||||||
|
expect(ctx.garbage[0].target).toBe('carol');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hráč, který přestal posílat desku, je vyřazen', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame('alice', 'bob');
|
||||||
|
// alice hraje dál, bob zavřel okno
|
||||||
|
for (let t = 0; t <= PLAYER_TIMEOUT_MS + 5000; t += 2000) {
|
||||||
|
ctx.game.update('alice', { board: BOARD, score: 0, lines: 0 });
|
||||||
|
ctx.clock.advance(2000);
|
||||||
|
}
|
||||||
|
expect(ctx.last().phase).toBe('finished');
|
||||||
|
expect(ctx.last().players.find(p => p.login === 'alice')?.place).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('do běžící hry se nelze připojit', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.startGame('alice');
|
||||||
|
expect(() => ctx.game.join('bob')).toThrow(GameError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { Server } from "socket.io";
|
||||||
|
import { Clock, defaultClock, GameError, gameHandler } from "./minigames";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multiplayerový Tetris (battle) přes websocket.
|
||||||
|
*
|
||||||
|
* Každý hráč hraje vlastní desku v prohlížeči (kvůli odezvě ovládání), server koordinuje:
|
||||||
|
* lobby a odpočet, společný seed pro pořadí kostek, přeposílání desek soupeřům,
|
||||||
|
* útoky (smazané řádky → odpadní řádky náhodnému soupeři) a vyřazování.
|
||||||
|
* Vyhrává poslední hráč ve hře, sólo hra končí prohrou.
|
||||||
|
*
|
||||||
|
* Klientovi se věří (skóre, smazané řádky) — jde o kancelářskou hru; útoky jsou ale
|
||||||
|
* omezené co do velikosti i frekvence. Stav je v paměti, předpokládá jednu repliku.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BOARD_WIDTH = 10;
|
||||||
|
export const BOARD_HEIGHT = 20;
|
||||||
|
export const MAX_PLAYERS = 8;
|
||||||
|
export const COUNTDOWN_SECONDS = 3;
|
||||||
|
/** Neodstartované lobby se po této době zruší (ms). */
|
||||||
|
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
|
/** Pojistka proti nekonečné hře (ms). */
|
||||||
|
export const MAX_GAME_MS = 15 * 60 * 1000;
|
||||||
|
/** Min. rozestup útoků jednoho hráče (ms) — víc řádků naráz ani rychleji fyzicky smazat nejde. */
|
||||||
|
export const MIN_ATTACK_GAP_MS = 150;
|
||||||
|
export const WATCH_ROOM = 'tetris-watchers';
|
||||||
|
/** Hráč, od kterého tak dlouho nepřišla deska (zavřený tab, výpadek), se bere jako vyřazený (ms). */
|
||||||
|
export const PLAYER_TIMEOUT_MS = 15_000;
|
||||||
|
/** Jak často se kontroluje, zda hráči žijí (ms). */
|
||||||
|
const WATCHDOG_MS = 5000;
|
||||||
|
/** Průběžné desky se sledujícím posílají nejvýš jednou za tuto dobu (ms). */
|
||||||
|
export const WATCH_FLUSH_MS = 100;
|
||||||
|
|
||||||
|
/** Kolik odpadních řádků pošle smazání daného počtu řádků naráz. */
|
||||||
|
export const GARBAGE_FOR_LINES: Record<number, number> = { 1: 0, 2: 1, 3: 2, 4: 4 };
|
||||||
|
|
||||||
|
export type TetrisPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
|
||||||
|
|
||||||
|
export type TetrisPlayer = {
|
||||||
|
login: string,
|
||||||
|
/** Deska po řádcích shora, znak 0 = prázdno, 1–8 = barva (8 = odpad). */
|
||||||
|
board: string,
|
||||||
|
score: number,
|
||||||
|
lines: number,
|
||||||
|
alive: boolean,
|
||||||
|
place?: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TetrisState = {
|
||||||
|
phase: TetrisPhase,
|
||||||
|
host?: string,
|
||||||
|
players: TetrisPlayer[],
|
||||||
|
/** Seed generátoru kostek — všichni hráči dostanou stejné pořadí. */
|
||||||
|
seed?: number,
|
||||||
|
countdown?: number,
|
||||||
|
minPlayers: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GarbageEvent = { target: string, from: string, lines: number };
|
||||||
|
|
||||||
|
type InternalPlayer = TetrisPlayer & { lastAttack: number, lastSeen: number, deathOrder?: number };
|
||||||
|
|
||||||
|
const EMPTY_BOARD = '0'.repeat(BOARD_WIDTH * BOARD_HEIGHT);
|
||||||
|
const BOARD_PATTERN = new RegExp(`^[0-8]{${BOARD_WIDTH * BOARD_HEIGHT}}$`);
|
||||||
|
|
||||||
|
export class TetrisGame {
|
||||||
|
private phase: TetrisPhase = 'idle';
|
||||||
|
private host?: string;
|
||||||
|
private players = new Map<string, InternalPlayer>();
|
||||||
|
private seed?: number;
|
||||||
|
private countdown = 0;
|
||||||
|
private deaths = 0;
|
||||||
|
private timer: unknown;
|
||||||
|
/** Čeká už odeslání průběžného stavu sledujícím? */
|
||||||
|
private flushPending = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param broadcastAll stav pro všechny (změny lobby a fáze)
|
||||||
|
* @param broadcastWatchers průběžný stav jen pro klienty s otevřenou hrou
|
||||||
|
* @param sendGarbage útok na hráče (doručuje se sledujícím, klient si vybere svůj)
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private readonly broadcastAll: (state: TetrisState) => void,
|
||||||
|
private readonly broadcastWatchers: (state: TetrisState) => void,
|
||||||
|
private readonly sendGarbage: (event: GarbageEvent) => void,
|
||||||
|
private readonly minPlayers = 1,
|
||||||
|
private readonly clock: Clock = defaultClock,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
getState(): TetrisState {
|
||||||
|
return {
|
||||||
|
phase: this.phase,
|
||||||
|
host: this.host,
|
||||||
|
players: [...this.players.values()].map(p => ({
|
||||||
|
login: p.login, board: p.board, score: p.score, lines: p.lines, alive: p.alive, place: p.place,
|
||||||
|
})),
|
||||||
|
seed: this.seed,
|
||||||
|
countdown: this.phase === 'countdown' ? this.countdown : undefined,
|
||||||
|
minPlayers: this.minPlayers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
join(login: string) {
|
||||||
|
if (this.phase === 'idle' || this.phase === 'finished') {
|
||||||
|
this.reset();
|
||||||
|
this.phase = 'lobby';
|
||||||
|
this.host = login;
|
||||||
|
this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
|
||||||
|
} else if (this.phase !== 'lobby') {
|
||||||
|
throw new GameError('Hra už běží, počkejte na další.');
|
||||||
|
}
|
||||||
|
if (!this.players.has(login)) {
|
||||||
|
if (this.players.size >= MAX_PLAYERS) {
|
||||||
|
throw new GameError('Lobby je plné.');
|
||||||
|
}
|
||||||
|
this.players.set(login, { login, board: EMPTY_BOARD, score: 0, lines: 0, alive: true, lastAttack: 0, lastSeen: 0 });
|
||||||
|
}
|
||||||
|
this.emitAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
leave(login: string) {
|
||||||
|
if (this.phase !== 'lobby' || !this.players.has(login)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.players.delete(login);
|
||||||
|
if (login === this.host || this.players.size === 0) {
|
||||||
|
this.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emitAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
start(login: string) {
|
||||||
|
if (this.phase !== 'lobby') {
|
||||||
|
throw new GameError('Není otevřené žádné lobby.');
|
||||||
|
}
|
||||||
|
if (login !== this.host) {
|
||||||
|
throw new GameError('Hru může spustit jen ten, kdo lobby založil.');
|
||||||
|
}
|
||||||
|
if (this.players.size < this.minPlayers) {
|
||||||
|
throw new GameError(`Ke hře je potřeba alespoň ${this.minPlayers} hráčů.`);
|
||||||
|
}
|
||||||
|
this.seed = Math.floor(this.clock.random() * 2 ** 31);
|
||||||
|
this.phase = 'countdown';
|
||||||
|
this.countdown = COUNTDOWN_SECONDS;
|
||||||
|
this.emitAll();
|
||||||
|
this.schedule(() => this.countdownStep(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Průběžný stav desky hráče (posílá klient po položení kostky a průběžně). */
|
||||||
|
update(login: string, payload: unknown) {
|
||||||
|
const p = this.players.get(login);
|
||||||
|
if (!p || !p.alive || this.phase !== 'playing') return;
|
||||||
|
const { board, score, lines } = (payload ?? {}) as { board?: unknown, score?: unknown, lines?: unknown };
|
||||||
|
if (typeof board !== 'string' || !BOARD_PATTERN.test(board)) return;
|
||||||
|
p.lastSeen = this.clock.now();
|
||||||
|
p.board = board;
|
||||||
|
if (Number.isSafeInteger(score) && (score as number) >= 0) p.score = score as number;
|
||||||
|
if (Number.isSafeInteger(lines) && (lines as number) >= 0) p.lines = lines as number;
|
||||||
|
this.emitWatchers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hráč smazal řádky — případný útok jde náhodnému živému soupeři. */
|
||||||
|
cleared(login: string, count: unknown) {
|
||||||
|
const p = this.players.get(login);
|
||||||
|
if (!p || !p.alive || this.phase !== 'playing' || !Number.isInteger(count)) return;
|
||||||
|
const garbage = GARBAGE_FOR_LINES[count as number] ?? 0;
|
||||||
|
const now = this.clock.now();
|
||||||
|
if (garbage === 0 || now - p.lastAttack < MIN_ATTACK_GAP_MS) return;
|
||||||
|
p.lastAttack = now;
|
||||||
|
const targets = [...this.players.values()].filter(t => t.alive && t.login !== login);
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
const target = targets[Math.floor(this.clock.random() * targets.length)];
|
||||||
|
this.sendGarbage({ target: target.login, from: login, lines: garbage });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hráči přetekla deska. */
|
||||||
|
died(login: string) {
|
||||||
|
const p = this.players.get(login);
|
||||||
|
if (!p || !p.alive || this.phase !== 'playing') return;
|
||||||
|
p.alive = false;
|
||||||
|
p.deathOrder = ++this.deaths;
|
||||||
|
const alive = [...this.players.values()].filter(x => x.alive).length;
|
||||||
|
if (alive === 0 || (this.players.size > 1 && alive <= 1)) {
|
||||||
|
this.finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emitAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private countdownStep() {
|
||||||
|
this.countdown--;
|
||||||
|
if (this.countdown > 0) {
|
||||||
|
this.emitAll();
|
||||||
|
this.schedule(() => this.countdownStep(), 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.phase = 'playing';
|
||||||
|
const now = this.clock.now();
|
||||||
|
this.players.forEach(p => { p.lastSeen = now; });
|
||||||
|
this.emitAll();
|
||||||
|
this.schedule(() => this.finish(), MAX_GAME_MS);
|
||||||
|
this.clock.setTimeout(() => this.watchdog(), WATCHDOG_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vyřadí hráče, kteří přestali posílat desku (zavřeli okno, ztratili spojení). */
|
||||||
|
private watchdog() {
|
||||||
|
if (this.phase !== 'playing') return;
|
||||||
|
const now = this.clock.now();
|
||||||
|
for (const p of this.players.values()) {
|
||||||
|
if (p.alive && now - p.lastSeen > PLAYER_TIMEOUT_MS) this.died(p.login);
|
||||||
|
}
|
||||||
|
if (this.phase === 'playing') this.clock.setTimeout(() => this.watchdog(), WATCHDOG_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pořadí: živí první (podle skóre), pak vyřazení od posledního. */
|
||||||
|
private finish() {
|
||||||
|
const ranked = [...this.players.values()].sort((a, b) =>
|
||||||
|
(b.deathOrder ?? Infinity) - (a.deathOrder ?? Infinity) || b.score - a.score);
|
||||||
|
ranked.forEach((p, i) => { p.place = i + 1; });
|
||||||
|
this.phase = 'finished';
|
||||||
|
this.clearTimer();
|
||||||
|
this.emitAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancel() {
|
||||||
|
this.reset();
|
||||||
|
this.emitAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset() {
|
||||||
|
this.clearTimer();
|
||||||
|
this.phase = 'idle';
|
||||||
|
this.host = undefined;
|
||||||
|
this.players.clear();
|
||||||
|
this.seed = undefined;
|
||||||
|
this.deaths = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(fn: () => void, ms: number) {
|
||||||
|
this.clearTimer();
|
||||||
|
this.timer = this.clock.setTimeout(() => {
|
||||||
|
this.timer = undefined;
|
||||||
|
fn();
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer() {
|
||||||
|
if (this.timer !== undefined) {
|
||||||
|
this.clock.clearTimeout(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitAll() {
|
||||||
|
this.broadcastAll(this.getState());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktualizace desek se slučují, aby 8 hráčů nezahltilo sledující. */
|
||||||
|
private emitWatchers() {
|
||||||
|
if (this.flushPending) return;
|
||||||
|
this.flushPending = true;
|
||||||
|
this.clock.setTimeout(() => {
|
||||||
|
this.flushPending = false;
|
||||||
|
if (this.phase === 'playing') this.broadcastWatchers(this.getState());
|
||||||
|
}, WATCH_FLUSH_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zaregistruje websocket handlery Tetrisu. */
|
||||||
|
export function registerTetrisGame(io: Server) {
|
||||||
|
const game = new TetrisGame(
|
||||||
|
state => io.emit('tetris:state', state),
|
||||||
|
state => io.to(WATCH_ROOM).emit('tetris:state', state),
|
||||||
|
event => io.to(WATCH_ROOM).emit('tetris:garbage', event),
|
||||||
|
1, // Tetris se dá hrát i sólo
|
||||||
|
);
|
||||||
|
|
||||||
|
io.on('connection', socket => {
|
||||||
|
const handle = (action: (login: string, payload: unknown) => void) => gameHandler(socket, 'tetris:error', action);
|
||||||
|
socket.on('tetris:sync', () => socket.emit('tetris:state', game.getState()));
|
||||||
|
socket.on('tetris:watch', () => {
|
||||||
|
socket.join(WATCH_ROOM);
|
||||||
|
socket.emit('tetris:state', game.getState());
|
||||||
|
});
|
||||||
|
socket.on('tetris:unwatch', () => socket.leave(WATCH_ROOM));
|
||||||
|
socket.on('tetris:join', handle(login => game.join(login)));
|
||||||
|
socket.on('tetris:leave', handle(login => game.leave(login)));
|
||||||
|
socket.on('tetris:start', handle(login => game.start(login)));
|
||||||
|
socket.on('tetris:update', handle((login, payload) => game.update(login, payload)));
|
||||||
|
socket.on('tetris:cleared', handle((login, payload) => game.cleared(login, (payload as { count?: unknown })?.count)));
|
||||||
|
socket.on('tetris:died', handle(login => game.died(login)));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { DefaultEventsMap, Server } from "socket.io";
|
import { DefaultEventsMap, Server } from "socket.io";
|
||||||
import { createAdapter } from "@socket.io/redis-adapter";
|
import { createAdapter } from "@socket.io/redis-adapter";
|
||||||
import { createClient } from "redis";
|
import { createClient } from "redis";
|
||||||
|
import { registerReactionDuel } from "./reactionDuel";
|
||||||
|
import { registerSnakeGame } from "./snakeGame";
|
||||||
|
import { registerTetrisGame } from "./tetrisGame";
|
||||||
|
import { registerPongGame } from "./pongGame";
|
||||||
|
import { registerDrawingGame } from "./drawingGame";
|
||||||
|
|
||||||
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
|
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
|
||||||
let pubClient: ReturnType<typeof createClient>;
|
let pubClient: ReturnType<typeof createClient>;
|
||||||
@@ -28,6 +33,11 @@ export const initWebsocket = (server: any) => {
|
|||||||
// console.log(`Client disconnected: ${socket.id}`);
|
// console.log(`Client disconnected: ${socket.id}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
registerReactionDuel(io);
|
||||||
|
registerSnakeGame(io);
|
||||||
|
registerTetrisGame(io);
|
||||||
|
registerPongGame(io);
|
||||||
|
registerDrawingGame(io);
|
||||||
return io;
|
return io;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user