470 lines
11 KiB
Go
470 lines
11 KiB
Go
package game
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"sync/atomic"
|
|
|
|
"gitea.boner.be/bdnugget/goonscape/assets"
|
|
"gitea.boner.be/bdnugget/goonscape/config"
|
|
"gitea.boner.be/bdnugget/goonscape/logging"
|
|
"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"
|
|
)
|
|
|
|
var audioMutex sync.Mutex
|
|
var audioInitOnce sync.Once
|
|
|
|
type Game struct {
|
|
ctx *GameContext
|
|
Player *types.Player
|
|
OtherPlayers sync.Map // Using sync.Map for concurrent access
|
|
Camera rl.Camera3D
|
|
Models []types.ModelAsset
|
|
Music rl.Music
|
|
Chat *Chat
|
|
MenuOpen atomic.Bool
|
|
QuitChan chan struct{} // Channel to signal shutdown
|
|
loginScreen *LoginScreen
|
|
isLoggedIn atomic.Bool
|
|
}
|
|
|
|
func New() *Game {
|
|
InitWorld()
|
|
game := &Game{
|
|
ctx: NewGameContext(),
|
|
OtherPlayers: sync.Map{},
|
|
Camera: rl.Camera3D{
|
|
Position: rl.NewVector3(0, 10, 10),
|
|
Target: rl.NewVector3(0, 0, 0),
|
|
Up: rl.NewVector3(0, 1, 0),
|
|
Fovy: 45.0,
|
|
Projection: rl.CameraPerspective,
|
|
},
|
|
Chat: NewChat(),
|
|
QuitChan: make(chan struct{}),
|
|
loginScreen: NewLoginScreen(),
|
|
}
|
|
game.Chat.userData = game
|
|
return game
|
|
}
|
|
|
|
func (g *Game) LoadAssets() error {
|
|
audioMutex.Lock()
|
|
defer audioMutex.Unlock()
|
|
|
|
logging.Info.Println("Loading game assets")
|
|
var err error
|
|
|
|
// Load models first
|
|
g.Models, err = assets.LoadModels()
|
|
if err != nil {
|
|
logging.Error.Printf("Failed to load models: %v", err)
|
|
return err
|
|
}
|
|
|
|
// Load music only if enabled
|
|
if config.Current.PlayMusic {
|
|
logging.Info.Println("Loading music stream")
|
|
g.Music = rl.LoadMusicStream("resources/audio/GoonScape2.mp3")
|
|
if g.Music.CtxType == 0 {
|
|
logging.Error.Println("Failed to load music stream")
|
|
return fmt.Errorf("failed to load music stream")
|
|
}
|
|
logging.Info.Println("Music stream loaded successfully")
|
|
} else {
|
|
logging.Info.Println("Music disabled by config")
|
|
}
|
|
|
|
logging.Info.Println("Assets loaded successfully")
|
|
return nil
|
|
}
|
|
|
|
func (g *Game) Update(deltaTime float32) {
|
|
if !g.isLoggedIn.Load() {
|
|
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.Store(true)
|
|
return
|
|
}
|
|
g.loginScreen.Draw()
|
|
return
|
|
}
|
|
|
|
// Handle ESC for menu
|
|
if rl.IsKeyPressed(rl.KeyEscape) {
|
|
g.MenuOpen.Store(!g.MenuOpen.Load())
|
|
return
|
|
}
|
|
|
|
// Don't process other inputs if menu is open
|
|
if g.MenuOpen.Load() {
|
|
return
|
|
}
|
|
|
|
if message, sent := g.Chat.Update(); sent {
|
|
g.Player.Lock()
|
|
g.Player.ActionQueue = append(g.Player.ActionQueue, &pb.Action{
|
|
Type: pb.Action_CHAT,
|
|
ChatMessage: message,
|
|
PlayerId: g.Player.ID,
|
|
})
|
|
g.Player.Unlock()
|
|
}
|
|
|
|
g.HandleInput()
|
|
|
|
if len(g.Player.TargetPath) > 0 {
|
|
g.Player.Lock()
|
|
if len(g.Player.TargetPath) > 0 {
|
|
g.Player.MoveTowards(g.Player.TargetPath[0], deltaTime, GetMapGrid())
|
|
}
|
|
g.Player.Unlock()
|
|
}
|
|
|
|
g.OtherPlayers.Range(func(key, value any) bool {
|
|
other := value.(*types.Player)
|
|
if len(other.TargetPath) > 0 {
|
|
other.MoveTowards(other.TargetPath[0], deltaTime, GetMapGrid())
|
|
}
|
|
return true
|
|
})
|
|
|
|
UpdateCamera(&g.Camera, g.Player.PosActual, deltaTime)
|
|
}
|
|
|
|
func (g *Game) DrawMap() {
|
|
for x := 0; x < types.MapWidth; x++ {
|
|
for y := 0; y < types.MapHeight; y++ {
|
|
height := GetTileHeight(x, y)
|
|
|
|
// Interpolate height for smoother landscape
|
|
if x > 0 {
|
|
height += GetTileHeight(x-1, y)
|
|
}
|
|
if y > 0 {
|
|
height += GetTileHeight(x, y-1)
|
|
}
|
|
if x > 0 && y > 0 {
|
|
height += GetTileHeight(x-1, y-1)
|
|
}
|
|
height /= 4.0
|
|
|
|
tilePos := rl.Vector3{
|
|
X: float32(x * types.TileSize),
|
|
Y: height * types.TileHeight,
|
|
Z: float32(y * types.TileSize),
|
|
}
|
|
color := rl.Color{R: uint8(height * 25), G: 100, B: 100, A: 64}
|
|
rl.DrawCube(tilePos, types.TileSize, types.TileHeight, types.TileSize, color)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (g *Game) DrawPlayer(player *types.Player) {
|
|
player.Lock()
|
|
defer player.Unlock()
|
|
|
|
if player.Model.Meshes == nil {
|
|
logging.Error.Println("Player model not initialized")
|
|
return
|
|
}
|
|
|
|
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 + 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(player.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(player.Model, anim, player.AnimationFrame)
|
|
}
|
|
}
|
|
|
|
rl.DrawModel(player.Model, playerPos, 16, rl.White)
|
|
|
|
// Draw floating messages and path indicators
|
|
if player.FloatingMessage != nil {
|
|
screenPos := rl.GetWorldToScreen(rl.Vector3{
|
|
X: playerPos.X,
|
|
Y: playerPos.Y + 24.0,
|
|
Z: playerPos.Z,
|
|
}, g.Camera)
|
|
|
|
player.FloatingMessage.ScreenPos = screenPos
|
|
}
|
|
|
|
if len(player.TargetPath) > 0 {
|
|
targetTile := player.TargetPath[len(player.TargetPath)-1]
|
|
targetPos := rl.Vector3{
|
|
X: float32(targetTile.X * types.TileSize),
|
|
Y: grid[targetTile.X][targetTile.Y].Height * types.TileHeight,
|
|
Z: float32(targetTile.Y * types.TileSize),
|
|
}
|
|
rl.DrawCubeWires(targetPos, types.TileSize, types.TileHeight, types.TileSize, rl.Green)
|
|
|
|
nextTile := player.TargetPath[0]
|
|
nextPos := rl.Vector3{
|
|
X: float32(nextTile.X * types.TileSize),
|
|
Y: grid[nextTile.X][nextTile.Y].Height * types.TileHeight,
|
|
Z: float32(nextTile.Y * types.TileSize),
|
|
}
|
|
rl.DrawCubeWires(nextPos, types.TileSize, types.TileHeight, types.TileSize, rl.Yellow)
|
|
}
|
|
}
|
|
|
|
func (g *Game) Render() {
|
|
if !rl.IsWindowReady() {
|
|
logging.Error.Println("Window not ready for rendering")
|
|
return
|
|
}
|
|
|
|
rl.BeginDrawing()
|
|
defer func() {
|
|
if rl.IsWindowReady() {
|
|
rl.EndDrawing()
|
|
}
|
|
}()
|
|
|
|
if !g.isLoggedIn.Load() {
|
|
g.loginScreen.Draw()
|
|
return
|
|
}
|
|
|
|
rl.BeginMode3D(g.Camera)
|
|
g.DrawMap()
|
|
g.DrawPlayer(g.Player)
|
|
|
|
g.OtherPlayers.Range(func(key, value any) bool {
|
|
other := value.(*types.Player)
|
|
if other.Model.Meshes == nil {
|
|
g.AssignModelToPlayer(other)
|
|
}
|
|
g.DrawPlayer(other)
|
|
return true
|
|
})
|
|
|
|
rl.EndMode3D()
|
|
|
|
// Draw floating messages
|
|
drawFloatingMessage := func(msg *types.FloatingMessage) {
|
|
if msg == nil || time.Now().After(msg.ExpireTime) {
|
|
return
|
|
}
|
|
pos := msg.ScreenPos
|
|
text := msg.Content
|
|
textWidth := rl.MeasureText(text, 20)
|
|
|
|
for offsetX := -2; offsetX <= 2; offsetX++ {
|
|
for offsetY := -2; offsetY <= 2; offsetY++ {
|
|
rl.DrawText(text,
|
|
int32(pos.X)-textWidth/2+int32(offsetX),
|
|
int32(pos.Y)+int32(offsetY),
|
|
20,
|
|
rl.Black)
|
|
}
|
|
}
|
|
rl.DrawText(text, int32(pos.X)-textWidth/2, int32(pos.Y), 20, rl.Yellow)
|
|
}
|
|
|
|
if g.Player.FloatingMessage != nil {
|
|
drawFloatingMessage(g.Player.FloatingMessage)
|
|
}
|
|
|
|
g.OtherPlayers.Range(func(key, value any) bool {
|
|
other := value.(*types.Player)
|
|
drawFloatingMessage(other.FloatingMessage)
|
|
return true
|
|
})
|
|
|
|
// Draw menu if open
|
|
if g.MenuOpen.Load() {
|
|
g.DrawMenu()
|
|
}
|
|
|
|
// Only draw chat if menu is not open
|
|
if !g.MenuOpen.Load() {
|
|
g.Chat.Draw(int32(rl.GetScreenWidth()), int32(rl.GetScreenHeight()))
|
|
}
|
|
|
|
rl.DrawFPS(10, 10)
|
|
}
|
|
|
|
func (g *Game) Cleanup() {
|
|
// Unload models
|
|
if g.Models != nil {
|
|
assets.UnloadModels(g.Models)
|
|
}
|
|
|
|
// Stop and unload music if enabled
|
|
if config.Current.PlayMusic && g.Music.CtxType != 0 {
|
|
rl.StopMusicStream(g.Music)
|
|
rl.UnloadMusicStream(g.Music)
|
|
}
|
|
|
|
// Close audio device if it's ready
|
|
if rl.IsAudioDeviceReady() {
|
|
rl.CloseAudioDevice()
|
|
}
|
|
}
|
|
|
|
func (g *Game) HandleInput() {
|
|
clickedTile, clicked := g.GetTileAtMouse()
|
|
if clicked {
|
|
path := FindPath(GetTile(g.Player.PosTile.X, g.Player.PosTile.Y), clickedTile)
|
|
if len(path) > 1 {
|
|
g.Player.Lock()
|
|
g.Player.TargetPath = path[1:]
|
|
g.Player.ActionQueue = append(g.Player.ActionQueue, &pb.Action{
|
|
Type: pb.Action_MOVE,
|
|
X: int32(clickedTile.X),
|
|
Y: int32(clickedTile.Y),
|
|
PlayerId: g.Player.ID,
|
|
})
|
|
g.Player.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (g *Game) DrawMenu() {
|
|
screenWidth := float32(rl.GetScreenWidth())
|
|
screenHeight := float32(rl.GetScreenHeight())
|
|
|
|
// Semi-transparent background
|
|
rl.DrawRectangle(0, 0, int32(screenWidth), int32(screenHeight), rl.ColorAlpha(rl.Black, 0.7))
|
|
|
|
// Menu title
|
|
title := "Menu"
|
|
titleSize := int32(40)
|
|
titleWidth := rl.MeasureText(title, titleSize)
|
|
rl.DrawText(title, int32(screenWidth/2)-titleWidth/2, 100, titleSize, rl.White)
|
|
|
|
// Menu buttons
|
|
buttonWidth := float32(200)
|
|
buttonHeight := float32(40)
|
|
buttonY := float32(200)
|
|
buttonSpacing := float32(60)
|
|
|
|
menuItems := []string{"Resume", "Settings", "Exit Game"}
|
|
for _, item := range menuItems {
|
|
buttonRect := rl.Rectangle{
|
|
X: screenWidth/2 - buttonWidth/2,
|
|
Y: buttonY,
|
|
Width: buttonWidth,
|
|
Height: buttonHeight,
|
|
}
|
|
|
|
// Check mouse hover
|
|
mousePoint := rl.GetMousePosition()
|
|
mouseHover := rl.CheckCollisionPointRec(mousePoint, buttonRect)
|
|
|
|
// Draw button
|
|
if mouseHover {
|
|
rl.DrawRectangleRec(buttonRect, rl.ColorAlpha(rl.White, 0.3))
|
|
if rl.IsMouseButtonPressed(rl.MouseLeftButton) {
|
|
switch item {
|
|
case "Resume":
|
|
g.MenuOpen.Store(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)
|
|
}
|
|
|
|
func (g *Game) AssignModelToPlayer(player *types.Player) {
|
|
modelIndex := int(player.ID) % len(g.Models)
|
|
modelAsset := g.Models[modelIndex]
|
|
|
|
player.Model = modelAsset.Model
|
|
player.Texture = modelAsset.Texture
|
|
player.AnimationFrame = 0
|
|
}
|
|
|
|
func (g *Game) Run() {
|
|
if config.Current.PlayMusic {
|
|
audioInitOnce.Do(func() {
|
|
logging.Info.Println("Initializing audio device")
|
|
rl.InitAudioDevice()
|
|
if !rl.IsAudioDeviceReady() {
|
|
logging.Error.Println("Failed to initialize audio device")
|
|
}
|
|
})
|
|
defer func() {
|
|
logging.Info.Println("Closing audio device")
|
|
rl.CloseAudioDevice()
|
|
}()
|
|
}
|
|
|
|
logging.Info.Println("Starting game loop")
|
|
for !rl.WindowShouldClose() {
|
|
deltaTime := rl.GetFrameTime()
|
|
if config.Current.PlayMusic {
|
|
rl.UpdateMusicStream(g.Music)
|
|
}
|
|
g.Update(deltaTime)
|
|
g.Render()
|
|
}
|
|
logging.Info.Println("Game loop ended")
|
|
|
|
logging.Info.Println("Closing quit channel")
|
|
close(g.QuitChan)
|
|
}
|