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; ComponentLookup m_RegionLookup; BufferLookup m_InvLookup; BufferLookup m_StatModLookup; [BurstCompile] public void OnCreate(ref SystemState state) { m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false); m_RegionLookup = state.GetComponentLookup(isReadOnly: true); m_InvLookup = state.GetBufferLookup(isReadOnly: false); m_StatModLookup = state.GetBufferLookup(isReadOnly: true); 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; m_StatModLookup.Update(ref state); // Slice 2: per-player melee stat fold (read inside the player loop) foreach (var (mc, control, input, facing, xform, owner, ds, entity) in SystemAPI.Query, RefRW, RefRO, RefRO, RefRO, RefRO, RefRO>() .WithAll().WithDisabled().WithEntityAccess()) { // 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; // Slice 2: fold the player's class/run StatModifiers onto the live-tunable melee base so the // PRIMARY verb scales with class identity (Warrior +MeleeDamage/+reach) + run augments. 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); cleaves.Add(new PendingCleave { From = xform.ValueRO.Position, Face = face, Damage = isFin ? pDamage * finisherMult : pDamage, Range = isFin ? pRange * finisherMult : pRange, 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); } // Gather harvest targets (resource nodes + Blight clutter) ONCE so "any attack harvests": a swing // depletes every node/clutter in its cone, crediting the shared ResourceLedger (the build currency // pool) just like a base projectile hit. SERVER-ONLY (this whole block) — interpolated node ghosts // are never rolled back, so the deposit + destroy fire exactly once per swing. bool haveLedger = SystemAPI.TryGetSingletonEntity(out var ledgerEntity); m_RegionLookup.Update(ref state); m_InvLookup.Update(ref state); var meleePlayerByConn = new NativeHashMap(8, Allocator.Temp); foreach (var (po, pe) in SystemAPI.Query>().WithAll().WithEntityAccess()) meleePlayerByConn[po.ValueRO.NetworkId] = pe; var harvEntity = new NativeList(Allocator.Temp); var harvPos = new NativeList(Allocator.Temp); var harvRemaining = new NativeList(Allocator.Temp); var harvYieldId = new NativeList(Allocator.Temp); var harvPerHit = new NativeList(Allocator.Temp); var harvIsClutter = new NativeList(Allocator.Temp); var harvVariant = new NativeList(Allocator.Temp); var harvToLedger = new NativeList(Allocator.Temp); foreach (var (hx, node, he) in SystemAPI.Query, RefRO>().WithEntityAccess()) { harvEntity.Add(he); harvPos.Add(hx.ValueRO.Position); harvRemaining.Add(node.ValueRO.Remaining); harvYieldId.Add(node.ValueRO.ResourceId); harvPerHit.Add(node.ValueRO.HarvestPerHit); harvIsClutter.Add(false); harvVariant.Add(0); harvToLedger.Add(m_RegionLookup.HasComponent(he) && m_RegionLookup[he].Region == RegionId.Base); } foreach (var (hx, clutter, he) in SystemAPI.Query, RefRO>().WithEntityAccess()) { harvEntity.Add(he); harvPos.Add(hx.ValueRO.Position); harvRemaining.Add(clutter.ValueRO.Remaining); harvYieldId.Add(clutter.ValueRO.ScrapResourceId); harvPerHit.Add(clutter.ValueRO.ScrapPerHit); harvIsClutter.Add(true); harvVariant.Add(clutter.ValueRO.Variant); harvToLedger.Add(m_RegionLookup.HasComponent(he) && m_RegionLookup[he].Region == RegionId.Base); } var harvDestroyed = new NativeArray(harvEntity.Length, Allocator.Temp); 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 }; } } } // HARVEST: deplete every node/clutter in each swing's cone, crediting the shared ledger; write // Remaining back so the [GhostField] replicates -> WorldFeedbackSystem chips fire on melee mining. for (int s = 0; s < cleaves.Length; s++) { var hc = cleaves[s]; for (int i = 0; i < harvEntity.Length; i++) { if (harvDestroyed[i]) continue; if (!MeleeConeMath.InCone(hc.From, hc.Face, hc.Range, cosHalf, harvPos[i])) continue; int amount = math.max(1, (int)harvPerHit[i]); byte yieldId = harvYieldId[i]; // Route by region: Base nodes credit the shared ledger DIRECTLY (the build pool); an // expedition / un-tagged target goes to the swinging player's PERSONAL inventory (spill to // ledger), mirroring ResourceHarvestSystem. Only deplete if the yield landed somewhere — // never consume a node for zero credit (e.g. no ledger singleton present). int remainder = amount; bool deposited = false; if (!harvToLedger[i] && meleePlayerByConn.TryGetValue(hc.OwnerId, out var meleePlayer) && m_InvLookup.HasBuffer(meleePlayer)) { var inv = m_InvLookup[meleePlayer]; remainder = InventoryMath.Deposit(inv, yieldId, amount, Tuning.DefaultStackMax, Tuning.InventoryMaxSlots); deposited = true; } if (remainder > 0 && haveLedger) { var ledger = SystemAPI.GetBuffer(ledgerEntity); StorageMath.Deposit(ledger, yieldId, remainder); deposited = true; } if (!deposited) continue; int rem = harvRemaining[i] - amount; harvRemaining[i] = rem; if (rem <= 0) { harvDestroyed[i] = true; ecb.DestroyEntity(harvEntity[i]); } else if (harvIsClutter[i]) { ecb.SetComponent(harvEntity[i], new BlightClutter { Remaining = rem, Variant = harvVariant[i], ScrapResourceId = yieldId, ScrapPerHit = harvPerHit[i], }); } else { ecb.SetComponent(harvEntity[i], new ResourceNode { ResourceId = yieldId, Remaining = rem, HarvestPerHit = harvPerHit[i], }); } } } ecb.Playback(state.EntityManager); ecb.Dispose(); enemyEntities.Dispose(); enemyPositions.Dispose(); harvEntity.Dispose(); harvPos.Dispose(); harvRemaining.Dispose(); harvYieldId.Dispose(); harvPerHit.Dispose(); harvIsClutter.Dispose(); harvVariant.Dispose(); harvDestroyed.Dispose(); harvToLedger.Dispose(); meleePlayerByConn.Dispose(); } if (cleaves.IsCreated) cleaves.Dispose(); } } }