Voxel/ShieldShell.cs
namespace Monolith;
/// <summary>
/// The visible husk around a shielded shape, and the node that switches it off.
///
/// This exists because the shield previously had **no presence whatsoever**: its only effects
/// were jamming drones (which a new player does not own) and a line of HUD text. It fired on
/// more than a quarter of stages and was reported as never happening, which is what an
/// invisible mechanic looks like from the outside.
///
/// The cage is built from thin bars rather than a solid box so you can still see and shoot the
/// shape through it. It blocks nothing: it is a statement, not an obstacle.
///
/// The NODE hanging above the shape is the interactive half. Shooting it drops the shield for
/// a couple of seconds and then it comes back, so a shielded stage is a repeating skill beat
/// rather than a timer you sit out. **Drone shots pass through it** (see Projectile), because a
/// shield your drones can clear is not jamming anything.
/// </summary>
public sealed class ShieldShell : Component
{
/// <summary>
/// There is only ever one shape, so there is only ever one shell. Not named Active:
/// Component.Active already exists and shadowing it is a trap.
/// </summary>
public static ShieldShell Current { get; private set; }
private static Model cageModel;
private static Model nodeModel;
private GameObject shellObject;
private ModelRenderer renderer;
private GameObject nodeObject;
private ModelRenderer nodeRenderer;
private PointLight nodeLight;
private bool visible;
/// <summary>Centre of the node, for hit tests and effects.</summary>
public Vector3 NodePosition => nodeObject.IsValid() ? nodeObject.WorldPosition : Vector3.Zero;
/// <summary>True while the node has been struck and the shield is down.</summary>
private bool IsBroken => MonolithManager.Instance.IsValid()
&& MonolithManager.Instance.ShieldBroken;
protected override void OnEnabled() => Current = this;
protected override void OnDisabled()
{
if ( Current == this )
Current = null;
}
public void Show( BBox bounds )
{
EnsureObjects();
float pad = Tuning.ShieldShellPadding;
shellObject.WorldPosition = bounds.Center;
shellObject.WorldScale = bounds.Size + new Vector3( pad, pad, pad ) * 2f;
shellObject.Enabled = true;
// The node hangs clear of the cage in open sky, so it is always shootable from the
// ground without having to find an angle through the shape.
nodeObject.WorldPosition = bounds.Center
.WithZ( bounds.Maxs.z + pad + Tuning.ShieldNodeHeight );
nodeObject.WorldScale = Tuning.ShieldNodeRadius;
nodeObject.Enabled = true;
visible = true;
}
public void Hide()
{
visible = false;
if ( shellObject.IsValid() ) shellObject.Enabled = false;
if ( nodeObject.IsValid() ) nodeObject.Enabled = false;
}
protected override void OnDestroy()
{
shellObject?.Destroy();
shellObject = null;
nodeObject?.Destroy();
nodeObject = null;
}
private void EnsureObjects()
{
if ( !shellObject.IsValid() )
{
shellObject = new GameObject( true, "Shield Shell" );
shellObject.NetworkMode = NetworkMode.Never;
shellObject.Parent = GameObject;
renderer = shellObject.AddComponent<ModelRenderer>();
renderer.Model = GetCageModel();
}
if ( nodeObject.IsValid() )
return;
nodeObject = new GameObject( true, "Shield Node" );
nodeObject.NetworkMode = NetworkMode.Never;
nodeRenderer = nodeObject.AddComponent<ModelRenderer>();
nodeRenderer.Model = GetNodeModel();
nodeLight = nodeObject.AddComponent<PointLight>();
nodeLight.Radius = 900f;
nodeLight.Shadows = false;
}
protected override void OnUpdate()
{
// Frozen while a blocking screen or the pause menu is up. See GameTime.
if ( GameTime.Paused )
return;
if ( !visible || !shellObject.IsValid() || !shellObject.Enabled )
return;
bool broken = IsBroken;
// Slow electric shimmer, so it reads as powered rather than as scenery. While broken the
// cage goes dim and stops pulsing: the state of the shield is legible from the shape
// itself, without looking at the HUD.
float pulse = 0.6f + 0.4f * MathF.Sin( Time.Now * 3.4f );
if ( renderer.IsValid() )
{
renderer.Tint = broken
? new Color( 0.12f, 0.20f, 0.26f )
: new Color( 0.25f, 0.75f, 1f ) * (1.2f + pulse * 1.6f);
}
if ( !nodeObject.IsValid() )
return;
// The node spins, so it is picked out as a machine against the sky rather than mistaken
// for a piece of the shape. It beats hard while live and goes cold while broken, which
// is also the recharge tell.
nodeObject.WorldRotation *= Rotation.From( 0f, 70f * Time.Delta, 25f * Time.Delta );
float beat = 0.5f + 0.5f * MathF.Sin( Time.Now * 6.5f );
var colour = broken
? new Color( 0.16f, 0.18f, 0.22f )
: new Color( 0.35f, 0.85f, 1f ) * (2.4f + beat * 3.4f);
if ( nodeRenderer.IsValid() )
nodeRenderer.Tint = colour;
if ( nodeLight.IsValid() )
nodeLight.LightColor = broken ? Color.Black : colour;
}
// ---------------------------------------------------------------- the weak point
/// <summary>
/// Sphere test against the node for one projectile step, same shape as every other hittable.
/// Returns false when there is no shield up or it is already broken, so a wasted shot passes
/// through to the rock behind instead of being eaten.
/// </summary>
public static bool TryHitNode( Vector3 start, Vector3 direction, float distance, out ShieldShell hit )
{
hit = null;
var shell = Current;
if ( !shell.IsValid() || !shell.visible || !shell.nodeObject.IsValid() )
return false;
var manager = MonolithManager.Instance;
if ( !manager.IsValid() || !manager.ShieldActive )
return false;
float radius = Tuning.ShieldNodeRadius;
var toCentre = shell.nodeObject.WorldPosition - start;
float along = Vector3.Dot( toCentre, direction );
if ( along < -radius || along > distance + radius )
return false;
float perpSq = toCentre.LengthSquared - along * along;
if ( perpSq > radius * radius )
return false;
hit = shell;
return true;
}
/// <summary>One clean hit takes the whole shield down. No health bar: it is a switch.</summary>
public void Strike()
{
if ( MonolithManager.Instance.IsValid() )
MonolithManager.Instance.BreakShield();
var progress = PlayerProgress.Local;
if ( progress.IsValid() )
{
progress.AddResonance();
progress.Data.ShieldNodesBroken++;
}
}
// ---------------------------------------------------------------- models
/// <summary>A unit cage: twelve thin bars along the edges of a unit cube.</summary>
private static Model GetCageModel()
{
if ( cageModel != null )
return cageModel;
var vb = new VertexBuffer();
vb.Init( true );
const float t = 0.012f;
int index = 0;
// Four bars along each axis, at the four edge positions of the cube.
for ( int axis = 0; axis < 3; axis++ )
{
for ( int corner = 0; corner < 4; corner++ )
{
float a = (corner & 1) == 0 ? -0.5f : 0.5f;
float b = (corner & 2) == 0 ? -0.5f : 0.5f;
Vector3 size = axis switch
{
0 => new Vector3( 1f, t, t ),
1 => new Vector3( t, 1f, t ),
_ => new Vector3( t, t, 1f ),
};
Vector3 centre = axis switch
{
0 => new Vector3( 0f, a, b ),
1 => new Vector3( a, 0f, b ),
_ => new Vector3( a, b, 0f ),
};
AddBox( vb, ref index, centre, size );
}
}
var mesh = new Mesh( Material.Load( "materials/default.vmat" ) );
mesh.CreateBuffers( vb );
cageModel = new ModelBuilder().AddMesh( mesh ).Create();
return cageModel;
}
/// <summary>A unit octahedron. Angular and obviously artificial, unlike the rock.</summary>
private static Model GetNodeModel()
{
if ( nodeModel != null )
return nodeModel;
var vb = new VertexBuffer();
vb.Init( true );
Vector3[] tips = { Vector3.Up, Vector3.Down };
Vector3[] ring =
{
new( 1f, 0f, 0f ), new( 0f, 1f, 0f ), new( -1f, 0f, 0f ), new( 0f, -1f, 0f ),
};
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];
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 );
nodeModel = new ModelBuilder().AddMesh( mesh ).Create();
return nodeModel;
}
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;
}
}
/// <summary>Magnitude of a box extent along an axis direction.</summary>
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;
}