From 9d60d5e9cd310c6cb6f3edd7ea883202de126182 Mon Sep 17 00:00:00 2001 From: bdnugget <1001337108312v3@gmail.com> Date: Wed, 16 Apr 2025 12:13:29 +0200 Subject: [PATCH] big ol refactor --- assets/assets.go | 131 +++++++++++--------- constants.go | 22 ++++ game/chat.go | 34 +++--- game/components.go | 107 ++++++++++++++++ game/game.go | 254 +++++++++++++++++++++----------------- game/pathfinding.go | 155 +++++++++++++++++------- game/utils.go | 27 +++++ network/network.go | 288 ++++++++++++++++++++++++-------------------- types/player.go | 102 +++++++++++++--- types/types.go | 9 ++ 10 files changed, 747 insertions(+), 382 deletions(-) create mode 100644 game/components.go diff --git a/assets/assets.go b/assets/assets.go index 43322b6..512bbe4 100644 --- a/assets/assets.go +++ b/assets/assets.go @@ -8,6 +8,72 @@ import ( rl "github.com/gen2brain/raylib-go/raylib" ) +// ModelLoader handles loading and fallback for 3D models +type ModelLoader struct { + safeMode bool +} + +// NewModelLoader creates a new model loader instance +func NewModelLoader() *ModelLoader { + return &ModelLoader{ + safeMode: os.Getenv("GOONSCAPE_SAFE_MODE") == "1", + } +} + +// IsSafeMode returns if we should avoid loading external models +func (ml *ModelLoader) IsSafeMode() bool { + return ml.safeMode || os.Getenv("GOONSCAPE_SAFE_MODE") == "1" +} + +// LoadModel attempts to load a model, returning a placeholder if it fails +func (ml *ModelLoader) LoadModel(fileName string, fallbackShape int, fallbackColor rl.Color) (rl.Model, bool, rl.Color) { + // Don't even try to load external models in safe mode + if ml.IsSafeMode() { + rl.TraceLog(rl.LogInfo, "Safe mode enabled, using primitive shape instead of %s", fileName) + return ml.createPrimitiveShape(fallbackShape), false, fallbackColor + } + + defer func() { + // Recover from any panics during model loading + if r := recover(); r != nil { + rl.TraceLog(rl.LogError, "Panic in LoadModel: %v", r) + } + }() + + // Try to load the model + model := rl.LoadModel(fileName) + + // Check if the model is valid + if model.Meshes == nil || model.Meshes.VertexCount <= 0 { + rl.TraceLog(rl.LogWarning, "Failed to load model %s, using placeholder", fileName) + return ml.createPrimitiveShape(fallbackShape), false, fallbackColor + } + + // For real models, return zero color since we don't need it + return model, true, rl.Color{} +} + +// createPrimitiveShape creates a simple shape without loading external models +func (ml *ModelLoader) createPrimitiveShape(shapeType int) rl.Model { + var mesh rl.Mesh + + switch shapeType { + case 0: // Cube + mesh = rl.GenMeshCube(1.0, 2.0, 1.0) + case 1: // Sphere + mesh = rl.GenMeshSphere(1.0, 8, 8) + case 2: // Cylinder + mesh = rl.GenMeshCylinder(0.8, 2.0, 8) + case 3: // Cone + mesh = rl.GenMeshCone(1.0, 2.0, 8) + default: // Default to cube + mesh = rl.GenMeshCube(1.0, 2.0, 1.0) + } + + model := rl.LoadModelFromMesh(mesh) + return model +} + // Helper function to load animations for a model func loadModelAnimations(animPaths map[string]string) (types.AnimationSet, error) { var animSet types.AnimationSet @@ -58,51 +124,8 @@ func CompletelyAvoidExternalModels() bool { // SafeLoadModel attempts to load a model, returning a placeholder if it fails func SafeLoadModel(fileName string, fallbackShape int, fallbackColor rl.Color) (rl.Model, bool, rl.Color) { - // Don't even try to load external models in safe mode - if CompletelyAvoidExternalModels() { - rl.TraceLog(rl.LogInfo, "Safe mode enabled, using primitive shape instead of %s", fileName) - return createPrimitiveShape(fallbackShape), false, fallbackColor - } - - defer func() { - // Recover from any panics during model loading - if r := recover(); r != nil { - rl.TraceLog(rl.LogError, "Panic in SafeLoadModel: %v", r) - } - }() - - // Try to load the model - model := rl.LoadModel(fileName) - - // Check if the model is valid - if model.Meshes == nil || model.Meshes.VertexCount <= 0 { - rl.TraceLog(rl.LogWarning, "Failed to load model %s, using placeholder", fileName) - return createPrimitiveShape(fallbackShape), false, fallbackColor - } - - // For real models, return zero color since we don't need it - return model, true, rl.Color{} -} - -// createPrimitiveShape creates a simple shape without loading external models -func createPrimitiveShape(shapeType int) rl.Model { - var mesh rl.Mesh - - switch shapeType { - case 0: // Cube - mesh = rl.GenMeshCube(1.0, 2.0, 1.0) - case 1: // Sphere - mesh = rl.GenMeshSphere(1.0, 8, 8) - case 2: // Cylinder - mesh = rl.GenMeshCylinder(0.8, 2.0, 8) - case 3: // Cone - mesh = rl.GenMeshCone(1.0, 2.0, 8) - default: // Default to cube - mesh = rl.GenMeshCube(1.0, 2.0, 1.0) - } - - model := rl.LoadModelFromMesh(mesh) - return model + loader := NewModelLoader() + return loader.LoadModel(fileName, fallbackShape, fallbackColor) } func LoadModels() ([]types.ModelAsset, error) { @@ -110,9 +133,7 @@ func LoadModels() ([]types.ModelAsset, error) { os.Setenv("GOONSCAPE_SAFE_MODE", "1") models := make([]types.ModelAsset, 0, 3) - - // Use environment variable to completely disable model loading - safeMode := CompletelyAvoidExternalModels() + modelLoader := NewModelLoader() // Colors for the different models goonerColor := rl.Color{R: 255, G: 200, B: 200, A: 255} // Pinkish @@ -120,9 +141,9 @@ func LoadModels() ([]types.ModelAsset, error) { shrekeColor := rl.Color{R: 180, G: 255, B: 180, A: 255} // Light green // If in safe mode, create all models directly without loading - if safeMode { + if modelLoader.IsSafeMode() { // Gooner model (cube) - cube := createPrimitiveShape(0) + cube := modelLoader.createPrimitiveShape(0) models = append(models, types.ModelAsset{ Model: cube, YOffset: 0.0, @@ -130,7 +151,7 @@ func LoadModels() ([]types.ModelAsset, error) { }) // Coomer model (sphere) - sphere := createPrimitiveShape(1) + sphere := modelLoader.createPrimitiveShape(1) models = append(models, types.ModelAsset{ Model: sphere, YOffset: -4.0, @@ -138,7 +159,7 @@ func LoadModels() ([]types.ModelAsset, error) { }) // Shreke model (cylinder) - cylinder := createPrimitiveShape(2) + cylinder := modelLoader.createPrimitiveShape(2) models = append(models, types.ModelAsset{ Model: cylinder, YOffset: 0.0, @@ -154,7 +175,7 @@ func LoadModels() ([]types.ModelAsset, error) { var success bool var modelColor rl.Color - goonerModel, success, modelColor = SafeLoadModel("resources/models/gooner/walk_no_y_transform.glb", 0, goonerColor) + goonerModel, success, modelColor = modelLoader.LoadModel("resources/models/gooner/walk_no_y_transform.glb", 0, goonerColor) // Create animations only if model was loaded successfully var goonerAnims types.AnimationSet @@ -184,7 +205,7 @@ func LoadModels() ([]types.ModelAsset, error) { // Coomer model with safe loading - using a sphere shape var coomerModel rl.Model - coomerModel, success, modelColor = SafeLoadModel("resources/models/coomer/idle_notransy.glb", 1, coomerColor) + coomerModel, success, modelColor = modelLoader.LoadModel("resources/models/coomer/idle_notransy.glb", 1, coomerColor) if success { // Only load animations if the model loaded successfully @@ -219,7 +240,7 @@ func LoadModels() ([]types.ModelAsset, error) { // Shreke model with safe loading - using a cylinder shape var shrekeModel rl.Model - shrekeModel, success, modelColor = SafeLoadModel("resources/models/shreke.obj", 2, shrekeColor) + shrekeModel, success, modelColor = modelLoader.LoadModel("resources/models/shreke.obj", 2, shrekeColor) if success { // Only proceed with texture if model loaded diff --git a/constants.go b/constants.go index 8090263..7e2ba93 100644 --- a/constants.go +++ b/constants.go @@ -9,4 +9,26 @@ const ( ClientTickRate = 50 * time.Millisecond // Client runs at higher rate for smooth rendering MaxTickDesync = 5 // Max ticks behind before forcing resync DefaultPort = "6969" // Default server port + + // Map constants + MapWidth = 50 + MapHeight = 50 + TileSize = 32 + TileHeight = 2.0 +) + +// UI constants +const ( + ChatMargin = 10 + ChatHeight = 200 + MessageHeight = 20 + InputHeight = 30 + MaxMessages = 50 +) + +// Environment variable names +const ( + EnvSafeMode = "GOONSCAPE_SAFE_MODE" + EnvDisableAnimations = "GOONSCAPE_DISABLE_ANIMATIONS" + EnvDisableAudio = "GOONSCAPE_DISABLE_AUDIO" ) diff --git a/game/chat.go b/game/chat.go index ed4b327..f5b9499 100644 --- a/game/chat.go +++ b/game/chat.go @@ -11,13 +11,9 @@ import ( rl "github.com/gen2brain/raylib-go/raylib" ) +// Local UI constants (these could be moved to a centralized constants package later) const ( - maxMessages = 50 - chatMargin = 10 // Margin from screen edges - chatHeight = 200 - messageHeight = 20 - inputHeight = 30 - runeLimit = 256 + runeLimit = 256 ) type Chat struct { @@ -32,7 +28,7 @@ type Chat struct { func NewChat() *Chat { return &Chat{ - messages: make([]types.ChatMessage, 0, maxMessages), + messages: make([]types.ChatMessage, 0, types.MaxChatMessages), inputBuffer: make([]rune, 0, runeLimit), } } @@ -44,7 +40,7 @@ func (c *Chat) AddMessage(playerID int32, content string) { Time: time.Now(), } - if len(c.messages) >= maxMessages { + if len(c.messages) >= types.MaxChatMessages { c.messages = c.messages[1:] } c.messages = append(c.messages, msg) @@ -72,14 +68,14 @@ func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) { // Only add if it's not already in our history if len(c.messages) == 0 || c.messages[len(c.messages)-1].Time.UnixNano() < msg.Timestamp { - if len(c.messages) >= maxMessages { + if len(c.messages) >= types.MaxChatMessages { c.messages = c.messages[1:] } c.messages = append(c.messages, localMsg) log.Printf("Added chat message from %s: %s", msg.Username, msg.Content) // Scroll to latest message if it's not already visible - visibleMessages := int((chatHeight - inputHeight) / messageHeight) + visibleMessages := int((types.ChatHeight - types.InputHeight) / types.MessageHeight) if len(c.messages) > visibleMessages { c.scrollOffset = len(c.messages) - visibleMessages } @@ -114,16 +110,16 @@ func (c *Chat) Draw(screenWidth, screenHeight int32) { defer c.mutex.RUnlock() // Calculate chat window width based on screen width - chatWindowWidth := screenWidth - (chatMargin * 2) + chatWindowWidth := screenWidth - (types.ChatMargin * 2) // Draw chat window background - chatX := float32(chatMargin) - chatY := float32(screenHeight - chatHeight - chatMargin) - rl.DrawRectangle(int32(chatX), int32(chatY), chatWindowWidth, chatHeight, rl.ColorAlpha(rl.Black, 0.5)) + chatX := float32(types.ChatMargin) + chatY := float32(screenHeight - types.ChatHeight - types.ChatMargin) + rl.DrawRectangle(int32(chatX), int32(chatY), chatWindowWidth, types.ChatHeight, rl.ColorAlpha(rl.Black, 0.5)) // Draw messages from oldest to newest messageY := chatY + 5 - visibleMessages := int((chatHeight - inputHeight) / messageHeight) + visibleMessages := int((types.ChatHeight - types.InputHeight) / types.MessageHeight) // Auto-scroll to bottom if no manual scrolling has occurred if c.scrollOffset == 0 { @@ -145,12 +141,12 @@ func (c *Chat) Draw(screenWidth, screenHeight int32) { } text := fmt.Sprintf("%s: %s", msg.Username, msg.Content) rl.DrawText(text, int32(chatX)+5, int32(messageY), 20, color) - messageY += messageHeight + messageY += types.MessageHeight } // Draw input field - inputY := chatY + float32(chatHeight-inputHeight) - rl.DrawRectangle(int32(chatX), int32(inputY), chatWindowWidth, inputHeight, rl.ColorAlpha(rl.White, 0.3)) + inputY := chatY + float32(types.ChatHeight-types.InputHeight) + rl.DrawRectangle(int32(chatX), int32(inputY), chatWindowWidth, types.InputHeight, rl.ColorAlpha(rl.White, 0.3)) if c.isTyping { inputText := string(c.inputBuffer) rl.DrawText(inputText, int32(chatX)+5, int32(inputY)+5, 20, rl.White) @@ -168,7 +164,7 @@ func (c *Chat) Update() (string, bool) { if !c.isTyping { wheelMove := rl.GetMouseWheelMove() if wheelMove != 0 { - maxScroll := max(0, len(c.messages)-int((chatHeight-inputHeight)/messageHeight)) + maxScroll := max(0, len(c.messages)-int((types.ChatHeight-types.InputHeight)/types.MessageHeight)) c.scrollOffset = clamp(c.scrollOffset-int(wheelMove), 0, maxScroll) } diff --git a/game/components.go b/game/components.go new file mode 100644 index 0000000..b9f4f3b --- /dev/null +++ b/game/components.go @@ -0,0 +1,107 @@ +package game + +import ( + "sync" + + "gitea.boner.be/bdnugget/goonscape/types" + rl "github.com/gen2brain/raylib-go/raylib" +) + +// PlayerManager handles all player-related operations +type PlayerManager struct { + LocalPlayer *types.Player + OtherPlayers map[int32]*types.Player + mutex sync.RWMutex +} + +// NewPlayerManager creates a new player manager +func NewPlayerManager() *PlayerManager { + return &PlayerManager{ + OtherPlayers: make(map[int32]*types.Player), + } +} + +// GetPlayer returns the player with the given ID, or the local player if ID matches +func (pm *PlayerManager) GetPlayer(id int32) *types.Player { + pm.mutex.RLock() + defer pm.mutex.RUnlock() + + if pm.LocalPlayer != nil && pm.LocalPlayer.ID == id { + return pm.LocalPlayer + } + + return pm.OtherPlayers[id] +} + +// AddPlayer adds a player to the manager +func (pm *PlayerManager) AddPlayer(player *types.Player) { + pm.mutex.Lock() + defer pm.mutex.Unlock() + + pm.OtherPlayers[player.ID] = player +} + +// RemovePlayer removes a player from the manager +func (pm *PlayerManager) RemovePlayer(id int32) { + pm.mutex.Lock() + defer pm.mutex.Unlock() + + delete(pm.OtherPlayers, id) +} + +// AssetManager handles all game assets +type AssetManager struct { + Models []types.ModelAsset + Music rl.Music +} + +// NewAssetManager creates a new asset manager +func NewAssetManager() *AssetManager { + return &AssetManager{} +} + +// GetModelForPlayer returns the appropriate model for a player +func (am *AssetManager) GetModelForPlayer(playerID int32) (types.ModelAsset, bool) { + if len(am.Models) == 0 { + return types.ModelAsset{}, false + } + + // Simple model assignment based on player ID + modelIndex := int(playerID) % len(am.Models) + return am.Models[modelIndex], true +} + +// UIManager manages all user interface components +type UIManager struct { + Chat *Chat + LoginScreen *LoginScreen + IsLoggedIn bool + MenuOpen bool +} + +// NewUIManager creates a new UI manager +func NewUIManager() *UIManager { + return &UIManager{ + Chat: NewChat(), + LoginScreen: NewLoginScreen(), + } +} + +// HandleChatInput processes chat input and returns messages to send +func (ui *UIManager) HandleChatInput() (string, bool) { + return ui.Chat.Update() +} + +// DrawUI renders all UI components +func (ui *UIManager) DrawUI(screenWidth, screenHeight int32) { + if !ui.IsLoggedIn { + ui.LoginScreen.Draw() + } else { + if ui.MenuOpen { + // Draw menu + } + + // Draw chat always when logged in + ui.Chat.Draw(screenWidth, screenHeight) + } +} diff --git a/game/game.go b/game/game.go index 4665f73..5185866 100644 --- a/game/game.go +++ b/game/game.go @@ -1,7 +1,7 @@ package game import ( - "fmt" + "log" "sync" "time" @@ -13,135 +13,163 @@ import ( ) type Game struct { - Player *types.Player - OtherPlayers map[int32]*types.Player + // Component-based architecture + PlayerManager *PlayerManager + AssetManager *AssetManager + UIManager *UIManager + + // Core game state Camera rl.Camera3D - Models []types.ModelAsset - Music rl.Music - Chat *Chat - MenuOpen bool quitChan chan struct{} - loginScreen *LoginScreen - isLoggedIn bool cleanupOnce sync.Once frameCounter int // For periodic logging + + // Legacy fields for backward compatibility + Player *types.Player // Use PlayerManager.LocalPlayer instead + OtherPlayers map[int32]*types.Player // Use PlayerManager.OtherPlayers instead + Models []types.ModelAsset // Use AssetManager.Models instead + Music rl.Music // Use AssetManager.Music instead + Chat *Chat // Use UIManager.Chat instead + MenuOpen bool // Use UIManager.MenuOpen instead + loginScreen *LoginScreen // Use UIManager.LoginScreen instead + isLoggedIn bool // Use UIManager.IsLoggedIn instead } func New() *Game { - InitWorld() - game := &Game{ - OtherPlayers: make(map[int32]*types.Player), + // Create managers + playerManager := NewPlayerManager() + assetManager := NewAssetManager() + uiManager := NewUIManager() + + g := &Game{ + PlayerManager: playerManager, + AssetManager: assetManager, + UIManager: uiManager, Camera: rl.Camera3D{ - Position: rl.NewVector3(0, 10, 10), - Target: rl.NewVector3(0, 0, 0), - Up: rl.NewVector3(0, 1, 0), + Position: rl.NewVector3(0.0, 20.0, 0.0), + Target: rl.NewVector3(0.0, 0.0, 0.0), + Up: rl.NewVector3(0.0, 1.0, 0.0), Fovy: 45.0, Projection: rl.CameraPerspective, }, - Chat: NewChat(), - quitChan: make(chan struct{}), - loginScreen: NewLoginScreen(), + quitChan: make(chan struct{}), } - game.Chat.userData = game - return game + + // Initialize legacy fields (for backward compatibility) + g.Player = g.PlayerManager.LocalPlayer + g.OtherPlayers = g.PlayerManager.OtherPlayers + g.Models = g.AssetManager.Models + g.Music = g.AssetManager.Music + g.Chat = g.UIManager.Chat + g.MenuOpen = g.UIManager.MenuOpen + g.loginScreen = g.UIManager.LoginScreen + g.isLoggedIn = g.UIManager.IsLoggedIn + + // Set up inter-component references + g.Chat.userData = g // Pass game instance to chat for callbacks + + // Initialize world + InitWorld() + + return g } func (g *Game) LoadAssets() error { - var loadErr error - defer func() { - if r := recover(); r != nil { - loadErr = fmt.Errorf("panic during asset loading: %v", r) - // Cleanup any partially loaded assets - g.Cleanup() + return SafeExecute(func() error { + // Load models + var err error + models, err := assets.LoadModels() + if err != nil { + log.Printf("Warning: Failed to load models: %v", err) } - }() + g.AssetManager.Models = models - // Load models with better error handling - g.Models, loadErr = assets.LoadModels() - if loadErr != nil { - return fmt.Errorf("failed to load models: %v", loadErr) - } + // Update legacy field + g.Models = models - // Verify model loading - for i, model := range g.Models { - if model.Model.Meshes == nil { - return fmt.Errorf("model %d failed to load properly", i) + // Try to load music + music, err := assets.LoadMusic("resources/audio/music.mp3") + if err != nil { + log.Printf("Warning: Failed to load music: %v", err) + } else { + g.AssetManager.Music = music + // Update legacy field + g.Music = music } - } - // Load music with better error handling - g.Music, loadErr = assets.LoadMusic("resources/audio/GoonScape2.mp3") - if loadErr != nil { - return fmt.Errorf("failed to load music: %v", loadErr) - } - - return nil + return nil + }) } func (g *Game) Update(deltaTime float32) { - if !g.isLoggedIn { - username, password, isRegistering, submitted := g.loginScreen.Update() - if submitted { + // Legacy code to maintain compatibility + if !g.UIManager.IsLoggedIn { + // Handle login + username, password, isRegistering, doAuth := g.UIManager.LoginScreen.Update() + // Update legacy fields + g.isLoggedIn = g.UIManager.IsLoggedIn + + if doAuth { conn, playerID, err := network.ConnectToServer(username, password, isRegistering) if err != nil { - g.loginScreen.SetError(err.Error()) + g.UIManager.LoginScreen.SetError(err.Error()) return } - g.Player = &types.Player{ + g.PlayerManager.LocalPlayer = &types.Player{ Speed: 50.0, TargetPath: []types.Tile{}, UserData: g, QuitDone: make(chan struct{}), ID: playerID, } - g.AssignModelToPlayer(g.Player) + g.AssignModelToPlayer(g.PlayerManager.LocalPlayer) - go network.HandleServerCommunication(conn, playerID, g.Player, g.OtherPlayers, g.quitChan) - g.isLoggedIn = true + go network.HandleServerCommunication(conn, playerID, g.PlayerManager.LocalPlayer, g.PlayerManager.OtherPlayers, g.quitChan) + g.UIManager.IsLoggedIn = true } return } // Handle ESC for menu if rl.IsKeyPressed(rl.KeyEscape) { - g.MenuOpen = !g.MenuOpen + g.UIManager.MenuOpen = !g.UIManager.MenuOpen return } // Don't process other inputs if menu is open - if g.MenuOpen { + if g.UIManager.MenuOpen { return } - if message, sent := g.Chat.Update(); sent { - g.Player.Lock() - g.Player.ActionQueue = append(g.Player.ActionQueue, &pb.Action{ + if message, sent := g.UIManager.Chat.Update(); sent { + g.PlayerManager.LocalPlayer.Lock() + g.PlayerManager.LocalPlayer.ActionQueue = append(g.PlayerManager.LocalPlayer.ActionQueue, &pb.Action{ Type: pb.Action_CHAT, ChatMessage: message, - PlayerId: g.Player.ID, + PlayerId: g.PlayerManager.LocalPlayer.ID, }) - g.Player.Unlock() + g.PlayerManager.LocalPlayer.Unlock() } g.HandleInput() - if len(g.Player.TargetPath) > 0 { - g.Player.MoveTowards(g.Player.TargetPath[0], deltaTime, GetMapGrid()) + if len(g.PlayerManager.LocalPlayer.TargetPath) > 0 { + g.PlayerManager.LocalPlayer.MoveTowards(g.PlayerManager.LocalPlayer.TargetPath[0], deltaTime, GetMapGrid()) } // Periodically log information about other players g.frameCounter++ if g.frameCounter%300 == 0 { - rl.TraceLog(rl.LogInfo, "There are %d other players", len(g.OtherPlayers)) - for id, other := range g.OtherPlayers { + rl.TraceLog(rl.LogInfo, "There are %d other players", len(g.PlayerManager.OtherPlayers)) + for id, other := range g.PlayerManager.OtherPlayers { rl.TraceLog(rl.LogInfo, "Other player ID: %d, Position: (%f, %f, %f), Has model: %v", id, other.PosActual.X, other.PosActual.Y, other.PosActual.Z, other.Model.Meshes != nil) } } // Process other players - for _, other := range g.OtherPlayers { + for _, other := range g.PlayerManager.OtherPlayers { if other == nil { continue } @@ -157,7 +185,19 @@ func (g *Game) Update(deltaTime float32) { } } - UpdateCamera(&g.Camera, g.Player.PosActual, deltaTime) + UpdateCamera(&g.Camera, g.PlayerManager.LocalPlayer.PosActual, deltaTime) + + // Update music if available + if g.AssetManager.Music.Stream.Buffer != nil { + rl.UpdateMusicStream(g.AssetManager.Music) + } + + // Update legacy fields + g.Player = g.PlayerManager.LocalPlayer + g.OtherPlayers = g.PlayerManager.OtherPlayers + g.Models = g.AssetManager.Models + g.Music = g.AssetManager.Music + g.MenuOpen = g.UIManager.MenuOpen } func (g *Game) DrawMap() { @@ -280,8 +320,8 @@ func (g *Game) Render() { rl.ClearBackground(rl.RayWhite) - if !g.isLoggedIn { - g.loginScreen.Draw() + if !g.UIManager.IsLoggedIn { + g.UIManager.LoginScreen.Draw() return } @@ -289,12 +329,12 @@ func (g *Game) Render() { g.DrawMap() // Draw player only if valid - if g.Player != nil && g.Player.Model.Meshes != nil { - g.DrawPlayer(g.Player, g.Player.Model) + if g.PlayerManager.LocalPlayer != nil && g.PlayerManager.LocalPlayer.Model.Meshes != nil { + g.DrawPlayer(g.PlayerManager.LocalPlayer, g.PlayerManager.LocalPlayer.Model) } // Draw other players with defensive checks - for _, other := range g.OtherPlayers { + for _, other := range g.PlayerManager.OtherPlayers { if other == nil { continue } @@ -332,24 +372,24 @@ func (g *Game) Render() { rl.DrawText(text, int32(pos.X)-textWidth/2, int32(pos.Y), 20, rl.Yellow) } - if g.Player != nil && g.Player.FloatingMessage != nil { - drawFloatingMessage(g.Player.FloatingMessage) + if g.PlayerManager.LocalPlayer != nil && g.PlayerManager.LocalPlayer.FloatingMessage != nil { + drawFloatingMessage(g.PlayerManager.LocalPlayer.FloatingMessage) } - for _, other := range g.OtherPlayers { + for _, other := range g.PlayerManager.OtherPlayers { if other != nil && other.FloatingMessage != nil { drawFloatingMessage(other.FloatingMessage) } } // Draw menu if open - if g.MenuOpen { + if g.UIManager.MenuOpen { g.DrawMenu() } // Only draw chat if menu is not open - if !g.MenuOpen && g.Chat != nil { - g.Chat.Draw(int32(rl.GetScreenWidth()), int32(rl.GetScreenHeight())) + if !g.UIManager.MenuOpen && g.UIManager.Chat != nil { + g.UIManager.Chat.Draw(int32(rl.GetScreenWidth()), int32(rl.GetScreenHeight())) } rl.DrawFPS(10, 10) @@ -357,35 +397,37 @@ func (g *Game) Render() { func (g *Game) Cleanup() { g.cleanupOnce.Do(func() { - // Stop music first - if g.Music.Stream.Buffer != nil { - rl.StopMusicStream(g.Music) - rl.UnloadMusicStream(g.Music) - } - - // Unload textures - for _, model := range g.Models { + // Cleanup models + for _, model := range g.AssetManager.Models { + rl.UnloadModel(model.Model) if model.Texture.ID > 0 { rl.UnloadTexture(model.Texture) } } + + // Unload music + if g.AssetManager.Music.Stream.Buffer != nil { + rl.UnloadMusicStream(g.AssetManager.Music) + } + + close(g.quitChan) }) } func (g *Game) HandleInput() { clickedTile, clicked := g.GetTileAtMouse() if clicked { - path := FindPath(GetTile(g.Player.PosTile.X, g.Player.PosTile.Y), clickedTile) + path := FindPath(GetTile(g.PlayerManager.LocalPlayer.PosTile.X, g.PlayerManager.LocalPlayer.PosTile.Y), clickedTile) if len(path) > 1 { - g.Player.Lock() - g.Player.TargetPath = path[1:] - g.Player.ActionQueue = append(g.Player.ActionQueue, &pb.Action{ + g.PlayerManager.LocalPlayer.Lock() + g.PlayerManager.LocalPlayer.TargetPath = path[1:] + g.PlayerManager.LocalPlayer.ActionQueue = append(g.PlayerManager.LocalPlayer.ActionQueue, &pb.Action{ Type: pb.Action_MOVE, X: int32(clickedTile.X), Y: int32(clickedTile.Y), - PlayerId: g.Player.ID, + PlayerId: g.PlayerManager.LocalPlayer.ID, }) - g.Player.Unlock() + g.PlayerManager.LocalPlayer.Unlock() } } } @@ -428,7 +470,7 @@ func (g *Game) DrawMenu() { if rl.IsMouseButtonPressed(rl.MouseLeftButton) { switch item { case "Resume": - g.MenuOpen = false + g.UIManager.MenuOpen = false case "Settings": // TODO: Implement settings case "Exit Game": @@ -453,7 +495,7 @@ func (g *Game) Shutdown() { } func (g *Game) HandleServerMessages(messages []*pb.ChatMessage) { - g.Chat.HandleServerMessages(messages) + g.UIManager.Chat.HandleServerMessages(messages) } func (g *Game) AssignModelToPlayer(player *types.Player) { @@ -461,32 +503,18 @@ func (g *Game) AssignModelToPlayer(player *types.Player) { return } - // Defensive check for empty models array - if len(g.Models) == 0 { - rl.TraceLog(rl.LogWarning, "No models available to assign to player") - return - } - - // Make sure model index is positive for consistent player appearances - // Use abs value of ID to ensure consistent appearance for negative IDs - modelIndex := abs(int(player.ID)) % len(g.Models) - if modelIndex < 0 || modelIndex >= len(g.Models) { - // Prevent out of bounds access - modelIndex = 0 - } - - rl.TraceLog(rl.LogInfo, "Assigning model %d to player %d", modelIndex, player.ID) - modelAsset := g.Models[modelIndex] - - // Validate model before assigning - if modelAsset.Model.Meshes == nil { - rl.TraceLog(rl.LogWarning, "Trying to assign invalid model to player %d", player.ID) + modelAsset, found := g.AssetManager.GetModelForPlayer(player.ID) + if !found { return } player.Model = modelAsset.Model - player.Texture = modelAsset.Texture player.PlaceholderColor = modelAsset.PlaceholderColor + + // Initialize animations if available + if len(modelAsset.Animations.Idle) > 0 || len(modelAsset.Animations.Walk) > 0 { + player.InitializeAnimations(modelAsset.Animations) + } } func (g *Game) QuitChan() <-chan struct{} { diff --git a/game/pathfinding.go b/game/pathfinding.go index ce59e31..b4f82b8 100644 --- a/game/pathfinding.go +++ b/game/pathfinding.go @@ -1,91 +1,157 @@ package game import ( + "container/heap" "fmt" "gitea.boner.be/bdnugget/goonscape/types" ) +// Node represents a node in the A* pathfinding algorithm type Node struct { Tile types.Tile Parent *Node - G, H, F float32 + G, H, F float32 // G = cost from start, H = heuristic to goal, F = G + H } +// PriorityQueue implements a min-heap for nodes ordered by F value +type PriorityQueue []*Node + +// Implement the heap.Interface for PriorityQueue +func (pq PriorityQueue) Len() int { return len(pq) } + +func (pq PriorityQueue) Less(i, j int) bool { + return pq[i].F < pq[j].F +} + +func (pq PriorityQueue) Swap(i, j int) { + pq[i], pq[j] = pq[j], pq[i] +} + +func (pq *PriorityQueue) Push(x interface{}) { + item := x.(*Node) + *pq = append(*pq, item) +} + +func (pq *PriorityQueue) Pop() interface{} { + old := *pq + n := len(old) + item := old[n-1] + *pq = old[0 : n-1] + return item +} + +// Helper to check if tile is in priority queue +func isInQueue(queue *PriorityQueue, tile types.Tile) (bool, *Node) { + for _, node := range *queue { + if node.Tile.X == tile.X && node.Tile.Y == tile.Y { + return true, node + } + } + return false, nil +} + +// FindPath implements A* pathfinding algorithm with a priority queue func FindPath(start, end types.Tile) []types.Tile { - openList := []*Node{} - closedList := make(map[[2]int]bool) + // Initialize open and closed sets + openSet := &PriorityQueue{} + heap.Init(openSet) - startNode := &Node{Tile: start, G: 0, H: heuristic(start, end)} + closedSet := make(map[[2]int]bool) + + // Create start node and add to open set + startNode := &Node{ + Tile: start, + Parent: nil, + G: 0, + H: heuristic(start, end), + } startNode.F = startNode.G + startNode.H - openList = append(openList, startNode) + heap.Push(openSet, startNode) - for len(openList) > 0 { - current := openList[0] - currentIndex := 0 - for i, node := range openList { - if node.F < current.F { - current = node - currentIndex = i - } - } - - openList = append(openList[:currentIndex], openList[currentIndex+1:]...) - closedList[[2]int{current.Tile.X, current.Tile.Y}] = true + // Main search loop + for openSet.Len() > 0 { + // Get node with lowest F score + current := heap.Pop(openSet).(*Node) + // If we reached the goal, reconstruct and return the path if current.Tile.X == end.X && current.Tile.Y == end.Y { - path := []types.Tile{} - node := current - for node != nil { - path = append([]types.Tile{node.Tile}, path...) - node = node.Parent - } - fmt.Printf("Path found: %v\n", path) - return path + return reconstructPath(current) } - neighbors := GetNeighbors(current.Tile) - for _, neighbor := range neighbors { - if !neighbor.Walkable || closedList[[2]int{neighbor.X, neighbor.Y}] { + // Add current to closed set + closedSet[[2]int{current.Tile.X, current.Tile.Y}] = true + + // Check all neighbors + for _, neighbor := range GetNeighbors(current.Tile) { + // Skip if in closed set or not walkable + if !neighbor.Walkable || closedSet[[2]int{neighbor.X, neighbor.Y}] { continue } + // Calculate tentative G score tentativeG := current.G + distance(current.Tile, neighbor) - inOpen := false - var existingNode *Node - for _, node := range openList { - if node.Tile.X == neighbor.X && node.Tile.Y == neighbor.Y { - existingNode = node - inOpen = true - break - } - } + // Check if in open set + inOpen, existingNode := isInQueue(openSet, neighbor) + + // If not in open set or better path found if !inOpen || tentativeG < existingNode.G { - newNode := &Node{ - Tile: neighbor, - Parent: current, - G: tentativeG, - H: heuristic(neighbor, end), + // Create or update the node + var neighborNode *Node + if inOpen { + neighborNode = existingNode + } else { + neighborNode = &Node{ + Tile: neighbor, + Parent: current, + } } - newNode.F = newNode.G + newNode.H + + // Update scores + neighborNode.G = tentativeG + neighborNode.H = heuristic(neighbor, end) + neighborNode.F = neighborNode.G + neighborNode.H + neighborNode.Parent = current + + // Add to open set if not already there if !inOpen { - openList = append(openList, newNode) + heap.Push(openSet, neighborNode) } } } } + + // No path found return nil } +// reconstructPath builds the path from goal node to start +func reconstructPath(node *Node) []types.Tile { + path := []types.Tile{} + current := node + + // Follow parent pointers back to start + for current != nil { + path = append([]types.Tile{current.Tile}, path...) + current = current.Parent + } + + fmt.Printf("Path found: %v\n", path) + return path +} + +// heuristic estimates cost from current to goal (Manhattan distance) func heuristic(a, b types.Tile) float32 { return float32(abs(a.X-b.X) + abs(a.Y-b.Y)) } +// distance calculates cost between adjacent tiles func distance(a, b types.Tile) float32 { return 1.0 // uniform cost for now } +// GetNeighbors returns walkable tiles adjacent to the given tile func GetNeighbors(tile types.Tile) []types.Tile { directions := [][2]int{ {1, 0}, {-1, 0}, {0, 1}, {0, -1}, @@ -104,6 +170,7 @@ func GetNeighbors(tile types.Tile) []types.Tile { return neighbors } +// abs returns the absolute value of x func abs(x int) int { if x < 0 { return -x diff --git a/game/utils.go b/game/utils.go index a827e21..08dad63 100644 --- a/game/utils.go +++ b/game/utils.go @@ -1,9 +1,36 @@ package game import ( + "fmt" + "log" + "runtime/debug" + rl "github.com/gen2brain/raylib-go/raylib" ) +// SafeExecute runs a function and recovers from panics +func SafeExecute(action func() error) (err error) { + defer func() { + if r := recover(); r != nil { + stack := debug.Stack() + log.Printf("Recovered from panic: %v\nStack trace:\n%s", r, stack) + err = fmt.Errorf("recovered from panic: %v", r) + } + }() + return action() +} + +// SafeExecuteVoid runs a void function and recovers from panics +func SafeExecuteVoid(action func()) { + defer func() { + if r := recover(); r != nil { + stack := debug.Stack() + log.Printf("Recovered from panic: %v\nStack trace:\n%s", r, stack) + } + }() + action() +} + func RayIntersectsBox(ray rl.Ray, boxMin, boxMax rl.Vector3) bool { tmin := (boxMin.X - ray.Position.X) / ray.Direction.X tmax := (boxMax.X - ray.Position.X) / ray.Direction.X diff --git a/network/network.go b/network/network.go index de3b361..013f444 100644 --- a/network/network.go +++ b/network/network.go @@ -26,6 +26,138 @@ func SetServerAddr(addr string) { log.Printf("Server address set to: %s", serverAddr) } +// MessageHandler handles reading and writing protobuf messages +type MessageHandler struct { + conn net.Conn + reader *bufio.Reader +} + +// NewMessageHandler creates a new message handler +func NewMessageHandler(conn net.Conn) *MessageHandler { + return &MessageHandler{ + conn: conn, + reader: bufio.NewReader(conn), + } +} + +// ReadMessage reads a single message from the network +func (mh *MessageHandler) ReadMessage() (*pb.ServerMessage, error) { + // Read message length + lengthBuf := make([]byte, 4) + if _, err := io.ReadFull(mh.reader, lengthBuf); err != nil { + return nil, fmt.Errorf("failed to read message length: %v", err) + } + + messageLength := binary.BigEndian.Uint32(lengthBuf) + + // Sanity check message size + if messageLength > 1024*1024 { // 1MB max message size + return nil, fmt.Errorf("message size too large: %d bytes", messageLength) + } + + // Read message body + messageBuf := make([]byte, messageLength) + if _, err := io.ReadFull(mh.reader, messageBuf); err != nil { + return nil, fmt.Errorf("failed to read message body: %v", err) + } + + // Unmarshal the message + var message pb.ServerMessage + if err := proto.Unmarshal(messageBuf, &message); err != nil { + return nil, fmt.Errorf("failed to unmarshal message: %v", err) + } + + return &message, nil +} + +// WriteMessage writes a protobuf message to the network +func (mh *MessageHandler) WriteMessage(msg proto.Message) error { + data, err := proto.Marshal(msg) + if err != nil { + return err + } + + // Write length prefix + lengthBuf := make([]byte, 4) + binary.BigEndian.PutUint32(lengthBuf, uint32(len(data))) + if _, err := mh.conn.Write(lengthBuf); err != nil { + return err + } + + // Write message body + _, err = mh.conn.Write(data) + return err +} + +// UpdateGameState processes a server message and updates game state +func UpdateGameState(serverMessage *pb.ServerMessage, player *types.Player, otherPlayers map[int32]*types.Player) { + playerID := player.ID + + player.Lock() + player.CurrentTick = serverMessage.CurrentTick + + tickDiff := serverMessage.CurrentTick - player.CurrentTick + if tickDiff > types.MaxTickDesync { + for _, state := range serverMessage.Players { + if state.PlayerId == playerID { + player.ForceResync(state) + break + } + } + } + player.Unlock() + + // Process player states + validPlayerIds := make(map[int32]bool) + for _, state := range serverMessage.Players { + validPlayerIds[state.PlayerId] = true + + if state.PlayerId == playerID { + player.Lock() + // Update initial position if not set + if player.PosActual.X == 0 && player.PosActual.Z == 0 { + player.PosActual = rl.Vector3{ + X: float32(state.X * types.TileSize), + Y: 0, + Z: float32(state.Y * types.TileSize), + } + player.PosTile = types.Tile{X: int(state.X), Y: int(state.Y)} + } + player.Unlock() + continue + } + + // Update or create other players + if otherPlayer, exists := otherPlayers[state.PlayerId]; exists { + otherPlayer.UpdatePosition(state, types.ServerTickRate) + } else { + log.Printf("Creating new player with ID: %d", state.PlayerId) + otherPlayers[state.PlayerId] = types.NewPlayer(state) + } + } + + // Remove players no longer in the server state + for id := range otherPlayers { + if id != playerID && !validPlayerIds[id] { + log.Printf("Removing player with ID: %d", id) + delete(otherPlayers, id) + } + } + + // Handle chat messages + if handler, ok := player.UserData.(types.ChatMessageHandler); ok && len(serverMessage.ChatMessages) > 0 { + log.Printf("Received %d chat messages from server", len(serverMessage.ChatMessages)) + handler.HandleServerMessages(serverMessage.ChatMessages) + + // Update the last seen message timestamp to the most recent message + if len(serverMessage.ChatMessages) > 0 { + lastMsg := serverMessage.ChatMessages[len(serverMessage.ChatMessages)-1] + lastSeenMessageTimestamp = lastMsg.Timestamp + log.Printf("Updated last seen message timestamp to %d", lastSeenMessageTimestamp) + } + } +} + func ConnectToServer(username, password string, isRegistering bool) (net.Conn, int32, error) { log.Printf("Connecting to server at %s...", serverAddr) @@ -57,6 +189,9 @@ func ConnectToServer(username, password string, isRegistering bool) (net.Conn, i log.Println("Connected to server. Authenticating...") + // Create a message handler + msgHandler := NewMessageHandler(conn) + // Send auth message authAction := &pb.Action{ Type: pb.Action_LOGIN, @@ -72,45 +207,24 @@ func ConnectToServer(username, password string, isRegistering bool) (net.Conn, i ProtocolVersion: protoVersion, } - if err := writeMessage(conn, authBatch); err != nil { + if err := msgHandler.WriteMessage(authBatch); err != nil { conn.Close() return nil, 0, fmt.Errorf("failed to send auth: %v", err) } - // Read server response with timeout - reader := bufio.NewReader(conn) - // Set a read deadline for authentication conn.SetReadDeadline(time.Now().Add(10 * time.Second)) - lengthBuf := make([]byte, 4) - if _, err := io.ReadFull(reader, lengthBuf); err != nil { + // Read server response + response, err := msgHandler.ReadMessage() + if err != nil { conn.Close() return nil, 0, fmt.Errorf("failed to read auth response: %v", err) } - messageLength := binary.BigEndian.Uint32(lengthBuf) - - // Sanity check message size - if messageLength > 1024*1024 { // 1MB max message size - conn.Close() - return nil, 0, fmt.Errorf("authentication response too large: %d bytes", messageLength) - } - - messageBuf := make([]byte, messageLength) - if _, err := io.ReadFull(reader, messageBuf); err != nil { - conn.Close() - return nil, 0, fmt.Errorf("failed to read auth response body: %v", err) - } // Clear read deadline after authentication conn.SetReadDeadline(time.Time{}) - var response pb.ServerMessage - if err := proto.Unmarshal(messageBuf, &response); err != nil { - conn.Close() - return nil, 0, fmt.Errorf("failed to unmarshal auth response: %v", err) - } - if response.ProtocolVersion > protoVersion { conn.Close() return nil, 0, fmt.Errorf("server requires newer protocol version (server: %d, client: %d)", @@ -132,7 +246,8 @@ func ConnectToServer(username, password string, isRegistering bool) (net.Conn, i } func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Player, otherPlayers map[int32]*types.Player, quitChan <-chan struct{}) { - reader := bufio.NewReader(conn) + msgHandler := NewMessageHandler(conn) + defer func() { if r := recover(); r != nil { log.Printf("Recovered from panic in HandleServerCommunication: %v", r) @@ -176,7 +291,7 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play PlayerId: playerID, }}, } - writeMessage(conn, disconnectMsg) + msgHandler.WriteMessage(disconnectMsg) done <- struct{}{} return case <-done: @@ -190,7 +305,7 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play PlayerId: playerID, LastSeenMessageTimestamp: lastSeenMessageTimestamp, } - if err := writeMessage(conn, emptyBatch); err != nil { + if err := msgHandler.WriteMessage(emptyBatch); err != nil { log.Printf("Failed to send heartbeat: %v", err) errChan <- err return @@ -211,7 +326,7 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play player.ActionQueue = player.ActionQueue[:0] player.Unlock() - if err := writeMessage(conn, batch); err != nil { + if err := msgHandler.WriteMessage(batch); err != nil { errChan <- err return } @@ -237,101 +352,21 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play case <-quitChan: return default: - lengthBuf := make([]byte, 4) - if _, err := io.ReadFull(reader, lengthBuf); err != nil { - if err != io.EOF { + serverMessage, err := msgHandler.ReadMessage() + if err != nil { + if err, ok := err.(net.Error); ok && err.Timeout() { + log.Printf("Network timeout: %v", err) + } else if err != io.EOF { log.Printf("Network read error: %v", err) - errChan <- fmt.Errorf("failed to read message length: %v", err) + errChan <- err } else { log.Printf("Connection closed by server") } return } - messageLength := binary.BigEndian.Uint32(lengthBuf) - // Sanity check message size to prevent potential memory issues - if messageLength > 1024*1024 { // 1MB max message size - log.Printf("Message size too large: %d bytes", messageLength) - errChan <- fmt.Errorf("message size too large: %d bytes", messageLength) - return - } - - messageBuf := make([]byte, messageLength) - if _, err := io.ReadFull(reader, messageBuf); err != nil { - log.Printf("Failed to read message body: %v", err) - errChan <- fmt.Errorf("failed to read message body: %v", err) - return - } - - var serverMessage pb.ServerMessage - if err := proto.Unmarshal(messageBuf, &serverMessage); err != nil { - log.Printf("Failed to unmarshal server message: %v", err) - continue // Skip this message but don't quit - } - - player.Lock() - player.CurrentTick = serverMessage.CurrentTick - - tickDiff := serverMessage.CurrentTick - player.CurrentTick - if tickDiff > types.MaxTickDesync { - for _, state := range serverMessage.Players { - if state.PlayerId == playerID { - player.ForceResync(state) - break - } - } - } - player.Unlock() - - // Process player states - validPlayerIds := make(map[int32]bool) - for _, state := range serverMessage.Players { - validPlayerIds[state.PlayerId] = true - - if state.PlayerId == playerID { - player.Lock() - // Update initial position if not set - if player.PosActual.X == 0 && player.PosActual.Z == 0 { - player.PosActual = rl.Vector3{ - X: float32(state.X * types.TileSize), - Y: 0, - Z: float32(state.Y * types.TileSize), - } - player.PosTile = types.Tile{X: int(state.X), Y: int(state.Y)} - } - player.Unlock() - continue - } - - // Update or create other players - if otherPlayer, exists := otherPlayers[state.PlayerId]; exists { - otherPlayer.UpdatePosition(state, types.ServerTickRate) - } else { - log.Printf("Creating new player with ID: %d", state.PlayerId) - otherPlayers[state.PlayerId] = types.NewPlayer(state) - } - } - - // Remove players no longer in the server state - for id := range otherPlayers { - if id != playerID && !validPlayerIds[id] { - log.Printf("Removing player with ID: %d", id) - delete(otherPlayers, id) - } - } - - // Handle chat messages - if handler, ok := player.UserData.(types.ChatMessageHandler); ok && len(serverMessage.ChatMessages) > 0 { - log.Printf("Received %d chat messages from server", len(serverMessage.ChatMessages)) - handler.HandleServerMessages(serverMessage.ChatMessages) - - // Update the last seen message timestamp to the most recent message - if len(serverMessage.ChatMessages) > 0 { - lastMsg := serverMessage.ChatMessages[len(serverMessage.ChatMessages)-1] - lastSeenMessageTimestamp = lastMsg.Timestamp - log.Printf("Updated last seen message timestamp to %d", lastSeenMessageTimestamp) - } - } + // Process the server message + UpdateGameState(serverMessage, player, otherPlayers) } } }() @@ -348,7 +383,7 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play PlayerId: playerID, }}, } - writeMessage(conn, disconnectMsg) + msgHandler.WriteMessage(disconnectMsg) close(done) case err := <-errChan: log.Printf("Network error: %v", err) @@ -358,21 +393,8 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play // Helper function to write length-prefixed messages func writeMessage(conn net.Conn, msg proto.Message) error { - data, err := proto.Marshal(msg) - if err != nil { - return err - } - - // Write length prefix - lengthBuf := make([]byte, 4) - binary.BigEndian.PutUint32(lengthBuf, uint32(len(data))) - if _, err := conn.Write(lengthBuf); err != nil { - return err - } - - // Write message body - _, err = conn.Write(data) - return err + msgHandler := NewMessageHandler(conn) + return msgHandler.WriteMessage(msg) } type Connection struct { diff --git a/types/player.go b/types/player.go index 9ad4ef6..2302add 100644 --- a/types/player.go +++ b/types/player.go @@ -8,6 +8,66 @@ import ( rl "github.com/gen2brain/raylib-go/raylib" ) +// AnimationController manages animation state and updates +type AnimationController struct { + animations AnimationSet + currentAnimation string // "idle" or "walk" + frame int32 + lastUpdate time.Time + frameCount int32 +} + +// NewAnimationController creates a new animation controller +func NewAnimationController(animations AnimationSet) *AnimationController { + return &AnimationController{ + animations: animations, + currentAnimation: "idle", + frame: 0, + lastUpdate: time.Now(), + } +} + +// Update updates the animation state based on movement +func (ac *AnimationController) Update(deltaTime float32, isMoving bool) { + // Set the current animation based on movement + newAnimation := "idle" + if isMoving { + newAnimation = "walk" + } + + // Reset frame counter when animation changes + if ac.currentAnimation != newAnimation { + ac.frame = 0 + ac.currentAnimation = newAnimation + } + + // Update the frame + ac.frame += int32(deltaTime * 60) + + // Determine which animation set to use + var frames []rl.ModelAnimation + if ac.currentAnimation == "walk" && len(ac.animations.Walk) > 0 { + frames = ac.animations.Walk + } else if len(ac.animations.Idle) > 0 { + frames = ac.animations.Idle + } + + // If we have frames, ensure we loop properly + if len(frames) > 0 && frames[0].FrameCount > 0 { + ac.frame = ac.frame % frames[0].FrameCount + } +} + +// GetAnimFrame returns the current animation frame +func (ac *AnimationController) GetAnimFrame() int32 { + return ac.frame +} + +// GetCurrentAnimation returns the current animation type +func (ac *AnimationController) GetCurrentAnimation() string { + return ac.currentAnimation +} + type Player struct { sync.RWMutex // Keep this for network operations Model rl.Model @@ -28,6 +88,7 @@ type Player struct { LastUpdateTime time.Time InterpolationProgress float32 PlaceholderColor rl.Color + AnimController *AnimationController } func (p *Player) MoveTowards(target Tile, deltaTime float32, mapGrid [][]Tile) { @@ -42,29 +103,29 @@ func (p *Player) MoveTowards(target Tile, deltaTime float32, mapGrid [][]Tile) { distance := rl.Vector3Length(direction) if distance > 1.0 { - wasMoving := p.IsMoving p.IsMoving = true - - if !wasMoving { - p.AnimationFrame = 0 - } - - oldFrame := p.AnimationFrame - p.AnimationFrame += int32(deltaTime * 60) - rl.TraceLog(rl.LogDebug, "Walk frame update: %d -> %d (delta: %f)", - oldFrame, p.AnimationFrame, deltaTime) } else { - wasMoving := p.IsMoving p.IsMoving = false + } - if wasMoving { - p.AnimationFrame = 0 + // Update animation if controller exists + if p.AnimController != nil { + p.AnimController.Update(deltaTime, p.IsMoving) + p.AnimationFrame = p.AnimController.GetAnimFrame() + } else { + // Legacy animation update for backward compatibility + if p.IsMoving { + if !p.IsMoving { + p.AnimationFrame = 0 + } + p.AnimationFrame += int32(deltaTime * 60) + } else { + wasMoving := p.IsMoving + if wasMoving { + p.AnimationFrame = 0 + } + p.AnimationFrame += int32(deltaTime * 60) } - - oldFrame := p.AnimationFrame - p.AnimationFrame += int32(deltaTime * 60) - rl.TraceLog(rl.LogDebug, "Idle frame update: %d -> %d (delta: %f)", - oldFrame, p.AnimationFrame, deltaTime) } if distance > 0 { @@ -100,6 +161,11 @@ func NewPlayer(state *pb.PlayerState) *Player { } } +// InitializeAnimations sets up the animation controller for the player +func (p *Player) InitializeAnimations(animations AnimationSet) { + p.AnimController = NewAnimationController(animations) +} + func (p *Player) UpdatePosition(state *pb.PlayerState, tickRate time.Duration) { p.Lock() defer p.Unlock() diff --git a/types/types.go b/types/types.go index a8b09c2..b656819 100644 --- a/types/types.go +++ b/types/types.go @@ -59,3 +59,12 @@ const ( ClientTickRate = 50 * time.Millisecond MaxTickDesync = 5 ) + +// UI constants +const ( + ChatMargin = 10 + ChatHeight = 200 + MessageHeight = 20 + InputHeight = 30 + MaxChatMessages = 50 +)