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 } } }