From 21f609c6c46f830a3ca39f9255a307111ca2623a Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 14 Jul 2026 11:13:05 -0700 Subject: [PATCH] LANTERN P1 step 2.5: client feel layer migrated to the socket kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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) --- .../Presentation/CombatFeedbackSystem.cs | 139 ++++++++---------- .../PlayerAnimationDriveSystem.cs | 60 +++++--- 2 files changed, 96 insertions(+), 103 deletions(-) diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index 337b735a8..ebea30e6b 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -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(_localPlayer) - && SystemAPI.TryGetSingleton(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(_localPlayer) + && EntityManager.HasBuffer(_localPlayer) + && EntityManager.HasBuffer(_localPlayer) + && SystemAPI.TryGetSingleton(out var fireDb) && fireDb.Value.IsCreated) { - ref var muzAdb = ref muzDb.Value.Value; - if (muzAdb.TryGetAbility(EntityManager.GetComponentData(_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(_localPlayer)) - { - uint nextFire = EntityManager.GetComponentData(_localPlayer).NextFireTick; - if (_fireTickInit && nextFire != 0 && nextFire != _lastLocalFireTick && !localIsCone) + var scd = EntityManager.GetComponentData(_localPlayer); + var socks = EntityManager.GetBuffer(_localPlayer, true); + var effs = EntityManager.GetBuffer(_localPlayer, true); + ref var fireAdb = ref fireDb.Value.Value; + Vector3 sface = Vector3.forward; + if (EntityManager.HasComponent(_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(_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(_localPlayer) - && EntityManager.HasComponent(_localPlayer) - && EntityManager.HasComponent(_localPlayer)) - { - uint nextFire = EntityManager.GetComponentData(_localPlayer).NextFireTick; - if (_coneTickInit && nextFire != 0 && nextFire != _lastConeFireTick - && SystemAPI.TryGetSingleton(out var coneDb) && coneDb.Value.IsCreated) - { - ref var coneAdb = ref coneDb.Value.Value; - if (coneAdb.TryGetAbility(EntityManager.GetComponentData(_localPlayer).Id, out var coneDef) - && coneDef.Archetype == (byte)AbilityArchetype.Cone) - { - var ceff = EntityManager.GetComponentData(_localPlayer); - Vector3 cface = Vector3.forward; - if (EntityManager.HasComponent(_localPlayer)) - { - var cfd = EntityManager.GetComponentData(_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) diff --git a/Assets/_Project/Scripts/Client/Presentation/PlayerAnimationDriveSystem.cs b/Assets/_Project/Scripts/Client/Presentation/PlayerAnimationDriveSystem.cs index 193bb78e5..88cfa5cd7 100644 --- a/Assets/_Project/Scripts/Client/Presentation/PlayerAnimationDriveSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/PlayerAnimationDriveSystem.cs @@ -137,6 +137,30 @@ namespace ProjectM.Client return start.IsValid && end.IsValid && !start.IsNewerThan(serverTick) && end.IsNewerThan(serverTick); } + // LANTERN 4-socket fire/cone anim resolution (any-socket model): firing if ANY socketed Spark's + // per-socket cooldown window is mid-fire; cone if any such active socket holds a Cone-archetype Spark. + // Replaces the single-ability FireActive + IsCone reads (AbilityCooldown/EffectiveAbilityStats/AbilityRef). + static void SocketFireAndCone(in SocketCooldown cd, DynamicBuffer sockets, + DynamicBuffer effSockets, BlobAssetReference abilityDb, + NetworkTick serverTick, uint animTicks, out bool firing, out bool cone) + { + firing = false; cone = false; + int n = math.min(SocketId.Count, math.min(sockets.Length, effSockets.Length)); + bool haveDb = abilityDb.IsCreated; + for (int sk = 0; sk < n; sk++) + { + byte sid = sockets[sk].SparkId; + if (sid == 0) continue; + if (!FireActive(cd.Get(sk), effSockets[sk].CooldownTicks, serverTick, animTicks)) continue; + firing = true; + if (haveDb) + { + ref var adb = ref abilityDb.Value; + if (adb.TryGetAbility(sid, out var d) && d.Archetype == (byte)AbilityArchetype.Cone) cone = true; + } + } + } + // LOCAL: GhostOwnerIsLocal ENABLED -> exactly the owned player. WithPresent so alive // (Dead-disabled) players are visited. NOTE: GhostOwnerIsLocal as a WithAll filter respects the // enable bit; do NOT take it as an `in` parameter (that matches on presence -> drives remotes too). @@ -157,9 +181,9 @@ namespace ProjectM.Client in EffectiveCharacterStats stats, in KinematicCharacterBody body, in MeleeCombo melee, - in AbilityCooldown fireCooldown, - in EffectiveAbilityStats abilityStats, - in AbilityRef abilityRef, + in SocketCooldown socketCd, + [ReadOnly] DynamicBuffer sockets, + [ReadOnly] DynamicBuffer effSockets, in DashState dashState, EnabledRefRO dead) { @@ -167,8 +191,8 @@ namespace ProjectM.Client float3 p = AnimParamMath.LocomotionParams(body.RelativeVelocity, facing.Direction, stats.MoveSpeed); Write(ref a, p, dead.ValueRO, moveX, moveZ, speed, isDead); if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, SwingActive(melee, serverTick, attackTicks)); - if (a.HasParameter(isFiring)) a.SetParameterValue(isFiring, - FireActive(fireCooldown.NextFireTick, abilityStats.CooldownTicks, serverTick, attackTicks)); + SocketFireAndCone(socketCd, sockets, effSockets, abilityDb, serverTick, attackTicks, out bool firingL, out bool coneL); + if (a.HasParameter(isFiring)) a.SetParameterValue(isFiring, firingL); // Dash lean: the LOCAL player's predicted DashState window [StartTick, IFrameUntilTick+tail). bool dashActive = false; if (dashState.StartTick != 0u && dashState.IFrameUntilTick != 0u && serverTick.IsValid) @@ -179,13 +203,7 @@ namespace ProjectM.Client } if (a.HasParameter(isDashing)) a.SetParameterValue(isDashing, dashActive); if (a.HasParameter(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step); - bool cone = false; - if (abilityDb.IsCreated) - { - ref var adb = ref abilityDb.Value; - if (adb.TryGetAbility(abilityRef.Id, out var adef)) cone = adef.Archetype == (byte)AbilityArchetype.Cone; - } - if (a.HasParameter(isCone)) a.SetParameterValue(isCone, cone); + if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneL); } } @@ -212,9 +230,9 @@ namespace ProjectM.Client in PlayerFacing facing, in EffectiveCharacterStats stats, in MeleeCombo melee, - in AbilityCooldown fireCooldown, - in EffectiveAbilityStats abilityStats, - in AbilityRef abilityRef, + in SocketCooldown socketCd, + [ReadOnly] DynamicBuffer sockets, + [ReadOnly] DynamicBuffer effSockets, EnabledRefRO dead) { seen.Add(e); @@ -228,17 +246,11 @@ namespace ProjectM.Client float3 p = AnimParamMath.LocomotionParams(vel, facing.Direction, stats.MoveSpeed); Write(ref a, p, dead.ValueRO, moveX, moveZ, speed, isDead); if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, SwingActive(melee, serverTick, attackTicks)); - if (a.HasParameter(isFiring)) a.SetParameterValue(isFiring, - FireActive(fireCooldown.NextFireTick, abilityStats.CooldownTicks, serverTick, attackTicks)); + SocketFireAndCone(socketCd, sockets, effSockets, abilityDb, serverTick, attackTicks, out bool firingR, out bool coneR); + if (a.HasParameter(isFiring)) a.SetParameterValue(isFiring, firingR); if (a.HasParameter(isDashing)) a.SetParameterValue(isDashing, false); // DashState is not replicated to remotes if (a.HasParameter(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step); - bool cone = false; - if (abilityDb.IsCreated) - { - ref var adb = ref abilityDb.Value; - if (adb.TryGetAbility(abilityRef.Id, out var adef)) cone = adef.Archetype == (byte)AbilityArchetype.Cone; - } - if (a.HasParameter(isCone)) a.SetParameterValue(isCone, cone); + if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneR); } }