using System;
using System.IO;
using System.Reflection;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using UnityEngine;
using KSP.UI.Screens;

// KSP 1.x plugin. This deliberately creates no vessel, part, collider, or physics object.
// The "entity" exists only as a position used by the flight-scene UI.
[KSPAddon(KSPAddon.Startup.Flight, false)]
public class EntityTracker : MonoBehaviour
{
    private sealed class EvaSpeedState
    {
        public float runSpeed;
        public float boundSpeed;
    }

    private const string ModFolderName = "Kraken's Kreations";
    private enum GroundVariant { Pilot, Scientist, Engineer, Tourist }
    private static EntityTracker instance;
    private static readonly BindingFlags ExpressionFieldFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
    private static readonly FieldInfo ExpressionPanicLevelField = typeof(kerbalExpressionSystem).GetField("panicLevel", ExpressionFieldFlags);
    private static readonly FieldInfo ExpressionFearFactorField = typeof(kerbalExpressionSystem).GetField("fearFactor", ExpressionFieldFlags);
    private static readonly FieldInfo ExpressionWheeLevelField = typeof(kerbalExpressionSystem).GetField("wheeLevel", ExpressionFieldFlags);
    private static readonly FieldInfo ExpressionTargetField = typeof(kerbalExpressionSystem).GetField("targetExpression", ExpressionFieldFlags);
    private static readonly FieldInfo ExpressionCurrentField = typeof(kerbalExpressionSystem).GetField("expression", ExpressionFieldFlags);
    private static readonly FieldInfo EvaRunSpeedField = typeof(KerbalEVA).GetField("runSpeed", ExpressionFieldFlags);
    private static readonly FieldInfo EvaBoundSpeedField = typeof(KerbalEVA).GetField("boundSpeed", ExpressionFieldFlags);
    private const float SpawnDistanceMetres = 50000f;
    private const float InterplanetarySpawnDistanceMetres = 1000000f;
    private const float ArrivalDistanceMetres = 15f;
    private const float VisualWorldSizeMetres = 6f;
    // The Eye is a billboard just like the Blackbox/Redbox, so it uses the
    // same six-metre world-space size and perspective calculation.
    private const float EyeVisualWorldSizeMetres = VisualWorldSizeMetres;
    private const float MaximumApproachSpeedMetresPerSecond = 1500f;
    private const float SlowdownDistanceMetres = 10000f;
    private const float OrbitalSpeedMultiplier = 10f;
    private const float InterplanetarySpeedMultiplier = 40f;

    private bool tracking;
    private float currentSpawnDistanceMetres = SpawnDistanceMetres;
    private bool arrived;
    private bool arrivalHandled;
    private bool quitOnArrival;
    private bool alwaysVisible = true;
    private bool tufxGlitchOnArrival = true;
    private bool randomEncounters = true;
    private bool debugTools = true;
    private float approachSpeedMetresPerSecond = 120f;
    private float blackboxSpeedMultiplier = 1f;
    private float groundEntitySpeedMultiplier = 1f;
    private float seaEntitySpeedMultiplier = 1f;
    private float eyeSpeedMultiplier = 1f;
    private float randomCheckSeconds = 45f;
    private float baseEncounterChance = 0.025f;
    // Surface encounters are deliberately frequent; their long spawn distance
    // makes them something to spot first rather than an instant ambush.
    private float groundEncounterChance = 0.25f;
    private float highEncounterMultiplier = 8f;
    private float rareEncounterMultiplier = 0.2f;
    private float randomCooldownSeconds = 1800f;
    private float groundEncounterCooldownSeconds = 600f;
    private string immuneBodies = "Laythe,Vall,Tylo,Bop";
    private string highEncounterBodies = "Eeloo";
    private string rareEncounterBodies = "Pol,Kerbin";
    private float encounterCheckTimer;
    private float encounterCooldownTimer;
    private float groundEncounterCooldownTimer;
    private float escapeDistanceGainMetres = 3000f;
    private float survivalEscapeSeconds = 180f;
    private bool survivalJolt = true;
    private double closestEncounterDistance;
    private bool survivalTimerStarted;
    private float survivalTimer;
    private int lastCountdownSecond = -1;
    private float countdownTextShakeTimer;
    private float safeMessageTimer;
    private int joltFramesRemaining;
    private float proximityJoltTimer;
    private WindowRect originalWindowRect;
    private bool hasOriginalWindowRect;
    private const float GroundSpawnMinimumMetres = 6000f;
    private const float GroundSpawnMaximumMetres = 18000f;
    private const float GroundActivationMetres = 16000f;
    private const float GroundSightHeightMetres = 35f;
    private const float VesselSightHeightMetres = 6f;
    private const float GroundIgnoreAltitudeMetres = 10f;
    private const float GroundArrivalDistanceMetres = 2f;
    private const float GroundChaseSpeedMetresPerSecond = 30f;
    private const float GroundRoamSpeedMetresPerSecond = 2.5f;
    // Visual-only entities have no colliders, so keep their pivots very close
    // to the sampled terrain rather than hovering above it.
    private const float GroundVisualClearanceMetres = 0.12f;
    private const float ScientistFollowSpeedMetresPerSecond = 9f;
    private const float ScientistFleeSpeedMetresPerSecond = 28f;
    private const float ScientistFleeDistanceMetres = 100f;
    private const float ScientistFleeSeconds = 5f;
    // The bulky nightmare is a separate surface hunter for Moho and Eve. It
    // has no patrol behavior: it waits where it appeared, then pursues.
    private const float BulkSpawnMinimumMetres = 6000f;
    private const float BulkSpawnMaximumMetres = 18000f;
    private const float BulkActivationMetres = 16000f;
    private const float BulkArrivalDistanceMetres = 8f;
    private const float BulkChaseSpeedMetresPerSecond = 42f;
    private const float BulkVisualScale = 2.2f;
    private const float WithdrawalStandSeconds = 3f;
    private const float WithdrawalFleeSeconds = 30f;
    private const float BulkEscapeDistanceMetres = 10000f;
    private const string BulkModelPath = "Kraken's Kreations/Models/nightmare_creature_1";
    private float bulkEncounterChance = 0.25f;
    private float bulkEncounterCooldownSeconds = 600f;
    private float bulkEncounterCooldownTimer;
    private GameObject bulkEntityObject;
    private BulkEntityBoneAnimation bulkEntityBoneAnimation;
    private AudioSource bulkFootstepsAudio;
    private bool bulkFootstepsLoaded;
    private CelestialBody bulkEntityBody;
    private Vector3d bulkEntityRelativePosition;
    private bool bulkEntityActive;
    private bool bulkEntityChasing;
    private bool bulkEntityStandingDown;
    private bool bulkEntityRetreating;
    private float bulkWithdrawalTimer;
    private float bulkChaseAnimationPhase;
    private string bulkEntityStatus = "NONE";
    // A separate land hunter for Laythe, built from RunBun Studios' PSX model.
    // It shares the terrain-bound, line-of-sight chase rules of the other
    // surface entities, while remaining completely independent of Bloop.
    // The Laythe land creature is intentionally disabled in the public 1.1
    // build while its model and animation are still unfinished.
    private const bool LaytheLandCreatureEnabled = false;
    private const float LaytheCreatureSpawnMinimumMetres = 6000f;
    private const float LaytheCreatureSpawnMaximumMetres = 18000f;
    private const float LaytheCreatureActivationMetres = 2500f;
    private const float LaytheCreatureArrivalDistanceMetres = 3f;
    private const float LaytheCreatureChaseSpeedMetresPerSecond = 34f;
    private const float LaytheCreatureRoamSpeedMetresPerSecond = 2.4f;
    private const float LaytheCreatureVisualScale = 0.42f;
    // This source mesh is already Y-up after its bind pose was baked during
    // conversion. Applying the normal Blender Z-up correction laid it over.
    // Total Horror is authored Z-up; rotate it into KSP's Y-up world and
    // align its forward axis with its actual direction of travel.
    // Rigged MU exports already apply Blender's Z-up to KSP conversion.  The
    // old -90 degree adjustment was for the previous baked-static export and
    // tipped the new skeleton sideways.
    private static readonly Quaternion LaytheCreatureModelAxisCorrection = Quaternion.identity;
    private const string LaytheCreatureModelPath = "Kraken's Kreations/Models/the_smile";
    private float laytheCreatureEncounterChance = 0.18f;
    private float laytheCreatureEncounterCooldownSeconds = 600f;
    private float laytheCreatureEncounterCooldownTimer;
    private GameObject laytheCreatureObject;
    private LaytheCreatureBoneAnimation laytheCreatureBoneAnimation;
    private Animation laytheCreatureAnimation;
    private AudioSource laytheCreatureFootstepsAudio;
    private AudioSource laytheCreatureScreamAudio;
    private bool laytheCreatureFootstepsLoaded;
    private bool laytheCreatureScreamLoaded;
    private CelestialBody laytheCreatureBody;
    private Vector3d laytheCreatureRelativePosition;
    private bool laytheCreatureActive;
    private bool laytheCreatureChasing;
    private float laytheCreatureAnimationPhase;
    private string laytheCreatureStatus = "NONE";
    // A common Eve-only hunter that stays below the Explodium ocean surface.
    private const float SeaSpawnMinimumMetres = 900f;
    private const float SeaSpawnMaximumMetres = 2200f;
    private const float SeaActivationMetres = 1500f;
    private const float SeaArrivalDistanceMetres = 18f;
    private const float SeaChaseSpeedMetresPerSecond = 52f;
    private const float SeaDepthMetres = 18f;
    // The model's visual centre sits lower than its logical entity point.
    // Lift only the render by this amount while retaining underwater logic.
    private const float SeaVisualElevationMetres = 16f;
    // The source asset uses unusually large authoring units; this yields a
    // roughly 20 m creature after its GLB offset is baked out.
    private const float SeaVisualScale = 0.035f;
    // The source model's long axis imports as local up in KSP. Rotate the
    // visual into a horizontal swimming pose after it faces its target.
    private static readonly Quaternion SeaModelAxisCorrection = Quaternion.Euler(90f, 0f, 0f);
    private const string SeaModelPath = "Kraken's Kreations/Models/sea_monster_shark_concept";
    private float seaEncounterChance = 0.18f;
    private float seaEncounterCooldownSeconds = 600f;
    private float seaEncounterCooldownTimer;
    private GameObject seaEntityObject;
    private AudioSource seaGrowlAudio;
    private bool seaGrowlLoaded;
    private CelestialBody seaEntityBody;
    private Vector3d seaEntityRelativePosition;
    private bool seaEntityActive;
    private bool seaEntityChasing;
    private float seaAnimationPhase;
    private string seaEntityStatus = "NONE";
    // Bloop is a colossal Laythe ocean hunter.  Its source model is already
    // hundreds of metres in authoring units, so it stays dramatically larger
    // than the Eve creature even after scaling for KSP.
    private const float BloopSpawnMinimumMetres = 3000f;
    private const float BloopSpawnMaximumMetres = 10000f;
    private const float BloopActivationMetres = 8000f;
    // Bloop's body is over 200 m long, so a normal humanoid-sized contact
    // radius makes it look as though it stops short of the vessel.
    private const float BloopArrivalDistanceMetres = 120f;
    private const float BloopCloseLungeDistanceMetres = 300f;
    private const float BloopChaseSpeedMetresPerSecond = 18f;
    private const float BloopDepthMetres = 70f;
    // Keep the rendered centre at the same depth as the logical entity.  A
    // previous +35 m lift put the huge model well above deep-diving vessels,
    // where Laythe's underwater visibility often hid it completely.
    private const float BloopVisualElevationMetres = 0f;
    private const float BloopVisualScale = 6.0f;
    private static readonly Quaternion BloopModelAxisCorrection = Quaternion.Euler(90f, 0f, 0f);
    private const string BloopModelPath = "Kraken's Kreations/Models/bloop";
    private float bloopEncounterChance = 0.14f;
    private float bloopEncounterCooldownSeconds = 900f;
    private float bloopEncounterCooldownTimer;
    private GameObject bloopEntityObject;
    private AudioSource bloopBurrowAudio;
    private bool bloopBurrowLoaded;
    private float bloopBurrowTimer;
    private bool bloopCloseCuePlayed;
    private CelestialBody bloopEntityBody;
    private Vector3d bloopEntityRelativePosition;
    private bool bloopEntityActive;
    private bool bloopEntityChasing;
    private float bloopAnimationPhase;
    private string bloopEntityStatus = "NONE";
    private const float RakeSpawnMinimumMetres = 6000f;
    private const float RakeSpawnMaximumMetres = 18000f;
    private const float RakeActivationMetres = 16000f;
    private const float RakeArrivalDistanceMetres = 8f;
    private const float RakeChaseSpeedMetresPerSecond = 58f;
    private const float RakeObservationEscapeSeconds = 20f;
    // Blender uses Z as up while KSP/Unity uses Y.  The Rake was exported with
    // its mesh several metres above its root, so use the correct axis mapping
    // and sink only the invisible root until its feet meet the terrain.
    private static readonly Quaternion RakeModelAxisCorrection = Quaternion.Euler(-90f, 0f, 0f);
    private const float RakeModelRootBelowFeetMetres = 6.56f;
    private const string RakeModelPath = "Kraken's Kreations/Models/the_rake";
    private float rakeEncounterChance = 0.06f;
    private float rakeEncounterCooldownSeconds = 1200f;
    private float rakeEncounterCooldownTimer;
    private GameObject rakeEntityObject;
    private RakeEntityAnimation rakeEntityAnimation;
    private RakeBoneAnimation rakeBoneAnimation;
    private AudioSource rakeChaseAudio;
    private bool rakeChaseSoundLoaded;
    private bool rakeChaseSoundRequested;
    private CelestialBody rakeEntityBody;
    private Vector3d rakeEntityRelativePosition;
    private bool rakeEntityActive;
    private bool rakeEntityChasing;
    private bool rakeEntityStandingDown;
    private bool rakeEntityRetreating;
    private float rakeWithdrawalTimer;
    private float rakeChaseSurvivalTimer;
    private bool rakeEntityVisible;
    private float rakeObservationTimer;
    private float rakeAnimationPhase;
    private string rakeEntityStatus = "NONE";
    private GameObject groundEntityObject;
    private GroundEntityGlitchAnimation groundEntityAnimation;
    private AudioSource groundFootstepsAudio;
    private AudioSource groundScreamAudio;
    private bool groundFootstepsLoaded;
    private bool groundScreamLoaded;
    private CelestialBody groundEntityBody;
    private Vector3d groundEntityRelativePosition;
    private bool groundEntityActive;
    private bool groundEntityChasing;
    private bool groundEntityFleeing;
    private float groundEntityFleeTimer;
    private bool groundEntityStandingDown;
    private bool groundEntityRetreating;
    private float groundEntityWithdrawalTimer;
    private float groundEntityChaseSurvivalTimer;
    private GroundVariant groundVariant;
    private Vector3d groundRoamDirection;
    private float groundRoamDirectionTimer;
    private bool groundEntityDebugRadar = true;
    private bool debugDisableWaterFog;
    private bool debugImmortality;
    private bool fogStateCaptured;
    private bool fogEnabledBeforeDebug;
    private string groundEntityStatus = "NONE";
    private bool unknownMapContacts = true;
    private readonly List<UnknownMapContact> mapContacts = new List<UnknownMapContact>();
    private ApplicationLauncherButton toolbarButton;
    private Texture2D toolbarIcon;
    private bool toolbarVisible;
    private bool trackerVisible = true;
    private Rect toolbarPanel = new Rect(18, 285, 250, 320);
    private Vector2 toolbarScrollPosition;
    private bool tufxGlitchStarted;
    private int glitchFramesRemaining;
    private Vector3d entityPosition;
    private CelestialBody entityReferenceBody;
    private Vector3d entityBodyRelativePosition;
    private Vector3d lastTrackingVesselPosition;
    private bool hasLastTrackingVesselPosition;
    private Vector3d orbitalTetherOffset;
    private bool orbitalTetherActive;
    private GameObject entitySoundObject;
    private AudioSource distantAudio;
    private AudioSource nearbyAudio;
    private AudioClip normalDistantClip;
    private AudioClip interplanetaryDistantClip;
    private AudioClip normalNearbyClip;
    private AudioClip interplanetaryNearbyClip;
    private Texture2D visualTexture;
    private Texture2D interplanetaryVisualTexture;
    private Texture2D eyeTexture;
    private Texture2D eyeBlinkTexture;
    private GameObject eyeBillboardObject;
    private Material eyeBillboardMaterial;
    private CelestialBody eyeLastObservedBody;
    private Vector3d eyeEntityPosition;
    private Vector3d eyeWatchDirection;
    private CelestialBody eyeChaseReferenceBody;
    private Vector3d eyeChaseRelativePosition;
    private Vector3d eyeChaseBaseVelocity;
    private bool eyeEntityActive;
    private bool eyeEntityChasing;
    private float eyeChaseTimer;
    private float eyeWarningTextTimer;
    private AudioSource eyeWarningAudio;
    private AudioSource eyeChaseAudio;
    private AudioSource randomHorrorAudio;
    private bool randomHorrorLoaded;
    private float randomHorrorCheckTimer;
    private GameObject ambientSoundObject;
    private AudioSource kerbinAmbientAudio;
    private bool kerbinAmbientLoaded;
    private float kerbinAmbientCheckTimer;
    private AudioSource laytheAmbientAudio;
    private bool laytheAmbientLoaded;
    private float laytheAmbientCheckTimer;
    private AudioSource eveAmbientAudio;
    private bool eveAmbientLoaded;
    private float eveAmbientCheckTimer;
    private readonly Dictionary<AudioSource, float> mutedMusicSources = new Dictionary<AudioSource, float>();
    private bool eyeWarningSoundLoaded;
    private bool eyeChaseSoundLoaded;
    private bool distantSoundLoaded;
    private bool nearbySoundLoaded;
    private bool blackboxScienceAwarded;
    private bool groundScienceAwarded;
    private bool touristScienceAwarded;
    // Courage is KSP's per-Kerbal bravery trait.  Keep each original value so
    // terror is a chase effect, not a permanent save-game stat change.
    private readonly Dictionary<ProtoCrewMember, float> courageBeforeTerror = new Dictionary<ProtoCrewMember, float>();
    private readonly Dictionary<KerbalEVA, EvaSpeedState> evaSpeedBeforePanic = new Dictionary<KerbalEVA, EvaSpeedState>();
    private float evaPanicSpeedCheckTimer;
    private bool crewTerrorActive;
    private bool bulkEvaScienceAwarded;
    private bool bulkSeismometerScienceAwarded;
    private bool seaScienceAwarded;
    private Rect panel = new Rect(18, 120, 275, 145);
    private int windowId;

    public void Awake()
    {
        // This addon is created whenever KSP enters a flight scene.  The
        // previous instance persists across scenes, so reject the new copy or
        // every launch would register another toolbar button.
        if (instance != null && instance != this)
        {
            Destroy(gameObject);
            return;
        }
        instance = this;
        windowId = Guid.NewGuid().GetHashCode();
        LoadSettings();
        DontDestroyOnLoad(gameObject);
        GameEvents.onGUIApplicationLauncherReady.Add(AddToolbarButton);
        GameEvents.onGUIApplicationLauncherDestroyed.Add(RemoveToolbarButton);
        GameEvents.onGameStateLoad.Add(OnGameStateLoaded);
        GameEvents.OnScienceRecieved.Add(OnScienceReceived);
        GameEvents.OnExperimentDeployed.Add(OnExperimentDeployed);
    }

    public void Start()
    {
        CreateWorldSoundSource();
        CreateUnknownMapContacts();
        if (ApplicationLauncher.Ready)
            AddToolbarButton();
        StartCoroutine(LoadEntitySound("DistantA120.mp3.mpeg", true, false));
        StartCoroutine(LoadEntitySound("orin-ayo-made-with-Voicemod.mp3", true, true));
        StartCoroutine(LoadEntitySound("NearbyA120.mp3.mpeg", false, false));
        StartCoroutine(LoadEntitySound("radio-glitch-sound-(loud)-made-with-Voicemod.mp3", false, true));
        StartCoroutine(LoadVisual());
        StartCoroutine(LoadInterplanetaryVisual());
        StartCoroutine(LoadEyeVisual("eye.png", false));
        StartCoroutine(LoadEyeVisual("eye blink.png", true));
        StartCoroutine(LoadEyeSound("alex_jauk-horrifying-entity-scream-216936.mp3", false));
        StartCoroutine(LoadEyeSound("eye-chase.mp3", true));
        StartCoroutine(LoadRandomHorrorSound());
        StartCoroutine(LoadKerbinAmbientSound());
        StartCoroutine(LoadLaytheAmbientSound());
        StartCoroutine(LoadEveAmbientSound());
    }

    public void Update()
    {
        UpdateWindowJolt();
        UpdateDebugFogOverride();
        countdownTextShakeTimer = Mathf.Max(0f, countdownTextShakeTimer - Time.unscaledDeltaTime);
        safeMessageTimer = Mathf.Max(0f, safeMessageTimer - Time.unscaledDeltaTime);
        eyeWarningTextTimer = Mathf.Max(0f, eyeWarningTextTimer - Time.unscaledDeltaTime);
        // Returning to the Space Center (or any non-flight scene) ends the encounter.
        // It will not resume from its former relative position on the next launch.
        if (tracking && !HighLogic.LoadedSceneIsFlight)
            EvadeEncounter();
        if (groundEntityActive && !HighLogic.LoadedSceneIsFlight)
            DespawnGroundEntity();
        if (bulkEntityActive && !HighLogic.LoadedSceneIsFlight)
            DespawnBulkEntity();
        if (laytheCreatureActive && !HighLogic.LoadedSceneIsFlight)
            DespawnLaytheCreature();
        if (seaEntityActive && !HighLogic.LoadedSceneIsFlight)
            DespawnSeaEntity();
        if (bloopEntityActive && !HighLogic.LoadedSceneIsFlight)
            DespawnBloopEntity();
        if (rakeEntityActive && !HighLogic.LoadedSceneIsFlight)
            DespawnRakeEntity();
        if (eyeEntityActive && !HighLogic.LoadedSceneIsFlight)
            DespawnEyeEntity();

        // Do not trigger while typing in another KSP UI control.
        if (Input.GetKeyDown(KeyCode.P) && !IsTypingInControl())
        {
            ToggleTracking();
        }

        UpdateRandomEncounters();
        UpdateGroundEntity();
        UpdateBulkEntity();
        UpdateLaytheCreature();
        UpdateSeaEntity();
        UpdateBloopEntity();
        UpdateRakeEntity();
        UpdateEyeEntity();
        UpdateRandomHorrorMusicEvent();
        UpdateKerbinNightAmbience();
        UpdateLaytheNightAmbience();
        UpdateEveNightAmbience();
        UpdateProximityScreenShake();
        UpdateCrewTerror();
        UpdateEvaPanicSpeed();

        if (!tracking || arrived || FlightGlobals.ActiveVessel == null)
        {
            UpdateSound();
            return;
        }

        if (Time.timeScale <= 0f)
        {
            UpdateSound();
            return;
        }

        Vessel vessel = FlightGlobals.ActiveVessel;
        RestoreEntityWorldPosition();
        Vector3d vesselPosition = vessel.GetWorldPos3D();

        // In space the Blackbox/Redbox has no independent world position: it
        // is tethered directly to the vessel's current position. Only the
        // remaining offset is simulated, so no burn or orbital velocity can
        // leave it behind like a normal physical object.
        if (IsOrbital(vessel))
        {
            if (!orbitalTetherActive)
            {
                orbitalTetherOffset = entityPosition - vesselPosition;
                orbitalTetherActive = true;
            }
            entityPosition = vesselPosition + orbitalTetherOffset;
        }
        else
        {
            orbitalTetherActive = false;
        }
        lastTrackingVesselPosition = vesselPosition;
        hasLastTrackingVesselPosition = true;
        Vector3d offset = vesselPosition - entityPosition;
        double distance = offset.magnitude;

        if (distance < closestEncounterDistance)
            closestEncounterDistance = distance;
        else if (distance >= closestEncounterDistance + escapeDistanceGainMetres)
        {
            EndEncounter();
            return;
        }

        if (!survivalTimerStarted && distance <= SlowdownDistanceMetres)
            survivalTimerStarted = true;
        if (survivalTimerStarted)
        {
            survivalTimer += TimeWarp.deltaTime;
            int remaining = Mathf.CeilToInt(survivalEscapeSeconds - survivalTimer);
            if (remaining > 0 && remaining <= 10 && remaining != lastCountdownSecond)
            {
                lastCountdownSecond = remaining;
                countdownTextShakeTimer = 0.22f;
                TriggerWindowJolt();
            }
            if (survivalTimer >= survivalEscapeSeconds)
            {
                safeMessageTimer = 10f;
                EndEncounter();
                return;
            }
        }

        // Let the optional TUFX profile render for three frames immediately before contact.
        if (!tufxGlitchStarted && distance <= ArrivalDistanceMetres + approachSpeedMetresPerSecond * TimeWarp.deltaTime * 3d)
        {
            tufxGlitchStarted = true;
            glitchFramesRemaining = 3;
            if (tufxGlitchOnArrival)
                TryActivateTufxGlitch();
        }

        if (glitchFramesRemaining > 0)
        {
            glitchFramesRemaining--;
            if (glitchFramesRemaining > 0)
                return;

            // The contact is forced after the brief effect so the TUFX profile never
            // remains active during a normal chase frame.
            entityPosition = vesselPosition;
            arrived = true;
            UpdateSound();
            HandleArrival();
            return;
        }

        if (distance <= ArrivalDistanceMetres)
        {
            entityPosition = vesselPosition;
            arrived = true;
            UpdateSound();
            HandleArrival();
            return;
        }

        // Pursuit uses KSP simulation time, so it scales with time warp.
        double step = GetApproachSpeed(distance) * TimeWarp.deltaTime;
        // It aims at the vessel, but advances from its own body-relative coordinate.
        // That coordinate is never attached to or reset from the vessel.
        entityPosition += offset.normalized * Math.Min(step, distance - ArrivalDistanceMetres);
        if (orbitalTetherActive)
            orbitalTetherOffset = entityPosition - vesselPosition;
        SaveEntityWorldPosition();
        UpdateSound();
    }

    private float GetApproachSpeed(double distance)
    {
        // 1.5 km/s while distant, easing down to the configured normal speed
        // close to the vessel. Orbital motion is handled separately above, so
        // no special orbit multiplier or speed boost is needed.
        float blend = Mathf.InverseLerp(SlowdownDistanceMetres, currentSpawnDistanceMetres, (float)distance);
        float speed = Mathf.Lerp(approachSpeedMetresPerSecond, MaximumApproachSpeedMetresPerSecond, blend);
        // Redbox keeps its original interplanetary menace: it shares the
        // vessel tether but retains the former 40x pursuit speed.
        speed *= Mathf.Clamp(blackboxSpeedMultiplier, 0.05f, 20f);
        return IsInterplanetaryEncounter() ? speed * InterplanetarySpeedMultiplier : speed;
    }

    private static float GetPursuitSpeedMultiplier(Vessel vessel)
    {
        if (vessel == null)
            return 1f;
        if (IsInterplanetaryOrbit(vessel))
            return InterplanetarySpeedMultiplier;
        return IsOrbital(vessel) ? OrbitalSpeedMultiplier : 1f;
    }

    private static bool IsOrbital(Vessel vessel)
    {
        return vessel != null && (vessel.situation == Vessel.Situations.ORBITING ||
                                  vessel.situation == Vessel.Situations.ESCAPING ||
                                  vessel.situation == Vessel.Situations.SUB_ORBITAL);
    }

    private static bool IsInterplanetaryOrbit(Vessel vessel)
    {
        return IsOrbital(vessel) && vessel.mainBody != null && Planetarium.fetch != null &&
               vessel.mainBody == Planetarium.fetch.Sun;
    }

    private bool IsInterplanetaryEncounter()
    {
        return tracking && currentSpawnDistanceMetres >= InterplanetarySpawnDistanceMetres;
    }

    private static float GetCappedSpawnCheckDelta()
    {
        // TimeWarp.deltaTime tracks KSP simulation time. Limit the shared
        // encounter-check timer to ten times real-time at high warp.
        return Mathf.Min(TimeWarp.deltaTime, Time.unscaledDeltaTime * 10f);
    }

