Compare commits
	
		
			1 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 75eff6c5ad | 
							
								
								
									
										284
									
								
								assets/assets.go
									
									
									
									
									
								
							
							
						
						
									
										284
									
								
								assets/assets.go
									
									
									
									
									
								
							@ -1,88 +1,14 @@
 | 
			
		||||
package assets
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
 | 
			
		||||
	"gitea.boner.be/bdnugget/goonscape/types"
 | 
			
		||||
	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
 | 
			
		||||
func loadModelAnimations(animPaths map[string]string) (types.AnimationSet, error) {
 | 
			
		||||
	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
 | 
			
		||||
	if idlePath, ok := animPaths["idle"]; ok {
 | 
			
		||||
		idleAnims := rl.LoadModelAnimations(idlePath)
 | 
			
		||||
@ -106,84 +32,10 @@ func loadModelAnimations(animPaths map[string]string) (types.AnimationSet, error
 | 
			
		||||
	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) {
 | 
			
		||||
	// Force safe mode for now until we fix the segfault
 | 
			
		||||
	os.Setenv("GOONSCAPE_SAFE_MODE", "1")
 | 
			
		||||
 | 
			
		||||
	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",
 | 
			
		||||
		})
 | 
			
		||||
	// Goonion model and animations
 | 
			
		||||
	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"})
 | 
			
		||||
 | 
			
		||||
	// Apply transformations
 | 
			
		||||
	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.MatrixScale(1.0, 1.0, 1.0))
 | 
			
		||||
	goonerModel.Transform = transform
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Always add a model (real or placeholder)
 | 
			
		||||
	models = append(models, types.ModelAsset{
 | 
			
		||||
	// Coomer model (ready for animations)
 | 
			
		||||
	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,
 | 
			
		||||
			Animation:  append(goonerAnims.Idle, goonerAnims.Walk...),
 | 
			
		||||
			AnimFrames: int32(len(goonerAnims.Idle) + len(goonerAnims.Walk)),
 | 
			
		||||
			Animations: goonerAnims,
 | 
			
		||||
			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,
 | 
			
		||||
			Animation:  append(coomerAnims.Idle, coomerAnims.Walk...),
 | 
			
		||||
			AnimFrames: int32(len(coomerAnims.Idle) + len(coomerAnims.Walk)),
 | 
			
		||||
			Animations: coomerAnims,
 | 
			
		||||
			YOffset:    -4.0,
 | 
			
		||||
			PlaceholderColor: rl.Color{}, // Not a placeholder
 | 
			
		||||
		})
 | 
			
		||||
	} else {
 | 
			
		||||
		// 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
 | 
			
		||||
		},
 | 
			
		||||
		{Model: shrekeModel, Texture: shrekeTexture},
 | 
			
		||||
	}, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func LoadMusic(filename string) (rl.Music, error) {
 | 
			
		||||
	defer func() {
 | 
			
		||||
		// 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)
 | 
			
		||||
	if music.Stream.Buffer == nil {
 | 
			
		||||
		return music, fmt.Errorf("failed to load music: %s", filename)
 | 
			
		||||
	}
 | 
			
		||||
	return music, nil
 | 
			
		||||
	return rl.LoadMusicStream(filename), nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func UnloadModels(models []types.ModelAsset) {
 | 
			
		||||
	for _, model := range models {
 | 
			
		||||
		if model.Animation != nil {
 | 
			
		||||
			for i := int32(0); i < model.AnimFrames; i++ {
 | 
			
		||||
				rl.UnloadModelAnimation(model.Animation[i])
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		rl.UnloadModel(model.Model)
 | 
			
		||||
		rl.UnloadTexture(model.Texture)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func UnloadMusic(music rl.Music) {
 | 
			
		||||
	rl.UnloadMusicStream(music)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										31
									
								
								constants.go
									
									
									
									
									
								
							
							
						
						
									
										31
									
								
								constants.go
									
									
									
									
									
								
							@ -2,33 +2,20 @@ package main
 | 
			
		||||
 | 
			
		||||
import "time"
 | 
			
		||||
 | 
			
		||||
// Game world constants
 | 
			
		||||
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
 | 
			
		||||
	MapHeight  = 50
 | 
			
		||||
	TileSize   = 32
 | 
			
		||||
	TileHeight = 2.0
 | 
			
		||||
)
 | 
			
		||||
	TickRate   = 600 * time.Millisecond // Server tick rate (600ms)
 | 
			
		||||
	serverAddr = "localhost:6969"
 | 
			
		||||
 | 
			
		||||
// UI constants
 | 
			
		||||
const (
 | 
			
		||||
	ChatMargin    = 10
 | 
			
		||||
	ChatHeight    = 200
 | 
			
		||||
	MessageHeight = 20
 | 
			
		||||
	InputHeight   = 30
 | 
			
		||||
	MaxMessages   = 50
 | 
			
		||||
)
 | 
			
		||||
	// RuneScape-style tick rate (600ms)
 | 
			
		||||
	ServerTickRate = 600 * time.Millisecond
 | 
			
		||||
 | 
			
		||||
// Environment variable names
 | 
			
		||||
const (
 | 
			
		||||
	EnvSafeMode          = "GOONSCAPE_SAFE_MODE"
 | 
			
		||||
	EnvDisableAnimations = "GOONSCAPE_DISABLE_ANIMATIONS"
 | 
			
		||||
	EnvDisableAudio      = "GOONSCAPE_DISABLE_AUDIO"
 | 
			
		||||
	// Client might run at a higher tick rate for smooth rendering
 | 
			
		||||
	ClientTickRate = 50 * time.Millisecond
 | 
			
		||||
 | 
			
		||||
	// Maximum number of ticks we can get behind before forcing a resync
 | 
			
		||||
	MaxTickDesync = 5
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,6 @@ var (
 | 
			
		||||
	cameraDistance = float32(20.0)
 | 
			
		||||
	cameraYaw      = float32(145.0)
 | 
			
		||||
	cameraPitch    = float32(45.0)
 | 
			
		||||
	lastMousePos   rl.Vector2 // Add this to track mouse movement
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
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) {
 | 
			
		||||
		cameraYaw += 100 * deltaTime
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										110
									
								
								game/chat.go
									
									
									
									
									
								
							
							
						
						
									
										110
									
								
								game/chat.go
									
									
									
									
									
								
							@ -2,8 +2,6 @@ package game
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"log"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"gitea.boner.be/bdnugget/goonscape/types"
 | 
			
		||||
@ -11,8 +9,12 @@ import (
 | 
			
		||||
	rl "github.com/gen2brain/raylib-go/raylib"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Local UI constants (these could be moved to a centralized constants package later)
 | 
			
		||||
const (
 | 
			
		||||
	maxMessages   = 50
 | 
			
		||||
	chatMargin    = 10 // Margin from screen edges
 | 
			
		||||
	chatHeight    = 200
 | 
			
		||||
	messageHeight = 20
 | 
			
		||||
	inputHeight   = 30
 | 
			
		||||
	runeLimit     = 256
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@ -23,13 +25,14 @@ type Chat struct {
 | 
			
		||||
	cursorPos    int
 | 
			
		||||
	scrollOffset int
 | 
			
		||||
	userData     interface{}
 | 
			
		||||
	mutex        sync.RWMutex
 | 
			
		||||
	input        InputHandler
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func NewChat() *Chat {
 | 
			
		||||
	return &Chat{
 | 
			
		||||
		messages:    make([]types.ChatMessage, 0, types.MaxChatMessages),
 | 
			
		||||
		messages:    make([]types.ChatMessage, 0, maxMessages),
 | 
			
		||||
		inputBuffer: make([]rune, 0, runeLimit),
 | 
			
		||||
		input:       &RaylibInput{},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -40,7 +43,7 @@ func (c *Chat) AddMessage(playerID int32, content string) {
 | 
			
		||||
		Time:     time.Now(),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(c.messages) >= types.MaxChatMessages {
 | 
			
		||||
	if len(c.messages) >= maxMessages {
 | 
			
		||||
		c.messages = c.messages[1:]
 | 
			
		||||
	}
 | 
			
		||||
	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) {
 | 
			
		||||
	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
 | 
			
		||||
	for _, msg := range messages {
 | 
			
		||||
		// Skip invalid messages
 | 
			
		||||
		if msg == nil {
 | 
			
		||||
			log.Printf("Warning: Received nil chat message")
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		localMsg := types.ChatMessage{
 | 
			
		||||
			PlayerID: msg.PlayerId,
 | 
			
		||||
			Username: msg.Username,
 | 
			
		||||
@ -74,54 +62,33 @@ func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) {
 | 
			
		||||
 | 
			
		||||
		// 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) >= types.MaxChatMessages {
 | 
			
		||||
			if len(c.messages) >= maxMessages {
 | 
			
		||||
				c.messages = c.messages[1:]
 | 
			
		||||
			}
 | 
			
		||||
			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
 | 
			
		||||
			visibleMessages := int((types.ChatHeight - types.InputHeight) / types.MessageHeight)
 | 
			
		||||
			visibleMessages := int((chatHeight - inputHeight) / messageHeight)
 | 
			
		||||
			if len(c.messages) > visibleMessages {
 | 
			
		||||
				c.scrollOffset = len(c.messages) - visibleMessages
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Add floating message to the player
 | 
			
		||||
			if game, ok := c.userData.(*Game); ok && game != nil {
 | 
			
		||||
				// Make sure each game component exists before using it
 | 
			
		||||
				if game.PlayerManager == nil {
 | 
			
		||||
					log.Printf("Warning: PlayerManager is nil when processing chat message")
 | 
			
		||||
					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{
 | 
			
		||||
			if game, ok := c.userData.(*Game); ok {
 | 
			
		||||
				if msg.PlayerId == game.Player.ID {
 | 
			
		||||
					game.Player.Lock()
 | 
			
		||||
					game.Player.FloatingMessage = &types.FloatingMessage{
 | 
			
		||||
						Content:    msg.Content,
 | 
			
		||||
						ExpireTime: time.Now().Add(6 * time.Second),
 | 
			
		||||
					}
 | 
			
		||||
					game.PlayerManager.LocalPlayer.Unlock()
 | 
			
		||||
				} else {
 | 
			
		||||
					// The other player might not be in our list yet, handle safely
 | 
			
		||||
					player := game.PlayerManager.GetPlayer(msg.PlayerId)
 | 
			
		||||
					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{
 | 
			
		||||
					game.Player.Unlock()
 | 
			
		||||
				} else if otherPlayer, exists := game.OtherPlayers[msg.PlayerId]; exists {
 | 
			
		||||
					otherPlayer.Lock()
 | 
			
		||||
					otherPlayer.FloatingMessage = &types.FloatingMessage{
 | 
			
		||||
						Content:    msg.Content,
 | 
			
		||||
						ExpireTime: time.Now().Add(6 * time.Second),
 | 
			
		||||
					}
 | 
			
		||||
					player.Unlock()
 | 
			
		||||
					log.Printf("Added floating message to other player %d", msg.PlayerId)
 | 
			
		||||
					otherPlayer.Unlock()
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
@ -129,20 +96,17 @@ func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *Chat) Draw(screenWidth, screenHeight int32) {
 | 
			
		||||
	c.mutex.RLock()
 | 
			
		||||
	defer c.mutex.RUnlock()
 | 
			
		||||
 | 
			
		||||
	// Calculate chat window width based on screen width
 | 
			
		||||
	chatWindowWidth := screenWidth - (types.ChatMargin * 2)
 | 
			
		||||
	chatWindowWidth := screenWidth - (chatMargin * 2)
 | 
			
		||||
 | 
			
		||||
	// Draw chat window background
 | 
			
		||||
	chatX := float32(types.ChatMargin)
 | 
			
		||||
	chatY := float32(screenHeight - types.ChatHeight - types.ChatMargin)
 | 
			
		||||
	rl.DrawRectangle(int32(chatX), int32(chatY), chatWindowWidth, types.ChatHeight, rl.ColorAlpha(rl.Black, 0.5))
 | 
			
		||||
	chatX := float32(chatMargin)
 | 
			
		||||
	chatY := float32(screenHeight - chatHeight - chatMargin)
 | 
			
		||||
	rl.DrawRectangle(int32(chatX), int32(chatY), chatWindowWidth, chatHeight, rl.ColorAlpha(rl.Black, 0.5))
 | 
			
		||||
 | 
			
		||||
	// Draw messages from oldest to newest
 | 
			
		||||
	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
 | 
			
		||||
	if c.scrollOffset == 0 {
 | 
			
		||||
@ -164,12 +128,12 @@ func (c *Chat) Draw(screenWidth, screenHeight int32) {
 | 
			
		||||
		}
 | 
			
		||||
		text := fmt.Sprintf("%s: %s", msg.Username, msg.Content)
 | 
			
		||||
		rl.DrawText(text, int32(chatX)+5, int32(messageY), 20, color)
 | 
			
		||||
		messageY += types.MessageHeight
 | 
			
		||||
		messageY += messageHeight
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Draw input field
 | 
			
		||||
	inputY := chatY + float32(types.ChatHeight-types.InputHeight)
 | 
			
		||||
	rl.DrawRectangle(int32(chatX), int32(inputY), chatWindowWidth, types.InputHeight, rl.ColorAlpha(rl.White, 0.3))
 | 
			
		||||
	inputY := chatY + float32(chatHeight-inputHeight)
 | 
			
		||||
	rl.DrawRectangle(int32(chatX), int32(inputY), chatWindowWidth, inputHeight, rl.ColorAlpha(rl.White, 0.3))
 | 
			
		||||
	if c.isTyping {
 | 
			
		||||
		inputText := string(c.inputBuffer)
 | 
			
		||||
		rl.DrawText(inputText, int32(chatX)+5, int32(inputY)+5, 20, rl.White)
 | 
			
		||||
@ -187,27 +151,27 @@ func (c *Chat) Update() (string, bool) {
 | 
			
		||||
	if !c.isTyping {
 | 
			
		||||
		wheelMove := rl.GetMouseWheelMove()
 | 
			
		||||
		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)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if rl.IsKeyPressed(rl.KeyT) {
 | 
			
		||||
		if c.input.IsKeyPressed(rl.KeyT) {
 | 
			
		||||
			c.isTyping = true
 | 
			
		||||
			return "", false
 | 
			
		||||
		}
 | 
			
		||||
		return "", false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	key := rl.GetCharPressed()
 | 
			
		||||
	key := c.input.GetCharPressed()
 | 
			
		||||
	for key > 0 {
 | 
			
		||||
		if len(c.inputBuffer) < runeLimit {
 | 
			
		||||
			c.inputBuffer = append(c.inputBuffer[:c.cursorPos], append([]rune{key}, c.inputBuffer[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 {
 | 
			
		||||
			message := string(c.inputBuffer)
 | 
			
		||||
			c.inputBuffer = c.inputBuffer[:0]
 | 
			
		||||
@ -218,21 +182,21 @@ func (c *Chat) Update() (string, bool) {
 | 
			
		||||
		c.isTyping = false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if rl.IsKeyPressed(rl.KeyEscape) && c.isTyping {
 | 
			
		||||
	if c.input.IsKeyPressed(rl.KeyEscape) && c.isTyping {
 | 
			
		||||
		c.inputBuffer = c.inputBuffer[:0]
 | 
			
		||||
		c.cursorPos = 0
 | 
			
		||||
		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.cursorPos--
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if rl.IsKeyPressed(rl.KeyLeft) && c.cursorPos > 0 {
 | 
			
		||||
	if c.input.IsKeyPressed(rl.KeyLeft) && c.cursorPos > 0 {
 | 
			
		||||
		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++
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										106
									
								
								game/chat_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								game/chat_test.go
									
									
									
									
									
										Normal 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")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@ -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)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										470
									
								
								game/game.go
									
									
									
									
									
								
							
							
						
						
									
										470
									
								
								game/game.go
									
									
									
									
									
								
							@ -1,8 +1,7 @@
 | 
			
		||||
package game
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"log"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"os"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"gitea.boner.be/bdnugget/goonscape/assets"
 | 
			
		||||
@ -13,179 +12,115 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type Game struct {
 | 
			
		||||
	// Component-based architecture
 | 
			
		||||
	PlayerManager *PlayerManager
 | 
			
		||||
	AssetManager  *AssetManager
 | 
			
		||||
	UIManager     *UIManager
 | 
			
		||||
 | 
			
		||||
	// Core game state
 | 
			
		||||
	Player       *types.Player
 | 
			
		||||
	OtherPlayers map[int32]*types.Player
 | 
			
		||||
	Camera       rl.Camera3D
 | 
			
		||||
	quitChan     chan struct{}
 | 
			
		||||
	cleanupOnce  sync.Once
 | 
			
		||||
	frameCounter int // For periodic logging
 | 
			
		||||
	Models       []types.ModelAsset
 | 
			
		||||
	Music        rl.Music
 | 
			
		||||
	Chat         *Chat
 | 
			
		||||
	MenuOpen     bool
 | 
			
		||||
	QuitChan     chan struct{} // Channel to signal shutdown
 | 
			
		||||
	loginScreen  *LoginScreen
 | 
			
		||||
	isLoggedIn   bool
 | 
			
		||||
	input        InputHandler
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func New() *Game {
 | 
			
		||||
	// Create managers
 | 
			
		||||
	playerManager := NewPlayerManager()
 | 
			
		||||
	assetManager := NewAssetManager()
 | 
			
		||||
	uiManager := NewUIManager()
 | 
			
		||||
 | 
			
		||||
	g := &Game{
 | 
			
		||||
		PlayerManager: playerManager,
 | 
			
		||||
		AssetManager:  assetManager,
 | 
			
		||||
		UIManager:     uiManager,
 | 
			
		||||
	InitWorld()
 | 
			
		||||
	game := &Game{
 | 
			
		||||
		OtherPlayers: make(map[int32]*types.Player),
 | 
			
		||||
		Camera: rl.Camera3D{
 | 
			
		||||
			Position:   rl.NewVector3(0.0, 20.0, 0.0),
 | 
			
		||||
			Target:     rl.NewVector3(0.0, 0.0, 0.0),
 | 
			
		||||
			Up:         rl.NewVector3(0.0, 1.0, 0.0),
 | 
			
		||||
			Position:   rl.NewVector3(0, 10, 10),
 | 
			
		||||
			Target:     rl.NewVector3(0, 0, 0),
 | 
			
		||||
			Up:         rl.NewVector3(0, 1, 0),
 | 
			
		||||
			Fovy:       45.0,
 | 
			
		||||
			Projection: rl.CameraPerspective,
 | 
			
		||||
		},
 | 
			
		||||
		quitChan: make(chan struct{}),
 | 
			
		||||
		Chat:        NewChat(),
 | 
			
		||||
		QuitChan:    make(chan struct{}),
 | 
			
		||||
		loginScreen: NewLoginScreen(),
 | 
			
		||||
		input:       &RaylibInput{},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set up inter-component references
 | 
			
		||||
	g.UIManager.Chat.userData = g // Pass game instance to chat for callbacks
 | 
			
		||||
 | 
			
		||||
	// Initialize world
 | 
			
		||||
	InitWorld()
 | 
			
		||||
 | 
			
		||||
	return g
 | 
			
		||||
	game.Chat.userData = game
 | 
			
		||||
	return game
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (g *Game) LoadAssets() error {
 | 
			
		||||
	return SafeExecute(func() error {
 | 
			
		||||
		// Load models
 | 
			
		||||
	var err error
 | 
			
		||||
		models, err := assets.LoadModels()
 | 
			
		||||
	g.Models, err = assets.LoadModels()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
			log.Printf("Warning: Failed to load models: %v", err)
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
		g.AssetManager.Models = models
 | 
			
		||||
 | 
			
		||||
		// Try to load music
 | 
			
		||||
		music, err := assets.LoadMusic("resources/audio/GoonScape1.mp3")
 | 
			
		||||
	g.Music, err = assets.LoadMusic("resources/audio/GoonScape2.mp3")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
			log.Printf("Warning: Failed to load music: %v", err)
 | 
			
		||||
		} else {
 | 
			
		||||
			g.AssetManager.Music = music
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (g *Game) Update(deltaTime float32) {
 | 
			
		||||
	// Handle login screen if not logged in
 | 
			
		||||
	if !g.UIManager.IsLoggedIn {
 | 
			
		||||
		// Handle login
 | 
			
		||||
		username, password, isRegistering, doAuth := g.UIManager.LoginScreen.Update()
 | 
			
		||||
 | 
			
		||||
		if doAuth {
 | 
			
		||||
	if !g.isLoggedIn {
 | 
			
		||||
		username, password, isRegistering, submitted := g.loginScreen.Update()
 | 
			
		||||
		if submitted {
 | 
			
		||||
			conn, playerID, err := network.ConnectToServer(username, password, isRegistering)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				g.UIManager.LoginScreen.SetError(err.Error())
 | 
			
		||||
				g.loginScreen.SetError(err.Error())
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			g.PlayerManager.LocalPlayer = &types.Player{
 | 
			
		||||
			g.Player = &types.Player{
 | 
			
		||||
				Speed:      50.0,
 | 
			
		||||
				TargetPath: []types.Tile{},
 | 
			
		||||
				ActionQueue: []*pb.Action{},
 | 
			
		||||
				UserData:   g,
 | 
			
		||||
				QuitDone:   make(chan struct{}),
 | 
			
		||||
				ID:         playerID,
 | 
			
		||||
			}
 | 
			
		||||
			g.AssignModelToPlayer(g.PlayerManager.LocalPlayer)
 | 
			
		||||
			g.AssignModelToPlayer(g.Player)
 | 
			
		||||
 | 
			
		||||
			// Set user data to allow chat message handling
 | 
			
		||||
			g.PlayerManager.LocalPlayer.UserData = g
 | 
			
		||||
 | 
			
		||||
			go network.HandleServerCommunication(conn, playerID, g.PlayerManager.LocalPlayer, g.PlayerManager.OtherPlayers, g.quitChan)
 | 
			
		||||
			g.UIManager.IsLoggedIn = true
 | 
			
		||||
		}
 | 
			
		||||
			go network.HandleServerCommunication(conn, playerID, g.Player, g.OtherPlayers, g.QuitChan)
 | 
			
		||||
			g.isLoggedIn = true
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
	// Skip update logic if player is not initialized yet
 | 
			
		||||
	if g.PlayerManager.LocalPlayer == nil {
 | 
			
		||||
		log.Printf("Warning: LocalPlayer is nil during update, skipping")
 | 
			
		||||
		g.loginScreen.Draw()
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Handle ESC for menu
 | 
			
		||||
	if rl.IsKeyPressed(rl.KeyEscape) {
 | 
			
		||||
		g.UIManager.MenuOpen = !g.UIManager.MenuOpen
 | 
			
		||||
	if g.input.IsKeyPressed(rl.KeyEscape) {
 | 
			
		||||
		g.MenuOpen = !g.MenuOpen
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Don't process other inputs if menu is open
 | 
			
		||||
	if g.UIManager.MenuOpen {
 | 
			
		||||
	if g.MenuOpen {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Handle chat updates
 | 
			
		||||
	if message, sent := g.UIManager.Chat.Update(); sent {
 | 
			
		||||
		g.PlayerManager.LocalPlayer.Lock()
 | 
			
		||||
		g.PlayerManager.LocalPlayer.ActionQueue = append(g.PlayerManager.LocalPlayer.ActionQueue, &pb.Action{
 | 
			
		||||
	if message, sent := g.Chat.Update(); sent {
 | 
			
		||||
		g.Player.Lock()
 | 
			
		||||
		g.Player.ActionQueue = append(g.Player.ActionQueue, &pb.Action{
 | 
			
		||||
			Type:        pb.Action_CHAT,
 | 
			
		||||
			ChatMessage: message,
 | 
			
		||||
			PlayerId:    g.PlayerManager.LocalPlayer.ID,
 | 
			
		||||
			PlayerId:    g.Player.ID,
 | 
			
		||||
		})
 | 
			
		||||
		g.PlayerManager.LocalPlayer.Unlock()
 | 
			
		||||
		g.Player.Unlock()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Process player input
 | 
			
		||||
	g.HandleInput()
 | 
			
		||||
 | 
			
		||||
	// Update local player movement
 | 
			
		||||
	if g.PlayerManager.LocalPlayer.TargetPath != nil && len(g.PlayerManager.LocalPlayer.TargetPath) > 0 {
 | 
			
		||||
		g.PlayerManager.LocalPlayer.MoveTowards(g.PlayerManager.LocalPlayer.TargetPath[0], deltaTime, GetMapGrid())
 | 
			
		||||
	if len(g.Player.TargetPath) > 0 {
 | 
			
		||||
		g.Player.MoveTowards(g.Player.TargetPath[0], deltaTime, GetMapGrid())
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Periodically log information about other players
 | 
			
		||||
	g.frameCounter++
 | 
			
		||||
	if g.frameCounter%300 == 0 {
 | 
			
		||||
		rl.TraceLog(rl.LogInfo, "There are %d other players", len(g.PlayerManager.OtherPlayers))
 | 
			
		||||
		for id, other := range g.PlayerManager.OtherPlayers {
 | 
			
		||||
			if other != nil {
 | 
			
		||||
				// Calculate tile coordinates from absolute position
 | 
			
		||||
				tileX := int(other.PosActual.X / float32(types.TileSize))
 | 
			
		||||
				tileY := int(other.PosActual.Z / float32(types.TileSize))
 | 
			
		||||
 | 
			
		||||
				rl.TraceLog(rl.LogInfo, "Other player ID: %d, Position: (%f, %f, %f), Tile: (%d, %d), Has model: %v",
 | 
			
		||||
					id, other.PosActual.X, other.PosActual.Y, other.PosActual.Z,
 | 
			
		||||
					tileX, tileY, other.Model.Meshes != nil)
 | 
			
		||||
			} else {
 | 
			
		||||
				rl.TraceLog(rl.LogInfo, "Other player ID: %d is nil", id)
 | 
			
		||||
			}
 | 
			
		||||
	for _, other := range g.OtherPlayers {
 | 
			
		||||
		if len(other.TargetPath) > 0 {
 | 
			
		||||
			other.MoveTowards(other.TargetPath[0], deltaTime, GetMapGrid())
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Process other players
 | 
			
		||||
	for _, other := range g.PlayerManager.OtherPlayers {
 | 
			
		||||
		if other == nil {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if other.TargetPath != nil && len(other.TargetPath) > 0 {
 | 
			
		||||
			target := other.TargetPath[0]
 | 
			
		||||
			other.MoveTowards(target, deltaTime, GetMapGrid())
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Assign model if needed
 | 
			
		||||
		if other.Model.Meshes == nil {
 | 
			
		||||
			g.AssignModelToPlayer(other)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Update camera position
 | 
			
		||||
	UpdateCamera(&g.Camera, g.PlayerManager.LocalPlayer.PosActual, deltaTime)
 | 
			
		||||
 | 
			
		||||
	// Update music if available
 | 
			
		||||
	if g.AssetManager.Music.Stream.Buffer != nil {
 | 
			
		||||
		rl.UpdateMusicStream(g.AssetManager.Music)
 | 
			
		||||
	}
 | 
			
		||||
	UpdateCamera(&g.Camera, g.Player.PosActual, deltaTime)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (g *Game) DrawMap() {
 | 
			
		||||
@ -217,169 +152,148 @@ func (g *Game) DrawMap() {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (g *Game) DrawPlayer(player *types.Player, model rl.Model) {
 | 
			
		||||
	if player == nil {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	player.Lock()
 | 
			
		||||
	defer player.Unlock()
 | 
			
		||||
 | 
			
		||||
	// Get necessary data
 | 
			
		||||
	modelIndex := int(player.ID) % len(g.AssetManager.Models)
 | 
			
		||||
	if modelIndex < 0 || modelIndex >= len(g.AssetManager.Models) {
 | 
			
		||||
		modelIndex = 0
 | 
			
		||||
	}
 | 
			
		||||
	modelAsset := g.AssetManager.Models[modelIndex]
 | 
			
		||||
	grid := GetMapGrid()
 | 
			
		||||
	modelIndex := int(player.ID) % len(g.Models)
 | 
			
		||||
	modelAsset := g.Models[modelIndex]
 | 
			
		||||
 | 
			
		||||
	// Calculate position
 | 
			
		||||
	const defaultHeight = 8.0
 | 
			
		||||
	const defaultHeight = 8.0 // Default height above tile, fine tune per model in types.ModelAsset
 | 
			
		||||
	playerPos := rl.Vector3{
 | 
			
		||||
		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,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Simple drawing with scale parameter
 | 
			
		||||
	var drawColor rl.Color = rl.White
 | 
			
		||||
	if player.PlaceholderColor.A > 0 {
 | 
			
		||||
		drawColor = player.PlaceholderColor
 | 
			
		||||
	// Check if model has animations
 | 
			
		||||
	if modelAsset.Animations.Idle != nil || modelAsset.Animations.Walk != nil {
 | 
			
		||||
		if player.IsMoving && len(modelAsset.Animations.Walk) > 0 {
 | 
			
		||||
			anim := modelAsset.Animations.Walk[0] // Use first walk animation
 | 
			
		||||
			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.0, drawColor)
 | 
			
		||||
	rl.DrawModel(model, playerPos, 16, rl.White)
 | 
			
		||||
 | 
			
		||||
	// Update floating message position
 | 
			
		||||
	// Draw floating messages and path indicators
 | 
			
		||||
	if player.FloatingMessage != nil {
 | 
			
		||||
		worldPos := rl.Vector3{
 | 
			
		||||
		screenPos := rl.GetWorldToScreen(rl.Vector3{
 | 
			
		||||
			X: playerPos.X,
 | 
			
		||||
			Y: playerPos.Y + 24.0, // Position above head
 | 
			
		||||
			Y: playerPos.Y + 24.0,
 | 
			
		||||
			Z: playerPos.Z,
 | 
			
		||||
		}
 | 
			
		||||
		player.FloatingMessage.ScreenPos = rl.GetWorldToScreen(worldPos, g.Camera)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
		}, g.Camera)
 | 
			
		||||
 | 
			
		||||
func (g *Game) DrawFloatingMessages() {
 | 
			
		||||
	var drawFloatingMessage = func(msg *types.FloatingMessage) {
 | 
			
		||||
		if msg == nil || time.Now().After(msg.ExpireTime) {
 | 
			
		||||
			return
 | 
			
		||||
		player.FloatingMessage.ScreenPos = screenPos
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
		// Draw the message with RuneScape-style coloring (black outline with yellow text)
 | 
			
		||||
		text := msg.Content
 | 
			
		||||
		textWidth := rl.MeasureText(text, 20)
 | 
			
		||||
	if len(player.TargetPath) > 0 {
 | 
			
		||||
		targetTile := player.TargetPath[len(player.TargetPath)-1]
 | 
			
		||||
		targetPos := rl.Vector3{
 | 
			
		||||
			X: float32(targetTile.X * types.TileSize),
 | 
			
		||||
			Y: grid[targetTile.X][targetTile.Y].Height * types.TileHeight,
 | 
			
		||||
			Z: float32(targetTile.Y * types.TileSize),
 | 
			
		||||
		}
 | 
			
		||||
		rl.DrawCubeWires(targetPos, types.TileSize, types.TileHeight, types.TileSize, rl.Green)
 | 
			
		||||
 | 
			
		||||
		// Draw black outline by offsetting the text slightly in all directions
 | 
			
		||||
		for offsetX := -2; offsetX <= 2; offsetX++ {
 | 
			
		||||
			for offsetY := -2; offsetY <= 2; offsetY++ {
 | 
			
		||||
				rl.DrawText(text,
 | 
			
		||||
					int32(msg.ScreenPos.X)-textWidth/2+int32(offsetX),
 | 
			
		||||
					int32(msg.ScreenPos.Y)+int32(offsetY),
 | 
			
		||||
					20,
 | 
			
		||||
					rl.Black)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Draw the yellow text on top
 | 
			
		||||
		rl.DrawText(text, int32(msg.ScreenPos.X)-textWidth/2, int32(msg.ScreenPos.Y), 20, rl.Yellow)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if g.PlayerManager.LocalPlayer != nil && g.PlayerManager.LocalPlayer.FloatingMessage != nil {
 | 
			
		||||
		drawFloatingMessage(g.PlayerManager.LocalPlayer.FloatingMessage)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, other := range g.PlayerManager.OtherPlayers {
 | 
			
		||||
		if other != nil && other.FloatingMessage != nil {
 | 
			
		||||
			drawFloatingMessage(other.FloatingMessage)
 | 
			
		||||
		nextTile := player.TargetPath[0]
 | 
			
		||||
		nextPos := rl.Vector3{
 | 
			
		||||
			X: float32(nextTile.X * types.TileSize),
 | 
			
		||||
			Y: grid[nextTile.X][nextTile.Y].Height * types.TileHeight,
 | 
			
		||||
			Z: float32(nextTile.Y * types.TileSize),
 | 
			
		||||
		}
 | 
			
		||||
		rl.DrawCubeWires(nextPos, types.TileSize, types.TileHeight, types.TileSize, rl.Yellow)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (g *Game) Render() {
 | 
			
		||||
	rl.BeginDrawing()
 | 
			
		||||
	defer rl.EndDrawing()
 | 
			
		||||
 | 
			
		||||
	rl.ClearBackground(rl.RayWhite)
 | 
			
		||||
 | 
			
		||||
	if !g.UIManager.IsLoggedIn {
 | 
			
		||||
		g.UIManager.LoginScreen.Draw()
 | 
			
		||||
	if !g.isLoggedIn {
 | 
			
		||||
		g.loginScreen.Draw()
 | 
			
		||||
		rl.EndDrawing()
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Draw 3D elements
 | 
			
		||||
	rl.BeginMode3D(g.Camera)
 | 
			
		||||
	g.DrawMap()
 | 
			
		||||
 | 
			
		||||
	// Draw player only if valid
 | 
			
		||||
	if g.PlayerManager.LocalPlayer != nil && g.PlayerManager.LocalPlayer.Model.Meshes != nil {
 | 
			
		||||
		g.DrawPlayer(g.PlayerManager.LocalPlayer, g.PlayerManager.LocalPlayer.Model)
 | 
			
		||||
	g.DrawPlayer(g.Player, g.Player.Model)
 | 
			
		||||
	for _, other := range g.OtherPlayers {
 | 
			
		||||
		if other.Model.Meshes == nil {
 | 
			
		||||
			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)
 | 
			
		||||
	}
 | 
			
		||||
	}
 | 
			
		||||
	rl.EndMode3D()
 | 
			
		||||
 | 
			
		||||
	// Draw floating messages with RuneScape style
 | 
			
		||||
	g.DrawFloatingMessages()
 | 
			
		||||
	// Draw floating messages
 | 
			
		||||
	drawFloatingMessage := func(msg *types.FloatingMessage) {
 | 
			
		||||
		if msg == nil || time.Now().After(msg.ExpireTime) {
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		pos := msg.ScreenPos
 | 
			
		||||
		text := msg.Content
 | 
			
		||||
		textWidth := rl.MeasureText(text, 20)
 | 
			
		||||
 | 
			
		||||
		for offsetX := -2; offsetX <= 2; offsetX++ {
 | 
			
		||||
			for offsetY := -2; offsetY <= 2; offsetY++ {
 | 
			
		||||
				rl.DrawText(text,
 | 
			
		||||
					int32(pos.X)-textWidth/2+int32(offsetX),
 | 
			
		||||
					int32(pos.Y)+int32(offsetY),
 | 
			
		||||
					20,
 | 
			
		||||
					rl.Black)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		rl.DrawText(text, int32(pos.X)-textWidth/2, int32(pos.Y), 20, rl.Yellow)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if g.Player.FloatingMessage != nil {
 | 
			
		||||
		drawFloatingMessage(g.Player.FloatingMessage)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, other := range g.OtherPlayers {
 | 
			
		||||
		drawFloatingMessage(other.FloatingMessage)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Draw menu if open
 | 
			
		||||
	if g.UIManager.MenuOpen {
 | 
			
		||||
	if g.MenuOpen {
 | 
			
		||||
		g.DrawMenu()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Only draw chat if menu is not open
 | 
			
		||||
	if !g.UIManager.MenuOpen && g.UIManager.Chat != nil {
 | 
			
		||||
		g.UIManager.Chat.Draw(int32(rl.GetScreenWidth()), int32(rl.GetScreenHeight()))
 | 
			
		||||
	if !g.MenuOpen {
 | 
			
		||||
		g.Chat.Draw(int32(rl.GetScreenWidth()), int32(rl.GetScreenHeight()))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Draw FPS counter
 | 
			
		||||
	rl.DrawFPS(10, 10)
 | 
			
		||||
	rl.EndDrawing()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (g *Game) Cleanup() {
 | 
			
		||||
	g.cleanupOnce.Do(func() {
 | 
			
		||||
		// Cleanup models
 | 
			
		||||
		for _, model := range g.AssetManager.Models {
 | 
			
		||||
			rl.UnloadModel(model.Model)
 | 
			
		||||
			if model.Texture.ID > 0 {
 | 
			
		||||
				rl.UnloadTexture(model.Texture)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Unload music
 | 
			
		||||
		if g.AssetManager.Music.Stream.Buffer != nil {
 | 
			
		||||
			rl.UnloadMusicStream(g.AssetManager.Music)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Only close the channel if it hasn't been closed yet
 | 
			
		||||
		select {
 | 
			
		||||
		case <-g.quitChan:
 | 
			
		||||
			// Channel already closed, do nothing
 | 
			
		||||
		default:
 | 
			
		||||
			close(g.quitChan)
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
	assets.UnloadModels(g.Models)
 | 
			
		||||
	assets.UnloadMusic(g.Music)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (g *Game) HandleInput() {
 | 
			
		||||
	clickedTile, clicked := g.GetTileAtMouse()
 | 
			
		||||
	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 {
 | 
			
		||||
			g.PlayerManager.LocalPlayer.Lock()
 | 
			
		||||
			g.PlayerManager.LocalPlayer.TargetPath = path[1:]
 | 
			
		||||
			g.PlayerManager.LocalPlayer.ActionQueue = append(g.PlayerManager.LocalPlayer.ActionQueue, &pb.Action{
 | 
			
		||||
			g.Player.Lock()
 | 
			
		||||
			g.Player.TargetPath = path[1:]
 | 
			
		||||
			g.Player.ActionQueue = append(g.Player.ActionQueue, &pb.Action{
 | 
			
		||||
				Type:     pb.Action_MOVE,
 | 
			
		||||
				X:        int32(clickedTile.X),
 | 
			
		||||
				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())
 | 
			
		||||
	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))
 | 
			
		||||
 | 
			
		||||
	// Draw menu items
 | 
			
		||||
	menuItems := []string{"Resume", "Settings", "Quit"}
 | 
			
		||||
	menuY := screenHeight/2 - float32(len(menuItems)*40)/2
 | 
			
		||||
	// Menu title
 | 
			
		||||
	title := "Menu"
 | 
			
		||||
	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 {
 | 
			
		||||
		itemY := menuY + float32(i*40)
 | 
			
		||||
		mousePoint := rl.GetMousePosition()
 | 
			
		||||
		itemRect := rl.Rectangle{X: screenWidth/2 - 100, Y: itemY, Width: 200, Height: 36}
 | 
			
		||||
	// Menu buttons
 | 
			
		||||
	buttonWidth := float32(200)
 | 
			
		||||
	buttonHeight := float32(40)
 | 
			
		||||
	buttonY := float32(200)
 | 
			
		||||
	buttonSpacing := float32(60)
 | 
			
		||||
 | 
			
		||||
		// Check for hover
 | 
			
		||||
		isHover := rl.CheckCollisionPointRec(mousePoint, itemRect)
 | 
			
		||||
	menuItems := []string{"Resume", "Settings", "Exit Game"}
 | 
			
		||||
	for _, item := range menuItems {
 | 
			
		||||
		buttonRect := rl.Rectangle{
 | 
			
		||||
			X:      screenWidth/2 - buttonWidth/2,
 | 
			
		||||
			Y:      buttonY,
 | 
			
		||||
			Width:  buttonWidth,
 | 
			
		||||
			Height: buttonHeight,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Draw button background
 | 
			
		||||
		if isHover {
 | 
			
		||||
			rl.DrawRectangleRec(itemRect, rl.ColorAlpha(rl.White, 0.3))
 | 
			
		||||
		} else {
 | 
			
		||||
			rl.DrawRectangleRec(itemRect, rl.ColorAlpha(rl.White, 0.1))
 | 
			
		||||
		// Check mouse hover
 | 
			
		||||
		mousePoint := g.input.GetMousePosition()
 | 
			
		||||
		mouseHover := rl.CheckCollisionPointRec(mousePoint, buttonRect)
 | 
			
		||||
 | 
			
		||||
		// 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
 | 
			
		||||
		textWidth := rl.MeasureText(item, 20)
 | 
			
		||||
		rl.DrawText(item, int32(itemRect.X+(itemRect.Width-float32(textWidth))/2), int32(itemRect.Y+8), 20, rl.White)
 | 
			
		||||
		textSize := int32(20)
 | 
			
		||||
		textWidth := rl.MeasureText(item, textSize)
 | 
			
		||||
		textX := int32(buttonRect.X+buttonRect.Width/2) - textWidth/2
 | 
			
		||||
		textY := int32(buttonRect.Y + buttonRect.Height/2 - float32(textSize)/2)
 | 
			
		||||
		rl.DrawText(item, textX, textY, textSize, rl.White)
 | 
			
		||||
 | 
			
		||||
		// Handle click
 | 
			
		||||
		if isHover && rl.IsMouseButtonReleased(rl.MouseLeftButton) {
 | 
			
		||||
			switch item {
 | 
			
		||||
			case "Resume":
 | 
			
		||||
				g.UIManager.MenuOpen = false
 | 
			
		||||
			case "Settings":
 | 
			
		||||
				// TODO: Implement settings
 | 
			
		||||
			case "Quit":
 | 
			
		||||
				g.Shutdown()
 | 
			
		||||
				rl.CloseWindow()
 | 
			
		||||
		buttonY += buttonSpacing
 | 
			
		||||
	}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (g *Game) HandleServerMessages(messages []*pb.ChatMessage) {
 | 
			
		||||
	// Check if Chat is properly initialized
 | 
			
		||||
	if g.UIManager != nil && g.UIManager.Chat != nil {
 | 
			
		||||
		g.UIManager.Chat.HandleServerMessages(messages)
 | 
			
		||||
	} else {
 | 
			
		||||
		log.Printf("Warning: Cannot handle server messages, Chat is not initialized")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (g *Game) QuitChan() <-chan struct{} {
 | 
			
		||||
	return g.quitChan
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (g *Game) Shutdown() {
 | 
			
		||||
	// Use the cleanup method which has channel-closing safety
 | 
			
		||||
	g.Cleanup()
 | 
			
		||||
	close(g.QuitChan)
 | 
			
		||||
	<-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) {
 | 
			
		||||
	if player == nil {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	modelAsset, found := g.AssetManager.GetModelForPlayer(player.ID)
 | 
			
		||||
	if !found {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	modelIndex := int(player.ID) % len(g.Models)
 | 
			
		||||
	modelAsset := g.Models[modelIndex]
 | 
			
		||||
 | 
			
		||||
	// Just use the original model - don't try to copy it
 | 
			
		||||
	player.Model = modelAsset.Model
 | 
			
		||||
	player.PlaceholderColor = modelAsset.PlaceholderColor
 | 
			
		||||
 | 
			
		||||
	// Initialize animations if available
 | 
			
		||||
	if len(modelAsset.Animations.Idle) > 0 || len(modelAsset.Animations.Walk) > 0 {
 | 
			
		||||
		player.InitializeAnimations(modelAsset.Animations)
 | 
			
		||||
	}
 | 
			
		||||
	player.Texture = modelAsset.Texture
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										106
									
								
								game/game_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								game/game_test.go
									
									
									
									
									
										Normal 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...
 | 
			
		||||
@ -3,16 +3,95 @@ package game
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
 | 
			
		||||
	"gitea.boner.be/bdnugget/goonscape/game/mock"
 | 
			
		||||
	"gitea.boner.be/bdnugget/goonscape/types"
 | 
			
		||||
	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) {
 | 
			
		||||
	if !rl.IsMouseButtonPressed(rl.MouseLeftButton) {
 | 
			
		||||
	if !g.input.IsMouseButtonPressed(toInt32(rl.MouseLeftButton)) {
 | 
			
		||||
		return types.Tile{}, false
 | 
			
		||||
	}
 | 
			
		||||
	mouse := rl.GetMousePosition()
 | 
			
		||||
	ray := rl.GetMouseRay(mouse, g.Camera)
 | 
			
		||||
	mouse := g.input.GetMousePosition()
 | 
			
		||||
	ray := g.input.GetMouseRay(mouse, g.Camera)
 | 
			
		||||
 | 
			
		||||
	for x := 0; x < types.MapWidth; x++ {
 | 
			
		||||
		for y := 0; y < types.MapHeight; y++ {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										187
									
								
								game/input_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								game/input_test.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										80
									
								
								game/login_test.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										15
									
								
								game/mock/raylib.go
									
									
									
									
									
										Normal 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
 | 
			
		||||
)
 | 
			
		||||
@ -1,157 +1,91 @@
 | 
			
		||||
package game
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"container/heap"
 | 
			
		||||
	"fmt"
 | 
			
		||||
 | 
			
		||||
	"gitea.boner.be/bdnugget/goonscape/types"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Node represents a node in the A* pathfinding algorithm
 | 
			
		||||
type Node struct {
 | 
			
		||||
	Tile    types.Tile
 | 
			
		||||
	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 {
 | 
			
		||||
	// Initialize open and closed sets
 | 
			
		||||
	openSet := &PriorityQueue{}
 | 
			
		||||
	heap.Init(openSet)
 | 
			
		||||
	openList := []*Node{}
 | 
			
		||||
	closedList := make(map[[2]int]bool)
 | 
			
		||||
 | 
			
		||||
	closedSet := make(map[[2]int]bool)
 | 
			
		||||
 | 
			
		||||
	// Create start node and add to open set
 | 
			
		||||
	startNode := &Node{
 | 
			
		||||
		Tile:   start,
 | 
			
		||||
		Parent: nil,
 | 
			
		||||
		G:      0,
 | 
			
		||||
		H:      heuristic(start, end),
 | 
			
		||||
	}
 | 
			
		||||
	startNode := &Node{Tile: start, G: 0, H: heuristic(start, end)}
 | 
			
		||||
	startNode.F = startNode.G + startNode.H
 | 
			
		||||
	heap.Push(openSet, startNode)
 | 
			
		||||
	openList = append(openList, startNode)
 | 
			
		||||
 | 
			
		||||
	// Main search loop
 | 
			
		||||
	for openSet.Len() > 0 {
 | 
			
		||||
		// Get node with lowest F score
 | 
			
		||||
		current := heap.Pop(openSet).(*Node)
 | 
			
		||||
 | 
			
		||||
		// If we reached the goal, reconstruct and return the path
 | 
			
		||||
		if current.Tile.X == end.X && current.Tile.Y == end.Y {
 | 
			
		||||
			return reconstructPath(current)
 | 
			
		||||
	for len(openList) > 0 {
 | 
			
		||||
		current := openList[0]
 | 
			
		||||
		currentIndex := 0
 | 
			
		||||
		for i, node := range openList {
 | 
			
		||||
			if node.F < current.F {
 | 
			
		||||
				current = node
 | 
			
		||||
				currentIndex = i
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Add current to closed set
 | 
			
		||||
		closedSet[[2]int{current.Tile.X, current.Tile.Y}] = true
 | 
			
		||||
		openList = append(openList[:currentIndex], openList[currentIndex+1:]...)
 | 
			
		||||
		closedList[[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}] {
 | 
			
		||||
		if current.Tile.X == end.X && current.Tile.Y == end.Y {
 | 
			
		||||
			path := []types.Tile{}
 | 
			
		||||
			node := current
 | 
			
		||||
			for node != nil {
 | 
			
		||||
				path = append([]types.Tile{node.Tile}, path...)
 | 
			
		||||
				node = node.Parent
 | 
			
		||||
			}
 | 
			
		||||
			fmt.Printf("Path found: %v\n", path)
 | 
			
		||||
			return path
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		neighbors := GetNeighbors(current.Tile)
 | 
			
		||||
		for _, neighbor := range neighbors {
 | 
			
		||||
			if !neighbor.Walkable || closedList[[2]int{neighbor.X, neighbor.Y}] {
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Calculate tentative G score
 | 
			
		||||
			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
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// 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{
 | 
			
		||||
				newNode := &Node{
 | 
			
		||||
					Tile:   neighbor,
 | 
			
		||||
					Parent: current,
 | 
			
		||||
					G:      tentativeG,
 | 
			
		||||
					H:      heuristic(neighbor, end),
 | 
			
		||||
				}
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// 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
 | 
			
		||||
				newNode.F = newNode.G + newNode.H
 | 
			
		||||
				if !inOpen {
 | 
			
		||||
					heap.Push(openSet, neighborNode)
 | 
			
		||||
					openList = append(openList, newNode)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// No path found
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// reconstructPath builds the path from goal node to start
 | 
			
		||||
func reconstructPath(node *Node) []types.Tile {
 | 
			
		||||
	path := []types.Tile{}
 | 
			
		||||
	current := node
 | 
			
		||||
 | 
			
		||||
	// Follow parent pointers back to start
 | 
			
		||||
	for current != nil {
 | 
			
		||||
		path = append([]types.Tile{current.Tile}, path...)
 | 
			
		||||
		current = current.Parent
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fmt.Printf("Path found: %v\n", path)
 | 
			
		||||
	return path
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// heuristic estimates cost from current to goal (Manhattan distance)
 | 
			
		||||
func heuristic(a, b types.Tile) float32 {
 | 
			
		||||
	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 {
 | 
			
		||||
	return 1.0 // uniform cost for now
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetNeighbors returns walkable tiles adjacent to the given tile
 | 
			
		||||
func GetNeighbors(tile types.Tile) []types.Tile {
 | 
			
		||||
	directions := [][2]int{
 | 
			
		||||
		{1, 0}, {-1, 0}, {0, 1}, {0, -1},
 | 
			
		||||
@ -170,7 +104,6 @@ func GetNeighbors(tile types.Tile) []types.Tile {
 | 
			
		||||
	return neighbors
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// abs returns the absolute value of x
 | 
			
		||||
func abs(x int) int {
 | 
			
		||||
	if x < 0 {
 | 
			
		||||
		return -x
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										141
									
								
								game/test_helpers.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								game/test_helpers.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										26
									
								
								game/test_setup.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										153
									
								
								game/testutils/helpers.go
									
									
									
									
									
										Normal 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)
 | 
			
		||||
}
 | 
			
		||||
@ -1,36 +1,9 @@
 | 
			
		||||
package game
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"log"
 | 
			
		||||
	"runtime/debug"
 | 
			
		||||
 | 
			
		||||
	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 {
 | 
			
		||||
	tmin := (boxMin.X - ray.Position.X) / ray.Direction.X
 | 
			
		||||
	tmax := (boxMax.X - ray.Position.X) / ray.Direction.X
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										7
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										7
									
								
								go.mod
									
									
									
									
									
								
							@ -8,8 +8,15 @@ require (
 | 
			
		||||
	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 (
 | 
			
		||||
	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/sys v0.29.0 // indirect
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										9
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										9
									
								
								go.sum
									
									
									
									
									
								
							@ -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/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/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q=
 | 
			
		||||
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/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/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
 | 
			
		||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
 | 
			
		||||
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/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=
 | 
			
		||||
 | 
			
		||||
 Submodule goonserver updated: 00aa302229...f9ec811b10
									
								
							
							
								
								
									
										119
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										119
									
								
								main.go
									
									
									
									
									
								
							@ -3,11 +3,7 @@ package main
 | 
			
		||||
import (
 | 
			
		||||
	"flag"
 | 
			
		||||
	"log"
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/signal"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"syscall"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"gitea.boner.be/bdnugget/goonscape/game"
 | 
			
		||||
	"gitea.boner.be/bdnugget/goonscape/network"
 | 
			
		||||
@ -15,27 +11,11 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
	verbose := flag.Bool("v", false, "Also show info logs (spammy)")
 | 
			
		||||
	local := flag.Bool("local", false, "Connect to local server")
 | 
			
		||||
	addr := flag.String("addr", "", "Server address (host or host:port)")
 | 
			
		||||
	flag.Parse()
 | 
			
		||||
 | 
			
		||||
	if *verbose {
 | 
			
		||||
		rl.SetTraceLogLevel(rl.LogTrace)
 | 
			
		||||
	} else {
 | 
			
		||||
		rl.SetTraceLogLevel(rl.LogWarning)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set server address based on flags
 | 
			
		||||
	if *local {
 | 
			
		||||
		if *addr != "" {
 | 
			
		||||
@ -50,104 +30,31 @@ func main() {
 | 
			
		||||
		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.SetExitKey(0)
 | 
			
		||||
	defer rl.CloseWindow()
 | 
			
		||||
 | 
			
		||||
	// Initialize audio with error handling
 | 
			
		||||
	if !rl.IsAudioDeviceReady() {
 | 
			
		||||
	rl.InitAudioDevice()
 | 
			
		||||
		if !rl.IsAudioDeviceReady() {
 | 
			
		||||
			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()
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
	defer rl.CloseAudioDevice()
 | 
			
		||||
 | 
			
		||||
	rl.SetTargetFPS(60)
 | 
			
		||||
 | 
			
		||||
	// Play music if available
 | 
			
		||||
	if gameInstance.AssetManager.Music.Stream.Buffer != nil {
 | 
			
		||||
		rl.PlayMusicStream(gameInstance.AssetManager.Music)
 | 
			
		||||
		rl.SetMusicVolume(gameInstance.AssetManager.Music, 0.5)
 | 
			
		||||
	game := game.New()
 | 
			
		||||
	if err := game.LoadAssets(); err != nil {
 | 
			
		||||
		log.Fatalf("Failed to load assets: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer game.Cleanup()
 | 
			
		||||
 | 
			
		||||
	// Handle OS signals for clean shutdown
 | 
			
		||||
	sigChan := make(chan os.Signal, 1)
 | 
			
		||||
	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
 | 
			
		||||
	go func() {
 | 
			
		||||
		<-sigChan
 | 
			
		||||
		if gameInstance != nil {
 | 
			
		||||
			gameInstance.Shutdown()
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
	rl.PlayMusicStream(game.Music)
 | 
			
		||||
	rl.SetMusicVolume(game.Music, 0.5)
 | 
			
		||||
 | 
			
		||||
	// Keep game loop in main thread for Raylib
 | 
			
		||||
	for !rl.WindowShouldClose() {
 | 
			
		||||
		deltaTime := rl.GetFrameTime()
 | 
			
		||||
 | 
			
		||||
		// Update music if available
 | 
			
		||||
		if gameInstance.AssetManager.Music.Stream.Buffer != nil {
 | 
			
		||||
			rl.UpdateMusicStream(gameInstance.AssetManager.Music)
 | 
			
		||||
		rl.UpdateMusicStream(game.Music)
 | 
			
		||||
		game.Update(deltaTime)
 | 
			
		||||
		game.Render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
		func() {
 | 
			
		||||
			defer func() {
 | 
			
		||||
				if r := recover(); r != nil {
 | 
			
		||||
					log.Printf("Recovered from panic in game update: %v", r)
 | 
			
		||||
				}
 | 
			
		||||
			}()
 | 
			
		||||
			gameInstance.Update(deltaTime)
 | 
			
		||||
		}()
 | 
			
		||||
 | 
			
		||||
		func() {
 | 
			
		||||
			defer func() {
 | 
			
		||||
				if r := recover(); r != nil {
 | 
			
		||||
					log.Printf("Recovered from panic in game render: %v", r)
 | 
			
		||||
				}
 | 
			
		||||
			}()
 | 
			
		||||
			gameInstance.Render()
 | 
			
		||||
		}()
 | 
			
		||||
 | 
			
		||||
		// Check if game requested shutdown
 | 
			
		||||
		select {
 | 
			
		||||
		case <-gameInstance.QuitChan():
 | 
			
		||||
			return
 | 
			
		||||
		default:
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	// Wait for clean shutdown
 | 
			
		||||
	<-game.QuitChan
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,6 @@ import (
 | 
			
		||||
	"io"
 | 
			
		||||
	"log"
 | 
			
		||||
	"net"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"gitea.boner.be/bdnugget/goonscape/types"
 | 
			
		||||
@ -18,226 +17,21 @@ import (
 | 
			
		||||
 | 
			
		||||
const protoVersion = 1
 | 
			
		||||
 | 
			
		||||
var serverAddr = "boner.be:6969"       // Default server address
 | 
			
		||||
var lastSeenMessageTimestamp int64 = 0 // Track the last message timestamp seen by this client
 | 
			
		||||
var serverAddr = "boner.be:6969"
 | 
			
		||||
 | 
			
		||||
func SetServerAddr(addr string) {
 | 
			
		||||
	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) {
 | 
			
		||||
	log.Printf("Connecting to server at %s...", serverAddr)
 | 
			
		||||
 | 
			
		||||
	var err error
 | 
			
		||||
	var conn net.Conn
 | 
			
		||||
 | 
			
		||||
	// 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")
 | 
			
		||||
	conn, err := net.Dial("tcp", serverAddr)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Printf("Failed to dial server: %v", err)
 | 
			
		||||
		return nil, 0, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Println("Connected to server. Authenticating...")
 | 
			
		||||
 | 
			
		||||
	// Create a message handler
 | 
			
		||||
	msgHandler := NewMessageHandler(conn)
 | 
			
		||||
 | 
			
		||||
	// Send auth message
 | 
			
		||||
	authAction := &pb.Action{
 | 
			
		||||
		Type:     pb.Action_LOGIN,
 | 
			
		||||
@ -253,23 +47,31 @@ func ConnectToServer(username, password string, isRegistering bool) (net.Conn, i
 | 
			
		||||
		ProtocolVersion: protoVersion,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := msgHandler.WriteMessage(authBatch); err != nil {
 | 
			
		||||
	if err := writeMessage(conn, authBatch); err != nil {
 | 
			
		||||
		conn.Close()
 | 
			
		||||
		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
 | 
			
		||||
	response, err := msgHandler.ReadMessage()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
	reader := bufio.NewReader(conn)
 | 
			
		||||
	lengthBuf := make([]byte, 4)
 | 
			
		||||
	if _, err := io.ReadFull(reader, lengthBuf); err != nil {
 | 
			
		||||
		conn.Close()
 | 
			
		||||
		return nil, 0, fmt.Errorf("failed to read auth response: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	messageLength := binary.BigEndian.Uint32(lengthBuf)
 | 
			
		||||
 | 
			
		||||
	// Clear read deadline after authentication
 | 
			
		||||
	conn.SetReadDeadline(time.Time{})
 | 
			
		||||
	messageBuf := make([]byte, messageLength)
 | 
			
		||||
	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 {
 | 
			
		||||
		conn.Close()
 | 
			
		||||
@ -284,74 +86,24 @@ func ConnectToServer(username, password string, isRegistering bool) (net.Conn, i
 | 
			
		||||
 | 
			
		||||
	playerID := response.GetPlayerId()
 | 
			
		||||
	log.Printf("Successfully authenticated with player ID: %d", playerID)
 | 
			
		||||
 | 
			
		||||
	// Reset the lastSeenMessageTimestamp when reconnecting
 | 
			
		||||
	lastSeenMessageTimestamp = 0
 | 
			
		||||
 | 
			
		||||
	return conn, playerID, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Player, otherPlayers map[int32]*types.Player, quitChan <-chan struct{}) {
 | 
			
		||||
	msgHandler := NewMessageHandler(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)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
	reader := bufio.NewReader(conn)
 | 
			
		||||
 | 
			
		||||
	actionTicker := time.NewTicker(types.ClientTickRate)
 | 
			
		||||
	defer actionTicker.Stop()
 | 
			
		||||
	defer conn.Close()
 | 
			
		||||
	defer close(player.QuitDone)
 | 
			
		||||
 | 
			
		||||
	// Add a heartbeat ticker to detect connection issues
 | 
			
		||||
	heartbeatTicker := time.NewTicker(5 * time.Second)
 | 
			
		||||
	defer heartbeatTicker.Stop()
 | 
			
		||||
	// Create a channel to signal when goroutines are done
 | 
			
		||||
	done := make(chan struct{})
 | 
			
		||||
 | 
			
		||||
	lastMessageTime := time.Now()
 | 
			
		||||
	// Create a set of current players to track disconnects
 | 
			
		||||
	currentPlayers := make(map[int32]bool)
 | 
			
		||||
 | 
			
		||||
	// Start message sending goroutine
 | 
			
		||||
	go func() {
 | 
			
		||||
		defer func() {
 | 
			
		||||
			if r := recover(); r != nil {
 | 
			
		||||
				log.Printf("Recovered from panic in message sender: %v", r)
 | 
			
		||||
				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 {
 | 
			
		||||
			select {
 | 
			
		||||
			case <-quitChan:
 | 
			
		||||
@ -363,55 +115,28 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play
 | 
			
		||||
						PlayerId: playerID,
 | 
			
		||||
					}},
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Try to send disconnect message, ignoring errors
 | 
			
		||||
				_ = msgHandler.WriteMessage(disconnectMsg)
 | 
			
		||||
 | 
			
		||||
				// No need to signal done channel here, the main goroutine handles this
 | 
			
		||||
				writeMessage(conn, disconnectMsg)
 | 
			
		||||
				done <- struct{}{}
 | 
			
		||||
				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:
 | 
			
		||||
				player.Lock()
 | 
			
		||||
				if len(player.ActionQueue) > 0 {
 | 
			
		||||
					actions := make([]*pb.Action, len(player.ActionQueue))
 | 
			
		||||
					copy(actions, player.ActionQueue)
 | 
			
		||||
 | 
			
		||||
					batch := &pb.ActionBatch{
 | 
			
		||||
						PlayerId: playerID,
 | 
			
		||||
						Actions:  actions,
 | 
			
		||||
						Tick:     player.CurrentTick,
 | 
			
		||||
						LastSeenMessageTimestamp: lastSeenMessageTimestamp,
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					player.ActionQueue = player.ActionQueue[:0]
 | 
			
		||||
					player.Unlock()
 | 
			
		||||
 | 
			
		||||
					if err := msgHandler.WriteMessage(batch); err != nil {
 | 
			
		||||
						select {
 | 
			
		||||
						case errChan <- err:
 | 
			
		||||
						case <-done:
 | 
			
		||||
					if err := writeMessage(conn, batch); err != nil {
 | 
			
		||||
						log.Printf("Failed to send actions to server: %v", err)
 | 
			
		||||
						return
 | 
			
		||||
					}
 | 
			
		||||
					}
 | 
			
		||||
					lastMessageTime = time.Now()
 | 
			
		||||
				} else {
 | 
			
		||||
					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 {
 | 
			
		||||
		select {
 | 
			
		||||
		case <-quitChan:
 | 
			
		||||
				return
 | 
			
		||||
			case <-done:
 | 
			
		||||
				return
 | 
			
		||||
			default:
 | 
			
		||||
				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)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
			done := make(chan struct{})
 | 
			
		||||
			go func() {
 | 
			
		||||
				<-done
 | 
			
		||||
				close(player.QuitDone)
 | 
			
		||||
			}()
 | 
			
		||||
 | 
			
		||||
	// Wait for error or quit signal
 | 
			
		||||
			select {
 | 
			
		||||
	case <-quitChan:
 | 
			
		||||
		log.Printf("Received quit signal, sending disconnect message")
 | 
			
		||||
		// The cleanup will happen in the deferred function
 | 
			
		||||
			case <-done:
 | 
			
		||||
				time.Sleep(100 * time.Millisecond)
 | 
			
		||||
			case <-time.After(1 * time.Second):
 | 
			
		||||
				log.Println("Shutdown timed out")
 | 
			
		||||
			}
 | 
			
		||||
			return
 | 
			
		||||
	case err := <-errChan:
 | 
			
		||||
		log.Printf("Network error: %v", err)
 | 
			
		||||
		// The cleanup will happen in the deferred function
 | 
			
		||||
		default:
 | 
			
		||||
			// Read message length (4 bytes)
 | 
			
		||||
			lengthBuf := make([]byte, 4)
 | 
			
		||||
			if _, err := io.ReadFull(reader, lengthBuf); err != nil {
 | 
			
		||||
				log.Printf("Failed to read message length: %v", err)
 | 
			
		||||
				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
 | 
			
		||||
func writeMessage(conn net.Conn, msg proto.Message) error {
 | 
			
		||||
	msgHandler := NewMessageHandler(conn)
 | 
			
		||||
	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)
 | 
			
		||||
	data, err := proto.Marshal(msg)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	return &Connection{
 | 
			
		||||
		conn:     conn,
 | 
			
		||||
		playerID: playerID,
 | 
			
		||||
		quitChan: make(chan struct{}),
 | 
			
		||||
		quitDone: make(chan struct{}),
 | 
			
		||||
	}, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *Connection) Close() {
 | 
			
		||||
	c.closeOnce.Do(func() {
 | 
			
		||||
		select {
 | 
			
		||||
		case <-c.quitChan: // Check if it's already closed
 | 
			
		||||
			// Already closed, do nothing
 | 
			
		||||
		default:
 | 
			
		||||
			close(c.quitChan)
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
		// Wait with timeout for network cleanup
 | 
			
		||||
		select {
 | 
			
		||||
		case <-c.quitDone:
 | 
			
		||||
			// Clean shutdown completed
 | 
			
		||||
		case <-time.After(500 * time.Millisecond):
 | 
			
		||||
			log.Println("Network cleanup timed out")
 | 
			
		||||
	// Write length prefix
 | 
			
		||||
	lengthBuf := make([]byte, 4)
 | 
			
		||||
	binary.BigEndian.PutUint32(lengthBuf, uint32(len(data)))
 | 
			
		||||
	if _, err := conn.Write(lengthBuf); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
		// 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
 | 
			
		||||
	// Write message body
 | 
			
		||||
	_, err = conn.Write(data)
 | 
			
		||||
	return err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										120
									
								
								types/player.go
									
									
									
									
									
								
							
							
						
						
									
										120
									
								
								types/player.go
									
									
									
									
									
								
							@ -1,98 +1,16 @@
 | 
			
		||||
package types
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"sync"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	pb "gitea.boner.be/bdnugget/goonserver/actions"
 | 
			
		||||
	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) {
 | 
			
		||||
	// No need for lock here as this is called from a single thread (game loop)
 | 
			
		||||
	p.Lock()
 | 
			
		||||
	defer p.Unlock()
 | 
			
		||||
 | 
			
		||||
	targetPos := rl.Vector3{
 | 
			
		||||
		X: float32(target.X * TileSize),
 | 
			
		||||
		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)
 | 
			
		||||
 | 
			
		||||
	if distance > 1.0 {
 | 
			
		||||
		wasMoving := p.IsMoving
 | 
			
		||||
		p.IsMoving = true
 | 
			
		||||
	} else {
 | 
			
		||||
		p.IsMoving = false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Update animation if controller exists
 | 
			
		||||
	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 {
 | 
			
		||||
		if !wasMoving {
 | 
			
		||||
			p.AnimationFrame = 0
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		oldFrame := p.AnimationFrame
 | 
			
		||||
		p.AnimationFrame += int32(deltaTime * 60)
 | 
			
		||||
		rl.TraceLog(rl.LogInfo, "Walk frame update: %d -> %d (delta: %f)",
 | 
			
		||||
			oldFrame, p.AnimationFrame, deltaTime)
 | 
			
		||||
	} else {
 | 
			
		||||
		wasMoving := p.IsMoving
 | 
			
		||||
		p.IsMoving = false
 | 
			
		||||
 | 
			
		||||
		if wasMoving {
 | 
			
		||||
			p.AnimationFrame = 0
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		oldFrame := p.AnimationFrame
 | 
			
		||||
		p.AnimationFrame += int32(deltaTime * 60)
 | 
			
		||||
		}
 | 
			
		||||
		rl.TraceLog(rl.LogInfo, "Idle frame update: %d -> %d (delta: %f)",
 | 
			
		||||
			oldFrame, p.AnimationFrame, deltaTime)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if distance > 0 {
 | 
			
		||||
@ -156,16 +74,9 @@ func NewPlayer(state *pb.PlayerState) *Player {
 | 
			
		||||
		IsMoving:       false,
 | 
			
		||||
		AnimationFrame: 0,
 | 
			
		||||
		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) {
 | 
			
		||||
	p.Lock()
 | 
			
		||||
	defer p.Unlock()
 | 
			
		||||
@ -180,7 +91,6 @@ func (p *Player) UpdatePosition(state *pb.PlayerState, tickRate time.Duration) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (p *Player) ForceResync(state *pb.PlayerState) {
 | 
			
		||||
	// Keep this lock since it's called from the network goroutine
 | 
			
		||||
	p.Lock()
 | 
			
		||||
	defer p.Unlock()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
package types
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"sync"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	pb "gitea.boner.be/bdnugget/goonserver/actions"
 | 
			
		||||
@ -13,6 +14,27 @@ type Tile struct {
 | 
			
		||||
	Walkable bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Player struct {
 | 
			
		||||
	sync.Mutex
 | 
			
		||||
	PosActual             rl.Vector3
 | 
			
		||||
	PosTile               Tile
 | 
			
		||||
	TargetPath            []Tile
 | 
			
		||||
	ActionQueue           []*pb.Action
 | 
			
		||||
	Speed                 float32
 | 
			
		||||
	Model                 rl.Model
 | 
			
		||||
	Texture               rl.Texture2D
 | 
			
		||||
	ID                    int32
 | 
			
		||||
	CurrentTick           int64
 | 
			
		||||
	LastUpdateTime        time.Time
 | 
			
		||||
	LastAnimUpdate        time.Time
 | 
			
		||||
	InterpolationProgress float32
 | 
			
		||||
	UserData              interface{}
 | 
			
		||||
	FloatingMessage       *FloatingMessage
 | 
			
		||||
	QuitDone              chan struct{}
 | 
			
		||||
	AnimationFrame        int32
 | 
			
		||||
	IsMoving              bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type AnimationSet struct {
 | 
			
		||||
	Idle []rl.ModelAnimation
 | 
			
		||||
	Walk []rl.ModelAnimation
 | 
			
		||||
@ -28,7 +50,6 @@ type ModelAsset struct {
 | 
			
		||||
	AnimFrames int32               // Keep this for compatibility
 | 
			
		||||
	Animations AnimationSet        // New field for organized animations
 | 
			
		||||
	YOffset    float32             // Additional height offset (added to default 8.0)
 | 
			
		||||
	PlaceholderColor rl.Color
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ChatMessage struct {
 | 
			
		||||
@ -59,12 +80,3 @@ const (
 | 
			
		||||
	ClientTickRate = 50 * time.Millisecond
 | 
			
		||||
	MaxTickDesync  = 5
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// UI constants
 | 
			
		||||
const (
 | 
			
		||||
	ChatMargin      = 10
 | 
			
		||||
	ChatHeight      = 200
 | 
			
		||||
	MessageHeight   = 20
 | 
			
		||||
	InputHeight     = 30
 | 
			
		||||
	MaxChatMessages = 50
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user