using Unity.Burst; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; namespace ProjectM.Simulation { /// /// MC-1 — the predicted dodge dash. On a fresh press (cooldown ready and not /// already mid-dash) it captures the dash heading from and opens a HALF-OPEN i-frame /// window [StartTick, IFrameUntilTick) plus a recovery tail. While the i-frame window is active it OVERRIDES /// with the dash velocity and raises /// 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. /// 's HealthApplyDamageSystem reads the window to negate hits authored inside it. /// /// Runs in AFTER (it overrides /// the input-derived MoveVelocity that system wrote this tick) and is gated /// .WithAll<Simulate>().WithDisabled<Dead>(). 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 TickUtil.NonZero; compared via /// only. DashSystem owns GroundedMovementSharpness on the player (base = the CC default /// 15); PlayerDeathStateSystem restores it + clears the window on death. /// /// [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 = 15f; // CharacterComponent.GetDefault() base const float SimTickRate = 60f; [BurstCompile] public void OnUpdate(ref SystemState state) { if (!SystemAPI.TryGetSingleton(out var netTime) || !netTime.ServerTick.IsValid) return; var serverTick = netTime.ServerTick; uint now = serverTick.TickIndexForValidTick; var t = SystemAPI.TryGetSingleton(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; foreach (var (ds, cd, control, character, input, facing) in SystemAPI.Query, RefRW, RefRW, RefRW, RefRO, RefRO>() .WithAll().WithDisabled()) { // --- 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) { float2 dir = 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 = DefaultSharpness; } else { if (character.ValueRO.GroundedMovementSharpness != DefaultSharpness) character.ValueRW.GroundedMovementSharpness = DefaultSharpness; // 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(out var telem)) { if (ds.ValueRO.NegatedCount == 0u) telem.ValueRW.DashesWasted++; ds.ValueRW = default; } } } } } }