Game/Shrapnel.cs
namespace Monolith;
/// <summary>
/// Chunks of the monolith thrown out by a volatile detonation, which land on marked ground and
/// take the stage if you are still standing there.
///
/// **It is a MOVER, not a killer, and that is the design.** The first version threw pieces on a
/// fixed ballistic arc and hoped they would hit you, which they almost never did: they flew about
/// 1400 units while the player stands roughly 450 from the shape, so every burst arced over your
/// head and landed behind you. Impressive, and no threat at all.
///
/// What makes it matter is not a better chance of hitting. It is that each piece TELLS YOU where
/// it is going to land, and that place is near you. Without a telegraph this is a die roll:
/// either a chunk happens to be where you are or it does not, and neither outcome is a decision.
/// Drawn as a ring on the floor it becomes a place you must not be standing in a moment, which is
/// a decision, and one that competes with everything else asking you to be somewhere.
///
/// **The danger is mostly what it moves you into.** Being made to leave a spot is only
/// interesting because of what else is out there: a floor tile part way through its amber
/// warning, a crawler winding up, a violet slab you were routing around. Shrapnel does not have
/// to be lethal often. It has to be lethal ENOUGH that you always leave, and leaving is what puts
/// you in front of the rest of the game.
///
/// **You cause it.** Nothing spawns this but your own shot landing on a red cube, which is the
/// skill shot the Instability upgrade sells you more of. So the biggest reward in ordinary fire
/// carries its own risk, and a bigger blast grows the payoff and the danger together.
/// </summary>
public sealed class Shrapnel : Component
{
private static Model cubeModel;
private static Model ringModel;
private static int liveCount;
/// <summary>
/// Shared across every piece, exactly like the Crawler grab cooldown and for the same reason:
/// a single detonation throws a dozen chunks, and without this one unlucky moment would be
/// charged to you a dozen times over.
/// </summary>
private static GameTimeSince timeSinceAnyHit = 99f;
private Vector3 velocity;
private Vector3 spin;
private float size;
private GameTimeSince timeSinceSpawn;
private ModelRenderer renderer;
private PointLight glow;
private float floorZ;
/// <summary>Where this piece is going to land. Drifts toward the player as it flies.</summary>
private Vector3 target;
/// <summary>Total seconds from launch to impact. Fixed, so the arc can be solved exactly.</summary>
private float flightTime;
/// <summary>The ring drawn on the floor where it will land.</summary>
private GameObject marker;
private ModelRenderer markerRenderer;
public static IReadOnlyList<Shrapnel> All => all;
private static readonly List<Shrapnel> all = new();
/// <summary>
/// Throws a burst from a volatile detonation, aimed at the ground around the player.
/// </summary>
/// <remarks>
/// Pieces are pushed OUTWARD from the arena centre through the detonation, then scattered
/// along and around that lane. Outward is measured from the centre rather than from the
/// surface normal on purpose: the normal sends pieces off whichever face was hit, including
/// back through the middle of the monolith, while this always sweeps them across the open
/// floor. Some land short of the player and some long, so the pattern is a spread you have to
/// leave rather than a single dot to sidestep.
/// </remarks>
public static void Burst( Scene scene, Vector3 centre, float worldRadius, float floorZ )
{
if ( scene == null )
return;
var manager = MonolithManager.Instance;
var arenaCentre = manager.IsValid() && manager.World != null
? manager.World.WorldBounds.Center
: centre;
var outward = (centre - arenaCentre).WithZ( 0f );
// A detonation dead in the middle has no outward direction to speak of. Pick one.
outward = outward.Length < 1f
? new Vector3( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ), 0f ).Normal
: outward.Normal;
var player = scene.GetAllComponents<PlayerMovement>().FirstOrDefault();
var aim = player.IsValid()
? player.WorldPosition.WithZ( floorZ )
: centre + outward * 600f;
int count = Math.Clamp(
(int)(worldRadius / Tuning.ShrapnelPerRadius), 3, Tuning.ShrapnelMaxPerBurst );
for ( int i = 0; i < count; i++ )
{
if ( liveCount >= Tuning.ShrapnelMaxLive )
return;
var obj = new GameObject( true, "Shrapnel" );
obj.NetworkMode = NetworkMode.Never;
obj.WorldPosition = centre + Vector3.Random.Normal * (worldRadius * 0.3f);
obj.WorldRotation = Rotation.Random;
var piece = obj.AddComponent<Shrapnel>();
piece.size = Tuning.VoxelSize * Game.Random.Float( 1.4f, 2.6f );
obj.WorldScale = piece.size;
float yaw = Game.Random.Float(
-Tuning.ShrapnelSpreadDegrees, Tuning.ShrapnelSpreadDegrees );
var lane = Rotation.FromAxis( Vector3.Up, yaw ) * outward;
var scatter = new Vector3(
Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ), 0f );
piece.target = (aim
+ lane * Game.Random.Float( -Tuning.ShrapnelScatter, Tuning.ShrapnelScatter )
+ scatter * Tuning.ShrapnelScatter).WithZ( floorZ );
piece.flightTime = Game.Random.Float(
Tuning.ShrapnelFlightTime * 0.85f, Tuning.ShrapnelFlightTime * 1.15f );
piece.spin = Vector3.Random.Normal * Game.Random.Float( 90f, 300f );
piece.floorZ = floorZ;
var mr = obj.AddComponent<ModelRenderer>();
mr.Model = GetCubeModel();
mr.Tint = new Color( 1f, 0.35f, 0.1f ) * 2.2f;
piece.renderer = mr;
// Its own light. This has to be readable against a black void while you are looking
// somewhere else, and a tint alone does not carry that far.
var light = obj.AddComponent<PointLight>();
light.LightColor = new Color( 1f, 0.4f, 0.12f ) * 3f;
light.Radius = 300f;
light.Shadows = false;
piece.glow = light;
piece.Solve();
piece.MakeMarker();
liveCount++;
}
}
/// <summary>Wipes every piece. Called when a stage changes under us.</summary>
public static void ClearAll()
{
foreach ( var piece in all.ToList() )
piece?.GameObject?.Destroy();
// NOT a loop on `all.Count`: Destroy is deferred, so the list does not shrink until the
// end of the frame and waiting for it here would hang. This bug has been written four
// times in this project already.
all.Clear();
liveCount = 0;
timeSinceAnyHit = 99f;
}
protected override void OnStart()
{
timeSinceSpawn = 0;
all.Add( this );
}
protected override void OnDestroy()
{
marker?.Destroy();
marker = null;
all.Remove( this );
liveCount = Math.Max( 0, liveCount - 1 );
}
protected override void OnUpdate()
{
// Frozen while a blocking screen or the pause menu is up. See GameTime.
if ( GameTime.Paused )
return;
float t = Math.Clamp( timeSinceSpawn / flightTime, 0f, 1f );
if ( t >= 1f )
{
Impact();
return;
}
Drift();
Solve();
velocity -= Vector3.Up * Tuning.ShrapnelGravity * GameTime.Delta;
WorldPosition += velocity * GameTime.Delta;
WorldRotation *= Rotation.From(
spin.x * GameTime.Delta, spin.y * GameTime.Delta, spin.z * GameTime.Delta );
UpdateMarker( t );
if ( renderer.IsValid() )
renderer.Tint = Color.Lerp(
new Color( 1f, 0.35f, 0.1f ) * 2.2f,
new Color( 1f, 0.2f, 0.05f ) * 2.8f,
t );
if ( glow.IsValid() )
glow.LightColor = new Color( 1f, 0.4f, 0.12f ) * (2f + t * 2f);
CheckHit();
}
/// <summary>
/// Solves the arc so the piece arrives at <see cref="target"/> exactly when its flight time
/// runs out.
/// </summary>
/// <remarks>
/// Recomputed every frame against the REMAINING time rather than once at launch. That is what
/// lets the target drift toward the player without the landing ring becoming a lie: the piece
/// re-aims continuously, so wherever the ring is drawn is genuinely where it will land.
///
/// It is also why the flight is expressed as a duration rather than a speed. Solving for a
/// fixed arrival time is two divisions. Solving a launch speed for a given range is a
/// quadratic with two roots and a failure case when the target is out of reach.
/// </remarks>
private void Solve()
{
float remaining = MathF.Max( 0.06f, flightTime - timeSinceSpawn );
var delta = target - WorldPosition;
float vz = (delta.z + 0.5f * Tuning.ShrapnelGravity * remaining * remaining) / remaining;
velocity = new Vector3( delta.x / remaining, delta.y / remaining, vz );
}
/// <summary>
/// Walks the landing point toward the player, slowly.
/// </summary>
/// <remarks>
/// **A capped speed in units per second, deliberately well under a walk.** The player moves
/// at 420 and this drifts at a fraction of that, so stepping out of a marked ring always works
/// and standing in one never does. That asymmetry is the entire mechanic: it is not trying to
/// hit you, it is trying to make you leave.
/// </remarks>
private void Drift()
{
var player = Scene?.GetAllComponents<PlayerMovement>().FirstOrDefault();
if ( !player.IsValid() )
return;
var wanted = player.WorldPosition.WithZ( floorZ );
var delta = (wanted - target).WithZ( 0f );
float step = Tuning.ShrapnelDriftSpeed * GameTime.Delta;
target = delta.Length <= step
? wanted
: target + delta.Normal * step;
}
private void MakeMarker()
{
marker = new GameObject( true, "Shrapnel Marker" );
marker.NetworkMode = NetworkMode.Never;
marker.WorldPosition = target + Vector3.Up * 3f;
marker.WorldScale = Tuning.ShrapnelImpactRadius;
markerRenderer = marker.AddComponent<ModelRenderer>();
markerRenderer.Model = GetRingModel();
}
private void UpdateMarker( float t )
{
if ( !marker.IsValid() )
return;
marker.WorldPosition = target + Vector3.Up * 3f;
// Brightens and then flashes as impact approaches, so urgency is readable without a
// number and without looking away from whatever you were aiming at.
if ( markerRenderer.IsValid() )
markerRenderer.Tint = Color.Lerp(
new Color( 1f, 0.5f, 0.1f ) * 1.2f,
new Color( 1f, 0.15f, 0.05f ) * (2.2f + MathF.Sin( Time.Now * 30f ) * 0.8f),
t );
}
/// <summary>
/// Lands. The blast is what hurts, not the pebble.
/// </summary>
/// <remarks>
/// Killing on the LANDING rather than on contact in flight is what makes the ring honest: it
/// says where the danger is, and the danger happens there. Contact in flight still counts, but
/// it is the rare case rather than the mechanic.
/// </remarks>
private void Impact()
{
Audio.Play( Audio.Explosion, target, 0.7f, Audio.Vary( 1.25f ) );
BlastEffect.Spawn( Scene, target, Tuning.ShrapnelImpactRadius, false );
Hurt( Tuning.ShrapnelImpactRadius, target );
GameObject.Destroy();
}
/// <summary>Direct contact with a piece still in the air. Secondary to the landing.</summary>
private void CheckHit()
{
if ( timeSinceSpawn < Tuning.ShrapnelArmSeconds )
return;
Hurt( Tuning.ShrapnelHitRadius + size * 0.5f, WorldPosition );
}
private void Hurt( float radius, Vector3 at )
{
if ( timeSinceAnyHit < Tuning.ShrapnelHitCooldown )
return;
var manager = MonolithManager.Instance;
if ( !manager.IsValid() )
return;
foreach ( var player in Scene.GetAllComponents<PlayerMovement>() )
{
// Measured FLAT, with a separate height gate. Comparing full 3D distance would quietly
// make every impact survivable by hopping over it, and that is the one answer which
// must not work here: the lethal floor already rewards being airborne, and this exists
// to make the air cost something too.
var flat = (player.WorldPosition - at).WithZ( 0f );
if ( flat.Length > radius )
continue;
if ( MathF.Abs( player.WorldPosition.z - floorZ ) > Tuning.ShrapnelImpactHeight )
continue;
timeSinceAnyHit = 0f;
Audio.Play( Audio.Explosion, at, 1f, 0.7f );
manager.ResetStageProgress( "Caught by shrapnel" );
return;
}
}
/// <summary>A flat ring, built once, used for every landing marker.</summary>
private static Model GetRingModel()
{
if ( ringModel != null )
return ringModel;
var vb = new VertexBuffer();
vb.Init( true );
const int segments = 28;
const float inner = 0.84f;
int index = 0;
for ( int i = 0; i < segments; i++ )
{
float a0 = i / (float)segments * MathF.Tau;
float a1 = (i + 1) / (float)segments * MathF.Tau;
var o0 = new Vector3( MathF.Cos( a0 ), MathF.Sin( a0 ), 0f );
var o1 = new Vector3( MathF.Cos( a1 ), MathF.Sin( a1 ), 0f );
var p0 = o0;
var p1 = o1;
var p2 = o1 * inner;
var p3 = o0 * inner;
var up = Vector3.Up;
var tangent = (p1 - p0).Normal;
vb.Add( new Vertex( p0, up, tangent, new Vector4( 0, 0, 0, 0 ) ) );
vb.Add( new Vertex( p1, up, tangent, new Vector4( 1, 0, 0, 0 ) ) );
vb.Add( new Vertex( p2, up, tangent, new Vector4( 1, 1, 0, 0 ) ) );
vb.Add( new Vertex( p3, up, tangent, 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 );
ringModel = new ModelBuilder().AddMesh( mesh ).Create();
return ringModel;
}
/// <summary>One unit cube, built once and shared by every piece.</summary>
private static Model GetCubeModel()
{
if ( cubeModel != null )
return cubeModel;
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 tangent = MathF.Abs( n.z ) > 0.9f ? Vector3.Forward : Vector3.Up;
var u = Vector3.Cross( n, tangent ).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 );
cubeModel = new ModelBuilder().AddMesh( mesh ).Create();
return cubeModel;
}
}