33 Commits

Author SHA1 Message Date
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
863f5a939c server 2025-01-18 23:36: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
b9d0d46bd6 Merge branch 'master' into feature/menu 2025-01-18 22:26:10 +00:00
b96c7ada7a Menu with broken exit and settings 2025-01-18 23:23:03 +01:00
d86cbe15a3 add quit channel to clean up after self 2025-01-18 22:27:22 +01:00
fb018e2a7d Add menu on esc and don't close on esc 2025-01-18 22:23:08 +01:00
5ca973fdf1 Update README.md 2025-01-18 20:18:45 +00:00
2a0f9348e9 Update README.md with addr and local flags 2025-01-18 20:41:02 +01:00
d6d0f36cee add addr flag 2025-01-18 20:38:35 +01:00
e8d062c4b7 Add -local flag to connect to local server 2025-01-18 14:23:48 +01:00
0cd3145d28 try to handle tcp fragmentation 2025-01-15 10:58:11 +01:00
0b6ab17ad5 try to handle tcp fragmentation 2025-01-15 10:50:51 +01:00
50952309f4 zoom towards testicles instead of feet 2025-01-14 13:53:12 +01:00
afc44710f2 Chat QoL updates 2025-01-13 21:29:06 +01:00
1a7b0eff42 Update README.md 2025-01-13 14:26:56 +00:00
bf7bf12a53 Readme 2025-01-13 15:13:17 +01:00
e661320508 Remove local dep on goonserver 2025-01-13 14:41:19 +01:00
567ec40c3d Merge pull request 'feature/chat' (#2) from feature/chat into master
Reviewed-on: #2
2025-01-13 13:24:33 +00:00
18 changed files with 896 additions and 141 deletions

13
.gitignore vendored Normal file
View File

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

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"]

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 ./...

124
README.md Normal file
View File

@ -0,0 +1,124 @@
# GoonScape
A multiplayer isometric game inspired by Oldschool RuneScape, built with Go and Raylib.
![GoonScape Screenshot](resources/screenshot.png)
## 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
```

View File

@ -13,6 +13,14 @@ var (
)
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
@ -47,9 +55,9 @@ func UpdateCamera(camera *rl.Camera3D, player rl.Vector3, deltaTime float32) {
cameraPitchRad := float64(cameraPitch) * rl.Deg2rad
camera.Position = 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)),
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 = player
camera.Target = targetPos
}

View File

@ -11,10 +11,11 @@ import (
const (
maxMessages = 50
chatWindowWidth = 400
chatMargin = 10 // Margin from screen edges
chatHeight = 200
messageHeight = 20
inputHeight = 30
runeLimit = 256
)
type Chat struct {
@ -29,7 +30,7 @@ type Chat struct {
func NewChat() *Chat {
return &Chat{
messages: make([]types.ChatMessage, 0, maxMessages),
inputBuffer: make([]rune, 0, 256),
inputBuffer: make([]rune, 0, runeLimit),
}
}
@ -52,6 +53,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),
}
@ -63,6 +65,12 @@ func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) {
}
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 {
@ -86,20 +94,38 @@ func (c *Chat) HandleServerMessages(messages []*pb.ChatMessage) {
}
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(10)
chatY := float32(screenHeight - chatHeight - 10)
chatX := float32(chatMargin)
chatY := float32(screenHeight - chatHeight - chatMargin)
rl.DrawRectangle(int32(chatX), int32(chatY), chatWindowWidth, chatHeight, rl.ColorAlpha(rl.Black, 0.5))
// Draw messages
// Draw messages from oldest to newest
messageY := chatY + 5
startIdx := len(c.messages) - 1 - c.scrollOffset
endIdx := max(0, startIdx-int((chatHeight-inputHeight)/messageHeight))
visibleMessages := int((chatHeight - inputHeight) / messageHeight)
for i := startIdx; i >= endIdx && i >= 0; i-- {
// 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]
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
}
@ -136,14 +162,14 @@ func (c *Chat) Update() (string, bool) {
key := rl.GetCharPressed()
for key > 0 {
if len(c.inputBuffer) < 256 {
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) {
if rl.IsKeyPressed(rl.KeyEnter) || rl.IsKeyPressed(rl.KeyKpEnter) {
if len(c.inputBuffer) > 0 {
message := string(c.inputBuffer)
c.inputBuffer = c.inputBuffer[:0]
@ -154,7 +180,7 @@ func (c *Chat) Update() (string, bool) {
c.isTyping = false
}
if rl.IsKeyPressed(rl.KeyEscape) {
if rl.IsKeyPressed(rl.KeyEscape) && c.isTyping {
c.inputBuffer = c.inputBuffer[:0]
c.cursorPos = 0
c.isTyping = false
@ -183,6 +209,13 @@ func max(a, b int) int {
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

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"
@ -16,18 +18,15 @@ type Game struct {
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{
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,
},
OtherPlayers: make(map[int32]*types.Player),
Camera: rl.Camera3D{
Position: rl.NewVector3(0, 10, 10),
@ -37,8 +36,9 @@ func New() *Game {
Projection: rl.CameraPerspective,
},
Chat: NewChat(),
QuitChan: make(chan struct{}),
loginScreen: NewLoginScreen(),
}
game.Player.UserData = game
game.Chat.userData = game
return game
}
@ -59,6 +59,46 @@ 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
}
// 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{
@ -125,7 +165,7 @@ func (g *Game) DrawPlayer(player *types.Player, model rl.Model) {
rl.DrawModel(model, playerPos, 16, rl.White)
if player.FloatingMessage != nil && time.Now().Before(player.FloatingMessage.ExpireTime) {
if player.FloatingMessage != nil {
screenPos := rl.GetWorldToScreen(rl.Vector3{
X: playerPos.X,
Y: playerPos.Y + 24.0,
@ -133,8 +173,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 {
@ -160,14 +198,27 @@ 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)
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
@ -196,8 +247,17 @@ func (g *Game) Render() {
drawFloatingMessage(other.FloatingMessage)
}
rl.DrawFPS(10, 10)
// 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()
}
@ -223,3 +283,72 @@ func (g *Game) HandleInput() {
}
}
}
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)
}

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
}

12
go.mod
View File

@ -3,15 +3,15 @@ module gitea.boner.be/bdnugget/goonscape
go 1.23.0
require (
gitea.boner.be/bdnugget/goonserver v0.0.0-20241011195320-f16e8647dc6b
github.com/gen2brain/raylib-go/raylib v0.0.0-20240930075631-c66f9e2942fe
google.golang.org/protobuf v1.35.1
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/ebitengine/purego v0.8.0 // indirect
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
golang.org/x/sys v0.26.0 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/sys v0.29.0 // indirect
)
replace gitea.boner.be/bdnugget/goonserver => ./goonserver

20
go.sum
View File

@ -1,12 +1,12 @@
github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE=
github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
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/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=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
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=

45
main.go
View File

@ -1,7 +1,9 @@
package main
import (
"flag"
"log"
"strings"
"gitea.boner.be/bdnugget/goonscape/game"
"gitea.boner.be/bdnugget/goonscape/network"
@ -9,39 +11,50 @@ import (
)
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.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)
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()
}
// Wait for clean shutdown
<-game.QuitChan
}

View File

@ -1,50 +1,124 @@
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"
)
func ConnectToServer() (net.Conn, int32, error) {
conn, err := net.Dial("tcp", types.ServerAddr)
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. Waiting for player ID...")
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.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(buf[:n], &response); err != nil {
log.Printf("Failed to unmarshal server response: %v", err)
return nil, 0, err
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 connected with player ID: %d", playerID)
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) {
buf := make([]byte, 4096)
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 range actionTicker.C {
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))
@ -59,13 +133,7 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play
player.ActionQueue = player.ActionQueue[:0]
player.Unlock()
data, err := proto.Marshal(batch)
if err != nil {
log.Printf("Failed to marshal action batch: %v", err)
continue
}
if _, err = conn.Write(data); err != nil {
if err := writeMessage(conn, batch); err != nil {
log.Printf("Failed to send actions to server: %v", err)
return
}
@ -73,17 +141,43 @@ func HandleServerCommunication(conn net.Conn, playerID int32, player *types.Play
player.Unlock()
}
}
}
}()
for {
n, err := conn.Read(buf)
if err != nil {
log.Printf("Failed to read from server: %v", err)
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(buf[:n], &serverMessage); err != nil {
if err := proto.Unmarshal(messageBuf, &serverMessage); err != nil {
log.Printf("Failed to unmarshal server message: %v", err)
continue
}
@ -103,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
}
@ -114,8 +220,35 @@ 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)
}
}
}
}
// 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
}

BIN
resources/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

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

@ -27,8 +27,9 @@ type Player struct {
CurrentTick int64
LastUpdateTime time.Time
InterpolationProgress float32
UserData interface{} // Used to store reference to game
UserData interface{}
FloatingMessage *FloatingMessage
QuitDone chan struct{}
}
type ModelAsset struct {
@ -38,6 +39,7 @@ type ModelAsset struct {
type ChatMessage struct {
PlayerID int32
Username string
Content string
Time time.Time
}
@ -48,6 +50,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
@ -58,6 +64,4 @@ const (
ServerTickRate = 600 * time.Millisecond
ClientTickRate = 50 * time.Millisecond
MaxTickDesync = 5
// ServerAddr = "localhost:6969"
ServerAddr = "boner.be:6969"
)