diff --git a/client/src/components/DrawingGame.scss b/client/src/components/DrawingGame.scss new file mode 100644 index 0000000..1aedfac --- /dev/null +++ b/client/src/components/DrawingGame.scss @@ -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; + } +} diff --git a/client/src/components/DrawingGame.tsx b/client/src/components/DrawingGame.tsx new file mode 100644 index 0000000..11a2f76 --- /dev/null +++ b/client/src/components/DrawingGame.tsx @@ -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(null); + const [open, setOpen] = useState(false); + const [error, setError] = useState(null); + const [word, setWord] = useState(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(null); + const chatRef = useRef(null); + const prevPhase = useRef('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): [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) => { + 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) => { + 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
Bylo to: {state?.answer}
; + } + return ( +
+ Kolo {state?.round}/{state?.totalRounds} · kreslí {state?.drawer} + {isDrawer + ? Nakreslete: {word ?? '…'} + : {state?.hint}} + ⏱ {secondsLeft} s +
+ ); + }; + + const renderGame = () => ( + <> + {renderTopBar()} +
+
+ + {isDrawer && ( +
+ {COLORS.map(c => ( + + ))} + + +
+ )} +
+
+
    + {ranked.map(p => ( +
  • + {p.login === state?.drawer && phase === 'drawing' ? '✏️ ' : ''}{p.login}{p.login === login && ' (vy)'} + {p.score} +
  • + ))} +
+ {renderChat()} +
+
+ + ); + + const renderChat = () => ( + <> +
+ {state?.messages.map((m, i) => ( +
+ {m.login && {m.login}: }{m.text} +
+ ))} +
+
+ setGuess(e.target.value)} + placeholder={isDrawer ? 'Kreslíte…' : canChat ? 'Váš tip…' : 'Uhodli jste ✔'} + disabled={!canChat} + maxLength={100} + onKeyDown={e => e.stopPropagation()} + /> + + + ); + + const renderBody = () => { + switch (phase) { + case 'idle': + return ( +

+ 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. +

+ ); + case 'lobby': + return ( + <> +

Lobby založil(a) {state?.host}. Hráči ({state?.players.length}):

+
    + {state?.players.map(p =>
  • {p.login}{p.login === state.host && ' 👑'}
  • )} +
+ {renderChat()} + + ); + case 'drawing': + case 'reveal': + return renderGame(); + case 'finished': + return ( + <> + + + + {ranked.map((p, i) => ( + + + + + + ))} + +
#HráčBody
{i < 3 ? MEDALS[i] : i + 1}{p.login}{p.login === login && ' (vy)'}{p.score}
+ + ); + } + }; + + const renderFooter = () => { + const buttons = []; + if (phase === 'idle' || phase === 'finished') { + buttons.push(); + } + if (phase === 'lobby') { + if (!isPlayer) { + buttons.push(); + } else { + buttons.push(); + if (login === state?.host) { + buttons.push(); + } + } + } + buttons.push(); + return buttons; + }; + + return ( + <> + {showInvite && ( + + )} + {showReturn && ( + + )} + setOpen(false)} size="xl" className="drawing-game-modal"> + +

{icon} Kreslení

