Compare commits
25 Commits
testbuild-
...
master
Author | SHA1 | Date | |
---|---|---|---|
6e5c1844ce | |||
630877cd34 | |||
541f53c06a | |||
62a6bb2926 | |||
9d60d5e9cd | |||
b866ac879e | |||
555b8118f2 | |||
5bf962a18d | |||
220a451475 | |||
417bf4ea63 | |||
84d63ba4c1 | |||
49b84c8540 | |||
0e509ad752 | |||
bcd63efd7b | |||
944c33ce3b | |||
d5bb464d9f | |||
4549ee7517 | |||
31ae9c525f | |||
06913a5217 | |||
49663c9094 | |||
a843680b09 | |||
7183df4a8b | |||
33e355200d | |||
e45066b2a8 | |||
bb01dccf2b |
15
.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
# Build artifacts
|
||||
build/
|
||||
goonscape
|
||||
goonscape.exe
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
resources/models/old_and_test/
|
25
.woodpecker.yml
Normal file
@ -0,0 +1,25 @@
|
||||
pipeline:
|
||||
build:
|
||||
image: golang:1.23
|
||||
commands:
|
||||
# Install build dependencies
|
||||
- apt-get update && apt-get install -y gcc-mingw-w64 cmake zip libasound2-dev mesa-common-dev libx11-dev libxrandr-dev libxi-dev xorg-dev libgl1-mesa-dev libglu1-mesa-dev libwayland-dev wayland-protocols libxkbcommon-dev
|
||||
|
||||
# Build for all platforms
|
||||
- make all
|
||||
|
||||
when:
|
||||
event: tag
|
||||
tag: v*
|
||||
|
||||
# Optional: Create Gitea release with built artifacts
|
||||
release:
|
||||
image: plugins/gitea-release
|
||||
settings:
|
||||
api_key:
|
||||
from_secret: gitea_token
|
||||
base_url: https://gitea.boner.be
|
||||
files: build/*.zip
|
||||
when:
|
||||
event: tag
|
||||
tag: v*
|
24
LICENSE
@ -1,11 +1,21 @@
|
||||
“Commons Clause” License Condition v1.0
|
||||
MIT License
|
||||
|
||||
The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition.
|
||||
Copyright (c) 2025 bdnugget
|
||||
|
||||
Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, right to Sell the Software.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
For purposes of the foregoing, “Sell” means practicing any or all of the rights granted to you under the License to provide to third parties, for a fee or other consideration (including without limitation fees for hosting or consulting/ support services related to the Software), a product or service whose value derives, entirely or substantially, from the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Cause License Condition notice.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
Software: GoonScape
|
||||
License: Commons Clause v1.0
|
||||
Licensor: bdnugget
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
2
Makefile
@ -3,7 +3,7 @@
|
||||
include scripts/platforms.mk
|
||||
|
||||
BINARY_NAME=goonscape
|
||||
VERSION=1.0.0
|
||||
VERSION=1.1.0
|
||||
BUILD_DIR=build
|
||||
ASSETS_DIR=resources
|
||||
|
||||
|
55
README.md
@ -16,24 +16,57 @@ A multiplayer isometric game inspired by Oldschool RuneScape, built with Go and
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.23 or higher
|
||||
- GCC (for CGO/SQLite support)
|
||||
- OpenGL development libraries
|
||||
- Raylib dependencies (see [raylib-go](https://github.com/gen2brain/raylib-go#requirements))
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
### Pre-built Binaries
|
||||
The easiest way to get started is to download the latest release from:
|
||||
```
|
||||
https://gitea.boner.be/bdnugget/goonscape/releases
|
||||
```
|
||||
Choose the appropriate zip file for your platform:
|
||||
- Windows: `goonscape-windows-amd64-v1.1.0.zip`
|
||||
- Linux: `goonscape-linux-amd64-v1.1.0.zip`
|
||||
|
||||
Extract the zip and run the executable.
|
||||
|
||||
### Quick Start
|
||||
For development:
|
||||
```bash
|
||||
# Run directly (recommended for development)
|
||||
go run main.go
|
||||
|
||||
# Run with local server
|
||||
go run main.go -local
|
||||
```
|
||||
|
||||
### Server Setup
|
||||
The server requires CGO for SQLite support:
|
||||
```bash
|
||||
# Enable CGO
|
||||
go env -w CGO_ENABLED=1
|
||||
|
||||
# Clone and build server
|
||||
git clone https://gitea.boner.be/bdnugget/goonserver.git
|
||||
cd goonserver
|
||||
go build
|
||||
```
|
||||
|
||||
### Client Installation
|
||||
Then install or build:
|
||||
```bash
|
||||
# Install the client
|
||||
go install gitea.boner.be/bdnugget/goonscape@latest
|
||||
```
|
||||
|
||||
Or build from source:
|
||||
```bash
|
||||
git clone https://gitea.boner.be/bdnugget/goonscape.git
|
||||
cd goonscape
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
3. Build and run:
|
||||
```bash
|
||||
go run main.go
|
||||
go build
|
||||
```
|
||||
|
||||
## Controls
|
||||
|
310
assets/assets.go
@ -1,41 +1,295 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.boner.be/bdnugget/goonscape/types"
|
||||
rl "github.com/gen2brain/raylib-go/raylib"
|
||||
)
|
||||
|
||||
func LoadModels() ([]types.ModelAsset, error) {
|
||||
goonerModel := rl.LoadModel("resources/models/goonion.obj")
|
||||
goonerTexture := rl.LoadTexture("resources/models/goonion.png")
|
||||
rl.SetMaterialTexture(goonerModel.Materials, rl.MapDiffuse, goonerTexture)
|
||||
|
||||
coomerModel := rl.LoadModel("resources/models/coomer.obj")
|
||||
coomerTexture := rl.LoadTexture("resources/models/coomer.png")
|
||||
rl.SetMaterialTexture(coomerModel.Materials, rl.MapDiffuse, coomerTexture)
|
||||
|
||||
shrekeModel := rl.LoadModel("resources/models/shreke.obj")
|
||||
shrekeTexture := rl.LoadTexture("resources/models/shreke.png")
|
||||
rl.SetMaterialTexture(shrekeModel.Materials, rl.MapDiffuse, shrekeTexture)
|
||||
|
||||
return []types.ModelAsset{
|
||||
{Model: goonerModel, Texture: goonerTexture},
|
||||
{Model: coomerModel, Texture: coomerTexture},
|
||||
{Model: shrekeModel, Texture: shrekeTexture},
|
||||
}, nil
|
||||
// ModelLoader handles loading and fallback for 3D models
|
||||
type ModelLoader struct {
|
||||
safeMode bool
|
||||
}
|
||||
|
||||
func LoadMusic(filename string) (rl.Music, error) {
|
||||
return rl.LoadMusicStream(filename), nil
|
||||
}
|
||||
|
||||
func UnloadModels(models []types.ModelAsset) {
|
||||
for _, model := range models {
|
||||
rl.UnloadModel(model.Model)
|
||||
rl.UnloadTexture(model.Texture)
|
||||
// NewModelLoader creates a new model loader instance
|
||||
func NewModelLoader() *ModelLoader {
|
||||
return &ModelLoader{
|
||||
safeMode: os.Getenv("GOONSCAPE_SAFE_MODE") == "1",
|
||||
}
|
||||
}
|
||||
|
||||
func UnloadMusic(music rl.Music) {
|
||||
rl.UnloadMusicStream(music)
|
||||
// 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)
|
||||
if len(idleAnims) > 0 {
|
||||
animSet.Idle = idleAnims
|
||||
rl.TraceLog(rl.LogInfo, "Loaded idle animation: %s (%d frames, %f seconds)",
|
||||
idlePath, idleAnims[0].FrameCount, float32(idleAnims[0].FrameCount)/60.0)
|
||||
}
|
||||
}
|
||||
|
||||
// Load walk animations if specified
|
||||
if walkPath, ok := animPaths["walk"]; ok {
|
||||
walkAnims := rl.LoadModelAnimations(walkPath)
|
||||
if len(walkAnims) > 0 {
|
||||
animSet.Walk = walkAnims
|
||||
rl.TraceLog(rl.LogInfo, "Loaded walk animation: %s (%d frames, %f seconds)",
|
||||
walkPath, walkAnims[0].FrameCount, float32(walkAnims[0].FrameCount)/60.0)
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
})
|
||||
|
||||
// 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))
|
||||
goonerModel.Transform = transform
|
||||
}
|
||||
|
||||
// Always add a model (real or placeholder)
|
||||
models = append(models, 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -1,12 +0,0 @@
|
||||
# Blender 3.6.0 MTL File: 'None'
|
||||
# www.blender.org
|
||||
|
||||
newmtl Material.001
|
||||
Ns 250.000000
|
||||
Ka 1.000000 1.000000 1.000000
|
||||
Ks 0.500000 0.500000 0.500000
|
||||
Ke 0.000000 0.000000 0.000000
|
||||
Ni 1.450000
|
||||
d 1.000000
|
||||
illum 2
|
||||
map_Kd coomer.png
|
Before Width: | Height: | Size: 2.2 MiB |
@ -1,12 +0,0 @@
|
||||
# Blender 3.6.0 MTL File: 'None'
|
||||
# www.blender.org
|
||||
|
||||
newmtl Material.001
|
||||
Ns 250.000000
|
||||
Ka 1.000000 1.000000 1.000000
|
||||
Ks 0.500000 0.500000 0.500000
|
||||
Ke 0.000000 0.000000 0.000000
|
||||
Ni 1.450000
|
||||
d 1.000000
|
||||
illum 2
|
||||
map_Kd goonion.png
|
Before Width: | Height: | Size: 2.5 MiB |
@ -1,12 +0,0 @@
|
||||
# Blender 3.6.0 MTL File: 'None'
|
||||
# www.blender.org
|
||||
|
||||
newmtl Material.001
|
||||
Ns 250.000000
|
||||
Ka 1.000000 1.000000 1.000000
|
||||
Ks 0.500000 0.500000 0.500000
|
||||
Ke 0.000000 0.000000 0.000000
|
||||
Ni 1.450000
|
||||
d 1.000000
|
||||
illum 2
|
||||
map_Kd shreke.png
|
Before Width: | Height: | Size: 4.8 MiB |
Before Width: | Height: | Size: 104 KiB |
@ -1,12 +0,0 @@
|
||||
# Blender 3.6.0 MTL File: 'None'
|
||||
# www.blender.org
|
||||
|
||||
newmtl Material.001
|
||||
Ns 250.000000
|
||||
Ka 1.000000 1.000000 1.000000
|
||||
Ks 0.500000 0.500000 0.500000
|
||||
Ke 0.000000 0.000000 0.000000
|
||||
Ni 1.450000
|
||||
d 1.000000
|
||||
illum 2
|
||||
map_Kd coomer.png
|
Before Width: | Height: | Size: 2.2 MiB |
@ -1,12 +0,0 @@
|
||||
# Blender 3.6.0 MTL File: 'None'
|
||||
# www.blender.org
|
||||
|
||||
newmtl Material.001
|
||||
Ns 250.000000
|
||||
Ka 1.000000 1.000000 1.000000
|
||||
Ks 0.500000 0.500000 0.500000
|
||||
Ke 0.000000 0.000000 0.000000
|
||||
Ni 1.450000
|
||||
d 1.000000
|
||||
illum 2
|
||||
map_Kd goonion.png
|
Before Width: | Height: | Size: 2.5 MiB |
@ -1,12 +0,0 @@
|
||||
# Blender 3.6.0 MTL File: 'None'
|
||||
# www.blender.org
|
||||
|
||||
newmtl Material.001
|
||||
Ns 250.000000
|
||||
Ka 1.000000 1.000000 1.000000
|
||||
Ks 0.500000 0.500000 0.500000
|
||||
Ke 0.000000 0.000000 0.000000
|
||||
Ni 1.450000
|
||||
d 1.000000
|
||||
illum 2
|
||||
map_Kd shreke.png
|
Before Width: | Height: | Size: 4.8 MiB |
Before Width: | Height: | Size: 104 KiB |
35
constants.go
@ -2,20 +2,33 @@ 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"
|
||||
|
||||
// RuneScape-style tick rate (600ms)
|
||||
ServerTickRate = 600 * time.Millisecond
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
// UI constants
|
||||
const (
|
||||
ChatMargin = 10
|
||||
ChatHeight = 200
|
||||
MessageHeight = 20
|
||||
InputHeight = 30
|
||||
MaxMessages = 50
|
||||
)
|
||||
|
||||
// Environment variable names
|
||||
const (
|
||||
EnvSafeMode = "GOONSCAPE_SAFE_MODE"
|
||||
EnvDisableAnimations = "GOONSCAPE_DISABLE_ANIMATIONS"
|
||||
EnvDisableAudio = "GOONSCAPE_DISABLE_AUDIO"
|
||||
)
|
||||
|
@ -10,6 +10,7 @@ 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) {
|
||||
@ -32,6 +33,34 @@ 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
|
||||
}
|
||||
|
105
game/chat.go
@ -2,6 +2,8 @@ package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.boner.be/bdnugget/goonscape/types"
|
||||
@ -9,13 +11,9 @@ 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
|
||||
runeLimit = 256
|
||||
)
|
||||
|
||||
type Chat struct {
|
||||
@ -25,11 +23,12 @@ type Chat struct {
|
||||
cursorPos int
|
||||
scrollOffset int
|
||||
userData interface{}
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
func NewChat() *Chat {
|
||||
return &Chat{
|
||||
messages: make([]types.ChatMessage, 0, maxMessages),
|
||||
messages: make([]types.ChatMessage, 0, types.MaxChatMessages),
|
||||
inputBuffer: make([]rune, 0, runeLimit),
|
||||
}
|
||||
}
|
||||
@ -41,7 +40,7 @@ func (c *Chat) AddMessage(playerID int32, content string) {
|
||||
Time: time.Now(),
|
||||
}
|
||||
|
||||
if len(c.messages) >= maxMessages {
|
||||
if len(c.messages) >= types.MaxChatMessages {
|
||||
c.messages = c.messages[1:]
|
||||
}
|
||||
c.messages = append(c.messages, msg)
|
||||
@ -49,43 +48,80 @@ 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,
|
||||
Content: msg.Content,
|
||||
Time: time.Unix(0, msg.Timestamp),
|
||||
}
|
||||
|
||||
// Only add if it's not already in our history
|
||||
if len(c.messages) == 0 || c.messages[len(c.messages)-1].Time.UnixNano() < msg.Timestamp {
|
||||
if len(c.messages) >= maxMessages {
|
||||
if len(c.messages) >= types.MaxChatMessages {
|
||||
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((chatHeight - inputHeight) / messageHeight)
|
||||
visibleMessages := int((types.ChatHeight - types.InputHeight) / types.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 {
|
||||
if msg.PlayerId == game.Player.ID {
|
||||
game.Player.Lock()
|
||||
game.Player.FloatingMessage = &types.FloatingMessage{
|
||||
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{
|
||||
Content: msg.Content,
|
||||
ExpireTime: time.Now().Add(6 * time.Second),
|
||||
}
|
||||
game.Player.Unlock()
|
||||
} else if otherPlayer, exists := game.OtherPlayers[msg.PlayerId]; exists {
|
||||
otherPlayer.Lock()
|
||||
otherPlayer.FloatingMessage = &types.FloatingMessage{
|
||||
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{
|
||||
Content: msg.Content,
|
||||
ExpireTime: time.Now().Add(6 * time.Second),
|
||||
}
|
||||
otherPlayer.Unlock()
|
||||
player.Unlock()
|
||||
log.Printf("Added floating message to other player %d", msg.PlayerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -93,17 +129,20 @@ func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) {
|
||||
}
|
||||
|
||||
func (c *Chat) Draw(screenWidth, screenHeight int32) {
|
||||
c.mutex.RLock()
|
||||
defer c.mutex.RUnlock()
|
||||
|
||||
// Calculate chat window width based on screen width
|
||||
chatWindowWidth := screenWidth - (chatMargin * 2)
|
||||
chatWindowWidth := screenWidth - (types.ChatMargin * 2)
|
||||
|
||||
// Draw chat window background
|
||||
chatX := float32(chatMargin)
|
||||
chatY := float32(screenHeight - chatHeight - chatMargin)
|
||||
rl.DrawRectangle(int32(chatX), int32(chatY), chatWindowWidth, chatHeight, rl.ColorAlpha(rl.Black, 0.5))
|
||||
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))
|
||||
|
||||
// Draw messages from oldest to newest
|
||||
messageY := chatY + 5
|
||||
visibleMessages := int((chatHeight - inputHeight) / messageHeight)
|
||||
visibleMessages := int((types.ChatHeight - types.InputHeight) / types.MessageHeight)
|
||||
|
||||
// Auto-scroll to bottom if no manual scrolling has occurred
|
||||
if c.scrollOffset == 0 {
|
||||
@ -117,14 +156,20 @@ func (c *Chat) Draw(screenWidth, screenHeight int32) {
|
||||
|
||||
for i := startIdx; i < endIdx; i++ {
|
||||
msg := c.messages[i]
|
||||
text := fmt.Sprintf("[%d]: %s", msg.PlayerID, msg.Content)
|
||||
rl.DrawText(text, int32(chatX)+5, int32(messageY), 20, rl.White)
|
||||
messageY += messageHeight
|
||||
var color rl.Color
|
||||
if msg.PlayerID == 0 { // System message
|
||||
color = rl.Gold
|
||||
} else {
|
||||
color = rl.White
|
||||
}
|
||||
text := fmt.Sprintf("%s: %s", msg.Username, msg.Content)
|
||||
rl.DrawText(text, int32(chatX)+5, int32(messageY), 20, color)
|
||||
messageY += types.MessageHeight
|
||||
}
|
||||
|
||||
// Draw input field
|
||||
inputY := chatY + float32(chatHeight-inputHeight)
|
||||
rl.DrawRectangle(int32(chatX), int32(inputY), chatWindowWidth, inputHeight, rl.ColorAlpha(rl.White, 0.3))
|
||||
inputY := chatY + float32(types.ChatHeight-types.InputHeight)
|
||||
rl.DrawRectangle(int32(chatX), int32(inputY), chatWindowWidth, types.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)
|
||||
@ -142,7 +187,7 @@ func (c *Chat) Update() (string, bool) {
|
||||
if !c.isTyping {
|
||||
wheelMove := rl.GetMouseWheelMove()
|
||||
if wheelMove != 0 {
|
||||
maxScroll := max(0, len(c.messages)-int((chatHeight-inputHeight)/messageHeight))
|
||||
maxScroll := max(0, len(c.messages)-int((types.ChatHeight-types.InputHeight)/types.MessageHeight))
|
||||
c.scrollOffset = clamp(c.scrollOffset-int(wheelMove), 0, maxScroll)
|
||||
}
|
||||
|
||||
|
107
game/components.go
Normal file
@ -0,0 +1,107 @@
|
||||
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)
|
||||
}
|
||||
}
|
489
game/game.go
@ -1,103 +1,191 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"os"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.boner.be/bdnugget/goonscape/assets"
|
||||
"gitea.boner.be/bdnugget/goonscape/network"
|
||||
"gitea.boner.be/bdnugget/goonscape/types"
|
||||
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
||||
rl "github.com/gen2brain/raylib-go/raylib"
|
||||
)
|
||||
|
||||
type Game struct {
|
||||
Player *types.Player
|
||||
OtherPlayers map[int32]*types.Player
|
||||
// Component-based architecture
|
||||
PlayerManager *PlayerManager
|
||||
AssetManager *AssetManager
|
||||
UIManager *UIManager
|
||||
|
||||
// Core game state
|
||||
Camera rl.Camera3D
|
||||
Models []types.ModelAsset
|
||||
Music rl.Music
|
||||
Chat *Chat
|
||||
MenuOpen bool
|
||||
QuitChan chan struct{} // Channel to signal shutdown
|
||||
quitChan chan struct{}
|
||||
cleanupOnce sync.Once
|
||||
frameCounter int // For periodic logging
|
||||
}
|
||||
|
||||
func New() *Game {
|
||||
InitWorld()
|
||||
game := &Game{
|
||||
Player: &types.Player{
|
||||
PosActual: rl.NewVector3(5*types.TileSize, 0, 5*types.TileSize),
|
||||
PosTile: GetTile(5, 5),
|
||||
Speed: 50.0,
|
||||
TargetPath: []types.Tile{},
|
||||
UserData: nil,
|
||||
QuitDone: make(chan struct{}),
|
||||
},
|
||||
OtherPlayers: make(map[int32]*types.Player),
|
||||
// Create managers
|
||||
playerManager := NewPlayerManager()
|
||||
assetManager := NewAssetManager()
|
||||
uiManager := NewUIManager()
|
||||
|
||||
g := &Game{
|
||||
PlayerManager: playerManager,
|
||||
AssetManager: assetManager,
|
||||
UIManager: uiManager,
|
||||
Camera: rl.Camera3D{
|
||||
Position: rl.NewVector3(0, 10, 10),
|
||||
Target: rl.NewVector3(0, 0, 0),
|
||||
Up: rl.NewVector3(0, 1, 0),
|
||||
Position: rl.NewVector3(0.0, 20.0, 0.0),
|
||||
Target: rl.NewVector3(0.0, 0.0, 0.0),
|
||||
Up: rl.NewVector3(0.0, 1.0, 0.0),
|
||||
Fovy: 45.0,
|
||||
Projection: rl.CameraPerspective,
|
||||
},
|
||||
Chat: NewChat(),
|
||||
QuitChan: make(chan struct{}),
|
||||
quitChan: make(chan struct{}),
|
||||
}
|
||||
game.Player.UserData = game
|
||||
game.Chat.userData = game
|
||||
return game
|
||||
|
||||
// Set up inter-component references
|
||||
g.UIManager.Chat.userData = g // Pass game instance to chat for callbacks
|
||||
|
||||
// Initialize world
|
||||
InitWorld()
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *Game) LoadAssets() error {
|
||||
var err error
|
||||
g.Models, err = assets.LoadModels()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return SafeExecute(func() error {
|
||||
// Load models
|
||||
var err error
|
||||
models, err := assets.LoadModels()
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load models: %v", err)
|
||||
}
|
||||
g.AssetManager.Models = models
|
||||
|
||||
g.Music, err = assets.LoadMusic("resources/audio/GoonScape2.mp3")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Try to load music
|
||||
music, err := assets.LoadMusic("resources/audio/GoonScape1.mp3")
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load music: %v", err)
|
||||
} else {
|
||||
g.AssetManager.Music = music
|
||||
}
|
||||
|
||||
return nil
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (g *Game) Update(deltaTime float32) {
|
||||
// Handle login screen if not logged in
|
||||
if !g.UIManager.IsLoggedIn {
|
||||
// Handle login
|
||||
username, password, isRegistering, doAuth := g.UIManager.LoginScreen.Update()
|
||||
|
||||
if doAuth {
|
||||
conn, playerID, err := network.ConnectToServer(username, password, isRegistering)
|
||||
if err != nil {
|
||||
g.UIManager.LoginScreen.SetError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
g.PlayerManager.LocalPlayer = &types.Player{
|
||||
Speed: 50.0,
|
||||
TargetPath: []types.Tile{},
|
||||
ActionQueue: []*pb.Action{},
|
||||
QuitDone: make(chan struct{}),
|
||||
ID: playerID,
|
||||
}
|
||||
g.AssignModelToPlayer(g.PlayerManager.LocalPlayer)
|
||||
|
||||
// Set user data to allow chat message handling
|
||||
g.PlayerManager.LocalPlayer.UserData = g
|
||||
|
||||
go network.HandleServerCommunication(conn, playerID, g.PlayerManager.LocalPlayer, g.PlayerManager.OtherPlayers, g.quitChan)
|
||||
g.UIManager.IsLoggedIn = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Skip update logic if player is not initialized yet
|
||||
if g.PlayerManager.LocalPlayer == nil {
|
||||
log.Printf("Warning: LocalPlayer is nil during update, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle ESC for menu
|
||||
if rl.IsKeyPressed(rl.KeyEscape) {
|
||||
g.MenuOpen = !g.MenuOpen
|
||||
g.UIManager.MenuOpen = !g.UIManager.MenuOpen
|
||||
return
|
||||
}
|
||||
|
||||
// Don't process other inputs if menu is open
|
||||
if g.MenuOpen {
|
||||
if g.UIManager.MenuOpen {
|
||||
return
|
||||
}
|
||||
|
||||
if message, sent := g.Chat.Update(); sent {
|
||||
g.Player.Lock()
|
||||
g.Player.ActionQueue = append(g.Player.ActionQueue, &pb.Action{
|
||||
// Handle chat updates
|
||||
if message, sent := g.UIManager.Chat.Update(); sent {
|
||||
g.PlayerManager.LocalPlayer.Lock()
|
||||
g.PlayerManager.LocalPlayer.ActionQueue = append(g.PlayerManager.LocalPlayer.ActionQueue, &pb.Action{
|
||||
Type: pb.Action_CHAT,
|
||||
ChatMessage: message,
|
||||
PlayerId: g.Player.ID,
|
||||
PlayerId: g.PlayerManager.LocalPlayer.ID,
|
||||
})
|
||||
g.Player.Unlock()
|
||||
g.PlayerManager.LocalPlayer.Unlock()
|
||||
}
|
||||
|
||||
// Process player input
|
||||
g.HandleInput()
|
||||
|
||||
if len(g.Player.TargetPath) > 0 {
|
||||
g.Player.MoveTowards(g.Player.TargetPath[0], deltaTime, GetMapGrid())
|
||||
// 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())
|
||||
}
|
||||
|
||||
for _, other := range g.OtherPlayers {
|
||||
if len(other.TargetPath) > 0 {
|
||||
other.MoveTowards(other.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UpdateCamera(&g.Camera, g.Player.PosActual, deltaTime)
|
||||
// Process other players
|
||||
for _, other := range g.PlayerManager.OtherPlayers {
|
||||
if other == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if other.TargetPath != nil && len(other.TargetPath) > 0 {
|
||||
target := other.TargetPath[0]
|
||||
other.MoveTowards(target, deltaTime, GetMapGrid())
|
||||
}
|
||||
|
||||
// Assign model if needed
|
||||
if other.Model.Meshes == nil {
|
||||
g.AssignModelToPlayer(other)
|
||||
}
|
||||
}
|
||||
|
||||
// Update camera position
|
||||
UpdateCamera(&g.Camera, g.PlayerManager.LocalPlayer.PosActual, deltaTime)
|
||||
|
||||
// Update music if available
|
||||
if g.AssetManager.Music.Stream.Buffer != nil {
|
||||
rl.UpdateMusicStream(g.AssetManager.Music)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Game) DrawMap() {
|
||||
@ -129,121 +217,169 @@ func (g *Game) DrawMap() {
|
||||
}
|
||||
|
||||
func (g *Game) DrawPlayer(player *types.Player, model rl.Model) {
|
||||
player.Lock()
|
||||
defer player.Unlock()
|
||||
if player == nil {
|
||||
return
|
||||
}
|
||||
|
||||
grid := GetMapGrid()
|
||||
// Get necessary data
|
||||
modelIndex := int(player.ID) % len(g.AssetManager.Models)
|
||||
if modelIndex < 0 || modelIndex >= len(g.AssetManager.Models) {
|
||||
modelIndex = 0
|
||||
}
|
||||
modelAsset := g.AssetManager.Models[modelIndex]
|
||||
|
||||
// Calculate position
|
||||
const defaultHeight = 8.0
|
||||
playerPos := rl.Vector3{
|
||||
X: player.PosActual.X,
|
||||
Y: grid[player.PosTile.X][player.PosTile.Y].Height*types.TileHeight + 16.0,
|
||||
Y: player.PosActual.Y + defaultHeight + modelAsset.YOffset,
|
||||
Z: player.PosActual.Z,
|
||||
}
|
||||
|
||||
rl.DrawModel(model, playerPos, 16, rl.White)
|
||||
|
||||
if player.FloatingMessage != nil {
|
||||
screenPos := rl.GetWorldToScreen(rl.Vector3{
|
||||
X: playerPos.X,
|
||||
Y: playerPos.Y + 24.0,
|
||||
Z: playerPos.Z,
|
||||
}, g.Camera)
|
||||
|
||||
player.FloatingMessage.ScreenPos = screenPos
|
||||
// Simple drawing with scale parameter
|
||||
var drawColor rl.Color = rl.White
|
||||
if player.PlaceholderColor.A > 0 {
|
||||
drawColor = player.PlaceholderColor
|
||||
}
|
||||
|
||||
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 the model at normal scale (16.0)
|
||||
rl.DrawModel(model, playerPos, 16.0, drawColor)
|
||||
|
||||
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),
|
||||
// Update floating message position
|
||||
if player.FloatingMessage != nil {
|
||||
worldPos := rl.Vector3{
|
||||
X: playerPos.X,
|
||||
Y: playerPos.Y + 24.0, // Position above head
|
||||
Z: playerPos.Z,
|
||||
}
|
||||
player.FloatingMessage.ScreenPos = rl.GetWorldToScreen(worldPos, g.Camera)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Game) DrawFloatingMessages() {
|
||||
var drawFloatingMessage = func(msg *types.FloatingMessage) {
|
||||
if msg == nil || time.Now().After(msg.ExpireTime) {
|
||||
return
|
||||
}
|
||||
|
||||
// Draw the message with RuneScape-style coloring (black outline with yellow text)
|
||||
text := msg.Content
|
||||
textWidth := rl.MeasureText(text, 20)
|
||||
|
||||
// Draw black outline by offsetting the text slightly in all directions
|
||||
for offsetX := -2; offsetX <= 2; offsetX++ {
|
||||
for offsetY := -2; offsetY <= 2; offsetY++ {
|
||||
rl.DrawText(text,
|
||||
int32(msg.ScreenPos.X)-textWidth/2+int32(offsetX),
|
||||
int32(msg.ScreenPos.Y)+int32(offsetY),
|
||||
20,
|
||||
rl.Black)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw the yellow text on top
|
||||
rl.DrawText(text, int32(msg.ScreenPos.X)-textWidth/2, int32(msg.ScreenPos.Y), 20, rl.Yellow)
|
||||
}
|
||||
|
||||
if g.PlayerManager.LocalPlayer != nil && g.PlayerManager.LocalPlayer.FloatingMessage != nil {
|
||||
drawFloatingMessage(g.PlayerManager.LocalPlayer.FloatingMessage)
|
||||
}
|
||||
|
||||
for _, other := range g.PlayerManager.OtherPlayers {
|
||||
if other != nil && other.FloatingMessage != nil {
|
||||
drawFloatingMessage(other.FloatingMessage)
|
||||
}
|
||||
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()
|
||||
return
|
||||
}
|
||||
|
||||
// Draw 3D elements
|
||||
rl.BeginMode3D(g.Camera)
|
||||
g.DrawMap()
|
||||
g.DrawPlayer(g.Player, g.Player.Model)
|
||||
for id, other := range g.OtherPlayers {
|
||||
g.DrawPlayer(other, g.Models[int(id)%len(g.Models)].Model)
|
||||
|
||||
// Draw player only if valid
|
||||
if g.PlayerManager.LocalPlayer != nil && g.PlayerManager.LocalPlayer.Model.Meshes != nil {
|
||||
g.DrawPlayer(g.PlayerManager.LocalPlayer, g.PlayerManager.LocalPlayer.Model)
|
||||
}
|
||||
|
||||
// Draw other players with defensive checks
|
||||
for _, other := range g.PlayerManager.OtherPlayers {
|
||||
if other == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if other.Model.Meshes != nil {
|
||||
g.DrawPlayer(other, other.Model)
|
||||
}
|
||||
}
|
||||
rl.EndMode3D()
|
||||
|
||||
// Draw floating messages
|
||||
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 floating messages with RuneScape style
|
||||
g.DrawFloatingMessages()
|
||||
|
||||
// Draw menu if open
|
||||
if g.MenuOpen {
|
||||
if g.UIManager.MenuOpen {
|
||||
g.DrawMenu()
|
||||
}
|
||||
|
||||
// Only draw chat if menu is not open
|
||||
if !g.MenuOpen {
|
||||
g.Chat.Draw(int32(rl.GetScreenWidth()), int32(rl.GetScreenHeight()))
|
||||
if !g.UIManager.MenuOpen && g.UIManager.Chat != nil {
|
||||
g.UIManager.Chat.Draw(int32(rl.GetScreenWidth()), int32(rl.GetScreenHeight()))
|
||||
}
|
||||
|
||||
// Draw FPS counter
|
||||
rl.DrawFPS(10, 10)
|
||||
rl.EndDrawing()
|
||||
}
|
||||
|
||||
func (g *Game) Cleanup() {
|
||||
assets.UnloadModels(g.Models)
|
||||
assets.UnloadMusic(g.Music)
|
||||
g.cleanupOnce.Do(func() {
|
||||
// Cleanup models
|
||||
for _, model := range g.AssetManager.Models {
|
||||
rl.UnloadModel(model.Model)
|
||||
if model.Texture.ID > 0 {
|
||||
rl.UnloadTexture(model.Texture)
|
||||
}
|
||||
}
|
||||
|
||||
// Unload music
|
||||
if g.AssetManager.Music.Stream.Buffer != nil {
|
||||
rl.UnloadMusicStream(g.AssetManager.Music)
|
||||
}
|
||||
|
||||
// Only close the channel if it hasn't been closed yet
|
||||
select {
|
||||
case <-g.quitChan:
|
||||
// Channel already closed, do nothing
|
||||
default:
|
||||
close(g.quitChan)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (g *Game) HandleInput() {
|
||||
clickedTile, clicked := g.GetTileAtMouse()
|
||||
if clicked {
|
||||
path := FindPath(GetTile(g.Player.PosTile.X, g.Player.PosTile.Y), clickedTile)
|
||||
path := FindPath(GetTile(g.PlayerManager.LocalPlayer.PosTile.X, g.PlayerManager.LocalPlayer.PosTile.Y), clickedTile)
|
||||
if len(path) > 1 {
|
||||
g.Player.Lock()
|
||||
g.Player.TargetPath = path[1:]
|
||||
g.Player.ActionQueue = append(g.Player.ActionQueue, &pb.Action{
|
||||
g.PlayerManager.LocalPlayer.Lock()
|
||||
g.PlayerManager.LocalPlayer.TargetPath = path[1:]
|
||||
g.PlayerManager.LocalPlayer.ActionQueue = append(g.PlayerManager.LocalPlayer.ActionQueue, &pb.Action{
|
||||
Type: pb.Action_MOVE,
|
||||
X: int32(clickedTile.X),
|
||||
Y: int32(clickedTile.Y),
|
||||
PlayerId: g.Player.ID,
|
||||
PlayerId: g.PlayerManager.LocalPlayer.ID,
|
||||
})
|
||||
g.Player.Unlock()
|
||||
g.PlayerManager.LocalPlayer.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -252,63 +388,80 @@ func (g *Game) DrawMenu() {
|
||||
screenWidth := float32(rl.GetScreenWidth())
|
||||
screenHeight := float32(rl.GetScreenHeight())
|
||||
|
||||
// Semi-transparent background
|
||||
// Draw semi-transparent background
|
||||
rl.DrawRectangle(0, 0, int32(screenWidth), int32(screenHeight), rl.ColorAlpha(rl.Black, 0.7))
|
||||
|
||||
// Menu title
|
||||
title := "Menu"
|
||||
titleSize := int32(40)
|
||||
titleWidth := rl.MeasureText(title, titleSize)
|
||||
rl.DrawText(title, int32(screenWidth/2)-titleWidth/2, 100, titleSize, rl.White)
|
||||
// Draw menu items
|
||||
menuItems := []string{"Resume", "Settings", "Quit"}
|
||||
menuY := screenHeight/2 - float32(len(menuItems)*40)/2
|
||||
|
||||
// Menu buttons
|
||||
buttonWidth := float32(200)
|
||||
buttonHeight := float32(40)
|
||||
buttonY := float32(200)
|
||||
buttonSpacing := float32(60)
|
||||
|
||||
menuItems := []string{"Resume", "Settings", "Exit Game"}
|
||||
for _, item := range menuItems {
|
||||
buttonRect := rl.Rectangle{
|
||||
X: screenWidth/2 - buttonWidth/2,
|
||||
Y: buttonY,
|
||||
Width: buttonWidth,
|
||||
Height: buttonHeight,
|
||||
}
|
||||
|
||||
// Check mouse hover
|
||||
for i, item := range menuItems {
|
||||
itemY := menuY + float32(i*40)
|
||||
mousePoint := rl.GetMousePosition()
|
||||
mouseHover := rl.CheckCollisionPointRec(mousePoint, buttonRect)
|
||||
itemRect := rl.Rectangle{X: screenWidth/2 - 100, Y: itemY, Width: 200, Height: 36}
|
||||
|
||||
// Draw button
|
||||
if mouseHover {
|
||||
rl.DrawRectangleRec(buttonRect, rl.ColorAlpha(rl.White, 0.3))
|
||||
if rl.IsMouseButtonPressed(rl.MouseLeftButton) {
|
||||
switch item {
|
||||
case "Resume":
|
||||
g.MenuOpen = false
|
||||
case "Settings":
|
||||
// TODO: Implement settings
|
||||
case "Exit Game":
|
||||
g.Shutdown()
|
||||
}
|
||||
}
|
||||
// Check for hover
|
||||
isHover := rl.CheckCollisionPointRec(mousePoint, itemRect)
|
||||
|
||||
// Draw button background
|
||||
if isHover {
|
||||
rl.DrawRectangleRec(itemRect, rl.ColorAlpha(rl.White, 0.3))
|
||||
} else {
|
||||
rl.DrawRectangleRec(itemRect, rl.ColorAlpha(rl.White, 0.1))
|
||||
}
|
||||
|
||||
// Draw button text
|
||||
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)
|
||||
textWidth := rl.MeasureText(item, 20)
|
||||
rl.DrawText(item, int32(itemRect.X+(itemRect.Width-float32(textWidth))/2), int32(itemRect.Y+8), 20, rl.White)
|
||||
|
||||
buttonY += buttonSpacing
|
||||
// Handle click
|
||||
if isHover && rl.IsMouseButtonReleased(rl.MouseLeftButton) {
|
||||
switch item {
|
||||
case "Resume":
|
||||
g.UIManager.MenuOpen = false
|
||||
case "Settings":
|
||||
// TODO: Implement settings
|
||||
case "Quit":
|
||||
g.Shutdown()
|
||||
rl.CloseWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Game) Shutdown() {
|
||||
close(g.QuitChan)
|
||||
<-g.Player.QuitDone
|
||||
rl.CloseWindow()
|
||||
os.Exit(0)
|
||||
func (g *Game) HandleServerMessages(messages []*pb.ChatMessage) {
|
||||
// Check if Chat is properly initialized
|
||||
if g.UIManager != nil && g.UIManager.Chat != nil {
|
||||
g.UIManager.Chat.HandleServerMessages(messages)
|
||||
} else {
|
||||
log.Printf("Warning: Cannot handle server messages, Chat is not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Game) QuitChan() <-chan struct{} {
|
||||
return g.quitChan
|
||||
}
|
||||
|
||||
func (g *Game) Shutdown() {
|
||||
// Use the cleanup method which has channel-closing safety
|
||||
g.Cleanup()
|
||||
}
|
||||
|
||||
func (g *Game) AssignModelToPlayer(player *types.Player) {
|
||||
if player == nil {
|
||||
return
|
||||
}
|
||||
|
||||
modelAsset, found := g.AssetManager.GetModelForPlayer(player.ID)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
|
||||
player.Model = modelAsset.Model
|
||||
player.PlaceholderColor = modelAsset.PlaceholderColor
|
||||
|
||||
// Initialize animations if available
|
||||
if len(modelAsset.Animations.Idle) > 0 || len(modelAsset.Animations.Walk) > 0 {
|
||||
player.InitializeAnimations(modelAsset.Animations)
|
||||
}
|
||||
}
|
||||
|
185
game/login.go
Normal file
@ -0,0 +1,185 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
rl "github.com/gen2brain/raylib-go/raylib"
|
||||
)
|
||||
|
||||
type LoginScreen struct {
|
||||
username string
|
||||
password string
|
||||
errorMessage string
|
||||
isRegistering bool
|
||||
focusedField int // 0 = username, 1 = password
|
||||
}
|
||||
|
||||
func NewLoginScreen() *LoginScreen {
|
||||
return &LoginScreen{}
|
||||
}
|
||||
|
||||
func (l *LoginScreen) Draw() {
|
||||
screenWidth := float32(rl.GetScreenWidth())
|
||||
screenHeight := float32(rl.GetScreenHeight())
|
||||
|
||||
// Draw background
|
||||
rl.DrawRectangle(0, 0, int32(screenWidth), int32(screenHeight), rl.RayWhite)
|
||||
|
||||
// Draw title
|
||||
title := "GoonScape"
|
||||
if l.isRegistering {
|
||||
title += " - Register"
|
||||
} else {
|
||||
title += " - Login"
|
||||
}
|
||||
titleSize := int32(40)
|
||||
titleWidth := rl.MeasureText(title, titleSize)
|
||||
rl.DrawText(title, int32(screenWidth/2)-titleWidth/2, 100, titleSize, rl.Black)
|
||||
|
||||
// Draw input fields
|
||||
inputWidth := float32(200)
|
||||
inputHeight := float32(30)
|
||||
inputX := screenWidth/2 - inputWidth/2
|
||||
|
||||
// Username field
|
||||
rl.DrawRectangleRec(rl.Rectangle{
|
||||
X: inputX, Y: 200,
|
||||
Width: inputWidth, Height: inputHeight,
|
||||
}, rl.LightGray)
|
||||
rl.DrawText(l.username, int32(inputX)+5, 205, 20, rl.Black)
|
||||
if l.focusedField == 0 {
|
||||
rl.DrawRectangleLinesEx(rl.Rectangle{
|
||||
X: inputX - 2, Y: 198,
|
||||
Width: inputWidth + 4, Height: inputHeight + 4,
|
||||
}, 2, rl.Blue)
|
||||
}
|
||||
|
||||
// Password field
|
||||
rl.DrawRectangleRec(rl.Rectangle{
|
||||
X: inputX, Y: 250,
|
||||
Width: inputWidth, Height: inputHeight,
|
||||
}, rl.LightGray)
|
||||
masked := ""
|
||||
for range l.password {
|
||||
masked += "*"
|
||||
}
|
||||
rl.DrawText(masked, int32(inputX)+5, 255, 20, rl.Black)
|
||||
if l.focusedField == 1 {
|
||||
rl.DrawRectangleLinesEx(rl.Rectangle{
|
||||
X: inputX - 2, Y: 248,
|
||||
Width: inputWidth + 4, Height: inputHeight + 4,
|
||||
}, 2, rl.Blue)
|
||||
}
|
||||
|
||||
// Draw error message
|
||||
if l.errorMessage != "" {
|
||||
msgWidth := rl.MeasureText(l.errorMessage, 20)
|
||||
rl.DrawText(l.errorMessage, int32(screenWidth/2)-msgWidth/2, 300, 20, rl.Red)
|
||||
}
|
||||
|
||||
// Draw buttons
|
||||
buttonWidth := float32(100)
|
||||
buttonHeight := float32(30)
|
||||
buttonY := float32(350)
|
||||
|
||||
// Login/Register button
|
||||
actionBtn := rl.Rectangle{
|
||||
X: screenWidth/2 - buttonWidth - 10,
|
||||
Y: buttonY,
|
||||
Width: buttonWidth,
|
||||
Height: buttonHeight,
|
||||
}
|
||||
rl.DrawRectangleRec(actionBtn, rl.Blue)
|
||||
actionText := "Login"
|
||||
if l.isRegistering {
|
||||
actionText = "Register"
|
||||
}
|
||||
actionWidth := rl.MeasureText(actionText, 20)
|
||||
rl.DrawText(actionText,
|
||||
int32(actionBtn.X+actionBtn.Width/2)-actionWidth/2,
|
||||
int32(actionBtn.Y+5),
|
||||
20, rl.White)
|
||||
|
||||
// Switch mode button
|
||||
switchBtn := rl.Rectangle{
|
||||
X: screenWidth/2 + 10,
|
||||
Y: buttonY,
|
||||
Width: buttonWidth,
|
||||
Height: buttonHeight,
|
||||
}
|
||||
rl.DrawRectangleRec(switchBtn, rl.DarkGray)
|
||||
switchText := "Register"
|
||||
if l.isRegistering {
|
||||
switchText = "Login"
|
||||
}
|
||||
switchWidth := rl.MeasureText(switchText, 20)
|
||||
rl.DrawText(switchText,
|
||||
int32(switchBtn.X+switchBtn.Width/2)-switchWidth/2,
|
||||
int32(switchBtn.Y+5),
|
||||
20, rl.White)
|
||||
}
|
||||
|
||||
func (l *LoginScreen) Update() (string, string, bool, bool) {
|
||||
// Handle input field focus
|
||||
if rl.IsMouseButtonPressed(rl.MouseLeftButton) {
|
||||
mousePos := rl.GetMousePosition()
|
||||
screenWidth := float32(rl.GetScreenWidth())
|
||||
inputWidth := float32(200)
|
||||
inputX := screenWidth/2 - inputWidth/2
|
||||
|
||||
// Check username field
|
||||
if mousePos.X >= inputX && mousePos.X <= inputX+inputWidth &&
|
||||
mousePos.Y >= 200 && mousePos.Y <= 230 {
|
||||
l.focusedField = 0
|
||||
}
|
||||
// Check password field
|
||||
if mousePos.X >= inputX && mousePos.X <= inputX+inputWidth &&
|
||||
mousePos.Y >= 250 && mousePos.Y <= 280 {
|
||||
l.focusedField = 1
|
||||
}
|
||||
|
||||
// Check buttons
|
||||
buttonWidth := float32(100)
|
||||
if mousePos.Y >= 350 && mousePos.Y <= 380 {
|
||||
// Action button
|
||||
if mousePos.X >= screenWidth/2-buttonWidth-10 &&
|
||||
mousePos.X <= screenWidth/2-10 {
|
||||
return l.username, l.password, l.isRegistering, true
|
||||
}
|
||||
// Switch mode button
|
||||
if mousePos.X >= screenWidth/2+10 &&
|
||||
mousePos.X <= screenWidth/2+buttonWidth+10 {
|
||||
l.isRegistering = !l.isRegistering
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle text input
|
||||
key := rl.GetCharPressed()
|
||||
for key > 0 {
|
||||
if l.focusedField == 0 && len(l.username) < 12 {
|
||||
l.username += string(key)
|
||||
} else if l.focusedField == 1 && len(l.password) < 20 {
|
||||
l.password += string(key)
|
||||
}
|
||||
key = rl.GetCharPressed()
|
||||
}
|
||||
|
||||
// Handle backspace
|
||||
if rl.IsKeyPressed(rl.KeyBackspace) {
|
||||
if l.focusedField == 0 && len(l.username) > 0 {
|
||||
l.username = l.username[:len(l.username)-1]
|
||||
} else if l.focusedField == 1 && len(l.password) > 0 {
|
||||
l.password = l.password[:len(l.password)-1]
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tab to switch fields
|
||||
if rl.IsKeyPressed(rl.KeyTab) {
|
||||
l.focusedField = (l.focusedField + 1) % 2
|
||||
}
|
||||
|
||||
return "", "", false, false
|
||||
}
|
||||
|
||||
func (l *LoginScreen) SetError(msg string) {
|
||||
l.errorMessage = msg
|
||||
}
|
@ -1,91 +1,157 @@
|
||||
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, H, F float32 // G = cost from start, H = heuristic to goal, F = G + H
|
||||
}
|
||||
|
||||
// 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 {
|
||||
openList := []*Node{}
|
||||
closedList := make(map[[2]int]bool)
|
||||
// Initialize open and closed sets
|
||||
openSet := &PriorityQueue{}
|
||||
heap.Init(openSet)
|
||||
|
||||
startNode := &Node{Tile: start, G: 0, H: heuristic(start, end)}
|
||||
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.F = startNode.G + startNode.H
|
||||
openList = append(openList, startNode)
|
||||
heap.Push(openSet, startNode)
|
||||
|
||||
for len(openList) > 0 {
|
||||
current := openList[0]
|
||||
currentIndex := 0
|
||||
for i, node := range openList {
|
||||
if node.F < current.F {
|
||||
current = node
|
||||
currentIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
openList = append(openList[:currentIndex], openList[currentIndex+1:]...)
|
||||
closedList[[2]int{current.Tile.X, current.Tile.Y}] = true
|
||||
// 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 {
|
||||
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
|
||||
return reconstructPath(current)
|
||||
}
|
||||
|
||||
neighbors := GetNeighbors(current.Tile)
|
||||
for _, neighbor := range neighbors {
|
||||
if !neighbor.Walkable || closedList[[2]int{neighbor.X, neighbor.Y}] {
|
||||
// Add current to closed set
|
||||
closedSet[[2]int{current.Tile.X, current.Tile.Y}] = true
|
||||
|
||||
// Check all neighbors
|
||||
for _, neighbor := range GetNeighbors(current.Tile) {
|
||||
// Skip if in closed set or not walkable
|
||||
if !neighbor.Walkable || closedSet[[2]int{neighbor.X, neighbor.Y}] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate tentative G score
|
||||
tentativeG := current.G + distance(current.Tile, neighbor)
|
||||
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 {
|
||||
newNode := &Node{
|
||||
Tile: neighbor,
|
||||
Parent: current,
|
||||
G: tentativeG,
|
||||
H: heuristic(neighbor, end),
|
||||
// Create or update the node
|
||||
var neighborNode *Node
|
||||
if inOpen {
|
||||
neighborNode = existingNode
|
||||
} else {
|
||||
neighborNode = &Node{
|
||||
Tile: neighbor,
|
||||
Parent: current,
|
||||
}
|
||||
}
|
||||
newNode.F = newNode.G + newNode.H
|
||||
|
||||
// Update scores
|
||||
neighborNode.G = tentativeG
|
||||
neighborNode.H = heuristic(neighbor, end)
|
||||
neighborNode.F = neighborNode.G + neighborNode.H
|
||||
neighborNode.Parent = current
|
||||
|
||||
// Add to open set if not already there
|
||||
if !inOpen {
|
||||
openList = append(openList, newNode)
|
||||
heap.Push(openSet, neighborNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No path found
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconstructPath builds the path from goal node to start
|
||||
func reconstructPath(node *Node) []types.Tile {
|
||||
path := []types.Tile{}
|
||||
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},
|
||||
@ -104,6 +170,7 @@ 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
|
||||
|
@ -1,9 +1,36 @@
|
||||
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
|
||||
|
2
go.mod
@ -3,7 +3,7 @@ module gitea.boner.be/bdnugget/goonscape
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
gitea.boner.be/bdnugget/goonserver v0.0.0-20250113131525-49e23114973c
|
||||
gitea.boner.be/bdnugget/goonserver v1.1.0
|
||||
github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b
|
||||
google.golang.org/protobuf v1.36.3
|
||||
)
|
||||
|
@ -1 +1 @@
|
||||
Subproject commit be32dec2025e02db94cd2d79293a39cfb808bbaf
|
||||
Subproject commit 00aa3022292b8f4eec6c01522b6a91cf6769155b
|
146
main.go
@ -3,7 +3,11 @@ package main
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.boner.be/bdnugget/goonscape/game"
|
||||
"gitea.boner.be/bdnugget/goonscape/network"
|
||||
@ -11,61 +15,139 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
local := flag.Bool("local", false, "Use local server instead of remote")
|
||||
addr := flag.String("addr", "boner.be:6969", "Server address (hostname:port or hostname)")
|
||||
// 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 *local && *addr != "boner.be:6969" {
|
||||
log.Fatal("Cannot use both -local and -addr flags")
|
||||
if *verbose {
|
||||
rl.SetTraceLogLevel(rl.LogTrace)
|
||||
} else {
|
||||
rl.SetTraceLogLevel(rl.LogWarning)
|
||||
}
|
||||
|
||||
// Set server address based on flags
|
||||
if *local {
|
||||
if *addr != "" {
|
||||
log.Fatal("Cannot use -local and -addr together")
|
||||
}
|
||||
network.SetServerAddr("localhost:6969")
|
||||
} else if *addr != "" {
|
||||
// If only hostname is provided, append default port
|
||||
// If port is not specified, append default port
|
||||
if !strings.Contains(*addr, ":") {
|
||||
*addr = *addr + ":6969"
|
||||
*addr += ":6969"
|
||||
}
|
||||
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()
|
||||
rl.InitAudioDevice()
|
||||
defer rl.CloseAudioDevice()
|
||||
|
||||
game := game.New()
|
||||
if err := game.LoadAssets(); err != nil {
|
||||
log.Fatalf("Failed to load assets: %v", err)
|
||||
// Initialize audio with error handling
|
||||
if !rl.IsAudioDeviceReady() {
|
||||
rl.InitAudioDevice()
|
||||
if !rl.IsAudioDeviceReady() {
|
||||
log.Println("Warning: Failed to initialize audio device, continuing without audio")
|
||||
}
|
||||
}
|
||||
defer game.Cleanup()
|
||||
|
||||
conn, playerID, err := network.ConnectToServer()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to server: %v", err)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
game.Player.ID = playerID
|
||||
modelIndex := int(playerID) % len(game.Models)
|
||||
game.Player.Model = game.Models[modelIndex].Model
|
||||
game.Player.Texture = game.Models[modelIndex].Texture
|
||||
if loadErr != nil {
|
||||
log.Printf("Failed to load assets after %d attempts. Starting with default assets.", maxAttempts)
|
||||
}
|
||||
|
||||
go network.HandleServerCommunication(conn, playerID, game.Player, game.OtherPlayers, game.QuitChan)
|
||||
defer func() {
|
||||
if gameInstance != nil {
|
||||
gameInstance.Cleanup()
|
||||
}
|
||||
rl.CloseWindow()
|
||||
if rl.IsAudioDeviceReady() {
|
||||
rl.CloseAudioDevice()
|
||||
}
|
||||
}()
|
||||
|
||||
rl.PlayMusicStream(game.Music)
|
||||
rl.SetMusicVolume(game.Music, 0.5)
|
||||
rl.SetTargetFPS(60)
|
||||
|
||||
for !rl.WindowShouldClose() {
|
||||
rl.UpdateMusicStream(game.Music)
|
||||
deltaTime := rl.GetFrameTime()
|
||||
|
||||
game.Update(deltaTime)
|
||||
game.Render()
|
||||
// Play music if available
|
||||
if gameInstance.AssetManager.Music.Stream.Buffer != nil {
|
||||
rl.PlayMusicStream(gameInstance.AssetManager.Music)
|
||||
rl.SetMusicVolume(gameInstance.AssetManager.Music, 0.5)
|
||||
}
|
||||
|
||||
// Wait for clean shutdown
|
||||
<-game.QuitChan
|
||||
// Handle OS signals for clean shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigChan
|
||||
if gameInstance != nil {
|
||||
gameInstance.Shutdown()
|
||||
}
|
||||
}()
|
||||
|
||||
// Keep game loop in main thread for Raylib
|
||||
for !rl.WindowShouldClose() {
|
||||
deltaTime := rl.GetFrameTime()
|
||||
|
||||
// Update music if available
|
||||
if gameInstance.AssetManager.Music.Stream.Buffer != nil {
|
||||
rl.UpdateMusicStream(gameInstance.AssetManager.Music)
|
||||
}
|
||||
|
||||
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:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,71 +3,355 @@ package network
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.boner.be/bdnugget/goonscape/game"
|
||||
"gitea.boner.be/bdnugget/goonscape/types"
|
||||
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
||||
rl "github.com/gen2brain/raylib-go/raylib"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
var serverAddr = "boner.be:6969"
|
||||
const protoVersion = 1
|
||||
|
||||
var serverAddr = "boner.be:6969" // Default server address
|
||||
var lastSeenMessageTimestamp int64 = 0 // Track the last message timestamp seen by this client
|
||||
|
||||
func SetServerAddr(addr string) {
|
||||
serverAddr = addr
|
||||
log.Printf("Server address set to: %s", serverAddr)
|
||||
}
|
||||
|
||||
func ConnectToServer() (net.Conn, int32, error) {
|
||||
conn, err := net.Dial("tcp", serverAddr)
|
||||
if err != nil {
|
||||
log.Printf("Failed to dial server: %v", err)
|
||||
return nil, 0, err
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Connected to server. Waiting for player ID...")
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
// Read message length (4 bytes)
|
||||
// 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(reader, lengthBuf); err != nil {
|
||||
log.Printf("Failed to read message length: %v", err)
|
||||
return nil, 0, err
|
||||
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)
|
||||
|
||||
// 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 nil, 0, err
|
||||
// Sanity check message size
|
||||
if messageLength > 1024*1024 { // 1MB max message size
|
||||
return nil, fmt.Errorf("message size too large: %d bytes", messageLength)
|
||||
}
|
||||
|
||||
var response pb.ServerMessage
|
||||
if err := proto.Unmarshal(messageBuf, &response); err != nil {
|
||||
log.Printf("Failed to unmarshal server response: %v", err)
|
||||
return nil, 0, err
|
||||
// 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")
|
||||
}
|
||||
|
||||
log.Println("Connected to server. Authenticating...")
|
||||
|
||||
// Create a message handler
|
||||
msgHandler := NewMessageHandler(conn)
|
||||
|
||||
// Send auth message
|
||||
authAction := &pb.Action{
|
||||
Type: pb.Action_LOGIN,
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
if isRegistering {
|
||||
authAction.Type = pb.Action_REGISTER
|
||||
}
|
||||
|
||||
authBatch := &pb.ActionBatch{
|
||||
Actions: []*pb.Action{authAction},
|
||||
ProtocolVersion: protoVersion,
|
||||
}
|
||||
|
||||
if err := msgHandler.WriteMessage(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 {
|
||||
conn.Close()
|
||||
return nil, 0, fmt.Errorf("failed to read auth response: %v", err)
|
||||
}
|
||||
|
||||
// Clear read deadline after authentication
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
|
||||
if response.ProtocolVersion > protoVersion {
|
||||
conn.Close()
|
||||
return nil, 0, fmt.Errorf("server requires newer protocol version (server: %d, client: %d)",
|
||||
response.ProtocolVersion, protoVersion)
|
||||
}
|
||||
|
||||
if !response.AuthSuccess {
|
||||
conn.Close()
|
||||
return nil, 0, fmt.Errorf(response.ErrorMessage)
|
||||
}
|
||||
|
||||
playerID := response.GetPlayerId()
|
||||
log.Printf("Successfully connected with player ID: %d", playerID)
|
||||
log.Printf("Successfully authenticated with player ID: %d", playerID)
|
||||
|
||||
// Reset the lastSeenMessageTimestamp when reconnecting
|
||||
lastSeenMessageTimestamp = 0
|
||||
|
||||
return conn, playerID, nil
|
||||
}
|
||||
|
||||
func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Player, otherPlayers map[int32]*types.Player, quitChan <-chan struct{}) {
|
||||
reader := bufio.NewReader(conn)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
actionTicker := time.NewTicker(types.ClientTickRate)
|
||||
defer actionTicker.Stop()
|
||||
defer conn.Close()
|
||||
defer close(player.QuitDone)
|
||||
|
||||
// Create a channel to signal when goroutines are done
|
||||
done := make(chan struct{})
|
||||
// Add a heartbeat ticker to detect connection issues
|
||||
heartbeatTicker := time.NewTicker(5 * time.Second)
|
||||
defer heartbeatTicker.Stop()
|
||||
|
||||
lastMessageTime := time.Now()
|
||||
|
||||
// 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:
|
||||
@ -79,28 +363,55 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play
|
||||
PlayerId: playerID,
|
||||
}},
|
||||
}
|
||||
writeMessage(conn, disconnectMsg)
|
||||
done <- struct{}{}
|
||||
|
||||
// Try to send disconnect message, ignoring errors
|
||||
_ = msgHandler.WriteMessage(disconnectMsg)
|
||||
|
||||
// No need to signal done channel here, the main goroutine handles this
|
||||
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,
|
||||
PlayerId: playerID,
|
||||
Actions: actions,
|
||||
Tick: player.CurrentTick,
|
||||
LastSeenMessageTimestamp: lastSeenMessageTimestamp,
|
||||
}
|
||||
|
||||
player.ActionQueue = player.ActionQueue[:0]
|
||||
player.Unlock()
|
||||
|
||||
if err := writeMessage(conn, batch); err != nil {
|
||||
log.Printf("Failed to send actions to server: %v", err)
|
||||
return
|
||||
if err := msgHandler.WriteMessage(batch); err != nil {
|
||||
select {
|
||||
case errChan <- err:
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
lastMessageTime = time.Now()
|
||||
} else {
|
||||
player.Unlock()
|
||||
}
|
||||
@ -108,92 +419,121 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-quitChan:
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
<-done
|
||||
close(player.QuitDone)
|
||||
}()
|
||||
// Main message receiving loop
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("Recovered from panic in message receiver: %v", r)
|
||||
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:
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
case <-time.After(1 * time.Second):
|
||||
log.Println("Shutdown timed out")
|
||||
}
|
||||
return
|
||||
default:
|
||||
// Read message length (4 bytes)
|
||||
lengthBuf := make([]byte, 4)
|
||||
if _, err := io.ReadFull(reader, lengthBuf); err != nil {
|
||||
log.Printf("Failed to read message length: %v", err)
|
||||
return
|
||||
}
|
||||
messageLength := binary.BigEndian.Uint32(lengthBuf)
|
||||
|
||||
// Read the full message
|
||||
messageBuf := make([]byte, messageLength)
|
||||
if _, err := io.ReadFull(reader, messageBuf); err != nil {
|
||||
log.Printf("Failed to read message body: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var serverMessage pb.ServerMessage
|
||||
if err := proto.Unmarshal(messageBuf, &serverMessage); err != nil {
|
||||
log.Printf("Failed to unmarshal server message: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
player.Lock()
|
||||
player.CurrentTick = serverMessage.CurrentTick
|
||||
|
||||
tickDiff := serverMessage.CurrentTick - player.CurrentTick
|
||||
if tickDiff > types.MaxTickDesync {
|
||||
for _, state := range serverMessage.Players {
|
||||
if state.PlayerId == playerID {
|
||||
player.ForceResync(state)
|
||||
break
|
||||
default:
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
player.Unlock()
|
||||
|
||||
for _, state := range serverMessage.Players {
|
||||
if state.PlayerId == playerID {
|
||||
continue
|
||||
return
|
||||
}
|
||||
|
||||
if otherPlayer, exists := otherPlayers[state.PlayerId]; exists {
|
||||
otherPlayer.UpdatePosition(state, types.ServerTickRate)
|
||||
} else {
|
||||
otherPlayers[state.PlayerId] = types.NewPlayer(state)
|
||||
}
|
||||
}
|
||||
|
||||
if g, ok := player.UserData.(*game.Game); ok && len(serverMessage.ChatMessages) > 0 {
|
||||
g.Chat.HandleServerMessages(serverMessage.ChatMessages)
|
||||
// Process the server message
|
||||
UpdateGameState(serverMessage, player, otherPlayers)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 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
|
||||
return
|
||||
case err := <-errChan:
|
||||
log.Printf("Network error: %v", err)
|
||||
// The cleanup will happen in the deferred function
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to write length-prefixed messages
|
||||
func writeMessage(conn net.Conn, 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 := conn.Write(lengthBuf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write message body
|
||||
_, err = conn.Write(data)
|
||||
return err
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
BIN
resources/models/coomer/Animation_Confused_Scratch_withSkin.glb
Normal file
BIN
resources/models/coomer/Animation_Idle_withSkin.glb
Normal file
BIN
resources/models/coomer/Animation_Running_withSkin.glb
Normal file
BIN
resources/models/coomer/Animation_Unsteady_Walk_withSkin.glb
Normal file
BIN
resources/models/coomer/Animation_Walking_withSkin.glb
Normal file
BIN
resources/models/coomer/idle_notransy.glb
Normal file
BIN
resources/models/coomer/unsteadywalk_notransy.glb
Normal file
BIN
resources/models/coomerAnim.zip
Normal file
BIN
resources/models/gooner/idle_no_y_transform.glb
Normal file
BIN
resources/models/gooner/walk_no_y_transform.glb
Normal file
@ -6,12 +6,15 @@ build() {
|
||||
local arch=$2
|
||||
local output=$3
|
||||
|
||||
# Set CGO flags for static linking
|
||||
export CGO_ENABLED=1
|
||||
# Set GOOS and GOARCH for cross-compilation
|
||||
export GOOS=$os
|
||||
export GOARCH=$arch
|
||||
|
||||
# Platform specific flags
|
||||
# Disable CGO only for cross-compilation
|
||||
if [ "$os" != "$(go env GOOS)" ] || [ "$arch" != "$(go env GOARCH)" ]; then
|
||||
export CGO_ENABLED=0
|
||||
fi
|
||||
|
||||
if [ "$os" = "windows" ]; then
|
||||
export CC=x86_64-w64-mingw32-gcc
|
||||
export CXX=x86_64-w64-mingw32-g++
|
||||
|
132
types/player.go
@ -1,16 +1,98 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
||||
rl "github.com/gen2brain/raylib-go/raylib"
|
||||
)
|
||||
|
||||
func (p *Player) MoveTowards(target Tile, deltaTime float32, mapGrid [][]Tile) {
|
||||
p.Lock()
|
||||
defer p.Unlock()
|
||||
// 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)
|
||||
targetPos := rl.Vector3{
|
||||
X: float32(target.X * TileSize),
|
||||
Y: mapGrid[target.X][target.Y].Height * TileHeight,
|
||||
@ -19,6 +101,33 @@ func (p *Player) MoveTowards(target Tile, deltaTime float32, mapGrid [][]Tile) {
|
||||
|
||||
direction := rl.Vector3Subtract(targetPos, p.PosActual)
|
||||
distance := rl.Vector3Length(direction)
|
||||
|
||||
if distance > 1.0 {
|
||||
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 {
|
||||
p.AnimationFrame = 0
|
||||
}
|
||||
p.AnimationFrame += int32(deltaTime * 60)
|
||||
} else {
|
||||
wasMoving := p.IsMoving
|
||||
if wasMoving {
|
||||
p.AnimationFrame = 0
|
||||
}
|
||||
p.AnimationFrame += int32(deltaTime * 60)
|
||||
}
|
||||
}
|
||||
|
||||
if distance > 0 {
|
||||
direction = rl.Vector3Scale(direction, p.Speed*deltaTime/distance)
|
||||
}
|
||||
@ -41,12 +150,22 @@ func NewPlayer(state *pb.PlayerState) *Player {
|
||||
Y: float32(state.Y * TileHeight),
|
||||
Z: float32(state.Y * TileSize),
|
||||
},
|
||||
PosTile: Tile{X: int(state.X), Y: int(state.Y)},
|
||||
Speed: 50.0,
|
||||
ID: state.PlayerId,
|
||||
PosTile: Tile{X: int(state.X), Y: int(state.Y)},
|
||||
Speed: 50.0,
|
||||
ID: state.PlayerId,
|
||||
IsMoving: false,
|
||||
AnimationFrame: 0,
|
||||
LastAnimUpdate: time.Now(),
|
||||
LastUpdateTime: time.Now(),
|
||||
InterpolationProgress: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@ -61,6 +180,7 @@ 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,7 +1,6 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
||||
@ -14,31 +13,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
|
||||
InterpolationProgress float32
|
||||
UserData interface{}
|
||||
FloatingMessage *FloatingMessage
|
||||
QuitDone chan struct{}
|
||||
type AnimationSet struct {
|
||||
Idle []rl.ModelAnimation
|
||||
Walk []rl.ModelAnimation
|
||||
// Can add more animation types later like:
|
||||
// Attack []ModelAnimation
|
||||
// Jump []ModelAnimation
|
||||
}
|
||||
|
||||
type ModelAsset struct {
|
||||
Model rl.Model
|
||||
Texture rl.Texture2D
|
||||
Model rl.Model
|
||||
Texture rl.Texture2D
|
||||
Animation []rl.ModelAnimation // Keep this for compatibility
|
||||
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 {
|
||||
PlayerID int32
|
||||
Username string
|
||||
Content string
|
||||
Time time.Time
|
||||
}
|
||||
@ -49,6 +44,10 @@ type FloatingMessage struct {
|
||||
ScreenPos rl.Vector2 // Store the screen position for 2D rendering
|
||||
}
|
||||
|
||||
type ChatMessageHandler interface {
|
||||
HandleServerMessages([]*pb.ChatMessage)
|
||||
}
|
||||
|
||||
const (
|
||||
MapWidth = 50
|
||||
MapHeight = 50
|
||||
@ -60,3 +59,12 @@ const (
|
||||
ClientTickRate = 50 * time.Millisecond
|
||||
MaxTickDesync = 5
|
||||
)
|
||||
|
||||
// UI constants
|
||||
const (
|
||||
ChatMargin = 10
|
||||
ChatHeight = 200
|
||||
MessageHeight = 20
|
||||
InputHeight = 30
|
||||
MaxChatMessages = 50
|
||||
)
|
||||
|