    private void UpdateProximityScreenShake()
    {
        if (Time.timeScale <= 0f || !HighLogic.LoadedSceneIsFlight)
            return;

        float strength = 0f;
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel != null && groundEntityActive && groundEntityChasing &&
            !groundEntityStandingDown && !groundEntityRetreating && groundEntityBody == vessel.mainBody)
        {
            Vector3d mimicPosition = groundEntityBody.position + groundEntityRelativePosition;
            // The Mimic's shake is a close-range warning rather than a cue
            // that starts across its entire 16 km sight range.
            strength = Mathf.InverseLerp(350f, GroundArrivalDistanceMetres, (float)(mimicPosition - vessel.GetWorldPos3D()).magnitude);
        }

        if (tracking && IsInterplanetaryEncounter() && vessel != null)
        {
            double redboxDistance = (entityPosition - vessel.GetWorldPos3D()).magnitude;
            // Redbox's close signal begins at 50 km; its shake grows steadily
            // from there until contact.
            strength = Mathf.Max(strength, Mathf.InverseLerp(50000f, ArrivalDistanceMetres, (float)redboxDistance));
        }

        if (strength <= 0f)
        {
            proximityJoltTimer = 0f;
            return;
        }

        proximityJoltTimer -= Time.unscaledDeltaTime;
        if (proximityJoltTimer > 0f || joltFramesRemaining > 0)
            return;

