1
0

initial commit

This commit is contained in:
Sky Johnson 2025-09-08 12:18:01 -05:00
commit cc6c92a005
18 changed files with 1341 additions and 0 deletions

BIN
assets/heightmap.bin Normal file

Binary file not shown.

BIN
assets/heightmap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
assets/textures/black.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
assets/textures/green.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
assets/textures/orange.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
assets/textures/purple.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
assets/textures/red.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
assets/textures/white.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

25
client/CMakeLists.txt Normal file
View File

@ -0,0 +1,25 @@
cmake_minimum_required(VERSION 3.20)
project(TerrainGame)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Find packages
find_package(raylib 4.0 REQUIRED)
find_package(Boost 1.75 REQUIRED COMPONENTS system)
find_package(Threads REQUIRED)
# Create executable
add_executable(game main.cpp)
# Link libraries
target_link_libraries(game
raylib
Boost::system
Threads::Threads
)
# Compiler flags
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(game PRIVATE -Wall -Wextra -O2)
endif()

23
client/Makefile Normal file
View File

@ -0,0 +1,23 @@
CXX = g++
CXXFLAGS = -std=c++20 -Wall -Wextra -O2 -I.
LDFLAGS = -lraylib -lboost_system -lpthread -lGL -lm -ldl -lrt -lX11
TARGET = game_client
SOURCES = main.cpp PlayerController.cpp net/NetworkManager.cpp
OBJECTS = $(SOURCES:.cpp=.o)
all: $(TARGET)
$(TARGET): $(OBJECTS)
$(CXX) $(OBJECTS) -o $(TARGET) $(LDFLAGS)
%.o: %.cpp
$(CXX) $(CXXFLAGS) -c $< -o $@
clean:
rm -f $(OBJECTS) $(TARGET)
run: $(TARGET)
./$(TARGET)
.PHONY: all clean run

131
client/PlayerController.cpp Normal file
View File

@ -0,0 +1,131 @@
#include "PlayerController.hpp"
#include <algorithm>
PlayerController::PlayerController(float distance, float height, float speed)
: cameraDistance(distance), cameraHeight(height), moveSpeed(speed) {
camera.position = {0, cameraHeight, cameraDistance};
camera.target = {0, 0, 0};
camera.up = {0, 1, 0};
camera.fovy = 45.0f;
camera.projection = CAMERA_PERSPECTIVE;
updateCameraPosition();
}
void PlayerController::update(float deltaTime) {
handleCameraRotation();
handleCameraZoom();
updateCameraPosition();
}
void PlayerController::setPlayerPosition(const Vector3& position) {
playerPosition = position;
}
Vector3 PlayerController::getMoveInput() const {
Vector3 moveDir = {0, 0, 0};
// Forward/Backward movement (W/S) - relative to camera direction
if (IsKeyDown(KEY_W)) {
moveDir.x += sinf(cameraYaw);
moveDir.z += cosf(cameraYaw);
}
if (IsKeyDown(KEY_S)) {
moveDir.x -= sinf(cameraYaw);
moveDir.z -= cosf(cameraYaw);
}
// Strafe left/right (Q/E) - perpendicular to camera direction (inverted)
if (IsKeyDown(KEY_Q)) {
moveDir.x += cosf(cameraYaw);
moveDir.z -= sinf(cameraYaw);
}
if (IsKeyDown(KEY_E)) {
moveDir.x -= cosf(cameraYaw);
moveDir.z += sinf(cameraYaw);
}
// Also support A/D for strafing as alternative (inverted)
if (IsKeyDown(KEY_A)) {
moveDir.x += cosf(cameraYaw);
moveDir.z -= sinf(cameraYaw);
}
if (IsKeyDown(KEY_D)) {
moveDir.x -= cosf(cameraYaw);
moveDir.z += sinf(cameraYaw);
}
// Normalize movement vector if it has length
float length = sqrtf(moveDir.x * moveDir.x + moveDir.z * moveDir.z);
if (length > 0.0f) {
moveDir.x /= length;
moveDir.z /= length;
}
return moveDir;
}
void PlayerController::updateCameraPosition() {
// Update camera target to player position
camera.target = playerPosition;
// Calculate camera position based on spherical coordinates
float cosYaw = cosf(cameraYaw);
float sinYaw = sinf(cameraYaw);
float cosPitch = cosf(cameraPitch);
float sinPitch = sinf(cameraPitch);
camera.position.x = playerPosition.x - sinYaw * cosPitch * cameraDistance;
camera.position.y = playerPosition.y + sinPitch * cameraDistance;
camera.position.z = playerPosition.z - cosYaw * cosPitch * cameraDistance;
}
void PlayerController::handleCameraRotation() {
// Check for right mouse button
if (IsMouseButtonDown(MOUSE_RIGHT_BUTTON)) {
if (!isRightMouseDown) {
// Just pressed - store initial mouse position
isRightMouseDown = true;
lastMousePos = GetMousePosition();
DisableCursor();
}
// Get mouse delta
Vector2 currentMousePos = GetMousePosition();
Vector2 mouseDelta = {
currentMousePos.x - lastMousePos.x,
currentMousePos.y - lastMousePos.y
};
// Update camera angles (inverted Y-axis)
const float sensitivity = 0.003f;
cameraYaw -= mouseDelta.x * sensitivity;
cameraPitch += mouseDelta.y * sensitivity;
// Clamp pitch to prevent camera flipping
cameraPitch = std::clamp(cameraPitch, -1.4f, 1.4f);
// Wrap yaw
if (cameraYaw > PI * 2.0f) cameraYaw -= PI * 2.0f;
if (cameraYaw < 0.0f) cameraYaw += PI * 2.0f;
lastMousePos = currentMousePos;
} else {
if (isRightMouseDown) {
// Just released
isRightMouseDown = false;
EnableCursor();
}
}
}
void PlayerController::handleCameraZoom() {
// Handle mouse wheel zoom
float wheel = GetMouseWheelMove();
if (wheel != 0) {
cameraDistance -= wheel * 2.0f;
// Clamp zoom distance
cameraDistance = std::clamp(cameraDistance, 5.0f, 50.0f);
}
}

