Game/Spotter.cs
namespace Monolith;
/// <summary>
/// A hovering searchlight that hunts the player, in the spirit of an ARC Raiders spotter.
///
/// It is the only thing in the game that can take something away from you, and that is the
/// point: everything else rewards standing still and holding fire, so one threat has to make
/// that a losing choice. Its three answers are all movement or aggression, never waiting:
///
/// - **Break line of sight.** The monolith itself is cover. This is the important one,
/// because it turns the thing you are mining into terrain you have to think about.
/// - **Leave the beam.** Sprint or dash out of the cone.
/// - **Shoot it down.** It is fragile enough to kill, if you can spare the shots.
///
/// Fairness is enforced in three places: a grace period before the lock even starts counting,
/// a decay that unwinds faster than the lock fills, and a full reset the instant line of sight
/// breaks. You should never lose a stage without having had an obvious way out.
/// </summary>
public sealed class Spotter : Component
{
private static readonly List<Spotter> all = new();
public static IReadOnlyList<Spotter> All => all;
/// <summary>Not named Active: Component.Active already means something and hiding it bites.</summary>
public static Spotter Primary => all.Count > 0 ? all[0] : null;
private static Model bodyModel;
[Property] public float Radius { get; set; } = Tuning.SpotterRadius;
public int Health { get; private set; } = Tuning.SpotterHealth;
/// <summary>0 to 1. At 1 the stage is wiped.</summary>
public float LockProgress { get; private set; }
/// <summary>True while the beam is on you and it can see you.</summary>
public bool HasContact { get; private set; }
/// <summary>
/// Asleep until you come near. They hang around the shape as scenery you can pick off at
/// leisure, and only become a threat once you choose to work in their area.
/// </summary>
public bool Standby { get; private set; } = true;
/// <summary>True once contact has been held past the grace period.</summary>
public bool IsLocking { get; private set; }
private Vector3 wanderTarget;
private Vector3 beamAim;
/// <summary>Unit vector the beam currently points along. Slews, never snaps.</summary>
private Vector3 beamDirection = Vector3.Down;
/// <summary>Last position it had clear sight of, which is what it works while blocked.</summary>
private Vector3 lastSeen;
/// <summary>False until it has had clear sight of you at least once.</summary>
private bool hasLastSeen;
private GameTimeSince timeSinceRetarget;
private float contactSeconds;
private ModelRenderer bodyRenderer;
private PointLight hullLight;
private GameObject beamObject;
private ModelRenderer beamRenderer;
/// <summary>Where the idle searchlight is sweeping while nobody is in contact.</summary>
private float sweepPhase;
/// <summary>Starts expired, so a freshly spawned Spotter is armed immediately.</summary>
private GameTimeSince timeSinceWipe = 99f;
/// <summary>
/// The Spotter's voice, and the reason this class has audio at all.
///
/// It is a repeating positional tick whose RATE and PITCH climb with the lock. That gives
/// the one thing the beam cannot: a warning that works when you are not looking at it.
/// Devil Daggers' spatial audio exists to tell you what is behind you (GOALS 6d), and a
/// searchlight you can only detect by facing it is a searchlight you will be caught by.
/// </summary>
private GameTimeSince timeSinceTick;
private bool announced;
/// <summary>
/// A light placed where the beam lands. This is the whole warning system: when it is on
/// you, the ground around you is lit and everything reddens, and when you break line of
/// sight it goes out. No HUD text, because reading the world should be the skill.
/// </summary>
private GameObject spotObject;
private PointLight spotLight;
public static Spotter Spawn( Scene scene, Vector3 position )
{
if ( scene == null ) return null;
var obj = new GameObject( true, "Spotter" );
obj.NetworkMode = NetworkMode.Never;
obj.WorldPosition = position;
var spotter = obj.AddComponent<Spotter>();
obj.WorldScale = spotter.Radius;
var mr = obj.AddComponent<ModelRenderer>();
mr.Model = GetBodyModel();
mr.Tint = new Color( 0.75f, 0.78f, 0.85f );
spotter.bodyRenderer = mr;
// Bright and wide. It hangs against an empty sky a long way off, where anything subtle
// is simply not seen: the report was twelve stages without noticing one.
var light = obj.AddComponent<PointLight>();
light.LightColor = new Color( 0.7f, 0.85f, 1f ) * 14f;
light.Radius = 1100f;
light.Shadows = false;
spotter.hullLight = light;
// The beam is its own object so it can be scaled and aimed independently of the body.
var beam = new GameObject( true, "Spotter Beam" );
beam.NetworkMode = NetworkMode.Never;
var beamMr = beam.AddComponent<ModelRenderer>();
beamMr.Model = Projectile.SharedBoltModel;
beamMr.Tint = Color.Black;
spotter.beamObject = beam;
spotter.beamRenderer = beamMr;
var spot = new GameObject( true, "Spotter Contact Light" );
spot.NetworkMode = NetworkMode.Never;
var spotLight = spot.AddComponent<PointLight>();
spotLight.LightColor = Color.Black;
spotLight.Radius = 900f;
spotLight.Shadows = false;
spotter.spotObject = spot;
spotter.spotLight = spotLight;
spotter.wanderTarget = position;
Log.Info( "A Spotter has arrived." );
return spotter;
}
protected override void OnEnabled() => all.Add( this );
protected override void OnDisabled()
{
all.Remove( this );
beamObject?.Destroy();
beamObject = null;
spotObject?.Destroy();
spotObject = 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();
// Wake only when the player comes close. Standby ones drift and do nothing.
if ( player.IsValid() )
{
float range = WorldPosition.Distance( player.WorldPosition );
if ( Standby && range < Tuning.SpotterWakeRange )
Standby = false;
else if ( !Standby && range > Tuning.SpotterWakeRange * 1.45f )
Standby = true;
}
if ( Standby )
{
HasContact = false;
IsLocking = false;
LockProgress = 0f;
contactSeconds = 0f;
Drift( manager );
AimAt( IdleDirection( manager ), manager );
UpdateBeamVisual( manager );
UpdateVoice();
return;
}
Chase( manager, player );
UpdateContact( manager, player );
UpdateLock( manager );
UpdateBeamVisual( manager );
UpdateVoice();
}
/// <summary>
/// Ticks faster and higher as the lock fills, so the countdown is audible as well as
/// visible. Silent on standby: a sleeping Spotter should be findable, not alarming.
/// </summary>
private void UpdateVoice()
{
if ( Standby )
{
announced = false;
return;
}
// One sting the moment it wakes, so you know something has noticed you even when it is
// behind you and the beam is nowhere in frame. This is the whole point of having audio.
if ( !announced )
{
announced = true;
Audio.Play( Audio.Alert, WorldPosition, 0.5f, Audio.Vary( 0.7f ) );
}
// 1.1s apart while merely hunting, down to 0.16s at a full lock. The ACCELERATION is the
// message: a steady tick is a nuisance, a quickening one is a countdown.
float interval = HasContact
? MathF.Max( 0.16f, 0.85f - LockProgress * 0.7f )
: 1.1f;
if ( timeSinceTick < interval )
return;
timeSinceTick = 0f;
float pitch = HasContact ? 1.1f + LockProgress * 1.1f : 0.85f;
float volume = HasContact ? 0.32f + LockProgress * 0.5f : 0.18f;
Audio.Play( Audio.MetalHit, WorldPosition, volume, Audio.Vary( pitch, 0.05f ) );
}
// ---------------------------------------------------------------- movement
/// <summary>
/// Awake movement: it comes after you and then keeps station.
///
/// It holds a standoff distance rather than sitting directly overhead. Overhead is the worst
/// of both worlds: the shape blocks its own line of sight constantly, and a target straight
/// up is awkward to shoot. Off to one side it can actually see you and you can actually
/// answer it.
/// </summary>
private void Chase( MonolithManager manager, PlayerMovement player )
{
if ( !player.IsValid() )
{
Drift( manager );
return;
}
var bounds = manager.World.WorldBounds;
// Keep its current bearing on you and correct only the distance, so it circles and
// closes rather than charging down one predictable line.
var flat = (WorldPosition - player.WorldPosition).WithZ( 0f );
if ( flat.Length < 1f )
flat = Vector3.Forward;
var target = player.WorldPosition + flat.Normal * Tuning.SpotterStandoff;
target.z = bounds.Maxs.z + Tuning.SpotterHoverHeight;
var toTarget = target - WorldPosition;
if ( toTarget.Length > 12f )
WorldPosition += toTarget.Normal * Tuning.SpotterChaseSpeed * Time.Delta;
WorldRotation *= Rotation.From( 0f, 24f * Time.Delta, 0f );
}
/// <summary>Standby movement: aimless wander above the shape.</summary>
private void Drift( MonolithManager manager )
{
var bounds = manager.World.WorldBounds;
if ( timeSinceRetarget > Tuning.SpotterRetargetSeconds )
{
timeSinceRetarget = 0;
// Drift around above the shape rather than sitting still, so its coverage changes
// and a spot that was safe a moment ago may not stay safe.
float spread = MathF.Max( 500f, bounds.Size.Length * 0.5f );
wanderTarget = bounds.Center
+ new Vector3( Game.Random.Float( -spread, spread ),
Game.Random.Float( -spread, spread ), 0f );
wanderTarget.z = bounds.Maxs.z + Tuning.SpotterHoverHeight;
}
var toTarget = wanderTarget - WorldPosition;
if ( toTarget.Length > 8f )
WorldPosition += toTarget.Normal * Tuning.SpotterSpeed * Time.Delta;
WorldRotation *= Rotation.From( 0f, 24f * Time.Delta, 0f );
}
// ---------------------------------------------------------------- detection
/// <summary>
/// The beam hunts you at a fixed slew rate and the lock only counts while it is actually ON
/// you.
///
/// This replaced an instant snap, where the beam teleported onto you the moment line of sight
/// existed. That made the whole encounter binary: seen or not seen, with nothing in between
/// and nothing your movement could do about it. A beam that has to catch up turns it into a
/// chase you can win. Sprinting across its arc, and especially a strafe double jump, moves
/// you faster than it can turn, so the gaze slips off and the count unwinds.
/// </summary>
private void UpdateContact( MonolithManager manager, PlayerMovement player )
{
HasContact = false;
if ( !player.IsValid() )
{
AimAt( IdleDirection( manager ), manager );
return;
}
var eye = player.WorldPosition;
var toPlayer = eye - WorldPosition;
float distance = toPlayer.Length;
if ( distance > Tuning.SpotterBeamRange )
{
AimAt( IdleDirection( manager ), manager );
contactSeconds = 0f;
return;
}
var toward = toPlayer / distance;
// Line of sight through the voxel world. If the shape is in the way, it cannot see
// you: this is what makes the monolith function as cover.
bool blocked = manager.World.TraceRay( WorldPosition, toward, distance - 24f, out _ );
if ( blocked )
{
// It keeps working the last place it saw you rather than following you through solid
// rock. Chasing your live position through cover would make ducking behind the shape
// pointless, and the beam visibly stops at the surface anyway.
//
// If it has never had sight of you it falls back to patrolling, rather than aiming at
// an unset last-known position (which is the world origin).
AimAt( hasLastSeen ? (lastSeen - WorldPosition).Normal : IdleDirection( manager ),
manager );
contactSeconds = 0f;
LockProgress = 0f;
IsLocking = false;
return;
}
lastSeen = eye;
hasLastSeen = true;
// Have you been SPOTTED? A wide cone about wherever it is currently looking.
bool spotted = AngleBetween( beamDirection, toward ) <= Tuning.SpotterViewCone;
if ( !spotted )
{
// IT DOES NOT LOOK AT YOU UNTIL IT HAS FOUND YOU.
//
// This line used to aim at the player unconditionally, on the reasoning that a
// Spotter which has noticed you should visibly turn to face you. The effect was that
// its gaze crept onto you every single time regardless of what you did, and once
// inside a 50 degree cone it never left: being locked on became the default state
// and the search was theatre.
//
// Patrolling while unaware makes acquisition an EVENT. The sweep has to cross you,
// which means where you stand and when you move actually decide whether you are
// found, and slipping back out of the cone is possible because the gaze is no longer
// glued to you.
AimAt( IdleDirection( manager ), manager );
contactSeconds = 0f;
return;
}
// Spotted. NOW it tracks you, and the countdown runs.
//
// Tracking only while aware is what gives movement a job: the gaze follows at a capped
// slew rate, so crossing its arc fast enough carries you back out of the cone and drops
// the contact. Cover and distance still work, and killing it always works.
//
// Note what is NOT done here: beamAim is left alone. The drawn line only reaches the
// player once the countdown is actually running (see UpdateBeamVisual). A beam that
// touches you before anything is at stake is a false alarm, and false alarms are how a
// warning system stops being read.
AimAt( toward, manager, distance );
HasContact = true;
}
/// <summary>
/// Called by a <see cref="Sentinel"/> that has eyes on the player: wakes this Spotter and
/// swings its gaze toward the reported position.
///
/// It does NOT hand over a lock. The Spotter still has to see you itself, and the sweep still
/// has to cross you. What an alert removes is the chance that it happens to be looking the
/// other way, which turns the two hazards into a system: sentinels are cheap and everywhere
/// and see a long way, spotters are the thing that actually costs you a stage. Leaving a
/// sentinel alive is what makes a spotter dangerous.
/// </summary>
public void AlertTo( Vector3 position )
{
if ( Standby )
Standby = false;
lastSeen = position;
hasLastSeen = true;
var toward = position - WorldPosition;
if ( toward.Length > 1f )
alertDirection = toward.Normal;
}
/// <summary>Set by an alert, consumed by the next patrol update. Zero when there is none.</summary>
private Vector3 alertDirection;
/// <summary>
/// Drops any lock and sends it back to searching. Called when a new stage condenses.
///
/// Spotters persist across stages so that blitzing accumulates pressure, but persisting a
/// live LOCK is a different thing entirely: a fresh stage would open with the countdown
/// already running and a beam already welded to you, which is not a threat you can answer,
/// it is a result you were handed. The machine stays; what it knows about you does not.
/// </summary>
public void ResetAcquisition()
{
HasContact = false;
IsLocking = false;
LockProgress = 0f;
contactSeconds = 0f;
hasLastSeen = false;
alertDirection = Vector3.Zero;
// Reuses the post-wipe grace, so a new stage always opens with a beat before anything
// can start counting.
timeSinceWipe = 0f;
// Points it somewhere else outright. Leaving the gaze on you would let it re-acquire on
// the very next frame and make the reset meaningless.
var away = Rotation.FromYaw( Game.Random.Float( 0f, 360f ) ).Forward;
beamDirection = (away + Vector3.Down * 0.6f).Normal;
}
/// <summary>Clears every Spotter's lock. Called when a new stage condenses.</summary>
public static void ResetAllAcquisition()
{
for ( int i = 0; i < all.Count; i++ )
{
if ( all[i].IsValid() )
all[i].ResetAcquisition();
}
}
/// <summary>Alerts every Spotter that could plausibly reach the position.</summary>
public static void AlertAll( Vector3 position )
{
for ( int i = 0; i < all.Count; i++ )
{
var spotter = all[i];
if ( spotter.IsValid() && spotter.WorldPosition.Distance( position ) < Tuning.SpotterBeamRange )
spotter.AlertTo( position );
}
}
/// <summary>
/// Turns the beam toward a direction at a capped angular rate, then works out where it lands.
/// The cap is the mechanic: it is what you can outrun.
/// </summary>
private void AimAt( Vector3 desired, MonolithManager manager, float targetDistance = 0f )
{
if ( desired.LengthSquared < 0.001f )
return;
desired = desired.Normal;
if ( beamDirection.LengthSquared < 0.001f )
beamDirection = desired;
float apart = AngleBetween( beamDirection, desired );
// The slew budget is the larger of a flat angular rate and whatever angle
// SpotterTrackLinearSpeed works out to at this distance.
//
// The angular rate alone had a bad failure: close up, a small sideways step is worth a
// huge number of degrees, so standing DIRECTLY UNDER a Spotter made it mathematically
// incapable of following you. The safest place in the arena was right beneath the
// searchlight. The linear term removes that dead zone without making the beam
// unbeatable further out, where the angular rate is the one that binds.
float degreesPerSecond = Tuning.SpotterTrackDegreesPerSecond;
if ( targetDistance > 1f )
{
float linear = MathF.Atan2( Tuning.SpotterTrackLinearSpeed, targetDistance )
* 180f / MathF.PI;
degreesPerSecond = MathF.Max( degreesPerSecond, linear );
}
float step = degreesPerSecond * Time.Delta;
beamDirection = apart <= step || apart < 0.01f
? desired
: Vector3.Lerp( beamDirection, desired, step / apart ).Normal;
beamAim = WorldPosition + beamDirection * BeamReach( manager );
}
/// <summary>
/// How far the beam travels before it lands on something. Terminating it on the rock is what
/// makes a searching beam readable: you watch the spot crawl across the surface toward you.
/// </summary>
private float BeamReach( MonolithManager manager )
{
float max = Tuning.SpotterBeamRange;
if ( manager.World.TraceRay( WorldPosition, beamDirection, max, out var hit ) )
{
var surface = manager.World.VoxelToWorld( hit.Voxel.x, hit.Voxel.y, hit.Voxel.z );
return MathF.Max( 60f, WorldPosition.Distance( surface ) );
}
// Otherwise run it into the arena floor, so the spot always lands on something.
float floor = manager.World.WorldBounds.Mins.z;
if ( beamDirection.z < -0.05f )
return MathF.Min( max, (WorldPosition.z - floor) / -beamDirection.z );
return max;
}
private static float AngleBetween( Vector3 a, Vector3 b )
{
float dot = Math.Clamp( Vector3.Dot( a, b ), -1f, 1f );
return MathF.Acos( dot ) * 180f / MathF.PI;
}
/// <summary>
/// Direction of the idle searchlight, walking a slow circle on the ground below.
///
/// A Spotter with no beam at all was invisible as a threat: it read as scenery right up
/// until it had you. A patrolling light says what the machine is FOR before it is pointed
/// at you, so the moment it starts swinging toward you means something.
/// </summary>
private Vector3 IdleDirection( MonolithManager manager )
{
// A sentinel has called it in. Sweep TOWARD that report rather than continuing the
// patrol, so the alert biases where the search happens without granting a free lock.
if ( alertDirection.LengthSquared > 0.001f )
{
var wanted = alertDirection;
// Consumed once the gaze has actually arrived, so the alert steers the sweep for as
// long as it takes to get there and then lets the patrol resume.
if ( AngleBetween( beamDirection, wanted ) < Tuning.SpotterViewCone * 0.5f )
alertDirection = Vector3.Zero;
return wanted;
}
sweepPhase += Time.Delta * 0.55f;
var bounds = manager.World.WorldBounds;
float reach = MathF.Max( 260f, bounds.Size.Length * 0.35f );
// Wobble the radius as well as the bearing, so the pattern is not a perfect circle.
float radius = reach * (0.55f + 0.45f * MathF.Sin( sweepPhase * 0.7f ));
var target = WorldPosition
+ new Vector3( MathF.Cos( sweepPhase ) * radius, MathF.Sin( sweepPhase ) * radius, 0f );
target.z = bounds.Mins.z;
return (target - WorldPosition).Normal;
}
private void UpdateLock( MonolithManager manager )
{
// Just after a wipe nobody locks. Reported as contact-free so the beam shows the pale
// searching colour: it is visibly still hunting, just not yet counting.
if ( timeSinceWipe < Tuning.SpotterWipeCooldown )
{
HasContact = false;
IsLocking = false;
LockProgress = 0f;
contactSeconds = 0f;
return;
}
if ( HasContact )
{
contactSeconds += Time.Delta;
// Grace: a sweep that merely crosses you is not yet a threat.
if ( contactSeconds >= Tuning.SpotterAcquireSeconds )
{
IsLocking = true;
LockProgress += Time.Delta / Tuning.SpotterLockSeconds;
}
}
else
{
contactSeconds = 0f;
IsLocking = false;
LockProgress -= Time.Delta
/ Tuning.SpotterLockSeconds * Tuning.SpotterDecayMultiplier;
}
LockProgress = Math.Clamp( LockProgress, 0f, 1f );
if ( LockProgress < 1f )
return;
// Locked. Wipe the stage and STAY.
//
// It used to destroy itself here, which meant the thing that just beat you was gone from
// the stage you had to redo. That reads as a reward for losing. The rebuilt stage now
// still has it in the sky, so the reset is a second attempt at the same problem.
LockProgress = 0f;
IsLocking = false;
contactSeconds = 0f;
timeSinceWipe = 0f;
// One exposure is ONE wipe. The whole group can see you at once and every one of them
// was filling its own lock and calling the reset independently: the log showed four
// resets on the same frame. Clearing the group means being caught costs you the stage
// once, by whichever Spotter got there first, and the rest go on cooldown with it.
foreach ( var other in all )
{
if ( other == this || !other.IsValid() )
continue;
other.LockProgress = 0f;
other.IsLocking = false;
other.HasContact = false;
other.contactSeconds = 0f;
other.timeSinceWipe = 0f;
}
manager.ResetStageProgress( "Spotter lock complete" );
BlastEffect.Spawn( Scene, WorldPosition, 400f, true );
Audio.Play( Audio.Explosion, WorldPosition, 1f, 0.6f );
}
// ---------------------------------------------------------------- visuals
/// <summary>
/// Draws the tracking line and colours it.
///
/// The colour IS the countdown, and it is the only warning there is:
/// yellow it has you, the lock has started
/// orange halfway
/// red ONE SECOND LEFT
///
/// A player who learns "red means run" needs no HUD and no number, which is why there is
/// neither. Red deliberately occupies the final second rather than a proportion, so the
/// warning means the same thing whatever the lock duration is tuned to.
/// </summary>
private void UpdateBeamVisual( MonolithManager manager )
{
if ( !beamObject.IsValid() )
return;
// Three states, and the beam is drawn in ALL of them. It used to be drawn only on
// contact, which meant that in practice you never saw one: a Spotter above the shape has
// no line of sight to a player working at its base, so the single visible state was the
// one state that almost never happened.
//
// idle slow white patrol light, sweeping the ground. Says what it is.
// searching pale, snapped to where it last saw you. Says it is hunting.
// locked yellow to orange to RED. Says how long you have.
// ATTACHED only while the countdown is live.
//
// The line used to snap onto the player the instant contact began. Since acquisition and
// the countdown start together that was almost the same moment, but "almost" is the
// problem: any frame where it touched you without the timer running taught you to ignore
// a beam that was on you. Now the connection means exactly one thing, and it means it
// every time.
bool attached = IsLocking;
bool searching = !Standby && !attached;
if ( attached )
{
var player = Scene.GetAllComponents<PlayerMovement>().FirstOrDefault();
if ( player.IsValid() )
beamAim = player.WorldPosition;
}
var toAim = beamAim - WorldPosition;
float length = MathF.Max( 50f, toAim.Length );
// A thin LINE from the spotter to the player, drawn with the shared unit cube stretched
// along its length. Not a cone: you need to see exactly who is being tracked and from
// where, and a wide cone hides both.
beamObject.WorldPosition = WorldPosition + toAim * 0.5f;
beamObject.WorldRotation = Rotation.LookAt( toAim.Normal );
Color color;
float power;
float thickness;
float pulse;
// The two hunting states are drawn WIDE and the locked state TIGHT, because they are
// different mechanics: a fat shaft is the search cone that can notice you, and the thin
// bright line is the tracking beam that has. Being able to see the difference is what
// lets you judge whether walking through an area is safe.
// Everything here is deliberately restrained. An earlier pass made the beam wide and hot
// so it could not be missed, and the result filled the middle of the screen and buried
// the thing you were trying to mine. **The COLOUR carries the threat; the area is just
// how you find where it is coming from.** A thin line that reddens is more alarming than
// a fat one, because you have to look at it to read it.
if ( Standby )
{
color = new Color( 0.55f, 0.75f, 0.95f );
pulse = 1f;
power = 0.7f;
thickness = Tuning.SpotterBeamThickness * 2f;
}
else if ( searching )
{
color = new Color( 0.95f, 0.97f, 1f );
pulse = 0.8f + 0.2f * MathF.Sin( Time.Now * 5f );
power = 1.1f * pulse;
thickness = Tuning.SpotterBeamThickness * 2.8f;
}
else
{
color = LockColor;
// Pulse rate rises with the lock, so the beam is visibly agitated by the end. It
// stays THIN throughout: the escalation is colour and rhythm, not size.
// This branch is now reached only while the countdown is running.
pulse = 0.75f + 0.25f * MathF.Sin( Time.Now * (7f + LockProgress * 26f) );
power = (1.8f + LockProgress * 3.5f) * pulse;
thickness = Tuning.SpotterBeamThickness * (1f + LockProgress * 0.9f);
}
beamObject.WorldScale = new Vector3( length, thickness, thickness );
if ( beamRenderer.IsValid() )
beamRenderer.Tint = color * power;
// The hull carries the same signal, so a Spotter you catch out of the corner of your eye
// still tells you which state it is in without following its beam back.
if ( bodyRenderer.IsValid() )
bodyRenderer.Tint = color * (1.4f + LockProgress * 2.2f);
if ( hullLight.IsValid() )
hullLight.LightColor = color * (8f + LockProgress * 16f);
// A light where the line lands, so you are lit up as well as pointed at.
if ( spotObject.IsValid() && spotLight.IsValid() )
{
spotObject.WorldPosition = beamAim + Vector3.Up * 40f;
// The pool of light where the beam lands. Cut hard: at 22x it washed out the whole
// area around your feet, which is the ground you most need to see while dodging.
spotLight.LightColor = Standby
? color * 1.2f
: color * (2.2f + LockProgress * 7f) * pulse;
spotLight.Radius = 260f + LockProgress * 300f;
}
}
/// <summary>Yellow, then orange, then red for the final second.</summary>
public Color LockColor
{
get
{
var yellow = new Color( 1f, 0.92f, 0.15f );
var orange = new Color( 1f, 0.48f, 0.05f );
var red = new Color( 1f, 0.08f, 0.04f );
if ( LockProgress < Tuning.SpotterOrangeAt )
return yellow;
if ( LockProgress < Tuning.SpotterRedAt )
{
float t = (LockProgress - Tuning.SpotterOrangeAt)
/ (Tuning.SpotterRedAt - Tuning.SpotterOrangeAt);
return Color.Lerp( yellow, orange, t );
}
float r = (LockProgress - Tuning.SpotterRedAt) / (1f - Tuning.SpotterRedAt);
return Color.Lerp( orange, red, MathF.Min( 1f, r * 2.5f ) );
}
}
// ---------------------------------------------------------------- damage
/// <summary>Sphere test for one projectile step, same shape as the other hittables.</summary>
public static bool TryHit( Vector3 start, Vector3 direction, float distance, out Spotter 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 )
return;
var progress = PlayerProgress.Local;
if ( progress.IsValid() )
{
progress.AwardDust( Tuning.SpotterDustReward );
progress.AddResonance( 3 );
progress.Data.SpottersDowned++;
}
BlastEffect.Spawn( Scene, WorldPosition, 420f, true );
Debris.Burst( Scene, WorldPosition, 220f, true );
Audio.Play( Audio.Explosion, WorldPosition, 0.9f, Audio.Vary( 1.25f ) );
Log.Info( "Spotter downed." );
GameObject.Destroy();
}
// ---------------------------------------------------------------- models
/// <summary>A saucer: a squashed octahedron, clearly not one of the angular threats.</summary>
private static Model GetBodyModel()
{
if ( bodyModel != null ) return bodyModel;
var vb = new VertexBuffer();
vb.Init( true );
Vector3[] tips = { Vector3.Up * 0.35f, Vector3.Down * 0.55f };
Vector3[] ring =
{
new( 1f, 0f, 0f ), new( 0.7f, 0.7f, 0f ), new( 0f, 1f, 0f ), new( -0.7f, 0.7f, 0f ),
new( -1f, 0f, 0f ), new( -0.7f, -0.7f, 0f ), new( 0f, -1f, 0f ), new( 0.7f, -0.7f, 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 );
bodyModel = new ModelBuilder().AddMesh( mesh ).Create();
return bodyModel;
}
}