refactor(world): establish live entity composition seams
This commit is contained in:
parent
db5a11707d
commit
d68c83d1a5
5 changed files with 357 additions and 21 deletions
|
|
@ -1007,8 +1007,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
private AcDream.App.Net.LiveSessionController? _liveSessionController;
|
||||
private AcDream.Core.Net.WorldSession? LiveSession =>
|
||||
_liveSessionController?.CurrentSession;
|
||||
private int _liveCenterX;
|
||||
private int _liveCenterY;
|
||||
private readonly AcDream.App.World.LiveWorldOriginState _liveWorldOrigin = new();
|
||||
private int _liveCenterX => _liveWorldOrigin.CenterX;
|
||||
private int _liveCenterY => _liveWorldOrigin.CenterY;
|
||||
// #192 (2026-07-09): true once the player's first canonical Position has
|
||||
// been accepted — i.e. _liveCenterX/Y reflects a real, server-confirmed
|
||||
// position, not the Holtburg startup placeholder. Renderability is not a
|
||||
|
|
@ -1019,7 +1020,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
// AcDream.Core.World.StreamingReadinessGate for the full mechanism this
|
||||
// closes (stale-centered geometry built during the InWorld-but-not-yet-
|
||||
// located window, applied late once its build finished).
|
||||
private bool _liveCenterKnown;
|
||||
private bool _liveCenterKnown => _liveWorldOrigin.IsKnown;
|
||||
|
||||
// K-fix1 (2026-04-26): cached at startup so per-frame branches are
|
||||
// single-flag reads instead of env-var lookups. True iff
|
||||
|
|
@ -2535,6 +2536,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
_worldState,
|
||||
AdvanceLandblockPresentationRetirement);
|
||||
|
||||
var liveEntityComponentLifecycle =
|
||||
new AcDream.App.World.DeferredLiveEntityRuntimeComponentLifecycle();
|
||||
_liveEntities = new AcDream.App.World.LiveEntityRuntime(
|
||||
_worldState,
|
||||
new AcDream.App.World.CompositeLiveEntityResourceLifecycle(
|
||||
|
|
@ -2544,7 +2547,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
new(
|
||||
entityScriptActivator.OnCreate,
|
||||
entityScriptActivator.OnRemove)),
|
||||
TearDownLiveEntityRuntimeComponents);
|
||||
liveEntityComponentLifecycle);
|
||||
liveEntityComponentLifecycle.Bind(
|
||||
new AcDream.App.World.DelegateLiveEntityRuntimeComponentLifecycle(
|
||||
TearDownLiveEntityRuntimeComponents));
|
||||
_liveEntities.ProjectionVisibilityChanged += (record, visible) =>
|
||||
{
|
||||
if (record.WorldEntity is { } entity)
|
||||
|
|
@ -2844,8 +2850,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
// enter the world as the first character on the account, and stream
|
||||
// CreateObject messages into _worldGameState as they arrive. Entirely
|
||||
// gated behind ACDREAM_LIVE=1 so the default run path is unchanged.
|
||||
_liveCenterX = centerX;
|
||||
_liveCenterY = centerY;
|
||||
_liveWorldOrigin.SetPlaceholder(centerX, centerY);
|
||||
_liveSessionController = new AcDream.App.Net.LiveSessionController();
|
||||
AcDream.App.Net.LiveSessionStartResult liveStart =
|
||||
_liveSessionController.Start(
|
||||
|
|
@ -3010,7 +3015,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
// X/Y are ignored until the next logical player CreateObject claims a
|
||||
// new origin. Keeping the last values avoids inventing a synthetic
|
||||
// landblock while still forcing first-spawn initialization.
|
||||
_liveCenterKnown = false;
|
||||
_liveWorldOrigin.Reset();
|
||||
}
|
||||
|
||||
private void ResetSessionLiveRuntime()
|
||||
|
|
@ -3409,21 +3414,22 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
private void TryInitializeLiveCenter(
|
||||
AcDream.Core.Net.WorldSession.EntitySpawn spawn)
|
||||
{
|
||||
if (_liveCenterKnown
|
||||
|| spawn.Guid != _playerServerGuid
|
||||
if (spawn.Guid != _playerServerGuid
|
||||
|| spawn.Position is not { } position)
|
||||
return;
|
||||
|
||||
int lbX = (int)((position.LandblockId >> 24) & 0xFFu);
|
||||
int lbY = (int)((position.LandblockId >> 16) & 0xFFu);
|
||||
_liveCenterKnown = true;
|
||||
if (lbX != _liveCenterX || lbY != _liveCenterY)
|
||||
int oldCenterX = _liveWorldOrigin.CenterX;
|
||||
int oldCenterY = _liveWorldOrigin.CenterY;
|
||||
if (!_liveWorldOrigin.TryInitialize(lbX, lbY))
|
||||
return;
|
||||
|
||||
if (lbX != oldCenterX || lbY != oldCenterY)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: first player position — recentering streaming from ({_liveCenterX},{_liveCenterY}) " +
|
||||
$"live: first player position — recentering streaming from ({oldCenterX},{oldCenterY}) " +
|
||||
$"to ({lbX},{lbY}) @0x{position.LandblockId:X8}");
|
||||
_liveCenterX = lbX;
|
||||
_liveCenterY = lbY;
|
||||
}
|
||||
|
||||
if (_streamingController is not null && _dats is not null)
|
||||
|
|
@ -7337,8 +7343,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
// can be the abandoned first destination, not the unplaced player's source.
|
||||
_physicsEngine.RemoveLandblock(streamingOriginLandblockId);
|
||||
|
||||
_liveCenterX = lbX;
|
||||
_liveCenterY = lbY;
|
||||
_liveWorldOrigin.Recenter(lbX, lbY);
|
||||
newWorldPos = new System.Numerics.Vector3(
|
||||
p.PositionX, p.PositionY, p.PositionZ);
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,64 @@ public interface ILiveEntityResourceLifecycle
|
|||
void Unregister(WorldEntity entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exact-incarnation App component cleanup invoked by the canonical runtime
|
||||
/// after it has retired the active identity. Mesh/script resource lifetime is
|
||||
/// owned separately by <see cref="ILiveEntityResourceLifecycle"/>.
|
||||
/// </summary>
|
||||
internal interface ILiveEntityRuntimeComponentLifecycle
|
||||
{
|
||||
void TearDown(LiveEntityRecord record);
|
||||
}
|
||||
|
||||
internal sealed class NullLiveEntityRuntimeComponentLifecycle :
|
||||
ILiveEntityRuntimeComponentLifecycle
|
||||
{
|
||||
public static NullLiveEntityRuntimeComponentLifecycle Instance { get; } = new();
|
||||
private NullLiveEntityRuntimeComponentLifecycle() { }
|
||||
public void TearDown(LiveEntityRecord record) { }
|
||||
}
|
||||
|
||||
internal sealed class DelegateLiveEntityRuntimeComponentLifecycle(
|
||||
Action<LiveEntityRecord> tearDown) : ILiveEntityRuntimeComponentLifecycle
|
||||
{
|
||||
private readonly Action<LiveEntityRecord> _tearDown =
|
||||
tearDown ?? throw new ArgumentNullException(nameof(tearDown));
|
||||
|
||||
public void TearDown(LiveEntityRecord record) => _tearDown(record);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composition bridge for the runtime-to-hydration construction cycle. It is
|
||||
/// deliberately fail-fast and single-assignment: a live session must not start
|
||||
/// before the exact component owner is bound, and ownership cannot be replaced
|
||||
/// during a session.
|
||||
/// </summary>
|
||||
internal sealed class DeferredLiveEntityRuntimeComponentLifecycle :
|
||||
ILiveEntityRuntimeComponentLifecycle
|
||||
{
|
||||
private ILiveEntityRuntimeComponentLifecycle? _inner;
|
||||
|
||||
public bool IsBound => Volatile.Read(ref _inner) is not null;
|
||||
|
||||
public void Bind(ILiveEntityRuntimeComponentLifecycle lifecycle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lifecycle);
|
||||
if (ReferenceEquals(lifecycle, this))
|
||||
throw new ArgumentException("A lifecycle bridge cannot bind to itself.", nameof(lifecycle));
|
||||
if (Interlocked.CompareExchange(ref _inner, lifecycle, null) is not null)
|
||||
throw new InvalidOperationException("The live-entity runtime component lifecycle is already bound.");
|
||||
}
|
||||
|
||||
public void TearDown(LiveEntityRecord record)
|
||||
{
|
||||
ILiveEntityRuntimeComponentLifecycle lifecycle = Volatile.Read(ref _inner)
|
||||
?? throw new InvalidOperationException(
|
||||
"The live-entity runtime component lifecycle has not been bound.");
|
||||
lifecycle.TearDown(record);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Delegate adapter used by the App composition root.</summary>
|
||||
public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLifecycle
|
||||
{
|
||||
|
|
@ -300,7 +358,7 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
private readonly GpuWorldState _spatial;
|
||||
private readonly ILiveEntityResourceLifecycle _resources;
|
||||
private readonly Action<LiveEntityRecord>? _tearDownRuntimeComponents;
|
||||
private readonly ILiveEntityRuntimeComponentLifecycle _runtimeComponentLifecycle;
|
||||
private readonly InboundPhysicsStateController _inbound = new();
|
||||
private readonly Dictionary<uint, LiveEntityRecord> _recordsByGuid = new();
|
||||
// Records leave the active gameplay map before arbitrary teardown callbacks
|
||||
|
|
@ -338,12 +396,38 @@ public sealed class LiveEntityRuntime
|
|||
public LiveEntityRuntime(
|
||||
GpuWorldState spatial,
|
||||
ILiveEntityResourceLifecycle resources,
|
||||
Action<LiveEntityRecord>? tearDownRuntimeComponents = null,
|
||||
uint firstLocalEntityId = FirstLiveEntityId)
|
||||
: this(
|
||||
spatial,
|
||||
resources,
|
||||
NullLiveEntityRuntimeComponentLifecycle.Instance,
|
||||
firstLocalEntityId)
|
||||
{
|
||||
}
|
||||
|
||||
public LiveEntityRuntime(
|
||||
GpuWorldState spatial,
|
||||
ILiveEntityResourceLifecycle resources,
|
||||
Action<LiveEntityRecord> tearDownRuntimeComponents,
|
||||
uint firstLocalEntityId = FirstLiveEntityId)
|
||||
: this(
|
||||
spatial,
|
||||
resources,
|
||||
new DelegateLiveEntityRuntimeComponentLifecycle(tearDownRuntimeComponents),
|
||||
firstLocalEntityId)
|
||||
{
|
||||
}
|
||||
|
||||
internal LiveEntityRuntime(
|
||||
GpuWorldState spatial,
|
||||
ILiveEntityResourceLifecycle resources,
|
||||
ILiveEntityRuntimeComponentLifecycle? runtimeComponentLifecycle,
|
||||
uint firstLocalEntityId = FirstLiveEntityId)
|
||||
{
|
||||
_spatial = spatial ?? throw new ArgumentNullException(nameof(spatial));
|
||||
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
|
||||
_tearDownRuntimeComponents = tearDownRuntimeComponents;
|
||||
_runtimeComponentLifecycle = runtimeComponentLifecycle
|
||||
?? throw new ArgumentNullException(nameof(runtimeComponentLifecycle));
|
||||
if (firstLocalEntityId is < FirstLiveEntityId or > LastLiveEntityId)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(firstLocalEntityId),
|
||||
|
|
@ -2239,8 +2323,7 @@ public sealed class LiveEntityRuntime
|
|||
if (!record.RuntimeComponentsTeardownCompleted)
|
||||
{
|
||||
record.RuntimeComponentsTeardownCompleted =
|
||||
_tearDownRuntimeComponents is null
|
||||
|| TryCleanup(() => _tearDownRuntimeComponents(record));
|
||||
TryCleanup(() => _runtimeComponentLifecycle.TearDown(record));
|
||||
}
|
||||
|
||||
if (record.WorldEntity is { } entity)
|
||||
|
|
|
|||
49
src/AcDream.App/World/LiveWorldOriginState.cs
Normal file
49
src/AcDream.App/World/LiveWorldOriginState.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
namespace AcDream.App.World;
|
||||
|
||||
/// <summary>
|
||||
/// Session-scoped owner of the landblock origin used to translate retail
|
||||
/// full-cell coordinates into the current streamed world coordinate system.
|
||||
/// A placeholder can exist before the first authoritative player position;
|
||||
/// <see cref="IsKnown"/> distinguishes it from a server-confirmed origin.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This owner is update/render-thread confined with the rest of live world
|
||||
/// mutation. It intentionally exposes no cross-thread snapshot contract.
|
||||
/// </remarks>
|
||||
internal sealed class LiveWorldOriginState
|
||||
{
|
||||
public int CenterX { get; private set; }
|
||||
public int CenterY { get; private set; }
|
||||
public bool IsKnown { get; private set; }
|
||||
|
||||
public void SetPlaceholder(int centerX, int centerY)
|
||||
{
|
||||
CenterX = centerX;
|
||||
CenterY = centerY;
|
||||
IsKnown = false;
|
||||
}
|
||||
|
||||
public bool TryInitialize(int centerX, int centerY)
|
||||
{
|
||||
if (IsKnown)
|
||||
return false;
|
||||
|
||||
CenterX = centerX;
|
||||
CenterY = centerY;
|
||||
IsKnown = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Recenter(int centerX, int centerY)
|
||||
{
|
||||
CenterX = centerX;
|
||||
CenterY = centerY;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends the accepted-origin lifetime while retaining the last coordinates
|
||||
/// as the next placeholder. Consumers must gate them on
|
||||
/// <see cref="IsKnown"/> until the next accepted player position.
|
||||
/// </summary>
|
||||
public void Reset() => IsKnown = false;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue