using System; using System.Collections.Generic; using System.Linq; using System.Reflection.Emit; using HarmonyLib; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.Serialization; using UnityEngine.UI; using UXAssist.Common; using Object = UnityEngine.Object; namespace UXAssist.Patches.Logistics; internal static class RealtimeInfoPanelPatch { public static void OnUpdate() { if (LogisticsPatch.RealtimeLogisticsInfoPanelEnabled.Value) { RealtimeLogisticsInfoPanel.StationInfoPanelsUpdate(); } } } internal class LogisticsConstrolPanelImprovement : PatchImpl { private static int ItemIdHintUnderMouse() { var itemId = GameMain.data.mainPlayer.inhandItemId; if (itemId > 0) return itemId; List targets = []; var pointer = new PointerEventData(EventSystem.current) { position = Input.mousePosition }; EventSystem.current.RaycastAll(pointer, targets); foreach (var target in targets) { var btn = target.gameObject.GetComponentInParent(); if (btn?.tips is { itemId: > 0 }) { return btn.tips.itemId; } var repWin = target.gameObject.GetComponentInParent(); if (repWin != null) { var mouseRecipeIndex = repWin.mouseRecipeIndex; var recipeProtoArray = repWin.recipeProtoArray; if (mouseRecipeIndex < 0) { return 0; } var recipeProto = recipeProtoArray[mouseRecipeIndex]; return recipeProto != null ? recipeProto.Results[0] : 0; } var grid = target.gameObject.GetComponentInParent(); if (grid != null) { var storage = grid.storage; if (storage == null) return 0; var mouseOnX = grid.mouseOnX; var mouseOnY = grid.mouseOnY; if (mouseOnX < 0 || mouseOnY < 0) return 0; var gridIndex = mouseOnX + mouseOnY * grid.colCount; return storage.grids[gridIndex].itemId; } var productEntry = target.gameObject.GetComponentInParent(); if (productEntry == null) continue; if (!productEntry.productionStatWindow.isProductionTab) return 0; return productEntry.entryData?.itemId ?? 0; } return 0; } private static bool SetFilterItemId(UIControlPanelFilterPanel filterPanel, int itemId) { var filter = filterPanel.GetCurrentFilter(); if (filter.itemsFilter is { Length: 1 } && filter.itemsFilter[0] == itemId) return false; filter.itemsFilter = [itemId]; filterPanel.SetNewFilter(filter); return true; } [HarmonyTranspiler] [HarmonyPatch(typeof(UIGame), nameof(UIGame.On_I_Switch))] private static IEnumerable UIGame_On_I_Switch_Transpiler(IEnumerable instructions, ILGenerator generator) { var matcher = new CodeMatcher(instructions, generator); matcher.End().MatchBack(false, new CodeMatch(OpCodes.Ldarg_0), new CodeMatch(OpCodes.Call, AccessTools.Method(typeof(UIGame), nameof(UIGame.ShutAllFunctionWindow))) ); if (matcher.IsInvalid) { UXAssist.Logger.LogWarning("Failed to patch UIGame.On_I_Switch()"); return matcher.InstructionEnumeration(); } var labels = matcher.Labels; matcher.Labels = []; matcher.Insert( new CodeInstruction(OpCodes.Ldarg_0).WithLabels(labels), Transpilers.EmitDelegate((UIGame uiGame) => { var itemId = ItemIdHintUnderMouse(); if (itemId <= 0) return; SetFilterItemId(uiGame.controlPanelWindow.filterPanel, itemId); }) ); return matcher.InstructionEnumeration(); } private static void OnStationEntryItemIconRightClick(UIControlPanelStationEntry stationEntry, int slot) { var storage = stationEntry.station?.storage; if (storage == null) return; var itemId = storage.Length > slot ? storage[slot].itemId : 0; var uiRoot = UIRoot.instance; if (!uiRoot) return; var controlPanelWindow = uiRoot.uiGame?.controlPanelWindow; if (controlPanelWindow == null) return; var filterPanel = controlPanelWindow.filterPanel; if (filterPanel == null) return; if (!SetFilterItemId(filterPanel, itemId)) return; filterPanel.RefreshFilterUI(); controlPanelWindow.DetermineFilterResults(); } private static readonly Dictionary[]> OnStationEntryItemIconRightClickActionsMap = new(); [HarmonyPostfix] [HarmonyPatch(typeof(UIControlPanelStationEntry), nameof(UIControlPanelStationEntry._OnRegEvent))] private static void UIControlPanelStationEntry__OnRegEvent_Postfix(UIControlPanelStationEntry __instance) { var actions = new Action[5]; actions[0] = _ => OnStationEntryItemIconRightClick(__instance, 0); actions[1] = _ => OnStationEntryItemIconRightClick(__instance, 1); actions[2] = _ => OnStationEntryItemIconRightClick(__instance, 2); actions[3] = _ => OnStationEntryItemIconRightClick(__instance, 3); actions[4] = _ => OnStationEntryItemIconRightClick(__instance, 4); OnStationEntryItemIconRightClickActionsMap[__instance] = actions; __instance.storageItem0.itemButton.onRightClick += actions[0]; __instance.storageItem1.itemButton.onRightClick += actions[1]; __instance.storageItem2.itemButton.onRightClick += actions[2]; __instance.storageItem3.itemButton.onRightClick += actions[3]; __instance.storageItem4.itemButton.onRightClick += actions[4]; } [HarmonyPostfix] [HarmonyPatch(typeof(UIControlPanelStationEntry), nameof(UIControlPanelStationEntry._OnUnregEvent))] private static void UIControlPanelStationEntry__OnUnregEvent_Postfix(UIControlPanelStationEntry __instance) { if (!OnStationEntryItemIconRightClickActionsMap.TryGetValue(__instance, out var actions)) return; __instance.storageItem0.itemButton.onRightClick -= actions[0]; __instance.storageItem1.itemButton.onRightClick -= actions[1]; __instance.storageItem2.itemButton.onRightClick -= actions[2]; __instance.storageItem3.itemButton.onRightClick -= actions[3]; __instance.storageItem4.itemButton.onRightClick -= actions[4]; OnStationEntryItemIconRightClickActionsMap.Remove(__instance); } } internal static class RealtimeLogisticsInfoPanel { private static StationTip[] _stationTips = new StationTip[16]; private static readonly StationTip[] StationTipsRecycle = new StationTip[128]; private static readonly Sprite[] StateSprite = [null, null, null]; private static int _stationTipsRecycleCount; private static GameObject _stationTipsRoot; private static GameObject _tipPrefab; private static readonly Color DemandColor = new(253f / 255, 150f / 255, 94f / 255); private static readonly Color SupplyColor = new(97f / 255, 216f / 255, 255f / 255); private static readonly Color OrderInColor = new(108f / 255, 187f / 255, 214f / 255); private static readonly Color OrderOutColor = new(255f / 255, 161f / 255, 109.5f / 255); private static int _lastPlanetId; private static int _localStorageMax = 5000; private static int _remoteStorageMax = 10000; private static int _localStorageExtra; private static int _remoteStorageExtra; private static int _localStorageMaxTotal = _localStorageMax; private static int _remoteStorageMaxTotal = _remoteStorageMax; private static float _localStoragePixelPerItem = StorageSliderWidth / _localStorageMaxTotal; private static float _remoteStoragePixelPerItem = StorageSliderWidth / _remoteStorageMaxTotal; private static int _storageMaxSlotCount = 5; private const int CarrierSlotCount = 3; private const float StorageSliderWidth = 70f; private const float StorageSliderHeight = 5f; private static bool UpdateStorageMax() { var history = GameMain.history; if (history == null) return false; if (_remoteStorageExtra == history.remoteStationExtraStorage && _localStorageExtra == history.localStationExtraStorage) return false; _localStorageExtra = history.localStationExtraStorage; _remoteStorageExtra = history.remoteStationExtraStorage; _localStorageMaxTotal = _localStorageMax + _localStorageExtra; _remoteStorageMaxTotal = _remoteStorageMax + _remoteStorageExtra; _localStoragePixelPerItem = StorageSliderWidth / _localStorageMaxTotal; _remoteStoragePixelPerItem = StorageSliderWidth / _remoteStorageMaxTotal; return true; } private static readonly Sprite[] LogisticsExtraItemSprites = [ Resources.Load("Icons/ItemRecipe/logistic-drone"), Resources.Load("Icons/ItemRecipe/logistic-vessel"), Resources.Load("Icons/ItemRecipe/space-warper") ]; internal static void Enable(bool on) { // Toggle the defensive localPlanet setter hook regardless of whether the GUI // root has been initialized yet (InitGUI may run later via OnDataLoaded). LocalPlanetWatcher.Enable(on); if (_stationTipsRoot == null) return; if (!on) { HideAndRecycleStationTips(); return; } if (DSPGame.IsMenuDemo || !GameMain.isRunning) { _lastPlanetId = 0; _stationTipsRoot.SetActive(false); return; } _lastPlanetId = GameMain.data?.localPlanet?.id ?? 0; _stationTipsRoot.SetActive(_lastPlanetId != 0); } internal static void EnableBars(bool on) { foreach (var stationTip in _stationTips) { if (stationTip == null) continue; stationTip.SetBarsVisible(on); } } internal static void OnGameBegin() { HideAndRecycleStationTips(); } internal static void OnGameEnd() { HideAndRecycleStationTips(); } internal static void OnDataLoaded() { _storageMaxSlotCount = 5; _localStorageMax = 5000; _remoteStorageMax = 10000; foreach (var model in LDB.models.dataArray) { var prefabDesc = model?.prefabDesc; if (prefabDesc == null) continue; if (prefabDesc.isStation) { _storageMaxSlotCount = Math.Max(_storageMaxSlotCount, prefabDesc.stationMaxItemKinds); if (prefabDesc.isStellarStation) { if (!prefabDesc.isCollectStation) { _remoteStorageMax = Math.Max(_remoteStorageMax, prefabDesc.stationMaxItemCount); } } else { if (!prefabDesc.isVeinCollector) { _localStorageMax = Math.Max(_localStorageMax, prefabDesc.stationMaxItemCount); } } } } _localStorageExtra = -1; _remoteStorageExtra = -1; _localStoragePixelPerItem = 0f; _remoteStoragePixelPerItem = 0f; UpdateStorageMax(); RealtimeLogisticsInfoPanel.InitGUI(); } internal static void InitGUI() { if (_stationTipsRoot != null) return; _stationTipsRoot = new GameObject("station-tips-root"); var rtrans = _stationTipsRoot.AddComponent(); _stationTipsRoot.transform.SetParent(GameObject.Find("UI Root/Overlay Canvas/In Game/Scene UIs").transform); rtrans.sizeDelta = new Vector2(0f, 0f); rtrans.localScale = new Vector3(1f, 1f, 1f); rtrans.anchorMax = new Vector2(1f, 1f); rtrans.anchorMin = new Vector2(0f, 0f); rtrans.pivot = new Vector2(0f, 0f); rtrans.anchoredPosition3D = new Vector3(0, 0, 0f); var sliderBgPrefab = GameObject.Find("UI Root/Overlay Canvas/In Game/Windows/Station Window/storage-box-0/slider-bg"); if (sliderBgPrefab == null) { UXAssist.Logger.LogWarning("RealtimeLogisticsInfoPanel.InitGUI: slider-bg prefab not found, aborting GUI init"); Object.Destroy(_stationTipsRoot); _stationTipsRoot = null; return; } var veinTipPrefabGo = GameObject.Find("UI Root/Overlay Canvas/In Game/Scene UIs/Vein Marks/vein-tips/vein-tip-prefab"); if (veinTipPrefabGo == null) { UXAssist.Logger.LogWarning("RealtimeLogisticsInfoPanel.InitGUI: vein-tip-prefab not found, aborting GUI init"); Object.Destroy(_stationTipsRoot); _stationTipsRoot = null; return; } var keyTipPrefabGo = GameObject.Find("UI Root/Overlay Canvas/In Game/Windows/Key Tips/tip-prefab"); if (keyTipPrefabGo == null) { UXAssist.Logger.LogWarning("RealtimeLogisticsInfoPanel.InitGUI: key tip-prefab not found, aborting GUI init"); Object.Destroy(_stationTipsRoot); _stationTipsRoot = null; return; } _tipPrefab = Object.Instantiate(veinTipPrefabGo, _stationTipsRoot.transform); _tipPrefab.name = "tipPrefab"; Object.Destroy(_tipPrefab.GetComponent()); var image = _tipPrefab.GetComponent(); image.sprite = keyTipPrefabGo.GetComponent().sprite; image.color = new Color(0, 0, 0, 0.8f); image.enabled = true; var rectTrans = (RectTransform)_tipPrefab.transform; rectTrans.localPosition = new Vector3(200f, 800f, 0); rectTrans.sizeDelta = new Vector2(150f, 160f); rectTrans.pivot = new Vector2(0.5f, 0.5f); var infoText = _tipPrefab.transform.Find("info-text").gameObject; var tipIconPrefab = _tipPrefab.transform.Find("icon"); for (var index = 0; index < _storageMaxSlotCount; ++index) { var y = -5f - 35f * index; var itemIcon = Object.Instantiate(tipIconPrefab.gameObject, new Vector3(0, 0, 0), Quaternion.identity, _tipPrefab.transform); itemIcon.name = "icon" + index; rectTrans = (RectTransform)itemIcon.transform; rectTrans.sizeDelta = new Vector2(30f, 30f); rectTrans.anchorMax = new Vector2(0f, 1f); rectTrans.anchorMin = new Vector2(0f, 1f); rectTrans.pivot = new Vector2(0f, 1f); rectTrans.anchoredPosition3D = new Vector3(0, y, 0); var sliderBg = Object.Instantiate(sliderBgPrefab.gameObject, new Vector3(0, 0, 0), Quaternion.identity, _tipPrefab.transform); sliderBg.name = "sliderBg" + index; rectTrans = (RectTransform)sliderBg.transform; rectTrans.sizeDelta = new Vector2(StorageSliderWidth, StorageSliderHeight); rectTrans.anchorMax = new Vector2(0f, 1f); rectTrans.anchorMin = new Vector2(0f, 1f); rectTrans.pivot = new Vector2(0f, 1f); rectTrans.anchoredPosition3D = new Vector3(30f, y - 22f, 0f); rectTrans = (RectTransform)sliderBg.transform.Find("current-fg").transform; rectTrans.sizeDelta = new Vector2(0f, StorageSliderHeight); rectTrans.anchorMax = new Vector2(0f, 1f); rectTrans.anchorMin = new Vector2(0f, 1f); rectTrans.pivot = new Vector2(0f, 1f); rectTrans.localPosition = new Vector3(0f, 0f, 0f); rectTrans = (RectTransform)sliderBg.transform.Find("ordered-fg").transform; rectTrans.sizeDelta = new Vector2(0f, StorageSliderHeight); rectTrans.anchorMax = new Vector2(0f, 1f); rectTrans.anchorMin = new Vector2(0f, 1f); rectTrans.pivot = new Vector2(0f, 1f); rectTrans.localPosition = new Vector3(0f, 0f, 0f); rectTrans = (RectTransform)sliderBg.transform.Find("max-fg").transform; rectTrans.sizeDelta = new Vector2(StorageSliderWidth, StorageSliderHeight); rectTrans.anchorMax = new Vector2(0f, 1f); rectTrans.anchorMin = new Vector2(0f, 1f); rectTrans.pivot = new Vector2(0f, 1f); rectTrans.localPosition = new Vector3(0f, 0f, 0f); image = rectTrans.GetComponent(); image.color = new Color(image.color.r, image.color.g, image.color.b, 0.15f); Object.Destroy(sliderBg.GetComponent()); Object.Destroy(sliderBg.transform.Find("thumb").gameObject); Object.Destroy(sliderBg.transform.Find("speed-text").gameObject); sliderBg.gameObject.SetActive(false); var countText = Object.Instantiate(infoText, Vector3.zero, Quaternion.identity, _tipPrefab.transform); countText.name = "countText" + index; var text = countText.GetComponent(); text.fontSize = 18; text.text = ""; text.alignment = TextAnchor.UpperRight; rectTrans = (RectTransform)countText.transform; rectTrans.sizeDelta = new Vector2(70f, 20f); rectTrans.anchorMax = new Vector2(0f, 1f); rectTrans.anchorMin = new Vector2(0f, 1f); rectTrans.pivot = new Vector2(0f, 1f); rectTrans.anchoredPosition3D = new Vector3(30f, y, 0); Object.Destroy(countText.GetComponent()); var stateLocal = Object.Instantiate(tipIconPrefab.gameObject, new Vector3(0, 0, 0), Quaternion.identity, _tipPrefab.transform); stateLocal.name = "iconLocal" + index; stateLocal.GetComponent().material = null; rectTrans = (RectTransform)stateLocal.transform; rectTrans.sizeDelta = new Vector2(16f, 16f); rectTrans.anchorMax = new Vector2(0f, 1f); rectTrans.anchorMin = new Vector2(0f, 1f); rectTrans.pivot = new Vector2(0f, 1f); rectTrans.anchoredPosition3D = new Vector3(102f, y, 0); var stateRemote = Object.Instantiate(tipIconPrefab.gameObject, new Vector3(0, 0, 0), Quaternion.identity, _tipPrefab.transform); stateRemote.name = "iconRemote" + index; stateRemote.GetComponent().material = null; rectTrans = (RectTransform)stateRemote.transform; rectTrans.sizeDelta = new Vector2(20f, 20f); rectTrans.anchorMax = new Vector2(0f, 1f); rectTrans.anchorMin = new Vector2(0f, 1f); rectTrans.pivot = new Vector2(0f, 1f); rectTrans.anchoredPosition3D = new Vector3(100f, y - 12f, 0); } var iconPrefab = Object.Instantiate(GameObject.Find("UI Root/Overlay Canvas/In Game/Top Tips/Entity Briefs/brief-info-top/brief-info/content/icons/icon"), _tipPrefab.transform); Object.Destroy(iconPrefab.transform.Find("count-text").gameObject); Object.Destroy(iconPrefab.transform.Find("bg").gameObject); Object.Destroy(iconPrefab.transform.Find("inc").gameObject); Object.Destroy(iconPrefab.GetComponent()); iconPrefab.SetActive(false); for (var i = 0; i < CarrierSlotCount; i++) { var iconObj = Object.Instantiate(iconPrefab, new Vector3(0, 0, 0), Quaternion.identity, _tipPrefab.transform); iconObj.name = "carrierIcon" + i; iconObj.GetComponent().sprite = LogisticsExtraItemSprites[i]; rectTrans = (RectTransform)iconObj.transform; rectTrans.localScale = new Vector3(0.7f, 0.7f, 1f); rectTrans.anchorMax = new Vector2(0f, 1f); rectTrans.anchorMin = new Vector2(0f, 1f); rectTrans.pivot = new Vector2(0f, 1f); rectTrans.anchoredPosition3D = new Vector3(0f, -180f, 0); iconObj.SetActive(true); var countText = Object.Instantiate(infoText, Vector3.zero, Quaternion.identity, iconObj.transform); Object.Destroy(countText.GetComponent()); countText.name = "carrierTotalCountText"; var text = countText.GetComponent(); text.fontSize = 22; text.text = "100"; text.alignment = TextAnchor.MiddleRight; text.color = Color.white; rectTrans = (RectTransform)countText.transform; rectTrans.sizeDelta = new Vector2(40f, 30f); rectTrans.anchorMax = new Vector2(0f, 1f); rectTrans.anchorMin = new Vector2(0f, 1f); rectTrans.pivot = new Vector2(0f, 1f); rectTrans.localPosition = new Vector3(0f, -18f, 0f); if (i >= CarrierSlotCount - 1) continue; countText = Object.Instantiate(infoText, Vector3.zero, Quaternion.identity, iconObj.transform); Object.Destroy(countText.GetComponent()); countText.name = "carrierIdleCountText"; text = countText.GetComponent(); text.fontSize = 22; text.text = "100"; text.alignment = TextAnchor.MiddleRight; text.color = Color.white; rectTrans = (RectTransform)countText.transform; rectTrans.sizeDelta = new Vector2(40f, 30f); rectTrans.anchorMax = new Vector2(0f, 1f); rectTrans.anchorMin = new Vector2(0f, 1f); rectTrans.pivot = new Vector2(0f, 1f); rectTrans.localPosition = new Vector3(0f, 8f, 0f); } tipIconPrefab.gameObject.SetActive(false); _tipPrefab.SetActive(false); _stationTipsRoot.SetActive(false); StateSprite[0] = Util.LoadEmbeddedSprite("assets/icon/keep.png"); StateSprite[1] = Util.LoadEmbeddedSprite("assets/icon/out.png"); StateSprite[2] = Util.LoadEmbeddedSprite("assets/icon/in.png"); } private static void ReleaseStationTip(StationTip stationTip) { if (!stationTip) return; var go = stationTip.gameObject; if (_stationTipsRecycleCount < StationTipsRecycle.Length) { stationTip.ResetStationTip(); go.SetActive(false); StationTipsRecycle[_stationTipsRecycleCount++] = stationTip; } else { // Recycle pool is full: destroy the cloned UI GameObject (not just the // StationTip MonoBehaviour). Destroying only the component would leave the // GameObject and all its UI children orphaned under _stationTipsRoot, // causing "ghost" tips to remain visible whenever the root is reactivated. go.SetActive(false); Object.Destroy(go); } } private static void RecycleStationTips() { foreach (var stationTip in _stationTips) { ReleaseStationTip(stationTip); } _stationTips = new StationTip[16]; } private static void RecycleStationTip(int index) { var stationTip = _stationTips[index]; if (!stationTip) return; ReleaseStationTip(stationTip); _stationTips[index] = null; } private static void HideAndRecycleStationTips() { _stationTipsRoot?.SetActive(false); RecycleStationTips(); _lastPlanetId = 0; } private static StationTip AllocateStationTip() { if (_stationTipsRecycleCount > 0) { _stationTipsRecycleCount--; var result = StationTipsRecycle[_stationTipsRecycleCount]; StationTipsRecycle[_stationTipsRecycleCount] = null; return result; } var tempTip = Object.Instantiate(_tipPrefab, _stationTipsRoot.transform); var stationTip = tempTip.AddComponent(); stationTip.InitStationTip(); return stationTip; } internal static void StationInfoPanelsUpdate() { if (DSPGame.IsMenuDemo || !GameMain.isRunning) return; var localPlanet = GameMain.data?.localPlanet; if (localPlanet == null || !localPlanet.factoryLoaded) { _stationTipsRoot.SetActive(false); if (_lastPlanetId == 0) return; RecycleStationTips(); _lastPlanetId = 0; return; } if (_lastPlanetId != localPlanet.id) { RecycleStationTips(); _lastPlanetId = localPlanet.id; } var factory = localPlanet.factory; var transport = factory?.transport; if (transport is not { stationCursor: > 1 } || (UIGame.viewMode != EViewMode.Normal && UIGame.viewMode != EViewMode.Globe)) { _stationTipsRoot.SetActive(false); return; } _stationTipsRoot.SetActive(true); if (UpdateStorageMax()) { foreach (var tip in _stationTips) { tip?.ResetStorageSlider(); } } var localPosition = GameCamera.main.transform.localPosition; var forward = GameCamera.main.transform.forward; var realRadius = localPlanet.realRadius; var stationCount = transport.stationCursor; if (stationCount > _stationTips.Length) { var newSize = stationCount - 1; newSize |= newSize >> 1; newSize |= newSize >> 2; newSize |= newSize >> 4; newSize |= newSize >> 8; newSize |= newSize >> 16; newSize++; Array.Resize(ref _stationTips, newSize); } for (var i = stationCount - 1; i > 0; i--) { var stationComponent = transport.stationPool[i]; if (stationComponent == null || i != stationComponent.id) { RecycleStationTip(i); continue; } var storageArray = stationComponent.storage; if (storageArray == null) { RecycleStationTip(i); continue; } var stationTip = _stationTips[i]; if (!stationTip) { stationTip = AllocateStationTip(); _stationTips[i] = stationTip; } var position = factory.entityPool[stationComponent.entityId].pos.normalized; var radius = realRadius; if (stationComponent.isCollector) { radius += 35f; } else if (stationComponent.isStellar) { radius += 20f; } else if (stationComponent.isVeinCollector) { radius += 8f; } else { radius += 15f; } position *= radius; var vec = position - localPosition; var magnitude = vec.magnitude; if (magnitude < 1.0 || Vector3.Dot(forward, vec) < 1.0 || !UIRoot.ScreenPointIntoRect(GameCamera.main.WorldToScreenPoint(position), (RectTransform)_stationTipsRoot.transform, out var rectPoint) || rectPoint.x is < -4096f or > 4096f || rectPoint.y is < -4096f or > 4096f || Phys.RayCastSphere(localPosition, vec / magnitude, magnitude, Vector3.zero, realRadius, out _) || storageArray.Select(x => x.itemId).All(x => x == 0)) { stationTip.gameObject.SetActive(false); continue; } stationTip.gameObject.SetActive(true); var localScaleMultiple = magnitude switch { < 50f => 1.5f, < 250f => 1.75f - magnitude * 0.005f, _ => 0.5f }; /* rectPoint.x = Mathf.Round(rectPoint.x); rectPoint.y = Mathf.Round(rectPoint.y); */ stationTip.rectTransform.anchoredPosition = rectPoint; stationTip.transform.localScale = Vector3.one * localScaleMultiple; stationTip.UpdateStationInfo(stationComponent); } } // Defensive Harmony hook on GameData.localPlanet setter. // It guarantees tips are hidden and recycled when the local planet id changes, // even if UXAssist.Update() is skipped that frame (e.g. by VFInput.inputing // while the player is typing while transitioning between planets, or by a brief // !GameMain.isRunning state during loading). _lastPlanetId is reset to 0 here so // the next StationInfoPanelsUpdate() re-validates factoryLoaded/transport/viewMode // and re-allocates fresh tips for the new planet. private class LocalPlanetWatcher : PatchImpl { [HarmonyPrefix] [HarmonyPatch(typeof(GameData), nameof(GameData.localPlanet), MethodType.Setter)] private static void GameData_localPlanet_Setter_Prefix(GameData __instance, PlanetData value) { var oldId = __instance.localPlanet?.id ?? 0; var newId = value?.id ?? 0; if (oldId == newId) return; HideAndRecycleStationTips(); } } internal class StationTip : MonoBehaviour { [FormerlySerializedAs("RectTransform")] public RectTransform rectTransform; private Transform[] _icons; private Transform[] _iconLocals; private Transform[] _iconRemotes; private Transform[] _countTexts; private Transform[] _sliderBg; private Transform[] _sliderMax; private Transform[] _sliderCurrent; private Transform[] _sliderOrdered; private Image[] _sliderOrderedImage; private Image[] _iconsImage; private Image[] _iconLocalsImage; private Image[] _iconRemotesImage; private Text[] _countTextsText; private Transform[] _carrierIcons; private Text[] _carrierTotalCountText; private Text[] _carrierIdleCountText; private GameObject _infoText; private EStationTipLayout _layout = EStationTipLayout.None; private int _storageNum; private float _pixelPerItem; private StorageItemData[] _storageItems; private static readonly Dictionary ItemSprites = []; private static readonly Color[] StateColor = [Color.gray, SupplyColor, DemandColor]; private struct StorageItemData { public int ItemId; public int ItemCount; public int ItemOrdered; public int ItemMax; public ELogisticStorage LocalState; public ELogisticStorage RemoteState; } private enum EStationTipLayout { None, Collector, VeinCollector, PlanetaryLogistics, InterstellarLogistics } internal void InitStationTip() { _storageItems = new StorageItemData[_storageMaxSlotCount]; rectTransform = (RectTransform)transform; _icons = new Transform[_storageMaxSlotCount]; _iconLocals = new Transform[_storageMaxSlotCount]; _iconRemotes = new Transform[_storageMaxSlotCount]; _iconsImage = new Image[_storageMaxSlotCount]; _iconLocalsImage = new Image[_storageMaxSlotCount]; _iconRemotesImage = new Image[_storageMaxSlotCount]; _countTexts = new Transform[_storageMaxSlotCount]; _countTextsText = new Text[_storageMaxSlotCount]; _sliderBg = new Transform[_storageMaxSlotCount]; _sliderMax = new Transform[_storageMaxSlotCount]; _sliderCurrent = new Transform[_storageMaxSlotCount]; _sliderOrdered = new Transform[_storageMaxSlotCount]; _sliderOrderedImage = new Image[_storageMaxSlotCount]; _carrierIcons = new Transform[3]; _carrierTotalCountText = new Text[3]; _carrierIdleCountText = new Text[2]; _infoText = transform.Find("info-text").gameObject; for (var i = CarrierSlotCount - 1; i >= 0; i--) { _carrierIcons[i] = transform.Find("carrierIcon" + i); _carrierIcons[i].gameObject.SetActive(false); _carrierTotalCountText[i] = _carrierIcons[i].Find("carrierTotalCountText").GetComponent(); if (i >= CarrierSlotCount - 1) continue; _carrierIdleCountText[i] = _carrierIcons[i].Find("carrierIdleCountText").GetComponent(); } for (var i = _storageMaxSlotCount - 1; i >= 0; i--) { _countTexts[i] = transform.Find("countText" + i); _countTextsText[i] = _countTexts[i].GetComponent(); _sliderBg[i] = transform.Find("sliderBg" + i); _sliderMax[i] = _sliderBg[i].Find("max-fg"); _sliderCurrent[i] = _sliderBg[i].Find("current-fg"); _sliderOrdered[i] = _sliderBg[i].Find("ordered-fg"); _sliderOrderedImage[i] = _sliderOrdered[i].GetComponent(); _icons[i] = transform.Find("icon" + i); _iconsImage[i] = _icons[i].GetComponent(); _iconLocals[i] = transform.Find("iconLocal" + i); _iconRemotes[i] = transform.Find("iconRemote" + i); _iconLocalsImage[i] = _iconLocals[i].GetComponent(); _iconRemotesImage[i] = _iconRemotes[i].GetComponent(); _countTexts[i].gameObject.SetActive(false); _sliderBg[i].gameObject.SetActive(false); _icons[i].gameObject.SetActive(false); _iconLocals[i].gameObject.SetActive(false); _iconRemotes[i].gameObject.SetActive(false); _storageItems[i] = new StorageItemData { ItemId = -1, ItemCount = -1, ItemOrdered = -1, ItemMax = -1, LocalState = ELogisticStorage.None, RemoteState = ELogisticStorage.None }; } _infoText.SetActive(false); } internal void ResetStationTip() { _layout = EStationTipLayout.None; for (var i = _storageMaxSlotCount - 1; i >= 0; i--) { _countTexts[i].gameObject.SetActive(false); _sliderBg[i].gameObject.SetActive(false); _icons[i].gameObject.SetActive(false); _iconLocals[i].gameObject.SetActive(false); _iconRemotes[i].gameObject.SetActive(false); _countTextsText[i].color = StateColor[0]; _iconLocalsImage[i].color = StateColor[0]; _iconRemotesImage[i].color = StateColor[0]; ref var storageItem = ref _storageItems[i]; storageItem.ItemId = -1; storageItem.ItemCount = -1; storageItem.ItemOrdered = -1; storageItem.ItemMax = -1; storageItem.LocalState = ELogisticStorage.None; storageItem.RemoteState = ELogisticStorage.None; } for (var i = CarrierSlotCount - 1; i >= 0; i--) { _carrierIcons[i].gameObject.SetActive(false); } } internal void ResetStorageSlider() { for (var i = _storageMaxSlotCount - 1; i >= 0; i--) { ref var storageItem = ref _storageItems[i]; storageItem.ItemId = -1; storageItem.ItemCount = -1; storageItem.ItemOrdered = -1; storageItem.ItemMax = -1; } _pixelPerItem = _layout == EStationTipLayout.InterstellarLogistics ? _remoteStoragePixelPerItem : _localStoragePixelPerItem; } private static Sprite GetItemSprite(int itemId) { if (ItemSprites.TryGetValue(itemId, out var sprite)) return sprite; sprite = LDB.items.Select(itemId)?.iconSprite; ItemSprites[itemId] = sprite; return sprite; } internal void SetItem(int i, StationStore storage, bool barEnabled) { ref var storageState = ref _storageItems[i]; var countUIText = _countTextsText[i]; var itemId = storage.itemId; if (itemId != storageState.ItemId) { var icon = _icons[i]; storageState.ItemId = itemId; if (itemId <= 0) { icon.gameObject.SetActive(false); _iconLocals[i].gameObject.SetActive(false); _iconRemotes[i].gameObject.SetActive(false); _sliderBg[i].gameObject.SetActive(false); countUIText.color = StateColor[0]; countUIText.text = "— "; return; } storage.count = -1; icon.gameObject.SetActive(true); _iconsImage[i].sprite = GetItemSprite(itemId); _iconLocals[i].gameObject.SetActive(CarrierEnabled[(int)_layout][0]); _iconLocalsImage[i].sprite = StateSprite[(int)storageState.LocalState]; _iconRemotes[i].gameObject.SetActive(CarrierEnabled[(int)_layout][1]); _iconRemotesImage[i].sprite = StateSprite[(int)storageState.RemoteState]; _sliderBg[i].gameObject.SetActive(barEnabled); switch (_layout) { case EStationTipLayout.InterstellarLogistics: { countUIText.color = _iconRemotesImage[i].color = StateColor[(int)storageState.RemoteState]; break; } case EStationTipLayout.VeinCollector: case EStationTipLayout.PlanetaryLogistics: { countUIText.color = _iconLocalsImage[i].color = StateColor[(int)storageState.LocalState]; break; } case EStationTipLayout.None: case EStationTipLayout.Collector: default: break; } } else if (itemId <= 0) return; var itemCount = storage.count; var itemLimit = _layout == EStationTipLayout.InterstellarLogistics ? _remoteStorageMaxTotal : _localStorageMaxTotal; var barPositionChanged = false; if (storageState.ItemCount != itemCount) { storageState.ItemCount = itemCount; countUIText.text = itemCount.ToString(); if (itemCount > itemLimit) itemCount = itemLimit; if (barEnabled) { if (itemCount == 0) { _sliderCurrent[i].gameObject.SetActive(false); } else { ((RectTransform)_sliderCurrent[i].transform).sizeDelta = new Vector2( _pixelPerItem * itemCount, StorageSliderHeight ); _sliderCurrent[i].gameObject.SetActive(true); } barPositionChanged = true; } } else { if (itemCount > itemLimit) itemCount = itemLimit; } if (barEnabled) { var itemOrdered = storage.totalOrdered; if (storageState.ItemOrdered != itemOrdered) { storageState.ItemOrdered = itemOrdered; barPositionChanged = true; } if (barPositionChanged) { switch (itemOrdered) { case > 0: if (itemOrdered + itemCount > itemLimit) itemOrdered = itemLimit - itemCount; _sliderOrderedImage[i].color = OrderInColor; var rectTrans = (RectTransform)_sliderOrdered[i].transform; rectTrans.localPosition = new Vector3( _pixelPerItem * itemCount, 0f, 0f ); rectTrans.sizeDelta = new Vector2( _pixelPerItem * itemOrdered + 0.49f, StorageSliderHeight ); break; case < 0: if (itemOrdered + itemCount < 0) itemOrdered = -itemCount; _sliderOrderedImage[i].color = OrderOutColor; rectTrans = (RectTransform)_sliderOrdered[i].transform; rectTrans.localPosition = new Vector3( _pixelPerItem * (itemCount + itemOrdered), 0f, 0f ); rectTrans.sizeDelta = new Vector2( _pixelPerItem * -itemOrdered + 0.49f, StorageSliderHeight ); break; } _sliderOrdered[i].gameObject.SetActive(itemOrdered != 0); } var itemMax = storage.max; if (storageState.ItemMax != itemMax) { storageState.ItemMax = itemMax; _sliderBg[i].gameObject.SetActive(itemMax > 0); if (itemMax > itemLimit) itemMax = itemLimit; ((RectTransform)_sliderMax[i].transform).sizeDelta = new Vector2( _pixelPerItem * itemMax, StorageSliderHeight ); } } switch (_layout) { case EStationTipLayout.InterstellarLogistics: { var localLogic = storage.localLogic; if (storageState.LocalState != localLogic) { storageState.LocalState = localLogic; var iconLocalImage = _iconLocalsImage[i]; iconLocalImage.sprite = StateSprite[(int)localLogic]; iconLocalImage.color = StateColor[(int)localLogic]; } var remoteLogic = storage.remoteLogic; if (storageState.RemoteState != remoteLogic) { storageState.RemoteState = remoteLogic; var iconRemoteImage = _iconRemotesImage[i]; iconRemoteImage.sprite = StateSprite[(int)remoteLogic]; countUIText.color = iconRemoteImage.color = StateColor[(int)remoteLogic]; } break; } case EStationTipLayout.VeinCollector: case EStationTipLayout.PlanetaryLogistics: { var localLogic = storage.localLogic; if (storageState.LocalState != localLogic) { storageState.LocalState = localLogic; var iconLocalImage = _iconLocalsImage[i]; iconLocalImage.sprite = StateSprite[(int)localLogic]; countUIText.color = iconLocalImage.color = StateColor[(int)localLogic]; } break; } case EStationTipLayout.None: case EStationTipLayout.Collector: default: break; } } private static readonly bool[][] CarrierEnabled = [ [false, false, false], [false, false, false], [false, false, false], [true, false, false], [true, true, true], ]; private static readonly float[] TipWindowWidths = [0f, 100f, 120f, 120f, 120f]; private static readonly float[] TipWindowExtraHeights = [0f, 5f, 5f, 40f, 40f]; private static readonly float[] CarrierPositionX = [5f, 35f, 85f]; internal void UpdateStationInfo(StationComponent stationComponent) { var layout = stationComponent.isCollector ? EStationTipLayout.Collector : stationComponent.isVeinCollector ? EStationTipLayout.VeinCollector : stationComponent.isStellar ? EStationTipLayout.InterstellarLogistics : EStationTipLayout.PlanetaryLogistics; if (_layout != layout) { _layout = layout; for (var i = _storageMaxSlotCount - 1; i >= 0; i--) { _iconLocals[i].gameObject.SetActive(false); _iconRemotes[i].gameObject.SetActive(false); _icons[i].gameObject.SetActive(false); switch (layout) { case EStationTipLayout.PlanetaryLogistics: var rectTrans = (RectTransform)_iconLocals[i].transform; rectTrans.sizeDelta = new Vector2(20f, 20f); rectTrans.anchoredPosition3D = new Vector3(100f, -5f - 35f * i - 5f, 0); break; case EStationTipLayout.InterstellarLogistics: rectTrans = (RectTransform)_iconLocals[i].transform; rectTrans.sizeDelta = new Vector2(16f, 16f); rectTrans.anchoredPosition3D = new Vector3(102f, -5f - 35f * i, 0); break; } } for (var i = _storageNum; i < _storageMaxSlotCount; i++) { _iconLocals[i].gameObject.SetActive(false); _iconRemotes[i].gameObject.SetActive(false); _icons[i].gameObject.SetActive(false); _countTexts[i].gameObject.SetActive(false); _sliderBg[i].gameObject.SetActive(false); } _storageNum = Math.Min(_storageMaxSlotCount, stationComponent.storage.Length); rectTransform.sizeDelta = new Vector2(TipWindowWidths[(int)layout], TipWindowExtraHeights[(int)layout] + 35f * _storageNum); for (var i = _storageMaxSlotCount - 1; i >= 0; i--) { _countTexts[i].gameObject.SetActive(i < _storageNum); } for (var i = CarrierSlotCount - 1; i >= 0; i--) { var active = CarrierEnabled[(int)layout][i]; _carrierIcons[i].gameObject.SetActive(active); if (!active) continue; var rectTrans = (RectTransform)_carrierIcons[i].transform; rectTrans.anchoredPosition3D = new Vector3(CarrierPositionX[i], -5f - 35f * _storageNum, 0); } _pixelPerItem = _layout == EStationTipLayout.InterstellarLogistics ? _remoteStoragePixelPerItem : _localStoragePixelPerItem; } if (_storageNum > 0) { var storageArray = stationComponent.storage; var barEnabled = LogisticsPatch.RealtimeLogisticsInfoPanelBarsEnabled.Value; for (var j = _storageNum - 1; j >= 0; j--) { var storage = storageArray[j]; SetItem(j, storage, barEnabled); } } switch (_layout) { case EStationTipLayout.PlanetaryLogistics: var totalCount = stationComponent.idleDroneCount + stationComponent.workDroneCount; var currentCount = stationComponent.idleDroneCount; _carrierIdleCountText[0].text = currentCount.ToString(); _carrierTotalCountText[0].text = totalCount.ToString(); break; case EStationTipLayout.InterstellarLogistics: totalCount = stationComponent.idleDroneCount + stationComponent.workDroneCount; currentCount = stationComponent.idleDroneCount; _carrierIdleCountText[0].text = currentCount.ToString(); _carrierTotalCountText[0].text = totalCount.ToString(); totalCount = stationComponent.idleShipCount + stationComponent.workShipCount; currentCount = stationComponent.idleShipCount; _carrierIdleCountText[1].text = currentCount.ToString(); _carrierTotalCountText[1].text = totalCount.ToString(); currentCount = stationComponent.warperCount; _carrierTotalCountText[2].text = currentCount.ToString(); break; case EStationTipLayout.None: case EStationTipLayout.Collector: case EStationTipLayout.VeinCollector: default: break; } } internal void SetBarsVisible(bool on) { for (var i = _storageNum - 1; i >= 0; i--) { _sliderBg[i].gameObject.SetActive(on && _storageItems[i].ItemId > 0); } for (var i = _storageNum; i < _storageMaxSlotCount; i++) { _sliderBg[i].gameObject.SetActive(false); } } } }