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(null); const [open, setOpen] = useState(false); const [error, setError] = useState(null); const canvasRef = useRef(null); const prevPhase = useRef('idle'); const icon = useGameIcon('pong'); const [botLevel, setBotLevel] = useState('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(); 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 && ( <>
{left?.login} {left?.score ?? 0} : {right?.score ?? 0} {right?.login}
{phase === 'countdown' &&
{state.countdown}
} {phase === 'finished' &&
🏆 {state.winner}
}
{isPlayer ? 'Myš, nebo ↑↓ / W S' : 'Sledujete zápas'}
); const renderBody = () => { switch (phase) { case 'idle': return (

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.

); case 'lobby': return ( <>

Lobby založil(a) {state?.host}.

    {state?.players.map(p =>
  • {p.login}{p.login === state.host && ' 👑'}
  • )}
{(state?.players.length ?? 0) < 2 &&

Čeká se na soupeře…

} {login === state?.host && (state?.players.length ?? 0) < 2 && (
Nebo hrát proti botovi: setBotLevel(e.target.value as BotLevel)}> {BOT_LEVELS.map(l => )}
)} {login === state?.host && state?.players.some(p => p.bot) && ( )} ); default: return renderBoard(); } }; const renderFooter = () => { const buttons = []; if (phase === 'idle' || phase === 'finished') { buttons.push(); } if (phase === 'lobby') { if (!isPlayer) { if ((state?.players.length ?? 0) < 2) { buttons.push(); } } else { buttons.push(); if (login === state?.host) { buttons.push(); } } } buttons.push(); return buttons; }; return ( <> {showInvite && ( )} {showReturn && ( )} setOpen(false)} size="xl" className="pong-game-modal">

{icon} Pong

{renderBody()} {error &&
{error}
}
{renderFooter()}
); }