        // Far away this is an occasional tremor; very close it becomes an
        // insistent shake. Reuse the existing safe window-jolt implementation.
        proximityJoltTimer = Mathf.Lerp(0.7f, 0.2f, strength);
        TriggerWindowJolt();
    }

    private void RestoreEntityWorldPosition()
    {
        if (entityReferenceBody != null)
            entityPosition = entityReferenceBody.position + entityBodyRelativePosition;
    }

    private void SaveEntityWorldPosition()
    {
        if (entityReferenceBody != null)
            entityBodyRelativePosition = entityPosition - entityReferenceBody.position;
    }

    public void OnGUI()
    {
        if (HighLogic.LoadedSceneIsFlight == false)
            return;

        if (unknownMapContacts && MapView.MapIsEnabled)
            DrawUnknownMapContacts();

        if (toolbarVisible)
            toolbarPanel = GUI.Window(windowId + 1, toolbarPanel, DrawToolbarPanel, "ENTITY TRACKER");

        if (groundEntityDebugRadar && groundEntityActive)
            DrawGroundEntityRadar();
        if (groundEntityDebugRadar && bulkEntityActive)
            DrawBulkEntityRadar();
        if (groundEntityDebugRadar && laytheCreatureActive)
            DrawLaytheCreatureRadar();
        if (groundEntityDebugRadar && seaEntityActive)
            DrawSeaEntityRadar();
        if (groundEntityDebugRadar && bloopEntityActive)
            DrawBloopEntityRadar();
        if (groundEntityDebugRadar)
            DrawSpawnDebugPanel();

        DrawSurvivalCountdown();

        if (eyeEntityActive)
        {
            DrawEyeWarning();
        }

        if (!tracking)
            return;

        DrawVisualOverlay();
        // The exact range/bearing tracker is a testing aid. Encounters remain
        // fully active without it, but it is only shown when Debug Tools is on.
        if (debugTools && trackerVisible)
            panel = GUI.Window(windowId, panel, DrawTracker, "ENTITY TRACKER");
    }

    private void DrawSurvivalCountdown()
    {
        GUIStyle style = new GUIStyle(GUI.skin.label);
        style.fontSize = Mathf.RoundToInt(Mathf.Min(Screen.width, Screen.height) * 0.35f);
        style.fontStyle = FontStyle.Bold;
        style.alignment = TextAnchor.MiddleCenter;
        if (survivalTimerStarted)
        {
            int remaining = Mathf.CeilToInt(survivalEscapeSeconds - survivalTimer);
            if (remaining > 0 && remaining <= 10)
            {
                Vector2 shake = countdownTextShakeTimer > 0f
                    ? UnityEngine.Random.insideUnitCircle * 28f
                    : Vector2.zero;
                style.normal.textColor = new Color(1f, 0f, 0f, 0.55f);
                GUI.Label(new Rect(shake.x, shake.y, Screen.width, Screen.height), remaining.ToString(), style);
            }
        }
        if (safeMessageTimer > 0f)
        {
            float alpha = Mathf.Clamp01(safeMessageTimer / 10f) * 0.8f;
            style.fontSize = Mathf.RoundToInt(Mathf.Min(Screen.width, Screen.height) * 0.24f);
            style.normal.textColor = new Color(0.25f, 1f, 0.35f, alpha);
            GUI.Label(new Rect(0f, 0f, Screen.width, Screen.height), "SAFE", style);
        }
    }

    private void DrawToolbarPanel(int id)
    {
        toolbarScrollPosition = GUILayout.BeginScrollView(toolbarScrollPosition, false, true);
        GUILayout.Label(tracking ? "SIGNAL ACTIVE" : "NO ACTIVE SIGNAL");
        if (debugTools)
        {
            Vessel vessel = FlightGlobals.ActiveVessel;
            if (GUILayout.Button(tracking ? (trackerVisible ? "Hide tracker" : "Show tracker") : "Force encounter"))
                ToggleTracking();
            // Only show a spawner where its entity can actually exist. This
            // keeps the debug panel from offering buttons that will just fail
            // with a body/terrain warning when pressed.
            bool onKerbinGround = vessel != null && vessel.mainBody != null &&
                                  string.Equals(vessel.mainBody.bodyName, "Kerbin", StringComparison.OrdinalIgnoreCase) &&
                                  !vessel.Splashed && vessel.heightFromTerrain < GroundIgnoreAltitudeMetres;
            bool onBulkGround = vessel != null && vessel.mainBody != null &&
                                IsBulkEntityBody(vessel.mainBody) && !vessel.Splashed &&
                                vessel.heightFromTerrain < GroundIgnoreAltitudeMetres;
            bool atEveSea = CanSeaEntityTarget(vessel);
            bool atLaythe = vessel != null && vessel.mainBody != null &&
                            string.Equals(vessel.mainBody.bodyName, "Laythe", StringComparison.OrdinalIgnoreCase);
            bool onLaytheGround = atLaythe && vessel != null && !vessel.Splashed &&
                                  vessel.heightFromTerrain < GroundIgnoreAltitudeMetres;
            bool atJoolOrBop = vessel != null && vessel.mainBody != null &&
                               (string.Equals(vessel.mainBody.bodyName, "Jool", StringComparison.OrdinalIgnoreCase) ||
                                string.Equals(vessel.mainBody.bodyName, "Bop", StringComparison.OrdinalIgnoreCase));

            if (onKerbinGround && GUILayout.Button("Spawn ground entity"))
                SpawnGroundEntity();
            if (onKerbinGround && GUILayout.Button("Spawn ground entity 200 m ahead"))
                SpawnGroundEntityForTesting(vessel);
            if (onBulkGround && GUILayout.Button("Spawn Moho/Eve entity"))
                SpawnBulkEntity();
            if (onBulkGround && GUILayout.Button("Spawn Moho/Eve entity 200 m ahead"))
                SpawnBulkEntityForTesting(vessel);
            if (atEveSea && GUILayout.Button("Spawn Eve sea entity"))
                SpawnSeaEntity();
            if (atEveSea && GUILayout.Button("Spawn Eve sea entity 200 m ahead"))
                SpawnSeaEntityForTesting(vessel);
            if (atLaythe && GUILayout.Button("Spawn Laythe Bloop"))
                SpawnBloopEntity(true);
            if (atLaythe && GUILayout.Button("Spawn Laythe Bloop 200 m ahead"))
                SpawnBloopEntityForTesting(vessel);
            if (LaytheLandCreatureEnabled && onLaytheGround && GUILayout.Button("Spawn Laythe land creature"))
                SpawnLaytheCreature();
            if (LaytheLandCreatureEnabled && onLaytheGround && GUILayout.Button("Spawn Laythe land creature 200 m ahead"))
                SpawnLaytheCreatureForTesting(vessel);
            if (atJoolOrBop && GUILayout.Button("Spawn Jool Eye"))
                ForceSpawnEyeEntity();
            if (atJoolOrBop && GUILayout.Button("Spawn Jool Eye 200 m ahead"))
                SpawnEyeEntityForTesting(vessel);
            groundEntityDebugRadar = GUILayout.Toggle(groundEntityDebugRadar, "Debug: entity radar / locator");
            bool requestedFogDisable = GUILayout.Toggle(debugDisableWaterFog, "Debug: disable water fog");
            if (requestedFogDisable != debugDisableWaterFog)
                SetDebugWaterFogDisabled(requestedFogDisable);
            debugImmortality = GUILayout.Toggle(debugImmortality, "Debug: immortality");
            if (GUILayout.Button("Debug: teleport to active monster"))
                TeleportToActiveMonster();
        }
        GUILayout.Label("GROUND: " + groundEntityStatus);
        GUILayout.Label("MOHO/EVE: " + bulkEntityStatus);
        GUILayout.Label("EVE OCEAN: " + seaEntityStatus);
        GUILayout.Label("LAYTHE BLOOP: " + bloopEntityStatus);
        if (LaytheLandCreatureEnabled)
            GUILayout.Label("LAYTHE LAND: " + laytheCreatureStatus);
        GUILayout.Label("JOOL EYE: " + (eyeEntityActive ? (eyeEntityChasing ? "CHASING" : "WATCHING") : "NONE"));
        if (GUILayout.Button("Sea monster model credit: requital_0111"))
            Application.OpenURL("https://sketchfab.com/requital_0111");
        if (GUILayout.Button("Bloop model credit: titaniumammas69"))
            Application.OpenURL("https://sketchfab.com/titaniumammas69");
        GUILayout.Space(4);
        GUILayout.Label("Return to Space Center to evade.");
        GUILayout.EndScrollView();
        GUI.DragWindow();
    }

    private void DrawGroundEntityRadar()
    {
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || groundEntityBody == null)
            return;

        Vector3d entityPosition = groundEntityBody.position + groundEntityRelativePosition;
        Vector3 localOffset = vessel.transform.InverseTransformDirection((Vector3)(entityPosition - vessel.GetWorldPos3D()));
        double distance = (entityPosition - vessel.GetWorldPos3D()).magnitude;

        const float size = 190f;
        const float radius = 72f;
        Rect radar = new Rect(Screen.width - size - 18f, Screen.height - size - 18f, size, size);
        GUI.Box(radar, "GROUND ENTITY DEBUG RADAR");

        Vector2 center = new Vector2(radar.x + size * 0.5f, radar.y + 106f);
        GUI.Label(new Rect(center.x - 8f, center.y - 8f, 16f, 16f), "+");
        float horizontalDistance = new Vector2(localOffset.x, localOffset.z).magnitude;
        float scale = Mathf.Min(radius, horizontalDistance * radius / GroundSpawnMaximumMetres);
        Vector2 direction = horizontalDistance > 0.01f
            ? new Vector2(localOffset.x, localOffset.z).normalized
            : Vector2.zero;
        // Positive Z is the vessel's forward direction, matching a simple top-down radar.
        Vector2 blip = center + new Vector2(direction.x * scale, -direction.y * scale);
        GUIStyle dot = new GUIStyle(GUI.skin.label);
        dot.fontSize = 22;
        dot.fontStyle = FontStyle.Bold;
        dot.normal.textColor = Color.magenta;
        dot.alignment = TextAnchor.MiddleCenter;
        GUI.Label(new Rect(blip.x - 12f, blip.y - 12f, 24f, 24f), "●", dot);

        GUIStyle label = new GUIStyle(GUI.skin.label);
        label.fontSize = 11;
        label.normal.textColor = Color.white;
        label.alignment = TextAnchor.MiddleCenter;
        GUI.Label(new Rect(radar.x + 8f, radar.y + 145f, size - 16f, 18f), "RANGE " + FormatDistance(distance), label);
        GUI.Label(new Rect(radar.x + 8f, radar.y + 163f, size - 16f, 18f), groundEntityChasing ? "STATE: CHASING" : "STATE: DORMANT", label);
    }

    private void DrawBulkEntityRadar()
    {
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || bulkEntityBody == null)
            return;

        Vector3d entityPosition = bulkEntityBody.position + bulkEntityRelativePosition;
        Vector3 localOffset = vessel.transform.InverseTransformDirection((Vector3)(entityPosition - vessel.GetWorldPos3D()));
        double distance = (entityPosition - vessel.GetWorldPos3D()).magnitude;

        const float size = 190f;
        const float radius = 72f;
        Rect radar = new Rect(Screen.width - size - 18f, Screen.height - size - 18f, size, size);
        GUI.Box(radar, "MOHO/EVE ENTITY DEBUG RADAR");

        Vector2 center = new Vector2(radar.x + size * 0.5f, radar.y + 106f);
        GUI.Label(new Rect(center.x - 8f, center.y - 8f, 16f, 16f), "+");
        float horizontalDistance = new Vector2(localOffset.x, localOffset.z).magnitude;
        float scale = Mathf.Min(radius, horizontalDistance * radius / BulkSpawnMaximumMetres);
        Vector2 direction = horizontalDistance > 0.01f
            ? new Vector2(localOffset.x, localOffset.z).normalized
            : Vector2.zero;
        Vector2 blip = center + new Vector2(direction.x * scale, -direction.y * scale);
        GUIStyle dot = new GUIStyle(GUI.skin.label);
        dot.fontSize = 22;
        dot.fontStyle = FontStyle.Bold;
        dot.normal.textColor = Color.red;
        dot.alignment = TextAnchor.MiddleCenter;
        GUI.Label(new Rect(blip.x - 12f, blip.y - 12f, 24f, 24f), "â—", dot);

        GUIStyle label = new GUIStyle(GUI.skin.label);
        label.fontSize = 11;
        label.normal.textColor = Color.white;
        label.alignment = TextAnchor.MiddleCenter;
        GUI.Label(new Rect(radar.x + 8f, radar.y + 145f, size - 16f, 18f), "RANGE " + FormatDistance(distance), label);
        GUI.Label(new Rect(radar.x + 8f, radar.y + 163f, size - 16f, 18f), bulkEntityChasing ? "STATE: CHASING" : "STATE: DORMANT", label);
    }

    private void DrawLaytheCreatureRadar()
    {
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || laytheCreatureBody == null)
            return;
        Vector3d entityPosition = laytheCreatureBody.position + laytheCreatureRelativePosition;
        Vector3 localOffset = vessel.transform.InverseTransformDirection((Vector3)(entityPosition - vessel.GetWorldPos3D()));
        double distance = (entityPosition - vessel.GetWorldPos3D()).magnitude;
        const float size = 190f;
        const float radius = 72f;
        Rect radar = new Rect(Screen.width - size - 18f, Screen.height - size - 18f, size, size);
        GUI.Box(radar, "LAYTHE LAND CREATURE RADAR");
        Vector2 center = new Vector2(radar.x + size * 0.5f, radar.y + 106f);
        GUI.Label(new Rect(center.x - 8f, center.y - 8f, 16f, 16f), "+");
        float horizontalDistance = new Vector2(localOffset.x, localOffset.z).magnitude;
        float scale = Mathf.Min(radius, horizontalDistance * radius / LaytheCreatureSpawnMaximumMetres);
        Vector2 direction = horizontalDistance > 0.01f ? new Vector2(localOffset.x, localOffset.z).normalized : Vector2.zero;
        Vector2 blip = center + new Vector2(direction.x * scale, -direction.y * scale);
        GUIStyle dot = new GUIStyle(GUI.skin.label);
        dot.fontSize = 22; dot.fontStyle = FontStyle.Bold; dot.normal.textColor = Color.yellow; dot.alignment = TextAnchor.MiddleCenter;
        GUI.Label(new Rect(blip.x - 12f, blip.y - 12f, 24f, 24f), "*", dot);
        GUIStyle label = new GUIStyle(GUI.skin.label);
        label.fontSize = 11; label.normal.textColor = Color.white; label.alignment = TextAnchor.MiddleCenter;
        GUI.Label(new Rect(radar.x + 8f, radar.y + 145f, size - 16f, 18f), "RANGE " + FormatDistance(distance), label);
        GUI.Label(new Rect(radar.x + 8f, radar.y + 163f, size - 16f, 18f), laytheCreatureChasing ? "STATE: CHASING" : "STATE: DORMANT", label);
    }

    private void DrawSeaEntityRadar()
    {
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || seaEntityBody == null)
            return;
        Vector3d entityPosition = seaEntityBody.position + seaEntityRelativePosition;
        Vector3 localOffset = vessel.transform.InverseTransformDirection((Vector3)(entityPosition - vessel.GetWorldPos3D()));
        double distance = (entityPosition - vessel.GetWorldPos3D()).magnitude;
        const float size = 190f;
        const float radius = 72f;
        Rect radar = new Rect(Screen.width - size - 18f, Screen.height - size - 18f, size, size);
        GUI.Box(radar, "EVE OCEAN ENTITY RADAR");
        Vector2 center = new Vector2(radar.x + size * 0.5f, radar.y + 106f);
        GUI.Label(new Rect(center.x - 8f, center.y - 8f, 16f, 16f), "+");
        float horizontalDistance = new Vector2(localOffset.x, localOffset.z).magnitude;
        float scale = Mathf.Min(radius, horizontalDistance * radius / SeaSpawnMaximumMetres);
        Vector2 direction = horizontalDistance > 0.01f ? new Vector2(localOffset.x, localOffset.z).normalized : Vector2.zero;
        Vector2 blip = center + new Vector2(direction.x * scale, -direction.y * scale);
        GUIStyle dot = new GUIStyle(GUI.skin.label);
        dot.fontSize = 22;
        dot.fontStyle = FontStyle.Bold;
        dot.normal.textColor = new Color(0.15f, 0.8f, 0.9f);
        dot.alignment = TextAnchor.MiddleCenter;
        GUI.Label(new Rect(blip.x - 12f, blip.y - 12f, 24f, 24f), "â—", dot);
        GUIStyle label = new GUIStyle(GUI.skin.label);
        label.fontSize = 11;
        label.normal.textColor = Color.white;
        label.alignment = TextAnchor.MiddleCenter;
        GUI.Label(new Rect(radar.x + 8f, radar.y + 145f, size - 16f, 18f), "RANGE " + FormatDistance(distance), label);
        GUI.Label(new Rect(radar.x + 8f, radar.y + 163f, size - 16f, 18f), seaEntityChasing ? "STATE: CHASING" : "STATE: DORMANT", label);
    }

    private void DrawBloopEntityRadar()
    {
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || bloopEntityBody == null)
            return;
        Vector3d entityPosition = bloopEntityBody.position + bloopEntityRelativePosition;
        Vector3 localOffset = vessel.transform.InverseTransformDirection((Vector3)(entityPosition - vessel.GetWorldPos3D()));
        double distance = (entityPosition - vessel.GetWorldPos3D()).magnitude;
        const float size = 190f;
        const float radius = 72f;
        Rect radar = new Rect(Screen.width - size - 18f, Screen.height - size - 18f, size, size);
        GUI.Box(radar, "LAYTHE BLOOP RADAR");
        Vector2 center = new Vector2(radar.x + size * 0.5f, radar.y + 106f);
        GUI.Label(new Rect(center.x - 8f, center.y - 8f, 16f, 16f), "+");
        float horizontalDistance = new Vector2(localOffset.x, localOffset.z).magnitude;
        float scale = Mathf.Min(radius, horizontalDistance * radius / BloopSpawnMaximumMetres);
        Vector2 direction = horizontalDistance > 0.01f ? new Vector2(localOffset.x, localOffset.z).normalized : Vector2.zero;
        Vector2 blip = center + new Vector2(direction.x * scale, -direction.y * scale);
        GUIStyle dot = new GUIStyle(GUI.skin.label);
        dot.fontSize = 22;
        dot.fontStyle = FontStyle.Bold;
        dot.normal.textColor = new Color(0.1f, 0.45f, 1f);
        dot.alignment = TextAnchor.MiddleCenter;
        GUI.Label(new Rect(blip.x - 12f, blip.y - 12f, 24f, 24f), "â—", dot);
        GUIStyle label = new GUIStyle(GUI.skin.label);
        label.fontSize = 11;
        label.normal.textColor = Color.white;
        label.alignment = TextAnchor.MiddleCenter;
        GUI.Label(new Rect(radar.x + 8f, radar.y + 145f, size - 16f, 18f), "RANGE " + FormatDistance(distance), label);
        GUI.Label(new Rect(radar.x + 8f, radar.y + 163f, size - 16f, 18f), bloopEntityChasing ? "STATE: CHASING" : "STATE: DORMANT", label);
    }

    private void DrawRakeEntityRadar()
    {
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || rakeEntityBody == null)
            return;
        Vector3d entityPosition = rakeEntityBody.position + rakeEntityRelativePosition;
        Vector3 localOffset = vessel.transform.InverseTransformDirection((Vector3)(entityPosition - vessel.GetWorldPos3D()));
        double distance = (entityPosition - vessel.GetWorldPos3D()).magnitude;
        const float size = 190f;
        const float radius = 72f;
        Rect radar = new Rect(Screen.width - size - 18f, Screen.height - size - 18f, size, size);
        GUI.Box(radar, "RAKE DEBUG RADAR");
        Vector2 center = new Vector2(radar.x + size * 0.5f, radar.y + 106f);
        GUI.Label(new Rect(center.x - 8f, center.y - 8f, 16f, 16f), "+");
        float horizontalDistance = new Vector2(localOffset.x, localOffset.z).magnitude;
        float scale = Mathf.Min(radius, horizontalDistance * radius / RakeSpawnMaximumMetres);
        Vector2 direction = horizontalDistance > 0.01f ? new Vector2(localOffset.x, localOffset.z).normalized : Vector2.zero;
        Vector2 blip = center + new Vector2(direction.x * scale, -direction.y * scale);
        GUIStyle dot = new GUIStyle(GUI.skin.label);
        dot.fontSize = 22;
        dot.fontStyle = FontStyle.Bold;
        dot.normal.textColor = new Color(0.8f, 0.8f, 0.8f);
        dot.alignment = TextAnchor.MiddleCenter;
        GUI.Label(new Rect(blip.x - 12f, blip.y - 12f, 24f, 24f), "â—", dot);
        GUIStyle label = new GUIStyle(GUI.skin.label);
        label.fontSize = 11;
        label.normal.textColor = Color.white;
        label.alignment = TextAnchor.MiddleCenter;
        GUI.Label(new Rect(radar.x + 8f, radar.y + 145f, size - 16f, 18f), "RANGE " + FormatDistance(distance), label);
        GUI.Label(new Rect(radar.x + 8f, radar.y + 163f, size - 16f, 18f), rakeEntityChasing ? "STATE: CHASING" : "STATE: WATCHING", label);
    }

    private void DrawSpawnDebugPanel()
    {
        const float width = 260f;
        Rect panelRect = new Rect(18f, Screen.height - 198f, width, 180f);
        GUI.Box(panelRect, "ENTITY SPAWN DEBUG");
        Vessel vessel = FlightGlobals.ActiveVessel;
        float nextCheck = Mathf.Max(0f, randomCheckSeconds - encounterCheckTimer);
        GUI.Label(new Rect(panelRect.x + 10f, panelRect.y + 25f, width - 20f, 18f), "NEXT SHARED CHECK: " + Mathf.CeilToInt(nextCheck) + " s");
        GUI.Label(new Rect(panelRect.x + 10f, panelRect.y + 43f, width - 20f, 18f), "BLACK BOX COOLDOWN: " + Mathf.CeilToInt(encounterCooldownTimer) + " s");
        GUI.Label(new Rect(panelRect.x + 10f, panelRect.y + 61f, width - 20f, 18f), "GROUND COOLDOWN: " + Mathf.CeilToInt(groundEncounterCooldownTimer) + " s");

        if (vessel == null || vessel.mainBody == null)
        {
            GUI.Label(new Rect(panelRect.x + 10f, panelRect.y + 87f, width - 20f, 18f), "NO ACTIVE VESSEL");
            return;
        }

        bool kerbin = string.Equals(vessel.mainBody.bodyName, "Kerbin", StringComparison.OrdinalIgnoreCase);
        bool ground = !vessel.Splashed && vessel.heightFromTerrain < GroundIgnoreAltitudeMetres;
        string biome = GetCurrentBiomeName(vessel);
        bool allowedBiome = !IsBlockedGroundBiome(biome);
        bool night = IsNightAtVessel(vessel);
        GUI.Label(new Rect(panelRect.x + 10f, panelRect.y + 87f, width - 20f, 18f), "KERBIN: " + DebugState(kerbin) + "   GROUND: " + DebugState(ground));
        GUI.Label(new Rect(panelRect.x + 10f, panelRect.y + 105f, width - 20f, 18f), "BIOME (" + biome + "): " + DebugState(allowedBiome));
        GUI.Label(new Rect(panelRect.x + 10f, panelRect.y + 123f, width - 20f, 18f), "NIGHT: " + DebugState(night));
        bool ready = kerbin && ground && allowedBiome && night && !tracking && !groundEntityActive && groundEncounterCooldownTimer <= 0f;
        GUI.Label(new Rect(panelRect.x + 10f, panelRect.y + 149f, width - 20f, 18f), "GROUND SPAWN: " + (ready ? "ELIGIBLE" : "BLOCKED"));
    }

    private static string DebugState(bool state)
    {
        return state ? "OK" : "BLOCKED";
    }

    private void AddToolbarButton()
    {
        if (toolbarButton != null || ApplicationLauncher.Instance == null)
            return;

        toolbarIcon = new Texture2D(38, 38, TextureFormat.RGBA32, false);
        Color[] pixels = new Color[38 * 38];
        for (int i = 0; i < pixels.Length; i++)
            pixels[i] = Color.black;
        toolbarIcon.SetPixels(pixels);
        toolbarIcon.Apply();

        toolbarButton = ApplicationLauncher.Instance.AddModApplication(
            delegate { toolbarVisible = true; },
            delegate { toolbarVisible = false; },
            null, null, null, null,
            ApplicationLauncher.AppScenes.FLIGHT,
            toolbarIcon);
    }

    private void RemoveToolbarButton()
    {
        if (toolbarButton == null)
            return;

        ApplicationLauncher.Instance.RemoveModApplication(toolbarButton);
        toolbarButton = null;
    }

    private void EvadeEncounter()
    {
        EndEncounter();
    }

    private void EndEncounter()
    {
        tracking = false;
        arrived = false;
        arrivalHandled = false;
        tufxGlitchStarted = false;
        glitchFramesRemaining = 0;
        entityPosition = Vector3d.zero;
        entityReferenceBody = null;
        entityBodyRelativePosition = Vector3d.zero;
        lastTrackingVesselPosition = Vector3d.zero;
        hasLastTrackingVesselPosition = false;
        orbitalTetherOffset = Vector3d.zero;
        orbitalTetherActive = false;
        currentSpawnDistanceMetres = SpawnDistanceMetres;
        closestEncounterDistance = 0d;
        survivalTimerStarted = false;
        survivalTimer = 0f;
        lastCountdownSecond = -1;
        blackboxScienceAwarded = false;
        RestoreWindowPosition();
        encounterCooldownTimer = Mathf.Max(encounterCooldownTimer, randomCooldownSeconds);
        UpdateSound();
    }

    // Revert-to-launch and quickload keep KSP in the Flight scene, so the
    // ordinary scene-exit cleanup does not run. Clear all world-bound state
    // here so an old entity can never survive into the restored vessel state.
    private void OnGameStateLoaded(ConfigNode gameState)
    {
        EndEncounter();
        DespawnGroundEntity();
        DespawnBulkEntity();
        DespawnLaytheCreature();
        DespawnSeaEntity();
        DespawnBloopEntity();
        DespawnRakeEntity();
        DespawnEyeEntity();
        eyeLastObservedBody = null;
        safeMessageTimer = 0f;
        countdownTextShakeTimer = 0f;
        trackerVisible = false;
    }

    // Stock KSP spells this event "Recieved".  The original report remains
    // intact; this supplies an encounter-specific addendum and bonus science.
    private void OnScienceReceived(float science, ScienceSubject subject, ProtoVessel vessel, bool transmitted)
    {
        if (subject == null || string.IsNullOrEmpty(subject.id))
            return;
        bool evaReport = subject.id.StartsWith("evaReport@", StringComparison.OrdinalIgnoreCase);
        bool seismicReport = subject.id.StartsWith("seismicScan@", StringComparison.OrdinalIgnoreCase);

        if (evaReport)
        {
            if (groundEntityActive && groundEntityChasing)
                AwardEncounterScience(ref groundScienceAwarded, "EVA REPORT\nGET OUT OF THERE NOW!", 8f);
            else if (IsTouristMimicNearby())
                AwardEncounterScience(ref touristScienceAwarded,
                    "EVA REPORT\nThe Tourist waves at you. Its visor is completely empty.", 4f);
            else if (bulkEntityActive)
                AwardEncounterScience(ref bulkEvaScienceAwarded, bulkEntityChasing ? "EVA REPORT\nRUN!" : "EVA REPORT\nThere's something nearby.", 8f);
            else if (seaEntityActive)
                AwardEncounterScience(ref seaScienceAwarded, "EVA REPORT\nNothing seems out of the ordinary...", 6f);
            else if (tracking)
                AwardEncounterScience(ref blackboxScienceAwarded,
                    "EVA REPORT\nTo mission control, I see something. It's like a... black box?\n\nMission Control: I'm looking at your suit's camera. I don't see anything. You might just be going crazy.", 8f);
        }
        else if (seismicReport && bulkEntityActive)
        {
            AwardEncounterScience(ref bulkSeismometerScienceAwarded,
                bulkEntityChasing
                    ? "SEISMOMETER REPORT\nYou should really be running instead of doing science scans."
                    : "SEISMOMETER REPORT\nThe seismometer detects rhythmic pulses of seismic activity, definitely not natural.",
                10f);
        }
    }

    private void OnExperimentDeployed(ScienceData data)
    {
        if (data == null || string.IsNullOrEmpty(data.subjectID))
            return;
        bool evaReport = data.subjectID.StartsWith("evaReport@", StringComparison.OrdinalIgnoreCase);
        bool seismicReport = data.subjectID.StartsWith("seismicScan@", StringComparison.OrdinalIgnoreCase);
        if (evaReport)
        {
            if (groundEntityActive && groundEntityChasing)
            {
                // Keep the stock biome wording, but mark why this report is
                // abnormal. The result body itself is just the warning.
                data.title = data.title + " (While getting chased by mimic)";
                ApplyEncounterExperimentResult(data, ref groundScienceAwarded, "GET OUT OF THERE NOW!", 8f);
            }
            else if (IsTouristMimicNearby())
            {
                data.title = data.title + " (Near Tourist mimic)";
                ApplyEncounterExperimentResult(data, ref touristScienceAwarded,
                    "The Tourist waves at you. Its visor is completely empty.", 4f);
            }
            else if (bulkEntityActive)
                ApplyEncounterExperimentResult(data, ref bulkEvaScienceAwarded, bulkEntityChasing ? "RUN!" : "There's something nearby.", 8f);
            else if (seaEntityActive)
                ApplyEncounterExperimentResult(data, ref seaScienceAwarded, "Nothing seems out of the ordinary...", 6f);
            else if (tracking)
                ApplyEncounterExperimentResult(data, ref blackboxScienceAwarded,
                    "To mission control, I see something. It's like a... black box?\n\nI'm looking at your suit's camera. I don't see anything. You might just be going crazy.", 8f);
        }
        else if (seismicReport && bulkEntityActive)
        {
            ApplyEncounterExperimentResult(data, ref bulkSeismometerScienceAwarded,
                bulkEntityChasing
                    ? "You should really be running instead of doing science scans."
                    : "The seismometer detects rhythmic pulses of seismic activity, definitely not natural.",
                10f);
        }
    }

    private static void ApplyEncounterExperimentResult(ScienceData data, ref bool awarded, string report, float bonusScience)
    {
        // ModuleScienceExperiment includes this string in the stock science
        // result window, so the report reads like a real EVA/science result.
        data.extraResultString = report;
        AwardEncounterScience(ref awarded, report, bonusScience);
    }

    private static void AwardEncounterScience(ref bool awarded, string report, float bonusScience)
    {
        if (awarded)
            return;
        awarded = true;
        if (ResearchAndDevelopment.Instance != null)
            ResearchAndDevelopment.Instance.AddScience(bonusScience, TransactionReasons.ScienceTransmission);
        ScreenMessages.PostScreenMessage(report + "\n+" + bonusScience.ToString("F0") + " science", 8f, ScreenMessageStyle.UPPER_CENTER);
    }

    private bool IsTouristMimicNearby()
    {
        if (!groundEntityActive || groundVariant != GroundVariant.Tourist || groundEntityBody == null)
            return false;
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || vessel.mainBody != groundEntityBody)
            return false;
        Vector3d touristPosition = groundEntityBody.position + groundEntityRelativePosition;
        return (vessel.GetWorldPos3D() - touristPosition).magnitude <= 100d;
    }

    private void UpdateCrewTerror()
    {
        // The black-box is pursuing whenever its signal is active.  Surface
        // hunters only count while genuinely chasing, not while roaming,
        // watching, standing down, or fleeing.
        bool beingChased = (tracking && !arrived) ||
                            (groundEntityChasing && !groundEntityStandingDown && !groundEntityRetreating) ||
                            (bulkEntityChasing && !bulkEntityStandingDown && !bulkEntityRetreating) ||
                            seaEntityChasing ||
                            (rakeEntityChasing && !rakeEntityStandingDown && !rakeEntityRetreating);
        if (!beingChased)
        {
            crewTerrorActive = false;
            RestoreCrewCourage();
            return;
        }

        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null)
            return;
        foreach (ProtoCrewMember kerbal in vessel.GetVesselCrew())
        {
            if (kerbal == null)
                continue;
            if (!courageBeforeTerror.ContainsKey(kerbal))
                courageBeforeTerror.Add(kerbal, kerbal.courage);
            // Even the normally fearless orange-suited Kerbals should look
            // horrified while something is sprinting at their vessel.
            kerbal.courage = 0f;
        }
        crewTerrorActive = true;
        ForceTerrifiedExpressions(vessel);
    }

    public void LateUpdate()
    {
        // Expression AI can overwrite values later in Update. Apply terror in
        // LateUpdate too, ensuring the visual panic wins that frame.
        if (crewTerrorActive && FlightGlobals.ActiveVessel != null)
            ForceTerrifiedExpressions(FlightGlobals.ActiveVessel);
    }

    private static void ForceTerrifiedExpressions(Vessel vessel)
    {
        if (vessel == null || vessel.parts == null)
            return;
        foreach (Part part in vessel.parts)
        {
            if (part == null)
                continue;
            ForceTerrifiedExpressions(part.gameObject.GetComponentsInChildren<kerbalExpressionSystem>(true));
            if (part.internalModel != null)
                ForceTerrifiedExpressions(part.internalModel.gameObject.GetComponentsInChildren<kerbalExpressionSystem>(true));
        }
    }

    private static void ForceTerrifiedExpressions(kerbalExpressionSystem[] systems)
    {
        foreach (kerbalExpressionSystem system in systems)
        {
            if (system == null)
                continue;
            // The controller maps high panic/fear and a negative expression to
            // the frightened face. Remove excitement at the same time so a
            // chase can never resolve to the default cheerful grin.
            if (ExpressionPanicLevelField != null) ExpressionPanicLevelField.SetValue(system, 1f);
            if (ExpressionFearFactorField != null) ExpressionFearFactorField.SetValue(system, 1f);
            if (ExpressionWheeLevelField != null) ExpressionWheeLevelField.SetValue(system, 0f);
            if (ExpressionTargetField != null) ExpressionTargetField.SetValue(system, -1f);
            if (ExpressionCurrentField != null) ExpressionCurrentField.SetValue(system, -1f);
        }
    }

    private void RestoreCrewCourage()
    {
        foreach (KeyValuePair<ProtoCrewMember, float> entry in courageBeforeTerror)
        {
            if (entry.Key != null)
                entry.Key.courage = entry.Value;
        }
        courageBeforeTerror.Clear();
    }

    private void UpdateEvaPanicSpeed()
    {
        // Re-check this at a deliberately modest cadence. It prevents a stale
        // EVA boost if an entity disappears through any of its despawn paths.
        evaPanicSpeedCheckTimer -= Time.unscaledDeltaTime;
        if (evaPanicSpeedCheckTimer > 0f)
            return;
        evaPanicSpeedCheckTimer = 2f;

        // This is intentionally broader than the facial panic effect: simply
        // having an entity present is enough to make an EVA Kerbal run harder.
        bool entityPresent = tracking || groundEntityActive || bulkEntityActive || seaEntityActive || rakeEntityActive;
        if (!entityPresent)
        {
            RestoreEvaPanicSpeed();
            return;
        }

        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || vessel.parts == null)
            return;
        foreach (Part part in vessel.parts)
        {
            if (part == null)
                continue;
            KerbalEVA eva = part.FindModuleImplementing<KerbalEVA>();
            if (eva == null || EvaRunSpeedField == null || EvaBoundSpeedField == null)
                continue;
            EvaSpeedState original;
            if (!evaSpeedBeforePanic.TryGetValue(eva, out original))
            {
                original = new EvaSpeedState
                {
                    runSpeed = (float)EvaRunSpeedField.GetValue(eva),
                    boundSpeed = (float)EvaBoundSpeedField.GetValue(eva)
                };
                evaSpeedBeforePanic.Add(eva, original);
            }
            // Restore the usual broad panic boost for every entity state.
            EvaRunSpeedField.SetValue(eva, original.runSpeed * 1.5f);
            EvaBoundSpeedField.SetValue(eva, original.boundSpeed * 1.5f);
        }
    }

    private void RestoreEvaPanicSpeed()
    {
        foreach (KeyValuePair<KerbalEVA, EvaSpeedState> entry in evaSpeedBeforePanic)
        {
            if (entry.Key == null)
                continue;
            if (EvaRunSpeedField != null) EvaRunSpeedField.SetValue(entry.Key, entry.Value.runSpeed);
            if (EvaBoundSpeedField != null) EvaBoundSpeedField.SetValue(entry.Key, entry.Value.boundSpeed);
        }
        evaSpeedBeforePanic.Clear();
    }

    private void SpawnGroundEntity()
    {
        SpawnGroundEntity(PickGroundVariant(), false);
    }

    private static Vector3d GetTestSpawnAhead(Vessel vessel, CelestialBody body)
    {
        if (vessel == null || body == null)
            return Vector3d.zero;
        Vector3d radialUp = (vessel.GetWorldPos3D() - body.position).normalized;
        Vector3d forward = vessel.transform.forward;
        forward -= radialUp * Vector3d.Dot(forward, radialUp);
        if (forward.sqrMagnitude < 0.01d)
            forward = vessel.transform.right - radialUp * Vector3d.Dot(vessel.transform.right, radialUp);
        return vessel.GetWorldPos3D() + forward.normalized * 200d;
    }

    private void SpawnGroundEntityForTesting(Vessel vessel)
    {
        SpawnGroundEntity();
        if (!groundEntityActive || groundEntityBody == null) return;
        Vector3d position = ProjectToGround(GetTestSpawnAhead(vessel, groundEntityBody), groundEntityBody);
        groundEntityRelativePosition = position - groundEntityBody.position;
        if (groundEntityObject != null) groundEntityObject.transform.position = (Vector3)position;
    }

    private void SpawnBulkEntityForTesting(Vessel vessel)
    {
        SpawnBulkEntity();
        if (!bulkEntityActive || bulkEntityBody == null) return;
        Vector3d position = ProjectToGround(GetTestSpawnAhead(vessel, bulkEntityBody), bulkEntityBody);
        bulkEntityRelativePosition = position - bulkEntityBody.position;
        if (bulkEntityObject != null) bulkEntityObject.transform.position = (Vector3)position;
    }

    private void SpawnLaytheCreatureForTesting(Vessel vessel)
    {
        SpawnLaytheCreature();
        if (!laytheCreatureActive || laytheCreatureBody == null) return;
        Vector3d position = ProjectToGround(GetTestSpawnAhead(vessel, laytheCreatureBody), laytheCreatureBody);
        laytheCreatureRelativePosition = position - laytheCreatureBody.position;
        if (laytheCreatureObject != null) laytheCreatureObject.transform.position = (Vector3)position;
    }

    private void SpawnSeaEntityForTesting(Vessel vessel)
    {
        SpawnSeaEntity();
        if (!seaEntityActive || seaEntityBody == null) return;
        Vector3d position = ProjectToEveSea(GetTestSpawnAhead(vessel, seaEntityBody), seaEntityBody);
        seaEntityRelativePosition = position - seaEntityBody.position;
        if (seaEntityObject != null) seaEntityObject.transform.position = (Vector3)position;
    }

    private void SpawnBloopEntityForTesting(Vessel vessel)
    {
        SpawnBloopEntity(true);
        if (!bloopEntityActive || bloopEntityBody == null) return;
        Vector3d position = ProjectToBloopSea(GetTestSpawnAhead(vessel, bloopEntityBody), bloopEntityBody);
        bloopEntityRelativePosition = position - bloopEntityBody.position;
        if (bloopEntityObject != null) bloopEntityObject.transform.position = (Vector3)position;
    }

    private void SpawnEyeEntityForTesting(Vessel vessel)
    {
        ForceSpawnEyeEntity();
        if (eyeEntityActive && vessel != null && vessel.mainBody != null)
            eyeEntityPosition = GetTestSpawnAhead(vessel, vessel.mainBody);
    }

    private static GroundVariant PickGroundVariant()
    {
        float roll = UnityEngine.Random.value;
        // The ordinary hostile suits dominate encounters. Scientist and Tourist
        // variants are deliberate rare sightings rather than every-fourth roll.
        if (roll < 0.45f) return GroundVariant.Pilot;
        if (roll < 0.90f) return GroundVariant.Engineer;
        if (roll < 0.95f) return GroundVariant.Scientist;
        return GroundVariant.Tourist;
    }

    private void SpawnGroundEntity(GroundVariant variant, bool naturalSpawn)
    {
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || vessel.mainBody == null || vessel.heightFromTerrain >= GroundIgnoreAltitudeMetres)
        {
            groundEntityStatus = "REQUIRES GROUND";
            return;
        }
        if (!string.Equals(vessel.mainBody.bodyName, "Kerbin", StringComparison.OrdinalIgnoreCase))
        {
            groundEntityStatus = "KERBIN ONLY";
            return;
        }

        // Engineers deliberately ignore the usual night/biome rules; the
        // other three variants retain the original night land-encounter rules.
        if (naturalSpawn && variant != GroundVariant.Engineer && !IsEligibleForGroundEncounter(vessel))
        {
            groundEntityStatus = "CONDITIONS BLOCKED";
            return;
        }

        DespawnGroundEntity();
        groundVariant = variant;
        groundEntityBody = vessel.mainBody;
        Vector3 horizontal = (vessel.transform.forward * UnityEngine.Random.Range(-1f, 1f)
                            + vessel.transform.right * UnityEngine.Random.Range(-1f, 1f)).normalized;
        if (horizontal.sqrMagnitude < 0.01f)
            horizontal = vessel.transform.forward.normalized;

        // Projecting onto a steep local terrain cell can shorten a direct
        // world-space offset. Validate the final ground position itself, not
        // merely the initial candidate, so it never materialises inside the
        // advertised 2.1 km minimum.
        Vector3d groundPosition = Vector3d.zero;
        bool validSpawnDistance = false;
        for (int attempt = 0; attempt < 24; attempt++)
        {
            Vector3 direction = attempt == 0
                ? horizontal
                : (vessel.transform.forward * UnityEngine.Random.Range(-1f, 1f)
                   + vessel.transform.right * UnityEngine.Random.Range(-1f, 1f)).normalized;
            if (direction.sqrMagnitude < 0.01f)
                direction = vessel.transform.forward.normalized;
            Vector3d candidate = vessel.GetWorldPos3D() + (Vector3d)(direction * UnityEngine.Random.Range(GroundSpawnMinimumMetres, GroundSpawnMaximumMetres));
            groundPosition = ProjectToGround(candidate, groundEntityBody);
            if (IsLandAt(groundPosition, groundEntityBody) &&
                (groundPosition - vessel.GetWorldPos3D()).magnitude >= GroundSpawnMinimumMetres)
            {
                validSpawnDistance = true;
                break;
            }
        }
        if (!validSpawnDistance)
        {
            groundEntityBody = null;
            groundEntityStatus = "NO VALID SPAWN POSITION";
            return;
        }
        groundEntityRelativePosition = groundPosition - groundEntityBody.position;
        groundEntityObject = CreateGroundEntityVisual(groundVariant == GroundVariant.Tourist);
        if (groundEntityObject == null)
        {
            groundEntityBody = null;
            groundEntityRelativePosition = Vector3d.zero;
            groundEntityStatus = "KERBAL MODEL UNAVAILABLE";
            return;
        }
        groundEntityObject.transform.position = (Vector3)groundPosition;
        groundEntityAnimation = groundEntityObject.GetComponent<GroundEntityGlitchAnimation>();
        if (groundVariant == GroundVariant.Pilot || groundVariant == GroundVariant.Engineer)
        {
            groundFootstepsAudio = ConfigureGroundAudioSource(groundEntityObject.AddComponent<AudioSource>(), 150f, 0.85f);
            groundScreamAudio = ConfigureGroundAudioSource(groundEntityObject.AddComponent<AudioSource>(), 2200f, 1f);
            AudioSource spawnThumpAudio = ConfigureGroundAudioSource(groundEntityObject.AddComponent<AudioSource>(), 5000f, 1f);
            spawnThumpAudio.loop = false;
            StartCoroutine(LoadGroundSound("seek-footsteps-made-with-Voicemod.mp3", groundFootstepsAudio, true));
            StartCoroutine(LoadGroundSound("entity-loud-scream-chase-made-with-Voicemod.mp3", groundScreamAudio, false));
            StartCoroutine(LoadGroundSpawnSound("horror-thump-made-with-Voicemod.mp3", spawnThumpAudio));
        }
        groundEntityActive = true;
        groundScienceAwarded = false;
        touristScienceAwarded = false;
        groundEntityChasing = false;
        groundEntityFleeing = false;
        groundEntityFleeTimer = 0f;
        groundEntityStandingDown = false;
        groundEntityRetreating = false;
        groundEntityWithdrawalTimer = 0f;
        groundEntityChaseSurvivalTimer = 0f;
        ChooseGroundRoamDirection(groundPosition);
        groundEntityStatus = groundVariant == GroundVariant.Scientist ? "SCIENTIST: WATCHING" :
                            groundVariant == GroundVariant.Tourist ? "TOURIST: WANDERING" :
                            groundVariant + ": ROAMING";
    }

    private void UpdateGroundEntity()
    {
        if (!groundEntityActive || groundEntityObject == null || groundEntityBody == null)
            return;

        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null)
            return;

        Vector3d entityPosition = groundEntityBody.position + groundEntityRelativePosition;
        double distance = (vessel.GetWorldPos3D() - entityPosition).magnitude;
        bool vesselOnGround = vessel.heightFromTerrain < GroundIgnoreAltitudeMetres;

        if (groundVariant == GroundVariant.Scientist)
        {
            UpdateScientistEntity(vessel, entityPosition, distance, vesselOnGround);
            return;
        }
        if (groundVariant == GroundVariant.Tourist)
        {
            UpdateTouristEntity(vessel, entityPosition, vesselOnGround);
            return;
        }

        if (!groundEntityChasing && !groundEntityStandingDown && !groundEntityRetreating && vesselOnGround &&
            distance <= GroundActivationMetres && HasGroundLineOfSight(entityPosition, vessel.GetWorldPos3D(), groundEntityBody))
            groundEntityChasing = true;

        // A grounded Mimic no longer gives up after a timed pursuit. As long
        // as the vessel remains low enough for it to reach, it keeps chasing.

        if (groundEntityChasing && !groundEntityStandingDown && !groundEntityRetreating && vesselOnGround && distance <= GroundArrivalDistanceMetres)
        {
            DespawnGroundEntity();
            HandleEntityCatch(vessel);
            return;
        }

        if (groundEntityStandingDown)
        {
            groundEntityWithdrawalTimer -= TimeWarp.deltaTime;
            groundEntityStatus = groundVariant + ": STANDING STILL";
            if (groundEntityWithdrawalTimer <= 0f)
            {
                groundEntityStandingDown = false;
                groundEntityRetreating = true;
                groundEntityWithdrawalTimer = WithdrawalFleeSeconds;
            }
        }
        else if (groundEntityRetreating)
        {
            groundEntityWithdrawalTimer -= TimeWarp.deltaTime;
            Vector3d radial = (entityPosition - groundEntityBody.position).normalized;
            Vector3d toVessel = vessel.GetWorldPos3D() - entityPosition;
            if (vesselOnGround && Time.timeScale > 0f)
            {
                Vector3d away = -toVessel - radial * Vector3d.Dot(-toVessel, radial);
                if (away.sqrMagnitude > 1d)
                {
                    entityPosition += away.normalized * GetGroundEntitySpeed(GroundChaseSpeedMetresPerSecond) * TimeWarp.deltaTime;
                    entityPosition = ProjectToGround(entityPosition, groundEntityBody);
                    groundEntityRelativePosition = entityPosition - groundEntityBody.position;
                }
            }
            groundEntityStatus = groundVariant + ": RETREATING";
            if (groundEntityWithdrawalTimer <= 0f)
            {
                DespawnGroundEntity();
                return;
            }
        }
        else if (groundEntityChasing && vesselOnGround && Time.timeScale > 0f)
        {
            Vector3d radial = (entityPosition - groundEntityBody.position).normalized;
            Vector3d toVessel = vessel.GetWorldPos3D() - entityPosition;
            // Never switch to a direct 3D lunge. The Mimic is a ground-bound
            // threat, so it follows only the terrain tangent and remains
            // unable to rise toward a vessel that has taken off.
            Vector3d surfaceDirection = toVessel - radial * Vector3d.Dot(toVessel, radial);
            if (surfaceDirection.sqrMagnitude > 1d)
            {
                entityPosition += surfaceDirection.normalized * GetGroundEntitySpeed(GroundChaseSpeedMetresPerSecond) * TimeWarp.deltaTime;
                entityPosition = ProjectToGround(entityPosition, groundEntityBody);
            }
            if (toVessel.sqrMagnitude > 0.01d)
                groundEntityRelativePosition = entityPosition - groundEntityBody.position;
            groundEntityStatus = "CHASING";
        }
        else if (!groundEntityChasing && Time.timeScale > 0f)
        {
            groundRoamDirectionTimer -= TimeWarp.deltaTime;
            if (groundRoamDirectionTimer <= 0f)
                ChooseGroundRoamDirection(entityPosition);
            entityPosition += groundRoamDirection * GroundRoamSpeedMetresPerSecond * TimeWarp.deltaTime;
            entityPosition = ProjectToGround(entityPosition, groundEntityBody);
            groundEntityRelativePosition = entityPosition - groundEntityBody.position;
            groundEntityStatus = "ROAMING";
        }
        else if (groundEntityChasing)
        {
            groundEntityStatus = "CANNOT REACH";
        }

        groundEntityObject.transform.position = (Vector3)entityPosition;
        if (vesselOnGround)
        {
            Vector3d direction = groundEntityRetreating
                ? entityPosition - vessel.GetWorldPos3D()
                : vessel.GetWorldPos3D() - entityPosition;
            Vector3d radialUp = (entityPosition - groundEntityBody.position).normalized;
            direction -= radialUp * Vector3d.Dot(direction, radialUp);
            if (direction.sqrMagnitude > 0.01d)
                groundEntityObject.transform.LookAt((Vector3)(entityPosition + direction), (Vector3)radialUp);
        }
        if (groundEntityAnimation != null)
        {
            bool activelyChasing = groundEntityChasing && !groundEntityStandingDown && !groundEntityRetreating;
            groundEntityAnimation.SetChasing(activelyChasing);
            groundEntityAnimation.SetRoaming(!activelyChasing && !groundEntityStandingDown);
        }
        UpdateGroundEntitySound();
    }

    private void UpdateScientistEntity(Vessel vessel, Vector3d entityPosition, double distance, bool vesselOnGround)
    {
        Vector3d toVessel = vessel.GetWorldPos3D() - entityPosition;
        Vector3d radial = (entityPosition - groundEntityBody.position).normalized;
        if (!groundEntityFleeing && distance <= ScientistFleeDistanceMetres)
        {
            groundEntityFleeing = true;
            groundEntityFleeTimer = ScientistFleeSeconds;
        }

        if (Time.timeScale > 0f && vesselOnGround)
        {
            Vector3d surfaceDirection = toVessel - radial * Vector3d.Dot(toVessel, radial);
            if (surfaceDirection.sqrMagnitude > 1d)
            {
                float speed = groundEntityFleeing ? ScientistFleeSpeedMetresPerSecond : ScientistFollowSpeedMetresPerSecond;
                if (groundEntityFleeing)
                    surfaceDirection = -surfaceDirection;
                entityPosition += surfaceDirection.normalized * speed * TimeWarp.deltaTime;
                entityPosition = ProjectToGround(entityPosition, groundEntityBody);
                groundEntityRelativePosition = entityPosition - groundEntityBody.position;
            }
        }

        if (groundEntityFleeing)
        {
            groundEntityFleeTimer -= TimeWarp.deltaTime;
            groundEntityStatus = "SCIENTIST: FLEEING";
            if (groundEntityFleeTimer <= 0f)
            {
                DespawnGroundEntity();
                return;
            }
        }
        else
        {
            groundEntityStatus = vesselOnGround ? "SCIENTIST: FOLLOWING" : "SCIENTIST: WATCHING";
        }

        groundEntityObject.transform.position = (Vector3)entityPosition;
        if (toVessel.sqrMagnitude > 0.01d)
        {
            Vector3d lookDirection = groundEntityFleeing ? -toVessel : toVessel;
            lookDirection -= radial * Vector3d.Dot(lookDirection, radial);
            if (lookDirection.sqrMagnitude > 0.01d)
                groundEntityObject.transform.LookAt(groundEntityObject.transform.position + (Vector3)lookDirection, (Vector3)radial);
        }
        if (groundEntityAnimation != null)
        {
            groundEntityAnimation.SetChasing(groundEntityFleeing);
            groundEntityAnimation.SetRoaming(!groundEntityFleeing);
        }
    }

    private void UpdateTouristEntity(Vessel vessel, Vector3d entityPosition, bool vesselOnGround)
    {
        if (Time.timeScale > 0f)
        {
            groundRoamDirectionTimer -= TimeWarp.deltaTime;
            if (groundRoamDirectionTimer <= 0f)
                ChooseGroundRoamDirection(entityPosition);
            entityPosition += groundRoamDirection * GroundRoamSpeedMetresPerSecond * TimeWarp.deltaTime;
            entityPosition = ProjectToGround(entityPosition, groundEntityBody);
            groundEntityRelativePosition = entityPosition - groundEntityBody.position;
        }

        groundEntityStatus = "TOURIST: IGNORING YOU";
        groundEntityObject.transform.position = (Vector3)entityPosition;
        Vector3d radial = (entityPosition - groundEntityBody.position).normalized;
        if (groundRoamDirection.sqrMagnitude > 0.001d)
            groundEntityObject.transform.LookAt((Vector3)(entityPosition + groundRoamDirection), (Vector3)radial);
        if (groundEntityAnimation != null)
        {
            groundEntityAnimation.SetChasing(false);
            groundEntityAnimation.SetRoaming(true);
        }
    }

    private void SpawnBulkEntity()
    {
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || vessel.mainBody == null || vessel.heightFromTerrain >= GroundIgnoreAltitudeMetres)
        {
            bulkEntityStatus = "REQUIRES GROUND";
            return;
        }
        if (!IsBulkEntityBody(vessel.mainBody))
        {
            bulkEntityStatus = "MOHO / EVE ONLY";
            return;
        }

        DespawnBulkEntity();
        bulkEntityBody = vessel.mainBody;
        Vector3d groundPosition;
        if (!TryFindLandSpawnPosition(vessel, bulkEntityBody, BulkSpawnMinimumMetres, BulkSpawnMaximumMetres, out groundPosition))
        {
            bulkEntityBody = null;
            bulkEntityStatus = "NO LAND SPAWN POSITION";
            return;
        }
        bulkEntityObject = CreateBulkEntityVisual();
        if (bulkEntityObject == null)
        {
            bulkEntityBody = null;
            bulkEntityStatus = "MODEL UNAVAILABLE";
            return;
        }

        bulkEntityRelativePosition = groundPosition - bulkEntityBody.position;
        bulkEntityObject.transform.position = (Vector3)groundPosition;
        bulkEntityBoneAnimation = bulkEntityObject.AddComponent<BulkEntityBoneAnimation>();
        bulkEntityBoneAnimation.Configure(bulkEntityObject);
        bulkFootstepsAudio = ConfigureGroundAudioSource(bulkEntityObject.AddComponent<AudioSource>(), 2200f, 0.85f);
        StartCoroutine(LoadBulkFootsteps());
        bulkEntityActive = true;
        bulkEvaScienceAwarded = false;
        bulkSeismometerScienceAwarded = false;
        bulkEntityChasing = false;
        bulkEntityStandingDown = false;
        bulkEntityRetreating = false;
        bulkWithdrawalTimer = 0f;
        bulkChaseAnimationPhase = UnityEngine.Random.Range(0f, Mathf.PI * 2f);
        bulkEntityStatus = "DORMANT";
    }

    private static bool IsBulkEntityBody(CelestialBody body)
    {
        if (body == null)
            return false;
        return string.Equals(body.bodyName, "Moho", StringComparison.OrdinalIgnoreCase) ||
               string.Equals(body.bodyName, "Eve", StringComparison.OrdinalIgnoreCase);
    }

    private static GameObject CreateBulkEntityVisual()
    {
        GameObject prefab = GameDatabase.Instance != null ? GameDatabase.Instance.GetModel(BulkModelPath) : null;
        if (prefab == null)
        {
            UnityEngine.Debug.Log("[EntityTracker] Could not load " + BulkModelPath + ".mu");
            return null;
        }

        prefab.name = "EntityTracker_MohoEveEntity";
        prefab.SetActive(true);
        prefab.transform.localScale = Vector3.one * BulkVisualScale;
        foreach (Transform transform in prefab.GetComponentsInChildren<Transform>(true))
        {
            transform.gameObject.SetActive(true);
            transform.gameObject.layer = 0;
        }
        foreach (Renderer renderer in prefab.GetComponentsInChildren<Renderer>(true))
            renderer.enabled = true;
        ConfigureLitEntityMaterials(prefab);
        foreach (Collider collider in prefab.GetComponentsInChildren<Collider>(true))
            Destroy(collider);
        foreach (Rigidbody body in prefab.GetComponentsInChildren<Rigidbody>(true))
            Destroy(body);
        return prefab;
    }

    // Imported Sketchfab models can carry an unlit/emissive material from their
    // original scene.  Give the visible meshes a normal KSP-lit material so
    // stock lighting and visual mods (including Scatterer) control how bright
    // they are, rather than leaving them self-illuminated in darkness.
    private static void ConfigureLitEntityMaterials(GameObject root)
    {
        if (root == null)
            return;

        Shader litShader = Shader.Find("KSP/Diffuse");
        if (litShader == null)
            litShader = Shader.Find("Legacy Shaders/Diffuse");
        if (litShader == null)
        {
            UnityEngine.Debug.Log("[EntityTracker] Could not find a lit shader for imported entity materials.");
            return;
        }

        foreach (Renderer renderer in root.GetComponentsInChildren<Renderer>(true))
        {
            if (renderer == null)
                continue;
            Material[] materials = renderer.materials;
            for (int i = 0; i < materials.Length; i++)
            {
                Material material = materials[i];
                if (material == null)
                    continue;

                Texture mainTexture = material.mainTexture;
                Color tint = material.HasProperty("_Color") ? material.color : Color.white;
                material.shader = litShader;
                material.mainTexture = mainTexture;
                if (material.HasProperty("_Color"))
                    material.color = tint;
                if (material.HasProperty("_EmissionColor"))
                    material.SetColor("_EmissionColor", Color.black);
                if (material.HasProperty("_EmissiveColor"))
                    material.SetColor("_EmissiveColor", Color.black);
                material.DisableKeyword("_EMISSION");
            }
        }
    }

    private void UpdateBulkEntity()
    {
        if (!bulkEntityActive || bulkEntityObject == null || bulkEntityBody == null)
            return;

        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null)
            return;

        Vector3d entityPosition = bulkEntityBody.position + bulkEntityRelativePosition;
        Vector3d toVessel = vessel.GetWorldPos3D() - entityPosition;
        double distance = toVessel.magnitude;
        bool vesselOnGround = vessel.heightFromTerrain < GroundIgnoreAltitudeMetres;
        if (!bulkEntityChasing && !bulkEntityStandingDown && !bulkEntityRetreating && vesselOnGround &&
            distance <= BulkActivationMetres && HasGroundLineOfSight(entityPosition, vessel.GetWorldPos3D(), bulkEntityBody))
            bulkEntityChasing = true;

        // Unlike the Rake, this entity will only give up when the player
        // actually opens a ten-kilometre gap after the chase has begun.
        if (bulkEntityChasing && !bulkEntityStandingDown && !bulkEntityRetreating && distance >= BulkEscapeDistanceMetres)
        {
            bulkEntityStandingDown = true;
            bulkWithdrawalTimer = WithdrawalStandSeconds;
        }

        if (bulkEntityChasing && !bulkEntityStandingDown && !bulkEntityRetreating && distance <= BulkArrivalDistanceMetres)
        {
            DespawnBulkEntity();
            HandleEntityCatch(vessel);
            return;
        }

        if (bulkEntityStandingDown)
        {
            bulkWithdrawalTimer -= TimeWarp.deltaTime;
            bulkEntityStatus = "STANDING STILL";
            if (bulkWithdrawalTimer <= 0f)
            {
                bulkEntityStandingDown = false;
                bulkEntityRetreating = true;
                bulkWithdrawalTimer = WithdrawalFleeSeconds;
            }
        }
        else if (bulkEntityRetreating)
        {
            bulkWithdrawalTimer -= TimeWarp.deltaTime;
            if (vesselOnGround && Time.timeScale > 0f)
            {
                Vector3d radial = (entityPosition - bulkEntityBody.position).normalized;
                Vector3d away = -toVessel - radial * Vector3d.Dot(-toVessel, radial);
                if (away.sqrMagnitude > 1d)
                {
                    entityPosition += away.normalized * GetGroundEntitySpeed(BulkChaseSpeedMetresPerSecond) * TimeWarp.deltaTime;
                    entityPosition = ProjectToGround(entityPosition, bulkEntityBody);
                    bulkEntityRelativePosition = entityPosition - bulkEntityBody.position;
                }
            }
            bulkEntityStatus = "RETREATING";
            if (bulkWithdrawalTimer <= 0f)
            {
                DespawnBulkEntity();
                return;
            }
        }
        else if (bulkEntityChasing && vesselOnGround && Time.timeScale > 0f)
        {
            Vector3d radial = (entityPosition - bulkEntityBody.position).normalized;
            if (distance <= 20d)
            {
                entityPosition += toVessel.normalized * GetGroundEntitySpeed(BulkChaseSpeedMetresPerSecond) * TimeWarp.deltaTime;
            }
            else
            {
                Vector3d surfaceDirection = toVessel - radial * Vector3d.Dot(toVessel, radial);
                if (surfaceDirection.sqrMagnitude > 1d)
                {
                    entityPosition += surfaceDirection.normalized * GetGroundEntitySpeed(BulkChaseSpeedMetresPerSecond) * TimeWarp.deltaTime;
                    entityPosition = ProjectToGround(entityPosition, bulkEntityBody);
                }
            }
            bulkEntityRelativePosition = entityPosition - bulkEntityBody.position;
            bulkEntityStatus = "CHASING";
        }
        else if (bulkEntityChasing)
        {
            bulkEntityStatus = "CANNOT REACH";
        }

        Vector3d radialUp = (entityPosition - bulkEntityBody.position).normalized;
        float bounce = 0f;
        Quaternion rotation = bulkEntityObject.transform.rotation;
        if (vesselOnGround && toVessel.sqrMagnitude > 0.01d)
        {
            // The imported model's forward axis is reversed relative to KSP.
            // Face it toward the vessel first, then apply that fixed 180-degree
            // correction before the chase pose is layered on top.
            Vector3d facingDirection = bulkEntityRetreating ? -toVessel : toVessel;
            facingDirection -= radialUp * Vector3d.Dot(facingDirection, radialUp);
            if (facingDirection.sqrMagnitude > 0.01d)
                rotation = Quaternion.LookRotation((Vector3)facingDirection.normalized, (Vector3)radialUp) * Quaternion.Euler(0f, 180f, 0f);
        }
        bool activelyChasing = bulkEntityChasing && !bulkEntityStandingDown && !bulkEntityRetreating;
        if (activelyChasing)
        {
            bulkChaseAnimationPhase += TimeWarp.deltaTime * 12f;
            float stride = Mathf.Sin(bulkChaseAnimationPhase);
            float lurch = Mathf.Sin(bulkChaseAnimationPhase * 2f);
            bounce = Mathf.Abs(stride) * 0.38f;
            rotation *= Quaternion.Euler(2.5f * lurch, 0f, 4f * stride);
            bulkEntityObject.transform.localScale = Vector3.one * BulkVisualScale * (1f + 0.02f * Mathf.Abs(stride));
        }
        else
        {
            bulkEntityObject.transform.localScale = Vector3.one * BulkVisualScale;
        }
        bulkEntityObject.transform.position = (Vector3)(entityPosition + radialUp * bounce);
        bulkEntityObject.transform.rotation = rotation;
        AlignBulkEntityFeetToGround(entityPosition + radialUp * bounce, (Vector3)radialUp);
        if (bulkEntityBoneAnimation != null)
            bulkEntityBoneAnimation.SetChasing(activelyChasing);
        UpdateBulkFootsteps(activelyChasing);
    }

    private void AlignBulkEntityFeetToGround(Vector3d groundPosition, Vector3 radialUp)
    {
        if (bulkEntityObject == null)
            return;
        Renderer[] renderers = bulkEntityObject.GetComponentsInChildren<Renderer>(true);
        if (renderers.Length == 0)
            return;

        // Bounds are world-aligned, so project each bound onto the body's
        // radial "up" direction. This gives the actual lowest visible point
        // after the model's axis correction and current bone animation.
        float lowestVisiblePoint = float.MaxValue;
        foreach (Renderer renderer in renderers)
        {
            if (renderer == null || !renderer.enabled)
                continue;
            Bounds bounds = renderer.bounds;
            float projectedMinimum = Vector3.Dot(bounds.center, radialUp)
                                   - Mathf.Abs(radialUp.x) * bounds.extents.x
                                   - Mathf.Abs(radialUp.y) * bounds.extents.y
                                   - Mathf.Abs(radialUp.z) * bounds.extents.z;
            lowestVisiblePoint = Mathf.Min(lowestVisiblePoint, projectedMinimum);
        }
        if (lowestVisiblePoint == float.MaxValue)
            return;

        float terrainPoint = Vector3.Dot((Vector3)groundPosition, radialUp) + 0.03f;
        bulkEntityObject.transform.position += radialUp * (terrainPoint - lowestVisiblePoint);
    }

    private void DespawnBulkEntity()
    {
        if (bulkEntityObject != null)
            Destroy(bulkEntityObject);
        bulkEntityObject = null;
        bulkEntityBoneAnimation = null;
        bulkFootstepsAudio = null;
        bulkFootstepsLoaded = false;
        bulkEntityBody = null;
        bulkEntityRelativePosition = Vector3d.zero;
        bulkEntityActive = false;
        bulkEntityChasing = false;
        bulkEntityStandingDown = false;
        bulkEntityRetreating = false;
        bulkWithdrawalTimer = 0f;
        bulkChaseAnimationPhase = 0f;
        bulkEntityStatus = "NONE";
    }

    private void SpawnLaytheCreature()
    {
        if (!LaytheLandCreatureEnabled)
            return;
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (!IsEligibleForLaytheCreature(vessel))
        {
            laytheCreatureStatus = "LAYTHE GROUND REQUIRED";
            return;
        }
        DespawnLaytheCreature();
        laytheCreatureBody = vessel.mainBody;
        Vector3d groundPosition;
        if (!TryFindLandSpawnPosition(vessel, laytheCreatureBody, LaytheCreatureSpawnMinimumMetres, LaytheCreatureSpawnMaximumMetres, out groundPosition))
        {
            laytheCreatureBody = null;
            laytheCreatureStatus = "NO LAND SPAWN POSITION";
            return;
        }
        laytheCreatureObject = GameDatabase.Instance != null ? GameDatabase.Instance.GetModel(LaytheCreatureModelPath) : null;
        if (laytheCreatureObject == null)
        {
            laytheCreatureBody = null;
            laytheCreatureStatus = "MODEL UNAVAILABLE";
            UnityEngine.Debug.Log("[EntityTracker] Could not load " + LaytheCreatureModelPath + ".mu");
            return;
        }
        laytheCreatureObject.name = "EntityTracker_LaytheLandCreature";
        laytheCreatureObject.SetActive(true);
        laytheCreatureObject.transform.localScale = Vector3.one * LaytheCreatureVisualScale;
        foreach (Transform transform in laytheCreatureObject.GetComponentsInChildren<Transform>(true))
        {
            transform.gameObject.SetActive(true);
            transform.gameObject.layer = 0;
        }
        foreach (Collider collider in laytheCreatureObject.GetComponentsInChildren<Collider>(true)) Destroy(collider);
        foreach (Rigidbody body in laytheCreatureObject.GetComponentsInChildren<Rigidbody>(true)) Destroy(body);
        ConfigureLitEntityMaterials(laytheCreatureObject);
        laytheCreatureRelativePosition = groundPosition - laytheCreatureBody.position;
        laytheCreatureObject.transform.position = (Vector3)groundPosition;
        laytheCreatureBoneAnimation = laytheCreatureObject.AddComponent<LaytheCreatureBoneAnimation>();
        laytheCreatureBoneAnimation.Configure(laytheCreatureObject);
        laytheCreatureAnimation = laytheCreatureObject.GetComponentInChildren<Animation>(true);
        PlayLaytheCreatureAnimation("Idle_Object_5");
        laytheCreatureFootstepsAudio = ConfigureGroundAudioSource(laytheCreatureObject.AddComponent<AudioSource>(), 180f, 0.8f);
        laytheCreatureScreamAudio = ConfigureGroundAudioSource(laytheCreatureObject.AddComponent<AudioSource>(), 3000f, 0.9f);
        StartCoroutine(LoadLaytheCreatureSound("daviddumaisaudio-robot-heavy-mechanical-footsteps-194039.mp3", laytheCreatureFootstepsAudio, true));
        StartCoroutine(LoadLaytheCreatureSound("u_kse9ncnirq-reversed-monster-screams-or-roars-angry-425881.mp3", laytheCreatureScreamAudio, false));
        laytheCreatureActive = true;
        laytheCreatureChasing = false;
        laytheCreatureAnimationPhase = UnityEngine.Random.Range(0f, Mathf.PI * 2f);
        laytheCreatureStatus = "DORMANT";
    }

    private void UpdateLaytheCreature()
    {
        if (!laytheCreatureActive || laytheCreatureObject == null || laytheCreatureBody == null)
            return;
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || vessel.mainBody != laytheCreatureBody || vessel.Splashed)
        {
            DespawnLaytheCreature();
            return;
        }
        Vector3d entityPosition = laytheCreatureBody.position + laytheCreatureRelativePosition;
        Vector3d toVessel = vessel.GetWorldPos3D() - entityPosition;
        double distance = toVessel.magnitude;
        bool vesselOnGround = vessel.heightFromTerrain < GroundIgnoreAltitudeMetres;
        if (!laytheCreatureChasing && vesselOnGround && distance <= LaytheCreatureActivationMetres &&
            HasGroundLineOfSight(entityPosition, vessel.GetWorldPos3D(), laytheCreatureBody))
            laytheCreatureChasing = true;
        if (laytheCreatureChasing && distance <= LaytheCreatureArrivalDistanceMetres)
        {
            DespawnLaytheCreature();
            HandleEntityCatch(vessel);
            return;
        }
        Vector3d radialUp = (entityPosition - laytheCreatureBody.position).normalized;
        if (laytheCreatureChasing && vesselOnGround && Time.timeScale > 0f)
        {
            Vector3d surfaceDirection = toVessel - radialUp * Vector3d.Dot(toVessel, radialUp);
            if (surfaceDirection.sqrMagnitude > 1d)
            {
                entityPosition += surfaceDirection.normalized * GetGroundEntitySpeed(LaytheCreatureChaseSpeedMetresPerSecond) * TimeWarp.deltaTime;
                entityPosition = ProjectToGround(entityPosition, laytheCreatureBody);
                laytheCreatureRelativePosition = entityPosition - laytheCreatureBody.position;
                radialUp = (entityPosition - laytheCreatureBody.position).normalized;
            }
            laytheCreatureStatus = "CHASING";
            laytheCreatureAnimationPhase += TimeWarp.deltaTime * 9f;
        }
        else if (laytheCreatureChasing)
            laytheCreatureStatus = "CANNOT REACH";
        else if (vesselOnGround && Time.timeScale > 0f)
        {
            // It wanders toward the player's area rather than selecting a
            // wholly random patrol direction.  The sideways sway keeps it
            // from looking like a distant slow-motion chase.
            Vector3d surfaceDirection = toVessel - radialUp * Vector3d.Dot(toVessel, radialUp);
            if (distance > 400d && surfaceDirection.sqrMagnitude > 1d)
            {
                Vector3d forward = surfaceDirection.normalized;
                Vector3d side = Vector3d.Cross(radialUp, forward).normalized;
                float sway = Mathf.Sin(laytheCreatureAnimationPhase * 0.55f) * 0.3f;
                entityPosition += (forward + side * sway).normalized * LaytheCreatureRoamSpeedMetresPerSecond * TimeWarp.deltaTime;
                entityPosition = ProjectToGround(entityPosition, laytheCreatureBody);
                laytheCreatureRelativePosition = entityPosition - laytheCreatureBody.position;
                radialUp = (entityPosition - laytheCreatureBody.position).normalized;
                toVessel = vessel.GetWorldPos3D() - entityPosition;
            }
            laytheCreatureAnimationPhase += TimeWarp.deltaTime * 2.2f;
            laytheCreatureStatus = "ROAMING NEARBY";
        }
        Vector3d levelFacing = toVessel - radialUp * Vector3d.Dot(toVessel, radialUp);
        Vector3 facing = levelFacing.sqrMagnitude > 0.01d ? (Vector3)levelFacing.normalized : laytheCreatureObject.transform.forward;
        Quaternion rotation = Quaternion.LookRotation(facing, (Vector3)radialUp) * LaytheCreatureModelAxisCorrection;
        // Keep the body itself stable.  Only the rig's limb bones animate;
        // a whole-model tilt made the creature look as though it was falling
        // over rather than running.
        laytheCreatureObject.transform.rotation = rotation;
        laytheCreatureObject.transform.localScale = Vector3.one * LaytheCreatureVisualScale;
        laytheCreatureObject.transform.position = (Vector3)(entityPosition + radialUp * 0.02f);
        AlignVisualFeetToGround(laytheCreatureObject, entityPosition, (Vector3)radialUp, 0.02f);
        if (laytheCreatureBoneAnimation != null)
            laytheCreatureBoneAnimation.SetState(laytheCreatureChasing, !laytheCreatureChasing);
        PlayLaytheCreatureAnimation(laytheCreatureChasing ? "Walk_Object_5" : "Idle_Object_5");
        UpdateGroundAudioSource(laytheCreatureFootstepsAudio, laytheCreatureFootstepsLoaded, laytheCreatureChasing && Time.timeScale > 0f);
        UpdateGroundAudioSource(laytheCreatureScreamAudio, laytheCreatureScreamLoaded, laytheCreatureChasing && Time.timeScale > 0f);
    }

    private static void AlignVisualFeetToGround(GameObject visual, Vector3d groundPosition, Vector3 radialUp, float clearance)
    {
        if (visual == null) return;
        float lowest = float.MaxValue;
        foreach (Renderer renderer in visual.GetComponentsInChildren<Renderer>(true))
        {
            if (renderer == null || !renderer.enabled) continue;
            Bounds bounds = renderer.bounds;
            float minimum = Vector3.Dot(bounds.center, radialUp) - Mathf.Abs(radialUp.x) * bounds.extents.x - Mathf.Abs(radialUp.y) * bounds.extents.y - Mathf.Abs(radialUp.z) * bounds.extents.z;
            lowest = Mathf.Min(lowest, minimum);
        }
        if (lowest != float.MaxValue)
            visual.transform.position += radialUp * (Vector3.Dot((Vector3)groundPosition, radialUp) + clearance - lowest);
    }

    private void DespawnLaytheCreature()
    {
        if (laytheCreatureObject != null) Destroy(laytheCreatureObject);
        laytheCreatureObject = null;
        laytheCreatureBoneAnimation = null;
        laytheCreatureAnimation = null;
        laytheCreatureFootstepsAudio = null;
        laytheCreatureScreamAudio = null;
        laytheCreatureFootstepsLoaded = false;
        laytheCreatureScreamLoaded = false;
        laytheCreatureBody = null;
        laytheCreatureRelativePosition = Vector3d.zero;
        laytheCreatureActive = false;
        laytheCreatureChasing = false;
        laytheCreatureAnimationPhase = 0f;
        laytheCreatureStatus = "NONE";
    }

    private void PlayLaytheCreatureAnimation(string clipName)
    {
        if (laytheCreatureAnimation == null || laytheCreatureAnimation[clipName] == null || laytheCreatureAnimation.IsPlaying(clipName))
            return;
        laytheCreatureAnimation.CrossFade(clipName, 0.12f);
    }

    private System.Collections.IEnumerator LoadLaytheCreatureSound(string filename, AudioSource target, bool footsteps)
    {
        string path = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "Sounds", filename);
        if (!File.Exists(path) || target == null) yield break;
        WWW request = new WWW("file:///" + path.Replace("\\", "/"));
        yield return request;
        if (string.IsNullOrEmpty(request.error))
        {
            target.clip = request.GetAudioClip(false, false, AudioType.MPEG);
            if (footsteps) laytheCreatureFootstepsLoaded = target.clip != null;
            else laytheCreatureScreamLoaded = target.clip != null;
        }
    }

    private void SpawnSeaEntity()
    {
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (!CanSeaEntityTarget(vessel))
        {
            seaEntityStatus = "EVE OCEAN REQUIRED";
            return;
        }

        DespawnSeaEntity();
        seaEntityBody = vessel.mainBody;
        Vector3 radial = ((Vector3)(vessel.GetWorldPos3D() - seaEntityBody.position)).normalized;
        Vector3 direction = vessel.transform.forward - radial * Vector3.Dot(vessel.transform.forward, radial);
        if (direction.sqrMagnitude < 0.01f)
            direction = Vector3.Cross(radial, Vector3.right);
        direction.Normalize();
        Vector3d candidate = vessel.GetWorldPos3D() + (Vector3d)(direction * UnityEngine.Random.Range(SeaSpawnMinimumMetres, SeaSpawnMaximumMetres));
        Vector3d seaPosition = ProjectToEveSea(candidate, seaEntityBody);
        seaEntityObject = GameDatabase.Instance != null ? GameDatabase.Instance.GetModel(SeaModelPath) : null;
        if (seaEntityObject == null)
        {
            seaEntityBody = null;
            seaEntityStatus = "MODEL UNAVAILABLE";
            return;
        }

        seaEntityObject.name = "EntityTracker_EveSeaMonster";
        seaEntityObject.SetActive(true);
        seaEntityObject.transform.localScale = Vector3.one * SeaVisualScale;
        foreach (Transform transform in seaEntityObject.GetComponentsInChildren<Transform>(true))
        {
            transform.gameObject.SetActive(true);
            transform.gameObject.layer = 0;
        }
        foreach (Renderer renderer in seaEntityObject.GetComponentsInChildren<Renderer>(true))
            renderer.enabled = true;
        ConfigureLitEntityMaterials(seaEntityObject);
        foreach (Collider collider in seaEntityObject.GetComponentsInChildren<Collider>(true))
            Destroy(collider);
        foreach (Rigidbody body in seaEntityObject.GetComponentsInChildren<Rigidbody>(true))
            Destroy(body);

        seaEntityRelativePosition = seaPosition - seaEntityBody.position;
        seaEntityObject.transform.position = (Vector3)seaPosition;
        seaGrowlAudio = ConfigureGroundAudioSource(seaEntityObject.AddComponent<AudioSource>(), 2500f, 0.8f);
        StartCoroutine(LoadSeaGrowl());
        seaEntityActive = true;
        seaScienceAwarded = false;
        seaEntityChasing = false;
        seaAnimationPhase = UnityEngine.Random.Range(0f, Mathf.PI * 2f);
        seaEntityStatus = "DORMANT";
    }

    private void UpdateSeaEntity()
    {
        if (!seaEntityActive || seaEntityObject == null || seaEntityBody == null)
            return;
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null)
            return;
        // It may wait close to the surface, but land or low flight is safe:
        // only a splashed or submerged vessel is a valid target.
        if (!IsSeaEntityActiveRange(vessel))
        {
            DespawnSeaEntity();
            return;
        }

        bool canTarget = CanSeaEntityTarget(vessel);

        Vector3d entityPosition = seaEntityBody.position + seaEntityRelativePosition;
        Vector3d toVessel = vessel.GetWorldPos3D() - entityPosition;
        double distance = toVessel.magnitude;
        if (!canTarget)
        {
            seaEntityChasing = false;
            seaEntityStatus = "WAITING BELOW SURFACE";
        }
        else if (!seaEntityChasing && distance <= SeaActivationMetres)
            seaEntityChasing = true;
        if (canTarget && seaEntityChasing && distance <= SeaArrivalDistanceMetres)
        {
            DespawnSeaEntity();
            HandleEntityCatch(vessel);
            return;
        }

        Vector3d radial = (entityPosition - seaEntityBody.position).normalized;
        if (canTarget && seaEntityChasing && Time.timeScale > 0f)
        {
            Vector3d surfaceDirection = toVessel - radial * Vector3d.Dot(toVessel, radial);
            if (surfaceDirection.sqrMagnitude > 1d)
            {
                entityPosition += surfaceDirection.normalized * GetSeaEntitySpeed(SeaChaseSpeedMetresPerSecond) * TimeWarp.deltaTime;
                entityPosition = ProjectToEveSea(entityPosition, seaEntityBody);
                seaEntityRelativePosition = entityPosition - seaEntityBody.position;
            }
            seaEntityStatus = "CHASING";
            seaAnimationPhase += TimeWarp.deltaTime * 3f;
        }

        radial = (entityPosition - seaEntityBody.position).normalized;
        if (toVessel.sqrMagnitude > 0.01d)
        {
            Vector3 facing = (Vector3)toVessel;
            facing -= (Vector3)radial * Vector3.Dot(facing, (Vector3)radial);
            if (facing.sqrMagnitude > 0.01f)
                seaEntityObject.transform.rotation = Quaternion.LookRotation(facing.normalized, (Vector3)radial) * Quaternion.Euler(0f, 180f, 0f) * SeaModelAxisCorrection;
        }
        float swim = seaEntityChasing ? Mathf.Sin(seaAnimationPhase) * 0.14f : 0f;
        seaEntityObject.transform.position = (Vector3)(entityPosition + radial * (SeaVisualElevationMetres + swim));
        UpdateSeaGrowl();
    }

    private void DespawnSeaEntity()
    {
        if (seaEntityObject != null)
            Destroy(seaEntityObject);
        seaEntityObject = null;
        seaGrowlAudio = null;
        seaGrowlLoaded = false;
        seaEntityBody = null;
        seaEntityRelativePosition = Vector3d.zero;
        seaEntityActive = false;
        seaEntityChasing = false;
        seaAnimationPhase = 0f;
        seaEntityStatus = "NONE";
    }

    private void SpawnBloopEntity()
    {
        SpawnBloopEntity(false);
    }

    private void SpawnBloopEntity(bool debugForce)
    {
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || vessel.mainBody == null ||
            !string.Equals(vessel.mainBody.bodyName, "Laythe", StringComparison.OrdinalIgnoreCase))
        {
            bloopEntityStatus = "LAYTHE REQUIRED";
            return;
        }
        if (!debugForce && !CanBloopRemainActive(vessel))
        {
            bloopEntityStatus = "100 M DEPTH REQUIRED";
            return;
        }

        DespawnBloopEntity();
        bloopEntityBody = vessel.mainBody;
        Vector3 radial = ((Vector3)(vessel.GetWorldPos3D() - bloopEntityBody.position)).normalized;
        Vector3 direction = vessel.transform.forward - radial * Vector3.Dot(vessel.transform.forward, radial);
        if (direction.sqrMagnitude < 0.01f)
            direction = Vector3.Cross(radial, Vector3.right);
        direction.Normalize();

        Vector3d candidate = vessel.GetWorldPos3D() + (Vector3d)(direction * UnityEngine.Random.Range(BloopSpawnMinimumMetres, BloopSpawnMaximumMetres));
        Vector3d bloopPosition = ProjectToBloopSea(candidate, bloopEntityBody);
        bloopEntityObject = GameDatabase.Instance != null ? GameDatabase.Instance.GetModel(BloopModelPath) : null;
        if (bloopEntityObject == null)
        {
            bloopEntityBody = null;
            bloopEntityStatus = "MODEL UNAVAILABLE";
            return;
        }

        bloopEntityObject.name = "EntityTracker_LaytheBloop";
        bloopEntityObject.SetActive(true);
        bloopEntityObject.transform.localScale = Vector3.one * BloopVisualScale;
        foreach (Transform transform in bloopEntityObject.GetComponentsInChildren<Transform>(true))
        {
            transform.gameObject.SetActive(true);
            transform.gameObject.layer = 0;
        }
        foreach (Renderer renderer in bloopEntityObject.GetComponentsInChildren<Renderer>(true))
            renderer.enabled = true;
        ConfigureLitEntityMaterials(bloopEntityObject);
        foreach (Collider collider in bloopEntityObject.GetComponentsInChildren<Collider>(true))
            Destroy(collider);
        foreach (Rigidbody body in bloopEntityObject.GetComponentsInChildren<Rigidbody>(true))
            Destroy(body);

        bloopEntityRelativePosition = bloopPosition - bloopEntityBody.position;
        bloopEntityObject.transform.position = (Vector3)bloopPosition;
        bloopBurrowAudio = ConfigureGroundAudioSource(bloopEntityObject.AddComponent<AudioSource>(), 6000f, 0.9f);
        bloopBurrowAudio.loop = false;
        StartCoroutine(LoadBloopBurrowSound());
        bloopEntityActive = true;
        bloopEntityChasing = false;
        bloopAnimationPhase = UnityEngine.Random.Range(0f, Mathf.PI * 2f);
        bloopBurrowTimer = UnityEngine.Random.Range(10f, 24f);
        bloopCloseCuePlayed = false;
        bloopEntityStatus = "DORMANT";
    }

    private void UpdateBloopEntity()
    {
        if (!bloopEntityActive || bloopEntityObject == null || bloopEntityBody == null)
            return;
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || vessel.mainBody != bloopEntityBody)
        {
            DespawnBloopEntity();
            return;
        }

        Vector3d entityPosition = bloopEntityBody.position + bloopEntityRelativePosition;
        Vector3d toVessel = vessel.GetWorldPos3D() - entityPosition;
        double distance = toVessel.magnitude;
        bool canTarget = CanBloopTarget(vessel);
        if (!canTarget)
        {
            bloopEntityChasing = false;
            bloopEntityStatus = "WAITING BELOW -100 M";
        }
        else if (!bloopEntityChasing && distance <= BloopActivationMetres)
        {
            bloopEntityChasing = true;
        }

        if (canTarget && bloopEntityChasing && distance <= BloopArrivalDistanceMetres)
        {
            DespawnBloopEntity();
            HandleEntityCatch(vessel);
            return;
        }

        Vector3d radial = (entityPosition - bloopEntityBody.position).normalized;
        if (canTarget && bloopEntityChasing && Time.timeScale > 0f)
        {
            // At close range, permit a direct underwater lunge. Keeping the
            // creature permanently on its -70 m depth plane caused it to
            // stall beside deeper vessels instead of completing the catch.
            if (distance <= BloopCloseLungeDistanceMetres && toVessel.sqrMagnitude > 1d)
            {
                entityPosition += toVessel.normalized * GetSeaEntitySpeed(BloopChaseSpeedMetresPerSecond) * TimeWarp.deltaTime;
            }
            else
            {
                Vector3d surfaceDirection = toVessel - radial * Vector3d.Dot(toVessel, radial);
                if (surfaceDirection.sqrMagnitude > 1d)
                {
                    entityPosition += surfaceDirection.normalized * GetSeaEntitySpeed(BloopChaseSpeedMetresPerSecond) * TimeWarp.deltaTime;
                    entityPosition = ProjectToBloopSea(entityPosition, bloopEntityBody);
                }
            }
            bloopEntityRelativePosition = entityPosition - bloopEntityBody.position;
            bloopEntityStatus = "CHASING";
            bloopAnimationPhase += TimeWarp.deltaTime * 0.65f;
        }

        radial = (entityPosition - bloopEntityBody.position).normalized;
        if (toVessel.sqrMagnitude > 0.01d)
        {
            Vector3 facing = (Vector3)toVessel;
            facing -= (Vector3)radial * Vector3.Dot(facing, (Vector3)radial);
            if (facing.sqrMagnitude > 0.01f)
                // The model needs two separate corrections: a counterclockwise
                // roll to orient its width/height correctly, then a clockwise
                // horizontal quarter-turn so its nose points into its movement.
                bloopEntityObject.transform.rotation = Quaternion.LookRotation(facing.normalized, (Vector3)radial) * Quaternion.Euler(0f, 180f, 0f) * BloopModelAxisCorrection * Quaternion.Euler(0f, 90f, 0f) * Quaternion.Euler(0f, 0f, -90f);
        }
        float swim = bloopEntityChasing ? Mathf.Sin(bloopAnimationPhase) * 2f : 0f;
        PositionBloopVisual(entityPosition, (Vector3)radial, swim);
        UpdateBloopBurrowSound(distance);
    }

    // Some imported GLB hierarchies retain a hidden local offset even after
    // conversion. Centre the actual renderer bounds on the logical Bloop
    // coordinate every frame, keeping the mesh exactly where its radar point
    // and chase logic say it is.
    private void PositionBloopVisual(Vector3d logicalPosition, Vector3 radialUp, float swim)
    {
        if (bloopEntityObject == null)
            return;
        Vector3 targetCentre = (Vector3)(logicalPosition + radialUp * (BloopVisualElevationMetres + swim));
        bloopEntityObject.transform.position = targetCentre;

        Renderer[] renderers = bloopEntityObject.GetComponentsInChildren<Renderer>(true);
        if (renderers.Length == 0)
            return;
        Bounds combinedBounds = renderers[0].bounds;
        for (int i = 1; i < renderers.Length; i++)
        {
            if (renderers[i] != null)
                combinedBounds.Encapsulate(renderers[i].bounds);
        }
        bloopEntityObject.transform.position += targetCentre - combinedBounds.center;
    }

    private void DespawnBloopEntity()
    {
        if (bloopEntityObject != null)
            Destroy(bloopEntityObject);
        bloopEntityObject = null;
        bloopBurrowAudio = null;
        bloopBurrowLoaded = false;
        bloopBurrowTimer = 0f;
        bloopCloseCuePlayed = false;
        bloopEntityBody = null;
        bloopEntityRelativePosition = Vector3d.zero;
        bloopEntityActive = false;
        bloopEntityChasing = false;
        bloopAnimationPhase = 0f;
        bloopEntityStatus = "NONE";
    }

    // Ground/sea model entities are physical threats: explode every attached
    // part so an EVA Kerbal goes KIA through KSP's normal part-death path and
    // a full craft does not survive as loose root-only debris. The black/red
    // box retains its separate main-menu arrival logic.
    private void HandleEntityCatch(Vessel vessel)
    {
        if (debugImmortality)
            return;
        DestroyCaughtVessel(vessel);
    }

    private void TeleportToActiveMonster()
    {
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null)
            return;

        Vector3d destination;
        if (eyeEntityActive)
            destination = eyeEntityPosition;
        else if (bloopEntityActive && bloopEntityBody != null)
            destination = bloopEntityBody.position + bloopEntityRelativePosition;
        else if (seaEntityActive && seaEntityBody != null)
            destination = seaEntityBody.position + seaEntityRelativePosition;
        else if (bulkEntityActive && bulkEntityBody != null)
            destination = bulkEntityBody.position + bulkEntityRelativePosition;
        else if (groundEntityActive && groundEntityBody != null)
            destination = groundEntityBody.position + groundEntityRelativePosition;
        else if (rakeEntityActive && rakeEntityBody != null)
            destination = rakeEntityBody.position + rakeEntityRelativePosition;
        else
            return;

        vessel.SetPosition(destination);
    }

    private static void DestroyCaughtVessel(Vessel vessel)
    {
        if (vessel == null)
            return;
        if (vessel.parts == null || vessel.parts.Count == 0)
        {
            vessel.Die();
            return;
        }

        List<Part> partsToDestroy = new List<Part>(vessel.parts);
        foreach (Part part in partsToDestroy)
        {
            if (part != null)
                part.explode();
        }
    }

    // The Eye is a Jool-system omen, not part of the normal random encounter
    // pool. It gets exactly one spawn roll when the active vessel enters Jool's
    // SOI, and it only becomes dangerous if the player proceeds into Bop's.
    private void UpdateEyeEntity()
    {
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || vessel.mainBody == null)
            return;

        CelestialBody currentBody = vessel.mainBody;
        string bodyName = currentBody.bodyName ?? string.Empty;
        bool enteredNewSoi = eyeLastObservedBody != null && eyeLastObservedBody != currentBody;
        if (!eyeEntityActive && enteredNewSoi && string.Equals(bodyName, "Jool", StringComparison.OrdinalIgnoreCase) &&
            UnityEngine.Random.value <= 0.25f)
            SpawnEyeEntity(vessel);
        eyeLastObservedBody = currentBody;

        if (!eyeEntityActive)
            return;

        // Any moon other than Bop, or leaving Jool altogether, safely ends it.
        if (!string.Equals(bodyName, "Jool", StringComparison.OrdinalIgnoreCase) &&
            !string.Equals(bodyName, "Bop", StringComparison.OrdinalIgnoreCase))
        {
            DespawnEyeEntity();
            return;
        }

        if (string.Equals(bodyName, "Bop", StringComparison.OrdinalIgnoreCase) && !eyeEntityChasing)
        {
            eyeEntityChasing = true;
            // Capture the vessel's orbit at the instant Bop is entered. From
            // this point the Eye owns a separate position/velocity bubble;
            // later burns do not get copied into it.
            eyeChaseReferenceBody = vessel.mainBody;
            eyeChaseRelativePosition = eyeEntityPosition - eyeChaseReferenceBody.position;
            // GetVel is expressed in this orbit's local (Bop-centred) frame.
            // obt_velocity can include parent-body motion in some KSP flight
            // states, which would otherwise launch the Eye away at Bop's own
            // orbital speed a second time.
            eyeChaseBaseVelocity = vessel.orbit != null ? vessel.orbit.GetVel() : Vector3d.zero;
            // The supplied scream is a one-time Bop threshold warning. The
            // Mimic scream remains the separate looping chase placeholder.
            if (eyeWarningAudio != null && eyeWarningSoundLoaded)
                eyeWarningAudio.Play();
        }

        if (eyeEntityChasing)
        {
            if (Time.timeScale > 0f)
                eyeChaseTimer += TimeWarp.deltaTime;
            // 1 m/s at the instant it notices Bop, growing to 1 km/s over the
            // next ten seconds. This is exponential growth, capped at 1 km/s.
            float chaseSpeed = Mathf.Min(1000f, Mathf.Pow(1000f, Mathf.Min(eyeChaseTimer, 10f) / 10f)) * Mathf.Clamp(eyeSpeedMultiplier, 0.05f, 20f);
            UpdateEyeChaseOrbit(vessel, chaseSpeed);
            if (eyeChaseAudio != null && eyeChaseSoundLoaded && Time.timeScale > 0f && !eyeChaseAudio.isPlaying)
                eyeChaseAudio.Play();

            if ((eyeEntityPosition - vessel.GetWorldPos3D()).magnitude <= 3d)
            {
                DespawnEyeEntity();
                if (vessel.isEVA)
                    HighLogic.LoadScene(GameScenes.MAINMENU);
                else
                    DestroyCaughtVessel(vessel);
            }
            UpdateEyeBillboard();
            return;
        }

        // While watching, it remains locked to a nearby offset from the active
        // vessel. Its old finite-speed correction visibly lagged behind every
        // manoeuvre, which made the billboard jitter and trail the craft.
        // The small distance drift keeps it from feeling perfectly static.
        float desiredDistance = 50f + Mathf.Sin(Time.realtimeSinceStartup * 0.45f) * 12f;
        eyeEntityPosition = vessel.GetWorldPos3D() + eyeWatchDirection * desiredDistance;
        UpdateEyeBillboard();
    }

    private void SpawnEyeEntity(Vessel vessel)
    {
        if (vessel == null)
            return;
        eyeWatchDirection = UnityEngine.Random.onUnitSphere;
        if (eyeWatchDirection.sqrMagnitude < 0.01d)
            eyeWatchDirection = vessel.transform.right;
        eyeWatchDirection.Normalize();
        eyeEntityPosition = vessel.GetWorldPos3D() + eyeWatchDirection * 50d;
        eyeEntityActive = true;
        eyeEntityChasing = false;
        eyeChaseTimer = 0f;
        eyeWarningTextTimer = 10f;
    }

    private void ForceSpawnEyeEntity()
    {
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || vessel.mainBody == null)
            return;

        string bodyName = vessel.mainBody.bodyName ?? string.Empty;
        if (!string.Equals(bodyName, "Jool", StringComparison.OrdinalIgnoreCase) &&
            !string.Equals(bodyName, "Bop", StringComparison.OrdinalIgnoreCase))
        {
            ScreenMessages.PostScreenMessage("The Jool Eye can only be forced in Jool's or Bop's SOI.", 4f, ScreenMessageStyle.UPPER_CENTER);
            return;
        }

        DespawnEyeEntity();
        SpawnEyeEntity(vessel);
        eyeLastObservedBody = vessel.mainBody;
    }

    private void MoveEyeToward(Vector3d target, float speed)
    {
        if (Time.timeScale <= 0f)
            return;
        Vector3d offset = target - eyeEntityPosition;
        double distance = offset.magnitude;
        if (distance <= 0.01d)
            return;
        eyeEntityPosition += offset.normalized * Math.Min(distance, speed * TimeWarp.deltaTime);
    }

    private void UpdateEyeChaseOrbit(Vessel vessel, float chaseSpeed)
    {
        if (vessel == null || eyeChaseReferenceBody == null || Time.timeScale <= 0f)
            return;

        // Follow the reference body's path through the solar system while
        // propagating the Eye's own Bop-relative orbital velocity. This
        // deliberately does not inherit any velocity the player gains after
        // the chase starts, making a later burn a real escape attempt.
        eyeEntityPosition = eyeChaseReferenceBody.position + eyeChaseRelativePosition;
        Vector3d toVessel = vessel.GetWorldPos3D() - eyeEntityPosition;
        Vector3d pursuitVelocity = toVessel.sqrMagnitude > 0.01d
            ? toVessel.normalized * chaseSpeed
            : Vector3d.zero;
        double radiusSquared = eyeChaseRelativePosition.sqrMagnitude;
        if (radiusSquared > 1d)
        {
            Vector3d gravity = -eyeChaseRelativePosition.normalized *
                               (eyeChaseReferenceBody.gravParameter / radiusSquared);
            eyeChaseBaseVelocity += gravity * TimeWarp.deltaTime;
        }
        eyeChaseRelativePosition += (eyeChaseBaseVelocity + pursuitVelocity) * TimeWarp.deltaTime;
        eyeEntityPosition = eyeChaseReferenceBody.position + eyeChaseRelativePosition;
    }

    private void DespawnEyeEntity()
    {
        eyeEntityActive = false;
        eyeEntityChasing = false;
        eyeChaseTimer = 0f;
        eyeWarningTextTimer = 0f;
        eyeEntityPosition = Vector3d.zero;
        eyeChaseReferenceBody = null;
        eyeChaseRelativePosition = Vector3d.zero;
        eyeChaseBaseVelocity = Vector3d.zero;
        if (eyeWarningAudio != null)
            eyeWarningAudio.Stop();
        if (eyeChaseAudio != null)
            eyeChaseAudio.Stop();
        if (eyeBillboardObject != null)
            Destroy(eyeBillboardObject);
        eyeBillboardObject = null;
        eyeBillboardMaterial = null;
    }

    private static Vector3d ProjectToEveSea(Vector3d worldPosition, CelestialBody body)
    {
        double latitude = body.GetLatitude(worldPosition);
        double longitude = body.GetLongitude(worldPosition);
        // Keep the creature permanently below datum sea level. It never moves
        // onto land or rises above the Explodium ocean surface.
        return body.GetWorldSurfacePosition(latitude, longitude, -SeaDepthMetres);
    }

    private static bool IsSeaEntityActiveRange(Vessel vessel)
    {
        return vessel != null && vessel.mainBody != null && vessel.altitude <= 30d &&
               string.Equals(vessel.mainBody.bodyName, "Eve", StringComparison.OrdinalIgnoreCase);
    }

    private static bool CanSeaEntityTarget(Vessel vessel)
    {
        return IsSeaEntityActiveRange(vessel) && (vessel.Splashed || vessel.altitude < -0.1d);
    }

    private static Vector3d ProjectToBloopSea(Vector3d worldPosition, CelestialBody body)
    {
        double latitude = body.GetLatitude(worldPosition);
        double longitude = body.GetLongitude(worldPosition);
        return body.GetWorldSurfacePosition(latitude, longitude, -BloopDepthMetres);
    }

    private static bool CanBloopRemainActive(Vessel vessel)
    {
        return vessel != null && vessel.mainBody != null && vessel.altitude <= -100d &&
               string.Equals(vessel.mainBody.bodyName, "Laythe", StringComparison.OrdinalIgnoreCase);
    }

    private static bool CanBloopTarget(Vessel vessel)
    {
        // Bloop lives in genuinely deep water: it ignores vessels at the
        // surface and only hunts once they are at least 100 m underwater.
        return CanBloopRemainActive(vessel);
    }

    private void SpawnRakeEntity()
    {
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (!IsEligibleForRakeEncounter(vessel))
        {
            rakeEntityStatus = "NOT ON AN ELIGIBLE BODY";
            return;
        }

        DespawnRakeEntity();
        rakeEntityBody = vessel.mainBody;
        Vector3d groundPosition;
        if (!TryFindLandSpawnPosition(vessel, rakeEntityBody, RakeSpawnMinimumMetres, RakeSpawnMaximumMetres, out groundPosition))
        {
            rakeEntityBody = null;
            rakeEntityStatus = "NO LAND SPAWN POSITION";
            return;
        }
        rakeEntityObject = GameDatabase.Instance != null ? GameDatabase.Instance.GetModel(RakeModelPath) : null;
        if (rakeEntityObject == null)
        {
            rakeEntityBody = null;
            rakeEntityStatus = "MODEL UNAVAILABLE";
            return;
        }

        rakeEntityObject.name = "EntityTracker_Rake";
        rakeEntityObject.SetActive(true);
        rakeEntityObject.transform.localScale = Vector3.one * 1.2f;
        foreach (Transform transform in rakeEntityObject.GetComponentsInChildren<Transform>(true))
        {
            transform.gameObject.SetActive(true);
            transform.gameObject.layer = 0;
        }
        foreach (Renderer renderer in rakeEntityObject.GetComponentsInChildren<Renderer>(true))
            renderer.enabled = true;
        foreach (Collider collider in rakeEntityObject.GetComponentsInChildren<Collider>(true))
            Destroy(collider);
        foreach (Rigidbody body in rakeEntityObject.GetComponentsInChildren<Rigidbody>(true))
            Destroy(body);

        rakeEntityRelativePosition = groundPosition - rakeEntityBody.position;
        rakeEntityObject.transform.position = (Vector3)groundPosition;
        rakeEntityAnimation = rakeEntityObject.AddComponent<RakeEntityAnimation>();
        rakeEntityAnimation.Configure(rakeEntityObject);
        rakeBoneAnimation = rakeEntityObject.AddComponent<RakeBoneAnimation>();
        rakeBoneAnimation.Configure(rakeEntityObject);
        rakeChaseAudio = ConfigureGroundAudioSource(rakeEntityObject.AddComponent<AudioSource>(), 4000f, 1f);
        rakeChaseAudio.loop = true;
        StartCoroutine(LoadRakeChaseSound());
        rakeEntityActive = true;
        rakeEntityChasing = false;
        rakeEntityStandingDown = false;
        rakeEntityRetreating = false;
        rakeWithdrawalTimer = 0f;
        rakeChaseSurvivalTimer = 0f;
        rakeEntityVisible = false;
        rakeObservationTimer = 0f;
        rakeAnimationPhase = UnityEngine.Random.Range(0f, Mathf.PI * 2f);
        rakeEntityStatus = "WATCHING";
    }

    private static bool IsEligibleForRakeEncounter(Vessel vessel)
    {
        if (vessel == null || vessel.mainBody == null || vessel.Splashed || vessel.heightFromTerrain >= GroundIgnoreAltitudeMetres)
            return false;
        string name = vessel.mainBody.bodyName;
        if (string.Equals(name, "Kerbin", StringComparison.OrdinalIgnoreCase) ||
            string.Equals(name, "Mun", StringComparison.OrdinalIgnoreCase))
            return false;
        // Jool has no land surface. Every other terrain-bearing major body and
        // moon is eligible, except the two deliberate exclusions above.
        return vessel.mainBody.pqsController != null;
    }

    private bool IsRakeVisibleToPlayer(Vessel vessel)
    {
        Camera camera = FlightCamera.fetch != null ? FlightCamera.fetch.mainCamera : Camera.main;
        if (camera == null || rakeEntityObject == null)
            return false;

        Vector3 radialUp = ((Vector3)(rakeEntityObject.transform.position - rakeEntityBody.position)).normalized;
        // The object root sits below the terrain so the model's feet are at
        // ground level; inspect the actual body instead of that buried root.
        Vector3 target = rakeEntityObject.transform.position + radialUp * (RakeModelRootBelowFeetMetres + 1.1f);
        Vector3 viewport = camera.WorldToViewportPoint(target);
        if (viewport.z <= 0f || viewport.x < 0f || viewport.x > 1f || viewport.y < 0f || viewport.y > 1f)
            return false;

        Vector3 direction = target - camera.transform.position;
        float distance = direction.magnitude;
        if (distance < 0.01f)
            return true;

        RaycastHit[] hits = Physics.RaycastAll(camera.transform.position, direction / distance, distance, ~0, QueryTriggerInteraction.Ignore);
        foreach (RaycastHit hit in hits)
        {
            Transform hitTransform = hit.collider != null ? hit.collider.transform : null;
            if (hitTransform == null)
                continue;
            // Ignore the player's own vessel and the collider-free Rake root;
            // terrain or another object in the ray genuinely blocks sight.
            if (vessel != null && hitTransform.IsChildOf(vessel.transform))
                continue;
            if (rakeEntityObject != null && hitTransform.IsChildOf(rakeEntityObject.transform))
                continue;
            return false;
        }
        return true;
    }

    private void UpdateRakeEntity()
    {
        if (!rakeEntityActive || rakeEntityObject == null || rakeEntityBody == null)
            return;
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null)
            return;

        Vector3d entityPosition = rakeEntityBody.position + rakeEntityRelativePosition;
        Vector3d toVessel = vessel.GetWorldPos3D() - entityPosition;
        double distance = toVessel.magnitude;
        bool vesselOnGround = vessel.heightFromTerrain < GroundIgnoreAltitudeMetres;
        if (!rakeEntityChasing && !rakeEntityStandingDown && !rakeEntityRetreating && vesselOnGround &&
            distance <= RakeActivationMetres && HasGroundLineOfSight(entityPosition, vessel.GetWorldPos3D(), rakeEntityBody))
        {
            rakeEntityChasing = true;
            rakeChaseSoundRequested = true;
            PlayRakeChaseSoundIfReady();
        }

        rakeEntityVisible = rakeEntityChasing && !rakeEntityStandingDown && !rakeEntityRetreating && IsRakeVisibleToPlayer(vessel);
        if (rakeEntityVisible)
        {
            rakeObservationTimer += TimeWarp.deltaTime;
            if (rakeObservationTimer >= RakeObservationEscapeSeconds)
            {
                rakeEntityStandingDown = true;
                rakeEntityVisible = false;
                rakeWithdrawalTimer = WithdrawalStandSeconds;
            }
        }
        else if (!rakeEntityRetreating && !rakeEntityStandingDown)
        {
            rakeObservationTimer = 0f;
        }

        if (rakeEntityChasing && !rakeEntityStandingDown && !rakeEntityRetreating && !rakeEntityVisible && distance <= RakeArrivalDistanceMetres)
        {
            DespawnRakeEntity();
            HandleEntityCatch(vessel);
            return;
        }

        Vector3d radial = (entityPosition - rakeEntityBody.position).normalized;
        // The timed withdrawal belongs to the Kerbal mimic, not the Rake.
        if (false && rakeEntityChasing && !rakeEntityStandingDown && !rakeEntityRetreating)
        {
            rakeChaseSurvivalTimer += TimeWarp.deltaTime;
            if (rakeChaseSurvivalTimer >= 120f)
            {
                rakeEntityStandingDown = true;
                rakeWithdrawalTimer = WithdrawalStandSeconds;
            }
        }

        if (rakeEntityStandingDown)
        {
            rakeWithdrawalTimer -= TimeWarp.deltaTime;
            rakeEntityStatus = "STANDING STILL";
            if (rakeWithdrawalTimer <= 0f)
            {
                rakeEntityStandingDown = false;
                rakeEntityRetreating = true;
                rakeWithdrawalTimer = WithdrawalFleeSeconds;
            }
        }
        else if (rakeEntityRetreating)
        {
            rakeWithdrawalTimer -= TimeWarp.deltaTime;
            if (vesselOnGround && Time.timeScale > 0f)
            {
                Vector3d away = -toVessel - radial * Vector3d.Dot(-toVessel, radial);
                if (away.sqrMagnitude > 1d)
                {
                    entityPosition += away.normalized * GetGroundEntitySpeed(RakeChaseSpeedMetresPerSecond) * TimeWarp.deltaTime;
                    entityPosition = ProjectToGround(entityPosition, rakeEntityBody);
                    rakeEntityRelativePosition = entityPosition - rakeEntityBody.position;
                }
            }
            rakeEntityStatus = "RETREATING";
            if (rakeWithdrawalTimer <= 0f)
            {
                DespawnRakeEntity();
                return;
            }
        }
        else if (rakeEntityVisible)
        {
            rakeEntityStatus = "FROZEN - WATCHING";
        }
        else if (rakeEntityChasing && vesselOnGround && Time.timeScale > 0f)
        {
            if (distance <= 20d)
                entityPosition += toVessel.normalized * GetGroundEntitySpeed(RakeChaseSpeedMetresPerSecond) * TimeWarp.deltaTime;
            else
            {
                Vector3d surfaceDirection = toVessel - radial * Vector3d.Dot(toVessel, radial);
                if (surfaceDirection.sqrMagnitude > 1d)
                {
                    entityPosition += surfaceDirection.normalized * GetGroundEntitySpeed(RakeChaseSpeedMetresPerSecond) * TimeWarp.deltaTime;
                    entityPosition = ProjectToGround(entityPosition, rakeEntityBody);
                }
            }
            rakeEntityRelativePosition = entityPosition - rakeEntityBody.position;
            rakeEntityStatus = "CHASING";
        }
        else if (rakeEntityChasing)
        {
            rakeEntityStatus = "CANNOT REACH";
        }

        bool activelyChasing = rakeEntityChasing && !rakeEntityStandingDown && !rakeEntityRetreating && !rakeEntityVisible;
        rakeAnimationPhase += activelyChasing ? TimeWarp.deltaTime * 11f : 0f;
        float lurch = activelyChasing ? Mathf.Abs(Mathf.Sin(rakeAnimationPhase)) * 0.22f : 0f;
        // the GLB's root is 5.47 m below its feet before our 1.2x visual
        // scale; keep that root underground while the rendered body stands
        // directly on the sampled terrain.
        rakeEntityObject.transform.position = (Vector3)(entityPosition + radial * (lurch - RakeModelRootBelowFeetMetres));
        if (toVessel.sqrMagnitude > 0.01d)
        {
            Vector3d facingDirection = rakeEntityRetreating ? -toVessel : toVessel;
            facingDirection -= radial * Vector3d.Dot(facingDirection, radial);
            if (facingDirection.sqrMagnitude > 0.01d)
            {
                Quaternion facing = Quaternion.LookRotation((Vector3)facingDirection.normalized, (Vector3)radial) * Quaternion.Euler(0f, 180f, 0f) * RakeModelAxisCorrection;
                if (activelyChasing)
                    facing *= Quaternion.Euler(3f * Mathf.Sin(rakeAnimationPhase * 2f), 0f, 5f * Mathf.Sin(rakeAnimationPhase));
                rakeEntityObject.transform.rotation = facing;
            }
        }
        if (rakeEntityAnimation != null)
            rakeEntityAnimation.SetChasing(activelyChasing);
        if (rakeBoneAnimation != null)
            rakeBoneAnimation.SetChasing(activelyChasing);
        UpdateRakeChaseSound();
    }

    private void DespawnRakeEntity()
    {
        if (rakeEntityObject != null)
            Destroy(rakeEntityObject);
        rakeEntityObject = null;
        rakeEntityAnimation = null;
        rakeBoneAnimation = null;
        rakeChaseAudio = null;
        rakeChaseSoundLoaded = false;
        rakeChaseSoundRequested = false;
        rakeEntityBody = null;
        rakeEntityRelativePosition = Vector3d.zero;
        rakeEntityActive = false;
        rakeEntityChasing = false;
        rakeEntityStandingDown = false;
        rakeEntityRetreating = false;
        rakeWithdrawalTimer = 0f;
        rakeChaseSurvivalTimer = 0f;
        rakeEntityVisible = false;
        rakeObservationTimer = 0f;
        rakeAnimationPhase = 0f;
        rakeEntityStatus = "NONE";
    }

    private static Vector3d ProjectToGround(Vector3d worldPosition, CelestialBody body)
    {
        double latitude = body.GetLatitude(worldPosition);
        double longitude = body.GetLongitude(worldPosition);
        double terrainAltitude = body.TerrainAltitude(latitude, longitude);
        // Keep the visual's pivot clear of uneven terrain so it never sinks
        // into hills, rocks, or the terrain collider while chasing.
        // Use KSP's world-space helper directly.  Hand-converting the relative
        // surface vector breaks under floating-origin/RSS coordinate frames.
        Vector3d sampledSurface = body.GetWorldSurfacePosition(latitude, longitude, terrainAltitude + GroundVisualClearanceMetres);

        // TerrainAltitude is a broad PQS sample. Near the player KSP also has
        // a finer collider mesh which can differ enough to bury visual-only
        // entities. Raycast that local mesh and prefer its exact surface.
        Vector3 outward = ((Vector3)(sampledSurface - body.position)).normalized;
        Vector3 rayOrigin = (Vector3)sampledSurface + outward * 500f;
        RaycastHit[] hits = Physics.RaycastAll(rayOrigin, -outward, 1000f, ~0, QueryTriggerInteraction.Ignore);
        RaycastHit closest = new RaycastHit();
        bool foundSurface = false;
        float closestDistance = float.MaxValue;
        foreach (RaycastHit hit in hits)
        {
            if (hit.collider == null || hit.distance >= closestDistance)
                continue;
            closest = hit;
            closestDistance = hit.distance;
            foundSurface = true;
        }
        if (foundSurface)
        {
            Vector3 preciseOutward = (closest.point - (Vector3)body.position).normalized;
            return (Vector3d)(closest.point + preciseOutward * GroundVisualClearanceMetres);
        }
        return sampledSurface;
    }

    private static bool TryFindLandSpawnPosition(Vessel vessel, CelestialBody body, float minimumDistance, float maximumDistance, out Vector3d groundPosition)
    {
        groundPosition = Vector3d.zero;
        if (vessel == null || body == null)
            return false;
        for (int attempt = 0; attempt < 32; attempt++)
        {
            Vector3 direction = (vessel.transform.forward * UnityEngine.Random.Range(-1f, 1f)
                               + vessel.transform.right * UnityEngine.Random.Range(-1f, 1f)).normalized;
            if (direction.sqrMagnitude < 0.01f)
                direction = vessel.transform.forward.normalized;
            Vector3d candidate = vessel.GetWorldPos3D() + (Vector3d)(direction * UnityEngine.Random.Range(minimumDistance, maximumDistance));
            Vector3d projected = ProjectToGround(candidate, body);
            if (IsLandAt(projected, body) && (projected - vessel.GetWorldPos3D()).magnitude >= minimumDistance)
            {
                groundPosition = projected;
                return true;
            }
        }
        return false;
    }

    private static bool IsLandAt(Vector3d worldPosition, CelestialBody body)
    {
        if (body == null || !body.ocean)
            return true;
        double latitude = body.GetLatitude(worldPosition);
        double longitude = body.GetLongitude(worldPosition);
        // Ocean cells lie at or below the body's sea-level datum. Rejecting
        // them forces land entities to reroll instead of materialising below
        // water, while still allowing genuinely low but dry terrain.
        return body.TerrainAltitude(latitude, longitude) > 0.01d;
    }

    // Check the actual terrain between an entity and the active vessel rather
    // than using a camera ray.  This works while the creature is off-screen
    // and naturally accounts for hills, ridges, and planetary curvature.
    private static bool HasGroundLineOfSight(Vector3d entityPosition, Vector3d vesselPosition, CelestialBody body)
    {
        if (body == null)
            return false;

        Vector3d entityUp = (entityPosition - body.position).normalized;
        Vector3d vesselUp = (vesselPosition - body.position).normalized;
        Vector3d from = entityPosition + entityUp * GroundSightHeightMetres;
        Vector3d to = vesselPosition + vesselUp * VesselSightHeightMetres;
        double distance = (to - from).magnitude;
        int samples = Mathf.Clamp(Mathf.CeilToInt((float)(distance / 350f)), 12, 72);

        // Endpoints are intentionally skipped: the entity itself and the
        // vessel may sit right on a high-detail terrain collider.
        for (int sampleIndex = 1; sampleIndex < samples; sampleIndex++)
        {
            double fraction = (double)sampleIndex / samples;
            Vector3d sightPoint = Vector3d.Lerp(from, to, fraction);
            double latitude = body.GetLatitude(sightPoint);
            double longitude = body.GetLongitude(sightPoint);
            double terrainAltitude = body.TerrainAltitude(latitude, longitude);
            Vector3d terrainPoint = body.GetWorldSurfacePosition(latitude, longitude, terrainAltitude);
            if ((sightPoint - body.position).magnitude <= (terrainPoint - body.position).magnitude + 0.5d)
                return false;
        }
        return true;
    }

    private void ChooseGroundRoamDirection(Vector3d entityPosition)
    {
        if (groundEntityBody == null)
            return;
        Vector3d radial = (entityPosition - groundEntityBody.position).normalized;
        Vector3 arbitrary = UnityEngine.Random.onUnitSphere;
        Vector3d tangent = arbitrary - radial * Vector3d.Dot(arbitrary, radial);
        groundRoamDirection = tangent.sqrMagnitude > 0.001d ? tangent.normalized : Vector3d.Cross(radial, Vector3d.up).normalized;
        groundRoamDirectionTimer = UnityEngine.Random.Range(8f, 22f);
    }

    // KSP keeps its crew meshes in the loaded Unity resources.  Cloning one of
    // those models means this is the real stock Kerbal mesh/rig, rather than a
    // rough character made from primitives.
    private static GameObject CreateGroundEntityVisual(bool tourist)
    {
        Component source = FindLoadedKerbalModel();
        if (source == null)
        {
            UnityEngine.Debug.Log("[EntityTracker] No stock Kerbal model is currently loaded.");
            return null;
        }

        GameObject root = Instantiate(source.gameObject);
        root.name = "EntityTracker_StretchedKerbal";
        // Kerbal portrait/prefab assets can be disabled or placed on a UI-only
        // layer.  Make the clone a normal active flight-scene object.
        root.SetActive(true);
        foreach (Transform transform in root.GetComponentsInChildren<Transform>(true))
        {
            transform.gameObject.SetActive(true);
            transform.gameObject.layer = 0;
        }
        if (root.GetComponentsInChildren<Renderer>(true).Length == 0)
        {
            Destroy(root);
            UnityEngine.Debug.Log("[EntityTracker] The selected EVA prefab contained no visible Kerbal mesh.");
            return null;
        }
        foreach (Renderer renderer in root.GetComponentsInChildren<Renderer>(true))
        {
            renderer.enabled = true;
            SkinnedMeshRenderer skinned = renderer as SkinnedMeshRenderer;
            if (skinned != null)
                skinned.updateWhenOffscreen = true;
        }

        // Keep the stock Animator/Animation components running, but make their
        // skeleton deform wildly.  This intentionally produces broken-looking
        // stock Kerbal animations instead of replacing them with a custom mesh.
        // Keep the Kerbal broadly normal-sized.  The unsettling silhouette
        // comes from separated head/shoulder bones, not a skyscraper-scale body.
        root.transform.localScale = Vector3.one * (tourist ? 0.72f : 1.25f);
        ConfigureGlitchRig(root);

        foreach (Collider collider in root.GetComponentsInChildren<Collider>(true))
            Destroy(collider);
        foreach (Rigidbody body in root.GetComponentsInChildren<Rigidbody>(true))
            Destroy(body);
        foreach (Component component in root.GetComponentsInChildren<Component>(true))
        {
            string typeName = component.GetType().Name;
            if (typeName == "KerbalEVA" || typeName == "Kerbal3DModel" || typeName == "Kerbal" || typeName == "Part" || typeName == "PartModule")
                Destroy(component);
        }

        foreach (Animator animator in root.GetComponentsInChildren<Animator>(true))
            animator.speed = UnityEngine.Random.Range(-2.5f, 2.5f);
        foreach (Animation animation in root.GetComponentsInChildren<Animation>(true))
        {
            foreach (AnimationState state in animation)
            {
                state.wrapMode = WrapMode.Loop;
                state.speed = UnityEngine.Random.Range(-2.5f, 2.5f);
                state.time = UnityEngine.Random.value * state.length;
                animation.Play(state.name);
                break;
            }
        }

        return root;
    }

    private static void ConfigureGlitchRig(GameObject root)
    {
        // Pull the real bones from the Kerbal's skinned mesh instead of relying
        // on transform names (which differ between suits).  This deforms arms,
        // legs, spine, and head independently rather than merely scaling up the
        // entire character.
        HashSet<Transform> bones = new HashSet<Transform>();
        foreach (SkinnedMeshRenderer skin in root.GetComponentsInChildren<SkinnedMeshRenderer>(true))
        {
            foreach (Transform bone in skin.bones)
            {
                if (bone != null && bone != root.transform)
                    bones.Add(bone);
            }
        }

        GroundEntityGlitchAnimation animation = root.AddComponent<GroundEntityGlitchAnimation>();
        animation.Configure(new List<Transform>(bones));
    }

    private static Component FindLoadedKerbalModel()
    {
        UnityEngine.Object[] components = Resources.FindObjectsOfTypeAll(typeof(Component));
        foreach (UnityEngine.Object candidate in components)
        {
            Component component = candidate as Component;
            if (component != null && (component.GetType().Name == "Kerbal3DModel" || component.GetType().Name == "Kerbal") &&
                component.GetComponentsInChildren<Renderer>(true).Length > 0)
                return component;
        }

        // Normally there is no EVA in the scene, so take the stock EVA part
        // prefab straight from KSP's prebuilt-part catalogue instead.
        if (PartLoader.LoadedPartsList != null)
        {
            foreach (AvailablePart availablePart in PartLoader.LoadedPartsList)
            {
                if (availablePart != null && availablePart.name == "kerbalEVA" && availablePart.partPrefab != null)
                    return availablePart.partPrefab;
            }
        }
        return null;
    }

    private void DespawnGroundEntity()
    {
        if (groundEntityObject != null)
            Destroy(groundEntityObject);
        groundEntityObject = null;
        groundEntityAnimation = null;
        groundFootstepsAudio = null;
        groundScreamAudio = null;
        groundFootstepsLoaded = false;
        groundScreamLoaded = false;
        groundEntityBody = null;
        groundEntityRelativePosition = Vector3d.zero;
        groundEntityActive = false;
        groundEntityChasing = false;
        groundEntityFleeing = false;
        groundEntityFleeTimer = 0f;
        groundEntityStandingDown = false;
        groundEntityRetreating = false;
        groundEntityWithdrawalTimer = 0f;
        groundEntityChaseSurvivalTimer = 0f;
        groundVariant = GroundVariant.Pilot;
        groundRoamDirection = Vector3d.zero;
        groundRoamDirectionTimer = 0f;
        groundEntityStatus = "NONE";
    }

    private void CreateUnknownMapContacts()
    {
        // These are map-only visual contacts, deliberately not vessels or save-game objects.
        for (int i = 0; i < 4; i++)
        {
            mapContacts.Add(new UnknownMapContact
            {
                phase = UnityEngine.Random.Range(0f, 360f),
                radius = SelectSolarOrbitRadius() * UnityEngine.Random.Range(0.75f, 1.25f),
                inclination = UnityEngine.Random.Range(-0.12f, 0.12f),
                angularRate = UnityEngine.Random.Range(0.002f, 0.012f),
                visibleStart = UnityEngine.Random.Range(0f, 210f)
            });
        }
    }

    private static double SelectSolarOrbitRadius()
    {
        CelestialBody sun = Planetarium.fetch != null ? Planetarium.fetch.Sun : null;
        if (sun != null)
        {
            List<CelestialBody> planets = new List<CelestialBody>();
            foreach (CelestialBody body in FlightGlobals.Bodies)
            {
                if (body != sun && body.orbit != null && body.orbit.referenceBody == sun)
                    planets.Add(body);
            }
            if (planets.Count > 0)
                return planets[UnityEngine.Random.Range(0, planets.Count)].orbit.semiMajorAxis;
        }

        return 15000000000d;
    }

    private void DrawUnknownMapContacts()
    {
        if (Planetarium.fetch == null || Planetarium.fetch.Sun == null || PlanetariumCamera.Camera == null)
            return;

        float time = Time.realtimeSinceStartup;
        GUIStyle iconStyle = new GUIStyle(GUI.skin.label);
        iconStyle.fontSize = 18;
        iconStyle.fontStyle = FontStyle.Bold;
        iconStyle.normal.textColor = new Color(1f, 0.15f, 0.15f, 0.9f);
        iconStyle.alignment = TextAnchor.MiddleCenter;

        foreach (UnknownMapContact contact in mapContacts)
        {
            // Each contact only bleeds into the map for 30 seconds of every 210 seconds.
            if (Mathf.Repeat(time + contact.visibleStart, 210f) > 30f)
                continue;

            double angle = (contact.phase + time * contact.angularRate) * Mathf.Deg2Rad;
            double radius = contact.radius;
            Vector3d localPosition = Planetarium.fetch.Sun.position + new Vector3d(
                Math.Cos(angle) * radius,
                Math.Sin(angle) * radius * contact.inclination,
                Math.Sin(angle) * radius);
            Vector3 scaledPosition = ScaledSpace.LocalToScaledSpace(localPosition);
            Vector3 point = PlanetariumCamera.Camera.WorldToScreenPoint(scaledPosition);
            if (point.z <= 0f || point.x < 0f || point.x > Screen.width || point.y < 0f || point.y > Screen.height)
                continue;

            GUI.Label(new Rect(point.x - 10f, Screen.height - point.y - 10f, 20f, 20f), "?", iconStyle);
        }
    }

    private class UnknownMapContact
    {
        public float phase;
        public double radius;
        public float inclination;
        public float angularRate;
        public float visibleStart;
    }

    private void DrawTracker(int id)
    {
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null)
        {
            GUILayout.Label("NO ACTIVE VESSEL");
            GUI.DragWindow();
            return;
        }

        double distance = (entityPosition - vessel.GetWorldPos3D()).magnitude;
        GUILayout.Label(arrived ? "STATUS: SIGNAL COINCIDENT" : "STATUS: APPROACHING");
        GUILayout.Label("DISTANCE: " + FormatDistance(distance));
        GUILayout.Label("BEARING: " + BearingTo(vessel).ToString("000") + "°");
        GUILayout.Space(5);
        GUILayout.Label("[P]  hide tracker");
        GUI.DragWindow();
    }

    private void ToggleTracking()
    {
        if (tracking)
        {
            // Hiding the readout must not despawn the actual entity. It keeps
            // pursuing from its own world position while the UI is hidden.
            trackerVisible = !trackerVisible;
            return;
        }

        StartTracking(FlightGlobals.ActiveVessel);
    }

    private void StartTracking(Vessel vessel)
    {
        if (vessel == null)
            return;

        // Use the vessel's local horizontal plane so the initial reading is 50 km away,
        // rather than potentially putting the signal high above or below it.
        Vector3 forward = vessel.transform.forward;
        Vector3 right = vessel.transform.right;
        Vector3 horizontal = (forward * UnityEngine.Random.Range(-1f, 1f)
                            + right * UnityEngine.Random.Range(-1f, 1f)).normalized;
        if (horizontal.sqrMagnitude < 0.01f)
            horizontal = forward.normalized;

        currentSpawnDistanceMetres = IsInterplanetaryOrbit(vessel)
            ? InterplanetarySpawnDistanceMetres
            : SpawnDistanceMetres;
        entityPosition = vessel.GetWorldPos3D() + (Vector3d)(horizontal * currentSpawnDistanceMetres);
        entityReferenceBody = vessel.mainBody;
        SaveEntityWorldPosition();
        lastTrackingVesselPosition = vessel.GetWorldPos3D();
        hasLastTrackingVesselPosition = true;
        orbitalTetherOffset = entityPosition - vessel.GetWorldPos3D();
        orbitalTetherActive = IsOrbital(vessel);
        closestEncounterDistance = currentSpawnDistanceMetres;
        blackboxScienceAwarded = false;
        survivalTimerStarted = false;
        survivalTimer = 0f;
        lastCountdownSecond = -1;
        countdownTextShakeTimer = 0f;
        safeMessageTimer = 0f;
        arrived = false;
        arrivalHandled = false;
        tufxGlitchStarted = false;
        glitchFramesRemaining = 0;
        tracking = true;
        trackerVisible = true;
        ResetBoxEncounterTimeWarp();
        ApplyDistantSoundForEncounter();
        ApplyNearbySoundForEncounter();
        if (distantAudio != null)
            distantAudio.maxDistance = currentSpawnDistanceMetres;
        if (nearbyAudio != null)
            nearbyAudio.maxDistance = currentSpawnDistanceMetres;
        UpdateSound();
    }

    private void ResetBoxEncounterTimeWarp()
    {
        if (!tracking || !HighLogic.LoadedSceneIsFlight)
            return;

        // Start every box encounter at normal speed, but do not keep forcing
        // it afterward: pausing remains available to the player.
        if (TimeWarp.CurrentRateIndex != 0)
            TimeWarp.SetRate(0, true);
    }

    private void LoadSettings()
    {
        string settingsPath = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "settings.cfg");
        ConfigNode settings = File.Exists(settingsPath) ? ConfigNode.Load(settingsPath) : null;
        if (settings != null && settings.HasValue("quitOnArrival"))
            bool.TryParse(settings.GetValue("quitOnArrival"), out quitOnArrival);
        if (settings != null && settings.HasValue("alwaysVisible"))
            bool.TryParse(settings.GetValue("alwaysVisible"), out alwaysVisible);
        if (settings != null && settings.HasValue("tufxGlitchOnArrival"))
            bool.TryParse(settings.GetValue("tufxGlitchOnArrival"), out tufxGlitchOnArrival);
        if (settings != null && settings.HasValue("randomEncounters"))
            bool.TryParse(settings.GetValue("randomEncounters"), out randomEncounters);
        if (settings != null && settings.HasValue("debugTools"))
            bool.TryParse(settings.GetValue("debugTools"), out debugTools);
        if (settings != null && settings.HasValue("unknownMapContacts"))
            bool.TryParse(settings.GetValue("unknownMapContacts"), out unknownMapContacts);
        if (settings != null && settings.HasValue("groundEntityDebugRadar"))
            bool.TryParse(settings.GetValue("groundEntityDebugRadar"), out groundEntityDebugRadar);
        ReadFloatSetting(settings, "randomCheckSeconds", ref randomCheckSeconds);
        ReadFloatSetting(settings, "baseEncounterChance", ref baseEncounterChance);
        ReadFloatSetting(settings, "groundEncounterChance", ref groundEncounterChance);
        ReadFloatSetting(settings, "groundEncounterCooldownSeconds", ref groundEncounterCooldownSeconds);
        ReadFloatSetting(settings, "highEncounterMultiplier", ref highEncounterMultiplier);
        ReadFloatSetting(settings, "rareEncounterMultiplier", ref rareEncounterMultiplier);
        ReadFloatSetting(settings, "randomCooldownSeconds", ref randomCooldownSeconds);
        ReadFloatSetting(settings, "approachSpeedMetresPerSecond", ref approachSpeedMetresPerSecond);
        ReadFloatSetting(settings, "blackboxSpeedMultiplier", ref blackboxSpeedMultiplier);
        ReadFloatSetting(settings, "groundEntitySpeedMultiplier", ref groundEntitySpeedMultiplier);
        ReadFloatSetting(settings, "seaEntitySpeedMultiplier", ref seaEntitySpeedMultiplier);
        ReadFloatSetting(settings, "eyeSpeedMultiplier", ref eyeSpeedMultiplier);
        ReadFloatSetting(settings, "escapeDistanceGainMetres", ref escapeDistanceGainMetres);
        ReadFloatSetting(settings, "survivalEscapeSeconds", ref survivalEscapeSeconds);
        ReadFloatSetting(settings, "bulkEncounterChance", ref bulkEncounterChance);
        ReadFloatSetting(settings, "bulkEncounterCooldownSeconds", ref bulkEncounterCooldownSeconds);
        ReadFloatSetting(settings, "bloopEncounterChance", ref bloopEncounterChance);
        ReadFloatSetting(settings, "bloopEncounterCooldownSeconds", ref bloopEncounterCooldownSeconds);
        ReadFloatSetting(settings, "rakeEncounterChance", ref rakeEncounterChance);
        ReadFloatSetting(settings, "rakeEncounterCooldownSeconds", ref rakeEncounterCooldownSeconds);
        if (settings != null && settings.HasValue("survivalJolt"))
            bool.TryParse(settings.GetValue("survivalJolt"), out survivalJolt);
        if (settings != null && settings.HasValue("immuneBodies"))
            immuneBodies = settings.GetValue("immuneBodies");
        if (settings != null && settings.HasValue("highEncounterBodies"))
            highEncounterBodies = settings.GetValue("highEncounterBodies");
        if (settings != null && settings.HasValue("rareEncounterBodies"))
            rareEncounterBodies = settings.GetValue("rareEncounterBodies");
    }

    private static void ReadFloatSetting(ConfigNode settings, string name, ref float value)
    {
        if (settings == null || !settings.HasValue(name))
            return;

        float parsed;
        if (float.TryParse(settings.GetValue(name), out parsed))
            value = parsed;
    }

    private float GetGroundEntitySpeed(float baseSpeed)
    {
        return baseSpeed * Mathf.Clamp(groundEntitySpeedMultiplier, 0.05f, 20f);
    }

    private float GetSeaEntitySpeed(float baseSpeed)
    {
        return baseSpeed * Mathf.Clamp(seaEntitySpeedMultiplier, 0.05f, 20f);
    }

    private void UpdateRandomEncounters()
    {
        if (!randomEncounters || tracking || arrived || Time.timeScale <= 0f)
            return;

        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || vessel.mainBody == null)
            return;

        float elapsed = Time.unscaledDeltaTime;
        encounterCooldownTimer = Mathf.Max(0f, encounterCooldownTimer - elapsed);
        groundEncounterCooldownTimer = Mathf.Max(0f, groundEncounterCooldownTimer - elapsed);
        bulkEncounterCooldownTimer = Mathf.Max(0f, bulkEncounterCooldownTimer - elapsed);
        laytheCreatureEncounterCooldownTimer = Mathf.Max(0f, laytheCreatureEncounterCooldownTimer - elapsed);
        seaEncounterCooldownTimer = Mathf.Max(0f, seaEncounterCooldownTimer - elapsed);
        bloopEncounterCooldownTimer = Mathf.Max(0f, bloopEncounterCooldownTimer - elapsed);
        rakeEncounterCooldownTimer = Mathf.Max(0f, rakeEncounterCooldownTimer - elapsed);
        encounterCheckTimer += GetCappedSpawnCheckDelta();
        if (encounterCheckTimer < randomCheckSeconds)
            return;

        encounterCheckTimer = 0f;
        string bodyName = vessel.mainBody.bodyName;
        float chance = baseEncounterChance;
        if (BodyListContains(highEncounterBodies, bodyName))
            chance *= highEncounterMultiplier;
        if (BodyListContains(rareEncounterBodies, bodyName))
            chance *= rareEncounterMultiplier;

        if (!BodyListContains(immuneBodies, bodyName) && encounterCooldownTimer <= 0f && UnityEngine.Random.value <= Mathf.Clamp01(chance))
        {
            encounterCooldownTimer = randomCooldownSeconds;
            StartTracking(vessel);
        }

        // The ground entity shares the same check interval, but is a little
        // more likely and has its own shorter cooldown.  It is strictly a
        // nighttime land encounter: never ocean, arctic, or mountain biomes.
        if (!tracking && !groundEntityActive && groundEncounterCooldownTimer <= 0f &&
            IsEligibleForAnyGroundVariant(vessel) && UnityEngine.Random.value <= Mathf.Clamp01(groundEncounterChance))
        {
            SpawnGroundEntity(PickGroundVariant(), true);
            if (groundEntityActive)
                groundEncounterCooldownTimer = groundEncounterCooldownSeconds;
        }

        // The Moho/Eve entity uses the same check interval as the other
        // encounters, but it is deliberately more likely and does not roam.
        if (!tracking && !groundEntityActive && !bulkEntityActive && bulkEncounterCooldownTimer <= 0f &&
            IsEligibleForBulkEncounter(vessel) && UnityEngine.Random.value <= Mathf.Clamp01(bulkEncounterChance))
        {
            SpawnBulkEntity();
            if (bulkEntityActive)
                bulkEncounterCooldownTimer = bulkEncounterCooldownSeconds;
        }

        if (LaytheLandCreatureEnabled && !tracking && !groundEntityActive && !bulkEntityActive && !laytheCreatureActive && laytheCreatureEncounterCooldownTimer <= 0f &&
            IsEligibleForLaytheCreature(vessel) && UnityEngine.Random.value <= Mathf.Clamp01(laytheCreatureEncounterChance))
        {
            SpawnLaytheCreature();
            if (laytheCreatureActive)
                laytheCreatureEncounterCooldownTimer = laytheCreatureEncounterCooldownSeconds;
        }

        // The sea monster is intentionally common, but only while the vessel
        // is actually in Eve's ocean. Leaving the water is a guaranteed escape.
        if (!tracking && !groundEntityActive && !bulkEntityActive && !seaEntityActive && seaEncounterCooldownTimer <= 0f &&
            CanSeaEntityTarget(vessel) && UnityEngine.Random.value <= Mathf.Clamp01(seaEncounterChance))
        {
            SpawnSeaEntity();
            if (seaEntityActive)
                seaEncounterCooldownTimer = seaEncounterCooldownSeconds;
        }

        if (!tracking && !groundEntityActive && !bulkEntityActive && !seaEntityActive && !bloopEntityActive && bloopEncounterCooldownTimer <= 0f &&
            CanBloopTarget(vessel) && UnityEngine.Random.value <= Mathf.Clamp01(bloopEncounterChance))
        {
            SpawnBloopEntity();
            if (bloopEntityActive)
                bloopEncounterCooldownTimer = bloopEncounterCooldownSeconds;
        }

    }

    private static bool IsEligibleForGroundEncounter(Vessel vessel)
    {
        if (vessel == null || vessel.mainBody == null || vessel.Splashed || vessel.heightFromTerrain >= GroundIgnoreAltitudeMetres ||
            !string.Equals(vessel.mainBody.bodyName, "Kerbin", StringComparison.OrdinalIgnoreCase))
            return false;

        if (IsBlockedGroundBiome(GetCurrentBiomeName(vessel)))
            return false;
        return IsNightAtVessel(vessel);
    }

    private static bool IsEligibleForAnyGroundVariant(Vessel vessel)
    {
        return vessel != null && vessel.mainBody != null && !vessel.Splashed &&
               vessel.heightFromTerrain < GroundIgnoreAltitudeMetres &&
               string.Equals(vessel.mainBody.bodyName, "Kerbin", StringComparison.OrdinalIgnoreCase);
    }

    private static bool IsEligibleForBulkEncounter(Vessel vessel)
    {
        return vessel != null && vessel.mainBody != null && !vessel.Splashed &&
               vessel.heightFromTerrain < GroundIgnoreAltitudeMetres && IsBulkEntityBody(vessel.mainBody);
    }

    private static bool IsEligibleForLaytheCreature(Vessel vessel)
    {
        return vessel != null && vessel.mainBody != null && !vessel.Splashed &&
               vessel.heightFromTerrain < GroundIgnoreAltitudeMetres &&
               string.Equals(vessel.mainBody.bodyName, "Laythe", StringComparison.OrdinalIgnoreCase);
    }

    private static string GetCurrentBiomeName(Vessel vessel)
    {
        if (vessel == null || vessel.mainBody == null || vessel.mainBody.BiomeMap == null)
            return "UNKNOWN";
        return vessel.mainBody.BiomeMap.GetAtt(vessel.latitude, vessel.longitude).name;
    }

    private static bool IsBlockedGroundBiome(string biome)
    {
        if (string.IsNullOrEmpty(biome))
            return false;
        string normalizedBiome = biome.ToLowerInvariant();
        return normalizedBiome.Contains("ocean") || normalizedBiome.Contains("arctic") || normalizedBiome.Contains("mountain");
    }

    private static bool IsNightAtVessel(Vessel vessel)
    {
        CelestialBody sun = Planetarium.fetch != null ? Planetarium.fetch.Sun : null;
        if (vessel == null || vessel.mainBody == null || sun == null)
            return false;
        Vector3d outward = (vessel.GetWorldPos3D() - vessel.mainBody.position).normalized;
        Vector3d towardSun = (sun.position - vessel.GetWorldPos3D()).normalized;
        return Vector3d.Dot(outward, towardSun) < 0d;
    }

    private static bool BodyListContains(string list, string bodyName)
    {
        string[] entries = list.Split(',');
        for (int i = 0; i < entries.Length; i++)
        {
            if (string.Equals(entries[i].Trim(), bodyName, StringComparison.OrdinalIgnoreCase))
                return true;
        }
        return false;
    }

    private void HandleArrival()
    {
        if (arrivalHandled)
            return;

        if (debugImmortality)
        {
            EndEncounter();
            return;
        }

        arrivalHandled = true;
        tracking = false;
        UpdateSound();

        if (quitOnArrival)
            Application.Quit();
        else
            HighLogic.LoadScene(GameScenes.MAINMENU);
    }

    private static void TryActivateTufxGlitch()
    {
        // TUFX is optional. Reflection means EntityTracker remains loadable without it.
        foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
        {
            Type loaderType = assembly.GetType("TUFX.TexturesUnlimitedFXLoader", false);
            if (loaderType == null)
                continue;

            FieldInfo instanceField = loaderType.GetField("INSTANCE", BindingFlags.Static | BindingFlags.NonPublic);
            MethodInfo setProfile = loaderType.GetMethod("SetCurrentProfile", BindingFlags.Instance | BindingFlags.NonPublic);
            object loader = instanceField != null ? instanceField.GetValue(null) : null;
            if (loader != null && setProfile != null)
            {
                setProfile.Invoke(loader, new object[] { "EntityTracker-Glitch" });
                return;
            }
        }
    }

    private void CreateWorldSoundSource()
    {
        entitySoundObject = new GameObject("EntityTracker_SoundSource");
        DontDestroyOnLoad(entitySoundObject);
        distantAudio = ConfigureAudioSource(entitySoundObject.AddComponent<AudioSource>());
        nearbyAudio = ConfigureAudioSource(entitySoundObject.AddComponent<AudioSource>());
        eyeWarningAudio = ConfigureAudioSource(entitySoundObject.AddComponent<AudioSource>());
        eyeWarningAudio.spatialBlend = 0f;
        eyeWarningAudio.loop = false;
        eyeWarningAudio.volume = 0.95f;
        eyeChaseAudio = ConfigureAudioSource(entitySoundObject.AddComponent<AudioSource>());
        eyeChaseAudio.spatialBlend = 0f;
        eyeChaseAudio.loop = true;
        eyeChaseAudio.volume = 0.8f;
        randomHorrorAudio = ConfigureAudioSource(entitySoundObject.AddComponent<AudioSource>());
        randomHorrorAudio.spatialBlend = 0f;
        randomHorrorAudio.loop = false;
        randomHorrorAudio.volume = 0.95f;
        ambientSoundObject = new GameObject("EntityTracker_KerbinAmbient");
        DontDestroyOnLoad(ambientSoundObject);
        kerbinAmbientAudio = ambientSoundObject.AddComponent<AudioSource>();
        kerbinAmbientAudio.playOnAwake = false;
        kerbinAmbientAudio.loop = false;
        kerbinAmbientAudio.spatialBlend = 1f;
        kerbinAmbientAudio.rolloffMode = AudioRolloffMode.Logarithmic;
        kerbinAmbientAudio.minDistance = 300f;
        kerbinAmbientAudio.maxDistance = 6000f;
        kerbinAmbientAudio.volume = 0.38f;
        laytheAmbientAudio = ambientSoundObject.AddComponent<AudioSource>();
        laytheAmbientAudio.playOnAwake = false;
        laytheAmbientAudio.loop = false;
        laytheAmbientAudio.spatialBlend = 1f;
        laytheAmbientAudio.rolloffMode = AudioRolloffMode.Logarithmic;
        laytheAmbientAudio.minDistance = 300f;
        laytheAmbientAudio.maxDistance = 6000f;
        laytheAmbientAudio.volume = 0.34f;
        eveAmbientAudio = ambientSoundObject.AddComponent<AudioSource>();
        eveAmbientAudio.playOnAwake = false;
        eveAmbientAudio.loop = false;
        eveAmbientAudio.spatialBlend = 1f;
        eveAmbientAudio.rolloffMode = AudioRolloffMode.Logarithmic;
        eveAmbientAudio.minDistance = 300f;
        eveAmbientAudio.maxDistance = 6000f;
        eveAmbientAudio.volume = 0.32f;
    }

    private static AudioSource ConfigureAudioSource(AudioSource source)
    {
        source.loop = true;
        source.playOnAwake = false;
        source.spatialBlend = 1f;
        source.rolloffMode = AudioRolloffMode.Linear;
        source.minDistance = 5f;
        source.maxDistance = SpawnDistanceMetres;
        source.volume = 0f;
        return source;
    }

    private static AudioSource ConfigureGroundAudioSource(AudioSource source, float maximumDistance, float volume)
    {
        source.loop = true;
        source.playOnAwake = false;
        source.spatialBlend = 1f;
        source.rolloffMode = AudioRolloffMode.Linear;
        source.minDistance = 3f;
        source.maxDistance = maximumDistance;
        source.volume = volume;
        return source;
    }

    private System.Collections.IEnumerator LoadGroundSound(string filename, AudioSource targetAudio, bool isFootsteps)
    {
        string soundPath = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "Sounds", filename);
        if (!File.Exists(soundPath) || targetAudio == null)
            yield break;

        WWW request = new WWW(new Uri(soundPath).AbsoluteUri);
        yield return request;
        if (!string.IsNullOrEmpty(request.error) || targetAudio == null)
            yield break;

        targetAudio.clip = request.GetAudioClip(true, false, AudioType.MPEG);
        if (isFootsteps)
            groundFootstepsLoaded = targetAudio.clip != null;
        else
            groundScreamLoaded = targetAudio.clip != null;
        UpdateGroundEntitySound();
    }

    private System.Collections.IEnumerator LoadGroundSpawnSound(string filename, AudioSource targetAudio)
    {
        string soundPath = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "Sounds", filename);
        if (!File.Exists(soundPath) || targetAudio == null)
            yield break;

        WWW request = new WWW(new Uri(soundPath).AbsoluteUri);
        yield return request;
        if (!string.IsNullOrEmpty(request.error) || targetAudio == null)
            yield break;

        targetAudio.clip = request.GetAudioClip(false, false, AudioType.MPEG);
        if (targetAudio.clip != null)
            targetAudio.Play();
    }

    private System.Collections.IEnumerator LoadRakeChaseSound()
    {
        string soundPath = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "Sounds", "entity-chase-sound-1-(loud-ver)-made-with-Voicemod.mp3");
        if (!File.Exists(soundPath) || rakeChaseAudio == null)
            yield break;

        WWW request = new WWW(new Uri(soundPath).AbsoluteUri);
        yield return request;
        if (!string.IsNullOrEmpty(request.error) || rakeChaseAudio == null)
            yield break;

        rakeChaseAudio.clip = request.GetAudioClip(false, false, AudioType.MPEG);
        rakeChaseSoundLoaded = rakeChaseAudio.clip != null;
        UpdateRakeChaseSound();
    }

    private System.Collections.IEnumerator LoadBulkFootsteps()
    {
        string soundPath = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "Sounds", "big-heavy-robot-footsteps-sound-effect-made-with-Voicemod.mp3");
        if (!File.Exists(soundPath) || bulkFootstepsAudio == null)
            yield break;

        WWW request = new WWW(new Uri(soundPath).AbsoluteUri);
        yield return request;
        if (!string.IsNullOrEmpty(request.error) || bulkFootstepsAudio == null)
            yield break;

        bulkFootstepsAudio.clip = request.GetAudioClip(false, false, AudioType.MPEG);
        bulkFootstepsLoaded = bulkFootstepsAudio.clip != null;
        UpdateBulkFootsteps(bulkEntityChasing && !bulkEntityStandingDown && !bulkEntityRetreating);
    }

    private void UpdateBulkFootsteps(bool chasing)
    {
        UpdateGroundAudioSource(bulkFootstepsAudio, bulkFootstepsLoaded, chasing && Time.timeScale > 0f);
    }

    private void PlayRakeChaseSoundIfReady()
    {
        if (rakeChaseSoundRequested && rakeChaseSoundLoaded && rakeChaseAudio != null && !rakeChaseAudio.isPlaying)
            rakeChaseAudio.Play();
    }

    private void UpdateRakeChaseSound()
    {
        bool shouldPlay = rakeEntityActive && rakeEntityChasing && !rakeEntityStandingDown && !rakeEntityRetreating && !rakeEntityVisible &&
                          rakeChaseSoundRequested && rakeChaseSoundLoaded && Time.timeScale > 0f;
        if (rakeChaseAudio == null)
            return;
        if (shouldPlay)
        {
            if (!rakeChaseAudio.isPlaying)
                rakeChaseAudio.Play();
        }
        else if (rakeChaseAudio.isPlaying)
        {
            rakeChaseAudio.Pause();
        }
    }

    private System.Collections.IEnumerator LoadSeaGrowl()
    {
        string soundPath = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "Sounds", "freesound_community-underwater-creature-growl-100438.mp3");
        if (!File.Exists(soundPath) || seaGrowlAudio == null)
            yield break;

        WWW request = new WWW(new Uri(soundPath).AbsoluteUri);
        yield return request;
        if (!string.IsNullOrEmpty(request.error) || seaGrowlAudio == null)
            yield break;

        seaGrowlAudio.clip = request.GetAudioClip(false, false, AudioType.MPEG);
        seaGrowlLoaded = seaGrowlAudio.clip != null;
        UpdateSeaGrowl();
    }

    private void UpdateSeaGrowl()
    {
        if (seaGrowlAudio == null)
            return;
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel != null && seaEntityBody != null)
        {
            Vector3d entityPosition = seaEntityBody.position + seaEntityRelativePosition;
            double distance = (entityPosition - vessel.GetWorldPos3D()).magnitude;
            float proximity = Mathf.InverseLerp(1800f, 100f, (float)distance);
            seaGrowlAudio.volume = Mathf.SmoothStep(0.04f, 0.8f, proximity);
        }
        bool shouldPlay = seaEntityActive && seaGrowlLoaded;
        if (shouldPlay)
        {
            if (!seaGrowlAudio.isPlaying)
                seaGrowlAudio.Play();
        }
        else if (seaGrowlAudio.isPlaying)
        {
            seaGrowlAudio.Pause();
        }
    }

    private System.Collections.IEnumerator LoadBloopBurrowSound()
    {
        string soundPath = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "Sounds", "earth-leviathan-burrow-1-made-with-Voicemod.mp3");
        if (!File.Exists(soundPath) || bloopBurrowAudio == null)
            yield break;

        WWW request = new WWW(new Uri(soundPath).AbsoluteUri);
        yield return request;
        if (!string.IsNullOrEmpty(request.error) || bloopBurrowAudio == null)
            yield break;

        bloopBurrowAudio.clip = request.GetAudioClip(false, false, AudioType.MPEG);
        bloopBurrowLoaded = bloopBurrowAudio.clip != null;
    }

    private void UpdateBloopBurrowSound(double distance)
    {
        if (bloopBurrowAudio == null || !bloopBurrowLoaded || !bloopEntityChasing || Time.timeScale <= 0f)
            return;

        // Crossing into the final kilometre always produces a cue.  Outside
        // it, a much less predictable low-frequency call plays at intervals.
        if (distance <= 1000d)
        {
            if (!bloopCloseCuePlayed)
            {
                bloopBurrowAudio.Play();
                bloopCloseCuePlayed = true;
            }
            return;
        }

        bloopCloseCuePlayed = false;
        bloopBurrowTimer -= TimeWarp.deltaTime;
        if (bloopBurrowTimer > 0f)
            return;

        bloopBurrowTimer = UnityEngine.Random.Range(12f, 30f);
        if (!bloopBurrowAudio.isPlaying && UnityEngine.Random.value <= 0.35f)
            bloopBurrowAudio.Play();
    }

    private System.Collections.IEnumerator LoadEntitySound(string filename, bool isDistantTrack, bool interplanetaryTrack)
    {
        string soundPath = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "Sounds", filename);
        if (!File.Exists(soundPath))
            yield break;

        WWW request = new WWW(new Uri(soundPath).AbsoluteUri);
        yield return request;
        AudioSource targetAudio = isDistantTrack ? distantAudio : nearbyAudio;
        if (!string.IsNullOrEmpty(request.error) || targetAudio == null)
            yield break;

        AudioClip clip = request.GetAudioClip(true, false, AudioType.MPEG);
        if (isDistantTrack)
        {
            if (interplanetaryTrack)
                interplanetaryDistantClip = clip;
            else
                normalDistantClip = clip;
            ApplyDistantSoundForEncounter();
        }
        else
        {
            if (interplanetaryTrack)
                interplanetaryNearbyClip = clip;
            else
                normalNearbyClip = clip;
            ApplyNearbySoundForEncounter();
        }
        UpdateSound();
    }

    private System.Collections.IEnumerator LoadEyeSound(string filename, bool isChaseSound)
    {
        string soundPath = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "Sounds", filename);
        AudioSource targetAudio = isChaseSound ? eyeChaseAudio : eyeWarningAudio;
        if (!File.Exists(soundPath) || targetAudio == null)
            yield break;

        WWW request = new WWW(new Uri(soundPath).AbsoluteUri);
        yield return request;
        if (!string.IsNullOrEmpty(request.error) || targetAudio == null)
            yield break;

        targetAudio.clip = request.GetAudioClip(true, false, AudioType.MPEG);
        if (isChaseSound)
            eyeChaseSoundLoaded = targetAudio.clip != null;
        else
            eyeWarningSoundLoaded = targetAudio.clip != null;
    }

    private System.Collections.IEnumerator LoadRandomHorrorSound()
    {
        string path = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "Sounds", "williams-horror-made-with-Voicemod.mp3");
        if (!File.Exists(path) || randomHorrorAudio == null) yield break;
        WWW request = new WWW("file:///" + path.Replace("\\", "/"));
        yield return request;
        if (string.IsNullOrEmpty(request.error))
        {
            randomHorrorAudio.clip = request.GetAudioClip(false, false, AudioType.MPEG);
            randomHorrorLoaded = randomHorrorAudio.clip != null;
        }
    }

    private void UpdateRandomHorrorMusicEvent()
    {
        if (!HighLogic.LoadedSceneIsFlight || Time.timeScale <= 0f || !randomHorrorLoaded || randomHorrorAudio == null)
            return;
        randomHorrorCheckTimer += Time.unscaledDeltaTime;
        if (randomHorrorCheckTimer < 30f || randomHorrorAudio.isPlaying) return;
        randomHorrorCheckTimer = 0f;
        if (UnityEngine.Random.value > 0.025f) return;
        mutedMusicSources.Clear();
        foreach (AudioSource source in FindObjectsOfType<AudioSource>())
        {
            if (source == randomHorrorAudio || source.gameObject == entitySoundObject) continue;
            string name = source.gameObject.name.ToLowerInvariant();
            if (name.Contains("music"))
            {
                mutedMusicSources[source] = source.volume;
                source.volume = 0f;
            }
        }
        randomHorrorAudio.Play();
        StartCoroutine(RestoreMusicAfterHorror(randomHorrorAudio.clip.length));
    }

    private System.Collections.IEnumerator RestoreMusicAfterHorror(float seconds)
    {
        yield return new WaitForSecondsRealtime(seconds + 0.1f);
        foreach (KeyValuePair<AudioSource, float> pair in mutedMusicSources)
            if (pair.Key != null) pair.Key.volume = pair.Value;
        mutedMusicSources.Clear();
    }

    private System.Collections.IEnumerator LoadKerbinAmbientSound()
    {
        string path = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "Sounds", "jusatti890-scream-horror-sfx-490916.mp3");
        if (!File.Exists(path) || kerbinAmbientAudio == null) yield break;
        WWW request = new WWW("file:///" + path.Replace("\\", "/"));
        yield return request;
        if (string.IsNullOrEmpty(request.error))
        {
            kerbinAmbientAudio.clip = request.GetAudioClip(false, false, AudioType.MPEG);
            kerbinAmbientLoaded = kerbinAmbientAudio.clip != null;
        }
    }

    private void UpdateKerbinNightAmbience()
    {
        if (!HighLogic.LoadedSceneIsFlight || Time.timeScale <= 0f || !kerbinAmbientLoaded || kerbinAmbientAudio == null || kerbinAmbientAudio.isPlaying)
            return;
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || vessel.mainBody == null || !string.Equals(vessel.mainBody.bodyName, "Kerbin", StringComparison.OrdinalIgnoreCase) || !IsNightAtVessel(vessel))
            return;
        kerbinAmbientCheckTimer += Time.unscaledDeltaTime;
        if (kerbinAmbientCheckTimer < 45f) return;
        kerbinAmbientCheckTimer = 0f;
        if (UnityEngine.Random.value > 0.06f) return;
        Vector3d radial = (vessel.GetWorldPos3D() - vessel.mainBody.position).normalized;
        Vector3 direction = vessel.transform.forward - (Vector3)radial * Vector3.Dot(vessel.transform.forward, (Vector3)radial);
        if (direction.sqrMagnitude < 0.01f) direction = vessel.transform.right;
        float distance = UnityEngine.Random.Range(2200f, 4200f);
        ambientSoundObject.transform.position = (Vector3)(vessel.GetWorldPos3D() + (Vector3d)direction.normalized * distance);
        kerbinAmbientAudio.Play();
    }

    private System.Collections.IEnumerator LoadLaytheAmbientSound()
    {
        string path = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "Sounds", "alesiadavina-horror-sound-lurking-horror-monster-189948.mp3");
        if (!File.Exists(path) || laytheAmbientAudio == null) yield break;
        WWW request = new WWW("file:///" + path.Replace("\\", "/"));
        yield return request;
        if (string.IsNullOrEmpty(request.error))
        {
            laytheAmbientAudio.clip = request.GetAudioClip(false, false, AudioType.MPEG);
            laytheAmbientLoaded = laytheAmbientAudio.clip != null;
        }
    }

    private void UpdateLaytheNightAmbience()
    {
        if (!HighLogic.LoadedSceneIsFlight || Time.timeScale <= 0f || !laytheAmbientLoaded || laytheAmbientAudio == null || laytheAmbientAudio.isPlaying)
            return;
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || vessel.mainBody == null || !string.Equals(vessel.mainBody.bodyName, "Laythe", StringComparison.OrdinalIgnoreCase) || !IsNightAtVessel(vessel))
            return;
        laytheAmbientCheckTimer += Time.unscaledDeltaTime;
        if (laytheAmbientCheckTimer < 45f) return;
        laytheAmbientCheckTimer = 0f;
        if (UnityEngine.Random.value > 0.06f) return;
        Vector3d radial = (vessel.GetWorldPos3D() - vessel.mainBody.position).normalized;
        Vector3 direction = vessel.transform.forward - (Vector3)radial * Vector3.Dot(vessel.transform.forward, (Vector3)radial);
        if (direction.sqrMagnitude < 0.01f) direction = vessel.transform.right;
        ambientSoundObject.transform.position = (Vector3)(vessel.GetWorldPos3D() + (Vector3d)direction.normalized * UnityEngine.Random.Range(2200f, 4200f));
        laytheAmbientAudio.Play();
    }

    private System.Collections.IEnumerator LoadEveAmbientSound()
    {
        string path = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "Sounds", "dragon-studio-cemetery-wind-467488.mp3");
        if (!File.Exists(path) || eveAmbientAudio == null) yield break;
        WWW request = new WWW("file:///" + path.Replace("\\", "/"));
        yield return request;
        if (string.IsNullOrEmpty(request.error))
        {
            eveAmbientAudio.clip = request.GetAudioClip(false, false, AudioType.MPEG);
            eveAmbientLoaded = eveAmbientAudio.clip != null;
        }
    }

    private void UpdateEveNightAmbience()
    {
        if (!HighLogic.LoadedSceneIsFlight || Time.timeScale <= 0f || !eveAmbientLoaded || eveAmbientAudio == null || eveAmbientAudio.isPlaying)
            return;
        Vessel vessel = FlightGlobals.ActiveVessel;
        if (vessel == null || vessel.mainBody == null || !string.Equals(vessel.mainBody.bodyName, "Eve", StringComparison.OrdinalIgnoreCase) || !IsNightAtVessel(vessel))
            return;
        eveAmbientCheckTimer += Time.unscaledDeltaTime;
        if (eveAmbientCheckTimer < 45f) return;
        eveAmbientCheckTimer = 0f;
        if (UnityEngine.Random.value > 0.06f) return;
        Vector3d radial = (vessel.GetWorldPos3D() - vessel.mainBody.position).normalized;
        Vector3 direction = vessel.transform.forward - (Vector3)radial * Vector3.Dot(vessel.transform.forward, (Vector3)radial);
        if (direction.sqrMagnitude < 0.01f) direction = vessel.transform.right;
        ambientSoundObject.transform.position = (Vector3)(vessel.GetWorldPos3D() + (Vector3d)direction.normalized * UnityEngine.Random.Range(2200f, 4200f));
        eveAmbientAudio.Play();
    }

    private void ApplyDistantSoundForEncounter()
    {
        if (distantAudio == null)
            return;
        AudioClip selected = IsInterplanetaryEncounter()
            ? (interplanetaryDistantClip ?? normalDistantClip)
            : normalDistantClip;
        if (selected != null && distantAudio.clip != selected)
        {
            bool wasPlaying = distantAudio.isPlaying;
            distantAudio.Stop();
            distantAudio.clip = selected;
            if (wasPlaying && Time.timeScale > 0f)
                distantAudio.Play();
        }
        distantSoundLoaded = distantAudio.clip != null;
    }

    private void ApplyNearbySoundForEncounter()
    {
        if (nearbyAudio == null)
            return;
        // Never let a delayed/failed optional Redbox clip leave the encounter
        // silent; the normal close ambience is a safe fallback.
        AudioClip selected = IsInterplanetaryEncounter()
            ? (interplanetaryNearbyClip ?? normalNearbyClip)
            : normalNearbyClip;
        if (selected != null && nearbyAudio.clip != selected)
        {
            bool wasPlaying = nearbyAudio.isPlaying;
            nearbyAudio.Stop();
            nearbyAudio.clip = selected;
            if (wasPlaying && Time.timeScale > 0f)
                nearbyAudio.Play();
        }
        nearbySoundLoaded = nearbyAudio.clip != null;
    }

    private System.Collections.IEnumerator LoadVisual()
    {
        string imagePath = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "Visuals", "black-box.png");
        if (!File.Exists(imagePath))
            yield break;

        WWW request = new WWW(new Uri(imagePath).AbsoluteUri);
        yield return request;
        if (!string.IsNullOrEmpty(request.error))
            yield break;

        visualTexture = request.texture;
    }

    // The Solar-orbit encounter uses an actual independent image file rather
    // than tinting the black sprite, since black pixels cannot be tinted red.
    private System.Collections.IEnumerator LoadInterplanetaryVisual()
    {
        string imagePath = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "Visuals", "red-box.png");
        if (!File.Exists(imagePath))
            yield break;
        WWW request = new WWW(new Uri(imagePath).AbsoluteUri);
        yield return request;
        if (string.IsNullOrEmpty(request.error))
            interplanetaryVisualTexture = request.texture;
    }

    private System.Collections.IEnumerator LoadEyeVisual(string filename, bool blink)
    {
        string imagePath = Path.Combine(KSPUtil.ApplicationRootPath, "GameData", ModFolderName, "Visuals", filename);
        if (!File.Exists(imagePath))
            yield break;
        WWW request = new WWW(new Uri(imagePath).AbsoluteUri);
        yield return request;
        if (!string.IsNullOrEmpty(request.error) || request.texture == null)
            yield break;

        Texture2D texture = request.texture;
        MakeNearWhiteTransparent(texture);
        if (blink)
            eyeBlinkTexture = texture;
        else
            eyeTexture = texture;
    }

    private static void MakeNearWhiteTransparent(Texture2D texture)
    {
        Color[] pixels = texture.GetPixels();
        for (int i = 0; i < pixels.Length; i++)
        {
            Color color = pixels[i];
            if (color.r > 0.92f && color.g > 0.92f && color.b > 0.92f)
                color.a = 0f;
            pixels[i] = color;
        }
        texture.SetPixels(pixels);
        texture.Apply();
    }

    private void UpdateSound()
    {
        if (distantAudio == null || nearbyAudio == null)
            return;

        if (!tracking || FlightGlobals.ActiveVessel == null || Time.timeScale <= 0f)
        {
            PauseAudio(distantAudio);
            PauseAudio(nearbyAudio);
            return;
        }

        // This source is placed at the signal's independent world coordinate. It is not
        // parented to the vessel, so leaving it behind (or outrunning it) increases range.
        entitySoundObject.transform.position = (Vector3)entityPosition;
        double distance = (entityPosition - FlightGlobals.ActiveVessel.GetWorldPos3D()).magnitude;
        float proximity = Mathf.Clamp01(1f - (float)(distance / currentSpawnDistanceMetres));
        // The normal black box uses its original broad cross-fade. The red
        // interplanetary box keeps its far radio ambience until the final
        // 50 km, then brings in its unique close-range glitch track.
        float farFadeIn = Mathf.SmoothStep(0f, 0.7f, Mathf.InverseLerp(0f, 0.6f, proximity));
        if (IsInterplanetaryEncounter())
        {
            // At interplanetary distances Unity's 3D attenuation can cull the
            // Redbox tracks entirely. Their distance fade is already driven
            // explicitly below, so make them 2D while this encounter is active.
            distantAudio.spatialBlend = 0f;
            nearbyAudio.spatialBlend = 0f;
            float redCloseProximity = Mathf.InverseLerp(50000f, 0f, (float)distance);
            distantAudio.volume = farFadeIn * (1f - Mathf.SmoothStep(0f, 1f, redCloseProximity));
            nearbyAudio.volume = Mathf.SmoothStep(0f, 0.95f, redCloseProximity);
        }
        else
        {
            distantAudio.spatialBlend = 1f;
            nearbyAudio.spatialBlend = 1f;
            distantAudio.volume = farFadeIn * (1f - Mathf.SmoothStep(0f, 0.75f, Mathf.InverseLerp(0.55f, 1f, proximity)));
            nearbyAudio.volume = Mathf.SmoothStep(0f, 0.95f, Mathf.InverseLerp(0.35f, 1f, proximity));
        }
        PlayIfLoaded(distantAudio, distantSoundLoaded);
        PlayIfLoaded(nearbyAudio, nearbySoundLoaded);
    }

    private void UpdateGroundEntitySound()
    {
        bool shouldPlay = groundEntityActive && groundEntityChasing && !groundEntityStandingDown && !groundEntityRetreating && Time.timeScale > 0f;
        UpdateGroundAudioSource(groundFootstepsAudio, groundFootstepsLoaded, shouldPlay);
        UpdateGroundAudioSource(groundScreamAudio, groundScreamLoaded, shouldPlay);
    }

    private static void UpdateGroundAudioSource(AudioSource source, bool loaded, bool shouldPlay)
    {
        if (source == null)
            return;
        if (!shouldPlay)
        {
            if (source.isPlaying)
                source.Pause();
            return;
        }
        if (loaded && !source.isPlaying)
            source.Play();
    }

    private static void PauseAudio(AudioSource source)
    {
        source.volume = 0f;
        if (source.isPlaying)
            source.Pause();
    }

    private static void PlayIfLoaded(AudioSource source, bool loaded)
    {
        if (loaded && !source.isPlaying)
            source.Play();
    }

    private void TriggerWindowJolt()
    {
        if (!survivalJolt || Screen.fullScreen)
            return;

        IntPtr window = Process.GetCurrentProcess().MainWindowHandle;
        if (window == IntPtr.Zero || !GetWindowRect(window, out originalWindowRect))
            return;

        hasOriginalWindowRect = true;
        joltFramesRemaining = 8;
    }

    private void UpdateWindowJolt()
    {
        if (joltFramesRemaining <= 0)
            return;

        IntPtr window = Process.GetCurrentProcess().MainWindowHandle;
        if (window == IntPtr.Zero)
        {
            joltFramesRemaining = 0;
            return;
        }

        joltFramesRemaining--;
        if (joltFramesRemaining == 0)
        {
            RestoreWindowPosition();
            return;
        }

        int x = originalWindowRect.Left + UnityEngine.Random.Range(-32, 33);
        int y = originalWindowRect.Top + UnityEngine.Random.Range(-32, 33);
        SetWindowPos(window, IntPtr.Zero, x, y, 0, 0, NoSizeNoZOrderNoActivate);
    }

    private void SetDebugWaterFogDisabled(bool disabled)
    {
        if (disabled && !fogStateCaptured)
        {
            fogEnabledBeforeDebug = RenderSettings.fog;
            fogStateCaptured = true;
        }
        debugDisableWaterFog = disabled;
        if (!disabled && fogStateCaptured)
        {
            RenderSettings.fog = fogEnabledBeforeDebug;
            fogStateCaptured = false;
        }
    }

    private void UpdateDebugFogOverride()
    {
        if (debugDisableWaterFog)
            RenderSettings.fog = false;
    }

    private void RestoreWindowPosition()
    {
        if (!hasOriginalWindowRect)
            return;

        IntPtr window = Process.GetCurrentProcess().MainWindowHandle;
        if (window != IntPtr.Zero)
            SetWindowPos(window, IntPtr.Zero, originalWindowRect.Left, originalWindowRect.Top, 0, 0, NoSizeNoZOrderNoActivate);
        hasOriginalWindowRect = false;
        joltFramesRemaining = 0;
    }

    private const uint NoSizeNoZOrderNoActivate = 0x0001 | 0x0004 | 0x0010;

    [DllImport("user32.dll")]
    private static extern bool GetWindowRect(IntPtr handle, out WindowRect rect);

    [DllImport("user32.dll")]
    private static extern bool SetWindowPos(IntPtr handle, IntPtr insertAfter, int x, int y, int cx, int cy, uint flags);

    [StructLayout(LayoutKind.Sequential)]
    private struct WindowRect
    {
        public int Left;
        public int Top;
        public int Right;
        public int Bottom;
    }

    private void DrawEyeOverlay()
    {
        if (!eyeEntityActive || eyeTexture == null || FlightGlobals.ActiveVessel == null)
            return;

        Camera camera = FlightCamera.fetch != null ? FlightCamera.fetch.mainCamera : Camera.main;
        if (camera == null)
            return;
        Vector3 screenPoint = camera.WorldToScreenPoint((Vector3)eyeEntityPosition);
        if (screenPoint.z <= 0f)
            return;

        // Unlike the Blackbox, the Eye is not meant to be a through-walls
        // signal. Keep its billboard out of view when real flight-scene
        // geometry (terrain, a craft, etc.) lies between it and the camera.
        Vector3 toEye = (Vector3)eyeEntityPosition - camera.transform.position;
        float lineOfSightDistance = toEye.magnitude;
        if (lineOfSightDistance > 0.01f)
        {
            RaycastHit[] hits = Physics.RaycastAll(camera.transform.position, toEye / lineOfSightDistance,
                                                    lineOfSightDistance - 0.05f, ~0, QueryTriggerInteraction.Ignore);
            foreach (RaycastHit hit in hits)
            {
                Transform hitTransform = hit.collider != null ? hit.collider.transform : null;
                // The Eye can be seen around the active craft; do not let the
                // player's own vessel turn invisible as soon as it is pursued.
                if (hitTransform != null && hitTransform.IsChildOf(FlightGlobals.ActiveVessel.transform))
                    continue;
                return;
            }
        }

        float focalLength = Screen.height / (2f * Mathf.Tan(camera.fieldOfView * Mathf.Deg2Rad * 0.5f));
        // The Eye often lingers only tens of metres away. At that range the
        // orbit camera can be several metres from the vessel, so using vessel
        // range makes it appear to scale backward when the camera swings. Use
        // camera depth, as a true fixed-size billboard would.
        float size = EyeVisualWorldSizeMetres * focalLength / Mathf.Max(0.01f, screenPoint.z);
        size = Mathf.Min(size, 900f);
        if (screenPoint.x < -size * 0.5f || screenPoint.x > Screen.width + size * 0.5f ||
            screenPoint.y < -size * 0.5f || screenPoint.y > Screen.height + size * 0.5f)
            return;

        bool blinking = Mathf.Repeat(Time.realtimeSinceStartup, 3f) < 0.3f;
        Texture2D sprite = blinking && eyeBlinkTexture != null ? eyeBlinkTexture : eyeTexture;
        GUI.DrawTexture(new Rect(screenPoint.x - size * 0.5f, Screen.height - screenPoint.y - size * 0.5f, size, size),
                        sprite, ScaleMode.ScaleToFit, true);
    }

    private void UpdateEyeBillboard()
    {
        if (!eyeEntityActive || eyeTexture == null)
            return;

        if (eyeBillboardObject == null)
        {
            eyeBillboardObject = GameObject.CreatePrimitive(PrimitiveType.Quad);
            eyeBillboardObject.name = "EntityTracker_JoolEye";
            Collider collider = eyeBillboardObject.GetComponent<Collider>();
            if (collider != null)
                Destroy(collider);

            Shader shader = Shader.Find("Unlit/Transparent");
            if (shader == null)
                shader = Shader.Find("Legacy Shaders/Transparent/Diffuse");
            if (shader == null)
            {
                Destroy(eyeBillboardObject);
                eyeBillboardObject = null;
                return;
            }
            eyeBillboardMaterial = new Material(shader);
            Renderer renderer = eyeBillboardObject.GetComponent<Renderer>();
            renderer.material = eyeBillboardMaterial;
            renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
            renderer.receiveShadows = false;
        }

        Camera camera = FlightCamera.fetch != null ? FlightCamera.fetch.mainCamera : Camera.main;
        if (camera == null || eyeBillboardMaterial == null)
            return;

        bool blinking = Mathf.Repeat(Time.realtimeSinceStartup, 3f) < 0.3f;
        eyeBillboardMaterial.mainTexture = blinking && eyeBlinkTexture != null ? eyeBlinkTexture : eyeTexture;
        eyeBillboardObject.transform.position = (Vector3)eyeEntityPosition;
        Vector3 toCamera = camera.transform.position - eyeBillboardObject.transform.position;
        if (toCamera.sqrMagnitude > 0.001f)
            eyeBillboardObject.transform.rotation = Quaternion.LookRotation(toCamera.normalized, camera.transform.up);
        // Same fixed 6 m world width as the box entities; the texture's native
        // aspect ratio gives the eyelid its correct non-square silhouette.
        eyeBillboardObject.transform.localScale = new Vector3(EyeVisualWorldSizeMetres,
                                                               EyeVisualWorldSizeMetres * (563f / 919f), 1f);
    }

    private void DrawEyeWarning()
    {
        if (eyeWarningTextTimer <= 0f)
            return;

        GUIStyle warning = new GUIStyle(GUI.skin.label);
        warning.fontSize = Mathf.RoundToInt(Mathf.Min(Screen.width, Screen.height) * 0.055f);
        warning.fontStyle = FontStyle.Bold;
        warning.alignment = TextAnchor.MiddleCenter;
        warning.normal.textColor = new Color(1f, 0.08f, 0.08f, Mathf.Clamp01(eyeWarningTextTimer / 10f));
        GUI.Label(new Rect(0f, Screen.height * 0.62f, Screen.width, warning.fontSize * 1.7f), "DON'T GO TO BOP", warning);
    }

    private void DrawVisualOverlay()
    {
        if (visualTexture == null || FlightGlobals.ActiveVessel == null)
            return;

        Camera camera = FlightCamera.fetch != null ? FlightCamera.fetch.mainCamera : Camera.main;
        if (camera == null)
            return;

        Vector3 screenPoint = camera.WorldToScreenPoint((Vector3)entityPosition);
        // These are physical-looking billboards, not edge-of-screen compass
        // markers. If the entity is behind or outside the camera view, do not
        // pin it to the nearest edge of the display.
        if (screenPoint.z <= 0f)
            return;

        // KSP's camera depth can become unreliable outside the physics bubble. Use the
        // tracker's own double-precision distance for a stable 6 x 6 m perspective size.
        float focalLength = Screen.height / (2f * Mathf.Tan(camera.fieldOfView * Mathf.Deg2Rad * 0.5f));
        double trueDistance = (entityPosition - FlightGlobals.ActiveVessel.GetWorldPos3D()).magnitude;
        float worldSize = VisualWorldSizeMetres * (IsInterplanetaryEncounter() ? 100f : 1f);
        float size = worldSize * focalLength / Mathf.Max(0.01f, (float)trueDistance);
        if (alwaysVisible)
            size = Mathf.Max(size, 3f);
        size = Mathf.Min(size, 900f);

        float halfSize = size * 0.5f;
        float x = screenPoint.x;
        float y = screenPoint.y;
        if (x < -halfSize || x > Screen.width + halfSize || y < -halfSize || y > Screen.height + halfSize)
            return;

        Rect position = new Rect(x - halfSize,
                                 Screen.height - y - halfSize,
                                 size, size);
        Texture2D sprite = IsInterplanetaryEncounter() && interplanetaryVisualTexture != null
            ? interplanetaryVisualTexture
            : visualTexture;
        GUI.DrawTexture(position, sprite, ScaleMode.ScaleToFit, true);
    }

    public void OnDestroy()
    {
        if (instance == this)
            instance = null;
        RestoreCrewCourage();
        RestoreEvaPanicSpeed();
        RestoreWindowPosition();
        SetDebugWaterFogDisabled(false);
        DespawnGroundEntity();
        DespawnBulkEntity();
        DespawnSeaEntity();
        DespawnBloopEntity();
        DespawnRakeEntity();
        DespawnEyeEntity();
        GameEvents.onGUIApplicationLauncherReady.Remove(AddToolbarButton);
        GameEvents.onGUIApplicationLauncherDestroyed.Remove(RemoveToolbarButton);
        GameEvents.onGameStateLoad.Remove(OnGameStateLoaded);
        GameEvents.OnScienceRecieved.Remove(OnScienceReceived);
        GameEvents.OnExperimentDeployed.Remove(OnExperimentDeployed);
        RemoveToolbarButton();
        if (entitySoundObject != null)
            Destroy(entitySoundObject);
        if (ambientSoundObject != null)
            Destroy(ambientSoundObject);
    }

    private int BearingTo(Vessel vessel)
    {
        Vector3 direction = ((Vector3)(entityPosition - vessel.GetWorldPos3D())).normalized;
        float angle = Vector3.SignedAngle(vessel.transform.forward, direction, vessel.transform.up);
        return Mathf.RoundToInt((angle + 360f) % 360f);
    }

    private static string FormatDistance(double metres)
    {
        return metres >= 1000d
            ? (metres / 1000d).ToString("F2") + " km"
            : metres.ToString("F0") + " m";
    }

    private static bool IsTypingInControl()
    {
        return GUIUtility.keyboardControl != 0;
    }
}

