diff --git a/assets/assets.go b/assets/assets.go index 4368cbf..0fea9ae 100644 --- a/assets/assets.go +++ b/assets/assets.go @@ -85,19 +85,3 @@ func LoadModels() ([]types.ModelAsset, error) { func LoadMusic(filename string) (rl.Music, error) { return rl.LoadMusicStream(filename), nil } - -func UnloadModels(models []types.ModelAsset) { - for _, model := range models { - if model.Animation != nil { - for i := int32(0); i < model.AnimFrames; i++ { - rl.UnloadModelAnimation(model.Animation[i]) - } - } - rl.UnloadModel(model.Model) - rl.UnloadTexture(model.Texture) - } -} - -func UnloadMusic(music rl.Music) { - rl.UnloadMusicStream(music) -} diff --git a/game/chat.go b/game/chat.go index 9aced0d..f6b3ce9 100644 --- a/game/chat.go +++ b/game/chat.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "sync" "time" "gitea.boner.be/bdnugget/goonscape/types" @@ -25,6 +26,7 @@ type Chat struct { cursorPos int scrollOffset int userData interface{} + mutex sync.RWMutex } func NewChat() *Chat { @@ -49,6 +51,9 @@ func (c *Chat) AddMessage(playerID int32, content string) { } func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) { + c.mutex.Lock() + defer c.mutex.Unlock() + // Convert protobuf messages to our local type for _, msg := range messages { localMsg := types.ChatMessage{ @@ -94,6 +99,9 @@ func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) { } func (c *Chat) Draw(screenWidth, screenHeight int32) { + c.mutex.RLock() + defer c.mutex.RUnlock() + // Calculate chat window width based on screen width chatWindowWidth := screenWidth - (chatMargin * 2) diff --git a/game/game.go b/game/game.go index 2a26f9a..9df3433 100644 --- a/game/game.go +++ b/game/game.go @@ -1,7 +1,8 @@ package game import ( - "os" + "fmt" + "sync" "time" "gitea.boner.be/bdnugget/goonscape/assets" @@ -19,9 +20,10 @@ type Game struct { Music rl.Music Chat *Chat MenuOpen bool - QuitChan chan struct{} // Channel to signal shutdown + quitChan chan struct{} loginScreen *LoginScreen isLoggedIn bool + cleanupOnce sync.Once } func New() *Game { @@ -36,7 +38,7 @@ func New() *Game { Projection: rl.CameraPerspective, }, Chat: NewChat(), - QuitChan: make(chan struct{}), + quitChan: make(chan struct{}), loginScreen: NewLoginScreen(), } game.Chat.userData = game @@ -44,15 +46,32 @@ func New() *Game { } func (g *Game) LoadAssets() error { - var err error - g.Models, err = assets.LoadModels() - if err != nil { - return err + 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() + } + }() + + // Load models with better error handling + g.Models, loadErr = assets.LoadModels() + if loadErr != nil { + return fmt.Errorf("failed to load models: %v", loadErr) } - g.Music, err = assets.LoadMusic("resources/audio/GoonScape2.mp3") - if err != nil { - return err + // Verify model loading + for i, model := range g.Models { + if model.Model.Meshes == nil { + return fmt.Errorf("model %d failed to load properly", i) + } + } + + // 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 @@ -77,11 +96,9 @@ func (g *Game) Update(deltaTime float32) { } g.AssignModelToPlayer(g.Player) - go network.HandleServerCommunication(conn, playerID, g.Player, g.OtherPlayers, g.QuitChan) + go network.HandleServerCommunication(conn, playerID, g.Player, g.OtherPlayers, g.quitChan) g.isLoggedIn = true - return } - g.loginScreen.Draw() return } @@ -274,8 +291,20 @@ func (g *Game) Render() { } func (g *Game) Cleanup() { - assets.UnloadModels(g.Models) - assets.UnloadMusic(g.Music) + 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 { + if model.Texture.ID > 0 { + rl.UnloadTexture(model.Texture) + } + } + }) } func (g *Game) HandleInput() { @@ -355,10 +384,7 @@ func (g *Game) DrawMenu() { } func (g *Game) Shutdown() { - close(g.QuitChan) - <-g.Player.QuitDone - rl.CloseWindow() - os.Exit(0) + close(g.quitChan) } func (g *Game) HandleServerMessages(messages []*pb.ChatMessage) { @@ -373,3 +399,7 @@ func (g *Game) AssignModelToPlayer(player *types.Player) { player.Model = modelAsset.Model player.Texture = modelAsset.Texture } + +func (g *Game) QuitChan() <-chan struct{} { + return g.quitChan +} diff --git a/main.go b/main.go index 7b1f06f..165291c 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,10 @@ package main import ( "flag" "log" + "os" + "os/signal" "strings" + "syscall" "gitea.boner.be/bdnugget/goonscape/game" "gitea.boner.be/bdnugget/goonscape/network" @@ -11,11 +14,24 @@ import ( ) func main() { + defer func() { + if r := recover(); r != nil { + log.Printf("Recovered from panic in main: %v", r) + } + }() + // Parse command line flags + verbose := flag.Bool("v", false, "Also show info logs (spammy)") local := flag.Bool("local", false, "Connect to local server") addr := flag.String("addr", "", "Server address (host or host:port)") flag.Parse() + if *verbose { + rl.SetTraceLogLevel(rl.LogTrace) + } else { + rl.SetTraceLogLevel(rl.LogWarning) + } + // Set server address based on flags if *local { if *addr != "" { @@ -32,29 +48,63 @@ func main() { rl.InitWindow(1024, 768, "GoonScape") rl.SetExitKey(0) - defer rl.CloseWindow() - rl.InitAudioDevice() - defer rl.CloseAudioDevice() + + gameInstance := game.New() + if err := gameInstance.LoadAssets(); err != nil { + log.Printf("Failed to load assets: %v", err) + return + } + + defer func() { + gameInstance.Cleanup() + rl.CloseWindow() + rl.CloseAudioDevice() + }() rl.SetTargetFPS(60) - game := game.New() - if err := game.LoadAssets(); err != nil { - log.Fatalf("Failed to load assets: %v", err) - } - defer game.Cleanup() + rl.PlayMusicStream(gameInstance.Music) + rl.SetMusicVolume(gameInstance.Music, 0.5) - rl.PlayMusicStream(game.Music) - rl.SetMusicVolume(game.Music, 0.5) + // Handle OS signals for clean shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigChan + if gameInstance != nil { + gameInstance.Shutdown() + } + }() + // Keep game loop in main thread for Raylib for !rl.WindowShouldClose() { deltaTime := rl.GetFrameTime() - rl.UpdateMusicStream(game.Music) - game.Update(deltaTime) - game.Render() - } + rl.UpdateMusicStream(gameInstance.Music) - // Wait for clean shutdown - <-game.QuitChan + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("Recovered from panic in game update: %v", r) + } + }() + gameInstance.Update(deltaTime) + }() + + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("Recovered from panic in game render: %v", r) + } + }() + gameInstance.Render() + }() + + // Check if game requested shutdown + select { + case <-gameInstance.QuitChan(): + return + default: + } + } } diff --git a/network/network.go b/network/network.go index 21e57ef..cf749dd 100644 --- a/network/network.go +++ b/network/network.go @@ -7,6 +7,7 @@ import ( "io" "log" "net" + "sync" "time" "gitea.boner.be/bdnugget/goonscape/types" @@ -91,19 +92,32 @@ func ConnectToServer(username, password string, isRegistering bool) (net.Conn, i func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Player, otherPlayers map[int32]*types.Player, quitChan <-chan struct{}) { reader := bufio.NewReader(conn) + defer func() { + if r := recover(); r != nil { + log.Printf("Recovered from panic in HandleServerCommunication: %v", r) + } + conn.Close() + if player.QuitDone != nil { + close(player.QuitDone) + } + }() actionTicker := time.NewTicker(types.ClientTickRate) defer actionTicker.Stop() - defer conn.Close() - defer close(player.QuitDone) - // Create a channel to signal when goroutines are done + // Create error channel for goroutine communication + errChan := make(chan error, 1) done := make(chan struct{}) - // Create a set of current players to track disconnects - currentPlayers := make(map[int32]bool) - + // 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) + } + }() + for { select { case <-quitChan: @@ -118,23 +132,23 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play writeMessage(conn, disconnectMsg) done <- struct{}{} return + case <-done: + return 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, } - player.ActionQueue = player.ActionQueue[:0] player.Unlock() if err := writeMessage(conn, batch); err != nil { - log.Printf("Failed to send actions to server: %v", err) + errChan <- err return } } else { @@ -144,93 +158,106 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play } }() - for { - select { - case <-quitChan: - done := make(chan struct{}) - go func() { - <-done - close(player.QuitDone) - }() + // Main message receiving loop + go func() { + 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) + } + }() + for { select { - case <-done: - time.Sleep(100 * time.Millisecond) - case <-time.After(1 * time.Second): - log.Println("Shutdown timed out") - } - return - default: - // Read message length (4 bytes) - lengthBuf := make([]byte, 4) - if _, err := io.ReadFull(reader, lengthBuf); err != nil { - log.Printf("Failed to read message length: %v", err) + case <-quitChan: return - } - messageLength := binary.BigEndian.Uint32(lengthBuf) - - // Read the full message - 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 + 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 } - } - player.Unlock() + messageLength := binary.BigEndian.Uint32(lengthBuf) - for _, state := range serverMessage.Players { - currentPlayers[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() + 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 } - if otherPlayer, exists := otherPlayers[state.PlayerId]; exists { - otherPlayer.UpdatePosition(state, types.ServerTickRate) - } else { - otherPlayers[state.PlayerId] = types.NewPlayer(state) - } - } + player.Lock() + player.CurrentTick = serverMessage.CurrentTick - // Remove players that are no longer in the server state - for id := range otherPlayers { - if !currentPlayers[id] { - delete(otherPlayers, id) + tickDiff := serverMessage.CurrentTick - player.CurrentTick + if tickDiff > types.MaxTickDesync { + for _, state := range serverMessage.Players { + if state.PlayerId == playerID { + player.ForceResync(state) + break + } + } } - } + player.Unlock() - if handler, ok := player.UserData.(types.ChatMessageHandler); ok && len(serverMessage.ChatMessages) > 0 { - handler.HandleServerMessages(serverMessage.ChatMessages) + 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) + } + } + + // 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) + } } } + }() + + // 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) + case err := <-errChan: + log.Printf("Network error: %v", err) } } @@ -252,3 +279,50 @@ func writeMessage(conn net.Conn, msg proto.Message) error { _, err = conn.Write(data) return err } + +type Connection struct { + conn net.Conn + playerID int32 + quitChan chan struct{} + quitDone chan struct{} + closeOnce sync.Once +} + +func NewConnection(username, password string, isRegistering bool) (*Connection, error) { + conn, playerID, err := ConnectToServer(username, password, isRegistering) + if err != nil { + return nil, err + } + return &Connection{ + conn: conn, + playerID: playerID, + quitChan: make(chan struct{}), + quitDone: make(chan struct{}), + }, nil +} + +func (c *Connection) Close() { + c.closeOnce.Do(func() { + close(c.quitChan) + // Wait with timeout for network cleanup + select { + case <-c.quitDone: + // Clean shutdown completed + case <-time.After(500 * time.Millisecond): + log.Println("Network cleanup timed out") + } + c.conn.Close() + }) +} + +func (c *Connection) PlayerID() int32 { + return c.playerID +} + +func (c *Connection) Start(player *types.Player, otherPlayers map[int32]*types.Player) { + go HandleServerCommunication(c.conn, c.playerID, player, otherPlayers, c.quitChan) +} + +func (c *Connection) QuitChan() <-chan struct{} { + return c.quitChan +} diff --git a/types/player.go b/types/player.go index 747e39f..cfd8473 100644 --- a/types/player.go +++ b/types/player.go @@ -1,12 +1,34 @@ package types import ( + "sync" "time" pb "gitea.boner.be/bdnugget/goonserver/actions" rl "github.com/gen2brain/raylib-go/raylib" ) +type Player struct { + sync.RWMutex + Model rl.Model + Texture rl.Texture2D + PosActual rl.Vector3 + PosTile Tile + TargetPath []Tile + Speed float32 + ActionQueue []*pb.Action + ID int32 + QuitDone chan struct{} + CurrentTick int64 + UserData interface{} + FloatingMessage *FloatingMessage + IsMoving bool + AnimationFrame int32 + LastAnimUpdate time.Time + LastUpdateTime time.Time + InterpolationProgress float32 +} + func (p *Player) MoveTowards(target Tile, deltaTime float32, mapGrid [][]Tile) { p.Lock() defer p.Unlock() @@ -68,12 +90,14 @@ func NewPlayer(state *pb.PlayerState) *Player { Y: float32(state.Y * TileHeight), Z: float32(state.Y * TileSize), }, - PosTile: Tile{X: int(state.X), Y: int(state.Y)}, - Speed: 50.0, - ID: state.PlayerId, - IsMoving: false, - AnimationFrame: 0, - LastAnimUpdate: time.Now(), + PosTile: Tile{X: int(state.X), Y: int(state.Y)}, + Speed: 50.0, + ID: state.PlayerId, + IsMoving: false, + AnimationFrame: 0, + LastAnimUpdate: time.Now(), + LastUpdateTime: time.Now(), + InterpolationProgress: 1.0, } } diff --git a/types/types.go b/types/types.go index 49aa718..f15c8af 100644 --- a/types/types.go +++ b/types/types.go @@ -1,7 +1,6 @@ package types import ( - "sync" "time" pb "gitea.boner.be/bdnugget/goonserver/actions" @@ -14,27 +13,6 @@ type Tile struct { Walkable bool } -type Player struct { - sync.Mutex - PosActual rl.Vector3 - PosTile Tile - TargetPath []Tile - ActionQueue []*pb.Action - Speed float32 - Model rl.Model - Texture rl.Texture2D - ID int32 - CurrentTick int64 - LastUpdateTime time.Time - LastAnimUpdate time.Time - InterpolationProgress float32 - UserData interface{} - FloatingMessage *FloatingMessage - QuitDone chan struct{} - AnimationFrame int32 - IsMoving bool -} - type AnimationSet struct { Idle []rl.ModelAnimation Walk []rl.ModelAnimation