Files
Project-M/Assets/_Project/Scripts/Simulation/Player/DashSystem.cs
T
kronic 7571091394 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>
2026-07-16 13:56:02 -07:00

125 lines
8.1 KiB
C#

using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-1 — the predicted dodge dash. On a fresh <see cref="PlayerInput.Dash"/> press (cooldown ready and not
/// already mid-dash) it captures the dash heading from <see cref="PlayerFacing"/> and opens a HALF-OPEN i-frame
/// window [StartTick, IFrameUntilTick) plus a recovery tail. While the i-frame window is active it OVERRIDES
/// <see cref="CharacterControl.MoveVelocity"/> with the dash velocity and raises
/// <see cref="CharacterComponent.GroundedMovementSharpness"/> to ~200 so the move reads as a BLINK (the CC
/// processor lerps RelativeVelocity toward MoveVelocity at that sharpness — no CharacterProcessor edit needed).
/// During the recovery tail movement is locked to zero (no i-frames) so a panic-dash is punishable.
/// <see cref="ProjectM.Server"/>'s HealthApplyDamageSystem reads the window to negate hits authored inside it.
/// <para>
/// Runs in <see cref="PredictedSimulationSystemGroup"/> AFTER <see cref="PlayerControlSystem"/> (it overrides
/// the input-derived MoveVelocity that system wrote this tick) and is gated
/// <c>.WithAll&lt;Simulate&gt;().WithDisabled&lt;Dead&gt;()</c>. The START is an idempotent pure function of
/// replicated input + tick (no IsFirstTimeFullyPredictingTick guard); the OVERRIDE re-applies on EVERY predicted
/// pass so rollback re-simulation converges. All ticks routed through <c>TickUtil.NonZero</c>; compared via
/// <see cref="NetworkTick"/> only. DashSystem owns GroundedMovementSharpness on the player (base = the CC default
/// 15); PlayerDeathStateSystem restores it + clears the window on death.
/// </para>
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(PlayerControlSystem))]
[BurstCompile]
public partial struct DashSystem : ISystem
{
// Feel knobs are LIVE-tunable via the TuningConfig singleton (MC-0): OnUpdate reads it each tick and falls
// back to TuningConfig.Defaults() when absent (release builds / EditMode), so behaviour is identical to the
// old baked consts. DefaultSharpness (the restore target) + SimTickRate stay compile-time (not tuned).
const float DefaultSharpness = CharacterComponent.DefaultGroundedSharpness; // CharacterComponent.GetDefault() base
const float SimTickRate = 60f;
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var netTime) || !netTime.ServerTick.IsValid)
return;
var serverTick = netTime.ServerTick;
uint now = serverTick.TickIndexForValidTick;
var t = SystemAPI.TryGetSingleton<TuningConfig>(out var tc) ? tc : TuningConfig.Defaults();
uint iFrameTicks = (uint)math.max(1f, t.IFrameWindowTicks);
uint recoverTicks = (uint)math.max(1f, t.RecoverTailTicks);
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>,
RefRW<CharacterComponent>, RefRO<PlayerInput>, RefRO<PlayerFacing>>()
.WithAll<Simulate>().WithDisabled<Dead>())
{
// --- START (idempotent: fresh press + cooldown ready + not already mid-dash) ---
bool ready = cd.ValueRO.NextTick == 0u
|| !new NetworkTick(cd.ValueRO.NextTick).IsNewerThan(serverTick);
bool inWindow = ds.ValueRO.RecoverUntilTick != 0u
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick);
if (input.ValueRO.Dash.IsSet && ready && !inWindow)
{
// 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
: 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;
ds.ValueRW.StartTick = TickUtil.NonZero(now);
ds.ValueRW.IFrameUntilTick = TickUtil.NonZero(now + iFrameTicks);
ds.ValueRW.RecoverUntilTick = TickUtil.NonZero(now + iFrameTicks + recoverTicks);
cd.ValueRW.NextTick = TickUtil.NonZero(now + cooldownTicks);
}
// --- OVERRIDE (runs every predicted pass so rollback re-simulation re-applies it) ---
// The lower bound matters: DashState is non-replicated, so prediction rollback does NOT restore
// it — a re-simulated PRE-dash tick (serverTick < StartTick) still sees the post-press window and,
// gated on the upper bound alone, would stomp dash velocity onto ticks that never had it
// (dash-start overshoot under real latency). Membership = the half-open [StartTick, …) test.
bool inDashWindow = ds.ValueRO.StartTick != 0u
&& !new NetworkTick(ds.ValueRO.StartTick).IsNewerThan(serverTick);
bool iFrameActive = inDashWindow && ds.ValueRO.IFrameUntilTick != 0u
&& new NetworkTick(ds.ValueRO.IFrameUntilTick).IsNewerThan(serverTick);
bool recoverActive = inDashWindow && ds.ValueRO.RecoverUntilTick != 0u
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick);
if (iFrameActive)
{
float2 d = ds.ValueRO.Dir;
control.ValueRW.MoveVelocity = new float3(d.x, 0f, d.y) * dashSpeed;
character.ValueRW.GroundedMovementSharpness = dashSharpness;
}
else if (recoverActive)
{
control.ValueRW.MoveVelocity = float3.zero; // movement locked during the punishable tail
character.ValueRW.GroundedMovementSharpness = baseSharpness;
}
else
{
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
// client keeps its copy un-zeroed so rollback re-simulation of the tail stays intact. All
// in-window strikes drain >= 9 ticks before this edge, so clearing can't eat a negation.
if (ds.ValueRO.RecoverUntilTick != 0u && SystemAPI.TryGetSingletonRW<DevTelemetry>(out var telem))
{
if (ds.ValueRO.NegatedCount == 0u)
telem.ValueRW.DashesWasted++;
ds.ValueRW = default;
}
}
}
}
}
}