Attack Boon Changes
This commit is contained in:
@@ -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<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
|
||||
/// 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<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>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user