feat: směrové ovládání tanků, větší mapy, zvýraznění vlastního tanku a legenda bonusů
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 42s
CI / Playwright E2E tests (push) Successful in 2m0s
CI / Build and push Docker image (push) Successful in 49s
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 42s
CI / Playwright E2E tests (push) Successful in 2m0s
CI / Build and push Docker image (push) Successful in 49s
CI / Notify (push) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c8132c283c
commit
16cf680183
6 files changed
+330
-146
No files matched your search
@@ -3,10 +3,17 @@
|
||||
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: 960px;
|
||||
max-width: 1120px;
|
||||
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 {
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
|
||||
@@ -31,13 +31,30 @@ const BOT_LEVELS: { value: BotLevel, label: string }[] = [
|
||||
{ value: 'hard', label: 'Těžký' },
|
||||
];
|
||||
|
||||
const FORWARD_KEYS = new Set(['ArrowUp', 'KeyW']);
|
||||
const BACK_KEYS = new Set(['ArrowDown', 'KeyS']);
|
||||
/** 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([...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 s = Math.ceil(ms / 1000);
|
||||
@@ -66,6 +83,7 @@ export default function TankGame() {
|
||||
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[]>([]);
|
||||
@@ -129,11 +147,10 @@ export default function TankGame() {
|
||||
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 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;
|
||||
@@ -198,7 +215,16 @@ export default function TankGame() {
|
||||
window.removeEventListener('contextmenu', onContextMenu);
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -242,9 +268,16 @@ export default function TankGame() {
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current && state && (inGame || phase === 'finished')) {
|
||||
drawTanks(canvasRef.current, state, flashes.current, login);
|
||||
}
|
||||
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;
|
||||
@@ -296,13 +329,32 @@ export default function TankGame() {
|
||||
<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'
|
||||
? ` · ${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>
|
||||
@@ -427,6 +479,7 @@ 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.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" />}
|
||||
</>
|
||||
);
|
||||
@@ -437,8 +490,8 @@ export default function TankGame() {
|
||||
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
|
||||
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.
|
||||
@@ -447,6 +500,7 @@ export default function TankGame() {
|
||||
<ul className="tanks-pickups">
|
||||
{Object.values(PICKUPS).map(p => <li key={p.icon}>{p.icon} {p.label}</li>)}
|
||||
</ul>
|
||||
{renderControls()}
|
||||
</>
|
||||
);
|
||||
case 'lobby':
|
||||
|
||||
@@ -84,13 +84,13 @@ 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' },
|
||||
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' },
|
||||
ghost: { icon: '👻', label: 'neviditelnost' },
|
||||
homing: { icon: '🧲', label: 'naváděné střely' },
|
||||
mine: { icon: '💣', label: 'miny (E / pravé tlačítko)' },
|
||||
heart: { icon: '❤️', label: 'život navíc' },
|
||||
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 }> = {
|
||||
@@ -477,9 +477,48 @@ export function drawTanks(canvas: HTMLCanvasElement, state: TankState, flashes:
|
||||
}
|
||||
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ě. */
|
||||
@@ -572,17 +611,22 @@ function drawDarkness(ctx: CanvasRenderingContext2D, state: TankState, lights: L
|
||||
function drawTank(ctx: CanvasRenderingContext2D, p: TankPlayer, mine: boolean, now: number) {
|
||||
const color = tankColor(p);
|
||||
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) {
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.12)';
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.fillStyle = 'rgba(250, 204, 21, 0.18)';
|
||||
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.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;
|
||||
|
||||
Reference in new issue
Block a user