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;
	}

}