Aetheria/Assets/Scripts/Gameplay/ActionGameManager.cs
2026-10-22 08:39:46 +02:00

1600 lines
69 KiB
C#

/* 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<ShipModCatalog.Exclusion>();
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<PlayerSettings>();
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("<Keyboard>/leftShift");
settings.InputSettings.ActionBarInputs.Add("<Mouse>/leftButton");
settings.InputSettings.ActionBarInputs.Add("<Mouse>/rightButton");
settings.InputSettings.ActionBarInputs.Add("<Mouse>/middleButton");
for (int i = 1; i < 6; i++) settings.InputSettings.ActionBarInputs.Add($"<Keyboard>/{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<float> progress, bool hostileOnly, PlaceUIElementWorldspace indicator, Rotate spin)[] _progressIndicators;
private (PlaceUIElementWorldspace indicator, Rotate spin) _incomingLockIndicator;
private Dictionary<Entity, VisibleTargetIndicator> _visibleHostileIndicators = new Dictionary<Entity, VisibleTargetIndicator>();
private Dictionary<Entity, VisibleTargetIndicator> _visibleFriendlyIndicators = new Dictionary<Entity, VisibleTargetIndicator>();
// 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<Entity, VisibleTargetIndicator> _bearingMarkers = new Dictionary<Entity, VisibleTargetIndicator>();
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<Entity> _markerScratch = new List<Entity>();
private List<IDisposable> _shipSubscriptions = new List<IDisposable>();
private List<IDisposable> _targetSubscriptions = new List<IDisposable>();
private readonly Dictionary<EquippedItem, ShotOutcome> _debugLastShots = new Dictionary<EquippedItem, ShotOutcome>();
private float _severeHeatstrokePhase;
private bool _uiHidden;
private bool _menuShown;
private List<ActionBarSlot> _actionBarSlots = new List<ActionBarSlot>();
private List<InputAction> _actionBarActions = new List<InputAction>();
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<DragObject, bool> _endDragCallback;
private List<Story> _stories = new List<Story>();
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.
// A new run is reseeded by RunStart; Continue's item draws come from the saved run's seed.
ItemManager = new ItemManager(
CurrentGalaxy == null ? RunStart.NoRunSeed
: RunStart.ItemSeed(CurrentGalaxy, PendingScenario != null ? RunStart.ArenaStep : RunStart.ContinueStep),
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<TextMeshProUGUI>();
_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<TMP_InputField>() != 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);
};
// The switches and the ping are presses on the entity's command, flipped at the next step.
Input.Player.OverrideShutdown.performed += context => CurrentEntity.Command.ToggleOverrideShutdown();
Input.Player.Ping.performed += context => CurrentEntity.Command.Ping();
// TODO: SFX: Success/Fail
Input.Player.ToggleHeatsinks.performed += context => CurrentEntity.Command.ToggleHeatsinks();
// TODO: SFX: Success/Fail
Input.Player.ToggleShield.performed += context => CurrentEntity.Command.ToggleShield();
// 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, so the first press declares hostile. The flip reads the target and its stance when applied,
// after the step's target presses, so two presses in a step cancel.
Input.Player.ToggleStance.performed += context => CurrentEntity.Command.ToggleStance();
#region Targeting
// The reticle, nearest, next and previous presses each command a verb; the applier chooses through Entity at the
// next step (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.Command.Verb(TargetVerb.UnderReticle);
Input.Player.TargetNearest.performed += context => CurrentEntity.Command.Verb(TargetVerb.NearestEnemy);
Input.Player.TargetNext.performed += context => CurrentEntity.Command.Verb(TargetVerb.Next);
Input.Player.TargetPrevious.performed += context => CurrentEntity.Command.Verb(TargetVerb.Previous);
// 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, applied at
// the next step.
Input.Player.CycleTargetItem.performed += context => CurrentEntity.Command.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<Sprite>($"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<ActivatedBehavior>();
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<EquippableItemData>()
.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<GuidedProjectileManager>(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;
}
bool? hostile = mode == "hostile" ? true : mode == "neutral" ? false : (bool?) null;
_currentEntity.Command.Iff(target, hostile);
console.AppendLogLine($"{target.Name}: {mode} queued; it applies at the next step");
});
// Editor only: capturepreset "<name>" [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 \"<name>\" [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
? $"Loadout '{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 loadouts."
: $"Loadout '{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<DragObject, bool> 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<TMP_InputField>() != 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<TMP_InputField>() != 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<SavedGame>();
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<string>();
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<Cockpit>() == null)
{
Dialog.Clear();
Dialog.Title.text = "Can't undock. Missing cockpit component!";
Dialog.Show();
Dialog.MoveToCursor();
// TODO: SFX: Fail
}
else if (CurrentEntity.GetBehavior<Thruster>() == null)
{
Dialog.Clear();
Dialog.Title.text = "Can't undock. Missing thruster component!";
Dialog.Show();
Dialog.MoveToCursor();
// TODO: SFX: Fail
}
else if (CurrentEntity.GetBehavior<Reactor>() == 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<Prototype>().ReturnToPool();
if(_incomingLockIndicator.indicator != null) _incomingLockIndicator.indicator.GetComponent<Prototype>().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<ArticulationPoint>()?.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<VisibleTargetIndicator>();
_visibleHostileIndicators.Add(hostile, indicator);
}
_shipSubscriptions.Add(CurrentEntity.VisibleEnemies.ObserveAdd().Subscribe(addEvent =>
{
var indicator = HostileTargetIndicator.Instantiate<VisibleTargetIndicator>();
_visibleHostileIndicators.Add(addEvent.Value, indicator);
}));
_shipSubscriptions.Add(CurrentEntity.VisibleEnemies.ObserveRemove().Subscribe(removeEvent =>
{
_visibleHostileIndicators[removeEvent.Value].GetComponent<Prototype>().ReturnToPool();
_visibleHostileIndicators.Remove(removeEvent.Value);
}));
foreach (var friendly in CurrentEntity.VisibleFriendlies)
{
var indicator = FriendlyTargetIndicator.Instantiate<VisibleTargetIndicator>();
_visibleFriendlyIndicators.Add(friendly, indicator);
}
_shipSubscriptions.Add(CurrentEntity.VisibleFriendlies.ObserveAdd().Subscribe(addEvent =>
{
var indicator = FriendlyTargetIndicator.Instantiate<VisibleTargetIndicator>();
_visibleFriendlyIndicators.Add(addEvent.Value, indicator);
}));
_shipSubscriptions.Add(CurrentEntity.VisibleFriendlies.ObserveRemove().Subscribe(removeEvent =>
{
_visibleFriendlyIndicators[removeEvent.Value].GetComponent<Prototype>().ReturnToPool();
_visibleFriendlyIndicators.Remove(removeEvent.Value);
}));
_shipSubscriptions.Add(CurrentEntity.Death.Subscribe(Die));
var progressSources = CurrentEntity.GetBehaviors<LockWeapon>().Select(x => ((Func<float>) (() => x.Lock), true))
.Concat(CurrentEntity.GetBehaviors<Scanner>().Select(x => ((Func<float>) (() => x.Progress), false)));
_progressIndicators = progressSources
.Select(x =>
{
var i = LockIndicator.Instantiate<PlaceUIElementWorldspace>();
return (x.Item1, x.Item2, i, i.GetComponent<Rotate>());
}).ToArray();
var incoming = LockIndicator.Instantiate<PlaceUIElementWorldspace>();
incoming.gameObject.SetActive(false);
_incomingLockIndicator = (incoming, incoming.GetComponent<Rotate>());
}
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);
}
}
// 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<EquippedCargoBay> AvailableCargoBays()
{
if (CurrentEntity.Parent != null)
{
foreach (var bay in CurrentEntity.Parent.DockingBays)
{
if (bay.DockedShip.IsPlayerShip) yield return bay;
}
}
}
public IEnumerable<Entity> 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<Vector2>();
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.Command.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.Command.Movement = Input.Player.Move.ReadValue<Vector2>().ToCultMath();
_helm.Held = Input.Player.FaceAim.IsPressed();
ship.Command.Turn = _helm.Demand(_viewDirection.xz, Input.Player.Turn.ReadValue<float>());
}
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.Command.Tractor = Input.Player.TractorBeam.ReadValue<float>();
}
}
// 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<Weapon>().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 = DrawAhead.Chunk(CurrentEntity.Zone, Settings.GameplaySettings, chunk, lead).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<VisibleTargetIndicator>();
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<Prototype>().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<Prototype>().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<PlaceUIElementWorldspace>();
ApplyLockProgress(indicator, indicator.GetComponent<Rotate>(), 0); // a pooled lock indicator keeps its last tightening
_sweepFlashes.Add((indicator, indicator.GetComponentInChildren<Image>(), 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; }
}