Files
Tarkov-UIFixes/Patches/ItemPanelPatches.cs

453 lines
21 KiB
C#

using Aki.Reflection.Patching;
using Aki.Reflection.Utils;
using EFT.InventoryLogic;
using EFT.UI;
using HarmonyLib;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using TMPro;
using UnityEngine;
namespace UIFixes
{
public class ItemPanelPatches
{
private static FieldInfo AttributeCompactPanelDictionaryField;
private static FieldInfo AttributeCompactDropdownDictionaryField;
private static FieldInfo ItemComponentItemField;
private static FieldInfo CompactCharacteristicPanelItemAttributeField;
public static void Enable()
{
AttributeCompactPanelDictionaryField = AccessTools.GetDeclaredFields(typeof(ItemSpecificationPanel)).First(f => typeof(IEnumerable<KeyValuePair<ItemAttributeClass, CompactCharacteristicPanel>>).IsAssignableFrom(f.FieldType));
AttributeCompactDropdownDictionaryField = AccessTools.GetDeclaredFields(typeof(ItemSpecificationPanel)).First(f => typeof(IEnumerable<KeyValuePair<ItemAttributeClass, CompactCharacteristicDropdownPanel>>).IsAssignableFrom(f.FieldType));
Type itemComponentType = PatchConstants.EftTypes.First(t => typeof(IItemComponent).IsAssignableFrom(t) && t.GetField("Item") != null); // GClass2754
ItemComponentItemField = AccessTools.Field(itemComponentType, "Item");
CompactCharacteristicPanelItemAttributeField = AccessTools.Field(typeof(CompactCharacteristicPanel), "ItemAttribute");
new InjectButtonPatch().Enable();
new LoadModStatsPatch().Enable();
new CompareModPatch().Enable();
new FormatCompactValuesPatch().Enable();
new FormatFullValuesPatch().Enable();
}
private class LoadModStatsPatch : ModulePatch
{
protected override MethodBase GetTargetMethod()
{
return AccessTools.Method(typeof(ItemSpecificationPanel), "method_5");
}
[PatchPostfix]
private static void Postfix(
ItemSpecificationPanel __instance,
Item ___item_0,
CompactCharacteristicPanel ____compactCharTemplate,
Transform ____compactPanel,
SimpleTooltip ___simpleTooltip_0)
{
if (!Settings.ShowModStats.Value || !(___item_0 is Mod))
{
return;
}
bool changed;
var deepAttributes = GetDeepAttributes(___item_0, out changed);
if (!changed)
{
return;
}
var compactPanels = AttributeCompactPanelDictionaryField.GetValue(__instance) as IDisposable;
// Clean up existing one
if (compactPanels != null)
{
compactPanels.Dispose();
}
var newCompactPanels = Activator.CreateInstance(AttributeCompactPanelDictionaryField.FieldType,
[
deepAttributes,
____compactCharTemplate,
____compactPanel,
(ItemAttributeClass attribute, CompactCharacteristicPanel viewer) => viewer.Show(attribute, ___simpleTooltip_0, __instance.Boolean_0, 100)
]) as IEnumerable<KeyValuePair<ItemAttributeClass, CompactCharacteristicPanel>>;
AttributeCompactPanelDictionaryField.SetValue(__instance, newCompactPanels);
if (newCompactPanels.Any())
{
newCompactPanels.Last().Value.OnTextWidthCalculated += __instance.method_3;
int siblingIndex = newCompactPanels.Last().Value.Transform.GetSiblingIndex();
var compactDropdownPanels = AttributeCompactDropdownDictionaryField.GetValue(__instance) as IEnumerable<KeyValuePair<ItemAttributeClass, CompactCharacteristicDropdownPanel>>;
foreach (var item in compactDropdownPanels)
{
item.Value.Transform.SetSiblingIndex(++siblingIndex);
}
}
__instance.method_10(0f);
__instance.method_6(null);
}
}
// The fundamental thing about mods is that unlike weapons, armor, etc, they do not change their own attributes when they "accept" inner mods.
// I guess weapons figure out their stats by deeply iterating all mods, rather than just their direct mods
// As a result, the compare method that works with weapons/armor doesn't work with mods. Normally, it "adds" the mod, clones the result, then reverts the "add". Hence
// the compareItem is the item with the mods. But again, as mods don't change their values, we see no change.
// I wish I could prefix method_6 and update the compare item with the deep attributes, but that only works when adding a mod
// When removing, current item and compare item end up the same since current item never considers the mod anyway
// So I have to forcably call the refresh values method
private class CompareModPatch : ModulePatch
{
private static MethodInfo RefreshStaticMethod;
protected override MethodBase GetTargetMethod()
{
RefreshStaticMethod = AccessTools.Method(typeof(ItemSpecificationPanel), "smethod_1", null, [typeof(CompactCharacteristicPanel)]);
return AccessTools.Method(typeof(ItemSpecificationPanel), "method_6");
}
[PatchPrefix]
private static void Prefix(Item compareItem)
{
if (compareItem == null)
{
return;
}
// Armor points is added in method_5, but not in other places so it's missed by compare
ArmorComponent[] armorComponents = compareItem.GetItemComponentsInChildren<ArmorComponent>(true).Where(c => c.ArmorClass > 0).ToArray<ArmorComponent>();
if (armorComponents.Any())
{
float maxDurability = armorComponents.Sum(c => c.Repairable.Durability);
ItemAttributeClass itemAttributeClass = new ItemAttributeClass(EItemAttributeId.ArmorPoints);
itemAttributeClass.Name = EItemAttributeId.ArmorPoints.GetName();
itemAttributeClass.Base = () => maxDurability;
itemAttributeClass.StringValue = () => Math.Round(maxDurability, 1).ToString(CultureInfo.InvariantCulture);
itemAttributeClass.DisplayType = () => EItemAttributeDisplayType.Compact;
compareItem.Attributes.Insert(0, itemAttributeClass);
}
}
[PatchPostfix]
private static void Postfix(ItemSpecificationPanel __instance, Item compareItem)
{
if (!Settings.ShowModStats.Value || !(compareItem is Mod))
{
return;
}
bool changed;
List<ItemAttributeClass> deepAttributes = GetDeepAttributes(compareItem, out changed);
if (!changed)
{
return;
}
var compactPanels = AttributeCompactPanelDictionaryField.GetValue(__instance);
RefreshStaticMethod.Invoke(null, [compactPanels, deepAttributes]);
}
}
private class InjectButtonPatch : ModulePatch
{
private static FieldInfo InteractionsButtonsContainerButtonTemplateField;
private static FieldInfo InteractionsButtonsContainerContainerField;
private static FieldInfo InteractionsButtonContainerUIField;
private static MethodInfo InteractionsButtonContainerUIAddDisposableMethod;
private static FieldInfo SimpleContextMenuButtonTextField;
protected override MethodBase GetTargetMethod()
{
InteractionsButtonsContainerButtonTemplateField = AccessTools.Field(typeof(InteractionButtonsContainer), "_buttonTemplate");
InteractionsButtonsContainerContainerField = AccessTools.Field(typeof(InteractionButtonsContainer), "_buttonsContainer");
InteractionsButtonContainerUIField = AccessTools.Field(typeof(InteractionButtonsContainer), "UI");
InteractionsButtonContainerUIAddDisposableMethod = AccessTools.Method(InteractionsButtonContainerUIField.FieldType, "AddDisposable", [typeof(Action)]);
SimpleContextMenuButtonTextField = AccessTools.Field(typeof(ContextMenuButton), "_text");
return AccessTools.Method(typeof(ItemSpecificationPanel), "method_4");
}
private static string GetLabel()
{
return Settings.ShowModStats.Value ? "HIDE MOD STATS" : "SHOW MOD STATS";
}
[PatchPostfix]
private static void Postfix(ItemSpecificationPanel __instance, ItemInfoInteractionsAbstractClass<EItemInfoButton> contextInteractions, Item ___item_0, InteractionButtonsContainer ____interactionButtonsContainer)
{
if (!(___item_0 is Mod))
{
return;
}
SimpleContextMenuButton template = InteractionsButtonsContainerButtonTemplateField.GetValue(____interactionButtonsContainer) as SimpleContextMenuButton;
Transform transform = InteractionsButtonsContainerContainerField.GetValue(____interactionButtonsContainer) as Transform;
SimpleContextMenuButton toggleButton = null;
Action onClick = () => Settings.ShowModStats.Value = !Settings.ShowModStats.Value;
// Listen to the setting and the work there to handle multiple windows open at once
EventHandler onSettingChanged = (sender, args) =>
{
var text = SimpleContextMenuButtonTextField.GetValue(toggleButton) as TextMeshProUGUI;
text.text = GetLabel();
__instance.method_5(); // rebuild stat panels
};
Settings.ShowModStats.SettingChanged += onSettingChanged;
Action createButton = () =>
{
Sprite sprite = CacheResourcesPopAbstractClass.Pop<Sprite>("Characteristics/Icons/Modding");
toggleButton = UnityEngine.Object.Instantiate(template, transform, false);
toggleButton.Show(GetLabel(), null, sprite, onClick, null);
____interactionButtonsContainer.method_5(toggleButton); // add to disposable list
};
// Subscribe to redraws to recreate when mods get dropped in
contextInteractions.OnRedrawRequired += createButton;
// And unsubscribe when the window goes away
InteractionsButtonContainerUIAddDisposableMethod.Invoke(InteractionsButtonContainerUIField.GetValue(____interactionButtonsContainer), [() =>
{
contextInteractions.OnRedrawRequired -= createButton;
Settings.ShowModStats.SettingChanged -= onSettingChanged;
}]);
createButton();
}
}
private class FormatCompactValuesPatch : ModulePatch
{
protected override MethodBase GetTargetMethod()
{
return AccessTools.Method(typeof(CompactCharacteristicPanel), "SetValues");
}
[PatchPostfix]
private static void Postfix(CompactCharacteristicPanel __instance, TextMeshProUGUI ___ValueText)
{
try
{
FormatText(__instance, ___ValueText);
}
catch (Exception ex)
{
Logger.LogError(ex);
}
}
}
private class FormatFullValuesPatch : ModulePatch
{
protected override MethodBase GetTargetMethod()
{
return AccessTools.Method(typeof(CharacteristicPanel), "SetValues");
}
[PatchPostfix]
private static void Postfix(CharacteristicPanel __instance, TextMeshProUGUI ___ValueText)
{
try
{
FormatText(__instance, ___ValueText);
}
catch (Exception ex)
{
Logger.LogError(ex);
}
}
}
// These fields are percents, but have been manually multipied by 100 already
private static readonly EItemAttributeId[] NonPercentPercents = [EItemAttributeId.ChangeMovementSpeed, EItemAttributeId.ChangeTurningSpeed, EItemAttributeId.Ergonomics];
private static void FormatText(CompactCharacteristicPanel panel, TextMeshProUGUI textMesh)
{
// Comparisons are shown as <value>(<changed>)
// <value> is from each attribute type's StringValue() function, so is formatted *mostly* ok
// <changed> is just naively formatted with ToString("F2"), so I have to figure out what it is and fix that
// This method is a gnarly pile of regex and replacements, blame BSG
if (!Settings.StyleItemPanel.Value)
{
return;
}
// These come from CompactCharacteristicPanel._increasingColor and _decreasingColor, which are hardcoded. Hardcoding here too because
// CharacteristicPanel doesn't define and you get clear
const string IncreasingColorHex = "#5EC1FF";
const string DecreasingColorHex = "#C40000";
string text = textMesh.text;
ItemAttributeClass attribute = CompactCharacteristicPanelItemAttributeField.GetValue(panel) as ItemAttributeClass;
// Some percents are formatted with ToString("P1"), which puts a space before the %. These are percents from 0-1, so the <changed> value need to be converted
var match = Regex.Match(text, @" %\(([+-].*)\)");
if (match.Success)
{
float value;
// If this fails to parse, I don't know what it is, leave it be
if (float.TryParse(match.Groups[1].Value, out value))
{
string sign = value > 0 ? "+" : "";
string color = (attribute.LessIsGood && value < 0) || (!attribute.LessIsGood && value > 0) ? IncreasingColorHex : DecreasingColorHex;
// Except some that have a space weren't actually formatted with P1 and are 0-100 with a manually added " %"
if (NonPercentPercents.Contains((EItemAttributeId)attribute.Id))
{
text = Regex.Replace(text, @"%\([+-].*\)", "%<color=" + color + ">(" + sign + value + "%)</color>");
}
else
{
text = Regex.Replace(text, @"%\([+-].*\)", "%<color=" + color + ">(" + sign + value.ToString("P1") + ")</color>");
}
}
}
else
{
// Others are rendered as num + "%", so there's no space before the %. These are percents but are from 0-100, not 0-1.
match = Regex.Match(text, @"(\S)%\(([+-].*)\)");
if (match.Success)
{
float value;
// If this fails to parse, I don't know what it is, leave it be
if (float.TryParse(match.Groups[2].Value, out value))
{
string sign = value > 0 ? "+" : "";
string color = (attribute.LessIsGood && value < 0) || (!attribute.LessIsGood && value > 0) ? IncreasingColorHex : DecreasingColorHex;
text = Regex.Replace(text, @"(\S)%\(([+-].*)\)", match.Groups[1].Value + "%<color=" + color + ">(" + sign + value + "%)</color>");
}
}
else
{
// Finally the ones that aren't percents
match = Regex.Match(text, @"\(([+-].*)\)");
if (match.Success)
{
float value;
// If this fails to parse, I don't know what it is, leave it be
if (float.TryParse(match.Groups[1].Value, out value))
{
string sign = value > 0 ? "+" : "";
string color = (attribute.LessIsGood && value < 0) || (!attribute.LessIsGood && value > 0) ? IncreasingColorHex : DecreasingColorHex;
text = Regex.Replace(text, @"\(([+-].*)\)", "<color=" + color + ">(" + sign + value + ")</color>");
}
}
}
}
// Remove trailing 0s
text = RemoveTrailingZeros(text);
// Fix spacing
text = text.Replace(" %", "%");
text = text.Replace("(", " (");
textMesh.text = text;
}
private static List<ItemAttributeClass> GetDeepAttributes(Item item, out bool changed)
{
changed = false;
List<ItemAttributeClass> itemAttributes = item.Attributes.Where(a => a.DisplayType() == EItemAttributeDisplayType.Compact).ToList();
foreach (var subItem in item.GetAllItems())
{
if (subItem == item)
{
continue;
}
var subAttributes = GetDeepAttributes(subItem, out changed);
itemAttributes = CombineAttributes(itemAttributes, subAttributes).ToList();
changed = true;
}
return itemAttributes;
}
private static IEnumerable<ItemAttributeClass> CombineAttributes(IList<ItemAttributeClass> first, IList<ItemAttributeClass> second)
{
foreach (EItemAttributeId id in first.Select(a => a.Id).Union(second.Select(a => a.Id)))
{
// Need to cast the id since it's of type Enum for some reason
var attribute = first.FirstOrDefault(a => (EItemAttributeId)a.Id == id);
var other = second.FirstOrDefault(a => (EItemAttributeId)a.Id == id);
if (attribute == null)
{
yield return other;
}
else if (other == null)
{
yield return attribute;
}
else
{
var combined = attribute.Clone();
switch (attribute.Id)
{
case EItemAttributeId.EffectiveDist:
case EItemAttributeId.SightingRange:
combined.Base = () => Math.Max(attribute.Base(), other.Base());
combined.StringValue = () => combined.Base().ToString();
break;
case EItemAttributeId.Accuracy:
case EItemAttributeId.Recoil:
combined.Base = () => attribute.Base() + other.Base();
combined.StringValue = () => combined.Base() + "%";
break;
case EItemAttributeId.Loudness:
case EItemAttributeId.Ergonomics:
case EItemAttributeId.Velocity:
combined.Base = () => attribute.Base() + other.Base();
combined.StringValue = () => combined.Base().ToString();
break;
case EItemAttributeId.DurabilityBurn:
case EItemAttributeId.HeatFactor:
case EItemAttributeId.CoolFactor:
combined.Base = () => attribute.Base() + other.Base();
combined.StringValue = () => combined.Base().ToString("P1");
break;
case EItemAttributeId.RaidModdable:
break;
default:
break;
}
yield return combined;
}
}
}
public static string RemoveTrailingZeros(string input)
{
// This matches: a number (so it doesn't apply to periods in words), named "integer"
// Followed by either
// a) a dot, some digits, and then a non-zero digit (named "significantDigits"), which is followed by one or more trailing 0
// b) a dot and some trailing 0
// And all that is replaced to the original integer, and the significantDigits (if they exist)
// If neither matches this doesn't match and does nothing
return Regex.Replace(input, @"(?<integer>\d)((?<significantDecimals>\.[0-9]*[1-9])0*\b)?(\.0+\b)?", "${integer}${significantDecimals}");
}
}
}