Game/MiningDrone.cs
namespace Monolith;
/// <summary>
/// A mining drone: a small angular machine that hangs in an arch above you and fires at the
/// shape.
///
/// This replaced an attempt at floating Terry heads, which failed for two reasons worth
/// recording so nobody tries it again:
///
/// 1. **Clothing is not a bodygroup.** Hiding the Chest / Legs / Feet / Hands bodygroups
/// hides the BODY, but every clothing item is a separate model parented to a bone. A
/// head-only citizen in a coat is a coat with nothing inside it, which is worse than
/// either extreme.
/// 2. `ClothingContainer.Apply` sets bodygroups itself, so applying the player's outfit
/// after switching them off simply switched them back on.
///
/// The result on screen was a ring of full-size citizens crowding the camera. A purpose-built
/// mesh avoids the whole problem: it is small, it is legible at a glance, and it costs one
/// hand-rolled vertex buffer.
///
/// They sit in a RAINBOW, an arch spanning left to right above the player and rising in the
/// middle, rather than a ring around them. A ring puts a drone between you and the shape no
/// matter which way you face; an arch overhead is always in view in third person and never in
/// the way of the crosshair.
/// </summary>
public sealed class MiningDrone : Component
{
private static readonly List<MiningDrone> all = new();
public static IReadOnlyList<MiningDrone> All => all;
private static Model droneModel;
/// <summary>Width of the arch, left tip to right tip.</summary>
[Property] public float ArcWidth { get; set; } = 340f;
/// <summary>How high the ends of the arch sit above the eye.</summary>
[Property] public float ArcBaseHeight { get; set; } = 70f;
/// <summary>Extra height at the peak. This is what makes it a rainbow rather than a line.</summary>
[Property] public float ArcLift { get; set; } = 130f;
/// <summary>How far in front of the eye the arch sits. Small: it is overhead, not ahead.</summary>
[Property] public float ArcForward { get; set; } = 30f;
/// <summary>Body size, in world units. Deliberately tiny next to a 64 unit player.</summary>
[Property] public float Size { get; set; } = 15f;
/// <summary>Where its bolt leaves from: just off the nose.</summary>
public Vector3 MuzzlePosition => WorldPosition + WorldRotation.Forward * (Size * 0.9f);
private ModelRenderer renderer;
private PointLight glow;
private float bobPhase;
private GameTimeSince timeSinceFired = 99f;
public static MiningDrone Spawn( Scene scene )
{
if ( scene == null ) return null;
var obj = new GameObject( true, "Mining Drone" );
obj.NetworkMode = NetworkMode.Never;
var drone = obj.AddComponent<MiningDrone>();
drone.bobPhase = Game.Random.Float( 0f, 10f );
var mr = obj.AddComponent<ModelRenderer>();
mr.Model = GetDroneModel();
drone.renderer = mr;
var light = obj.AddComponent<PointLight>();
light.LightColor = new Color( 1f, 0.72f, 0.3f ) * 2f;
light.Radius = 200f;
light.Shadows = false;
drone.glow = light;
obj.WorldScale = drone.Size;
return drone;
}
protected override void OnEnabled() => all.Add( this );
protected override void OnDisabled() => all.Remove( this );
/// <summary>Called when this drone takes a shot, so it flashes and you can see which fired.</summary>
public void OnFired() => timeSinceFired = 0f;
/// <summary>
/// Places this drone on the arch and points it at whatever is being mined.
///
/// Position is written here rather than by parenting to the player. A parented drone would
/// inherit your PITCH, so looking down would drive the whole arch into the floor. Only the
/// yaw should carry.
/// </summary>
public void Follow( Vector3 eye, float yaw, Vector3 lookAt, int index, int total )
{
// Spread across the arch. A single drone sits at the peak.
float t = total <= 1 ? 0.5f : index / (float)(total - 1);
bobPhase += Time.Delta * 1.7f;
// Sine gives zero at both ends and one in the middle, which is the rainbow.
float lift = MathF.Sin( t * MathF.PI );
var local = new Vector3(
ArcForward,
(0.5f - t) * ArcWidth,
ArcBaseHeight + lift * ArcLift + MathF.Sin( bobPhase ) * 6f );
var target = eye + Rotation.FromYaw( yaw ) * local;
// Eases in so a drone that has just been bought, or been left behind by a dash, flies
// into formation instead of appearing in it.
WorldPosition = Vector3.Lerp( WorldPosition, target, MathF.Min( 1f, Time.Delta * 8f ) );
var toTarget = lookAt - WorldPosition;
if ( toTarget.Length > 1f )
WorldRotation = Rotation.LookAt( toTarget.Normal );
WorldScale = Size;
}
protected override void OnUpdate()
{
// Frozen while a blocking screen or the pause menu is up. See GameTime.
if ( GameTime.Paused )
return;
float flash = MathF.Max( 0f, 1f - timeSinceFired / 0.12f );
// Idles as a dim ember and flares on firing, so the arch reads as a row of working
// machines rather than decoration.
if ( renderer.IsValid() )
renderer.Tint = new Color( 1f, 0.62f, 0.24f ) * (1.5f + flash * 5f);
if ( glow.IsValid() )
glow.LightColor = new Color( 1f, 0.72f, 0.3f ) * (1.4f + flash * 7f);
}
/// <summary>
/// Grows or shrinks the arch to match the Drones upgrade.
/// </summary>
/// <remarks>
/// **`GameObject.Destroy()` IS DEFERRED.** The object is not removed from <c>all</c> until
/// the end of the frame, so a loop written as
///
/// while ( all.Count > wanted ) all[^1].GameObject.Destroy();
///
/// never terminates: the count it is waiting on cannot change until the frame it is blocking
/// completes. **That was the collapse hang.** Collapse resets upgrade levels, drones drop
/// from nine to zero, and the game locks up on the spot - which is why it only ever happened
/// on Collapse, the one moment a population SHRINKS.
///
/// Trimming is now a bounded for-loop over a snapshot, so it terminates regardless of when
/// the engine actually reaps the objects. **Never loop on a collection that deferred
/// destruction is supposed to shrink.**
/// </remarks>
public static void MatchPopulation( Scene scene, int wanted )
{
if ( all.Count > wanted )
{
var doomed = all.Skip( Math.Max( 0, wanted ) ).ToList();
foreach ( var drone in doomed )
{
if ( drone.IsValid() )
drone.GameObject.Destroy();
}
// The list will not shrink until end of frame, so there is nothing left to add.
return;
}
while ( all.Count < wanted )
Spawn( scene );
}
/// <summary>
/// A unit drone: a squat diamond hull with a stubby barrel out the front, so it has an
/// obvious face and you can tell which way it is aiming.
/// </summary>
private static Model GetDroneModel()
{
if ( droneModel != null )
return droneModel;
var vb = new VertexBuffer();
vb.Init( true );
int index = 0;
// Hull: an octahedron squashed along Z.
Vector3[] tips = { Vector3.Up * 0.45f, Vector3.Down * 0.45f };
Vector3[] ring =
{
new( 0.55f, 0f, 0f ), new( 0f, 0.5f, 0f ), new( -0.5f, 0f, 0f ), new( 0f, -0.5f, 0f ),
};
foreach ( var tip in tips )
{
for ( int i = 0; i < ring.Length; i++ )
{
var a = ring[i];
var b = ring[(i + 1) % ring.Length];
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;
}
}
// Barrel out along +X.
AddBox( vb, ref index, new Vector3( 0.7f, 0f, 0f ), new Vector3( 0.5f, 0.16f, 0.16f ) );
var mesh = new Mesh( Material.Load( "materials/default.vmat" ) );
mesh.CreateBuffers( vb );
droneModel = new ModelBuilder().AddMesh( mesh ).Create();
return droneModel;
}
private static void AddBox( VertexBuffer vb, ref int index, Vector3 centre, Vector3 size )
{
Vector3[] normals =
{
Vector3.Forward, Vector3.Backward, Vector3.Left,
Vector3.Right, Vector3.Up, Vector3.Down,
};
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 face = centre + n * 0.5f * Project( size, n );
var du = u * 0.5f * Project( size, u );
var dv = v * 0.5f * Project( size, v );
var p0 = face - du - dv;
var p1 = face + du - dv;
var p2 = face + du + dv;
var p3 = face - du + dv;
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;
}
}
private static float Project( Vector3 size, Vector3 axis )
=> MathF.Abs( axis.x ) * size.x + MathF.Abs( axis.y ) * size.y + MathF.Abs( axis.z ) * size.z;
}