Slice Combat Depth (MC-2): enemy-variety server spine — Spitter, Swarmer, 4-type mix (DR-041)
Adds the server-authoritative mechanics for three new enemy archetypes on top of the Grunt/Charger base, plus the weighted wave-composition that introduces them: - Spitter: a ranged Husk variant (SpitterState) that holds a preferred range-band (advance/retreat/hold via EnemyAIMath.BandVelocity) and fires a telegraphed, dodgeable EnemyProjectile. New server EnemyProjectileMoveSystem (integrate + store LastStep) + EnemyProjectileDamageSystem (region-filtered swept hit-test rebuilt from LastStep — DR-018 anti-tunnelling; players use HitRadius, structures a const radius; at-most-once destroy). Concurrent-spit soft cap, soft-fail retry. - Swarmer: marker tag + deterministic cluster spawn (1 slot = 1 pack; EnemyAIMath.ClusterOffset), MaxAlive counts ENTITIES so a pack defers if it won't fit. - 4-type weighted mix: MixBands -> ZoneEnemyMath.WaveSlots/KindForSlot/ PackSizeForSlot drives both the expedition director and (fork-4a) the base siege, with a mandatory MaxAlive cap. Legacy WaveSize/IsChargerSlot kept + parity-tested. - Discriminator stays component-presence (no enum in Bursted systems): query- partition guards keep each enemy moved by exactly one EnemyAISystem pass (sole-Position-writer). EnemyTelegraph.IsCharger -> Kind byte for the client cue. New authoring (Spitter/Swarmer/EnemyProjectile) + expanded director authorings with tunable mix/cluster defaults. 13 new EditMode tests (mix composition + legacy parity, band/cluster math, projectile move + cross-region + swept anti-tunnelling regressions); full suite green before commit. Dormant until the prefab/subscene wiring lands (next): the new systems guard on TryGetSingleton/RequireForUpdate, so with no prefabs wired the new types stay inert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -56,12 +56,15 @@ namespace ProjectM.Authoring
|
||||
// EnemyTelegraph even on a Charger (the prefab composes both authorings on one entity); reading the
|
||||
// sibling ChargerAuthoring here avoids a double-AddComponent. WindupTicks = the client danger-ramp
|
||||
// denominator per variant; IsCharger lets the client pick the Charger look (LungeState is server-only).
|
||||
bool isCharger = GetComponent<ChargerAuthoring>() != null;
|
||||
AddComponent(entity, new EnemyTelegraph
|
||||
{
|
||||
WindupTicks = (byte)(isCharger ? 30 : Tuning.AttackWindupTicks),
|
||||
IsCharger = (byte)(isCharger ? 1 : 0),
|
||||
});
|
||||
// Kind byte (client telegraph look) — derived from the sibling variant authoring (EnemyBaker is the
|
||||
// SOLE EnemyTelegraph writer). Grunt=0 / Charger=1 / Spitter=2 / Swarmer=3 (ZoneEnemyMath.Kind*).
|
||||
byte kind = ZoneEnemyMath.KindGrunt;
|
||||
byte windup = (byte)Tuning.AttackWindupTicks;
|
||||
var spitter = GetComponent<SpitterAuthoring>();
|
||||
if (GetComponent<ChargerAuthoring>() != null) { kind = ZoneEnemyMath.KindCharger; windup = 30; }
|
||||
else if (spitter != null) { kind = ZoneEnemyMath.KindSpitter; windup = (byte)Mathf.Clamp(spitter.WindupTicks, 1, 255); }
|
||||
else if (GetComponent<SwarmerAuthoring>() != null) { kind = ZoneEnemyMath.KindSwarmer; windup = 6; }
|
||||
AddComponent(entity, new EnemyTelegraph { WindupTicks = windup, Kind = kind });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-2 — authoring for the hostile Spitter projectile prefab (an ownerless INTERPOLATED ghost, duplicated from an
|
||||
/// existing interpolated ghost so the GhostAuthoringComponent comes free). Bakes <see cref="EnemyProjectile"/> with
|
||||
/// the spit's default Speed/Damage/Range; the firing Spitter OVERRIDES Direction + Speed + Damage + Region at spawn
|
||||
/// and ADDS the <c>RegionTag</c> (so this prefab MUST NOT bake RegionTag — AddComponent would throw on a baked one).
|
||||
/// NO Health (so it is invisible to every player hit-test) and NO <c>[GhostField]</c> beyond the stock LocalTransform.
|
||||
/// </summary>
|
||||
public class EnemyProjectileAuthoring : MonoBehaviour
|
||||
{
|
||||
[Min(0f), Tooltip("Default muzzle speed (the firing Spitter overrides this per-variant).")]
|
||||
public float Speed = 11f;
|
||||
|
||||
[Min(0f), Tooltip("Default damage (the firing Spitter overrides this from its AttackDamage).")]
|
||||
public float Damage = 8f;
|
||||
|
||||
[Min(0f), Tooltip("Max travel distance before the spit expires (world units).")]
|
||||
public float Range = 16f;
|
||||
|
||||
private class EnemyProjectileBaker : Baker<EnemyProjectileAuthoring>
|
||||
{
|
||||
public override void Bake(EnemyProjectileAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||
AddComponent(entity, new EnemyProjectile
|
||||
{
|
||||
Speed = authoring.Speed,
|
||||
Damage = authoring.Damage,
|
||||
Range = authoring.Range,
|
||||
Direction = new float2(0f, 1f),
|
||||
DistanceTravelled = 0f,
|
||||
LastStep = 0f,
|
||||
Region = 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ff79c8fbcacb8c34faad37d59836b5ac
|
||||
@@ -0,0 +1,49 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-2 — marks a Husk prefab as a SPITTER variant (the ranged "reposition" question). Compose WITH
|
||||
/// <see cref="EnemyAuthoring"/> on the prefab root: EnemyAuthoring bakes the common Husk components + the spit's
|
||||
/// damage/cooldown (EnemyStats.AttackDamage / AttackCooldownTicks), this bakes the server-only
|
||||
/// <see cref="SpitterState"/> (zeroed NextShotTick = ready). Component-PRESENCE is the discriminator EnemyAISystem
|
||||
/// branches on (no enum); the Grunt + Charger passes exclude it via <c>.WithNone<SpitterState>()</c>. The
|
||||
/// actual spit projectile is a SEPARATE ghost configured by the SpitterProjectilePrefab subscene singleton.
|
||||
/// </summary>
|
||||
public class SpitterAuthoring : MonoBehaviour
|
||||
{
|
||||
[Min(0f), Tooltip("Distance the Spitter tries to hold from its target (band centre).")]
|
||||
public float PreferredRange = 9f;
|
||||
|
||||
[Min(0f), Tooltip("Half-width dead-zone around PreferredRange where it holds and fires.")]
|
||||
public float RangeTolerance = 1.5f;
|
||||
|
||||
[Min(0f), Tooltip("Muzzle speed of the spit (world units/second). Slow enough to be dodgeable at range.")]
|
||||
public float ProjectileSpeed = 11f;
|
||||
|
||||
[Min(0f), Tooltip("If the target closes within this AND the Spitter can't retreat, it fires point-blank.")]
|
||||
public float CorneredRange = 3f;
|
||||
|
||||
[Min(1), Tooltip("Telegraph wind-up before the spit fires (ticks). Keep >= ~24 (> interp delay) to stay dodgeable.")]
|
||||
public int WindupTicks = 26;
|
||||
|
||||
private class SpitterBaker : Baker<SpitterAuthoring>
|
||||
{
|
||||
public override void Bake(SpitterAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||
AddComponent(entity, new SpitterState
|
||||
{
|
||||
PreferredRange = authoring.PreferredRange,
|
||||
RangeTolerance = authoring.RangeTolerance,
|
||||
ProjectileSpeed = authoring.ProjectileSpeed,
|
||||
CorneredRange = authoring.CorneredRange,
|
||||
WindupTicks = authoring.WindupTicks,
|
||||
NextShotTick = 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 55fe00810b31aa54abd577b6a07192e2
|
||||
@@ -0,0 +1,25 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-2 — marks a Husk prefab as a SWARMER variant (the "surround" question). Compose WITH
|
||||
/// <see cref="EnemyAuthoring"/> on the prefab root, tuned fast + low-HP + fast frequent low-chip bites (via the
|
||||
/// EnemyAuthoring fields). This bakes only the <see cref="SwarmerTag"/> marker: a Swarmer has NO AI branch (it
|
||||
/// falls through the Grunt seek+strike pass); the tag drives the director's CLUSTER spawn (a pack per slot) + a
|
||||
/// client tint. Keeps EnemyTag + RegionTag like every Husk.
|
||||
/// </summary>
|
||||
public class SwarmerAuthoring : MonoBehaviour
|
||||
{
|
||||
private class SwarmerBaker : Baker<SwarmerAuthoring>
|
||||
{
|
||||
public override void Bake(SwarmerAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||
AddComponent<SwarmerTag>(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b6a84b442d0535642abc303c01546a15
|
||||
@@ -22,6 +22,17 @@ namespace ProjectM.Authoring
|
||||
[Min(0), Tooltip("Additional Husks per subsequent wave.")] public int CountPerWave = 2;
|
||||
[Min(1), Tooltip("Ticks between individual spawns within a wave (~60/sec).")] public int SpawnIntervalTicks = 24;
|
||||
[Min(1), Tooltip("Ticks of calm between waves (~60/sec).")] public int LullTicks = 240;
|
||||
[Min(1), Tooltip("Max concurrent live BASE husks (mandatory fork-4a cap; bounds spits + swarmer packs at the climax).")] public int MaxAlive = 14;
|
||||
[Min(0), Tooltip("Charger base count.")] public int ChargerBase = 0;
|
||||
[Min(0), Tooltip("Spitter base count.")] public int SpitterBase = 0;
|
||||
[Min(0), Tooltip("Swarmer SLOT base count (each slot = one pack).")] public int SwarmerSlotBase = 0;
|
||||
[Min(0), Tooltip("Extra chargers per wave.")] public int ChargerPerEpoch = 1;
|
||||
[Min(0), Tooltip("Extra spitters per wave (the base gains the ranged question as it escalates).")] public int SpitterPerEpoch = 1;
|
||||
[Min(0), Tooltip("Extra swarmer slots per wave (0 = no base swarms in v1; bounded by MaxAlive when enabled).")] public int SwarmerSlotPerEpoch = 0;
|
||||
[Min(1), Tooltip("Swarmers per swarmer-slot cluster.")] public int SwarmerPackSize = 3;
|
||||
[Min(0), Tooltip("Swarmer pack-size ramp per wave (0 = fixed).")] public int SwarmerPackPerEpoch = 0;
|
||||
[Min(0f), Tooltip("Tight ring radius for a swarmer pack.")] public float ClusterTightRadius = 2.5f;
|
||||
|
||||
|
||||
private class WaveDirectorBaker : Baker<WaveDirectorAuthoring>
|
||||
{
|
||||
@@ -37,6 +48,16 @@ namespace ProjectM.Authoring
|
||||
CountPerWave = authoring.CountPerWave,
|
||||
SpawnIntervalTicks = authoring.SpawnIntervalTicks,
|
||||
LullTicks = authoring.LullTicks,
|
||||
MaxAlive = authoring.MaxAlive,
|
||||
ChargerBase = authoring.ChargerBase,
|
||||
SpitterBase = authoring.SpitterBase,
|
||||
SwarmerSlotBase = authoring.SwarmerSlotBase,
|
||||
ChargerPerEpoch = authoring.ChargerPerEpoch,
|
||||
SpitterPerEpoch = authoring.SpitterPerEpoch,
|
||||
SwarmerSlotPerEpoch = authoring.SwarmerSlotPerEpoch,
|
||||
SwarmerPackSize = authoring.SwarmerPackSize,
|
||||
SwarmerPackPerEpoch = authoring.SwarmerPackPerEpoch,
|
||||
ClusterTightRadius = authoring.ClusterTightRadius,
|
||||
});
|
||||
|
||||
var buffer = AddBuffer<WaveEnemyPrefab>(entity);
|
||||
|
||||
@@ -14,15 +14,23 @@ namespace ProjectM.Authoring
|
||||
/// </summary>
|
||||
public class ZoneEnemyDirectorAuthoring : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Zone-enemy variant prefabs. Index 0 = Grunt, index 1 = Charger. Each must carry EnemyAuthoring + an interpolated GhostAuthoringComponent.")]
|
||||
[Tooltip("Zone-enemy variant prefabs by Kind: [0]=Grunt, [1]=Charger, [2]=Spitter, [3]=Swarmer. Each must carry EnemyAuthoring (+ its variant authoring) + an interpolated GhostAuthoringComponent.")]
|
||||
public GameObject[] EnemyPrefabs;
|
||||
|
||||
[Min(1), Tooltip("Max concurrent live zone enemies (the v1 ghost-relevancy budget).")] public int MaxAlive = 12;
|
||||
[Min(1), Tooltip("Max concurrent live zone enemies (the ghost-relevancy budget; also caps swarmer packs).")] public int MaxAlive = 12;
|
||||
[Min(0f)] public float RingRadius = 14f;
|
||||
[Min(1)] public int RingSlots = 10;
|
||||
[Min(1), Tooltip("Ticks between individual spawns within a wave (~60/sec).")] public int SpawnIntervalTicks = 30;
|
||||
[Min(0), Tooltip("Grunts in the epoch-1 wave (held roughly constant as the epoch climbs).")] public int GruntsPerWave = 4;
|
||||
[Min(0), Tooltip("Chargers in the epoch-1 wave (grows ~1 per epoch -> charger-heavy).")] public int ChargersPerWave = 1;
|
||||
[Min(1), Tooltip("Ticks between individual slot spawns within a wave (~60/sec).")] public int SpawnIntervalTicks = 30;
|
||||
[Min(0), Tooltip("Grunt base count (the fixed floor = remainder of the slots).")] public int GruntsPerWave = 4;
|
||||
[Min(0), Tooltip("Charger base count.")] public int ChargersPerWave = 1;
|
||||
[Min(0), Tooltip("Spitter base count (0 = introduced via the per-epoch ramp).")] public int SpitterBase = 0;
|
||||
[Min(0), Tooltip("Swarmer SLOT base count (each slot = one pack).")] public int SwarmerSlotBase = 0;
|
||||
[Min(0), Tooltip("Extra chargers per epoch.")] public int ChargerPerEpoch = 1;
|
||||
[Min(0), Tooltip("Extra spitters per epoch (1 -> Spitter first appears at epoch 2).")] public int SpitterPerEpoch = 1;
|
||||
[Min(0), Tooltip("Extra swarmer slots per epoch.")] public int SwarmerSlotPerEpoch = 1;
|
||||
[Min(1), Tooltip("Swarmers per swarmer-slot cluster.")] public int SwarmerPackSize = 4;
|
||||
[Min(0), Tooltip("Swarmer pack-size ramp per epoch (0 = fixed).")] public int SwarmerPackPerEpoch = 0;
|
||||
[Min(0f), Tooltip("Tight ring radius for a swarmer pack.")] public float ClusterTightRadius = 2.5f;
|
||||
[Min(0), Tooltip("Flat Ore banked to the shared ledger on a real clear, once per sortie.")] public int RewardOre = 25;
|
||||
|
||||
private class ZoneEnemyDirectorBaker : Baker<ZoneEnemyDirectorAuthoring>
|
||||
@@ -40,6 +48,14 @@ namespace ProjectM.Authoring
|
||||
GruntsPerWave = authoring.GruntsPerWave,
|
||||
ChargersPerWave = authoring.ChargersPerWave,
|
||||
RewardOre = authoring.RewardOre,
|
||||
SpitterBase = authoring.SpitterBase,
|
||||
SwarmerSlotBase = authoring.SwarmerSlotBase,
|
||||
ChargerPerEpoch = authoring.ChargerPerEpoch,
|
||||
SpitterPerEpoch = authoring.SpitterPerEpoch,
|
||||
SwarmerSlotPerEpoch = authoring.SwarmerSlotPerEpoch,
|
||||
SwarmerPackSize = authoring.SwarmerPackSize,
|
||||
SwarmerPackPerEpoch = authoring.SwarmerPackPerEpoch,
|
||||
ClusterTightRadius = authoring.ClusterTightRadius,
|
||||
});
|
||||
|
||||
var buffer = AddBuffer<ZoneEnemyPrefab>(entity);
|
||||
|
||||
@@ -27,11 +27,14 @@ namespace ProjectM.Server
|
||||
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
|
||||
public partial struct EnemyAISystem : ISystem
|
||||
{
|
||||
EntityQuery m_EnemyProjectiles;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>()));
|
||||
m_EnemyProjectiles = state.GetEntityQuery(ComponentType.ReadOnly<EnemyProjectile>());
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
@@ -104,7 +107,7 @@ namespace ProjectM.Server
|
||||
foreach (var (xform, stats, cooldown, knockback, windup, region) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
||||
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRO<RegionTag>>()
|
||||
.WithAll<EnemyTag>().WithNone<LungeState>())
|
||||
.WithAll<EnemyTag>().WithNone<LungeState, SpitterState>())
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
byte huskRegion = region.ValueRO.Region;
|
||||
@@ -212,7 +215,7 @@ namespace ProjectM.Server
|
||||
foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
||||
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>, RefRO<RegionTag>>()
|
||||
.WithAll<EnemyTag>())
|
||||
.WithAll<EnemyTag>().WithNone<SpitterState>())
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
byte cHuskRegion = region.ValueRO.Region;
|
||||
@@ -331,6 +334,112 @@ namespace ProjectM.Server
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- Spitter pass: a Husk variant baked with SpitterState holds a RANGED range-band and fires a
|
||||
// telegraphed, dodgeable spit. Partitioned .WithAll<SpitterState>().WithNone<LungeState>() (and the Grunt
|
||||
// pass excludes SpitterState) so a Spitter is moved by EXACTLY this pass — the sole-Position-writer rule.
|
||||
bool haveSpit = SystemAPI.TryGetSingleton<SpitterProjectilePrefab>(out var spitCfg) && spitCfg.Prefab != Entity.Null;
|
||||
int liveSpits = m_EnemyProjectiles.CalculateEntityCount();
|
||||
LocalTransform spitBakedLt = default;
|
||||
EnemyProjectile spitBakedProj = default;
|
||||
if (haveSpit)
|
||||
{
|
||||
spitBakedLt = state.EntityManager.GetComponentData<LocalTransform>(spitCfg.Prefab);
|
||||
spitBakedProj = state.EntityManager.GetComponentData<EnemyProjectile>(spitCfg.Prefab);
|
||||
}
|
||||
foreach (var (xform, stats, knockback, windup, spitter, region) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<KnockbackState>,
|
||||
RefRW<AttackWindup>, RefRW<SpitterState>, RefRO<RegionTag>>()
|
||||
.WithAll<EnemyTag, SpitterState>().WithNone<LungeState>())
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
byte sRegion = region.ValueRO.Region;
|
||||
bool sCoreAlive = coreAlive && sRegion == RegionId.Base;
|
||||
|
||||
// 1. Knockback overrides everything (sole Position writer preserved).
|
||||
var kb = knockback.ValueRO;
|
||||
if (kb.UntilTick != 0)
|
||||
{
|
||||
var kbTick = new NetworkTick(kb.UntilTick);
|
||||
if (kbTick.IsValid && kbTick.IsNewerThan(serverTick))
|
||||
{
|
||||
float3 kpos = pos + new float3(kb.Dir.x, 0f, kb.Dir.y) * (kb.Speed * dt);
|
||||
kpos.y = pos.y;
|
||||
if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
|
||||
xform.ValueRW.Position = kpos;
|
||||
windup.ValueRW.WindUpUntilTick = 0;
|
||||
continue;
|
||||
}
|
||||
knockback.ValueRW.UntilTick = 0;
|
||||
}
|
||||
|
||||
// 2. Target (region-scoped shared helper); Core fallback like the Grunt/Charger passes.
|
||||
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, sRegion, structAggro, out bool sIsStruct, out int sIdx);
|
||||
if (sIdx < 0 && !sCoreAlive)
|
||||
continue;
|
||||
Entity sTargetEntity = sIdx < 0 ? Entity.Null
|
||||
: (sIsStruct ? structureEntities[sIdx] : playerEntities[sIdx]);
|
||||
float3 sTargetPos = sIdx < 0 ? corePos
|
||||
: (sIsStruct ? structurePositions[sIdx] : playerPositions[sIdx]);
|
||||
|
||||
// 3. Range-band movement: advance if too far, retreat if too close, hold in-band. Face the target.
|
||||
var sp = spitter.ValueRO;
|
||||
float3 bandVel = EnemyAIMath.BandVelocity(pos, sTargetPos, stats.ValueRO.MoveSpeed, sp.PreferredRange, sp.RangeTolerance);
|
||||
float3 sNewPos = pos + bandVel * dt; sNewPos.y = pos.y;
|
||||
if (sweep) sNewPos = SweptMove(in physics, pos, sNewPos, SweepRadius, envFilter);
|
||||
xform.ValueRW.Position = sNewPos;
|
||||
float3 sToTarget = sTargetPos - pos; sToTarget.y = 0f;
|
||||
if (math.lengthsq(sToTarget) > 1e-6f)
|
||||
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(sToTarget), math.up());
|
||||
|
||||
// 4. Telegraphed shot: commit a wind-up (the dodge window) when the shot gate is ready; on elapse,
|
||||
// spawn a spit toward the target. A cornered Spitter still fires (point-blank) — no safe corner.
|
||||
uint sWindRaw = windup.ValueRO.WindUpUntilTick;
|
||||
if (sWindRaw != 0)
|
||||
{
|
||||
var sWindTick = new NetworkTick(sWindRaw);
|
||||
if (!(sWindTick.IsValid && sWindTick.IsNewerThan(serverTick)))
|
||||
{
|
||||
float2 dir2 = math.lengthsq(sToTarget) > 1e-6f ? math.normalize(sToTarget.xz) : new float2(0f, 1f);
|
||||
if (haveSpit && liveSpits < math.max(1, spitCfg.MaxLiveProjectiles))
|
||||
{
|
||||
float3 spawnPos = pos + new float3(dir2.x, 0f, dir2.y) * 0.8f;
|
||||
spawnPos.y = pos.y;
|
||||
var spit = ecb.Instantiate(spitCfg.Prefab);
|
||||
ecb.SetComponent(spit, spitBakedLt.WithPosition(spawnPos)); // preserve baked [GhostField] Scale
|
||||
ecb.SetComponent(spit, new EnemyProjectile
|
||||
{
|
||||
Direction = dir2,
|
||||
Speed = sp.ProjectileSpeed,
|
||||
Damage = stats.ValueRO.AttackDamage,
|
||||
Range = spitBakedProj.Range,
|
||||
DistanceTravelled = 0f,
|
||||
LastStep = 0f,
|
||||
Region = sRegion,
|
||||
});
|
||||
ecb.AddComponent(spit, new RegionTag { Region = sRegion }); // relevancy (the spit prefab bakes none)
|
||||
liveSpits++;
|
||||
uint shotCd = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
|
||||
spitter.ValueRW.NextShotTick = TickUtil.NonZero(now + shotCd);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Over the concurrent cap (or no prefab wired): soft-fail — short retry, no full cooldown burn.
|
||||
spitter.ValueRW.NextShotTick = TickUtil.NonZero(now + 8u);
|
||||
}
|
||||
windup.ValueRW.WindUpUntilTick = 0;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
bool sReady = sp.NextShotTick == 0 || !new NetworkTick(sp.NextShotTick).IsNewerThan(serverTick);
|
||||
if (sReady && (sTargetEntity != Entity.Null || sCoreAlive))
|
||||
{
|
||||
uint wTicks = (uint)math.max(1, sp.WindupTicks);
|
||||
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + wTicks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Slice 1 (Feature D): derive the replicated IsLunging cue ONCE per tick from the end-of-tick LungeState
|
||||
// (single point, idempotent — mirrors PlayerDeathStateSystem deriving Dead from Health). .WithPresent so a
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
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>
|
||||
/// MC-2 — resolves hostile Spitter projectiles against PLAYERS + STRUCTURES (never other enemies — only
|
||||
/// PlayerTag / PlacedStructure are snapshotted, so a spit can't friendly-fire the Husks), server-only in the
|
||||
/// plain <see cref="SimulationSystemGroup"/> after <see cref="EnemyProjectileMoveSystem"/> (post-move position).
|
||||
/// SWEPT planar hit-test (the DR-018 anti-tunnelling discipline): the travel segment is rebuilt from the STORED
|
||||
/// <see cref="EnemyProjectile.LastStep"/> (cur - Direction*LastStep), NEVER a fresh delta. REGION-FILTERED: a
|
||||
/// target whose <see cref="RegionTag"/>.Region != the spit's Region is skipped — relevancy hides cross-region
|
||||
/// ghosts from CLIENTS, but the server world holds base + expedition players 1000u apart, so server damage needs
|
||||
/// its own guard (the missing-filter blocker the design review caught). On a hit it appends
|
||||
/// DamageEvent{SourceNetworkId=-1, SourceTick=now} (drained the FOLLOWING tick by the predicted
|
||||
/// <c>HealthApplyDamageSystem</c> — appending from the predicted loop would double-apply on rollback; SourceTick
|
||||
/// makes the dash i-frame negation correct across the 1-tick gap, so dash-through-spit works for free) and
|
||||
/// destroys the spit at-most-once; a spit past its Range expires.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(EnemyProjectileMoveSystem))]
|
||||
public partial struct EnemyProjectileDamageSystem : ISystem
|
||||
{
|
||||
/// <summary>Extra forgiveness for the spit's own size, added to a target's hit radius.</summary>
|
||||
const float k_ProjectileRadius = 0.2f;
|
||||
|
||||
/// <summary>Hit radius used for structures, which (by design) bake no HitRadius (so player shots never hit them).</summary>
|
||||
const float k_StructureRadius = 1.0f;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<EnemyProjectile>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
uint now = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick;
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
// Snapshot valid targets once (stable query order). PLAYERS carry HitRadius (PlayerAuthoring);
|
||||
// STRUCTURES deliberately do NOT (so player projectiles never friendly-fire the base) -> a constant.
|
||||
var targetEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
var targetPositions = new NativeList<float3>(Allocator.Temp);
|
||||
var targetRadii = new NativeList<float>(Allocator.Temp);
|
||||
var targetRegions = new NativeList<byte>(Allocator.Temp);
|
||||
|
||||
foreach (var (xform, hitRadius, health, region, e) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<Health>, RefRO<RegionTag>>()
|
||||
.WithAll<PlayerTag>().WithEntityAccess())
|
||||
{
|
||||
if (health.ValueRO.Current <= 0f) continue; // don't hit a corpse
|
||||
targetEntities.Add(e);
|
||||
targetPositions.Add(xform.ValueRO.Position);
|
||||
targetRadii.Add(hitRadius.ValueRO.Value);
|
||||
targetRegions.Add(region.ValueRO.Region);
|
||||
}
|
||||
foreach (var (xform, health, region, e) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
|
||||
.WithAll<PlacedStructure>().WithEntityAccess())
|
||||
{
|
||||
if (health.ValueRO.Current <= 0f) continue; // skip a structure pending destroy this tick
|
||||
targetEntities.Add(e);
|
||||
targetPositions.Add(xform.ValueRO.Position);
|
||||
targetRadii.Add(k_StructureRadius);
|
||||
targetRegions.Add(region.ValueRO.Region);
|
||||
}
|
||||
|
||||
var destroyed = new NativeHashSet<Entity>(16, Allocator.Temp);
|
||||
foreach (var (xform, proj, projEntity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<EnemyProjectile>>().WithEntityAccess())
|
||||
{
|
||||
float3 cur = xform.ValueRO.Position;
|
||||
float2 segEnd = new float2(cur.x, cur.z);
|
||||
float2 dir = proj.ValueRO.Direction;
|
||||
float2 segStart = segEnd - dir * proj.ValueRO.LastStep; // stored move-step, never a fresh dt
|
||||
float2 seg = segEnd - segStart;
|
||||
float segLenSq = math.lengthsq(seg);
|
||||
byte projRegion = proj.ValueRO.Region;
|
||||
|
||||
int bestIdx = -1;
|
||||
float bestT = float.MaxValue;
|
||||
for (int i = 0; i < targetEntities.Length; i++)
|
||||
{
|
||||
if (targetRegions[i] != projRegion) continue; // server-side damage region guard
|
||||
float2 tp = new float2(targetPositions[i].x, targetPositions[i].z);
|
||||
float t = segLenSq > 1e-8f
|
||||
? math.saturate(math.dot(tp - segStart, seg) / segLenSq)
|
||||
: 0f;
|
||||
float2 closest = segStart + t * seg;
|
||||
float hitDist = targetRadii[i] + k_ProjectileRadius;
|
||||
if (math.distancesq(tp, closest) <= hitDist * hitDist && t < bestT)
|
||||
{
|
||||
bestT = t;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx >= 0)
|
||||
{
|
||||
ecb.AppendToBuffer(targetEntities[bestIdx], new DamageEvent
|
||||
{
|
||||
Amount = proj.ValueRO.Damage,
|
||||
SourceNetworkId = -1, // hostile environment, not a player
|
||||
SourceTick = TickUtil.NonZero(now),
|
||||
});
|
||||
if (destroyed.Add(projEntity))
|
||||
ecb.DestroyEntity(projEntity);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (proj.ValueRO.DistanceTravelled >= proj.ValueRO.Range && destroyed.Add(projEntity))
|
||||
ecb.DestroyEntity(projEntity);
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
|
||||
ecb.Dispose();
|
||||
destroyed.Dispose();
|
||||
targetEntities.Dispose();
|
||||
targetPositions.Dispose();
|
||||
targetRadii.Dispose();
|
||||
targetRegions.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4f6dbd4ab9a2b154e8d7cb1796904ab6
|
||||
@@ -0,0 +1,49 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-2 — integrates hostile Spitter projectiles (<see cref="EnemyProjectile"/>) server-only in the plain
|
||||
/// <see cref="SimulationSystemGroup"/> (the spits are ownerless INTERPOLATED ghosts, not predicted — like the
|
||||
/// Husks that fire them). Advances each spit along its locked Direction at Speed*dt, accumulates
|
||||
/// DistanceTravelled, and STORES <see cref="EnemyProjectile.LastStep"/> = Speed*dt so
|
||||
/// <see cref="EnemyProjectileDamageSystem"/> can rebuild the exact swept segment it traversed this tick
|
||||
/// (cur - Direction*LastStep) WITHOUT re-reading a delta in that separate system (the DR-018 swept-tunnelling
|
||||
/// discipline — a fresh delta in the damage pass is the trap). Ordered <c>[UpdateAfter(EnemyAISystem)]</c> (the
|
||||
/// spawner) so a spit moves the same tick it is born. Writes LocalTransform (replicated via the stock variant);
|
||||
/// structural-free. dt is the server fixed step here, exactly as <see cref="EnemyAISystem"/> reads it.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(EnemyAISystem))]
|
||||
public partial struct EnemyProjectileMoveSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<EnemyProjectile>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
float dt = SystemAPI.Time.DeltaTime; // server fixed step in the plain group, same as EnemyAISystem
|
||||
foreach (var (xform, proj) in SystemAPI.Query<RefRW<LocalTransform>, RefRW<EnemyProjectile>>())
|
||||
{
|
||||
float step = proj.ValueRO.Speed * dt;
|
||||
float3 dir = new float3(proj.ValueRO.Direction.x, 0f, proj.ValueRO.Direction.y);
|
||||
float3 from = xform.ValueRO.Position;
|
||||
float3 pos = from + dir * step;
|
||||
pos.y = from.y; // hold the movement plane
|
||||
xform.ValueRW.Position = pos;
|
||||
proj.ValueRW.LastStep = step;
|
||||
proj.ValueRW.DistanceTravelled = proj.ValueRO.DistanceTravelled + step;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9acb4c22874b1fa489433644b90334db
|
||||
@@ -51,6 +51,21 @@ namespace ProjectM.Server
|
||||
|
||||
var wave = SystemAPI.GetComponent<WaveState>(directorEntity);
|
||||
|
||||
// MC-2 fork-4a: the base siege adopts the 4-type weighted mix (BaseCount = the Grunt base). The size
|
||||
// curve becomes WaveSlots(wave, bands) — a deliberate, operator-approved redefinition; MaxAlive is the
|
||||
// mandatory cap so spitter spits + swarmer packs can't spike the relevancy loop during the END-game climax.
|
||||
var bands = new MixBands
|
||||
{
|
||||
GruntBase = director.BaseCount,
|
||||
ChargerBase = director.ChargerBase,
|
||||
SpitterBase = director.SpitterBase,
|
||||
SwarmerSlotBase = director.SwarmerSlotBase,
|
||||
ChargerPerEpoch = director.ChargerPerEpoch,
|
||||
SpitterPerEpoch = director.SpitterPerEpoch,
|
||||
SwarmerSlotPerEpoch = director.SwarmerSlotPerEpoch,
|
||||
SwarmerPackPerEpoch = director.SwarmerPackPerEpoch,
|
||||
};
|
||||
|
||||
// Ring centre on the base plot when present.
|
||||
float3 center = new float3(0f, 1f, 0f);
|
||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var baseAnchor))
|
||||
@@ -65,8 +80,7 @@ namespace ProjectM.Server
|
||||
{
|
||||
// Start the next (bigger) wave.
|
||||
wave.WaveNumber += 1;
|
||||
wave.RemainingToSpawn =
|
||||
math.max(1, director.BaseCount + (wave.WaveNumber - 1) * director.CountPerWave);
|
||||
wave.RemainingToSpawn = ZoneEnemyMath.WaveSlots(wave.WaveNumber, bands);
|
||||
wave.Phase = WavePhase.Spawning;
|
||||
wave.NextActionTick = TickUtil.NonZero(now); // spawn the first Husk this tick
|
||||
}
|
||||
@@ -78,24 +92,41 @@ namespace ProjectM.Server
|
||||
if (dueNow)
|
||||
{
|
||||
int slots = math.max(1, director.RingSlots);
|
||||
int prefabIdx = wave.SpawnCounter % prefabs.Length;
|
||||
float3 pos = EnemyAIMath.RingPosition(center, wave.SpawnCounter, slots, director.RingRadius);
|
||||
pos.y = center.y;
|
||||
byte kind = ZoneEnemyMath.KindForSlot(wave.WaveNumber, wave.SpawnCounter, bands);
|
||||
int packSize = kind == ZoneEnemyMath.KindSwarmer
|
||||
? ZoneEnemyMath.PackSizeForSlot(wave.WaveNumber, wave.SpawnCounter, bands, director.SwarmerPackSize) : 1;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
var husk = ecb.Instantiate(prefabs[prefabIdx].Prefab);
|
||||
// Preserve the prefab's baked variant Scale (a replicated [GhostField]) + rotation;
|
||||
// LocalTransform.FromPosition() would reset Scale->1, shrinking/growing animated variants.
|
||||
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefabs[prefabIdx].Prefab);
|
||||
ecb.SetComponent(husk, baked.WithPosition(pos));
|
||||
// Husks belong to the base region (hidden from expedition players by relevancy).
|
||||
ecb.AddComponent(husk, new RegionTag { Region = RegionId.Base });
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
// Live BASE husks for the entity cap (expedition zone enemies are EnemyTag too -> excluded).
|
||||
int aliveBase = 0;
|
||||
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>())
|
||||
if (hr.ValueRO.Region == RegionId.Base) aliveBase++;
|
||||
|
||||
wave.SpawnCounter += 1;
|
||||
wave.RemainingToSpawn -= 1;
|
||||
wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.SpawnIntervalTicks));
|
||||
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — don't consume the slot).
|
||||
if (aliveBase + packSize <= math.max(1, director.MaxAlive))
|
||||
{
|
||||
int prefabIdx = kind;
|
||||
if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively
|
||||
float3 packCenter = EnemyAIMath.RingPosition(center, wave.SpawnCounter, slots, director.RingRadius);
|
||||
packCenter.y = center.y;
|
||||
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefabs[prefabIdx].Prefab);
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
for (int k = 0; k < packSize; k++)
|
||||
{
|
||||
float3 pos = packSize > 1
|
||||
? EnemyAIMath.ClusterOffset(packCenter, k, packSize, director.ClusterTightRadius) : packCenter;
|
||||
pos.y = center.y;
|
||||
var husk = ecb.Instantiate(prefabs[prefabIdx].Prefab);
|
||||
ecb.SetComponent(husk, baked.WithPosition(pos)); // preserve baked [GhostField] Scale
|
||||
ecb.AddComponent(husk, new RegionTag { Region = RegionId.Base });
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
|
||||
wave.SpawnCounter += 1; // ONE slot consumed even for a pack
|
||||
wave.RemainingToSpawn -= 1;
|
||||
wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.SpawnIntervalTicks));
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
@@ -71,58 +71,82 @@ namespace ProjectM.Server
|
||||
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
|
||||
int epoch = runtime.ExpeditionEpoch;
|
||||
|
||||
// (Re)seed this epoch's wave once — its OWN counter, never WaveState's.
|
||||
// MC-2: build the 4-type weighted mix band from the director's baked weights (shared math with the base
|
||||
// siege). GruntsPerWave/ChargersPerWave are the Grunt/Charger base counts.
|
||||
var bands = new MixBands
|
||||
{
|
||||
GruntBase = dir.GruntsPerWave,
|
||||
ChargerBase = dir.ChargersPerWave,
|
||||
SpitterBase = dir.SpitterBase,
|
||||
SwarmerSlotBase = dir.SwarmerSlotBase,
|
||||
ChargerPerEpoch = dir.ChargerPerEpoch,
|
||||
SpitterPerEpoch = dir.SpitterPerEpoch,
|
||||
SwarmerSlotPerEpoch = dir.SwarmerSlotPerEpoch,
|
||||
SwarmerPackPerEpoch = dir.SwarmerPackPerEpoch,
|
||||
};
|
||||
|
||||
// (Re)seed this epoch's wave once — its OWN counter (in SLOTS; a swarmer slot is one pack).
|
||||
if (zs.SeededEpoch != epoch)
|
||||
{
|
||||
zs.SeededEpoch = epoch;
|
||||
zs.SpawnCounter = 0;
|
||||
zs.RemainingToSpawn = ZoneEnemyMath.WaveSize(epoch, dir.GruntsPerWave, dir.ChargersPerWave);
|
||||
zs.NextSpawnTick = TickUtil.NonZero(now); // first enemy this tick
|
||||
zs.RemainingToSpawn = ZoneEnemyMath.WaveSlots(epoch, bands);
|
||||
zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick
|
||||
}
|
||||
|
||||
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
|
||||
|
||||
if (zs.RemainingToSpawn > 0)
|
||||
{
|
||||
// Spawn only in Calm (a base Siege pauses the expedition wave; it resumes when the base is safe), one
|
||||
// per cadence, and only while under the concurrent cap.
|
||||
// Spawn only in Calm (a base Siege pauses the expedition wave), one SLOT per cadence, under the cap.
|
||||
bool calm = cycle.Phase == CyclePhase.Calm;
|
||||
bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick);
|
||||
if (calm && dueNow && aliveZone < math.max(1, dir.MaxAlive))
|
||||
if (calm && dueNow)
|
||||
{
|
||||
float3 baseCenter = new float3(0f, 1f, 0f);
|
||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||
baseCenter = BaseGridMath.PlotCenter(anchor);
|
||||
float3 origin = RegionMath.RegionOrigin(RegionId.Expedition, baseCenter);
|
||||
|
||||
int slot = (int)zs.SpawnCounter;
|
||||
bool charger = ZoneEnemyMath.IsChargerSlot(epoch, slot, dir.GruntsPerWave, dir.ChargersPerWave);
|
||||
int prefabIdx = charger ? 1 : 0;
|
||||
if (prefabIdx >= prefabs.Length) prefabIdx = prefabs.Length - 1;
|
||||
var prefab = prefabs[prefabIdx].Prefab;
|
||||
byte kind = ZoneEnemyMath.KindForSlot(epoch, slot, bands);
|
||||
int packSize = kind == ZoneEnemyMath.KindSwarmer
|
||||
? ZoneEnemyMath.PackSizeForSlot(epoch, slot, bands, dir.SwarmerPackSize) : 1;
|
||||
|
||||
float3 pos = EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
|
||||
pos.y = origin.y;
|
||||
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — don't consume the slot).
|
||||
if (aliveZone + packSize <= math.max(1, dir.MaxAlive))
|
||||
{
|
||||
float3 baseCenter = new float3(0f, 1f, 0f);
|
||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||
baseCenter = BaseGridMath.PlotCenter(anchor);
|
||||
float3 origin = RegionMath.RegionOrigin(RegionId.Expedition, baseCenter);
|
||||
float3 center = EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
|
||||
center.y = origin.y;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
var enemy = ecb.Instantiate(prefab);
|
||||
// Preserve the prefab's baked Scale ([GhostField]) + rotation — FromPosition would reset Scale->1.
|
||||
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefab);
|
||||
ecb.SetComponent(enemy, baked.WithPosition(pos));
|
||||
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition });
|
||||
ecb.AddComponent<ZoneEnemyTag>(enemy);
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
int prefabIdx = kind;
|
||||
if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively
|
||||
var prefab = prefabs[prefabIdx].Prefab;
|
||||
// Preserve the prefab's baked Scale ([GhostField]) — FromPosition would reset Scale->1.
|
||||
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefab);
|
||||
|
||||
zs.SpawnCounter += 1;
|
||||
zs.RemainingToSpawn -= 1;
|
||||
zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks));
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
for (int k = 0; k < packSize; k++)
|
||||
{
|
||||
float3 pos = packSize > 1
|
||||
? EnemyAIMath.ClusterOffset(center, k, packSize, dir.ClusterTightRadius) : center;
|
||||
pos.y = origin.y;
|
||||
var enemy = ecb.Instantiate(prefab);
|
||||
ecb.SetComponent(enemy, baked.WithPosition(pos));
|
||||
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition });
|
||||
ecb.AddComponent<ZoneEnemyTag>(enemy);
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
|
||||
zs.SpawnCounter += 1; // ONE slot consumed even for a pack
|
||||
zs.RemainingToSpawn -= 1;
|
||||
zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks));
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (aliveZone == 0 && runtime.ClearedThisEpoch == 0)
|
||||
{
|
||||
// Wave fully spawned AND every zone enemy dead -> a REAL clear (the player killed them; the empty-edge
|
||||
// teardown can't reach here because we early-return when no one is out). Mark once; the gate pays the
|
||||
// Wave fully spawned AND every zone enemy dead -> a REAL clear. Mark once; the gate pays the
|
||||
// once-per-epoch Ore reward on the player's return to base.
|
||||
runtime.ClearedThisEpoch = 1;
|
||||
SystemAPI.SetComponent(cycleEntity, runtime);
|
||||
|
||||
@@ -29,7 +29,8 @@ namespace ProjectM.Simulation
|
||||
/// <summary>Per-variant wind-up DURATION in ticks (the client danger-ramp denominator).</summary>
|
||||
public byte WindupTicks;
|
||||
|
||||
/// <summary>0 = Grunt-style; 1 = Charger (committed-lunge tell).</summary>
|
||||
public byte IsCharger;
|
||||
/// <summary>Enemy kind for the client telegraph look: 0=Grunt, 1=Charger, 2=Spitter, 3=Swarmer (the
|
||||
/// ZoneEnemyMath.Kind* bytes). Baked per variant by EnemyBaker (the SOLE writer); never a [GhostField].</summary>
|
||||
public byte Kind;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,5 +126,42 @@ namespace ProjectM.Simulation
|
||||
if (sq < bestSq) { bestSq = sq; index = i; isStructure = true; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MC-2 Spitter range-band velocity (planar XZ): ADVANCE toward <paramref name="to"/> at
|
||||
/// <paramref name="speed"/> when farther than <paramref name="preferred"/> + <paramref name="tolerance"/>,
|
||||
/// RETREAT directly away when closer than <paramref name="preferred"/> - <paramref name="tolerance"/>, and
|
||||
/// HOLD (zero) inside the dead-zone band. Y forced to 0. Returns zero when the points coincide. Pure /
|
||||
/// Burst-safe / EditMode-testable; keeps the Spitter at its firing distance instead of closing to melee.
|
||||
/// </summary>
|
||||
public static float3 BandVelocity(float3 from, float3 to, float speed, float preferred, float tolerance)
|
||||
{
|
||||
float3 d = to - from;
|
||||
d.y = 0f;
|
||||
float distSq = math.lengthsq(d);
|
||||
if (distSq < 1e-8f)
|
||||
return float3.zero;
|
||||
float dist = math.sqrt(distSq);
|
||||
float3 dir = d / dist;
|
||||
float tol = math.max(0f, tolerance);
|
||||
if (dist > preferred + tol)
|
||||
return dir * speed; // too far -> close in
|
||||
if (dist < preferred - tol)
|
||||
return -dir * speed; // too close -> back off
|
||||
return float3.zero; // in band -> hold and fire
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic tight-cluster offset for swarmer <paramref name="index"/> of a pack of
|
||||
/// <paramref name="packSize"/> around <paramref name="center"/> at <paramref name="tightRadius"/> (reuses the
|
||||
/// <see cref="RingPosition"/> even-angle math at a small radius). A single swarmer (packSize<=1) spawns at
|
||||
/// the centre. Stable per index so a replayed pack lands identically. Pure.
|
||||
/// </summary>
|
||||
public static float3 ClusterOffset(float3 center, int index, int packSize, float tightRadius)
|
||||
{
|
||||
if (packSize <= 1)
|
||||
return center;
|
||||
return RingPosition(center, index, packSize, tightRadius);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-2 — a hostile Spitter projectile: a server-spawned, OWNERLESS INTERPOLATED ghost moved server-only in the
|
||||
/// plain SimulationSystemGroup (NOT predicted — like the Husks that fire it). It replicates ONLY the stock
|
||||
/// LocalTransform (no hand-written [GhostField]); this component is server-only state. It deliberately carries NO
|
||||
/// Health, so it is invisible to every WithAll<Health> target loop (player melee/projectile hit-tests can
|
||||
/// never see it — fork 2a: spits are pure dodge/dash checks, NOT shootable). Integrated by
|
||||
/// EnemyProjectileMoveSystem and swept-hit-tested against players + structures by EnemyProjectileDamageSystem.
|
||||
/// </summary>
|
||||
public struct EnemyProjectile : IComponentData
|
||||
{
|
||||
/// <summary>Planar heading (world XZ -> float2 x,y), unit length, locked at spawn.</summary>
|
||||
public float2 Direction;
|
||||
|
||||
/// <summary>Travel speed (world units/second).</summary>
|
||||
public float Speed;
|
||||
|
||||
/// <summary>Damage applied to the first valid same-region target hit.</summary>
|
||||
public float Damage;
|
||||
|
||||
/// <summary>Max travel distance before it expires (world units).</summary>
|
||||
public float Range;
|
||||
|
||||
/// <summary>Accumulated travelled distance (server-only; drives range-expiry).</summary>
|
||||
public float DistanceTravelled;
|
||||
|
||||
/// <summary>Distance moved on the LAST tick (= Speed * the server fixed step). The damage system rebuilds the
|
||||
/// swept segment as cur - Direction*LastStep — NEVER a fresh SystemAPI.Time.DeltaTime (this system runs in the
|
||||
/// PLAIN group where that dt is the wall-frame delta, not the fixed step). Prevents high-speed tunnelling.</summary>
|
||||
public float LastStep;
|
||||
|
||||
/// <summary>Region byte (RegionId.Base/Expedition), copied from the firing Spitter. The damage system skips any
|
||||
/// target whose RegionTag.Region != this — relevancy hides cross-region ghosts from CLIENTS, but the SERVER
|
||||
/// world holds base + expedition players 1000u apart, so server damage needs its OWN region guard.</summary>
|
||||
public byte Region;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baked subscene singleton: the Spitter projectile ghost prefab + the concurrent soft-cap. The server reads it
|
||||
/// via GetSingleton (the prefab Entity lives HERE, never per-Spitter — mirrors AbilityDatabase / WaveEnemyPrefab).
|
||||
/// MaxLiveProjectiles bounds the RegionRelevancySystem O(ghosts x conn)/tick loop: a Spitter at/over the cap
|
||||
/// soft-fails its shot (no cooldown burn — the EB-2 turret soft-fail pattern).
|
||||
/// </summary>
|
||||
public struct SpitterProjectilePrefab : IComponentData
|
||||
{
|
||||
public Entity Prefab;
|
||||
public int MaxLiveProjectiles;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8a31a7b0c834ae24db480005ffdb6a15
|
||||
@@ -0,0 +1,30 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-2 — baked weighted-composition table shared by BOTH enemy directors (the expedition
|
||||
/// ZoneEnemyDirectorSystem and the base-siege WaveSystem). Pure integer weights consumed by the deterministic
|
||||
/// <see cref="ZoneEnemyMath"/>.{WaveSlots, KindForSlot, PackSizeForSlot} functions (no enum, no RNG ->
|
||||
/// replay/save-stable). Per kind: a base count + a per-epoch ramp; the Grunt count is the REMAINDER (slots minus
|
||||
/// the others) so it stays a fixed floor while chargers / spitters / swarmer-slots grow as the epoch (expedition)
|
||||
/// or wave (base siege) climbs. A "swarmer slot" expands to a PackSize cluster at spawn (PackSizeForSlot), so one
|
||||
/// slot = one pack. The LEGACY band {GruntBase=g, ChargerBase=c, ChargerPerEpoch=1, rest 0} reproduces the old
|
||||
/// 2-type <see cref="ZoneEnemyMath.WaveSize"/> / <see cref="ZoneEnemyMath.IsChargerSlot"/> exactly (a parity test
|
||||
/// pins this, so the base-siege size curve is provably unchanged where it must be).
|
||||
/// </summary>
|
||||
public struct MixBands : IComponentData
|
||||
{
|
||||
public int GruntBase;
|
||||
public int ChargerBase;
|
||||
public int SpitterBase;
|
||||
public int SwarmerSlotBase;
|
||||
public int ChargerPerEpoch;
|
||||
public int SpitterPerEpoch;
|
||||
public int SwarmerSlotPerEpoch;
|
||||
|
||||
/// <summary>Exposed-but-default-0 epoch ramp for the swarmer PACK size (PackSizeForSlot adds
|
||||
/// SwarmerPackPerEpoch*(epoch-1) to the director's base pack size). v1 keeps it 0 = fixed pack size.</summary>
|
||||
public int SwarmerPackPerEpoch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 850f904d96b1c7d41959dddbdbf0b4b5
|
||||
@@ -0,0 +1,45 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-2 — server-only Spitter "reposition" brain state. Component PRESENCE is the Spitter discriminator (no
|
||||
/// enum / brain byte — honours the Burst cross-assembly-enum rule; EnemyAISystem is Bursted): a Husk variant
|
||||
/// baked with SpitterState is driven by the ranged range-band branch, mutually exclusive with the Charger
|
||||
/// branch (the AI partitions Spitter = .WithAll<EnemyTag,SpitterState>().WithNone<LungeState>() so no
|
||||
/// enemy is ever double-moved). The Spitter holds a PREFERRED RANGE band from its target — retreating if too
|
||||
/// close, advancing if too far — and fires a TELEGRAPHED, dodgeable projectile on its OWN fire gate. If
|
||||
/// cornered (no retreat room) within CorneredRange it falls back to the Grunt seek+strike. NOT a [GhostField]
|
||||
/// (only server systems read it). All ticks via TickUtil.NonZero; compared with NetworkTick only.
|
||||
/// </summary>
|
||||
public struct SpitterState : IComponentData
|
||||
{
|
||||
/// <summary>Band centre: the distance the Spitter tries to hold from its target (world units).</summary>
|
||||
public float PreferredRange;
|
||||
|
||||
/// <summary>Half-width dead-zone around PreferredRange; inside [pref-tol, pref+tol] the Spitter holds.</summary>
|
||||
public float RangeTolerance;
|
||||
|
||||
/// <summary>Muzzle speed baked onto the spit projectile (world units/second).</summary>
|
||||
public float ProjectileSpeed;
|
||||
|
||||
/// <summary>If the target closes within this distance AND the Spitter can't retreat, it melee-falls-back.</summary>
|
||||
public float CorneredRange;
|
||||
|
||||
/// <summary>Telegraph wind-up lead in ticks before the spit fires (the dodge window). Baked (v1 not
|
||||
/// live-tunable); keep >= ~24 (> interp delay) so a player reacting to the aim-line can clear the shot.</summary>
|
||||
public int WindupTicks;
|
||||
|
||||
/// <summary>Server-only fire gate: raw tick of the earliest tick it may spit again (NonZero; 0 = ready). Its
|
||||
/// OWN gate, never EnemyAttackCooldown. Compared via NetworkTick.IsNewerThan.</summary>
|
||||
public uint NextShotTick;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MC-2 — pure marker for a Swarmer "surround" enemy: mechanically a Grunt (NO AI branch — it falls through the
|
||||
/// Grunt seek+strike pass) with swarm-tuned baked EnemyStats (fast, low-HP, fast frequent low-chip bites). The
|
||||
/// tag drives only (a) the director's CLUSTER spawn (PackSize swarmers in one tick) and (b) a client tint. Keeps
|
||||
/// EnemyTag + RegionTag like every Husk, so readability / health-bars / damage / region-AI all work unchanged.
|
||||
/// </summary>
|
||||
public struct SwarmerTag : IComponentData { }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: be9404154fd4f964099918079d2da6b8
|
||||
@@ -16,6 +16,17 @@ namespace ProjectM.Simulation
|
||||
public int CountPerWave;
|
||||
public int SpawnIntervalTicks;
|
||||
public int LullTicks;
|
||||
public int MaxAlive; // MC-2 fork-4a: mandatory cap for the 4-type base siege (uncapped packs/spits spike relevancy).
|
||||
// MC-2 base-siege mix bands (the director builds a ZoneEnemyMath.MixBands): BaseCount above = the Grunt base count.
|
||||
public int ChargerBase;
|
||||
public int SpitterBase;
|
||||
public int SwarmerSlotBase;
|
||||
public int ChargerPerEpoch;
|
||||
public int SpitterPerEpoch;
|
||||
public int SwarmerSlotPerEpoch;
|
||||
public int SwarmerPackSize;
|
||||
public int SwarmerPackPerEpoch;
|
||||
public float ClusterTightRadius;
|
||||
}
|
||||
|
||||
/// <summary>Baked pool of Husk prefab variants the director draws from round-robin (Grunt / Swarmer / Brute / ...).</summary>
|
||||
|
||||
@@ -28,6 +28,16 @@ namespace ProjectM.Simulation
|
||||
public int SpawnIntervalTicks;
|
||||
public int GruntsPerWave;
|
||||
public int ChargersPerWave;
|
||||
// MC-2 mix bands (the director builds a ZoneEnemyMath.MixBands from these): GruntsPerWave/ChargersPerWave
|
||||
// above are the Grunt/Charger BASE counts; these add the Spitter/Swarmer bases + per-epoch ramps + the pack.
|
||||
public int SpitterBase;
|
||||
public int SwarmerSlotBase;
|
||||
public int ChargerPerEpoch;
|
||||
public int SpitterPerEpoch;
|
||||
public int SwarmerSlotPerEpoch;
|
||||
public int SwarmerPackSize; // swarmers per swarmer-slot cluster (>=1)
|
||||
public int SwarmerPackPerEpoch; // exposed pack ramp (v1 default 0 = fixed)
|
||||
public float ClusterTightRadius; // tight ring radius for a swarmer pack
|
||||
public int RewardOre;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,5 +38,66 @@ namespace ProjectM.Simulation
|
||||
int s = ((slot % size) + size) % size;
|
||||
return s >= size - chargers;
|
||||
}
|
||||
|
||||
// ---- MC-2: 4-type weighted composition (Grunt/Charger/Spitter/Swarmer), shared by both directors ----
|
||||
// Kind bytes (NO C# enum — directors index a per-Kind prefab buffer by these; EnemyAISystem is Bursted).
|
||||
public const byte KindGrunt = 0;
|
||||
public const byte KindCharger = 1;
|
||||
public const byte KindSpitter = 2;
|
||||
public const byte KindSwarmer = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Total SLOTS in this epoch/wave under <paramref name="bands"/>: GruntBase + the per-kind ramped counts
|
||||
/// (charger/spitter/swarmer-slot = base + perEpoch*(epoch-1)). Lower-bounded at 1 so there is always a fight.
|
||||
/// A swarmer SLOT expands to a pack at spawn (<see cref="PackSizeForSlot"/>), so this counts packs, not
|
||||
/// individual swarmers. For the LEGACY band it equals <see cref="WaveSize"/> (parity-tested). Pure integer.
|
||||
/// </summary>
|
||||
public static int WaveSlots(int epoch, in MixBands bands)
|
||||
{
|
||||
int e = math.max(1, epoch);
|
||||
int grunts = math.max(0, bands.GruntBase);
|
||||
int chargers = math.max(0, bands.ChargerBase + bands.ChargerPerEpoch * (e - 1));
|
||||
int spitters = math.max(0, bands.SpitterBase + bands.SpitterPerEpoch * (e - 1));
|
||||
int swarmers = math.max(0, bands.SwarmerSlotBase + bands.SwarmerSlotPerEpoch * (e - 1));
|
||||
return math.max(1, grunts + chargers + spitters + swarmers);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic Kind byte for spawn <paramref name="slot"/> of this epoch/wave. Slots are partitioned in a
|
||||
/// FIXED order — Grunts, then Spitters, then Chargers, then Swarmer-slots last — so the wave skews threat-heavy
|
||||
/// as the ramped counts climb (Grunts are the remainder = a fixed floor). Any leftover slot (when the kinds
|
||||
/// under-fill the max(1,..) floor) defaults to Grunt. Stable per (epoch, slot). For the LEGACY band this
|
||||
/// returns KindCharger on exactly the slots the old <see cref="IsChargerSlot"/> did (parity-tested). Pure.
|
||||
/// </summary>
|
||||
public static byte KindForSlot(int epoch, int slot, in MixBands bands)
|
||||
{
|
||||
int e = math.max(1, epoch);
|
||||
int size = WaveSlots(epoch, bands);
|
||||
int chargers = math.max(0, bands.ChargerBase + bands.ChargerPerEpoch * (e - 1));
|
||||
int spitters = math.max(0, bands.SpitterBase + bands.SpitterPerEpoch * (e - 1));
|
||||
int swarmers = math.max(0, bands.SwarmerSlotBase + bands.SwarmerSlotPerEpoch * (e - 1));
|
||||
int grunts = math.max(0, size - chargers - spitters - swarmers); // remainder = fixed grunt floor
|
||||
|
||||
int s = ((slot % size) + size) % size;
|
||||
if (s < grunts) return KindGrunt;
|
||||
s -= grunts;
|
||||
if (s < spitters) return KindSpitter;
|
||||
s -= spitters;
|
||||
if (s < chargers) return KindCharger;
|
||||
s -= chargers;
|
||||
if (s < swarmers) return KindSwarmer;
|
||||
return KindGrunt; // defensive: unreachable while counts sum to size
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Swarmer cluster size for a swarmer slot: <paramref name="basePackSize"/> plus the (default-0)
|
||||
/// <see cref="MixBands.SwarmerPackPerEpoch"/> ramp. Lower-bounded at 1. v1 bakes the ramp 0 -> a fixed pack;
|
||||
/// the field is exposed for later tuning.
|
||||
/// </summary>
|
||||
public static int PackSizeForSlot(int epoch, int slot, in MixBands bands, int basePackSize)
|
||||
{
|
||||
int e = math.max(1, epoch);
|
||||
return math.max(1, basePackSize + math.max(0, bands.SwarmerPackPerEpoch) * (e - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user