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:
parent
a51ebc66e9
commit
1e98d81448
46 changed files with 4883 additions and 127 deletions
282
src/AcDream.App/World/LiveEntityPresentationController.cs
Normal file
282
src/AcDream.App/World/LiveEntityPresentationController.cs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
using AcDream.App.Physics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.World;
|
||||
|
||||
/// <summary>
|
||||
/// Applies the presentation/collision side effects of accepted retail
|
||||
/// PhysicsState transitions after the canonical live owner is ready.
|
||||
/// Logical ownership stays in <see cref="LiveEntityRuntime"/>; Hidden never
|
||||
/// unregisters scripts, particles, lights, meshes, or the live record.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ports <c>CPhysicsObj::set_state</c> (<c>0x00514DD0</c>),
|
||||
/// <c>set_nodraw</c> (<c>0x0050FCA0</c>), and <c>set_hidden</c>
|
||||
/// (<c>0x00514C60</c>). Typed script ids are retail
|
||||
/// <c>PS_UnHide=0x75</c> and <c>PS_Hidden=0x76</c> from <c>acclient.h</c>.
|
||||
/// </remarks>
|
||||
public sealed class LiveEntityPresentationController : IDisposable
|
||||
{
|
||||
public const uint UnHideScriptType = 0x75u;
|
||||
public const uint HiddenScriptType = 0x76u;
|
||||
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ShadowObjectRegistry _shadows;
|
||||
private readonly Func<uint, uint, float, bool> _playTyped;
|
||||
private readonly Action<uint, bool> _setDirectChildrenNoDraw;
|
||||
private readonly Action<uint> _clearInvalidTarget;
|
||||
private readonly Func<(int X, int Y)> _liveCenter;
|
||||
private readonly Action<uint>? _onShadowRestored;
|
||||
private readonly Dictionary<uint, ushort> _readyGenerationByGuid = new();
|
||||
private readonly Dictionary<uint, ushort> _suspendedShadowGenerationByGuid = new();
|
||||
private readonly Dictionary<uint, ushort> _activePlacementGenerationByGuid = new();
|
||||
private bool _disposed;
|
||||
|
||||
public LiveEntityPresentationController(
|
||||
LiveEntityRuntime liveEntities,
|
||||
ShadowObjectRegistry shadows,
|
||||
Func<uint, uint, float, bool> playTyped,
|
||||
Action<uint, bool>? setDirectChildrenNoDraw = null,
|
||||
Action<uint>? clearInvalidTarget = null,
|
||||
Func<(int X, int Y)>? liveCenter = null,
|
||||
Action<uint>? onShadowRestored = null)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
|
||||
_playTyped = playTyped ?? throw new ArgumentNullException(nameof(playTyped));
|
||||
_setDirectChildrenNoDraw = setDirectChildrenNoDraw ?? ((_, _) => { });
|
||||
_clearInvalidTarget = clearInvalidTarget ?? (_ => { });
|
||||
_liveCenter = liveCenter ?? (() => (0, 0));
|
||||
_onShadowRestored = onShadowRestored;
|
||||
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the state-side-effect barrier after render/effect owners have
|
||||
/// registered, then drains constructor and pre-materialization transitions
|
||||
/// exactly once in accepted order.
|
||||
/// </summary>
|
||||
public bool OnLiveEntityReady(uint serverGuid)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| record.WorldEntity is null
|
||||
|| !record.ResourcesRegistered)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_readyGenerationByGuid[serverGuid] = record.Generation;
|
||||
ApplyPendingTransitions(record);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Drains a newly accepted SetState when this incarnation is ready.</summary>
|
||||
public bool OnStateAccepted(uint serverGuid)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| !_readyGenerationByGuid.TryGetValue(serverGuid, out ushort generation)
|
||||
|| generation != record.Generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ApplyPendingTransitions(record);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retains ownership of a collision shadow that another retail transition
|
||||
/// suspended while this incarnation's spatial projection is unavailable.
|
||||
/// Hidden/UnHide and teleport rollback intentionally converge on this one
|
||||
/// generation-scoped restoration marker.
|
||||
/// </summary>
|
||||
public bool DeferShadowRestore(uint serverGuid, ushort generation)
|
||||
=> CompleteAuthoritativePlacement(serverGuid, generation, deferShadowRestore: true);
|
||||
|
||||
/// <summary>
|
||||
/// Completes active placement ownership after its final frame/cell is
|
||||
/// known, either returning a stable deferred restore to presentation or
|
||||
/// clearing the suspension for an immediate controller-owned restore.
|
||||
/// </summary>
|
||||
public bool CompleteAuthoritativePlacement(
|
||||
uint serverGuid,
|
||||
ushort generation,
|
||||
bool deferShadowRestore)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| record.Generation != generation
|
||||
|| record.WorldEntity is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_activePlacementGenerationByGuid.TryGetValue(
|
||||
serverGuid,
|
||||
out ushort activeGeneration)
|
||||
&& activeGeneration == generation)
|
||||
{
|
||||
_activePlacementGenerationByGuid.Remove(serverGuid);
|
||||
}
|
||||
|
||||
if (deferShadowRestore)
|
||||
_suspendedShadowGenerationByGuid[serverGuid] = generation;
|
||||
else if (_suspendedShadowGenerationByGuid.TryGetValue(
|
||||
serverGuid,
|
||||
out ushort deferredGeneration)
|
||||
&& deferredGeneration == generation)
|
||||
_suspendedShadowGenerationByGuid.Remove(serverGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transfers a deferred restore to a newer authoritative placement before
|
||||
/// that placement rebuckets and can become visible. The placement hands
|
||||
/// ownership back after it reaches a stable Hidden destination/rollback;
|
||||
/// this prevents an intervening UnHide from restoring an unresolved pose.
|
||||
/// </summary>
|
||||
public bool BeginAuthoritativePlacement(uint serverGuid, ushort generation)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| record.Generation != generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_activePlacementGenerationByGuid[serverGuid] = generation;
|
||||
if (_suspendedShadowGenerationByGuid.TryGetValue(
|
||||
serverGuid,
|
||||
out ushort deferredGeneration)
|
||||
&& deferredGeneration == generation)
|
||||
{
|
||||
_suspendedShadowGenerationByGuid.Remove(serverGuid);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool HasDeferredShadowRestore(uint serverGuid) =>
|
||||
_suspendedShadowGenerationByGuid.ContainsKey(serverGuid);
|
||||
|
||||
internal bool HasActivePlacement(uint serverGuid) =>
|
||||
_activePlacementGenerationByGuid.ContainsKey(serverGuid);
|
||||
|
||||
public void Forget(LiveEntityRecord record)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
if (_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort generation)
|
||||
&& generation == record.Generation)
|
||||
{
|
||||
_readyGenerationByGuid.Remove(record.ServerGuid);
|
||||
}
|
||||
if (_suspendedShadowGenerationByGuid.TryGetValue(
|
||||
record.ServerGuid,
|
||||
out ushort suspendedGeneration)
|
||||
&& suspendedGeneration == record.Generation)
|
||||
{
|
||||
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
|
||||
}
|
||||
if (_activePlacementGenerationByGuid.TryGetValue(
|
||||
record.ServerGuid,
|
||||
out ushort activeGeneration)
|
||||
&& activeGeneration == record.Generation)
|
||||
{
|
||||
_activePlacementGenerationByGuid.Remove(record.ServerGuid);
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_readyGenerationByGuid.Clear();
|
||||
_suspendedShadowGenerationByGuid.Clear();
|
||||
_activePlacementGenerationByGuid.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
_liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
|
||||
Clear();
|
||||
}
|
||||
|
||||
private void ApplyPendingTransitions(LiveEntityRecord record)
|
||||
{
|
||||
while (record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition))
|
||||
{
|
||||
if (record.WorldEntity is not { } entity)
|
||||
return;
|
||||
|
||||
// set_state writes the final state before any PartArray/cell side
|
||||
// effect. Retained shadow registrations must see those same bits
|
||||
// even while their cell rows are suspended.
|
||||
_shadows.UpdatePhysicsState(entity.Id, (uint)transition.FinalState);
|
||||
|
||||
switch (transition.HiddenTransition)
|
||||
{
|
||||
case RetailHiddenTransition.BecameHidden:
|
||||
_playTyped(entity.Id, HiddenScriptType, 1f);
|
||||
_setDirectChildrenNoDraw(record.ServerGuid, true);
|
||||
_shadows.Suspend(entity.Id);
|
||||
if (!IsPlacementActive(record))
|
||||
_suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation;
|
||||
_clearInvalidTarget(record.ServerGuid);
|
||||
break;
|
||||
|
||||
case RetailHiddenTransition.BecameVisible:
|
||||
_playTyped(entity.Id, UnHideScriptType, 1f);
|
||||
_setDirectChildrenNoDraw(record.ServerGuid, false);
|
||||
if (!IsPlacementActive(record) && RestoreShadow(record, entity))
|
||||
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool RestoreShadow(LiveEntityRecord record, AcDream.Core.World.WorldEntity entity)
|
||||
{
|
||||
if (!record.IsSpatiallyProjected
|
||||
|| !record.IsSpatiallyVisible
|
||||
|| record.FullCellId == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
(int centerX, int centerY) = _liveCenter();
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
record.FullCellId,
|
||||
centerX,
|
||||
centerY);
|
||||
_onShadowRestored?.Invoke(record.ServerGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
||||
{
|
||||
if (!visible
|
||||
|| record.WorldEntity is not { } entity
|
||||
|| (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
|
||||
|| IsPlacementActive(record)
|
||||
|| !_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort readyGeneration)
|
||||
|| readyGeneration != record.Generation
|
||||
|| !_suspendedShadowGenerationByGuid.TryGetValue(
|
||||
record.ServerGuid,
|
||||
out ushort suspendedGeneration)
|
||||
|| suspendedGeneration != record.Generation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (RestoreShadow(record, entity))
|
||||
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
|
||||
}
|
||||
|
||||
private bool IsPlacementActive(LiveEntityRecord record) =>
|
||||
_activePlacementGenerationByGuid.TryGetValue(
|
||||
record.ServerGuid,
|
||||
out ushort generation)
|
||||
&& generation == record.Generation;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue