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
@@ -85,8 +85,12 @@ namespace ProjectM.Client
AudioClip _telegraphClip;
AudioClip _dashClip;
AudioClip _swingClip;
AudioClip _meleeConnectClip, _footstepClip; // combat feel pass: connect thunk / footstep
Vector3 _lastFootPos; float _footTimer; bool _footInit; // footstep edge-detect (local player locomotion)
AudioClip _meleeConnectClip; // combat feel pass: connect thunk
readonly AudioClip[] _footstepClips = new AudioClip[3]; // 07-15: heavy underwater thud variants (random pick per step)
ParticleSystem _stepFx; // 07-15: silt puff per footstep
ParticleSystem _bubbleFx; float _bubbleTimer; // 07-16: dome bubble exhaust (+1 per footstep, loose sync)
Light _lampLight; // 07-16e: the shoulder lamp CASTS — a warm spot beam riding the body yaw
Vector3 _lastFootPos; float _footDistAccum; float _footStepGap; bool _footInit; // stride-distance footsteps (local player)
Entity _localPlayer = Entity.Null;
uint[] _lastSocketFire = new uint[SocketId.Count]; // LANTERN per-socket fire-edge cache (was _lastLocalFireTick)
@@ -110,7 +114,11 @@ namespace ProjectM.Client
_dashClip = MakeClip("dash", 950f, 240f, 0.12f, 0.50f, noise: false);
_swingClip = MakeClip("swing", 720f, 200f, 0.09f, 0.42f, noise: false);
_meleeConnectClip = MakeClip("melee_thunk", 180f, 60f, 0.13f, 0.55f, noise: true); // meaty low connect
_footstepClip = MakeClip("step", 200f, 110f, 0.06f, 0.18f, noise: true); // soft footfall
// 07-15 underwater feel: three deep muffled thud variants (noise burst, low sweep, long decay) —
// a random pick + volume jitter per step reads as weight (PlayClipAtPoint has no pitch control).
_footstepClips[0] = MakeClip("step_a", 95f, 38f, 0.13f, 0.5f, noise: true, decay: 7f);
_footstepClips[1] = MakeClip("step_b", 108f, 42f, 0.12f, 0.5f, noise: true, decay: 7f);
_footstepClips[2] = MakeClip("step_c", 120f, 45f, 0.11f, 0.5f, noise: true, decay: 7f);
}
protected override void OnStartRunning()
@@ -124,6 +132,12 @@ namespace ProjectM.Client
_muzzleFx = MakeBurst(_fxRoot, "Muzzle", mat, new Color(0.6f, 2.4f, 3.2f), 0.12f, 5f, 0.20f, 128);
_dashFx = MakeBurst(_fxRoot, "DashWhoosh", mat, new Color(0.7f, 2.6f, 3.0f), 0.16f, 4f, 0.30f, 256);
_swingFx = MakeBurst(_fxRoot, "MeleeSwing", mat, new Color(3.0f, 2.6f, 0.9f), 0.14f, 6f, 0.28f, 256);
// 07-15: silt puff per footstep — dark, slow, hangs briefly (underwater weight read; dark = no bloom).
_stepFx = MakeBurst(_fxRoot, "SiltPuff", mat, new Color(0.06f, 0.10f, 0.11f, 0.85f), 0.20f, 0.8f, 1.1f, 128, gravity: 0.03f, shapeRadius: 0.18f, sizeTail: 1.5f);
// 07-16: bubble exhaust — tiny pale spheres that RISE (negative gravity) and drift from the dome.
_bubbleFx = MakeBurst(_fxRoot, "Bubbles", mat, new Color(0.75f, 0.9f, 1.0f, 0.85f), 0.055f, 0.25f, 2.4f, 128, gravity: -0.45f, shapeRadius: 0.06f, sizeTail: 0.35f);
BuildSlash();
for (int i = 0; i < NumberPoolSize; i++)
@@ -160,6 +174,8 @@ namespace ProjectM.Client
EntityManager.CompleteDependencyBeforeRO<DashState>();
EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
EntityManager.CompleteDependencyBeforeRO<MeleeCombo>();
EntityManager.CompleteDependencyBeforeRO<PlayerInput>(); // 07-15: local FX read the fire direction
// Resolve the local player (for hit colouring + fire feedback).
_localPlayer = Entity.Null;
@@ -326,13 +342,15 @@ namespace ProjectM.Client
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))
// 07-15: the cue must match the DAMAGE direction — ResolveAim(Aim, facing), the same source the
// sim fire sites read; PlayerFacing alone is body-yaw and can be up to 180° off under move-facing.
float2 sfdir = new float2(0f, 1f);
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer) && EntityManager.HasComponent<PlayerInput>(_localPlayer))
{
var sfd = EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction;
if (math.lengthsq(sfd) > 1e-6f) sface = new Vector3(sfd.x, 0f, sfd.y).normalized;
sfdir = FacingMath.ResolveAim(
EntityManager.GetComponentData<PlayerInput>(_localPlayer).Aim,
EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction);
}
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++)
{
@@ -342,7 +360,11 @@ namespace ProjectM.Client
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;
bool haveDef = fireAdb.TryGetAbility(sid, out var sdef);
// 07-16 review: a blink (Movement archetype) stamps its cooldown row too — it's a dodge,
// not a cast; no muzzle/fire cue (mirrors TickWindowMath's Movement skip).
if (haveDef && sdef.Archetype == (byte)AbilityArchetype.Movement) continue;
bool coneSpark = haveDef && sdef.Archetype == (byte)AbilityArchetype.Cone;
if (!coneSpark)
{
Burst(_muzzleFx, cfg != null ? cfg.Muzzle : null, (Vector3)localPos + Vector3.up * 0.9f, 8);
@@ -405,11 +427,14 @@ namespace ProjectM.Client
if (_swingTickInit && mc.SwingStartTick != 0 && mc.SwingStartTick != _lastLocalSwingTick)
{
int step = math.max(1, (int)mc.Step);
// 07-15: match the cleave's DAMAGE direction (ResolveAim(Aim, facing) — same as MeleeComboSystem).
Vector3 face = Vector3.forward;
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer))
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer) && EntityManager.HasComponent<PlayerInput>(_localPlayer))
{
var d = EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction;
if (math.lengthsq(d) > 1e-6f) face = new Vector3(d.x, 0f, d.y).normalized;
var d = FacingMath.ResolveAim(
EntityManager.GetComponentData<PlayerInput>(_localPlayer).Aim,
EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction);
face = new Vector3(d.x, 0f, d.y);
}
EmitAt(_swingFx, (Vector3)localPos + Vector3.up * 0.9f + face * 0.8f, 6 + (step - 1) * 5);
PlayClip(_swingClip, (Vector3)localPos, 0.45f);
@@ -458,18 +483,86 @@ namespace ProjectM.Client
}
// Footsteps (combat feel): edge-detect local locomotion from the position delta; a soft step at a cadence.
// Shoulder-lamp beam (07-16e, operator: "make the lamp actually light up" — LANTERN's light-is-
// territory read starts on the suit). A warm STEADY spot (steady = true light) mounted at the kit
// lamp's clavicle offset, riding the BODY yaw (PlayerFacing — the lamp is bolted to the suit, so it
// sweeps with the body, not the cursor), tilted down onto the seabed ahead. Local player only.
if (_localPlayer != Entity.Null && FeelConfig.ShoulderLampIntensity > 0f)
{
if (_lampLight == null)
{
var lampGo = new GameObject("~ShoulderLamp");
lampGo.transform.SetParent(_fxRoot, false);
_lampLight = lampGo.AddComponent<Light>();
_lampLight.type = LightType.Spot;
_lampLight.color = new Color(1f, 0.78f, 0.45f); // warm gold — ours, steady
_lampLight.shadows = LightShadows.None;
_lampLight.spotAngle = 58f;
_lampLight.innerSpotAngle = 26f;
}
_lampLight.intensity = FeelConfig.ShoulderLampIntensity;
_lampLight.range = FeelConfig.ShoulderLampRange;
float2 lampFace = EntityManager.HasComponent<PlayerFacing>(_localPlayer)
? EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction
: new float2(0f, 1f);
if (math.lengthsq(lampFace) < 1e-6f) lampFace = new float2(0f, 1f);
var lampYaw = Quaternion.LookRotation(new Vector3(lampFace.x, 0f, lampFace.y));
_lampLight.transform.SetPositionAndRotation(
(Vector3)localPos + lampYaw * new Vector3(0.27f, 0.55f, 0.05f), // the kit lamp's shoulder offset
lampYaw * Quaternion.Euler(26f, 0f, 0f)); // down-tilt puts the hotspot ~3m ahead (14° landed ~6m out — too diffuse)
}
else if (_lampLight != null && _lampLight.intensity > 0f)
{
_lampLight.intensity = 0f;
}
// Bubble exhaust (07-16 gap-list): a periodic trickle from the dome (entity origin ≈ chest; dome
// sits ~0.78 up), jittered so it never reads as a metronome. One extra bubble rides each footstep.
if (_localPlayer != Entity.Null && FeelConfig.BubbleIntervalSec > 0f && FeelConfig.BubbleBurstCount > 0)
{
_bubbleTimer -= dt;
if (_bubbleTimer <= 0f)
{
_bubbleTimer = FeelConfig.BubbleIntervalSec * (0.8f + UnityEngine.Random.value * 0.4f);
EmitAt(_bubbleFx, (Vector3)localPos + Vector3.up * 0.78f, FeelConfig.BubbleBurstCount);
}
}
// Footsteps (07-15 underwater feel): stride-DISTANCE stepping — accumulate planar travel and step
// every FootstepStrideMeters, so cadence tracks the real (drifting) velocity instead of a fixed
// timer. Heavier read: deep thud variant + volume jitter + a silt puff at the feet.
if (_localPlayer != Entity.Null)
{
Vector3 lp = (Vector3)localPos;
if (_footInit)
{
float sp = dt > 1e-4f ? new Vector2(lp.x - _lastFootPos.x, lp.z - _lastFootPos.z).magnitude / dt : 0f;
_footTimer -= dt;
if (sp >= FeelConfig.FootstepMinSpeed && _footTimer <= 0f)
float planar = new Vector2(lp.x - _lastFootPos.x, lp.z - _lastFootPos.z).magnitude;
float sp = dt > 1e-4f ? planar / dt : 0f;
_footStepGap -= dt;
if (sp >= FeelConfig.FootstepMinSpeed)
{
PlayClip(_footstepClip, lp, FeelConfig.FootstepVolume);
_footTimer = FeelConfig.FootstepIntervalSec;
_footDistAccum += planar;
// 07-16 review: the seconds floor stops a dash/blink from machine-gunning 2-4 thuds in ~0.1s
// (the old fixed-interval timer capped this implicitly).
if (_footDistAccum >= FeelConfig.FootstepStrideMeters && _footStepGap <= 0f)
{
_footDistAccum = 0f;
_footStepGap = 0.18f;
float j = FeelConfig.FootstepJitter;
var stepClip = _footstepClips[UnityEngine.Random.Range(0, _footstepClips.Length)];
PlayClip(stepClip, lp, FeelConfig.FootstepVolume * (1f + UnityEngine.Random.Range(-j, j)));
if (FeelConfig.FootstepPuffCount > 0)
EmitAt(_stepFx, lp + Vector3.down * 0.85f, FeelConfig.FootstepPuffCount); // entity origin = capsule center -> feet
if (FeelConfig.BubbleBurstCount > 0)
EmitAt(_bubbleFx, lp + Vector3.up * 0.78f, 1); // exertion bubble, loosely step-synced
}
}
else
{
_footDistAccum = FeelConfig.FootstepStrideMeters * 0.6f; // primed: the first step lands quickly on move-start
}
}
_lastFootPos = lp; _footInit = true;
@@ -805,6 +898,9 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int
// renders via _slashMr; here each REMOTE player (interpolated, GhostOwnerIsLocal DISABLED) gets a pooled
// slash arc edge-detected from its replicated MeleeCombo.SwingStartTick + PlayerFacing. Observe-only client
// presentation; no sim, no new [GhostField]. Anchored to the moving teammate while it sweeps open + fades.
// NOTE (07-15 SoD facing, accepted asymmetry): remote arcs deliberately do NOT use FacingMath.ResolveAim —
// PlayerInput.Aim is owner-only (never replicated to non-owners), so body-yaw PlayerFacing is the closest
// replicated proxy; the cast-turn converges facing onto the aim within a few ticks. Do not "fix" this to Aim.
void UpdateRemoteSwings(float dt)
{
if (!FeelConfig.RemoteSwingEnabled || _fxRoot == null) return;