first commit
This commit is contained in:
462
internal/game/game_server.go
Normal file
462
internal/game/game_server.go
Normal file
@@ -0,0 +1,462 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user