1
0

Compare commits

...

2 Commits

Author SHA1 Message Date
151ac6ab8e refactor server for split 2025-09-09 23:00:09 -05:00
dd682b8b67 return movement authority to server 2025-09-09 22:38:23 -05:00
18 changed files with 1443 additions and 210 deletions

BIN
assets/heightmap_large.bin Normal file

Binary file not shown.

BIN
assets/heightmap_small.bin Normal file

Binary file not shown.

64
client/config.hpp Normal file
View File

@ -0,0 +1,64 @@
#pragma once
#include <iostream>
#include <string>
struct GameConfig {
// Terrain configuration
std::string heightmapFile = "../assets/heightmap.bin";
float unitsPerSample = 1.0f; // Meters per heightmap sample
float heightScale = 1.0f; // Height multiplier
// Window configuration
int windowWidth = 1280;
int windowHeight = 720;
std::string windowTitle = "Game";
int targetFPS = 60;
// Movement
float moveSpeed = 15.0f;
// Network
float heartbeatInterval = 5.0f;
// Debug options
bool showDebugInfo = false;
// Load configuration from command line args or config file
static GameConfig fromArgs(int argc, char* argv[]) {
GameConfig config;
// Parse command line arguments
for (int i = 1; i < argc; i++) {
std::string arg = argv[i];
if (arg == "--heightmap" && i + 1 < argc) {
config.heightmapFile = argv[++i];
} else if (arg == "--scale" && i + 1 < argc) {
config.unitsPerSample = std::stof(argv[++i]);
} else if (arg == "--height-scale" && i + 1 < argc) {
config.heightScale = std::stof(argv[++i]);
} else if (arg == "--debug") {
config.showDebugInfo = true;
} else if (arg == "--help") {
printHelp();
exit(0);
}
}
return config;
}
private:
static void printHelp() {
std::cout << "Game Options:\n";
std::cout << " --heightmap <file> Load specific heightmap file\n";
std::cout << " --scale <value> Units per heightmap sample (default: 1.0)\n";
std::cout << " --height-scale <val> Height multiplier (default: 1.0)\n";
std::cout << " --debug Show debug information\n";
std::cout << " --help Show this help\n";
std::cout << "\nExamples:\n";
std::cout << " ./game --heightmap ../assets/heightmap_small.bin\n";
std::cout << " ./game --heightmap ../assets/heightmap_large.bin --scale 2.0\n";
}
};

View File

