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