View File

@ -0,0 +1,37 @@
#pragma once
#include <raylib.h>
#include <raymath.h>
class PlayerController {
private:
Camera3D camera{};
Vector3 playerPosition{0, 0, 0};
float cameraDistance;
float cameraHeight;
float moveSpeed;
float cameraYaw{0.0f};
float cameraPitch{0.3f};
bool isRightMouseDown{false};
Vector2 lastMousePos{0, 0};
public:
PlayerController(float distance = 10.0f, float height = 5.0f, float speed = 5.0f);
void update(float deltaTime);
void setPlayerPosition(const Vector3& position);
Vector3 getPlayerPosition() const { return playerPosition; }
Vector3 getMoveInput() const;
Camera3D& getCamera() { return camera; }
float getCameraYaw() const { return cameraYaw; }
float getCameraPitch() const { return cameraPitch; }
private:
void updateCameraPosition();
void handleCameraRotation();
void handleCameraZoom();
};

291
client/main.cpp Normal file
View File

@ -0,0 +1,291 @@
#include <raylib.h>
#include <raymath.h>
#include <iostream>
#include <vector>
#include <fstream>
#include <unordered_map>
#include <cstdint>
#include "PlayerController.hpp"
#include "net/NetworkManager.hpp"
constexpr int WORLD_SIZE = 100;
constexpr float WORLD_SCALE = 10.0f;
constexpr float MOVE_SPEED = 15.0f;
struct Heightmap {
std::vector<float> data;
int size{0};
float getHeight(float x, float z) const {
auto hx = static_cast<int>((x / WORLD_SIZE + 0.5f) * (size - 1));
auto hz = static_cast<int>((z / WORLD_SIZE + 0.5f) * (size - 1));
if (hx < 0 || hx >= size || hz < 0 || hz >= size) return 0.0f;
return data[hz * size + hx];
}
bool load(const std::string& filename) {
std::ifstream file(filename, std::ios::binary);
if (!file) return false;
int32_t fileSize;
file.read(reinterpret_cast<char*>(&fileSize), sizeof(fileSize));
size = fileSize;
data.resize(size * size);
file.read(reinterpret_cast<char*>(data.data()), data.size() * sizeof(float));
return true;
}
Mesh generateMesh() const {
auto mesh = Mesh{};
int vertexCount = size * size;
int triangleCount = (size - 1) * (size - 1) * 2;
mesh.vertexCount = vertexCount;
mesh.triangleCount = triangleCount;
mesh.vertices = (float*)MemAlloc(vertexCount * 3 * sizeof(float));
mesh.texcoords = (float*)MemAlloc(vertexCount * 2 * sizeof(float));
mesh.normals = (float*)MemAlloc(vertexCount * 3 * sizeof(float));
mesh.indices = (unsigned short*)MemAlloc(triangleCount * 3 * sizeof(unsigned short));
// Generate vertices
for (int z = 0; z < size; z++) {
for (int x = 0; x < size; x++) {
int idx = z * size + x;
mesh.vertices[idx * 3] = (float(x) / (size - 1) - 0.5f) * WORLD_SIZE;
mesh.vertices[idx * 3 + 1] = data[idx];
mesh.vertices[idx * 3 + 2] = (float(z) / (size - 1) - 0.5f) * WORLD_SIZE;
mesh.texcoords[idx * 2] = static_cast<float>(x) / size;
mesh.texcoords[idx * 2 + 1] = static_cast<float>(z) / size;
}
}
// Generate indices
int triIdx = 0;
for (int z = 0; z < size - 1; z++) {
for (int x = 0; x < size - 1; x++) {
int topLeft = z * size + x;
int topRight = topLeft + 1;
int bottomLeft = (z + 1) * size + x;
int bottomRight = bottomLeft + 1;
mesh.indices[triIdx++] = topLeft;
mesh.indices[triIdx++] = bottomLeft;
mesh.indices[triIdx++] = topRight;
mesh.indices[triIdx++] = topRight;
mesh.indices[triIdx++] = bottomLeft;
mesh.indices[triIdx++] = bottomRight;
}
}
// Calculate normals
for (int i = 0; i < vertexCount; i++) {
mesh.normals[i * 3] = 0;
mesh.normals[i * 3 + 1] = 1;
mesh.normals[i * 3 + 2] = 0;
}
UploadMesh(&mesh, false);
return mesh;
}
};
class Game {
PlayerController playerController;
Model terrainModel;
Model playerModel;
Heightmap heightmap;
NetworkManager network;
Vector3 playerPos{0, 0, 0};
Texture2D terrainTexture;
std::unordered_map<std::string, Texture2D> playerTextures;
std::unordered_map<uint32_t, Model> remotePlayerModels;
public:
Game() {
InitWindow(1280, 720, "Multiplayer Terrain Game");
SetTargetFPS(60);
// Load heightmap
if (!heightmap.load("../assets/heightmap.bin")) {
std::cerr << "Failed to load heightmap\n";
}
// Load textures
terrainTexture = LoadTexture("../assets/textures/black.png");
// Load all player color textures
playerTextures["red"] = LoadTexture("../assets/textures/red.png");
playerTextures["green"] = LoadTexture("../assets/textures/green.png");
playerTextures["orange"] = LoadTexture("../assets/textures/orange.png");
playerTextures["purple"] = LoadTexture("../assets/textures/purple.png");
playerTextures["white"] = LoadTexture("../assets/textures/white.png");
// Create terrain model
auto terrainMesh = heightmap.generateMesh();
terrainModel = LoadModelFromMesh(terrainMesh);
terrainModel.materials[0].maps[MATERIAL_MAP_DIFFUSE].texture = terrainTexture;
// Create player cube (texture will be set when we know our color)
auto cubeMesh = GenMeshCube(1.0f, 2.0f, 1.0f);
playerModel = LoadModelFromMesh(cubeMesh);
// Connect to server
network.sendLogin();
}
~Game() {
UnloadTexture(terrainTexture);
for (auto& [color, texture] : playerTextures) {
UnloadTexture(texture);
}
UnloadModel(terrainModel);
UnloadModel(playerModel);
for (auto& [id, model] : remotePlayerModels) {
UnloadModel(model);
}
CloseWindow();
}
void run() {
while (!WindowShouldClose()) {
update();
render();
}
}
private:
void update() {
if (!network.isConnected()) {
if (IsKeyPressed(KEY_SPACE)) {
network.sendLogin();
}
return;
}
// Get server position and update player controller
playerPos = network.getPosition();
playerController.setPlayerPosition(playerPos);
// Set player texture based on assigned color
if (network.isConnected() && playerTextures.count(network.getPlayerColor()) > 0) {
playerModel.materials[0].maps[MATERIAL_MAP_DIFFUSE].texture = playerTextures[network.getPlayerColor()];
}
// Update remote player models
auto remotePlayers = network.getRemotePlayers();
for (const auto& [id, player] : remotePlayers) {
if (remotePlayerModels.find(id) == remotePlayerModels.end()) {
// Create new model for this player
auto cubeMesh = GenMeshCube(1.0f, 2.0f, 1.0f);
remotePlayerModels[id] = LoadModelFromMesh(cubeMesh);
}
// Always update texture in case color changed
if (playerTextures.count(player.color) > 0) {
remotePlayerModels[id].materials[0].maps[MATERIAL_MAP_DIFFUSE].texture = playerTextures[player.color];
}
}
// Remove models for players who left
for (auto it = remotePlayerModels.begin(); it != remotePlayerModels.end();) {
if (remotePlayers.find(it->first) == remotePlayers.end()) {
UnloadModel(it->second);
it = remotePlayerModels.erase(it);
} else {
++it;
}
}
// Update player controller (handles camera)
float deltaTime = GetFrameTime();
playerController.update(deltaTime);
// Get movement input from player controller
Vector3 moveInput = playerController.getMoveInput();
// Send normalized movement direction to server (server handles speed)
if (moveInput.x != 0 || moveInput.z != 0) {
network.sendMove(moveInput.x, 0, moveInput.z);
}
// Handle color change with arrow keys
static int currentColorIndex = -1;
if (IsKeyPressed(KEY_LEFT) || IsKeyPressed(KEY_RIGHT)) {
// Get current color index if not set
if (currentColorIndex == -1) {
auto currentColor = network.getPlayerColor();
for (size_t i = 0; i < NetworkManager::AVAILABLE_COLORS.size(); i++) {
if (NetworkManager::AVAILABLE_COLORS[i] == currentColor) {
currentColorIndex = i;
break;
}
}
if (currentColorIndex == -1) currentColorIndex = 0;
}
// Change color index
if (IsKeyPressed(KEY_LEFT)) {
currentColorIndex--;
if (currentColorIndex < 0) {
currentColorIndex = NetworkManager::AVAILABLE_COLORS.size() - 1;
}
} else if (IsKeyPressed(KEY_RIGHT)) {
currentColorIndex++;
if (currentColorIndex >= (int)NetworkManager::AVAILABLE_COLORS.size()) {
currentColorIndex = 0;
}
}
// Send color change to server
network.sendColorChange(NetworkManager::AVAILABLE_COLORS[currentColorIndex]);
}
}
void render() {
BeginDrawing();
ClearBackground(SKYBLUE);
BeginMode3D(playerController.getCamera());
// Draw terrain
DrawModel(terrainModel, {0, 0, 0}, 1.0f, WHITE);
// Draw player
if (network.isConnected()) {
DrawModel(playerModel, playerPos, 1.0f, WHITE);
}
// Draw remote players
auto remotePlayers = network.getRemotePlayers();
for (const auto& [id, player] : remotePlayers) {
if (remotePlayerModels.find(id) != remotePlayerModels.end()) {
DrawModel(remotePlayerModels[id], player.position, 1.0f, WHITE);
}
}
EndMode3D();
// UI
DrawText(network.isConnected() ? "Connected" : "Press SPACE to connect", 10, 10, 20, WHITE);
DrawText("WASD: Move | Q/E: Strafe | Right-Click: Rotate Camera", 10, 35, 20, WHITE);
DrawText("Left/Right Arrow: Change Color | Mouse Wheel: Zoom", 10, 60, 20, WHITE);
if (network.isConnected()) {
std::string colorText = "Your color: " + network.getPlayerColor();
DrawText(colorText.c_str(), 10, 85, 20, WHITE);
}
DrawFPS(10, 110);
EndDrawing();
}
};
int main() {
Game game;
game.run();
return 0;
}

