feat(physics): live-entity collision registration (Commit B)

NPCs / monsters / other players now register into ShadowObjectRegistry
as collision targets. The local player walks into them and stops at
the body cylinder, instead of passing through.

GameWindow.OnLiveEntitySpawnedLocked: after the WorldEntity is built
and stored in `_entitiesByServerGuid`, call the new
RegisterLiveEntityCollision helper for any non-self entity. The helper
honors retail's geometry-priority order (acclient_2013_pseudo_c.txt:
276858-276987) — CylSpheres > Setup.Radius > Sphere fallback — and
applies the retail phantom-Setup skip (no CylSpheres / no Spheres /
zero Radius → walk-through, matching FUN_FindObjCollisions's OK_TS
fallthrough at :276917).

GameWindow.OnLivePositionUpdated: after the entity's render pos/rot
are set to server truth, push the same coordinates into the registry
via ShadowObjectRegistry.UpdatePosition (the cheap
preserve-shape-and-flags path Commit A added). Mirrors retail's
SetPosition → change_cell → AddShadowObject chain (
acclient_2013_pseudo_c.txt:284276 / 281200 / 282862).

The local player's own server guid is filtered out at both
registration and update — its PhysicsBody is the simulator (the
source of truth for our collisions), not a collision target.

The decoded EntityCollisionFlags + raw PhysicsState bits are stored
on each ShadowEntry but NOT YET CONSULTED by the collision resolver
— Commit C is where the PvP exemption block lands. Practical effect
of THIS commit: every visible body, including non-PK other players,
blocks the local player. Two non-PK players currently can't pass
through each other; that's the rule Commit C reverts to retail.

No new unit tests in this commit (Commit A's ShadowObjectRegistry +
EntityCollisionFlags suite covers the new field plumbing).
Verification is live: at Holtburg the +Acdream test character should
stop on contact with NPCs / vendors. Phantom decorations (small
plants, grass) continue to pass through (L-fix3 phantom skip
extends naturally to the live path via the same Setup-shape gate).

dotnet build green, dotnet test 1454 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-29 13:16:22 +02:00
parent ffefc6977f
commit 46ca3ba26b

View file

