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 { // Baked-first feel knobs (MC-1; promote to a live TuningConfig later). Sim runs at 60 ticks/sec. const float DashDistance = 4.0f; // world units covered during the i-frame window const uint IFrameWindowTicks = 12; // ~0.20 s of i-frames const uint RecoverTailTicks = 9; // ~0.15 s movement-locked tail (punishes spam) const uint DashCooldownTicks = 45; // ~0.75 s const float DashSharpness = 200f; // GroundedMovementSharpness during the dash -> blink 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; float dashSpeed = DashDistance / (IFrameWindowTicks / SimTickRate); 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 + IFrameWindowTicks); ds.ValueRW.RecoverUntilTick = TickUtil.NonZero(now + IFrameWindowTicks + RecoverTailTicks); cd.ValueRW.NextTick = TickUtil.NonZero(now + DashCooldownTicks); } // --- 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; } } } } } }