View File

@ -0,0 +1,243 @@
#include "NetworkManager.hpp"
#include <iostream>
#include <cstring>
const std::vector<std::string> NetworkManager::AVAILABLE_COLORS = {
"red", "green", "orange", "purple", "white"
};
NetworkManager::NetworkManager() : serverEndpoint(ip::make_address("127.0.0.1"), 9999) {
socket.open(udp::v4());
startReceive();
ioThread = std::thread([this] { ioContext.run(); });
}
NetworkManager::~NetworkManager() {
ioContext.stop();
if (ioThread.joinable()) ioThread.join();
}
void NetworkManager::startReceive() {
socket.async_receive_from(
buffer(recvBuffer), serverEndpoint,
[this](std::error_code ec, std::size_t bytes) {
if (!ec && bytes > 0) {
processMessage(recvBuffer.data(), bytes);
}
startReceive();
}
);
}
void NetworkManager::processMessage(const uint8_t* data, std::size_t size) {
if (size == 0) return;
auto msgType = static_cast<MessageType>(data[0]);
switch (msgType) {
case MessageType::Spawn:
handleSpawn(data, size);
break;
case MessageType::Update:
handleUpdate(data, size);
break;
case MessageType::PlayerJoined:
handlePlayerJoined(data, size);
break;
case MessageType::PlayerLeft:
handlePlayerLeft(data, size);
break;
case MessageType::PlayerList:
handlePlayerList(data, size);
break;
case MessageType::ColorChanged:
handleColorChanged(data, size);
break;
default:
break;
}
}
void NetworkManager::handleSpawn(const uint8_t* data, std::size_t size) {
// Message format: [type(1)][id(4)][x(4)][y(4)][z(4)][colorLen(1)][color(colorLen)]
if (size < 18) return;
uint32_t id;
float x, y, z;
std::memcpy(&id, &data[1], sizeof(id));
std::memcpy(&x, &data[5], sizeof(x));
std::memcpy(&y, &data[9], sizeof(y));
std::memcpy(&z, &data[13], sizeof(z));
uint8_t colorLen = data[17];
if (size >= 18 + colorLen) {
playerColor = std::string(reinterpret_cast<const char*>(&data[18]), colorLen);
}
playerID = id;
{
std::lock_guard lock(positionMutex);
serverPosition = {x, y, z};
}
connected = true;
std::cout << "Connected as player " << id << " with color " << playerColor << "\n";
}
void NetworkManager::handleUpdate(const uint8_t* data, std::size_t size) {
if (size < 17) return;
uint32_t id;
float x, y, z;
std::memcpy(&id, &data[1], sizeof(id));
std::memcpy(&x, &data[5], sizeof(x));
std::memcpy(&y, &data[9], sizeof(y));
std::memcpy(&z, &data[13], sizeof(z));
if (id == playerID) {
std::lock_guard lock(positionMutex);
serverPosition = {x, y, z};
} else {
std::lock_guard lock(remotePlayersMutex);
if (remotePlayers.find(id) != remotePlayers.end()) {
remotePlayers[id].position = {x, y, z};
remotePlayers[id].lastUpdate = GetTime();
}
}
}
void NetworkManager::handlePlayerJoined(const uint8_t* data, std::size_t size) {
// Message format: [type(1)][id(4)][x(4)][y(4)][z(4)][colorLen(1)][color(colorLen)]
if (size < 18) return;
uint32_t id;
float x, y, z;
std::memcpy(&id, &data[1], sizeof(id));
std::memcpy(&x, &data[5], sizeof(x));
std::memcpy(&y, &data[9], sizeof(y));
std::memcpy(&z, &data[13], sizeof(z));
uint8_t colorLen = data[17];
std::string color = "red";
if (size >= 18 + colorLen) {
color = std::string(reinterpret_cast<const char*>(&data[18]), colorLen);
}
if (id != playerID) {
std::lock_guard lock(remotePlayersMutex);
remotePlayers[id] = {id, {x, y, z}, color, static_cast<float>(GetTime())};
std::cout << "Player " << id << " joined with color " << color << "\n";
}
}
void NetworkManager::handlePlayerLeft(const uint8_t* data, std::size_t size) {
if (size < 5) return;
uint32_t id;
std::memcpy(&id, &data[1], sizeof(id));
std::lock_guard lock(remotePlayersMutex);
remotePlayers.erase(id);
std::cout << "Player " << id << " left\n";
}
void NetworkManager::handlePlayerList(const uint8_t* data, std::size_t size) {
if (size < 2) return;
uint8_t count = data[1];
size_t offset = 2;
std::lock_guard lock(remotePlayersMutex);
remotePlayers.clear();
for (uint8_t i = 0; i < count && offset + 17 < size; i++) {
uint32_t id;
float x, y, z;
std::memcpy(&id, &data[offset], sizeof(id));
std::memcpy(&x, &data[offset + 4], sizeof(x));
std::memcpy(&y, &data[offset + 8], sizeof(y));
std::memcpy(&z, &data[offset + 12], sizeof(z));
uint8_t colorLen = data[offset + 16];
std::string color = "red";
if (offset + 17 + colorLen <= size) {
color = std::string(reinterpret_cast<const char*>(&data[offset + 17]), colorLen);
}
offset += 17 + colorLen;
if (id != playerID) {
remotePlayers[id] = {id, {x, y, z}, color, static_cast<float>(GetTime())};
}
}
std::cout << "Received list of " << (int)count << " players\n";
}
void NetworkManager::sendLogin() {
std::array<uint8_t, 1> msg{static_cast<uint8_t>(MessageType::Login)};
socket.send_to(buffer(msg), serverEndpoint);
}
void NetworkManager::sendMove(float dx, float dy, float dz) {
if (!connected) return;
std::array<uint8_t, 17> msg{};
msg[0] = static_cast<uint8_t>(MessageType::Move);
uint32_t id = playerID;
std::memcpy(&msg[1], &id, sizeof(id));
std::memcpy(&msg[5], &dx, sizeof(dx));
std::memcpy(&msg[9], &dy, sizeof(dy));
std::memcpy(&msg[13], &dz, sizeof(dz));
socket.send_to(buffer(msg), serverEndpoint);
}
void NetworkManager::sendColorChange(const std::string& newColor) {
if (!connected) return;
std::vector<uint8_t> msg(6 + newColor.size());
msg[0] = static_cast<uint8_t>(MessageType::ChangeColor);
uint32_t id = playerID;
std::memcpy(&msg[1], &id, sizeof(id));
msg[5] = static_cast<uint8_t>(newColor.size());
std::memcpy(&msg[6], newColor.data(), newColor.size());
socket.send_to(buffer(msg), serverEndpoint);
}
Vector3 NetworkManager::getPosition() {
std::lock_guard lock(positionMutex);
return serverPosition;
}
void NetworkManager::handleColorChanged(const uint8_t* data, std::size_t size) {
// Message format: [type(1)][id(4)][colorLen(1)][color(colorLen)]
if (size < 6) return;
uint32_t id;
std::memcpy(&id, &data[1], sizeof(id));
uint8_t colorLen = data[5];
if (size >= 6 + colorLen) {
std::string newColor(reinterpret_cast<const char*>(&data[6]), colorLen);
if (id == playerID) {
playerColor = newColor;
std::cout << "Your color changed to " << newColor << "\n";
} else {
std::lock_guard lock(remotePlayersMutex);
if (remotePlayers.find(id) != remotePlayers.end()) {
remotePlayers[id].color = newColor;
std::cout << "Player " << id << " changed color to " << newColor << "\n";
}
}
}
}
std::unordered_map<uint32_t, RemotePlayer> NetworkManager::getRemotePlayers() {
std::lock_guard lock(remotePlayersMutex);
return remotePlayers;
}

