Game/Interceptor.cs
namespace Monolith;
/// <summary>
/// A drifting obstacle that positions itself between you and the shape and eats your shots.
///
/// It cannot hurt you. Its whole job is to make aiming a decision: shots spent on an
/// interceptor are shots not spent on the rock, but leaving them alive means a steadily larger
/// share of your fire never lands. Killing one pays out, so clearing them is worth doing rather
/// than merely necessary.
/// </summary>
public sealed class Interceptor : Component
{
private static readonly List<Interceptor> all = new();
public static IReadOnlyList<Interceptor> All => all;
private static Model shellModel;
[Property] public float Radius { get; set; } = 34f;
public int Health { get; private set; } = Tuning.InterceptorHealth;
private Vector3 driftTarget;
private Vector3 velocity;
private GameTimeSince timeSinceRetarget;
private ModelRenderer renderer;
private GameTimeSince timeSinceHit = 99f;
public static Interceptor Spawn( Scene scene, Vector3 position )
{
if ( scene == null ) return null;
var obj = new GameObject( true, "Interceptor" );
obj.NetworkMode = NetworkMode.Never;
obj.WorldPosition = position;
obj.WorldRotation = Rotation.Random;
var interceptor = obj.AddComponent<Interceptor>();
obj.WorldScale = interceptor.Radius * 2f;
var mr = obj.AddComponent<ModelRenderer>();
mr.Model = GetShellModel();
mr.Tint = new Color( 0.85f, 0.20f, 0.08f );
interceptor.renderer = mr;
return interceptor;
}
protected override void OnEnabled() => all.Add( this );
protected override void OnDisabled() => all.Remove( this );
protected override void OnUpdate()
{
// Frozen while a blocking screen or the pause menu is up. See GameTime.
if ( GameTime.Paused )
return;
var manager = MonolithManager.Instance;
if ( !manager.IsValid() || manager.World == null )
return;
// Retarget periodically to a point between the player and the shape, which is what
// makes them read as deliberately getting in the way rather than milling about.
if ( timeSinceRetarget > Tuning.InterceptorRetargetSeconds )
{
timeSinceRetarget = 0;
driftTarget = PickBlockingPosition( manager );
}
var toTarget = driftTarget - WorldPosition;
if ( toTarget.Length > 1f )
velocity = velocity.LerpTo( toTarget.Normal * Tuning.InterceptorSpeed, 1.6f * Time.Delta );
WorldPosition += velocity * Time.Delta;
WorldRotation *= Rotation.From( 40f * Time.Delta, 55f * Time.Delta, 0f );
// Flash on hit so damage is legible without a health bar.
if ( renderer.IsValid() )
{
float flash = timeSinceHit < 0.12f ? 1f : 0f;
float wear = Health / (float)Tuning.InterceptorHealth;
renderer.Tint = Color.Lerp(
new Color( 0.45f, 0.09f, 0.03f ),
new Color( 1f, 0.35f, 0.12f ),
wear ) + new Color( flash, flash * 0.8f, flash * 0.5f );
}
}
/// <summary>Somewhere on the line between the player and the shape, offset a little.</summary>
private Vector3 PickBlockingPosition( MonolithManager manager )
{
var bounds = manager.World.WorldBounds;
var player = Scene.GetAllComponents<PlayerMovement>().FirstOrDefault();
var from = player.IsValid() ? player.WorldPosition : bounds.Center + Vector3.Backward * 800f;
float t = Game.Random.Float( 0.35f, 0.75f );
var onLine = Vector3.Lerp( from, bounds.Center, t );
return onLine + Vector3.Random.Normal * Game.Random.Float( 60f, 260f );
}
/// <summary>
/// Sphere test against every live interceptor for one projectile step. Linear, but the
/// population is capped in the low tens so it is far cheaper than a physics query.
/// </summary>
public static bool TryIntercept( Scene scene, Vector3 start, Vector3 direction, float distance,
out Interceptor hit )
{
hit = null;
float best = float.MaxValue;
for ( int i = 0; i < all.Count; i++ )
{
var candidate = all[i];
if ( !candidate.IsValid() ) continue;
var toCentre = candidate.WorldPosition - start;
float along = Vector3.Dot( toCentre, direction );
// Allow a small negative so a projectile spawned just inside still registers.
if ( along < -candidate.Radius || along > distance + candidate.Radius )
continue;
float perpSq = toCentre.LengthSquared - along * along;
if ( perpSq > candidate.Radius * candidate.Radius )
continue;
if ( along < best )
{
best = along;
hit = candidate;
}
}
return hit != null;
}
public void TakeHit( int damage )
{
Health -= damage;
timeSinceHit = 0;
if ( Health > 0 )
return;
BlastEffect.Spawn( Scene, WorldPosition, 220f, true );
Debris.Burst( Scene, WorldPosition, 130f, true );
var progress = PlayerProgress.Local;
if ( progress.IsValid() )
{
progress.AwardDust( Tuning.InterceptorDustReward );
progress.Data.InterceptorKills++;
progress.AddResonance();
}
GameObject.Destroy();
}
private static Model GetShellModel()
{
if ( shellModel != null )
return shellModel;
// An octahedron: distinct from every cube in the scene at a glance, and cheap.
var vb = new VertexBuffer();
vb.Init( true );
Vector3[] tips =
{
Vector3.Up * 0.5f, Vector3.Down * 0.5f,
};
Vector3[] ring =
{
Vector3.Forward * 0.5f, Vector3.Right * 0.5f,
Vector3.Backward * 0.5f, Vector3.Left * 0.5f,
};
int index = 0;
foreach ( var tip in tips )
{
for ( int i = 0; i < ring.Length; i++ )
{
var a = ring[i];
var b = ring[(i + 1) % ring.Length];
// Wind so both caps face outwards.
var (p1, p2) = tip.z > 0 ? (a, b) : (b, a);
var normal = Vector3.Cross( p2 - tip, p1 - tip ).Normal;
var tangent = (p1 - tip).Normal;
vb.Add( new Vertex( tip, normal, tangent, new Vector4( 0.5f, 0, 0, 0 ) ) );
vb.Add( new Vertex( p1, normal, tangent, new Vector4( 0, 1, 0, 0 ) ) );
vb.Add( new Vertex( p2, normal, tangent, new Vector4( 1, 1, 0, 0 ) ) );
vb.AddRawIndex( index + 0 );
vb.AddRawIndex( index + 1 );
vb.AddRawIndex( index + 2 );
index += 3;
}
}
var mesh = new Mesh( Material.Load( "materials/default.vmat" ) );
mesh.CreateBuffers( vb );
shellModel = new ModelBuilder().AddMesh( mesh ).Create();
return shellModel;
}
}