initial commit
BIN
assets/heightmap.bin
Normal file
BIN
assets/heightmap.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
assets/textures/black.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
assets/textures/green.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
assets/textures/orange.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
assets/textures/purple.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
assets/textures/red.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
assets/textures/white.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
25
client/CMakeLists.txt
Normal 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
@ -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
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
client/PlayerController.hpp
Normal 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
@ -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;
|
||||||
|
}
|
||||||
243
client/net/NetworkManager.cpp
Normal 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;
|
||||||
|
}
|
||||||
78
client/net/NetworkManager.hpp
Normal 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
@ -0,0 +1,3 @@
|
|||||||
|
module server
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
509
server/main.go
Normal 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
@ -0,0 +1 @@
|
|||||||
|
{"1":{"X":-2.5774379,"Y":0.3485479,"Z":3.2305741},"2":{"X":-1.6390398,"Y":0.5682664,"Z":1.0276936}}
|
||||||