CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 30s
CI / Build client (push) Successful in 46s
CI / Playwright E2E tests (push) Successful in 1m57s
CI / Build and push Docker image (push) Successful in 46s
CI / Notify (push) Successful in 2s
Nová skrytá minihra (kód "tanky") pro více hráčů, simulaci drží server: - jízda šipkami/WSAD, otočná věž míří za myší, střelba klikem nebo mezerníkem - režimy poslední přeživší (výchozí) a na čas, volitelně ve dvou týmech - volitelné odrážení střel od zdí (výchozí vypnuto) - hardcore režim: tma, vidět je jen světlo kolem tanku a reflektor ve směru věže - bonusy: štít, trojitá střela, rakety, turbo, neviditelnost, naváděné střely, miny, život - 7 map v pěti prostředích (město, les, poušť, zima, bažina) s vodou, zpomalujícím terénem a zničitelnými bednami - boti se třemi obtížnostmi (hledání cesty, rozstřelování beden, v hardcore vidí jen světlo) - efekty: záblesk výstřelu, výbuchy, hořící vraky Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
510 lines
24 KiB
TypeScript
510 lines
24 KiB
TypeScript
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 "./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ý' },
|
|
];
|
|
|
|
const FORWARD_KEYS = new Set(['ArrowUp', 'KeyW']);
|
|
const BACK_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([...FORWARD_KEYS, ...BACK_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 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 input = {
|
|
drive: (any(FORWARD_KEYS) ? 1 : 0) - (any(BACK_KEYS) ? 1 : 0),
|
|
turn: (any(RIGHT_KEYS) ? 1 : 0) - (any(LEFT_KEYS) ? 1 : 0),
|
|
fire: any(FIRE_KEYS) || mouseFire,
|
|
};
|
|
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]);
|
|
|
|
// 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 (canvasRef.current && state && (inGame || phase === 'finished')) {
|
|
drawTanks(canvasRef.current, state, flashes.current, login);
|
|
}
|
|
}, [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
|
|
? ' · ↑↓ / W S jízda, ←→ / A D otáčení, myš míření, klik / mezerník střelba, E / pravé tlačítko mina'
|
|
: ' · sledujete hru')}
|
|
</div>
|
|
{renderScores()}
|
|
</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>
|
|
</>
|
|
);
|
|
|
|
const renderBody = () => {
|
|
switch (phase) {
|
|
case 'idle':
|
|
return (
|
|
<>
|
|
<p>
|
|
Tanková bitva pro více hráčů. Jezdí se šipkami nebo WSAD, 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>
|
|
</>
|
|
);
|
|
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>
|
|
</>
|
|
);
|
|
}
|