22 Commits

Author SHA1 Message Date
75eff6c5ad Stash bunch of broken generated test with shitty broken mocking 2025-01-22 00:40:31 +01:00
84d63ba4c1 Add coomer animation lol 2025-01-21 01:23:40 +01:00
49b84c8540 Added semi working gooner animation and added segfaults when exiting 2025-01-20 23:37:43 +01:00
0e509ad752 Try gooner animation 2025-01-20 14:30:34 +01:00
bcd63efd7b .gitignore 2025-01-20 14:29:10 +01:00
944c33ce3b Update License 2025-01-20 01:17:13 +01:00
d5bb464d9f Add system messages to chat 2025-01-20 00:03:50 +01:00
4549ee7517 Fix cgo bullshit in build 2025-01-19 23:28:04 +01:00
31ae9c525f mod 2025-01-19 23:22:09 +01:00
06913a5217 Update buildscript and readme 2025-01-19 22:50:50 +01:00
49663c9094 rm build artefacts from git tracking 2025-01-19 22:25:48 +01:00
a843680b09 Prepare v1.1.0 release with auth and db functionality 2025-01-19 22:20:17 +01:00
7183df4a8b Merge pull request 'feature/db' (#4) from feature/db into master
Reviewed-on: #4
2025-01-19 21:07:43 +00:00
33e355200d Update goonserver 2025-01-19 22:07:04 +01:00
e45066b2a8 Based db and accounts and log in where you logged out 2025-01-19 21:52:56 +01:00
bb01dccf2b Getting somewhere lol 2025-01-19 21:23:47 +01:00
0f56916295 Add build instructions 2025-01-19 01:33:01 +01:00
a1ddbadea0 Build system + release win64 + linux64 2025-01-19 01:31:05 +01:00
e4d0b98945 actually close instead of continuing with uninitialized GLFW crap 2025-01-19 00:56:17 +01:00
509bc8b20b fix racism condition / deadlock 2025-01-19 00:51:49 +01:00
c40e4ae7ac Merge branch 'feature/menu' 2025-01-18 23:37:21 +01:00
cd68581429 Merge pull request 'feature/menu' (#3) from feature/menu into master
Reviewed-on: #3
2025-01-18 22:26:19 +00:00
38 changed files with 1637 additions and 113 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
# Build artifacts
build/
goonscape
goonscape.exe
# IDE files
.vscode/
.idea/
*.swp
# OS files
.DS_Store
Thumbs.db
resources/models/old_and_test/

25
.woodpecker.yml Normal file
View 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
View 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"]

24
LICENSE
View File

@ -1,11 +1,21 @@
“Commons Clause” License Condition v1.0
MIT License
The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition.
Copyright (c) 2025 bdnugget
Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, right to Sell the Software.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
For purposes of the foregoing, “Sell” means practicing any or all of the rights granted to you under the License to provide to third parties, for a fee or other consideration (including without limitation fees for hosting or consulting/ support services related to the Software), a product or service whose value derives, entirely or substantially, from the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Cause License Condition notice.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
Software: GoonScape
License: Commons Clause v1.0
Licensor: bdnugget
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

30
Makefile Normal file
View 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 ./...

View File

@ -16,24 +16,57 @@ A multiplayer isometric game inspired by Oldschool RuneScape, built with Go and
## Prerequisites
- Go 1.23 or higher
- GCC (for CGO/SQLite support)
- OpenGL development libraries
- Raylib dependencies (see [raylib-go](https://github.com/gen2brain/raylib-go#requirements))
## Installation
1. Clone the repository:
### Pre-built Binaries
The easiest way to get started is to download the latest release from:
```
https://gitea.boner.be/bdnugget/goonscape/releases
```
Choose the appropriate zip file for your platform:
- Windows: `goonscape-windows-amd64-v1.1.0.zip`
- Linux: `goonscape-linux-amd64-v1.1.0.zip`
Extract the zip and run the executable.
### Quick Start
For development:
```bash
# Run directly (recommended for development)
go run main.go
# Run with local server
go run main.go -local
```
### Server Setup
The server requires CGO for SQLite support:
```bash
# Enable CGO
go env -w CGO_ENABLED=1
# Clone and build server
git clone https://gitea.boner.be/bdnugget/goonserver.git
cd goonserver
go build
```
### Client Installation
Then install or build:
```bash
# Install the client
go install gitea.boner.be/bdnugget/goonscape@latest
```
Or build from source:
```bash
git clone https://gitea.boner.be/bdnugget/goonscape.git
cd goonscape
```
2. Install dependencies:
```bash
go mod tidy
```
3. Build and run:
```bash
go run main.go
go build
```
## Controls
@ -63,6 +96,26 @@ 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:

View File

@ -5,22 +5,79 @@ import (
rl "github.com/gen2brain/raylib-go/raylib"
)
// Helper function to load animations for a model
func loadModelAnimations(animPaths map[string]string) (types.AnimationSet, error) {
var animSet types.AnimationSet
// 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
}
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)
// Goonion model and animations
goonerModel := rl.LoadModel("resources/models/gooner/walk_no_y_transform.glb")
goonerAnims, _ := loadModelAnimations(map[string]string{"idle": "resources/models/gooner/idle_no_y_transform.glb", "walk": "resources/models/gooner/walk_no_y_transform.glb"})
coomerModel := rl.LoadModel("resources/models/coomer.obj")
coomerTexture := rl.LoadTexture("resources/models/coomer.png")
rl.SetMaterialTexture(coomerModel.Materials, rl.MapDiffuse, coomerTexture)
// 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
// Coomer model (ready for animations)
coomerModel := rl.LoadModel("resources/models/coomer/idle_notransy.glb")
// coomerTexture := rl.LoadTexture("resources/models/coomer.png")
// rl.SetMaterialTexture(coomerModel.Materials, rl.MapDiffuse, coomerTexture)
// When you have animations, add them like:
coomerAnims, _ := loadModelAnimations(map[string]string{"idle": "resources/models/coomer/idle_notransy.glb", "walk": "resources/models/coomer/unsteadywalk_notransy.glb"})
coomerModel.Transform = transform
// Shreke model (ready for animations)
shrekeModel := rl.LoadModel("resources/models/shreke.obj")
shrekeTexture := rl.LoadTexture("resources/models/shreke.png")
rl.SetMaterialTexture(shrekeModel.Materials, rl.MapDiffuse, shrekeTexture)
// When you have animations, add them like:
// shrekeAnims, _ := loadModelAnimations("resources/models/shreke.glb",
// map[string]string{
// "idle": "resources/models/shreke_idle.glb",
// "walk": "resources/models/shreke_walk.glb",
// })
return []types.ModelAsset{
{Model: goonerModel, Texture: goonerTexture},
{Model: coomerModel, Texture: coomerTexture},
{
Model: goonerModel,
Animation: append(goonerAnims.Idle, goonerAnims.Walk...),
AnimFrames: int32(len(goonerAnims.Idle) + len(goonerAnims.Walk)),
Animations: goonerAnims,
YOffset: 0.0,
},
{
Model: coomerModel,
Animation: append(coomerAnims.Idle, coomerAnims.Walk...),
AnimFrames: int32(len(coomerAnims.Idle) + len(coomerAnims.Walk)),
Animations: coomerAnims,
YOffset: -4.0,
},
{Model: shrekeModel, Texture: shrekeTexture},
}, nil
}
@ -31,6 +88,11 @@ func LoadMusic(filename string) (rl.Music, error) {
func UnloadModels(models []types.ModelAsset) {
for _, model := range models {
if model.Animation != nil {
for i := int32(0); i < model.AnimFrames; i++ {
rl.UnloadModelAnimation(model.Animation[i])
}
}
rl.UnloadModel(model.Model)
rl.UnloadTexture(model.Texture)
}

View File

@ -25,12 +25,14 @@ type Chat struct {
cursorPos int
scrollOffset int
userData interface{}
input InputHandler
}
func NewChat() *Chat {
return &Chat{
messages: make([]types.ChatMessage, 0, maxMessages),
inputBuffer: make([]rune, 0, runeLimit),
input: &RaylibInput{},
}
}
@ -53,6 +55,7 @@ func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) {
for _, msg := range messages {
localMsg := types.ChatMessage{
PlayerID: msg.PlayerId,
Username: msg.Username,
Content: msg.Content,
Time: time.Unix(0, msg.Timestamp),
}
@ -117,8 +120,14 @@ func (c *Chat) Draw(screenWidth, screenHeight int32) {
for i := startIdx; i < endIdx; i++ {
msg := c.messages[i]
text := fmt.Sprintf("[%d]: %s", msg.PlayerID, msg.Content)
rl.DrawText(text, int32(chatX)+5, int32(messageY), 20, rl.White)
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
}
@ -146,23 +155,23 @@ func (c *Chat) Update() (string, bool) {
c.scrollOffset = clamp(c.scrollOffset-int(wheelMove), 0, maxScroll)
}
if rl.IsKeyPressed(rl.KeyT) {
if c.input.IsKeyPressed(rl.KeyT) {
c.isTyping = true
return "", false
}
return "", false
}
key := rl.GetCharPressed()
key := c.input.GetCharPressed()
for key > 0 {
if len(c.inputBuffer) < runeLimit {
c.inputBuffer = append(c.inputBuffer[:c.cursorPos], append([]rune{key}, c.inputBuffer[c.cursorPos:]...)...)
c.cursorPos++
}
key = rl.GetCharPressed()
key = c.input.GetCharPressed()
}
if rl.IsKeyPressed(rl.KeyEnter) || rl.IsKeyPressed(rl.KeyKpEnter) {
if c.input.IsKeyPressed(rl.KeyEnter) || c.input.IsKeyPressed(rl.KeyKpEnter) {
if len(c.inputBuffer) > 0 {
message := string(c.inputBuffer)
c.inputBuffer = c.inputBuffer[:0]
@ -173,21 +182,21 @@ func (c *Chat) Update() (string, bool) {
c.isTyping = false
}
if rl.IsKeyPressed(rl.KeyEscape) && c.isTyping {
if c.input.IsKeyPressed(rl.KeyEscape) && c.isTyping {
c.inputBuffer = c.inputBuffer[:0]
c.cursorPos = 0
c.isTyping = false
}
if rl.IsKeyPressed(rl.KeyBackspace) && c.cursorPos > 0 {
if c.input.IsKeyPressed(rl.KeyBackspace) && c.cursorPos > 0 {
c.inputBuffer = append(c.inputBuffer[:c.cursorPos-1], c.inputBuffer[c.cursorPos:]...)
c.cursorPos--
}
if rl.IsKeyPressed(rl.KeyLeft) && c.cursorPos > 0 {
if c.input.IsKeyPressed(rl.KeyLeft) && c.cursorPos > 0 {
c.cursorPos--
}
if rl.IsKeyPressed(rl.KeyRight) && c.cursorPos < len(c.inputBuffer) {
if c.input.IsKeyPressed(rl.KeyRight) && c.cursorPos < len(c.inputBuffer) {
c.cursorPos++
}

106
game/chat_test.go Normal file
View File

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

View File

@ -1,9 +1,11 @@
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"
@ -18,20 +20,14 @@ type Game struct {
Chat *Chat
MenuOpen bool
QuitChan chan struct{} // Channel to signal shutdown
QuitDone chan struct{} // New channel to signal when cleanup is complete
loginScreen *LoginScreen
isLoggedIn bool
input InputHandler
}
func New() *Game {
InitWorld()
game := &Game{
Player: &types.Player{
PosActual: rl.NewVector3(5*types.TileSize, 0, 5*types.TileSize),
PosTile: GetTile(5, 5),
Speed: 50.0,
TargetPath: []types.Tile{},
UserData: nil,
QuitDone: make(chan struct{}),
},
OtherPlayers: make(map[int32]*types.Player),
Camera: rl.Camera3D{
Position: rl.NewVector3(0, 10, 10),
@ -40,11 +36,11 @@ func New() *Game {
Fovy: 45.0,
Projection: rl.CameraPerspective,
},
Chat: NewChat(),
QuitChan: make(chan struct{}),
QuitDone: make(chan struct{}),
Chat: NewChat(),
QuitChan: make(chan struct{}),
loginScreen: NewLoginScreen(),
input: &RaylibInput{},
}
game.Player.UserData = game
game.Chat.userData = game
return game
}
@ -65,8 +61,34 @@ func (g *Game) LoadAssets() error {
}
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
}
g.Player = &types.Player{
Speed: 50.0,
TargetPath: []types.Tile{},
UserData: g,
QuitDone: make(chan struct{}),
ID: playerID,
}
g.AssignModelToPlayer(g.Player)
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) {
if g.input.IsKeyPressed(rl.KeyEscape) {
g.MenuOpen = !g.MenuOpen
return
}
@ -134,15 +156,33 @@ func (g *Game) DrawPlayer(player *types.Player, model rl.Model) {
defer player.Unlock()
grid := GetMapGrid()
modelIndex := int(player.ID) % len(g.Models)
modelAsset := g.Models[modelIndex]
const defaultHeight = 8.0 // Default height above tile, fine tune per model in types.ModelAsset
playerPos := rl.Vector3{
X: player.PosActual.X,
Y: grid[player.PosTile.X][player.PosTile.Y].Height*types.TileHeight + 16.0,
Y: grid[player.PosTile.X][player.PosTile.Y].Height*types.TileHeight + defaultHeight + modelAsset.YOffset,
Z: player.PosActual.Z,
}
// Check if model has animations
if modelAsset.Animations.Idle != nil || modelAsset.Animations.Walk != nil {
if player.IsMoving && len(modelAsset.Animations.Walk) > 0 {
anim := modelAsset.Animations.Walk[0] // Use first walk animation
player.AnimationFrame = player.AnimationFrame % anim.FrameCount
rl.UpdateModelAnimation(model, anim, player.AnimationFrame)
} else if len(modelAsset.Animations.Idle) > 0 {
anim := modelAsset.Animations.Idle[0] // Use first idle animation
player.AnimationFrame = player.AnimationFrame % anim.FrameCount
rl.UpdateModelAnimation(model, anim, player.AnimationFrame)
}
}
rl.DrawModel(model, playerPos, 16, rl.White)
if player.FloatingMessage != nil && time.Now().Before(player.FloatingMessage.ExpireTime) {
// Draw floating messages and path indicators
if player.FloatingMessage != nil {
screenPos := rl.GetWorldToScreen(rl.Vector3{
X: playerPos.X,
Y: playerPos.Y + 24.0,
@ -150,8 +190,6 @@ func (g *Game) DrawPlayer(player *types.Player, model rl.Model) {
}, g.Camera)
player.FloatingMessage.ScreenPos = screenPos
} else if player.FloatingMessage != nil {
player.FloatingMessage = nil
}
if len(player.TargetPath) > 0 {
@ -177,11 +215,20 @@ 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 {
g.DrawPlayer(other, g.Models[int(id)%len(g.Models)].Model)
for _, other := range g.OtherPlayers {
if other.Model.Meshes == nil {
g.AssignModelToPlayer(other)
}
g.DrawPlayer(other, other.Model)
}
rl.EndMode3D()
@ -280,22 +327,20 @@ func (g *Game) DrawMenu() {
}
// Check mouse hover
mousePoint := rl.GetMousePosition()
mousePoint := g.input.GetMousePosition()
mouseHover := rl.CheckCollisionPointRec(mousePoint, buttonRect)
// Draw button
if mouseHover {
rl.DrawRectangleRec(buttonRect, rl.ColorAlpha(rl.White, 0.3))
if rl.IsMouseButtonPressed(rl.MouseLeftButton) {
if g.input.IsMouseButtonPressed(toInt32(rl.MouseLeftButton)) {
switch item {
case "Resume":
g.MenuOpen = false
case "Settings":
// TODO: Implement settings
case "Exit Game":
close(g.QuitChan)
<-g.QuitDone
rl.CloseWindow()
g.Shutdown()
}
}
}
@ -310,3 +355,23 @@ func (g *Game) DrawMenu() {
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)
}
func (g *Game) AssignModelToPlayer(player *types.Player) {
modelIndex := int(player.ID) % len(g.Models)
modelAsset := g.Models[modelIndex]
// Just use the original model - don't try to copy it
player.Model = modelAsset.Model
player.Texture = modelAsset.Texture
}

106
game/game_test.go Normal file
View File

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

View File

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

187
game/input_test.go Normal file
View File

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

185
game/login.go Normal file
View 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
}

80
game/login_test.go Normal file
View File

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

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

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

141
game/test_helpers.go Normal file
View File

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

26
game/test_setup.go Normal file
View File

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

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

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

9
go.mod
View File

@ -3,13 +3,20 @@ module gitea.boner.be/bdnugget/goonscape
go 1.23.0
require (
gitea.boner.be/bdnugget/goonserver v0.0.0-20250113131525-49e23114973c
gitea.boner.be/bdnugget/goonserver v1.1.0
github.com/gen2brain/raylib-go/raylib v0.0.0-20250109172833-6dbba4f81a9b
google.golang.org/protobuf v1.36.3
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require (
github.com/ebitengine/purego v0.8.2 // indirect
github.com/stretchr/testify v1.10.0
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/sys v0.29.0 // indirect
)

9
go.sum
View File

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

37
main.go
View File

@ -11,20 +11,21 @@ import (
)
func main() {
local := flag.Bool("local", false, "Use local server instead of remote")
addr := flag.String("addr", "boner.be:6969", "Server address (hostname:port or hostname)")
// 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()
if *local && *addr != "boner.be:6969" {
log.Fatal("Cannot use both -local and -addr flags")
}
// Set server address based on flags
if *local {
if *addr != "" {
log.Fatal("Cannot use -local and -addr together")
}
network.SetServerAddr("localhost:6969")
} else if *addr != "" {
// If only hostname is provided, append default port
// If port is not specified, append default port
if !strings.Contains(*addr, ":") {
*addr = *addr + ":6969"
*addr += ":6969"
}
network.SetServerAddr(*addr)
}
@ -32,36 +33,24 @@ func main() {
rl.InitWindow(1024, 768, "GoonScape")
rl.SetExitKey(0)
defer rl.CloseWindow()
rl.InitAudioDevice()
defer rl.CloseAudioDevice()
rl.SetTargetFPS(60)
game := game.New()
if err := game.LoadAssets(); err != nil {
log.Fatalf("Failed to load assets: %v", err)
}
defer game.Cleanup()
conn, playerID, err := network.ConnectToServer()
if err != nil {
log.Fatalf("Failed to connect to server: %v", err)
}
defer conn.Close()
game.Player.ID = playerID
modelIndex := int(playerID) % len(game.Models)
game.Player.Model = game.Models[modelIndex].Model
game.Player.Texture = game.Models[modelIndex].Texture
go network.HandleServerCommunication(conn, playerID, game.Player, game.OtherPlayers, game.QuitChan)
rl.PlayMusicStream(game.Music)
rl.SetMusicVolume(game.Music, 0.5)
rl.SetTargetFPS(60)
for !rl.WindowShouldClose() {
rl.UpdateMusicStream(game.Music)
deltaTime := rl.GetFrameTime()
rl.UpdateMusicStream(game.Music)
game.Update(deltaTime)
game.Render()
}

View File

@ -3,56 +3,89 @@ package network
import (
"bufio"
"encoding/binary"
"fmt"
"io"
"log"
"net"
"time"
"gitea.boner.be/bdnugget/goonscape/game"
"gitea.boner.be/bdnugget/goonscape/types"
pb "gitea.boner.be/bdnugget/goonserver/actions"
rl "github.com/gen2brain/raylib-go/raylib"
"google.golang.org/protobuf/proto"
)
const protoVersion = 1
var serverAddr = "boner.be:6969"
func SetServerAddr(addr string) {
serverAddr = addr
}
func ConnectToServer() (net.Conn, int32, error) {
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. Waiting for player ID...")
reader := bufio.NewReader(conn)
log.Println("Connected to server. Authenticating...")
// Read message length (4 bytes)
// 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 {
log.Printf("Failed to read message length: %v", err)
return nil, 0, err
conn.Close()
return nil, 0, fmt.Errorf("failed to read auth response: %v", err)
}
messageLength := binary.BigEndian.Uint32(lengthBuf)
// Read the full message
messageBuf := make([]byte, messageLength)
if _, err := io.ReadFull(reader, messageBuf); err != nil {
log.Printf("Failed to read message body: %v", err)
return nil, 0, err
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 {
log.Printf("Failed to unmarshal server response: %v", err)
return nil, 0, err
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 connected with player ID: %d", playerID)
log.Printf("Successfully authenticated with player ID: %d", playerID)
return conn, playerID, nil
}
@ -67,6 +100,9 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play
// 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 {
@ -111,9 +147,18 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play
for {
select {
case <-quitChan:
<-done // Wait for action goroutine to finish
close(done)
time.Sleep(100 * time.Millisecond) // Give time for disconnect message to be sent
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)
@ -152,7 +197,19 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play
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
}
@ -163,8 +220,15 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play
}
}
if g, ok := player.UserData.(*game.Game); ok && len(serverMessage.ChatMessages) > 0 {
g.Chat.HandleServerMessages(serverMessage.ChatMessages)
// 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)
}
}
}

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.

27
scripts/build.sh Executable file
View 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
View File

@ -0,0 +1 @@
PLATFORMS=windows/amd64 linux/amd64

View File

@ -19,6 +19,33 @@ func (p *Player) MoveTowards(target Tile, deltaTime float32, mapGrid [][]Tile) {
direction := rl.Vector3Subtract(targetPos, p.PosActual)
distance := rl.Vector3Length(direction)
if distance > 1.0 {
wasMoving := p.IsMoving
p.IsMoving = true
if !wasMoving {
p.AnimationFrame = 0
}
oldFrame := p.AnimationFrame
p.AnimationFrame += int32(deltaTime * 60)
rl.TraceLog(rl.LogInfo, "Walk frame update: %d -> %d (delta: %f)",
oldFrame, p.AnimationFrame, deltaTime)
} else {
wasMoving := p.IsMoving
p.IsMoving = false
if wasMoving {
p.AnimationFrame = 0
}
oldFrame := p.AnimationFrame
p.AnimationFrame += int32(deltaTime * 60)
rl.TraceLog(rl.LogInfo, "Idle frame update: %d -> %d (delta: %f)",
oldFrame, p.AnimationFrame, deltaTime)
}
if distance > 0 {
direction = rl.Vector3Scale(direction, p.Speed*deltaTime/distance)
}
@ -41,9 +68,12 @@ func NewPlayer(state *pb.PlayerState) *Player {
Y: float32(state.Y * TileHeight),
Z: float32(state.Y * TileSize),
},
PosTile: Tile{X: int(state.X), Y: int(state.Y)},
Speed: 50.0,
ID: state.PlayerId,
PosTile: Tile{X: int(state.X), Y: int(state.Y)},
Speed: 50.0,
ID: state.PlayerId,
IsMoving: false,
AnimationFrame: 0,
LastAnimUpdate: time.Now(),
}
}

View File

@ -26,19 +26,35 @@ type Player struct {
ID int32
CurrentTick int64
LastUpdateTime time.Time
LastAnimUpdate time.Time
InterpolationProgress float32
UserData interface{}
FloatingMessage *FloatingMessage
QuitDone chan struct{}
AnimationFrame int32
IsMoving bool
}
type AnimationSet struct {
Idle []rl.ModelAnimation
Walk []rl.ModelAnimation
// Can add more animation types later like:
// Attack []ModelAnimation
// Jump []ModelAnimation
}
type ModelAsset struct {
Model rl.Model
Texture rl.Texture2D
Model rl.Model
Texture rl.Texture2D
Animation []rl.ModelAnimation // Keep this for compatibility
AnimFrames int32 // Keep this for compatibility
Animations AnimationSet // New field for organized animations
YOffset float32 // Additional height offset (added to default 8.0)
}
type ChatMessage struct {
PlayerID int32
Username string
Content string
Time time.Time
}
@ -49,6 +65,10 @@ type FloatingMessage struct {
ScreenPos rl.Vector2 // Store the screen position for 2D rendering
}
type ChatMessageHandler interface {
HandleServerMessages([]*pb.ChatMessage)
}
const (
MapWidth = 50
MapHeight = 50