feat: vytržení miniher do samostatného projektu
CI / Server unit tests (push) Successful in 26s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 2m24s
CI / Build and push Docker image (push) Successful in 44s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 26s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 2m24s
CI / Build and push Docker image (push) Successful in 44s
CI / Notify (push) Successful in 2s
This commit is contained in:
42 files changed
+130
-8218
No files matched your search
@@ -1,6 +1,21 @@
|
|||||||
// Pozvánka Kreslení nad pozvánkami ostatních her, aby se nepřekrývaly
|
.duel-invite {
|
||||||
.duel-invite.draw-invite {
|
position: fixed;
|
||||||
bottom: 224px;
|
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 {
|
.drawing-game-modal {
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -2,12 +2,8 @@ import { useEffect, useState } from "react";
|
|||||||
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import SettingsModal from "./modals/SettingsModal";
|
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 DrawingGame from "./DrawingGame";
|
||||||
import TankGame from "./TankGame";
|
import "./Header.scss";
|
||||||
import { useMinigameSecret } from "../hooks/useMinigameSecret";
|
import { useMinigameSecret } from "../hooks/useMinigameSecret";
|
||||||
import { useSettings, ThemePreference } from "../context/settings";
|
import { useSettings, ThemePreference } from "../context/settings";
|
||||||
import HuePicker from "./HuePicker";
|
import HuePicker from "./HuePicker";
|
||||||
@@ -205,12 +201,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
</NavDropdown>
|
</NavDropdown>
|
||||||
</Nav>
|
</Nav>
|
||||||
</Navbar.Collapse>
|
</Navbar.Collapse>
|
||||||
<ReactionDuel />
|
|
||||||
<SnakeGame />
|
|
||||||
<TetrisGame />
|
|
||||||
<PongGame />
|
|
||||||
<DrawingGame />
|
<DrawingGame />
|
||||||
<TankGame />
|
|
||||||
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
||||||
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
||||||
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<LobbyChatMessage[]>([]);
|
|
||||||
const [text, setText] = useState('');
|
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(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 (
|
|
||||||
<div className="lobby-chat">
|
|
||||||
<div className="lobby-chat-messages" ref={listRef}>
|
|
||||||
{messages.length === 0 && <div className="text-muted">Zatím žádné zprávy…</div>}
|
|
||||||
{messages.map((m, i) => <div key={i}><strong>{m.login}: </strong>{m.text}</div>)}
|
|
||||||
</div>
|
|
||||||
<div className="lobby-chat-emojis">
|
|
||||||
{EMOJIS.map(e => <button key={e} type="button" onClick={() => addEmoji(e)}>{e}</button>)}
|
|
||||||
</div>
|
|
||||||
<Form onSubmit={submit}>
|
|
||||||
<Form.Control
|
|
||||||
ref={inputRef}
|
|
||||||
size="sm"
|
|
||||||
value={text}
|
|
||||||
onChange={e => setText(e.target.value)}
|
|
||||||
placeholder="Napište zprávu…"
|
|
||||||
maxLength={200}
|
|
||||||
onKeyDown={e => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<PongState | null>(null);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const prevPhase = useRef<PongPhase>('idle');
|
|
||||||
const icon = useGameIcon('pong');
|
|
||||||
const touch = useTouchDevice();
|
|
||||||
const [botLevel, setBotLevel] = useState<BotLevel>('medium');
|
|
||||||
/** Výška hřiště pro přepočet myši — přes ref, ať se efekt nerestartuje každým snímkem. */
|
|
||||||
const fieldHeight = useRef(500);
|
|
||||||
fieldHeight.current = state?.height ?? fieldHeight.current;
|
|
||||||
|
|
||||||
const send = useCallback((event: string, extra?: object) => {
|
|
||||||
setError(null);
|
|
||||||
socket.emit(event, { token: getToken(), ...extra });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onState = (s: PongState) => setState(s);
|
|
||||||
const onError = (msg: string) => setError(msg);
|
|
||||||
const onOpen = () => setOpen(true);
|
|
||||||
const sync = () => socket.emit('pong:sync');
|
|
||||||
socket.on('pong:state', onState);
|
|
||||||
socket.on('pong:error', onError);
|
|
||||||
socket.on('connect', sync);
|
|
||||||
window.addEventListener(OPEN_EVENT, onOpen);
|
|
||||||
sync();
|
|
||||||
return () => {
|
|
||||||
socket.off('pong:state', onState);
|
|
||||||
socket.off('pong:error', onError);
|
|
||||||
socket.off('connect', sync);
|
|
||||||
window.removeEventListener(OPEN_EVENT, onOpen);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Snímky hry dostávají jen klienti s otevřeným oknem
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const watch = () => socket.emit('pong:watch');
|
|
||||||
watch();
|
|
||||||
socket.on('connect', watch);
|
|
||||||
return () => {
|
|
||||||
socket.off('connect', watch);
|
|
||||||
socket.emit('pong:unwatch');
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
|
|
||||||
const phase = state?.phase ?? 'idle';
|
|
||||||
const inGame = phase === 'countdown' || phase === 'playing';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevPhase.current === 'lobby' && phase === 'countdown' && isPlayer) {
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
prevPhase.current = phase;
|
|
||||||
}, [phase, isPlayer]);
|
|
||||||
|
|
||||||
// Ovládání: drží se klávesa → pálka jede, puštění → stojí
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !inGame || !isPlayer) return;
|
|
||||||
const pressed = new Set<string>();
|
|
||||||
const update = () => {
|
|
||||||
const up = [...pressed].some(k => UP_KEYS.has(k));
|
|
||||||
const down = [...pressed].some(k => DOWN_KEYS.has(k));
|
|
||||||
send('pong:input', { input: up === down ? 'stop' : up ? 'up' : 'down' });
|
|
||||||
};
|
|
||||||
const onDown = (e: KeyboardEvent) => {
|
|
||||||
if (!UP_KEYS.has(e.code) && !DOWN_KEYS.has(e.code)) return;
|
|
||||||
e.preventDefault();
|
|
||||||
if (e.repeat || pressed.has(e.code)) return;
|
|
||||||
pressed.add(e.code);
|
|
||||||
update();
|
|
||||||
};
|
|
||||||
const onUp = (e: KeyboardEvent) => {
|
|
||||||
if (!pressed.delete(e.code)) return;
|
|
||||||
update();
|
|
||||||
};
|
|
||||||
const onBlur = () => {
|
|
||||||
pressed.clear();
|
|
||||||
update();
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', onDown);
|
|
||||||
window.addEventListener('keyup', onUp);
|
|
||||||
window.addEventListener('blur', onBlur);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('keydown', onDown);
|
|
||||||
window.removeEventListener('keyup', onUp);
|
|
||||||
window.removeEventListener('blur', onBlur);
|
|
||||||
};
|
|
||||||
}, [open, inGame, isPlayer, send]);
|
|
||||||
|
|
||||||
// Ovládání myší č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 && (
|
|
||||||
<>
|
|
||||||
<div className="pong-scoreboard">
|
|
||||||
<span style={{ color: SIDE_COLORS[0] }}>{left?.login}</span>
|
|
||||||
<strong>{left?.score ?? 0} : {right?.score ?? 0}</strong>
|
|
||||||
<span style={{ color: SIDE_COLORS[1] }}>{right?.login}</span>
|
|
||||||
</div>
|
|
||||||
<div className="pong-board-wrap">
|
|
||||||
<canvas ref={canvasRef} className="pong-board" width={state.width} height={state.height} />
|
|
||||||
{phase === 'countdown' && <div className="pong-overlay">{state.countdown}</div>}
|
|
||||||
{phase === 'finished' && <div className="pong-overlay small">🏆 {state.winner}</div>}
|
|
||||||
</div>
|
|
||||||
<div className="pong-status">{isPlayer ? (touch ? 'Táhněte prstem po hřišti' : 'Myš, nebo ↑↓ / W S') : 'Sledujete zápas'}</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBody = () => {
|
|
||||||
switch (phase) {
|
|
||||||
case 'idle':
|
|
||||||
return (
|
|
||||||
<p>
|
|
||||||
Klasický Pong pro dva. Ovládání myší (pálka sleduje kurzor), 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.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
case 'lobby':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p>Lobby založil(a) <strong>{state?.host}</strong>.</p>
|
|
||||||
<ul>{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}</ul>
|
|
||||||
{(state?.players.length ?? 0) < 2 && <p className="text-muted">Čeká se na soupeře…</p>}
|
|
||||||
{login === state?.host && (state?.players.length ?? 0) < 2 && (
|
|
||||||
<div className="pong-bot-picker">
|
|
||||||
<span>Nebo hrát proti botovi:</span>
|
|
||||||
<Form.Select size="sm" value={botLevel} onChange={e => setBotLevel(e.target.value as BotLevel)}>
|
|
||||||
{BOT_LEVELS.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
|
|
||||||
</Form.Select>
|
|
||||||
<Button size="sm" variant="outline-primary" onClick={() => send('pong:addBot', { level: botLevel })}>
|
|
||||||
Přidat bota
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{login === state?.host && state?.players.some(p => p.bot) && (
|
|
||||||
<Button size="sm" variant="outline-secondary" onClick={() => send('pong:removeBot')}>Odebrat bota</Button>
|
|
||||||
)}
|
|
||||||
{isPlayer && <LobbyChat game="pong" />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return renderBoard();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderFooter = () => {
|
|
||||||
const buttons = [];
|
|
||||||
if (phase === 'idle' || phase === 'finished') {
|
|
||||||
buttons.push(<Button key="new" onClick={() => send('pong:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
|
||||||
}
|
|
||||||
if (phase === 'lobby') {
|
|
||||||
if (!isPlayer) {
|
|
||||||
if ((state?.players.length ?? 0) < 2) {
|
|
||||||
buttons.push(<Button key="join" onClick={() => send('pong:join')}>Připojit se</Button>);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('pong:leave')}>
|
|
||||||
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
|
||||||
</Button>);
|
|
||||||
if (login === state?.host) {
|
|
||||||
buttons.push(<Button key="start" onClick={() => send('pong:start')}
|
|
||||||
disabled={(state?.players.length ?? 0) < 2}>Spustit hru</Button>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
|
||||||
return buttons;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showInvite && (
|
|
||||||
<button className="duel-invite pong-invite" onClick={() => setOpen(true)}>
|
|
||||||
{icon} <strong>{state?.host}</strong> hledá soupeře na Pong — připojit se
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{showReturn && (
|
|
||||||
<button className="duel-invite pong-invite" onClick={() => setOpen(true)}>
|
|
||||||
{icon} Pong běží — zpět do hry
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<Modal show={open} onHide={() => setOpen(false)} size="xl" fullscreen="sm-down" className="pong-game-modal">
|
|
||||||
<Modal.Header closeButton>
|
|
||||||
<Modal.Title><h2>{icon} Pong</h2></Modal.Title>
|
|
||||||
</Modal.Header>
|
|
||||||
<Modal.Body>
|
|
||||||
{renderBody()}
|
|
||||||
{error && <div className="text-danger mt-2">{error}</div>}
|
|
||||||
</Modal.Body>
|
|
||||||
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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); }
|
|
||||||
}
|
|
||||||
@@ -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<DuelState | null>(null);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(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<DuelPhase>('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 (
|
|
||||||
<div
|
|
||||||
className={`duel-arena ${cls}`}
|
|
||||||
onPointerDown={click}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<div className="duel-round">Kolo {state.round}/{state.totalRounds}</div>
|
|
||||||
<div className="duel-signal">{text}</div>
|
|
||||||
{isPlayer
|
|
||||||
? <div className="duel-hint">{touch ? 'Ťukněte, až zezelená.' : 'Klikněte nebo zmáčkněte mezerník, až zezelená.'}</div>
|
|
||||||
: <div className="duel-hint">Sledujete hru.</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderResults = (final: boolean) => (
|
|
||||||
<Table size="sm" className="duel-results">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>#</th>
|
|
||||||
<th>Hráč</th>
|
|
||||||
{Array.from({ length: state?.totalRounds ?? 0 }, (_, i) => <th key={i}>{i + 1}. kolo</th>)}
|
|
||||||
<th>Celkem</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{ranked.map((p, i) => (
|
|
||||||
<tr key={p.login} className={final && p.login === state?.loser ? 'duel-loser' : undefined}>
|
|
||||||
<td>{final && i < 3 ? MEDALS[i] : i + 1}</td>
|
|
||||||
<td>{p.login}{p.login === login && ' (vy)'}</td>
|
|
||||||
{Array.from({ length: state?.totalRounds ?? 0 }, (_, r) => <td key={r}>{formatResult(p.rounds[r])}</td>)}
|
|
||||||
<td><strong>{p.rounds.length ? `${p.total} ms` : '—'}</strong></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBody = () => {
|
|
||||||
switch (phase) {
|
|
||||||
case 'idle':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
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ů.
|
|
||||||
</p>
|
|
||||||
<p className="text-muted">Po založení lobby dostanou ostatní online pozvánku.</p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case 'lobby':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
|
|
||||||
<ul className="duel-players">
|
|
||||||
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
|
|
||||||
</ul>
|
|
||||||
{state && state.players.length < state.minPlayers &&
|
|
||||||
<p className="text-muted">Čeká se na další hráče…</p>}
|
|
||||||
{isPlayer && <LobbyChat game="duel" />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case 'waiting':
|
|
||||||
case 'go':
|
|
||||||
return renderArena();
|
|
||||||
case 'roundResult':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p>Kolo {state?.round}/{state?.totalRounds} skončilo, další za chvíli…</p>
|
|
||||||
{renderResults(false)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case 'finished':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="duel-podium">
|
|
||||||
{ranked.slice(0, 3).map((p, i) => (
|
|
||||||
<div key={p.login} className={`duel-place place-${i + 1}`}>
|
|
||||||
<div className="duel-medal">{MEDALS[i]}</div>
|
|
||||||
<div className="duel-place-name">{p.login}</div>
|
|
||||||
<div className="duel-place-time">{p.total} ms</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{state?.loser && ranked.length > 3 &&
|
|
||||||
<p className="duel-last">🐢 Poslední: <strong>{state.loser}</strong></p>}
|
|
||||||
{renderResults(true)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderFooter = () => {
|
|
||||||
const buttons = [];
|
|
||||||
if (phase === 'idle' || phase === 'finished') {
|
|
||||||
buttons.push(<Button key="new" onClick={() => send('duel:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
|
||||||
}
|
|
||||||
if (phase === 'lobby') {
|
|
||||||
if (!isPlayer) {
|
|
||||||
buttons.push(<Button key="join" onClick={() => send('duel:join')}>Připojit se</Button>);
|
|
||||||
} else {
|
|
||||||
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('duel:leave')}>
|
|
||||||
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
|
||||||
</Button>);
|
|
||||||
if (login === state?.host) {
|
|
||||||
buttons.push(<Button key="start" onClick={() => send('duel:start')}
|
|
||||||
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 2)}>Spustit hru</Button>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
|
||||||
return buttons;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showInvite && (
|
|
||||||
<button className="duel-invite" onClick={() => setOpen(true)}>
|
|
||||||
{icon} <strong>{state?.host}</strong> zve na reakční hru — připojit se
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{showReturn && (
|
|
||||||
<button className="duel-invite" onClick={() => setOpen(true)}>
|
|
||||||
{icon} Reakční hra běží — zpět do hry
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<Modal show={open} onHide={() => setOpen(false)} size="lg" fullscreen="sm-down" className="reaction-duel-modal">
|
|
||||||
<Modal.Header closeButton>
|
|
||||||
<Modal.Title><h2>{icon} Reakční hra</h2></Modal.Title>
|
|
||||||
</Modal.Header>
|
|
||||||
<Modal.Body>
|
|
||||||
{renderBody()}
|
|
||||||
{error && <div className="text-danger mt-2">{error}</div>}
|
|
||||||
</Modal.Body>
|
|
||||||
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<string, Direction> = {
|
|
||||||
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<SnakeState | null>(null);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const prevPhase = useRef<SnakePhase>('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<HTMLCanvasElement>) => {
|
|
||||||
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<HTMLCanvasElement>) => {
|
|
||||||
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 && (
|
|
||||||
<div className="snake-board-wrap">
|
|
||||||
<canvas ref={canvasRef} className="snake-board" width={state.width * 24} height={state.height * 24}
|
|
||||||
onPointerDown={onBoardPointerDown} onPointerMove={onBoardPointerMove}
|
|
||||||
onPointerUp={onBoardPointerEnd} onPointerCancel={onBoardPointerEnd} />
|
|
||||||
{phase === 'countdown' && <div className="snake-overlay">{state.countdown}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderScores = () => (
|
|
||||||
<Table size="sm" className="snake-scores">
|
|
||||||
<thead>
|
|
||||||
<tr><th>#</th><th>Hráč</th><th>Jídla</th><th>Délka</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{ranked.map((p, i) => (
|
|
||||||
<tr key={p.login} className={p.alive || phase !== 'playing' ? undefined : 'snake-dead'}>
|
|
||||||
<td>{phase === 'finished' && i < 3 ? MEDALS[i] : (phase === 'finished' ? i + 1 : '')}</td>
|
|
||||||
<td>
|
|
||||||
<span className="snake-dot" style={{ background: COLORS[p.color % COLORS.length] }} />
|
|
||||||
{p.login}{p.login === login && ' (vy)'}{!p.alive && inGame && ' 💀'}
|
|
||||||
</td>
|
|
||||||
<td>{p.score}</td>
|
|
||||||
<td>{p.body.length || '—'}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBody = () => {
|
|
||||||
switch (phase) {
|
|
||||||
case 'idle':
|
|
||||||
return (
|
|
||||||
<p>
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
case 'lobby':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
|
|
||||||
<ul className="snake-players">
|
|
||||||
{state?.players.map(p => (
|
|
||||||
<li key={p.login}>
|
|
||||||
<span className="snake-dot" style={{ background: COLORS[p.color % COLORS.length] }} />
|
|
||||||
{p.login}{p.login === state.host && ' 👑'}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{isPlayer && <LobbyChat game="snake" />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case 'countdown':
|
|
||||||
case 'playing':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{renderBoard()}
|
|
||||||
<div className="snake-status">
|
|
||||||
{isPlayer ? (touch ? 'Táhněte prstem po ploše' : 'Šipky / WSAD') : 'Sledujete hru'} · zbývá {secondsLeft} s
|
|
||||||
</div>
|
|
||||||
{touch && isPlayer && (
|
|
||||||
<div className="snake-touch-pad">
|
|
||||||
<TouchButton label="Nahoru" className="up" onPress={() => turn('up')}><FontAwesomeIcon icon={faCaretUp} /></TouchButton>
|
|
||||||
<TouchButton label="Doleva" className="left" onPress={() => turn('left')}><FontAwesomeIcon icon={faCaretLeft} /></TouchButton>
|
|
||||||
<TouchButton label="Doprava" className="right" onPress={() => turn('right')}><FontAwesomeIcon icon={faCaretRight} /></TouchButton>
|
|
||||||
<TouchButton label="Dolů" className="down" onPress={() => turn('down')}><FontAwesomeIcon icon={faCaretDown} /></TouchButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{renderScores()}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case 'finished':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{renderBoard()}
|
|
||||||
{renderScores()}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderFooter = () => {
|
|
||||||
const buttons = [];
|
|
||||||
if (phase === 'idle' || phase === 'finished') {
|
|
||||||
buttons.push(<Button key="new" onClick={() => send('snake:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
|
||||||
}
|
|
||||||
if (phase === 'lobby') {
|
|
||||||
if (!isPlayer) {
|
|
||||||
buttons.push(<Button key="join" onClick={() => send('snake:join')}>Připojit se</Button>);
|
|
||||||
} else {
|
|
||||||
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('snake:leave')}>
|
|
||||||
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
|
||||||
</Button>);
|
|
||||||
if (login === state?.host) {
|
|
||||||
buttons.push(<Button key="start" onClick={() => send('snake:start')}
|
|
||||||
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 1)}>Spustit hru</Button>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
|
||||||
return buttons;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showInvite && (
|
|
||||||
<button className="duel-invite snake-invite" onClick={() => setOpen(true)}>
|
|
||||||
{icon} <strong>{state?.host}</strong> zve na hada — připojit se
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{showReturn && (
|
|
||||||
<button className="duel-invite snake-invite" onClick={() => setOpen(true)}>
|
|
||||||
{icon} Had běží — zpět do hry
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<Modal show={open} onHide={() => setOpen(false)} size="xl" fullscreen="sm-down" className="snake-game-modal">
|
|
||||||
<Modal.Header closeButton>
|
|
||||||
<Modal.Title><h2>{icon} Had</h2></Modal.Title>
|
|
||||||
</Modal.Header>
|
|
||||||
<Modal.Body>
|
|
||||||
{renderBody()}
|
|
||||||
{error && <div className="text-danger mt-2">{error}</div>}
|
|
||||||
</Modal.Body>
|
|
||||||
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<TankState | null>(null);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [botLevel, setBotLevel] = useState<BotLevel>('medium');
|
|
||||||
const [controls, setControls] = useState<Controls>(getInitialControls);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const prevPhase = useRef<TankPhase>('idle');
|
|
||||||
const flashes = useRef<MuzzleFlash[]>([]);
|
|
||||||
const prevShots = useRef(new Map<string, number>());
|
|
||||||
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<string>();
|
|
||||||
let mouseFire = false;
|
|
||||||
let sent = '';
|
|
||||||
const any = (keys: Set<string>) => [...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) => <span className="tanks-dot" style={{ background: TEAM_COLORS[team][0] }} />;
|
|
||||||
|
|
||||||
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 && (
|
|
||||||
<div className="tanks-layout">
|
|
||||||
{state.teams && state.teamScores && (
|
|
||||||
<div className="tanks-teamscore">
|
|
||||||
<span style={{ color: TEAM_COLORS[0][0] }}>{TEAM_NAMES[0]}</span>
|
|
||||||
<strong>{state.teamScores[0]} : {state.teamScores[1]}</strong>
|
|
||||||
<span style={{ color: TEAM_COLORS[1][0] }}>{TEAM_NAMES[1]}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="tanks-board-wrap">
|
|
||||||
<canvas ref={canvasRef} className="tanks-board" width={state.width} height={state.height} />
|
|
||||||
{phase === 'countdown' && <div className="tanks-overlay">{state.countdown}</div>}
|
|
||||||
{phase === 'playing' && me && !me.alive && me.respawnIn !== undefined && (
|
|
||||||
<div className="tanks-overlay small">Zničen — návrat za {Math.ceil(me.respawnIn / 1000)} s</div>
|
|
||||||
)}
|
|
||||||
{phase === 'playing' && me && !me.alive && me.respawnIn === undefined && (
|
|
||||||
<div className="tanks-overlay small">Vypadli jste — sledujete hru</div>
|
|
||||||
)}
|
|
||||||
{phase === 'finished' && <div className="tanks-overlay small">{winnerText()}</div>}
|
|
||||||
</div>
|
|
||||||
{inGame && me?.alive && (
|
|
||||||
<div className="tanks-equipment">
|
|
||||||
Výbava: {equipment(me).length ? equipment(me).join(' ') : <span className="text-muted">nic — sbírejte bonusy</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="tanks-status">
|
|
||||||
{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')}
|
|
||||||
</div>
|
|
||||||
{inGame && isPlayer && renderControls()}
|
|
||||||
{inGame && (
|
|
||||||
<ul className="tanks-legend">
|
|
||||||
{(Object.entries(PICKUPS) as [PickupType, typeof PICKUPS[PickupType]][])
|
|
||||||
.filter(([type]) => type !== 'heart' || state.mode === 'lastman')
|
|
||||||
.map(([type, p]) => <li key={type}>{p.icon} {p.label}</li>)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{renderScores()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Volba ovládání — osobní nastavení hráče, pamatuje si ho prohlížeč. */
|
|
||||||
const renderControls = () => (
|
|
||||||
<div className="tanks-controls">
|
|
||||||
<span className="tanks-option-label">Ovládání</span>
|
|
||||||
{CONTROLS.map(c => (
|
|
||||||
<Form.Check key={c.value} inline type="radio" id={`tanks-controls-${c.value}`} name="tanks-controls"
|
|
||||||
label={c.label} title={c.hint} checked={controls === c.value} onChange={() => changeControls(c.value)} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderScores = () => (
|
|
||||||
<Table size="sm" className="tanks-scores">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>#</th>
|
|
||||||
<th>Hráč</th>
|
|
||||||
{state?.mode === 'lastman' ? <th>Životy</th> : <th>Body</th>}
|
|
||||||
<th>Zničil(a)</th>
|
|
||||||
<th>Zničen(a)</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{ranked.map((p, i) => (
|
|
||||||
<tr key={p.login} className={state?.mode === 'lastman' && p.lives === 0 && inGame ? 'tanks-out' : undefined}>
|
|
||||||
<td>{phase === 'finished' ? (i < 3 ? MEDALS[i] : i + 1) : ''}</td>
|
|
||||||
<td>
|
|
||||||
<span className="tanks-dot" style={{ background: tankColor(p) }} />
|
|
||||||
{p.login}{p.login === login && ' (vy)'}
|
|
||||||
{state?.teams && p.team !== undefined && <span className="text-muted"> · {TEAM_NAMES[p.team]}</span>}
|
|
||||||
{p.alive && inGame && equipment(p).length > 0 && <span className="tanks-items"> {equipment(p).join(' ')}</span>}
|
|
||||||
</td>
|
|
||||||
<td>{state?.mode === 'lastman' ? '❤️'.repeat(p.lives ?? 0) || '💀' : p.score}</td>
|
|
||||||
<td>{p.kills}</td>
|
|
||||||
<td>{p.deaths}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderPlayer = (p: TankPlayer) => (
|
|
||||||
<li key={p.login}>
|
|
||||||
<span className="tanks-dot" style={{ background: tankColor(p) }} />
|
|
||||||
{p.login}{p.login === state?.host && ' 👑'}
|
|
||||||
{state?.teams && (isHost || p.login === login) && (
|
|
||||||
<Button size="sm" variant="link" className="tanks-switch" title="Přesunout do druhého týmu"
|
|
||||||
onClick={() => send('tanks:team', { target: p.login })}>⇄</Button>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderLobby = () => state && (
|
|
||||||
<>
|
|
||||||
<p>Lobby založil(a) <strong>{state.host}</strong>. Hráči ({state.players.length}/{state.maxPlayers}):</p>
|
|
||||||
{state.teams ? (
|
|
||||||
<div className="tanks-teams">
|
|
||||||
{([0, 1] as const).map(team => {
|
|
||||||
const members = state.players.filter(p => p.team === team);
|
|
||||||
return (
|
|
||||||
<div key={team}>
|
|
||||||
<strong>{teamDot(team)}{TEAM_NAMES[team]}</strong> ({members.length}/{MAX_TEAM_SIZE})
|
|
||||||
<ul className="tanks-players">{members.map(renderPlayer)}</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="tanks-players two-columns">{state.players.map(renderPlayer)}</ul>
|
|
||||||
)}
|
|
||||||
{isHost ? (
|
|
||||||
<>
|
|
||||||
<div className="tanks-config">
|
|
||||||
<Form.Group>
|
|
||||||
<Form.Label>Režim</Form.Label>
|
|
||||||
<Form.Select size="sm" value={state.mode} onChange={e => send('tanks:configure', { mode: e.target.value })}>
|
|
||||||
{MODES.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
|
|
||||||
</Form.Select>
|
|
||||||
</Form.Group>
|
|
||||||
<Form.Group>
|
|
||||||
<Form.Label>Mapa</Form.Label>
|
|
||||||
<Form.Select size="sm" value={state.mapId} onChange={e => send('tanks:configure', { mapId: e.target.value })}>
|
|
||||||
<option value="random">{mapLabel()}</option>
|
|
||||||
{state.maps.map(m => <option key={m.id} value={m.id}>{mapLabel(m)}</option>)}
|
|
||||||
</Form.Select>
|
|
||||||
</Form.Group>
|
|
||||||
</div>
|
|
||||||
<div className="tanks-options">
|
|
||||||
<span className="tanks-option-label">Hráči</span>
|
|
||||||
<div>
|
|
||||||
<Form.Check inline type="radio" id="tanks-solo" name="tanks-teams" label="Každý sám za sebe"
|
|
||||||
checked={!state.teams} onChange={() => send('tanks:configure', { teams: false })} />
|
|
||||||
<Form.Check inline type="radio" id="tanks-teams" name="tanks-teams" label="Dva týmy"
|
|
||||||
checked={state.teams} onChange={() => send('tanks:configure', { teams: true })} />
|
|
||||||
</div>
|
|
||||||
<span className="tanks-option-label">Střely</span>
|
|
||||||
<Form.Check type="switch" id="tanks-bounce" label="Odráží se od zdí" checked={state.bounce}
|
|
||||||
onChange={e => send('tanks:configure', { bounce: e.target.checked })} />
|
|
||||||
<span className="tanks-option-label">Hardcore</span>
|
|
||||||
<Form.Check type="switch" id="tanks-hardcore" label="🌑 Tma — vidět je jen okolí tanku" checked={state.hardcore}
|
|
||||||
onChange={e => send('tanks:configure', { hardcore: e.target.checked })} />
|
|
||||||
{(state.players.length < state.maxPlayers || state.players.some(p => p.bot)) && (
|
|
||||||
<>
|
|
||||||
<span className="tanks-option-label">Boti</span>
|
|
||||||
<div className="tanks-bot-picker">
|
|
||||||
{state.players.length < state.maxPlayers && (
|
|
||||||
<>
|
|
||||||
<Form.Select size="sm" value={botLevel} onChange={e => setBotLevel(e.target.value as BotLevel)}>
|
|
||||||
{BOT_LEVELS.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
|
|
||||||
</Form.Select>
|
|
||||||
<Button size="sm" variant="outline-primary" onClick={() => send('tanks:addBot', { level: botLevel })}>
|
|
||||||
Přidat bota
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{state.players.some(p => p.bot) && (
|
|
||||||
<Button size="sm" variant="outline-secondary" onClick={() => send('tanks:removeBots')}>Odebrat boty</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p>
|
|
||||||
Režim: <strong>{mode.label}{state.teams && ' v týmech'}{state.bounce && ', odrazy'}{state.hardcore && ', 🌑 hardcore'}</strong>
|
|
||||||
{' · '}mapa: <strong>{mapName}</strong>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-muted small mb-0">
|
|
||||||
{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.'}
|
|
||||||
</p>
|
|
||||||
{isPlayer && renderControls()}
|
|
||||||
{isPlayer && <LobbyChat game="tanks" />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBody = () => {
|
|
||||||
switch (phase) {
|
|
||||||
case 'idle':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
<p className="mb-1">Bonusy:</p>
|
|
||||||
<ul className="tanks-pickups">
|
|
||||||
{Object.values(PICKUPS).map(p => <li key={p.icon}>{p.icon} {p.label}</li>)}
|
|
||||||
</ul>
|
|
||||||
{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(<Button key="new" onClick={() => send('tanks:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
|
||||||
}
|
|
||||||
if (phase === 'lobby') {
|
|
||||||
if (!isPlayer) {
|
|
||||||
buttons.push(<Button key="join" onClick={() => send('tanks:join')}
|
|
||||||
disabled={(state?.players.length ?? 0) >= (state?.maxPlayers ?? 0)}>Připojit se</Button>);
|
|
||||||
} else {
|
|
||||||
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('tanks:leave')}>
|
|
||||||
{isHost ? 'Zrušit lobby' : 'Odejít'}
|
|
||||||
</Button>);
|
|
||||||
if (isHost) {
|
|
||||||
buttons.push(<Button key="start" onClick={() => send('tanks:start')} disabled={startBlocked()}>Spustit hru</Button>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
|
||||||
return buttons;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showInvite && (
|
|
||||||
<button className="duel-invite tanks-invite" onClick={() => setOpen(true)}>
|
|
||||||
{icon} <strong>{state?.host}</strong> zve na tanky — připojit se
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{showReturn && (
|
|
||||||
<button className="duel-invite tanks-invite" onClick={() => setOpen(true)}>
|
|
||||||
{icon} Tanky běží — zpět do hry
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="tanks-game-modal">
|
|
||||||
<Modal.Header closeButton>
|
|
||||||
<Modal.Title><h2>{icon} Tanky</h2></Modal.Title>
|
|
||||||
</Modal.Header>
|
|
||||||
<Modal.Body>
|
|
||||||
{renderBody()}
|
|
||||||
{error && <div className="text-danger mt-2">{error}</div>}
|
|
||||||
</Modal.Body>
|
|
||||||
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<TetrisTargeting, { label: string, help: string }> = {
|
|
||||||
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<TetrisWinMode, string> = {
|
|
||||||
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<string, Action> = {
|
|
||||||
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<Action>(['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<number | string>, 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<HTMLCanvasElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (ref.current) drawBoard(ref.current, player.board, MINI_CELL);
|
|
||||||
}, [player.board]);
|
|
||||||
return (
|
|
||||||
<div className={`tetris-mini ${player.alive ? '' : 'dead'}`}>
|
|
||||||
<canvas ref={ref} width={WIDTH * MINI_CELL} height={HEIGHT * MINI_CELL} />
|
|
||||||
<div className="tetris-mini-name">{player.login}{!player.alive && ' 💀'}</div>
|
|
||||||
<div className="tetris-mini-score">{player.score}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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<TetrisState | null>(null);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [flash, setFlash] = useState<string | null>(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<TetrisSession | null>(null);
|
|
||||||
const sessionSeed = useRef<number | undefined>(undefined);
|
|
||||||
const boardRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const nextRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const prevPhase = useRef<TetrisPhase>('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<number | undefined>(undefined);
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
const icon = useGameIcon('tetris');
|
|
||||||
const touch = useTouchDevice();
|
|
||||||
const gesture = useRef<Gesture | null>(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<HTMLCanvasElement>) => {
|
|
||||||
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<HTMLCanvasElement>) => {
|
|
||||||
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<HTMLCanvasElement>) => {
|
|
||||||
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<TetrisSettings>) => send('tetris:configure', change);
|
|
||||||
|
|
||||||
const renderSettings = () => settings && (
|
|
||||||
<div className="tetris-settings">
|
|
||||||
<Form.Group>
|
|
||||||
<Form.Label>Míření odpadu</Form.Label>
|
|
||||||
{isHost ? (
|
|
||||||
<Form.Select size="sm" value={settings.targeting}
|
|
||||||
onChange={e => configure({ targeting: e.target.value as TetrisTargeting })}>
|
|
||||||
{Object.entries(TARGETING_LABELS).map(([value, { label }]) => <option key={value} value={value}>{label}</option>)}
|
|
||||||
</Form.Select>
|
|
||||||
) : <div><strong>{TARGETING_LABELS[settings.targeting].label}</strong></div>}
|
|
||||||
<Form.Text>{TARGETING_LABELS[settings.targeting].help}</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
<Form.Group>
|
|
||||||
<Form.Label>Vítěz</Form.Label>
|
|
||||||
{isHost ? (
|
|
||||||
<Form.Select size="sm" value={settings.winMode}
|
|
||||||
onChange={e => configure({ winMode: e.target.value as TetrisWinMode })}>
|
|
||||||
{Object.entries(WIN_MODE_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
|
||||||
</Form.Select>
|
|
||||||
) : <div><strong>{WIN_MODE_LABELS[settings.winMode]}</strong></div>}
|
|
||||||
</Form.Group>
|
|
||||||
{settings.winMode === 'score' && (
|
|
||||||
<Form.Group>
|
|
||||||
<Form.Label>Délka hry</Form.Label>
|
|
||||||
{isHost ? (
|
|
||||||
<Form.Select size="sm" value={settings.timeLimit}
|
|
||||||
onChange={e => configure({ timeLimit: Number(e.target.value) })}>
|
|
||||||
{TIME_LIMITS.map(t => <option key={t} value={t}>{t / 60} min</option>)}
|
|
||||||
</Form.Select>
|
|
||||||
) : <div><strong>{settings.timeLimit / 60} min</strong></div>}
|
|
||||||
</Form.Group>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderPlaying = () => (
|
|
||||||
<div className="tetris-layout">
|
|
||||||
{(isPlayer || practice) && (
|
|
||||||
<div className="tetris-own-col">
|
|
||||||
<div className="tetris-own">
|
|
||||||
<div className="tetris-board-wrap">
|
|
||||||
<canvas ref={boardRef} className="tetris-board" width={WIDTH * CELL} height={HEIGHT * CELL}
|
|
||||||
onPointerDown={onBoardPointerDown} onPointerMove={onBoardPointerMove}
|
|
||||||
onPointerUp={onBoardPointerUp} onPointerCancel={onBoardPointerCancel} />
|
|
||||||
{!practice && phase === 'countdown' && <div className="tetris-overlay">{state?.countdown}</div>}
|
|
||||||
{session && !session.alive && <div className="tetris-overlay small">Konec 💀</div>}
|
|
||||||
{flash && <div className="tetris-flash">{flash}</div>}
|
|
||||||
</div>
|
|
||||||
<div className="tetris-side">
|
|
||||||
{remaining !== undefined && (
|
|
||||||
<>
|
|
||||||
<div className="tetris-label">Zbývá</div>
|
|
||||||
<div className="tetris-value">{formatTime(remaining)}</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="tetris-label">Další</div>
|
|
||||||
<canvas ref={nextRef} width={100} height={60} />
|
|
||||||
<div className="tetris-label">Skóre</div>
|
|
||||||
<div className="tetris-value">{session?.score ?? 0}</div>
|
|
||||||
<div className="tetris-label">Řádky</div>
|
|
||||||
<div className="tetris-value">{session?.lines ?? 0}</div>
|
|
||||||
<div className="tetris-label">Úroveň</div>
|
|
||||||
<div className="tetris-value">{session?.level ?? 0}</div>
|
|
||||||
<div className="tetris-help">
|
|
||||||
{touch
|
|
||||||
? <>ťuknutí otočí<br />tažení posouvá<br />švihnutí dolů shodí</>
|
|
||||||
: <>←→ / AD posun<br />↑ / W otočit<br />↓ / S dolů<br />mezerník shodit</>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{touch && (
|
|
||||||
<div className="tetris-touch-pad">
|
|
||||||
<div className="tetris-touch-group">
|
|
||||||
<TouchButton label="Doleva" repeat onPress={() => control('left')}><FontAwesomeIcon icon={faCaretLeft} /></TouchButton>
|
|
||||||
<TouchButton label="Dolů" repeat onPress={() => control('down')}><FontAwesomeIcon icon={faCaretDown} /></TouchButton>
|
|
||||||
<TouchButton label="Doprava" repeat onPress={() => control('right')}><FontAwesomeIcon icon={faCaretRight} /></TouchButton>
|
|
||||||
</div>
|
|
||||||
<div className="tetris-touch-group">
|
|
||||||
<TouchButton label="Otočit po směru hodinových ručiček" onPress={() => control('rotateBack')}><FontAwesomeIcon icon={faRotateRight} /></TouchButton>
|
|
||||||
<TouchButton label="Otočit proti směru hodinových ručiček" onPress={() => control('rotate')}><FontAwesomeIcon icon={faRotateLeft} /></TouchButton>
|
|
||||||
<TouchButton label="Shodit" onPress={() => control('drop')}><FontAwesomeIcon icon={faAnglesDown} /></TouchButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{others.length > 0 && (
|
|
||||||
<div className="tetris-opponents">
|
|
||||||
{others.map(p => <MiniBoard key={p.login} player={p} />)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBody = () => {
|
|
||||||
if (practice) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p className="text-muted text-center">🎯 Trénink — hra jen pro vás, nikdo ji nevidí.</p>
|
|
||||||
{renderPlaying()}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
switch (phase) {
|
|
||||||
case 'idle':
|
|
||||||
return (
|
|
||||||
<p>
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
case 'lobby':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
|
|
||||||
<ul className="tetris-players">
|
|
||||||
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
|
|
||||||
</ul>
|
|
||||||
{state && state.players.length < state.minPlayers &&
|
|
||||||
<p className="text-muted">Čeká se na další hráče… Pro hru o samotě použijte Trénink.</p>}
|
|
||||||
{renderSettings()}
|
|
||||||
{isPlayer && <LobbyChat game="tetris" />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case 'countdown':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!isPlayer && (
|
|
||||||
<p className="text-center">
|
|
||||||
Hra začne za <strong>{state?.countdown} s</strong> — ještě se můžete připojit.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{renderPlaying()}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case 'playing':
|
|
||||||
return renderPlaying();
|
|
||||||
case 'finished':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{settings && (
|
|
||||||
<p className="text-muted">
|
|
||||||
Hodnocení: {WIN_MODE_LABELS[settings.winMode]}
|
|
||||||
{settings.winMode === 'score' && ` (${settings.timeLimit / 60} min)`}
|
|
||||||
{' · '}míření: {TARGETING_LABELS[settings.targeting].label}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Table size="sm" className="tetris-results">
|
|
||||||
<thead>
|
|
||||||
<tr><th>#</th><th>Hráč</th><th>Skóre</th><th>Řádky</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{ranked.map((p, i) => (
|
|
||||||
<tr key={p.login}>
|
|
||||||
<td>{i < 3 ? MEDALS[i] : i + 1}</td>
|
|
||||||
<td>{p.login}{p.login === login && ' (vy)'}</td>
|
|
||||||
<td>{p.score}</td>
|
|
||||||
<td>{p.lines}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderFooter = () => {
|
|
||||||
const buttons = [];
|
|
||||||
if (practice) {
|
|
||||||
if (session && !session.alive) {
|
|
||||||
buttons.push(<Button key="again" onClick={startPractice}>Znovu</Button>);
|
|
||||||
}
|
|
||||||
buttons.push(<Button key="stop" variant="outline-secondary" onClick={stopPractice}>Ukončit trénink</Button>);
|
|
||||||
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
|
||||||
return buttons;
|
|
||||||
}
|
|
||||||
// Trénink jde spustit, kdykoli zrovna nehrajete online hru
|
|
||||||
if (!(isPlayer && (phase === 'lobby' || inGame))) {
|
|
||||||
buttons.push(<Button key="practice" variant="outline-primary" onClick={startPractice}>Trénink</Button>);
|
|
||||||
}
|
|
||||||
if (phase === 'idle' || phase === 'finished') {
|
|
||||||
buttons.push(<Button key="new" onClick={() => send('tetris:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
|
||||||
}
|
|
||||||
if (joinable && !isPlayer) {
|
|
||||||
buttons.push(<Button key="join" onClick={() => send('tetris:join')}>Připojit se</Button>);
|
|
||||||
}
|
|
||||||
if (phase === 'lobby' && isPlayer) {
|
|
||||||
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('tetris:leave')}>
|
|
||||||
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
|
||||||
</Button>);
|
|
||||||
if (login === state?.host) {
|
|
||||||
buttons.push(<Button key="start" onClick={() => send('tetris:start')}
|
|
||||||
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 2)}>Spustit hru</Button>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
|
||||||
return buttons;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showInvite && (
|
|
||||||
<button className="duel-invite tetris-invite" onClick={() => setOpen(true)}>
|
|
||||||
{icon} {phase === 'countdown'
|
|
||||||
? <>Tetris začíná za <strong>{state?.countdown} s</strong> — připojit se</>
|
|
||||||
: <><strong>{state?.host}</strong> zve na Tetris — připojit se</>}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{showReturn && (
|
|
||||||
<button className="duel-invite tetris-invite" onClick={() => setOpen(true)}>
|
|
||||||
{icon} Tetris běží — zpět do hry
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<Modal show={open} onHide={() => setOpen(false)} size="xl" fullscreen="sm-down" className="tetris-game-modal">
|
|
||||||
<Modal.Header closeButton>
|
|
||||||
<Modal.Title><h2>{icon} Tetris</h2></Modal.Title>
|
|
||||||
</Modal.Header>
|
|
||||||
<Modal.Body>
|
|
||||||
{renderBody()}
|
|
||||||
{error && <div className="text-danger mt-2">{error}</div>}
|
|
||||||
</Modal.Body>
|
|
||||||
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<number | undefined>(undefined);
|
|
||||||
const onPressRef = useRef(onPress);
|
|
||||||
onPressRef.current = onPress;
|
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
window.clearTimeout(timer.current);
|
|
||||||
timer.current = undefined;
|
|
||||||
};
|
|
||||||
useEffect(() => stop, []);
|
|
||||||
|
|
||||||
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
||||||
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 (
|
|
||||||
<button type="button" tabIndex={-1} aria-label={label} className={`touch-btn ${className ?? ''}`}
|
|
||||||
onPointerDown={onPointerDown} onPointerUp={stop} onPointerCancel={stop} onLostPointerCapture={stop}
|
|
||||||
onContextMenu={e => e.preventDefault()}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<PickupType, { icon: string, label: string }> = {
|
|
||||||
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<Biome, { icon: string }> = {
|
|
||||||
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<Biome, BiomeStyle> = {
|
|
||||||
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<Biome, { patch: string, blade: string, blade2: string }> = {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
import { StyleTheme, useSettings } from "../context/settings";
|
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<StyleTheme, Record<GameId, string>> = {
|
const ICONS: Record<StyleTheme, Record<GameId, string>> = {
|
||||||
simple: { reaction: '⚡', snake: '🐍', tetris: '🧱', pong: '🏓', drawing: '🎨', tanks: '💥' },
|
simple: { drawing: '🎨' },
|
||||||
// Dostihy: startovní vlajka, kůň místo hada, mrkve jako kostky, žokej, trofej, terč
|
// Dostihy: trofej
|
||||||
umamusume: { reaction: '🏁', snake: '🐎', tetris: '🥕', pong: '🏇', drawing: '🏆', tanks: '🎯' },
|
umamusume: { drawing: '🏆' },
|
||||||
// Duha, jednorožec, drahokamy, balónky, paleta a blesk Rainbow Dash
|
// Paleta Rarity
|
||||||
mlp: { reaction: '🌈', snake: '🦄', tetris: '💎', pong: '🎈', drawing: '🖌️', tanks: '⚡' },
|
mlp: { drawing: '🖌️' },
|
||||||
// Pekelná korespondence: plamen, rudá nit, lampion, slaměná panenka, svitek, svíčka
|
// Pekelná korespondence: svitek
|
||||||
hellgirl: { reaction: '🔥', snake: '🪢', tetris: '🏮', pong: '🪆', drawing: '📜', tanks: '🕯️' },
|
hellgirl: { drawing: '📜' },
|
||||||
// Klub lehké hudby: bicí Ritsu, želva Ton-chan, dortíky k čaji, kytara Gitah, notový zápis a klávesy Mugi
|
// Klub lehké hudby: notový zápis
|
||||||
kon: { reaction: '🥁', snake: '🐢', tetris: '🍰', pong: '🎸', drawing: '🎼', tanks: '🎹' },
|
kon: { drawing: '🎼' },
|
||||||
// Kessoku Band: mikrofon Kity, kabel od kytary, krabice od manga, bicí Nijiky, sešit s texty Bocchi a její kytara
|
// Kessoku Band: sešit s texty Bocchi
|
||||||
bocchi: { reaction: '🎤', snake: '🔌', tetris: '📦', pong: '🥁', drawing: '📝', tanks: '🎸' },
|
bocchi: { drawing: '📝' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Styl z <html data-luncher-theme> — pro místa mimo React kontext nastavení. */
|
/** Styl z <html data-luncher-theme> — pro místa mimo React kontext nastavení. */
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 { openDrawingGame } from "../components/DrawingGame";
|
||||||
import { openTankGame } from "../components/TankGame";
|
|
||||||
import { GameId, gameIcon } from "../games/themeIcons";
|
import { GameId, gameIcon } from "../games/themeIcons";
|
||||||
|
import { useAppConfig } from "./useGroupLimits";
|
||||||
|
|
||||||
type Minigame = {
|
type Minigame = {
|
||||||
id: GameId,
|
id: GameId,
|
||||||
@@ -16,36 +12,11 @@ type Minigame = {
|
|||||||
open: () => void,
|
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[] = [
|
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',
|
id: 'drawing',
|
||||||
code: 'kresleni',
|
code: 'kresleni',
|
||||||
@@ -53,25 +24,23 @@ const MINIGAMES: Minigame[] = [
|
|||||||
description: 'Jeden kreslí jídlo z dnešních menu, ostatní hádají.',
|
description: 'Jeden kreslí jídlo z dnešních menu, ostatní hádají.',
|
||||||
open: openDrawingGame,
|
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 HELP_CODE = 'help';
|
||||||
const SECRET_CLICKS = 3;
|
const SECRET_CLICKS = 3;
|
||||||
/** Max. pauza mezi kliknutími na logo, aby se počítala do série (ms). */
|
/** Max. pauza mezi kliknutími na logo, aby se počítala do série (ms). */
|
||||||
const SECRET_CLICK_GAP_MS = 400;
|
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;
|
const SECRET_ARM_MS = 8000;
|
||||||
|
|
||||||
function printHelp() {
|
function printHelp(lobbyUrl?: string) {
|
||||||
console.log('%c🎮 Luncher minihry', 'font-size: 16px; font-weight: bold');
|
console.log('%c🎮 Luncher minihry', 'font-size: 16px; font-weight: bold');
|
||||||
|
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.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 })));
|
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();
|
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,
|
* Skrytý vstup do miniher: 3× rychle kliknout na logo. Je-li nastavena adresa Lobby, otevře se v nové záložce.
|
||||||
* případně "help" pro výpis her do konzole prohlížeče.
|
* 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).
|
* 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) {
|
export function useMinigameSecret(href: string) {
|
||||||
|
const { lobbyUrl } = useAppConfig();
|
||||||
const [armed, setArmed] = useState(false);
|
const [armed, setArmed] = useState(false);
|
||||||
const clicks = useRef(0);
|
const clicks = useRef(0);
|
||||||
const timer = useRef<number | undefined>(undefined);
|
const timer = useRef<number | undefined>(undefined);
|
||||||
@@ -98,12 +69,17 @@ export function useMinigameSecret(href: string) {
|
|||||||
clicks.current++;
|
clicks.current++;
|
||||||
if (clicks.current >= SECRET_CLICKS) {
|
if (clicks.current >= SECRET_CLICKS) {
|
||||||
clicks.current = 0;
|
clicks.current = 0;
|
||||||
|
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
|
// Fokus musí proběhnout přímo v obsluze kliknutí, jinak iOS klávesnici neotevře
|
||||||
const input = inputRef.current;
|
const input = inputRef.current;
|
||||||
if (input) {
|
if (input) {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
input.focus({ preventScroll: true });
|
input.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setArmed(true);
|
setArmed(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -111,7 +87,7 @@ export function useMinigameSecret(href: string) {
|
|||||||
clicks.current = 0;
|
clicks.current = 0;
|
||||||
window.location.assign(href);
|
window.location.assign(href);
|
||||||
}, SECRET_CLICK_GAP_MS);
|
}, SECRET_CLICK_GAP_MS);
|
||||||
}, [href]);
|
}, [href, lobbyUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!armed) return;
|
if (!armed) return;
|
||||||
@@ -121,7 +97,7 @@ export function useMinigameSecret(href: string) {
|
|||||||
const check = (text: string) => {
|
const check = (text: string) => {
|
||||||
if (text.endsWith(HELP_CODE)) {
|
if (text.endsWith(HELP_CODE)) {
|
||||||
setArmed(false);
|
setArmed(false);
|
||||||
printHelp();
|
printHelp(lobbyUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const game = MINIGAMES.find(g => text.endsWith(g.code));
|
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
|
// Softwarová klávesnice (Android) posílá v keydown jen "Unidentified", proto se čte celý text pole
|
||||||
const onInput = () => check(normalizeCode(input?.value ?? ''));
|
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);
|
window.addEventListener('keydown', onKey);
|
||||||
input?.addEventListener('input', onInput);
|
input?.addEventListener('input', onInput);
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(disarm);
|
window.clearTimeout(disarm);
|
||||||
window.removeEventListener('keydown', onKey);
|
window.removeEventListener('keydown', onKey);
|
||||||
input?.removeEventListener('input', onInput);
|
input?.removeEventListener('input', onInput);
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
if (input) {
|
if (input) {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
input.blur();
|
input.blur();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [armed]);
|
}, [armed, lobbyUrl]);
|
||||||
|
|
||||||
useEffect(() => () => window.clearTimeout(timer.current), []);
|
useEffect(() => () => window.clearTimeout(timer.current), []);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,7 @@ getConfig().then(({ data }) => {
|
|||||||
loaded: true,
|
loaded: true,
|
||||||
trustedHeaders: data?.auth?.trustedHeaders,
|
trustedHeaders: data?.auth?.trustedHeaders,
|
||||||
storeAdmin: data?.features?.storeAdmin,
|
storeAdmin: data?.features?.storeAdmin,
|
||||||
|
lobbyUrl: data?.links?.lobbyUrl ?? undefined,
|
||||||
groupLimits: data?.limits?.group,
|
groupLimits: data?.limits?.group,
|
||||||
});
|
});
|
||||||
if (data?.sentry.dsn) {
|
if (data?.sentry.dsn) {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export type AppRuntimeConfig = {
|
|||||||
trustedHeaders?: boolean,
|
trustedHeaders?: boolean,
|
||||||
/** Správa obchodů je zapnutá (server má admin heslo); undefined = nevíme */
|
/** Správa obchodů je zapnutá (server má admin heslo); undefined = nevíme */
|
||||||
storeAdmin?: boolean,
|
storeAdmin?: boolean,
|
||||||
|
/** Adresa aplikace Lobby s minihrami; undefined = není nastavena nebo nevíme */
|
||||||
|
lobbyUrl?: string,
|
||||||
groupLimits: GroupLimits,
|
groupLimits: GroupLimits,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Zda se má stisk klávesy ignorovat pro navigaci šipkami mezi dny/týdny na pozadí stránky.
|
* 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ý
|
* 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.
|
* 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 {
|
export function isArrowNavigationBlocked(e: KeyboardEvent): boolean {
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ data:
|
|||||||
REDIS_PORT: "6379"
|
REDIS_PORT: "6379"
|
||||||
PORT: "3001"
|
PORT: "3001"
|
||||||
HOST: "0.0.0.0"
|
HOST: "0.0.0.0"
|
||||||
|
LOBBY_URL: "http://lobby.localhost"
|
||||||
@@ -52,6 +52,10 @@
|
|||||||
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
|
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
|
||||||
# REFRESH_BYPASS_PASSWORD=
|
# 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.
|
# 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).
|
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
|
||||||
# ADMIN_PASSWORD=
|
# ADMIN_PASSWORD=
|
||||||
|
|||||||
@@ -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“",
|
"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 😉",
|
"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 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 😉"
|
||||||
]
|
]
|
||||||
@@ -150,6 +150,9 @@ app.get("/api/config", (_req, res) => {
|
|||||||
features: {
|
features: {
|
||||||
storeAdmin: !!process.env.ADMIN_PASSWORD,
|
storeAdmin: !!process.env.ADMIN_PASSWORD,
|
||||||
},
|
},
|
||||||
|
links: {
|
||||||
|
lobbyUrl: process.env.LOBBY_URL || null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+2
-18
@@ -1,7 +1,7 @@
|
|||||||
import { Server, Socket } from "socket.io";
|
import { Socket } from "socket.io";
|
||||||
import { getLogin } from "./auth";
|
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. */
|
/** Chyba určená hráči — posílá se zpět klientovi, nic se neloguje. */
|
||||||
export class GameError extends Error { }
|
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. */
|
/** 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;
|
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 });
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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<BotLevel, BotProfile> = {
|
|
||||||
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)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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<string, DuelPlayer>();
|
|
||||||
/** Výsledky aktuálního kola. */
|
|
||||||
private current = new Map<string, RoundResult>();
|
|
||||||
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)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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<Direction, Cell> = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
|
|
||||||
const OPPOSITE: Record<Direction, Direction> = { 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<string, InternalPlayer>();
|
|
||||||
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<string>();
|
|
||||||
|
|
||||||
// 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<string, number>();
|
|
||||||
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<string>(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)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<typeof setup>, reactions: Record<string, number>) {
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<typeof setup>, login: string, type: Parameters<TankGame['addPickupForTest']>[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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<TetrisSettings>, ...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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<number, number> = { 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<string, InternalPlayer>();
|
|
||||||
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<Record<keyof TetrisSettings, unknown>>;
|
|
||||||
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)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
import { DefaultEventsMap, Server } from "socket.io";
|
import { DefaultEventsMap, Server } from "socket.io";
|
||||||
import { createAdapter } from "@socket.io/redis-adapter";
|
import { createAdapter } from "@socket.io/redis-adapter";
|
||||||
import { createClient } from "redis";
|
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 { registerDrawingGame } from "./drawingGame";
|
||||||
import { registerTankGame } from "./tankGame";
|
|
||||||
import { registerLobbyChat } from "./minigames";
|
|
||||||
|
|
||||||
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
|
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
|
||||||
let pubClient: ReturnType<typeof createClient>;
|
let pubClient: ReturnType<typeof createClient>;
|
||||||
@@ -35,13 +29,7 @@ export const initWebsocket = (server: any) => {
|
|||||||
// console.log(`Client disconnected: ${socket.id}`);
|
// console.log(`Client disconnected: ${socket.id}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
registerReactionDuel(io);
|
|
||||||
registerSnakeGame(io);
|
|
||||||
registerTetrisGame(io);
|
|
||||||
registerPongGame(io);
|
|
||||||
registerDrawingGame(io);
|
registerDrawingGame(io);
|
||||||
registerTankGame(io);
|
|
||||||
registerLobbyChat(io);
|
|
||||||
return io;
|
return io;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
get:
|
get:
|
||||||
operationId: getConfig
|
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
|
security: [] # Nevyžaduje autentizaci
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -9,7 +9,7 @@ get:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [sentry, limits, auth, features]
|
required: [sentry, limits, auth, features, links]
|
||||||
properties:
|
properties:
|
||||||
auth:
|
auth:
|
||||||
type: object
|
type: object
|
||||||
@@ -25,6 +25,14 @@ get:
|
|||||||
storeAdmin:
|
storeAdmin:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Správa obchodů je zapnutá (server má nastavené ADMIN_PASSWORD)
|
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:
|
limits:
|
||||||
type: object
|
type: object
|
||||||
required: [group]
|
required: [group]
|
||||||
|
|||||||
Reference in new issue
Block a user