666 lines
16 KiB
Go
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
|
|
}
|
|
}
|
|
}
|