Compare commits
3
Commits
master
...
16cf680183
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16cf680183 | ||
|
|
c8132c283c | ||
|
|
2a5eb07778 |
No files matched your search
Binary file not shown.
@@ -0,0 +1,35 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useAuth } from "../context/auth";
|
|||||||
import { getToken } from "../Utils";
|
import { getToken } from "../Utils";
|
||||||
import { useGameIcon } from "../games/themeIcons";
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
import { useTouchDevice } from "../hooks/useTouchDevice";
|
import { useTouchDevice } from "../hooks/useTouchDevice";
|
||||||
|
import LobbyChat from "./LobbyChat";
|
||||||
import "./PongGame.scss";
|
import "./PongGame.scss";
|
||||||
|
|
||||||
// Typy musí odpovídat server/src/pongGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
// Typy musí odpovídat server/src/pongGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
||||||
@@ -257,6 +258,7 @@ export default function PongGame() {
|
|||||||
{login === state?.host && state?.players.some(p => p.bot) && (
|
{login === state?.host && state?.players.some(p => p.bot) && (
|
||||||
<Button size="sm" variant="outline-secondary" onClick={() => send('pong:removeBot')}>Odebrat bota</Button>
|
<Button size="sm" variant="outline-secondary" onClick={() => send('pong:removeBot')}>Odebrat bota</Button>
|
||||||
)}
|
)}
|
||||||
|
{isPlayer && <LobbyChat game="pong" />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useAuth } from "../context/auth";
|
|||||||
import { getToken } from "../Utils";
|
import { getToken } from "../Utils";
|
||||||
import { useGameIcon } from "../games/themeIcons";
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
import { useTouchDevice } from "../hooks/useTouchDevice";
|
import { useTouchDevice } from "../hooks/useTouchDevice";
|
||||||
|
import LobbyChat from "./LobbyChat";
|
||||||
import "./ReactionDuel.scss";
|
import "./ReactionDuel.scss";
|
||||||
|
|
||||||
// Typy musí odpovídat server/src/reactionDuel.ts (stav chodí přes websocket, ne přes OpenAPI)
|
// Typy musí odpovídat server/src/reactionDuel.ts (stav chodí přes websocket, ne přes OpenAPI)
|
||||||
@@ -183,6 +184,7 @@ export default function ReactionDuel() {
|
|||||||
</ul>
|
</ul>
|
||||||
{state && state.players.length < state.minPlayers &&
|
{state && state.players.length < state.minPlayers &&
|
||||||
<p className="text-muted">Čeká se na další hráče…</p>}
|
<p className="text-muted">Čeká se na další hráče…</p>}
|
||||||
|
{isPlayer && <LobbyChat game="duel" />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case 'waiting':
|
case 'waiting':
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getToken } from "../Utils";
|
|||||||
import { useGameIcon } from "../games/themeIcons";
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
import { useTouchDevice } from "../hooks/useTouchDevice";
|
import { useTouchDevice } from "../hooks/useTouchDevice";
|
||||||
import TouchButton from "./TouchButton";
|
import TouchButton from "./TouchButton";
|
||||||
|
import LobbyChat from "./LobbyChat";
|
||||||
import "./SnakeGame.scss";
|
import "./SnakeGame.scss";
|
||||||
|
|
||||||
// Typy musí odpovídat server/src/snakeGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
// Typy musí odpovídat server/src/snakeGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
||||||
@@ -279,6 +280,7 @@ export default function SnakeGame() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{isPlayer && <LobbyChat game="snake" />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case 'countdown':
|
case 'countdown':
|
||||||
|
|||||||
@@ -3,10 +3,17 @@
|
|||||||
bottom: 276px;
|
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-game-modal {
|
||||||
.tanks-board-wrap {
|
.tanks-board-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
max-width: 960px;
|
max-width: 1120px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +99,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
.tanks-equipment {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
BIOMES, BotLevel, drawTanks, FLASH_MS, MuzzleFlash, PICKUPS, PickupType, tankColor, TankMode, TankPhase,
|
BIOMES, BotLevel, drawTanks, FLASH_MS, MuzzleFlash, PICKUPS, PickupType, tankColor, TankMode, TankPhase,
|
||||||
TankPlayer, TankState, TEAM_COLORS, TEAM_NAMES,
|
TankPlayer, TankState, TEAM_COLORS, TEAM_NAMES,
|
||||||
} from "../games/tankRender";
|
} from "../games/tankRender";
|
||||||
|
import LobbyChat from "./LobbyChat";
|
||||||
import "./TankGame.scss";
|
import "./TankGame.scss";
|
||||||
|
|
||||||
const OPEN_EVENT = 'luncher:open-tanks';
|
const OPEN_EVENT = 'luncher:open-tanks';
|
||||||
@@ -30,13 +31,30 @@ const BOT_LEVELS: { value: BotLevel, label: string }[] = [
|
|||||||
{ value: 'hard', label: 'Těžký' },
|
{ value: 'hard', label: 'Těžký' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const FORWARD_KEYS = new Set(['ArrowUp', 'KeyW']);
|
/** Směrové: klávesa = směr jízdy na mapě; tankové: nahoru/dolů jízda vpřed/vzad, doleva/doprava otáčení. */
|
||||||
const BACK_KEYS = new Set(['ArrowDown', 'KeyS']);
|
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 LEFT_KEYS = new Set(['ArrowLeft', 'KeyA']);
|
||||||
const RIGHT_KEYS = new Set(['ArrowRight', 'KeyD']);
|
const RIGHT_KEYS = new Set(['ArrowRight', 'KeyD']);
|
||||||
const FIRE_KEYS = new Set(['Space']);
|
const FIRE_KEYS = new Set(['Space']);
|
||||||
const MINE_KEYS = new Set(['KeyE']);
|
const MINE_KEYS = new Set(['KeyE']);
|
||||||
const GAME_KEYS = new Set([...FORWARD_KEYS, ...BACK_KEYS, ...LEFT_KEYS, ...RIGHT_KEYS, ...FIRE_KEYS, ...MINE_KEYS]);
|
const GAME_KEYS = new Set([...UP_KEYS, ...DOWN_KEYS, ...LEFT_KEYS, ...RIGHT_KEYS, ...FIRE_KEYS, ...MINE_KEYS]);
|
||||||
|
|
||||||
const formatTime = (ms: number) => {
|
const formatTime = (ms: number) => {
|
||||||
const s = Math.ceil(ms / 1000);
|
const s = Math.ceil(ms / 1000);
|
||||||
@@ -65,6 +83,7 @@ export default function TankGame() {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [botLevel, setBotLevel] = useState<BotLevel>('medium');
|
const [botLevel, setBotLevel] = useState<BotLevel>('medium');
|
||||||
|
const [controls, setControls] = useState<Controls>(getInitialControls);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const prevPhase = useRef<TankPhase>('idle');
|
const prevPhase = useRef<TankPhase>('idle');
|
||||||
const flashes = useRef<MuzzleFlash[]>([]);
|
const flashes = useRef<MuzzleFlash[]>([]);
|
||||||
@@ -128,11 +147,10 @@ export default function TankGame() {
|
|||||||
let sent = '';
|
let sent = '';
|
||||||
const any = (keys: Set<string>) => [...pressed].some(k => keys.has(k));
|
const any = (keys: Set<string>) => [...pressed].some(k => keys.has(k));
|
||||||
const update = () => {
|
const update = () => {
|
||||||
const input = {
|
const x = (any(RIGHT_KEYS) ? 1 : 0) - (any(LEFT_KEYS) ? 1 : 0);
|
||||||
drive: (any(FORWARD_KEYS) ? 1 : 0) - (any(BACK_KEYS) ? 1 : 0),
|
const y = (any(DOWN_KEYS) ? 1 : 0) - (any(UP_KEYS) ? 1 : 0);
|
||||||
turn: (any(RIGHT_KEYS) ? 1 : 0) - (any(LEFT_KEYS) ? 1 : 0),
|
const fire = any(FIRE_KEYS) || mouseFire;
|
||||||
fire: any(FIRE_KEYS) || mouseFire,
|
const input = controls === 'tank' ? { drive: -y, turn: x, fire } : { dx: x, dy: y, fire };
|
||||||
};
|
|
||||||
const key = JSON.stringify(input);
|
const key = JSON.stringify(input);
|
||||||
if (key === sent) return;
|
if (key === sent) return;
|
||||||
sent = key;
|
sent = key;
|
||||||
@@ -197,7 +215,16 @@ export default function TankGame() {
|
|||||||
window.removeEventListener('contextmenu', onContextMenu);
|
window.removeEventListener('contextmenu', onContextMenu);
|
||||||
window.removeEventListener('blur', onBlur);
|
window.removeEventListener('blur', onBlur);
|
||||||
};
|
};
|
||||||
}, [open, inGame, isPlayer, send]);
|
}, [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
|
// Míření myší: kurzor nad hrací plochou natáčí věž, mimo ni se věž vrátí do směru jízdy
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -241,9 +268,16 @@ export default function TankGame() {
|
|||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (canvasRef.current && state && (inGame || phase === 'finished')) {
|
if (!state || !(inGame || phase === 'finished')) return;
|
||||||
drawTanks(canvasRef.current, state, flashes.current, login);
|
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]);
|
}, [state, inGame, phase, login]);
|
||||||
|
|
||||||
if (!login) return null;
|
if (!login) return null;
|
||||||
@@ -295,13 +329,32 @@ export default function TankGame() {
|
|||||||
<div className="tanks-status">
|
<div className="tanks-status">
|
||||||
{mode.label}{state.teams && ' v týmech'}{state.bounce && ' · odrazy'}{state.hardcore && ' · 🌑 hardcore'} · {mapName}{inGame && ` · zbývá ${formatTime(state.timeLeftMs)}`}
|
{mode.label}{state.teams && ' v týmech'}{state.bounce && ' · odrazy'}{state.hardcore && ' · 🌑 hardcore'} · {mapName}{inGame && ` · zbývá ${formatTime(state.timeLeftMs)}`}
|
||||||
{inGame && (isPlayer
|
{inGame && (isPlayer
|
||||||
? ' · ↑↓ / W S jízda, ←→ / A D otáčení, myš míření, klik / mezerník střelba, E / pravé tlačítko mina'
|
? ` · ${CONTROLS.find(c => c.value === controls)!.hint}, myš míření, klik / mezerník střelba, E / pravé tlačítko mina`
|
||||||
: ' · sledujete hru')}
|
: ' · sledujete hru')}
|
||||||
</div>
|
</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()}
|
{renderScores()}
|
||||||
</div>
|
</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 = () => (
|
const renderScores = () => (
|
||||||
<Table size="sm" className="tanks-scores">
|
<Table size="sm" className="tanks-scores">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -426,6 +479,8 @@ export default function TankGame() {
|
|||||||
{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.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.'}
|
+ (state.teams ? ' Co osvítí spoluhráč, vidíte také.' : '') + ' Výbuchy a výstřely prosvítí i tmou.'}
|
||||||
</p>
|
</p>
|
||||||
|
{isPlayer && renderControls()}
|
||||||
|
{isPlayer && <LobbyChat game="tanks" />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -435,8 +490,8 @@ export default function TankGame() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
Tanková bitva pro více hráčů. Jezdí se šipkami nebo WSAD, věž míří za myší a střílí se
|
Tanková bitva pro více hráčů. Jezdí se šipkami nebo WSAD (směrově, nebo po tankovém —
|
||||||
kliknutím nebo mezerníkem. Bedny se dají rozstřílet, přes vodu se nedá projet (ale dá se
|
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ší
|
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);
|
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.
|
když není s kým hrát, přidejte si do lobby boty.
|
||||||
@@ -445,6 +500,7 @@ export default function TankGame() {
|
|||||||
<ul className="tanks-pickups">
|
<ul className="tanks-pickups">
|
||||||
{Object.values(PICKUPS).map(p => <li key={p.icon}>{p.icon} {p.label}</li>)}
|
{Object.values(PICKUPS).map(p => <li key={p.icon}>{p.icon} {p.label}</li>)}
|
||||||
</ul>
|
</ul>
|
||||||
|
{renderControls()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case 'lobby':
|
case 'lobby':
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TetrisSession, WIDTH, HEIGHT, pieceCells, Piece } from "../games/tetris
|
|||||||
import { useGameIcon } from "../games/themeIcons";
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
import { useTouchDevice } from "../hooks/useTouchDevice";
|
import { useTouchDevice } from "../hooks/useTouchDevice";
|
||||||
import TouchButton from "./TouchButton";
|
import TouchButton from "./TouchButton";
|
||||||
|
import LobbyChat from "./LobbyChat";
|
||||||
import "./TetrisGame.scss";
|
import "./TetrisGame.scss";
|
||||||
|
|
||||||
// Typy musí odpovídat server/src/tetrisGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
// Typy musí odpovídat server/src/tetrisGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
||||||
@@ -554,6 +555,7 @@ export default function TetrisGame() {
|
|||||||
{state && state.players.length < state.minPlayers &&
|
{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>}
|
<p className="text-muted">Čeká se na další hráče… Pro hru o samotě použijte Trénink.</p>}
|
||||||
{renderSettings()}
|
{renderSettings()}
|
||||||
|
{isPlayer && <LobbyChat game="tetris" />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case 'countdown':
|
case 'countdown':
|
||||||
|
|||||||
@@ -84,13 +84,13 @@ export const TEAM_NAMES = ['Červení', 'Modří'];
|
|||||||
|
|
||||||
export const PICKUPS: Record<PickupType, { icon: string, label: string }> = {
|
export const PICKUPS: Record<PickupType, { icon: string, label: string }> = {
|
||||||
shield: { icon: '🛡️', label: 'štít — vydrží jeden zásah' },
|
shield: { icon: '🛡️', label: 'štít — vydrží jeden zásah' },
|
||||||
triple: { icon: '🔱', label: 'trojitá střela' },
|
triple: { icon: '🔱', label: 'trojitá střela — tři střely do vějíře' },
|
||||||
rocket: { icon: '🚀', label: 'rakety — výbuch zasáhne i okolí' },
|
rocket: { icon: '🚀', label: 'rakety — výbuch zasáhne i okolí' },
|
||||||
turbo: { icon: '💨', label: 'turbo' },
|
turbo: { icon: '💨', label: 'turbo — rychlejší jízda' },
|
||||||
ghost: { icon: '👻', label: 'neviditelnost' },
|
ghost: { icon: '👻', label: 'neviditelnost — soupeř vás uvidí jen zblízka' },
|
||||||
homing: { icon: '🧲', label: 'naváděné střely' },
|
homing: { icon: '🧲', label: 'naváděné střely — stáčí se k soupeři' },
|
||||||
mine: { icon: '💣', label: 'miny (E / pravé tlačítko)' },
|
mine: { icon: '💣', label: 'miny — položíte E / pravým tlačítkem' },
|
||||||
heart: { icon: '❤️', label: 'život navíc' },
|
heart: { icon: '❤️', label: 'život navíc (jen na přežití)' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BIOMES: Record<Biome, { icon: string }> = {
|
export const BIOMES: Record<Biome, { icon: string }> = {
|
||||||
@@ -477,9 +477,48 @@ export function drawTanks(canvas: HTMLCanvasElement, state: TankState, flashes:
|
|||||||
}
|
}
|
||||||
for (const e of state.explosions) drawExplosion(ctx, e);
|
for (const e of state.explosions) drawExplosion(ctx, e);
|
||||||
for (const p of tanks) drawLabel(ctx, p, p.login === me);
|
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);
|
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 ---
|
// --- 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ě. */
|
/** Paprsek světla ze středu tanku do každého směru; délka končí na zdi či bedně. */
|
||||||
@@ -572,17 +611,22 @@ function drawDarkness(ctx: CanvasRenderingContext2D, state: TankState, lights: L
|
|||||||
function drawTank(ctx: CanvasRenderingContext2D, p: TankPlayer, mine: boolean, now: number) {
|
function drawTank(ctx: CanvasRenderingContext2D, p: TankPlayer, mine: boolean, now: number) {
|
||||||
const color = tankColor(p);
|
const color = tankColor(p);
|
||||||
const base = ctx.globalAlpha;
|
const base = ctx.globalAlpha;
|
||||||
// Vlastní tank má pod sebou světlý kruh, ať se v hemžení najde
|
// 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) {
|
if (mine) {
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.12)';
|
ctx.fillStyle = 'rgba(250, 204, 21, 0.18)';
|
||||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)';
|
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
ctx.setLineDash([4, 4]);
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(p.x, p.y, TANK_RADIUS + 5, 0, Math.PI * 2);
|
ctx.arc(p.x, p.y, TANK_RADIUS + 7, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
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.stroke();
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
|
ctx.lineDashOffset = 0;
|
||||||
}
|
}
|
||||||
// Ochrana po zrození — tank bliká
|
// Ochrana po zrození — tank bliká
|
||||||
if (p.protected && Math.floor(now / 120) % 2 === 0) ctx.globalAlpha = base * 0.35;
|
if (p.protected && Math.floor(now / 120) % 2 === 0) ctx.globalAlpha = base * 0.35;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[
|
[
|
||||||
"Tetris: kostky se otáčí proti směru hodinových ručiček kolem svého středu, tyčka a S/Z kostky už jen přepínají mezi vodorovnou a svislou polohou",
|
"Tetris: kostky se otáčí proti směru hodinových ručiček kolem svého středu, tyčka a S/Z kostky už jen přepínají mezi vodorovnou a svislou polohou",
|
||||||
"Tetris: nová kostka se objeví nad hrací plochou, takže ji jde otočit dřív, než do plochy vjede",
|
"Tetris: nová kostka se objeví nad hrací plochou, takže ji jde otočit dřív, než do plochy vjede",
|
||||||
"Minihry jdou hrát i na mobilu a tabletu 📱 — Tetris, had i Pong mají dotykové ovládání a okna her se na telefonu roztáhnou přes celou obrazovku"
|
"Minihry jdou hrát i na mobilu a tabletu 📱 — Tetris, had i Pong mají dotykové ovládání a okna her se na telefonu roztáhnou přes celou obrazovku",
|
||||||
"Přibyla další minihra — tentokrát se bude střílet 😉"
|
"Přibyla další minihra — tentokrát se bude střílet 😉"
|
||||||
]
|
]
|
||||||
@@ -1,3 +1,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 😉",
|
||||||
|
"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ů"
|
||||||
]
|
]
|
||||||
+17
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Socket } from "socket.io";
|
import { Server, Socket } from "socket.io";
|
||||||
import { getLogin } from "./auth";
|
import { getLogin } from "./auth";
|
||||||
|
|
||||||
/** Sdílené pomůcky pro websocketové minihry (reakční hra, had). */
|
/** Sdílené pomůcky pro websocketové minihry (reakční hra, had). */
|
||||||
@@ -60,3 +60,19 @@ 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 });
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
+144
-114
@@ -20,8 +20,8 @@ import { Clock, defaultClock, GameError, gameHandler, minPlayersForEnv } from ".
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const TILE = 40;
|
export const TILE = 40;
|
||||||
export const COLS = 24;
|
export const COLS = 28;
|
||||||
export const ROWS = 15;
|
export const ROWS = 17;
|
||||||
export const FIELD_WIDTH = COLS * TILE;
|
export const FIELD_WIDTH = COLS * TILE;
|
||||||
export const FIELD_HEIGHT = ROWS * TILE;
|
export const FIELD_HEIGHT = ROWS * TILE;
|
||||||
export const TICK_MS = 33;
|
export const TICK_MS = 33;
|
||||||
@@ -127,21 +127,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
|
|||||||
name: 'Aréna',
|
name: 'Aréna',
|
||||||
biome: 'city',
|
biome: 'city',
|
||||||
half: [
|
half: [
|
||||||
"S..........+",
|
"S..........++.",
|
||||||
"............",
|
"..............",
|
||||||
"..##........",
|
"..##..........",
|
||||||
"..#.....#...",
|
"..#.....#.....",
|
||||||
"........#...",
|
"........#..+..",
|
||||||
".....+......",
|
".....+........",
|
||||||
"....##......",
|
"....##....#...",
|
||||||
"S..........+",
|
"..........#...",
|
||||||
"....##......",
|
"S......+......",
|
||||||
".....+......",
|
"..........#...",
|
||||||
"........#...",
|
"....##....#...",
|
||||||
"..#.....#...",
|
".....+........",
|
||||||
"..##........",
|
"........#..+..",
|
||||||
"............",
|
"..#.....#.....",
|
||||||
"S..........+",
|
"..##..........",
|
||||||
|
"..............",
|
||||||
|
"S..........++.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -149,21 +151,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
|
|||||||
name: 'Bludiště',
|
name: 'Bludiště',
|
||||||
biome: 'city',
|
biome: 'city',
|
||||||
half: [
|
half: [
|
||||||
"S...#.......",
|
"S...#.........",
|
||||||
"..#.#.####..",
|
"..#.#.####....",
|
||||||
"..#......#..",
|
"..#......#..+.",
|
||||||
"..####.#.#..",
|
"..####.#.#....",
|
||||||
".......#....",
|
".......#...##.",
|
||||||
"##.+.###.+.#",
|
"##.+.###......",
|
||||||
"....+.......",
|
"....+.....+...",
|
||||||
"S.#####..+..",
|
".#.......##...",
|
||||||
"....+.......",
|
"S.#####...+...",
|
||||||
"##.+.###.+.#",
|
".#.......##...",
|
||||||
".......#....",
|
"....+.....+...",
|
||||||
"..####.#.#..",
|
"##.+.###......",
|
||||||
"..#......#..",
|
".......#...##.",
|
||||||
"..#.#.####..",
|
"..####.#.#....",
|
||||||
"S...#.......",
|
"..#......#..+.",
|
||||||
|
"..#.#.####....",
|
||||||
|
"S...#.........",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -171,21 +175,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
|
|||||||
name: 'Sklad',
|
name: 'Sklad',
|
||||||
biome: 'city',
|
biome: 'city',
|
||||||
half: [
|
half: [
|
||||||
"S.....+.....",
|
"S.....+.......",
|
||||||
".++.+++.++..",
|
".++.+++.++..+.",
|
||||||
".++.....++.+",
|
".++.....++....",
|
||||||
"....##......",
|
"....##......+.",
|
||||||
"+.+.##.+.+..",
|
"+.+.##.+.+....",
|
||||||
"....+.......",
|
"....+......##.",
|
||||||
".#..+.###...",
|
".#..+.###.....",
|
||||||
"S...+...#..+",
|
".#......#..+..",
|
||||||
".#..+.###...",
|
"S...+...#.....",
|
||||||
"....+.......",
|
".#......#..+..",
|
||||||
"+.+.##.+.+..",
|
".#..+.###.....",
|
||||||
"....##......",
|
"....+......##.",
|
||||||
".++.....++.+",
|
"+.+.##.+.+....",
|
||||||
".++.+++.++..",
|
"....##......+.",
|
||||||
"S.....+.....",
|
".++.....++....",
|
||||||
|
".++.+++.++..+.",
|
||||||
|
"S.....+.......",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -193,21 +199,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
|
|||||||
name: 'Les',
|
name: 'Les',
|
||||||
biome: 'forest',
|
biome: 'forest',
|
||||||
half: [
|
half: [
|
||||||
"S..%%...#...",
|
"S..%%...#..#..",
|
||||||
"..%%%..##...",
|
"..%%%..##...%.",
|
||||||
"......%%...%",
|
"......%%...%%.",
|
||||||
"#..#.......%",
|
"#..#.......%..",
|
||||||
"##...%%.....",
|
"##...%%..#...#",
|
||||||
"....%%%..#..",
|
"....%%%..##...",
|
||||||
".#......%%..",
|
".#......%%..#.",
|
||||||
"~~.~~~~.~~~~",
|
"~~.~~~~.~~~~.~",
|
||||||
"..%%....#...",
|
"S.%%%.....+...",
|
||||||
"S.%%%.....+.",
|
"..%%..##..%%..",
|
||||||
"....##..%%..",
|
"#.......#..%..",
|
||||||
"#........%%.",
|
"##..%%.....#..",
|
||||||
"##..%%..#...",
|
"...%%%..##....",
|
||||||
"...%%%..##..",
|
".#.....%%...#.",
|
||||||
"S......+....",
|
"...##...%..~~.",
|
||||||
|
"..%%..#....~~.",
|
||||||
|
"S......+......",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -215,21 +223,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
|
|||||||
name: 'Poušť',
|
name: 'Poušť',
|
||||||
biome: 'desert',
|
biome: 'desert',
|
||||||
half: [
|
half: [
|
||||||
"S.....##....",
|
"S.....##......",
|
||||||
"...::.......",
|
"...::.....#...",
|
||||||
"..::::..#...",
|
"..::::..#...::",
|
||||||
".......##...",
|
".......##.....",
|
||||||
"#.........~~",
|
"#..#......~~..",
|
||||||
"##..+....~~~",
|
"##..+....~~~..",
|
||||||
"......#..~~~",
|
"......#..~~~.#",
|
||||||
"S.....#...~~",
|
"..#...#...~~..",
|
||||||
"......#..~~~",
|
"S.....#..+....",
|
||||||
"##..+....~~~",
|
"..#...#...~~..",
|
||||||
"#.........~~",
|
"......#..~~~.#",
|
||||||
".......##...",
|
"##..+....~~~..",
|
||||||
"..::::..#...",
|
"#..#......~~..",
|
||||||
"...::.......",
|
".......##.....",
|
||||||
"S.....##....",
|
"..::::..#...::",
|
||||||
|
"...::.....#...",
|
||||||
|
"S.....##......",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -237,21 +247,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
|
|||||||
name: 'Zima',
|
name: 'Zima',
|
||||||
biome: 'winter',
|
biome: 'winter',
|
||||||
half: [
|
half: [
|
||||||
"S...%%....#.",
|
"S...%%....#...",
|
||||||
".::.%%..::..",
|
".::.%%..::..#.",
|
||||||
".::.....::..",
|
".::.....::....",
|
||||||
"....##......",
|
"....##.....%%.",
|
||||||
"%%..##..~~..",
|
"%%..##..~~....",
|
||||||
"%%......~~..",
|
"%%......~~..#.",
|
||||||
"....::......",
|
"...#::......#.",
|
||||||
"S..+::..##.+",
|
"....::..##....",
|
||||||
"....::......",
|
"S..+::..##.+..",
|
||||||
"%%......~~..",
|
"....::........",
|
||||||
"%%..##..~~..",
|
"...#::......#.",
|
||||||
"....##......",
|
"%%......~~..#.",
|
||||||
".::.....::..",
|
"%%..##..~~....",
|
||||||
".::.%%..::..",
|
"....##.....%%.",
|
||||||
"S...%%....#.",
|
".::.....::....",
|
||||||
|
".::.%%..::..#.",
|
||||||
|
"S...%%....#...",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -259,21 +271,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
|
|||||||
name: 'Bažina',
|
name: 'Bažina',
|
||||||
biome: 'swamp',
|
biome: 'swamp',
|
||||||
half: [
|
half: [
|
||||||
"S..::..%%...",
|
"S..::..%%.....",
|
||||||
"..:::~~%%.::",
|
"..:::~~%%.::#.",
|
||||||
".::.~~~...::",
|
".::.~~~...::..",
|
||||||
".....~..#...",
|
".....~..#.....",
|
||||||
"%%.......~~.",
|
"%%.......~~.#.",
|
||||||
"%%%.::::.~~.",
|
"%%%.::::.~~...",
|
||||||
"..+.::::....",
|
"..+.::::....%%",
|
||||||
"S...::....+.",
|
".#........#.%%",
|
||||||
"..+.::::....",
|
"S...::....+...",
|
||||||
"%%%.::::.~~.",
|
".#........#...",
|
||||||
"%%.......~~.",
|
"..+.::::....%%",
|
||||||
".....~..#...",
|
"%%%.::::.~~...",
|
||||||
".::.~~~...::",
|
"%%.......~~.#.",
|
||||||
"..:::~~%%.::",
|
".....~..#.....",
|
||||||
"S..::..%%...",
|
".::.~~~...::..",
|
||||||
|
"..:::~~%%.::#.",
|
||||||
|
"S..::..%%.....",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -362,7 +376,8 @@ export type TankState = {
|
|||||||
/** Nastavení hry z lobby (hodnoty přichází od klienta, ověřují se v {@link TankGame.configure}). */
|
/** Nastavení hry z lobby (hodnoty přichází od klienta, ověřují se v {@link TankGame.configure}). */
|
||||||
export type TankOptions = { mode?: unknown, mapId?: unknown, teams?: unknown, hardcore?: unknown, bounce?: unknown };
|
export type TankOptions = { mode?: unknown, mapId?: unknown, teams?: unknown, hardcore?: unknown, bounce?: unknown };
|
||||||
|
|
||||||
type Input = { drive: number, turn: number, fire: boolean };
|
/** Tankové ovládání (`drive` + `turn`), nebo směrové — `dir` je požadovaný směr jízdy na hřišti. */
|
||||||
|
type Input = { drive: number, turn: number, fire: boolean, dir?: [number, number] };
|
||||||
|
|
||||||
type InternalPlayer = TankPlayer & {
|
type InternalPlayer = TankPlayer & {
|
||||||
input: Input,
|
input: Input,
|
||||||
@@ -639,12 +654,18 @@ export class TankGame {
|
|||||||
this.schedule(() => this.countdownStep(), 1000);
|
this.schedule(() => this.countdownStep(), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stav ovládání hráče: drive −1/0/1 (couvání/stát/jízda), turn −1/0/1 (doleva/rovně/doprava), fire. */
|
/**
|
||||||
|
* Stav ovládání hráče: drive −1/0/1 (couvání/stát/jízda), turn −1/0/1 (doleva/rovně/doprava), fire.
|
||||||
|
* Směrové ovládání místo drive/turn posílá dx, dy −1/0/1 — tank se natočí a jede tím směrem.
|
||||||
|
*/
|
||||||
input(login: string, payload: unknown) {
|
input(login: string, payload: unknown) {
|
||||||
const p = this.players.get(login);
|
const p = this.players.get(login);
|
||||||
if (!p || p.bot) return;
|
if (!p || p.bot) return;
|
||||||
const data = (payload ?? {}) as { drive?: unknown, turn?: unknown, fire?: unknown };
|
const data = (payload ?? {}) as { drive?: unknown, turn?: unknown, fire?: unknown, dx?: unknown, dy?: unknown };
|
||||||
p.input = { drive: axis(data.drive), turn: axis(data.turn), fire: data.fire === true };
|
const fire = data.fire === true;
|
||||||
|
p.input = 'dx' in data || 'dy' in data
|
||||||
|
? { drive: 0, turn: 0, fire, dir: [axis(data.dx), axis(data.dy)] }
|
||||||
|
: { drive: axis(data.drive), turn: axis(data.turn), fire };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Míření myší: bod na hřišti, kam se má věž natočit; `null` vrátí věž do směru jízdy. */
|
/** Míření myší: bod na hřišti, kam se má věž natočit; `null` vrátí věž do směru jízdy. */
|
||||||
@@ -768,13 +789,22 @@ export class TankGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private moveTank(p: InternalPlayer, dt: number, now: number) {
|
private moveTank(p: InternalPlayer, dt: number, now: number) {
|
||||||
|
let drive = p.input.drive;
|
||||||
|
if (p.input.dir) {
|
||||||
|
const [dx, dy] = p.input.dir;
|
||||||
|
if (!dx && !dy) return;
|
||||||
|
// Korba se rovnou otočí do zvoleného směru a tank jede
|
||||||
|
p.angle = Math.atan2(dy, dx);
|
||||||
|
drive = 1;
|
||||||
|
} else {
|
||||||
p.angle = (p.angle + p.input.turn * TANK_TURN_SPEED * dt) % (Math.PI * 2);
|
p.angle = (p.angle + p.input.turn * TANK_TURN_SPEED * dt) % (Math.PI * 2);
|
||||||
if (!p.input.drive) return;
|
}
|
||||||
let speed = p.input.drive > 0 ? TANK_SPEED : TANK_REVERSE_SPEED;
|
if (!drive) return;
|
||||||
|
let speed = drive > 0 ? TANK_SPEED : TANK_REVERSE_SPEED;
|
||||||
if (p.turboUntil > now) speed *= TURBO_FACTOR;
|
if (p.turboUntil > now) speed *= TURBO_FACTOR;
|
||||||
const ground = this.tileAtPos(p.x, p.y);
|
const ground = this.tileAtPos(p.x, p.y);
|
||||||
if (ground === ':' || ground === '%') speed *= MUD_FACTOR;
|
if (ground === ':' || ground === '%') speed *= MUD_FACTOR;
|
||||||
const dist = p.input.drive * speed * dt;
|
const dist = drive * speed * dt;
|
||||||
// Po osách zvlášť, aby tank klouzal podél zdí
|
// Po osách zvlášť, aby tank klouzal podél zdí
|
||||||
const nx = p.x + Math.cos(p.angle) * dist;
|
const nx = p.x + Math.cos(p.angle) * dist;
|
||||||
if (!this.tankBlocked(p, nx, p.y)) p.x = nx;
|
if (!this.tankBlocked(p, nx, p.y)) p.x = nx;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
TankGame, TankState, MAPS, COLS, ROWS, TICK_MS, COUNTDOWN_SECONDS, RESPAWN_MS, DEATHMATCH_MS,
|
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,
|
LAST_MAN_LIVES, MAX_PLAYERS, TANK_RADIUS, ROCKET_AMMO, MINE_AMMO, MINE_ARM_MS, FIELD_WIDTH,
|
||||||
} from '../tankGame';
|
} from '../tankGame';
|
||||||
import { GameError } from '../minigames';
|
import { GameError } from '../minigames';
|
||||||
import { fakeClock } from './helpers/fakeClock';
|
import { fakeClock } from './helpers/fakeClock';
|
||||||
@@ -94,6 +94,32 @@ describe('tanky', () => {
|
|||||||
expect(ctx.player('alice').angle).toBe(0);
|
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í', () => {
|
test('zásah zničí soupeře, přidá bod a soupeř se po chvíli znovu objeví', () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
ctx.startGame();
|
ctx.startGame();
|
||||||
@@ -397,7 +423,7 @@ describe('tanky — týmy', () => {
|
|||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
ctx.startGame('deathmatch', LOGINS, 'arena', true);
|
ctx.startGame('deathmatch', LOGINS, 'arena', true);
|
||||||
for (const p of ctx.last().players) {
|
for (const p of ctx.last().players) {
|
||||||
expect(p.x < 480).toBe(p.team === 0);
|
expect(p.x < FIELD_WIDTH / 2).toBe(p.team === 0);
|
||||||
}
|
}
|
||||||
// alice a carol jsou tým 0, bob tým 1
|
// alice a carol jsou tým 0, bob tým 1
|
||||||
ctx.game.placeForTest('alice', 100, 60, 0);
|
ctx.game.placeForTest('alice', 100, 60, 0);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { registerTetrisGame } from "./tetrisGame";
|
|||||||
import { registerPongGame } from "./pongGame";
|
import { registerPongGame } from "./pongGame";
|
||||||
import { registerDrawingGame } from "./drawingGame";
|
import { registerDrawingGame } from "./drawingGame";
|
||||||
import { registerTankGame } from "./tankGame";
|
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>;
|
||||||
@@ -40,6 +41,7 @@ export const initWebsocket = (server: any) => {
|
|||||||
registerPongGame(io);
|
registerPongGame(io);
|
||||||
registerDrawingGame(io);
|
registerDrawingGame(io);
|
||||||
registerTankGame(io);
|
registerTankGame(io);
|
||||||
|
registerLobbyChat(io);
|
||||||
return io;
|
return io;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user