package game import ( "log" "sync" "time" "gitea.boner.be/bdnugget/goonscape/assets" "gitea.boner.be/bdnugget/goonscape/network" "gitea.boner.be/bdnugget/goonscape/types" pb "gitea.boner.be/bdnugget/goonserver/actions" rl "github.com/gen2brain/raylib-go/raylib" ) type Game struct { // Component-based architecture PlayerManager *PlayerManager AssetManager *AssetManager UIManager *UIManager // Core game state Camera rl.Camera3D quitChan chan struct{} 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 { // Create managers playerManager := NewPlayerManager() assetManager := NewAssetManager() uiManager := NewUIManager() g := &Game{ PlayerManager: playerManager, AssetManager: assetManager, UIManager: uiManager, Camera: rl.Camera3D{ 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, }, quitChan: make(chan struct{}), // Initialize empty maps to avoid nil references OtherPlayers: make(map[int32]*types.Player), } // 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 { 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 // Update legacy field g.Models = models // 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 } return nil }) } func (g *Game) Update(deltaTime float32) { // 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.UIManager.LoginScreen.SetError(err.Error()) return } g.PlayerManager.LocalPlayer = &types.Player{ Speed: 50.0, TargetPath: []types.Tile{}, ActionQueue: []*pb.Action{}, QuitDone: make(chan struct{}), ID: playerID, } g.AssignModelToPlayer(g.PlayerManager.LocalPlayer) // 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.UIManager.MenuOpen = !g.UIManager.MenuOpen return } // Don't process other inputs if menu is open if g.UIManager.MenuOpen { return } // 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.PlayerManager.LocalPlayer.ID, }) g.PlayerManager.LocalPlayer.Unlock() } // Process player input g.HandleInput() // 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()) } // 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) } } } // 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() { for x := 0; x < types.MapWidth; x++ { for y := 0; y < types.MapHeight; y++ { height := GetTileHeight(x, y) // Interpolate height for smoother landscape if x > 0 { height += GetTileHeight(x-1, y) } if y > 0 { height += GetTileHeight(x, y-1) } if x > 0 && y > 0 { height += GetTileHeight(x-1, y-1) } height /= 4.0 tilePos := rl.Vector3{ X: float32(x * types.TileSize), Y: height * types.TileHeight, Z: float32(y * types.TileSize), } color := rl.Color{R: uint8(height * 25), G: 100, B: 100, A: 64} rl.DrawCube(tilePos, types.TileSize, types.TileHeight, types.TileSize, color) } } } func (g *Game) DrawPlayer(player *types.Player, model rl.Model) { // 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 playerPos := rl.Vector3{ X: player.PosActual.X, Y: grid[player.PosTile.X][player.PosTile.Y].Height*types.TileHeight + defaultHeight + modelAsset.YOffset, Z: player.PosActual.Z, } // Check if model has animations 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 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 if anim.FrameCount > 0 { currentFrame := player.AnimationFrame % anim.FrameCount rl.UpdateModelAnimation(model, anim, currentFrame) } } } // 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 { screenPos := rl.GetWorldToScreen(rl.Vector3{ X: playerPos.X, Y: playerPos.Y + 24.0, Z: playerPos.Z, }, g.Camera) player.FloatingMessage.ScreenPos = screenPos } if len(player.TargetPath) > 0 { targetTile := player.TargetPath[len(player.TargetPath)-1] targetPos := rl.Vector3{ X: float32(targetTile.X * types.TileSize), Y: grid[targetTile.X][targetTile.Y].Height * types.TileHeight, Z: float32(targetTile.Y * types.TileSize), } rl.DrawCubeWires(targetPos, types.TileSize, types.TileHeight, types.TileSize, rl.Green) nextTile := player.TargetPath[0] nextPos := rl.Vector3{ X: float32(nextTile.X * types.TileSize), Y: grid[nextTile.X][nextTile.Y].Height * types.TileHeight, Z: float32(nextTile.Y * types.TileSize), } rl.DrawCubeWires(nextPos, types.TileSize, types.TileHeight, types.TileSize, rl.Yellow) } } 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.UIManager.IsLoggedIn { g.UIManager.LoginScreen.Draw() return } rl.BeginMode3D(g.Camera) g.DrawMap() // 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) } rl.EndMode3D() // Draw floating messages drawFloatingMessage := func(msg *types.FloatingMessage) { if msg == nil || time.Now().After(msg.ExpireTime) { return } pos := msg.ScreenPos text := msg.Content textWidth := rl.MeasureText(text, 20) for offsetX := -2; offsetX <= 2; offsetX++ { for offsetY := -2; offsetY <= 2; offsetY++ { rl.DrawText(text, int32(pos.X)-textWidth/2+int32(offsetX), int32(pos.Y)+int32(offsetY), 20, rl.Black) } } rl.DrawText(text, int32(pos.X)-textWidth/2, int32(pos.Y), 20, rl.Yellow) } if g.PlayerManager.LocalPlayer != nil && g.PlayerManager.LocalPlayer.FloatingMessage != nil { drawFloatingMessage(g.PlayerManager.LocalPlayer.FloatingMessage) } for _, other := range g.PlayerManager.OtherPlayers { if other != nil && other.FloatingMessage != nil { drawFloatingMessage(other.FloatingMessage) } } // Draw menu if open if g.UIManager.MenuOpen { g.DrawMenu() } // Only draw chat if menu is not open if !g.UIManager.MenuOpen && g.UIManager.Chat != nil { g.UIManager.Chat.Draw(int32(rl.GetScreenWidth()), int32(rl.GetScreenHeight())) } rl.DrawFPS(10, 10) } func (g *Game) Cleanup() { g.cleanupOnce.Do(func() { // 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.PlayerManager.LocalPlayer.PosTile.X, g.PlayerManager.LocalPlayer.PosTile.Y), clickedTile) if len(path) > 1 { 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.PlayerManager.LocalPlayer.ID, }) g.PlayerManager.LocalPlayer.Unlock() } } } func (g *Game) DrawMenu() { screenWidth := float32(rl.GetScreenWidth()) screenHeight := float32(rl.GetScreenHeight()) // Semi-transparent background rl.DrawRectangle(0, 0, int32(screenWidth), int32(screenHeight), rl.ColorAlpha(rl.Black, 0.7)) // Menu title title := "Menu" titleSize := int32(40) titleWidth := rl.MeasureText(title, titleSize) rl.DrawText(title, int32(screenWidth/2)-titleWidth/2, 100, titleSize, rl.White) // Menu buttons buttonWidth := float32(200) buttonHeight := float32(40) buttonY := float32(200) buttonSpacing := float32(60) menuItems := []string{"Resume", "Settings", "Exit Game"} for _, item := range menuItems { buttonRect := rl.Rectangle{ X: screenWidth/2 - buttonWidth/2, Y: buttonY, Width: buttonWidth, Height: buttonHeight, } // Check mouse hover mousePoint := rl.GetMousePosition() mouseHover := rl.CheckCollisionPointRec(mousePoint, buttonRect) // Draw button if mouseHover { rl.DrawRectangleRec(buttonRect, rl.ColorAlpha(rl.White, 0.3)) if rl.IsMouseButtonPressed(rl.MouseLeftButton) { switch item { case "Resume": g.UIManager.MenuOpen = false case "Settings": // TODO: Implement settings case "Exit Game": g.Shutdown() } } } // Draw button text textSize := int32(20) textWidth := rl.MeasureText(item, textSize) textX := int32(buttonRect.X+buttonRect.Width/2) - textWidth/2 textY := int32(buttonRect.Y + buttonRect.Height/2 - float32(textSize)/2) rl.DrawText(item, textX, textY, textSize, rl.White) buttonY += buttonSpacing } } func (g *Game) Shutdown() { // Use the cleanup method which has channel-closing safety g.Cleanup() } func (g *Game) HandleServerMessages(messages []*pb.ChatMessage) { // 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) { if player == nil { return } modelAsset, found := g.AssetManager.GetModelForPlayer(player.ID) if !found { return } player.Model = modelAsset.Model 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{} { return g.quitChan }