using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; namespace ProjectM.Simulation { /// One server-side cleave queued from a player's swing-start this tick, resolved after the player loop so /// the living-enemy snapshot is gathered ONCE and ONLY when a swing actually fired (never on the client, never on an /// idle tick). Blittable (Burst-friendly). struct PendingCleave { public float3 From; public float2 Face; public float Damage; public float Range; public float KnockSpeed; public int OwnerId; public uint Stamp; public uint KnockUntil; } /// /// MC-4 — the predicted melee combo (Hades-style 2-3 hit chain; the player's PRIMARY verb). On a fresh /// press (not locked, not mid-dash) it ADVANCES /// (chain if re-pressed inside [LockUntilTick, LockUntilTick + grace), else reset to 1) and opens a movement-commit /// lock; the finisher (Step == ComboLength) hits bigger. The Step/SwingStartTick/LockUntilTick anchor is /// owner-replicated ( [GhostField]s) so a rollback restores the authoritative combo /// position; every write is an ABSOLUTE function of (restored Step, tick) — re-running a tick re-derives identical /// state (the DashSystem idempotency idiom; never an in-place prev+1 of a non-restored field — MC-4 review PRED-1). /// /// Runs in AFTER (it scales the /// MoveVelocity that system wrote) and BEFORE (a dash OVERRIDES the swing's movement = /// dash-cancel) — and so before HealthApplyDamageSystem ([UpdateAfter(DashSystem)]) which drains the cleave's /// DamageEvent the same tick. Movement-commit re-applies every predicted pass, lower-bounded on SwingStartTick (a /// re-simulated pre-swing tick must NOT inherit the scale — the DashSystem inDashWindow fix). DAMAGE is SERVER-ONLY /// (enemies are interpolated ghosts; the client never predicts enemy health) and mirrors ProjectileDamageSystem: /// queue each swing, then collect ALL living enemies in the per-step cone () ONCE, append /// a SourceTick-stamped DamageEvent + stamp KnockbackState. All ticks via TickUtil.NonZero, compared with /// NetworkTick only; feel knobs live in (MC-0, fallback to Defaults()). /// /// [UpdateInGroup(typeof(PredictedSimulationSystemGroup))] [UpdateAfter(typeof(PlayerControlSystem))] [UpdateBefore(typeof(DashSystem))] [BurstCompile] public partial struct MeleeComboSystem : ISystem { ComponentLookup m_KnockbackLookup; [BurstCompile] public void OnCreate(ref SystemState state) { m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false); state.RequireForUpdate(); } [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(); float baseDamage = math.max(0f, t.MeleeDamage); float baseRange = math.max(0f, t.MeleeRange); float cosHalf = math.cos(math.max(0f, t.MeleeConeHalfAngleRad)); uint recoverTicks = (uint)math.max(1f, t.MeleeRecoverTicks); uint graceTicks = (uint)math.max(1f, t.MeleeChainGraceTicks); float moveScale = math.max(0f, t.MeleeSwingMoveScale); float knockSpeed = math.max(0f, t.MeleeKnockbackSpeed); float finisherMult = math.max(1f, t.MeleeFinisherMult); byte comboLen = (byte)math.clamp((int)t.MeleeComboLength, 1, 3); uint stamp = TickUtil.NonZero(now); uint knockUntil = TickUtil.NonZero(now + (uint)math.max(1, Tuning.KnockbackDurationTicks)); bool isServer = state.WorldUnmanaged.IsServer(); // Server-only queue of cleaves to resolve after the player loop (so enemies are gathered ONCE, and only // when at least one swing actually started — no per-tick enemy gather on idle/client ticks). var cleaves = isServer ? new NativeList(Allocator.Temp) : default; foreach (var (mc, control, input, facing, xform, owner, ds) in SystemAPI.Query, RefRW, RefRO, RefRO, RefRO, RefRO, RefRO>() .WithAll().WithDisabled()) { // A dash window (i-frame OR recovery) active = dash owns movement + blocks a swing start (dash-cancel). bool dashActive = ds.ValueRO.StartTick != 0u && !new NetworkTick(ds.ValueRO.StartTick).IsNewerThan(serverTick) && ds.ValueRO.RecoverUntilTick != 0u && new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick); // Locked: mid swing / recovery (now < LockUntilTick). bool locked = mc.ValueRO.LockUntilTick != 0u && new NetworkTick(mc.ValueRO.LockUntilTick).IsNewerThan(serverTick); // --- ADVANCE (idempotent ABSOLUTE writes; dash wins same-tick ties via !Dash.IsSet) --- bool swingStarted = false; byte swingStep = 0; if (input.ValueRO.Attack.IsSet && !locked && !dashActive && !input.ValueRO.Dash.IsSet) { bool inChainWindow = false; if (mc.ValueRO.LockUntilTick != 0u) { var deadline = new NetworkTick(TickUtil.NonZero(mc.ValueRO.LockUntilTick + graceTicks)); inChainWindow = deadline.IsValid && deadline.IsNewerThan(serverTick); // now < lock+grace (now >= lock since !locked) } byte prev = mc.ValueRO.Step; swingStep = (inChainWindow && prev >= 1 && prev < comboLen) ? (byte)(prev + 1) : (byte)1; bool isFin = swingStep >= comboLen; uint stepRecover = isFin ? (uint)math.max(1f, math.round(recoverTicks * finisherMult)) : recoverTicks; mc.ValueRW.Step = swingStep; mc.ValueRW.SwingStartTick = TickUtil.NonZero(now); mc.ValueRW.LockUntilTick = TickUtil.NonZero(now + stepRecover); swingStarted = true; } // --- MOVEMENT COMMIT (every pass; lower-bounded on SwingStartTick; DashSystem overrides later) --- bool inSwingWindow = mc.ValueRO.SwingStartTick != 0u && !new NetworkTick(mc.ValueRO.SwingStartTick).IsNewerThan(serverTick) && mc.ValueRO.LockUntilTick != 0u && new NetworkTick(mc.ValueRO.LockUntilTick).IsNewerThan(serverTick); if (inSwingWindow && !dashActive) control.ValueRW.MoveVelocity *= moveScale; // --- SERVER: queue the cleave (resolved after the loop). Damage/cone are SERVER-authoritative. --- if (swingStarted && isServer) { bool isFin = swingStep >= comboLen; float2 face = facing.ValueRO.Direction; face = math.lengthsq(face) < 1e-6f ? new float2(0f, 1f) : math.normalize(face); cleaves.Add(new PendingCleave { From = xform.ValueRO.Position, Face = face, Damage = isFin ? baseDamage * finisherMult : baseDamage, Range = isFin ? baseRange * finisherMult : baseRange, KnockSpeed = isFin ? knockSpeed * finisherMult : knockSpeed, OwnerId = owner.ValueRO.NetworkId, Stamp = stamp, KnockUntil = knockUntil, }); } } // Resolve queued cleaves SERVER-ONLY and only when one actually fired: gather living enemies ONCE, then // append a SourceTick-stamped DamageEvent + stamp KnockbackState for each enemy in each swing's cone. if (isServer && cleaves.IsCreated && cleaves.Length > 0) { var enemyEntities = new NativeList(Allocator.Temp); var enemyPositions = new NativeList(Allocator.Temp); foreach (var (xform, health, enemyEntity) in SystemAPI.Query, RefRO>() .WithAny() .WithEntityAccess()) { if (health.ValueRO.Current <= 0f) continue; // skip already-dead enemies (about to despawn) enemyEntities.Add(enemyEntity); enemyPositions.Add(xform.ValueRO.Position); } m_KnockbackLookup.Update(ref state); var ecb = new EntityCommandBuffer(Allocator.Temp); for (int s = 0; s < cleaves.Length; s++) { var c = cleaves[s]; for (int i = 0; i < enemyEntities.Length; i++) { if (!MeleeConeMath.InCone(c.From, c.Face, c.Range, cosHalf, enemyPositions[i])) continue; var target = enemyEntities[i]; ecb.AppendToBuffer(target, new DamageEvent { Amount = c.Damage, SourceNetworkId = c.OwnerId, SourceTick = c.Stamp, }); if (c.KnockSpeed > 0f && m_KnockbackLookup.HasComponent(target)) { float3 d3 = enemyPositions[i] - c.From; float2 kdir = new float2(d3.x, d3.z); kdir = math.lengthsq(kdir) > 1e-6f ? math.normalize(kdir) : c.Face; m_KnockbackLookup[target] = new KnockbackState { Dir = kdir, Speed = c.KnockSpeed, UntilTick = c.KnockUntil }; } } } ecb.Playback(state.EntityManager); ecb.Dispose(); enemyEntities.Dispose(); enemyPositions.Dispose(); } if (cleaves.IsCreated) cleaves.Dispose(); } } }