fix(client): restore retail interaction parity
Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
parent
0c699240e0
commit
f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions
|
|
@ -6,7 +6,9 @@ using AcDream.Core.Combat;
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Properties;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Ui;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Interaction;
|
||||
|
|
@ -25,6 +27,22 @@ internal readonly record struct WorldInteractionTarget(
|
|||
|
||||
internal readonly record struct ClosestCombatTarget(uint ServerGuid, float DistanceSquared);
|
||||
|
||||
internal enum RetailSelectionKind
|
||||
{
|
||||
Item,
|
||||
CompassItem,
|
||||
Monster,
|
||||
Player,
|
||||
UnopenedCorpse,
|
||||
}
|
||||
|
||||
internal enum RetailSelectionDirection
|
||||
{
|
||||
Closest,
|
||||
Previous,
|
||||
Next,
|
||||
}
|
||||
|
||||
internal readonly record struct InteractionApproach(
|
||||
WorldInteractionTarget Target,
|
||||
PlayerInteractionPose Player,
|
||||
|
|
@ -36,6 +54,7 @@ internal readonly record struct InteractionApproach(
|
|||
|
||||
internal interface IWorldSelectionQuery
|
||||
{
|
||||
uint PlayerGuid => 0u;
|
||||
uint? PickAtCursor(bool includeSelf);
|
||||
uint? PickAt(float mouseX, float mouseY, bool includeSelf);
|
||||
void BeginLightingPulse(uint serverGuid);
|
||||
|
|
@ -46,6 +65,16 @@ internal interface IWorldSelectionQuery
|
|||
bool IsHostileMonster(uint serverGuid);
|
||||
bool IsAttackableTarget(uint serverGuid);
|
||||
ClosestCombatTarget? FindClosestHostileMonster();
|
||||
uint? FindSelectionTarget(
|
||||
RetailSelectionKind kind,
|
||||
RetailSelectionDirection direction,
|
||||
uint? anchor,
|
||||
bool excludeOwnedByPlayer = false) =>
|
||||
kind == RetailSelectionKind.Monster
|
||||
&& direction == RetailSelectionDirection.Closest
|
||||
? FindClosestHostileMonster()?.ServerGuid
|
||||
: null;
|
||||
uint? FindLastAttacker() => null;
|
||||
bool IsUseable(uint serverGuid);
|
||||
bool IsPickupable(uint serverGuid);
|
||||
bool IsWieldedByPlayer(uint serverGuid);
|
||||
|
|
@ -111,6 +140,9 @@ internal sealed class WorldSelectionQuery
|
|||
private readonly Func<uint, WorldEntity, (float Radius, float Height)> _setupCylinder;
|
||||
private readonly Func<uint, (Vector3 Origin, float Radius)?> _selectionSphere;
|
||||
private readonly Func<uint, Matrix4x4?> _childRootPose;
|
||||
private readonly Func<uint, bool> _hasOpenedCorpse;
|
||||
private readonly Func<CombatMode> _combatMode;
|
||||
private readonly Func<uint, bool> _isFellow;
|
||||
|
||||
public WorldSelectionQuery(
|
||||
LiveEntityRuntime liveEntities,
|
||||
|
|
@ -122,7 +154,10 @@ internal sealed class WorldSelectionQuery
|
|||
Func<PlayerInteractionPose?> playerPose,
|
||||
Func<uint, WorldEntity, (float Radius, float Height)> setupCylinder,
|
||||
Func<uint, (Vector3 Origin, float Radius)?> selectionSphere,
|
||||
Func<uint, Matrix4x4?> childRootPose)
|
||||
Func<uint, Matrix4x4?> childRootPose,
|
||||
Func<uint, bool>? hasOpenedCorpse = null,
|
||||
Func<CombatMode>? combatMode = null,
|
||||
Func<uint, bool>? isFellow = null)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
|
|
@ -134,8 +169,13 @@ internal sealed class WorldSelectionQuery
|
|||
_setupCylinder = setupCylinder ?? throw new ArgumentNullException(nameof(setupCylinder));
|
||||
_selectionSphere = selectionSphere ?? throw new ArgumentNullException(nameof(selectionSphere));
|
||||
_childRootPose = childRootPose ?? throw new ArgumentNullException(nameof(childRootPose));
|
||||
_hasOpenedCorpse = hasOpenedCorpse ?? (_ => false);
|
||||
_combatMode = combatMode ?? (() => CombatMode.NonCombat);
|
||||
_isFellow = isFellow ?? (_ => false);
|
||||
}
|
||||
|
||||
public uint PlayerGuid => _playerGuid();
|
||||
|
||||
public uint? PickAtCursor(bool includeSelf)
|
||||
{
|
||||
Vector2 cursor = _cursor();
|
||||
|
|
@ -293,6 +333,183 @@ internal sealed class WorldSelectionQuery
|
|||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of retail <c>CPlayerSystem::SelectNext @ 0x0055F9A0</c>. The
|
||||
/// ordering scalar is the retail player-space horizontal distance plus
|
||||
/// <c>1.2 * abs(z)</c>; the object id breaks exact-distance ties through
|
||||
/// <c>CPlayerSystem::Farther @ 0x0055D830</c>. Previous/next wrap exactly
|
||||
/// as the paired calls in <c>CPlayerSystem::OnAction @ 0x00561890</c>.
|
||||
/// </summary>
|
||||
public uint? FindSelectionTarget(
|
||||
RetailSelectionKind kind,
|
||||
RetailSelectionDirection direction,
|
||||
uint? anchor,
|
||||
bool excludeOwnedByPlayer = false)
|
||||
{
|
||||
uint playerGuid = _playerGuid();
|
||||
if (!_liveEntities.TryGetWorldEntity(playerGuid, out WorldEntity player))
|
||||
return null;
|
||||
|
||||
float radarRadius = IsOutdoorCell(player.VisibilityCellId)
|
||||
? RetailRadar.OutdoorRangeMeters
|
||||
: RetailRadar.IndoorRangeMeters;
|
||||
var candidates = new List<(uint Guid, float Order)>();
|
||||
foreach (LiveEntityRecord record in _liveEntities.VisibleRecords)
|
||||
{
|
||||
uint guid = record.ServerGuid;
|
||||
if (guid == 0u
|
||||
|| guid == playerGuid
|
||||
|| record.WorldEntity is not { } entity
|
||||
|| _objects.Get(guid) is not { } obj
|
||||
|| (excludeOwnedByPlayer
|
||||
&& _objects.IsOwnedByObject(guid, playerGuid)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float order = SelectionOrder(player, entity);
|
||||
if (order > radarRadius
|
||||
|| !MatchesSelectionKind(kind, guid, obj, record.FinalPhysicsState))
|
||||
continue;
|
||||
candidates.Add((guid, order));
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
return null;
|
||||
candidates.Sort(static (left, right) =>
|
||||
{
|
||||
int distance = left.Order.CompareTo(right.Order);
|
||||
return distance != 0 ? distance : left.Guid.CompareTo(right.Guid);
|
||||
});
|
||||
|
||||
if (direction == RetailSelectionDirection.Closest)
|
||||
return candidates[0].Guid;
|
||||
|
||||
(float Order, uint Guid)? anchorKey = null;
|
||||
if (anchor is { } anchorGuid
|
||||
&& _liveEntities.TryGetWorldEntity(anchorGuid, out WorldEntity anchorEntity))
|
||||
{
|
||||
anchorKey = (SelectionOrder(player, anchorEntity), anchorGuid);
|
||||
}
|
||||
|
||||
if (anchorKey is null)
|
||||
{
|
||||
return direction == RetailSelectionDirection.Previous
|
||||
? candidates[^1].Guid
|
||||
: candidates[0].Guid;
|
||||
}
|
||||
|
||||
if (direction == RetailSelectionDirection.Next)
|
||||
{
|
||||
foreach ((uint guid, float order) in candidates)
|
||||
{
|
||||
if (CompareSelectionKey(order, guid, anchorKey.Value.Order, anchorKey.Value.Guid) > 0)
|
||||
return guid;
|
||||
}
|
||||
return candidates[0].Guid;
|
||||
}
|
||||
|
||||
for (int i = candidates.Count - 1; i >= 0; i--)
|
||||
{
|
||||
(uint guid, float order) = candidates[i];
|
||||
if (CompareSelectionKey(order, guid, anchorKey.Value.Order, anchorKey.Value.Guid) < 0)
|
||||
return guid;
|
||||
}
|
||||
return candidates[^1].Guid;
|
||||
}
|
||||
|
||||
public uint? FindLastAttacker()
|
||||
{
|
||||
uint playerGuid = _playerGuid();
|
||||
uint attacker = 0u;
|
||||
if (_objects.Get(playerGuid) is not { } playerObject
|
||||
|| !playerObject.Properties.InstanceIds.TryGetValue(
|
||||
(uint)PropertyInstanceId.CurrentAttacker,
|
||||
out attacker)
|
||||
|| attacker == 0u
|
||||
|| !_liveEntities.TryGetWorldEntity(playerGuid, out WorldEntity player)
|
||||
|| !_liveEntities.TryGetWorldEntity(attacker, out WorldEntity target))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
float radarRadius = IsOutdoorCell(player.VisibilityCellId)
|
||||
? RetailRadar.OutdoorRangeMeters
|
||||
: RetailRadar.IndoorRangeMeters;
|
||||
return SelectionOrder(player, target) <= radarRadius ? attacker : null;
|
||||
}
|
||||
|
||||
private bool MatchesSelectionKind(
|
||||
RetailSelectionKind kind,
|
||||
uint guid,
|
||||
ClientObject obj,
|
||||
PhysicsStateFlags physicsState)
|
||||
{
|
||||
bool showableOnRadar = obj.RadarBehavior is { } behavior
|
||||
&& RetailRadar.IsShowable((RadarBehavior)behavior, hasPhysicsObject: true);
|
||||
PublicWeenieFlags flags = (PublicWeenieFlags)(obj.PublicWeenieBitfield ?? 0u);
|
||||
bool isFellow = _isFellow(guid);
|
||||
bool isCombatCompass = _combatMode() is CombatMode.Melee or CombatMode.Missile;
|
||||
bool isSpecialCompassObject = (flags
|
||||
& (PublicWeenieFlags.Lifestone
|
||||
| PublicWeenieFlags.Portal
|
||||
| PublicWeenieFlags.Bindstone)) != 0;
|
||||
|
||||
// The common tail of CPlayerSystem::SelectNext rejects every object
|
||||
// currently inside a container, every cloaked physics object, and a
|
||||
// PWD carrying the reserved sign bit, independent of selection kind.
|
||||
if (obj.ContainerId != 0u
|
||||
|| (physicsState & PhysicsStateFlags.Cloaked) != 0
|
||||
|| (((uint)flags & 0x8000_0000u) != 0))
|
||||
return false;
|
||||
|
||||
return kind switch
|
||||
{
|
||||
RetailSelectionKind.Item =>
|
||||
obj.RadarBehavior is null or 0
|
||||
|| isSpecialCompassObject,
|
||||
RetailSelectionKind.CompassItem =>
|
||||
(isSpecialCompassObject || showableOnRadar)
|
||||
&& (!isCombatCompass
|
||||
|| (IsAttackableTarget(guid)
|
||||
&& !isFellow
|
||||
&& (flags & PublicWeenieFlags.Vendor) == 0
|
||||
&& (physicsState & PhysicsStateFlags.ReportAsEnvironment) == 0)),
|
||||
RetailSelectionKind.Monster =>
|
||||
showableOnRadar
|
||||
&& IsAttackableTarget(guid)
|
||||
&& !isFellow
|
||||
&& (flags & PublicWeenieFlags.Vendor) == 0,
|
||||
RetailSelectionKind.Player =>
|
||||
showableOnRadar && (flags & PublicWeenieFlags.Player) != 0,
|
||||
RetailSelectionKind.UnopenedCorpse =>
|
||||
(flags & PublicWeenieFlags.Corpse) != 0
|
||||
&& !_hasOpenedCorpse(guid),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static float SelectionOrder(WorldEntity player, WorldEntity target)
|
||||
{
|
||||
Vector3 delta = target.Position - player.Position;
|
||||
Vector3 local = Vector3.Transform(delta, Quaternion.Inverse(player.Rotation));
|
||||
return MathF.Sqrt(local.X * local.X + local.Y * local.Y)
|
||||
+ MathF.Abs(local.Z) * 1.2f;
|
||||
}
|
||||
|
||||
private static int CompareSelectionKey(
|
||||
float leftOrder,
|
||||
uint leftGuid,
|
||||
float rightOrder,
|
||||
uint rightGuid)
|
||||
{
|
||||
int order = leftOrder.CompareTo(rightOrder);
|
||||
return order != 0 ? order : leftGuid.CompareTo(rightGuid);
|
||||
}
|
||||
|
||||
private static bool IsOutdoorCell(uint? cellId)
|
||||
=> cellId is null || (cellId.Value & 0xFFFFu) < 0x100u;
|
||||
|
||||
/// <summary>
|
||||
/// #298 follow-up: retail <c>ClientCombatSystem::UpdateTargetTracking
|
||||
/// @ 0x0056A950</c> (pc:375691-375696) gates <c>CameraSet::TrackTarget</c>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue