Files
Project-M/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs
T
kronic d9d67c4e78 Slice 2 (WIP): class data layer + melee-augment routing
Foundation for Two Classes (DR-037). New ids (CharacterId.Warrior/Ranger,
AbilityId.WarriorCone, StatTarget.MeleeDamage/MeleeRange); CharacterStatsRef.Id ->
[GhostField] so the owning client folds the right class stats; MeleeComboSystem
folds per-player MeleeDamage/MeleeRange off the replicated StatModifier buffer
(HasBuffer-guarded -> identity without class seeds, so behavior-preserving).
345/345 EditMode. Slice 2 design review + locked forks logged in the session note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:23:56 -07:00

337 lines
19 KiB
C#

using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Simulation
{
/// <summary>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).</summary>
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;
}
/// <summary>
/// MC-4 — the predicted melee combo (Hades-style 2-3 hit chain; the player's PRIMARY verb). On a fresh
/// <see cref="PlayerInput.Attack"/> press (not locked, not mid-dash) it ADVANCES <see cref="MeleeCombo.Step"/>
/// (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 (<see cref="MeleeCombo"/> [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).
/// <para>
/// Runs in <see cref="PredictedSimulationSystemGroup"/> AFTER <see cref="PlayerControlSystem"/> (it scales the
/// MoveVelocity that system wrote) and BEFORE <see cref="DashSystem"/> (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 (<see cref="MeleeConeMath"/>) ONCE, append
/// a SourceTick-stamped DamageEvent + stamp KnockbackState. All ticks via TickUtil.NonZero, compared with
/// NetworkTick only; feel knobs live in <see cref="TuningConfig"/> (MC-0, fallback to Defaults()).
/// </para>
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(PlayerControlSystem))]
[UpdateBefore(typeof(DashSystem))]
[BurstCompile]
public partial struct MeleeComboSystem : ISystem
{
ComponentLookup<KnockbackState> m_KnockbackLookup;
ComponentLookup<RegionTag> m_RegionLookup;
BufferLookup<InventorySlot> m_InvLookup;
BufferLookup<StatModifier> m_StatModLookup;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_RegionLookup = state.GetComponentLookup<RegionTag>(isReadOnly: true);
m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false);
m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true);
state.RequireForUpdate<NetworkTime>();
}
[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();
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<PendingCleave>(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<MeleeCombo>, RefRW<CharacterControl>, RefRO<PlayerInput>,
RefRO<PlayerFacing>, RefRO<LocalTransform>, RefRO<GhostOwner>, RefRO<DashState>>()
.WithAll<Simulate>().WithDisabled<Dead>().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<Entity>(Allocator.Temp);
var enemyPositions = new NativeList<float3>(Allocator.Temp);
foreach (var (xform, health, enemyEntity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
.WithAny<EnemyTag, TrainingDummyTag>()
.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<ResourceLedger>(out var ledgerEntity);
m_RegionLookup.Update(ref state);
m_InvLookup.Update(ref state);
var meleePlayerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (po, pe) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, InventorySlot>().WithEntityAccess())
meleePlayerByConn[po.ValueRO.NetworkId] = pe;
var harvEntity = new NativeList<Entity>(Allocator.Temp);
var harvPos = new NativeList<float3>(Allocator.Temp);
var harvRemaining = new NativeList<int>(Allocator.Temp);
var harvYieldId = new NativeList<byte>(Allocator.Temp);
var harvPerHit = new NativeList<float>(Allocator.Temp);
var harvIsClutter = new NativeList<bool>(Allocator.Temp);
var harvVariant = new NativeList<byte>(Allocator.Temp);
var harvToLedger = new NativeList<bool>(Allocator.Temp);
foreach (var (hx, node, he) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<ResourceNode>>().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<LocalTransform>, RefRO<BlightClutter>>().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<bool>(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<StorageEntry>(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();
}
}
}