View File

@ -0,0 +1,78 @@
#pragma once
#include <boost/asio.hpp>
#include <raylib.h>
#include <cstdint>
#include <thread>
#include <mutex>
#include <atomic>
#include <unordered_map>
#include <array>
using namespace boost::asio;
using ip::udp;
enum class MessageType : uint8_t {
Login = 0x01,
Position = 0x02,
Spawn = 0x03,
Move = 0x04,
Update = 0x05,
PlayerJoined = 0x06,
PlayerLeft = 0x07,
PlayerList = 0x08,
ChangeColor = 0x09,
ColorChanged = 0x0A
};
struct RemotePlayer {
uint32_t id;
Vector3 position;
std::string color;
float lastUpdate;
};
class NetworkManager {
public:
NetworkManager();
~NetworkManager();
void sendLogin();
void sendMove(float dx, float dy, float dz);
void sendColorChange(const std::string& newColor);
Vector3 getPosition();
bool isConnected() const { return connected; }
uint32_t getPlayerID() const { return playerID; }
std::string getPlayerColor() const { return playerColor; }
std::unordered_map<uint32_t, RemotePlayer> getRemotePlayers();
// Available colors for cycling
static const std::vector<std::string> AVAILABLE_COLORS;
private:
io_context ioContext;
udp::socket socket{ioContext};
udp::endpoint serverEndpoint;
std::thread ioThread;
std::array<uint8_t, 1024> recvBuffer;
std::atomic<uint32_t> playerID{0};
std::string playerColor{"red"};
std::mutex positionMutex;
Vector3 serverPosition{0, 0, 0};
std::atomic<bool> connected{false};
std::mutex remotePlayersMutex;
std::unordered_map<uint32_t, RemotePlayer> remotePlayers;
void startReceive();
void processMessage(const uint8_t* data, std::size_t size);
void handleSpawn(const uint8_t* data, std::size_t size);
void handleUpdate(const uint8_t* data, std::size_t size);
void handlePlayerJoined(const uint8_t* data, std::size_t size);
void handlePlayerLeft(const uint8_t* data, std::size_t size);
void handlePlayerList(const uint8_t* data, std::size_t size);
void handleColorChanged(const uint8_t* data, std::size_t size);
};

