From 864868de49ea552ba8c2231a40fc71745b9e31e3 Mon Sep 17 00:00:00 2001 From: Dmitry Gammel Date: Sun, 26 Jul 2026 21:22:20 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9B=D0=BE=D0=B1=D0=B1=D0=B8:=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BD=D0=B0=D1=82=D1=8B=20=D0=BD=D0=B0=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80=D0=B5=20(C++)=20=D0=B8=20UI=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сервер: Lobby/Room/Player — create/join/leave/list_rooms, broadcast обновлений, проверка авторизации (hello) и состояния комнаты (waiting/full/in_progress). Клиент: полноценный UI лобби (создание, список, вход в комнату, выход), блокировка кнопок до установки WS-соединения. nginx: no-cache для JS/CSS (нет хеширования имён файлов). --- frontend/css/game.css | 91 ++++++++++++++- frontend/js/game/lobby.js | 137 ++++++++++++++++++++-- frontend/js/game/main.js | 4 +- frontend/js/game/network.js | 12 ++ gameserver/CMakeLists.txt | 1 + gameserver/src/Lobby.cpp | 227 ++++++++++++++++++++++++++++++++++++ gameserver/src/Lobby.hpp | 64 ++++++++++ gameserver/src/main.cpp | 38 ++---- nginx/nginx.conf | 6 + 9 files changed, 538 insertions(+), 42 deletions(-) create mode 100644 gameserver/src/Lobby.cpp create mode 100644 gameserver/src/Lobby.hpp diff --git a/frontend/css/game.css b/frontend/css/game.css index 4e54213..b58023e 100644 --- a/frontend/css/game.css +++ b/frontend/css/game.css @@ -1 +1,90 @@ -/* TODO: лобби и игровое поле — этапы 3-6 */ +.lobby-panel { + border: var(--border); + background: var(--color-bg-raised); + padding: 1.25rem 1.5rem; + margin: 1.5rem 0; + max-width: 640px; +} + +.lobby-panel__row { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.prompt-inline { + color: var(--color-accent-dim); +} + +.lobby-panel input { + background: var(--color-bg); + border: 1px solid var(--color-accent-dim); + color: var(--color-fg); + font-family: var(--font-mono); + padding: 0.3rem 0.5rem; +} + +.lobby-panel button { + background: transparent; + border: 1px solid var(--color-accent-dim); + color: var(--color-accent); + font-family: var(--font-mono); + padding: 0.35rem 0.75rem; + cursor: pointer; +} + +.lobby-panel button:hover:not(:disabled) { + border-color: var(--color-accent); + box-shadow: 0 0 6px var(--color-accent-dim); +} + +.lobby-panel button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.lobby-error { + color: var(--color-silver); + border-left: 2px solid var(--color-accent-dim); + padding-left: 0.6rem; + margin: 0 0 1rem; +} + +.room-list, +.room-players { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.room-list__item { + display: flex; + align-items: center; + gap: 0.75rem; + border-bottom: var(--border); + padding-bottom: 0.5rem; +} + +.room-list__name { + flex: 1; +} + +.room-list__count, +.room-list__state { + color: var(--color-silver); + font-size: 0.85rem; +} + +.room-list__empty { + color: var(--color-silver); +} + +.room-players li::before { + content: '- '; + color: var(--color-accent-dim); +} diff --git a/frontend/js/game/lobby.js b/frontend/js/game/lobby.js index f90c003..043d364 100644 --- a/frontend/js/game/lobby.js +++ b/frontend/js/game/lobby.js @@ -1,21 +1,136 @@ -// TODO: полноценный UI лобби (список/создание/присоединение комнат) — этап 3. -// Пока — проверка связки клиент <-> gameserver (hello -> lobby.list_rooms). import { Network, ServerMessage } from './network.js'; -export function connectAndProbe() { - const net = new Network(); +const container = document.getElementById('lobby'); - net.on('_open', () => net.hello('cacto')); +let net; +let rooms = []; +let currentRoom = null; +let errorMessage = ''; +let connected = false; +let nickname = 'cacto-' + Math.floor(Math.random() * 1000); + +function roomRow(room) { + const full = room.players >= room.max_players; + return ` +
  • + ${room.name} + ${room.mode} + ${room.players}/${room.max_players} + ${room.state} + +
  • `; +} + +function errorBanner() { + return errorMessage ? `

    ! ${errorMessage}

    ` : ''; +} + +function renderLobbyView() { + const dis = connected ? '' : 'disabled'; + container.innerHTML = ` +
    + ${errorBanner()} + ${connected ? '' : '

    подключение...

    '} +
    + + +
    +
    + + + +
    + +
    `; +} + +function renderRoomView() { + container.innerHTML = ` +
    + ${errorBanner()} +

    room ${currentRoom.name}

    +

    режим: ${currentRoom.mode}

    + + +
    `; +} + +function render() { + if (currentRoom) { + renderRoomView(); + } else { + renderLobbyView(); + } +} + +container?.addEventListener('click', (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + errorMessage = ''; + switch (btn.dataset.action) { + case 'create': + net.createRoom(btn.dataset.mode); + break; + case 'join': + net.joinRoom(btn.dataset.roomId); + break; + case 'leave': + currentRoom = null; + net.leaveRoom(); + render(); + break; + case 'refresh': + net.listRooms(); + break; + } +}); + +container?.addEventListener('change', (e) => { + if (e.target.id === 'nickname-input') { + nickname = e.target.value.trim() || nickname; + } +}); + +export function initLobby() { + net = new Network(); + + net.on('_open', () => net.hello(nickname)); net.on(ServerMessage.HELLO_ACK, () => { - console.log('[gameserver] hello.ack received'); + connected = true; net.listRooms(); }); - net.on(ServerMessage.LOBBY_ROOMS, (payload) => { - console.log('[gameserver] rooms:', payload.rooms); - }); - net.on(ServerMessage.ERROR, (payload) => { - console.error('[gameserver] error:', payload); + net.on('_close', () => { + connected = false; + render(); }); + net.on(ServerMessage.LOBBY_ROOMS, (payload) => { + rooms = payload.rooms; + render(); + }); + + net.on(ServerMessage.LOBBY_ROOM_JOINED, (payload) => { + currentRoom = payload; + render(); + }); + + net.on(ServerMessage.LOBBY_ROOM_UPDATED, (payload) => { + if (currentRoom) { + currentRoom.players = payload.players; + render(); + } + }); + + net.on(ServerMessage.ERROR, (payload) => { + console.error('[gameserver] error:', payload); + errorMessage = payload.message; + render(); + }); + + render(); return net; } diff --git a/frontend/js/game/main.js b/frontend/js/game/main.js index 107031b..47baf7a 100644 --- a/frontend/js/game/main.js +++ b/frontend/js/game/main.js @@ -1,4 +1,4 @@ // TODO: инициализация Quintus и игрового цикла рендера — этапы 4-5 -import { connectAndProbe } from './lobby.js'; +import { initLobby } from './lobby.js'; -connectAndProbe(); +initLobby(); diff --git a/frontend/js/game/network.js b/frontend/js/game/network.js index fed83f2..fe6784e 100644 --- a/frontend/js/game/network.js +++ b/frontend/js/game/network.js @@ -33,6 +33,18 @@ export class Network { listRooms() { this.send(ClientMessage.LOBBY_LIST_ROOMS); } + + createRoom(mode) { + this.send(ClientMessage.LOBBY_CREATE_ROOM, { mode }); + } + + joinRoom(roomId) { + this.send(ClientMessage.LOBBY_JOIN_ROOM, { room_id: roomId }); + } + + leaveRoom() { + this.send(ClientMessage.LOBBY_LEAVE_ROOM); + } } export { ServerMessage }; diff --git a/gameserver/CMakeLists.txt b/gameserver/CMakeLists.txt index d2566eb..ae5c26d 100644 --- a/gameserver/CMakeLists.txt +++ b/gameserver/CMakeLists.txt @@ -39,6 +39,7 @@ target_compile_definitions(usockets PUBLIC LIBUS_NO_SSL LIBUS_USE_EPOLL) add_executable(gameserver src/main.cpp + src/Lobby.cpp ) target_include_directories(gameserver PRIVATE ${uwebsockets_SOURCE_DIR}/src diff --git a/gameserver/src/Lobby.cpp b/gameserver/src/Lobby.cpp new file mode 100644 index 0000000..eb19f01 --- /dev/null +++ b/gameserver/src/Lobby.cpp @@ -0,0 +1,227 @@ +#include "Lobby.hpp" + +#include +#include +#include + +void Lobby::send(WS *ws, const std::string &type, json payload) { + json msg = {{"type", type}, {"payload", std::move(payload)}}; + ws->send(msg.dump(), uWS::OpCode::TEXT); +} + +void Lobby::sendError(WS *ws, const std::string &code, const std::string &message) { + send(ws, "error", {{"code", code}, {"message", message}}); +} + +std::string Lobby::generateRoomId() { + std::ostringstream oss; + oss << "cactus-room-" << std::setw(2) << std::setfill('0') << next_room_id_++; + return oss.str(); +} + +json Lobby::roomsSummary() const { + json rooms = json::array(); + for (const auto &[id, room] : rooms_) { + rooms.push_back({ + {"id", room.id}, + {"name", room.id}, + {"mode", room.mode}, + {"players", room.players.size()}, + {"max_players", kMaxPlayersPerRoom}, + {"state", room.state}, + }); + } + return rooms; +} + +json Lobby::roomPlayers(const Room &room) const { + json players = json::array(); + for (size_t i = 0; i < room.players.size(); i++) { + players.push_back({ + {"id", room.players[i].player_id}, + {"nickname", room.players[i].nickname}, + {"slot", i}, + }); + } + return players; +} + +void Lobby::broadcastRoomsToLobby() { + json rooms = roomsSummary(); + for (WS *client : sockets_) { + if (client->getUserData()->room_id.empty()) { + send(client, "lobby.rooms", {{"rooms", rooms}}); + } + } +} + +void Lobby::broadcastRoomUpdated(const Room &room, WS *exclude) { + json payload = {{"players", roomPlayers(room)}}; + for (const auto &p : room.players) { + if (p.ws != exclude) { + send(p.ws, "lobby.room_updated", payload); + } + } +} + +void Lobby::onOpen(WS *ws) { + sockets_.insert(ws); +} + +void Lobby::onClose(WS *ws) { + removePlayerFromRoom(ws); + sockets_.erase(ws); +} + +void Lobby::onMessage(WS *ws, std::string_view message) { + json request; + std::string type; + try { + request = json::parse(message); + type = request.value("type", ""); + } catch (const std::exception &e) { + sendError(ws, "bad_json", e.what()); + return; + } + + json payload = request.value("payload", json::object()); + + if (type == "hello") { + handleHello(ws, payload); + } else if (type == "lobby.list_rooms") { + handleListRooms(ws); + } else if (type == "lobby.create_room") { + handleCreateRoom(ws, payload); + } else if (type == "lobby.join_room") { + handleJoinRoom(ws, payload); + } else if (type == "lobby.leave_room") { + handleLeaveRoom(ws); + } else { + sendError(ws, "unknown_type", "unknown message type: " + type); + } +} + +void Lobby::handleHello(WS *ws, const json &payload) { + auto *data = ws->getUserData(); + if (data->player_id.empty()) { + data->player_id = "player-" + std::to_string(next_player_id_++); + } + data->nickname = payload.value("nickname", "anon"); + send(ws, "hello.ack", {{"player_id", data->player_id}}); +} + +void Lobby::handleListRooms(WS *ws) { + send(ws, "lobby.rooms", {{"rooms", roomsSummary()}}); +} + +void Lobby::handleCreateRoom(WS *ws, const json &payload) { + auto *data = ws->getUserData(); + if (data->player_id.empty()) { + sendError(ws, "not_authenticated", "send hello first"); + return; + } + if (!data->room_id.empty()) { + sendError(ws, "already_in_room", "leave the current room first"); + return; + } + + std::string mode = payload.value("mode", ""); + if (mode != "coop" && mode != "pvp") { + sendError(ws, "invalid_mode", "mode must be \"coop\" or \"pvp\""); + return; + } + + Room room; + room.id = generateRoomId(); + room.mode = mode; + room.players.push_back({data->player_id, data->nickname, ws}); + + data->room_id = room.id; + std::string room_id = room.id; + rooms_[room_id] = std::move(room); + + send(ws, "lobby.room_joined", { + {"room_id", room_id}, + {"name", room_id}, + {"mode", mode}, + {"players", roomPlayers(rooms_[room_id])}, + }); + + broadcastRoomsToLobby(); +} + +void Lobby::handleJoinRoom(WS *ws, const json &payload) { + auto *data = ws->getUserData(); + if (data->player_id.empty()) { + sendError(ws, "not_authenticated", "send hello first"); + return; + } + if (!data->room_id.empty()) { + sendError(ws, "already_in_room", "leave the current room first"); + return; + } + + std::string room_id = payload.value("room_id", ""); + auto it = rooms_.find(room_id); + if (it == rooms_.end()) { + sendError(ws, "room_not_found", "no such room: " + room_id); + return; + } + + Room &room = it->second; + if (room.state != "waiting") { + sendError(ws, "room_in_progress", "room is not accepting new players"); + return; + } + if (room.players.size() >= kMaxPlayersPerRoom) { + sendError(ws, "room_full", "room is full"); + return; + } + + room.players.push_back({data->player_id, data->nickname, ws}); + data->room_id = room_id; + + send(ws, "lobby.room_joined", { + {"room_id", room_id}, + {"name", room_id}, + {"mode", room.mode}, + {"players", roomPlayers(room)}, + }); + + broadcastRoomUpdated(room, ws); + broadcastRoomsToLobby(); +} + +void Lobby::handleLeaveRoom(WS *ws) { + if (ws->getUserData()->room_id.empty()) { + sendError(ws, "not_in_room", "you are not in a room"); + return; + } + removePlayerFromRoom(ws); + broadcastRoomsToLobby(); +} + +void Lobby::removePlayerFromRoom(WS *ws) { + auto *data = ws->getUserData(); + if (data->room_id.empty()) { + return; + } + + auto it = rooms_.find(data->room_id); + data->room_id.clear(); + if (it == rooms_.end()) { + return; + } + + Room &room = it->second; + room.players.erase( + std::remove_if(room.players.begin(), room.players.end(), + [ws](const RoomPlayer &p) { return p.ws == ws; }), + room.players.end()); + + if (room.players.empty()) { + rooms_.erase(it); + } else { + broadcastRoomUpdated(room); + } +} diff --git a/gameserver/src/Lobby.hpp b/gameserver/src/Lobby.hpp new file mode 100644 index 0000000..87a2fdd --- /dev/null +++ b/gameserver/src/Lobby.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +struct PerSocketData { + std::string player_id; + std::string nickname; + std::string room_id; // пусто, если игрок в лобби, а не в комнате +}; + +using WS = uWS::WebSocket; + +struct RoomPlayer { + std::string player_id; + std::string nickname; + WS *ws; +}; + +struct Room { + std::string id; + std::string mode; // "coop" | "pvp" + std::string state = "waiting"; // waiting | playing | finished + std::vector players; +}; + +// Лобби и реестр комнат (этап 3 из TZ.md). Игровая логика — этапы 4-5. +class Lobby { +public: + void onOpen(WS *ws); + void onMessage(WS *ws, std::string_view message); + void onClose(WS *ws); + +private: + static constexpr size_t kMaxPlayersPerRoom = 2; + + std::unordered_map rooms_; + std::unordered_set sockets_; + int next_player_id_ = 1; + int next_room_id_ = 1; + + void handleHello(WS *ws, const json &payload); + void handleListRooms(WS *ws); + void handleCreateRoom(WS *ws, const json &payload); + void handleJoinRoom(WS *ws, const json &payload); + void handleLeaveRoom(WS *ws); + void removePlayerFromRoom(WS *ws); + + void broadcastRoomsToLobby(); + void broadcastRoomUpdated(const Room &room, WS *exclude = nullptr); + + json roomsSummary() const; + json roomPlayers(const Room &room) const; + std::string generateRoomId(); + + static void send(WS *ws, const std::string &type, json payload); + static void sendError(WS *ws, const std::string &code, const std::string &message); +}; diff --git a/gameserver/src/main.cpp b/gameserver/src/main.cpp index c9990bb..ea65b6b 100644 --- a/gameserver/src/main.cpp +++ b/gameserver/src/main.cpp @@ -1,41 +1,23 @@ -// Каркас (этап 1 из TZ.md): проверка связки nginx <-ws-> gameserver. -// Лобби/комнаты/игровая логика — следующие этапы. +// Игровая логика (тик, коллизии) — этапы 4-5 из TZ.md. #include #include -#include -using json = nlohmann::json; - -struct PerSocketData {}; +#include "Lobby.hpp" int main() { + Lobby lobby; + uWS::App() .ws("/ws", { - .open = [](auto *ws) { - std::cout << "client connected\n"; + .open = [&lobby](auto *ws) { + lobby.onOpen(ws); }, - .message = [](auto *ws, std::string_view message, uWS::OpCode) { - json response; - try { - json request = json::parse(message); - std::string type = request.value("type", ""); - - if (type == "hello") { - response = {{"type", "hello.ack"}, {"payload", {{"player_id", "stub"}}}}; - } else if (type == "lobby.list_rooms") { - response = {{"type", "lobby.rooms"}, {"payload", {{"rooms", json::array()}}}}; - } else { - response = {{"type", "error"}, - {"payload", {{"code", "unknown_type"}, {"message", "unknown message type: " + type}}}}; - } - } catch (const std::exception &e) { - response = {{"type", "error"}, {"payload", {{"code", "bad_json"}, {"message", e.what()}}}}; - } - ws->send(response.dump(), uWS::OpCode::TEXT); + .message = [&lobby](auto *ws, std::string_view message, uWS::OpCode) { + lobby.onMessage(ws, message); }, - .close = [](auto *ws, int, std::string_view) { - std::cout << "client disconnected\n"; + .close = [&lobby](auto *ws, int, std::string_view) { + lobby.onClose(ws); }, }) .listen(9001, [](auto *token) { diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 8657086..1c08e14 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -8,6 +8,12 @@ server { try_files $uri $uri/ =404; } + # без хешей в именах файлов — просим браузер всегда переспрашивать свежесть + location ~* \.(js|css)$ { + add_header Cache-Control "no-cache"; + try_files $uri =404; + } + location /ws { proxy_pass http://gameserver:9001; proxy_http_version 1.1;