feat: scene ghosting, as the client streams a zone's scenes

The client keeps the scene under the player loaded, the scenes the zone
file's transitions connect to it, and the global scene
(Zone::StreamScenesAroundPosition 0x0108a3f0, TerrainManager::GetSceneAtPos
0x01069010, the connected scenes at 0x01066500; transitions naming a
missing scene dropped as Zone::FixupInvalidTransitions 0x010842e0 does).

- ZoneScenes (dCommon): the terrain's scene map lookup and the scene graph,
  shared by the world server and the dashboard.
- Objects remember the scene they were placed in (spawners pass theirs on).
- ghosting_scenes=1 (world config, off by default): players get the objects
  of their loaded scenes instead of the ones within the ghosting distances;
  objects from no scene go by the scene under them. Zones without a scene
  map keep distance ghosting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 22:02:20 -05:00
parent 696580cce5
commit 1b9724a7ec
20 changed files with 363 additions and 19 deletions

View File

@@ -138,6 +138,7 @@ Entity::Entity(const LWOOBJID& objectID, const EntityInfo& info, User* parentUse
m_DefaultPosition = info.pos;
m_DefaultRotation = info.rot;
m_Scale = info.scale;
m_Scene = info.scene;
m_Spawner = info.spawner;
m_SpawnerID = info.spawnerID;
m_HasSpawnerNodeID = info.hasSpawnerNodeID;

View File

@@ -349,6 +349,9 @@ public:
// Scale will only be communicated to the client when the construction packet is sent
void SetScale(const float scale) { m_Scale = scale; };
// The zone scene the object was placed in (EntityInfo::scene), -1 when it came from none
int32_t GetScene() const { return m_Scene; }
void RegisterMsg(const MessageType::Game msgId, std::function<bool(GameMessages::GameMsg&)> handler);
bool HandleMsg(GameMessages::GameMsg& msg) const;
@@ -392,6 +395,7 @@ private:
NiPoint3 m_DefaultPosition;
NiQuaternion m_DefaultRotation = QuatUtils::IDENTITY;
float m_Scale;
int32_t m_Scene = -1;
Spawner* m_Spawner;
LWOOBJID m_SpawnerID;

View File

@@ -11,6 +11,8 @@
#include "SwitchComponent.h"
#include "UserManager.h"
#include "dZoneManager.h"
#include "Zone.h"
#include "ZoneScenes.h"
#include "MissionComponent.h"
#include "Game.h"
#include "Logger.h"
@@ -98,6 +100,13 @@ void EntityManager::Initialize() {
Game::zoneManager->GetZoneID().GetMapID()
) == m_GhostingExcludedZones.end();
// Scene ghosting needs the terrain's scene map; without one it stays off
if (m_GhostingEnabled && GeneralUtils::TryParse<bool>(Game::config->GetValue("ghosting_scenes")).value_or(false)) {
auto* zone = Game::zoneManager->GetZoneMut();
m_SceneGhosting = zone && zone->LoadSceneMap();
LOG("Scene ghosting is %s", m_SceneGhosting ? "on" : "off: the zone has no scene map");
}
// grab hardcore mode settings and load them with sane defaults
Game::config->AddConfigHandler([]() {Game::entityManager->ReloadConfig();});
Game::entityManager->ReloadConfig();
@@ -505,6 +514,8 @@ void EntityManager::UpdateGhosting(Entity* player) {
const auto& referencePoint = ghostComponent->GetGhostReferencePoint();
const auto isOverride = ghostComponent->GetGhostOverride();
const auto* zone = m_SceneGhosting ? Game::zoneManager->GetZone() : nullptr;
const auto loaded = zone ? zone->GetSceneGraph().Loaded(zone->GetSceneMap().SceneAt(referencePoint.x, referencePoint.z)) : std::set<uint32_t>{};
for (auto* entity : m_EntitiesToGhost) {
const auto& entityPoint = entity->GetPosition();
@@ -513,24 +524,32 @@ void EntityManager::UpdateGhosting(Entity* player) {
const auto observed = ghostComponent->IsObserved(id);
const auto distance = NiPoint3::DistanceSquared(referencePoint, entityPoint);
bool tooFar = false, nearEnough = false;
if (zone) {
// As the client streams scenes: the objects of the scenes loaded around the player
nearEnough = ZoneScenes::InLoadedScene(entity->GetScene(), zone->GetSceneMap().SceneAt(entityPoint.x, entityPoint.z), loaded);
tooFar = !nearEnough;
} else {
const auto distance = NiPoint3::DistanceSquared(referencePoint, entityPoint);
auto ghostingDistanceMax = m_GhostDistanceMaxSquared;
auto ghostingDistanceMin = m_GhostDistanceMinSqaured;
auto ghostingDistanceMax = m_GhostDistanceMaxSquared;
auto ghostingDistanceMin = m_GhostDistanceMinSqaured;
const auto isAudioEmitter = entity->GetLOT() == 6368; // https://explorer.lu/objects/6368
if (isAudioEmitter) {
ghostingDistanceMax = ghostingDistanceMin;
const auto isAudioEmitter = entity->GetLOT() == 6368; // https://explorer.lu/objects/6368
if (isAudioEmitter) {
ghostingDistanceMax = ghostingDistanceMin;
}
tooFar = distance > ghostingDistanceMax;
nearEnough = ghostingDistanceMin > distance;
}
if (observed && distance > ghostingDistanceMax && !isOverride) {
if (observed && tooFar && !isOverride) {
ghostComponent->GhostEntity(id);
DestructEntity(entity, player->GetSystemAddress());
entity->SetObservers(entity->GetObservers() - 1);
} else if (!observed && ghostingDistanceMin > distance) {
} else if (!observed && nearEnough) {
// Check collectables, don't construct if it has been collected
uint32_t collectionId = entity->GetCollectibleID();
@@ -555,6 +574,8 @@ void EntityManager::CheckGhosting(Entity* entity) {
if (!entity) return;
const auto& referencePoint = entity->GetPosition();
const auto* zone = m_SceneGhosting ? Game::zoneManager->GetZone() : nullptr;
const auto entityScene = zone ? zone->GetSceneMap().SceneAt(referencePoint.x, referencePoint.z) : 0;
for (auto* player : PlayerManager::GetAllPlayers()) {
auto* ghostComponent = player->GetComponent<GhostComponent>();
if (!ghostComponent) continue;
@@ -562,13 +583,22 @@ void EntityManager::CheckGhosting(Entity* entity) {
const auto& entityPoint = ghostComponent->GetGhostReferencePoint();
const auto id = entity->GetObjectID();
const auto observed = ghostComponent->IsObserved(id);
const auto distance = NiPoint3::DistanceSquared(referencePoint, entityPoint);
bool tooFar = false, nearEnough = false;
if (zone) {
const auto loaded = zone->GetSceneGraph().Loaded(zone->GetSceneMap().SceneAt(entityPoint.x, entityPoint.z));
nearEnough = ZoneScenes::InLoadedScene(entity->GetScene(), entityScene, loaded);
tooFar = !nearEnough;
} else {
const auto distance = NiPoint3::DistanceSquared(referencePoint, entityPoint);
tooFar = distance > m_GhostDistanceMaxSquared;
nearEnough = m_GhostDistanceMinSqaured > distance;
}
if (observed && distance > m_GhostDistanceMaxSquared) {
if (observed && tooFar) {
ghostComponent->GhostEntity(id);
DestructEntity(entity, player->GetSystemAddress());
entity->SetObservers(entity->GetObservers() - 1);
} else if (!observed && m_GhostDistanceMinSqaured > distance) {
} else if (!observed && nearEnough) {
ghostComponent->ObserveEntity(id);
ConstructEntity(entity, player->GetSystemAddress());
entity->SetObservers(entity->GetObservers() + 1);

View File

@@ -62,6 +62,7 @@ public:
void CheckGhosting(Entity* entity);
Entity* GetGhostCandidate(LWOOBJID id) const;
bool GetGhostingEnabled() const;
bool GetSceneGhosting() const { return m_SceneGhosting; }
void ScheduleForKill(Entity* entity);
@@ -109,6 +110,9 @@ private:
float m_GhostDistanceMinSqaured = 100 * 100;
float m_GhostDistanceMaxSquared = 150 * 150;
bool m_GhostingEnabled = true;
// ghosting_scenes: players get the objects of the scenes their client keeps loaded (ZoneScenes) instead of the
// ones within the ghosting distances
bool m_SceneGhosting = false;
std::stack<uint16_t> m_LostNetworkIds;

View File

@@ -37,4 +37,6 @@ struct EntityInfo {
LwoNameValue settings;
LwoNameValue networkSettings;
float scale;
// The zone scene (its scene id) the object was placed in, for scene ghosting; -1: none (spawned at run time)
int32_t scene = -1;
};