Boss becomes a real fight + readable-but-fair enemy threat

BossState (server-only) + BossAISystem: sole mover, seek -> telegraphed
radial slam (AttackWindup) -> phase-2 speed + swarmer summon. Excluded from
the EnemyAISystem Charger pass; knockback-immune at all 3 stamp sites;
arena-anchored spawn; scaled Health/HitRadius/AttackRange. Grunt windup now
commits in its last ~30% (whiffs out-of-range); Charger stagger roots; grunt
speed 4.2->5.2; swarmer telegraph synced. SweptMove + zone-enemy spawn-stamp
extracted to shared helpers. Zero new [GhostField].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 20:21:18 -07:00
parent c6b7890212
commit 3995af736c
16 changed files with 456 additions and 70 deletions
+1 -1
View File
@@ -927,7 +927,7 @@ MonoBehaviour:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
MaxHealth: 30
HitRadius: 0.7
MoveSpeed: 4.2
MoveSpeed: 5.2
AttackRange: 1.6
AttackDamage: 5
AttackCooldownTicks: 48
@@ -67,7 +67,7 @@ namespace ProjectM.Authoring
Debug.LogError($"Enemy '{authoring.name}' has BOTH ChargerAuthoring and SpitterAuthoring; it would match no AI pass and never move. Remove one.", authoring);
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; }
else if (GetComponent<SwarmerAuthoring>() != null) { kind = ZoneEnemyMath.KindSwarmer; windup = (byte)Tuning.AttackWindupTicks; /* B4: match the server windup (grunt-path swarmers use GruntWindupTicks); baked 6 was a snap-ramp lie */ }
AddComponent(entity, new EnemyTelegraph { WindupTicks = windup, Kind = kind });
}
}
@@ -0,0 +1,198 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Physics;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative EXPEDITION BOSS brain — the SOLE mover/attacker of <c>.WithAll&lt;EnemyTag, BossState&gt;()</c>
/// (EnemyAISystem's Charger MOVE pass excludes it via <c>.WithNone&lt;BossState&gt;()</c>, so exactly one system
/// writes the boss's Position/Rotation/AttackWindup — the sole-writer invariant). Runs SERVER-ONLY in the plain
/// <see cref="SimulationSystemGroup"/> <c>[UpdateAfter(EnemyAISystem)]</c> (a linear chain, no sort cycle), once per
/// tick (interpolated ghost, no rollback → no Simulate filter, no IsFirstTimeFullyPredictingTick).
///
/// v2 boss = a real fight (operator-locked): chase the nearest living expedition player, then a telegraphed radial
/// SLAM — the client danger cue rides the replicated <see cref="AttackWindup"/> [GhostField] (CombatFeedbackSystem
/// draws a boss-scale ring). At/below <see cref="Tuning.BossPhase2HealthFraction"/> HP it enters phase two: faster,
/// slams more often, and periodically summons swarmer adds. The boss does NOT lunge (it keeps its baked LungeState
/// idle, so EnemyAISystem's IsLunging derive sees UntilTick==0 → bit off, harmless). Knockback-immune (the stamp
/// sites skip BossState; this system also clears any residual so nothing else can shove it). Summoned adds go
/// through <see cref="ZoneEnemySpawnUtil"/> so they carry the SAME ZoneEnemyTag/RoomTag/RegionTag stack the
/// room-clear gate + teardown depend on (dropping one would leak adds or clear the room early). All ticks route
/// through <c>TickUtil.NonZero</c> and compare with <see cref="NetworkTick"/> only (never raw uint).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(EnemyAISystem))]
public partial struct BossAISystem : ISystem
{
EntityQuery m_Bosses;
EntityQuery m_ZoneEnemies;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
m_Bosses = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>(), ComponentType.ReadOnly<BossState>());
state.RequireForUpdate(m_Bosses);
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>());
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
float dt = SystemAPI.Time.DeltaTime;
// Living EXPEDITION players — the boss's only valid targets. Snapshot once (stable query order).
var playerEntities = new NativeList<Entity>(Allocator.Temp);
var playerPositions = new NativeList<float3>(Allocator.Temp);
foreach (var (xform, health, region, entity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
.WithAll<PlayerTag>().WithEntityAccess())
{
if (health.ValueRO.Current <= 0f || region.ValueRO.Region != RegionId.Expedition)
continue;
playerEntities.Add(entity);
playerPositions.Add(xform.ValueRO.Position);
}
// Collide-and-slide setup (mirrors EnemyAISystem).
bool havePhysics = SystemAPI.TryGetSingleton<PhysicsWorldSingleton>(out var physics);
uint envMask = SystemAPI.TryGetSingleton<WorldCollisionConfig>(out var worldCol) ? worldCol.EnvironmentMask : 0u;
uint sweepMask = envMask | worldCol.StructureMask;
var envFilter = new CollisionFilter { BelongsTo = ~0u, CollidesWith = sweepMask, GroupIndex = 0 };
bool sweep = havePhysics && sweepMask != 0u;
const float SweepRadius = 0.8f; // the boss is a big body
int liveZone = m_ZoneEnemies.CalculateEntityCount();
// Summon resources (phase two): the swarmer prefab + baked transform + the current room byte.
bool haveDirector = SystemAPI.TryGetSingletonEntity<ZoneEnemyDirector>(out var directorEntity);
Entity swarmerPrefab = Entity.Null;
LocalTransform swarmerBaked = default;
if (haveDirector)
{
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
if (prefabs.Length > ZoneEnemyMath.KindSwarmer)
{
swarmerPrefab = prefabs[ZoneEnemyMath.KindSwarmer].Prefab;
if (swarmerPrefab != Entity.Null)
swarmerBaked = state.EntityManager.GetComponentData<LocalTransform>(swarmerPrefab);
}
}
byte roomByte = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo) ? (byte)(runInfo.CurrentRoom & 0xFF) : (byte)0;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (xform, stats, health, boss, windup, knockback) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRO<Health>, RefRW<BossState>,
RefRW<AttackWindup>, RefRW<KnockbackState>>()
.WithAll<EnemyTag, BossState>())
{
float3 pos = xform.ValueRO.Position;
// Knockback-immune: never recoil (A4). Zero any residual so a competing stamp can't shove the boss.
if (knockback.ValueRO.UntilTick != 0u) knockback.ValueRW.UntilTick = 0u;
// Phase from the boss's own Current vs (server-side, real ×BossHealthMultiplier) Max.
float maxHp = math.max(1f, health.ValueRO.Max);
byte phase = health.ValueRO.Current <= maxHp * Tuning.BossPhase2HealthFraction ? (byte)2 : (byte)1;
boss.ValueRW.Phase = phase;
// Target: nearest living expedition player.
int tgt = -1; float bestSq = float.MaxValue;
for (int i = 0; i < playerPositions.Length; i++)
{
float d = math.distancesq(pos, playerPositions[i]);
if (d < bestSq) { bestSq = d; tgt = i; }
}
if (tgt < 0)
continue; // no valid target -> idle (InRoom-abort handles a fully-empty expedition)
float3 targetPos = playerPositions[tgt];
// Face the target (planar) at all times, incl. while telegraphing.
float3 toTarget = targetPos - pos; toTarget.y = 0f;
if (math.lengthsq(toTarget) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(toTarget), math.up());
// --- SLAM in progress: root (the telegraph) until it lands, then AoE all players in the ring. ---
uint windRaw = windup.ValueRO.WindUpUntilTick;
if (windRaw != 0u)
{
var wt = new NetworkTick(windRaw);
if (!(wt.IsValid && wt.IsNewerThan(serverTick)))
{
float slamSq = Tuning.BossSlamRadius * Tuning.BossSlamRadius;
for (int i = 0; i < playerEntities.Length; i++)
{
if (math.distancesq(pos, playerPositions[i]) > slamSq)
continue;
ecb.AppendToBuffer(playerEntities[i], new DamageEvent
{
Amount = Tuning.BossSlamDamage,
SourceNetworkId = -1, // environment / boss, not a player
SourceTick = TickUtil.NonZero(now),
});
}
windup.ValueRW.WindUpUntilTick = 0u;
uint baseCd = Tuning.BossSlamCooldownTicks;
uint cd = phase == 2
? (uint)math.max(1f, baseCd * Tuning.BossPhase2SlamCooldownMult)
: baseCd;
boss.ValueRW.SlamReadyTick = TickUtil.NonZero(now + cd);
}
continue; // rooted while winding up (the tell); rotation already written above
}
// --- Chase (no active slam). ---
float speed = stats.ValueRO.MoveSpeed * (phase == 2 ? Tuning.BossPhase2SpeedMult : 1f);
float stopDist = stats.ValueRO.AttackRange * 0.9f;
float3 vel = EnemyAIMath.SeekVelocity(pos, targetPos, speed, stopDist);
float3 newPos = pos + vel * dt; newPos.y = pos.y;
if (sweep) newPos = EnemyMoveUtil.SweptMove(in physics, pos, newPos, SweepRadius, envFilter);
xform.ValueRW.Position = newPos;
// Slam gate: ready + a player inside (ring + a small lead) -> commit a telegraphed slam.
bool slamReady = boss.ValueRO.SlamReadyTick == 0u
|| !new NetworkTick(boss.ValueRO.SlamReadyTick).IsNewerThan(serverTick);
float lead = Tuning.BossSlamRadius + 1.5f;
if (slamReady && math.distancesq(newPos, targetPos) <= lead * lead)
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + Tuning.BossSlamWindupTicks);
// Summon (phase two only): ready + under the live cap + a swarmer prefab wired.
if (phase == 2 && swarmerPrefab != Entity.Null && liveZone < Tuning.BossSummonLiveCap)
{
bool summonReady = boss.ValueRO.SummonReadyTick == 0u
|| !new NetworkTick(boss.ValueRO.SummonReadyTick).IsNewerThan(serverTick);
if (summonReady)
{
int toSpawn = math.min(Tuning.BossSummonCount, Tuning.BossSummonLiveCap - liveZone);
for (int k = 0; k < toSpawn; k++)
{
float3 spawnPos = EnemyAIMath.ClusterOffset(newPos, k, math.max(1, toSpawn), 2.5f);
spawnPos.y = newPos.y;
ZoneEnemySpawnUtil.Spawn(ecb, swarmerPrefab, in swarmerBaked, spawnPos, RegionId.Expedition, roomByte);
liveZone++;
}
boss.ValueRW.SummonReadyTick = TickUtil.NonZero(now + Tuning.BossSummonCooldownTicks);
}
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
playerEntities.Dispose();
playerPositions.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 105d73021b780c449a16ea72bcc29b69
@@ -165,25 +165,32 @@ namespace ProjectM.Server
if (windRaw != 0)
{
if (!inRange)
// B1 commitment: once inside the final ~30% of the wind-up the strike is COMMITTED — leaving
// range no longer cancels it (a last-instant step-back won't save you; dash i-frames or an early
// exit will). A committed swing the player dodged out of WHIFFS at elapse (still burns cooldown)
// -> the punish window, mirroring the Charger. Grunts (speed < player) can now actually land on a
// lingering player instead of being trivially kited (readable-but-fair).
var windTick = new NetworkTick(windRaw);
bool elapsed = !(windTick.IsValid && windTick.IsNewerThan(serverTick));
int remain = windTick.IsValid ? windTick.TicksSince(serverTick) : 0;
uint gTotal = (uint)math.max(1f, tune.GruntWindupTicks);
bool committed = remain > 0 && remain <= (int)(gTotal * 0.3f);
if (!elapsed)
{
windup.ValueRW.WindUpUntilTick = 0; // target left range -> cancel the wind-up
if (!inRange && !committed)
windup.ValueRW.WindUpUntilTick = 0; // left range early -> cancel the wind-up
}
else
{
var windTick = new NetworkTick(windRaw);
if (!(windTick.IsValid && windTick.IsNewerThan(serverTick)))
if (inRange && targetEntity != Entity.Null) ecb.AppendToBuffer(targetEntity, new DamageEvent
{
if (targetEntity != Entity.Null) ecb.AppendToBuffer(targetEntity, new DamageEvent
{
Amount = stats.ValueRO.AttackDamage,
SourceNetworkId = -1, // environment / Husk, not a player
SourceTick = TickUtil.NonZero(now),
});
uint cooldownTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cooldownTicks);
windup.ValueRW.WindUpUntilTick = 0;
}
Amount = stats.ValueRO.AttackDamage,
SourceNetworkId = -1, // environment / Husk, not a player
SourceTick = TickUtil.NonZero(now),
});
uint cooldownTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cooldownTicks);
windup.ValueRW.WindUpUntilTick = 0;
}
}
else if (inRange)
@@ -216,7 +223,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>().WithNone<SpitterState>())
.WithAll<EnemyTag>().WithNone<SpitterState, BossState>())
{
float3 pos = xform.ValueRO.Position;
byte cHuskRegion = region.ValueRO.Region;
@@ -297,12 +304,18 @@ namespace ProjectM.Server
lunge.ValueRW.StaggerUntilTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); // scoreable punish window
}
// 3. Seek + face (shared shape with the Grunt path).
float cStop = stats.ValueRO.AttackRange * 0.9f;
float3 cvel = EnemyAIMath.SeekVelocity(pos, cTargetPos, stats.ValueRO.MoveSpeed, cStop);
float3 cNewPos = pos + cvel * dt; cNewPos.y = pos.y;
if (sweep) cNewPos = SweptMove(in physics, pos, cNewPos, SweepRadius, envFilter);
xform.ValueRW.Position = cNewPos;
// 3. Seek + face (shared shape with the Grunt path). B3: a whiffed Charger is ROOTED during its
// stagger punish window so the advertised punish reads (the player sees it stop). Facing still tracks.
bool cStaggered = lunge.ValueRO.StaggerUntilTick != 0u
&& new NetworkTick(lunge.ValueRO.StaggerUntilTick).IsNewerThan(serverTick);
if (!cStaggered)
{
float cStop = stats.ValueRO.AttackRange * 0.9f;
float3 cvel = EnemyAIMath.SeekVelocity(pos, cTargetPos, stats.ValueRO.MoveSpeed, cStop);
float3 cNewPos = pos + cvel * dt; cNewPos.y = pos.y;
if (sweep) cNewPos = SweptMove(in physics, pos, cNewPos, SweepRadius, envFilter);
xform.ValueRW.Position = cNewPos;
}
float3 cToTarget = cTargetPos - pos; cToTarget.y = 0f;
if (math.lengthsq(cToTarget) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(cToTarget), math.up());
@@ -472,37 +485,10 @@ namespace ProjectM.Server
structureRegions.Dispose();
}
// Swept collide-and-slide for server-authoritative Husk movement: sphere-cast the intended step against
// the static environment (boundary ring + landmarks) and stop at / glance along the first wall hit. Closest-
// hit SphereCast is non-generic -> Burst-safe (CLAUDE.md generic-collector hazard avoided). Y is held flat.
// Swept collide-and-slide for server-authoritative enemy movement — delegates to the shared
// EnemyMoveUtil.SweptMove (extracted so BossAISystem reuses ONE collide-and-slide impl; a fix reaches both).
// Kept as a private wrapper so this file's many call sites read unchanged.
static float3 SweptMove(in PhysicsWorldSingleton physics, float3 from, float3 to, float radius, CollisionFilter filter)
{
float3 delta = to - from;
delta.y = 0f;
float dist = math.length(delta);
if (dist < 1e-5f)
return to;
float3 dir = delta / dist;
const float skin = 0.05f;
var cw = physics.CollisionWorld;
if (!cw.SphereCast(from, radius, dir, dist, out var hit, filter))
return to;
float allowed = math.max(0f, hit.Fraction * dist - skin);
float3 stop = from + dir * allowed;
stop.y = from.y;
// Slide the unused motion along the wall, then sweep the slide so we don't tunnel a second wall.
float3 slide = EnemyAIMath.SlideVelocity(to - stop, hit.SurfaceNormal);
float slideDist = math.length(slide);
if (slideDist < 1e-5f)
return stop;
float3 sdir = slide / slideDist;
float3 result = cw.SphereCast(stop, radius, sdir, slideDist, out var hit2, filter)
? stop + sdir * math.max(0f, hit2.Fraction * slideDist - skin)
: stop + slide;
result.y = from.y;
return result;
}
=> EnemyMoveUtil.SweptMove(in physics, from, to, radius, filter);
}
}
@@ -0,0 +1,48 @@
using ProjectM.Simulation;
using Unity.Mathematics;
using Unity.Physics;
namespace ProjectM.Server
{
/// <summary>
/// Shared server-side collide-and-slide mover for OWNERLESS enemy ghosts (extracted from EnemyAISystem so the
/// boss brain reuses ONE copy — a fix to the tunnelling sweep or wall-glance reaches every enemy at once).
/// Both EnemyAISystem's three passes and BossAISystem call <see cref="SweptMove"/>. Pure given the CollisionWorld;
/// Burst-safe (non-generic closest-hit SphereCast, per the CLAUDE.md generic-collector hazard).
/// </summary>
public static class EnemyMoveUtil
{
/// <summary>Collide-and-slide sphere-cast for server-authoritative enemy movement: sweep the intended step
/// against the static environment (boundary ring + landmarks + player-built walls) and stop at / glance along
/// the first wall hit. Y is held flat (top-down movement plane).</summary>
public static float3 SweptMove(in PhysicsWorldSingleton physics, float3 from, float3 to, float radius, CollisionFilter filter)
{
float3 delta = to - from;
delta.y = 0f;
float dist = math.length(delta);
if (dist < 1e-5f)
return to;
float3 dir = delta / dist;
const float skin = 0.05f;
var cw = physics.CollisionWorld;
if (!cw.SphereCast(from, radius, dir, dist, out var hit, filter))
return to;
float allowed = math.max(0f, hit.Fraction * dist - skin);
float3 stop = from + dir * allowed;
stop.y = from.y;
// Slide the unused motion along the wall, then sweep the slide so we don't tunnel a second wall.
float3 slide = EnemyAIMath.SlideVelocity(to - stop, hit.SurfaceNormal);
float slideDist = math.length(slide);
if (slideDist < 1e-5f)
return stop;
float3 sdir = slide / slideDist;
float3 result = cw.SphereCast(stop, radius, sdir, slideDist, out var hit2, filter)
? stop + sdir * math.max(0f, hit2.Fraction * slideDist - skin)
: stop + slide;
result.y = from.y;
return result;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5db3fdcf478dbe74a9912884c1320f16
@@ -43,6 +43,11 @@ namespace ProjectM.Server
/// <summary>RW lookup to stamp server-only knockback on a hit Husk (Husks bake KnockbackState; players/dummies don't).</summary>
ComponentLookup<KnockbackState> m_KnockbackLookup;
/// <summary>Read-only lookup so a BOSS (BossState) is skipped by the knockback stamp — the boss is
/// knockback-immune (A4) so a solo player can't perma-stunlock it out of its slam wind-ups.</summary>
ComponentLookup<BossState> m_BossLookup;
/// <summary>Extra forgiveness added to a target's hit radius to approximate the projectile's own size.</summary>
const float k_ProjectileRadius = 0.2f;
@@ -51,6 +56,8 @@ namespace ProjectM.Server
{
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(isReadOnly: true);
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
// No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely.
state.RequireForUpdate<Projectile>();
@@ -61,6 +68,8 @@ namespace ProjectM.Server
{
m_GhostOwnerLookup.Update(ref state);
m_KnockbackLookup.Update(ref state);
m_BossLookup.Update(ref state);
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
float dt = SystemAPI.Time.DeltaTime;
@@ -133,7 +142,7 @@ namespace ProjectM.Server
SourceTick = haveTick ? TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick) : 0u,
});
var hitTarget = targetEntities[bestIdx];
if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget))
if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget) && !m_BossLookup.HasComponent(hitTarget))
{
m_KnockbackLookup[hitTarget] = new KnockbackState
{
@@ -145,7 +145,7 @@ namespace ProjectM.Server
baseCenter = BaseGridMath.PlotCenter(anchor);
float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
float3 center = bossRoom
? origin // the boss anchors the room center
? origin + new float3(0f, 0f, 12f) // the boss anchors the room center
: EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
center.y = origin.y;
@@ -160,20 +160,35 @@ namespace ProjectM.Server
float3 pos = packSize > 1
? EnemyAIMath.ClusterOffset(center, k, packSize, dir.ClusterTightRadius) : center;
pos.y = origin.y;
var enemy = ecb.Instantiate(prefab);
var xform = baked.WithPosition(pos); // preserve the baked [GhostField] Scale
var enemy = ZoneEnemySpawnUtil.Spawn(ecb, prefab, in baked, pos, RegionId.Expedition, room);
if (bossRoom)
xform.Scale = baked.Scale * Tuning.BossScaleMultiplier;
ecb.SetComponent(enemy, xform);
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition });
ecb.AddComponent<ZoneEnemyTag>(enemy);
ecb.AddComponent(enemy, new RoomTag { Room = room });
if (bossRoom && SystemAPI.HasComponent<Health>(prefab))
{
var hp = SystemAPI.GetComponent<Health>(prefab);
hp.Current *= Tuning.BossHealthMultiplier;
hp.Max *= Tuning.BossHealthMultiplier;
ecb.SetComponent(enemy, hp);
// Boss = a scaled Charger given a real kit by BossAISystem. Scale the visual AND the
// hitbox/reach (so hits register on the big model + its reach matches), multiply Health,
// and tag BossState (server-only discriminator) so BossAISystem alone drives it.
var bxform = baked.WithPosition(pos);
bxform.Scale = baked.Scale * Tuning.BossScaleMultiplier;
ecb.SetComponent(enemy, bxform);
if (SystemAPI.HasComponent<Health>(prefab))
{
var hp = SystemAPI.GetComponent<Health>(prefab);
hp.Current *= Tuning.BossHealthMultiplier;
hp.Max *= Tuning.BossHealthMultiplier;
ecb.SetComponent(enemy, hp);
}
if (SystemAPI.HasComponent<HitRadius>(prefab))
{
var hr = SystemAPI.GetComponent<HitRadius>(prefab);
hr.Value *= Tuning.BossScaleMultiplier;
ecb.SetComponent(enemy, hr);
}
if (SystemAPI.HasComponent<EnemyStats>(prefab))
{
var es = SystemAPI.GetComponent<EnemyStats>(prefab);
es.AttackRange *= Tuning.BossScaleMultiplier;
ecb.SetComponent(enemy, es);
}
ecb.AddComponent(enemy, new BossState { Phase = 1 });
}
}
ecb.Playback(state.EntityManager);
@@ -0,0 +1,29 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// The ONE per-enemy spawn-stamp for expedition ROOM enemies, shared by RoomEnemyDirectorSystem (wave drip) and
/// BossAISystem (phase-two summon). Keeps the teardown/relevancy/clear-count contract in a single place so a boss
/// add can never silently drop a tag: <see cref="ZoneEnemyTag"/> (counted by the room-clear gate),
/// <see cref="RoomTag"/> (the RoomTeardown filter — Room byte MUST equal the current room), and
/// <see cref="RegionTag"/>{Expedition} (relevancy — an untagged add would leak to base players, a Base-tagged one
/// would hide from the expedition party). Scale preserved via <c>baked.WithPosition</c> (never FromPosition, which
/// resets the [GhostField] Scale). Returns the spawned entity so a caller can layer extra (e.g. boss HP/scale).
/// </summary>
public static class ZoneEnemySpawnUtil
{
public static Entity Spawn(EntityCommandBuffer ecb, Entity prefab, in LocalTransform baked, float3 pos, byte region, byte room)
{
var enemy = ecb.Instantiate(prefab);
ecb.SetComponent(enemy, baked.WithPosition(pos)); // preserve the baked [GhostField] Scale
ecb.AddComponent(enemy, new RegionTag { Region = region });
ecb.AddComponent<ZoneEnemyTag>(enemy);
ecb.AddComponent(enemy, new RoomTag { Room = room });
return enemy;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 73476d3f27c37514f8f681b480091a21
@@ -37,11 +37,17 @@ namespace ProjectM.Simulation
[BurstCompile]
public partial struct AbilityFireSystem : ISystem
{
// C3/A4: knockback stamp for the Warrior CONE (guarded HasComponent + boss-immune). Server-only use.
ComponentLookup<KnockbackState> m_KnockbackLookup;
ComponentLookup<BossState> m_BossLookup;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<AbilityDatabase>();
state.RequireForUpdate<NetworkTime>();
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
}
[BurstCompile]
@@ -63,6 +69,8 @@ namespace ProjectM.Simulation
var abilityDb = SystemAPI.GetSingleton<AbilityDatabase>();
bool isServer = state.WorldUnmanaged.IsServer();
m_KnockbackLookup.Update(ref state);
m_BossLookup.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.
@@ -132,6 +140,19 @@ namespace ProjectM.Simulation
SourceNetworkId = owner.ValueRO.NetworkId,
SourceTick = cStamp,
});
// 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).
if (m_KnockbackLookup.HasComponent(coneTargets[ci]) && !m_BossLookup.HasComponent(coneTargets[ci]))
{
float3 kd3 = coneTargetPos[ci] - xform.ValueRO.Position;
float2 kdir = math.lengthsq(kd3.xz) > 1e-6f ? math.normalize(kd3.xz) : cFace;
m_KnockbackLookup[coneTargets[ci]] = new KnockbackState
{
Dir = kdir,
Speed = Tuning.KnockbackSpeed,
UntilTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)),
};
}
}
}
uint coneCd = (uint)math.max(1, eff.ValueRO.CooldownTicks);
@@ -0,0 +1,31 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// SERVER-ONLY working state for the expedition BOSS (a scaled Charger that <see cref="Server"/>'s
/// RoomEnemyDirectorSystem tags at spawn). NOT replicated and NOT baked — added at runtime via ECB on the boss
/// entity, so it needs no ghost-hash change (a runtime-added replicated component would not replicate anyway;
/// this one is deliberately server-only, like <see cref="LungeState"/>/<see cref="KnockbackState"/>).
/// <para>
/// Component PRESENCE is the boss discriminator: BossAISystem is the SOLE mover/attacker of
/// <c>.WithAll&lt;EnemyTag, BossState&gt;()</c>, and EnemyAISystem's Charger MOVE pass excludes it via
/// <c>.WithNone&lt;BossState&gt;()</c> so exactly one system writes the boss's Position/AttackWindup. The boss does
/// NOT use LungeState (its signature move is a telegraphed radial SLAM, not a lunge) — so EnemyAISystem's
/// IsLunging derive visits it but sees <c>LungeState.UntilTick==0</c> and derives the bit off (harmless single
/// writer). <see cref="Phase"/> is a byte (never a C# enum on a Bursted path — the cross-assembly-enum ICE rule).
/// All tick fields route through <c>TickUtil.NonZero</c> and compare with <see cref="Unity.NetCode.NetworkTick"/>.
/// </para>
/// </summary>
public struct BossState : IComponentData
{
/// <summary>1 = phase one (heavy Charger + slam), 2 = phase two (&lt;50% HP: faster + summons adds). Byte, not enum.</summary>
public byte Phase;
/// <summary>Earliest raw tick the boss may begin its next radial SLAM wind-up (NonZero; 0 = ready).</summary>
public uint SlamReadyTick;
/// <summary>Earliest raw tick the boss may summon its next add pack (phase two only; NonZero; 0 = ready).</summary>
public uint SummonReadyTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fdf576e1f07162e43bae89c4ccc06dec
@@ -49,6 +49,8 @@ namespace ProjectM.Simulation
public partial struct MeleeComboSystem : ISystem
{
ComponentLookup<KnockbackState> m_KnockbackLookup;
ComponentLookup<BossState> m_BossLookup; // A4: the boss is knockback-immune (no melee stunlock out of its slams)
ComponentLookup<RegionTag> m_RegionLookup;
BufferLookup<InventorySlot> m_InvLookup;
BufferLookup<StatModifier> m_StatModLookup;
@@ -57,6 +59,8 @@ namespace ProjectM.Simulation
public void OnCreate(ref SystemState state)
{
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
m_RegionLookup = state.GetComponentLookup<RegionTag>(isReadOnly: true);
m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false);
m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true);
@@ -224,6 +228,8 @@ namespace ProjectM.Simulation
m_KnockbackLookup.Update(ref state);
m_BossLookup.Update(ref state);
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int s = 0; s < cleaves.Length; s++)
{
@@ -239,7 +245,7 @@ namespace ProjectM.Simulation
SourceNetworkId = c.OwnerId,
SourceTick = c.Stamp,
});
if (c.KnockSpeed > 0f && m_KnockbackLookup.HasComponent(target))
if (c.KnockSpeed > 0f && m_KnockbackLookup.HasComponent(target) && !m_BossLookup.HasComponent(target))
{
float3 d3 = enemyPositions[i] - c.From;
float2 kdir = new float2(d3.x, d3.z);
+36 -1
View File
@@ -72,7 +72,7 @@ namespace ProjectM.Simulation
/// <summary>DR-042 C6c: Ore deposited into the shared ledger at spawn on a NEW game ONLY (a restored save keeps
/// its persisted ledger). Bootstraps the Fabricator(30)->Charge->Turret(10) chain so a turret placed before any
/// mining isn't a silent cold deadlock. Ore-only so the 'build a Fabricator to arm turrets' lesson survives.</summary>
public const int StartingOre = 50;
public const int StartingOre = 90;
// ---- Expedition run economy (RoomFieldSystem / RunDirectorSystem) ----
@@ -95,6 +95,41 @@ namespace ProjectM.Simulation
/// polish; an idle player died in ~2 s of landing). Subsequent slots keep the normal drip cadence.</summary>
public const uint RoomEntryGraceTicks = 100;
// ---- Expedition BOSS (a scaled Charger given a real kit by BossAISystem; server-only feel consts) ----
/// <summary>Radial SLAM AoE radius (world units): a player inside this ring at wind-up elapse eats the hit
/// unless dashing (i-frames). Sized well above melee reach so the tell reads as a boss-scale ring.</summary>
public const float BossSlamRadius = 5.5f;
/// <summary>Radial SLAM damage. Heavy but survivable given generous i-frames + respawn (readable-but-fair).</summary>
public const float BossSlamDamage = 34f;
/// <summary>SLAM telegraph lead (~0.75 s @60): a fair dodge window under interp lag before the ring lands.</summary>
public const uint BossSlamWindupTicks = 45;
/// <summary>Ticks between SLAMs in phase one (~4 s). Phase two multiplies this by BossPhase2SlamCooldownMult.</summary>
public const uint BossSlamCooldownTicks = 240;
/// <summary>Health fraction at/below which the boss enters phase two (faster + summons adds).</summary>
public const float BossPhase2HealthFraction = 0.5f;
/// <summary>Phase-two move-speed multiplier over the boss's base EnemyStats.MoveSpeed (a visible gear shift).</summary>
public const float BossPhase2SpeedMult = 1.45f;
/// <summary>Phase-two SLAM cooldown multiplier (&lt;1 = slams more often when enraged).</summary>
public const float BossPhase2SlamCooldownMult = 0.6f;
/// <summary>Swarmers summoned per phase-two summon (spawned around the boss, tagged as room zone enemies).</summary>
public const int BossSummonCount = 3;
/// <summary>Ticks between phase-two summons (~7 s).</summary>
public const uint BossSummonCooldownTicks = 420;
/// <summary>Cap on live zone enemies the boss will summon toward — it holds fire above this so a boss room
/// never floods past readable (also bounded by the director's MaxAlive count).</summary>
public const int BossSummonLiveCap = 8;
// ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ----
/// <summary>Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger.</summary>