diff --git a/assets/heightmap.bin b/assets/heightmap.bin index e77e467..08ce812 100644 Binary files a/assets/heightmap.bin and b/assets/heightmap.bin differ diff --git a/assets/heightmap.png b/assets/heightmap.png index 8881242..70537e6 100644 Binary files a/assets/heightmap.png and b/assets/heightmap.png differ diff --git a/server/main.go b/server/main.go index 0af165d..62577b8 100644 --- a/server/main.go +++ b/server/main.go @@ -2,59 +2,19 @@ package main import ( "encoding/binary" - "encoding/json" + "flag" "fmt" "image" "image/color" "image/png" + "log" "math" "math/rand" - "net" "os" - "sync" - "time" - "slices" + "server/net" ) -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 -) +const WorldSize = 100 func generateHeightmap(size int) [][]float32 { heightmap := make([][]float32, size) @@ -63,19 +23,19 @@ func generateHeightmap(size int) [][]float32 { } // Simple perlin-like noise - for y := 0; y < size; y++ { - for x := 0; x < size; x++ { + 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 + 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++ { + for range 3 { newHeightmap := make([][]float32, size) for y := range newHeightmap { newHeightmap[y] = make([]float32, size) @@ -100,7 +60,7 @@ func generateHeightmap(size int) [][]float32 { return heightmap } -func saveHeightmapPNG(heightmap [][]float32, filename string) { +func saveHeightmapPNG(heightmap [][]float32, filename string) error { size := len(heightmap) img := image.NewGray(image.Rect(0, 0, size, size)) @@ -117,393 +77,124 @@ func saveHeightmapPNG(heightmap [][]float32, filename string) { } } + // 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{uint8(normalized * 255)}) + img.SetGray(x, y, color.Gray{Y: uint8(normalized * 255)}) } } - file, _ := os.Create(filename) + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", filename, err) + } defer file.Close() - png.Encode(file, img) + + 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) { +func saveHeightmapBinary(heightmap [][]float32, filename string) error { size := len(heightmap) - file, _ := os.Create(filename) + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", filename, err) + } defer file.Close() - binary.Write(file, binary.LittleEndian, int32(size)) + 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] { - binary.Write(file, binary.LittleEndian, heightmap[y][x]) + if err := binary.Write(file, binary.LittleEndian, heightmap[y][x]); err != nil { + return fmt.Errorf("failed to write heightmap data: %w", err) + } } } + return nil } -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") +// loadHeightmapBinary loads a heightmap from a binary file +func loadHeightmapBinary(filename string) ([][]float32, error) { + file, err := os.Open(filename) if err != nil { - return + 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) } - 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) + 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() { - // Generate and save heightmap - fmt.Println("Generating heightmap...") - heightmap := generateHeightmap(WORLD_SIZE) - saveHeightmapPNG(heightmap, "../assets/heightmap.png") - saveHeightmapBinary(heightmap, "../assets/heightmap.bin") + // 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") + assetsPath = flag.String("assets", "../assets", "Path to assets directory") + ) + flag.Parse() - // Start UDP server - addr, _ := net.ResolveUDPAddr("udp", ":9999") - conn, err := net.ListenUDP("udp", addr) + // Setup logging + log.SetPrefix("[GameServer] ") + log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds) + + var heightmap [][]float32 + + if !*skipGen { + // Generate and save heightmap + log.Printf("Generating %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) + } + + binPath := fmt.Sprintf("%s/heightmap.bin", *assetsPath) + 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 { + // Load existing heightmap + binPath := fmt.Sprintf("%s/heightmap.bin", *assetsPath) + log.Printf("Loading existing heightmap from %s", binPath) + var err error + heightmap, err = loadHeightmapBinary(binPath) + if err != nil { + log.Fatalf("Failed to load heightmap: %v", err) + } + } + + server, err := net.NewServer(*port, heightmap) if err != nil { - panic(err) - } - defer conn.Close() - - server := &GameServer{ - conn: conn, - players: make(map[uint32]*Player), - heightmap: heightmap, - nextID: 0, + log.Fatalf("Failed to create server: %v", err) } - server.loadPlayerPositions() - - fmt.Println("Server running on :9999") - server.run() + log.Printf("Starting game server on port %s", *port) + if err := server.Run(); err != nil { + log.Fatalf("Server failed: %v", err) + } } diff --git a/server/net/packets.go b/server/net/packets.go new file mode 100644 index 0000000..4268f7e --- /dev/null +++ b/server/net/packets.go @@ -0,0 +1,144 @@ +package net + +import ( + "encoding/binary" + "math" +) + +// Message type constants +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 +) + +// Vec3 represents a 3D vector +type Vec3 struct { + X, Y, Z float32 +} + +// EncodeSpawnPacket creates a spawn packet +func EncodeSpawnPacket(playerID uint32, position Vec3, color string) []byte { + 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(position.X)) + binary.LittleEndian.PutUint32(msg[9:13], math.Float32bits(position.Y)) + binary.LittleEndian.PutUint32(msg[13:17], math.Float32bits(position.Z)) + msg[17] = uint8(len(colorBytes)) + copy(msg[18:], colorBytes) + return msg +} + +// EncodeUpdatePacket creates an update packet +func EncodeUpdatePacket(playerID uint32, position Vec3) []byte { + msg := make([]byte, 17) + msg[0] = MSG_UPDATE + binary.LittleEndian.PutUint32(msg[1:5], playerID) + binary.LittleEndian.PutUint32(msg[5:9], math.Float32bits(position.X)) + binary.LittleEndian.PutUint32(msg[9:13], math.Float32bits(position.Y)) + binary.LittleEndian.PutUint32(msg[13:17], math.Float32bits(position.Z)) + return msg +} + +// EncodePlayerJoinedPacket creates a player joined packet +func EncodePlayerJoinedPacket(playerID uint32, position Vec3, color string) []byte { + colorBytes := []byte(color) + msg := make([]byte, 18+len(colorBytes)) + msg[0] = MSG_PLAYER_JOINED + binary.LittleEndian.PutUint32(msg[1:5], playerID) + binary.LittleEndian.PutUint32(msg[5:9], math.Float32bits(position.X)) + binary.LittleEndian.PutUint32(msg[9:13], math.Float32bits(position.Y)) + binary.LittleEndian.PutUint32(msg[13:17], math.Float32bits(position.Z)) + msg[17] = uint8(len(colorBytes)) + copy(msg[18:], colorBytes) + return msg +} + +// EncodePlayerLeftPacket creates a player left packet +func EncodePlayerLeftPacket(playerID uint32) []byte { + msg := make([]byte, 5) + msg[0] = MSG_PLAYER_LEFT + binary.LittleEndian.PutUint32(msg[1:5], playerID) + return msg +} + +// EncodeColorChangedPacket creates a color changed packet +func EncodeColorChangedPacket(playerID uint32, color string) []byte { + 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) + return msg +} + +// EncodePlayerListPacket creates a player list packet +func EncodePlayerListPacket(players []*Player) []byte { + if len(players) == 0 { + return []byte{MSG_PLAYER_LIST, 0} + } + + 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 + } + } + + return msg[:offset] +} + +// DecodeMovePacket decodes a move packet +func DecodeMovePacket(data []byte) (playerID uint32, delta Vec3, ok bool) { + if len(data) < 17 { + return 0, Vec3{}, false + } + + playerID = binary.LittleEndian.Uint32(data[1:5]) + delta.X = math.Float32frombits(binary.LittleEndian.Uint32(data[5:9])) + delta.Y = math.Float32frombits(binary.LittleEndian.Uint32(data[9:13])) + delta.Z = math.Float32frombits(binary.LittleEndian.Uint32(data[13:17])) + + return playerID, delta, true +} + +// DecodeColorChangePacket decodes a color change packet +func DecodeColorChangePacket(data []byte) (playerID uint32, color string, ok bool) { + if len(data) < 6 { + return 0, "", false + } + + playerID = binary.LittleEndian.Uint32(data[1:5]) + colorLen := data[5] + + if len(data) < 6+int(colorLen) { + return 0, "", false + } + + color = string(data[6 : 6+colorLen]) + return playerID, color, true +} \ No newline at end of file diff --git a/server/net/server.go b/server/net/server.go new file mode 100644 index 0000000..493c08f --- /dev/null +++ b/server/net/server.go @@ -0,0 +1,353 @@ +package net + +import ( + "slices" + "encoding/json" + "fmt" + "log" + "math" + "math/rand" + "net" + "os" + "sync" + "time" +) + +// Player represents a connected player +type Player struct { + ID uint32 + Position Vec3 + Velocity Vec3 + Color string + Address *net.UDPAddr + LastSeen time.Time +} + +// Server manages the game state and networking +type Server struct { + conn *net.UDPConn + players map[uint32]*Player + heightmap [][]float32 + mutex sync.RWMutex + nextID uint32 +} + +// NewServer creates a new game server +func NewServer(port string, heightmap [][]float32) (*Server, error) { + addr, err := net.ResolveUDPAddr("udp", ":"+port) + if err != nil { + return nil, fmt.Errorf("failed to resolve UDP address: %w", err) + } + + conn, err := net.ListenUDP("udp", addr) + if err != nil { + return nil, fmt.Errorf("failed to listen on UDP: %w", err) + } + + server := &Server{ + conn: conn, + players: make(map[uint32]*Player), + heightmap: heightmap, + nextID: 0, + } + + server.loadPlayerPositions() + + return server, nil +} + +// Run starts the server main loop +func (s *Server) Run() error { + defer s.conn.Close() + + // Start periodic save + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for range ticker.C { + s.savePlayerPositions() + } + }() + + // Start player timeout checker + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for range ticker.C { + s.checkTimeouts() + } + }() + + buffer := make([]byte, 1024) + log.Println("Server running...") + + for { + n, addr, err := s.conn.ReadFromUDP(buffer) + if err != nil { + log.Printf("Error reading UDP: %v", err) + 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 (s *Server) handleLogin(addr *net.UDPAddr) { + s.mutex.Lock() + s.nextID++ + playerID := s.nextID + + // Assign color based on player ID + colors := []string{"red", "green", "orange", "purple", "white"} + colorIndex := (playerID - 1) % uint32(len(colors)) + color := colors[colorIndex] + + // Spawn at random position on heightmap + x := rand.Float32()*100 - 50 + z := rand.Float32()*100 - 50 + y := s.getHeightAt(x, z) + 1.0 + + player := &Player{ + ID: playerID, + Position: Vec3{x, y, z}, + Color: color, + Address: addr, + LastSeen: time.Now(), + } + + // Send existing players to new player before adding them + 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 + spawnMsg := EncodeSpawnPacket(playerID, player.Position, color) + s.conn.WriteToUDP(spawnMsg, addr) + + // Send player list to new player + if len(existingPlayers) > 0 { + listMsg := EncodePlayerListPacket(existingPlayers) + s.conn.WriteToUDP(listMsg, addr) + } + + // Notify other players about new player + s.broadcastPlayerJoined(player) + + log.Printf("Player %d logged in at (%.2f, %.2f, %.2f) with color %s", + playerID, x, y, z, color) + + s.savePlayerPositions() +} + +func (s *Server) handleMove(data []byte, _ *net.UDPAddr) { + playerID, delta, ok := DecodeMovePacket(data) + if !ok { + return + } + + s.mutex.Lock() + player, exists := s.players[playerID] + if !exists { + s.mutex.Unlock() + return + } + + // Server-authoritative movement + deltaTime := float32(0.016) // 60fps + newX := player.Position.X + delta.X*15.0*deltaTime + newZ := player.Position.Z + delta.Z*15.0*deltaTime + + // Clamp to world bounds + newX = float32(math.Max(-50, math.Min(50, float64(newX)))) + newZ = float32(math.Max(-50, math.Min(50, float64(newZ)))) + + // Set Y to terrain height with smoothing + targetY := s.getHeightAt(newX, newZ) + 1.0 + smoothFactor := float32(0.15) + 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 *Server) handleColorChange(data []byte, _ *net.UDPAddr) { + playerID, newColor, ok := DecodeColorChangePacket(data) + if !ok { + return + } + + // 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) +} + +func (s *Server) getHeightAt(x, z float32) float32 { + // Convert world coords to heightmap coords with bilinear interpolation + size := float32(len(s.heightmap)) + fx := (x/100 + 0.5) * (size - 1) + fz := (z/100 + 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 *Server) broadcastUpdate(player *Player) { + msg := EncodeUpdatePacket(player.ID, player.Position) + + s.mutex.RLock() + for _, p := range s.players { + if p.Address != nil { + s.conn.WriteToUDP(msg, p.Address) + } + } + s.mutex.RUnlock() +} + +func (s *Server) broadcastPlayerJoined(newPlayer *Player) { + msg := EncodePlayerJoinedPacket(newPlayer.ID, newPlayer.Position, newPlayer.Color) + + 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 *Server) broadcastPlayerLeft(playerID uint32) { + msg := EncodePlayerLeftPacket(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 *Server) broadcastColorChanged(playerID uint32, color string) { + msg := EncodeColorChangedPacket(playerID, color) + + s.mutex.RLock() + for _, p := range s.players { + if p.Address != nil { + s.conn.WriteToUDP(msg, p.Address) + } + } + s.mutex.RUnlock() +} + +func (s *Server) checkTimeouts() { + s.mutex.Lock() + defer s.mutex.Unlock() + + now := time.Now() + for id, player := range s.players { + if now.Sub(player.LastSeen) > 30*time.Second { + delete(s.players, id) + go s.broadcastPlayerLeft(id) + log.Printf("Player %d timed out", id) + } + } +} + +func (s *Server) 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 *Server) 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) +} diff --git a/server/players.json b/server/players.json index a7c4f67..9159d25 100644 --- a/server/players.json +++ b/server/players.json @@ -1 +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 +{"1":{"X":-13.046684,"Y":-0.008753866,"Z":15.9760895}} \ No newline at end of file