LANTERN feel pass (DR-052 + gap list): SoD facing, underwater feel, Bathynaut kit, walk/run gait, suit lamp + new Synty anim packs

- SoD facing: PlayerFacing = body-yaw only (move-facing / cast-turn / idle-hold); every fire
  direction re-sourced to FacingMath.ResolveAim (pre-code review blocking catch); TickWindowMath
  shared windows with Movement-skip; reticle/FX coupled to the damage direction; cursor-dash kept.
- Underwater feel: sharpness 15->6, turn 720->360, MoveSpeed 6->4.2; TuningKnob 26-28
  (0 = no-override sentinels, dev-protocol bump on DebugTuningReport); stride footsteps + silt +
  cadence floor; bubbles; underwater ambience bed + distant groans; camera drag + dev scroll zoom.
- Bathynaut kit in-engine: dome/tank/shoulder-lamp + bare head grafted (GraftSmr rigid rebase,
  RecalculateTangents); EmissiveGloamSkinned shader (Rukhanka deformation); shoulder lamp CASTS
  (warm steady spot on body yaw).
- Gait: two-ring walk/run FreeformDirectional tree (walk @0.35, run @1.0) + blended-natural
  StrideScale; additive Posture(Bank) + Lead(chest-lead) layers; idle = AnimationIdles Base;
  banking driven from facing turn rate; flat terrain (Env_SeabedKit seabed squashed - CC is planar).
- New packs: Synty AnimationIdles + AnimationSwordCombat (combat pass queued) + SyntyPropBoneTool;
  four authored clips (sway/trudge/banks/lean) + Anim_Player_Underwater.blend + suit-kit FBX.
- Validation: 411/411 EditMode green; Play smokes (server==client facing, Aim-true projectile,
  bank/stride live-sampled, lamp beam verified); pre-code + post-impl adversarial reviews applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 13:56:02 -07:00
