Compare commits
No commits in common. "master" and "feature/chat" have entirely different histories.
master
...
feature/ch
15
.gitignore
vendored
15
.gitignore
vendored
@ -1,15 +0,0 @@
|
|||||||
# Build artifacts
|
|
||||||
build/
|
|
||||||
goonscape
|
|
||||||
goonscape.exe
|
|
||||||
|
|
||||||
# IDE files
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
|
|
||||||
# OS files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
resources/models/old_and_test/
|
|
@ -1,25 +0,0 @@
|
|||||||
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*
|
|
@ -1,30 +0,0 @@
|
|||||||
FROM golang:1.23
|
|
||||||
|
|
||||||
# Install build dependencies
|
|
||||||
RUN 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 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
# Copy build scripts
|
|
||||||
COPY . /build/
|
|
||||||
|
|
||||||
# Set execute permissions
|
|
||||||
RUN chmod +x /build/scripts/build.sh
|
|
||||||
|
|
||||||
# Build command
|
|
||||||
CMD ["make", "all"]
|
|
24
LICENSE
24
LICENSE
@ -1,21 +1,11 @@
|
|||||||
MIT License
|
“Commons Clause” License Condition v1.0
|
||||||
|
|
||||||
Copyright (c) 2025 bdnugget
|
The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
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.
|
||||||
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:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
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.
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
Software: GoonScape
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
License: Commons Clause v1.0
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
Licensor: bdnugget
|
||||||
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.
|
|
30
Makefile
30
Makefile
@ -1,30 +0,0 @@
|
|||||||
.PHONY: all clean windows linux darwin
|
|
||||||
|
|
||||||
include scripts/platforms.mk
|
|
||||||
|
|
||||||
BINARY_NAME=goonscape
|
|
||||||
VERSION=1.1.0
|
|
||||||
BUILD_DIR=build
|
|
||||||
ASSETS_DIR=resources
|
|
||||||
|
|
||||||
all: clean $(PLATFORMS)
|
|
||||||
|
|
||||||
$(PLATFORMS):
|
|
||||||
@echo "Building for $@..."
|
|
||||||
@mkdir -p $(BUILD_DIR)/$@
|
|
||||||
@scripts/build.sh $(word 1,$(subst /, ,$@)) $(word 2,$(subst /, ,$@)) \
|
|
||||||
$(BUILD_DIR)/$@/$(BINARY_NAME)$(if $(findstring windows,$@),.exe,)
|
|
||||||
@cp -r $(ASSETS_DIR) $(BUILD_DIR)/$@/
|
|
||||||
@cd $(BUILD_DIR) && zip -r $(BINARY_NAME)-$(word 1,$(subst /, ,$@))-$(word 2,$(subst /, ,$@))-v$(VERSION).zip $@
|
|
||||||
@echo "Done building for $@"
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm -rf $(BUILD_DIR)
|
|
||||||
|
|
||||||
# Development build for current platform
|
|
||||||
dev:
|
|
||||||
go build -o $(BINARY_NAME)
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
test:
|
|
||||||
go test ./...
|
|
124
README.md
124
README.md
@ -1,124 +0,0 @@
|
|||||||
# GoonScape
|
|
||||||
|
|
||||||
A multiplayer isometric game inspired by Oldschool RuneScape, built with Go and Raylib.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- 3D isometric world with height-mapped terrain
|
|
||||||
- Multiplayer support with client-server architecture
|
|
||||||
- Pathfinding and click-to-move navigation
|
|
||||||
- Global chat system with floating messages
|
|
||||||
- Multiple character models
|
|
||||||
- Background music
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
### 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
|
|
||||||
go build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Controls
|
|
||||||
|
|
||||||
- **Mouse Click**: Move to location
|
|
||||||
- **T**: Open chat
|
|
||||||
- **Enter**: Send chat message
|
|
||||||
- **Escape**: Cancel chat/Close game (it does both of these at the same time so gg)
|
|
||||||
- **Arrow Keys**: Rotate camera
|
|
||||||
- **Mouse Wheel**: Zoom in/out
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Server connection can be configured using command-line flags:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Connect to default server (boner.be:6969)
|
|
||||||
go run main.go
|
|
||||||
|
|
||||||
# Connect to local server
|
|
||||||
go run main.go -local
|
|
||||||
|
|
||||||
# Connect to specific server
|
|
||||||
go run main.go -addr somehost # Uses somehost:6969
|
|
||||||
go run main.go -addr somehost:6970 # Uses somehost:6970
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: The `-local` flag is a shorthand for `-addr localhost:6969` and cannot be used together with `-addr`.
|
|
||||||
|
|
||||||
## Building Release Binaries
|
|
||||||
|
|
||||||
The project uses Docker to create consistent builds across platforms. To build release binaries:
|
|
||||||
|
|
||||||
1. Build the Docker image (only needed once):
|
|
||||||
```bash
|
|
||||||
sudo docker build -t goonscape-builder -f Dockerfile.build .
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Create release builds:
|
|
||||||
```bash
|
|
||||||
sudo docker run -v $(pwd):/build goonscape-builder
|
|
||||||
```
|
|
||||||
|
|
||||||
This will create zip files in the `build` directory for:
|
|
||||||
- Windows (64-bit): `goonscape-windows-amd64-v1.0.0.zip`
|
|
||||||
- Linux (64-bit): `goonscape-linux-amd64-v1.0.0.zip`
|
|
||||||
|
|
||||||
Each zip contains the binary and all required assets.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
The project uses Protocol Buffers for network communication. If you modify the `.proto` files, regenerate the Go code with:
|
|
||||||
```bash
|
|
||||||
protoc --go_out=. goonserver/actions/actions.proto
|
|
||||||
```
|
|
302
assets/assets.go
302
assets/assets.go
@ -1,295 +1,41 @@
|
|||||||
package assets
|
package assets
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"gitea.boner.be/bdnugget/goonscape/types"
|
"gitea.boner.be/bdnugget/goonscape/types"
|
||||||
rl "github.com/gen2brain/raylib-go/raylib"
|
rl "github.com/gen2brain/raylib-go/raylib"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ModelLoader handles loading and fallback for 3D models
|
|
||||||
type ModelLoader struct {
|
|
||||||
safeMode bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewModelLoader creates a new model loader instance
|
|
||||||
func NewModelLoader() *ModelLoader {
|
|
||||||
return &ModelLoader{
|
|
||||||
safeMode: os.Getenv("GOONSCAPE_SAFE_MODE") == "1",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSafeMode returns if we should avoid loading external models
|
|
||||||
func (ml *ModelLoader) IsSafeMode() bool {
|
|
||||||
return ml.safeMode || os.Getenv("GOONSCAPE_SAFE_MODE") == "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadModel attempts to load a model, returning a placeholder if it fails
|
|
||||||
func (ml *ModelLoader) LoadModel(fileName string, fallbackShape int, fallbackColor rl.Color) (rl.Model, bool, rl.Color) {
|
|
||||||
// Don't even try to load external models in safe mode
|
|
||||||
if ml.IsSafeMode() {
|
|
||||||
rl.TraceLog(rl.LogInfo, "Safe mode enabled, using primitive shape instead of %s", fileName)
|
|
||||||
return ml.createPrimitiveShape(fallbackShape), false, fallbackColor
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
// Recover from any panics during model loading
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
rl.TraceLog(rl.LogError, "Panic in LoadModel: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Try to load the model
|
|
||||||
model := rl.LoadModel(fileName)
|
|
||||||
|
|
||||||
// Check if the model is valid
|
|
||||||
if model.Meshes == nil || model.Meshes.VertexCount <= 0 {
|
|
||||||
rl.TraceLog(rl.LogWarning, "Failed to load model %s, using placeholder", fileName)
|
|
||||||
return ml.createPrimitiveShape(fallbackShape), false, fallbackColor
|
|
||||||
}
|
|
||||||
|
|
||||||
// For real models, return zero color since we don't need it
|
|
||||||
return model, true, rl.Color{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createPrimitiveShape creates a simple shape without loading external models
|
|
||||||
func (ml *ModelLoader) createPrimitiveShape(shapeType int) rl.Model {
|
|
||||||
var mesh rl.Mesh
|
|
||||||
|
|
||||||
switch shapeType {
|
|
||||||
case 0: // Cube
|
|
||||||
mesh = rl.GenMeshCube(1.0, 2.0, 1.0)
|
|
||||||
case 1: // Sphere
|
|
||||||
mesh = rl.GenMeshSphere(1.0, 8, 8)
|
|
||||||
case 2: // Cylinder
|
|
||||||
mesh = rl.GenMeshCylinder(0.8, 2.0, 8)
|
|
||||||
case 3: // Cone
|
|
||||||
mesh = rl.GenMeshCone(1.0, 2.0, 8)
|
|
||||||
default: // Default to cube
|
|
||||||
mesh = rl.GenMeshCube(1.0, 2.0, 1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
model := rl.LoadModelFromMesh(mesh)
|
|
||||||
return model
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to load animations for a model
|
|
||||||
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) {
|
func LoadModels() ([]types.ModelAsset, error) {
|
||||||
// Force safe mode for now until we fix the segfault
|
goonerModel := rl.LoadModel("resources/models/goonion.obj")
|
||||||
os.Setenv("GOONSCAPE_SAFE_MODE", "1")
|
goonerTexture := rl.LoadTexture("resources/models/goonion.png")
|
||||||
|
rl.SetMaterialTexture(goonerModel.Materials, rl.MapDiffuse, goonerTexture)
|
||||||
|
|
||||||
models := make([]types.ModelAsset, 0, 3)
|
coomerModel := rl.LoadModel("resources/models/coomer.obj")
|
||||||
modelLoader := NewModelLoader()
|
coomerTexture := rl.LoadTexture("resources/models/coomer.png")
|
||||||
|
rl.SetMaterialTexture(coomerModel.Materials, rl.MapDiffuse, coomerTexture)
|
||||||
|
|
||||||
// Colors for the different models
|
shrekeModel := rl.LoadModel("resources/models/shreke.obj")
|
||||||
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")
|
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)
|
rl.SetMaterialTexture(shrekeModel.Materials, rl.MapDiffuse, shrekeTexture)
|
||||||
|
|
||||||
models = append(models, types.ModelAsset{
|
return []types.ModelAsset{
|
||||||
Model: shrekeModel,
|
{Model: goonerModel, Texture: goonerTexture},
|
||||||
Texture: shrekeTexture,
|
{Model: coomerModel, Texture: coomerTexture},
|
||||||
YOffset: 0.0,
|
{Model: shrekeModel, Texture: shrekeTexture},
|
||||||
PlaceholderColor: rl.Color{}, // Not a placeholder
|
}, nil
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Add another placeholder with different shape/color
|
|
||||||
models = append(models, types.ModelAsset{
|
|
||||||
Model: shrekeModel,
|
|
||||||
YOffset: 0.0,
|
|
||||||
PlaceholderColor: modelColor,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(models) == 0 {
|
|
||||||
return nil, fmt.Errorf("failed to load any models")
|
|
||||||
}
|
|
||||||
|
|
||||||
return models, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadMusic(filename string) (rl.Music, error) {
|
func LoadMusic(filename string) (rl.Music, error) {
|
||||||
defer func() {
|
return rl.LoadMusicStream(filename), nil
|
||||||
// Recover from any panics during music loading
|
}
|
||||||
if r := recover(); r != nil {
|
|
||||||
rl.TraceLog(rl.LogError, "Panic in LoadMusic: %v", r)
|
func UnloadModels(models []types.ModelAsset) {
|
||||||
}
|
for _, model := range models {
|
||||||
}()
|
rl.UnloadModel(model.Model)
|
||||||
|
rl.UnloadTexture(model.Texture)
|
||||||
// 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")
|
func UnloadMusic(music rl.Music) {
|
||||||
}
|
rl.UnloadMusicStream(music)
|
||||||
|
|
||||||
music := rl.LoadMusicStream(filename)
|
|
||||||
if music.Stream.Buffer == nil {
|
|
||||||
return music, fmt.Errorf("failed to load music: %s", filename)
|
|
||||||
}
|
|
||||||
return music, nil
|
|
||||||
}
|
}
|
||||||
|
31
constants.go
31
constants.go
@ -2,33 +2,20 @@ package main
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// Game world constants
|
|
||||||
const (
|
const (
|
||||||
// Server-related constants
|
|
||||||
ServerTickRate = 600 * time.Millisecond // RuneScape-style tick rate
|
|
||||||
ClientTickRate = 50 * time.Millisecond // Client runs at higher rate for smooth rendering
|
|
||||||
MaxTickDesync = 5 // Max ticks behind before forcing resync
|
|
||||||
DefaultPort = "6969" // Default server port
|
|
||||||
|
|
||||||
// Map constants
|
|
||||||
MapWidth = 50
|
MapWidth = 50
|
||||||
MapHeight = 50
|
MapHeight = 50
|
||||||
TileSize = 32
|
TileSize = 32
|
||||||
TileHeight = 2.0
|
TileHeight = 2.0
|
||||||
)
|
TickRate = 600 * time.Millisecond // Server tick rate (600ms)
|
||||||
|
serverAddr = "localhost:6969"
|
||||||
|
|
||||||
// UI constants
|
// RuneScape-style tick rate (600ms)
|
||||||
const (
|
ServerTickRate = 600 * time.Millisecond
|
||||||
ChatMargin = 10
|
|
||||||
ChatHeight = 200
|
|
||||||
MessageHeight = 20
|
|
||||||
InputHeight = 30
|
|
||||||
MaxMessages = 50
|
|
||||||
)
|
|
||||||
|
|
||||||
// Environment variable names
|
// Client might run at a higher tick rate for smooth rendering
|
||||||
const (
|
ClientTickRate = 50 * time.Millisecond
|
||||||
EnvSafeMode = "GOONSCAPE_SAFE_MODE"
|
|
||||||
EnvDisableAnimations = "GOONSCAPE_DISABLE_ANIMATIONS"
|
// Maximum number of ticks we can get behind before forcing a resync
|
||||||
EnvDisableAudio = "GOONSCAPE_DISABLE_AUDIO"
|
MaxTickDesync = 5
|
||||||
)
|
)
|
||||||
|
@ -10,18 +10,9 @@ var (
|
|||||||
cameraDistance = float32(20.0)
|
cameraDistance = float32(20.0)
|
||||||
cameraYaw = float32(145.0)
|
cameraYaw = float32(145.0)
|
||||||
cameraPitch = float32(45.0)
|
cameraPitch = float32(45.0)
|
||||||
lastMousePos rl.Vector2 // Add this to track mouse movement
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func UpdateCamera(camera *rl.Camera3D, player rl.Vector3, deltaTime float32) {
|
func UpdateCamera(camera *rl.Camera3D, player rl.Vector3, deltaTime float32) {
|
||||||
// Adjust target position to be at character's torso height (about half character height)
|
|
||||||
characterHeight := float32(32.0) // Assuming character is roughly 32 units tall
|
|
||||||
targetPos := rl.Vector3{
|
|
||||||
X: player.X,
|
|
||||||
Y: player.Y + characterHeight*0.5, // Focus on middle of character
|
|
||||||
Z: player.Z,
|
|
||||||
}
|
|
||||||
|
|
||||||
wheelMove := rl.GetMouseWheelMove()
|
wheelMove := rl.GetMouseWheelMove()
|
||||||
if wheelMove != 0 {
|
if wheelMove != 0 {
|
||||||
cameraDistance += -wheelMove * 5
|
cameraDistance += -wheelMove * 5
|
||||||
@ -33,34 +24,6 @@ func UpdateCamera(camera *rl.Camera3D, player rl.Vector3, deltaTime float32) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle middle mouse camera rotation
|
|
||||||
if rl.IsMouseButtonDown(rl.MouseMiddleButton) {
|
|
||||||
currentMousePos := rl.GetMousePosition()
|
|
||||||
|
|
||||||
// If we just started holding the button, initialize last position
|
|
||||||
if !rl.IsMouseButtonPressed(rl.MouseMiddleButton) {
|
|
||||||
mouseDelta := rl.Vector2{
|
|
||||||
X: currentMousePos.X - lastMousePos.X,
|
|
||||||
Y: currentMousePos.Y - lastMousePos.Y,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust rotation speed as needed
|
|
||||||
cameraYaw += mouseDelta.X * 0.5 * deltaTime * 60
|
|
||||||
cameraPitch += mouseDelta.Y * 0.5 * deltaTime * 60
|
|
||||||
|
|
||||||
// Clamp pitch to prevent camera flipping
|
|
||||||
if cameraPitch < 20 {
|
|
||||||
cameraPitch = 20
|
|
||||||
}
|
|
||||||
if cameraPitch > 85 {
|
|
||||||
cameraPitch = 85
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lastMousePos = currentMousePos
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the keyboard controls too
|
|
||||||
if rl.IsKeyDown(rl.KeyRight) {
|
if rl.IsKeyDown(rl.KeyRight) {
|
||||||
cameraYaw += 100 * deltaTime
|
cameraYaw += 100 * deltaTime
|
||||||
}
|
}
|
||||||
@ -84,9 +47,9 @@ func UpdateCamera(camera *rl.Camera3D, player rl.Vector3, deltaTime float32) {
|
|||||||
cameraPitchRad := float64(cameraPitch) * rl.Deg2rad
|
cameraPitchRad := float64(cameraPitch) * rl.Deg2rad
|
||||||
|
|
||||||
camera.Position = rl.Vector3{
|
camera.Position = rl.Vector3{
|
||||||
X: targetPos.X + cameraDistance*float32(math.Cos(cameraYawRad))*float32(math.Cos(cameraPitchRad)),
|
X: player.X + cameraDistance*float32(math.Cos(cameraYawRad))*float32(math.Cos(cameraPitchRad)),
|
||||||
Y: targetPos.Y + cameraDistance*float32(math.Sin(cameraPitchRad)),
|
Y: player.Y + cameraDistance*float32(math.Sin(cameraPitchRad)),
|
||||||
Z: targetPos.Z + cameraDistance*float32(math.Sin(cameraYawRad))*float32(math.Cos(cameraPitchRad)),
|
Z: player.Z + cameraDistance*float32(math.Sin(cameraYawRad))*float32(math.Cos(cameraPitchRad)),
|
||||||
}
|
}
|
||||||
camera.Target = targetPos
|
camera.Target = player
|
||||||
}
|
}
|
||||||
|
139
game/chat.go
139
game/chat.go
@ -2,8 +2,6 @@ package game
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.boner.be/bdnugget/goonscape/types"
|
"gitea.boner.be/bdnugget/goonscape/types"
|
||||||
@ -11,9 +9,12 @@ import (
|
|||||||
rl "github.com/gen2brain/raylib-go/raylib"
|
rl "github.com/gen2brain/raylib-go/raylib"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Local UI constants (these could be moved to a centralized constants package later)
|
|
||||||
const (
|
const (
|
||||||
runeLimit = 256
|
maxMessages = 50
|
||||||
|
chatWindowWidth = 400
|
||||||
|
chatHeight = 200
|
||||||
|
messageHeight = 20
|
||||||
|
inputHeight = 30
|
||||||
)
|
)
|
||||||
|
|
||||||
type Chat struct {
|
type Chat struct {
|
||||||
@ -23,13 +24,12 @@ type Chat struct {
|
|||||||
cursorPos int
|
cursorPos int
|
||||||
scrollOffset int
|
scrollOffset int
|
||||||
userData interface{}
|
userData interface{}
|
||||||
mutex sync.RWMutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewChat() *Chat {
|
func NewChat() *Chat {
|
||||||
return &Chat{
|
return &Chat{
|
||||||
messages: make([]types.ChatMessage, 0, types.MaxChatMessages),
|
messages: make([]types.ChatMessage, 0, maxMessages),
|
||||||
inputBuffer: make([]rune, 0, runeLimit),
|
inputBuffer: make([]rune, 0, 256),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,7 +40,7 @@ func (c *Chat) AddMessage(playerID int32, content string) {
|
|||||||
Time: time.Now(),
|
Time: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(c.messages) >= types.MaxChatMessages {
|
if len(c.messages) >= maxMessages {
|
||||||
c.messages = c.messages[1:]
|
c.messages = c.messages[1:]
|
||||||
}
|
}
|
||||||
c.messages = append(c.messages, msg)
|
c.messages = append(c.messages, msg)
|
||||||
@ -48,80 +48,37 @@ func (c *Chat) AddMessage(playerID int32, content string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) {
|
func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) {
|
||||||
c.mutex.Lock()
|
|
||||||
defer c.mutex.Unlock()
|
|
||||||
|
|
||||||
if len(messages) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Processing %d chat messages", len(messages))
|
|
||||||
|
|
||||||
// Convert protobuf messages to our local type
|
// Convert protobuf messages to our local type
|
||||||
for _, msg := range messages {
|
for _, msg := range messages {
|
||||||
// Skip invalid messages
|
|
||||||
if msg == nil {
|
|
||||||
log.Printf("Warning: Received nil chat message")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
localMsg := types.ChatMessage{
|
localMsg := types.ChatMessage{
|
||||||
PlayerID: msg.PlayerId,
|
PlayerID: msg.PlayerId,
|
||||||
Username: msg.Username,
|
|
||||||
Content: msg.Content,
|
Content: msg.Content,
|
||||||
Time: time.Unix(0, msg.Timestamp),
|
Time: time.Unix(0, msg.Timestamp),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only add if it's not already in our history
|
// Only add if it's not already in our history
|
||||||
if len(c.messages) == 0 || c.messages[len(c.messages)-1].Time.UnixNano() < msg.Timestamp {
|
if len(c.messages) == 0 || c.messages[len(c.messages)-1].Time.UnixNano() < msg.Timestamp {
|
||||||
if len(c.messages) >= types.MaxChatMessages {
|
if len(c.messages) >= maxMessages {
|
||||||
c.messages = c.messages[1:]
|
c.messages = c.messages[1:]
|
||||||
}
|
}
|
||||||
c.messages = append(c.messages, localMsg)
|
c.messages = append(c.messages, localMsg)
|
||||||
log.Printf("Added chat message from %s: %s", msg.Username, msg.Content)
|
|
||||||
|
|
||||||
// Scroll to latest message if it's not already visible
|
|
||||||
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
|
// Add floating message to the player
|
||||||
if game, ok := c.userData.(*Game); ok && game != nil {
|
if game, ok := c.userData.(*Game); ok {
|
||||||
// Make sure each game component exists before using it
|
if msg.PlayerId == game.Player.ID {
|
||||||
if game.PlayerManager == nil {
|
game.Player.Lock()
|
||||||
log.Printf("Warning: PlayerManager is nil when processing chat message")
|
game.Player.FloatingMessage = &types.FloatingMessage{
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg.PlayerId == game.PlayerManager.LocalPlayer.ID {
|
|
||||||
// Check if local player exists
|
|
||||||
if game.PlayerManager.LocalPlayer == nil {
|
|
||||||
log.Printf("Warning: Local player is nil when trying to add floating message")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
game.PlayerManager.LocalPlayer.Lock()
|
|
||||||
game.PlayerManager.LocalPlayer.FloatingMessage = &types.FloatingMessage{
|
|
||||||
Content: msg.Content,
|
Content: msg.Content,
|
||||||
ExpireTime: time.Now().Add(6 * time.Second),
|
ExpireTime: time.Now().Add(6 * time.Second),
|
||||||
}
|
}
|
||||||
game.PlayerManager.LocalPlayer.Unlock()
|
game.Player.Unlock()
|
||||||
} else {
|
} else if otherPlayer, exists := game.OtherPlayers[msg.PlayerId]; exists {
|
||||||
// The other player might not be in our list yet, handle safely
|
otherPlayer.Lock()
|
||||||
player := game.PlayerManager.GetPlayer(msg.PlayerId)
|
otherPlayer.FloatingMessage = &types.FloatingMessage{
|
||||||
if player == nil {
|
|
||||||
log.Printf("Could not find other player %d to add floating message (player not in game yet)", msg.PlayerId)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
player.Lock()
|
|
||||||
player.FloatingMessage = &types.FloatingMessage{
|
|
||||||
Content: msg.Content,
|
Content: msg.Content,
|
||||||
ExpireTime: time.Now().Add(6 * time.Second),
|
ExpireTime: time.Now().Add(6 * time.Second),
|
||||||
}
|
}
|
||||||
player.Unlock()
|
otherPlayer.Unlock()
|
||||||
log.Printf("Added floating message to other player %d", msg.PlayerId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -129,47 +86,26 @@ func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Chat) Draw(screenWidth, screenHeight int32) {
|
func (c *Chat) Draw(screenWidth, screenHeight int32) {
|
||||||
c.mutex.RLock()
|
|
||||||
defer c.mutex.RUnlock()
|
|
||||||
|
|
||||||
// Calculate chat window width based on screen width
|
|
||||||
chatWindowWidth := screenWidth - (types.ChatMargin * 2)
|
|
||||||
|
|
||||||
// Draw chat window background
|
// Draw chat window background
|
||||||
chatX := float32(types.ChatMargin)
|
chatX := float32(10)
|
||||||
chatY := float32(screenHeight - types.ChatHeight - types.ChatMargin)
|
chatY := float32(screenHeight - chatHeight - 10)
|
||||||
rl.DrawRectangle(int32(chatX), int32(chatY), chatWindowWidth, types.ChatHeight, rl.ColorAlpha(rl.Black, 0.5))
|
rl.DrawRectangle(int32(chatX), int32(chatY), chatWindowWidth, chatHeight, rl.ColorAlpha(rl.Black, 0.5))
|
||||||
|
|
||||||
// Draw messages from oldest to newest
|
// Draw messages
|
||||||
messageY := chatY + 5
|
messageY := chatY + 5
|
||||||
visibleMessages := int((types.ChatHeight - types.InputHeight) / types.MessageHeight)
|
startIdx := len(c.messages) - 1 - c.scrollOffset
|
||||||
|
endIdx := max(0, startIdx-int((chatHeight-inputHeight)/messageHeight))
|
||||||
|
|
||||||
// Auto-scroll to bottom if no manual scrolling has occurred
|
for i := startIdx; i >= endIdx && i >= 0; i-- {
|
||||||
if c.scrollOffset == 0 {
|
|
||||||
if len(c.messages) > visibleMessages {
|
|
||||||
c.scrollOffset = len(c.messages) - visibleMessages
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startIdx := max(0, c.scrollOffset)
|
|
||||||
endIdx := min(len(c.messages), startIdx+visibleMessages)
|
|
||||||
|
|
||||||
for i := startIdx; i < endIdx; i++ {
|
|
||||||
msg := c.messages[i]
|
msg := c.messages[i]
|
||||||
var color rl.Color
|
text := fmt.Sprintf("[%d]: %s", msg.PlayerID, msg.Content)
|
||||||
if msg.PlayerID == 0 { // System message
|
rl.DrawText(text, int32(chatX)+5, int32(messageY), 20, rl.White)
|
||||||
color = rl.Gold
|
messageY += messageHeight
|
||||||
} 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
|
// Draw input field
|
||||||
inputY := chatY + float32(types.ChatHeight-types.InputHeight)
|
inputY := chatY + float32(chatHeight-inputHeight)
|
||||||
rl.DrawRectangle(int32(chatX), int32(inputY), chatWindowWidth, types.InputHeight, rl.ColorAlpha(rl.White, 0.3))
|
rl.DrawRectangle(int32(chatX), int32(inputY), chatWindowWidth, inputHeight, rl.ColorAlpha(rl.White, 0.3))
|
||||||
if c.isTyping {
|
if c.isTyping {
|
||||||
inputText := string(c.inputBuffer)
|
inputText := string(c.inputBuffer)
|
||||||
rl.DrawText(inputText, int32(chatX)+5, int32(inputY)+5, 20, rl.White)
|
rl.DrawText(inputText, int32(chatX)+5, int32(inputY)+5, 20, rl.White)
|
||||||
@ -187,7 +123,7 @@ func (c *Chat) Update() (string, bool) {
|
|||||||
if !c.isTyping {
|
if !c.isTyping {
|
||||||
wheelMove := rl.GetMouseWheelMove()
|
wheelMove := rl.GetMouseWheelMove()
|
||||||
if wheelMove != 0 {
|
if wheelMove != 0 {
|
||||||
maxScroll := max(0, len(c.messages)-int((types.ChatHeight-types.InputHeight)/types.MessageHeight))
|
maxScroll := max(0, len(c.messages)-int((chatHeight-inputHeight)/messageHeight))
|
||||||
c.scrollOffset = clamp(c.scrollOffset-int(wheelMove), 0, maxScroll)
|
c.scrollOffset = clamp(c.scrollOffset-int(wheelMove), 0, maxScroll)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,14 +136,14 @@ func (c *Chat) Update() (string, bool) {
|
|||||||
|
|
||||||
key := rl.GetCharPressed()
|
key := rl.GetCharPressed()
|
||||||
for key > 0 {
|
for key > 0 {
|
||||||
if len(c.inputBuffer) < runeLimit {
|
if len(c.inputBuffer) < 256 {
|
||||||
c.inputBuffer = append(c.inputBuffer[:c.cursorPos], append([]rune{key}, c.inputBuffer[c.cursorPos:]...)...)
|
c.inputBuffer = append(c.inputBuffer[:c.cursorPos], append([]rune{key}, c.inputBuffer[c.cursorPos:]...)...)
|
||||||
c.cursorPos++
|
c.cursorPos++
|
||||||
}
|
}
|
||||||
key = rl.GetCharPressed()
|
key = rl.GetCharPressed()
|
||||||
}
|
}
|
||||||
|
|
||||||
if rl.IsKeyPressed(rl.KeyEnter) || rl.IsKeyPressed(rl.KeyKpEnter) {
|
if rl.IsKeyPressed(rl.KeyEnter) {
|
||||||
if len(c.inputBuffer) > 0 {
|
if len(c.inputBuffer) > 0 {
|
||||||
message := string(c.inputBuffer)
|
message := string(c.inputBuffer)
|
||||||
c.inputBuffer = c.inputBuffer[:0]
|
c.inputBuffer = c.inputBuffer[:0]
|
||||||
@ -218,7 +154,7 @@ func (c *Chat) Update() (string, bool) {
|
|||||||
c.isTyping = false
|
c.isTyping = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if rl.IsKeyPressed(rl.KeyEscape) && c.isTyping {
|
if rl.IsKeyPressed(rl.KeyEscape) {
|
||||||
c.inputBuffer = c.inputBuffer[:0]
|
c.inputBuffer = c.inputBuffer[:0]
|
||||||
c.cursorPos = 0
|
c.cursorPos = 0
|
||||||
c.isTyping = false
|
c.isTyping = false
|
||||||
@ -247,13 +183,6 @@ func max(a, b int) int {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func min(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func clamp(value, min, max int) int {
|
func clamp(value, min, max int) int {
|
||||||
if value < min {
|
if value < min {
|
||||||
return min
|
return min
|
||||||
|
@ -1,107 +0,0 @@
|
|||||||
package game
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"gitea.boner.be/bdnugget/goonscape/types"
|
|
||||||
rl "github.com/gen2brain/raylib-go/raylib"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PlayerManager handles all player-related operations
|
|
||||||
type PlayerManager struct {
|
|
||||||
LocalPlayer *types.Player
|
|
||||||
OtherPlayers map[int32]*types.Player
|
|
||||||
mutex sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPlayerManager creates a new player manager
|
|
||||||
func NewPlayerManager() *PlayerManager {
|
|
||||||
return &PlayerManager{
|
|
||||||
OtherPlayers: make(map[int32]*types.Player),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPlayer returns the player with the given ID, or the local player if ID matches
|
|
||||||
func (pm *PlayerManager) GetPlayer(id int32) *types.Player {
|
|
||||||
pm.mutex.RLock()
|
|
||||||
defer pm.mutex.RUnlock()
|
|
||||||
|
|
||||||
if pm.LocalPlayer != nil && pm.LocalPlayer.ID == id {
|
|
||||||
return pm.LocalPlayer
|
|
||||||
}
|
|
||||||
|
|
||||||
return pm.OtherPlayers[id]
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddPlayer adds a player to the manager
|
|
||||||
func (pm *PlayerManager) AddPlayer(player *types.Player) {
|
|
||||||
pm.mutex.Lock()
|
|
||||||
defer pm.mutex.Unlock()
|
|
||||||
|
|
||||||
pm.OtherPlayers[player.ID] = player
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemovePlayer removes a player from the manager
|
|
||||||
func (pm *PlayerManager) RemovePlayer(id int32) {
|
|
||||||
pm.mutex.Lock()
|
|
||||||
defer pm.mutex.Unlock()
|
|
||||||
|
|
||||||
delete(pm.OtherPlayers, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssetManager handles all game assets
|
|
||||||
type AssetManager struct {
|
|
||||||
Models []types.ModelAsset
|
|
||||||
Music rl.Music
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAssetManager creates a new asset manager
|
|
||||||
func NewAssetManager() *AssetManager {
|
|
||||||
return &AssetManager{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetModelForPlayer returns the appropriate model for a player
|
|
||||||
func (am *AssetManager) GetModelForPlayer(playerID int32) (types.ModelAsset, bool) {
|
|
||||||
if len(am.Models) == 0 {
|
|
||||||
return types.ModelAsset{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple model assignment based on player ID
|
|
||||||
modelIndex := int(playerID) % len(am.Models)
|
|
||||||
return am.Models[modelIndex], true
|
|
||||||
}
|
|
||||||
|
|
||||||
// UIManager manages all user interface components
|
|
||||||
type UIManager struct {
|
|
||||||
Chat *Chat
|
|
||||||
LoginScreen *LoginScreen
|
|
||||||
IsLoggedIn bool
|
|
||||||
MenuOpen bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUIManager creates a new UI manager
|
|
||||||
func NewUIManager() *UIManager {
|
|
||||||
return &UIManager{
|
|
||||||
Chat: NewChat(),
|
|
||||||
LoginScreen: NewLoginScreen(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleChatInput processes chat input and returns messages to send
|
|
||||||
func (ui *UIManager) HandleChatInput() (string, bool) {
|
|
||||||
return ui.Chat.Update()
|
|
||||||
}
|
|
||||||
|
|
||||||
// DrawUI renders all UI components
|
|
||||||
func (ui *UIManager) DrawUI(screenWidth, screenHeight int32) {
|
|
||||||
if !ui.IsLoggedIn {
|
|
||||||
ui.LoginScreen.Draw()
|
|
||||||
} else {
|
|
||||||
if ui.MenuOpen {
|
|
||||||
// Draw menu
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw chat always when logged in
|
|
||||||
ui.Chat.Draw(screenWidth, screenHeight)
|
|
||||||
}
|
|
||||||
}
|
|
440
game/game.go
440
game/game.go
@ -1,191 +1,87 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.boner.be/bdnugget/goonscape/assets"
|
"gitea.boner.be/bdnugget/goonscape/assets"
|
||||||
"gitea.boner.be/bdnugget/goonscape/network"
|
|
||||||
"gitea.boner.be/bdnugget/goonscape/types"
|
"gitea.boner.be/bdnugget/goonscape/types"
|
||||||
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
||||||
rl "github.com/gen2brain/raylib-go/raylib"
|
rl "github.com/gen2brain/raylib-go/raylib"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Game struct {
|
type Game struct {
|
||||||
// Component-based architecture
|
Player *types.Player
|
||||||
PlayerManager *PlayerManager
|
OtherPlayers map[int32]*types.Player
|
||||||
AssetManager *AssetManager
|
|
||||||
UIManager *UIManager
|
|
||||||
|
|
||||||
// Core game state
|
|
||||||
Camera rl.Camera3D
|
Camera rl.Camera3D
|
||||||
quitChan chan struct{}
|
Models []types.ModelAsset
|
||||||
cleanupOnce sync.Once
|
Music rl.Music
|
||||||
frameCounter int // For periodic logging
|
Chat *Chat
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Game {
|
func New() *Game {
|
||||||
// Create managers
|
InitWorld()
|
||||||
playerManager := NewPlayerManager()
|
game := &Game{
|
||||||
assetManager := NewAssetManager()
|
Player: &types.Player{
|
||||||
uiManager := NewUIManager()
|
PosActual: rl.NewVector3(5*types.TileSize, 0, 5*types.TileSize),
|
||||||
|
PosTile: GetTile(5, 5),
|
||||||
g := &Game{
|
Speed: 50.0,
|
||||||
PlayerManager: playerManager,
|
TargetPath: []types.Tile{},
|
||||||
AssetManager: assetManager,
|
UserData: nil,
|
||||||
UIManager: uiManager,
|
},
|
||||||
|
OtherPlayers: make(map[int32]*types.Player),
|
||||||
Camera: rl.Camera3D{
|
Camera: rl.Camera3D{
|
||||||
Position: rl.NewVector3(0.0, 20.0, 0.0),
|
Position: rl.NewVector3(0, 10, 10),
|
||||||
Target: rl.NewVector3(0.0, 0.0, 0.0),
|
Target: rl.NewVector3(0, 0, 0),
|
||||||
Up: rl.NewVector3(0.0, 1.0, 0.0),
|
Up: rl.NewVector3(0, 1, 0),
|
||||||
Fovy: 45.0,
|
Fovy: 45.0,
|
||||||
Projection: rl.CameraPerspective,
|
Projection: rl.CameraPerspective,
|
||||||
},
|
},
|
||||||
quitChan: make(chan struct{}),
|
Chat: NewChat(),
|
||||||
}
|
}
|
||||||
|
game.Player.UserData = game
|
||||||
// Set up inter-component references
|
game.Chat.userData = game
|
||||||
g.UIManager.Chat.userData = g // Pass game instance to chat for callbacks
|
return game
|
||||||
|
|
||||||
// Initialize world
|
|
||||||
InitWorld()
|
|
||||||
|
|
||||||
return g
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) LoadAssets() error {
|
func (g *Game) LoadAssets() error {
|
||||||
return SafeExecute(func() error {
|
|
||||||
// Load models
|
|
||||||
var err error
|
var err error
|
||||||
models, err := assets.LoadModels()
|
g.Models, err = assets.LoadModels()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: Failed to load models: %v", err)
|
return err
|
||||||
}
|
}
|
||||||
g.AssetManager.Models = models
|
|
||||||
|
|
||||||
// Try to load music
|
g.Music, err = assets.LoadMusic("resources/audio/GoonScape2.mp3")
|
||||||
music, err := assets.LoadMusic("resources/audio/GoonScape1.mp3")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: Failed to load music: %v", err)
|
return err
|
||||||
} else {
|
|
||||||
g.AssetManager.Music = music
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) Update(deltaTime float32) {
|
func (g *Game) Update(deltaTime float32) {
|
||||||
// Handle login screen if not logged in
|
if message, sent := g.Chat.Update(); sent {
|
||||||
if !g.UIManager.IsLoggedIn {
|
g.Player.Lock()
|
||||||
// Handle login
|
g.Player.ActionQueue = append(g.Player.ActionQueue, &pb.Action{
|
||||||
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.UIManager.MenuOpen = !g.UIManager.MenuOpen
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't process other inputs if menu is open
|
|
||||||
if g.UIManager.MenuOpen {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle chat updates
|
|
||||||
if message, sent := g.UIManager.Chat.Update(); sent {
|
|
||||||
g.PlayerManager.LocalPlayer.Lock()
|
|
||||||
g.PlayerManager.LocalPlayer.ActionQueue = append(g.PlayerManager.LocalPlayer.ActionQueue, &pb.Action{
|
|
||||||
Type: pb.Action_CHAT,
|
Type: pb.Action_CHAT,
|
||||||
ChatMessage: message,
|
ChatMessage: message,
|
||||||
PlayerId: g.PlayerManager.LocalPlayer.ID,
|
PlayerId: g.Player.ID,
|
||||||
})
|
})
|
||||||
g.PlayerManager.LocalPlayer.Unlock()
|
g.Player.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process player input
|
|
||||||
g.HandleInput()
|
g.HandleInput()
|
||||||
|
|
||||||
// Update local player movement
|
if len(g.Player.TargetPath) > 0 {
|
||||||
if g.PlayerManager.LocalPlayer.TargetPath != nil && len(g.PlayerManager.LocalPlayer.TargetPath) > 0 {
|
g.Player.MoveTowards(g.Player.TargetPath[0], deltaTime, GetMapGrid())
|
||||||
g.PlayerManager.LocalPlayer.MoveTowards(g.PlayerManager.LocalPlayer.TargetPath[0], deltaTime, GetMapGrid())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Periodically log information about other players
|
for _, other := range g.OtherPlayers {
|
||||||
g.frameCounter++
|
if len(other.TargetPath) > 0 {
|
||||||
if g.frameCounter%300 == 0 {
|
other.MoveTowards(other.TargetPath[0], deltaTime, GetMapGrid())
|
||||||
rl.TraceLog(rl.LogInfo, "There are %d other players", len(g.PlayerManager.OtherPlayers))
|
|
||||||
for id, other := range g.PlayerManager.OtherPlayers {
|
|
||||||
if other != nil {
|
|
||||||
// Calculate tile coordinates from absolute position
|
|
||||||
tileX := int(other.PosActual.X / float32(types.TileSize))
|
|
||||||
tileY := int(other.PosActual.Z / float32(types.TileSize))
|
|
||||||
|
|
||||||
rl.TraceLog(rl.LogInfo, "Other player ID: %d, Position: (%f, %f, %f), Tile: (%d, %d), Has model: %v",
|
|
||||||
id, other.PosActual.X, other.PosActual.Y, other.PosActual.Z,
|
|
||||||
tileX, tileY, other.Model.Meshes != nil)
|
|
||||||
} else {
|
|
||||||
rl.TraceLog(rl.LogInfo, "Other player ID: %d is nil", id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process other players
|
UpdateCamera(&g.Camera, g.Player.PosActual, deltaTime)
|
||||||
for _, other := range g.PlayerManager.OtherPlayers {
|
|
||||||
if other == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if other.TargetPath != nil && len(other.TargetPath) > 0 {
|
|
||||||
target := other.TargetPath[0]
|
|
||||||
other.MoveTowards(target, deltaTime, GetMapGrid())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign model if needed
|
|
||||||
if other.Model.Meshes == nil {
|
|
||||||
g.AssignModelToPlayer(other)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update camera position
|
|
||||||
UpdateCamera(&g.Camera, g.PlayerManager.LocalPlayer.PosActual, deltaTime)
|
|
||||||
|
|
||||||
// Update music if available
|
|
||||||
if g.AssetManager.Music.Stream.Buffer != nil {
|
|
||||||
rl.UpdateMusicStream(g.AssetManager.Music)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) DrawMap() {
|
func (g *Game) DrawMap() {
|
||||||
@ -217,251 +113,113 @@ func (g *Game) DrawMap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) DrawPlayer(player *types.Player, model rl.Model) {
|
func (g *Game) DrawPlayer(player *types.Player, model rl.Model) {
|
||||||
if player == nil {
|
player.Lock()
|
||||||
return
|
defer player.Unlock()
|
||||||
}
|
|
||||||
|
|
||||||
// Get necessary data
|
grid := GetMapGrid()
|
||||||
modelIndex := int(player.ID) % len(g.AssetManager.Models)
|
|
||||||
if modelIndex < 0 || modelIndex >= len(g.AssetManager.Models) {
|
|
||||||
modelIndex = 0
|
|
||||||
}
|
|
||||||
modelAsset := g.AssetManager.Models[modelIndex]
|
|
||||||
|
|
||||||
// Calculate position
|
|
||||||
const defaultHeight = 8.0
|
|
||||||
playerPos := rl.Vector3{
|
playerPos := rl.Vector3{
|
||||||
X: player.PosActual.X,
|
X: player.PosActual.X,
|
||||||
Y: player.PosActual.Y + defaultHeight + modelAsset.YOffset,
|
Y: grid[player.PosTile.X][player.PosTile.Y].Height*types.TileHeight + 16.0,
|
||||||
Z: player.PosActual.Z,
|
Z: player.PosActual.Z,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple drawing with scale parameter
|
rl.DrawModel(model, playerPos, 16, rl.White)
|
||||||
var drawColor rl.Color = rl.White
|
|
||||||
if player.PlaceholderColor.A > 0 {
|
|
||||||
drawColor = player.PlaceholderColor
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw the model at normal scale (16.0)
|
if player.FloatingMessage != nil && time.Now().Before(player.FloatingMessage.ExpireTime) {
|
||||||
rl.DrawModel(model, playerPos, 16.0, drawColor)
|
screenPos := rl.GetWorldToScreen(rl.Vector3{
|
||||||
|
|
||||||
// Update floating message position
|
|
||||||
if player.FloatingMessage != nil {
|
|
||||||
worldPos := rl.Vector3{
|
|
||||||
X: playerPos.X,
|
X: playerPos.X,
|
||||||
Y: playerPos.Y + 24.0, // Position above head
|
Y: playerPos.Y + 24.0,
|
||||||
Z: playerPos.Z,
|
Z: playerPos.Z,
|
||||||
}
|
}, g.Camera)
|
||||||
player.FloatingMessage.ScreenPos = rl.GetWorldToScreen(worldPos, g.Camera)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *Game) DrawFloatingMessages() {
|
player.FloatingMessage.ScreenPos = screenPos
|
||||||
var drawFloatingMessage = func(msg *types.FloatingMessage) {
|
} else if player.FloatingMessage != nil {
|
||||||
if msg == nil || time.Now().After(msg.ExpireTime) {
|
player.FloatingMessage = nil
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw the message with RuneScape-style coloring (black outline with yellow text)
|
if len(player.TargetPath) > 0 {
|
||||||
text := msg.Content
|
targetTile := player.TargetPath[len(player.TargetPath)-1]
|
||||||
textWidth := rl.MeasureText(text, 20)
|
targetPos := rl.Vector3{
|
||||||
|
X: float32(targetTile.X * types.TileSize),
|
||||||
|
Y: grid[targetTile.X][targetTile.Y].Height * types.TileHeight,
|
||||||
|
Z: float32(targetTile.Y * types.TileSize),
|
||||||
|
}
|
||||||
|
rl.DrawCubeWires(targetPos, types.TileSize, types.TileHeight, types.TileSize, rl.Green)
|
||||||
|
|
||||||
// Draw black outline by offsetting the text slightly in all directions
|
nextTile := player.TargetPath[0]
|
||||||
for offsetX := -2; offsetX <= 2; offsetX++ {
|
nextPos := rl.Vector3{
|
||||||
for offsetY := -2; offsetY <= 2; offsetY++ {
|
X: float32(nextTile.X * types.TileSize),
|
||||||
rl.DrawText(text,
|
Y: grid[nextTile.X][nextTile.Y].Height * types.TileHeight,
|
||||||
int32(msg.ScreenPos.X)-textWidth/2+int32(offsetX),
|
Z: float32(nextTile.Y * types.TileSize),
|
||||||
int32(msg.ScreenPos.Y)+int32(offsetY),
|
|
||||||
20,
|
|
||||||
rl.Black)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw the yellow text on top
|
|
||||||
rl.DrawText(text, int32(msg.ScreenPos.X)-textWidth/2, int32(msg.ScreenPos.Y), 20, rl.Yellow)
|
|
||||||
}
|
|
||||||
|
|
||||||
if g.PlayerManager.LocalPlayer != nil && g.PlayerManager.LocalPlayer.FloatingMessage != nil {
|
|
||||||
drawFloatingMessage(g.PlayerManager.LocalPlayer.FloatingMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, other := range g.PlayerManager.OtherPlayers {
|
|
||||||
if other != nil && other.FloatingMessage != nil {
|
|
||||||
drawFloatingMessage(other.FloatingMessage)
|
|
||||||
}
|
}
|
||||||
|
rl.DrawCubeWires(nextPos, types.TileSize, types.TileHeight, types.TileSize, rl.Yellow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) Render() {
|
func (g *Game) Render() {
|
||||||
rl.BeginDrawing()
|
rl.BeginDrawing()
|
||||||
defer rl.EndDrawing()
|
|
||||||
|
|
||||||
rl.ClearBackground(rl.RayWhite)
|
rl.ClearBackground(rl.RayWhite)
|
||||||
|
|
||||||
if !g.UIManager.IsLoggedIn {
|
|
||||||
g.UIManager.LoginScreen.Draw()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw 3D elements
|
|
||||||
rl.BeginMode3D(g.Camera)
|
rl.BeginMode3D(g.Camera)
|
||||||
g.DrawMap()
|
g.DrawMap()
|
||||||
|
g.DrawPlayer(g.Player, g.Player.Model)
|
||||||
// Draw player only if valid
|
for id, other := range g.OtherPlayers {
|
||||||
if g.PlayerManager.LocalPlayer != nil && g.PlayerManager.LocalPlayer.Model.Meshes != nil {
|
g.DrawPlayer(other, g.Models[int(id)%len(g.Models)].Model)
|
||||||
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()
|
rl.EndMode3D()
|
||||||
|
|
||||||
// Draw floating messages with RuneScape style
|
drawFloatingMessage := func(msg *types.FloatingMessage) {
|
||||||
g.DrawFloatingMessages()
|
if msg == nil || time.Now().After(msg.ExpireTime) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pos := msg.ScreenPos
|
||||||
|
text := msg.Content
|
||||||
|
textWidth := rl.MeasureText(text, 20)
|
||||||
|
|
||||||
// Draw menu if open
|
for offsetX := -2; offsetX <= 2; offsetX++ {
|
||||||
if g.UIManager.MenuOpen {
|
for offsetY := -2; offsetY <= 2; offsetY++ {
|
||||||
g.DrawMenu()
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only draw chat if menu is not open
|
if g.Player.FloatingMessage != nil {
|
||||||
if !g.UIManager.MenuOpen && g.UIManager.Chat != nil {
|
drawFloatingMessage(g.Player.FloatingMessage)
|
||||||
g.UIManager.Chat.Draw(int32(rl.GetScreenWidth()), int32(rl.GetScreenHeight()))
|
}
|
||||||
|
|
||||||
|
for _, other := range g.OtherPlayers {
|
||||||
|
drawFloatingMessage(other.FloatingMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw FPS counter
|
|
||||||
rl.DrawFPS(10, 10)
|
rl.DrawFPS(10, 10)
|
||||||
|
g.Chat.Draw(int32(rl.GetScreenWidth()), int32(rl.GetScreenHeight()))
|
||||||
|
rl.EndDrawing()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) Cleanup() {
|
func (g *Game) Cleanup() {
|
||||||
g.cleanupOnce.Do(func() {
|
assets.UnloadModels(g.Models)
|
||||||
// Cleanup models
|
assets.UnloadMusic(g.Music)
|
||||||
for _, model := range g.AssetManager.Models {
|
|
||||||
rl.UnloadModel(model.Model)
|
|
||||||
if model.Texture.ID > 0 {
|
|
||||||
rl.UnloadTexture(model.Texture)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unload music
|
|
||||||
if g.AssetManager.Music.Stream.Buffer != nil {
|
|
||||||
rl.UnloadMusicStream(g.AssetManager.Music)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only close the channel if it hasn't been closed yet
|
|
||||||
select {
|
|
||||||
case <-g.quitChan:
|
|
||||||
// Channel already closed, do nothing
|
|
||||||
default:
|
|
||||||
close(g.quitChan)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) HandleInput() {
|
func (g *Game) HandleInput() {
|
||||||
clickedTile, clicked := g.GetTileAtMouse()
|
clickedTile, clicked := g.GetTileAtMouse()
|
||||||
if clicked {
|
if clicked {
|
||||||
path := FindPath(GetTile(g.PlayerManager.LocalPlayer.PosTile.X, g.PlayerManager.LocalPlayer.PosTile.Y), clickedTile)
|
path := FindPath(GetTile(g.Player.PosTile.X, g.Player.PosTile.Y), clickedTile)
|
||||||
if len(path) > 1 {
|
if len(path) > 1 {
|
||||||
g.PlayerManager.LocalPlayer.Lock()
|
g.Player.Lock()
|
||||||
g.PlayerManager.LocalPlayer.TargetPath = path[1:]
|
g.Player.TargetPath = path[1:]
|
||||||
g.PlayerManager.LocalPlayer.ActionQueue = append(g.PlayerManager.LocalPlayer.ActionQueue, &pb.Action{
|
g.Player.ActionQueue = append(g.Player.ActionQueue, &pb.Action{
|
||||||
Type: pb.Action_MOVE,
|
Type: pb.Action_MOVE,
|
||||||
X: int32(clickedTile.X),
|
X: int32(clickedTile.X),
|
||||||
Y: int32(clickedTile.Y),
|
Y: int32(clickedTile.Y),
|
||||||
PlayerId: g.PlayerManager.LocalPlayer.ID,
|
PlayerId: g.Player.ID,
|
||||||
})
|
})
|
||||||
g.PlayerManager.LocalPlayer.Unlock()
|
g.Player.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) DrawMenu() {
|
|
||||||
screenWidth := float32(rl.GetScreenWidth())
|
|
||||||
screenHeight := float32(rl.GetScreenHeight())
|
|
||||||
|
|
||||||
// Draw semi-transparent background
|
|
||||||
rl.DrawRectangle(0, 0, int32(screenWidth), int32(screenHeight), rl.ColorAlpha(rl.Black, 0.7))
|
|
||||||
|
|
||||||
// Draw menu items
|
|
||||||
menuItems := []string{"Resume", "Settings", "Quit"}
|
|
||||||
menuY := screenHeight/2 - float32(len(menuItems)*40)/2
|
|
||||||
|
|
||||||
for i, item := range menuItems {
|
|
||||||
itemY := menuY + float32(i*40)
|
|
||||||
mousePoint := rl.GetMousePosition()
|
|
||||||
itemRect := rl.Rectangle{X: screenWidth/2 - 100, Y: itemY, Width: 200, Height: 36}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
textWidth := rl.MeasureText(item, 20)
|
|
||||||
rl.DrawText(item, int32(itemRect.X+(itemRect.Width-float32(textWidth))/2), int32(itemRect.Y+8), 20, rl.White)
|
|
||||||
|
|
||||||
// Handle click
|
|
||||||
if isHover && rl.IsMouseButtonReleased(rl.MouseLeftButton) {
|
|
||||||
switch item {
|
|
||||||
case "Resume":
|
|
||||||
g.UIManager.MenuOpen = false
|
|
||||||
case "Settings":
|
|
||||||
// TODO: Implement settings
|
|
||||||
case "Quit":
|
|
||||||
g.Shutdown()
|
|
||||||
rl.CloseWindow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
185
game/login.go
@ -1,185 +0,0 @@
|
|||||||
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,157 +1,91 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"container/heap"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.boner.be/bdnugget/goonscape/types"
|
"gitea.boner.be/bdnugget/goonscape/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Node represents a node in the A* pathfinding algorithm
|
|
||||||
type Node struct {
|
type Node struct {
|
||||||
Tile types.Tile
|
Tile types.Tile
|
||||||
Parent *Node
|
Parent *Node
|
||||||
G, H, F float32 // G = cost from start, H = heuristic to goal, F = G + H
|
G, H, F float32
|
||||||
}
|
}
|
||||||
|
|
||||||
// PriorityQueue implements a min-heap for nodes ordered by F value
|
|
||||||
type PriorityQueue []*Node
|
|
||||||
|
|
||||||
// Implement the heap.Interface for PriorityQueue
|
|
||||||
func (pq PriorityQueue) Len() int { return len(pq) }
|
|
||||||
|
|
||||||
func (pq PriorityQueue) Less(i, j int) bool {
|
|
||||||
return pq[i].F < pq[j].F
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pq PriorityQueue) Swap(i, j int) {
|
|
||||||
pq[i], pq[j] = pq[j], pq[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pq *PriorityQueue) Push(x interface{}) {
|
|
||||||
item := x.(*Node)
|
|
||||||
*pq = append(*pq, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pq *PriorityQueue) Pop() interface{} {
|
|
||||||
old := *pq
|
|
||||||
n := len(old)
|
|
||||||
item := old[n-1]
|
|
||||||
*pq = old[0 : n-1]
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to check if tile is in priority queue
|
|
||||||
func isInQueue(queue *PriorityQueue, tile types.Tile) (bool, *Node) {
|
|
||||||
for _, node := range *queue {
|
|
||||||
if node.Tile.X == tile.X && node.Tile.Y == tile.Y {
|
|
||||||
return true, node
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindPath implements A* pathfinding algorithm with a priority queue
|
|
||||||
func FindPath(start, end types.Tile) []types.Tile {
|
func FindPath(start, end types.Tile) []types.Tile {
|
||||||
// Initialize open and closed sets
|
openList := []*Node{}
|
||||||
openSet := &PriorityQueue{}
|
closedList := make(map[[2]int]bool)
|
||||||
heap.Init(openSet)
|
|
||||||
|
|
||||||
closedSet := make(map[[2]int]bool)
|
startNode := &Node{Tile: start, G: 0, H: heuristic(start, end)}
|
||||||
|
|
||||||
// Create start node and add to open set
|
|
||||||
startNode := &Node{
|
|
||||||
Tile: start,
|
|
||||||
Parent: nil,
|
|
||||||
G: 0,
|
|
||||||
H: heuristic(start, end),
|
|
||||||
}
|
|
||||||
startNode.F = startNode.G + startNode.H
|
startNode.F = startNode.G + startNode.H
|
||||||
heap.Push(openSet, startNode)
|
openList = append(openList, startNode)
|
||||||
|
|
||||||
// Main search loop
|
for len(openList) > 0 {
|
||||||
for openSet.Len() > 0 {
|
current := openList[0]
|
||||||
// Get node with lowest F score
|
currentIndex := 0
|
||||||
current := heap.Pop(openSet).(*Node)
|
for i, node := range openList {
|
||||||
|
if node.F < current.F {
|
||||||
// If we reached the goal, reconstruct and return the path
|
current = node
|
||||||
if current.Tile.X == end.X && current.Tile.Y == end.Y {
|
currentIndex = i
|
||||||
return reconstructPath(current)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add current to closed set
|
openList = append(openList[:currentIndex], openList[currentIndex+1:]...)
|
||||||
closedSet[[2]int{current.Tile.X, current.Tile.Y}] = true
|
closedList[[2]int{current.Tile.X, current.Tile.Y}] = true
|
||||||
|
|
||||||
// Check all neighbors
|
if current.Tile.X == end.X && current.Tile.Y == end.Y {
|
||||||
for _, neighbor := range GetNeighbors(current.Tile) {
|
path := []types.Tile{}
|
||||||
// Skip if in closed set or not walkable
|
node := current
|
||||||
if !neighbor.Walkable || closedSet[[2]int{neighbor.X, neighbor.Y}] {
|
for node != nil {
|
||||||
|
path = append([]types.Tile{node.Tile}, path...)
|
||||||
|
node = node.Parent
|
||||||
|
}
|
||||||
|
fmt.Printf("Path found: %v\n", path)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
neighbors := GetNeighbors(current.Tile)
|
||||||
|
for _, neighbor := range neighbors {
|
||||||
|
if !neighbor.Walkable || closedList[[2]int{neighbor.X, neighbor.Y}] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate tentative G score
|
|
||||||
tentativeG := current.G + distance(current.Tile, neighbor)
|
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 {
|
if !inOpen || tentativeG < existingNode.G {
|
||||||
// Create or update the node
|
newNode := &Node{
|
||||||
var neighborNode *Node
|
|
||||||
if inOpen {
|
|
||||||
neighborNode = existingNode
|
|
||||||
} else {
|
|
||||||
neighborNode = &Node{
|
|
||||||
Tile: neighbor,
|
Tile: neighbor,
|
||||||
Parent: current,
|
Parent: current,
|
||||||
|
G: tentativeG,
|
||||||
|
H: heuristic(neighbor, end),
|
||||||
}
|
}
|
||||||
}
|
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 {
|
if !inOpen {
|
||||||
heap.Push(openSet, neighborNode)
|
openList = append(openList, newNode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No path found
|
|
||||||
return nil
|
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 {
|
func heuristic(a, b types.Tile) float32 {
|
||||||
return float32(abs(a.X-b.X) + abs(a.Y-b.Y))
|
return float32(abs(a.X-b.X) + abs(a.Y-b.Y))
|
||||||
}
|
}
|
||||||
|
|
||||||
// distance calculates cost between adjacent tiles
|
|
||||||
func distance(a, b types.Tile) float32 {
|
func distance(a, b types.Tile) float32 {
|
||||||
return 1.0 // uniform cost for now
|
return 1.0 // uniform cost for now
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNeighbors returns walkable tiles adjacent to the given tile
|
|
||||||
func GetNeighbors(tile types.Tile) []types.Tile {
|
func GetNeighbors(tile types.Tile) []types.Tile {
|
||||||
directions := [][2]int{
|
directions := [][2]int{
|
||||||
{1, 0}, {-1, 0}, {0, 1}, {0, -1},
|
{1, 0}, {-1, 0}, {0, 1}, {0, -1},
|
||||||
@ -170,7 +104,6 @@ func GetNeighbors(tile types.Tile) []types.Tile {
|
|||||||
return neighbors
|
return neighbors
|
||||||
}
|
}
|
||||||
|
|
||||||
// abs returns the absolute value of x
|
|
||||||
func abs(x int) int {
|
func abs(x int) int {
|
||||||
if x < 0 {
|
if x < 0 {
|
||||||
return -x
|
return -x
|
||||||
|
@ -1,36 +1,9 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"runtime/debug"
|
|
||||||
|
|
||||||
rl "github.com/gen2brain/raylib-go/raylib"
|
rl "github.com/gen2brain/raylib-go/raylib"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SafeExecute runs a function and recovers from panics
|
|
||||||
func SafeExecute(action func() error) (err error) {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
stack := debug.Stack()
|
|
||||||
log.Printf("Recovered from panic: %v\nStack trace:\n%s", r, stack)
|
|
||||||
err = fmt.Errorf("recovered from panic: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return action()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SafeExecuteVoid runs a void function and recovers from panics
|
|
||||||
func SafeExecuteVoid(action func()) {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
stack := debug.Stack()
|
|
||||||
log.Printf("Recovered from panic: %v\nStack trace:\n%s", r, stack)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
action()
|
|
||||||
}
|
|
||||||
|
|
||||||
func RayIntersectsBox(ray rl.Ray, boxMin, boxMax rl.Vector3) bool {
|
func RayIntersectsBox(ray rl.Ray, boxMin, boxMax rl.Vector3) bool {
|
||||||
tmin := (boxMin.X - ray.Position.X) / ray.Direction.X
|
tmin := (boxMin.X - ray.Position.X) / ray.Direction.X
|
||||||
tmax := (boxMax.X - ray.Position.X) / ray.Direction.X
|
tmax := (boxMax.X - ray.Position.X) / ray.Direction.X
|
||||||
|
12
go.mod
12
go.mod
@ -3,15 +3,15 @@ module gitea.boner.be/bdnugget/goonscape
|
|||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.boner.be/bdnugget/goonserver v1.1.0
|
gitea.boner.be/bdnugget/goonserver v0.0.0-20241011195320-f16e8647dc6b
|
||||||
github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b
|
github.com/gen2brain/raylib-go/raylib v0.0.0-20240930075631-c66f9e2942fe
|
||||||
google.golang.org/protobuf v1.36.3
|
google.golang.org/protobuf v1.35.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/ebitengine/purego v0.8.2 // indirect
|
github.com/ebitengine/purego v0.8.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
|
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
|
||||||
golang.org/x/sys v0.29.0 // indirect
|
golang.org/x/sys v0.26.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
replace gitea.boner.be/bdnugget/goonserver => ./goonserver
|
replace gitea.boner.be/bdnugget/goonserver => ./goonserver
|
||||||
|
20
go.sum
20
go.sum
@ -1,12 +1,12 @@
|
|||||||
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE=
|
||||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b h1:JJfspevP3YOXcSKVABizYOv++yMpTJIdPUtoDzF/RWw=
|
github.com/gen2brain/raylib-go/raylib v0.0.0-20240930075631-c66f9e2942fe h1:mInjrbJkUglTM7tBmXG+epnPCE744aj15J7vjJwM4gs=
|
||||||
github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q=
|
github.com/gen2brain/raylib-go/raylib v0.0.0-20240930075631-c66f9e2942fe/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
|
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
|
||||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
|
||||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
|
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
||||||
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
|
@ -1 +1 @@
|
|||||||
Subproject commit 00aa3022292b8f4eec6c01522b6a91cf6769155b
|
Subproject commit 368fbdbc4743ad5d571789e5ebb7f76cfb964743
|
148
main.go
148
main.go
@ -1,13 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.boner.be/bdnugget/goonscape/game"
|
"gitea.boner.be/bdnugget/goonscape/game"
|
||||||
"gitea.boner.be/bdnugget/goonscape/network"
|
"gitea.boner.be/bdnugget/goonscape/network"
|
||||||
@ -15,139 +9,39 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Set up panic recovery at the top level
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Printf("Recovered from fatal panic in main: %v", r)
|
|
||||||
// Give the user a chance to see the error
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Parse command line flags
|
|
||||||
verbose := flag.Bool("v", false, "Also show info logs (spammy)")
|
|
||||||
local := flag.Bool("local", false, "Connect to local server")
|
|
||||||
addr := flag.String("addr", "", "Server address (host or host:port)")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if *verbose {
|
|
||||||
rl.SetTraceLogLevel(rl.LogTrace)
|
|
||||||
} else {
|
|
||||||
rl.SetTraceLogLevel(rl.LogWarning)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set server address based on flags
|
|
||||||
if *local {
|
|
||||||
if *addr != "" {
|
|
||||||
log.Fatal("Cannot use -local and -addr together")
|
|
||||||
}
|
|
||||||
network.SetServerAddr("localhost:6969")
|
|
||||||
} else if *addr != "" {
|
|
||||||
// If port is not specified, append default port
|
|
||||||
if !strings.Contains(*addr, ":") {
|
|
||||||
*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.InitWindow(1024, 768, "GoonScape")
|
||||||
|
defer rl.CloseWindow()
|
||||||
rl.SetExitKey(0)
|
|
||||||
|
|
||||||
// Initialize audio with error handling
|
|
||||||
if !rl.IsAudioDeviceReady() {
|
|
||||||
rl.InitAudioDevice()
|
rl.InitAudioDevice()
|
||||||
if !rl.IsAudioDeviceReady() {
|
defer rl.CloseAudioDevice()
|
||||||
log.Println("Warning: Failed to initialize audio device, continuing without audio")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a maximum of 3 attempts to load assets
|
game := game.New()
|
||||||
var gameInstance *game.Game
|
if err := game.LoadAssets(); err != nil {
|
||||||
var loadErr error
|
log.Fatalf("Failed to load assets: %v", err)
|
||||||
maxAttempts := 3
|
}
|
||||||
|
defer game.Cleanup()
|
||||||
|
|
||||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
conn, playerID, err := network.ConnectToServer()
|
||||||
gameInstance = game.New()
|
if err != nil {
|
||||||
loadErr = gameInstance.LoadAssets()
|
log.Fatalf("Failed to connect to server: %v", err)
|
||||||
if loadErr == nil {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
log.Printf("Attempt %d/%d: Failed to load assets: %v", attempt, maxAttempts, loadErr)
|
game.Player.ID = playerID
|
||||||
if attempt < maxAttempts {
|
modelIndex := int(playerID) % len(game.Models)
|
||||||
log.Println("Retrying...")
|
game.Player.Model = game.Models[modelIndex].Model
|
||||||
gameInstance.Cleanup() // Cleanup before retrying
|
game.Player.Texture = game.Models[modelIndex].Texture
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if loadErr != nil {
|
go network.HandleServerCommunication(conn, playerID, game.Player, game.OtherPlayers)
|
||||||
log.Printf("Failed to load assets after %d attempts. Starting with default assets.", maxAttempts)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
if gameInstance != nil {
|
|
||||||
gameInstance.Cleanup()
|
|
||||||
}
|
|
||||||
rl.CloseWindow()
|
|
||||||
if rl.IsAudioDeviceReady() {
|
|
||||||
rl.CloseAudioDevice()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
|
rl.PlayMusicStream(game.Music)
|
||||||
|
rl.SetMusicVolume(game.Music, 0.5)
|
||||||
rl.SetTargetFPS(60)
|
rl.SetTargetFPS(60)
|
||||||
|
|
||||||
// Play music if available
|
|
||||||
if gameInstance.AssetManager.Music.Stream.Buffer != nil {
|
|
||||||
rl.PlayMusicStream(gameInstance.AssetManager.Music)
|
|
||||||
rl.SetMusicVolume(gameInstance.AssetManager.Music, 0.5)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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() {
|
for !rl.WindowShouldClose() {
|
||||||
|
rl.UpdateMusicStream(game.Music)
|
||||||
deltaTime := rl.GetFrameTime()
|
deltaTime := rl.GetFrameTime()
|
||||||
|
|
||||||
// Update music if available
|
game.Update(deltaTime)
|
||||||
if gameInstance.AssetManager.Music.Stream.Buffer != nil {
|
game.Render()
|
||||||
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:
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,121 +1,100 @@
|
|||||||
package network
|
package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.boner.be/bdnugget/goonscape/game"
|
||||||
"gitea.boner.be/bdnugget/goonscape/types"
|
"gitea.boner.be/bdnugget/goonscape/types"
|
||||||
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
||||||
rl "github.com/gen2brain/raylib-go/raylib"
|
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
const protoVersion = 1
|
func ConnectToServer() (net.Conn, int32, error) {
|
||||||
|
conn, err := net.Dial("tcp", types.ServerAddr)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MessageHandler handles reading and writing protobuf messages
|
|
||||||
type MessageHandler struct {
|
|
||||||
conn net.Conn
|
|
||||||
reader *bufio.Reader
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMessageHandler creates a new message handler
|
|
||||||
func NewMessageHandler(conn net.Conn) *MessageHandler {
|
|
||||||
return &MessageHandler{
|
|
||||||
conn: conn,
|
|
||||||
reader: bufio.NewReader(conn),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadMessage reads a single message from the network
|
|
||||||
func (mh *MessageHandler) ReadMessage() (*pb.ServerMessage, error) {
|
|
||||||
// Read message length
|
|
||||||
lengthBuf := make([]byte, 4)
|
|
||||||
if _, err := io.ReadFull(mh.reader, lengthBuf); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read message length: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
messageLength := binary.BigEndian.Uint32(lengthBuf)
|
|
||||||
|
|
||||||
// Sanity check message size
|
|
||||||
if messageLength > 1024*1024 { // 1MB max message size
|
|
||||||
return nil, fmt.Errorf("message size too large: %d bytes", messageLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read message body
|
|
||||||
messageBuf := make([]byte, messageLength)
|
|
||||||
if _, err := io.ReadFull(mh.reader, messageBuf); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read message body: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unmarshal the message
|
|
||||||
var message pb.ServerMessage
|
|
||||||
if err := proto.Unmarshal(messageBuf, &message); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal message: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &message, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteMessage writes a protobuf message to the network
|
|
||||||
func (mh *MessageHandler) WriteMessage(msg proto.Message) error {
|
|
||||||
data, err := proto.Marshal(msg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
log.Printf("Failed to dial server: %v", err)
|
||||||
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write length prefix
|
log.Println("Connected to server. Waiting for player ID...")
|
||||||
lengthBuf := make([]byte, 4)
|
buf := make([]byte, 1024)
|
||||||
binary.BigEndian.PutUint32(lengthBuf, uint32(len(data)))
|
n, err := conn.Read(buf)
|
||||||
if _, err := mh.conn.Write(lengthBuf); err != nil {
|
if err != nil {
|
||||||
return err
|
log.Printf("Error reading player ID from server: %v", err)
|
||||||
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write message body
|
var response pb.ServerMessage
|
||||||
_, err = mh.conn.Write(data)
|
if err := proto.Unmarshal(buf[:n], &response); err != nil {
|
||||||
return err
|
log.Printf("Failed to unmarshal server response: %v", err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID := response.GetPlayerId()
|
||||||
|
log.Printf("Successfully connected with player ID: %d", playerID)
|
||||||
|
return conn, playerID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateGameState processes a server message and updates game state
|
func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Player, otherPlayers map[int32]*types.Player) {
|
||||||
func UpdateGameState(serverMessage *pb.ServerMessage, player *types.Player, otherPlayers map[int32]*types.Player) {
|
buf := make([]byte, 4096)
|
||||||
// Safety check for nil inputs
|
|
||||||
if serverMessage == nil {
|
actionTicker := time.NewTicker(types.ClientTickRate)
|
||||||
log.Printf("Warning: Received nil server message")
|
defer actionTicker.Stop()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for range actionTicker.C {
|
||||||
|
player.Lock()
|
||||||
|
if len(player.ActionQueue) > 0 {
|
||||||
|
actions := make([]*pb.Action, len(player.ActionQueue))
|
||||||
|
copy(actions, player.ActionQueue)
|
||||||
|
|
||||||
|
batch := &pb.ActionBatch{
|
||||||
|
PlayerId: playerID,
|
||||||
|
Actions: actions,
|
||||||
|
Tick: player.CurrentTick,
|
||||||
|
}
|
||||||
|
|
||||||
|
player.ActionQueue = player.ActionQueue[:0]
|
||||||
|
player.Unlock()
|
||||||
|
|
||||||
|
data, err := proto.Marshal(batch)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to marshal action batch: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = conn.Write(data); err != nil {
|
||||||
|
log.Printf("Failed to send actions to server: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
player.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to read from server: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if player == nil {
|
var serverMessage pb.ServerMessage
|
||||||
log.Printf("Warning: Local player is nil when updating game state")
|
if err := proto.Unmarshal(buf[:n], &serverMessage); err != nil {
|
||||||
return
|
log.Printf("Failed to unmarshal server message: %v", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if otherPlayers == nil {
|
|
||||||
log.Printf("Warning: otherPlayers map is nil when updating game state")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
playerID := player.ID
|
|
||||||
|
|
||||||
player.Lock()
|
player.Lock()
|
||||||
player.CurrentTick = serverMessage.CurrentTick
|
player.CurrentTick = serverMessage.CurrentTick
|
||||||
|
|
||||||
tickDiff := serverMessage.CurrentTick - player.CurrentTick
|
tickDiff := serverMessage.CurrentTick - player.CurrentTick
|
||||||
if tickDiff > types.MaxTickDesync {
|
if tickDiff > types.MaxTickDesync {
|
||||||
for _, state := range serverMessage.Players {
|
for _, state := range serverMessage.Players {
|
||||||
if state != nil && state.PlayerId == playerID {
|
if state.PlayerId == playerID {
|
||||||
player.ForceResync(state)
|
player.ForceResync(state)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -123,417 +102,20 @@ func UpdateGameState(serverMessage *pb.ServerMessage, player *types.Player, othe
|
|||||||
}
|
}
|
||||||
player.Unlock()
|
player.Unlock()
|
||||||
|
|
||||||
// Process player states
|
|
||||||
validPlayerIds := make(map[int32]bool)
|
|
||||||
for _, state := range serverMessage.Players {
|
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 {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update or create other players
|
|
||||||
if otherPlayer, exists := otherPlayers[state.PlayerId]; exists {
|
if otherPlayer, exists := otherPlayers[state.PlayerId]; exists {
|
||||||
if otherPlayer != nil {
|
|
||||||
otherPlayer.UpdatePosition(state, types.ServerTickRate)
|
otherPlayer.UpdatePosition(state, types.ServerTickRate)
|
||||||
} else {
|
} 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)
|
otherPlayers[state.PlayerId] = types.NewPlayer(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove players no longer in the server state
|
if g, ok := player.UserData.(*game.Game); ok && len(serverMessage.ChatMessages) > 0 {
|
||||||
for id := range otherPlayers {
|
g.Chat.HandleServerMessages(serverMessage.ChatMessages)
|
||||||
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 authenticated with player ID: %d", playerID)
|
|
||||||
|
|
||||||
// Reset the lastSeenMessageTimestamp when reconnecting
|
|
||||||
lastSeenMessageTimestamp = 0
|
|
||||||
|
|
||||||
return conn, playerID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Player, otherPlayers map[int32]*types.Player, quitChan <-chan struct{}) {
|
|
||||||
msgHandler := NewMessageHandler(conn)
|
|
||||||
|
|
||||||
// Create channels for coordinating goroutines
|
|
||||||
errChan := make(chan error, 1)
|
|
||||||
done := make(chan struct{})
|
|
||||||
|
|
||||||
// Create a WaitGroup to track both sender and receiver goroutines
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(2) // One for sender, one for receiver
|
|
||||||
|
|
||||||
// Set up a deferred cleanup function
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Printf("Recovered from panic in HandleServerCommunication: %v", r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close the done channel to signal both goroutines to exit
|
|
||||||
close(done)
|
|
||||||
|
|
||||||
// Wait for both goroutines to finish
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
// Close the connection
|
|
||||||
conn.Close()
|
|
||||||
|
|
||||||
// Close the player's QuitDone channel if it exists
|
|
||||||
if player.QuitDone != nil {
|
|
||||||
select {
|
|
||||||
case <-player.QuitDone: // Check if it's already closed
|
|
||||||
// Already closed, do nothing
|
|
||||||
default:
|
|
||||||
close(player.QuitDone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
actionTicker := time.NewTicker(types.ClientTickRate)
|
|
||||||
defer actionTicker.Stop()
|
|
||||||
|
|
||||||
// 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:
|
|
||||||
// Send disconnect message to server
|
|
||||||
disconnectMsg := &pb.ActionBatch{
|
|
||||||
PlayerId: playerID,
|
|
||||||
Actions: []*pb.Action{{
|
|
||||||
Type: pb.Action_DISCONNECT,
|
|
||||||
PlayerId: playerID,
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
LastSeenMessageTimestamp: lastSeenMessageTimestamp,
|
|
||||||
}
|
|
||||||
player.ActionQueue = player.ActionQueue[:0]
|
|
||||||
player.Unlock()
|
|
||||||
|
|
||||||
if err := msgHandler.WriteMessage(batch); err != nil {
|
|
||||||
select {
|
|
||||||
case errChan <- err:
|
|
||||||
case <-done:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastMessageTime = time.Now()
|
|
||||||
} else {
|
|
||||||
player.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Main message receiving loop
|
|
||||||
go func() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Printf("Recovered from panic in message receiver: %v", r)
|
|
||||||
select {
|
|
||||||
case errChan <- fmt.Errorf("message receiver panic: %v", r):
|
|
||||||
default:
|
|
||||||
// Channel already closed or full, just log
|
|
||||||
log.Printf("Unable to send error: %v", r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wg.Done() // Mark this goroutine as done
|
|
||||||
}()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-quitChan:
|
|
||||||
return
|
|
||||||
case <-done:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
serverMessage, err := msgHandler.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
if err, ok := err.(net.Error); ok && err.Timeout() {
|
|
||||||
log.Printf("Network timeout: %v", err)
|
|
||||||
} else if err != io.EOF {
|
|
||||||
log.Printf("Network read error: %v", err)
|
|
||||||
select {
|
|
||||||
case errChan <- err:
|
|
||||||
case <-done:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Printf("Connection closed by server")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process the server message
|
|
||||||
UpdateGameState(serverMessage, player, otherPlayers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 104 KiB |
@ -1,27 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Main build process
|
|
||||||
build() {
|
|
||||||
local os=$1
|
|
||||||
local arch=$2
|
|
||||||
local output=$3
|
|
||||||
|
|
||||||
# Set GOOS and GOARCH for cross-compilation
|
|
||||||
export GOOS=$os
|
|
||||||
export GOARCH=$arch
|
|
||||||
|
|
||||||
# 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++
|
|
||||||
fi
|
|
||||||
|
|
||||||
go build -buildvcs=false -ldflags="-s -w" -o $output
|
|
||||||
}
|
|
||||||
|
|
||||||
# Call build with provided arguments
|
|
||||||
build "$1" "$2" "$3"
|
|
@ -1 +0,0 @@
|
|||||||
PLATFORMS=windows/amd64 linux/amd64
|
|
126
types/player.go
126
types/player.go
@ -1,98 +1,16 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
||||||
rl "github.com/gen2brain/raylib-go/raylib"
|
rl "github.com/gen2brain/raylib-go/raylib"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AnimationController manages animation state and updates
|
|
||||||
type AnimationController struct {
|
|
||||||
animations AnimationSet
|
|
||||||
currentAnimation string // "idle" or "walk"
|
|
||||||
frame int32
|
|
||||||
lastUpdate time.Time
|
|
||||||
frameCount int32
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAnimationController creates a new animation controller
|
|
||||||
func NewAnimationController(animations AnimationSet) *AnimationController {
|
|
||||||
return &AnimationController{
|
|
||||||
animations: animations,
|
|
||||||
currentAnimation: "idle",
|
|
||||||
frame: 0,
|
|
||||||
lastUpdate: time.Now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update updates the animation state based on movement
|
|
||||||
func (ac *AnimationController) Update(deltaTime float32, isMoving bool) {
|
|
||||||
// Set the current animation based on movement
|
|
||||||
newAnimation := "idle"
|
|
||||||
if isMoving {
|
|
||||||
newAnimation = "walk"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset frame counter when animation changes
|
|
||||||
if ac.currentAnimation != newAnimation {
|
|
||||||
ac.frame = 0
|
|
||||||
ac.currentAnimation = newAnimation
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the frame
|
|
||||||
ac.frame += int32(deltaTime * 60)
|
|
||||||
|
|
||||||
// Determine which animation set to use
|
|
||||||
var frames []rl.ModelAnimation
|
|
||||||
if ac.currentAnimation == "walk" && len(ac.animations.Walk) > 0 {
|
|
||||||
frames = ac.animations.Walk
|
|
||||||
} else if len(ac.animations.Idle) > 0 {
|
|
||||||
frames = ac.animations.Idle
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have frames, ensure we loop properly
|
|
||||||
if len(frames) > 0 && frames[0].FrameCount > 0 {
|
|
||||||
ac.frame = ac.frame % frames[0].FrameCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAnimFrame returns the current animation frame
|
|
||||||
func (ac *AnimationController) GetAnimFrame() int32 {
|
|
||||||
return ac.frame
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrentAnimation returns the current animation type
|
|
||||||
func (ac *AnimationController) GetCurrentAnimation() string {
|
|
||||||
return ac.currentAnimation
|
|
||||||
}
|
|
||||||
|
|
||||||
type Player struct {
|
|
||||||
sync.RWMutex // Keep this for network operations
|
|
||||||
Model rl.Model
|
|
||||||
Texture rl.Texture2D
|
|
||||||
PosActual rl.Vector3
|
|
||||||
PosTile Tile
|
|
||||||
TargetPath []Tile
|
|
||||||
Speed float32
|
|
||||||
ActionQueue []*pb.Action
|
|
||||||
ID int32
|
|
||||||
QuitDone chan struct{}
|
|
||||||
CurrentTick int64
|
|
||||||
UserData interface{}
|
|
||||||
FloatingMessage *FloatingMessage
|
|
||||||
IsMoving bool
|
|
||||||
AnimationFrame int32
|
|
||||||
LastAnimUpdate time.Time
|
|
||||||
LastUpdateTime time.Time
|
|
||||||
InterpolationProgress float32
|
|
||||||
PlaceholderColor rl.Color
|
|
||||||
AnimController *AnimationController
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Player) MoveTowards(target Tile, deltaTime float32, mapGrid [][]Tile) {
|
func (p *Player) MoveTowards(target Tile, deltaTime float32, mapGrid [][]Tile) {
|
||||||
// No need for lock here as this is called from a single thread (game loop)
|
p.Lock()
|
||||||
|
defer p.Unlock()
|
||||||
|
|
||||||
targetPos := rl.Vector3{
|
targetPos := rl.Vector3{
|
||||||
X: float32(target.X * TileSize),
|
X: float32(target.X * TileSize),
|
||||||
Y: mapGrid[target.X][target.Y].Height * TileHeight,
|
Y: mapGrid[target.X][target.Y].Height * TileHeight,
|
||||||
@ -101,33 +19,6 @@ func (p *Player) MoveTowards(target Tile, deltaTime float32, mapGrid [][]Tile) {
|
|||||||
|
|
||||||
direction := rl.Vector3Subtract(targetPos, p.PosActual)
|
direction := rl.Vector3Subtract(targetPos, p.PosActual)
|
||||||
distance := rl.Vector3Length(direction)
|
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 {
|
if distance > 0 {
|
||||||
direction = rl.Vector3Scale(direction, p.Speed*deltaTime/distance)
|
direction = rl.Vector3Scale(direction, p.Speed*deltaTime/distance)
|
||||||
}
|
}
|
||||||
@ -153,19 +44,9 @@ func NewPlayer(state *pb.PlayerState) *Player {
|
|||||||
PosTile: Tile{X: int(state.X), Y: int(state.Y)},
|
PosTile: Tile{X: int(state.X), Y: int(state.Y)},
|
||||||
Speed: 50.0,
|
Speed: 50.0,
|
||||||
ID: state.PlayerId,
|
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) {
|
func (p *Player) UpdatePosition(state *pb.PlayerState, tickRate time.Duration) {
|
||||||
p.Lock()
|
p.Lock()
|
||||||
defer p.Unlock()
|
defer p.Unlock()
|
||||||
@ -180,7 +61,6 @@ func (p *Player) UpdatePosition(state *pb.PlayerState, tickRate time.Duration) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Player) ForceResync(state *pb.PlayerState) {
|
func (p *Player) ForceResync(state *pb.PlayerState) {
|
||||||
// Keep this lock since it's called from the network goroutine
|
|
||||||
p.Lock()
|
p.Lock()
|
||||||
defer p.Unlock()
|
defer p.Unlock()
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
||||||
@ -13,27 +14,30 @@ type Tile struct {
|
|||||||
Walkable bool
|
Walkable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnimationSet struct {
|
type Player struct {
|
||||||
Idle []rl.ModelAnimation
|
sync.Mutex
|
||||||
Walk []rl.ModelAnimation
|
PosActual rl.Vector3
|
||||||
// Can add more animation types later like:
|
PosTile Tile
|
||||||
// Attack []ModelAnimation
|
TargetPath []Tile
|
||||||
// Jump []ModelAnimation
|
ActionQueue []*pb.Action
|
||||||
|
Speed float32
|
||||||
|
Model rl.Model
|
||||||
|
Texture rl.Texture2D
|
||||||
|
ID int32
|
||||||
|
CurrentTick int64
|
||||||
|
LastUpdateTime time.Time
|
||||||
|
InterpolationProgress float32
|
||||||
|
UserData interface{} // Used to store reference to game
|
||||||
|
FloatingMessage *FloatingMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelAsset struct {
|
type ModelAsset struct {
|
||||||
Model rl.Model
|
Model rl.Model
|
||||||
Texture rl.Texture2D
|
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 {
|
type ChatMessage struct {
|
||||||
PlayerID int32
|
PlayerID int32
|
||||||
Username string
|
|
||||||
Content string
|
Content string
|
||||||
Time time.Time
|
Time time.Time
|
||||||
}
|
}
|
||||||
@ -44,10 +48,6 @@ type FloatingMessage struct {
|
|||||||
ScreenPos rl.Vector2 // Store the screen position for 2D rendering
|
ScreenPos rl.Vector2 // Store the screen position for 2D rendering
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatMessageHandler interface {
|
|
||||||
HandleServerMessages([]*pb.ChatMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MapWidth = 50
|
MapWidth = 50
|
||||||
MapHeight = 50
|
MapHeight = 50
|
||||||
@ -58,13 +58,6 @@ const (
|
|||||||
ServerTickRate = 600 * time.Millisecond
|
ServerTickRate = 600 * time.Millisecond
|
||||||
ClientTickRate = 50 * time.Millisecond
|
ClientTickRate = 50 * time.Millisecond
|
||||||
MaxTickDesync = 5
|
MaxTickDesync = 5
|
||||||
)
|
// ServerAddr = "localhost:6969"
|
||||||
|
ServerAddr = "boner.be:6969"
|
||||||
// UI constants
|
|
||||||
const (
|
|
||||||
ChatMargin = 10
|
|
||||||
ChatHeight = 200
|
|
||||||
MessageHeight = 20
|
|
||||||
InputHeight = 30
|
|
||||||
MaxChatMessages = 50
|
|
||||||
)
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user