463 lines
11 KiB
Go
463 lines
11 KiB
Go
package game
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"a301_game_server/config"
|
|
"a301_game_server/internal/ai"
|
|
"a301_game_server/internal/auth"
|
|
"a301_game_server/internal/db"
|
|
"a301_game_server/internal/db/repository"
|
|
"a301_game_server/internal/network"
|
|
"a301_game_server/internal/player"
|
|
"a301_game_server/internal/world"
|
|
"a301_game_server/pkg/logger"
|
|
"a301_game_server/pkg/mathutil"
|
|
pb "a301_game_server/proto/gen/pb"
|
|
)
|
|
|
|
const defaultZoneID uint32 = 1
|
|
|
|
// GameServer is the top-level orchestrator that connects networking with game logic.
|
|
type GameServer struct {
|
|
cfg *config.Config
|
|
world *World
|
|
sessions *player.SessionManager
|
|
dbPool *db.Pool
|
|
authSvc *auth.Service
|
|
charRepo *repository.CharacterRepo
|
|
|
|
mu sync.RWMutex
|
|
connPlayer map[uint64]*player.Player // connID -> player
|
|
playerConn map[uint64]uint64 // playerID -> connID
|
|
|
|
nextPlayerID atomic.Uint64
|
|
cancelSave context.CancelFunc
|
|
}
|
|
|
|
// NewGameServer creates the game server.
|
|
func NewGameServer(cfg *config.Config, dbPool *db.Pool) *GameServer {
|
|
gs := &GameServer{
|
|
cfg: cfg,
|
|
world: NewWorld(cfg),
|
|
sessions: player.NewSessionManager(),
|
|
dbPool: dbPool,
|
|
authSvc: auth.NewService(dbPool),
|
|
charRepo: repository.NewCharacterRepo(dbPool),
|
|
connPlayer: make(map[uint64]*player.Player),
|
|
playerConn: make(map[uint64]uint64),
|
|
}
|
|
|
|
// Create zones, portals, and mobs.
|
|
gs.setupWorld()
|
|
|
|
return gs
|
|
}
|
|
|
|
// World returns the game world.
|
|
func (gs *GameServer) World() *World { return gs.world }
|
|
|
|
// Start launches all zone game loops and periodic save.
|
|
func (gs *GameServer) Start() {
|
|
gs.world.mu.RLock()
|
|
for _, zone := range gs.world.zones {
|
|
zone.SetMessageHandler(gs)
|
|
zone.SetZoneTransferCallback(gs.handleZoneTransfer)
|
|
}
|
|
gs.world.mu.RUnlock()
|
|
|
|
gs.world.StartAll()
|
|
|
|
// Start periodic character save.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
gs.cancelSave = cancel
|
|
go gs.periodicSave(ctx)
|
|
}
|
|
|
|
// Stop shuts down all zone game loops and saves all players.
|
|
func (gs *GameServer) Stop() {
|
|
if gs.cancelSave != nil {
|
|
gs.cancelSave()
|
|
}
|
|
|
|
// Final save of all online players.
|
|
gs.saveAllPlayers()
|
|
|
|
gs.world.StopAll()
|
|
}
|
|
|
|
// OnPacket handles incoming packets from a connection.
|
|
func (gs *GameServer) OnPacket(conn *network.Connection, pkt *network.Packet) {
|
|
switch pkt.Type {
|
|
case network.MsgLoginRequest:
|
|
gs.handleLogin(conn, pkt)
|
|
case network.MsgEnterWorldRequest:
|
|
gs.handleEnterWorld(conn, pkt)
|
|
default:
|
|
gs.mu.RLock()
|
|
p, ok := gs.connPlayer[conn.ID()]
|
|
gs.mu.RUnlock()
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
zone, err := gs.world.GetZone(p.ZoneID())
|
|
if err != nil {
|
|
return
|
|
}
|
|
zone.EnqueueMessage(PlayerMessage{PlayerID: p.EntityID(), Packet: pkt})
|
|
}
|
|
}
|
|
|
|
// OnDisconnect handles a connection closing.
|
|
func (gs *GameServer) OnDisconnect(conn *network.Connection) {
|
|
gs.mu.Lock()
|
|
p, ok := gs.connPlayer[conn.ID()]
|
|
if !ok {
|
|
gs.mu.Unlock()
|
|
return
|
|
}
|
|
delete(gs.connPlayer, conn.ID())
|
|
delete(gs.playerConn, p.EntityID())
|
|
gs.mu.Unlock()
|
|
|
|
// Save character to DB on disconnect.
|
|
if p.CharID() != 0 {
|
|
if err := gs.charRepo.Save(context.Background(), p.ToCharacterData()); err != nil {
|
|
logger.Error("failed to save player on disconnect", "playerID", p.EntityID(), "error", err)
|
|
}
|
|
}
|
|
|
|
zone, err := gs.world.GetZone(p.ZoneID())
|
|
if err == nil {
|
|
zone.EnqueueMessage(PlayerMessage{
|
|
PlayerID: p.EntityID(),
|
|
Packet: &network.Packet{Type: msgPlayerDisconnect},
|
|
})
|
|
}
|
|
|
|
logger.Info("player disconnected", "connID", conn.ID(), "playerID", p.EntityID())
|
|
}
|
|
|
|
// Internal message types.
|
|
const (
|
|
msgPlayerDisconnect uint16 = 0xFFFF
|
|
msgPlayerEnterWorld uint16 = 0xFFFE
|
|
)
|
|
|
|
// HandleZoneMessage implements ZoneMessageHandler.
|
|
func (gs *GameServer) HandleZoneMessage(zone *Zone, msg PlayerMessage) bool {
|
|
switch msg.Packet.Type {
|
|
case msgPlayerDisconnect:
|
|
zone.RemovePlayer(msg.PlayerID)
|
|
return true
|
|
case msgPlayerEnterWorld:
|
|
gs.mu.RLock()
|
|
var found *player.Player
|
|
for _, p := range gs.connPlayer {
|
|
if p.EntityID() == msg.PlayerID {
|
|
found = p
|
|
break
|
|
}
|
|
}
|
|
gs.mu.RUnlock()
|
|
if found != nil {
|
|
zone.AddPlayer(found)
|
|
}
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (gs *GameServer) handleLogin(conn *network.Connection, pkt *network.Packet) {
|
|
req := pkt.Payload.(*pb.LoginRequest)
|
|
ctx := context.Background()
|
|
|
|
// Try login first.
|
|
accountID, err := gs.authSvc.Login(ctx, req.Username, req.Password)
|
|
if err != nil {
|
|
// Auto-register if account doesn't exist.
|
|
accountID, err = gs.authSvc.Register(ctx, req.Username, req.Password)
|
|
if err != nil {
|
|
conn.Send(network.MsgLoginResponse, &pb.LoginResponse{
|
|
Success: false,
|
|
ErrorMessage: "login failed: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Create default character on first registration.
|
|
_, charErr := gs.charRepo.Create(ctx, accountID, req.Username)
|
|
if charErr != nil {
|
|
logger.Error("failed to create default character", "accountID", accountID, "error", charErr)
|
|
}
|
|
}
|
|
|
|
session := gs.sessions.Create(uint64(accountID), req.Username)
|
|
|
|
conn.Send(network.MsgLoginResponse, &pb.LoginResponse{
|
|
Success: true,
|
|
SessionToken: session.Token,
|
|
PlayerId: uint64(accountID),
|
|
})
|
|
|
|
logger.Info("player logged in", "username", req.Username, "accountID", accountID)
|
|
}
|
|
|
|
func (gs *GameServer) handleEnterWorld(conn *network.Connection, pkt *network.Packet) {
|
|
req := pkt.Payload.(*pb.EnterWorldRequest)
|
|
|
|
session := gs.sessions.Get(req.SessionToken)
|
|
if session == nil {
|
|
conn.Send(network.MsgEnterWorldResponse, &pb.EnterWorldResponse{
|
|
Success: false,
|
|
ErrorMessage: "invalid session",
|
|
})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Load character from DB.
|
|
chars, err := gs.charRepo.GetByAccountID(ctx, int64(session.PlayerID))
|
|
if err != nil || len(chars) == 0 {
|
|
conn.Send(network.MsgEnterWorldResponse, &pb.EnterWorldResponse{
|
|
Success: false,
|
|
ErrorMessage: "no character found",
|
|
})
|
|
return
|
|
}
|
|
|
|
charData := chars[0] // Use first character for now.
|
|
p := player.NewPlayerFromDB(charData, conn)
|
|
|
|
// Register connection-player mapping.
|
|
gs.mu.Lock()
|
|
gs.connPlayer[conn.ID()] = p
|
|
gs.playerConn[p.EntityID()] = conn.ID()
|
|
gs.mu.Unlock()
|
|
|
|
zoneID := p.ZoneID()
|
|
zone, err := gs.world.GetZone(zoneID)
|
|
if err != nil {
|
|
// Fall back to default zone.
|
|
zoneID = defaultZoneID
|
|
p.SetZoneID(defaultZoneID)
|
|
zone, _ = gs.world.GetZone(defaultZoneID)
|
|
}
|
|
|
|
zone.EnqueueMessage(PlayerMessage{
|
|
PlayerID: p.EntityID(),
|
|
Packet: &network.Packet{Type: msgPlayerEnterWorld},
|
|
})
|
|
|
|
conn.Send(network.MsgEnterWorldResponse, &pb.EnterWorldResponse{
|
|
Success: true,
|
|
Self: p.ToProto(),
|
|
ZoneId: zoneID,
|
|
})
|
|
|
|
logger.Info("player entered world", "playerID", p.EntityID(), "charID", charData.ID, "zone", zoneID)
|
|
}
|
|
|
|
// periodicSave saves all dirty player data to DB at configured intervals.
|
|
func (gs *GameServer) periodicSave(ctx context.Context) {
|
|
ticker := time.NewTicker(gs.cfg.Database.SaveInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
gs.saveAllPlayers()
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (gs *GameServer) saveAllPlayers() {
|
|
gs.mu.RLock()
|
|
var dirty []*player.Player
|
|
for _, p := range gs.connPlayer {
|
|
if p.IsDirty() && p.CharID() != 0 {
|
|
dirty = append(dirty, p)
|
|
}
|
|
}
|
|
gs.mu.RUnlock()
|
|
|
|
if len(dirty) == 0 {
|
|
return
|
|
}
|
|
|
|
chars := make([]*repository.CharacterData, 0, len(dirty))
|
|
for _, p := range dirty {
|
|
chars = append(chars, p.ToCharacterData())
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if err := gs.charRepo.SaveBatch(ctx, chars); err != nil {
|
|
logger.Error("periodic save failed", "count", len(chars), "error", err)
|
|
return
|
|
}
|
|
|
|
for _, p := range dirty {
|
|
p.ClearDirty()
|
|
}
|
|
|
|
logger.Debug("periodic save completed", "count", len(chars))
|
|
}
|
|
|
|
// setupWorld creates all zones, portals, and mob spawn points.
|
|
func (gs *GameServer) setupWorld() {
|
|
zone1 := gs.world.CreateZone(1) // Starting zone - plains
|
|
zone2 := gs.world.CreateZone(2) // Forest zone - medium difficulty
|
|
zone3 := gs.world.CreateZone(3) // Volcano zone - hard
|
|
|
|
// Set spawn positions.
|
|
zone1.spawnPos = mathutil.NewVec3(0, 0, 0)
|
|
zone2.spawnPos = mathutil.NewVec3(5, 0, 5)
|
|
zone3.spawnPos = mathutil.NewVec3(10, 0, 10)
|
|
|
|
// Portals: Zone 1 <-> Zone 2
|
|
zone1.AddPortal(world.ZonePortal{
|
|
SourceZoneID: 1,
|
|
TriggerPos: mathutil.NewVec3(300, 0, 150),
|
|
TriggerRadius: 5.0,
|
|
TargetZoneID: 2,
|
|
TargetPos: mathutil.NewVec3(5, 0, 5),
|
|
})
|
|
zone2.AddPortal(world.ZonePortal{
|
|
SourceZoneID: 2,
|
|
TriggerPos: mathutil.NewVec3(0, 0, 0),
|
|
TriggerRadius: 5.0,
|
|
TargetZoneID: 1,
|
|
TargetPos: mathutil.NewVec3(295, 0, 150),
|
|
})
|
|
|
|
// Portals: Zone 2 <-> Zone 3
|
|
zone2.AddPortal(world.ZonePortal{
|
|
SourceZoneID: 2,
|
|
TriggerPos: mathutil.NewVec3(300, 0, 300),
|
|
TriggerRadius: 5.0,
|
|
TargetZoneID: 3,
|
|
TargetPos: mathutil.NewVec3(10, 0, 10),
|
|
})
|
|
zone3.AddPortal(world.ZonePortal{
|
|
SourceZoneID: 3,
|
|
TriggerPos: mathutil.NewVec3(0, 0, 0),
|
|
TriggerRadius: 5.0,
|
|
TargetZoneID: 2,
|
|
TargetPos: mathutil.NewVec3(295, 0, 295),
|
|
})
|
|
|
|
// Populate zones with mobs.
|
|
gs.setupZoneMobs(zone1, []mobSpawnConfig{
|
|
{mobID: 1, count: 3, baseX: 20, baseZ: 30, spacing: 15}, // Goblins
|
|
{mobID: 2, count: 2, baseX: 80, baseZ: 80, spacing: 12}, // Wolves
|
|
})
|
|
gs.setupZoneMobs(zone2, []mobSpawnConfig{
|
|
{mobID: 2, count: 4, baseX: 50, baseZ: 50, spacing: 15}, // Wolves
|
|
{mobID: 3, count: 2, baseX: 150, baseZ: 150, spacing: 20}, // Trolls
|
|
{mobID: 4, count: 1, baseX: 200, baseZ: 50, spacing: 0}, // Fire Elemental
|
|
})
|
|
gs.setupZoneMobs(zone3, []mobSpawnConfig{
|
|
{mobID: 4, count: 3, baseX: 80, baseZ: 80, spacing: 25}, // Fire Elementals
|
|
{mobID: 5, count: 1, baseX: 200, baseZ: 200, spacing: 0}, // Dragon Whelp
|
|
})
|
|
}
|
|
|
|
type mobSpawnConfig struct {
|
|
mobID uint32
|
|
count int
|
|
baseX float32
|
|
baseZ float32
|
|
spacing float32
|
|
}
|
|
|
|
// setupZoneMobs configures mob spawn points for a zone.
|
|
func (gs *GameServer) setupZoneMobs(zone *Zone, configs []mobSpawnConfig) {
|
|
registry := ai.NewMobRegistry()
|
|
spawner := zone.Spawner()
|
|
|
|
for _, cfg := range configs {
|
|
def := registry.Get(cfg.mobID)
|
|
if def == nil {
|
|
continue
|
|
}
|
|
|
|
respawn := time.Duration(15+def.Level*3) * time.Second
|
|
|
|
for i := 0; i < cfg.count; i++ {
|
|
spawner.AddSpawnPoint(ai.SpawnPoint{
|
|
MobDef: def,
|
|
Position: mathutil.NewVec3(cfg.baseX+float32(i)*cfg.spacing, 0, cfg.baseZ+float32(i)*cfg.spacing),
|
|
RespawnDelay: respawn,
|
|
MaxCount: 1,
|
|
})
|
|
}
|
|
}
|
|
|
|
spawner.InitialSpawn()
|
|
}
|
|
|
|
// handleZoneTransfer moves a player between zones.
|
|
func (gs *GameServer) handleZoneTransfer(playerID uint64, targetZoneID uint32, targetPos mathutil.Vec3) {
|
|
gs.mu.RLock()
|
|
var p *player.Player
|
|
var connID uint64
|
|
for cid, pl := range gs.connPlayer {
|
|
if pl.EntityID() == playerID {
|
|
p = pl
|
|
connID = cid
|
|
break
|
|
}
|
|
}
|
|
gs.mu.RUnlock()
|
|
|
|
if p == nil {
|
|
return
|
|
}
|
|
_ = connID
|
|
|
|
sourceZone, err := gs.world.GetZone(p.ZoneID())
|
|
if err != nil {
|
|
return
|
|
}
|
|
targetZone, err := gs.world.GetZone(targetZoneID)
|
|
if err != nil {
|
|
logger.Warn("zone transfer target not found", "targetZone", targetZoneID)
|
|
return
|
|
}
|
|
|
|
// Remove from source zone.
|
|
sourceZone.RemovePlayer(playerID)
|
|
|
|
// Update player state.
|
|
p.SetPosition(targetPos)
|
|
p.SetZoneID(targetZoneID)
|
|
|
|
// Add to target zone via message queue.
|
|
targetZone.EnqueueMessage(PlayerMessage{
|
|
PlayerID: playerID,
|
|
Packet: &network.Packet{Type: msgPlayerEnterWorld},
|
|
})
|
|
|
|
// Notify client of zone change.
|
|
p.Connection().Send(network.MsgZoneTransferNotify, &pb.ZoneTransferNotify{
|
|
NewZoneId: targetZoneID,
|
|
Self: p.ToProto(),
|
|
})
|
|
|
|
logger.Info("zone transfer",
|
|
"playerID", playerID,
|
|
"from", sourceZone.ID(),
|
|
"to", targetZoneID,
|
|
)
|
|
}
|