/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ using GameCult.Caching; using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using Cinemachine; using Ink; using Ink.Runtime; using MessagePack; using Newtonsoft.Json; using TMPro; using UniRx; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.Rendering.PostProcessing; using UnityEngine.Serialization; using UnityEngine.UI; using CultMath; using CultMath.UnityBridge; using UnityEngine.EventSystems; using static CultMath.math; using float2 = CultMath.float2; using float3 = CultMath.float3; using Path = System.IO.Path; using quaternion = CultMath.quaternion; using Random = UnityEngine.Random; public class ActionGameManager : MonoBehaviour { // Always check for null if accessing from anywhere that might not be in-game (e.g. menu UI) public static ActionGameManager Instance { get; private set; } private static DirectoryInfo _gameDataDirectory; public static DirectoryInfo GameDataDirectory { get => _gameDataDirectory ??= new DirectoryInfo(Application.dataPath).Parent.CreateSubdirectory("GameData"); } private static CultCache _cultCache; // Mod packages left out of this boot (a bad package, or a name collision), for the main menu to name. public static ShipModCatalog.Exclusion[] ModExclusions { get; private set; } = Array.Empty(); private static string CatalogPath => Path.Combine(GameDataDirectory.FullName, "Aetheria.cc"); public static string ModsPath => Path.Combine(GameDataDirectory.FullName, "Mods"); // Disposable: composed at every boot from the shipped catalog and the mod packages, never authored. private static string DerivedCatalogPath => Path.Combine(Application.persistentDataPath, "Aetheria.modded.cc"); public static CultCache CultCache { get { if (_cultCache != null) return _cultCache; // All three stores attach once and stay attached until the process exits. The run lifecycle is record-level // inside this cache; reopening would replace the catalog instances the galaxy and live entities hold. // The catalog is read-only in every build; capturepreset writes through its own cache (Loadouts.Commit) to the // shipped file. With mod packages installed the game reads the derived catalog instead. var (catalog, excluded) = ShipModCatalog.ResolveCatalog(CatalogPath, DerivedCatalogPath, ModsPath); ModExclusions = excluded; _cultCache = AetheriaStores.Open( catalog, runPath: Path.Combine(GameDataDirectory.FullName, "run.cc"), playerPath: Path.Combine(GameDataDirectory.FullName, "player.cc")); // Mod ship prototypes import asynchronously from here; entering a game waits on ShipModShips.Loading. ShipModShips.Preload(_cultCache, ModsPath); return _cultCache; } } // The only creator of PlayerSettings: an absent global means first launch, and the defaults are committed. public static PlayerSettings PlayerSettings { get { var settings = CultCache.GetGlobal(); if (settings != null) return settings; settings = GetDefaultPlayerSettings(); CultCache.Commit(batch => batch.Upsert(settings)); return settings; } } public static void SavePlayerSettings() { var settings = PlayerSettings; CultCache.Commit(batch => batch.Upsert(settings)); } private static PlayerSettings GetDefaultPlayerSettings() { var settings = new PlayerSettings(); settings.Name = Environment.UserName; settings.InputSettings.ActionBarInputs.Add("/leftShift"); settings.InputSettings.ActionBarInputs.Add("/leftButton"); settings.InputSettings.ActionBarInputs.Add("/rightButton"); settings.InputSettings.ActionBarInputs.Add("/middleButton"); for (int i = 1; i < 6; i++) settings.InputSettings.ActionBarInputs.Add($"/{i}"); return settings; } public static Galaxy CurrentGalaxy; // The scenario a new run starts from: MainMenu.Launch sets it, and StartGame's new-run branch consumes it once. public static Scenario PendingScenario; public GameSettings Settings; //public string StarterShipTemplate = "Longinus"; // The run's credits: seeded from the scenario stage on a new game and from SavedGame on Continue, written by SaveRun. [NonSerialized] public int Credits; // Presentation scale: the player's own Visibility at which the target panel's VisibilityToTargetFill reads full. public float OwnSignatureFullScale = 10; // The music last asked for: Update decides it each frame from what the player's own instruments hold and calls // PlayMusic only when it changes. private MusicType? _music; [Header("Postprocessing")] public float DeathPostTransitionTime; public PostProcessVolume DeathPost; public PostProcessVolume HeatstrokePost; public PostProcessVolume HypothermiaPost; public PostProcessVolume SevereHeatstrokePost; public PostProcessVolume SevereHypothermiaPost; [Header("Scene Links")] public GameObject UiRoot; public GameObject HelpScreen; public InputDisplayLayout InputDisplayLayout; public Transform ActionBar; public ActionBarSlot ActionBarSlot; public Transform EffectManagerParent; public ZoneRenderer ZoneRenderer; public CinemachineVirtualCamera DockCamera; public CinemachineVirtualCamera FollowCamera; public CinemachineVirtualCamera WormholeCamera; //public SectorRenderer SectorRenderer; public SectorMap SectorMap; [Header("Menu UI")] public MainMenu MainMenu; public MenuPanel Menu; public MapRenderer MenuMap; public TradeMenu TradeMenu; public InventoryMenu Inventory; public InventoryPanel ShipPanel; public InventoryPanel TargetShipPanel; public ConfirmationDialog Dialog; public ContextMenu Context; public DropdownMenu Dropdown; [Header("Gameplay UI")] public CanvasGroup GameplayUI; public EventLog EventLog; public Prototype HostileTargetIndicator; public Prototype FriendlyTargetIndicator; public PlaceUIElementWorldspace ViewDot; public Prototype LockIndicator; // Presentation tuning: how far from the ship, toward the locker, the incoming-lock indicator sits. public float IncomingLockIndicatorDistance = 30; // Presentation tuning: how far from the ship, toward a remembered hostile's bearing, its marker sits; how long a // sweep flash takes to fade. public float BearingMarkerDistance = 40; public float SearchFlashSeconds = .6f; public PlaceUIElementWorldspace[] Crosshairs; public GameObject HitMarker; public float HitMarkerDuration; public SchematicDisplay SchematicDisplay; public SchematicDisplay TargetSchematicDisplay; public TextMeshProUGUI DebugInfoText; [Header("Target Indicator")] public PlaceUIElementWorldspace TargetIndicator; public Image TargetHitpointsFill; public Image TargetVisibilityFill; public Image VisibilityToTargetFill; public Image TargetShieldsBackground; public Image TargetShieldsFill; public Image TargetShieldsIcon; public Color ShieldColor; public Color NoShieldColor; public Sprite ShieldIcon; public Sprite NoShieldIcon; //public PlayerInput Input; // private CinemachineFramingTransposer _transposer; // private CinemachineComposer _composer; private bool _paused; private float _time; private int _zoomLevelIndex; private Entity _currentEntity; // private ShipInput _shipInput; private float2 _entityYawPitch; private float3 _viewDirection; // The player's face-the-aim state (Left Shift held, Caps Lock latched); Ship.Turn is its only output. private readonly HelmInput _helm = new HelmInput(); private TextMeshProUGUI _faceAimMark; private (HardpointData[] hardpoints, Transform[] barrels, PlaceUIElementWorldspace crosshair)[] _articulationGroups; // The outgoing progress indicators on the player's target: one per lock weapon (its Lock, shown only on a hostile target) // and one per scanner (its scan Progress, shown on any target: a scan reads no stance). private (Func progress, bool hostileOnly, PlaceUIElementWorldspace indicator, Rotate spin)[] _progressIndicators; private (PlaceUIElementWorldspace indicator, Rotate spin) _incomingLockIndicator; private Dictionary _visibleHostileIndicators = new Dictionary(); private Dictionary _visibleFriendlyIndicators = new Dictionary(); // Display-only: a marker per remembered hostile (shares HostileTargetIndicator's pool), the Search warnings the last // ticks delivered (bearing, zone time; a per-tick buffer), and the sweep flashes drawn from them. private readonly Dictionary _bearingMarkers = new Dictionary(); private readonly List<(float2 bearing, float time)> _pendingSweeps = new List<(float2, float)>(); private readonly List<(PlaceUIElementWorldspace indicator, Image image, float2 bearing, float time)> _sweepFlashes = new List<(PlaceUIElementWorldspace, Image, float2, float)>(); private readonly List _markerScratch = new List(); private List _shipSubscriptions = new List(); private List _targetSubscriptions = new List(); private readonly Dictionary _debugLastShots = new Dictionary(); private float _severeHeatstrokePhase; private bool _uiHidden; private bool _menuShown; private List _actionBarSlots = new List(); private List _actionBarActions = new List(); private float _hitMarkerTime; public AetheriaInput Input { get; private set; } public EquippedDockingBay DockingBay { get; private set; } public Entity DockedEntity { get; private set; } //if better place, please move public Entity TowingStation { get; private set; } public ZoneEnvironment CurrentEnvironment { get { return Settings.DefaultEnvironment; } } public Entity CurrentEntity { get => _currentEntity; set => _currentEntity = value; } public ItemManager ItemManager { get; private set; } public Zone Zone { get; private set; } public static SimClock Clock { get; } = new SimClock(1f / 60f); private bool _zoneEntered; private readonly (float2 direction, string name)[] _directions = { (float2(0, 1), "Front"), (float2(1, 0), "Right"), (float2(-1, 0), "Left"), (float2(0, -1), "Rear") }; public DragObject DragObject { get; private set; } private Func _endDragCallback; private List _stories = new List(); private void OnApplicationQuit() { SaveRun(); SavePlayerSettings(); } // Wormhole arrival and quit save the run; a dead run has no galaxy, so it is not saved. public void SaveRun() { if (CurrentGalaxy != null) { var (game, zones) = RunSave.Capture(CultCache, CurrentGalaxy, Zone, DockedEntity ?? CurrentEntity, _actionBarSlots.Select(s => s.Save()).ToArray(), Credits); RunSave.Commit(CultCache, game, zones, ItemManager.Lots); } } private void OnDisable() { Input.Dispose(); ConsoleController.ClearCommands(); EntityInstance.ClearWeaponManagers(); } void Start() { Instance = this; EntityInstance.EffectManagerParent = EffectManagerParent; ConsoleController.MessageReceiver = this; // A new run mints into an empty ledger; the saved run's stays in the store until the new run has started. ItemManager = new ItemManager(CultCache, PendingScenario != null ? new ProvenanceLedger() : RunSave.Lots(CultCache), Settings.GameplaySettings, Debug.Log); ZoneRenderer.ItemManager = ItemManager; // TODO: Process Stories #region Input Handling Input = new AetheriaInput(); foreach (var action in PlayerSettings.InputSettings.InputActionMap) foreach (var binding in action.Value) Input.asset[action.Key].ApplyBindingOverride(binding.Key, binding.Value); InputDisplayLayout.Input = Input.asset; Input.Global.Enable(); // The latch is the game's own, so it can disagree with the keyboard's Caps Lock light: show it. Input.Player.ToggleFaceAim.performed += context => _helm.Toggle(); var markParent = GameplayUI != null ? GameplayUI.transform : UiRoot.transform; var markObject = new GameObject("Face Aim Mark", typeof(RectTransform)); markObject.transform.SetParent(markParent, false); _faceAimMark = markObject.AddComponent(); _faceAimMark.text = "FACE AIM"; _faceAimMark.fontSize = 24; _faceAimMark.alignment = TextAlignmentOptions.Center; _faceAimMark.raycastTarget = false; _faceAimMark.rectTransform.anchorMin = _faceAimMark.rectTransform.anchorMax = new Vector2(.5f, .12f); _faceAimMark.rectTransform.sizeDelta = new Vector2(300, 40); _faceAimMark.enabled = false; _zoomLevelIndex = Settings.DefaultMinimapZoom; Input.Player.MinimapZoom.performed += context => { _zoomLevelIndex = (_zoomLevelIndex + 1) % Settings.MinimapZoomLevels.Length; ZoneRenderer.MinimapDistance = Settings.MinimapZoomLevels[_zoomLevelIndex]; }; Input.Global.ZoneMap.performed += context => { ToggleMenuTab(MenuTab.Map); MenuMap.Position = DrawAhead.Position(CurrentEntity, Clock.Lead).xz; }; Input.Global.Inventory.performed += context => ToggleMenuTab(MenuTab.Inventory); Input.Global.GalaxyMap.performed += context => ToggleMenuTab(MenuTab.Galaxy); Input.Global.Interact.performed += context => { if (EventSystem.current.currentSelectedGameObject != null && EventSystem.current.currentSelectedGameObject.GetComponent() != null) return; if (MainMenu.gameObject.activeSelf) return; if (CurrentEntity == null) { // TODO: SFX: Fail Dialog.Clear(); Dialog.Title.text = "Can't undock. You dont have a ship!"; Dialog.Show(); Dialog.MoveToCursor(); } else if (CurrentEntity.Parent == null) { foreach (var wormhole in ZoneRenderer.WormholeInstances.Keys) { if (!(length(wormhole.Position - CurrentEntity.Position.xz) < Settings.GameplaySettings.WormholeExitRadius)) continue; if (wormhole.Exit) { // The exit gate leads nowhere: TryWin consults RunGoal and ends the run when the gate is open. if (!TryWin()) { Dialog.Clear(); Dialog.Title.text = $"The exit gate is sealed. {RunGoal.Boss(Zone)?.Name ?? "Its guardian"} holds it shut."; Dialog.Show(); Dialog.MoveToCursor(); } return; } EnterWormhole(wormhole); } Dock(); } else Undock(); }; Input.Global.MainMenu.performed += context => { if(Menu.gameObject.activeSelf) ToggleMenuTab(Menu.CurrentTab); else ToggleFullscreenMenu(MainMenu.gameObject); }; Input.Global.InputScreen.performed += context => ToggleFullscreenMenu(HelpScreen); Input.Player.HideUI.performed += context => { _uiHidden = !_uiHidden; GameplayUI.alpha = _uiHidden ? 0 : 1; ActionBar.gameObject.SetActive(!_uiHidden); }; Input.Player.OverrideShutdown.performed += context => { CurrentEntity.OverrideShutdown = !CurrentEntity.OverrideShutdown; }; Input.Player.Ping.performed += context => { CurrentEntity.Sensor?.Ping(); }; Input.Player.ToggleHeatsinks.performed += context => { CurrentEntity.HeatsinksEnabled = !CurrentEntity.HeatsinksEnabled; // TODO: SFX: Success/Fail }; Input.Player.ToggleShield.performed += context => { if (CurrentEntity.Shield != null) { CurrentEntity.Shield.Item.Enabled.Value = !CurrentEntity.Shield.Item.Enabled.Value; // TODO: SFX: Success/Fail } }; // Flips the player's own stance on the current target between hostile and neutral. Going // neutral mid-fight safes weapons (Weapon.StanceAllowsFire); an unmarked/undetected target // reads as non-hostile here, so the first press declares hostile. Input.Player.ToggleStance.performed += context => { var target = CurrentEntity.Target.Value.Entity; if (target == null) return; CurrentEntity.SetIff(target, !CurrentEntity.IsHostileTo(target)); }; #region Targeting // The reticle, nearest, next and previous presses each choose through Entity (mining target-queries cut): // the rule lives with the entity, so the player, the test agent and any director pick alike. Input.Player.TargetReticle.performed += context => CurrentEntity.TargetUnderReticle(); Input.Player.TargetNearest.performed += context => CurrentEntity.TargetNearestEnemy(); Input.Player.TargetNext.performed += context => CurrentEntity.TargetNext(); Input.Player.TargetPrevious.performed += context => CurrentEntity.TargetPrevious(); // Cut 2 (docs/fire-control-cut.md): steps the aim through the target's schematic rows -- every mount this ship // has located, empty or not, minus the ones it saw destroyed. Decides nothing: the entity owns the cycle. Input.Player.CycleTargetItem.performed += context => CurrentEntity.CycleAim(); #endregion #region Action Bar ActionBarSlot createBinding(string controlPath) { var action = new InputAction(binding: controlPath); _actionBarActions.Add(action); var slot = Instantiate(ActionBarSlot, ActionBar); slot.Binding = null; _actionBarSlots.Add(slot); action.started += context => slot.Binding?.Activate(); action.canceled += context => slot.Binding?.Deactivate(); var shortName = controlPath.Substring(controlPath.LastIndexOf('/') + 1); var sprite = Resources.Load($"Sprites/Input/{shortName}"); if (sprite != null) { slot.InputIcon.sprite = sprite; slot.InputLabel.gameObject.SetActive(false); } else { slot.InputLabel.text = shortName; slot.InputIcon.gameObject.SetActive(false); } slot.PointerEnterTrigger.OnPointerEnterAsObservable().Subscribe(_ => { //Debug.Log($"Pointer entered action bar slot {controlPath}"); RegisterDragTarget(dragAction => { //Debug.Log("Registering binding!"); switch (dragAction) { case EquippedItemDragObject equippedItemDragAction: var trigger = equippedItemDragAction.EquippedItem.GetBehavior(); if (trigger == null) return false; slot.Binding = new ActionBarGearBinding(CurrentEntity, slot, equippedItemDragAction.EquippedItem, trigger); return true; case ItemInstanceDragObject itemInstanceDragAction: if (!(ItemManager.GetData(itemInstanceDragAction.Item) is ConsumableItemData consumable)) return false; slot.Binding = new ActionBarConsumableBinding(CurrentEntity, slot, consumable); return true; case WeaponGroupDragObject weaponGroupDragAction: slot.Binding = new ActionBarWeaponGroupBinding(CurrentEntity, slot, weaponGroupDragAction.Group); return true; default: throw new ArgumentOutOfRangeException(nameof(dragAction)); } }); }); slot.PointerExitTrigger.OnPointerExitAsObservable().Subscribe(_ => { //Debug.Log($"Pointer exited action bar slot {controlPath}"); UnregisterDragTarget(); }); return slot; } var bindings = PlayerSettings.InputSettings.ActionBarInputs.OrderBy(i => i) .Select(createBinding).ToList(); #endregion #endregion StartGame(); //if (!PlayerSettings.InputSettings.ActionBarInputs.Any()) { var newbinds = Enumerable.Range(0, 64)//CurrentEntity.WeaponGroups .Zip( bindings, (i, slot) => slot.Binding = new ActionBarWeaponGroupBinding(CurrentEntity, slot, i) ); } ConsoleController.AddCommand("revealzones", _ => { foreach (var zones in CurrentGalaxy.Zones .Where(z=>!CurrentGalaxy.DiscoveredZones.Contains(z)) .GroupBy(z=>z.Distance[CurrentGalaxy.Entrance]) .OrderBy(g=>g.Key)) { SectorMap.QueueZoneReveal(zones); } }); ConsoleController.AddCommand("give", args => { var itemName = string.Join(" ", args); var item = ItemManager.ItemData.GetAll() .FirstOrDefault(itemData => string.Equals(itemData.Name, itemName, StringComparison.InvariantCultureIgnoreCase)); if (item == null) { ConsoleController.Instance.AppendLogLine($"No equippable item named \"{itemName}\""); return; } if (item is HullData hull) { if (hull.HullType != HullType.Ship) { ConsoleController.Instance.AppendLogLine($"Refused: {item.Name} is a {hull.HullType} hull, not a ship"); return; } if (DockedEntity == null) { ConsoleController.Instance.AppendLogLine($"Refused: dock to take delivery of a {item.Name}"); return; } CommissionShip(ItemManager.CreateInstance(ItemManager.CreateLot(item, default, .95f)) as EquippableItem); ConsoleController.Instance.AppendLogLine($"{item.Name} moored at {DockedEntity.Name}"); return; } if (!_currentEntity.CargoBays.First().TryStore(ItemManager.CreateInstance(ItemManager.CreateLot(item, default, .95f)))) ConsoleController.Instance.AppendLogLine($"Refused: no cargo space for {item.Name}"); }); ConsoleController.AddCommand("trackmissile", _ => { foreach (var missileManager in FindObjectsByType(FindObjectsSortMode.None)) { missileManager.OnFireGuided.Where(x => x.source == _currentEntity).Take(1).Subscribe(x => { FollowCamera.Follow = x.missile.transform; FollowCamera.LookAt = x.target ? x.target : x.missile.transform; x.missile.OnKill += () => { FollowCamera.LookAt = ZoneRenderer.EntityInstances[CurrentEntity].LookAtPoint; FollowCamera.Follow = ZoneRenderer.EntityInstances[CurrentEntity].transform; }; }); } }); ConsoleController.AddCommand("spawnturret", _ => { var nearestFaction = CurrentGalaxy.Factions.MinBy(f => CurrentGalaxy.HomeZones[f].Distance[Zone.GalaxyZone]); var loadoutGenerator = new LoadoutGenerator(ref ItemManager.Random, ItemManager, CurrentGalaxy, Zone.GalaxyZone, nearestFaction, .5f); var turret = EntitySerializer.Unpack(ItemManager, Zone, loadoutGenerator.GenerateTurretLoadout()); turret.Position.xz = _currentEntity.Position.xz + ItemManager.Random.NextFloat2Direction() * ItemManager.Random.NextFloat(50, 500); Zone.Admit(turret, piloted: false); }); //Temporary, or not ConsoleController.AddCommand("tow", _ => TowShip()); // Manual IFF override for the player's current target, for hand-testing combat. // "iff hostile"/"iff neutral" force a stance; "iff clear" restores the derived faction rule. ConsoleController.AddCommand("iff", args => { var console = ConsoleController.Instance; var target = _currentEntity?.Target.Value.Entity; if (target == null) { console.AppendLogLine("iff: no target selected"); return; } var mode = args.Length > 0 ? args[0] : ""; if (mode != "hostile" && mode != "neutral" && mode != "clear") { console.AppendLogLine("usage: iff hostile|neutral|clear"); return; } switch (mode) { case "hostile": _currentEntity.SetIff(target, true); break; case "neutral": _currentEntity.SetIff(target, false); break; case "clear": _currentEntity.SetIff(target, null); break; } console.AppendLogLine($"{target.Name}: {(_currentEntity.IsHostileTo(target) ? "hostile" : "neutral")}"); }); // Editor only: capturepreset "" [replace] writes the piloted ship as a catalog preset. A multi-word name must // be quoted; any other second argument than replace is refused rather than dropped. if (Application.isEditor) ConsoleController.AddCommand("capturepreset", args => { var console = ConsoleController.Instance; var name = args.Length > 0 ? args[0] : ""; var replace = args.Length == 2 && args[1] == "replace"; if (string.IsNullOrWhiteSpace(name) || args.Length > 2 || args.Length == 2 && !replace) { console.AppendLogLine("usage: capturepreset \"\" [replace] (quote a name with spaces)"); return; } if (!(_currentEntity is Ship ship)) { console.AppendLogLine("capturepreset: pilot a ship first"); return; } try { var written = Loadouts.Commit(CatalogPath, Loadouts.Capture(ItemManager, ship, name), replace); console.AppendLogLine(written ? $"Preset '{name}' written to {CatalogPath}. This session sees it after the catalog reloads (next launch). " + "Reopen CultCache Studio before saving there: a Studio session opened earlier overwrites captured presets." : $"Preset '{name}' changed on disk since load; nothing written"); } catch (InvalidOperationException e) { console.AppendLogLine(e.Message); } }); } public void BeginDrag(DragObject dragObject) { this.DragObject = dragObject; } public void RegisterDragTarget(Func onEndDrag) { _endDragCallback = onEndDrag; } public void UnregisterDragTarget() { _endDragCallback = null; } public bool EndDrag() { var success = _endDragCallback?.Invoke(DragObject); DragObject = null; return success ?? false; } public void EnablePlayerInput() { Cursor.lockState = CursorLockMode.Locked; Input.Player.Enable(); foreach (var a in _actionBarActions) a.Enable(); } public void DisablePlayerInput() { Cursor.lockState = CursorLockMode.None; Input.Player.Disable(); foreach (var a in _actionBarActions) a.Disable(); } private void EnterWormhole(Wormhole wormhole) { if (!(CurrentEntity is Ship ship) || ship.WormholeAnimationInProgress) return; // var wormholeCameraFollow = new GameObject("Wormhole Camera Follow").transform; // wormholeCameraFollow.position = new Vector3(wormhole.Position.x, -50, wormhole.Position.y); // wormholeCameraFollow.rotation = Quaternion.LookRotation(Vector3.down, ship.Aim); // WormholeCamera.enabled = true; // WormholeCamera.Follow = wormholeCameraFollow; // FollowCamera.enabled = false; ship.EnterWormhole(wormhole.Position); ship.OnEnteredWormhole += () => { var oldZone = Zone; PopulateLevel(wormhole.Target); foreach (var zone in wormhole.Target.AdjacentZones) CurrentGalaxy.DiscoveredZones.Add(zone); SectorMap.QueueZoneReveal(wormhole.Target.AdjacentZones); ship.ExitWormhole(ZoneRenderer.WormholeInstances.Keys.First(w => w.Target == oldZone.GalaxyZone).Position, Settings.GameplaySettings.WormholeExitVelocity * ItemManager.Random.NextFloat2Direction()); CurrentEntity.Zone = Zone; SaveRun(); }; } public void PopulateLevel(GalaxyZone galaxyZone) { if (galaxyZone == null) throw new ArgumentNullException(nameof(galaxyZone)); if (galaxyZone.Contents == null) { galaxyZone.PackedContents ??= ZoneGenerator.GenerateZone( ItemManager, Settings.ZoneSettings, Settings.PlanetSettings, CurrentGalaxy, galaxyZone, CurrentGalaxy.IsPrelude ); galaxyZone.Contents = new Zone(ItemManager, Settings.PlanetSettings, galaxyZone.PackedContents, galaxyZone, CurrentGalaxy); } Zone = galaxyZone.Contents; _zoneEntered = true; // the clock forgets its lead between frames, never inside a step PlayMusic(MusicType.Overworld); _music = MusicType.Overworld; Zone.Log = s => Debug.Log($"Zone: {s}"); if (CurrentEntity != null) { CurrentEntity.Deactivate(); CurrentEntity.Zone.Remove(CurrentEntity); CurrentEntity.Zone = Zone; Zone.Admit(CurrentEntity, piloted: false); } ZoneRenderer.LoadZone(Zone); if (CurrentEntity != null) { UnbindEntity(); BindToEntity(CurrentEntity); } } private void ToggleFullscreenMenu(GameObject menu) { if (EventSystem.current != null && EventSystem.current.currentSelectedGameObject != null && EventSystem.current.currentSelectedGameObject.GetComponent() != null) return; if (CurrentEntity == null) return; if (menu.activeSelf) { _paused = false; menu.SetActive(false); UiRoot.SetActive(true); if (!_menuShown) { EnablePlayerInput(); UpdatePlayerPanel(); UpdateTargetPanel(CurrentEntity.Target.Value); } } else { _paused = true; menu.SetActive(true); UiRoot.SetActive(false); _menuShown = Menu.gameObject.activeSelf; if (!_menuShown) DisablePlayerInput(); } } private void ToggleMenuTab(MenuTab tab) { if (EventSystem.current.currentSelectedGameObject != null && EventSystem.current.currentSelectedGameObject.GetComponent() != null) return; if (MainMenu.gameObject.activeSelf) return; if (Menu.gameObject.activeSelf && Menu.CurrentTab == tab) { Menu.gameObject.SetActive(false); if (CurrentEntity != null && CurrentEntity.Parent == null) { EnablePlayerInput(); UpdatePlayerPanel(); UpdateTargetPanel(CurrentEntity.Target.Value); GameplayUI.gameObject.SetActive(true); } return; } DisablePlayerInput(); Menu.ShowTab(tab); GameplayUI.gameObject.SetActive(false); } private void StartGame() { if (CurrentGalaxy != null) { if (PendingScenario != null) StartScenario(); else { var saved = CultCache.GetGlobal(); Credits = saved.Credits; foreach(var group in CurrentGalaxy.DiscoveredZones .GroupBy(dz=>dz.Distance[CurrentGalaxy.Entrance])) SectorMap.QueueZoneReveal(group); PopulateLevel(CurrentGalaxy.Zones[saved.CurrentZone]); var targetEntity = Zone.Entities[saved.CurrentZoneEntity]; if (targetEntity is OrbitalEntity orbitalEntity) { CurrentEntity = targetEntity.Children.First(c => c is Ship {IsPlayerShip: true}); DoDock(orbitalEntity, orbitalEntity.DockingBays.First()); } else { BindToEntity(targetEntity); } for (var i = 0; i < _actionBarSlots.Count; i++) { _actionBarSlots[i].Restore(saved.ActionBarBindings[i], CurrentEntity); } } } } // RunStart owns what a new run starts with (docs/scenarios-cut.md, R.4); this enters and binds. The saved run is // replaced only once the scenario has staged. A start that fails leaves the saved run as it was and says why on // the main menu, where Continue still resumes the saved run. private void StartScenario() { var scenario = PendingScenario; PendingScenario = null; var failures = new List(); RunStart.Staged staged = null; try { staged = RunSave.Replace(CultCache, () => { RunStart.GenerateArena(ItemManager, Settings.ZoneSettings, Settings.PlanetSettings, CurrentGalaxy, scenario); PopulateLevel(CurrentGalaxy.Entrance); return RunStart.Stage(ItemManager, Zone, scenario, Settings.StartingHullName, Settings.TutorialGenerationSettings, failures); }); } catch (Exception exception) { Debug.LogException(exception); failures.Add(exception.Message); } if (staged == null) { CurrentGalaxy = null; MainMenu.gameObject.SetActive(true); MainMenu.Refuse($"{scenario.Name} could not start", string.Join("\n", failures)); return; } Credits = staged.Credits; SectorMap.QueueZoneReveal(CurrentGalaxy.Entrance.AdjacentZones.Prepend(CurrentGalaxy.Entrance)); BindToEntity(staged.Player); } public void Dock() { if (CurrentEntity.Parent != null) return; if (CurrentEntity is Ship ship) { foreach (var entity in Zone.Entities.ToArray()) { if (entity != CurrentEntity && lengthsq(entity.Position.xz - CurrentEntity.Position.xz) < Settings.GameplaySettings.DockingDistance * Settings.GameplaySettings.DockingDistance) { var bay = entity.TryDock(ship); if (bay != null) { UnbindEntity(); DoDock(entity, bay); // TODO: SFX: Docking //AkSoundEngine.PostEvent("Dock", gameObject); return; } } } } } // A ship hull acquired while docked becomes a bare player ship moored at the docked entity. It takes no docking // bay: mothballed ships do not need one. Buying from the trade menu and the give command both land here. public Ship CommissionShip(EquippableItem hull) { var ship = new Ship(ItemManager, Zone, hull, ItemManager.GameplaySettings.DefaultEntitySettings) { IsPlayerShip = true }; ship.SetParent(DockedEntity); return ship; } private void DoDock(Entity entity, EquippedDockingBay dockingBay) { TradeMenu.Inventory = entity.CargoBays.First(); DockedEntity = entity; ZoneRenderer.PerspectiveEntity = DockedEntity; DockingBay = dockingBay; DockCamera.enabled = true; FollowCamera.enabled = false; var orbital = (OrbitalEntity) entity; DockCamera.Follow = ZoneRenderer.EntityInstances[orbital].transform; var parentOrbit = Zone.Orbits[orbital.OrbitData].Data.Parent.Key; var parentOrbitPlanet = Zone.Planets.FirstOrDefault(p => p.Value.Orbit.Key.Equals(parentOrbit)).Key; if (parentOrbitPlanet.IsSet() && ZoneRenderer.Planets.ContainsKey(parentOrbitPlanet)) DockCamera.LookAt = ZoneRenderer.Planets[parentOrbitPlanet].Body.transform; else DockCamera.LookAt = ZoneRenderer.ZoneRoot; if (entity is OrbitalEntity {CanTow: true}) TowingStation = entity; Menu.ShowTab(MenuTab.Inventory); } public void Undock() { if (CurrentEntity.Parent == null) return; if (CurrentEntity is Ship ship) { if (CurrentEntity.GetBehavior() == null) { Dialog.Clear(); Dialog.Title.text = "Can't undock. Missing cockpit component!"; Dialog.Show(); Dialog.MoveToCursor(); // TODO: SFX: Fail } else if (CurrentEntity.GetBehavior() == null) { Dialog.Clear(); Dialog.Title.text = "Can't undock. Missing thruster component!"; Dialog.Show(); Dialog.MoveToCursor(); // TODO: SFX: Fail } else if (CurrentEntity.GetBehavior() == null) { Dialog.Clear(); Dialog.Title.text = "Can't undock. Missing reactor component!"; Dialog.Show(); Dialog.MoveToCursor(); // TODO: SFX: Fail } else if (CurrentEntity.Parent.TryUndock(ship)) { BindToEntity(ship); // TODO: SFX: Undock } else { Dialog.Title.text = "Can't undock. Must empty docking bay!"; Dialog.Show(); Dialog.MoveToCursor(); // TODO: SFX: Fail } } } public void TowShip() { if (CurrentEntity.Parent != null) return; if (CurrentEntity is Ship ship) { if (Zone.Equals(TowingStation.Zone)) { var distance = (int)length(TowingStation.Position.xz - CurrentEntity.Position.xz); CurrentEntity.Position.xz = TowingStation.Position.xz; Dock(); //Debug.Log($"${distance}"); //payment = distance * TowingZoneRate; } else { var distance = Zone.GalaxyZone.Distance[TowingStation.Zone.GalaxyZone]; PopulateLevel(TowingStation.Zone.GalaxyZone); CurrentEntity.Zone = Zone; CurrentEntity.Position.xz = TowingStation.Position.xz; Dock(); //Debug.Log($"${distance * 1000}"); //payment = distance * TowingGalaxyRate; } } } private void UnbindEntity() { foreach (var indicator in _visibleHostileIndicators) Destroy(indicator.Value.gameObject); _visibleHostileIndicators.Clear(); foreach (var indicator in _visibleFriendlyIndicators) Destroy(indicator.Value.gameObject); _visibleFriendlyIndicators.Clear(); foreach (var marker in _bearingMarkers.Values) RetireMarker(marker); _bearingMarkers.Clear(); foreach (var flash in _sweepFlashes) RetireFlash(flash.indicator, flash.image); _sweepFlashes.Clear(); _pendingSweeps.Clear(); if(_progressIndicators!=null) foreach(var (_, _, indicator, _) in _progressIndicators) indicator.GetComponent().ReturnToPool(); if(_incomingLockIndicator.indicator != null) _incomingLockIndicator.indicator.GetComponent().ReturnToPool(); _incomingLockIndicator = default; DisablePlayerInput(); Cursor.lockState = CursorLockMode.None; GameplayUI.gameObject.SetActive(false); foreach(var subscription in _shipSubscriptions) subscription.Dispose(); _shipSubscriptions.Clear(); _debugLastShots.Clear(); if (DebugInfoText != null) DebugInfoText.text = ""; } private void BindToEntity(Entity entity) { if (!ZoneRenderer.EntityInstances.ContainsKey(entity)) { Debug.LogError($"Attempted to bind to entity {entity.Name}, but SectorRenderer has no such instance!"); return; } CurrentEntity = entity; DeathPost.weight = 0; ZoneRenderer.PerspectiveEntity = CurrentEntity; Menu.gameObject.SetActive(false); DockedEntity = null; DockingBay = null; DockCamera.enabled = false; FollowCamera.enabled = true; if (length(CurrentEntity.Direction) > .1f) _viewDirection = float3(CurrentEntity.Direction.x,0,CurrentEntity.Direction.y); Cursor.lockState = CursorLockMode.Locked; EnablePlayerInput(); GameplayUI.gameObject.SetActive(true); ShipPanel.Display(CurrentEntity, true); SchematicDisplay.ShowShip(CurrentEntity); _shipSubscriptions.Add(CurrentEntity.Zone.ShotResolved .Where(outcome => outcome.Source == CurrentEntity) .Subscribe(outcome => _debugLastShots[outcome.Weapon] = outcome)); FollowCamera.LookAt = ZoneRenderer.EntityInstances[CurrentEntity].LookAtPoint; FollowCamera.Follow = ZoneRenderer.EntityInstances[CurrentEntity].transform; _articulationGroups = CurrentEntity.Equipment .Where(item => item.Behaviors.Any(x => x.Data is WeaponData && !(x.Data is LauncherData))) .GroupBy(item => ZoneRenderer.EntityInstances[CurrentEntity] .GetBarrel(item.Hardpoint) .GetComponentInParent()?.Group ?? -1) .Select((group, index) => { return ( group.Select(item => item.Hardpoint).ToArray(), group.Select(item => ZoneRenderer.EntityInstances[CurrentEntity].GetBarrel(item.Hardpoint)).ToArray(), Crosshairs[index] ); }).ToArray(); foreach (var crosshair in Crosshairs) crosshair.gameObject.SetActive(false); foreach (var group in _articulationGroups) group.crosshair.gameObject.SetActive(true); _shipSubscriptions.Add(CurrentEntity.Target.Subscribe(target => { // Clear previous subscriptions related to currently targeted enemy foreach(var subscription in _targetSubscriptions) subscription.Dispose(); _targetSubscriptions.Clear(); UpdateTargetPanel(target); var targetEntity = target.Entity; if (targetEntity != null) { if (targetEntity.Shield != null) { TargetShieldsBackground.color = new Color(ShieldColor.r, ShieldColor.g, ShieldColor.b, .4f); TargetShieldsIcon.color = ShieldColor; TargetShieldsIcon.sprite = ShieldIcon; } else { TargetShieldsBackground.color = new Color(NoShieldColor.r, NoShieldColor.g, NoShieldColor.b, .4f); TargetShieldsIcon.color = NoShieldColor; TargetShieldsIcon.sprite = NoShieldIcon; } // Subscribe to incoming hits from the player ship to display the hit marker _targetSubscriptions.Add(targetEntity.IncomingHit.Where(e => e == CurrentEntity).Subscribe(_ => { HitMarker.SetActive(true); _hitMarkerTime = HitMarkerDuration; })); } })); foreach (var hostile in CurrentEntity.VisibleEnemies) { var indicator = HostileTargetIndicator.Instantiate(); _visibleHostileIndicators.Add(hostile, indicator); } _shipSubscriptions.Add(CurrentEntity.VisibleEnemies.ObserveAdd().Subscribe(addEvent => { var indicator = HostileTargetIndicator.Instantiate(); _visibleHostileIndicators.Add(addEvent.Value, indicator); })); _shipSubscriptions.Add(CurrentEntity.VisibleEnemies.ObserveRemove().Subscribe(removeEvent => { _visibleHostileIndicators[removeEvent.Value].GetComponent().ReturnToPool(); _visibleHostileIndicators.Remove(removeEvent.Value); })); foreach (var friendly in CurrentEntity.VisibleFriendlies) { var indicator = FriendlyTargetIndicator.Instantiate(); _visibleFriendlyIndicators.Add(friendly, indicator); } _shipSubscriptions.Add(CurrentEntity.VisibleFriendlies.ObserveAdd().Subscribe(addEvent => { var indicator = FriendlyTargetIndicator.Instantiate(); _visibleFriendlyIndicators.Add(addEvent.Value, indicator); })); _shipSubscriptions.Add(CurrentEntity.VisibleFriendlies.ObserveRemove().Subscribe(removeEvent => { _visibleFriendlyIndicators[removeEvent.Value].GetComponent().ReturnToPool(); _visibleFriendlyIndicators.Remove(removeEvent.Value); })); _shipSubscriptions.Add(CurrentEntity.Death.Subscribe(Die)); var progressSources = CurrentEntity.GetBehaviors().Select(x => ((Func) (() => x.Lock), true)) .Concat(CurrentEntity.GetBehaviors().Select(x => ((Func) (() => x.Progress), false))); _progressIndicators = progressSources .Select(x => { var i = LockIndicator.Instantiate(); return (x.Item1, x.Item2, i, i.GetComponent()); }).ToArray(); var incoming = LockIndicator.Instantiate(); incoming.gameObject.SetActive(false); _incomingLockIndicator = (incoming, incoming.GetComponent()); } private void UpdatePlayerPanel() { ShipPanel.Display(CurrentEntity, true); SchematicDisplay.ShowShip(CurrentEntity); } // Mining Cut 3: any target gets the indicator; only a ship target gets the ship panel. private void UpdateTargetPanel(TargetRef target) { TargetIndicator.gameObject.SetActive(!target.IsNone); TargetShipPanel.gameObject.SetActive(target.Entity != null); if (target.Entity != null) { TargetShipPanel.Display(target.Entity, true); TargetSchematicDisplay.ShowShip(target.Entity, CurrentEntity); } } // A chunk's pose is planar (Zone.ChunkPose), read at the drawn time so the reticle sits on the drawn rock; the renderer // draws it at the asteroid layer's height. private float3 ChunkWorldPosition(ChunkId chunk) { var zone = CurrentEntity.Zone; var pose = zone.ChunkPose(chunk.Field, chunk.Index, DrawAhead.Time(zone, Clock.Lead)); return float3(pose.x, Settings.PlanetSettings.AsteroidVerticalOffset, pose.y); } // The one way a run ends, won or lost: the run is cleared and the main menu returns. private void EndRun() { UnbindEntity(); CurrentEntity = null; MainMenu.gameObject.SetActive(true); Menu.gameObject.SetActive(false); CurrentGalaxy = null; RunSave.Clear(CultCache); SavePlayerSettings(); } // The one way a run is won: RunGoal decides whether the exit is open (read before EndRun clears the galaxy), the // caller shows its own refusal. private bool TryWin() { if (!RunGoal.ExitOpen(Zone)) return false; EndRun(); Dialog.Clear(); Dialog.Title.text = "You have escaped the Terminus. This was the demo; the whole of it is yet to come."; Dialog.Show(); Dialog.MoveToCursor(); return true; } private void Die(CauseOfDeath cause) { var deathTime = Time.time; EndRun(); Observable.EveryUpdate() .Where(_ => Time.time - deathTime < DeathPostTransitionTime) .Subscribe(_ => { var t = (Time.time - deathTime) / DeathPostTransitionTime; if(cause==CauseOfDeath.Heatstroke) { HeatstrokePost.weight = 1 - t; SevereHeatstrokePost.weight = 1 - t; } else if (cause == CauseOfDeath.Hypothermia) { HypothermiaPost.weight = 1 - t; SevereHypothermiaPost.weight = 1 - t; } DeathPost.weight = t; }, () => { HeatstrokePost.weight = 0; SevereHeatstrokePost.weight = 0; HypothermiaPost.weight = 0; SevereHypothermiaPost.weight = 0; DeathPost.weight = 1; }); } // public void ToggleEditMode() // { // _editMode = !_editMode; // FollowCamera.gameObject.SetActive(!_editMode); // TopDownCamera.gameObject.SetActive(_editMode); // } public IEnumerable AvailableCargoBays() { if (CurrentEntity.Parent != null) { foreach (var bay in CurrentEntity.Parent.DockingBays) { if (bay.DockedShip.IsPlayerShip) yield return bay; } } } public IEnumerable AvailableEntities() { if(DockedEntity != null) foreach (var entity in DockedEntity.Children) { if (entity is Ship { IsPlayerShip: true }) yield return entity; } else if (CurrentEntity != null) yield return CurrentEntity; } public void PlayMusic(MusicType type) { // TODO: SFX: Music } // What the player's own instruments say about combat, nothing a hostile knows: a hostile tracked, a paint on the // player, or a remembered hostile the player still has a bearing on (a reveal younger than BearingMemory). private void UpdateMusic() { var combat = CurrentEntity.VisibleEnemies.Count > 0 || CurrentEntity.IncomingPaints.Count > 0; combat |= CurrentEntity.RememberedHostiles.Any(); var type = combat ? MusicType.Combat : MusicType.Overworld; if (_music == type) return; _music = type; PlayMusic(type); } // An indicator's fill is how far the player's own sensors have resolved the ship from a track toward identifying // it (ContactOf.Info over the detection-to-gear span): nothing of what the ship sees of the player. private void PlaceFill(VisibleTargetIndicator indicator, Entity subject) { indicator.Fill.enabled = subject.Active; if (!subject.Active) return; var settings = Settings.GameplaySettings; indicator.Fill.fillAmount = saturate(unlerp(settings.TargetDetectionInfoThreshold, settings.TargetGearInfoThreshold, CurrentEntity.ContactOf(subject).Info)); } // Whether an effect drawn at `subject`'s true position may be drawn: the player's own ship, or a ship its // instruments track. The one seam for impact and hit effects (follow-up presentation-gated-on-tracked); a bearing // alone (Detected without Tracked) places nothing there. No player ship, nothing to gate on. public static bool DrawsAt(Entity subject) => Instance == null || Instance.CurrentEntity == null || subject == null || subject == Instance.CurrentEntity || Instance.CurrentEntity.ContactOf(subject).Tracked; void Update() { if(!_paused) { _time += Time.deltaTime; _hitMarkerTime -= Time.deltaTime; if(HitMarker.activeSelf && _hitMarkerTime < 0) HitMarker.SetActive(false); // ItemManager.Time = _time; if(CurrentEntity !=null && CurrentEntity.Parent==null) { foreach (var indicator in _visibleHostileIndicators) { indicator.Value.gameObject.SetActive(indicator.Key!=CurrentEntity.Target.Value.Entity); indicator.Value.Place.Target = DrawAhead.Position(indicator.Key, Clock.Lead).ToUnity(); PlaceFill(indicator.Value, indicator.Key); } foreach (var indicator in _visibleFriendlyIndicators) { indicator.Value.gameObject.SetActive(indicator.Key!=CurrentEntity.Target.Value.Entity); indicator.Value.Place.Target = DrawAhead.Position(indicator.Key, Clock.Lead).ToUnity(); PlaceFill(indicator.Value, indicator.Key); } var look = Input.Player.Look.ReadValue(); var sensitivity = PlayerSettings.InputSettings.Sensitivity; _entityYawPitch = float2(_entityYawPitch.x + look.x * sensitivity.x, clamp(_entityYawPitch.y + look.y * sensitivity.y, -.45f * PI, .45f * PI)); _viewDirection = mul(float3(0, 0, 1), CultMath.float3x3.Euler(float3(_entityYawPitch.yx, 0), RotationOrder.YXZ)); CurrentEntity.Aim = _viewDirection; HeatstrokePost.weight = saturate(unlerp(0, Settings.GameplaySettings.SevereHeatstrokeRiskThreshold, CurrentEntity.Heatstroke)); var severeHeatstrokeLerp = saturate(unlerp(Settings.GameplaySettings.SevereHeatstrokeRiskThreshold, 1, CurrentEntity.Heatstroke)); SevereHeatstrokePost.weight = severeHeatstrokeLerp + severeHeatstrokeLerp * (1 - severeHeatstrokeLerp) * max(Settings.HeatstrokePhasingFloor, sin(Time.time * Settings.HeatstrokePhasingFrequency)); _faceAimMark.enabled = _helm.Latched; if(CurrentEntity is Ship ship) { ship.MovementDirection = Input.Player.Move.ReadValue().ToCultMath(); _helm.Held = Input.Player.FaceAim.IsPressed(); ship.Turn = _helm.Demand(ship, _viewDirection.xz, Input.Player.Turn.ReadValue()); } UpdateMusic(); UpdateFireControlDebug(CurrentEntity.Target.Value); var target = CurrentEntity.Target.Value.Entity; if (target != null) { var threshold = Settings.GameplaySettings.TargetDetectionInfoThreshold; TargetVisibilityFill.fillAmount = lerp(.25f, .75f, (CurrentEntity.ContactOf(target).Info - threshold)/(1-threshold)); VisibilityToTargetFill.fillAmount = lerp(.25f, .75f, saturate(CurrentEntity.Visibility / OwnSignatureFullScale)); TargetHitpointsFill.fillAmount = lerp(.25f, .75f, target.Hull.Durability / target.EquippedHull.Data.Durability); TargetShieldsFill.fillAmount = target.Shield == null ? 0 : lerp(.25f, .75f, target.Shield.Progress); } CurrentEntity.TractorCommand = Input.Player.TractorBeam.ReadValue(); } } // One clock steps the zone, paused or not: a paused frame feeds it no real time. // A zone entered inside a step (a wormhole) ends this frame's stepping: the rest of its steps are the old zone's. void EnterNewZone() { if (_zoneEntered) { Clock.Reset(); _zoneEntered = false; } } EnterNewZone(); var steppedZone = Zone; Clock.Advance(_paused ? 0f : Time.deltaTime, delta => { if (Zone != steppedZone) return; steppedZone.Update(delta); // A warning lives one tick, so sweeps are collected per tick, never read per frame. if (CurrentEntity != null && CurrentEntity.Zone == steppedZone) foreach (var warning in CurrentEntity.IncomingPaints) if (warning.Mode == PaintMode.Search) _pendingSweeps.Add((warning.Bearing, steppedZone.Time)); }); EnterNewZone(); } private static string ResultLabel(ShotResult result) => result switch { ShotResult.Hit => "HIT", ShotResult.Burst => "BURST", _ => "MISS" }; private void UpdateFireControlDebug(TargetRef target) { if (DebugInfoText == null) return; EquippedItem selectedItem = null; Weapon selectedWeapon = null; foreach (var item in CurrentEntity.Equipment) { var weapon = item.Behaviors.OfType().FirstOrDefault(x => !(x is LockWeapon)); if (weapon == null) continue; selectedItem = item; selectedWeapon = weapon; break; } if (selectedWeapon == null) { DebugInfoText.text = "FIRE CONTROL\nNo non-lock weapon"; return; } // Mining Cut 3: fire control prices ships; a chunk target reads as none there until Cut 4. var d = FireControl.Inspect(selectedWeapon, CurrentEntity, target.Entity); var pendingLine = "pending: none"; for (var i = CurrentEntity.Zone.PendingShots.Count - 1; i >= 0; i--) { var shot = CurrentEntity.Zone.PendingShots[i]; if (shot.Source != CurrentEntity || shot.Weapon != selectedItem) continue; if (shot.Committed) pendingLine = $"shot {shot.ShotId}: committed {ResultLabel(shot.Outcome.Result)}"; else { var pDeviation = FireControl.DeviationProbability(shot, CurrentEntity.Zone.Time, out var deviation); // Cut 12.2 (docs/fire-control-cut.md): retires the inline estimate (shot.PBase * pDeviation), // a second, wrong copy of the model now that the target's facing enters at Commit -- this // calls the same function Commit itself rolls against (TheHudEstimateIsTheCommitPrice). var estimate = FireControl.CommitProbability(shot, CurrentEntity.Zone.Time, out _); pendingLine = $"shot {shot.ShotId}: dev {deviation:F1}/{shot.Tracking:F1} x{pDeviation:F3} estimate {estimate:P1}"; } break; } var lastLine = _debugLastShots.TryGetValue(selectedItem, out var lastShot) ? $"last {lastShot.ShotId}: {ResultLabel(lastShot.Result)} cell {lastShot.Cell.x},{lastShot.Cell.y}" : "last: none"; var gates = target.Chunk.HasValue ? "target: chunk" : target.Entity == null ? "target: none" : $"gates designated {d.Designated} arc {d.InArc}"; // A fused weapon's forecast is its outcome, not a hit chance: the burst point or the refusal. var outcomeLine = d.Outcome == FireOutcome.Burst ? $"burst at {d.BurstReach:F0}" : d.Outcome == FireOutcome.Refused ? "refused: arming distance exceeds range" : $"base {d.PBase:P1}"; DebugInfoText.text = $"FIRE CONTROL - {ItemManager.Title(selectedItem.EquippableItem)}\n" + $"{gates}\n" + $"range {d.Range:F0} [{d.MinRange:F0}..{d.MaxRange:F0}]\n" + $"info {d.Info:F3}/{d.InfoDemandCeiling:F3} sensor {d.PSensor:F3}\n" + $"accuracy {d.Accuracy:F3} spread {d.PSpread:F3} hull {d.POnHull:F3}\n" + $"precision {d.Precision:F3} tracking {d.Tracking:F1}\n" + $"mount {d.AngularVelocity:F1}/{d.TrackingRate:F1} x{d.PMount:F3}\n" + $"{outcomeLine}\n" + $"{pendingLine}\n" + lastLine; } private void LateUpdate() { UpdateTargetIndicators(); } private void UpdateTargetIndicators() { if (CurrentEntity == null || CurrentEntity.Parent != null) return; var lead = Clock.Lead; var ownDrawn = DrawAhead.Position(CurrentEntity, lead); ViewDot.Target = ZoneRenderer.EntityInstances[CurrentEntity].LookAtPoint.position; var target = CurrentEntity.Target.Value; if (target.Chunk is ChunkId chunk) TargetIndicator.Target = ChunkWorldPosition(chunk).ToUnity(); else if (target.Entity != null) TargetIndicator.Target = DrawAhead.Position(target.Entity, lead).ToUnity(); var distance = length(ViewDot.Target.ToCultMath() - ownDrawn); foreach (var (_, barrels, crosshair) in _articulationGroups) { var averagePosition = Vector3.zero; foreach (var barrel in barrels) averagePosition += barrel.position + barrel.forward * distance; averagePosition /= barrels.Length; crosshair.Target = averagePosition; } foreach (var (progress, hostileOnly, indicator, spin) in _progressIndicators) { var value = progress(); var targetEntity = CurrentEntity.Target.Value.Entity; var showIndicator = value > .01f && targetEntity != null && (!hostileOnly || CurrentEntity.IsHostileTo(targetEntity)); indicator.gameObject.SetActive(showIndicator); if(showIndicator) { indicator.Target = DrawAhead.Position(targetEntity, lead).ToUnity(); ApplyLockProgress(indicator, spin, value); } } // The strongest lock or scan on the player's ship, read only from IncomingPaints (never another ship's LockWeapon or // Scanner). A Scan paint's Strength is the scan's progress (ruling scan-is-an-act). Search paints (ping sweeps) are // this class's sweep flashes (UpdateSweepFlashes). Same curves as the outgoing indicator, driven by the warning's Strength. var warnings = CurrentEntity.IncomingPaints; var strongest = -1; for (var w = 0; w < warnings.Count; w++) if ((warnings[w].Mode is PaintMode.Track or PaintMode.Scan) && (strongest < 0 || warnings[w].Strength > warnings[strongest].Strength)) strongest = w; var (incomingIndicator, incomingSpin) = _incomingLockIndicator; incomingIndicator.gameObject.SetActive(strongest >= 0); if (strongest >= 0) { var warning = warnings[strongest]; incomingIndicator.Target = DrawAhead.AtBearing(CurrentEntity, warning.Bearing, IncomingLockIndicatorDistance, lead).ToUnity(); ApplyLockProgress(incomingIndicator, incomingSpin, warning.Strength); } UpdateBearingMarkers(); UpdateSweepFlashes(); } // One marker per remembered hostile (a contact held by a bearing only): at the bearing the book keeps, labelled with the // classes it claimed. Never at the subject's position, never read from its gear. private void UpdateBearingMarkers() { var remembered = CurrentEntity.RememberedHostiles.ToList(); _markerScratch.Clear(); foreach (var subject in _bearingMarkers.Keys) if (!remembered.Contains(subject)) _markerScratch.Add(subject); foreach (var subject in _markerScratch) { RetireMarker(_bearingMarkers[subject]); _bearingMarkers.Remove(subject); } foreach (var subject in remembered) { if (!_bearingMarkers.TryGetValue(subject, out var marker)) { marker = HostileTargetIndicator.Instantiate(); marker.Fill.enabled = false; _bearingMarkers.Add(subject, marker); } marker.gameObject.SetActive(true); var bearing = CurrentEntity.ContactOf(subject).Bearing; marker.Place.Target = DrawAhead.AtBearing(CurrentEntity, bearing, BearingMarkerDistance, Clock.Lead).ToUnity(); marker.ShowLabel(string.Join(", ", CurrentEntity.ClaimedClasses(subject).Select(c => c.Name))); } } // A pooled indicator goes back as the next hostile indicator will want it: label hidden, fill restored. private static void RetireMarker(VisibleTargetIndicator marker) { marker.ShowLabel(null); marker.Fill.enabled = true; marker.GetComponent().ReturnToPool(); } private static void RetireFlash(PlaceUIElementWorldspace indicator, Image image) { if (image != null) image.color = new Color(image.color.r, image.color.g, image.color.b, 1); indicator.GetComponent().ReturnToPool(); } // A ping crossing the player draws one flash toward the pinger, fading over SearchFlashSeconds of zone time. private void UpdateSweepFlashes() { foreach (var (bearing, time) in _pendingSweeps) { var indicator = LockIndicator.Instantiate(); ApplyLockProgress(indicator, indicator.GetComponent(), 0); // a pooled lock indicator keeps its last tightening _sweepFlashes.Add((indicator, indicator.GetComponentInChildren(), bearing, time)); } _pendingSweeps.Clear(); for (var i = _sweepFlashes.Count - 1; i >= 0; i--) { var (indicator, image, bearing, time) = _sweepFlashes[i]; var age = Zone.Time - time; if (age < 0 || age >= SearchFlashSeconds) { RetireFlash(indicator, image); _sweepFlashes.RemoveAt(i); continue; } indicator.Target = DrawAhead.AtBearing(CurrentEntity, bearing, IncomingLockIndicatorDistance, Clock.Lead).ToUnity(); if (image != null) image.color = new Color(image.color.r, image.color.g, image.color.b, 1 - age / SearchFlashSeconds); } } // The one lock-indicator curve: outgoing (the player's own lock) and incoming (the warning's strength) both tighten through it. private void ApplyLockProgress(PlaceUIElementWorldspace indicator, Rotate spin, float progress) { indicator.NoiseAmplitude = Settings.GameplaySettings.LockIndicatorNoiseAmplitude * (1 - progress); indicator.NoiseFrequency = Settings.GameplaySettings.LockIndicatorFrequency.Evaluate(progress); spin.Speed = Settings.GameplaySettings.LockSpinSpeed.Evaluate(progress); } } public abstract class DragObject{} public class WeaponGroupDragObject : DragObject { public WeaponGroupDragObject(int group) { Group = group; } public int Group { get; } } public abstract class ItemDragObject : DragObject { protected ItemDragObject(int2 originCellOffset, ItemInstance item) { OriginCellOffset = originCellOffset; Item = item; } public ItemInstance Item { get; } public int2 OriginCellOffset { get; } } public class ItemInstanceDragObject : ItemDragObject { public ItemInstanceDragObject(ItemInstance item, EquippedCargoBay originInventory, int2 originCellOffset) : base(originCellOffset, item) { OriginInventory = originInventory; } public EquippedCargoBay OriginInventory { get; } } public class EquippedItemDragObject : ItemDragObject { public EquippedItemDragObject(EquippedItem item, Entity originEntity, int2 originCellOffset) : base(originCellOffset, item.EquippableItem) { EquippedItem = item; OriginEntity = originEntity; } public EquippedItem EquippedItem { get; } public Entity OriginEntity { get; } }