fix(combat): escape occupied login positions

Port retail's radius-aware placement ring so a relogging player is seated beside creatures occupying the saved location, and register the local body in the shared resolved-shadow pipeline. Route new forward movement and jump through AbortAutomaticAttack so repeat combat cancels immediately on movement.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-12 22:20:17 +02:00
parent e671b8a5c4
commit 00fe993f6f
11 changed files with 643 additions and 16 deletions

View file

@ -1325,4 +1325,47 @@ public sealed class PhysicsEngine
return resolveResult;
}
/// <summary>
/// Runs retail's radius-aware placement-ring search after the host has
/// validated the server's cell and grounded the initial position. This is
/// the <c>CTransition::find_placement_pos</c> half of enter-world
/// <c>SetPosition</c>; unlike an ordinary zero-distance transition it tests
/// object occupancy and can seat a relogging player beside a creature that
/// now occupies the saved location.
/// </summary>
public ResolveResult ResolvePlacement(
Vector3 position,
uint cellId,
float sphereRadius,
float sphereHeight,
float stepUpHeight,
float stepDownHeight,
ObjectInfoState moverFlags = ObjectInfoState.None,
uint movingEntityId = 0)
{
var transition = new Transition();
transition.ObjectInfo.StepUpHeight = stepUpHeight;
transition.ObjectInfo.StepDownHeight = stepDownHeight;
transition.ObjectInfo.StepDown = true;
transition.ObjectInfo.SelfEntityId = movingEntityId;
transition.ObjectInfo.State = moverFlags;
transition.SpherePath.InitPath(
position, position, cellId, sphereRadius, sphereHeight);
transition.SpherePath.InsertType = InsertType.Placement;
bool ok = transition.FindPlacementPos(this);
var sp = transition.SpherePath;
var ci = transition.CollisionInfo;
bool onGround = ci.ContactPlaneValid
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
return new ResolveResult(
sp.CurPos,
sp.CurCellId != 0 ? sp.CurCellId : cellId,
onGround,
ci.CollisionNormalValid,
ci.CollisionNormal,
ok);
}
}

View file

@ -391,6 +391,7 @@ public sealed class SpherePath
public Vector3 NegCollisionNormal;
public bool CheckWalkable;
public InsertType InsertType = InsertType.Transition;
public bool PlacementAllowsSliding = true;
/// <summary>
/// Retail <c>SPHEREPATH.obstruction_ethereal</c>: set to <c>1</c> per-target
@ -987,6 +988,122 @@ public sealed class Transition
return transitionState == TransitionState.OK;
}
/// <summary>
/// Retail <c>CTransition::find_placement_pos</c> (0x0050BA50).
/// Tests the requested position first, then searches concentric rings up
/// to four metres away for the nearest valid placement. The ring spacing
/// and angular sampling are radius-dependent exactly as in retail.
/// </summary>
public bool FindPlacementPos(PhysicsEngine engine)
{
var sp = SpherePath;
sp.SetCheckPos(sp.CurPos, sp.CurCellId);
CollisionInfo.SlidingNormalValid = false;
CollisionInfo.ContactPlaneValid = false;
CollisionInfo.ContactPlaneIsWater = false;
var transitionState = ValidatePlacementTransition(
TransitionalInsert(3, engine));
if (transitionState == TransitionState.OK)
return true;
if (!sp.PlacementAllowsSliding)
return false;
float adjustDistance = 4f;
float adjustRadius = adjustDistance;
float sphereRadius = sp.LocalSphere[0].Radius;
bool fakeSphere = false;
if (sphereRadius < 0.125f)
{
fakeSphere = true;
adjustRadius = 2f;
}
else if (sphereRadius < 0.48f)
{
sphereRadius = 0.48f;
}
float stepCountExact = 4f / sphereRadius;
if (fakeSphere)
stepCountExact *= 0.5f;
if (stepCountExact <= 1f)
return false;
int stepCount = (int)MathF.Ceiling(stepCountExact);
float distancePerStep = adjustRadius / stepCount;
float radiansPerStep = MathF.PI * distancePerStep / sphereRadius;
float totalDistance = 0f;
float totalRadians = 0f;
for (int ring = 0; ring < stepCount; ring++)
{
totalDistance += distancePerStep;
totalRadians += radiansPerStep;
int sampleCount = (int)MathF.Ceiling(totalRadians) * 2;
float headingStep = 360f / sampleCount;
for (int sample = 0; sample < sampleCount; sample++)
{
sp.SetCheckPos(sp.CurPos, sp.CurCellId);
// Frame::set_heading/get_vector_heading: 0 degrees is +Y,
// 90 degrees is +X in AC's compass convention.
float headingRadians = headingStep * sample * (MathF.PI / 180f);
var offset = new Vector3(
MathF.Sin(headingRadians) * totalDistance,
MathF.Cos(headingRadians) * totalDistance,
0f);
sp.GlobalOffset = AdjustOffset(offset);
if (sp.GlobalOffset.Length() < PhysicsGlobals.EPSILON)
continue;
sp.AddOffsetToCheckPos(sp.GlobalOffset);
CollisionInfo.SlidingNormalValid = false;
CollisionInfo.ContactPlaneValid = false;
CollisionInfo.ContactPlaneIsWater = false;
transitionState = ValidatePlacementTransition(
TransitionalInsert(3, engine));
if (transitionState == TransitionState.OK)
return true;
}
}
return false;
}
private TransitionState ValidatePlacementTransition(TransitionState transitionState)
{
var sp = SpherePath;
if (sp.CheckCellId == 0)
return TransitionState.Collided;
if (transitionState == TransitionState.OK)
{
sp.CurPos = sp.CheckPos;
sp.CurCellId = sp.CheckCellId;
sp.CurOrientation = sp.CheckOrientation;
for (int i = 0; i < sp.NumSphere; i++)
{
sp.GlobalCurrCenter[i].Origin = sp.LocalSphere[i].Origin + sp.CurPos;
sp.GlobalCurrCenter[i].Radius = sp.LocalSphere[i].Radius;
}
}
else if (sp.PlacementAllowsSliding)
{
// COLLISIONINFO::init at retail 0x0050B052. Placement probes are
// independent; a failed compass sample must not bias the next one.
CollisionInfo = new CollisionInfo();
}
return transitionState;
}
// -----------------------------------------------------------------------
// Per-step collision check
// -----------------------------------------------------------------------