// Intentionally corrupts the stock Kerbal rig after it has been cloned.  It
// uses the real skinned-mesh bones, so every suit gets recognisably broken
// limbs rather than a uniformly tall silhouette.
public class GroundEntityGlitchAnimation : MonoBehaviour
{
    private sealed class BonePose
    {
        public Transform transform;
        public Quaternion baseRotation;
        public Vector3 baseScale;
        public Vector3 basePosition;
        public Vector3 positionOffset;
        public Vector3 rotationAxis;
        public float phase;
        public float rate;
    }

    private readonly List<BonePose> poses = new List<BonePose>();
    private bool chasing;
    private bool roaming;

    public void SetChasing(bool value)
    {
        chasing = value;
    }

    public void SetRoaming(bool value)
    {
        roaming = value;
    }

    public void Configure(List<Transform> bones)
    {
        foreach (Transform bone in bones)
        {
            BonePose pose = new BonePose();
            pose.transform = bone;
            pose.baseRotation = bone.localRotation;
            pose.baseScale = bone.localScale;
            pose.basePosition = bone.localPosition;
            pose.rotationAxis = UnityEngine.Random.onUnitSphere;
            pose.phase = UnityEngine.Random.Range(0f, Mathf.PI * 2f);
            pose.rate = UnityEngine.Random.Range(2f, 5f);

            string name = bone.name.ToLowerInvariant();
            Vector3 awayFromParent = pose.basePosition.sqrMagnitude > 0.0001f
                ? pose.basePosition.normalized
                : Vector3.right;
            if (name.Contains("head"))
                pose.positionOffset = new Vector3(0f, 1.35f, 0f);
            else if (name.Contains("shoulder") || name.Contains("clavicle"))
                // Keep the shoulders equally separated; this is deliberately
                // smaller than the old one-sided-looking shoulder pull.
                pose.positionOffset = awayFromParent * 0.65f;
            else if (name.Contains("upperarm") || name.Contains("upper_arm") ||
                     name.Contains("forearm") || name.Contains("lowerarm") ||
                     name.Contains("lower_arm") || name.Contains("bottom_arm"))
                // Every main arm segment gets the same extension.  Using the
                // bone's own rest direction preserves the mirrored left/right
                // layout regardless of how a particular stock suit names it.
                pose.positionOffset = awayFromParent * 0.65f;
            else if (name.Contains("thigh") || name.Contains("upperleg") ||
                     name.Contains("upper_leg") || name.Contains("calf") ||
                     name.Contains("lowerleg") || name.Contains("lower_leg"))
                // Match the arm segment extension on both legs, giving the
                // Mimic four comparably long limbs instead of only long arms.
                pose.positionOffset = awayFromParent * 0.65f;
            else if (name.Contains("hand") || name.Contains("foot") || name.Contains("toe"))
                // End segments are identical for arms and legs so neither side
                // grows a comically longer hand/foot than the other.
                pose.positionOffset = awayFromParent * 0.45f;
            else if (name.Contains("spine") || name.Contains("neck"))
                pose.positionOffset = new Vector3(0f, 0.18f, 0f);

            // Preserve mesh thickness; this is displacement, not extreme scaling.
            bone.localScale = pose.baseScale;
            poses.Add(pose);
        }
    }

