Game/Anchor.cs
namespace Monolith;
/// <summary>
/// A grapple emplacement that fires a cable at you and holds you down.
///
/// This is the hazard that most directly forces the thing everything else only encourages: you
/// cannot outrun it, you cannot ignore it, and **the only way off is to shoot the far end**,
/// which is across the arena and nowhere near the shape you were mining. It is the one threat
/// that makes you turn your back on your work.
///
/// The design constraint that shaped it: the tether had to be breakable by shooting something
/// AWAY from the player, not something stuck to them. A parasite on your own body is fiddly to
/// aim at and reads as a UI problem; a cable running off into the dark reads as a place to point
/// your gun, and points it in the least convenient direction available.
///
/// Costs time, never progress. While tethered you move at half speed, which is dangerous
/// specifically because a Spotter is easier to escape when you are fast.
/// </summary>
public sealed class Anchor : Component
{
private static readonly List<Anchor> all = new();
public static IReadOnlyList<Anchor> All => all;
private static Model bodyModel;
[Property] public float Radius { get; set; } = 70f;
public int Health { get; private set; } = Tuning.AnchorHealth;
/// <summary>True while the cable is attached and slowing the player.</summary>
public bool Attached { get; private set; }
private ModelRenderer renderer;
private PointLight glow;
private GameObject cableObject;
private ModelRenderer cableRenderer;
private GameTimeSince timeSinceStateChange = 99f;
private float bobPhase;
public static Anchor Spawn( Scene scene, Vector3 position )
{
if ( scene == null ) return null;
var obj = new GameObject( true, "Anchor" );
obj.NetworkMode = NetworkMode.Never;
obj.WorldPosition = position;
var anchor = obj.AddComponent<Anchor>();
anchor.bobPhase = Game.Random.Float( 0f, 10f );
obj.WorldScale = anchor.Radius;
var mr = obj.AddComponent<ModelRenderer>();
mr.Model = GetBodyModel();
anchor.renderer = mr;
var light = obj.AddComponent<PointLight>();
light.LightColor = new Color( 0.5f, 1f, 0.4f ) * 4f;
light.Radius = 600f;
light.Shadows = false;
anchor.glow = light;
// The cable is its own object so it can be stretched between two moving points.
var cable = new GameObject( true, "Anchor Cable" );
cable.NetworkMode = NetworkMode.Never;
var cableMr = cable.AddComponent<ModelRenderer>();
cableMr.Model = Projectile.SharedBoltModel;
cableMr.Tint = Color.Black;
anchor.cableObject = cable;
anchor.cableRenderer = cableMr;
Log.Info( "An Anchor has arrived." );
return anchor;
}
protected override void OnEnabled() => all.Add( this );
protected override void OnDisabled()
{
all.Remove( this );
cableObject?.Destroy();
cableObject = null;
}
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;
var player = Scene.GetAllComponents<PlayerMovement>().FirstOrDefault();
bobPhase += Time.Delta * 1.3f;
WorldPosition = WorldPosition.WithZ(
manager.World.WorldBounds.Mins.z + Tuning.AnchorHoverHeight
+ MathF.Sin( bobPhase ) * 14f );
if ( Attached )
UpdateAttached( manager, player );
else
UpdateSeeking( manager, player );
DrawCable( player );
}
private void UpdateSeeking( MonolithManager manager, PlayerMovement player )
{
if ( glow.IsValid() )
glow.LightColor = new Color( 0.5f, 1f, 0.4f ) * 3f;
if ( renderer.IsValid() )
renderer.Tint = new Color( 0.45f, 0.85f, 0.4f ) * 1.6f;
if ( !player.IsValid() || timeSinceStateChange < Tuning.AnchorCooldownSeconds )
return;
var toPlayer = player.WorldPosition - WorldPosition;
float distance = toPlayer.Length;
if ( distance > Tuning.AnchorRange )
return;
// Needs a clear line to fire, so the shape shelters you from it. Consistent with the
// Spotter and the Sentinel: everything in this game respects cover.
if ( manager.World.TraceRay( WorldPosition, toPlayer.Normal, distance - 24f, out _ ) )
return;
Attached = true;
timeSinceStateChange = 0f;
Audio.Play( Audio.Alert, WorldPosition, 0.6f, 0.5f );
}
private void UpdateAttached( MonolithManager manager, PlayerMovement player )
{
// Releases on its own eventually, so a player who genuinely cannot find it is not stuck
// forever. Being unable to solve a problem is frustrating; being slowed for nine seconds
// is merely expensive.
if ( !player.IsValid() || timeSinceStateChange > Tuning.AnchorHoldSeconds )
{
Release();
return;
}
// Line of sight is NOT required to keep holding. Once it has you, ducking behind the
// shape does not help: that is what makes this different from every other hazard and
// why the answer has to be aggression rather than cover.
player.SpeedMultiplier = Tuning.AnchorSlowFactor;
float pulse = 0.6f + 0.4f * MathF.Sin( Time.Now * 7f );
if ( glow.IsValid() )
glow.LightColor = new Color( 0.6f, 1f, 0.35f ) * (5f + pulse * 5f);
if ( renderer.IsValid() )
renderer.Tint = new Color( 0.7f, 1f, 0.4f ) * (2.2f + pulse * 2f);
}
private void Release()
{
Attached = false;
timeSinceStateChange = 0f;
}
/// <summary>
/// Lets go and goes on cooldown. Called when a new stage condenses.
///
/// Anchors carry over between stages like the other hazards, and I reset the Spotter's lock
/// and the Sentinel's reload but never this. So a stage could open with the cable already on
/// you, halving your speed, which is the "line attached at spawn" report: it is not a Spotter
/// beam at all, it is a tether nobody released.
///
/// **THIRD time the same mistake.** Carrying a hazard across stages is right; carrying its
/// STATE across is wrong, and each hazard has its own state to clear.
/// </summary>
public void ReleaseForNewStage()
{
Attached = false;
// Restarted from zero, so the full AnchorCooldownSeconds must elapse before it can grab
// again. Leaving the timer where it was would let it re-tether almost immediately and
// reproduce the same complaint one second later.
timeSinceStateChange = 0f;
}
/// <summary>Releases every anchor. Called when a new stage condenses.</summary>
public static void ReleaseAll()
{
for ( int i = 0; i < all.Count; i++ )
{
if ( all[i].IsValid() )
all[i].ReleaseForNewStage();
}
}
/// <summary>
/// Stretches the cable between the anchor and the player. Drawn only while attached: a
/// visible line is the tell that tells you where to shoot, and it should not be showing
/// when there is nothing to do about it.
/// </summary>
private void DrawCable( PlayerMovement player )
{
if ( !cableObject.IsValid() )
return;
if ( !Attached || !player.IsValid() )
{
cableObject.WorldScale = Vector3.Zero;
if ( cableRenderer.IsValid() )
cableRenderer.Tint = Color.Black;
return;
}
var toPlayer = player.WorldPosition - WorldPosition;
float length = MathF.Max( 40f, toPlayer.Length );
cableObject.WorldPosition = WorldPosition + toPlayer * 0.5f;
cableObject.WorldRotation = Rotation.LookAt( toPlayer.Normal );
cableObject.WorldScale = new Vector3( length, 3.5f, 3.5f );
float pulse = 0.7f + 0.3f * MathF.Sin( Time.Now * 9f );
if ( cableRenderer.IsValid() )
cableRenderer.Tint = new Color( 0.6f, 1f, 0.35f ) * (2.5f + pulse * 2f);
}
public static bool TryHit( Vector3 start, Vector3 direction, float distance, out Anchor 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 );
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;
if ( Health > 0 )
{
Audio.Play( Audio.MetalHit, WorldPosition, 0.5f, Audio.Vary( 1.4f ) );
return;
}
var progress = PlayerProgress.Local;
if ( progress.IsValid() )
{
progress.AwardDust( Tuning.AnchorDustReward );
// Worth more than the dust: cutting a tether under pressure is the play this hazard
// exists to create, so it pays the chain generously.
progress.AddResonance( 3 );
progress.Data.AnchorsCut++;
}
BlastEffect.Spawn( Scene, WorldPosition, 300f, true );
Debris.Burst( Scene, WorldPosition, 160f, true );
Audio.Play( Audio.Explosion, WorldPosition, 0.8f, Audio.Vary( 1.35f ) );
Log.Info( "Anchor cut." );
GameObject.Destroy();
}
/// <summary>A squat spike: reads as a stake driven into the air.</summary>
private static Model GetBodyModel()
{
if ( bodyModel != null ) return bodyModel;
var vb = new VertexBuffer();
vb.Init( true );
int index = 0;
Vector3[] tips = { Vector3.Up * 1.1f, Vector3.Down * 0.6f };
Vector3[] ring =
{
new( 0.6f, 0f, 0f ), new( 0.42f, 0.42f, 0f ), new( 0f, 0.6f, 0f ),
new( -0.42f, 0.42f, 0f ), new( -0.6f, 0f, 0f ), new( -0.42f, -0.42f, 0f ),
new( 0f, -0.6f, 0f ), new( 0.42f, -0.42f, 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;
}
}
var mesh = new Mesh( Material.Load( "materials/default.vmat" ) );
mesh.CreateBuffers( vb );
bodyModel = new ModelBuilder().AddMesh( mesh ).Create();
return bodyModel;
}
}