diff --git a/client/.yarn/install-state.gz b/client/.yarn/install-state.gz new file mode 100644 index 0000000..e8c8b42 Binary files /dev/null and b/client/.yarn/install-state.gz differ diff --git a/client/src/components/LobbyChat.scss b/client/src/components/LobbyChat.scss new file mode 100644 index 0000000..b691314 --- /dev/null +++ b/client/src/components/LobbyChat.scss @@ -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); + } + } + } +} diff --git a/client/src/components/LobbyChat.tsx b/client/src/components/LobbyChat.tsx new file mode 100644 index 0000000..235ff47 --- /dev/null +++ b/client/src/components/LobbyChat.tsx @@ -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([]); + const [text, setText] = useState(''); + const listRef = useRef(null); + const inputRef = useRef(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 ( +
+
+ {messages.length === 0 &&
Zatím žádné zprávy…
} + {messages.map((m, i) =>
{m.login}: {m.text}
)} +
+
+ {EMOJIS.map(e => )} +
+
+ setText(e.target.value)} + placeholder="Napište zprávu…" + maxLength={200} + onKeyDown={e => e.stopPropagation()} + /> + +
+ ); +} diff --git a/client/src/components/PongGame.tsx b/client/src/components/PongGame.tsx index a8d0926..2ba1ed5 100644 --- a/client/src/components/PongGame.tsx +++ b/client/src/components/PongGame.tsx @@ -5,6 +5,7 @@ import { useAuth } from "../context/auth"; import { getToken } from "../Utils"; import { useGameIcon } from "../games/themeIcons"; import { useTouchDevice } from "../hooks/useTouchDevice"; +import LobbyChat from "./LobbyChat"; import "./PongGame.scss"; // 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) && ( )} + {isPlayer && } ); default: diff --git a/client/src/components/ReactionDuel.tsx b/client/src/components/ReactionDuel.tsx index 239d473..2b9537c 100644 --- a/client/src/components/ReactionDuel.tsx +++ b/client/src/components/ReactionDuel.tsx @@ -5,6 +5,7 @@ import { useAuth } from "../context/auth"; import { getToken } from "../Utils"; import { useGameIcon } from "../games/themeIcons"; import { useTouchDevice } from "../hooks/useTouchDevice"; +import LobbyChat from "./LobbyChat"; import "./ReactionDuel.scss"; // 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() { {state && state.players.length < state.minPlayers &&

Čeká se na další hráče…

} + {isPlayer && } ); case 'waiting': diff --git a/client/src/components/SnakeGame.tsx b/client/src/components/SnakeGame.tsx index 711825b..83a2df7 100644 --- a/client/src/components/SnakeGame.tsx +++ b/client/src/components/SnakeGame.tsx @@ -8,6 +8,7 @@ import { getToken } from "../Utils"; import { useGameIcon } from "../games/themeIcons"; import { useTouchDevice } from "../hooks/useTouchDevice"; import TouchButton from "./TouchButton"; +import LobbyChat from "./LobbyChat"; import "./SnakeGame.scss"; // 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() { ))} + {isPlayer && } ); case 'countdown': diff --git a/client/src/components/TankGame.tsx b/client/src/components/TankGame.tsx index 35a0b56..6643637 100644 --- a/client/src/components/TankGame.tsx +++ b/client/src/components/TankGame.tsx @@ -8,6 +8,7 @@ import { BIOMES, BotLevel, drawTanks, FLASH_MS, MuzzleFlash, PICKUPS, PickupType, tankColor, TankMode, TankPhase, TankPlayer, TankState, TEAM_COLORS, TEAM_NAMES, } from "../games/tankRender"; +import LobbyChat from "./LobbyChat"; import "./TankGame.scss"; const OPEN_EVENT = 'luncher:open-tanks'; @@ -426,6 +427,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.'}

+ {isPlayer && } ); diff --git a/client/src/components/TetrisGame.tsx b/client/src/components/TetrisGame.tsx index 6ad23e9..33473e6 100644 --- a/client/src/components/TetrisGame.tsx +++ b/client/src/components/TetrisGame.tsx @@ -9,6 +9,7 @@ import { TetrisSession, WIDTH, HEIGHT, pieceCells, Piece } from "../games/tetris import { useGameIcon } from "../games/themeIcons"; import { useTouchDevice } from "../hooks/useTouchDevice"; import TouchButton from "./TouchButton"; +import LobbyChat from "./LobbyChat"; import "./TetrisGame.scss"; // 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 &&

Čeká se na další hráče… Pro hru o samotě použijte Trénink.

} {renderSettings()} + {isPlayer && } ); case 'countdown': diff --git a/server/changelogs/2026-10-21.json b/server/changelogs/2026-10-21.json index 7e32580..7e877a6 100644 --- a/server/changelogs/2026-10-21.json +++ b/server/changelogs/2026-10-21.json @@ -1,3 +1,4 @@ [ - "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 😉" ] diff --git a/server/src/minigames.ts b/server/src/minigames.ts index 032769b..a8581ad 100644 --- a/server/src/minigames.ts +++ b/server/src/minigames.ts @@ -1,4 +1,4 @@ -import { Socket } from "socket.io"; +import { Server, Socket } from "socket.io"; import { getLogin } from "./auth"; /** 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. */ 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 }); + })); + }); +} diff --git a/server/src/websocket.ts b/server/src/websocket.ts index 4429d48..aef9277 100644 --- a/server/src/websocket.ts +++ b/server/src/websocket.ts @@ -7,6 +7,7 @@ import { registerTetrisGame } from "./tetrisGame"; import { registerPongGame } from "./pongGame"; import { registerDrawingGame } from "./drawingGame"; import { registerTankGame } from "./tankGame"; +import { registerLobbyChat } from "./minigames"; let io: Server; let pubClient: ReturnType; @@ -40,6 +41,7 @@ export const initWebsocket = (server: any) => { registerPongGame(io); registerDrawingGame(io); registerTankGame(io); + registerLobbyChat(io); return io; };