@ -0,0 +1,206 @@
#pragma once
#include <raylib.h>
#include <raymath.h>
#include <vector>
#include <fstream>
#include <cstdint>
class Heightmap {
public:
struct Config {
float unitsPerSample = 1.0f; // How many world units (meters) per heightmap sample
float heightScale = 1.0f; // Scale factor for height values
};
private:
std::vector<float> data;
int samplesPerSide{0}; // Number of samples along one edge
float worldWidth{0.0f}; // Total world width in units
float worldHeight{0.0f}; // Total world height in units
Config config;
public:
Heightmap() : config{} {}
Heightmap(const Config& cfg) : config(cfg) {}
// Load heightmap from binary file
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));
samplesPerSide = fileSize;
data.resize(samplesPerSide * samplesPerSide);
file.read(reinterpret_cast<char*>(data.data()), data.size() * sizeof(float));
// Calculate world dimensions based on samples and units per sample
worldWidth = worldHeight = (samplesPerSide - 1) * config.unitsPerSample;
return true;
}
// Get interpolated height at world position
float getHeightAtPosition(float worldX, float worldZ) const {
// Convert world coordinates to sample coordinates
float sampleX = (worldX + worldWidth * 0.5f) / config.unitsPerSample;
float sampleZ = (worldZ + worldHeight * 0.5f) / config.unitsPerSample;
// Clamp to valid range
sampleX = std::max(0.0f, std::min(sampleX, float(samplesPerSide - 1)));
sampleZ = std::max(0.0f, std::min(sampleZ, float(samplesPerSide - 1)));
// Get integer sample indices
int x0 = static_cast<int>(sampleX);
int z0 = static_cast<int>(sampleZ);
int x1 = std::min(x0 + 1, samplesPerSide - 1);
int z1 = std::min(z0 + 1, samplesPerSide - 1);
// Get fractional parts for interpolation
float fx = sampleX - x0;
float fz = sampleZ - z0;
// Get heights at four corners
float h00 = data[z0 * samplesPerSide + x0] * config.heightScale;
float h10 = data[z0 * samplesPerSide + x1] * config.heightScale;
float h01 = data[z1 * samplesPerSide + x0] * config.heightScale;
float h11 = data[z1 * samplesPerSide + x1] * config.heightScale;
// Bilinear interpolation
float h0 = h00 * (1.0f - fx) + h10 * fx;
float h1 = h01 * (1.0f - fx) + h11 * fx;
return h0 * (1.0f - fz) + h1 * fz;
}
// Generate mesh from heightmap
Mesh generateMesh() const {
auto mesh = Mesh{};
int vertexCount = samplesPerSide * samplesPerSide;
int triangleCount = (samplesPerSide - 1) * (samplesPerSide - 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 < samplesPerSide; z++) {
for (int x = 0; x < samplesPerSide; x++) {
int idx = z * samplesPerSide + x;
// World position in units (meters)
// Center the terrain around origin
float worldX = (x * config.unitsPerSample) - worldWidth * 0.5f;
float worldZ = (z * config.unitsPerSample) - worldHeight * 0.5f;
float worldY = data[idx] * config.heightScale;
mesh.vertices[idx * 3] = worldX;
mesh.vertices[idx * 3 + 1] = worldY;
mesh.vertices[idx * 3 + 2] = worldZ;
// Texture coordinates: 1 unit = 1 texture tile
// Map from world coordinates to texture coordinates
float texU = worldX + worldWidth * 0.5f;
float texV = worldZ + worldHeight * 0.5f;
mesh.texcoords[idx * 2] = texU;
mesh.texcoords[idx * 2 + 1] = texV;
}
}
// Generate indices
int triIdx = 0;
for (int z = 0; z < samplesPerSide - 1; z++) {
for (int x = 0; x < samplesPerSide - 1; x++) {
int topLeft = z * samplesPerSide + x;
int topRight = topLeft + 1;
int bottomLeft = (z + 1) * samplesPerSide + 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 proper normals
calculateNormals(mesh);
UploadMesh(&mesh, false);
return mesh;
}
// Getters
float getWorldWidth() const { return worldWidth; }
float getWorldHeight() const { return worldHeight; }
Vector3 getWorldBounds() const {
return {worldWidth, getMaxHeight() * config.heightScale, worldHeight};
}
Vector3 getWorldCenter() const { return {0, 0, 0}; }
int getSamplesPerSide() const { return samplesPerSide; }
private:
void calculateNormals(Mesh& mesh) const {
// Initialize normals to zero
for (int i = 0; i < mesh.vertexCount * 3; i++) {
mesh.normals[i] = 0.0f;
}
// Calculate face normals and add to vertex normals
for (int i = 0; i < mesh.triangleCount; i++) {
unsigned short i1 = mesh.indices[i * 3];
unsigned short i2 = mesh.indices[i * 3 + 1];
unsigned short i3 = mesh.indices[i * 3 + 2];
Vector3 v1 = {mesh.vertices[i1 * 3], mesh.vertices[i1 * 3 + 1], mesh.vertices[i1 * 3 + 2]};
Vector3 v2 = {mesh.vertices[i2 * 3], mesh.vertices[i2 * 3 + 1], mesh.vertices[i2 * 3 + 2]};
Vector3 v3 = {mesh.vertices[i3 * 3], mesh.vertices[i3 * 3 + 1], mesh.vertices[i3 * 3 + 2]};
Vector3 edge1 = Vector3Subtract(v2, v1);
Vector3 edge2 = Vector3Subtract(v3, v1);
Vector3 normal = Vector3Normalize(Vector3CrossProduct(edge1, edge2));
// Add face normal to each vertex
mesh.normals[i1 * 3] += normal.x;
mesh.normals[i1 * 3 + 1] += normal.y;
mesh.normals[i1 * 3 + 2] += normal.z;
mesh.normals[i2 * 3] += normal.x;
mesh.normals[i2 * 3 + 1] += normal.y;
mesh.normals[i2 * 3 + 2] += normal.z;
mesh.normals[i3 * 3] += normal.x;
mesh.normals[i3 * 3 + 1] += normal.y;
mesh.normals[i3 * 3 + 2] += normal.z;
}
// Normalize vertex normals
for (int i = 0; i < mesh.vertexCount; i++) {
Vector3 normal = {
mesh.normals[i * 3],
mesh.normals[i * 3 + 1],
mesh.normals[i * 3 + 2]
};
normal = Vector3Normalize(normal);
mesh.normals[i * 3] = normal.x;
mesh.normals[i * 3 + 1] = normal.y;
mesh.normals[i * 3 + 2] = normal.z;
}
}
float getMaxHeight() const {
float maxHeight = 0.0f;
for (float h : data) {
maxHeight = std::max(maxHeight, h);
}
return maxHeight;
}
};

234
server/cmd/login/main.go Normal file
View File

@ -0,0 +1,234 @@
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"log"
"net"
"server/internal/db"
"time"
"golang.org/x/crypto/bcrypt"
)
type LoginServer struct {
listener net.Listener
database *db.Database
worldURL string
serverID string
}
type LoginRequest struct {
Type string `json:"type"`
Username string `json:"username"`
Password string `json:"password"`
}
type LoginResponse struct {
Type string `json:"type"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Token string `json:"token,omitempty"`
WorldURL string `json:"worldUrl,omitempty"`
PlayerID int64 `json:"playerId,omitempty"`
}
type RegisterRequest struct {
Type string `json:"type"`
Username string `json:"username"`
Password string `json:"password"`
}
type RegisterResponse struct {
Type string `json:"type"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
func main() {
var (
port = flag.String("port", "8081", "Login server port")
dbDSN = flag.String("db", "user:password@tcp(localhost:3306)/game", "Database DSN")
worldURL = flag.String("world", "localhost:8082", "World server URL")
)
flag.Parse()
database, err := db.New(*dbDSN)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer database.Close()
server := &LoginServer{
database: database,
worldURL: *worldURL,
serverID: generateServerID(),
}
listener, err := net.Listen("tcp", ":"+*port)
if err != nil {
log.Fatalf("Failed to start login server: %v", err)
}
server.listener = listener
log.Printf("Login server started on port %s (ID: %s)", *port, server.serverID)
go server.cleanupSessions()
for {
conn, err := listener.Accept()
if err != nil {
log.Printf("Accept error: %v", err)
continue
}
go server.handleConnection(conn)
}
}
func (s *LoginServer) handleConnection(conn net.Conn) {
defer conn.Close()
decoder := json.NewDecoder(conn)
encoder := json.NewEncoder(conn)
for {
var msg json.RawMessage
if err := decoder.Decode(&msg); err != nil {
return
}
var baseMsg struct {
Type string `json:"type"`
}
if err := json.Unmarshal(msg, &baseMsg); err != nil {
continue
}
switch baseMsg.Type {
case "login":
var req LoginRequest
if err := json.Unmarshal(msg, &req); err != nil {
continue
}
s.handleLogin(req, encoder)
case "register":
var req RegisterRequest
if err := json.Unmarshal(msg, &req); err != nil {
continue
}
s.handleRegister(req, encoder)
}
}
}
func (s *LoginServer) handleLogin(req LoginRequest, encoder *json.Encoder) {
player, err := s.database.GetPlayerByUsername(req.Username)
if err != nil {
encoder.Encode(LoginResponse{
Type: "loginResponse",
Success: false,
Message: "Invalid username or password",
})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(player.PasswordHash), []byte(req.Password)); err != nil {
encoder.Encode(LoginResponse{
Type: "loginResponse",
Success: false,
Message: "Invalid username or password",
})
return
}
token := generateToken()
if err := s.database.CreateSession(player.ID, token, "world", s.serverID, 24*time.Hour); err != nil {
encoder.Encode(LoginResponse{
Type: "loginResponse",
Success: false,
Message: "Failed to create session",
})
return
}
s.database.UpdateLastLogin(player.ID)
encoder.Encode(LoginResponse{
Type: "loginResponse",
Success: true,
Token: token,
WorldURL: s.worldURL,
PlayerID: player.ID,
})
}
func (s *LoginServer) handleRegister(req RegisterRequest, encoder *json.Encoder) {
if len(req.Username) < 3 || len(req.Username) > 20 {
encoder.Encode(RegisterResponse{
Type: "registerResponse",
Success: false,
Message: "Username must be between 3 and 20 characters",
})
return
}
if len(req.Password) < 6 {
encoder.Encode(RegisterResponse{
Type: "registerResponse",
Success: false,
Message: "Password must be at least 6 characters",
})
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
encoder.Encode(RegisterResponse{
Type: "registerResponse",
Success: false,
Message: "Failed to process password",
})
return
}
_, err = s.database.CreatePlayer(req.Username, string(hashedPassword))
if err != nil {
encoder.Encode(RegisterResponse{
Type: "registerResponse",
Success: false,
Message: "Username already exists",
})
return
}
encoder.Encode(RegisterResponse{
Type: "registerResponse",
Success: true,
Message: "Registration successful",
})
}
func (s *LoginServer) cleanupSessions() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
if err := s.database.CleanExpiredSessions(); err != nil {
log.Printf("Failed to clean expired sessions: %v", err)
}
}
}
func generateToken() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
func generateServerID() string {
return fmt.Sprintf("login-%d", time.Now().Unix())
}