    private void Update()
    {
        if (Time.timeScale <= 0f)
            return;

        float time = Time.time;
        foreach (BonePose pose in poses)
        {
            if (pose.transform == null)
                continue;
            float rateMultiplier = chasing ? 4.5f : (roaming ? 1.35f : 1f);
            float angleMultiplier = chasing ? 5f : (roaming ? 1.8f : 1f);
            Vector3 jitter = chasing
                ? UnityEngine.Random.insideUnitSphere * 0.12f
                : (roaming ? UnityEngine.Random.insideUnitSphere * 0.015f : Vector3.zero);
            pose.transform.localPosition = pose.basePosition + pose.positionOffset + jitter;
            float angle = (Mathf.Sin(time * pose.rate * rateMultiplier + pose.phase) * 14f
                          + Mathf.Sin(time * (pose.rate * 0.37f) * rateMultiplier + pose.phase * 2f) * 8f) * angleMultiplier;
            pose.transform.localRotation = pose.baseRotation * Quaternion.AngleAxis(angle, pose.rotationAxis);
        }

    }
}

// A compact procedural animation layer for the imported nightmare rig.  It
// works from the actual SkinnedMeshRenderer bone list, so the limbs and spine
// move independently even though the source clip could not be exported.
public class BulkEntityBoneAnimation : MonoBehaviour
{
    private enum BoneKind { Other, UpperLeg, LowerLeg, Foot, UpperArm, LowerArm, Hand, Clavicle, Spine }

