Game/Projectile.cs
namespace Monolith;
/// <summary>
/// A real projectile, not a tracer. It leaves the muzzle, travels, and the blast happens where
/// and when it actually lands.
///
/// This replaced a cosmetic version where the blast applied on click. That was the safer choice
/// for a game firing twenty times a second, but it meant nothing could ever get in the way of a
/// shot, so interceptors and lead-the-target play were impossible. Travel time is what makes
/// those exist.
///
/// Each projectile marches the voxel grid itself rather than using physics: the monolith has no
/// colliders (see VoxelWorld.TraceRay), so a step-wise grid march is both the only option and
/// the cheap one.
/// </summary>
public sealed class Projectile : Component
{
private static Model boltModel;
private static int liveCount;
/// <summary>
/// Hard ceiling. Multi-shot plus a high fire rate can otherwise flood the scene.
///
/// **This is why the gun went quiet.** At 21 projectiles per shot, 220 is TEN trigger pulls'
/// worth, and past the cap Fire just returns without spawning anything. It stayed invisible
/// for a long time because bolts used to die almost immediately: firing was gated on the
/// crosshair being over rock, so every shot hit something within a few hundred units.
///
/// Then I removed that gate so hazards in open sky could be shot at all. Correct fix, but it
/// means a shot at the sky, or through one of the holes you have just blown in the shape,
/// now flies for its full lifetime. A few of those per trigger pull and the cap is saturated,
/// after which most of your shots silently never exist.
///
/// Raised, and paired with a much shorter lifetime and an arena cull below so the population
/// actually turns over instead of just having a higher ceiling to fill.
/// </summary>
private const int MaxLive = 900;
public static int LiveCount => liveCount;
private Vector3 direction;
private float speed;
private float blastRadius;
private bool isCharge;
/// <summary>Drone shots cannot break a shield node. See Tuning.ShieldBreakSeconds.</summary>
private bool fromDrone;
private GameTimeSince timeSinceSpawn;
private float maxLifetime;
private ModelRenderer renderer;
private Color tint;
/// <summary>
/// Fires one projectile. Direction should already be normalised.
/// </summary>
/// <param name="essential">
/// The primary bolt of a volley. These IGNORE the population cap.
///
/// The cap used to apply to everything equally, which meant that under pressure it ate whole
/// trigger pulls at random: you clicked and nothing came out. Since drones draw from the same
/// global count, a busy player also silently starved their own drones.
///
/// Now the shot you actually asked for always exists and only the multi-shot EXTRAS thin out.
/// The gun therefore always responds; what degrades under load is throughput, which is a
/// number, rather than responsiveness, which is a feeling.
/// </param>
public static void Fire( Scene scene, Vector3 from, Vector3 direction,
float blastRadius, bool isCharge, bool heavy = false, bool fromDrone = false,
bool essential = false )
{
if ( scene == null )
return;
// Essential bolts still have a ceiling, just a far higher one, so a pathological case
// cannot allocate without bound.
int ceiling = essential ? MaxLive * 2 : MaxLive;
if ( liveCount >= ceiling )
return;
var obj = new GameObject( true, "Projectile" );
obj.NetworkMode = NetworkMode.Never;
obj.WorldPosition = from;
obj.WorldRotation = Rotation.LookAt( direction );
var bolt = obj.AddComponent<Projectile>();
bolt.direction = direction.Normal;
bolt.speed = heavy ? Tuning.ChargeProjectileSpeed : Tuning.ProjectileSpeed;
bolt.blastRadius = blastRadius;
bolt.isCharge = isCharge;
bolt.fromDrone = fromDrone;
bolt.maxLifetime = Tuning.ProjectileLifetime;
bolt.tint = isCharge
? new Color( 1f, 0.42f, 0.10f )
: new Color( 1f, 0.78f, 0.34f );
var mr = obj.AddComponent<ModelRenderer>();
mr.Model = GetBoltModel();
mr.Tint = bolt.tint;
float thickness = heavy ? 6.5f : 3.4f;
obj.WorldScale = new Vector3( thickness * 5f, thickness, thickness );
bolt.renderer = mr;
liveCount++;
}
protected override void OnStart()
{
timeSinceSpawn = 0;
}
protected override void OnDestroy()
{
liveCount = Math.Max( 0, liveCount - 1 );
}
/// <summary>
/// Z of the arena floor, so shrapnel lands on it instead of falling through the world.
/// Read from the player rather than stored: the floor moves with every stage.
/// </summary>
private float FloorHeightFor( MonolithManager manager )
{
var movement = Scene?.GetAllComponents<PlayerMovement>().FirstOrDefault();
if ( movement.IsValid() )
return movement.FloorZ;
return manager?.World?.WorldBounds.Mins.z ?? 0f;
}
protected override void OnUpdate()
{
// Frozen while a blocking screen or the pause menu is up. See GameTime.
if ( GameTime.Paused )
return;
if ( timeSinceSpawn > maxLifetime )
{
GameObject.Destroy();
return;
}
var manager = MonolithManager.Instance;
if ( !manager.IsValid() || manager.World == null )
{
GameObject.Destroy();
return;
}
// Anything past the walls is never coming back. Culling it frees its slot in the live
// cap immediately rather than after the full lifetime, which is what keeps the
// population turning over when you are firing into open space.
var centre = manager.World.WorldBounds.Center;
float leash = manager.ArenaHalfExtent * 1.5f;
if ( WorldPosition.Distance( centre ) > leash )
{
GameObject.Destroy();
return;
}
float step = speed * Time.Delta;
var start = WorldPosition;
// Barriers first: they are solid and unkillable, so nothing gets past one. The lattice is
// see-through, so the absorb has to be loud: the shot dies exactly on the panel and the
// whole panel flares, otherwise it reads as the gun failing rather than as being blocked.
if ( OrbitalBarrier.Blocks( start, direction, step, out var barrier, out var absorbedAt ) )
{
barrier.Absorb();
// Bigger and brighter than a rock hit on purpose. A shot that achieved NOTHING has to
// look more dramatic than one that worked, or the feedback teaches the wrong lesson:
// silence reads as "it went through" and you keep firing into a wall.
BlastEffect.Spawn( Scene, absorbedAt, 210f, true );
Debris.Burst( Scene, absorbedAt, 90f, false );
Audio.Play( Audio.MetalHit, absorbedAt, 0.75f, Audio.Vary( 0.62f ) );
GameObject.Destroy();
return;
}
// The Spotter hovers above everything, so it is checked early. Shooting it down is one
// of the three answers to a lock and has to actually work under pressure.
if ( Spotter.TryHit( start, direction, step, out var spotter ) )
{
spotter.TakeHit( isCharge ? Tuning.InterceptorChargeDamage : 1 );
BlastEffect.Spawn( Scene, spotter.WorldPosition, 110f, false );
GameObject.Destroy();
return;
}
// The shield node hangs in the open above the shape and is YOUR answer to a shielded
// stage, so a drone shot passes straight through it. If drones could clear their own
// jam the shield would undo itself and go back to being something you wait out.
if ( !fromDrone && ShieldShell.TryHitNode( start, direction, step, out var node ) )
{
node.Strike();
BlastEffect.Spawn( Scene, node.NodePosition, 180f, true );
Audio.Play( Audio.Explosion, node.NodePosition, 0.8f, 1.5f );
GameObject.Destroy();
return;
}
// Incoming orbs are checked FIRST of all the killables. They are the only thing here on
// a clock, so a shot aimed at one must never be stolen by something behind it.
if ( SentinelOrb.TryHit( start, direction, step, out var orb ) )
{
orb.Shatter();
GameObject.Destroy();
return;
}
if ( Sentinel.TryHit( start, direction, step, out var sentinel ) )
{
sentinel.TakeHit( isCharge ? Tuning.InterceptorChargeDamage : 1 );
BlastEffect.Spawn( Scene, sentinel.WorldPosition, 110f, false );
GameObject.Destroy();
return;
}
if ( Crawler.TryHit( start, direction, step, out var crawler ) )
{
crawler.TakeHit( isCharge ? Tuning.InterceptorChargeDamage : 1 );
BlastEffect.Spawn( Scene, crawler.WorldPosition, 90f, false );
GameObject.Destroy();
return;
}
if ( Anchor.TryHit( start, direction, step, out var anchor ) )
{
anchor.TakeHit( isCharge ? Tuning.InterceptorChargeDamage : 1 );
BlastEffect.Spawn( Scene, anchor.WorldPosition, 100f, false );
GameObject.Destroy();
return;
}
// Leeches sit on the surface, so they are checked before the rock they are clamped to,
// otherwise you could never hit one.
if ( Leech.TryHit( start, direction, step, out var leech ) )
{
leech.TakeHit( isCharge ? Tuning.InterceptorChargeDamage : 1 );
BlastEffect.Spawn( Scene, leech.WorldPosition, 80f, false );
GameObject.Destroy();
return;
}
// Interceptors are checked next: something physically in the way should stop the shot
// before it can reach the rock behind it.
if ( Interceptor.TryIntercept( Scene, start, direction, step, out var blocker ) )
{
blocker.TakeHit( isCharge ? Tuning.InterceptorChargeDamage : 1 );
BlastEffect.Spawn( Scene, blocker.WorldPosition, 90f, false );
GameObject.Destroy();
return;
}
if ( manager.World.TraceRay( start, direction, step, out var hit ) )
{
Impact( manager, hit.Voxel );
return;
}
WorldPosition = start + direction * step;
}
private void Impact( MonolithManager manager, Vector3Int voxel )
{
var progress = PlayerProgress.Local;
// A volatile cube only goes off when a shot lands squarely on it. This is now the ONLY
// source of a big detonation from ordinary fire, so aiming at the red blocks is the
// skill the Instability upgrade buys you more of.
var impact = manager.World.VoxelToWorld( voxel.x, voxel.y, voxel.z );
if ( Stages.IsVolatile( voxel.x, voxel.y, voxel.z ) )
{
float volatileRadius = progress.IsValid()
? progress.VolatileRadius
: Tuning.VolatileRadiusBase;
manager.RequestBlast( voxel, volatileRadius, true );
// A volatile hit is the skill shot in ordinary fire, so it gets the loudest
// confirmation an ordinary shot can earn.
Audio.Play( Audio.Explosion, impact, 0.75f, Audio.Vary( 1.15f ) );
// AND IT THROWS THE ROCK AT YOU. The best shot in ordinary fire is now also the one
// that creates a hazard, so a bigger Instability radius grows the payoff and the
// danger together rather than the payoff alone.
Shrapnel.Burst( Scene, impact,
volatileRadius * Tuning.VoxelSize, FloorHeightFor( manager ) );
if ( progress.IsValid() )
{
progress.Data.VolatileDetonations++;
progress.AddResonance();
}
}
else
{
manager.RequestBlast( voxel, blastRadius, isCharge );
// Rock breaking. Quiet and pitched up for drone fire so a flock of them stays a
// texture rather than a wall of noise, and always varied: this plays many times a
// second and an unvaried sample becomes unbearable faster than any other in the game.
Audio.Play( Audio.RockHit, impact,
fromDrone ? 0.16f : 0.34f,
Audio.Vary( fromDrone ? 1.5f : 1.0f ) );
// Charges feed the chain; ordinary shots do not, or it would never lapse.
if ( isCharge && progress.IsValid() )
progress.AddResonance();
}
GameObject.Destroy();
}
/// <summary>Shared with the demolition charge so both read as the same kind of object.</summary>
public static Model SharedBoltModel => GetBoltModel();
/// <summary>A unit cube, stretched per bolt. Built once and shared by every projectile.</summary>
private static Model GetBoltModel()
{
if ( boltModel != null )
return boltModel;
var vb = new VertexBuffer();
vb.Init( true );
Vector3[] normals =
{
Vector3.Forward, Vector3.Backward, Vector3.Left,
Vector3.Right, Vector3.Up, Vector3.Down,
};
int index = 0;
foreach ( var n in normals )
{
var reference = MathF.Abs( n.z ) > 0.9f ? Vector3.Forward : Vector3.Up;
var u = Vector3.Cross( n, reference ).Normal;
var v = Vector3.Cross( n, u ).Normal;
var centre = n * 0.5f;
var p0 = centre - u * 0.5f - v * 0.5f;
var p1 = centre + u * 0.5f - v * 0.5f;
var p2 = centre + u * 0.5f + v * 0.5f;
var p3 = centre - u * 0.5f + v * 0.5f;
vb.Add( new Vertex( p0, n, u, new Vector4( 0, 0, 0, 0 ) ) );
vb.Add( new Vertex( p1, n, u, new Vector4( 1, 0, 0, 0 ) ) );
vb.Add( new Vertex( p2, n, u, new Vector4( 1, 1, 0, 0 ) ) );
vb.Add( new Vertex( p3, n, u, new Vector4( 0, 1, 0, 0 ) ) );
vb.AddRawIndex( index + 0 ); vb.AddRawIndex( index + 1 ); vb.AddRawIndex( index + 2 );
vb.AddRawIndex( index + 0 ); vb.AddRawIndex( index + 2 ); vb.AddRawIndex( index + 3 );
index += 4;
}
var mesh = new Mesh( Material.Load( "materials/default.vmat" ) );
mesh.CreateBuffers( vb );
boltModel = new ModelBuilder().AddMesh( mesh ).Create();
return boltModel;
}
}