commit cc6c92a005300e7c0e842f7a5451e2c971630583 Author: Sky Johnson Date: Mon Sep 8 12:18:01 2025 -0500 initial commit diff --git a/assets/heightmap.bin b/assets/heightmap.bin new file mode 100644 index 0000000..e77e467 Binary files /dev/null and b/assets/heightmap.bin differ diff --git a/assets/heightmap.png b/assets/heightmap.png new file mode 100644 index 0000000..8881242 Binary files /dev/null and b/assets/heightmap.png differ diff --git a/assets/textures/black.png b/assets/textures/black.png new file mode 100644 index 0000000..adf5e6f Binary files /dev/null and b/assets/textures/black.png differ diff --git a/assets/textures/green.png b/assets/textures/green.png new file mode 100644 index 0000000..386293d Binary files /dev/null and b/assets/textures/green.png differ diff --git a/assets/textures/orange.png b/assets/textures/orange.png new file mode 100644 index 0000000..5a500d9 Binary files /dev/null and b/assets/textures/orange.png differ diff --git a/assets/textures/purple.png b/assets/textures/purple.png new file mode 100644 index 0000000..470cc5c Binary files /dev/null and b/assets/textures/purple.png differ diff --git a/assets/textures/red.png b/assets/textures/red.png new file mode 100644 index 0000000..07cfc41 Binary files /dev/null and b/assets/textures/red.png differ diff --git a/assets/textures/white.png b/assets/textures/white.png new file mode 100644 index 0000000..195ad77 Binary files /dev/null and b/assets/textures/white.png differ 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