    private sealed class BonePose
    {
        public Transform transform;
        public Quaternion baseRotation;
        public Vector3 axis;
        public float phase;
        public float rate;
        public float amplitude;
        public BoneKind kind;
        public float side;
    }

    private readonly List<BonePose> poses = new List<BonePose>();
    private bool chasing;

    public void Configure(GameObject root)
    {
        if (root == null)
            return;

        HashSet<Transform> bones = new HashSet<Transform>();
        foreach (SkinnedMeshRenderer skin in root.GetComponentsInChildren<SkinnedMeshRenderer>(true))
        {
            foreach (Transform bone in skin.bones)
            {
                if (bone != null && bones.Add(bone))
                {
                    string name = bone.name.ToLowerInvariant();
                    poses.Add(new BonePose
                    {
                        transform = bone,
                        baseRotation = bone.localRotation,
                        axis = UnityEngine.Random.onUnitSphere,
                        phase = UnityEngine.Random.Range(0f, Mathf.PI * 2f),
                        rate = UnityEngine.Random.Range(5.5f, 10f),
                        amplitude = UnityEngine.Random.Range(2f, 5f),
                        kind = GetBoneKind(name),
                        side = name.Contains(".r") || name.Contains("right") ? -1f : 1f
                    });
                }
            }
        }
    }