3
server/go.mod Normal file
View File

@ -0,0 +1,3 @@
module server
go 1.25.0

509
server/main.go Normal file
View File

@ -0,0 +1,509 @@
package main
import (
"encoding/binary"
"encoding/json"
"fmt"
"image"
"image/color"
"image/png"
"math"
"math/rand"
"net"
"os"
"sync"
"time"
"slices"
)
type Vec3 struct {
X, Y, Z float32
}
type Player struct {
ID uint32
Position Vec3
Velocity Vec3
Color string
Address *net.UDPAddr
LastSeen time.Time
}
type GameServer struct {
conn *net.UDPConn
players map[uint32]*Player
heightmap [][]float32
mutex sync.RWMutex
nextID uint32
}
const (
MSG_LOGIN = 0x01
MSG_POSITION = 0x02
MSG_SPAWN = 0x03
MSG_MOVE = 0x04
MSG_UPDATE = 0x05
MSG_PLAYER_JOINED = 0x06
MSG_PLAYER_LEFT = 0x07
MSG_PLAYER_LIST = 0x08
MSG_CHANGE_COLOR = 0x09
MSG_COLOR_CHANGED = 0x0A
WORLD_SIZE = 100
WORLD_SCALE = 10.0
MOVE_SPEED = 15.0
GRAVITY = -9.8
PLAYER_HEIGHT = 1.0
)
func generateHeightmap(size int) [][]float32 {
heightmap := make([][]float32, size)
for i := range heightmap {
heightmap[i] = make([]float32, size)
}
// Simple perlin-like noise
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
nx := float64(x) / float64(size) * 4
ny := float64(y) / float64(size) * 4
heightmap[y][x] = float32(
math.Sin(nx*2+rand.Float64()) * 0.5 +
math.Cos(ny*3+rand.Float64()) * 0.3 +
rand.Float64() * 0.2) * 10.0
}
}
// Smooth the heightmap
for i := 0; i < 3; i++ {
newHeightmap := make([][]float32, size)
for y := range newHeightmap {
newHeightmap[y] = make([]float32, size)
for x := range newHeightmap[y] {
sum := heightmap[y][x]
count := float32(1)
for dy := -1; dy <= 1; dy++ {
for dx := -1; dx <= 1; dx++ {
nx, ny := x+dx, y+dy
if nx >= 0 && nx < size && ny >= 0 && ny < size {
sum += heightmap[ny][nx]
count++
}
}
}
newHeightmap[y][x] = sum / count
}
}
heightmap = newHeightmap
}
return heightmap
}
func saveHeightmapPNG(heightmap [][]float32, filename string) {
size := len(heightmap)
img := image.NewGray(image.Rect(0, 0, size, size))
// Find min/max for normalization
minH, maxH := heightmap[0][0], heightmap[0][0]
for y := range heightmap {
for x := range heightmap[y] {
if heightmap[y][x] < minH {
minH = heightmap[y][x]
}
if heightmap[y][x] > maxH {
maxH = heightmap[y][x]
}
}
}
for y := range heightmap {
for x := range heightmap[y] {
normalized := (heightmap[y][x] - minH) / (maxH - minH)
img.SetGray(x, y, color.Gray{uint8(normalized * 255)})
}
}
file, _ := os.Create(filename)
defer file.Close()
png.Encode(file, img)
}
func saveHeightmapBinary(heightmap [][]float32, filename string) {
size := len(heightmap)
file, _ := os.Create(filename)
defer file.Close()
binary.Write(file, binary.LittleEndian, int32(size))
for y := range heightmap {
for x := range heightmap[y] {
binary.Write(file, binary.LittleEndian, heightmap[y][x])
}
}
}
func (s *GameServer) getHeightAt(x, z float32) float32 {
// Convert world coords to heightmap coords with bilinear interpolation
size := float32(len(s.heightmap))
fx := (x/WORLD_SIZE + 0.5) * (size - 1)
fz := (z/WORLD_SIZE + 0.5) * (size - 1)
// Get integer coordinates
x0 := int(math.Floor(float64(fx)))
z0 := int(math.Floor(float64(fz)))
x1 := x0 + 1
z1 := z0 + 1
// Clamp to bounds
if x0 < 0 || x1 >= len(s.heightmap) || z0 < 0 || z1 >= len(s.heightmap) {
return 0
}
// Get fractional parts
tx := fx - float32(x0)
tz := fz - float32(z0)
// Bilinear interpolation
h00 := s.heightmap[z0][x0]
h10 := s.heightmap[z0][x1]
h01 := s.heightmap[z1][x0]
h11 := s.heightmap[z1][x1]
h0 := h00*(1-tx) + h10*tx
h1 := h01*(1-tx) + h11*tx
return h0*(1-tz) + h1*tz
}
func (s *GameServer) loadPlayerPositions() {
data, err := os.ReadFile("players.json")
if err != nil {
return
}
var savedPlayers map[uint32]Vec3
json.Unmarshal(data, &savedPlayers)
for id, pos := range savedPlayers {
if id > s.nextID {
s.nextID = id
}
s.players[id] = &Player{
ID: id,
Position: pos,
LastSeen: time.Now(),
}
}
}
func (s *GameServer) sendPlayerList(addr *net.UDPAddr, players []*Player) {
if len(players) == 0 {
return
}
msg := make([]byte, 1024)
msg[0] = MSG_PLAYER_LIST
msg[1] = uint8(len(players))
offset := 2
for _, p := range players {
binary.LittleEndian.PutUint32(msg[offset:], p.ID)
binary.LittleEndian.PutUint32(msg[offset+4:], math.Float32bits(p.Position.X))
binary.LittleEndian.PutUint32(msg[offset+8:], math.Float32bits(p.Position.Y))
binary.LittleEndian.PutUint32(msg[offset+12:], math.Float32bits(p.Position.Z))
colorBytes := []byte(p.Color)
msg[offset+16] = uint8(len(colorBytes))
copy(msg[offset+17:], colorBytes)
offset += 17 + len(colorBytes)
if offset > 1000 {
break // Prevent overflow
}
}
s.conn.WriteToUDP(msg[:offset], addr)
}
func (s *GameServer) broadcastPlayerJoined(newPlayer *Player) {
colorBytes := []byte(newPlayer.Color)
msg := make([]byte, 18+len(colorBytes))
msg[0] = MSG_PLAYER_JOINED
binary.LittleEndian.PutUint32(msg[1:5], newPlayer.ID)
binary.LittleEndian.PutUint32(msg[5:9], math.Float32bits(newPlayer.Position.X))
binary.LittleEndian.PutUint32(msg[9:13], math.Float32bits(newPlayer.Position.Y))
binary.LittleEndian.PutUint32(msg[13:17], math.Float32bits(newPlayer.Position.Z))
msg[17] = uint8(len(colorBytes))
copy(msg[18:], colorBytes)
s.mutex.RLock()
for _, p := range s.players {
if p.ID != newPlayer.ID && p.Address != nil {
s.conn.WriteToUDP(msg, p.Address)
}
}
s.mutex.RUnlock()
}
func (s *GameServer) broadcastPlayerLeft(playerID uint32) {
msg := make([]byte, 5)
msg[0] = MSG_PLAYER_LEFT
binary.LittleEndian.PutUint32(msg[1:5], playerID)
s.mutex.RLock()
for _, p := range s.players {
if p.ID != playerID && p.Address != nil {
s.conn.WriteToUDP(msg, p.Address)
}
}
s.mutex.RUnlock()
}
func (s *GameServer) broadcastUpdate(player *Player) {
msg := make([]byte, 17)
msg[0] = MSG_UPDATE
binary.LittleEndian.PutUint32(msg[1:5], player.ID)
binary.LittleEndian.PutUint32(msg[5:9], math.Float32bits(player.Position.X))
binary.LittleEndian.PutUint32(msg[9:13], math.Float32bits(player.Position.Y))
binary.LittleEndian.PutUint32(msg[13:17], math.Float32bits(player.Position.Z))
s.mutex.RLock()
for _, p := range s.players {
if p.Address != nil {
s.conn.WriteToUDP(msg, p.Address)
}
}
s.mutex.RUnlock()
}
func (s *GameServer) handleColorChange(data []byte, addr *net.UDPAddr) {
if len(data) < 6 {
return
}
playerID := binary.LittleEndian.Uint32(data[1:5])
colorLen := data[5]
if len(data) < 6+int(colorLen) {
return
}
newColor := string(data[6 : 6+colorLen])
// Validate color
validColors := []string{"red", "green", "orange", "purple", "white"}
isValid := slices.Contains(validColors, newColor)
if !isValid {
return
}
s.mutex.Lock()
player, exists := s.players[playerID]
if !exists {
s.mutex.Unlock()
return
}
player.Color = newColor
s.mutex.Unlock()
// Broadcast color change to all players
s.broadcastColorChanged(playerID, newColor)
fmt.Printf("Player %d changed color to %s\n", playerID, newColor)
}
func (s *GameServer) broadcastColorChanged(playerID uint32, color string) {
colorBytes := []byte(color)
msg := make([]byte, 6+len(colorBytes))
msg[0] = MSG_COLOR_CHANGED
binary.LittleEndian.PutUint32(msg[1:5], playerID)
msg[5] = uint8(len(colorBytes))
copy(msg[6:], colorBytes)
s.mutex.RLock()
for _, p := range s.players {
if p.Address != nil {
s.conn.WriteToUDP(msg, p.Address)
}
}
s.mutex.RUnlock()
}
func (s *GameServer) savePlayerPositions() {
s.mutex.RLock()
savedPlayers := make(map[uint32]Vec3)
for id, player := range s.players {
savedPlayers[id] = player.Position
}
s.mutex.RUnlock()
data, _ := json.Marshal(savedPlayers)
os.WriteFile("players.json", data, 0644)
}
func (s *GameServer) handleLogin(addr *net.UDPAddr) {
s.mutex.Lock()
s.nextID++
playerID := s.nextID
// Assign color based on player ID to ensure variety
colors := []string{"red", "green", "orange", "purple", "white"}
// Cycle through colors based on player ID
colorIndex := (playerID - 1) % uint32(len(colors))
color := colors[colorIndex]
// Spawn at random position on heightmap
x := rand.Float32() * WORLD_SIZE - WORLD_SIZE/2
z := rand.Float32() * WORLD_SIZE - WORLD_SIZE/2
y := s.getHeightAt(x, z) + PLAYER_HEIGHT
player := &Player{
ID: playerID,
Position: Vec3{x, y, z},
Color: color,
Address: addr,
LastSeen: time.Now(),
}
// Send existing players to new player
existingPlayers := make([]*Player, 0)
for _, p := range s.players {
if p.ID != playerID {
existingPlayers = append(existingPlayers, p)
}
}
s.players[playerID] = player
s.mutex.Unlock()
// Send spawn message with color
colorBytes := []byte(color)
msg := make([]byte, 18+len(colorBytes))
msg[0] = MSG_SPAWN
binary.LittleEndian.PutUint32(msg[1:5], playerID)
binary.LittleEndian.PutUint32(msg[5:9], math.Float32bits(player.Position.X))
binary.LittleEndian.PutUint32(msg[9:13], math.Float32bits(player.Position.Y))
binary.LittleEndian.PutUint32(msg[13:17], math.Float32bits(player.Position.Z))
msg[17] = uint8(len(colorBytes))
copy(msg[18:], colorBytes)
s.conn.WriteToUDP(msg, addr)
// Send player list to new player
s.sendPlayerList(addr, existingPlayers)
// Notify other players about new player
s.broadcastPlayerJoined(player)
fmt.Printf("Player %d logged in at (%.2f, %.2f, %.2f) with color %s\n", playerID, x, y, z, color)
s.savePlayerPositions()
}
func (s *GameServer) handleMove(data []byte, addr *net.UDPAddr) {
if len(data) < 17 {
return
}
playerID := binary.LittleEndian.Uint32(data[1:5])
dx := math.Float32frombits(binary.LittleEndian.Uint32(data[5:9]))
// dy := math.Float32frombits(binary.LittleEndian.Uint32(data[9:13])) // Not used - Y position is determined by terrain height
dz := math.Float32frombits(binary.LittleEndian.Uint32(data[13:17]))
s.mutex.Lock()
player, exists := s.players[playerID]
if !exists {
s.mutex.Unlock()
return
}
// Server-authoritative movement - server decides the actual speed
// dx/dz from client are just normalized direction vectors
deltaTime := float32(0.016) // Assume 60fps for now
newX := player.Position.X + dx * MOVE_SPEED * deltaTime
newZ := player.Position.Z + dz * MOVE_SPEED * deltaTime
// Clamp to world bounds
newX = float32(math.Max(float64(-WORLD_SIZE/2), math.Min(float64(WORLD_SIZE/2), float64(newX))))
newZ = float32(math.Max(float64(-WORLD_SIZE/2), math.Min(float64(WORLD_SIZE/2), float64(newZ))))
// Set Y to terrain height with some smoothing
targetY := s.getHeightAt(newX, newZ) + PLAYER_HEIGHT
// Smooth the Y transition
smoothFactor := float32(0.15) // How quickly to adapt to new height
newY := player.Position.Y + (targetY - player.Position.Y) * smoothFactor
player.Position.X = newX
player.Position.Y = newY
player.Position.Z = newZ
player.LastSeen = time.Now()
s.mutex.Unlock()
// Broadcast position update to all players
s.broadcastUpdate(player)
}
func (s *GameServer) run() {
buffer := make([]byte, 1024)
// Periodic save
go func() {
ticker := time.NewTicker(10 * time.Second)
for range ticker.C {
s.savePlayerPositions()
}
}()
for {
n, addr, err := s.conn.ReadFromUDP(buffer)
if err != nil {
continue
}
if n < 1 {
continue
}
msgType := buffer[0]
switch msgType {
case MSG_LOGIN:
s.handleLogin(addr)
case MSG_MOVE:
s.handleMove(buffer[:n], addr)
case MSG_CHANGE_COLOR:
s.handleColorChange(buffer[:n], addr)
}
}
}
func main() {
// Generate and save heightmap
fmt.Println("Generating heightmap...")
heightmap := generateHeightmap(WORLD_SIZE)
saveHeightmapPNG(heightmap, "../assets/heightmap.png")
saveHeightmapBinary(heightmap, "../assets/heightmap.bin")
// Start UDP server
addr, _ := net.ResolveUDPAddr("udp", ":9999")
conn, err := net.ListenUDP("udp", addr)
if err != nil {
panic(err)
}
defer conn.Close()
server := &GameServer{
conn: conn,
players: make(map[uint32]*Player),
heightmap: heightmap,
nextID: 0,
}
server.loadPlayerPositions()
fmt.Println("Server running on :9999")
server.run()
}

1
server/players.json Normal file
View File

@ -0,0 +1 @@
{"1":{"X":-2.5774379,"Y":0.3485479,"Z":3.2305741},"2":{"X":-1.6390398,"Y":0.5682664,"Z":1.0276936}}