feat: přepínání režimů a systému vítězství v Tetrisu
CI / Generate TypeScript types (push) Canceled after 0s
CI / Server unit tests (push) Canceled after 0s
CI / Build server (push) Canceled after 0s
CI / Build client (push) Canceled after 0s
CI / Playwright E2E tests (push) Canceled after 0s
CI / Build and push Docker image (push) Canceled after 0s
CI / Notify (push) Canceled after 0s
CI / Generate TypeScript types (push) Canceled after 0s
CI / Server unit tests (push) Canceled after 0s
CI / Build server (push) Canceled after 0s
CI / Build client (push) Canceled after 0s
CI / Playwright E2E tests (push) Canceled after 0s
CI / Build and push Docker image (push) Canceled after 0s
CI / Notify (push) Canceled after 0s
This commit is contained in:
5 files changed
+277
-39
No files matched your search
@@ -117,3 +117,19 @@
|
||||
columns: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.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,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, Modal, Table } from "react-bootstrap";
|
||||
import { Button, Form, Modal, Table } from "react-bootstrap";
|
||||
import { socket } from "../context/socket";
|
||||
import { useAuth } from "../context/auth";
|
||||
import { getToken } from "../Utils";
|
||||
@@ -10,6 +10,9 @@ 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,
|
||||
@@ -17,11 +20,30 @@ type TetrisState = {
|
||||
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));
|
||||
|
||||
@@ -115,6 +137,9 @@ export default function TetrisGame() {
|
||||
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 send = useCallback((event: string, extra?: object) => {
|
||||
@@ -128,7 +153,10 @@ export default function TetrisGame() {
|
||||
}, [send]);
|
||||
|
||||
useEffect(() => {
|
||||
const onState = (s: TetrisState) => setState(s);
|
||||
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');
|
||||
@@ -226,6 +254,13 @@ export default function TetrisGame() {
|
||||
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;
|
||||
@@ -287,6 +322,46 @@ export default function TetrisGame() {
|
||||
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">
|
||||
@@ -299,6 +374,12 @@ export default function TetrisGame() {
|
||||
{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>
|
||||
@@ -335,8 +416,9 @@ export default function TetrisGame() {
|
||||
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 1, 2 nebo 4 odpadní řádky soupeři, který jich zatím dostal nejméně. Smazáním řádků
|
||||
lze zároveň odrazit odpad, který na vás čeká. Vyhrává poslední hráč ve hře.
|
||||
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>
|
||||
);
|
||||
@@ -347,6 +429,7 @@ export default function TetrisGame() {
|
||||
<ul className="tetris-players">
|
||||
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
|
||||
</ul>
|
||||
{renderSettings()}
|
||||
</>
|
||||
);
|
||||
case 'countdown':
|
||||
@@ -354,21 +437,30 @@ export default function TetrisGame() {
|
||||
return renderPlaying();
|
||||
case 'finished':
|
||||
return (
|
||||
<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>
|
||||
<>
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user