using System.Collections.Generic; using System.Linq; using NitroxModel.Core; using NitroxModel.DataStructures; using NitroxModel.DataStructures.GameLogic; using NitroxModel.DataStructures.GameLogic.Entities; using NitroxModel.DataStructures.GameLogic.Entities.Metadata; using NitroxModel.DataStructures.Unity; using NitroxModel.DataStructures.Util; using NitroxModel.Helper; using NitroxModel.Packets; using NitroxServer.GameLogic.Entities.Spawning; namespace NitroxServer.GameLogic.Entities; /// /// Regular are held in cells and should be registered in and . /// But are held in their own root object (GlobalRoot) so they should never be registered in cells (they're seeable at all times). /// public class WorldEntityManager { private readonly EntityRegistry entityRegistry; /// /// World entities can disappear if you go out of range. /// private readonly Dictionary> worldEntitiesByCell; /// /// Global root entities that are always visible. /// private readonly Dictionary globalRootEntitiesById; private readonly BatchEntitySpawner batchEntitySpawner; private readonly PlayerManager playerManager; private readonly object worldEntitiesLock; private readonly object globalRootEntitiesLock; public WorldEntityManager(EntityRegistry entityRegistry, BatchEntitySpawner batchEntitySpawner, PlayerManager playerManager) { List worldEntities = entityRegistry.GetEntities(); globalRootEntitiesById = entityRegistry.GetEntities().ToDictionary(entity => entity.Id); worldEntitiesByCell = worldEntities.Where(entity => entity is not GlobalRootEntity) .GroupBy(entity => entity.AbsoluteEntityCell) .ToDictionary(group => group.Key, group => group.ToDictionary(entity => entity.Id, entity => entity)); this.entityRegistry = entityRegistry; this.batchEntitySpawner = batchEntitySpawner; this.playerManager = playerManager; worldEntitiesLock = new(); globalRootEntitiesLock = new(); } public List GetGlobalRootEntities(bool rootOnly = false) { if (rootOnly) { return GetGlobalRootEntities().Where(entity => entity.ParentId == null).ToList(); } return GetGlobalRootEntities(); } public List GetGlobalRootEntities() where T : GlobalRootEntity { lock (globalRootEntitiesLock) { return new(globalRootEntitiesById.Values.OfType()); } } public List GetPersistentGlobalRootEntities() { // TODO: refactor if there are more entities that should not be persisted return GetGlobalRootEntities(true).Where(entity => { if (entity.Metadata is CyclopsMetadata cyclopsMetadata) { // Do not save cyclops wrecks return !cyclopsMetadata.IsDestroyed; } return true; }).ToList(); } public List GetEntities(AbsoluteEntityCell cell) { lock (worldEntitiesLock) { if (worldEntitiesByCell.TryGetValue(cell, out Dictionary batchEntities)) { return batchEntities.Values.ToList(); } } return []; } public bool TryUpdateEntityPosition(NitroxId id, NitroxVector3 position, NitroxQuaternion rotation, out AbsoluteEntityCell newCell, out WorldEntity worldEntity) { lock (worldEntitiesLock) { if (!entityRegistry.TryGetEntityById(id, out worldEntity)) { Log.WarnOnce($"[{nameof(WorldEntityManager)}] Can't update entity position of {id} because it isn't registered"); newCell = null; return false; } AbsoluteEntityCell oldCell = worldEntity.AbsoluteEntityCell; worldEntity.Transform.Position = position; worldEntity.Transform.Rotation = rotation; newCell = worldEntity.AbsoluteEntityCell; if (oldCell != newCell) { EntitySwitchedCells(worldEntity, oldCell, newCell); } return true; } } public Optional RemoveGlobalRootEntity(NitroxId entityId, bool removeFromRegistry = true) { Optional removedEntity = Optional.Empty; lock (globalRootEntitiesLock) { if (removeFromRegistry) { // In case there were player entities under the removed entity, we need to reparent them to the GlobalRoot // to make sure that they won't be removed if (entityRegistry.TryGetEntityById(entityId, out GlobalRootEntity globalRootEntity)) { MovePlayerChildrenToRoot(globalRootEntity); } removedEntity = entityRegistry.RemoveEntity(entityId); } globalRootEntitiesById.Remove(entityId); } return removedEntity; } public void MovePlayerChildrenToRoot(GlobalRootEntity globalRootEntity) { List playerEntities = FindPlayerEntitiesInChildren(globalRootEntity); foreach (PlayerWorldEntity childPlayerEntity in playerEntities) { // Reparent the entity on top of GlobalRoot globalRootEntity.ChildEntities.Remove(childPlayerEntity); childPlayerEntity.ParentId = null; // Make sure the PlayerEntity is correctly registered AddOrUpdateGlobalRootEntity(childPlayerEntity); } } public void TrackEntityInTheWorld(WorldEntity entity) { if (entity is GlobalRootEntity globalRootEntity) { AddOrUpdateGlobalRootEntity(globalRootEntity, false); return; } RegisterWorldEntity(entity); } /// /// Automatically registers a WorldEntity in its AbsoluteEntityCell /// /// /// The provided should NOT be a GlobalRootEntity (they don't stand in cells) /// public void RegisterWorldEntity(WorldEntity entity) { RegisterWorldEntityInCell(entity, entity.AbsoluteEntityCell); } public void RegisterWorldEntityInCell(WorldEntity entity, AbsoluteEntityCell cell) { lock (worldEntitiesLock) { if (!worldEntitiesByCell.TryGetValue(cell, out Dictionary worldEntitiesInCell)) { worldEntitiesInCell = worldEntitiesByCell[cell] = []; } worldEntitiesInCell[entity.Id] = entity; } } /// /// Automatically unregisters a WorldEntity in its AbsoluteEntityCell /// public void UnregisterWorldEntity(WorldEntity entity) { UnregisterWorldEntityFromCell(entity.Id, entity.AbsoluteEntityCell); } public void UnregisterWorldEntityFromCell(NitroxId entityId, AbsoluteEntityCell cell) { lock (worldEntitiesLock) { if (worldEntitiesByCell.TryGetValue(cell, out Dictionary worldEntitiesInCell)) { worldEntitiesInCell.Remove(entityId); } } } public void LoadAllUnspawnedEntities(System.Threading.CancellationToken token) { IMap map = NitroxServiceLocator.LocateService(); int totalBatches = map.DimensionsInBatches.X * map.DimensionsInBatches.Y * map.DimensionsInBatches.Z; int batchesLoaded = 0; for (int x = 0; x < map.DimensionsInBatches.X; x++) { token.ThrowIfCancellationRequested(); for (int y = 0; y < map.DimensionsInBatches.Y; y++) { for (int z = 0; z < map.DimensionsInBatches.Z; z++) { int spawned = LoadUnspawnedEntities(new(x, y, z), true); Log.Debug($"Loaded {spawned} entities from batch ({x}, {y}, {z})"); batchesLoaded++; } } if (batchesLoaded > 0) { Log.Info($"Loading : {(int)(100f * batchesLoaded / totalBatches)}%"); } } } public int LoadUnspawnedEntities(NitroxInt3 batchId, bool suppressLogs) { List spawnedEntities = batchEntitySpawner.LoadUnspawnedEntities(batchId, suppressLogs); List entitiesInCells = spawnedEntities.Where(entity => typeof(WorldEntity).IsAssignableFrom(entity.GetType()) && entity.GetType() != typeof(CellRootEntity) && entity.GetType() != typeof(GlobalRootEntity)) .Cast() .ToList(); // UWE stores entities serialized with a handful of parent cell roots. These only represent a small fraction of all possible cell // roots that could exist. There is no reason for the server to know about these and much easier to consider top-level world entities // as positioned globally and not locally. Thus, we promote cell root children to top level and throw the cell roots away. foreach (CellRootEntity cellRoot in spawnedEntities.OfType()) { foreach (WorldEntity worldEntity in cellRoot.ChildEntities.Cast()) { worldEntity.ParentId = null; worldEntity.Transform.SetParent(null, true); entitiesInCells.Add(worldEntity); } cellRoot.ChildEntities = new List(); } // Specific type of entities which is not parented to a CellRootEntity entitiesInCells.AddRange(spawnedEntities.OfType()); entityRegistry.AddEntitiesIgnoringDuplicate(entitiesInCells.OfType().ToList()); foreach (WorldEntity entity in entitiesInCells) { RegisterWorldEntity(entity); } return entitiesInCells.Count; } private void EntitySwitchedCells(WorldEntity entity, AbsoluteEntityCell oldCell, AbsoluteEntityCell newCell) { if (entity is GlobalRootEntity) { return; // We don't care what cell a global root entity resides in. Only phasing entities. } if (oldCell != newCell) { lock (worldEntitiesLock) { // Specifically remove entity from oldCell UnregisterWorldEntityFromCell(entity.Id, oldCell); // Automatically add entity to its new cell RegisterWorldEntityInCell(entity, newCell); // It can happen for some players that the entity moves to a loaded cell of theirs, but that they hadn't spawned it in the first place foreach (Player player in playerManager.ConnectedPlayers()) { if (player.HasCellLoaded(newCell) && !player.HasCellLoaded(oldCell)) { player.SendPacket(new SpawnEntities(entity)); } } } } } public void StopTrackingEntity(WorldEntity entity) { if (entity is GlobalRootEntity) { RemoveGlobalRootEntity(entity.Id, false); } else { UnregisterWorldEntity(entity); } } public bool TryDestroyEntity(NitroxId entityId, out Entity entity) { Optional optEntity = entityRegistry.RemoveEntity(entityId); if (!optEntity.HasValue) { entity = null; return false; } entity = optEntity.Value; if (entity is WorldEntity worldEntity) { StopTrackingEntity(worldEntity); } return true; } /// /// To avoid risking not having the same entity in and in EntityRegistry, we update both at the same time. /// public void AddOrUpdateGlobalRootEntity(GlobalRootEntity entity, bool addOrUpdateRegistry = true) { lock (globalRootEntitiesLock) { if (addOrUpdateRegistry) { entityRegistry.AddOrUpdate(entity); } globalRootEntitiesById[entity.Id] = entity; } } /// /// Iterative breadth-first search which gets all children player entities in 's hierarchy. /// private List FindPlayerEntitiesInChildren(Entity parentEntity) { List playerWorldEntities = []; List entitiesToSearch = [parentEntity]; while (entitiesToSearch.Count > 0) { Entity currentEntity = entitiesToSearch[^1]; entitiesToSearch.RemoveAt(entitiesToSearch.Count - 1); if (currentEntity is PlayerWorldEntity playerWorldEntity) { playerWorldEntities.Add(playerWorldEntity); } else { entitiesToSearch.InsertRange(0, currentEntity.ChildEntities); } } return playerWorldEntities; } }