Attack Boon Changes

This commit is contained in:
2026-07-13 18:30:41 -07:00
parent 972e0d5b4f
commit 24800f4bcb
34 changed files with 1306 additions and 112 deletions
@@ -21,6 +21,13 @@ namespace ProjectM.Simulation
/// snapshotted into the spawned Projectile, so the downstream move/damage systems are unchanged and
/// 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&lt;Simulate&gt;() 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
/// non-idempotent effects (spawning, cooldown advance) are gated behind
/// 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.
ComponentLookup<KnockbackState> m_KnockbackLookup;
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]
public void OnCreate(ref SystemState state)
@@ -48,6 +60,7 @@ namespace ProjectM.Simulation
state.RequireForUpdate<NetworkTime>();
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
m_BoonEffectsLookup = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
}
[BurstCompile]
@@ -71,6 +84,7 @@ namespace ProjectM.Simulation
bool isServer = state.WorldUnmanaged.IsServer();
m_KnockbackLookup.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
// 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
}
// 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
// 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.
@@ -143,9 +161,10 @@ namespace ProjectM.Simulation
});
// 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).
// Phase 1.7 Gravity Pull: drag toward the player instead of away.
KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, coneTargets[ci],
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);
@@ -195,28 +214,50 @@ namespace ProjectM.Simulation
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
var projectile = ecb.Instantiate(prefab);
float3 planarDir = new float3(dir.x, 0f, dir.y);
float3 spawnPos = xform.ValueRO.Position + planarDir * 0.6f;
spawnPos.y = xform.ValueRO.Position.y;
quaternion rot = quaternion.LookRotationSafe(planarDir, math.up());
ecb.SetComponent(projectile, LocalTransform.FromPositionRotation(spawnPos, rot));
ecb.SetComponent(projectile, new GhostOwner { NetworkId = owner.ValueRO.NetworkId });
// Snapshot the effective ability stats into the projectile (base + modifiers, computed
// identically on both worlds), so the move/damage systems need no modifier lookup.
ecb.SetComponent(projectile, new Projectile
for (int s = 0; s < shots; s++)
{
Direction = math.normalize(dir),
SpawnId = spawnId,
Speed = eff.ValueRO.ProjectileSpeed,
Damage = eff.ValueRO.Damage,
Range = eff.ValueRO.Range,
DistanceTravelled = 0f,
});
// 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);
float3 planarDir = new float3(sdir.x, 0f, sdir.y);
float3 spawnPos = xform.ValueRO.Position + planarDir * 0.6f;
spawnPos.y = xform.ValueRO.Position.y;
quaternion rot = quaternion.LookRotationSafe(planarDir, math.up());
ecb.SetComponent(projectile, LocalTransform.FromPositionRotation(spawnPos, rot));
ecb.SetComponent(projectile, new GhostOwner { NetworkId = owner.ValueRO.NetworkId });
// Snapshot the effective ability stats into the projectile (base + modifiers, computed
// identically on both worlds), so the move/damage systems need no modifier lookup.
ecb.SetComponent(projectile, new Projectile
{
Direction = sdir,
SpawnId = spawnId,
Speed = eff.ValueRO.ProjectileSpeed,
Damage = eff.ValueRO.Damage,
Range = eff.ValueRO.Range,
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.
uint cooldownTicks = (uint)math.max(1, eff.ValueRO.CooldownTicks);
@@ -4,25 +4,48 @@ using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// One authored boon in the catalog blob: a thin wrapper over the existing stat pipeline
/// <see cref="Target"/>/<see cref="Op"/>/<see cref="Value"/> map 1:1 onto a <see cref="StatModifier"/> row
/// (bytes, never enums, on the baked path). <see cref="Id"/> is the stable APPEND-ONLY key the replicated
/// <c>BoonOffer</c> options and pick RPC carry. <see cref="Weight"/> is the rarity draw weight
/// (common 100 / rare 30 / epic 10). <see cref="ClassMask"/> gates by class: bit0 = Warrior (classId 0),
/// bit1 = Ranger (classId 1), 3 = both.
/// 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, the
/// original path) OR a Phase-1.7 MECHANIC-CHANGER (<see cref="Kind"/>==1 — <see cref="EffectKind"/> selects the
/// hook; for the stacking kinds Pierce/Fork/Chain <see cref="Value"/> is the per-pick count delta, else it's a
/// flag). Bytes, never enums, on the baked path. <see cref="Id"/> is the stable key the replicated
/// <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>
public struct BoonDefBlob
{
public byte Id;
public byte Target; // StatTarget as byte
public byte Op; // ModOp as byte
public float Value;
public byte Target; // StatTarget as byte (Kind==0)
public byte Op; // ModOp as byte (Kind==0)
public float Value; // Kind==0: modifier magnitude; Kind==1 stacking: per-pick count delta
public byte Weight;
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 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>
public struct BoonCatalogBlob
{
@@ -45,8 +68,10 @@ namespace ProjectM.Simulation
}
/// <summary>
/// Pure, deterministic boon selection math — integer-hash only (<see cref="RunMapMath.Hash(uint,uint)"/> chain,
/// no RNG state), so an offer is a reproducible function of (runSeed, room, player). EditMode-tested.
/// Pure, deterministic boon selection math — integer-hash only (<c>RunMapMath.Hash</c> chain, no RNG state), so
/// 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>
public static class BoonMath
{
@@ -54,63 +79,75 @@ namespace ProjectM.Simulation
public static byte MaskFor(byte classId) => (byte)(1 << (classId & 1));
/// <summary>
/// Draw 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
/// last-drawn candidates (a catalog authoring smell, not a crash). Returns the number of distinct ids.
/// Draw up to 3 DISTINCT, rarity-weighted, class-filtered boon ids from the pool. Deterministic per
/// (<paramref name="offerSeed"/>, <paramref name="owned"/>). Phase 1.7: a non-stacking FLAG effect the player
/// 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>
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)
{
byte classBit = MaskFor(classId);
int ownedFamilies = OwnedFamilyMask(owned);
// Class-legal candidate indices + the total weight.
var candidates = new FixedList128Bytes<byte>();
var candidates = new FixedList128Bytes<byte>(); // catalog indices
var weights = new FixedList128Bytes<byte>(); // biased draw weight per candidate (parallel)
int totalWeight = 0;
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].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);
totalWeight += pool.Defs[i].Weight;
weights.Add((byte)w);
totalWeight += w;
}
o0 = o1 = o2 = 0;
if (candidates.Length == 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;
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;
byte drawn = candidates[candidates.Length - 1];
int chosen = candidates.Length - 1;
int acc = 0;
for (int c = 0; c < candidates.Length; c++)
{
acc += pool.Defs[candidates[c]].Weight;
if (roll < (uint)acc) { drawn = candidates[c]; break; }
acc += weights[c];
if (roll < (uint)acc) { chosen = c; break; }
}
byte drawn = candidates[chosen];
byte fam = pool.Defs[drawn].Family;
bool dup = false;
for (int p = 0; p < picked.Length; p++)
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);
if (fam != 0) pickedFamilies.Add(fam);
salt = 0;
}
else if (++salt > 16)
{
// Rejection budget spent — take the first unpicked candidate (still deterministic).
for (int c = 0; c < candidates.Length; c++)
{
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; }
}
// Rejection budget spent — deterministic linear fill (first unused, family-distinct if possible).
AddFallback(ref picked, ref pickedFamilies, candidates, ref pool);
salt = 0;
}
}
@@ -121,6 +158,63 @@ namespace ProjectM.Simulation
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>
public static int FindDef(ref BoonCatalogBlob pool, byte id)
{
@@ -131,8 +225,9 @@ namespace ProjectM.Simulation
}
/// <summary>
/// The DEFAULT v1 boon table + the blob builder the baker AND EditMode tests share (single source — the
/// authoring bakes this table verbatim when its designer-row list is empty). Append-only ids.
/// The DEFAULT Phase-1.7 boon table + the blob builder the baker AND EditMode tests share — 8 mechanic-changers
/// (<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>
public static class BoonCatalogData
{
@@ -143,25 +238,28 @@ namespace ProjectM.Simulation
ref var root = ref builder.ConstructRoot<BoonCatalogBlob>();
var defs = builder.Allocate(ref root.Defs, 12);
int i = 0;
// id, target, op, value, weight, mask(1=Warrior,2=Ranger,3=both), name, desc
defs[i++] = Make(1, StatTarget.Damage, ModOp.PercentAdd, 0.20f, 100, 3, "Honed Edge", "+20% ability damage");
defs[i++] = Make(2, StatTarget.CooldownTicks, ModOp.PercentMult, -0.15f, 100, 3, "Swift Hands", "-15% ability cooldown");
defs[i++] = Make(3, StatTarget.Range, ModOp.PercentAdd, 0.25f, 100, 2, "Long Reach", "+25% projectile range");
defs[i++] = Make(4, StatTarget.MoveSpeed, ModOp.PercentAdd, 0.12f, 100, 3, "Fleet Foot", "+12% move speed");
defs[i++] = Make(5, StatTarget.MaxHealth, ModOp.Flat, 25f, 100, 3, "Iron Constitution", "+25 max health");
defs[i++] = Make(6, StatTarget.MeleeDamage, ModOp.PercentAdd, 0.25f, 100, 1, "Heavy Blows", "+25% melee damage");
defs[i++] = Make(7, StatTarget.MeleeRange, ModOp.PercentAdd, 0.20f, 60, 1, "Extended Haft", "+20% melee reach");
defs[i++] = Make(8, StatTarget.ProjectileSpeed, ModOp.PercentAdd, 0.25f, 60, 2, "Swift Bolts", "+25% projectile speed");
defs[i++] = Make(9, StatTarget.AutoTargetRange, ModOp.PercentAdd, 0.20f, 60, 3, "Keen Instinct", "+20% auto-target range");
defs[i++] = Make(10, StatTarget.CooldownTicks, ModOp.PercentMult, -0.25f, 30, 3, "Berserker's Pace", "-25% ability cooldown");
defs[i++] = Make(11, StatTarget.MaxHealth, ModOp.Flat, 60f, 30, 3, "Titan's Vigor", "+60 max health");
defs[i++] = Make(12, StatTarget.Damage, ModOp.PercentAdd, 0.50f, 10, 3, "Executioner", "+50% ability damage");
// ---- 8 mechanic-changers (Kind=1). mask: 1=Warrior, 2=Ranger, 3=both. Projectile boons are Ranger-only
// (the Warrior's Fire is a cone, not a projectile). ----
defs[i++] = Effect(1, BoonEffectKind.Pierce, 1f, 100, 2, BoonFamily.Projectile, "Piercing Shots", "Your shots pierce +1 enemy");
defs[i++] = Effect(2, BoonEffectKind.Fork, 1f, 60, 2, BoonFamily.Projectile, "Split Shot", "Fire +1 extra shot in a spread");
defs[i++] = Effect(3, BoonEffectKind.Chain, 1f, 60, 2, BoonFamily.Projectile, "Ricochet", "Your shots chain to +1 nearby enemy");
defs[i++] = Effect(4, BoonEffectKind.FinisherDetonate, 0f, 60, 1, BoonFamily.Melee, "Detonating Finisher", "Your combo finisher blasts an AoE");
defs[i++] = Effect(5, BoonEffectKind.DashTrail, 0f, 100, 3, BoonFamily.Mobility, "Blade Dash", "Dashing damages enemies you pass through");
defs[i++] = Effect(6, BoonEffectKind.KnockToPull, 0f, 30, 3, BoonFamily.Melee, "Gravity Pull", "Your knockback drags enemies IN");
defs[i++] = Effect(7, BoonEffectKind.Siphon, 0f, 60, 3, BoonFamily.OnKill, "Siphon", "Killing an enemy heals you");
defs[i++] = Effect(8, BoonEffectKind.Frenzy, 0f, 30, 3, BoonFamily.OnKill, "Frenzy", "A kill briefly speeds your abilities");
// ---- 4 strong flat-stat boons (Kind=0) ----
defs[i++] = Stat(9, StatTarget.Damage, ModOp.PercentAdd, 0.50f, 30, 3, BoonFamily.StatDamage, "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);
builder.Dispose();
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)
{
return new BoonDefBlob
@@ -172,6 +270,30 @@ namespace ProjectM.Simulation
Value = value,
Weight = weight,
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),
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&lt;Simulate&gt;()</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>
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
{
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))
return;
float3 delta = targetPos - sourcePos;
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 };
}
}
@@ -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++; }
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;
}
}
}