Игровая логика PvP: сервер (C++) + клиент (Quintus.js)

Сервер: Game — authoritative-тик 20 Гц через uSockets timer, движение/коллизии
танков и пуль, разрушаемые кирпичные стены, respawn, победа/поражение/ничья,
технический проигрыш при дисконнекте. game.ready запускает матч, когда оба
игрока в pvp-комнате готовы.

Клиент: заведён Quintus.js (MIT, vendored) — Tank/Bullet/Wall спрайты,
рендер по снапшотам game.state, keyboard-инпут (WASD/стрелки/space),
экран результата матча.

Найден и исправлен use-after-free: колбэк onFinished_ синхронно удалял
Game изнутри его же метода — перенесено на uWS::Loop::defer.
This commit is contained in:
2026-07-28 12:05:01 +05:00
parent a9e4828dd9
commit 2d0be9c287
17 changed files with 6632 additions and 5 deletions
+1
View File
@@ -40,6 +40,7 @@ target_compile_definitions(usockets PUBLIC LIBUS_NO_SSL LIBUS_USE_EPOLL)
add_executable(gameserver
src/main.cpp
src/Lobby.cpp
src/Game.cpp
)
target_include_directories(gameserver PRIVATE
${uwebsockets_SOURCE_DIR}/src
+1 -1
View File
@@ -8,7 +8,7 @@ WORKDIR /app
# Заглушки вместо src/, чтобы cmake configure не падал на несуществующих
# исходниках — они не компилируются, т.к. просим собрать только usockets.
COPY CMakeLists.txt ./
RUN mkdir src && touch src/main.cpp src/Lobby.cpp src/Lobby.hpp
RUN mkdir src && touch src/main.cpp src/Lobby.cpp src/Lobby.hpp src/Game.cpp src/Game.hpp
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --target usockets -j"$(nproc)"
# Реальный код поверх уже собранных зависимостей — пересобирается за секунды.
+402
View File
@@ -0,0 +1,402 @@
#include "Game.hpp"
#include <App.h>
#include <libusockets.h>
#include <algorithm>
#include <cmath>
namespace {
constexpr double kTankHalf = 0.4;
constexpr double kTankSpeed = 4.0; // тайлов/сек
constexpr double kBulletSpeed = 8.0; // тайлов/сек
constexpr double kBulletHalf = 0.08;
constexpr int kFireCooldownTicks = 10; // 0.5с при 20 Гц
constexpr double kDt = Game::kTickMs / 1000.0;
} // namespace
Game::Game(Room &room, std::function<void()> onFinished) : onFinished_(std::move(onFinished)) {
buildMap();
for (int i = 0; i < 2; i++) {
Tank &t = tanks_[i];
t.player_id = room.players[i].player_id;
t.ws = room.players[i].ws;
if (i == 0) {
t.x = 1.5;
t.y = kMapHeight - 2.5;
t.dir = Dir::Up;
} else {
t.x = kMapWidth - 2.5;
t.y = 1.5;
t.dir = Dir::Down;
}
}
}
Game::~Game() {
if (timer_) {
us_timer_close(timer_);
}
}
void Game::buildMap() {
map_.assign(kMapHeight, std::vector<int>(kMapWidth, kEmpty));
for (int y = 0; y < kMapHeight; y++) {
for (int x = 0; x < kMapWidth; x++) {
if (x == 0 || y == 0 || x == kMapWidth - 1 || y == kMapHeight - 1) {
map_[y][x] = kSteel;
}
}
}
// Симметричные (180°) кластеры кирпича/стали в интерьере карты.
auto placeMirrored = [this](int x, int y, int w, int h, int tile) {
for (int dy = 0; dy < h; dy++) {
for (int dx = 0; dx < w; dx++) {
map_[y + dy][x + dx] = tile;
map_[kMapHeight - 1 - (y + dy)][kMapWidth - 1 - (x + dx)] = tile;
}
}
};
placeMirrored(3, 3, 2, 2, kBrick);
placeMirrored(6, 3, 1, 2, kBrick);
placeMirrored(3, 6, 2, 1, kSteel);
placeMirrored(6, 6, 3, 3, kBrick);
}
void Game::start() {
timer_ = us_create_timer((us_loop_t *)uWS::Loop::get(), 0, sizeof(Game *));
*(Game **)us_timer_ext(timer_) = this;
us_timer_set(
timer_,
[](us_timer_t *t) {
Game *self = *(Game **)us_timer_ext(t);
self->tick();
},
kTickMs, kTickMs);
}
void Game::handleInput(const std::string &player_id, const json &payload) {
for (auto &t : tanks_) {
if (t.player_id == player_id) {
t.up = payload.value("up", false);
t.down = payload.value("down", false);
t.left = payload.value("left", false);
t.right = payload.value("right", false);
t.fire = payload.value("fire", false);
return;
}
}
}
bool Game::tileSolid(int tx, int ty) const {
if (tx < 0 || ty < 0 || ty >= kMapHeight || tx >= kMapWidth) {
return true;
}
return map_[ty][tx] != kEmpty;
}
bool Game::rectHitsSolid(double x, double y) const {
int minX = (int)std::floor(x - kTankHalf);
int maxX = (int)std::floor(x + kTankHalf - 1e-6);
int minY = (int)std::floor(y - kTankHalf);
int maxY = (int)std::floor(y + kTankHalf - 1e-6);
for (int ty = minY; ty <= maxY; ty++) {
for (int tx = minX; tx <= maxX; tx++) {
if (tileSolid(tx, ty)) {
return true;
}
}
}
return false;
}
bool Game::rectHitsTank(double x, double y, const Tank &self) const {
for (const auto &t : tanks_) {
if (&t == &self || !t.alive) {
continue;
}
if (std::abs(x - t.x) < 2 * kTankHalf && std::abs(y - t.y) < 2 * kTankHalf) {
return true;
}
}
return false;
}
void Game::applyInput(Tank &tank) {
if (!tank.alive) {
return;
}
if (tank.fire_cooldown > 0) {
tank.fire_cooldown--;
}
Dir moveDir = tank.dir;
bool moving = false;
if (tank.up) {
moveDir = Dir::Up;
moving = true;
} else if (tank.down) {
moveDir = Dir::Down;
moving = true;
} else if (tank.left) {
moveDir = Dir::Left;
moving = true;
} else if (tank.right) {
moveDir = Dir::Right;
moving = true;
}
tank.dir = moveDir;
if (moving) {
double nx = tank.x, ny = tank.y;
double step = kTankSpeed * kDt;
switch (moveDir) {
case Dir::Up: ny -= step; break;
case Dir::Down: ny += step; break;
case Dir::Left: nx -= step; break;
case Dir::Right: nx += step; break;
}
if (!rectHitsSolid(nx, ny) && !rectHitsTank(nx, ny, tank)) {
tank.x = nx;
tank.y = ny;
}
}
if (tank.fire) {
fireBullet(tank);
}
}
void Game::fireBullet(Tank &tank) {
if (tank.fire_cooldown > 0) {
return;
}
bool hasOwnBullet = std::any_of(bullets_.begin(), bullets_.end(),
[&](const Bullet &b) { return b.owner_id == tank.player_id; });
if (hasOwnBullet) {
return;
}
Bullet b;
b.id = next_bullet_id_++;
b.owner_id = tank.player_id;
b.dir = tank.dir;
double offset = kTankHalf + kBulletHalf + 0.01;
b.x = tank.x;
b.y = tank.y;
switch (tank.dir) {
case Dir::Up: b.y -= offset; break;
case Dir::Down: b.y += offset; break;
case Dir::Left: b.x -= offset; break;
case Dir::Right: b.x += offset; break;
}
bullets_.push_back(b);
tank.fire_cooldown = kFireCooldownTicks;
}
void Game::updateBullets() {
double step = kBulletSpeed * kDt;
for (auto &b : bullets_) {
switch (b.dir) {
case Dir::Up: b.y -= step; break;
case Dir::Down: b.y += step; break;
case Dir::Left: b.x -= step; break;
case Dir::Right: b.x += step; break;
}
}
// Пуля против пули: встречные уничтожают друг друга.
std::vector<bool> dead(bullets_.size(), false);
for (size_t i = 0; i < bullets_.size(); i++) {
if (dead[i]) continue;
for (size_t j = i + 1; j < bullets_.size(); j++) {
if (dead[j] || bullets_[i].owner_id == bullets_[j].owner_id) continue;
if (std::abs(bullets_[i].x - bullets_[j].x) < 2 * kBulletHalf &&
std::abs(bullets_[i].y - bullets_[j].y) < 2 * kBulletHalf) {
dead[i] = dead[j] = true;
}
}
}
int loserIndex = -1;
bool anyoneHit = false;
for (size_t i = 0; i < bullets_.size(); i++) {
if (dead[i]) continue;
Bullet &b = bullets_[i];
int tx = (int)std::floor(b.x);
int ty = (int)std::floor(b.y);
if (tx < 0 || ty < 0 || tx >= kMapWidth || ty >= kMapHeight) {
dead[i] = true;
continue;
}
if (map_[ty][tx] == kSteel) {
dead[i] = true;
continue;
}
if (map_[ty][tx] == kBrick) {
map_[ty][tx] = kEmpty;
dead[i] = true;
continue;
}
for (int ti = 0; ti < 2; ti++) {
Tank &t = tanks_[ti];
if (!t.alive || t.player_id == b.owner_id) continue;
if (std::abs(b.x - t.x) < kTankHalf + kBulletHalf && std::abs(b.y - t.y) < kTankHalf + kBulletHalf) {
dead[i] = true;
t.lives--;
anyoneHit = true;
if (t.lives <= 0) {
t.alive = false;
loserIndex = ti;
} else {
// респаун на стартовой позиции
t.x = (ti == 0) ? 1.5 : kMapWidth - 2.5;
t.y = (ti == 0) ? kMapHeight - 2.5 : 1.5;
}
break;
}
}
}
std::vector<Bullet> alive;
for (size_t i = 0; i < bullets_.size(); i++) {
if (!dead[i]) alive.push_back(bullets_[i]);
}
bullets_ = std::move(alive);
(void)anyoneHit;
if (loserIndex != -1) {
endGame(1 - loserIndex);
}
}
void Game::tick() {
if (finished_) {
return;
}
tick_count_++;
for (auto &t : tanks_) {
applyInput(t);
}
updateBullets();
if (!finished_) {
broadcastState();
}
}
json Game::dirToJson(Dir d) {
switch (d) {
case Dir::Up: return "up";
case Dir::Down: return "down";
case Dir::Left: return "left";
case Dir::Right: return "right";
}
return "up";
}
json Game::serializeTank(const Tank &t) const {
return {
{"id", t.player_id},
{"x", t.x},
{"y", t.y},
{"direction", dirToJson(t.dir)},
{"lives", t.lives},
{"alive", t.alive},
};
}
json Game::serializeBullet(const Bullet &b) const {
return {
{"id", b.id},
{"owner_id", b.owner_id},
{"x", b.x},
{"y", b.y},
{"direction", dirToJson(b.dir)},
};
}
void Game::sendTo(WS *ws, const std::string &type, json payload) {
json msg = {{"type", type}, {"payload", std::move(payload)}};
ws->send(msg.dump(), uWS::OpCode::TEXT);
}
void Game::broadcastState() {
json tanks = json::array();
for (const auto &t : tanks_) tanks.push_back(serializeTank(t));
json bullets = json::array();
for (const auto &b : bullets_) bullets.push_back(serializeBullet(b));
json payload = {{"tick", tick_count_}, {"tanks", tanks}, {"bullets", bullets}};
for (const auto &t : tanks_) {
sendTo(t.ws, "game.state", payload);
}
}
json Game::buildStartPayload(const std::string &mode) const {
json mapJson = json::array();
for (const auto &row : map_) {
mapJson.push_back(row);
}
json players = json::array();
for (const auto &t : tanks_) {
players.push_back({{"id", t.player_id}});
}
return {
{"map", mapJson},
{"mode", mode},
{"players", players},
{"tick_rate", 1000 / kTickMs},
};
}
void Game::handleDisconnect(WS *ws) {
if (finished_) {
return;
}
for (int i = 0; i < 2; i++) {
if (tanks_[i].ws == ws) {
int winnerIndex = 1 - i;
finished_ = true;
if (timer_) {
us_timer_close(timer_);
timer_ = nullptr;
}
sendTo(tanks_[winnerIndex].ws, "game.over", {{"result", "win"}, {"reason", "соперник отключился"}});
if (onFinished_) {
onFinished_();
}
return;
}
}
}
void Game::endGame(int winnerIndex) {
finished_ = true;
if (timer_) {
us_timer_close(timer_);
timer_ = nullptr;
}
for (int i = 0; i < 2; i++) {
std::string result = (winnerIndex == -1) ? "draw" : (i == winnerIndex ? "win" : "lose");
std::string reason = (winnerIndex == -1) ? "оба танка уничтожены одновременно"
: (i == winnerIndex) ? "противник уничтожен"
: "танк уничтожен";
sendTo(tanks_[i].ws, "game.over", {{"result", result}, {"reason", reason}});
}
if (onFinished_) {
onFinished_();
}
}
+77
View File
@@ -0,0 +1,77 @@
#pragma once
#include "Lobby.hpp"
#include <array>
#include <functional>
#include <string>
#include <vector>
struct us_timer_t;
// Authoritative-игра для одной комнаты в режиме pvp (этап 4 из TZ.md).
// Coop с AI — этап 5, наследует эту же тик-логику.
class Game {
public:
static constexpr int kTickMs = 50; // 20 Гц
static constexpr int kMapWidth = 15;
static constexpr int kMapHeight = 15;
static constexpr int kStartLives = 3;
enum Tile { kEmpty = 0, kBrick = 1, kSteel = 2 };
enum class Dir { Up, Right, Down, Left };
struct Tank {
std::string player_id;
WS *ws;
double x, y;
Dir dir = Dir::Up;
int lives = kStartLives;
int fire_cooldown = 0;
bool up = false, down = false, left = false, right = false, fire = false;
bool alive = true;
};
struct Bullet {
int id;
std::string owner_id;
double x, y;
Dir dir;
};
// room.players.size() должен быть == 2 на момент создания.
Game(Room &room, std::function<void()> onFinished);
~Game();
void start();
void handleInput(const std::string &player_id, const json &payload);
// Технический проигрыш отключившегося — второй игрок побеждает.
void handleDisconnect(WS *ws);
json buildStartPayload(const std::string &mode) const;
private:
std::vector<std::vector<int>> map_;
std::array<Tank, 2> tanks_;
std::vector<Bullet> bullets_;
int next_bullet_id_ = 1;
int tick_count_ = 0;
bool finished_ = false;
us_timer_t *timer_ = nullptr;
std::function<void()> onFinished_;
void buildMap();
void tick();
void applyInput(Tank &tank);
void updateBullets();
bool tileSolid(int tx, int ty) const;
bool rectHitsSolid(double x, double y) const;
bool rectHitsTank(double x, double y, const Tank &self) const;
void fireBullet(Tank &tank);
void endGame(int winnerIndex); // -1 = ничья
void broadcastState();
void sendTo(WS *ws, const std::string &type, json payload);
static json dirToJson(Dir d);
json serializeTank(const Tank &t) const;
json serializeBullet(const Bullet &b) const;
};
+98
View File
@@ -1,9 +1,13 @@
#include "Lobby.hpp"
#include "Game.hpp"
#include <algorithm>
#include <iomanip>
#include <sstream>
Lobby::Lobby() = default;
Lobby::~Lobby() = default;
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);
@@ -41,6 +45,7 @@ json Lobby::roomPlayers(const Room &room) const {
{"id", room.players[i].player_id},
{"nickname", room.players[i].nickname},
{"slot", i},
{"ready", room.players[i].ready},
});
}
return players;
@@ -69,6 +74,7 @@ void Lobby::onOpen(WS *ws) {
}
void Lobby::onClose(WS *ws) {
endActiveGameIfAny(ws->getUserData()->room_id, ws);
removePlayerFromRoom(ws);
sockets_.erase(ws);
}
@@ -96,6 +102,10 @@ void Lobby::onMessage(WS *ws, std::string_view message) {
handleJoinRoom(ws, payload);
} else if (type == "lobby.leave_room") {
handleLeaveRoom(ws);
} else if (type == "game.ready") {
handleGameReady(ws);
} else if (type == "game.input") {
handleGameInput(ws, payload);
} else {
sendError(ws, "unknown_type", "unknown message type: " + type);
}
@@ -197,10 +207,98 @@ void Lobby::handleLeaveRoom(WS *ws) {
sendError(ws, "not_in_room", "you are not in a room");
return;
}
endActiveGameIfAny(ws->getUserData()->room_id, ws);
removePlayerFromRoom(ws);
broadcastRoomsToLobby();
}
void Lobby::handleGameReady(WS *ws) {
auto *data = ws->getUserData();
if (data->room_id.empty()) {
sendError(ws, "not_in_room", "you are not in a room");
return;
}
auto it = rooms_.find(data->room_id);
if (it == rooms_.end()) {
return;
}
for (auto &p : it->second.players) {
if (p.ws == ws) {
p.ready = true;
}
}
broadcastRoomUpdated(it->second);
maybeStartGame(data->room_id);
}
void Lobby::handleGameInput(WS *ws, const json &payload) {
auto *data = ws->getUserData();
if (data->room_id.empty()) {
return;
}
auto it = games_.find(data->room_id);
if (it == games_.end()) {
return;
}
it->second->handleInput(data->player_id, payload);
}
void Lobby::maybeStartGame(const std::string &room_id) {
auto it = rooms_.find(room_id);
if (it == rooms_.end()) {
return;
}
Room &room = it->second;
if (room.mode != "pvp" || room.state != "waiting" || room.players.size() != kMaxPlayersPerRoom) {
return;
}
bool allReady = std::all_of(room.players.begin(), room.players.end(),
[](const RoomPlayer &p) { return p.ready; });
if (!allReady) {
return;
}
room.state = "playing";
auto game = std::make_unique<Game>(room, [this, room_id]() {
auto rit = rooms_.find(room_id);
if (rit != rooms_.end()) {
rit->second.state = "waiting";
for (auto &p : rit->second.players) {
p.ready = false;
}
broadcastRoomUpdated(rit->second);
}
broadcastRoomsToLobby();
// Мы всё ещё внутри вызова метода Game (tick/handleDisconnect), который
// и вызвал этот колбэк — удалять объект прямо сейчас было бы use-after-free
// на возврате из этого вызова. Откладываем erase на следующий тик луп'а.
uWS::Loop::get()->defer([this, room_id]() { games_.erase(room_id); });
});
json startPayload = game->buildStartPayload(room.mode);
for (const auto &p : room.players) {
send(p.ws, "game.start", startPayload);
}
game->start();
games_[room_id] = std::move(game);
broadcastRoomsToLobby();
}
void Lobby::endActiveGameIfAny(const std::string &room_id, WS *leavingWs) {
if (room_id.empty()) {
return;
}
auto it = games_.find(room_id);
if (it == games_.end()) {
return;
}
it->second->handleDisconnect(leavingWs);
games_.erase(it);
}
void Lobby::removePlayerFromRoom(WS *ws) {
auto *data = ws->getUserData();
if (data->room_id.empty()) {
+12
View File
@@ -1,6 +1,7 @@
#pragma once
#include <App.h>
#include <memory>
#include <nlohmann/json.hpp>
#include <string>
#include <unordered_map>
@@ -17,10 +18,13 @@ struct PerSocketData {
using WS = uWS::WebSocket<false, true, PerSocketData>;
class Game;
struct RoomPlayer {
std::string player_id;
std::string nickname;
WS *ws;
bool ready = false;
};
struct Room {
@@ -33,6 +37,9 @@ struct Room {
// Лобби и реестр комнат (этап 3 из TZ.md). Игровая логика — этапы 4-5.
class Lobby {
public:
Lobby();
~Lobby();
void onOpen(WS *ws);
void onMessage(WS *ws, std::string_view message);
void onClose(WS *ws);
@@ -41,6 +48,7 @@ private:
static constexpr size_t kMaxPlayersPerRoom = 2;
std::unordered_map<std::string, Room> rooms_;
std::unordered_map<std::string, std::unique_ptr<Game>> games_;
std::unordered_set<WS *> sockets_;
int next_player_id_ = 1;
int next_room_id_ = 1;
@@ -50,7 +58,11 @@ private:
void handleCreateRoom(WS *ws, const json &payload);
void handleJoinRoom(WS *ws, const json &payload);
void handleLeaveRoom(WS *ws);
void handleGameReady(WS *ws);
void handleGameInput(WS *ws, const json &payload);
void removePlayerFromRoom(WS *ws);
void maybeStartGame(const std::string &room_id);
void endActiveGameIfAny(const std::string &room_id, WS *leavingWs);
void broadcastRoomsToLobby();
void broadcastRoomUpdated(const Room &room, WS *exclude = nullptr);