diff --git a/game/chat.go b/game/chat.go new file mode 100644 index 0000000..e4a163f --- /dev/null +++ b/game/chat.go @@ -0,0 +1,194 @@ +package game + +import ( + "fmt" + "time" + + "gitea.boner.be/bdnugget/goonscape/types" + pb "gitea.boner.be/bdnugget/goonserver/actions" + rl "github.com/gen2brain/raylib-go/raylib" +) + +const ( + maxMessages = 50 + chatWindowWidth = 400 + chatHeight = 200 + messageHeight = 20 + inputHeight = 30 +) + +type Chat struct { + messages []types.ChatMessage + inputBuffer []rune + isTyping bool + cursorPos int + scrollOffset int + userData interface{} +} + +func NewChat() *Chat { + return &Chat{ + messages: make([]types.ChatMessage, 0, maxMessages), + inputBuffer: make([]rune, 0, 256), + } +} + +func (c *Chat) AddMessage(playerID int32, content string) { + msg := types.ChatMessage{ + PlayerID: playerID, + Content: content, + Time: time.Now(), + } + + if len(c.messages) >= maxMessages { + c.messages = c.messages[1:] + } + c.messages = append(c.messages, msg) + c.scrollOffset = 0 // Reset scroll position for new messages +} + +func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) { + // Convert protobuf messages to our local type + for _, msg := range messages { + localMsg := types.ChatMessage{ + PlayerID: msg.PlayerId, + Content: msg.Content, + Time: time.Unix(0, msg.Timestamp), + } + + // 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 { + c.messages = c.messages[1:] + } + c.messages = append(c.messages, localMsg) + + // 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{ + 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{ + Content: msg.Content, + ExpireTime: time.Now().Add(6 * time.Second), + } + otherPlayer.Unlock() + } + } + } + } +} + +func (c *Chat) Draw(screenWidth, screenHeight int32) { + // Draw chat window background + chatX := float32(10) + chatY := float32(screenHeight - chatHeight - 10) + rl.DrawRectangle(int32(chatX), int32(chatY), chatWindowWidth, chatHeight, rl.ColorAlpha(rl.Black, 0.5)) + + // Draw messages + messageY := chatY + 5 + startIdx := len(c.messages) - 1 - c.scrollOffset + endIdx := max(0, startIdx-int((chatHeight-inputHeight)/messageHeight)) + + for i := startIdx; i >= endIdx && i >= 0; i-- { + msg := c.messages[i] + text := fmt.Sprintf("[%d]: %s", msg.PlayerID, msg.Content) + rl.DrawText(text, int32(chatX)+5, int32(messageY), 20, rl.White) + messageY += messageHeight + } + + // Draw input field + inputY := chatY + float32(chatHeight-inputHeight) + rl.DrawRectangle(int32(chatX), int32(inputY), chatWindowWidth, 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) + + // Draw cursor + cursorX := rl.MeasureText(inputText[:c.cursorPos], 20) + rl.DrawRectangle(int32(chatX)+5+cursorX, int32(inputY)+5, 2, 20, rl.White) + } else { + rl.DrawText("Press T to chat", int32(chatX)+5, int32(inputY)+5, 20, rl.Gray) + } +} + +func (c *Chat) Update() (string, bool) { + // Handle scrolling with mouse wheel when not typing + if !c.isTyping { + wheelMove := rl.GetMouseWheelMove() + if wheelMove != 0 { + maxScroll := max(0, len(c.messages)-int((chatHeight-inputHeight)/messageHeight)) + c.scrollOffset = clamp(c.scrollOffset-int(wheelMove), 0, maxScroll) + } + + if rl.IsKeyPressed(rl.KeyT) { + c.isTyping = true + return "", false + } + return "", false + } + + key := rl.GetCharPressed() + for key > 0 { + if len(c.inputBuffer) < 256 { + c.inputBuffer = append(c.inputBuffer[:c.cursorPos], append([]rune{key}, c.inputBuffer[c.cursorPos:]...)...) + c.cursorPos++ + } + key = rl.GetCharPressed() + } + + if rl.IsKeyPressed(rl.KeyEnter) { + if len(c.inputBuffer) > 0 { + message := string(c.inputBuffer) + c.inputBuffer = c.inputBuffer[:0] + c.cursorPos = 0 + c.isTyping = false + return message, true + } + c.isTyping = false + } + + if rl.IsKeyPressed(rl.KeyEscape) { + c.inputBuffer = c.inputBuffer[:0] + c.cursorPos = 0 + c.isTyping = false + } + + if rl.IsKeyPressed(rl.KeyBackspace) && c.cursorPos > 0 { + c.inputBuffer = append(c.inputBuffer[:c.cursorPos-1], c.inputBuffer[c.cursorPos:]...) + c.cursorPos-- + } + + if rl.IsKeyPressed(rl.KeyLeft) && c.cursorPos > 0 { + c.cursorPos-- + } + if rl.IsKeyPressed(rl.KeyRight) && c.cursorPos < len(c.inputBuffer) { + c.cursorPos++ + } + + return "", false +} + +// Add helper functions +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func clamp(value, min, max int) int { + if value < min { + return min + } + if value > max { + return max + } + return value +} diff --git a/game/game.go b/game/game.go index eb10d3b..8220586 100644 --- a/game/game.go +++ b/game/game.go @@ -1,6 +1,8 @@ package game import ( + "time" + "gitea.boner.be/bdnugget/goonscape/assets" "gitea.boner.be/bdnugget/goonscape/types" pb "gitea.boner.be/bdnugget/goonserver/actions" @@ -13,6 +15,7 @@ type Game struct { Camera rl.Camera3D Models []types.ModelAsset Music rl.Music + Chat *Chat } func New() *Game { @@ -23,6 +26,7 @@ func New() *Game { PosTile: GetTile(5, 5), Speed: 50.0, TargetPath: []types.Tile{}, + UserData: nil, }, OtherPlayers: make(map[int32]*types.Player), Camera: rl.Camera3D{ @@ -32,7 +36,10 @@ func New() *Game { Fovy: 45.0, Projection: rl.CameraPerspective, }, + Chat: NewChat(), } + game.Player.UserData = game + game.Chat.userData = game return game } @@ -52,6 +59,16 @@ func (g *Game) LoadAssets() error { } func (g *Game) Update(deltaTime float32) { + if message, sent := g.Chat.Update(); sent { + g.Player.Lock() + g.Player.ActionQueue = append(g.Player.ActionQueue, &pb.Action{ + Type: pb.Action_CHAT, + ChatMessage: message, + PlayerId: g.Player.ID, + }) + g.Player.Unlock() + } + g.HandleInput() if len(g.Player.TargetPath) > 0 { @@ -108,6 +125,18 @@ func (g *Game) DrawPlayer(player *types.Player, model rl.Model) { rl.DrawModel(model, playerPos, 16, rl.White) + if player.FloatingMessage != nil && time.Now().Before(player.FloatingMessage.ExpireTime) { + screenPos := rl.GetWorldToScreen(rl.Vector3{ + X: playerPos.X, + Y: playerPos.Y + 24.0, + Z: playerPos.Z, + }, g.Camera) + + player.FloatingMessage.ScreenPos = screenPos + } else if player.FloatingMessage != nil { + player.FloatingMessage = nil + } + if len(player.TargetPath) > 0 { targetTile := player.TargetPath[len(player.TargetPath)-1] targetPos := rl.Vector3{ @@ -130,17 +159,45 @@ func (g *Game) DrawPlayer(player *types.Player, model rl.Model) { func (g *Game) Render() { rl.BeginDrawing() rl.ClearBackground(rl.RayWhite) - rl.BeginMode3D(g.Camera) + rl.BeginMode3D(g.Camera) g.DrawMap() g.DrawPlayer(g.Player, g.Player.Model) - for id, other := range g.OtherPlayers { g.DrawPlayer(other, g.Models[int(id)%len(g.Models)].Model) } - rl.EndMode3D() + + 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.Player.FloatingMessage != nil { + drawFloatingMessage(g.Player.FloatingMessage) + } + + for _, other := range g.OtherPlayers { + drawFloatingMessage(other.FloatingMessage) + } + rl.DrawFPS(10, 10) + g.Chat.Draw(int32(rl.GetScreenWidth()), int32(rl.GetScreenHeight())) rl.EndDrawing() } @@ -153,7 +210,7 @@ func (g *Game) HandleInput() { clickedTile, clicked := g.GetTileAtMouse() if clicked { path := FindPath(GetTile(g.Player.PosTile.X, g.Player.PosTile.Y), clickedTile) - if path != nil && len(path) > 1 { + if len(path) > 1 { g.Player.Lock() g.Player.TargetPath = path[1:] g.Player.ActionQueue = append(g.Player.ActionQueue, &pb.Action{ diff --git a/goonserver b/goonserver index 4b73492..368fbdb 160000 --- a/goonserver +++ b/goonserver @@ -1 +1 @@ -Subproject commit 4b73492ffc0824faccddd81053c08d3d7607919a +Subproject commit 368fbdbc4743ad5d571789e5ebb7f76cfb964743 diff --git a/network/network.go b/network/network.go index 1dd29b8..4a0dcf1 100644 --- a/network/network.go +++ b/network/network.go @@ -5,6 +5,7 @@ import ( "net" "time" + "gitea.boner.be/bdnugget/goonscape/game" "gitea.boner.be/bdnugget/goonscape/types" pb "gitea.boner.be/bdnugget/goonserver/actions" "google.golang.org/protobuf/proto" @@ -112,5 +113,9 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play otherPlayers[state.PlayerId] = types.NewPlayer(state) } } + + if g, ok := player.UserData.(*game.Game); ok && len(serverMessage.ChatMessages) > 0 { + g.Chat.HandleServerMessages(serverMessage.ChatMessages) + } } } diff --git a/types/types.go b/types/types.go index 68744fa..9462593 100644 --- a/types/types.go +++ b/types/types.go @@ -27,6 +27,8 @@ type Player struct { CurrentTick int64 LastUpdateTime time.Time InterpolationProgress float32 + UserData interface{} // Used to store reference to game + FloatingMessage *FloatingMessage } type ModelAsset struct { @@ -34,6 +36,18 @@ type ModelAsset struct { Texture rl.Texture2D } +type ChatMessage struct { + PlayerID int32 + Content string + Time time.Time +} + +type FloatingMessage struct { + Content string + ExpireTime time.Time + ScreenPos rl.Vector2 // Store the screen position for 2D rendering +} + const ( MapWidth = 50 MapHeight = 50 @@ -44,5 +58,6 @@ const ( ServerTickRate = 600 * time.Millisecond ClientTickRate = 50 * time.Millisecond MaxTickDesync = 5 - ServerAddr = "localhost:6969" + // ServerAddr = "localhost:6969" + ServerAddr = "boner.be:6969" )