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,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user