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 } 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{}), } // Set up inter-component references g.UIManager.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 // Try to load music music, err := assets.LoadMusic("resources/audio/GoonScape1.mp3") if err != nil { log.Printf("Warning: Failed to load music: %v", err) } else { g.AssetManager.Music = music } return nil }) } func (g *Game) Update(deltaTime float32) { // Handle login screen if not logged in if !g.UIManager.IsLoggedIn { // Handle login username, password, isRegistering, doAuth := g.UIManager.LoginScreen.Update() 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) // 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 { // Calculate tile coordinates from absolute position tileX := int(other.PosActual.X / float32(types.TileSize)) tileY := int(other.PosActual.Z / float32(types.TileSize)) rl.TraceLog(rl.LogInfo, "Other player ID: %d, Position: (%f, %f, %f), Tile: (%d, %d), Has model: %v", id, other.PosActual.X, other.PosActual.Y, other.PosActual.Z, tileX, tileY, 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) } } 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) { if player == nil { return } // Get necessary data modelIndex := int(player.ID) % len(g.AssetManager.Models) if modelIndex < 0 || modelIndex >= len(g.AssetManager.Models) { modelIndex = 0 } modelAsset := g.AssetManager.Models[modelIndex] // Calculate position const defaultHeight = 8.0 playerPos := rl.Vector3{ X: player.PosActual.X, Y: player.PosActual.Y + defaultHeight + modelAsset.YOffset, Z: player.PosActual.Z, } // Simple drawing with scale parameter var drawColor rl.Color = rl.White if player.PlaceholderColor.A > 0 { drawColor = player.PlaceholderColor } // Draw the model at normal scale (16.0) rl.DrawModel(model, playerPos, 16.0, drawColor) // Update floating message position if player.FloatingMessage != nil { worldPos := rl.Vector3{ X: playerPos.X, Y: playerPos.Y + 24.0, // Position above head Z: playerPos.Z, } player.FloatingMessage.ScreenPos = rl.GetWorldToScreen(worldPos, g.Camera) } } func (g *Game) DrawFloatingMessages() { var drawFloatingMessage = func(msg *types.FloatingMessage) { if msg == nil || time.Now().After(msg.ExpireTime) { return } // Draw the message with RuneScape-style coloring (black outline with yellow text) text := msg.Content textWidth := rl.MeasureText(text, 20) // Draw black outline by offsetting the text slightly in all directions for offsetX := -2; offsetX <= 2; offsetX++ { for offsetY := -2; offsetY <= 2; offsetY++ { rl.DrawText(text, int32(msg.ScreenPos.X)-textWidth/2+int32(offsetX), int32(msg.ScreenPos.Y)+int32(offsetY), 20, rl.Black) } } // Draw the yellow text on top rl.DrawText(text, int32(msg.ScreenPos.X)-textWidth/2, int32(msg.ScreenPos.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) } } } func (g *Game) Render() { rl.BeginDrawing() defer rl.EndDrawing() rl.ClearBackground(rl.RayWhite) if !g.UIManager.IsLoggedIn { g.UIManager.LoginScreen.Draw() return } // Draw 3D elements 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 } if other.Model.Meshes != nil { g.DrawPlayer(other, other.Model) } } rl.EndMode3D() // Draw floating messages with RuneScape style g.DrawFloatingMessages() // 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())) } // Draw FPS counter 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()) // Draw semi-transparent background rl.DrawRectangle(0, 0, int32(screenWidth), int32(screenHeight), rl.ColorAlpha(rl.Black, 0.7)) // Draw menu items menuItems := []string{"Resume", "Settings", "Quit"} menuY := screenHeight/2 - float32(len(menuItems)*40)/2 for i, item := range menuItems { itemY := menuY + float32(i*40) mousePoint := rl.GetMousePosition() itemRect := rl.Rectangle{X: screenWidth/2 - 100, Y: itemY, Width: 200, Height: 36} // Check for hover isHover := rl.CheckCollisionPointRec(mousePoint, itemRect) // Draw button background if isHover { rl.DrawRectangleRec(itemRect, rl.ColorAlpha(rl.White, 0.3)) } else { rl.DrawRectangleRec(itemRect, rl.ColorAlpha(rl.White, 0.1)) } // Draw button text textWidth := rl.MeasureText(item, 20) rl.DrawText(item, int32(itemRect.X+(itemRect.Width-float32(textWidth))/2), int32(itemRect.Y+8), 20, rl.White) // Handle click if isHover && rl.IsMouseButtonReleased(rl.MouseLeftButton) { switch item { case "Resume": g.UIManager.MenuOpen = false case "Settings": // TODO: Implement settings case "Quit": g.Shutdown() rl.CloseWindow() } } } } 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) QuitChan() <-chan struct{} { return g.quitChan } func (g *Game) Shutdown() { // Use the cleanup method which has channel-closing safety g.Cleanup() } 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) } }