Files
a301_game_server/internal/game/zone.go
2026-02-26 17:52:48 +09:00

666 lines
16 KiB
Go

package game
import (
"sync/atomic"
"time"
"a301_game_server/config"
"a301_game_server/internal/ai"
"a301_game_server/internal/combat"
"a301_game_server/internal/entity"
"a301_game_server/internal/network"
"a301_game_server/internal/world"
"a301_game_server/pkg/logger"
"a301_game_server/pkg/mathutil"
pb "a301_game_server/proto/gen/pb"
"google.golang.org/protobuf/proto"
)
const (
maxMoveSpeed float32 = 10.0 // units per second
)
// PlayerMessage is a message from a player connection queued for zone processing.
type PlayerMessage struct {
PlayerID uint64
Packet *network.Packet
}
// PlayerEntity wraps an entity.Entity that is also a connected player.
type PlayerEntity interface {
entity.Entity
Connection() *network.Connection
Velocity() mathutil.Vec3
SetVelocity(vel mathutil.Vec3)
}
// ZoneMessageHandler provides an extension point for handling custom message types in a zone.
type ZoneMessageHandler interface {
HandleZoneMessage(zone *Zone, msg PlayerMessage) bool // returns true if handled
}
// Zone is a self-contained game area with its own game loop.
type Zone struct {
id uint32
cfg *config.Config
entities map[uint64]entity.Entity
players map[uint64]PlayerEntity
aoi world.AOIManager
incoming chan PlayerMessage
stopCh chan struct{}
tick int64
// Metrics
lastTickDuration atomic.Int64
// AOI toggle support
aoiEnabled bool
gridAOI *world.GridAOI
broadcastAOI *world.BroadcastAllAOI
// Combat
combatMgr *combat.Manager
// AI / Mobs
spawner *ai.Spawner
nextEntityID atomic.Uint64
// External message handler for custom/internal messages.
extHandler ZoneMessageHandler
// Respawn position
spawnPos mathutil.Vec3
// Zone portals
portals []world.ZonePortal
// Zone transfer callback (set by GameServer)
onZoneTransfer func(playerID uint64, targetZoneID uint32, targetPos mathutil.Vec3)
}
// NewZone creates a new zone with the given configuration.
func NewZone(id uint32, cfg *config.Config) *Zone {
gridAOI := world.NewGridAOI(cfg.World.AOI.CellSize, cfg.World.AOI.ViewRange)
broadcastAOI := world.NewBroadcastAllAOI()
var activeAOI world.AOIManager
if cfg.World.AOI.Enabled {
activeAOI = gridAOI
} else {
activeAOI = broadcastAOI
}
cm := combat.NewManager()
z := &Zone{
id: id,
cfg: cfg,
entities: make(map[uint64]entity.Entity),
players: make(map[uint64]PlayerEntity),
aoi: activeAOI,
incoming: make(chan PlayerMessage, 4096),
stopCh: make(chan struct{}),
aoiEnabled: cfg.World.AOI.Enabled,
gridAOI: gridAOI,
broadcastAOI: broadcastAOI,
combatMgr: cm,
spawnPos: mathutil.NewVec3(0, 0, 0),
}
// Wire combat manager broadcast to zone AOI.
cm.SetBroadcast(z.broadcastCombatEvent, z.sendToEntity)
// Create mob spawner.
z.spawner = ai.NewSpawner(
&z.nextEntityID,
func(m *ai.Mob) { z.addMob(m) },
func(mobID uint64) { z.removeMob(mobID) },
)
return z
}
// ID returns the zone identifier.
func (z *Zone) ID() uint32 { return z.id }
// AddPortal registers a zone portal.
func (z *Zone) AddPortal(portal world.ZonePortal) {
z.portals = append(z.portals, portal)
}
// SetZoneTransferCallback sets the function called when a player enters a portal.
func (z *Zone) SetZoneTransferCallback(fn func(playerID uint64, targetZoneID uint32, targetPos mathutil.Vec3)) {
z.onZoneTransfer = fn
}
// PlayerCount returns the current number of players in this zone.
func (z *Zone) PlayerCount() int { return len(z.players) }
// EntityCount returns the current number of entities in this zone.
func (z *Zone) EntityCount() int { return len(z.entities) }
// LastTickDuration returns the duration of the last tick in microseconds.
func (z *Zone) LastTickDuration() int64 { return z.lastTickDuration.Load() }
// AOIEnabled returns whether grid-based AOI is currently active.
func (z *Zone) AOIEnabled() bool { return z.aoiEnabled }
// EnqueueMessage queues a player message for processing in the next tick.
func (z *Zone) EnqueueMessage(msg PlayerMessage) {
select {
case z.incoming <- msg:
default:
logger.Warn("zone message queue full, dropping", "zoneID", z.id, "playerID", msg.PlayerID)
}
}
// AddPlayer adds a player to the zone.
// Must be called from the zone's goroutine or before Run() starts.
func (z *Zone) AddPlayer(p PlayerEntity) {
z.entities[p.EntityID()] = p
z.players[p.EntityID()] = p
z.aoi.Add(p)
// Notify existing nearby players about the new player.
spawnData, _ := network.Encode(network.MsgSpawnEntity, &pb.SpawnEntity{Entity: p.ToProto()})
for _, nearby := range z.aoi.GetNearby(p) {
if np, ok := z.players[nearby.EntityID()]; ok {
np.Connection().SendRaw(spawnData)
}
}
logger.Info("player added to zone", "zoneID", z.id, "playerID", p.EntityID(), "players", len(z.players))
}
// RemovePlayer removes a player from the zone.
func (z *Zone) RemovePlayer(playerID uint64) {
entity, ok := z.entities[playerID]
if !ok {
return
}
events := z.aoi.Remove(entity)
z.handleAOIEvents(events)
z.combatMgr.RemoveEntity(playerID)
delete(z.entities, playerID)
delete(z.players, playerID)
logger.Info("player removed from zone", "zoneID", z.id, "playerID", playerID, "players", len(z.players))
}
// ToggleAOI switches between grid-based and broadcast-all AOI at runtime.
func (z *Zone) ToggleAOI(enabled bool) {
if z.aoiEnabled == enabled {
return
}
z.aoiEnabled = enabled
// Rebuild the target AOI manager with current entities.
var newAOI world.AOIManager
if enabled {
g := world.NewGridAOI(z.cfg.World.AOI.CellSize, z.cfg.World.AOI.ViewRange)
for _, e := range z.entities {
g.Add(e)
}
z.gridAOI = g
newAOI = g
} else {
b := world.NewBroadcastAllAOI()
for _, e := range z.entities {
b.Add(e)
}
z.broadcastAOI = b
newAOI = b
}
z.aoi = newAOI
// After toggle, send full spawn list to all players so they see the correct set.
for _, p := range z.players {
z.sendNearbySnapshot(p)
}
logger.Info("AOI toggled", "zoneID", z.id, "enabled", enabled)
}
// Run starts the zone's game loop. Blocks until Stop() is called.
func (z *Zone) Run() {
interval := z.cfg.TickInterval()
ticker := time.NewTicker(interval)
defer ticker.Stop()
logger.Info("zone started", "zoneID", z.id, "tickInterval", interval)
for {
select {
case <-ticker.C:
start := time.Now()
z.processTick()
z.lastTickDuration.Store(time.Since(start).Microseconds())
case <-z.stopCh:
logger.Info("zone stopped", "zoneID", z.id)
return
}
}
}
// Stop signals the zone's game loop to exit.
func (z *Zone) Stop() {
close(z.stopCh)
}
func (z *Zone) processTick() {
z.tick++
z.processInputQueue()
z.updateMovement()
z.updateAI()
z.updateCombat()
z.checkDeaths()
z.spawner.Update(time.Now())
z.broadcastState()
}
func (z *Zone) updateCombat() {
dt := z.cfg.TickInterval()
z.combatMgr.UpdateBuffs(dt, func(id uint64) combat.Combatant {
if p, ok := z.players[id]; ok {
if c, ok := p.(combat.Combatant); ok {
return c
}
}
return nil
})
}
func (z *Zone) processInputQueue() {
for {
select {
case msg := <-z.incoming:
z.handleMessage(msg)
default:
return
}
}
}
// SetMessageHandler sets an external handler for custom message types.
func (z *Zone) SetMessageHandler(h ZoneMessageHandler) {
z.extHandler = h
}
func (z *Zone) handleMessage(msg PlayerMessage) {
// Try external handler first (for internal messages like disconnect/enter).
if z.extHandler != nil && z.extHandler.HandleZoneMessage(z, msg) {
return
}
switch msg.Packet.Type {
case network.MsgMoveRequest:
z.handleMoveRequest(msg)
case network.MsgUseSkillRequest:
z.handleUseSkill(msg)
case network.MsgRespawnRequest:
z.handleRespawn(msg)
case network.MsgPing:
z.handlePing(msg)
case network.MsgAOIToggleRequest:
z.handleAOIToggle(msg)
case network.MsgMetricsRequest:
z.handleMetrics(msg)
}
}
func (z *Zone) handleMoveRequest(msg PlayerMessage) {
p, ok := z.players[msg.PlayerID]
if !ok {
return
}
req := msg.Packet.Payload.(*pb.MoveRequest)
newPos := mathutil.NewVec3(req.Position.X, req.Position.Y, req.Position.Z)
vel := mathutil.NewVec3(req.Velocity.X, req.Velocity.Y, req.Velocity.Z)
// Server-side speed validation.
if vel.Length() > maxMoveSpeed*1.1 { // 10% tolerance
vel = vel.Normalize().Scale(maxMoveSpeed)
}
oldPos := p.Position()
p.SetPosition(newPos)
p.SetRotation(req.Rotation)
p.SetVelocity(vel)
// Update AOI and handle events.
events := z.aoi.UpdatePosition(p, oldPos, newPos)
z.handleAOIEvents(events)
// Check portal triggers.
z.checkPortals(p, newPos)
}
func (z *Zone) handlePing(msg PlayerMessage) {
p, ok := z.players[msg.PlayerID]
if !ok {
return
}
ping := msg.Packet.Payload.(*pb.Ping)
p.Connection().Send(network.MsgPong, &pb.Pong{
ClientTime: ping.ClientTime,
ServerTime: time.Now().UnixMilli(),
})
}
func (z *Zone) handleAOIToggle(msg PlayerMessage) {
p, ok := z.players[msg.PlayerID]
if !ok {
return
}
req := msg.Packet.Payload.(*pb.AOIToggleRequest)
z.ToggleAOI(req.Enabled)
status := "disabled"
if req.Enabled {
status = "enabled"
}
p.Connection().Send(network.MsgAOIToggleResponse, &pb.AOIToggleResponse{
Enabled: req.Enabled,
Message: "AOI " + status,
})
}
func (z *Zone) handleMetrics(msg PlayerMessage) {
p, ok := z.players[msg.PlayerID]
if !ok {
return
}
p.Connection().Send(network.MsgServerMetrics, &pb.ServerMetrics{
OnlinePlayers: int32(len(z.players)),
TotalEntities: int32(len(z.entities)),
TickDurationUs: z.lastTickDuration.Load(),
AoiEnabled: z.aoiEnabled,
})
}
func (z *Zone) updateMovement() {
// Movement is applied immediately in handleMoveRequest (client-authoritative position
// with server validation). Future: add server-side physics/collision here.
}
func (z *Zone) broadcastState() {
if len(z.players) == 0 {
return
}
// For each player, send state updates of nearby entities.
for _, p := range z.players {
nearby := z.aoi.GetNearby(p)
if len(nearby) == 0 {
continue
}
states := make([]*pb.EntityState, 0, len(nearby))
for _, e := range nearby {
states = append(states, e.ToProto())
}
p.Connection().Send(network.MsgStateUpdate, &pb.StateUpdate{
Entities: states,
ServerTick: z.tick,
})
}
}
func (z *Zone) handleAOIEvents(events []world.AOIEvent) {
for _, evt := range events {
observerPlayer, ok := z.players[evt.Observer.EntityID()]
if !ok {
continue
}
switch evt.Type {
case world.AOIEnter:
observerPlayer.Connection().Send(network.MsgSpawnEntity, &pb.SpawnEntity{
Entity: evt.Target.ToProto(),
})
case world.AOILeave:
observerPlayer.Connection().Send(network.MsgDespawnEntity, &pb.DespawnEntity{
EntityId: evt.Target.EntityID(),
})
}
}
}
func (z *Zone) sendNearbySnapshot(p PlayerEntity) {
nearby := z.aoi.GetNearby(p)
states := make([]*pb.EntityState, 0, len(nearby))
for _, e := range nearby {
states = append(states, e.ToProto())
}
p.Connection().Send(network.MsgStateUpdate, &pb.StateUpdate{
Entities: states,
ServerTick: z.tick,
})
}
// ─── Combat Handlers ────────────────────────────────────────
func (z *Zone) handleUseSkill(msg PlayerMessage) {
p, ok := z.players[msg.PlayerID]
if !ok {
return
}
req := msg.Packet.Payload.(*pb.UseSkillRequest)
var targetPos mathutil.Vec3
if req.TargetPos != nil {
targetPos = mathutil.NewVec3(req.TargetPos.X, req.TargetPos.Y, req.TargetPos.Z)
}
caster, ok := p.(combat.Combatant)
if !ok {
return
}
success, errMsg := z.combatMgr.UseSkill(
caster,
req.SkillId,
req.TargetId,
targetPos,
func(id uint64) entity.Entity { return z.entities[id] },
z.getEntitiesInRadius,
)
p.Connection().Send(network.MsgUseSkillResponse, &pb.UseSkillResponse{
Success: success,
ErrorMessage: errMsg,
})
}
func (z *Zone) handleRespawn(msg PlayerMessage) {
p, ok := z.players[msg.PlayerID]
if !ok {
return
}
c, ok := p.(combat.Combatant)
if !ok || c.IsAlive() {
return
}
oldPos := p.Position()
z.combatMgr.Respawn(c, z.spawnPos)
// Update AOI for the new position.
events := z.aoi.UpdatePosition(p, oldPos, z.spawnPos)
z.handleAOIEvents(events)
// Notify respawn.
respawnEvt := &pb.CombatEvent{
TargetId: p.EntityID(),
TargetHp: c.HP(),
TargetMaxHp: c.MaxHP(),
EventType: pb.CombatEventType_COMBAT_EVENT_RESPAWN,
}
z.broadcastCombatEvent(p, network.MsgCombatEvent, respawnEvt)
p.Connection().Send(network.MsgRespawnResponse, &pb.RespawnResponse{
Self: p.ToProto(),
})
}
// broadcastCombatEvent sends a combat event to all players who can see the entity.
func (z *Zone) broadcastCombatEvent(ent entity.Entity, msgType uint16, msg interface{}) {
protoMsg, ok := msg.(proto.Message)
if !ok {
return
}
data, err := network.Encode(msgType, protoMsg)
if err != nil {
return
}
// Send to the entity itself if it's a player.
if p, ok := z.players[ent.EntityID()]; ok {
p.Connection().SendRaw(data)
}
// Send to nearby players.
for _, nearby := range z.aoi.GetNearby(ent) {
if p, ok := z.players[nearby.EntityID()]; ok {
p.Connection().SendRaw(data)
}
}
}
// sendToEntity sends a message to a specific entity (if it's a player).
func (z *Zone) sendToEntity(entityID uint64, msgType uint16, msg interface{}) {
p, ok := z.players[entityID]
if !ok {
return
}
protoMsg, ok := msg.(proto.Message)
if !ok {
return
}
p.Connection().Send(msgType, protoMsg)
}
// getEntitiesInRadius returns all entities within a radius of a point.
func (z *Zone) getEntitiesInRadius(center mathutil.Vec3, radius float32) []entity.Entity {
radiusSq := radius * radius
var result []entity.Entity
for _, e := range z.entities {
if e.Position().DistanceSqTo(center) <= radiusSq {
result = append(result, e)
}
}
return result
}
// ─── AI / Mob Management ────────────────────────────────────
// Spawner returns the zone's mob spawner for external configuration.
func (z *Zone) Spawner() *ai.Spawner { return z.spawner }
func (z *Zone) addMob(m *ai.Mob) {
z.entities[m.EntityID()] = m
z.aoi.Add(m)
// Notify nearby players about the new mob.
spawnData, _ := network.Encode(network.MsgSpawnEntity, &pb.SpawnEntity{Entity: m.ToProto()})
for _, nearby := range z.aoi.GetNearby(m) {
if p, ok := z.players[nearby.EntityID()]; ok {
p.Connection().SendRaw(spawnData)
}
}
}
func (z *Zone) removeMob(mobID uint64) {
ent, ok := z.entities[mobID]
if !ok {
return
}
events := z.aoi.Remove(ent)
z.handleAOIEvents(events)
z.combatMgr.RemoveEntity(mobID)
delete(z.entities, mobID)
}
func (z *Zone) updateAI() {
dt := z.cfg.TickInterval()
for _, m := range z.spawner.AliveMobs() {
oldPos := m.Position()
ai.UpdateMob(m, dt, z, z)
newPos := m.Position()
// Update AOI if mob moved.
if oldPos != newPos {
events := z.aoi.UpdatePosition(m, oldPos, newPos)
z.handleAOIEvents(events)
}
}
}
func (z *Zone) checkDeaths() {
for _, m := range z.spawner.AliveMobs() {
if !m.IsAlive() {
z.spawner.NotifyDeath(m.EntityID())
}
}
}
// ─── ai.EntityProvider implementation ───────────────────────
func (z *Zone) GetEntity(id uint64) entity.Entity {
return z.entities[id]
}
func (z *Zone) GetPlayersInRange(center mathutil.Vec3, radius float32) []entity.Entity {
radiusSq := radius * radius
var result []entity.Entity
for _, p := range z.players {
if p.Position().DistanceSqTo(center) <= radiusSq {
result = append(result, p)
}
}
return result
}
// ─── ai.SkillUser implementation ────────────────────────────
func (z *Zone) UseSkill(casterID uint64, skillID uint32, targetID uint64, targetPos mathutil.Vec3) (bool, string) {
ent := z.entities[casterID]
if ent == nil {
return false, "caster not found"
}
caster, ok := ent.(combat.Combatant)
if !ok {
return false, "caster cannot fight"
}
return z.combatMgr.UseSkill(
caster, skillID, targetID, targetPos,
func(id uint64) entity.Entity { return z.entities[id] },
z.getEntitiesInRadius,
)
}
// ─── Zone Portals ───────────────────────────────────────────
func (z *Zone) checkPortals(p PlayerEntity, pos mathutil.Vec3) {
if z.onZoneTransfer == nil || len(z.portals) == 0 {
return
}
for _, portal := range z.portals {
if portal.IsInRange(pos) {
z.onZoneTransfer(p.EntityID(), portal.TargetZoneID, portal.TargetPos)
return
}
}
}