From 35cf2ca9d7695f03ef3a4a36803efbd7ead602b3 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Thu, 22 Oct 2026 12:42:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20vytr=C5=BEen=C3=AD=20miniher=20do=20sam?= =?UTF-8?q?ostatn=C3=A9ho=20projektu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/DrawingGame.scss | 21 +- client/src/components/Header.scss | 31 + client/src/components/Header.tsx | 11 +- client/src/components/LobbyChat.scss | 35 - client/src/components/LobbyChat.tsx | 64 - client/src/components/PongGame.scss | 71 -- client/src/components/PongGame.tsx | 317 ----- client/src/components/ReactionDuel.scss | 134 --- client/src/components/ReactionDuel.tsx | 266 ----- client/src/components/SnakeGame.scss | 82 -- client/src/components/SnakeGame.tsx | 361 ------ client/src/components/TankGame.scss | 182 --- client/src/components/TankGame.tsx | 565 --------- client/src/components/TetrisGame.scss | 193 --- client/src/components/TetrisGame.tsx | 663 ----------- client/src/components/TouchButton.scss | 37 - client/src/components/TouchButton.tsx | 52 - client/src/games/tankRender.ts | 948 --------------- client/src/games/tetrisEngine.ts | 270 ----- client/src/games/themeIcons.ts | 26 +- client/src/hooks/useMinigameSecret.ts | 103 +- client/src/hooks/useTouchDevice.ts | 16 - client/src/index.tsx | 1 + client/src/utils/appConfig.ts | 2 + client/src/utils/arrowNavigation.ts | 2 +- k8s/base/server-configmap.yaml | 1 + server/.env.template | 4 + server/changelogs/2026-10-21.json | 3 +- server/src/index.ts | 3 + server/src/minigames.ts | 20 +- server/src/pongGame.ts | 429 ------- server/src/reactionDuel.ts | 250 ---- server/src/snakeGame.ts | 344 ------ server/src/tankGame.ts | 1426 ----------------------- server/src/tests/pongGame.test.ts | 124 -- server/src/tests/reactionDuel.test.ts | 101 -- server/src/tests/snakeGame.test.ts | 102 -- server/src/tests/tankGame.test.ts | 451 ------- server/src/tests/tetrisGame.test.ts | 233 ---- server/src/tetrisGame.ts | 392 ------- server/src/websocket.ts | 12 - types/paths/config/config.yml | 12 +- 42 files changed, 136 insertions(+), 8224 deletions(-) create mode 100644 client/src/components/Header.scss delete mode 100644 client/src/components/LobbyChat.scss delete mode 100644 client/src/components/LobbyChat.tsx delete mode 100644 client/src/components/PongGame.scss delete mode 100644 client/src/components/PongGame.tsx delete mode 100644 client/src/components/ReactionDuel.scss delete mode 100644 client/src/components/ReactionDuel.tsx delete mode 100644 client/src/components/SnakeGame.scss delete mode 100644 client/src/components/SnakeGame.tsx delete mode 100644 client/src/components/TankGame.scss delete mode 100644 client/src/components/TankGame.tsx delete mode 100644 client/src/components/TetrisGame.scss delete mode 100644 client/src/components/TetrisGame.tsx delete mode 100644 client/src/components/TouchButton.scss delete mode 100644 client/src/components/TouchButton.tsx delete mode 100644 client/src/games/tankRender.ts delete mode 100644 client/src/games/tetrisEngine.ts delete mode 100644 client/src/hooks/useTouchDevice.ts delete mode 100644 server/src/pongGame.ts delete mode 100644 server/src/reactionDuel.ts delete mode 100644 server/src/snakeGame.ts delete mode 100644 server/src/tankGame.ts delete mode 100644 server/src/tests/pongGame.test.ts delete mode 100644 server/src/tests/reactionDuel.test.ts delete mode 100644 server/src/tests/snakeGame.test.ts delete mode 100644 server/src/tests/tankGame.test.ts delete mode 100644 server/src/tests/tetrisGame.test.ts delete mode 100644 server/src/tetrisGame.ts diff --git a/client/src/components/DrawingGame.scss b/client/src/components/DrawingGame.scss index 4b53933..1bc92be 100644 --- a/client/src/components/DrawingGame.scss +++ b/client/src/components/DrawingGame.scss @@ -1,6 +1,21 @@ -// Pozvánka Kreslení nad pozvánkami ostatních her, aby se nepřekrývaly -.duel-invite.draw-invite { - bottom: 224px; +.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); } } .drawing-game-modal { diff --git a/client/src/components/Header.scss b/client/src/components/Header.scss new file mode 100644 index 0000000..092739e --- /dev/null +++ b/client/src/components/Header.scss @@ -0,0 +1,31 @@ +// Bez zoomu dvojitým ťuknutím, jinak by iOS sérii ťuknutí na logo spolkl +.navbar-brand { + touch-action: manipulation; +} + +// Skryté pole pro kód minihry — musí jít zaměřit (kvůli softwarové klávesnici), proto ne display: none. +// Písmo aspoň 16px, jinak iOS při zaměření přiblíží stránku. +.minigame-secret-input { + position: fixed; + top: 0; + left: 0; + width: 1px; + height: 1px; + padding: 0; + border: 0; + font-size: 16px; + opacity: 0; + pointer-events: none; +} + +// 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/Header.tsx b/client/src/components/Header.tsx index cd46afa..8e12746 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -2,12 +2,8 @@ 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 TankGame from "./TankGame"; +import "./Header.scss"; import { useMinigameSecret } from "../hooks/useMinigameSecret"; import { useSettings, ThemePreference } from "../context/settings"; import HuePicker from "./HuePicker"; @@ -205,12 +201,7 @@ export default function Header({ choices, dayIndex }: Props) { - - - - - diff --git a/client/src/components/LobbyChat.scss b/client/src/components/LobbyChat.scss deleted file mode 100644 index b691314..0000000 --- a/client/src/components/LobbyChat.scss +++ /dev/null @@ -1,35 +0,0 @@ -.lobby-chat { - margin-top: 12px; - - .lobby-chat-messages { - height: 140px; - overflow-y: auto; - border: 1px solid rgba(127, 127, 127, 0.35); - border-radius: 6px; - padding: 6px 8px; - margin-bottom: 6px; - font-size: 0.9rem; - word-break: break-word; - } - - .lobby-chat-emojis { - display: flex; - flex-wrap: wrap; - gap: 2px; - margin-bottom: 4px; - - button { - border: none; - background: none; - padding: 0 3px; - font-size: 1.2rem; - line-height: 1.4; - border-radius: 4px; - cursor: pointer; - - &:hover { - background: rgba(127, 127, 127, 0.2); - } - } - } -} diff --git a/client/src/components/LobbyChat.tsx b/client/src/components/LobbyChat.tsx deleted file mode 100644 index 235ff47..0000000 --- a/client/src/components/LobbyChat.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { FormEvent, useEffect, useRef, useState } from "react"; -import { Form } from "react-bootstrap"; -import { socket } from "../context/socket"; -import { getToken } from "../Utils"; -import "./LobbyChat.scss"; - -const EMOJIS = ['😀', '😂', '😎', '😢', '😡', '👍', '👎', '👋', '🎉', '🔥', '❤️', '🍕', '🍔', '🍺', '⏳']; - -type LobbyChatMessage = { game: string, login: string, text: string }; - -/** Jednoduchý chat v lobby minihry — zprávy se drží jen po dobu zobrazení lobby. */ -export default function LobbyChat({ game }: { game: string }) { - const [messages, setMessages] = useState([]); - const [text, setText] = useState(''); - const listRef = useRef(null); - const inputRef = useRef(null); - - useEffect(() => { - const onMessage = (m: LobbyChatMessage) => { - if (m.game === game) setMessages(prev => [...prev.slice(-99), m]); - }; - socket.on('lobbychat:message', onMessage); - return () => { socket.off('lobbychat:message', onMessage); }; - }, [game]); - - useEffect(() => { - listRef.current?.scrollTo({ top: listRef.current.scrollHeight }); - }, [messages]); - - const submit = (e: FormEvent) => { - e.preventDefault(); - if (!text.trim()) return; - socket.emit('lobbychat:send', { token: getToken(), game, text }); - setText(''); - }; - - const addEmoji = (emoji: string) => { - setText(prev => (prev + emoji).slice(0, 200)); - inputRef.current?.focus(); - }; - - return ( -
-
- {messages.length === 0 &&
Zatím žádné zprávy…
} - {messages.map((m, i) =>
{m.login}: {m.text}
)} -
-
- {EMOJIS.map(e => )} -
-
- setText(e.target.value)} - placeholder="Napište zprávu…" - maxLength={200} - onKeyDown={e => e.stopPropagation()} - /> - -
- ); -} diff --git a/client/src/components/PongGame.scss b/client/src/components/PongGame.scss deleted file mode 100644 index 3f6f79e..0000000 --- a/client/src/components/PongGame.scss +++ /dev/null @@ -1,71 +0,0 @@ -// 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; - // Tah prstem po hřišti hýbe pálkou, ne stránkou - touch-action: none; - user-select: none; - -webkit-user-select: none; - -webkit-touch-callout: none; - } - - .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 deleted file mode 100644 index 2ba1ed5..0000000 --- a/client/src/components/PongGame.tsx +++ /dev/null @@ -1,317 +0,0 @@ -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 { useTouchDevice } from "../hooks/useTouchDevice"; -import LobbyChat from "./LobbyChat"; -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 touch = useTouchDevice(); - 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ší či prstem: 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); - // Prst nemá "hover" — pálka skočí už na dotyk, ne až na první pohyb - window.addEventListener('pointerdown', onMove); - window.addEventListener('pointermove', onMove); - return () => { - window.clearInterval(id); - window.removeEventListener('pointerdown', onMove); - 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 ? (touch ? 'Táhněte prstem po hřišti' : '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), prstem po hřišti 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) && ( - - )} - {isPlayer && } - - ); - 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" fullscreen="sm-down" className="pong-game-modal"> - -

{icon} Pong

-
- - {renderBody()} - {error &&
{error}
} -
- {renderFooter()} -
- - ); -} diff --git a/client/src/components/ReactionDuel.scss b/client/src/components/ReactionDuel.scss deleted file mode 100644 index 12b194b..0000000 --- a/client/src/components/ReactionDuel.scss +++ /dev/null @@ -1,134 +0,0 @@ -.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); - } -} - -// Bez zoomu dvojitým ťuknutím, jinak by iOS sérii ťuknutí na logo spolkl -.navbar-brand { - touch-action: manipulation; -} - -// Skryté pole pro kód minihry — musí jít zaměřit (kvůli softwarové klávesnici), proto ne display: none. -// Písmo aspoň 16px, jinak iOS při zaměření přiblíží stránku. -.minigame-secret-input { - position: fixed; - top: 0; - left: 0; - width: 1px; - height: 1px; - padding: 0; - border: 0; - font-size: 16px; - opacity: 0; - pointer-events: none; -} - -// 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 deleted file mode 100644 index 2b9537c..0000000 --- a/client/src/components/ReactionDuel.tsx +++ /dev/null @@ -1,266 +0,0 @@ -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 { useTouchDevice } from "../hooks/useTouchDevice"; -import LobbyChat from "./LobbyChat"; -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 touch = useTouchDevice(); - - 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 - ?
{touch ? 'Ťukněte, až zezelená.' : '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…

} - {isPlayer && } - - ); - 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" fullscreen="sm-down" 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 deleted file mode 100644 index 2cba93b..0000000 --- a/client/src/components/SnakeGame.scss +++ /dev/null @@ -1,82 +0,0 @@ -// 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; - touch-action: none; - user-select: none; - -webkit-user-select: none; - -webkit-touch-callout: none; - } - - // Kříž šipek pro dotykové ovládání - .snake-touch-pad { - display: grid; - grid-template-areas: ". up ." "left . right" ". down ."; - grid-template-columns: repeat(3, calc(var(--touch-btn-size) + 8px)); - grid-template-rows: repeat(3, var(--touch-btn-size)); - gap: 4px; - justify-content: center; - margin: 8px 0 16px; - - .up { grid-area: up; } - .left { grid-area: left; } - .right { grid-area: right; } - .down { grid-area: down; } - } - - .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 deleted file mode 100644 index 83a2df7..0000000 --- a/client/src/components/SnakeGame.tsx +++ /dev/null @@ -1,361 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { Button, Modal, Table } from "react-bootstrap"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCaretDown, faCaretLeft, faCaretRight, faCaretUp } from "@fortawesome/free-solid-svg-icons"; -import { socket } from "../context/socket"; -import { useAuth } from "../context/auth"; -import { getToken } from "../Utils"; -import { useGameIcon } from "../games/themeIcons"; -import { useTouchDevice } from "../hooks/useTouchDevice"; -import TouchButton from "./TouchButton"; -import LobbyChat from "./LobbyChat"; -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', -}; - -/** Délka tahu prstem po ploše (px), od které se bere jako zatočení. */ -const SWIPE_PX = 24; - -/** 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 touch = useTouchDevice(); - /** Rozpracovaný tah prstem — bod, od kterého se měří další zatočení, a poslední směr v tomto tahu. */ - const swipe = useRef<{ pointerId: number, x: number, y: number, last?: Direction } | null>(null); - - 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'; - const controllable = open && inGame && isPlayer; - - const turn = useCallback((dir: Direction) => { - if (controllable) send('snake:turn', { dir }); - }, [controllable, send]); - - // 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 (!controllable) return; - const onKey = (e: KeyboardEvent) => { - const dir = KEY_DIRECTIONS[e.code]; - if (!dir) return; - e.preventDefault(); - if (!e.repeat) turn(dir); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [controllable, turn]); - - // Ovládání tahem prstu po ploše; v jednom tahu jde zatočit i víckrát (cik-cak) - const onBoardPointerDown = (e: React.PointerEvent) => { - if (e.pointerType === 'mouse' || !controllable) return; - e.preventDefault(); - e.currentTarget.setPointerCapture(e.pointerId); - swipe.current = { pointerId: e.pointerId, x: e.clientX, y: e.clientY }; - }; - - const onBoardPointerMove = (e: React.PointerEvent) => { - const s = swipe.current; - if (!s || s.pointerId !== e.pointerId) return; - const dx = e.clientX - s.x; - const dy = e.clientY - s.y; - if (Math.max(Math.abs(dx), Math.abs(dy)) < SWIPE_PX) return; - const dir: Direction = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up'); - if (dir !== s.last) turn(dir); - swipe.current = { ...s, x: e.clientX, y: e.clientY, last: dir }; - }; - - const onBoardPointerEnd = () => { - swipe.current = null; - }; - - 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, na dotykovém displeji tahem prstu - po ploše nebo tlačítky. 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 && ' 👑'} -
  • - ))} -
