From 3f1075e67fc978efc7a0ed13ff62b9f9b5aa95ca Mon Sep 17 00:00:00 2001 From: Guus Waals <_@guusw.nl> Date: Fri, 30 Dec 2022 00:58:33 +0100 Subject: [PATCH] Initial commit --- .gitignore | 9 + mod/.gitignore | 3 + mod/Mod.cs | 63 + mod/Options.cs | 28 + mod/PLib/PLib.csproj | 49 + mod/PLib/PLib.xcf | Bin 0 -> 117291 bytes mod/PLib/README.md | 239 ++++ mod/PLib/icon.png | Bin 0 -> 86763 bytes mod/PLibAVC/IModVersionChecker.cs | 42 + mod/PLibAVC/JsonURLVersionChecker.cs | 146 +++ mod/PLibAVC/ModVersionCheckResults.cs | 70 ++ mod/PLibAVC/PLibAVC.csproj | 22 + mod/PLibAVC/PVersionCheck.cs | 340 ++++++ mod/PLibAVC/SteamVersionChecker.cs | 168 +++ mod/PLibAVC/VersionCheckTask.cs | 109 ++ mod/PLibAVC/YamlURLVersionChecker.cs | 82 ++ mod/PLibActions/PAction.cs | 92 ++ mod/PLibActions/PActionManager.cs | 357 ++++++ mod/PLibActions/PKeyBinding.cs | 71 ++ mod/PLibActions/PLibActions.csproj | 20 + mod/PLibActions/PToolMode.cs | 118 ++ mod/PLibBuildings/BuildIngredient.cs | 110 ++ mod/PLibBuildings/ColoredRangeVisualizer.cs | 271 +++++ mod/PLibBuildings/ConduitConnection.cs | 43 + mod/PLibBuildings/PBuilding.Utils.cs | 158 +++ mod/PLibBuildings/PBuilding.cs | 479 ++++++++ mod/PLibBuildings/PBuildingManager.cs | 208 ++++ mod/PLibBuildings/PLibBuildings.csproj | 20 + mod/PLibBuildings/PowerRequirement.cs | 48 + mod/PLibCore/Detours/DetourException.cs | 28 + mod/PLibCore/Detours/DetouredField.cs | 53 + mod/PLibCore/Detours/DetouredMethod.cs | 73 ++ mod/PLibCore/Detours/IDetouredField.cs | 44 + mod/PLibCore/Detours/LazyDetouredField.cs | 93 ++ mod/PLibCore/Detours/PDetours.cs | 600 ++++++++++ mod/PLibCore/ExtensionMethods.cs | 285 +++++ mod/PLibCore/IPLibRegistry.cs | 68 ++ mod/PLibCore/IRefreshUserMenu.cs | 31 + mod/PLibCore/PForwardedComponent.cs | 292 +++++ mod/PLibCore/PGameUtils.cs | 143 +++ mod/PLibCore/PLibCore.csproj | 24 + mod/PLibCore/PLibCorePatches.cs | 103 ++ mod/PLibCore/PLibLocalization.cs | 78 ++ mod/PLibCore/PLibStrings.cs | 204 ++++ mod/PLibCore/PPatchTools.cs | 1028 +++++++++++++++++ mod/PLibCore/PRegistry.cs | 137 +++ mod/PLibCore/PRegistryComponent.cs | 253 ++++ mod/PLibCore/PStateMachines.cs | 105 ++ mod/PLibCore/PTranspilerTools.cs | 359 ++++++ mod/PLibCore/PUtil.cs | 252 ++++ mod/PLibCore/PVersion.cs | 30 + mod/PLibCore/PVersionList.cs | 45 + mod/PLibCore/PatchManager/IPLibAnnotation.cs | 38 + .../PatchManager/IPatchMethodInstance.cs | 33 + .../PatchManager/PLibMethodAttribute.cs | 115 ++ .../PatchManager/PLibPatchAttribute.cs | 312 +++++ mod/PLibCore/PatchManager/PPatchManager.cs | 268 +++++ mod/PLibCore/PatchManager/RunAt.cs | 93 ++ mod/PLibCore/PriorityQueue.cs | 271 +++++ mod/PLibCore/Remote/PRemoteComponent.cs | 146 +++ mod/PLibCore/Remote/PRemoteRegistry.cs | 150 +++ mod/PLibCore/TextMeshProPatcher.cs | 164 +++ mod/PLibCore/translations/fr.po | 96 ++ mod/PLibCore/translations/ru.po | 163 +++ mod/PLibCore/translations/template.pot | 150 +++ mod/PLibCore/translations/zh.po | 206 ++++ mod/PLibDatabase/PCodexManager.cs | 316 +++++ mod/PLibDatabase/PColonyAchievement.cs | 172 +++ mod/PLibDatabase/PDatabaseUtils.cs | 73 ++ mod/PLibDatabase/PLibDatabase.csproj | 20 + mod/PLibDatabase/PLocalization.cs | 155 +++ mod/PLibLighting/ILightShape.cs | 45 + mod/PLibLighting/LightingArgs.cs | 133 +++ mod/PLibLighting/LightingPatches.cs | 170 +++ mod/PLibLighting/OctantBuilder.cs | 116 ++ mod/PLibLighting/PLibLighting.csproj | 20 + mod/PLibLighting/PLightManager.cs | 378 ++++++ mod/PLibLighting/PLightShape.cs | 85 ++ mod/PLibLighting/PRemoteLightWrapper.cs | 83 ++ mod/PLibOptions/ButtonOptionsEntry.cs | 72 ++ mod/PLibOptions/CategoryExpandHandler.cs | 102 ++ mod/PLibOptions/CheckboxOptionsEntry.cs | 71 ++ mod/PLibOptions/Color32OptionsEntry.cs | 38 + mod/PLibOptions/ColorBaseOptionsEntry.cs | 328 ++++++ mod/PLibOptions/ColorGradient.cs | 227 ++++ mod/PLibOptions/ColorOptionsEntry.cs | 38 + mod/PLibOptions/CompositeOptionsEntry.cs | 161 +++ mod/PLibOptions/ConfigFileAttribute.cs | 59 + mod/PLibOptions/DynamicOptionAttribute.cs | 54 + mod/PLibOptions/FloatOptionsEntry.cs | 119 ++ mod/PLibOptions/IOptionSpec.cs | 53 + mod/PLibOptions/IOptions.cs | 54 + mod/PLibOptions/IOptionsEntry.cs | 46 + mod/PLibOptions/IntOptionsEntry.cs | 112 ++ mod/PLibOptions/LimitAttribute.cs | 75 ++ mod/PLibOptions/LogFloatOptionsEntry.cs | 120 ++ mod/PLibOptions/ModDialogInfo.cs | 93 ++ mod/PLibOptions/ModInfoAttribute.cs | 57 + mod/PLibOptions/NullableFloatOptionsEntry.cs | 132 +++ mod/PLibOptions/NullableIntOptionsEntry.cs | 130 +++ mod/PLibOptions/OptionAttribute.cs | 80 ++ mod/PLibOptions/OptionsDialog.cs | 473 ++++++++ mod/PLibOptions/OptionsEntry.cs | 383 ++++++ mod/PLibOptions/OptionsHandlers.cs | 151 +++ mod/PLibOptions/PLibOptions.csproj | 21 + mod/PLibOptions/POptions.cs | 405 +++++++ mod/PLibOptions/RequireDLCAttribute.cs | 68 ++ mod/PLibOptions/RestartRequiredAttribute.cs | 30 + mod/PLibOptions/SelectOneOptionsEntry.cs | 179 +++ mod/PLibOptions/SingletonOptions.cs | 52 + mod/PLibOptions/SlidingBaseOptionsEntry.cs | 105 ++ mod/PLibOptions/StringOptionsEntry.cs | 94 ++ mod/PLibOptions/TextBlockOptionsEntry.cs | 76 ++ mod/PLibUI/IDynamicSizable.cs | 32 + mod/PLibUI/ISettableFlexSize.cs | 36 + mod/PLibUI/ITooltipListableOption.cs | 27 + mod/PLibUI/IUIComponent.cs | 43 + mod/PLibUI/ImageTransform.cs | 36 + mod/PLibUI/LayoutSizes.cs | 88 ++ mod/PLibUI/Layouts/AbstractLayoutGroup.cs | 239 ++++ mod/PLibUI/Layouts/BoxLayoutGroup.cs | 266 +++++ mod/PLibUI/Layouts/BoxLayoutParams.cs | 61 + mod/PLibUI/Layouts/BoxLayoutResults.cs | 147 +++ mod/PLibUI/Layouts/CardLayoutGroup.cs | 208 ++++ mod/PLibUI/Layouts/CardLayoutResults.cs | 66 ++ mod/PLibUI/Layouts/GridComponent.cs | 42 + mod/PLibUI/Layouts/GridComponentSpec.cs | 166 +++ mod/PLibUI/Layouts/GridLayoutResults.cs | 304 +++++ mod/PLibUI/Layouts/PGridLayoutGroup.cs | 349 ++++++ mod/PLibUI/Layouts/RelativeLayoutGroup.cs | 292 +++++ mod/PLibUI/Layouts/RelativeLayoutParams.cs | 185 +++ mod/PLibUI/Layouts/RelativeLayoutResults.cs | 132 +++ mod/PLibUI/Layouts/RelativeLayoutUtil.cs | 401 +++++++ mod/PLibUI/PButton.cs | 173 +++ mod/PLibUI/PCheckBox.cs | 270 +++++ mod/PLibUI/PComboBox.cs | 315 +++++ mod/PLibUI/PComboBoxComponent.cs | 260 +++++ mod/PLibUI/PContainer.cs | 99 ++ mod/PLibUI/PDialog.cs | 434 +++++++ mod/PLibUI/PGridPanel.cs | 144 +++ mod/PLibUI/PLabel.cs | 93 ++ mod/PLibUI/PLibUI.csproj | 20 + mod/PLibUI/PPanel.cs | 168 +++ mod/PLibUI/PRelativePanel.cs | 338 ++++++ mod/PLibUI/PScrollPane.cs | 376 ++++++ mod/PLibUI/PSlider.cs | 268 +++++ mod/PLibUI/PSpacer.cs | 66 ++ mod/PLibUI/PTextArea.cs | 219 ++++ mod/PLibUI/PTextComponent.cs | 332 ++++++ mod/PLibUI/PTextField.cs | 302 +++++ mod/PLibUI/PTextFieldEvents.cs | 159 +++ mod/PLibUI/PToggle.cs | 174 +++ mod/PLibUI/PUIDelegates.cs | 89 ++ mod/PLibUI/PUIElements.cs | 357 ++++++ mod/PLibUI/PUITuning.cs | 432 +++++++ mod/PLibUI/PUIUtils.cs | 801 +++++++++++++ mod/PLibUI/TextAnchorUtils.cs | 92 ++ mod/PLibUI/UIDetours.cs | 97 ++ mod/Patches/BuildMenu.cs | 19 + mod/Patches/BuildMenuCategoriesScreen.cs | 45 + mod/Patches/Deconstructable.cs | 74 ++ mod/Patches/DragTool.cs | 26 + mod/Patches/MaterialSelectionPanel.cs | 45 + mod/Patches/PlanScreen.cs | 35 + mod/Patches/PriorityScreen.cs | 69 ++ mod/Patches/Screen.cs | 48 + mod/Strings.cs | 23 + mod/Utils.cs | 66 ++ mod/build | 15 + mod/meta/__PreviewImage.png | Bin 0 -> 4269 bytes mod/meta/__TemplateIcon.png | Bin 0 -> 4269 bytes mod/meta/mod.yaml | 3 + mod/meta/mod_info.yaml | 4 + mod/oni-prio.csproj | 66 ++ omnisharp.json | 23 + oni-prio.sln | 34 + 176 files changed, 25715 insertions(+) create mode 100644 .gitignore create mode 100644 mod/.gitignore create mode 100644 mod/Mod.cs create mode 100644 mod/Options.cs create mode 100644 mod/PLib/PLib.csproj create mode 100644 mod/PLib/PLib.xcf create mode 100644 mod/PLib/README.md create mode 100644 mod/PLib/icon.png create mode 100644 mod/PLibAVC/IModVersionChecker.cs create mode 100644 mod/PLibAVC/JsonURLVersionChecker.cs create mode 100644 mod/PLibAVC/ModVersionCheckResults.cs create mode 100644 mod/PLibAVC/PLibAVC.csproj create mode 100644 mod/PLibAVC/PVersionCheck.cs create mode 100644 mod/PLibAVC/SteamVersionChecker.cs create mode 100644 mod/PLibAVC/VersionCheckTask.cs create mode 100644 mod/PLibAVC/YamlURLVersionChecker.cs create mode 100644 mod/PLibActions/PAction.cs create mode 100644 mod/PLibActions/PActionManager.cs create mode 100644 mod/PLibActions/PKeyBinding.cs create mode 100644 mod/PLibActions/PLibActions.csproj create mode 100644 mod/PLibActions/PToolMode.cs create mode 100644 mod/PLibBuildings/BuildIngredient.cs create mode 100644 mod/PLibBuildings/ColoredRangeVisualizer.cs create mode 100644 mod/PLibBuildings/ConduitConnection.cs create mode 100644 mod/PLibBuildings/PBuilding.Utils.cs create mode 100644 mod/PLibBuildings/PBuilding.cs create mode 100644 mod/PLibBuildings/PBuildingManager.cs create mode 100644 mod/PLibBuildings/PLibBuildings.csproj create mode 100644 mod/PLibBuildings/PowerRequirement.cs create mode 100644 mod/PLibCore/Detours/DetourException.cs create mode 100644 mod/PLibCore/Detours/DetouredField.cs create mode 100644 mod/PLibCore/Detours/DetouredMethod.cs create mode 100644 mod/PLibCore/Detours/IDetouredField.cs create mode 100644 mod/PLibCore/Detours/LazyDetouredField.cs create mode 100644 mod/PLibCore/Detours/PDetours.cs create mode 100644 mod/PLibCore/ExtensionMethods.cs create mode 100644 mod/PLibCore/IPLibRegistry.cs create mode 100644 mod/PLibCore/IRefreshUserMenu.cs create mode 100644 mod/PLibCore/PForwardedComponent.cs create mode 100644 mod/PLibCore/PGameUtils.cs create mode 100644 mod/PLibCore/PLibCore.csproj create mode 100644 mod/PLibCore/PLibCorePatches.cs create mode 100644 mod/PLibCore/PLibLocalization.cs create mode 100644 mod/PLibCore/PLibStrings.cs create mode 100644 mod/PLibCore/PPatchTools.cs create mode 100644 mod/PLibCore/PRegistry.cs create mode 100644 mod/PLibCore/PRegistryComponent.cs create mode 100644 mod/PLibCore/PStateMachines.cs create mode 100644 mod/PLibCore/PTranspilerTools.cs create mode 100644 mod/PLibCore/PUtil.cs create mode 100644 mod/PLibCore/PVersion.cs create mode 100644 mod/PLibCore/PVersionList.cs create mode 100644 mod/PLibCore/PatchManager/IPLibAnnotation.cs create mode 100644 mod/PLibCore/PatchManager/IPatchMethodInstance.cs create mode 100644 mod/PLibCore/PatchManager/PLibMethodAttribute.cs create mode 100644 mod/PLibCore/PatchManager/PLibPatchAttribute.cs create mode 100644 mod/PLibCore/PatchManager/PPatchManager.cs create mode 100644 mod/PLibCore/PatchManager/RunAt.cs create mode 100644 mod/PLibCore/PriorityQueue.cs create mode 100644 mod/PLibCore/Remote/PRemoteComponent.cs create mode 100644 mod/PLibCore/Remote/PRemoteRegistry.cs create mode 100644 mod/PLibCore/TextMeshProPatcher.cs create mode 100644 mod/PLibCore/translations/fr.po create mode 100644 mod/PLibCore/translations/ru.po create mode 100644 mod/PLibCore/translations/template.pot create mode 100644 mod/PLibCore/translations/zh.po create mode 100644 mod/PLibDatabase/PCodexManager.cs create mode 100644 mod/PLibDatabase/PColonyAchievement.cs create mode 100644 mod/PLibDatabase/PDatabaseUtils.cs create mode 100644 mod/PLibDatabase/PLibDatabase.csproj create mode 100644 mod/PLibDatabase/PLocalization.cs create mode 100644 mod/PLibLighting/ILightShape.cs create mode 100644 mod/PLibLighting/LightingArgs.cs create mode 100644 mod/PLibLighting/LightingPatches.cs create mode 100644 mod/PLibLighting/OctantBuilder.cs create mode 100644 mod/PLibLighting/PLibLighting.csproj create mode 100644 mod/PLibLighting/PLightManager.cs create mode 100644 mod/PLibLighting/PLightShape.cs create mode 100644 mod/PLibLighting/PRemoteLightWrapper.cs create mode 100644 mod/PLibOptions/ButtonOptionsEntry.cs create mode 100644 mod/PLibOptions/CategoryExpandHandler.cs create mode 100644 mod/PLibOptions/CheckboxOptionsEntry.cs create mode 100644 mod/PLibOptions/Color32OptionsEntry.cs create mode 100644 mod/PLibOptions/ColorBaseOptionsEntry.cs create mode 100644 mod/PLibOptions/ColorGradient.cs create mode 100644 mod/PLibOptions/ColorOptionsEntry.cs create mode 100644 mod/PLibOptions/CompositeOptionsEntry.cs create mode 100644 mod/PLibOptions/ConfigFileAttribute.cs create mode 100644 mod/PLibOptions/DynamicOptionAttribute.cs create mode 100644 mod/PLibOptions/FloatOptionsEntry.cs create mode 100644 mod/PLibOptions/IOptionSpec.cs create mode 100644 mod/PLibOptions/IOptions.cs create mode 100644 mod/PLibOptions/IOptionsEntry.cs create mode 100644 mod/PLibOptions/IntOptionsEntry.cs create mode 100644 mod/PLibOptions/LimitAttribute.cs create mode 100644 mod/PLibOptions/LogFloatOptionsEntry.cs create mode 100644 mod/PLibOptions/ModDialogInfo.cs create mode 100644 mod/PLibOptions/ModInfoAttribute.cs create mode 100644 mod/PLibOptions/NullableFloatOptionsEntry.cs create mode 100644 mod/PLibOptions/NullableIntOptionsEntry.cs create mode 100644 mod/PLibOptions/OptionAttribute.cs create mode 100644 mod/PLibOptions/OptionsDialog.cs create mode 100644 mod/PLibOptions/OptionsEntry.cs create mode 100644 mod/PLibOptions/OptionsHandlers.cs create mode 100644 mod/PLibOptions/PLibOptions.csproj create mode 100644 mod/PLibOptions/POptions.cs create mode 100644 mod/PLibOptions/RequireDLCAttribute.cs create mode 100644 mod/PLibOptions/RestartRequiredAttribute.cs create mode 100644 mod/PLibOptions/SelectOneOptionsEntry.cs create mode 100644 mod/PLibOptions/SingletonOptions.cs create mode 100644 mod/PLibOptions/SlidingBaseOptionsEntry.cs create mode 100644 mod/PLibOptions/StringOptionsEntry.cs create mode 100644 mod/PLibOptions/TextBlockOptionsEntry.cs create mode 100644 mod/PLibUI/IDynamicSizable.cs create mode 100644 mod/PLibUI/ISettableFlexSize.cs create mode 100644 mod/PLibUI/ITooltipListableOption.cs create mode 100644 mod/PLibUI/IUIComponent.cs create mode 100644 mod/PLibUI/ImageTransform.cs create mode 100644 mod/PLibUI/LayoutSizes.cs create mode 100644 mod/PLibUI/Layouts/AbstractLayoutGroup.cs create mode 100644 mod/PLibUI/Layouts/BoxLayoutGroup.cs create mode 100644 mod/PLibUI/Layouts/BoxLayoutParams.cs create mode 100644 mod/PLibUI/Layouts/BoxLayoutResults.cs create mode 100644 mod/PLibUI/Layouts/CardLayoutGroup.cs create mode 100644 mod/PLibUI/Layouts/CardLayoutResults.cs create mode 100644 mod/PLibUI/Layouts/GridComponent.cs create mode 100644 mod/PLibUI/Layouts/GridComponentSpec.cs create mode 100644 mod/PLibUI/Layouts/GridLayoutResults.cs create mode 100644 mod/PLibUI/Layouts/PGridLayoutGroup.cs create mode 100644 mod/PLibUI/Layouts/RelativeLayoutGroup.cs create mode 100644 mod/PLibUI/Layouts/RelativeLayoutParams.cs create mode 100644 mod/PLibUI/Layouts/RelativeLayoutResults.cs create mode 100644 mod/PLibUI/Layouts/RelativeLayoutUtil.cs create mode 100644 mod/PLibUI/PButton.cs create mode 100644 mod/PLibUI/PCheckBox.cs create mode 100644 mod/PLibUI/PComboBox.cs create mode 100644 mod/PLibUI/PComboBoxComponent.cs create mode 100644 mod/PLibUI/PContainer.cs create mode 100644 mod/PLibUI/PDialog.cs create mode 100644 mod/PLibUI/PGridPanel.cs create mode 100644 mod/PLibUI/PLabel.cs create mode 100644 mod/PLibUI/PLibUI.csproj create mode 100644 mod/PLibUI/PPanel.cs create mode 100644 mod/PLibUI/PRelativePanel.cs create mode 100644 mod/PLibUI/PScrollPane.cs create mode 100644 mod/PLibUI/PSlider.cs create mode 100644 mod/PLibUI/PSpacer.cs create mode 100644 mod/PLibUI/PTextArea.cs create mode 100644 mod/PLibUI/PTextComponent.cs create mode 100644 mod/PLibUI/PTextField.cs create mode 100644 mod/PLibUI/PTextFieldEvents.cs create mode 100644 mod/PLibUI/PToggle.cs create mode 100644 mod/PLibUI/PUIDelegates.cs create mode 100644 mod/PLibUI/PUIElements.cs create mode 100644 mod/PLibUI/PUITuning.cs create mode 100644 mod/PLibUI/PUIUtils.cs create mode 100644 mod/PLibUI/TextAnchorUtils.cs create mode 100644 mod/PLibUI/UIDetours.cs create mode 100644 mod/Patches/BuildMenu.cs create mode 100644 mod/Patches/BuildMenuCategoriesScreen.cs create mode 100644 mod/Patches/Deconstructable.cs create mode 100644 mod/Patches/DragTool.cs create mode 100644 mod/Patches/MaterialSelectionPanel.cs create mode 100644 mod/Patches/PlanScreen.cs create mode 100644 mod/Patches/PriorityScreen.cs create mode 100644 mod/Patches/Screen.cs create mode 100644 mod/Strings.cs create mode 100644 mod/Utils.cs create mode 100755 mod/build create mode 100644 mod/meta/__PreviewImage.png create mode 100644 mod/meta/__TemplateIcon.png create mode 100644 mod/meta/mod.yaml create mode 100644 mod/meta/mod_info.yaml create mode 100644 mod/oni-prio.csproj create mode 100644 omnisharp.json create mode 100644 oni-prio.sln diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a867d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +reference*/ +TemplateMod/ +OxygenNotIncluded* +Libs +Logs +Backups/ +.vscode/ +IlSpy +.DS_Store diff --git a/mod/.gitignore b/mod/.gitignore new file mode 100644 index 0000000..8026b10 --- /dev/null +++ b/mod/.gitignore @@ -0,0 +1,3 @@ +obj/ +bin/ +dist/ diff --git a/mod/Mod.cs b/mod/Mod.cs new file mode 100644 index 0000000..37bdc67 --- /dev/null +++ b/mod/Mod.cs @@ -0,0 +1,63 @@ +using HarmonyLib; +using PeterHan.PLib.Core; +using PeterHan.PLib.Database; +using PeterHan.PLib.Options; + +namespace PriorityUX { + public class Mod : KMod.UserMod2 { + // public static readonly Type[] ToolTypes = { + // typeof(AttackTool), + // typeof(CaptureTool), + // typeof(ClearTool), + // typeof(DeconstructTool), + // typeof(DigTool), + // typeof(EmptyPipeTool), + // typeof(HarvestTool), + // typeof(MopTool), + // typeof(PrioritizeTool), + // }; + + + public override void OnLoad(Harmony harmony) { + Debug.Log("UX Mod Load"); + Harmony.DEBUG = true; + + base.OnLoad(harmony); + + Debug.Log("UX Mod PostLoad base"); + + PUtil.InitLibrary(); + LocString.CreateLocStringKeys(typeof(Strings.PRIORITYUX)); + new PLocalization().Register(); + new POptions().RegisterOptions(this, typeof(Options)); + + // MethodInfo patchMethod = typeof(GenericToolPatches).GetMethod(nameof(GenericToolPatches.OnActivateTranspiler)); + // foreach (var toolType in ToolTypes) { + // MethodInfo method = toolType.GetMethod("OnActivateTool", BindingFlags.NonPublic); + // harmony.Patch(method, postfix: new HarmonyMethod(patchMethod)); + // } + } + } + + // static class DefaultPriorityFixes { + // [HarmonyPatch(typeof(Capturable), nameof(Capturable.MarkForCapture))] + // [HarmonyPrefix] + // static bool Capturable_MarkForCapture(Capturable __instance, bool mark) { + // Debug.Log("[PriorityUX] >> Capturable_MarkForCapture"); + // PrioritySetting priority = new PrioritySetting(PriorityScreen.PriorityClass.basic, 5); + // __instance.MarkForCapture(mark, priority); + // return true; + // } + + // static readonly FieldInfo masterPrioritySetting = typeof(Prioritizable).GetField("masterPrioritySetting", BindingFlags.NonPublic); + + // [HarmonyPatch(typeof(Prioritizable), "OnSpawn")] + // [HarmonyPrefix] + // static void OnSpawn(object __instance) { + // if (Options.Instance.buildingsInheritPriority) { + // Debug.Log("[PriorityUX] >> Set Prioritizable.OnSpawn"); + // masterPrioritySetting.SetValue(__instance, Utils.getLastPriority()); + // } + // } + // } +} diff --git a/mod/Options.cs b/mod/Options.cs new file mode 100644 index 0000000..3d4f212 --- /dev/null +++ b/mod/Options.cs @@ -0,0 +1,28 @@ +using Newtonsoft.Json; +using PeterHan.PLib.Options; + +namespace PriorityUX { + [ModInfo("tdrz.nl")] + class Options : SingletonOptions { + [Option] + [JsonProperty] + public bool resetPriority { get; set; } = false; + + [Option] + [Limit(1.0, 9.0)] + [JsonProperty] + public int resetPriorityLevel { get; set; } = 3; + + [Option] + [JsonProperty] + public bool enableKeysForAllTools { get; set; } = true; + + [Option] + [JsonProperty] + public bool enableKeysForBuilding { get; set; } = true; + + [Option] + [JsonProperty] + public bool deconstructInheritPriority { get; set; } = true; + } +} diff --git a/mod/PLib/PLib.csproj b/mod/PLib/PLib.csproj new file mode 100644 index 0000000..893ef5c --- /dev/null +++ b/mod/PLib/PLib.csproj @@ -0,0 +1,49 @@ + + + + PeterHan.PLib + PLib + 4.11.0.0 + + Peter Han (https://github.com/peterhaneve) + https://github.com/peterhaneve/ONIMods + MIT + icon.png + Game, OxygenNotIncluded, Oxygen, PLib, Library + true + Copyright 2022 Peter Han + PLib - Peter Han's library used for creating mods for Oxygen Not Included, a simulation game by Klei Entertainment. + +Contains methods aimed at improving cross-mod compatibility, in-game user interfaces, and game-wide functions such as Actions and Lighting. An easy-to-use Mod Options menu is also included. + +Update PLib Lighting for compatibility with Hot Shots. + +Significantly improve the PLib Options DynamicOption attribute. Custom option handlers can now +have constructors with similar parameters to those used in the provided option handlers. The +resulting option can also have a title and tooltip assigned with a parallel OptionAttribute. +Provide a LogFloatOptionsEntry to allow logarithmic float input, which can now be instantiated +with the new dynamic option improvements using DynamicOption. + + PeterHan.PLib + 4.11.0.0 + false + false + false + false + Vanilla;Mergedown + + + bin\$(Platform)\Release\PLib.xml + + + + + + + + + + + + + diff --git a/mod/PLib/PLib.xcf b/mod/PLib/PLib.xcf new file mode 100644 index 0000000000000000000000000000000000000000..20c236589d1f9597bd3bdc64ffc94489bea63c90 GIT binary patch literal 117291 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 GiWva+I49u% literal 0 HcmV?d00001 diff --git a/mod/PLib/README.md b/mod/PLib/README.md new file mode 100644 index 0000000..0daeb69 --- /dev/null +++ b/mod/PLib/README.md @@ -0,0 +1,239 @@ +# PLib + +PLib is Peter's library for making mods. All mods in this repository depend on PLib via [ILMerge](https://github.com/dotnet/ILMerge). + +PLib 4.0 is now modular, allowing mods to only include and merge the components that they use, along with the much reduced PLib Core library. +PLib 4.0 is not backwards compatible with PLib 2.0 and 3.0, but both can be run side by side (if that was possible). +PLib 4.0 and up supports [Harmony 2.0 for the new game versions merging the vanilla game and Spaced Out! DLC](https://forums.kleientertainment.com/forums/topic/130712-oni-is-upgrading-to-harmony-20/), but PLib 3.0 and lower do not. + +## Obtaining + +### Source + +PLib can be checked out and compiled from source (tested on Visual Studio Community 2019). +If Oxygen Not Included is installed in a different location, make a copy of `Directory.Build.Props.default` named `Directory.Build.Props.user` and update the game folder paths appropriately. + +### Binary + +DLL releases for major versions are available in the [releases](https://github.com/peterhaneve/ONIMods/releases) page. +**Only the complete library is available from NuGet, which is sufficient for most users.** +The lightweight PLib Options library (which includes only the options portion of PLib, and does not perform any forwarding) is also available in the releases section. + +### NuGet + +PLib is available as a [NuGet package](https://www.nuget.org/packages/PLib/). + +## Usage + +PLib must be included with mods that depend on it. +The best method to do this is to use ILMerge or ILRepack and add the PLib project or DLL as a reference in the mod project. +ILMerge is available as a [NuGet package](https://www.nuget.org/packages/ilmerge) and is best used as a post-build command. +Suggested command: +```powershell +"$(ILMergeConsolePath)" /ndebug /out:$(TargetName)Merged.dll $(TargetName).dll PLib.dll /targetplatform:v4,C:\Windows\Microsoft.NET\Framework64\v4.0.30319 +``` + +This helps ensure that each mod uses the version of PLib that it was built against, reducing the risk of breakage due to PLib changes. +Note that if using ILMerge, all dependencies of your mod *and PLib* must be added as references to the project. +Avoid merging Unity assemblies with the compiled DLL, and turn off *Copy Local* on all other library DLLs such as `0Harmony` and `Assembly-CSharp`. +PLib can also be packaged separately as a DLL with an individual mod, but on Mac and Linux this may lead to version issues. + +Some parts of PLib need to be patched only once, or rely on having the latest version. +To handle this problem, PLib uses *forwarded components*, which only loads the components and patches that are in use, using the latest version of each component available across all installed mods. +Only one instance of each registered forwarded component is instantiated, even if multiple mods have the same version, although which one is used in case of a tie is unspecified. +Custom forwarded components in mod code can be implemented by subclassing `PeterHan.PLib.Core.PForwardedComponent`. + +### Initialization + +Initialize PLib by calling `PUtil.InitLibrary(bool)` in `OnLoad`. +PLib *must* be initialized before using most of PLib functionality, but instantiating most PLib components will now also initialize PLib if necessary. + +It will emit the mod's `AssemblyFileVersion` to the log if the `bool` parameter is true, which can aids with debugging. +Using the `AssemblyVersion` instead is discouraged, because changing `AssemblyVersion` breaks any explicit references to the assembly by name. +(Ever wonder why .NET 3.5 still uses the .NET 2.0 version string?) + +## Core + +The `PLib.Core` component is required by all other PLib components. +It contains only a minimal patch manager and general utilities that do not require any patches at runtime, such as Detours, reflection utilities, and basic game helpers. + +## User Interface + +The `PLib.UI` component is used to create custom user interfaces, in the same style as the base game. +It requires `PLib.Core`. +For more details on the classes in this component, see the XML documentation. + +### Side Screens + +Add a side screen class in a postfix patch on `DetailsScreen.OnPrefabInit` using `PUIUtils.AddSideScreenContent()`. +The type parameter should be a custom class which extends `SideScreenContent`. +The optional argument can be used to set an existing UI `GameObject` as the UI to be displayed, either created using PLib UI or custom creation. + +If the argument is `null`, create the UI in the `OnPrefabInit` of the side screen content class, and use `AddTo(gameObject, 0)` on the root PPanel to add it to the side screen content. +A reference to the UI `GameObject` created by `AddTo` should also be stored in the `ContentContainer` property of the side screen content class. +Note that `SetTarget` is called on the very first object selected *before* `OnPrefabInit` runs for the first time. +Make sure that this case is handled in code, and that `OnPrefabInit` refreshes the UI after it is built to match the current target object. + +## Options + +The `PLib.Options` component provides utilities for reading and writing config files, as well as editing configs in-game via the mod menu. +It requires `PLib.UI` and `PLib.Core`. + +#### Reading/writing config files + +To read, use `PLib.Options.POptions.ReadSettings()` where T is the type that the config file will be deserialized to. +In PLib 4.0, the type will be associated with the assembly that defines that type, not the calling assembly like PLib 2.0 and 3.0. +By default, PLib will place the config file in the mod assembly directory, named `config.json`, and will give each archived version its own configuration. + +The `ConfigFile` attribute can be used to modify the name of the configuration file and enable auto-indenting to improve human readability. +If the Use + +To write, use `PLib.Options.POptions.WriteSettings(T settings)`, where again T is the settings type. + +#### Registering for the config screen + +PLib.Options adds configuration menus to the Mods screen for mods that are registered. +Register a mod by using `POptions.RegisterOptions(UserMod2, Type settingsType)` in `OnLoad`. +Creating a new `POptions` instance is required to use this method, but only one `POptions` instance should be created per mod. + +The argument should be the type of the class the mod uses for its options, and must be JSON serializable. +`Newtonsoft.Json` is bundled with the game and can be referenced. + +The class used for mod options can also contain a `ModInfo([string url=""], [string image=""])` annotation to display additional mod information. +**Note that the title from PLib 2.0 and 3.0 is no longer part of this attribute**, as this functionality has been moved to the Klei `mod.yaml` file. +The URL can be used to specify a custom website for the mod's home page; if left empty, it defaults to the Steam Workshop page for the mod. +The image, if specified, will attempt to load a preview image (best size is 192x192) with that name from the mod's data folder and display it in the settings dialog. + +Each option must be a property, not a member, and should be annotated with `Option(string displaytext, [string tooltip=""])` to be visible in the mod config menu. +Currently supported types are: `int`, `int?`, `float`, `float?`, `string`, `bool`, `Color`, `Color32`, and `Enum`. +If a property is a read-only `System.Action`, a button will be created that will execute the returned action if clicked. +If a property is of type `LocText`, no matter what it returns, the text in `displaytext` will be displayed as a full-width label with no input field. +If a property is of a user-defined type, PLib will check the public properties of that type -- if any of them have `Option` attributes, the property will be rendered as its own category with each of the inner options grouped inside. +If a valid localization string key name is used for `displaytext` (such as `STRINGS.YOURMOD.OPTIONS.YOUROPTION`), the localized value of that string from the strings database is used as the display text. + +To support types not in the predefined list, the `[DynamicOption(Type)]` attribute can be added to specify the type of an `IOptionsEntry` handler class that can display the specified type. + +#### Categories + +The optional third parameter of `Option` allows setting a custom category for the option to group related options together. +The category name is displayed as the title for the section. +If a valid localization string key name is used for the category (such as `STRINGS.YOURMOD.OPTIONS.YOURCATEGORY`), the localized value of that string from the strings database is used as the title. +All options inside a nested custom options class are placed under a category matching the title of the declaring `Option` property. + +#### Range limits + +`int`, `int?`, `float` and `float?` options can have validation in the form of a range limit. +Annotate the property with `PLib.Options.Limit(double min, double max)`. +If `PLib.Options.Limit` is used on a `string` field, the `max` will be used as the maximum string length for the option value. +Note that users can still enter values outside of the range manually in the configuration file. + +#### Example + +```cs +using Newtonsoft.Json; +using PeterHan.PLib.Options; + +// ... + +[JsonObject(MemberSerialization.OptIn)] +[ModInfo("https://www.github.com/peterhaneve/ONIMods")] +public class TestModSettings +{ + [Option("Wattage", "How many watts you can use before exploding.")] + [Limit(1, 50000)] + [JsonProperty] + public float Watts { get; set; } + + public TestModSettings() + { + Watts = 10000f; // defaults to 10000, e.g. if the config doesn't exist + } +} +``` + +```cs +using PeterHan.PLib.Core; +using PeterHan.PLib.Options; + +// ... + +public sealed class ModLoad : KMod.UserMod2 +{ + public static void OnLoad() + { + PUtil.InitLibrary(false); + new POptions().RegisterOptions(typeof(TestModSettings)); + } +} +``` + +This is how it looks in the mod menu: + +![mod menu example screenshot](https://i.imgur.com/1S1i9ru.png) + +## Actions + +The `PLib.Actions` component is used to register actions to be executed on user input. +It requires `PLib.Core`. +Actions created by this component can be rebound in the game options. + +Register actions by using `PActionManager.CreateAction(string, LocString, PKeyBinding)` in `OnLoad`. +Creating a new `PActionManager` instance is required to use this method, but only one `PActionManager` instance should be created per mod. + +The identifier should be unique to the action used and should include the mod name to avoid conflicts with other mods. +If multiple mods register the same action identifier, only the first will receive a valid `PAction`. +The returned `PAction` object has a `GetKAction` method which can be used to retrieve an `Action` that works in standard Klei functions. +The `PKeyBinding` is used to specify the default key binding. + +Note that the game can change the values in the `Action` enum. Instead of using the built-in `Action.NumActions` to denote "no action", consider using `PAction.MaxAction` instead which will use the correct value at runtime if necessary. + +## Lighting + +The `PLib.Lighting` component allows `Light2D` objects to emit light in a custom shape and intensity falloff. +It requires `PLib.Core`. + +Register lighting types by using `PLightManager.Register(string, CastLight)` in `OnLoad`. +Creating a new `PLightManager` instance is required to use this method, but only one `PLightManager` instance should be created per mod. + +The identifier should be unique to the light pattern that will be registered. +If multiple mods register the same lighting type identifier, all will receive a valid `ILightShape` object but only the first mod to register that shape will be used to render it. +The returned `ILightShape` object has a `GetKLightShape` method which can be used to retrieve a `LightShape` that works in standard `Light2D` functions. +The `CastLight` specifies a callback in the mod that can handle drawing the light. It needs the signature +```c +void CastLight(LightingArgs args); +``` +The mod will receive an object encapsulating the lighting arguments, including the source of the light with the `Light2D` component and the starting cell. +See the `LightingArgs` class for more details. + +## Buildings + +The `PLib.Buildings` component abstracts several details of adding new buildings. +It requires `PLib.Core`. + +Register a new building by using `PBuildingManager.Register(PBuilding)` in `OnLoad`. +Creating a new `PBuildingManager` instance is required to use this method, but only one `PBuildingManager` instance should be created per mod. + +The `PBuilding` instance should be created only once, in `OnLoad`. +The building name, description, and effect can all be specified directly, or left empty to use the default localization string keys for each string. +The tech tree location and build menu location can also be specified without needing other patches. + +## Database + +The `PLib.Database` component deals with non-building operations involving the game database, translation, and codex files. +It requires `PLib.Core`. + +### Translations + +Register a mod for translation by using `PLocalization.Register()` in `OnLoad`. +Creating a new `PLocalization` instance is required to use this method, but only one `PLocalization` instance should be created per mod. + +All classes in the mod assembly with `public static` `LocString` fields will be eligible for translation. +Translation files need to be placed in the `translations` folder in the mod directory, named as the target language code (*zh-CN* for example) and ending with the `.po` extension. +Note that the translation only occurs after all mods load, so avoid referencing the `LocString` fields during class initialization or `OnLoad` as they may not yet be localized at that time. + +### Codex Entries + +Register a mod for codex loading by using `PCodexManager.RegisterCreatures()` and/or `PCodexManager.RegisterPlants()`. +Creating a new `PCodexManager` instance is required to use this method, but only one `PCodexManager` instance should be created per mod. + +The codex files will be loaded using the same structure as the base game: a `codex` folder must exist in the mod directory, with `Creatures` and `Plants` subfolders containing the codex data. diff --git a/mod/PLib/icon.png b/mod/PLib/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c7ab4f82905b672ddc9a4f072514f07a6837e347 GIT binary patch literal 86763 zcmXt*kG3_1j)OS(Zynq5E;MOZ?lS)?VUn??N4CEcNPiFEV$ zea`vK`^?Na^XHs<&fIz5*NxH9QYI!~AOHXW#HuP!b^qlZ|?I@7p2s$jB?IJMfoS{&;AA^Spc zH}%vX-L(g@lKl;|eXjvTAOWZdCII5CJ|OBvk_aN+xRO?pG%dG~dP|Z&ml>IK63dC= z_ORBV7gEj?-i;inn9#4e;p zmZn*I+Q>Ph(senEomYGc4stoQ1F4RYITDZ@*jA%UJwQXXge6NerO_2g?X2fd5PHH! zkRLlT&D3jO&595z$S-z+y}I=F|Di>z_+%huZ3bE-5o^`qWzvYG%M>Rh)H(npncv>4IH)c*VFbUC8dmpedjQ|Y9<2>iuCNh51h@IQgbi-gyz_#JJNqigUmF;)mpx=1$*Ty%**$F z6PZ5Ce8Uz`^C;zw#M+`c*03E;<}0oIt=JOb@4D58;i!^n>F^$bl~ygzTmg#rd`PL_ z@4t^uXQSA?J}PYSt-=M0Xwc@f9Hb$-A(^;a=qM0O7D;fLBf zd7zs|wTlcED0k{&C9=FFUnQZZ2?wqUBX!YgQ`EC~=})v*;9vhnk-!-AvNo_0>yc)F z>}~O3u!oL;0-&P$^V2&<0W(1Bvk&(dvW%tyfXu16D~L};=#bKA(wru7YRWL9J%{9* z8~)p6ltlmLIqh2<^JcvsZEbvLFC79Ay>2+lp8QQ5w0RjPf~=PJR6@Tr8G@i%X0FIb zi7xPLA9gMPS&pA}qx`G|=#i-b#h+2rK5f~6l1JJFtc}CXJhSpur1R5eE}fqQC5PI` z%@Pji&mjMf-1c>+P0|%2&y#uM1TV|amFh? zul~S_4(6aBZIsf+JNy)>E`^Dk>pcOrzv#(?n7sZNib%41e*i43D{vsAfe4t(50Xx|>|Xwk5+O=-qI8MEluF&}2<#4M{Mo z|MC$Ll&eUA=Y-F368pRYglIFnEW1Mt)jg;~?P@$h(>X-MK+V}Yb{cREx-;4MPn+#w zVsQ7l86Y-JV44&OpD!}6!)aiO{9a;8mMZAU5qX;Yn_%33Qf?6a$#YOngg)uSD!$S5 zUTKBOikqe_%cy?!V``L(EiiF^I%({TIJ?g^iBcq-$;u`~gE@uaDWL9z5@nn* zlHQ5LC&}Qf2ywhmZ14gx(>N4l#UP`1N;jJDG7^x`+QotSC7j0Ed0@@T1{uuR2m$CR zR|2YHGy%bVrT{Pf6rgAF9^mUnt_4iQq{AM(C6}H+IY|6_{M*W#8;RRxMf;`LXK8;i zOFVAo&qe*G1IB~BakJ~OkGpUSWBx<%g^7(=U`cUD8Z+`4=Poyvtizk`L`JCu&)9Hs+L4vl8YsIhYt8!okc@S_*z0v zKEOvS=-P3!MCf(rk7i0*rFPh5Xs;p6UAE)fN;h6M{KVV=t0{JX#9%pfA?_c3>ue8E!`TE{bC~t@&iTIsuWf??dh}-^sL`{Dm z`4&EU_|7&g1W)vEMQqy0nFUD7 zD*K`X8tnB124v%0xFpX1Vj*g(sEo;FM!S{ah;iolL+rt;eDgucV!vsjD`ML)B$q2d zKo^1{NTkHgjc{UlG>t&58ju`7W$$hlw(auvb-hab?* znd=-z*S+p05y$nz2+ z@+)mE{;m({Az{NiFpmx^t)rX84e@`Tjig8X$1#xoVoE7u4$ri1M24-f1qLW=7~VNK zvA8r$su&dU0Yd%u@YgEaD?+xLW7k*Y-iTio7r9l;WX<9}zK)?)WBZ~q46WWb$00ok zUq!F=31-X!-)qY;4wZZM@{sPA|DJM7yft`|{na)ZB3!%-Sejx!GmNiz9nk}0etQ`E zj(0T=;j9)%@PZn_qa3G@N5OVdQzh|49krXjYd-CU(Jp%_46$wBvqr3Ju$ZD;1{0{Q zzW5{^0G0tBtsVmTp((PQFA?3>!PsioTxB2>>xq9&Kd%3>%V3`TA;RC>w(zMsLXlgThSWDA09K4yyRpon| zGF?9vUSBsq<$B#6)-W$ty!)lXZe=UMGA}+1(zI|{zwS@8s^m7CVt-}3tF}7 z>WK~^QG`aoA;e&MM?v2qSei}PdNa6I^Z_UdZP1*Mf_qK$2CcBSctAHAP(iQwS#c`k z6zw?bFy%ZCEwt`UepikZW!@&yU83EXg-p4Re5_?b0(ZqODUjW&C1oFmNz5KR$ws&5 z@}E3J^euP=0*edS1h*>b0ixB;Afup5I^=QR2O}wkHngq*y$k~qmI(9G3EB>*${t_T z0II2me8;7gR?b8yhWR{)YyjTWB_ETZ_z++-Y5gF;7F!}PwYz|J;zu-Ca*rK+p;tLh zQ5#|vW*Yl*6H$721ouq+YYTeIo!5i%Z5`IalE1ov>3^ZhMM&AQyhLgp zLep>r_1=UtN>otv4N>woPSYa685h9ZPrX9P?BN{lJ>JB$g=f(dG-*gRwycqn@EE4M ztU9dHMwA_$ZIb<-teA;zoe!GiXjUjcij>$QL*T^P0ZY?Tdr$&Q{x>A@$_o4!QjAA5 zu6RShQS-qSH=wVKJbM?fL)LJ^le==%Xfh8{90}jY`W#O5E*vc4FPUX82Wzs8E;Qge zAes3%)gr-0m&T%&nuyBEjtW7baJb%+u|lPt$Th+7R!pEoo&6Q<%E!NZ#})vt$|UP- zaGfoM9nO6-@PGlz)}`bfEr|?S)HcSOrJrM4d*hi1nglE3f^Duc7O+8EW!H)VNtyz{ zH@?*8ib=kaCBz!*RYVYjK763c1cx{MPqT3C>^S64o_uViUD$&u|9oMaA8W7 zre~{_Acb8oF8$OgD^aJxH_9dy8{gTq!y(n8*pjNU2o9XB9UDOMX+QdVzQ3a>@EU&N zU|!IpNGoNT3K1I0G(}5}?%OA)fv#d)6aK@#-BL5ebWKdRVILRjuE=4UV>dafwEQqY z$Sy}exXE%sB8{bYI|Dyb$9x{`I!$Q;&d;cf?jap)08NEhv0J)?t~hW%dB;Tcd%A8kYAhT*WfFmUA zfC|)W>Z9$2wI$G}j_&)!C}qY7smO$LG@(WT-&TNr3yLY(xD0CSzTb)ZnhdBqm>YNq zID2z6(GL?{kr@tOnqb?|79tMdumlG*tzDp4sy|j>D_>~DNBB)u1mf4m<3Ik05E4Cn zq(c@g72I)QqPTILmXQ6^v!iQ2>3k&IrHHqa`a)uW{?emz{x(|EuDn%}1c|S9$m1Ur z^d}%FqUg&%vHu{6zeUtDE_~?t9QsFU{}`7Glw!e#NTm~yB2SjZ^xh5RW)n8l&O z@!82x6s{3TvZ;v|dmi>DQmJ=tG*OWmsaKo0O_-)&M{*Pk*Zw`&mhFf6CLE%%LFJD* zKA4EU#np}1Nw_eT1KfiHZH_*EAN^uF?=ln>t{~>~vbTrKk-Cx9pID=?q=*=GcJ(O^ zrskBs0n+DnRvmrnB$$N)SPq!rx&&(h%ucY3Yb1;Hx*LYqpm-OK5jkoxgEYTT;shBs z{&0KOPQ%P(&irLgnSAoDBj(EUqHC2jI$~{2kDEl-i(8O}Nh^;s;Y=>bq z+h=kPCs*io@P`@j?FS`r&YnvMLJY{r?B}-Z5o5*Mcx!AjuaSB#G169YzHt{V5f-4?{u}Q<)Kin)9F(W>4$!$k zMaEN=g$3*w=I%v>R8Q3g`jA(G_8$$aBbmK)Ko^m9K7gA+tbn7)>?1+h&lxf}q!qDa z<5VHJr;!UAPF9M@irHTF7}b!*9siANqD8&mXFY02*QJVhyF3fe zWJ`3xcGZc@E6%5Regufjl-Om5*YJBv>p*%{L2oYZ;}k9;6cDLa;g{1=U(NMfiv4sF z6O$q7G;SufPA}+a9`8-At_rQ=s78|0508_~oBU+aI4<<7VUXuUNs&}1A3?t4DWc5! z;%L;uO1s{$-{g?0uQ#iSL6%gGZHcs#am{=q3o?ih{BUOtV=8^%OC)&;exdcuQ(VR- z%Ldyv7y|ZGb#ap3dQkq=2Gvj-$pZEgO3Ov9(rY7^YJ|((apY2+ELJKjFC7$g-p#H7 zFI7UZa23uWd>FS9lqKRv-;4IQ#!JR_4wF&%&p2;{av!Z_Wc?`(u2~JN+nua5jCdU1 zlm3_#nQe6XAMqKRAvlPACSvdDwYTSz4@rioXZl#&0g~3CrUaj8CI0>s&7F3>O)q51 z3oV1CEYTDneG~vD9epvq<7kq-Ew|b9mjmfFNYW59DAd`tO z0Z92>ATQc0GnYc=vtxg_e%@OW5QRqbIMW)(B{#5@*ZC8KB`H1unY@pg0BTRw))4Yy=eO#T zWCi1WutA&XL8u!7--JAMgr^#)sqw%8rou`*=Ar1aj=SJ69I5My|U$~ zd|_?{;Fk{_9Gw-ov5|(Cg*=2H5VD$-O@Vq`HE`dOy9O@>LSFp2~(0FtZTRT{k%iK+bF4`&4$h6m-B zQuXS#e67Rp)rzCtUg`JgECU=DGqg9c4ZMN4ZD1pfW(ms7}aOI zU1ppSb1$Iv$gCM8A5_{xq0@cVyaY@)U7n#u&rae01rhF>j6rMjKA9%sG^*>3+!QNS z3m1TKD&XBW2&KY*Mr6O&Pz9-xfgvx*4C^~ogmVn^!pqOeH~xfFchU4_nE3Bn1G0o= zU5?PVr`Ydgc>dxD?bO}E0A=Xk9fWKPw%N&Gayo4Rg2&via4-0Yt(L|$O{i?&4^GmJ z=9wgz;pQi7SbN2#2JBWo(Xw$qYaEis70Ek;o^5Fnj{dzU0R}6A;6T>gF?z{@o<~4B z=~s92?kvQCq8C3AGYbyh_d_8lcB&#gl?P6or)c?;-{yGRtYZ@S3QuoYeA#xSss7Ta z@>*kE8>;`L>;s#!*8@{_XTus;TSfhy4t434?EN!V-4G^uHP65U%ORnvRou^(ZdgYvYJm#Bx+U1E93cMxllkye`DYpM-gZ88%_5^73e3V#uX9pd9_e@ zbY2=MWVGC}ToWDMHgsFR`=m;yiDfpgGuu_h?{yW;fW-iz{kJlS4)D+{8@JV_Plxp; zdc}$DNd%Z|j=wdem@r&7{GlW3Mf*wAl@?g41mU3gptdyyazi!L?{_aIu&tf`kQEBEgDK)DB` zio`L*(Vvin+rv&(^7ZpGf5NG%r%{G4-q32`@Joy*16JbIM^NUBTSfLs4H%>jFuY8C)gSR*c>K0Ghi z4L$p`U>WR)UZPUYUYY=P=kVjUW0m*e^k2GaNmWdx-qE@$vp5Tnz7wv-uktB0gDd&J z^{o13S5N_$|HUS!s4!mdMO=%&tq&hh_$rCx8z+Vf39^%MD~Gy)1C>hikj1dq_{HB} zwc#;6r3W;}DKCJcUmU`N{+4cD_+PbU*SwPzdD^q zU{kSD2f<|e-2R%Q?o|UOEoG)w-Kp7}3KuO`9+$f)h3=q!1wgyW-^m@R54j@}AseU0 zB&)RF1tTpl3>6d=;I~XCB1E_TTkmpNyTS+G#vFS@7~6NJLJk{_&yRahato*JE}R#e zS36gEILf_}-~1$Z!*_qRq*hw)9ZDy{zDLutZrPdRU@LTbo?um<-=;x1ofgo97Td4{avn-pJyu`P)vh7g{U8(od}})L{OH<{Ype4>ePP2Fj`l zZpepCd^zB^1z%6;M;C5N3KW-$g#Q2p`UlvsUk0laQN~`;8^~I*m_Xc%W+#p<>W{wc zD1=<6&k88ohrrWtM4bZ@QO?QY-?!sb2NUnrR09TF=r@4lHNnIn%?9a1Dx>l&Q3<1| z>tDaww-8HfEL%pWTaOTF93dd#Xxd*t1PDwgUI4gNo}Rjms?a!dA(DQ_LYez@ zgK?n6A5V~~Y$S)jyWsxsezRfTXILR?;_jh+0<#Zgj7-ht_WH<}s3`E9307I3O0GJv z2@8KUtoJ3Glo>$-c>ZYoRHnVX0uTVtQqWIvHWAc=KMg9V+MG_osiN&3?}n?J0b);csm{MW4C zJC?(f-<#ZIa=WujsRU$M{DJnaZtsSullTt^I7mL+3vDCvSu~uIujqtLiyHboBO21f zOjZOSaZcKW;OVjoKRT7j$>B+8ZG!pWK0s!l&gDVX_*DWqj%!Nfn$Gc=OZF|LV}MKQ zKB#b>anTTMah9Q&1t^=ZaKTlICjIGL={)&!vH;BiM z8>}+*x8p^kZgM2{r=rB-IvXGVLgapQ@7@c|nvbZ=j6>xEt2)~s;dIX=C3TU6fnuRwSSJ>=L5D(r#tv*&cATJ%6s=%arw4##_RlD=6%U8HJ?opvHr{?`pc=cTreLQKpi%t7sQj z(%Wii24#N(=?ZX)YZ}Ax_pLiVo-3K5H=o(Gzuj`t?ufah__AL#`NdS}q^5}LOOeva zRuMes{B7!<_O$#T;%}unXR-3I*S^_eAEbz1Yv*NjcPrjhZGCv{#^&Uvc76@UCwpYq{FPKgB)~Eh7Gm`=aRfQvtFa5%qHD6wg5UPDxO@ha^gb&k_59Aj z8Gx=CGayB^s^u?1zx+KSI;p;G5)b6CP|&GLW&wbzf$gVYCL zuc5)VM?L(#zI-FCndfZLpLPx6=E<5QcTQcK*jz0dW!4PwENFEnvuOQ%79Tq*?{@sD z-x~X({y_ljyDQ=bC`_;y*~RUNL})w{Vog(PX0A=4`S}L@vCMizA@;!lzvII|#}}i= z>B@rPOQY(A;8f=wUo8~PSky2B(rIlFq^}@y4Tv7%1on0sd6Q*UY``}Gl7bm#TI`VF zZF~{HAVW=GK5Bd_Y$A;W-F!zm+^|yS*DcB7DV#-BB$^hOW9Mi(<_ZtMGhgu$wE7k17*XVl zpzb6nBDFz_b8OLLSqe_@5c%5-IA!r$fd4ZZl?Pb#!Y4umvt33gsczyzI+)|VzZ=m^ zzP;16g*mKord;z5E&1?~>TAz(-)GIks1|FCL@^D-gOHiQ4DUNhBrO3i+hJ@1_EJdJ5d4TAEu9J_rg=s-)k45!!JFS_|( zAD*r5qnTaTIE2iCY5=wEDK>~hsV-v}leGdZTG^QGWceAumjAW=Hl!WCkM*q;OZ5|w>sMvUjzikl68k)aSJFS`Q02XV82e9y(xVPLVq(sm!@D>5RLSv z5>$-Hcox1@;t(Ad)u6@kpevg7p#1f{B>BysORwt=pdQZHeg{Qw_k9SorN6uHm$mZ* z`z_1{K`dJ;{5gO}sk`{3Qp7Lf+|0ga(22}{;5_A?Q0U1JPdS7m@$hrqgL_@z-I~;v z6u)g0>bTcZ4xv+E|Mo5QdDr{e|Jptey%k}OkC%A~VY%^oaHbXJ@3(DH6kWRdVlo4K zkR842HqsW#4fLCup=&sO6fC-Si;n1j(qA5Vp!ymU%-Qb#D?nwA=3FxLA%{VH21^)Y zqTi3n*h*4vQ_3evB*=}_lPVwhO-qP+Naso;b59Jgt#Vq^-RgacFk|P5Xj>ge>V~lT z^Kd7wO%9pVojI*xbV**R7`_H@T>S}-Pt+v~Y^5vu`~!&AmLv#ClIC9nT}!*C^pIZ( zFM1kCIr{xhRQoYDzNW8%Wx}prk{H?I?rD1It83(Cv` zd_;F?{G10|u{}^U)q|5_C!km%W>1v2hAj`-m(mH)fQq+txR{3cuC(V>y2=5#m6t|AHQwv?Jz=AR{9zt6jJHTH6d1U@ZOac-W} z*=pD3@HHaHU8lM9VWV{O#3_Rj+mwHcWG^-MqhrN(EP4>KtYZ145*|sG_(`_!YXHN} zKM|DK5%S)NMNmFr;zlx!sDgA=5X5*77<2C{ZF`r~095NzmVg+@?sJ3|tE2j;y0nfm zDy=-`id;zF!Hd+EkUF1o=G?A*0gk^?$cD+ieh6yB#$QsVmS3ilrrRz}7g%+tIeL?c zj>V3dM(x?2J#!pEOs#Xce4V#S(5S@e`+Cq*TN4}Jl%w6-dI+n6b{!AIw%XP%Lg|OfR6(hw$|K)#nYH02+ z>P`UZ)YWRbTAJKJaOjj;Az81Gz0pJ5f8NYiN@!oE$^5dbR$4o$|19%2A8C;wrA7#6 zcLm@m3aV*WoK056q9TjBp&u}3fZYvfPkL!gs-JQ#OxXCue4j87Y5C@D>WBKEiRa28zoN;CN!M=4%? z%}T_z|F*(iYbT%$GWTC_@j7n7H!ve=C3xG+_ZO~YuMu2|8+@IfK8@qv`a>p(KYW5j zRMBQu+kf09oKw!(5${doa_Gq{Wy@Dnu~ng7VIvK=@VGW;-Cpt$#AIdKf(dkEZ;&hm zWYC$J86-eK%ivS~ZTv*os?zWl)h$7BzKq2l+q5+w5E~ncPj{SG0x=V-q=acyX{GJs z9ysgLPVjf=5SoP3i$FZwS2q8LMa3GNamW9k7+c?{<+eOWgz33gJ(- zx@dBfr!pw<$qiSK8;4vica@=GHO0)2XX4;#qb?TgV^+OW*0~|I@Cxtppk@Ke<{y3m zOAi_Rf8^bB7haME4^0OX6FEqB-Tw)#n?UOZsqMf0Z&|?P+m7D!BdWCxhNfNf1Mc)JPRsaxneTMH#MCvS=X3LD;K~!KeK01 zJtsd|QHioi`DGy`a+Z6ktN4eRB}Zf`N=EpZqXkoMtZ0l}784Ldd&6fk*nr-Dn9aKM zWB~rV0w^nJa?e5+dh$NNei$fI%syKU`wWoA(AlFN%Hc~2+SbSzt9~|O0h9Z1;l0C9 zuff?<6T~8x3|OmFl+#0Sz6Z{a9J#ZKp^C~|LWL1xNjZ(MGwFcnzX+js4oDsgKcLfv zFZ-5??q6WXk-jn@ifWM*>tbokni|Q93V@&xcN_p+lTbT|QCk5gvEp$5<7!~jquxLF zja`2P!cdgdDx_t=W#0D9JC(!My|k5CXc}~U#6I@z$wQ}Ik)`NbLA?(J_b~1Vy}|;w zh}g;P2g2gv=dneRn|v1!f_ZP(v6kcQGFPm39>4BMDPRwYwCEtUSg;2mM??qkgacsXhq7>_^523~I4{<|3I zi2%GaM{iCYVyYgS_b4r&@0u?WLmcUGDDALrgVRnGt3K)?}dNOXf4d$~I@1w3glyK$*MzDf05tj?_3^u#d-{55*tp=;`W zHZm*d5SIGQKPu?Cizjt5aB6eKEyMV64YB`}eQ@F|ldzyI-*79m{cOoD`;6=n*%oao zXd|)2jR$1uX;Op!q%6NFs*>#LfQN~xo_*J8=rtnLA@`V9#{fSxosw)1iR5Y&=ePiHh8seMn<@m6RYOq zI)H`)Kgq-tZ#qFD+khCYiY0H190I)0E={sl|ME!)g0_4l73TFq;D~9>WgCEfroo3g zP7hF{O8vyq?>)*OoDhvwFVP}!v};p+CKSVD~$hcnqAGRi? z6Q4du^Fy$Q4%DiNca5}m`fKg>V>kHxTo~c-FK;?bId(m4Bi!y9?-Ega;(VU(R`LMx zO?ROu#t!jHe{kTh8{(Cr>*R$P<{~86+L|d}R82l-jzL%=zsR95qO$q?{XJ2w>)6r9 z6u0{&D?ykgQ9B)kg1@sGUS>S?k8(3eom`y5n?kr0Bm*yvLmk*g69rl?Nq_Wv5qX&l z+lr2w_4mqF(HQB?f~#jO5F^075vjyBcXWGWUII<4=*EM#O;qFnS_s8)v-ulno2|WJGcpe**sxi==qW0VHkwF1JDfZvrfA3co?y zTB`tBZEtM0a^AX2G%s%Xf!C5V6ZJghSA&0DLi~U4&gFXSMX(5-J5B0Y+1&Amh=;;d zdB-}m9(#r@Bw3SYbV{%a4%IXYh&s*OoZ5eUHL;mT6Q549%{VSm=yCJsj=-1y#67p7 z>QQKD#oN9W!qbYg4T!dy{WERe!-}DWL}R+v-?BBbw4KXRhoYXq*k#-FCLE*)6M%Qr zyltx$F&US5LsX3-bGDZwDAF@{tfPhd#kd43`A$9yfjBYxf$pOl&SNX8=jJ-Xycb(I zX1Ot=r9R0PR2UKvI}f6bV2lp|(k(-&>FLCWLm>scnq(Lrm6y#b@a^|VMC8d{+aIxI z(wNP^FGza+#=g1P{46IdoETIVQYkgfH|<;Q+E7*w9VBeqOaYrjTfUZw$i%CBJr9O{ zS1_iXaXy|+L0CE`m*Z7uWJy!lBBn3N98vvRLf9-(^24t-%BbWCB|qxa8xso=WZ*X8 z6*#rWH_ILJRp)sRVBP6COegyYAdC4hzw=QX$K*N7m4@6Q4x2rB^|3z?wg|+CbIW#sMb$>NAUx_kf4L+7>yi~N_&i{gu z6)bT*Vp3tw)Rilzuxa?7wA}7V@%Zka_yL9#F+LZ%L4JvbsRbpg z=Q4eTeEx;kCPATqWxTMNYeN6407FT!&SatZv~5JsdOcseIt(MTHZf}-o(v52ectza zR77hgfX*i|pt}}2ktrl)bfn1%2zE(H{e;a?p*|MEbgwPkvqnRM2NOonzldJ3Z3(e_ zA=nBL%_HBUocIsSWJ9fYa3psxrS}4n3^rqrpc+H#yJLuKJu^fRDm0xfD=c@`3IBh1 zTF=I;bEPO|{XFfTh(Uj72o1UO;=-Hwc)*~7PJMIS%8~*96*^)nIHLK^GXLM5mICPxi3(@oW*x`Y)v<(v$IvYUV}3(q zP;b%(MQ@-RJ0ZHwMDTO+--oCYF3lK#w;~Icv{yDwa9we?I7ff;5#NCn?x?W%F&)aL~I3wq%C19YXl5! zwuY}Ct9m$7%+w!Po!j+l^}A-vQH;GWG*h!AZn)-DF3892hxFNb7)H#;bB1MC19bRPX1dPW{qD03Hl{p;=o(!sIJ1{Xj7~ z{Z5OgYIb{r2$JqkBWaG54&=%BjkwKX9c)9?47zQ@A5A_4OKLyl1UqaO#zzq8#@EBI zsz>ArkBBGhxDMMVti{N%92tKSoo?%lF1x-hE+nqRqYjlvo_M1*!^(C$j2vJ66I*P+ z>%s8H9;k-Yu-ZR-PhWl%pgraNF*PV6SqLlq3DhjK${$>P$l zyE1a-=WEjc4$#Hdc`45|Q89XjtaY54Roa~9 zPUjkN$nyqR%xJL&I!a?)uZQeg_p~gBx33;F3?Q}Vz6BDVNL>r>D_TtNN>i?}v2)`U z)>afyO7`R@*20VAECOI0=xTeQO6@)un-H*pTm%Vg6scvi;+v!Acu^=V7(S8U|0fz5 zlGa)t#dpX`QutVZs?B9dpq+`Nnx~3Rmii{t=RXj`m zcqBR6)91kh7L&Wp+C?b0dbj#^o}@q%Y4@%>NmiMD*hYoW_>f5DCrVtV3L+`#RbE;( z4|$NHqfWyO0HLFKwm@QkjWAH;8B=pQk)tf}(H-)0_ZPzt*SC#Be|e9VB2z!U*0aue`!4H+$I~(p{lINm#Nh7s zxq|@Q$@wguuZnS@xt=_dt2tm(zUmr6rBV)Nl~na5S-L}&H< zj;0Z}qisx~czai#orl-h%H~FXT%7O5ObUW`?TZv(Ey0MNcurNznqs1%40tex-Pwpr(rJ70gHH%dm-%8}YShuwq-r2bv%U zUL$HFR@nBMK}kf`DeoHr|C#RrYUqy~ALFo!6tl#w-2xWSzP0xubhXrMg=lx{)JqDV zy!>4-4%GgDyp~<&9ZbH6=p&l2_6uHB618nat5%@_d~xt|H96hzlO}Eh{eurnpKE@u z2dVbI`j)1?GOI_dLS+TnHeBs0bfbHBdE4Dis#Kz%BNpE z{~5domw$ES!t&%U%<=X-zw2zg|3K`3Cw70`=X1AxJd()@(f0duJf9S=ICe8p-#*}2<5H;;Sc_FmP4Q*V z110?Z6c073j1GxxAKGgSIzo(@MJxxL@zf3Z)J^@s&&%k(S^prwaIM8;3K$CfXff?G zcsL)y=_J~QCP~i^Q8e74TEtH;i@Rl3sdaJV$7uU}W`@WXPu)^GDv5kSNgu^-60T6Q z9OG?0sh*c1KT)LIqypIMnOOj%T9m&#pabeZ#6d&i@1!_5vi>8-oO|~^kv5f?>B8YV zWO@T!z5le358(ueiQ+9F)zg%IJLo3zVD>x#x~$T2psVRTBVn^i-Yo3ullZqfocp(! zK=K7CzX!|Va`rB}@+*Yg!$ z=Dzd$!~e?l=x1P7vjMw@glswjNc&#vfGfcn1D53 zpgIRQN@WzVf|hrT&cOVVDm1BIz0%weEk>mln4>e(U1&}8)T6^Xr~NF#tE!=q21Hu! zd@iq5uRXj4m2m`-djBxjta)2w45fZ0;DG+nY%mY#b9F$Fv8Ry$_6lh{pP4$lAU=g; zLg;aoWUVUOa<4MJ=K6>pUSVvcRQBwBbPuBZ-R zuET5$Mz?Gd_*YC;qJQ2DQ(@xo22O779qXg_Uj^Yj>cU#?8|JF+uC9_#4Rg&i{EFt~ zKv~W{89%v6!UbD2!?W1zbr%F553q@2&I4U}d&fECA5?&TEWWp7Hl(l?=oX7>0wpkm z&!6*L3_MLf)rM|bTI{;PI2UI(%ZC30``;Z?p^AKnH<5-SdN~m0!Ms2g$j_6iz)Gi;;AWc;kt~>hrM+55$Ud9s%eS3%8718f;wjA2h->vX{F zx7kBhb0qc;691I}SiZ9Y(Jf61W&_OzSLxw3!}w{lD^%x@EZ&vN1=HaSg7$zr&82f( z%fNZlrmX*2kn=x`{c|QsA!Vv_wn2816+@~=CYzO3TB1Fvie|tw6R`K%m$K+G%wFb9!n3sEn486uO5<|q)N&)?fW-G1-=Q+wEwUVhm z8?#KR4H9lln7uesIk>KXjOCO#MwWhi`vXpE;6{ENN@}`C8E9EyFoKV@CqZe-f1z|j zMy3^m0ixP*ULAhDP=AgpQXgz+mlq<{vC$oCagC&vHgL#;LZu+H5< zYU`1?7Nr%D$CA@;GX{UBl>&5C*?jfC>j4!!>XJj07nIk6m`f_RRaAy@Ok~9vD6Tn4 zI+mbkW()ISe`-9Pe+c$*sYev$y@})^TCxlg3+_M;mdNs3Up;wR(A#rcgwO?td>KYs zz0$})2E7XvuYY-Bf~P^y$~Z|QTNgYj%Z0cuu(ID0@f^O)K5B;zmkzfLdkPH=k=I;k zbN>wkfVNExwn0|Wp(=)mCmov(qr@lq4;}xt>sSC}7R|qWL?s0(T?0m9EThnp%}8mW z`Fd88tk0!sK2(5j`rzz>g}S*%DKUcxe0hdmu6N7J-??kGw6OnmuAf$B`XLc(?eD)d@v9xIy8 zy$x0p1BxO{J|FxuA0XM%{ZoL%`Kb=PsDo7w6&JY*Fw))0&OT!MiCsBfy%1pmgCj`M z03;yh%e@t0L{IcX52J)*Pa6!V+CNoRLv#LZx~A*BSh=0GI20#tXq94eO?E4WqmAob zBnf0|U!{rz-M5a9D9Hq+y2=85Y)mUkX#qz&L4dW*;Da}ZA~_&xqvAGXsIBM-m{s^K z&?>Nc{0us;`Nm1Q%5Z7Ldz5_*$86EC*#u0F(hfmn6}(>{)K4L)W<`2z2jU_Mh(_c7 z_);H=lmx>NpS)plKAg-(eJqOrJ{y+u`{NDOTOr@TooFFp>$O8#iE!f$4X>;4%KEU9 z2Jl5reDNWraOk- z;IS7z3*Pg8=eyK~hkVM(Lh3QXB9U3m73CQ(8gVsPEovt8Bs~DJyPLd%K1=Iu1Lk*YV~FgW{GjoyGH77);@shu0b8$O4q3?i|Jfc7ox^e z9^$y?&R5!XKK*nAF0eW@P0OOoVQL`kX(BD`@l~9_cbry1(9z^&zpe)!0QwLOI7|~- zrge)g7zI#_1kQk`s^ELGu=?+9$DbKcyDq$ni?~D0+%v|y`>)qh!%y{VP>&pc z0}X`g&KP|g;G&6WeQ|x~{?5qGYw&XI@#GO9tY;&|fv}MJiw4Fn>Cg)gDgUpIC_uem z7HQ&yO!Y~GIWkS%D}bK1ajhGJ4xi=yJ^-IlGYmNY_RkqwvT)h zODJC!U6cpp`6RHya^@9_7{(tS8YRFQZC7yN^i9U@iBrJo|K1Ay{=%?686)su=RFGJNR9MY z?!1N8x!r#B9I@;p+z89;Zb%M?3RpPQyP%R*r1PMRx*VgZxM#bF%xqa~Zcg*Nz?*0$ zWqOG(D>S44=JdHPi4;L*uyB(1BXMF7*uwm+J^-q!NDs)%__W>S%dQk=5EtHqt29K7RJR@;TH78pz92%e)}IR)$e@ zir<1Qt=H896L+~QP;B?&MhW5dzuFzG?<$>O>yuwPgNhe52LSf4T^4klU`X!VeLQHf zOX;W9^d6;7f08vvGt=X#@3TzjnqLq4&3Ts?b-Z6Fvpniz+G#xKpe)HgEOXNe{dFQ6 z$-DFEU&ziI_+`=a=;J83-)pyoVb)xOe7Sr&^2GYoW+a1pkh)2j16YorA~}JyT3Tm8 zSZMw1KxB*yiXu*%yEw>^PrDf$xn8Zl$?@p#_@e6TADiRoH6gVbNmis8tl1=Ja9&8B zQN+#BDAFp#nszw0k|2(f)JH6R;Y(@k6uIdG{#1RhT=NBDW*%v90DpH(sAu#6OA~n=(TA0 zbrv6+BJZC+2>;@#$Cr84d6f~fUC=nIWZjU{V4!lLr;Y;9Z@Vvsg;JrD|AoW^7i>p8A|K}?bt7n? z91KGy=Y?&4NZ%8k;tqXvc3^p-t(XBy>6Xai~!e%KUh+yuGsPtG1AlM z6yR82n*exM-;qZvKO|uTDXU060h=Zdd!mNIUJ4WBjEtG6jm155{G>aFGO7seCy%Q& zPt;m~dcx*vR}uV-gP!9oQ9^~fZ~*G!o7{jeULKk&O^O%zd$uTlTsQjmCOsy)von30 zn?jv?ob{ODYb0BUkP!R)m%o0CT=d7jETK0$looO<9Q8iy5G~6b|HdsN9NxwRF}+01 zo*Z-c?J0hZ$}TnUlYRao;atd+3X}@|#7~d>di31k%&x1h|L|QYd#L(A;gXBALNSF@ zto))!GhjD?=8YVF_^*KzDKd9~YURGK^!80|^fU8Z*kSihKKc=bCSxv71gmVXYeTOm z4LkKP%uNxye^zcZ3>~s4er>#WYjQkI)k7E&X?r(Go^2>(maa$<^ZG{Puz7UM{=ur` za1QD_rvBwBoinT9RNl`UwOhTDIiKjXr(%=h6JylARB>|Co)N02JqBjp63&XP+|f8K zXpB|~;AKA)HuH(OOr_rSN4Ci=k?5VfEBwT2+^D3uma}?(dY&uqr=?A~@;U9b)hQ

;mlar=eB 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 literal 0 HcmV?d00001 diff --git a/mod/PLibAVC/IModVersionChecker.cs b/mod/PLibAVC/IModVersionChecker.cs new file mode 100644 index 0000000..aa82d84 --- /dev/null +++ b/mod/PLibAVC/IModVersionChecker.cs @@ -0,0 +1,42 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +namespace PeterHan.PLib.AVC { + ///

+ /// Implemented by classes which can check the current mod version and detect if it is out + /// of date. + /// + public interface IModVersionChecker { + /// + /// The event to subscribe for when the check completes. + /// + event PVersionCheck.OnVersionCheckComplete OnVersionCheckCompleted; + + /// + /// Checks the mod and reports if it is out of date. The mod's current version as + /// reported by its mod_info.yaml file is available on the packagedModInfo member. + /// + /// This method might not be run on the foreground thread. Do not create new behaviors + /// or components without a coroutine to an existing GameObject. + /// + /// The mod whose version is being checked. + /// true if the version check has started, or false if it could not be + /// started, which will trigger the next version checker in line. + bool CheckVersion(KMod.Mod mod); + } +} diff --git a/mod/PLibAVC/JsonURLVersionChecker.cs b/mod/PLibAVC/JsonURLVersionChecker.cs new file mode 100644 index 0000000..7af1540 --- /dev/null +++ b/mod/PLibAVC/JsonURLVersionChecker.cs @@ -0,0 +1,146 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using KMod; +using Newtonsoft.Json; +using PeterHan.PLib.Core; +using System; +using System.Collections.Generic; +using System.IO; +using UnityEngine.Networking; + +namespace PeterHan.PLib.AVC { + /// + /// Checks the mod version using a URL to a JSON file. The file at this URL must resolve + /// to a JSON file which can deserialize to the JsonURLVersionChecker.ModVersions class. + /// + public sealed class JsonURLVersionChecker : IModVersionChecker { + /// + /// The timeout in seconds for the web request before declaring the check as failed. + /// + public const int REQUEST_TIMEOUT = 8; + + /// + /// The URL to query for checking the mod version. + /// + public string JsonVersionURL { get; } + + public event PVersionCheck.OnVersionCheckComplete OnVersionCheckCompleted; + + public JsonURLVersionChecker(string url) { + if (string.IsNullOrEmpty(url)) + throw new ArgumentNullException(nameof(url)); + JsonVersionURL = url; + } + + public bool CheckVersion(Mod mod) { + if (mod == null) + throw new ArgumentNullException(nameof(mod)); + var request = UnityWebRequest.Get(JsonVersionURL); + request.SetRequestHeader("Content-Type", "application/json"); + request.SetRequestHeader("User-Agent", "PLib AVC"); + request.timeout = REQUEST_TIMEOUT; + var operation = request.SendWebRequest(); + operation.completed += (_) => OnRequestFinished(request, mod); + return true; + } + + /// + /// When a web request completes, triggers the handler for the next updater. + /// + /// The JSON web request data. + /// The mod that needs to be checked. + private void OnRequestFinished(UnityWebRequest request, Mod mod) { + ModVersionCheckResults result = null; + if (request.result == UnityWebRequest.Result.Success) { + // Parse the text + ModVersions versions; + using (var reader = new StreamReader(new MemoryStream(request. + downloadHandler.data))) { + versions = new JsonSerializer() { + MaxDepth = 4, DateTimeZoneHandling = DateTimeZoneHandling.Utc, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore + }.Deserialize(new JsonTextReader(reader)); + } + if (versions != null) + result = ParseModVersion(mod, versions); + } + request.Dispose(); + OnVersionCheckCompleted?.Invoke(result); + } + + /// + /// Parses the JSON file and looks up the version for the specified mod. + /// + /// The mod's static ID. + /// The data from the web JSON file. + /// The results of the update, or null if the mod could not be found in the + /// JSON. + private ModVersionCheckResults ParseModVersion(Mod mod, ModVersions versions) { + ModVersionCheckResults result = null; + string id = mod.staticID; + if (versions.mods != null) + foreach (var modVersion in versions.mods) + if (modVersion != null && modVersion.staticID == id) { + string newVersion = modVersion.version?.Trim(); + if (string.IsNullOrEmpty(newVersion)) + result = new ModVersionCheckResults(id, true); + else + result = new ModVersionCheckResults(id, newVersion != + PVersionCheck.GetCurrentVersion(mod), newVersion); + break; + } + return result; + } + + /// + /// The serialization type for JSONURLVersionChecker. Allows multiple mods to query + /// the same URL. + /// + [JsonObject(MemberSerialization.OptIn)] + public sealed class ModVersions { + [JsonProperty] + public List mods; + + public ModVersions() { + mods = new List(16); + } + } + + /// + /// Represents the current version of each mod. + /// + public sealed class ModVersion { + /// + /// The mod's static ID, as reported by its mod.yaml. If a mod does not specify its + /// static ID, it gets the default ID mod.label.id + "_" + mod.label. + /// distribution_platform. + /// + public string staticID { get; set; } + + /// + /// The mod's current version. + /// + public string version { get; set; } + + public override string ToString() { + return "{0}: version={1}".F(staticID, version); + } + } + } +} diff --git a/mod/PLibAVC/ModVersionCheckResults.cs b/mod/PLibAVC/ModVersionCheckResults.cs new file mode 100644 index 0000000..43bf636 --- /dev/null +++ b/mod/PLibAVC/ModVersionCheckResults.cs @@ -0,0 +1,70 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using Newtonsoft.Json; +using PeterHan.PLib.Core; + +namespace PeterHan.PLib.AVC { + /// + /// The results of checking the mod version. + /// + [JsonObject(MemberSerialization.OptIn)] + public sealed class ModVersionCheckResults { + /// + /// true if the mod is up to date, or false if it is out of date. + /// + [JsonProperty] + public bool IsUpToDate { get; set; } + + /// + /// The mod whose version was queried. The current mod version is available on this + /// mod through its packagedModInfo. + /// + [JsonProperty] + public string ModChecked { get; set; } + + /// + /// The new version of this mod. If it is not available, it can be null, even if + /// IsUpdated is false. Not relevant if IsUpToDate reports true. + /// + [JsonProperty] + public string NewVersion { get; set; } + + public ModVersionCheckResults() : this("", false) { } + + public ModVersionCheckResults(string id, bool updated, string newVersion = null) { + IsUpToDate = updated; + ModChecked = id; + NewVersion = newVersion; + } + + public override bool Equals(object obj) { + return obj is ModVersionCheckResults other && other.ModChecked == ModChecked && + IsUpToDate == other.IsUpToDate && NewVersion == other.NewVersion; + } + + public override int GetHashCode() { + return ModChecked.GetHashCode(); + } + + public override string ToString() { + return "ModVersionCheckResults[{0},updated={1},newVersion={2}]".F( + ModChecked, IsUpToDate, NewVersion ?? ""); + } + } +} diff --git a/mod/PLibAVC/PLibAVC.csproj b/mod/PLibAVC/PLibAVC.csproj new file mode 100644 index 0000000..44b9ddc --- /dev/null +++ b/mod/PLibAVC/PLibAVC.csproj @@ -0,0 +1,22 @@ + + + + PLib Automatic Version Check + PLib.AVC + 4.11.0.0 + false + PeterHan.PLib.AVC + 4.11.0.0 + false + true + Vanilla;Mergedown + + + bin\$(Platform)\Release\PLibAVC.xml + + + + + + + diff --git a/mod/PLibAVC/PVersionCheck.cs b/mod/PLibAVC/PVersionCheck.cs new file mode 100644 index 0000000..763f428 --- /dev/null +++ b/mod/PLibAVC/PVersionCheck.cs @@ -0,0 +1,340 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using PeterHan.PLib.Core; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Reflection; +using UnityEngine; + +namespace PeterHan.PLib.AVC { + /// + /// Implements a basic automatic version check, using either Steam or an external website. + /// + /// The version of the current mod is taken from the mod version attribute of the provided + /// mod. + /// + public sealed class PVersionCheck : PForwardedComponent { + /// + /// The delegate type used when a background version check completes. + /// + /// The results of the check. If null, the check has failed, + /// and the next version should be tried. + public delegate void OnVersionCheckComplete(ModVersionCheckResults result); + + /// + /// The instantiated copy of this class. + /// + internal static PVersionCheck Instance { get; private set; } + + /// + /// The version of this component. Uses the running PLib version. + /// + internal static readonly Version VERSION = new Version(PVersion.VERSION); + + /// + /// Gets the reported version of the specified assembly. + /// + /// The assembly to check. + /// The assembly's file version, or if that is unset, its assembly version. + private static string GetCurrentVersion(Assembly assembly) { + string version = null; + if (assembly != null) { + version = assembly.GetFileVersion(); + if (string.IsNullOrEmpty(version)) + version = assembly.GetName()?.Version?.ToString(); + } + return version; + } + + /// + /// Gets the current version of the mod. If the version is specified in mod_info.yaml, + /// that version is reported. Otherwise, the assembly file version (and failing that, + /// the assembly version) of the assembly defining the mod's first UserMod2 instance + /// is reported. + /// + /// This method will only work after mods have loaded. + /// + /// The mod to check. + /// The current version of that mod. + public static string GetCurrentVersion(KMod.Mod mod) { + if (mod == null) + throw new ArgumentNullException(nameof(mod)); + string version = mod.packagedModInfo?.version; + if (string.IsNullOrEmpty(version)) { + // Does it have UM2 instances? + var instances = mod.loaded_mod_data?.userMod2Instances; + var dlls = mod.loaded_mod_data?.dlls; + if (instances != null) + // Use first UserMod2 + foreach (var um2 in instances) { + version = GetCurrentVersion(um2.Key); + if (!string.IsNullOrEmpty(version)) + break; + } + else if (dlls != null && dlls.Count > 0) + // Use first DLL + foreach (var assembly in dlls) { + version = GetCurrentVersion(assembly); + if (!string.IsNullOrEmpty(version)) + break; + } + else + // All methods of determining the version have failed + version = ""; + } + return version; + } + + private static void MainMenu_OnSpawn_Postfix() { + Instance?.RunVersionCheck(); + } + + private static void ModsScreen_BuildDisplay_Postfix(System.Collections.IEnumerable + ___displayedMods) { + // Must cast the type because ModsScreen.DisplayedMod is private + if (Instance != null && ___displayedMods != null) + foreach (var modEntry in ___displayedMods) + Instance.AddWarningIfOutdated(modEntry); + } + + /// + /// The mods whose version will be checked. + /// + private readonly IDictionary checkVersions; + + /// + /// The location where the outcome of mod version checking will be stored. + /// + private readonly ConcurrentDictionary results; + + public override Version Version => VERSION; + + public PVersionCheck() { + checkVersions = new Dictionary(8); + results = new ConcurrentDictionary(2, 16); + InstanceData = results.Values; + } + + /// + /// Adds a warning to the mods screen if a mod is outdated. + /// + /// The mod entry to modify. + private void AddWarningIfOutdated(object modEntry) { + int index = -1; + var type = modEntry.GetType(); + var indexVal = type.GetFieldSafe("mod_index", false)?.GetValue(modEntry); + if (indexVal is int intVal) + index = intVal; + var rowInstance = type.GetFieldSafe("rect_transform", false)?.GetValue( + modEntry) as RectTransform; + var mods = Global.Instance.modManager?.mods; + string id; + if (rowInstance != null && mods != null && index >= 0 && index < mods.Count && + !string.IsNullOrEmpty(id = mods[index]?.staticID) && rowInstance. + TryGetComponent(out HierarchyReferences hr) && results.TryGetValue(id, + out ModVersionCheckResults data) && data != null) + // Version text is thankfully known, even if other mods have added buttons + AddWarningIfOutdated(data, hr.GetReference("Version")); + } + + /// + /// Adds a warning to a mod version label if it is outdated. + /// + /// The updated mod version. + /// The current mod version label. + private void AddWarningIfOutdated(ModVersionCheckResults data, LocText versionText) { + GameObject go; + if (versionText != null && (go = versionText.gameObject) != null && !data. + IsUpToDate) { + string text = versionText.text; + if (string.IsNullOrEmpty(text)) + text = PLibStrings.OUTDATED_WARNING; + else + text = text + " " + PLibStrings.OUTDATED_WARNING; + versionText.text = text; + go.AddOrGet().toolTip = string.Format(PLibStrings.OUTDATED_TOOLTIP, + data.NewVersion ?? ""); + } + } + + public override void Initialize(Harmony plibInstance) { + Instance = this; + plibInstance.Patch(typeof(MainMenu), "OnSpawn", postfix: PatchMethod(nameof( + MainMenu_OnSpawn_Postfix))); + plibInstance.Patch(typeof(ModsScreen), "BuildDisplay", postfix: PatchMethod( + nameof(ModsScreen_BuildDisplay_Postfix))); + } + + public override void Process(uint operation, object args) { + if (operation == 0 && args is System.Action runNext) { + VersionCheckTask first = null, previous = null; + results.Clear(); + foreach (var pair in checkVersions) { + string staticID = pair.Key; + var mod = pair.Value; +#if DEBUG + PUtil.LogDebug("Checking version for mod {0}".F(staticID)); +#endif + foreach (var checker in mod.Methods) { + var node = new VersionCheckTask(mod.ModToCheck, checker, results) { + Next = runNext + }; + if (previous != null) + previous.Next = runNext; + if (first == null) + first = node; + } + } + first?.Run(); + } + } + + /// + /// Registers the specified mod for automatic version checking. Mods will be registered + /// using their static ID, so to avoid the default ID from being used instead, set this + /// attribute in mod.yaml. + /// + /// The same mod can be registered multiple times with different methods to check the + /// mod versions. The methods will be attempted in order from first registered to last. + /// However, the same mod must not be registered multiple times in different instances + /// of PVersionCheck. + /// + /// The mod instance to check. + /// The method to use for checking the mod version. + public void Register(KMod.UserMod2 mod, IModVersionChecker checker) { + var kmod = mod?.mod; + if (kmod == null) + throw new ArgumentNullException(nameof(mod)); + if (checker == null) + throw new ArgumentNullException(nameof(checker)); + RegisterForForwarding(); + string staticID = kmod.staticID; + if (!checkVersions.TryGetValue(staticID, out VersionCheckMethods checkers)) + checkVersions.Add(staticID, checkers = new VersionCheckMethods(kmod)); + checkers.Methods.Add(checker); + } + + /// + /// Reports the results of the version check. + /// + private void ReportResults() { + var allMods = PRegistry.Instance.GetAllComponents(ID); + results.Clear(); + if (allMods != null) + // Consolidate them through JSON roundtrip into results dictionary + foreach (var mod in allMods) { + var modResults = mod.GetInstanceDataSerialized>(); + if (modResults != null) + foreach (var result in modResults) + results.TryAdd(result.ModChecked, result); + } + } + + /// + /// Starts the automatic version check for all mods. + /// + internal void RunVersionCheck() { + var allMods = PRegistry.Instance.GetAllComponents(ID); + // See if Mod Updater triggered master disable + if (!PRegistry.GetData("PLib.VersionCheck.ModUpdaterActive") && + allMods != null) + new AllVersionCheckTask(allMods, this).Run(); + } + + /// + /// Checks each mod's version one at a time to avoid saturating the network with + /// generally nonessential traffic (in the case of yaml/json checkers). + /// + private sealed class AllVersionCheckTask { + /// + /// A list of actions that will check each version in turn. + /// + private readonly IList checkAllVersions; + + /// + /// The current location in the list. + /// + private int index; + + /// + /// Handles version check result reporting when complete. + /// + private readonly PVersionCheck parent; + + internal AllVersionCheckTask(IEnumerable allMods, + PVersionCheck parent) { + if (allMods == null) + throw new ArgumentNullException(nameof(allMods)); + checkAllVersions = new List(allMods); + index = 0; + this.parent = parent ?? throw new ArgumentNullException(nameof(parent)); + } + + /// + /// Runs all checks and fires the callback when complete. + /// + internal void Run() { + int n = checkAllVersions.Count; + if (index >= n) + parent.ReportResults(); + while (index < n) { + var doCheck = checkAllVersions[index++]; + if (doCheck != null) { + doCheck.Process(0, new System.Action(Run)); + break; + } else if (index >= n) + parent.ReportResults(); + } + } + + public override string ToString() { + return "AllVersionCheckTask for {0:D} mods".F(checkAllVersions.Count); + } + } + + /// + /// A placeholder class which stores all methods used to check a single mod. + /// + private sealed class VersionCheckMethods { + /// + /// The methods which will be used to check. + /// + internal IList Methods { get; } + + // + /// The mod whose version will be checked. + /// + internal KMod.Mod ModToCheck { get; } + + internal VersionCheckMethods(KMod.Mod mod) { + Methods = new List(8); + ModToCheck = mod ?? throw new ArgumentNullException(nameof(mod)); + PUtil.LogDebug("Registered mod ID {0} for automatic version checking".F( + ModToCheck.staticID)); + } + + public override string ToString() { + return ModToCheck.staticID; + } + } + } +} diff --git a/mod/PLibAVC/SteamVersionChecker.cs b/mod/PLibAVC/SteamVersionChecker.cs new file mode 100644 index 0000000..6727bbd --- /dev/null +++ b/mod/PLibAVC/SteamVersionChecker.cs @@ -0,0 +1,168 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using KMod; +using PeterHan.PLib.Core; +using System; +using System.Reflection; + +namespace PeterHan.PLib.AVC { + /// + /// Checks Steam to see if mods are out of date. + /// + public sealed class SteamVersionChecker : IModVersionChecker { + /// + /// A reference to the PublishedFileId_t type, or null if running on the EGS/WeGame + /// version. + /// + private static readonly Type PUBLISHED_FILE_ID = PPatchTools.GetTypeSafe( + "Steamworks.PublishedFileId_t"); + + /// + /// A reference to the SteamUGC type, or null if running on the EGS/WeGame version. + /// + private static readonly Type STEAM_UGC = PPatchTools.GetTypeSafe( + "Steamworks.SteamUGC"); + + /// + /// A reference to the game's version of SteamUGCService, or null if running on the + /// EGS/WeGame version. + /// + private static readonly Type STEAM_UGC_SERVICE = PPatchTools.GetTypeSafe( + nameof(SteamUGCService), "Assembly-CSharp"); + + /// + /// Detours requires knowing the types at compile time, which might not be available, + /// and these methods are only called once at startup. + /// + private static readonly MethodInfo FIND_MOD = STEAM_UGC_SERVICE?.GetMethodSafe( + nameof(SteamUGCService.FindMod), false, PUBLISHED_FILE_ID); + + private static readonly MethodInfo GET_ITEM_INSTALL_INFO = STEAM_UGC?.GetMethodSafe( + "GetItemInstallInfo", true, PUBLISHED_FILE_ID, typeof(ulong). + MakeByRefType(), typeof(string).MakeByRefType(), typeof(uint), typeof(uint). + MakeByRefType()); + + private static readonly ConstructorInfo NEW_PUBLISHED_FILE_ID = PUBLISHED_FILE_ID?. + GetConstructor(PPatchTools.BASE_FLAGS | BindingFlags.Instance, null, + new Type[] { typeof(ulong) }, null); + + /// + /// The number of minutes allowed before a mod is considered out of date. + /// + public const double UPDATE_JITTER = 10.0; + + /// + /// The epoch time for Steam time stamps. + /// + private static readonly System.DateTime UNIX_EPOCH = new System.DateTime(1970, 1, 1, + 0, 0, 0, DateTimeKind.Utc); + + /// + /// Gets the last modified date of a mod's local files. The time is returned in UTC. + /// + /// The mod ID to check. + /// The date and time of its last modification. + private static System.DateTime GetLocalLastModified(ulong id) { + var result = System.DateTime.UtcNow; + // Create a published file object, leave it boxed + if (GET_ITEM_INSTALL_INFO != null) { + // 260 = MAX_PATH + var methodArgs = new object[] { + NEW_PUBLISHED_FILE_ID.Invoke(new object[] { id }), 0UL, "", 260U, 0U + }; + if (GET_ITEM_INSTALL_INFO.Invoke(null, methodArgs) is bool success && + success && methodArgs.Length == 5 && methodArgs[4] is uint timestamp && + timestamp > 0U) + result = UnixEpochToDateTime(timestamp); + else + PUtil.LogDebug("Unable to determine last modified date for: " + id); + } + return result; + } + + /// + /// Converts a time from Steam (seconds since Unix epoch) to a C# DateTime. + /// + /// The timestamp since the epoch. + /// The UTC date and time that it represents. + public static System.DateTime UnixEpochToDateTime(ulong timeSeconds) { + return UNIX_EPOCH.AddSeconds(timeSeconds); + } + + public event PVersionCheck.OnVersionCheckComplete OnVersionCheckCompleted; + + public bool CheckVersion(Mod mod) { + // Epic editions of the game do not even have SteamUGCService + return FIND_MOD != null && NEW_PUBLISHED_FILE_ID != null && DoCheckVersion(mod); + } + + /// + /// Checks the mod on Steam and reports if it is out of date. This helper method + /// avoids a type load error if a non-Steam version of the game is used to load this + /// mod. + /// + /// The mod whose version is being checked. + /// true if the version check has started, or false if it could not be + /// started. + private bool DoCheckVersion(Mod mod) { + bool check = false; + if (mod.label.distribution_platform == Label.DistributionPlatform.Steam && ulong. + TryParse(mod.label.id, out ulong id)) { + // Jump into a coroutine and wait for it to be initialized + Global.Instance.StartCoroutine(WaitForSteamInit(id, mod)); + check = true; + } else + PUtil.LogWarning("SteamVersionChecker cannot check version for non-Steam mod {0}". + F(mod.staticID)); + return check; + } + + /// + /// To avoid blowing the stack, waits for Steam to initialize in a coroutine. + /// + /// The Steam file ID of the mod. + /// The mod to check for updates. + private System.Collections.IEnumerator WaitForSteamInit(ulong id, Mod mod) { + var boxedID = NEW_PUBLISHED_FILE_ID.Invoke(new object[] { id }); + ModVersionCheckResults results = null; + int timeout = 0; + do { + yield return null; + var inst = SteamUGCService.Instance; + // Mod takes time to be populated in the list + if (inst != null && FIND_MOD.Invoke(inst, new object[] { boxedID }) is + SteamUGCService.Mod steamMod) { + ulong ticks = steamMod.lastUpdateTime; + var steamUpdate = (ticks == 0U) ? System.DateTime.MinValue : + UnixEpochToDateTime(ticks); + bool updated = steamUpdate <= GetLocalLastModified(id).AddMinutes( + UPDATE_JITTER); + results = new ModVersionCheckResults(mod.staticID, + updated, updated ? null : steamUpdate.ToString("f")); + } + // 2 seconds at 60 FPS + } while (results == null && ++timeout < 120); + if (results == null) + PUtil.LogWarning("Unable to check version for mod {0} (SteamUGCService timeout)". + F(mod.label.title)); + OnVersionCheckCompleted?.Invoke(results); + yield break; + } + } +} diff --git a/mod/PLibAVC/VersionCheckTask.cs b/mod/PLibAVC/VersionCheckTask.cs new file mode 100644 index 0000000..956400a --- /dev/null +++ b/mod/PLibAVC/VersionCheckTask.cs @@ -0,0 +1,109 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System; +using System.Collections.Concurrent; + +namespace PeterHan.PLib.AVC { + /// + /// Represents a "task" to check a particular mod for updates. + /// + internal sealed class VersionCheckTask { + /// + /// The method which will be used to check. + /// + private readonly IModVersionChecker method; + + /// + /// The mod whose version will be checked. + /// + private readonly KMod.Mod mod; + + /// + /// The next task to run when the check completes, or null to not run any task. + /// + internal System.Action Next { get; set; } + + /// + /// The location where the outcome of mod version checking will be stored. + /// + private readonly ConcurrentDictionary results; + + internal VersionCheckTask(KMod.Mod mod, IModVersionChecker method, + ConcurrentDictionary results) { + this.mod = mod ?? throw new ArgumentNullException(nameof(mod)); + this.method = method ?? throw new ArgumentNullException(nameof(method)); + this.results = results ?? throw new ArgumentNullException(nameof(results)); + Next = null; + } + + /// + /// Records the result of the mod version check, and runs the next checker in + /// line, from this mod or a different one. + /// + /// The results from the version check. + private void OnComplete(ModVersionCheckResults result) { + method.OnVersionCheckCompleted -= OnComplete; + if (result != null) { + results.TryAdd(result.ModChecked, result); + if (!result.IsUpToDate) + PUtil.LogWarning("Mod {0} is out of date! New version: {1}".F(result. + ModChecked, result.NewVersion ?? "unknown")); + else { +#if DEBUG + PUtil.LogDebug("Mod {0} is up to date".F(result.ModChecked)); +#endif + } + } else + RunNext(); + } + + /// + /// Runs the version check, and registers a callback to run the next one if + /// it is not null. + /// + internal void Run() { + if (results.ContainsKey(mod.staticID)) + RunNext(); + else { + bool run = false; + method.OnVersionCheckCompleted += OnComplete; + // Version check errors should not crash the game + try { + run = method.CheckVersion(mod); + } catch (Exception e) { + PUtil.LogWarning("Unable to check version for mod " + mod.label.title + ":"); + PUtil.LogExcWarn(e); + run = false; + } + if (!run) { + method.OnVersionCheckCompleted -= OnComplete; + RunNext(); + } + } + } + + /// + /// Runs the next version check. + /// + private void RunNext() { + Next?.Invoke(); + } + } +} diff --git a/mod/PLibAVC/YamlURLVersionChecker.cs b/mod/PLibAVC/YamlURLVersionChecker.cs new file mode 100644 index 0000000..9f47967 --- /dev/null +++ b/mod/PLibAVC/YamlURLVersionChecker.cs @@ -0,0 +1,82 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using Klei; +using KMod; +using PeterHan.PLib.Core; +using System; +using UnityEngine.Networking; + +namespace PeterHan.PLib.AVC { + /// + /// Checks the mod version using a URL to a YAML file. The file at this URL must resolve + /// to a YAML file of the same format as the mod_info.yaml class. + /// + public sealed class YamlURLVersionChecker : IModVersionChecker { + /// + /// The URL to query for checking the mod version. + /// + public string YamlVersionURL { get; } + + public event PVersionCheck.OnVersionCheckComplete OnVersionCheckCompleted; + + public YamlURLVersionChecker(string url) { + if (string.IsNullOrEmpty(url)) + throw new ArgumentNullException(nameof(url)); + YamlVersionURL = url; + } + + public bool CheckVersion(Mod mod) { + if (mod == null) + throw new ArgumentNullException(nameof(mod)); + var request = UnityWebRequest.Get(YamlVersionURL); + request.SetRequestHeader("Content-Type", "application/x-yaml"); + request.SetRequestHeader("User-Agent", "PLib AVC"); + request.timeout = JsonURLVersionChecker.REQUEST_TIMEOUT; + var operation = request.SendWebRequest(); + operation.completed += (_) => OnRequestFinished(request, mod); + return true; + } + + /// + /// When a web request completes, triggers the handler for the next updater. + /// + /// The YAML web request data. + /// The mod that needs to be checked. + private void OnRequestFinished(UnityWebRequest request, Mod mod) { + ModVersionCheckResults result = null; + if (request.result == UnityWebRequest.Result.Success) { + // Parse the text + var modInfo = YamlIO.Parse(request.downloadHandler. + text, default); + string newVersion = modInfo?.version; + if (modInfo != null && !string.IsNullOrEmpty(newVersion)) { + string curVersion = PVersionCheck.GetCurrentVersion(mod); +#if DEBUG + PUtil.LogDebug("Current version: {0} New YAML version: {1}".F( + curVersion, newVersion)); +#endif + result = new ModVersionCheckResults(mod.staticID, newVersion != + curVersion, newVersion); + } + } + request.Dispose(); + OnVersionCheckCompleted?.Invoke(result); + } + } +} diff --git a/mod/PLibActions/PAction.cs b/mod/PLibActions/PAction.cs new file mode 100644 index 0000000..d325559 --- /dev/null +++ b/mod/PLibActions/PAction.cs @@ -0,0 +1,92 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; + +namespace PeterHan.PLib.Actions { + /// + /// An Action managed by PLib. Actions have key bindings assigned to them. + /// + public sealed class PAction { + /// + /// The maximum action value (typically used to mean "no action") used in the currently + /// running instance of the game. + /// + /// Since Action is compiled to a const int when a mod is built, any changes to the + /// Action enum will break direct references to Action.NumActions. Use this property + /// instead to always use the intended "no action" value. + /// + public static Action MaxAction { get; } + + static PAction() { + if (!Enum.TryParse(nameof(Action.NumActions), out Action limit)) + limit = Action.NumActions; + MaxAction = limit; + } + + /// + /// The default key binding for this action. Not necessarily the current key binding. + /// + internal PKeyBinding DefaultBinding { get; } + + /// + /// The action's non-localized identifier. Something like YOURMOD.CATEGORY.ACTIONNAME. + /// + public string Identifier { get; } + + /// + /// The action's ID. This ID is assigned internally upon register and used for PLib + /// indexing. Even if you somehow obtain it in your mod, it is not to be used! + /// + private readonly int id; + + /// + /// The action's title. + /// + public LocString Title { get; } + + internal PAction(int id, string identifier, LocString title, PKeyBinding binding) { + if (id <= 0) + throw new ArgumentOutOfRangeException(nameof(id)); + DefaultBinding = binding; + Identifier = identifier; + this.id = id; + Title = title; + } + + public override bool Equals(object obj) { + return (obj is PAction other) && other.id == id; + } + + /// + /// Retrieves the Klei action for this PAction. + /// + /// The Klei action for use in game functions. + public Action GetKAction() { + return (Action)((int)MaxAction + id); + } + + public override int GetHashCode() { + return id; + } + + public override string ToString() { + return "PAction[" + Identifier + "]: " + Title; + } + } +} diff --git a/mod/PLibActions/PActionManager.cs b/mod/PLibActions/PActionManager.cs new file mode 100644 index 0000000..f5ae249 --- /dev/null +++ b/mod/PLibActions/PActionManager.cs @@ -0,0 +1,357 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using PeterHan.PLib.Core; +using PeterHan.PLib.Detours; +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace PeterHan.PLib.Actions { + /// + /// Manages PAction functionality which must be single instance. + /// + public sealed class PActionManager : PForwardedComponent { + /// + /// Prototypes the required parameters for new BindingEntry() since they changed in + /// U39-489490. + /// + private delegate BindingEntry NewEntry(string group, GamepadButton button, + KKeyCode key_code, Modifier modifier, Action action); + + /// + /// The category used for all PLib keys. + /// + public const string CATEGORY = "PLib"; + + /// + /// Creates a new BindingEntry. + /// + private static readonly NewEntry NEW_BINDING_ENTRY = typeof(BindingEntry). + DetourConstructor(); + + /// + /// The version of this component. Uses the running PLib version. + /// + internal static readonly Version VERSION = new Version(PVersion.VERSION); + + /// + /// The instantiated copy of this class. + /// + internal static PActionManager Instance { get; private set; } + + /// + /// Assigns the key bindings to each Action when they are needed. + /// + private static void AssignKeyBindings() { + var allMods = PRegistry.Instance.GetAllComponents(typeof(PActionManager).FullName); + if (allMods != null) + foreach (var mod in allMods) + mod.Process(0, null); + } + + private static void CKeyDef_Postfix(KInputController.KeyDef __instance) { + if (Instance != null) + __instance.mActionFlags = ExtendFlags(__instance.mActionFlags, + Instance.GetMaxAction()); + } + + /// + /// Extends the action flags array to the new maximum length. + /// + /// The old flags array. + /// The minimum length. + /// The new action flags array. + internal static bool[] ExtendFlags(bool[] oldActionFlags, int newMax) { + int n = oldActionFlags.Length; + bool[] newActionFlags; + if (n < newMax) { + newActionFlags = new bool[newMax]; + Array.Copy(oldActionFlags, newActionFlags, n); + } else + newActionFlags = oldActionFlags; + return newActionFlags; + } + + /// + /// Checks to see if an action is already bound to a key. + /// + /// The current key bindings. + /// The action to look up. + /// true if the action already has a binding assigned, or false otherwise. + private static bool FindKeyBinding(IEnumerable currentBindings, + Action action) { + bool inBindings = false; + foreach (var entry in currentBindings) + if (entry.mAction == action) { + LogKeyBind("Action {0} already exists; assigned to KeyCode {1}".F(action, + entry.mKeyCode)); + inBindings = true; + break; + } + return inBindings; + } + + /// + /// Retrieves a Klei key binding title. + /// + /// The category of the key binding. + /// The key binding to retrieve. + /// The Strings entry describing this key binding. + private static string GetBindingTitle(string category, string item) { + return "STRINGS.INPUT_BINDINGS." + category.ToUpperInvariant() + "." + item. + ToUpperInvariant(); + } + + /// + /// Retrieves a "localized" (if PLib is localized) description of additional key codes + /// from the KKeyCode enumeration, to avoid warning spam on popular keybinds like + /// arrow keys, delete, home, and so forth. + /// + /// The key code. + /// A description of that key code, or null if no localization is found. + internal static string GetExtraKeycodeLocalized(KKeyCode code) { + string localCode = null; + switch (code) { + case KKeyCode.Home: + localCode = PLibStrings.KEY_HOME; + break; + case KKeyCode.End: + localCode = PLibStrings.KEY_END; + break; + case KKeyCode.Delete: + localCode = PLibStrings.KEY_DELETE; + break; + case KKeyCode.PageDown: + localCode = PLibStrings.KEY_PAGEDOWN; + break; + case KKeyCode.PageUp: + localCode = PLibStrings.KEY_PAGEUP; + break; + case KKeyCode.LeftArrow: + localCode = PLibStrings.KEY_ARROWLEFT; + break; + case KKeyCode.UpArrow: + localCode = PLibStrings.KEY_ARROWUP; + break; + case KKeyCode.RightArrow: + localCode = PLibStrings.KEY_ARROWRIGHT; + break; + case KKeyCode.DownArrow: + localCode = PLibStrings.KEY_ARROWDOWN; + break; + case KKeyCode.Pause: + localCode = PLibStrings.KEY_PAUSE; + break; + case KKeyCode.SysReq: + localCode = PLibStrings.KEY_SYSRQ; + break; + case KKeyCode.Print: + localCode = PLibStrings.KEY_PRTSCREEN; + break; + default: + break; + } + return localCode; + } + + private static bool GetKeycodeLocalized_Prefix(KKeyCode key_code, ref string __result) { + string newResult = GetExtraKeycodeLocalized(key_code); + if (newResult != null) + __result = newResult; + return newResult == null; + } + + private static void IsActive_Prefix(ref bool[] ___mActionState) { + if (Instance != null) + ___mActionState = ExtendFlags(___mActionState, Instance.GetMaxAction()); + } + + /// + /// Logs a message encountered by the PLib key binding system. + /// + /// The message. + internal static void LogKeyBind(string message) { + Debug.LogFormat("[PKeyBinding] {0}", message); + } + + /// + /// Logs a warning encountered by the PLib key binding system. + /// + /// The warning message. + internal static void LogKeyBindWarning(string message) { + Debug.LogWarningFormat("[PKeyBinding] {0}", message); + } + + private static void QueueButtonEvent_Prefix(ref bool[] ___mActionState, + KInputController.KeyDef key_def) { + if (KInputManager.isFocused && Instance != null) { + int max = Instance.GetMaxAction(); + key_def.mActionFlags = ExtendFlags(key_def.mActionFlags, max); + ___mActionState = ExtendFlags(___mActionState, max); + } + } + + private static void SetDefaultKeyBindings_Postfix() { + Instance?.UpdateMaxAction(); + } + + public override Version Version => VERSION; + + /// + /// Queued key binds which are resolved on load. + /// + private readonly IList actions; + + /// + /// The maximum action index of any custom action registered across all mods. + /// + private int maxAction; + + /// + /// Creates a new action manager used to create and assign custom actions. Due to the + /// timing of when the user's key bindings are loaded, all actions must be added in + /// OnLoad(). + /// + public PActionManager() { + actions = new List(8); + maxAction = 0; + } + + public override void Bootstrap(Harmony plibInstance) { + // GameInputMapping.LoadBindings occurs after mods load but before the rest of the + // PLib components are initialized + plibInstance.Patch(typeof(GameInputMapping), nameof(GameInputMapping. + LoadBindings), prefix: PatchMethod(nameof(AssignKeyBindings))); + } + + /// + /// Registers a PAction with the action manager. + /// + /// This call should occur after PUtil.InitLibrary() during the mod OnLoad(). If called + /// earlier, it may fail with InvalidOperationException, and if called later, the + /// user's custom key bind (if applicable) will be discarded. + /// + /// The identifier for this action. + /// The action's title. If null, the default value from + /// STRINGS.INPUT_BINDINGS.PLIB.identifier will be used instead. + /// The default key binding for this action. If null, no key will + /// be bound by default, but the user can set a key bind. + /// The action thus registered. + public PAction CreateAction(string identifier, LocString title, + PKeyBinding binding = null) { + PAction action; + RegisterForForwarding(); + int curIndex = GetSharedData(1); + action = new PAction(curIndex, identifier, title, binding); + SetSharedData(curIndex + 1); + actions.Add(action); + return action; + } + + /// + /// Returns the maximum length of the Action enum, including custom actions. If no + /// actions are defined, returns NumActions - 1 since NumActions is reserved in the + /// base game. + /// + /// This value will not be accurate until all mods have loaded and key binds + /// registered (AfterLayerableLoad or later such as BeforeDbInit). + /// + /// The maximum length required to represent all Actions. + public int GetMaxAction() { + int nActions = (int)PAction.MaxAction; + return (maxAction > 0) ? (maxAction + nActions) : nActions - 1; + } + + public override void Initialize(Harmony plibInstance) { + Instance = this; + + // GameInputMapping + plibInstance.Patch(typeof(GameInputMapping), nameof(GameInputMapping. + SetDefaultKeyBindings), postfix: PatchMethod(nameof( + SetDefaultKeyBindings_Postfix))); + + // GameUtil + plibInstance.Patch(typeof(GameUtil), nameof(GameUtil.GetKeycodeLocalized), + prefix: PatchMethod(nameof(GetKeycodeLocalized_Prefix))); + + // KInputController + plibInstance.PatchConstructor(typeof(KInputController.KeyDef), new Type[] { + typeof(KKeyCode), typeof(Modifier) + }, postfix: PatchMethod(nameof(CKeyDef_Postfix))); + plibInstance.Patch(typeof(KInputController), nameof(KInputController.IsActive), + prefix: PatchMethod(nameof(IsActive_Prefix))); + plibInstance.Patch(typeof(KInputController), nameof(KInputController. + QueueButtonEvent), prefix: PatchMethod(nameof(QueueButtonEvent_Prefix))); + } + + public override void PostInitialize(Harmony plibInstance) { + // Needs to occur after localization + Strings.Add(GetBindingTitle(CATEGORY, "NAME"), PLibStrings.KEY_CATEGORY_TITLE); + var allMods = PRegistry.Instance.GetAllComponents(ID); + if (allMods != null) + foreach (var mod in allMods) + mod.Process(1, null); + } + + public override void Process(uint operation, object _) { + int n = actions.Count; + if (n > 0) { + if (operation == 0) + RegisterKeyBindings(); + else if (operation == 1) { +#if DEBUG + LogKeyBind("Localizing titles for {0}".F(Assembly.GetExecutingAssembly(). + GetNameSafe() ?? "?")); +#endif + foreach (var action in actions) + Strings.Add(GetBindingTitle(CATEGORY, action.GetKAction().ToString()), + action.Title); + } + } + } + + private void RegisterKeyBindings() { + int n = actions.Count; + LogKeyBind("Registering {0:D} key bind(s) for mod {1}".F(n, Assembly. + GetExecutingAssembly().GetNameSafe() ?? "?")); + var currentBindings = new List(GameInputMapping.DefaultBindings); + foreach (var action in actions) { + var kAction = action.GetKAction(); + var binding = action.DefaultBinding; + if (!FindKeyBinding(currentBindings, kAction)) { + if (binding == null) + binding = new PKeyBinding(); + // This constructor changes often enough to be worth detouring + currentBindings.Add(NEW_BINDING_ENTRY.Invoke(CATEGORY, binding. + GamePadButton, binding.Key, binding.Modifiers, kAction)); + } + } + GameInputMapping.SetDefaultKeyBindings(currentBindings.ToArray()); + UpdateMaxAction(); + } + + /// + /// Updates the maximum action for this instance. + /// + private void UpdateMaxAction() { + maxAction = GetSharedData(0); + } + } +} diff --git a/mod/PLibActions/PKeyBinding.cs b/mod/PLibActions/PKeyBinding.cs new file mode 100644 index 0000000..c23c08a --- /dev/null +++ b/mod/PLibActions/PKeyBinding.cs @@ -0,0 +1,71 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +namespace PeterHan.PLib.Actions { + /// + /// Represents a single key binding to an action. + /// + public sealed class PKeyBinding { + /// + /// The gamepad button to bind. + /// + public GamepadButton GamePadButton { get; set; } + + /// + /// The key code. + /// + public KKeyCode Key { get; set; } + + /// + /// The modifier code. + /// + public Modifier Modifiers { get; set; } + + public PKeyBinding(KKeyCode keyCode = KKeyCode.None, Modifier modifiers = Modifier. + None, GamepadButton gamePadButton = GamepadButton.NumButtons) { + GamePadButton = gamePadButton; + Key = keyCode; + Modifiers = modifiers; + } + + public PKeyBinding(PKeyBinding other) : this() { + if (other != null) { + GamePadButton = other.GamePadButton; + Key = other.Key; + Modifiers = other.Modifiers; + } + } + + public override bool Equals(object obj) { + return obj is PKeyBinding other && other.Key == Key && other.Modifiers == + Modifiers && other.GamePadButton == GamePadButton; + } + + public override int GetHashCode() { + // Was auto generated by VS, please do not kill me + int hashCode = 1488379021 + GamePadButton.GetHashCode(); + hashCode = hashCode * -1521134295 + Key.GetHashCode(); + hashCode = hashCode * -1521134295 + Modifiers.GetHashCode(); + return hashCode; + } + + public override string ToString() { + return Modifiers + " " + Key; + } + } +} diff --git a/mod/PLibActions/PLibActions.csproj b/mod/PLibActions/PLibActions.csproj new file mode 100644 index 0000000..623f7c8 --- /dev/null +++ b/mod/PLibActions/PLibActions.csproj @@ -0,0 +1,20 @@ + + + + PLib Actions + PLib.Actions + 4.11.0.0 + false + PeterHan.PLib.Actions + 4.11.0.0 + false + true + Vanilla;Mergedown + + + bin\$(Platform)\Release\PLibActions.xml + + + + + diff --git a/mod/PLibActions/PToolMode.cs b/mod/PLibActions/PToolMode.cs new file mode 100644 index 0000000..d5f61d0 --- /dev/null +++ b/mod/PLibActions/PToolMode.cs @@ -0,0 +1,118 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System; +using System.Collections.Generic; + +namespace PeterHan.PLib.Actions { + /// + /// A tool mode used in custom tool menus. Shown in the options in the bottom right. + /// + public sealed class PToolMode { + /// + /// Sets up tool options in the tool parameter menu. + /// + /// The menu to configure. + /// The available modes. + /// A dictionary which is updated in real time to contain the actual state of each mode. + public static IDictionary PopulateMenu( + ToolParameterMenu menu, ICollection options) { + if (options == null) + throw new ArgumentNullException(nameof(options)); + var kOpt = new Dictionary(options.Count); + // Add to Klei format, yes it loses the order but it means less of a mess + foreach (var option in options) { + string key = option.Key; + if (!string.IsNullOrEmpty(option.Title)) + Strings.Add("STRINGS.UI.TOOLS.FILTERLAYERS." + key, option.Title); + kOpt.Add(key, option.State); + } + menu.PopulateMenu(kOpt); + return kOpt; + } + + /// + /// Registers a tool with the game. It still must be added to a tool collection to be + /// visible. + /// + /// The tool type to register. + /// The player controller which will be its parent; consider + /// using in a postfix on PlayerController.OnPrefabInit. + public static void RegisterTool(PlayerController controller) where T : InterfaceTool + { + if (controller == null) + throw new ArgumentNullException(nameof(controller)); + // Create list so that new tool can be appended at the end + var interfaceTools = ListPool.Allocate(); + interfaceTools.AddRange(controller.tools); + var newTool = new UnityEngine.GameObject(typeof(T).Name); + var tool = newTool.AddComponent(); + // Reparent tool to the player controller, then enable/disable to load it + newTool.transform.SetParent(controller.gameObject.transform); + newTool.gameObject.SetActive(true); + newTool.gameObject.SetActive(false); + interfaceTools.Add(tool); + controller.tools = interfaceTools.ToArray(); + interfaceTools.Recycle(); + } + + /// + /// A unique key used to identify this mode. + /// + public string Key { get; } + + /// + /// The current state of this tool mode. + /// + public ToolParameterMenu.ToggleState State { get; } + + /// + /// The title displayed on-screen for this mode. + /// + public LocString Title { get; } + + /// + /// Creates a new tool mode entry. + /// + /// The key which identifies this tool mode. + /// The title to be displayed. If null, the title will be taken + /// from the default location in STRINGS.UI.TOOLS.FILTERLAYERS. + /// The initial state, default Off. + public PToolMode(string key, LocString title, ToolParameterMenu.ToggleState state = + ToolParameterMenu.ToggleState.Off) { + if (string.IsNullOrEmpty(key)) + throw new ArgumentNullException(nameof(key)); + Key = key; + State = state; + Title = title; + } + + public override bool Equals(object obj) { + return obj is PToolMode other && other.Key == Key; + } + + public override int GetHashCode() { + return Key.GetHashCode(); + } + + public override string ToString() { + return "{0} ({1})".F(Key, Title); + } + } +} diff --git a/mod/PLibBuildings/BuildIngredient.cs b/mod/PLibBuildings/BuildIngredient.cs new file mode 100644 index 0000000..e0bc617 --- /dev/null +++ b/mod/PLibBuildings/BuildIngredient.cs @@ -0,0 +1,110 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System; + +namespace PeterHan.PLib.Buildings { + /// + /// An ingredient to be used in a building. + /// + public class BuildIngredient { + /// + /// The material tag name. + /// + public string Material { get; } + + /// + /// The quantity required in kg. + /// + public float Quantity { get; } + + public BuildIngredient(string name, float quantity) { + if (string.IsNullOrEmpty(name)) + throw new ArgumentNullException(nameof(name)); + if (quantity.IsNaNOrInfinity() || quantity <= 0.0f) + throw new ArgumentException(nameof(quantity)); + Material = name; + Quantity = quantity; + } + + public BuildIngredient(string[] material, int tier) : this(material[0], tier) { } + + public BuildIngredient(string material, int tier) { + // Intended for use from the MATERIALS and BUILDINGS presets + if (string.IsNullOrEmpty(material)) + throw new ArgumentNullException(nameof(material)); + Material = material; + float qty; + switch (tier) { + case -1: + // Tier -1: 5 + qty = TUNING.BUILDINGS.CONSTRUCTION_MASS_KG.TIER_TINY[0]; + break; + case 0: + // Tier 0: 25 + qty = TUNING.BUILDINGS.CONSTRUCTION_MASS_KG.TIER0[0]; + break; + case 1: + // Tier 1: 50 + qty = TUNING.BUILDINGS.CONSTRUCTION_MASS_KG.TIER1[0]; + break; + case 2: + // Tier 2: 100 + qty = TUNING.BUILDINGS.CONSTRUCTION_MASS_KG.TIER2[0]; + break; + case 3: + // Tier 3: 200 + qty = TUNING.BUILDINGS.CONSTRUCTION_MASS_KG.TIER3[0]; + break; + case 4: + // Tier 4: 400 + qty = TUNING.BUILDINGS.CONSTRUCTION_MASS_KG.TIER4[0]; + break; + case 5: + // Tier 5: 800 + qty = TUNING.BUILDINGS.CONSTRUCTION_MASS_KG.TIER5[0]; + break; + case 6: + // Tier 6: 1200 + qty = TUNING.BUILDINGS.CONSTRUCTION_MASS_KG.TIER6[0]; + break; + case 7: + // Tier 7: 2000 + qty = TUNING.BUILDINGS.CONSTRUCTION_MASS_KG.TIER7[0]; + break; + default: + throw new ArgumentException("tier must be between -1 and 7 inclusive"); + } + Quantity = qty; + } + + public override bool Equals(object obj) { + return obj is BuildIngredient other && other.Material == Material && other. + Quantity == Quantity; + } + + public override int GetHashCode() { + return Material.GetHashCode(); + } + + public override string ToString() { + return "Material[Tag={0},Quantity={1:F0}]".F(Material, Quantity); + } + } +} diff --git a/mod/PLibBuildings/ColoredRangeVisualizer.cs b/mod/PLibBuildings/ColoredRangeVisualizer.cs new file mode 100644 index 0000000..e2fe184 --- /dev/null +++ b/mod/PLibBuildings/ColoredRangeVisualizer.cs @@ -0,0 +1,271 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace PeterHan.PLib.Buildings { + /// + /// A visualizer that colors cells with an overlay when a building is selected or being + /// previewed. + /// + public abstract class ColoredRangeVisualizer : KMonoBehaviour { + /// + /// The anim name to use when visualizing. + /// + private const string ANIM_NAME = "transferarmgrid_kanim"; + + /// + /// The animations to play when the visualization is created. + /// + private static readonly HashedString[] PRE_ANIMS = new HashedString[] { + "grid_pre", + "grid_loop" + }; + + /// + /// The animation to play when the visualization is destroyed. + /// + private static readonly HashedString POST_ANIM = "grid_pst"; + + /// + /// The layer on which to display the visualizer. + /// + public Grid.SceneLayer Layer { get; set; } + + // These components are automatically populated by KMonoBehaviour +#pragma warning disable IDE0044 // Add readonly modifier +#pragma warning disable CS0649 + [MyCmpGet] + protected BuildingPreview preview; + + [MyCmpGet] + protected Rotatable rotatable; +#pragma warning restore CS0649 +#pragma warning restore IDE0044 // Add readonly modifier + + /// + /// The cells where animations are being displayed. + /// + private readonly HashSet cells; + + protected ColoredRangeVisualizer() { + cells = new HashSet(); + Layer = Grid.SceneLayer.FXFront; + } + + /// + /// Creates or updates the visualizers as necessary. + /// + private void CreateVisualizers() { + var visCells = HashSetPool.Allocate(); + var newCells = ListPool.Allocate(); + try { + if (gameObject != null) + VisualizeCells(visCells); + // Destroy cells that are not used in the new one + foreach (var cell in cells) + if (visCells.Remove(cell)) + newCells.Add(cell); + else + cell.Destroy(); + // Newcomers get their controller created and added to the list + foreach (var newCell in visCells) { + newCell.CreateController(Layer); + newCells.Add(newCell); + } + // Copy back to global + cells.Clear(); + foreach (var cell in newCells) + cells.Add(cell); + } finally { + visCells.Recycle(); + newCells.Recycle(); + } + } + + /// + /// Called when cells are changed in the building radius. + /// + private void OnCellChange() { + CreateVisualizers(); + } + + protected override void OnCleanUp() { + Unsubscribe((int)GameHashes.SelectObject); + if (preview != null) { + Singleton.Instance.UnregisterCellChangedHandler(transform, + OnCellChange); + if (rotatable != null) + Unsubscribe((int)GameHashes.Rotated); + } + RemoveVisualizers(); + base.OnCleanUp(); + } + + /// + /// Called when the object is rotated. + /// + private void OnRotated(object _) { + CreateVisualizers(); + } + + /// + /// Called when the object is selected. + /// + /// true if selected, or false if deselected. + private void OnSelect(object data) { + if (data is bool selected) { + var position = transform.position; + // Play the appropriate sound and update the visualizers + if (selected) { + PGameUtils.PlaySound("RadialGrid_form", position); + CreateVisualizers(); + } else { + PGameUtils.PlaySound("RadialGrid_disappear", position); + RemoveVisualizers(); + } + } + } + + protected override void OnSpawn() { + base.OnSpawn(); + Subscribe((int)GameHashes.SelectObject, OnSelect); + if (preview != null) { + // Previews can be moved + Singleton.Instance.RegisterCellChangedHandler(transform, + OnCellChange, nameof(ColoredRangeVisualizer) + ".OnSpawn"); + if (rotatable != null) + Subscribe((int)GameHashes.Rotated, OnRotated); + } + } + + /// + /// Removes all of the visualizers. + /// + private void RemoveVisualizers() { + foreach (var cell in cells) + cell.Destroy(); + cells.Clear(); + } + + /// + /// Calculates the offset cell from the specified starting point, including the + /// rotation of this object. + /// + /// The starting cell. + /// The offset if the building had its default rotation. + /// The computed destination cell. + protected int RotateOffsetCell(int baseCell, CellOffset offset) { + if (rotatable != null) + offset = rotatable.GetRotatedCellOffset(offset); + return Grid.OffsetCell(baseCell, offset); + } + + /// + /// Called when cell visualizations need to be updated. Visualized cells should be + /// added to the collection supplied as an argument. + /// + /// The cells which should be visualized. + protected abstract void VisualizeCells(ICollection newCells); + + /// + /// Stores the data about a particular cell, including its anim controller and tint + /// color. + /// + protected sealed class VisCellData : IComparable { + /// + /// The target cell. + /// + public int Cell { get; } + + /// + /// The anim controller for this cell. + /// + public KBatchedAnimController Controller { get; private set; } + + /// + /// The tint used for this cell. + /// + public Color Tint { get; } + + /// + /// Creates a visualized cell. + /// + /// The cell to visualize. + public VisCellData(int cell) : this(cell, Color.white) { } + + /// + /// Creates a visualized cell. + /// + /// The cell to visualize. + /// The color to tint it. + public VisCellData(int cell, Color tint) { + Cell = cell; + Controller = null; + Tint = tint; + } + + public int CompareTo(VisCellData other) { + if (other == null) + throw new ArgumentNullException(nameof(other)); + return Cell.CompareTo(other.Cell); + } + + /// + /// Creates the anim controller for this cell. + /// + /// The layer on which to display the animation. + public void CreateController(Grid.SceneLayer sceneLayer) { + Controller = FXHelpers.CreateEffect(ANIM_NAME, Grid.CellToPosCCC(Cell, + sceneLayer), null, false, sceneLayer, true); + Controller.destroyOnAnimComplete = false; + Controller.visibilityType = KAnimControllerBase.VisibilityType.Always; + Controller.gameObject.SetActive(true); + Controller.Play(PRE_ANIMS, KAnim.PlayMode.Loop); + Controller.TintColour = Tint; + } + + /// + /// Destroys the anim controller for this cell. + /// + public void Destroy() { + if (Controller != null) { + Controller.destroyOnAnimComplete = true; + Controller.Play(POST_ANIM, KAnim.PlayMode.Once, 1f, 0f); + Controller = null; + } + } + + public override bool Equals(object obj) { + return obj is VisCellData other && other.Cell == Cell && Tint.Equals(other. + Tint); + } + + public override int GetHashCode() { + return Cell; + } + + public override string ToString() { + return "CellData[cell={0:D},color={1}]".F(Cell, Tint); + } + } + } +} diff --git a/mod/PLibBuildings/ConduitConnection.cs b/mod/PLibBuildings/ConduitConnection.cs new file mode 100644 index 0000000..2045b6a --- /dev/null +++ b/mod/PLibBuildings/ConduitConnection.cs @@ -0,0 +1,43 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +namespace PeterHan.PLib.Buildings { + /// + /// Represents a pipe connection to a building. + /// + public class ConduitConnection { + /// + /// The conduit location. + /// + public CellOffset Location { get; } + + /// + /// The conduit type. + /// + public ConduitType Type { get; } + + public ConduitConnection(ConduitType type, CellOffset location) { + Location = location; + Type = type; + } + + public override string ToString() { + return string.Format("Connection[Type={0},Location={1}]", Type, Location); + } + } +} diff --git a/mod/PLibBuildings/PBuilding.Utils.cs b/mod/PLibBuildings/PBuilding.Utils.cs new file mode 100644 index 0000000..e1e1468 --- /dev/null +++ b/mod/PLibBuildings/PBuilding.Utils.cs @@ -0,0 +1,158 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System.Collections.Generic; +using UnityEngine; + +namespace PeterHan.PLib.Buildings { + /// + /// Utility methods for creating new buildings. + /// + public sealed partial class PBuilding { + /// + /// The default building category. + /// + private static readonly HashedString DEFAULT_CATEGORY = new HashedString("Base"); + + /// + /// Makes the building always operational. + /// + /// The game object to configure. + private static void ApplyAlwaysOperational(GameObject go) { + // Remove default components that could make a building non-operational + if (go.TryGetComponent(out BuildingEnabledButton enabled)) + Object.DestroyImmediate(enabled); + if (go.TryGetComponent(out Operational op)) + Object.DestroyImmediate(op); + if (go.TryGetComponent(out LogicPorts lp)) + Object.DestroyImmediate(lp); + } + + /// + /// Creates a logic port, in a method compatible with both the new and old Automation + /// updates. The port will have the default strings which fit well with the + /// LogicOperationalController. + /// + /// A logic port compatible with both editions. + public static LogicPorts.Port CompatLogicPort(LogicPortSpriteType type, + CellOffset offset) { + return new LogicPorts.Port(LogicOperationalController.PORT_ID, offset, + STRINGS.UI.LOGIC_PORTS.CONTROL_OPERATIONAL, + STRINGS.UI.LOGIC_PORTS.CONTROL_OPERATIONAL_ACTIVE, + STRINGS.UI.LOGIC_PORTS.CONTROL_OPERATIONAL_INACTIVE, false, type); + } + + /// + /// Adds the building to the plan menu. + /// + public void AddPlan() { + if (!addedPlan && Category.IsValid) { + bool add = false; + foreach (var menu in TUNING.BUILDINGS.PLANORDER) + if (menu.category == Category) { + AddPlanToCategory(menu); + add = true; + break; + } + if (!add) + PUtil.LogWarning("Unable to find build menu: " + Category); + addedPlan = true; + } + } + + /// + /// Adds a building to a specific plan menu. + /// + /// The menu to which to add the building. + private void AddPlanToCategory(PlanScreen.PlanInfo menu) { + // Found category + var data = menu.buildingAndSubcategoryData; + if (data != null) { + string addID = AddAfter; + bool add = false; + if (addID != null) { + // Optionally choose the position + int n = data.Count; + for (int i = 0; i < n - 1 && !add; i++) + if (data[i].Key == addID) { + data.Insert(i + 1, new KeyValuePair(ID, + SubCategory)); + add = true; + } + } + if (!add) + data.Add(new KeyValuePair(ID, SubCategory)); + } else + PUtil.LogWarning("Build menu " + Category + " has invalid entries!"); + } + + /// + /// Adds the building strings to the strings list. + /// + public void AddStrings() { + if (!addedStrings) { + string prefix = "STRINGS.BUILDINGS.PREFABS." + ID.ToUpperInvariant() + "."; + string nameStr = prefix + "NAME"; + if (Strings.TryGet(nameStr, out StringEntry localized)) + Name = localized.String; + else + Strings.Add(nameStr, Name); + // Allow null values to be defined in LocString class adds / etc + if (Description != null) + Strings.Add(prefix + "DESC", Description); + if (EffectText != null) + Strings.Add(prefix + "EFFECT", EffectText); + addedStrings = true; + } + } + + /// + /// Adds the building tech to the tech tree. + /// + public void AddTech() { + if (!addedTech && Tech != null) { + var technology = Db.Get().Techs?.TryGet(Tech); + if (technology != null) + technology.unlockedItemIDs?.Add(ID); + addedTech = true; + } + } + + /// + /// Splits up logic input/output ports and configures the game object with them. + /// + /// The game object to configure. + private void SplitLogicPorts(GameObject go) { + int n = LogicIO.Count; + var inputs = new List(n); + var outputs = new List(n); + foreach (var port in LogicIO) + if (port.spriteType == LogicPortSpriteType.Output) + outputs.Add(port); + else + inputs.Add(port); + // This works in both the old and new versions + var ports = go.AddOrGet(); + if (inputs.Count > 0) + ports.inputPortInfo = inputs.ToArray(); + if (outputs.Count > 0) + ports.outputPortInfo = outputs.ToArray(); + } + } +} diff --git a/mod/PLibBuildings/PBuilding.cs b/mod/PLibBuildings/PBuilding.cs new file mode 100644 index 0000000..baefc00 --- /dev/null +++ b/mod/PLibBuildings/PBuilding.cs @@ -0,0 +1,479 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace PeterHan.PLib.Buildings { + /// + /// A class used for creating new buildings. Abstracts many of the details to allow them + /// to be used across different game versions. + /// + public sealed partial class PBuilding { + /// + /// The building ID which should precede this building ID in the plan menu. + /// + public string AddAfter { get; set; } + + /// + /// Whether the building is always operational. + /// + public bool AlwaysOperational { get; set; } + + /// + /// The building's animation. + /// + public string Animation { get; set; } + + /// + /// The audio sounds used when placing/completing the building. + /// + public string AudioCategory { get; set; } + + /// + /// The audio volume used when placing/completing the building. + /// + public string AudioSize { get; set; } + + /// + /// Whether this building can break down. + /// + public bool Breaks { get; set; } + + /// + /// The build menu category. + /// + public HashedString Category { get; set; } + + /// + /// The construction time in seconds on x1 speed. + /// + public float ConstructionTime { get; set; } + + /// + /// The decor of this building. + /// + public EffectorValues Decor { get; set; } + + /// + /// The building description. + /// + public string Description { get; set; } + + /// + /// Text describing the building's effect. + /// + public string EffectText { get; set; } + + /// + /// Whether this building can entomb. + /// + public bool Entombs { get; set; } + + /// + /// The heat generation from the exhaust in kDTU/s. + /// + public float ExhaustHeatGeneration { get; set; } + + /// + /// Whether this building can flood. + /// + public bool Floods { get; set; } + + /// + /// The default priority of this building, with null to not add a priority. + /// + public int? DefaultPriority { get; set; } + + /// + /// The self-heating when active in kDTU/s. + /// + public float HeatGeneration { get; set; } + + /// + /// The building height. + /// + public int Height { get; set; } + + /// + /// The building HP until it breaks down. + /// + public int HP { get; set; } + + /// + /// The ingredients required for construction. + /// + public IList Ingredients { get; } + + /// + /// The building ID. + /// + public string ID { get; } + + /// + /// Whether this building is an industrial machine. + /// + public bool IndustrialMachine { get; set; } + + /// + /// The input conduits. + /// + public IList InputConduits { get; } + + /// + /// Whether this building is (or can be) a solid tile. + /// + public bool IsSolidTile { get; set; } + + /// + /// The logic ports. + /// + public IList LogicIO { get; } + + /// + /// The building name. + /// + public string Name { get; private set; } + + /// + /// The noise of this building (not used by Klei). + /// + public EffectorValues Noise { get; set; } + + /// + /// The layer for this building. + /// + public ObjectLayer ObjectLayer { get; set; } + + /// + /// The output conduits. + /// + public IList OutputConduits { get; } + + /// + /// If null, the building does not overheat; otherwise, it overheats at this + /// temperature in K. + /// + public float? OverheatTemperature { get; set; } + + /// + /// The location where this building may be built. + /// + public BuildLocationRule Placement { get; set; } + + /// + /// If null, the building has no power input; otherwise, it uses this much power. + /// + public PowerRequirement PowerInput { get; set; } + + /// + /// If null, the building has no power output; otherwise, it provides this much power. + /// + public PowerRequirement PowerOutput { get; set; } + + /// + /// The directions this building can face. + /// + public PermittedRotations RotateMode { get; set; } + + /// + /// The scene layer for this building. + /// + public Grid.SceneLayer SceneLayer { get; set; } + + /// + /// The subcategory for this building. + /// + /// The base game currently defines the following: + /// Base: + /// ladders, tiles, printing pods, doors, storage, tubes, default + /// Oxygen: + /// producers, scrubbers + /// Power: + /// generators, wires, batteries, transformers, switches + /// Food: + /// cooking, farming, ranching + /// Plumbing: + /// bathroom, pipes, pumps, valves, sensors + /// HVAC: + /// pipes, pumps, valves, sensors + /// Refining: + /// materials, oil, advanced + /// Medical: + /// cleaning, hospital, wellness + /// Furniture: + /// bed, lights, dining, recreation, pots, sculpture, electronic decor, moulding, + /// canvas, dispaly, monument, signs + /// Equipment: + /// research, exploration, work stations, suits general, oxygen masks, atmo suits, + /// jet suits, lead suits + /// Utilities: + /// temperature, other utilities, special + /// Automation: + /// wires, sensors, logic gates, utilities + /// Solid Transport: + /// conduit, valves, utilities + /// Rocketry: + /// telescopes, launch pad, railguns, engines, fuel and oxidizer, cargo, utility, + /// command, fittings + /// Radiation: + /// HEP, uranium, radiation + /// + public string SubCategory { get; set; } + + /// + /// The technology name required to unlock the building. + /// + public string Tech { get; set; } + + /// + /// The view mode used when placing this building. + /// + public HashedString ViewMode { get; set; } + + /// + /// The building width. + /// + public int Width { get; set; } + + /// + /// Whether the building was added to the plan menu. + /// + private bool addedPlan; + + /// + /// Whether the strings were added. + /// + private bool addedStrings; + + /// + /// Whether the technology wes added. + /// + private bool addedTech; + + /// + /// Creates a new building. All buildings thus created must be registered using + /// PBuilding.Register and have an appropriate IBuildingConfig class. + /// + /// Building should be created in OnLoad or a post-load patch (not in static + /// initializers) to give the localization framework time to patch the LocString + /// containing the building name and description. + /// + /// The building ID. + /// The building name. + public PBuilding(string id, string name) { + if (string.IsNullOrEmpty(id)) + throw new ArgumentNullException(nameof(id)); + if (string.IsNullOrEmpty(name)) + throw new ArgumentNullException(nameof(name)); + AddAfter = null; + AlwaysOperational = false; + Animation = ""; + AudioCategory = "Metal"; + AudioSize = "medium"; + Breaks = true; + Category = DEFAULT_CATEGORY; + ConstructionTime = 10.0f; + Decor = TUNING.BUILDINGS.DECOR.NONE; + DefaultPriority = null; + Description = "Default Building Description"; + EffectText = "Default Building Effect"; + Entombs = true; + ExhaustHeatGeneration = 0.0f; + Floods = true; + HeatGeneration = 0.0f; + Height = 1; + Ingredients = new List(4); + IndustrialMachine = false; + InputConduits = new List(4); + HP = 100; + ID = id; + LogicIO = new List(4); + Name = name; + Noise = TUNING.NOISE_POLLUTION.NONE; + ObjectLayer = PGameUtils.GetObjectLayer(nameof(ObjectLayer.Building), ObjectLayer. + Building); + OutputConduits = new List(4); + OverheatTemperature = null; + Placement = BuildLocationRule.OnFloor; + PowerInput = null; + PowerOutput = null; + RotateMode = PermittedRotations.Unrotatable; + SceneLayer = Grid.SceneLayer.Building; + // Hard coded strings in base game, no const to reference + SubCategory = "default"; + Tech = null; + ViewMode = OverlayModes.None.ID; + Width = 1; + + addedPlan = false; + addedStrings = false; + addedTech = false; + } + + /// + /// Creates the building def from this class. + /// + /// The Klei building def. + public BuildingDef CreateDef() { + // The number of fields in BuildingDef makes it somewhat impractical to detour + if (Width < 1) + throw new InvalidOperationException("Building width: " + Width); + if (Height < 1) + throw new InvalidOperationException("Building height: " + Height); + if (HP < 1) + throw new InvalidOperationException("Building HP: " + HP); + if (ConstructionTime.IsNaNOrInfinity()) + throw new InvalidOperationException("Construction time: " + ConstructionTime); + // Build an ingredients list + int n = Ingredients.Count; + if (n < 1) + throw new InvalidOperationException("No ingredients for build"); + float[] quantity = new float[n]; + string[] tag = new string[n]; + for (int i = 0; i < n; i++) { + var ingredient = Ingredients[i]; + if (ingredient == null) + throw new ArgumentNullException(nameof(ingredient)); + quantity[i] = ingredient.Quantity; + tag[i] = ingredient.Material; + } + // Melting point is not currently used + var def = BuildingTemplates.CreateBuildingDef(ID, Width, Height, Animation, HP, + Math.Max(0.1f, ConstructionTime), quantity, tag, 2400.0f, Placement, Decor, + Noise); + // Solid tile? + if (IsSolidTile) { + //def.isSolidTile = true; + def.BaseTimeUntilRepair = -1.0f; + def.UseStructureTemperature = false; + BuildingTemplates.CreateFoundationTileDef(def); + } + def.AudioCategory = AudioCategory; + def.AudioSize = AudioSize; + if (OverheatTemperature != null) { + def.Overheatable = true; + def.OverheatTemperature = OverheatTemperature ?? 348.15f; + } else + def.Overheatable = false; + // Plug in + if (PowerInput != null) { + def.RequiresPowerInput = true; + def.EnergyConsumptionWhenActive = PowerInput.MaxWattage; + def.PowerInputOffset = PowerInput.PlugLocation; + } + // Plug out + if (PowerOutput != null) { + def.RequiresPowerOutput = true; + def.GeneratorWattageRating = PowerOutput.MaxWattage; + def.PowerOutputOffset = PowerOutput.PlugLocation; + } + def.Breakable = Breaks; + def.PermittedRotations = RotateMode; + def.ExhaustKilowattsWhenActive = ExhaustHeatGeneration; + def.SelfHeatKilowattsWhenActive = HeatGeneration; + def.Floodable = Floods; + def.Entombable = Entombs; + def.ObjectLayer = ObjectLayer; + def.SceneLayer = SceneLayer; + def.ViewMode = ViewMode; + // Conduits (multiple per building are hard but will be added someday...) + if (InputConduits.Count > 1) + throw new InvalidOperationException("Only supports one input conduit"); + foreach (var conduit in InputConduits) { + def.UtilityInputOffset = conduit.Location; + def.InputConduitType = conduit.Type; + } + if (OutputConduits.Count > 1) + throw new InvalidOperationException("Only supports one output conduit"); + foreach (var conduit in OutputConduits) { + def.UtilityOutputOffset = conduit.Location; + def.OutputConduitType = conduit.Type; + } + return def; + } + + /// + /// Configures the building template of this building. Should be called in + /// ConfigureBuildingTemplate. + /// + /// The game object to configure. + public void ConfigureBuildingTemplate(GameObject go) { + if (AlwaysOperational) + ApplyAlwaysOperational(go); + } + + /// + /// Populates the logic ports of this building. Must be used after the + /// PBuilding.DoPostConfigureComplete method if logic ports are required. + /// + /// Should be called in DoPostConfigureComplete, DoPostConfigurePreview, and + /// DoPostConfigureUnderConstruction. + /// + /// The game object to configure. + public void CreateLogicPorts(GameObject go) { + SplitLogicPorts(go); + } + + /// + /// Performs the post-configure complete steps that this building object can do. + /// Not exhaustive! Other components must likely be added. + /// + /// This method does NOT add the logic ports. Use CreateLogicPorts to do so, + /// after this method has been invoked. + /// + /// The game object to configure. + public void DoPostConfigureComplete(GameObject go) { + if (InputConduits.Count == 1) { + var conduitConsumer = go.AddOrGet(); + foreach (var conduit in InputConduits) { + conduitConsumer.alwaysConsume = true; + conduitConsumer.conduitType = conduit.Type; + conduitConsumer.wrongElementResult = ConduitConsumer.WrongElementResult. + Store; + } + } + if (OutputConduits.Count == 1) { + var conduitDispenser = go.AddOrGet(); + foreach (var conduit in OutputConduits) { + conduitDispenser.alwaysDispense = true; + conduitDispenser.conduitType = conduit.Type; + conduitDispenser.elementFilter = null; + } + } + if (IndustrialMachine && go.TryGetComponent(out KPrefabID id)) + id.AddTag(RoomConstraints.ConstraintTags.IndustrialMachinery, false); + if (PowerInput != null) + go.AddOrGet(); + if (PowerOutput != null) + go.AddOrGet(); + // Set a default priority + if (DefaultPriority != null && go.TryGetComponent(out Prioritizable pr)) { + Prioritizable.AddRef(go); + pr.SetMasterPriority(new PrioritySetting(PriorityScreen.PriorityClass.basic, + DefaultPriority ?? 5)); + } + } + + public override string ToString() { + return "PBuilding[ID={0}]".F(ID); + } + } +} diff --git a/mod/PLibBuildings/PBuildingManager.cs b/mod/PLibBuildings/PBuildingManager.cs new file mode 100644 index 0000000..6a74c01 --- /dev/null +++ b/mod/PLibBuildings/PBuildingManager.cs @@ -0,0 +1,208 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using PeterHan.PLib.Core; +using PeterHan.PLib.PatchManager; +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace PeterHan.PLib.Buildings { + /// + /// Manages PLib buildings to break down PBuilding into a more reasonable sized class. + /// + public sealed class PBuildingManager : PForwardedComponent { + /// + /// The version of this component. Uses the running PLib version. + /// + internal static readonly Version VERSION = new Version(PVersion.VERSION); + + /// + /// The instantiated copy of this class. + /// + internal static PBuildingManager Instance { get; private set; } + + /// + /// Immediately adds an existing building ID to an existing technology ID in the + /// tech tree. + /// + /// Do not use this method on buildings registered through PBuilding as they + /// are added automatically. + /// + /// This method must be used in a Db.Initialize postfix patch or RunAt.AfterDbInit + /// PPatchManager method/patch. + /// + /// The technology tree node ID. + /// The building ID to add to that node. + public static void AddExistingBuildingToTech(string tech, string id) { + if (string.IsNullOrEmpty(tech)) + throw new ArgumentNullException(nameof(tech)); + if (string.IsNullOrEmpty(id)) + throw new ArgumentNullException(nameof(id)); + var technology = Db.Get().Techs?.TryGet(id); + if (technology != null) + technology.unlockedItemIDs?.Add(tech); + } + + private static void CreateBuildingDef_Postfix(BuildingDef __result, string anim, + string id) { + var animFiles = __result?.AnimFiles; + if (animFiles != null && animFiles.Length > 0 && animFiles[0] == null) + Debug.LogWarningFormat("(when looking for KAnim named {0} on building {1})", + anim, id); + } + + private static void CreateEquipmentDef_Postfix(EquipmentDef __result, string Anim, + string Id) { + var anim = __result?.Anim; + if (anim == null) + Debug.LogWarningFormat("(when looking for KAnim named {0} on equipment {1})", + Anim, Id); + } + + /// + /// Logs a message encountered by the PLib building system. + /// + /// The debug message. + internal static void LogBuildingDebug(string message) { + Debug.LogFormat("[PLibBuildings] {0}", message); + } + + private static void LoadGeneratedBuildings_Prefix() { + Instance?.AddAllStrings(); + } + + /// + /// The buildings which need to be registered. + /// + private readonly ICollection buildings; + + public override Version Version => VERSION; + + /// + /// Creates a building manager to register PLib buildings. + /// + public PBuildingManager() { + buildings = new List(16); + } + + /// + /// Adds the strings for every registered building in all mods to the database. + /// + private void AddAllStrings() { + InvokeAllProcess(0, null); + } + + /// + /// Adds the strings for each registered building in this mod to the database. + /// + private void AddStrings() { + int n = buildings.Count; + if (n > 0) { + LogBuildingDebug("Register strings for {0:D} building(s) from {1}".F(n, + Assembly.GetExecutingAssembly().GetNameSafe() ?? "?")); + foreach (var building in buildings) + if (building != null) { + building.AddStrings(); + building.AddPlan(); + } + } + } + + /// + /// Adds the techs for every registered building in all mods to the database. + /// + private void AddAllTechs() { + InvokeAllProcess(1, null); + } + + /// + /// Adds the techs for each registered building in this mod to the database. + /// + private void AddTechs() { + int n = buildings.Count; + if (n > 0) { + LogBuildingDebug("Register techs for {0:D} building(s) from {1}".F(n, + Assembly.GetExecutingAssembly().GetNameSafe() ?? "?")); + foreach (var building in buildings) + if (building != null) { + building.AddTech(); + } + } + } + + /// + /// Registers a building to properly display its name, description, and tech tree + /// entry. PLib must be initialized using InitLibrary before using this method. Each + /// building should only be registered once, either in OnLoad or a post-load patch. + /// + /// The building to register. + public void Register(PBuilding building) { + if (building == null) + throw new ArgumentNullException(nameof(building)); + RegisterForForwarding(); + // Must use object as the building table type + buildings.Add(building); +#if DEBUG + PUtil.LogDebug("Registered building: {0}".F(building.ID)); +#endif + } + + public override void Initialize(Harmony plibInstance) { + Instance = this; + + // Non essential, do not crash on fail + try { + plibInstance.Patch(typeof(BuildingTemplates), nameof(BuildingTemplates. + CreateBuildingDef), postfix: PatchMethod(nameof( + CreateBuildingDef_Postfix))); + plibInstance.Patch(typeof(EquipmentTemplates), nameof(EquipmentTemplates. + CreateEquipmentDef), postfix: PatchMethod(nameof( + CreateEquipmentDef_Postfix))); + } catch (Exception e) { +#if DEBUG + PUtil.LogExcWarn(e); +#endif + } + plibInstance.Patch(typeof(GeneratedBuildings), nameof(GeneratedBuildings. + LoadGeneratedBuildings), prefix: PatchMethod(nameof( + LoadGeneratedBuildings_Prefix))); + + // Avoid another Harmony patch by using PatchManager + var pm = new PPatchManager(plibInstance); + pm.RegisterPatch(RunAt.AfterDbInit, new BuildingTechRegistration()); + } + + public override void Process(uint operation, object _) { + if (operation == 0) + AddStrings(); + else if (operation == 1) + AddTechs(); + } + + /// + /// A Patch Manager patch which registers all PBuilding technologies. + /// + private sealed class BuildingTechRegistration : IPatchMethodInstance { + public void Run(Harmony instance) { + Instance?.AddAllTechs(); + } + } + } +} diff --git a/mod/PLibBuildings/PLibBuildings.csproj b/mod/PLibBuildings/PLibBuildings.csproj new file mode 100644 index 0000000..e565829 --- /dev/null +++ b/mod/PLibBuildings/PLibBuildings.csproj @@ -0,0 +1,20 @@ + + + + PLib Buildings + PLib.Buildings + 4.11.0.0 + false + PeterHan.PLib.Buildings + 4.11.0.0 + false + true + Vanilla;Mergedown + + + bin\$(Platform)\Release\PLibBuildings.xml + + + + + diff --git a/mod/PLibBuildings/PowerRequirement.cs b/mod/PLibBuildings/PowerRequirement.cs new file mode 100644 index 0000000..d7f8f2a --- /dev/null +++ b/mod/PLibBuildings/PowerRequirement.cs @@ -0,0 +1,48 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System; + +namespace PeterHan.PLib.Buildings { + /// + /// Stores related information about a building's power requirements. + /// + public class PowerRequirement { + /// + /// The maximum building wattage. + /// + public float MaxWattage { get; } + + /// + /// The location of the plug related to the foundation tile. + /// + public CellOffset PlugLocation { get; } + + public PowerRequirement(float wattage, CellOffset plugLocation) { + if (wattage.IsNaNOrInfinity() || wattage < 0.0f) + throw new ArgumentException("wattage"); + MaxWattage = wattage; + PlugLocation = plugLocation; + } + + public override string ToString() { + return "Power[Watts={0:F0},Location={1}]".F(MaxWattage, PlugLocation); + } + } +} diff --git a/mod/PLibCore/Detours/DetourException.cs b/mod/PLibCore/Detours/DetourException.cs new file mode 100644 index 0000000..8d13bc4 --- /dev/null +++ b/mod/PLibCore/Detours/DetourException.cs @@ -0,0 +1,28 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; + +namespace PeterHan.PLib.Detours { + /// + /// An exception thrown when constructing a detour. + /// + public class DetourException : ArgumentException { + public DetourException(string message) : base(message) { } + } +} diff --git a/mod/PLibCore/Detours/DetouredField.cs b/mod/PLibCore/Detours/DetouredField.cs new file mode 100644 index 0000000..148e5fe --- /dev/null +++ b/mod/PLibCore/Detours/DetouredField.cs @@ -0,0 +1,53 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; + +namespace PeterHan.PLib.Detours { + /// + /// Stores delegates used to read and write fields or properties. + /// + /// The containing type of the field or property. + /// The element type of the field or property. + internal sealed class DetouredField : IDetouredField { + /// + /// Invoke to get the field/property value. + /// + public Func Get { get; } + + /// + /// The field name. + /// + public string Name { get; } + + /// + /// Invoke to set the field/property value. Null if the field is const or readonly. + /// + public Action Set { get; } + + internal DetouredField(string name, Func get, Action set) { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Get = get; + Set = set; + } + + public override string ToString() { + return string.Format("DetouredField[name={0}]", Name); + } + } +} diff --git a/mod/PLibCore/Detours/DetouredMethod.cs b/mod/PLibCore/Detours/DetouredMethod.cs new file mode 100644 index 0000000..c0138bf --- /dev/null +++ b/mod/PLibCore/Detours/DetouredMethod.cs @@ -0,0 +1,73 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; + +namespace PeterHan.PLib.Detours { + /// + /// Stores a detoured method, only performing the expensive reflection when the detour is + /// first used. + /// + /// This class is not thread safe. + /// The delegate type to be used to call the detour. + /// + public sealed class DetouredMethod where D : Delegate { + /// + /// Emulates the ability of Delegate.Invoke to actually call the method. + /// + public D Invoke { + get { + Initialize(); + return delg; + } + } + + /// + /// The method name. + /// + public string Name { get; } + + /// + /// The delegate method which will be called. + /// + private D delg; + + /// + /// The target type. + /// + private readonly Type type; + + internal DetouredMethod(Type type, string name) { + this.type = type ?? throw new ArgumentNullException(nameof(type)); + Name = name ?? throw new ArgumentNullException(nameof(name)); + delg = null; + } + + /// + /// Initializes the getter and setter functions immediately if necessary. + /// + public void Initialize() { + if (delg == null) + delg = PDetours.Detour(type, Name); + } + + public override string ToString() { + return string.Format("LazyDetouredMethod[type={1},name={0}]", Name, type.FullName); + } + } +} diff --git a/mod/PLibCore/Detours/IDetouredField.cs b/mod/PLibCore/Detours/IDetouredField.cs new file mode 100644 index 0000000..e0f75c7 --- /dev/null +++ b/mod/PLibCore/Detours/IDetouredField.cs @@ -0,0 +1,44 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; + +namespace PeterHan.PLib.Detours { + /// + /// An interface that describes a detoured field, which stores delegates used to read and + /// write fields or properties. + /// + /// The containing type of the field or property. + /// The element type of the field or property. + public interface IDetouredField { + /// + /// Invoke to get the field/property value. + /// + Func Get { get; } + + /// + /// The field name. + /// + string Name { get; } + + /// + /// Invoke to set the field/property value. + /// + Action Set { get; } + } +} diff --git a/mod/PLibCore/Detours/LazyDetouredField.cs b/mod/PLibCore/Detours/LazyDetouredField.cs new file mode 100644 index 0000000..7a8ff1c --- /dev/null +++ b/mod/PLibCore/Detours/LazyDetouredField.cs @@ -0,0 +1,93 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; + +namespace PeterHan.PLib.Detours { + /// + /// Stores delegates used to read and write fields or properties. This version is lazy and + /// only calculates the destination when it is first used. + /// + /// This class is not thread safe. + /// + /// The containing type of the field or property. + /// The element type of the field or property. + internal sealed class LazyDetouredField : IDetouredField { + /// + /// Invoke to get the field/property value. + /// + public Func Get { + get { + Initialize(); + return getter; + } + } + + /// + /// The field name. + /// + public string Name { get; } + + /// + /// Invoke to set the field/property value. + /// + public Action Set { + get { + Initialize(); + return setter; + } + } + + /// + /// The function to get the field value. + /// + private Func getter; + + /// + /// The function to set the field value. + /// + private Action setter; + + /// + /// The target type. + /// + private readonly Type type; + + internal LazyDetouredField(Type type, string name) { + this.type = type ?? throw new ArgumentNullException(nameof(type)); + Name = name ?? throw new ArgumentNullException(nameof(name)); + getter = null; + setter = null; + } + + /// + /// Initializes the getter and setter functions immediately if necessary. + /// + public void Initialize() { + if (getter == null && setter == null) { + var dt = PDetours.DetourField(Name); + getter = dt.Get; + setter = dt.Set; + } + } + + public override string ToString() { + return string.Format("LazyDetouredField[type={1},name={0}]", Name, type.FullName); + } + } +} diff --git a/mod/PLibCore/Detours/PDetours.cs b/mod/PLibCore/Detours/PDetours.cs new file mode 100644 index 0000000..15175b3 --- /dev/null +++ b/mod/PLibCore/Detours/PDetours.cs @@ -0,0 +1,600 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System; +using System.Reflection; +using System.Reflection.Emit; + +namespace PeterHan.PLib.Detours { + /// + /// Efficiently detours around many changes in the game by creating detour methods and + /// accessors which are resilient against many types of source compatible but binary + /// incompatible changes. + /// + public static class PDetours { + /// + /// Creates a dynamic detour method of the specified delegate type to wrap a base game + /// method with the same name as the delegate type. The dynamic method will + /// automatically adapt if optional parameters are added, filling in their default + /// values. + /// + /// The delegate type to be used to call the detour. + /// The target type. + /// The detour that will call the method with the name of the delegate type. + /// If the delegate does not match any valid target method. + public static D Detour(this Type type) where D : Delegate { + return Detour(type, typeof(D).Name); + } + + /// + /// Creates a dynamic detour method of the specified delegate type to wrap a base game + /// method with the specified name. The dynamic method will automatically adapt if + /// optional parameters are added, filling in their default values. + /// + /// The delegate type to be used to call the detour. + /// The target type. + /// The method name. + /// The detour that will call that method. + /// If the delegate does not match any valid target method. + public static D Detour(this Type type, string name) where D : Delegate { + if (type == null) + throw new ArgumentNullException(nameof(type)); + if (string.IsNullOrEmpty(name)) + throw new ArgumentNullException(nameof(name)); + var methods = type.GetMethods(PPatchTools.BASE_FLAGS | BindingFlags.Static | + BindingFlags.Instance); + // Determine delegate return type + var expected = DelegateInfo.Create(typeof(D)); + MethodInfo bestMatch = null; + int bestParamCount = int.MaxValue; + foreach (var method in methods) + if (method.Name == name) { + try { + var result = ValidateDelegate(expected, method, method.ReturnType); + int n = result.Length; + // Choose overload with fewest parameters to substitute + if (n < bestParamCount) { + bestParamCount = n; + bestMatch = method; + } + } catch (DetourException) { + // Keep looking + } + } + if (bestMatch == null) + throw new DetourException("No match found for {1}.{0}".F(name, type.FullName)); + return Detour(bestMatch); + } + + /// + /// Creates a dynamic detour method of the specified delegate type to wrap a base game + /// constructor. The dynamic method will automatically adapt if optional parameters + /// are added, filling in their default values. + /// + /// The delegate type to be used to call the detour. + /// The target type. + /// The detour that will call that type's constructor. + /// If the delegate does not match any valid target method. + public static D DetourConstructor(this Type type) where D : Delegate { + if (type == null) + throw new ArgumentNullException(nameof(type)); + var constructors = type.GetConstructors(PPatchTools.BASE_FLAGS | BindingFlags. + Instance); + // Determine delegate return type + var expected = DelegateInfo.Create(typeof(D)); + ConstructorInfo bestMatch = null; + int bestParamCount = int.MaxValue; + foreach (var constructor in constructors) + try { + var result = ValidateDelegate(expected, constructor, type); + int n = result.Length; + // Choose overload with fewest parameters to substitute + if (n < bestParamCount) { + bestParamCount = n; + bestMatch = constructor; + } + } catch (DetourException) { + // Keep looking + } + if (bestMatch == null) + throw new DetourException("No match found for {0} constructor".F(type. + FullName)); + return Detour(bestMatch); + } + + /// + /// Creates a dynamic detour method of the specified delegate type to wrap a base game + /// method with the specified name. The dynamic method will automatically adapt if + /// optional parameters are added, filling in their default values. + /// + /// This overload creates a lazy detour that only performs the expensive reflection + /// when it is first used. + /// + /// The delegate type to be used to call the detour. + /// The target type. + /// The method name. + /// The detour that will call that method. + /// If the delegate does not match any valid target method. + public static DetouredMethod DetourLazy(this Type type, string name) + where D : Delegate { + if (type == null) + throw new ArgumentNullException(nameof(type)); + if (string.IsNullOrEmpty(name)) + throw new ArgumentNullException(nameof(name)); + return new DetouredMethod(type, name); + } + + /// + /// Creates a dynamic detour method of the specified delegate type to wrap a base game + /// method with the specified name. The dynamic method will automatically adapt if + /// optional parameters are added, filling in their default values. + /// + /// The delegate type to be used to call the detour. + /// The target method to be called. + /// The detour that will call that method. + /// If the delegate does not match the target. + public static D Detour(this MethodInfo target) where D : Delegate { + if (target == null) + throw new ArgumentNullException(nameof(target)); + if (target.ContainsGenericParameters) + throw new ArgumentException("Generic types must have all parameters defined"); + var expected = DelegateInfo.Create(typeof(D)); + var parentType = target.DeclaringType; + var expectedParamTypes = expected.parameterTypes; + var actualParams = ValidateDelegate(expected, target, target.ReturnType); + int offset = target.IsStatic ? 0 : 1; + if (parentType == null) + throw new ArgumentException("Method is not declared by an actual type"); + // Method will be "declared" in the type of the target, as we are detouring around + // a method of that type + var caller = new DynamicMethod(target.Name + "_Detour", expected.returnType, + expectedParamTypes, parentType, true); + var generator = caller.GetILGenerator(); + LoadParameters(generator, actualParams, expectedParamTypes, offset); + if (parentType.IsValueType || target.IsStatic) + generator.Emit(OpCodes.Call, target); + else + generator.Emit(OpCodes.Callvirt, target); + generator.Emit(OpCodes.Ret); + FinishDynamicMethod(caller, actualParams, expectedParamTypes, offset); +#if DEBUG + PUtil.LogDebug("Created delegate {0} for method {1}.{2} with parameters [{3}]". + F(caller.Name, parentType.FullName, target.Name, actualParams.Join(","))); +#endif + return caller.CreateDelegate(typeof(D)) as D; + } + + /// + /// Creates a dynamic detour method of the specified delegate type to wrap a base game + /// constructor. The dynamic method will automatically adapt if optional parameters + /// are added, filling in their default values. + /// + /// The delegate type to be used to call the detour. + /// The target constructor to be called. + /// The detour that will call that constructor. + /// If the delegate does not match the target. + public static D Detour(this ConstructorInfo target) where D : Delegate { + if (target == null) + throw new ArgumentNullException(nameof(target)); + if (target.ContainsGenericParameters) + throw new ArgumentException("Generic types must have all parameters defined"); + if (target.IsStatic) + throw new ArgumentException("Static constructors cannot be called manually"); + var expected = DelegateInfo.Create(typeof(D)); + var parentType = target.DeclaringType; + var expectedParamTypes = expected.parameterTypes; + var actualParams = ValidateDelegate(expected, target, parentType); + if (parentType == null) + throw new ArgumentException("Method is not declared by an actual type"); + // Method will be "declared" in the type of the target, as we are detouring around + // a constructor of that type + var caller = new DynamicMethod("Constructor_Detour", expected.returnType, + expectedParamTypes, parentType, true); + var generator = caller.GetILGenerator(); + LoadParameters(generator, actualParams, expectedParamTypes, 0); + generator.Emit(OpCodes.Newobj, target); + generator.Emit(OpCodes.Ret); + FinishDynamicMethod(caller, actualParams, expectedParamTypes, 0); +#if DEBUG + PUtil.LogDebug("Created delegate {0} for constructor {1} with parameters [{2}]". + F(caller.Name, parentType.FullName, actualParams.Join(","))); +#endif + return caller.CreateDelegate(typeof(D)) as D; + } + + /// + /// Creates dynamic detour methods to wrap a base game field or property with the + /// specified name. The detour will still work even if the field is converted to a + /// source compatible property and vice versa. + /// + /// The type of the parent class. + /// The type of the field or property element. + /// The name of the field or property to be accessed. + /// A detour element that wraps the field or property with common getter and + /// setter delegates which will work on both types. + public static IDetouredField DetourField(string name) { + if (string.IsNullOrEmpty(name)) + throw new ArgumentNullException(nameof(name)); + var pt = typeof(P); + var field = pt.GetField(name, PPatchTools.BASE_FLAGS | BindingFlags. + Static | BindingFlags.Instance); + IDetouredField d; + if (field == null) { + try { + var property = pt.GetProperty(name, PPatchTools.BASE_FLAGS | + BindingFlags.Static | BindingFlags.Instance); + if (property == null) + throw new DetourException("Unable to find {0} on type {1}". + F(name, typeof(P).FullName)); + d = DetourProperty(property); + } catch (AmbiguousMatchException) { + throw new DetourException("Unable to find {0} on type {1}". + F(name, typeof(P).FullName)); + } + } else { + if (pt.IsValueType || (pt.IsByRef && (pt.GetElementType()?.IsValueType ?? + false))) + throw new ArgumentException("For accessing struct fields, use DetourStructField"); + d = DetourField(field); + } + return d; + } + + /// + /// Creates dynamic detour methods to wrap a base game field or property with the + /// specified name. The detour will still work even if the field is converted to a + /// source compatible property and vice versa. + /// + /// This overload creates a lazy detour that only performs the expensive reflection + /// when it is first used. + /// + /// The type of the parent class. + /// The type of the field or property element. + /// The name of the field or property to be accessed. + /// A detour element that wraps the field or property with common getter and + /// setter delegates which will work on both types. + public static IDetouredField DetourFieldLazy(string name) { + if (string.IsNullOrEmpty(name)) + throw new ArgumentNullException(nameof(name)); + return new LazyDetouredField(typeof(P), name); + } + + /// + /// Creates dynamic detour methods to wrap a base game field with the specified name. + /// + /// The type of the parent class. + /// The type of the field element. + /// The field which will be accessed. + /// A detour element that wraps the field with a common interface matching + /// that of a detoured property. + private static IDetouredField DetourField(FieldInfo target) { + if (target == null) + throw new ArgumentNullException(nameof(target)); + var parentType = target.DeclaringType; + string name = target.Name; + if (parentType != typeof(P)) + throw new ArgumentException("Parent type does not match delegate to be created"); + var getter = new DynamicMethod(name + "_Detour_Get", typeof(T), new[] { + typeof(P) + }, true); + var generator = getter.GetILGenerator(); + // Getter will load the first argument and use ldfld/ldsfld + if (target.IsStatic) + generator.Emit(OpCodes.Ldsfld, target); + else { + generator.Emit(OpCodes.Ldarg_0); + generator.Emit(OpCodes.Ldfld, target); + } + generator.Emit(OpCodes.Ret); + DynamicMethod setter; + if (target.IsInitOnly) + // Handle readonly fields + setter = null; + else { + setter = new DynamicMethod(name + "_Detour_Set", null, new[] { + typeof(P), typeof(T) + }, true); + generator = setter.GetILGenerator(); + // Setter will load both arguments and use stfld/stsfld (argument 1 is ignored + // for static fields) + if (target.IsStatic) { + generator.Emit(OpCodes.Ldarg_1); + generator.Emit(OpCodes.Stsfld, target); + } else { + generator.Emit(OpCodes.Ldarg_0); + generator.Emit(OpCodes.Ldarg_1); + generator.Emit(OpCodes.Stfld, target); + } + generator.Emit(OpCodes.Ret); + } +#if DEBUG + PUtil.LogDebug("Created delegate for field {0}.{1} with type {2}". + F(parentType.FullName, target.Name, typeof(T).FullName)); +#endif + return new DetouredField(name, getter.CreateDelegate(typeof(Func)) as + Func, setter?.CreateDelegate(typeof(Action)) as Action); + } + + /// + /// Creates dynamic detour methods to wrap a base game property with the specified name. + /// + /// The type of the parent class. + /// The type of the property element. + /// The property which will be accessed. + /// A detour element that wraps the property with a common interface matching + /// that of a detoured field. + /// If the property has indexers. + private static IDetouredField DetourProperty(PropertyInfo target) { + if (target == null) + throw new ArgumentNullException(nameof(target)); + var parentType = target.DeclaringType; + string name = target.Name; + if (parentType != typeof(P)) + throw new ArgumentException("Parent type does not match delegate to be created"); + var indexes = target.GetIndexParameters(); + if (indexes != null && indexes.Length > 0) + throw new DetourException("Cannot detour on properties with index arguments"); + DynamicMethod getter, setter; + var getMethod = target.GetGetMethod(true); + if (target.CanRead && getMethod != null) { + getter = new DynamicMethod(name + "_Detour_Get", typeof(T), new[] { + typeof(P) + }, true); + var generator = getter.GetILGenerator(); + // Getter will load the first argument and call the property getter + if (!getMethod.IsStatic) + generator.Emit(OpCodes.Ldarg_0); + generator.Emit(OpCodes.Call, getMethod); + generator.Emit(OpCodes.Ret); + } else + getter = null; + var setMethod = target.GetSetMethod(true); + if (target.CanWrite && setMethod != null) { + setter = new DynamicMethod(name + "_Detour_Set", null, new[] { + typeof(P), typeof(T) + }, true); + var generator = setter.GetILGenerator(); + // Setter will load both arguments and call property setter (argument 1 is + // ignored for static properties) + if (!setMethod.IsStatic) + generator.Emit(OpCodes.Ldarg_0); + generator.Emit(OpCodes.Ldarg_1); + generator.Emit(OpCodes.Call, setMethod); + generator.Emit(OpCodes.Ret); + } else + setter = null; +#if DEBUG + PUtil.LogDebug("Created delegate for property {0}.{1} with type {2}". + F(parentType.FullName, target.Name, typeof(T).FullName)); +#endif + return new DetouredField(name, getter?.CreateDelegate(typeof(Func)) as + Func, setter?.CreateDelegate(typeof(Action)) as Action); + } + + /// + /// Creates dynamic detour methods to wrap a base game struct field with the specified + /// name. For static struct fields, use the regular DetourField. + /// + /// The type of the field element. + /// The struct type which will be accessed. + /// The name of the struct field to be accessed. + /// A detour element that wraps the field with a common interface matching + /// that of a detoured property. + public static IDetouredField DetourStructField(this Type parentType, + string name) { + if (parentType == null) + throw new ArgumentNullException(nameof(parentType)); + if (string.IsNullOrEmpty(name)) + throw new ArgumentNullException(nameof(name)); + var target = parentType.GetField(name, PPatchTools.BASE_FLAGS | BindingFlags. + Instance); + if (target == null) + throw new DetourException("Unable to find {0} on type {1}".F(name, parentType. + FullName)); + var getter = new DynamicMethod(name + "_Detour_Get", typeof(T), new[] { + typeof(object) + }, true); + var generator = getter.GetILGenerator(); + // Getter will load the first argument, unbox. and use ldfld + generator.Emit(OpCodes.Ldarg_0); + generator.Emit(OpCodes.Unbox, parentType); + generator.Emit(OpCodes.Ldfld, target); + generator.Emit(OpCodes.Ret); + DynamicMethod setter; + if (target.IsInitOnly) + // Handle readonly fields + setter = null; + else { + setter = new DynamicMethod(name + "_Detour_Set", null, new[] { + typeof(object), typeof(T) + }, true); + generator = setter.GetILGenerator(); + // Setter will load both arguments, unbox and use stfld + generator.Emit(OpCodes.Ldarg_0); + generator.Emit(OpCodes.Unbox, parentType); + generator.Emit(OpCodes.Ldarg_1); + generator.Emit(OpCodes.Stfld, target); + generator.Emit(OpCodes.Ret); + } +#if DEBUG + PUtil.LogDebug("Created delegate for struct field {0}.{1} with type {2}". + F(parentType.FullName, target.Name, typeof(T).FullName)); +#endif + return new DetouredField(name, getter.CreateDelegate(typeof( + Func)) as Func, setter?.CreateDelegate(typeof( + Action)) as Action); + } + + /// + /// Generates the required method parameters for the dynamic detour method. + /// + /// The method where the parameters will be defined. + /// The actual parameters required. + /// The parameters provided. + /// The offset to start loading (0 = static, 1 = instance). + private static void FinishDynamicMethod(DynamicMethod caller, + ParameterInfo[] actualParams, Type[] expectedParams, int offset) { + int n = expectedParams.Length; + if (offset > 0) + caller.DefineParameter(1, ParameterAttributes.None, "this"); + for (int i = offset; i < n; i++) { + var oldParam = actualParams[i - offset]; + caller.DefineParameter(i + 1, oldParam.Attributes, oldParam.Name); + } + } + + /// + /// Generates instructions to load arguments or default values onto the stack in a + /// detour method. + /// + /// The method where the calls will be added. + /// The actual parameters required. + /// The parameters provided. + /// The offset to start loading (0 = static, 1 = instance). + private static void LoadParameters(ILGenerator generator, ParameterInfo[] actualParams, + Type[] expectedParams, int offset) { + // Load the known method arguments onto the stack + int n = expectedParams.Length, need = actualParams.Length + offset; + if (n > 0) + generator.Emit(OpCodes.Ldarg_0); + if (n > 1) + generator.Emit(OpCodes.Ldarg_1); + if (n > 2) + generator.Emit(OpCodes.Ldarg_2); + if (n > 3) + generator.Emit(OpCodes.Ldarg_3); + for (int i = 4; i < n; i++) + generator.Emit(OpCodes.Ldarg_S, i); + // Load the rest as defaults + for (int i = n; i < need; i++) { + var param = actualParams[i - offset]; + PTranspilerTools.GenerateDefaultLoad(generator, param.ParameterType, param. + DefaultValue); + } + } + + /// + /// Verifies that the delegate signature provided in dst can be dynamically mapped to + /// the method provided by src, with the possible addition of optional parameters set + /// to their default values. + /// + /// The method return type and parameter types expected. + /// The method to be called. + /// The type of the method or constructor's return value. + /// The parameters used in the call to the actual method. + /// If the delegate does not match the target. + private static ParameterInfo[] ValidateDelegate(DelegateInfo expected, + MethodBase actual, Type actualReturn) { + var parameterTypes = expected.parameterTypes; + var returnType = expected.returnType; + // Validate return types + if (!returnType.IsAssignableFrom(actualReturn)) + throw new DetourException("Return type {0} cannot be converted to type {1}". + F(actualReturn.FullName, returnType.FullName)); + // Do not allow methods declared in not yet closed generic types + var baseType = actual.DeclaringType; + if (baseType == null) + throw new ArgumentException("Method is not declared by an actual type"); + if (baseType.ContainsGenericParameters) + throw new DetourException(("Method parent type {0} must have all " + + "generic parameters defined").F(baseType.FullName)); + // Validate parameter types + string actualName = baseType.FullName + "." + actual.Name; + var actualParams = actual.GetParameters(); + int n = actualParams.Length, check = parameterTypes.Length; + Type[] actualParamTypes, currentTypes = new Type[n]; + bool noThisPointer = actual.IsStatic || actual.IsConstructor; + for (int i = 0; i < n; i++) + currentTypes[i] = actualParams[i].ParameterType; + if (noThisPointer) + actualParamTypes = currentTypes; + else { + actualParamTypes = PTranspilerTools.PushDeclaringType(currentTypes, baseType); + n++; + } + if (check > n) + throw new DetourException(("Method {0} has only {1:D} parameters, but " + + "{2:D} were supplied").F(actual.ToString(), n, check)); + // Check up to the number we have + for (int i = 0; i < check; i++) { + Type have = actualParamTypes[i], want = parameterTypes[i]; + if (!have.IsAssignableFrom(want)) + throw new DetourException(("Argument {0:D} for method {3} cannot be " + + "converted from {1} to {2}").F(i, have.FullName, want.FullName, + actualName)); + } + // Any remaining parameters must be optional + int offset = noThisPointer ? 0 : 1; + for (int i = check; i < n; i++) { + var cParam = actualParams[i - offset]; + if (!cParam.IsOptional) + throw new DetourException(("New argument {0:D} for method {1} ({2}) " + + "is not optional").F(i, actualName, cParam.ParameterType.FullName)); + } + return actualParams; + } + + /// + /// Stores information about a delegate. + /// + private sealed class DelegateInfo { + /// + /// Creates delegate information on the specified delegate type. + /// + /// The delegate type to wrap. + /// Information about that delegate's return and parameter types. + public static DelegateInfo Create(Type delegateType) { + if (delegateType == null) + throw new ArgumentNullException(nameof(delegateType)); + var expected = delegateType.GetMethodSafe("Invoke", false, PPatchTools. + AnyArguments); + if (expected == null) + throw new ArgumentException("Invalid delegate type: " + delegateType); + return new DelegateInfo(delegateType, expected.GetParameterTypes(), + expected.ReturnType); + } + + /// + /// The delegate's type. + /// + private readonly Type delegateType; + + /// + /// The delegate's parameter types. + /// + public readonly Type[] parameterTypes; + + /// + /// The delegate's return types. + /// + public readonly Type returnType; + + private DelegateInfo(Type delegateType, Type[] parameterTypes, Type returnType) { + this.delegateType = delegateType; + this.parameterTypes = parameterTypes; + this.returnType = returnType; + } + + public override string ToString() { + return "DelegateInfo[delegate={0},return={1},parameters={2}]".F(delegateType, + returnType, parameterTypes.Join()); + } + } + } +} diff --git a/mod/PLibCore/ExtensionMethods.cs b/mod/PLibCore/ExtensionMethods.cs new file mode 100644 index 0000000..c368892 --- /dev/null +++ b/mod/PLibCore/ExtensionMethods.cs @@ -0,0 +1,285 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using System; +using System.Reflection; +using System.Text; +using UnityEngine; + +namespace PeterHan.PLib.Core { + /// + /// Extension methods to make life easier! + /// + public static class ExtensionMethods { + /// + /// Shorthand for string.Format() which can be invoked directly on the message. + /// + /// The format template message. + /// The substitutions to be included. + /// The formatted string. + public static string F(this string message, params object[] args) { + return string.Format(message, args); + } + + /// + /// Retrieves a component, but returns null if the GameObject is disposed. + /// + /// The component type to retrieve. + /// The GameObject that hosts the component. + /// The requested component, or null if it does not exist + public static T GetComponentSafe(this GameObject obj) where T : Component { +#pragma warning disable IDE0031 // Use null propagation + // == operator is overloaded on GameObject to be equal to null if destroyed + return (obj == null) ? null : obj.GetComponent(); +#pragma warning restore IDE0031 // Use null propagation + } + + /// + /// Gets the assembly name of an assembly. + /// + /// The assembly to query. + /// The assembly name, or null if assembly is null. + public static string GetNameSafe(this Assembly assembly) { + return assembly?.GetName()?.Name; + } + + /// + /// Gets the file version of the specified assembly. + /// + /// The assembly to query + /// The AssemblyFileVersion of that assembly, or null if it could not be determined. + public static string GetFileVersion(this Assembly assembly) { + // Mod version + var fileVersions = assembly.GetCustomAttributes(typeof( + AssemblyFileVersionAttribute), true); + string modVersion = null; + if (fileVersions != null && fileVersions.Length > 0) { + // Retrieves the "File Version" attribute + var assemblyFileVersion = (AssemblyFileVersionAttribute)fileVersions[0]; + if (assemblyFileVersion != null) + modVersion = assemblyFileVersion.Version; + } + return modVersion; + } + + /// + /// Coerces a floating point number into the specified range. + /// + /// The original number. + /// The minimum value (inclusive). + /// The maximum value (inclusive). + /// The nearest value between minimum and maximum inclusive to value. + public static double InRange(this double value, double min, double max) { + double result = value; + if (result < min) result = min; + if (result > max) result = max; + return result; + } + + /// + /// Coerces a floating point number into the specified range. + /// + /// The original number. + /// The minimum value (inclusive). + /// The maximum value (inclusive). + /// The nearest value between minimum and maximum inclusive to value. + public static float InRange(this float value, float min, float max) { + float result = value; + if (result < min) result = min; + if (result > max) result = max; + return result; + } + + /// + /// Coerces an integer into the specified range. + /// + /// The original number. + /// The minimum value (inclusive). + /// The maximum value (inclusive). + /// The nearest value between minimum and maximum inclusive to value. + public static int InRange(this int value, int min, int max) { + int result = value; + if (result < min) result = min; + if (result > max) result = max; + return result; + } + + /// + /// Checks to see if an object is falling. + /// + /// The object to check. + /// true if it is falling, or false otherwise. + public static bool IsFalling(this GameObject obj) { + int cell = Grid.PosToCell(obj); + return obj.TryGetComponent(out Navigator navigator) && !navigator.IsMoving() && + Grid.IsValidCell(cell) && Grid.IsValidCell(Grid.CellBelow(cell)) && + !navigator.NavGrid.NavTable.IsValid(cell, navigator.CurrentNavType); + } + + /// + /// Checks to see if a floating point value is NaN or infinite. + /// + /// The value to check. + /// true if it is NaN, PositiveInfinity, or NegativeInfinity, or false otherwise. + public static bool IsNaNOrInfinity(this double value) { + return double.IsNaN(value) || double.IsInfinity(value); + } + + /// + /// Checks to see if a floating point value is NaN or infinite. + /// + /// The value to check. + /// true if it is NaN, PositiveInfinity, or NegativeInfinity, or false otherwise. + public static bool IsNaNOrInfinity(this float value) { + return float.IsNaN(value) || float.IsInfinity(value); + } + + /// + /// Checks to see if a building is usable. + /// + /// The building component to check. + /// true if it is usable (enabled, not broken, not overheated), or false otherwise. + public static bool IsUsable(this GameObject building) { + return building.TryGetComponent(out Operational op) && op.IsFunctional; + } + + /// + /// Creates a string joining the members of an enumerable. + /// + /// The values to join. + /// The delimiter to use between values. + /// A string consisting of each value in order, with the delimiter in between. + public static string Join(this System.Collections.IEnumerable values, + string delimiter = ",") { + var ret = new StringBuilder(128); + bool first = true; + // Append all, but skip comma if the first time + foreach (var value in values) { + if (!first) + ret.Append(delimiter); + ret.Append(value); + first = false; + } + return ret.ToString(); + } + + /// + /// Patches a method manually. + /// + /// The Harmony instance. + /// The class to modify. + /// The method to patch. + /// The prefix to apply, or null if none. + /// The postfix to apply, or null if none. + public static void Patch(this Harmony instance, Type type, string methodName, + HarmonyMethod prefix = null, HarmonyMethod postfix = null) { + if (type == null) + throw new ArgumentNullException(nameof(type)); + if (string.IsNullOrEmpty(methodName)) + throw new ArgumentNullException(nameof(methodName)); + // Fetch the method + try { + var method = type.GetMethod(methodName, PPatchTools.BASE_FLAGS | BindingFlags. + Static | BindingFlags.Instance); + if (method != null) + instance.Patch(method, prefix, postfix); + else + PUtil.LogWarning("Unable to find method {0} on type {1}".F(methodName, + type.FullName)); + } catch (AmbiguousMatchException e) { +#if DEBUG + PUtil.LogWarning("When patching candidate method {0}.{1}:".F(type.FullName, + methodName)); +#endif + PUtil.LogException(e); + } + } + + /// + /// Patches a constructor manually. + /// + /// The Harmony instance. + /// The class to modify. + /// The constructor's argument types. + /// The prefix to apply, or null if none. + /// The postfix to apply, or null if none. + public static void PatchConstructor(this Harmony instance, Type type, + Type[] arguments, HarmonyMethod prefix = null, HarmonyMethod postfix = null) { + if (type == null) + throw new ArgumentNullException(nameof(type)); + // Fetch the constructor + try { + var cons = type.GetConstructor(PPatchTools.BASE_FLAGS | BindingFlags.Static | + BindingFlags.Instance, null, arguments, null); + if (cons != null) + instance.Patch(cons, prefix, postfix); + else + PUtil.LogWarning("Unable to find constructor on type {0}".F(type. + FullName)); + } catch (ArgumentException e) { + PUtil.LogException(e); + } + } + + /// + /// Patches a method manually with a transpiler. + /// + /// The Harmony instance. + /// The class to modify. + /// The method to patch. + /// The transpiler to apply. + public static void PatchTranspile(this Harmony instance, Type type, + string methodName, HarmonyMethod transpiler) { + if (type == null) + throw new ArgumentNullException(nameof(type)); + if (string.IsNullOrEmpty(methodName)) + throw new ArgumentNullException(nameof(methodName)); + // Fetch the method + try { + var method = type.GetMethod(methodName, PPatchTools.BASE_FLAGS | + BindingFlags.Static | BindingFlags.Instance); + if (method != null) + instance.Patch(method, null, null, transpiler); + else + PUtil.LogWarning("Unable to find method {0} on type {1}".F(methodName, + type.FullName)); + } catch (AmbiguousMatchException e) { + PUtil.LogException(e); + } catch (FormatException e) { + PUtil.LogWarning("Unable to transpile method {0}: {1}".F(methodName, + e.Message)); + } + } + + /// + /// Sets a game object's parent. + /// + /// The game object to modify. + /// The new parent object. + /// The game object, for call chaining. + public static GameObject SetParent(this GameObject child, GameObject parent) { + if (child == null) + throw new ArgumentNullException(nameof(child)); +#pragma warning disable IDE0031 // Use null propagation + child.transform.SetParent((parent == null) ? null : parent.transform, false); +#pragma warning restore IDE0031 // Use null propagation + return child; + } + } +} diff --git a/mod/PLibCore/IPLibRegistry.cs b/mod/PLibCore/IPLibRegistry.cs new file mode 100644 index 0000000..cae8753 --- /dev/null +++ b/mod/PLibCore/IPLibRegistry.cs @@ -0,0 +1,68 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System.Collections.Generic; + +namespace PeterHan.PLib.Core { + /// + /// An interface used for both local and remote PLib registry instances. + /// + public interface IPLibRegistry { + /// + /// Data shared between mods in key value pairs. + /// + IDictionary ModData { get; } + + /// + /// Adds a candidate version of a forwarded component. + /// + /// The instance of the component to add. + void AddCandidateVersion(PForwardedComponent instance); + + /// + /// Gets the latest version of a forwarded component of PLib (or another mod). + /// + /// The component ID to look up. + /// The latest version of that component, or a forwarded proxy of the + /// component if functionality is provided by another mod. + PForwardedComponent GetLatestVersion(string id); + + /// + /// Gets the shared data for a particular component. + /// + /// The component ID that holds the data. + /// The shared data for components with that ID, or null if no component by + /// that name was found, or if the data is unset. + object GetSharedData(string id); + + /// + /// Gets all registered forwarded components for the given ID. + /// + /// The component ID to look up. + /// All registered components with that ID, with forwarded proxies for any + /// whose functionality is provided by another mod. + IEnumerable GetAllComponents(string id); + + /// + /// Sets the shared data for a particular component. + /// + /// The component ID that holds the data. + /// The new shared data value. + void SetSharedData(string id, object data); + } +} diff --git a/mod/PLibCore/IRefreshUserMenu.cs b/mod/PLibCore/IRefreshUserMenu.cs new file mode 100644 index 0000000..18a531e --- /dev/null +++ b/mod/PLibCore/IRefreshUserMenu.cs @@ -0,0 +1,31 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +namespace PeterHan.PLib.Core { + /// + /// Implemented by classes which want to use the utility user menu refresh to save some + /// boilerplate code. + /// + public interface IRefreshUserMenu { + /// + /// Called when the user button menu in the info panel is refreshed. Since the + /// arguments are always null, no parameter is passed. + /// + void OnRefreshUserMenu(); + } +} diff --git a/mod/PLibCore/PForwardedComponent.cs b/mod/PLibCore/PForwardedComponent.cs new file mode 100644 index 0000000..bce67b0 --- /dev/null +++ b/mod/PLibCore/PForwardedComponent.cs @@ -0,0 +1,292 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using Newtonsoft.Json; +using System; +using System.IO; +using System.Reflection; +using System.Text; + +namespace PeterHan.PLib.Core { + /// + /// A library component that is forwarded across multiple assemblies, to allow only the + /// latest version available on the system to run. Provides methods to marshal some + /// objects across the assembly boundaries. + /// + public abstract class PForwardedComponent : IComparable { + /// + /// The default maximum serialization depth for marshaling data. + /// + public const int MAX_DEPTH = 8; + + /// + /// The data stored in this object. It can be retrieved, with optional round trip + /// serialization, by the instantiated version of this component. + /// + protected virtual object InstanceData { get; set; } + + /// + /// The ID used by PLib for this component. + /// + /// This method is non-virtual for a reason, as the ID is sometimes only available + /// on methods of type object, so GetType().FullName is used directly there. + /// + public string ID { + get { + return GetType().FullName; + } + } + + /// + /// The JSON serialization settings to be used if the Data is marshaled across + /// assembly boundaries. + /// + protected JsonSerializer SerializationSettings { get; set; } + + /// + /// Retrieves the version of the component provided by this assembly. + /// + public abstract Version Version { get; } + + /// + /// Whether this object has been registered. + /// + private volatile bool registered; + + /// + /// Serializes access to avoid race conditions when registering this component. + /// + private readonly object candidateLock; + + protected PForwardedComponent() { + candidateLock = new object(); + InstanceData = null; + registered = false; + SerializationSettings = new JsonSerializer() { + DateTimeZoneHandling = DateTimeZoneHandling.RoundtripKind, + Culture = System.Globalization.CultureInfo.InvariantCulture, + MaxDepth = MAX_DEPTH + }; + } + + /// + /// Called only on the first instance of a particular component to be registered. + /// For some particular components that need very early patches, this call might be + /// required to initialize state before the rest of the forwarded components are + /// initialized. However, this call might occur on a version that is not the latest of + /// this component in the system, or on an instance that will not be instantiated or + /// initialized by the other callbacks. + /// + /// The Harmony instance to use for patching if necessary. + public virtual void Bootstrap(Harmony plibInstance) { } + + public int CompareTo(PForwardedComponent other) { + return Version.CompareTo(other.Version); + } + + /// + /// Initializes this component. Only called on the version that is selected as the + /// latest. + /// + /// The Harmony instance to use for patching if necessary. + /// The initialized instance. + internal virtual object DoInitialize(Harmony plibInstance) { + Initialize(plibInstance); + return this; + } + + /// + /// Gets the data from this component as a specific type. Only works if the type is + /// shared across all mods (in some shared assembly's memory space) such as types in + /// System or the base game. + /// + /// The data type to retrieve. + /// The default value if the instance data is unset. + /// The data, or defValue if the instance data has not been set. + public T GetInstanceData(T defValue = default) { + if (!(InstanceData is T outData)) + outData = defValue; + return outData; + } + + /// + /// Gets the data from this component, serialized to the specified type. The data is + /// retrieved from the base component, serialized with JSON, and reconstituted as type + /// T in the memory space of the caller. + /// + /// The target type must exist and be a [JsonObject] in both this assembly and the + /// target component's assembly. + /// + /// This method is somewhat slow and memory intensive, and should be used sparingly. + /// + /// The data type to retrieve and into which to convert. + /// The default value if the instance data is unset. + /// The data, or defValue if the instance data has not been set or cannot be serialized. + public T GetInstanceDataSerialized(T defValue = default) { + var remoteData = InstanceData; + T result = defValue; + using (var buffer = new MemoryStream(1024)) { + try { + var writer = new StreamWriter(buffer, Encoding.UTF8); + SerializationSettings.Serialize(writer, remoteData); + writer.Flush(); + buffer.Position = 0L; + var reader = new StreamReader(buffer, Encoding.UTF8); + if (SerializationSettings.Deserialize(reader, typeof(T)) is T decoded) + result = decoded; + } catch (JsonException e) { + PUtil.LogError("Unable to serialize instance data for component " + ID + + ":"); + PUtil.LogException(e); + result = defValue; + } + } + return result; + } + + /// + /// Gets the shared data between components with this ID as a specific type. Only works + /// if the type is shared across all mods (in some shared assembly's memory space) such + /// as types in System or the base game. + /// + /// The data type to retrieve. + /// The default value if the shared data is unset. + /// The data, or defValue if the shared data has not been set. + public T GetSharedData(T defValue = default) { + if (!(PRegistry.Instance.GetSharedData(ID) is T outData)) + outData = defValue; + return outData; + } + + /// + /// Gets the shared data between components with this ID, serialized to the specified + /// type. The shared data is retrieved, serialized with JSON, and reconstituted as type + /// T in the memory space of the caller. + /// + /// The target type must exist and be a [JsonObject] in both this assembly and the + /// target component's assembly. + /// + /// This method is somewhat slow and memory intensive, and should be used sparingly. + /// + /// The data type to retrieve and into which to convert. + /// The default value if the shared data is unset. + /// The data, or defValue if the shared data has not been set or cannot be serialized. + public T GetSharedDataSerialized(T defValue = default) { + var remoteData = PRegistry.Instance.GetSharedData(ID); + T result = defValue; + using (var buffer = new MemoryStream(1024)) { + try { + SerializationSettings.Serialize(new StreamWriter(buffer, Encoding.UTF8), + remoteData); + buffer.Position = 0L; + if (SerializationSettings.Deserialize(new StreamReader(buffer, + Encoding.UTF8), typeof(T)) is T decoded) + result = decoded; + } catch (JsonException e) { + PUtil.LogError("Unable to serialize shared data for component " + ID + + ":"); + PUtil.LogException(e); + result = defValue; + } + } + return result; + } + + /// + /// Gets the assembly which provides this component. + /// + /// The assembly which owns this component. + public virtual Assembly GetOwningAssembly() { + return GetType().Assembly; + } + + /// + /// Initializes this component. Only called on the version that is selected as the + /// latest. Component initialization order is undefined, so anything relying on another + /// component cannot be used until PostInitialize. + /// + /// The Harmony instance to use for patching if necessary. + public abstract void Initialize(Harmony plibInstance); + + /// + /// Invokes the Process method on all registered components of this type. + /// + /// The operation to pass to Process. + /// The arguments to pass to Process. + protected void InvokeAllProcess(uint operation, object args) { + var allComponents = PRegistry.Instance.GetAllComponents(ID); + if (allComponents != null) + foreach (var component in allComponents) + component.Process(operation, args); + } + + /// + /// Gets a HarmonyMethod instance for manual patching using a method from this class. + /// + /// The method name. + /// A reference to that method as a HarmonyMethod for patching. + public HarmonyMethod PatchMethod(string name) { + return new HarmonyMethod(GetType(), name); + } + + /// + /// Initializes this component. Only called on the version that is selected as the + /// latest. Other components have been initialized when this method is called. + /// + /// The Harmony instance to use for patching if necessary. + public virtual void PostInitialize(Harmony plibInstance) { } + + /// + /// Called on demand by the initialized instance to run processing in all other + /// instances. + /// + /// The operation to perform. The meaning of this parameter + /// varies by component. + /// The arguments for processing. + public virtual void Process(uint operation, object args) { } + + /// + /// Registers this component into the list of versions available for forwarding. This + /// method is thread safe. If this component instance is already registered, it will + /// not be registered again. + /// + /// true if the component was registered, or false if it was already registered. + protected bool RegisterForForwarding() { + bool result = false; + lock (candidateLock) { + if (!registered) { + PUtil.InitLibrary(false); + PRegistry.Instance.AddCandidateVersion(this); + registered = result = true; + } + } + return result; + } + + /// + /// Sets the shared data between components with this ID. Only works if the type is + /// shared across all mods (in some shared assembly's memory space) such as types in + /// System or the base game. + /// + /// The new value for the shared data. + public void SetSharedData(object value) { + PRegistry.Instance.SetSharedData(ID, value); + } + } +} diff --git a/mod/PLibCore/PGameUtils.cs b/mod/PLibCore/PGameUtils.cs new file mode 100644 index 0000000..f9fb9e1 --- /dev/null +++ b/mod/PLibCore/PGameUtils.cs @@ -0,0 +1,143 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using UnityEngine; + +namespace PeterHan.PLib.Core { + /// + /// Utility and helper functions to perform common game-related (not UI) tasks. + /// + public static class PGameUtils { + /// + /// Centers and selects an entity. + /// + /// The entity to center and focus. + public static void CenterAndSelect(KMonoBehaviour entity) { + if (entity != null && entity.TryGetComponent(out KSelectable select)) + SelectTool.Instance.SelectAndFocus(entity.transform.position, select, Vector3. + zero); + } + + /// + /// Copies the sounds from one animation to another animation. + /// + /// The destination anim file name. + /// The source anim file name. + public static void CopySoundsToAnim(string dstAnim, string srcAnim) { + if (string.IsNullOrEmpty(dstAnim)) + throw new ArgumentNullException(nameof(dstAnim)); + if (string.IsNullOrEmpty(srcAnim)) + throw new ArgumentNullException(nameof(srcAnim)); + var anim = Assets.GetAnim(dstAnim); + if (anim != null) { + var audioSheet = GameAudioSheets.Get(); + var animData = anim.GetData(); + // For each anim in the kanim, look for existing sound events under the old + // anim's file name + for (int i = 0; i < animData.animCount; i++) { + string animName = animData.GetAnim(i)?.name ?? ""; + var events = audioSheet.GetEvents(srcAnim + "." + animName); + if (events != null) { +#if DEBUG + PUtil.LogDebug("Adding {0:D} audio event(s) to anim {1}.{2}".F(events. + Count, dstAnim, animName)); +#endif + audioSheet.events[dstAnim + "." + animName] = events; + } + } + } else + PUtil.LogWarning("Destination animation \"{0}\" not found!".F(dstAnim)); + } + + /// + /// Creates a popup message at the specified cell location on the Move layer. + /// + /// The image to display, likely from PopFXManager.Instance. + /// The text to display. + /// The cell location to create the message. + public static void CreatePopup(Sprite image, string text, int cell) { + CreatePopup(image, text, Grid.CellToPosCBC(cell, Grid.SceneLayer.Move)); + } + + /// + /// Creates a popup message at the specified location. + /// + /// The image to display, likely from PopFXManager.Instance. + /// The text to display. + /// The position to create the message. + public static void CreatePopup(Sprite image, string text, Vector3 position) { + PopFXManager.Instance.SpawnFX(image, text, null, position); + } + + /// + /// Creates a default user menu handler for a class implementing IRefreshUserMenu. + /// + /// The class to handle events. + /// A handler which can be used to Subscribe for RefreshUserMenu events. + public static EventSystem.IntraObjectHandler CreateUserMenuHandler() + where T : Component, IRefreshUserMenu { + return new Action((T target, object ignore) => { +#if DEBUG + PUtil.LogDebug("OnRefreshUserMenu<{0}> on {1}".F(typeof(T).Name, target)); +#endif + target.OnRefreshUserMenu(); + }); + } + + /// + /// Retrieves an object layer by its name, resolving the value at runtime to handle + /// differences in the layer enum. This method is slower than a direct lookup - + /// consider caching the result. + /// + /// The name of the layer (use nameof()!) + /// The default value (use the value at compile time) + /// The value to use for this object layer. + public static ObjectLayer GetObjectLayer(string name, ObjectLayer defValue) { + if (!Enum.TryParse(name, out ObjectLayer value)) + value = defValue; + return value; + } + + /// + /// Highlights an entity. Use Color.black to unhighlight it. + /// + /// The entity to highlight. + /// The color to highlight it. + public static void HighlightEntity(Component entity, Color highlightColor) { + if (entity != null && entity.TryGetComponent(out KAnimControllerBase kbac)) + kbac.HighlightColour = highlightColor; + } + + /// + /// Plays a sound effect. + /// + /// The sound effect name to play. + /// The position where the sound is generated. + public static void PlaySound(string name, Vector3 position) { + SoundEvent.PlayOneShot(GlobalAssets.GetSound(name), position); + } + + /// + /// Saves the current list of mods. + /// + public static void SaveMods() { + Global.Instance.modManager.Save(); + } + } +} diff --git a/mod/PLibCore/PLibCore.csproj b/mod/PLibCore/PLibCore.csproj new file mode 100644 index 0000000..9203d6c --- /dev/null +++ b/mod/PLibCore/PLibCore.csproj @@ -0,0 +1,24 @@ + + + + PLib Core + PLib.Core + 4.11.0.0 + false + PeterHan.PLib.Core + 4.11.0.0 + false + false + false + true + Vanilla;Mergedown + + + bin\$(Platform)\Release\PLibCore.xml + + + + + + + diff --git a/mod/PLibCore/PLibCorePatches.cs b/mod/PLibCore/PLibCorePatches.cs new file mode 100644 index 0000000..85ff009 --- /dev/null +++ b/mod/PLibCore/PLibCorePatches.cs @@ -0,0 +1,103 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using System; +using System.Collections.Generic; + +namespace PeterHan.PLib.Core { + /// + /// A small component which applies core patches used by PLib. + /// + internal sealed class PLibCorePatches : PForwardedComponent { + /// + /// The version of this component. Uses the running PLib version. + /// + internal static readonly Version VERSION = new Version(PVersion.VERSION); + + /// + /// Localizes all mods to the current locale. + /// + private static void Initialize_Postfix() { + var locale = Localization.GetLocale(); + if (locale != null) { + int n = 0; + string locCode = locale.Code; + if (string.IsNullOrEmpty(locCode)) + locCode = Localization.GetCurrentLanguageCode(); + var libLocal = PRegistry.Instance.GetAllComponents(typeof(PLibCorePatches). + FullName); + if (libLocal != null) + foreach (var component in libLocal) + component.Process(0, locale); + var modLocal = PRegistry.Instance.GetAllComponents( + "PeterHan.PLib.Database.PLocalization"); + if (modLocal != null) + foreach (var component in modLocal) { + component.Process(0, locale); + n++; + } + if (n > 0) + PRegistry.LogPatchDebug("Localized {0:D} mod(s) to locale {1}".F( + n, locCode)); + } + } + + private static IEnumerable LoadPreviewImage_Transpile( + IEnumerable body) { + var target = typeof(Debug).GetMethodSafe(nameof(Debug.LogFormat), true, + typeof(string), typeof(object[])); + return (target == null) ? body : PPatchTools.RemoveMethodCall(body, target); + } + + public override Version Version => VERSION; + + public override void Initialize(Harmony plibInstance) { + UI.TextMeshProPatcher.Patch(); + var ugc = PPatchTools.GetTypeSafe("SteamUGCService", "Assembly-CSharp"); + if (ugc != null) + try { + plibInstance.PatchTranspile(ugc, "LoadPreviewImage", PatchMethod(nameof( + LoadPreviewImage_Transpile))); + } catch (Exception e) { +#if DEBUG + PUtil.LogExcWarn(e); +#endif + } + plibInstance.Patch(typeof(Localization), nameof(Localization.Initialize), + postfix: PatchMethod(nameof(Initialize_Postfix))); + } + + public override void Process(uint operation, object _) { + var locale = Localization.GetLocale(); + if (locale != null && operation == 0) + PLibLocalization.LocalizeItself(locale); + } + + /// + /// Registers this instance of the PLib core patches. + /// + /// The registry instance to use (since PRegistry.Instance + /// is not yet fully initialized). + internal void Register(IPLibRegistry instance) { + if (instance == null) + throw new ArgumentNullException(nameof(instance)); + instance.AddCandidateVersion(this); + } + } +} diff --git a/mod/PLibCore/PLibLocalization.cs b/mod/PLibCore/PLibLocalization.cs new file mode 100644 index 0000000..96f548c --- /dev/null +++ b/mod/PLibCore/PLibLocalization.cs @@ -0,0 +1,78 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; + +namespace PeterHan.PLib.Core { + /// + /// Handles localization of PLib for mods by automatically loading po files stored as + /// EmbeddedResources in PLibCore.dll and ILMerged with the mod assembly. + /// + public static class PLibLocalization { + /// + /// The file extension used for localization files. + /// + public const string TRANSLATIONS_EXT = ".po"; + + /// + /// The Prefix of LogicalName of EmbeddedResources that stores the content of po files. + /// Must match the specified value in the Directory.Build.targets file. + /// + private const string TRANSLATIONS_RES_PATH = "PeterHan.PLib.Core.PLibStrings."; + + /// + /// Localizes the PLib strings. + /// + /// The locale to use. + internal static void LocalizeItself(Localization.Locale locale) { + if (locale == null) + throw new ArgumentNullException(nameof(locale)); + Localization.RegisterForTranslation(typeof(PLibStrings)); + var assembly = Assembly.GetExecutingAssembly(); + string locCode = locale.Code; + if (string.IsNullOrEmpty(locCode)) + locCode = Localization.GetCurrentLanguageCode(); + try { + using (var stream = assembly.GetManifestResourceStream( + TRANSLATIONS_RES_PATH + locCode + TRANSLATIONS_EXT)) { + if (stream != null) { + // File.ReadAllLines does not work on streams unfortunately + var lines = new List(128); + using (var reader = new StreamReader(stream, Encoding.UTF8)) { + string line; + while ((line = reader.ReadLine()) != null) + lines.Add(line); + } + Localization.OverloadStrings(Localization.ExtractTranslatedStrings( + lines.ToArray())); +#if DEBUG + PUtil.LogDebug("Localizing PLib Core to locale {0}".F(locCode)); +#endif + } + } + } catch (Exception e) { + PUtil.LogWarning("Failed to load {0} localization for PLib Core:".F(locCode)); + PUtil.LogExcWarn(e); + } + } + } +} diff --git a/mod/PLibCore/PLibStrings.cs b/mod/PLibCore/PLibStrings.cs new file mode 100644 index 0000000..7fde7cb --- /dev/null +++ b/mod/PLibCore/PLibStrings.cs @@ -0,0 +1,204 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +namespace PeterHan.PLib.Core { + /// + /// Strings used in PLib. + /// + public static class PLibStrings { + /// + /// The button used to manually edit the mod configuration. + /// + public static LocString BUTTON_MANUAL = "MANUAL CONFIG"; + + /// + /// The button used to reset the configuration to its default value. + /// + public static LocString BUTTON_RESET = "RESET TO DEFAULT"; + + /// + /// The text shown on the Done button. + /// + public static LocString BUTTON_OK = STRINGS.UI.FRONTEND.OPTIONS_SCREEN.BACK; + + /// + /// The text shown on the Options button. + /// + public static LocString BUTTON_OPTIONS = STRINGS.UI.FRONTEND.MAINMENU.OPTIONS; + + /// + /// The dialog title used for options, where {0} is substituted with the mod friendly name. + /// + public static LocString DIALOG_TITLE = "Options for {0}"; + + // Utility key names + public static LocString KEY_HOME = "Home"; + public static LocString KEY_END = "End"; + public static LocString KEY_DELETE = "Delete"; + public static LocString KEY_PAGEUP = "Page Up"; + public static LocString KEY_PAGEDOWN = "Page Down"; + public static LocString KEY_SYSRQ = "SysRq"; + public static LocString KEY_PRTSCREEN = "Print Screen"; + public static LocString KEY_PAUSE = "Pause"; + + // Arrow key names + public static LocString KEY_ARROWLEFT = "Left Arrow"; + public static LocString KEY_ARROWUP = "Up Arrow"; + public static LocString KEY_ARROWRIGHT = "Right Arrow"; + public static LocString KEY_ARROWDOWN = "Down Arrow"; + + /// + /// The title used for the PLib key bind category. + /// + public static LocString KEY_CATEGORY_TITLE = "Mods"; + + /// + /// The abbreviation text shown on the Blue field. + /// + public static LocString LABEL_B = "B"; + + /// + /// The abbreviation text shown on the Green field. + /// + public static LocString LABEL_G = "G"; + + /// + /// The abbreviation text shown on the Red field. + /// + public static LocString LABEL_R = "R"; + + /// + /// The mod version in Mod Options if retrieved from the default AssemblyVersion, where + /// {0} is substituted with the version text. + /// + public static LocString MOD_ASSEMBLY_VERSION = "Assembly Version: {0}"; + + /// + /// The button text which goes to the mod's home page when clicked. + /// + public static LocString MOD_HOMEPAGE = "Mod Homepage"; + + /// + /// The mod version in Mod Options if specified via AssemblyFileVersion, where {0} is + /// substituted with the version text. + /// + public static LocString MOD_VERSION = "Mod Version: {0}"; + + /// + /// The cancel button in the restart dialog. + /// + public static LocString RESTART_CANCEL = STRINGS.UI.FRONTEND.MOD_DIALOGS.RESTART.CANCEL; + + /// + /// The OK button in the restart dialog. + /// + public static LocString RESTART_OK = STRINGS.UI.FRONTEND.MOD_DIALOGS.RESTART.OK; + + /// + /// The details tooltip when AVC detects a mod to be outdated. + /// + public static LocString OUTDATED_TOOLTIP = "This mod is out of date!\nNew version: {0}\n\nUpdate local mods manually, or use Mod Updater to force update Steam mods"; + + /// + /// Displayed when AVC detects a mod to be outdated. + /// + public static LocString OUTDATED_WARNING = "Outdated!"; + + /// + /// The message prompting the user to restart. + /// + public static LocString RESTART_REQUIRED = "Oxygen Not Included must be restarted " + + "for these options to take effect."; + + /// + /// The tooltip on the BLUE field in color pickers. + /// + public static LocString TOOLTIP_BLUE = "Blue"; + + /// + /// The tooltip on the CANCEL button. + /// + public static LocString TOOLTIP_CANCEL = "Discard changes."; + + /// + /// The tooltip on the GREEN field in color pickers. + /// + public static LocString TOOLTIP_GREEN = "Green"; + + /// + /// The tooltip on the Mod Homepage button. + /// + public static LocString TOOLTIP_HOMEPAGE = "Visit the mod's website."; + + /// + /// The tooltip on the Hue slider in color pickers. + /// + public static LocString TOOLTIP_HUE = "Hue"; + + /// + /// The tooltip on the MANUAL CONFIG button. + /// + public static LocString TOOLTIP_MANUAL = "Opens the folder containing the full mod configuration."; + + /// + /// The tooltip for cycling to the next item. + /// + public static LocString TOOLTIP_NEXT = "Next"; + + /// + /// The tooltip on the OK button. + /// + public static LocString TOOLTIP_OK = "Save these options. Some mods may require " + + "a restart for the options to take effect."; + + /// + /// The tooltip for cycling to the previous item. + /// + public static LocString TOOLTIP_PREVIOUS = "Previous"; + + /// + /// The tooltip on the RED field in color pickers. + /// + public static LocString TOOLTIP_RED = "Red"; + + /// + /// The tooltip on the RESET TO DEFAULT button. + /// + public static LocString TOOLTIP_RESET = "Resets the mod configuration to default values."; + + /// + /// The tooltip on the Saturation slider in color pickers. + /// + public static LocString TOOLTIP_SATURATION = "Saturation"; + + /// + /// The tooltip for each category visibility toggle. + /// + public static LocString TOOLTIP_TOGGLE = "Show or hide this options category"; + + /// + /// The tooltip on the Value slider in color pickers. + /// + public static LocString TOOLTIP_VALUE = "Value"; + + /// + /// The tooltip for the mod version. + /// + public static LocString TOOLTIP_VERSION = "The currently installed version of this mod.\n\nCompare this version with the mod's Release Notes to see if it is outdated."; + } +} diff --git a/mod/PLibCore/PPatchTools.cs b/mod/PLibCore/PPatchTools.cs new file mode 100644 index 0000000..dbb1efb --- /dev/null +++ b/mod/PLibCore/PPatchTools.cs @@ -0,0 +1,1028 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using System.Text; + +using TranspiledMethod = System.Collections.Generic.IEnumerable; + +namespace PeterHan.PLib.Core { + /// + /// Contains tools to aid with patching. + /// + public static class PPatchTools { + /// + /// The base binding flags for all reflection methods. + /// + public const BindingFlags BASE_FLAGS = BindingFlags.Public | BindingFlags.NonPublic; + + /// + /// Passed to GetMethodSafe to match any method arguments. + /// + public static Type[] AnyArguments { + get { + return new Type[] { null }; + } + } + + /// + /// A placeholder flag to ReplaceMethodCallSafe to remove the method call. + /// + public static readonly MethodInfo RemoveCall = typeof(PPatchTools).GetMethodSafe( + nameof(RemoveMethodCallPrivate), true); + + /// + /// Creates a delegate for a private instance method. This delegate is over ten times + /// faster than reflection, so useful if called frequently on the same object. + /// + /// A delegate type which matches the method signature. + /// The declaring type of the target method. + /// The target method name. + /// The object on which to call the method. + /// The types of the target method arguments, or PPatchTools. + /// AnyArguments (not recommended, type safety is good) to match any method with + /// that name. + /// A delegate which calls this method, or null if the method could not be + /// found or did not match the types. + public static T CreateDelegate(this Type type, string method, object caller, + params Type[] argumentTypes) where T : Delegate { + var del = default(T); + if (type == null) + throw new ArgumentNullException(nameof(type)); + if (string.IsNullOrEmpty(method)) + throw new ArgumentNullException(nameof(method)); + var reflectMethod = GetMethodSafe(type, method, false, argumentTypes); + // MethodInfo.CreateDelegate is @since .NET 5.0 :( + if (reflectMethod != null) + del = Delegate.CreateDelegate(typeof(T), caller, reflectMethod, false) as T; + return del; + } + + /// + /// Creates a delegate for a private instance method. This delegate is over ten times + /// faster than reflection, so useful if called frequently on the same object. + /// + /// A delegate type which matches the method signature. + /// The declaring type of the target method. + /// The target method. + /// The object on which to call the method. + /// A delegate which calls this method, or null if the method was null or did + /// not match the delegate type. + public static T CreateDelegate(this MethodInfo method, object caller) + where T : Delegate { + var del = default(T); + if (method != null) + del = Delegate.CreateDelegate(typeof(T), caller, method, false) as T; + return del; + } + + /// + /// Creates a delegate for a private instance property getter. This delegate is over + /// ten times faster than reflection, so useful if called frequently on the same object. + /// + /// This method does not work on indexed properties. + /// + /// The property's type. + /// The declaring type of the target property. + /// The target property name. + /// The object on which to call the property getter. + /// A delegate which calls this property's getter, or null if the property + /// could not be found or did not match the type. + public static Func CreateGetDelegate(this Type type, string property, + object caller) { + Func del = null; + if (type == null) + throw new ArgumentNullException(nameof(type)); + if (string.IsNullOrEmpty(property)) + throw new ArgumentNullException(nameof(property)); + var reflectMethod = GetPropertySafe(type, property, false)?.GetGetMethod(true); + if (reflectMethod != null) + del = Delegate.CreateDelegate(typeof(Func), caller, reflectMethod, false) + as Func; + return del; + } + + /// + /// Creates a delegate for a private instance property getter. This delegate is over + /// ten times faster than reflection, so useful if called frequently on the same object. + /// + /// This method does not work on indexed properties. + /// + /// The property's type. + /// The target property. + /// The object on which to call the property getter. + /// A delegate which calls this property's getter, or null if the property + /// was null or did not match the type. + public static Func CreateGetDelegate(this PropertyInfo property, object caller) { + Func del = null; + var reflectMethod = property?.GetGetMethod(true); + if (reflectMethod != null && typeof(T).IsAssignableFrom(property.PropertyType)) + del = Delegate.CreateDelegate(typeof(Func), caller, reflectMethod, false) + as Func; + return del; + } + + /// + /// Creates a delegate for a private instance property setter. This delegate is over + /// ten times faster than reflection, so useful if called frequently on the same object. + /// + /// This method does not work on indexed properties. + /// + /// The property's type. + /// The declaring type of the target property. + /// The target property name. + /// The object on which to call the property setter. + /// A delegate which calls this property's setter, or null if the property + /// could not be found or did not match the type. + public static Action CreateSetDelegate(this Type type, string property, + object caller) { + Action del = null; + if (type == null) + throw new ArgumentNullException(nameof(type)); + if (string.IsNullOrEmpty(property)) + throw new ArgumentNullException(nameof(property)); + var reflectMethod = GetPropertySafe(type, property, false)?.GetSetMethod(true); + if (reflectMethod != null) + del = Delegate.CreateDelegate(typeof(Action), caller, reflectMethod, false) + as Action; + return del; + } + + /// + /// Creates a delegate for a private instance property setter. This delegate is over + /// ten times faster than reflection, so useful if called frequently on the same object. + /// + /// This method does not work on indexed properties. + /// + /// The property's type. + /// The target property. + /// The object on which to call the property setter. + /// A delegate which calls this property's setter, or null if the property + /// was null or did not match the type. + public static Action CreateSetDelegate(this PropertyInfo property, + object caller) { + Action del = null; + var reflectMethod = property?.GetSetMethod(true); + if (reflectMethod != null && property.PropertyType.IsAssignableFrom(typeof(T))) + del = Delegate.CreateDelegate(typeof(Action), caller, reflectMethod, false) + as Action; + return del; + } + + /// + /// Creates a delegate for a private static method. This delegate is over ten times + /// faster than reflection, so useful if called frequently. + /// + /// A delegate type which matches the method signature. + /// The declaring type of the target method. + /// The target method name. + /// The types of the target method arguments, or PPatchTools. + /// AnyArguments (not recommended, type safety is good) to match any static method with + /// that name. + /// A delegate which calls this method, or null if the method could not be + /// found or did not match the types. + public static T CreateStaticDelegate(this Type type, string method, + params Type[] argumentTypes) where T : Delegate { + var del = default(T); + if (type == null) + throw new ArgumentNullException(nameof(type)); + if (string.IsNullOrEmpty(method)) + throw new ArgumentNullException(nameof(method)); + var reflectMethod = GetMethodSafe(type, method, true, argumentTypes); + if (reflectMethod != null) + del = Delegate.CreateDelegate(typeof(T), reflectMethod, false) as T; + return del; + } + + /// + /// Replaces method calls in a transpiled method. + /// + /// The method to patch. + /// A mapping from the old method calls to replace, to the + /// new method calls to use instead. + /// A transpiled version of that method that replaces or removes all calls + /// to the specified methods. + private static TranspiledMethod DoReplaceMethodCalls(TranspiledMethod method, + IDictionary translation) { + var remove = RemoveCall; + int replaced = 0; + foreach (var instruction in method) { + var opcode = instruction.opcode; + if ((opcode == OpCodes.Call || opcode == OpCodes.Calli || opcode == OpCodes. + Callvirt) && instruction.operand is MethodInfo target && translation. + TryGetValue(target, out MethodInfo newMethod)) { + if (newMethod != null && newMethod != remove) { + // Replace with new method + instruction.opcode = newMethod.IsStatic ? OpCodes.Call : + OpCodes.Callvirt; + instruction.operand = newMethod; + yield return instruction; + } else { + // Pop "this" if needed + int n = target.GetParameters().Length; + if (!target.IsStatic) n++; + // Pop the arguments off the stack + instruction.opcode = (n == 0) ? OpCodes.Nop : OpCodes.Pop; + instruction.operand = null; + yield return instruction; + for (int i = 0; i < n - 1; i++) + yield return new CodeInstruction(OpCodes.Pop); + } + replaced++; + } else + yield return instruction; + } +#if DEBUG + if (replaced == 0) { + if (translation.Count == 1) { + // Diagnose the method that could not be replaced + var items = new KeyValuePair[1]; + translation.CopyTo(items, 0); + MethodInfo from = items[0].Key, to = items[0].Value; + PUtil.LogWarning("No method calls replaced: {0}.{1} to {2}.{3}".F( + from.DeclaringType.FullName, from.Name, to.DeclaringType.FullName, + to.Name)); + } else + PUtil.LogWarning("No method calls replaced (multiple replacements)"); + } +#endif + } + + /// + /// Dumps the IL body of the method to the debug log. + /// + /// Only to be used for debugging purposes. + /// + /// The IL instructions to log. + public static void DumpMethodBody(TranspiledMethod opcodes) { + var result = new StringBuilder(1024); + result.AppendLine("METHOD BODY:"); + foreach (var instr in opcodes) { + foreach (var block in instr.blocks) { + var type = block.blockType; + if (type == ExceptionBlockType.EndExceptionBlock) + result.AppendLine("}"); + else { + if (type != ExceptionBlockType.BeginExceptionBlock) + result.Append("} "); + result.Append(block.blockType); + result.AppendLine(" {"); + } + } + foreach (var label in instr.labels) { + // Label hashcodes are just easy integers + result.Append(label.GetHashCode()); + result.Append(": "); + } + result.Append('\t'); + result.Append(instr.opcode); + var operand = instr.operand; + if (operand != null) { + result.Append('\t'); + if (operand is Label) + result.Append(operand.GetHashCode()); + else if (operand is MethodBase method) + FormatMethodCall(result, method); + else + result.Append(FormatArgument(operand)); + } + result.AppendLine(); + } + PUtil.LogDebug(result.ToString()); + } + + /// + /// This method was taken directly from Harmony (https://github.com/pardeike/Harmony) + /// which is also available under the MIT License. + /// + /// The argument to format. + /// The IL argument in string form. + private static string FormatArgument(object argument) { + if (argument is null) return "NULL"; + + if (argument is MethodBase method) + return method.FullDescription(); + + if (argument is FieldInfo field) + return $"{field.FieldType.FullDescription()} {field.DeclaringType.FullDescription()}::{field.Name}"; + + if (argument is Label label) + return $"Label{label.GetHashCode()}"; + + if (argument is Label[] labels) { + int n = labels.Length; + string[] labelCodes = new string[n]; + for (int i = 0; i < n; i++) + labelCodes[i] = labels[i].GetHashCode().ToString(); + return $"Labels{labelCodes.Join(",")}"; + } + + if (argument is LocalBuilder lb) + return $"{lb.LocalIndex} ({lb.LocalType})"; + + if (argument is string sval) + return sval.ToString().ToLiteral(); + + return argument.ToString().Trim(); + } + + /// + /// Formats a method call for logging. + /// + /// The location where the log is stored. + /// The method that is called. + private static void FormatMethodCall(StringBuilder result, MethodBase method) { + bool first = true; + // The default representation leaves off the class name! + if (method is MethodInfo hasRet) { + result.Append(hasRet.ReturnType.Name); + result.Append(' '); + } + result.Append(method.DeclaringType.Name); + result.Append('.'); + result.Append(method.Name); + result.Append('('); + // Put the default value in there too + foreach (var pr in method.GetParameters()) { + string paramName = pr.Name; + if (!first) + result.Append(", "); + result.Append(pr.ParameterType.Name); + if (!string.IsNullOrEmpty(paramName)) { + result.Append(' '); + result.Append(paramName); + } + if (pr.IsOptional) { + result.Append(" = "); + result.Append(pr.DefaultValue); + } + first = false; + } + result.Append(')'); + } + + /// + /// Retrieves a field using reflection, or returns null if it does not exist. + /// + /// The base type. + /// The field name. + /// true to find static fields, or false to find instance + /// fields. + /// The field, or null if no such field could be found. + public static FieldInfo GetFieldSafe(this Type type, string fieldName, + bool isStatic) { + FieldInfo field = null; + if (type != null) + try { + var flag = isStatic ? BindingFlags.Static : BindingFlags.Instance; + field = type.GetField(fieldName, BASE_FLAGS | flag); + } catch (AmbiguousMatchException e) { + PUtil.LogException(e); + } + return field; + } + + /// + /// Creates a store instruction to the same local as the specified load instruction. + /// + /// The initial load instruction. + /// The counterbalancing store instruction. + public static CodeInstruction GetMatchingStoreInstruction(CodeInstruction load) { + CodeInstruction instr; + var opcode = load.opcode; + if (opcode == OpCodes.Ldloc) + instr = new CodeInstruction(OpCodes.Stloc, load.operand); + else if (opcode == OpCodes.Ldloc_S) + instr = new CodeInstruction(OpCodes.Stloc_S, load.operand); + else if (opcode == OpCodes.Ldloc_0) + instr = new CodeInstruction(OpCodes.Stloc_0); + else if (opcode == OpCodes.Ldloc_1) + instr = new CodeInstruction(OpCodes.Stloc_1); + else if (opcode == OpCodes.Ldloc_2) + instr = new CodeInstruction(OpCodes.Stloc_2); + else if (opcode == OpCodes.Ldloc_3) + instr = new CodeInstruction(OpCodes.Stloc_3); + else + instr = new CodeInstruction(OpCodes.Pop); + return instr; + } + + /// + /// Retrieves a method using reflection, or returns null if it does not exist. + /// + /// The base type. + /// The method name. + /// true to find static methods, or false to find instance + /// methods. + /// The method argument types. If null is provided, any + /// argument types are matched, whereas no arguments match only void methods. + /// The method, or null if no such method could be found. + public static MethodInfo GetMethodSafe(this Type type, string methodName, + bool isStatic, params Type[] arguments) { + MethodInfo method = null; + if (type != null && arguments != null) + try { + var flag = isStatic ? BindingFlags.Static : BindingFlags.Instance; + if (arguments.Length == 1 && arguments[0] == null) + // AnyArguments + method = type.GetMethod(methodName, BASE_FLAGS | flag); + else + method = type.GetMethod(methodName, BASE_FLAGS | flag, null, arguments, + new ParameterModifier[arguments.Length]); + } catch (AmbiguousMatchException e) { + PUtil.LogException(e); + } + return method; + } + + /// + /// Retrieves a property using reflection, or returns null if it does not exist. + /// + /// The base type. + /// The property name. + /// true to find static properties, or false to find instance + /// properties. + /// The property field type. + /// The property, or null if no such property could be found. + public static PropertyInfo GetPropertySafe(this Type type, string propName, + bool isStatic) { + PropertyInfo field = null; + if (type != null) + try { + var flag = isStatic ? BindingFlags.Static : BindingFlags.Instance; + field = type.GetProperty(propName, BASE_FLAGS | flag, null, typeof(T), + Type.EmptyTypes, null); + } catch (AmbiguousMatchException e) { + PUtil.LogException(e); + } + return field; + } + + /// + /// Retrieves an indexed property using reflection, or returns null if it does not + /// exist. + /// + /// The base type. + /// The property name. + /// true to find static properties, or false to find instance + /// properties. + /// The property indexer's arguments. + /// The property field type. + /// The property, or null if no such property could be found. + public static PropertyInfo GetPropertyIndexedSafe(this Type type, string propName, + bool isStatic, params Type[] arguments) { + PropertyInfo field = null; + if (type != null && arguments != null) + try { + var flag = isStatic ? BindingFlags.Static : BindingFlags.Instance; + field = type.GetProperty(propName, BASE_FLAGS | flag, null, typeof(T), + arguments, new ParameterModifier[arguments.Length]); + } catch (AmbiguousMatchException e) { + PUtil.LogException(e); + } + return field; + } + + /// + /// Retrieves a type using its full name (including namespace). However, the assembly + /// name is optional, as this method searches all assemblies in the current + /// AppDomain if it is null or empty. + /// + /// The type name to retrieve. + /// If specified, the name of the assembly that contains + /// the type. No other assembly name will be searched if this parameter is not null + /// or empty. The assembly name might not match the DLL name, use a decompiler to + /// make sure. + /// The type, or null if the type is not found or cannot be loaded. + public static Type GetTypeSafe(string name, string assemblyName = null) { + Type type = null; + if (string.IsNullOrEmpty(assemblyName)) + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { + try { + type = assembly.GetType(name, false); + } catch (System.IO.IOException) { + // The common parent of exceptions when the type requires another type + // that cannot be loaded + } catch (BadImageFormatException) { } + if (type != null) break; + } + else { + try { + type = Type.GetType(name + ", " + assemblyName, false); + } catch (TargetInvocationException e) { + PUtil.LogWarning("Unable to load type {0} from assembly {1}:".F(name, + assemblyName)); + PUtil.LogExcWarn(e); + } catch (ArgumentException e) { + // A generic type is loaded with bad arguments + PUtil.LogWarning("Unable to load type {0} from assembly {1}:".F(name, + assemblyName)); + PUtil.LogExcWarn(e); + } catch (System.IO.IOException) { + // The common parent of exceptions when the type requires another type that + // cannot be loaded + } catch (BadImageFormatException) { } + } + return type; + } + + /// + /// Checks to see if a patch with the specified method name (the method used in the + /// patch class) and type is defined. + /// + /// The Harmony instance to query for patches. Unused. + /// The target method to search for patches. + /// The patch type to look up. + /// The patch method name to look up (name as declared by patch owner). + /// true if such a patch was found, or false otherwise + public static bool HasPatchWithMethodName(Harmony instance, MethodBase target, + HarmonyPatchType type, string name) { + bool found = false; + if (target == null) + throw new ArgumentNullException(nameof(target)); + if (string.IsNullOrEmpty(name)) + throw new ArgumentNullException(nameof(name)); + var patches = Harmony.GetPatchInfo(target); + if (patches != null) { + ICollection patchList; + switch (type) { + case HarmonyPatchType.Prefix: + patchList = patches.Prefixes; + break; + case HarmonyPatchType.Postfix: + patchList = patches.Postfixes; + break; + case HarmonyPatchType.Transpiler: + patchList = patches.Transpilers; + break; + case HarmonyPatchType.All: + default: + // All + if (patches.Transpilers != null) + found = HasPatchWithMethodName(patches.Transpilers, name); + if (patches.Prefixes != null) + found = found || HasPatchWithMethodName(patches.Prefixes, name); + patchList = patches.Postfixes; + break; + } + if (patchList != null) + found = found || HasPatchWithMethodName(patchList, name); + } + return found; + } + + /// + /// Checks to see if the patch list has a method with the specified name. + /// + /// The patch list to search. + /// The declaring method name to look up. + /// true if a patch matches that name, or false otherwise + private static bool HasPatchWithMethodName(IEnumerable patchList, string name) { + bool found = false; + foreach (var patch in patchList) + if (patch.PatchMethod.Name == name) { + found = true; + break; + } + return found; + } + + /// + /// Checks to see if an instruction opcode is a branch instruction. + /// + /// The opcode to check. + /// true if it is a branch, or false otherwise. + public static bool IsConditionalBranchInstruction(this OpCode opcode) { + return PTranspilerTools.IsConditionalBranchInstruction(opcode); + } + + /// + /// Adds a logger to all unhandled exceptions. + /// + [Obsolete("Do not use this method in production code. Make sure to remove it in release builds, or disable it with #if DEBUG.")] + public static void LogAllExceptions() { + PUtil.LogWarning("PLib in mod " + Assembly.GetCallingAssembly().GetName()?.Name + + " is logging ALL unhandled exceptions!"); + PTranspilerTools.LogAllExceptions(); + } + + /// + /// Adds a logger to all failed assertions. The assertions will still fail, but a stack + /// trace will be printed for each failed assertion. + /// + [Obsolete("Do not use this method in production code. Make sure to remove it in release builds, or disable it with #if DEBUG.")] + public static void LogAllFailedAsserts() { + PUtil.LogWarning("PLib in mod " + Assembly.GetCallingAssembly().GetName()?.Name + + " is logging ALL failed assertions!"); + PTranspilerTools.LogAllFailedAsserts(); + } + + /// + /// Transpiles a method to replace instances of one constant value with another. + /// + /// The method to patch. + /// The old constant to remove. + /// The new constant to replace. + /// true to replace all instances, or false to replace the first + /// instance (default). + /// A transpiled version of that method which replaces instances of the first + /// constant with that of the second. + public static TranspiledMethod ReplaceConstant(TranspiledMethod method, + double oldValue, double newValue, bool all = false) { + if (method == null) + throw new ArgumentNullException(nameof(method)); + int replaced = 0; + foreach (var inst in method) { + var instruction = inst; + var opcode = instruction.opcode; + if ((opcode == OpCodes.Ldc_R8 && (instruction.operand is double dval) && + dval == oldValue)) { + // Replace instruction if first instance, or all to be replaced + if (all || replaced == 0) + instruction.operand = newValue; + replaced++; + } + yield return instruction; + } + } + + /// + /// Transpiles a method to replace instances of one constant value with another. + /// + /// The method to patch. + /// The old constant to remove. + /// The new constant to replace. + /// true to replace all instances, or false to replace the first + /// instance (default). + /// A transpiled version of that method which replaces instances of the first + /// constant with that of the second. + public static TranspiledMethod ReplaceConstant(TranspiledMethod method, float oldValue, + float newValue, bool all = false) { + if (method == null) + throw new ArgumentNullException(nameof(method)); + int replaced = 0; + foreach (var inst in method) { + var instruction = inst; + var opcode = instruction.opcode; + if ((opcode == OpCodes.Ldc_R4 && (instruction.operand is float fval) && + fval == oldValue)) { + // Replace instruction if first instance, or all to be replaced + if (all || replaced == 0) + instruction.operand = newValue; + replaced++; + } + yield return instruction; + } + } + + /// + /// Transpiles a method to replace instances of one constant value with another. + /// + /// Note that values of type byte, short, char, and bool are also represented with "i4" + /// constants which can be targeted by this method. + /// + /// The method to patch. + /// The old constant to remove. + /// The new constant to replace. + /// true to replace all instances, or false to replace the first + /// instance (default). + /// A transpiled version of that method which replaces instances of the first + /// constant with that of the second. + public static TranspiledMethod ReplaceConstant(TranspiledMethod method, int oldValue, + int newValue, bool all = false) { + int replaced = 0; + bool quickCode = oldValue >= -1 && oldValue <= 8; + var qc = OpCodes.Nop; + if (method == null) + throw new ArgumentNullException(nameof(method)); + // Quick test for the opcode on the shorthand forms + if (quickCode) + qc = PTranspilerTools.LOAD_INT[oldValue + 1]; + foreach (var inst in method) { + var instruction = inst; + var opcode = instruction.opcode; + object operand = instruction.operand; + if ((opcode == OpCodes.Ldc_I4 && (operand is int ival) && ival == oldValue) || + (opcode == OpCodes.Ldc_I4_S && (operand is byte bval) && bval == + oldValue) || (quickCode && qc == opcode)) { + // Replace instruction if first instance, or all to be replaced + if (all || replaced == 0) + PTranspilerTools.ModifyLoadI4(instruction, newValue); + replaced++; + } + yield return instruction; + } + } + + /// + /// Transpiles a method to replace instances of one constant value with another. + /// + /// The method to patch. + /// The old constant to remove. + /// The new constant to replace. + /// true to replace all instances, or false to replace the first + /// instance (default). + /// A transpiled version of that method which replaces instances of the first + /// constant with that of the second. + public static TranspiledMethod ReplaceConstant(TranspiledMethod method, long oldValue, + long newValue, bool all = false) { + if (method == null) + throw new ArgumentNullException(nameof(method)); + int replaced = 0; + foreach (var inst in method) { + var instruction = inst; + var opcode = instruction.opcode; + if ((opcode == OpCodes.Ldc_I8 && (instruction.operand is long lval) && + lval == oldValue)) { + // Replace instruction if first instance, or all to be replaced + if (all || replaced == 0) + instruction.operand = newValue; + replaced++; + } + yield return instruction; + } + } + + /// + /// Transpiles a method to remove all calls to the specified victim method. + /// + /// The method to patch. + /// The old method calls to remove. + /// A transpiled version of that method that removes all calls to method. + /// If the method being removed had a return value + /// (with what would it be replaced?). + public static TranspiledMethod RemoveMethodCall(TranspiledMethod method, + MethodInfo victim) { + return ReplaceMethodCallSafe(method, new Dictionary() { + { victim, RemoveCall } + }); + } + + /// + /// A placeholder method for signaling call removal. Not actually called. + /// + private static void RemoveMethodCallPrivate() { } + + /// + /// Transpiles a method to replace all calls to the specified victim method with + /// another method, altering the call type if necessary. The argument types and return + /// type must match exactly, including in/out/ref parameters. + /// + /// If replacing an instance method call with a static method, the first argument + /// will receive the "this" which the old method would have received. + /// + /// If newMethod is null, the calls will all be removed silently instead. This will + /// fail if the method call being removed had a return type (what would it be replaced + /// with?); in those cases, declare an empty method with the same signature and + /// replace it instead. + /// + /// The method to patch. + /// The old method calls to remove. + /// The new method to replace, or null to delete the calls. + /// A transpiled version of that method that replaces or removes all calls + /// to method. + /// If the new method's argument types do not + /// exactly match the old method's argument types. + [Obsolete("This method is unsafe. Use the RemoveMethodCall or ReplaceMethodCallSafe versions instead.")] + public static TranspiledMethod ReplaceMethodCall(TranspiledMethod method, + MethodInfo victim, MethodInfo newMethod = null) { + if (newMethod == null) + newMethod = RemoveCall; + return ReplaceMethodCallSafe(method, new Dictionary() { + { victim, newMethod } + }); + } + + /// + /// Transpiles a method to replace all calls to the specified victim method with + /// another method, altering the call type if necessary. The argument types and return + /// type must match exactly, including in/out/ref parameters. + /// + /// If replacing an instance method call with a static method, the first argument + /// will receive the "this" which the old method would have received. + /// + /// The method to patch. + /// The old method calls to remove. + /// The new method to replace. + /// A transpiled version of that method that replaces all calls to method. + /// If the new method's argument types do not + /// exactly match the old method's argument types. + public static TranspiledMethod ReplaceMethodCallSafe(TranspiledMethod method, + MethodInfo victim, MethodInfo newMethod) { + if (newMethod == null) + throw new ArgumentNullException(nameof(newMethod)); + return ReplaceMethodCallSafe(method, new Dictionary() { + { victim, newMethod } + }); + } + + /// + /// Transpiles a method to replace calls to the specified victim methods with + /// replacement methods, altering the call type if necessary. + /// + /// Each key to value pair must meet the criteria defined in + /// ReplaceMethodCall(TranspiledMethod, MethodInfo, MethodInfo). + /// + /// The method to patch. + /// A mapping from the old method calls to replace, to the + /// new method calls to use instead. + /// A transpiled version of that method that replaces or removes all calls + /// to the specified methods. + /// If any of the new methods' argument types do + /// not exactly match the old methods' argument types. + [Obsolete("This method is unsafe. Use ReplaceMethodCallSafe instead.")] + public static TranspiledMethod ReplaceMethodCall(TranspiledMethod method, + IDictionary translation) { + if (method == null) + throw new ArgumentNullException(nameof(method)); + if (translation == null) + throw new ArgumentNullException(nameof(translation)); + // Sanity check arguments + foreach (var pair in translation) { + var victim = pair.Key; + var newMethod = pair.Value; + if (victim == null) + throw new ArgumentNullException(nameof(victim)); + if (newMethod != null) + PTranspilerTools.CompareMethodParams(victim, victim.GetParameterTypes(), + newMethod); + else if (victim.ReturnType != typeof(void)) + throw new ArgumentException("Cannot remove method {0} with a return value". + F(victim.Name)); + } + return DoReplaceMethodCalls(method, translation); + } + + /// + /// Transpiles a method to replace calls to the specified victim methods with + /// replacement methods, altering the call type if necessary. + /// + /// Each key to value pair must meet the criteria defined in + /// ReplaceMethodCallSafe(TranspiledMethod, MethodInfo, MethodInfo). + /// + /// The method to patch. + /// A mapping from the old method calls to replace, to the + /// new method calls to use instead. + /// A transpiled version of that method that replaces or removes all calls + /// to the specified methods. + /// If any of the new methods' argument types do + /// not exactly match the old methods' argument types. + public static TranspiledMethod ReplaceMethodCallSafe(TranspiledMethod method, + IDictionary translation) { + if (method == null) + throw new ArgumentNullException(nameof(method)); + if (translation == null) + throw new ArgumentNullException(nameof(translation)); + // Sanity check arguments + var remove = RemoveCall; + foreach (var pair in translation) { + var victim = pair.Key; + var newMethod = pair.Value; + if (victim == null) + throw new ArgumentNullException(nameof(victim)); + if (newMethod == null) + throw new ArgumentNullException(nameof(newMethod)); + if (newMethod == remove) { + if (victim.ReturnType != typeof(void)) + throw new ArgumentException("Cannot remove method {0} with a return value". + F(victim.Name)); + } else + PTranspilerTools.CompareMethodParams(victim, victim.GetParameterTypes(), + newMethod); + } + return DoReplaceMethodCalls(method, translation); + } + + /// + /// Attempts to read a field value from an object of a type not in this assembly. + /// + /// If this operation is expected to be performed more than once on the same object, + /// use a delegate. If the type of the object is known, use Detours. + /// + /// The type of the value to read. + /// The source object. + /// The field name. + /// The location where the field value will be stored. + /// true if the field was read, or false if the field was not found or + /// has the wrong type. + public static bool TryGetFieldValue(object source, string name, out T value) { + bool ok = false; + if (source != null && !string.IsNullOrEmpty(name)) { + var type = source.GetType(); + var field = type.GetFieldSafe(name, false); + if (field != null && field.GetValue(source) is T newValue) { + ok = true; + value = newValue; + } else + value = default; + } else + value = default; + return ok; + } + + /// + /// Attempts to read a property value from an object of a type not in this assembly. + /// + /// If this operation is expected to be performed more than once on the same object, + /// use a delegate. If the type of the object is known, use Detours. + /// + /// The type of the value to read. + /// The source object. + /// The property name. + /// The location where the property value will be stored. + /// true if the property was read, or false if the property was not found or + /// has the wrong type. + public static bool TryGetPropertyValue(object source, string name, out T value) { + bool ok = false; + if (source != null && !string.IsNullOrEmpty(name)) { + var type = source.GetType(); + var prop = type.GetPropertySafe(name, false); + ParameterInfo[] indexes; + if (prop != null && ((indexes = prop.GetIndexParameters()) == null || indexes. + Length < 1) && prop.GetValue(source, null) is T newValue) { + ok = true; + value = newValue; + } else + value = default; + } else + value = default; + return ok; + } + + /// + /// Transpiles a method to wrap it with a try/catch that logs and rethrows all + /// exceptions. + /// + /// The method body to patch. + /// The IL generator to make labels. + /// A transpiled version of that method that is wrapped with an error + /// logger. + public static TranspiledMethod WrapWithErrorLogger(TranspiledMethod method, + ILGenerator generator) { + var logger = typeof(PUtil).GetMethodSafe(nameof(PUtil.LogException), true, + typeof(Exception)); + var ee = method.GetEnumerator(); + // Emit all but the last instruction + if (ee.MoveNext()) { + CodeInstruction last; + bool hasNext, isFirst = true; + var endMethod = generator.DefineLabel(); + do { + last = ee.Current; + if (isFirst) + last.blocks.Add(new ExceptionBlock(ExceptionBlockType. + BeginExceptionBlock, null)); + hasNext = ee.MoveNext(); + isFirst = false; + if (hasNext) + yield return last; + } while (hasNext); + CodeInstruction startHandler; + // Preserves the labels "ret" might have had + last.opcode = OpCodes.Nop; + last.operand = null; + yield return last; + // Add a "leave" + yield return new CodeInstruction(OpCodes.Leave, endMethod); + // The exception is already on the stack + if (logger != null) + startHandler = new CodeInstruction(OpCodes.Call, logger); + else + startHandler = new CodeInstruction(OpCodes.Pop); + startHandler.blocks.Add(new ExceptionBlock(ExceptionBlockType.BeginCatchBlock, + typeof(Exception))); + yield return startHandler; + // Rethrow exception + yield return new CodeInstruction(OpCodes.Rethrow); + // End catch block + var endCatch = new CodeInstruction(OpCodes.Leave, endMethod); + endCatch.blocks.Add(new ExceptionBlock(ExceptionBlockType.EndExceptionBlock, + null)); + yield return endCatch; + // Actual new ret + var ret = new CodeInstruction(OpCodes.Ret); + ret.labels.Add(endMethod); + yield return ret; + } // Otherwise, there were no instructions to wrap + } + } +} diff --git a/mod/PLibCore/PRegistry.cs b/mod/PLibCore/PRegistry.cs new file mode 100644 index 0000000..3444876 --- /dev/null +++ b/mod/PLibCore/PRegistry.cs @@ -0,0 +1,137 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; + +namespace PeterHan.PLib.Core { + /// + /// Provides the user facing API to the PLib Registry. + /// + public static class PRegistry { + /// + /// The singleton instance of this class. + /// + public static IPLibRegistry Instance { + get { + lock (instanceLock) { + if (instance == null) + Init(); + } + return instance; + } + } + + /// + /// A pointer to the active PLib registry. + /// + private static IPLibRegistry instance = null; + + /// + /// Ensures that PLib can only be initialized by one thread at a time. + /// + private static readonly object instanceLock = new object(); + + /// + /// Retrieves a value from the single-instance share. + /// + /// The type of the desired data. + /// The string key to retrieve. Suggested key format: YourMod. + /// Category.KeyName + /// The data associated with that key. + public static T GetData(string key) { + T value = default; + if (string.IsNullOrEmpty(key)) + throw new ArgumentNullException(nameof(key)); + var registry = Instance.ModData; + if (registry != null && registry.TryGetValue(key, out object sval) && sval is + T newVal) + value = newVal; + return value; + } + + /// + /// Initializes the patch bootstrapper, creating a PRegistry if not yet present. + /// + private static void Init() { + const string INTENDED_NAME = "PRegistryComponent"; + var obj = Global.Instance?.gameObject; + if (obj != null) { + var plr = obj.GetComponent(INTENDED_NAME); + if (plr == null) { + var localReg = obj.AddComponent(); + // If PLib is ILMerged more than once, PRegistry gets added with a weird + // type name including a GUID which does not match GetComponent.Name! + string typeName = localReg.GetType().Name; + if (typeName != INTENDED_NAME) + LogPatchWarning(INTENDED_NAME + " has the type name " + typeName + + "; this may be the result of ILMerging PLib more than once!"); +#if DEBUG + LogPatchDebug("Creating PLib Registry from " + System.Reflection.Assembly. + GetExecutingAssembly()?.FullName ?? "?"); +#endif + // Patch in the bootstrap method + localReg.ApplyBootstrapper(); + instance = localReg; + } else { + instance = new PRemoteRegistry(plr); + } + } else { +#if DEBUG + LogPatchWarning("Attempted to initialize PLib Registry before Global created!"); +#endif + instance = null; + } + if (instance != null) + new PLibCorePatches().Register(instance); + } + + /// + /// Logs a debug message while patching in PLib patches. + /// + /// The debug message. + internal static void LogPatchDebug(string message) { + Debug.LogFormat("[PLibPatches] {0}", message); + } + + /// + /// Logs a warning encountered while patching in PLib patches. + /// + /// The warning message. + internal static void LogPatchWarning(string message) { + Debug.LogWarningFormat("[PLibPatches] {0}", message); + } + + /// + /// Saves a value into the single-instance share. + /// + /// The string key to set. Suggested key format: YourMod. + /// Category.KeyName + /// The data to be associated with that key. + public static void PutData(string key, object value) { + if (string.IsNullOrEmpty(key)) + throw new ArgumentNullException(nameof(key)); + var registry = Instance.ModData; + if (registry != null) { + if (registry.ContainsKey(key)) + registry[key] = value; + else + registry.Add(key, value); + } + } + } +} diff --git a/mod/PLibCore/PRegistryComponent.cs b/mod/PLibCore/PRegistryComponent.cs new file mode 100644 index 0000000..7500a67 --- /dev/null +++ b/mod/PLibCore/PRegistryComponent.cs @@ -0,0 +1,253 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Reflection; +using UnityEngine; + +namespace PeterHan.PLib.Core { + /// + /// A custom component added to manage shared data between mods, especially instances of + /// PForwardedComponent used by both PLib and other mods. + /// + internal sealed class PRegistryComponent : MonoBehaviour, IPLibRegistry { + /// + /// The Harmony instance name used when patching via PLib. + /// + internal const string PLIB_HARMONY = "PeterHan.PLib"; + + /// + /// A pointer to the active PLib registry. + /// + private static PRegistryComponent instance = null; + + /// + /// true if the forwarded components have been instantiated, or false otherwise. + /// + private static bool instantiated = false; + + /// + /// Applies the latest version of all forwarded components. + /// + private static void ApplyLatest() { + bool apply = false; + if (instance != null) + lock (instance) { + if (!instantiated) + apply = instantiated = true; + } + if (apply) + instance.Instantiate(); + } + + /// + /// Stores shared mod data which needs single instance existence. Available to all + /// PLib consumers through PLib API. + /// + public IDictionary ModData { get; } + + /// + /// The Harmony instance used by PLib patching. + /// + public Harmony PLibInstance { get; } + + /// + /// The candidate components with versions, from multiple assemblies. + /// + private readonly ConcurrentDictionary forwardedComponents; + + /// + /// The components actually instantiated (latest version of each). + /// + private readonly ConcurrentDictionary instantiatedComponents; + + /// + /// The latest versions of each component. + /// + private readonly ConcurrentDictionary latestComponents; + + internal PRegistryComponent() { + if (instance == null) + instance = this; + else { +#if DEBUG + PRegistry.LogPatchWarning("Multiple PLocalRegistry created!"); +#endif + } + ModData = new ConcurrentDictionary(2, 64); + forwardedComponents = new ConcurrentDictionary(2, 32); + instantiatedComponents = new ConcurrentDictionary(2, 32); + latestComponents = new ConcurrentDictionary(2, 32); + PLibInstance = new Harmony(PLIB_HARMONY); + } + + public void AddCandidateVersion(PForwardedComponent instance) { + if (instance == null) + throw new ArgumentNullException(nameof(instance)); + AddCandidateVersion(instance.ID, instance); + } + + /// + /// Adds a remote or local forwarded component by ID. + /// + /// The real ID of the component. + /// The candidate instance to add. + private void AddCandidateVersion(string id, PForwardedComponent instance) { + var versions = forwardedComponents.GetOrAdd(id, (_) => new PVersionList()); + if (versions == null) + PRegistry.LogPatchWarning("Missing version info for component type " + id); + else { + var list = versions.Components; + bool first = list.Count < 1; + list.Add(instance); +#if DEBUG + PRegistry.LogPatchDebug("Candidate version of {0} from {1}".F(id, instance. + GetOwningAssembly())); +#endif + if (first) + instance.Bootstrap(PLibInstance); + } + } + + /// + /// Applies a bootstrapper patch which will complete forwarded component initialization + /// before mods are post-loaded. + /// + internal void ApplyBootstrapper() { + try { + PLibInstance.Patch(typeof(KMod.Mod), nameof(KMod.Mod.PostLoad), prefix: + new HarmonyMethod(typeof(PRegistryComponent), nameof(ApplyLatest))); + } catch (AmbiguousMatchException e) { + PUtil.LogException(e); + } catch (ArgumentException e) { + PUtil.LogException(e); + } catch (TypeLoadException e) { + PUtil.LogException(e); + } + } + + /// + /// Called from other mods to add a candidate version of a particular component. + /// + /// The component to be added. + internal void DoAddCandidateVersion(object instance) { + AddCandidateVersion(instance.GetType().FullName, new PRemoteComponent(instance)); + } + + /// + /// Called from other mods to get a list of all components with the given ID. + /// + /// The component ID to retrieve. + /// The instantiated instance of that component, or null if no component by + /// that name was found or ever registered. + internal System.Collections.ICollection DoGetAllComponents(string id) { + if (!forwardedComponents.TryGetValue(id, out PVersionList all)) + all = null; + return all?.Components; + } + + /// + /// Called from other mods to get the instantiated version of a particular component. + /// + /// The component ID to retrieve. + /// The instantiated instance of that component, or null if no component by + /// that name was found or successfully instantiated. + internal object DoGetLatestVersion(string id) { + if (!instantiatedComponents.TryGetValue(id, out object component)) + component = null; + return component; + } + + public IEnumerable GetAllComponents(string id) { + if (string.IsNullOrEmpty(id)) + throw new ArgumentNullException(nameof(id)); + if (!forwardedComponents.TryGetValue(id, out PVersionList all)) + all = null; + return all?.Components; + } + + public PForwardedComponent GetLatestVersion(string id) { + if (string.IsNullOrEmpty(id)) + throw new ArgumentNullException(nameof(id)); + if (!latestComponents.TryGetValue(id, out PForwardedComponent remoteComponent)) { +#if DEBUG + PRegistry.LogPatchWarning("Unable to find a component matching: " + id); +#endif + remoteComponent = null; + } + return remoteComponent; + } + + public object GetSharedData(string id) { + if (!forwardedComponents.TryGetValue(id, out PVersionList all)) + all = null; + return all?.SharedData; + } + + /// + /// Goes through the forwarded components, and picks the latest version of each to + /// instantiate. + /// + public void Instantiate() { + foreach (var pair in forwardedComponents) { + // Sort value by version + var versions = pair.Value.Components; + int n = versions.Count; + if (n > 0) { + string id = pair.Key; + versions.Sort(); + var component = versions[n - 1]; + latestComponents.GetOrAdd(id, component); +#if DEBUG + PRegistry.LogPatchDebug("Instantiating component {0} using version {1} from assembly {2}".F( + id, component.Version, component.GetOwningAssembly().FullName)); +#endif + try { + instantiatedComponents.GetOrAdd(id, component?.DoInitialize( + PLibInstance)); + } catch (Exception e) { + PRegistry.LogPatchWarning("Error when instantiating component " + id + + ":"); + PUtil.LogException(e); + } + } + } + // Post initialize for component compatibility + foreach (var pair in latestComponents) + try { + pair.Value.PostInitialize(PLibInstance); + } catch (Exception e) { + PRegistry.LogPatchWarning("Error when instantiating component " + + pair.Key + ":"); + PUtil.LogException(e); + } + } + + public void SetSharedData(string id, object data) { + if (forwardedComponents.TryGetValue(id, out PVersionList all)) + all.SharedData = data; + } + + public override string ToString() { + return forwardedComponents.ToString(); + } + } +} diff --git a/mod/PLibCore/PStateMachines.cs b/mod/PLibCore/PStateMachines.cs new file mode 100644 index 0000000..40438c8 --- /dev/null +++ b/mod/PLibCore/PStateMachines.cs @@ -0,0 +1,105 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; + +namespace PeterHan.PLib.Core { + /// + /// Contains tools for dealing with state machines. + /// + public static class PStateMachines { + /// + /// Creates and initializes a new state. This method should be used in a postfix patch + /// on InitializeStates if new states are to be added. + /// + /// The state machine type. + /// The state machine Instance type. + /// The base state machine. + /// The state name. + /// The new state. + public static GameStateMachine.State CreateState( + this GameStateMachine sm, string name) + where T : GameStateMachine where I : + GameStateMachine.GameInstance { + var state = new GameStateMachine.State(); + if (string.IsNullOrEmpty(name)) + name = "State"; + if (sm == null) + throw new ArgumentNullException(nameof(sm)); + state.defaultState = sm.GetDefaultState(); + // Process any sub parameters + sm.CreateStates(state); + sm.BindState(sm.root, state, name); + return state; + } + + /// + /// Creates and initializes a new state. This method should be used in a postfix patch + /// on InitializeStates if new states are to be added. + /// + /// The state machine type. + /// The state machine Instance type. + /// The state machine Target type. + /// The base state machine. + /// The state name. + /// The new state. + public static GameStateMachine.State CreateState( + this GameStateMachine sm, string name) where M : IStateMachineTarget + where T : GameStateMachine where I : + GameStateMachine.GameInstance { + var state = new GameStateMachine.State(); + if (string.IsNullOrEmpty(name)) + name = "State"; + if (sm == null) + throw new ArgumentNullException(nameof(sm)); + state.defaultState = sm.GetDefaultState(); + // Process any sub parameters + sm.CreateStates(state); + sm.BindState(sm.root, state, name); + return state; + } + + /// + /// Clears the existing Enter actions on a state. + /// + /// The state to modify. + public static void ClearEnterActions(this StateMachine.BaseState state) { + if (state != null) + state.enterActions.Clear(); + } + + /// + /// Clears the existing Exit actions on a state. + /// + /// The state to modify. + public static void ClearExitActions(this StateMachine.BaseState state) { + if (state != null) + state.exitActions.Clear(); + } + + /// + /// Clears the existing Transition actions on a state. Parameter transitions are not + /// affected. + /// + /// The state to modify. + public static void ClearTransitions(this StateMachine.BaseState state) { + if (state != null) + state.transitions.Clear(); + } + } +} diff --git a/mod/PLibCore/PTranspilerTools.cs b/mod/PLibCore/PTranspilerTools.cs new file mode 100644 index 0000000..33e8f69 --- /dev/null +++ b/mod/PLibCore/PTranspilerTools.cs @@ -0,0 +1,359 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; + +namespace PeterHan.PLib.Core { + /// + /// A utility class with transpiler tools. + /// + internal static class PTranspilerTools { + /// + /// The opcodes that branch control conditionally. + /// + private static readonly ISet BRANCH_CODES; + + /// + /// Opcodes to load an integer onto the stack. + /// + internal static readonly OpCode[] LOAD_INT = { + OpCodes.Ldc_I4_M1, OpCodes.Ldc_I4_0, OpCodes.Ldc_I4_1, OpCodes.Ldc_I4_2, + OpCodes.Ldc_I4_3, OpCodes.Ldc_I4_4, OpCodes.Ldc_I4_5, OpCodes.Ldc_I4_6, + OpCodes.Ldc_I4_7, OpCodes.Ldc_I4_8 + }; + + static PTranspilerTools() { + // OpCode has a GetHashCode method! + BRANCH_CODES = new HashSet { + OpCodes.Beq, + OpCodes.Beq_S, + OpCodes.Bge, + OpCodes.Bge_S, + OpCodes.Bge_Un, + OpCodes.Bge_Un_S, + OpCodes.Bgt, + OpCodes.Bgt_S, + OpCodes.Bgt_Un, + OpCodes.Bgt_Un_S, + OpCodes.Ble, + OpCodes.Ble_S, + OpCodes.Ble_Un, + OpCodes.Ble_Un_S, + OpCodes.Blt, + OpCodes.Blt_S, + OpCodes.Blt_Un, + OpCodes.Blt_Un_S, + OpCodes.Bne_Un, + OpCodes.Bne_Un_S, + OpCodes.Brfalse, + OpCodes.Brfalse_S, + OpCodes.Brtrue, + OpCodes.Brtrue_S, + }; + } + + /// + /// Compares the method parameters and throws ArgumentException if they do not match. + /// + /// The victim method. + /// The method's parameter types. + /// The replacement method. + internal static void CompareMethodParams(MethodInfo victim, Type[] paramTypes, + MethodInfo newMethod) { + var newTypes = GetParameterTypes(newMethod); + if (!newMethod.IsStatic) + newTypes = PushDeclaringType(newTypes, newMethod.DeclaringType); + if (!victim.IsStatic) + paramTypes = PushDeclaringType(paramTypes, victim.DeclaringType); + int n = paramTypes.Length; + // Argument count check + if (newTypes.Length != n) + throw new ArgumentException(("New method {0} ({1:D} arguments) does not " + + "match method {2} ({3:D} arguments)").F(newMethod.Name, newTypes.Length, + victim.Name, n)); + // Argument type check + for (int i = 0; i < n; i++) + if (!newTypes[i].IsAssignableFrom(paramTypes[i])) + throw new ArgumentException(("Argument {0:D}: New method type {1} does " + + "not match old method type {2}").F(i, paramTypes[i].FullName, + newTypes[i].FullName)); + if (!victim.ReturnType.IsAssignableFrom(newMethod.ReturnType)) + throw new ArgumentException(("New method {0} (returns {1}) does not match " + + "method {2} (returns {3})").F(newMethod.Name, newMethod. + ReturnType, victim.Name, victim.ReturnType)); + } + + /// + /// Pushes the specified value onto the evaluation stack. This method does not work on + /// compound value types or by-ref types, as those need a local variable. If the value + /// is DBNull.Value, then default(value) will be used instead. + /// + /// The IL generator where the opcodes will be emitted. + /// The type of the value to generate. + /// The value to load. + /// true if instructions were pushed (all basic types and reference types), + /// or false otherwise (by ref type or compound value type). + private static bool GenerateBasicLoad(ILGenerator generator, Type type, object value) { + bool ok = !type.IsByRef; + if (ok) { + if (type == typeof(int)) { + // int + if (value is int iVal) + generator.Emit(OpCodes.Ldc_I4, iVal); + else + generator.Emit(OpCodes.Ldc_I4_0); + } else if (type == typeof(char)) { + // char + if (value is char cVal) + generator.Emit(OpCodes.Ldc_I4, cVal); + else + generator.Emit(OpCodes.Ldc_I4_0); + } else if (type == typeof(short)) { + // short + if (value is short sVal) + generator.Emit(OpCodes.Ldc_I4, sVal); + else + generator.Emit(OpCodes.Ldc_I4_0); + } else if (type == typeof(uint)) { + // uint + if (value is uint uiVal) + generator.Emit(OpCodes.Ldc_I4, (int)uiVal); + else + generator.Emit(OpCodes.Ldc_I4_0); + } else if (type == typeof(ushort)) { + // ushort + if (value is ushort usVal) + generator.Emit(OpCodes.Ldc_I4, usVal); + else + generator.Emit(OpCodes.Ldc_I4_0); + } else if (type == typeof(byte)) { + // byte (unsigned) + if (value is byte bVal) + generator.Emit(OpCodes.Ldc_I4_S, bVal); + else + generator.Emit(OpCodes.Ldc_I4_0); + } else if (type == typeof(sbyte)) { + // byte (signed) + if (value is sbyte sbVal) + generator.Emit(OpCodes.Ldc_I4, sbVal); + else + generator.Emit(OpCodes.Ldc_I4_0); + } else if (type == typeof(bool)) + // bool + generator.Emit((value is bool kVal && kVal) ? OpCodes.Ldc_I4_1 : OpCodes. + Ldc_I4_0); + else if (type == typeof(long)) + // long + generator.Emit(OpCodes.Ldc_I8, (value is long lVal) ? lVal : 0L); + else if (type == typeof(ulong)) + // ulong + generator.Emit(OpCodes.Ldc_I8, (value is ulong ulVal) ? (long)ulVal : 0L); + else if (type == typeof(float)) + // float + generator.Emit(OpCodes.Ldc_R4, (value is float fVal) ? fVal : 0.0f); + else if (type == typeof(double)) + // double + generator.Emit(OpCodes.Ldc_R8, (value is double dVal) ? dVal : 0.0); + else if (type == typeof(string)) + // string + generator.Emit(OpCodes.Ldstr, (value is string sVal) ? sVal : ""); + else if (type.IsPointer) + // All pointers + generator.Emit(OpCodes.Ldc_I4_0); + else if (!type.IsValueType) + // All reference types (including Nullable) + generator.Emit(OpCodes.Ldnull); + else + ok = false; + } + return ok; + } + + /// + /// Creates a local if necessary, and generates initialization code for the default + /// value of the specified type. The resulting value ends up on the stack in a form + /// that it would be used for the method argument. + /// + /// The IL generator where the opcodes will be emitted. + /// The type to load and initialize. + /// The default value to load. + internal static void GenerateDefaultLoad(ILGenerator generator, Type type, + object defaultValue) { + if (type == null) + throw new ArgumentNullException(nameof(type)); + if (!GenerateBasicLoad(generator, type, defaultValue)) { + // This method will fail if there are more than 255 local variables, oh no! + if (type.IsByRef) { + var baseType = type.GetElementType(); + var localVariable = generator.DeclareLocal(baseType); + int index = localVariable.LocalIndex; + if (GenerateBasicLoad(generator, baseType, defaultValue)) + // Reference type or basic type + generator.Emit(OpCodes.Stloc_S, index); + else { + // Value types not handled by it, ref vars cannot have a default value + generator.Emit(OpCodes.Ldloca_S, index); + generator.Emit(OpCodes.Initobj, type); + } + generator.Emit(OpCodes.Ldloca_S, index); + } else { + var localVariable = generator.DeclareLocal(type); + int index = localVariable.LocalIndex; + // Is a value type, those cannot have default values other than default() + // as it must be constant + generator.Emit(OpCodes.Ldloca_S, index); + generator.Emit(OpCodes.Initobj, type); + generator.Emit(OpCodes.Ldloc_S, index); + } + } + } + + /// + /// Gets the method's parameter types. + /// + /// The method to query. + /// The type of each parameter of the method. + internal static Type[] GetParameterTypes(this MethodInfo method) { + if (method == null) + throw new ArgumentNullException(nameof(method)); + var pm = method.GetParameters(); + int n = pm.Length; + var types = new Type[n]; + for (int i = 0; i < n; i++) + types[i] = pm[i].ParameterType; + return types; + } + + /// + /// Checks to see if an instruction opcode is a branch instruction. + /// + /// The opcode to check. + /// true if it is a branch, or false otherwise. + internal static bool IsConditionalBranchInstruction(OpCode opcode) { + return BRANCH_CODES.Contains(opcode); + } + + /// + /// Adds a logger to all unhandled exceptions. + /// + /// Not for production use. + /// + internal static void LogAllExceptions() { + AppDomain.CurrentDomain.UnhandledException += OnThrown; + } + + /// + /// Adds a logger to all failed assertions. The assertions will still fail, but a stack + /// trace will be printed for each failed assertion. + /// + /// Not for production use. + /// + internal static void LogAllFailedAsserts() { + var inst = new Harmony("PeterHan.PLib.LogFailedAsserts"); + MethodBase assert; + var handler = new HarmonyMethod(typeof(PTranspilerTools), nameof(OnAssertFailed)); + try { + // Assert(bool) + assert = typeof(Debug).GetMethodSafe("Assert", true, typeof(bool)); + if (assert != null) + inst.Patch(assert, handler); + // Assert(bool, object) + assert = typeof(Debug).GetMethodSafe("Assert", true, typeof(bool), typeof( + object)); + if (assert != null) + inst.Patch(assert, handler); + // Assert(bool, object, UnityEngine.Object) + assert = typeof(Debug).GetMethodSafe("Assert", true, typeof(bool), typeof( + object), typeof(UnityEngine.Object)); + if (assert != null) + inst.Patch(assert, handler); + } catch (Exception e) { + PUtil.LogException(e); + } + } + + /// + /// Modifies a load instruction to load the specified constant, using short forms if + /// possible. + /// + /// The instruction to modify. + /// The new i4 constant to load. + internal static void ModifyLoadI4(CodeInstruction instruction, int newValue) { + if (newValue >= -1 && newValue <= 8) { + // Short form: constant + instruction.opcode = LOAD_INT[newValue + 1]; + instruction.operand = null; + } else if (newValue >= sbyte.MinValue && newValue <= sbyte.MaxValue) { + // Short form: -128 to 127 -- looks like Harmony has issues with emitting + // the operand as a Byte + instruction.opcode = OpCodes.Ldc_I4_S; + instruction.operand = newValue; + } else { + // Long form + instruction.opcode = OpCodes.Ldc_I4; + instruction.operand = newValue; + } + } + + /// + /// Logs a failed assertion that is about to occur. + /// + internal static void OnAssertFailed(bool condition) { + if (!condition) { + Debug.LogError("Assert is about to fail:"); + Debug.LogError(new System.Diagnostics.StackTrace().ToString()); + } + } + + /// + /// An optional handler for all unhandled exceptions. + /// + internal static void OnThrown(object sender, UnhandledExceptionEventArgs e) { + if (!e.IsTerminating) { + Debug.LogError("Unhandled exception on Thread " + System.Threading.Thread. + CurrentThread.Name); + if (e.ExceptionObject is Exception ex) + Debug.LogException(ex); + else + Debug.LogError(e.ExceptionObject); + } + } + + /// + /// Inserts the declaring instance type to the front of the specified array. + /// + /// The parameter types. + /// The type which declared this method. + /// The types with declaringType inserted at the beginning. + internal static Type[] PushDeclaringType(Type[] types, Type declaringType) { + int n = types.Length; + // Allow special case of passing "this" as first static arg + var newParamTypes = new Type[n + 1]; + if (declaringType.IsValueType) + declaringType = declaringType.MakeByRefType(); + newParamTypes[0] = declaringType; + for (int i = 0; i < n; i++) + newParamTypes[i + 1] = types[i]; + return newParamTypes; + } + } +} diff --git a/mod/PLibCore/PUtil.cs b/mod/PLibCore/PUtil.cs new file mode 100644 index 0000000..bb31b1b --- /dev/null +++ b/mod/PLibCore/PUtil.cs @@ -0,0 +1,252 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using UnityEngine; + +namespace PeterHan.PLib.Core { + /// + /// Static utility functions used across mods. + /// + public static class PUtil { + /// + /// Retrieves the current changelist version of the game. LU-371502 has a version of + /// 371502u. + /// + /// If the version cannot be determined, returns 0. + /// + public static uint GameVersion { get; } + + /// + /// Whether PLib has been initialized. + /// + private static volatile bool initialized; + + /// + /// Serializes attempts to initialize PLib. + /// + private static readonly object initializeLock; + + /// + /// The characters which are not allowed in file names. + /// + private static readonly HashSet INVALID_FILE_CHARS; + + static PUtil() { + initialized = false; + initializeLock = new object(); + INVALID_FILE_CHARS = new HashSet(Path.GetInvalidFileNameChars()); + GameVersion = GetGameVersion(); + } + + /// + /// Generates a mapping of assembly names to Mod instances. Only works after all mods + /// have been loaded. + /// + /// A mapping from assemblies to the Mod instance that owns them. + public static IDictionary CreateAssemblyToModTable() { + var allMods = Global.Instance?.modManager?.mods; + var result = new Dictionary(32); + if (allMods != null) + foreach (var mod in allMods) { + var dlls = mod?.loaded_mod_data?.dlls; + if (dlls != null) + foreach (var assembly in dlls) + result[assembly] = mod; + } + return result; + } + + /// + /// Finds the distance between two points. + /// + /// The first X coordinate. + /// The first Y coordinate. + /// The second X coordinate. + /// The second Y coordinate. + /// The non-taxicab (straight line) distance between the points. + public static float Distance(float x1, float y1, float x2, float y2) { + float dx = x2 - x1, dy = y2 - y1; + return Mathf.Sqrt(dx * dx + dy * dy); + } + + /// + /// Finds the distance between two points. + /// + /// The first X coordinate. + /// The first Y coordinate. + /// The second X coordinate. + /// The second Y coordinate. + /// The non-taxicab (straight line) distance between the points. + public static double Distance(double x1, double y1, double x2, double y2) { + double dx = x2 - x1, dy = y2 - y1; + return Math.Sqrt(dx * dx + dy * dy); + } + + /// + /// Retrieves the current game version from the Klei code. + /// + /// The change list version of the game, or 0 if it cannot be determined. + private static uint GetGameVersion() { + /* + * KleiVersion.ChangeList is a const which is substituted at compile time; if + * accessed directly, PLib would have a version "baked in" and would never + * update depending on the game version in use. + */ + var field = PPatchTools.GetFieldSafe(typeof(KleiVersion), nameof(KleiVersion. + ChangeList), true); + uint ver = 0U; + if (field != null && field.GetValue(null) is uint newVer) + ver = newVer; + return ver; + } + + /// + /// Retrieves the mod directory for the specified assembly. If an archived version is + /// running, the path to that version is reported. + /// + /// The assembly used for a mod. + /// The directory where the mod is currently executing. + public static string GetModPath(Assembly modDLL) { + if (modDLL == null) + throw new ArgumentNullException(nameof(modDLL)); + string dir = null; + try { + dir = Directory.GetParent(modDLL.Location)?.FullName; + } catch (NotSupportedException e) { + // Guess from the Klei strings + LogExcWarn(e); + } catch (System.Security.SecurityException e) { + // Guess from the Klei strings + LogExcWarn(e); + } catch (IOException e) { + // Guess from the Klei strings + LogExcWarn(e); + } + if (dir == null) + dir = Path.Combine(KMod.Manager.GetDirectory(), modDLL.GetName()?.Name ?? ""); + return dir; + } + + /// + /// Initializes PLib. While most components are initialized dynamically if used, some + /// key infrastructure must be initialized first. + /// + /// If true, the mod name and version is emitted to the log. + public static void InitLibrary(bool logVersion = true) { + var assembly = Assembly.GetCallingAssembly(); + lock (initializeLock) { + if (!initialized) { + initialized = true; + if (assembly != null && logVersion) + Debug.LogFormat("[PLib] Mod {0} initialized, version {1}", + assembly.GetNameSafe(), assembly.GetFileVersion() ?? "Unknown"); + } + } + } + + /// + /// Returns true if the file is a valid file name. If the argument contains path + /// separator characters, this method returns false, since that is not a valid file + /// name. + /// + /// Null and empty file names are not valid file names. + /// + /// The file name to check. + /// true if the name could be used to name a file, or false otherwise. + public static bool IsValidFileName(string file) { + bool valid = (file != null); + if (valid) { + // Cannot contain characters in INVALID_FILE_CHARS + int len = file.Length; + for (int i = 0; i < len && valid; i++) + if (INVALID_FILE_CHARS.Contains(file[i])) + valid = false; + } + return valid; + } + + /// + /// Logs a message to the debug log. + /// + /// The message to log. + public static void LogDebug(object message) { + Debug.LogFormat("[PLib/{0}] {1}", Assembly.GetCallingAssembly().GetNameSafe(), + message); + } + + /// + /// Logs an error message to the debug log. + /// + /// The message to log. + public static void LogError(object message) { + // Cannot make a utility property or method for Assembly.GetCalling... because + // its caller would then be the assembly PLib is in, not the assembly which + // invoked LogXXX + Debug.LogErrorFormat("[PLib/{0}] {1}", Assembly.GetCallingAssembly(). + GetNameSafe() ?? "?", message); + } + + /// + /// Logs an exception message to the debug log. + /// + /// The exception to log. + public static void LogException(Exception thrown) { + Debug.LogErrorFormat("[PLib/{0}] {1} {2} {3}", Assembly.GetCallingAssembly(). + GetNameSafe() ?? "?", thrown.GetType(), thrown.Message, thrown.StackTrace); + } + + /// + /// Logs an exception message to the debug log at WARNING level. + /// + /// The exception to log. + public static void LogExcWarn(Exception thrown) { + Debug.LogWarningFormat("[PLib/{0}] {1} {2} {3}", Assembly.GetCallingAssembly(). + GetNameSafe() ?? "?", thrown.GetType(), thrown.Message, thrown.StackTrace); + } + + /// + /// Logs a warning message to the debug log. + /// + /// The message to log. + public static void LogWarning(object message) { + Debug.LogWarningFormat("[PLib/{0}] {1}", Assembly.GetCallingAssembly(). + GetNameSafe() ?? "?", message); + } + + /// + /// Measures how long the specified code takes to run. The result is logged to the + /// debug log in microseconds. + /// + /// The code to execute. + /// The name used in the log to describe this code. + public static void Time(System.Action code, string header = "Code") { + if (code == null) + throw new ArgumentNullException(nameof(code)); + var watch = new System.Diagnostics.Stopwatch(); + watch.Start(); + code.Invoke(); + watch.Stop(); + LogDebug("{1} took {0:D} us".F(watch.ElapsedTicks * 1000000L / System.Diagnostics. + Stopwatch.Frequency, header)); + } + } +} diff --git a/mod/PLibCore/PVersion.cs b/mod/PLibCore/PVersion.cs new file mode 100644 index 0000000..585e4d0 --- /dev/null +++ b/mod/PLibCore/PVersion.cs @@ -0,0 +1,30 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +namespace PeterHan.PLib { + /// + /// Used to pass the PLib version in the ILMerged assembly since the PLib version will + /// not be included in the file version. + /// + public static class PVersion { + /// + /// The PLib version. + /// + public const string VERSION = "4.11.0.0"; + } +} diff --git a/mod/PLibCore/PVersionList.cs b/mod/PLibCore/PVersionList.cs new file mode 100644 index 0000000..43b44d5 --- /dev/null +++ b/mod/PLibCore/PVersionList.cs @@ -0,0 +1,45 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System.Collections.Generic; + +namespace PeterHan.PLib.Core { + /// + /// Stores a list of forwarded component versions and their shared data. + /// + internal sealed class PVersionList { + /// + /// The list of registered components. + /// + public List Components { get; } + + /// + /// The data shared between all components. + /// + public object SharedData { get; set; } + + public PVersionList() { + Components = new List(32); + SharedData = null; + } + + public override string ToString() { + return Components.ToString(); + } + } +} diff --git a/mod/PLibCore/PatchManager/IPLibAnnotation.cs b/mod/PLibCore/PatchManager/IPLibAnnotation.cs new file mode 100644 index 0000000..912c1c7 --- /dev/null +++ b/mod/PLibCore/PatchManager/IPLibAnnotation.cs @@ -0,0 +1,38 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System.Reflection; + +namespace PeterHan.PLib.PatchManager { + /// + /// The commmon parent of [PLibPatch] and [PLibMethod]. + /// + internal interface IPLibAnnotation { + /// + /// When this method is run. + /// + uint Runtime { get; } + + /// + /// Creates a new patch method instance. + /// + /// The method that was attributed. + /// An instance that can execute this patch. + IPatchMethodInstance CreateInstance(MethodInfo method); + } +} diff --git a/mod/PLibCore/PatchManager/IPatchMethodInstance.cs b/mod/PLibCore/PatchManager/IPatchMethodInstance.cs new file mode 100644 index 0000000..9a3ca10 --- /dev/null +++ b/mod/PLibCore/PatchManager/IPatchMethodInstance.cs @@ -0,0 +1,33 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; + +namespace PeterHan.PLib.PatchManager { + /// + /// Refers to a single instance of the annotation, with its annotated method. + /// + public interface IPatchMethodInstance { + /// + /// Runs the patch or method if the conditions are met. This method should check its + /// preconditions before executing the target. + /// + /// The Harmony instance to use. + void Run(Harmony instance); + } +} diff --git a/mod/PLibCore/PatchManager/PLibMethodAttribute.cs b/mod/PLibCore/PatchManager/PLibMethodAttribute.cs new file mode 100644 index 0000000..132ef27 --- /dev/null +++ b/mod/PLibCore/PatchManager/PLibMethodAttribute.cs @@ -0,0 +1,115 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using PeterHan.PLib.Core; +using System; +using System.Reflection; + +namespace PeterHan.PLib.PatchManager { + /// + /// Represents a method that will be run by PLib at a specific time to reduce the number + /// of patches required and allow conditional integration with other mods. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] + public sealed class PLibMethodAttribute : Attribute, IPLibAnnotation { + /// + /// Requires the specified assembly to be loaded for this method to run. If RequireType + /// is null or empty, no particular types need to be defined in the assembly. The + /// assembly name is required, but the version is optional (strong named assemblies + /// can never load in ONI, since neither Unity nor Klei types are strong named...) + /// + public string RequireAssembly { get; set; } + + /// + /// Requires the specified type full name (not assembly qualified name) to exist for + /// this method to run. If RequireAssembly is null or empty, a type in any assembly + /// will satisfy the requirement. + /// + public string RequireType { get; set; } + + /// + /// When this method is run. + /// + public uint Runtime { get; } + + public PLibMethodAttribute(uint runtime) { + Runtime = runtime; + } + + /// + /// Creates a new patch method instance. + /// + /// The method that was attributed. + /// An instance that can execute this patch. + public IPatchMethodInstance CreateInstance(MethodInfo method) { + return new PLibMethodInstance(this, method); + } + + public override string ToString() { + return "PLibMethod[RunAt={0}]".F(RunAt.ToString(Runtime)); + } + } + + /// + /// Refers to a single instance of the annotation, with its annotated method. + /// + internal sealed class PLibMethodInstance : IPatchMethodInstance { + /// + /// The attribute describing the method. + /// + public PLibMethodAttribute Descriptor { get; } + + /// + /// The method to run. + /// + public MethodInfo Method { get; } + + public PLibMethodInstance(PLibMethodAttribute attribute, MethodInfo method) { + Descriptor = attribute ?? throw new ArgumentNullException(nameof(attribute)); + Method = method ?? throw new ArgumentNullException(nameof(method)); + } + + /// + /// Runs the method, passing the required parameters if any. + /// + /// The Harmony instance to use if the method wants to + /// perform a patch. + public void Run(Harmony instance) { + if (PPatchManager.CheckConditions(Descriptor.RequireAssembly, Descriptor. + RequireType, out Type requiredType)) { + // Only runs once, no meaningful savings with a delegate + var paramTypes = Method.GetParameterTypes(); + int len = paramTypes.Length; + if (len <= 0) + // No parameters, static method only + Method.Invoke(null, null); + else if (paramTypes[0] == typeof(Harmony)) { + if (len == 1) + // Harmony instance parameter + Method.Invoke(null, new object[] { instance }); + else if (len == 2 && paramTypes[1] == typeof(Type)) + // Type parameter + Method.Invoke(null, new object[] { instance, requiredType }); + } else + PUtil.LogWarning("Invalid signature for PLibMethod - must have (), " + + "(HarmonyInstance), or (HarmonyInstance, Type)"); + } + } + } +} diff --git a/mod/PLibCore/PatchManager/PLibPatchAttribute.cs b/mod/PLibCore/PatchManager/PLibPatchAttribute.cs new file mode 100644 index 0000000..588a3a3 --- /dev/null +++ b/mod/PLibCore/PatchManager/PLibPatchAttribute.cs @@ -0,0 +1,312 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using PeterHan.PLib.Core; +using System; +using System.Reflection; + +namespace PeterHan.PLib.PatchManager { + /// + /// Represents a method that will be patched by PLib at a specific time to allow + /// conditional integration with other mods. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] + public sealed class PLibPatchAttribute : Attribute, IPLibAnnotation { + /// + /// The required argument types. If null, any matching method name is patched, or an + /// exception thrown if more than one matches. + /// + public Type[] ArgumentTypes { get; set; } + + /// + /// If this flag is set, the patch will emit only at DEBUG level if the target method + /// is not found or matches ambiguously. + /// + public bool IgnoreOnFail { get; set; } + + /// + /// The name of the method to patch. + /// + public string MethodName { get; } + + /// + /// The type of patch to apply through Harmony. + /// + public HarmonyPatchType PatchType { get; set; } + + /// + /// Requires the specified assembly to be loaded for this method to run. If RequireType + /// is null or empty, no particular types need to be defined in the assembly. The + /// assembly name is required, but the version is optional (strong named assemblies + /// can never load in ONI, since neither Unity nor Klei types are strong named...) + /// + public string RequireAssembly { get; set; } + + /// + /// Requires the specified type full name (not assembly qualified name) to exist for + /// this method to run. If RequireAssembly is null or empty, a type in any assembly + /// will satisfy the requirement. + /// + public string RequireType { get; set; } + + /// + /// When this method is run. + /// + public uint Runtime { get; } + + /// + /// The type to patch. If null, the patcher will try to use the required type from the + /// RequireType parameter. + /// + public Type TargetType { get; } + + /// + /// Patches a concrete type and method. + /// + /// Passing null as the method name will attempt to patch a constructor. Only one + /// declared constructor may be present, or the call will fail at patch time. + /// + /// When to apply the patch. + /// The type to patch. + /// The method name to patch. + public PLibPatchAttribute(uint runtime, Type target, string method) { + ArgumentTypes = null; + IgnoreOnFail = false; + MethodName = method; + PatchType = HarmonyPatchType.All; + Runtime = runtime; + TargetType = target ?? throw new ArgumentNullException(nameof(target)); + } + + /// + /// Patches a concrete type and overloaded method. + /// + /// Passing null as the method name will attempt to patch a constructor. + /// + /// When to apply the patch. + /// The type to patch. + /// The method name to patch. + /// The types of the overload to patch. + public PLibPatchAttribute(uint runtime, Type target, string method, + params Type[] argTypes) { + ArgumentTypes = argTypes; + IgnoreOnFail = false; + MethodName = method; + PatchType = HarmonyPatchType.All; + Runtime = runtime; + TargetType = target ?? throw new ArgumentNullException(nameof(target)); + } + + /// + /// Patches a method only if a specified type is available. Use optional parameters to + /// specify the type to patch using RequireType / RequireAssembly. + /// + /// Passing null as the method name will attempt to patch a constructor. Only one + /// declared constructor may be present, or the call will fail at patch time. + /// + /// When to apply the patch. + /// The method name to patch. + public PLibPatchAttribute(uint runtime, string method) { + ArgumentTypes = null; + IgnoreOnFail = false; + MethodName = method; + PatchType = HarmonyPatchType.All; + Runtime = runtime; + TargetType = null; + } + + /// + /// Patches an overloaded method only if a specified type is available. Use optional + /// parameters to specify the type to patch using RequireType / RequireAssembly. + /// + /// Passing null as the method name will attempt to patch a constructor. + /// + /// When to apply the patch. + /// The method name to patch. + /// The types of the overload to patch. + public PLibPatchAttribute(uint runtime, string method, params Type[] argTypes) { + ArgumentTypes = argTypes; + IgnoreOnFail = false; + MethodName = method; + PatchType = HarmonyPatchType.All; + Runtime = runtime; + TargetType = null; + } + + /// + /// Creates a new patch method instance. + /// + /// The method that was attributed. + /// An instance that can execute this patch. + public IPatchMethodInstance CreateInstance(MethodInfo method) { + return new PLibPatchInstance(this, method); + } + + public override string ToString() { + return "PLibPatch[RunAt={0},PatchType={1},MethodName={2}]".F(RunAt.ToString( + Runtime), PatchType, MethodName); + } + } + + /// + /// Refers to a single instance of the annotation, with its annotated method. + /// + internal sealed class PLibPatchInstance : IPatchMethodInstance { + /// + /// The attribute describing the method. + /// + public PLibPatchAttribute Descriptor { get; } + + /// + /// The method to run. + /// + public MethodInfo Method { get; } + + public PLibPatchInstance(PLibPatchAttribute attribute, MethodInfo method) { + Descriptor = attribute ?? throw new ArgumentNullException(nameof(attribute)); + Method = method ?? throw new ArgumentNullException(nameof(method)); + } + + /// + /// Calculates the patch type to perform. + /// + /// The type of Harmony patch to use for this method. + private HarmonyPatchType GetPatchType() { + var patchType = Descriptor.PatchType; + if (patchType == HarmonyPatchType.All) { + // Auto-determine the patch type based on name, if possible + string patchName = Method.Name; + foreach (var value in Enum.GetValues(typeof(HarmonyPatchType))) + if (value is HarmonyPatchType eval && eval != patchType && patchName. + EndsWith(eval.ToString(), StringComparison.Ordinal)) { + patchType = eval; + break; + } + } + return patchType; + } + + /// + /// Gets the specified instance constructor. + /// + /// The type to be constructed. + /// The target constructor. + /// If no parameter types were specified, + /// and multiple declared constructors exist. + private MethodBase GetTargetConstructor(Type targetType, Type[] argumentTypes) { + MethodBase constructor; + if (argumentTypes == null) { + var cons = targetType.GetConstructors(PPatchManager.FLAGS | BindingFlags. + Instance); + if (cons == null || cons.Length != 1) + throw new InvalidOperationException("No constructor for {0} found".F( + targetType.FullName)); + constructor = cons[0]; + } else + constructor = targetType.GetConstructor(PPatchManager.FLAGS | BindingFlags. + Instance, null, argumentTypes, null); + return constructor; + } + + /// + /// Calculates the target method to patch. + /// + /// The type to use if no type was specified. + /// The method to patch. + /// If no parameter types were specified, + /// and multiple options match the method name. + /// If the target method was not found. + private MethodBase GetTargetMethod(Type requiredType) { + var targetType = Descriptor.TargetType; + var argumentTypes = Descriptor.ArgumentTypes; + string name = Descriptor.MethodName; + MethodBase method; + if (targetType == null) + targetType = requiredType; + // Only allow non-inherited members, patching inherited members gets spooky on + // Mac OS and Linux + if (targetType == null) + throw new InvalidOperationException("No type specified to patch"); + if (string.IsNullOrEmpty(name) || name == ".ctor") + // Constructor + method = GetTargetConstructor(targetType, argumentTypes); + else + // Method + method = (argumentTypes == null) ? targetType.GetMethod(name, PPatchManager. + FLAGS_EITHER) : targetType.GetMethod(name, PPatchManager.FLAGS_EITHER, + null, argumentTypes, null); + if (method == null) + throw new InvalidOperationException("Method {0}.{1} not found".F(targetType. + FullName, name)); + return method; + } + + /// + /// Logs a message at debug level if Ignore On Patch Fail is enabled. + /// + /// The exception thrown during patching. + /// true to suppress the exception, or false to rethrow it. + private bool LogIgnoreOnFail(Exception e) { + bool ignore = Descriptor.IgnoreOnFail; + if (ignore) + PUtil.LogDebug("Patch for {0} not applied: {1}".F(Descriptor. + MethodName, e.Message)); + return ignore; + } + + /// + /// Applies the patch. + /// + /// The Harmony instance to use. + /// If the + /// If no parameter types were specified, + /// and multiple options match the method name. + public void Run(Harmony instance) { + if (PPatchManager.CheckConditions(Descriptor.RequireAssembly, Descriptor. + RequireType, out Type requiredType)) { + var dest = new HarmonyMethod(Method); + if (instance == null) + throw new ArgumentNullException(nameof(instance)); + try { + var method = GetTargetMethod(requiredType); + switch (GetPatchType()) { + case HarmonyPatchType.Postfix: + instance.Patch(method, postfix: dest); + break; + case HarmonyPatchType.Prefix: + instance.Patch(method, prefix: dest); + break; + case HarmonyPatchType.Transpiler: + instance.Patch(method, transpiler: dest); + break; + default: + throw new ArgumentOutOfRangeException(nameof(HarmonyPatchType)); + } + } catch (AmbiguousMatchException e) { + // Multi catch or filtering is not available in this version of C# + if (!LogIgnoreOnFail(e)) + throw; + } catch (InvalidOperationException e) { + if (!LogIgnoreOnFail(e)) + throw; + } + } + } + } +} diff --git a/mod/PLibCore/PatchManager/PPatchManager.cs b/mod/PLibCore/PatchManager/PPatchManager.cs new file mode 100644 index 0000000..55dd027 --- /dev/null +++ b/mod/PLibCore/PatchManager/PPatchManager.cs @@ -0,0 +1,268 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using PeterHan.PLib.Core; +using System; +using System.Collections.Generic; +using System.Reflection; + +using PrivateRunList = System.Collections.Generic.ICollection; + +namespace PeterHan.PLib.PatchManager { + /// + /// Manages patches that PLib will conditionally apply. + /// + public sealed class PPatchManager : PForwardedComponent { + /// + /// The base flags to use when matching instance or static methods. + /// + internal const BindingFlags FLAGS = PPatchTools.BASE_FLAGS | BindingFlags.DeclaredOnly; + + /// + /// The flags to use when matching instance and static methods. + /// + internal const BindingFlags FLAGS_EITHER = FLAGS | BindingFlags.Static | BindingFlags. + Instance; + + /// + /// The version of this component. Uses the running PLib version. + /// + internal static readonly Version VERSION = new Version(PVersion.VERSION); + + /// + /// The instantiated copy of this class. + /// + internal static PPatchManager Instance { get; private set; } + + /// + /// true if the AfterModsLoad patches have been run, or false otherwise. + /// + private static volatile bool afterModsLoaded = false; + + private static void Game_DestroyInstances_Postfix() { + Instance?.InvokeAllProcess(RunAt.OnEndGame, null); + } + + private static void Game_OnPrefabInit_Postfix() { + Instance?.InvokeAllProcess(RunAt.OnStartGame, null); + } + + private static void Initialize_Prefix() { + Instance?.InvokeAllProcess(RunAt.BeforeDbInit, null); + } + + private static void Initialize_Postfix() { + Instance?.InvokeAllProcess(RunAt.AfterDbInit, null); + } + + private static void Instance_Postfix() { + bool load = false; + if (Instance != null) + lock (VERSION) { + if (!afterModsLoaded) + load = afterModsLoaded = true; + } + if (load) + Instance.InvokeAllProcess(RunAt.AfterLayerableLoad, null); + } + + private static void MainMenu_OnSpawn_Postfix() { + Instance?.InvokeAllProcess(RunAt.InMainMenu, null); + } + + public override Version Version => VERSION; + + /// + /// The Harmony instance to use for patching. + /// + private readonly Harmony harmony; + + /// + /// Patches and delegates to be run at specific points in the runtime. Put the kibosh + /// on patching Db.Initialize()! + /// + private readonly IDictionary patches; + + /// + /// Checks to see if the conditions for a method running are met. + /// + /// The assembly name that must be present, or null if none is required. + /// The type full name that must be present, or null if none is required. + /// The type that was required, if typeName was not null or empty. + /// true if the requirements are met, or false otherwise. + internal static bool CheckConditions(string assemblyName, string typeName, + out Type requiredType) { + bool ok = false, emptyType = string.IsNullOrEmpty(typeName); + if (string.IsNullOrEmpty(assemblyName)) { + if (emptyType) { + requiredType = null; + ok = true; + } else { + requiredType = PPatchTools.GetTypeSafe(typeName); + ok = requiredType != null; + } + } else if (emptyType) { + requiredType = null; + // Search for assembly only, by name + foreach (var candidate in AppDomain.CurrentDomain.GetAssemblies()) + if (candidate.GetName().Name == assemblyName) { + ok = true; + break; + } + } else { + requiredType = PPatchTools.GetTypeSafe(typeName, assemblyName); + ok = requiredType != null; + } + return ok; + } + + /// + /// Creates a patch manager to execute patches at specific times. + /// + /// Create this instance in OnLoad() and use RegisterPatchClass to register a + /// patch class. + /// + /// The Harmony instance to use for patching. + public PPatchManager(Harmony harmony) { + if (harmony == null) { + PUtil.LogWarning("Use the Harmony instance from OnLoad to create PPatchManager"); + harmony = new Harmony("PLib.PostLoad." + Assembly.GetExecutingAssembly(). + GetNameSafe()); + } + this.harmony = harmony; + patches = new Dictionary(8); + InstanceData = patches; + } + + /// + /// Schedules a patch method instance to be run. + /// + /// When to run the patch. + /// The patch method instance to run. + /// The Harmony instance to use for patching. + private void AddHandler(uint when, IPatchMethodInstance instance) { + if (!patches.TryGetValue(when, out PrivateRunList atTime)) + patches.Add(when, atTime = new List(16)); + atTime.Add(instance); + } + + public override void Initialize(Harmony plibInstance) { + Instance = this; + + // Db + plibInstance.Patch(typeof(Db), nameof(Db.Initialize), prefix: PatchMethod(nameof( + Initialize_Prefix)), postfix: PatchMethod(nameof(Initialize_Postfix))); + + // Game + plibInstance.Patch(typeof(Game), "DestroyInstances", postfix: PatchMethod(nameof( + Game_DestroyInstances_Postfix))); + plibInstance.Patch(typeof(Game), "OnPrefabInit", postfix: PatchMethod(nameof( + Game_OnPrefabInit_Postfix))); + + // GlobalResources + plibInstance.Patch(typeof(GlobalResources), "Instance", postfix: + PatchMethod(nameof(Instance_Postfix))); + + // MainMenu + plibInstance.Patch(typeof(MainMenu), "OnSpawn", postfix: PatchMethod( + nameof(MainMenu_OnSpawn_Postfix))); + } + + public override void PostInitialize(Harmony plibInstance) { + InvokeAllProcess(RunAt.AfterModsLoad, null); + } + + public override void Process(uint when, object _) { + if (patches.TryGetValue(when, out PrivateRunList atTime) && atTime != null && + atTime.Count > 0) { + string stage = RunAt.ToString(when); +#if DEBUG + PRegistry.LogPatchDebug("Executing {0:D} handler(s) from {1} for stage {2}".F( + atTime.Count, Assembly.GetExecutingAssembly().GetNameSafe() ?? "?", stage)); +#endif + foreach (var patch in atTime) + try { + patch.Run(harmony); + } catch (TargetInvocationException e) { + // Use the inner exception + PUtil.LogError("Error running patches for stage " + stage + ":"); + PUtil.LogException(e.GetBaseException()); + } catch (Exception e) { + // Say which mod's postload crashed + PUtil.LogError("Error running patches for stage " + stage + ":"); + PUtil.LogException(e); + } + } + } + + /// + /// Registers a single patch to be run by Patch Manager. Obviously, the patch must be + /// registered before the time that it is used. + /// + /// The time when the method should be run. + /// The patch to execute. + public void RegisterPatch(uint when, IPatchMethodInstance patch) { + RegisterForForwarding(); + if (patch == null) + throw new ArgumentNullException(nameof(patch)); + if (when == RunAt.Immediately) + // Now now now! + patch.Run(harmony); + else + AddHandler(when, patch); + } + + /// + /// Registers a class containing methods for [PLibPatch] and [PLibMethod] handlers. + /// All methods, public and private, of the type will be searched for annotations. + /// However, nested and derived types will not be searched, nor will inherited methods. + /// + /// This method cannot be used to register a class from another mod, as the annotations + /// on those methods would have a different assembly qualified name and would thus + /// not be recognized. + /// + /// The type to register. + /// The Harmony instance to use for immediate patches. Use + /// the instance provided from UserMod2.OnLoad(). + public void RegisterPatchClass(Type type) { + int count = 0; + if (type == null) + throw new ArgumentNullException(nameof(type)); + RegisterForForwarding(); + foreach (var method in type.GetMethods(FLAGS | BindingFlags.Static)) + foreach (var attrib in method.GetCustomAttributes(true)) + if (attrib is IPLibAnnotation pm) { + var when = pm.Runtime; + var instance = pm.CreateInstance(method); + if (when == RunAt.Immediately) + // Now now now! + instance.Run(harmony); + else + AddHandler(pm.Runtime, instance); + count++; + } + if (count > 0) + PRegistry.LogPatchDebug("Registered {0:D} handler(s) for {1}".F(count, + Assembly.GetCallingAssembly().GetNameSafe() ?? "?")); + else + PRegistry.LogPatchWarning("RegisterPatchClass could not find any handlers!"); + } + } +} diff --git a/mod/PLibCore/PatchManager/RunAt.cs b/mod/PLibCore/PatchManager/RunAt.cs new file mode 100644 index 0000000..a60e9b4 --- /dev/null +++ b/mod/PLibCore/PatchManager/RunAt.cs @@ -0,0 +1,93 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +namespace PeterHan.PLib.PatchManager { + /// + /// Describes when a PLibPatch or PLibMethod should be invoked. + /// + /// Due to a bug in ILRepack an enum type in PLib cannot be used as a parameter for a + /// custom attribute. ILmerge does not have this bug. + /// + public static class RunAt { + /// + /// Runs the method/patch now. + /// + /// Note that mods may load in any order and thus not all mods may be initialized at + /// this time. + /// + public const uint Immediately = 0U; + + /// + /// Runs after all mods load, but before most other aspects of the game (including + /// Assets, Db, and so forth) are initialized. This will run before any other mod + /// has their UserMod2.AfterModsLoad executed. All PLib components will be initialized + /// by this point. + /// + public const uint AfterModsLoad = 1U; + + /// + /// Runs immediately before Db.Initialize. + /// + public const uint BeforeDbInit = 2U; + + /// + /// Runs immediately after Db.Initialize. + /// + public const uint AfterDbInit = 3U; + + /// + /// Runs when the main menu has loaded. + /// + public const uint InMainMenu = 4U; + + /// + /// Runs when Game.OnPrefabInit has completed. + /// + public const uint OnStartGame = 5U; + + /// + /// Runs when Game.DestroyInstances is executed. + /// + public const uint OnEndGame = 6U; + + /// + /// Runs after all mod data (including layerable files like world gen and codex/ + /// elements) are loaded. This comes after all UserMod2.AfterModsLoad handlers execute. + /// All PLib components will be initialized by this point. + /// + public const uint AfterLayerableLoad = 7U; + + /// + /// The string equivalents of each constant for debugging. + /// + private static readonly string[] STRING_VALUES = new string[] { + nameof(Immediately), nameof(AfterModsLoad), nameof(BeforeDbInit), + nameof(AfterDbInit), nameof(InMainMenu), nameof(OnStartGame), nameof(OnEndGame), + nameof(AfterLayerableLoad) + }; + + /// + /// Gets a human readable representation of a run time constant. + /// + /// The time when the patch should be run. + public static string ToString(uint runtime) { + return (runtime < STRING_VALUES.Length) ? STRING_VALUES[runtime] : runtime. + ToString(); + } + } +} diff --git a/mod/PLibCore/PriorityQueue.cs b/mod/PLibCore/PriorityQueue.cs new file mode 100644 index 0000000..4ec00ef --- /dev/null +++ b/mod/PLibCore/PriorityQueue.cs @@ -0,0 +1,271 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using System.Collections.Generic; + +namespace PeterHan.PLib.Core { + /// + /// A class similar to Queue that allows efficient access to its + /// items in ascending order. + /// + public class PriorityQueue where T : IComparable { + /// + /// Returns the index of the specified item's first child. Its second child index is + /// that index plus one. + /// + /// The item index. + /// The index of its first child. + private static int ChildIndex(int index) { + return 2 * index + 1; + } + + /// + /// Returns the index of the specified item's parent. + /// + /// The item index. + /// The index of its parent. + private static int ParentIndex(int index) { + return (index - 1) / 2; + } + + /// + /// The number of elements in this queue. + /// + public int Count => heap.Count; + + /// + /// The heap where the items are stored. + /// + private readonly IList heap; + + /// + /// Creates a new PriorityQueue<> with the default + /// initial capacity. + /// + public PriorityQueue() : this(32) { } + + /// + /// Creates a new PriorityQueue<> with the specified + /// initial capacity. + /// + /// The initial capacity of this queue. + public PriorityQueue(int capacity) { + if (capacity < 1) + throw new ArgumentException("capacity > 0"); + heap = new List(Math.Max(capacity, 8)); + } + + /// + /// Removes all objects from this PriorityQueue<>. + /// + public void Clear() { + heap.Clear(); + } + + /// + /// Returns whether the specified key is present in this priority queue. This operation + /// is fairly slow, use with caution. + /// + /// The key to check. + /// true if it exists in this priority queue, or false otherwise. + public bool Contains(T key) { + return heap.Contains(key); + } + + /// + /// Removes and returns the smallest object in the + /// PriorityQueue<>. + /// + /// If multiple objects are the smallest object, an unspecified one is returned. + /// + /// The object that is removed from this PriorityQueue. + /// If this queue is empty. + public T Dequeue() { + int index = 0, length = heap.Count, childIndex; + if (length == 0) + throw new InvalidOperationException("Queue is empty"); + T top = heap[0]; + // Put the last element as the new head + heap[0] = heap[--length]; + heap.RemoveAt(length); + while ((childIndex = ChildIndex(index)) < length) { + T first = heap[index], child = heap[childIndex]; + if (childIndex < length - 1) { + var rightChild = heap[childIndex + 1]; + // Select the smallest child + if (child.CompareTo(rightChild) > 0) { + childIndex++; + child = rightChild; + } + } + if (first.CompareTo(child) < 0) + break; + heap[childIndex] = first; + heap[index] = child; + index = childIndex; + } + return top; + } + + /// + /// Adds an object to the PriorityQueue<>. + /// + /// The object to add to this PriorityQueue. + /// If item is null. + public void Enqueue(T item) { + if (item == null) + throw new ArgumentNullException(nameof(item)); + int index = heap.Count; + heap.Add(item); + while (index > 0) { + int parentIndex = ParentIndex(index); + T first = heap[index], parent = heap[parentIndex]; + if (first.CompareTo(parent) > 0) + break; + heap[parentIndex] = first; + heap[index] = parent; + index = parentIndex; + } + } + + /// + /// Returns the smallest object in the PriorityQueue<> + /// without removing it. + /// + /// If multiple objects are the smallest object, an unspecified one is returned. + /// + /// The smallest object in this PriorityQueue. + /// If this queue is empty. + public T Peek() { + if (Count == 0) + throw new InvalidOperationException("Queue is empty"); + return heap[0]; + } + + public override string ToString() { + return heap.ToString(); + } + } + + /// + /// A priority queue that includes a paired value. + /// + /// The type to use for the sorting in the PriorityQueue. + /// The type to include as extra data. + public sealed class PriorityDictionary : PriorityQueue. + PriorityQueuePair> where K : IComparable { + /// + /// Creates a new PriorityDictionary<, + /// > with the default initial capacity. + /// + public PriorityDictionary() : base() { } + + /// + /// Creates a new PriorityDictionary<, + /// > with the specified initial capacity. + /// + /// The initial capacity of this dictionary. + public PriorityDictionary(int capacity) : base(capacity) { } + + /// + /// Removes and returns the smallest object in the + /// PriorityDictionary<, >. + /// + /// If multiple objects are the smallest object, an unspecified one is returned. + /// + /// The key of the object removed. + /// The value of the object removed. + /// If this dictionary is empty. + public void Dequeue(out K key, out V value) { + var pair = Dequeue(); + key = pair.Key; + value = pair.Value; + } + + /// + /// Adds an object to the PriorityDictionary<, + /// >. + /// + /// The object to add to this PriorityDictionary. + /// If item is null. + public void Enqueue(K key, V value) { + Enqueue(new PriorityQueuePair(key, value)); + } + + /// + /// Returns the smallest object in the PriorityDictionary<, + /// > without removing it. + /// + /// If multiple objects are the smallest object, an unspecified one is returned. + /// + /// The key of the smallest object. + /// The value of the smallest object. + /// If this dictionary is empty. + public void Peek(out K key, out V value) { + var pair = Peek(); + key = pair.Key; + value = pair.Value; + } + + /// + /// Stores a value with the key that is used for comparison. + /// + public sealed class PriorityQueuePair : IComparable { + /// + /// Retrieves the key of this QueueItem. + /// + public K Key { get; } + + /// + /// Retrieves the value of this QueueItem. + /// + public V Value { get; } + + /// + /// Creates a new priority queue pair. + /// + /// The item key. + /// The item value. + public PriorityQueuePair(K key, V value) { + if (key == null) + throw new ArgumentNullException(nameof(key)); + Key = key; + Value = value; + } + + public int CompareTo(PriorityQueuePair other) { + if (other == null) + throw new ArgumentNullException(nameof(other)); + return Key.CompareTo(other.Key); + } + + public override bool Equals(object obj) { + return obj is PriorityQueuePair other && other.Key.Equals(Key); + } + + public override int GetHashCode() { + return Key.GetHashCode(); + } + + public override string ToString() { + return "PriorityQueueItem[key=" + Key + ",value=" + Value + "]"; + } + } + } +} diff --git a/mod/PLibCore/Remote/PRemoteComponent.cs b/mod/PLibCore/Remote/PRemoteComponent.cs new file mode 100644 index 0000000..69d6d38 --- /dev/null +++ b/mod/PLibCore/Remote/PRemoteComponent.cs @@ -0,0 +1,146 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using System; +using System.Reflection; + +namespace PeterHan.PLib.Core { + /// + /// Delegates calls to forwarded components in other assemblies. + /// + internal sealed class PRemoteComponent : PForwardedComponent { + /// + /// The prototype used for delegates to remote Initialize. + /// + private delegate void InitializeDelegate(Harmony instance); + + /// + /// The prototype used for delegates to remote Process. + /// + private delegate void ProcessDelegate(uint operation, object args); + + protected override object InstanceData { + get { + return getData?.Invoke(); + } + set { + setData?.Invoke(value); + } + } + + public override Version Version { + get { + return version; + } + } + + /// + /// Points to the component's version of Bootstrap. + /// + private readonly InitializeDelegate doBootstrap; + + /// + /// Points to the component's version of Initialize. + /// + private readonly InitializeDelegate doInitialize; + + /// + /// Points to the component's version of PostInitialize. + /// + private readonly InitializeDelegate doPostInitialize; + + /// + /// Gets the component's data. + /// + private readonly Func getData; + + /// + /// Runs the processing method of the component. + /// + private readonly ProcessDelegate process; + + /// + /// Sets the component's data. + /// + private readonly Action setData; + + /// + /// The component's version. + /// + private readonly Version version; + + /// + /// The wrapped instance from the other mod. + /// + private readonly object wrapped; + + internal PRemoteComponent(object wrapped) { + this.wrapped = wrapped ?? throw new ArgumentNullException(nameof(wrapped)); + if (!PPatchTools.TryGetPropertyValue(wrapped, nameof(Version), out Version + version)) + throw new ArgumentException("Remote component missing Version property"); + this.version = version; + // Initialize + var type = wrapped.GetType(); + doInitialize = type.CreateDelegate(nameof(PForwardedComponent. + Initialize), wrapped, typeof(Harmony)); + if (doInitialize == null) + throw new ArgumentException("Remote component missing Initialize"); + // Bootstrap + doBootstrap = type.CreateDelegate(nameof(PForwardedComponent. + Bootstrap), wrapped, typeof(Harmony)); + doPostInitialize = type.CreateDelegate(nameof( + PForwardedComponent.PostInitialize), wrapped, typeof(Harmony)); + getData = type.CreateGetDelegate(nameof(InstanceData), wrapped); + setData = type.CreateSetDelegate(nameof(InstanceData), wrapped); + process = type.CreateDelegate(nameof(PForwardedComponent. + Process), wrapped, typeof(uint), typeof(object)); + } + + public override void Bootstrap(Harmony plibInstance) { + doBootstrap?.Invoke(plibInstance); + } + + internal override object DoInitialize(Harmony plibInstance) { + doInitialize.Invoke(plibInstance); + return wrapped; + } + + public override Assembly GetOwningAssembly() { + return wrapped.GetType().Assembly; + } + + public override void Initialize(Harmony plibInstance) { + DoInitialize(plibInstance); + } + + public override void PostInitialize(Harmony plibInstance) { + doPostInitialize?.Invoke(plibInstance); + } + + public override void Process(uint operation, object args) { + process?.Invoke(operation, args); + } + + public override string ToString() { + return "PRemoteComponent[ID={0},TargetType={1}]".F(ID, wrapped.GetType(). + AssemblyQualifiedName); + } + } +} diff --git a/mod/PLibCore/Remote/PRemoteRegistry.cs b/mod/PLibCore/Remote/PRemoteRegistry.cs new file mode 100644 index 0000000..ed5d4b1 --- /dev/null +++ b/mod/PLibCore/Remote/PRemoteRegistry.cs @@ -0,0 +1,150 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using System.Collections.Generic; + +namespace PeterHan.PLib.Core { + /// + /// Transparently provides the functionality of PRegistry, while the actual instance is + /// from another mod's bootstrapper. + /// + internal sealed class PRemoteRegistry : IPLibRegistry { + /// + /// The prototype used for delegates to remote GetAllComponents. + /// + private delegate System.Collections.ICollection GetAllComponentsDelegate(string id); + + /// + /// The prototype used for delegates to remote GetLatestVersion and GetSharedData. + /// + private delegate object GetObjectDelegate(string id); + + /// + /// The prototype used for delegates to remote SetSharedData. + /// + private delegate void SetObjectDelegate(string id, object value); + + /// + /// Points to the local registry's version of AddCandidateVersion. + /// + private readonly Action addCandidateVersion; + + /// + /// Points to the local registry's version of GetAllComponents. + /// + private readonly GetAllComponentsDelegate getAllComponents; + + /// + /// Points to the local registry's version of GetLatestVersion. + /// + private readonly GetObjectDelegate getLatestVersion; + + /// + /// Points to the local registry's version of GetSharedData. + /// + private readonly GetObjectDelegate getSharedData; + + /// + /// Points to the local registry's version of SetSharedData. + /// + private readonly SetObjectDelegate setSharedData; + + public IDictionary ModData { get; private set; } + + /// + /// The components actually instantiated (latest version of each). + /// + private readonly IDictionary remoteComponents; + + /// + /// Creates a remote registry wrapping the target object. + /// + /// The PRegistryComponent instance to wrap. + internal PRemoteRegistry(object instance) { + if (instance == null) + throw new ArgumentNullException(nameof(instance)); + remoteComponents = new Dictionary(32); + if (!PPatchTools.TryGetPropertyValue(instance, nameof(ModData), out + IDictionary modData)) + throw new ArgumentException("Remote instance missing ModData"); + ModData = modData; + var type = instance.GetType(); + addCandidateVersion = type.CreateDelegate>(nameof( + PRegistryComponent.DoAddCandidateVersion), instance, typeof(object)); + getAllComponents = type.CreateDelegate(nameof( + PRegistryComponent.DoGetAllComponents), instance, typeof(string)); + getLatestVersion = type.CreateDelegate(nameof( + PRegistryComponent.DoGetLatestVersion), instance, typeof(string)); + if (addCandidateVersion == null || getLatestVersion == null || + getAllComponents == null) + throw new ArgumentException("Remote instance missing candidate versions"); + getSharedData = type.CreateDelegate(nameof(IPLibRegistry. + GetSharedData), instance, typeof(string)); + setSharedData = type.CreateDelegate(nameof(IPLibRegistry. + SetSharedData), instance, typeof(string), typeof(object)); + if (getSharedData == null || setSharedData == null) + throw new ArgumentException("Remote instance missing shared data"); + } + + public void AddCandidateVersion(PForwardedComponent instance) { + addCandidateVersion.Invoke(instance); + } + + public IEnumerable GetAllComponents(string id) { + ICollection results = null; + var all = getAllComponents.Invoke(id); + if (all != null) { + results = new List(all.Count); + foreach (var component in all) + if (component is PForwardedComponent local) + results.Add(local); + else + results.Add(new PRemoteComponent(component)); + } + return results; + } + + public PForwardedComponent GetLatestVersion(string id) { + if (!remoteComponents.TryGetValue(id, out PForwardedComponent remoteComponent)) { + // Attempt to resolve it + object instantiated = getLatestVersion.Invoke(id); + if (instantiated == null) { +#if DEBUG + PRegistry.LogPatchWarning("Unable to find a component matching: " + id); +#endif + remoteComponent = null; + } else if (instantiated is PForwardedComponent inThisMod) + // Running the current version + remoteComponent = inThisMod; + else + remoteComponent = new PRemoteComponent(instantiated); + remoteComponents.Add(id, remoteComponent); + } + return remoteComponent; + } + + public object GetSharedData(string id) { + return getSharedData.Invoke(id); + } + + public void SetSharedData(string id, object data) { + setSharedData.Invoke(id, data); + } + } +} diff --git a/mod/PLibCore/TextMeshProPatcher.cs b/mod/PLibCore/TextMeshProPatcher.cs new file mode 100644 index 0000000..124486d --- /dev/null +++ b/mod/PLibCore/TextMeshProPatcher.cs @@ -0,0 +1,164 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using PeterHan.PLib.Core; +using System; +using System.Collections.Generic; +using TMPro; +using UnityEngine; + +namespace PeterHan.PLib.UI { + /// + /// Patches bugs in Text Mesh Pro. + /// + internal static class TextMeshProPatcher { + /// + /// The ID to use for Harmony patches. + /// + private const string HARMONY_ID = "TextMeshProPatch"; + + /// + /// Tracks whether the TMP patches have been checked. + /// + private static volatile bool patchChecked = false; + + /// + /// Serializes multiple thread access to the patch status. + /// + private static readonly object patchLock = new object(); + + /// + /// Applied to TMP_InputField to fix a bug that prevented auto layout from ever + /// working. + /// + private static bool AssignPositioningIfNeeded_Prefix(TMP_InputField __instance, + RectTransform ___caretRectTrans, TMP_Text ___m_TextComponent) { + bool cont = true; + var crt = ___caretRectTrans; + if (___m_TextComponent != null && crt != null && __instance != null && + ___m_TextComponent.isActiveAndEnabled) { + var rt = ___m_TextComponent.rectTransform; + if (crt.localPosition != rt.localPosition || + crt.localRotation != rt.localRotation || + crt.localScale != rt.localScale || + crt.anchorMin != rt.anchorMin || + crt.anchorMax != rt.anchorMax || + crt.anchoredPosition != rt.anchoredPosition || + crt.sizeDelta != rt.sizeDelta || + crt.pivot != rt.pivot) { + __instance.StartCoroutine(ResizeCaret(crt, rt)); + cont = false; + } + } + return cont; + } + + /// + /// Checks to see if a patch with our class name has already been applied. + /// + /// The patch list to search. + /// true if a patch with this class has already patched the method, or false otherwise. + private static bool HasOurPatch(IEnumerable patchList) { + bool found = false; + if (patchList != null) { + foreach (var patch in patchList) { + string ownerName = patch.PatchMethod.DeclaringType.Name; + // Avoid stomping ourselves, or legacy PLibs < 3.14 + if (ownerName == nameof(TextMeshProPatcher) || ownerName == "PLibPatches") + { +#if DEBUG + PUtil.LogDebug("TextMeshProPatcher found existing patch from: {0}". + F(patch.owner)); +#endif + found = true; + break; + } + } + } + return found; + } + + /// + /// Patches TMP_InputField with fixes, but only if necessary. + /// + /// The type of TMP_InputField. + /// The Harmony instance to use for patching. + private static void InputFieldPatches(Type tmpType) { + var instance = new Harmony(HARMONY_ID); + var aip = tmpType.GetMethodSafe("AssignPositioningIfNeeded", false, + PPatchTools.AnyArguments); + if (aip != null && !HasOurPatch(Harmony.GetPatchInfo(aip)?.Prefixes)) + instance.Patch(aip, prefix: new HarmonyMethod(typeof(TextMeshProPatcher), + nameof(AssignPositioningIfNeeded_Prefix))); + var oe = tmpType.GetMethodSafe("OnEnable", false, PPatchTools.AnyArguments); + if (oe != null && !HasOurPatch(Harmony.GetPatchInfo(oe)?.Postfixes)) + instance.Patch(oe, postfix: new HarmonyMethod(typeof(TextMeshProPatcher), + nameof(OnEnable_Postfix))); + } + + /// + /// Applied to TMPro.TMP_InputField to fix a clipping bug inside of Scroll Rects. + /// + /// https://forum.unity.com/threads/textmeshpro-text-still-visible-when-using-nested-rectmask2d.537967/ + /// + private static void OnEnable_Postfix(UnityEngine.UI.Scrollbar ___m_VerticalScrollbar, + TMP_Text ___m_TextComponent) { + var component = ___m_TextComponent; + if (component != null) + component.ignoreRectMaskCulling = ___m_VerticalScrollbar != null; + } + + /// + /// Patches Text Mesh Pro input fields to fix a variety of bugs. Should be used before + /// any Text Mesh Pro objects are created. + /// + public static void Patch() { + lock (patchLock) { + if (!patchChecked) { + var tmpType = PPatchTools.GetTypeSafe("TMPro.TMP_InputField"); + if (tmpType != null) + try { + InputFieldPatches(tmpType); + } catch (Exception) { + PUtil.LogWarning("Unable to patch TextMeshPro bug, text fields may display improperly inside scroll areas"); + } + patchChecked = true; + } + } + } + + /// + /// Resizes the caret object to match the text. Used as an enumerator. + /// + /// The rectTransform of the caret. + /// The rectTransform of the text. + private static System.Collections.IEnumerator ResizeCaret( + RectTransform caretTransform, RectTransform textTransform) { + yield return new WaitForEndOfFrame(); + caretTransform.localPosition = textTransform.localPosition; + caretTransform.localRotation = textTransform.localRotation; + caretTransform.localScale = textTransform.localScale; + caretTransform.anchorMin = textTransform.anchorMin; + caretTransform.anchorMax = textTransform.anchorMax; + caretTransform.anchoredPosition = textTransform.anchoredPosition; + caretTransform.sizeDelta = textTransform.sizeDelta; + caretTransform.pivot = textTransform.pivot; + } + } +} diff --git a/mod/PLibCore/translations/fr.po b/mod/PLibCore/translations/fr.po new file mode 100644 index 0000000..854c85c --- /dev/null +++ b/mod/PLibCore/translations/fr.po @@ -0,0 +1,96 @@ +msgid "" +msgstr "" +"Application: Oxygen Not IncludedPOT Version: 2.0\n" +"Project-Id-Version: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 2.4.1\n" +"Last-Translator: \n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"Language: fr\n" + +#. PeterHan.PLib.Core.PLibStrings.BUTTON_MANUAL +msgctxt "PeterHan.PLib.Core.PLibStrings.BUTTON_MANUAL" +msgid "MANUAL CONFIG" +msgstr "CONFIGURATION MANUELLE" + +#. PeterHan.PLib.Core.PLibStrings.DIALOG_TITLE +msgctxt "PeterHan.PLib.Core.PLibStrings.DIALOG_TITLE" +msgid "Options for {0}" +msgstr "Options pour {0}" + +#. PeterHan.PLib.Core.PLibStrings.MOD_ASSEMBLY_VERSION +msgctxt "PeterHan.PLib.Core.PLibStrings.MOD_ASSEMBLY_VERSION" +msgid "Assembly Version: {0}" +msgstr "Version d'assemblage: {0}" + +#. PeterHan.PLib.Core.PLibStrings.MOD_HOMEPAGE +msgctxt "PeterHan.PLib.Core.PLibStrings.MOD_HOMEPAGE" +msgid "Mod Homepage" +msgstr "Page d'accueil du mod" + +#. PeterHan.PLib.Core.PLibStrings.MOD_VERSION +msgctxt "PeterHan.PLib.Core.PLibStrings.MOD_VERSION" +msgid "Mod Version: {0}" +msgstr "Version du mod: {0}" + +#. PeterHan.PLib.Core.PLibStrings.RESTART_REQUIRED +msgctxt "PeterHan.PLib.Core.PLibStrings.RESTART_REQUIRED" +msgid "Oxygen Not Included must be restarted for these options to take effect." +msgstr "" +"Oxygen Not Included doit être relancé pour que ces options prennent effet." + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_CANCEL +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_CANCEL" +msgid "Discard changes." +msgstr "Annuler les changements." + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_HOMEPAGE +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_HOMEPAGE" +msgid "Visit the mod's website." +msgstr "Visiter le site web du mod." + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_MANUAL +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_MANUAL" +msgid "Opens the folder containing the full mod configuration." +msgstr "Ouvre le dossier contenant la configuration complète du mod." + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_NEXT +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_NEXT" +msgid "Next" +msgstr "Suivant" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_OK +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_OK" +msgid "" +"Save these options. Some mods may require a restart for the options to take " +"effect." +msgstr "" +"Conservez ces options. Certains mods peuvent nécessiter un redémarrage pour " +"que les options prennent effet." + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_PREVIOUS +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_PREVIOUS" +msgid "Previous" +msgstr "Précédent" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_TOGGLE +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_TOGGLE" +msgid "Show or hide this options category" +msgstr "Afficher ou masquer cette catégorie d'options" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_VERSION +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_VERSION" +msgid "" +"The currently installed version of this mod.\n" +"\n" +"Compare this version with the mod's Release Notes to see if it is outdated." +msgstr "" +"La version actuellement installée de ce mod.\n" +"\n" +"Comparez cette version avec les notes de mise à jour du mod pour voir si " +"elle n'est pas dépassée." diff --git a/mod/PLibCore/translations/ru.po b/mod/PLibCore/translations/ru.po new file mode 100644 index 0000000..2b03cd5 --- /dev/null +++ b/mod/PLibCore/translations/ru.po @@ -0,0 +1,163 @@ +msgid "" +msgstr "" +"Application: Oxygen Not Included\n" +"POT Version: 2.0\n" +"Project-Id-Version:\n" +"POT-Creation-Date:\n" +"PO-Revision-Date:\n" +"Language-Team:\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 2.0.6\n" +"Last-Translator:\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" +"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"Language: ru\n" +"X-Poedit-SourceCharset: UTF-8\n" + +#. PeterHan.PLib.Core.PLibStrings.BUTTON_MANUAL +msgctxt "PeterHan.PLib.Core.PLibStrings.BUTTON_MANUAL" +msgid "MANUAL CONFIG" +msgstr "РУЧНАЯ НАСТРОЙКА" + +#. PeterHan.PLib.Core.PLibStrings.BUTTON_RESET +msgctxt "PeterHan.PLib.Core.PLibStrings.BUTTON_RESET" +msgid "RESET TO DEFAULT" +msgstr "СБРОС ПО УМОЛЧАНИЮ" + +#. PeterHan.PLib.Core.PLibStrings.DIALOG_TITLE +msgctxt "PeterHan.PLib.Core.PLibStrings.DIALOG_TITLE" +msgid "Options for {0}" +msgstr "Настройки для {0}" + +#. PeterHan.PLib.Core.PLibStrings.KEY_ARROWDOWN +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_ARROWDOWN" +msgid "Down Arrow" +msgstr "Стрелка Вниз" + +#. PeterHan.PLib.Core.PLibStrings.KEY_ARROWLEFT +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_ARROWLEFT" +msgid "Left Arrow" +msgstr "Стрелка Влево" + +#. PeterHan.PLib.Core.PLibStrings.KEY_ARROWRIGHT +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_ARROWRIGHT" +msgid "Right Arrow" +msgstr "Стрелка Вправо" + +#. PeterHan.PLib.Core.PLibStrings.KEY_ARROWUP +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_ARROWUP" +msgid "Up Arrow" +msgstr "Стрелка Вверх" + +#. PeterHan.PLib.Core.PLibStrings.KEY_CATEGORY_TITLE +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_CATEGORY_TITLE" +msgid "Mods" +msgstr "Моды" + +#. PeterHan.PLib.Core.PLibStrings.MOD_ASSEMBLY_VERSION +msgctxt "PeterHan.PLib.Core.PLibStrings.MOD_ASSEMBLY_VERSION" +msgid "Assembly Version: {0}" +msgstr "Версия сборки: {0}" + +#. PeterHan.PLib.Core.PLibStrings.MOD_HOMEPAGE +msgctxt "PeterHan.PLib.Core.PLibStrings.MOD_HOMEPAGE" +msgid "Mod Homepage" +msgstr "Страница мода" + +#. PeterHan.PLib.Core.PLibStrings.MOD_VERSION +msgctxt "PeterHan.PLib.Core.PLibStrings.MOD_VERSION" +msgid "Mod Version: {0}" +msgstr "Версия мода: {0}" + +#. PeterHan.PLib.Core.PLibStrings.OUTDATED_TOOLTIP +msgctxt "PeterHan.PLib.Core.PLibStrings.OUTDATED_TOOLTIP" +msgid "This mod is out of date!\nNew version: {0}\n\nUpdate local mods manually, or use Mod Updater to force update Steam mods" +msgstr "Этот мод устарел!\nНовая версия: {0}\n\nОбновите локальные моды вручную или используйте Mod Updater для принудительного обновления модов Steam" + +#. PeterHan.PLib.Core.PLibStrings.OUTDATED_WARNING +msgctxt "PeterHan.PLib.Core.PLibStrings.OUTDATED_WARNING" +msgid "Outdated!" +msgstr "Устарел!" + +#. PeterHan.PLib.Core.PLibStrings.RESTART_REQUIRED +msgctxt "PeterHan.PLib.Core.PLibStrings.RESTART_REQUIRED" +msgid "Oxygen Not Included must be restarted for these options to take effect." +msgstr "Чтобы эти параметры вступили в силу, необходимо перезапустить Oxygen Not Included." + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_BLUE +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_BLUE" +msgid "Blue" +msgstr "Синий" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_CANCEL +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_CANCEL" +msgid "Discard changes." +msgstr "Отменить изменения." + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_GREEN +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_GREEN" +msgid "Green" +msgstr "Зелёный" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_HOMEPAGE +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_HOMEPAGE" +msgid "Visit the mod's website." +msgstr "Посетите сайт мода." + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_HUE +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_HUE" +msgid "Hue" +msgstr "Тон" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_MANUAL +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_MANUAL" +msgid "Opens the folder containing the full mod configuration." +msgstr "Открыть папку, содержащую полную конфигурацию мода." + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_NEXT +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_NEXT" +msgid "Next" +msgstr "Следующий" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_OK +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_OK" +msgid "Save these options. Some mods may require a restart for the options to take effect." +msgstr "Сохранить эти параметры. Для некоторых модов может потребоваться перезапуск, чтобы параметры вступили в силу." + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_PREVIOUS +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_PREVIOUS" +msgid "Previous" +msgstr "Предыдущий" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_RED +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_RED" +msgid "Red" +msgstr "Красный" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_RESET +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_RESET" +msgid "Resets the mod configuration to default values." +msgstr "Сбросить конфигурацию мода до значений по умолчанию." + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_SATURATION +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_SATURATION" +msgid "Saturation" +msgstr "Насыщенность" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_TOGGLE +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_TOGGLE" +msgid "Show or hide this options category" +msgstr "Показать или скрыть эту категорию настроек" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_VALUE +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_VALUE" +msgid "Value" +msgstr "Яркость" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_VERSION +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_VERSION" +msgid "The currently installed version of this mod.\n\nCompare this version with the mod's Release Notes to see if it is outdated." +msgstr "Текущая установленная версия этого мода.\n\nСравните эту версию с примечаниями к выпуску мода, чтобы узнать, не устарел ли он." + diff --git a/mod/PLibCore/translations/template.pot b/mod/PLibCore/translations/template.pot new file mode 100644 index 0000000..2dd1192 --- /dev/null +++ b/mod/PLibCore/translations/template.pot @@ -0,0 +1,150 @@ +msgid "" +msgstr "" +"Application: Oxygen Not Included" +"POT Version: 2.0" + +#. PeterHan.PLib.Core.PLibStrings.BUTTON_MANUAL +msgctxt "PeterHan.PLib.Core.PLibStrings.BUTTON_MANUAL" +msgid "MANUAL CONFIG" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.BUTTON_RESET +msgctxt "PeterHan.PLib.Core.PLibStrings.BUTTON_RESET" +msgid "RESET TO DEFAULT" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.DIALOG_TITLE +msgctxt "PeterHan.PLib.Core.PLibStrings.DIALOG_TITLE" +msgid "Options for {0}" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.KEY_ARROWDOWN +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_ARROWDOWN" +msgid "Down Arrow" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.KEY_ARROWLEFT +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_ARROWLEFT" +msgid "Left Arrow" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.KEY_ARROWRIGHT +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_ARROWRIGHT" +msgid "Right Arrow" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.KEY_ARROWUP +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_ARROWUP" +msgid "Up Arrow" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.KEY_CATEGORY_TITLE +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_CATEGORY_TITLE" +msgid "Mods" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.MOD_ASSEMBLY_VERSION +msgctxt "PeterHan.PLib.Core.PLibStrings.MOD_ASSEMBLY_VERSION" +msgid "Assembly Version: {0}" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.MOD_HOMEPAGE +msgctxt "PeterHan.PLib.Core.PLibStrings.MOD_HOMEPAGE" +msgid "Mod Homepage" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.MOD_VERSION +msgctxt "PeterHan.PLib.Core.PLibStrings.MOD_VERSION" +msgid "Mod Version: {0}" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.OUTDATED_TOOLTIP +msgctxt "PeterHan.PLib.Core.PLibStrings.OUTDATED_TOOLTIP" +msgid "This mod is out of date!\nNew version: {0}\n\nUpdate local mods manually, or use Mod Updater to force update Steam mods" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.OUTDATED_WARNING +msgctxt "PeterHan.PLib.Core.PLibStrings.OUTDATED_WARNING" +msgid "Outdated!" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.RESTART_REQUIRED +msgctxt "PeterHan.PLib.Core.PLibStrings.RESTART_REQUIRED" +msgid "Oxygen Not Included must be restarted for these options to take effect." +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_BLUE +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_BLUE" +msgid "Blue" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_CANCEL +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_CANCEL" +msgid "Discard changes." +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_GREEN +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_GREEN" +msgid "Green" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_HOMEPAGE +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_HOMEPAGE" +msgid "Visit the mod's website." +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_HUE +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_HUE" +msgid "Hue" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_MANUAL +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_MANUAL" +msgid "Opens the folder containing the full mod configuration." +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_NEXT +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_NEXT" +msgid "Next" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_OK +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_OK" +msgid "Save these options. Some mods may require a restart for the options to take effect." +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_PREVIOUS +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_PREVIOUS" +msgid "Previous" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_RED +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_RED" +msgid "Red" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_RESET +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_RESET" +msgid "Resets the mod configuration to default values." +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_SATURATION +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_SATURATION" +msgid "Saturation" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_TOGGLE +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_TOGGLE" +msgid "Show or hide this options category" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_VALUE +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_VALUE" +msgid "Value" +msgstr "" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_VERSION +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_VERSION" +msgid "The currently installed version of this mod.\n\nCompare this version with the mod's Release Notes to see if it is outdated." +msgstr "" + diff --git a/mod/PLibCore/translations/zh.po b/mod/PLibCore/translations/zh.po new file mode 100644 index 0000000..feab65c --- /dev/null +++ b/mod/PLibCore/translations/zh.po @@ -0,0 +1,206 @@ +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: Ventulus\n" +"Language-Team: \n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"Application: Oxygen Not Included\n" +"X-Generator: Poedit 3.0.1\n" + +#. PeterHan.PLib.Core.PLibStrings.BUTTON_MANUAL +msgctxt "PeterHan.PLib.Core.PLibStrings.BUTTON_MANUAL" +msgid "MANUAL CONFIG" +msgstr "手动配置" + +#. PeterHan.PLib.Core.PLibStrings.BUTTON_OK +msgctxt "PeterHan.PLib.Core.PLibStrings.BUTTON_OK" +msgid "Done" +msgstr "完成" + +#. PeterHan.PLib.Core.PLibStrings.BUTTON_OPTIONS +msgctxt "PeterHan.PLib.Core.PLibStrings.BUTTON_OPTIONS" +msgid "OPTIONS" +msgstr "选项" + +#. PeterHan.PLib.Core.PLibStrings.BUTTON_RESET +msgctxt "PeterHan.PLib.Core.PLibStrings.BUTTON_RESET" +msgid "RESET TO DEFAULT" +msgstr "重置为默认" + +#. PeterHan.PLib.Core.PLibStrings.DIALOG_TITLE +msgctxt "PeterHan.PLib.Core.PLibStrings.DIALOG_TITLE" +msgid "Options for {0}" +msgstr "{0}的选项" + +#. PeterHan.PLib.Core.PLibStrings.KEY_ARROWDOWN +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_ARROWDOWN" +msgid "Down Arrow" +msgstr "↓" + +#. PeterHan.PLib.Core.PLibStrings.KEY_ARROWLEFT +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_ARROWLEFT" +msgid "Left Arrow" +msgstr "←" + +#. PeterHan.PLib.Core.PLibStrings.KEY_ARROWRIGHT +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_ARROWRIGHT" +msgid "Right Arrow" +msgstr "→" + +#. PeterHan.PLib.Core.PLibStrings.KEY_ARROWUP +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_ARROWUP" +msgid "Up Arrow" +msgstr "↑" + +#. PeterHan.PLib.Core.PLibStrings.KEY_CATEGORY_TITLE +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_CATEGORY_TITLE" +msgid "Mods" +msgstr "模组" + +#. PeterHan.PLib.Core.PLibStrings.KEY_DELETE +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_DELETE" +msgid "Delete" +msgstr "Delete" + +#. PeterHan.PLib.Core.PLibStrings.KEY_END +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_END" +msgid "End" +msgstr "End" + +#. PeterHan.PLib.Core.PLibStrings.KEY_HOME +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_HOME" +msgid "Home" +msgstr "Home" + +#. PeterHan.PLib.Core.PLibStrings.KEY_PAGEDOWN +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_PAGEDOWN" +msgid "Page Down" +msgstr "Page Down" + +#. PeterHan.PLib.Core.PLibStrings.KEY_PAGEUP +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_PAGEUP" +msgid "Page Up" +msgstr "Page Up" + +#. PeterHan.PLib.Core.PLibStrings.KEY_PAUSE +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_PAUSE" +msgid "Pause" +msgstr "Pause" + +#. PeterHan.PLib.Core.PLibStrings.KEY_PRTSCREEN +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_PRTSCREEN" +msgid "Print Screen" +msgstr "Print Screen" + +#. PeterHan.PLib.Core.PLibStrings.KEY_SYSRQ +msgctxt "PeterHan.PLib.Core.PLibStrings.KEY_SYSRQ" +msgid "SysRq" +msgstr "SysRq" + +#. PeterHan.PLib.Core.PLibStrings.MOD_ASSEMBLY_VERSION +msgctxt "PeterHan.PLib.Core.PLibStrings.MOD_ASSEMBLY_VERSION" +msgid "Assembly Version: {0}" +msgstr "程序集版本:{0}" + +#. PeterHan.PLib.Core.PLibStrings.MOD_HOMEPAGE +msgctxt "PeterHan.PLib.Core.PLibStrings.MOD_HOMEPAGE" +msgid "Mod Homepage" +msgstr "模组主页" + +#. PeterHan.PLib.Core.PLibStrings.MOD_VERSION +msgctxt "PeterHan.PLib.Core.PLibStrings.MOD_VERSION" +msgid "Mod Version: {0}" +msgstr "模组版本:{0}" + +#. PeterHan.PLib.Core.PLibStrings.OUTDATED_TOOLTIP +msgctxt "PeterHan.PLib.Core.PLibStrings.OUTDATED_TOOLTIP" +msgid "" +"This mod is out of date!\n" +"New version: {0}\n" +"\n" +"Update local mods manually, or use Mod Updater to force update Steam " +"mods" +msgstr "" +"这个模组已经过时了!\n" +"新版本:{0}\n" +"\n" +"手动更新本地模组,或使用Mod Updater来强制更新Steam模组" + +#. PeterHan.PLib.Core.PLibStrings.OUTDATED_WARNING +msgctxt "PeterHan.PLib.Core.PLibStrings.OUTDATED_WARNING" +msgid "Outdated!" +msgstr "过时了!" + +#. PeterHan.PLib.Core.PLibStrings.RESTART_CANCEL +msgctxt "PeterHan.PLib.Core.PLibStrings.RESTART_CANCEL" +msgid "CONTINUE" +msgstr "继续" + +#. PeterHan.PLib.Core.PLibStrings.RESTART_OK +msgctxt "PeterHan.PLib.Core.PLibStrings.RESTART_OK" +msgid "RESTART" +msgstr "重启" + +#. PeterHan.PLib.Core.PLibStrings.RESTART_REQUIRED +msgctxt "PeterHan.PLib.Core.PLibStrings.RESTART_REQUIRED" +msgid "Oxygen Not Included must be restarted for these options to take effect." +msgstr "要使这些选项生效,必须重新启动游戏。" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_CANCEL +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_CANCEL" +msgid "Discard changes." +msgstr "放弃更改。" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_HOMEPAGE +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_HOMEPAGE" +msgid "Visit the mod's website." +msgstr "访问模组网站。" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_MANUAL +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_MANUAL" +msgid "Opens the folder containing the full mod configuration." +msgstr "打开包含完整模组配置的文件夹。" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_NEXT +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_NEXT" +msgid "Next" +msgstr "下一个" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_OK +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_OK" +msgid "" +"Save these options. Some mods may require a restart for the options to take " +"effect." +msgstr "保存这些选项。某些模组可能需要重新启动才能使选项生效。" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_PREVIOUS +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_PREVIOUS" +msgid "Previous" +msgstr "上一个" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_RESET +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_RESET" +msgid "Resets the mod configuration to default values." +msgstr "将模组配置重置为默认值。" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_TOGGLE +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_TOGGLE" +msgid "Show or hide this options category" +msgstr "显示或隐藏此选项类别" + +#. PeterHan.PLib.Core.PLibStrings.TOOLTIP_VERSION +msgctxt "PeterHan.PLib.Core.PLibStrings.TOOLTIP_VERSION" +msgid "" +"The currently installed version of this mod.\n" +"\n" +"Compare this version with the mod's Release Notes to see if it is outdated." +msgstr "" +"此模组的当前安装版本。\n" +"\n" +"请将此版本与模组的发行说明进行比较,以查看它是否过时。" diff --git a/mod/PLibDatabase/PCodexManager.cs b/mod/PLibDatabase/PCodexManager.cs new file mode 100644 index 0000000..286773d --- /dev/null +++ b/mod/PLibDatabase/PCodexManager.cs @@ -0,0 +1,316 @@ +/* + * Copyright 2020 Davis Cook + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using HarmonyLib; +using Klei; +using PeterHan.PLib.Core; + +using CodexDictionary = System.Collections.Generic.Dictionary>; +using WidgetMappingList = System.Collections.Generic.List>; + +namespace PeterHan.PLib.Database { + /// + /// Handles codex entries for mods by automatically loading YAML entries and subentries for + /// critters and plants from the codex folder in their mod directories. + /// + /// The layerable files loader in the stock game is broken, so this class is required to + /// correctly load new codex entries. + /// + public sealed class PCodexManager : PForwardedComponent { + /// + /// The subfolder from which critter codex entries are loaded. + /// + public const string CREATURES_DIR = "codex/Creatures"; + + /// + /// The subfolder from which plant codex entries are loaded. + /// + public const string PLANTS_DIR = "codex/Plants"; + + /// + /// The file extension used for codex entry/subentries. + /// + public const string CODEX_FILES = "*.yaml"; + + /// + /// The codex category under which critter entries should go. + /// + public const string CREATURES_CATEGORY = "CREATURES"; + + /// + /// The codex category under which plant entries should go. + /// + public const string PLANTS_CATEGORY = "PLANTS"; + + /// + /// The version of this component. Uses the running PLib version. + /// + internal static readonly Version VERSION = new Version(PVersion.VERSION); + + /// + /// Allow access to the private widget tag mappings field. + /// Detouring sadly is not possible because CodexCache is a static class and cannot be + /// a type parameter. + /// + private static readonly FieldInfo WIDGET_TAG_MAPPINGS = typeof(CodexCache). + GetFieldSafe("widgetTagMappings", true); + + /// + /// The instantiated copy of this class. + /// + internal static PCodexManager Instance { get; private set; } + + public override Version Version => VERSION; + + /// + /// Applied to CodexCache to collect dynamic codex entries from the file system. + /// + private static void CollectEntries_Postfix(string folder, List __result, + string ___baseEntryPath) { + // Check to see if we are loading from either the "Creatures" directory or + // "Plants" directory + if (Instance != null) { + string path = string.IsNullOrEmpty(folder) ? ___baseEntryPath : Path.Combine( + ___baseEntryPath, folder); + bool modified = false; + if (path.EndsWith("Creatures")) { + __result.AddRange(Instance.LoadEntries(CREATURES_CATEGORY)); + modified = true; + } + if (path.EndsWith("Plants")) { + __result.AddRange(Instance.LoadEntries(PLANTS_CATEGORY)); + modified = true; + } + if (modified) { + foreach (var codexEntry in __result) + // Fill in a default sort string if necessary + if (string.IsNullOrEmpty(codexEntry.sortString)) + codexEntry.sortString = Strings.Get(codexEntry.title); + __result.Sort((x, y) => x.sortString.CompareTo(y.sortString)); + } + } + } + + /// + /// Applied to CodexCache to collect dynamic codex sub entries from the file system. + /// + private static void CollectSubEntries_Postfix(List __result) { + if (Instance != null) { + int startSize = __result.Count; + __result.AddRange(Instance.LoadSubEntries()); + if (__result.Count != startSize) + __result.Sort((x, y) => x.title.CompareTo(y.title)); + } + } + + /// + /// Loads codex entries from the specified directory. + /// + /// The location where the data will be placed. + /// The directory to load. + /// The category to assign to each entry thus loaded. + private static void LoadFromDirectory(ICollection entries, string dir, + string category) { + string[] codexFiles = new string[0]; + try { + // List codex data files in the codex directory + codexFiles = Directory.GetFiles(dir, CODEX_FILES); + } catch (UnauthorizedAccessException ex) { + PUtil.LogExcWarn(ex); + } catch (IOException ex) { + PUtil.LogExcWarn(ex); + } + var widgetTagMappings = WIDGET_TAG_MAPPINGS?.GetValue(null) as WidgetMappingList; + if (widgetTagMappings == null) + PDatabaseUtils.LogDatabaseWarning("Unable to load codex files: no tag mappings found"); + foreach (string str in codexFiles) + try { + string filename = str; + var codexEntry = YamlIO.LoadFile(filename, YamlParseErrorCB, + widgetTagMappings); + if (codexEntry != null) { + codexEntry.category = category; + entries.Add(codexEntry); + } + } catch (IOException ex) { + PDatabaseUtils.LogDatabaseWarning("Unable to load codex files from {0}:". + F(dir)); + PUtil.LogExcWarn(ex); + } catch (InvalidDataException ex) { + PUtil.LogException(ex); + } +#if DEBUG + PDatabaseUtils.LogDatabaseDebug("Loaded codex entries from directory: {0}".F(dir)); +#endif + } + + /// + /// Loads codex subentries from the specified directory. + /// + /// The location where the data will be placed. + /// The directory to load. + private static void LoadFromDirectory(ICollection entries, string dir) { + string[] codexFiles = new string[0]; + try { + // List codex data files in the codex directory + codexFiles = Directory.GetFiles(dir, CODEX_FILES, SearchOption. + AllDirectories); + } catch (UnauthorizedAccessException ex) { + PUtil.LogExcWarn(ex); + } catch (IOException ex) { + PUtil.LogExcWarn(ex); + } + var widgetTagMappings = WIDGET_TAG_MAPPINGS?.GetValue(null) as WidgetMappingList; + if (widgetTagMappings == null) + PDatabaseUtils.LogDatabaseWarning("Unable to load codex files: no tag mappings found"); + foreach (string filename in codexFiles) + try { + var subEntry = YamlIO.LoadFile(filename, YamlParseErrorCB, + widgetTagMappings); + if (entries != null) + entries.Add(subEntry); + } catch (IOException ex) { + PDatabaseUtils.LogDatabaseWarning("Unable to load codex files from {0}:". + F(dir)); + PUtil.LogExcWarn(ex); + } catch (InvalidDataException ex) { + PUtil.LogException(ex); + } +#if DEBUG + PDatabaseUtils.LogDatabaseDebug("Loaded codex sub entries from directory: {0}". + F(dir)); +#endif + } + + /// + /// A callback function for the YAML parser to process errors that it throws. + /// + /// The YAML parsing error + internal static void YamlParseErrorCB(YamlIO.Error error, bool _) { + throw new InvalidDataException(string.Format("{0} parse error in {1}\n{2}", error. + severity, error.file.full_path, error.message), error.inner_exception); + } + + /// + /// The paths for creature codex entries. + /// + private readonly ISet creaturePaths; + + /// + /// The paths for plant codex entries. + /// + private readonly ISet plantPaths; + + public PCodexManager() { + creaturePaths = new HashSet(); + plantPaths = new HashSet(); + // Data is a hacky but usable 2 item dictionary + InstanceData = new CodexDictionary(4) { + { CREATURES_CATEGORY, creaturePaths }, + { PLANTS_CATEGORY, plantPaths } + }; + PUtil.InitLibrary(false); + PRegistry.Instance.AddCandidateVersion(this); + } + + public override void Initialize(Harmony plibInstance) { + Instance = this; + + plibInstance.Patch(typeof(CodexCache), nameof(CodexCache.CollectEntries), + postfix: PatchMethod(nameof(CollectEntries_Postfix))); + plibInstance.Patch(typeof(CodexCache), nameof(CodexCache.CollectSubEntries), + postfix: PatchMethod(nameof(CollectSubEntries_Postfix))); + } + + /// + /// Loads all codex entries for all mods registered. + /// + /// The codex category under which these data entries should be loaded. + /// The list of entries that were loaded. + private IList LoadEntries(string category) { + var entries = new List(32); + var allMods = PRegistry.Instance.GetAllComponents(ID); + if (allMods != null) + foreach (var mod in allMods) { + var codex = mod?.GetInstanceData(); + if (codex != null && codex.TryGetValue(category, out ISet dirs)) + foreach (var dir in dirs) + LoadFromDirectory(entries, dir, category); + } + return entries; + } + + /// + /// Loads all codex subentries for all mods registered. + /// + /// The list of subentries that were loaded. + private IList LoadSubEntries() { + var entries = new List(32); + var allMods = PRegistry.Instance.GetAllComponents(ID); + if (allMods != null) + foreach (var mod in allMods) { + var codex = mod?.GetInstanceData(); + if (codex != null) + // Lots of nested for, but required! (entryType should only have + // 2 values, and usually only one dir per mod) + foreach (var entryType in codex) + foreach (var dir in entryType.Value) + LoadFromDirectory(entries, dir); + } + return entries; + } + + /// + /// Registers the calling mod as having custom creature codex entries. The entries will + /// be read from the mod directory in the "codex/Creatures" subfolder. If the argument + /// is omitted, the calling assembly is registered. + /// + /// The assembly to register as having creatures. + public void RegisterCreatures(Assembly assembly = null) { + if (assembly == null) + assembly = Assembly.GetCallingAssembly(); + string dir = Path.Combine(PUtil.GetModPath(assembly), CREATURES_DIR); + creaturePaths.Add(dir); +#if DEBUG + PDatabaseUtils.LogDatabaseDebug("Registered codex creatures directory: {0}". + F(dir)); +#endif + } + + /// + /// Registers the calling mod as having custom plant codex entries. The entries will + /// be read from the mod directory in the "codex/Plants" subfolder. If the argument + /// is omitted, the calling assembly is registered. + /// + /// The assembly to register as having creatures. + public void RegisterPlants(Assembly assembly = null) { + if (assembly == null) + assembly = Assembly.GetCallingAssembly(); + string dir = Path.Combine(PUtil.GetModPath(assembly), PLANTS_DIR); + plantPaths.Add(dir); +#if DEBUG + PDatabaseUtils.LogDatabaseDebug("Registered codex plants directory: {0}".F(dir)); +#endif + } + } +} diff --git a/mod/PLibDatabase/PColonyAchievement.cs b/mod/PLibDatabase/PColonyAchievement.cs new file mode 100644 index 0000000..078ce00 --- /dev/null +++ b/mod/PLibDatabase/PColonyAchievement.cs @@ -0,0 +1,172 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using Database; +using FMODUnity; +using PeterHan.PLib.Core; +using PeterHan.PLib.Detours; +using System; +using System.Collections.Generic; + +namespace PeterHan.PLib.Database { + /// + /// A wrapper class used to create ColonyAchievement instances. + /// + public sealed class PColonyAchievement { + /// + /// Prototypes the new ColonyAchievment constructor. This one is a monster with a + /// zillion parallel parameters used only for victory animations (Klei please!) and + /// gets changed often enough to warrant a detour. + /// + private delegate ColonyAchievement NewColonyAchievement(string Id, + string platformAchievementId, string Name, string description, + bool isVictoryCondition, List requirementChecklist, + string messageTitle, string messageBody, string videoDataName, + string victoryLoopVideo, Action VictorySequence); + + /// + /// Creates a new colony achievement. + /// + private static readonly NewColonyAchievement NEW_COLONY_ACHIEVEMENT = + typeof(ColonyAchievement).DetourConstructor(); + + /// + /// The achievement description (string, not a string key!) + /// + public string Description { get; set; } + + /// + /// The icon to use for the achievement. + /// + public string Icon { get; set; } + + /// + /// The achievement ID. + /// + public string ID { get; } + + /// + /// Whether this colony achievement is considered a victory achievement. + /// + /// Victory achievements are displayed at the top, and can play a movie when they + /// are satisfied. + /// + public bool IsVictory { get; set; } + + /// + /// The achievement display name (string, not a string key!) + /// + public string Name { get; set; } + + /// + /// The callback triggered if this achievement is a victory achievement when it is + /// completed. + /// + public Action OnVictory { get; set; } + + /// + /// The requirements for this achievement. + /// + public List Requirements { get; set; } + + /// + /// This member is obsolete since the Sweet Dreams update. Use VictoryAudioSnapshoRef + /// instead. + /// + [Obsolete("Set victory audio snapshot directly due to Klei changes in the Sweet Dreams update")] + public string VictoryAudioSnapshot { get; set; } + + /// + /// The message body to display when this achievement triggers. + /// + /// The game does not use this field by default, but it is available for victory + /// callbacks. + /// + public string VictoryMessage { get; set; } + + /// + /// The message title to display when this achievement triggers. + /// + /// The game does not use this field by default, but it is available for victory + /// callbacks. + /// + public string VictoryTitle { get; set; } + + /// + /// The video data file to play when this achievement triggers. + /// + /// The game does not use this field by default, but it is available for victory + /// callbacks. + /// + public string VictoryVideoData { get; set; } + + /// + /// The video data file to loop behind the message when this achievement triggers. + /// + /// The game does not use this field by default, but it is available for victory + /// callbacks. + /// + public string VictoryVideoLoop { get; set; } + + /// + /// Creates a new colony achievement wrapper. + /// + /// The achievement ID. + public PColonyAchievement(string id) { + if (string.IsNullOrEmpty(id)) + throw new ArgumentNullException(nameof(id)); + Description = ""; + Icon = ""; + ID = id; + IsVictory = false; + Name = ""; + OnVictory = null; + Requirements = null; + VictoryMessage = ""; + VictoryTitle = ""; + VictoryVideoData = ""; + VictoryVideoLoop = ""; + } + + /// + /// Creates and adds the achievement to the database. As platform achievements cannot + /// be added using mods, the platform achievement ID will always be empty. + /// + public void AddAchievement() { + if (Requirements == null) + throw new ArgumentNullException("No colony achievement requirements specified"); + var achieve = NEW_COLONY_ACHIEVEMENT.Invoke(ID, "", Name, Description, IsVictory, + Requirements, VictoryTitle, VictoryMessage, VictoryVideoData, VictoryVideoLoop, + OnVictory); + achieve.icon = Icon; + PDatabaseUtils.AddColonyAchievement(achieve); + } + + public override bool Equals(object obj) { + return obj is PColonyAchievement other && ID == other.ID; + } + + public override int GetHashCode() { + return ID.GetHashCode(); + } + + public override string ToString() { + return "PColonyAchievement[ID={0},Name={1}]".F(ID, Name); + } + } +} diff --git a/mod/PLibDatabase/PDatabaseUtils.cs b/mod/PLibDatabase/PDatabaseUtils.cs new file mode 100644 index 0000000..f2c26f8 --- /dev/null +++ b/mod/PLibDatabase/PDatabaseUtils.cs @@ -0,0 +1,73 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using Database; +using System; + +namespace PeterHan.PLib.Database { + /// + /// Functions which deal with entries in the game database and strings. + /// + public static class PDatabaseUtils { + /// + /// Adds a colony achievement to the colony summary screen. Must be invoked after the + /// database is initialized (Db.Initialize() postfix recommended). + /// + /// Note that achievement structures significantly changed from Vanilla to the DLC. + /// + /// The achievement to add. + public static void AddColonyAchievement(ColonyAchievement achievement) { + if (achievement == null) + throw new ArgumentNullException(nameof(achievement)); + Db.Get()?.ColonyAchievements?.resources?.Add(achievement); + } + + /// + /// Adds the name and description for a status item. + /// + /// Must be used before the StatusItem is first instantiated. + /// + /// The status item ID. + /// The status item category. + /// The name to display in the UI. + /// The description to display in the UI. + public static void AddStatusItemStrings(string id, string category, string name, + string desc) { + string uid = id.ToUpperInvariant(); + string ucategory = category.ToUpperInvariant(); + Strings.Add("STRINGS." + ucategory + ".STATUSITEMS." + uid + ".NAME", name); + Strings.Add("STRINGS." + ucategory + ".STATUSITEMS." + uid + ".TOOLTIP", desc); + } + + /// + /// Logs a message encountered by the PLib database system. + /// + /// The debug message. + internal static void LogDatabaseDebug(string message) { + Debug.LogFormat("[PLibDatabase] {0}", message); + } + + /// + /// Logs a warning encountered by the PLib database system. + /// + /// The warning message. + internal static void LogDatabaseWarning(string message) { + Debug.LogWarningFormat("[PLibDatabase] {0}", message); + } + } +} diff --git a/mod/PLibDatabase/PLibDatabase.csproj b/mod/PLibDatabase/PLibDatabase.csproj new file mode 100644 index 0000000..9c2033a --- /dev/null +++ b/mod/PLibDatabase/PLibDatabase.csproj @@ -0,0 +1,20 @@ + + + + PLib Database + PLib.Database + 4.11.0.0 + false + PeterHan.PLib.Database + 4.11.0.0 + false + true + Vanilla;Mergedown + + + bin\$(Platform)\Release\PLibDatabase.xml + + + + + diff --git a/mod/PLibDatabase/PLocalization.cs b/mod/PLibDatabase/PLocalization.cs new file mode 100644 index 0000000..aa5688d --- /dev/null +++ b/mod/PLibDatabase/PLocalization.cs @@ -0,0 +1,155 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using PeterHan.PLib.Core; +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +namespace PeterHan.PLib.Database { + /// + /// Handles localization for mods by automatically loading po files from the translations + /// folder in their mod directories. + /// + public sealed class PLocalization : PForwardedComponent { + /// + /// The subfolder from which translations will be loaded. + /// + public const string TRANSLATIONS_DIR = "translations"; + + /// + /// The version of this component. Uses the running PLib version. + /// + internal static readonly Version VERSION = new Version(PVersion.VERSION); + + /// + /// Localizes the specified mod assembly. + /// + /// The assembly to localize. + /// The locale file name to be used. + private static void Localize(Assembly modAssembly, Localization.Locale locale) { + string path = PUtil.GetModPath(modAssembly); + string locCode = locale.Code; + if (string.IsNullOrEmpty(locCode)) + locCode = Localization.GetCurrentLanguageCode(); + var poFile = Path.Combine(Path.Combine(path, TRANSLATIONS_DIR), locCode + + PLibLocalization.TRANSLATIONS_EXT); + try { + Localization.OverloadStrings(Localization.LoadStringsFile(poFile, false)); + RewriteStrings(modAssembly); + } catch (FileNotFoundException) { + // No localization available for this locale +#if DEBUG + PDatabaseUtils.LogDatabaseDebug("No {0} localization available for mod {1}".F( + locCode, modAssembly.GetNameSafe() ?? "?")); +#endif + } catch (DirectoryNotFoundException) { + } catch (IOException e) { + PDatabaseUtils.LogDatabaseWarning("Failed to load {0} localization for mod {1}:". + F(locCode, modAssembly.GetNameSafe() ?? "?")); + PUtil.LogExcWarn(e); + } + } + + /// + /// Searches types in the assembly (no worries, Localization did this anyways, so they + /// all either loaded or failed to load) for fields that already had loc string keys + /// created, and fixes them if so. + /// + /// The assembly to check for strings. + internal static void RewriteStrings(Assembly assembly) { + foreach (var type in assembly.GetTypes()) + foreach (var field in type.GetFields(PPatchTools.BASE_FLAGS | BindingFlags. + FlattenHierarchy | BindingFlags.Static)) { + // Only use fields of type LocString + if (field.FieldType == typeof(LocString) && field.GetValue(null) is + LocString ls) { +#if DEBUG + PDatabaseUtils.LogDatabaseDebug("Rewrote string {0}: {1} to {2}".F(ls. + key.String, Strings.Get(ls.key.String), ls.text)); +#endif + Strings.Add(ls.key.String, ls.text); + } + } + } + + public override Version Version => VERSION; + + /// + /// The assemblies to be localized. + /// + private readonly ICollection toLocalize; + + public PLocalization() { + toLocalize = new List(4); + InstanceData = toLocalize; + } + + /// + /// Debug dumps the translation templates for ALL registered PLib localized mods. + /// + internal void DumpAll() { + var allMods = PRegistry.Instance.GetAllComponents(ID); + if (allMods != null) + foreach (var toDump in allMods) { + // Reach for those assemblies + var assemblies = toDump.GetInstanceData>(); + if (assemblies != null) + foreach (var modAssembly in assemblies) + ModUtil.RegisterForTranslation(modAssembly.GetTypes()[0]); + } + } + + public override void Initialize(Harmony plibInstance) { + // PLibLocalization will invoke Process here + } + + public override void Process(uint operation, object _) { + var locale = Localization.GetLocale(); + if (locale != null && operation == 0) + foreach (var modAssembly in toLocalize) + Localize(modAssembly, locale); + } + + /// + /// Registers the specified assembly for automatic PLib localization. If the argument + /// is omitted, the calling assembly is registered. + /// + /// The assembly to register for PLib localization. + public void Register(Assembly assembly = null) { + if (assembly == null) + assembly = Assembly.GetCallingAssembly(); + var types = assembly.GetTypes(); + if (types == null || types.Length == 0) + PDatabaseUtils.LogDatabaseWarning("Registered assembly " + assembly. + GetNameSafe() + " that had no types for localization!"); + else { + RegisterForForwarding(); + toLocalize.Add(assembly); + // This call searches all types in the assembly implicitly + Localization.RegisterForTranslation(types[0]); +#if DEBUG + PDatabaseUtils.LogDatabaseDebug("Localizing assembly {0} using base namespace {1}". + F(assembly.GetNameSafe(), types[0].Namespace)); +#endif + } + } + } +} diff --git a/mod/PLibLighting/ILightShape.cs b/mod/PLibLighting/ILightShape.cs new file mode 100644 index 0000000..4997e0e --- /dev/null +++ b/mod/PLibLighting/ILightShape.cs @@ -0,0 +1,45 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +namespace PeterHan.PLib.Lighting { + /// + /// An interface describing local and remote instances of PLightShape. + /// + public interface ILightShape { + /// + /// The light shape identifier. + /// + string Identifier { get; } + + /// + /// The Klei LightShape represented by this light shape, used in Light2D definitions. + /// + LightShape KleiLightShape { get; } + + /// + /// The raycast mode used by this light shape. (-1) if no rays are to be emitted. + /// + LightShape RayMode { get; } + + /// + /// Invokes the light handler with the provided light information. + /// + /// The arguments passed to the user light handler. + void FillLight(LightingArgs args); + } +} diff --git a/mod/PLibLighting/LightingArgs.cs b/mod/PLibLighting/LightingArgs.cs new file mode 100644 index 0000000..726a9b1 --- /dev/null +++ b/mod/PLibLighting/LightingArgs.cs @@ -0,0 +1,133 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +namespace PeterHan.PLib.Lighting { + /// + /// Arguments which are passed to lighting callbacks to perform lighting calculations. + /// + /// The range is the light radius supplied during the Light2D creation; do not light up + /// tiles outside of this radius (measured by a square around SourceCell)! + /// + /// The source cell is the cell nearest to where the Light2D is currently located. + /// + /// Use the IDictionary interface to store the relative brightness of cells by their cell + /// location. These values should be between 0 and 1 normally, with the maximum brightness + /// being set by the intensity parameter of the Light2D. The user is responsible for + /// ensuring that cells are valid before lighting them up. + /// + public sealed class LightingArgs : EventArgs, IDictionary { + /// + /// The location where lighting results are stored. + /// + public IDictionary Brightness { get; } + + /// + /// The maximum range to use for cell lighting. Do not light up cells beyond this + /// range from SourceCell. + /// + public int Range { get; } + + /// + /// The source of the light. + /// + public GameObject Source { get; } + + /// + /// The originating cell. Actual lighting can begin elsewhere, but the range limit is + /// measured from this cell. + /// + public int SourceCell { get; } + + internal LightingArgs(GameObject source, int cell, int range, + IDictionary output) { + if (source == null) + // Cannot use "throw" expression because of UnityEngine.Object.operator== + throw new ArgumentNullException(nameof(source)); + Brightness = output ?? throw new ArgumentNullException(nameof(output)); + Range = range; + Source = source; + SourceCell = cell; + } + + #region IDictionary + + public ICollection Keys => Brightness.Keys; + + public ICollection Values => Brightness.Values; + + public int Count => Brightness.Count; + + public bool IsReadOnly => Brightness.IsReadOnly; + + public float this[int key] { get => Brightness[key]; set => Brightness[key] = value; } + + public bool ContainsKey(int key) { + return Brightness.ContainsKey(key); + } + + public void Add(int key, float value) { + Brightness.Add(key, value); + } + + public bool Remove(int key) { + return Brightness.Remove(key); + } + + public bool TryGetValue(int key, out float value) { + return Brightness.TryGetValue(key, out value); + } + + public void Add(KeyValuePair item) { + Brightness.Add(item); + } + + public void Clear() { + Brightness.Clear(); + } + + public bool Contains(KeyValuePair item) { + return Brightness.Contains(item); + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) { + Brightness.CopyTo(array, arrayIndex); + } + + public bool Remove(KeyValuePair item) { + return Brightness.Remove(item); + } + + public IEnumerator> GetEnumerator() { + return Brightness.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() { + return Brightness.GetEnumerator(); + } + #endregion + + public override string ToString() { + return string.Format("LightingArgs[source={0:D},range={1:D}]", SourceCell, Range); + } + } +} diff --git a/mod/PLibLighting/LightingPatches.cs b/mod/PLibLighting/LightingPatches.cs new file mode 100644 index 0000000..967758e --- /dev/null +++ b/mod/PLibLighting/LightingPatches.cs @@ -0,0 +1,170 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using PeterHan.PLib.Core; +using PeterHan.PLib.Detours; +using System; +using System.Collections.Generic; +using UnityEngine; + +using IntHandle = HandleVector.Handle; +using LightGridEmitter = LightGridManager.LightGridEmitter; +using TranspiledMethod = System.Collections.Generic.IEnumerable; + +namespace PeterHan.PLib.Lighting { + /// + /// Contains all patches (many!) required by the PLib Lighting subsystem. Only applied by + /// the latest version of PLightManager. + /// + internal static class LightingPatches { + private delegate IntHandle AddToLayerDelegate(Light2D instance, Extents ext, + ScenePartitionerLayer layer); + + private static readonly DetouredMethod ADD_TO_LAYER = + typeof(Light2D).DetourLazy("AddToLayer"); + + private static readonly IDetouredField ORIGIN = PDetours. + DetourFieldLazy("origin"); + + private static readonly IDetouredField PREVIOUS_CELL = + PDetours.DetourFieldLazy("previousCell"); + + private static bool ComputeExtents_Prefix(Light2D __instance, ref Extents __result) { + var lm = PLightManager.Instance; + bool cont = true; + if (lm != null && __instance != null) { + var shape = __instance.shape; + int rad = Mathf.CeilToInt(__instance.Range), cell; + lm.AddLight(__instance.emitter, __instance.gameObject); + if (shape > LightShape.Cone && rad > 0 && Grid.IsValidCell(cell = ORIGIN.Get( + __instance))) { + Grid.CellToXY(cell, out int x, out int y); + // Better safe than sorry, check whole possible radius + __result = new Extents(x - rad, y - rad, 2 * rad, 2 * rad); + cont = false; + } + } + return cont; + } + + /// + /// Applies the required lighting related patches. + /// + /// The Harmony instance to use for patching. + public static void ApplyPatches(Harmony plibInstance) { + // Light2D + plibInstance.Patch(typeof(Light2D), "ComputeExtents", + prefix: PatchMethod(nameof(ComputeExtents_Prefix))); + plibInstance.Patch(typeof(Light2D), nameof(Light2D.FullRemove), postfix: + PatchMethod(nameof(Light2D_FullRemove_Postfix))); + plibInstance.Patch(typeof(Light2D), nameof(Light2D.RefreshShapeAndPosition), + postfix: PatchMethod(nameof(Light2D_RefreshShapeAndPosition_Postfix))); + + // LightBuffer + try { + plibInstance.PatchTranspile(typeof(LightBuffer), "LateUpdate", PatchMethod( + nameof(LightBuffer_LateUpdate_Transpile))); + } catch (Exception e) { + // Only visual, log as warning + PUtil.LogExcWarn(e); + } + + // LightGridEmitter + plibInstance.Patch(typeof(LightGridEmitter), "ComputeLux", prefix: + PatchMethod(nameof(ComputeLux_Prefix))); + plibInstance.Patch(typeof(LightGridEmitter), nameof(LightGridEmitter. + UpdateLitCells), prefix: PatchMethod(nameof(UpdateLitCells_Prefix))); + + // LightGridManager + plibInstance.Patch(typeof(LightGridManager), nameof(LightGridManager. + CreatePreview), prefix: PatchMethod(nameof(CreatePreview_Prefix))); + + // LightShapePreview + plibInstance.Patch(typeof(LightShapePreview), "Update", prefix: + PatchMethod(nameof(LightShapePreview_Update_Prefix))); + + // Rotatable + plibInstance.Patch(typeof(Rotatable), "OrientVisualizer", postfix: + PatchMethod(nameof(OrientVisualizer_Postfix))); + } + + private static bool ComputeLux_Prefix(LightGridEmitter __instance, int cell, + LightGridEmitter.State ___state, ref int __result) { + var lm = PLightManager.Instance; + return lm == null || !lm.GetBrightness(__instance, cell, ___state, out __result); + } + + private static bool CreatePreview_Prefix(int origin_cell, float radius, + LightShape shape, int lux) { + var lm = PLightManager.Instance; + return lm == null || !lm.PreviewLight(origin_cell, radius, shape, lux); + } + + private static void Light2D_FullRemove_Postfix(Light2D __instance) { + var lm = PLightManager.Instance; + if (lm != null && __instance != null) + lm.DestroyLight(__instance.emitter); + } + + private static void Light2D_RefreshShapeAndPosition_Postfix(Light2D __instance, + Light2D.RefreshResult __result) { + var lm = PLightManager.Instance; + if (lm != null && __instance != null && __result == Light2D.RefreshResult.Updated) + lm.AddLight(__instance.emitter, __instance.gameObject); + } + + private static TranspiledMethod LightBuffer_LateUpdate_Transpile( + TranspiledMethod body) { + var target = typeof(Light2D).GetPropertySafe(nameof(Light2D.shape), + false)?.GetGetMethod(true); + return (target == null) ? body : PPatchTools.ReplaceMethodCallSafe(body, target, + typeof(PLightManager).GetMethodSafe(nameof(PLightManager.LightShapeToRayShape), + true, typeof(Light2D))); + } + + private static void LightShapePreview_Update_Prefix(LightShapePreview __instance) { + var lm = PLightManager.Instance; + // Pass the preview object into LightGridManager + if (lm != null && __instance != null) + lm.PreviewObject = __instance.gameObject; + } + + private static void OrientVisualizer_Postfix(Rotatable __instance) { + // Force regeneration on next Update() + if (__instance != null && __instance.TryGetComponent(out LightShapePreview + preview)) + PREVIOUS_CELL.Set(preview, -1); + } + + /// + /// Gets a HarmonyMethod instance for manual patching using a method from this class. + /// + /// The method name. + /// A reference to that method as a HarmonyMethod for patching. + private static HarmonyMethod PatchMethod(string name) { + return new HarmonyMethod(typeof(LightingPatches), name); + } + + private static bool UpdateLitCells_Prefix(LightGridEmitter __instance, + List ___litCells, LightGridEmitter.State ___state) { + var lm = PLightManager.Instance; + return lm == null || !lm.UpdateLitCells(__instance, ___state, ___litCells); + } + } +} diff --git a/mod/PLibLighting/OctantBuilder.cs b/mod/PLibLighting/OctantBuilder.cs new file mode 100644 index 0000000..2916972 --- /dev/null +++ b/mod/PLibLighting/OctantBuilder.cs @@ -0,0 +1,116 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using PeterHan.PLib.Detours; +using System; +using System.Collections.Generic; + +using BrightnessDict = System.Collections.Generic.IDictionary; +using Octant = DiscreteShadowCaster.Octant; + +namespace PeterHan.PLib.Lighting { + /// + /// A builder class which creates default light patterns based on octants. + /// + public sealed class OctantBuilder { + /// + /// The delegate type called to run the default DiscreteShadowCaster.ScanOctant. + /// + private delegate void ScanOctantFunc(Vector2I cellPos, int range, int depth, + Octant octant, double startSlope, double endSlope, List visiblePoints); + + /// + /// The method to call to scan octants. + /// + private static readonly ScanOctantFunc OCTANT_SCAN; + + static OctantBuilder() { + // Cache the method for faster execution + OCTANT_SCAN = typeof(DiscreteShadowCaster).Detour("ScanOctant"); + if (OCTANT_SCAN == null) + PLightManager.LogLightingWarning("OctantBuilder cannot find default octant scanner!"); + } + + /// + /// The fallout to use when building the light. + /// + public float Falloff { get; set; } + + /// + /// If false, uses the default game smoothing. If true, uses better smoothing. + /// + public bool SmoothLight { get; set; } + + /// + /// The origin cell. + /// + public int SourceCell { get; } + + /// + /// The location where light cells are added. + /// + private readonly BrightnessDict destination; + + /// + /// Creates a new octant builder. + /// + /// The location where the lit cells will be placed. + /// The origin cell of the light. + public OctantBuilder(BrightnessDict destination, int sourceCell) { + if (!Grid.IsValidCell(sourceCell)) + throw new ArgumentOutOfRangeException(nameof(sourceCell)); + this.destination = destination ?? throw new ArgumentNullException(nameof( + destination)); + destination[sourceCell] = 1.0f; + Falloff = 0.5f; + // Use the default game's light algorithm + SmoothLight = false; + SourceCell = sourceCell; + } + + /// + /// Adds an octant of light. + /// + /// The range of the light. + /// The octant to scan. + /// This object, for call chaining. + public OctantBuilder AddOctant(int range, Octant octant) { + var points = ListPool.Allocate(); + OCTANT_SCAN?.Invoke(Grid.CellToXY(SourceCell), range, 1, octant, 1.0, 0.0, points); + // Transfer to our array using: + foreach (int cell in points) { + float intensity; + if (SmoothLight) + // Better, not rounded falloff + intensity = PLightManager.GetSmoothFalloff(Falloff, cell, SourceCell); + else + // Default falloff + intensity = PLightManager.GetDefaultFalloff(Falloff, cell, SourceCell); + destination[cell] = intensity; + } + points.Recycle(); + return this; + } + + public override string ToString() { + return string.Format("OctantBuilder[Cell {0:D}, {1:D} lit]", SourceCell, + destination.Count); + } + } +} diff --git a/mod/PLibLighting/PLibLighting.csproj b/mod/PLibLighting/PLibLighting.csproj new file mode 100644 index 0000000..018b368 --- /dev/null +++ b/mod/PLibLighting/PLibLighting.csproj @@ -0,0 +1,20 @@ + + + + PLib Lighting + PLib.Lighting + 4.11.0.0 + false + PeterHan.PLib.Lighting + 4.11.0.0 + false + true + Vanilla;Mergedown + + + bin\$(Platform)\Release\PLibLighting.xml + + + + + diff --git a/mod/PLibLighting/PLightManager.cs b/mod/PLibLighting/PLightManager.cs new file mode 100644 index 0000000..003770f --- /dev/null +++ b/mod/PLibLighting/PLightManager.cs @@ -0,0 +1,378 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using PeterHan.PLib.Core; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using UnityEngine; + +using BrightnessDict = System.Collections.Generic.IDictionary; +using LightGridEmitter = LightGridManager.LightGridEmitter; + +namespace PeterHan.PLib.Lighting { + /// + /// Manages lighting. Instantiated only by the latest PLib version. + /// + public sealed class PLightManager : PForwardedComponent { + /// + /// Implemented by classes which want to handle lighting calls. + /// + /// The parameters to use for lighting, and the location to + /// store results. See the LightingArgs class documentation for details. + public delegate void CastLightDelegate(LightingArgs args); + + /// + /// A singleton empty list instance for default values. + /// + private static readonly List EMPTY_SHAPES = new List(1); + + /// + /// The version of this component. Uses the running PLib version. + /// + internal static readonly Version VERSION = new Version(PVersion.VERSION); + + /// + /// If true, enables the smooth light falloff mode even on vanilla lights. + /// + internal static bool ForceSmoothLight { get; set; } + + /// + /// The instantiated copy of this class. + /// + internal static PLightManager Instance { get; private set; } + + /// + /// Calculates the brightness falloff as it would be in the stock game. + /// + /// The falloff rate to use. + /// The cell where falloff is being computed. + /// The light origin cell. + /// The brightness at that location from 0 to 1. + public static float GetDefaultFalloff(float falloffRate, int cell, int origin) { + return 1.0f / Math.Max(1.0f, Mathf.RoundToInt(falloffRate * Math.Max(Grid. + GetCellDistance(origin, cell), 1))); + } + + /// + /// Calculates the brightness falloff similar to the default falloff, but far smoother. + /// Slightly heavier on computation however. + /// + /// The falloff rate to use. + /// The cell where falloff is being computed. + /// The light origin cell. + /// The brightness at that location from 0 to 1. + public static float GetSmoothFalloff(float falloffRate, int cell, int origin) { + Vector2I newCell = Grid.CellToXY(cell), start = Grid.CellToXY(origin); + return 1.0f / Math.Max(1.0f, falloffRate * PUtil.Distance(start.X, start.Y, + newCell.X, newCell.Y)); + } + + /// + /// Gets the raycasting shape to use for the given light. + /// + /// The light which is being drawn. + /// The shape to use for its rays. + internal static LightShape LightShapeToRayShape(Light2D light) { + var shape = light.shape; + if (shape != LightShape.Cone && shape != LightShape.Circle) + shape = Instance.GetRayShape(shape); + return shape; + } + + /// + /// Logs a message encountered by the PLib lighting system. + /// + /// The debug message. + internal static void LogLightingDebug(string message) { + Debug.LogFormat("[PLibLighting] {0}", message); + } + + /// + /// Logs a warning encountered by the PLib lighting system. + /// + /// The warning message. + internal static void LogLightingWarning(string message) { + Debug.LogWarningFormat("[PLibLighting] {0}", message); + } + + public override Version Version => VERSION; + + /// + /// The light brightness set by the last lighting brightness request. + /// + private readonly ConcurrentDictionary brightCache; + + /// + /// The last object that requested a preview. Only one preview can be requested at a + /// time, so no need for thread safety. + /// + internal GameObject PreviewObject { get; set; } + + /// + /// The lighting shapes available, all in this mod's namespace. + /// + private readonly IList shapes; + + /// + /// Creates a lighting manager to register PLib lighting. + /// + public PLightManager() { + // Needs to be thread safe! + brightCache = new ConcurrentDictionary(2, 128); + PreviewObject = null; + shapes = new List(16); + } + + /// + /// Adds a light to the lookup table. + /// + /// The source of the light. + /// The light's owning game object. + internal void AddLight(LightGridEmitter source, GameObject owner) { + if (owner == null) + throw new ArgumentNullException(nameof(owner)); + if (source == null) + throw new ArgumentNullException(nameof(source)); + // The default equality comparer will be used; since each Light2D is supposed + // to have exactly one LightGridEmitter, this should be fine + brightCache.TryAdd(source, new CacheEntry(owner)); + } + + public override void Bootstrap(Harmony plibInstance) { + SetSharedData(new List(16)); + } + + /// + /// Ends a call to lighting update initiated by CreateLight. + /// + /// The source of the light. + internal void DestroyLight(LightGridEmitter source) { + if (source != null) + brightCache.TryRemove(source, out _); + } + + /// + /// Gets the brightness at a given cell for the specified light source. + /// + /// The source of the light. + /// The location to check. + /// The lighting state. + /// The brightness there. + /// true if that brightness is valid, or false otherwise. + internal bool GetBrightness(LightGridEmitter source, int location, + LightGridEmitter.State state, out int result) { + bool valid; + var shape = state.shape; + if (shape != LightShape.Cone && shape != LightShape.Circle) { + valid = brightCache.TryGetValue(source, out CacheEntry cacheEntry); + if (valid) { + valid = cacheEntry.Intensity.TryGetValue(location, out float ratio); + if (valid) + result = Mathf.RoundToInt(cacheEntry.BaseLux * ratio); + else { +#if DEBUG + LogLightingDebug("GetBrightness for invalid cell at {0:D}".F(location)); +#endif + result = 0; + } + } else { +#if DEBUG + LogLightingDebug("GetBrightness for invalid emitter at {0:D}".F(location)); +#endif + result = 0; + } + } else if (ForceSmoothLight) { + // Use smooth light even for vanilla Cone and Circle + result = Mathf.RoundToInt(state.intensity * GetSmoothFalloff(state.falloffRate, + location, state.origin)); + valid = true; + } else { + // Stock + result = 0; + valid = false; + } + return valid; + } + + /// + /// Checks to see if a light has specified one of the built-in ray options to cast + /// the little yellow rays around it. + /// + /// The light shape to check. + /// The light shape to use for ray casting, or the original shape if it is + /// a stock shape or a light shape not known to PLib Lighting. + internal LightShape GetRayShape(LightShape shape) { + int index = shape - LightShape.Cone - 1; + ILightShape ps; + if (index >= 0 && index < shapes.Count && (ps = shapes[index]) != null) { + var newShape = ps.RayMode; + if (newShape >= LightShape.Circle) + shape = newShape; + } + return shape; + } + + public override void Initialize(Harmony plibInstance) { + Instance = this; + + shapes.Clear(); + foreach (var light in GetSharedData(EMPTY_SHAPES)) { + var ls = PRemoteLightWrapper.LightToInstance(light); + shapes.Add(ls); + if (ls == null) + // Moe must clean it! + LogLightingWarning("Foreign contaminant in PLightManager!"); + } + + LightingPatches.ApplyPatches(plibInstance); + } + + /// + /// Creates the preview for a given light. + /// + /// The starting cell. + /// The light radius. + /// The light shape. + /// The base brightness in lux. + /// true if the lighting was handled, or false otherwise. + internal bool PreviewLight(int origin, float radius, LightShape shape, int lux) { + bool handled = false; + var owner = PreviewObject; + int index = shape - LightShape.Cone - 1; + if (index >= 0 && index < shapes.Count && owner != null) { + var cells = DictionaryPool.Allocate(); + // Found handler! + shapes[index]?.FillLight(new LightingArgs(owner, origin, + (int)radius, cells)); + foreach (var pair in cells) { + int cell = pair.Key; + if (Grid.IsValidCell(cell)) { + // Allow any fraction, not just linear falloff + int lightValue = Mathf.RoundToInt(lux * pair.Value); + LightGridManager.previewLightCells.Add(new Tuple(cell, + lightValue)); + LightGridManager.previewLux[cell] = lightValue; + } + } + PreviewObject = null; + handled = true; + cells.Recycle(); + } + return handled; + } + + /// + /// Registers a light shape handler. + /// + /// A unique identifier for this shape. If another mod has + /// already registered that identifier, the previous mod will take precedence. + /// The handler for that shape. + /// The type of visual rays that are displayed from the light. + /// The light shape which can be used. + public ILightShape Register(string identifier, CastLightDelegate handler, + LightShape rayMode = (LightShape)(-1)) { + if (string.IsNullOrEmpty(identifier)) + throw new ArgumentNullException(nameof(identifier)); + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + ILightShape lightShape = null; + RegisterForForwarding(); + // Try to find a match for this identifier + var registered = GetSharedData(EMPTY_SHAPES); + int n = registered.Count; + foreach (var obj in registered) { + var light = PRemoteLightWrapper.LightToInstance(obj); + // Might be from another assembly so the types may or may not be compatible + if (light != null && light.Identifier == identifier) { + LogLightingDebug("Found existing light shape: " + identifier + " from " + + (obj.GetType().Assembly.GetNameSafe() ?? "?")); + lightShape = light; + break; + } + } + if (lightShape == null) { + // Not currently existing + lightShape = new PLightShape(n + 1, identifier, handler, rayMode); + LogLightingDebug("Registered new light shape: " + identifier); + registered.Add(lightShape); + } + return lightShape; + } + + /// + /// Updates the lit cells list. + /// + /// The source of the light. + /// The light emitter state. + /// The location where lit cells will be placed. + /// true if the lighting was handled, or false otherwise. + internal bool UpdateLitCells(LightGridEmitter source, LightGridEmitter.State state, + IList litCells) { + bool handled = false; + int index = state.shape - LightShape.Cone - 1; + if (source == null) + throw new ArgumentNullException(nameof(source)); + if (index >= 0 && index < shapes.Count && litCells != null && brightCache. + TryGetValue(source, out CacheEntry entry)) { + var ps = shapes[index]; + var brightness = entry.Intensity; + // Proper owner found + brightness.Clear(); + entry.BaseLux = state.intensity; + ps.FillLight(new LightingArgs(entry.Owner, state.origin, (int)state. + radius, brightness)); + foreach (var point in brightness) + litCells.Add(point.Key); + handled = true; + } + return handled; + } + + /// + /// A cache entry in the light brightness cache. + /// + private sealed class CacheEntry { + /// + /// The base intensity in lux. + /// + internal int BaseLux { get; set; } + + /// + /// The relative brightness per cell. + /// + internal BrightnessDict Intensity { get; } + + /// + /// The owner which initiated the lighting call. + /// + internal GameObject Owner { get; } + + internal CacheEntry(GameObject owner) { + // Do not use the pool because these might last a long time and be numerous + Intensity = new Dictionary(64); + Owner = owner; + } + + public override string ToString() { + return "Lighting Cache Entry for " + (Owner == null ? "" : Owner.name); + } + } + } +} diff --git a/mod/PLibLighting/PLightShape.cs b/mod/PLibLighting/PLightShape.cs new file mode 100644 index 0000000..9ed164c --- /dev/null +++ b/mod/PLibLighting/PLightShape.cs @@ -0,0 +1,85 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using UnityEngine; + +using BrightnessDict = System.Collections.Generic.IDictionary; + +namespace PeterHan.PLib.Lighting { + /// + /// Represents a light shape which can be used by mods. + /// + internal sealed class PLightShape : ILightShape { + /// + /// The handler for this light shape. + /// + private readonly PLightManager.CastLightDelegate handler; + + public string Identifier { get; } + + public LightShape KleiLightShape { + get { + return ShapeID + LightShape.Cone; + } + } + + public LightShape RayMode { get; } + + /// + /// The light shape ID. + /// + internal int ShapeID { get; } + + internal PLightShape(int id, string identifier, PLightManager.CastLightDelegate handler, + LightShape rayMode) { + this.handler = handler ?? throw new ArgumentNullException(nameof(handler)); + Identifier = identifier ?? throw new ArgumentNullException(nameof(identifier)); + RayMode = rayMode; + ShapeID = id; + } + + public override bool Equals(object obj) { + return obj is PLightShape other && other.Identifier == Identifier; + } + + /// + /// Invokes the light handler with the provided light information. + /// + /// The source of the light. + /// The origin cell. + /// The range to fill. + /// The location where lit points will be stored. + internal void DoFillLight(GameObject source, int cell, int range, + BrightnessDict brightness) { + handler.Invoke(new LightingArgs(source, cell, range, brightness)); + } + + public void FillLight(LightingArgs args) { + handler.Invoke(args); + } + + public override int GetHashCode() { + return Identifier.GetHashCode(); + } + + public override string ToString() { + return "PLightShape[ID=" + Identifier + "]"; + } + } +} diff --git a/mod/PLibLighting/PRemoteLightWrapper.cs b/mod/PLibLighting/PRemoteLightWrapper.cs new file mode 100644 index 0000000..c8d99ce --- /dev/null +++ b/mod/PLibLighting/PRemoteLightWrapper.cs @@ -0,0 +1,83 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System; +using UnityEngine; + +using BrightnessDict = System.Collections.Generic.IDictionary; + +namespace PeterHan.PLib.Lighting { + /// + /// Wraps a lighting system call from another mod's namespace. + /// + internal sealed class PRemoteLightWrapper : ILightShape { + // The delegate type covering calls to FillLight from other mods. + private delegate void FillLightDelegate(GameObject source, int cell, int range, + BrightnessDict brightness); + + /// + /// Creates a light shape instance from another mod. + /// + /// The object to convert. + /// A light shape object in this mod's namespace that delegates lighting + /// calls to the other mod if necessary. + internal static ILightShape LightToInstance(object other) { + return (other == null || other.GetType().Name != nameof(PLightShape)) ? null : + ((other is ILightShape ls) ? ls : new PRemoteLightWrapper(other)); + } + + /// + /// The method to call when lighting system handling is requested. + /// + private readonly FillLightDelegate fillLight; + + public string Identifier { get; } + + public LightShape KleiLightShape { get; } + + public LightShape RayMode { get; } + + internal PRemoteLightWrapper(object other) { + if (other == null) + throw new ArgumentNullException(nameof(other)); + if (!PPatchTools.TryGetPropertyValue(other, nameof(ILightShape.KleiLightShape), + out LightShape ls)) + throw new ArgumentException("Light shape is missing KleiLightShape"); + KleiLightShape = ls; + if (!PPatchTools.TryGetPropertyValue(other, nameof(ILightShape.Identifier), + out string id) || id == null) + throw new ArgumentException("Light shape is missing Identifier"); + Identifier = id; + if (!PPatchTools.TryGetPropertyValue(other, nameof(ILightShape.RayMode), + out LightShape rm)) + rm = (LightShape)(-1); + RayMode = rm; + var otherType = other.GetType(); + fillLight = otherType.CreateDelegate(nameof(PLightShape. + DoFillLight), other, typeof(GameObject), typeof(int), typeof(int), + typeof(BrightnessDict)); + if (fillLight == null) + throw new ArgumentException("Light shape is missing FillLight"); + } + + public void FillLight(LightingArgs args) { + fillLight.Invoke(args.Source, args.SourceCell, args.Range, args.Brightness); + } + } +} diff --git a/mod/PLibOptions/ButtonOptionsEntry.cs b/mod/PLibOptions/ButtonOptionsEntry.cs new file mode 100644 index 0000000..e39cefc --- /dev/null +++ b/mod/PLibOptions/ButtonOptionsEntry.cs @@ -0,0 +1,72 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.UI; +using System; +using UnityEngine; + +namespace PeterHan.PLib.Options { + /// + /// An options entry that displays a button. Not intended to be serializable to the + /// options file, instead declare a read-only property that returns a handler method as + /// an Action in the settings class, e.g: + /// + /// [Option("Click Here!", "Button tool tip")] + /// public System.Action<object> MyButton => Handler; + /// + /// public void Handler() { + /// // ... + /// } + /// + public class ButtonOptionsEntry : OptionsEntry { + public override object Value { + get { + return value; + } + set { + if (value is Action newValue) + this.value = newValue; + } + } + + /// + /// The action to invoke when the button is pushed. + /// + private Action value; + + public ButtonOptionsEntry(string field, IOptionSpec spec) : base(field, spec) { } + + public override void CreateUIEntry(PGridPanel parent, ref int row) { + parent.AddChild(new PButton(Field) { + Text = LookInStrings(Title), ToolTip = LookInStrings(Tooltip), + OnClick = OnButtonClicked + }.SetKleiPinkStyle(), new GridComponentSpec(row, 0) { + Margin = CONTROL_MARGIN, Alignment = TextAnchor.MiddleCenter, ColumnSpan = 2 + }); + } + + private void OnButtonClicked(GameObject _) { + value?.Invoke(null); + } + + public override GameObject GetUIComponent() { + // Will not be invoked + return new GameObject("Empty"); + } + } +} diff --git a/mod/PLibOptions/CategoryExpandHandler.cs b/mod/PLibOptions/CategoryExpandHandler.cs new file mode 100644 index 0000000..9e8520a --- /dev/null +++ b/mod/PLibOptions/CategoryExpandHandler.cs @@ -0,0 +1,102 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.UI; +using UnityEngine; +using UnityEngine.Events; + +namespace PeterHan.PLib.Options { + /// + /// Handles events for expanding and contracting options categories. + /// + internal sealed class CategoryExpandHandler { + /// + /// The realized panel containing the options. + /// + private GameObject contents; + + /// + /// The initial state of the button. + /// + private readonly bool initialState; + + /// + /// The realized toggle button. + /// + private GameObject toggle; + + /// + /// Creates a new options category. + /// + /// true to start expanded, or false to start collapsed. + public CategoryExpandHandler(bool initialState = true) { + this.initialState = initialState; + } + + /// + /// Fired when the options category is expanded or contracted. + /// + /// true if the button is on, or false if it is off. + public void OnExpandContract(GameObject _, bool on) { + var scale = on ? Vector3.one : Vector3.zero; + if (contents != null) { + var rt = contents.rectTransform(); + rt.localScale = scale; + if (rt != null) + UnityEngine.UI.LayoutRebuilder.MarkLayoutForRebuild(rt); + } + } + + /// + /// Fired when the header is clicked. + /// + private void OnHeaderClicked() { + if (toggle != null) { + bool state = PToggle.GetToggleState(toggle); + PToggle.SetToggleState(toggle, !state); + } + } + + /// + /// Fired when the category label is realized. + /// + /// The realized header label of the category. + public void OnRealizeHeader(GameObject header) { + var button = header.AddComponent(); + button.onClick.AddListener(new UnityAction(OnHeaderClicked)); + button.interactable = true; + } + + /// + /// Fired when the body is realized. + /// + /// The realized body of the category. + public void OnRealizePanel(GameObject panel) { + contents = panel; + OnExpandContract(null, initialState); + } + + /// + /// Fired when the toggle button is realized. + /// + /// The realized expand/contract button. + public void OnRealizeToggle(GameObject toggle) { + this.toggle = toggle; + } + } +} diff --git a/mod/PLibOptions/CheckboxOptionsEntry.cs b/mod/PLibOptions/CheckboxOptionsEntry.cs new file mode 100644 index 0000000..172866d --- /dev/null +++ b/mod/PLibOptions/CheckboxOptionsEntry.cs @@ -0,0 +1,71 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.UI; +using UnityEngine; + +namespace PeterHan.PLib.Options { + /// + /// An options entry which represents bool and displays a check box. + /// + public class CheckboxOptionsEntry : OptionsEntry { + public override object Value { + get { + return check; + } + set { + if (value is bool newCheck) { + check = newCheck; + Update(); + } + } + } + + /// + /// true if it is checked, or false otherwise + /// + private bool check; + + /// + /// The realized item checkbox. + /// + private GameObject checkbox; + + public CheckboxOptionsEntry(string field, IOptionSpec spec) : base(field, spec) { + check = false; + checkbox = null; + } + + public override GameObject GetUIComponent() { + checkbox = new PCheckBox() { + OnChecked = (source, state) => { + // Swap the check: checked and partial -> unchecked + check = state == PCheckBox.STATE_UNCHECKED; + Update(); + }, ToolTip = LookInStrings(Tooltip) + }.SetKleiBlueStyle().Build(); + Update(); + return checkbox; + } + + private void Update() { + PCheckBox.SetCheckState(checkbox, check ? PCheckBox.STATE_CHECKED : PCheckBox. + STATE_UNCHECKED); + } + } +} diff --git a/mod/PLibOptions/Color32OptionsEntry.cs b/mod/PLibOptions/Color32OptionsEntry.cs new file mode 100644 index 0000000..377dc0f --- /dev/null +++ b/mod/PLibOptions/Color32OptionsEntry.cs @@ -0,0 +1,38 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using UnityEngine; + +namespace PeterHan.PLib.Options { + /// + /// An options entry which represents Color32 and displays a color picker with sliders. + /// + internal class Color32OptionsEntry : ColorBaseOptionsEntry { + public override object Value { + get => (Color32)value; + set { + if (value is Color32 newValue) { + this.value = newValue; + UpdateAll(); + } + } + } + + public Color32OptionsEntry(string field, IOptionSpec spec) : base(field, spec) { } + } +} diff --git a/mod/PLibOptions/ColorBaseOptionsEntry.cs b/mod/PLibOptions/ColorBaseOptionsEntry.cs new file mode 100644 index 0000000..3403b85 --- /dev/null +++ b/mod/PLibOptions/ColorBaseOptionsEntry.cs @@ -0,0 +1,328 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using PeterHan.PLib.UI; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.Options { + /// + /// The abstract base of options entries that display a color picker with sliders. + /// + internal abstract class ColorBaseOptionsEntry : OptionsEntry { + /// + /// The margin between the color sliders and the rest of the dialog. + /// + protected static readonly RectOffset ENTRY_MARGIN = new RectOffset(10, 10, 2, 5); + + /// + /// The margin around each slider. + /// + protected static readonly RectOffset SLIDER_MARGIN = new RectOffset(10, 0, 2, 2); + + /// + /// The size of the sample swatch. + /// + protected const float SWATCH_SIZE = 32.0f; + + /// + /// The hue displayed gradient. + /// + protected ColorGradient hueGradient; + + /// + /// The hue slider. + /// + protected KSlider hueSlider; + + /// + /// The realized text field for BLUE. + /// + protected TMP_InputField blue; + + /// + /// The realized text field for GREEN. + /// + protected TMP_InputField green; + + /// + /// The realized text field for RED. + /// + protected TMP_InputField red; + + /// + /// The saturation displayed gradient. + /// + protected ColorGradient satGradient; + + /// + /// The saturation slider. + /// + protected KSlider satSlider; + + /// + /// The color sample swatch. + /// + protected Image swatch; + + /// + /// The value displayed gradient. + /// + protected ColorGradient valGradient; + + /// + /// The value slider. + /// + protected KSlider valSlider; + + /// + /// The value as a Color. + /// + protected Color value; + + protected ColorBaseOptionsEntry(string field, IOptionSpec spec) : base(field, spec) { + value = Color.white; + blue = null; + green = null; + red = null; + hueGradient = null; hueSlider = null; + satGradient = null; satSlider = null; + valGradient = null; valSlider = null; + swatch = null; + } + + public override void CreateUIEntry(PGridPanel parent, ref int row) { + base.CreateUIEntry(parent, ref row); + // Add 3 rows for the H, S, and V + parent.AddRow(new GridRowSpec()); + var h = new PSliderSingle("Hue") { + ToolTip = PLibStrings.TOOLTIP_HUE, MinValue = 0.0f, MaxValue = 1.0f, + CustomTrack = true, FlexSize = Vector2.right, OnValueChanged = OnHueChanged + }.AddOnRealize(OnHueRealized); + var s = new PSliderSingle("Saturation") { + ToolTip = PLibStrings.TOOLTIP_SATURATION, MinValue = 0.0f, MaxValue = 1.0f, + CustomTrack = true, FlexSize = Vector2.right, OnValueChanged = OnSatChanged + }.AddOnRealize(OnSatRealized); + var v = new PSliderSingle("Value") { + ToolTip = PLibStrings.TOOLTIP_VALUE, MinValue = 0.0f, MaxValue = 1.0f, + CustomTrack = true, FlexSize = Vector2.right, OnValueChanged = OnValChanged + }.AddOnRealize(OnValRealized); + var sw = new PLabel("Swatch") { + ToolTip = LookInStrings(Tooltip), DynamicSize = false, + Sprite = PUITuning.Images.BoxBorder, SpriteMode = Image.Type.Sliced, + SpriteSize = new Vector2(SWATCH_SIZE, SWATCH_SIZE) + }.AddOnRealize(OnSwatchRealized); + var panel = new PRelativePanel("ColorPicker") { + FlexSize = Vector2.right, DynamicSize = false + }.AddChild(h).AddChild(s).AddChild(v).AddChild(sw).SetRightEdge(h, fraction: 1.0f). + SetRightEdge(s, fraction: 1.0f).SetRightEdge(v, fraction: 1.0f). + SetLeftEdge(sw, fraction: 0.0f).SetMargin(h, SLIDER_MARGIN). + SetMargin(s, SLIDER_MARGIN).SetMargin(v, SLIDER_MARGIN).AnchorYAxis(sw). + SetLeftEdge(h, toRight: sw).SetLeftEdge(s, toRight: sw). + SetLeftEdge(v, toRight: sw).SetTopEdge(h, fraction: 1.0f). + SetBottomEdge(v, fraction: 0.0f).SetTopEdge(s, below: h). + SetTopEdge(v, below: s); + parent.AddChild(panel, new GridComponentSpec(++row, 0) { + ColumnSpan = 2, Margin = ENTRY_MARGIN + }); + } + + public override GameObject GetUIComponent() { + Color32 rgb = value; + var go = new PPanel("RGB") { + DynamicSize = false, Alignment = TextAnchor.MiddleRight, Spacing = 5, + Direction = PanelDirection.Horizontal + }.AddChild(new PLabel("Red") { + TextStyle = PUITuning.Fonts.TextLightStyle, Text = PLibStrings.LABEL_R + }).AddChild(new PTextField("RedValue") { + OnTextChanged = OnRGBChanged, ToolTip = PLibStrings.TOOLTIP_RED, + Text = rgb.r.ToString(), MinWidth = 32, MaxLength = 3, + Type = PTextField.FieldType.Integer + }.AddOnRealize(OnRedRealized)).AddChild(new PLabel("Green") { + TextStyle = PUITuning.Fonts.TextLightStyle, Text = PLibStrings.LABEL_G + }).AddChild(new PTextField("GreenValue") { + OnTextChanged = OnRGBChanged, ToolTip = PLibStrings.TOOLTIP_GREEN, + Text = rgb.g.ToString(), MinWidth = 32, MaxLength = 3, + Type = PTextField.FieldType.Integer + }.AddOnRealize(OnGreenRealized)).AddChild(new PLabel("Blue") { + TextStyle = PUITuning.Fonts.TextLightStyle, Text = PLibStrings.LABEL_B + }).AddChild(new PTextField("BlueValue") { + OnTextChanged = OnRGBChanged, ToolTip = PLibStrings.TOOLTIP_BLUE, + Text = rgb.b.ToString(), MinWidth = 32, MaxLength = 3, + Type = PTextField.FieldType.Integer + }.AddOnRealize(OnBlueRealized)).Build(); + UpdateAll(); + return go; + } + + private void OnBlueRealized(GameObject realized) { + blue = realized.GetComponentInChildren(); + } + + private void OnGreenRealized(GameObject realized) { + green = realized.GetComponentInChildren(); + } + + private void OnHueChanged(GameObject _, float newHue) { + if (hueGradient != null && hueSlider != null) { + float oldAlpha = value.a; + hueGradient.Position = hueSlider.value; + value = hueGradient.SelectedColor; + value.a = oldAlpha; + UpdateRGB(); + UpdateSat(false); + UpdateVal(false); + } + } + + private void OnHueRealized(GameObject realized) { + hueGradient = realized.AddOrGet(); + realized.TryGetComponent(out hueSlider); + } + + private void OnRedRealized(GameObject realized) { + red = realized.GetComponentInChildren(); + } + + /// + /// Called when the red, green, or blue field's text is changed. + /// + /// The new color value. + protected void OnRGBChanged(GameObject _, string text) { + Color32 rgb = value; + if (byte.TryParse(red.text, out byte r)) + rgb.r = r; + if (byte.TryParse(green.text, out byte g)) + rgb.g = g; + if (byte.TryParse(blue.text, out byte b)) + rgb.b = b; + value = rgb; + UpdateAll(); + } + + private void OnSatChanged(GameObject _, float newSat) { + if (satGradient != null && satSlider != null) { + float oldAlpha = value.a; + satGradient.Position = satSlider.value; + value = satGradient.SelectedColor; + value.a = oldAlpha; + UpdateRGB(); + UpdateHue(false); + UpdateVal(false); + } + } + + private void OnSatRealized(GameObject realized) { + satGradient = realized.AddOrGet(); + realized.TryGetComponent(out satSlider); + } + + private void OnSwatchRealized(GameObject realized) { + swatch = realized.GetComponentInChildren(); + } + + private void OnValChanged(GameObject _, float newValue) { + if (valGradient != null && valSlider != null) { + float oldAlpha = value.a; + valGradient.Position = valSlider.value; + value = valGradient.SelectedColor; + value.a = oldAlpha; + UpdateRGB(); + UpdateHue(false); + UpdateSat(false); + } + } + + private void OnValRealized(GameObject realized) { + valGradient = realized.AddOrGet(); + realized.TryGetComponent(out valSlider); + } + + /// + /// If the color is changed externally, updates all sliders. + /// + protected void UpdateAll() { + UpdateRGB(); + UpdateHue(true); + UpdateSat(true); + UpdateVal(true); + } + + /// + /// Updates the position of the hue slider with the currently selected color. + /// + /// true to move the slider handle if necessary, or false to + /// leave it where it is. + protected void UpdateHue(bool moveSlider) { + if (hueGradient != null && hueSlider != null) { + Color.RGBToHSV(value, out _, out float s, out float v); + hueGradient.SetRange(0.0f, 1.0f, s, s, v, v); + hueGradient.SelectedColor = value; + if (moveSlider) + hueSlider.value = hueGradient.Position; + } + } + + /// + /// Updates the displayed value. + /// + protected void UpdateRGB() { + Color32 rgb = value; + if (red != null) + red.text = rgb.r.ToString(); + if (green != null) + green.text = rgb.g.ToString(); + if (blue != null) + blue.text = rgb.b.ToString(); + if (swatch != null) + swatch.color = value; + } + + /// + /// Updates the position of the saturation slider with the currently selected color. + /// + /// true to move the slider handle if necessary, or false to + /// leave it where it is. + protected void UpdateSat(bool moveSlider) { + if (satGradient != null && satSlider != null) { + Color.RGBToHSV(value, out float h, out _, out float v); + satGradient.SetRange(h, h, 0.0f, 1.0f, v, v); + satGradient.SelectedColor = value; + if (moveSlider) + satSlider.value = satGradient.Position; + } + } + + /// + /// Updates the position of the value slider with the currently selected color. + /// + /// true to move the slider handle if necessary, or false to + /// leave it where it is. + protected void UpdateVal(bool moveSlider) { + if (valGradient != null && valSlider != null) { + Color.RGBToHSV(value, out float h, out float s, out _); + valGradient.SetRange(h, h, s, s, 0.0f, 1.0f); + valGradient.SelectedColor = value; + if (moveSlider) + valSlider.value = valGradient.Position; + } + } + } +} diff --git a/mod/PLibOptions/ColorGradient.cs b/mod/PLibOptions/ColorGradient.cs new file mode 100644 index 0000000..c8e1ef9 --- /dev/null +++ b/mod/PLibOptions/ColorGradient.cs @@ -0,0 +1,227 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.Options { + /// + /// A background image which displays a gradient between two different colors using HSV + /// interpolation. + /// + internal sealed class ColorGradient : Image { + /// + /// The position to use on the track. + /// + public float Position { + get => position; + set { + position = Mathf.Clamp01(value); + SetPosition(); + } + } + + /// + /// The currently selected color. + /// + public Color SelectedColor { + get => current; + set { + current = value; + EstimatePosition(); + } + } + + /// + /// The currently selected color. + /// + private Color current; + + /// + /// Whether the image texture needs to be regenerated. + /// + private bool dirty; + + /// + /// Gradient unfortunately always uses RGB. Run curves manually using HSV. + /// + private Vector2 hue; + + /// + /// The position to use on the track. + /// + private float position; + + /// + /// The texture used when drawing the background. + /// + private Texture2D preview; + + private Vector2 sat; + + private Vector2 val; + + public ColorGradient() { + current = Color.black; + position = 0.0f; + preview = null; + dirty = true; + hue = Vector2.right; + sat = Vector2.right; + val = Vector2.right; + } + + /// + /// Estimates a position based on the selected color. + /// + internal void EstimatePosition() { + Color.RGBToHSV(current, out float h, out float s, out float v); + float newPos = 0.0f, divider = 0.0f; + if (!Mathf.Approximately(hue.x, hue.y)) { + newPos += Mathf.Clamp01(Mathf.InverseLerp(hue.x, hue.y, h)); + divider += 1.0f; + } + if (!Mathf.Approximately(sat.x, sat.y)) { + newPos += Mathf.Clamp01(Mathf.InverseLerp(sat.x, sat.y, s)); + divider += 1.0f; + } + if (!Mathf.Approximately(val.x, val.y)) { + newPos += Mathf.Clamp01(Mathf.InverseLerp(val.x, val.y, v)); + divider += 1.0f; + } + if (divider > 0.0f) { + position = newPos / divider; + SetPosition(); + } + } + + /// + /// Cleans up the preview when the component is disposed. + /// + protected override void OnDestroy() { + if (preview != null) { + Destroy(preview); + preview = null; + } + if (sprite != null) { + Destroy(sprite); + sprite = null; + } + base.OnDestroy(); + } + + /// + /// Called when the image needs to be resized. + /// + protected override void OnRectTransformDimensionsChange() { + base.OnRectTransformDimensionsChange(); + dirty = true; + } + + /// + /// Updates the currently selected color with the interpolated value based on the + /// current position. + /// + internal void SetPosition() { + float h = Mathf.Clamp01(Mathf.Lerp(hue.x, hue.y, position)); + float s = Mathf.Clamp01(Mathf.Lerp(sat.x, sat.y, position)); + float v = Mathf.Clamp01(Mathf.Lerp(val.x, val.y, position)); + current = Color.HSVToRGB(h, s, v); + } + + /// + /// Sets the range of colors to be displayed. + /// + /// The minimum hue. + /// The maximum hue. + /// The minimum saturation. + /// The maximum saturation. + /// The minimum value. + /// The maximum value. + public void SetRange(float hMin, float hMax, float sMin, float sMax, float vMin, + float vMax) { + // Ensure correct order + if (hMin > hMax) { + hue.x = hMax; hue.y = hMin; + } else { + hue.x = hMin; hue.y = hMax; + } + if (sMin > sMax) { + sat.x = sMax; sat.y = sMin; + } else { + sat.x = sMin; sat.y = sMax; + } + if (vMin > vMax) { + val.x = vMax; val.y = vMin; + } else { + val.x = vMin; val.y = vMax; + } + EstimatePosition(); + dirty = true; + } + + /// + /// Called by Unity when the component is created. + /// + protected override void Start() { + base.Start(); + dirty = true; + } + + /// + /// Regenerates the color gradient image if necessary. + /// + internal void Update() { + if (dirty || preview == null || sprite == null) { + var rt = rectTransform.rect; + int w = Mathf.RoundToInt(rt.width), h = Mathf.RoundToInt(rt.height); + if (w > 0 && h > 0) { + var newData = new Color[w * h]; + float hMin = hue.x, hMax = hue.y, sMin = sat.x, sMax = sat.y, vMin = val.x, + vMax = val.y; + var oldSprite = sprite; + bool rebuild = preview == null || oldSprite == null || preview.width != + w || preview.height != h; + if (rebuild) { + if (preview != null) + Destroy(preview); + if (oldSprite != null) + Destroy(oldSprite); + preview = new Texture2D(w, h); + } + // Generate one row of colors + for (int i = 0; i < w; i++) { + float t = (float)i / w; + newData[i] = Color.HSVToRGB(Mathf.Lerp(hMin, hMax, t), Mathf.Lerp(sMin, + sMax, t), Mathf.Lerp(vMin, vMax, t)); + } + // Native copy row to all others + for (int i = 1; i < h; i++) + Array.Copy(newData, 0, newData, i * w, w); + preview.SetPixels(newData); + preview.Apply(); + if (rebuild) + sprite = Sprite.Create(preview, new Rect(0.0f, 0.0f, w, h), + new Vector2(0.5f, 0.5f)); + } + dirty = false; + } + } + } +} diff --git a/mod/PLibOptions/ColorOptionsEntry.cs b/mod/PLibOptions/ColorOptionsEntry.cs new file mode 100644 index 0000000..373f0ff --- /dev/null +++ b/mod/PLibOptions/ColorOptionsEntry.cs @@ -0,0 +1,38 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using UnityEngine; + +namespace PeterHan.PLib.Options { + /// + /// An options entry which represents Color and displays a color picker with sliders. + /// + internal class ColorOptionsEntry : ColorBaseOptionsEntry { + public override object Value { + get => value; + set { + if (value is Color newValue) { + this.value = newValue; + UpdateAll(); + } + } + } + + public ColorOptionsEntry(string field, IOptionSpec spec) : base(field, spec) { } + } +} diff --git a/mod/PLibOptions/CompositeOptionsEntry.cs b/mod/PLibOptions/CompositeOptionsEntry.cs new file mode 100644 index 0000000..c89d801 --- /dev/null +++ b/mod/PLibOptions/CompositeOptionsEntry.cs @@ -0,0 +1,161 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using PeterHan.PLib.UI; +using System; +using System.Collections.Generic; +using System.Reflection; +using UnityEngine; + +namespace PeterHan.PLib.Options { + /// + /// An options entry that encapsulates other options. The category annotation on those + /// objects will be ignored, and the category of the Option attribute on the property + /// that declared those options (to avoid infinite loops) will be used instead. + /// + /// This object is not in the scene graph. Any events in OnRealize will never be + /// invoked, and it is never "built". + /// + internal class CompositeOptionsEntry : OptionsEntry { + /// + /// Creates an options entry wrapper for the specified property, iterating its internal + /// fields to create sub-options if needed (recursively). + /// + /// The property to wrap. + /// The option title and tool tip. + /// The current depth of iteration to avoid infinite loops. + /// An options wrapper, or null if no inner properties are themselves options. + internal static CompositeOptionsEntry Create(IOptionSpec spec, PropertyInfo info, + int depth) { + var type = info.PropertyType; + var composite = new CompositeOptionsEntry(info.Name, spec, type); + // Skip static properties if they exist + foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags. + Instance)) { + var entry = TryCreateEntry(prop, depth + 1); + if (entry != null) + composite.AddField(prop, entry); + } + return composite.ChildCount > 0 ? composite : null; + } + + /// + /// Reports the number of options contained inside this one. + /// + public int ChildCount { + get { + return subOptions.Count; + } + } + + public override object Value { + get { + return value; + } + set { + if (value != null && targetType.IsAssignableFrom(value.GetType())) + this.value = value; + } + } + + /// + /// The options encapsulated in this object. + /// + protected readonly IDictionary subOptions; + + /// + /// The type of the encapsulated object. + /// + protected readonly Type targetType; + + /// + /// The object thus wrapped. + /// + protected object value; + + public CompositeOptionsEntry(string field, IOptionSpec spec, Type fieldType) : + base(field, spec) { + subOptions = new Dictionary(16); + targetType = fieldType ?? throw new ArgumentNullException(nameof(fieldType)); + value = OptionsDialog.CreateOptions(fieldType); + } + + /// + /// Adds an options entry object that operates on Option fields of the encapsulated + /// object. + /// + /// The property that is wrapped. + /// The entry to add. + public void AddField(PropertyInfo info, IOptionsEntry entry) { + if (entry == null) + throw new ArgumentNullException(nameof(entry)); + if (info == null) + throw new ArgumentNullException(nameof(info)); + subOptions.Add(info, entry); + } + + public override void CreateUIEntry(PGridPanel parent, ref int row) { + int i = row; + bool first = true; + parent.AddOnRealize(WhenRealized); + // Render each sub-entry - order is always Add Spec, Create Entry, Increment Row + foreach (var pair in subOptions) { + if (!first) { + i++; + parent.AddRow(new GridRowSpec()); + } + pair.Value.CreateUIEntry(parent, ref i); + first = false; + } + row = i; + } + + public override GameObject GetUIComponent() { + // Will not be invoked + return new GameObject("Empty"); + } + + public override void ReadFrom(object settings) { + base.ReadFrom(settings); + foreach (var pair in subOptions) + pair.Value.ReadFrom(value); + } + + public override string ToString() { + return "{1}[field={0},title={2},children=[{3}]]".F(Field, GetType().Name, Title, + subOptions.Join()); + } + + /// + /// Updates the child objects for the first time when the panel is realized. + /// + private void WhenRealized(GameObject _) { + foreach (var pair in subOptions) + pair.Value.ReadFrom(value); + } + + public override void WriteTo(object settings) { + foreach (var pair in subOptions) + // Cannot detour as the types are not known at compile time, and delegates + // bake in the target object which changes upon each update + pair.Value.WriteTo(value); + base.WriteTo(settings); + } + } +} diff --git a/mod/PLibOptions/ConfigFileAttribute.cs b/mod/PLibOptions/ConfigFileAttribute.cs new file mode 100644 index 0000000..7aa7f4b --- /dev/null +++ b/mod/PLibOptions/ConfigFileAttribute.cs @@ -0,0 +1,59 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; + +namespace PeterHan.PLib.Options { + /// + /// An attribute placed on an options class only (will not function on a member property) + /// which denotes the config file name to use for that mod, and allows save/load options + /// to be set. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public sealed class ConfigFileAttribute : Attribute { + /// + /// The configuration file name. If null, the default file name will be used. + /// + public string ConfigFileName { get; } + + /// + /// Whether the output should be indented nicely. Defaults to false for smaller + /// config files. + /// + public bool IndentOutput { get; } + + /// + /// If true, the config file will be moved from the mod folder to a folder in the + /// config directory shared across mods. This change preserves the mod configuration + /// across updates, but may not be cleared when the mod is uninstalled. Use with + /// caution. + /// + public bool UseSharedConfigLocation { get; } + + public ConfigFileAttribute(string FileName = POptions.CONFIG_FILE_NAME, + bool IndentOutput = false, bool SharedConfigLocation = false) { + ConfigFileName = FileName; + this.IndentOutput = IndentOutput; + UseSharedConfigLocation = SharedConfigLocation; + } + + public override string ToString() { + return ConfigFileName; + } + } +} diff --git a/mod/PLibOptions/DynamicOptionAttribute.cs b/mod/PLibOptions/DynamicOptionAttribute.cs new file mode 100644 index 0000000..c5b2dc6 --- /dev/null +++ b/mod/PLibOptions/DynamicOptionAttribute.cs @@ -0,0 +1,54 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System; + +namespace PeterHan.PLib { + /// + /// An attribute placed on an option property for a class used as mod options in order to + /// make PLib use a custom options handler. The type used for the handler must inherit + /// from IOptionsEntry. + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public sealed class DynamicOptionAttribute : Attribute { + /// + /// The option category. + /// + public string Category { get; } + + /// + /// The option handler. + /// + public Type Handler { get; } + + /// + /// Denotes a mod option field. + /// + /// The type that will handle this dynamic option. + /// The category to use, or null for the default category. + public DynamicOptionAttribute(Type type, string category = null) { + Category = category; + Handler = type ?? throw new ArgumentNullException(nameof(type)); + } + + public override string ToString() { + return "DynamicOption[handler={0},category={1}]".F(Handler.FullName, Category); + } + } +} diff --git a/mod/PLibOptions/FloatOptionsEntry.cs b/mod/PLibOptions/FloatOptionsEntry.cs new file mode 100644 index 0000000..9766f39 --- /dev/null +++ b/mod/PLibOptions/FloatOptionsEntry.cs @@ -0,0 +1,119 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.UI; +using TMPro; +using UnityEngine; + +namespace PeterHan.PLib.Options { + /// + /// An options entry which represents float and displays a text field and slider. + /// + public class FloatOptionsEntry : SlidingBaseOptionsEntry { + /// + /// The format to use if none is provided. + /// + public const string DEFAULT_FORMAT = "F2"; + + public override object Value { + get { + return value; + } + set { + if (value is float newValue) { + this.value = newValue; + Update(); + } + } + } + + /// + /// The realized text field. + /// + private GameObject textField; + + /// + /// The value in the text field. + /// + private float value; + + public FloatOptionsEntry(string field, IOptionSpec spec, LimitAttribute limit = null) : + base(field, spec, limit) { + textField = null; + value = 0.0f; + } + + protected override PSliderSingle GetSlider() { + return new PSliderSingle() { + OnValueChanged = OnSliderChanged, ToolTip = LookInStrings(Tooltip), + MinValue = (float)limits.Minimum, MaxValue = (float)limits.Maximum, + InitialValue = value + }; + } + + public override GameObject GetUIComponent() { + textField = new PTextField() { + OnTextChanged = OnTextChanged, ToolTip = LookInStrings(Tooltip), + Text = value.ToString(Format ?? DEFAULT_FORMAT), MinWidth = 64, MaxLength = 16, + Type = PTextField.FieldType.Float + }.Build(); + Update(); + return textField; + } + + /// + /// Called when the slider's value is changed. + /// + /// The new slider value. + private void OnSliderChanged(GameObject _, float newValue) { + // Record the value + if (limits != null) + value = limits.ClampToRange(newValue); + else + value = newValue; + Update(); + } + + /// + /// Called when the input field's text is changed. + /// + /// The new text. + private void OnTextChanged(GameObject _, string text) { + if (float.TryParse(text, out float newValue)) { + if (Format != null && Format.ToUpperInvariant().IndexOf('P') >= 0) + newValue *= 0.01f; + if (limits != null) + newValue = limits.ClampToRange(newValue); + // Record the valid value + value = newValue; + } + Update(); + } + + /// + /// Updates the displayed value. + /// + protected override void Update() { + var field = textField?.GetComponentInChildren(); + if (field != null) + field.text = value.ToString(Format ?? DEFAULT_FORMAT); + if (slider != null) + PSliderSingle.SetCurrentValue(slider, value); + } + } +} diff --git a/mod/PLibOptions/IOptionSpec.cs b/mod/PLibOptions/IOptionSpec.cs new file mode 100644 index 0000000..f8e7af7 --- /dev/null +++ b/mod/PLibOptions/IOptionSpec.cs @@ -0,0 +1,53 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +namespace PeterHan.PLib.Options { + /// + /// The common parent of all classes that can specify the user visible attributes of an + /// option. + /// + public interface IOptionSpec { + /// + /// The option category. Ignored and replaced with the parent option's category if + /// this option is part of a custom grouped type. + /// + string Category { get; } + + /// + /// The format string to use when displaying this option value. Only applicable for + /// some types of options. + /// + /// Warning: Attribute may have issues on nested classes that are used as custom + /// grouped options. To mitigate, try declaring the custom class in a non-nested + /// context (i.e. not declared inside another class). + /// + string Format { get; } + + /// + /// The option title. Ignored for fields which are displayed as custom grouped types + /// types of other options. + /// + string Title { get; } + + /// + /// The option description tooltip. Ignored for fields which are displayed as custom + /// grouped types of other options. + /// + string Tooltip { get; } + } +} diff --git a/mod/PLibOptions/IOptions.cs b/mod/PLibOptions/IOptions.cs new file mode 100644 index 0000000..b94ab56 --- /dev/null +++ b/mod/PLibOptions/IOptions.cs @@ -0,0 +1,54 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System.Collections.Generic; + +namespace PeterHan.PLib.Options { + /// + /// An optional interface which can be implemented to give mods the ability to dynamically + /// add new options at runtime, or to get a notification when options are updated to the + /// options file. + /// + /// This interface is optional. There is no need to implement it to use PLib + /// Options. But if one method is implemented, the other must also be. If not used, + /// OnOptionsChanged should be empty, and CreateOptions should return an empty collection. + /// + public interface IOptions { + /// + /// Called to create additional options. After the options in this class have been + /// read from the data file, but before the dialog is shown, this method will be + /// invoked. Each return value must be of a type that implements IOptionsEntry. + /// + /// The options will be sorted and categorized normally as if they were present at the + /// end of the property list in a regular options class. + /// + /// This method can be an enumerator using code like + /// yield return new MyOptionsHandler(); + /// + /// The custom options to implement. + IEnumerable CreateOptions(); + + /// + /// Called when options are written to the file. The current object will have the same + /// values as the data that was just written to the file. This call happens after the + /// options have been stored to disk, but before any restart required dialog is shown + /// (if [RestartRequired] is also on this class). + /// + void OnOptionsChanged(); + } +} diff --git a/mod/PLibOptions/IOptionsEntry.cs b/mod/PLibOptions/IOptionsEntry.cs new file mode 100644 index 0000000..d9d592b --- /dev/null +++ b/mod/PLibOptions/IOptionsEntry.cs @@ -0,0 +1,46 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.UI; + +namespace PeterHan.PLib.Options { + /// + /// All options handlers, including user dynamic option handlers, implement this type. + /// + public interface IOptionsEntry : IOptionSpec { + /// + /// Creates UI components that will present this option. + /// + /// The parent panel where the components should be added. + /// The row index where the component should be placed. If multiple + /// rows of components are added, increment this value for each additional row. + void CreateUIEntry(PGridPanel parent, ref int row); + + /// + /// Reads the option value into the UI from the provided settings object. + /// + /// The settings object. + void ReadFrom(object settings); + + /// + /// Writes the option value from the UI into the provided settings object. + /// + /// The settings object. + void WriteTo(object settings); + } +} diff --git a/mod/PLibOptions/IntOptionsEntry.cs b/mod/PLibOptions/IntOptionsEntry.cs new file mode 100644 index 0000000..5098eb4 --- /dev/null +++ b/mod/PLibOptions/IntOptionsEntry.cs @@ -0,0 +1,112 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.UI; +using TMPro; +using UnityEngine; + +namespace PeterHan.PLib.Options { + /// + /// An options entry which represents int and displays a text field and slider. + /// + public class IntOptionsEntry : SlidingBaseOptionsEntry { + public override object Value { + get { + return value; + } + set { + if (value is int newValue) { + this.value = newValue; + Update(); + } + } + } + + /// + /// The realized text field. + /// + private GameObject textField; + + /// + /// The value in the text field. + /// + private int value; + + public IntOptionsEntry(string field, IOptionSpec spec, LimitAttribute limit = null) : + base(field, spec, limit) { + textField = null; + value = 0; + } + + protected override PSliderSingle GetSlider() { + return new PSliderSingle() { + OnValueChanged = OnSliderChanged, ToolTip = LookInStrings(Tooltip), + MinValue = (float)limits.Minimum, MaxValue = (float)limits.Maximum, + InitialValue = value, IntegersOnly = true + }; + } + + public override GameObject GetUIComponent() { + textField = new PTextField() { + OnTextChanged = OnTextChanged, ToolTip = LookInStrings(Tooltip), + Text = value.ToString(Format ?? "D"), MinWidth = 64, MaxLength = 10, + Type = PTextField.FieldType.Integer + }.Build(); + Update(); + return textField; + } + + /// + /// Called when the slider's value is changed. + /// + /// The new slider value. + private void OnSliderChanged(GameObject _, float newValue) { + int newIntValue = Mathf.RoundToInt(newValue); + if (limits != null) + newIntValue = limits.ClampToRange(newIntValue); + // Record the value + value = newIntValue; + Update(); + } + + /// + /// Called when the input field's text is changed. + /// + /// The new text. + private void OnTextChanged(GameObject _, string text) { + if (int.TryParse(text, out int newValue)) { + if (limits != null) + newValue = limits.ClampToRange(newValue); + // Record the valid value + value = newValue; + } + Update(); + } + + /// + /// Updates the displayed value. + /// + protected override void Update() { + var field = textField?.GetComponentInChildren(); + if (field != null) + field.text = value.ToString(Format ?? "D"); + if (slider != null) + PSliderSingle.SetCurrentValue(slider, value); + } + } +} diff --git a/mod/PLibOptions/LimitAttribute.cs b/mod/PLibOptions/LimitAttribute.cs new file mode 100644 index 0000000..c1a5470 --- /dev/null +++ b/mod/PLibOptions/LimitAttribute.cs @@ -0,0 +1,75 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System; + +namespace PeterHan.PLib.Options { + /// + /// An attribute placed on an option field for a property used as mod options to define + /// minimum and maximum acceptable values. + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public sealed class LimitAttribute : Attribute { + /// + /// The maximum value (inclusive). + /// + public double Maximum { get; } + + /// + /// The minimum value (inclusive). + /// + public double Minimum { get; } + + public LimitAttribute(double min, double max) { + Minimum = min.IsNaNOrInfinity() ? 0.0 : min; + Maximum = (max.IsNaNOrInfinity() || max < min) ? min : max; + } + + /// + /// Clamps the specified value to the range of this Limits object. + /// + /// The value to coerce. + /// The nearest value included by these limits to the specified value. + public float ClampToRange(float value) { + return value.InRange((float)Minimum, (float)Maximum); + } + + /// + /// Clamps the specified value to the range of this Limits object. + /// + /// The value to coerce. + /// The nearest value included by these limits to the specified value. + public int ClampToRange(int value) { + return value.InRange((int)Minimum, (int)Maximum); + } + + /// + /// Reports whether a value is in the range included in these limits. + /// + /// The value to check. + /// true if it is included in the limits, or false otherwise. + public bool InRange(double value) { + return value >= Minimum && value <= Maximum; + } + + public override string ToString() { + return "{0:F2} to {1:F2}".F(Minimum, Maximum); + } + } +} \ No newline at end of file diff --git a/mod/PLibOptions/LogFloatOptionsEntry.cs b/mod/PLibOptions/LogFloatOptionsEntry.cs new file mode 100644 index 0000000..5d8cc05 --- /dev/null +++ b/mod/PLibOptions/LogFloatOptionsEntry.cs @@ -0,0 +1,120 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.UI; +using System; +using TMPro; +using UnityEngine; + +namespace PeterHan.PLib.Options { + /// + /// An options entry which represents float and displays a text field and slider. + /// This entry uses a logarithmic scale. + /// + public class LogFloatOptionsEntry : SlidingBaseOptionsEntry { + /// + /// The format to use if none is provided. + /// + public const string DEFAULT_FORMAT = "F2"; + + public override object Value { + get { + return value; + } + set { + if (value is float newValue) { + this.value = limits.ClampToRange(newValue); + Update(); + } + } + } + + /// + /// The realized text field. + /// + private GameObject textField; + + /// + /// The value in the text field. + /// + private float value; + + public LogFloatOptionsEntry(string field, IOptionSpec spec, LimitAttribute limit) : + base(field, spec, limit) { + if (limit == null) + throw new ArgumentNullException(nameof(limit)); + if (limit.Minimum <= 0.0f || limit.Maximum <= 0.0f) + throw new ArgumentOutOfRangeException(nameof(limit), + "Logarithmic values must be positive"); + textField = null; + value = limit.ClampToRange(1.0f); + } + + protected override PSliderSingle GetSlider() { + return new PSliderSingle() { + OnValueChanged = OnSliderChanged, ToolTip = LookInStrings(Tooltip), + MinValue = Mathf.Log((float)limits.Minimum), MaxValue = Mathf.Log( + (float)limits.Maximum), InitialValue = Mathf.Log(value) + }; + } + + public override GameObject GetUIComponent() { + textField = new PTextField() { + OnTextChanged = OnTextChanged, ToolTip = LookInStrings(Tooltip), + Text = value.ToString(Format ?? DEFAULT_FORMAT), MinWidth = 64, MaxLength = 16, + Type = PTextField.FieldType.Float + }.Build(); + Update(); + return textField; + } + + /// + /// Called when the slider's value is changed. + /// + /// The new slider value. + private void OnSliderChanged(GameObject _, float newValue) { + value = limits.ClampToRange(Mathf.Exp(newValue)); + Update(); + } + + /// + /// Called when the input field's text is changed. + /// + /// The new text. + private void OnTextChanged(GameObject _, string text) { + if (float.TryParse(text, out float newValue)) { + if (Format != null && Format.ToUpperInvariant().IndexOf('P') >= 0) + newValue *= 0.01f; + value = limits.ClampToRange(newValue); + } + Update(); + } + + /// + /// Updates the displayed value. + /// + protected override void Update() { + var field = textField?.GetComponentInChildren(); + if (field != null) + field.text = value.ToString(Format ?? DEFAULT_FORMAT); + if (slider != null) + PSliderSingle.SetCurrentValue(slider, Mathf.Log(Mathf.Max(float.Epsilon, + value))); + } + } +} diff --git a/mod/PLibOptions/ModDialogInfo.cs b/mod/PLibOptions/ModDialogInfo.cs new file mode 100644 index 0000000..b1e15aa --- /dev/null +++ b/mod/PLibOptions/ModDialogInfo.cs @@ -0,0 +1,93 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System; + +namespace PeterHan.PLib.Options { + /// + /// Stores the information displayed about a mod in its options dialog. + /// + internal sealed class ModDialogInfo { + /// + /// Gets the text shown for a mod's version. + /// + /// The type used for the mod settings. + /// The mod version description. + private static string GetModVersionText(Type optionsType) { + var asm = optionsType.Assembly; + string version = asm.GetFileVersion(); + // Use FileVersion if available, else assembly version + if (string.IsNullOrEmpty(version)) + version = string.Format(PLibStrings.MOD_ASSEMBLY_VERSION, asm.GetName(). + Version); + else + version = string.Format(PLibStrings.MOD_VERSION, version); + return version; + } + + /// + /// The path to the image displayed (on the file system) for this mod. + /// + public string Image { get; } + + /// + /// The mod title. The title is taken directly from the mod version information. + /// + public string Title { get; } + + /// + /// The URL which will be displayed. If none was provided, the Steam workshop page URL + /// will be reported for Steam mods, and an empty string for local/dev mods. + /// + public string URL { get; } + + /// + /// The mod version. + /// + public string Version { get; } + + internal ModDialogInfo(Type type, string url, string image) { + var mod = POptions.GetModFromType(type); + string title, version; + Image = image ?? ""; + if (mod != null) { + string modInfoVersion = mod.packagedModInfo?.version; + title = mod.title; + if (string.IsNullOrEmpty(url) && mod.label.distribution_platform == KMod.Label. + DistributionPlatform.Steam) + url = "https://steamcommunity.com/sharedfiles/filedetails/?id=" + mod. + label.id; + if (!string.IsNullOrEmpty(modInfoVersion)) + version = string.Format(PLibStrings.MOD_VERSION, modInfoVersion); + else + version = GetModVersionText(type); + } else { + title = type.Assembly.GetNameSafe(); + version = GetModVersionText(type); + } + Title = title ?? ""; + URL = url ?? ""; + Version = version; + } + + public override string ToString() { + return base.ToString(); + } + } +} diff --git a/mod/PLibOptions/ModInfoAttribute.cs b/mod/PLibOptions/ModInfoAttribute.cs new file mode 100644 index 0000000..bd4012c --- /dev/null +++ b/mod/PLibOptions/ModInfoAttribute.cs @@ -0,0 +1,57 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; + +namespace PeterHan.PLib.Options { + /// + /// Allows mod authors to specify attributes for their mods to be shown in the Options + /// dialog. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public sealed class ModInfoAttribute : Attribute { + /// + /// If true, forces all categories in the options screen to begin collapsed (except + /// the default category). + /// + public bool ForceCollapseCategories { get; } + + /// + /// The name of the image file (in the mod's root directory) to display in the options + /// dialog. If null or empty (or it cannot be loaded), no image is displayed. + /// + public string Image { get; } + + /// + /// The URL to use for the mod. If null or empty, the Steam workshop link will be used + /// if possible, or otherwise the button will not be shown. + /// + public string URL { get; } + + public ModInfoAttribute(string url, string image = null, bool collapse = false) + { + ForceCollapseCategories = collapse; + Image = image; + URL = url; + } + + public override string ToString() { + return string.Format("ModInfoAttribute[URL={1},Image={2}]", URL, Image); + } + } +} diff --git a/mod/PLibOptions/NullableFloatOptionsEntry.cs b/mod/PLibOptions/NullableFloatOptionsEntry.cs new file mode 100644 index 0000000..d9ea2a9 --- /dev/null +++ b/mod/PLibOptions/NullableFloatOptionsEntry.cs @@ -0,0 +1,132 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.UI; +using TMPro; +using UnityEngine; + +namespace PeterHan.PLib.Options { + /// + /// An options entry which represents float? and displays a text field and slider. + /// + public class NullableFloatOptionsEntry : SlidingBaseOptionsEntry { + /// + /// The text that is rendered for the current value of the entry. + /// + protected virtual string FieldText { + get { + return value?.ToString(Format ?? FloatOptionsEntry.DEFAULT_FORMAT) ?? string. + Empty; + } + } + + public override object Value { + get { + return value; + } + set { + if (value == null) { + this.value = null; + Update(); + } else if (value is float newValue) { + this.value = newValue; + Update(); + } + } + } + + /// + /// The realized text field. + /// + private GameObject textField; + + /// + /// The value in the text field. + /// + private float? value; + + public NullableFloatOptionsEntry(string field, IOptionSpec spec, + LimitAttribute limit = null) : base(field, spec, limit) { + textField = null; + value = null; + } + + protected override PSliderSingle GetSlider() { + float minLimit = (float)limits.Minimum, maxLimit = (float)limits.Maximum; + return new PSliderSingle() { + OnValueChanged = OnSliderChanged, ToolTip = LookInStrings(Tooltip), + MinValue = minLimit, MaxValue = maxLimit, InitialValue = 0.5f * (minLimit + + maxLimit) + }; + } + + public override GameObject GetUIComponent() { + textField = new PTextField() { + OnTextChanged = OnTextChanged, ToolTip = LookInStrings(Tooltip), + Text = FieldText, MinWidth = 64, MaxLength = 16, + Type = PTextField.FieldType.Float + }.Build(); + Update(); + return textField; + } + + /// + /// Called when the slider's value is changed. + /// + /// The new slider value. + private void OnSliderChanged(GameObject _, float newValue) { + // Record the value + if (limits != null) + value = limits.ClampToRange(newValue); + else + value = newValue; + Update(); + } + + /// + /// Called when the input field's text is changed. + /// + /// The new text. + private void OnTextChanged(GameObject _, string text) { + if (string.IsNullOrWhiteSpace(text)) + // Limits are assumed to allow null, because why not use non-nullable + // otherwise? + value = null; + else if (float.TryParse(text, out float newValue)) { + if (Format != null && Format.ToUpperInvariant().IndexOf('P') >= 0) + newValue *= 0.01f; + if (limits != null) + newValue = limits.ClampToRange(newValue); + // Record the valid value + value = newValue; + } + Update(); + } + + /// + /// Updates the displayed value. + /// + protected override void Update() { + var field = textField?.GetComponentInChildren(); + if (field != null) + field.text = FieldText; + if (slider != null && value != null) + PSliderSingle.SetCurrentValue(slider, (float)value); + } + } +} diff --git a/mod/PLibOptions/NullableIntOptionsEntry.cs b/mod/PLibOptions/NullableIntOptionsEntry.cs new file mode 100644 index 0000000..8f22ef8 --- /dev/null +++ b/mod/PLibOptions/NullableIntOptionsEntry.cs @@ -0,0 +1,130 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.UI; +using System.Reflection; +using TMPro; +using UnityEngine; + +namespace PeterHan.PLib.Options { + /// + /// An options entry which represents int? and displays a text field and slider. + /// + public class NullableIntOptionsEntry : SlidingBaseOptionsEntry { + /// + /// The text that is rendered for the current value of the entry. + /// + protected virtual string FieldText { + get { + return value?.ToString(Format ?? "D") ?? string.Empty; + } + } + + public override object Value { + get { + return value; + } + set { + if (value == null) { + this.value = null; + Update(); + } else if (value is int newValue) { + this.value = newValue; + Update(); + } + } + } + + /// + /// The realized text field. + /// + private GameObject textField; + + /// + /// The value in the text field. + /// + private int? value; + + public NullableIntOptionsEntry(string field, IOptionSpec spec, + LimitAttribute limit = null) : base(field, spec, limit) { + textField = null; + value = null; + } + + protected override PSliderSingle GetSlider() { + float minLimit = (float)limits.Minimum, maxLimit = (float)limits.Maximum; + return new PSliderSingle() { + OnValueChanged = OnSliderChanged, ToolTip = LookInStrings(Tooltip), + MinValue = minLimit, MaxValue = maxLimit, InitialValue = minLimit, + IntegersOnly = true + }; + } + + public override GameObject GetUIComponent() { + textField = new PTextField() { + OnTextChanged = OnTextChanged, ToolTip = LookInStrings(Tooltip), + Text = FieldText, MinWidth = 64, MaxLength = 10, + Type = PTextField.FieldType.Integer + }.Build(); + Update(); + return textField; + } + + /// + /// Called when the slider's value is changed. + /// + /// The new slider value. + private void OnSliderChanged(GameObject _, float newValue) { + int newIntValue = Mathf.RoundToInt(newValue); + if (limits != null) + newIntValue = limits.ClampToRange(newIntValue); + // Record the value + value = newIntValue; + Update(); + } + + /// + /// Called when the input field's text is changed. + /// + /// The new text. + private void OnTextChanged(GameObject _, string text) { + if (string.IsNullOrWhiteSpace(text)) + // Limits are assumed to allow null, because why not use non-nullable + // otherwise? + value = null; + else if (int.TryParse(text, out int newValue)) { + if (limits != null) + newValue = limits.ClampToRange(newValue); + // Record the valid value + value = newValue; + } + Update(); + } + + /// + /// Updates the displayed value. + /// + protected override void Update() { + var field = textField?.GetComponentInChildren(); + if (field != null) + field.text = FieldText; + if (slider != null && value != null) + PSliderSingle.SetCurrentValue(slider, (int)value); + } + } +} diff --git a/mod/PLibOptions/OptionAttribute.cs b/mod/PLibOptions/OptionAttribute.cs new file mode 100644 index 0000000..1c76131 --- /dev/null +++ b/mod/PLibOptions/OptionAttribute.cs @@ -0,0 +1,80 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; + +namespace PeterHan.PLib.Options { + /// + /// An attribute placed on an option property or enum value for a class used as mod options + /// in order to denote the display title and other options. + /// + /// Options attributes will be recursively searched if a custom type is used for a property + /// with this attribute. If fields in that type have Option attributes, they will be + /// displayed under the category of their parent option (ignoring their own category + /// declaration). + /// + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false, + Inherited = true)] + public sealed class OptionAttribute : Attribute, IOptionSpec { + public string Category { get; } + + public string Format { get; set; } + + public string Title { get; } + + public string Tooltip { get; } + + /// + /// Denotes a mod option field. Can also be used on members of an Enum type to give + /// them a friendly display name. + /// + /// This overload will take the option strings from STRINGS, using the namespace of the + /// declaring type and the name of the property. A type declared in the MyName. + /// MyNamespace namespace with a property named TestProperty will get the title + /// STRINGS.MYNAME.MYNAMESPACE.OPTIONS.TESTPROPERTY.NAME, the tooltip + /// STRINGS.MYNAME.MYNAMESPACE.OPTIONS.TESTPROPERTY.TOOLTIP, and the category + /// STRINGS.MYNAME.MYNAMESPACE.OPTIONS.TESTPROPERTY.CATEGORY. + /// + public OptionAttribute() { + Format = null; + Title = null; + Tooltip = null; + Category = null; + } + + /// + /// Denotes a mod option field. Can also be used on members of an Enum type to give + /// them a friendly display name. + /// + /// The field title to display. + /// The tool tip for the field. + /// The category to use, or null for the default category. + public OptionAttribute(string title, string tooltip = null, string category = null) { + if (string.IsNullOrEmpty(title)) + throw new ArgumentNullException(nameof(title)); + Category = category; + Format = null; + Title = title; + Tooltip = tooltip; + } + + public override string ToString() { + return Title; + } + } +} \ No newline at end of file diff --git a/mod/PLibOptions/OptionsDialog.cs b/mod/PLibOptions/OptionsDialog.cs new file mode 100644 index 0000000..96b7e88 --- /dev/null +++ b/mod/PLibOptions/OptionsDialog.cs @@ -0,0 +1,473 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using PeterHan.PLib.UI; +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using UnityEngine; + +using OptionsList = System.Collections.Generic.ICollection; + +namespace PeterHan.PLib.Options { + /// + /// A dialog for handling mod options events. + /// + internal sealed class OptionsDialog { + /// + /// The color of option category titles. + /// + private static readonly Color CATEGORY_TITLE_COLOR = new Color32(143, 150, 175, 255); + + /// + /// The text style applied to option category titles. + /// + private static readonly TextStyleSetting CATEGORY_TITLE_STYLE; + + /// + /// The margins inside the colored boxes in each config section. + /// + private static readonly int CATEGORY_MARGIN = 8; + + /// + /// The size of the mod preview image displayed. + /// + private static readonly Vector2 MOD_IMAGE_SIZE = new Vector2(192.0f, 192.0f); + + /// + /// The margins between the dialog edge and the colored boxes in each config section. + /// + private static readonly int OUTER_MARGIN = 10; + + /// + /// The default size of the Mod Settings dialog. + /// + private static readonly Vector2 SETTINGS_DIALOG_SIZE = new Vector2(320.0f, 200.0f); + + /// + /// The maximum size of the Mod Settings dialog before it gets scroll bars. + /// + private static readonly Vector2 SETTINGS_DIALOG_MAX_SIZE = new Vector2(800.0f, 600.0f); + + /// + /// The size of the toggle button on each (non-default) config section. + /// + private static readonly Vector2 TOGGLE_SIZE = new Vector2(12.0f, 12.0f); + + static OptionsDialog() { + CATEGORY_TITLE_STYLE = PUITuning.Fonts.UILightStyle.DeriveStyle(newColor: + CATEGORY_TITLE_COLOR, style: TMPro.FontStyles.Bold); + } + + /// + /// Creates an options object using the default constructor if possible. + /// + /// The type of the object to create. + internal static object CreateOptions(Type type) { + object result = null; + try { + var cons = type.GetConstructor(Type.EmptyTypes); + if (cons != null) + result = cons.Invoke(null); + } catch (TargetInvocationException e) { + // Other mod's error + PUtil.LogExcWarn(e.GetBaseException()); + } catch (AmbiguousMatchException e) { + // Other mod's error + PUtil.LogException(e); + } catch (MemberAccessException e) { + // Other mod's error + PUtil.LogException(e); + } + return result; + } + + /// + /// Saves the mod enabled settings and restarts the game. + /// + private static void SaveAndRestart() { +#if OPTIONS_ONLY + POptionsPatches.SaveMods(); +#else + PGameUtils.SaveMods(); +#endif + App.instance.Restart(); + } + + /// + /// If true, all categories begin collapsed. + /// + private readonly bool collapseCategories; + + /// + /// The config file attribute for the options type, if present. + /// + private readonly ConfigFileAttribute configAttr; + + /// + /// The currently active dialog. + /// + private KScreen dialog; + + /// + /// The sprite to display for this mod. + /// + private Sprite modImage; + + /// + /// Collects information from the ModInfoAttribute and KMod.Mod objects for display. + /// + private readonly ModDialogInfo displayInfo; + + /// + /// The event to invoke when the dialog is closed. + /// + public Action OnClose { get; set; } + + /// + /// The option entries in the dialog. + /// + private readonly IDictionary optionCategories; + + /// + /// The options read from the config. It might contain hidden options so preserve its + /// contents here. + /// + private object options; + + /// + /// The type used to determine which options are visible. + /// + private readonly Type optionsType; + + internal OptionsDialog(Type optionsType) { + OnClose = null; + dialog = null; + modImage = null; + this.optionsType = optionsType ?? throw new ArgumentNullException(nameof( + optionsType)); + optionCategories = OptionsEntry.BuildOptions(optionsType); + options = null; + // Determine config location + var infoAttr = optionsType.GetCustomAttribute(); + collapseCategories = infoAttr != null && infoAttr.ForceCollapseCategories; + configAttr = optionsType.GetCustomAttribute(); + displayInfo = new ModDialogInfo(optionsType, infoAttr?.URL, infoAttr?.Image); + } + + /// + /// Adds a category header to the dialog. + /// + /// The parent of the header. + /// The header title. + /// The panel containing the options in this category. + private void AddCategoryHeader(PGridPanel container, string category, + PGridPanel contents) { + contents.AddColumn(new GridColumnSpec(flex: 1.0f)).AddColumn(new GridColumnSpec()); + if (!string.IsNullOrEmpty(category)) { + bool state = !collapseCategories; + var handler = new CategoryExpandHandler(state); + container.AddColumn(new GridColumnSpec()).AddColumn(new GridColumnSpec( + flex: 1.0f)).AddRow(new GridRowSpec()).AddRow(new GridRowSpec(flex: 1.0f)); + // Toggle is upper left, header is upper right + container.AddChild(new PLabel("CategoryHeader") { + Text = OptionsEntry.LookInStrings(category), TextStyle = + CATEGORY_TITLE_STYLE, TextAlignment = TextAnchor.LowerCenter + }.AddOnRealize(handler.OnRealizeHeader), new GridComponentSpec(0, 1) { + Margin = new RectOffset(OUTER_MARGIN, OUTER_MARGIN, 0, 0) + }).AddChild(new PToggle("CategoryToggle") { + Color = PUITuning.Colors.ComponentDarkStyle, InitialState = state, + ToolTip = PLibStrings.TOOLTIP_TOGGLE, Size = TOGGLE_SIZE, + OnStateChanged = handler.OnExpandContract + }.AddOnRealize(handler.OnRealizeToggle), new GridComponentSpec(0, 0)); + contents.OnRealize += handler.OnRealizePanel; + container.AddChild(contents, new GridComponentSpec(1, 0) { ColumnSpan = 2 }); + } else + // Default of unconstrained fills the whole panel + container.AddColumn(new GridColumnSpec(flex: 1.0f)).AddRow(new GridRowSpec( + flex: 1.0f)).AddChild(contents, new GridComponentSpec(0, 0)); + } + + /// + /// Fills in the mod info screen, assuming that infoAttr is non-null. + /// + /// The dialog to populate. + private void AddModInfoScreen(PDialog optionsDialog) { + string image = displayInfo.Image; + var body = optionsDialog.Body; + // Try to load the mod image sprite if possible + if (modImage == null && !string.IsNullOrEmpty(image)) { + string rootDir = PUtil.GetModPath(optionsType.Assembly); + modImage = PUIUtils.LoadSpriteFile(rootDir == null ? image : Path.Combine( + rootDir, image)); + } + var websiteButton = new PButton("ModSite") { + Text = PLibStrings.MOD_HOMEPAGE, ToolTip = PLibStrings.TOOLTIP_HOMEPAGE, + OnClick = VisitModHomepage, Margin = PDialog.BUTTON_MARGIN + }.SetKleiBlueStyle(); + var versionLabel = new PLabel("ModVersion") { + Text = displayInfo.Version, ToolTip = PLibStrings.TOOLTIP_VERSION, + TextStyle = PUITuning.Fonts.UILightStyle, Margin = new RectOffset(0, 0, + OUTER_MARGIN, 0) + }; + // Find mod URL + string modURL = displayInfo.URL; + if (modImage != null) { + // 2 rows and 1 column + if (optionCategories.Count > 0) + body.Direction = PanelDirection.Horizontal; + var infoPanel = new PPanel("ModInfo") { + FlexSize = Vector2.up, Direction = PanelDirection.Vertical, + Alignment = TextAnchor.UpperCenter + }.AddChild(new PLabel("ModImage") { + SpriteSize = MOD_IMAGE_SIZE, TextAlignment = TextAnchor.UpperLeft, + Margin = new RectOffset(0, OUTER_MARGIN, 0, OUTER_MARGIN), + Sprite = modImage + }); + if (!string.IsNullOrEmpty(modURL)) + infoPanel.AddChild(websiteButton); + body.AddChild(infoPanel.AddChild(versionLabel)); + } else { + if (!string.IsNullOrEmpty(modURL)) + body.AddChild(websiteButton); + body.AddChild(versionLabel); + } + } + + /// + /// Checks the mod config class for the [RestartRequired] attribute, and brings up a + /// restart dialog if necessary. + /// + private void CheckForRestart() { + if (options != null && options.GetType().GetCustomAttribute(typeof( + RestartRequiredAttribute)) != null) + // Prompt user to restart + PUIElements.ShowConfirmDialog(null, PLibStrings.RESTART_REQUIRED, + SaveAndRestart, null, PLibStrings.RESTART_OK, PLibStrings.RESTART_CANCEL); + } + + /// + /// Closes the current dialog. + /// + private void CloseDialog() { + if (dialog != null) { + dialog.Deactivate(); + // dialog's game object is destroyed by Deactivate() + dialog = null; + } + if (modImage != null) { + UnityEngine.Object.Destroy(modImage); + modImage = null; + } + } + + /// + /// Fills in the actual mod option fields. + /// + /// The dialog to populate. + private void FillModOptions(PDialog optionsDialog) { + IEnumerable dynamicOptions; + var body = optionsDialog.Body; + var margin = new RectOffset(CATEGORY_MARGIN, CATEGORY_MARGIN, CATEGORY_MARGIN, + CATEGORY_MARGIN); + // For each option, add its UI component to panel + body.Margin = new RectOffset(); + var scrollBody = new PPanel("ScrollContent") { + Spacing = OUTER_MARGIN, Direction = PanelDirection.Vertical, Alignment = + TextAnchor.UpperCenter, FlexSize = Vector2.right + }; + var allOptions = optionCategories; + // Add options from the user's class + if (options is IOptions dynOptions && (dynamicOptions = dynOptions. + CreateOptions()) != null) { + allOptions = new Dictionary(optionCategories); + foreach (var dynamicOption in dynamicOptions) + OptionsEntry.AddToCategory(allOptions, dynamicOption); + } + // Display all categories + foreach (var catEntries in allOptions) { + string category = catEntries.Key; + var optionsList = catEntries.Value; + if (optionsList.Count > 0) { + string name = string.IsNullOrEmpty(category) ? "Default" : category; + int i = 0; + // Not optimal for layout performance, but the panel is needed to have a + // different background color for each category "box" + var container = new PGridPanel("Category_" + name) { + Margin = margin, BackColor = PUITuning.Colors.DialogDarkBackground, + FlexSize = Vector2.right + }; + // Needs to be a separate panel so that it can be collapsed + var contents = new PGridPanel("Entries") { FlexSize = Vector2.right }; + AddCategoryHeader(container, catEntries.Key, contents); + foreach (var entry in optionsList) { + contents.AddRow(new GridRowSpec()); + entry.CreateUIEntry(contents, ref i); + i++; + } + scrollBody.AddChild(container); + } + } + // Manual config and reset button + scrollBody.AddChild(new PPanel("ConfigButtons") { + Spacing = 10, Direction = PanelDirection.Horizontal, Alignment = + TextAnchor.MiddleCenter, FlexSize = Vector2.right + }.AddChild(new PButton("ManualConfig") { + Text = PLibStrings.BUTTON_MANUAL, ToolTip = PLibStrings.TOOLTIP_MANUAL, + OnClick = OnManualConfig, TextAlignment = TextAnchor.MiddleCenter, Margin = + PDialog.BUTTON_MARGIN + }.SetKleiBlueStyle()).AddChild(new PButton("ResetConfig") { + Text = PLibStrings.BUTTON_RESET, ToolTip = PLibStrings.TOOLTIP_RESET, + OnClick = OnResetConfig, TextAlignment = TextAnchor.MiddleCenter, Margin = + PDialog.BUTTON_MARGIN + }.SetKleiBlueStyle())); + body.AddChild(new PScrollPane() { + ScrollHorizontal = false, ScrollVertical = allOptions.Count > 0, + Child = scrollBody, FlexSize = Vector2.right, TrackSize = 8, + AlwaysShowHorizontal = false, AlwaysShowVertical = false + }); + } + + /// + /// Invoked when the manual config button is pressed. + /// + private void OnManualConfig(GameObject _) { + string uri = null, path = POptions.GetConfigFilePath(optionsType); + try { + uri = new Uri(Path.GetDirectoryName(path) ?? path).AbsoluteUri; + } catch (UriFormatException e) { + PUtil.LogWarning("Unable to convert parent of " + path + " to a URI:"); + PUtil.LogExcWarn(e); + } + if (!string.IsNullOrEmpty(uri)) { + // Open the config folder, opening the file itself might start an unknown + // editor which could execute the json somehow... + WriteOptions(); + CloseDialog(); + PUtil.LogDebug("Opening config folder: " + uri); + Application.OpenURL(uri); + CheckForRestart(); + } + } + + /// + /// Invoked when the dialog is closed. + /// + /// The action key taken. + private void OnOptionsSelected(string action) { + switch (action) { + case "ok": + // Save changes to mod options + WriteOptions(); + CheckForRestart(); + break; + case PDialog.DIALOG_KEY_CLOSE: + OnClose?.Invoke(options); + break; + } + } + + /// + /// Invoked when the reset to default button is pressed. + /// + private void OnResetConfig(GameObject _) { + options = CreateOptions(optionsType); + UpdateOptions(); + } + + /// + /// Triggered when the Mod Options button is clicked. + /// + public void ShowDialog() { + string title; + if (string.IsNullOrEmpty(displayInfo.Title)) + title = PLibStrings.BUTTON_OPTIONS; + else + title = string.Format(PLibStrings.DIALOG_TITLE, OptionsEntry.LookInStrings( + displayInfo.Title)); + // Close current dialog if open + CloseDialog(); + // Ensure that it is on top of other screens (which may be +100 modal) + var pDialog = new PDialog("ModOptions") { + Title = title, Size = SETTINGS_DIALOG_SIZE, SortKey = 150.0f, + DialogBackColor = PUITuning.Colors.OptionsBackground, + DialogClosed = OnOptionsSelected, MaxSize = SETTINGS_DIALOG_MAX_SIZE, + RoundToNearestEven = true + }.AddButton("ok", STRINGS.UI.CONFIRMDIALOG.OK, PLibStrings.TOOLTIP_OK, + PUITuning.Colors.ButtonPinkStyle).AddButton(PDialog.DIALOG_KEY_CLOSE, + STRINGS.UI.CONFIRMDIALOG.CANCEL, PLibStrings.TOOLTIP_CANCEL, + PUITuning.Colors.ButtonBlueStyle); + options = POptions.ReadSettings(POptions.GetConfigFilePath(optionsType), + optionsType) ?? CreateOptions(optionsType); + AddModInfoScreen(pDialog); + FillModOptions(pDialog); + // Manually build the dialog so the options can be updated after realization + var obj = pDialog.Build(); + UpdateOptions(); + if (obj.TryGetComponent(out dialog)) + dialog.Activate(); + } + + /// + /// Calls the user OnOptionsChanged handler if present. + /// + /// The updated options object. + private void TriggerUpdateOptions(object newOptions) { + // Call the user handler + if (newOptions is IOptions onChanged) + onChanged.OnOptionsChanged(); + OnClose?.Invoke(newOptions); + } + + /// + /// Updates the dialog with the latest options from the file. + /// + private void UpdateOptions() { + // Read into local options + if (options != null) + foreach (var catEntries in optionCategories) + foreach (var option in catEntries.Value) + option.ReadFrom(options); + } + + /// + /// If configured, opens the mod's home page in the default browser. + /// + private void VisitModHomepage(GameObject _) { + if (!string.IsNullOrWhiteSpace(displayInfo.URL)) + Application.OpenURL(displayInfo.URL); + } + + /// + /// Writes the mod options to its config file. + /// + private void WriteOptions() { + if (options != null) { + // Update from local options + foreach (var catEntries in optionCategories) + foreach (var option in catEntries.Value) + option.WriteTo(options); + POptions.WriteSettings(options, POptions.GetConfigFilePath(optionsType), + configAttr?.IndentOutput ?? false); + TriggerUpdateOptions(options); + } + } + } +} diff --git a/mod/PLibOptions/OptionsEntry.cs b/mod/PLibOptions/OptionsEntry.cs new file mode 100644 index 0000000..4397e12 --- /dev/null +++ b/mod/PLibOptions/OptionsEntry.cs @@ -0,0 +1,383 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.UI; +using System; +using System.Collections.Generic; +using System.Reflection; +using UnityEngine; + +using OptionsList = System.Collections.Generic.ICollection; +using AllOptions = System.Collections.Generic.IDictionary>; +using PeterHan.PLib.Core; + +namespace PeterHan.PLib.Options { + /// + /// An abstract parent class containing methods shared by all built-in options handlers. + /// + public abstract class OptionsEntry : IOptionsEntry, IComparable, + IUIComponent { + private const BindingFlags INSTANCE_PUBLIC = BindingFlags.Public | BindingFlags. + Instance; + + /// + /// The margins around the control used in each entry. + /// + protected static readonly RectOffset CONTROL_MARGIN = new RectOffset(0, 0, 2, 2); + + /// + /// The margins around the label for each entry. + /// + protected static readonly RectOffset LABEL_MARGIN = new RectOffset(0, 5, 2, 2); + + /// + /// Adds an options entry to the category list, creating a new category if necessary. + /// + /// The existing categories. + /// The option entry to add. + internal static void AddToCategory(AllOptions entries, IOptionsEntry entry) { + string category = entry.Category ?? ""; + if (!entries.TryGetValue(category, out OptionsList inCat)) { + inCat = new List(16); + entries.Add(category, inCat); + } + inCat.Add(entry); + } + + /// + /// Builds the options entries from the type. + /// + /// The type of the options class. + /// A list of all public properties annotated for options dialogs. + internal static AllOptions BuildOptions(Type forType) { + var entries = new SortedList(8); + OptionsHandlers.InitPredefinedOptions(); + foreach (var prop in forType.GetProperties(INSTANCE_PUBLIC)) { + // Must have the annotation + var entry = TryCreateEntry(prop, 0); + if (entry != null) + AddToCategory(entries, entry); + } + return entries; + } + + /// + /// Creates a default UI entry. This entry will have the title and tool tip in the + /// first column, and the provided UI component in the second column. Only one row is + /// added by this method. + /// + /// The options entry to be presented. + /// The parent where the components will be added. + /// The row index where the components will be added. + /// The presenter that can display this option's value. + public static void CreateDefaultUIEntry(IOptionsEntry entry, PGridPanel parent, + int row, IUIComponent presenter) { + parent.AddChild(new PLabel("Label") { + Text = LookInStrings(entry.Title), ToolTip = LookInStrings(entry.Tooltip), + TextStyle = PUITuning.Fonts.TextLightStyle + }, new GridComponentSpec(row, 0) { + Margin = LABEL_MARGIN, Alignment = TextAnchor.MiddleLeft + }); + parent.AddChild(presenter, new GridComponentSpec(row, 1) { + Alignment = TextAnchor.MiddleRight, Margin = CONTROL_MARGIN + }); + } + + /// + /// Creates a dynamic options entry. + /// + /// The property to be created. + /// The type which can handle the property. + /// The created entry, or null if no entry could be created. + private static IOptionsEntry CreateDynamicOption(PropertyInfo prop, Type handler) { + IOptionsEntry result = null; + var constructors = handler.GetConstructors(BindingFlags.Public | BindingFlags. + Instance); + string name = prop.Name; + int n = constructors.Length; + for (int i = 0; i < n && result == null; i++) + try { + if (ExecuteConstructor(prop, constructors[i]) is IOptionsEntry entry) + result = entry; + } catch (TargetInvocationException e) { + PUtil.LogError("Unable to create option handler for property " + + name + ":"); + PUtil.LogException(e.GetBaseException()); + } catch (MemberAccessException) { + // Should never happen, filtered to public and instance + } catch (AmbiguousMatchException) { + } catch (TypeLoadException e) { + PUtil.LogError("Unable to instantiate option handler for property " + + name + ":"); + PUtil.LogException(e.GetBaseException()); + } + if (result == null) + PUtil.LogWarning("Unable to create option handler for property " + + name + ", it must have a public constructor"); + return result; + } + + /// + /// Runs a dynamic option constructor. + /// + /// The property to be created. + /// The constructor to run. + /// The constructed dynamic option. + private static object ExecuteConstructor(PropertyInfo prop, ConstructorInfo cons) { + object result; + var parameters = cons.GetParameters(); + int p = parameters.Length; + if (p == 0) + // Public default + result = cons.Invoke(null); + else { + var values = new object[p]; + for (int j = 0; j < p; j++) { + var targetType = parameters[j].ParameterType; + // Custom attribute + if (typeof(Attribute).IsAssignableFrom(targetType)) + values[j] = prop.GetCustomAttribute(targetType); + else if (targetType == typeof(IOptionSpec)) + values[j] = prop.GetCustomAttribute(); + else if (targetType == typeof(string)) + values[j] = prop.Name; + else + PUtil.LogWarning("DynamicOption cannot handle constructor parameter of type " + + targetType.FullName); + } + result = cons.Invoke(values); + } + return result; + } + + /// + /// Substitutes default strings for an options entry with an empty title. + /// + /// The option attribute supplied (Format is still accepted!) + /// The item declaring the attribute. + /// A substitute attribute with default values from STRINGS. + internal static IOptionSpec HandleDefaults(IOptionSpec spec, MemberInfo member) { + // Replace with entries takem from the strings + string prefix = "STRINGS.{0}.OPTIONS.{1}.".F(member.DeclaringType?. + Namespace?.ToUpperInvariant(), member.Name.ToUpperInvariant()); + string category = ""; + if (Strings.TryGet(prefix + "CATEGORY", out var entry)) + category = entry.String; + return new OptionAttribute(prefix + "NAME", prefix + "TOOLTIP", category) { + Format = spec.Format + }; + } + + /// + /// First looks to see if the string exists in the string database; if it does, returns + /// the localized value, otherwise returns the string unmodified. + /// + /// This method is somewhat slow. Cache the result if possible. + /// + /// The string key to check. + /// The string value with that key, or the key if there is no such localized + /// string value. + public static string LookInStrings(string keyOrValue) { + string result = keyOrValue; + if (!string.IsNullOrEmpty(keyOrValue) && Strings.TryGet(keyOrValue, out var + entry)) + result = entry.String; + return result; + } + + /// + /// Shared code to create an options entry if an [Option] attribute is found on a + /// property. + /// + /// The property to inspect. + /// The current depth of iteration to avoid infinite loops. + /// The OptionsEntry created, or null if none was. + internal static IOptionsEntry TryCreateEntry(PropertyInfo prop, int depth) { + IOptionsEntry result = null; + // Must have the annotation, cannot be indexed + var indexes = prop.GetIndexParameters(); + if (indexes.Length < 1) { + var attributes = ListPool.Allocate(); + bool dlcMatch = true; + attributes.AddRange(prop.GetCustomAttributes()); + int n = attributes.Count; + for (int i = 0; i < n; i++) + // Do not create an entry if the DLC does not match + if (attributes[i] is RequireDLCAttribute requireDLC && DlcManager. + IsContentActive(requireDLC.DlcID) != requireDLC.Required) { + dlcMatch = false; + break; + } + if (dlcMatch) + for (int i = 0; i < n; i++) { + result = TryCreateEntry(attributes[i], prop, depth); + if (result != null) + break; + } + attributes.Recycle(); + } + return result; + } + + /// + /// Creates an options entry if an attribute is a valid IOptionSpec or + /// DynamicOptionAttribute. + /// + /// The attribute to parse. + /// The property to inspect. + /// The current depth of iteration to avoid infinite loops. + /// The OptionsEntry created from the attribute, or null if none was. + private static IOptionsEntry TryCreateEntry(Attribute attribute, PropertyInfo prop, + int depth) { + IOptionsEntry result = null; + if (prop == null) + throw new ArgumentNullException(nameof(prop)); + if (attribute is IOptionSpec spec) { + if (string.IsNullOrEmpty(spec.Title)) + spec = HandleDefaults(spec, prop); + // Attempt to find a class that will represent it + var type = prop.PropertyType; + result = OptionsHandlers.FindOptionClass(spec, prop); + // See if it has entries that can themselves be added, ignore + // value types and avoid infinite recursion + if (result == null && !type.IsValueType && depth < 16 && type != + prop.DeclaringType) + result = CompositeOptionsEntry.Create(spec, prop, depth); + } else if (attribute is DynamicOptionAttribute doa && + typeof(IOptionsEntry).IsAssignableFrom(doa.Handler)) + result = CreateDynamicOption(prop, doa.Handler); + return result; + } + + /// + /// The category for this entry. + /// + public string Category { get; } + + /// + /// The option field name. + /// + public string Field { get; } + + /// + /// The format string to use when rendering this option, or null if none was supplied. + /// + public string Format { get; } + + public virtual string Name => nameof(OptionsEntry); + + public event PUIDelegates.OnRealize OnRealize; + + /// + /// The option title on screen. + /// + public string Title { get; protected set; } + + /// + /// The tool tip to display. + /// + public string Tooltip { get; protected set; } + + /// + /// The current value selected by the user. + /// + public abstract object Value { get; set; } + + protected OptionsEntry(string field, IOptionSpec attr) { + if (attr == null) + throw new ArgumentNullException(nameof(attr)); + Field = field; + Format = attr.Format; + Title = attr.Title ?? throw new ArgumentException("attr.Title is null"); + Tooltip = attr.Tooltip; + Category = attr.Category; + } + + public GameObject Build() { + var comp = GetUIComponent(); + OnRealize?.Invoke(comp); + return comp; + } + + public int CompareTo(OptionsEntry other) { + if (other == null) + throw new ArgumentNullException(nameof(other)); + return string.Compare(Category, other.Category, StringComparison. + CurrentCultureIgnoreCase); + } + + /// + /// Adds the line item entry for this options entry. + /// + /// The location to add this entry. + /// The layout row index to use. If updated, the row index will + /// continue to count up from the new value. + public virtual void CreateUIEntry(PGridPanel parent, ref int row) { + CreateDefaultUIEntry(this, parent, row, this); + } + + /// + /// Retrieves the UI component which can alter this setting. It should be sized + /// properly to display any of the valid settings. The actual value will be set after + /// the component is realized. + /// + /// The UI component to display. + public abstract GameObject GetUIComponent(); + + public virtual void ReadFrom(object settings) { + if (Field != null && settings != null) + try { + var prop = settings.GetType().GetProperty(Field, INSTANCE_PUBLIC); + if (prop != null && prop.CanRead) + Value = prop.GetValue(settings, null); + } catch (TargetInvocationException e) { + // Other mod's error + PUtil.LogException(e); + } catch (AmbiguousMatchException e) { + // Other mod's error + PUtil.LogException(e); + } catch (InvalidCastException e) { + // Our error! + PUtil.LogException(e); + } + } + + public override string ToString() { + return "{1}[field={0},title={2}]".F(Field, GetType().Name, Title); + } + + public virtual void WriteTo(object settings) { + if (Field != null && settings != null) + try { + var prop = settings.GetType().GetProperty(Field, INSTANCE_PUBLIC); + if (prop != null && prop.CanWrite) + prop.SetValue(settings, Value, null); + } catch (TargetInvocationException e) { + // Other mod's error + PUtil.LogException(e); + } catch (AmbiguousMatchException e) { + // Other mod's error + PUtil.LogException(e); + } catch (InvalidCastException e) { + // Our error! + PUtil.LogException(e); + } + } + } +} diff --git a/mod/PLibOptions/OptionsHandlers.cs b/mod/PLibOptions/OptionsHandlers.cs new file mode 100644 index 0000000..886d3b8 --- /dev/null +++ b/mod/PLibOptions/OptionsHandlers.cs @@ -0,0 +1,151 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using System.Collections.Generic; +using System.Reflection; +using PeterHan.PLib.Core; +using PeterHan.PLib.Detours; +using UnityEngine; + +namespace PeterHan.PLib.Options { + /// + /// Registers types to options entry classes that can handle them. + /// + public static class OptionsHandlers { + private delegate IOptionsEntry CreateOption(string field, IOptionSpec spec); + + private delegate IOptionsEntry CreateOptionType(string field, IOptionSpec spec, + Type fieldType); + + private delegate IOptionsEntry CreateOptionLimit(string field, IOptionSpec spec, + LimitAttribute limit); + + /// + /// Maps types to the constructor delegate that can create an options entry for them. + /// + private static readonly IDictionary OPTIONS_HANDLERS = + new Dictionary(64); + + /// + /// Adds a custom type to handle all options entries of a specific type. The change + /// will only affect this mod's options. + /// + /// The property type to be handled. + /// The type which will handle all option attributes of + /// this type. It must subclass from IOptionsEntry and have a constructor of the + /// signature HandlerType(string, IOptionSpec). + public static void AddOptionClass(Type optionType, Type handlerType) { + if (optionType != null && handlerType != null && !OPTIONS_HANDLERS. + ContainsKey(optionType) && typeof(IOptionsEntry).IsAssignableFrom( + handlerType)) { + var constructors = handlerType.GetConstructors(); + int n = constructors.Length; + for (int i = 0; i < n; i++) { + var del = CreateDelegate(handlerType, constructors[i]); + if (del != null) { + OPTIONS_HANDLERS[optionType] = del; + break; + } + } + } + } + + /// + /// If a candidate options entry constructor is valid, creates a delegate which can + /// call the constructor. + /// + /// The constructor to wrap. + /// The type which will handle all option attributes of this type. + /// If the constructor can be used to create an options entry, a delegate + /// which calls the constructor using one of the delegate types declared in this class; + /// otherwise, null. + private static Delegate CreateDelegate(Type handlerType, ConstructorInfo constructor) { + var param = constructor.GetParameters(); + int n = param.Length; + Delegate result = null; + // Must begin with string, IOptionsSpec + if (n > 1 && param[0].ParameterType.IsAssignableFrom(typeof(string)) && + param[1].ParameterType.IsAssignableFrom(typeof(IOptionSpec))) { + switch (n) { + case 2: + result = constructor.Detour(); + break; + case 3: + var extraType = param[2].ParameterType; + if (extraType.IsAssignableFrom(typeof(LimitAttribute))) + result = constructor.Detour(); + else if (extraType.IsAssignableFrom(typeof(Type))) + result = constructor.Detour(); + break; + default: + PUtil.LogWarning("Constructor on options handler type " + handlerType + + " cannot be constructed by OptionsHandlers"); + break; + } + } + return result; + } + + /// + /// Creates an options entry wrapper for the specified property. + /// + /// The property to wrap. + /// The option title and tool tip. + /// An options wrapper, or null if none can handle this type. + public static IOptionsEntry FindOptionClass(IOptionSpec spec, PropertyInfo info) { + IOptionsEntry entry = null; + if (spec != null && info != null) { + var type = info.PropertyType; + string field = info.Name; + if (type.IsEnum) + // Enumeration type + entry = new SelectOneOptionsEntry(field, spec, type); + else if (OPTIONS_HANDLERS.TryGetValue(type, out var handler)) { + if (handler is CreateOption createOption) + entry = createOption.Invoke(field, spec); + else if (handler is CreateOptionLimit createOptionLimit) + entry = createOptionLimit.Invoke(field, spec, info. + GetCustomAttribute()); + else if (handler is CreateOptionType createOptionType) + entry = createOptionType.Invoke(field, spec, type); + } + } + return entry; + } + + /// + /// Adds the predefined options classes. + /// + internal static void InitPredefinedOptions() { + if (OPTIONS_HANDLERS.Count < 1) { + AddOptionClass(typeof(bool), typeof(CheckboxOptionsEntry)); + AddOptionClass(typeof(int), typeof(IntOptionsEntry)); + AddOptionClass(typeof(int?), typeof(NullableIntOptionsEntry)); + AddOptionClass(typeof(float), typeof(FloatOptionsEntry)); + AddOptionClass(typeof(float?), typeof(NullableFloatOptionsEntry)); + AddOptionClass(typeof(Color32), typeof(Color32OptionsEntry)); + AddOptionClass(typeof(Color), typeof(ColorOptionsEntry)); + AddOptionClass(typeof(int), typeof(IntOptionsEntry)); + AddOptionClass(typeof(Action), typeof(ButtonOptionsEntry)); + AddOptionClass(typeof(int), typeof(IntOptionsEntry)); + AddOptionClass(typeof(LocText), typeof(TextBlockOptionsEntry)); + } + } + } +} diff --git a/mod/PLibOptions/PLibOptions.csproj b/mod/PLibOptions/PLibOptions.csproj new file mode 100644 index 0000000..05a5db6 --- /dev/null +++ b/mod/PLibOptions/PLibOptions.csproj @@ -0,0 +1,21 @@ + + + + PLib Options + PLib.Options + 4.11.0.0 + false + PeterHan.PLib.Options + 4.11.0.0 + false + true + Vanilla;Mergedown + + + bin\$(Platform)\Release\PLibOptions.xml + + + + + + diff --git a/mod/PLibOptions/POptions.cs b/mod/PLibOptions/POptions.cs new file mode 100644 index 0000000..6ca7d12 --- /dev/null +++ b/mod/PLibOptions/POptions.cs @@ -0,0 +1,405 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using HarmonyLib; +using Newtonsoft.Json; +using PeterHan.PLib.Core; +using PeterHan.PLib.UI; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Reflection; +using UnityEngine; + +namespace PeterHan.PLib.Options { + /// + /// Adds an "Options" screen to a mod in the Mods menu. + /// + public sealed class POptions : PForwardedComponent { + /// + /// The configuration file name used by default for classes that do not specify + /// otherwise. This file name is case sensitive. + /// + public const string CONFIG_FILE_NAME = "config.json"; + + /// + /// The maximum nested class depth which will be serialized in mod options to avoid + /// infinite loops. + /// + public const int MAX_SERIALIZATION_DEPTH = 8; + + /// + /// The margins around the Options button. + /// + internal static readonly RectOffset OPTION_BUTTON_MARGIN = new RectOffset(11, 11, 5, 5); + + /// + /// The shared mod configuration folder, which works between archived versions and + /// local/dev/Steam. + /// + public const string SHARED_CONFIG_FOLDER = "config"; + + /// + /// The version of this component. Uses the running PLib version. + /// + internal static readonly Version VERSION = new Version(PVersion.VERSION); + + /// + /// The instantiated copy of this class. + /// + internal static POptions Instance { get; private set; } + + /// + /// Applied to ModsScreen if mod options are registered, after BuildDisplay runs. + /// + private static void BuildDisplay_Postfix(GameObject ___entryPrefab, + System.Collections.IEnumerable ___displayedMods) { + if (Instance != null) { + int index = 0; + // Harmony does not check the type at all on accessing private fields with ___ + foreach (var displayedMod in ___displayedMods) + Instance.AddModOptions(displayedMod, index++, ___entryPrefab); + } + } + + /// + /// Retrieves the configuration file path used by PLib Options for a specified type. + /// + /// The options type stored in the config file. + /// The path to the configuration file that will be used by PLib for that + /// mod's config. + public static string GetConfigFilePath(Type optionsType) { + return GetConfigPath(optionsType.GetCustomAttribute(), + optionsType.Assembly); + } + + /// + /// Attempts to find the mod which owns the specified type. + /// + /// The type to look up. + /// The Mod that owns it, or null if no owning mod could be found, such as for + /// types in System or Assembly-CSharp. + internal static KMod.Mod GetModFromType(Type optionsType) { + if (optionsType == null) + throw new ArgumentNullException(nameof(optionsType)); + var sd = PRegistry.Instance.GetSharedData(typeof(POptions).FullName); + // Look up mod in the shared data + if (!(sd is IDictionary lookup) || !lookup.TryGetValue( + optionsType.Assembly, out KMod.Mod result)) + result = null; + return result; + } + + /// + /// Retrieves the configuration file path used by PLib Options for a specified type. + /// + /// The config file attribute for that type. + /// The assembly to use for determining the path. + /// The path to the configuration file that will be used by PLib for that + /// mod's config. + private static string GetConfigPath(ConfigFileAttribute attr, Assembly modAssembly) { + string path, name = modAssembly.GetNameSafe(); + path = (name != null && (attr?.UseSharedConfigLocation == true)) ? + Path.Combine(KMod.Manager.GetDirectory(), SHARED_CONFIG_FOLDER, name) : + PUtil.GetModPath(modAssembly); + return Path.Combine(path, attr?.ConfigFileName ?? CONFIG_FILE_NAME); + } + + /// + /// Reads a mod's settings from its configuration file. The assembly defining T is used + /// to resolve the proper settings folder. + /// + /// The type of the settings object. + /// The settings read, or null if they could not be read (e.g. newly installed). + public static T ReadSettings() where T : class { + var type = typeof(T); + return ReadSettings(GetConfigPath(type.GetCustomAttribute(), + type.Assembly), type) as T; + } + + /// + /// Reads a mod's settings from its configuration file. + /// + /// The path to the settings file. + /// The options type. + /// The settings read, or null if they could not be read (e.g. newly installed) + internal static object ReadSettings(string path, Type optionsType) { + object options = null; + try { + using (var jr = new JsonTextReader(File.OpenText(path))) { + var serializer = new JsonSerializer { MaxDepth = MAX_SERIALIZATION_DEPTH }; + // Deserialize from stream avoids reading file text into memory + options = serializer.Deserialize(jr, optionsType); + } + } catch (FileNotFoundException) { +#if DEBUG + PUtil.LogDebug("{0} was not found; using default settings".F(Path.GetFileName( + path))); +#endif + } catch (UnauthorizedAccessException e) { + // Options will be set to defaults + PUtil.LogExcWarn(e); + } catch (IOException e) { + // Again set defaults + PUtil.LogExcWarn(e); + } catch (JsonException e) { + // Again set defaults + PUtil.LogExcWarn(e); + } + return options; + } + + /// + /// Shows a mod options dialog now, as if Options was used inside the Mods menu. + /// + /// The type of the options to show. The mod to configure, + /// configuration directory, and so forth will be retrieved from the provided type. + /// This type must be the same type configured in RegisterOptions for the mod. + /// The method to call when the dialog is closed. + public static void ShowDialog(Type optionsType, Action onClose = null) { + var args = new OpenDialogArgs(optionsType, onClose); + var allOptions = PRegistry.Instance.GetAllComponents(typeof(POptions).FullName); + if (allOptions != null) + foreach (var mod in allOptions) + mod?.Process(0, args); + } + + /// + /// Writes a mod's settings to its configuration file. The assembly defining T is used + /// to resolve the proper settings folder. + /// + /// The type of the settings object. + /// The settings to write. + public static void WriteSettings(T settings) where T : class { + var attr = typeof(T).GetCustomAttribute(); + WriteSettings(settings, GetConfigPath(attr, typeof(T).Assembly), attr?. + IndentOutput ?? false); + } + + /// + /// Writes a mod's settings to its configuration file. + /// + /// The settings to write. + /// The path to the settings file. + /// true to indent the output, or false to leave it in one line. + internal static void WriteSettings(object settings, string path, bool indent = false) { + if (settings != null) + try { + // SharedConfigLocation + Directory.CreateDirectory(Path.GetDirectoryName(path)); + using (var jw = new JsonTextWriter(File.CreateText(path))) { + var serializer = new JsonSerializer { + MaxDepth = MAX_SERIALIZATION_DEPTH + }; + serializer.Formatting = indent ? Formatting.Indented : Formatting.None; + // Serialize from stream avoids creating file text in memory + serializer.Serialize(jw, settings); + } + } catch (UnauthorizedAccessException e) { + // Options cannot be set + PUtil.LogExcWarn(e); + } catch (IOException e) { + // Options cannot be set + PUtil.LogExcWarn(e); + } catch (JsonException e) { + // Options cannot be set + PUtil.LogExcWarn(e); + } + } + + /// + /// Maps mod static IDs to their options. + /// + private readonly IDictionary modOptions; + + /// + /// Maps mod assemblies to handlers that can fire their options. Only populated in + /// the instantiated copy of POptions. + /// + private readonly IDictionary registered; + + public override Version Version => VERSION; + + public POptions() { + modOptions = new Dictionary(8); + registered = new Dictionary(32); + InstanceData = modOptions; + } + + /// + /// Adds the Options button to the Mods screen. + /// + /// The mod entry where the button should be added. + /// The index to use if it cannot be determined from the entry. + /// The parent where the entries were added, used only if the + /// fallback index is required. + private void AddModOptions(object modEntry, int fallbackIndex, GameObject parent) { + var mods = Global.Instance.modManager?.mods; + if (!PPatchTools.TryGetFieldValue(modEntry, "mod_index", out int index)) + index = fallbackIndex; + if (!PPatchTools.TryGetFieldValue(modEntry, "rect_transform", out Transform + transform)) + transform = parent.transform.GetChild(index); + if (mods != null && index >= 0 && index < mods.Count && transform != null) { + var modSpec = mods[index]; + string label = modSpec.staticID; + if (modSpec.IsEnabledForActiveDlc() && registered.TryGetValue(label, + out ModOptionsHandler handler)) { +#if DEBUG + PUtil.LogDebug("Adding options for mod: {0}".F(modSpec.staticID)); +#endif + // Create delegate to open settings dialog + new PButton("ModSettingsButton") { + FlexSize = Vector2.up, OnClick = handler.ShowDialog, + ToolTip = PLibStrings.DIALOG_TITLE.text.F(modSpec.title), Text = + CultureInfo.CurrentCulture.TextInfo.ToTitleCase(PLibStrings. + BUTTON_OPTIONS.text.ToLower()), Margin = OPTION_BUTTON_MARGIN + // Move before the subscription and enable button + }.SetKleiPinkStyle().AddTo(transform.gameObject, 4); + } + } + } + + /// + /// Initializes and stores the options table for quicker lookups later. + /// + public override void Initialize(Harmony plibInstance) { + Instance = this; + + registered.Clear(); + SetSharedData(PUtil.CreateAssemblyToModTable()); + foreach (var optionsProvider in PRegistry.Instance.GetAllComponents(ID)) { + var options = optionsProvider.GetInstanceData>(); + if (options != null) + // Map the static ID to the mod's option type and fire the correct handler + foreach (var pair in options) { + string label = pair.Key; + if (registered.ContainsKey(label)) + PUtil.LogWarning("Mod {0} already has options registered - only one option type per mod". + F(label ?? "?")); + else + registered.Add(label, new ModOptionsHandler(optionsProvider, + pair.Value)); + } + } + + plibInstance.Patch(typeof(ModsScreen), "BuildDisplay", postfix: PatchMethod(nameof( + BuildDisplay_Postfix))); + } + + public override void Process(uint operation, object args) { + // POptions is no longer forwarded, show the dialog from the assembly that has the + // options type - ignore calls that are not for our mod + if (operation == 0 && PPatchTools.TryGetPropertyValue(args, nameof(OpenDialogArgs. + OptionsType), out Type forType)) { + foreach (var pair in modOptions) + // Linear search is not phenomenal, but there is usually only one options + // type per instance + if (pair.Value == forType) { + var dialog = new OptionsDialog(forType); + if (PPatchTools.TryGetPropertyValue(args, nameof(OpenDialogArgs. + OnClose), out Action handler)) + dialog.OnClose = handler; + dialog.ShowDialog(); + break; + } + } + } + + /// + /// Registers a class as a mod options class. The type is registered for the mod + /// instance specified, which is easily available in OnLoad. + /// + /// The mod for which the type will be registered. + /// The class which will represent the options for this mod. + public void RegisterOptions(KMod.UserMod2 mod, Type optionsType) { + var kmod = mod?.mod; + if (optionsType == null) + throw new ArgumentNullException(nameof(optionsType)); + if (kmod == null) + throw new ArgumentNullException(nameof(mod)); + RegisterForForwarding(); + string id = kmod.staticID; + if (modOptions.TryGetValue(id, out Type curType)) + PUtil.LogWarning("Mod {0} already has options type {1}".F(id, curType. + FullName)); + else { + modOptions.Add(id, optionsType); + PUtil.LogDebug("Registered mod options class {0} for {1}".F(optionsType. + FullName, id)); + } + } + + /// + /// Opens the mod options dialog for a specific mod assembly. + /// + private sealed class ModOptionsHandler { + /// + /// The type whose options will be shown. + /// + private readonly Type forType; + + /// + /// The options instance that will handle the dialog. + /// + private readonly PForwardedComponent options; + + internal ModOptionsHandler(PForwardedComponent options, Type forType) { + this.forType = forType ?? throw new ArgumentNullException(nameof(forType)); + this.options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Shows the options dialog. + /// + internal void ShowDialog(GameObject _) { + options.Process(0, new OpenDialogArgs(forType, null)); + } + + public override string ToString() { + return "ModOptionsHandler[Type={0}]".F(forType); + } + } + + /// + /// The arguments to be passed with message SHOW_DIALOG_MOD. + /// + private sealed class OpenDialogArgs { + /// + /// The handler (if not null) to be called when the dialog is closed. + /// + public Action OnClose { get; } + + /// + /// The mod options type to show. + /// + public Type OptionsType { get; } + + public OpenDialogArgs(Type optionsType, Action onClose) { + OnClose = onClose; + OptionsType = optionsType ?? throw new ArgumentNullException( + nameof(optionsType)); + } + + public override string ToString() { + return "OpenDialogArgs[Type={0}]".F(OptionsType); + } + } + } +} diff --git a/mod/PLibOptions/RequireDLCAttribute.cs b/mod/PLibOptions/RequireDLCAttribute.cs new file mode 100644 index 0000000..9b8da5d --- /dev/null +++ b/mod/PLibOptions/RequireDLCAttribute.cs @@ -0,0 +1,68 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System; + +namespace PeterHan.PLib.Options { + /// + /// An attribute placed on an option property for a class used as mod options in order to + /// show or hide it for particular DLCs. If the option is hidden, the value currently + /// in the options file is preserved unchanged when reading or writing. + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = true, Inherited = true)] + public sealed class RequireDLCAttribute : Attribute { + /// + /// The DLC ID to check. + /// + public string DlcID { get; } + + /// + /// If true, the DLC is required, and the option is hidden if the DLC is inactive. + /// If false, the DLC is forbidden, and the option is hidden if the DLC is active. + /// + public bool Required { get; } + + /// + /// Annotates an option field as requiring the specified DLC. The [Option] attribute + /// must also be present to be displayed at all. + /// + /// The DLC ID to require. Must be one of: + /// DlcManager.EXPANSION1_ID, DlcManager.VANILLA_ID + public RequireDLCAttribute(string dlcID) { + DlcID = dlcID; + Required = true; + } + + /// + /// Annotates an option field as requiring or forbidding the specified DLC. The + /// [Option] attribute must also be present to be displayed at all. + /// + /// The DLC ID to require or forbid. Must be one of: + /// DlcManager.EXPANSION1_ID, DlcManager.VANILLA_ID + /// true to require the DLC, or false to forbid it. + public RequireDLCAttribute(string dlcID, bool required) { + DlcID = dlcID ?? ""; + Required = required; + } + + public override string ToString() { + return "RequireDLC[DLC={0},require={1}]".F(DlcID, Required); + } + } +} diff --git a/mod/PLibOptions/RestartRequiredAttribute.cs b/mod/PLibOptions/RestartRequiredAttribute.cs new file mode 100644 index 0000000..b06a02d --- /dev/null +++ b/mod/PLibOptions/RestartRequiredAttribute.cs @@ -0,0 +1,30 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; + +namespace PeterHan.PLib.Options { + /// + /// An empty marker attribute. If applied to an options class, PLib will notify the user + /// that the game must be restarted to apply the options. This attribute will not work if + /// it is applied to an individual option, only if applied to the class as a whole. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public sealed class RestartRequiredAttribute : Attribute { + } +} diff --git a/mod/PLibOptions/SelectOneOptionsEntry.cs b/mod/PLibOptions/SelectOneOptionsEntry.cs new file mode 100644 index 0000000..d2a96ae --- /dev/null +++ b/mod/PLibOptions/SelectOneOptionsEntry.cs @@ -0,0 +1,179 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using System.Collections.Generic; +using System.Reflection; +using PeterHan.PLib.UI; +using UnityEngine; + +namespace PeterHan.PLib.Options { + /// + /// An options entry which represents Enum and displays a spinner with text options. + /// + public class SelectOneOptionsEntry : OptionsEntry { + /// + /// Obtains the title and tool tip for an enumeration value. + /// + /// The value in the enumeration. + /// The type of the Enum field. + /// The matching Option + private static EnumOption GetAttribute(object enumValue, Type fieldType) { + if (enumValue == null) + throw new ArgumentNullException(nameof(enumValue)); + string valueName = enumValue.ToString(), title = valueName, tooltip = ""; + foreach (var enumField in fieldType.GetMember(valueName, BindingFlags.Public | + BindingFlags.Static)) + if (enumField.DeclaringType == fieldType) { + // Search for OptionsAttribute + foreach (var attrib in enumField.GetCustomAttributes(false)) + if (attrib is IOptionSpec spec) { + if (string.IsNullOrEmpty(spec.Title)) + spec = HandleDefaults(spec, enumField); + title = LookInStrings(spec.Title); + tooltip = LookInStrings(spec.Tooltip); + break; + } + break; + } + return new EnumOption(title, tooltip, enumValue); + } + + public override object Value { + get { + return chosen?.Value; + } + set { + // Find a matching value, if possible + string valueStr = value?.ToString() ?? ""; + foreach (var option in options) + if (valueStr == option.Value.ToString()) { + chosen = option; + Update(); + break; + } + } + } + + /// + /// The chosen item in the array. + /// + protected EnumOption chosen; + + /// + /// The realized item label. + /// + private GameObject comboBox; + + /// + /// The available options to cycle through. + /// + protected readonly IList options; + + public SelectOneOptionsEntry(string field, IOptionSpec spec, Type fieldType) : + base(field, spec) { + var eval = Enum.GetValues(fieldType); + if (eval == null) + throw new ArgumentException("No values, or invalid values, for enum"); + int n = eval.Length; + if (n == 0) + throw new ArgumentException("Enum has no declared members"); + chosen = null; + comboBox = null; + options = new List(n); + for (int i = 0; i < n; i++) + options.Add(GetAttribute(eval.GetValue(i), fieldType)); + } + + public override GameObject GetUIComponent() { + // Find largest option to size the label appropriately, using em width this time! + EnumOption firstOption = null; + int maxLen = 0; + foreach (var option in options) { + int len = option.Title?.Trim()?.Length ?? 0; + // Kerning each string is slow, so estimate based on em width instead + if (firstOption == null && len > 0) + firstOption = option; + if (len > maxLen) + maxLen = len; + } + comboBox = new PComboBox("Select") { + BackColor = PUITuning.Colors.ButtonPinkStyle, InitialItem = firstOption, + Content = options, EntryColor = PUITuning.Colors.ButtonBlueStyle, + TextStyle = PUITuning.Fonts.TextLightStyle, OnOptionSelected = UpdateValue, + }.SetMinWidthInCharacters(maxLen).Build(); + Update(); + return comboBox; + } + + /// + /// Updates the displayed text to match the current item. + /// + private void Update() { + if (comboBox != null && chosen != null) + PComboBox.SetSelectedItem(comboBox, chosen, false); + } + + /// + /// Triggered when the value chosen from the combo box has been changed. + /// + /// The value selected by the user. + private void UpdateValue(GameObject _, EnumOption selected) { + if (selected != null) + chosen = selected; + } + + /// + /// Represents a selectable option. + /// + protected sealed class EnumOption : ITooltipListableOption { + /// + /// The option title. + /// + public string Title { get; } + + /// + /// The option tool tip. + /// + public string ToolTip { get; } + + /// + /// The value to assign if this option is chosen. + /// + public object Value { get; } + + public EnumOption(string title, string toolTip, object value) { + Title = title ?? throw new ArgumentNullException(nameof(title)); + ToolTip = toolTip; + Value = value; + } + + public string GetProperName() { + return Title; + } + + public string GetToolTipText() { + return ToolTip; + } + + public override string ToString() { + return string.Format("Option[Title={0},Value={1}]", Title, Value); + } + } + } +} diff --git a/mod/PLibOptions/SingletonOptions.cs b/mod/PLibOptions/SingletonOptions.cs new file mode 100644 index 0000000..867bc41 --- /dev/null +++ b/mod/PLibOptions/SingletonOptions.cs @@ -0,0 +1,52 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +namespace PeterHan.PLib.Options { + /// + /// A class which can be used by mods to maintain a singleton of their options. This + /// class should be the superclass of the mod options class, and <T> should be + /// the type of the options class to store. + /// + /// This class only initializes the mod options once by default. If the settings can + /// be updated without restarting the game, update the Instance manually using + /// IOptions.OnOptionsChanged. If the game has to be restarted anyways, add + /// [RestartRequired]. + /// + /// The mod options class to wrap. + public abstract class SingletonOptions where T : class, new() { + /// + /// The only instance of the singleton options. + /// + protected static T instance; + + /// + /// Retrieves the program options, or lazily initializes them if not yet loaded. + /// + public static T Instance { + get { + if (instance == null) + instance = POptions.ReadSettings() ?? new T(); + return instance; + } + protected set { + if (value != null) + instance = value; + } + } + } +} diff --git a/mod/PLibOptions/SlidingBaseOptionsEntry.cs b/mod/PLibOptions/SlidingBaseOptionsEntry.cs new file mode 100644 index 0000000..99844c4 --- /dev/null +++ b/mod/PLibOptions/SlidingBaseOptionsEntry.cs @@ -0,0 +1,105 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.UI; +using UnityEngine; + +namespace PeterHan.PLib.Options { + /// + /// An options entry which displays a slider below it. + /// + public abstract class SlidingBaseOptionsEntry : OptionsEntry { + /// + /// The margin between the slider extra row and the rest of the dialog. + /// + internal static readonly RectOffset ENTRY_MARGIN = new RectOffset(15, 0, 0, 5); + + /// + /// The margin between the slider and its labels. + /// + internal static readonly RectOffset SLIDER_MARGIN = new RectOffset(10, 10, 0, 0); + + /// + /// The limits allowed for the entry. + /// + protected readonly LimitAttribute limits; + + /// + /// The realized slider. + /// + protected GameObject slider; + + protected SlidingBaseOptionsEntry(string field, IOptionSpec spec, + LimitAttribute limit = null) : base(field, spec) { + limits = limit; + slider = null; + } + + public override void CreateUIEntry(PGridPanel parent, ref int row) { + double minLimit, maxLimit; + base.CreateUIEntry(parent, ref row); + if (limits != null && (minLimit = limits.Minimum) > float.MinValue && (maxLimit = + limits.Maximum) < float.MaxValue && maxLimit > minLimit) { + // NaN will be false on either comparison + var slider = GetSlider().AddOnRealize(OnRealizeSlider); + // Min and max labels + var minLabel = new PLabel("MinValue") { + TextStyle = PUITuning.Fonts.TextLightStyle, Text = minLimit. + ToString(Format ?? "G3"), TextAlignment = TextAnchor.MiddleRight + }; + var maxLabel = new PLabel("MaxValue") { + TextStyle = PUITuning.Fonts.TextLightStyle, Text = maxLimit. + ToString(Format ?? "G3"), TextAlignment = TextAnchor.MiddleLeft + }; + // Lay out left to right + var panel = new PRelativePanel("Slider Grid") { + FlexSize = Vector2.right, DynamicSize = false + }.AddChild(slider).AddChild(minLabel).AddChild(maxLabel).AnchorYAxis(slider). + AnchorYAxis(minLabel, 0.5f).AnchorYAxis(maxLabel, 0.5f).SetLeftEdge( + minLabel, fraction: 0.0f).SetRightEdge(maxLabel, fraction: 1.0f). + SetLeftEdge(slider, toRight: minLabel).SetRightEdge(slider, toLeft: + maxLabel).SetMargin(slider, SLIDER_MARGIN); + // Add another row for the slider + parent.AddRow(new GridRowSpec()); + parent.AddChild(panel, new GridComponentSpec(++row, 0) { + ColumnSpan = 2, Margin = ENTRY_MARGIN + }); + } + } + + /// + /// Gets the initialized PLib slider to be used for value display. + /// + /// The slider to be used. + protected abstract PSliderSingle GetSlider(); + + /// + /// Called when the slider is realized. + /// + /// The actual slider. + protected void OnRealizeSlider(GameObject realized) { + slider = realized; + Update(); + } + + /// + /// Updates the displayed value. + /// + protected abstract void Update(); + } +} diff --git a/mod/PLibOptions/StringOptionsEntry.cs b/mod/PLibOptions/StringOptionsEntry.cs new file mode 100644 index 0000000..372d886 --- /dev/null +++ b/mod/PLibOptions/StringOptionsEntry.cs @@ -0,0 +1,94 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.UI; +using System; +using TMPro; +using UnityEngine; + +namespace PeterHan.PLib.Options { + /// + /// An options entry which represents a string and displays a text field. + /// + public class StringOptionsEntry : OptionsEntry { + public override object Value { + get { + return value; + } + set { + this.value = (value == null) ? "" : value.ToString(); + Update(); + } + } + + /// + /// The maximum entry length. + /// + private readonly int maxLength; + + /// + /// The realized text field. + /// + private GameObject textField; + + /// + /// The value in the text field. + /// + private string value; + + public StringOptionsEntry(string field, IOptionSpec spec, + LimitAttribute limit = null) : base(field, spec) { + // Use the maximum limit value for the max length, if present + if (limit != null) + maxLength = Math.Max(2, (int)Math.Round(limit.Maximum)); + else + maxLength = 256; + textField = null; + value = ""; + } + + public override GameObject GetUIComponent() { + textField = new PTextField() { + OnTextChanged = OnTextChanged, ToolTip = LookInStrings(Tooltip), + Text = value.ToString(), MinWidth = 128, Type = PTextField.FieldType.Text, + MaxLength = maxLength + }.Build(); + Update(); + return textField; + } + + /// + /// Called when the input field's text is changed. + /// + /// The new text. + private void OnTextChanged(GameObject _, string text) { + value = text; + Update(); + } + + /// + /// Updates the displayed value. + /// + private void Update() { + TMP_InputField field; + if (textField != null && (field = textField. + GetComponentInChildren()) != null) + field.text = value; + } + } +} diff --git a/mod/PLibOptions/TextBlockOptionsEntry.cs b/mod/PLibOptions/TextBlockOptionsEntry.cs new file mode 100644 index 0000000..3ed00a8 --- /dev/null +++ b/mod/PLibOptions/TextBlockOptionsEntry.cs @@ -0,0 +1,76 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.UI; +using UnityEngine; + +namespace PeterHan.PLib.Options { + /// + /// An options entry that displays static text. Not intended to be serializable to the + /// options file, instead declare a read-only property that returns null with a type of + /// LocText, e.g: + /// + /// [Option("Your text goes here", "Tool tip for the text")] + /// public LocText MyLabel => null; + /// + /// Unity font formatting can be used in the text. The name of a strings table entry can + /// also be used to allow localization. + /// + public class TextBlockOptionsEntry : OptionsEntry { + /// + /// A font style that looks like TextLightStyle but allows word wrapping. + /// + private static readonly TextStyleSetting WRAP_TEXT_STYLE; + + static TextBlockOptionsEntry() { + WRAP_TEXT_STYLE = PUITuning.Fonts.TextLightStyle.DeriveStyle(); + WRAP_TEXT_STYLE.enableWordWrapping = true; + } + + public override object Value { + get { + return ignore; + } + set { + if (value is LocText newValue) + ignore = newValue; + } + } + + /// + /// This value is not used, it only exists to satisfy the contract. + /// + private LocText ignore; + + public TextBlockOptionsEntry(string field, IOptionSpec spec) : base(field, spec) { } + + public override void CreateUIEntry(PGridPanel parent, ref int row) { + parent.AddChild(new PLabel(Field) { + Text = LookInStrings(Title), ToolTip = LookInStrings(Tooltip), + TextStyle = WRAP_TEXT_STYLE + }, new GridComponentSpec(row, 0) { + Margin = CONTROL_MARGIN, Alignment = TextAnchor.MiddleCenter, ColumnSpan = 2 + }); + } + + public override GameObject GetUIComponent() { + // Will not be invoked + return new GameObject("Empty"); + } + } +} diff --git a/mod/PLibUI/IDynamicSizable.cs b/mod/PLibUI/IDynamicSizable.cs new file mode 100644 index 0000000..77a0fa0 --- /dev/null +++ b/mod/PLibUI/IDynamicSizable.cs @@ -0,0 +1,32 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +namespace PeterHan.PLib.UI { + /// + /// A UI component which can be dynamically resized for its content. + /// + public interface IDynamicSizable : IUIComponent { + /// + /// Whether the component should dynamically resize for its content. This adds more + /// components and more layout depth, so should only be enabled if necessary. + /// + /// Defaults to false. Must be set to true for components with a nonzero flex size. + /// + bool DynamicSize { get; set; } + } +} diff --git a/mod/PLibUI/ISettableFlexSize.cs b/mod/PLibUI/ISettableFlexSize.cs new file mode 100644 index 0000000..bb6cb69 --- /dev/null +++ b/mod/PLibUI/ISettableFlexSize.cs @@ -0,0 +1,36 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// Describes a UI component whose flexible size can be mutated. + /// + internal interface ISettableFlexSize : ILayoutGroup { + /// + /// The flexible width of the completed layout group can be set. + /// + float flexibleWidth { get; set; } + + /// + /// The flexible height of the completed layout group can be set. + /// + float flexibleHeight { get; set; } + } +} diff --git a/mod/PLibUI/ITooltipListableOption.cs b/mod/PLibUI/ITooltipListableOption.cs new file mode 100644 index 0000000..22009e5 --- /dev/null +++ b/mod/PLibUI/ITooltipListableOption.cs @@ -0,0 +1,27 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +namespace PeterHan.PLib.UI { + public interface ITooltipListableOption : IListableOption { + /// + /// Retrieves the tool tip text for this option. + /// + /// The text to be shown on the tool tip. + string GetToolTipText(); + } +} diff --git a/mod/PLibUI/IUIComponent.cs b/mod/PLibUI/IUIComponent.cs new file mode 100644 index 0000000..97cb7b9 --- /dev/null +++ b/mod/PLibUI/IUIComponent.cs @@ -0,0 +1,43 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using UnityEngine; + +namespace PeterHan.PLib.UI { + /// + /// Implemented by PLib UI components. + /// + public interface IUIComponent { + /// + /// The component name. + /// + string Name { get; } + + /// + /// Creates a physical game object embodying this component. + /// + /// The game object representing this UI component. Multiple invocations return + /// unique objects. + GameObject Build(); + + /// + /// Actions invoked when the UI component is actually realized. + /// + event PUIDelegates.OnRealize OnRealize; + } +} diff --git a/mod/PLibUI/ImageTransform.cs b/mod/PLibUI/ImageTransform.cs new file mode 100644 index 0000000..8a9c051 --- /dev/null +++ b/mod/PLibUI/ImageTransform.cs @@ -0,0 +1,36 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; + +namespace PeterHan.PLib.UI { + /// + /// An enumeration describing how to transform the image in a label. + /// + /// Rotations are counterclockwise from 0 (straight up). + /// + [Flags] + public enum ImageTransform : uint { + None = 0, + FlipHorizontal = 1, + FlipVertical = 2, + Rotate90 = 4, + Rotate180 = 8, + Rotate270 = Rotate90 | Rotate180 + } +} diff --git a/mod/PLibUI/LayoutSizes.cs b/mod/PLibUI/LayoutSizes.cs new file mode 100644 index 0000000..ea6f378 --- /dev/null +++ b/mod/PLibUI/LayoutSizes.cs @@ -0,0 +1,88 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using UnityEngine; + +namespace PeterHan.PLib.UI { + /// + /// A class representing the size sets of a particular component. + /// + internal struct LayoutSizes { + /// + /// The flexible dimension value. + /// + public float flexible; + + /// + /// If true, this component should be ignored completely. + /// + public bool ignore; + + /// + /// The minimum dimension value. + /// + public float min; + + /// + /// The preferred dimension value. + /// + public float preferred; + + /// + /// The source of these values. + /// + public readonly GameObject source; + + internal LayoutSizes(GameObject source) : this(source, 0.0f, 0.0f, 0.0f) { } + + internal LayoutSizes(GameObject source, float min, float preferred, + float flexible) { + ignore = false; + this.source = source; + this.flexible = flexible; + this.min = min; + this.preferred = preferred; + } + + /// + /// Adds another set of layout sizes to this one. + /// + /// The size values to add. + public void Add(LayoutSizes other) { + flexible += other.flexible; + min += other.min; + preferred += other.preferred; + } + + /// + /// Enlarges this layout size, if necessary, using the values from another. + /// + /// The minimum size values to enforce. + public void Max(LayoutSizes other) { + flexible = Math.Max(flexible, other.flexible); + min = Math.Max(min, other.min); + preferred = Math.Max(preferred, other.preferred); + } + + public override string ToString() { + return string.Format("LayoutSizes[min={0:F2},preferred={1:F2},flexible={2:F2}]", + min, preferred, flexible); + } + } +} diff --git a/mod/PLibUI/Layouts/AbstractLayoutGroup.cs b/mod/PLibUI/Layouts/AbstractLayoutGroup.cs new file mode 100644 index 0000000..bbcd2a8 --- /dev/null +++ b/mod/PLibUI/Layouts/AbstractLayoutGroup.cs @@ -0,0 +1,239 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System.Collections; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI.Layouts { + /// + /// The abstract parent of most layout groups. + /// + public abstract class AbstractLayoutGroup : UIBehaviour, ISettableFlexSize, ILayoutElement + { + /// + /// Sets an object's layout dirty on the next frame. + /// + /// The transform to set dirty. + /// A coroutine to set it dirty. + internal static IEnumerator DelayedSetDirty(RectTransform transform) { + yield return null; + LayoutRebuilder.MarkLayoutForRebuild(transform); + } + + /// + /// Removes and destroys any PLib layouts on the component. They will be replaced with + /// a static LayoutElement containing the old size of the component. + /// + /// The component to cleanse. + internal static void DestroyAndReplaceLayout(GameObject component) { + if (component != null && component.TryGetComponent(out AbstractLayoutGroup + layoutGroup)) { + var replacement = component.AddOrGet(); + replacement.flexibleHeight = layoutGroup.flexibleHeight; + replacement.flexibleWidth = layoutGroup.flexibleWidth; + replacement.layoutPriority = layoutGroup.layoutPriority; + replacement.minHeight = layoutGroup.minHeight; + replacement.minWidth = layoutGroup.minWidth; + replacement.preferredHeight = layoutGroup.preferredHeight; + replacement.preferredWidth = layoutGroup.preferredWidth; + DestroyImmediate(layoutGroup); + } + } + + public float minWidth { + get { + return mMinWidth; + } + set { + mMinWidth = value; + } + } + + public float preferredWidth { + get { + return mPreferredWidth; + } + set { + mPreferredWidth = value; + } + } + + /// + /// The flexible width of the completed layout group can be set. + /// + public float flexibleWidth { + get { + return mFlexibleWidth; + } + set { + mFlexibleWidth = value; + } + } + + public float minHeight { + get { + return mMinHeight; + } + set { + mMinHeight = value; + } + } + + public float preferredHeight { + get { + return mPreferredHeight; + } + set { + mPreferredHeight = value; + } + } + + /// + /// The flexible height of the completed layout group can be set. + /// + public float flexibleHeight { + get { + return mFlexibleHeight; + } + set { + mFlexibleHeight = value; + } + } + + /// + /// The priority of this layout group. + /// + public int layoutPriority { + get { + return mLayoutPriority; + } + set { + mLayoutPriority = value; + } + } + + protected RectTransform rectTransform { + get { + if (cachedTransform == null) + cachedTransform = gameObject.rectTransform(); + return cachedTransform; + } + } + + /// + /// Whether the layout is currently locked. + /// + [SerializeField] + protected bool locked; + + // The backing fields must be annotated with the attribute to have prefabbed versions + // successfully copy the values + [SerializeField] + private float mMinWidth, mMinHeight, mPreferredWidth, mPreferredHeight; + [SerializeField] + private float mFlexibleWidth, mFlexibleHeight; + [SerializeField] + private int mLayoutPriority; + + /// + /// The cached rect transform to speed up layout. + /// + private RectTransform cachedTransform; + + protected AbstractLayoutGroup() { + cachedTransform = null; + locked = false; + mLayoutPriority = 1; + } + + public abstract void CalculateLayoutInputHorizontal(); + + public abstract void CalculateLayoutInputVertical(); + + /// + /// Triggers a layout with the current parent, and then locks the layout size. Further + /// attempts to automatically lay out the component, unless UnlockLayout is called, + /// will not trigger any action. + /// + /// The resulting layout has very good performance, but cannot adapt to changes in the + /// size of its children or its own size. + /// + /// The computed size of this component when locked. + public virtual Vector2 LockLayout() { + var rt = gameObject.rectTransform(); + if (rt != null) { + locked = false; + CalculateLayoutInputHorizontal(); + rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, minWidth); + SetLayoutHorizontal(); + CalculateLayoutInputVertical(); + rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, minHeight); + SetLayoutVertical(); + locked = true; + } + return new Vector2(minWidth, minHeight); + } + + protected override void OnDidApplyAnimationProperties() { + base.OnDidApplyAnimationProperties(); + SetDirty(); + } + + protected override void OnDisable() { + base.OnDisable(); + SetDirty(); + } + + protected override void OnEnable() { + base.OnEnable(); + SetDirty(); + } + + protected override void OnRectTransformDimensionsChange() { + base.OnRectTransformDimensionsChange(); + SetDirty(); + } + + /// + /// Sets this layout as dirty. + /// + protected virtual void SetDirty() { + if (gameObject != null && IsActive()) { + if (CanvasUpdateRegistry.IsRebuildingLayout()) + LayoutRebuilder.MarkLayoutForRebuild(rectTransform); + else + StartCoroutine(DelayedSetDirty(rectTransform)); + } + } + + public abstract void SetLayoutHorizontal(); + + public abstract void SetLayoutVertical(); + + /// + /// Unlocks the layout, allowing it to again dynamically resize when component sizes + /// are changed. + /// + public virtual void UnlockLayout() { + locked = false; + LayoutRebuilder.MarkLayoutForRebuild(gameObject.rectTransform()); + } + } +} diff --git a/mod/PLibUI/Layouts/BoxLayoutGroup.cs b/mod/PLibUI/Layouts/BoxLayoutGroup.cs new file mode 100644 index 0000000..e9ddc75 --- /dev/null +++ b/mod/PLibUI/Layouts/BoxLayoutGroup.cs @@ -0,0 +1,266 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +//#define DEBUG_LAYOUT +using PeterHan.PLib.UI.Layouts; +using System; +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// A freezable, flexible layout manager that fixes the issues I am having with + /// HorizontalLayoutGroup and VerticalLayoutGroup. You get a content size fitter for + /// free too! + /// + /// Intended to work something like Java's BoxLayout... + /// + public sealed class BoxLayoutGroup : AbstractLayoutGroup { + /// + /// Calculates the size of the box layout container. + /// + /// The container to lay out. + /// The parameters to use for layout. + /// The direction which is being calculated. + /// The minimum and preferred box layout size. + private static BoxLayoutResults Calc(GameObject obj, BoxLayoutParams args, + PanelDirection direction) { + var transform = obj.AddOrGet(); + int n = transform.childCount; + var result = new BoxLayoutResults(direction, n); + var components = ListPool.Allocate(); + for (int i = 0; i < n; i++) { + var child = transform.GetChild(i)?.gameObject; + if (child != null && child.activeInHierarchy) { + // Only on active game objects + components.Clear(); + child.GetComponents(components); + var hc = PUIUtils.CalcSizes(child, direction, components); + if (!hc.ignore) { + if (args.Direction == direction) + result.Accum(hc, args.Spacing); + else + result.Expand(hc); + result.children.Add(hc); + } + } + } + components.Recycle(); + return result; + } + + /// + /// Lays out components in the box layout container. + /// + /// The parameters to use for layout. + /// The calculated minimum and preferred sizes. + /// The total available size in this dimension. + private static void DoLayout(BoxLayoutParams args, BoxLayoutResults required, + float size) { + if (required == null) + throw new ArgumentNullException(nameof(required)); + var direction = required.direction; + var status = new BoxLayoutStatus(direction, args.Margin ?? new RectOffset(), size); + if (args.Direction == direction) + DoLayoutLinear(required, args, status); + else + DoLayoutPerp(required, args, status); + } + + /// + /// Lays out components in the box layout container parallel to the layout axis. + /// + /// The calculated minimum and preferred sizes. + /// The parameters to use for layout. + /// The current status of layout. + private static void DoLayoutLinear(BoxLayoutResults required, BoxLayoutParams args, + BoxLayoutStatus status) { + var total = required.total; + var components = ListPool.Allocate(); + var direction = args.Direction; + // Determine flex size ratio + float size = status.size, prefRatio = 0.0f, minSize = total.min, prefSize = + total.preferred, excess = Math.Max(0.0f, size - prefSize), flexTotal = total. + flexible, offset = status.offset, spacing = args.Spacing; + if (size > minSize && prefSize > minSize) + // Do not divide by 0 + prefRatio = Math.Min(1.0f, (size - minSize) / (prefSize - minSize)); + if (excess > 0.0f && flexTotal == 0.0f) + // If no components can be expanded, offset all + offset += PUIUtils.GetOffset(args.Alignment, status.direction, excess); + foreach (var child in required.children) { + var obj = child.source; + // Active objects only + if (obj != null && obj.activeInHierarchy) { + float compSize = child.min; + if (prefRatio > 0.0f) + compSize += (child.preferred - child.min) * prefRatio; + if (excess > 0.0f && flexTotal > 0.0f) + compSize += excess * child.flexible / flexTotal; + // Place and size component + obj.AddOrGet().SetInsetAndSizeFromParentEdge(status.edge, + offset, compSize); + offset += compSize + ((compSize > 0.0f) ? spacing : 0.0f); + // Invoke SetLayout on dependents + components.Clear(); + obj.GetComponents(components); + foreach (var component in components) + if (direction == PanelDirection.Horizontal) + component.SetLayoutHorizontal(); + else // if (direction == PanelDirection.Vertical) + component.SetLayoutVertical(); + } + } + components.Recycle(); + } + + /// + /// Lays out components in the box layout container against the layout axis. + /// + /// The calculated minimum and preferred sizes. + /// The parameters to use for layout. + /// The current status of layout. + private static void DoLayoutPerp(BoxLayoutResults required, BoxLayoutParams args, + BoxLayoutStatus status) { + var components = ListPool.Allocate(); + var direction = args.Direction; + float size = status.size; + foreach (var child in required.children) { + var obj = child.source; + // Active objects only + if (obj != null && obj.activeInHierarchy) { + float compSize = size; + if (child.flexible <= 0.0f) + // Does not expand to all + compSize = Math.Min(compSize, child.preferred); + float offset = (size > compSize) ? PUIUtils.GetOffset(args.Alignment, + status.direction, size - compSize) : 0.0f; + // Place and size component + obj.AddOrGet().SetInsetAndSizeFromParentEdge(status.edge, + offset + status.offset, compSize); + // Invoke SetLayout on dependents + components.Clear(); + obj.GetComponents(components); + foreach (var component in components) + if (direction == PanelDirection.Horizontal) + component.SetLayoutVertical(); + else // if (direction == PanelDirection.Vertical) + component.SetLayoutHorizontal(); + } + } + components.Recycle(); + } + + /// + /// The parameters used to set up this box layout. + /// + public BoxLayoutParams Params { + get { + return parameters; + } + set { + parameters = value ?? throw new ArgumentNullException(nameof(Params)); + } + } + + /// + /// Results from the horizontal calculation pass. + /// + private BoxLayoutResults horizontal; + + /// + /// The parameters used to set up this box layout. + /// + [SerializeField] + private BoxLayoutParams parameters; + + /// + /// Results from the vertical calculation pass. + /// + private BoxLayoutResults vertical; + + internal BoxLayoutGroup() { + horizontal = null; + layoutPriority = 1; + parameters = new BoxLayoutParams(); + vertical = null; + } + + public override void CalculateLayoutInputHorizontal() { + if (!locked) { + var margin = parameters.Margin; + float gap = (margin == null) ? 0.0f : margin.left + margin.right; + horizontal = Calc(gameObject, parameters, PanelDirection.Horizontal); + var hTotal = horizontal.total; + minWidth = hTotal.min + gap; + preferredWidth = hTotal.preferred + gap; +#if DEBUG_LAYOUT + PUIUtils.LogUIDebug("CalculateLayoutInputHorizontal for {0} preferred {1:F2}". + F(gameObject.name, preferredWidth)); +#endif + } + } + + public override void CalculateLayoutInputVertical() { + if (!locked) { + var margin = parameters.Margin; + float gap = (margin == null) ? 0.0f : margin.top + margin.bottom; + vertical = Calc(gameObject, parameters, PanelDirection.Vertical); + var vTotal = vertical.total; + minHeight = vTotal.min + gap; + preferredHeight = vTotal.preferred + gap; +#if DEBUG_LAYOUT + PUIUtils.LogUIDebug("CalculateLayoutInputVertical for {0} preferred {1:F2}".F( + gameObject.name, preferredHeight)); +#endif + } + } + + protected override void OnDisable() { + base.OnDisable(); + horizontal = null; + vertical = null; + } + + protected override void OnEnable() { + base.OnEnable(); + horizontal = null; + vertical = null; + } + + public override void SetLayoutHorizontal() { + if (horizontal != null && !locked) { +#if DEBUG_LAYOUT + PUIUtils.LogUIDebug("SetLayoutHorizontal for {0} resolved width to {1:F2}".F( + gameObject.name, rectTransform.rect.width)); +#endif + DoLayout(parameters, horizontal, rectTransform.rect.width); + } + } + + public override void SetLayoutVertical() { + if (vertical != null && !locked) { +#if DEBUG_LAYOUT + PUIUtils.LogUIDebug("SetLayoutVertical for {0} resolved height to {1:F2}".F( + gameObject.name, rectTransform.rect.height)); +#endif + DoLayout(parameters, vertical, rectTransform.rect.height); + } + } + } +} diff --git a/mod/PLibUI/Layouts/BoxLayoutParams.cs b/mod/PLibUI/Layouts/BoxLayoutParams.cs new file mode 100644 index 0000000..884c40e --- /dev/null +++ b/mod/PLibUI/Layouts/BoxLayoutParams.cs @@ -0,0 +1,61 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using UnityEngine; + +namespace PeterHan.PLib.UI { + /// + /// The parameters used for laying out a box layout. + /// + [Serializable] + public sealed class BoxLayoutParams { + /// + /// The alignment to use for components that are not big enough to fit and have no + /// flexible width. + /// + public TextAnchor Alignment { get; set; } + + /// + /// The direction of layout. + /// + public PanelDirection Direction { get; set; } + + /// + /// The margin between the children and the component edge. + /// + public RectOffset Margin { get; set; } + + /// + /// The spacing between components. + /// + public float Spacing { get; set; } + + public BoxLayoutParams() { + Alignment = TextAnchor.MiddleCenter; + Direction = PanelDirection.Horizontal; + Margin = null; + Spacing = 0.0f; + } + + public override string ToString() { + return string.Format("BoxLayoutParams[Alignment={0},Direction={1},Spacing={2:F2}]", + Alignment, Direction, Spacing); + } + } +} diff --git a/mod/PLibUI/Layouts/BoxLayoutResults.cs b/mod/PLibUI/Layouts/BoxLayoutResults.cs new file mode 100644 index 0000000..b2a7e88 --- /dev/null +++ b/mod/PLibUI/Layouts/BoxLayoutResults.cs @@ -0,0 +1,147 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace PeterHan.PLib.UI.Layouts { + /// + /// A class which stores the results of a single box layout calculation pass. + /// + internal sealed class BoxLayoutResults { + /// + /// The components which were laid out. + /// + public readonly ICollection children; + + /// + /// The current direction of flow. + /// + public readonly PanelDirection direction; + + /// + /// Whether any spaces have been added yet for minimum size. + /// + private bool haveMinSpace; + + /// + /// Whether any spaces have been added yet for preferred size. + /// + private bool havePrefSpace; + + /// + /// The total sizes. + /// + public LayoutSizes total; + + internal BoxLayoutResults(PanelDirection direction, int presize) { + children = new List(presize); + this.direction = direction; + haveMinSpace = false; + havePrefSpace = false; + total = new LayoutSizes(); + } + + /// + /// Accumulates another component into the results. + /// + /// The size of the component to add. + /// The component spacing. + public void Accum(LayoutSizes sizes, float spacing) { + float newMin = sizes.min, newPreferred = sizes.preferred; + if (newMin > 0.0f) { + // Skip one space + if (haveMinSpace) + newMin += spacing; + haveMinSpace = true; + } + total.min += newMin; + if (newPreferred > 0.0f) { + // Skip one space + if (havePrefSpace) + newPreferred += spacing; + havePrefSpace = true; + } + total.preferred += newPreferred; + total.flexible += sizes.flexible; + } + + /// + /// Expands the results around another component. + /// + /// The size of the component to expand to. + public void Expand(LayoutSizes sizes) { + float newMin = sizes.min, newPreferred = sizes.preferred, newFlexible = sizes. + flexible; + if (newMin > total.min) + total.min = newMin; + if (newPreferred > total.preferred) + total.preferred = newPreferred; + if (newFlexible > total.flexible) + total.flexible = newFlexible; + } + + public override string ToString() { + return direction + " " + total; + } + } + + /// + /// Maintains the status of a layout in progress. + /// + internal sealed class BoxLayoutStatus { + /// + /// The current direction of flow. + /// + public readonly PanelDirection direction; + + /// + /// The edge from where layout started. + /// + public readonly RectTransform.Edge edge; + + /// + /// The next component's offset. + /// + public readonly float offset; + + /// + /// The component size in that direction minus margins. + /// + public readonly float size; + + internal BoxLayoutStatus(PanelDirection direction, RectOffset margins, float size) { + this.direction = direction; + switch (direction) { + case PanelDirection.Horizontal: + edge = RectTransform.Edge.Left; + offset = margins.left; + this.size = size - offset - margins.right; + break; + case PanelDirection.Vertical: + edge = RectTransform.Edge.Top; + offset = margins.top; + this.size = size - offset - margins.bottom; + break; + default: + throw new ArgumentException("direction"); + } + } + } +} diff --git a/mod/PLibUI/Layouts/CardLayoutGroup.cs b/mod/PLibUI/Layouts/CardLayoutGroup.cs new file mode 100644 index 0000000..2249f1c --- /dev/null +++ b/mod/PLibUI/Layouts/CardLayoutGroup.cs @@ -0,0 +1,208 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.UI.Layouts; +using System; +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// A freezable layout manager that displays one of its contained objects at a time. + /// Unlike other layout groups, even inactive children are considered for sizing. + /// + public sealed class CardLayoutGroup : AbstractLayoutGroup { + /// + /// Calculates the size of the card layout container. + /// + /// The container to lay out. + /// The parameters to use for layout. + /// The direction which is being calculated. + /// The minimum and preferred box layout size. + private static CardLayoutResults Calc(GameObject obj, PanelDirection direction) { + var transform = obj.AddOrGet(); + int n = transform.childCount; + var result = new CardLayoutResults(direction, n); + var components = ListPool.Allocate(); + for (int i = 0; i < n; i++) { + var child = transform.GetChild(i)?.gameObject; + if (child != null) { + bool active = child.activeInHierarchy; + // Not only on active game objects + components.Clear(); + child.GetComponents(components); + child.SetActive(true); + var hc = PUIUtils.CalcSizes(child, direction, components); + if (!hc.ignore) { + result.Expand(hc); + result.children.Add(hc); + } + child.SetActive(active); + } + } + components.Recycle(); + return result; + } + + /// + /// Lays out components in the card layout container. + /// + /// The margin to allow around the components. + /// The calculated minimum and preferred sizes. + /// The total available size in this dimension. + private static void DoLayout(RectOffset margin, CardLayoutResults required, float size) + { + if (required == null) + throw new ArgumentNullException(nameof(required)); + var direction = required.direction; + var components = ListPool.Allocate(); + // Compensate for margins + if (direction == PanelDirection.Horizontal) + size -= margin.left + margin.right; + else + size -= margin.top + margin.bottom; + foreach (var child in required.children) { + var obj = child.source; + if (obj != null) { + float compSize = PUIUtils.GetProperSize(child, size); + // Place and size component + var transform = obj.AddOrGet(); + if (direction == PanelDirection.Horizontal) + transform.SetInsetAndSizeFromParentEdge(RectTransform.Edge.Left, + margin.left, compSize); + else + transform.SetInsetAndSizeFromParentEdge(RectTransform.Edge.Top, + margin.top, compSize); + // Invoke SetLayout on dependents + components.Clear(); + obj.GetComponents(components); + foreach (var component in components) + if (direction == PanelDirection.Horizontal) + component.SetLayoutHorizontal(); + else // if (direction == PanelDirection.Vertical) + component.SetLayoutVertical(); + } + } + components.Recycle(); + } + + /// + /// The margin around the components as a whole. + /// + public RectOffset Margin { + get { + return margin; + } + set { + margin = value; + } + } + + /// + /// Results from the horizontal calculation pass. + /// + private CardLayoutResults horizontal; + + /// + /// The margin around the components as a whole. + /// + [SerializeField] + private RectOffset margin; + + /// + /// Results from the vertical calculation pass. + /// + private CardLayoutResults vertical; + + internal CardLayoutGroup() { + horizontal = null; + layoutPriority = 1; + vertical = null; + } + + public override void CalculateLayoutInputHorizontal() { + if (!locked) { + var margin = Margin; + float gap = (margin == null) ? 0.0f : margin.left + margin.right; + horizontal = Calc(gameObject, PanelDirection.Horizontal); + var hTotal = horizontal.total; + minWidth = hTotal.min + gap; + preferredWidth = hTotal.preferred + gap; + } + } + + public override void CalculateLayoutInputVertical() { + if (!locked) { + var margin = Margin; + float gap = (margin == null) ? 0.0f : margin.top + margin.bottom; + vertical = Calc(gameObject, PanelDirection.Vertical); + var vTotal = vertical.total; + minHeight = vTotal.min + gap; + preferredHeight = vTotal.preferred + gap; + } + } + + protected override void OnDisable() { + base.OnDisable(); + horizontal = null; + vertical = null; + } + + protected override void OnEnable() { + base.OnEnable(); + horizontal = null; + vertical = null; + } + + /// + /// Switches the active card. + /// + /// The child to make active, or null to inactivate all children. + public void SetActiveCard(GameObject card) { + int n = transform.childCount; + for (int i = 0; i < n; i++) { + var child = transform.GetChild(i)?.gameObject; + if (child != null) + child.SetActive(child == card); + } + } + + /// + /// Switches the active card. + /// + /// The child index to make active, or -1 to inactivate all children. + public void SetActiveCard(int index) { + int n = transform.childCount; + for (int i = 0; i < n; i++) { + var child = transform.GetChild(i)?.gameObject; + if (child != null) + child.SetActive(i == index); + } + } + + public override void SetLayoutHorizontal() { + if (horizontal != null && !locked) + DoLayout(Margin ?? new RectOffset(), horizontal, rectTransform.rect.width); + } + + public override void SetLayoutVertical() { + if (vertical != null && !locked) + DoLayout(Margin ?? new RectOffset(), vertical, rectTransform.rect.height); + } + } +} diff --git a/mod/PLibUI/Layouts/CardLayoutResults.cs b/mod/PLibUI/Layouts/CardLayoutResults.cs new file mode 100644 index 0000000..413ab14 --- /dev/null +++ b/mod/PLibUI/Layouts/CardLayoutResults.cs @@ -0,0 +1,66 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System.Collections.Generic; + +namespace PeterHan.PLib.UI.Layouts { + /// + /// A class which stores the results of a single card layout calculation pass. + /// + internal sealed class CardLayoutResults { + /// + /// The components which were laid out. + /// + public readonly ICollection children; + + /// + /// The current direction of flow. + /// + public readonly PanelDirection direction; + + /// + /// The total sizes. + /// + public LayoutSizes total; + + internal CardLayoutResults(PanelDirection direction, int presize) { + children = new List(presize); + this.direction = direction; + total = new LayoutSizes(); + } + + /// + /// Expands the results around another component. + /// + /// The size of the component to expand to. + public void Expand(LayoutSizes sizes) { + float newMin = sizes.min, newPreferred = sizes.preferred, newFlexible = + sizes.flexible; + if (newMin > total.min) + total.min = newMin; + if (newPreferred > total.preferred) + total.preferred = newPreferred; + if (newFlexible > total.flexible) + total.flexible = newFlexible; + } + + public override string ToString() { + return direction + " " + total; + } + } +} diff --git a/mod/PLibUI/Layouts/GridComponent.cs b/mod/PLibUI/Layouts/GridComponent.cs new file mode 100644 index 0000000..97e185e --- /dev/null +++ b/mod/PLibUI/Layouts/GridComponent.cs @@ -0,0 +1,42 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; + +namespace PeterHan.PLib.UI { + /// + /// A component in the grid with its placement information. + /// + [Serializable] + internal sealed class GridComponent : GridComponentSpec where T : class { + /// + /// The object to place here. + /// + public T Item { get; } + + internal GridComponent(GridComponentSpec spec, T item) { + Alignment = spec.Alignment; + Item = item; + Column = spec.Column; + ColumnSpan = spec.ColumnSpan; + Margin = spec.Margin; + Row = spec.Row; + RowSpan = spec.RowSpan; + } + } +} diff --git a/mod/PLibUI/Layouts/GridComponentSpec.cs b/mod/PLibUI/Layouts/GridComponentSpec.cs new file mode 100644 index 0000000..6ad82bc --- /dev/null +++ b/mod/PLibUI/Layouts/GridComponentSpec.cs @@ -0,0 +1,166 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System; +using UnityEngine; + +namespace PeterHan.PLib.UI { + /// + /// Stores the state of a component in a grid layout. + /// + public class GridComponentSpec { + /// + /// The alignment of the component. + /// + public TextAnchor Alignment { get; set; } + + /// + /// The column of the component. + /// + public int Column { get; set; } + + /// + /// The number of columns this component spans. + /// + public int ColumnSpan { get; set; } + + /// + /// The margin to allocate around each component. + /// + public RectOffset Margin { get; set; } + + /// + /// The row of the component. + /// + public int Row { get; set; } + + /// + /// The number of rows this component spans. + /// + public int RowSpan { get; set; } + + internal GridComponentSpec() { } + + /// + /// Creates a new grid component specification. While the row and column are mandatory, + /// the other attributes can be optionally specified in the initializer. + /// + /// The row to place the component. + /// The column to place the component. + public GridComponentSpec(int row, int column) { + if (row < 0) + throw new ArgumentOutOfRangeException(nameof(row)); + if (column < 0) + throw new ArgumentOutOfRangeException(nameof(column)); + Alignment = TextAnchor.MiddleCenter; + Row = row; + Column = column; + Margin = null; + RowSpan = 1; + ColumnSpan = 1; + } + + public override string ToString() { + return string.Format("GridComponentSpec[Row={0:D},Column={1:D},RowSpan={2:D},ColumnSpan={3:D}]", + Row, Column, RowSpan, ColumnSpan); + } + } + + /// + /// The specifications for one column in a grid layout. + /// + [Serializable] + public sealed class GridColumnSpec { + /// + /// The flexible width of this grid column. If there is space left after all + /// columns get their nominal width, each column will get a fraction of the space + /// left proportional to their FlexWidth value as a ratio to the total flexible + /// width values. + /// + public float FlexWidth { get; } + + /// + /// The nominal width of this grid column. If zero, the preferred width of the + /// largest component is used. If there are no components in this column (possibly + /// because the only components in this row all have column spans from other + /// columns), the width will be zero! + /// + public float Width { get; } + + /// + /// Creates a new grid column specification. + /// + /// The column's base width, or 0 to auto-size the column to the + /// preferred width of its largest component. + /// The percentage of the leftover width the column should occupy. + public GridColumnSpec(float width = 0.0f, float flex = 0.0f) { + if (width.IsNaNOrInfinity() || width < 0.0f) + throw new ArgumentOutOfRangeException(nameof(width)); + if (flex.IsNaNOrInfinity() || flex < 0.0f) + throw new ArgumentOutOfRangeException(nameof(flex)); + Width = width; + FlexWidth = flex; + } + + public override string ToString() { + return string.Format("GridColumnSpec[Width={0:F2}]", Width); + } + } + + /// + /// The specifications for one row in a grid layout. + /// + [Serializable] + public sealed class GridRowSpec { + /// + /// The flexible height of this grid row. If there is space left after all rows + /// get their nominal height, each row will get a fraction of the space left + /// proportional to their FlexHeight value as a ratio to the total flexible + /// height values. + /// + public float FlexHeight { get; } + + /// + /// The nominal height of this grid row. If zero, the preferred height of the + /// largest component is used. If there are no components in this row (possibly + /// because the only components in this row all have row spans from other rows), + /// the height will be zero! + /// + public float Height { get; } + + /// + /// Creates a new grid row specification. + /// + /// The row's base width, or 0 to auto-size the row to the + /// preferred height of its largest component. + /// The percentage of the leftover height the row should occupy. + public GridRowSpec(float height = 0.0f, float flex = 0.0f) { + if (height.IsNaNOrInfinity() || height < 0.0f) + throw new ArgumentOutOfRangeException(nameof(height)); + if (flex.IsNaNOrInfinity() || flex < 0.0f) + throw new ArgumentOutOfRangeException(nameof(flex)); + Height = height; + FlexHeight = flex; + } + + public override string ToString() { + return string.Format("GridRowSpec[Height={0:F2}]", Height); + } + } +} diff --git a/mod/PLibUI/Layouts/GridLayoutResults.cs b/mod/PLibUI/Layouts/GridLayoutResults.cs new file mode 100644 index 0000000..4f5142d --- /dev/null +++ b/mod/PLibUI/Layouts/GridLayoutResults.cs @@ -0,0 +1,304 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace PeterHan.PLib.UI.Layouts { + /// + /// A class which stores the results of a single grid layout calculation pass. + /// + internal sealed class GridLayoutResults { + /// + /// Builds a matrix of the components at each given location. Components only are + /// entered at their origin cell (ignoring row and column span). + /// + /// The maximum number of rows. + /// The maximum number of columns. + /// The components to add. + /// A 2-D array of the components at a given row/column location. + private static ICollection[,] GetMatrix(int rows, int columns, + ICollection components) { + var spec = new ICollection[rows, columns]; + foreach (var component in components) { + int x = component.Row, y = component.Column; + if (x >= 0 && x < rows && y >= 0 && y < columns) { + // Multiple components are allowed at a given cell + var atLocation = spec[x, y]; + if (atLocation == null) + spec[x, y] = atLocation = new List(8); + atLocation.Add(component); + } + } + return spec; + } + + /// + /// The columns in the grid. + /// + public IList ColumnSpecs { get; } + + /// + /// The components in the grid, in order of addition. + /// + public ICollection Components { get; } + + /// + /// The number of columns in the grid. + /// + public int Columns { get; } + + /// + /// The columns in the grid with their calculated widths. + /// + public IList ComputedColumnSpecs { get; } + + /// + /// The rows in the grid with their calculated heights. + /// + public IList ComputedRowSpecs { get; } + + /// + /// The minimum total height. + /// + public float MinHeight { get; private set; } + + /// + /// The minimum total width. + /// + public float MinWidth { get; private set; } + + /// + /// The components which were laid out. + /// + public ICollection[,] Matrix { get; } + + /// + /// The rows in the grid. + /// + public IList RowSpecs { get; } + + /// + /// The number of rows in the grid. + /// + public int Rows { get; } + + /// + /// The total flexible height weights. + /// + public float TotalFlexHeight { get; private set; } + + /// + /// The total flexible width weights. + /// + public float TotalFlexWidth { get; private set; } + + internal GridLayoutResults(IList rows, IList columns, + ICollection> components) { + if (rows == null) + throw new ArgumentNullException(nameof(rows)); + if (columns == null) + throw new ArgumentNullException(nameof(columns)); + if (components == null) + throw new ArgumentNullException(nameof(components)); + Columns = columns.Count; + Rows = rows.Count; + ColumnSpecs = columns; + MinHeight = MinWidth = 0.0f; + RowSpecs = rows; + ComputedColumnSpecs = new List(Columns); + ComputedRowSpecs = new List(Rows); + TotalFlexHeight = TotalFlexWidth = 0.0f; + // Populate alive components + Components = new List(Math.Max(components.Count, 4)); + foreach (var component in components) { + var item = component.Item; + if (item != null) + Components.Add(new SizedGridComponent(component, item)); + } + Matrix = GetMatrix(Rows, Columns, Components); + } + + /// + /// Calculates the base height of each row, the minimum it gets before extra space + /// is distributed. + /// + internal void CalcBaseHeights() { + int rows = Rows; + MinHeight = TotalFlexHeight = 0.0f; + ComputedRowSpecs.Clear(); + for (int row = 0; row < rows; row++) { + var spec = RowSpecs[row]; + float height = spec.Height, flex = spec.FlexHeight; + if (height <= 0.0f) + // Auto height + for (int i = 0; i < Columns; i++) + height = Math.Max(height, PreferredHeightAt(row, i)); + if (flex > 0.0f) + TotalFlexHeight += flex; + ComputedRowSpecs.Add(new GridRowSpec(height, flex)); + } + foreach (var component in Components) + if (component.RowSpan > 1) + ExpandMultiRow(component); + // Min height is calculated after all multirow components are distributed + for (int row = 0; row < rows; row++) + MinHeight += ComputedRowSpecs[row].Height; + } + + /// + /// Calculates the base width of each row, the minimum it gets before extra space + /// is distributed. + /// + internal void CalcBaseWidths() { + int columns = Columns; + MinWidth = TotalFlexWidth = 0.0f; + ComputedColumnSpecs.Clear(); + for (int column = 0; column < columns; column++) { + var spec = ColumnSpecs[column]; + float width = spec.Width, flex = spec.FlexWidth; + if (width <= 0.0f) + // Auto width + for (int i = 0; i < Rows; i++) + width = Math.Max(width, PreferredWidthAt(i, column)); + if (flex > 0.0f) + TotalFlexWidth += flex; + ComputedColumnSpecs.Add(new GridColumnSpec(width, flex)); + } + foreach (var component in Components) + if (component.ColumnSpan > 1) + ExpandMultiColumn(component); + // Min width is calculated after all multicolumn components are distributed + for (int column = 0; column < columns; column++) + MinWidth += ComputedColumnSpecs[column].Width; + } + + /// + /// For a multicolumn component, ratiometrically splits up any excess preferred size + /// among the columns in its span that have a flexible width. + /// + /// The component to reallocate sizes. + private void ExpandMultiColumn(SizedGridComponent component) { + float need = component.HorizontalSize.preferred, totalFlex = 0.0f; + int start = component.Column, end = start + component.ColumnSpan; + for (int i = start; i < end; i++) { + var spec = ComputedColumnSpecs[i]; + if (spec.FlexWidth > 0.0f) + totalFlex += spec.FlexWidth; + need -= spec.Width; + } + if (need > 0.0f && totalFlex > 0.0f) + // No flex = we can do nothing about it + for (int i = start; i < end; i++) { + var spec = ComputedColumnSpecs[i]; + float flex = spec.FlexWidth; + if (flex > 0.0f) + ComputedColumnSpecs[i] = new GridColumnSpec(spec.Width + flex * need / + totalFlex, flex); + } + } + + /// + /// For a multirow component, ratiometrically splits up any excess preferred size + /// among the rows in its span that have a flexible height. + /// + /// The component to reallocate sizes. + private void ExpandMultiRow(SizedGridComponent component) { + float need = component.VerticalSize.preferred, totalFlex = 0.0f; + int start = component.Row, end = start + component.RowSpan; + for (int i = start; i < end; i++) { + var spec = ComputedRowSpecs[i]; + if (spec.FlexHeight > 0.0f) + totalFlex += spec.FlexHeight; + need -= spec.Height; + } + if (need > 0.0f && totalFlex > 0.0f) + // No flex = we can do nothing about it + for (int i = start; i < end; i++) { + var spec = ComputedRowSpecs[i]; + float flex = spec.FlexHeight; + if (flex > 0.0f) + ComputedRowSpecs[i] = new GridRowSpec(spec.Height + flex * need / + totalFlex, flex); + } + } + + /// + /// Retrieves the preferred height of a cell. + /// + /// The cell's row. + /// The cell's column. + /// The preferred height. + private float PreferredHeightAt(int row, int column) { + float size = 0.0f; + var atLocation = Matrix[row, column]; + if (atLocation != null && atLocation.Count > 0) + foreach (var component in atLocation) { + var sizes = component.VerticalSize; + if (component.RowSpan < 2) + size = Math.Max(size, sizes.preferred); + } + return size; + } + + /// + /// Retrieves the preferred width of a cell. + /// + /// The cell's row. + /// The cell's column. + /// The preferred width. + private float PreferredWidthAt(int row, int column) { + float size = 0.0f; + var atLocation = Matrix[row, column]; + if (atLocation != null && atLocation.Count > 0) + foreach (var component in atLocation) { + var sizes = component.HorizontalSize; + if (component.ColumnSpan < 2) + size = Math.Max(size, sizes.preferred); + } + return size; + } + } + + /// + /// A component in the grid with its sizes computed. + /// + internal sealed class SizedGridComponent : GridComponentSpec { + /// + /// The object and its computed horizontal sizes. + /// + public LayoutSizes HorizontalSize { get; set; } + + /// + /// The object and its computed vertical sizes. + /// + public LayoutSizes VerticalSize { get; set; } + + internal SizedGridComponent(GridComponentSpec spec, GameObject item) { + Alignment = spec.Alignment; + Column = spec.Column; + ColumnSpan = spec.ColumnSpan; + Margin = spec.Margin; + Row = spec.Row; + RowSpan = spec.RowSpan; + HorizontalSize = new LayoutSizes(item); + VerticalSize = new LayoutSizes(item); + } + } +} diff --git a/mod/PLibUI/Layouts/PGridLayoutGroup.cs b/mod/PLibUI/Layouts/PGridLayoutGroup.cs new file mode 100644 index 0000000..08eb100 --- /dev/null +++ b/mod/PLibUI/Layouts/PGridLayoutGroup.cs @@ -0,0 +1,349 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using PeterHan.PLib.UI.Layouts; +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// Implements a flexible version of the base GridLayout. + /// + public sealed class PGridLayoutGroup : AbstractLayoutGroup { + /// + /// Calculates all column widths. + /// + /// The results from layout. + /// The current container width. + /// The margins within the borders. + /// The column widths. + private static float[] GetColumnWidths(GridLayoutResults results, float width, + RectOffset margin) { + int columns = results.Columns; + // Find out how much flexible size can be given out + float position = margin?.left ?? 0, right = margin?.right ?? 0; + float actualWidth = width - position - right, totalFlex = results.TotalFlexWidth, + excess = (totalFlex > 0.0f) ? (actualWidth - results.MinWidth) / totalFlex : + 0.0f; + float[] colX = new float[columns + 1]; + // Determine start of columns + for (int i = 0; i < columns; i++) { + var spec = results.ComputedColumnSpecs[i]; + colX[i] = position; + position += spec.Width + spec.FlexWidth * excess; + } + colX[columns] = position; + return colX; + } + + /// + /// Calculates all row heights. + /// + /// The results from layout. + /// The current container height. + /// The margins within the borders. + /// The row heights. + private static float[] GetRowHeights(GridLayoutResults results, float height, + RectOffset margin) { + int rows = results.Rows; + // Find out how much flexible size can be given out + float position = margin?.bottom ?? 0, top = margin?.top ?? 0; + float actualWidth = height - position - top, totalFlex = results.TotalFlexHeight, + excess = (totalFlex > 0.0f) ? (actualWidth - results.MinHeight) / totalFlex : + 0.0f; + float[] rowY = new float[rows + 1]; + // Determine start of rows + for (int i = 0; i < rows; i++) { + var spec = results.ComputedRowSpecs[i]; + rowY[i] = position; + position += spec.Height + spec.FlexHeight * excess; + } + rowY[rows] = position; + return rowY; + } + + /// + /// Calculates the final height of this component and applies it to the component. + /// + /// The component to calculate. + /// The row locations from GetRowHeights. + /// true if the height was applied, or false if the component was not laid out + /// due to being disposed or set to ignore layout. + private static bool SetFinalHeight(SizedGridComponent component, float[] rowY) { + var margin = component.Margin; + var sizes = component.VerticalSize; + var target = sizes.source; + bool ok = !sizes.ignore && target != null; + if (ok) { + int rows = rowY.Length - 1; + // Clamp first and last row occupied by this object + int first = component.Row, last = first + component.RowSpan; + first = first.InRange(0, rows - 1); + last = last.InRange(1, rows); + // Align correctly in the cell box + float y = rowY[first], rowHeight = rowY[last] - y; + if (margin != null) { + float border = margin.top + margin.bottom; + y += margin.top; + rowHeight -= border; + sizes.min -= border; + sizes.preferred -= border; + } + float actualHeight = PUIUtils.GetProperSize(sizes, rowHeight); + // Take alignment into account + y += PUIUtils.GetOffset(component.Alignment, PanelDirection.Vertical, + rowHeight - actualHeight); + target.rectTransform().SetInsetAndSizeFromParentEdge(RectTransform.Edge. + Top, y, actualHeight); + } + return ok; + } + + /// + /// Calculates the final width of this component and applies it to the component. + /// + /// The component to calculate. + /// The column locations from GetColumnWidths. + /// true if the width was applied, or false if the component was not laid out + /// due to being disposed or set to ignore layout. + private static bool SetFinalWidth(SizedGridComponent component, float[] colX) { + var margin = component.Margin; + var sizes = component.HorizontalSize; + var target = sizes.source; + bool ok = !sizes.ignore && target != null; + if (ok) { + int columns = colX.Length - 1; + // Clamp first and last column occupied by this object + int first = component.Column, last = first + component.ColumnSpan; + first = first.InRange(0, columns - 1); + last = last.InRange(1, columns); + // Align correctly in the cell box + float x = colX[first], colWidth = colX[last] - x; + if (margin != null) { + float border = margin.left + margin.right; + x += margin.left; + colWidth -= border; + sizes.min -= border; + sizes.preferred -= border; + } + float actualWidth = PUIUtils.GetProperSize(sizes, colWidth); + // Take alignment into account + x += PUIUtils.GetOffset(component.Alignment, PanelDirection.Horizontal, + colWidth - actualWidth); + target.rectTransform().SetInsetAndSizeFromParentEdge(RectTransform.Edge. + Left, x, actualWidth); + } + return ok; + } + + /// + /// The margin around the components as a whole. + /// + public RectOffset Margin { + get { + return margin; + } + set { + margin = value; + } + } + +#pragma warning disable IDE0044 // Cannot be readonly for Unity serialization to work + /// + /// The children of this panel. + /// + [SerializeField] + private IList> children; + + /// + /// The columns in this panel. + /// + [SerializeField] + private IList columns; + + /// + /// The margin around the components as a whole. + /// + [SerializeField] + private RectOffset margin; + + /// + /// The current layout status. + /// + private GridLayoutResults results; + + /// + /// The rows in this panel. + /// + [SerializeField] + private IList rows; +#pragma warning restore IDE0044 + + internal PGridLayoutGroup() { + children = new List>(16); + columns = new List(16); + rows = new List(16); + layoutPriority = 1; + Margin = null; + results = null; + } + + /// + /// Adds a column to this grid layout. + /// + /// The specification for that column. + public void AddColumn(GridColumnSpec column) { + if (column == null) + throw new ArgumentNullException(nameof(column)); + columns.Add(column); + } + + /// + /// Adds a component to this layout. Components added through other means to the + /// transform will not be laid out at all! + /// + /// The child to add. + /// The location where the child will be placed. + public void AddComponent(GameObject child, GridComponentSpec spec) { + if (child == null) + throw new ArgumentNullException(nameof(child)); + if (spec == null) + throw new ArgumentNullException(nameof(spec)); + children.Add(new GridComponent(spec, child)); + child.SetParent(gameObject); + } + + /// + /// Adds a row to this grid layout. + /// + /// The specification for that row. + public void AddRow(GridRowSpec row) { + if (row == null) + throw new ArgumentNullException(nameof(row)); + rows.Add(row); + } + + public override void CalculateLayoutInputHorizontal() { + if (!locked) { + results = new GridLayoutResults(rows, columns, children); + var elements = ListPool.Allocate(); + foreach (var component in results.Components) { + // Cache size of children + var obj = component.HorizontalSize.source; + var margin = component.Margin; + elements.Clear(); + obj.GetComponents(elements); + var sz = PUIUtils.CalcSizes(obj, PanelDirection.Horizontal, elements); + if (!sz.ignore) { + // Add borders + int border = (margin == null) ? 0 : margin.left + margin.right; + sz.min += border; + sz.preferred += border; + } + component.HorizontalSize = sz; + } + elements.Recycle(); + // Calculate columns sizes and our size + results.CalcBaseWidths(); + float width = results.MinWidth; + if (Margin != null) + width += Margin.left + Margin.right; + minWidth = preferredWidth = width; + flexibleWidth = (results.TotalFlexWidth > 0.0f) ? 1.0f : 0.0f; + } + } + + public override void CalculateLayoutInputVertical() { + if (results != null && !locked) { + var elements = ListPool.Allocate(); + foreach (var component in results.Components) { + // Cache size of children + var obj = component.VerticalSize.source; + var margin = component.Margin; + elements.Clear(); + obj.GetComponents(elements); + var sz = PUIUtils.CalcSizes(obj, PanelDirection.Vertical, elements); + if (!sz.ignore) { + // Add borders + int border = (margin == null) ? 0 : margin.top + margin.bottom; + sz.min += border; + sz.preferred += border; + } + component.VerticalSize = sz; + } + elements.Recycle(); + // Calculate row sizes and our size + results.CalcBaseHeights(); + float height = results.MinHeight; + if (Margin != null) + height += Margin.bottom + Margin.top; + minHeight = preferredHeight = height; + flexibleHeight = (results.TotalFlexHeight > 0.0f) ? 1.0f : 0.0f; + } + } + + protected override void OnDisable() { + base.OnDisable(); + results = null; + } + + protected override void OnEnable() { + base.OnEnable(); + results = null; + } + + public override void SetLayoutHorizontal() { + var obj = gameObject; + if (results != null && obj != null && results.Columns > 0 && !locked) { + float[] colX = GetColumnWidths(results, rectTransform.rect.width, Margin); + // All components lay out + var controllers = ListPool.Allocate(); + foreach (var component in results.Components) + if (SetFinalWidth(component, colX)) { + // Lay out all children + controllers.Clear(); + component.HorizontalSize.source.GetComponents(controllers); + foreach (var controller in controllers) + controller.SetLayoutHorizontal(); + } + controllers.Recycle(); + } + } + + public override void SetLayoutVertical() { + var obj = gameObject; + if (results != null && obj != null && results.Rows > 0 && !locked) { + float[] rowY = GetRowHeights(results, rectTransform.rect.height, Margin); + // All components lay out + var controllers = ListPool.Allocate(); + foreach (var component in results.Components) + if (!SetFinalHeight(component, rowY)) { + // Lay out all children + controllers.Clear(); + component.VerticalSize.source.GetComponents(controllers); + foreach (var controller in controllers) + controller.SetLayoutVertical(); + } + controllers.Recycle(); + } + } + } +} diff --git a/mod/PLibUI/Layouts/RelativeLayoutGroup.cs b/mod/PLibUI/Layouts/RelativeLayoutGroup.cs new file mode 100644 index 0000000..2db4f44 --- /dev/null +++ b/mod/PLibUI/Layouts/RelativeLayoutGroup.cs @@ -0,0 +1,292 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.UI.Layouts; +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// A layout group based on the constraints defined in RelativeLayout. Allows the same + /// fast relative positioning that RelativeLayout does, but can respond to changes in the + /// size of its containing components. + /// + public sealed class RelativeLayoutGroup : AbstractLayoutGroup, + ISerializationCallbackReceiver { + /// + /// The margin added around all components in the layout. This is in addition to any + /// margins around the components. + /// + /// Note that this margin is not taken into account with percentage based anchors. + /// Items anchored to the extremes will always work fine. Items anchored in the middle + /// will use the middle before margins are effective. + /// + public RectOffset Margin { + get { + return margin; + } + set { + margin = value; + } + } + + /// + /// Constraints for each object are stored here. + /// + private readonly IDictionary locConstraints; + + /// + /// The serialized constraints. + /// + [SerializeField] + private IList> serialConstraints; + + /// + /// The margin around the components as a whole. + /// + [SerializeField] + private RectOffset margin; + + /// + /// The results of the layout in progress. + /// + private readonly IList results; + + internal RelativeLayoutGroup() { + layoutPriority = 1; + locConstraints = new Dictionary(32); + results = new List(32); + serialConstraints = null; + } + + /// + /// Retrieves the parameters for a child game object. Creates an entry if none exists + /// for this component. + /// + /// The item to look up. + /// The parameters for that object. + private RelativeLayoutParams AddOrGet(GameObject item) { + if (!locConstraints.TryGetValue(item, out RelativeLayoutParams param)) + locConstraints[item] = param = new RelativeLayoutParams(); + return param; + } + + public RelativeLayoutGroup AnchorXAxis(GameObject item, float anchor = 0.5f) { + SetLeftEdge(item, fraction: anchor); + return SetRightEdge(item, fraction: anchor); + } + + public RelativeLayoutGroup AnchorYAxis(GameObject item, float anchor = 0.5f) { + SetTopEdge(item, fraction: anchor); + return SetBottomEdge(item, fraction: anchor); + } + + public override void CalculateLayoutInputHorizontal() { + var all = gameObject?.rectTransform(); + if (all != null && !locked) { + int ml, mr, passes, limit; + if (Margin == null) + ml = mr = 0; + else { + ml = Margin.left; + mr = Margin.right; + } + // X layout + results.CalcX(all, locConstraints); + if (results.Count > 0) { + limit = 2 * results.Count; + for (passes = 0; passes < limit && !results.RunPassX(); passes++) ; + if (passes >= limit) + results.ThrowUnresolvable(passes, PanelDirection.Horizontal); + } + minWidth = preferredWidth = results.GetMinSizeX() + ml + mr; + } + } + + public override void CalculateLayoutInputVertical() { + var all = gameObject?.rectTransform(); + if (all != null && !locked) { + int passes, limit = 2 * results.Count, mt, mb; + if (Margin == null) + mt = mb = 0; + else { + mt = Margin.top; + mb = Margin.bottom; + } + // Y layout + if (results.Count > 0) { + results.CalcY(); + for (passes = 0; passes < limit && !results.RunPassY(); passes++) ; + if (passes >= limit) + results.ThrowUnresolvable(passes, PanelDirection.Vertical); + } + minHeight = preferredHeight = results.GetMinSizeY() + mt + mb; + } + } + + /// + /// Imports the data from RelativeLayout for compatibility. + /// + /// The raw data to import. + internal void Import(IDictionary values) { + locConstraints.Clear(); + foreach (var pair in values) + locConstraints[pair.Key] = pair.Value; + } + + public void OnBeforeSerialize() { + int n = locConstraints.Count; + if (n > 0) { + serialConstraints = new List>(n); + foreach (var pair in locConstraints) + serialConstraints.Add(pair); + } + } + + public void OnAfterDeserialize() { + if (serialConstraints != null) { + locConstraints.Clear(); + foreach (var pair in serialConstraints) + locConstraints[pair.Key] = pair.Value; + serialConstraints = null; + } + } + + public RelativeLayoutGroup OverrideSize(GameObject item, Vector2 size) { + if (item != null) + AddOrGet(item).OverrideSize = size; + return this; + } + + public RelativeLayoutGroup SetBottomEdge(GameObject item, float fraction = -1.0f, + GameObject above = null) { + if (item != null) { + if (above == item) + throw new ArgumentException("Component cannot refer directly to itself"); + SetEdge(AddOrGet(item).BottomEdge, fraction, above); + } + return this; + } + + protected override void SetDirty() { + if (!locked) + base.SetDirty(); + } + + /// + /// Sets a component's edge constraint. + /// + /// The edge to set. + /// The fraction of the parent to anchor. + /// The other component to anchor. + private void SetEdge(RelativeLayoutParams.EdgeStatus edge, float fraction, + GameObject child) { + if (fraction >= 0.0f && fraction <= 1.0f) { + edge.Constraint = RelativeConstraintType.ToAnchor; + edge.FromAnchor = fraction; + edge.FromComponent = null; + } else if (child != null) { + edge.Constraint = RelativeConstraintType.ToComponent; + edge.FromComponent = child; + } else { + edge.Constraint = RelativeConstraintType.Unconstrained; + edge.FromComponent = null; + } + } + + public override void SetLayoutHorizontal() { + if (!locked && results.Count > 0) { + var components = ListPool.Allocate(); + int ml, mr; + if (Margin == null) + ml = mr = 0; + else { + ml = Margin.left; + mr = Margin.right; + } + // Lay out children + results.ExecuteX(components, ml, mr); + components.Recycle(); + } + } + + public override void SetLayoutVertical() { + if (!locked && results.Count > 0) { + var components = ListPool.Allocate(); + int mt, mb; + if (Margin == null) + mt = mb = 0; + else { + mt = Margin.top; + mb = Margin.bottom; + } + // Lay out children + results.ExecuteY(components, mt, mb); + components.Recycle(); + } + } + + public RelativeLayoutGroup SetLeftEdge(GameObject item, float fraction = -1.0f, + GameObject toRight = null) { + if (item != null) { + if (toRight == item) + throw new ArgumentException("Component cannot refer directly to itself"); + SetEdge(AddOrGet(item).LeftEdge, fraction, toRight); + } + return this; + } + + public RelativeLayoutGroup SetMargin(GameObject item, RectOffset insets) { + if (item != null) + AddOrGet(item).Insets = insets; + return this; + } + + /// + /// Sets all layout parameters of an object at once. + /// + /// The item to configure. + /// The raw parameters to use. + internal void SetRaw(GameObject item, RelativeLayoutParams rawParams) { + if (item != null && rawParams != null) + locConstraints[item] = rawParams; + } + + public RelativeLayoutGroup SetRightEdge(GameObject item, float fraction = -1.0f, + GameObject toLeft = null) { + if (item != null) { + if (toLeft == item) + throw new ArgumentException("Component cannot refer directly to itself"); + SetEdge(AddOrGet(item).RightEdge, fraction, toLeft); + } + return this; + } + + public RelativeLayoutGroup SetTopEdge(GameObject item, float fraction = -1.0f, + GameObject below = null) { + if (item != null) { + if (below == item) + throw new ArgumentException("Component cannot refer directly to itself"); + SetEdge(AddOrGet(item).TopEdge, fraction, below); + } + return this; + } + } +} diff --git a/mod/PLibUI/Layouts/RelativeLayoutParams.cs b/mod/PLibUI/Layouts/RelativeLayoutParams.cs new file mode 100644 index 0000000..e95c8d2 --- /dev/null +++ b/mod/PLibUI/Layouts/RelativeLayoutParams.cs @@ -0,0 +1,185 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using UnityEngine; + +namespace PeterHan.PLib.UI.Layouts { + /// + /// Stores constraints applied to a game object in a relative layout. + /// + [Serializable] + internal sealed class RelativeLayoutParams : RelativeLayoutParamsBase { } + + /// + /// Stores constraints applied to an object in a relative layout. + /// + /// The type of the target object. + internal class RelativeLayoutParamsBase { + /// + /// The anchored position of the bottom edge. + /// + internal EdgeStatus BottomEdge { get; } + + /// + /// The insets. If null, insets are all zero. + /// + internal RectOffset Insets { get; set; } + + /// + /// The anchored position of the left edge. + /// + internal EdgeStatus LeftEdge { get; } + + /// + /// Overrides the size of the component if set. + /// + internal Vector2 OverrideSize { get; set; } + + /// + /// The anchored position of the right edge. + /// + internal EdgeStatus RightEdge { get; } + + /// + /// The anchored position of the top edge. + /// + internal EdgeStatus TopEdge { get; } + + internal RelativeLayoutParamsBase() { + Insets = null; + BottomEdge = new EdgeStatus(); + LeftEdge = new EdgeStatus(); + RightEdge = new EdgeStatus(); + TopEdge = new EdgeStatus(); + OverrideSize = Vector2.zero; + } + + /// + /// The edge position determined for a component. + /// + [Serializable] + internal sealed class EdgeStatus { + /// + /// The type of constraint to use for this relative layout. + /// + internal RelativeConstraintType Constraint; + + /// + /// The anchor position in the component that sets the relative anchor. + /// + /// 0.0f is the bottom/left, 1.0f is the top/right. + /// + internal float FromAnchor; + + /// + /// The component to which this edge is anchored. + /// + internal T FromComponent; + + /// + /// The offset in pixels from the anchor. + is upwards/rightwards, - is downwards/ + /// leftwards. + /// + internal float Offset; + + /// + /// True if the position has been locked down in the code. + /// Locked should only be set by the layout manager, crashes may occur otherwise. + /// + public bool Locked { + get { + return Constraint == RelativeConstraintType.Locked; + } + } + + /// + /// True if the position is not constrained to anything. + /// + public bool Unconstrained { + get { + return Constraint == RelativeConstraintType.Unconstrained; + } + } + + internal EdgeStatus() { + FromAnchor = 0.0f; + FromComponent = default; + Offset = 0.0f; + Constraint = RelativeConstraintType.Unconstrained; + } + + /// + /// Copies data from another edge status object. + /// + /// The object to copy. + internal void CopyFrom(EdgeStatus other) { + if (other == null) + throw new ArgumentNullException(nameof(other)); + switch (Constraint = other.Constraint) { + case RelativeConstraintType.ToComponent: + FromComponent = other.FromComponent; + break; + case RelativeConstraintType.ToAnchor: + FromAnchor = other.FromAnchor; + break; + case RelativeConstraintType.Locked: + FromAnchor = other.FromAnchor; + Offset = other.Offset; + break; + case RelativeConstraintType.Unconstrained: + default: + break; + } + } + + public override bool Equals(object obj) { + // Caution: floats are imprecise + return obj is EdgeStatus other && other.FromAnchor == FromAnchor && other. + Offset == Offset && Constraint == other.Constraint; + } + + public override int GetHashCode() { + return 37 * (37 * FromAnchor.GetHashCode() + Offset.GetHashCode()) + + Constraint.GetHashCode(); + } + + /// + /// Resets these offsets to unlocked. + /// + public void Reset() { + Constraint = RelativeConstraintType.Unconstrained; + Offset = 0.0f; + FromAnchor = 0.0f; + FromComponent = default; + } + + public override string ToString() { + return string.Format("EdgeStatus[Constraints={2},Anchor={0:F2},Offset={1:F2}]", + FromAnchor, Offset, Constraint); + } + } + } + + /// + /// The types of constraints which can be applied to components in a relative layout. + /// + internal enum RelativeConstraintType { + Unconstrained, ToComponent, ToAnchor, Locked + } +} diff --git a/mod/PLibUI/Layouts/RelativeLayoutResults.cs b/mod/PLibUI/Layouts/RelativeLayoutResults.cs new file mode 100644 index 0000000..de36845 --- /dev/null +++ b/mod/PLibUI/Layouts/RelativeLayoutResults.cs @@ -0,0 +1,132 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using UnityEngine; + +namespace PeterHan.PLib.UI.Layouts { + /// + /// Parameters used to store the dynamic data of an object during a relative layout. + /// + internal sealed class RelativeLayoutResults : RelativeLayoutParamsBase { + /// + /// A set of insets that are always zero. + /// + private static readonly RectOffset ZERO = new RectOffset(); + + /// + /// The instance parameters of the bottom edge's component. + /// + internal RelativeLayoutResults BottomParams { get; set; } + + /// + /// The height of the component plus its margin box. + /// + internal float EffectiveHeight { get; private set; } + + /// + /// The width of the component plus its margin box. + /// + internal float EffectiveWidth { get; private set; } + + /// + /// The instance parameters of the left edge's component. + /// + internal RelativeLayoutResults LeftParams { get; set; } + + /// + /// The preferred height at which this component will be laid out, unless both + /// edges are constrained. + /// + internal float PreferredHeight { + get { + return prefSize.y; + } + set { + prefSize.y = value; + EffectiveHeight = value + Insets.top + Insets.bottom; + } + } + + /// + /// The preferred width at which this component will be laid out, unless both + /// edges are constrained. + /// + internal float PreferredWidth { + get { + return prefSize.x; + } + set { + prefSize.x = value; + EffectiveWidth = value + Insets.left + Insets.right; + } + } + + /// + /// The instance parameters of the right edge's component. + /// + internal RelativeLayoutResults RightParams { get; set; } + + /// + /// The instance parameters of the top edge's component. + /// + internal RelativeLayoutResults TopParams { get; set; } + + /// + /// The object to lay out. + /// + internal RectTransform Transform { get; set; } + + /// + /// Whether the size delta should be used in the X direction (as opposed to offsets). + /// + internal bool UseSizeDeltaX { get; set; } + + /// + /// Whether the size delta should be used in the Y direction (as opposed to offsets). + /// + internal bool UseSizeDeltaY { get; set; } + + /// + /// The preferred size of this component. + /// + private Vector2 prefSize; + + internal RelativeLayoutResults(RectTransform transform, RelativeLayoutParams other) { + Transform = transform ?? throw new ArgumentNullException(nameof(transform)); + if (other != null) { + BottomEdge.CopyFrom(other.BottomEdge); + TopEdge.CopyFrom(other.TopEdge); + RightEdge.CopyFrom(other.RightEdge); + LeftEdge.CopyFrom(other.LeftEdge); + Insets = other.Insets ?? ZERO; + OverrideSize = other.OverrideSize; + } else + Insets = ZERO; + BottomParams = LeftParams = TopParams = RightParams = null; + PreferredWidth = PreferredHeight = 0.0f; + UseSizeDeltaX = UseSizeDeltaY = false; + } + + public override string ToString() { + var go = Transform.gameObject; + return string.Format("component={0} {1:F2}x{2:F2}", (go == null) ? "null" : go. + name, prefSize.x, prefSize.y); + } + } +} diff --git a/mod/PLibUI/Layouts/RelativeLayoutUtil.cs b/mod/PLibUI/Layouts/RelativeLayoutUtil.cs new file mode 100644 index 0000000..7a4490a --- /dev/null +++ b/mod/PLibUI/Layouts/RelativeLayoutUtil.cs @@ -0,0 +1,401 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using System.Collections.Generic; +using System.Text; +using UnityEngine; +using UnityEngine.UI; + +using EdgeStatus = PeterHan.PLib.UI.Layouts.RelativeLayoutParams.EdgeStatus; + +namespace PeterHan.PLib.UI.Layouts { + /// + /// A helper class for RelativeLayout. + /// + internal static class RelativeLayoutUtil { + /// + /// Initializes and computes horizontal sizes for the components in this relative + /// layout. + /// + /// The location to store information about these components. + /// The components to lay out. + /// The constraints defined for these components. + internal static void CalcX(this ICollection children, + RectTransform all, IDictionary constraints) { + var comps = ListPool.Allocate(); + var paramMap = DictionaryPool.Allocate(); + int n = all.childCount; + children.Clear(); + for (int i = 0; i < n; i++) { + var child = all.GetChild(i)?.gameObject; + if (child != null) { + comps.Clear(); + // Calculate the preferred size using all layout components + child.GetComponents(comps); + var horiz = PUIUtils.CalcSizes(child, PanelDirection.Horizontal, comps); + if (!horiz.ignore) { + RelativeLayoutResults ip; + float w = horiz.preferred; + if (constraints.TryGetValue(child, out RelativeLayoutParams cons)) { + ip = new RelativeLayoutResults(child.rectTransform(), cons); + // Set override size by axis if necessary + var overrideSize = ip.OverrideSize; + if (overrideSize.x > 0.0f) + w = overrideSize.x; + paramMap[child] = ip; + } else + // Default its layout to fill all + ip = new RelativeLayoutResults(child.rectTransform(), null); + ip.PreferredWidth = w; + children.Add(ip); + } + } + } + // Resolve object references to other children + foreach (var ip in children) { + ip.TopParams = InitResolve(ip.TopEdge, paramMap); + ip.BottomParams = InitResolve(ip.BottomEdge, paramMap); + ip.LeftParams = InitResolve(ip.LeftEdge, paramMap); + ip.RightParams = InitResolve(ip.RightEdge, paramMap); + // All of these will die simultaneously when the list is recycled + } + paramMap.Recycle(); + comps.Recycle(); + } + + /// + /// Computes vertical sizes for the components in this relative layout. + /// + /// The location to store information about these components. + internal static void CalcY(this ICollection children) { + var comps = ListPool.Allocate(); + foreach (var ip in children) { + var child = ip.Transform.gameObject; + var overrideSize = ip.OverrideSize; + comps.Clear(); + // Calculate the preferred size using all layout components + child.gameObject.GetComponents(comps); + float h = PUIUtils.CalcSizes(child, PanelDirection.Vertical, comps).preferred; + // Set override size by axis if necessary + if (overrideSize.y > 0.0f) + h = overrideSize.y; + ip.PreferredHeight = h; + } + comps.Recycle(); + } + + /// + /// Calculates the minimum size the component must be to support a specific child + /// component. + /// + /// The lower edge constraint. + /// The upper edge constraint. + /// The component size in that dimension plus margins. + /// The minimum parent component size to fit the child. + internal static float ElbowRoom(EdgeStatus min, EdgeStatus max, float effective) { + float aMin = min.FromAnchor, aMax = max.FromAnchor, result, offMin = min.Offset, + offMax = max.Offset; + if (aMax > aMin) + // "Elbow room" method + result = (effective + offMin - offMax) / (aMax - aMin); + else + // Anchors are together + result = Math.Max(effective, Math.Max(Math.Abs(offMin), Math.Abs(offMax))); + return result; + } + + /// + /// Executes the horizontal layout. + /// + /// The components to lay out. + /// The location where components will be temporarily stored. + /// The left margin. + /// The right margin. + internal static void ExecuteX(this IEnumerable children, + List scratch, float mLeft = 0.0f, float mRight = 0.0f) { + foreach (var child in children) { + var rt = child.Transform; + var insets = child.Insets; + EdgeStatus l = child.LeftEdge, r = child.RightEdge; + // Set corner positions + rt.anchorMin = new Vector2(l.FromAnchor, 0.0f); + rt.anchorMax = new Vector2(r.FromAnchor, 1.0f); + // If anchored by pivot, resize with current anchors + if (child.UseSizeDeltaX) + rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, child. + PreferredWidth); + else { + // Left + rt.offsetMin = new Vector2(l.Offset + insets.left + (l.FromAnchor <= 0.0f ? + mLeft : 0.0f), rt.offsetMin.y); + // Right + rt.offsetMax = new Vector2(r.Offset - insets.right - (r.FromAnchor >= + 1.0f ? mRight : 0.0f), rt.offsetMax.y); + } + // Execute layout controllers if present + scratch.Clear(); + rt.gameObject.GetComponents(scratch); + foreach (var component in scratch) + component.SetLayoutHorizontal(); + } + } + + /// + /// Executes the vertical layout. + /// + /// The components to lay out. + /// The location where components will be temporarily stored. + /// The bottom margin. + /// The top margin. + internal static void ExecuteY(this IEnumerable children, + List scratch, float mBottom = 0.0f, float mTop = 0.0f) { + foreach (var child in children) { + var rt = child.Transform; + var insets = child.Insets; + EdgeStatus t = child.TopEdge, b = child.BottomEdge; + // Set corner positions + rt.anchorMin = new Vector2(rt.anchorMin.x, b.FromAnchor); + rt.anchorMax = new Vector2(rt.anchorMax.x, t.FromAnchor); + // If anchored by pivot, resize with current anchors + if (child.UseSizeDeltaY) + rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, child. + PreferredHeight); + else { + // Bottom + rt.offsetMin = new Vector2(rt.offsetMin.x, b.Offset + insets.bottom + + (b.FromAnchor <= 0.0f ? mBottom : 0.0f)); + // Top + rt.offsetMax = new Vector2(rt.offsetMax.x, t.Offset - insets.top - + (t.FromAnchor >= 1.0f ? mTop : 0.0f)); + } + // Execute layout controllers if present + scratch.Clear(); + rt.gameObject.GetComponents(scratch); + foreach (var component in scratch) + component.SetLayoutVertical(); + } + } + + /// + /// Calculates the minimum size in the X direction. + /// + /// The components to lay out. + /// The minimum horizontal size. + internal static float GetMinSizeX(this IEnumerable children) { + float maxWidth = 0.0f; + foreach (var child in children) { + float width = ElbowRoom(child.LeftEdge, child.RightEdge, child.EffectiveWidth); + if (width > maxWidth) maxWidth = width; + } + return maxWidth; + } + + /// + /// Calculates the minimum size in the Y direction. + /// + /// The components to lay out. + /// The minimum vertical size. + internal static float GetMinSizeY(this IEnumerable children) { + float maxHeight = 0.0f; + foreach (var child in children) { + float height = ElbowRoom(child.BottomEdge, child.TopEdge, child. + EffectiveHeight); + if (height > maxHeight) maxHeight = height; + } + return maxHeight; + } + + /// + /// Resolves a component reference if needed. + /// + /// The edge to resolve. + /// The location where the component can be looked up. + /// The linked parameters for that edge if needed. + private static RelativeLayoutResults InitResolve(EdgeStatus edge, + IDictionary lookup) { + RelativeLayoutResults result = null; + if (edge.Constraint == RelativeConstraintType.ToComponent) + if (!lookup.TryGetValue(edge.FromComponent, out result)) + edge.Constraint = RelativeConstraintType.Unconstrained; + return result; + } + + /// + /// Locks both edges if they are constrained to the same anchor. + /// + /// The edge to check. + /// The other edge to check. + /// true if it was able to lock, or false otherwise. + private static bool LockEdgeAnchor(EdgeStatus edge, EdgeStatus otherEdge) { + bool useDelta = edge.Constraint == RelativeConstraintType.ToAnchor && otherEdge. + Constraint == RelativeConstraintType.ToAnchor && edge.FromAnchor == otherEdge. + FromAnchor; + if (useDelta) { + edge.Constraint = RelativeConstraintType.Locked; + otherEdge.Constraint = RelativeConstraintType.Locked; + edge.Offset = 0.0f; + otherEdge.Offset = 0.0f; + } + return useDelta; + } + + /// + /// Locks an edge if it is constrained to an anchor. + /// + /// The edge to check. + private static void LockEdgeAnchor(EdgeStatus edge) { + if (edge.Constraint == RelativeConstraintType.ToAnchor) { + edge.Constraint = RelativeConstraintType.Locked; + edge.Offset = 0.0f; + } + } + + /// + /// Locks an edge if it can be determined from another component. + /// + /// The edge to check. + /// The component's offset in that direction. + /// The opposing edge of the referenced component. + private static void LockEdgeComponent(EdgeStatus edge, EdgeStatus otherEdge) { + if (edge.Constraint == RelativeConstraintType.ToComponent && otherEdge.Locked) { + edge.Constraint = RelativeConstraintType.Locked; + edge.FromAnchor = otherEdge.FromAnchor; + edge.Offset = otherEdge.Offset; + } + } + + /// + /// Locks an edge if it can be determined from the other edge. + /// + /// The edge to check. + /// The component's effective size in that direction. + /// The component's other edge. + private static void LockEdgeRelative(EdgeStatus edge, float size, EdgeStatus opposing) + { + if (edge.Constraint == RelativeConstraintType.Unconstrained) { + if (opposing.Locked) { + edge.Constraint = RelativeConstraintType.Locked; + edge.FromAnchor = opposing.FromAnchor; + edge.Offset = opposing.Offset + size; + } else if (opposing.Constraint == RelativeConstraintType.Unconstrained) { + // Both unconstrained, full size + edge.Constraint = RelativeConstraintType.Locked; + edge.FromAnchor = 0.0f; + edge.Offset = 0.0f; + opposing.Constraint = RelativeConstraintType.Locked; + opposing.FromAnchor = 1.0f; + opposing.Offset = 0.0f; + } + } + } + + /// + /// Runs a layout pass in the X direction, resolving edges that can be resolved. + /// + /// The children to resolve. + /// true if all children have all X edges constrained, or false otherwise. + internal static bool RunPassX(this IEnumerable children) { + bool done = true; + foreach (var child in children) { + float width = child.EffectiveWidth; + EdgeStatus l = child.LeftEdge, r = child.RightEdge; + if (LockEdgeAnchor(l, r)) + child.UseSizeDeltaX = true; + LockEdgeAnchor(l); + LockEdgeAnchor(r); + LockEdgeRelative(l, -width, r); + LockEdgeRelative(r, width, l); + // Lock to other components + if (child.LeftParams != null) + LockEdgeComponent(l, child.LeftParams.RightEdge); + if (child.RightParams != null) + LockEdgeComponent(r, child.RightParams.LeftEdge); + if (!l.Locked || !r.Locked) done = false; + } + return done; + } + + /// + /// Runs a layout pass in the Y direction, resolving edges that can be resolved. + /// + /// The children to resolve. + /// true if all children have all Y edges constrained, or false otherwise. + internal static bool RunPassY(this IEnumerable children) { + bool done = true; + foreach (var child in children) { + float height = child.EffectiveHeight; + EdgeStatus t = child.TopEdge, b = child.BottomEdge; + if (LockEdgeAnchor(t, b)) + child.UseSizeDeltaY = true; + LockEdgeAnchor(b); + LockEdgeAnchor(t); + LockEdgeRelative(b, -height, t); + LockEdgeRelative(t, height, b); + // Lock to other components + if (child.BottomParams != null) + LockEdgeComponent(b, child.BottomParams.TopEdge); + if (child.TopParams != null) + LockEdgeComponent(t, child.TopParams.BottomEdge); + if (!t.Locked || !b.Locked) done = false; + } + return done; + } + + /// + /// Throws an error when resolution fails. + /// + /// The children, some of which failed to resolve. + /// The number of passes executed before failing. + /// The direction that failed. + internal static void ThrowUnresolvable(this IEnumerable children, + int limit, PanelDirection direction) { + var message = new StringBuilder(256); + message.Append("After ").Append(limit); + message.Append(" passes, unable to complete resolution of RelativeLayout:"); + foreach (var child in children) { + string name = child.Transform.gameObject.name; + if (direction == PanelDirection.Horizontal) { + if (!child.LeftEdge.Locked) { + message.Append(' '); + message.Append(name); + message.Append(".Left"); + } + if (!child.RightEdge.Locked) { + message.Append(' '); + message.Append(name); + message.Append(".Right"); + } + } else { + if (!child.BottomEdge.Locked) { + message.Append(' '); + message.Append(name); + message.Append(".Bottom"); + } + if (!child.TopEdge.Locked) { + message.Append(' '); + message.Append(name); + message.Append(".Top"); + } + } + } + throw new InvalidOperationException(message.ToString()); + } + } +} diff --git a/mod/PLibUI/PButton.cs b/mod/PLibUI/PButton.cs new file mode 100644 index 0000000..415a6f1 --- /dev/null +++ b/mod/PLibUI/PButton.cs @@ -0,0 +1,173 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// A custom UI button factory class. + /// + public class PButton : PTextComponent { + /// + /// The default margins around a button. + /// + internal static readonly RectOffset BUTTON_MARGIN = new RectOffset(7, 7, 5, 5); + + /// + /// Sets up the button to have the right sound and background image. + /// + /// The button to set up. + /// The background image. + internal static void SetupButton(KButton button, KImage bgImage) { + UIDetours.ADDITIONAL_K_IMAGES.Set(button, new KImage[0]); + UIDetours.SOUND_PLAYER_BUTTON.Set(button, PUITuning.ButtonSounds); + UIDetours.BG_IMAGE.Set(button, bgImage); + } + + /// + /// Sets up the background image to have the right sprite and slice type. + /// + /// The image that forms the button background. + internal static void SetupButtonBackground(KImage bgImage) { + UIDetours.APPLY_COLOR_STYLE.Invoke(bgImage); + bgImage.sprite = PUITuning.Images.ButtonBorder; + bgImage.type = Image.Type.Sliced; + } + + /// + /// Enables or disables a realized button. + /// + /// The realized button object. + /// true to make it enabled, or false to make it disabled (greyed out). + public static void SetButtonEnabled(GameObject obj, bool enabled) { + if (obj != null && obj.TryGetComponent(out KButton button)) + UIDetours.IS_INTERACTABLE.Set(button, enabled); + } + + /// + /// The button's background color. + /// + public ColorStyleSetting Color { get; set; } + + /// + /// The action to trigger on click. It is passed the realized source object. + /// + public PUIDelegates.OnButtonPressed OnClick { get; set; } + + public PButton() : this(null) { } + + public PButton(string name) : base(name ?? "Button") { + Margin = BUTTON_MARGIN; + Sprite = null; + Text = null; + ToolTip = ""; + } + + /// + /// Adds a handler when this button is realized. + /// + /// The handler to invoke on realization. + /// This button for call chaining. + public PButton AddOnRealize(PUIDelegates.OnRealize onRealize) { + OnRealize += onRealize; + return this; + } + + public override GameObject Build() { + var button = PUIElements.CreateUI(null, Name); + GameObject sprite = null, text = null; + // Background + var bgImage = button.AddComponent(); + var bgColorStyle = Color ?? PUITuning.Colors.ButtonPinkStyle; + UIDetours.COLOR_STYLE_SETTING.Set(bgImage, bgColorStyle); + SetupButtonBackground(bgImage); + // Set on click event + var kButton = button.AddComponent(); + var evt = OnClick; + if (evt != null) + // Detouring an Event is not worth the effort + kButton.onClick += () => evt?.Invoke(button); + SetupButton(kButton, bgImage); + // Add foreground image since the background already has one + if (Sprite != null) { + var fgImage = ImageChildHelper(button, this); + UIDetours.FG_IMAGE.Set(kButton, fgImage); + sprite = fgImage.gameObject; + } + // Add text + if (!string.IsNullOrEmpty(Text)) + text = TextChildHelper(button, TextStyle ?? PUITuning.Fonts.UILightStyle, + Text).gameObject; + // Add tooltip + PUIElements.SetToolTip(button, ToolTip).SetActive(true); + // Arrange the icon and text + var layout = button.AddComponent(); + layout.Margin = Margin; + GameObject inner; + ArrangeComponent(layout, inner = WrapTextAndSprite(text, sprite), TextAlignment); + if (!DynamicSize) layout.LockLayout(); + layout.flexibleWidth = FlexSize.x; + layout.flexibleHeight = FlexSize.y; + DestroyLayoutIfPossible(button); + InvokeRealize(button); + return button; + } + + /// + /// Sets the sprite to a leftward facing arrow. Beware the size, scale the button down! + /// + /// This button for call chaining. + public PButton SetImageLeftArrow() { + Sprite = PUITuning.Images.Arrow; + SpriteTransform = ImageTransform.FlipHorizontal; + return this; + } + + /// + /// Sets the sprite to a rightward facing arrow. Beware the size, scale the button + /// down! + /// + /// This button for call chaining. + public PButton SetImageRightArrow() { + Sprite = PUITuning.Images.Arrow; + SpriteTransform = ImageTransform.None; + return this; + } + + /// + /// Sets the default Klei pink button style as this button's color and text style. + /// + /// This button for call chaining. + public PButton SetKleiPinkStyle() { + TextStyle = PUITuning.Fonts.UILightStyle; + Color = PUITuning.Colors.ButtonPinkStyle; + return this; + } + + /// + /// Sets the default Klei blue button style as this button's color and text style. + /// + /// This button for call chaining. + public PButton SetKleiBlueStyle() { + TextStyle = PUITuning.Fonts.UILightStyle; + Color = PUITuning.Colors.ButtonBlueStyle; + return this; + } + } +} diff --git a/mod/PLibUI/PCheckBox.cs b/mod/PLibUI/PCheckBox.cs new file mode 100644 index 0000000..9972562 --- /dev/null +++ b/mod/PLibUI/PCheckBox.cs @@ -0,0 +1,270 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// A custom UI button check box factory class. + /// + public class PCheckBox : PTextComponent { + /// + /// The border size between the checkbox border and icon. + /// + private const float CHECKBOX_MARGIN = 2.0f; + + /// + /// The unchecked state. + /// + public const int STATE_UNCHECKED = 0; + + /// + /// The checked state. + /// + public const int STATE_CHECKED = 1; + + /// + /// The partially checked state. + /// + public const int STATE_PARTIAL = 2; + + /// + /// Generates the checkbox image states. + /// + /// The color style for the checked icon. + /// The states for this checkbox. + private static ToggleState[] GenerateStates(ColorStyleSetting imageColor) { + // Structs sadly do not work well with detours + var sps = new StatePresentationSetting() { + color = imageColor.activeColor, use_color_on_hover = true, + color_on_hover = imageColor.hoverColor, image_target = null, + name = "Partial" + }; + return new ToggleState[] { + new ToggleState() { + // Unchecked + color = PUITuning.Colors.Transparent, color_on_hover = PUITuning.Colors. + Transparent, sprite = null, use_color_on_hover = false, + additional_display_settings = new StatePresentationSetting[] { + new StatePresentationSetting() { + color = imageColor.activeColor, use_color_on_hover = false, + image_target = null, name = "Unchecked" + } + } + }, + new ToggleState() { + // Checked + color = imageColor.activeColor, color_on_hover = imageColor.hoverColor, + sprite = PUITuning.Images.Checked, use_color_on_hover = true, + additional_display_settings = new StatePresentationSetting[] { sps } + }, + new ToggleState() { + // Partial + color = imageColor.activeColor, color_on_hover = imageColor.hoverColor, + sprite = PUITuning.Images.Partial, use_color_on_hover = true, + additional_display_settings = new StatePresentationSetting[] { sps } + } + }; + } + + /// + /// Gets a realized check box's state. + /// + /// The realized check box. + /// The check box state. + public static int GetCheckState(GameObject realized) { + int state = STATE_UNCHECKED; + if (realized == null) + throw new ArgumentNullException(nameof(realized)); + var checkButton = realized.GetComponentInChildren(); + if (checkButton != null) + state = UIDetours.CURRENT_STATE.Get(checkButton); + return state; + } + + /// + /// Sets a realized check box's state. + /// + /// The realized check box. + /// The new state to set. + public static void SetCheckState(GameObject realized, int state) { + if (realized == null) + throw new ArgumentNullException(nameof(realized)); + var checkButton = realized.GetComponentInChildren(); + if (checkButton != null && UIDetours.CURRENT_STATE.Get(checkButton) != state) + UIDetours.CHANGE_STATE.Invoke(checkButton, state); + } + + /// + /// The check box color. + /// + public ColorStyleSetting CheckColor { get; set; } + + /// + /// The check box's background color. + /// + /// Unlike other components, this color applies only to the check box itself. + /// + public Color BackColor { get; set; } + + /// + /// The size to scale the check box. If 0x0, it will not be scaled. + /// + public Vector2 CheckSize { get; set; } + + /// + /// The background color of everything that is not the check box. + /// + public Color ComponentBackColor { get; set; } + + /// + /// The initial check box state. + /// + public int InitialState { get; set; } + + /// + /// The action to trigger on click. It is passed the realized source object. + /// + public PUIDelegates.OnChecked OnChecked { get; set; } + + public PCheckBox() : this(null) { } + + public PCheckBox(string name) : base(name ?? "CheckBox") { + BackColor = PUITuning.Colors.BackgroundLight; + CheckColor = null; + CheckSize = new Vector2(16.0f, 16.0f); + ComponentBackColor = PUITuning.Colors.Transparent; + IconSpacing = 3; + InitialState = STATE_UNCHECKED; + Sprite = null; + Text = null; + ToolTip = ""; + } + + /// + /// Adds a handler when this check box is realized. + /// + /// The handler to invoke on realization. + /// This check box for call chaining. + public PCheckBox AddOnRealize(PUIDelegates.OnRealize onRealize) { + OnRealize += onRealize; + return this; + } + + public override GameObject Build() { + var checkbox = PUIElements.CreateUI(null, Name); + var actualSize = CheckSize; + GameObject sprite = null, text = null; + // Background + if (ComponentBackColor.a > 0) + checkbox.AddComponent().color = ComponentBackColor; + var trueColor = CheckColor ?? PUITuning.Colors.ComponentLightStyle; + // Checkbox background + var checkBG = PUIElements.CreateUI(checkbox, "CheckBox"); + checkBG.AddComponent().color = BackColor; + var checkImage = CreateCheckImage(checkBG, trueColor, ref actualSize); + checkBG.SetUISize(new Vector2(actualSize.x + 2.0f * CHECKBOX_MARGIN, + actualSize.y + 2.0f * CHECKBOX_MARGIN), true); + // Add foreground image + if (Sprite != null) + sprite = ImageChildHelper(checkbox, this).gameObject; + // Add text + if (!string.IsNullOrEmpty(Text)) + text = TextChildHelper(checkbox, TextStyle ?? PUITuning.Fonts.UILightStyle, + Text).gameObject; + // Toggle + var mToggle = checkbox.AddComponent(); + var evt = OnChecked; + if (evt != null) + mToggle.onClick += () => evt?.Invoke(checkbox, mToggle.CurrentState); + UIDetours.PLAY_SOUND_CLICK.Set(mToggle, true); + UIDetours.PLAY_SOUND_RELEASE.Set(mToggle, false); + mToggle.states = GenerateStates(trueColor); + mToggle.toggle_image = checkImage; + UIDetours.CHANGE_STATE.Invoke(mToggle, InitialState); + PUIElements.SetToolTip(checkbox, ToolTip).SetActive(true); + // Faster than ever! + var subLabel = WrapTextAndSprite(text, sprite); + var layout = checkbox.AddComponent(); + layout.Margin = Margin; + ArrangeComponent(layout, WrapTextAndSprite(subLabel, checkBG), TextAlignment); + if (!DynamicSize) layout.LockLayout(); + layout.flexibleWidth = FlexSize.x; + layout.flexibleHeight = FlexSize.y; + DestroyLayoutIfPossible(checkbox); + InvokeRealize(checkbox); + return checkbox; + } + + /// + /// Creates the actual image that shows the checkbox graphically. + /// + /// The parent object to add the image. + /// The color style for the box border. + /// The actual check mark size, which will be updated if it + /// is 0x0 to the default size. + /// The image reference to the checkmark image itself. + private Image CreateCheckImage(GameObject checkbox, ColorStyleSetting color, + ref Vector2 actualSize) { + // Checkbox border (grr rule of only one Graphics per GO...) + var checkBorder = PUIElements.CreateUI(checkbox, "CheckBorder"); + var borderImg = checkBorder.AddComponent(); + borderImg.sprite = PUITuning.Images.CheckBorder; + borderImg.color = color.activeColor; + borderImg.type = Image.Type.Sliced; + // Checkbox foreground + var imageChild = PUIElements.CreateUI(checkbox, "CheckMark", true, PUIAnchoring. + Center, PUIAnchoring.Center); + var checkImage = imageChild.AddComponent(); + checkImage.sprite = PUITuning.Images.Checked; + checkImage.preserveAspect = true; + // Determine the checkbox size + if (actualSize.x <= 0.0f || actualSize.y <= 0.0f) { + var rt = imageChild.rectTransform(); + actualSize.x = LayoutUtility.GetPreferredWidth(rt); + actualSize.y = LayoutUtility.GetPreferredHeight(rt); + } + imageChild.SetUISize(CheckSize, false); + return checkImage; + } + + /// + /// Sets the default Klei pink button style as this check box's color and text style. + /// + /// This check box for call chaining. + public PCheckBox SetKleiPinkStyle() { + TextStyle = PUITuning.Fonts.UILightStyle; + BackColor = PUITuning.Colors.ButtonPinkStyle.inactiveColor; + CheckColor = PUITuning.Colors.ComponentDarkStyle; + return this; + } + + /// + /// Sets the default Klei blue button style as this check box's color and text style. + /// + /// This check box for call chaining. + public PCheckBox SetKleiBlueStyle() { + TextStyle = PUITuning.Fonts.UILightStyle; + BackColor = PUITuning.Colors.ButtonBlueStyle.inactiveColor; + CheckColor = PUITuning.Colors.ComponentDarkStyle; + return this; + } + } +} diff --git a/mod/PLibUI/PComboBox.cs b/mod/PLibUI/PComboBox.cs new file mode 100644 index 0000000..362a1b2 --- /dev/null +++ b/mod/PLibUI/PComboBox.cs @@ -0,0 +1,315 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// A custom UI combo box factory class. + /// + public sealed class PComboBox : IUIComponent, IDynamicSizable where T : class, + IListableOption { + /// + /// The default margin around items in the pulldown. + /// + private static readonly RectOffset DEFAULT_ITEM_MARGIN = new RectOffset(3, 3, 3, 3); + + /// + /// Sets the selected option in a realized combo box. + /// + /// The realized combo box. + /// The option to set. + /// true to fire the on select listener, or false otherwise. + public static void SetSelectedItem(GameObject realized, IListableOption option, + bool fireListener = false) { + if (option != null && realized != null && realized.TryGetComponent( + out PComboBoxComponent component)) + component.SetSelectedItem(option, fireListener); + } + + /// + /// The size of the sprite used to expand/contract the options. + /// + public Vector2 ArrowSize { get; set; } + + /// + /// The combo box's background color. + /// + public ColorStyleSetting BackColor { get; set; } + + /// + /// The size of the check mark sprite used on the selected option. + /// + public Vector2 CheckSize { get; set; } + + /// + /// The content of this combo box. + /// + public IEnumerable Content { get; set; } + + public bool DynamicSize { get; set; } + + /// + /// The background color for each entry in the combo box pulldown. + /// + public ColorStyleSetting EntryColor { get; set; } + + /// + /// The flexible size bounds of this combo box. + /// + public Vector2 FlexSize { get; set; } + + /// + /// The initially selected item of the combo box. + /// + public T InitialItem { get; set; } + + /// + /// The margin around each item in the pulldown. + /// + public RectOffset ItemMargin { get; set; } + + /// + /// The margin around the component. + /// + public RectOffset Margin { get; set; } + + /// + /// The maximum number of items to be shown at once before a scroll bar is added. + /// + public int MaxRowsShown { get; set; } + + /// + /// The minimum width in units (not characters!) of this text field. + /// + public int MinWidth { get; set; } + + public string Name { get; } + + /// + /// The action to trigger when an item is selected. It is passed the realized source + /// object. + /// + public PUIDelegates.OnDropdownChanged OnOptionSelected { get; set; } + + public event PUIDelegates.OnRealize OnRealize; + + /// + /// The text alignment in the combo box. + /// + public TextAnchor TextAlignment { get; set; } + + /// + /// The combo box's text color, font, word wrap settings, and font size. + /// + public TextStyleSetting TextStyle { get; set; } + + /// + /// The tool tip text. + /// + public string ToolTip { get; set; } + + public PComboBox() : this("Dropdown") { } + + public PComboBox(string name) { + ArrowSize = new Vector2(8.0f, 8.0f); + BackColor = null; + CheckSize = new Vector2(12.0f, 12.0f); + Content = null; + DynamicSize = false; + FlexSize = Vector2.zero; + InitialItem = null; + ItemMargin = DEFAULT_ITEM_MARGIN; + Margin = PButton.BUTTON_MARGIN; + MaxRowsShown = 6; + MinWidth = 0; + Name = name; + TextAlignment = TextAnchor.MiddleLeft; + TextStyle = null; + ToolTip = null; + } + + /// + /// Adds a handler when this combo box is realized. + /// + /// The handler to invoke on realization. + /// This combo box for call chaining. + public PComboBox AddOnRealize(PUIDelegates.OnRealize onRealize) { + OnRealize += onRealize; + return this; + } + + public GameObject Build() { + var combo = PUIElements.CreateUI(null, Name); + var style = TextStyle ?? PUITuning.Fonts.UILightStyle; + var entryColor = EntryColor ?? PUITuning.Colors.ButtonBlueStyle; + RectOffset margin = Margin, im = ItemMargin; + // Background color + var bgImage = combo.AddComponent(); + var backColorStyle = BackColor ?? PUITuning.Colors.ButtonBlueStyle; + UIDetours.COLOR_STYLE_SETTING.Set(bgImage, backColorStyle); + PButton.SetupButtonBackground(bgImage); + // Need a LocText (selected item) + var selection = PUIElements.CreateUI(combo, "SelectedItem"); + if (MinWidth > 0) + selection.SetMinUISize(new Vector2(MinWidth, 0.0f)); + var selectedLabel = PUIElements.AddLocText(selection, style); + // Vertical flow panel with the choices + var contentContainer = PUIElements.CreateUI(null, "Content"); + contentContainer.AddComponent().childForceExpandWidth = true; + // Scroll pane with items is laid out below everything else + var pullDown = new PScrollPane("PullDown") { + ScrollHorizontal = false, ScrollVertical = true, AlwaysShowVertical = true, + FlexSize = Vector2.right, TrackSize = 8.0f, BackColor = entryColor. + inactiveColor + }.BuildScrollPane(combo, contentContainer); + // Add a black border (Does not work, covered by the combo box buttons...) +#if false + var pdImage = pullDown.GetComponent(); + pdImage.sprite = PUITuning.Images.BoxBorder; + pdImage.type = Image.Type.Sliced; +#endif + pullDown.rectTransform().pivot = new Vector2(0.5f, 1.0f); + // Initialize the drop down + var comboBox = combo.AddComponent(); + comboBox.CheckColor = style.textColor; + comboBox.ContentContainer = contentContainer.rectTransform(); + comboBox.EntryPrefab = BuildRowPrefab(style, entryColor); + comboBox.MaxRowsShown = MaxRowsShown; + comboBox.Pulldown = pullDown; + comboBox.SelectedLabel = selectedLabel; + comboBox.SetItems(Content); + comboBox.SetSelectedItem(InitialItem); + comboBox.OnSelectionChanged = (obj, item) => OnOptionSelected?.Invoke(obj. + gameObject, item as T); + // Inner component with the pulldown image + var image = PUIElements.CreateUI(combo, "OpenImage"); + var icon = image.AddComponent(); + icon.sprite = PUITuning.Images.Contract; + icon.color = style.textColor; + // Button component + var dropButton = combo.AddComponent(); + PButton.SetupButton(dropButton, bgImage); + UIDetours.FG_IMAGE.Set(dropButton, icon); + dropButton.onClick += comboBox.OnClick; + // Add tooltip + PUIElements.SetToolTip(selection, ToolTip); + combo.SetActive(true); + // Button gets laid out on the right, rest of space goes to the label + // Scroll pane is laid out on the bottom + var layout = combo.AddComponent(); + layout.AnchorYAxis(selection).SetLeftEdge(selection, fraction: + 0.0f).SetRightEdge(selection, toLeft: image).AnchorYAxis(image).SetRightEdge( + image, fraction: 1.0f).SetMargin(selection, new RectOffset(margin.left, + im.right, margin.top, margin.bottom)).SetMargin(image, new RectOffset(0, + margin.right, margin.top, margin.bottom)).OverrideSize(image, ArrowSize). + AnchorYAxis(pullDown, 0.0f).OverrideSize(pullDown, Vector2.up); + layout.LockLayout(); + if (DynamicSize) + layout.UnlockLayout(); + // Disable sizing on the pulldown + pullDown.AddOrGet().ignoreLayout = true; + // Scroll pane is hidden right away + pullDown.SetActive(false); + layout.flexibleWidth = FlexSize.x; + layout.flexibleHeight = FlexSize.y; + OnRealize?.Invoke(combo); + return combo; + } + + /// + /// Builds a row selection prefab object for this combo box. + /// + /// The text style for the entries. + /// The color for the entry backgrounds. + /// A template for each row in the dropdown. + private GameObject BuildRowPrefab(TextStyleSetting style, ColorStyleSetting entryColor) + { + var im = ItemMargin; + var rowPrefab = PUIElements.CreateUI(null, "RowEntry"); + // Background of the entry + var bgImage = rowPrefab.AddComponent(); + UIDetours.COLOR_STYLE_SETTING.Set(bgImage, entryColor); + UIDetours.APPLY_COLOR_STYLE.Invoke(bgImage); + // Checkmark for the front of the entry + var isSelected = PUIElements.CreateUI(rowPrefab, "Selected"); + var fgImage = isSelected.AddComponent(); + fgImage.color = style.textColor; + fgImage.preserveAspect = true; + fgImage.sprite = PUITuning.Images.Checked; + // Button for the entry to select it + var entryButton = rowPrefab.AddComponent(); + PButton.SetupButton(entryButton, bgImage); + UIDetours.FG_IMAGE.Set(entryButton, fgImage); + // Tooltip for the entry + rowPrefab.AddComponent(); + // Text for the entry + var textContainer = PUIElements.CreateUI(rowPrefab, "Text"); + PUIElements.AddLocText(textContainer, style).SetText(" "); + // Configure the entire layout in 1 statement! (jk this is awful) + var group = rowPrefab.AddComponent(); + group.AnchorYAxis(isSelected).OverrideSize(isSelected, CheckSize).SetLeftEdge( + isSelected, fraction: 0.0f).SetMargin(isSelected, im).AnchorYAxis( + textContainer).SetLeftEdge(textContainer, toRight: isSelected).SetRightEdge( + textContainer, 1.0f).SetMargin(textContainer, new RectOffset(0, im.right, + im.top, im.bottom)).LockLayout(); + rowPrefab.SetActive(false); + return rowPrefab; + } + + /// + /// Sets the default Klei pink button style as this combo box's foreground color and text style. + /// + /// This combo box for call chaining. + public PComboBox SetKleiPinkStyle() { + TextStyle = PUITuning.Fonts.UILightStyle; + BackColor = PUITuning.Colors.ButtonPinkStyle; + return this; + } + + /// + /// Sets the default Klei blue button style as this combo box's foreground color and text style. + /// + /// This combo box for call chaining. + public PComboBox SetKleiBlueStyle() { + TextStyle = PUITuning.Fonts.UILightStyle; + BackColor = PUITuning.Colors.ButtonBlueStyle; + return this; + } + + /// + /// Sets the minimum (and preferred) width of this combo box in characters. + /// + /// The width is computed using the currently selected text style. + /// + /// The number of characters to be displayed. + /// This combo box for call chaining. + public PComboBox SetMinWidthInCharacters(int chars) { + int width = Mathf.RoundToInt(chars * PUIUtils.GetEmWidth(TextStyle)); + if (width > 0) + MinWidth = width; + return this; + } + + public override string ToString() { + return string.Format("PComboBox[Name={0}]", Name); + } + } +} diff --git a/mod/PLibUI/PComboBoxComponent.cs b/mod/PLibUI/PComboBoxComponent.cs new file mode 100644 index 0000000..e1e8bda --- /dev/null +++ b/mod/PLibUI/PComboBoxComponent.cs @@ -0,0 +1,260 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using System.Collections.Generic; +using TMPro; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// An improved variant of DropDown/Dropdown. + /// + internal sealed class PComboBoxComponent : KMonoBehaviour { + /// + /// The container where the combo box items will be placed. + /// + internal RectTransform ContentContainer { get; set; } + + /// + /// The color for the checkbox if it is selected. + /// + internal Color CheckColor { get; set; } + + /// + /// The prefab used to display each row. + /// + internal GameObject EntryPrefab { get; set; } + + /// + /// The maximum number of rows to be shown. + /// + internal int MaxRowsShown { get; set; } + + /// + /// Called when an item is selected. + /// + internal Action OnSelectionChanged { get; set; } + + /// + /// The object which contains the pull down section of the combo box. + /// + internal GameObject Pulldown { get; set; } + + /// + /// The selected label. + /// + internal TMP_Text SelectedLabel { get; set; } + + /// + /// The items which are currently shown in this combo box. + /// + private readonly IList currentItems; + + /// + /// The currently active mouse event handler, or null if not yet configured. + /// + private MouseEventHandler handler; + + /// + /// Whether the combo box is expanded. + /// + private bool open; + + internal PComboBoxComponent() { + CheckColor = Color.white; + currentItems = new List(32); + handler = null; + MaxRowsShown = 8; + open = false; + } + + /// + /// Closes the pulldown. The selected choice is not saved. + /// + public void Close() { + Pulldown?.SetActive(false); + open = false; + } + + /// + /// Triggered when the combo box is clicked. + /// + public void OnClick() { + if (open) + Close(); + else + Open(); + } + + protected override void OnPrefabInit() { + base.OnPrefabInit(); + handler = gameObject.AddOrGet(); + } + + /// + /// Opens the pulldown. + /// + public void Open() { + var pdn = Pulldown; + if (pdn != null) { + float rowHeight = 0.0f; + int itemCount = currentItems.Count, rows = Math.Min(MaxRowsShown, itemCount); + var canvas = pdn.AddOrGet(); + pdn.SetActive(true); + // Calculate desired height of scroll pane + if (itemCount > 0) { + var rt = currentItems[0].rowInstance.rectTransform(); + LayoutRebuilder.ForceRebuildLayoutImmediate(rt); + rowHeight = LayoutUtility.GetPreferredHeight(rt); + } + // Update size and enable/disable scrolling if not needed + pdn.rectTransform().SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, + rows * rowHeight); + if (pdn.TryGetComponent(out ScrollRect sp)) + sp.vertical = itemCount >= MaxRowsShown; + // Move above normal UI elements but below the tooltips + if (canvas != null) { + canvas.overrideSorting = true; + canvas.sortingOrder = 2; + } + } + open = true; + } + + /// + /// Sets the items which will be shown in this combo box. + /// + /// The items to show. + public void SetItems(IEnumerable items) { + if (items != null) { + var content = ContentContainer; + var pdn = Pulldown; + int n = content.childCount; + var prefab = EntryPrefab; + bool wasOpen = open; + if (wasOpen) + Close(); + // Destroy only destroys it at end of frame! + for (int i = 0; i < n; i++) + Destroy(content.GetChild(i)); + currentItems.Clear(); + foreach (var item in items) { + string tooltip = ""; + var rowInstance = Util.KInstantiate(prefab, content.gameObject); + // Update the text shown + rowInstance.GetComponentInChildren().SetText(item. + GetProperName()); + // Apply the listener for the button + var button = rowInstance.GetComponentInChildren(); + button.ClearOnClick(); + button.onClick += () => { + SetSelectedItem(item, true); + Close(); + }; + // Assign the tooltip if possible + if (item is ITooltipListableOption extended) + tooltip = extended.GetToolTipText(); + if (rowInstance.TryGetComponent(out ToolTip tt)) { + if (string.IsNullOrEmpty(tooltip)) + tt.ClearMultiStringTooltip(); + else + tt.SetSimpleTooltip(tooltip); + } + rowInstance.SetActive(true); + currentItems.Add(new ComboBoxItem(item, rowInstance)); + } + SelectedLabel?.SetText((currentItems.Count > 0) ? currentItems[0].data. + GetProperName() : ""); + if (wasOpen) + Open(); + } + } + + /// + /// Sets the selected item in the combo box. + /// + /// The option that was chosen. + /// true to also fire the option selected listener, or false otherwise. + public void SetSelectedItem(IListableOption option, bool fireListener = false) { + if (option != null) { + SelectedLabel?.SetText(option.GetProperName()); + // No guarantee that the options are hashable + foreach (var item in currentItems) { + var data = item.data; + // Show or hide the check mark next to the selected option + item.rowImage.color = (data != null && data.Equals(option)) ? CheckColor : + PUITuning.Colors.Transparent; + } + if (fireListener) + OnSelectionChanged?.Invoke(this, option); + } + } + + /// + /// Called each frame by Unity, checks to see if the user clicks/scrolls outside of + /// the dropdown while open, and closes it if so. + /// + internal void Update() { + if (open && handler != null && !handler.IsOver && (Input.GetMouseButton(0) || + Input.GetAxis("Mouse ScrollWheel") != 0.0f)) + Close(); + } + + /// + /// The items in a combo box, paired with the game object owning that row and the + /// object that goes there. + /// + private struct ComboBoxItem { + public readonly IListableOption data; + public readonly Image rowImage; + public readonly GameObject rowInstance; + + public ComboBoxItem(IListableOption data, GameObject rowInstance) { + this.data = data; + this.rowInstance = rowInstance; + rowImage = rowInstance.GetComponentInChildrenOnly(); + } + } + + /// + /// Handles mouse events on the pulldown. + /// + private sealed class MouseEventHandler : MonoBehaviour, IPointerEnterHandler, + IPointerExitHandler { + /// + /// Whether the mouse is over this component. + /// + public bool IsOver { get; private set; } + + internal MouseEventHandler() { + IsOver = true; + } + + public void OnPointerEnter(PointerEventData data) { + IsOver = true; + } + + public void OnPointerExit(PointerEventData data) { + IsOver = false; + } + } + } +} diff --git a/mod/PLibUI/PContainer.cs b/mod/PLibUI/PContainer.cs new file mode 100644 index 0000000..e1484b3 --- /dev/null +++ b/mod/PLibUI/PContainer.cs @@ -0,0 +1,99 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// The abstract parent of PLib UI objects that are meant to contain other UI objects. + /// + public abstract class PContainer : IUIComponent { + /// + /// The background color of this panel. + /// + public Color BackColor { get; set; } + + /// + /// The background image of this panel. Tinted by the background color, acts as all + /// white if left null. + /// + /// Note that the default background color is transparent, so unless it is set to + /// some other color this image will be invisible! + /// + public Sprite BackImage { get; set; } + + /// + /// The flexible size bounds of this component. + /// + public Vector2 FlexSize { get; set; } + + /// + /// The mode to use when displaying the background image. + /// + public Image.Type ImageMode { get; set; } + + /// + /// The margin left around the contained components in pixels. If null, no margin will + /// be used. + /// + public RectOffset Margin { get; set; } + + public string Name { get; protected set; } + + public event PUIDelegates.OnRealize OnRealize; + + protected PContainer(string name) { + BackColor = PUITuning.Colors.Transparent; + BackImage = null; + FlexSize = Vector2.zero; + ImageMode = Image.Type.Simple; + Margin = null; + Name = name ?? "Container"; + } + + public abstract GameObject Build(); + + /// + /// Invokes the OnRealize event. + /// + /// The realized text component. + protected void InvokeRealize(GameObject obj) { + OnRealize?.Invoke(obj); + } + + /// + /// Configures the background color and/or image for this panel. + /// + /// The realized panel object. + protected void SetImage(GameObject panel) { + if (BackColor.a > 0.0f || BackImage != null) { + var img = panel.AddComponent(); + img.color = BackColor; + if (BackImage != null) { + img.sprite = BackImage; + img.type = ImageMode; + } + } + } + + public override string ToString() { + return string.Format("PContainer[Name={0}]", Name); + } + } +} diff --git a/mod/PLibUI/PDialog.cs b/mod/PLibUI/PDialog.cs new file mode 100644 index 0000000..8eeff13 --- /dev/null +++ b/mod/PLibUI/PDialog.cs @@ -0,0 +1,434 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// A dialog root for UI components. + /// + public sealed class PDialog : IUIComponent { + /// + /// The margin around dialog buttons. + /// + public static readonly RectOffset BUTTON_MARGIN = new RectOffset(13, 13, 13, 13); + + /// + /// The margin inside the dialog close button. + /// + private static readonly RectOffset CLOSE_ICON_MARGIN = new RectOffset(4, 4, 4, 4); + + /// + /// The size of the dialog close button's icon. + /// + private static readonly Vector2 CLOSE_ICON_SIZE = new Vector2f(16.0f, 16.0f); + + /// + /// The dialog key returned if the user closes the dialog with [ESC] or the X. + /// + public const string DIALOG_KEY_CLOSE = "close"; + + /// + /// Returns a suitable parent object for a dialog. + /// + /// A game object that can be used as a dialog parent depending on the game + /// stage, or null if none is available. + public static GameObject GetParentObject() { + GameObject parent = null; + var fi = FrontEndManager.Instance; + if (fi != null) + parent = fi.gameObject; + else { + // Grr unity + var gi = GameScreenManager.Instance; + if (gi != null) + parent = gi.ssOverlayCanvas; + else + PUIUtils.LogUIWarning("No dialog parent found!"); + } + return parent; + } + + /// + /// Rounds the size up to the nearest even integer. + /// + /// The current size. + /// The maximum allowed size. + /// The rounded size. + private static float RoundUpSize(float size, float maxSize) { + int upOne = Mathf.CeilToInt(size); + if (upOne % 2 == 1) upOne++; + if (upOne > maxSize && maxSize > 0.0f) + upOne -= 2; + return upOne; + } + + /// + /// The dialog body panel. To add custom components to the dialog, use AddChild on + /// this panel. Its direction, margin, and spacing can also be customized. + /// + public PPanel Body { get; } + + /// + /// The background color of the dialog itself (including button panel). + /// + public Color DialogBackColor { get; set; } + + /// + /// The dialog's maximum size. If the dialog preferred size is bigger than this size, + /// the dialog will be decreased in size to fit. If either axis is zero, the dialog + /// gets its preferred size in that axis, at least the value in Size. + /// + public Vector2 MaxSize { get; set; } + + public string Name { get; } + + /// + /// The dialog's parent. + /// + public GameObject Parent { get; set; } + + /// + /// If a dialog with an odd width/height is displayed, all offsets will end up on a + /// half pixel offset, which may cause unusual display artifacts as Banker's Rounding + /// will round values that are supposed to be 1.0 units apart into integer values 2 + /// units apart. If set, this flag will cause Build to round the dialog's size up to + /// the nearest even integer. If the dialog is already at its maximum size and is still + /// an odd integer in size, it is rounded down one instead. + /// + public bool RoundToNearestEven { get; set; } + + /// + /// The dialog's minimum size. If the dialog preferred size is bigger than this size, + /// the dialog will be increased in size to fit. If either axis is zero, the dialog + /// gets its preferred size in that axis, up until the value in MaxSize. + /// + public Vector2 Size { get; set; } + + /// + /// The dialog sort order which determines which other dialogs this one is on top of. + /// + public float SortKey { get; set; } + + /// + /// The dialog's title. + /// + public string Title { get; set; } + + /// + /// The allowable button choices for the dialog. + /// + private readonly ICollection buttons; + + /// + /// The events to invoke when the dialog is closed. + /// + public PUIDelegates.OnDialogClosed DialogClosed { get; set; } + + public event PUIDelegates.OnRealize OnRealize; + + public PDialog(string name) { + Body = new PPanel("Body") { + Alignment = TextAnchor.UpperCenter, FlexSize = Vector2.one, + Margin = new RectOffset(6, 6, 6, 6) + }; + DialogBackColor = PUITuning.Colors.ButtonBlueStyle.inactiveColor; + buttons = new List(4); + MaxSize = Vector2.zero; + Name = name ?? "Dialog"; + // First try the front end manager (menu), then the in-game (game) + Parent = GetParentObject(); + RoundToNearestEven = false; + Size = Vector2.zero; + SortKey = 0.0f; + Title = "Dialog"; + } + + /// + /// Adds a button to the dialog. The button will use a blue background with white text + /// in the default UI font, except for the last button which will be pink. + /// + /// The key to report if this button is selected. + /// The button text. + /// The tooltip to display on the button (optional) + /// This dialog for call chaining. + public PDialog AddButton(string key, string text, string tooltip = null) { + buttons.Add(new DialogButton(key, text, tooltip, null, null)); + return this; + } + + /// + /// Adds a button to the dialog. + /// + /// The key to report if this button is selected. + /// The button text. + /// The tooltip to display on the button (optional) + /// The background color to use for the button. If null or + /// omitted, the last button will be pink and all others will be blue. + /// The foreground color to use for the button. If null or + /// omitted, white text with the default game UI font will be used. + /// This dialog for call chaining. + public PDialog AddButton(string key, string text, string tooltip = null, + ColorStyleSetting backColor = null, TextStyleSetting foreColor = null) { + buttons.Add(new DialogButton(key, text, tooltip, backColor, foreColor)); + return this; + } + + /// + /// Adds a handler when this dialog is realized. + /// + /// The handler to invoke on realization. + /// This dialog for call chaining. + public PDialog AddOnRealize(PUIDelegates.OnRealize onRealize) { + OnRealize += onRealize; + return this; + } + + public GameObject Build() { + if (Parent == null) + throw new InvalidOperationException("Parent for dialog may not be null"); + var dialog = PUIElements.CreateUI(Parent, Name); + var dComponent = dialog.AddComponent(); + // Background + dialog.AddComponent(); + dialog.AddComponent(); + var bg = dialog.AddComponent(); + bg.color = DialogBackColor; + bg.sprite = PUITuning.Images.BoxBorder; + bg.type = Image.Type.Sliced; + // Add each component + var layout = dialog.AddComponent(); + layout.AddRow(new GridRowSpec()); + layout.AddRow(new GridRowSpec(flex: 1.0f)); + layout.AddRow(new GridRowSpec()); + layout.AddColumn(new GridColumnSpec(flex: 1.0f)); + layout.AddColumn(new GridColumnSpec()); + LayoutTitle(layout, dComponent.DoButton); + layout.AddComponent(Body.Build(), new GridComponentSpec(1, 0) { + ColumnSpan = 2, Margin = new RectOffset(10, 10, 10, 10) + }); + CreateUserButtons(layout, dComponent.DoButton); + // Configure body position + SetDialogSize(dialog); + // Dialog is realized + dComponent.dialog = this; + dComponent.sortKey = SortKey; + OnRealize?.Invoke(dialog); + return dialog; + } + + /// + /// Creates the user buttons. + /// + /// The location to add the buttons. + /// The handler to call when any button is pressed. + private void CreateUserButtons(PGridLayoutGroup layout, + PUIDelegates.OnButtonPressed onPressed) { + var buttonPanel = new PPanel("Buttons") { + Alignment = TextAnchor.LowerCenter, Spacing = 7, Direction = PanelDirection. + Horizontal, Margin = new RectOffset(5, 5, 0, 10) + }; + int i = 0; + // Add each user button + foreach (var button in buttons) { + string key = button.key; + var bgColor = button.backColor; + var fgColor = button.textColor ?? PUITuning.Fonts.UILightStyle; + var db = new PButton(key) { + Text = button.text, ToolTip = button.tooltip, Margin = BUTTON_MARGIN, + OnClick = onPressed, Color = bgColor, TextStyle = fgColor + }; + // Last button is special and gets a pink color + if (bgColor == null) { + if (++i >= buttons.Count) + db.SetKleiPinkStyle(); + else + db.SetKleiBlueStyle(); + } + buttonPanel.AddChild(db); + } + layout.AddComponent(buttonPanel.Build(), new GridComponentSpec(2, 0) { + ColumnSpan = 2 + }); + } + + /// + /// Lays out the dialog title bar and close button. + /// + /// The layout manager for the dialog. + /// The action to invoke when close is pressed. + private void LayoutTitle(PGridLayoutGroup layout, PUIDelegates.OnButtonPressed + onClose) { + // Title text, expand to width + var title = new PLabel("Title") { + Margin = new RectOffset(3, 4, 0, 0), Text = Title, FlexSize = Vector2.one + }.SetKleiPinkColor().Build(); + layout.AddComponent(title, new GridComponentSpec(0, 0) { + Margin = new RectOffset(0, -2, 0, 0) + }); + // Black border on the title bar overlaps the edge, but 0 margins so should be OK + // Fixes the 2px bug handily! + var titleBG = title.AddOrGet(); + titleBG.sprite = PUITuning.Images.BoxBorder; + titleBG.type = Image.Type.Sliced; + // Close button + layout.AddComponent(new PButton(DIALOG_KEY_CLOSE) { + Sprite = PUITuning.Images.Close, Margin = CLOSE_ICON_MARGIN, OnClick = onClose, + SpriteSize = CLOSE_ICON_SIZE, ToolTip = STRINGS.UI.TOOLTIPS.CLOSETOOLTIP + }.SetKleiBlueStyle().Build(), new GridComponentSpec(0, 1)); + } + + /// + /// Sets the final size of the dialog using its current position. + /// + /// The realized dialog with all components populated. + private void SetDialogSize(GameObject dialog) { + // Calculate the final dialog size + var dialogRT = dialog.rectTransform(); + LayoutRebuilder.ForceRebuildLayoutImmediate(dialogRT); + float bodyWidth = Math.Max(Size.x, LayoutUtility.GetPreferredWidth(dialogRT)), + bodyHeight = Math.Max(Size.y, LayoutUtility.GetPreferredHeight(dialogRT)), + maxX = MaxSize.x, maxY = MaxSize.y; + // Maximum size constraint + if (maxX > 0.0f) + bodyWidth = Math.Min(bodyWidth, maxX); + if (maxY > 0.0f) + bodyHeight = Math.Min(bodyHeight, maxY); + if (RoundToNearestEven) { + // Round up the size to even integers, even if currently fractional + bodyWidth = RoundUpSize(bodyWidth, maxX); + bodyHeight = RoundUpSize(bodyHeight, maxY); + } + dialogRT.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, bodyWidth); + dialogRT.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, bodyHeight); + } + + /// + /// Builds and shows this dialog. + /// + public void Show() { + if (Build().TryGetComponent(out KScreen screen)) + UIDetours.ACTIVATE_KSCREEN.Invoke(screen); + } + + public override string ToString() { + return string.Format("PDialog[Name={0},Title={1}]", Name, Title); + } + + /// + /// Stores information about a dialog button in this dialog. + /// + private sealed class DialogButton { + /// + /// The color to use when displaying the button. If null, the default color will + /// be used. + /// + public readonly ColorStyleSetting backColor; + + /// + /// The button key used to indicate that it was selected. + /// + public readonly string key; + + /// + /// The text to display for the button. + /// + public readonly string text; + + /// + /// The color to use when displaying the button text. If null, the default color + /// will be used. + /// + public readonly TextStyleSetting textColor; + + /// + /// The tooltip for this button. + /// + public readonly string tooltip; + + internal DialogButton(string key, string text, string tooltip, + ColorStyleSetting backColor, TextStyleSetting foreColor) { + this.backColor = backColor; + this.key = key; + this.text = text; + this.tooltip = tooltip; + textColor = foreColor; + } + + public override string ToString() { + return string.Format("DialogButton[key={0:D},text={1:D}]", key, text); + } + } + + /// + /// The Klei component which backs the dialog. + /// + private sealed class PDialogComp : KScreen { + /// + /// The events to invoke when the dialog is closed. + /// + internal PDialog dialog; + + /// + /// The key selected by the user. + /// + internal string key; + + /// + /// The sort order of this dialog. + /// + internal float sortKey; + + internal PDialogComp() { + key = DIALOG_KEY_CLOSE; + sortKey = 0.0f; + } + + /// + /// A delegate which closes the dialog on prompt. + /// + /// The button source. + internal void DoButton(GameObject source) { + key = source.name; + UIDetours.DEACTIVATE_KSCREEN.Invoke(this); + } + + public override float GetSortKey() { + return sortKey; + } + + protected override void OnDeactivate() { + if (dialog != null) + // Klei destroys the dialog GameObject for us + dialog.DialogClosed?.Invoke(key); + base.OnDeactivate(); + dialog = null; + } + + public override void OnKeyDown(KButtonEvent e) { + if (e.TryConsume(Action.Escape)) + UIDetours.DEACTIVATE_KSCREEN.Invoke(this); + else + base.OnKeyDown(e); + } + } + } +} diff --git a/mod/PLibUI/PGridPanel.cs b/mod/PLibUI/PGridPanel.cs new file mode 100644 index 0000000..1497779 --- /dev/null +++ b/mod/PLibUI/PGridPanel.cs @@ -0,0 +1,144 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace PeterHan.PLib.UI { + /// + /// A panel which lays out its components using grid-type constraints. + /// + public class PGridPanel : PContainer, IDynamicSizable { + /// + /// The number of columns currently defined. + /// + public int Columns => columns.Count; + + public bool DynamicSize { get; set; } + + /// + /// The number of rows currently defined. + /// + public int Rows => rows.Count; + + /// + /// The children of this panel. + /// + private readonly ICollection> children; + + /// + /// The columns in this panel. + /// + private readonly IList columns; + + /// + /// The rows in this panel. + /// + private readonly IList rows; + + public PGridPanel() : this(null) { } + + public PGridPanel(string name) : base(name ?? "GridPanel") { + children = new List>(16); + columns = new List(16); + rows = new List(16); + DynamicSize = true; + Margin = null; + } + + /// + /// Adds a child to this panel. + /// + /// The child to add. + /// The location where the child will be placed. + /// This panel for call chaining. + public PGridPanel AddChild(IUIComponent child, GridComponentSpec spec) { + if (child == null) + throw new ArgumentNullException(nameof(child)); + if (spec == null) + throw new ArgumentNullException(nameof(spec)); + children.Add(new GridComponent(spec, child)); + return this; + } + + /// + /// Adds a column to this panel. + /// + /// The specification for that column. + /// This panel for call chaining. + public PGridPanel AddColumn(GridColumnSpec column) { + if (column == null) + throw new ArgumentNullException(nameof(column)); + columns.Add(column); + return this; + } + + /// + /// Adds a handler when this panel is realized. + /// + /// The handler to invoke on realization. + /// This panel for call chaining. + public PGridPanel AddOnRealize(PUIDelegates.OnRealize onRealize) { + OnRealize += onRealize; + return this; + } + + /// + /// Adds a row to this panel. + /// + /// The specification for that row. + /// This panel for call chaining. + public PGridPanel AddRow(GridRowSpec row) { + if (row == null) + throw new ArgumentNullException(nameof(row)); + rows.Add(row); + return this; + } + + public override GameObject Build() { + if (Columns < 1) + throw new InvalidOperationException("At least one column must be defined"); + if (Rows < 1) + throw new InvalidOperationException("At least one row must be defined"); + var panel = PUIElements.CreateUI(null, Name); + SetImage(panel); + // Add layout component + var layout = panel.AddComponent(); + layout.Margin = Margin; + foreach (var column in columns) + layout.AddColumn(column); + foreach (var row in rows) + layout.AddRow(row); + // Add children + foreach (var child in children) + layout.AddComponent(child.Item.Build(), child); + if (!DynamicSize) + layout.LockLayout(); + layout.flexibleWidth = FlexSize.x; + layout.flexibleHeight = FlexSize.y; + InvokeRealize(panel); + return panel; + } + + public override string ToString() { + return string.Format("PGridPanel[Name={0},Rows={1:D},Columns={2:D}]", Name, Rows, + Columns); + } + } +} diff --git a/mod/PLibUI/PLabel.cs b/mod/PLibUI/PLabel.cs new file mode 100644 index 0000000..40c608c --- /dev/null +++ b/mod/PLibUI/PLabel.cs @@ -0,0 +1,93 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// A custom UI label factory class. + /// + public class PLabel : PTextComponent { + /// + /// The label's background color. + /// + public Color BackColor { get; set; } + + public PLabel() : this(null) { } + + public PLabel(string name) : base(name ?? "Label") { + BackColor = PUITuning.Colors.Transparent; + } + + /// + /// Adds a handler when this label is realized. + /// + /// The handler to invoke on realization. + /// This label for call chaining. + public PLabel AddOnRealize(PUIDelegates.OnRealize onRealize) { + OnRealize += onRealize; + return this; + } + + public override GameObject Build() { + var label = PUIElements.CreateUI(null, Name); + GameObject sprite = null, text = null; + // Background + if (BackColor.a > 0) + label.AddComponent().color = BackColor; + // Add foreground image + if (Sprite != null) + sprite = ImageChildHelper(label, this).gameObject; + // Add text + if (!string.IsNullOrEmpty(Text)) + text = TextChildHelper(label, TextStyle ?? PUITuning.Fonts.UILightStyle, + Text).gameObject; + // Add tooltip + PUIElements.SetToolTip(label, ToolTip).SetActive(true); + // Arrange the icon and text + var layout = label.AddComponent(); + layout.Margin = Margin; + ArrangeComponent(layout, WrapTextAndSprite(text, sprite), TextAlignment); + if (!DynamicSize) layout.LockLayout(); + layout.flexibleWidth = FlexSize.x; + layout.flexibleHeight = FlexSize.y; + DestroyLayoutIfPossible(label); + InvokeRealize(label); + return label; + } + + /// + /// Sets the background color to the default Klei dialog blue. + /// + /// This label for call chaining. + public PLabel SetKleiBlueColor() { + BackColor = PUITuning.Colors.ButtonBlueStyle.inactiveColor; + return this; + } + + /// + /// Sets the background color to the Klei dialog header pink. + /// + /// This label for call chaining. + public PLabel SetKleiPinkColor() { + BackColor = PUITuning.Colors.ButtonPinkStyle.inactiveColor; + return this; + } + } +} diff --git a/mod/PLibUI/PLibUI.csproj b/mod/PLibUI/PLibUI.csproj new file mode 100644 index 0000000..e9a8b65 --- /dev/null +++ b/mod/PLibUI/PLibUI.csproj @@ -0,0 +1,20 @@ + + + + PLib User Interface + PLib.UI + 4.11.0.0 + false + PeterHan.PLib.UI + 4.11.0.0 + false + true + Vanilla;Mergedown + + + bin\$(Platform)\Release\PLibUI.xml + + + + + diff --git a/mod/PLibUI/PPanel.cs b/mod/PLibUI/PPanel.cs new file mode 100644 index 0000000..eaec916 --- /dev/null +++ b/mod/PLibUI/PPanel.cs @@ -0,0 +1,168 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace PeterHan.PLib.UI { + /// + /// A custom UI panel factory which can arrange its children horizontally or vertically. + /// + public class PPanel : PContainer, IDynamicSizable { + /// + /// The alignment position to use for child elements if they are smaller than the + /// required size. + /// + public TextAnchor Alignment { get; set; } + + /// + /// The direction in which components will be laid out. + /// + public PanelDirection Direction { get; set; } + + public bool DynamicSize { get; set; } + + /// + /// The spacing between components in pixels. + /// + public int Spacing { get; set; } + + /// + /// The children of this panel. + /// + protected readonly ICollection children; + + public PPanel() : this(null) { } + + public PPanel(string name) : base(name ?? "Panel") { + Alignment = TextAnchor.MiddleCenter; + children = new List(); + Direction = PanelDirection.Vertical; + DynamicSize = true; + Spacing = 0; + } + + /// + /// Adds a child to this panel. + /// + /// The child to add. + /// This panel for call chaining. + public PPanel AddChild(IUIComponent child) { + if (child == null) + throw new ArgumentNullException(nameof(child)); + children.Add(child); + return this; + } + + /// + /// Adds a handler when this panel is realized. + /// + /// The handler to invoke on realization. + /// This panel for call chaining. + public PPanel AddOnRealize(PUIDelegates.OnRealize onRealize) { + OnRealize += onRealize; + return this; + } + + public override GameObject Build() { + return Build(default, DynamicSize); + } + + /// + /// Builds this panel. + /// + /// The fixed size to use if dynamic is false. + /// Whether to use dynamic sizing. + /// The realized panel. + private GameObject Build(Vector2 size, bool dynamic) { + var panel = PUIElements.CreateUI(null, Name); + SetImage(panel); + // Add children + foreach (var child in children) { + var obj = child.Build(); + obj.SetParent(panel); + PUIElements.SetAnchors(obj, PUIAnchoring.Stretch, PUIAnchoring.Stretch); + } + var lg = panel.AddComponent(); + lg.Params = new BoxLayoutParams() { + Direction = Direction, Alignment = Alignment, Spacing = Spacing, + Margin = Margin + }; + if (!dynamic) { + lg.LockLayout(); + panel.SetMinUISize(size); + } + lg.flexibleWidth = FlexSize.x; + lg.flexibleHeight = FlexSize.y; + InvokeRealize(panel); + return panel; + } + + /// + /// Builds this panel with a given default size. + /// + /// The fixed size to use. + /// The realized panel. + public GameObject BuildWithFixedSize(Vector2 size) { + return Build(size, false); + } + + /// + /// Removes a child from this panel. + /// + /// The child to remove. + /// This panel for call chaining. + public PPanel RemoveChild(IUIComponent child) { + if (child == null) + throw new ArgumentNullException(nameof(child)); + children.Remove(child); + return this; + } + + /// + /// Sets the background color to the default Klei dialog blue. + /// + /// This panel for call chaining. + public PPanel SetKleiBlueColor() { + BackColor = PUITuning.Colors.ButtonBlueStyle.inactiveColor; + return this; + } + + /// + /// Sets the background color to the Klei dialog header pink. + /// + /// This panel for call chaining. + public PPanel SetKleiPinkColor() { + BackColor = PUITuning.Colors.ButtonPinkStyle.inactiveColor; + return this; + } + + public override string ToString() { + return string.Format("PPanel[Name={0},Direction={1}]", Name, Direction); + } + } + + /// + /// The direction in which PPanel lays out components. + /// + public enum PanelDirection { + Horizontal, Vertical + } +} diff --git a/mod/PLibUI/PRelativePanel.cs b/mod/PLibUI/PRelativePanel.cs new file mode 100644 index 0000000..d85bbce --- /dev/null +++ b/mod/PLibUI/PRelativePanel.cs @@ -0,0 +1,338 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using PeterHan.PLib.UI.Layouts; +using System; +using System.Collections.Generic; +using UnityEngine; + +using RelativeConfig = PeterHan.PLib.UI.Layouts.RelativeLayoutParamsBase; + +namespace PeterHan.PLib.UI { + /// + /// A panel which lays out its components using relative constraints. + /// + /// This layout manager is the fastest of all panels when laid out, especially since it + /// can function properly when frozen even on dynamically sized items. However, it is also + /// the most difficult to set up and cannot handle all layouts. + /// + public class PRelativePanel : PContainer, IDynamicSizable { + /// + /// Constraints for each object are stored here. + /// + private readonly IDictionary constraints; + + public bool DynamicSize { get; set; } + + public PRelativePanel() : this(null) { + DynamicSize = true; + } + + public PRelativePanel(string name) : base(name ?? "RelativePanel") { + constraints = new Dictionary(16); + Margin = null; + } + + /// + /// Adds a child to this panel. Children must be added to the panel before they are + /// referenced in a constraint. + /// + /// The child to add. + /// This panel for call chaining. + public PRelativePanel AddChild(IUIComponent child) { + if (child == null) + throw new ArgumentNullException(nameof(child)); + if (!constraints.ContainsKey(child)) + constraints.Add(child, new RelativeConfig()); + return this; + } + + /// + /// Adds a handler when this panel is realized. + /// + /// The handler to invoke on realization. + /// This panel for call chaining. + public PRelativePanel AddOnRealize(PUIDelegates.OnRealize onRealize) { + OnRealize += onRealize; + return this; + } + + /// + /// Anchors the component's pivot in the X axis to the specified anchor position. + /// The component will be laid out at its preferred (or overridden) width with its + /// pivot locked to the specified relative fraction of the parent component's width. + /// + /// Any other existing left or right edge constraints will be overwritten. This method + /// is equivalent to setting both the left and right edges to the same fraction. + /// + /// The component to adjust. + /// The fraction to which to align the pivot, with 0.0f + /// being the left and 1.0f being the right. + /// This object, for call chaining. + public PRelativePanel AnchorXAxis(IUIComponent item, float anchor = 0.5f) { + SetLeftEdge(item, fraction: anchor); + return SetRightEdge(item, fraction: anchor); + } + + /// + /// Anchors the component's pivot in the Y axis to the specified anchor position. + /// The component will be laid out at its preferred (or overridden) height with its + /// pivot locked to the specified relative fraction of the parent component's height. + /// + /// Any other existing top or bottom edge constraints will be overwritten. This method + /// is equivalent to setting both the top and bottom edges to the same fraction. + /// + /// The component to adjust. + /// The fraction to which to align the pivot, with 0.0f + /// being the bottom and 1.0f being the top. + /// This object, for call chaining. + public PRelativePanel AnchorYAxis(IUIComponent item, float anchor = 0.5f) { + SetTopEdge(item, fraction: anchor); + return SetBottomEdge(item, fraction: anchor); + } + + public override GameObject Build() { + var panel = PUIElements.CreateUI(null, Name); + var mapping = DictionaryPool.Allocate(); + SetImage(panel); + // Realize each component and add them to the panel + foreach (var pair in constraints) { + var component = pair.Key; + var realized = component.Build(); + realized.SetParent(panel); + // We were already guaranteed that there were no duplicate keys + mapping[component] = realized; + } + // Add layout component + var layout = panel.AddComponent(); + layout.Margin = Margin; + foreach (var pair in constraints) { + var realized = mapping[pair.Key]; + var rawParams = pair.Value; + var newParams = new RelativeLayoutParams(); + // Copy all of the settings + Resolve(newParams.TopEdge, rawParams.TopEdge, mapping); + Resolve(newParams.BottomEdge, rawParams.BottomEdge, mapping); + Resolve(newParams.LeftEdge, rawParams.LeftEdge, mapping); + Resolve(newParams.RightEdge, rawParams.RightEdge, mapping); + newParams.OverrideSize = rawParams.OverrideSize; + newParams.Insets = rawParams.Insets; + layout.SetRaw(realized, newParams); + } + if (!DynamicSize) + layout.LockLayout(); + mapping.Recycle(); + // Set flex size + layout.flexibleWidth = FlexSize.x; + layout.flexibleHeight = FlexSize.y; + InvokeRealize(panel); + return panel; + } + + /// + /// Retrieves the constraints for a component, or throws an exception if the component + /// has not yet been added. + /// + /// The unrealized component to look up. + /// The constraints for that component. + /// If the component has not yet been added to the panel. + private RelativeConfig GetOrThrow(IUIComponent item) { + if (item == null) + throw new ArgumentNullException(nameof(item)); + if (!constraints.TryGetValue(item, out RelativeConfig value)) + throw new ArgumentException("Components must be added to the panel before using them in a constraint"); + return value; + } + + /// + /// Overrides the preferred size of a component. If set, instead of looking at layout + /// sizes of the component, the specified size will be used instead. + /// + /// The component to adjust. + /// The size to apply. Only dimensions greater than zero will be used. + /// This object, for call chaining. + public PRelativePanel OverrideSize(IUIComponent item, Vector2 size) { + if (item != null) + GetOrThrow(item).OverrideSize = size; + return this; + } + + /// + /// Converts the edge settings configured in this component to settings for the + /// relative panel. + /// + /// The location where the converted settings will be stored. + /// The original component edge configuration. + /// The mapping from PLib UI components to Unity objects. + private void Resolve(RelativeLayoutParams.EdgeStatus dest, RelativeConfig.EdgeStatus + status, IDictionary mapping) { + var c = status.FromComponent; + dest.FromAnchor = status.FromAnchor; + if (c != null) + dest.FromComponent = mapping[c]; + dest.Constraint = status.Constraint; + dest.Offset = status.Offset; + } + + /// + /// Sets the bottom edge of a game object. If the fraction is supplied, the component + /// will be laid out with the bottom edge anchored to that fraction of the parent's + /// height. If a component is specified and no fraction is specified, the component + /// will be anchored with its bottom edge above the top edge of that component. + /// If neither is specified, all bottom edge constraints will be removed. + /// + /// Any other existing bottom edge constraint will be overwritten. + /// + /// Remember that +Y is in the upwards direction. + /// + /// The component to adjust. + /// The fraction to which to align the bottom edge, with 0.0f + /// being the bottom and 1.0f being the top. + /// The game object which this component must be above. + /// This object, for call chaining. + public PRelativePanel SetBottomEdge(IUIComponent item, float fraction = -1.0f, + IUIComponent above = null) { + if (item != null) { + if (above == item) + throw new ArgumentException("Component cannot refer directly to itself"); + SetEdge(GetOrThrow(item).BottomEdge, fraction, above); + } + return this; + } + + /// + /// Sets a component's edge constraint. + /// + /// The edge to set. + /// The fraction of the parent to anchor. + /// The other component to anchor. + private void SetEdge(RelativeConfig.EdgeStatus edge, float fraction, + IUIComponent child) { + if (fraction >= 0.0f && fraction <= 1.0f) { + edge.Constraint = RelativeConstraintType.ToAnchor; + edge.FromAnchor = fraction; + edge.FromComponent = null; + } else if (child != null) { + edge.Constraint = RelativeConstraintType.ToComponent; + edge.FromComponent = child; + } else { + edge.Constraint = RelativeConstraintType.Unconstrained; + edge.FromComponent = null; + } + } + + /// + /// Sets the left edge of a game object. If the fraction is supplied, the component + /// will be laid out with the left edge anchored to that fraction of the parent's + /// width. If a component is specified and no fraction is specified, the component + /// will be anchored with its left edge to the right of that component. + /// If neither is specified, all left edge constraints will be removed. + /// + /// Any other existing left edge constraint will be overwritten. + /// + /// The component to adjust. + /// The fraction to which to align the left edge, with 0.0f + /// being the left and 1.0f being the right. + /// The game object which this component must be to the right of. + /// This object, for call chaining. + public PRelativePanel SetLeftEdge(IUIComponent item, float fraction = -1.0f, + IUIComponent toRight = null) { + if (item != null) { + if (toRight == item) + throw new ArgumentException("Component cannot refer directly to itself"); + SetEdge(GetOrThrow(item).LeftEdge, fraction, toRight); + } + return this; + } + + /// + /// Sets the insets of a component from its anchor points. A positive number insets the + /// component away from the edge, whereas a negative number out-sets the component + /// across the edge. + /// + /// All components default to no insets. + /// + /// Any reference to a component's edge using other constraints always refers to its + /// edge before insets are applied. + /// + /// The component to adjust. + /// The insets to apply. If null, the insets will be set to zero. + /// This object, for call chaining. + public PRelativePanel SetMargin(IUIComponent item, RectOffset insets) { + if (item != null) + GetOrThrow(item).Insets = insets; + return this; + } + + /// + /// Sets the right edge of a game object. If the fraction is supplied, the component + /// will be laid out with the right edge anchored to that fraction of the parent's + /// width. If a component is specified and no fraction is specified, the component + /// will be anchored with its right edge to the left of that component. + /// If neither is specified, all right edge constraints will be removed. + /// + /// Any other existing right edge constraint will be overwritten. + /// + /// The component to adjust. + /// The fraction to which to align the right edge, with 0.0f + /// being the left and 1.0f being the right. + /// The game object which this component must be to the left of. + /// This object, for call chaining. + public PRelativePanel SetRightEdge(IUIComponent item, float fraction = -1.0f, + IUIComponent toLeft = null) { + if (item != null) { + if (toLeft == item) + throw new ArgumentException("Component cannot refer directly to itself"); + SetEdge(GetOrThrow(item).RightEdge, fraction, toLeft); + } + return this; + } + + /// + /// Sets the top edge of a game object. If the fraction is supplied, the component + /// will be laid out with the top edge anchored to that fraction of the parent's + /// height. If a component is specified and no fraction is specified, the component + /// will be anchored with its top edge above the bottom edge of that component. + /// If neither is specified, all top edge constraints will be removed. + /// + /// Any other existing top edge constraint will be overwritten. + /// + /// Remember that +Y is in the upwards direction. + /// + /// The component to adjust. + /// The fraction to which to align the top edge, with 0.0f + /// being the bottom and 1.0f being the top. + /// The game object which this component must be below. + /// This object, for call chaining. + public PRelativePanel SetTopEdge(IUIComponent item, float fraction = -1.0f, + IUIComponent below = null) { + if (item != null) { + if (below == item) + throw new ArgumentException("Component cannot refer directly to itself"); + SetEdge(GetOrThrow(item).TopEdge, fraction, below); + } + return this; + } + + public override string ToString() { + return string.Format("PRelativePanel[Name={0}]", Name); + } + } +} diff --git a/mod/PLibUI/PScrollPane.cs b/mod/PLibUI/PScrollPane.cs new file mode 100644 index 0000000..ff72f32 --- /dev/null +++ b/mod/PLibUI/PScrollPane.cs @@ -0,0 +1,376 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using PeterHan.PLib.UI.Layouts; +using System; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// A factory for scrollable panes. + /// + public sealed class PScrollPane : IUIComponent { + /// + /// The track size of scrollbars is based on the sprite. + /// + private const float DEFAULT_TRACK_SIZE = 16.0f; + + /// + /// Whether the horizontal scrollbar is always visible. + /// + public bool AlwaysShowHorizontal { get; set; } + + /// + /// Whether the vertical scrollbar is always visible. + /// + public bool AlwaysShowVertical { get; set; } + + /// + /// The background color of this scroll pane. + /// + public Color BackColor { get; set; } + + /// + /// The child of this scroll pane. + /// + public IUIComponent Child { get; set; } + + /// + /// The flexible size bounds of this component. + /// + public Vector2 FlexSize { get; set; } + + public string Name { get; } + + /// + /// Whether horizontal scrolling is allowed. + /// + public bool ScrollHorizontal { get; set; } + + /// + /// Whether vertical scrolling is allowed. + /// + public bool ScrollVertical { get; set; } + + /// + /// The size of the scrollbar track. + /// + public float TrackSize { get; set; } + + public event PUIDelegates.OnRealize OnRealize; + + public PScrollPane() : this(null) { } + + public PScrollPane(string name) { + AlwaysShowHorizontal = AlwaysShowVertical = true; + BackColor = PUITuning.Colors.Transparent; + Child = null; + FlexSize = Vector2.zero; + Name = name ?? "Scroll"; + ScrollHorizontal = false; + ScrollVertical = false; + TrackSize = DEFAULT_TRACK_SIZE; + } + + /// + /// Adds a handler when this scroll pane is realized. + /// + /// The handler to invoke on realization. + /// This scroll pane for call chaining. + public PScrollPane AddOnRealize(PUIDelegates.OnRealize onRealize) { + OnRealize += onRealize; + return this; + } + + public GameObject Build() { + if (Child == null) + throw new InvalidOperationException("No child component"); + var pane = BuildScrollPane(null, Child.Build()); + OnRealize?.Invoke(pane); + return pane; + } + + /// + /// Builds the actual scroll pane object. + /// + /// The parent of this scroll pane. + /// The child element of this scroll pane. + /// The realized scroll pane. + internal GameObject BuildScrollPane(GameObject parent, GameObject child) { + var pane = PUIElements.CreateUI(parent, Name); + if (BackColor.a > 0.0f) + pane.AddComponent().color = BackColor; + pane.SetActive(false); + // Scroll pane itself + var scroll = pane.AddComponent(); + scroll.horizontal = ScrollHorizontal; + scroll.vertical = ScrollVertical; + // Viewport + var viewport = PUIElements.CreateUI(pane, "Viewport"); + viewport.rectTransform().pivot = Vector2.up; + viewport.AddComponent().enabled = true; + viewport.AddComponent(); + scroll.viewport = viewport.rectTransform(); + // Give the Child a separate Canvas to reduce layout rebuilds + child.AddOrGet().pixelPerfect = false; + child.AddOrGet(); + PUIElements.SetAnchors(child.SetParent(viewport), PUIAnchoring.Beginning, + PUIAnchoring.End); + scroll.content = child.rectTransform(); + // Vertical scrollbar + if (ScrollVertical) { + scroll.verticalScrollbar = CreateScrollVert(pane); + scroll.verticalScrollbarVisibility = AlwaysShowVertical ? ScrollRect. + ScrollbarVisibility.Permanent : ScrollRect.ScrollbarVisibility. + AutoHideAndExpandViewport; + } + if (ScrollHorizontal) { + scroll.horizontalScrollbar = CreateScrollHoriz(pane); + scroll.horizontalScrollbarVisibility = AlwaysShowHorizontal ? ScrollRect. + ScrollbarVisibility.Permanent : ScrollRect.ScrollbarVisibility. + AutoHideAndExpandViewport; + } + pane.SetActive(true); + // Custom layout to pass child sizes to the scroll pane + var layout = pane.AddComponent(); + layout.flexibleHeight = FlexSize.y; + layout.flexibleWidth = FlexSize.x; + return pane; + } + + /// + /// Creates a horizontal scroll bar. + /// + /// The parent component. + /// The scroll bar component. + private Scrollbar CreateScrollHoriz(GameObject parent) { + // Outer scrollbar + var track = PUIElements.CreateUI(parent, "Scrollbar H", true, PUIAnchoring.Stretch, + PUIAnchoring.Beginning); + var bg = track.AddComponent(); + bg.sprite = PUITuning.Images.ScrollBorderHorizontal; + bg.type = Image.Type.Sliced; + // Scroll track + var sb = track.AddComponent(); + sb.interactable = true; + sb.transition = Selectable.Transition.ColorTint; + sb.colors = PUITuning.Colors.ScrollbarColors; + sb.SetDirection(Scrollbar.Direction.RightToLeft, true); + // Handle + var handle = PUIElements.CreateUI(track, "Handle", true, PUIAnchoring.Stretch, + PUIAnchoring.End); + PUIElements.SetAnchorOffsets(handle, 1.0f, 1.0f, 1.0f, 1.0f); + sb.handleRect = handle.rectTransform(); + var hImg = handle.AddComponent(); + hImg.sprite = PUITuning.Images.ScrollHandleHorizontal; + hImg.type = Image.Type.Sliced; + sb.targetGraphic = hImg; + track.SetActive(true); + PUIElements.SetAnchorOffsets(track, 2.0f, 2.0f, -TrackSize, 0.0f); + return sb; + } + + /// + /// Creates a vertical scroll bar. + /// + /// The parent component. + /// The scroll bar component. + private Scrollbar CreateScrollVert(GameObject parent) { + // Outer scrollbar + var track = PUIElements.CreateUI(parent, "Scrollbar V", true, PUIAnchoring.End, + PUIAnchoring.Stretch); + var bg = track.AddComponent(); + bg.sprite = PUITuning.Images.ScrollBorderVertical; + bg.type = Image.Type.Sliced; + // Scroll track + var sb = track.AddComponent(); + sb.interactable = true; + sb.transition = Selectable.Transition.ColorTint; + sb.colors = PUITuning.Colors.ScrollbarColors; + sb.SetDirection(Scrollbar.Direction.BottomToTop, true); + // Handle + var handle = PUIElements.CreateUI(track, "Handle", true, PUIAnchoring.Stretch, + PUIAnchoring.Beginning); + PUIElements.SetAnchorOffsets(handle, 1.0f, 1.0f, 1.0f, 1.0f); + sb.handleRect = handle.rectTransform(); + var hImg = handle.AddComponent(); + hImg.sprite = PUITuning.Images.ScrollHandleVertical; + hImg.type = Image.Type.Sliced; + sb.targetGraphic = hImg; + track.SetActive(true); + PUIElements.SetAnchorOffsets(track, -TrackSize, 0.0f, 2.0f, 2.0f); + return sb; + } + + /// + /// Sets the background color to the default Klei dialog blue. + /// + /// This scroll pane for call chaining. + public PScrollPane SetKleiBlueColor() { + BackColor = PUITuning.Colors.ButtonBlueStyle.inactiveColor; + return this; + } + + /// + /// Sets the background color to the Klei dialog header pink. + /// + /// This scroll pane for call chaining. + public PScrollPane SetKleiPinkColor() { + BackColor = PUITuning.Colors.ButtonPinkStyle.inactiveColor; + return this; + } + + public override string ToString() { + return string.Format("PScrollPane[Name={0},Child={1}]", Name, Child); + } + + /// + /// Handles layout for scroll panes. Not freezable. + /// + private sealed class PScrollPaneLayout : AbstractLayoutGroup { + /// + /// Caches elements when calculating layout to improve performance. + /// + private Component[] calcElements; + + /// + /// The calculated horizontal size of the child element. + /// + private LayoutSizes childHorizontal; + + /// + /// The calculated vertical size of the child element. + /// + private LayoutSizes childVertical; + + /// + /// The child object inside the scroll rect. + /// + private GameObject child; + + /// + /// Caches elements when setting layout to improve performance. + /// + private ILayoutController[] setElements; + + /// + /// The viewport which clips the child rectangle. + /// + private GameObject viewport; + + internal PScrollPaneLayout() { + minHeight = minWidth = 0.0f; + child = viewport = null; + } + + public override void CalculateLayoutInputHorizontal() { + if (child == null) + UpdateComponents(); + if (child != null) { + calcElements = child.GetComponents(); + // Lay out children + childHorizontal = PUIUtils.CalcSizes(child, PanelDirection.Horizontal, + calcElements); + if (childHorizontal.ignore) + throw new InvalidOperationException("ScrollPane child ignores layout!"); + preferredWidth = childHorizontal.preferred; + } + } + + public override void CalculateLayoutInputVertical() { + if (child == null) + UpdateComponents(); + if (child != null && calcElements != null) { + // Lay out children + childVertical = PUIUtils.CalcSizes(child, PanelDirection.Vertical, + calcElements); + preferredHeight = childVertical.preferred; + calcElements = null; + } + } + + protected override void OnEnable() { + base.OnEnable(); + UpdateComponents(); + } + + public override void SetLayoutHorizontal() { + if (viewport != null && child != null) { + // Observe the flex width + float prefWidth = childHorizontal.preferred; + float actualWidth = viewport.rectTransform().rect.width; + if (prefWidth < actualWidth && childHorizontal.flexible > 0.0f) + prefWidth = actualWidth; + // Resize child + setElements = child.GetComponents(); + child.rectTransform().SetSizeWithCurrentAnchors(RectTransform.Axis. + Horizontal, prefWidth); + // ScrollRect does not rebuild the child's layout + foreach (var component in setElements) + component.SetLayoutHorizontal(); + } + } + + public override void SetLayoutVertical() { + if (viewport != null && child != null && setElements != null) { + // Observe the flex height + float prefHeight = childVertical.preferred; + float actualHeight = viewport.rectTransform().rect.height; + if (prefHeight < actualHeight && childVertical.flexible > 0.0f) + prefHeight = actualHeight; + // Resize child + child.rectTransform().SetSizeWithCurrentAnchors(RectTransform.Axis. + Vertical, prefHeight); + // ScrollRect does not rebuild the child's layout + foreach (var component in setElements) + component.SetLayoutVertical(); + setElements = null; + } + } + + /// + /// Caches the child component for performance reasons at runtime. + /// + private void UpdateComponents() { + var obj = gameObject; + if (obj != null && obj.TryGetComponent(out ScrollRect sr)) { + child = sr.content?.gameObject; + viewport = sr.viewport?.gameObject; + } else + child = viewport = null; + } + } + + /// + /// A layout group object that does nothing. While it seems completely pointless, + /// it allows LayoutRebuilder to pass by the viewport on Scroll Rects on its way up + /// the tree, thus ensuring that the scroll rect gets rebuilt. + /// + /// On the way back down, this component gets skipped over by PScrollPaneLayout to + /// save on processing, and the child layout is built directly. + /// + private sealed class ViewportLayoutGroup : UIBehaviour, ILayoutGroup { + public void SetLayoutHorizontal() { } + + public void SetLayoutVertical() { } + } + } +} diff --git a/mod/PLibUI/PSlider.cs b/mod/PLibUI/PSlider.cs new file mode 100644 index 0000000..8c96518 --- /dev/null +++ b/mod/PLibUI/PSlider.cs @@ -0,0 +1,268 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System; +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// A custom UI slider factory class with one handle. Does not include a text field to set + /// the value. + /// + public class PSliderSingle : IUIComponent { + /// + /// Sets the current value of a realized slider. + /// + /// The realized slider. + /// The value to set. + public static void SetCurrentValue(GameObject realized, float value) { + if (realized != null && realized.TryGetComponent(out KSlider slider) && !value. + IsNaNOrInfinity()) + slider.value = value.InRange(slider.minValue, slider.maxValue); + } + + /// + /// If true, the default Klei track and fill will be skipped; only the handle will be + /// shown. + /// + public bool CustomTrack { get; set; } + + /// + /// The direction of the slider. The slider goes from minimum to maximum value in the + /// direction indicated, i.e. LeftToRight is minimum left, maximum right. + /// + public Slider.Direction Direction { get; set; } + + /// + /// The flexible size bounds of this component. + /// + public Vector2 FlexSize { get; set; } + + /// + /// The slider's handle color. + /// + public ColorStyleSetting HandleColor { get; set; } + + /// + /// The size of the slider handle. + /// + public float HandleSize { get; set; } + + /// + /// The initial slider value. + /// + public float InitialValue { get; set; } + + /// + /// true to make the slider snap to integers, or false to allow any representable + /// floating point number in the range. + /// + public bool IntegersOnly { get; set; } + + /// + /// The maximum value that can be set by this slider. The slider is a linear scale, but + /// can be post-scaled by the user to nonlinear if necessary. + /// + public float MaxValue { get; set; } + + /// + /// The minimum value that can be set by this slider. + /// + public float MinValue { get; set; } + + public string Name { get; } + + /// + /// The action to trigger during slider dragging. + /// + public PUIDelegates.OnSliderDrag OnDrag { get; set; } + + /// + /// The preferred length of the scrollbar. If vertical, this is the height, otherwise + /// it is the width. + /// + public float PreferredLength { get; set; } + + /// + /// The action to trigger after the slider is changed. It is passed the realized source + /// object and new value. + /// + public PUIDelegates.OnSliderChanged OnValueChanged { get; set; } + + public event PUIDelegates.OnRealize OnRealize; + + /// + /// The tool tip text. If {0} is present, it will be formatted with the slider's + /// current value. + /// + public string ToolTip { get; set; } + + /// + /// The size of the slider track. + /// + public float TrackSize { get; set; } + + public PSliderSingle() : this("SliderSingle") { } + + public PSliderSingle(string name) { + CustomTrack = false; + Direction = Slider.Direction.LeftToRight; + HandleColor = PUITuning.Colors.ButtonPinkStyle; + HandleSize = 16.0f; + InitialValue = 0.5f; + IntegersOnly = false; + MaxValue = 1.0f; + MinValue = 0.0f; + Name = name; + PreferredLength = 100.0f; + TrackSize = 12.0f; + } + + /// + /// Adds a handler when this slider is realized. + /// + /// The handler to invoke on realization. + /// This slider for call chaining. + public PSliderSingle AddOnRealize(PUIDelegates.OnRealize onRealize) { + OnRealize += onRealize; + return this; + } + + public GameObject Build() { + // Bounds must be valid + if (MaxValue.IsNaNOrInfinity()) + throw new ArgumentException(nameof(MaxValue)); + if (MinValue.IsNaNOrInfinity()) + throw new ArgumentException(nameof(MinValue)); + // max > min + if (MaxValue <= MinValue) + throw new ArgumentOutOfRangeException(nameof(MaxValue)); + // Initial value must be in range + var slider = PUIElements.CreateUI(null, Name); + bool isVertical = Direction == Slider.Direction.BottomToTop || Direction == + Slider.Direction.TopToBottom; + var trueColor = HandleColor ?? PUITuning.Colors.ButtonBlueStyle; + slider.SetActive(false); + // Track (visual) + if (!CustomTrack) { + var trackImg = slider.AddComponent(); + trackImg.sprite = isVertical ? PUITuning.Images.ScrollBorderVertical : + PUITuning.Images.ScrollBorderHorizontal; + trackImg.type = Image.Type.Sliced; + } + // Fill + var fill = PUIElements.CreateUI(slider, "Fill", true); + if (!CustomTrack) { + var fillImg = fill.AddComponent(); + fillImg.sprite = isVertical ? PUITuning.Images.ScrollHandleVertical : + PUITuning.Images.ScrollHandleHorizontal; + fillImg.color = trueColor.inactiveColor; + fillImg.type = Image.Type.Sliced; + } + PUIElements.SetAnchorOffsets(fill, 1.0f, 1.0f, 1.0f, 1.0f); + // Slider component itself + var ks = slider.AddComponent(); + ks.maxValue = MaxValue; + ks.minValue = MinValue; + ks.value = InitialValue.IsNaNOrInfinity() ? MinValue : InitialValue.InRange( + MinValue, MaxValue); + ks.wholeNumbers = IntegersOnly; + ks.handleRect = CreateHandle(slider).rectTransform(); + ks.fillRect = fill.rectTransform(); + ks.SetDirection(Direction, true); + if (OnValueChanged != null) + ks.onValueChanged.AddListener((value) => OnValueChanged(slider, value)); + if (OnDrag != null) + ks.onDrag += () => OnDrag(slider, ks.value); + // Manually add tooltip with slider link + string tt = ToolTip; + if (!string.IsNullOrEmpty(tt)) { + var toolTip = slider.AddComponent(); + toolTip.OnToolTip = () => string.Format(tt, ks.value); + // Tooltip can be dynamically updated + toolTip.refreshWhileHovering = true; + } + slider.SetActive(true); + // Static layout! + slider.SetMinUISize(isVertical ? new Vector2(TrackSize, PreferredLength) : + new Vector2(PreferredLength, TrackSize)); + slider.SetFlexUISize(FlexSize); + OnRealize?.Invoke(slider); + return slider; + } + + /// + /// Creates the handle component. + /// + /// The parent component. + /// The sliding handle object. + private GameObject CreateHandle(GameObject slider) { + // Handle + var handle = PUIElements.CreateUI(slider, "Handle", true, PUIAnchoring.Center, + PUIAnchoring.Center); + var handleImg = handle.AddComponent(); + handleImg.sprite = PUITuning.Images.SliderHandle; + handleImg.preserveAspect = true; + handle.SetUISize(new Vector2(HandleSize, HandleSize)); + // Rotate the handle if needed (CCW) + float rot = 0.0f; + switch (Direction) { + case Slider.Direction.TopToBottom: + rot = 90.0f; + break; + case Slider.Direction.RightToLeft: + rot = 180.0f; + break; + case Slider.Direction.BottomToTop: + rot = 270.0f; + break; + default: + break; + } + if (rot != 0.0f) + handle.transform.Rotate(new Vector3(0.0f, 0.0f, rot)); + return handle; + } + + /// + /// Sets the default Klei pink button style as this slider's foreground color style. + /// + /// This button for call chaining. + public PSliderSingle SetKleiPinkStyle() { + HandleColor = PUITuning.Colors.ButtonPinkStyle; + return this; + } + + /// + /// Sets the default Klei blue button style as this slider's foreground color style. + /// + /// Note that the default slider handle has a hard coded pink color. + /// + /// This button for call chaining. + public PSliderSingle SetKleiBlueStyle() { + HandleColor = PUITuning.Colors.ButtonBlueStyle; + return this; + } + + public override string ToString() { + return string.Format("PSliderSingle[Name={0}]", Name); + } + } +} diff --git a/mod/PLibUI/PSpacer.cs b/mod/PLibUI/PSpacer.cs new file mode 100644 index 0000000..74357de --- /dev/null +++ b/mod/PLibUI/PSpacer.cs @@ -0,0 +1,66 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// A spacer to add into layouts. Has a large flexible width/height by default to eat all + /// the extra space. + /// + public class PSpacer : IUIComponent { + /// + /// The flexible size of this spacer. Defaults to (1, 1) but can be set to (0, 0) to + /// make this spacer a fixed size area instead. + /// + public Vector2 FlexSize { get; set; } + + /// + /// The preferred size of this spacer. + /// + public Vector2 PreferredSize { get; set; } + + public event PUIDelegates.OnRealize OnRealize; + + public string Name { get; } + + public PSpacer() { + Name = "Spacer"; + FlexSize = Vector2.one; + PreferredSize = Vector2.zero; + } + + public GameObject Build() { + var spacer = new GameObject(Name); + var le = spacer.AddComponent(); + le.flexibleHeight = FlexSize.y; + le.flexibleWidth = FlexSize.x; + le.minHeight = 0.0f; + le.minWidth = 0.0f; + le.preferredHeight = PreferredSize.y; + le.preferredWidth = PreferredSize.x; + OnRealize?.Invoke(spacer); + return spacer; + } + + public override string ToString() { + return "PSpacer"; + } + } +} diff --git a/mod/PLibUI/PTextArea.cs b/mod/PLibUI/PTextArea.cs new file mode 100644 index 0000000..d7343d7 --- /dev/null +++ b/mod/PLibUI/PTextArea.cs @@ -0,0 +1,219 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// A custom UI text area (multi-line text field) factory class. This class should + /// probably be wrapped in a scroll pane. + /// + public sealed class PTextArea : IUIComponent { + /// + /// The text area's background color. + /// + public Color BackColor { get; set; } + + /// + /// The flexible size bounds of this component. + /// + public Vector2 FlexSize { get; set; } + + /// + /// The preferred number of text lines to be displayed. If the component is made + /// bigger, the number of text lines (and size) can increase. + /// + public int LineCount { get; set; } + + /// + /// The maximum number of characters in this text area. + /// + public int MaxLength { get; set; } + + public string Name { get; } + + /// + /// The minimum width in units (not characters!) of this text area. + /// + public int MinWidth { get; set; } + + /// + /// The text alignment in the text area. + /// + public TextAlignmentOptions TextAlignment { get; set; } + + /// + /// The initial text in the text field. + /// + public string Text { get; set; } + + /// + /// The text field's text color, font, word wrap settings, and font size. + /// + public TextStyleSetting TextStyle { get; set; } + + /// + /// The tool tip text. + /// + public string ToolTip { get; set; } + + public event PUIDelegates.OnRealize OnRealize; + + /// + /// The action to trigger on text change. It is passed the realized source object. + /// + public PUIDelegates.OnTextChanged OnTextChanged { get; set; } + + /// + /// The callback to invoke when validating input. + /// + public TMP_InputField.OnValidateInput OnValidate { get; set; } + + public PTextArea() : this(null) { } + + public PTextArea(string name) { + BackColor = PUITuning.Colors.BackgroundLight; + FlexSize = Vector2.one; + LineCount = 4; + MaxLength = 1024; + MinWidth = 64; + Name = name ?? "TextArea"; + Text = null; + TextAlignment = TextAlignmentOptions.TopLeft; + TextStyle = PUITuning.Fonts.TextDarkStyle; + ToolTip = ""; + } + + /// + /// Adds a handler when this text area is realized. + /// + /// The handler to invoke on realization. + /// This text area for call chaining. + public PTextArea AddOnRealize(PUIDelegates.OnRealize onRealize) { + OnRealize += onRealize; + return this; + } + + public GameObject Build() { + var textField = PUIElements.CreateUI(null, Name); + var style = TextStyle ?? PUITuning.Fonts.TextLightStyle; + // Background + var border = textField.AddComponent(); + border.sprite = PUITuning.Images.BoxBorderWhite; + border.type = Image.Type.Sliced; + border.color = style.textColor; + // Text box with rectangular clipping area; put pivot in upper left + var textArea = PUIElements.CreateUI(textField, "Text Area", false); + textArea.AddComponent().color = BackColor; + var mask = textArea.AddComponent(); + // Scrollable text + var textBox = PUIElements.CreateUI(textArea, "Text"); + // Text to display + var textDisplay = PTextField.ConfigureField(textBox.AddComponent(), + style, TextAlignment); + textDisplay.enableWordWrapping = true; + textDisplay.raycastTarget = true; + // Text field itself + textField.SetActive(false); + var textEntry = textField.AddComponent(); + textEntry.textComponent = textDisplay; + textEntry.textViewport = textArea.rectTransform(); + textEntry.text = Text ?? ""; + textDisplay.text = Text ?? ""; + // Events! + ConfigureTextEntry(textEntry); + var events = textField.AddComponent(); + events.OnTextChanged = OnTextChanged; + events.OnValidate = OnValidate; + events.TextObject = textBox; + // Add tooltip + PUIElements.SetToolTip(textField, ToolTip); + mask.enabled = true; + PUIElements.SetAnchorOffsets(textBox, new RectOffset()); + textField.SetActive(true); + // Lay out + var layout = PUIUtils.InsetChild(textField, textArea, Vector2.one, new Vector2( + MinWidth, Math.Max(LineCount, 1) * PUIUtils.GetLineHeight(style))). + AddOrGet(); + layout.flexibleWidth = FlexSize.x; + layout.flexibleHeight = FlexSize.y; + OnRealize?.Invoke(textField); + return textField; + } + + /// + /// Sets up the text entry field. + /// + /// The input field to configure. + private void ConfigureTextEntry(TMP_InputField textEntry) { + textEntry.characterLimit = Math.Max(1, MaxLength); + textEntry.enabled = true; + textEntry.inputType = TMP_InputField.InputType.Standard; + textEntry.interactable = true; + textEntry.isRichTextEditingAllowed = false; + textEntry.keyboardType = TouchScreenKeyboardType.Default; + textEntry.lineType = TMP_InputField.LineType.MultiLineNewline; + textEntry.navigation = Navigation.defaultNavigation; + textEntry.richText = false; + textEntry.selectionColor = PUITuning.Colors.SelectionBackground; + textEntry.transition = Selectable.Transition.None; + textEntry.restoreOriginalTextOnEscape = true; + } + + /// + /// Sets the default Klei pink style as this text area's color and text style. + /// + /// This button for call chaining. + public PTextArea SetKleiPinkStyle() { + TextStyle = PUITuning.Fonts.UILightStyle; + BackColor = PUITuning.Colors.ButtonPinkStyle.inactiveColor; + return this; + } + + /// + /// Sets the default Klei blue style as this text area's color and text style. + /// + /// This button for call chaining. + public PTextArea SetKleiBlueStyle() { + TextStyle = PUITuning.Fonts.UILightStyle; + BackColor = PUITuning.Colors.ButtonBlueStyle.inactiveColor; + return this; + } + + /// + /// Sets the minimum (and preferred) width of this text area in characters. + /// + /// The width is computed using the currently selected text style. + /// + /// The number of characters to be displayed. + /// This button for call chaining. + public PTextArea SetMinWidthInCharacters(int chars) { + int width = Mathf.RoundToInt(chars * PUIUtils.GetEmWidth(TextStyle)); + if (width > 0) + MinWidth = width; + return this; + } + + public override string ToString() { + return string.Format("PTextArea[Name={0}]", Name); + } + } +} diff --git a/mod/PLibUI/PTextComponent.cs b/mod/PLibUI/PTextComponent.cs new file mode 100644 index 0000000..7fefbdc --- /dev/null +++ b/mod/PLibUI/PTextComponent.cs @@ -0,0 +1,332 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using PeterHan.PLib.UI.Layouts; +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// The abstract parent of PLib UI components which display text and/or images. + /// + public abstract class PTextComponent : IDynamicSizable { + /// + /// The center of an object for pivoting. + /// + private static readonly Vector2 CENTER = new Vector2(0.5f, 0.5f); + + /// + /// Arranges a component in the parent layout in both directions. + /// + /// The layout to modify. + /// The target object to arrange. + /// The object alignment to use. + protected static void ArrangeComponent(RelativeLayoutGroup layout, GameObject target, + TextAnchor alignment) { + // X + switch (alignment) { + case TextAnchor.LowerLeft: + case TextAnchor.MiddleLeft: + case TextAnchor.UpperLeft: + layout.SetLeftEdge(target, fraction: 0.0f); + break; + case TextAnchor.LowerRight: + case TextAnchor.MiddleRight: + case TextAnchor.UpperRight: + layout.SetRightEdge(target, fraction: 1.0f); + break; + default: + // MiddleCenter, LowerCenter, UpperCenter + layout.AnchorXAxis(target, 0.5f); + break; + } + // Y + switch (alignment) { + case TextAnchor.LowerLeft: + case TextAnchor.LowerCenter: + case TextAnchor.LowerRight: + layout.SetBottomEdge(target, fraction: 0.0f); + break; + case TextAnchor.UpperLeft: + case TextAnchor.UpperCenter: + case TextAnchor.UpperRight: + layout.SetTopEdge(target, fraction: 1.0f); + break; + default: + // MiddleCenter, LowerCenter, UpperCenter + layout.AnchorYAxis(target, 0.5f); + break; + } + } + + /// + /// Shared routine to spawn UI image objects. + /// + /// The parent object for the image. + /// The settings to use for displaying the image. + /// The child image object. + protected static Image ImageChildHelper(GameObject parent, PTextComponent settings) { + var imageChild = PUIElements.CreateUI(parent, "Image", true, + PUIAnchoring.Beginning, PUIAnchoring.Beginning); + var rt = imageChild.rectTransform(); + // The pivot is important here + rt.pivot = CENTER; + var img = imageChild.AddComponent(); + img.color = settings.SpriteTint; + img.sprite = settings.Sprite; + img.type = settings.SpriteMode; + img.preserveAspect = settings.MaintainSpriteAspect; + // Set up transform + var scale = Vector3.one; + float rot = 0.0f; + var rotate = settings.SpriteTransform; + if ((rotate & ImageTransform.FlipHorizontal) != ImageTransform.None) + scale.x = -1.0f; + if ((rotate & ImageTransform.FlipVertical) != ImageTransform.None) + scale.y = -1.0f; + if ((rotate & ImageTransform.Rotate90) != ImageTransform.None) + rot = 90.0f; + if ((rotate & ImageTransform.Rotate180) != ImageTransform.None) + rot += 180.0f; + // Update transform + var transform = imageChild.rectTransform(); + transform.localScale = scale; + transform.Rotate(new Vector3(0.0f, 0.0f, rot)); + // Limit size if needed + var imageSize = settings.SpriteSize; + if (imageSize.x > 0.0f && imageSize.y > 0.0f) + imageChild.SetUISize(imageSize, true); + return img; + } + + /// + /// Shared routine to spawn UI text objects. + /// + /// The parent object for the text. + /// The text style to use. + /// The default text. + /// The child text object. + protected static LocText TextChildHelper(GameObject parent, TextStyleSetting style, + string contents = "") { + var textChild = PUIElements.CreateUI(parent, "Text"); + var locText = PUIElements.AddLocText(textChild, style); + // Font needs to be set before the text + locText.alignment = TMPro.TextAlignmentOptions.Center; + locText.text = contents; + return locText; + } + + public bool DynamicSize { get; set; } + + /// + /// The flexible size bounds of this component. + /// + public Vector2 FlexSize { get; set; } + + /// + /// The spacing between text and icon. + /// + public int IconSpacing { get; set; } + + /// + /// If true, the sprite aspect ratio will be maintained even if it is resized. + /// + public bool MaintainSpriteAspect { get; set; } + + /// + /// The margin around the component. + /// + public RectOffset Margin { get; set; } + + public string Name { get; } + + /// + /// The sprite to display, or null to display no sprite. + /// + public Sprite Sprite { get; set; } + + /// + /// The image mode to use for the sprite. + /// + public Image.Type SpriteMode { get; set; } + + /// + /// The position to use for the sprite relative to the text. + /// + /// If TextAnchor.MiddleCenter is used, the image will directly overlap the text. + /// Otherwise, it will be placed in the specified location relative to the text. + /// + public TextAnchor SpritePosition { get; set; } + + /// + /// The size to scale the sprite. If 0x0, it will not be scaled. + /// + public Vector2 SpriteSize { get; set; } + + /// + /// The color to tint the sprite. For no tint, use Color.white. + /// + public Color SpriteTint { get; set; } + + /// + /// How to rotate or flip the sprite. + /// + public ImageTransform SpriteTransform { get; set; } + + /// + /// The component's text. + /// + public string Text { get; set; } + + /// + /// The text alignment in the component. Controls the placement of the text and sprite + /// combination relative to the component's overall outline if the component is + /// expanded from its default size. + /// + /// The text and sprite will move as a unit to follow this text alignment. Note that + /// incorrect positions will result if this alignment is centered in the same direction + /// as the sprite position offset, if both a sprite and text are defined. + /// + /// If the SpritePosition uses any variant of Left or Right, using UpperCenter, + /// MiddleCenter, or LowerCenter for TextAlignment would result in undefined text and + /// sprite positioning. Likewise, a SpritePosition using any variant of Lower or Upper + /// would cause undefined positioning if TextAlignment was MiddleLeft, MiddleCenter, + /// or MiddleRight. + /// + public TextAnchor TextAlignment { get; set; } + + /// + /// The component's text color, font, word wrap settings, and font size. + /// + public TextStyleSetting TextStyle { get; set; } + + /// + /// The tool tip text. + /// + public string ToolTip { get; set; } + + public event PUIDelegates.OnRealize OnRealize; + + protected PTextComponent(string name) { + DynamicSize = false; + FlexSize = Vector2.zero; + IconSpacing = 0; + MaintainSpriteAspect = true; + Margin = null; + Name = name; + Sprite = null; + SpriteMode = Image.Type.Simple; + SpritePosition = TextAnchor.MiddleLeft; + SpriteSize = Vector2.zero; + SpriteTint = Color.white; + SpriteTransform = ImageTransform.None; + Text = null; + TextAlignment = TextAnchor.MiddleCenter; + TextStyle = null; + ToolTip = ""; + } + + public abstract GameObject Build(); + + /// + /// If the flex size is zero and dynamic size is false, the layout group can be + /// completely destroyed on a text component after the layout is locked. + /// + /// The realized text component. + protected void DestroyLayoutIfPossible(GameObject component) { + if (FlexSize.x == 0.0f && FlexSize.y == 0.0f && !DynamicSize) + AbstractLayoutGroup.DestroyAndReplaceLayout(component); + } + + /// + /// Invokes the OnRealize event. + /// + /// The realized text component. + protected void InvokeRealize(GameObject obj) { + OnRealize?.Invoke(obj); + } + + public override string ToString() { + return string.Format("{3}[Name={0},Text={1},Sprite={2}]", Name, Text, Sprite, + GetType().Name); + } + + /// + /// Wraps the text and sprite into a single GameObject that properly positions them + /// relative to each other, if necessary. + /// + /// The text component. + /// The sprite component. + /// A game object that contains both of them, or null if both are null. + protected GameObject WrapTextAndSprite(GameObject text, GameObject sprite) { + GameObject result = null; + if (text != null && sprite != null) { + // Automatically hoist them into a new game object + result = PUIElements.CreateUI(text.GetParent(), "AlignmentWrapper"); + text.SetParent(result); + sprite.SetParent(result); + var layout = result.AddOrGet(); + // X + switch (SpritePosition) { + case TextAnchor.MiddleLeft: + case TextAnchor.LowerLeft: + case TextAnchor.UpperLeft: + layout.SetLeftEdge(sprite, fraction: 0.0f).SetLeftEdge(text, toRight: + sprite).SetMargin(sprite, new RectOffset(0, IconSpacing, 0, 0)); + break; + case TextAnchor.MiddleRight: + case TextAnchor.LowerRight: + case TextAnchor.UpperRight: + layout.SetRightEdge(sprite, fraction: 1.0f).SetRightEdge(text, toLeft: + sprite).SetMargin(sprite, new RectOffset(IconSpacing, 0, 0, 0)); + break; + default: + // MiddleCenter, UpperCenter, LowerCenter + layout.AnchorXAxis(text).AnchorXAxis(sprite); + break; + } + // Y + switch (SpritePosition) { + case TextAnchor.UpperCenter: + case TextAnchor.UpperLeft: + case TextAnchor.UpperRight: + layout.SetTopEdge(sprite, fraction: 1.0f).SetTopEdge(text, below: + sprite).SetMargin(sprite, new RectOffset(0, 0, 0, IconSpacing)); + break; + case TextAnchor.LowerCenter: + case TextAnchor.LowerLeft: + case TextAnchor.LowerRight: + layout.SetBottomEdge(sprite, fraction: 0.0f).SetBottomEdge(text, above: + sprite).SetMargin(sprite, new RectOffset(0, 0, IconSpacing, 0)); + break; + default: + // MiddleCenter, MiddleLeft, MiddleRight + layout.AnchorYAxis(text).AnchorYAxis(sprite); + break; + } + if (!DynamicSize) + layout.LockLayout(); + } else if (text != null) + result = text; + else if (sprite != null) + result = sprite; + return result; + } + } +} diff --git a/mod/PLibUI/PTextField.cs b/mod/PLibUI/PTextField.cs new file mode 100644 index 0000000..7113da3 --- /dev/null +++ b/mod/PLibUI/PTextField.cs @@ -0,0 +1,302 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// A custom UI text field factory class. + /// + public sealed class PTextField : IUIComponent { + /// + /// Configures a Text Mesh Pro field. + /// + /// The text component to configure. + /// The desired text color, font, and style. + /// The text alignment. + /// The component, for call chaining. + internal static TextMeshProUGUI ConfigureField(TextMeshProUGUI component, + TextStyleSetting style, TextAlignmentOptions alignment) { + component.alignment = alignment; + component.autoSizeTextContainer = false; + component.enabled = true; + component.color = style.textColor; + component.font = style.sdfFont; + component.fontSize = style.fontSize; + component.fontStyle = style.style; + component.overflowMode = TextOverflowModes.Overflow; + return component; + } + + /// + /// Gets a text field's text. + /// + /// The UI element to retrieve. + /// The current text in the field. + public static string GetText(GameObject textField) { + if (textField == null) + throw new ArgumentNullException(nameof(textField)); + return textField.TryGetComponent(out TMP_InputField field) ? field.text : ""; + } + + /// + /// The text field's background color. + /// + public Color BackColor { get; set; } + + /// + /// Retrieves the built-in field type used for Text Mesh Pro. + /// + private TMP_InputField.ContentType ContentType { + get { + TMP_InputField.ContentType cType; + switch (Type) { + case FieldType.Float: + cType = TMP_InputField.ContentType.DecimalNumber; + break; + case FieldType.Integer: + cType = TMP_InputField.ContentType.IntegerNumber; + break; + case FieldType.Text: + default: + cType = TMP_InputField.ContentType.Standard; + break; + } + return cType; + } + } + + /// + /// The flexible size bounds of this component. + /// + public Vector2 FlexSize { get; set; } + + /// + /// The maximum number of characters in this text field. + /// + public int MaxLength { get; set; } + + /// + /// The minimum width in units (not characters!) of this text field. + /// + public int MinWidth { get; set; } + + /// + /// The placeholder text style (including color, font, and word wrap settings) if the + /// field is empty. + /// + public TextStyleSetting PlaceholderStyle { get; set; } + + /// + /// The placeholder text if the field is empty. + /// + public string PlaceholderText { get; set; } + + public string Name { get; } + + /// + /// The text alignment in the text field. + /// + public TextAlignmentOptions TextAlignment { get; set; } + + /// + /// The initial text in the text field. + /// + public string Text { get; set; } + + /// + /// The text field's text color, font, word wrap settings, and font size. + /// + public TextStyleSetting TextStyle { get; set; } + + /// + /// The tool tip text. + /// + public string ToolTip { get; set; } + + /// + /// The field type. + /// + public FieldType Type { get; set; } + + public event PUIDelegates.OnRealize OnRealize; + + /// + /// The action to trigger on text change. It is passed the realized source object. + /// + public PUIDelegates.OnTextChanged OnTextChanged { get; set; } + + /// + /// The callback to invoke when validating input. + /// + public TMP_InputField.OnValidateInput OnValidate { get; set; } + + public PTextField() : this(null) { } + + public PTextField(string name) { + BackColor = PUITuning.Colors.BackgroundLight; + FlexSize = Vector2.zero; + MaxLength = 256; + MinWidth = 32; + Name = name ?? "TextField"; + PlaceholderText = null; + Text = null; + TextAlignment = TextAlignmentOptions.Center; + TextStyle = PUITuning.Fonts.TextDarkStyle; + PlaceholderStyle = TextStyle; + ToolTip = ""; + Type = FieldType.Text; + } + + /// + /// Adds a handler when this text field is realized. + /// + /// The handler to invoke on realization. + /// This text field for call chaining. + public PTextField AddOnRealize(PUIDelegates.OnRealize onRealize) { + OnRealize += onRealize; + return this; + } + + public GameObject Build() { + var textField = PUIElements.CreateUI(null, Name); + var style = TextStyle ?? PUITuning.Fonts.TextLightStyle; + // Background + var border = textField.AddComponent(); + border.sprite = PUITuning.Images.BoxBorderWhite; + border.type = Image.Type.Sliced; + border.color = style.textColor; + // Text box with rectangular clipping area + var textArea = PUIElements.CreateUI(textField, "Text Area", false); + textArea.AddComponent().color = BackColor; + var mask = textArea.AddComponent(); + // Scrollable text + var textBox = PUIElements.CreateUI(textArea, "Text"); + // Text to display + var textDisplay = ConfigureField(textBox.AddComponent(), style, + TextAlignment); + textDisplay.enableWordWrapping = false; + textDisplay.maxVisibleLines = 1; + textDisplay.raycastTarget = true; + // Text field itself + textField.SetActive(false); + var textEntry = textField.AddComponent(); + textEntry.textComponent = textDisplay; + textEntry.textViewport = textArea.rectTransform(); + textEntry.text = Text ?? ""; + textDisplay.text = Text ?? ""; + // Placeholder + if (PlaceholderText != null) { + var placeholder = PUIElements.CreateUI(textArea, "Placeholder Text"); + var textPlace = ConfigureField(placeholder.AddComponent(), + PlaceholderStyle ?? style, TextAlignment); + textPlace.maxVisibleLines = 1; + textPlace.text = PlaceholderText; + textEntry.placeholder = textPlace; + } + // Events! + ConfigureTextEntry(textEntry); + var events = textField.AddComponent(); + events.OnTextChanged = OnTextChanged; + events.OnValidate = OnValidate; + events.TextObject = textBox; + // Add tooltip + PUIElements.SetToolTip(textField, ToolTip); + mask.enabled = true; + PUIElements.SetAnchorOffsets(textBox, new RectOffset()); + textField.SetActive(true); + // Lay out + var rt = textBox.rectTransform(); + LayoutRebuilder.ForceRebuildLayoutImmediate(rt); + var layout = PUIUtils.InsetChild(textField, textArea, Vector2.one, new Vector2( + MinWidth, LayoutUtility.GetPreferredHeight(rt))).AddOrGet(); + layout.flexibleWidth = FlexSize.x; + layout.flexibleHeight = FlexSize.y; + OnRealize?.Invoke(textField); + return textField; + } + + /// + /// Sets up the text entry field. + /// + /// The input field to configure. + private void ConfigureTextEntry(TMP_InputField textEntry) { + textEntry.characterLimit = Math.Max(1, MaxLength); + textEntry.contentType = ContentType; + textEntry.enabled = true; + textEntry.inputType = TMP_InputField.InputType.Standard; + textEntry.interactable = true; + textEntry.isRichTextEditingAllowed = false; + textEntry.keyboardType = TouchScreenKeyboardType.Default; + textEntry.lineType = TMP_InputField.LineType.SingleLine; + textEntry.navigation = Navigation.defaultNavigation; + textEntry.richText = false; + textEntry.selectionColor = PUITuning.Colors.SelectionBackground; + textEntry.transition = Selectable.Transition.None; + textEntry.restoreOriginalTextOnEscape = true; + } + + /// + /// Sets the default Klei pink style as this text field's color and text style. + /// + /// This text field for call chaining. + public PTextField SetKleiPinkStyle() { + TextStyle = PUITuning.Fonts.UILightStyle; + BackColor = PUITuning.Colors.ButtonPinkStyle.inactiveColor; + return this; + } + + /// + /// Sets the default Klei blue style as this text field's color and text style. + /// + /// This text field for call chaining. + public PTextField SetKleiBlueStyle() { + TextStyle = PUITuning.Fonts.UILightStyle; + BackColor = PUITuning.Colors.ButtonBlueStyle.inactiveColor; + return this; + } + + /// + /// Sets the minimum (and preferred) width of this text field in characters. + /// + /// The width is computed using the currently selected text style. + /// + /// The number of characters to be displayed. + /// This text field for call chaining. + public PTextField SetMinWidthInCharacters(int chars) { + int width = Mathf.RoundToInt(chars * PUIUtils.GetEmWidth(TextStyle)); + if (width > 0) + MinWidth = width; + return this; + } + + public override string ToString() { + return string.Format("PTextField[Name={0},Type={1}]", Name, Type); + } + + /// + /// The valid text field types supported by this class. + /// + public enum FieldType { + Text, Integer, Float + } + } +} diff --git a/mod/PLibUI/PTextFieldEvents.cs b/mod/PLibUI/PTextFieldEvents.cs new file mode 100644 index 0000000..96bc472 --- /dev/null +++ b/mod/PLibUI/PTextFieldEvents.cs @@ -0,0 +1,159 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// A class instance that handles events for text fields. + /// + internal sealed class PTextFieldEvents : KScreen { + /// + /// The action to trigger on text change. It is passed the realized source object. + /// + [SerializeField] + internal PUIDelegates.OnTextChanged OnTextChanged { get; set; } + + /// + /// The callback to invoke when validating input. + /// + [SerializeField] + internal TMP_InputField.OnValidateInput OnValidate { get; set; } + + /// + /// The object to resize on text change. + /// + [SerializeField] + internal GameObject TextObject { get; set; } + + [MyCmpReq] +#pragma warning disable IDE0044 // Add readonly modifier +#pragma warning disable CS0649 + private TMP_InputField textEntry; +#pragma warning restore CS0649 +#pragma warning restore IDE0044 // Add readonly modifier + + /// + /// Whether editing is in progress. + /// + private bool editing; + + internal PTextFieldEvents() { + activateOnSpawn = true; + editing = false; + TextObject = null; + } + + /// + /// Completes the edit process one frame after the data is entered. + /// + private System.Collections.IEnumerator DelayEndEdit() { + yield return new WaitForEndOfFrame(); + StopEditing(); + } + + public override float GetSortKey() { + return editing ? 99.0f : base.GetSortKey(); + } + + protected override void OnCleanUp() { + textEntry.onFocus -= OnFocus; + textEntry.onValueChanged.RemoveListener(OnValueChanged); + textEntry.onEndEdit.RemoveListener(OnEndEdit); + base.OnCleanUp(); + } + + protected override void OnSpawn() { + base.OnSpawn(); + textEntry.onFocus += OnFocus; + textEntry.onValueChanged.AddListener(OnValueChanged); + textEntry.onEndEdit.AddListener(OnEndEdit); + if (OnValidate != null) + textEntry.onValidateInput = OnValidate; + } + + /// + /// Triggered when editing of the text ends (field loses focus). + /// + /// The text entered. + private void OnEndEdit(string text) { + var obj = gameObject; + if (obj != null) { + OnTextChanged?.Invoke(obj, text); + if (obj.activeInHierarchy) + StartCoroutine(DelayEndEdit()); + } + } + + /// + /// Triggered when the text field gains focus. + /// + private void OnFocus() { + editing = true; + textEntry.Select(); + textEntry.ActivateInputField(); + KScreenManager.Instance.RefreshStack(); + } + + /// + /// Destroys events if editing is in progress to prevent bubbling through to the + /// game UI. + /// + public override void OnKeyDown(KButtonEvent e) { + if (editing) + e.Consumed = true; + else + base.OnKeyDown(e); + } + + /// + /// Destroys events if editing is in progress to prevent bubbling through to the + /// game UI. + /// + public override void OnKeyUp(KButtonEvent e) { + if (editing) + e.Consumed = true; + else + base.OnKeyUp(e); + } + + /// + /// Triggered when the text box value changes. + /// + /// The text entered. + private void OnValueChanged(string text) { + var obj = gameObject; + if (obj != null && TextObject != null) { + var rt = TextObject.rectTransform(); + rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, LayoutUtility. + GetPreferredHeight(rt)); + } + } + + /// + /// Completes the edit process. + /// + private void StopEditing() { + if (textEntry != null && textEntry.gameObject.activeInHierarchy) + textEntry.DeactivateInputField(); + editing = false; + } + } +} diff --git a/mod/PLibUI/PToggle.cs b/mod/PLibUI/PToggle.cs new file mode 100644 index 0000000..dc8e36f --- /dev/null +++ b/mod/PLibUI/PToggle.cs @@ -0,0 +1,174 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// A custom UI toggled button factory class. + /// + public sealed class PToggle : IDynamicSizable { + /// + /// The default margins around a toggle. + /// + internal static readonly RectOffset TOGGLE_MARGIN = new RectOffset(1, 1, 1, 1); + + /// + /// Gets a realized toggle button's state. + /// + /// The realized toggle button. + /// The toggle button state. + public static bool GetToggleState(GameObject realized) { + return realized != null && realized.TryGetComponent(out KToggle toggle) && + UIDetours.IS_ON.Get(toggle); + } + + /// + /// Sets a realized toggle button's state. + /// + /// The realized toggle button. + /// Whether the button should be on or off. + public static void SetToggleState(GameObject realized, bool on) { + if (realized != null && realized.TryGetComponent(out KToggle toggle)) + UIDetours.IS_ON.Set(toggle, on); + } + + /// + /// The sprite to display when active. + /// + public Sprite ActiveSprite { get; set; } + + /// + /// The toggle's color. + /// + public ColorStyleSetting Color { get; set; } + + public bool DynamicSize { get; set; } + + /// + /// The flexible size bounds of this component. + /// + public Vector2 FlexSize { get; set; } + + /// + /// The sprite to display when inactive. + /// + public Sprite InactiveSprite { get; set; } + + /// + /// The initial state of the toggle button. + /// + public bool InitialState { get; set; } + + /// + /// The margin around the component. + /// + public RectOffset Margin { get; set; } + + public string Name { get; } + + /// + /// The action to trigger when the state changes. It is passed the realized source + /// object. + /// + public PUIDelegates.OnToggleButton OnStateChanged { get; set; } + + /// + /// The size to scale the toggle images. If 0x0, it will not be scaled. + /// + public Vector2 Size { get; set; } + + /// + /// The tool tip text. + /// + public string ToolTip { get; set; } + + public event PUIDelegates.OnRealize OnRealize; + + public PToggle() : this(null) { } + + public PToggle(string name) { + ActiveSprite = PUITuning.Images.Contract; + Color = PUITuning.Colors.ComponentDarkStyle; + InitialState = false; + Margin = TOGGLE_MARGIN; + Name = name ?? "Toggle"; + InactiveSprite = PUITuning.Images.Expand; + ToolTip = ""; + } + + /// + /// Adds a handler when this toggle button is realized. + /// + /// The handler to invoke on realization. + /// This toggle button for call chaining. + public PToggle AddOnRealize(PUIDelegates.OnRealize onRealize) { + OnRealize += onRealize; + return this; + } + + public GameObject Build() { + var toggle = PUIElements.CreateUI(null, Name); + // Set on click event + var kToggle = toggle.AddComponent(); + var evt = OnStateChanged; + if (evt != null) + kToggle.onValueChanged += (on) => { + evt?.Invoke(toggle, on); + }; + UIDetours.ART_EXTENSION.Set(kToggle, new KToggleArtExtensions()); + UIDetours.SOUND_PLAYER_TOGGLE.Set(kToggle, PUITuning.ToggleSounds); + // Background image + var fgImage = toggle.AddComponent(); + fgImage.color = Color.activeColor; + fgImage.sprite = InactiveSprite; + toggle.SetActive(false); + // Toggled images + var toggleImage = toggle.AddComponent(); + toggleImage.TargetImage = fgImage; + toggleImage.useSprites = true; + toggleImage.InactiveSprite = InactiveSprite; + toggleImage.ActiveSprite = ActiveSprite; + toggleImage.startingState = InitialState ? ImageToggleState.State.Active : + ImageToggleState.State.Inactive; + toggleImage.useStartingState = true; + toggleImage.ActiveColour = Color.activeColor; + toggleImage.DisabledActiveColour = Color.disabledActiveColor; + toggleImage.InactiveColour = Color.inactiveColor; + toggleImage.DisabledColour = Color.disabledColor; + toggleImage.HoverColour = Color.hoverColor; + toggleImage.DisabledHoverColor = Color.disabledhoverColor; + UIDetours.IS_ON.Set(kToggle, InitialState); + toggle.SetActive(true); + // Set size + if (Size.x > 0.0f && Size.y > 0.0f) + toggle.SetUISize(Size, true); + else + PUIElements.AddSizeFitter(toggle, DynamicSize); + // Add tooltip + PUIElements.SetToolTip(toggle, ToolTip).SetFlexUISize(FlexSize).SetActive(true); + OnRealize?.Invoke(toggle); + return toggle; + } + + public override string ToString() { + return string.Format("PToggle[Name={0}]", Name); + } + } +} diff --git a/mod/PLibUI/PUIDelegates.cs b/mod/PLibUI/PUIDelegates.cs new file mode 100644 index 0000000..af0fb2c --- /dev/null +++ b/mod/PLibUI/PUIDelegates.cs @@ -0,0 +1,89 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using UnityEngine; + +namespace PeterHan.PLib.UI { + /// + /// Delegate types used in the UI event system. + /// + public sealed class PUIDelegates { + /// + /// The delegate type invoked when a dialog is closed. + /// + /// The key of the chosen option, or PDialog.DIALOG_CLOSE_KEY if + /// the dialog was closed with ESC or the X button. + public delegate void OnDialogClosed(string option); + + /// + /// The delegate type invoked when a button is pressed. + /// + /// The source button. + public delegate void OnButtonPressed(GameObject source); + + /// + /// The delegate type invoked when a checkbox is clicked. + /// + /// The source button. + /// The checkbox state. + public delegate void OnChecked(GameObject source, int state); + + /// + /// The delegate type invoked when an option is selected from a combo box. + /// + /// The source dropdown. + /// The option chosen. + /// The type of the objects in the drop down. + public delegate void OnDropdownChanged(GameObject source, T choice) where T : + class, IListableOption; + + /// + /// The delegate type invoked when components are converted into Unity game objects. + /// + /// The realized object. + public delegate void OnRealize(GameObject realized); + + /// + /// The delegate type invoked once after a slider is changed and released. + /// + /// The source slider. + /// The new slider value. + public delegate void OnSliderChanged(GameObject source, float newValue); + + /// + /// The delegate type invoked while a slider is being changed. + /// + /// The source slider. + /// The new slider value. + public delegate void OnSliderDrag(GameObject source, float newValue); + + /// + /// The delegate type invoked when text in a text field is changed. + /// + /// The source text field. + /// The new text. + public delegate void OnTextChanged(GameObject source, string text); + + /// + /// The delegate type invoked when a toggle button is swapped between states. + /// + /// The source button. + /// true if the button is toggled on, or false otherwise. + public delegate void OnToggleButton(GameObject source, bool on); + } +} diff --git a/mod/PLibUI/PUIElements.cs b/mod/PLibUI/PUIElements.cs new file mode 100644 index 0000000..2da8906 --- /dev/null +++ b/mod/PLibUI/PUIElements.cs @@ -0,0 +1,357 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using PeterHan.PLib.Detours; +using System; +using UnityEngine; +using UnityEngine.UI; + +using ContentFitMode = UnityEngine.UI.ContentSizeFitter.FitMode; + +namespace PeterHan.PLib.UI { + /// + /// Used for creating and managing UI elements. + /// + public sealed class PUIElements { + /// + /// Safely adds a LocText to a game object without throwing an NRE on construction. + /// + /// The game object to add the LocText. + /// The text style. + /// The added LocText object. + internal static LocText AddLocText(GameObject parent, TextStyleSetting setting = null) + { + if (parent == null) + throw new ArgumentNullException(nameof(parent)); + bool active = parent.activeSelf; + parent.SetActive(false); + var text = parent.AddComponent(); + // This is enough to let it activate + UIDetours.LOCTEXT_KEY.Set(text, string.Empty); + UIDetours.LOCTEXT_STYLE.Set(text, setting ?? PUITuning.Fonts.UIDarkStyle); + parent.SetActive(active); + return text; + } + + /// + /// Adds an auto-fit resizer to a UI element. + /// + /// UI elements should be active before any layouts are added, especially if they are + /// to be frozen. + /// + /// The element to resize. + /// true to use the Unity content size fitter which adjusts to + /// content changes, or false to set the size only once. + /// The sizing mode to use in the horizontal direction. + /// The sizing mode to use in the vertical direction. + /// The UI element, for call chaining. + public static GameObject AddSizeFitter(GameObject uiElement, bool dynamic = false, + ContentFitMode modeHoriz = ContentFitMode.PreferredSize, + ContentFitMode modeVert = ContentFitMode.PreferredSize) { + if (uiElement == null) + throw new ArgumentNullException(nameof(uiElement)); + if (dynamic) { + var fitter = uiElement.AddOrGet(); + fitter.horizontalFit = modeHoriz; + fitter.verticalFit = modeVert; + fitter.enabled = true; + } else + FitSizeNow(uiElement, modeHoriz, modeVert); + return uiElement; + } + + /// + /// Creates a UI game object. + /// + /// The parent of the UI object. If not set now, or added/changed + /// later, the anchors must be redefined. + /// The object name. + /// true to add a canvas renderer, or false otherwise. + /// How to anchor the object horizontally. + /// How to anchor the object vertically. + /// The UI object with transform and canvas initialized. + public static GameObject CreateUI(GameObject parent, string name, bool canvas = true, + PUIAnchoring horizAnchor = PUIAnchoring.Stretch, + PUIAnchoring vertAnchor = PUIAnchoring.Stretch) { + var element = new GameObject(name); + if (parent != null) + element.SetParent(parent); + // Size and position + var transform = element.AddOrGet(); + transform.localScale = Vector3.one; + SetAnchors(element, horizAnchor, vertAnchor); + // Almost all UI components need a canvas renderer for some reason + if (canvas) + element.AddComponent(); + element.layer = LayerMask.NameToLayer("UI"); + return element; + } + + /// + /// Does nothing, to make the buttons appear. + /// + private static void DoNothing() { } + + /// + /// Fits the UI element's size immediately, as if ContentSizeFitter was created on it, + /// but does not create a component and only affects the size once. + /// + /// The element to resize. + /// The sizing mode to use in the horizontal direction. + /// The sizing mode to use in the vertical direction. + /// The UI element, for call chaining. + private static void FitSizeNow(GameObject uiElement, ContentFitMode modeHoriz, + ContentFitMode modeVert) { + float width = 0.0f, height = 0.0f; + if (uiElement == null) + throw new ArgumentNullException(nameof(uiElement)); + // Follow order in https://docs.unity3d.com/Manual/UIAutoLayout.html + var elements = uiElement.GetComponents(); + var constraints = uiElement.AddOrGet(); + var rt = uiElement.AddOrGet(); + // Calculate horizontal + foreach (var layoutElement in elements) + layoutElement.CalculateLayoutInputHorizontal(); + if (modeHoriz != ContentFitMode.Unconstrained) { + // Layout horizontal + foreach (var layoutElement in elements) + switch (modeHoriz) { + case ContentFitMode.MinSize: + width = Math.Max(width, layoutElement.minWidth); + break; + case ContentFitMode.PreferredSize: + width = Math.Max(width, layoutElement.preferredWidth); + break; + default: + break; + } + width = Math.Max(width, constraints.minWidth); + rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, width); + constraints.minWidth = width; + constraints.flexibleWidth = 0.0f; + } + // Calculate vertical + foreach (var layoutElement in elements) + layoutElement.CalculateLayoutInputVertical(); + if (modeVert != ContentFitMode.Unconstrained) { + // Layout vertical + foreach (var layoutElement in elements) + switch (modeVert) { + case ContentFitMode.MinSize: + height = Math.Max(height, layoutElement.minHeight); + break; + case ContentFitMode.PreferredSize: + height = Math.Max(height, layoutElement.preferredHeight); + break; + default: + break; + } + height = Math.Max(height, constraints.minHeight); + rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height); + constraints.minHeight = height; + constraints.flexibleHeight = 0.0f; + } + } + + /// + /// Sets the anchor location of a UI element. The offsets will be reset, use + /// SetAnchorOffsets to adjust the offset from the new anchor locations. + /// + /// The UI element to modify. + /// The horizontal anchor mode. + /// The vertical anchor mode. + /// The UI element, for call chaining. + public static GameObject SetAnchors(GameObject uiElement, PUIAnchoring horizAnchor, + PUIAnchoring vertAnchor) { + Vector2 aMax = new Vector2(), aMin = new Vector2(), pivot = new Vector2(); + if (uiElement == null) + throw new ArgumentNullException(nameof(uiElement)); + var transform = uiElement.rectTransform(); + // Anchor: horizontal + switch (horizAnchor) { + case PUIAnchoring.Center: + aMin.x = 0.5f; + aMax.x = 0.5f; + pivot.x = 0.5f; + break; + case PUIAnchoring.End: + aMin.x = 1.0f; + aMax.x = 1.0f; + pivot.x = 1.0f; + break; + case PUIAnchoring.Stretch: + aMin.x = 0.0f; + aMax.x = 1.0f; + pivot.x = 0.5f; + break; + default: + aMin.x = 0.0f; + aMax.x = 0.0f; + pivot.x = 0.0f; + break; + } + // Anchor: vertical + switch (vertAnchor) { + case PUIAnchoring.Center: + aMin.y = 0.5f; + aMax.y = 0.5f; + pivot.y = 0.5f; + break; + case PUIAnchoring.End: + aMin.y = 1.0f; + aMax.y = 1.0f; + pivot.y = 1.0f; + break; + case PUIAnchoring.Stretch: + aMin.y = 0.0f; + aMax.y = 1.0f; + pivot.y = 0.5f; + break; + default: + aMin.y = 0.0f; + aMax.y = 0.0f; + pivot.y = 0.0f; + break; + } + transform.anchorMax = aMax; + transform.anchorMin = aMin; + transform.pivot = pivot; + transform.anchoredPosition = Vector2.zero; + transform.offsetMax = Vector2.zero; + transform.offsetMin = Vector2.zero; + return uiElement; + } + + /// + /// Sets the offsets of the UI component from its anchors. Positive for each value + /// denotes towards the component center, and negative away from the component center. + /// + /// The UI element to modify. + /// The offset of each corner from the anchors. + /// The UI element, for call chaining. + public static GameObject SetAnchorOffsets(GameObject uiElement, RectOffset border) { + return SetAnchorOffsets(uiElement, border.left, border.right, border.top, border. + bottom); + } + + /// + /// Sets the offsets of the UI component from its anchors. Positive for each value + /// denotes towards the component center, and negative away from the component center. + /// + /// The UI element to modify. + /// The left border in pixels. + /// The right border in pixels. + /// The top border in pixels. + /// The bottom border in pixels. + /// The UI element, for call chaining. + public static GameObject SetAnchorOffsets(GameObject uiElement, float left, + float right, float top, float bottom) { + if (uiElement == null) + throw new ArgumentNullException(nameof(uiElement)); + var transform = uiElement.rectTransform(); + transform.offsetMin = new Vector2(left, bottom); + transform.offsetMax = new Vector2(-right, -top); + return uiElement; + } + + /// + /// Sets a UI element's text. + /// + /// The UI element to modify. + /// The text to display on the element. + /// The UI element, for call chaining. + public static GameObject SetText(GameObject uiElement, string text) { + if (uiElement == null) + throw new ArgumentNullException(nameof(uiElement)); + var lt = uiElement.GetComponentInChildren(); + if (lt != null) + lt.SetText(text ?? string.Empty); + return uiElement; + } + + /// + /// Sets a UI element's tool tip. + /// + /// The UI element to modify. + /// The tool tip text to display when hovered. + /// The UI element, for call chaining. + public static GameObject SetToolTip(GameObject uiElement, string tooltip) { + if (uiElement == null) + throw new ArgumentNullException(nameof(uiElement)); + if (!string.IsNullOrEmpty(tooltip)) + uiElement.AddOrGet().toolTip = tooltip; + return uiElement; + } + + /// + /// Shows a confirmation dialog. + /// + /// The dialog's parent. + /// The message to display. + /// The action to invoke if Yes or OK is selected. + /// The action to invoke if No or Cancel is selected. + /// The text for the OK/Yes button. + /// The text for the Cancel/No button. + /// The dialog created. + public static ConfirmDialogScreen ShowConfirmDialog(GameObject parent, string message, + System.Action onConfirm, System.Action onCancel = null, + string confirmText = null, string cancelText = null) { + if (parent == null) + parent = PDialog.GetParentObject(); + var obj = Util.KInstantiateUI(ScreenPrefabs.Instance.ConfirmDialogScreen. + gameObject, parent, false); + if (obj.TryGetComponent(out ConfirmDialogScreen confirmDialog)) { + UIDetours.POPUP_CONFIRM.Invoke(confirmDialog, message, onConfirm, onCancel ?? + DoNothing, null, null, null, confirmText, cancelText); + obj.SetActive(true); + } else + confirmDialog = null; + return confirmDialog; + } + + /// + /// Shows a message dialog. + /// + /// The dialog's parent. + /// The message to display. + /// The dialog created. + public static ConfirmDialogScreen ShowMessageDialog(GameObject parent, string message) + { + return ShowConfirmDialog(parent, message, DoNothing); + } + + // This class should probably be static, but that might be binary compatibility + // breaking + private PUIElements() { } + } + + /// + /// The anchor mode to set a UI component. + /// + public enum PUIAnchoring { + // Stretch to all + Stretch, + // Relative to beginning + Beginning, + // Relative to center + Center, + // Relative to end + End + } +} diff --git a/mod/PLibUI/PUITuning.cs b/mod/PLibUI/PUITuning.cs new file mode 100644 index 0000000..4c7e5a4 --- /dev/null +++ b/mod/PLibUI/PUITuning.cs @@ -0,0 +1,432 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System.Collections.Generic; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +namespace PeterHan.PLib.UI { + /// + /// Sets up common parameters for the UI in PLib based mods. Note that this class is still + /// specific to individual mods so the values in the latest PLib will not supersede them. + /// + public static class PUITuning { + /// + /// UI images. + /// + public static class Images { + /// + /// The right arrow image. Rotate it in the Image to get more directions. + /// + public static Sprite Arrow { get; } + + /// + /// The image used to make a 1px solid black border. + /// + public static Sprite BoxBorder { get; } + + /// + /// The image used to make a 1px solid white border. + /// + public static Sprite BoxBorderWhite { get; } + + /// + /// The default image used for button appearance. + /// + public static Sprite ButtonBorder { get; } + + /// + /// The border image around a checkbox. + /// + public static Sprite CheckBorder { get; } + + /// + /// The image for a check box which is checked. + /// + public static Sprite Checked { get; } + + /// + /// The image used for dialog close buttons. + /// + public static Sprite Close { get; } + + /// + /// The image for contracting a category. + /// + public static Sprite Contract { get; } + + /// + /// The image for expanding a category. + /// + public static Sprite Expand { get; } + + /// + /// The image for a check box which is neither checked nor unchecked. + /// + public static Sprite Partial { get; } + + /// + /// The border of a horizontal scroll bar. + /// + public static Sprite ScrollBorderHorizontal { get; } + + /// + /// The handle of a horizontal scroll bar. + /// + public static Sprite ScrollHandleHorizontal { get; } + + /// + /// The border of a vertical scroll bar. + /// + public static Sprite ScrollBorderVertical { get; } + + /// + /// The handle of a vertical scroll bar. + /// + public static Sprite ScrollHandleVertical { get; } + + /// + /// The handle of a horizontal slider. + /// + public static Sprite SliderHandle { get; } + + /// + /// The sprite dictionary. + /// + private static readonly IDictionary SPRITES; + + static Images() { + SPRITES = new Dictionary(512); + // List out all sprites shipped with the game + foreach (var img in Resources.FindObjectsOfTypeAll()) { + string name = img?.name; + if (!string.IsNullOrEmpty(name) && !SPRITES.ContainsKey(name)) + SPRITES.Add(name, img); + } + + Arrow = GetSpriteByName("game_speed_play"); + BoxBorder = GetSpriteByName("web_box"); + BoxBorderWhite = GetSpriteByName("web_border"); + ButtonBorder = GetSpriteByName("web_button"); + CheckBorder = GetSpriteByName("overview_jobs_skill_box"); + Checked = GetSpriteByName("overview_jobs_icon_checkmark"); + Close = GetSpriteByName("cancel"); + Contract = GetSpriteByName("iconDown"); + Expand = GetSpriteByName("iconRight"); + Partial = GetSpriteByName("overview_jobs_icon_mixed"); + ScrollBorderHorizontal = GetSpriteByName("build_menu_scrollbar_frame_horizontal"); + ScrollHandleHorizontal = GetSpriteByName("build_menu_scrollbar_inner_horizontal"); + ScrollBorderVertical = GetSpriteByName("build_menu_scrollbar_frame"); + ScrollHandleVertical = GetSpriteByName("build_menu_scrollbar_inner"); + SliderHandle = GetSpriteByName("game_speed_selected_med"); + } + + /// + /// Retrieves a sprite by its name. + /// + /// The sprite name. + /// The matching sprite, or null if no sprite found in the resources has that name. + public static Sprite GetSpriteByName(string name) { + if (!SPRITES.TryGetValue(name, out Sprite sprite)) + sprite = null; + return sprite; + } + } + + /// + /// UI colors. + /// + public static class Colors { + /// + /// A white color used for default backgrounds. + /// + public static Color BackgroundLight { get; } + + /// + /// The color styles used on pink buttons. + /// + public static ColorStyleSetting ButtonPinkStyle { get; } + + /// + /// The color styles used on blue buttons. + /// + public static ColorStyleSetting ButtonBlueStyle { get; } + + /// + /// The default colors used on check boxes / toggles with dark backgrounds. + /// + public static ColorStyleSetting ComponentDarkStyle { get; } + + /// + /// The default colors used on check boxes / toggles with white backgrounds. + /// + public static ColorStyleSetting ComponentLightStyle { get; } + + /// + /// The color displayed on dialog backgrounds. + /// + public static Color DialogBackground { get; } + + /// + /// The color displayed in the large border around the outsides of options dialogs. + /// + public static Color DialogDarkBackground { get; } + + /// + /// The color displayed on options dialog backgrounds. + /// + public static Color OptionsBackground { get; } + + /// + /// The color displayed on scrollbar handles. + /// + public static ColorBlock ScrollbarColors { get; } + + /// + /// The background color for selections. + /// + public static Color SelectionBackground { get; } + + /// + /// The foreground color for selections. + /// + public static Color SelectionForeground { get; } + + /// + /// A completely transparent color. + /// + public static Color Transparent { get; } + + /// + /// Used for dark-colored UI text. + /// + public static Color UITextDark { get; } + + /// + /// Used for light-colored UI text. + /// + public static Color UITextLight { get; } + + static Colors() { + BackgroundLight = new Color32(255, 255, 255, 255); + DialogBackground = new Color32(0, 0, 0, 255); + DialogDarkBackground = new Color32(48, 52, 67, 255); + OptionsBackground = new Color32(31, 34, 43, 255); + SelectionBackground = new Color32(189, 218, 255, 255); + SelectionForeground = new Color32(0, 0, 0, 255); + Transparent = new Color32(255, 255, 255, 0); + UITextLight = new Color32(255, 255, 255, 255); + UITextDark = new Color32(0, 0, 0, 255); + + // Check boxes + Color active = new Color(0.0f, 0.0f, 0.0f), disabled = new Color(0.784f, + 0.784f, 0.784f, 1.0f); + ComponentLightStyle = ScriptableObject.CreateInstance(); + ComponentLightStyle.activeColor = active; + ComponentLightStyle.inactiveColor = active; + ComponentLightStyle.hoverColor = active; + ComponentLightStyle.disabledActiveColor = disabled; + ComponentLightStyle.disabledColor = disabled; + ComponentLightStyle.disabledhoverColor = disabled; + + active = new Color(1.0f, 1.0f, 1.0f); + ComponentDarkStyle = ScriptableObject.CreateInstance(); + ComponentDarkStyle.activeColor = active; + ComponentDarkStyle.inactiveColor = active; + ComponentDarkStyle.hoverColor = active; + ComponentDarkStyle.disabledActiveColor = disabled; + ComponentDarkStyle.disabledColor = disabled; + ComponentDarkStyle.disabledhoverColor = disabled; + + // Buttons: pink + ButtonPinkStyle = ScriptableObject.CreateInstance(); + ButtonPinkStyle.activeColor = new Color(0.7941176f, 0.4496107f, 0.6242238f); + ButtonPinkStyle.inactiveColor = new Color(0.5294118f, 0.2724914f, 0.4009516f); + ButtonPinkStyle.disabledColor = new Color(0.4156863f, 0.4117647f, 0.4f); + ButtonPinkStyle.disabledActiveColor = Transparent; + ButtonPinkStyle.hoverColor = new Color(0.6176471f, 0.3315311f, 0.4745891f); + ButtonPinkStyle.disabledhoverColor = new Color(0.5f, 0.5f, 0.5f); + + // Buttons: blue + ButtonBlueStyle = ScriptableObject.CreateInstance(); + ButtonBlueStyle.activeColor = new Color(0.5033521f, 0.5444419f, 0.6985294f); + ButtonBlueStyle.inactiveColor = new Color(0.2431373f, 0.2627451f, 0.3411765f); + ButtonBlueStyle.disabledColor = new Color(0.4156863f, 0.4117647f, 0.4f); + ButtonBlueStyle.disabledActiveColor = new Color(0.625f, 0.6158088f, 0.5882353f); + ButtonBlueStyle.hoverColor = new Color(0.3461289f, 0.3739619f, 0.4852941f); + ButtonBlueStyle.disabledhoverColor = new Color(0.5f, 0.4898898f, 0.4595588f); + + // Scrollbars + ScrollbarColors = new ColorBlock { + colorMultiplier = 1.0f, + fadeDuration = 0.1f, + disabledColor = new Color(0.392f, 0.392f, 0.392f), + highlightedColor = new Color32(161, 163, 174, 255), + normalColor = new Color32(161, 163, 174, 255), + pressedColor = BackgroundLight + }; + } + } + + /// + /// Collects references to fonts in the game. + /// + public static class Fonts { + /// + /// The text font name. + /// + private const string DEFAULT_FONT_TEXT = "NotoSans-Regular"; + + /// + /// The UI font name. + /// + private const string DEFAULT_FONT_UI = "GRAYSTROKE REGULAR SDF"; + + /// + /// The default font size. + /// + public static int DefaultSize { get; } + + /// + /// The default font asset for text strings. + /// + private static readonly TMP_FontAsset DefaultTextFont; + + /// + /// The default font asset for UI titles and buttons. + /// + private static readonly TMP_FontAsset DefaultUIFont; + + /// + /// The font used on text. + /// + internal static TMP_FontAsset Text { + get { + TMP_FontAsset font = null; + if (Localization.GetSelectedLanguageType() != Localization. + SelectedLanguageType.None) + font = Localization.FontAsset; + return font ?? DefaultTextFont; + } + } + + /// + /// The text styles used on all items with a light background. + /// + public static TextStyleSetting TextDarkStyle { get; } + + /// + /// The text styles used on all items with a dark background. + /// + public static TextStyleSetting TextLightStyle { get; } + + /// + /// The font used on UI elements. + /// + internal static TMP_FontAsset UI { + get { + TMP_FontAsset font = null; + if (Localization.GetSelectedLanguageType() != Localization. + SelectedLanguageType.None) + font = Localization.FontAsset; + return font ?? DefaultUIFont; + } + } + + /// + /// The text styles used on all UI items with a light background. + /// + public static TextStyleSetting UIDarkStyle { get; } + + /// + /// The text styles used on all UI items with a dark background. + /// + public static TextStyleSetting UILightStyle { get; } + + /// + /// The font dictionary. + /// + private static readonly IDictionary FONTS; + + static Fonts() { + FONTS = new Dictionary(16); + // List out all fonts shipped with the game + foreach (var newFont in Resources.FindObjectsOfTypeAll()) { + string name = newFont?.name; + if (!string.IsNullOrEmpty(name) && !FONTS.ContainsKey(name)) + FONTS.Add(name, newFont); + } + + // Initialization: UI fonts + if ((DefaultTextFont = GetFontByName(DEFAULT_FONT_TEXT)) == null) + PUIUtils.LogUIWarning("Unable to find font " + DEFAULT_FONT_TEXT); + if ((DefaultUIFont = GetFontByName(DEFAULT_FONT_UI)) == null) + PUIUtils.LogUIWarning("Unable to find font " + DEFAULT_FONT_UI); + + // Initialization: Text style + DefaultSize = 14; + TextDarkStyle = ScriptableObject.CreateInstance(); + TextDarkStyle.enableWordWrapping = false; + TextDarkStyle.fontSize = DefaultSize; + TextDarkStyle.sdfFont = Text; + TextDarkStyle.style = FontStyles.Normal; + TextDarkStyle.textColor = Colors.UITextDark; + TextLightStyle = TextDarkStyle.DeriveStyle(newColor: Colors.UITextLight); + UIDarkStyle = ScriptableObject.CreateInstance(); + UIDarkStyle.enableWordWrapping = false; + UIDarkStyle.fontSize = DefaultSize; + UIDarkStyle.sdfFont = UI; + UIDarkStyle.style = FontStyles.Normal; + UIDarkStyle.textColor = Colors.UITextDark; + UILightStyle = UIDarkStyle.DeriveStyle(newColor: Colors.UITextLight); + } + + /// + /// Retrieves a font by its name. + /// + /// The font name. + /// The matching font, or null if no font found in the resources has that name. + internal static TMP_FontAsset GetFontByName(string name) { + if (!FONTS.TryGetValue(name, out TMP_FontAsset font)) + font = null; + return font; + } + } + + /// + /// The sounds played by the button. + /// + internal static ButtonSoundPlayer ButtonSounds { get; } + + /// + /// The sounds played by the toggle. + /// + internal static ToggleSoundPlayer ToggleSounds { get; } + + + static PUITuning() { + // Initialization: Button sounds + ButtonSounds = new ButtonSoundPlayer() { + Enabled = true + }; + ToggleSounds = new ToggleSoundPlayer(); + } + } +} diff --git a/mod/PLibUI/PUIUtils.cs b/mod/PLibUI/PUIUtils.cs new file mode 100644 index 0000000..cf76273 --- /dev/null +++ b/mod/PLibUI/PUIUtils.cs @@ -0,0 +1,801 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Core; +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +using SideScreenRef = DetailsScreen.SideScreenRef; + +namespace PeterHan.PLib.UI { + /// + /// Utility functions for dealing with Unity UIs. + /// + public static class PUIUtils { + /// + /// Adds text describing a particular component if available. + /// + /// The location to append the text. + /// The component to describe. + private static void AddComponentText(StringBuilder result, Component component) { + // Include all fields + var fields = component.GetType().GetFields(BindingFlags.DeclaredOnly | + BindingFlags.Instance | PPatchTools.BASE_FLAGS); + // Class specific + if (component is TMP_Text lt) + result.AppendFormat(", Text={0}, Color={1}, Font={2}", lt.text, lt.color, + lt.font); + else if (component is Image im) { + result.AppendFormat(", Color={0}", im.color); + if (im.sprite != null) + result.AppendFormat(", Sprite={0}", im.sprite); + } else if (component is HorizontalOrVerticalLayoutGroup lg) + result.AppendFormat(", Child Align={0}, Control W={1}, Control H={2}", + lg.childAlignment, lg.childControlWidth, lg.childControlHeight); + foreach (var field in fields) { + object value = field.GetValue(component) ?? "null"; + // Value type specific + if (value is LayerMask lm) + value = "Layer #" + lm.value; + else if (value is System.Collections.ICollection ic) + value = "[" + ic.Join() + "]"; + else if (value is Array ar) + value = "[" + ar.Join() + "]"; + result.AppendFormat(", {0}={1}", field.Name, value); + } + } + + /// + /// Adds a hot pink rectangle over the target matching its size, to help identify it + /// better. + /// + /// The target UI component. + public static void AddPinkOverlay(GameObject parent) { + var child = PUIElements.CreateUI(parent, "Overlay"); + var img = child.AddComponent(); + img.color = new Color(1.0f, 0.0f, 1.0f, 0.2f); + } + + /// + /// Adds the specified side screen content to the side screen list. The side screen + /// behavior should be defined in a class inherited from SideScreenContent. + /// + /// The side screen will be added at the end of the list, which will cause it to + /// appear above previous side screens in the details panel. + /// + /// This method should be used in a postfix on DetailsScreen.OnPrefabInit. + /// + /// The type of the controller that will determine how the side + /// screen works. A new instance will be created and added as a component to the new + /// side screen. + /// The UI prefab to use. If null is passed, the UI should + /// be created and added to the GameObject hosting the controller object in its + /// constructor. + public static void AddSideScreenContent(GameObject uiPrefab = null) + where T : SideScreenContent { + AddSideScreenContentWithOrdering(null, true, uiPrefab); + } + + /// + /// Adds the specified side screen content to the side screen list. The side screen + /// behavior should be defined in a class inherited from SideScreenContent. + /// + /// This method should be used in a postfix on DetailsScreen.OnPrefabInit. + /// + /// The type of the controller that will determine how the side + /// screen works. A new instance will be created and added as a component to the new + /// side screen. + /// The full name of the type of side screen to based to ordering + /// around. An example of how this method can be used is: + /// `AddSideScreenContentWithOrdering<MySideScreen>(typeof(CapacityControlSideScreen).FullName);` + /// `typeof(TargetedSideScreen).FullName` is the suggested value of this parameter. + /// Side screens from other mods can be used with their qualified names, even if no + /// reference to their type is available, but the target mod must have added their + /// custom side screen to the list first. + /// Whether to insert the new screen before or after the + /// target side screen in the list. Defaults to before (true). + /// When inserting before the screen, if both are valid for a building then the side + /// screen of type "T" will show below the one of type "fullName". When inserting after + /// the screen, the reverse is true. + /// The UI prefab to use. If null is passed, the UI should + /// be created and added to the GameObject hosting the controller object in its + /// constructor. + public static void AddSideScreenContentWithOrdering(string targetClassName, + bool insertBefore = true, GameObject uiPrefab = null) + where T : SideScreenContent { + var inst = DetailsScreen.Instance; + if (inst == null) + LogUIWarning("DetailsScreen is not yet initialized, try a postfix on DetailsScreen.OnPrefabInit"); + else { + var screens = UIDetours.SIDE_SCREENS.Get(inst); + var body = UIDetours.SS_CONTENT_BODY.Get(inst); + string name = typeof(T).Name; + if (body != null && screens != null) { + // The ref normally contains a prefab which is instantiated + var newScreen = new DetailsScreen.SideScreenRef(); + // Mimic the basic screens + var rootObject = PUIElements.CreateUI(body, name); + // Preserve the border by fitting the child + rootObject.AddComponent().Params = new BoxLayoutParams() { + Direction = PanelDirection.Vertical, Alignment = TextAnchor. + UpperCenter, Margin = new RectOffset(1, 1, 0, 1) + }; + var controller = rootObject.AddComponent(); + if (uiPrefab != null) { + // Add prefab if supplied + UIDetours.SS_CONTENT_CONTAINER.Set(controller, uiPrefab); + uiPrefab.transform.SetParent(rootObject.transform); + } + newScreen.name = name; + // Offset is never used + UIDetours.SS_OFFSET.Set(newScreen, Vector2.zero); + UIDetours.SS_PREFAB.Set(newScreen, controller); + UIDetours.SS_INSTANCE.Set(newScreen, controller); + InsertSideScreenContent(screens, newScreen, targetClassName, insertBefore); + } + } + } + + /// + /// Builds a PLib UI object and adds it to an existing UI object. + /// + /// The UI object to add. + /// The parent of the new object. + /// The sibling index to insert the element at, if provided. + /// The built version of the UI object. + public static GameObject AddTo(this IUIComponent component, GameObject parent, + int index = -2) { + if (component == null) + throw new ArgumentNullException(nameof(component)); + if (parent == null) + throw new ArgumentNullException(nameof(parent)); + var child = component.Build(); + child.SetParent(parent); + if (index == -1) + child.transform.SetAsLastSibling(); + else if (index >= 0) + child.transform.SetSiblingIndex(index); + return child; + } + + /// + /// Calculates the size of a single game object. + /// + /// The object to calculate. + /// The direction to calculate. + /// The components of this game object. + /// The object's minimum and preferred size. + internal static LayoutSizes CalcSizes(GameObject obj, PanelDirection direction, + IEnumerable components) { + float min = 0.0f, preferred = 0.0f, flexible = 0.0f, scaleFactor; + int minPri = int.MinValue, prefPri = int.MinValue, flexPri = int.MinValue; + var scale = obj.transform.localScale; + // Find the correct scale direction + if (direction == PanelDirection.Horizontal) + scaleFactor = Math.Abs(scale.x); + else + scaleFactor = Math.Abs(scale.y); + bool ignore = false; + foreach (var component in components) { + if ((component as ILayoutIgnorer)?.ignoreLayout == true) { + ignore = true; + break; + } + if ((component as Behaviour)?.isActiveAndEnabled != false && component is + ILayoutElement le) { + int lp = le.layoutPriority; + // Calculate must come first + if (direction == PanelDirection.Horizontal) { + le.CalculateLayoutInputHorizontal(); + PriValue(ref min, le.minWidth, lp, ref minPri); + PriValue(ref preferred, le.preferredWidth, lp, ref prefPri); + PriValue(ref flexible, le.flexibleWidth, lp, ref flexPri); + } else { // if (direction == PanelDirection.Vertical) + le.CalculateLayoutInputVertical(); + PriValue(ref min, le.minHeight, lp, ref minPri); + PriValue(ref preferred, le.preferredHeight, lp, ref prefPri); + PriValue(ref flexible, le.flexibleHeight, lp, ref flexPri); + } + } + } + return new LayoutSizes(obj, min * scaleFactor, Math.Max(min, preferred) * + scaleFactor, flexible) { ignore = ignore }; + } + + /// + /// Dumps information about the parent tree of the specified GameObject to the debug + /// log. + /// + /// The item to determine hierarchy. + public static void DebugObjectHierarchy(this GameObject item) { + string info = "null"; + if (item != null) { + var result = new StringBuilder(256); + do { + result.Append("- "); + result.Append(item.name ?? "Unnamed"); + item = item.transform?.parent?.gameObject; + if (item != null) + result.AppendLine(); + } while (item != null); + info = result.ToString(); + } + LogUIDebug("Object Tree:" + Environment.NewLine + info); + } + + /// + /// Dumps information about the specified GameObject to the debug log. + /// + /// The root hierarchy to dump. + public static void DebugObjectTree(this GameObject root) { + string info = "null"; + if (root != null) + info = GetObjectTree(root, 0); + LogUIDebug("Object Dump:" + Environment.NewLine + info); + } + + /// + /// Derives a font style from an existing style. The font face is copied unchanged, + /// but the other settings can be optionally modified. + /// + /// The style to use as a template. + /// The font size, or 0 to use the template size. + /// The font color, or null to use the template color. + /// The font style, or null to use the template style. + /// A copy of the root style with the specified parameters altered. + public static TextStyleSetting DeriveStyle(this TextStyleSetting root, int size = 0, + Color? newColor = null, FontStyles? style = null) { + if (root == null) + throw new ArgumentNullException(nameof(root)); + var newStyle = ScriptableObject.CreateInstance(); + newStyle.enableWordWrapping = root.enableWordWrapping; + newStyle.style = (style == null) ? root.style : (FontStyles)style; + newStyle.fontSize = (size > 0) ? size : root.fontSize; + newStyle.sdfFont = root.sdfFont; + newStyle.textColor = (newColor == null) ? root.textColor : (Color)newColor; + return newStyle; + } + + /// + /// A debug function used to forcefully re-layout a UI. + /// + /// The UI to layout + /// The UI element, for call chaining. + public static GameObject ForceLayoutRebuild(GameObject uiElement) { + if (uiElement == null) + throw new ArgumentNullException(nameof(uiElement)); + var rt = uiElement.rectTransform(); + if (rt != null) + LayoutRebuilder.ForceRebuildLayoutImmediate(rt); + return uiElement; + } + + /// + /// Retrieves the estimated width of a single string character (uses 'm' as the + /// standard estimation character) in the given text style. + /// + /// The text style to use. + /// The width in pixels that should be allocated. + public static float GetEmWidth(TextStyleSetting style) { + float width = 0.0f; + if (style == null) + throw new ArgumentNullException(nameof(style)); + var font = style.sdfFont; + // Use the em width + if (font != null && font.characterDictionary.TryGetValue('m', out TMP_Glyph em)) { + var info = font.fontInfo; + float ptSize = style.fontSize / (info.PointSize * info.Scale); + width = em.width * ptSize + style.fontSize * 0.01f * font.normalSpacingOffset; + } + return width; + } + + /// + /// Retrieves the estimated height of one line of text in the given text style. + /// + /// The text style to use. + /// The height in pixels that should be allocated. + public static float GetLineHeight(TextStyleSetting style) { + float height = 0.0f; + if (style == null) + throw new ArgumentNullException(nameof(style)); + var font = style.sdfFont; + if (font != null) { + var info = font.fontInfo; + height = info.LineHeight * style.fontSize / (info.Scale * info.PointSize); + } + return height; + } + + /// + /// Creates a string recursively describing the specified GameObject. + /// + /// The root GameObject hierarchy. + /// The indentation to use. + /// A string describing this game object. + private static string GetObjectTree(GameObject root, int indent) { + var result = new StringBuilder(1024); + // Calculate indent to make nested reading easier + var solBuilder = new StringBuilder(indent); + for (int i = 0; i < indent; i++) + solBuilder.Append(' '); + string sol = solBuilder.ToString(); + var transform = root.transform; + int n = transform.childCount; + // Basic information + result.Append(sol).AppendFormat("GameObject[{0}, {1:D} child(ren), Layer {2:D}, " + + "Active={3}]", root.name, n, root.layer, root.activeInHierarchy).AppendLine(); + // Transformation + result.Append(sol).AppendFormat(" Translation={0} [{3}] Rotation={1} [{4}] " + + "Scale={2}", transform.position, transform.rotation, transform. + localScale, transform.localPosition, transform.localRotation).AppendLine(); + // Components + foreach (var component in root.GetComponents()) { + if (component is RectTransform rt) { + // UI rectangle + Vector2 size = rt.rect.size, aMin = rt.anchorMin, aMax = rt.anchorMax, + oMin = rt.offsetMin, oMax = rt.offsetMax, pivot = rt.pivot; + result.Append(sol).AppendFormat(" Rect[Size=({0:F2},{1:F2}) Min=" + + "({2:F2},{3:F2}) ", size.x, size.y, LayoutUtility.GetMinWidth(rt), + LayoutUtility.GetMinHeight(rt)); + result.AppendFormat("Preferred=({0:F2},{1:F2}) Flexible=({2:F2}," + + "{3:F2}) ", LayoutUtility.GetPreferredWidth(rt), LayoutUtility. + GetPreferredHeight(rt), LayoutUtility.GetFlexibleWidth(rt), + LayoutUtility.GetFlexibleHeight(rt)); + result.AppendFormat("Pivot=({4:F2},{5:F2}) AnchorMin=({0:F2},{1:F2}) " + + "AnchorMax=({2:F2},{3:F2}) ", aMin.x, aMin.y, aMax.x, aMax.y, pivot.x, + pivot.y); + result.AppendFormat("OffsetMin=({0:F2},{1:F2}) OffsetMax=({2:F2}," + + "{3:F2})]", oMin.x, oMin.y, oMax.x, oMax.y).AppendLine(); + } else if (component != null && !(component is Transform)) { + // Exclude destroyed components and Transform objects + result.Append(sol).Append(" Component[").Append(component.GetType(). + FullName); + AddComponentText(result, component); + result.AppendLine("]"); + } + } + // Children + if (n > 0) + result.Append(sol).AppendLine(" Children:"); + for (int i = 0; i < n; i++) { + var child = transform.GetChild(i).gameObject; + if (child != null) + // Exclude destroyed objects + result.AppendLine(GetObjectTree(child, indent + 2)); + } + return result.ToString().TrimEnd(); + } + + /// + /// Determines the size for a component on a particular axis. + /// + /// The declared sizes. + /// The space allocated. + /// The size that the component should be. + internal static float GetProperSize(LayoutSizes sizes, float allocated) { + float size = sizes.min, preferred = Math.Max(sizes.preferred, size); + // Compute size: minimum guaranteed, then preferred, then flexible + if (allocated > size) + size = Math.Min(preferred, allocated); + if (allocated > preferred && sizes.flexible > 0.0f) + size = allocated; + return size; + } + + /// + /// Gets the offset required for a component in its box. + /// + /// The alignment to use. + /// The direction of layout. + /// The remaining space. + /// The offset from the edge. + internal static float GetOffset(TextAnchor alignment, PanelDirection direction, + float delta) { + float offset = 0.0f; + // Based on alignment, offset component + if (direction == PanelDirection.Horizontal) + switch (alignment) { + case TextAnchor.LowerCenter: + case TextAnchor.MiddleCenter: + case TextAnchor.UpperCenter: + offset = delta * 0.5f; + break; + case TextAnchor.LowerRight: + case TextAnchor.MiddleRight: + case TextAnchor.UpperRight: + offset = delta; + break; + default: + break; + } else + switch (alignment) { + case TextAnchor.MiddleLeft: + case TextAnchor.MiddleCenter: + case TextAnchor.MiddleRight: + offset = delta * 0.5f; + break; + case TextAnchor.LowerLeft: + case TextAnchor.LowerCenter: + case TextAnchor.LowerRight: + offset = delta; + break; + default: + break; + } + return offset; + } + + /// + /// Retrieves the parent of the GameObject, or null if it does not have a parent. + /// + /// The child object. + /// The parent of that object, or null if it does not have a parent. + public static GameObject GetParent(this GameObject child) { + GameObject parent = null; + if (child != null) { + var newParent = child.transform.parent?.gameObject; + // If parent is disposed, prevent crash + if (newParent != null) + parent = newParent; + } + return parent; + } + + /// + /// Insets a child component from its parent, and assigns a fixed size to the parent + /// equal to the provided size plus the insets. + /// + /// The parent component. + /// The child to inset. + /// The vertical inset on each side. + /// The horizontal inset on each side. + /// The minimum component size. + /// The parent component. + internal static GameObject InsetChild(GameObject parent, GameObject child, + Vector2 insets, Vector2 prefSize = default(Vector2)) { + var rt = child.rectTransform(); + float horizontal = insets.x, vertical = insets.y, width = prefSize.x, height = + prefSize.y; + rt.offsetMax = new Vector2(-horizontal, -vertical); + rt.offsetMin = insets; + var layout = parent.AddOrGet(); + layout.minWidth = layout.preferredWidth = (width <= 0.0f ? LayoutUtility. + GetPreferredWidth(rt) : width) + horizontal * 2.0f; + layout.minHeight = layout.preferredHeight = (height <= 0.0f ? LayoutUtility. + GetPreferredHeight(rt) : height) + vertical * 2.0f; + return parent; + } + + /// + /// Inserts the side screen at the target location. + /// + /// The current list of side screens. + /// The screen to insert. + /// The target class name for locating the screen. If this + /// class is not found, it will be added at the end regardless of insertBefore. + /// true to insert before that class, or false to insert after. + private static void InsertSideScreenContent(IList screens, + SideScreenRef newScreen, string targetClassName, bool insertBefore) { + if (screens == null) + throw new ArgumentNullException(nameof(screens)); + if (newScreen == null) + throw new ArgumentNullException(nameof(newScreen)); + if (string.IsNullOrEmpty(targetClassName)) + // Add to end by default + screens.Add(newScreen); + else { + int n = screens.Count; + bool found = false; + for (int i = 0; i < n; i++) { + var screen = screens[i]; + var sideScreenPrefab = UIDetours.SS_PREFAB.Get(screen); + if (sideScreenPrefab != null) { + var contents = sideScreenPrefab. + GetComponentsInChildren(); + if (contents == null || contents.Length < 1) + // Some naughty mod added a prefab with no side screen content! + LogUIWarning("Could not find SideScreenContent on side screen: " + + screen.name); + else if (contents[0].GetType().FullName == targetClassName) { + // Once the first matching screen is found, perform insertion + if (insertBefore) + screens.Insert(i, newScreen); + else if (i >= n - 1) + screens.Add(newScreen); + else + screens.Insert(i + 1, newScreen); + found = true; + break; + } + } + } + // Warn if no match found + if (!found) { + LogUIWarning("No side screen with class name {0} found!".F( + targetClassName)); + screens.Add(newScreen); + } + } + } + + /// + /// Loads a sprite embedded in the calling assembly. + /// + /// It may be encoded using PNG, DXT5, or JPG format. + /// + /// The fully qualified path to the image to load. + /// The sprite border. If there is no 9-patch border, use default(Vector4). + /// true to log the sprite load, or false to load silently. + /// The sprite thus loaded. + /// If the image could not be loaded. + public static Sprite LoadSprite(string path, Vector4 border = default, bool log = true) + { + return LoadSprite(Assembly.GetCallingAssembly() ?? Assembly.GetExecutingAssembly(), + path, border, log); + } + + /// + /// Loads a sprite embedded in the specified assembly as a 9-slice sprite. + /// + /// It may be encoded using PNG, DXT5, or JPG format. + /// + /// The assembly containing the image. + /// The fully qualified path to the image to load. + /// The sprite border. + /// true to log the load, or false otherwise. + /// The sprite thus loaded. + /// If the image could not be loaded. + internal static Sprite LoadSprite(Assembly assembly, string path, Vector4 border = + default, bool log = false) { + // Open a stream to the image + try { + using (var stream = assembly.GetManifestResourceStream(path)) { + if (stream == null) + throw new ArgumentException("Could not load image: " + path); + // If len > int.MaxValue we will not go to space today + int len = (int)stream.Length; + byte[] buffer = new byte[len]; + var texture = new Texture2D(2, 2); + // Load the texture from the stream + stream.Read(buffer, 0, len); + ImageConversion.LoadImage(texture, buffer, false); + // Create a sprite centered on the texture + int width = texture.width, height = texture.height; +#if DEBUG + log = true; +#endif + if (log) + LogUIDebug("Loaded sprite: {0} ({1:D}x{2:D}, {3:D} bytes)".F(path, + width, height, len)); + // pivot is in RELATIVE coordinates! + return Sprite.Create(texture, new Rect(0, 0, width, height), new Vector2( + 0.5f, 0.5f), 100.0f, 0, SpriteMeshType.FullRect, border); + } + } catch (IOException e) { + throw new ArgumentException("Could not load image: " + path, e); + } + } + + /// + /// Loads a sprite from the file system as a 9-slice sprite. + /// + /// It may be encoded using PNG, DXT5, or JPG format. + /// + /// The path to the image to load. + /// The sprite border. + /// true to log the load, or false otherwise. + /// The sprite thus loaded, or null if it could not be loaded. + public static Sprite LoadSpriteFile(string path, Vector4 border = default) { + Sprite sprite = null; + // Open a stream to the image + try { + using (var stream = new FileStream(path, FileMode.Open)) { + // If len > int.MaxValue we will not go to space today + int len = (int)stream.Length; + byte[] buffer = new byte[len]; + var texture = new Texture2D(2, 2); + // Load the texture from the stream + stream.Read(buffer, 0, len); + ImageConversion.LoadImage(texture, buffer, false); + // Create a sprite centered on the texture + int width = texture.width, height = texture.height; + sprite = Sprite.Create(texture, new Rect(0, 0, width, height), new Vector2( + 0.5f, 0.5f), 100.0f, 0, SpriteMeshType.FullRect, border); + } + } catch (IOException e) { +#if DEBUG + PUtil.LogExcWarn(e); +#endif + } + return sprite; + } + + /// + /// Loads a DDS sprite embedded in the specified assembly as a 9-slice sprite. + /// + /// It must be encoded using the DXT5 format. + /// + /// The assembly containing the image. + /// The fully qualified path to the DDS image to load. + /// The desired width. + /// The desired height. + /// The sprite border. + /// true to log the load, or false otherwise. + /// The sprite thus loaded. + /// If the image could not be loaded. + internal static Sprite LoadSpriteLegacy(Assembly assembly, string path, int width, + int height, Vector4 border = default) { + // Open a stream to the image + try { + using (var stream = assembly.GetManifestResourceStream(path)) { + const int SKIP = 128; + if (stream == null) + throw new ArgumentException("Could not load image: " + path); + // If len > int.MaxValue we will not go to space today, skip first 128 + // bytes of stream + int len = (int)stream.Length - SKIP; + if (len < 0) + throw new ArgumentException("Image is too small: " + path); + byte[] buffer = new byte[len]; + stream.Seek(SKIP, SeekOrigin.Begin); + stream.Read(buffer, 0, len); + // Load the texture from the stream + var texture = new Texture2D(width, height, TextureFormat.DXT5, false); + texture.LoadRawTextureData(buffer); + texture.Apply(true, true); + // Create a sprite centered on the texture + LogUIDebug("Loaded sprite: {0} ({1:D}x{2:D}, {3:D} bytes)".F(path, + width, height, len)); + // pivot is in RELATIVE coordinates! + return Sprite.Create(texture, new Rect(0, 0, width, height), new Vector2( + 0.5f, 0.5f), 100.0f, 0, SpriteMeshType.FullRect, border); + } + } catch (IOException e) { + throw new ArgumentException("Could not load image: " + path, e); + } + } + + /// + /// Logs a debug message encountered in PLib UI functions. + /// + /// The debug message. + internal static void LogUIDebug(string message) { + Debug.LogFormat("[PLib/UI/{0}] {1}", Assembly.GetCallingAssembly()?.GetName()?. + Name ?? "?", message); + } + + /// + /// Logs a warning encountered in PLib UI functions. + /// + /// The warning message. + internal static void LogUIWarning(string message) { + Debug.LogWarningFormat("[PLib/UI/{0}] {1}", Assembly.GetCallingAssembly()?. + GetName()?.Name ?? "?", message); + } + + /// + /// Aggregates layout values, replacing the value if a higher priority value is given + /// and otherwise taking the largest value. + /// + /// The current value. + /// The candidate new value. No operation if this is less than zero. + /// The new value's layout priority. + /// The current value's priority + private static void PriValue(ref float value, float newValue, int newPri, ref int pri) + { + int thisPri = pri; + if (newValue >= 0.0f) { + if (newPri > thisPri) { + // Priority override? + pri = newPri; + value = newValue; + } else if (newValue > value && newPri == thisPri) + // Same priority and higher value? + value = newValue; + } + } + + /// + /// Sets a UI element's flexible size. + /// + /// The UI element to modify. + /// The flexible size as a ratio. + /// The UI element, for call chaining. + public static GameObject SetFlexUISize(this GameObject uiElement, Vector2 flexSize) { + if (uiElement == null) + throw new ArgumentNullException(nameof(uiElement)); + if (uiElement.TryGetComponent(out ISettableFlexSize fs)) { + // Avoid duplicate LayoutElement on layouts + fs.flexibleWidth = flexSize.x; + fs.flexibleHeight = flexSize.y; + } else { + var le = uiElement.AddOrGet(); + le.flexibleWidth = flexSize.x; + le.flexibleHeight = flexSize.y; + } + return uiElement; + } + + /// + /// Sets a UI element's minimum size. + /// + /// The UI element to modify. + /// The minimum size in units. + /// The UI element, for call chaining. + public static GameObject SetMinUISize(this GameObject uiElement, Vector2 minSize) { + if (uiElement == null) + throw new ArgumentNullException(nameof(uiElement)); + float minX = minSize.x, minY = minSize.y; + if (minX > 0.0f || minY > 0.0f) { + var le = uiElement.AddOrGet(); + if (minX > 0.0f) + le.minWidth = minX; + if (minY > 0.0f) + le.minHeight = minY; + } + return uiElement; + } + + /// + /// Immediately resizes a UI element. Uses the element's current anchors. If a + /// dimension of the size is negative, the component will not be resized in that + /// dimension. + /// + /// If addLayout is true, a layout element is also added so that future auto layout + /// calls will try to maintain that size. Do not set addLayout to true if either of + /// the size dimensions are negative, as laying out components with a negative + /// preferred size may cause unexpected behavior. + /// + /// The UI element to modify. + /// The new element size. + /// true to add a layout element with that size, or false + /// otherwise. + /// The UI element, for call chaining. + public static GameObject SetUISize(this GameObject uiElement, Vector2 size, + bool addLayout = false) { + if (uiElement == null) + throw new ArgumentNullException(nameof(uiElement)); + var transform = uiElement.rectTransform(); + float width = size.x, height = size.y; + if (transform != null) { + if (width >= 0.0f) + transform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, width); + if (height >= 0.0f) + transform.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height); + } + if (addLayout) { + var le = uiElement.AddOrGet(); + // Set minimum and preferred size + le.minWidth = width; + le.minHeight = height; + le.preferredWidth = width; + le.preferredHeight = height; + le.flexibleHeight = 0.0f; + le.flexibleWidth = 0.0f; + } + return uiElement; + } + } +} diff --git a/mod/PLibUI/TextAnchorUtils.cs b/mod/PLibUI/TextAnchorUtils.cs new file mode 100644 index 0000000..164fe38 --- /dev/null +++ b/mod/PLibUI/TextAnchorUtils.cs @@ -0,0 +1,92 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using UnityEngine; + +namespace PeterHan.PLib.UI { + /// + /// Extension methods to deal with TextAnchor alignments. + /// + public static class TextAnchorUtils { + /// + /// Returns true if this text alignment is at the left. + /// + /// The anchoring to check. + /// true if it denotes a Left alignment, or false otherwise. + public static bool IsLeft(this TextAnchor anchor) { + return anchor == TextAnchor.UpperLeft || anchor == TextAnchor.MiddleLeft || + anchor == TextAnchor.LowerLeft; + } + + /// + /// Returns true if this text alignment is at the bottom. + /// + /// The anchoring to check. + /// true if it denotes a Lower alignment, or false otherwise. + public static bool IsLower(this TextAnchor anchor) { + return anchor == TextAnchor.LowerCenter || anchor == TextAnchor.LowerLeft || + anchor == TextAnchor.LowerRight; + } + + /// + /// Returns true if this text alignment is at the right. + /// + /// The anchoring to check. + /// true if it denotes a Right alignment, or false otherwise. + public static bool IsRight(this TextAnchor anchor) { + return anchor == TextAnchor.UpperRight || anchor == TextAnchor.MiddleRight || + anchor == TextAnchor.LowerRight; + } + + /// + /// Returns true if this text alignment is at the top. + /// + /// The anchoring to check. + /// true if it denotes an Upper alignment, or false otherwise. + public static bool IsUpper(this TextAnchor anchor) { + return anchor == TextAnchor.UpperCenter || anchor == TextAnchor.UpperLeft || + anchor == TextAnchor.UpperRight; + } + + /// + /// Mirrors a text alignment horizontally. UpperLeft becomes UpperRight, MiddleLeft + /// becomes MiddleRight, and so forth. + /// + /// The anchoring to mirror. + /// The horizontally reflected version of that mirror. + public static TextAnchor MirrorHorizontal(this TextAnchor anchor) { + int newAnchor = (int)anchor; + // UL UC UR ML MC MR LL LC LR + // Danger will robinson! + newAnchor = 3 * (newAnchor / 3) + 2 - newAnchor % 3; + return (TextAnchor)newAnchor; + } + + /// + /// Mirrors a text alignment vertically. UpperLeft becomes LowerLeft, LowerCenter + /// becomes UpperCenter, and so forth. + /// + /// The anchoring to mirror. + /// The vertically reflected version of that mirror. + public static TextAnchor MirrorVertical(this TextAnchor anchor) { + int newAnchor = (int)anchor; + newAnchor = 6 - 3 * (newAnchor / 3) + newAnchor % 3; + return (TextAnchor)newAnchor; + } + } +} diff --git a/mod/PLibUI/UIDetours.cs b/mod/PLibUI/UIDetours.cs new file mode 100644 index 0000000..12d96fd --- /dev/null +++ b/mod/PLibUI/UIDetours.cs @@ -0,0 +1,97 @@ +/* + * Copyright 2022 Peter Han + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using PeterHan.PLib.Detours; +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; + +using SideScreenRef = DetailsScreen.SideScreenRef; + +namespace PeterHan.PLib.UI { + /// + /// Stores detours used for Klei UI components. Klei loves adding optional parameters and + /// changing fields to/from properties, which while source compatible is binary + /// incompatible. These lazy detours (resolved on first use) can bridge over a variety of + /// such differences with minimal overhead and no recompilation. + /// + internal static class UIDetours { + #region ConfirmDialogScreen + public delegate void PCD(ConfirmDialogScreen dialog, string text, + System.Action on_confirm, System.Action on_cancel, string configurable_text, + System.Action on_configurable_clicked, string title_text, string confirm_text, + string cancel_text); + + public static readonly DetouredMethod POPUP_CONFIRM = typeof(ConfirmDialogScreen).DetourLazy(nameof(ConfirmDialogScreen.PopupConfirmDialog)); + #endregion + + #region DetailsScreen + public static readonly IDetouredField> SIDE_SCREENS = PDetours.DetourFieldLazy>("sideScreens"); + public static readonly IDetouredField SS_CONTENT_BODY = PDetours.DetourFieldLazy("sideScreenContentBody"); + #endregion + + #region KButton + public static readonly IDetouredField ADDITIONAL_K_IMAGES = PDetours.DetourFieldLazy(nameof(KButton.additionalKImages)); + public static readonly IDetouredField BG_IMAGE = PDetours.DetourFieldLazy(nameof(KButton.bgImage)); + public static readonly IDetouredField FG_IMAGE = PDetours.DetourFieldLazy(nameof(KButton.fgImage)); + public static readonly IDetouredField IS_INTERACTABLE = PDetours.DetourFieldLazy(nameof(KButton.isInteractable)); + public static readonly IDetouredField SOUND_PLAYER_BUTTON = PDetours.DetourFieldLazy(nameof(KButton.soundPlayer)); + #endregion + + #region KImage + public static readonly IDetouredField COLOR_STYLE_SETTING = PDetours.DetourFieldLazy(nameof(KImage.colorStyleSetting)); + + public static readonly DetouredMethod> APPLY_COLOR_STYLE = typeof(KImage).DetourLazy>(nameof(KImage.ApplyColorStyleSetting)); + #endregion + + #region KScreen + public static readonly DetouredMethod> ACTIVATE_KSCREEN = typeof(KScreen).DetourLazy>(nameof(KScreen.Activate)); + public static readonly DetouredMethod> DEACTIVATE_KSCREEN = typeof(KScreen).DetourLazy>(nameof(KScreen.Deactivate)); + #endregion + + #region KToggle + public static readonly IDetouredField ART_EXTENSION = PDetours.DetourFieldLazy(nameof(KToggle.artExtension)); + public static readonly IDetouredField IS_ON = PDetours.DetourFieldLazy(nameof(KToggle.isOn)); + public static readonly IDetouredField SOUND_PLAYER_TOGGLE = PDetours.DetourFieldLazy(nameof(KToggle.soundPlayer)); + #endregion + + #region LocText + public static readonly IDetouredField LOCTEXT_KEY = PDetours.DetourFieldLazy(nameof(LocText.key)); + public static readonly IDetouredField LOCTEXT_STYLE = PDetours.DetourFieldLazy(nameof(LocText.textStyleSetting)); + #endregion + + #region MultiToggle + public static readonly IDetouredField CURRENT_STATE = PDetours.DetourFieldLazy(nameof(MultiToggle.CurrentState)); + public static readonly IDetouredField PLAY_SOUND_CLICK = PDetours.DetourFieldLazy(nameof(MultiToggle.play_sound_on_click)); + public static readonly IDetouredField PLAY_SOUND_RELEASE = PDetours.DetourFieldLazy(nameof(MultiToggle.play_sound_on_release)); + + public static readonly DetouredMethod> CHANGE_STATE = typeof(MultiToggle).DetourLazy>(nameof(MultiToggle.ChangeState)); + #endregion + + #region SideScreenContent + public static readonly IDetouredField SS_CONTENT_CONTAINER = PDetours.DetourFieldLazy(nameof(SideScreenContent.ContentContainer)); + #endregion + + #region SideScreenRef + public static readonly IDetouredField SS_OFFSET = PDetours.DetourFieldLazy(nameof(SideScreenRef.offset)); + public static readonly IDetouredField SS_PREFAB = PDetours.DetourFieldLazy(nameof(SideScreenRef.screenPrefab)); + public static readonly IDetouredField SS_INSTANCE = PDetours.DetourFieldLazy(nameof(SideScreenRef.screenInstance)); + #endregion + } +} diff --git a/mod/Patches/BuildMenu.cs b/mod/Patches/BuildMenu.cs new file mode 100644 index 0000000..0a543c5 --- /dev/null +++ b/mod/Patches/BuildMenu.cs @@ -0,0 +1,19 @@ +using HarmonyLib; + +namespace PriorityUX { + [HarmonyPatch(typeof(BuildMenu))] + public class BuildMenuPatches { + + [HarmonyPatch(nameof(BuildMenu.OnKeyDown))] + [HarmonyPrefix] + static void BuildMenu_OnKeyDown_Prefix(KButtonEvent e) { + Debug.Log("BuildMenu_OnKeyDown_Prefix" + e); + } + + [HarmonyPatch(nameof(BuildMenu.OnKeyUp))] + [HarmonyPrefix] + static void BuildMenu_OnKeyUp_Prefix(KButtonEvent e) { + Debug.Log("BuildMenu_OnKeyUp_Prefix" + e); + } + } +} diff --git a/mod/Patches/BuildMenuCategoriesScreen.cs b/mod/Patches/BuildMenuCategoriesScreen.cs new file mode 100644 index 0000000..d64ad16 --- /dev/null +++ b/mod/Patches/BuildMenuCategoriesScreen.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; +using KMod; + +namespace PriorityUX { + [HarmonyPatch(typeof(BuildMenuCategoriesScreen))] + public class BuildMenuCategoriesScreenPatches { + + [HarmonyPatch(nameof(BuildMenuCategoriesScreen.OnKeyDown))] + [HarmonyPrefix] + static bool BuildMenu_OnKeyDown_Prefix(KButtonEvent e) { + Debug.Log("BuildMenu_OnKeyDown_Prefix" + e); + + FieldInfo productInfoScreenField = typeof(BuildMenu).GetField("productInfoScreen", BindingFlags.NonPublic | BindingFlags.Instance); + Debug.Log(productInfoScreenField); + + ProductInfoScreen ___productInfoScreen = (ProductInfoScreen)productInfoScreenField.GetValue(BuildMenu.Instance); + + MaterialSelectionPanel msp = ___productInfoScreen.materialSelectionPanel; + if (___productInfoScreen.gameObject.activeSelf && msp.gameObject.activeSelf) { + MaterialSelectionPanelPatches.OnKeyDown(e, msp); + } + return true; + } + + [HarmonyPatch(nameof(BuildMenuCategoriesScreen.OnKeyUp))] + [HarmonyPrefix] + static bool BuildMenu_OnKeyUp_Prefix(KButtonEvent e) { + Debug.Log("BuildMenu_OnKeyUp_Prefix" + e); + + FieldInfo productInfoScreenField = typeof(BuildMenu).GetField("productInfoScreen", BindingFlags.NonPublic | BindingFlags.Instance); + Debug.Log(productInfoScreenField); + + ProductInfoScreen ___productInfoScreen = (ProductInfoScreen)productInfoScreenField.GetValue(BuildMenu.Instance); + + MaterialSelectionPanel msp = ___productInfoScreen.materialSelectionPanel; + if (___productInfoScreen.gameObject.activeSelf && msp.gameObject.activeSelf) { + MaterialSelectionPanelPatches.OnKeyUp(e, msp); + } + return true; + } + } +} diff --git a/mod/Patches/Deconstructable.cs b/mod/Patches/Deconstructable.cs new file mode 100644 index 0000000..1fb0332 --- /dev/null +++ b/mod/Patches/Deconstructable.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; +using KMod; + +namespace PriorityUX { + [HarmonyDebug] + [HarmonyPatch(typeof(Deconstructable))] + public class DeconstructablePatches { + public static PrioritySetting getChorePriority() { + Debug.Log("Getting deconstruct chore priority"); + if (Options.Instance.deconstructInheritPriority) + return Utils.getLastPriority(); + else + return Utils.getDefaultPriority(); + } + + [HarmonyPatch("QueueDeconstruction")] + [HarmonyPostfix] + static void QueueDeconstruction_Postfix(Deconstructable __instance) { + Prioritizable prioritizable = __instance.GetComponent(); + if (prioritizable != null) { + Debug.Log("Updating deconstruct priority"); + prioritizable.SetMasterPriority(getChorePriority()); + } + } + /* + [HarmonyPatch("QueueDeconstruction")] + [HarmonyTranspiler] + static IEnumerable QueueDeconstruction_Transpiler(IEnumerable instructions, ILGenerator ilGenerator) { + Harmony.DEBUG = true; + + var priorityLocal = ilGenerator.DeclareLocal(typeof(int)); + var priorityClassLocal = ilGenerator.DeclareLocal(typeof(PriorityScreen.PriorityClass)); + var tmpLocal0 = ilGenerator.DeclareLocal(typeof(int)); + var tmpLocal1 = ilGenerator.DeclareLocal(typeof(int)); + yield return CodeInstruction.Call(typeof(DeconstructablePatches), nameof(getChorePriority)); + yield return new CodeInstruction(OpCodes.Dup); + // yield return new CodeInstruction(OpCodes.Pop); + // yield return new CodeInstruction(OpCodes.Pop); + yield return CodeInstruction.LoadField(typeof(PrioritySetting), nameof(PrioritySetting.priority_value)); + yield return new CodeInstruction(OpCodes.Stloc, priorityLocal.LocalIndex); + yield return CodeInstruction.LoadField(typeof(PrioritySetting), nameof(PrioritySetting.priority_class)); + yield return new CodeInstruction(OpCodes.Stloc, priorityClassLocal.LocalIndex); + + ConstructorInfo choreConstructor = typeof(WorkChore).GetConstructors()[0]; + // FieldInfo field = typeof(Deconstructable).GetField(nameof(Deconstructable.chore)); + foreach (var instruction in instructions) { + // IL_0087: ldc.i4.1 + // IL_0088: ldc.i4.0 // priority_class + // IL_0089: ldc.i4.5 // priority_value + // IL_008a: ldc.i4.1 + // IL_008b: ldc.i4.1 + // IL_008c: newobj instance void class WorkChore`1::.ctor(class ChoreType, class ['Assembly-CSharp-firstpass']IStateMachineTarget, class ChoreProvider, bool, class [mscorlib]System.Action`1, class [mscorlib]System.Action`1, class [mscorlib]System.Action`1, bool, class ScheduleBlockType, bool, bool, class ['Assembly-CSharp-firstpass']KAnimFile, bool, bool, bool, valuetype PriorityScreen/PriorityClass, int32, bool, bool) + // IL_0091: stfld class Chore Deconstructable::chore + if (instruction.OperandIs(choreConstructor)) { + Debug.Log("PATCHING CHORE CONSTRUCTOR\n" + System.Environment.StackTrace); + yield return new CodeInstruction(OpCodes.Stloc, tmpLocal1.LocalIndex); + yield return new CodeInstruction(OpCodes.Stloc, tmpLocal0.LocalIndex); + yield return new CodeInstruction(OpCodes.Pop); + yield return new CodeInstruction(OpCodes.Pop); + yield return new CodeInstruction(OpCodes.Ldloc, priorityClassLocal.LocalIndex); + yield return new CodeInstruction(OpCodes.Ldloc, priorityLocal.LocalIndex); + yield return new CodeInstruction(OpCodes.Ldloc, tmpLocal0.LocalIndex); + yield return new CodeInstruction(OpCodes.Ldloc, tmpLocal1.LocalIndex); + yield return instruction; + continue; + } + yield return instruction; + } + } */ + } +} diff --git a/mod/Patches/DragTool.cs b/mod/Patches/DragTool.cs new file mode 100644 index 0000000..fd80442 --- /dev/null +++ b/mod/Patches/DragTool.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using HarmonyLib; + +namespace PriorityUX { + [HarmonyPatch(typeof(DragTool))] + static class DragToolPatches { + + static readonly FieldInfo interceptNumberKeysForPriority = typeof(DragTool).GetField("interceptNumberKeysForPriority", BindingFlags.NonPublic | BindingFlags.Instance); + + + [HarmonyPatch("OnActivateTool")] + [HarmonyPostfix] + static void OnActivateTool_Postfix(DragTool __instance) { + Debug.Log("[PriorityUX] >> OnActivateTool_Postfix " + __instance.GetType().Name + " -> refreshToolMenuPriority"); + Utils.refreshToolMenuPriority(); + } + + [HarmonyPatch("OnPrefabInit")] + [HarmonyPostfix] + static void OnPrefabInit_Postfix(object __instance) { + if (Options.Instance.enableKeysForAllTools) { + interceptNumberKeysForPriority.SetValue(__instance, true); + } + } + } +} diff --git a/mod/Patches/MaterialSelectionPanel.cs b/mod/Patches/MaterialSelectionPanel.cs new file mode 100644 index 0000000..592078a --- /dev/null +++ b/mod/Patches/MaterialSelectionPanel.cs @@ -0,0 +1,45 @@ +using HarmonyLib; + +namespace PriorityUX { + [HarmonyPatch(typeof(MaterialSelectionPanel))] + public class MaterialSelectionPanelPatches { + + [HarmonyPatch(nameof(MaterialSelectionPanel.RefreshSelectors))] + [HarmonyPostfix] + static void RefreshSelectorsPostfix(PriorityScreen ___priorityScreen) { + Debug.Log("RefreshSelectors"); + Debug.Log("___priorityScreen.gameObject.activeSelf: " + ___priorityScreen.gameObject.activeSelf); + // if (___priorityScreen.gameObject.activeSelf) + Utils.refreshPriorityScreen(___priorityScreen); + } + + public static void OnKeyDown(KButtonEvent e, MaterialSelectionPanel panel) { + Debug.Log("MaterialSelectionPanelPatches OnKeyDown " + e.GetAction()); + if (!Options.Instance.enableKeysForBuilding) + return; + + Action action = e.GetAction(); + // Repurpose GotoUserNav1 keys (default: Shift+1/2/3/etc.) + // if (Action.GotoUserNav1 <= action && action <= Action.GotoUserNav10 && e.TryConsume(action)) { + // int num = (int)(action - 25 + 1); + if (Action.Plan1 <= action && action <= Action.Plan10 && e.TryConsume(action)) { + int num = (int)(action - 36 + 1); + if (num <= 9) { + panel.PriorityScreen.SetScreenPriority(new PrioritySetting(PriorityScreen.PriorityClass.basic, num), play_sound: true); + } else { + panel.PriorityScreen.SetScreenPriority(new PrioritySetting(PriorityScreen.PriorityClass.topPriority, 1), play_sound: true); + } + } + } + + public static void OnKeyUp(KButtonEvent e, MaterialSelectionPanel panel) { + if (!Options.Instance.enableKeysForBuilding) + return; + + Action action = e.GetAction(); + if (Action.GotoUserNav1 <= action && action <= Action.GotoUserNav10) { + e.TryConsume(action); + } + } + } +} diff --git a/mod/Patches/PlanScreen.cs b/mod/Patches/PlanScreen.cs new file mode 100644 index 0000000..c42f671 --- /dev/null +++ b/mod/Patches/PlanScreen.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using HarmonyLib; + +namespace PriorityUX { + [HarmonyPatch(typeof(PlanScreen))] + static class PlanScreenPatches { + [HarmonyPatch("OnKeyDown")] + [HarmonyPrefix] + public static bool OnKeyDown_Prefix(KButtonEvent e, PlanScreen __instance) { + // Debug.Log("PlanScreen OnKeyDown " + e.GetAction()); + // Debug.Log("__instance.ProductInfoScreen: " + __instance.ProductInfoScreen); + // Debug.Log("ProductInfoScreen Active: " + __instance.ProductInfoScreen.gameObject.activeSelf); + if (__instance.ProductInfoScreen.gameObject.activeSelf) { + MaterialSelectionPanelPatches.OnKeyDown(e, __instance.ProductInfoScreen.materialSelectionPanel); + return false; + } + + return true; + } + + [HarmonyPatch("OnKeyUp")] + [HarmonyPrefix] + public static bool OnKeyUp_Prefix(KButtonEvent e, PlanScreen __instance) { + // Debug.Log("PlanScreen OnKeyUp " + e.GetAction()); + // Debug.Log("__instance.ProductInfoScreen: " + __instance.ProductInfoScreen); + // Debug.Log("ProductInfoScreen Active: " + __instance.ProductInfoScreen.gameObject.activeSelf); + if (__instance.ProductInfoScreen.gameObject.activeSelf) { + MaterialSelectionPanelPatches.OnKeyUp(e, __instance.ProductInfoScreen.materialSelectionPanel); + return false; + } + + return true; + } + } +} diff --git a/mod/Patches/PriorityScreen.cs b/mod/Patches/PriorityScreen.cs new file mode 100644 index 0000000..f7fa6e5 --- /dev/null +++ b/mod/Patches/PriorityScreen.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; + +namespace PriorityUX { + [HarmonyPatch(typeof(PriorityScreen))] + static class PriorityScreenPatches { + static readonly MethodInfo SetScreenPriorityMethodName = typeof(PriorityScreen).GetMethod(nameof(PriorityScreen.SetScreenPriority)); + + [HarmonyPatch(nameof(PriorityScreen.ResetPriority))] + [HarmonyPrefix] + static bool ResetPriority_Prefix(PriorityScreen __instance) { + var options = Options.Instance; + Debug.Log("[PriorityUX] >> ResetPriority patch options.resetPriority:" + options.resetPriority); + if (options.resetPriority) { + __instance.SetScreenPriority(new PrioritySetting(PriorityScreen.PriorityClass.basic, options.resetPriorityLevel)); + } + return false; // Ignore base implementation + } + + [HarmonyPatch(nameof(PriorityScreen.SetScreenPriority))] + [HarmonyPostfix] + static void SetScreenPriorityPostfix(PrioritySetting priority, PriorityScreen __instance) { + if (__instance == ToolMenu.Instance.PriorityScreen || + __instance == Utils.getPlanPriorityScreen()) { + // Debug.Log("[PriorityUX] >> SetScreenPriorityPostfix: " + priority.priority_class.ToString() + "-" + priority.priority_value); + Utils.setLastPriority(priority); + } + + // If this was set from some other instance than the tool menu, propagate the setting here + // var toolPriorityScreen = ToolMenu.Instance.PriorityScreen; + // if (toolPriorityScreen != null && toolPriorityScreen != __instance) { + // Debug.Log("[PriorityUX] >> Syncing priority to tool priority"); + // if (toolPriorityScreen.gameObject.activeSelf) { + // Debug.Log("[PriorityUX] >> & active"); + // toolPriorityScreen.SetScreenPriority(priority); + // } + // } + } + + static void SetInitialPriority(PriorityScreen priorityScreen) { + Debug.Log("[PriorityUX] >> SetInitialPriority", priorityScreen); + priorityScreen.SetScreenPriority(Utils.getLastPriority()); + } + + [HarmonyPatch(nameof(PriorityScreen.InstantiateButtons))] + [HarmonyTranspiler] + static IEnumerable InstantiateButtonsTranspiler(IEnumerable instructions) { + foreach (var instruction in instructions) { + // SetScreenPriority(new PrioritySetting(PriorityClass.basic, 5)); + // IL_0166: ldarg.0 + // IL_0167: ldc.i4.0 + // IL_0168: ldc.i4.5 + // IL_0169: newobj instance void PrioritySetting::.ctor(valuetype PriorityScreen/PriorityClass, int32) + // IL_016e: ldc.i4.0 + // IL_016f: call instance void PriorityScreen::SetScreenPriority(valuetype PrioritySetting, bool) + if (instruction.Calls(SetScreenPriorityMethodName)) { + Debug.Log("[PriorityUX] >> Transpile default priority in InstantiateButtons"); + yield return new CodeInstruction(OpCodes.Pop); // Priority + yield return new CodeInstruction(OpCodes.Pop); // Bool + yield return CodeInstruction.Call(typeof(PriorityScreenPatches), nameof(PriorityScreenPatches.SetInitialPriority)); + continue; + } + yield return instruction; + } + } + } +} diff --git a/mod/Patches/Screen.cs b/mod/Patches/Screen.cs new file mode 100644 index 0000000..03b53ac --- /dev/null +++ b/mod/Patches/Screen.cs @@ -0,0 +1,48 @@ + + +// Generic place to intercept screen functions that are not overriden +using System; +using HarmonyLib; +using UnityEngine; + +namespace PriorityUX { + // [HarmonyPatch(typeof(KScreen))] + // static class ScreenKeyPatches { + + // [HarmonyPatch("OnKeyDown")] + // [HarmonyPostfix] + // static void OnKeyDown_Postfix(KButtonEvent e, object __instance) { + // // Debug.Log("KeyDown " + e + " " + __instance); + // // Debug.Log(Environment.StackTrace); + // // if (__instance is MaterialSelectionPanel msp) { + // // Debug.Log("KD> Material Selection"); + // // } + // // if (__instance is MaterialSelectionPanel msp) { + // // Debug.Log("[PriorityUX] >> Redirect MaterialSelectionPanel KeyDown"); + // // Debug.Log(msp); + // // MaterialSelectionPanelPatches.OnKeyDown(e, msp); + // // } + // } + + // [HarmonyPatch("OnKeyUp")] + // [HarmonyPostfix] + // static void OnKeyUp_Postfix(KButtonEvent e, object __instance) { + // // if (__instance is MaterialSelectionPanel msp) { + // // Debug.Log("[PriorityUX] >> Redirect MaterialSelectionPanel KeyUp"); + // // Debug.Log(msp); + // // MaterialSelectionPanelPatches.OnKeyUp(e, msp); + // // } + // } + // } + + // [HarmonyPatch(typeof(KIconToggleMenu))] + // static class Test { + + // [HarmonyPatch("OnKeyDown")] + // [HarmonyPostfix] + // static void OnKeyDown_Postfix(KButtonEvent e, object __instance) { + // // Debug.Log("KIconToggleMenu KeyDown " + e + " " + __instance); + // // Debug.Log(Environment.StackTrace); + // } + // } +} diff --git a/mod/Strings.cs b/mod/Strings.cs new file mode 100644 index 0000000..7970e92 --- /dev/null +++ b/mod/Strings.cs @@ -0,0 +1,23 @@ +namespace PriorityUX { + public static class Strings { + public static class PRIORITYUX { + public static class OPTIONS { + public static class RESETPRIORITY { + public static LocString NAME = "Reset priority"; + public static LocString TOOLTIP = "Reset priority every time a tool is opened"; + public static LocString CATEGORY = "Tweaks"; + } + public static class RESETPRIORITYLEVEL { + public static LocString NAME = "Default priority"; + public static LocString TOOLTIP = "Used for reset and as a default priority"; + public static LocString CATEGORY = "Tweaks"; + } + public static class ENABLEPRIORITYHOTKEYSEVERYWHERE { + public static LocString NAME = "Enable priority keys everywhere"; + public static LocString TOOLTIP = "Allows the 1-9 keys to select priority for all tools. Close the tool to open the building menus."; + public static LocString CATEGORY = "Tweaks"; + } + } + } + } +} diff --git a/mod/Utils.cs b/mod/Utils.cs new file mode 100644 index 0000000..db611b6 --- /dev/null +++ b/mod/Utils.cs @@ -0,0 +1,66 @@ +using System.Reflection; + +namespace PriorityUX { + static class Utils { + static readonly string PlayerPrefsKey = "LastUsedPriority"; + static int? lastValue; + + public static PrioritySetting getDefaultPriority() { + return new PrioritySetting(PriorityScreen.PriorityClass.basic, Options.Instance.resetPriorityLevel); + } + + static int prioritySettingToInt(PrioritySetting setting) { + if (setting.priority_class == PriorityScreen.PriorityClass.topPriority) + return 0; + return setting.priority_value; + } + + static PrioritySetting intToPrioritySetting(int v) { + if (v < 1 || v > 9) + return new PrioritySetting(PriorityScreen.PriorityClass.topPriority, 1); + return new PrioritySetting(PriorityScreen.PriorityClass.basic, v); + } + + public static void setLastPriority(PrioritySetting setting) { + int value = prioritySettingToInt(setting); + if (lastValue.HasValue && lastValue.Value == value) + return; + + Debug.Log("[PriorityUX] >> setLastPriority: " + value); + KPlayerPrefs.SetInt(PlayerPrefsKey, value); + lastValue = value; + } + + public static PrioritySetting getLastPriority() { + int value = KPlayerPrefs.GetInt(PlayerPrefsKey, -1); + if (value < 0) { + Debug.Log("[PriorityUX] >> getLastPriority: "); + return getDefaultPriority(); + } else { + // Debug.Log("[PriorityUX] >> getLastPriority: " + value); + return intToPrioritySetting(value); + } + } + + public static void refreshPriorityScreen(PriorityScreen screen) { + screen.SetScreenPriority(Utils.getLastPriority()); + } + + public static void refreshToolMenuPriority() { + refreshPriorityScreen(ToolMenu.Instance.PriorityScreen); + } + + // static readonly FieldInfo productInfoScreenField = typeof(BuildMenu).GetField("productInfoScreen", BindingFlags.NonPublic | BindingFlags.Instance); + // public static ProductInfoScreen? getPlanScreenProductInfoScreen() { + // if (BuildMenu.Instance) { + // ProductInfoScreen productInfoScreen = (ProductInfoScreen)productInfoScreenField.GetValue(BuildMenu.Instance); + // return productInfoScreen; + // } + // return null; + // } + + public static PriorityScreen? getPlanPriorityScreen() { + return PlanScreen.Instance?.ProductInfoScreen?.materialSelectionPanel?.PriorityScreen; + } + } +} diff --git a/mod/build b/mod/build new file mode 100755 index 0000000..2eb2bb3 --- /dev/null +++ b/mod/build @@ -0,0 +1,15 @@ +#/bin/bash + +msbuild=/Library/Frameworks/Mono.framework/Versions/6.12.0/bin/msbuild + +ROOT=$( cd "$(dirname "$0")" ; pwd -P ) +pushd $ROOT + +# Do the build (need to use mono) +$msbuild + +set -x +rm -rf dist +mkdir dist +cp bin/Debug/net48/oni-prio.dll dist/ +cp -r meta/* dist/ diff --git a/mod/meta/__PreviewImage.png b/mod/meta/__PreviewImage.png new file mode 100644 index 0000000000000000000000000000000000000000..10d287a1e75dcd895ecb0fa110d8e04b46023c38 GIT binary patch literal 4269 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 + + + net48 + oni_prio + enable + enable + 8.0 + + + + ../Libs/Assembly-CSharp-firstpass.dll + false + + + ../Libs/Assembly-CSharp.dll + false + + + ../Libs/0Harmony.dll + false + + + ../Libs/FMODUnity.dll + false + + + ../Libs/UnityEngine.CoreModule.dll + false + + + ../Libs/UnityEngine.TextRenderingModule.dll + false + + + ../Libs/UnityEngine.UI.dll + false + + + ../Libs/Unity.TextMeshPro.dll + false + + + ../Libs/UnityEngine.ImageConversionModule.dll + + + ../Libs/UnityEngine.UnityWebRequestModule.dll + + + ../Libs/UnityEngine.InputLegacyModule.dll + + + ../Libs/UnityEngine.UI.dll + + + ../Libs/UnityEngine.UIModule.dll + + + ../Libs/UnityEngine.dll + false + + + ../Libs/Newtonsoft.Json.dll + + + diff --git a/omnisharp.json b/omnisharp.json new file mode 100644 index 0000000..545a4ce --- /dev/null +++ b/omnisharp.json @@ -0,0 +1,23 @@ +{ + "FormattingOptions": { + "newLine": "\n", + "useTabs": false, + "tabSize": 2, + "indentationSize": 2, + "NewLinesForBracesInTypes": false, + "NewLinesForBracesInMethods": false, + "NewLinesForBracesInProperties": false, + "NewLinesForBracesInAccessors": false, + "NewLinesForBracesInAnonymousMethods": false, + "NewLinesForBracesInControlBlocks": false, + "NewLinesForBracesInAnonymousTypes": false, + "NewLinesForBracesInObjectCollectionArrayInitializers": true, + "NewLinesForBracesInLambdaExpressionBody": false, + "NewLineForElse": false, + "NewLineForCatch": false, + "NewLineForFinally": false, + "NewLineForMembersInObjectInit": false, + "NewLineForMembersInAnonymousTypes": false, + "NewLineForClausesInQuery": false + } +} diff --git a/oni-prio.sln b/oni-prio.sln new file mode 100644 index 0000000..8821fa6 --- /dev/null +++ b/oni-prio.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "oni-prio", "mod\oni-prio.csproj", "{B5C0FB18-102D-434C-B8D7-2DDAE54367D6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Assembly-CSharp", "reference\Assembly-CSharp.csproj", "{501D90AF-582D-4F90-9FE6-1479FBA92A4B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Assembly-CSharp-firstpass", "reference-firstpass\Assembly-CSharp-firstpass.csproj", "{F0AD7234-F5CE-4A5E-8E8A-F621F0B7C0A0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B5C0FB18-102D-434C-B8D7-2DDAE54367D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5C0FB18-102D-434C-B8D7-2DDAE54367D6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5C0FB18-102D-434C-B8D7-2DDAE54367D6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5C0FB18-102D-434C-B8D7-2DDAE54367D6}.Release|Any CPU.Build.0 = Release|Any CPU + {501D90AF-582D-4F90-9FE6-1479FBA92A4B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {501D90AF-582D-4F90-9FE6-1479FBA92A4B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {501D90AF-582D-4F90-9FE6-1479FBA92A4B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {501D90AF-582D-4F90-9FE6-1479FBA92A4B}.Release|Any CPU.Build.0 = Release|Any CPU + {F0AD7234-F5CE-4A5E-8E8A-F621F0B7C0A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0AD7234-F5CE-4A5E-8E8A-F621F0B7C0A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0AD7234-F5CE-4A5E-8E8A-F621F0B7C0A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0AD7234-F5CE-4A5E-8E8A-F621F0B7C0A0}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal