From 3995af736cd5df42d02148f1828817c70a02275a Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Sun, 5 Jul 2026 20:21:18 -0700 Subject: [PATCH] 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) --- Assets/_Project/Prefabs/EnemyWerewolf.prefab | 2 +- .../Authoring/Combat/EnemyAuthoring.cs | 2 +- .../Scripts/Server/Combat/BossAISystem.cs | 198 ++++++++++++++++++ .../Server/Combat/BossAISystem.cs.meta | 2 + .../Scripts/Server/Combat/EnemyAISystem.cs | 90 ++++---- .../Scripts/Server/Combat/EnemyMoveUtil.cs | 48 +++++ .../Server/Combat/EnemyMoveUtil.cs.meta | 2 + .../Server/Combat/ProjectileDamageSystem.cs | 11 +- .../Server/Combat/RoomEnemyDirectorSystem.cs | 41 ++-- .../Server/Combat/ZoneEnemySpawnUtil.cs | 29 +++ .../Server/Combat/ZoneEnemySpawnUtil.cs.meta | 2 + .../Simulation/Combat/AbilityFireSystem.cs | 21 ++ .../Scripts/Simulation/Combat/BossState.cs | 31 +++ .../Simulation/Combat/BossState.cs.meta | 2 + .../Simulation/Player/MeleeComboSystem.cs | 8 +- Assets/_Project/Scripts/Simulation/Tuning.cs | 37 +++- 16 files changed, 456 insertions(+), 70 deletions(-) create mode 100644 Assets/_Project/Scripts/Server/Combat/BossAISystem.cs create mode 100644 Assets/_Project/Scripts/Server/Combat/BossAISystem.cs.meta create mode 100644 Assets/_Project/Scripts/Server/Combat/EnemyMoveUtil.cs create mode 100644 Assets/_Project/Scripts/Server/Combat/EnemyMoveUtil.cs.meta create mode 100644 Assets/_Project/Scripts/Server/Combat/ZoneEnemySpawnUtil.cs create mode 100644 Assets/_Project/Scripts/Server/Combat/ZoneEnemySpawnUtil.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Combat/BossState.cs create mode 100644 Assets/_Project/Scripts/Simulation/Combat/BossState.cs.meta diff --git a/Assets/_Project/Prefabs/EnemyWerewolf.prefab b/Assets/_Project/Prefabs/EnemyWerewolf.prefab index 7051d8feb..94c87774b 100644 --- a/Assets/_Project/Prefabs/EnemyWerewolf.prefab +++ b/Assets/_Project/Prefabs/EnemyWerewolf.prefab @@ -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 diff --git a/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs index 60641947a..901fd6078 100644 --- a/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs @@ -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() != null) { kind = ZoneEnemyMath.KindCharger; windup = 30; } else if (spitter != null) { kind = ZoneEnemyMath.KindSpitter; windup = (byte)Mathf.Clamp(spitter.WindupTicks, 1, 255); } - else if (GetComponent() != null) { kind = ZoneEnemyMath.KindSwarmer; windup = 6; } + else if (GetComponent() != 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 }); } } diff --git a/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs b/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs new file mode 100644 index 000000000..d452aa03e --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs @@ -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 +{ + /// + /// Server-authoritative EXPEDITION BOSS brain — the SOLE mover/attacker of .WithAll<EnemyTag, BossState>() + /// (EnemyAISystem's Charger MOVE pass excludes it via .WithNone<BossState>(), so exactly one system + /// writes the boss's Position/Rotation/AttackWindup — the sole-writer invariant). Runs SERVER-ONLY in the plain + /// [UpdateAfter(EnemyAISystem)] (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 [GhostField] (CombatFeedbackSystem + /// draws a boss-scale ring). At/below 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 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 TickUtil.NonZero and compare with only (never raw uint). + /// + [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(); + m_Bosses = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.ReadOnly()); + state.RequireForUpdate(m_Bosses); + m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly()); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var serverTick = SystemAPI.GetSingleton().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(Allocator.Temp); + var playerPositions = new NativeList(Allocator.Temp); + foreach (var (xform, health, region, entity) in + SystemAPI.Query, RefRO, RefRO>() + .WithAll().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(out var physics); + uint envMask = SystemAPI.TryGetSingleton(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(out var directorEntity); + Entity swarmerPrefab = Entity.Null; + LocalTransform swarmerBaked = default; + if (haveDirector) + { + var prefabs = SystemAPI.GetBuffer(directorEntity); + if (prefabs.Length > ZoneEnemyMath.KindSwarmer) + { + swarmerPrefab = prefabs[ZoneEnemyMath.KindSwarmer].Prefab; + if (swarmerPrefab != Entity.Null) + swarmerBaked = state.EntityManager.GetComponentData(swarmerPrefab); + } + } + byte roomByte = SystemAPI.TryGetSingleton(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, RefRO, RefRO, RefRW, + RefRW, RefRW>() + .WithAll()) + { + 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(); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs.meta new file mode 100644 index 000000000..c8e6dcc42 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 105d73021b780c449a16ea72bcc29b69 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs index 89b87e9c9..5d75bd1ad 100644 --- a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs @@ -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, RefRO, RefRW, RefRW, RefRW, RefRW, RefRO>() - .WithAll().WithNone()) + .WithAll().WithNone()) { 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); } } diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyMoveUtil.cs b/Assets/_Project/Scripts/Server/Combat/EnemyMoveUtil.cs new file mode 100644 index 000000000..54f34d2c9 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/EnemyMoveUtil.cs @@ -0,0 +1,48 @@ +using ProjectM.Simulation; +using Unity.Mathematics; +using Unity.Physics; + +namespace ProjectM.Server +{ + /// + /// 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 . Pure given the CollisionWorld; + /// Burst-safe (non-generic closest-hit SphereCast, per the CLAUDE.md generic-collector hazard). + /// + public static class EnemyMoveUtil + { + /// 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). + 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; + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyMoveUtil.cs.meta b/Assets/_Project/Scripts/Server/Combat/EnemyMoveUtil.cs.meta new file mode 100644 index 000000000..7004a9797 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/EnemyMoveUtil.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5db3fdcf478dbe74a9912884c1320f16 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs b/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs index 066849ac5..f5679f4c3 100644 --- a/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs @@ -43,6 +43,11 @@ namespace ProjectM.Server /// RW lookup to stamp server-only knockback on a hit Husk (Husks bake KnockbackState; players/dummies don't). ComponentLookup m_KnockbackLookup; + /// 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. + ComponentLookup m_BossLookup; + + /// Extra forgiveness added to a target's hit radius to approximate the projectile's own size. const float k_ProjectileRadius = 0.2f; @@ -51,6 +56,8 @@ namespace ProjectM.Server { m_GhostOwnerLookup = state.GetComponentLookup(isReadOnly: true); m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false); + m_BossLookup = state.GetComponentLookup(isReadOnly: true); + // No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely. state.RequireForUpdate(); @@ -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(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 { diff --git a/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs b/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs index 0fcb1310a..0a31a93cb 100644 --- a/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs @@ -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(enemy); - ecb.AddComponent(enemy, new RoomTag { Room = room }); - if (bossRoom && SystemAPI.HasComponent(prefab)) { - var hp = SystemAPI.GetComponent(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(prefab)) + { + var hp = SystemAPI.GetComponent(prefab); + hp.Current *= Tuning.BossHealthMultiplier; + hp.Max *= Tuning.BossHealthMultiplier; + ecb.SetComponent(enemy, hp); + } + if (SystemAPI.HasComponent(prefab)) + { + var hr = SystemAPI.GetComponent(prefab); + hr.Value *= Tuning.BossScaleMultiplier; + ecb.SetComponent(enemy, hr); + } + if (SystemAPI.HasComponent(prefab)) + { + var es = SystemAPI.GetComponent(prefab); + es.AttackRange *= Tuning.BossScaleMultiplier; + ecb.SetComponent(enemy, es); + } + ecb.AddComponent(enemy, new BossState { Phase = 1 }); } } ecb.Playback(state.EntityManager); diff --git a/Assets/_Project/Scripts/Server/Combat/ZoneEnemySpawnUtil.cs b/Assets/_Project/Scripts/Server/Combat/ZoneEnemySpawnUtil.cs new file mode 100644 index 000000000..dbf5cd603 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/ZoneEnemySpawnUtil.cs @@ -0,0 +1,29 @@ +using ProjectM.Simulation; +using Unity.Entities; +using Unity.Mathematics; +using Unity.Transforms; + +namespace ProjectM.Server +{ + /// + /// 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: (counted by the room-clear gate), + /// (the RoomTeardown filter — Room byte MUST equal the current room), and + /// {Expedition} (relevancy — an untagged add would leak to base players, a Base-tagged one + /// would hide from the expedition party). Scale preserved via baked.WithPosition (never FromPosition, which + /// resets the [GhostField] Scale). Returns the spawned entity so a caller can layer extra (e.g. boss HP/scale). + /// + 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(enemy); + ecb.AddComponent(enemy, new RoomTag { Room = room }); + return enemy; + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/ZoneEnemySpawnUtil.cs.meta b/Assets/_Project/Scripts/Server/Combat/ZoneEnemySpawnUtil.cs.meta new file mode 100644 index 000000000..19402ee03 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/ZoneEnemySpawnUtil.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 73476d3f27c37514f8f681b480091a21 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs b/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs index 295748712..65a42a651 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs @@ -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 m_KnockbackLookup; + ComponentLookup m_BossLookup; + [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); state.RequireForUpdate(); + m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false); + m_BossLookup = state.GetComponentLookup(isReadOnly: true); } [BurstCompile] @@ -63,6 +69,8 @@ namespace ProjectM.Simulation var abilityDb = SystemAPI.GetSingleton(); 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); diff --git a/Assets/_Project/Scripts/Simulation/Combat/BossState.cs b/Assets/_Project/Scripts/Simulation/Combat/BossState.cs new file mode 100644 index 000000000..8cbd8b150 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/BossState.cs @@ -0,0 +1,31 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// SERVER-ONLY working state for the expedition BOSS (a scaled Charger that '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 /). + /// + /// Component PRESENCE is the boss discriminator: BossAISystem is the SOLE mover/attacker of + /// .WithAll<EnemyTag, BossState>(), and EnemyAISystem's Charger MOVE pass excludes it via + /// .WithNone<BossState>() 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 LungeState.UntilTick==0 and derives the bit off (harmless single + /// writer). is a byte (never a C# enum on a Bursted path — the cross-assembly-enum ICE rule). + /// All tick fields route through TickUtil.NonZero and compare with . + /// + /// + public struct BossState : IComponentData + { + /// 1 = phase one (heavy Charger + slam), 2 = phase two (<50% HP: faster + summons adds). Byte, not enum. + public byte Phase; + + /// Earliest raw tick the boss may begin its next radial SLAM wind-up (NonZero; 0 = ready). + public uint SlamReadyTick; + + /// Earliest raw tick the boss may summon its next add pack (phase two only; NonZero; 0 = ready). + public uint SummonReadyTick; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/BossState.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/BossState.cs.meta new file mode 100644 index 000000000..bb3a7d94d --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/BossState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fdf576e1f07162e43bae89c4ccc06dec \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs index d74e59c2f..ae557491e 100644 --- a/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs @@ -49,6 +49,8 @@ namespace ProjectM.Simulation public partial struct MeleeComboSystem : ISystem { ComponentLookup m_KnockbackLookup; + ComponentLookup m_BossLookup; // A4: the boss is knockback-immune (no melee stunlock out of its slams) + ComponentLookup m_RegionLookup; BufferLookup m_InvLookup; BufferLookup m_StatModLookup; @@ -57,6 +59,8 @@ namespace ProjectM.Simulation public void OnCreate(ref SystemState state) { m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false); + m_BossLookup = state.GetComponentLookup(isReadOnly: true); + m_RegionLookup = state.GetComponentLookup(isReadOnly: true); m_InvLookup = state.GetBufferLookup(isReadOnly: false); m_StatModLookup = state.GetBufferLookup(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); diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs index 766a9e8f0..9ae05e2d6 100644 --- a/Assets/_Project/Scripts/Simulation/Tuning.cs +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs @@ -72,7 +72,7 @@ namespace ProjectM.Simulation /// 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. - 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. public const uint RoomEntryGraceTicks = 100; + // ---- Expedition BOSS (a scaled Charger given a real kit by BossAISystem; server-only feel consts) ---- + + /// 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. + public const float BossSlamRadius = 5.5f; + + /// Radial SLAM damage. Heavy but survivable given generous i-frames + respawn (readable-but-fair). + public const float BossSlamDamage = 34f; + + /// SLAM telegraph lead (~0.75 s @60): a fair dodge window under interp lag before the ring lands. + public const uint BossSlamWindupTicks = 45; + + /// Ticks between SLAMs in phase one (~4 s). Phase two multiplies this by BossPhase2SlamCooldownMult. + public const uint BossSlamCooldownTicks = 240; + + /// Health fraction at/below which the boss enters phase two (faster + summons adds). + public const float BossPhase2HealthFraction = 0.5f; + + /// Phase-two move-speed multiplier over the boss's base EnemyStats.MoveSpeed (a visible gear shift). + public const float BossPhase2SpeedMult = 1.45f; + + /// Phase-two SLAM cooldown multiplier (<1 = slams more often when enraged). + public const float BossPhase2SlamCooldownMult = 0.6f; + + /// Swarmers summoned per phase-two summon (spawned around the boss, tagged as room zone enemies). + public const int BossSummonCount = 3; + + /// Ticks between phase-two summons (~7 s). + public const uint BossSummonCooldownTicks = 420; + + /// 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). + public const int BossSummonLiveCap = 8; + + // ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ---- /// Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger.