LANTERN P1 step 2.5: client feel layer migrated to the socket kit

Per Phase1_Combat_Gym_Build_Spec §8 (the "KEEP as-is" feel layer was really a migration):
- PlayerAnimationDriveSystem: both jobs (Local/Remote) now read SocketCooldown +
  DynamicBuffer<AbilitySocket>/<EffectiveSocketStats> instead of the single AbilityCooldown/
  AbilityRef/EffectiveAbilityStats; shared SocketFireAndCone helper (any-socket-firing model:
  IsFiring if any socketed Spark's per-socket cooldown window is mid-fire; IsCone if any such
  is Cone).
- CombatFeedbackSystem: the muzzle-flash + cone-cue blocks unified into one per-socket
  fire-edge loop over SocketCooldown (per-socket uint cache); non-Cone -> muzzle flash,
  Cone -> the aimed slash-arc cue. Dropped the now-dead single-cooldown latches.
L1 clean; L2 494/494 (no regression).

Deferred to the AbilityRef-removal cleanup (surfaced, NOT silent): the FrameId server-writer +
the 2 class-HUD re-points (ClassPrepPortalHudSystem/MetaShopHudSystem) + HudSystem's ability
bar. Rationale: AbilityRef is still baked + set (EquipSystem/ClassSwapUtil), so ClassForAbility
remains a valid class signal through the transition; FrameId is baked ready. No feel regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 11:13:05 -07:00
parent 12f86c86de
commit 21f609c6c4
2 changed files with 96 additions and 103 deletions
@@ -64,9 +64,7 @@ namespace ProjectM.Client
bool _slashActive;
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first
double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter)
bool _coneTickInit;
// Remote teammates' melee cleave arcs (deferred-items pass, co-op): one pooled slash renderer per remote
// player, edge-detected from the replicated MeleeCombo.SwingStartTick (the local player keeps _slashMr).
@@ -91,8 +89,8 @@ namespace ProjectM.Client
Vector3 _lastFootPos; float _footTimer; bool _footInit; // footstep edge-detect (local player locomotion)
Entity _localPlayer = Entity.Null;
uint _lastLocalFireTick;
bool _fireTickInit;
uint[] _lastSocketFire = new uint[SocketId.Count]; // LANTERN per-socket fire-edge cache (was _lastLocalFireTick)
bool _socketFireInit;
uint _lastLocalDashTick;
bool _dashTickInit;
uint _lastLocalSwingTick;
@@ -315,31 +313,67 @@ namespace ProjectM.Client
}
}
// C3: a CONE ability (the Warrior cleave) gets the dedicated cone-arc cue below, NOT the projectile muzzle
// flash + zap (those read as a ranged shot). Detect the local ability archetype once and gate the muzzle.
bool localIsCone = false;
if (_localPlayer != Entity.Null && EntityManager.HasComponent<AbilityRef>(_localPlayer)
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var muzDb) && muzDb.Value.IsCreated)
// LANTERN 4-socket fire feedback: edge-detect each socket's SocketCooldown (raw uint edge, cosmetic
// only like dash/melee). A non-Cone Spark -> muzzle flash + zap; a Cone Spark -> the aimed slash-arc
// cue (server-only cleave has no projectile). Replaces the single-AbilityCooldown muzzle + cone blocks.
if (_localPlayer != Entity.Null
&& EntityManager.HasComponent<SocketCooldown>(_localPlayer)
&& EntityManager.HasBuffer<AbilitySocket>(_localPlayer)
&& EntityManager.HasBuffer<EffectiveSocketStats>(_localPlayer)
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var fireDb) && fireDb.Value.IsCreated)
{
ref var muzAdb = ref muzDb.Value.Value;
if (muzAdb.TryGetAbility(EntityManager.GetComponentData<AbilityRef>(_localPlayer).Id, out var muzDef)
&& muzDef.Archetype == (byte)AbilityArchetype.Cone)
localIsCone = true;
}
// Local-player fire feedback: AbilityCooldown.NextFireTick advances on each shot.
// Raw uint inequality is intentional here: only the edge (a new shot) matters and the worst case
// of a tick wrap is a single dropped/duplicated muzzle flash — purely cosmetic, never the sim.
if (_localPlayer != Entity.Null && EntityManager.HasComponent<AbilityCooldown>(_localPlayer))
{
uint nextFire = EntityManager.GetComponentData<AbilityCooldown>(_localPlayer).NextFireTick;
if (_fireTickInit && nextFire != 0 && nextFire != _lastLocalFireTick && !localIsCone)
var scd = EntityManager.GetComponentData<SocketCooldown>(_localPlayer);
var socks = EntityManager.GetBuffer<AbilitySocket>(_localPlayer, true);
var effs = EntityManager.GetBuffer<EffectiveSocketStats>(_localPlayer, true);
ref var fireAdb = ref fireDb.Value.Value;
Vector3 sface = Vector3.forward;
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer))
{
Burst(_muzzleFx, cfg != null ? cfg.Muzzle : null, (Vector3)localPos + Vector3.up * 0.9f, 8);
PlayClip(_fireClip, (Vector3)localPos, 0.5f);
var sfd = EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction;
if (math.lengthsq(sfd) > 1e-6f) sface = new Vector3(sfd.x, 0f, sfd.y).normalized;
}
_lastLocalFireTick = nextFire;
_fireTickInit = true;
float2 sfdir = new float2(sface.x, sface.z);
int sn = math.min(SocketId.Count, math.min(socks.Length, effs.Length));
for (int sk = 0; sk < sn; sk++)
{
uint nf = scd.Get(sk);
bool edge = _socketFireInit && nf != 0 && nf != _lastSocketFire[sk];
_lastSocketFire[sk] = nf;
if (!edge) continue;
byte sid = socks[sk].SparkId;
if (sid == 0) continue;
bool coneSpark = fireAdb.TryGetAbility(sid, out var sdef) && sdef.Archetype == (byte)AbilityArchetype.Cone;
if (!coneSpark)
{
Burst(_muzzleFx, cfg != null ? cfg.Muzzle : null, (Vector3)localPos + Vector3.up * 0.9f, 8);
PlayClip(_fireClip, (Vector3)localPos, 0.5f);
continue;
}
var es = effs[sk];
float coneRange = Mathf.Max(0.1f, es.Range);
float coneHalf = Mathf.Clamp(es.AutoTargetConeRadians, 0.01f, 3.14159f);
bool coneConnected = false; Vector3 coneHit = (Vector3)localPos; float cnd = float.MaxValue;
float coneCos = Mathf.Cos(coneHalf);
foreach (var kv in _cache)
{
if (!kv.Value.IsEnemy) continue;
if (MeleeConeMath.InCone(localPos, sfdir, coneRange, coneCos, kv.Value.Pos))
{
float cd2 = math.distancesq(localPos, kv.Value.Pos);
if (cd2 < cnd) { cnd = cd2; coneHit = (Vector3)kv.Value.Pos; coneConnected = true; }
}
}
TriggerSlash((Vector3)localPos, sfdir, coneRange, coneHalf, 1, 1, coneConnected);
PlayClip(_swingClip, (Vector3)localPos, 0.5f);
PrototypeCameraRig.AddShake(0.06f);
if (coneConnected)
{
Burst(_hitFx, cfg != null ? cfg.Hit : null, coneHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
PlayClip(_meleeConnectClip, coneHit, FeelConfig.MeleeConnectVolume);
PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs);
}
}
_socketFireInit = true;
}
// Local-player dash feedback (MC-1): DashCooldown.NextTick advances exactly once per dash
@@ -423,59 +457,6 @@ namespace ProjectM.Client
_swingTickInit = true;
}
// Slice-2 deferred: the Warrior CONE ability had no client VFX (server-only cleave). Edge-detect the
// PREDICTED AbilityCooldown (raw uint, like the dash) and reuse the slash-arc sweep with the ability's
// own effective geometry — an aimed cone IS an arc.
if (_localPlayer != Entity.Null
&& EntityManager.HasComponent<AbilityCooldown>(_localPlayer)
&& EntityManager.HasComponent<AbilityRef>(_localPlayer)
&& EntityManager.HasComponent<EffectiveAbilityStats>(_localPlayer))
{
uint nextFire = EntityManager.GetComponentData<AbilityCooldown>(_localPlayer).NextFireTick;
if (_coneTickInit && nextFire != 0 && nextFire != _lastConeFireTick
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var coneDb) && coneDb.Value.IsCreated)
{
ref var coneAdb = ref coneDb.Value.Value;
if (coneAdb.TryGetAbility(EntityManager.GetComponentData<AbilityRef>(_localPlayer).Id, out var coneDef)
&& coneDef.Archetype == (byte)AbilityArchetype.Cone)
{
var ceff = EntityManager.GetComponentData<EffectiveAbilityStats>(_localPlayer);
Vector3 cface = Vector3.forward;
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer))
{
var cfd = EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction;
if (math.lengthsq(cfd) > 1e-6f) cface = new Vector3(cfd.x, 0f, cfd.y).normalized;
}
float coneRange = Mathf.Max(0.1f, ceff.Range);
float coneHalf = Mathf.Clamp(ceff.AutoTargetConeRadians, 0.01f, 3.14159f);
// C3: client cone-overlap over the cached enemy snapshot -> an immediate "you hit" read + thunk
// (the server-only cone damage arrives a few ticks later), mirroring the melee connect path.
bool coneConnected = false; Vector3 coneHit = (Vector3)localPos; float cnd = float.MaxValue;
float coneCos = Mathf.Cos(coneHalf);
float2 cfdir = new float2(cface.x, cface.z);
foreach (var kv in _cache)
{
if (!kv.Value.IsEnemy) continue;
if (MeleeConeMath.InCone(localPos, cfdir, coneRange, coneCos, kv.Value.Pos))
{
float cd2 = math.distancesq(localPos, kv.Value.Pos);
if (cd2 < cnd) { cnd = cd2; coneHit = (Vector3)kv.Value.Pos; coneConnected = true; }
}
}
TriggerSlash((Vector3)localPos, new float2(cface.x, cface.z), coneRange, coneHalf, 1, 1, coneConnected);
PlayClip(_swingClip, (Vector3)localPos, 0.5f);
PrototypeCameraRig.AddShake(0.06f);
if (coneConnected)
{
Burst(_hitFx, cfg != null ? cfg.Hit : null, coneHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
PlayClip(_meleeConnectClip, coneHit, FeelConfig.MeleeConnectVolume);
PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs);
}
}
}
_lastConeFireTick = nextFire;
_coneTickInit = true;
}
// Footsteps (combat feel): edge-detect local locomotion from the position delta; a soft step at a cadence.
if (_localPlayer != Entity.Null)