    public void SetChasing(bool value)
    {
        chasing = value;
    }

    private static BoneKind GetBoneKind(string name)
    {
        if (name.Contains("upper_leg")) return BoneKind.UpperLeg;
        if (name.Contains("low_leg") || name.Contains("lower_leg")) return BoneKind.LowerLeg;
        if (name.Contains("foot") || name.Contains("toe")) return BoneKind.Foot;
        if (name.Contains("upper_arm")) return BoneKind.UpperArm;
        if (name.Contains("bottom_arm") || name.Contains("lower_arm") || name.Contains("forearm")) return BoneKind.LowerArm;
        if (name.Contains("hand")) return BoneKind.Hand;
        if (name.Contains("clavicle") || name.Contains("shoulder")) return BoneKind.Clavicle;
        if (name.Contains("spine") || name.Contains("chest")) return BoneKind.Spine;
        return BoneKind.Other;
    }

    private void LateUpdate()
    {
        if (Time.timeScale <= 0f)
            return;

        float time = Time.time;
        foreach (BonePose pose in poses)
        {
            if (pose.transform == null)
                continue;

            if (!chasing)
            {
                pose.transform.localRotation = Quaternion.Slerp(pose.transform.localRotation, pose.baseRotation, TimeWarp.deltaTime * 8f);
                continue;
            }

            float stride = Mathf.Sin(time * 10f);
            float legSwing = stride * pose.side;
            Quaternion offset;
            switch (pose.kind)
            {
                case BoneKind.UpperLeg:
                    offset = Quaternion.AngleAxis(31f * legSwing, Vector3.right);
                    break;
                case BoneKind.LowerLeg:
                    offset = Quaternion.AngleAxis(-25f * legSwing, Vector3.right);
                    break;
                case BoneKind.Foot:
                    offset = Quaternion.AngleAxis(12f * legSwing, Vector3.right);
                    break;
                case BoneKind.Clavicle:
                    // Pull both shoulders ahead of the torso and keep them there.
                    offset = Quaternion.Euler(-22f, 0f, 7f * pose.side);
                    break;
                case BoneKind.UpperArm:
                    // Raised, forward-reaching arms with only a small running pump.
                    offset = Quaternion.Euler(-48f + 7f * legSwing, 0f, 11f * pose.side);
                    break;
                case BoneKind.LowerArm:
                    offset = Quaternion.Euler(-36f - 5f * legSwing, 0f, 4f * pose.side);
                    break;
                case BoneKind.Hand:
                    offset = Quaternion.Euler(-14f, 0f, 2f * pose.side);
                    break;
                case BoneKind.Spine:
                    offset = Quaternion.Euler(2.5f * stride, 0f, 1.5f * stride);
                    break;
                default:
                    // Tiny independent movement keeps the rest of the rig alive
                    // without the previous full-body violent sway.
                    float angle = Mathf.Sin(time * pose.rate + pose.phase) * pose.amplitude;
                    offset = Quaternion.AngleAxis(angle, pose.axis);
                    break;
            }
            pose.transform.localRotation = pose.baseRotation * offset;
        }
    }
}

