Game/OrbitalBarrier.cs
namespace Monolith;
/// <summary>
/// A wide panel that orbits the shape and swallows anything fired through it.
///
/// The important difference from an interceptor: **you cannot shoot it away**. An interceptor
/// is a target, so the answer is more damage. A barrier has no answer except to move, which is
/// exactly why it exists. Standing still and holding fire stops being viable the moment one of
/// these sweeps across your lane.
///
/// Blocking is tested in cylindrical coordinates about the shape centre rather than with a
/// mesh, which makes it exact and costs a couple of trig calls per projectile step.
/// </summary>
public sealed class OrbitalBarrier : Component
{
private static readonly List<OrbitalBarrier> all = new();
public static IReadOnlyList<OrbitalBarrier> All => all;
private static Model panelModel;
/// <summary>Centre of the orbit, in world space.</summary>
public Vector3 Centre { get; set; }
/// <summary>Distance from the centre the panel sits at.</summary>
public float OrbitRadius { get; set; } = 700f;
/// <summary>Current angle around the centre, in degrees.</summary>
public float Angle { get; set; }
/// <summary>Degrees per second. Signed, so panels can counter-rotate.</summary>
public float AngularSpeed { get; set; } = 14f;
/// <summary>Half-height of the panel above and below its centre height.</summary>
public float HalfHeight { get; set; } = 260f;
/// <summary>Height of the panel's centre, relative to the orbit centre.</summary>
public float HeightOffset { get; set; }
private ModelRenderer renderer;
public static OrbitalBarrier Spawn( Scene scene, Vector3 centre, float radius,
float angle, float speed, float heightOffset, float halfHeight )
{
if ( scene == null ) return null;
var obj = new GameObject( true, "Orbital Barrier" );
obj.NetworkMode = NetworkMode.Never;
var barrier = obj.AddComponent<OrbitalBarrier>();
barrier.Centre = centre;
barrier.OrbitRadius = radius;
barrier.Angle = angle;
barrier.AngularSpeed = speed;
barrier.HeightOffset = heightOffset;
barrier.HalfHeight = halfHeight;
var mr = obj.AddComponent<ModelRenderer>();
mr.Model = GetPanelModel();
mr.Tint = new Color( 0.55f, 0.14f, 0.30f );
barrier.renderer = mr;
barrier.Reposition();
return barrier;
}
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;
Angle += AngularSpeed * Time.Delta;
if ( Angle >= 360f ) Angle -= 360f;
if ( Angle < 0f ) Angle += 360f;
Reposition();
if ( !renderer.IsValid() )
return;
// Emissive rather than dark. A dim maroon slab read as a painted wall; bars that glow
// read as a field, which is what tells you at a glance that it is not part of the shape
// and not something you can shoot away.
float flash = MathF.Max( 0f, 1f - timeSinceAbsorb / AbsorbFlashSeconds );
float hum = 0.85f + 0.15f * MathF.Sin( Time.Now * 2.6f + OrbitRadius );
// Heat accumulates per shot eaten and bleeds off when you stop. A single flash is easy
// to miss while firing ten times a second; a panel that visibly heats under sustained
// fire tells you that you are wasting every one of those shots.
heat = Math.Clamp( heat + absorbCount * HeatPerHit
- HeatDecayPerSecond * Time.Delta, 0f, 1f );
absorbCount = 0;
// Angry white through pink at full heat, so the message escalates rather than repeating.
var idle = new Color( 1.5f, 0.28f, 0.5f ) * hum;
var hot = Color.Lerp( idle, new Color( 1f, 0.55f, 0.75f ) * 5f, heat );
renderer.Tint = Color.Lerp( hot, new Color( 1f, 0.85f, 0.95f ) * 11f, flash );
}
private void Reposition()
{
float radians = Angle * MathF.PI / 180f;
var position = Centre + new Vector3(
MathF.Cos( radians ) * OrbitRadius,
MathF.Sin( radians ) * OrbitRadius,
HeightOffset );
WorldPosition = position;
// Face the centre so the panel presents its width across your line of fire.
WorldRotation = Rotation.LookAt( (Centre.WithZ( position.z ) - position).Normal );
// Arc length at this radius gives the panel its apparent width.
float arcWidth = Tuning.BarrierArcDegrees * MathF.PI / 180f * OrbitRadius;
WorldScale = new Vector3( 24f, arcWidth, HalfHeight * 2f );
}
/// <summary>
/// True if the segment blocks this projectile step. Tested in cylindrical coordinates:
/// a point is inside when its radius, height and bearing all fall within the panel.
///
/// Reports WHICH barrier and WHERE, because a lattice you can see through has to prove it
/// is still solid. Without the absorb landing somewhere visible, a barrier that eats your
/// shots while you can see the rock behind it just looks like the gun has stopped working.
/// </summary>
public static bool Blocks( Vector3 start, Vector3 direction, float distance,
out OrbitalBarrier hit, out Vector3 point )
{
hit = null;
point = start;
if ( all.Count == 0 )
return false;
// A barrier shields the MONOLITH. It does not shield the hazards.
//
// This was an unwinnable interaction. Barriers orbit at ~0.55 of the shape's diagonal
// while Sentinels sit at 0.7 and Anchors at 0.9, so the hazards are ALWAYS outside the
// barrier ring: every shot aimed at the things that force you to look away from the
// shape was eaten by the ring on the way out. You could not cut a tether or kill a
// sentinel at all, which is exactly what "these do nothing" looks like.
//
// Only shots travelling INWARD are blocked now. Firing out at a hazard passes; firing in
// at the rock is what the panel is there to stop. It also reads correctly: the thing is
// a shield around the monolith, so it faces the monolith's attacker.
if ( !IsHeadingInward( all[0].Centre, start, direction ) )
return false;
// Sample along the step.
//
// Three samples used to be "plenty", and it was not. A bolt travels 4200 units per
// second, so at a good framerate one step is 30 to 70 units while the blocking slab is
// 80 units thick radially. Approach it at a shallow angle and the sampled points could
// straddle it entirely, which is why shots appeared to pass through from some directions
// and not others. Sample density has to be set by the THINNEST dimension of the volume,
// not by the step length.
int samples = Math.Clamp( (int)(distance / 12f) + 2, 4, 24 );
for ( int s = 0; s <= samples; s++ )
{
var sample = start + direction * (distance * s / (float)samples);
for ( int i = 0; i < all.Count; i++ )
{
if ( !all[i].Contains( sample ) )
continue;
hit = all[i];
point = sample;
return true;
}
}
return false;
}
/// <summary>
/// A shot just died on this panel. Flares the whole lattice for a moment so the absorb is
/// attributed to the barrier and not to thin air.
/// </summary>
public void Absorb()
{
timeSinceAbsorb = 0f;
absorbCount++;
}
private GameTimeSince timeSinceAbsorb = 99f;
/// <summary>
/// Shots eaten. Drives a rising glow, so a barrier you are firing into gets visibly hotter
/// the longer you waste ammunition on it. One flash is easy to miss when you are firing ten
/// times a second; a panel that brightens under sustained fire is not.
/// </summary>
private int absorbCount;
private GameTimeSince timeSinceAbsorbDecay;
/// <summary>Length of the single-hit flare. Short: it is a punctuation mark, not a state.</summary>
private const float AbsorbFlashSeconds = 0.3f;
/// <summary>Hits it takes to reach full heat, and how fast that heat bleeds off.</summary>
private const float HeatPerHit = 0.17f;
private const float HeatDecayPerSecond = 0.8f;
private float heat;
private bool Contains( Vector3 point )
{
var local = point - Centre;
float height = local.z - HeightOffset;
if ( MathF.Abs( height ) > HalfHeight )
return false;
float radius = new Vector2( local.x, local.y ).Length;
if ( MathF.Abs( radius - OrbitRadius ) > 40f )
return false;
float bearing = MathF.Atan2( local.y, local.x ) * 180f / MathF.PI;
if ( bearing < 0f ) bearing += 360f;
float delta = MathF.Abs( DeltaAngle( bearing, Angle ) );
return delta <= Tuning.BarrierArcDegrees * 0.5f;
}
/// <summary>
/// True if the shot is closing on the shape centre in the horizontal plane.
///
/// Measured in XY only. Height is irrelevant to "is this aimed at the monolith", and
/// including it would mean a shot at a Spotter directly overhead counted as inward.
/// </summary>
private static bool IsHeadingInward( Vector3 centre, Vector3 start, Vector3 direction )
{
var outward = (start - centre).WithZ( 0f );
// Standing dead centre: nothing sensible to compare against, so let it through rather
// than blocking arbitrarily.
if ( outward.Length < 1f )
return false;
return Vector3.Dot( direction.WithZ( 0f ), outward.Normal ) < 0f;
}
/// <summary>Shortest signed distance between two bearings, in degrees.</summary>
private static float DeltaAngle( float a, float b )
{
float d = (a - b + 540f) % 360f - 180f;
return d;
}
/// <summary>
/// A unit LATTICE, scaled per barrier into a wide panel of bars.
///
/// It used to be a solid slab, which meant a barrier drifting between you and the shape
/// blanked out the thing you were trying to mine. The blocking test is unchanged and still
/// covers the whole rectangle: only what you can see through it changed. That is the right
/// trade, because the barrier's job is to move your feet, not to hide the level.
///
/// Local axes at draw time are (thickness, width, height), so the bars are laid out in the
/// Y/Z plane and left full depth in X.
/// </summary>
private static Model GetPanelModel()
{
if ( panelModel != null )
return panelModel;
var vb = new VertexBuffer();
vb.Init( true );
int index = 0;
// JUST THE FRAME. The lattice version read as a window pane, which made a barrier look
// like scenery you were behind rather than an obstacle in your way. An empty rectangle
// says "the opening is blocked" without pretending to be glass.
//
// `depth` stays small: the bars used to span the panel's full 24 unit thickness, so the
// moment you looked at one off-axis they occluded each other and it filled in solid.
// A frame is only see-through when it is FLAT.
const float bar = 0.04f;
const float depth = 0.28f;
// Top and bottom rails.
AddBox( vb, ref index, new Vector3( 0f, 0f, -0.5f ), new Vector3( depth, 1f, bar ) );
AddBox( vb, ref index, new Vector3( 0f, 0f, 0.5f ), new Vector3( depth, 1f, bar ) );
// Left and right posts.
AddBox( vb, ref index, new Vector3( 0f, -0.5f, 0f ), new Vector3( depth, bar, 1f ) );
AddBox( vb, ref index, new Vector3( 0f, 0.5f, 0f ), new Vector3( depth, bar, 1f ) );
var mesh = new Mesh( Material.Load( "materials/default.vmat" ) );
mesh.CreateBuffers( vb );
panelModel = new ModelBuilder().AddMesh( mesh ).Create();
return panelModel;
}
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;
}