From cc6c92a005300e7c0e842f7a5451e2c971630583 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Mon, 8 Sep 2025 12:18:01 -0500 Subject: [PATCH] initial commit --- assets/heightmap.bin | Bin 0 -> 40004 bytes assets/heightmap.png | Bin 0 -> 4639 bytes assets/textures/black.png | Bin 0 -> 2743 bytes assets/textures/green.png | Bin 0 -> 2743 bytes assets/textures/orange.png | Bin 0 -> 2743 bytes assets/textures/purple.png | Bin 0 -> 2743 bytes assets/textures/red.png | Bin 0 -> 2743 bytes assets/textures/white.png | Bin 0 -> 2743 bytes client/CMakeLists.txt | 25 ++ client/Makefile | 23 ++ client/PlayerController.cpp | 131 +++++++++ client/PlayerController.hpp | 37 +++ client/main.cpp | 291 +++++++++++++++++++ client/net/NetworkManager.cpp | 243 ++++++++++++++++ client/net/NetworkManager.hpp | 78 ++++++ server/go.mod | 3 + server/main.go | 509 ++++++++++++++++++++++++++++++++++ server/players.json | 1 + 18 files changed, 1341 insertions(+) create mode 100644 assets/heightmap.bin create mode 100644 assets/heightmap.png create mode 100644 assets/textures/black.png create mode 100644 assets/textures/green.png create mode 100644 assets/textures/orange.png create mode 100644 assets/textures/purple.png create mode 100644 assets/textures/red.png create mode 100644 assets/textures/white.png create mode 100644 client/CMakeLists.txt create mode 100644 client/Makefile create mode 100644 client/PlayerController.cpp create mode 100644 client/PlayerController.hpp create mode 100644 client/main.cpp create mode 100644 client/net/NetworkManager.cpp create mode 100644 client/net/NetworkManager.hpp create mode 100644 server/go.mod create mode 100644 server/main.go create mode 100644 server/players.json diff --git a/assets/heightmap.bin b/assets/heightmap.bin new file mode 100644 index 0000000000000000000000000000000000000000..e77e467caa4d71be1eb54050b098e14628ffe715 GIT binary patch literal 40004 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 literal 0 HcmV?d00001 diff --git a/assets/heightmap.png b/assets/heightmap.png new file mode 100644 index 0000000000000000000000000000000000000000..8881242325a0f21678a073c09d08c81d0006ce1f GIT binary patch literal 4639 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 V$~)z2?Y!#DRy!G1#DWu>jY@{+A9v z1fzr13|@uWZmZM#bxL>%b4B`hp7(#t_G|B!2bwtwNDYAm>4w{km;b(cQ%9=KQT4D8 zXs~6-WB2*Kd(&@5n9wLqY6yH|*dc3hcm26{?4;@(RSyh-8*B^G!w+Zwj(^9@(7^6< zkDa06_WT(y>%zF!Z?8G>9&zAdaSS$ST`a)$u>YmQ z55eeQHG@~7w%h9Tew`9t!d#K|o$vk6vi;h-<$-370#ZXDLAv2KUK z++$~GxIKTy%ewF~`_b?rBLohZF>GgE_WSD1zhr10)dC5DYKA*}Gv1Y_eg`!JMgdtN x@P@I#I$>A*+`If_X&>pDHt;rd+c6w^&mXWhQtz0Tm>noUJYD@<);T3K0RZ&XVvYa+ literal 0 HcmV?d00001 diff --git a/assets/textures/orange.png b/assets/textures/orange.png new file mode 100644 index 0000000000000000000000000000000000000000..5a500d92ee0cbabbe3a51971eb696f207bc44ba3 GIT binary patch literal 2743 zcmeAS@N?(olHy`uVBq!ia0y~yU;#2&7+9ErRIjnw1`sdZ(btiIVPik{pF~z5pFhAS z#P!v5EA5aHZu&0Y-NX4zU*Bp6|IPkDI1{<_47T|i=|I*=y zV05sW!K+Z)ZFPFTP6;nzu1NdN_x@+ue(l}zKr=@HsUeUc-Ef=n^50i)>PXc&svZ^s z4Ymw<>^|RjZ~Dy$6B?yS4S{bAJ7f*+u0QvVom8Ep>VY9}gKa^2_~Go|@$Z-!8rXgA zu`@K>otX?}hy5=d zeh5Yfs~NlswcS>y_v@7K66T7u?|konmhIQxEe|wv6p$JM3DOO>8882R^`?$gouleu zA<$sUkjL)xefOr{j4+{5n$!^Z#;`-y;O_c!@7PJzIjSBQ0yo$eq=z5Q{vH30nW2H* z=N>yl!|nMqUe<+|*^h<~86j}MjA1+Tvfo#4{v|{6s1`^FR5RS+oAItZ^*g8;Fbc>D xfj5i=)(N}f=icQfOZ!OIw1Kyw+m7MTd;Wm6k$T6x#Oy!;;_2$=vd$@?2>{SvWGesw literal 0 HcmV?d00001 diff --git a/assets/textures/red.png b/assets/textures/red.png new file mode 100644 index 0000000000000000000000000000000000000000..07cfc4120c3a00e5b7b6c55d700ca5fc4cdbdf82 GIT binary patch literal 2743 zcmeAS@N?(olHy`uVBq!ia0y~yU;#2&7+9ErRIjnw1`sdZ(btiIVPik{pF~z5pFhAS z#PyYN^nc^{|Ns9#Wia~BV6pU|p&0`MSFopxV@SoVx7Qqbk2vtKI0hTEE*9W=*#FYu zhhTKDn!&43+ii7vzfK7+VXjE~&iDRj*?#Ta@<2030jVL7Al-1A@$%nSZ|X?ZIjSBO z0u8nddF(#lcW?U52ooBmNezK-3_D~E?yf)gj-6DUqw0YnaD#0@dide&-|_F585-Dq z?y)m8+@3$zopr05C+_ zeeSU{G~Av)<7HiVnf++^kP!k0%ow&aFZ+G<=3g>2k7|L0KsCc1z8UYzQ@?|n0i%Gd x5O~8_V4bile(qgv_Q!7>#TAfB#%F6*2UngG%-W^Mog literal 0 HcmV?d00001 diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt new file mode 100644 index 0000000..d85ee83 --- /dev/null +++ b/client/CMakeLists.txt @@ -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() diff --git a/client/Makefile b/client/Makefile new file mode 100644 index 0000000..f34df73 --- /dev/null +++ b/client/Makefile @@ -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 \ No newline at end of file diff --git a/client/PlayerController.cpp b/client/PlayerController.cpp new file mode 100644 index 0000000..5cf205d --- /dev/null +++ b/client/PlayerController.cpp @@ -0,0 +1,131 @@ +#include "PlayerController.hpp" +#include + +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); + } +} \ No newline at end of file diff --git a/client/PlayerController.hpp b/client/PlayerController.hpp new file mode 100644 index 0000000..86719df --- /dev/null +++ b/client/PlayerController.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +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(); +}; diff --git a/client/main.cpp b/client/main.cpp new file mode 100644 index 0000000..2baac00 --- /dev/null +++ b/client/main.cpp @@ -0,0 +1,291 @@ +#include +#include +#include +#include +#include +#include +#include +#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 data; + int size{0}; + + float getHeight(float x, float z) const { + auto hx = static_cast((x / WORLD_SIZE + 0.5f) * (size - 1)); + auto hz = static_cast((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(&fileSize), sizeof(fileSize)); + size = fileSize; + + data.resize(size * size); + file.read(reinterpret_cast(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(x) / size; + mesh.texcoords[idx * 2 + 1] = static_cast(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 playerTextures; + std::unordered_map 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; +} diff --git a/client/net/NetworkManager.cpp b/client/net/NetworkManager.cpp new file mode 100644 index 0000000..02e66b7 --- /dev/null +++ b/client/net/NetworkManager.cpp @@ -0,0 +1,243 @@ +#include "NetworkManager.hpp" +#include +#include + +const std::vector 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(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(&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(&data[18]), colorLen); + } + + if (id != playerID) { + std::lock_guard lock(remotePlayersMutex); + remotePlayers[id] = {id, {x, y, z}, color, static_cast(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(&data[offset + 17]), colorLen); + } + + offset += 17 + colorLen; + + if (id != playerID) { + remotePlayers[id] = {id, {x, y, z}, color, static_cast(GetTime())}; + } + } + + std::cout << "Received list of " << (int)count << " players\n"; +} + +void NetworkManager::sendLogin() { + std::array msg{static_cast(MessageType::Login)}; + socket.send_to(buffer(msg), serverEndpoint); +} + +void NetworkManager::sendMove(float dx, float dy, float dz) { + if (!connected) return; + + std::array msg{}; + msg[0] = static_cast(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 msg(6 + newColor.size()); + msg[0] = static_cast(MessageType::ChangeColor); + + uint32_t id = playerID; + std::memcpy(&msg[1], &id, sizeof(id)); + msg[5] = static_cast(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(&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 NetworkManager::getRemotePlayers() { + std::lock_guard lock(remotePlayersMutex); + return remotePlayers; +} \ No newline at end of file diff --git a/client/net/NetworkManager.hpp b/client/net/NetworkManager.hpp new file mode 100644 index 0000000..b1fe6e2 --- /dev/null +++ b/client/net/NetworkManager.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +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 getRemotePlayers(); + + // Available colors for cycling + static const std::vector AVAILABLE_COLORS; + +private: + io_context ioContext; + udp::socket socket{ioContext}; + udp::endpoint serverEndpoint; + std::thread ioThread; + std::array recvBuffer; + + std::atomic playerID{0}; + std::string playerColor{"red"}; + std::mutex positionMutex; + Vector3 serverPosition{0, 0, 0}; + std::atomic connected{false}; + + std::mutex remotePlayersMutex; + std::unordered_map 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); +}; \ No newline at end of file diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..c2f0746 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,3 @@ +module server + +go 1.25.0 diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..0af165d --- /dev/null +++ b/server/main.go @@ -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() +} diff --git a/server/players.json b/server/players.json new file mode 100644 index 0000000..a7c4f67 --- /dev/null +++ b/server/players.json @@ -0,0 +1 @@ +{"1":{"X":-2.5774379,"Y":0.3485479,"Z":3.2305741},"2":{"X":-1.6390398,"Y":0.5682664,"Z":1.0276936}} \ No newline at end of file