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
@@ -44,7 +44,7 @@ namespace ProjectM.Tests
em.AddComponentData(e, new DashState());
em.AddComponentData(e, new DashCooldown { NextTick = 0 });
em.AddComponentData(e, new CharacterControl { MoveVelocity = float3.zero });
em.AddComponentData(e, CharacterComponent.GetDefault()); // GroundedMovementSharpness = 15
em.AddComponentData(e, CharacterComponent.GetDefault()); // GroundedMovementSharpness = DefaultGroundedSharpness
em.AddComponentData(e, new PlayerInput());
em.AddComponentData(e, new PlayerFacing { Direction = facing });
em.AddComponent<Simulate>(e); // enabled by default
@@ -91,7 +91,7 @@ namespace ProjectM.Tests
Assert.AreEqual(0f, math.length(em.GetComponentData<CharacterControl>(e).MoveVelocity), 1e-3f,
"Recovery tail locks movement to zero (the punishable window).");
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
Assert.AreEqual(CharacterComponent.DefaultGroundedSharpness, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
"Recovery tail restores sharpness to the default (crisp stop).");
}
}
@@ -110,7 +110,7 @@ namespace ProjectM.Tests
group.Update(); // tick 200: window fully elapsed
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
Assert.AreEqual(CharacterComponent.DefaultGroundedSharpness, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
"Sharpness restored to default after the dash window elapses.");
Assert.AreEqual(7f, em.GetComponentData<CharacterControl>(e).MoveVelocity.x, 1e-3f,
"Outside the window DashSystem does NOT touch MoveVelocity (PlayerControlSystem's input stands).");
@@ -204,7 +204,7 @@ namespace ProjectM.Tests
Assert.IsTrue(em.IsComponentEnabled<Dead>(e), "Health<=0 derives Dead enabled.");
Assert.AreEqual(0u, em.GetComponentData<DashState>(e).IFrameUntilTick, "Death clears the dash window (no stale i-frames on respawn).");
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f, "Death restores base sharpness.");
Assert.AreEqual(CharacterComponent.DefaultGroundedSharpness, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f, "Death restores base sharpness.");
Assert.AreEqual(0f, math.length(em.GetComponentData<CharacterControl>(e).MoveVelocity), 1e-3f, "Death zeroes movement.");
}
}
@@ -227,7 +227,7 @@ namespace ProjectM.Tests
Assert.AreEqual(5f, em.GetComponentData<CharacterControl>(e).MoveVelocity.x, 1e-3f,
"A re-simulated PRE-dash tick keeps PlayerControl's input velocity (no dash override, no recovery lock).");
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
Assert.AreEqual(CharacterComponent.DefaultGroundedSharpness, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
"A re-simulated PRE-dash tick keeps base sharpness.");
}
}
@@ -0,0 +1,115 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Mathematics;
namespace ProjectM.Tests
{
/// <summary>
/// Pure-math coverage for the Shape-of-Dreams facing model (07-15): ResolveAim (the shared gameplay
/// fire-direction resolver), SelectTarget (the Aim→Move→hold cascade — castActive grants Aim PRIORITY,
/// never bypasses the cascade), and RotateToward (the rate-limited turn extracted verbatim from
/// PlayerAimSystem). Plain NUnit, no World.
/// </summary>
public class FacingMathTests
{
const float Eps = 1e-4f;
// ---- ResolveAim ----
[Test]
public void ResolveAim_Aim_Wins_And_Normalizes()
{
var d = FacingMath.ResolveAim(new float2(3f, 0f), new float2(0f, 1f));
Assert.AreEqual(1f, d.x, Eps);
Assert.AreEqual(0f, d.y, Eps);
}
[Test]
public void ResolveAim_Zero_Aim_Falls_Back_To_Facing()
{
var d = FacingMath.ResolveAim(float2.zero, new float2(0f, -2f));
Assert.AreEqual(0f, d.x, Eps);
Assert.AreEqual(-1f, d.y, Eps);
}
[Test]
public void ResolveAim_Both_Zero_Falls_Back_To_PlusZ()
{
var d = FacingMath.ResolveAim(float2.zero, float2.zero);
Assert.AreEqual(0f, d.x, Eps);
Assert.AreEqual(1f, d.y, Eps);
}
// ---- SelectTarget (the cascade) ----
[Test]
public void SelectTarget_CastActive_Prefers_Aim_Over_Move()
{
Assert.IsTrue(FacingMath.SelectTarget(true, new float2(1f, 0f), new float2(0f, 1f), out var t));
Assert.AreEqual(1f, t.x, Eps);
}
[Test]
public void SelectTarget_CastActive_Zero_Aim_Falls_Through_To_Move()
{
// Gamepad resting right stick mid-cast: Aim is exact zero -> the cascade must fall to Move,
// never freeze facing (the review's should-fix on the cast branch).
Assert.IsTrue(FacingMath.SelectTarget(true, float2.zero, new float2(0f, 1f), out var t));
Assert.AreEqual(1f, t.y, Eps);
}
[Test]
public void SelectTarget_Not_Casting_Ignores_Aim()
{
// The cursor is never passively tracked: outside a cast window Aim must NOT win.
Assert.IsTrue(FacingMath.SelectTarget(false, new float2(1f, 0f), new float2(0f, 1f), out var t));
Assert.AreEqual(1f, t.y, Eps);
Assert.AreEqual(0f, t.x, Eps);
}
[Test]
public void SelectTarget_No_Input_Holds()
{
Assert.IsFalse(FacingMath.SelectTarget(false, float2.zero, float2.zero, out _));
Assert.IsFalse(FacingMath.SelectTarget(true, float2.zero, float2.zero, out _));
}
// ---- RotateToward ----
[Test]
public void RotateToward_Uninitialized_Snaps()
{
var d = FacingMath.RotateToward(float2.zero, new float2(1f, 0f), 0.01f);
Assert.AreEqual(1f, d.x, Eps);
}
[Test]
public void RotateToward_Within_Reach_Snaps_To_Target()
{
var d = FacingMath.RotateToward(new float2(0f, 1f), new float2(0f, 1f), 0.1f);
Assert.AreEqual(0f, d.x, Eps);
Assert.AreEqual(1f, d.y, Eps);
}
[Test]
public void RotateToward_Steps_By_MaxStep_Toward_Target()
{
// 90° to cover, 30° step -> lands at 60° remaining (rotated 30° toward +X, the clockwise side).
float step = math.radians(30f);
var d = FacingMath.RotateToward(new float2(0f, 1f), new float2(1f, 0f), step);
float remaining = math.degrees(math.acos(math.clamp(math.dot(d, new float2(1f, 0f)), -1f, 1f)));
Assert.AreEqual(60f, remaining, 0.05f);
Assert.Greater(d.x, 0f, "rotates toward the target side");
}
[Test]
public void RotateToward_Turns_The_Short_Way_Both_Sides()
{
float step = math.radians(10f);
var right = FacingMath.RotateToward(new float2(0f, 1f), new float2(1f, 0.001f), step);
var left = FacingMath.RotateToward(new float2(0f, 1f), new float2(-1f, 0.001f), step);
Assert.Greater(right.x, 0f);
Assert.Less(left.x, 0f);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: df3dffe03fe2912418e03469505c774b
@@ -55,6 +55,10 @@ namespace ProjectM.Tests
Add<StatRecomputeSystem>(); Add<MeleeComboSystem>(); Add<DashSystem>(); Add<DashTrailDamageSystem>();
Add<AbilityFireSystem>(); Add<ProjectileMoveSystem>(); Add<ProjectileDamageSystem>();
Add<HealthApplyDamageSystem>(); Add<KillRewardSystem>();
// 07-15 facing rework: PlayerAimSystem gained [UpdateAfter(MeleeComboSystem)] (plus the existing
// StatRecompute/PlayerDeathState UpdateBefore edges) - co-register the full facing neighborhood so a
// cycle in these edges throws here instead of only at Play world-creation.
Add<PlayerAimSystem>(); Add<PlayerControlSystem>(); Add<BlinkSystem>(); Add<PlayerDeathStateSystem>();
Assert.DoesNotThrow(() => group.SortSystems(),
"A cycle in the Phase 1.7 predicted combat chain throws here instead of only at Play world-creation.");
@@ -0,0 +1,156 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Coverage for the shared tick-window predicates (extracted 07-15 from PlayerAnimationDriveSystem so the
/// predicted facing path and the anim path share one implementation): swing/fire window boundaries
/// (wrap-safe NetworkTick math, 0-sentinel guards) and the Movement-archetype skip (a blink is a dodge,
/// never a cast — and never a phantom IsFiring at its cooldown tail).
/// </summary>
public class TickWindowMathTests
{
const uint N = 13; // window ticks (the anim pulse length; callers pass their own)
// ---- SwingActive ----
[Test]
public void SwingActive_Window_Is_HalfOpen_From_Start()
{
var mc = new MeleeCombo { SwingStartTick = 100u };
Assert.IsFalse(TickWindowMath.SwingActive(mc, new NetworkTick(99u), N), "before start");
Assert.IsTrue(TickWindowMath.SwingActive(mc, new NetworkTick(100u), N), "start tick included");
Assert.IsTrue(TickWindowMath.SwingActive(mc, new NetworkTick(112u), N), "last tick included");
Assert.IsFalse(TickWindowMath.SwingActive(mc, new NetworkTick(113u), N), "end excluded");
}
[Test]
public void SwingActive_Zero_Stamp_Or_Invalid_Tick_Is_False()
{
Assert.IsFalse(TickWindowMath.SwingActive(default, new NetworkTick(100u), N));
Assert.IsFalse(TickWindowMath.SwingActive(new MeleeCombo { SwingStartTick = 100u }, default, N));
}
// ---- FireActive ----
[Test]
public void FireActive_Reconstructs_The_Window_From_The_Cooldown_Stamp()
{
// NextFireTick 120, CooldownTicks 20 -> fired at 100 -> window [100, 113).
Assert.IsFalse(TickWindowMath.FireActive(120u, 20, new NetworkTick(99u), N));
Assert.IsTrue(TickWindowMath.FireActive(120u, 20, new NetworkTick(100u), N));
Assert.IsTrue(TickWindowMath.FireActive(120u, 20, new NetworkTick(112u), N));
Assert.IsFalse(TickWindowMath.FireActive(120u, 20, new NetworkTick(113u), N));
}
[Test]
public void FireActive_Sentinels_Are_False()
{
Assert.IsFalse(TickWindowMath.FireActive(0u, 20, new NetworkTick(100u), N), "no stamp");
Assert.IsFalse(TickWindowMath.FireActive(120u, 0, new NetworkTick(100u), N), "no cooldown");
Assert.IsFalse(TickWindowMath.FireActive(120u, 20, default, N), "invalid now");
}
[Test]
public void FireActive_Window_Start_Wrapping_To_Zero_Coerces_Through_NonZero()
{
// The project's canonical tick-wrap hazard: a computed start of exactly 0 (the "invalid" sentinel)
// must coerce through TickUtil.NonZero to 1, not read as no-window. NextFireTick 20, CooldownTicks 20
// -> raw start 0 -> window [1, 14).
Assert.IsTrue(TickWindowMath.FireActive(20u, 20, new NetworkTick(5u), N));
Assert.IsTrue(TickWindowMath.FireActive(20u, 20, new NetworkTick(13u), N));
Assert.IsFalse(TickWindowMath.FireActive(20u, 20, new NetworkTick(14u), N), "end excluded");
}
[Test]
public void Missing_AbilityDb_Falls_Back_To_Include_All()
{
// Without a blob the archetype is unknown: the socket is INCLUDED (matches the old include-all
// behavior) — even a Movement spark counts, because it cannot be identified as one.
using var world = new World("TickWindowTestNoDb");
var em = world.EntityManager;
var e = em.CreateEntity();
em.AddBuffer<AbilitySocket>(e);
em.AddBuffer<EffectiveSocketStats>(e);
var sockets = em.GetBuffer<AbilitySocket>(e);
sockets.Add(new AbilitySocket { SparkId = 2 });
for (int i = 1; i < SocketId.Count; i++) sockets.Add(default);
var effs = em.GetBuffer<EffectiveSocketStats>(e);
effs.Add(new EffectiveSocketStats { CooldownTicks = 20 });
for (int i = 1; i < SocketId.Count; i++) effs.Add(default);
var cd = default(SocketCooldown);
cd.Set(0, 120u);
TickWindowMath.SocketFireAndCone(cd, em.GetBuffer<AbilitySocket>(e), em.GetBuffer<EffectiveSocketStats>(e),
default, new NetworkTick(105u), N, out bool firing, out bool cone);
Assert.IsTrue(firing);
Assert.IsFalse(cone);
}
// ---- SocketFireAndCone (Movement skip) ----
static BlobAssetReference<AbilityDatabaseBlob> BuildDb()
{
using var builder = new BlobBuilder(Allocator.Temp);
ref var root = ref builder.ConstructRoot<AbilityDatabaseBlob>();
var abilities = builder.Allocate(ref root.Abilities, 3);
abilities[0] = new AbilityDefBlob { Id = 1, Archetype = (byte)AbilityArchetype.Projectile, CooldownTicks = 20, Name = "Proj" };
abilities[1] = new AbilityDefBlob { Id = 2, Archetype = (byte)AbilityArchetype.Movement, CooldownTicks = 20, Name = "Blink" };
abilities[2] = new AbilityDefBlob { Id = 3, Archetype = (byte)AbilityArchetype.Cone, CooldownTicks = 20, Name = "Cone" };
builder.Allocate(ref root.Characters, 0);
return builder.CreateBlobAssetReference<AbilityDatabaseBlob>(Allocator.Persistent);
}
static void RunSocketCase(byte sparkId, out bool firing, out bool cone)
{
using var world = new World("TickWindowTest");
var em = world.EntityManager;
var e = em.CreateEntity();
em.AddBuffer<AbilitySocket>(e);
em.AddBuffer<EffectiveSocketStats>(e);
var sockets = em.GetBuffer<AbilitySocket>(e);
sockets.Add(new AbilitySocket { SparkId = sparkId });
for (int i = 1; i < SocketId.Count; i++) sockets.Add(default);
var effs = em.GetBuffer<EffectiveSocketStats>(e);
effs.Add(new EffectiveSocketStats { CooldownTicks = 20 });
for (int i = 1; i < SocketId.Count; i++) effs.Add(default);
var db = BuildDb();
try
{
var cd = default(SocketCooldown);
cd.Set(0, 120u); // fired at 100 (CooldownTicks 20) -> window [100, 113)
TickWindowMath.SocketFireAndCone(cd, em.GetBuffer<AbilitySocket>(e), em.GetBuffer<EffectiveSocketStats>(e),
db, new NetworkTick(105u), N, out firing, out cone);
}
finally { db.Dispose(); }
}
[Test]
public void Movement_Socket_Never_Counts_As_Firing()
{
RunSocketCase(2, out bool firing, out bool cone);
Assert.IsFalse(firing, "a blink (Movement archetype) is a dodge, never a cast/IsFiring pulse");
Assert.IsFalse(cone);
}
[Test]
public void Projectile_Socket_Mid_Window_Fires_Without_Cone()
{
RunSocketCase(1, out bool firing, out bool cone);
Assert.IsTrue(firing);
Assert.IsFalse(cone);
}
[Test]
public void Cone_Socket_Mid_Window_Fires_With_Cone()
{
RunSocketCase(3, out bool firing, out bool cone);
Assert.IsTrue(firing);
Assert.IsTrue(cone);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 09e0ffd7ff3700143ada83fd776310d3
@@ -84,6 +84,36 @@ namespace ProjectM.Tests
Assert.AreEqual(0f, c.ChargerLungeSpeed, 1e-6f, "ChargerLungeSpeed floors at 0");
}
[Test]
public void FeelOverride_Knobs_Default_To_Zero_And_Allow_The_NoOverride_Sentinel()
{
// 07-15 facing/underwater feel: TurnRateDeg/CastTurnRateDeg/MoveSharpness use 0 = NO-OVERRIDE
// (consumers substitute the authored stat / cast const / sharpness const). They must default to 0,
// clamp negatives to 0, and round-trip 0 through Apply (the default >=1 tick-branch would silently
// floor them to 1 and force a permanent 1-unit override).
var c = TuningConfig.Defaults();
Assert.AreEqual(0f, c.TurnRateDeg, 1e-6f);
Assert.AreEqual(0f, c.CastTurnRateDeg, 1e-6f);
Assert.AreEqual(0f, c.MoveSharpness, 1e-6f);
TuningConfig.Apply(ref c, TuningKnob.TurnRateDeg, -5f);
Assert.AreEqual(0f, c.TurnRateDeg, 1e-6f, "negatives clamp to the sentinel, not 1");
TuningConfig.Apply(ref c, TuningKnob.CastTurnRateDeg, 720f);
Assert.AreEqual(720f, c.CastTurnRateDeg, 1e-6f);
TuningConfig.Apply(ref c, TuningKnob.CastTurnRateDeg, 0f);
Assert.AreEqual(0f, c.CastTurnRateDeg, 1e-6f, "0 must survive Apply (back to no-override)");
TuningConfig.Apply(ref c, TuningKnob.MoveSharpness, 9f);
Assert.AreEqual(9f, TuningConfig.Get(c, TuningKnob.MoveSharpness), 1e-6f);
// The new fields ride the report like every other knob (a missed FromReport field would zero them).
c.TurnRateDeg = 360f; c.CastTurnRateDeg = 1080f; c.MoveSharpness = 6f;
var r = TuningConfig.FromReport(TuningConfig.ToReport(c));
Assert.AreEqual(360f, r.TurnRateDeg, 1e-6f);
Assert.AreEqual(1080f, r.CastTurnRateDeg, 1e-6f);
Assert.AreEqual(6f, r.MoveSharpness, 1e-6f);
}
[Test]
public void Apply_Ignores_An_Out_Of_Range_Knob_Index()
{