+
+ + {renderBody()} + {error &&
{error}
} +
+ {renderFooter()} +
+ + ); +} diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index 3cd5d7e..cc983c2 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -2,6 +2,12 @@ import { useEffect, useState } from "react"; import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap"; import { useAuth } from "../context/auth"; 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 HuePicker from "./HuePicker"; import PizzaCalculatorModal from "./modals/PizzaCalculatorModal"; @@ -39,6 +45,7 @@ export default function Header({ choices, dayIndex }: Props) { const [clearMockModalOpen, setClearMockModalOpen] = useState(false); const effectiveDark = settings?.effectiveDark ?? false; + const minigameSecret = useMinigameSecret("/"); useEffect(() => { if (!auth?.login) return; @@ -147,7 +154,7 @@ export default function Header({ choices, dayIndex }: Props) { } return - Luncher + Luncher + + + + + diff --git a/client/src/components/PongGame.scss b/client/src/components/PongGame.scss new file mode 100644 index 0000000..48e4c3d --- /dev/null +++ b/client/src/components/PongGame.scss @@ -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; + } +} diff --git a/client/src/components/PongGame.tsx b/client/src/components/PongGame.tsx new file mode 100644 index 0000000..f41c125 --- /dev/null +++ b/client/src/components/PongGame.tsx @@ -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(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()} +
+ + ); +} diff --git a/client/src/components/ReactionDuel.scss b/client/src/components/ReactionDuel.scss new file mode 100644 index 0000000..e613ad7 --- /dev/null +++ b/client/src/components/ReactionDuel.scss @@ -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); } +} diff --git a/client/src/components/ReactionDuel.tsx b/client/src/components/ReactionDuel.tsx new file mode 100644 index 0000000..70491eb --- /dev/null +++ b/client/src/components/ReactionDuel.tsx @@ -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(null); + const [open, setOpen] = useState(false); + const [error, setError] = useState(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('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 ( +
+
Kolo {state.round}/{state.totalRounds}
+
{text}
+ {isPlayer + ?
Klikněte nebo zmáčkněte mezerník, až zezelená.
+ :
Sledujete hru.
} +
+ ); + }; + + const renderResults = (final: boolean) => ( + + + + + + {Array.from({ length: state?.totalRounds ?? 0 }, (_, i) => )} + + + + + {ranked.map((p, i) => ( + + + + {Array.from({ length: state?.totalRounds ?? 0 }, (_, r) => )} + + + ))} + +
#Hráč{i + 1}. koloCelkem
{final && i < 3 ? MEDALS[i] : i + 1}{p.login}{p.login === login && ' (vy)'}{formatResult(p.rounds[r])}{p.rounds.length ? `${p.total} ms` : '—'}
+ ); + + const renderBody = () => { + switch (phase) { + case 'idle': + return ( + <> +

+ 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ů. +

+

Po založení lobby dostanou ostatní online pozvánku.

+ + ); + case 'lobby': + return ( + <> +

Lobby založil(a) {state?.host}. Hráči ({state?.players.length}):

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

Čeká se na další hráče…

} + + ); + case 'waiting': + case 'go': + return renderArena(); + case 'roundResult': + return ( + <> +

Kolo {state?.round}/{state?.totalRounds} skončilo, další za chvíli…

+ {renderResults(false)} + + ); + case 'finished': + return ( + <> +
+ {ranked.slice(0, 3).map((p, i) => ( +
+
{MEDALS[i]}
+
{p.login}
+
{p.total} ms
+
+ ))} +
+ {state?.loser && ranked.length > 3 && +

🐢 Poslední: {state.loser}

} + {renderResults(true)} + + ); + } + }; + + const renderFooter = () => { + const buttons = []; + if (phase === 'idle' || phase === 'finished') { + buttons.push(); + } + if (phase === 'lobby') { + if (!isPlayer) { + buttons.push(); + } else { + buttons.push(); + if (login === state?.host) { + buttons.push(); + } + } + } + buttons.push(); + return buttons; + }; + + return ( + <> + {showInvite && ( + + )} + {showReturn && ( + + )} + setOpen(false)} size="lg" className="reaction-duel-modal"> + +

{icon} Reakční hra

