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 ? '' : '
подключение...
'}
+
+
+
+
+
+
+
+
+
+
+ ${rooms.length === 0 ? '- пока пусто — создай комнату
' : rooms.map(roomRow).join('')}
+
+
`;
+}
+
+function renderRoomView() {
+ container.innerHTML = `
+
+ ${errorBanner()}
+
room ${currentRoom.name}
+
режим: ${currentRoom.mode}
+
+ ${currentRoom.players.map((p) => `- ${p.nickname}
`).join('')}
+
+
+
`;
+}
+
+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;