398
server/cmd/world/main.go Normal file
View File

@ -0,0 +1,398 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"math"
"net"
"server/internal/db"
"server/internal/net/packets"
"sync"
"time"
)
type WorldServer struct {
listener net.Listener
database *db.Database
serverID string
clients map[int]*Client
clientLock sync.RWMutex
world *packets.WorldConfig
heightmap [][]float32
ticker *time.Ticker
timeOfDay float32
}
type Client struct {
ID int
PlayerID int64
Username string
Conn net.Conn
Encoder *json.Encoder
Decoder *json.Decoder
Position packets.Vec3
Rotation packets.Vec2
Velocity packets.Vec3
LastUpdate time.Time
}
type AuthRequest struct {
Type string `json:"type"`
Token string `json:"token"`
}
type AuthResponse struct {
Type string `json:"type"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
func main() {
var (
port = flag.String("port", "8082", "World server port")
dbDSN = flag.String("db", "user:password@tcp(localhost:3306)/game", "Database DSN")
)
flag.Parse()
database, err := db.New(*dbDSN)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer database.Close()
heightmap := generateDefaultHeightmap(257)
worldConfig := packets.NewWorldConfig(heightmap, 1.0)
server := &WorldServer{
database: database,
serverID: generateServerID(),
clients: make(map[int]*Client),
world: worldConfig,
heightmap: heightmap,
timeOfDay: 12.0,
}
listener, err := net.Listen("tcp", ":"+*port)
if err != nil {
log.Fatalf("Failed to start world server: %v", err)
}
server.listener = listener
log.Printf("World server started on port %s (ID: %s)", *port, server.serverID)
log.Printf("World bounds: Min(%v), Max(%v)", server.world.MinBounds, server.world.MaxBounds)
go server.gameLoop()
go server.updateTimeOfDay()
for {
conn, err := listener.Accept()
if err != nil {
log.Printf("Accept error: %v", err)
continue
}
go server.handleConnection(conn)
}
}
func (s *WorldServer) handleConnection(conn net.Conn) {
defer conn.Close()
decoder := json.NewDecoder(conn)
encoder := json.NewEncoder(conn)
var authReq AuthRequest
if err := decoder.Decode(&authReq); err != nil || authReq.Type != "auth" {
encoder.Encode(AuthResponse{
Type: "authResponse",
Success: false,
Message: "Authentication required",
})
return
}
session, err := s.database.ValidateSession(authReq.Token)
if err != nil {
encoder.Encode(AuthResponse{
Type: "authResponse",
Success: false,
Message: "Invalid or expired session",
})
return
}
player, err := s.database.GetPlayerByID(session.PlayerID)
if err != nil {
encoder.Encode(AuthResponse{
Type: "authResponse",
Success: false,
Message: "Player not found",
})
return
}
encoder.Encode(AuthResponse{
Type: "authResponse",
Success: true,
})
position, _ := s.database.GetPlayerPosition(player.ID)
client := &Client{
ID: s.getNextClientID(),
PlayerID: player.ID,
Username: player.Username,
Conn: conn,
Encoder: encoder,
Decoder: decoder,
Position: packets.Vec3{X: position.X, Y: position.Y, Z: position.Z},
Rotation: packets.Vec2{X: position.Yaw, Y: position.Pitch},
LastUpdate: time.Now(),
}
s.addClient(client)
defer s.removeClient(client)
s.sendInitialState(client)
s.broadcastPlayerJoined(client)
for {
var msg json.RawMessage
if err := decoder.Decode(&msg); err != nil {
break
}
var baseMsg struct {
Type string `json:"type"`
}
if err := json.Unmarshal(msg, &baseMsg); err != nil {
continue
}
switch baseMsg.Type {
case "movement":
var moveMsg packets.MovementMessage
if err := json.Unmarshal(msg, &moveMsg); err != nil {
continue
}
s.handleMovement(client, moveMsg)
case "chat":
var chatMsg packets.ChatMessage
if err := json.Unmarshal(msg, &chatMsg); err != nil {
continue
}
s.handleChat(client, chatMsg)
}
}
s.database.SavePlayerPosition(client.PlayerID, &db.Position{
X: client.Position.X,
Y: client.Position.Y,
Z: client.Position.Z,
Yaw: client.Rotation.X,
Pitch: client.Rotation.Y,
World: "main",
})
}
func (s *WorldServer) handleMovement(client *Client, msg packets.MovementMessage) {
newPos := packets.Vec3{X: msg.X, Y: msg.Y, Z: msg.Z}
if !s.world.IsInBounds(newPos) {
newPos = s.world.ClampPosition(newPos)
client.Encoder.Encode(packets.PositionCorrectionMessage{
Type: "positionCorrection",
X: newPos.X,
Y: newPos.Y,
Z: newPos.Z,
})
}
terrainHeight := s.world.GetHeightAt(s.heightmap, newPos.X, newPos.Z)
if newPos.Y < terrainHeight {
newPos.Y = terrainHeight
client.Encoder.Encode(packets.PositionCorrectionMessage{
Type: "positionCorrection",
X: newPos.X,
Y: newPos.Y,
Z: newPos.Z,
})
}
client.Position = newPos
client.Rotation = packets.Vec2{X: msg.Yaw, Y: msg.Pitch}
client.Velocity = packets.Vec3{X: msg.VelX, Y: msg.VelY, Z: msg.VelZ}
client.LastUpdate = time.Now()
s.broadcastMovement(client)
}
func (s *WorldServer) handleChat(client *Client, msg packets.ChatMessage) {
fullMsg := packets.ChatMessage{
Type: "chat",
Username: client.Username,
Message: msg.Message,
}
s.broadcast(fullMsg)
}
func (s *WorldServer) sendInitialState(client *Client) {
client.Encoder.Encode(packets.InitMessage{
Type: "init",
PlayerID: client.ID,
X: client.Position.X,
Y: client.Position.Y,
Z: client.Position.Z,
Yaw: client.Rotation.X,
Pitch: client.Rotation.Y,
TimeOfDay: s.timeOfDay,
})
s.clientLock.RLock()
for _, other := range s.clients {
if other.ID != client.ID {
client.Encoder.Encode(packets.PlayerJoinedMessage{
Type: "playerJoined",
PlayerID: other.ID,
Username: other.Username,
X: other.Position.X,
Y: other.Position.Y,
Z: other.Position.Z,
})
}
}
s.clientLock.RUnlock()
}
func (s *WorldServer) broadcastPlayerJoined(client *Client) {
msg := packets.PlayerJoinedMessage{
Type: "playerJoined",
PlayerID: client.ID,
Username: client.Username,
X: client.Position.X,
Y: client.Position.Y,
Z: client.Position.Z,
}
s.broadcastExcept(msg, client.ID)
}
func (s *WorldServer) broadcastMovement(client *Client) {
msg := packets.PlayerMovementMessage{
Type: "playerMovement",
PlayerID: client.ID,
X: client.Position.X,
Y: client.Position.Y,
Z: client.Position.Z,
Yaw: client.Rotation.X,
Pitch: client.Rotation.Y,
VelX: client.Velocity.X,
VelY: client.Velocity.Y,
VelZ: client.Velocity.Z,
}
s.broadcastExcept(msg, client.ID)
}
func (s *WorldServer) broadcast(msg interface{}) {
s.clientLock.RLock()
defer s.clientLock.RUnlock()
for _, client := range s.clients {
client.Encoder.Encode(msg)
}
}
func (s *WorldServer) broadcastExcept(msg interface{}, excludeID int) {
s.clientLock.RLock()
defer s.clientLock.RUnlock()
for _, client := range s.clients {
if client.ID != excludeID {
client.Encoder.Encode(msg)
}
}
}
func (s *WorldServer) addClient(client *Client) {
s.clientLock.Lock()
s.clients[client.ID] = client
s.clientLock.Unlock()
log.Printf("Player %s (ID: %d) joined the world", client.Username, client.ID)
}
func (s *WorldServer) removeClient(client *Client) {
s.clientLock.Lock()
delete(s.clients, client.ID)
s.clientLock.Unlock()
s.broadcast(packets.PlayerLeftMessage{
Type: "playerLeft",
PlayerID: client.ID,
})
log.Printf("Player %s (ID: %d) left the world", client.Username, client.ID)
}
func (s *WorldServer) gameLoop() {
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
s.clientLock.RLock()
for _, client := range s.clients {
if time.Since(client.LastUpdate) > 30*time.Second {
go func(c *Client) {
c.Conn.Close()
}(client)
}
}
s.clientLock.RUnlock()
}
}
func (s *WorldServer) updateTimeOfDay() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
s.timeOfDay += 0.1
if s.timeOfDay >= 24.0 {
s.timeOfDay -= 24.0
}
s.broadcast(packets.TimeUpdateMessage{
Type: "timeUpdate",
TimeOfDay: s.timeOfDay,
})
}
}
func (s *WorldServer) getNextClientID() int {
s.clientLock.Lock()
defer s.clientLock.Unlock()
id := 1
for {
if _, exists := s.clients[id]; !exists {
return id
}
id++
}
}
func generateDefaultHeightmap(size int) [][]float32 {
heightmap := make([][]float32, size)
for i := range heightmap {
heightmap[i] = make([]float32, size)
for j := range heightmap[i] {
x := float64(j-size/2) / float64(size) * 10
z := float64(i-size/2) / float64(size) * 10
heightmap[i][j] = float32(10 * (math.Sin(x) + math.Cos(z)))
}
}
return heightmap
}
func generateServerID() string {
return fmt.Sprintf("world-%d", time.Now().Unix())
}

View File

@ -1,3 +1,10 @@
module server module server
go 1.25.0 go 1.25.0
require (
github.com/go-sql-driver/mysql v1.9.3
golang.org/x/crypto v0.42.0
)
require filippo.io/edwards25519 v1.1.0 // indirect

6
server/go.sum Normal file
View File

@ -0,0 +1,6 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=

183
server/internal/db/db.go Normal file
View File

@ -0,0 +1,183 @@
package db
import (
"database/sql"
"fmt"
"time"
_ "github.com/go-sql-driver/mysql"
)
type Database struct {
conn *sql.DB
}
func New(dsn string) (*Database, error) {
conn, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
conn.SetMaxOpenConns(25)
conn.SetMaxIdleConns(5)
conn.SetConnMaxLifetime(5 * time.Minute)
if err := conn.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
db := &Database{conn: conn}
if err := db.createTables(); err != nil {
return nil, fmt.Errorf("failed to create tables: %w", err)
}
return db, nil
}
func (db *Database) createTables() error {
queries := []string{
`CREATE TABLE IF NOT EXISTS players (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP NULL,
INDEX idx_username (username)
)`,
`CREATE TABLE IF NOT EXISTS player_positions (
player_id INT PRIMARY KEY,
x FLOAT NOT NULL DEFAULT 0,
y FLOAT NOT NULL DEFAULT 0,
z FLOAT NOT NULL DEFAULT 0,
yaw FLOAT NOT NULL DEFAULT 0,
pitch FLOAT NOT NULL DEFAULT 0,
world VARCHAR(255) DEFAULT 'main',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS player_sessions (
id INT AUTO_INCREMENT PRIMARY KEY,
player_id INT NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL,
server_type VARCHAR(50) NOT NULL,
server_id VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
INDEX idx_token (token),
INDEX idx_player (player_id),
FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE
)`,
}
for _, query := range queries {
if _, err := db.conn.Exec(query); err != nil {
return fmt.Errorf("failed to execute query: %w", err)
}
}
return nil
}
func (db *Database) Close() error {
return db.conn.Close()
}
func (db *Database) CreatePlayer(username, passwordHash string) (int64, error) {
result, err := db.conn.Exec(
"INSERT INTO players (username, password_hash) VALUES (?, ?)",
username, passwordHash,
)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
func (db *Database) GetPlayerByUsername(username string) (*Player, error) {
p := &Player{}
err := db.conn.QueryRow(
"SELECT id, username, password_hash FROM players WHERE username = ?",
username,
).Scan(&p.ID, &p.Username, &p.PasswordHash)
if err != nil {
return nil, err
}
return p, nil
}
func (db *Database) GetPlayerByID(playerID int64) (*Player, error) {
p := &Player{}
err := db.conn.QueryRow(
"SELECT id, username, password_hash FROM players WHERE id = ?",
playerID,
).Scan(&p.ID, &p.Username, &p.PasswordHash)
if err != nil {
return nil, err
}
return p, nil
}
func (db *Database) UpdateLastLogin(playerID int64) error {
_, err := db.conn.Exec(
"UPDATE players SET last_login = CURRENT_TIMESTAMP WHERE id = ?",
playerID,
)
return err
}
func (db *Database) SavePlayerPosition(playerID int64, pos *Position) error {
_, err := db.conn.Exec(`
INSERT INTO player_positions (player_id, x, y, z, yaw, pitch, world)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
x = VALUES(x), y = VALUES(y), z = VALUES(z),
yaw = VALUES(yaw), pitch = VALUES(pitch), world = VALUES(world)
`, playerID, pos.X, pos.Y, pos.Z, pos.Yaw, pos.Pitch, pos.World)
return err
}
func (db *Database) GetPlayerPosition(playerID int64) (*Position, error) {
pos := &Position{}
err := db.conn.QueryRow(
"SELECT x, y, z, yaw, pitch, world FROM player_positions WHERE player_id = ?",
playerID,
).Scan(&pos.X, &pos.Y, &pos.Z, &pos.Yaw, &pos.Pitch, &pos.World)
if err == sql.ErrNoRows {
return &Position{X: 0, Y: 0, Z: 0, Yaw: 0, Pitch: 0, World: "main"}, nil
}
return pos, err
}
func (db *Database) CreateSession(playerID int64, token, serverType, serverID string, duration time.Duration) error {
expiresAt := time.Now().Add(duration)
_, err := db.conn.Exec(
`INSERT INTO player_sessions (player_id, token, server_type, server_id, expires_at)
VALUES (?, ?, ?, ?, ?)`,
playerID, token, serverType, serverID, expiresAt,
)
return err
}
func (db *Database) ValidateSession(token string) (*Session, error) {
s := &Session{}
err := db.conn.QueryRow(
`SELECT id, player_id, token, server_type, server_id, expires_at
FROM player_sessions
WHERE token = ? AND expires_at > NOW()`,
token,
).Scan(&s.ID, &s.PlayerID, &s.Token, &s.ServerType, &s.ServerID, &s.ExpiresAt)
if err != nil {
return nil, err
}
return s, nil
}
func (db *Database) DeleteSession(token string) error {
_, err := db.conn.Exec("DELETE FROM player_sessions WHERE token = ?", token)
return err
}
func (db *Database) CleanExpiredSessions() error {
_, err := db.conn.Exec("DELETE FROM player_sessions WHERE expires_at < NOW()")
return err
}

View File

@ -0,0 +1,30 @@
package db
import "time"
type Player struct {
ID int64
Username string
PasswordHash string
CreatedAt time.Time
LastLogin *time.Time
}
type Position struct {
X float32
Y float32
Z float32
Yaw float32
Pitch float32
World string
}
type Session struct {
ID int64
PlayerID int64
Token string
ServerType string
ServerID string
CreatedAt time.Time
ExpiresAt time.Time
}

View File

@ -0,0 +1,80 @@
package packets
type Vec3 struct {
X float32 `json:"x"`
Y float32 `json:"y"`
Z float32 `json:"z"`
}
type Vec2 struct {
X float32 `json:"x"`
Y float32 `json:"y"`
}
type InitMessage struct {
Type string `json:"type"`
PlayerID int `json:"playerId"`
X float32 `json:"x"`
Y float32 `json:"y"`
Z float32 `json:"z"`
Yaw float32 `json:"yaw"`
Pitch float32 `json:"pitch"`
TimeOfDay float32 `json:"timeOfDay"`
}
type MovementMessage struct {
Type string `json:"type"`
X float32 `json:"x"`
Y float32 `json:"y"`
Z float32 `json:"z"`
Yaw float32 `json:"yaw"`
Pitch float32 `json:"pitch"`
VelX float32 `json:"velX"`
VelY float32 `json:"velY"`
VelZ float32 `json:"velZ"`
}
type PlayerMovementMessage struct {
Type string `json:"type"`
PlayerID int `json:"playerId"`
X float32 `json:"x"`
Y float32 `json:"y"`
Z float32 `json:"z"`
Yaw float32 `json:"yaw"`
Pitch float32 `json:"pitch"`
VelX float32 `json:"velX"`
VelY float32 `json:"velY"`
VelZ float32 `json:"velZ"`
}
type PositionCorrectionMessage struct {
Type string `json:"type"`
X float32 `json:"x"`
Y float32 `json:"y"`
Z float32 `json:"z"`
}
type PlayerJoinedMessage struct {
Type string `json:"type"`
PlayerID int `json:"playerId"`
Username string `json:"username"`
X float32 `json:"x"`
Y float32 `json:"y"`
Z float32 `json:"z"`
}
type PlayerLeftMessage struct {
Type string `json:"type"`
PlayerID int `json:"playerId"`
}
type ChatMessage struct {
Type string `json:"type"`
Username string `json:"username,omitempty"`
Message string `json:"message"`
}
type TimeUpdateMessage struct {
Type string `json:"type"`
TimeOfDay float32 `json:"timeOfDay"`
}

View File

@ -0,0 +1,77 @@
package packets
import "math"
type WorldConfig struct {
SamplesPerSide int
UnitsPerSample float32
WorldWidth float32
WorldHeight float32
MinBounds Vec3
MaxBounds Vec3
MaxTerrainHeight float32
}
func NewWorldConfig(heightmap [][]float32, unitsPerSample float32) *WorldConfig {
samplesPerSide := len(heightmap)
worldSize := float32(samplesPerSide-1) * unitsPerSample
halfSize := worldSize * 0.5
maxHeight := float32(0)
for y := range heightmap {
for x := range heightmap[y] {
if heightmap[y][x] > maxHeight {
maxHeight = heightmap[y][x]
}
}
}
return &WorldConfig{
SamplesPerSide: samplesPerSide,
UnitsPerSample: unitsPerSample,
WorldWidth: worldSize,
WorldHeight: worldSize,
MinBounds: Vec3{X: -halfSize, Y: 0, Z: -halfSize},
MaxBounds: Vec3{X: halfSize, Y: maxHeight + 10, Z: halfSize},
MaxTerrainHeight: maxHeight,
}
}
func (w *WorldConfig) ClampPosition(pos Vec3) Vec3 {
return Vec3{
X: float32(math.Max(float64(w.MinBounds.X), math.Min(float64(w.MaxBounds.X), float64(pos.X)))),
Y: float32(math.Max(float64(w.MinBounds.Y), math.Min(float64(w.MaxBounds.Y), float64(pos.Y)))),
Z: float32(math.Max(float64(w.MinBounds.Z), math.Min(float64(w.MaxBounds.Z), float64(pos.Z)))),
}
}
func (w *WorldConfig) IsInBounds(pos Vec3) bool {
return pos.X >= w.MinBounds.X && pos.X <= w.MaxBounds.X &&
pos.Y >= w.MinBounds.Y && pos.Y <= w.MaxBounds.Y &&
pos.Z >= w.MinBounds.Z && pos.Z <= w.MaxBounds.Z
}
func (w *WorldConfig) GetHeightAt(heightmap [][]float32, worldX, worldZ float32) float32 {
sampleX := (worldX + w.WorldWidth*0.5) / w.UnitsPerSample
sampleZ := (worldZ + w.WorldHeight*0.5) / w.UnitsPerSample
sampleX = float32(math.Max(0, math.Min(float64(w.SamplesPerSide-1), float64(sampleX))))
sampleZ = float32(math.Max(0, math.Min(float64(w.SamplesPerSide-1), float64(sampleZ))))
x0 := int(math.Floor(float64(sampleX)))
z0 := int(math.Floor(float64(sampleZ)))
x1 := int(math.Min(float64(x0+1), float64(w.SamplesPerSide-1)))
z1 := int(math.Min(float64(z0+1), float64(w.SamplesPerSide-1)))
fx := sampleX - float32(x0)
fz := sampleZ - float32(z0)
h00 := heightmap[z0][x0]
h10 := heightmap[z0][x1]
h01 := heightmap[z1][x0]
h11 := heightmap[z1][x1]
h0 := h00*(1-fx) + h10*fx
h1 := h01*(1-fx) + h11*fx
return h0*(1-fz) + h1*fz
}

View File

@ -0,0 +1,89 @@
package net
import "math"
// WorldConfig holds world configuration based on heightmap
type WorldConfig struct {
SamplesPerSide int // Number of heightmap samples per side
UnitsPerSample float32 // World units (meters) per sample
WorldWidth float32 // Total world width in units
WorldHeight float32 // Total world height (depth) in units
MinBounds Vec3 // Minimum world bounds
MaxBounds Vec3 // Maximum world bounds
MaxTerrainHeight float32 // Maximum terrain height
}
// NewWorldConfig creates world configuration from heightmap
func NewWorldConfig(heightmap [][]float32, unitsPerSample float32) *WorldConfig {
samplesPerSide := len(heightmap)
worldSize := float32(samplesPerSide-1) * unitsPerSample
halfSize := worldSize * 0.5
// Find max terrain height
maxHeight := float32(0)
for y := range heightmap {
for x := range heightmap[y] {
if heightmap[y][x] > maxHeight {
maxHeight = heightmap[y][x]
}
}
}
return &WorldConfig{
SamplesPerSide: samplesPerSide,
UnitsPerSample: unitsPerSample,
WorldWidth: worldSize,
WorldHeight: worldSize,
MinBounds: Vec3{X: -halfSize, Y: 0, Z: -halfSize},
MaxBounds: Vec3{X: halfSize, Y: maxHeight + 10, Z: halfSize},
MaxTerrainHeight: maxHeight,
}
}
// ClampPosition clamps a position to world bounds
func (w *WorldConfig) ClampPosition(pos Vec3) Vec3 {
return Vec3{
X: float32(math.Max(float64(w.MinBounds.X), math.Min(float64(w.MaxBounds.X), float64(pos.X)))),
Y: float32(math.Max(float64(w.MinBounds.Y), math.Min(float64(w.MaxBounds.Y), float64(pos.Y)))),
Z: float32(math.Max(float64(w.MinBounds.Z), math.Min(float64(w.MaxBounds.Z), float64(pos.Z)))),
}
}
// IsInBounds checks if a position is within world bounds
func (w *WorldConfig) IsInBounds(pos Vec3) bool {
return pos.X >= w.MinBounds.X && pos.X <= w.MaxBounds.X &&
pos.Y >= w.MinBounds.Y && pos.Y <= w.MaxBounds.Y &&
pos.Z >= w.MinBounds.Z && pos.Z <= w.MaxBounds.Z
}
// GetHeightAt gets interpolated height at world position
func (w *WorldConfig) GetHeightAt(heightmap [][]float32, worldX, worldZ float32) float32 {
// Convert world coordinates to sample coordinates
sampleX := (worldX + w.WorldWidth*0.5) / w.UnitsPerSample
sampleZ := (worldZ + w.WorldHeight*0.5) / w.UnitsPerSample
// Clamp to valid range
sampleX = float32(math.Max(0, math.Min(float64(w.SamplesPerSide-1), float64(sampleX))))
sampleZ = float32(math.Max(0, math.Min(float64(w.SamplesPerSide-1), float64(sampleZ))))
// Get integer sample indices
x0 := int(math.Floor(float64(sampleX)))
z0 := int(math.Floor(float64(sampleZ)))
x1 := int(math.Min(float64(x0+1), float64(w.SamplesPerSide-1)))
z1 := int(math.Min(float64(z0+1), float64(w.SamplesPerSide-1)))
// Get fractional parts for interpolation
fx := sampleX - float32(x0)
fz := sampleZ - float32(z0)
// Get heights at four corners
h00 := heightmap[z0][x0]
h10 := heightmap[z0][x1]
h01 := heightmap[z1][x0]
h11 := heightmap[z1][x1]
// Bilinear interpolation
h0 := h00*(1-fx) + h10*fx
h1 := h01*(1-fx) + h11*fx
return h0*(1-fz) + h1*fz
}

View File

@ -1,210 +0,0 @@
package main
import (
"encoding/binary"
"flag"
"fmt"
"image"
"image/color"
"image/png"
"log"
"math"
"math/rand"
"os"
"server/net"
)
const WorldSize = 100
func generateHeightmap(size int) [][]float32 {
heightmap := make([][]float32, size)
for i := range heightmap {
heightmap[i] = make([]float32, size)
}
// Simple perlin-like noise
for y := range heightmap {
for x := range heightmap[y] {
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 range 3 {
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) error {
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]
}
}
}
// Apply normalized values to image
for y := range heightmap {
for x := range heightmap[y] {
normalized := (heightmap[y][x] - minH) / (maxH - minH)
img.SetGray(x, y, color.Gray{Y: uint8(normalized * 255)})
}
}
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("failed to create file %s: %w", filename, err)
}
defer file.Close()
if err := png.Encode(file, img); err != nil {
return fmt.Errorf("failed to encode PNG: %w", err)
}
return nil
}
func saveHeightmapBinary(heightmap [][]float32, filename string) error {
size := len(heightmap)
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("failed to create file %s: %w", filename, err)
}
defer file.Close()
if err := binary.Write(file, binary.LittleEndian, int32(size)); err != nil {
return fmt.Errorf("failed to write size: %w", err)
}
for y := range heightmap {
for x := range heightmap[y] {
if err := binary.Write(file, binary.LittleEndian, heightmap[y][x]); err != nil {
return fmt.Errorf("failed to write heightmap data: %w", err)
}
}
}
return nil
}
// loadHeightmapBinary loads a heightmap from a binary file
func loadHeightmapBinary(filename string) ([][]float32, error) {
file, err := os.Open(filename)
if err != nil {
return nil, fmt.Errorf("failed to open file %s: %w", filename, err)
}
defer file.Close()
var size int32
if err := binary.Read(file, binary.LittleEndian, &size); err != nil {
return nil, fmt.Errorf("failed to read size: %w", err)
}
heightmap := make([][]float32, size)
for y := range heightmap {
heightmap[y] = make([]float32, size)
for x := range heightmap[y] {
if err := binary.Read(file, binary.LittleEndian, &heightmap[y][x]); err != nil {
return nil, fmt.Errorf("failed to read heightmap data: %w", err)
}
}
}
return heightmap, nil
}
func main() {
// Parse command-line flags
var (
port = flag.String("port", "9999", "UDP port to listen on")
worldSize = flag.Int("size", WorldSize, "World size for heightmap generation")
skipGen = flag.Bool("skip-gen", false, "Skip heightmap generation (fail if none exists)")
forceGen = flag.Bool("force-gen", false, "Force heightmap regeneration even if one exists")
assetsPath = flag.String("assets", "../assets", "Path to assets directory")
)
flag.Parse()
// Setup logging
log.SetPrefix("[Game] ")
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
var heightmap [][]float32
binPath := fmt.Sprintf("%s/heightmap.bin", *assetsPath)
// Check if heightmap exists and should be loaded
if _, err := os.Stat(binPath); err == nil && !*forceGen && !*skipGen {
// Heightmap exists and we're not forcing regeneration
log.Printf("Found existing heightmap at %s, loading...", binPath)
heightmap, err = loadHeightmapBinary(binPath)
if err != nil {
log.Printf("Failed to load existing heightmap: %v, generating new one...", err)
heightmap = nil
} else {
log.Printf("Successfully loaded existing heightmap")
}
} else if *forceGen {
log.Printf("Force regeneration requested, ignoring existing heightmap")
}
// Generate new heightmap if needed (not loaded, force generation, or doesn't exist)
if heightmap == nil && !*skipGen {
log.Printf("Generating new %dx%d heightmap...", *worldSize, *worldSize)
heightmap = generateHeightmap(*worldSize)
pngPath := fmt.Sprintf("%s/heightmap.png", *assetsPath)
if err := saveHeightmapPNG(heightmap, pngPath); err != nil {
log.Printf("Warning: Failed to save PNG heightmap: %v", err)
} else {
log.Printf("Saved heightmap PNG to %s", pngPath)
}
if err := saveHeightmapBinary(heightmap, binPath); err != nil {
log.Fatalf("Failed to save binary heightmap: %v", err)
}
log.Printf("Saved heightmap binary to %s", binPath)
} else if *skipGen && heightmap == nil {
// skip-gen was specified but no heightmap exists
log.Fatalf("No existing heightmap found and generation was skipped (--skip-gen flag)")
}
server, err := net.NewServer(*port, heightmap)
if err != nil {
log.Fatalf("Failed to create server: %v", err)
}
log.Printf("Starting game server on port %s", *port)
if err := server.Run(); err != nil {
log.Fatalf("Server failed: %v", err)
}
}

BIN
tools/generate_heightmap Executable file

Binary file not shown.

View File

@ -0,0 +1,69 @@
#include <iostream>
#include <fstream>
#include <vector>
#include <cmath>
#include <cstdint>
#include <string>
// Simple heightmap generator for testing different world sizes
void generateHeightmap(const std::string& filename, int size, float amplitude = 10.0f) {
std::vector<float> data(size * size);
// Generate a simple heightmap with some hills
for (int z = 0; z < size; z++) {
for (int x = 0; x < size; x++) {
float fx = (float)x / (size - 1) * 2.0f - 1.0f;
float fz = (float)z / (size - 1) * 2.0f - 1.0f;
// Create some hills using sine waves
float height = 0;
height += sin(fx * 3.14159f * 2.0f) * cos(fz * 3.14159f * 2.0f) * amplitude * 0.5f;
height += sin(fx * 3.14159f * 4.0f) * sin(fz * 3.14159f * 4.0f) * amplitude * 0.25f;
// Add a central mountain
float dist = sqrt(fx * fx + fz * fz);
height += std::max(0.0f, (1.0f - dist) * amplitude);
data[z * size + x] = height;
}
}
// Write to binary file
std::ofstream file(filename, std::ios::binary);
if (!file) {
std::cerr << "Failed to open file: " << filename << "\n";
return;
}
int32_t fileSize = size;
file.write(reinterpret_cast<char*>(&fileSize), sizeof(fileSize));
file.write(reinterpret_cast<char*>(data.data()), data.size() * sizeof(float));
std::cout << "Generated heightmap: " << filename << " (" << size << "x" << size << ")\n";
std::cout << "World size will be: " << (size-1) << "x" << (size-1) << " units\n";
}
int main(int argc, char* argv[]) {
if (argc < 3) {
std::cout << "Usage: " << argv[0] << " <size> <output_file> [amplitude]\n";
std::cout << "Example: " << argv[0] << " 256 heightmap_256.bin 20.0\n";
std::cout << "\nCommon sizes:\n";
std::cout << " 64x64 -> 63x63 unit world (small)\n";
std::cout << " 128x128 -> 127x127 unit world (medium)\n";
std::cout << " 256x256 -> 255x255 unit world (large)\n";
std::cout << " 512x512 -> 511x511 unit world (huge)\n";
return 1;
}
int size = std::stoi(argv[1]);
std::string filename = argv[2];
float amplitude = (argc > 3) ? std::stof(argv[3]) : 10.0f;
if (size < 2) {
std::cerr << "Size must be at least 2\n";
return 1;
}
generateHeightmap(filename, size, amplitude);
return 0;
}