parent 385d0de08e
commit 7571091394
2637 changed files with 1810753 additions and 303 deletions
@@ -46,17 +46,26 @@ namespace ProjectM.Client
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_Bank = new FastAnimatorParameter("Bank");
static readonly FastAnimatorParameter k_StrideScale = new FastAnimatorParameter("StrideScale"); // 07-16b: Locomotion playback = planarSpeed / TrudgeNaturalSpeed (foot-skate fix) // 07-16: additive Posture layer — signed facing turn rate, local player only (subtle; not derived for remotes)
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;
const uint k_AttackAnimTicks = PlayerAimSystem.CastFacingTicks; // structurally tied (07-16 review): body-yaw cast-turn and the swing anim pulse must agree
// Remote prevPos cache (per ghost Entity). Pruned every frame (a vanished remote = a despawn).
NativeParallelHashMap<Entity, float3> _prevPos;
// 07-16 banking: previous local-player facing + the smoothed bank value (main-thread state; one entity).
float2 _prevLocalFacing;
bool _bankInit;
float _bankSmoothed;
protected override void OnCreate()
{
_prevPos = new NativeParallelHashMap<Entity, float3>(16, Allocator.Persistent);
@@ -77,13 +86,37 @@ namespace ProjectM.Client
// 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) ---
// 07-16 banking: signed facing turn rate -> Bank (-1..1, + = bank right, leaning INTO the turn).
// Sampled main-thread (a single local player), smoothed so the additive pose eases in and out.
float bankTarget = 0f;
foreach (var facingRO in SystemAPI.Query<RefRO<PlayerFacing>>().WithAll<GhostOwnerIsLocal>())
{
float2 f = facingRO.ValueRO.Direction;
if (_bankInit && math.lengthsq(f) > 1e-6f && math.lengthsq(_prevLocalFacing) > 1e-6f)
{
float2 a2 = math.normalize(_prevLocalFacing);
float2 b2 = math.normalize(f);
float signed = math.atan2(a2.x * b2.y - a2.y * b2.x, math.clamp(math.dot(a2, b2), -1f, 1f));
float degPerSec = math.degrees(signed) / dt;
bankTarget = math.clamp(-degPerSec / math.max(30f, FeelConfig.BankFullTurnDegPerSec), -1f, 1f);
}
_prevLocalFacing = f;
_bankInit = true;
break;
}
_bankSmoothed = math.lerp(_bankSmoothed, bankTarget, 1f - math.exp(-6f * dt));
// --- 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,
bank = k_Bank, bankValue = _bankSmoothed,
strideScale = k_StrideScale, trudgeNaturalSpeed = math.max(0.5f, FeelConfig.TrudgeNaturalSpeed),
runNaturalSpeed = math.max(0.5f, FeelConfig.RunNaturalSpeed),
};
Dependency = localJob.ScheduleParallel(Dependency);
@@ -96,6 +129,8 @@ namespace ProjectM.Client
comboStep = k_ComboStep, isCone = k_IsCone, abilityDb = abilityBlob,
serverTick = serverTick, attackTicks = k_AttackAnimTicks,
dt = dt,
strideScale = k_StrideScale, trudgeNaturalSpeed = math.max(0.5f, FeelConfig.TrudgeNaturalSpeed),
runNaturalSpeed = math.max(0.5f, FeelConfig.RunNaturalSpeed),
prevPos = _prevPos,
seen = seen,
};
@@ -113,53 +148,9 @@ namespace ProjectM.Client
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;
}
}
}
// The swing/fire tick-window predicates (SwingActive/FireActive/SocketFireAndCone) moved to
// TickWindowMath (Simulation/Combat) 07-15 — shared verbatim with the predicted facing path
// (PlayerAimSystem); this system passes k_AttackAnimTicks at the call sites.
// LOCAL: GhostOwnerIsLocal ENABLED -> exactly the owned player. WithPresent<Dead> so alive
// (Dead-disabled) players are visited. NOTE: GhostOwnerIsLocal as a WithAll filter respects the
@@ -169,7 +160,13 @@ namespace ProjectM.Client
[WithPresent(typeof(Dead))]
partial struct LocalDriveJob : IJobEntity
{
public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking, isFiring, isDashing, comboStep, isCone;
public FastAnimatorParameter bank;
public float bankValue;
public FastAnimatorParameter strideScale;
public float trudgeNaturalSpeed;
public float runNaturalSpeed;
public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking, isFiring, isDashing, comboStep, isCone;
public BlobAssetReference<AbilityDatabaseBlob> abilityDb;
public NetworkTick serverTick;
public uint attackTicks;
@@ -190,8 +187,8 @@ namespace ProjectM.Client
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(isAttacking)) a.SetParameterValue(isAttacking, TickWindowMath.SwingActive(melee, serverTick, attackTicks));
TickWindowMath.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;
@@ -202,6 +199,10 @@ namespace ProjectM.Client
dashActive = dStart.IsValid && dEnd.IsValid && !dStart.IsNewerThan(serverTick) && dEnd.IsNewerThan(serverTick);
}
if (a.HasParameter(isDashing)) a.SetParameterValue(isDashing, dashActive);
if (a.HasParameter(bank)) a.SetParameterValue(bank, bankValue);
if (a.HasParameter(strideScale))
a.SetParameterValue(strideScale, StrideScaleValue(math.length(body.RelativeVelocity.xz), stats.MoveSpeed, trudgeNaturalSpeed, runNaturalSpeed));
if (a.HasParameter(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step);
if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneL);
}
@@ -219,6 +220,9 @@ namespace ProjectM.Client
public NetworkTick serverTick;
public uint attackTicks;
public float dt;
public FastAnimatorParameter strideScale;
public float trudgeNaturalSpeed;
public float runNaturalSpeed;
public NativeParallelHashMap<Entity, float3> prevPos;
public NativeParallelHashSet<Entity> seen;
@@ -245,16 +249,30 @@ namespace ProjectM.Client
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(isAttacking)) a.SetParameterValue(isAttacking, TickWindowMath.SwingActive(melee, serverTick, attackTicks));
TickWindowMath.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(strideScale))
a.SetParameterValue(strideScale, StrideScaleValue(math.length(vel.xz), stats.MoveSpeed, trudgeNaturalSpeed, runNaturalSpeed));
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.
// 07-16e: playback correction against the walk→run BLENDED natural — the two-ring gait tree picks
// the clip; this keeps residual cadence honest between/beyond the rings. Pure, Burst-safe.
static float StrideScaleValue(float planarSpeed, float maxSpeed, float walkNatural, float runNatural)
{
float maxSp = math.max(0.5f, maxSpeed);
float walkRing = walkNatural / maxSp;
float t = math.saturate((planarSpeed / maxSp - walkRing) / math.max(0.05f, 1f - walkRing));
float natural = math.lerp(walkNatural, runNatural, t);
return math.clamp(planarSpeed / math.max(0.3f, natural), 0.6f, 2.2f);
}
// 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)