diff --git a/assets/assets.go b/assets/assets.go index 0fea9ae..512bbe4 100644 --- a/assets/assets.go +++ b/assets/assets.go @@ -1,14 +1,88 @@ package assets import ( + "fmt" + "os" + "gitea.boner.be/bdnugget/goonscape/types" 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 + // Only try to load animations if environment variable isn't set + if os.Getenv("GOONSCAPE_DISABLE_ANIMATIONS") == "1" { + return animSet, nil + } + // Load idle animations if specified if idlePath, ok := animPaths["idle"]; ok { idleAnims := rl.LoadModelAnimations(idlePath) @@ -32,56 +106,190 @@ func loadModelAnimations(animPaths map[string]string) (types.AnimationSet, error return animSet, nil } +// ValidateModel checks if a model is valid and properly loaded +func ValidateModel(model rl.Model) error { + if model.Meshes == nil { + return fmt.Errorf("model has nil meshes") + } + if model.Meshes.VertexCount <= 0 { + return fmt.Errorf("model has invalid vertex count") + } + return nil +} + +// CompletelyAvoidExternalModels determines if we should avoid loading external models +func CompletelyAvoidExternalModels() bool { + return os.Getenv("GOONSCAPE_SAFE_MODE") == "1" +} + +// 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) { + loader := NewModelLoader() + return loader.LoadModel(fileName, fallbackShape, fallbackColor) +} + func LoadModels() ([]types.ModelAsset, error) { - // Goonion model and animations - goonerModel := rl.LoadModel("resources/models/gooner/walk_no_y_transform.glb") - goonerAnims, _ := loadModelAnimations(map[string]string{"idle": "resources/models/gooner/idle_no_y_transform.glb", "walk": "resources/models/gooner/walk_no_y_transform.glb"}) + // Force safe mode for now until we fix the segfault + os.Setenv("GOONSCAPE_SAFE_MODE", "1") - // Apply transformations - transform := rl.MatrixIdentity() - transform = rl.MatrixMultiply(transform, rl.MatrixRotateY(180*rl.Deg2rad)) - transform = rl.MatrixMultiply(transform, rl.MatrixRotateX(-90*rl.Deg2rad)) - transform = rl.MatrixMultiply(transform, rl.MatrixScale(1.0, 1.0, 1.0)) - goonerModel.Transform = transform + models := make([]types.ModelAsset, 0, 3) + modelLoader := NewModelLoader() - // Coomer model (ready for animations) - coomerModel := rl.LoadModel("resources/models/coomer/idle_notransy.glb") - // coomerTexture := rl.LoadTexture("resources/models/coomer.png") - // rl.SetMaterialTexture(coomerModel.Materials, rl.MapDiffuse, coomerTexture) - // When you have animations, add them like: - coomerAnims, _ := loadModelAnimations(map[string]string{"idle": "resources/models/coomer/idle_notransy.glb", "walk": "resources/models/coomer/unsteadywalk_notransy.glb"}) - coomerModel.Transform = transform + // Colors for the different models + goonerColor := rl.Color{R: 255, G: 200, B: 200, A: 255} // Pinkish + coomerColor := rl.Color{R: 200, G: 230, B: 255, A: 255} // Light blue + shrekeColor := rl.Color{R: 180, G: 255, B: 180, A: 255} // Light green - // Shreke model (ready for animations) - shrekeModel := rl.LoadModel("resources/models/shreke.obj") - shrekeTexture := rl.LoadTexture("resources/models/shreke.png") - rl.SetMaterialTexture(shrekeModel.Materials, rl.MapDiffuse, shrekeTexture) - // When you have animations, add them like: - // shrekeAnims, _ := loadModelAnimations("resources/models/shreke.glb", - // map[string]string{ - // "idle": "resources/models/shreke_idle.glb", - // "walk": "resources/models/shreke_walk.glb", - // }) + // If in safe mode, create all models directly without loading + if modelLoader.IsSafeMode() { + // Gooner model (cube) + cube := modelLoader.createPrimitiveShape(0) + models = append(models, types.ModelAsset{ + Model: cube, + YOffset: 0.0, + PlaceholderColor: goonerColor, + }) - return []types.ModelAsset{ - { - Model: goonerModel, - Animation: append(goonerAnims.Idle, goonerAnims.Walk...), - AnimFrames: int32(len(goonerAnims.Idle) + len(goonerAnims.Walk)), - Animations: goonerAnims, - YOffset: 0.0, - }, - { - Model: coomerModel, - Animation: append(coomerAnims.Idle, coomerAnims.Walk...), - AnimFrames: int32(len(coomerAnims.Idle) + len(coomerAnims.Walk)), - Animations: coomerAnims, - YOffset: -4.0, - }, - {Model: shrekeModel, Texture: shrekeTexture}, - }, nil + // Coomer model (sphere) + sphere := modelLoader.createPrimitiveShape(1) + models = append(models, types.ModelAsset{ + Model: sphere, + YOffset: -4.0, + PlaceholderColor: coomerColor, + }) + + // Shreke model (cylinder) + cylinder := modelLoader.createPrimitiveShape(2) + models = append(models, types.ModelAsset{ + Model: cylinder, + YOffset: 0.0, + PlaceholderColor: shrekeColor, + }) + + return models, nil + } + + // The rest of the function with normal model loading + // Load Goonion model with error handling + var goonerModel rl.Model + var success bool + var modelColor rl.Color + + 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 + if success { + goonerAnims, _ = loadModelAnimations(map[string]string{ + "idle": "resources/models/gooner/idle_no_y_transform.glb", + "walk": "resources/models/gooner/walk_no_y_transform.glb", + }) + + // Apply transformations + transform := rl.MatrixIdentity() + transform = rl.MatrixMultiply(transform, rl.MatrixRotateY(180*rl.Deg2rad)) + transform = rl.MatrixMultiply(transform, rl.MatrixRotateX(-90*rl.Deg2rad)) + transform = rl.MatrixMultiply(transform, rl.MatrixScale(1.0, 1.0, 1.0)) + goonerModel.Transform = transform + } + + // Always add a model (real or placeholder) + models = append(models, types.ModelAsset{ + Model: goonerModel, + Animation: append(goonerAnims.Idle, goonerAnims.Walk...), + AnimFrames: int32(len(goonerAnims.Idle) + len(goonerAnims.Walk)), + Animations: goonerAnims, + YOffset: 0.0, + PlaceholderColor: modelColor, + }) + + // Coomer model with safe loading - using a sphere shape + var coomerModel rl.Model + coomerModel, success, modelColor = modelLoader.LoadModel("resources/models/coomer/idle_notransy.glb", 1, coomerColor) + + if success { + // Only load animations if the model loaded successfully + coomerAnims, _ := loadModelAnimations(map[string]string{ + "idle": "resources/models/coomer/idle_notransy.glb", + "walk": "resources/models/coomer/unsteadywalk_notransy.glb", + }) + + // Apply transformations + transform := rl.MatrixIdentity() + transform = rl.MatrixMultiply(transform, rl.MatrixRotateY(180*rl.Deg2rad)) + transform = rl.MatrixMultiply(transform, rl.MatrixRotateX(-90*rl.Deg2rad)) + transform = rl.MatrixMultiply(transform, rl.MatrixScale(1.0, 1.0, 1.0)) + coomerModel.Transform = transform + + models = append(models, types.ModelAsset{ + Model: coomerModel, + Animation: append(coomerAnims.Idle, coomerAnims.Walk...), + AnimFrames: int32(len(coomerAnims.Idle) + len(coomerAnims.Walk)), + Animations: coomerAnims, + YOffset: -4.0, + PlaceholderColor: rl.Color{}, // Not a placeholder + }) + } else { + // Add a placeholder with different shape/color + models = append(models, types.ModelAsset{ + Model: coomerModel, + YOffset: -4.0, + PlaceholderColor: modelColor, + }) + } + + // Shreke model with safe loading - using a cylinder shape + var shrekeModel rl.Model + shrekeModel, success, modelColor = modelLoader.LoadModel("resources/models/shreke.obj", 2, shrekeColor) + + if success { + // Only proceed with texture if model loaded + shrekeTexture := rl.LoadTexture("resources/models/shreke.png") + if shrekeTexture.ID <= 0 { + rl.TraceLog(rl.LogWarning, "Failed to load shreke texture") + } else { + rl.SetMaterialTexture(shrekeModel.Materials, rl.MapDiffuse, shrekeTexture) + + models = append(models, types.ModelAsset{ + Model: shrekeModel, + Texture: shrekeTexture, + YOffset: 0.0, + PlaceholderColor: rl.Color{}, // Not a placeholder + }) + } + } else { + // Add another placeholder with different shape/color + models = append(models, types.ModelAsset{ + Model: shrekeModel, + YOffset: 0.0, + PlaceholderColor: modelColor, + }) + } + + if len(models) == 0 { + return nil, fmt.Errorf("failed to load any models") + } + + return models, nil } func LoadMusic(filename string) (rl.Music, error) { - return rl.LoadMusicStream(filename), nil + defer func() { + // Recover from any panics during music loading + if r := recover(); r != nil { + rl.TraceLog(rl.LogError, "Panic in LoadMusic: %v", r) + } + }() + + // Skip loading music if environment variable is set + if os.Getenv("GOONSCAPE_DISABLE_AUDIO") == "1" { + rl.TraceLog(rl.LogInfo, "Audio disabled, skipping music loading") + return rl.Music{}, fmt.Errorf("audio disabled") + } + + music := rl.LoadMusicStream(filename) + if music.Stream.Buffer == nil { + return music, fmt.Errorf("failed to load music: %s", filename) + } + return music, nil } diff --git a/constants.go b/constants.go index dcab734..7e2ba93 100644 --- a/constants.go +++ b/constants.go @@ -2,20 +2,33 @@ package main import "time" +// Game world constants const ( + // Server-related constants + ServerTickRate = 600 * time.Millisecond // RuneScape-style tick rate + 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 - TickRate = 600 * time.Millisecond // Server tick rate (600ms) - serverAddr = "localhost:6969" - - // RuneScape-style tick rate (600ms) - ServerTickRate = 600 * time.Millisecond - - // Client might run at a higher tick rate for smooth rendering - ClientTickRate = 50 * time.Millisecond - - // Maximum number of ticks we can get behind before forcing a resync - MaxTickDesync = 5 +) + +// 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 f6b3ce9..098f9ac 100644 --- a/game/chat.go +++ b/game/chat.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "log" "sync" "time" @@ -10,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 { @@ -31,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), } } @@ -43,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) @@ -54,8 +51,20 @@ func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) { c.mutex.Lock() defer c.mutex.Unlock() + if len(messages) == 0 { + return + } + + log.Printf("Processing %d chat messages", len(messages)) + // Convert protobuf messages to our local type for _, msg := range messages { + // Skip invalid messages + if msg == nil { + log.Printf("Warning: Received nil chat message") + continue + } + localMsg := types.ChatMessage{ PlayerID: msg.PlayerId, Username: msg.Username, @@ -65,33 +74,54 @@ 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 } // Add floating message to the player - if game, ok := c.userData.(*Game); ok { - if msg.PlayerId == game.Player.ID { - game.Player.Lock() - game.Player.FloatingMessage = &types.FloatingMessage{ + if game, ok := c.userData.(*Game); ok && game != nil { + // Make sure each game component exists before using it + if game.PlayerManager == nil { + log.Printf("Warning: PlayerManager is nil when processing chat message") + continue + } + + if msg.PlayerId == game.PlayerManager.LocalPlayer.ID { + // Check if local player exists + if game.PlayerManager.LocalPlayer == nil { + log.Printf("Warning: Local player is nil when trying to add floating message") + continue + } + + game.PlayerManager.LocalPlayer.Lock() + game.PlayerManager.LocalPlayer.FloatingMessage = &types.FloatingMessage{ Content: msg.Content, ExpireTime: time.Now().Add(6 * time.Second), } - game.Player.Unlock() - } else if otherPlayer, exists := game.OtherPlayers[msg.PlayerId]; exists { - otherPlayer.Lock() - otherPlayer.FloatingMessage = &types.FloatingMessage{ + game.PlayerManager.LocalPlayer.Unlock() + } else { + // The other player might not be in our list yet, handle safely + player := game.PlayerManager.GetPlayer(msg.PlayerId) + if player == nil { + log.Printf("Could not find other player %d to add floating message (player not in game yet)", msg.PlayerId) + continue + } + + player.Lock() + player.FloatingMessage = &types.FloatingMessage{ Content: msg.Content, ExpireTime: time.Now().Add(6 * time.Second), } - otherPlayer.Unlock() + player.Unlock() + log.Printf("Added floating message to other player %d", msg.PlayerId) } } } @@ -103,16 +133,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 { @@ -134,12 +164,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) @@ -157,7 +187,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 9df3433..84ac39a 100644 --- a/game/game.go +++ b/game/game.go @@ -1,7 +1,7 @@ package game import ( - "fmt" + "log" "sync" "time" @@ -13,129 +13,213 @@ 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{}), + // Initialize empty maps to avoid nil references + OtherPlayers: make(map[int32]*types.Player), } - 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{ - Speed: 50.0, - TargetPath: []types.Tile{}, - UserData: g, - QuitDone: make(chan struct{}), - ID: playerID, + g.PlayerManager.LocalPlayer = &types.Player{ + Speed: 50.0, + TargetPath: []types.Tile{}, + ActionQueue: []*pb.Action{}, + 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 + // Update the legacy Player field + g.Player = g.PlayerManager.LocalPlayer + + // Set user data to allow chat message handling + g.PlayerManager.LocalPlayer.UserData = g + + go network.HandleServerCommunication(conn, playerID, g.PlayerManager.LocalPlayer, g.PlayerManager.OtherPlayers, g.quitChan) + g.UIManager.IsLoggedIn = true } return } + // Skip update logic if player is not initialized yet + if g.PlayerManager.LocalPlayer == nil { + log.Printf("Warning: LocalPlayer is nil during update, skipping") + 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{ + // Handle chat updates + 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() } + // Process player input g.HandleInput() - if len(g.Player.TargetPath) > 0 { - g.Player.MoveTowards(g.Player.TargetPath[0], deltaTime, GetMapGrid()) + // Update local player movement + if g.PlayerManager.LocalPlayer.TargetPath != nil && len(g.PlayerManager.LocalPlayer.TargetPath) > 0 { + g.PlayerManager.LocalPlayer.MoveTowards(g.PlayerManager.LocalPlayer.TargetPath[0], deltaTime, GetMapGrid()) } - for _, other := range g.OtherPlayers { - if len(other.TargetPath) > 0 { - other.MoveTowards(other.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.PlayerManager.OtherPlayers)) + for id, other := range g.PlayerManager.OtherPlayers { + if other != nil { + 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) + } else { + rl.TraceLog(rl.LogInfo, "Other player ID: %d is nil", id) + } } } - UpdateCamera(&g.Camera, g.Player.PosActual, deltaTime) + // Process other players + for _, other := range g.PlayerManager.OtherPlayers { + if other == nil { + continue + } + + if other.TargetPath != nil && len(other.TargetPath) > 0 { + target := other.TargetPath[0] + other.MoveTowards(target, deltaTime, GetMapGrid()) + } + + // Assign model if needed + if other.Model.Meshes == nil { + g.AssignModelToPlayer(other) + } + } + + // Update camera position + 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() { @@ -167,11 +251,21 @@ func (g *Game) DrawMap() { } func (g *Game) DrawPlayer(player *types.Player, model rl.Model) { - player.Lock() - defer player.Unlock() + // No need for lock in rendering, we'll use a "take snapshot" approach + // This avoids potential deadlocks and makes the rendering more consistent + + // Check for invalid model + if model.Meshes == nil || model.Meshes.VertexCount <= 0 { + // Don't try to draw invalid models + return + } grid := GetMapGrid() modelIndex := int(player.ID) % len(g.Models) + if modelIndex < 0 || modelIndex >= len(g.Models) { + // Prevent out of bounds access + modelIndex = 0 + } modelAsset := g.Models[modelIndex] const defaultHeight = 8.0 // Default height above tile, fine tune per model in types.ModelAsset @@ -185,16 +279,25 @@ func (g *Game) DrawPlayer(player *types.Player, model rl.Model) { if modelAsset.Animations.Idle != nil || modelAsset.Animations.Walk != nil { if player.IsMoving && len(modelAsset.Animations.Walk) > 0 { anim := modelAsset.Animations.Walk[0] // Use first walk animation - player.AnimationFrame = player.AnimationFrame % anim.FrameCount - rl.UpdateModelAnimation(model, anim, player.AnimationFrame) + if anim.FrameCount > 0 { + currentFrame := player.AnimationFrame % anim.FrameCount + rl.UpdateModelAnimation(model, anim, currentFrame) + } } else if len(modelAsset.Animations.Idle) > 0 { anim := modelAsset.Animations.Idle[0] // Use first idle animation - player.AnimationFrame = player.AnimationFrame % anim.FrameCount - rl.UpdateModelAnimation(model, anim, player.AnimationFrame) + if anim.FrameCount > 0 { + currentFrame := player.AnimationFrame % anim.FrameCount + rl.UpdateModelAnimation(model, anim, currentFrame) + } } } - rl.DrawModel(model, playerPos, 16, rl.White) + // Use placeholder color if it's set, otherwise use white + var drawColor rl.Color = rl.White + if player.PlaceholderColor.A > 0 { + drawColor = player.PlaceholderColor + } + rl.DrawModel(model, playerPos, 16, drawColor) // Draw floating messages and path indicators if player.FloatingMessage != nil { @@ -228,20 +331,43 @@ func (g *Game) DrawPlayer(player *types.Player, model rl.Model) { func (g *Game) Render() { rl.BeginDrawing() + defer func() { + // This defer will catch any panics that might occur during rendering + // and ensure EndDrawing gets called to maintain proper graphics state + if r := recover(); r != nil { + rl.TraceLog(rl.LogError, "Panic during rendering: %v", r) + } + rl.EndDrawing() + }() + rl.ClearBackground(rl.RayWhite) - if !g.isLoggedIn { - g.loginScreen.Draw() - rl.EndDrawing() + if !g.UIManager.IsLoggedIn { + g.UIManager.LoginScreen.Draw() return } rl.BeginMode3D(g.Camera) g.DrawMap() - g.DrawPlayer(g.Player, g.Player.Model) - for _, other := range g.OtherPlayers { + + // Draw player only if valid + 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.PlayerManager.OtherPlayers { + if other == nil { + continue + } + + // Make sure model is assigned if other.Model.Meshes == nil { g.AssignModelToPlayer(other) + // Skip this frame if assignment failed + if other.Model.Meshes == nil { + continue + } } g.DrawPlayer(other, other.Model) } @@ -268,59 +394,68 @@ func (g *Game) Render() { rl.DrawText(text, int32(pos.X)-textWidth/2, int32(pos.Y), 20, rl.Yellow) } - if 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 { - drawFloatingMessage(other.FloatingMessage) + 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.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) - rl.EndDrawing() } 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) + } + + // Only close the channel if it hasn't been closed yet + select { + case <-g.quitChan: + // Channel already closed, do nothing + default: + 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() } } } @@ -363,7 +498,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": @@ -384,20 +519,36 @@ func (g *Game) DrawMenu() { } func (g *Game) Shutdown() { - close(g.quitChan) + // Use the cleanup method which has channel-closing safety + g.Cleanup() } func (g *Game) HandleServerMessages(messages []*pb.ChatMessage) { - g.Chat.HandleServerMessages(messages) + // Check if Chat is properly initialized + if g.UIManager != nil && g.UIManager.Chat != nil { + g.UIManager.Chat.HandleServerMessages(messages) + } else { + log.Printf("Warning: Cannot handle server messages, Chat is not initialized") + } } func (g *Game) AssignModelToPlayer(player *types.Player) { - modelIndex := int(player.ID) % len(g.Models) - modelAsset := g.Models[modelIndex] + if player == nil { + return + } + + modelAsset, found := g.AssetManager.GetModelForPlayer(player.ID) + if !found { + return + } - // Just use the original model - don't try to copy it 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/goonserver b/goonserver index f9ec811..00aa302 160000 --- a/goonserver +++ b/goonserver @@ -1 +1 @@ -Subproject commit f9ec811b10bbab54e843199eb68156e9e7c143cc +Subproject commit 00aa3022292b8f4eec6c01522b6a91cf6769155b diff --git a/main.go b/main.go index 165291c..117a023 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "os/signal" "strings" "syscall" + "time" "gitea.boner.be/bdnugget/goonscape/game" "gitea.boner.be/bdnugget/goonscape/network" @@ -14,9 +15,12 @@ import ( ) func main() { + // Set up panic recovery at the top level defer func() { if r := recover(); r != nil { - log.Printf("Recovered from panic in main: %v", r) + log.Printf("Recovered from fatal panic in main: %v", r) + // Give the user a chance to see the error + time.Sleep(5 * time.Second) } }() @@ -46,26 +50,61 @@ func main() { network.SetServerAddr(*addr) } + // Initialize window with error handling + rl.SetConfigFlags(rl.FlagMsaa4xHint | rl.FlagWindowResizable) // Enable MSAA and make window resizable rl.InitWindow(1024, 768, "GoonScape") - rl.SetExitKey(0) - rl.InitAudioDevice() - gameInstance := game.New() - if err := gameInstance.LoadAssets(); err != nil { - log.Printf("Failed to load assets: %v", err) - return + rl.SetExitKey(0) + + // Initialize audio with error handling + if !rl.IsAudioDeviceReady() { + rl.InitAudioDevice() + if !rl.IsAudioDeviceReady() { + log.Println("Warning: Failed to initialize audio device, continuing without audio") + } + } + + // Use a maximum of 3 attempts to load assets + var gameInstance *game.Game + var loadErr error + maxAttempts := 3 + + for attempt := 1; attempt <= maxAttempts; attempt++ { + gameInstance = game.New() + loadErr = gameInstance.LoadAssets() + if loadErr == nil { + break + } + + log.Printf("Attempt %d/%d: Failed to load assets: %v", attempt, maxAttempts, loadErr) + if attempt < maxAttempts { + log.Println("Retrying...") + gameInstance.Cleanup() // Cleanup before retrying + time.Sleep(500 * time.Millisecond) + } + } + + if loadErr != nil { + log.Printf("Failed to load assets after %d attempts. Starting with default assets.", maxAttempts) } defer func() { - gameInstance.Cleanup() + if gameInstance != nil { + gameInstance.Cleanup() + } rl.CloseWindow() - rl.CloseAudioDevice() + if rl.IsAudioDeviceReady() { + rl.CloseAudioDevice() + } }() rl.SetTargetFPS(60) - rl.PlayMusicStream(gameInstance.Music) - rl.SetMusicVolume(gameInstance.Music, 0.5) + // Play music if available + if gameInstance.Music.Stream.Buffer != nil { + rl.PlayMusicStream(gameInstance.Music) + rl.SetMusicVolume(gameInstance.Music, 0.5) + } // Handle OS signals for clean shutdown sigChan := make(chan os.Signal, 1) @@ -80,7 +119,11 @@ func main() { // Keep game loop in main thread for Raylib for !rl.WindowShouldClose() { deltaTime := rl.GetFrameTime() - rl.UpdateMusicStream(gameInstance.Music) + + // Update music if available + if gameInstance.Music.Stream.Buffer != nil { + rl.UpdateMusicStream(gameInstance.Music) + } func() { defer func() { diff --git a/network/network.go b/network/network.go index cf749dd..d9913b7 100644 --- a/network/network.go +++ b/network/network.go @@ -18,21 +18,226 @@ import ( const protoVersion = 1 -var serverAddr = "boner.be:6969" +var serverAddr = "boner.be:6969" // Default server address +var lastSeenMessageTimestamp int64 = 0 // Track the last message timestamp seen by this client func SetServerAddr(addr string) { serverAddr = addr + 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) { + // Safety check for nil inputs + if serverMessage == nil { + log.Printf("Warning: Received nil server message") + return + } + + if player == nil { + log.Printf("Warning: Local player is nil when updating game state") + return + } + + if otherPlayers == nil { + log.Printf("Warning: otherPlayers map is nil when updating game state") + return + } + + 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 != nil && state.PlayerId == playerID { + player.ForceResync(state) + break + } + } + } + player.Unlock() + + // Process player states + validPlayerIds := make(map[int32]bool) + for _, state := range serverMessage.Players { + // Skip invalid player states + if state == nil { + log.Printf("Warning: Received nil player state") + continue + } + + 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 { + if otherPlayer != nil { + otherPlayer.UpdatePosition(state, types.ServerTickRate) + } else { + // Replace nil player with a new one + log.Printf("Replacing nil player with ID: %d", state.PlayerId) + otherPlayers[state.PlayerId] = types.NewPlayer(state) + } + } 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 with safety checks + if handler, ok := player.UserData.(types.ChatMessageHandler); ok && handler != nil && len(serverMessage.ChatMessages) > 0 { + log.Printf("Received %d chat messages from server", len(serverMessage.ChatMessages)) + + // Make sure we have valid chat messages + validMessages := make([]*pb.ChatMessage, 0, len(serverMessage.ChatMessages)) + for _, msg := range serverMessage.ChatMessages { + if msg != nil { + validMessages = append(validMessages, msg) + } + } + + if len(validMessages) > 0 { + // Use a separate goroutine to handle messages to prevent blocking + // network handling if there's an issue with chat processing + go func(msgs []*pb.ChatMessage) { + defer func() { + if r := recover(); r != nil { + log.Printf("Recovered from panic in chat message handler: %v", r) + } + }() + handler.HandleServerMessages(msgs) + }(validMessages) + + // Update the last seen message timestamp to the most recent message + lastMsg := validMessages[len(validMessages)-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) { - conn, err := net.Dial("tcp", serverAddr) - if err != nil { - log.Printf("Failed to dial server: %v", err) - return nil, 0, err + log.Printf("Connecting to server at %s...", serverAddr) + + var err error + var conn net.Conn + + // Try connecting with a timeout + connChan := make(chan net.Conn, 1) + errChan := make(chan error, 1) + + go func() { + c, e := net.Dial("tcp", serverAddr) + if e != nil { + errChan <- e + return + } + connChan <- c + }() + + // Wait for connection with timeout + select { + case conn = <-connChan: + // Connection successful, continue + case err = <-errChan: + return nil, 0, fmt.Errorf("failed to dial server: %v", err) + case <-time.After(5 * time.Second): + return nil, 0, fmt.Errorf("connection timeout after 5 seconds") } log.Println("Connected to server. Authenticating...") + // Create a message handler + msgHandler := NewMessageHandler(conn) + // Send auth message authAction := &pb.Action{ Type: pb.Action_LOGIN, @@ -48,31 +253,23 @@ 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) } + // Set a read deadline for authentication + conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + // Read server response - reader := bufio.NewReader(conn) - lengthBuf := make([]byte, 4) - if _, err := io.ReadFull(reader, lengthBuf); err != nil { + 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) - 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) - } - - 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) - } + // Clear read deadline after authentication + conn.SetReadDeadline(time.Time{}) if response.ProtocolVersion > protoVersion { conn.Close() @@ -87,35 +284,72 @@ func ConnectToServer(username, password string, isRegistering bool) (net.Conn, i playerID := response.GetPlayerId() log.Printf("Successfully authenticated with player ID: %d", playerID) + + // Reset the lastSeenMessageTimestamp when reconnecting + lastSeenMessageTimestamp = 0 + return conn, playerID, nil } 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) + + // Create channels for coordinating goroutines + errChan := make(chan error, 1) + done := make(chan struct{}) + + // Create a WaitGroup to track both sender and receiver goroutines + var wg sync.WaitGroup + wg.Add(2) // One for sender, one for receiver + + // Set up a deferred cleanup function defer func() { if r := recover(); r != nil { log.Printf("Recovered from panic in HandleServerCommunication: %v", r) } + + // Close the done channel to signal both goroutines to exit + close(done) + + // Wait for both goroutines to finish + wg.Wait() + + // Close the connection conn.Close() + + // Close the player's QuitDone channel if it exists if player.QuitDone != nil { - close(player.QuitDone) + select { + case <-player.QuitDone: // Check if it's already closed + // Already closed, do nothing + default: + close(player.QuitDone) + } } }() actionTicker := time.NewTicker(types.ClientTickRate) defer actionTicker.Stop() - // Create error channel for goroutine communication - errChan := make(chan error, 1) - done := make(chan struct{}) + // Add a heartbeat ticker to detect connection issues + heartbeatTicker := time.NewTicker(5 * time.Second) + defer heartbeatTicker.Stop() + + lastMessageTime := time.Now() // Start message sending goroutine go func() { defer func() { if r := recover(); r != nil { log.Printf("Recovered from panic in message sender: %v", r) - errChan <- fmt.Errorf("message sender panic: %v", r) + select { + case errChan <- fmt.Errorf("message sender panic: %v", r): + default: + // Channel already closed or full, just log + log.Printf("Unable to send error: %v", r) + } } + wg.Done() // Mark this goroutine as done }() for { @@ -129,28 +363,55 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play PlayerId: playerID, }}, } - writeMessage(conn, disconnectMsg) - done <- struct{}{} + + // Try to send disconnect message, ignoring errors + _ = msgHandler.WriteMessage(disconnectMsg) + + // No need to signal done channel here, the main goroutine handles this return case <-done: return + case <-heartbeatTicker.C: + // If no message has been sent for a while, send a heartbeat + timeSinceLastMessage := time.Since(lastMessageTime) + if timeSinceLastMessage > 5*time.Second { + // Send an empty batch as a heartbeat + emptyBatch := &pb.ActionBatch{ + PlayerId: playerID, + LastSeenMessageTimestamp: lastSeenMessageTimestamp, + } + if err := msgHandler.WriteMessage(emptyBatch); err != nil { + log.Printf("Failed to send heartbeat: %v", err) + select { + case errChan <- err: + case <-done: + return + } + } + lastMessageTime = time.Now() + } case <-actionTicker.C: player.Lock() if len(player.ActionQueue) > 0 { actions := make([]*pb.Action, len(player.ActionQueue)) copy(actions, player.ActionQueue) batch := &pb.ActionBatch{ - PlayerId: playerID, - Actions: actions, - Tick: player.CurrentTick, + PlayerId: playerID, + Actions: actions, + Tick: player.CurrentTick, + LastSeenMessageTimestamp: lastSeenMessageTimestamp, } player.ActionQueue = player.ActionQueue[:0] player.Unlock() - if err := writeMessage(conn, batch); err != nil { - errChan <- err - return + if err := msgHandler.WriteMessage(batch); err != nil { + select { + case errChan <- err: + case <-done: + return + } } + lastMessageTime = time.Now() } else { player.Unlock() } @@ -163,83 +424,42 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play defer func() { if r := recover(); r != nil { log.Printf("Recovered from panic in message receiver: %v", r) - errChan <- fmt.Errorf("message receiver panic: %v", r) + select { + case errChan <- fmt.Errorf("message receiver panic: %v", r): + default: + // Channel already closed or full, just log + log.Printf("Unable to send error: %v", r) + } } + wg.Done() // Mark this goroutine as done }() for { select { case <-quitChan: return + case <-done: + return default: - lengthBuf := make([]byte, 4) - if _, err := io.ReadFull(reader, lengthBuf); err != nil { - if err != io.EOF { - errChan <- fmt.Errorf("failed to read message length: %v", err) - } - return - } - messageLength := binary.BigEndian.Uint32(lengthBuf) - - messageBuf := make([]byte, messageLength) - if _, err := io.ReadFull(reader, messageBuf); err != nil { - log.Printf("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 - } - - 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 + 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) + select { + case errChan <- err: + case <-done: + return } - } - } - player.Unlock() - - for _, state := range serverMessage.Players { - 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 - } - - if otherPlayer, exists := otherPlayers[state.PlayerId]; exists { - otherPlayer.UpdatePosition(state, types.ServerTickRate) } else { - otherPlayers[state.PlayerId] = types.NewPlayer(state) + log.Printf("Connection closed by server") } + return } - // Remove players that are no longer in the server state - for id := range otherPlayers { - if id != playerID { - delete(otherPlayers, id) - } - } - - if handler, ok := player.UserData.(types.ChatMessageHandler); ok && len(serverMessage.ChatMessages) > 0 { - handler.HandleServerMessages(serverMessage.ChatMessages) - } + // Process the server message + UpdateGameState(serverMessage, player, otherPlayers) } } }() @@ -247,37 +467,20 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play // Wait for error or quit signal select { case <-quitChan: - // Send disconnect message - disconnectMsg := &pb.ActionBatch{ - PlayerId: playerID, - Actions: []*pb.Action{{ - Type: pb.Action_DISCONNECT, - PlayerId: playerID, - }}, - } - writeMessage(conn, disconnectMsg) + log.Printf("Received quit signal, sending disconnect message") + // The cleanup will happen in the deferred function + return case err := <-errChan: log.Printf("Network error: %v", err) + // The cleanup will happen in the deferred function + return } } // 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 { @@ -303,7 +506,13 @@ func NewConnection(username, password string, isRegistering bool) (*Connection, func (c *Connection) Close() { c.closeOnce.Do(func() { - close(c.quitChan) + select { + case <-c.quitChan: // Check if it's already closed + // Already closed, do nothing + default: + close(c.quitChan) + } + // Wait with timeout for network cleanup select { case <-c.quitDone: @@ -311,6 +520,8 @@ func (c *Connection) Close() { case <-time.After(500 * time.Millisecond): log.Println("Network cleanup timed out") } + + // Make sure the connection is closed c.conn.Close() }) } diff --git a/types/player.go b/types/player.go index cfd8473..2302add 100644 --- a/types/player.go +++ b/types/player.go @@ -8,8 +8,68 @@ 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 + sync.RWMutex // Keep this for network operations Model rl.Model Texture rl.Texture2D PosActual rl.Vector3 @@ -27,12 +87,12 @@ type Player struct { LastAnimUpdate time.Time LastUpdateTime time.Time InterpolationProgress float32 + PlaceholderColor rl.Color + AnimController *AnimationController } func (p *Player) MoveTowards(target Tile, deltaTime float32, mapGrid [][]Tile) { - p.Lock() - defer p.Unlock() - + // No need for lock here as this is called from a single thread (game loop) targetPos := rl.Vector3{ X: float32(target.X * TileSize), Y: mapGrid[target.X][target.Y].Height * TileHeight, @@ -43,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.LogInfo, "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.LogInfo, "Idle frame update: %d -> %d (delta: %f)", - oldFrame, p.AnimationFrame, deltaTime) } if distance > 0 { @@ -101,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() @@ -115,6 +180,7 @@ func (p *Player) UpdatePosition(state *pb.PlayerState, tickRate time.Duration) { } func (p *Player) ForceResync(state *pb.PlayerState) { + // Keep this lock since it's called from the network goroutine p.Lock() defer p.Unlock() diff --git a/types/types.go b/types/types.go index f15c8af..b656819 100644 --- a/types/types.go +++ b/types/types.go @@ -22,12 +22,13 @@ type AnimationSet struct { } type ModelAsset struct { - Model rl.Model - Texture rl.Texture2D - Animation []rl.ModelAnimation // Keep this for compatibility - AnimFrames int32 // Keep this for compatibility - Animations AnimationSet // New field for organized animations - YOffset float32 // Additional height offset (added to default 8.0) + Model rl.Model + Texture rl.Texture2D + Animation []rl.ModelAnimation // Keep this for compatibility + AnimFrames int32 // Keep this for compatibility + Animations AnimationSet // New field for organized animations + YOffset float32 // Additional height offset (added to default 8.0) + PlaceholderColor rl.Color } type ChatMessage struct { @@ -58,3 +59,12 @@ const ( ClientTickRate = 50 * time.Millisecond MaxTickDesync = 5 ) + +// UI constants +const ( + ChatMargin = 10 + ChatHeight = 200 + MessageHeight = 20 + InputHeight = 30 + MaxChatMessages = 50 +)