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 "./DrawingGame.scss"; // Typy musí odpovídat server/src/drawingGame.ts (stav chodí přes websocket, ne přes OpenAPI) type DrawPhase = 'idle' | 'lobby' | 'drawing' | 'reveal' | 'finished'; type DrawPlayer = { login: string, score: number, guessed: boolean, place?: number }; type ChatMessage = { login?: string, text: string, system?: boolean }; type Stroke = { color: string, size: number, points: [number, number][], start: boolean }; type DrawState = { phase: DrawPhase, host?: string, players: DrawPlayer[], drawer?: string, round: number, totalRounds: number, hint?: string, answer?: string, remainingMs?: number, messages: ChatMessage[], minPlayers: number, }; const OPEN_EVENT = 'luncher:open-drawing'; /** Otevře okno Kreslení. */ export const openDrawingGame = () => window.dispatchEvent(new Event(OPEN_EVENT)); const CANVAS_W = 800; const CANVAS_H = 600; /** Souřadnice tahů jsou 0–1000 v obou osách, nezávisle na velikosti plátna. */ const COORD_MAX = 1000; const COLORS = ['#111827', '#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#a855f7', '#92400e', '#ffffff']; const SIZES = [3, 8, 18]; /** Jak často se rozkreslený tah posílá ostatním (ms). */ const FLUSH_MS = 40; const MEDALS = ['🥇', '🥈', '🥉']; function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke) { const sx = CANVAS_W / COORD_MAX; const sy = CANVAS_H / COORD_MAX; ctx.strokeStyle = stroke.color; ctx.fillStyle = stroke.color; ctx.lineWidth = stroke.size; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; const [first, ...rest] = stroke.points; if (rest.length === 0) { ctx.beginPath(); ctx.arc(first[0] * sx, first[1] * sy, stroke.size / 2, 0, Math.PI * 2); ctx.fill(); return; } ctx.beginPath(); ctx.moveTo(first[0] * sx, first[1] * sy); rest.forEach(([x, y]) => ctx.lineTo(x * sx, y * sy)); ctx.stroke(); } function clearCanvas(ctx: CanvasRenderingContext2D) { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); } /** Kreslení — jeden kreslí jídlo z dnešních menu, ostatní hádají v chatu. */ export default function DrawingGame() { const auth = useAuth(); const login = auth?.login; const [state, setState] = useState(null); const [open, setOpen] = useState(false); const [error, setError] = useState(null); const [word, setWord] = useState(null); const [color, setColor] = useState(COLORS[0]); const [size, setSize] = useState(SIZES[1]); const [guess, setGuess] = useState(''); const [secondsLeft, setSecondsLeft] = useState(0); const canvasRef = useRef(null); const chatRef = useRef(null); const prevPhase = useRef('idle'); /** Rozkreslený tah: body čekající na odeslání a poslední odeslaný bod pro navázání. */ const pending = useRef<{ points: [number, number][], start: boolean } | null>(null); const drawingRef = useRef(false); const icon = useGameIcon('drawing'); const send = useCallback((event: string, extra?: object) => { setError(null); socket.emit(event, { token: getToken(), ...extra }); }, []); const ctx = () => canvasRef.current?.getContext('2d') ?? null; useEffect(() => { const onState = (s: DrawState) => setState(s); const onError = (msg: string) => setError(msg); const onOpen = () => setOpen(true); const sync = () => socket.emit('draw:sync'); socket.on('draw:state', onState); socket.on('draw:error', onError); socket.on('connect', sync); window.addEventListener(OPEN_EVENT, onOpen); sync(); return () => { socket.off('draw:state', onState); socket.off('draw:error', onError); socket.off('connect', sync); window.removeEventListener(OPEN_EVENT, onOpen); }; }, []); const isDrawer = !!login && state?.phase === 'drawing' && state.drawer === login; const isDrawerRef = useRef(isDrawer); isDrawerRef.current = isDrawer; // Kresba a slovo chodí jen klientům s otevřeným oknem useEffect(() => { if (!open) return; const watch = () => socket.emit('draw:watch', { token: getToken() }); const onStrokes = (strokes: Stroke[]) => { const c = ctx(); if (!c) return; clearCanvas(c); strokes.forEach(s => drawStroke(c, s)); }; // Vlastní tahy kreslíř vykresluje hned lokálně const onStroke = (s: Stroke) => { const c = ctx(); if (c && !isDrawerRef.current) drawStroke(c, s); }; const onClear = () => { const c = ctx(); if (c) clearCanvas(c); }; const onWord = (w: string) => setWord(w); socket.on('draw:strokes', onStrokes); socket.on('draw:stroke', onStroke); socket.on('draw:clear', onClear); socket.on('draw:word', onWord); socket.on('connect', watch); watch(); return () => { socket.off('draw:strokes', onStrokes); socket.off('draw:stroke', onStroke); socket.off('draw:clear', onClear); socket.off('draw:word', onWord); socket.off('connect', watch); socket.emit('draw:unwatch'); }; }, [open]); const isPlayer = !!login && !!state?.players.some(p => p.login === login); const phase = state?.phase ?? 'idle'; const inGame = phase === 'drawing' || phase === 'reveal'; useEffect(() => { if (prevPhase.current === 'lobby' && phase === 'drawing' && isPlayer) { setOpen(true); } prevPhase.current = phase; }, [phase, isPlayer]); // Slovo patří jen k aktuálnímu kolu useEffect(() => { if (!isDrawer) setWord(null); }, [isDrawer, state?.round]); // Plátno se po vykreslení (nové kolo, otevření okna) vyplní bílou a znovu vyžádá kresba useEffect(() => { if (open && inGame) { const c = ctx(); if (c) clearCanvas(c); socket.emit('draw:watch', { token: getToken() }); } }, [open, inGame]); // Odpočet kola počítaný lokálně od posledního stavu ze serveru useEffect(() => { if (phase !== 'drawing' || state?.remainingMs === undefined) { setSecondsLeft(0); return; } const endsAt = Date.now() + state.remainingMs; const update = () => setSecondsLeft(Math.max(0, Math.ceil((endsAt - Date.now()) / 1000))); update(); const id = window.setInterval(update, 250); return () => window.clearInterval(id); }, [phase, state?.remainingMs]); useEffect(() => { chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight }); }, [state?.messages.length]); // Průběžné odesílání rozkresleného tahu useEffect(() => { if (!isDrawer) return; const id = window.setInterval(() => { const p = pending.current; if (!p || p.points.length === 0 || (p.points.length === 1 && !p.start)) return; send('draw:stroke', { color, size, points: p.points, start: p.start }); // Další dávka začne posledním bodem, aby čára navazovala pending.current = drawingRef.current ? { points: [p.points[p.points.length - 1]], start: false } : null; }, FLUSH_MS); return () => window.clearInterval(id); }, [isDrawer, color, size, send]); const toCoords = (e: React.PointerEvent): [number, number] => { const rect = e.currentTarget.getBoundingClientRect(); const x = Math.round(((e.clientX - rect.left) / rect.width) * COORD_MAX); const y = Math.round(((e.clientY - rect.top) / rect.height) * COORD_MAX); return [Math.min(COORD_MAX, Math.max(0, x)), Math.min(COORD_MAX, Math.max(0, y))]; }; const onPointerDown = (e: React.PointerEvent) => { if (!isDrawer) return; e.currentTarget.setPointerCapture(e.pointerId); drawingRef.current = true; const point = toCoords(e); pending.current = { points: [point], start: true }; const c = ctx(); if (c) drawStroke(c, { color, size, points: [point], start: true }); }; const onPointerMove = (e: React.PointerEvent) => { if (!isDrawer || !drawingRef.current || !pending.current) return; const point = toCoords(e); const prev = pending.current.points[pending.current.points.length - 1]; if (prev && prev[0] === point[0] && prev[1] === point[1]) return; pending.current.points.push(point); const c = ctx(); if (c && prev) drawStroke(c, { color, size, points: [prev, point], start: false }); }; const onPointerUp = () => { drawingRef.current = false; }; const submitGuess = (e: React.FormEvent) => { e.preventDefault(); if (!guess.trim()) return; send('draw:guess', { text: guess }); setGuess(''); }; if (!login) return null; const me = state?.players.find(p => p.login === login); const canChat = isPlayer && !isDrawer && !(phase === 'drawing' && me?.guessed); const ranked = [...(state?.players ?? [])].sort((a, b) => b.score - a.score); const showInvite = !open && phase === 'lobby' && !isPlayer; const showReturn = !open && isPlayer && inGame; const renderTopBar = () => { if (phase === 'reveal' || phase === 'finished') { return
Bylo to: {state?.answer}
; } return (
Kolo {state?.round}/{state?.totalRounds} · kreslí {state?.drawer} {isDrawer ? Nakreslete: {word ?? '…'} : {state?.hint}} ⏱ {secondsLeft} s
); }; const renderGame = () => ( <> {renderTopBar()}
{isDrawer && (
{COLORS.map(c => ( ))}
)}
    {ranked.map(p => (
  • {p.login === state?.drawer && phase === 'drawing' ? '✏️ ' : ''}{p.login}{p.login === login && ' (vy)'} {p.score}
  • ))}
{renderChat()}
); const renderChat = () => ( <>
{state?.messages.map((m, i) => (
{m.login && {m.login}: }{m.text}
))}
setGuess(e.target.value)} placeholder={isDrawer ? 'Kreslíte…' : canChat ? 'Váš tip…' : 'Uhodli jste ✔'} disabled={!canChat} maxLength={100} onKeyDown={e => e.stopPropagation()} /> ); const renderBody = () => { switch (phase) { case 'idle': return (

Jeden kreslí jídlo z dnešních menu, ostatní hádají v chatu. Stačí trefit hlavní slovo (např. „guláš"), diakritika nevadí. Čím dřív uhodnete, tím víc bodů; kreslíř dostává body za každého, kdo uhodl. Každý kreslí jednou.

); case 'lobby': return ( <>

Lobby založil(a) {state?.host}. Hráči ({state?.players.length}):

    {state?.players.map(p =>
  • {p.login}{p.login === state.host && ' 👑'}
  • )}
{renderChat()} ); case 'drawing': case 'reveal': return renderGame(); case 'finished': return ( <> {ranked.map((p, i) => ( ))}
#HráčBody
{i < 3 ? MEDALS[i] : i + 1} {p.login}{p.login === login && ' (vy)'} {p.score}
); } }; const renderFooter = () => { const buttons = []; if (phase === 'idle' || phase === 'finished') { buttons.push(); } if (phase === 'lobby') { if (!isPlayer) { buttons.push(); } else { buttons.push(); if (login === state?.host) { buttons.push(); } } } buttons.push(); return buttons; }; return ( <> {showInvite && ( )} {showReturn && ( )} setOpen(false)} size="xl" fullscreen="sm-down" className="drawing-game-modal">

{icon} Kreslení

{renderBody()} {error &&
{error}
}
{renderFooter()}
); }