@ -2067,6 +2067,18 @@ public sealed class GameWindow : IDisposable
// UpdateMotion / UpdatePosition events can reseat this entity by guid.
_entitiesByServerGuid[spawn.Guid] = entity;
// Commit B 2026-04-29 — live-entity collision registration. The
// local player is the simulator (its PhysicsBody is the source of
// truth for our own movement); only remotes register as targets.
// Phantom-Setup entities (no CylSpheres / no Spheres / no Radius)
// are deliberately skipped — retail FUN's `FindObjCollisions`
// falls through to OK_TS for any object with no collision
// geometry (acclient_2013_pseudo_c.txt:276917,276987).
if (spawn.Guid != _playerServerGuid)
{
RegisterLiveEntityCollision(entity, setup, spawn, origin);
}
// Phase B.2: capture the server-sent MotionTableId for our own
// character so UpdatePlayerAnimation can pass it to GetIdleCycle.
// The Setup's DefaultMotionTable is often 0 for human characters;
@ -2284,6 +2296,100 @@ public sealed class GameWindow : IDisposable
}
}
/// <summary>
/// Commit B 2026-04-29 — register a live (server-spawned) entity into
/// the <see cref="ShadowObjectRegistry"/> as a single collision body.
/// One entry per entity (in contrast to static scenery's per-CylSphere
/// registration) so <c>RemoveLiveEntityByServerGuid</c>'s single
/// <c>Deregister(entity.Id)</c> cleans it up without leaks.
///
/// <para>
/// Geometry-priority order matches retail
/// (<c>acclient_2013_pseudo_c.txt:276858-276987</c>): CylSpheres &gt;
/// Sphere fallback &gt; Setup.Radius. Phantom Setups (no shape) are
/// rejected — retail's <c>FindObjCollisions</c> falls through to
/// OK_TS in that case.
/// </para>
///
/// <para>
/// Carries <see cref="EntityCollisionFlags"/> derived from the PWD
/// bitfield (<c>acclient.h:6431-6463</c>) plus <c>IsCreature</c>
/// derived from the inbound ItemType. Commit C consumes these in
/// the PvP exemption block.
/// </para>
/// </summary>
private void RegisterLiveEntityCollision(
AcDream.Core.World.WorldEntity entity,
DatReaderWriter.DBObjs.Setup setup,
AcDream.Core.Net.WorldSession.EntitySpawn spawn,
System.Numerics.Vector3 origin)
{
if (spawn.Position is null) return;
bool hasCyl = setup.CylSpheres.Count > 0;
bool hasSphere = setup.Spheres.Count > 0;
bool hasRadius = setup.Radius > 0.0001f;
// Retail-faithful phantom skip (acclient_2013_pseudo_c.txt:276917).
if (!hasCyl && !hasSphere && !hasRadius)
return;
float entScale = spawn.ObjScale ?? 1.0f;
float radius;
float height;
if (hasCyl)
{
// Pick the largest CylSphere as the body cylinder. Retail
// tests every CylSphere in turn (276891) but for collision
// BLOCKING the largest is sufficient — the player will stop
// at the body's outer radius.
var sph = setup.CylSpheres[0];
for (int i = 1; i < setup.CylSpheres.Count; i++)
{
if (setup.CylSpheres[i].Radius > sph.Radius) sph = setup.CylSpheres[i];
}
radius = sph.Radius * entScale;
height = (sph.Height > 0 ? sph.Height : sph.Radius * 4f) * entScale;
}
else if (hasRadius)
{
radius = setup.Radius * entScale;
height = (setup.Height > 0 ? setup.Height : setup.Radius * 2f) * entScale;
}
else
{
// Sphere-only: largest sphere as a Cylinder approximation.
var sph = setup.Spheres[0];
for (int i = 1; i < setup.Spheres.Count; i++)
{
if (setup.Spheres[i].Radius > sph.Radius) sph = setup.Spheres[i];
}
radius = sph.Radius * entScale;
height = sph.Radius * 2f * entScale;
}
if (radius <= 0f) return;
// Decode PvP / Player / Impenetrable from PWD._bitfield.
// IsCreature comes from the spawn's ItemType (server-known type).
var flags = AcDream.Core.Physics.EntityCollisionFlags.None;
if (spawn.ObjectDescriptionFlags is { } odf)
flags = AcDream.Core.Physics.EntityCollisionFlagsExt.FromPwdBitfield(odf);
if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature)
flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature;
uint state = spawn.PhysicsState ?? 0u;
_physicsEngine.ShadowObjects.Register(
entity.Id, entity.SourceGfxObjOrSetupId,
entity.Position, entity.Rotation, radius,
origin.X, origin.Y, spawn.Position.Value.LandblockId,
AcDream.Core.Physics.ShadowCollisionType.Cylinder,
cylHeight: height, scale: 1.0f,
state: state, flags: flags);
}
private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete)
{
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
@ -2980,6 +3086,18 @@ public sealed class GameWindow : IDisposable
entity.Position = worldPos;
entity.Rotation = rot;
// Commit B 2026-04-29 — keep the shadow registry in sync with
// server-authoritative position so the player's collision broadphase
// tests against the up-to-date target body. Skip the local player
// (its body is the simulator, not a target). Retail does the
// equivalent via SetPosition → change_cell → AddShadowObject
// (acclient_2013_pseudo_c.txt:284276 / 281200 / 282862).
if (update.Guid != _playerServerGuid)
{
_physicsEngine.ShadowObjects.UpdatePosition(
entity.Id, worldPos, rot, origin.X, origin.Y, p.LandblockId);
}
// Track remote-entity motion for stop detection. Only record the
// timestamp when position moved MEANINGFULLY (> 0.05m). Updates
// that report the same position keep the old Time, so the