Compare commits
47 Commits
feature/mu
...
feature/ma
Author | SHA1 | Date | |
---|---|---|---|
d5bb464d9f | |||
4549ee7517 | |||
31ae9c525f | |||
06913a5217 | |||
49663c9094 | |||
a843680b09 | |||
7183df4a8b | |||
33e355200d | |||
e45066b2a8 | |||
bb01dccf2b | |||
0f56916295 | |||
a1ddbadea0 | |||
e4d0b98945 | |||
509bc8b20b | |||
c40e4ae7ac | |||
863f5a939c | |||
cd68581429 | |||
b9d0d46bd6 | |||
b96c7ada7a | |||
d86cbe15a3 | |||
fb018e2a7d | |||
5ca973fdf1 | |||
2a0f9348e9 | |||
d6d0f36cee | |||
e8d062c4b7 | |||
0cd3145d28 | |||
0b6ab17ad5 | |||
50952309f4 | |||
afc44710f2 | |||
1a7b0eff42 | |||
bf7bf12a53 | |||
e661320508 | |||
567ec40c3d | |||
c01b8d1c59 | |||
d301d597e8 | |||
91cdbab54a | |||
0a58e0453a | |||
8d70129c73 | |||
4012a2ed92 | |||
4f36c2ee1f | |||
63e3837441 | |||
5c5040cd42 | |||
2b9ece3c10 | |||
4bfb5af362 | |||
c7f7c083b1 | |||
1c42ec2802 | |||
7ab75e8128 |
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Build artifacts
|
||||||
|
build/
|
||||||
|
goonscape
|
||||||
|
goonscape.exe
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[submodule "goonserver"]
|
||||||
|
path = goonserver
|
||||||
|
url = https://gitea.boner.be/bdnugget/goonserver
|
25
.woodpecker.yml
Normal file
25
.woodpecker.yml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
pipeline:
|
||||||
|
build:
|
||||||
|
image: golang:1.23
|
||||||
|
commands:
|
||||||
|
# Install build dependencies
|
||||||
|
- apt-get update && apt-get install -y gcc-mingw-w64 cmake zip libasound2-dev mesa-common-dev libx11-dev libxrandr-dev libxi-dev xorg-dev libgl1-mesa-dev libglu1-mesa-dev libwayland-dev wayland-protocols libxkbcommon-dev
|
||||||
|
|
||||||
|
# Build for all platforms
|
||||||
|
- make all
|
||||||
|
|
||||||
|
when:
|
||||||
|
event: tag
|
||||||
|
tag: v*
|
||||||
|
|
||||||
|
# Optional: Create Gitea release with built artifacts
|
||||||
|
release:
|
||||||
|
image: plugins/gitea-release
|
||||||
|
settings:
|
||||||
|
api_key:
|
||||||
|
from_secret: gitea_token
|
||||||
|
base_url: https://gitea.boner.be
|
||||||
|
files: build/*.zip
|
||||||
|
when:
|
||||||
|
event: tag
|
||||||
|
tag: v*
|
30
Dockerfile.build
Normal file
30
Dockerfile.build
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
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"]
|
11
LICENSE
Normal file
11
LICENSE
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
“Commons Clause” License Condition v1.0
|
||||||
|
|
||||||
|
The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Software: GoonScape
|
||||||
|
License: Commons Clause v1.0
|
||||||
|
Licensor: bdnugget
|
30
Makefile
Normal file
30
Makefile
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
.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
Normal file
124
README.md
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
# 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
|
||||||
|
```
|
41
assets/assets.go
Normal file
41
assets/assets.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package assets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.boner.be/bdnugget/goonscape/types"
|
||||||
|
rl "github.com/gen2brain/raylib-go/raylib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func LoadModels() ([]types.ModelAsset, error) {
|
||||||
|
goonerModel := rl.LoadModel("resources/models/goonion.obj")
|
||||||
|
goonerTexture := rl.LoadTexture("resources/models/goonion.png")
|
||||||
|
rl.SetMaterialTexture(goonerModel.Materials, rl.MapDiffuse, goonerTexture)
|
||||||
|
|
||||||
|
coomerModel := rl.LoadModel("resources/models/coomer.obj")
|
||||||
|
coomerTexture := rl.LoadTexture("resources/models/coomer.png")
|
||||||
|
rl.SetMaterialTexture(coomerModel.Materials, rl.MapDiffuse, coomerTexture)
|
||||||
|
|
||||||
|
shrekeModel := rl.LoadModel("resources/models/shreke.obj")
|
||||||
|
shrekeTexture := rl.LoadTexture("resources/models/shreke.png")
|
||||||
|
rl.SetMaterialTexture(shrekeModel.Materials, rl.MapDiffuse, shrekeTexture)
|
||||||
|
|
||||||
|
return []types.ModelAsset{
|
||||||
|
{Model: goonerModel, Texture: goonerTexture},
|
||||||
|
{Model: coomerModel, Texture: coomerTexture},
|
||||||
|
{Model: shrekeModel, Texture: shrekeTexture},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadMusic(filename string) (rl.Music, error) {
|
||||||
|
return rl.LoadMusicStream(filename), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UnloadModels(models []types.ModelAsset) {
|
||||||
|
for _, model := range models {
|
||||||
|
rl.UnloadModel(model.Model)
|
||||||
|
rl.UnloadTexture(model.Texture)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UnloadMusic(music rl.Music) {
|
||||||
|
rl.UnloadMusicStream(music)
|
||||||
|
}
|
21
constants.go
Normal file
21
constants.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
MapWidth = 50
|
||||||
|
MapHeight = 50
|
||||||
|
TileSize = 32
|
||||||
|
TileHeight = 2.0
|
||||||
|
TickRate = 600 * time.Millisecond // Server tick rate (600ms)
|
||||||
|
serverAddr = "localhost:6969"
|
||||||
|
|
||||||
|
// RuneScape-style tick rate (600ms)
|
||||||
|
ServerTickRate = 600 * time.Millisecond
|
||||||
|
|
||||||
|
// Client might run at a higher tick rate for smooth rendering
|
||||||
|
ClientTickRate = 50 * time.Millisecond
|
||||||
|
|
||||||
|
// Maximum number of ticks we can get behind before forcing a resync
|
||||||
|
MaxTickDesync = 5
|
||||||
|
)
|
@ -1 +0,0 @@
|
|||||||
package utils
|
|
@ -1 +0,0 @@
|
|||||||
package utils
|
|
63
game/camera.go
Normal file
63
game/camera.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
|
||||||
|
rl "github.com/gen2brain/raylib-go/raylib"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cameraDistance = float32(20.0)
|
||||||
|
cameraYaw = float32(145.0)
|
||||||
|
cameraPitch = float32(45.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
if wheelMove != 0 {
|
||||||
|
cameraDistance += -wheelMove * 5
|
||||||
|
if cameraDistance < 10 {
|
||||||
|
cameraDistance = 10
|
||||||
|
}
|
||||||
|
if cameraDistance > 250 {
|
||||||
|
cameraDistance = 250
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rl.IsKeyDown(rl.KeyRight) {
|
||||||
|
cameraYaw += 100 * deltaTime
|
||||||
|
}
|
||||||
|
if rl.IsKeyDown(rl.KeyLeft) {
|
||||||
|
cameraYaw -= 100 * deltaTime
|
||||||
|
}
|
||||||
|
if rl.IsKeyDown(rl.KeyUp) {
|
||||||
|
cameraPitch -= 50 * deltaTime
|
||||||
|
if cameraPitch < 20 {
|
||||||
|
cameraPitch = 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rl.IsKeyDown(rl.KeyDown) {
|
||||||
|
cameraPitch += 50 * deltaTime
|
||||||
|
if cameraPitch > 85 {
|
||||||
|
cameraPitch = 85
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cameraYawRad := float64(cameraYaw) * rl.Deg2rad
|
||||||
|
cameraPitchRad := float64(cameraPitch) * rl.Deg2rad
|
||||||
|
|
||||||
|
camera.Position = rl.Vector3{
|
||||||
|
X: targetPos.X + cameraDistance*float32(math.Cos(cameraYawRad))*float32(math.Cos(cameraPitchRad)),
|
||||||
|
Y: targetPos.Y + cameraDistance*float32(math.Sin(cameraPitchRad)),
|
||||||
|
Z: targetPos.Z + cameraDistance*float32(math.Sin(cameraYawRad))*float32(math.Cos(cameraPitchRad)),
|
||||||
|
}
|
||||||
|
camera.Target = targetPos
|
||||||
|
}
|
227
game/chat.go
Normal file
227
game/chat.go
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.boner.be/bdnugget/goonscape/types"
|
||||||
|
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
||||||
|
rl "github.com/gen2brain/raylib-go/raylib"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxMessages = 50
|
||||||
|
chatMargin = 10 // Margin from screen edges
|
||||||
|
chatHeight = 200
|
||||||
|
messageHeight = 20
|
||||||
|
inputHeight = 30
|
||||||
|
runeLimit = 256
|
||||||
|
)
|
||||||
|
|
||||||
|
type Chat struct {
|
||||||
|
messages []types.ChatMessage
|
||||||
|
inputBuffer []rune
|
||||||
|
isTyping bool
|
||||||
|
cursorPos int
|
||||||
|
scrollOffset int
|
||||||
|
userData interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChat() *Chat {
|
||||||
|
return &Chat{
|
||||||
|
messages: make([]types.ChatMessage, 0, maxMessages),
|
||||||
|
inputBuffer: make([]rune, 0, runeLimit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chat) AddMessage(playerID int32, content string) {
|
||||||
|
msg := types.ChatMessage{
|
||||||
|
PlayerID: playerID,
|
||||||
|
Content: content,
|
||||||
|
Time: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.messages) >= maxMessages {
|
||||||
|
c.messages = c.messages[1:]
|
||||||
|
}
|
||||||
|
c.messages = append(c.messages, msg)
|
||||||
|
c.scrollOffset = 0 // Reset scroll position for new messages
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) {
|
||||||
|
// Convert protobuf messages to our local type
|
||||||
|
for _, msg := range messages {
|
||||||
|
localMsg := types.ChatMessage{
|
||||||
|
PlayerID: msg.PlayerId,
|
||||||
|
Username: msg.Username,
|
||||||
|
Content: msg.Content,
|
||||||
|
Time: time.Unix(0, msg.Timestamp),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add if it's not already in our history
|
||||||
|
if len(c.messages) == 0 || c.messages[len(c.messages)-1].Time.UnixNano() < msg.Timestamp {
|
||||||
|
if len(c.messages) >= maxMessages {
|
||||||
|
c.messages = c.messages[1:]
|
||||||
|
}
|
||||||
|
c.messages = append(c.messages, localMsg)
|
||||||
|
|
||||||
|
// Scroll to latest message if it's not already visible
|
||||||
|
visibleMessages := int((chatHeight - inputHeight) / messageHeight)
|
||||||
|
if len(c.messages) > visibleMessages {
|
||||||
|
c.scrollOffset = len(c.messages) - visibleMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add floating message to the player
|
||||||
|
if game, ok := c.userData.(*Game); ok {
|
||||||
|
if msg.PlayerId == game.Player.ID {
|
||||||
|
game.Player.Lock()
|
||||||
|
game.Player.FloatingMessage = &types.FloatingMessage{
|
||||||
|
Content: msg.Content,
|
||||||
|
ExpireTime: time.Now().Add(6 * time.Second),
|
||||||
|
}
|
||||||
|
game.Player.Unlock()
|
||||||
|
} else if otherPlayer, exists := game.OtherPlayers[msg.PlayerId]; exists {
|
||||||
|
otherPlayer.Lock()
|
||||||
|
otherPlayer.FloatingMessage = &types.FloatingMessage{
|
||||||
|
Content: msg.Content,
|
||||||
|
ExpireTime: time.Now().Add(6 * time.Second),
|
||||||
|
}
|
||||||
|
otherPlayer.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chat) Draw(screenWidth, screenHeight int32) {
|
||||||
|
// Calculate chat window width based on screen width
|
||||||
|
chatWindowWidth := screenWidth - (chatMargin * 2)
|
||||||
|
|
||||||
|
// Draw chat window background
|
||||||
|
chatX := float32(chatMargin)
|
||||||
|
chatY := float32(screenHeight - chatHeight - chatMargin)
|
||||||
|
rl.DrawRectangle(int32(chatX), int32(chatY), chatWindowWidth, chatHeight, rl.ColorAlpha(rl.Black, 0.5))
|
||||||
|
|
||||||
|
// Draw messages from oldest to newest
|
||||||
|
messageY := chatY + 5
|
||||||
|
visibleMessages := int((chatHeight - inputHeight) / messageHeight)
|
||||||
|
|
||||||
|
// Auto-scroll to bottom if no manual scrolling has occurred
|
||||||
|
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]
|
||||||
|
var color rl.Color
|
||||||
|
if msg.PlayerID == 0 { // System message
|
||||||
|
color = rl.Gold
|
||||||
|
} else {
|
||||||
|
color = rl.White
|
||||||
|
}
|
||||||
|
text := fmt.Sprintf("%s: %s", msg.Username, msg.Content)
|
||||||
|
rl.DrawText(text, int32(chatX)+5, int32(messageY), 20, color)
|
||||||
|
messageY += messageHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw input field
|
||||||
|
inputY := chatY + float32(chatHeight-inputHeight)
|
||||||
|
rl.DrawRectangle(int32(chatX), int32(inputY), chatWindowWidth, inputHeight, rl.ColorAlpha(rl.White, 0.3))
|
||||||
|
if c.isTyping {
|
||||||
|
inputText := string(c.inputBuffer)
|
||||||
|
rl.DrawText(inputText, int32(chatX)+5, int32(inputY)+5, 20, rl.White)
|
||||||
|
|
||||||
|
// Draw cursor
|
||||||
|
cursorX := rl.MeasureText(inputText[:c.cursorPos], 20)
|
||||||
|
rl.DrawRectangle(int32(chatX)+5+cursorX, int32(inputY)+5, 2, 20, rl.White)
|
||||||
|
} else {
|
||||||
|
rl.DrawText("Press T to chat", int32(chatX)+5, int32(inputY)+5, 20, rl.Gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chat) Update() (string, bool) {
|
||||||
|
// Handle scrolling with mouse wheel when not typing
|
||||||
|
if !c.isTyping {
|
||||||
|
wheelMove := rl.GetMouseWheelMove()
|
||||||
|
if wheelMove != 0 {
|
||||||
|
maxScroll := max(0, len(c.messages)-int((chatHeight-inputHeight)/messageHeight))
|
||||||
|
c.scrollOffset = clamp(c.scrollOffset-int(wheelMove), 0, maxScroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rl.IsKeyPressed(rl.KeyT) {
|
||||||
|
c.isTyping = true
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
key := rl.GetCharPressed()
|
||||||
|
for key > 0 {
|
||||||
|
if len(c.inputBuffer) < runeLimit {
|
||||||
|
c.inputBuffer = append(c.inputBuffer[:c.cursorPos], append([]rune{key}, c.inputBuffer[c.cursorPos:]...)...)
|
||||||
|
c.cursorPos++
|
||||||
|
}
|
||||||
|
key = rl.GetCharPressed()
|
||||||
|
}
|
||||||
|
|
||||||
|
if rl.IsKeyPressed(rl.KeyEnter) || rl.IsKeyPressed(rl.KeyKpEnter) {
|
||||||
|
if len(c.inputBuffer) > 0 {
|
||||||
|
message := string(c.inputBuffer)
|
||||||
|
c.inputBuffer = c.inputBuffer[:0]
|
||||||
|
c.cursorPos = 0
|
||||||
|
c.isTyping = false
|
||||||
|
return message, true
|
||||||
|
}
|
||||||
|
c.isTyping = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if rl.IsKeyPressed(rl.KeyEscape) && c.isTyping {
|
||||||
|
c.inputBuffer = c.inputBuffer[:0]
|
||||||
|
c.cursorPos = 0
|
||||||
|
c.isTyping = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if rl.IsKeyPressed(rl.KeyBackspace) && c.cursorPos > 0 {
|
||||||
|
c.inputBuffer = append(c.inputBuffer[:c.cursorPos-1], c.inputBuffer[c.cursorPos:]...)
|
||||||
|
c.cursorPos--
|
||||||
|
}
|
||||||
|
|
||||||
|
if rl.IsKeyPressed(rl.KeyLeft) && c.cursorPos > 0 {
|
||||||
|
c.cursorPos--
|
||||||
|
}
|
||||||
|
if rl.IsKeyPressed(rl.KeyRight) && c.cursorPos < len(c.inputBuffer) {
|
||||||
|
c.cursorPos++
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add helper functions
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(value, min, max int) int {
|
||||||
|
if value < min {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
if value > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
354
game/game.go
Normal file
354
game/game.go
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.boner.be/bdnugget/goonscape/assets"
|
||||||
|
"gitea.boner.be/bdnugget/goonscape/network"
|
||||||
|
"gitea.boner.be/bdnugget/goonscape/types"
|
||||||
|
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
||||||
|
rl "github.com/gen2brain/raylib-go/raylib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Game struct {
|
||||||
|
Player *types.Player
|
||||||
|
OtherPlayers map[int32]*types.Player
|
||||||
|
Camera rl.Camera3D
|
||||||
|
Models []types.ModelAsset
|
||||||
|
Music rl.Music
|
||||||
|
Chat *Chat
|
||||||
|
MenuOpen bool
|
||||||
|
QuitChan chan struct{} // Channel to signal shutdown
|
||||||
|
loginScreen *LoginScreen
|
||||||
|
isLoggedIn bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Game {
|
||||||
|
InitWorld()
|
||||||
|
game := &Game{
|
||||||
|
OtherPlayers: make(map[int32]*types.Player),
|
||||||
|
Camera: rl.Camera3D{
|
||||||
|
Position: rl.NewVector3(0, 10, 10),
|
||||||
|
Target: rl.NewVector3(0, 0, 0),
|
||||||
|
Up: rl.NewVector3(0, 1, 0),
|
||||||
|
Fovy: 45.0,
|
||||||
|
Projection: rl.CameraPerspective,
|
||||||
|
},
|
||||||
|
Chat: NewChat(),
|
||||||
|
QuitChan: make(chan struct{}),
|
||||||
|
loginScreen: NewLoginScreen(),
|
||||||
|
}
|
||||||
|
game.Chat.userData = game
|
||||||
|
return game
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) LoadAssets() error {
|
||||||
|
var err error
|
||||||
|
g.Models, err = assets.LoadModels()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Music, err = assets.LoadMusic("resources/audio/GoonScape2.mp3")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) Update(deltaTime float32) {
|
||||||
|
if !g.isLoggedIn {
|
||||||
|
username, password, isRegistering, submitted := g.loginScreen.Update()
|
||||||
|
if submitted {
|
||||||
|
conn, playerID, err := network.ConnectToServer(username, password, isRegistering)
|
||||||
|
if err != nil {
|
||||||
|
g.loginScreen.SetError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign model based on player ID
|
||||||
|
modelIndex := int(playerID) % len(g.Models)
|
||||||
|
g.Player = &types.Player{
|
||||||
|
Speed: 50.0,
|
||||||
|
TargetPath: []types.Tile{},
|
||||||
|
UserData: g,
|
||||||
|
QuitDone: make(chan struct{}),
|
||||||
|
ID: playerID,
|
||||||
|
Model: g.Models[modelIndex].Model,
|
||||||
|
Texture: g.Models[modelIndex].Texture,
|
||||||
|
}
|
||||||
|
|
||||||
|
go network.HandleServerCommunication(conn, playerID, g.Player, g.OtherPlayers, g.QuitChan)
|
||||||
|
g.isLoggedIn = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.loginScreen.Draw()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ESC for menu
|
||||||
|
if rl.IsKeyPressed(rl.KeyEscape) {
|
||||||
|
g.MenuOpen = !g.MenuOpen
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't process other inputs if menu is open
|
||||||
|
if g.MenuOpen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if message, sent := g.Chat.Update(); sent {
|
||||||
|
g.Player.Lock()
|
||||||
|
g.Player.ActionQueue = append(g.Player.ActionQueue, &pb.Action{
|
||||||
|
Type: pb.Action_CHAT,
|
||||||
|
ChatMessage: message,
|
||||||
|
PlayerId: g.Player.ID,
|
||||||
|
})
|
||||||
|
g.Player.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
g.HandleInput()
|
||||||
|
|
||||||
|
if len(g.Player.TargetPath) > 0 {
|
||||||
|
g.Player.MoveTowards(g.Player.TargetPath[0], deltaTime, GetMapGrid())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, other := range g.OtherPlayers {
|
||||||
|
if len(other.TargetPath) > 0 {
|
||||||
|
other.MoveTowards(other.TargetPath[0], deltaTime, GetMapGrid())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateCamera(&g.Camera, g.Player.PosActual, deltaTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) DrawMap() {
|
||||||
|
for x := 0; x < types.MapWidth; x++ {
|
||||||
|
for y := 0; y < types.MapHeight; y++ {
|
||||||
|
height := GetTileHeight(x, y)
|
||||||
|
|
||||||
|
// Interpolate height for smoother landscape
|
||||||
|
if x > 0 {
|
||||||
|
height += GetTileHeight(x-1, y)
|
||||||
|
}
|
||||||
|
if y > 0 {
|
||||||
|
height += GetTileHeight(x, y-1)
|
||||||
|
}
|
||||||
|
if x > 0 && y > 0 {
|
||||||
|
height += GetTileHeight(x-1, y-1)
|
||||||
|
}
|
||||||
|
height /= 4.0
|
||||||
|
|
||||||
|
tilePos := rl.Vector3{
|
||||||
|
X: float32(x * types.TileSize),
|
||||||
|
Y: height * types.TileHeight,
|
||||||
|
Z: float32(y * types.TileSize),
|
||||||
|
}
|
||||||
|
color := rl.Color{R: uint8(height * 25), G: 100, B: 100, A: 64}
|
||||||
|
rl.DrawCube(tilePos, types.TileSize, types.TileHeight, types.TileSize, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) DrawPlayer(player *types.Player, model rl.Model) {
|
||||||
|
player.Lock()
|
||||||
|
defer player.Unlock()
|
||||||
|
|
||||||
|
grid := GetMapGrid()
|
||||||
|
playerPos := rl.Vector3{
|
||||||
|
X: player.PosActual.X,
|
||||||
|
Y: grid[player.PosTile.X][player.PosTile.Y].Height*types.TileHeight + 16.0,
|
||||||
|
Z: player.PosActual.Z,
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.DrawModel(model, playerPos, 16, rl.White)
|
||||||
|
|
||||||
|
if player.FloatingMessage != nil {
|
||||||
|
screenPos := rl.GetWorldToScreen(rl.Vector3{
|
||||||
|
X: playerPos.X,
|
||||||
|
Y: playerPos.Y + 24.0,
|
||||||
|
Z: playerPos.Z,
|
||||||
|
}, g.Camera)
|
||||||
|
|
||||||
|
player.FloatingMessage.ScreenPos = screenPos
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(player.TargetPath) > 0 {
|
||||||
|
targetTile := player.TargetPath[len(player.TargetPath)-1]
|
||||||
|
targetPos := rl.Vector3{
|
||||||
|
X: float32(targetTile.X * types.TileSize),
|
||||||
|
Y: grid[targetTile.X][targetTile.Y].Height * types.TileHeight,
|
||||||
|
Z: float32(targetTile.Y * types.TileSize),
|
||||||
|
}
|
||||||
|
rl.DrawCubeWires(targetPos, types.TileSize, types.TileHeight, types.TileSize, rl.Green)
|
||||||
|
|
||||||
|
nextTile := player.TargetPath[0]
|
||||||
|
nextPos := rl.Vector3{
|
||||||
|
X: float32(nextTile.X * types.TileSize),
|
||||||
|
Y: grid[nextTile.X][nextTile.Y].Height * types.TileHeight,
|
||||||
|
Z: float32(nextTile.Y * types.TileSize),
|
||||||
|
}
|
||||||
|
rl.DrawCubeWires(nextPos, types.TileSize, types.TileHeight, types.TileSize, rl.Yellow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) Render() {
|
||||||
|
rl.BeginDrawing()
|
||||||
|
rl.ClearBackground(rl.RayWhite)
|
||||||
|
|
||||||
|
if !g.isLoggedIn {
|
||||||
|
g.loginScreen.Draw()
|
||||||
|
rl.EndDrawing()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.BeginMode3D(g.Camera)
|
||||||
|
g.DrawMap()
|
||||||
|
g.DrawPlayer(g.Player, g.Player.Model)
|
||||||
|
for id, other := range g.OtherPlayers {
|
||||||
|
if other.Model.Meshes == nil {
|
||||||
|
// Assign model based on player ID for consistency
|
||||||
|
modelIndex := int(id) % len(g.Models)
|
||||||
|
other.Model = g.Models[modelIndex].Model
|
||||||
|
other.Texture = g.Models[modelIndex].Texture
|
||||||
|
}
|
||||||
|
g.DrawPlayer(other, other.Model)
|
||||||
|
}
|
||||||
|
rl.EndMode3D()
|
||||||
|
|
||||||
|
// Draw floating messages
|
||||||
|
drawFloatingMessage := func(msg *types.FloatingMessage) {
|
||||||
|
if msg == nil || time.Now().After(msg.ExpireTime) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pos := msg.ScreenPos
|
||||||
|
text := msg.Content
|
||||||
|
textWidth := rl.MeasureText(text, 20)
|
||||||
|
|
||||||
|
for offsetX := -2; offsetX <= 2; offsetX++ {
|
||||||
|
for offsetY := -2; offsetY <= 2; offsetY++ {
|
||||||
|
rl.DrawText(text,
|
||||||
|
int32(pos.X)-textWidth/2+int32(offsetX),
|
||||||
|
int32(pos.Y)+int32(offsetY),
|
||||||
|
20,
|
||||||
|
rl.Black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rl.DrawText(text, int32(pos.X)-textWidth/2, int32(pos.Y), 20, rl.Yellow)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Player.FloatingMessage != nil {
|
||||||
|
drawFloatingMessage(g.Player.FloatingMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, other := range g.OtherPlayers {
|
||||||
|
drawFloatingMessage(other.FloatingMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw menu if open
|
||||||
|
if g.MenuOpen {
|
||||||
|
g.DrawMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only draw chat if menu is not open
|
||||||
|
if !g.MenuOpen {
|
||||||
|
g.Chat.Draw(int32(rl.GetScreenWidth()), int32(rl.GetScreenHeight()))
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.DrawFPS(10, 10)
|
||||||
|
rl.EndDrawing()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) Cleanup() {
|
||||||
|
assets.UnloadModels(g.Models)
|
||||||
|
assets.UnloadMusic(g.Music)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) HandleInput() {
|
||||||
|
clickedTile, clicked := g.GetTileAtMouse()
|
||||||
|
if clicked {
|
||||||
|
path := FindPath(GetTile(g.Player.PosTile.X, g.Player.PosTile.Y), clickedTile)
|
||||||
|
if len(path) > 1 {
|
||||||
|
g.Player.Lock()
|
||||||
|
g.Player.TargetPath = path[1:]
|
||||||
|
g.Player.ActionQueue = append(g.Player.ActionQueue, &pb.Action{
|
||||||
|
Type: pb.Action_MOVE,
|
||||||
|
X: int32(clickedTile.X),
|
||||||
|
Y: int32(clickedTile.Y),
|
||||||
|
PlayerId: g.Player.ID,
|
||||||
|
})
|
||||||
|
g.Player.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) DrawMenu() {
|
||||||
|
screenWidth := float32(rl.GetScreenWidth())
|
||||||
|
screenHeight := float32(rl.GetScreenHeight())
|
||||||
|
|
||||||
|
// Semi-transparent background
|
||||||
|
rl.DrawRectangle(0, 0, int32(screenWidth), int32(screenHeight), rl.ColorAlpha(rl.Black, 0.7))
|
||||||
|
|
||||||
|
// Menu title
|
||||||
|
title := "Menu"
|
||||||
|
titleSize := int32(40)
|
||||||
|
titleWidth := rl.MeasureText(title, titleSize)
|
||||||
|
rl.DrawText(title, int32(screenWidth/2)-titleWidth/2, 100, titleSize, rl.White)
|
||||||
|
|
||||||
|
// Menu buttons
|
||||||
|
buttonWidth := float32(200)
|
||||||
|
buttonHeight := float32(40)
|
||||||
|
buttonY := float32(200)
|
||||||
|
buttonSpacing := float32(60)
|
||||||
|
|
||||||
|
menuItems := []string{"Resume", "Settings", "Exit Game"}
|
||||||
|
for _, item := range menuItems {
|
||||||
|
buttonRect := rl.Rectangle{
|
||||||
|
X: screenWidth/2 - buttonWidth/2,
|
||||||
|
Y: buttonY,
|
||||||
|
Width: buttonWidth,
|
||||||
|
Height: buttonHeight,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check mouse hover
|
||||||
|
mousePoint := rl.GetMousePosition()
|
||||||
|
mouseHover := rl.CheckCollisionPointRec(mousePoint, buttonRect)
|
||||||
|
|
||||||
|
// Draw button
|
||||||
|
if mouseHover {
|
||||||
|
rl.DrawRectangleRec(buttonRect, rl.ColorAlpha(rl.White, 0.3))
|
||||||
|
if rl.IsMouseButtonPressed(rl.MouseLeftButton) {
|
||||||
|
switch item {
|
||||||
|
case "Resume":
|
||||||
|
g.MenuOpen = false
|
||||||
|
case "Settings":
|
||||||
|
// TODO: Implement settings
|
||||||
|
case "Exit Game":
|
||||||
|
g.Shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw button text
|
||||||
|
textSize := int32(20)
|
||||||
|
textWidth := rl.MeasureText(item, textSize)
|
||||||
|
textX := int32(buttonRect.X+buttonRect.Width/2) - textWidth/2
|
||||||
|
textY := int32(buttonRect.Y + buttonRect.Height/2 - float32(textSize)/2)
|
||||||
|
rl.DrawText(item, textX, textY, textSize, rl.White)
|
||||||
|
|
||||||
|
buttonY += buttonSpacing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) Shutdown() {
|
||||||
|
close(g.QuitChan)
|
||||||
|
<-g.Player.QuitDone
|
||||||
|
rl.CloseWindow()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) HandleServerMessages(messages []*pb.ChatMessage) {
|
||||||
|
g.Chat.HandleServerMessages(messages)
|
||||||
|
}
|
31
game/input.go
Normal file
31
game/input.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.boner.be/bdnugget/goonscape/types"
|
||||||
|
rl "github.com/gen2brain/raylib-go/raylib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (g *Game) GetTileAtMouse() (types.Tile, bool) {
|
||||||
|
if !rl.IsMouseButtonPressed(rl.MouseLeftButton) {
|
||||||
|
return types.Tile{}, false
|
||||||
|
}
|
||||||
|
mouse := rl.GetMousePosition()
|
||||||
|
ray := rl.GetMouseRay(mouse, g.Camera)
|
||||||
|
|
||||||
|
for x := 0; x < types.MapWidth; x++ {
|
||||||
|
for y := 0; y < types.MapHeight; y++ {
|
||||||
|
tile := GetTile(x, y)
|
||||||
|
tilePos := rl.NewVector3(float32(x*types.TileSize), tile.Height*types.TileHeight, float32(y*types.TileSize))
|
||||||
|
boxMin := rl.Vector3Subtract(tilePos, rl.NewVector3(types.TileSize/2, types.TileHeight/2, types.TileSize/2))
|
||||||
|
boxMax := rl.Vector3Add(tilePos, rl.NewVector3(types.TileSize/2, types.TileHeight/2, types.TileSize/2))
|
||||||
|
|
||||||
|
if RayIntersectsBox(ray, boxMin, boxMax) {
|
||||||
|
fmt.Printf("Clicked: %d, %d\n", tile.X, tile.Y)
|
||||||
|
return tile, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return types.Tile{}, false
|
||||||
|
}
|
185
game/login.go
Normal file
185
game/login.go
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
rl "github.com/gen2brain/raylib-go/raylib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoginScreen struct {
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
errorMessage string
|
||||||
|
isRegistering bool
|
||||||
|
focusedField int // 0 = username, 1 = password
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLoginScreen() *LoginScreen {
|
||||||
|
return &LoginScreen{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LoginScreen) Draw() {
|
||||||
|
screenWidth := float32(rl.GetScreenWidth())
|
||||||
|
screenHeight := float32(rl.GetScreenHeight())
|
||||||
|
|
||||||
|
// Draw background
|
||||||
|
rl.DrawRectangle(0, 0, int32(screenWidth), int32(screenHeight), rl.RayWhite)
|
||||||
|
|
||||||
|
// Draw title
|
||||||
|
title := "GoonScape"
|
||||||
|
if l.isRegistering {
|
||||||
|
title += " - Register"
|
||||||
|
} else {
|
||||||
|
title += " - Login"
|
||||||
|
}
|
||||||
|
titleSize := int32(40)
|
||||||
|
titleWidth := rl.MeasureText(title, titleSize)
|
||||||
|
rl.DrawText(title, int32(screenWidth/2)-titleWidth/2, 100, titleSize, rl.Black)
|
||||||
|
|
||||||
|
// Draw input fields
|
||||||
|
inputWidth := float32(200)
|
||||||
|
inputHeight := float32(30)
|
||||||
|
inputX := screenWidth/2 - inputWidth/2
|
||||||
|
|
||||||
|
// Username field
|
||||||
|
rl.DrawRectangleRec(rl.Rectangle{
|
||||||
|
X: inputX, Y: 200,
|
||||||
|
Width: inputWidth, Height: inputHeight,
|
||||||
|
}, rl.LightGray)
|
||||||
|
rl.DrawText(l.username, int32(inputX)+5, 205, 20, rl.Black)
|
||||||
|
if l.focusedField == 0 {
|
||||||
|
rl.DrawRectangleLinesEx(rl.Rectangle{
|
||||||
|
X: inputX - 2, Y: 198,
|
||||||
|
Width: inputWidth + 4, Height: inputHeight + 4,
|
||||||
|
}, 2, rl.Blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password field
|
||||||
|
rl.DrawRectangleRec(rl.Rectangle{
|
||||||
|
X: inputX, Y: 250,
|
||||||
|
Width: inputWidth, Height: inputHeight,
|
||||||
|
}, rl.LightGray)
|
||||||
|
masked := ""
|
||||||
|
for range l.password {
|
||||||
|
masked += "*"
|
||||||
|
}
|
||||||
|
rl.DrawText(masked, int32(inputX)+5, 255, 20, rl.Black)
|
||||||
|
if l.focusedField == 1 {
|
||||||
|
rl.DrawRectangleLinesEx(rl.Rectangle{
|
||||||
|
X: inputX - 2, Y: 248,
|
||||||
|
Width: inputWidth + 4, Height: inputHeight + 4,
|
||||||
|
}, 2, rl.Blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw error message
|
||||||
|
if l.errorMessage != "" {
|
||||||
|
msgWidth := rl.MeasureText(l.errorMessage, 20)
|
||||||
|
rl.DrawText(l.errorMessage, int32(screenWidth/2)-msgWidth/2, 300, 20, rl.Red)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw buttons
|
||||||
|
buttonWidth := float32(100)
|
||||||
|
buttonHeight := float32(30)
|
||||||
|
buttonY := float32(350)
|
||||||
|
|
||||||
|
// Login/Register button
|
||||||
|
actionBtn := rl.Rectangle{
|
||||||
|
X: screenWidth/2 - buttonWidth - 10,
|
||||||
|
Y: buttonY,
|
||||||
|
Width: buttonWidth,
|
||||||
|
Height: buttonHeight,
|
||||||
|
}
|
||||||
|
rl.DrawRectangleRec(actionBtn, rl.Blue)
|
||||||
|
actionText := "Login"
|
||||||
|
if l.isRegistering {
|
||||||
|
actionText = "Register"
|
||||||
|
}
|
||||||
|
actionWidth := rl.MeasureText(actionText, 20)
|
||||||
|
rl.DrawText(actionText,
|
||||||
|
int32(actionBtn.X+actionBtn.Width/2)-actionWidth/2,
|
||||||
|
int32(actionBtn.Y+5),
|
||||||
|
20, rl.White)
|
||||||
|
|
||||||
|
// Switch mode button
|
||||||
|
switchBtn := rl.Rectangle{
|
||||||
|
X: screenWidth/2 + 10,
|
||||||
|
Y: buttonY,
|
||||||
|
Width: buttonWidth,
|
||||||
|
Height: buttonHeight,
|
||||||
|
}
|
||||||
|
rl.DrawRectangleRec(switchBtn, rl.DarkGray)
|
||||||
|
switchText := "Register"
|
||||||
|
if l.isRegistering {
|
||||||
|
switchText = "Login"
|
||||||
|
}
|
||||||
|
switchWidth := rl.MeasureText(switchText, 20)
|
||||||
|
rl.DrawText(switchText,
|
||||||
|
int32(switchBtn.X+switchBtn.Width/2)-switchWidth/2,
|
||||||
|
int32(switchBtn.Y+5),
|
||||||
|
20, rl.White)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LoginScreen) Update() (string, string, bool, bool) {
|
||||||
|
// Handle input field focus
|
||||||
|
if rl.IsMouseButtonPressed(rl.MouseLeftButton) {
|
||||||
|
mousePos := rl.GetMousePosition()
|
||||||
|
screenWidth := float32(rl.GetScreenWidth())
|
||||||
|
inputWidth := float32(200)
|
||||||
|
inputX := screenWidth/2 - inputWidth/2
|
||||||
|
|
||||||
|
// Check username field
|
||||||
|
if mousePos.X >= inputX && mousePos.X <= inputX+inputWidth &&
|
||||||
|
mousePos.Y >= 200 && mousePos.Y <= 230 {
|
||||||
|
l.focusedField = 0
|
||||||
|
}
|
||||||
|
// Check password field
|
||||||
|
if mousePos.X >= inputX && mousePos.X <= inputX+inputWidth &&
|
||||||
|
mousePos.Y >= 250 && mousePos.Y <= 280 {
|
||||||
|
l.focusedField = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check buttons
|
||||||
|
buttonWidth := float32(100)
|
||||||
|
if mousePos.Y >= 350 && mousePos.Y <= 380 {
|
||||||
|
// Action button
|
||||||
|
if mousePos.X >= screenWidth/2-buttonWidth-10 &&
|
||||||
|
mousePos.X <= screenWidth/2-10 {
|
||||||
|
return l.username, l.password, l.isRegistering, true
|
||||||
|
}
|
||||||
|
// Switch mode button
|
||||||
|
if mousePos.X >= screenWidth/2+10 &&
|
||||||
|
mousePos.X <= screenWidth/2+buttonWidth+10 {
|
||||||
|
l.isRegistering = !l.isRegistering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle text input
|
||||||
|
key := rl.GetCharPressed()
|
||||||
|
for key > 0 {
|
||||||
|
if l.focusedField == 0 && len(l.username) < 12 {
|
||||||
|
l.username += string(key)
|
||||||
|
} else if l.focusedField == 1 && len(l.password) < 20 {
|
||||||
|
l.password += string(key)
|
||||||
|
}
|
||||||
|
key = rl.GetCharPressed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle backspace
|
||||||
|
if rl.IsKeyPressed(rl.KeyBackspace) {
|
||||||
|
if l.focusedField == 0 && len(l.username) > 0 {
|
||||||
|
l.username = l.username[:len(l.username)-1]
|
||||||
|
} else if l.focusedField == 1 && len(l.password) > 0 {
|
||||||
|
l.password = l.password[:len(l.password)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tab to switch fields
|
||||||
|
if rl.IsKeyPressed(rl.KeyTab) {
|
||||||
|
l.focusedField = (l.focusedField + 1) % 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LoginScreen) SetError(msg string) {
|
||||||
|
l.errorMessage = msg
|
||||||
|
}
|
112
game/pathfinding.go
Normal file
112
game/pathfinding.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.boner.be/bdnugget/goonscape/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Node struct {
|
||||||
|
Tile types.Tile
|
||||||
|
Parent *Node
|
||||||
|
G, H, F float32
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindPath(start, end types.Tile) []types.Tile {
|
||||||
|
openList := []*Node{}
|
||||||
|
closedList := make(map[[2]int]bool)
|
||||||
|
|
||||||
|
startNode := &Node{Tile: start, G: 0, H: heuristic(start, end)}
|
||||||
|
startNode.F = startNode.G + startNode.H
|
||||||
|
openList = append(openList, startNode)
|
||||||
|
|
||||||
|
for len(openList) > 0 {
|
||||||
|
current := openList[0]
|
||||||
|
currentIndex := 0
|
||||||
|
for i, node := range openList {
|
||||||
|
if node.F < current.F {
|
||||||
|
current = node
|
||||||
|
currentIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openList = append(openList[:currentIndex], openList[currentIndex+1:]...)
|
||||||
|
closedList[[2]int{current.Tile.X, current.Tile.Y}] = true
|
||||||
|
|
||||||
|
if current.Tile.X == end.X && current.Tile.Y == end.Y {
|
||||||
|
path := []types.Tile{}
|
||||||
|
node := current
|
||||||
|
for node != nil {
|
||||||
|
path = append([]types.Tile{node.Tile}, path...)
|
||||||
|
node = node.Parent
|
||||||
|
}
|
||||||
|
fmt.Printf("Path found: %v\n", path)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
neighbors := GetNeighbors(current.Tile)
|
||||||
|
for _, neighbor := range neighbors {
|
||||||
|
if !neighbor.Walkable || closedList[[2]int{neighbor.X, neighbor.Y}] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tentativeG := current.G + distance(current.Tile, neighbor)
|
||||||
|
inOpen := false
|
||||||
|
var existingNode *Node
|
||||||
|
for _, node := range openList {
|
||||||
|
if node.Tile.X == neighbor.X && node.Tile.Y == neighbor.Y {
|
||||||
|
existingNode = node
|
||||||
|
inOpen = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !inOpen || tentativeG < existingNode.G {
|
||||||
|
newNode := &Node{
|
||||||
|
Tile: neighbor,
|
||||||
|
Parent: current,
|
||||||
|
G: tentativeG,
|
||||||
|
H: heuristic(neighbor, end),
|
||||||
|
}
|
||||||
|
newNode.F = newNode.G + newNode.H
|
||||||
|
if !inOpen {
|
||||||
|
openList = append(openList, newNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func heuristic(a, b types.Tile) float32 {
|
||||||
|
return float32(abs(a.X-b.X) + abs(a.Y-b.Y))
|
||||||
|
}
|
||||||
|
|
||||||
|
func distance(a, b types.Tile) float32 {
|
||||||
|
return 1.0 // uniform cost for now
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetNeighbors(tile types.Tile) []types.Tile {
|
||||||
|
directions := [][2]int{
|
||||||
|
{1, 0}, {-1, 0}, {0, 1}, {0, -1},
|
||||||
|
{1, 1}, {-1, -1}, {1, -1}, {-1, 1},
|
||||||
|
}
|
||||||
|
neighbors := []types.Tile{}
|
||||||
|
grid := GetMapGrid()
|
||||||
|
for _, dir := range directions {
|
||||||
|
nx, ny := tile.X+dir[0], tile.Y+dir[1]
|
||||||
|
if nx >= 0 && nx < types.MapWidth && ny >= 0 && ny < types.MapHeight {
|
||||||
|
if grid[nx][ny].Walkable {
|
||||||
|
neighbors = append(neighbors, grid[nx][ny])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return neighbors
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs(x int) int {
|
||||||
|
if x < 0 {
|
||||||
|
return -x
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
45
game/utils.go
Normal file
45
game/utils.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
rl "github.com/gen2brain/raylib-go/raylib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RayIntersectsBox(ray rl.Ray, boxMin, boxMax rl.Vector3) bool {
|
||||||
|
tmin := (boxMin.X - ray.Position.X) / ray.Direction.X
|
||||||
|
tmax := (boxMax.X - ray.Position.X) / ray.Direction.X
|
||||||
|
|
||||||
|
if tmin > tmax {
|
||||||
|
tmin, tmax = tmax, tmin
|
||||||
|
}
|
||||||
|
|
||||||
|
tymin := (boxMin.Z - ray.Position.Z) / ray.Direction.Z
|
||||||
|
tymax := (boxMax.Z - ray.Position.Z) / ray.Direction.Z
|
||||||
|
|
||||||
|
if tymin > tymax {
|
||||||
|
tymin, tymax = tymax, tymin
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tmin > tymax) || (tymin > tmax) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if tymin > tmin {
|
||||||
|
tmin = tymin
|
||||||
|
}
|
||||||
|
if tymax < tmax {
|
||||||
|
tmax = tymax
|
||||||
|
}
|
||||||
|
|
||||||
|
tzmin := (boxMin.Y - ray.Position.Y) / ray.Direction.Y
|
||||||
|
tzmax := (boxMax.Y - ray.Position.Y) / ray.Direction.Y
|
||||||
|
|
||||||
|
if tzmin > tzmax {
|
||||||
|
tzmin, tzmax = tzmax, tzmin
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tmin > tzmax) || (tzmin > tmax) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
39
game/world.go
Normal file
39
game/world.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.boner.be/bdnugget/goonscape/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mapGrid [][]types.Tile
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetMapGrid() [][]types.Tile {
|
||||||
|
return mapGrid
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitWorld() {
|
||||||
|
mapGrid = make([][]types.Tile, types.MapWidth)
|
||||||
|
for x := 0; x < types.MapWidth; x++ {
|
||||||
|
mapGrid[x] = make([]types.Tile, types.MapHeight)
|
||||||
|
for y := 0; y < types.MapHeight; y++ {
|
||||||
|
mapGrid[x][y] = types.Tile{
|
||||||
|
X: x,
|
||||||
|
Y: y,
|
||||||
|
Height: 1.0 + float32(x%5),
|
||||||
|
Walkable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTile(x, y int) types.Tile {
|
||||||
|
if x >= 0 && x < types.MapWidth && y >= 0 && y < types.MapHeight {
|
||||||
|
return mapGrid[x][y]
|
||||||
|
}
|
||||||
|
return types.Tile{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTileHeight(x, y int) float32 {
|
||||||
|
return mapGrid[x][y].Height
|
||||||
|
}
|
16
go.mod
16
go.mod
@ -1,15 +1,17 @@
|
|||||||
module goonscape
|
module gitea.boner.be/bdnugget/goonscape
|
||||||
|
|
||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.boner.be/bdnugget/goonserver v0.0.0-20241011122434-4bd5303cfd46
|
gitea.boner.be/bdnugget/goonserver v1.1.0
|
||||||
github.com/gen2brain/raylib-go/raylib v0.0.0-20240930075631-c66f9e2942fe
|
github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b
|
||||||
google.golang.org/protobuf v1.35.1
|
google.golang.org/protobuf v1.36.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/ebitengine/purego v0.8.0 // indirect
|
github.com/ebitengine/purego v0.8.2 // indirect
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
|
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
|
||||||
golang.org/x/sys v0.26.0 // indirect
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace gitea.boner.be/bdnugget/goonserver => ./goonserver
|
||||||
|
22
go.sum
22
go.sum
@ -1,14 +1,12 @@
|
|||||||
gitea.boner.be/bdnugget/goonserver v0.0.0-20241011122434-4bd5303cfd46 h1:T2D4QcmvBqzGoHO0VJGNUd1k2lLmUcyg6Rc/vN4/Im8=
|
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
||||||
gitea.boner.be/bdnugget/goonserver v0.0.0-20241011122434-4bd5303cfd46/go.mod h1:inR1bKrr/vcTba+G1KzmmY6vssMq9oGNOk836VwPa4c=
|
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE=
|
github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b h1:JJfspevP3YOXcSKVABizYOv++yMpTJIdPUtoDzF/RWw=
|
||||||
github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
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 h1:mInjrbJkUglTM7tBmXG+epnPCE744aj15J7vjJwM4gs=
|
|
||||||
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-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
|
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
|
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
|
||||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
|
1
goonserver
Submodule
1
goonserver
Submodule
Submodule goonserver added at d25ee09155
563
main.go
563
main.go
@ -1,547 +1,60 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"strings"
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
"gitea.boner.be/bdnugget/goonscape/game"
|
||||||
|
"gitea.boner.be/bdnugget/goonscape/network"
|
||||||
rl "github.com/gen2brain/raylib-go/raylib"
|
rl "github.com/gen2brain/raylib-go/raylib"
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
MapWidth = 50
|
|
||||||
MapHeight = 50
|
|
||||||
TileSize = 32
|
|
||||||
TileHeight = 2.0
|
|
||||||
TickRate = 2600 * time.Millisecond // Server tick rate (600ms)
|
|
||||||
serverAddr = "localhost:6969"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
cameraDistance = float32(20.0)
|
|
||||||
cameraYaw = float32(145.0)
|
|
||||||
cameraPitch = float32(45.0) // Adjusted for a more overhead view
|
|
||||||
)
|
|
||||||
|
|
||||||
type Tile struct {
|
|
||||||
X, Y int
|
|
||||||
Height float32
|
|
||||||
Walkable bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActionType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
MoveAction ActionType = iota
|
|
||||||
)
|
|
||||||
|
|
||||||
type Action struct {
|
|
||||||
Type ActionType
|
|
||||||
X, Y int // Target position for movement
|
|
||||||
}
|
|
||||||
|
|
||||||
type Player struct {
|
|
||||||
PosActual rl.Vector3
|
|
||||||
PosTile Tile
|
|
||||||
TargetPath []Tile
|
|
||||||
Speed float32
|
|
||||||
ActionQueue []Action // Queue for player actions
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the map with some height data
|
|
||||||
func InitMap() [][]Tile {
|
|
||||||
mapGrid := make([][]Tile, MapWidth)
|
|
||||||
for x := 0; x < MapWidth; x++ {
|
|
||||||
mapGrid[x] = make([]Tile, MapHeight)
|
|
||||||
for y := 0; y < MapHeight; y++ {
|
|
||||||
mapGrid[x][y] = Tile{
|
|
||||||
X: x,
|
|
||||||
Y: y,
|
|
||||||
Height: 1.0 + float32(x%5), // Example height
|
|
||||||
Walkable: true, // Set to false for obstacles
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mapGrid
|
|
||||||
}
|
|
||||||
|
|
||||||
func DrawMap(mapGrid [][]Tile) {
|
|
||||||
for x := 0; x < MapWidth; x++ {
|
|
||||||
for y := 0; y < MapHeight; y++ {
|
|
||||||
tile := mapGrid[x][y]
|
|
||||||
// Interpolate height between adjacent tiles for a smoother landscape
|
|
||||||
height := tile.Height
|
|
||||||
if x > 0 {
|
|
||||||
height += mapGrid[x-1][y].Height
|
|
||||||
}
|
|
||||||
if y > 0 {
|
|
||||||
height += mapGrid[x][y-1].Height
|
|
||||||
}
|
|
||||||
if x > 0 && y > 0 {
|
|
||||||
height += mapGrid[x-1][y-1].Height
|
|
||||||
}
|
|
||||||
height /= 4.0
|
|
||||||
// Draw each tile as a 3D cube based on its height
|
|
||||||
tilePos := rl.Vector3{
|
|
||||||
X: float32(x * TileSize), // X-axis for horizontal position
|
|
||||||
Y: height * TileHeight, // Y-axis for height (Z in 3D is Y here)
|
|
||||||
Z: float32(y * TileSize), // Z-axis for depth (Y in 3D is Z here)
|
|
||||||
}
|
|
||||||
color := rl.Color{R: uint8(height * 25), G: 100, B: 100, A: 64}
|
|
||||||
rl.DrawCube(tilePos, TileSize, TileHeight, TileSize, color) // Draw a cube representing the tile
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func DrawPlayer(player Player, model *rl.Model, mapGrid [][]Tile) {
|
|
||||||
// Draw the player based on its actual position (PosActual) and current tile height
|
|
||||||
playerPos := rl.Vector3{
|
|
||||||
X: player.PosActual.X,
|
|
||||||
Y: mapGrid[player.PosTile.X][player.PosTile.Y].Height*TileHeight + 16.0,
|
|
||||||
Z: player.PosActual.Z,
|
|
||||||
}
|
|
||||||
// rl.DrawCube(playerPos, 16, 16, 16, rl.Green) // Draw player cube
|
|
||||||
rl.DrawModel(*model, playerPos, 16, rl.White)
|
|
||||||
|
|
||||||
// Draw highlight around target tile
|
|
||||||
if len(player.TargetPath) > 0 {
|
|
||||||
targetTile := player.TargetPath[len(player.TargetPath)-1] // last tile in the slice
|
|
||||||
targetPos := rl.Vector3{
|
|
||||||
X: float32(targetTile.X * TileSize),
|
|
||||||
Y: mapGrid[targetTile.X][targetTile.Y].Height * TileHeight,
|
|
||||||
Z: float32(targetTile.Y * TileSize),
|
|
||||||
}
|
|
||||||
rl.DrawCubeWires(targetPos, TileSize, TileHeight, TileSize, rl.Green)
|
|
||||||
|
|
||||||
nextTile := player.TargetPath[0] // first tile in the slice
|
|
||||||
nextPos := rl.Vector3{
|
|
||||||
X: float32(nextTile.X * TileSize),
|
|
||||||
Y: mapGrid[nextTile.X][nextTile.Y].Height * TileHeight,
|
|
||||||
Z: float32(nextTile.Y * TileSize),
|
|
||||||
}
|
|
||||||
rl.DrawCubeWires(nextPos, TileSize, TileHeight, TileSize, rl.Yellow)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to test ray-box intersection (slab method)
|
|
||||||
func RayIntersectsBox(ray rl.Ray, boxMin, boxMax rl.Vector3) bool {
|
|
||||||
tmin := (boxMin.X - ray.Position.X) / ray.Direction.X
|
|
||||||
tmax := (boxMax.X - ray.Position.X) / ray.Direction.X
|
|
||||||
|
|
||||||
if tmin > tmax {
|
|
||||||
tmin, tmax = tmax, tmin
|
|
||||||
}
|
|
||||||
|
|
||||||
tymin := (boxMin.Z - ray.Position.Z) / ray.Direction.Z
|
|
||||||
tymax := (boxMax.Z - ray.Position.Z) / ray.Direction.Z
|
|
||||||
|
|
||||||
if tymin > tymax {
|
|
||||||
tymin, tymax = tymax, tymin
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tmin > tymax) || (tymin > tmax) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if tymin > tmin {
|
|
||||||
tmin = tymin
|
|
||||||
}
|
|
||||||
if tymax < tmax {
|
|
||||||
tmax = tymax
|
|
||||||
}
|
|
||||||
|
|
||||||
tzmin := (boxMin.Y - ray.Position.Y) / ray.Direction.Y
|
|
||||||
tzmax := (boxMax.Y - ray.Position.Y) / ray.Direction.Y
|
|
||||||
|
|
||||||
if tzmin > tzmax {
|
|
||||||
tzmin, tzmax = tzmax, tzmin
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tmin > tzmax) || (tzmin > tmax) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetTileAtMouse(mapGrid [][]Tile, camera *rl.Camera3D) (Tile, bool) {
|
|
||||||
if !rl.IsMouseButtonPressed(rl.MouseLeftButton) {
|
|
||||||
return Tile{}, false
|
|
||||||
}
|
|
||||||
mouse := rl.GetMousePosition()
|
|
||||||
ray := rl.GetMouseRay(mouse, *camera)
|
|
||||||
|
|
||||||
for x := 0; x < MapWidth; x++ {
|
|
||||||
for y := 0; y < MapHeight; y++ {
|
|
||||||
tile := mapGrid[x][y]
|
|
||||||
|
|
||||||
// Define the bounding box for each tile based on its position and height
|
|
||||||
tilePos := rl.NewVector3(float32(x*TileSize), tile.Height*TileHeight, float32(y*TileSize))
|
|
||||||
boxMin := rl.Vector3Subtract(tilePos, rl.NewVector3(TileSize/2, TileHeight/2, TileSize/2))
|
|
||||||
boxMax := rl.Vector3Add(tilePos, rl.NewVector3(TileSize/2, TileHeight/2, TileSize/2))
|
|
||||||
|
|
||||||
// Check if the ray intersects the bounding box
|
|
||||||
if RayIntersectsBox(ray, boxMin, boxMax) {
|
|
||||||
fmt.Println("Clicked:", tile.X, tile.Y)
|
|
||||||
return tile, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Tile{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (player *Player) MoveTowards(target Tile, deltaTime float32, mapGrid [][]Tile) {
|
|
||||||
// Calculate the direction vector to the target tile
|
|
||||||
targetPos := rl.Vector3{
|
|
||||||
X: float32(target.X * TileSize),
|
|
||||||
Y: mapGrid[target.X][target.Y].Height * TileHeight,
|
|
||||||
Z: float32(target.Y * TileSize),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate direction and normalize it for smooth movement
|
|
||||||
direction := rl.Vector3Subtract(targetPos, player.PosActual)
|
|
||||||
distance := rl.Vector3Length(direction)
|
|
||||||
if distance > 0 {
|
|
||||||
direction = rl.Vector3Scale(direction, player.Speed*deltaTime/distance)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move the player towards the target tile
|
|
||||||
if distance > 1.0 {
|
|
||||||
player.PosActual = rl.Vector3Add(player.PosActual, direction)
|
|
||||||
} else {
|
|
||||||
// Snap to the target tile when close enough
|
|
||||||
player.PosActual = targetPos
|
|
||||||
player.PosTile = target // Update player's tile
|
|
||||||
player.TargetPath = player.TargetPath[1:] // Move to next tile in path if any
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleInput(player *Player, mapGrid [][]Tile, camera *rl.Camera) {
|
|
||||||
clickedTile, clicked := GetTileAtMouse(mapGrid, camera)
|
|
||||||
if clicked {
|
|
||||||
path := FindPath(mapGrid, mapGrid[player.PosTile.X][player.PosTile.Y], clickedTile)
|
|
||||||
if path != nil {
|
|
||||||
// Exclude the first tile (current position)
|
|
||||||
if len(path) > 1 {
|
|
||||||
player.TargetPath = path[1:]
|
|
||||||
player.ActionQueue = append(player.ActionQueue, Action{Type: MoveAction, X: clickedTile.X, Y: clickedTile.Y})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateCamera(camera *rl.Camera3D, player rl.Vector3, deltaTime float32) {
|
|
||||||
// Update camera based on mouse wheel
|
|
||||||
wheelMove := rl.GetMouseWheelMove()
|
|
||||||
if wheelMove != 0 {
|
|
||||||
cameraDistance += -wheelMove * 5
|
|
||||||
if cameraDistance < 10 {
|
|
||||||
cameraDistance = 10
|
|
||||||
}
|
|
||||||
if cameraDistance > 250 {
|
|
||||||
cameraDistance = 250
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Orbit camera around the player using arrow keys
|
|
||||||
if rl.IsKeyDown(rl.KeyRight) {
|
|
||||||
cameraYaw += 100 * deltaTime
|
|
||||||
}
|
|
||||||
if rl.IsKeyDown(rl.KeyLeft) {
|
|
||||||
cameraYaw -= 100 * deltaTime
|
|
||||||
}
|
|
||||||
if rl.IsKeyDown(rl.KeyUp) {
|
|
||||||
cameraPitch -= 50 * deltaTime
|
|
||||||
if cameraPitch < 20 {
|
|
||||||
cameraPitch = 20
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if rl.IsKeyDown(rl.KeyDown) {
|
|
||||||
cameraPitch += 50 * deltaTime
|
|
||||||
if cameraPitch > 85 {
|
|
||||||
cameraPitch = 85
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the new camera position using spherical coordinates
|
|
||||||
cameraYawRad := float64(cameraYaw) * rl.Deg2rad
|
|
||||||
cameraPitchRad := float64(cameraPitch) * rl.Deg2rad
|
|
||||||
cameraPos := rl.Vector3{
|
|
||||||
X: player.X + cameraDistance*float32(math.Cos(cameraYawRad))*float32(math.Cos(cameraPitchRad)),
|
|
||||||
Y: player.Y + cameraDistance*float32(math.Sin(cameraPitchRad)),
|
|
||||||
Z: player.Z + cameraDistance*float32(math.Sin(cameraYawRad))*float32(math.Cos(cameraPitchRad)),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the camera's position and target
|
|
||||||
camera.Position = cameraPos
|
|
||||||
camera.Target = rl.NewVector3(player.X, player.Y, player.Z)
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Parse command line flags
|
||||||
|
local := flag.Bool("local", false, "Connect to local server")
|
||||||
|
addr := flag.String("addr", "", "Server address (host or host:port)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
rl.InitWindow(1024, 768, "GoonScape")
|
rl.InitWindow(1024, 768, "GoonScape")
|
||||||
|
rl.SetExitKey(0)
|
||||||
defer rl.CloseWindow()
|
defer rl.CloseWindow()
|
||||||
|
|
||||||
rl.InitAudioDevice()
|
rl.InitAudioDevice()
|
||||||
defer rl.CloseAudioDevice()
|
defer rl.CloseAudioDevice()
|
||||||
|
|
||||||
mapGrid := InitMap()
|
|
||||||
|
|
||||||
player := Player{
|
|
||||||
PosActual: rl.NewVector3(5*TileSize, 0, 5*TileSize),
|
|
||||||
PosTile: mapGrid[5][5],
|
|
||||||
Speed: 50.0,
|
|
||||||
TargetPath: []Tile{},
|
|
||||||
}
|
|
||||||
|
|
||||||
camera := rl.Camera3D{
|
|
||||||
Position: rl.NewVector3(0, 10, 10), // Will be updated every frame
|
|
||||||
Target: player.PosActual,
|
|
||||||
Up: rl.NewVector3(0, 1, 0), // Y is up in 3D
|
|
||||||
Fovy: 45.0,
|
|
||||||
Projection: rl.CameraPerspective,
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, playerID, err := ConnectToServer()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to connect to server: %v", err)
|
|
||||||
}
|
|
||||||
log.Printf("Player ID: %d", playerID)
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
go HandleServerCommunication(conn, playerID, &player)
|
|
||||||
|
|
||||||
playerModel := rl.LoadModel("resources/models/goonion.obj")
|
|
||||||
defer rl.UnloadModel(playerModel)
|
|
||||||
playerTexture := rl.LoadTexture("resources/models/goonion.png")
|
|
||||||
defer rl.UnloadTexture(playerTexture)
|
|
||||||
rl.SetMaterialTexture(playerModel.Materials, rl.MapDiffuse, playerTexture)
|
|
||||||
|
|
||||||
coomerModel := rl.LoadModel("resources/models/coomer.obj")
|
|
||||||
defer rl.UnloadModel(coomerModel)
|
|
||||||
coomerTexture := rl.LoadTexture("resources/models/coomer.png")
|
|
||||||
defer rl.UnloadTexture(coomerTexture)
|
|
||||||
rl.SetMaterialTexture(coomerModel.Materials, rl.MapDiffuse, coomerTexture)
|
|
||||||
|
|
||||||
rl.SetTargetFPS(60)
|
rl.SetTargetFPS(60)
|
||||||
|
|
||||||
// Music
|
game := game.New()
|
||||||
music := rl.LoadMusicStream("resources/audio/GoonScape2.mp3")
|
if err := game.LoadAssets(); err != nil {
|
||||||
rl.PlayMusicStream(music)
|
log.Fatalf("Failed to load assets: %v", err)
|
||||||
rl.SetMusicVolume(music, 0.5)
|
}
|
||||||
defer rl.UnloadMusicStream(music)
|
defer game.Cleanup()
|
||||||
|
|
||||||
|
rl.PlayMusicStream(game.Music)
|
||||||
|
rl.SetMusicVolume(game.Music, 0.5)
|
||||||
|
|
||||||
for !rl.WindowShouldClose() {
|
for !rl.WindowShouldClose() {
|
||||||
|
|
||||||
rl.UpdateMusicStream(music)
|
|
||||||
|
|
||||||
// Time management
|
|
||||||
deltaTime := rl.GetFrameTime()
|
deltaTime := rl.GetFrameTime()
|
||||||
|
rl.UpdateMusicStream(game.Music)
|
||||||
// Handle input
|
game.Update(deltaTime)
|
||||||
HandleInput(&player, mapGrid, &camera)
|
game.Render()
|
||||||
|
|
||||||
// Update player
|
|
||||||
if len(player.TargetPath) > 0 {
|
|
||||||
player.MoveTowards(player.TargetPath[0], deltaTime, mapGrid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update camera
|
|
||||||
UpdateCamera(&camera, player.PosActual, deltaTime)
|
|
||||||
|
|
||||||
// Rendering
|
|
||||||
rl.BeginDrawing()
|
|
||||||
rl.ClearBackground(rl.RayWhite)
|
|
||||||
rl.BeginMode3D(camera)
|
|
||||||
DrawMap(mapGrid)
|
|
||||||
DrawPlayer(player, &playerModel, mapGrid)
|
|
||||||
|
|
||||||
rl.DrawModel(coomerModel, rl.NewVector3(5*TileSize+32, 32, 5*TileSize+32), 16, rl.White)
|
|
||||||
|
|
||||||
rl.DrawFPS(10, 10)
|
|
||||||
|
|
||||||
rl.EndMode3D()
|
|
||||||
rl.EndDrawing()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ConnectToServer() (net.Conn, int32, error) {
|
|
||||||
// Attempt to connect to the server
|
|
||||||
conn, err := net.Dial("tcp", serverAddr)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to dial server: %v", err)
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Connected to server. Waiting for player ID...")
|
// Wait for clean shutdown
|
||||||
// Buffer for incoming server message
|
<-game.QuitChan
|
||||||
buf := make([]byte, 1024)
|
|
||||||
n, err := conn.Read(buf)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error reading player ID from server: %v", err)
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Received data: %x", buf[:n])
|
|
||||||
|
|
||||||
// Unmarshal server message to extract the player ID
|
|
||||||
var response pb.ServerMessage
|
|
||||||
if err := proto.Unmarshal(buf[:n], &response); err != nil {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleServerCommunication(conn net.Conn, playerID int32, player *Player) {
|
|
||||||
for {
|
|
||||||
// Check if there are actions in the player's queue
|
|
||||||
if len(player.ActionQueue) > 0 {
|
|
||||||
// Process the first action in the queue
|
|
||||||
actionData := player.ActionQueue[0]
|
|
||||||
action := &pb.Action{
|
|
||||||
PlayerId: playerID,
|
|
||||||
Type: pb.Action_MOVE,
|
|
||||||
X: int32(actionData.X),
|
|
||||||
Y: int32(actionData.Y),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serialize the action
|
|
||||||
data, err := proto.Marshal(action)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to marshal action: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send action to server
|
|
||||||
_, err = conn.Write(data)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to send action to server: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the action from the queue once it's sent
|
|
||||||
player.ActionQueue = player.ActionQueue[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a delay based on the server's tick rate
|
|
||||||
time.Sleep(TickRate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pathfinding
|
|
||||||
type Node struct {
|
|
||||||
Tile Tile
|
|
||||||
Parent *Node
|
|
||||||
G, H, F float32
|
|
||||||
}
|
|
||||||
|
|
||||||
func FindPath(mapGrid [][]Tile, start, end Tile) []Tile {
|
|
||||||
openList := []*Node{}
|
|
||||||
closedList := make(map[[2]int]bool)
|
|
||||||
|
|
||||||
startNode := &Node{Tile: start, G: 0, H: heuristic(start, end)}
|
|
||||||
startNode.F = startNode.G + startNode.H
|
|
||||||
openList = append(openList, startNode)
|
|
||||||
|
|
||||||
for len(openList) > 0 {
|
|
||||||
// Find node with lowest F
|
|
||||||
current := openList[0]
|
|
||||||
currentIndex := 0
|
|
||||||
for i, node := range openList {
|
|
||||||
if node.F < current.F {
|
|
||||||
current = node
|
|
||||||
currentIndex = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move current to closed list
|
|
||||||
openList = append(openList[:currentIndex], openList[currentIndex+1:]...)
|
|
||||||
closedList[[2]int{current.Tile.X, current.Tile.Y}] = true
|
|
||||||
|
|
||||||
// Check if reached the end
|
|
||||||
if current.Tile.X == end.X && current.Tile.Y == end.Y {
|
|
||||||
path := []Tile{}
|
|
||||||
node := current
|
|
||||||
for node != nil {
|
|
||||||
path = append([]Tile{node.Tile}, path...)
|
|
||||||
node = node.Parent
|
|
||||||
}
|
|
||||||
fmt.Printf("Path found: %v\n", path)
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate neighbors
|
|
||||||
neighbors := GetNeighbors(mapGrid, current.Tile)
|
|
||||||
for _, neighbor := range neighbors {
|
|
||||||
if !neighbor.Walkable || closedList[[2]int{neighbor.X, neighbor.Y}] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
tentativeG := current.G + distance(current.Tile, neighbor)
|
|
||||||
inOpen := false
|
|
||||||
var existingNode *Node
|
|
||||||
for _, node := range openList {
|
|
||||||
if node.Tile.X == neighbor.X && node.Tile.Y == neighbor.Y {
|
|
||||||
existingNode = node
|
|
||||||
inOpen = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !inOpen || tentativeG < existingNode.G {
|
|
||||||
newNode := &Node{
|
|
||||||
Tile: neighbor,
|
|
||||||
Parent: current,
|
|
||||||
G: tentativeG,
|
|
||||||
H: heuristic(neighbor, end),
|
|
||||||
}
|
|
||||||
newNode.F = newNode.G + newNode.H
|
|
||||||
if !inOpen {
|
|
||||||
openList = append(openList, newNode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No path found
|
|
||||||
fmt.Println("No path found")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func heuristic(a, b Tile) float32 {
|
|
||||||
return float32(abs(a.X-b.X) + abs(a.Y-b.Y))
|
|
||||||
}
|
|
||||||
|
|
||||||
func distance(a, b Tile) float32 {
|
|
||||||
_ = a
|
|
||||||
_ = b
|
|
||||||
return 1.0 //uniform cost for now
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetNeighbors(mapGrid [][]Tile, tile Tile) []Tile {
|
|
||||||
directions := [][2]int{
|
|
||||||
{1, 0}, {-1, 0}, {0, 1}, {0, -1},
|
|
||||||
{1, 1}, {-1, -1}, {1, -1}, {-1, 1},
|
|
||||||
}
|
|
||||||
neighbors := []Tile{}
|
|
||||||
for _, dir := range directions {
|
|
||||||
nx, ny := tile.X+dir[0], tile.Y+dir[1]
|
|
||||||
if nx >= 0 && nx < MapWidth && ny >= 0 && ny < MapHeight {
|
|
||||||
neighbors = append(neighbors, mapGrid[nx][ny])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return neighbors
|
|
||||||
}
|
|
||||||
|
|
||||||
func abs(x int) int {
|
|
||||||
if x < 0 {
|
|
||||||
return -x
|
|
||||||
}
|
|
||||||
return x
|
|
||||||
}
|
}
|
||||||
|
254
network/network.go
Normal file
254
network/network.go
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
package network
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.boner.be/bdnugget/goonscape/types"
|
||||||
|
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
||||||
|
rl "github.com/gen2brain/raylib-go/raylib"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const protoVersion = 1
|
||||||
|
|
||||||
|
var serverAddr = "boner.be:6969"
|
||||||
|
|
||||||
|
func SetServerAddr(addr string) {
|
||||||
|
serverAddr = addr
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConnectToServer(username, password string, isRegistering bool) (net.Conn, int32, error) {
|
||||||
|
conn, err := net.Dial("tcp", serverAddr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to dial server: %v", err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Connected to server. Authenticating...")
|
||||||
|
|
||||||
|
// 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 := writeMessage(conn, authBatch); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, 0, fmt.Errorf("failed to send auth: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read server response
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
lengthBuf := make([]byte, 4)
|
||||||
|
if _, err := io.ReadFull(reader, lengthBuf); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, 0, fmt.Errorf("failed to read auth response: %v", err)
|
||||||
|
}
|
||||||
|
messageLength := binary.BigEndian.Uint32(lengthBuf)
|
||||||
|
|
||||||
|
messageBuf := make([]byte, messageLength)
|
||||||
|
if _, err := io.ReadFull(reader, messageBuf); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, 0, fmt.Errorf("failed to read auth response body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response pb.ServerMessage
|
||||||
|
if err := proto.Unmarshal(messageBuf, &response); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, 0, fmt.Errorf("failed to unmarshal auth response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.ProtocolVersion > protoVersion {
|
||||||
|
conn.Close()
|
||||||
|
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)
|
||||||
|
return conn, playerID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Player, otherPlayers map[int32]*types.Player, quitChan <-chan struct{}) {
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
|
||||||
|
actionTicker := time.NewTicker(types.ClientTickRate)
|
||||||
|
defer actionTicker.Stop()
|
||||||
|
defer conn.Close()
|
||||||
|
defer close(player.QuitDone)
|
||||||
|
|
||||||
|
// Create a channel to signal when goroutines are done
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
// Create a set of current players to track disconnects
|
||||||
|
currentPlayers := make(map[int32]bool)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-quitChan:
|
||||||
|
// Send disconnect message to server
|
||||||
|
disconnectMsg := &pb.ActionBatch{
|
||||||
|
PlayerId: playerID,
|
||||||
|
Actions: []*pb.Action{{
|
||||||
|
Type: pb.Action_DISCONNECT,
|
||||||
|
PlayerId: playerID,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
writeMessage(conn, disconnectMsg)
|
||||||
|
done <- struct{}{}
|
||||||
|
return
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
player.ActionQueue = player.ActionQueue[:0]
|
||||||
|
player.Unlock()
|
||||||
|
|
||||||
|
if err := writeMessage(conn, batch); err != nil {
|
||||||
|
log.Printf("Failed to send actions to server: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
player.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-quitChan:
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
<-done
|
||||||
|
close(player.QuitDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
case <-time.After(1 * time.Second):
|
||||||
|
log.Println("Shutdown timed out")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// Read message length (4 bytes)
|
||||||
|
lengthBuf := make([]byte, 4)
|
||||||
|
if _, err := io.ReadFull(reader, lengthBuf); err != nil {
|
||||||
|
log.Printf("Failed to read message length: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messageLength := binary.BigEndian.Uint32(lengthBuf)
|
||||||
|
|
||||||
|
// Read the full message
|
||||||
|
messageBuf := make([]byte, messageLength)
|
||||||
|
if _, err := io.ReadFull(reader, messageBuf); err != nil {
|
||||||
|
log.Printf("Failed to read message body: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var serverMessage pb.ServerMessage
|
||||||
|
if err := proto.Unmarshal(messageBuf, &serverMessage); err != nil {
|
||||||
|
log.Printf("Failed to unmarshal server message: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
player.Lock()
|
||||||
|
player.CurrentTick = serverMessage.CurrentTick
|
||||||
|
|
||||||
|
tickDiff := serverMessage.CurrentTick - player.CurrentTick
|
||||||
|
if tickDiff > types.MaxTickDesync {
|
||||||
|
for _, state := range serverMessage.Players {
|
||||||
|
if state.PlayerId == playerID {
|
||||||
|
player.ForceResync(state)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.Unlock()
|
||||||
|
|
||||||
|
for _, state := range serverMessage.Players {
|
||||||
|
currentPlayers[state.PlayerId] = true
|
||||||
|
if state.PlayerId == playerID {
|
||||||
|
player.Lock()
|
||||||
|
// Update initial position if not set
|
||||||
|
if player.PosActual.X == 0 && player.PosActual.Z == 0 {
|
||||||
|
player.PosActual = rl.Vector3{
|
||||||
|
X: float32(state.X * types.TileSize),
|
||||||
|
Y: 0,
|
||||||
|
Z: float32(state.Y * types.TileSize),
|
||||||
|
}
|
||||||
|
player.PosTile = types.Tile{X: int(state.X), Y: int(state.Y)}
|
||||||
|
}
|
||||||
|
player.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if otherPlayer, exists := otherPlayers[state.PlayerId]; exists {
|
||||||
|
otherPlayer.UpdatePosition(state, types.ServerTickRate)
|
||||||
|
} else {
|
||||||
|
otherPlayers[state.PlayerId] = types.NewPlayer(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove players that are no longer in the server state
|
||||||
|
for id := range otherPlayers {
|
||||||
|
if !currentPlayers[id] {
|
||||||
|
delete(otherPlayers, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if handler, ok := player.UserData.(types.ChatMessageHandler); ok && len(serverMessage.ChatMessages) > 0 {
|
||||||
|
handler.HandleServerMessages(serverMessage.ChatMessages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to write length-prefixed messages
|
||||||
|
func writeMessage(conn net.Conn, msg proto.Message) error {
|
||||||
|
data, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write length prefix
|
||||||
|
lengthBuf := make([]byte, 4)
|
||||||
|
binary.BigEndian.PutUint32(lengthBuf, uint32(len(data)))
|
||||||
|
if _, err := conn.Write(lengthBuf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write message body
|
||||||
|
_, err = conn.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
12
resources/models/shreke.mtl
Normal file
12
resources/models/shreke.mtl
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Blender 3.6.0 MTL File: 'None'
|
||||||
|
# www.blender.org
|
||||||
|
|
||||||
|
newmtl Material.001
|
||||||
|
Ns 250.000000
|
||||||
|
Ka 1.000000 1.000000 1.000000
|
||||||
|
Ks 0.500000 0.500000 0.500000
|
||||||
|
Ke 0.000000 0.000000 0.000000
|
||||||
|
Ni 1.450000
|
||||||
|
d 1.000000
|
||||||
|
illum 2
|
||||||
|
map_Kd shreke.png
|
210035
resources/models/shreke.obj
Normal file
210035
resources/models/shreke.obj
Normal file
File diff suppressed because it is too large
Load Diff
BIN
resources/models/shreke.png
Normal file
BIN
resources/models/shreke.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.8 MiB |
BIN
resources/screenshot.png
Normal file
BIN
resources/screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 104 KiB |
27
scripts/build.sh
Executable file
27
scripts/build.sh
Executable file
@ -0,0 +1,27 @@
|
|||||||
|
#!/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
scripts/platforms.mk
Normal file
1
scripts/platforms.mk
Normal file
@ -0,0 +1 @@
|
|||||||
|
PLATFORMS=windows/amd64 linux/amd64
|
76
types/player.go
Normal file
76
types/player.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
||||||
|
rl "github.com/gen2brain/raylib-go/raylib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *Player) MoveTowards(target Tile, deltaTime float32, mapGrid [][]Tile) {
|
||||||
|
p.Lock()
|
||||||
|
defer p.Unlock()
|
||||||
|
|
||||||
|
targetPos := rl.Vector3{
|
||||||
|
X: float32(target.X * TileSize),
|
||||||
|
Y: mapGrid[target.X][target.Y].Height * TileHeight,
|
||||||
|
Z: float32(target.Y * TileSize),
|
||||||
|
}
|
||||||
|
|
||||||
|
direction := rl.Vector3Subtract(targetPos, p.PosActual)
|
||||||
|
distance := rl.Vector3Length(direction)
|
||||||
|
if distance > 0 {
|
||||||
|
direction = rl.Vector3Scale(direction, p.Speed*deltaTime/distance)
|
||||||
|
}
|
||||||
|
|
||||||
|
if distance > 1.0 {
|
||||||
|
p.PosActual = rl.Vector3Add(p.PosActual, direction)
|
||||||
|
} else {
|
||||||
|
p.PosActual = targetPos
|
||||||
|
p.PosTile = target
|
||||||
|
if len(p.TargetPath) > 1 {
|
||||||
|
p.TargetPath = p.TargetPath[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPlayer(state *pb.PlayerState) *Player {
|
||||||
|
return &Player{
|
||||||
|
PosActual: rl.Vector3{
|
||||||
|
X: float32(state.X * TileSize),
|
||||||
|
Y: float32(state.Y * TileHeight),
|
||||||
|
Z: float32(state.Y * TileSize),
|
||||||
|
},
|
||||||
|
PosTile: Tile{X: int(state.X), Y: int(state.Y)},
|
||||||
|
Speed: 50.0,
|
||||||
|
ID: state.PlayerId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Player) UpdatePosition(state *pb.PlayerState, tickRate time.Duration) {
|
||||||
|
p.Lock()
|
||||||
|
defer p.Unlock()
|
||||||
|
|
||||||
|
targetTile := Tile{X: int(state.X), Y: int(state.Y)}
|
||||||
|
if p.PosTile != targetTile {
|
||||||
|
p.PosTile = targetTile
|
||||||
|
p.LastUpdateTime = time.Now()
|
||||||
|
p.InterpolationProgress = 0
|
||||||
|
p.TargetPath = []Tile{targetTile}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Player) ForceResync(state *pb.PlayerState) {
|
||||||
|
p.Lock()
|
||||||
|
defer p.Unlock()
|
||||||
|
|
||||||
|
p.PosTile = Tile{X: int(state.X), Y: int(state.Y)}
|
||||||
|
p.PosActual = rl.Vector3{
|
||||||
|
X: float32(state.X * TileSize),
|
||||||
|
Y: float32(state.Y * TileHeight),
|
||||||
|
Z: float32(state.Y * TileSize),
|
||||||
|
}
|
||||||
|
p.TargetPath = nil
|
||||||
|
p.ActionQueue = nil
|
||||||
|
p.InterpolationProgress = 1.0
|
||||||
|
}
|
67
types/types.go
Normal file
67
types/types.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "gitea.boner.be/bdnugget/goonserver/actions"
|
||||||
|
rl "github.com/gen2brain/raylib-go/raylib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tile struct {
|
||||||
|
X, Y int
|
||||||
|
Height float32
|
||||||
|
Walkable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Player struct {
|
||||||
|
sync.Mutex
|
||||||
|
PosActual rl.Vector3
|
||||||
|
PosTile Tile
|
||||||
|
TargetPath []Tile
|
||||||
|
ActionQueue []*pb.Action
|
||||||
|
Speed float32
|
||||||
|
Model rl.Model
|
||||||
|
Texture rl.Texture2D
|
||||||
|
ID int32
|
||||||
|
CurrentTick int64
|
||||||
|
LastUpdateTime time.Time
|
||||||
|
InterpolationProgress float32
|
||||||
|
UserData interface{}
|
||||||
|
FloatingMessage *FloatingMessage
|
||||||
|
QuitDone chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelAsset struct {
|
||||||
|
Model rl.Model
|
||||||
|
Texture rl.Texture2D
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatMessage struct {
|
||||||
|
PlayerID int32
|
||||||
|
Username string
|
||||||
|
Content string
|
||||||
|
Time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type FloatingMessage struct {
|
||||||
|
Content string
|
||||||
|
ExpireTime time.Time
|
||||||
|
ScreenPos rl.Vector2 // Store the screen position for 2D rendering
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatMessageHandler interface {
|
||||||
|
HandleServerMessages([]*pb.ChatMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
MapWidth = 50
|
||||||
|
MapHeight = 50
|
||||||
|
TileSize = 32
|
||||||
|
TileHeight = 2.0
|
||||||
|
|
||||||
|
// RuneScape-style tick rate (600ms)
|
||||||
|
ServerTickRate = 600 * time.Millisecond
|
||||||
|
ClientTickRate = 50 * time.Millisecond
|
||||||
|
MaxTickDesync = 5
|
||||||
|
)
|
@ -1 +0,0 @@
|
|||||||
package utils
|
|
Reference in New Issue
Block a user