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:
parent
ffefc6977f
commit
46ca3ba26b
1 changed files with 118 additions and 0 deletions
|
|
@ -2067,6 +2067,18 @@ public sealed class GameWindow : IDisposable
|
||||||
// UpdateMotion / UpdatePosition events can reseat this entity by guid.
|
// UpdateMotion / UpdatePosition events can reseat this entity by guid.
|
||||||
_entitiesByServerGuid[spawn.Guid] = entity;
|
_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
|
// Phase B.2: capture the server-sent MotionTableId for our own
|
||||||
// character so UpdatePlayerAnimation can pass it to GetIdleCycle.
|
// character so UpdatePlayerAnimation can pass it to GetIdleCycle.
|
||||||
// The Setup's DefaultMotionTable is often 0 for human characters;
|
// 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 >
|
||||||
|
/// Sphere fallback > 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)
|
private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete)
|
||||||
{
|
{
|
||||||
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
|
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
|
||||||
|
|
@ -2980,6 +3086,18 @@ public sealed class GameWindow : IDisposable
|
||||||
entity.Position = worldPos;
|
entity.Position = worldPos;
|
||||||
entity.Rotation = rot;
|
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
|
// Track remote-entity motion for stop detection. Only record the
|
||||||
// timestamp when position moved MEANINGFULLY (> 0.05m). Updates
|
// timestamp when position moved MEANINGFULLY (> 0.05m). Updates
|
||||||
// that report the same position keep the old Time, so the
|
// that report the same position keep the old Time, so the
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue