21f609c6c4
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>
269 lines
16 KiB
C#
269 lines
16 KiB
C#
using ProjectM.Simulation;
|
|
using Rukhanka; // FastAnimatorParameter, AnimatorParametersAspect, ParameterValue,
|
|
// AnimatorControllerParameterComponent, AnimatorControllerParameterIndexTableComponent,
|
|
// RukhankaAnimationSystemGroup
|
|
using Unity.Burst;
|
|
using Unity.Collections;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.NetCode; // GhostOwnerIsLocal, NetworkTime, NetworkTick
|
|
using Unity.Transforms; // LocalTransform
|
|
using Unity.CharacterController; // KinematicCharacterBody
|
|
|
|
namespace ProjectM.Client
|
|
{
|
|
/// <summary>
|
|
/// Client-only animation driver. OBSERVES authoritative/replicated state and writes Rukhanka animator
|
|
/// blend params; never mutates the sim (presentation-only). Runs once/frame in the client/local world,
|
|
/// BEFORE Rukhanka evaluates the controller this frame (same-frame, no 1-tick lag). NOTE: this lands in
|
|
/// SimulationSystemGroup (via UpdateBefore the Rukhanka group, which itself has no UpdateInGroup), a
|
|
/// deliberate, documented exception to the project's "all juice = PresentationSystemGroup" rule -- the
|
|
/// params MUST be set before Rukhanka's same-frame controller eval (see DR-022).
|
|
///
|
|
/// Drives MoveX/MoveZ/Speed/IsDead from locomotion + IsDead, plus IsAttacking (MC-4) pulsed from the
|
|
/// replicated <see cref="MeleeCombo"/> swing window so the AC_PlayerTopDown MeleeSwing state plays per swing.
|
|
///
|
|
/// Two paths:
|
|
/// LOCAL (owner-predicted, GhostOwnerIsLocal ENABLED): realized CC RelativeVelocity (wall-aware).
|
|
/// REMOTE (interpolated, GhostOwnerIsLocal DISABLED): KinematicCharacterBody is NOT a [GhostField] and
|
|
/// the CC processor is owner-only, so RelativeVelocity stays baked-zero on remotes -> derive
|
|
/// planar velocity from replicated LocalTransform.Position deltas. PlayerFacing.Direction is a
|
|
/// [GhostField] (valid on remotes); EffectiveCharacterStats.MoveSpeed is derived locally each
|
|
/// tick by StatRecomputeSystem (present on remotes). MeleeCombo replicates so teammates' swings
|
|
/// animate too. Cache prevPos per Entity, prune each frame.
|
|
/// </summary>
|
|
[WorldSystemFilter(WorldSystemFilterFlags.LocalSimulation | WorldSystemFilterFlags.ClientSimulation)]
|
|
[UpdateBefore(typeof(RukhankaAnimationSystemGroup))]
|
|
[RequireMatchingQueriesForUpdate]
|
|
public partial class PlayerAnimationDriveSystem : SystemBase
|
|
{
|
|
// Perfect-hash keys, built once (managed string ctor). Names MUST match AC_PlayerTopDown.controller
|
|
// parameter names exactly. Immutable readonly hashes -> domain-reload safe.
|
|
static readonly FastAnimatorParameter k_MoveX = new FastAnimatorParameter("MoveX");
|
|
static readonly FastAnimatorParameter k_MoveZ = new FastAnimatorParameter("MoveZ");
|
|
static readonly FastAnimatorParameter k_Speed = new FastAnimatorParameter("Speed");
|
|
static readonly FastAnimatorParameter k_IsDead = new FastAnimatorParameter("IsDead");
|
|
static readonly FastAnimatorParameter k_IsAttacking = new FastAnimatorParameter("IsAttacking");
|
|
static readonly FastAnimatorParameter k_IsFiring = new FastAnimatorParameter("IsFiring"); // Blender-authored A_Fire_OneHand (B7)
|
|
static readonly FastAnimatorParameter k_IsDashing = new FastAnimatorParameter("IsDashing"); // Blender-authored A_Dash_Lean (local player only - DashState is not replicated)
|
|
static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter("ComboStep"); // replicated MeleeCombo.Step -> per-step swing clips (Swing1/2/3)
|
|
static readonly FastAnimatorParameter k_IsCone = new FastAnimatorParameter("IsCone"); // ability archetype (replicated AbilityRef + blob): Cone -> two-hand slam, else the shot
|
|
|
|
// Ticks after a swing-start that IsAttacking stays true (drives the MeleeSwing state). Kept < the swing lock
|
|
// (MeleeRecoverTicks ~16) so a CHAINED swing re-pulses the bool false->true and re-triggers the Any State
|
|
// transition per hit. ~0.22s @ 60Hz. Presentation-only.
|
|
const uint k_AttackAnimTicks = 13;
|
|
|
|
// Remote prevPos cache (per ghost Entity). Pruned every frame (a vanished remote = a despawn).
|
|
NativeParallelHashMap<Entity, float3> _prevPos;
|
|
|
|
protected override void OnCreate()
|
|
{
|
|
_prevPos = new NativeParallelHashMap<Entity, float3>(16, Allocator.Persistent);
|
|
}
|
|
|
|
protected override void OnDestroy()
|
|
{
|
|
if (_prevPos.IsCreated) _prevPos.Dispose();
|
|
}
|
|
|
|
protected override void OnUpdate()
|
|
{
|
|
float dt = SystemAPI.Time.DeltaTime; // wall-frame delta is correct for presentation
|
|
if (dt < 1e-5f) dt = 1e-5f;
|
|
|
|
// Current authoritative tick for the swing-window check (default = invalid -> IsAttacking stays false).
|
|
NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt) ? nt.ServerTick : default;
|
|
// Ability blob for the per-class special read (Cone -> slam anim); default(BlobAssetReference) if absent.
|
|
var abilityBlob = SystemAPI.TryGetSingleton<AbilityDatabase>(out var adbSingleton) ? adbSingleton.Value : default;
|
|
|
|
// --- LOCAL owner (CC velocity) ---
|
|
var localJob = new LocalDriveJob
|
|
{
|
|
moveX = k_MoveX, moveZ = k_MoveZ, speed = k_Speed, isDead = k_IsDead,
|
|
isAttacking = k_IsAttacking, isFiring = k_IsFiring, isDashing = k_IsDashing,
|
|
comboStep = k_ComboStep, isCone = k_IsCone, abilityDb = abilityBlob,
|
|
serverTick = serverTick, attackTicks = k_AttackAnimTicks,
|
|
};
|
|
Dependency = localJob.ScheduleParallel(Dependency);
|
|
|
|
// --- REMOTE players (position-delta). Single-threaded write to the shared prevPos cache. ---
|
|
var seen = new NativeParallelHashSet<Entity>(16, Allocator.TempJob);
|
|
var remoteJob = new RemoteDriveJob
|
|
{
|
|
moveX = k_MoveX, moveZ = k_MoveZ, speed = k_Speed, isDead = k_IsDead,
|
|
isAttacking = k_IsAttacking, isFiring = k_IsFiring, isDashing = k_IsDashing,
|
|
comboStep = k_ComboStep, isCone = k_IsCone, abilityDb = abilityBlob,
|
|
serverTick = serverTick, attackTicks = k_AttackAnimTicks,
|
|
dt = dt,
|
|
prevPos = _prevPos,
|
|
seen = seen,
|
|
};
|
|
Dependency = remoteJob.Schedule(Dependency); // .Schedule (not parallel): mutates _prevPos
|
|
// Prune stale entries (despawned remotes) AFTER the job, on the main thread.
|
|
Dependency.Complete();
|
|
PruneCache(seen);
|
|
seen.Dispose();
|
|
}
|
|
|
|
void PruneCache(NativeParallelHashSet<Entity> seen)
|
|
{
|
|
using var keys = _prevPos.GetKeyArray(Allocator.Temp);
|
|
for (int i = 0; i < keys.Length; i++)
|
|
if (!seen.Contains(keys[i])) _prevPos.Remove(keys[i]);
|
|
}
|
|
|
|
// True while now is within [SwingStartTick, SwingStartTick + animTicks) -- a per-swing pulse that re-triggers
|
|
// on each chained swing. NetworkTick arithmetic (wrap-safe). Presentation-only, Burst-safe.
|
|
static bool SwingActive(in MeleeCombo mc, NetworkTick serverTick, uint animTicks)
|
|
{
|
|
if (mc.SwingStartTick == 0u || !serverTick.IsValid) return false;
|
|
var start = new NetworkTick(mc.SwingStartTick);
|
|
var end = new NetworkTick(TickUtil.NonZero(mc.SwingStartTick + animTicks));
|
|
return start.IsValid && end.IsValid && !start.IsNewerThan(serverTick) && end.IsNewerThan(serverTick);
|
|
}
|
|
|
|
// B7: the class FIRE ability finally plays a body animation - a stateless window derived from the
|
|
// replicated AbilityCooldown ([GhostField] NextFireTick) minus the locally-derived CooldownTicks
|
|
// (EffectiveAbilityStats recomputes identically on every world), so it works for the predicted local
|
|
// player AND interpolated remotes with no cached edges (review B7: edge-caches false-fire on
|
|
// relevancy/join/rollback). Reuses the swing clip until a dedicated fire clip exists.
|
|
static bool FireActive(uint nextFireRaw, int cooldownTicks, NetworkTick serverTick, uint animTicks)
|
|
{
|
|
if (nextFireRaw == 0u || cooldownTicks <= 0 || !serverTick.IsValid) return false;
|
|
uint startRaw = TickUtil.NonZero(nextFireRaw - (uint)cooldownTicks);
|
|
var start = new NetworkTick(startRaw);
|
|
var end = new NetworkTick(TickUtil.NonZero(startRaw + animTicks));
|
|
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<AbilitySocket> sockets,
|
|
DynamicBuffer<EffectiveSocketStats> effSockets, BlobAssetReference<AbilityDatabaseBlob> 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<Dead> 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).
|
|
[BurstCompile]
|
|
[WithAll(typeof(GhostOwnerIsLocal))]
|
|
[WithPresent(typeof(Dead))]
|
|
partial struct LocalDriveJob : IJobEntity
|
|
{
|
|
public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking, isFiring, isDashing, comboStep, isCone;
|
|
public BlobAssetReference<AbilityDatabaseBlob> abilityDb;
|
|
public NetworkTick serverTick;
|
|
public uint attackTicks;
|
|
|
|
void Execute(
|
|
AnimatorControllerParameterIndexTableComponent indexTable,
|
|
DynamicBuffer<AnimatorControllerParameterComponent> parametersArr,
|
|
in PlayerFacing facing,
|
|
in EffectiveCharacterStats stats,
|
|
in KinematicCharacterBody body,
|
|
in MeleeCombo melee,
|
|
in SocketCooldown socketCd,
|
|
[ReadOnly] DynamicBuffer<AbilitySocket> sockets,
|
|
[ReadOnly] DynamicBuffer<EffectiveSocketStats> effSockets,
|
|
in DashState dashState,
|
|
EnabledRefRO<Dead> dead)
|
|
{
|
|
var a = new AnimatorParametersAspect(parametersArr, indexTable);
|
|
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));
|
|
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)
|
|
{
|
|
var dStart = new NetworkTick(dashState.StartTick);
|
|
var dEnd = new NetworkTick(TickUtil.NonZero(dashState.IFrameUntilTick + 6u));
|
|
dashActive = dStart.IsValid && dEnd.IsValid && !dStart.IsNewerThan(serverTick) && dEnd.IsNewerThan(serverTick);
|
|
}
|
|
if (a.HasParameter(isDashing)) a.SetParameterValue(isDashing, dashActive);
|
|
if (a.HasParameter(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step);
|
|
if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneL);
|
|
}
|
|
}
|
|
|
|
// REMOTE: GhostOwnerIsLocal DISABLED -> interpolated teammates. Velocity from LocalTransform.Position
|
|
// delta (KinematicCharacterBody.RelativeVelocity is baked-zero on remotes, non-GhostField, owner-only).
|
|
[BurstCompile]
|
|
[WithDisabled(typeof(GhostOwnerIsLocal))]
|
|
[WithPresent(typeof(Dead))]
|
|
partial struct RemoteDriveJob : IJobEntity
|
|
{
|
|
public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking, isFiring, isDashing, comboStep, isCone;
|
|
public BlobAssetReference<AbilityDatabaseBlob> abilityDb;
|
|
public NetworkTick serverTick;
|
|
public uint attackTicks;
|
|
public float dt;
|
|
public NativeParallelHashMap<Entity, float3> prevPos;
|
|
public NativeParallelHashSet<Entity> seen;
|
|
|
|
void Execute(
|
|
Entity e,
|
|
AnimatorControllerParameterIndexTableComponent indexTable,
|
|
DynamicBuffer<AnimatorControllerParameterComponent> parametersArr,
|
|
in LocalTransform xform,
|
|
in PlayerFacing facing,
|
|
in EffectiveCharacterStats stats,
|
|
in MeleeCombo melee,
|
|
in SocketCooldown socketCd,
|
|
[ReadOnly] DynamicBuffer<AbilitySocket> sockets,
|
|
[ReadOnly] DynamicBuffer<EffectiveSocketStats> effSockets,
|
|
EnabledRefRO<Dead> dead)
|
|
{
|
|
seen.Add(e);
|
|
float3 cur = xform.Position;
|
|
float3 vel = float3.zero;
|
|
if (prevPos.TryGetValue(e, out var prev))
|
|
vel = (cur - prev) / dt;
|
|
prevPos[e] = cur;
|
|
|
|
var a = new AnimatorParametersAspect(parametersArr, indexTable);
|
|
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));
|
|
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);
|
|
if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneR);
|
|
}
|
|
}
|
|
|
|
// ParameterValue has implicit float/bool operators -> SetParameterValue(key, float) / (key, bool) compile.
|
|
static void Write(ref AnimatorParametersAspect a, float3 p, bool isDeadVal,
|
|
FastAnimatorParameter moveX, FastAnimatorParameter moveZ,
|
|
FastAnimatorParameter speed, FastAnimatorParameter isDead)
|
|
{
|
|
if (a.HasParameter(moveX)) a.SetParameterValue(moveX, p.x);
|
|
if (a.HasParameter(moveZ)) a.SetParameterValue(moveZ, p.y);
|
|
if (a.HasParameter(speed)) a.SetParameterValue(speed, p.z);
|
|
if (a.HasParameter(isDead)) a.SetParameterValue(isDead, isDeadVal);
|
|
}
|
|
}
|
|
}
|