- {isPlayer && } - - ); - case 'countdown': - case 'playing': - return ( - <> - {renderBoard()} -
- {isPlayer ? (touch ? 'Táhněte prstem po ploše' : 'Šipky / WSAD') : 'Sledujete hru'} · zbývá {secondsLeft} s -
- {touch && isPlayer && ( -
- turn('up')}> - turn('left')}> - turn('right')}> - turn('down')}> -
- )} - {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" fullscreen="sm-down" className="snake-game-modal"> - -

{icon} Had

-
- - {renderBody()} - {error &&
{error}
} -
- {renderFooter()} -
- - ); -} diff --git a/client/src/components/TankGame.scss b/client/src/components/TankGame.scss deleted file mode 100644 index e0b3858..0000000 --- a/client/src/components/TankGame.scss +++ /dev/null @@ -1,182 +0,0 @@ -// Pozvánka tanků nad pozvánkami ostatních her, aby se nepřekrývaly -.duel-invite.tanks-invite { - bottom: 276px; -} - -// Hřiště je široké 1120 px — okno je o kus širší než běžné xl, ať se plocha nezmenšuje -@media (min-width: 1200px) { - .tanks-game-modal .modal-dialog { - --bs-modal-width: min(1200px, calc(100vw - 32px)); - } -} - -.tanks-game-modal { - .tanks-board-wrap { - position: relative; - max-width: 1120px; - margin: 0 auto; - } - - .tanks-board { - display: block; - width: 100%; - height: auto; - border-radius: 8px; - cursor: crosshair; - } - - .tanks-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: 2rem; - } - } - - .tanks-status { - text-align: center; - opacity: 0.75; - margin: 8px 0; - } - - .tanks-scores { - max-width: 960px; - margin: 0 auto; - } - - .tanks-out td { - opacity: 0.5; - } - - .tanks-dot { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 50%; - margin-right: 6px; - } - - .tanks-players { - list-style: none; - padding-left: 0; - - &.two-columns { - columns: 2; - } - } - - .tanks-switch { - padding: 0 6px; - line-height: 1; - text-decoration: none; - } - - .tanks-teams { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; - } - - .tanks-teamscore { - display: flex; - justify-content: center; - align-items: baseline; - gap: 24px; - font-size: 1.1rem; - margin-bottom: 8px; - - strong { - font-size: 1.8rem; - } - } - - .tanks-legend { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 2px 18px; - list-style: none; - padding: 0; - margin: 8px 0 0; - font-size: 0.8rem; - opacity: 0.8; - } - - .tanks-controls { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - gap: 4px 16px; - margin-top: 8px; - } - - .tanks-equipment { - text-align: center; - font-size: 1.1rem; - margin-top: 8px; - } - - .tanks-items { - white-space: nowrap; - } - - .tanks-pickups { - columns: 2; - list-style: none; - padding-left: 0; - } - - .tanks-config { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin-bottom: 8px; - - .form-label { - margin-bottom: 2px; - font-size: 0.85rem; - } - - .form-select { - min-width: 190px; - } - } - - // Volby hry pod sebou: popisek vlevo, ovládání vpravo - .tanks-options { - display: grid; - grid-template-columns: max-content 1fr; - align-items: center; - gap: 8px 16px; - margin-bottom: 12px; - - .form-check { - margin-bottom: 0; - } - } - - .tanks-option-label { - font-size: 0.85rem; - opacity: 0.8; - } - - .tanks-bot-picker { - display: flex; - gap: 6px; - - .form-select { - width: auto; - min-width: 130px; - } - } -} diff --git a/client/src/components/TankGame.tsx b/client/src/components/TankGame.tsx deleted file mode 100644 index 6f1f7de..0000000 --- a/client/src/components/TankGame.tsx +++ /dev/null @@ -1,565 +0,0 @@ -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 { - BIOMES, BotLevel, drawTanks, FLASH_MS, MuzzleFlash, PICKUPS, PickupType, tankColor, TankMode, TankPhase, - TankPlayer, TankState, TEAM_COLORS, TEAM_NAMES, -} from "../games/tankRender"; -import LobbyChat from "./LobbyChat"; -import "./TankGame.scss"; - -const OPEN_EVENT = 'luncher:open-tanks'; - -/** Otevře okno tanků. */ -export const openTankGame = () => window.dispatchEvent(new Event(OPEN_EVENT)); - -/** Jak často se posílá poloha myši (ms). */ -const AIM_SEND_MS = 30; -const MAX_TEAM_SIZE = 3; - -const MEDALS = ['🥇', '🥈', '🥉']; -const MODES: { value: TankMode, label: string, description: string }[] = [ - { value: 'lastman', label: 'Poslední přeživší', description: 'Každý má 3 životy, vyhrává ten, komu nějaký zbyde.' }, - { value: 'deathmatch', label: 'Na čas', description: '3 minuty, po zničení se tank vrací do hry. Vyhrává nejvíc bodů.' }, -]; -const BOT_LEVELS: { value: BotLevel, label: string }[] = [ - { value: 'easy', label: 'Lehký' }, - { value: 'medium', label: 'Střední' }, - { value: 'hard', label: 'Těžký' }, -]; - -/** Směrové: klávesa = směr jízdy na mapě; tankové: nahoru/dolů jízda vpřed/vzad, doleva/doprava otáčení. */ -type Controls = 'directional' | 'tank'; -const CONTROLS_KEY = 'tanks_controls'; -const CONTROLS: { value: Controls, label: string, hint: string }[] = [ - { value: 'directional', label: 'Směrové', hint: 'šipky / WSAD jízda daným směrem' }, - { value: 'tank', label: 'Tankové', hint: '↑↓ / W S jízda, ←→ / A D otáčení' }, -]; - -function getInitialControls(): Controls { - try { - return localStorage.getItem(CONTROLS_KEY) === 'tank' ? 'tank' : 'directional'; - } catch { - // localStorage nedostupný - } - return 'directional'; -} - -const UP_KEYS = new Set(['ArrowUp', 'KeyW']); -const DOWN_KEYS = new Set(['ArrowDown', 'KeyS']); -const LEFT_KEYS = new Set(['ArrowLeft', 'KeyA']); -const RIGHT_KEYS = new Set(['ArrowRight', 'KeyD']); -const FIRE_KEYS = new Set(['Space']); -const MINE_KEYS = new Set(['KeyE']); -const GAME_KEYS = new Set([...UP_KEYS, ...DOWN_KEYS, ...LEFT_KEYS, ...RIGHT_KEYS, ...FIRE_KEYS, ...MINE_KEYS]); - -const formatTime = (ms: number) => { - const s = Math.ceil(ms / 1000); - return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; -}; - -/** Aktivní efekty a munice hráče jako ikony (pro výpis výbavy a tabulku). */ -function equipment(p: TankPlayer) { - const items: string[] = []; - const effect = (on: boolean, type: PickupType) => { if (on) items.push(PICKUPS[type].icon); }; - effect(p.shield, 'shield'); - effect(p.triple, 'triple'); - effect(p.turbo, 'turbo'); - effect(p.ghost, 'ghost'); - effect(p.homing, 'homing'); - if (p.rockets) items.push(`${PICKUPS.rocket.icon}${p.rockets}`); - if (p.mines) items.push(`${PICKUPS.mine.icon}${p.mines}`); - return items; -} - -/** Tanky pro více hráčů — lobby, hrací plocha a výsledky; simulaci drží server. */ -export default function TankGame() { - const auth = useAuth(); - const login = auth?.login; - const [state, setState] = useState(null); - const [open, setOpen] = useState(false); - const [error, setError] = useState(null); - const [botLevel, setBotLevel] = useState('medium'); - const [controls, setControls] = useState(getInitialControls); - const canvasRef = useRef(null); - const prevPhase = useRef('idle'); - const flashes = useRef([]); - const prevShots = useRef(new Map()); - const icon = useGameIcon('tanks'); - - const send = useCallback((event: string, extra?: object) => { - setError(null); - socket.emit(event, { token: getToken(), ...extra }); - }, []); - - useEffect(() => { - const onState = (s: TankState) => setState(s); - const onError = (msg: string) => setError(msg); - const onOpen = () => setOpen(true); - const sync = () => socket.emit('tanks:sync'); - socket.on('tanks:state', onState); - socket.on('tanks:error', onError); - socket.on('connect', sync); - window.addEventListener(OPEN_EVENT, onOpen); - sync(); - return () => { - socket.off('tanks:state', onState); - socket.off('tanks: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('tanks:watch'); - watch(); - socket.on('connect', watch); - return () => { - socket.off('connect', watch); - socket.emit('tanks:unwatch'); - }; - }, [open]); - - const isPlayer = !!login && !!state?.players.some(p => p.login === login); - const isHost = !!login && login === state?.host; - const phase = state?.phase ?? 'idle'; - const inGame = phase === 'countdown' || phase === 'playing'; - const me = state?.players.find(p => p.login === login); - - // 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í: držené klávesy a tlačítko myši → stav ovládání, posílá se jen při změně - useEffect(() => { - if (!open || !inGame || !isPlayer) return; - const pressed = new Set(); - let mouseFire = false; - let sent = ''; - const any = (keys: Set) => [...pressed].some(k => keys.has(k)); - const update = () => { - const x = (any(RIGHT_KEYS) ? 1 : 0) - (any(LEFT_KEYS) ? 1 : 0); - const y = (any(DOWN_KEYS) ? 1 : 0) - (any(UP_KEYS) ? 1 : 0); - const fire = any(FIRE_KEYS) || mouseFire; - const input = controls === 'tank' ? { drive: -y, turn: x, fire } : { dx: x, dy: y, fire }; - const key = JSON.stringify(input); - if (key === sent) return; - sent = key; - send('tanks:input', input); - }; - const onDown = (e: KeyboardEvent) => { - if (!GAME_KEYS.has(e.code)) return; - // Mezerník by jinak "klikl" na tlačítko s fokusem - e.preventDefault(); - if (pressed.has(e.code)) return; - pressed.add(e.code); - if (MINE_KEYS.has(e.code)) send('tanks:mine'); - update(); - }; - const onUp = (e: KeyboardEvent) => { - if (GAME_KEYS.has(e.code)) e.preventDefault(); - if (!pressed.delete(e.code)) return; - update(); - }; - // Levé tlačítko nad hrací plochou střílí, dokud se nepustí (puštění kdekoli); pravé položí minu - const onMouseDown = (e: MouseEvent) => { - if (e.target !== canvasRef.current) return; - if (e.button === 2) { - e.preventDefault(); - send('tanks:mine'); - return; - } - if (e.button !== 0) return; - e.preventDefault(); - mouseFire = true; - update(); - }; - const onMouseUp = (e: MouseEvent) => { - if (e.button !== 0 || !mouseFire) return; - mouseFire = false; - update(); - }; - const onContextMenu = (e: MouseEvent) => { - if (e.target === canvasRef.current) e.preventDefault(); - }; - const onBlur = () => { - pressed.clear(); - mouseFire = false; - update(); - }; - (document.activeElement as HTMLElement | null)?.blur?.(); - window.addEventListener('keydown', onDown); - window.addEventListener('keyup', onUp); - window.addEventListener('mousedown', onMouseDown); - window.addEventListener('mouseup', onMouseUp); - window.addEventListener('contextmenu', onContextMenu); - window.addEventListener('blur', onBlur); - window.addEventListener('pagehide', onBlur); - return () => { - // Server si pamatuje poslední stav — bez puštění by tank jel dál (zavřené okno, pád stránky) - onBlur(); - window.removeEventListener('pagehide', onBlur); - window.removeEventListener('keydown', onDown); - window.removeEventListener('keyup', onUp); - window.removeEventListener('mousedown', onMouseDown); - window.removeEventListener('mouseup', onMouseUp); - window.removeEventListener('contextmenu', onContextMenu); - window.removeEventListener('blur', onBlur); - }; - }, [open, inGame, isPlayer, send, controls]); - - const changeControls = (value: Controls) => { - setControls(value); - try { - localStorage.setItem(CONTROLS_KEY, value); - } catch { - // localStorage nedostupný - } - }; - - // Míření myší: kurzor nad hrací plochou natáčí věž, mimo ni se věž vrátí do směru jízdy - useEffect(() => { - if (!open || !inGame || !isPlayer) return; - let latest: [number, number] | null = null; - let sent = ''; - const onMove = (e: PointerEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom; - latest = inside - ? [Math.round((e.clientX - rect.left) / rect.width * canvas.width), Math.round((e.clientY - rect.top) / rect.height * canvas.height)] - : null; - }; - const id = window.setInterval(() => { - const key = JSON.stringify(latest); - if (key === sent) return; - sent = key; - send('tanks:aim', latest ? { x: latest[0], y: latest[1] } : { x: null }); - }, AIM_SEND_MS); - window.addEventListener('pointermove', onMove); - return () => { - window.clearInterval(id); - window.removeEventListener('pointermove', onMove); - }; - }, [open, inGame, isPlayer, send]); - - // Záblesky výstřelů: podle změny počítadla výstřelů u hráče - useEffect(() => { - if (!state) return; - const now = performance.now(); - for (const p of state.players) { - const before = prevShots.current.get(p.login); - if (before !== undefined && p.shots > before && state.phase === 'playing') { - flashes.current.push({ login: p.login, start: now }); - } - prevShots.current.set(p.login, p.shots); - } - flashes.current = flashes.current.filter(f => now - f.start < FLASH_MS); - }, [state]); - - useEffect(() => { - if (!state || !(inGame || phase === 'finished')) return; - const draw = () => canvasRef.current && drawTanks(canvasRef.current, state, flashes.current, login); - draw(); - // Při odpočtu chodí stav jen jednou za sekundu — značka vlastního tanku se animuje překreslováním - if (phase !== 'countdown') return; - let frame = requestAnimationFrame(function loop() { - draw(); - frame = requestAnimationFrame(loop); - }); - return () => cancelAnimationFrame(frame); - }, [state, inGame, phase, login]); - - if (!login) return null; - - const mode = MODES.find(m => m.value === state?.mode) ?? MODES[0]; - const mapOf = (id?: string) => state?.maps.find(m => m.id === id); - const mapLabel = (m?: { name: string, biome: keyof typeof BIOMES }) => m ? `${BIOMES[m.biome].icon} ${m.name}` : '🎲 Náhodná'; - const mapName = mapLabel(state?.mapId === 'random' ? undefined : mapOf(state?.mapId)); - const showInvite = !open && phase === 'lobby' && !isPlayer && (state?.players.length ?? 0) < (state?.maxPlayers ?? 0); - const showReturn = !open && isPlayer && inGame; - const ranked = [...(state?.players ?? [])].sort((a, b) => phase === 'finished' - ? (a.place ?? 99) - (b.place ?? 99) - : state?.mode === 'lastman' - ? (b.lives ?? 0) - (a.lives ?? 0) || b.kills - a.kills - : b.score - a.score || a.deaths - b.deaths); - const teamDot = (team: 0 | 1) => ; - - const winnerText = () => { - if (!state?.teams) return `🏆 ${ranked[0]?.login}`; - if (state.winnerTeam === undefined || state.winnerTeam < 0) return '🤝 Remíza'; - return `🏆 Vyhrává tým ${TEAM_NAMES[state.winnerTeam]}`; - }; - - const renderBoard = () => state && ( -
- {state.teams && state.teamScores && ( -
- {TEAM_NAMES[0]} - {state.teamScores[0]} : {state.teamScores[1]} - {TEAM_NAMES[1]} -
- )} -
- - {phase === 'countdown' &&
{state.countdown}
} - {phase === 'playing' && me && !me.alive && me.respawnIn !== undefined && ( -
Zničen — návrat za {Math.ceil(me.respawnIn / 1000)} s
- )} - {phase === 'playing' && me && !me.alive && me.respawnIn === undefined && ( -
Vypadli jste — sledujete hru
- )} - {phase === 'finished' &&
{winnerText()}
} -
- {inGame && me?.alive && ( -
- Výbava: {equipment(me).length ? equipment(me).join(' ') : nic — sbírejte bonusy} -
- )} -
- {mode.label}{state.teams && ' v týmech'}{state.bounce && ' · odrazy'}{state.hardcore && ' · 🌑 hardcore'} · {mapName}{inGame && ` · zbývá ${formatTime(state.timeLeftMs)}`} - {inGame && (isPlayer - ? ` · ${CONTROLS.find(c => c.value === controls)!.hint}, myš míření, klik / mezerník střelba, E / pravé tlačítko mina` - : ' · sledujete hru')} -
- {inGame && isPlayer && renderControls()} - {inGame && ( -
    - {(Object.entries(PICKUPS) as [PickupType, typeof PICKUPS[PickupType]][]) - .filter(([type]) => type !== 'heart' || state.mode === 'lastman') - .map(([type, p]) =>
  • {p.icon} {p.label}
  • )} -
- )} - {renderScores()} -
- ); - - /** Volba ovládání — osobní nastavení hráče, pamatuje si ho prohlížeč. */ - const renderControls = () => ( -
- Ovládání - {CONTROLS.map(c => ( - changeControls(c.value)} /> - ))} -
- ); - - const renderScores = () => ( - - - - - - {state?.mode === 'lastman' ? : } - - - - - - {ranked.map((p, i) => ( - - - - - - - - ))} - -
#HráčŽivotyBodyZničil(a)Zničen(a)
{phase === 'finished' ? (i < 3 ? MEDALS[i] : i + 1) : ''} - - {p.login}{p.login === login && ' (vy)'} - {state?.teams && p.team !== undefined && · {TEAM_NAMES[p.team]}} - {p.alive && inGame && equipment(p).length > 0 && {equipment(p).join(' ')}} - {state?.mode === 'lastman' ? '❤️'.repeat(p.lives ?? 0) || '💀' : p.score}{p.kills}{p.deaths}
- ); - - const renderPlayer = (p: TankPlayer) => ( -
  • - - {p.login}{p.login === state?.host && ' 👑'} - {state?.teams && (isHost || p.login === login) && ( - - )} -
  • - ); - - const renderLobby = () => state && ( - <> -

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

    - {state.teams ? ( -
    - {([0, 1] as const).map(team => { - const members = state.players.filter(p => p.team === team); - return ( -
    - {teamDot(team)}{TEAM_NAMES[team]} ({members.length}/{MAX_TEAM_SIZE}) -
      {members.map(renderPlayer)}
    -
    - ); - })} -
    - ) : ( -
      {state.players.map(renderPlayer)}
    - )} - {isHost ? ( - <> -
    - - Režim - send('tanks:configure', { mode: e.target.value })}> - {MODES.map(m => )} - - - - Mapa - send('tanks:configure', { mapId: e.target.value })}> - - {state.maps.map(m => )} - - -
    -
    - Hráči -
    - send('tanks:configure', { teams: false })} /> - send('tanks:configure', { teams: true })} /> -
    - Střely - send('tanks:configure', { bounce: e.target.checked })} /> - Hardcore - send('tanks:configure', { hardcore: e.target.checked })} /> - {(state.players.length < state.maxPlayers || state.players.some(p => p.bot)) && ( - <> - Boti -
    - {state.players.length < state.maxPlayers && ( - <> - setBotLevel(e.target.value as BotLevel)}> - {BOT_LEVELS.map(l => )} - - - - )} - {state.players.some(p => p.bot) && ( - - )} -
    - - )} -
    - - ) : ( -

    - Režim: {mode.label}{state.teams && ' v týmech'}{state.bounce && ', odrazy'}{state.hardcore && ', 🌑 hardcore'} - {' · '}mapa: {mapName} -

    - )} -

    - {mode.description}{state.teams && ' Spoluhráči se navzájem nezraní, týmy začínají každý na své půlce mapy.'} - {state.bounce && ' Střely se odráží od zdí — pozor na vlastní!'} - {state.hardcore && ' V aréně je tma: vidíte jen okolí tanku a kužel reflektoru ve směru věže, světlo zastaví zdi a bedny.' - + (state.teams ? ' Co osvítí spoluhráč, vidíte také.' : '') + ' Výbuchy a výstřely prosvítí i tmou.'} -

    - {isPlayer && renderControls()} - {isPlayer && } - - ); - - const renderBody = () => { - switch (phase) { - case 'idle': - return ( - <> -

    - Tanková bitva pro více hráčů. Jezdí se šipkami nebo WSAD (směrově, nebo po tankovém — - vpřed/vzad a otáčení), věž míří za myší a střílí se kliknutím nebo mezerníkem. Bedny se dají rozstřílet, přes vodu se nedá projet (ale dá se - přes ni střílet) a bláto, sníh, písek nebo porost zpomalují. Hraje se na poslední přeživší - nebo na čas, sólo i v týmech, volitelně s odrážením střel od zdí nebo potmě (hardcore); - když není s kým hrát, přidejte si do lobby boty. -

    -

    Bonusy:

    -
      - {Object.values(PICKUPS).map(p =>
    • {p.icon} {p.label}
    • )} -
    - {renderControls()} - - ); - case 'lobby': - return renderLobby(); - default: - return renderBoard(); - } - }; - - const startBlocked = () => { - if (!state) return true; - if (state.players.length < state.minPlayers) return true; - return state.teams && state.minPlayers > 1 && [0, 1].some(t => !state.players.some(p => p.team === t)); - }; - - const renderFooter = () => { - const buttons = []; - if (phase === 'idle' || phase === 'finished') { - buttons.push(); - } - if (phase === 'lobby') { - if (!isPlayer) { - buttons.push(); - } else { - buttons.push(); - if (isHost) { - buttons.push(); - } - } - } - buttons.push(); - return buttons; - }; - - return ( - <> - {showInvite && ( - - )} - {showReturn && ( - - )} - setOpen(false)} size="xl" className="tanks-game-modal"> - -

    {icon} Tanky

    -
    - - {renderBody()} - {error &&
    {error}
    } -
    - {renderFooter()} -
    - - ); -} diff --git a/client/src/components/TetrisGame.scss b/client/src/components/TetrisGame.scss deleted file mode 100644 index b96e702..0000000 --- a/client/src/components/TetrisGame.scss +++ /dev/null @@ -1,193 +0,0 @@ -// 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-col { - display: flex; - flex-direction: column; - gap: 12px; - } - - .tetris-own { - display: flex; - gap: 16px; - // Ovládání pod deskou může být na tabletu širší než deska s postranním panelem - align-self: center; - } - - .tetris-board-wrap { - position: relative; - } - - .tetris-board { - display: block; - border-radius: 6px; - // Gesta prstem nesmí posouvat ani přibližovat stránku - touch-action: none; - user-select: none; - -webkit-user-select: none; - -webkit-touch-callout: none; - } - - .tetris-touch-pad { - display: flex; - justify-content: space-between; - gap: 12px; - } - - .tetris-touch-group { - display: flex; - gap: calc(var(--touch-btn-size) * 0.12); - } - - .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; - } - - // Na dotykovém displeji se deska zmenší, aby se i s ovládáním vešla na výšku telefonu - @media (any-pointer: coarse) { - .tetris-own { - gap: 12px; - } - - .tetris-board { - width: auto; - // Výška okna bez hlavičky, patičky, odstupů a řady tlačítek - height: clamp(280px, calc(100dvh - 278px - var(--touch-btn-size)), #{20 * 26px}); - } - - .tetris-side { - min-width: 90px; - } - } - - @media (max-width: 575.98px) { - .modal-body { - padding: 12px; - } - - .tetris-layout { - gap: 12px; - } - } -} - -// Bootstrap dává oknu mezi 576 a 992 px (tablet na výšku) jen 500 px a větší dotyková tlačítka by se nevešla -@media (min-width: 576px) and (max-width: 991.98px) { - .tetris-game-modal .modal-dialog { - --bs-modal-width: 640px; - } -} - -.tetris-game-modal .tetris-settings { - display: flex; - flex-wrap: wrap; - gap: 16px; - max-width: 640px; - - > div { - flex: 1 1 180px; - } - - .form-label { - margin-bottom: 2px; - font-weight: 600; - } -} diff --git a/client/src/components/TetrisGame.tsx b/client/src/components/TetrisGame.tsx deleted file mode 100644 index 33473e6..0000000 --- a/client/src/components/TetrisGame.tsx +++ /dev/null @@ -1,663 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { Button, Form, Modal, Table } from "react-bootstrap"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faAnglesDown, faCaretDown, faCaretLeft, faCaretRight, faRotateLeft, faRotateRight } from "@fortawesome/free-solid-svg-icons"; -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 { useTouchDevice } from "../hooks/useTouchDevice"; -import TouchButton from "./TouchButton"; -import LobbyChat from "./LobbyChat"; -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 TetrisTargeting = 'random' | 'even' | 'payback'; -type TetrisWinMode = 'survival' | 'score'; -type TetrisSettings = { targeting: TetrisTargeting, winMode: TetrisWinMode, timeLimit: number }; -type TetrisState = { - phase: TetrisPhase, - host?: string, - players: TetrisPlayer[], - seed?: number, - countdown?: number, - minPlayers: number, - settings: TetrisSettings, - remainingMs?: number, -}; -type GarbageEvent = { target: string, from: string, lines: number }; - -const OPEN_EVENT = 'luncher:open-tetris'; - -const TARGETING_LABELS: Record = { - random: { label: 'Náhodně (Random)', help: 'útok jde náhodnému soupeři' }, - even: { label: 'Rovnoměrně (Even)', help: 'útok jde soupeři, který zatím dostal nejméně odpadu' }, - payback: { label: 'Oplácení (Payback)', help: 'útok jde tomu, kdo vás naposledy napadl, jinak rovnoměrně' }, -}; -const WIN_MODE_LABELS: Record = { - survival: 'Poslední ve hře (Last man standing)', - score: 'Nejvyšší skóre', -}; -/** Musí odpovídat TIME_LIMITS na serveru (s). */ -const TIME_LIMITS = [120, 180, 300]; - -const formatTime = (ms: number) => { - const total = Math.ceil(ms / 1000); - return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`; -}; - -/** 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; - -type Action = 'left' | 'right' | 'down' | 'rotate' | 'rotateBack' | 'drop'; - -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', -}; -/** Akce, které se opakují při podržení klávesy. */ -const REPEATABLE = new Set(['left', 'right', 'down']); - -// Dotyková gesta na desce: ťuknutí otočí, tažení posouvá o políčka, švihnutí dolů shodí -/** Posun prstu (px), do kterého se dotyk ještě bere jako ťuknutí. */ -const TAP_SLOP_PX = 10; -/** Delší dotyk bez pohybu už není ťuknutí. */ -const TAP_MAX_MS = 300; -/** Nejmenší krok tažení o jedno políčko (px) — na malé desce by jinak byl příliš citlivý. */ -const MIN_STEP_PX = 14; -/** Švihnutí = pohyb dolů aspoň touto rychlostí (px/ms) za posledních FLICK_WINDOW_MS. */ -const FLICK_SPEED = 0.7; -const FLICK_MIN_PX = 30; -const FLICK_WINDOW_MS = 100; - -/** Rozpracované dotykové gesto na desce. */ -type Gesture = { - pointerId: number, - /** Kostka, se kterou gesto začalo — když mezitím dopadne, zbytek gesta se ignoruje. */ - pieceNo: number, - startX: number, - startY: number, - startTime: number, - /** Bod, od kterého se měří další krok o políčko. */ - anchorX: number, - anchorY: number, - moved: boolean, - /** Poslední polohy prstu pro rychlost švihnutí. */ - trail: { y: number, t: number }[], -}; - -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'); - /** Konec hry na skóre v čase prohlížeče (ze zbývajícího času, ať nevadí rozdíl hodin se serverem). */ - const deadline = useRef(undefined); - const [now, setNow] = useState(() => Date.now()); - const icon = useGameIcon('tetris'); - const touch = useTouchDevice(); - const gesture = useRef(null); - - 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) => { - deadline.current = s.remainingMs !== undefined ? Date.now() + s.remainingMs : undefined; - 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'; - const controllable = open && (practice || (phase === 'playing' && isPlayer)); - - // 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, attack) => { - if (attack > 0) send('tetris:cleared', { count: cleared, sent: attack }); - 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]); - - // Odpočet zbývajícího času hry na skóre - useEffect(() => { - if (phase !== 'playing' || state?.settings.winMode !== 'score') return; - const id = window.setInterval(() => setNow(Date.now()), 500); - return () => window.clearInterval(id); - }, [phase, state?.settings.winMode]); - - // 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]); - - /** Herní akce z klávesnice i dotykového ovládání. */ - const control = useCallback((action: Action) => { - const s = sessionRef.current; - if (!controllable || !s?.alive) return; - switch (action) { - case 'left': s.move(-1); break; - case 'right': s.move(1); break; - case 'down': s.softDrop(); break; - case 'rotate': s.rotate(1); break; - case 'rotateBack': s.rotate(-1); break; - case 'drop': s.hardDrop(); break; - } - }, [controllable]); - - // Deska mohla zmizet s prstem na ní (konec hry, zavření okna) — rozpracované gesto se zahodí - useEffect(() => { - if (!controllable) gesture.current = null; - }, [controllable]); - - // Ovládání klávesnicí - useEffect(() => { - if (!controllable) return; - const onKey = (e: KeyboardEvent) => { - const action = KEYS[e.code]; - if (!action || !sessionRef.current) return; - e.preventDefault(); - if (e.repeat && !REPEATABLE.has(action)) return; - control(action); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [controllable, control]); - - // 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); - }, []); - - // Dotyková gesta na desce (myš se ignoruje, aby kliknutí do desky na počítači neotáčelo kostkou) - const onBoardPointerDown = (e: React.PointerEvent) => { - const s = sessionRef.current; - if (e.pointerType === 'mouse' || !controllable || !s?.alive) return; - e.preventDefault(); - e.currentTarget.setPointerCapture(e.pointerId); - gesture.current = { - pointerId: e.pointerId, pieceNo: s.pieceNo, - startX: e.clientX, startY: e.clientY, startTime: e.timeStamp, - anchorX: e.clientX, anchorY: e.clientY, moved: false, - trail: [{ y: e.clientY, t: e.timeStamp }], - }; - }; - - const onBoardPointerMove = (e: React.PointerEvent) => { - const g = gesture.current; - const s = sessionRef.current; - if (!g || g.pointerId !== e.pointerId || !s || s.pieceNo !== g.pieceNo) return; - if (Math.abs(e.clientX - g.startX) > TAP_SLOP_PX || Math.abs(e.clientY - g.startY) > TAP_SLOP_PX) g.moved = true; - const step = Math.max(MIN_STEP_PX, e.currentTarget.getBoundingClientRect().width / WIDTH); - for (; e.clientX - g.anchorX >= step; g.anchorX += step) control('right'); - for (; g.anchorX - e.clientX >= step; g.anchorX -= step) control('left'); - // Tažení dolů kostku nezamkne (to udělá gravitace nebo švihnutí), aby tah nepokračoval na další kostce - for (; e.clientY - g.anchorY >= step; g.anchorY += step) { - if (!s.grounded) control('down'); - } - g.anchorY = Math.min(g.anchorY, e.clientY); - g.trail.push({ y: e.clientY, t: e.timeStamp }); - while (e.timeStamp - g.trail[0].t > FLICK_WINDOW_MS) g.trail.shift(); - }; - - const onBoardPointerUp = (e: React.PointerEvent) => { - const g = gesture.current; - if (!g || g.pointerId !== e.pointerId) return; - gesture.current = null; - if (sessionRef.current?.pieceNo !== g.pieceNo) return; - if (!g.moved) { - if (e.timeStamp - g.startTime <= TAP_MAX_MS) control('rotate'); - return; - } - const from = g.trail.find(p => e.timeStamp - p.t <= FLICK_WINDOW_MS); - if (!from || e.timeStamp <= from.t) return; - const dy = e.clientY - from.y; - if (dy >= FLICK_MIN_PX && dy / (e.timeStamp - from.t) >= FLICK_SPEED) control('drop'); - }; - - const onBoardPointerCancel = () => { - gesture.current = null; - }; - - if (!login) return null; - - const session = sessionRef.current; - // Připojit se jde i během odpočtu před startem - const joinable = phase === 'lobby' || phase === 'countdown'; - const showInvite = !open && joinable && !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 settings = state?.settings; - const isHost = login === state?.host; - const remaining = !practice && phase === 'playing' && deadline.current !== undefined - ? Math.max(0, deadline.current - now) : undefined; - const configure = (change: Partial) => send('tetris:configure', change); - - const renderSettings = () => settings && ( -
    - - Míření odpadu - {isHost ? ( - configure({ targeting: e.target.value as TetrisTargeting })}> - {Object.entries(TARGETING_LABELS).map(([value, { label }]) => )} - - ) :
    {TARGETING_LABELS[settings.targeting].label}
    } - {TARGETING_LABELS[settings.targeting].help} -
    - - Vítěz - {isHost ? ( - configure({ winMode: e.target.value as TetrisWinMode })}> - {Object.entries(WIN_MODE_LABELS).map(([value, label]) => )} - - ) :
    {WIN_MODE_LABELS[settings.winMode]}
    } -
    - {settings.winMode === 'score' && ( - - Délka hry - {isHost ? ( - configure({ timeLimit: Number(e.target.value) })}> - {TIME_LIMITS.map(t => )} - - ) :
    {settings.timeLimit / 60} min
    } -
    - )} -
    - ); - - const renderPlaying = () => ( -
    - {(isPlayer || practice) && ( -
    -
    -
    - - {!practice && phase === 'countdown' &&
    {state?.countdown}
    } - {session && !session.alive &&
    Konec 💀
    } - {flash &&
    {flash}
    } -
    -
    - {remaining !== undefined && ( - <> -
    Zbývá
    -
    {formatTime(remaining)}
    - - )} -
    Další
    - -
    Skóre
    -
    {session?.score ?? 0}
    -
    Řádky
    -
    {session?.lines ?? 0}
    -
    Úroveň
    -
    {session?.level ?? 0}
    -
    - {touch - ? <>ťuknutí otočí
    tažení posouvá
    švihnutí dolů shodí - : <>←→ / AD posun
    ↑ / W otočit
    ↓ / S dolů
    mezerník shodit} -
    -
    -
    - {touch && ( -
    -
    - control('left')}> - control('down')}> - control('right')}> -
    -
    - control('rotateBack')}> - control('rotate')}> - control('drop')}> -
    -
    - )} -
    - )} - {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 soupeři 1, 2 nebo 4 odpadní řádky. Smazáním řádků lze zároveň odrazit odpad, - který na vás čeká. Zakladatel lobby volí, komu útoky půjdou a zda vyhrává poslední - hráč ve hře, nebo nejvyšší skóre po časovém limitu. - 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 && ' 👑'}
    • )} -
    - {state && state.players.length < state.minPlayers && -

    Čeká se na další hráče… Pro hru o samotě použijte Trénink.

    } - {renderSettings()} - {isPlayer && } - - ); - case 'countdown': - return ( - <> - {!isPlayer && ( -

    - Hra začne za {state?.countdown} s — ještě se můžete připojit. -

    - )} - {renderPlaying()} - - ); - case 'playing': - return renderPlaying(); - case 'finished': - return ( - <> - {settings && ( -

    - Hodnocení: {WIN_MODE_LABELS[settings.winMode]} - {settings.winMode === 'score' && ` (${settings.timeLimit / 60} min)`} - {' · '}míření: {TARGETING_LABELS[settings.targeting].label} -

    - )} - - - - - - {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 (joinable && !isPlayer) { - buttons.push(); - } - if (phase === 'lobby' && isPlayer) { - buttons.push(); - if (login === state?.host) { - buttons.push(); - } - } - buttons.push(); - return buttons; - }; - - return ( - <> - {showInvite && ( - - )} - {showReturn && ( - - )} - setOpen(false)} size="xl" fullscreen="sm-down" className="tetris-game-modal"> - -

    {icon} Tetris

    -
    - - {renderBody()} - {error &&
    {error}
    } -
    - {renderFooter()} -
    - - ); -} diff --git a/client/src/components/TouchButton.scss b/client/src/components/TouchButton.scss deleted file mode 100644 index 400c69a..0000000 --- a/client/src/components/TouchButton.scss +++ /dev/null @@ -1,37 +0,0 @@ -// Velikost dotykových tlačítek — podle ní se řídí i rozložení ovládání v jednotlivých hrách -:root { - --touch-btn-size: 52px; -} - -// Tablet (na rozdíl od telefonu na šířku je vysoký i široký) — na velkém displeji jsou malá tlačítka špatně trefitelná -@media (any-pointer: coarse) and (min-width: 700px) and (min-height: 600px) { - :root { - --touch-btn-size: 76px; - } -} - -// Dotyková tlačítka miniher — bez zoomu dvojitým ťuknutím, výběru textu a kontextové nabídky při podržení -.touch-btn { - display: flex; - align-items: center; - justify-content: center; - min-width: var(--touch-btn-size); - height: var(--touch-btn-size); - padding: 0 10px; - border: 1px solid var(--luncher-border, rgba(127, 127, 127, 0.4)); - border-radius: calc(var(--touch-btn-size) * 0.23); - background: var(--bs-tertiary-bg); - color: var(--luncher-text, inherit); - font-size: calc(var(--touch-btn-size) * 0.45); - line-height: 1; - touch-action: none; - user-select: none; - -webkit-user-select: none; - -webkit-touch-callout: none; - -webkit-tap-highlight-color: transparent; - - &:active { - background: var(--luncher-primary, #198754); - color: #fff; - } -} diff --git a/client/src/components/TouchButton.tsx b/client/src/components/TouchButton.tsx deleted file mode 100644 index cca5419..0000000 --- a/client/src/components/TouchButton.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { ReactNode, useEffect, useRef } from "react"; -import "./TouchButton.scss"; - -/** Prodleva před opakováním a interval opakování při podržení tlačítka (ms), podobně jako u klávesnice. */ -const REPEAT_DELAY_MS = 170; -const REPEAT_MS = 50; - -type Props = { - label: string, - onPress: () => void, - /** Při podržení se akce opakuje (posun, pád). */ - repeat?: boolean, - className?: string, - children: ReactNode, -}; - -/** - * Tlačítko dotykového ovládání miniher. Reaguje hned na dotyk (ne až na click) a je mimo pořadí - * fokusu, aby mezerník nebo šipky z klávesnice nespouštěly tlačítko místo hry. - */ -export default function TouchButton({ label, onPress, repeat, className, children }: Props) { - const timer = useRef(undefined); - const onPressRef = useRef(onPress); - onPressRef.current = onPress; - - const stop = () => { - window.clearTimeout(timer.current); - timer.current = undefined; - }; - useEffect(() => stop, []); - - const onPointerDown = (e: React.PointerEvent) => { - e.preventDefault(); - e.currentTarget.setPointerCapture(e.pointerId); - stop(); - onPressRef.current(); - if (!repeat) return; - const tick = () => { - onPressRef.current(); - timer.current = window.setTimeout(tick, REPEAT_MS); - }; - timer.current = window.setTimeout(tick, REPEAT_DELAY_MS); - }; - - return ( - - ); -} diff --git a/client/src/games/tankRender.ts b/client/src/games/tankRender.ts deleted file mode 100644 index 601b905..0000000 --- a/client/src/games/tankRender.ts +++ /dev/null @@ -1,948 +0,0 @@ -/** - * Vykreslování tanků do canvasu: mapa podle prostředí (biomu), tanky, střely, miny, bonusy, - * hořící vraky, výbuchy a záblesky výstřelů. Stav chodí ze serveru (server/src/tankGame.ts). - */ - -// Typy musí odpovídat server/src/tankGame.ts (stav chodí přes websocket, ne přes OpenAPI) -export type TankPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished'; -export type TankMode = 'deathmatch' | 'lastman'; -export type BotLevel = 'easy' | 'medium' | 'hard'; -export type PickupType = 'shield' | 'triple' | 'rocket' | 'turbo' | 'ghost' | 'homing' | 'mine' | 'heart'; -export type Biome = 'city' | 'forest' | 'desert' | 'winter' | 'swamp'; -export type Team = 0 | 1; -export type TankPlayer = { - login: string, - color: number, - team?: Team, - x: number, - y: number, - angle: number, - turret: number, - alive: boolean, - kills: number, - deaths: number, - score: number, - lives?: number, - shield: boolean, - triple: boolean, - turbo: boolean, - ghost: boolean, - homing: boolean, - rockets: number, - mines: number, - protected: boolean, - shots: number, - respawnIn?: number, - bot?: BotLevel, - place?: number, -}; -export type TankState = { - phase: TankPhase, - host?: string, - mode: TankMode, - teams: boolean, - hardcore: boolean, - bounce: boolean, - mapId: string, - biome: Biome, - maps: { id: string, name: string, biome: Biome }[], - tiles: string[], - players: TankPlayer[], - bullets: { x: number, y: number, color: number, team?: Team, angle: number, rocket: boolean }[], - pickups: { x: number, y: number, type: PickupType }[], - mines: { x: number, y: number, owner: string, team?: Team, color: number, armed: boolean }[], - wrecks: { x: number, y: number, angle: number, turret: number, color: number, team?: Team, age: number }[], - explosions: { x: number, y: number, r: number, age: number }[], - teamScores?: [number, number], - winnerTeam?: number, - countdown?: number, - timeLeftMs: number, - width: number, - height: number, - tile: number, - minPlayers: number, - maxPlayers: number, -}; -/** Záblesk výstřelu — klient ho zakládá podle změny počítadla výstřelů. */ -export type MuzzleFlash = { login: string, start: number }; - -// Konstanty musí odpovídat serveru -const TANK_RADIUS = 14; -const BULLET_RADIUS = 4; -const EXPLOSION_MS = 600; -const WRECK_MS = 8000; -const REVEAL_DISTANCE = 70; -const LIGHT_RADIUS = 130; -const SPOTLIGHT_RANGE = 300; -const SPOTLIGHT_HALF_ANGLE = 0.45; -export const FLASH_MS = 110; - -export const COLORS = ['#4ade80', '#60a5fa', '#f472b6', '#facc15', '#a78bfa', '#fb923c']; -/** Týmy mají vlastní paletu (teplé × studené barvy). */ -export const TEAM_COLORS: [string[], string[]] = [['#f87171', '#fb923c', '#fbbf24'], ['#60a5fa', '#22d3ee', '#a78bfa']]; -export const TEAM_NAMES = ['Červení', 'Modří']; - -export const PICKUPS: Record = { - shield: { icon: '🛡️', label: 'štít — vydrží jeden zásah' }, - triple: { icon: '🔱', label: 'trojitá střela — tři střely do vějíře' }, - rocket: { icon: '🚀', label: 'rakety — výbuch zasáhne i okolí' }, - turbo: { icon: '💨', label: 'turbo — rychlejší jízda' }, - ghost: { icon: '👻', label: 'neviditelnost — soupeř vás uvidí jen zblízka' }, - homing: { icon: '🧲', label: 'naváděné střely — stáčí se k soupeři' }, - mine: { icon: '💣', label: 'miny — položíte E / pravým tlačítkem' }, - heart: { icon: '❤️', label: 'život navíc (jen na přežití)' }, -}; - -export const BIOMES: Record = { - city: { icon: '🏙️' }, - forest: { icon: '🌲' }, - desert: { icon: '🏜️' }, - winter: { icon: '❄️' }, - swamp: { icon: '🐸' }, -}; - -export const tankColor = (o: { color: number, team?: Team }) => - o.team !== undefined ? TEAM_COLORS[o.team][o.color % TEAM_COLORS[o.team].length] : COLORS[o.color % COLORS.length]; - -/** Deterministická „náhoda" podle polohy dlaždice — dekorace se nemění mezi snímky. */ -const hash = (col: number, row: number, k: number) => { - const v = Math.sin(col * 12.9898 + row * 78.233 + k * 37.719) * 43758.5453; - return v - Math.floor(v); -}; - -type BiomeStyle = { - ground: [string, string], - speck: string, - water: string, - mud: string, -}; - -const STYLES: Record = { - city: { ground: ['#1f2937', '#243041'], speck: 'rgba(255,255,255,0.03)', water: '#1e3a5f', mud: '#4b4036' }, - forest: { ground: ['#2d4a26', '#31502a'], speck: 'rgba(0,0,0,0.12)', water: '#2563eb', mud: '#5b4630' }, - desert: { ground: ['#d8b46c', '#d2ad64'], speck: 'rgba(120,80,30,0.18)', water: '#0e7490', mud: '#b8934f' }, - winter: { ground: ['#e2e8f0', '#dbe2ec'], speck: 'rgba(100,116,139,0.15)', water: '#1e3a8a', mud: '#f8fafc' }, - swamp: { ground: ['#38432a', '#3c482d'], speck: 'rgba(0,0,0,0.15)', water: '#1f3d36', mud: '#4a3b26' }, -}; - -// --- Statická vrstva mapy — překresluje se jen při změně dlaždic --- - -let layerCache: { key: string, ground: HTMLCanvasElement } | undefined; - -function mapLayer(state: TankState) { - const key = `${state.biome}|${state.tiles.join('')}`; - if (layerCache?.key === key) return layerCache.ground; - const ground = document.createElement('canvas'); - ground.width = state.width; - ground.height = state.height; - drawGround(ground.getContext('2d')!, state); - layerCache = { key, ground }; - return ground; -} - -function drawGround(ctx: CanvasRenderingContext2D, state: TankState) { - const t = state.tile; - const style = STYLES[state.biome]; - state.tiles.forEach((row, r) => [...row].forEach((_, col) => { - const x = col * t; - const y = r * t; - ctx.fillStyle = style.ground[(col + r) % 2]; - ctx.fillRect(x, y, t, t); - ctx.fillStyle = style.speck; - for (let k = 0; k < 4; k++) { - ctx.fillRect(x + hash(col, r, k) * (t - 4), y + hash(col, r, k + 9) * (t - 4), 3, 3); - } - })); - // Nejdřív plochy (voda, bláto, porost), pak překážky přes ně — stromy a skály přesahují políčko - state.tiles.forEach((row, r) => [...row].forEach((c, col) => { - if (c === '~') drawWater(ctx, state.biome, col, r, t); - else if (c === ':') drawMud(ctx, state.biome, col, r, t); - else if (c === '%') drawGrowth(ctx, state.biome, col, r, t); - })); - state.tiles.forEach((row, r) => [...row].forEach((c, col) => { - if (c === '#') drawWall(ctx, state.biome, col, r, t); - else if (c === '+') drawCrate(ctx, state.biome, col, r, t); - })); -} - -function drawWater(ctx: CanvasRenderingContext2D, biome: Biome, col: number, row: number, t: number) { - const x = col * t; - const y = row * t; - ctx.fillStyle = STYLES[biome].water; - ctx.fillRect(x, y, t, t); - if (biome === 'winter') { - // Díra v ledu: světlý okraj - ctx.strokeStyle = 'rgba(255,255,255,0.7)'; - ctx.lineWidth = 3; - ctx.strokeRect(x + 1.5, y + 1.5, t - 3, t - 3); - } - // Vlnky - ctx.strokeStyle = 'rgba(255,255,255,0.18)'; - ctx.lineWidth = 1.5; - for (let k = 0; k < 2; k++) { - const wx = x + 6 + hash(col, row, k) * (t - 18); - const wy = y + 8 + hash(col, row, k + 4) * (t - 16); - ctx.beginPath(); - ctx.moveTo(wx, wy); - ctx.quadraticCurveTo(wx + 3, wy - 3, wx + 6, wy); - ctx.quadraticCurveTo(wx + 9, wy + 3, wx + 12, wy); - ctx.stroke(); - } - if (biome === 'swamp' && hash(col, row, 7) > 0.5) { - // Lekníny - ctx.fillStyle = '#4d7c0f'; - ctx.beginPath(); - ctx.arc(x + 10 + hash(col, row, 8) * 20, y + 10 + hash(col, row, 9) * 20, 5, 0.4, Math.PI * 2); - ctx.lineTo(x + 10 + hash(col, row, 8) * 20, y + 10 + hash(col, row, 9) * 20); - ctx.fill(); - } -} - -function drawMud(ctx: CanvasRenderingContext2D, biome: Biome, col: number, row: number, t: number) { - const x = col * t; - const y = row * t; - ctx.fillStyle = STYLES[biome].mud; - ctx.fillRect(x, y, t, t); - if (biome === 'winter') { - // Závěje: stínované oblouky - ctx.strokeStyle = 'rgba(148,163,184,0.5)'; - ctx.lineWidth = 2; - for (let k = 0; k < 2; k++) { - ctx.beginPath(); - ctx.arc(x + 8 + hash(col, row, k) * 24, y + 14 + k * 14, 8, Math.PI * 1.1, Math.PI * 1.9); - ctx.stroke(); - } - return; - } - if (biome === 'desert') { - // Pohyblivý písek: víry - ctx.strokeStyle = 'rgba(120,80,30,0.35)'; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.arc(x + t / 2, y + t / 2, 10, 0, Math.PI * 1.5); - ctx.stroke(); - ctx.beginPath(); - ctx.arc(x + t / 2, y + t / 2, 4, Math.PI, Math.PI * 2.5); - ctx.stroke(); - return; - } - // Bláto: tmavší louže - ctx.fillStyle = 'rgba(0,0,0,0.2)'; - for (let k = 0; k < 3; k++) { - ctx.beginPath(); - ctx.ellipse(x + 6 + hash(col, row, k) * 28, y + 6 + hash(col, row, k + 3) * 28, 6, 4, 0, 0, Math.PI * 2); - ctx.fill(); - } -} - -function drawWall(ctx: CanvasRenderingContext2D, biome: Biome, col: number, row: number, t: number) { - const x = col * t; - const y = row * t; - const cx = x + t / 2; - const cy = y + t / 2; - switch (biome) { - case 'city': - ctx.fillStyle = '#4b5563'; - ctx.fillRect(x, y, t, t); - ctx.fillStyle = '#6b7280'; - ctx.fillRect(x, y, t, 4); - ctx.strokeStyle = 'rgba(0,0,0,0.25)'; - ctx.lineWidth = 1; - ctx.strokeRect(x + 0.5, y + 0.5, t - 1, t - 1); - return; - case 'forest': { - // Strom: stín, koruna ve vrstvách a odlesk - ctx.fillStyle = 'rgba(0,0,0,0.3)'; - ctx.beginPath(); - ctx.arc(cx + 3, cy + 4, t * 0.5, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = '#14532d'; - ctx.beginPath(); - ctx.arc(cx, cy, t * 0.52, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = '#166534'; - for (let k = 0; k < 4; k++) { - const a = hash(col, row, k) * Math.PI * 2; - ctx.beginPath(); - ctx.arc(cx + Math.cos(a) * 8, cy + Math.sin(a) * 8, t * 0.26, 0, Math.PI * 2); - ctx.fill(); - } - ctx.fillStyle = 'rgba(134,239,172,0.25)'; - ctx.beginPath(); - ctx.arc(cx - 6, cy - 6, t * 0.18, 0, Math.PI * 2); - ctx.fill(); - return; - } - case 'swamp': { - // Pahýl: kořeny, letokruhy a mech - ctx.fillStyle = '#2b2118'; - for (let k = 0; k < 5; k++) { - const a = k / 5 * Math.PI * 2 + hash(col, row, k); - ctx.beginPath(); - ctx.ellipse(cx + Math.cos(a) * 13, cy + Math.sin(a) * 13, 7, 4, a, 0, Math.PI * 2); - ctx.fill(); - } - ctx.fillStyle = '#5b4632'; - ctx.beginPath(); - ctx.arc(cx, cy, 14, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = '#3b2d20'; - ctx.lineWidth = 1.5; - for (const r of [10, 6, 2.5]) { - ctx.beginPath(); - ctx.arc(cx, cy, r, 0, Math.PI * 2); - ctx.stroke(); - } - ctx.fillStyle = 'rgba(101,163,13,0.6)'; - ctx.beginPath(); - ctx.arc(cx - 8, cy + 7, 5, 0, Math.PI * 2); - ctx.fill(); - return; - } - default: { - // Skála (poušť pískovec, zima žula se sněhem) - const winter = biome === 'winter'; - ctx.fillStyle = 'rgba(0,0,0,0.25)'; - ctx.beginPath(); - ctx.roundRect(x + 3, y + 5, t - 2, t - 3, 10); - ctx.fill(); - ctx.fillStyle = winter ? '#64748b' : '#a87b45'; - ctx.beginPath(); - ctx.roundRect(x + 1, y + 1, t - 2, t - 2, 10); - ctx.fill(); - ctx.fillStyle = winter ? '#475569' : '#7c5a32'; - ctx.beginPath(); - ctx.roundRect(x + 1, y + t * 0.55, t - 2, t * 0.45 - 1, [0, 0, 10, 10]); - ctx.fill(); - if (winter) { - ctx.fillStyle = '#f8fafc'; - ctx.beginPath(); - ctx.roundRect(x + 2, y + 1, t - 4, t * 0.35, [10, 10, 6, 6]); - ctx.fill(); - } else { - ctx.strokeStyle = 'rgba(255,240,200,0.35)'; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.moveTo(x + 8, y + 12); - ctx.lineTo(x + t - 10, y + 10); - ctx.moveTo(x + 6, y + 20); - ctx.lineTo(x + t - 14, y + 19); - ctx.stroke(); - } - } - } -} - -function drawCrate(ctx: CanvasRenderingContext2D, biome: Biome, col: number, row: number, t: number) { - const x = col * t; - const y = row * t; - if (biome === 'forest' || biome === 'swamp') { - // Hromada klád - for (const [lx, ly] of [[13, 25], [27, 25], [20, 13]]) { - ctx.fillStyle = '#78350f'; - ctx.beginPath(); - ctx.arc(x + lx, y + ly, 8, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = '#d6a76b'; - ctx.beginPath(); - ctx.arc(x + lx, y + ly, 5.5, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = '#92400e'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(x + lx, y + ly, 2.5, 0, Math.PI * 2); - ctx.stroke(); - } - return; - } - ctx.fillStyle = '#a16207'; - ctx.fillRect(x + 2, y + 2, t - 4, t - 4); - ctx.strokeStyle = '#713f12'; - ctx.lineWidth = 3; - ctx.strokeRect(x + 4, y + 4, t - 8, t - 8); - ctx.beginPath(); - ctx.moveTo(x + 4, y + 4); - ctx.lineTo(x + t - 4, y + t - 4); - ctx.moveTo(x + t - 4, y + 4); - ctx.lineTo(x + 4, y + t - 4); - ctx.stroke(); - if (biome === 'winter') { - ctx.fillStyle = '#f8fafc'; - ctx.beginPath(); - ctx.roundRect(x + 2, y + 1, t - 4, 7, 3); - ctx.fill(); - } -} - -/** - * Porost (zpomaluje): nízká vegetace v úrovni země, přes kterou se jede — kapradí, suchá tráva, - * keříky ve sněhu nebo rákos. Kreslí se pod tanky a je řídký, aby nepřipomínal stromy. - */ -function drawGrowth(ctx: CanvasRenderingContext2D, biome: Biome, col: number, row: number, t: number) { - const x = col * t; - const y = row * t; - const style = GROWTH[biome]; - // Lehce odlišený podklad, ať je vidět, kde porost začíná - ctx.fillStyle = style.patch; - ctx.beginPath(); - ctx.roundRect(x + 1, y + 1, t - 2, t - 2, 10); - ctx.fill(); - for (let k = 0; k < 6; k++) { - const bx = x + 6 + hash(col, row, k) * (t - 12); - const by = y + 10 + hash(col, row, k + 11) * (t - 14); - // Trs stébel / lístků vějířovitě nahoru - ctx.strokeStyle = k % 2 ? style.blade : style.blade2; - ctx.lineWidth = biome === 'winter' ? 1.5 : 1.8; - ctx.lineCap = 'round'; - const blades = biome === 'swamp' ? 3 : 5; - for (let b = 0; b < blades; b++) { - const a = -Math.PI / 2 + (b / (blades - 1) - 0.5) * (biome === 'swamp' ? 0.5 : 1.6); - const len = (biome === 'swamp' ? 13 : 8) + hash(col, row, k * 7 + b) * 4; - ctx.beginPath(); - ctx.moveTo(bx, by); - ctx.quadraticCurveTo(bx + Math.cos(a) * len * 0.5, by + Math.sin(a) * len * 0.6, - bx + Math.cos(a) * len, by + Math.sin(a) * len); - ctx.stroke(); - } - if (biome === 'swamp' && k % 2 === 0) { - // Palice orobince - ctx.fillStyle = '#6b3f1d'; - ctx.beginPath(); - ctx.ellipse(bx, by - 13, 2, 4, 0, 0, Math.PI * 2); - ctx.fill(); - } - if (biome === 'winter') { - ctx.fillStyle = '#fff'; - ctx.beginPath(); - ctx.arc(bx + 2, by - 7, 1.8, 0, Math.PI * 2); - ctx.fill(); - } - } - ctx.lineCap = 'butt'; -} - -const GROWTH: Record = { - city: { patch: 'rgba(74,222,128,0.08)', blade: '#4d7c0f', blade2: '#65a30d' }, - forest: { patch: 'rgba(132,204,22,0.12)', blade: '#84cc16', blade2: '#65a30d' }, - desert: { patch: 'rgba(161,98,7,0.12)', blade: '#a3842a', blade2: '#8a6d1f' }, - winter: { patch: 'rgba(120,113,108,0.1)', blade: '#78716c', blade2: '#57534e' }, - swamp: { patch: 'rgba(163,230,53,0.1)', blade: '#7c9a2a', blade2: '#a3b33a' }, -}; - -// --- Snímek --- - -/** - * Vykreslí celý snímek. `me` je přihlášený hráč — sebe a spoluhráče s neviditelností vidí - * průsvitně, neviditelné soupeře jen zblízka. V hardcore vidí soupeře, střely a bonusy jen ve světle - * svém a spoluhráčů; výbuchy a záblesky výstřelů prosvítí i tmou. Divák a vyřazený hráč vidí všechno. - */ -export function drawTanks(canvas: HTMLCanvasElement, state: TankState, flashes: MuzzleFlash[], me?: string) { - const ctx = canvas.getContext('2d'); - if (!ctx) return; - const scale = canvas.width / state.width; - const now = performance.now(); - ctx.setTransform(scale, 0, 0, scale, 0, 0); - ctx.drawImage(mapLayer(state), 0, 0); - - const self = state.players.find(p => p.login === me); - const friendly = (o: { login?: string, owner?: string, team?: Team }) => - !self || (o.login ?? o.owner) === me || (state.teams && o.team === self.team); - const near = (o: { x: number, y: number }) => !!self?.alive && Math.hypot(o.x - self.x, o.y - self.y) < REVEAL_DISTANCE; - const perceivable = (p: TankPlayer) => p.alive && (friendly(p) || !p.ghost || near(p)); - - // Tma: hráč, který ještě hraje (i čekající na návrat), vidí jen světlo svoje a spoluhráčů - const dark = state.hardcore && !!self && (self.alive || self.respawnIn !== undefined); - const lights = dark - ? state.players.filter(p => p.alive && friendly(p)).map(p => castLight(state, p)) - : []; - const lit = (o: { x: number, y: number }, margin = 0) => !dark || inLight(lights, o.x, o.y, margin); - - for (const w of state.wrecks) if (lit(w, 20)) drawWreck(ctx, w, now); - for (const m of state.mines) if (friendly(m) || lit(m)) drawMine(ctx, m, friendly(m), now); - for (const pk of state.pickups) if (lit(pk, 14)) drawPickup(ctx, pk, now); - - const tanks = state.players.filter(p => perceivable(p) && (friendly(p) || lit(p, TANK_RADIUS))); - for (const p of tanks) { - ctx.globalAlpha = p.ghost ? 0.45 : 1; - drawTank(ctx, p, p.login === me, now); - ctx.globalAlpha = 1; - } - for (const b of state.bullets) if (lit(b, BULLET_RADIUS)) drawBullet(ctx, b, now); - - if (dark) drawDarkness(ctx, state, lights); - - for (const f of flashes) { - const p = state.players.find(x => x.login === f.login); - if (p && perceivable(p)) drawFlash(ctx, p, (now - f.start) / FLASH_MS); - } - for (const e of state.explosions) drawExplosion(ctx, e); - for (const p of tanks) drawLabel(ctx, p, p.login === me); - // Při startu a po návratu do hry ukazuje na vlastní tank šipka s pulzujícím kruhem - if (self?.alive && (state.phase === 'countdown' || self.protected)) drawSelfMarker(ctx, self, now); - ctx.setTransform(1, 0, 0, 1, 0, 0); -} - -function drawSelfMarker(ctx: CanvasRenderingContext2D, p: TankPlayer, now: number) { - const pulse = (now % 900) / 900; - ctx.strokeStyle = `rgba(250, 204, 21, ${0.9 * (1 - pulse)})`; - ctx.lineWidth = 3; - ctx.beginPath(); - ctx.arc(p.x, p.y, TANK_RADIUS + 10 + pulse * 40, 0, Math.PI * 2); - ctx.stroke(); - - // Šipka nad jmenovkou; u horního okraje hřiště pod tankem - const below = p.y < 70; - const dir = below ? -1 : 1; - const bob = Math.sin(now / 150) * 3; - const tipY = below ? p.y + TANK_RADIUS + 10 + bob : p.y - TANK_RADIUS - 20 + bob; - ctx.beginPath(); - ctx.moveTo(p.x, tipY); - ctx.lineTo(p.x - 9, tipY - dir * 14); - ctx.lineTo(p.x - 4, tipY - dir * 14); - ctx.lineTo(p.x - 4, tipY - dir * 24); - ctx.lineTo(p.x + 4, tipY - dir * 24); - ctx.lineTo(p.x + 4, tipY - dir * 14); - ctx.lineTo(p.x + 9, tipY - dir * 14); - ctx.closePath(); - ctx.lineWidth = 3; - ctx.strokeStyle = '#0b0f19'; - ctx.stroke(); - ctx.fillStyle = '#facc15'; - ctx.fill(); - - ctx.font = 'bold 13px sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - const textY = tipY - dir * 33; - ctx.lineWidth = 3; - ctx.strokeText('VY', p.x, textY); - ctx.fillText('VY', p.x, textY); -} - -// --- Hardcore: světlo a tma --- - -/** Paprsek světla ze středu tanku do každého směru; délka končí na zdi či bedně. */ -type Light = { x: number, y: number, lengths: Float32Array }; - -const LIGHT_RAYS = 240; -const LIGHT_STEP = 4; -/** Šířka přechodu mezi kruhem a kuželem reflektoru (rad), ať okraj kužele není ostrý zub. */ -const SPOTLIGHT_EDGE = 0.12; - -const wrapAngle = (a: number) => { - const d = a % (Math.PI * 2); - return d < 0 ? d + Math.PI * 2 : d; -}; - -function castLight(state: TankState, src: TankPlayer): Light { - const t = state.tile; - const blocks = (x: number, y: number) => { - if (x < 0 || y < 0 || x >= state.width || y >= state.height) return true; - const c = state.tiles[Math.floor(y / t)]?.[Math.floor(x / t)]; - return c === '#' || c === '+'; - }; - const lengths = new Float32Array(LIGHT_RAYS); - for (let i = 0; i < LIGHT_RAYS; i++) { - const a = i / LIGHT_RAYS * Math.PI * 2; - const off = Math.abs(wrapAngle(a - src.turret + Math.PI) - Math.PI); - const reach = off <= SPOTLIGHT_HALF_ANGLE - ? SPOTLIGHT_RANGE - : off <= SPOTLIGHT_HALF_ANGLE + SPOTLIGHT_EDGE - ? LIGHT_RADIUS + (SPOTLIGHT_RANGE - LIGHT_RADIUS) * (1 - (off - SPOTLIGHT_HALF_ANGLE) / SPOTLIGHT_EDGE) - : LIGHT_RADIUS; - const dx = Math.cos(a); - const dy = Math.sin(a); - let d = 0; - while (d < reach && !blocks(src.x + dx * (d + LIGHT_STEP), src.y + dy * (d + LIGHT_STEP))) d += LIGHT_STEP; - // Světlo dopadne i na stěnu překážky, ne jen před ni - lengths[i] = d < reach ? d + LIGHT_STEP * 2 : reach; - } - return { x: src.x, y: src.y, lengths }; -} - -/** Zda je bod (s tolerancí poloměru objektu) v některém světle. */ -function inLight(lights: Light[], x: number, y: number, margin: number) { - return lights.some(l => { - const d = Math.hypot(x - l.x, y - l.y); - if (d <= margin) return true; - const i = Math.round(wrapAngle(Math.atan2(y - l.y, x - l.x)) / (Math.PI * 2) * LIGHT_RAYS) % LIGHT_RAYS; - return d - margin <= l.lengths[i]; - }); -} - -let darknessCanvas: HTMLCanvasElement | undefined; - -/** Tma přes celou plochu s vyříznutým světlem (rozmazané okraje, slábnoucí do dálky). */ -function drawDarkness(ctx: CanvasRenderingContext2D, state: TankState, lights: Light[]) { - if (!darknessCanvas) darknessCanvas = document.createElement('canvas'); - const dc = darknessCanvas; - if (dc.width !== state.width || dc.height !== state.height) { - dc.width = state.width; - dc.height = state.height; - } - const d = dc.getContext('2d'); - if (!d) return; - d.globalCompositeOperation = 'source-over'; - d.clearRect(0, 0, dc.width, dc.height); - // Mapa zůstane matně vidět, aby se dalo orientovat - d.fillStyle = 'rgba(2, 4, 10, 0.84)'; - d.fillRect(0, 0, dc.width, dc.height); - d.globalCompositeOperation = 'destination-out'; - d.filter = 'blur(8px)'; - for (const l of lights) { - const fade = d.createRadialGradient(l.x, l.y, 0, l.x, l.y, SPOTLIGHT_RANGE); - fade.addColorStop(0, 'rgba(0,0,0,1)'); - fade.addColorStop(LIGHT_RADIUS / SPOTLIGHT_RANGE * 0.8, 'rgba(0,0,0,1)'); - fade.addColorStop(1, 'rgba(0,0,0,0.55)'); - d.fillStyle = fade; - d.beginPath(); - for (let i = 0; i < LIGHT_RAYS; i++) { - const a = i / LIGHT_RAYS * Math.PI * 2; - d.lineTo(l.x + Math.cos(a) * l.lengths[i], l.y + Math.sin(a) * l.lengths[i]); - } - d.closePath(); - d.fill(); - } - d.filter = 'none'; - d.globalCompositeOperation = 'source-over'; - ctx.drawImage(dc, 0, 0); -} - -function drawTank(ctx: CanvasRenderingContext2D, p: TankPlayer, mine: boolean, now: number) { - const color = tankColor(p); - const base = ctx.globalAlpha; - // Vlastní tank má pod sebou výrazný otáčející se kruh, ať se v hemžení najde (tmavý lem kvůli světlým mapám) - if (mine) { - ctx.fillStyle = 'rgba(250, 204, 21, 0.18)'; - ctx.beginPath(); - ctx.arc(p.x, p.y, TANK_RADIUS + 7, 0, Math.PI * 2); - ctx.fill(); - ctx.setLineDash([7, 5]); - ctx.lineDashOffset = -now / 40; - ctx.strokeStyle = 'rgba(11, 15, 25, 0.7)'; - ctx.lineWidth = 4.5; - ctx.stroke(); - ctx.strokeStyle = '#facc15'; - ctx.lineWidth = 2.5; - ctx.stroke(); - ctx.setLineDash([]); - ctx.lineDashOffset = 0; - } - // Ochrana po zrození — tank bliká - if (p.protected && Math.floor(now / 120) % 2 === 0) ctx.globalAlpha = base * 0.35; - // Turbo: proužky za tankem - if (p.turbo) { - ctx.strokeStyle = 'rgba(255,255,255,0.5)'; - ctx.lineWidth = 2; - for (const s of [-8, 0, 8]) { - const len = 10 + Math.sin(now / 60 + s) * 4; - const bx = p.x - Math.cos(p.angle) * 18 + -Math.sin(p.angle) * s; - const by = p.y - Math.sin(p.angle) * 18 + Math.cos(p.angle) * s; - ctx.beginPath(); - ctx.moveTo(bx, by); - ctx.lineTo(bx - Math.cos(p.angle) * len, by - Math.sin(p.angle) * len); - ctx.stroke(); - } - } - drawHull(ctx, p.x, p.y, p.angle, color, (p.x + p.y) * 0.7); - drawTurret(ctx, p.x, p.y, p.turret, color, p.triple ? 3 : 1, p.homing); - if (p.shield) { - ctx.strokeStyle = '#38bdf8'; - ctx.lineWidth = 2.5; - ctx.beginPath(); - ctx.arc(p.x, p.y, TANK_RADIUS + 6, 0, Math.PI * 2); - ctx.stroke(); - ctx.fillStyle = 'rgba(56,189,248,0.12)'; - ctx.fill(); - } - ctx.globalAlpha = base; -} - -function drawHull(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number, color: string, travel: number) { - ctx.save(); - ctx.translate(x, y); - ctx.rotate(angle); - // Stín - ctx.fillStyle = 'rgba(0, 0, 0, 0.35)'; - ctx.beginPath(); - ctx.roundRect(-14, -13, 32, 30, 6); - ctx.fill(); - // Pásy s články, které se posouvají s jízdou - const tread = travel % 5; - for (const ty of [-15, 8]) { - ctx.fillStyle = '#111827'; - ctx.beginPath(); - ctx.roundRect(-16, ty, 32, 7, 3); - ctx.fill(); - ctx.strokeStyle = '#4b5563'; - ctx.lineWidth = 1.5; - ctx.beginPath(); - for (let tx = -15 + tread; tx < 15; tx += 5) { - ctx.moveTo(tx, ty + 1); - ctx.lineTo(tx, ty + 6); - } - ctx.stroke(); - } - // Korba se stínováním a čelním pancířem - ctx.fillStyle = color; - ctx.beginPath(); - ctx.roundRect(-13, -9, 26, 18, 4); - ctx.fill(); - const light = ctx.createLinearGradient(0, -9, 0, 9); - light.addColorStop(0, 'rgba(255, 255, 255, 0.3)'); - light.addColorStop(0.5, 'rgba(255, 255, 255, 0)'); - light.addColorStop(1, 'rgba(0, 0, 0, 0.3)'); - ctx.fillStyle = light; - ctx.fill(); - ctx.strokeStyle = 'rgba(0, 0, 0, 0.55)'; - ctx.lineWidth = 1.5; - ctx.stroke(); - ctx.fillStyle = 'rgba(0, 0, 0, 0.25)'; - ctx.fillRect(8, -8, 3, 16); - ctx.restore(); -} - -function drawTurret(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number, color: string, barrels: number, homing: boolean) { - ctx.save(); - ctx.translate(x, y); - ctx.rotate(angle); - for (const a of barrels === 3 ? [-0.22, 0, 0.22] : [0]) { - ctx.save(); - ctx.rotate(a); - ctx.fillStyle = '#d1d5db'; - ctx.strokeStyle = '#1f2937'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.rect(2, -2.5, 17, 5); - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = homing ? '#e879f9' : '#6b7280'; - ctx.beginPath(); - ctx.rect(18, -3.5, 4, 7); - ctx.fill(); - ctx.stroke(); - ctx.restore(); - } - ctx.fillStyle = color; - ctx.beginPath(); - ctx.roundRect(-8, -7, 14, 14, 5); - ctx.fill(); - const glow = ctx.createRadialGradient(-3, -3, 1, -1, 0, 10); - glow.addColorStop(0, 'rgba(255, 255, 255, 0.45)'); - glow.addColorStop(1, 'rgba(0, 0, 0, 0.2)'); - ctx.fillStyle = glow; - ctx.fill(); - ctx.strokeStyle = 'rgba(0, 0, 0, 0.6)'; - ctx.lineWidth = 1.5; - ctx.stroke(); - ctx.fillStyle = 'rgba(0, 0, 0, 0.35)'; - ctx.beginPath(); - ctx.arc(-3, 0, 2.5, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); -} - -/** Bonus: záře, tmavý podklad se zlatým pulzujícím kruhem a větší ikona, která lehce poskakuje. */ -function drawPickup(ctx: CanvasRenderingContext2D, pk: TankState['pickups'][number], now: number) { - const pulse = (Math.sin(now / 180 + pk.x) + 1) / 2; - const bob = Math.sin(now / 260 + pk.y) * 2; - const glow = ctx.createRadialGradient(pk.x, pk.y, 6, pk.x, pk.y, 28 + pulse * 6); - glow.addColorStop(0, 'rgba(253, 224, 71, 0.55)'); - glow.addColorStop(1, 'rgba(253, 224, 71, 0)'); - ctx.fillStyle = glow; - ctx.beginPath(); - ctx.arc(pk.x, pk.y, 34, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = 'rgba(15, 23, 42, 0.75)'; - ctx.beginPath(); - ctx.arc(pk.x, pk.y + bob, 16, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = `rgba(250, 204, 21, ${0.7 + pulse * 0.3})`; - ctx.lineWidth = 2.5; - ctx.stroke(); - // Otáčející se čárkovaný prstenec, ať bonus upoutá pozornost - ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'; - ctx.lineWidth = 1.5; - ctx.setLineDash([4, 6]); - ctx.lineDashOffset = -now / 40; - ctx.beginPath(); - ctx.arc(pk.x, pk.y + bob, 20 + pulse * 2, 0, Math.PI * 2); - ctx.stroke(); - ctx.setLineDash([]); - ctx.lineDashOffset = 0; - ctx.font = '20px sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(PICKUPS[pk.type].icon, pk.x, pk.y + bob + 1); -} - -function drawLabel(ctx: CanvasRenderingContext2D, p: TankPlayer, mine: boolean) { - const y = p.y - TANK_RADIUS - 10; - ctx.font = `${mine ? 'bold ' : ''}11px sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.lineWidth = 3; - ctx.strokeStyle = '#0b0f19'; - ctx.strokeText(p.login, p.x, y); - ctx.fillStyle = mine ? '#fff' : tankColor(p); - ctx.fillText(p.login, p.x, y); -} - -function drawBullet(ctx: CanvasRenderingContext2D, b: TankState['bullets'][number], now: number) { - const color = tankColor(b); - if (!b.rocket) { - ctx.fillStyle = color; - ctx.beginPath(); - ctx.arc(b.x, b.y, BULLET_RADIUS, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = '#fff'; - ctx.beginPath(); - ctx.arc(b.x, b.y, BULLET_RADIUS / 2, 0, Math.PI * 2); - ctx.fill(); - return; - } - ctx.save(); - ctx.translate(b.x, b.y); - ctx.rotate(b.angle); - // Plamen z trysky - const flame = 8 + Math.sin(now / 30) * 3; - ctx.fillStyle = '#f97316'; - ctx.beginPath(); - ctx.moveTo(-6, -3); - ctx.lineTo(-6 - flame, 0); - ctx.lineTo(-6, 3); - ctx.fill(); - ctx.fillStyle = '#fde047'; - ctx.beginPath(); - ctx.moveTo(-6, -1.5); - ctx.lineTo(-6 - flame * 0.5, 0); - ctx.lineTo(-6, 1.5); - ctx.fill(); - ctx.fillStyle = '#e5e7eb'; - ctx.fillRect(-6, -3, 10, 6); - ctx.fillStyle = color; - ctx.beginPath(); - ctx.moveTo(4, -3); - ctx.lineTo(9, 0); - ctx.lineTo(4, 3); - ctx.fill(); - ctx.fillRect(-7, -4.5, 3, 9); - ctx.restore(); -} - -function drawMine(ctx: CanvasRenderingContext2D, m: TankState['mines'][number], friendly: boolean, now: number) { - // Cizí miny jsou jen málo vidět - ctx.globalAlpha = friendly ? 1 : 0.3; - ctx.fillStyle = '#1f2937'; - ctx.strokeStyle = tankColor(m); - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc(m.x, m.y, 7, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = m.armed && Math.floor(now / 300) % 2 === 0 ? '#ef4444' : '#6b7280'; - ctx.beginPath(); - ctx.arc(m.x, m.y, 2.5, 0, Math.PI * 2); - ctx.fill(); - ctx.globalAlpha = 1; -} - -/** Vrak: ohořelá korba s věží, plameny slábnou a stoupá kouř. */ -function drawWreck(ctx: CanvasRenderingContext2D, w: TankState['wrecks'][number], now: number) { - const fire = Math.max(0, 1 - w.age / WRECK_MS); - ctx.save(); - ctx.translate(w.x, w.y); - ctx.rotate(w.angle); - ctx.fillStyle = 'rgba(0,0,0,0.35)'; - ctx.beginPath(); - ctx.ellipse(1, 2, 20, 17, 0, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = '#1c1917'; - ctx.fillRect(-16, -15, 32, 7); - ctx.fillRect(-16, 8, 32, 7); - ctx.fillStyle = '#292524'; - ctx.beginPath(); - ctx.roundRect(-13, -9, 26, 18, 4); - ctx.fill(); - ctx.globalAlpha = 0.3; - ctx.fillStyle = tankColor(w); - ctx.fill(); - ctx.globalAlpha = 1; - ctx.rotate(w.turret - w.angle + 0.5); - ctx.fillStyle = '#44403c'; - ctx.fillRect(2, -2.5, 15, 5); - ctx.fillStyle = '#292524'; - ctx.beginPath(); - ctx.arc(0, 0, 7, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - - // Kouř stoupá a mizí, s ohněm postupně řídne - for (let i = 0; i < 6; i++) { - const phase = (now / 1400 + i / 6) % 1; - ctx.fillStyle = `rgba(68,64,60,${(1 - phase) * (0.15 + 0.35 * fire)})`; - ctx.beginPath(); - ctx.arc(w.x + Math.sin(phase * 5 + i) * 7 + phase * 6, w.y - phase * 34, 4 + phase * 9, 0, Math.PI * 2); - ctx.fill(); - } - if (fire <= 0) return; - for (let i = 0; i < 5; i++) { - const fx = w.x + Math.sin(i * 2.1) * 8 + Math.sin(now / 70 + i) * 2; - const fy = w.y + Math.cos(i * 1.7) * 6 - Math.abs(Math.sin(now / 90 + i * 1.3)) * 4; - const r = (4 + Math.abs(Math.sin(now / 110 + i)) * 4) * (0.4 + 0.6 * fire); - ctx.fillStyle = 'rgba(249,115,22,0.85)'; - ctx.beginPath(); - ctx.arc(fx, fy, r, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = 'rgba(253,224,71,0.9)'; - ctx.beginPath(); - ctx.arc(fx, fy + 1, r * 0.5, 0, Math.PI * 2); - ctx.fill(); - } -} - -function drawExplosion(ctx: CanvasRenderingContext2D, e: TankState['explosions'][number]) { - const k = e.age / EXPLOSION_MS; - if (k < 0 || k > 1) return; - ctx.globalAlpha = 1 - k; - ctx.fillStyle = '#f97316'; - ctx.beginPath(); - ctx.arc(e.x, e.y, e.r * (0.3 + 0.7 * k), 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = '#fde047'; - ctx.beginPath(); - ctx.arc(e.x, e.y, e.r * (0.15 + 0.4 * k), 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = '#fff7ed'; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc(e.x, e.y, e.r * (0.4 + 0.8 * k), 0, Math.PI * 2); - ctx.stroke(); - ctx.globalAlpha = 1; -} - -/** Záblesk u ústí hlavně: hvězdice, která rychle zhasne. */ -function drawFlash(ctx: CanvasRenderingContext2D, p: TankPlayer, k: number) { - if (k < 0 || k > 1) return; - const tip = 24; - const size = 10 * (1 - k * 0.5); - ctx.save(); - ctx.translate(p.x + Math.cos(p.turret) * tip, p.y + Math.sin(p.turret) * tip); - ctx.rotate(p.turret); - ctx.globalAlpha = 1 - k; - ctx.fillStyle = '#fde047'; - ctx.beginPath(); - for (let i = 0; i < 10; i++) { - const a = i / 10 * Math.PI * 2; - const r = i % 2 ? size * 0.4 : size * (i === 0 ? 1.6 : 1); - ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r); - } - ctx.closePath(); - ctx.fill(); - ctx.fillStyle = '#fff'; - ctx.beginPath(); - ctx.arc(0, 0, size * 0.35, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - ctx.globalAlpha = 1; -} diff --git a/client/src/games/tetrisEngine.ts b/client/src/games/tetrisEngine.ts deleted file mode 100644 index 02085e3..0000000 --- a/client/src/games/tetrisEngine.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * 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, velikost jejich čtverce pro rotaci - * a počet různých poloh (O jedna; I, S, Z jen dvě, aby se při otáčení neposouvaly). - */ -const SHAPES: { cells: Point[], box: number, states: 1 | 2 | 4 }[] = [ - { cells: [[0, 1], [1, 1], [2, 1], [3, 1]], box: 4, states: 2 }, - { cells: [[0, 0], [1, 0], [0, 1], [1, 1]], box: 2, states: 1 }, - { cells: [[1, 0], [0, 1], [1, 1], [2, 1]], box: 3, states: 4 }, - { cells: [[1, 0], [2, 0], [0, 1], [1, 1]], box: 3, states: 2 }, - { cells: [[0, 0], [1, 0], [1, 1], [2, 1]], box: 3, states: 2 }, - { cells: [[0, 0], [0, 1], [1, 1], [2, 1]], box: 3, states: 4 }, - { cells: [[2, 0], [0, 1], [1, 1], [2, 1]], box: 3, states: 4 }, -]; - -/** - * Předpočítané rotace pro každou kostku; rot + 1 = otočení proti směru hodinových ručiček. - * T, J, L se otáčí kolem prostředního políčka. I, S, Z jen přepínají mezi vodorovnou - * a svislou polohou (svislá = vodorovná otočená po směru, stejná osa jako v klasickém Tetrisu). - */ -const ROTATIONS: Point[][][] = SHAPES.map(({ cells, box, states }) => { - const ccw = (pts: Point[]) => pts.map(([x, y]) => [y, box - 1 - x] as Point); - const cw = (pts: Point[]) => pts.map(([x, y]) => [box - 1 - y, x] as Point); - if (states === 1) return [cells, cells, cells, cells]; - if (states === 2) return [cells, cw(cells), cells, cw(cells)]; - const result = [cells]; - for (let r = 1; r < 4; r++) result.push(ccw(result[r - 1])); - 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]; - -/** Odpadní řádky za smazání 0–4 řádků naráz (musí odpovídat GARBAGE_FOR_LINES na serveru). */ -const ATTACK_FOR_LINES = [0, 0, 1, 2, 4]; - -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ů, - * `attack` = odpadní řádky pro soupeře po odražení vlastního čekajícího odpadu. - */ - onLock: (cleared: number, attack: 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; - /** Pořadové číslo padající kostky — dotykové gesto podle něj pozná, že jeho kostka mezitím dopadla. */ - pieceNo = 0; - 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); - } - - /** Zda kostka leží na dně či na jiných kostkách (další krok dolů by ji zamkl). */ - get grounded() { - return !!this.active && !this.fits({ ...this.active, y: this.active.y + 1 }); - } - - 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 čekají a přidají se po dopadu kostky, která nic nesmaže (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(); - // Kostka se objeví celá nad deskou, aby ji šlo otočit dřív, než vjede do hrací plochy - const bottom = Math.max(...ROTATIONS[type][0].map(([, y]) => y)); - const piece: Piece = { type, rot: 0, x: type === 1 ? 4 : 3, y: -bottom - 1 }; - if (!this.fits(piece)) { - this.active = null; - this.die(); - return; - } - this.active = piece; - this.pieceNo++; - } - - 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(); - let attack = 0; - if (cleared > 0) { - this.score += LINE_SCORES[cleared] * (this.level + 1); - this.lines += cleared; - // Útok nejdřív odráží čekající odpad, soupeři jde jen zbytek - attack = ATTACK_FOR_LINES[cleared] ?? 0; - const cancelled = Math.min(attack, this.pendingGarbage); - this.pendingGarbage -= cancelled; - attack -= cancelled; - } - this.callbacks.onLock(cleared, attack); - // Dokud hráč maže řádky, zbylý odpad ještě čeká - if (cleared === 0 && 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 index 1ac26f7..020f76d 100644 --- a/client/src/games/themeIcons.ts +++ b/client/src/games/themeIcons.ts @@ -1,20 +1,20 @@ import { StyleTheme, useSettings } from "../context/settings"; -export type GameId = 'reaction' | 'snake' | 'tetris' | 'pong' | 'drawing' | 'tanks'; +export type GameId = 'drawing'; -/** Ikony miniher podle vizuálního stylu aplikace. */ +/** Ikony miniher podle vizuálního stylu aplikace (ostatní minihry jsou v samostatné aplikaci Lobby). */ const ICONS: Record> = { - simple: { reaction: '⚡', snake: '🐍', tetris: '🧱', pong: '🏓', drawing: '🎨', tanks: '💥' }, - // Dostihy: startovní vlajka, kůň místo hada, mrkve jako kostky, žokej, trofej, terč - umamusume: { reaction: '🏁', snake: '🐎', tetris: '🥕', pong: '🏇', drawing: '🏆', tanks: '🎯' }, - // Duha, jednorožec, drahokamy, balónky, paleta a blesk Rainbow Dash - mlp: { reaction: '🌈', snake: '🦄', tetris: '💎', pong: '🎈', drawing: '🖌️', tanks: '⚡' }, - // Pekelná korespondence: plamen, rudá nit, lampion, slaměná panenka, svitek, svíčka - hellgirl: { reaction: '🔥', snake: '🪢', tetris: '🏮', pong: '🪆', drawing: '📜', tanks: '🕯️' }, - // Klub lehké hudby: bicí Ritsu, želva Ton-chan, dortíky k čaji, kytara Gitah, notový zápis a klávesy Mugi - kon: { reaction: '🥁', snake: '🐢', tetris: '🍰', pong: '🎸', drawing: '🎼', tanks: '🎹' }, - // Kessoku Band: mikrofon Kity, kabel od kytary, krabice od manga, bicí Nijiky, sešit s texty Bocchi a její kytara - bocchi: { reaction: '🎤', snake: '🔌', tetris: '📦', pong: '🥁', drawing: '📝', tanks: '🎸' }, + simple: { drawing: '🎨' }, + // Dostihy: trofej + umamusume: { drawing: '🏆' }, + // Paleta Rarity + mlp: { drawing: '🖌️' }, + // Pekelná korespondence: svitek + hellgirl: { drawing: '📜' }, + // Klub lehké hudby: notový zápis + kon: { drawing: '🎼' }, + // Kessoku Band: sešit s texty Bocchi + bocchi: { drawing: '📝' }, }; /** Styl z — pro místa mimo React kontext nastavení. */ diff --git a/client/src/hooks/useMinigameSecret.ts b/client/src/hooks/useMinigameSecret.ts index 678d6d5..30fa411 100644 --- a/client/src/hooks/useMinigameSecret.ts +++ b/client/src/hooks/useMinigameSecret.ts @@ -1,11 +1,7 @@ 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 { openTankGame } from "../components/TankGame"; import { GameId, gameIcon } from "../games/themeIcons"; +import { useAppConfig } from "./useGroupLimits"; type Minigame = { id: GameId, @@ -16,36 +12,11 @@ type Minigame = { open: () => void, }; -/** Skryté minihry dostupné přes logo. Novou hru stačí přidat sem. */ +/** + * Skryté minihry, které zůstaly v Luncheru (ostatní jsou v samostatné aplikaci Lobby). + * 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', @@ -53,25 +24,23 @@ const MINIGAMES: Minigame[] = [ description: 'Jeden kreslí jídlo z dnešních menu, ostatní hádají.', open: openDrawingGame, }, - { - id: 'tanks', - code: 'tanky', - name: 'Tanky', - description: 'Tanková bitva pro více hráčů (i s boty), střely se odráží od zdí. Na čas nebo na poslední přeživší.', - open: openTankGame, - }, ]; 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). */ +/** Jak dlouho po sérii kliknutí (případně po návratu z Lobby do této záložky) lze napsat kód (ms). */ const SECRET_ARM_MS = 8000; -function printHelp() { +function printHelp(lobbyUrl?: string) { console.log('%c🎮 Luncher minihry', 'font-size: 16px; font-weight: bold'); - console.log('Klikněte 3× na logo a napište kód hry:'); + if (lobbyUrl) { + console.log(`Klikněte 3× na logo — otevře se Lobby s dalšími minihrami (${lobbyUrl}).`); + console.log('Po návratu do Luncheru lze napsat kód hry:'); + } else { + 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 }))); } @@ -79,12 +48,14 @@ function printHelp() { const normalizeCode = (text: string) => text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim(); /** - * 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. + * Skrytý vstup do miniher: 3× rychle kliknout na logo. Je-li nastavena adresa Lobby, otevře se v nové záložce. + * Poté lze (po návratu do Luncheru) napsat kód hry, která zůstala v Luncheru, případně "help" pro výpis do konzole prohlížeče. * Jedno či dvě kliknutí se chovají jako běžný odkaz (jen s malým zpožděním). - * Na dotykových zařízeních se kód píše do skrytého pole (`inputRef`), které otevře softwarovou klávesnici. + * Bez Lobby se na dotykových zařízeních kód píše do skrytého pole (`inputRef`), které otevře softwarovou klávesnici. + * Po otevření Lobby klávesnici po návratu do záložky otevřít nelze (chce gesto uživatele), na mobilu tak kód zadat nepůjde. */ export function useMinigameSecret(href: string) { + const { lobbyUrl } = useAppConfig(); const [armed, setArmed] = useState(false); const clicks = useRef(0); const timer = useRef(undefined); @@ -98,11 +69,16 @@ export function useMinigameSecret(href: string) { clicks.current++; if (clicks.current >= SECRET_CLICKS) { clicks.current = 0; - // Fokus musí proběhnout přímo v obsluze kliknutí, jinak iOS klávesnici neotevře - const input = inputRef.current; - if (input) { - input.value = ''; - input.focus({ preventScroll: true }); + if (lobbyUrl) { + // Otevření musí proběhnout přímo v obsluze kliknutí, jinak ho prohlížeč zablokuje jako vyskakovací okno + window.open(lobbyUrl, '_blank', 'noopener'); + } else { + // Fokus musí proběhnout přímo v obsluze kliknutí, jinak iOS klávesnici neotevře + const input = inputRef.current; + if (input) { + input.value = ''; + input.focus({ preventScroll: true }); + } } setArmed(true); return; @@ -111,7 +87,7 @@ export function useMinigameSecret(href: string) { clicks.current = 0; window.location.assign(href); }, SECRET_CLICK_GAP_MS); - }, [href]); + }, [href, lobbyUrl]); useEffect(() => { if (!armed) return; @@ -121,7 +97,7 @@ export function useMinigameSecret(href: string) { const check = (text: string) => { if (text.endsWith(HELP_CODE)) { setArmed(false); - printHelp(); + printHelp(lobbyUrl); return; } const game = MINIGAMES.find(g => text.endsWith(g.code)); @@ -138,19 +114,34 @@ export function useMinigameSecret(href: string) { }; // Softwarová klávesnice (Android) posílá v keydown jen "Unidentified", proto se čte celý text pole const onInput = () => check(normalizeCode(input?.value ?? '')); - const disarm = window.setTimeout(() => setArmed(false), SECRET_ARM_MS); + // Limit běží, jen když je záložka vidět — po odchodu do Lobby se zastaví a po návratu začne znovu + let disarm: number | undefined; + const startDisarm = () => { + window.clearTimeout(disarm); + disarm = window.setTimeout(() => setArmed(false), SECRET_ARM_MS); + }; + const onVisibility = () => { + if (document.visibilityState === 'visible') { + startDisarm(); + } else { + window.clearTimeout(disarm); + } + }; + startDisarm(); window.addEventListener('keydown', onKey); input?.addEventListener('input', onInput); + document.addEventListener('visibilitychange', onVisibility); return () => { window.clearTimeout(disarm); window.removeEventListener('keydown', onKey); input?.removeEventListener('input', onInput); + document.removeEventListener('visibilitychange', onVisibility); if (input) { input.value = ''; input.blur(); } }; - }, [armed]); + }, [armed, lobbyUrl]); useEffect(() => () => window.clearTimeout(timer.current), []); diff --git a/client/src/hooks/useTouchDevice.ts b/client/src/hooks/useTouchDevice.ts deleted file mode 100644 index 36771ce..0000000 --- a/client/src/hooks/useTouchDevice.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useEffect, useState } from "react"; - -/** Libovolné dotykové ovládání — iPad se hlásí jako Mac, takže se nedá spoléhat na user agent. */ -const QUERY = '(any-pointer: coarse)'; - -/** Zda má zařízení dotykový displej (podle toho minihry ukazují dotyková tlačítka a nápovědu). */ -export function useTouchDevice() { - const [touch, setTouch] = useState(() => window.matchMedia(QUERY).matches); - useEffect(() => { - const media = window.matchMedia(QUERY); - const onChange = () => setTouch(media.matches); - media.addEventListener('change', onChange); - return () => media.removeEventListener('change', onChange); - }, []); - return touch; -} diff --git a/client/src/index.tsx b/client/src/index.tsx index ef7908a..bc4495a 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -24,6 +24,7 @@ getConfig().then(({ data }) => { loaded: true, trustedHeaders: data?.auth?.trustedHeaders, storeAdmin: data?.features?.storeAdmin, + lobbyUrl: data?.links?.lobbyUrl ?? undefined, groupLimits: data?.limits?.group, }); if (data?.sentry.dsn) { diff --git a/client/src/utils/appConfig.ts b/client/src/utils/appConfig.ts index 1ea6db0..52992cf 100644 --- a/client/src/utils/appConfig.ts +++ b/client/src/utils/appConfig.ts @@ -22,6 +22,8 @@ export type AppRuntimeConfig = { trustedHeaders?: boolean, /** Správa obchodů je zapnutá (server má admin heslo); undefined = nevíme */ storeAdmin?: boolean, + /** Adresa aplikace Lobby s minihrami; undefined = není nastavena nebo nevíme */ + lobbyUrl?: string, groupLimits: GroupLimits, }; diff --git a/client/src/utils/arrowNavigation.ts b/client/src/utils/arrowNavigation.ts index e74e66b..16ef879 100644 --- a/client/src/utils/arrowNavigation.ts +++ b/client/src/utils/arrowNavigation.ts @@ -1,7 +1,7 @@ /** * Zda se má stisk klávesy ignorovat pro navigaci šipkami mezi dny/týdny na pozadí stránky. * Navigace se nemá provádět, pokud uživatel píše do formulářového pole, nebo pokud je otevřený - * modální dialog (typicky minihra jako Tetris či Had, která šipky používá k ovládání) – + * modální dialog (např. minihra, která klávesnici používá k ovládání) – * jinak by se stránka na pozadí neustále přepínala a zbytečně přenačítala data ze serveru. */ export function isArrowNavigationBlocked(e: KeyboardEvent): boolean { diff --git a/k8s/base/server-configmap.yaml b/k8s/base/server-configmap.yaml index 3959a8d..d9d8528 100644 --- a/k8s/base/server-configmap.yaml +++ b/k8s/base/server-configmap.yaml @@ -10,3 +10,4 @@ data: REDIS_PORT: "6379" PORT: "3001" HOST: "0.0.0.0" + LOBBY_URL: "http://lobby.localhost" diff --git a/server/.env.template b/server/.env.template index 9786e6a..0343f99 100644 --- a/server/.env.template +++ b/server/.env.template @@ -52,6 +52,10 @@ # Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu). # REFRESH_BYPASS_PASSWORD= +# Adresa aplikace Lobby s minihrami. Skrytý odkaz (3× kliknutí na logo) ji otevře v nové záložce. +# Bez vyplnění 3× kliknutí jen čeká na kód hry, která zůstala v Luncheru (kreslení). +# LOBBY_URL='https://lobby.example.com' + # Admin heslo pro správu seznamu obchodů na stránce /objednani. # Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403). # ADMIN_PASSWORD= diff --git a/server/changelogs/2026-10-21.json b/server/changelogs/2026-10-21.json index f176d97..1df8de0 100644 --- a/server/changelogs/2026-10-21.json +++ b/server/changelogs/2026-10-21.json @@ -2,5 +2,6 @@ "U oběda v restauraci si můžete zadat cenu (ikonka cenovky u vaší volby) — promítne se do měsíčního přehledu stravování a předvyplní se i v „Zaplatit za všechny“", "V lobby miniher (Pong, Reakční hra, Had, Tetris, Tanky) si hráči mohou psát v chatu, emotikony se vkládají jedním kliknutím 😉", "Tanky mají nově výchozí směrové ovládání — šipka nebo WSAD určuje směr jízdy na mapě; původní tankové ovládání (vpřed/vzad a otáčení) lze zapnout volbou Ovládání", - "Tanky: větší mapy s více překážkami, vlastní tank je výrazně označený (při startu a po návratu do hry na něj ukazuje šipka) a pod hrací plochou je legenda bonusů" + "Tanky: větší mapy s více překážkami, vlastní tank je výrazně označený (při startu a po návratu do hry na něj ukazuje šipka) a pod hrací plochou je legenda bonusů", + "Minihry se přestěhovaly do vlastní aplikace 😉" ] diff --git a/server/src/index.ts b/server/src/index.ts index eabfc4e..8b840bf 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -150,6 +150,9 @@ app.get("/api/config", (_req, res) => { features: { storeAdmin: !!process.env.ADMIN_PASSWORD, }, + links: { + lobbyUrl: process.env.LOBBY_URL || null, + }, }); }); diff --git a/server/src/minigames.ts b/server/src/minigames.ts index a8581ad..8179754 100644 --- a/server/src/minigames.ts +++ b/server/src/minigames.ts @@ -1,7 +1,7 @@ -import { Server, Socket } from "socket.io"; +import { Socket } from "socket.io"; import { getLogin } from "./auth"; -/** Sdílené pomůcky pro websocketové minihry (reakční hra, had). */ +/** Pomůcky pro websocketové minihry (kreslení). Ostatní minihry jsou v samostatné aplikaci Lobby. */ /** Chyba určená hráči — posílá se zpět klientovi, nic se neloguje. */ export class GameError extends Error { } @@ -60,19 +60,3 @@ export function gameHandler(socket: Socket, errorEvent: string, action: (login: /** 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; - -/** - * Jednoduchý chat v lobby miniher — zprávy se nikde neukládají, server je jen - * přepošle všem (klient si je filtruje podle hry). - */ -export function registerLobbyChat(io: Server) { - io.on('connection', socket => { - socket.on('lobbychat:send', gameHandler(socket, 'lobbychat:error', (login, payload) => { - const { game, text } = (payload ?? {}) as { game?: unknown, text?: unknown }; - if (typeof game !== 'string' || typeof text !== 'string') return; - const trimmed = text.trim().slice(0, 200); - if (!trimmed) return; - io.emit('lobbychat:message', { game: game.slice(0, 20), login, text: trimmed }); - })); - }); -} diff --git a/server/src/pongGame.ts b/server/src/pongGame.ts deleted file mode 100644 index 0c763e8..0000000 --- a/server/src/pongGame.ts +++ /dev/null @@ -1,429 +0,0 @@ -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 deleted file mode 100644 index ed5670b..0000000 --- a/server/src/reactionDuel.ts +++ /dev/null @@ -1,250 +0,0 @@ -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 deleted file mode 100644 index bb7e29e..0000000 --- a/server/src/snakeGame.ts +++ /dev/null @@ -1,344 +0,0 @@ -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/tankGame.ts b/server/src/tankGame.ts deleted file mode 100644 index e069938..0000000 --- a/server/src/tankGame.ts +++ /dev/null @@ -1,1426 +0,0 @@ -import { Server } from "socket.io"; -import { Clock, defaultClock, GameError, gameHandler, minPlayersForEnv } from "./minigames"; - -/** - * Tanky pro více hráčů přes websocket. - * - * Server drží celou simulaci: pohyb tanků po mapě z dlaždic, střely (volitelně odrážené od zdí), - * zničitelné bedny, terén (voda, bláto, porost) a bonusy. Klienti posílají jen stav ovládání - * (jízda, otáčení, střelba), bod, kam míří myší (věž se otáčí nezávisle na korbě), a pokládání min. - * - * Režimy: - * - `deathmatch` — hraje se na čas, po zničení se tank znovu objeví; vyhrává nejvíc bodů - * (zničení soupeře +1, zničení sebe sama −1). - * - `lastman` — každý má {@link LAST_MAN_LIVES} životy, vyhrává poslední, komu nějaký zbyl. - * Oba režimy jdou hrát i ve dvou týmech (levá a pravá strana mapy); spoluhráči se navzájem nezraní, - * a v hardcore variantě, kdy je aréna potemnělá a vidět je jen světlo kolem tanku (viz {@link LIGHT_RADIUS}). - * - * Zakladatel lobby může přidat boty. Průběžné snímky chodí jen do {@link WATCH_ROOM}. - * Stav je v paměti, předpokládá jednu repliku. - */ - -export const TILE = 40; -export const COLS = 28; -export const ROWS = 17; -export const FIELD_WIDTH = COLS * TILE; -export const FIELD_HEIGHT = ROWS * TILE; -export const TICK_MS = 33; -export const TANK_RADIUS = 14; -/** Rychlosti v jednotkách hřiště za sekundu, otáčení v rad/s. */ -export const TANK_SPEED = 130; -export const TANK_REVERSE_SPEED = 85; -export const TANK_TURN_SPEED = 3.2; -/** Věž se otáčí nezávisle na korbě a rychleji. */ -export const TURRET_TURN_SPEED = 6.5; -/** Zpomalení v blátě / sněhu / písku a v porostu. */ -export const MUD_FACTOR = 0.55; -export const BULLET_SPEED = 320; -export const BULLET_RADIUS = 4; -export const BULLET_LIFETIME_MS = 3500; -/** Kolikrát se střela může odrazit od zdi, než zanikne (jen se zapnutým odrážením). */ -export const BULLET_MAX_BOUNCES = 2; -export const FIRE_COOLDOWN_MS = 450; -/** Max. počet střel jednoho tanku ve vzduchu (s trojitou střelou trojnásobek). */ -export const MAX_BULLETS = 4; -export const RESPAWN_MS = 2000; -/** Ochrana po (znovu)zrození — střely tankem proletí. */ -export const SPAWN_PROTECTION_MS = 1500; -export const DEATHMATCH_MS = 3 * 60 * 1000; -export const LAST_MAN_LIVES = 3; -/** Pojistka, aby hra na přežití netrvala donekonečna. */ -export const LAST_MAN_MAX_MS = 5 * 60 * 1000; -export const PICKUP_INTERVAL_MS = 6000; -export const MAX_PICKUPS = 3; -export const PICKUP_RADIUS = 14; -export const SHIELD_MS = 15000; -export const TRIPLE_SHOT_MS = 10000; -export const TURBO_MS = 8000; -export const TURBO_FACTOR = 1.6; -export const GHOST_MS = 8000; -export const HOMING_MS = 10000; -/** Jak rychle se naváděná střela stáčí k cíli (rad/s) a z jaké dálky cíl hledá. */ -export const HOMING_TURN_SPEED = 3; -export const HOMING_RANGE = 300; -/** Kolik raket / min dá jeden bonus a kolik jich jde mít nejvýš. */ -export const ROCKET_AMMO = 3; -export const MINE_AMMO = 3; -export const MAX_AMMO = 6; -export const ROCKET_SPEED = 260; -/** Poloměr výbuchu rakety a miny. */ -export const BLAST_RADIUS = 55; -/** Mina se aktivuje až chvíli po položení. */ -export const MINE_ARM_MS = 1000; -export const MINE_TRIGGER_RADIUS = TANK_RADIUS + 6; -/** Na dálku, ze které je vidět i neviditelný tank. */ -export const REVEAL_DISTANCE = 70; -/** - * Hardcore (tma): hráč vidí jen kruh kolem tanku a kužel reflektoru ve směru věže (poloviční úhel v rad); - * světlo zastaví zdi a bedny. Ve hře v týmech je vidění společné. Tmu kreslí klient, server ji uplatňuje u botů. - */ -export const LIGHT_RADIUS = 130; -export const SPOTLIGHT_RANGE = 300; -export const SPOTLIGHT_HALF_ANGLE = 0.45; -/** Jak dlouho zůstává na mapě hořící vrak a výbuch (ms). */ -export const WRECK_MS = 8000; -export const EXPLOSION_MS = 600; -/** Úhel krajních střel trojité střely (rad). */ -const TRIPLE_SPREAD = 0.22; -export const COUNTDOWN_SECONDS = 3; -export const MAX_PLAYERS = 6; -export const MAX_TEAM_SIZE = MAX_PLAYERS / 2; -export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000; -export const WATCH_ROOM = 'tanks-watchers'; - -export type TankPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished'; -export type TankMode = 'deathmatch' | 'lastman'; -export type BotLevel = 'easy' | 'medium' | 'hard'; -export type PickupType = 'shield' | 'triple' | 'rocket' | 'turbo' | 'ghost' | 'homing' | 'mine' | 'heart'; -export type Biome = 'city' | 'forest' | 'desert' | 'winter' | 'swamp'; -export type Team = 0 | 1; - -type BotProfile = { - label: string, - /** Jak často bot přehodnocuje cíl a cestu (ms). */ - reactMs: number, - /** Max. odchylka míření (± rad). */ - aimError: number, - /** Jak přesně musí mířit, aby vystřelil (rad). */ - fireTolerance: number, -}; - -export const BOT_PROFILES: Record = { - easy: { label: 'Lehký', reactMs: 450, aimError: 0.3, fireTolerance: 0.2 }, - medium: { label: 'Střední', reactMs: 250, aimError: 0.12, fireTolerance: 0.12 }, - hard: { label: 'Těžký', reactMs: 120, aimError: 0.04, fireTolerance: 0.07 }, -}; - -/** - * Mapy: levá polovina řádku, pravá se doplní zrcadlově (v týmech hraje každý tým na své půlce). - * `#` zeď / skála / strom, `+` bedna (zničitelná), `~` voda (neprůjezdná, ale přestřelitelná), - * `:` bláto / sníh / písek, `%` porost — kapradí, suchá tráva, keříky, rákos (obojí zpomaluje), - * `S` místo zrození, `.` volno. - * Mimo hřiště je zeď. - */ -const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] = [ - { - id: 'arena', - name: 'Aréna', - biome: 'city', - half: [ - "S..........++.", - "..............", - "..##..........", - "..#.....#.....", - "........#..+..", - ".....+........", - "....##....#...", - "..........#...", - "S......+......", - "..........#...", - "....##....#...", - ".....+........", - "........#..+..", - "..#.....#.....", - "..##..........", - "..............", - "S..........++.", - ], - }, - { - id: 'maze', - name: 'Bludiště', - biome: 'city', - half: [ - "S...#.........", - "..#.#.####....", - "..#......#..+.", - "..####.#.#....", - ".......#...##.", - "##.+.###......", - "....+.....+...", - ".#.......##...", - "S.#####...+...", - ".#.......##...", - "....+.....+...", - "##.+.###......", - ".......#...##.", - "..####.#.#....", - "..#......#..+.", - "..#.#.####....", - "S...#.........", - ], - }, - { - id: 'warehouse', - name: 'Sklad', - biome: 'city', - half: [ - "S.....+.......", - ".++.+++.++..+.", - ".++.....++....", - "....##......+.", - "+.+.##.+.+....", - "....+......##.", - ".#..+.###.....", - ".#......#..+..", - "S...+...#.....", - ".#......#..+..", - ".#..+.###.....", - "....+......##.", - "+.+.##.+.+....", - "....##......+.", - ".++.....++....", - ".++.+++.++..+.", - "S.....+.......", - ], - }, - { - id: 'forest', - name: 'Les', - biome: 'forest', - half: [ - "S..%%...#..#..", - "..%%%..##...%.", - "......%%...%%.", - "#..#.......%..", - "##...%%..#...#", - "....%%%..##...", - ".#......%%..#.", - "~~.~~~~.~~~~.~", - "S.%%%.....+...", - "..%%..##..%%..", - "#.......#..%..", - "##..%%.....#..", - "...%%%..##....", - ".#.....%%...#.", - "...##...%..~~.", - "..%%..#....~~.", - "S......+......", - ], - }, - { - id: 'desert', - name: 'Poušť', - biome: 'desert', - half: [ - "S.....##......", - "...::.....#...", - "..::::..#...::", - ".......##.....", - "#..#......~~..", - "##..+....~~~..", - "......#..~~~.#", - "..#...#...~~..", - "S.....#..+....", - "..#...#...~~..", - "......#..~~~.#", - "##..+....~~~..", - "#..#......~~..", - ".......##.....", - "..::::..#...::", - "...::.....#...", - "S.....##......", - ], - }, - { - id: 'winter', - name: 'Zima', - biome: 'winter', - half: [ - "S...%%....#...", - ".::.%%..::..#.", - ".::.....::....", - "....##.....%%.", - "%%..##..~~....", - "%%......~~..#.", - "...#::......#.", - "....::..##....", - "S..+::..##.+..", - "....::........", - "...#::......#.", - "%%......~~..#.", - "%%..##..~~....", - "....##.....%%.", - ".::.....::....", - ".::.%%..::..#.", - "S...%%....#...", - ], - }, - { - id: 'swamp', - name: 'Bažina', - biome: 'swamp', - half: [ - "S..::..%%.....", - "..:::~~%%.::#.", - ".::.~~~...::..", - ".....~..#.....", - "%%.......~~.#.", - "%%%.::::.~~...", - "..+.::::....%%", - ".#........#.%%", - "S...::....+...", - ".#........#...", - "..+.::::....%%", - "%%%.::::.~~...", - "%%.......~~.#.", - ".....~..#.....", - ".::.~~~...::..", - "..:::~~%%.::#.", - "S..::..%%.....", - ], - }, -]; - -export const MAPS = MAP_HALVES.map(m => ({ - id: m.id, - name: m.name, - biome: m.biome, - rows: m.half.map(r => r + [...r].reverse().join('')), -})); - -export type TankPlayer = { - login: string, - /** Index barvy (klient si mapuje na paletu; v týmech index v rámci týmu). */ - color: number, - team?: Team, - x: number, - y: number, - /** Natočení v radiánech, 0 = doprava, kladně po směru hodinových ručiček. */ - angle: number, - /** Natočení věže (směr střelby) v radiánech, nezávislé na korbě. */ - turret: number, - alive: boolean, - kills: number, - deaths: number, - score: number, - /** Zbývající životy (jen režim lastman). */ - lives?: number, - shield: boolean, - triple: boolean, - turbo: boolean, - ghost: boolean, - homing: boolean, - rockets: number, - mines: number, - /** Ochrana po zrození. */ - protected: boolean, - /** Počítadlo výstřelů — klient podle změny kreslí záblesk. */ - shots: number, - /** Za kolik ms se tank znovu objeví (jen zničený tank, který ještě může hrát). */ - respawnIn?: number, - bot?: BotLevel, - /** Konečné umístění (jen ve fázi finished). */ - place?: number, -}; - -export type TankBullet = { x: number, y: number, color: number, team?: Team, angle: number, rocket: boolean }; -export type TankPickup = { x: number, y: number, type: PickupType }; -export type TankMine = { x: number, y: number, owner: string, team?: Team, color: number, armed: boolean }; -export type TankWreck = { x: number, y: number, angle: number, turret: number, color: number, team?: Team, age: number }; -export type TankExplosion = { x: number, y: number, r: number, age: number }; - -export type TankState = { - phase: TankPhase, - host?: string, - mode: TankMode, - teams: boolean, - hardcore: boolean, - /** Střely se odráží od zdí (jinak na zdi zaniknou). */ - bounce: boolean, - /** Zvolená mapa, v lobby může být 'random'. */ - mapId: string, - biome: Biome, - maps: { id: string, name: string, biome: Biome }[], - /** Aktuální dlaždice mapy (bedny mizí). */ - tiles: string[], - players: TankPlayer[], - bullets: TankBullet[], - pickups: TankPickup[], - mines: TankMine[], - wrecks: TankWreck[], - explosions: TankExplosion[], - /** Body týmů (na čas součet bodů, na přežití součet životů). */ - teamScores?: [number, number], - /** Vítězný tým po skončení, -1 = remíza. */ - winnerTeam?: number, - countdown?: number, - timeLeftMs: number, - width: number, - height: number, - tile: number, - minPlayers: number, - maxPlayers: number, -}; - -/** Nastavení hry z lobby (hodnoty přichází od klienta, ověřují se v {@link TankGame.configure}). */ -export type TankOptions = { mode?: unknown, mapId?: unknown, teams?: unknown, hardcore?: unknown, bounce?: unknown }; - -/** Tankové ovládání (`drive` + `turn`), nebo směrové — `dir` je požadovaný směr jízdy na hřišti. */ -type Input = { drive: number, turn: number, fire: boolean, dir?: [number, number] }; - -type InternalPlayer = TankPlayer & { - input: Input, - /** Bod na hřišti, kam hráč míří myší; bez něj věž sleduje směr korby. */ - aim?: [number, number], - nextFireAt: number, - respawnAt?: number, - protectedUntil: number, - shieldUntil: number, - tripleUntil: number, - turboUntil: number, - ghostUntil: number, - homingUntil: number, - /** Kdy přišel o poslední život (lastman), pro pořadí. */ - eliminatedAt?: number, - // Stav bota - botThinkAt?: number, - botAngle?: number, - botTurret?: number, - botFire?: boolean, - botLastPos?: [number, number], - botReverseUntil?: number, - /** Kam bot ve tmě prohledává mapu a do kdy. */ - botRoam?: [number, number], - botRoamUntil?: number, -}; - -type Bullet = { - owner: string, team?: Team, color: number, - x: number, y: number, vx: number, vy: number, - bornAt: number, bounces: number, rocket: boolean, homing: boolean, -}; -type Mine = { owner: string, team?: Team, color: number, x: number, y: number, armedAt: number }; -type Wreck = Omit & { at: number }; -type Explosion = Omit & { at: number }; - -/** Výchozí stav efektů a munice (při startu, zrození i zničení). */ -const NO_EFFECTS = { - shieldUntil: 0, tripleUntil: 0, turboUntil: 0, ghostUntil: 0, homingUntil: 0, rockets: 0, mines: 0, -}; - -const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v)); -/** Rozdíl úhlů v rozsahu (−π, π]. */ -const angleDiff = (a: number, b: number) => { - let d = (a - b) % (Math.PI * 2); - if (d > Math.PI) d -= Math.PI * 2; - if (d <= -Math.PI) d += Math.PI * 2; - return d; -}; -const round2 = (v: number) => Math.round(v * 100) / 100; -const isMode = (m: unknown): m is TankMode => m === 'deathmatch' || m === 'lastman'; -const isBotLevel = (l: unknown): l is BotLevel => typeof l === 'string' && l in BOT_PROFILES; -const axis = (v: unknown) => typeof v === 'number' && Number.isFinite(v) ? clamp(Math.round(v), -1, 1) : 0; - -export class TankGame { - private phase: TankPhase = 'idle'; - private host?: string; - private mode: TankMode = 'lastman'; - private teams = false; - private hardcore = false; - private bounce = false; - private mapId = 'random'; - private tiles: string[][] = []; - private players = new Map(); - private bullets: Bullet[] = []; - private pickups: TankPickup[] = []; - private mines: Mine[] = []; - private wrecks: Wreck[] = []; - private explosions: Explosion[] = []; - private winnerTeam?: number; - private countdown = 0; - private startedAt = 0; - private nextPickupAt = 0; - private lastTick = 0; - private timer: unknown; - - constructor( - private readonly broadcastAll: (state: TankState) => void, - private readonly broadcastWatchers: (state: TankState) => void, - private readonly minPlayers = 2, - private readonly clock: Clock = defaultClock, - ) { } - - getState(): TankState { - const now = this.clock.now(); - const limit = this.mode === 'deathmatch' ? DEATHMATCH_MS : LAST_MAN_MAX_MS; - const map = MAPS.find(m => m.id === this.mapId) ?? MAPS[0]; - return { - phase: this.phase, - host: this.host, - mode: this.mode, - teams: this.teams, - hardcore: this.hardcore, - bounce: this.bounce, - mapId: this.mapId, - biome: map.biome, - maps: MAPS.map(m => ({ id: m.id, name: m.name, biome: m.biome })), - tiles: this.tiles.length ? this.tiles.map(r => r.join('')) : map.rows, - players: [...this.players.values()].map(p => ({ - login: p.login, - color: p.color, - team: p.team, - x: Math.round(p.x), - y: Math.round(p.y), - angle: round2(p.angle), - turret: round2(p.turret), - alive: p.alive, - kills: p.kills, - deaths: p.deaths, - score: p.score, - lives: p.lives, - shield: p.alive && p.shieldUntil > now, - triple: p.alive && p.tripleUntil > now, - turbo: p.alive && p.turboUntil > now, - ghost: p.alive && p.ghostUntil > now, - homing: p.alive && p.homingUntil > now, - rockets: p.rockets, - mines: p.mines, - protected: p.alive && p.protectedUntil > now, - shots: p.shots, - respawnIn: !p.alive && p.respawnAt !== undefined ? Math.max(0, p.respawnAt - now) : undefined, - bot: p.bot, - place: p.place, - })), - bullets: this.bullets.map(b => ({ - x: Math.round(b.x), y: Math.round(b.y), color: b.color, team: b.team, - angle: round2(Math.atan2(b.vy, b.vx)), rocket: b.rocket, - })), - pickups: this.pickups.map(p => ({ ...p })), - mines: this.mines.map(m => ({ - x: Math.round(m.x), y: Math.round(m.y), owner: m.owner, team: m.team, color: m.color, armed: now >= m.armedAt, - })), - wrecks: this.wrecks.map(({ at, ...w }) => ({ ...w, x: Math.round(w.x), y: Math.round(w.y), age: now - at })), - explosions: this.explosions.map(({ at, ...e }) => ({ ...e, x: Math.round(e.x), y: Math.round(e.y), age: now - at })), - teamScores: this.teams ? this.teamScores() : undefined, - winnerTeam: this.winnerTeam, - countdown: this.phase === 'countdown' ? this.countdown : undefined, - timeLeftMs: this.phase === 'playing' ? Math.max(0, limit - (now - this.startedAt)) : limit, - width: FIELD_WIDTH, - height: FIELD_HEIGHT, - tile: TILE, - minPlayers: this.minPlayers, - maxPlayers: MAX_PLAYERS, - }; - } - - 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)) return; - this.addPlayer(login); - this.emitAll(); - } - - leave(login: string) { - if (this.phase !== 'lobby' || !this.players.has(login)) return; - if (login === this.host) { - this.cancel(); - return; - } - this.players.delete(login); - this.recolor(); - this.emitAll(); - } - - /** Zakladatel nastaví režim, mapu, hru v týmech, odrážení střel a hardcore (tmu); chybějící volby se nemění. */ - configure(login: string, options: TankOptions) { - const { mode, mapId, teams, hardcore, bounce } = options; - this.requireHostInLobby(login); - if (mode !== undefined) { - if (!isMode(mode)) throw new GameError('Neznámý režim.'); - this.mode = mode; - } - if (mapId !== undefined) { - if (mapId !== 'random' && !MAPS.some(m => m.id === mapId)) throw new GameError('Neznámá mapa.'); - this.mapId = mapId as string; - } - if (teams !== undefined) { - this.teams = teams === true; - // Rozdělení střídavě do týmů v pořadí příchodu - [...this.players.values()].forEach((p, i) => { p.team = this.teams ? (i % 2) as Team : undefined; }); - this.recolor(); - } - if (hardcore !== undefined) { - this.hardcore = hardcore === true; - } - if (bounce !== undefined) { - this.bounce = bounce === true; - } - this.emitAll(); - } - - /** Přesun do druhého týmu — sám sebe může přesunout každý, ostatní (i boty) zakladatel. */ - switchTeam(login: string, target: unknown) { - if (this.phase !== 'lobby') throw new GameError('Není otevřené žádné lobby.'); - if (!this.teams) throw new GameError('Nehraje se v týmech.'); - const who = typeof target === 'string' ? target : login; - if (who !== login && login !== this.host) throw new GameError('Ostatní hráče může přesouvat jen zakladatel.'); - const p = this.players.get(who); - if (!p) return; - const to: Team = p.team === 0 ? 1 : 0; - if (this.teamSize(to) >= MAX_TEAM_SIZE) throw new GameError('Druhý tým je plný.'); - p.team = to; - this.recolor(); - this.emitAll(); - } - - addBot(login: string, level: unknown) { - this.requireHostInLobby(login); - if (!isBotLevel(level)) throw new GameError('Neznámá obtížnost bota.'); - let n = 1; - while ([...this.players.keys()].some(l => l.startsWith(`🤖 Bot ${n} `))) n++; - this.addPlayer(`🤖 Bot ${n} (${BOT_PROFILES[level].label})`, level); - this.emitAll(); - } - - removeBots(login: string) { - if (this.phase !== 'lobby' || login !== this.host) return; - for (const [l, p] of this.players) { - if (p.bot) this.players.delete(l); - } - this.recolor(); - this.emitAll(); - } - - start(login: string) { - this.requireHostInLobby(login); - if (this.players.size < this.minPlayers) { - throw new GameError(`Ke hře jsou potřeba alespoň ${this.minPlayers} tanky (boti se počítají).`); - } - if (this.teams && this.minPlayers > 1 && (this.teamSize(0) === 0 || this.teamSize(1) === 0)) { - throw new GameError('V obou týmech musí být alespoň jeden tank.'); - } - const map = this.mapId === 'random' - ? MAPS[Math.floor(this.clock.random() * MAPS.length)] - : MAPS.find(m => m.id === this.mapId)!; - this.mapId = map.id; - this.tiles = map.rows.map(r => [...r]); - this.bullets = []; - this.pickups = []; - this.mines = []; - this.wrecks = []; - this.explosions = []; - this.winnerTeam = undefined; - // Rozmístění po spawnech v náhodném pořadí; týmy každý na své půlce - const order = [...this.players.values()].sort(() => this.clock.random() - 0.5); - const used = new Map(); - order.forEach(p => { - const spawns = this.spawnPoints(p.team); - const key = String(p.team); - const i = used.get(key) ?? 0; - used.set(key, i + 1); - const [x, y] = spawns[i % spawns.length]; - const angle = x < FIELD_WIDTH / 2 ? 0 : Math.PI; - Object.assign(p, { - x, y, angle, turret: angle, aim: undefined, - alive: true, kills: 0, deaths: 0, score: 0, shots: 0, - lives: this.mode === 'lastman' ? LAST_MAN_LIVES : undefined, - place: undefined, respawnAt: undefined, eliminatedAt: undefined, - input: { drive: 0, turn: 0, fire: false }, - nextFireAt: 0, protectedUntil: 0, ...NO_EFFECTS, - botThinkAt: undefined, botAngle: undefined, botTurret: undefined, botFire: false, botLastPos: undefined, botReverseUntil: undefined, - }); - }); - this.phase = 'countdown'; - this.countdown = COUNTDOWN_SECONDS; - this.emitAll(); - this.schedule(() => this.countdownStep(), 1000); - } - - /** - * Stav ovládání hráče: drive −1/0/1 (couvání/stát/jízda), turn −1/0/1 (doleva/rovně/doprava), fire. - * Směrové ovládání místo drive/turn posílá dx, dy −1/0/1 — tank se natočí a jede tím směrem. - */ - input(login: string, payload: unknown) { - const p = this.players.get(login); - if (!p || p.bot) return; - const data = (payload ?? {}) as { drive?: unknown, turn?: unknown, fire?: unknown, dx?: unknown, dy?: unknown }; - const fire = data.fire === true; - p.input = 'dx' in data || 'dy' in data - ? { drive: 0, turn: 0, fire, dir: [axis(data.dx), axis(data.dy)] } - : { drive: axis(data.drive), turn: axis(data.turn), fire }; - } - - /** Míření myší: bod na hřišti, kam se má věž natočit; `null` vrátí věž do směru jízdy. */ - aimAt(login: string, payload: unknown) { - const p = this.players.get(login); - if (!p || p.bot) return; - const { x, y } = (payload ?? {}) as { x?: unknown, y?: unknown }; - if (typeof x === 'number' && typeof y === 'number' && Number.isFinite(x) && Number.isFinite(y)) { - p.aim = [clamp(x, 0, FIELD_WIDTH), clamp(y, 0, FIELD_HEIGHT)]; - } else { - p.aim = undefined; - } - } - - /** Položí minu za tank (pokud nějakou má). */ - dropMine(login: string) { - const p = this.players.get(login); - if (!p || !p.alive || p.mines <= 0 || this.phase !== 'playing') return; - const back = TANK_RADIUS + 6; - let x = p.x - Math.cos(p.angle) * back; - let y = p.y - Math.sin(p.angle) * back; - if (this.tankSolidAt(x, y)) { - x = p.x; - y = p.y; - } - p.mines--; - this.mines.push({ owner: p.login, team: p.team, color: p.color, x, y, armedAt: this.clock.now() + MINE_ARM_MS }); - } - - private requireHostInLobby(login: string) { - if (this.phase !== 'lobby') throw new GameError('Není otevřené žádné lobby.'); - if (login !== this.host) throw new GameError('To může jen ten, kdo lobby založil.'); - } - - private addPlayer(login: string, bot?: BotLevel) { - if (this.players.size >= MAX_PLAYERS) throw new GameError('Lobby je plné.'); - const team: Team | undefined = this.teams ? (this.teamSize(1) < this.teamSize(0) ? 1 : 0) : undefined; - this.players.set(login, { - login, color: 0, team, x: 0, y: 0, angle: 0, turret: 0, alive: true, kills: 0, deaths: 0, score: 0, - shield: false, triple: false, turbo: false, ghost: false, homing: false, protected: false, - shots: 0, bot, - input: { drive: 0, turn: 0, fire: false }, nextFireAt: 0, protectedUntil: 0, ...NO_EFFECTS, - }); - this.recolor(); - } - - private teamSize(team: Team) { - return [...this.players.values()].filter(p => p.team === team).length; - } - - /** Barvy podle pořadí — v týmech pořadí v rámci týmu (klient má pro každý tým vlastní paletu). */ - private recolor() { - const counts = [0, 0, 0]; - for (const p of this.players.values()) { - const slot = p.team ?? 2; - p.color = counts[slot]++; - } - } - - private isEnemy(a: { login: string, team?: Team }, b: { login: string, team?: Team }) { - return a.login !== b.login && (!this.teams || a.team !== b.team); - } - - /** Místa zrození; pro tým jen na jeho půlce mapy (tým 0 vlevo). */ - private spawnPoints(team?: Team): [number, number][] { - const points: [number, number][] = []; - this.tiles.forEach((row, r) => row.forEach((c, col) => { - if (c === 'S') points.push([(col + 0.5) * TILE, (r + 0.5) * TILE]); - })); - if (team === undefined || !this.teams) return points; - return points.filter(([x]) => (x < FIELD_WIDTH / 2) === (team === 0)); - } - - private countdownStep() { - this.countdown--; - if (this.countdown > 0) { - this.emitAll(); - this.schedule(() => this.countdownStep(), 1000); - return; - } - const now = this.clock.now(); - this.phase = 'playing'; - this.startedAt = now; - this.lastTick = now; - this.nextPickupAt = now + PICKUP_INTERVAL_MS; - for (const p of this.players.values()) p.protectedUntil = now + SPAWN_PROTECTION_MS; - this.emitAll(); - this.schedule(() => this.step(), TICK_MS); - } - - /** Jeden krok simulace. */ - 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.values()) { - if (!p.alive) { - if (p.respawnAt !== undefined && now >= p.respawnAt) this.respawn(p, now); - continue; - } - if (p.bot) this.driveBot(p, now, dt); - this.moveTank(p, dt, now); - this.rotateTurret(p, dt); - this.collectPickups(p, now); - if (p.input.fire && now >= p.nextFireAt) this.fire(p, now); - } - this.moveBullets(dt, now); - this.triggerMines(now); - this.spawnPickup(now); - this.wrecks = this.wrecks.filter(w => now - w.at < WRECK_MS); - this.explosions = this.explosions.filter(e => now - e.at < EXPLOSION_MS); - - if (this.isOver(now)) { - this.finish(); - return; - } - this.emitWatchers(); - this.schedule(() => this.step(), TICK_MS); - } - - private moveTank(p: InternalPlayer, dt: number, now: number) { - let drive = p.input.drive; - if (p.input.dir) { - const [dx, dy] = p.input.dir; - if (!dx && !dy) return; - // Korba se rovnou otočí do zvoleného směru a tank jede - p.angle = Math.atan2(dy, dx); - drive = 1; - } else { - p.angle = (p.angle + p.input.turn * TANK_TURN_SPEED * dt) % (Math.PI * 2); - } - if (!drive) return; - let speed = drive > 0 ? TANK_SPEED : TANK_REVERSE_SPEED; - if (p.turboUntil > now) speed *= TURBO_FACTOR; - const ground = this.tileAtPos(p.x, p.y); - if (ground === ':' || ground === '%') speed *= MUD_FACTOR; - const dist = drive * speed * dt; - // Po osách zvlášť, aby tank klouzal podél zdí - const nx = p.x + Math.cos(p.angle) * dist; - if (!this.tankBlocked(p, nx, p.y)) p.x = nx; - const ny = p.y + Math.sin(p.angle) * dist; - if (!this.tankBlocked(p, p.x, ny)) p.y = ny; - } - - /** Věž se dotáčí k cíli (myš, bot), jinak do směru jízdy. */ - private rotateTurret(p: InternalPlayer, dt: number) { - const goal = p.bot - ? p.botTurret ?? p.angle - : p.aim ? Math.atan2(p.aim[1] - p.y, p.aim[0] - p.x) : p.angle; - const step = TURRET_TURN_SPEED * dt; - p.turret = (p.turret + clamp(angleDiff(goal, p.turret), -step, step)) % (Math.PI * 2); - } - - private tankBlocked(p: InternalPlayer, x: number, y: number) { - if (this.circleHitsTiles(x, y, TANK_RADIUS, true)) return true; - for (const o of this.players.values()) { - if (o === p || !o.alive) continue; - const overlapNow = Math.hypot(o.x - p.x, o.y - p.y) < TANK_RADIUS * 2; - // Překrývající se tanky (po zrození) se smí rozjet od sebe - if (Math.hypot(o.x - x, o.y - y) < TANK_RADIUS * 2 && !overlapNow) return true; - } - return false; - } - - /** Zda kruh zasahuje do pevné dlaždice; pro tanky je pevná i voda. */ - private circleHitsTiles(x: number, y: number, r: number, forTank: boolean) { - if (x - r < 0 || y - r < 0 || x + r > FIELD_WIDTH || y + r > FIELD_HEIGHT) return true; - for (let row = Math.floor((y - r) / TILE); row <= Math.floor((y + r) / TILE); row++) { - for (let col = Math.floor((x - r) / TILE); col <= Math.floor((x + r) / TILE); col++) { - if (!(forTank ? this.tankSolidTile(col, row) : this.solidTile(col, row))) continue; - const cx = clamp(x, col * TILE, (col + 1) * TILE); - const cy = clamp(y, row * TILE, (row + 1) * TILE); - if ((x - cx) ** 2 + (y - cy) ** 2 < r * r) return true; - } - } - return false; - } - - private tileAt(col: number, row: number) { - if (col < 0 || row < 0 || col >= COLS || row >= ROWS) return '#'; - return this.tiles[row][col]; - } - - private tileAtPos(x: number, y: number) { - return this.tiles.length ? this.tileAt(Math.floor(x / TILE), Math.floor(y / TILE)) : '.'; - } - - /** Pevné pro střely (zeď, bedna). */ - private solidTile(col: number, row: number) { - const t = this.tileAt(col, row); - return t === '#' || t === '+'; - } - - /** Neprůjezdné pro tanky (navíc voda). */ - private tankSolidTile(col: number, row: number) { - return this.solidTile(col, row) || this.tileAt(col, row) === '~'; - } - - private solidAt(x: number, y: number) { - return this.solidTile(Math.floor(x / TILE), Math.floor(y / TILE)); - } - - private tankSolidAt(x: number, y: number) { - return this.tankSolidTile(Math.floor(x / TILE), Math.floor(y / TILE)); - } - - private breakCrate(x: number, y: number) { - const col = Math.floor(x / TILE); - const row = Math.floor(y / TILE); - if (this.tileAt(col, row) !== '+') return false; - this.tiles[row][col] = '.'; - return true; - } - - private fire(p: InternalPlayer, now: number) { - const triple = p.tripleUntil > now; - const own = this.bullets.filter(b => b.owner === p.login).length; - if (own >= (triple ? MAX_BULLETS * 3 : MAX_BULLETS)) return; - const rocket = p.rockets > 0; - const cooldown = rocket ? FIRE_COOLDOWN_MS * 1.5 : FIRE_COOLDOWN_MS; - const offset = TANK_RADIUS + BULLET_RADIUS + 2; - const sx = p.x + Math.cos(p.turret) * offset; - const sy = p.y + Math.sin(p.turret) * offset; - if (this.solidAt(sx, sy)) { - // Hlaveň v bedně ji rozbije rovnou (raketa vybuchne), do zdi se nestřílí (jinak by šlo pálit skrz) - if (this.tileAtPos(sx, sy) !== '+') return; - p.nextFireAt = now + cooldown; - p.shots++; - if (rocket) { - p.rockets--; - this.explode(sx, sy, p.login, p.team, now); - } else { - this.breakCrate(sx, sy); - } - return; - } - p.nextFireAt = now + cooldown; - p.shots++; - if (rocket) p.rockets--; - const speed = rocket ? ROCKET_SPEED : BULLET_SPEED; - for (const a of triple ? [-TRIPLE_SPREAD, 0, TRIPLE_SPREAD] : [0]) { - const angle = p.turret + a; - this.bullets.push({ - owner: p.login, team: p.team, color: p.color, x: sx, y: sy, - vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, - bornAt: now, bounces: 0, rocket, homing: p.homingUntil > now, - }); - } - } - - private moveBullets(dt: number, now: number) { - // Kopie — výbuch rakety může během pohybu odstranit i jiné střely nebo přidat výbuchy - for (const b of [...this.bullets]) { - if (!this.bullets.includes(b)) continue; - let alive = now - b.bornAt < BULLET_LIFETIME_MS; - if (alive) { - if (b.homing) this.steerHoming(b, dt); - alive = this.moveBullet(b, dt, now); - } else if (b.rocket) { - this.explode(b.x, b.y, b.owner, b.team, now); - } - if (!alive) this.bullets = this.bullets.filter(x => x !== b); - } - } - - /** Naváděná střela se stáčí k nejbližšímu viditelnému soupeři v dosahu. */ - private steerHoming(b: Bullet, dt: number) { - let best: InternalPlayer | undefined; - let bestDist = HOMING_RANGE; - for (const p of this.players.values()) { - if (!p.alive || !this.isEnemy({ login: b.owner, team: b.team }, p)) continue; - const d = Math.hypot(p.x - b.x, p.y - b.y); - if (d < bestDist) { - best = p; - bestDist = d; - } - } - if (!best) return; - const speed = Math.hypot(b.vx, b.vy); - const current = Math.atan2(b.vy, b.vx); - const step = HOMING_TURN_SPEED * dt; - const angle = current + clamp(angleDiff(Math.atan2(best.y - b.y, best.x - b.x), current), -step, step); - b.vx = Math.cos(angle) * speed; - b.vy = Math.sin(angle) * speed; - } - - /** Posune střelu po malých krocích (aby nepřeletěla zeď ani tank); vrací, zda přežila. */ - private moveBullet(b: Bullet, dt: number, now: number): boolean { - const total = Math.hypot(b.vx, b.vy) * dt; - const steps = Math.max(1, Math.ceil(total / 8)); - for (let i = 0; i < steps; i++) { - const nx = b.x + b.vx * dt / steps; - const ny = b.y + b.vy * dt / steps; - if (this.solidAt(nx, ny)) { - if (b.rocket) { - this.explode(b.x, b.y, b.owner, b.team, now); - return false; - } - // Bedna střelu zastaví a rozpadne se - for (const [x, y] of [[nx, ny], [nx, b.y], [b.x, ny]]) { - if (this.breakCrate(x, y)) return false; - } - if (!this.bounce || ++b.bounces > BULLET_MAX_BOUNCES) return false; - const hitX = this.solidAt(nx, b.y); - const hitY = this.solidAt(b.x, ny); - if (hitX || !hitY) b.vx = -b.vx; - if (hitY || !hitX) b.vy = -b.vy; - continue; - } - b.x = nx; - b.y = ny; - // Zásah miny ji odpálí - const mine = this.mines.find(m => Math.hypot(m.x - b.x, m.y - b.y) < 8 + BULLET_RADIUS); - if (mine) { - this.detonateMine(mine, now); - if (b.rocket) this.explode(b.x, b.y, b.owner, b.team, now); - return false; - } - for (const p of this.players.values()) { - if (!p.alive || p.protectedUntil > now) continue; - // Spoluhráčem střela proletí, střelce samotného (po odrazu) zasáhne - if (p.login !== b.owner && !this.isEnemy({ login: b.owner, team: b.team }, p)) continue; - if (Math.hypot(p.x - b.x, p.y - b.y) >= TANK_RADIUS + BULLET_RADIUS) continue; - if (b.rocket) { - this.explode(b.x, b.y, b.owner, b.team, now); - } else { - this.damage(p, b.owner, now); - } - return false; - } - } - return true; - } - - /** Výbuch rakety / miny: rozbije bedny a zasáhne tanky v okolí (kromě spoluhráčů), odpálí miny. */ - private explode(x: number, y: number, owner: string, team: Team | undefined, now: number) { - this.explosions.push({ x, y, r: BLAST_RADIUS, at: now }); - for (let row = Math.floor((y - BLAST_RADIUS) / TILE); row <= Math.floor((y + BLAST_RADIUS) / TILE); row++) { - for (let col = Math.floor((x - BLAST_RADIUS) / TILE); col <= Math.floor((x + BLAST_RADIUS) / TILE); col++) { - if (this.tileAt(col, row) !== '+') continue; - const cx = clamp(x, col * TILE, (col + 1) * TILE); - const cy = clamp(y, row * TILE, (row + 1) * TILE); - if (Math.hypot(x - cx, y - cy) < BLAST_RADIUS * 0.8) this.tiles[row][col] = '.'; - } - } - for (const p of this.players.values()) { - if (!p.alive || p.protectedUntil > now) continue; - if (p.login !== owner && !this.isEnemy({ login: owner, team }, p)) continue; - if (Math.hypot(p.x - x, p.y - y) < BLAST_RADIUS + TANK_RADIUS / 2) this.damage(p, owner, now); - } - for (const m of this.mines.filter(m => Math.hypot(m.x - x, m.y - y) < BLAST_RADIUS)) { - this.detonateMine(m, now); - } - } - - private detonateMine(mine: Mine, now: number) { - if (!this.mines.includes(mine)) return; - this.mines = this.mines.filter(m => m !== mine); - this.explode(mine.x, mine.y, mine.owner, mine.team, now); - } - - /** Aktivní mina vybuchne pod soupeřem (vlastní tým ji přejede bez újmy). */ - private triggerMines(now: number) { - for (const m of [...this.mines]) { - if (now < m.armedAt) continue; - const victim = [...this.players.values()].find(p => p.alive && p.protectedUntil <= now - && this.isEnemy({ login: m.owner, team: m.team }, p) - && Math.hypot(p.x - m.x, p.y - m.y) < MINE_TRIGGER_RADIUS); - if (victim) this.detonateMine(m, now); - } - } - - /** Zásah: štít ho pohltí, jinak je tank zničen. */ - private damage(victim: InternalPlayer, killerLogin: string, now: number) { - if (!victim.alive) return; - if (victim.shieldUntil > now) { - victim.shieldUntil = 0; - return; - } - this.kill(victim, killerLogin, now); - } - - private kill(victim: InternalPlayer, killerLogin: string, now: number) { - victim.alive = false; - victim.deaths++; - Object.assign(victim, NO_EFFECTS); - victim.input = { ...victim.input, fire: false }; - this.wrecks.push({ x: victim.x, y: victim.y, angle: victim.angle, turret: victim.turret, color: victim.color, team: victim.team, at: now }); - this.wrecks = this.wrecks.slice(-12); - this.explosions.push({ x: victim.x, y: victim.y, r: 30, at: now }); - const killer = this.players.get(killerLogin); - if (killer === victim) { - victim.score--; - } else if (killer) { - killer.kills++; - killer.score++; - } - if (victim.lives !== undefined) { - victim.lives--; - } - if (victim.lives === 0) { - victim.respawnAt = undefined; - victim.eliminatedAt = now; - } else { - victim.respawnAt = now + RESPAWN_MS; - } - } - - /** Znovuzrození na spawnu (v týmech na své půlce) co nejdál od soupeřů. */ - private respawn(p: InternalPlayer, now: number) { - const alive = [...this.players.values()].filter(o => o.alive); - const enemies = alive.filter(o => this.isEnemy(p, o)); - const isFree = ([x, y]: [number, number]) => alive.every(o => Math.hypot(o.x - x, o.y - y) >= TANK_RADIUS * 2); - let free = this.spawnPoints(p.team).filter(isFree); - if (!free.length) free = this.spawnPoints().filter(isFree); - if (!free.length) return; // zkusí se v dalším ticku - const distance = ([x, y]: [number, number]) => Math.min(Infinity, ...enemies.map(o => Math.hypot(o.x - x, o.y - y))); - const [x, y] = free.reduce((best, s) => distance(s) > distance(best) ? s : best); - const angle = x < FIELD_WIDTH / 2 ? 0 : Math.PI; - Object.assign(p, { - x, y, angle, turret: angle, alive: true, respawnAt: undefined, - protectedUntil: now + SPAWN_PROTECTION_MS, nextFireAt: now, - botThinkAt: undefined, botLastPos: undefined, botReverseUntil: undefined, - }); - } - - private collectPickups(p: InternalPlayer, now: number) { - this.pickups = this.pickups.filter(pk => { - if (Math.hypot(pk.x - p.x, pk.y - p.y) >= TANK_RADIUS + PICKUP_RADIUS) return true; - switch (pk.type) { - case 'shield': p.shieldUntil = now + SHIELD_MS; break; - case 'triple': p.tripleUntil = now + TRIPLE_SHOT_MS; break; - case 'turbo': p.turboUntil = now + TURBO_MS; break; - case 'ghost': p.ghostUntil = now + GHOST_MS; break; - case 'homing': p.homingUntil = now + HOMING_MS; break; - case 'rocket': p.rockets = Math.min(MAX_AMMO, p.rockets + ROCKET_AMMO); break; - case 'mine': p.mines = Math.min(MAX_AMMO, p.mines + MINE_AMMO); break; - case 'heart': p.lives = Math.min(LAST_MAN_LIVES, (p.lives ?? 0) + 1); break; - } - return false; - }); - } - - /** Občas se na volném místě (dál od tanků) objeví bonus; život jen ve hře na přežití. */ - private spawnPickup(now: number) { - if (now < this.nextPickupAt) return; - this.nextPickupAt = now + PICKUP_INTERVAL_MS; - if (this.pickups.length >= MAX_PICKUPS) return; - const types: PickupType[] = ['shield', 'triple', 'rocket', 'turbo', 'ghost', 'homing', 'mine']; - if (this.mode === 'lastman') types.push('heart'); - for (let attempt = 0; attempt < 50; attempt++) { - const col = Math.floor(this.clock.random() * COLS); - const row = Math.floor(this.clock.random() * ROWS); - const x = (col + 0.5) * TILE; - const y = (row + 0.5) * TILE; - if (this.tileAt(col, row) !== '.') continue; - if ([...this.players.values()].some(p => p.alive && Math.hypot(p.x - x, p.y - y) < TILE * 3)) continue; - if (this.pickups.some(pk => pk.x === x && pk.y === y)) continue; - this.pickups.push({ x, y, type: types[Math.floor(this.clock.random() * types.length)] }); - return; - } - } - - /** Body týmů: na čas součet bodů, na přežití součet zbylých životů. */ - private teamScores(): [number, number] { - const scores: [number, number] = [0, 0]; - for (const p of this.players.values()) { - if (p.team === undefined) continue; - scores[p.team] += this.mode === 'deathmatch' ? p.score : (p.lives ?? 0); - } - return scores; - } - - private isOver(now: number) { - const elapsed = now - this.startedAt; - if (this.mode === 'deathmatch') return elapsed >= DEATHMATCH_MS; - if (elapsed >= LAST_MAN_MAX_MS) return true; - const remaining = [...this.players.values()].filter(p => (p.lives ?? 0) > 0); - if (remaining.length === 0) return true; - if (this.teams) { - const teamsLeft = new Set(remaining.map(p => p.team)).size; - const teamsPlaying = new Set([...this.players.values()].map(p => p.team)).size; - return teamsPlaying > 1 && teamsLeft <= 1; - } - return this.players.size > 1 && remaining.length <= 1; - } - - private finish() { - const individual = (a: InternalPlayer, b: InternalPlayer) => this.mode === 'deathmatch' - ? b.score - a.score || a.deaths - b.deaths - : (b.lives ?? 0) - (a.lives ?? 0) - || (b.eliminatedAt ?? Infinity) - (a.eliminatedAt ?? Infinity) - || b.kills - a.kills; - if (this.teams) { - const [a, b] = this.teamScores(); - this.winnerTeam = a === b ? -1 : a > b ? 0 : 1; - } - const winner = this.winnerTeam; - const ranked = [...this.players.values()].sort((a, b) => - (winner !== undefined && winner >= 0 ? Number(b.team === winner) - Number(a.team === winner) : 0) || individual(a, b)); - ranked.forEach((p, i) => { p.place = i + 1; }); - this.bullets = []; - this.phase = 'finished'; - this.clearTimer(); - this.emitAll(); - } - - // --- Boti --- - - /** - * Bot v intervalu reakční doby vybere nejbližšího viditelného soupeře. Když na něj vidí, míří věží - * a střílí; jinak jede po nejkratší cestě v mřížce (bedny po cestě rozstřílí). Občas položí minu. - * Když se zasekne, chvíli couvá. - */ - private driveBot(p: InternalPlayer, now: number, dt: number) { - const profile = BOT_PROFILES[p.bot!]; - if (p.botThinkAt === undefined || now >= p.botThinkAt) { - p.botThinkAt = now + profile.reactMs; - this.botThink(p, now, profile); - } - if (p.botReverseUntil !== undefined && now < p.botReverseUntil) { - p.input = { drive: -1, turn: p.input.turn, fire: false }; - return; - } - const target = p.botAngle ?? p.angle; - const diff = angleDiff(target, p.angle); - // Plynulé dotočení přesně na cíl, ať bot nekmitá kolem úhlu - const turn = clamp(diff / (TANK_TURN_SPEED * Math.max(dt, 0.001)), -1, 1); - const aligned = Math.abs(angleDiff(p.botTurret ?? p.angle, p.turret)) < profile.fireTolerance; - p.input = { - drive: p.input.drive, - turn, - fire: !!p.botFire && aligned, - }; - } - - private botThink(p: InternalPlayer, now: number, profile: BotProfile) { - // Detekce zaseknutí: chtěl jet, ale skoro se nepohnul - if (p.botLastPos && p.input.drive !== 0 && Math.hypot(p.x - p.botLastPos[0], p.y - p.botLastPos[1]) < 2) { - p.botReverseUntil = now + 400; - p.input = { drive: -1, turn: this.clock.random() < 0.5 ? -1 : 1, fire: false }; - p.botLastPos = undefined; - return; - } - p.botLastPos = [p.x, p.y]; - - const dist = (o: InternalPlayer) => Math.hypot(o.x - p.x, o.y - p.y); - const visible = (o: InternalPlayer) => - (o.ghostUntil <= now || dist(o) < REVEAL_DISTANCE) && this.litFor(p, o.x, o.y); - const enemies = [...this.players.values()].filter(o => o.alive && this.isEnemy(p, o) && visible(o)); - const target = enemies.sort((a, b) => dist(a) - dist(b))[0]; - const error = (this.clock.random() * 2 - 1) * profile.aimError; - - if (target && p.mines > 0 && dist(target) < 220 && this.clock.random() < 0.15) { - this.dropMine(p.login); - } - - if (target && this.lineOfSight(p.x, p.y, target.x, target.y)) { - const d = dist(target); - // S raketou nestřílet zblízka, výbuch by zasáhl i bota - const tooClose = p.rockets > 0 && d < BLAST_RADIUS + 30; - p.botAngle = Math.atan2(target.y - p.y, target.x - p.x); - p.botTurret = p.botAngle + error; - p.botFire = target.protectedUntil <= now && !tooClose; - p.input = { ...p.input, drive: tooClose || d < 110 ? -1 : d > 260 ? 1 : 0 }; - return; - } - - // Bez výhledu: k bonusu, pokud je blízko, jinak za soupeřem; ve tmě o soupeři neví, tak prohledává mapu - const pickup = this.pickups.find(pk => Math.hypot(pk.x - p.x, pk.y - p.y) < TILE * 6 && this.litFor(p, pk.x, pk.y)); - const goal = pickup ?? target ?? (this.hardcore ? this.roamGoal(p, now) : undefined); - if (!goal) { - p.botFire = false; - p.input = { ...p.input, drive: 0 }; - return; - } - const next = this.nextStep(p.x, p.y, goal.x, goal.y); - if (!next) { - p.botFire = false; - p.input = { ...p.input, drive: 0 }; - return; - } - const [col, row] = next; - const wx = (col + 0.5) * TILE; - const wy = (row + 0.5) * TILE; - p.botAngle = Math.atan2(wy - p.y, wx - p.x); - p.botTurret = p.botAngle; - if (this.tileAt(col, row) === '+') { - // Bedna v cestě — rozstřílet (raketou jen z bezpečné vzdálenosti) - const tooClose = p.rockets > 0 && Math.hypot(wx - p.x, wy - p.y) < BLAST_RADIUS + 30; - p.botFire = !tooClose; - p.input = { ...p.input, drive: tooClose ? -1 : 0 }; - } else { - p.botFire = false; - p.input = { ...p.input, drive: Math.abs(angleDiff(p.botAngle, p.angle)) < 0.6 ? 1 : 0 }; - } - } - - /** Zda je bod vidět pro hráče — bez hardcore vždy, jinak ve světle jeho tanku nebo spoluhráčů. */ - private litFor(viewer: InternalPlayer, x: number, y: number) { - if (!this.hardcore) return true; - for (const src of this.players.values()) { - if (!src.alive || (src !== viewer && this.isEnemy(viewer, src))) continue; - const d = Math.hypot(x - src.x, y - src.y); - const inCone = d <= SPOTLIGHT_RANGE && Math.abs(angleDiff(Math.atan2(y - src.y, x - src.x), src.turret)) <= SPOTLIGHT_HALF_ANGLE; - if ((d <= LIGHT_RADIUS || inCone) && this.lineOfSight(src.x, src.y, x, y)) return true; - } - return false; - } - - /** Náhodné průjezdné místo, kam bot ve tmě jede hledat soupeře; mění se po dojetí nebo po chvíli. */ - private roamGoal(p: InternalPlayer, now: number) { - const reached = p.botRoam && Math.hypot(p.botRoam[0] - p.x, p.botRoam[1] - p.y) < TILE; - if (!p.botRoam || reached || now >= (p.botRoamUntil ?? 0)) { - p.botRoam = undefined; - for (let attempt = 0; attempt < 50 && !p.botRoam; attempt++) { - const col = Math.floor(this.clock.random() * COLS); - const row = Math.floor(this.clock.random() * ROWS); - const t = this.tileAt(col, row); - if (t !== '#' && t !== '~' && t !== '+') p.botRoam = [(col + 0.5) * TILE, (row + 0.5) * TILE]; - } - p.botRoamUntil = now + 8000; - } - return p.botRoam ? { x: p.botRoam[0], y: p.botRoam[1] } : undefined; - } - - private lineOfSight(x1: number, y1: number, x2: number, y2: number) { - const dist = Math.hypot(x2 - x1, y2 - y1); - const steps = Math.ceil(dist / 8); - for (let i = 1; i < steps; i++) { - const t = i / steps; - if (this.solidAt(x1 + (x2 - x1) * t, y1 + (y2 - y1) * t)) return false; - } - return true; - } - - /** Další dlaždice na nejkratší cestě (BFS po mřížce; bedny jsou průjezdné — dají se rozstřílet, voda ne). */ - private nextStep(fx: number, fy: number, tx: number, ty: number): [number, number] | undefined { - const start: [number, number] = [Math.floor(fx / TILE), Math.floor(fy / TILE)]; - const goal: [number, number] = [Math.floor(tx / TILE), Math.floor(ty / TILE)]; - if (start[0] === goal[0] && start[1] === goal[1]) return goal; - const prev = new Map(); - const id = (c: number, r: number) => r * COLS + c; - const queue: [number, number][] = [start]; - prev.set(id(...start), -1); - while (queue.length) { - const [c, r] = queue.shift()!; - if (c === goal[0] && r === goal[1]) break; - for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - const nc = c + dc; - const nr = r + dr; - const t = this.tileAt(nc, nr); - if (t === '#' || t === '~' || prev.has(id(nc, nr))) continue; - prev.set(id(nc, nr), id(c, r)); - queue.push([nc, nr]); - } - } - let cur = id(...goal); - if (!prev.has(cur)) return undefined; - while (prev.get(cur) !== id(...start)) { - cur = prev.get(cur)!; - if (cur === -1) return undefined; - } - return [cur % COLS, Math.floor(cur / COLS)]; - } - - // --- Režie --- - - private cancel() { - this.reset(); - this.emitAll(); - } - - private reset() { - this.clearTimer(); - this.phase = 'idle'; - this.host = undefined; - this.players.clear(); - this.tiles = []; - this.bullets = []; - this.pickups = []; - this.mines = []; - this.wrecks = []; - this.explosions = []; - this.winnerTeam = undefined; - this.mapId = 'random'; - this.mode = 'lastman'; - this.teams = false; - this.hardcore = false; - this.bounce = false; - } - - 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: přesune tank a natočí ho. */ - placeForTest(login: string, x: number, y: number, angle: number) { - const p = this.players.get(login); - if (!p) return; - Object.assign(p, { x, y, angle, turret: angle, aim: undefined, protectedUntil: 0 }); - } - - /** Pro testy: položí bonus na dané místo. */ - addPickupForTest(x: number, y: number, type: PickupType) { - this.pickups.push({ x, y, type }); - } -} - -/** Zaregistruje websocket handlery tanků. */ -export function registerTankGame(io: Server) { - const game = new TankGame( - state => io.emit('tanks:state', state), - state => io.to(WATCH_ROOM).emit('tanks:state', state), - minPlayersForEnv(2), - ); - - io.on('connection', socket => { - const handle = (action: (login: string, payload: unknown) => void) => gameHandler(socket, 'tanks:error', action); - socket.on('tanks:sync', () => socket.emit('tanks:state', game.getState())); - socket.on('tanks:watch', () => { - socket.join(WATCH_ROOM); - socket.emit('tanks:state', game.getState()); - }); - socket.on('tanks:unwatch', () => socket.leave(WATCH_ROOM)); - socket.on('tanks:join', handle(login => game.join(login))); - socket.on('tanks:leave', handle(login => game.leave(login))); - socket.on('tanks:start', handle(login => game.start(login))); - socket.on('tanks:configure', handle((login, payload) => game.configure(login, (payload ?? {}) as TankOptions))); - socket.on('tanks:team', handle((login, payload) => game.switchTeam(login, (payload as { target?: unknown })?.target))); - socket.on('tanks:addBot', handle((login, payload) => game.addBot(login, (payload as { level?: unknown })?.level))); - socket.on('tanks:removeBots', handle(login => game.removeBots(login))); - socket.on('tanks:input', handle((login, payload) => game.input(login, payload))); - socket.on('tanks:aim', handle((login, payload) => game.aimAt(login, payload))); - socket.on('tanks:mine', handle(login => game.dropMine(login))); - }); -} diff --git a/server/src/tests/pongGame.test.ts b/server/src/tests/pongGame.test.ts deleted file mode 100644 index eda24e0..0000000 --- a/server/src/tests/pongGame.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -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 deleted file mode 100644 index 904bf31..0000000 --- a/server/src/tests/reactionDuel.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -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 deleted file mode 100644 index 0acf9b5..0000000 --- a/server/src/tests/snakeGame.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -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/tankGame.test.ts b/server/src/tests/tankGame.test.ts deleted file mode 100644 index 019e836..0000000 --- a/server/src/tests/tankGame.test.ts +++ /dev/null @@ -1,451 +0,0 @@ -import { - TankGame, TankState, MAPS, COLS, ROWS, TICK_MS, COUNTDOWN_SECONDS, RESPAWN_MS, DEATHMATCH_MS, - LAST_MAN_LIVES, MAX_PLAYERS, TANK_RADIUS, ROCKET_AMMO, MINE_AMMO, MINE_ARM_MS, FIELD_WIDTH, -} from '../tankGame'; -import { GameError } from '../minigames'; -import { fakeClock } from './helpers/fakeClock'; - -/** Deterministická pseudonáhoda pro boty. */ -function lcg(seed = 1) { - let x = seed; - return () => (x = (x * 1103515245 + 12345) % 2147483648) / 2147483648; -} - -function setup(random?: () => number) { - const clock = fakeClock(random); - const states: TankState[] = []; - const game = new TankGame(s => states.push(s), s => states.push(s), 2, clock); - const last = () => states[states.length - 1]; - const player = (login: string) => last().players.find(p => p.login === login)!; - /** Odrážení je ve výchozím stavu vypnuté, testy ho zapínají — sebezničení odraženou střelou je nejjednodušší zásah. */ - const startGame = (mode: 'deathmatch' | 'lastman' = 'deathmatch', all = ['alice', 'bob'], map = 'arena', teams = false, bounce = true) => { - all.forEach(l => game.join(l)); - game.configure(all[0], { mode, mapId: map, teams, bounce }); - game.start(all[0]); - clock.advance(COUNTDOWN_SECONDS * 1000); - }; - const ticks = (n: number) => clock.advance(n * TICK_MS); - /** Jeden výstřel: stisk na jeden tick a puštění. */ - const shoot = (login: string) => { - game.input(login, { fire: true }); - ticks(1); - game.input(login, { fire: false }); - }; - return { game, clock, last, player, startGame, ticks, shoot }; -} - -describe('tanky', () => { - test('mapy mají správný rozměr, dost spawnů a všechna volná místa jsou propojená', () => { - for (const map of MAPS) { - expect(map.rows).toHaveLength(ROWS); - map.rows.forEach(r => expect(r).toHaveLength(COLS)); - map.rows.forEach(r => expect(r).toMatch(/^[.#+~:%S]+$/)); - // Každý tým má na své půlce dost spawnů - const leftSpawns = map.rows.join('').split('').filter((c, i) => c === 'S' && i % COLS < COLS / 2).length; - expect(leftSpawns).toBeGreaterThanOrEqual(MAX_PLAYERS / 2); - // Bedny se dají rozstřílet, takže se počítají jako průjezdné; zeď a voda ne - const blocked = (c: string) => c === '#' || c === '~'; - const open: [number, number][] = []; - map.rows.forEach((r, y) => [...r].forEach((c, x) => { if (!blocked(c)) open.push([x, y]); })); - const seen = new Set([open[0].join()]); - const queue = [open[0]]; - while (queue.length) { - const [x, y] = queue.shift()!; - for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - const nx = x + dx, ny = y + dy; - if (map.rows[ny]?.[nx] === undefined || blocked(map.rows[ny][nx]) || seen.has(`${nx},${ny}`)) continue; - seen.add(`${nx},${ny}`); - queue.push([nx, ny]); - } - } - expect(seen.size).toBe(open.length); - } - }); - - test('hru spouští zakladatel a potřebuje alespoň dva tanky (boti se počítají)', () => { - const ctx = setup(); - ctx.game.join('alice'); - expect(() => ctx.game.start('alice')).toThrow(GameError); - expect(() => ctx.game.addBot('bob', 'easy')).toThrow(GameError); - ctx.game.addBot('alice', 'easy'); - expect(ctx.last().players.map(p => p.login)).toContain('🤖 Bot 1 (Lehký)'); - ctx.game.start('alice'); - expect(ctx.last().phase).toBe('countdown'); - expect(() => ctx.game.join('bob')).toThrow(GameError); - }); - - test('tank jede kupředu a zastaví se o okraj hřiště', () => { - const ctx = setup(); - ctx.startGame(); - ctx.game.placeForTest('alice', 100, 60, Math.PI); - ctx.game.input('alice', { drive: 1 }); - ctx.ticks(30); - expect(ctx.player('alice').x).toBe(TANK_RADIUS); - expect(ctx.player('alice').y).toBe(60); - }); - - test('nesmyslné ovládání se ořízne', () => { - const ctx = setup(); - ctx.startGame(); - ctx.game.placeForTest('alice', 300, 60, 0); - ctx.game.input('alice', { drive: 5, turn: 'x' }); - ctx.ticks(10); - expect(ctx.player('alice').x).toBeGreaterThan(300); - expect(ctx.player('alice').angle).toBe(0); - }); - - test('směrové ovládání jede rovnou zvoleným směrem', () => { - const ctx = setup(); - ctx.startGame(); - ctx.game.placeForTest('alice', 300, 300, 0); - ctx.game.input('alice', { dx: 0, dy: -1 }); - ctx.ticks(10); - const p = ctx.player('alice'); - expect(p.angle).toBeCloseTo(-Math.PI / 2, 2); - expect(p.x).toBeCloseTo(300, 0); - expect(p.y).toBeLessThan(300); - }); - - test('směrové ovládání bez stisknutého směru tank zastaví a nechá natočený', () => { - const ctx = setup(); - ctx.startGame(); - ctx.game.placeForTest('alice', 300, 300, 0); - ctx.game.input('alice', { dx: -1, dy: 0 }); - ctx.ticks(1); - ctx.game.input('alice', { dx: 0, dy: 0 }); - const { x, angle } = ctx.player('alice'); - expect(x).toBeLessThan(300); - ctx.ticks(5); - expect(ctx.player('alice').angle).toBe(angle); - expect(ctx.player('alice').x).toBe(x); - }); - - test('zásah zničí soupeře, přidá bod a soupeř se po chvíli znovu objeví', () => { - const ctx = setup(); - ctx.startGame(); - ctx.game.placeForTest('alice', 100, 60, 0); - ctx.game.placeForTest('bob', 300, 60, 0); - ctx.shoot('alice'); - ctx.ticks(30); - expect(ctx.player('alice')).toMatchObject({ score: 1, kills: 1 }); - expect(ctx.player('bob')).toMatchObject({ alive: false, deaths: 1 }); - ctx.ticks(Math.ceil(RESPAWN_MS / TICK_MS) + 1); - expect(ctx.player('bob')).toMatchObject({ alive: true, protected: true }); - }); - - test('věž se natočí za myší nezávisle na korbě a střílí tím směrem', () => { - const ctx = setup(); - ctx.startGame(); - ctx.game.placeForTest('alice', 60, 60, 0); - ctx.game.placeForTest('bob', 60, 250, 0); - ctx.game.aimAt('alice', { x: 60, y: 250 }); - ctx.ticks(10); - expect(ctx.player('alice').turret).toBeCloseTo(Math.PI / 2, 1); - expect(ctx.player('alice').angle).toBe(0); - ctx.shoot('alice'); - ctx.ticks(30); - expect(ctx.player('bob')).toMatchObject({ alive: false }); - expect(ctx.player('alice').kills).toBe(1); - // Bez myši se věž vrátí do směru jízdy - ctx.game.aimAt('alice', { x: null }); - ctx.ticks(10); - expect(ctx.player('alice').turret).toBeCloseTo(0, 1); - }); - - test('ve výchozím stavu je režim poslední přeživší a střela na zdi zanikne', () => { - const ctx = setup(); - ctx.game.join('alice'); - ctx.game.join('bob'); - expect(ctx.last()).toMatchObject({ mode: 'lastman', bounce: false }); - ctx.game.configure('alice', { mapId: 'arena' }); - ctx.game.start('alice'); - ctx.clock.advance(COUNTDOWN_SECONDS * 1000); - ctx.game.placeForTest('alice', 100, 60, -Math.PI / 2); - ctx.game.placeForTest('bob', 900, 500, 0); - ctx.shoot('alice'); - ctx.ticks(20); - expect(ctx.player('alice')).toMatchObject({ alive: true, lives: LAST_MAN_LIVES }); - expect(ctx.last().bullets).toHaveLength(0); - }); - - test('odražená vlastní střela zničí i střelce a stojí bod', () => { - const ctx = setup(); - ctx.startGame(); - ctx.game.placeForTest('alice', 100, 60, -Math.PI / 2); - ctx.shoot('alice'); - ctx.ticks(20); - expect(ctx.player('alice')).toMatchObject({ alive: false, score: -1, kills: 0 }); - }); - - test('střela rozbije bednu a zanikne', () => { - const ctx = setup(); - ctx.startGame(); - ctx.game.placeForTest('alice', 380, 20, 0); - ctx.shoot('alice'); - ctx.ticks(10); - expect(ctx.last().tiles[0][11]).toBe('.'); - expect(ctx.last().tiles[0][12]).toBe('+'); - expect(ctx.last().bullets).toHaveLength(0); - }); - - test('výstřel s hlavní v bedně ji rozbije rovnou', () => { - const ctx = setup(); - ctx.startGame(); - ctx.game.placeForTest('alice', 425, 20, 0); - ctx.shoot('alice'); - expect(ctx.last().tiles[0][11]).toBe('.'); - expect(ctx.last().bullets).toHaveLength(0); - }); - - test('na přežití vypadne, kdo přijde o všechny životy, a hra skončí', () => { - const ctx = setup(); - ctx.startGame('lastman'); - expect(ctx.player('bob').lives).toBe(LAST_MAN_LIVES); - for (let i = 0; i < LAST_MAN_LIVES; i++) { - ctx.game.placeForTest('alice', 900, 300, 0); - ctx.game.placeForTest('bob', 100, 60, -Math.PI / 2); - ctx.shoot('bob'); - ctx.ticks(20); - if (i < LAST_MAN_LIVES - 1) ctx.ticks(Math.ceil(RESPAWN_MS / TICK_MS) + 1); - } - expect(ctx.last().phase).toBe('finished'); - expect(ctx.player('alice').place).toBe(1); - expect(ctx.player('bob')).toMatchObject({ place: 2, lives: 0 }); - }); - - test('hra na čas skončí po vypršení limitu', () => { - const ctx = setup(); - ctx.startGame(); - ctx.clock.advance(DEATHMATCH_MS + 100); - expect(ctx.last().phase).toBe('finished'); - expect(ctx.last().players.every(p => p.place !== undefined)).toBe(true); - }); - - test('boti hrají mezi sebou a navzájem se ničí', () => { - const ctx = setup(lcg(7)); - ctx.game.join('alice'); - ctx.game.configure('alice', { mode: 'deathmatch', mapId: 'maze' }); - ctx.game.addBot('alice', 'hard'); - ctx.game.addBot('alice', 'medium'); - ctx.game.start('alice'); - ctx.clock.advance(COUNTDOWN_SECONDS * 1000); - ctx.clock.advance(60 * 1000); - const bots = ctx.last().players.filter(p => p.bot); - expect(bots.reduce((sum, b) => sum + b.kills, 0)).toBeGreaterThan(0); - }); -}); - -describe('tanky — bonusy', () => { - /** Položí bonus pod tank a nechá ho sebrat. */ - const pickUp = (ctx: ReturnType, login: string, type: Parameters[2]) => { - ctx.ticks(1); // aktuální poloha po placeForTest - const p = ctx.player(login); - ctx.game.addPickupForTest(p.x, p.y, type); - ctx.ticks(1); - }; - - test('raketa vybuchne při zásahu, zničí tank a zbyde po něm hořící vrak', () => { - const ctx = setup(); - ctx.startGame(); - ctx.game.placeForTest('alice', 100, 60, 0); - ctx.game.placeForTest('bob', 300, 60, 0); - pickUp(ctx, 'alice', 'rocket'); - expect(ctx.player('alice').rockets).toBe(ROCKET_AMMO); - ctx.shoot('alice'); - expect(ctx.last().bullets[0]).toMatchObject({ rocket: true }); - ctx.ticks(30); - expect(ctx.player('alice')).toMatchObject({ rockets: ROCKET_AMMO - 1, kills: 1, shots: 1 }); - expect(ctx.player('bob').alive).toBe(false); - expect(ctx.last().wrecks).toHaveLength(1); - }); - - test('výbuch rakety rozbije i sousední bedny', () => { - const ctx = setup(); - ctx.startGame(); - ctx.game.placeForTest('alice', 380, 20, 0); - ctx.game.placeForTest('bob', 900, 500, 0); - pickUp(ctx, 'alice', 'rocket'); - ctx.shoot('alice'); - ctx.ticks(10); - expect(ctx.last().tiles[0].slice(11, 13)).toBe('..'); - expect(ctx.last().explosions.length).toBeGreaterThan(0); - }); - - test('mina se aktivuje až po chvíli a vybuchne pod soupeřem', () => { - const ctx = setup(); - ctx.startGame(); - ctx.game.placeForTest('alice', 100, 60, 0); - ctx.game.placeForTest('bob', 500, 300, 0); - pickUp(ctx, 'alice', 'mine'); - ctx.game.dropMine('alice'); - ctx.ticks(1); - expect(ctx.player('alice').mines).toBe(MINE_AMMO - 1); - expect(ctx.last().mines[0]).toMatchObject({ x: 80, y: 60, armed: false }); - ctx.game.placeForTest('alice', 500, 500, 0); - ctx.ticks(Math.ceil(MINE_ARM_MS / TICK_MS)); - ctx.game.placeForTest('bob', 200, 60, Math.PI); - ctx.game.input('bob', { drive: 1 }); - ctx.ticks(40); - expect(ctx.player('bob').alive).toBe(false); - expect(ctx.player('alice').kills).toBe(1); - expect(ctx.last().mines).toHaveLength(0); - }); - - test('štít pohltí jeden zásah', () => { - const ctx = setup(); - ctx.startGame(); - ctx.game.placeForTest('alice', 100, 60, 0); - ctx.game.placeForTest('bob', 300, 60, 0); - pickUp(ctx, 'bob', 'shield'); - ctx.shoot('alice'); - ctx.ticks(30); - expect(ctx.player('bob')).toMatchObject({ alive: true, shield: false }); - }); - - test('turbo zrychlí jízdu', () => { - const ctx = setup(); - ctx.startGame(); - ctx.game.placeForTest('alice', 100, 60, 0); - ctx.game.placeForTest('bob', 100, 300, 0); - pickUp(ctx, 'alice', 'turbo'); - ctx.game.input('alice', { drive: 1 }); - ctx.game.input('bob', { drive: 1 }); - ctx.ticks(20); - expect(ctx.player('alice').turbo).toBe(true); - expect(ctx.player('alice').x - 100).toBeGreaterThan((ctx.player('bob').x - 100) * 1.4); - }); - - test('naváděná střela se stočí k soupeři', () => { - const ctx = setup(); - ctx.startGame(); - ctx.game.placeForTest('alice', 100, 60, 0); - ctx.game.placeForTest('bob', 300, 130, 0); - pickUp(ctx, 'alice', 'homing'); - ctx.shoot('alice'); - ctx.ticks(40); - expect(ctx.player('bob').alive).toBe(false); - }); - - test('život navíc se ve hře na přežití vrátí, ale nepřesáhne maximum', () => { - const ctx = setup(); - ctx.startGame('lastman'); - ctx.game.placeForTest('alice', 100, 60, -Math.PI / 2); - ctx.shoot('alice'); - ctx.ticks(20); - expect(ctx.player('alice').lives).toBe(LAST_MAN_LIVES - 1); - ctx.ticks(Math.ceil(RESPAWN_MS / TICK_MS) + 1); - pickUp(ctx, 'alice', 'heart'); - pickUp(ctx, 'alice', 'heart'); - expect(ctx.player('alice').lives).toBe(LAST_MAN_LIVES); - }); -}); - -describe('tanky — terén', () => { - test('voda tank zastaví, ale střela ji přeletí', () => { - const ctx = setup(); - // Les: řádek 7 (y 280–320) je řeka, v prvním sloupci bez brodu - ctx.startGame('deathmatch', ['alice', 'bob'], 'forest'); - ctx.game.placeForTest('alice', 20, 240, Math.PI / 2); - ctx.game.placeForTest('bob', 20, 380, 0); - ctx.game.input('alice', { drive: 1 }); - ctx.ticks(30); - expect(ctx.player('alice').y).toBeLessThanOrEqual(280 - TANK_RADIUS); - ctx.game.input('alice', { drive: 0 }); - ctx.shoot('alice'); - ctx.ticks(20); - expect(ctx.player('bob').alive).toBe(false); - }); - - test('porost zpomaluje jízdu', () => { - const ctx = setup(); - // Les: v prvním řádku jsou sloupce 3–4 (x 120–200) porost, třetí řádek vlevo je volný - ctx.startGame('deathmatch', ['alice', 'bob'], 'forest'); - ctx.game.placeForTest('alice', 130, 20, 0); - ctx.game.placeForTest('bob', 20, 100, 0); - ctx.game.input('alice', { drive: 1 }); - ctx.game.input('bob', { drive: 1 }); - ctx.ticks(10); - expect(ctx.player('alice').x - 130).toBeLessThan((ctx.player('bob').x - 20) * 0.7); - }); -}); - -describe('tanky — hardcore (tma)', () => { - /** alice a těžký bot ve volném druhém řádku arény, bot natočený od alice. */ - function duel(hardcore: boolean, distance: number) { - const ctx = setup(); - ctx.game.join('alice'); - ctx.game.configure('alice', { mode: 'deathmatch', mapId: 'arena', hardcore }); - ctx.game.addBot('alice', 'hard'); - ctx.game.start('alice'); - ctx.clock.advance(COUNTDOWN_SECONDS * 1000); - const bot = ctx.last().players.find(p => p.bot)!.login; - ctx.game.placeForTest('alice', 100, 60, 0); - ctx.game.placeForTest(bot, 100 + distance, 60, 0); - ctx.ticks(30); - return { ...ctx, bot }; - } - - test('zakladatel ho zapne v lobby', () => { - const ctx = setup(); - ctx.game.join('alice'); - ctx.game.configure('alice', { hardcore: true }); - expect(ctx.last().hardcore).toBe(true); - }); - - test('bot ve tmě nevidí soupeře mimo světlo, bez tmy ano', () => { - expect(duel(true, 600).last().bullets).toHaveLength(0); - const lit = duel(false, 600); - expect(lit.last().bullets.length > 0 || !lit.player('alice').alive).toBe(true); - }); - - test('bot ve tmě vidí soupeře ve svém okolí', () => { - const ctx = duel(true, 110); - expect(ctx.last().bullets.length > 0 || !ctx.player('alice').alive).toBe(true); - }); -}); - -describe('tanky — týmy', () => { - const LOGINS = ['alice', 'bob', 'carol', 'dave']; - - test('hráči se střídavě rozdělí do týmů a jde se přesunout', () => { - const ctx = setup(); - LOGINS.forEach(l => ctx.game.join(l)); - ctx.game.configure('alice', { teams: true }); - expect(ctx.last().players.map(p => p.team)).toEqual([0, 1, 0, 1]); - expect(() => ctx.game.switchTeam('bob', 'carol')).toThrow(GameError); - ctx.game.switchTeam('bob'); - expect(ctx.player('bob').team).toBe(0); - ctx.game.switchTeam('alice', 'bob'); - expect(ctx.player('bob').team).toBe(1); - }); - - test('týmy začínají každý na své půlce a střela spoluhráčem proletí', () => { - const ctx = setup(); - ctx.startGame('deathmatch', LOGINS, 'arena', true); - for (const p of ctx.last().players) { - expect(p.x < FIELD_WIDTH / 2).toBe(p.team === 0); - } - // alice a carol jsou tým 0, bob tým 1 - ctx.game.placeForTest('alice', 100, 60, 0); - ctx.game.placeForTest('carol', 200, 60, 0); - ctx.game.placeForTest('bob', 300, 60, 0); - ctx.shoot('alice'); - ctx.ticks(30); - expect(ctx.player('carol').alive).toBe(true); - expect(ctx.player('bob').alive).toBe(false); - expect(ctx.last().teamScores).toEqual([1, 0]); - }); - - test('na čas vyhrává tým s víc body', () => { - const ctx = setup(); - ctx.startGame('deathmatch', LOGINS, 'arena', true); - ctx.game.placeForTest('bob', 100, 60, 0); - ctx.game.placeForTest('alice', 300, 60, 0); - ctx.shoot('bob'); - ctx.clock.advance(DEATHMATCH_MS); - const state = ctx.last(); - expect(state.phase).toBe('finished'); - expect(state.winnerTeam).toBe(1); - expect(state.players.filter(p => p.place! <= 2).every(p => p.team === 1)).toBe(true); - }); -}); diff --git a/server/src/tests/tetrisGame.test.ts b/server/src/tests/tetrisGame.test.ts deleted file mode 100644 index 934e032..0000000 --- a/server/src/tests/tetrisGame.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { TetrisGame, TetrisState, TetrisSettings, GarbageEvent, COUNTDOWN_SECONDS, JOIN_GRACE_SECONDS, MIN_ATTACK_GAP_MS, WATCH_FLUSH_MS, BOARD_WIDTH, BOARD_HEIGHT, PLAYER_TIMEOUT_MS, DEFAULT_SETTINGS } 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(minPlayers = 1) { - 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), minPlayers, clock); - const last = () => all[all.length - 1]; - const startWith = (settings: Partial, ...logins: string[]) => { - logins.forEach(l => game.join(l)); - game.configure(logins[0], settings); - game.start(logins[0]); - clock.advance(COUNTDOWN_SECONDS * 1000); - }; - const startGame = (...logins: string[]) => startWith({}, ...logins); - return { game, clock, all, watched, garbage, last, startGame, startWith }; -} - -describe('tetris', () => { - test('společnou hru nejde spustit jen s jedním hráčem', () => { - const ctx = setup(2); - ctx.game.join('alice'); - expect(() => ctx.game.start('alice')).toThrow(GameError); - expect(ctx.last()).toMatchObject({ phase: 'lobby' }); - ctx.game.join('bob'); - ctx.game.start('alice'); - expect(ctx.last()).toMatchObject({ phase: 'countdown' }); - }); - - 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('útok jde soupeři, který dostal nejméně odpadu', () => { - const ctx = setup(); - ctx.startGame('alice', 'bob', 'carol', 'dave'); - ctx.game.cleared('alice', 4); // všichni 0 → první z nich (random = 0) - ctx.clock.advance(MIN_ATTACK_GAP_MS); - ctx.game.cleared('alice', 2); - ctx.clock.advance(MIN_ATTACK_GAP_MS); - ctx.game.cleared('alice', 3); - ctx.game.cleared('bob', 2); // carol 1, dave 2, alice 0 → alice - ctx.game.cleared('carol', 2); // alice 1, bob 4, dave 2 → alice - expect(ctx.garbage.map(g => g.target)).toEqual(['bob', 'carol', 'dave', 'alice', 'alice']); - }); - - test('odražený odpad zmenší útok, víc než tabulka poslat nejde', () => { - const ctx = setup(); - ctx.startGame('alice', 'bob'); - ctx.game.cleared('alice', 4, 1); - ctx.clock.advance(MIN_ATTACK_GAP_MS); - ctx.game.cleared('alice', 2, 0); // vše odraženo → žádný útok - ctx.game.cleared('alice', 2, 50); - ctx.clock.advance(MIN_ATTACK_GAP_MS); - ctx.game.cleared('alice', 3, -5); - expect(ctx.garbage.map(g => g.lines)).toEqual([1, 1]); - }); - - 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('nastavení mění jen zakladatel v lobby, neplatné hodnoty se ignorují', () => { - const ctx = setup(); - ctx.game.join('alice'); - ctx.game.join('bob'); - expect(ctx.last().settings).toEqual(DEFAULT_SETTINGS); - expect(() => ctx.game.configure('bob', { targeting: 'random' })).toThrow(GameError); - ctx.game.configure('alice', { targeting: 'payback', winMode: 'nesmysl', timeLimit: 7 }); - expect(ctx.last().settings).toEqual({ ...DEFAULT_SETTINGS, targeting: 'payback' }); - ctx.game.start('alice'); - expect(() => ctx.game.configure('alice', { winMode: 'score' })).toThrow(GameError); - }); - - test('random míří náhodně bez ohledu na obdržený odpad', () => { - const ctx = setup(); - ctx.startWith({ targeting: 'random' }, 'alice', 'bob', 'carol'); - ctx.game.cleared('alice', 4); - ctx.clock.advance(MIN_ATTACK_GAP_MS); - ctx.game.cleared('alice', 4); // random = 0 → pořád první soupeř - expect(ctx.garbage.map(g => g.target)).toEqual(['bob', 'bob']); - }); - - test('payback vrací útok poslednímu útočníkovi, jinak míří jako even', () => { - const ctx = setup(); - ctx.startWith({ targeting: 'payback' }, 'alice', 'bob', 'carol'); - ctx.game.cleared('carol', 2); // carol nikdo nenapadl → even → alice - ctx.game.cleared('alice', 2); // alice napadla carol → carol - ctx.game.cleared('bob', 2); // bob nikdo nenapadl → even (alice 1, carol 1) → alice - ctx.clock.advance(MIN_ATTACK_GAP_MS); - ctx.game.cleared('alice', 2); // naposledy ji napadl bob → bob - ctx.game.died('bob'); - ctx.game.cleared('bob', 2); // mrtvý neútočí - ctx.game.cleared('carol', 2); // naposledy ji napadla alice → alice - expect(ctx.garbage.map(g => `${g.from}>${g.target}`)).toEqual(['carol>alice', 'alice>carol', 'bob>alice', 'alice>bob', 'carol>alice']); - }); - - test('hra na skóre končí časovým limitem a pořadí je podle skóre', () => { - const ctx = setup(); - ctx.startWith({ winMode: 'score', timeLimit: 120 }, 'alice', 'bob', 'carol'); - expect(ctx.last().remainingMs).toBe(120_000); - ctx.game.update('bob', { board: BOARD, score: 900, lines: 8 }); - ctx.game.died('bob'); - ctx.game.died('alice'); // zbývá jediný hráč, ale hraje se dál - expect(ctx.last().phase).toBe('playing'); - ctx.game.update('alice', { board: BOARD, score: 100, lines: 1 }); // mrtvému se skóre už nemění - ctx.game.update('carol', { board: BOARD, score: 500, lines: 4 }); - ctx.clock.advance(120_000); - const state = ctx.last(); - expect(state.phase).toBe('finished'); - const place = (l: string) => state.players.find(p => p.login === l)?.place; - expect([place('bob'), place('carol'), place('alice')]).toEqual([1, 2, 3]); - }); - - test('hra na skóre skončí dřív, když vypadnou všichni', () => { - const ctx = setup(); - ctx.startWith({ winMode: 'score' }, 'alice', 'bob'); - ctx.game.died('alice'); - expect(ctx.last().phase).toBe('playing'); - ctx.game.died('bob'); - expect(ctx.last().phase).toBe('finished'); - }); - - test('do běžící hry se nelze připojit', () => { - const ctx = setup(); - ctx.startGame('alice'); - expect(() => ctx.game.join('bob')).toThrow(GameError); - }); - - test('během odpočtu se lze ještě připojit', () => { - const ctx = setup(); - ctx.game.join('alice'); - ctx.game.start('alice'); - ctx.clock.advance(1000); - ctx.game.join('bob'); - expect(ctx.last()).toMatchObject({ phase: 'countdown', countdown: COUNTDOWN_SECONDS - 1 }); - expect(ctx.last().players.map(p => p.login)).toEqual(['alice', 'bob']); - ctx.clock.advance((COUNTDOWN_SECONDS - 1) * 1000); - expect(ctx.last().phase).toBe('playing'); - }); - - test('připojení na poslední chvíli prodlouží odpočet', () => { - const ctx = setup(); - ctx.game.join('alice'); - ctx.game.start('alice'); - ctx.clock.advance((COUNTDOWN_SECONDS - 1) * 1000); - ctx.game.join('bob'); - expect(ctx.last()).toMatchObject({ phase: 'countdown', countdown: JOIN_GRACE_SECONDS }); - ctx.clock.advance((JOIN_GRACE_SECONDS - 1) * 1000); - expect(ctx.last().phase).toBe('countdown'); - ctx.clock.advance(1000); - expect(ctx.last().phase).toBe('playing'); - }); -}); diff --git a/server/src/tetrisGame.ts b/server/src/tetrisGame.ts deleted file mode 100644 index 2d8f1fb..0000000 --- a/server/src/tetrisGame.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { Server } from "socket.io"; -import { Clock, defaultClock, GameError, gameHandler, minPlayersForEnv } 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 (během něj se ještě dá připojit), společný seed pro pořadí kostek, přeposílání desek soupeřům, - * útoky (smazané řádky → odpadní řádky soupeři podle zvoleného režimu míření) a vyřazování. - * Zakladatel lobby volí režim míření a způsob hodnocení: buď vyhrává poslední hráč ve hře - * (sólo hra končí prohrou), nebo nejvyšší skóre po uplynutí časového limitu. - * - * 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; -/** Odpočet po spuštění hry — během něj se ještě mohou připojit opozdilci. */ -export const COUNTDOWN_SECONDS = 5; -/** Kolik sekund odpočtu má nejméně zbývat po připojení dalšího hráče, ať se stihne připravit. */ -export const JOIN_GRACE_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'; - -/** - * Komu jde útok: - * - random — náhodnému živému soupeři, - * - even — soupeři, který zatím dostal nejméně odpadu, - * - payback — tomu, kdo útočníka naposledy napadl (jinak jako even). - */ -export type TetrisTargeting = 'random' | 'even' | 'payback'; -/** survival — vyhrává poslední ve hře; score — nejvyšší skóre po časovém limitu. */ -export type TetrisWinMode = 'survival' | 'score'; - -export type TetrisSettings = { - targeting: TetrisTargeting, - winMode: TetrisWinMode, - /** Délka hry v sekundách (jen pro winMode = score). */ - timeLimit: number, -}; - -export const TARGETING_MODES: TetrisTargeting[] = ['random', 'even', 'payback']; -export const WIN_MODES: TetrisWinMode[] = ['survival', 'score']; -/** Povolené délky hry na skóre (s). */ -export const TIME_LIMITS = [120, 180, 300]; -export const DEFAULT_SETTINGS: TetrisSettings = { targeting: 'even', winMode: 'survival', timeLimit: 180 }; - -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, - settings: TetrisSettings, - /** Zbývající čas hry na skóre (ms), jen ve fázi playing. */ - remainingMs?: number, -}; - -export type GarbageEvent = { target: string, from: string, lines: number }; - -type InternalPlayer = TetrisPlayer & { - lastAttack: number, - lastSeen: number, - deathOrder?: number, - /** Kolik odpadních řádků už hráč od soupeřů dostal (režim even). */ - garbageReceived: number, - /** Kdo hráče naposledy napadl (režim payback). */ - lastAttacker?: string, -}; - -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 settings: TetrisSettings = { ...DEFAULT_SETTINGS }; - /** Kdy skončí hra na skóre (čas z clock.now()). */ - private endsAt?: number; - 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 = 2, - 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, - settings: { ...this.settings }, - remainingMs: this.phase === 'playing' && this.endsAt !== undefined - ? Math.max(0, this.endsAt - this.clock.now()) : 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' && this.phase !== 'countdown') { - 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, garbageReceived: 0 }); - // Opozdilec připojený na poslední chvíli dostane čas se připravit (počet hráčů je omezený, odpočet se neprodlužuje donekonečna) - if (this.phase === 'countdown' && this.countdown < JOIN_GRACE_SECONDS) { - this.countdown = JOIN_GRACE_SECONDS; - } - } - 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(); - } - - /** Nastavení hry — jen zakladatel a jen v lobby; neplatné hodnoty se ignorují. */ - configure(login: string, payload: unknown) { - if (this.phase !== 'lobby') { - throw new GameError('Nastavení lze měnit jen v lobby.'); - } - if (login !== this.host) { - throw new GameError('Nastavení může měnit jen ten, kdo lobby založil.'); - } - const { targeting, winMode, timeLimit } = (payload ?? {}) as Partial>; - if (TARGETING_MODES.includes(targeting as TetrisTargeting)) this.settings.targeting = targeting as TetrisTargeting; - if (WIN_MODES.includes(winMode as TetrisWinMode)) this.settings.winMode = winMode as TetrisWinMode; - if (TIME_LIMITS.includes(timeLimit as number)) this.settings.timeLimit = timeLimit as number; - 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 živému soupeři podle režimu míření (viz TetrisTargeting). - * - * @param sent kolik řádků po odražení vlastního čekajícího odpadu zbylo na útok (počítá klient); - * bez něj se pošle plný útok podle tabulky, víc než tabulka ale nikdy - */ - cleared(login: string, count: unknown, sent?: unknown) { - const p = this.players.get(login); - if (!p || !p.alive || this.phase !== 'playing' || !Number.isInteger(count)) return; - const attack = GARBAGE_FOR_LINES[count as number] ?? 0; - const garbage = Number.isInteger(sent) ? Math.min(Math.max(sent as number, 0), attack) : attack; - const now = this.clock.now(); - if (garbage === 0 || now - p.lastAttack < MIN_ATTACK_GAP_MS) return; - p.lastAttack = now; - const opponents = [...this.players.values()].filter(t => t.alive && t.login !== login); - if (opponents.length === 0) return; - const target = this.pickTarget(p, opponents); - target.garbageReceived += garbage; - target.lastAttacker = login; - this.sendGarbage({ target: target.login, from: login, lines: garbage }); - } - - private pickTarget(attacker: InternalPlayer, opponents: InternalPlayer[]): InternalPlayer { - const pick = (list: InternalPlayer[]) => list[Math.floor(this.clock.random() * list.length)]; - if (this.settings.targeting === 'random') return pick(opponents); - if (this.settings.targeting === 'payback') { - const revenge = opponents.find(t => t.login === attacker.lastAttacker); - if (revenge) return revenge; - } - // even (a payback, dokud útočníka nikdo nenapadl): kdo dostal nejméně, při shodě náhodně - const least = Math.min(...opponents.map(t => t.garbageReceived)); - return pick(opponents.filter(t => t.garbageReceived === least)); - } - - /** 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; - // Ve hře na skóre se hraje do limitu, dokud je ve hře aspoň jeden hráč - const lastStanding = this.settings.winMode === 'survival' && this.players.size > 1 && alive <= 1; - if (alive === 0 || lastStanding) { - 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; }); - const duration = this.settings.winMode === 'score' ? this.settings.timeLimit * 1000 : MAX_GAME_MS; - this.endsAt = this.settings.winMode === 'score' ? now + duration : undefined; - this.emitAll(); - this.schedule(() => this.finish(), duration); - 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í — survival: živí první (podle skóre), pak vyřazení od posledního; - * score: podle skóre, při shodě déle hrající. - */ - private finish() { - const survival = (a: InternalPlayer, b: InternalPlayer) => (b.deathOrder ?? Infinity) - (a.deathOrder ?? Infinity); - const ranked = [...this.players.values()].sort(this.settings.winMode === 'score' - ? (a, b) => b.score - a.score || survival(a, b) - : (a, b) => survival(a, b) || 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; - this.endsAt = 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; - } - } - - 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), - // Sólo hra je jen Trénink v prohlížeči, společná hra by blokovala ostatní - minPlayersForEnv(2), - ); - - 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:configure', handle((login, payload) => game.configure(login, payload))); - 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) => { - const { count, sent } = (payload ?? {}) as { count?: unknown, sent?: unknown }; - game.cleared(login, count, sent); - })); - socket.on('tetris:died', handle(login => game.died(login))); - }); -} diff --git a/server/src/websocket.ts b/server/src/websocket.ts index aef9277..7371f0b 100644 --- a/server/src/websocket.ts +++ b/server/src/websocket.ts @@ -1,13 +1,7 @@ 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"; -import { registerTankGame } from "./tankGame"; -import { registerLobbyChat } from "./minigames"; let io: Server; let pubClient: ReturnType; @@ -35,13 +29,7 @@ export const initWebsocket = (server: any) => { // console.log(`Client disconnected: ${socket.id}`); }); }); - registerReactionDuel(io); - registerSnakeGame(io); - registerTetrisGame(io); - registerPongGame(io); registerDrawingGame(io); - registerTankGame(io); - registerLobbyChat(io); return io; }; diff --git a/types/paths/config/config.yml b/types/paths/config/config.yml index 3fb2378..c8f7200 100644 --- a/types/paths/config/config.yml +++ b/types/paths/config/config.yml @@ -1,6 +1,6 @@ get: operationId: getConfig - summary: Vrátí veřejnou runtime konfiguraci pro klienta (Sentry DSN, limity objednávek, způsob přihlášení, zapnuté funkce). Nevyžaduje autentizaci. + summary: Vrátí veřejnou runtime konfiguraci pro klienta (Sentry DSN, limity objednávek, způsob přihlášení, zapnuté funkce, odkazy). Nevyžaduje autentizaci. security: [] # Nevyžaduje autentizaci responses: "200": @@ -9,7 +9,7 @@ get: application/json: schema: type: object - required: [sentry, limits, auth, features] + required: [sentry, limits, auth, features, links] properties: auth: type: object @@ -25,6 +25,14 @@ get: storeAdmin: type: boolean description: Správa obchodů je zapnutá (server má nastavené ADMIN_PASSWORD) + links: + type: object + required: [lobbyUrl] + properties: + lobbyUrl: + type: string + nullable: true + description: Adresa aplikace Lobby s minihrami (LOBBY_URL), null = odkaz není nastaven limits: type: object required: [group]