Compare commits

..

1 Commits

24 changed files with 1470 additions and 1578 deletions

View File

@ -1,88 +1,14 @@
package assets package assets
import ( import (
"fmt"
"os"
"gitea.boner.be/bdnugget/goonscape/types" "gitea.boner.be/bdnugget/goonscape/types"
rl "github.com/gen2brain/raylib-go/raylib" 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 // Helper function to load animations for a model
func loadModelAnimations(animPaths map[string]string) (types.AnimationSet, error) { func loadModelAnimations(animPaths map[string]string) (types.AnimationSet, error) {
var animSet types.AnimationSet 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 // Load idle animations if specified
if idlePath, ok := animPaths["idle"]; ok { if idlePath, ok := animPaths["idle"]; ok {
idleAnims := rl.LoadModelAnimations(idlePath) idleAnims := rl.LoadModelAnimations(idlePath)
@ -106,84 +32,10 @@ func loadModelAnimations(animPaths map[string]string) (types.AnimationSet, error
return animSet, nil 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) { func LoadModels() ([]types.ModelAsset, error) {
// Force safe mode for now until we fix the segfault // Goonion model and animations
os.Setenv("GOONSCAPE_SAFE_MODE", "1") 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"})
models := make([]types.ModelAsset, 0, 3)
modelLoader := NewModelLoader()
// 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
// 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,
})
// 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 // Apply transformations
transform := rl.MatrixIdentity() transform := rl.MatrixIdentity()
@ -191,105 +43,61 @@ func LoadModels() ([]types.ModelAsset, error) {
transform = rl.MatrixMultiply(transform, rl.MatrixRotateX(-90*rl.Deg2rad)) transform = rl.MatrixMultiply(transform, rl.MatrixRotateX(-90*rl.Deg2rad))
transform = rl.MatrixMultiply(transform, rl.MatrixScale(1.0, 1.0, 1.0)) transform = rl.MatrixMultiply(transform, rl.MatrixScale(1.0, 1.0, 1.0))
goonerModel.Transform = transform goonerModel.Transform = transform
}
// Always add a model (real or placeholder) // Coomer model (ready for animations)
models = append(models, types.ModelAsset{ 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
// 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",
// })
return []types.ModelAsset{
{
Model: goonerModel, Model: goonerModel,
Animation: append(goonerAnims.Idle, goonerAnims.Walk...), Animation: append(goonerAnims.Idle, goonerAnims.Walk...),
AnimFrames: int32(len(goonerAnims.Idle) + len(goonerAnims.Walk)), AnimFrames: int32(len(goonerAnims.Idle) + len(goonerAnims.Walk)),
Animations: goonerAnims, Animations: goonerAnims,
YOffset: 0.0, 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, Model: coomerModel,
Animation: append(coomerAnims.Idle, coomerAnims.Walk...), Animation: append(coomerAnims.Idle, coomerAnims.Walk...),
AnimFrames: int32(len(coomerAnims.Idle) + len(coomerAnims.Walk)), AnimFrames: int32(len(coomerAnims.Idle) + len(coomerAnims.Walk)),
Animations: coomerAnims, Animations: coomerAnims,
YOffset: -4.0, YOffset: -4.0,
PlaceholderColor: rl.Color{}, // Not a placeholder },
}) {Model: shrekeModel, Texture: shrekeTexture},
} else { }, nil
// 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) { func LoadMusic(filename string) (rl.Music, error) {
defer func() { return rl.LoadMusicStream(filename), nil
// 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) func UnloadModels(models []types.ModelAsset) {
if music.Stream.Buffer == nil { for _, model := range models {
return music, fmt.Errorf("failed to load music: %s", filename) if model.Animation != nil {
for i := int32(0); i < model.AnimFrames; i++ {
rl.UnloadModelAnimation(model.Animation[i])
} }
return music, nil }
rl.UnloadModel(model.Model)
rl.UnloadTexture(model.Texture)
}
}
func UnloadMusic(music rl.Music) {
rl.UnloadMusicStream(music)
} }

View File

@ -2,33 +2,20 @@ package main
import "time" import "time"
// Game world constants
const ( 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 MapWidth = 50
MapHeight = 50 MapHeight = 50
TileSize = 32 TileSize = 32
TileHeight = 2.0 TileHeight = 2.0
) TickRate = 600 * time.Millisecond // Server tick rate (600ms)
serverAddr = "localhost:6969"
// UI constants // RuneScape-style tick rate (600ms)
const ( ServerTickRate = 600 * time.Millisecond
ChatMargin = 10
ChatHeight = 200
MessageHeight = 20
InputHeight = 30
MaxMessages = 50
)
// Environment variable names // Client might run at a higher tick rate for smooth rendering
const ( ClientTickRate = 50 * time.Millisecond
EnvSafeMode = "GOONSCAPE_SAFE_MODE"
EnvDisableAnimations = "GOONSCAPE_DISABLE_ANIMATIONS" // Maximum number of ticks we can get behind before forcing a resync
EnvDisableAudio = "GOONSCAPE_DISABLE_AUDIO" MaxTickDesync = 5
) )

View File

@ -10,7 +10,6 @@ var (
cameraDistance = float32(20.0) cameraDistance = float32(20.0)
cameraYaw = float32(145.0) cameraYaw = float32(145.0)
cameraPitch = float32(45.0) cameraPitch = float32(45.0)
lastMousePos rl.Vector2 // Add this to track mouse movement
) )
func UpdateCamera(camera *rl.Camera3D, player rl.Vector3, deltaTime float32) { func UpdateCamera(camera *rl.Camera3D, player rl.Vector3, deltaTime float32) {
@ -33,34 +32,6 @@ func UpdateCamera(camera *rl.Camera3D, player rl.Vector3, deltaTime float32) {
} }
} }
// Handle middle mouse camera rotation
if rl.IsMouseButtonDown(rl.MouseMiddleButton) {
currentMousePos := rl.GetMousePosition()
// If we just started holding the button, initialize last position
if !rl.IsMouseButtonPressed(rl.MouseMiddleButton) {
mouseDelta := rl.Vector2{
X: currentMousePos.X - lastMousePos.X,
Y: currentMousePos.Y - lastMousePos.Y,
}
// Adjust rotation speed as needed
cameraYaw += mouseDelta.X * 0.5 * deltaTime * 60
cameraPitch += mouseDelta.Y * 0.5 * deltaTime * 60
// Clamp pitch to prevent camera flipping
if cameraPitch < 20 {
cameraPitch = 20
}
if cameraPitch > 85 {
cameraPitch = 85
}
}
lastMousePos = currentMousePos
}
// Keep the keyboard controls too
if rl.IsKeyDown(rl.KeyRight) { if rl.IsKeyDown(rl.KeyRight) {
cameraYaw += 100 * deltaTime cameraYaw += 100 * deltaTime
} }

View File

@ -2,8 +2,6 @@ package game
import ( import (
"fmt" "fmt"
"log"
"sync"
"time" "time"
"gitea.boner.be/bdnugget/goonscape/types" "gitea.boner.be/bdnugget/goonscape/types"
@ -11,8 +9,12 @@ import (
rl "github.com/gen2brain/raylib-go/raylib" rl "github.com/gen2brain/raylib-go/raylib"
) )
// Local UI constants (these could be moved to a centralized constants package later)
const ( const (
maxMessages = 50
chatMargin = 10 // Margin from screen edges
chatHeight = 200
messageHeight = 20
inputHeight = 30
runeLimit = 256 runeLimit = 256
) )
@ -23,13 +25,14 @@ type Chat struct {
cursorPos int cursorPos int
scrollOffset int scrollOffset int
userData interface{} userData interface{}
mutex sync.RWMutex input InputHandler
} }
func NewChat() *Chat { func NewChat() *Chat {
return &Chat{ return &Chat{
messages: make([]types.ChatMessage, 0, types.MaxChatMessages), messages: make([]types.ChatMessage, 0, maxMessages),
inputBuffer: make([]rune, 0, runeLimit), inputBuffer: make([]rune, 0, runeLimit),
input: &RaylibInput{},
} }
} }
@ -40,7 +43,7 @@ func (c *Chat) AddMessage(playerID int32, content string) {
Time: time.Now(), Time: time.Now(),
} }
if len(c.messages) >= types.MaxChatMessages { if len(c.messages) >= maxMessages {
c.messages = c.messages[1:] c.messages = c.messages[1:]
} }
c.messages = append(c.messages, msg) c.messages = append(c.messages, msg)
@ -48,23 +51,8 @@ func (c *Chat) AddMessage(playerID int32, content string) {
} }
func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) { 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 // Convert protobuf messages to our local type
for _, msg := range messages { for _, msg := range messages {
// Skip invalid messages
if msg == nil {
log.Printf("Warning: Received nil chat message")
continue
}
localMsg := types.ChatMessage{ localMsg := types.ChatMessage{
PlayerID: msg.PlayerId, PlayerID: msg.PlayerId,
Username: msg.Username, Username: msg.Username,
@ -74,54 +62,33 @@ func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) {
// Only add if it's not already in our history // 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) == 0 || c.messages[len(c.messages)-1].Time.UnixNano() < msg.Timestamp {
if len(c.messages) >= types.MaxChatMessages { if len(c.messages) >= maxMessages {
c.messages = c.messages[1:] c.messages = c.messages[1:]
} }
c.messages = append(c.messages, localMsg) 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 // Scroll to latest message if it's not already visible
visibleMessages := int((types.ChatHeight - types.InputHeight) / types.MessageHeight) visibleMessages := int((chatHeight - inputHeight) / messageHeight)
if len(c.messages) > visibleMessages { if len(c.messages) > visibleMessages {
c.scrollOffset = len(c.messages) - visibleMessages c.scrollOffset = len(c.messages) - visibleMessages
} }
// Add floating message to the player // Add floating message to the player
if game, ok := c.userData.(*Game); ok && game != nil { if game, ok := c.userData.(*Game); ok {
// Make sure each game component exists before using it if msg.PlayerId == game.Player.ID {
if game.PlayerManager == nil { game.Player.Lock()
log.Printf("Warning: PlayerManager is nil when processing chat message") game.Player.FloatingMessage = &types.FloatingMessage{
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, Content: msg.Content,
ExpireTime: time.Now().Add(6 * time.Second), ExpireTime: time.Now().Add(6 * time.Second),
} }
game.PlayerManager.LocalPlayer.Unlock() game.Player.Unlock()
} else { } else if otherPlayer, exists := game.OtherPlayers[msg.PlayerId]; exists {
// The other player might not be in our list yet, handle safely otherPlayer.Lock()
player := game.PlayerManager.GetPlayer(msg.PlayerId) otherPlayer.FloatingMessage = &types.FloatingMessage{
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, Content: msg.Content,
ExpireTime: time.Now().Add(6 * time.Second), ExpireTime: time.Now().Add(6 * time.Second),
} }
player.Unlock() otherPlayer.Unlock()
log.Printf("Added floating message to other player %d", msg.PlayerId)
} }
} }
} }
@ -129,20 +96,17 @@ func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) {
} }
func (c *Chat) Draw(screenWidth, screenHeight int32) { func (c *Chat) Draw(screenWidth, screenHeight int32) {
c.mutex.RLock()
defer c.mutex.RUnlock()
// Calculate chat window width based on screen width // Calculate chat window width based on screen width
chatWindowWidth := screenWidth - (types.ChatMargin * 2) chatWindowWidth := screenWidth - (chatMargin * 2)
// Draw chat window background // Draw chat window background
chatX := float32(types.ChatMargin) chatX := float32(chatMargin)
chatY := float32(screenHeight - types.ChatHeight - types.ChatMargin) chatY := float32(screenHeight - chatHeight - chatMargin)
rl.DrawRectangle(int32(chatX), int32(chatY), chatWindowWidth, types.ChatHeight, rl.ColorAlpha(rl.Black, 0.5)) rl.DrawRectangle(int32(chatX), int32(chatY), chatWindowWidth, chatHeight, rl.ColorAlpha(rl.Black, 0.5))
// Draw messages from oldest to newest // Draw messages from oldest to newest
messageY := chatY + 5 messageY := chatY + 5
visibleMessages := int((types.ChatHeight - types.InputHeight) / types.MessageHeight) visibleMessages := int((chatHeight - inputHeight) / messageHeight)
// Auto-scroll to bottom if no manual scrolling has occurred // Auto-scroll to bottom if no manual scrolling has occurred
if c.scrollOffset == 0 { if c.scrollOffset == 0 {
@ -164,12 +128,12 @@ func (c *Chat) Draw(screenWidth, screenHeight int32) {
} }
text := fmt.Sprintf("%s: %s", msg.Username, msg.Content) text := fmt.Sprintf("%s: %s", msg.Username, msg.Content)
rl.DrawText(text, int32(chatX)+5, int32(messageY), 20, color) rl.DrawText(text, int32(chatX)+5, int32(messageY), 20, color)
messageY += types.MessageHeight messageY += messageHeight
} }
// Draw input field // Draw input field
inputY := chatY + float32(types.ChatHeight-types.InputHeight) inputY := chatY + float32(chatHeight-inputHeight)
rl.DrawRectangle(int32(chatX), int32(inputY), chatWindowWidth, types.InputHeight, rl.ColorAlpha(rl.White, 0.3)) rl.DrawRectangle(int32(chatX), int32(inputY), chatWindowWidth, inputHeight, rl.ColorAlpha(rl.White, 0.3))
if c.isTyping { if c.isTyping {
inputText := string(c.inputBuffer) inputText := string(c.inputBuffer)
rl.DrawText(inputText, int32(chatX)+5, int32(inputY)+5, 20, rl.White) rl.DrawText(inputText, int32(chatX)+5, int32(inputY)+5, 20, rl.White)
@ -187,27 +151,27 @@ func (c *Chat) Update() (string, bool) {
if !c.isTyping { if !c.isTyping {
wheelMove := rl.GetMouseWheelMove() wheelMove := rl.GetMouseWheelMove()
if wheelMove != 0 { if wheelMove != 0 {
maxScroll := max(0, len(c.messages)-int((types.ChatHeight-types.InputHeight)/types.MessageHeight)) maxScroll := max(0, len(c.messages)-int((chatHeight-inputHeight)/messageHeight))
c.scrollOffset = clamp(c.scrollOffset-int(wheelMove), 0, maxScroll) c.scrollOffset = clamp(c.scrollOffset-int(wheelMove), 0, maxScroll)
} }
if rl.IsKeyPressed(rl.KeyT) { if c.input.IsKeyPressed(rl.KeyT) {
c.isTyping = true c.isTyping = true
return "", false return "", false
} }
return "", false return "", false
} }
key := rl.GetCharPressed() key := c.input.GetCharPressed()
for key > 0 { for key > 0 {
if len(c.inputBuffer) < runeLimit { if len(c.inputBuffer) < runeLimit {
c.inputBuffer = append(c.inputBuffer[:c.cursorPos], append([]rune{key}, c.inputBuffer[c.cursorPos:]...)...) c.inputBuffer = append(c.inputBuffer[:c.cursorPos], append([]rune{key}, c.inputBuffer[c.cursorPos:]...)...)
c.cursorPos++ c.cursorPos++
} }
key = rl.GetCharPressed() key = c.input.GetCharPressed()
} }
if rl.IsKeyPressed(rl.KeyEnter) || rl.IsKeyPressed(rl.KeyKpEnter) { if c.input.IsKeyPressed(rl.KeyEnter) || c.input.IsKeyPressed(rl.KeyKpEnter) {
if len(c.inputBuffer) > 0 { if len(c.inputBuffer) > 0 {
message := string(c.inputBuffer) message := string(c.inputBuffer)
c.inputBuffer = c.inputBuffer[:0] c.inputBuffer = c.inputBuffer[:0]
@ -218,21 +182,21 @@ func (c *Chat) Update() (string, bool) {
c.isTyping = false c.isTyping = false
} }
if rl.IsKeyPressed(rl.KeyEscape) && c.isTyping { if c.input.IsKeyPressed(rl.KeyEscape) && c.isTyping {
c.inputBuffer = c.inputBuffer[:0] c.inputBuffer = c.inputBuffer[:0]
c.cursorPos = 0 c.cursorPos = 0
c.isTyping = false c.isTyping = false
} }
if rl.IsKeyPressed(rl.KeyBackspace) && c.cursorPos > 0 { if c.input.IsKeyPressed(rl.KeyBackspace) && c.cursorPos > 0 {
c.inputBuffer = append(c.inputBuffer[:c.cursorPos-1], c.inputBuffer[c.cursorPos:]...) c.inputBuffer = append(c.inputBuffer[:c.cursorPos-1], c.inputBuffer[c.cursorPos:]...)
c.cursorPos-- c.cursorPos--
} }
if rl.IsKeyPressed(rl.KeyLeft) && c.cursorPos > 0 { if c.input.IsKeyPressed(rl.KeyLeft) && c.cursorPos > 0 {
c.cursorPos-- c.cursorPos--
} }
if rl.IsKeyPressed(rl.KeyRight) && c.cursorPos < len(c.inputBuffer) { if c.input.IsKeyPressed(rl.KeyRight) && c.cursorPos < len(c.inputBuffer) {
c.cursorPos++ c.cursorPos++
} }

106
game/chat_test.go Normal file
View File

@ -0,0 +1,106 @@
package game
import (
"testing"
"time"
"gitea.boner.be/bdnugget/goonscape/game/testutils"
"gitea.boner.be/bdnugget/goonscape/types"
pb "gitea.boner.be/bdnugget/goonserver/actions"
rl "github.com/gen2brain/raylib-go/raylib"
"github.com/stretchr/testify/assert"
)
func TestChat_AddMessage(t *testing.T) {
chat := NewChat()
// Test adding single message
chat.AddMessage(1, "Hello")
assert.Equal(t, 1, len(chat.messages))
assert.Equal(t, int32(1), chat.messages[0].PlayerID)
assert.Equal(t, "Hello", chat.messages[0].Content)
// Test message limit
for i := 0; i < maxMessages+10; i++ {
chat.AddMessage(1, "spam")
}
assert.Equal(t, maxMessages, len(chat.messages))
assert.Equal(t, "spam", chat.messages[len(chat.messages)-1].Content)
}
func TestChat_HandleServerMessages(t *testing.T) {
chat := NewChat()
mockGame := &Game{
Player: &types.Player{ID: 1},
OtherPlayers: map[int32]*types.Player{
2: {ID: 2},
},
}
chat.userData = mockGame
messages := []*pb.ChatMessage{
{
PlayerId: 1,
Username: "player1",
Content: "test1",
Timestamp: time.Now().UnixNano(),
},
{
PlayerId: 2,
Username: "player2",
Content: "test2",
Timestamp: time.Now().UnixNano(),
},
}
chat.HandleServerMessages(messages)
assert.Equal(t, 2, len(chat.messages))
assert.Equal(t, "test1", chat.messages[0].Content)
assert.Equal(t, "test2", chat.messages[1].Content)
// Test duplicate message prevention
chat.HandleServerMessages(messages)
assert.Equal(t, 2, len(chat.messages))
}
func TestChat_Update(t *testing.T) {
t.Parallel()
done := make(chan bool)
go func() {
game, cleanup := setupTestEnvironment(t)
defer cleanup()
chat := game.Chat
// Test starting chat
testutils.SimulateKeyPress(rl.KeyT)
msg, sent := chat.Update()
assert.True(t, chat.isTyping)
assert.False(t, sent)
assert.Empty(t, msg)
// Test typing message
testutils.SimulateCharInput('h')
msg, sent = chat.Update()
testutils.SimulateCharInput('i')
msg, sent = chat.Update()
assert.Equal(t, 2, len(chat.inputBuffer))
assert.False(t, sent)
assert.Empty(t, msg)
// Test sending message
testutils.SimulateKeyPress(rl.KeyEnter)
msg, sent = chat.Update()
assert.True(t, sent)
assert.Equal(t, "hi", msg)
assert.False(t, chat.isTyping)
done <- true
}()
select {
case <-done:
// Test completed successfully
case <-time.After(5 * time.Second):
t.Fatal("Test timed out")
}
}

View File

@ -1,107 +0,0 @@
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)
}
}

View File

@ -1,8 +1,7 @@
package game package game
import ( import (
"log" "os"
"sync"
"time" "time"
"gitea.boner.be/bdnugget/goonscape/assets" "gitea.boner.be/bdnugget/goonscape/assets"
@ -13,179 +12,115 @@ import (
) )
type Game struct { type Game struct {
// Component-based architecture Player *types.Player
PlayerManager *PlayerManager OtherPlayers map[int32]*types.Player
AssetManager *AssetManager
UIManager *UIManager
// Core game state
Camera rl.Camera3D Camera rl.Camera3D
quitChan chan struct{} Models []types.ModelAsset
cleanupOnce sync.Once Music rl.Music
frameCounter int // For periodic logging Chat *Chat
MenuOpen bool
QuitChan chan struct{} // Channel to signal shutdown
loginScreen *LoginScreen
isLoggedIn bool
input InputHandler
} }
func New() *Game { func New() *Game {
// Create managers InitWorld()
playerManager := NewPlayerManager() game := &Game{
assetManager := NewAssetManager() OtherPlayers: make(map[int32]*types.Player),
uiManager := NewUIManager()
g := &Game{
PlayerManager: playerManager,
AssetManager: assetManager,
UIManager: uiManager,
Camera: rl.Camera3D{ Camera: rl.Camera3D{
Position: rl.NewVector3(0.0, 20.0, 0.0), Position: rl.NewVector3(0, 10, 10),
Target: rl.NewVector3(0.0, 0.0, 0.0), Target: rl.NewVector3(0, 0, 0),
Up: rl.NewVector3(0.0, 1.0, 0.0), Up: rl.NewVector3(0, 1, 0),
Fovy: 45.0, Fovy: 45.0,
Projection: rl.CameraPerspective, Projection: rl.CameraPerspective,
}, },
quitChan: make(chan struct{}), Chat: NewChat(),
QuitChan: make(chan struct{}),
loginScreen: NewLoginScreen(),
input: &RaylibInput{},
} }
game.Chat.userData = game
// Set up inter-component references return game
g.UIManager.Chat.userData = g // Pass game instance to chat for callbacks
// Initialize world
InitWorld()
return g
} }
func (g *Game) LoadAssets() error { func (g *Game) LoadAssets() error {
return SafeExecute(func() error {
// Load models
var err error var err error
models, err := assets.LoadModels() g.Models, err = assets.LoadModels()
if err != nil { if err != nil {
log.Printf("Warning: Failed to load models: %v", err) return err
} }
g.AssetManager.Models = models
// Try to load music g.Music, err = assets.LoadMusic("resources/audio/GoonScape2.mp3")
music, err := assets.LoadMusic("resources/audio/GoonScape1.mp3")
if err != nil { if err != nil {
log.Printf("Warning: Failed to load music: %v", err) return err
} else {
g.AssetManager.Music = music
} }
return nil return nil
})
} }
func (g *Game) Update(deltaTime float32) { func (g *Game) Update(deltaTime float32) {
// Handle login screen if not logged in if !g.isLoggedIn {
if !g.UIManager.IsLoggedIn { username, password, isRegistering, submitted := g.loginScreen.Update()
// Handle login if submitted {
username, password, isRegistering, doAuth := g.UIManager.LoginScreen.Update()
if doAuth {
conn, playerID, err := network.ConnectToServer(username, password, isRegistering) conn, playerID, err := network.ConnectToServer(username, password, isRegistering)
if err != nil { if err != nil {
g.UIManager.LoginScreen.SetError(err.Error()) g.loginScreen.SetError(err.Error())
return return
} }
g.PlayerManager.LocalPlayer = &types.Player{ g.Player = &types.Player{
Speed: 50.0, Speed: 50.0,
TargetPath: []types.Tile{}, TargetPath: []types.Tile{},
ActionQueue: []*pb.Action{}, UserData: g,
QuitDone: make(chan struct{}), QuitDone: make(chan struct{}),
ID: playerID, ID: playerID,
} }
g.AssignModelToPlayer(g.PlayerManager.LocalPlayer) g.AssignModelToPlayer(g.Player)
// Set user data to allow chat message handling go network.HandleServerCommunication(conn, playerID, g.Player, g.OtherPlayers, g.QuitChan)
g.PlayerManager.LocalPlayer.UserData = g g.isLoggedIn = true
go network.HandleServerCommunication(conn, playerID, g.PlayerManager.LocalPlayer, g.PlayerManager.OtherPlayers, g.quitChan)
g.UIManager.IsLoggedIn = true
}
return return
} }
g.loginScreen.Draw()
// Skip update logic if player is not initialized yet
if g.PlayerManager.LocalPlayer == nil {
log.Printf("Warning: LocalPlayer is nil during update, skipping")
return return
} }
// Handle ESC for menu // Handle ESC for menu
if rl.IsKeyPressed(rl.KeyEscape) { if g.input.IsKeyPressed(rl.KeyEscape) {
g.UIManager.MenuOpen = !g.UIManager.MenuOpen g.MenuOpen = !g.MenuOpen
return return
} }
// Don't process other inputs if menu is open // Don't process other inputs if menu is open
if g.UIManager.MenuOpen { if g.MenuOpen {
return return
} }
// Handle chat updates if message, sent := g.Chat.Update(); sent {
if message, sent := g.UIManager.Chat.Update(); sent { g.Player.Lock()
g.PlayerManager.LocalPlayer.Lock() g.Player.ActionQueue = append(g.Player.ActionQueue, &pb.Action{
g.PlayerManager.LocalPlayer.ActionQueue = append(g.PlayerManager.LocalPlayer.ActionQueue, &pb.Action{
Type: pb.Action_CHAT, Type: pb.Action_CHAT,
ChatMessage: message, ChatMessage: message,
PlayerId: g.PlayerManager.LocalPlayer.ID, PlayerId: g.Player.ID,
}) })
g.PlayerManager.LocalPlayer.Unlock() g.Player.Unlock()
} }
// Process player input
g.HandleInput() g.HandleInput()
// Update local player movement if len(g.Player.TargetPath) > 0 {
if g.PlayerManager.LocalPlayer.TargetPath != nil && len(g.PlayerManager.LocalPlayer.TargetPath) > 0 { g.Player.MoveTowards(g.Player.TargetPath[0], deltaTime, GetMapGrid())
g.PlayerManager.LocalPlayer.MoveTowards(g.PlayerManager.LocalPlayer.TargetPath[0], deltaTime, GetMapGrid())
} }
// Periodically log information about other players for _, other := range g.OtherPlayers {
g.frameCounter++ if len(other.TargetPath) > 0 {
if g.frameCounter%300 == 0 { other.MoveTowards(other.TargetPath[0], deltaTime, GetMapGrid())
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 UpdateCamera(&g.Camera, g.Player.PosActual, deltaTime)
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() { func (g *Game) DrawMap() {
@ -217,169 +152,148 @@ func (g *Game) DrawMap() {
} }
func (g *Game) DrawPlayer(player *types.Player, model rl.Model) { func (g *Game) DrawPlayer(player *types.Player, model rl.Model) {
if player == nil { player.Lock()
return defer player.Unlock()
}
// Get necessary data grid := GetMapGrid()
modelIndex := int(player.ID) % len(g.AssetManager.Models) modelIndex := int(player.ID) % len(g.Models)
if modelIndex < 0 || modelIndex >= len(g.AssetManager.Models) { modelAsset := g.Models[modelIndex]
modelIndex = 0
}
modelAsset := g.AssetManager.Models[modelIndex]
// Calculate position const defaultHeight = 8.0 // Default height above tile, fine tune per model in types.ModelAsset
const defaultHeight = 8.0
playerPos := rl.Vector3{ playerPos := rl.Vector3{
X: player.PosActual.X, X: player.PosActual.X,
Y: player.PosActual.Y + defaultHeight + modelAsset.YOffset, Y: grid[player.PosTile.X][player.PosTile.Y].Height*types.TileHeight + defaultHeight + modelAsset.YOffset,
Z: player.PosActual.Z, Z: player.PosActual.Z,
} }
// Simple drawing with scale parameter // Check if model has animations
var drawColor rl.Color = rl.White if modelAsset.Animations.Idle != nil || modelAsset.Animations.Walk != nil {
if player.PlaceholderColor.A > 0 { if player.IsMoving && len(modelAsset.Animations.Walk) > 0 {
drawColor = player.PlaceholderColor anim := modelAsset.Animations.Walk[0] // Use first walk animation
player.AnimationFrame = player.AnimationFrame % anim.FrameCount
rl.UpdateModelAnimation(model, anim, player.AnimationFrame)
} 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)
}
} }
// Draw the model at normal scale (16.0) rl.DrawModel(model, playerPos, 16, rl.White)
rl.DrawModel(model, playerPos, 16.0, drawColor)
// Update floating message position // Draw floating messages and path indicators
if player.FloatingMessage != nil { if player.FloatingMessage != nil {
worldPos := rl.Vector3{ screenPos := rl.GetWorldToScreen(rl.Vector3{
X: playerPos.X, X: playerPos.X,
Y: playerPos.Y + 24.0, // Position above head Y: playerPos.Y + 24.0,
Z: playerPos.Z, Z: playerPos.Z,
} }, g.Camera)
player.FloatingMessage.ScreenPos = rl.GetWorldToScreen(worldPos, g.Camera)
} player.FloatingMessage.ScreenPos = screenPos
} }
func (g *Game) DrawFloatingMessages() { if len(player.TargetPath) > 0 {
var drawFloatingMessage = func(msg *types.FloatingMessage) { targetTile := player.TargetPath[len(player.TargetPath)-1]
if msg == nil || time.Now().After(msg.ExpireTime) { targetPos := rl.Vector3{
return 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)
// Draw the message with RuneScape-style coloring (black outline with yellow text) nextTile := player.TargetPath[0]
text := msg.Content nextPos := rl.Vector3{
textWidth := rl.MeasureText(text, 20) X: float32(nextTile.X * types.TileSize),
Y: grid[nextTile.X][nextTile.Y].Height * types.TileHeight,
// Draw black outline by offsetting the text slightly in all directions Z: float32(nextTile.Y * types.TileSize),
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)
} }
rl.DrawCubeWires(nextPos, types.TileSize, types.TileHeight, types.TileSize, rl.Yellow)
} }
} }
func (g *Game) Render() { func (g *Game) Render() {
rl.BeginDrawing() rl.BeginDrawing()
defer rl.EndDrawing()
rl.ClearBackground(rl.RayWhite) rl.ClearBackground(rl.RayWhite)
if !g.UIManager.IsLoggedIn { if !g.isLoggedIn {
g.UIManager.LoginScreen.Draw() g.loginScreen.Draw()
rl.EndDrawing()
return return
} }
// Draw 3D elements
rl.BeginMode3D(g.Camera) rl.BeginMode3D(g.Camera)
g.DrawMap() g.DrawMap()
g.DrawPlayer(g.Player, g.Player.Model)
// Draw player only if valid for _, other := range g.OtherPlayers {
if g.PlayerManager.LocalPlayer != nil && g.PlayerManager.LocalPlayer.Model.Meshes != nil { if other.Model.Meshes == nil {
g.DrawPlayer(g.PlayerManager.LocalPlayer, g.PlayerManager.LocalPlayer.Model) g.AssignModelToPlayer(other)
} }
// 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) g.DrawPlayer(other, other.Model)
} }
}
rl.EndMode3D() rl.EndMode3D()
// Draw floating messages with RuneScape style // Draw floating messages
g.DrawFloatingMessages() 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)
}
// Draw menu if open // Draw menu if open
if g.UIManager.MenuOpen { if g.MenuOpen {
g.DrawMenu() g.DrawMenu()
} }
// Only draw chat if menu is not open // Only draw chat if menu is not open
if !g.UIManager.MenuOpen && g.UIManager.Chat != nil { if !g.MenuOpen {
g.UIManager.Chat.Draw(int32(rl.GetScreenWidth()), int32(rl.GetScreenHeight())) g.Chat.Draw(int32(rl.GetScreenWidth()), int32(rl.GetScreenHeight()))
} }
// Draw FPS counter
rl.DrawFPS(10, 10) rl.DrawFPS(10, 10)
rl.EndDrawing()
} }
func (g *Game) Cleanup() { func (g *Game) Cleanup() {
g.cleanupOnce.Do(func() { assets.UnloadModels(g.Models)
// Cleanup models assets.UnloadMusic(g.Music)
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() { func (g *Game) HandleInput() {
clickedTile, clicked := g.GetTileAtMouse() clickedTile, clicked := g.GetTileAtMouse()
if clicked { if clicked {
path := FindPath(GetTile(g.PlayerManager.LocalPlayer.PosTile.X, g.PlayerManager.LocalPlayer.PosTile.Y), clickedTile) path := FindPath(GetTile(g.Player.PosTile.X, g.Player.PosTile.Y), clickedTile)
if len(path) > 1 { if len(path) > 1 {
g.PlayerManager.LocalPlayer.Lock() g.Player.Lock()
g.PlayerManager.LocalPlayer.TargetPath = path[1:] g.Player.TargetPath = path[1:]
g.PlayerManager.LocalPlayer.ActionQueue = append(g.PlayerManager.LocalPlayer.ActionQueue, &pb.Action{ g.Player.ActionQueue = append(g.Player.ActionQueue, &pb.Action{
Type: pb.Action_MOVE, Type: pb.Action_MOVE,
X: int32(clickedTile.X), X: int32(clickedTile.X),
Y: int32(clickedTile.Y), Y: int32(clickedTile.Y),
PlayerId: g.PlayerManager.LocalPlayer.ID, PlayerId: g.Player.ID,
}) })
g.PlayerManager.LocalPlayer.Unlock() g.Player.Unlock()
} }
} }
} }
@ -388,80 +302,76 @@ func (g *Game) DrawMenu() {
screenWidth := float32(rl.GetScreenWidth()) screenWidth := float32(rl.GetScreenWidth())
screenHeight := float32(rl.GetScreenHeight()) screenHeight := float32(rl.GetScreenHeight())
// Draw semi-transparent background // Semi-transparent background
rl.DrawRectangle(0, 0, int32(screenWidth), int32(screenHeight), rl.ColorAlpha(rl.Black, 0.7)) rl.DrawRectangle(0, 0, int32(screenWidth), int32(screenHeight), rl.ColorAlpha(rl.Black, 0.7))
// Draw menu items // Menu title
menuItems := []string{"Resume", "Settings", "Quit"} title := "Menu"
menuY := screenHeight/2 - float32(len(menuItems)*40)/2 titleSize := int32(40)
titleWidth := rl.MeasureText(title, titleSize)
rl.DrawText(title, int32(screenWidth/2)-titleWidth/2, 100, titleSize, rl.White)
for i, item := range menuItems { // Menu buttons
itemY := menuY + float32(i*40) buttonWidth := float32(200)
mousePoint := rl.GetMousePosition() buttonHeight := float32(40)
itemRect := rl.Rectangle{X: screenWidth/2 - 100, Y: itemY, Width: 200, Height: 36} buttonY := float32(200)
buttonSpacing := float32(60)
// Check for hover menuItems := []string{"Resume", "Settings", "Exit Game"}
isHover := rl.CheckCollisionPointRec(mousePoint, itemRect) for _, item := range menuItems {
buttonRect := rl.Rectangle{
X: screenWidth/2 - buttonWidth/2,
Y: buttonY,
Width: buttonWidth,
Height: buttonHeight,
}
// Draw button background // Check mouse hover
if isHover { mousePoint := g.input.GetMousePosition()
rl.DrawRectangleRec(itemRect, rl.ColorAlpha(rl.White, 0.3)) mouseHover := rl.CheckCollisionPointRec(mousePoint, buttonRect)
} else {
rl.DrawRectangleRec(itemRect, rl.ColorAlpha(rl.White, 0.1)) // Draw button
if mouseHover {
rl.DrawRectangleRec(buttonRect, rl.ColorAlpha(rl.White, 0.3))
if g.input.IsMouseButtonPressed(toInt32(rl.MouseLeftButton)) {
switch item {
case "Resume":
g.MenuOpen = false
case "Settings":
// TODO: Implement settings
case "Exit Game":
g.Shutdown()
}
}
} }
// Draw button text // Draw button text
textWidth := rl.MeasureText(item, 20) textSize := int32(20)
rl.DrawText(item, int32(itemRect.X+(itemRect.Width-float32(textWidth))/2), int32(itemRect.Y+8), 20, rl.White) 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)
// Handle click buttonY += buttonSpacing
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() { func (g *Game) Shutdown() {
// Use the cleanup method which has channel-closing safety close(g.QuitChan)
g.Cleanup() <-g.Player.QuitDone
rl.CloseWindow()
os.Exit(0)
}
func (g *Game) HandleServerMessages(messages []*pb.ChatMessage) {
g.Chat.HandleServerMessages(messages)
} }
func (g *Game) AssignModelToPlayer(player *types.Player) { func (g *Game) AssignModelToPlayer(player *types.Player) {
if player == nil { modelIndex := int(player.ID) % len(g.Models)
return modelAsset := g.Models[modelIndex]
}
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.Model = modelAsset.Model
player.PlaceholderColor = modelAsset.PlaceholderColor player.Texture = modelAsset.Texture
// Initialize animations if available
if len(modelAsset.Animations.Idle) > 0 || len(modelAsset.Animations.Walk) > 0 {
player.InitializeAnimations(modelAsset.Animations)
}
} }

106
game/game_test.go Normal file
View File

@ -0,0 +1,106 @@
package game
import (
"testing"
"gitea.boner.be/bdnugget/goonscape/game/testutils"
"gitea.boner.be/bdnugget/goonscape/types"
pb "gitea.boner.be/bdnugget/goonserver/actions"
rl "github.com/gen2brain/raylib-go/raylib"
"github.com/stretchr/testify/assert"
)
func TestGame_HandleInput(t *testing.T) {
game := New()
game.Player = &types.Player{
ID: 1,
Speed: 50.0,
}
// Test valid click
simulateMouseRay(rl.Ray{
Position: rl.Vector3{X: 0, Y: 10, Z: 0},
Direction: rl.Vector3{X: 0, Y: -1, Z: 0},
})
simulateMouseButton(toInt32(rl.MouseLeftButton), true)
game.HandleInput()
assert.NotEmpty(t, game.Player.TargetPath)
// Test invalid click (outside map)
simulateMouseRay(rl.Ray{
Position: rl.Vector3{X: 1000, Y: 10, Z: 1000},
Direction: rl.Vector3{X: 0, Y: -1, Z: 0},
})
simulateMouseButton(toInt32(rl.MouseLeftButton), true)
game.HandleInput()
assert.Empty(t, game.Player.TargetPath)
}
func TestGame_UpdateCamera(t *testing.T) {
game := New()
// Test zoom limits
testutils.SimulateMouseWheel(1.0) // Zoom in
testutils.SimulateMouseWheel(1.0)
assert.GreaterOrEqual(t, cameraDistance, float32(10.0))
testutils.SimulateMouseWheel(-1.0) // Zoom out
testutils.SimulateMouseWheel(-1.0)
assert.LessOrEqual(t, cameraDistance, float32(250.0))
// Test camera rotation
originalYaw := cameraYaw
testutils.SimulateKeyDown(rl.KeyRight, true)
game.Update(0.1)
assert.Greater(t, cameraYaw, originalYaw)
// Test pitch limits
simulateKeyDown(rl.KeyUp, true)
for i := 0; i < 100; i++ {
game.Update(0.1)
}
assert.GreaterOrEqual(t, cameraPitch, float32(20.0))
assert.LessOrEqual(t, cameraPitch, float32(85.0))
}
func TestGame_ChatIntegration(t *testing.T) {
game := New()
game.Player = &types.Player{ID: 1}
// Test chat message to action queue
testutils.SimulateKeyPress(rl.KeyT)
game.Update(0.1)
assert.True(t, game.Chat.isTyping)
testutils.SimulateCharInput('h')
testutils.SimulateCharInput('i')
testutils.SimulateKeyPress(rl.KeyEnter)
game.Update(0.1)
assert.Equal(t, 1, len(game.Player.ActionQueue))
assert.Equal(t, pb.Action_CHAT, game.Player.ActionQueue[0].Type)
assert.Equal(t, "hi", game.Player.ActionQueue[0].ChatMessage)
}
func TestGame_MenuHandling(t *testing.T) {
game := New()
// Test menu toggle
assert.False(t, game.MenuOpen)
testutils.SimulateKeyPress(rl.KeyEscape)
game.Update(0.1)
assert.True(t, game.MenuOpen)
// Test input blocking when menu is open
game.Player = &types.Player{ID: 1}
testutils.SimulateMouseButton(testutils.ToInt32(rl.MouseLeftButton), true)
game.Update(0.1)
assert.Empty(t, game.Player.TargetPath)
// Test menu close
testutils.SimulateKeyPress(rl.KeyEscape)
game.Update(0.1)
assert.False(t, game.MenuOpen)
}
// Add more test helpers as needed...

View File

@ -3,16 +3,95 @@ package game
import ( import (
"fmt" "fmt"
"gitea.boner.be/bdnugget/goonscape/game/mock"
"gitea.boner.be/bdnugget/goonscape/types" "gitea.boner.be/bdnugget/goonscape/types"
rl "github.com/gen2brain/raylib-go/raylib" rl "github.com/gen2brain/raylib-go/raylib"
) )
// InputHandler abstracts raylib input functions for testing
type InputHandler interface {
IsKeyPressed(key int32) bool
IsKeyDown(key int32) bool
IsMouseButtonPressed(button int32) bool
GetMousePosition() rl.Vector2
GetMouseRay(mousePos rl.Vector2, camera rl.Camera3D) rl.Ray
GetMouseWheelMove() float32
GetCharPressed() rune
}
// RaylibInput implements InputHandler using actual raylib functions
type RaylibInput struct{}
func (r *RaylibInput) IsKeyPressed(key int32) bool { return rl.IsKeyPressed(key) }
func (r *RaylibInput) IsKeyDown(key int32) bool { return rl.IsKeyDown(key) }
func (r *RaylibInput) IsMouseButtonPressed(button int32) bool {
return rl.IsMouseButtonPressed(rl.MouseButton(button))
}
func (r *RaylibInput) GetMousePosition() rl.Vector2 { return rl.GetMousePosition() }
func (r *RaylibInput) GetMouseRay(mousePos rl.Vector2, camera rl.Camera3D) rl.Ray {
return rl.GetMouseRay(mousePos, camera)
}
func (r *RaylibInput) GetMouseWheelMove() float32 { return rl.GetMouseWheelMove() }
func (r *RaylibInput) GetCharPressed() rune { return rl.GetCharPressed() }
// MockInput implements InputHandler using our mock functions
type MockInput struct{}
func (m *MockInput) IsKeyPressed(key int32) bool {
if mock.IsKeyPressed == nil {
return false
}
return mock.IsKeyPressed(key)
}
func (m *MockInput) IsKeyDown(key int32) bool {
if mock.IsKeyDown == nil {
return false
}
return mock.IsKeyDown(key)
}
func (m *MockInput) IsMouseButtonPressed(button int32) bool {
if mock.IsMouseButtonPressed == nil {
return false
}
return mock.IsMouseButtonPressed(button)
}
func (m *MockInput) GetMousePosition() rl.Vector2 {
if mock.GetMousePosition == nil {
return rl.Vector2{}
}
return mock.GetMousePosition()
}
func (m *MockInput) GetMouseRay(mousePos rl.Vector2, camera rl.Camera3D) rl.Ray {
if mock.GetMouseRay == nil {
return rl.Ray{}
}
return mock.GetMouseRay(mousePos, camera)
}
func (m *MockInput) GetMouseWheelMove() float32 {
if mock.GetMouseWheelMove == nil {
return 0
}
return mock.GetMouseWheelMove()
}
func (m *MockInput) GetCharPressed() rune {
if mock.GetCharPressed == nil {
return 0
}
return mock.GetCharPressed()
}
func (g *Game) GetTileAtMouse() (types.Tile, bool) { func (g *Game) GetTileAtMouse() (types.Tile, bool) {
if !rl.IsMouseButtonPressed(rl.MouseLeftButton) { if !g.input.IsMouseButtonPressed(toInt32(rl.MouseLeftButton)) {
return types.Tile{}, false return types.Tile{}, false
} }
mouse := rl.GetMousePosition() mouse := g.input.GetMousePosition()
ray := rl.GetMouseRay(mouse, g.Camera) ray := g.input.GetMouseRay(mouse, g.Camera)
for x := 0; x < types.MapWidth; x++ { for x := 0; x < types.MapWidth; x++ {
for y := 0; y < types.MapHeight; y++ { for y := 0; y < types.MapHeight; y++ {

187
game/input_test.go Normal file
View File

@ -0,0 +1,187 @@
package game
import (
"testing"
"gitea.boner.be/bdnugget/goonscape/game/testutils"
"gitea.boner.be/bdnugget/goonscape/types"
rl "github.com/gen2brain/raylib-go/raylib"
"github.com/stretchr/testify/assert"
)
func TestMouseInput_EdgeCases(t *testing.T) {
game, cleanup := setupTestEnvironment(t)
defer cleanup()
game.Player = &types.Player{ID: 1}
tests := []struct {
name string
ray rl.Ray
expected bool
}{
{
name: "Click outside map bounds",
ray: rl.Ray{
Position: rl.Vector3{X: 1000, Y: 10, Z: 1000},
Direction: rl.Vector3{X: 0, Y: -1, Z: 0},
},
expected: false,
},
{
name: "Click at map edge",
ray: rl.Ray{
Position: rl.Vector3{X: float32(types.MapWidth * types.TileSize), Y: 10, Z: 0},
Direction: rl.Vector3{X: 0, Y: -1, Z: 0},
},
expected: false,
},
{
name: "Click on valid tile",
ray: rl.Ray{
Position: rl.Vector3{X: 32, Y: 10, Z: 32},
Direction: rl.Vector3{X: 0, Y: -1, Z: 0},
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testutils.ResetMockInput()
testutils.SimulateMouseRay(tt.ray)
testutils.SimulateMouseButton(testutils.ToInt32(rl.MouseLeftButton), true)
tile, clicked := game.GetTileAtMouse()
assert.Equal(t, tt.expected, clicked)
if tt.expected {
assert.NotEmpty(t, tile)
}
})
}
}
func TestChat_InputValidation(t *testing.T) {
game, cleanup := setupTestEnvironment(t)
defer cleanup()
game.Player = &types.Player{ID: 1}
tests := []struct {
name string
input []rune
expected string
}{
{
name: "Empty message",
input: []rune{},
expected: "",
},
{
name: "Message with only spaces",
input: []rune(" "),
expected: "",
},
{
name: "Very long message",
input: []rune(string(make([]rune, runeLimit))),
expected: string(make([]rune, runeLimit)),
},
{
name: "Unicode characters",
input: []rune("Hello 世界"),
expected: "Hello 世界",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testutils.ResetMockInput()
testutils.SimulateKeyPress(rl.KeyT)
game.Update(0.1)
for _, r := range tt.input {
testutils.SimulateCharInput(r)
game.Update(0.1)
}
testutils.SimulateKeyPress(rl.KeyEnter)
game.Update(0.1)
if tt.expected != "" {
assert.Equal(t, 1, len(game.Player.ActionQueue))
assert.Equal(t, tt.expected, game.Player.ActionQueue[0].ChatMessage)
} else {
assert.Empty(t, game.Player.ActionQueue)
}
})
}
}
func TestLogin_InputValidation(t *testing.T) {
_, cleanup := setupTestEnvironment(t)
defer cleanup()
tests := []struct {
name string
username string
password string
expectSuccess bool
}{
{
name: "Valid credentials",
username: "validuser",
password: "validpass",
expectSuccess: true,
},
{
name: "Empty username",
username: "",
password: "password",
expectSuccess: false,
},
{
name: "Empty password",
username: "username",
password: "",
expectSuccess: false,
},
{
name: "Username too long",
username: "verylongusername123",
password: "password",
expectSuccess: false,
},
{
name: "Special characters in username",
username: "user@name",
password: "password",
expectSuccess: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
login := NewLoginScreen()
testutils.ResetMockInput()
// Simulate typing username
login.focusedField = 0
for _, r := range tt.username {
testutils.SimulateCharInput(r)
login.Update()
}
// Simulate typing password
login.focusedField = 1
for _, r := range tt.password {
testutils.SimulateCharInput(r)
login.Update()
}
// Simulate clicking login button
testutils.SimulateMouseClick(400, 365)
_, _, _, submitted := login.Update()
assert.Equal(t, tt.expectSuccess, submitted)
})
}
}

80
game/login_test.go Normal file
View File

@ -0,0 +1,80 @@
package game
import (
"testing"
"gitea.boner.be/bdnugget/goonscape/game/testutils"
rl "github.com/gen2brain/raylib-go/raylib"
"github.com/stretchr/testify/assert"
)
func TestLoginScreen_Update(t *testing.T) {
login := NewLoginScreen()
// Test field focus switching
simulateMouseClick(400, 215) // Click username field
assert.Equal(t, 0, login.focusedField)
simulateMouseClick(400, 265) // Click password field
assert.Equal(t, 1, login.focusedField)
// Test input length limits
login.focusedField = 0
for i := 0; i < 20; i++ {
simulateCharInput('x')
}
assert.LessOrEqual(t, len(login.username), 12)
login.focusedField = 1
for i := 0; i < 30; i++ {
simulateCharInput('x')
}
assert.LessOrEqual(t, len(login.password), 20)
// Test mode switching
simulateMouseClick(600, 365) // Click switch mode button
assert.True(t, login.isRegistering)
simulateMouseClick(600, 365) // Click again
assert.False(t, login.isRegistering)
// Test submission
login.username = "test"
login.password = "password"
testutils.SimulateMousePosition(400, 365)
testutils.SimulateMouseButton(testutils.ToInt32(rl.MouseLeftButton), true)
username, password, isRegistering, submitted := login.Update()
assert.True(t, submitted)
assert.Equal(t, "test", username)
assert.Equal(t, "password", password)
assert.False(t, isRegistering)
}
func TestLoginScreen_ErrorHandling(t *testing.T) {
login := NewLoginScreen()
// Test empty fields
login.username = ""
login.password = "test"
testutils.SimulateMousePosition(400, 365)
testutils.SimulateMouseButton(testutils.ToInt32(rl.MouseLeftButton), true)
_, _, _, submitted := login.Update()
assert.False(t, submitted)
assert.Contains(t, login.errorMessage, "username")
// Test special characters
login.username = "test!@#"
login.password = "password"
testutils.SimulateMousePosition(400, 365)
testutils.SimulateMouseButton(testutils.ToInt32(rl.MouseLeftButton), true)
_, _, _, submitted = login.Update()
assert.False(t, submitted)
assert.Contains(t, login.errorMessage, "invalid characters")
// Test error message display
login.SetError("Test error")
assert.Equal(t, "Test error", login.errorMessage)
}
func simulateMouseClick(x, y float32) {
// Implementation would depend on how raylib is mocked
}

15
game/mock/raylib.go Normal file
View File

@ -0,0 +1,15 @@
package mock
import (
rl "github.com/gen2brain/raylib-go/raylib"
)
var (
IsKeyPressed func(key int32) bool
IsKeyDown func(key int32) bool
IsMouseButtonPressed func(button int32) bool
GetMousePosition func() rl.Vector2
GetMouseRay func(mousePos rl.Vector2, camera rl.Camera3D) rl.Ray
GetMouseWheelMove func() float32
GetCharPressed func() rune
)

View File

@ -1,157 +1,91 @@
package game package game
import ( import (
"container/heap"
"fmt" "fmt"
"gitea.boner.be/bdnugget/goonscape/types" "gitea.boner.be/bdnugget/goonscape/types"
) )
// Node represents a node in the A* pathfinding algorithm
type Node struct { type Node struct {
Tile types.Tile Tile types.Tile
Parent *Node Parent *Node
G, H, F float32 // G = cost from start, H = heuristic to goal, F = G + H G, H, F float32
} }
// 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 { func FindPath(start, end types.Tile) []types.Tile {
// Initialize open and closed sets openList := []*Node{}
openSet := &PriorityQueue{} closedList := make(map[[2]int]bool)
heap.Init(openSet)
closedSet := make(map[[2]int]bool) startNode := &Node{Tile: start, G: 0, H: heuristic(start, end)}
// 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 startNode.F = startNode.G + startNode.H
heap.Push(openSet, startNode) openList = append(openList, startNode)
// Main search loop for len(openList) > 0 {
for openSet.Len() > 0 { current := openList[0]
// Get node with lowest F score currentIndex := 0
current := heap.Pop(openSet).(*Node) 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
// If we reached the goal, reconstruct and return the path
if current.Tile.X == end.X && current.Tile.Y == end.Y { if current.Tile.X == end.X && current.Tile.Y == end.Y {
return reconstructPath(current)
}
// 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)
// Check if in open set
inOpen, existingNode := isInQueue(openSet, neighbor)
// If not in open set or better path found
if !inOpen || tentativeG < existingNode.G {
// Create or update the node
var neighborNode *Node
if inOpen {
neighborNode = existingNode
} else {
neighborNode = &Node{
Tile: neighbor,
Parent: current,
}
}
// 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 {
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{} path := []types.Tile{}
current := node node := current
for node != nil {
// Follow parent pointers back to start path = append([]types.Tile{node.Tile}, path...)
for current != nil { node = node.Parent
path = append([]types.Tile{current.Tile}, path...)
current = current.Parent
} }
fmt.Printf("Path found: %v\n", path) fmt.Printf("Path found: %v\n", path)
return path return path
} }
// heuristic estimates cost from current to goal (Manhattan distance) neighbors := GetNeighbors(current.Tile)
for _, neighbor := range neighbors {
if !neighbor.Walkable || closedList[[2]int{neighbor.X, neighbor.Y}] {
continue
}
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
}
}
if !inOpen || tentativeG < existingNode.G {
newNode := &Node{
Tile: neighbor,
Parent: current,
G: tentativeG,
H: heuristic(neighbor, end),
}
newNode.F = newNode.G + newNode.H
if !inOpen {
openList = append(openList, newNode)
}
}
}
}
return nil
}
func heuristic(a, b types.Tile) float32 { func heuristic(a, b types.Tile) float32 {
return float32(abs(a.X-b.X) + abs(a.Y-b.Y)) 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 { func distance(a, b types.Tile) float32 {
return 1.0 // uniform cost for now return 1.0 // uniform cost for now
} }
// GetNeighbors returns walkable tiles adjacent to the given tile
func GetNeighbors(tile types.Tile) []types.Tile { func GetNeighbors(tile types.Tile) []types.Tile {
directions := [][2]int{ directions := [][2]int{
{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 0}, {-1, 0}, {0, 1}, {0, -1},
@ -170,7 +104,6 @@ func GetNeighbors(tile types.Tile) []types.Tile {
return neighbors return neighbors
} }
// abs returns the absolute value of x
func abs(x int) int { func abs(x int) int {
if x < 0 { if x < 0 {
return -x return -x

141
game/test_helpers.go Normal file
View File

@ -0,0 +1,141 @@
package game
import (
"sync"
"gitea.boner.be/bdnugget/goonscape/game/mock"
rl "github.com/gen2brain/raylib-go/raylib"
)
var (
mockInput struct {
sync.Mutex
keyPressed map[int32]bool
keyDown map[int32]bool
mousePressed map[int32]bool
mousePosition rl.Vector2
mouseRay rl.Ray
mouseWheel float32
charPressed rune
}
)
func init() {
resetMockInput()
setupMockFunctions()
}
func setupMockFunctions() {
mock.IsKeyPressed = mockIsKeyPressed
mock.IsKeyDown = mockIsKeyDown
mock.IsMouseButtonPressed = mockIsMouseButtonPressed
mock.GetMousePosition = mockGetMousePosition
mock.GetMouseRay = mockGetMouseRay
mock.GetMouseWheelMove = mockGetMouseWheelMove
mock.GetCharPressed = mockGetCharPressed
}
func resetMockInput() {
mockInput.Lock()
defer mockInput.Unlock()
mockInput.keyPressed = make(map[int32]bool)
mockInput.keyDown = make(map[int32]bool)
mockInput.mousePressed = make(map[int32]bool)
mockInput.mousePosition = rl.Vector2{}
mockInput.mouseRay = rl.Ray{}
mockInput.mouseWheel = 0
mockInput.charPressed = 0
}
// Mock input simulation functions
func simulateKeyPress(key int32) {
mockInput.Lock()
mockInput.keyPressed[key] = true
mockInput.Unlock()
}
func simulateKeyDown(key int32, isDown bool) {
mockInput.Lock()
mockInput.keyDown[key] = isDown
mockInput.Unlock()
}
func simulateMouseButton(button int32, isPressed bool) {
mockInput.Lock()
mockInput.mousePressed[button] = isPressed
mockInput.Unlock()
}
func simulateMousePosition(x, y float32) {
mockInput.Lock()
mockInput.mousePosition = rl.Vector2{X: x, Y: y}
mockInput.Unlock()
}
func simulateMouseRay(ray rl.Ray) {
mockInput.Lock()
mockInput.mouseRay = ray
mockInput.Unlock()
}
func simulateMouseWheel(move float32) {
mockInput.Lock()
mockInput.mouseWheel = move
mockInput.Unlock()
}
func simulateCharInput(char rune) {
mockInput.Lock()
mockInput.charPressed = char
mockInput.Unlock()
}
// Mock raylib functions
func mockIsKeyPressed(key int32) bool {
mockInput.Lock()
defer mockInput.Unlock()
return mockInput.keyPressed[key]
}
func mockIsKeyDown(key int32) bool {
mockInput.Lock()
defer mockInput.Unlock()
return mockInput.keyDown[key]
}
func mockIsMouseButtonPressed(button int32) bool {
mockInput.Lock()
defer mockInput.Unlock()
return mockInput.mousePressed[button]
}
func mockGetMousePosition() rl.Vector2 {
mockInput.Lock()
defer mockInput.Unlock()
return mockInput.mousePosition
}
func mockGetMouseRay(mousePos rl.Vector2, camera rl.Camera3D) rl.Ray {
mockInput.Lock()
defer mockInput.Unlock()
return mockInput.mouseRay
}
func mockGetMouseWheelMove() float32 {
mockInput.Lock()
defer mockInput.Unlock()
return mockInput.mouseWheel
}
func mockGetCharPressed() rune {
mockInput.Lock()
defer mockInput.Unlock()
return mockInput.charPressed
}
// Add more mock implementations...
// Add this helper function
func toInt32(button rl.MouseButton) int32 {
return int32(button)
}

26
game/test_setup.go Normal file
View File

@ -0,0 +1,26 @@
package game
import (
"testing"
"gitea.boner.be/bdnugget/goonscape/game/mock"
"gitea.boner.be/bdnugget/goonscape/game/testutils"
)
func setupTestEnvironment(t *testing.T) (*Game, func()) {
testutils.ResetMockInput()
testutils.SetupMockFunctions()
game := New()
game.input = &MockInput{}
game.Chat.input = &MockInput{} // Also inject mock input into chat
// Verify mock setup
if mock.IsKeyPressed == nil || mock.GetCharPressed == nil {
t.Fatal("Mock functions not properly initialized")
}
return game, func() {
testutils.ResetMockInput()
}
}

153
game/testutils/helpers.go Normal file
View File

@ -0,0 +1,153 @@
package testutils
import (
"sync"
"gitea.boner.be/bdnugget/goonscape/game/mock"
rl "github.com/gen2brain/raylib-go/raylib"
)
var (
mockInput struct {
sync.Mutex
keyPressed map[int32]bool
keyDown map[int32]bool
mousePressed map[int32]bool
mousePosition rl.Vector2
mouseRay rl.Ray
mouseWheel float32
charPressed rune
}
)
func init() {
ResetMockInput()
SetupMockFunctions()
}
// SetupMockFunctions initializes mock functions
func SetupMockFunctions() {
mock.IsKeyPressed = MockIsKeyPressed
mock.IsKeyDown = MockIsKeyDown
mock.IsMouseButtonPressed = MockIsMouseButtonPressed
mock.GetMousePosition = MockGetMousePosition
mock.GetMouseRay = MockGetMouseRay
mock.GetMouseWheelMove = MockGetMouseWheelMove
mock.GetCharPressed = MockGetCharPressed
}
// ResetMockInput resets all mock input states
func ResetMockInput() {
mockInput.Lock()
defer mockInput.Unlock()
mockInput.keyPressed = make(map[int32]bool)
mockInput.keyDown = make(map[int32]bool)
mockInput.mousePressed = make(map[int32]bool)
mockInput.mousePosition = rl.Vector2{}
mockInput.mouseRay = rl.Ray{}
mockInput.mouseWheel = 0
mockInput.charPressed = 0
}
// SimulateKeyPress simulates a key press
func SimulateKeyPress(key int32) {
mockInput.Lock()
mockInput.keyPressed[key] = true
mockInput.Unlock()
}
// SimulateKeyDown simulates holding a key down
func SimulateKeyDown(key int32, isDown bool) {
mockInput.Lock()
mockInput.keyDown[key] = isDown
mockInput.Unlock()
}
// SimulateMouseButton simulates a mouse button press
func SimulateMouseButton(button int32, isPressed bool) {
mockInput.Lock()
mockInput.mousePressed[button] = isPressed
mockInput.Unlock()
}
// SimulateMousePosition simulates mouse movement
func SimulateMousePosition(x, y float32) {
mockInput.Lock()
mockInput.mousePosition = rl.Vector2{X: x, Y: y}
mockInput.Unlock()
}
// SimulateMouseClick simulates a mouse click at the given position
func SimulateMouseClick(x, y float32) {
SimulateMousePosition(x, y)
SimulateMouseButton(ToInt32(rl.MouseLeftButton), true)
}
// SimulateMouseRay simulates a mouse ray
func SimulateMouseRay(ray rl.Ray) {
mockInput.Lock()
mockInput.mouseRay = ray
mockInput.Unlock()
}
// SimulateMouseWheel simulates mouse wheel movement
func SimulateMouseWheel(move float32) {
mockInput.Lock()
mockInput.mouseWheel = move
mockInput.Unlock()
}
// SimulateCharInput simulates character input
func SimulateCharInput(char rune) {
mockInput.Lock()
mockInput.charPressed = char
mockInput.Unlock()
}
// Mock raylib functions
func MockIsKeyPressed(key int32) bool {
mockInput.Lock()
defer mockInput.Unlock()
return mockInput.keyPressed[key]
}
func MockIsKeyDown(key int32) bool {
mockInput.Lock()
defer mockInput.Unlock()
return mockInput.keyDown[key]
}
func MockIsMouseButtonPressed(button int32) bool {
mockInput.Lock()
defer mockInput.Unlock()
return mockInput.mousePressed[button]
}
func MockGetMousePosition() rl.Vector2 {
mockInput.Lock()
defer mockInput.Unlock()
return mockInput.mousePosition
}
func MockGetMouseRay(mousePos rl.Vector2, camera rl.Camera3D) rl.Ray {
mockInput.Lock()
defer mockInput.Unlock()
return mockInput.mouseRay
}
func MockGetMouseWheelMove() float32 {
mockInput.Lock()
defer mockInput.Unlock()
return mockInput.mouseWheel
}
func MockGetCharPressed() rune {
mockInput.Lock()
defer mockInput.Unlock()
return mockInput.charPressed
}
// ToInt32 converts MouseButton to int32
func ToInt32(button rl.MouseButton) int32 {
return int32(button)
}

View File

@ -1,36 +1,9 @@
package game package game
import ( import (
"fmt"
"log"
"runtime/debug"
rl "github.com/gen2brain/raylib-go/raylib" 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 { func RayIntersectsBox(ray rl.Ray, boxMin, boxMax rl.Vector3) bool {
tmin := (boxMin.X - ray.Position.X) / ray.Direction.X tmin := (boxMin.X - ray.Position.X) / ray.Direction.X
tmax := (boxMax.X - ray.Position.X) / ray.Direction.X tmax := (boxMax.X - ray.Position.X) / ray.Direction.X

7
go.mod
View File

@ -8,8 +8,15 @@ require (
google.golang.org/protobuf v1.36.3 google.golang.org/protobuf v1.36.3
) )
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require ( require (
github.com/ebitengine/purego v0.8.2 // indirect github.com/ebitengine/purego v0.8.2 // indirect
github.com/stretchr/testify v1.10.0
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/sys v0.29.0 // indirect golang.org/x/sys v0.29.0 // indirect
) )

9
go.sum
View File

@ -1,12 +1,21 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b h1:JJfspevP3YOXcSKVABizYOv++yMpTJIdPUtoDzF/RWw= github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b h1:JJfspevP3YOXcSKVABizYOv++yMpTJIdPUtoDzF/RWw=
github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q= github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

@ -1 +1 @@
Subproject commit 00aa3022292b8f4eec6c01522b6a91cf6769155b Subproject commit f9ec811b10bbab54e843199eb68156e9e7c143cc

119
main.go
View File

@ -3,11 +3,7 @@ package main
import ( import (
"flag" "flag"
"log" "log"
"os"
"os/signal"
"strings" "strings"
"syscall"
"time"
"gitea.boner.be/bdnugget/goonscape/game" "gitea.boner.be/bdnugget/goonscape/game"
"gitea.boner.be/bdnugget/goonscape/network" "gitea.boner.be/bdnugget/goonscape/network"
@ -15,27 +11,11 @@ import (
) )
func main() { func main() {
// Set up panic recovery at the top level
defer func() {
if r := recover(); r != nil {
log.Printf("Recovered from fatal panic in main: %v", r)
// Give the user a chance to see the error
time.Sleep(5 * time.Second)
}
}()
// Parse command line flags // Parse command line flags
verbose := flag.Bool("v", false, "Also show info logs (spammy)")
local := flag.Bool("local", false, "Connect to local server") local := flag.Bool("local", false, "Connect to local server")
addr := flag.String("addr", "", "Server address (host or host:port)") addr := flag.String("addr", "", "Server address (host or host:port)")
flag.Parse() flag.Parse()
if *verbose {
rl.SetTraceLogLevel(rl.LogTrace)
} else {
rl.SetTraceLogLevel(rl.LogWarning)
}
// Set server address based on flags // Set server address based on flags
if *local { if *local {
if *addr != "" { if *addr != "" {
@ -50,104 +30,31 @@ func main() {
network.SetServerAddr(*addr) 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.InitWindow(1024, 768, "GoonScape")
rl.SetExitKey(0) rl.SetExitKey(0)
defer rl.CloseWindow()
// Initialize audio with error handling
if !rl.IsAudioDeviceReady() {
rl.InitAudioDevice() rl.InitAudioDevice()
if !rl.IsAudioDeviceReady() { defer rl.CloseAudioDevice()
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() {
if gameInstance != nil {
gameInstance.Cleanup()
}
rl.CloseWindow()
if rl.IsAudioDeviceReady() {
rl.CloseAudioDevice()
}
}()
rl.SetTargetFPS(60) rl.SetTargetFPS(60)
// Play music if available game := game.New()
if gameInstance.AssetManager.Music.Stream.Buffer != nil { if err := game.LoadAssets(); err != nil {
rl.PlayMusicStream(gameInstance.AssetManager.Music) log.Fatalf("Failed to load assets: %v", err)
rl.SetMusicVolume(gameInstance.AssetManager.Music, 0.5)
} }
defer game.Cleanup()
// Handle OS signals for clean shutdown rl.PlayMusicStream(game.Music)
sigChan := make(chan os.Signal, 1) rl.SetMusicVolume(game.Music, 0.5)
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() { for !rl.WindowShouldClose() {
deltaTime := rl.GetFrameTime() deltaTime := rl.GetFrameTime()
rl.UpdateMusicStream(game.Music)
// Update music if available game.Update(deltaTime)
if gameInstance.AssetManager.Music.Stream.Buffer != nil { game.Render()
rl.UpdateMusicStream(gameInstance.AssetManager.Music)
} }
func() { // Wait for clean shutdown
defer func() { <-game.QuitChan
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:
}
}
} }

View File

@ -7,7 +7,6 @@ import (
"io" "io"
"log" "log"
"net" "net"
"sync"
"time" "time"
"gitea.boner.be/bdnugget/goonscape/types" "gitea.boner.be/bdnugget/goonscape/types"
@ -18,226 +17,21 @@ import (
const protoVersion = 1 const protoVersion = 1
var serverAddr = "boner.be:6969" // Default server address var serverAddr = "boner.be:6969"
var lastSeenMessageTimestamp int64 = 0 // Track the last message timestamp seen by this client
func SetServerAddr(addr string) { func SetServerAddr(addr string) {
serverAddr = addr 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) { func ConnectToServer(username, password string, isRegistering bool) (net.Conn, int32, error) {
log.Printf("Connecting to server at %s...", serverAddr) conn, err := net.Dial("tcp", serverAddr)
if err != nil {
var err error log.Printf("Failed to dial server: %v", err)
var conn net.Conn return nil, 0, err
// 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...") log.Println("Connected to server. Authenticating...")
// Create a message handler
msgHandler := NewMessageHandler(conn)
// Send auth message // Send auth message
authAction := &pb.Action{ authAction := &pb.Action{
Type: pb.Action_LOGIN, Type: pb.Action_LOGIN,
@ -253,23 +47,31 @@ func ConnectToServer(username, password string, isRegistering bool) (net.Conn, i
ProtocolVersion: protoVersion, ProtocolVersion: protoVersion,
} }
if err := msgHandler.WriteMessage(authBatch); err != nil { if err := writeMessage(conn, authBatch); err != nil {
conn.Close() conn.Close()
return nil, 0, fmt.Errorf("failed to send auth: %v", err) 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 // Read server response
response, err := msgHandler.ReadMessage() reader := bufio.NewReader(conn)
if err != nil { lengthBuf := make([]byte, 4)
if _, err := io.ReadFull(reader, lengthBuf); err != nil {
conn.Close() conn.Close()
return nil, 0, fmt.Errorf("failed to read auth response: %v", err) return nil, 0, fmt.Errorf("failed to read auth response: %v", err)
} }
messageLength := binary.BigEndian.Uint32(lengthBuf)
// Clear read deadline after authentication messageBuf := make([]byte, messageLength)
conn.SetReadDeadline(time.Time{}) 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)
}
if response.ProtocolVersion > protoVersion { if response.ProtocolVersion > protoVersion {
conn.Close() conn.Close()
@ -284,74 +86,24 @@ func ConnectToServer(username, password string, isRegistering bool) (net.Conn, i
playerID := response.GetPlayerId() playerID := response.GetPlayerId()
log.Printf("Successfully authenticated with player ID: %d", playerID) log.Printf("Successfully authenticated with player ID: %d", playerID)
// Reset the lastSeenMessageTimestamp when reconnecting
lastSeenMessageTimestamp = 0
return conn, playerID, nil return conn, playerID, nil
} }
func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Player, otherPlayers map[int32]*types.Player, quitChan <-chan struct{}) { func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Player, otherPlayers map[int32]*types.Player, quitChan <-chan struct{}) {
msgHandler := NewMessageHandler(conn) reader := bufio.NewReader(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 {
select {
case <-player.QuitDone: // Check if it's already closed
// Already closed, do nothing
default:
close(player.QuitDone)
}
}
}()
actionTicker := time.NewTicker(types.ClientTickRate) actionTicker := time.NewTicker(types.ClientTickRate)
defer actionTicker.Stop() defer actionTicker.Stop()
defer conn.Close()
defer close(player.QuitDone)
// Add a heartbeat ticker to detect connection issues // Create a channel to signal when goroutines are done
heartbeatTicker := time.NewTicker(5 * time.Second) done := make(chan struct{})
defer heartbeatTicker.Stop()
lastMessageTime := time.Now() // Create a set of current players to track disconnects
currentPlayers := make(map[int32]bool)
// Start message sending goroutine
go func() { go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Recovered from panic in message sender: %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 { for {
select { select {
case <-quitChan: case <-quitChan:
@ -363,55 +115,28 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play
PlayerId: playerID, PlayerId: playerID,
}}, }},
} }
writeMessage(conn, disconnectMsg)
// Try to send disconnect message, ignoring errors done <- struct{}{}
_ = msgHandler.WriteMessage(disconnectMsg)
// No need to signal done channel here, the main goroutine handles this
return 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: case <-actionTicker.C:
player.Lock() player.Lock()
if len(player.ActionQueue) > 0 { if len(player.ActionQueue) > 0 {
actions := make([]*pb.Action, len(player.ActionQueue)) actions := make([]*pb.Action, len(player.ActionQueue))
copy(actions, player.ActionQueue) copy(actions, player.ActionQueue)
batch := &pb.ActionBatch{ batch := &pb.ActionBatch{
PlayerId: playerID, PlayerId: playerID,
Actions: actions, Actions: actions,
Tick: player.CurrentTick, Tick: player.CurrentTick,
LastSeenMessageTimestamp: lastSeenMessageTimestamp,
} }
player.ActionQueue = player.ActionQueue[:0] player.ActionQueue = player.ActionQueue[:0]
player.Unlock() player.Unlock()
if err := msgHandler.WriteMessage(batch); err != nil { if err := writeMessage(conn, batch); err != nil {
select { log.Printf("Failed to send actions to server: %v", err)
case errChan <- err:
case <-done:
return return
} }
}
lastMessageTime = time.Now()
} else { } else {
player.Unlock() player.Unlock()
} }
@ -419,121 +144,111 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play
} }
}() }()
// Main message receiving loop
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Recovered from panic in message receiver: %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 { for {
select { select {
case <-quitChan: case <-quitChan:
return done := make(chan struct{})
case <-done: go func() {
return <-done
default: close(player.QuitDone)
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
}
} else {
log.Printf("Connection closed by server")
}
return
}
// Process the server message
UpdateGameState(serverMessage, player, otherPlayers)
}
}
}() }()
// Wait for error or quit signal
select { select {
case <-quitChan: case <-done:
log.Printf("Received quit signal, sending disconnect message") time.Sleep(100 * time.Millisecond)
// The cleanup will happen in the deferred function case <-time.After(1 * time.Second):
log.Println("Shutdown timed out")
}
return return
case err := <-errChan: default:
log.Printf("Network error: %v", err) // Read message length (4 bytes)
// The cleanup will happen in the deferred function lengthBuf := make([]byte, 4)
if _, err := io.ReadFull(reader, lengthBuf); err != nil {
log.Printf("Failed to read message length: %v", err)
return 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
}
}
}
player.Unlock()
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()
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 !currentPlayers[id] {
delete(otherPlayers, id)
}
}
if handler, ok := player.UserData.(types.ChatMessageHandler); ok && len(serverMessage.ChatMessages) > 0 {
handler.HandleServerMessages(serverMessage.ChatMessages)
}
}
}
} }
// Helper function to write length-prefixed messages // Helper function to write length-prefixed messages
func writeMessage(conn net.Conn, msg proto.Message) error { func writeMessage(conn net.Conn, msg proto.Message) error {
msgHandler := NewMessageHandler(conn) data, err := proto.Marshal(msg)
return msgHandler.WriteMessage(msg)
}
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 { if err != nil {
return nil, err return err
}
return &Connection{
conn: conn,
playerID: playerID,
quitChan: make(chan struct{}),
quitDone: make(chan struct{}),
}, nil
} }
func (c *Connection) Close() { // Write length prefix
c.closeOnce.Do(func() { lengthBuf := make([]byte, 4)
select { binary.BigEndian.PutUint32(lengthBuf, uint32(len(data)))
case <-c.quitChan: // Check if it's already closed if _, err := conn.Write(lengthBuf); err != nil {
// Already closed, do nothing return err
default:
close(c.quitChan)
} }
// Wait with timeout for network cleanup // Write message body
select { _, err = conn.Write(data)
case <-c.quitDone: return err
// Clean shutdown completed
case <-time.After(500 * time.Millisecond):
log.Println("Network cleanup timed out")
}
// Make sure the connection is closed
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
} }

View File

@ -1,98 +1,16 @@
package types package types
import ( import (
"sync"
"time" "time"
pb "gitea.boner.be/bdnugget/goonserver/actions" pb "gitea.boner.be/bdnugget/goonserver/actions"
rl "github.com/gen2brain/raylib-go/raylib" 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 // Keep this for network operations
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
PlaceholderColor rl.Color
AnimController *AnimationController
}
func (p *Player) MoveTowards(target Tile, deltaTime float32, mapGrid [][]Tile) { func (p *Player) MoveTowards(target Tile, deltaTime float32, mapGrid [][]Tile) {
// No need for lock here as this is called from a single thread (game loop) p.Lock()
defer p.Unlock()
targetPos := rl.Vector3{ targetPos := rl.Vector3{
X: float32(target.X * TileSize), X: float32(target.X * TileSize),
Y: mapGrid[target.X][target.Y].Height * TileHeight, Y: mapGrid[target.X][target.Y].Height * TileHeight,
@ -103,29 +21,29 @@ func (p *Player) MoveTowards(target Tile, deltaTime float32, mapGrid [][]Tile) {
distance := rl.Vector3Length(direction) distance := rl.Vector3Length(direction)
if distance > 1.0 { if distance > 1.0 {
wasMoving := p.IsMoving
p.IsMoving = true p.IsMoving = true
} else {
p.IsMoving = false
}
// Update animation if controller exists if !wasMoving {
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 = 0
} }
oldFrame := p.AnimationFrame
p.AnimationFrame += int32(deltaTime * 60) p.AnimationFrame += int32(deltaTime * 60)
rl.TraceLog(rl.LogInfo, "Walk frame update: %d -> %d (delta: %f)",
oldFrame, p.AnimationFrame, deltaTime)
} else { } else {
wasMoving := p.IsMoving wasMoving := p.IsMoving
p.IsMoving = false
if wasMoving { if wasMoving {
p.AnimationFrame = 0 p.AnimationFrame = 0
} }
oldFrame := p.AnimationFrame
p.AnimationFrame += int32(deltaTime * 60) p.AnimationFrame += int32(deltaTime * 60)
} rl.TraceLog(rl.LogInfo, "Idle frame update: %d -> %d (delta: %f)",
oldFrame, p.AnimationFrame, deltaTime)
} }
if distance > 0 { if distance > 0 {
@ -156,16 +74,9 @@ func NewPlayer(state *pb.PlayerState) *Player {
IsMoving: false, IsMoving: false,
AnimationFrame: 0, AnimationFrame: 0,
LastAnimUpdate: time.Now(), LastAnimUpdate: time.Now(),
LastUpdateTime: time.Now(),
InterpolationProgress: 1.0,
} }
} }
// 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) { func (p *Player) UpdatePosition(state *pb.PlayerState, tickRate time.Duration) {
p.Lock() p.Lock()
defer p.Unlock() defer p.Unlock()
@ -180,7 +91,6 @@ func (p *Player) UpdatePosition(state *pb.PlayerState, tickRate time.Duration) {
} }
func (p *Player) ForceResync(state *pb.PlayerState) { func (p *Player) ForceResync(state *pb.PlayerState) {
// Keep this lock since it's called from the network goroutine
p.Lock() p.Lock()
defer p.Unlock() defer p.Unlock()

View File

@ -1,6 +1,7 @@
package types package types
import ( import (
"sync"
"time" "time"
pb "gitea.boner.be/bdnugget/goonserver/actions" pb "gitea.boner.be/bdnugget/goonserver/actions"
@ -13,6 +14,27 @@ type Tile struct {
Walkable bool 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 { type AnimationSet struct {
Idle []rl.ModelAnimation Idle []rl.ModelAnimation
Walk []rl.ModelAnimation Walk []rl.ModelAnimation
@ -28,7 +50,6 @@ type ModelAsset struct {
AnimFrames int32 // Keep this for compatibility AnimFrames int32 // Keep this for compatibility
Animations AnimationSet // New field for organized animations Animations AnimationSet // New field for organized animations
YOffset float32 // Additional height offset (added to default 8.0) YOffset float32 // Additional height offset (added to default 8.0)
PlaceholderColor rl.Color
} }
type ChatMessage struct { type ChatMessage struct {
@ -59,12 +80,3 @@ const (
ClientTickRate = 50 * time.Millisecond ClientTickRate = 50 * time.Millisecond
MaxTickDesync = 5 MaxTickDesync = 5
) )
// UI constants
const (
ChatMargin = 10
ChatHeight = 200
MessageHeight = 20
InputHeight = 30
MaxChatMessages = 50
)