+
+ + {renderBody()} + {error &&
{error}
} +
+ {renderFooter()} +
+ + ); +} diff --git a/client/src/components/SnakeGame.scss b/client/src/components/SnakeGame.scss new file mode 100644 index 0000000..e4dd1cd --- /dev/null +++ b/client/src/components/SnakeGame.scss @@ -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; + } +} diff --git a/client/src/components/SnakeGame.tsx b/client/src/components/SnakeGame.tsx new file mode 100644 index 0000000..f4a08a4 --- /dev/null +++ b/client/src/components/SnakeGame.tsx @@ -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 = { + 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(null); + const [open, setOpen] = useState(false); + const [error, setError] = useState(null); + const canvasRef = useRef(null); + const prevPhase = useRef('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 && ( +
+ + {phase === 'countdown' &&
{state.countdown}
} +
+ ); + + const renderScores = () => ( + + + + + + {ranked.map((p, i) => ( + + + + + + + ))} + +
#HráčJídlaDélka
{phase === 'finished' && i < 3 ? MEDALS[i] : (phase === 'finished' ? i + 1 : '')} + + {p.login}{p.login === login && ' (vy)'}{!p.alive && inGame && ' 💀'} + {p.score}{p.body.length || '—'}
+ ); + + const renderBody = () => { + switch (phase) { + case 'idle': + return ( +

+ 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. +

+ ); + case 'lobby': + return ( + <> +

Lobby založil(a) {state?.host}. Hráči ({state?.players.length}):

+
    + {state?.players.map(p => ( +
  • + + {p.login}{p.login === state.host && ' 👑'} +
  • + ))} +
+ + ); + case 'countdown': + case 'playing': + return ( + <> + {renderBoard()} +
+ {isPlayer ? 'Šipky / WSAD' : 'Sledujete hru'} · zbývá {secondsLeft} s +
+ {renderScores()} + + ); + case 'finished': + return ( + <> + {renderBoard()} + {renderScores()} + + ); + } + }; + + const renderFooter = () => { + const buttons = []; + if (phase === 'idle' || phase === 'finished') { + buttons.push(); + } + if (phase === 'lobby') { + if (!isPlayer) { + buttons.push(); + } else { + buttons.push(); + if (login === state?.host) { + buttons.push(); + } + } + } + buttons.push(); + return buttons; + }; + + return ( + <> + {showInvite && ( + + )} + {showReturn && ( + + )} + setOpen(false)} size="xl" className="snake-game-modal"> + +

{icon} Had

+
+ + {renderBody()} + {error &&
{error}
} +
+ {renderFooter()} +
+ + ); +} diff --git a/client/src/components/TetrisGame.scss b/client/src/components/TetrisGame.scss new file mode 100644 index 0000000..c515fa0 --- /dev/null +++ b/client/src/components/TetrisGame.scss @@ -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; + } +} diff --git a/client/src/components/TetrisGame.tsx b/client/src/components/TetrisGame.tsx new file mode 100644 index 0000000..074b9e6 --- /dev/null +++ b/client/src/components/TetrisGame.tsx @@ -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 = { + 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, 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(null); + useEffect(() => { + if (ref.current) drawBoard(ref.current, player.board, MINI_CELL); + }, [player.board]); + return ( +
+ +
{player.login}{!player.alive && ' 💀'}
+
{player.score}
+
+ ); +} + +/** 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(null); + const [open, setOpen] = useState(false); + const [error, setError] = useState(null); + const [flash, setFlash] = useState(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(null); + const sessionSeed = useRef(undefined); + const boardRef = useRef(null); + const nextRef = useRef(null); + const prevPhase = useRef('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 = () => ( +
+ {(isPlayer || practice) && ( +
+
+ + {!practice && phase === 'countdown' &&
{state?.countdown}
} + {session && !session.alive &&
Konec 💀
} + {flash &&
{flash}
} +
+
+
Další
+ +
Skóre
+
{session?.score ?? 0}
+
Řádky
+
{session?.lines ?? 0}
+
Úroveň
+
{session?.level ?? 0}
+
+ ←→ / AD posun
↑ / W otočit
↓ / S dolů
mezerník shodit +
+
+
+ )} + {others.length > 0 && ( +
+ {others.map(p => )} +
+ )} +
+ ); + + const renderBody = () => { + if (practice) { + return ( + <> +

🎯 Trénink — hra jen pro vás, nikdo ji nevidí.

+ {renderPlaying()} + + ); + } + switch (phase) { + case 'idle': + return ( +

+ 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. +

+ ); + case 'lobby': + return ( + <> +

Lobby založil(a) {state?.host}. Hráči ({state?.players.length}):

+
    + {state?.players.map(p =>
  • {p.login}{p.login === state.host && ' 👑'}
  • )} +
+ + ); + case 'countdown': + case 'playing': + return renderPlaying(); + case 'finished': + return ( + + + + + + {ranked.map((p, i) => ( + + + + + + + ))} + +
#HráčSkóreŘádky
{i < 3 ? MEDALS[i] : i + 1}{p.login}{p.login === login && ' (vy)'}{p.score}{p.lines}
+ ); + } + }; + + const renderFooter = () => { + const buttons = []; + if (practice) { + if (session && !session.alive) { + buttons.push(); + } + buttons.push(); + buttons.push(); + return buttons; + } + // Trénink jde spustit, kdykoli zrovna nehrajete online hru + if (!(isPlayer && (phase === 'lobby' || inGame))) { + buttons.push(); + } + if (phase === 'idle' || phase === 'finished') { + buttons.push(); + } + if (phase === 'lobby') { + if (!isPlayer) { + buttons.push(); + } else { + buttons.push(); + if (login === state?.host) { + buttons.push(); + } + } + } + buttons.push(); + return buttons; + }; + + return ( + <> + {showInvite && ( + + )} + {showReturn && ( + + )} + setOpen(false)} size="xl" className="tetris-game-modal"> + +

{icon} Tetris

+
+ + {renderBody()} + {error &&
{error}
} +
+ {renderFooter()} +
+ + ); +} diff --git a/client/src/games/tetrisEngine.ts b/client/src/games/tetrisEngine.ts new file mode 100644 index 0000000..707f4c3 --- /dev/null +++ b/client/src/games/tetrisEngine.ts @@ -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(); + } +} diff --git a/client/src/games/themeIcons.ts b/client/src/games/themeIcons.ts new file mode 100644 index 0000000..144bebc --- /dev/null +++ b/client/src/games/themeIcons.ts @@ -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> = { + 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 — 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 . */ +export function useGameIcon(game: GameId) { + const settings = useSettings(); + return gameIcon(game, settings?.styleTheme ?? currentTheme()); +} diff --git a/client/src/hooks/useMinigameSecret.ts b/client/src/hooks/useMinigameSecret.ts new file mode 100644 index 0000000..20ebef1 --- /dev/null +++ b/client/src/hooks/useMinigameSecret.ts @@ -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(undefined); + + const onLogoClick = useCallback((e: React.MouseEvent) => { + // 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 }; +} diff --git a/server/changelogs/2026-10-12.json b/server/changelogs/2026-10-12.json index bb14e16..b1d2473 100644 --- a/server/changelogs/2026-10-12.json +++ b/server/changelogs/2026-10-12.json @@ -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 😉" ] diff --git a/server/src/drawingGame.ts b/server/src/drawingGame.ts new file mode 100644 index 0000000..7e5d15b --- /dev/null +++ b/server/src/drawingGame.ts @@ -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(); + 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, + 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; + 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 { + 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) => 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))); + }); +} diff --git a/server/src/minigames.ts b/server/src/minigames.ts new file mode 100644 index 0000000..032769b --- /dev/null +++ b/server/src/minigames.ts @@ -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) { + 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; diff --git a/server/src/pongGame.ts b/server/src/pongGame.ts new file mode 100644 index 0000000..0c763e8 --- /dev/null +++ b/server/src/pongGame.ts @@ -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 = { + 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))); + }); +} diff --git a/server/src/reactionDuel.ts b/server/src/reactionDuel.ts new file mode 100644 index 0000000..ed5670b --- /dev/null +++ b/server/src/reactionDuel.ts @@ -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(); + /** Výsledky aktuálního kola. */ + private current = new Map(); + 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))); + }); +} diff --git a/server/src/snakeGame.ts b/server/src/snakeGame.ts new file mode 100644 index 0000000..bb7e29e --- /dev/null +++ b/server/src/snakeGame.ts @@ -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 = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }; +const OPPOSITE: Record = { 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(); + 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(); + + // 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(); + 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(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))); + }); +} diff --git a/server/src/tests/drawingGame.test.ts b/server/src/tests/drawingGame.test.ts new file mode 100644 index 0000000..fa195f2 --- /dev/null +++ b/server/src/tests/drawingGame.test.ts @@ -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); + }); +}); diff --git a/server/src/tests/helpers/fakeClock.ts b/server/src/tests/helpers/fakeClock.ts new file mode 100644 index 0000000..092610c --- /dev/null +++ b/server/src/tests/helpers/fakeClock.ts @@ -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; + }, + }; +} diff --git a/server/src/tests/pongGame.test.ts b/server/src/tests/pongGame.test.ts new file mode 100644 index 0000000..eda24e0 --- /dev/null +++ b/server/src/tests/pongGame.test.ts @@ -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); + }); +}); diff --git a/server/src/tests/reactionDuel.test.ts b/server/src/tests/reactionDuel.test.ts new file mode 100644 index 0000000..904bf31 --- /dev/null +++ b/server/src/tests/reactionDuel.test.ts @@ -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, reactions: Record) { + 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'); + }); +}); diff --git a/server/src/tests/snakeGame.test.ts b/server/src/tests/snakeGame.test.ts new file mode 100644 index 0000000..0acf9b5 --- /dev/null +++ b/server/src/tests/snakeGame.test.ts @@ -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); + }); +}); diff --git a/server/src/tests/tetrisGame.test.ts b/server/src/tests/tetrisGame.test.ts new file mode 100644 index 0000000..6811cce --- /dev/null +++ b/server/src/tests/tetrisGame.test.ts @@ -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); + }); +}); diff --git a/server/src/tetrisGame.ts b/server/src/tetrisGame.ts new file mode 100644 index 0000000..eb56c7b --- /dev/null +++ b/server/src/tetrisGame.ts @@ -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 = { 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(); + 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))); + }); +} diff --git a/server/src/websocket.ts b/server/src/websocket.ts index 394b9b7..5519c2c 100644 --- a/server/src/websocket.ts +++ b/server/src/websocket.ts @@ -1,6 +1,11 @@ import { DefaultEventsMap, Server } from "socket.io"; import { createAdapter } from "@socket.io/redis-adapter"; 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; let pubClient: ReturnType; @@ -28,6 +33,11 @@ export const initWebsocket = (server: any) => { // console.log(`Client disconnected: ${socket.id}`); }); }); + registerReactionDuel(io); + registerSnakeGame(io); + registerTetrisGame(io); + registerPongGame(io); + registerDrawingGame(io); return io; };