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
@@ -61,6 +61,10 @@ namespace ProjectM.Simulation
m_SocketLookup.Update(ref state);
m_SocketCdLookup.Update(ref state);
float blinkSpeed = k_BlinkDistance / (k_BlinkWindowTicks / SimTickRate); // window>=1 -> no div-by-0
// 07-15 underwater feel: the restore target honors the MoveSharpness dev-override (0 = authored const).
var t = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults();
float baseSharpness = t.MoveSharpness > 0f ? t.MoveSharpness : DefaultSharpness;
foreach (var (blink, control, character, input, facing, dash, entity) in
SystemAPI.Query<RefRW<BlinkState>, RefRW<CharacterControl>, RefRW<CharacterComponent>,
@@ -97,8 +101,11 @@ namespace ProjectM.Simulation
bool inWindow = blink.ValueRO.UntilTick != 0u && new NetworkTick(blink.ValueRO.UntilTick).IsNewerThan(serverTick);
if (ready && !inWindow)
{
// 07-15 fork: Move → cursor Aim → last facing (stationary blink keeps going toward the cursor).
float2 mv = input.ValueRO.Move;
float2 dir = math.lengthsq(mv) > 1e-4f ? mv : facing.ValueRO.Direction;
float2 dir = math.lengthsq(mv) > 1e-4f ? mv
: math.lengthsq(input.ValueRO.Aim) > 1e-6f ? input.ValueRO.Aim
: facing.ValueRO.Direction;
if (math.lengthsq(dir) < 1e-6f) dir = new float2(0f, 1f);
dir = math.normalize(dir);
blink.ValueRW.Dir = dir;
@@ -124,7 +131,7 @@ namespace ProjectM.Simulation
else if (!dashActive && character.ValueRO.GroundedMovementSharpness == k_BlinkSharpness)
{
// restore only what WE raised (dash owns its own restore); never stomp an active dash.
character.ValueRW.GroundedMovementSharpness = DefaultSharpness;
character.ValueRW.GroundedMovementSharpness = baseSharpness;
}
}
}
@@ -17,8 +17,10 @@ namespace ProjectM.Simulation
public struct CharacterComponent : IComponentData
{
/// <summary>The CC's default grounded-movement smoothing sharpness — the single source for GetDefault, the
/// authoring default, DashSystem's base, and the death-state reset.</summary>
public const float DefaultGroundedSharpness = 15f;
/// authoring default, DashSystem's base, and the death-state reset. 07-15 underwater feel: 15 → 6 (slow
/// build-up + glidey stop — the seabed drag read); Player.prefab's serialized value must match (serialized
/// wins over this initializer at bake). Dev-override: TuningKnob.MoveSharpness (0 = this const).</summary>
public const float DefaultGroundedSharpness = 6f;
/// <summary>How quickly RelativeVelocity is lerped toward the target velocity on the ground.</summary>
public float GroundedMovementSharpness;
@@ -48,6 +48,9 @@ namespace ProjectM.Simulation
uint cooldownTicks = (uint)math.max(1f, t.DashCooldownTicks);
float dashSpeed = t.DashDistance / (iFrameTicks / SimTickRate); // iFrameTicks>=1 -> never div-by-0 (review F1)
float dashSharpness = t.DashSharpness;
// 07-15 underwater feel: the restore target honors the MoveSharpness dev-override (0 = authored const).
float baseSharpness = t.MoveSharpness > 0f ? t.MoveSharpness : DefaultSharpness;
foreach (var (ds, cd, control, character, input, facing) in
SystemAPI.Query<RefRW<DashState>, RefRW<DashCooldown>, RefRW<CharacterControl>,
@@ -61,10 +64,13 @@ namespace ProjectM.Simulation
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick);
if (input.ValueRO.Dash.IsSet && ready && !inWindow)
{
// C1: dash toward MOVEMENT input when moving (a panic 'dash away' while aiming at the threat now
// works), else toward facing (a stationary aim-and-dash). Pure fn of replicated input -> idempotent.
// C1 + 07-15 fork: dash toward MOVEMENT input when moving (a panic 'dash away' still works); else
// toward the CURSOR aim (stationary aim-and-dash keeps today's KBM feel under move-facing —
// operator fork 07-15); else last facing (resting gamepad stick). Pure replicated input -> idempotent.
float2 mv = input.ValueRO.Move;
float2 dir = math.lengthsq(mv) > 1e-4f ? mv : facing.ValueRO.Direction;
float2 dir = math.lengthsq(mv) > 1e-4f ? mv
: math.lengthsq(input.ValueRO.Aim) > 1e-6f ? input.ValueRO.Aim
: facing.ValueRO.Direction;
if (math.lengthsq(dir) < 1e-6f) dir = new float2(0f, 1f);
dir = math.normalize(dir);
ds.ValueRW.Dir = dir;
@@ -94,12 +100,12 @@ namespace ProjectM.Simulation
else if (recoverActive)
{
control.ValueRW.MoveVelocity = float3.zero; // movement locked during the punishable tail
character.ValueRW.GroundedMovementSharpness = DefaultSharpness;
character.ValueRW.GroundedMovementSharpness = baseSharpness;
}
else
{
if (character.ValueRO.GroundedMovementSharpness != DefaultSharpness)
character.ValueRW.GroundedMovementSharpness = DefaultSharpness; // restore after the dash
if (character.ValueRO.GroundedMovementSharpness != baseSharpness)
character.ValueRW.GroundedMovementSharpness = baseSharpness; // restore after the dash
// Window-close edge: score a wasted dash (negated nothing) ONCE, then clear the window.
// SERVER-only — the DevTelemetry singleton exists only in the (editor) server world; the
@@ -160,8 +160,9 @@ namespace ProjectM.Simulation
bool hasMods = m_StatModLookup.HasBuffer(entity);
float pDamage = math.max(0f, hasMods ? StatMath.Apply(baseDamage, StatTarget.MeleeDamage, m_StatModLookup[entity]) : baseDamage);
float pRange = math.max(0f, hasMods ? StatMath.Apply(baseRange, StatTarget.MeleeRange, m_StatModLookup[entity]) : baseRange);
float2 face = facing.ValueRO.Direction;
face = math.lengthsq(face) < 1e-6f ? new float2(0f, 1f) : math.normalize(face);
// Manual-aim cleave (07-15): the cone follows the CURRENT tick's replicated Aim (SoD skillshot
// grammar) — PlayerFacing is body-yaw only; the facing fallback covers a resting gamepad stick.
float2 face = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
cleaves.Add(new PendingCleave
{
From = xform.ValueRO.Position,
@@ -7,62 +7,66 @@ using Unity.Transforms;
namespace ProjectM.Simulation
{
/// <summary>
/// Predicted aim/facing: writes <see cref="PlayerFacing"/> from twin-stick Aim, falling back to
/// the movement direction when Aim is zero (controller-first directional aim). Also turns the
/// ghost transform toward the facing direction for top-down presentation. When there is no input
/// this tick the previous facing is held. Deterministic (pure math); filtered to
/// <see cref="Simulate"/> so it runs only for predicted ghosts.
/// Predicted body facing — the Shape-of-Dreams model (07-15): while MOVING the body turns toward the
/// movement direction; during a CAST window (melee swing, any non-Movement socket fire window) it turns
/// toward the aim at a snappier rate; idle holds the last facing. The cursor is never passively tracked.
/// PlayerFacing is presentation/body-yaw ONLY — every gameplay fire direction reads
/// FacingMath.ResolveAim(PlayerInput.Aim, facing) at its own site (AbilityFireSystem, MeleeComboSystem).
/// The rate-limited turn is an incremental integrator over the snapshot-restored [GhostField]
/// PlayerFacing — NO IsFirstTimeFullyPredictingTick guard; it must re-integrate on EVERY predicted pass
/// so rollback re-simulation converges (gating it freezes facing at the rollback-tick value and diverges
/// from the server). Partial-tick writes are discarded by the prediction restore before the next full
/// tick. [UpdateAfter(MeleeComboSystem)] is a hygiene pin (sorter tie-breaks are deterministic and
/// cross-world-identical) so the melee cast window opens the same tick the swing starts; socket windows
/// open 1 tick late by construction (AbilityFireSystem stamps AFTER this system) — cosmetic-only.
/// Turn rates: locomotion = EffectiveCharacterStats.TurnRateRadiansPerSec; cast = a const — both
/// dev-overridable via TuningKnob.TurnRateDeg / CastTurnRateDeg (0 = no override; Defaults() fallback
/// keeps release worlds server==client). Deterministic (pure math, fixed-step dt); Simulate-filtered.
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(MeleeComboSystem))]
[BurstCompile]
public partial struct PlayerAimSystem : ISystem
{
/// <summary>Cast-window turn rate (rad/s): snappy turn toward the aim mid-swing/fire (1080 deg/s).</summary>
public const float DefaultCastTurnRateRadiansPerSec = 18.8495559f;
/// <summary>Ticks facing stays aimed after a swing/fire starts (~0.22s @60Hz — matches the anim pulse
/// k_AttackAnimTicks so body yaw and the swing animation agree).</summary>
public const uint CastFacingTicks = 13;
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
float dt = SystemAPI.Time.DeltaTime;
foreach (var (facing, transform, input, stats) in
SystemAPI.Query<RefRW<PlayerFacing>, RefRW<LocalTransform>, RefRO<PlayerInput>, RefRO<EffectiveCharacterStats>>()
.WithAll<Simulate>().WithDisabled<Dead>())
NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt) ? nt.ServerTick : default;
var abilityDb = SystemAPI.TryGetSingleton<AbilityDatabase>(out var adbSingleton) ? adbSingleton.Value : default;
var tc = SystemAPI.TryGetSingleton<TuningConfig>(out var tcs) ? tcs : TuningConfig.Defaults();
var effSocketLookup = SystemAPI.GetBufferLookup<EffectiveSocketStats>(true);
foreach (var (facing, transform, input, stats, melee, socketCd, sockets, entity) in
SystemAPI.Query<RefRW<PlayerFacing>, RefRW<LocalTransform>, RefRO<PlayerInput>,
RefRO<EffectiveCharacterStats>, RefRO<MeleeCombo>, RefRO<SocketCooldown>,
DynamicBuffer<AbilitySocket>>()
.WithAll<Simulate>().WithDisabled<Dead>().WithEntityAccess())
{
float2 aim = input.ValueRO.Aim;
if (math.lengthsq(aim) < 1e-6f)
aim = input.ValueRO.Move; // fall back to movement heading
if (math.lengthsq(aim) < 1e-6f)
continue; // no input this tick: keep last facing
// Cast window: melee swing first (cheap), else any non-Movement socket fire window
// (TickWindowMath skips Movement sockets — a blink is a dodge, never a cast).
bool castActive = TickWindowMath.SwingActive(melee.ValueRO, serverTick, CastFacingTicks);
if (!castActive && effSocketLookup.HasBuffer(entity))
TickWindowMath.SocketFireAndCone(socketCd.ValueRO, sockets, effSocketLookup[entity],
abilityDb, serverTick, CastFacingTicks, out castActive, out _);
aim = math.normalize(aim);
if (!FacingMath.SelectTarget(castActive, input.ValueRO.Aim, input.ValueRO.Move, out float2 target))
continue; // no target this tick: keep last facing
// Rate-limited turn: rotate the current facing toward the aim target by at most
// TurnRateRadiansPerSec * dt this tick. Deterministic (pure planar math, fixed-step dt)
// so it replays correctly on rollback; the first tick (uninitialized facing) snaps.
float2 cur = facing.ValueRO.Direction;
float2 dir;
if (math.lengthsq(cur) < 1e-6f)
{
dir = aim; // uninitialized facing -> snap to target
}
else
{
cur = math.normalize(cur);
float maxStep = stats.ValueRO.TurnRateRadiansPerSec * dt;
float angle = math.acos(math.clamp(math.dot(cur, aim), -1f, 1f));
if (angle <= maxStep)
{
dir = aim; // within reach this tick
}
else
{
float sign = (cur.x * aim.y - cur.y * aim.x) >= 0f ? 1f : -1f;
math.sincos(maxStep * sign, out float sn, out float cs);
dir = math.normalize(new float2(cur.x * cs - cur.y * sn, cur.x * sn + cur.y * cs));
}
}
float rate = castActive
? (tc.CastTurnRateDeg > 0f ? math.radians(tc.CastTurnRateDeg) : DefaultCastTurnRateRadiansPerSec)
: (tc.TurnRateDeg > 0f ? math.radians(tc.TurnRateDeg) : stats.ValueRO.TurnRateRadiansPerSec);
float2 dir = FacingMath.RotateToward(facing.ValueRO.Direction, target, rate * dt);
facing.ValueRW.Direction = dir;
float3 forward = new float3(dir.x, 0f, dir.y);
transform.ValueRW.Rotation = quaternion.LookRotationSafe(forward, math.up());
transform.ValueRW.Rotation = quaternion.LookRotationSafe(new float3(dir.x, 0f, dir.y), math.up());
}
}
}
@@ -48,7 +48,9 @@ namespace ProjectM.Simulation
if (SystemAPI.HasComponent<CharacterComponent>(entity))
{
var cc = SystemAPI.GetComponent<CharacterComponent>(entity);
cc.GroundedMovementSharpness = CharacterComponent.DefaultGroundedSharpness;
// 07-15: honor the MoveSharpness dev-override (0 = authored const) like the dash/blink restores.
var tdc = SystemAPI.TryGetSingleton<TuningConfig>(out var tdcv) ? tdcv : TuningConfig.Defaults();
cc.GroundedMovementSharpness = tdc.MoveSharpness > 0f ? tdc.MoveSharpness : CharacterComponent.DefaultGroundedSharpness;
SystemAPI.SetComponent(entity, cc);
}
}