Game/InterceptorSpawner.cs
namespace Monolith;
/// <summary>
/// Keeps a small population of interceptors alive around the shape.
///
/// The population scales with the stage rather than with time, so an early stage stays a clean
/// place to learn to shoot and later ones are genuinely contested. It is capped low: these are
/// an aiming tax, not a bullet hell.
/// </summary>
public sealed class InterceptorSpawner : Component
{
/// <summary>Named to avoid shadowing Component.Enabled, which is a real trap.</summary>
[Property] public bool SpawnHazards { get; set; } = true;
private GameTimeSince timeSinceSpawn;
protected override void OnUpdate()
{
// Frozen while a blocking screen or the pause menu is up. See GameTime.
if ( GameTime.Paused )
return;
if ( !SpawnHazards ) return;
var manager = MonolithManager.Instance;
if ( !manager.IsValid() || manager.World == null || !manager.SnapshotReady )
return;
UpdateLeeches( manager );
UpdateBarriers( manager );
UpdateSpotter( manager );
UpdateSentinels( manager );
UpdateAnchors( manager );
UpdateCrawlers( manager );
int target = TargetPopulation( manager );
if ( Interceptor.All.Count >= target )
return;
if ( timeSinceSpawn < Tuning.InterceptorSpawnInterval )
return;
timeSinceSpawn = 0;
// Spawn out at the edge of the shape so they visibly drift in rather than appearing
// already in your face.
var bounds = manager.World.WorldBounds;
float distance = bounds.Size.Length * 0.75f;
var position = bounds.Center
+ Vector3.Random.Normal.WithZ( MathF.Abs( Vector3.Random.z ) * 0.6f ).Normal * distance;
Interceptor.Spawn( Scene, position );
}
/// <summary>
/// Makes the next few draws from <c>Game.Random</c> a pure function of the stage.
///
/// The <paramref name="channel"/> keeps independent decisions independent. Without it the
/// Spotter roll would shift every subsequent draw, so adding a hazard later would silently
/// re-roll every stage in the game and invalidate every existing leaderboard time. One
/// channel per system means each can be tuned or added without disturbing the others.
///
/// This seeds the SHARED generator, which is safe here only because s&box re-seeds it
/// every tick: the determinism lasts for the rolls immediately following the call and does
/// not leak into later frames. Call it directly before the draws it is meant to govern.
/// </summary>
public static void SeedForStage( int stageKey, int channel )
{
Game.SetRandomSeed( unchecked(stageKey * 73856093 ^ channel * 19349663 ^ 0x5f3759df) );
}
// ---------------------------------------------------------------- spotter
private int spotterRolledForStage = -1;
private bool spotterDueThisStage;
/// <summary>
/// Fraction of the stage that must be cleared before the Spotter arrives.
///
/// This used to be a wall-clock delay, which meant a player who cleared a stage in twenty
/// seconds almost never saw one: the arrival landed after the stage had already ended and
/// the next stage rerolled it. Keying it to PROGRESS makes it show up regardless of how
/// fast you are, and it arrives at the same point in the experience for everyone.
/// </summary>
private float spotterArrivesAtProgress;
/// <summary>
/// At most one Spotter per stage, arriving at a random moment rather than at the start.
/// A stage that opens with one is just a stage you restart; a stage that is quiet for
/// thirty seconds and then is not is a stage you have to stay alert through.
/// </summary>
private void UpdateSpotter( MonolithManager manager )
{
int key = manager.InMonolith ? -2 : manager.Tier;
if ( spotterRolledForStage != key )
{
spotterRolledForStage = key;
// SEEDED, not random. Devil Daggers' spawn set is fully deterministic, and that is
// what makes its leaderboard mean anything: everyone fights the identical fight, so
// the ranking measures execution instead of luck (GOALS 6d).
//
// Ours was `Game.Random` for the Spotter roll, its arrival, the barrier count and the
// shield, which meant two players' stage 40 were different stages and the
// ladder-seconds board was partly measuring who drew the easier hazards.
SeedForStage( key, 1 );
// The opening is a teaching space. See Tuning.SpotterGraceStages.
bool inGrace = !manager.InMonolith && manager.Tier < Tuning.SpotterGraceStages;
spotterDueThisStage = Game.Random.Float()
< (inGrace ? Tuning.SpotterGraceChance : Tuning.SpotterChance);
spotterArrivesAtProgress = Game.Random.Float(
Tuning.SpotterArriveMinProgress, Tuning.SpotterArriveMaxProgress );
// SPOTTERS NO LONGER LEAVE WITH THE STAGE.
//
// They used to be destroyed on every stage change, which was correct when a stage
// took a minute and quietly broken once stages started falling in about a second: a
// three second lock cannot complete inside a one second stage, so a fast player
// outran the threat entirely without ever dodging anything. Forty stages with no
// deaths is what that looks like from the outside.
//
// Letting them carry over means blitzing stages ACCUMULATES pressure instead of
// dodging it, which is the behaviour a speed-focused player should get. Standby ones
// still expire so the arena cannot silently fill up with sleeping machines.
foreach ( var old in Spotter.All.ToList() )
{
if ( old.IsValid() && old.Standby )
old.GameObject.Destroy();
}
// Survivors keep existing but LOSE what they knew. A new stage must never open with
// the countdown already running and a beam already on you.
Spotter.ResetAllAcquisition();
}
if ( !spotterDueThisStage )
return;
float cleared = manager.TotalCubes <= 0
? 0f
: 1f - (float)((double)manager.RemainingCubes / manager.TotalCubes);
if ( cleared < spotterArrivesAtProgress )
return;
// Capped rather than gated on zero. The old check refused to spawn if ANY Spotter was
// alive, which combined with carry-over would have meant one survivor suppressing every
// future arrival for the rest of the run. The cap is 1 during the opening grace.
bool graceCap = !manager.InMonolith && manager.Tier < Tuning.SpotterGraceStages;
if ( Spotter.All.Count >= (graceCap ? 1 : Tuning.SpotterCountMax) )
return;
spotterDueThisStage = false;
var bounds = manager.World.WorldBounds;
float spread = MathF.Max( 500f, bounds.Size.Length * 0.6f );
// Several arrive together and spread out around the shape. They sit on standby, so a
// group is not a group of threats: it is a set of positions you have to work around,
// and each one you shoot is one fewer place that becomes dangerous later.
// During the grace the ceiling is ONE, total, not one arrival: carried-over Spotters
// count against it, so the early ladder can never have two in the sky at once.
bool grace = !manager.InMonolith && manager.Tier < Tuning.SpotterGraceStages;
int ceiling = grace ? 1 : Tuning.SpotterCountMax;
int count = Math.Min(
grace ? 1 : Game.Random.Int( Tuning.SpotterCountMin, Tuning.SpotterCountMax ),
ceiling - Spotter.All.Count );
if ( count <= 0 )
return;
// Spread by a fixed minimum bearing rather than evenly around a circle. With only one or
// two arriving, an even split of a small count can still drop them side by side; forcing
// a wide separation means a pair actually covers the arena instead of stacking.
float baseAngle = Game.Random.Float( 0f, MathF.PI * 2f );
float spacing = Tuning.SpotterSpacingDegrees * MathF.PI / 180f;
for ( int i = 0; i < count; i++ )
{
float angle = baseAngle + i * spacing + Game.Random.Float( -0.25f, 0.25f );
float distance = spread * Game.Random.Float( 0.7f, 1.15f );
var position = bounds.Center + new Vector3(
MathF.Cos( angle ) * distance,
MathF.Sin( angle ) * distance,
0f );
position.z = bounds.Maxs.z
+ Tuning.SpotterHoverHeight * Game.Random.Float( 0.6f, 1.3f );
Spotter.Spawn( Scene, position );
}
}
// ---------------------------------------------------------------- barriers
private int barriersBuiltForStage = -1;
/// <summary>
/// Barriers belong to the stage, not to a spawn timer: they are rebuilt once when the shape
/// changes and then simply orbit. Their count scales with the stage so early ones stay
/// clean and later ones genuinely need working around.
/// </summary>
private void UpdateBarriers( MonolithManager manager )
{
int key = manager.InMonolith ? -2 : manager.Tier;
if ( barriersBuiltForStage == key )
return;
barriersBuiltForStage = key;
// Channel 2. Same stage, same barriers, for everyone.
SeedForStage( key, 2 );
foreach ( var existing in OrbitalBarrier.All.ToList() )
existing.GameObject.Destroy();
int count = manager.InMonolith
? Tuning.BarrierMaxCount
: Math.Clamp( (manager.Tier - Tuning.BarrierFirstStage) / Tuning.BarrierStagesPerExtra + 1,
0, Tuning.BarrierMaxCount );
if ( count <= 0 )
return;
var bounds = manager.World.WorldBounds;
// Clamped INSIDE the arena. A barrier outside the walls is one the player can never
// stand beyond and never shoot past, so it stops being an obstacle and becomes scenery.
// On the shared Monolith that is exactly what happened.
float ceiling = manager.ArenaHalfExtent * Tuning.BarrierMaxArenaFraction;
float baseRadius = MathF.Min( ceiling, MathF.Max( 420f, bounds.Size.Length * 0.55f ) );
for ( int i = 0; i < count; i++ )
{
// Alternate direction and stagger height so they cross each other rather than
// forming one predictable wall.
float speed = Game.Random.Float( Tuning.BarrierSpeedMin, Tuning.BarrierSpeedMax )
* (i % 2 == 0 ? 1f : -1f);
float radius = MathF.Min( ceiling, baseRadius * Game.Random.Float( 0.85f, 1.25f ) );
float height = bounds.Size.z * Game.Random.Float( 0.15f, 0.75f );
float halfHeight = bounds.Size.z * Game.Random.Float( 0.18f, 0.32f ) + 120f;
OrbitalBarrier.Spawn( Scene, bounds.Center, radius,
Game.Random.Float( 0f, 360f ), speed,
height - bounds.Size.z * 0.5f, halfHeight );
}
}
// ---------------------------------------------------------------- sentinels
private int sentinelsBuiltForStage = -1;
/// <summary>
/// Sentinels belong to the stage, like barriers, rather than trickling in on a timer. A
/// threat that shoots back has to be countable: you should be able to look around once, see
/// how many guns are pointed at you, and plan. A drip feed makes that impossible.
/// </summary>
private void UpdateSentinels( MonolithManager manager )
{
int key = manager.InMonolith ? -2 : manager.Tier;
if ( sentinelsBuiltForStage == key )
return;
sentinelsBuiltForStage = key;
// SENTINELS CARRY OVER, for the same reason Spotters do.
//
// A sentinel needs SentinelFireInterval (3.4s) to get a single shot away. Stages are
// currently falling in about a second, and these were being destroyed and rebuilt on
// every stage change, so they never once reached the end of a reload. That is what
// "the non-spotter enemy isn't working" looks like: not a broken component, a component
// that is deleted before its first action completes.
//
// In-flight orbs still clear with the stage. An orb fired at a shape that no longer
// exists is just an unfair hit from a dead threat.
foreach ( var orb in SentinelOrb.All.ToList() )
orb.GameObject.Destroy();
// Survivors re-arm. Carrying over their READINESS was the bug: a sentinel that had been
// waiting through the last stage arrived at the new one with a full reload and shot
// immediately, which is the same unfairness as a Spotter keeping its lock.
Sentinel.RearmAll();
SeedForStage( key, 4 );
int wanted = manager.InMonolith
? Tuning.SentinelMaxCount
: Math.Clamp( (manager.Tier - Tuning.SentinelFirstStage) / Tuning.SentinelStagesPerExtra + 1,
0, Tuning.SentinelMaxCount );
// Trim only if the stage wants fewer than are already out, so the population tracks the
// stage without resetting everyone's reload.
//
// Bounded loop over a SNAPSHOT. `GameObject.Destroy()` is deferred, so
// `while ( All.Count > wanted )` never terminates: the count cannot change until the
// frame it is blocking finishes. See MiningDrone.MatchPopulation, where the identical
// mistake was the collapse hang.
if ( Sentinel.All.Count > wanted )
{
foreach ( var doomed in Sentinel.All.Skip( Math.Max( 0, wanted ) ).ToList() )
{
if ( doomed.IsValid() )
doomed.GameObject.Destroy();
}
return;
}
int count = wanted - Sentinel.All.Count;
if ( count <= 0 )
return;
var bounds = manager.World.WorldBounds;
float spread = MathF.Max( 700f, bounds.Size.Length * 0.7f );
for ( int i = 0; i < count; i++ )
{
// Spread around the shape, so no single facing has all of them behind you and there
// is no corner of the arena that is safe from every gun at once.
float angle = (i / (float)count) * MathF.PI * 2f + Game.Random.Float( -0.4f, 0.4f );
var position = bounds.Center + new Vector3(
MathF.Cos( angle ) * spread,
MathF.Sin( angle ) * spread,
0f );
position.z = bounds.Center.z + Tuning.SentinelHoverHeight;
Sentinel.Spawn( Scene, position );
}
}
// ---------------------------------------------------------------- anchors
private int anchorsBuiltForStage = -1;
private void UpdateAnchors( MonolithManager manager )
{
int key = manager.InMonolith ? -2 : manager.Tier;
if ( anchorsBuiltForStage == key )
return;
anchorsBuiltForStage = key;
// Carry over too. An Anchor has a cooldown before it can fire and a hold time after, so
// like the Sentinel it needs more than one stage's worth of seconds to do anything.
//
// But it must LET GO. A stage opening with the tether already attached and your speed
// already halved is the same unfairness as a Spotter keeping its lock, and it is what
// the "line attached to me on spawn" report actually was.
Anchor.ReleaseAll();
SeedForStage( key, 5 );
int wanted = manager.InMonolith
? Tuning.AnchorMaxCount
: Math.Clamp( (manager.Tier - Tuning.AnchorFirstStage) / Tuning.AnchorStagesPerExtra + 1,
0, Tuning.AnchorMaxCount );
// Bounded snapshot, same reason as the sentinels above: deferred destruction means a
// while-loop on the count is an infinite loop.
if ( Anchor.All.Count > wanted )
{
foreach ( var doomed in Anchor.All.Skip( Math.Max( 0, wanted ) ).ToList() )
{
if ( doomed.IsValid() )
doomed.GameObject.Destroy();
}
return;
}
int count = wanted - Anchor.All.Count;
if ( count <= 0 )
return;
var bounds = manager.World.WorldBounds;
float spread = MathF.Max( 900f, bounds.Size.Length * 0.9f );
for ( int i = 0; i < count; i++ )
{
// Placed further out than the sentinels and low to the ground, so cutting a tether
// means looking away from the shape and DOWN, which is the least convenient thing
// to be asked to do while mining something above you.
float angle = (i / (float)count) * MathF.PI * 2f + Game.Random.Float( -0.6f, 0.6f );
var position = bounds.Center + new Vector3(
MathF.Cos( angle ) * spread,
MathF.Sin( angle ) * spread,
0f );
position.z = bounds.Mins.z + Tuning.AnchorHoverHeight;
Anchor.Spawn( Scene, position );
}
}
// ---------------------------------------------------------------- crawlers
private int crawlersBuiltForStage = -1;
/// <summary>
/// Ground chasers. Like the other persistent hazards they carry over, because a chase that
/// resets every stage is not a chase. What DOES reset is the wind-up: see `Crawler.CalmAll`.
/// </summary>
private void UpdateCrawlers( MonolithManager manager )
{
int key = manager.InMonolith ? -2 : manager.Tier;
if ( crawlersBuiltForStage == key )
return;
crawlersBuiltForStage = key;
// Carried over, but calmed. A stage must never open with a fully enraged crawler already
// on top of you, which is the same fairness rule as the Spotter losing its lock.
Crawler.CalmAll();
SeedForStage( key, 6 );
int wanted = manager.InMonolith
? Tuning.CrawlerMaxCount
: Math.Clamp( (manager.Tier - Tuning.CrawlerFirstStage) / Tuning.CrawlerStagesPerExtra + 1,
0, Tuning.CrawlerMaxCount );
if ( Crawler.All.Count > wanted )
{
// Bounded snapshot: Destroy is deferred, so a while-loop on the count never ends.
foreach ( var doomed in Crawler.All.Skip( Math.Max( 0, wanted ) ).ToList() )
{
if ( doomed.IsValid() )
doomed.GameObject.Destroy();
}
return;
}
int count = wanted - Crawler.All.Count;
if ( count <= 0 )
return;
var bounds = manager.World.WorldBounds;
for ( int i = 0; i < count; i++ )
{
// They walk in from the edge of the arena, on the floor, spread around it. Arriving
// at distance is what gives you the chance to hear one coming and deal with it
// before it has wound up.
float angle = Game.Random.Float( 0f, MathF.PI * 2f );
float spread = MathF.Min( manager.ArenaHalfExtent * 0.85f, Tuning.CrawlerSpawnDistance );
var position = bounds.Center + new Vector3(
MathF.Cos( angle ) * spread,
MathF.Sin( angle ) * spread,
0f );
position.z = bounds.Mins.z + 30f;
Crawler.Spawn( Scene, position );
}
}
// ---------------------------------------------------------------- leeches
private GameTimeSince timeSinceLeech;
/// <summary>
/// Leeches clamp onto the shape itself, so they are placed by tracing inward from outside
/// and sitting on the first surface found. That keeps them visibly attached rather than
/// floating near it.
/// </summary>
private void UpdateLeeches( MonolithManager manager )
{
if ( manager.Tier < Tuning.LeechFirstStage && !manager.InMonolith )
return;
if ( Leech.All.Count >= Tuning.LeechMaxPopulation )
return;
if ( timeSinceLeech < Tuning.LeechSpawnInterval )
return;
timeSinceLeech = 0;
var bounds = manager.World.WorldBounds;
float outer = bounds.Size.Length;
for ( int attempt = 0; attempt < 10; attempt++ )
{
var from = bounds.Center + Vector3.Random.Normal * outer;
var dir = (bounds.Center - from).Normal;
if ( !manager.World.TraceRay( from, dir, outer * 2.5f, out var hit ) )
continue;
// Sit just proud of the surface it latched onto.
var surface = manager.World.VoxelToWorld( hit.Voxel.x, hit.Voxel.y, hit.Voxel.z );
Leech.Spawn( Scene, surface - dir * 30f );
return;
}
}
private int TargetPopulation( MonolithManager manager )
{
if ( manager.InMonolith )
return Tuning.InterceptorMaxPopulation;
// Ramp in over the first few stages so the opening is uncontested.
int fromStage = (manager.Tier - Tuning.InterceptorFirstStage) / Tuning.InterceptorStagesPerExtra + 1;
return Math.Clamp( fromStage, 0, Tuning.InterceptorMaxPopulation );
}
}