Attack Boon Changes
This commit is contained in:
@@ -32,6 +32,9 @@ namespace ProjectM.Authoring
|
|||||||
Damage = authoring.Damage,
|
Damage = authoring.Damage,
|
||||||
Range = authoring.Range
|
Range = authoring.Range
|
||||||
});
|
});
|
||||||
|
// Phase 1.7: server-only pierce/chain/pull state, baked inert; seeded at spawn by AbilityFireSystem
|
||||||
|
// (a separate component keeps the Projectile ghost hash frozen).
|
||||||
|
AddComponent<ProjectileEffectState>(entity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ namespace ProjectM.Authoring
|
|||||||
// flag + the owner-only choice-of-3 boon offer (inert until Step 9's BoonOfferSystem lights it up).
|
// flag + the owner-only choice-of-3 boon offer (inert until Step 9's BoonOfferSystem lights it up).
|
||||||
AddComponent<PlayerReady>(entity);
|
AddComponent<PlayerReady>(entity);
|
||||||
AddComponent<BoonOffer>(entity);
|
AddComponent<BoonOffer>(entity);
|
||||||
|
// Phase 1.7 boon overhaul: the mechanic-changer state (replicated SendToOwner, baked inert, zeroed on
|
||||||
|
// the Returning edge) + the server-only Blade-Dash per-dash dedup accumulator (non-replicated).
|
||||||
|
AddComponent<BoonEffects>(entity);
|
||||||
|
AddComponent<DashTrailState>(entity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,30 @@ namespace ProjectM.Server
|
|||||||
if (idx < 0)
|
if (idx < 0)
|
||||||
return false; // unknown id (catalog drift) — preserve-and-skip, never throw
|
return false; // unknown id (catalog drift) — preserve-and-skip, never throw
|
||||||
|
|
||||||
|
if (pool.Defs[idx].Kind == 1)
|
||||||
|
{
|
||||||
|
// Phase 1.7 mechanic-changer: mutate the baked-present BoonEffects (non-structural) instead of
|
||||||
|
// appending a StatModifier. Bytes only (Burst-safe switch). No BoonPickCounter bump (no band row).
|
||||||
|
if (!state.EntityManager.HasComponent<BoonEffects>(player))
|
||||||
|
return false; // real players are baked with it; skip defensively otherwise
|
||||||
|
var fx = state.EntityManager.GetComponentData<BoonEffects>(player);
|
||||||
|
byte delta = (byte)pool.Defs[idx].Value;
|
||||||
|
switch (pool.Defs[idx].EffectKind)
|
||||||
|
{
|
||||||
|
case BoonEffectKind.Pierce: fx.Pierce = (byte)(fx.Pierce + delta); break;
|
||||||
|
case BoonEffectKind.Fork: fx.Fork = (byte)(fx.Fork + delta); break;
|
||||||
|
case BoonEffectKind.Chain: fx.Chain = (byte)(fx.Chain + delta); break;
|
||||||
|
case BoonEffectKind.DashTrail: fx.Flags |= BoonFlag.DashTrail; break;
|
||||||
|
case BoonEffectKind.FinisherDetonate: fx.Flags |= BoonFlag.FinisherDetonate; break;
|
||||||
|
case BoonEffectKind.KnockToPull: fx.Flags |= BoonFlag.KnockToPull; break;
|
||||||
|
case BoonEffectKind.Siphon: fx.Flags |= BoonFlag.Siphon; break;
|
||||||
|
case BoonEffectKind.Frenzy: fx.Flags |= BoonFlag.Frenzy; break;
|
||||||
|
default: return false; // unknown effect kind — preserve-and-skip
|
||||||
|
}
|
||||||
|
state.EntityManager.SetComponentData(player, fx);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
var mods = state.EntityManager.GetBuffer<StatModifier>(player);
|
var mods = state.EntityManager.GetBuffer<StatModifier>(player);
|
||||||
mods.Add(new StatModifier
|
mods.Add(new StatModifier
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,16 +58,16 @@ namespace ProjectM.Server
|
|||||||
return;
|
return;
|
||||||
ref var pool = ref catalog.Value.Value;
|
ref var pool = ref catalog.Value.Value;
|
||||||
|
|
||||||
foreach (var (offer, owner, region, cls) in
|
foreach (var (offer, owner, region, cls, fx) in
|
||||||
SystemAPI.Query<RefRW<BoonOffer>, RefRO<GhostOwner>, RefRO<RegionTag>, RefRO<PlayerClass>>()
|
SystemAPI.Query<RefRW<BoonOffer>, RefRO<GhostOwner>, RefRO<RegionTag>, RefRO<PlayerClass>, RefRO<BoonEffects>>()
|
||||||
.WithAll<PlayerTag>())
|
.WithAll<PlayerTag>())
|
||||||
{
|
{
|
||||||
if (region.ValueRO.Region != RegionId.Expedition)
|
if (region.ValueRO.Region != RegionId.Expedition)
|
||||||
continue; // home-bound players (dead-respawned, joiners) are dealt nothing
|
continue; // home-bound players (dead-respawned, joiners) are dealt nothing
|
||||||
|
|
||||||
// Deterministic per-player draw: reconnect-stable per session, replay-reproducible per (seed, room).
|
// Deterministic per-player draw: reconnect-stable per session, replay-reproducible per (seed, room, player, owned-effects-at-draw).
|
||||||
uint offerSeed = RunMapMath.Hash(run.RunSeed, (uint)info.CurrentRoom, (uint)owner.ValueRO.NetworkId) | 1u;
|
uint offerSeed = RunMapMath.Hash(run.RunSeed, (uint)info.CurrentRoom, (uint)owner.ValueRO.NetworkId) | 1u;
|
||||||
BoonMath.PickBoons(offerSeed, cls.ValueRO.ClassId, ref pool, out byte o0, out byte o1, out byte o2);
|
BoonMath.PickBoons(offerSeed, cls.ValueRO.ClassId, fx.ValueRO, ref pool, out byte o0, out byte o1, out byte o2);
|
||||||
offer.ValueRW = new BoonOffer { Pending = 1, Option0 = o0, Option1 = o1, Option2 = o2 };
|
offer.ValueRW = new BoonOffer { Pending = 1, Option0 = o0, Option1 = o1, Option2 = o2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Burst;
|
||||||
|
using Unity.Collections;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.Mathematics;
|
||||||
|
using Unity.NetCode;
|
||||||
|
using Unity.Transforms;
|
||||||
|
|
||||||
|
namespace ProjectM.Server
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 1.7 "Blade Dash" boon (<see cref="BoonFlag.DashTrail"/>): while a player is inside its dash blink window,
|
||||||
|
/// living enemies within <see cref="k_Radius"/> of the player take damage — one hit per enemy per dash. SERVER-ONLY
|
||||||
|
/// (enemies are interpolated ghosts the client never predicts — mirrors the melee cleave / cone / projectile-damage
|
||||||
|
/// pattern), inside the predicted group after <see cref="DashSystem"/> (dash state committed) and before
|
||||||
|
/// <c>HealthApplyDamageSystem</c> (the DamageEvent drains the same tick). Enemies carry no <c>DashState</c>, so the
|
||||||
|
/// dash-i-frame negation branch in HealthApplyDamageSystem is skipped — harmless.
|
||||||
|
///
|
||||||
|
/// Dedup is keyed to <see cref="DashState.StartTick"/> (which is <c>TickUtil.NonZero(now)</c> on every dash and has
|
||||||
|
/// NO reliable clear edge on a release server): <see cref="DashTrailState.Hit"/> is cleared whenever the current
|
||||||
|
/// StartTick differs from <see cref="DashTrailState.LastStartTick"/>. Server-only ⇒ no rollback, so persisting the
|
||||||
|
/// accumulator across ticks is safe. A per-tick radius test (run every blink tick) approximates the swept path; the
|
||||||
|
/// per-tick dash step (<~0.6u) is well inside the radius, so a thin enemy is not tunnelled. Hit-set overflow stops
|
||||||
|
/// adding (a possible re-hit on a very crowded dash — accepted v1 cap).
|
||||||
|
/// </summary>
|
||||||
|
[BurstCompile]
|
||||||
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||||
|
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
||||||
|
[UpdateAfter(typeof(DashSystem))]
|
||||||
|
[UpdateBefore(typeof(HealthApplyDamageSystem))]
|
||||||
|
public partial struct DashTrailDamageSystem : ISystem
|
||||||
|
{
|
||||||
|
const float k_Radius = 1.6f; // planar hit radius around the dashing player (tunable)
|
||||||
|
const float k_Damage = 12f; // per-enemy damage for a dash pass (tunable)
|
||||||
|
|
||||||
|
[BurstCompile]
|
||||||
|
public void OnCreate(ref SystemState state)
|
||||||
|
{
|
||||||
|
state.RequireForUpdate<NetworkTime>();
|
||||||
|
state.RequireForUpdate<DashTrailState>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[BurstCompile]
|
||||||
|
public void OnUpdate(ref SystemState state)
|
||||||
|
{
|
||||||
|
var nt = SystemAPI.GetSingleton<NetworkTime>();
|
||||||
|
var serverTick = nt.ServerTick;
|
||||||
|
if (!serverTick.IsValid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Snapshot living enemies once (positions + radii + entities), stable query order.
|
||||||
|
var enemyEntities = new NativeList<Entity>(Allocator.Temp);
|
||||||
|
var enemyPositions = new NativeList<float3>(Allocator.Temp);
|
||||||
|
var enemyRadii = new NativeList<float>(Allocator.Temp);
|
||||||
|
foreach (var (tx, hr, hp, te) in
|
||||||
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<Health>>()
|
||||||
|
.WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess())
|
||||||
|
{
|
||||||
|
if (hp.ValueRO.Current <= 0f) continue;
|
||||||
|
enemyEntities.Add(te);
|
||||||
|
enemyPositions.Add(tx.ValueRO.Position);
|
||||||
|
enemyRadii.Add(hr.ValueRO.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enemyEntities.Length == 0)
|
||||||
|
{
|
||||||
|
enemyEntities.Dispose(); enemyPositions.Dispose(); enemyRadii.Dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint stamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
|
||||||
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||||
|
|
||||||
|
foreach (var (xform, dash, trail, owner, fx) in
|
||||||
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<DashState>, RefRW<DashTrailState>,
|
||||||
|
RefRO<GhostOwner>, RefRO<BoonEffects>>()
|
||||||
|
.WithAll<PlayerTag, Simulate>())
|
||||||
|
{
|
||||||
|
if ((fx.ValueRO.Flags & BoonFlag.DashTrail) == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
uint startRaw = dash.ValueRO.StartTick;
|
||||||
|
if (startRaw == 0u)
|
||||||
|
continue; // never dashed
|
||||||
|
|
||||||
|
// Inside the blink (i-frame) window [StartTick, IFrameUntilTick)?
|
||||||
|
var startTick = new NetworkTick(startRaw);
|
||||||
|
var untilTick = new NetworkTick(dash.ValueRO.IFrameUntilTick);
|
||||||
|
bool dashing = startTick.IsValid && untilTick.IsValid
|
||||||
|
&& !startTick.IsNewerThan(serverTick) && untilTick.IsNewerThan(serverTick);
|
||||||
|
if (!dashing)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// New dash → reset the per-dash hit set (StartTick changes every dash; no reliable DashState clear).
|
||||||
|
if (trail.ValueRO.LastStartTick != startRaw)
|
||||||
|
{
|
||||||
|
trail.ValueRW.Hit.Clear();
|
||||||
|
trail.ValueRW.LastStartTick = startRaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
float3 p = xform.ValueRO.Position;
|
||||||
|
int ownerId = owner.ValueRO.NetworkId;
|
||||||
|
for (int i = 0; i < enemyEntities.Length; i++)
|
||||||
|
{
|
||||||
|
var enemy = enemyEntities[i];
|
||||||
|
if (HitContains(trail.ValueRO, enemy))
|
||||||
|
continue;
|
||||||
|
float2 d = new float2(enemyPositions[i].x - p.x, enemyPositions[i].z - p.z);
|
||||||
|
float reach = k_Radius + enemyRadii[i];
|
||||||
|
if (math.lengthsq(d) > reach * reach)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (trail.ValueRO.Hit.Length >= trail.ValueRO.Hit.Capacity) break; // hit-cap: never damage an enemy we can't record (else re-hit every tick)
|
||||||
|
ecb.AppendToBuffer(enemy, new DamageEvent
|
||||||
|
{
|
||||||
|
Amount = k_Damage,
|
||||||
|
SourceNetworkId = ownerId, // a real player id (legit Charger whiff-punish credit)
|
||||||
|
SourceTick = stamp,
|
||||||
|
});
|
||||||
|
if (trail.ValueRO.Hit.Length < trail.ValueRO.Hit.Capacity)
|
||||||
|
trail.ValueRW.Hit.Add(enemy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ecb.Playback(state.EntityManager);
|
||||||
|
ecb.Dispose();
|
||||||
|
enemyEntities.Dispose();
|
||||||
|
enemyPositions.Dispose();
|
||||||
|
enemyRadii.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool HitContains(in DashTrailState trail, Entity e)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < trail.Hit.Length; i++)
|
||||||
|
if (trail.Hit[i] == e) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f2a00802a81103745a1d20475a3c7b7b
|
||||||
@@ -77,6 +77,7 @@ namespace ProjectM.Server
|
|||||||
bool isCharger = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<LungeState>(entity);
|
bool isCharger = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<LungeState>(entity);
|
||||||
uint negatedForThisEntity = 0u;
|
uint negatedForThisEntity = 0u;
|
||||||
float total = 0f;
|
float total = 0f;
|
||||||
|
int killerNetId = -1; // Phase 1.7: last player-sourced (non-negated) hit this tick → on-kill boon credit
|
||||||
for (int i = 0; i < dmg.Length; i++)
|
for (int i = 0; i < dmg.Length; i++)
|
||||||
{
|
{
|
||||||
uint src = dmg[i].SourceTick;
|
uint src = dmg[i].SourceTick;
|
||||||
@@ -96,6 +97,7 @@ namespace ProjectM.Server
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
total += dmg[i].Amount;
|
total += dmg[i].Amount;
|
||||||
|
if (dmg[i].SourceNetworkId >= 0) killerNetId = dmg[i].SourceNetworkId; // Phase 1.7 kill credit
|
||||||
|
|
||||||
// MC-1 punish scoring: a player-sourced hit (SourceNetworkId >= 0) landing inside a Charger's
|
// MC-1 punish scoring: a player-sourced hit (SourceNetworkId >= 0) landing inside a Charger's
|
||||||
// whiff-stagger window counts ONCE — zeroing StaggerUntilTick keeps punishes:windows <= 1.
|
// whiff-stagger window counts ONCE — zeroing StaggerUntilTick keeps punishes:windows <= 1.
|
||||||
@@ -149,6 +151,8 @@ namespace ProjectM.Server
|
|||||||
ecb.AddComponent(entity, new Dying
|
ecb.AddComponent(entity, new Dying
|
||||||
{
|
{
|
||||||
UntilTick = TickUtil.NonZero(netTime.ServerTick.TickIndexForValidTick + Tuning.EnemyDeathWindowTicks),
|
UntilTick = TickUtil.NonZero(netTime.ServerTick.TickIndexForValidTick + Tuning.EnemyDeathWindowTicks),
|
||||||
|
KillerNetId = killerNetId, // Phase 1.7: KillRewardSystem reads this for Siphon/Frenzy
|
||||||
|
Rewarded = 0,
|
||||||
});
|
});
|
||||||
if (SystemAPI.HasComponent<AttackWindup>(entity)) SystemAPI.SetComponent(entity, default(AttackWindup));
|
if (SystemAPI.HasComponent<AttackWindup>(entity)) SystemAPI.SetComponent(entity, default(AttackWindup));
|
||||||
if (SystemAPI.HasComponent<KnockbackState>(entity)) SystemAPI.SetComponent(entity, default(KnockbackState));
|
if (SystemAPI.HasComponent<KnockbackState>(entity)) SystemAPI.SetComponent(entity, default(KnockbackState));
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Burst;
|
||||||
|
using Unity.Collections;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.Mathematics;
|
||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Server
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 1.7 on-kill boons. When <c>HealthApplyDamageSystem</c> stamps an enemy <see cref="Dying"/> it records the
|
||||||
|
/// crediting player's NetworkId; this system grants that killer their on-kill boons ONCE per corpse:
|
||||||
|
/// <see cref="BoonFlag.Siphon"/> heals the killer (clamped to <see cref="EffectiveCharacterStats.MaxHealth"/>) and
|
||||||
|
/// <see cref="BoonFlag.Frenzy"/> refreshes a short cooldown-reduction buff (<see cref="TimedModifierUtil.Upsert"/> —
|
||||||
|
/// re-stamped, never stacked). Idempotent via the <see cref="Dying.Rewarded"/> latch (a value write, no edge-detect).
|
||||||
|
///
|
||||||
|
/// A SEPARATE system (not folded into HealthApplyDamageSystem) because healing the killer needs RW
|
||||||
|
/// <see cref="Health"/> access, which would alias that system's <c>RefRW<Health></c> victim query. Here the
|
||||||
|
/// only query is <c>RefRW<Dying></c> over enemies, and all killer writes go through ComponentLookup/BufferLookup
|
||||||
|
/// on player entities — no aliasing. Server-only (no rollback) inside the predicted group, after damage application.
|
||||||
|
/// </summary>
|
||||||
|
[BurstCompile]
|
||||||
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||||
|
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
||||||
|
[UpdateAfter(typeof(HealthApplyDamageSystem))]
|
||||||
|
public partial struct KillRewardSystem : ISystem
|
||||||
|
{
|
||||||
|
ComponentLookup<BoonEffects> m_Fx;
|
||||||
|
ComponentLookup<Health> m_Health;
|
||||||
|
ComponentLookup<EffectiveCharacterStats> m_EffChar;
|
||||||
|
BufferLookup<StatModifier> m_Mods;
|
||||||
|
BufferLookup<TimedModifier> m_Timed;
|
||||||
|
|
||||||
|
const float k_SiphonHeal = 8f; // HP restored per kill (tunable)
|
||||||
|
|
||||||
|
[BurstCompile]
|
||||||
|
public void OnCreate(ref SystemState state)
|
||||||
|
{
|
||||||
|
m_Fx = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
|
||||||
|
m_Health = state.GetComponentLookup<Health>(isReadOnly: false);
|
||||||
|
m_EffChar = state.GetComponentLookup<EffectiveCharacterStats>(isReadOnly: true);
|
||||||
|
m_Mods = state.GetBufferLookup<StatModifier>(isReadOnly: false);
|
||||||
|
m_Timed = state.GetBufferLookup<TimedModifier>(isReadOnly: false);
|
||||||
|
state.RequireForUpdate<NetworkTime>();
|
||||||
|
state.RequireForUpdate<Dying>(); // only run while a fresh corpse exists
|
||||||
|
}
|
||||||
|
|
||||||
|
[BurstCompile]
|
||||||
|
public void OnUpdate(ref SystemState state)
|
||||||
|
{
|
||||||
|
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||||
|
if (!serverTick.IsValid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_Fx.Update(ref state);
|
||||||
|
m_Health.Update(ref state);
|
||||||
|
m_EffChar.Update(ref state);
|
||||||
|
m_Mods.Update(ref state);
|
||||||
|
m_Timed.Update(ref state);
|
||||||
|
|
||||||
|
// Resolve killers by NetworkId (players only).
|
||||||
|
var playerByNet = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||||
|
foreach (var (owner, e) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag>().WithEntityAccess())
|
||||||
|
playerByNet[owner.ValueRO.NetworkId] = e;
|
||||||
|
|
||||||
|
uint until = TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.FrenzyDurationTicks));
|
||||||
|
|
||||||
|
foreach (var (dying, corpse) in SystemAPI.Query<RefRW<Dying>>().WithAll<EnemyTag>().WithEntityAccess())
|
||||||
|
{
|
||||||
|
if (dying.ValueRO.Rewarded != 0)
|
||||||
|
continue;
|
||||||
|
dying.ValueRW.Rewarded = 1; // mark ONCE — idempotent even when the killer can't be resolved
|
||||||
|
|
||||||
|
int killerNet = dying.ValueRO.KillerNetId;
|
||||||
|
if (killerNet < 0 || !playerByNet.TryGetValue(killerNet, out var killer))
|
||||||
|
continue;
|
||||||
|
if (!m_Fx.HasComponent(killer))
|
||||||
|
continue;
|
||||||
|
byte flags = m_Fx[killer].Flags;
|
||||||
|
|
||||||
|
// Siphon: heal the killer, clamped to their effective max (no over-heal; skip a corpse killer).
|
||||||
|
if ((flags & BoonFlag.Siphon) != 0 && m_Health.HasComponent(killer))
|
||||||
|
{
|
||||||
|
var h = m_Health[killer];
|
||||||
|
if (h.Current > 0f)
|
||||||
|
{
|
||||||
|
float max = m_EffChar.HasComponent(killer) ? m_EffChar[killer].MaxHealth : h.Max;
|
||||||
|
h.Current = math.min(h.Current + k_SiphonHeal, max);
|
||||||
|
m_Health[killer] = h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frenzy: refresh (never stack) a short cooldown-reduction buff on the killer.
|
||||||
|
if ((flags & BoonFlag.Frenzy) != 0 && m_Mods.HasBuffer(killer) && m_Timed.HasBuffer(killer))
|
||||||
|
{
|
||||||
|
TimedModifierUtil.Upsert(m_Mods[killer], m_Timed[killer], Tuning.FrenzySourceId,
|
||||||
|
(byte)StatTarget.CooldownTicks, (byte)ModOp.PercentMult, Tuning.FrenzyCooldownMult, until);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playerByNet.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 43348399863cc454a8752cce54cc329d
|
||||||
@@ -24,12 +24,18 @@ namespace ProjectM.Server
|
|||||||
/// A target whose <see cref="GhostOwner"/> matches the projectile's owner is skipped (no self-hits);
|
/// A target whose <see cref="GhostOwner"/> matches the projectile's owner is skipped (no self-hits);
|
||||||
/// dummies carry no <see cref="GhostOwner"/> and are therefore always valid targets.
|
/// dummies carry no <see cref="GhostOwner"/> and are therefore always valid targets.
|
||||||
///
|
///
|
||||||
|
/// Phase 1.7 mechanic-changer boons ride the server-only <see cref="ProjectileEffectState"/> (seeded at
|
||||||
|
/// spawn by <c>AbilityFireSystem</c> from the owner's <see cref="BoonEffects"/>): PIERCE lets the projectile
|
||||||
|
/// survive a hit (decrement, don't destroy), CHAIN retargets its (replicated) <see cref="Projectile.Direction"/>
|
||||||
|
/// toward the next-nearest living enemy, and PULL flips the knockback heading toward the shooter. A per-projectile
|
||||||
|
/// hit-set is excluded DURING target selection so a surviving projectile never re-hits a target across ticks; the
|
||||||
|
/// set full (or no pierce/chain left) destroys as before. Projectiles WITHOUT the component behave exactly as
|
||||||
|
/// before (destroy on hit) — graceful degradation. Exactly ONE destroy per projectile per tick is preserved.
|
||||||
|
///
|
||||||
/// On a hit the system appends a <see cref="DamageEvent"/> to the target (consumed by
|
/// On a hit the system appends a <see cref="DamageEvent"/> to the target (consumed by
|
||||||
/// <c>HealthApplyDamageSystem</c>) and destroys the projectile. Deferring damage to a buffer lets a
|
/// <c>HealthApplyDamageSystem</c>). Deferring damage to a buffer lets a single tick stack hits from multiple
|
||||||
/// single tick stack hits from multiple projectiles. All structural changes go through an
|
/// projectiles. All structural changes go through an <see cref="EntityCommandBuffer"/> that plays back
|
||||||
/// <see cref="EntityCommandBuffer"/> that plays back immediately to the
|
/// immediately to the <see cref="EntityManager"/> (Temp allocator).
|
||||||
/// <see cref="EntityManager"/> (Temp allocator) — keeping this server-only, once-per-tick system
|
|
||||||
/// self-contained and plain-world testable without a separate ECB system.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||||
@@ -47,17 +53,22 @@ namespace ProjectM.Server
|
|||||||
/// knockback-immune (A4) so a solo player can't perma-stunlock it out of its slam wind-ups.</summary>
|
/// knockback-immune (A4) so a solo player can't perma-stunlock it out of its slam wind-ups.</summary>
|
||||||
ComponentLookup<BossState> m_BossLookup;
|
ComponentLookup<BossState> m_BossLookup;
|
||||||
|
|
||||||
|
/// <summary>RW lookup for the per-projectile Phase-1.7 pierce/chain/pull state + re-hit set.</summary>
|
||||||
|
ComponentLookup<ProjectileEffectState> m_FxLookup;
|
||||||
|
|
||||||
/// <summary>Extra forgiveness added to a target's hit radius to approximate the projectile's own size.</summary>
|
/// <summary>Extra forgiveness added to a target's hit radius to approximate the projectile's own size.</summary>
|
||||||
const float k_ProjectileRadius = 0.2f;
|
const float k_ProjectileRadius = 0.2f;
|
||||||
|
|
||||||
|
/// <summary>Max planar distance a Ricochet chain will reach for its next target (tunable).</summary>
|
||||||
|
const float k_ChainRange = 8f;
|
||||||
|
|
||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
public void OnCreate(ref SystemState state)
|
public void OnCreate(ref SystemState state)
|
||||||
{
|
{
|
||||||
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(isReadOnly: true);
|
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(isReadOnly: true);
|
||||||
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
|
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
|
||||||
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
|
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
|
||||||
|
m_FxLookup = state.GetComponentLookup<ProjectileEffectState>(isReadOnly: false);
|
||||||
|
|
||||||
// No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely.
|
// No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely.
|
||||||
state.RequireForUpdate<Projectile>();
|
state.RequireForUpdate<Projectile>();
|
||||||
@@ -69,6 +80,7 @@ namespace ProjectM.Server
|
|||||||
m_GhostOwnerLookup.Update(ref state);
|
m_GhostOwnerLookup.Update(ref state);
|
||||||
m_KnockbackLookup.Update(ref state);
|
m_KnockbackLookup.Update(ref state);
|
||||||
m_BossLookup.Update(ref state);
|
m_BossLookup.Update(ref state);
|
||||||
|
m_FxLookup.Update(ref state);
|
||||||
|
|
||||||
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
|
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
|
||||||
|
|
||||||
@@ -92,11 +104,14 @@ namespace ProjectM.Server
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach (var (xform, proj, owner, projectileEntity) in
|
foreach (var (xform, proj, owner, projectileEntity) in
|
||||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Projectile>, RefRO<GhostOwner>>()
|
SystemAPI.Query<RefRO<LocalTransform>, RefRW<Projectile>, RefRO<GhostOwner>>()
|
||||||
.WithEntityAccess())
|
.WithEntityAccess())
|
||||||
{
|
{
|
||||||
int projOwnerId = owner.ValueRO.NetworkId;
|
int projOwnerId = owner.ValueRO.NetworkId;
|
||||||
|
|
||||||
|
bool hasFx = m_FxLookup.HasComponent(projectileEntity);
|
||||||
|
ProjectileEffectState fx = hasFx ? m_FxLookup[projectileEntity] : default;
|
||||||
|
|
||||||
// This tick's planar travel segment: [segStart -> segEnd]. Sweeping the segment (rather
|
// This tick's planar travel segment: [segStart -> segEnd]. Sweeping the segment (rather
|
||||||
// than testing only segEnd) is what prevents fast projectiles from tunnelling targets.
|
// than testing only segEnd) is what prevents fast projectiles from tunnelling targets.
|
||||||
float3 cur = xform.ValueRO.Position;
|
float3 cur = xform.ValueRO.Position;
|
||||||
@@ -117,6 +132,11 @@ namespace ProjectM.Server
|
|||||||
m_GhostOwnerLookup[target].NetworkId == projOwnerId)
|
m_GhostOwnerLookup[target].NetworkId == projOwnerId)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
// Phase 1.7: a surviving (pierced/chained) projectile never re-hits a target already struck —
|
||||||
|
// excluded DURING selection, not post-filtered.
|
||||||
|
if (hasFx && HitSetContains(in fx, target))
|
||||||
|
continue;
|
||||||
|
|
||||||
float2 tp = new float2(targetPositions[i].x, targetPositions[i].z);
|
float2 tp = new float2(targetPositions[i].x, targetPositions[i].z);
|
||||||
|
|
||||||
// Closest point on the travel segment to the target centre.
|
// Closest point on the travel segment to the target centre.
|
||||||
@@ -135,23 +155,68 @@ namespace ProjectM.Server
|
|||||||
|
|
||||||
if (bestIdx >= 0)
|
if (bestIdx >= 0)
|
||||||
{
|
{
|
||||||
// Earliest target along the travel path: deal damage and consume the projectile.
|
var hitTarget = targetEntities[bestIdx];
|
||||||
ecb.AppendToBuffer(targetEntities[bestIdx], new DamageEvent
|
|
||||||
|
// Earliest target along the travel path: deal damage.
|
||||||
|
ecb.AppendToBuffer(hitTarget, new DamageEvent
|
||||||
{
|
{
|
||||||
Amount = proj.ValueRO.Damage,
|
Amount = proj.ValueRO.Damage,
|
||||||
SourceNetworkId = projOwnerId,
|
SourceNetworkId = projOwnerId,
|
||||||
SourceTick = haveTick ? TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick) : 0u,
|
SourceTick = haveTick ? TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick) : 0u,
|
||||||
});
|
});
|
||||||
var hitTarget = targetEntities[bestIdx];
|
|
||||||
|
// Knockback (Phase 1.7: PULL flips the heading toward the shooter when the owner's boon is set).
|
||||||
if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget) && !m_BossLookup.HasComponent(hitTarget))
|
if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget) && !m_BossLookup.HasComponent(hitTarget))
|
||||||
{
|
{
|
||||||
|
bool pull = hasFx && (fx.Flags & ProjectileEffectFlag.Pull) != 0;
|
||||||
|
float2 kdir = pull ? -proj.ValueRO.Direction : proj.ValueRO.Direction;
|
||||||
m_KnockbackLookup[hitTarget] = new KnockbackState
|
m_KnockbackLookup[hitTarget] = new KnockbackState
|
||||||
{
|
{
|
||||||
Dir = proj.ValueRO.Direction,
|
Dir = kdir,
|
||||||
Speed = Tuning.KnockbackSpeed,
|
Speed = Tuning.KnockbackSpeed,
|
||||||
UntilTick = TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)),
|
UntilTick = TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 1.7: pierce/chain let the projectile SURVIVE; else it is consumed. Record the target so it
|
||||||
|
// can't be re-hit. A full hit-set is a natural cap → destroy.
|
||||||
|
bool survive = false;
|
||||||
|
if (hasFx)
|
||||||
|
{
|
||||||
|
if (fx.Hit.Length < fx.Hit.Capacity)
|
||||||
|
{
|
||||||
|
fx.Hit.Add(hitTarget);
|
||||||
|
if (fx.PierceRemaining > 0)
|
||||||
|
{
|
||||||
|
fx.PierceRemaining = (byte)(fx.PierceRemaining - 1);
|
||||||
|
survive = true;
|
||||||
|
}
|
||||||
|
else if (fx.ChainRemaining > 0)
|
||||||
|
{
|
||||||
|
int nextIdx = FindChainTarget(targetEntities, targetPositions, cur, projOwnerId, in fx);
|
||||||
|
if (nextIdx >= 0)
|
||||||
|
{
|
||||||
|
float2 to = new float2(targetPositions[nextIdx].x - cur.x, targetPositions[nextIdx].z - cur.z);
|
||||||
|
if (math.lengthsq(to) > 1e-6f)
|
||||||
|
{
|
||||||
|
proj.ValueRW.Direction = math.normalize(to);
|
||||||
|
fx.ChainRemaining = (byte)(fx.ChainRemaining - 1);
|
||||||
|
survive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m_FxLookup[projectileEntity] = fx;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (survive)
|
||||||
|
{
|
||||||
|
// A surviving projectile still expires once it has travelled its full range.
|
||||||
|
if (proj.ValueRO.DistanceTravelled >= proj.ValueRO.Range)
|
||||||
|
ecb.DestroyEntity(projectileEntity);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
ecb.DestroyEntity(projectileEntity);
|
ecb.DestroyEntity(projectileEntity);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -168,5 +233,38 @@ namespace ProjectM.Server
|
|||||||
targetPositions.Dispose();
|
targetPositions.Dispose();
|
||||||
targetRadii.Dispose();
|
targetRadii.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>True when <paramref name="target"/> is already in the projectile's re-hit history.</summary>
|
||||||
|
static bool HitSetContains(in ProjectileEffectState fx, Entity target)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < fx.Hit.Length; i++)
|
||||||
|
if (fx.Hit[i] == target) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Nearest living target to <paramref name="from"/> within <see cref="k_ChainRange"/> that is neither
|
||||||
|
/// the caster's own ghost nor already in the projectile's hit-set. Returns the snapshot index or -1.</summary>
|
||||||
|
int FindChainTarget(in NativeList<Entity> targetEntities, in NativeList<float3> targetPositions,
|
||||||
|
float3 from, int projOwnerId, in ProjectileEffectState fx)
|
||||||
|
{
|
||||||
|
int best = -1;
|
||||||
|
float bestDistSq = k_ChainRange * k_ChainRange;
|
||||||
|
for (int i = 0; i < targetEntities.Length; i++)
|
||||||
|
{
|
||||||
|
var target = targetEntities[i];
|
||||||
|
if (m_GhostOwnerLookup.HasComponent(target) && m_GhostOwnerLookup[target].NetworkId == projOwnerId)
|
||||||
|
continue;
|
||||||
|
if (HitSetContains(in fx, target))
|
||||||
|
continue;
|
||||||
|
float2 d = new float2(targetPositions[i].x - from.x, targetPositions[i].z - from.z);
|
||||||
|
float dsq = math.lengthsq(d);
|
||||||
|
if (dsq <= bestDistSq)
|
||||||
|
{
|
||||||
|
bestDistSq = dsq;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -389,14 +389,18 @@ case RunLifecycle.RouteSelect:
|
|||||||
// boon-band StatModifier (replicates via the [GhostField] buffer; StatRecompute reverts the
|
// boon-band StatModifier (replicates via the [GhostField] buffer; StatRecompute reverts the
|
||||||
// effective stats on both worlds) and zeroes any straggler offer. Class/meta/equip bands are
|
// effective stats on both worlds) and zeroes any straggler offer. Class/meta/equip bands are
|
||||||
// disjoint and survive. Idempotent — safe on every Returning tick.
|
// disjoint and survive. Idempotent — safe on every Returning tick.
|
||||||
foreach (var (mods, offer) in
|
foreach (var (mods, timed, fx, offer) in
|
||||||
SystemAPI.Query<DynamicBuffer<StatModifier>, RefRW<BoonOffer>>().WithAll<PlayerTag>())
|
SystemAPI.Query<DynamicBuffer<StatModifier>, DynamicBuffer<TimedModifier>, RefRW<BoonEffects>, RefRW<BoonOffer>>().WithAll<PlayerTag>())
|
||||||
{
|
{
|
||||||
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase,
|
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase,
|
||||||
Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan);
|
Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan);
|
||||||
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.PrepSourceIdBase,
|
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.PrepSourceIdBase,
|
||||||
Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan); // DR-046: strip the run-scoped prep loadout too
|
Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan); // DR-046: strip the run-scoped prep loadout too
|
||||||
|
|
||||||
|
// Phase 1.7: zero the mechanic-changer boons + strip the stale Frenzy timed row (its paired
|
||||||
|
// StatModifier is already cleared by the boon-band range-strip above).
|
||||||
|
fx.ValueRW = default;
|
||||||
|
TimedModifierUtil.RemoveBySourceId(timed, Tuning.FrenzySourceId);
|
||||||
offer.ValueRW = default;
|
offer.ValueRW = default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ namespace ProjectM.Simulation
|
|||||||
/// snapshotted into the spawned Projectile, so the downstream move/damage systems are unchanged and
|
/// snapshotted into the spawned Projectile, so the downstream move/damage systems are unchanged and
|
||||||
/// predicted + server projectiles match (both folded the same replicated modifiers).
|
/// predicted + server projectiles match (both folded the same replicated modifiers).
|
||||||
///
|
///
|
||||||
|
/// Phase 1.7 mechanic-changer boons ride the owner-replicated <see cref="BoonEffects"/> (SendToOwner, so the
|
||||||
|
/// predicting owner has it — the .WithAll<Simulate>() filter means only the owner's own player is processed
|
||||||
|
/// client-side), read via a ComponentLookup keyed by the player (the query is already at the 7-type SystemAPI
|
||||||
|
/// limit). FORK fans <c>Fork</c> extra predicted projectiles in a symmetric spread, each with a UNIQUE
|
||||||
|
/// deterministic SpawnId (fork index packed into the low bits) so classification predicts each. PIERCE/CHAIN/PULL
|
||||||
|
/// are seeded into the server-only <see cref="ProjectileEffectState"/> at spawn (resolved by ProjectileDamageSystem).
|
||||||
|
///
|
||||||
/// Determinism / idempotency: the prediction loop re-runs this system on rollback, so all
|
/// Determinism / idempotency: the prediction loop re-runs this system on rollback, so all
|
||||||
/// non-idempotent effects (spawning, cooldown advance) are gated behind
|
/// non-idempotent effects (spawning, cooldown advance) are gated behind
|
||||||
/// NetworkTime.IsFirstTimeFullyPredictingTick so they happen exactly once per tick. The absolute
|
/// NetworkTime.IsFirstTimeFullyPredictingTick so they happen exactly once per tick. The absolute
|
||||||
@@ -40,6 +47,11 @@ namespace ProjectM.Simulation
|
|||||||
// C3/A4: knockback stamp for the Warrior CONE (guarded HasComponent + boss-immune). Server-only use.
|
// C3/A4: knockback stamp for the Warrior CONE (guarded HasComponent + boss-immune). Server-only use.
|
||||||
ComponentLookup<KnockbackState> m_KnockbackLookup;
|
ComponentLookup<KnockbackState> m_KnockbackLookup;
|
||||||
ComponentLookup<BossState> m_BossLookup;
|
ComponentLookup<BossState> m_BossLookup;
|
||||||
|
// Phase 1.7: owner-replicated mechanic-changer boons, read by the player entity (query is at the 7-type cap).
|
||||||
|
ComponentLookup<BoonEffects> m_BoonEffectsLookup;
|
||||||
|
|
||||||
|
/// <summary>~9° gap between adjacent Split-Shot projectiles (tunable).</summary>
|
||||||
|
const float k_ForkSpreadRad = 0.157f;
|
||||||
|
|
||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
public void OnCreate(ref SystemState state)
|
public void OnCreate(ref SystemState state)
|
||||||
@@ -48,6 +60,7 @@ namespace ProjectM.Simulation
|
|||||||
state.RequireForUpdate<NetworkTime>();
|
state.RequireForUpdate<NetworkTime>();
|
||||||
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
|
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
|
||||||
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
|
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
|
||||||
|
m_BoonEffectsLookup = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
@@ -71,6 +84,7 @@ namespace ProjectM.Simulation
|
|||||||
bool isServer = state.WorldUnmanaged.IsServer();
|
bool isServer = state.WorldUnmanaged.IsServer();
|
||||||
m_KnockbackLookup.Update(ref state);
|
m_KnockbackLookup.Update(ref state);
|
||||||
m_BossLookup.Update(ref state);
|
m_BossLookup.Update(ref state);
|
||||||
|
m_BoonEffectsLookup.Update(ref state);
|
||||||
|
|
||||||
// Server-only target set (LIVING enemies/dummies), collected once: positions feed the gamepad
|
// Server-only target set (LIVING enemies/dummies), collected once: positions feed the gamepad
|
||||||
// auto-target assist, and entities+positions feed the Warrior CONE archetype's server-only cleave.
|
// auto-target assist, and entities+positions feed the Warrior CONE archetype's server-only cleave.
|
||||||
@@ -113,6 +127,10 @@ namespace ProjectM.Simulation
|
|||||||
continue; // still cooling down
|
continue; // still cooling down
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 1.7 mechanic-changer boons (owner-replicated; read by entity — see class doc for the 7-type cap).
|
||||||
|
BoonEffects bfx = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity] : default;
|
||||||
|
bool pull = (bfx.Flags & BoonFlag.KnockToPull) != 0;
|
||||||
|
|
||||||
// MC-4 spike for MC-6: dispatch on the authored ability ARCHETYPE byte (baked in the blob, read here -- NOT
|
// MC-4 spike for MC-6: dispatch on the authored ability ARCHETYPE byte (baked in the blob, read here -- NOT
|
||||||
// folded through EffectiveAbilityStats; it is static identity, not a tunable stat). All current
|
// folded through EffectiveAbilityStats; it is static identity, not a tunable stat). All current
|
||||||
// abilities are Projectile (0); hitscan/cone/aoe archetypes plug in at this point in MC-6.
|
// abilities are Projectile (0); hitscan/cone/aoe archetypes plug in at this point in MC-6.
|
||||||
@@ -143,9 +161,10 @@ namespace ProjectM.Simulation
|
|||||||
});
|
});
|
||||||
// C3: the cone reads as weak vs the melee cleave without knockback — stamp it like melee
|
// C3: the cone reads as weak vs the melee cleave without knockback — stamp it like melee
|
||||||
// (guarded: dummies lack KnockbackState → ECB throw; the boss is knockback-immune, A4).
|
// (guarded: dummies lack KnockbackState → ECB throw; the boss is knockback-immune, A4).
|
||||||
|
// Phase 1.7 Gravity Pull: drag toward the player instead of away.
|
||||||
KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, coneTargets[ci],
|
KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, coneTargets[ci],
|
||||||
xform.ValueRO.Position, coneTargetPos[ci], cFace, Tuning.KnockbackSpeed,
|
xform.ValueRO.Position, coneTargetPos[ci], cFace, Tuning.KnockbackSpeed,
|
||||||
TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)));
|
TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), pull);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
uint coneCd = (uint)math.max(1, eff.ValueRO.CooldownTicks);
|
uint coneCd = (uint)math.max(1, eff.ValueRO.CooldownTicks);
|
||||||
@@ -195,11 +214,25 @@ namespace ProjectM.Simulation
|
|||||||
candidates);
|
candidates);
|
||||||
}
|
}
|
||||||
|
|
||||||
uint spawnId = (uint)owner.ValueRO.NetworkId << 16 | absoluteFireCount;
|
// Phase 1.7 mechanic-changer seeds. Fork fans (1 + Fork) shots in a symmetric spread; each carries the
|
||||||
|
// pierce/chain/pull state into its server-only ProjectileEffectState.
|
||||||
|
byte pierce = bfx.Pierce;
|
||||||
|
byte chain = bfx.Chain;
|
||||||
|
byte projFlags = pull ? ProjectileEffectFlag.Pull : (byte)0;
|
||||||
|
int shots = 1 + math.min((int)bfx.Fork, 8); // cap forks: forkIndex is 4 spawnId bits (no wrap) + a sane spread ceiling
|
||||||
|
|
||||||
|
for (int s = 0; s < shots; s++)
|
||||||
|
{
|
||||||
|
// Symmetric fan around the (assisted) aim heading; s=0 is centred when there is no fork.
|
||||||
|
float offset = (s - (shots - 1) * 0.5f) * k_ForkSpreadRad;
|
||||||
|
math.sincos(offset, out float sa, out float ca);
|
||||||
|
float2 sdir = math.normalize(new float2(dir.x * ca - dir.y * sa, dir.x * sa + dir.y * ca));
|
||||||
|
|
||||||
|
// Unique deterministic classification key: owner(16) | fireCount(12) | forkIndex(4).
|
||||||
|
uint spawnId = (((uint)owner.ValueRO.NetworkId) << 16) | ((absoluteFireCount & 0x0FFFu) << 4) | (uint)(s & 0xF);
|
||||||
|
|
||||||
var projectile = ecb.Instantiate(prefab);
|
var projectile = ecb.Instantiate(prefab);
|
||||||
|
float3 planarDir = new float3(sdir.x, 0f, sdir.y);
|
||||||
float3 planarDir = new float3(dir.x, 0f, dir.y);
|
|
||||||
float3 spawnPos = xform.ValueRO.Position + planarDir * 0.6f;
|
float3 spawnPos = xform.ValueRO.Position + planarDir * 0.6f;
|
||||||
spawnPos.y = xform.ValueRO.Position.y;
|
spawnPos.y = xform.ValueRO.Position.y;
|
||||||
quaternion rot = quaternion.LookRotationSafe(planarDir, math.up());
|
quaternion rot = quaternion.LookRotationSafe(planarDir, math.up());
|
||||||
@@ -210,13 +243,21 @@ namespace ProjectM.Simulation
|
|||||||
// identically on both worlds), so the move/damage systems need no modifier lookup.
|
// identically on both worlds), so the move/damage systems need no modifier lookup.
|
||||||
ecb.SetComponent(projectile, new Projectile
|
ecb.SetComponent(projectile, new Projectile
|
||||||
{
|
{
|
||||||
Direction = math.normalize(dir),
|
Direction = sdir,
|
||||||
SpawnId = spawnId,
|
SpawnId = spawnId,
|
||||||
Speed = eff.ValueRO.ProjectileSpeed,
|
Speed = eff.ValueRO.ProjectileSpeed,
|
||||||
Damage = eff.ValueRO.Damage,
|
Damage = eff.ValueRO.Damage,
|
||||||
Range = eff.ValueRO.Range,
|
Range = eff.ValueRO.Range,
|
||||||
DistanceTravelled = 0f,
|
DistanceTravelled = 0f,
|
||||||
});
|
});
|
||||||
|
// Server-only pierce/chain/pull seed (baked inert on the prefab; harmless on the client copy).
|
||||||
|
ecb.SetComponent(projectile, new ProjectileEffectState
|
||||||
|
{
|
||||||
|
PierceRemaining = pierce,
|
||||||
|
ChainRemaining = chain,
|
||||||
|
Flags = projFlags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Earliest raw tick the player may fire again. Clamp cooldown to >= 1 tick.
|
// Earliest raw tick the player may fire again. Clamp cooldown to >= 1 tick.
|
||||||
uint cooldownTicks = (uint)math.max(1, eff.ValueRO.CooldownTicks);
|
uint cooldownTicks = (uint)math.max(1, eff.ValueRO.CooldownTicks);
|
||||||
|
|||||||
@@ -4,25 +4,48 @@ using Unity.Entities;
|
|||||||
namespace ProjectM.Simulation
|
namespace ProjectM.Simulation
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One authored boon in the catalog blob: a thin wrapper over the existing stat pipeline —
|
/// One authored boon in the catalog blob. A boon is EITHER a flat-stat modifier (<see cref="Kind"/>==0 —
|
||||||
/// <see cref="Target"/>/<see cref="Op"/>/<see cref="Value"/> map 1:1 onto a <see cref="StatModifier"/> row
|
/// <see cref="Target"/>/<see cref="Op"/>/<see cref="Value"/> map 1:1 onto a <see cref="StatModifier"/> row, the
|
||||||
/// (bytes, never enums, on the baked path). <see cref="Id"/> is the stable APPEND-ONLY key the replicated
|
/// original path) OR a Phase-1.7 MECHANIC-CHANGER (<see cref="Kind"/>==1 — <see cref="EffectKind"/> selects the
|
||||||
/// <c>BoonOffer</c> options and pick RPC carry. <see cref="Weight"/> is the rarity draw weight
|
/// hook; for the stacking kinds Pierce/Fork/Chain <see cref="Value"/> is the per-pick count delta, else it's a
|
||||||
/// (common 100 / rare 30 / epic 10). <see cref="ClassMask"/> gates by class: bit0 = Warrior (classId 0),
|
/// flag). Bytes, never enums, on the baked path. <see cref="Id"/> is the stable key the replicated
|
||||||
/// bit1 = Ranger (classId 1), 3 = both.
|
/// <c>BoonOffer</c> options + pick RPC carry. <see cref="Weight"/> is the rarity draw weight (common 100 /
|
||||||
|
/// uncommon 60 / rare 30 / epic 10). <see cref="ClassMask"/>: bit0 = Warrior (classId 0), bit1 = Ranger
|
||||||
|
/// (classId 1), 3 = both. <see cref="Family"/> tags synergy/dedup — no two same-family options in one deal.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public struct BoonDefBlob
|
public struct BoonDefBlob
|
||||||
{
|
{
|
||||||
public byte Id;
|
public byte Id;
|
||||||
public byte Target; // StatTarget as byte
|
public byte Target; // StatTarget as byte (Kind==0)
|
||||||
public byte Op; // ModOp as byte
|
public byte Op; // ModOp as byte (Kind==0)
|
||||||
public float Value;
|
public float Value; // Kind==0: modifier magnitude; Kind==1 stacking: per-pick count delta
|
||||||
public byte Weight;
|
public byte Weight;
|
||||||
public byte ClassMask;
|
public byte ClassMask;
|
||||||
|
public byte Kind; // 0 = stat, 1 = mechanic-changer (Phase 1.7)
|
||||||
|
public byte EffectKind; // BoonEffectKind byte (Kind==1)
|
||||||
|
public byte Family; // BoonFamily byte — dedup/dominated/bias tag
|
||||||
public FixedString64Bytes Name;
|
public FixedString64Bytes Name;
|
||||||
public FixedString128Bytes Desc;
|
public FixedString128Bytes Desc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synergy/dedup tags for <see cref="BoonDefBlob.Family"/>. Bytes (Burst-safe). No two options of the same
|
||||||
|
/// family are offered in one deal (dominated-offer protection — kills the "-15% vs -25% cooldown" case); owning
|
||||||
|
/// a mechanic family biases future offers toward it (light build-bias).
|
||||||
|
/// </summary>
|
||||||
|
public static class BoonFamily
|
||||||
|
{
|
||||||
|
public const byte None = 0;
|
||||||
|
public const byte Projectile = 1;
|
||||||
|
public const byte Melee = 2;
|
||||||
|
public const byte Mobility = 3;
|
||||||
|
public const byte OnKill = 4;
|
||||||
|
public const byte StatDamage = 5;
|
||||||
|
public const byte StatHealth = 6;
|
||||||
|
public const byte StatSpeed = 7;
|
||||||
|
public const byte StatCooldown = 8;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The baked boon pool (config blob, both worlds, NOT replicated — the AbilityDatabase pattern).</summary>
|
/// <summary>The baked boon pool (config blob, both worlds, NOT replicated — the AbilityDatabase pattern).</summary>
|
||||||
public struct BoonCatalogBlob
|
public struct BoonCatalogBlob
|
||||||
{
|
{
|
||||||
@@ -45,8 +68,10 @@ namespace ProjectM.Simulation
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pure, deterministic boon selection math — integer-hash only (<see cref="RunMapMath.Hash(uint,uint)"/> chain,
|
/// Pure, deterministic boon selection math — integer-hash only (<c>RunMapMath.Hash</c> chain, no RNG state), so
|
||||||
/// no RNG state), so an offer is a reproducible function of (runSeed, room, player). EditMode-tested.
|
/// an offer is a reproducible function of (runSeed, room, player, ownedState-at-draw). The owned-state input is
|
||||||
|
/// safe because <c>BoonOfferSystem</c> draws each player exactly ONCE per RoomEpoch (the OfferedRoomEpoch latch)
|
||||||
|
/// — the client never re-runs it. EditMode-tested.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BoonMath
|
public static class BoonMath
|
||||||
{
|
{
|
||||||
@@ -54,24 +79,35 @@ namespace ProjectM.Simulation
|
|||||||
public static byte MaskFor(byte classId) => (byte)(1 << (classId & 1));
|
public static byte MaskFor(byte classId) => (byte)(1 << (classId & 1));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Draw 3 DISTINCT, rarity-weighted, class-filtered boon ids from the pool. Deterministic per
|
/// Draw up to 3 DISTINCT, rarity-weighted, class-filtered boon ids from the pool. Deterministic per
|
||||||
/// <paramref name="offerSeed"/>. If the class-legal pool has fewer than 3 entries the tail repeats the
|
/// (<paramref name="offerSeed"/>, <paramref name="owned"/>). Phase 1.7: a non-stacking FLAG effect the player
|
||||||
/// last-drawn candidates (a catalog authoring smell, not a crash). Returns the number of distinct ids.
|
/// already owns is excluded (dedup); no two options share a <see cref="BoonFamily"/> in one deal
|
||||||
|
/// (dominated-offer protection); a candidate whose family matches an owned effect's family draws at ×1.5
|
||||||
|
/// weight (light build-bias). Falls back deterministically when draws collide. Returns the number of
|
||||||
|
/// distinct ids (tail repeats the last when the legal pool has fewer than 3).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static int PickBoons(uint offerSeed, byte classId, ref BoonCatalogBlob pool,
|
public static int PickBoons(uint offerSeed, byte classId, in BoonEffects owned, ref BoonCatalogBlob pool,
|
||||||
out byte o0, out byte o1, out byte o2)
|
out byte o0, out byte o1, out byte o2)
|
||||||
{
|
{
|
||||||
byte classBit = MaskFor(classId);
|
byte classBit = MaskFor(classId);
|
||||||
|
int ownedFamilies = OwnedFamilyMask(owned);
|
||||||
|
|
||||||
// Class-legal candidate indices + the total weight.
|
var candidates = new FixedList128Bytes<byte>(); // catalog indices
|
||||||
var candidates = new FixedList128Bytes<byte>();
|
var weights = new FixedList128Bytes<byte>(); // biased draw weight per candidate (parallel)
|
||||||
int totalWeight = 0;
|
int totalWeight = 0;
|
||||||
for (int i = 0; i < pool.Defs.Length && candidates.Length < candidates.Capacity; i++)
|
for (int i = 0; i < pool.Defs.Length && candidates.Length < candidates.Capacity; i++)
|
||||||
{
|
{
|
||||||
if ((pool.Defs[i].ClassMask & classBit) == 0) continue;
|
if ((pool.Defs[i].ClassMask & classBit) == 0) continue;
|
||||||
if (pool.Defs[i].Weight == 0) continue;
|
if (pool.Defs[i].Weight == 0) continue;
|
||||||
|
if (IsOwnedFlag(pool.Defs[i], owned)) continue; // non-stacking flag already held → dedup
|
||||||
|
int w = pool.Defs[i].Weight;
|
||||||
|
byte fam = pool.Defs[i].Family;
|
||||||
|
if (fam != 0 && (ownedFamilies & (1 << fam)) != 0)
|
||||||
|
w += w / 2; // ×1.5 build-bias (integer)
|
||||||
|
if (w > 255) w = 255;
|
||||||
candidates.Add((byte)i);
|
candidates.Add((byte)i);
|
||||||
totalWeight += pool.Defs[i].Weight;
|
weights.Add((byte)w);
|
||||||
|
totalWeight += w;
|
||||||
}
|
}
|
||||||
|
|
||||||
o0 = o1 = o2 = 0;
|
o0 = o1 = o2 = 0;
|
||||||
@@ -79,38 +115,39 @@ namespace ProjectM.Simulation
|
|||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
var picked = new FixedList32Bytes<byte>(); // picked catalog indices
|
var picked = new FixedList32Bytes<byte>(); // picked catalog indices
|
||||||
|
var pickedFamilies = new FixedList32Bytes<byte>(); // families used this deal (fam != 0)
|
||||||
uint salt = 0;
|
uint salt = 0;
|
||||||
while (picked.Length < 3 && picked.Length < candidates.Length)
|
while (picked.Length < 3 && picked.Length < candidates.Length)
|
||||||
{
|
{
|
||||||
// Weighted draw with rejection on duplicates (bounded; falls through to a linear fill).
|
|
||||||
uint roll = RunMapMath.Hash(offerSeed, (uint)picked.Length, salt) % (uint)totalWeight;
|
uint roll = RunMapMath.Hash(offerSeed, (uint)picked.Length, salt) % (uint)totalWeight;
|
||||||
byte drawn = candidates[candidates.Length - 1];
|
int chosen = candidates.Length - 1;
|
||||||
int acc = 0;
|
int acc = 0;
|
||||||
for (int c = 0; c < candidates.Length; c++)
|
for (int c = 0; c < candidates.Length; c++)
|
||||||
{
|
{
|
||||||
acc += pool.Defs[candidates[c]].Weight;
|
acc += weights[c];
|
||||||
if (roll < (uint)acc) { drawn = candidates[c]; break; }
|
if (roll < (uint)acc) { chosen = c; break; }
|
||||||
}
|
}
|
||||||
|
byte drawn = candidates[chosen];
|
||||||
|
byte fam = pool.Defs[drawn].Family;
|
||||||
|
|
||||||
bool dup = false;
|
bool dup = false;
|
||||||
for (int p = 0; p < picked.Length; p++)
|
for (int p = 0; p < picked.Length; p++)
|
||||||
if (picked[p] == drawn) { dup = true; break; }
|
if (picked[p] == drawn) { dup = true; break; }
|
||||||
|
bool famClash = false;
|
||||||
|
if (!dup && fam != 0)
|
||||||
|
for (int p = 0; p < pickedFamilies.Length; p++)
|
||||||
|
if (pickedFamilies[p] == fam) { famClash = true; break; }
|
||||||
|
|
||||||
if (!dup)
|
if (!dup && !famClash)
|
||||||
{
|
{
|
||||||
picked.Add(drawn);
|
picked.Add(drawn);
|
||||||
|
if (fam != 0) pickedFamilies.Add(fam);
|
||||||
salt = 0;
|
salt = 0;
|
||||||
}
|
}
|
||||||
else if (++salt > 16)
|
else if (++salt > 16)
|
||||||
{
|
{
|
||||||
// Rejection budget spent — take the first unpicked candidate (still deterministic).
|
// Rejection budget spent — deterministic linear fill (first unused, family-distinct if possible).
|
||||||
for (int c = 0; c < candidates.Length; c++)
|
AddFallback(ref picked, ref pickedFamilies, candidates, ref pool);
|
||||||
{
|
|
||||||
bool used = false;
|
|
||||||
for (int p = 0; p < picked.Length; p++)
|
|
||||||
if (picked[p] == candidates[c]) { used = true; break; }
|
|
||||||
if (!used) { picked.Add(candidates[c]); break; }
|
|
||||||
}
|
|
||||||
salt = 0;
|
salt = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,6 +158,63 @@ namespace ProjectM.Simulation
|
|||||||
return picked.Length;
|
return picked.Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Deterministic tail-fill when the weighted draw keeps colliding: take the first unused candidate
|
||||||
|
/// that is family-distinct from the deal; if none, the first unused (family clash tolerated as last resort so
|
||||||
|
/// the deal never wedges below 3 while candidates remain).</summary>
|
||||||
|
static void AddFallback(ref FixedList32Bytes<byte> picked, ref FixedList32Bytes<byte> pickedFamilies,
|
||||||
|
in FixedList128Bytes<byte> candidates, ref BoonCatalogBlob pool)
|
||||||
|
{
|
||||||
|
int firstUnused = -1;
|
||||||
|
for (int c = 0; c < candidates.Length; c++)
|
||||||
|
{
|
||||||
|
byte cand = candidates[c];
|
||||||
|
bool used = false;
|
||||||
|
for (int p = 0; p < picked.Length; p++)
|
||||||
|
if (picked[p] == cand) { used = true; break; }
|
||||||
|
if (used) continue;
|
||||||
|
if (firstUnused < 0) firstUnused = cand;
|
||||||
|
byte cfam = pool.Defs[cand].Family;
|
||||||
|
bool clash = false;
|
||||||
|
if (cfam != 0)
|
||||||
|
for (int p = 0; p < pickedFamilies.Length; p++)
|
||||||
|
if (pickedFamilies[p] == cfam) { clash = true; break; }
|
||||||
|
if (clash) continue;
|
||||||
|
picked.Add(cand);
|
||||||
|
if (cfam != 0) pickedFamilies.Add(cfam);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (firstUnused >= 0)
|
||||||
|
picked.Add((byte)firstUnused);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when a candidate is a non-stacking FLAG effect the player already owns (dedup). Pierce/Fork/
|
||||||
|
/// Chain STACK, so they're never excluded. Byte switch — Burst-safe.</summary>
|
||||||
|
static bool IsOwnedFlag(in BoonDefBlob d, in BoonEffects owned)
|
||||||
|
{
|
||||||
|
if (d.Kind != 1) return false;
|
||||||
|
switch (d.EffectKind)
|
||||||
|
{
|
||||||
|
case BoonEffectKind.DashTrail: return (owned.Flags & BoonFlag.DashTrail) != 0;
|
||||||
|
case BoonEffectKind.FinisherDetonate: return (owned.Flags & BoonFlag.FinisherDetonate) != 0;
|
||||||
|
case BoonEffectKind.KnockToPull: return (owned.Flags & BoonFlag.KnockToPull) != 0;
|
||||||
|
case BoonEffectKind.Siphon: return (owned.Flags & BoonFlag.Siphon) != 0;
|
||||||
|
case BoonEffectKind.Frenzy: return (owned.Flags & BoonFlag.Frenzy) != 0;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bitmask (indexed by <see cref="BoonFamily"/> value) of the MECHANIC families the player owns —
|
||||||
|
/// drives the ×1.5 build-bias. Stat families are never marked (build-bias is mechanic-synergy only).</summary>
|
||||||
|
static int OwnedFamilyMask(in BoonEffects owned)
|
||||||
|
{
|
||||||
|
int m = 0;
|
||||||
|
if (owned.Pierce != 0 || owned.Fork != 0 || owned.Chain != 0) m |= 1 << BoonFamily.Projectile;
|
||||||
|
if ((owned.Flags & (BoonFlag.FinisherDetonate | BoonFlag.KnockToPull)) != 0) m |= 1 << BoonFamily.Melee;
|
||||||
|
if ((owned.Flags & BoonFlag.DashTrail) != 0) m |= 1 << BoonFamily.Mobility;
|
||||||
|
if ((owned.Flags & (BoonFlag.Siphon | BoonFlag.Frenzy)) != 0) m |= 1 << BoonFamily.OnKill;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Find a def index by its stable id (-1 when absent — callers preserve-and-skip unknown ids).</summary>
|
/// <summary>Find a def index by its stable id (-1 when absent — callers preserve-and-skip unknown ids).</summary>
|
||||||
public static int FindDef(ref BoonCatalogBlob pool, byte id)
|
public static int FindDef(ref BoonCatalogBlob pool, byte id)
|
||||||
{
|
{
|
||||||
@@ -131,8 +225,9 @@ namespace ProjectM.Simulation
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The DEFAULT v1 boon table + the blob builder the baker AND EditMode tests share (single source — the
|
/// The DEFAULT Phase-1.7 boon table + the blob builder the baker AND EditMode tests share — 8 mechanic-changers
|
||||||
/// authoring bakes this table verbatim when its designer-row list is empty). Append-only ids.
|
/// (<see cref="BoonDefBlob.Kind"/>==1) + 4 strong flat-stat boons (Kind==0). Ids are within-session stable (both
|
||||||
|
/// worlds bake the same code; boons never persist across saves — stripped on the Returning edge).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BoonCatalogData
|
public static class BoonCatalogData
|
||||||
{
|
{
|
||||||
@@ -143,25 +238,28 @@ namespace ProjectM.Simulation
|
|||||||
ref var root = ref builder.ConstructRoot<BoonCatalogBlob>();
|
ref var root = ref builder.ConstructRoot<BoonCatalogBlob>();
|
||||||
var defs = builder.Allocate(ref root.Defs, 12);
|
var defs = builder.Allocate(ref root.Defs, 12);
|
||||||
int i = 0;
|
int i = 0;
|
||||||
// id, target, op, value, weight, mask(1=Warrior,2=Ranger,3=both), name, desc
|
// ---- 8 mechanic-changers (Kind=1). mask: 1=Warrior, 2=Ranger, 3=both. Projectile boons are Ranger-only
|
||||||
defs[i++] = Make(1, StatTarget.Damage, ModOp.PercentAdd, 0.20f, 100, 3, "Honed Edge", "+20% ability damage");
|
// (the Warrior's Fire is a cone, not a projectile). ----
|
||||||
defs[i++] = Make(2, StatTarget.CooldownTicks, ModOp.PercentMult, -0.15f, 100, 3, "Swift Hands", "-15% ability cooldown");
|
defs[i++] = Effect(1, BoonEffectKind.Pierce, 1f, 100, 2, BoonFamily.Projectile, "Piercing Shots", "Your shots pierce +1 enemy");
|
||||||
defs[i++] = Make(3, StatTarget.Range, ModOp.PercentAdd, 0.25f, 100, 2, "Long Reach", "+25% projectile range");
|
defs[i++] = Effect(2, BoonEffectKind.Fork, 1f, 60, 2, BoonFamily.Projectile, "Split Shot", "Fire +1 extra shot in a spread");
|
||||||
defs[i++] = Make(4, StatTarget.MoveSpeed, ModOp.PercentAdd, 0.12f, 100, 3, "Fleet Foot", "+12% move speed");
|
defs[i++] = Effect(3, BoonEffectKind.Chain, 1f, 60, 2, BoonFamily.Projectile, "Ricochet", "Your shots chain to +1 nearby enemy");
|
||||||
defs[i++] = Make(5, StatTarget.MaxHealth, ModOp.Flat, 25f, 100, 3, "Iron Constitution", "+25 max health");
|
defs[i++] = Effect(4, BoonEffectKind.FinisherDetonate, 0f, 60, 1, BoonFamily.Melee, "Detonating Finisher", "Your combo finisher blasts an AoE");
|
||||||
defs[i++] = Make(6, StatTarget.MeleeDamage, ModOp.PercentAdd, 0.25f, 100, 1, "Heavy Blows", "+25% melee damage");
|
defs[i++] = Effect(5, BoonEffectKind.DashTrail, 0f, 100, 3, BoonFamily.Mobility, "Blade Dash", "Dashing damages enemies you pass through");
|
||||||
defs[i++] = Make(7, StatTarget.MeleeRange, ModOp.PercentAdd, 0.20f, 60, 1, "Extended Haft", "+20% melee reach");
|
defs[i++] = Effect(6, BoonEffectKind.KnockToPull, 0f, 30, 3, BoonFamily.Melee, "Gravity Pull", "Your knockback drags enemies IN");
|
||||||
defs[i++] = Make(8, StatTarget.ProjectileSpeed, ModOp.PercentAdd, 0.25f, 60, 2, "Swift Bolts", "+25% projectile speed");
|
defs[i++] = Effect(7, BoonEffectKind.Siphon, 0f, 60, 3, BoonFamily.OnKill, "Siphon", "Killing an enemy heals you");
|
||||||
defs[i++] = Make(9, StatTarget.AutoTargetRange, ModOp.PercentAdd, 0.20f, 60, 3, "Keen Instinct", "+20% auto-target range");
|
defs[i++] = Effect(8, BoonEffectKind.Frenzy, 0f, 30, 3, BoonFamily.OnKill, "Frenzy", "A kill briefly speeds your abilities");
|
||||||
defs[i++] = Make(10, StatTarget.CooldownTicks, ModOp.PercentMult, -0.25f, 30, 3, "Berserker's Pace", "-25% ability cooldown");
|
// ---- 4 strong flat-stat boons (Kind=0) ----
|
||||||
defs[i++] = Make(11, StatTarget.MaxHealth, ModOp.Flat, 60f, 30, 3, "Titan's Vigor", "+60 max health");
|
defs[i++] = Stat(9, StatTarget.Damage, ModOp.PercentAdd, 0.50f, 30, 3, BoonFamily.StatDamage, "Executioner", "+50% ability damage");
|
||||||
defs[i++] = Make(12, StatTarget.Damage, ModOp.PercentAdd, 0.50f, 10, 3, "Executioner", "+50% ability damage");
|
defs[i++] = Stat(10, StatTarget.MaxHealth, ModOp.Flat, 60f, 100, 3, BoonFamily.StatHealth, "Titan's Vigor", "+60 max health");
|
||||||
|
defs[i++] = Stat(11, StatTarget.MoveSpeed, ModOp.PercentAdd, 0.18f, 100, 3, BoonFamily.StatSpeed, "Fleet Foot", "+18% move speed");
|
||||||
|
defs[i++] = Stat(12, StatTarget.CooldownTicks, ModOp.PercentMult, -0.25f, 60, 3, BoonFamily.StatCooldown, "Berserker's Pace", "-25% ability cooldown");
|
||||||
var blob = builder.CreateBlobAssetReference<BoonCatalogBlob>(allocator);
|
var blob = builder.CreateBlobAssetReference<BoonCatalogBlob>(allocator);
|
||||||
builder.Dispose();
|
builder.Dispose();
|
||||||
return blob;
|
return blob;
|
||||||
}
|
}
|
||||||
|
|
||||||
static BoonDefBlob Make(byte id, StatTarget target, ModOp op, float value, byte weight, byte mask,
|
/// <summary>A flat-stat boon row (Kind=0 — appends a <see cref="StatModifier"/>).</summary>
|
||||||
|
static BoonDefBlob Stat(byte id, StatTarget target, ModOp op, float value, byte weight, byte mask, byte family,
|
||||||
string name, string desc)
|
string name, string desc)
|
||||||
{
|
{
|
||||||
return new BoonDefBlob
|
return new BoonDefBlob
|
||||||
@@ -172,6 +270,30 @@ namespace ProjectM.Simulation
|
|||||||
Value = value,
|
Value = value,
|
||||||
Weight = weight,
|
Weight = weight,
|
||||||
ClassMask = mask,
|
ClassMask = mask,
|
||||||
|
Kind = 0,
|
||||||
|
EffectKind = BoonEffectKind.None,
|
||||||
|
Family = family,
|
||||||
|
Name = new FixedString64Bytes(name),
|
||||||
|
Desc = new FixedString128Bytes(desc),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A mechanic-changer boon row (Kind=1 — mutates <see cref="BoonEffects"/>). <paramref name="value"/>
|
||||||
|
/// is the stacking count delta for Pierce/Fork/Chain (usually 1), ignored for flag effects.</summary>
|
||||||
|
static BoonDefBlob Effect(byte id, byte effectKind, float value, byte weight, byte mask, byte family,
|
||||||
|
string name, string desc)
|
||||||
|
{
|
||||||
|
return new BoonDefBlob
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Target = 0,
|
||||||
|
Op = 0,
|
||||||
|
Value = value,
|
||||||
|
Weight = weight,
|
||||||
|
ClassMask = mask,
|
||||||
|
Kind = 1,
|
||||||
|
EffectKind = effectKind,
|
||||||
|
Family = family,
|
||||||
Name = new FixedString64Bytes(name),
|
Name = new FixedString64Bytes(name),
|
||||||
Desc = new FixedString128Bytes(desc),
|
Desc = new FixedString128Bytes(desc),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 1.7 mechanic-changer boon state on a player — the run-scoped counterpart to the flat-stat
|
||||||
|
/// <see cref="StatModifier"/> band. Stackable counts (<see cref="Pierce"/>/<see cref="Fork"/>/<see cref="Chain"/>)
|
||||||
|
/// and boolean <see cref="Flags"/> (see <see cref="BoonFlag"/>) that combat systems read to alter behaviour.
|
||||||
|
/// <para>
|
||||||
|
/// Replicated <see cref="SendToOwnerType.SendToOwner"/> (matching <c>BoonOffer</c>): rollback-correctness is
|
||||||
|
/// provided by the <c>[GhostField]</c>s themselves — the owner is the sole predicting client and needs the
|
||||||
|
/// replicated Fork/Pierce/Chain so its OWN predict-spawned projectiles (in <c>AbilityFireSystem</c>, which
|
||||||
|
/// filters <c>.WithAll<Simulate>()</c>) don't mispredict. Non-owning clients render forked/pierced/chained
|
||||||
|
/// projectiles as interpolated server ghosts and never read the shooter's effects; every other read is
|
||||||
|
/// server-only. NOT <see cref="SendToOwnerType.All"/> — the send type is not what enables rollback, the
|
||||||
|
/// <c>[GhostField]</c> is.
|
||||||
|
/// </para>
|
||||||
|
/// Baked INERT (all 0) on the player prefab (the <c>BoonOffer</c> idiom) so a pick is a non-structural mutate;
|
||||||
|
/// zeroed on the Returning edge in <c>RunDirectorSystem</c> alongside the StatModifier band strips.
|
||||||
|
/// </summary>
|
||||||
|
[GhostComponent(OwnerSendType = SendToOwnerType.SendToOwner)]
|
||||||
|
public struct BoonEffects : IComponentData
|
||||||
|
{
|
||||||
|
/// <summary>Extra enemy hits a projectile survives before despawning (stacks).</summary>
|
||||||
|
[GhostField] public byte Pierce;
|
||||||
|
/// <summary>Extra spread projectiles spawned per shot (stacks).</summary>
|
||||||
|
[GhostField] public byte Fork;
|
||||||
|
/// <summary>Targets a projectile chains to after a hit (stacks).</summary>
|
||||||
|
[GhostField] public byte Chain;
|
||||||
|
/// <summary>Boolean effect bits — see <see cref="BoonFlag"/>.</summary>
|
||||||
|
[GhostField] public byte Flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bit masks for <see cref="BoonEffects.Flags"/>. Plain byte consts (never an enum compared in Burst).</summary>
|
||||||
|
public static class BoonFlag
|
||||||
|
{
|
||||||
|
public const byte DashTrail = 1; // dashing damages enemies passed through
|
||||||
|
public const byte FinisherDetonate = 2; // the melee combo finisher blasts an AoE
|
||||||
|
public const byte KnockToPull = 4; // this player's knockback pulls enemies IN instead of away
|
||||||
|
public const byte Siphon = 8; // killing an enemy heals this player
|
||||||
|
public const byte Frenzy = 16; // a kill grants a short cooldown-reduction surge
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stable byte discriminator for a <c>BoonDefBlob</c> mechanic-changer effect (0 = a plain stat boon).
|
||||||
|
/// Bytes only — Burst-safe, never an enum compared inside a Bursted system.
|
||||||
|
/// </summary>
|
||||||
|
public static class BoonEffectKind
|
||||||
|
{
|
||||||
|
public const byte None = 0;
|
||||||
|
public const byte Pierce = 1;
|
||||||
|
public const byte Fork = 2;
|
||||||
|
public const byte Chain = 3;
|
||||||
|
public const byte DashTrail = 4;
|
||||||
|
public const byte FinisherDetonate = 5;
|
||||||
|
public const byte KnockToPull = 6;
|
||||||
|
public const byte Siphon = 7;
|
||||||
|
public const byte Frenzy = 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 76c925707efba46478fb9c697d391e0d
|
||||||
@@ -15,5 +15,13 @@ namespace ProjectM.Simulation
|
|||||||
{
|
{
|
||||||
/// <summary>Server tick the corpse despawns (via <c>TickUtil.NonZero</c>; compared via <c>NetworkTick</c>).</summary>
|
/// <summary>Server tick the corpse despawns (via <c>TickUtil.NonZero</c>; compared via <c>NetworkTick</c>).</summary>
|
||||||
public uint UntilTick;
|
public uint UntilTick;
|
||||||
|
|
||||||
|
/// <summary>Phase 1.7: NetworkId of the player credited with the kill (the last player-sourced DamageEvent
|
||||||
|
/// drained this tick), or -1 if none. Read once by <c>KillRewardSystem</c> for on-kill boons (Siphon/Frenzy).</summary>
|
||||||
|
public int KillerNetId;
|
||||||
|
|
||||||
|
/// <summary>Phase 1.7: 0 until <c>KillRewardSystem</c> has granted this corpse's on-kill rewards (idempotent
|
||||||
|
/// value latch — no structural change, no edge-detection).</summary>
|
||||||
|
public byte Rewarded;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ namespace ProjectM.Simulation
|
|||||||
static class KnockbackUtil
|
static class KnockbackUtil
|
||||||
{
|
{
|
||||||
public static void Stamp(ref ComponentLookup<KnockbackState> lookup, in ComponentLookup<BossState> bossLookup,
|
public static void Stamp(ref ComponentLookup<KnockbackState> lookup, in ComponentLookup<BossState> bossLookup,
|
||||||
Entity target, float3 sourcePos, float3 targetPos, float2 faceFallback, float speed, uint untilTick)
|
Entity target, float3 sourcePos, float3 targetPos, float2 faceFallback, float speed, uint untilTick, bool pull = false)
|
||||||
{
|
{
|
||||||
if (!lookup.HasComponent(target) || bossLookup.HasComponent(target))
|
if (!lookup.HasComponent(target) || bossLookup.HasComponent(target))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
float3 delta = targetPos - sourcePos;
|
float3 delta = targetPos - sourcePos;
|
||||||
float2 dir = math.lengthsq(delta.xz) > 1e-6f ? math.normalize(delta.xz) : faceFallback;
|
float2 dir = math.lengthsq(delta.xz) > 1e-6f ? math.normalize(delta.xz) : faceFallback;
|
||||||
|
if (pull) dir = -dir; // Phase 1.7 Gravity Pull: drag the target TOWARD the attacker
|
||||||
lookup[target] = new KnockbackState { Dir = dir, Speed = speed, UntilTick = untilTick };
|
lookup[target] = new KnockbackState { Dir = dir, Speed = speed, UntilTick = untilTick };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Unity.Collections;
|
||||||
|
using Unity.Entities;
|
||||||
|
|
||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 1.7 per-projectile mechanic-changer state — SERVER-ONLY, NOT a <c>[GhostField]</c> (mirrors
|
||||||
|
/// <see cref="KnockbackState"/>): it adds no replicated surface, so the <see cref="Projectile"/> ghost hash
|
||||||
|
/// stays FROZEN (adding fields to the ghost <see cref="Projectile"/> component itself would change its
|
||||||
|
/// StableTypeHash → serializer hash → ghost re-bake; a separate server-only component does not). Baked inert
|
||||||
|
/// on the projectile prefab; seeded server-side at spawn (<c>AbilityFireSystem</c>) from the owner's
|
||||||
|
/// <see cref="BoonEffects"/>, and read only by <c>ProjectileDamageSystem</c> (also server-only) — the owner's
|
||||||
|
/// predicted projectile needs no local copy (pierce = server delays despawn → client reconciles via ghost
|
||||||
|
/// persistence; chain = server rewrites the replicated <see cref="Projectile.Direction"/>; pull just flips the
|
||||||
|
/// server-stamped <see cref="KnockbackState.Dir"/>).
|
||||||
|
/// </summary>
|
||||||
|
public struct ProjectileEffectState : IComponentData
|
||||||
|
{
|
||||||
|
/// <summary>Enemy hits remaining before the projectile despawns (0 = destroy on next hit).</summary>
|
||||||
|
public byte PierceRemaining;
|
||||||
|
/// <summary>Chain-to-next-target hops remaining after a hit.</summary>
|
||||||
|
public byte ChainRemaining;
|
||||||
|
/// <summary>bit0 = Pull (stamp knockback TOWARD the shooter instead of away).</summary>
|
||||||
|
public byte Flags;
|
||||||
|
/// <summary>Targets already hit by this projectile — excluded DURING target selection so a surviving
|
||||||
|
/// (pierced/chained) projectile never re-hits the same enemy across ticks. Overflow ⇒ destroy (natural cap).</summary>
|
||||||
|
public FixedList64Bytes<Entity> Hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bit masks for <see cref="ProjectileEffectState.Flags"/>.</summary>
|
||||||
|
public static class ProjectileEffectFlag
|
||||||
|
{
|
||||||
|
public const byte Pull = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b54e702af1501ec408b0b09e23853f63
|
||||||
@@ -42,5 +42,32 @@ namespace ProjectM.Simulation
|
|||||||
if (mods[j].SourceId >= lo && mods[j].SourceId < hiExclusive) { mods.RemoveAtSwapBack(j); removed++; }
|
if (mods[j].SourceId >= lo && mods[j].SourceId < hiExclusive) { mods.RemoveAtSwapBack(j); removed++; }
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Phase 1.7: guarantee EXACTLY ONE row per <paramref name="sourceId"/> in BOTH the replicated
|
||||||
|
/// <see cref="StatModifier"/> buffer and this server-only <see cref="TimedModifier"/> buffer (remove-then-add) so a
|
||||||
|
/// timed buff REFRESHES (re-stamps <paramref name="untilTick"/>) rather than stacking on a repeat grant. Used by
|
||||||
|
/// <c>KillRewardSystem</c> for Frenzy so successive kills extend the surge instead of compounding the modifier.</summary>
|
||||||
|
public static void Upsert(DynamicBuffer<StatModifier> mods, DynamicBuffer<TimedModifier> timed,
|
||||||
|
uint sourceId, byte target, byte op, float value, uint untilTick)
|
||||||
|
{
|
||||||
|
RemoveBySourceId(mods, sourceId);
|
||||||
|
for (int j = timed.Length - 1; j >= 0; j--)
|
||||||
|
if (timed[j].SourceId == sourceId) timed.RemoveAtSwapBack(j);
|
||||||
|
mods.Add(new StatModifier { Target = target, Op = op, Value = value, SourceId = sourceId });
|
||||||
|
timed.Add(new TimedModifier { SourceId = sourceId, UntilTick = untilTick });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Phase 1.7: remove every server-only <see cref="TimedModifier"/> row matching <paramref name="sourceId"/>
|
||||||
|
/// (the paired <see cref="StatModifier"/> is cleared separately — e.g. the Returning boon-band range-strip). This
|
||||||
|
/// closes the cross-run gap where a stale Frenzy timed row could outlive its StatModifier. Returns the count removed.</summary>
|
||||||
|
public static int RemoveBySourceId(DynamicBuffer<TimedModifier> timed, uint sourceId)
|
||||||
|
{
|
||||||
|
int removed = 0;
|
||||||
|
for (int j = timed.Length - 1; j >= 0; j--)
|
||||||
|
if (timed[j].SourceId == sourceId) { timed.RemoveAtSwapBack(j); removed++; }
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Unity.Collections;
|
||||||
|
using Unity.Entities;
|
||||||
|
|
||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 1.7 Blade-Dash bookkeeping — SERVER-ONLY, plain (NOT a <c>[GhostField]</c>, so no ghost-hash impact;
|
||||||
|
/// it piggybacks the <see cref="BoonEffects"/> player re-bake). Keys the per-dash "hit once" dedup to
|
||||||
|
/// <see cref="DashState.StartTick"/> (which is <c>TickUtil.NonZero(now)</c> on every dash and cannot be relied
|
||||||
|
/// upon to reset) rather than to any DashState clear edge: <c>DashTrailDamageSystem</c> clears <see cref="Hit"/>
|
||||||
|
/// whenever the current StartTick differs from <see cref="LastStartTick"/>. Server-only (no rollback) so the
|
||||||
|
/// accumulator is safe to persist across ticks.
|
||||||
|
/// </summary>
|
||||||
|
public struct DashTrailState : IComponentData
|
||||||
|
{
|
||||||
|
/// <summary>The <see cref="DashState.StartTick"/> the <see cref="Hit"/> set currently belongs to.</summary>
|
||||||
|
public uint LastStartTick;
|
||||||
|
/// <summary>Enemies already struck by the CURRENT dash's trail (one hit per enemy per dash).</summary>
|
||||||
|
public FixedList64Bytes<Entity> Hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 248bd87d96cfc5b43b4681a203756c5c
|
||||||
@@ -20,6 +20,9 @@ namespace ProjectM.Simulation
|
|||||||
public int OwnerId;
|
public int OwnerId;
|
||||||
public uint Stamp;
|
public uint Stamp;
|
||||||
public uint KnockUntil;
|
public uint KnockUntil;
|
||||||
|
public bool IsFinisher; // Phase 1.7: this swing is the combo finisher
|
||||||
|
public bool Detonate; // Phase 1.7: attacker has the FinisherDetonate boon
|
||||||
|
public bool Pull; // Phase 1.7: attacker has the KnockToPull boon
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -54,6 +57,10 @@ namespace ProjectM.Simulation
|
|||||||
ComponentLookup<RegionTag> m_RegionLookup;
|
ComponentLookup<RegionTag> m_RegionLookup;
|
||||||
BufferLookup<InventorySlot> m_InvLookup;
|
BufferLookup<InventorySlot> m_InvLookup;
|
||||||
BufferLookup<StatModifier> m_StatModLookup;
|
BufferLookup<StatModifier> m_StatModLookup;
|
||||||
|
ComponentLookup<BoonEffects> m_BoonEffectsLookup; // Phase 1.7 (player query is at the 7-type cap -> lookup)
|
||||||
|
|
||||||
|
/// <summary>Phase 1.7 Detonating Finisher blast radius (planar, tunable).</summary>
|
||||||
|
const float k_DetonateRadius = 3.5f;
|
||||||
|
|
||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
public void OnCreate(ref SystemState state)
|
public void OnCreate(ref SystemState state)
|
||||||
@@ -64,6 +71,7 @@ namespace ProjectM.Simulation
|
|||||||
m_RegionLookup = state.GetComponentLookup<RegionTag>(isReadOnly: true);
|
m_RegionLookup = state.GetComponentLookup<RegionTag>(isReadOnly: true);
|
||||||
m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false);
|
m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false);
|
||||||
m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true);
|
m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true);
|
||||||
|
m_BoonEffectsLookup = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
|
||||||
state.RequireForUpdate<NetworkTime>();
|
state.RequireForUpdate<NetworkTime>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +101,8 @@ namespace ProjectM.Simulation
|
|||||||
// Server-only queue of cleaves to resolve after the player loop (so enemies are gathered ONCE, and only
|
// 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).
|
// 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;
|
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)
|
m_StatModLookup.Update(ref state);
|
||||||
|
m_BoonEffectsLookup.Update(ref state); // Phase 1.7: per-player boon flags (read inside the player loop)
|
||||||
|
|
||||||
foreach (var (mc, control, input, facing, xform, owner, ds, entity) in
|
foreach (var (mc, control, input, facing, xform, owner, ds, entity) in
|
||||||
SystemAPI.Query<RefRW<MeleeCombo>, RefRW<CharacterControl>, RefRO<PlayerInput>,
|
SystemAPI.Query<RefRW<MeleeCombo>, RefRW<CharacterControl>, RefRO<PlayerInput>,
|
||||||
@@ -147,6 +156,7 @@ namespace ProjectM.Simulation
|
|||||||
bool isFin = swingStep >= comboLen;
|
bool isFin = swingStep >= comboLen;
|
||||||
// Slice 2: fold the player's class/run StatModifiers onto the live-tunable melee base so the
|
// 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.
|
// PRIMARY verb scales with class identity (Warrior +MeleeDamage/+reach) + run augments.
|
||||||
|
byte bflags = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0;
|
||||||
bool hasMods = m_StatModLookup.HasBuffer(entity);
|
bool hasMods = m_StatModLookup.HasBuffer(entity);
|
||||||
float pDamage = math.max(0f, hasMods ? StatMath.Apply(baseDamage, StatTarget.MeleeDamage, m_StatModLookup[entity]) : baseDamage);
|
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);
|
float pRange = math.max(0f, hasMods ? StatMath.Apply(baseRange, StatTarget.MeleeRange, m_StatModLookup[entity]) : baseRange);
|
||||||
@@ -162,6 +172,9 @@ namespace ProjectM.Simulation
|
|||||||
OwnerId = owner.ValueRO.NetworkId,
|
OwnerId = owner.ValueRO.NetworkId,
|
||||||
Stamp = stamp,
|
Stamp = stamp,
|
||||||
KnockUntil = knockUntil,
|
KnockUntil = knockUntil,
|
||||||
|
IsFinisher = isFin,
|
||||||
|
Detonate = (bflags & BoonFlag.FinisherDetonate) != 0,
|
||||||
|
Pull = (bflags & BoonFlag.KnockToPull) != 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,9 +267,31 @@ namespace ProjectM.Simulation
|
|||||||
});
|
});
|
||||||
if (c.KnockSpeed > 0f)
|
if (c.KnockSpeed > 0f)
|
||||||
KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, target,
|
KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, target,
|
||||||
c.From, enemyPositions[i], c.Face, c.KnockSpeed, c.KnockUntil);
|
c.From, enemyPositions[i], c.Face, c.KnockSpeed, c.KnockUntil, c.Pull);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Phase 1.7 Detonating Finisher: a finisher swing with the boon blasts a planar AoE around its
|
||||||
|
// origin (mirrors HazardExplosionSystem). Cone+blast overlap is the normal DamageEvent-summation.
|
||||||
|
for (int s = 0; s < cleaves.Length; s++)
|
||||||
|
{
|
||||||
|
var dc = cleaves[s];
|
||||||
|
if (!dc.IsFinisher || !dc.Detonate)
|
||||||
|
continue;
|
||||||
|
float detRadSq = k_DetonateRadius * k_DetonateRadius;
|
||||||
|
for (int i = 0; i < enemyEntities.Length; i++)
|
||||||
|
{
|
||||||
|
float2 dd = new float2(enemyPositions[i].x - dc.From.x, enemyPositions[i].z - dc.From.z);
|
||||||
|
if (math.lengthsq(dd) > detRadSq)
|
||||||
|
continue;
|
||||||
|
ecb.AppendToBuffer(enemyEntities[i], new DamageEvent
|
||||||
|
{
|
||||||
|
Amount = dc.Damage,
|
||||||
|
SourceNetworkId = dc.OwnerId,
|
||||||
|
SourceTick = dc.Stamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HARVEST: deplete every node/clutter in each swing's cone, crediting the shared ledger; write
|
// 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.
|
// Remaining back so the [GhostField] replicates -> WorldFeedbackSystem chips fire on melee mining.
|
||||||
for (int s = 0; s < cleaves.Length; s++)
|
for (int s = 0; s < cleaves.Length; s++)
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ namespace ProjectM.Simulation
|
|||||||
// inline mods share that one id and are stripped target-agnostically via
|
// inline mods share that one id and are stripped target-agnostically via
|
||||||
// TimedModifierUtil.RemoveBySourceId on unequip/swap. Full StatModifier SourceId map (keep DISJOINT):
|
// TimedModifierUtil.RemoveBySourceId on unequip/swap. Full StatModifier SourceId map (keep DISJOINT):
|
||||||
// 0u = pickups + debug-injection; 0x00A0E711 = ability-damage upgrade; 0x00DEB061 = debug stat command;
|
// 0u = pickups + debug-injection; 0x00A0E711 = ability-damage upgrade; 0x00DEB061 = debug stat command;
|
||||||
// 0x00B00000..0x00B10000 = run-scoped BOONS (stripped on return); 0x00C1A550.. = class traits (permanent);
|
// 0x00B00000..0x00B10000 = run-scoped BOONS (stripped on return; top slot 0x00B0FFFF = Frenzy timed buff); 0x00C1A550.. = class traits (permanent);
|
||||||
// 0x00E7A000..0x00E7A100 = permanent META upgrades (Step 12a); 0x00E91000.. = equipment (4 slots).
|
// 0x00E7A000..0x00E7A100 = permanent META upgrades (Step 12a); 0x00E91000.. = equipment (4 slots).
|
||||||
|
|
||||||
/// <summary>Base of the run-scoped BOON SourceId band: each applied pick draws BoonSourceIdBase +
|
/// <summary>Base of the run-scoped BOON SourceId band: each applied pick draws BoonSourceIdBase +
|
||||||
@@ -219,6 +219,19 @@ namespace ProjectM.Simulation
|
|||||||
/// <summary>Width of the boon band [Base, Base+Span) — far above any realistic per-run pick count.</summary>
|
/// <summary>Width of the boon band [Base, Base+Span) — far above any realistic per-run pick count.</summary>
|
||||||
public const uint BoonSourceIdSpan = 0x10000u;
|
public const uint BoonSourceIdSpan = 0x10000u;
|
||||||
|
|
||||||
|
/// <summary>Phase 1.7: the single Frenzy on-kill timed buff's SourceId, pinned to the TOP of the boon band
|
||||||
|
/// (Base + Span - 1 = 0x00B0FFFF). The per-pick counter allocates from the BOTTOM (Base + counter % Span) and
|
||||||
|
/// cannot reach Span-1 within a run, so it never aliases a pick; the Returning whole-band RemoveBySourceIdRange
|
||||||
|
/// still clears it for free. Refreshed (never stacked) via TimedModifierUtil.Upsert. Do NOT copy the sibling-band
|
||||||
|
/// Base+smallIndex idiom into the boon band — its low offsets are counter-consumed.</summary>
|
||||||
|
public const uint FrenzySourceId = BoonSourceIdBase + BoonSourceIdSpan - 1u; // 0x00B0FFFF
|
||||||
|
|
||||||
|
/// <summary>Phase 1.7: Frenzy surge duration (ticks, ~60/s) re-stamped on each kill.</summary>
|
||||||
|
public const int FrenzyDurationTicks = 240;
|
||||||
|
|
||||||
|
/// <summary>Phase 1.7: Frenzy cooldown modifier (PercentMult on CooldownTicks; -0.30 = 30% faster abilities).</summary>
|
||||||
|
public const float FrenzyCooldownMult = -0.30f;
|
||||||
|
|
||||||
/// <summary>DR-046: base PREP-LOADOUT run-scoped SourceId band [Base, Base+Span). DISJOINT from boon
|
/// <summary>DR-046: base PREP-LOADOUT run-scoped SourceId band [Base, Base+Span). DISJOINT from boon
|
||||||
/// (0x00B00000), class (0x00C1A550), meta (0x00E7A000), equip (0x00E91000); one prep option's live
|
/// (0x00B00000), class (0x00C1A550), meta (0x00E7A000), equip (0x00E91000); one prep option's live
|
||||||
/// StatModifier is keyed PrepSourceIdBase + optionId. Stripped on the Returning edge like boons.</summary>
|
/// StatModifier is keyed PrepSourceIdBase + optionId. Stripped on the Returning edge like boons.</summary>
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ using System.Collections.Generic;
|
|||||||
namespace ProjectM.Tests
|
namespace ProjectM.Tests
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pins the two-channel boon lifecycle: <see cref="BoonApplySystem"/> (a valid pick appends exactly ONE
|
/// Pins the two-channel boon lifecycle (Phase 1.7 table). <see cref="BoonApplySystem"/>: a valid STAT pick appends
|
||||||
/// boon-band <see cref="StatModifier"/> and clears Pending; out-of-range / not-pending / closed-lifecycle picks
|
/// exactly ONE boon-band <see cref="StatModifier"/> and clears Pending; a MECHANIC-CHANGER pick mutates
|
||||||
/// are rejected; the grace auto-pick deals Option0) and the RunDirector Returning-edge RANGE STRIP (every
|
/// <see cref="BoonEffects"/> (no StatModifier row); out-of-range / not-pending / closed-lifecycle picks are rejected;
|
||||||
/// boon-band row dies; class/meta/equip bands survive; offers zeroed) — run boons NEVER persist (DR-037).
|
/// the grace auto-pick deals Option0. The RunDirector Returning-edge strip: every boon-band StatModifier dies,
|
||||||
|
/// BoonEffects is zeroed, the Frenzy timed row is removed from BOTH buffers, and class/meta/equip bands survive.
|
||||||
|
/// New default table ids: 1 Piercing (effect), 4 Detonating (effect), 9 Executioner (Damage +50%),
|
||||||
|
/// 10 Titan (MaxHealth +60), 11 Fleet Foot (MoveSpeed +18%).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BoonApplyTests
|
public class BoonApplyTests
|
||||||
{
|
{
|
||||||
@@ -48,9 +51,11 @@ namespace ProjectM.Tests
|
|||||||
return (world, group, dir, catalog);
|
return (world, group, dir, catalog);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Entity MakePicker(EntityManager em, int netId, byte o0 = 1, byte o1 = 4, byte o2 = 5)
|
// Defaults to STAT ids so an accepted pick appends a StatModifier row (o1 = 11 Fleet Foot).
|
||||||
|
static Entity MakePicker(EntityManager em, int netId, byte o0 = 9, byte o1 = 11, byte o2 = 10)
|
||||||
{
|
{
|
||||||
var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner), typeof(RegionTag));
|
var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner), typeof(RegionTag),
|
||||||
|
typeof(BoonEffects));
|
||||||
em.AddBuffer<StatModifier>(e);
|
em.AddBuffer<StatModifier>(e);
|
||||||
em.SetComponentData(e, new GhostOwner { NetworkId = netId });
|
em.SetComponentData(e, new GhostOwner { NetworkId = netId });
|
||||||
em.SetComponentData(e, new RegionTag { Region = RegionId.Expedition });
|
em.SetComponentData(e, new RegionTag { Region = RegionId.Expedition });
|
||||||
@@ -78,14 +83,14 @@ namespace ProjectM.Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void ValidPick_AppendsBoonBandRow_AndClearsPending()
|
public void ValidStatPick_AppendsBoonBandRow_AndClearsPending()
|
||||||
{
|
{
|
||||||
var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward);
|
var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward);
|
||||||
using (world)
|
using (world)
|
||||||
{
|
{
|
||||||
var em = world.EntityManager;
|
var em = world.EntityManager;
|
||||||
var player = MakePicker(em, 1);
|
var player = MakePicker(em, 1);
|
||||||
SendPick(em, 1, index: 1); // Option1 = id 4 (Fleet Foot, MoveSpeed +12%)
|
SendPick(em, 1, index: 1); // Option1 = id 11 (Fleet Foot, MoveSpeed +18%)
|
||||||
|
|
||||||
group.Update();
|
group.Update();
|
||||||
|
|
||||||
@@ -93,12 +98,31 @@ namespace ProjectM.Tests
|
|||||||
var mods = em.GetBuffer<StatModifier>(player);
|
var mods = em.GetBuffer<StatModifier>(player);
|
||||||
Assert.AreEqual((byte)StatTarget.MoveSpeed, mods[0].Target, "the picked def's target");
|
Assert.AreEqual((byte)StatTarget.MoveSpeed, mods[0].Target, "the picked def's target");
|
||||||
Assert.AreEqual((byte)ModOp.PercentAdd, mods[0].Op);
|
Assert.AreEqual((byte)ModOp.PercentAdd, mods[0].Op);
|
||||||
Assert.AreEqual(0.12f, mods[0].Value, 1e-4f);
|
Assert.AreEqual(0.18f, mods[0].Value, 1e-4f);
|
||||||
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "pick consumed");
|
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "pick consumed");
|
||||||
Assert.AreEqual(1u, em.GetComponentData<RunRuntime>(dir).BoonPickCounter, "band provenance advanced");
|
Assert.AreEqual(1u, em.GetComponentData<RunRuntime>(dir).BoonPickCounter, "band provenance advanced");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void EffectPick_MutatesBoonEffects_AppendsNoStatRow()
|
||||||
|
{
|
||||||
|
var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var player = MakePicker(em, 1, o0: 1); // Option0 = id 1 (Piercing Shots — a mechanic-changer)
|
||||||
|
SendPick(em, 1, index: 0);
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
Assert.AreEqual(0, BoonRows(em, player), "a mechanic-changer appends NO StatModifier row");
|
||||||
|
Assert.AreEqual(1, em.GetComponentData<BoonEffects>(player).Pierce, "Pierce incremented");
|
||||||
|
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "pick consumed");
|
||||||
|
Assert.AreEqual(0u, em.GetComponentData<RunRuntime>(dir).BoonPickCounter, "no band row → counter unchanged");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Rejects_NotPending_ClosedLifecycle_KeepsBufferClean()
|
public void Rejects_NotPending_ClosedLifecycle_KeepsBufferClean()
|
||||||
{
|
{
|
||||||
@@ -106,7 +130,7 @@ namespace ProjectM.Tests
|
|||||||
var (w1, g1, d1, c1) = MakeWorld(RunLifecycle.RoomReward);
|
var (w1, g1, d1, c1) = MakeWorld(RunLifecycle.RoomReward);
|
||||||
_worlds.Add(w1);
|
_worlds.Add(w1);
|
||||||
var p1 = MakePicker(w1.EntityManager, 1);
|
var p1 = MakePicker(w1.EntityManager, 1);
|
||||||
w1.EntityManager.SetComponentData(p1, new BoonOffer { Pending = 0, Option0 = 1 });
|
w1.EntityManager.SetComponentData(p1, new BoonOffer { Pending = 0, Option0 = 9 });
|
||||||
SendPick(w1.EntityManager, 1, 0);
|
SendPick(w1.EntityManager, 1, 0);
|
||||||
g1.Update();
|
g1.Update();
|
||||||
Assert.AreEqual(0, BoonRows(w1.EntityManager, p1), "not-pending pick rejected");
|
Assert.AreEqual(0, BoonRows(w1.EntityManager, p1), "not-pending pick rejected");
|
||||||
@@ -129,7 +153,7 @@ namespace ProjectM.Tests
|
|||||||
using (world)
|
using (world)
|
||||||
{
|
{
|
||||||
var em = world.EntityManager;
|
var em = world.EntityManager;
|
||||||
var afk = MakePicker(em, 1, o0: 5); // Option0 = id 5 (Iron Constitution, +25 MaxHealth)
|
var afk = MakePicker(em, 1, o0: 10); // Option0 = id 10 (Titan's Vigor, +60 MaxHealth)
|
||||||
var run = em.GetComponentData<RunRuntime>(dir);
|
var run = em.GetComponentData<RunRuntime>(dir);
|
||||||
run.RewardGraceTick = T0 - 10; // already elapsed
|
run.RewardGraceTick = T0 - 10; // already elapsed
|
||||||
em.SetComponentData(dir, run);
|
em.SetComponentData(dir, run);
|
||||||
@@ -144,9 +168,10 @@ namespace ProjectM.Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void ReturningStrip_KillsBoonBand_SparesClassMetaEquip()
|
public void ReturningStrip_KillsBoonBand_ZeroesEffects_SparesClassMetaEquip()
|
||||||
{
|
{
|
||||||
// Drive the REAL RunDirectorSystem Returning edge over a player carrying all four bands.
|
// Drive the REAL RunDirectorSystem Returning edge over a player carrying all four StatModifier bands
|
||||||
|
// PLUS mechanic-changer BoonEffects + a Frenzy timed row (StatModifier + TimedModifier).
|
||||||
var world = new World("BoonStripTest");
|
var world = new World("BoonStripTest");
|
||||||
using (world)
|
using (world)
|
||||||
{
|
{
|
||||||
@@ -163,24 +188,29 @@ namespace ProjectM.Tests
|
|||||||
em.SetComponentData(dir, new RunRuntime { RunSeed = 7u, RunEpoch = 1, RoomsClearedThisRun = 3 });
|
em.SetComponentData(dir, new RunRuntime { RunSeed = 7u, RunEpoch = 1, RoomsClearedThisRun = 3 });
|
||||||
|
|
||||||
var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(BoonOffer),
|
var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(BoonOffer),
|
||||||
typeof(RegionTag), typeof(LocalTransform));
|
typeof(RegionTag), typeof(LocalTransform), typeof(BoonEffects));
|
||||||
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition });
|
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition });
|
||||||
em.SetComponentData(player, LocalTransform.Identity);
|
em.SetComponentData(player, LocalTransform.Identity);
|
||||||
em.SetComponentData(player, new BoonOffer { Pending = 1, Option0 = 1 });
|
em.SetComponentData(player, new BoonOffer { Pending = 1, Option0 = 1 });
|
||||||
|
em.SetComponentData(player, new BoonEffects { Pierce = 2, Flags = BoonFlag.Frenzy });
|
||||||
var mods = em.AddBuffer<StatModifier>(player);
|
var mods = em.AddBuffer<StatModifier>(player);
|
||||||
mods.Add(new StatModifier { Target = 0, Op = 1, Value = 0.2f, SourceId = Tuning.BoonSourceIdBase }); // boon
|
mods.Add(new StatModifier { Target = 0, Op = 1, Value = 0.2f, SourceId = Tuning.BoonSourceIdBase }); // boon
|
||||||
mods.Add(new StatModifier { Target = 0, Op = 1, Value = 0.5f, SourceId = Tuning.BoonSourceIdBase + 1 }); // boon
|
mods.Add(new StatModifier { Target = 1, Op = 2, Value = -0.3f, SourceId = Tuning.FrenzySourceId }); // Frenzy (boon band top)
|
||||||
mods.Add(new StatModifier { Target = 6, Op = 1, Value = 0.1f, SourceId = Tuning.ClassSourceId }); // class
|
mods.Add(new StatModifier { Target = 6, Op = 1, Value = 0.1f, SourceId = Tuning.ClassSourceId }); // class
|
||||||
mods.Add(new StatModifier { Target = 8, Op = 0, Value = 10f, SourceId = 0x00E7A000u }); // meta (12a band)
|
mods.Add(new StatModifier { Target = 8, Op = 0, Value = 10f, SourceId = 0x00E7A000u }); // meta (12a band)
|
||||||
mods.Add(new StatModifier { Target = 0, Op = 0, Value = 5f, SourceId = Tuning.EquipSourceIdBase }); // equip
|
mods.Add(new StatModifier { Target = 0, Op = 0, Value = 5f, SourceId = Tuning.EquipSourceIdBase }); // equip
|
||||||
|
var timed = em.AddBuffer<TimedModifier>(player);
|
||||||
|
timed.Add(new TimedModifier { SourceId = Tuning.FrenzySourceId, UntilTick = T0 + 100 });
|
||||||
|
|
||||||
group.Update(); // Returning: strip + bank + home -> Staging
|
group.Update(); // Returning: strip + bank + home -> Staging
|
||||||
|
|
||||||
var after = em.GetBuffer<StatModifier>(player);
|
var after = em.GetBuffer<StatModifier>(player);
|
||||||
Assert.AreEqual(3, after.Length, "both boon rows stripped, all three permanent bands survive");
|
Assert.AreEqual(3, after.Length, "both boon-band rows (incl. Frenzy) stripped, three permanent bands survive");
|
||||||
for (int i = 0; i < after.Length; i++)
|
for (int i = 0; i < after.Length; i++)
|
||||||
Assert.IsFalse(after[i].SourceId >= Tuning.BoonSourceIdBase
|
Assert.IsFalse(after[i].SourceId >= Tuning.BoonSourceIdBase
|
||||||
&& after[i].SourceId < Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan, "no boon-band survivor");
|
&& after[i].SourceId < Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan, "no boon-band survivor");
|
||||||
|
Assert.AreEqual(0, em.GetBuffer<TimedModifier>(player).Length, "Frenzy timed row stripped");
|
||||||
|
Assert.AreEqual(default(BoonEffects), em.GetComponentData<BoonEffects>(player), "mechanic-changer effects zeroed");
|
||||||
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "straggler offer zeroed");
|
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "straggler offer zeroed");
|
||||||
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData<RunInfo>(dir).Lifecycle);
|
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData<RunInfo>(dir).Lifecycle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ namespace ProjectM.Tests
|
|||||||
{
|
{
|
||||||
for (uint seed = 1; seed < 200; seed += 7)
|
for (uint seed = 1; seed < 200; seed += 7)
|
||||||
{
|
{
|
||||||
int n = BoonMath.PickBoons(seed, classId, ref pool, out byte a0, out byte a1, out byte a2);
|
int n = BoonMath.PickBoons(seed, classId, default(BoonEffects), ref pool, out byte a0, out byte a1, out byte a2);
|
||||||
Assert.AreEqual(3, n, "the default pool always fills 3 options");
|
Assert.AreEqual(3, n, "the default pool always fills 3 options");
|
||||||
Assert.AreNotEqual(a0, a1, "distinct");
|
Assert.AreNotEqual(a0, a1, "distinct");
|
||||||
Assert.AreNotEqual(a1, a2, "distinct");
|
Assert.AreNotEqual(a1, a2, "distinct");
|
||||||
Assert.AreNotEqual(a0, a2, "distinct");
|
Assert.AreNotEqual(a0, a2, "distinct");
|
||||||
|
|
||||||
// Deterministic re-draw.
|
// Deterministic re-draw.
|
||||||
BoonMath.PickBoons(seed, classId, ref pool, out byte b0, out byte b1, out byte b2);
|
BoonMath.PickBoons(seed, classId, default(BoonEffects), ref pool, out byte b0, out byte b1, out byte b2);
|
||||||
Assert.AreEqual(a0, b0);
|
Assert.AreEqual(a0, b0);
|
||||||
Assert.AreEqual(a1, b1);
|
Assert.AreEqual(a1, b1);
|
||||||
Assert.AreEqual(a2, b2);
|
Assert.AreEqual(a2, b2);
|
||||||
@@ -73,7 +73,7 @@ namespace ProjectM.Tests
|
|||||||
Entity MakePlayer(int netId, byte region, byte classId)
|
Entity MakePlayer(int netId, byte region, byte classId)
|
||||||
{
|
{
|
||||||
var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner),
|
var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner),
|
||||||
typeof(RegionTag), typeof(PlayerClass));
|
typeof(RegionTag), typeof(PlayerClass), typeof(BoonEffects));
|
||||||
em.SetComponentData(e, new GhostOwner { NetworkId = netId });
|
em.SetComponentData(e, new GhostOwner { NetworkId = netId });
|
||||||
em.SetComponentData(e, new RegionTag { Region = region });
|
em.SetComponentData(e, new RegionTag { Region = region });
|
||||||
em.SetComponentData(e, new PlayerClass { ClassId = classId });
|
em.SetComponentData(e, new PlayerClass { ClassId = classId });
|
||||||
@@ -102,5 +102,44 @@ namespace ProjectM.Tests
|
|||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static byte FamilyOf(ref BoonCatalogBlob pool, byte id)
|
||||||
|
{
|
||||||
|
int idx = BoonMath.FindDef(ref pool, id);
|
||||||
|
return idx >= 0 ? pool.Defs[idx].Family : (byte)0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void PickBoons_NeverOffersTwoSameFamily_InOneDeal()
|
||||||
|
{
|
||||||
|
var blob = BoonCatalogData.BuildDefault(Allocator.Temp);
|
||||||
|
ref var pool = ref blob.Value;
|
||||||
|
for (byte classId = 0; classId <= 1; classId++)
|
||||||
|
for (uint seed = 1; seed < 200; seed += 3)
|
||||||
|
{
|
||||||
|
BoonMath.PickBoons(seed, classId, default(BoonEffects), ref pool, out byte a0, out byte a1, out byte a2);
|
||||||
|
byte f0 = FamilyOf(ref pool, a0), f1 = FamilyOf(ref pool, a1), f2 = FamilyOf(ref pool, a2);
|
||||||
|
Assert.AreNotEqual(f0, f1, "dominated-offer protection: no two same-family options in one deal");
|
||||||
|
Assert.AreNotEqual(f1, f2, "dominated-offer protection: no two same-family options in one deal");
|
||||||
|
Assert.AreNotEqual(f0, f2, "dominated-offer protection: no two same-family options in one deal");
|
||||||
|
}
|
||||||
|
blob.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void PickBoons_ExcludesOwnedNonStackingFlag()
|
||||||
|
{
|
||||||
|
var blob = BoonCatalogData.BuildDefault(Allocator.Temp);
|
||||||
|
ref var pool = ref blob.Value;
|
||||||
|
var owned = new BoonEffects { Flags = BoonFlag.DashTrail }; // already own Blade Dash (id 5, both classes)
|
||||||
|
for (byte classId = 0; classId <= 1; classId++)
|
||||||
|
for (uint seed = 1; seed < 300; seed += 3)
|
||||||
|
{
|
||||||
|
BoonMath.PickBoons(seed, classId, owned, ref pool, out byte a0, out byte a1, out byte a2);
|
||||||
|
Assert.IsFalse(a0 == 5 || a1 == 5 || a2 == 5, "an owned non-stacking flag boon (Blade Dash) is never re-offered");
|
||||||
|
}
|
||||||
|
blob.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using ProjectM.Server;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Core;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.Mathematics;
|
||||||
|
using Unity.NetCode;
|
||||||
|
using Unity.Transforms;
|
||||||
|
|
||||||
|
namespace ProjectM.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Plain-Entities tests for <see cref="DashTrailDamageSystem"/> (Phase 1.7 Blade Dash). A dashing player with the
|
||||||
|
/// boon damages a nearby enemy ONCE per dash (StartTick-keyed dedup survives a re-tick); a fresh dash hits again;
|
||||||
|
/// no boon → no damage.
|
||||||
|
/// </summary>
|
||||||
|
public class DashTrailDamageSystemTests
|
||||||
|
{
|
||||||
|
static (World world, SimulationSystemGroup group, EntityManager em) MakeWorld(uint tick)
|
||||||
|
{
|
||||||
|
var world = new World("DashTrailTest");
|
||||||
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||||
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<DashTrailDamageSystem>());
|
||||||
|
group.SortSystems();
|
||||||
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||||
|
var em = world.EntityManager;
|
||||||
|
em.SetComponentData(em.CreateEntity(typeof(NetworkTime)), new NetworkTime { ServerTick = new NetworkTick(tick) });
|
||||||
|
return (world, group, em);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void SetTick(EntityManager em, uint tick)
|
||||||
|
{
|
||||||
|
using var q = em.CreateEntityQuery(typeof(NetworkTime));
|
||||||
|
em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) });
|
||||||
|
}
|
||||||
|
|
||||||
|
static Entity MakeDasher(EntityManager em, byte flags, uint startTick, uint iframeUntil)
|
||||||
|
{
|
||||||
|
var e = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(BoonEffects), typeof(DashTrailState));
|
||||||
|
em.AddComponentData(e, LocalTransform.FromPosition(new float3(0f, 0f, 0f)));
|
||||||
|
em.AddComponentData(e, new DashState { Dir = new float2(1f, 0f), StartTick = startTick, IFrameUntilTick = iframeUntil, RecoverUntilTick = iframeUntil + 9 });
|
||||||
|
em.AddComponent<Simulate>(e); // enabled by default
|
||||||
|
em.SetComponentData(e, new GhostOwner { NetworkId = 1 });
|
||||||
|
em.SetComponentData(e, new BoonEffects { Flags = flags });
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Entity MakeEnemy(EntityManager em, float3 pos)
|
||||||
|
{
|
||||||
|
var e = em.CreateEntity(typeof(EnemyTag));
|
||||||
|
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||||
|
em.AddComponentData(e, new HitRadius { Value = 0.5f });
|
||||||
|
em.AddComponentData(e, new Health { Current = 60f, Max = 60f });
|
||||||
|
em.AddBuffer<DamageEvent>(e);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void BladeDash_DamagesNearbyEnemy_OncePerDash_ReHitsOnNextDash()
|
||||||
|
{
|
||||||
|
var (world, group, em) = MakeWorld(100);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var player = MakeDasher(em, BoonFlag.DashTrail, startTick: 100, iframeUntil: 112);
|
||||||
|
var enemy = MakeEnemy(em, new float3(1f, 0f, 0f)); // within 1.6 + 0.5
|
||||||
|
|
||||||
|
group.Update(); // tick 100, dashing
|
||||||
|
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "enemy in the dash path takes one hit");
|
||||||
|
|
||||||
|
group.Update(); // same tick + same StartTick -> dedup, no second hit
|
||||||
|
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "no re-hit within the same dash");
|
||||||
|
|
||||||
|
// A fresh dash (new StartTick) resets the dedup set -> the enemy can be hit again.
|
||||||
|
SetTick(em, 130);
|
||||||
|
em.SetComponentData(player, new DashState { Dir = new float2(1f, 0f), StartTick = 130, IFrameUntilTick = 142, RecoverUntilTick = 151 });
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(2, em.GetBuffer<DamageEvent>(enemy).Length, "a fresh dash hits the enemy again");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void NoBoon_NoDamage()
|
||||||
|
{
|
||||||
|
var (world, group, em) = MakeWorld(100);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
MakeDasher(em, flags: 0, startTick: 100, iframeUntil: 112); // no DashTrail flag
|
||||||
|
var enemy = MakeEnemy(em, new float3(1f, 0f, 0f));
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length, "no Blade Dash boon -> no trail damage");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7603c5c6b91bb854d8b88739bdb6f4b1
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using ProjectM.Server;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Core;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Plain-Entities tests for <see cref="KillRewardSystem"/> (Phase 1.7 on-kill boons). Siphon heals the credited
|
||||||
|
/// killer (clamped to their effective max, once per corpse via the Dying.Rewarded latch); Frenzy upserts a single
|
||||||
|
/// cooldown-reduction row; an unresolved killer (KillerNetId < 0) grants nothing but is still latched.
|
||||||
|
/// </summary>
|
||||||
|
public class KillRewardSystemTests
|
||||||
|
{
|
||||||
|
const uint T0 = 5000;
|
||||||
|
|
||||||
|
static (World world, SimulationSystemGroup group, EntityManager em) MakeWorld()
|
||||||
|
{
|
||||||
|
var world = new World("KillRewardTest");
|
||||||
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||||
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<KillRewardSystem>());
|
||||||
|
group.SortSystems();
|
||||||
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var nt = em.CreateEntity(typeof(NetworkTime));
|
||||||
|
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(T0) });
|
||||||
|
return (world, group, em);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Entity MakeKiller(EntityManager em, int netId, byte flags, float hp, float maxHp)
|
||||||
|
{
|
||||||
|
var e = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(BoonEffects),
|
||||||
|
typeof(Health), typeof(EffectiveCharacterStats));
|
||||||
|
em.AddBuffer<StatModifier>(e);
|
||||||
|
em.AddBuffer<TimedModifier>(e);
|
||||||
|
em.SetComponentData(e, new GhostOwner { NetworkId = netId });
|
||||||
|
em.SetComponentData(e, new BoonEffects { Flags = flags });
|
||||||
|
em.SetComponentData(e, new Health { Current = hp, Max = maxHp });
|
||||||
|
em.SetComponentData(e, new EffectiveCharacterStats { MaxHealth = maxHp });
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Entity MakeCorpse(EntityManager em, int killerNetId)
|
||||||
|
{
|
||||||
|
var e = em.CreateEntity(typeof(EnemyTag), typeof(Dying));
|
||||||
|
em.SetComponentData(e, new Dying { UntilTick = T0 + 50, KillerNetId = killerNetId, Rewarded = 0 });
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int FrenzyRows(EntityManager em, Entity player)
|
||||||
|
{
|
||||||
|
var mods = em.GetBuffer<StatModifier>(player);
|
||||||
|
int n = 0;
|
||||||
|
for (int i = 0; i < mods.Length; i++) if (mods[i].SourceId == Tuning.FrenzySourceId) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Siphon_HealsKiller_ClampedToMax_OncePerCorpse()
|
||||||
|
{
|
||||||
|
var (world, group, em) = MakeWorld();
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var killer = MakeKiller(em, 1, BoonFlag.Siphon, hp: 50f, maxHp: 130f);
|
||||||
|
var corpse = MakeCorpse(em, killerNetId: 1);
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
Assert.Greater(em.GetComponentData<Health>(killer).Current, 50f, "Siphon healed the killer");
|
||||||
|
Assert.AreEqual(1, em.GetComponentData<Dying>(corpse).Rewarded, "corpse latched as rewarded");
|
||||||
|
|
||||||
|
float afterFirst = em.GetComponentData<Health>(killer).Current;
|
||||||
|
group.Update(); // second tick: Rewarded==1 -> no double-heal
|
||||||
|
Assert.AreEqual(afterFirst, em.GetComponentData<Health>(killer).Current, 1e-4f, "no double-heal on a re-tick");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Siphon_DoesNotOverheal_AboveEffectiveMax()
|
||||||
|
{
|
||||||
|
var (world, group, em) = MakeWorld();
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var killer = MakeKiller(em, 1, BoonFlag.Siphon, hp: 128f, maxHp: 130f);
|
||||||
|
MakeCorpse(em, killerNetId: 1);
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(130f, em.GetComponentData<Health>(killer).Current, 1e-4f, "heal clamps to the effective max");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Frenzy_UpsertsSingleCooldownRow()
|
||||||
|
{
|
||||||
|
var (world, group, em) = MakeWorld();
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var killer = MakeKiller(em, 1, BoonFlag.Frenzy, hp: 100f, maxHp: 130f);
|
||||||
|
MakeCorpse(em, killerNetId: 1);
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(1, FrenzyRows(em, killer), "one Frenzy StatModifier row");
|
||||||
|
|
||||||
|
// A second corpse (new kill) re-stamps rather than stacking.
|
||||||
|
var c2 = em.CreateEntity(typeof(EnemyTag), typeof(Dying));
|
||||||
|
em.SetComponentData(c2, new Dying { UntilTick = T0 + 60, KillerNetId = 1, Rewarded = 0 });
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(1, FrenzyRows(em, killer), "Frenzy refreshes, never stacks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void UnresolvedKiller_GrantsNothing_ButLatches()
|
||||||
|
{
|
||||||
|
var (world, group, em) = MakeWorld();
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var killer = MakeKiller(em, 1, BoonFlag.Siphon | BoonFlag.Frenzy, hp: 50f, maxHp: 130f);
|
||||||
|
var corpse = MakeCorpse(em, killerNetId: -1); // environment/AoE kill — no credit
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(50f, em.GetComponentData<Health>(killer).Current, 1e-4f, "no heal for an uncredited kill");
|
||||||
|
Assert.AreEqual(0, FrenzyRows(em, killer), "no Frenzy for an uncredited kill");
|
||||||
|
Assert.AreEqual(1, em.GetComponentData<Dying>(corpse).Rewarded, "still latched so it is not reprocessed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4e10a4fa71c531a42b093a1b43d1ccaf
|
||||||
@@ -147,5 +147,65 @@ namespace ProjectM.Tests
|
|||||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(target).Length, "No target in the path: no damage.");
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(target).Length, "No target in the path: no damage.");
|
||||||
Assert.IsFalse(em.Exists(projectile), "A projectile past its range must be destroyed.");
|
Assert.IsFalse(em.Exists(projectile), "A projectile past its range must be destroyed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static Entity MakeProjectileFx(EntityManager em, float3 pos, float2 dir, float speed, float damage,
|
||||||
|
float range, float distanceTravelled, int ownerId, byte pierce, byte chain, byte flags = 0)
|
||||||
|
{
|
||||||
|
var e = MakeProjectile(em, pos, dir, speed, damage, range, distanceTravelled, ownerId);
|
||||||
|
em.AddComponentData(e, new ProjectileEffectState { PierceRemaining = pierce, ChainRemaining = chain, Flags = flags });
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Pierce_SurvivesFirstTarget_HitsSecond_NeverReHitsFirst()
|
||||||
|
{
|
||||||
|
using var world = MakeWorld().world;
|
||||||
|
var group = world.GetExistingSystemManaged<SimulationSystemGroup>();
|
||||||
|
var em = world.EntityManager;
|
||||||
|
|
||||||
|
var a = MakeTarget(em, new float3(0f, 0f, 3f), hitRadius: 0.8f, health: 60f);
|
||||||
|
var b = MakeTarget(em, new float3(0f, 0f, 6f), hitRadius: 0.8f, health: 60f);
|
||||||
|
// Post-move at z=6; the swept segment [z=0 -> z=6] (speed*dt = 6) covers both; A (z=3) is earliest.
|
||||||
|
var proj = MakeProjectileFx(em, new float3(0f, 0f, 6f), new float2(0f, 1f),
|
||||||
|
speed: 60f, damage: 20f, range: 20f, distanceTravelled: 6f, ownerId: 1, pierce: 1, chain: 0);
|
||||||
|
|
||||||
|
Tick(world, group, 0.1f);
|
||||||
|
|
||||||
|
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(a).Length, "earliest target A is hit");
|
||||||
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(b).Length, "only the earliest target is hit per tick");
|
||||||
|
Assert.IsTrue(em.Exists(proj), "pierce lets the projectile survive the first hit");
|
||||||
|
Assert.AreEqual(0, em.GetComponentData<ProjectileEffectState>(proj).PierceRemaining, "pierce consumed");
|
||||||
|
|
||||||
|
Tick(world, group, 0.1f); // same position: A now excluded by the hit-set -> B is the earliest
|
||||||
|
|
||||||
|
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(a).Length, "A must NOT be re-hit (hit-set exclusion)");
|
||||||
|
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(b).Length, "B hit on the second pass");
|
||||||
|
Assert.IsFalse(em.Exists(proj), "with pierce spent, the second hit consumes the projectile");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Chain_RetargetsTowardNextEnemy_AfterHit()
|
||||||
|
{
|
||||||
|
using var world = MakeWorld().world;
|
||||||
|
var group = world.GetExistingSystemManaged<SimulationSystemGroup>();
|
||||||
|
var em = world.EntityManager;
|
||||||
|
|
||||||
|
var a = MakeTarget(em, new float3(0f, 0f, 3f), hitRadius: 0.8f, health: 60f); // on-axis, hit first
|
||||||
|
var b = MakeTarget(em, new float3(2f, 0f, 6f), hitRadius: 0.8f, health: 60f); // off-axis, chain target
|
||||||
|
var proj = MakeProjectileFx(em, new float3(0f, 0f, 6f), new float2(0f, 1f),
|
||||||
|
speed: 60f, damage: 20f, range: 20f, distanceTravelled: 6f, ownerId: 1, pierce: 0, chain: 1);
|
||||||
|
|
||||||
|
Tick(world, group, 0.1f);
|
||||||
|
|
||||||
|
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(a).Length, "A (on the path) is hit");
|
||||||
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(b).Length, "B is off the path, not hit by the sweep this tick");
|
||||||
|
Assert.IsTrue(em.Exists(proj), "chain lets the projectile survive to seek the next enemy");
|
||||||
|
Assert.AreEqual(0, em.GetComponentData<ProjectileEffectState>(proj).ChainRemaining, "chain consumed");
|
||||||
|
var dir = em.GetComponentData<Projectile>(proj).Direction;
|
||||||
|
Assert.Greater(dir.x, 0.5f, "Direction retargeted toward the off-axis next enemy B (+x)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,5 +38,26 @@ namespace ProjectM.Tests
|
|||||||
Assert.DoesNotThrow(() => group.SortSystems(),
|
Assert.DoesNotThrow(() => group.SortSystems(),
|
||||||
"A [UpdateBefore/After] cycle in the run/cycle/combat chain throws here instead of only at Play world-creation.");
|
"A [UpdateBefore/After] cycle in the run/cycle/combat chain throws here instead of only at Play world-creation.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void PredictedCombatChain_Sorts_Without_A_Dependency_Cycle()
|
||||||
|
{
|
||||||
|
// Phase 1.7 added DashTrailDamageSystem ([UpdateAfter(DashSystem)][UpdateBefore(HealthApplyDamageSystem)])
|
||||||
|
// and KillRewardSystem ([UpdateAfter(HealthApplyDamageSystem)]) to the predicted combat chain. A cycle in
|
||||||
|
// these [UpdateBefore/After] edges is INVISIBLE to per-system fixtures — it only throws at Play world
|
||||||
|
// creation. Co-register the chain and sort to reproduce that headlessly (SortSystems only, never Update).
|
||||||
|
using var world = new World("OrderCyclePredicted");
|
||||||
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||||
|
void Add<T>() where T : unmanaged, ISystem
|
||||||
|
=> group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
|
||||||
|
|
||||||
|
Add<StatRecomputeSystem>(); Add<MeleeComboSystem>(); Add<DashSystem>(); Add<DashTrailDamageSystem>();
|
||||||
|
Add<AbilityFireSystem>(); Add<ProjectileMoveSystem>(); Add<ProjectileDamageSystem>();
|
||||||
|
Add<HealthApplyDamageSystem>(); Add<KillRewardSystem>();
|
||||||
|
|
||||||
|
Assert.DoesNotThrow(() => group.SortSystems(),
|
||||||
|
"A cycle in the Phase 1.7 predicted combat chain throws here instead of only at Play world-creation.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Entities;
|
||||||
|
|
||||||
|
namespace ProjectM.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pins <see cref="TimedModifierUtil.Upsert"/> (Phase 1.7 C4): a repeat grant on one SourceId REFRESHES (re-stamps
|
||||||
|
/// UntilTick) rather than STACKING — exactly one row per id in BOTH the StatModifier and TimedModifier buffers —
|
||||||
|
/// and the TimedModifier-buffer <see cref="TimedModifierUtil.RemoveBySourceId(DynamicBuffer{TimedModifier}, uint)"/>
|
||||||
|
/// overload (C5) clears the paired timed row.
|
||||||
|
/// </summary>
|
||||||
|
public class TimedModifierUtilTests
|
||||||
|
{
|
||||||
|
static (int stat, int timed, uint until) Count(DynamicBuffer<StatModifier> mods, DynamicBuffer<TimedModifier> timed, uint id)
|
||||||
|
{
|
||||||
|
int s = 0; for (int i = 0; i < mods.Length; i++) if (mods[i].SourceId == id) s++;
|
||||||
|
int t = 0; uint u = 0; for (int i = 0; i < timed.Length; i++) if (timed[i].SourceId == id) { t++; u = timed[i].UntilTick; }
|
||||||
|
return (s, t, u);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Upsert_RefreshesExactlyOneRow_InBothBuffers()
|
||||||
|
{
|
||||||
|
using var world = new World("UpsertTest");
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var e = em.CreateEntity();
|
||||||
|
em.AddBuffer<StatModifier>(e);
|
||||||
|
em.AddBuffer<TimedModifier>(e);
|
||||||
|
uint id = Tuning.FrenzySourceId;
|
||||||
|
|
||||||
|
for (uint k = 1; k <= 3; k++)
|
||||||
|
TimedModifierUtil.Upsert(em.GetBuffer<StatModifier>(e), em.GetBuffer<TimedModifier>(e),
|
||||||
|
id, (byte)StatTarget.CooldownTicks, (byte)ModOp.PercentMult, -0.30f, 100u * k);
|
||||||
|
|
||||||
|
var c = Count(em.GetBuffer<StatModifier>(e), em.GetBuffer<TimedModifier>(e), id);
|
||||||
|
Assert.AreEqual(1, c.stat, "exactly one StatModifier row (refresh, never stack)");
|
||||||
|
Assert.AreEqual(1, c.timed, "exactly one TimedModifier row");
|
||||||
|
Assert.AreEqual(300u, c.until, "UntilTick re-stamped to the latest grant");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void RemoveBySourceId_TimedOverload_ClearsPairedRow()
|
||||||
|
{
|
||||||
|
using var world = new World("TimedStripTest");
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var e = em.CreateEntity();
|
||||||
|
em.AddBuffer<StatModifier>(e);
|
||||||
|
em.AddBuffer<TimedModifier>(e);
|
||||||
|
uint id = Tuning.FrenzySourceId;
|
||||||
|
TimedModifierUtil.Upsert(em.GetBuffer<StatModifier>(e), em.GetBuffer<TimedModifier>(e),
|
||||||
|
id, (byte)StatTarget.CooldownTicks, (byte)ModOp.PercentMult, -0.30f, 500u);
|
||||||
|
|
||||||
|
TimedModifierUtil.RemoveBySourceId(em.GetBuffer<StatModifier>(e), id);
|
||||||
|
TimedModifierUtil.RemoveBySourceId(em.GetBuffer<TimedModifier>(e), id);
|
||||||
|
|
||||||
|
var c = Count(em.GetBuffer<StatModifier>(e), em.GetBuffer<TimedModifier>(e), id);
|
||||||
|
Assert.AreEqual(0, c.stat, "StatModifier row stripped");
|
||||||
|
Assert.AreEqual(0, c.timed, "TimedModifier row stripped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a4b41bc944a5f4340ad5eef53beb2cfc
|
||||||
Reference in New Issue
Block a user