feat(vfx): port retail hidden and teleport presentation

Preserve canonical live-object ownership across Hidden transitions and remote teleport placement so effects, collision, streaming, and targeting remain synchronized.
This commit is contained in:
Erik 2026-07-14 14:59:48 +02:00
parent a51ebc66e9
commit 1e98d81448
46 changed files with 4883 additions and 127 deletions

View file

@ -88,6 +88,8 @@ public sealed class GpuWorldState
// list, so rebuilding it replaces the reference atomically.
private IReadOnlyList<WorldEntity> _flatEntities = System.Array.Empty<WorldEntity>();
private readonly HashSet<uint> _visibleLiveGuids = new();
private readonly Queue<(uint ServerGuid, bool Visible)> _visibilityTransitions = new();
private bool _dispatchingVisibilityTransitions;
public IReadOnlyList<WorldEntity> Entities => _flatEntities;
public IReadOnlyCollection<uint> LoadedLandblockIds => _loaded.Keys;
@ -282,6 +284,10 @@ public sealed class GpuWorldState
// and it couldn't reach a pending entity). Re-appending routes the entity
// to _loaded (drawn) when its landblock is loaded, or back to pending to
// await AddLandblock otherwise.
// Remove + place is one spatial transaction. Rebuilding between the
// two operations exposes a false/true implementation pulse for a
// loaded-to-loaded move and lets reentrant visibility callbacks observe
// a state that never exists at the LiveEntityRuntime boundary.
RemoveEntityFromAllBuckets(entity);
PlaceLiveEntityProjection(canonical, entity);
}
@ -307,7 +313,6 @@ public sealed class GpuWorldState
if (j != i) newList.Add(entities[j]);
_loaded[kvp.Key] = new LoadedLandblock(
kvp.Value.LandblockId, kvp.Value.Heightmap, newList);
RebuildFlatView();
return;
}
}
@ -510,6 +515,7 @@ public sealed class GpuWorldState
bucket.Add(entity);
if (probePersistent)
EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)");
RebuildFlatView();
}
/// <summary>
@ -624,21 +630,48 @@ public sealed class GpuWorldState
_flatEntities
.Where(entity => entity.ServerGuid != 0)
.Select(entity => entity.ServerGuid));
foreach (uint guid in _visibleLiveGuids)
{
if (!nowVisible.Contains(guid))
LiveProjectionVisibilityChanged?.Invoke(guid, false);
}
foreach (uint guid in nowVisible)
{
if (!_visibleLiveGuids.Contains(guid))
LiveProjectionVisibilityChanged?.Invoke(guid, true);
}
uint[] becameHidden = _visibleLiveGuids
.Where(guid => !nowVisible.Contains(guid))
.ToArray();
uint[] becameVisible = nowVisible
.Where(guid => !_visibleLiveGuids.Contains(guid))
.ToArray();
// Commit the new spatial truth before notifying observers. A
// visibility callback may synchronously rebucket/withdraw the same
// object (deferred teleport rollback); those nested transitions must
// compare against this committed state rather than the stale prior set.
_visibleLiveGuids.Clear();
_visibleLiveGuids.UnionWith(nowVisible);
foreach (uint guid in becameHidden)
_visibilityTransitions.Enqueue((guid, false));
foreach (uint guid in becameVisible)
_visibilityTransitions.Enqueue((guid, true));
DrainVisibilityTransitions();
if (EntityVanishProbe.Enabled) ProbeFlatViewTransitions();
}
private void DrainVisibilityTransitions()
{
if (_dispatchingVisibilityTransitions)
return;
_dispatchingVisibilityTransitions = true;
try
{
while (_visibilityTransitions.TryDequeue(out var transition))
{
LiveProjectionVisibilityChanged?.Invoke(
transition.ServerGuid,
transition.Visible);
}
}
finally
{
_dispatchingVisibilityTransitions = false;
}
}
// TEMP (#138-B): persistent guids currently present in the drawn flat
// view, for EntityVanishProbe transition logging. Strip with the probe.
private readonly HashSet<uint> _persistentInFlatProbe = new();