// The PSX creature's rig uses numbered bone names, so animate its actual
// skinned bones by index rather than relying on humanoid naming conventions.
public class LaytheCreatureBoneAnimation : MonoBehaviour
{
    private sealed class Pose { public Transform bone; public Quaternion baseRotation; public int index; }
    private readonly List<Pose> poses = new List<Pose>();
    private bool chasing;
    private bool roaming;

    public void Configure(GameObject root)
    {
        HashSet<Transform> seen = new HashSet<Transform>();
        foreach (SkinnedMeshRenderer skin in root.GetComponentsInChildren<SkinnedMeshRenderer>(true))
        {
            foreach (Transform bone in skin.bones)
            {
                if (bone != null && seen.Add(bone))
                    poses.Add(new Pose { bone = bone, baseRotation = bone.localRotation, index = poses.Count });
            }
        }
    }

    public void SetState(bool isChasing, bool isRoaming) { chasing = isChasing; roaming = isRoaming; }

    private void LateUpdate()
    {
        float blend = chasing ? 1f : (roaming ? 0.22f : 0f);
        float rate = chasing ? 11f : 2.2f;
        foreach (Pose pose in poses)
        {
            if (pose.bone == null) continue;
            // The first four numbered bones form the root/torso.  Leave them
            // untouched, then give each of the four limb chains a separate
            // phase.  Child joints bend against their parent so this reads as
            // a running stride instead of every limb moving in unison.
            if (pose.index < 4)
            {
                pose.bone.localRotation = pose.baseRotation;
                continue;
            }
            int limbIndex = (pose.index - 4) / 4;
            int jointIndex = (pose.index - 4) % 4;
            float phase = limbIndex * (Mathf.PI * 0.5f);
            float swing = Mathf.Sin(Time.time * rate + phase);
            float bend = Mathf.Sin(Time.time * rate + phase + 0.8f);
            float amount = jointIndex == 0 ? 28f : (jointIndex == 1 ? -21f : (jointIndex == 2 ? 15f : -9f));
            Quaternion offset = Quaternion.Euler(amount * swing, 2.5f * bend, 1.5f * swing);
            pose.bone.localRotation = Quaternion.Slerp(pose.baseRotation, pose.baseRotation * offset, blend);
        }
    }
}

// The Rake source asset had to be exported as static mesh pieces because its
// armature is incompatible with the legacy MU writer. Animate those pieces
// directly to give it a physical, snarling chase motion in KSP.
public class RakeEntityAnimation : MonoBehaviour
{
    private sealed class PiecePose
    {
        public Transform transform;
        public Vector3 basePosition;
        public Quaternion baseRotation;
        public string name;
        public float phase;
    }

    private readonly List<PiecePose> pieces = new List<PiecePose>();
    private bool chasing;

    public void Configure(GameObject root)
    {
        foreach (Renderer renderer in root.GetComponentsInChildren<Renderer>(true))
        {
            Transform transform = renderer.transform;
            pieces.Add(new PiecePose
            {
                transform = transform,
                basePosition = transform.localPosition,
                baseRotation = transform.localRotation,
                name = transform.name.ToLowerInvariant(),
                phase = UnityEngine.Random.Range(0f, Mathf.PI * 2f)
            });
        }
    }

    public void SetChasing(bool value)
    {
        chasing = value;
    }

    private void LateUpdate()
    {
        float blend = chasing && Time.timeScale > 0f ? 1f : 0f;
        float time = Time.time;
        foreach (PiecePose piece in pieces)
        {
            if (piece.transform == null)
                continue;
            float stride = Mathf.Sin(time * 10f + piece.phase);
            Vector3 position = piece.basePosition;
            Quaternion rotation = piece.baseRotation;
            if (piece.name.Contains("lowerteeth"))
            {
                // Jaw opens and snaps shut while it runs.
                position += Vector3.down * (0.10f + 0.06f * Mathf.Abs(stride)) * blend;
                rotation *= Quaternion.Euler(8f * Mathf.Abs(stride) * blend, 0f, 0f);
            }
            else if (piece.name.Contains("upperteeth"))
            {
                rotation *= Quaternion.Euler(-3f * Mathf.Abs(stride) * blend, 0f, 0f);
            }
            else if (piece.name.Contains("eyes"))
            {
                position += new Vector3(0.012f * stride, 0.018f * Mathf.Sin(time * 17f + piece.phase), 0f) * blend;
            }
            else
            {
                // Body meshes receive a short forward-and-down lunge each stride.
                position += new Vector3(0f, -0.045f * Mathf.Abs(stride), 0.055f * stride) * blend;
                rotation *= Quaternion.Euler(2f * stride * blend, 0f, 0f);
            }
            piece.transform.localPosition = position;
            piece.transform.localRotation = rotation;
        }
    }
}

// Drives the exported Rake armature directly: alternating legs, a forward
// reach, and a hunched spine while chasing.
public class RakeBoneAnimation : MonoBehaviour
{
    private enum BoneKind { Other, Thigh, Shin, Foot, UpperArm, Forearm, Hand, Spine, Head }

    private sealed class BonePose
    {
        public Transform transform;
        public Quaternion baseRotation;
        public BoneKind kind;
        public float side;
        public float phase;
    }

    private readonly List<BonePose> poses = new List<BonePose>();
    private bool chasing;

    public void Configure(GameObject root)
    {
        HashSet<Transform> bones = new HashSet<Transform>();
        foreach (SkinnedMeshRenderer skin in root.GetComponentsInChildren<SkinnedMeshRenderer>(true))
        {
            foreach (Transform bone in skin.bones)
            {
                if (bone == null || !bones.Add(bone))
                    continue;
                string name = bone.name.ToLowerInvariant();
                poses.Add(new BonePose
                {
                    transform = bone,
                    baseRotation = bone.localRotation,
                    kind = Classify(name),
                    side = name.Contains(".r") || name.Contains("right") ? -1f : 1f,
                    phase = UnityEngine.Random.Range(0f, Mathf.PI * 2f)
                });
            }
        }
    }

    public void SetChasing(bool value)
    {
        chasing = value;
    }

    private static BoneKind Classify(string name)
    {
        if (name.Contains("thigh")) return BoneKind.Thigh;
        if (name.Contains("shin")) return BoneKind.Shin;
        if (name.Contains("foot") || name.Contains("toe")) return BoneKind.Foot;
        if (name.Contains("upper_arm")) return BoneKind.UpperArm;
        if (name.Contains("forearm")) return BoneKind.Forearm;
        if (name.Contains("hand")) return BoneKind.Hand;
        if (name.Contains("spine")) return BoneKind.Spine;
        if (name.Contains("head")) return BoneKind.Head;
        return BoneKind.Other;
    }

    private void LateUpdate()
    {
        float blend = chasing && Time.timeScale > 0f ? 1f : 0f;
        float stride = Mathf.Sin(Time.time * 10f);
        foreach (BonePose pose in poses)
        {
            if (pose.transform == null)
                continue;
            float swing = stride * pose.side;
            Quaternion offset;
            switch (pose.kind)
            {
                case BoneKind.Thigh: offset = Quaternion.AngleAxis(35f * swing, Vector3.right); break;
                case BoneKind.Shin: offset = Quaternion.AngleAxis(-30f * swing, Vector3.right); break;
                case BoneKind.Foot: offset = Quaternion.AngleAxis(14f * swing, Vector3.right); break;
                case BoneKind.UpperArm: offset = Quaternion.Euler(-52f + 8f * swing, 0f, 10f * pose.side); break;
                case BoneKind.Forearm: offset = Quaternion.Euler(-36f - 5f * swing, 0f, 4f * pose.side); break;
                case BoneKind.Hand: offset = Quaternion.Euler(-16f, 0f, 0f); break;
                case BoneKind.Spine: offset = Quaternion.Euler(5f * stride, 0f, 2f * stride); break;
                case BoneKind.Head: offset = Quaternion.Euler(-8f, 0f, 0f); break;
                default: offset = Quaternion.AngleAxis(2.5f * Mathf.Sin(Time.time * 7f + pose.phase), Vector3.up); break;
            }
            pose.transform.localRotation = Quaternion.Slerp(pose.baseRotation, pose.baseRotation * offset, blend);
        }
    }
}
