From 5349a4bae76690ab3d2392e575eb45dabb0d699d Mon Sep 17 00:00:00 2001 From: divisionbyz0rro Date: Mon, 24 Jun 2024 21:54:03 -0500 Subject: [PATCH] Slot Modification Manager --- .../Encounters/CachedGBCNPCDescriptor.cs | 21 + .../Encounters/EncounterExtensions.cs | 31 ++ InscryptionAPI/Saves/SaveFileExtensions.cs | 67 +++ .../Slots/SlotModificationBehaviour.cs | 31 ++ .../Slots/SlotModificationExtensions.cs | 182 ++++++++ .../SlotModificationGainAbilityBehaviour.cs | 80 ++++ .../Slots/SlotModificationManager.cs | 415 ++++++++++++++++++ .../Triggers/CustomTriggerFinder.cs | 6 +- .../Triggers/CustomTriggerPatches.cs | 20 +- docs/wiki/slots.md | 101 +++++ 10 files changed, 952 insertions(+), 2 deletions(-) create mode 100644 InscryptionAPI/Encounters/CachedGBCNPCDescriptor.cs create mode 100644 InscryptionAPI/Saves/SaveFileExtensions.cs create mode 100644 InscryptionAPI/Slots/SlotModificationBehaviour.cs create mode 100644 InscryptionAPI/Slots/SlotModificationExtensions.cs create mode 100644 InscryptionAPI/Slots/SlotModificationGainAbilityBehaviour.cs create mode 100644 InscryptionAPI/Slots/SlotModificationManager.cs create mode 100644 docs/wiki/slots.md diff --git a/InscryptionAPI/Encounters/CachedGBCNPCDescriptor.cs b/InscryptionAPI/Encounters/CachedGBCNPCDescriptor.cs new file mode 100644 index 00000000..27cf274c --- /dev/null +++ b/InscryptionAPI/Encounters/CachedGBCNPCDescriptor.cs @@ -0,0 +1,21 @@ +using GBC; + +namespace InscryptionAPI.Encounters; + +public class CachedGCBNPCDescriptor +{ + public string ID { get; set; } + public CardTemple BossTemple { get; set; } + public bool IsBoss { get; set; } + public PixelBoardSpriteSetter.BoardTheme BattleBackgroundTheme { get; set; } + public DialogueSpeaker DialogueSpeaker { get; set; } + + public CachedGCBNPCDescriptor(CardBattleNPC npc) + { + ID = npc.ID; + BossTemple = npc.BossTemple; + IsBoss = npc.IsBoss; + BattleBackgroundTheme = npc.BattleBackgroundTheme; + DialogueSpeaker = npc.DialogueSpeaker; + } +} \ No newline at end of file diff --git a/InscryptionAPI/Encounters/EncounterExtensions.cs b/InscryptionAPI/Encounters/EncounterExtensions.cs index 0ba0b812..c5f4b220 100644 --- a/InscryptionAPI/Encounters/EncounterExtensions.cs +++ b/InscryptionAPI/Encounters/EncounterExtensions.cs @@ -1,10 +1,14 @@ +using System.Collections; +using System.Runtime.CompilerServices; using DiskCardGame; +using GBC; using HarmonyLib; using InscryptionAPI.Card; using static DiskCardGame.EncounterBlueprintData; namespace InscryptionAPI.Encounters; +[HarmonyPatch] public static class EncounterExtensions { #region Opponent Extensions @@ -275,4 +279,31 @@ public static T SyncTurnDifficulties(this T blueprint, int minDifficulty, int #endregion #endregion + + #region GBC NPC Information + + private static ConditionalWeakTable LAST_KNOWN_NPC = new(); + + [HarmonyPatch(typeof(GBCEncounterManager), nameof(GBCEncounterManager.EncounterSequence)), HarmonyPostfix] + private static IEnumerator CaptureLastKnownTriggeringNPC(IEnumerator sequence, CardBattleNPC triggeringNPC) + { + LAST_KNOWN_NPC.Remove(GBCEncounterManager.Instance); + LAST_KNOWN_NPC.Add(GBCEncounterManager.Instance, new CachedGCBNPCDescriptor(triggeringNPC)); + + yield return sequence; + + LAST_KNOWN_NPC.Remove(GBCEncounterManager.Instance); + } + + /// + /// Gets information about the NPC that triggered the current battle + /// + public static CachedGCBNPCDescriptor GetTriggeringNPC(this GBCEncounterManager mgr) + { + if (LAST_KNOWN_NPC.TryGetValue(mgr, out CachedGCBNPCDescriptor value)) + return value; + return null; + } + + #endregion } \ No newline at end of file diff --git a/InscryptionAPI/Saves/SaveFileExtensions.cs b/InscryptionAPI/Saves/SaveFileExtensions.cs new file mode 100644 index 00000000..302f1f8c --- /dev/null +++ b/InscryptionAPI/Saves/SaveFileExtensions.cs @@ -0,0 +1,67 @@ +using GBC; +using InscryptionAPI.Encounters; +using UnityEngine.SceneManagement; + +namespace InscryptionAPI.Saves; + +public static class SaveFileExtensions +{ + /// + /// Gets the player's current location as a CardTemple + /// + /// The temple of the player's current location OR null if the player is in an ambiguous location + public static CardTemple? GetSceneAsCardTemple(this SaveFile save) + { + // Easy stuff + if (save.IsGrimora) + return CardTemple.Undead; + if (save.IsMagnificus) + return CardTemple.Wizard; + if (save.IsPart1) + return CardTemple.Nature; + if (save.IsPart3) + return CardTemple.Tech; + + // Now the hard part; if this is Act 2 + if (save.IsPart2) + { + // If there is an active battle, we should be able to get it from the NPC + var npc = GBCEncounterManager.Instance.GetTriggeringNPC(); + if (npc != null) + { + // Translate the theme to a card temlpe + if (npc.BattleBackgroundTheme == PixelBoardSpriteSetter.BoardTheme.Nature) + return CardTemple.Nature; + if (npc.BattleBackgroundTheme == PixelBoardSpriteSetter.BoardTheme.Tech) + return CardTemple.Tech; + if (npc.BattleBackgroundTheme == PixelBoardSpriteSetter.BoardTheme.P03) + return CardTemple.Tech; + if (npc.BattleBackgroundTheme == PixelBoardSpriteSetter.BoardTheme.Undead) + return CardTemple.Undead; + if (npc.BattleBackgroundTheme == PixelBoardSpriteSetter.BoardTheme.Wizard) + return CardTemple.Wizard; + + // A bit of an arbitrary choice here for "finale" + // P03 takes over so... + if (npc.BattleBackgroundTheme == PixelBoardSpriteSetter.BoardTheme.Finale) + return CardTemple.Tech; + } + + // Okay, let's try to figure it out from the scene name + string sceneName = SceneManager.GetActiveScene().name.ToLowerInvariant(); + if (sceneName.Contains("nature")) + return CardTemple.Nature; + if (sceneName.Contains("tech")) + return CardTemple.Tech; + if (sceneName.Contains("wizard")) + return CardTemple.Wizard; + if (sceneName.Contains("undead")) + return CardTemple.Undead; + } + + // And now we're at the point where there's no way to figure it out. + // You're either in a neutral area of the Act 2 map + // Or you're not in a game scene at all. + return null; + } +} \ No newline at end of file diff --git a/InscryptionAPI/Slots/SlotModificationBehaviour.cs b/InscryptionAPI/Slots/SlotModificationBehaviour.cs new file mode 100644 index 00000000..392addcf --- /dev/null +++ b/InscryptionAPI/Slots/SlotModificationBehaviour.cs @@ -0,0 +1,31 @@ +using System.Collections; +using DiskCardGame; + +namespace InscryptionAPI.Slots; + +/// +/// Base class for all slot modification behaviors +/// +public abstract class SlotModificationBehaviour : TriggerReceiver +{ + /// + /// The slot that the behaviour is applied to. + /// + public CardSlot Slot => gameObject.GetComponent(); + + /// + /// Use to setup any additional custom slot visualizations when created. + /// + public virtual IEnumerator Setup() + { + yield break; + } + + /// + /// Use to clean up any additional custom slot visualizations before being removed + /// + public virtual IEnumerator Cleanup(SlotModificationManager.ModificationType replacement) + { + yield break; + } +} \ No newline at end of file diff --git a/InscryptionAPI/Slots/SlotModificationExtensions.cs b/InscryptionAPI/Slots/SlotModificationExtensions.cs new file mode 100644 index 00000000..aafddb6a --- /dev/null +++ b/InscryptionAPI/Slots/SlotModificationExtensions.cs @@ -0,0 +1,182 @@ +using System.Collections; +using DiskCardGame; +using GBC; +using HarmonyLib; +using InscryptionAPI.Encounters; +using InscryptionAPI.Helpers.Extensions; +using InscryptionAPI.Saves; +using UnityEngine; +using UnityEngine.UIElements; + +namespace InscryptionAPI.Slots; + +[HarmonyPatch] +/// +/// Contains extension methods to simplify slot modification management +/// +public static class SlotModificationExtensions +{ + /// + /// Assigns a new slot modification to a slot. + /// + /// The slot to assign the modification to + /// The modification type to assign + public static IEnumerator SetSlotModification(this CardSlot slot, SlotModificationManager.ModificationType modType) + { + if (slot == null) + yield break; + + SlotModificationManager.Info defn = SlotModificationManager.AllSlotModifications.FirstOrDefault(m => m.ModificationType == modType); + + // Set the ability behaviour + var oldSlotModification = slot.GetComponent(); + if (oldSlotModification != null) + { + yield return oldSlotModification.Cleanup(modType); + CustomCoroutine.WaitOnConditionThenExecute(() => GlobalTriggerHandler.Instance.StackSize == 0, () => GameObject.Destroy(oldSlotModification)); + SlotModificationManager.Instance.SlotReceivers.Remove(slot); + } + + if (defn != null && defn.SlotBehaviour != null) + { + SlotModificationBehaviour newBehaviour = slot.gameObject.AddComponent(defn.SlotBehaviour) as SlotModificationBehaviour; + + SlotModificationManager.Instance.SlotReceivers[slot] = new(modType, newBehaviour); + yield return newBehaviour.Setup(); + } + + // Set the texture and/or sprite + CardTemple temple = SaveManager.SaveFile.GetSceneAsCardTemple() ?? CardTemple.Nature; + + if (defn == null) + { + slot.ResetSlotTexture(); + } + else if (slot is PixelCardSlot pcs) + { + pcs.SetSlotSprite(defn); + } + else + { + if (defn.Texture == null || !defn.Texture.ContainsKey(temple)) + slot.ResetSlotTexture(); + else + slot.SetTexture(defn.Texture[temple]); + } + } + + /// + /// Gets the current modification of a slot + /// + public static SlotModificationManager.ModificationType GetSlotModification(this CardSlot slot) + { + return slot == null + ? SlotModificationManager.ModificationType.NoModification + : SlotModificationManager.Instance.SlotReceivers.ContainsKey(slot) + ? SlotModificationManager.Instance.SlotReceivers[slot].Item1 + : SlotModificationManager.ModificationType.NoModification; + } + + private static void SetSlotSprite(this PixelCardSlot slot, SlotModificationManager.Info defn) + { + if (defn == null) + { + InscryptionAPIPlugin.Logger.LogDebug($"Resetting slot {slot.Index} to default because mod info was null"); + slot.ResetSlotSprite(); + return; + } + + if (defn.PixelBoardSlotSprites == null) + { + InscryptionAPIPlugin.Logger.LogDebug($"Resetting slot {slot.Index} to default because mod info did not contain pixel slot info"); + slot.ResetSlotSprite(); + return; + } + + var triggeringNPC = GBCEncounterManager.Instance?.GetTriggeringNPC(); + if (triggeringNPC == null) + { + InscryptionAPIPlugin.Logger.LogDebug($"Doing nothing to slot {slot.Index} because the triggering NPC was null"); + return; + } + + if (!defn.PixelBoardSlotSprites.ContainsKey(triggeringNPC.BattleBackgroundTheme)) + { + InscryptionAPIPlugin.Logger.LogDebug($"Resetting slot {slot.Index} to default because pixel slot info did not contain a definition for {triggeringNPC.BattleBackgroundTheme}"); + slot.ResetSlotSprite(); + return; + } + + var spriteSet = defn.PixelBoardSlotSprites[triggeringNPC.BattleBackgroundTheme]; + if (spriteSet == null) + { + InscryptionAPIPlugin.Logger.LogDebug($"Resetting slot {slot.Index} to default because pixel slot info had a null definition for {triggeringNPC.BattleBackgroundTheme}"); + slot.ResetSlotSprite(); + return; + } + + var specificSprites = spriteSet.specificSlotSprites.Find(s => s.playerSlot == slot.IsPlayerSlot && s.index == slot.Index); + + if (specificSprites == null) + slot.SetSprites(spriteSet.slotDefault, spriteSet.slotHighlight, slot.IsPlayerSlot && spriteSet.flipPlayerSlotSpriteY, false); + else + slot.SetSprites(specificSprites.slotDefault, specificSprites.slotHighlight, slot.IsPlayerSlot && spriteSet.flipPlayerSlotSpriteY, false); + } + + private static void ResetSlotSprite(this PixelCardSlot slot) + { + var triggeringNPC = GBCEncounterManager.Instance?.GetTriggeringNPC(); + if (triggeringNPC == null) + return; + + var spriteSet = PixelBoardSpriteSetter.Instance.themeSpriteSets.Find(s => s.id == triggeringNPC.BattleBackgroundTheme); + if (spriteSet == null) + return; + + var specificSprites = spriteSet.specificSlotSprites.Find(s => s.playerSlot == slot.IsPlayerSlot && s.index == slot.Index); + + if (specificSprites != null) + slot.SetSprites(specificSprites.slotDefault, specificSprites.slotHighlight, slot.IsPlayerSlot && spriteSet.flipPlayerSlotSpriteY, false); + else + slot.SetSprites(spriteSet.slotDefault, spriteSet.slotHighlight, slot.IsPlayerSlot && spriteSet.flipPlayerSlotSpriteY, false); + } + + /// + /// Resets a slot's texture back to the default texture for that slot based on the current act. + /// + public static void ResetSlotTexture(this CardSlot slot) + { + if (slot is PixelCardSlot pcs) + { + pcs.ResetSlotSprite(); + return; + } + + CardTemple temple = SaveManager.SaveFile.GetSceneAsCardTemple() ?? CardTemple.Nature; + + Dictionary> lookup = slot.IsOpponentSlot() ? SlotModificationManager.OpponentOverrideSlots : SlotModificationManager.PlayerOverrideSlots; + var newTexture = SlotModificationManager.DefaultSlotTextures[temple]; + if (lookup.ContainsKey(temple)) + { + // Get the texture overrides + var textureChoices = lookup[temple]; + int idx = slot.Index; + if (idx >= textureChoices.Count) + { + // Try to guess what the best index would be + int slotCount = BoardManager.Instance.PlayerSlotsCopy.Count; + if (slot.Index == slotCount - 1) // the last slot + idx = textureChoices.Count - 1; + else // Use the next to last slot + idx = textureChoices.Count - 2; + } + if (idx < 0) + idx = 0; + + if (textureChoices[idx] != null) + newTexture = textureChoices[idx]; + } + + slot.SetTexture(newTexture); + } +} \ No newline at end of file diff --git a/InscryptionAPI/Slots/SlotModificationGainAbilityBehaviour.cs b/InscryptionAPI/Slots/SlotModificationGainAbilityBehaviour.cs new file mode 100644 index 00000000..25ab4976 --- /dev/null +++ b/InscryptionAPI/Slots/SlotModificationGainAbilityBehaviour.cs @@ -0,0 +1,80 @@ +using System.Collections; +using DiskCardGame; +using UnityEngine; + +namespace InscryptionAPI.Slots; + +/// +/// Base class for all slot modification behaviors +/// +public abstract class SlotModificationGainAbilityBehaviour : SlotModificationBehaviour +{ + protected abstract Ability AbilityToGain { get; } + + private string TemporaryModId => $"SlotModification{AbilityToGain}{Slot.IsPlayerSlot}{Slot.Index}"; + + private CardModificationInfo GetSlotAbilityMod(PlayableCard card, bool create = false) + { + card.temporaryMods ??= new(); + CardModificationInfo mod = card.TemporaryMods.FirstOrDefault(m => m != null && !string.IsNullOrEmpty(m.singletonId) && m.singletonId.Equals(TemporaryModId)); + if (mod == null && create) + mod = new(AbilityToGain) { singletonId = TemporaryModId }; + + return mod; + } + + public override bool RespondsToOtherCardAssignedToSlot(PlayableCard otherCard) => true; + + public override IEnumerator OnOtherCardAssignedToSlot(PlayableCard otherCard) + { + if (otherCard == null || otherCard.Slot == null) + yield break; + + bool shouldHaveThisTempMod = Slot.Card == otherCard; + + var mod = GetSlotAbilityMod(otherCard); + + if (shouldHaveThisTempMod && mod == null) + { + mod = GetSlotAbilityMod(otherCard, true); + otherCard.AddTemporaryMod(mod); + ResourcesManager.Instance.ForceGemsUpdate(); // just in case + } + + if (!shouldHaveThisTempMod && mod != null) + { + otherCard.RemoveTemporaryMod(mod); + ResourcesManager.Instance.ForceGemsUpdate(); // just in case + } + + yield return new WaitForSeconds(0.1f); + } + + public override IEnumerator Cleanup(SlotModificationManager.ModificationType replacement) + { + if (Slot.Card != null) + { + var mod = GetSlotAbilityMod(Slot.Card); + if (mod != null) + { + Slot.Card.RemoveTemporaryMod(mod); + yield return new WaitForSeconds(0.1f); + } + } + yield break; + } + + public override IEnumerator Setup() + { + if (Slot.Card != null) + { + var mod = GetSlotAbilityMod(Slot.Card); + if (mod == null) + { + Slot.Card.RemoveTemporaryMod(mod); + yield return new WaitForSeconds(0.1f); + } + } + yield break; + } +} \ No newline at end of file diff --git a/InscryptionAPI/Slots/SlotModificationManager.cs b/InscryptionAPI/Slots/SlotModificationManager.cs new file mode 100644 index 00000000..5dc435a9 --- /dev/null +++ b/InscryptionAPI/Slots/SlotModificationManager.cs @@ -0,0 +1,415 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using DiskCardGame; +using GBC; +using HarmonyLib; +using InscryptionAPI.Guid; +using InscryptionAPI.Helpers; +using InscryptionAPI.Helpers.Extensions; +using InscryptionAPI.Triggers; +using Sirenix.Serialization.Utilities; +using UnityEngine; + +namespace InscryptionAPI.Slots; + +[HarmonyPatch] +/// +/// Manager for card slot modifications +/// +public class SlotModificationManager : MonoBehaviour +{ + private static SlotModificationManager m_instance; + + /// + /// Singleton instance of the slot modification manager + /// + public static SlotModificationManager Instance + { + get + { + if (m_instance != null) + return m_instance; + Instantiate(); + return m_instance; + } + set => m_instance = value; + } + + private static void Instantiate() + { + if (m_instance != null) + return; + + GameObject slotModManager = new("SlotModificationManager"); + slotModManager.transform.SetParent(BoardManager.Instance.gameObject.transform); + m_instance = slotModManager.AddComponent(); + } + + /// + /// Unique identifiers for slot modifications. + /// + public enum ModificationType + { + NoModification = 0 + } + + /// + /// Contains information about a slot modification. + /// + public class Info + { + /// + /// The slot's modified texture in 3D scenes (Leshy, P03, etc) (154x226) + /// + public Dictionary Texture { get; internal set; } + + /// + /// The slots modified textures in 2D scenes (Act 2) (44x58) + /// + /// Each NPC has a slightly different theme, so custom slot textures need to accomodate these themes. + public Dictionary PixelBoardSlotSprites { get; internal set; } + + /// + /// Unique identifier for the slot modification. This will be assigned by the API. + /// + public ModificationType ModificationType { get; internal set; } + + /// + /// Class that contains the behavior for the slot. This must be a subclass of SlotModificationBehaviour + /// + public Type SlotBehaviour { get; internal set; } + } + + internal static List AllSlotModifications = new() { + new () { + Texture = null, + ModificationType = ModificationType.NoModification, + SlotBehaviour = null + } + }; + + private static Color ParseHtml(string html) + { + if (ColorUtility.TryParseHtmlString(html, out Color c)) + return c; + return Color.white; + } + + private static readonly Color TRANSPARENT = new Color(0f, 0f, 0f, 0f); + + private static readonly Dictionary> DEFAULT_COLORS = new() + { + { PixelBoardSpriteSetter.BoardTheme.Tech, new(ParseHtml("#446969"), ParseHtml("#B4FFEC")) }, + { PixelBoardSpriteSetter.BoardTheme.P03, new(ParseHtml("#446969"), ParseHtml("#B4FFEC")) }, + { PixelBoardSpriteSetter.BoardTheme.Nature, new(ParseHtml("#FF9226"), ParseHtml("#F7C376")) }, + { PixelBoardSpriteSetter.BoardTheme.Wizard, new(ParseHtml("#C1D080"), ParseHtml("#EEF4C6")) }, + { PixelBoardSpriteSetter.BoardTheme.Undead, new(ParseHtml("#C1D080"), ParseHtml("#EEF4C6")) }, + { PixelBoardSpriteSetter.BoardTheme.Finale, new(ParseHtml("#E14C89"), ParseHtml("#F779AD")) }, + }; + + private static Texture2D ConvertAct2TextureColor(Texture2D tex, Color targetColor) + { + Texture2D newTex = TextureHelper.DuplicateTexture(tex); + newTex.filterMode = FilterMode.Point; + for (int x = 0; x < newTex.width; x++) + { + for (int y = 0; y < newTex.height; y++) + { + var pix = tex.GetPixel(x, y); + if (pix.a < 1) + newTex.SetPixel(x, y, TRANSPARENT); + else if (pix == Color.black) + newTex.SetPixel(x, y, Color.black); + else + newTex.SetPixel(x, y, targetColor); + } + } + newTex.Apply(); + return newTex; + } + + private static PixelBoardSpriteSetter.BoardThemeSpriteSet GetSpriteSetFromTexture(Texture2D tex, PixelBoardSpriteSetter.BoardTheme theme) + { + int offset = theme == PixelBoardSpriteSetter.BoardTheme.Finale ? 4 : (int)theme; + bool hasOpponentSlots = tex.height == 232; + + Sprite playerSprite = Sprite.Create(tex, new Rect(0f + (float)offset * 44f, tex.height - 58f, 44f, 58f), new Vector2(0.5f, 0.5f)); + Sprite playerHoverSprite = Sprite.Create(tex, new Rect(0f + (float)offset * 44f, tex.height - 116f, 44f, 58f), new Vector2(0.5f, 0.5f)); + + Sprite opponentSprite = !hasOpponentSlots ? playerSprite : Sprite.Create(tex, new Rect(0f + (float)offset * 44f, tex.height - 174f, 44f, 58f), new Vector2(0.5f, 0.5f)); + Sprite opponentHoverSprite = !hasOpponentSlots ? playerHoverSprite : Sprite.Create(tex, new Rect(0f + (float)offset * 44f, 0f, 44f, 58f), new Vector2(0.5f, 0.5f)); + + List opponentSprites = new(); + if (hasOpponentSlots) + { + for (int i = 0; i < 4; i++) + { + opponentSprites.Add(new() + { + playerSlot = false, + index = i, + slotDefault = opponentSprite, + slotHighlight = opponentHoverSprite + }); + } + } + + return new() + { + id = theme, + slotDefault = playerSprite, + slotHighlight = playerHoverSprite, + specificSlotSprites = opponentSprites, + flipPlayerSlotSpriteX = false, + flipPlayerSlotSpriteY = false + }; + } + + /// + /// Converts a sheet of act 2 slot textures into a full collection of sprite sets. READ THE DOCUMENTATION. + /// + /// + /// This expects a texture with 8 slot textures in it, arranged in either 2 or 4 rows of 5. Each slot texture is 44x58. + /// The first row contains all of the the normal slot sprites and the second row contains all of the hover sprites. + /// If you want the opponent sprites to be different, you have to provide 4 rows; the third row contains normal opponent slot sprites and the fourth row contains hovered opponent slot sprites. + /// Each row must go in this order: nature, undead, tech, wizard, finale. + public static Dictionary BuildAct2SpriteSetFromSpriteSheetTexture(Texture2D texture) + { + Dictionary retval = new(); + + List themes = Enum.GetValues(typeof(PixelBoardSpriteSetter.BoardTheme)).Cast().ToList(); + themes.Remove(PixelBoardSpriteSetter.BoardTheme.P03); + themes.Remove(PixelBoardSpriteSetter.BoardTheme.NUM_THEMES); + + foreach (var theme in themes) + retval[theme] = GetSpriteSetFromTexture(texture, theme); + + return retval; + } + + /// + /// Converts a single act 2 slot texture into a full collection of sprite sets by repeatedly recoloring the texture + /// + /// + public static Dictionary BuildAct2SpriteSetFromTexture(Texture2D texture) + { + Dictionary retval = new(); + foreach (PixelBoardSpriteSetter.BoardTheme theme in Enum.GetValues(typeof(PixelBoardSpriteSetter.BoardTheme))) + { + if (theme == PixelBoardSpriteSetter.BoardTheme.NUM_THEMES) + continue; + + Texture2D slotTexture = ConvertAct2TextureColor(texture, DEFAULT_COLORS[theme].Item1); + Texture2D highlightedTexture = ConvertAct2TextureColor(texture, DEFAULT_COLORS[theme].Item2); + + PixelBoardSpriteSetter.BoardThemeSpriteSet spriteSet = new(); + spriteSet.id = theme; + spriteSet.slotDefault = Sprite.Create(slotTexture, new Rect(0f, 0f, 44f, 58f), new Vector2(0.5f, 0.5f)); + spriteSet.slotHighlight = Sprite.Create(highlightedTexture, new Rect(0f, 0f, 44f, 58f), new Vector2(0.5f, 0.5f)); + spriteSet.specificSlotSprites = new(); + + retval[theme] = spriteSet; + } + return retval; + } + + internal readonly Dictionary> SlotReceivers = new(); + + /// + /// Creates a new card slot modification + /// + /// Unique ID for the mod creating the slot modification + /// Reference name for the slot modification + /// The class that controls the behavior for the new slot + /// The 3D scene slot texture (154x226) + /// The 2D scene slot texture + /// Unique identifier for the modification type; used to set the slot modification in the future + public static ModificationType New(string modGuid, string modificationName, Type behaviour, Dictionary slotTexture, Dictionary pixelBoardSlotSprites) + { + if (!behaviour.IsSubclassOf(typeof(SlotModificationBehaviour))) + throw new InvalidOperationException("The slot behavior must be a subclass of SlotModificationBehaviour"); + + ModificationType mType = GuidManager.GetEnumValue(modGuid, modificationName); + + AllSlotModifications.Add(new() + { + Texture = slotTexture, + PixelBoardSlotSprites = pixelBoardSlotSprites ?? new(), + SlotBehaviour = behaviour, + ModificationType = mType + }); + return mType; + } + + /// + /// Creates a new card slot modification + /// + /// Unique ID for the mod creating the slot modification + /// Reference name for the slot modification + /// The class that controls the behavior for the new slot + /// The 3D scene slot texture (154x226) + /// The 2D scene slot texture + /// Unique identifier for the modification type; used to set the slot modification in the future + public static ModificationType New(string modGuid, string modificationName, Type behaviour, Texture2D slotTexture, Dictionary pixelBoardSlotSprites) + { + Dictionary templeMap = new(); + templeMap[CardTemple.Nature] = slotTexture; + templeMap[CardTemple.Tech] = slotTexture; + templeMap[CardTemple.Undead] = slotTexture; + templeMap[CardTemple.Wizard] = slotTexture; + return New(modGuid, modificationName, behaviour, templeMap, pixelBoardSlotSprites); + } + + /// + /// Creates a new card slot modification + /// + /// Unique ID for the mod creating the slot modification + /// Reference name for the slot modification + /// The class that controls the behavior for the new slot + /// The 3D scene slot texture (154x226) + /// The 2D scene slot texture. If it is the size of a single slot (44x58) it will be color converted to match each theme. If it is the size of a slot sprite sheet (220x116) it will be sliced into individual sprites. + /// Unique identifier for the modification type; used to set the slot modification in the future + public static ModificationType New(string modGuid, string modificationName, Type behaviour, Texture2D slotTexture, Texture2D pixelSlotTexture) + { + Dictionary spriteSet = null; + if (pixelSlotTexture != null) + { + if (pixelSlotTexture.width == 44 && pixelSlotTexture.height == 58) + spriteSet = BuildAct2SpriteSetFromTexture(pixelSlotTexture); + else if (pixelSlotTexture.width == 220 && (pixelSlotTexture.height == 116 || pixelSlotTexture.height == 232)) + spriteSet = BuildAct2SpriteSetFromSpriteSheetTexture(pixelSlotTexture); + else + throw new InvalidOperationException($"Cannot create slot mod {modGuid}/{modificationName}. The pixel slot texture must either be a single slot (44x58) or a 5x2 sprite sheet (220x116) or a 5x4 sprite sheet (220x232)"); + } + return New(modGuid, modificationName, behaviour, slotTexture, spriteSet); + } + + /// + /// Creates a new card slot modification + /// + /// Unique ID for the mod creating the slot modification + /// Reference name for the slot modification + /// The class that controls the behavior for the new slot + /// The 3D scene slot texture (154x226) + /// Unique identifier for the modification type; used to set the slot modification in the future + public static ModificationType New(string modGuid, string modificationName, Type behaviour, Texture2D slotTexture) => New(modGuid, modificationName, behaviour, slotTexture, (Texture2D)null); + + /// + /// Creates a new card slot modification + /// + /// Unique ID for the mod creating the slot modification + /// Reference name for the slot modification + /// The class that controls the behavior for the new slot + /// Unique identifier for the modification type; used to set the slot modification in the future + public static ModificationType New(string modGuid, string modificationName, Type behaviour) => New(modGuid, modificationName, behaviour, null, (Texture2D)null); + + [HarmonyPatch(typeof(TurnManager), nameof(TurnManager.CleanupPhase))] + [HarmonyPostfix] + private static IEnumerator CleanupSlots(IEnumerator sequence) + { + foreach (CardSlot slot in BoardManager.Instance.AllSlots) + { + if (slot.GetSlotModification() != ModificationType.NoModification) + yield return slot.SetSlotModification(ModificationType.NoModification); + } + + // At this point, all of the receivers should be clear, but regardless, we double check + if (GlobalTriggerHandler.Instance.StackSize > 0) + yield return new WaitUntil(() => GlobalTriggerHandler.Instance.StackSize == 0); + foreach (var kvp in Instance.SlotReceivers) + GameObject.Destroy(kvp.Value.Item2); + Instance.SlotReceivers.Clear(); + + yield return sequence; + } + + [HarmonyPatch(typeof(BoardManager), nameof(BoardManager.CleanUp))] + [HarmonyPostfix] + private static IEnumerator CleanUpModifiedSlots(IEnumerator sequence) + { + foreach (Info defn in AllSlotModifications.Where(m => m.SlotBehaviour != null)) + { + Component comp = BoardManager.Instance.gameObject.GetComponent(defn.SlotBehaviour); + if (!comp.SafeIsUnityNull()) + UnityEngine.Object.Destroy(comp); + } + + yield return sequence; + } + + internal static readonly Dictionary DefaultSlotTextures = new() + { + { CardTemple.Nature, ResourceBank.Get("Art/Cards/card_slot") }, + { CardTemple.Tech, ResourceBank.Get("Art/Cards/card_slot_tech") }, + { CardTemple.Wizard, ResourceBank.Get("Art/Cards/card_slot_undead") }, + { CardTemple.Undead, ResourceBank.Get("Art/Cards/card_slot_wizard") } + }; + + internal static readonly Dictionary> PlayerOverrideSlots = new(); + internal static readonly Dictionary> OpponentOverrideSlots = new(); + + private static void ConditionallyResetAllSlotTextures() + { + if (BoardManager.Instance != null) + { + foreach (var slot in BoardManager.Instance.AllSlotsCopy) + { + if (slot.GetSlotModification() == ModificationType.NoModification) + slot.ResetSlotTexture(); + } + } + } + + /// + /// Allows you to change the default slot texture for a given 3D scene + /// + /// Indicator for which scene/scrybe this should apply to + /// Texture for each player slot. Null textures will be replaced with the game's default. + /// Texture for each opponent slot. Null textures will be replaced with the game's default. + /// This does not work for Act 2. The base game uses the CardBattleNPC to + /// set default textures. It's possible that a future API update would allow for custom Act 2 + /// NPCs, so this will deliberately not touch Act 2 + public static void OverrideDefaultSlotTexture(CardTemple temple, Texture playerSlot, Texture opponentSlot) + { + OverrideDefaultSlotTexture( + temple, + playerSlot == null ? null : new List() { playerSlot, playerSlot, playerSlot, playerSlot }, + opponentSlot == null ? null : new List() { opponentSlot, opponentSlot, opponentSlot, opponentSlot } + ); + } + + /// + /// Allows you to change the default slot texture for a given 3D scene + /// + /// Indicator for which scene/scrybe this should apply to + /// Textures for each player slot. Null textures will be replaced with the game's default. + /// Textures for each opponent slot. Null textures will be replaced with the game's default. + /// This does not work for Act 2. The base game uses the CardBattleNPC to + /// set default textures. It's possible that a future API update would allow for custom Act 2 + /// NPCs, so this will deliberately not touch Act 2 + public static void OverrideDefaultSlotTexture(CardTemple temple, List playerSlots, List opponentSlots) + { + if (playerSlots != null && playerSlots.Any(t => t != null)) + PlayerOverrideSlots[temple] = new(playerSlots); + + if (opponentSlots != null && opponentSlots.Any(t => t != null)) + OpponentOverrideSlots[temple] = new(opponentSlots); + + ConditionallyResetAllSlotTextures(); + } + + /// + /// Resets the default slot texture back to the base game's default texture. + /// + /// The scene/scrybe to reset + public static void ResetDefaultSlotTexture(CardTemple temple) + { + if (PlayerOverrideSlots.ContainsKey(temple)) + PlayerOverrideSlots.Remove(temple); + if (OpponentOverrideSlots.ContainsKey(temple)) + OpponentOverrideSlots.Remove(temple); + ConditionallyResetAllSlotTextures(); + } +} \ No newline at end of file diff --git a/InscryptionAPI/Triggers/CustomTriggerFinder.cs b/InscryptionAPI/Triggers/CustomTriggerFinder.cs index dd8e0bf2..50cc73c3 100644 --- a/InscryptionAPI/Triggers/CustomTriggerFinder.cs +++ b/InscryptionAPI/Triggers/CustomTriggerFinder.cs @@ -1,6 +1,7 @@ using System.Collections; using DiskCardGame; using InscryptionAPI.Card; +using InscryptionAPI.Slots; using Sirenix.Serialization.Utilities; namespace InscryptionAPI.Triggers; @@ -421,7 +422,10 @@ public static IEnumerable FindTriggersOnBoard(bool findFacedown) { List cardsOnBoardCache = new(BoardManager.Instance.CardsOnBoard); List triggerReceiverCache = new(GlobalTriggerHandler.Instance.nonCardReceivers); - return triggerReceiverCache.Where(x => !x.SafeIsUnityNull()).Where(x => x.TriggerBeforeCards).OfType().Concat(cardsOnBoardCache.Where(x => x != null && x.OnBoard && (!x.FaceDown || findFacedown)).SelectMany(FindTriggersOnCard)) + List slotModificationCache = new(SlotModificationManager.Instance?.SlotReceivers?.Select(kvp => kvp.Value.Item2) ?? Enumerable.Empty()); + return triggerReceiverCache.Where(x => !x.SafeIsUnityNull()).Where(x => x.TriggerBeforeCards).OfType() + .Concat(slotModificationCache.Where(x => !x.SafeIsUnityNull()).OfType()) + .Concat(cardsOnBoardCache.Where(x => x != null && x.OnBoard && (!x.FaceDown || findFacedown)).SelectMany(FindTriggersOnCard)) .Concat(triggerReceiverCache.Where(x => !x.SafeIsUnityNull()).Where(x => !x.TriggerBeforeCards).OfType()) .Concat(cardsOnBoardCache.Where(x => x != null && x.OnBoard && x.FaceDown && !findFacedown).SelectMany(FindTriggersOnCard) .Where(x => x is IActivateWhenFacedown && (x as IActivateWhenFacedown).ShouldTriggerCustomWhenFaceDown(typeof(T)))); diff --git a/InscryptionAPI/Triggers/CustomTriggerPatches.cs b/InscryptionAPI/Triggers/CustomTriggerPatches.cs index 49e67d42..ba4b309d 100644 --- a/InscryptionAPI/Triggers/CustomTriggerPatches.cs +++ b/InscryptionAPI/Triggers/CustomTriggerPatches.cs @@ -1,6 +1,7 @@ using DiskCardGame; using HarmonyLib; using InscryptionAPI.Helpers.Extensions; +using InscryptionAPI.Slots; using System.Collections; using System.ComponentModel; using System.Reflection; @@ -401,7 +402,7 @@ static IEnumerable TriggerOnTurnEndInQueuePlayer(IEnumerable TriggerOnTurnEndInQueueOpponent(IEnumerable instructions) => TriggerOnTurnEndInQueue(instructions, false); - + static IEnumerable TriggerOnTurnEndInQueue(IEnumerable instructions, bool playerTurn) { List codes = instructions.ToList(); @@ -426,6 +427,23 @@ private static IEnumerator TriggerOnTurnEndInQueueCoro(IEnumerator originalTrigg } } + [HarmonyPatch(typeof(GlobalTriggerHandler), nameof(GlobalTriggerHandler.TriggerNonCardReceivers)), HarmonyPostfix] + private static IEnumerator TriggerSlotModificationHandlers(IEnumerator sequence, bool beforeCards, Trigger trigger, params object[] otherArgs) + { + yield return sequence; + + if (beforeCards) + { + // Trigger slot modifications + List slotModificationCache = new(SlotModificationManager.Instance?.SlotReceivers?.Select(kvp => kvp.Value.Item2) ?? Enumerable.Empty()); + foreach (var slotMod in slotModificationCache) + { + if (slotMod != null && GlobalTriggerHandler.ReceiverRespondsToTrigger(trigger, slotMod, otherArgs)) + yield return GlobalTriggerHandler.Instance.TriggerSequence(trigger, slotMod, otherArgs); + } + } + } + // IModifyAttackingSlots code can be found in DoCombatPhasePatches // IModifyDamageTaken and IPreTakeDamage logic can be found in TakeDamagePatches diff --git a/docs/wiki/slots.md b/docs/wiki/slots.md new file mode 100644 index 00000000..ad2fb019 --- /dev/null +++ b/docs/wiki/slots.md @@ -0,0 +1,101 @@ +## Slot Modifications +--- +The API now supports adding abilities and behaviors to card slots! These "slot modifications" are implemented very similarly to how you would implement sigils; you just need to code your modification logic as a subclass of `SlotModificationBehaviour` and make some custom artwork for what the new slot should look like. + +### Creating Slot Textures and Sprites + +Slot textures in 3D game zones are 154x226 pixels. You can either create a single slot texture for all zones, or create a separate texture for Leshy, P03, Grimora, and Magnificus. + +Slot textures in 2D game zones are 44x58 pixels. There are five different themes for 2D battles (Nature, Tech, Undead, Wizard, and Finale), and slots change their appearance when you hover over them. Additionally, you may wish to have a different texture for the opponent than for the player (this is most likely to happen in the Nature theme as the claw icons faces down for the opponent and up for the player). In order to accomodate all of these different combinations, you are given a number of options when supplying textures to the `SlotModificationManager`: + +- If you provide a 44x58 texture, the manager will create 10 variations of it automatically, recoloring it to fit the color (standard and highlighted/mouse-over) of each theme. All black and transparent pixels will be left as-is; all remaining pixels will be replaced with the appropriate color for the theme. +- If you provide a 220x116 texture, the manager will slice this into 10 sprites - two rows, five columns. The first row will be the standard slot, and the second row will be the slot when hovering. In order, the columns will be Nature, Undead, Tech, Wizard, Finale. +- If you provide a 220x232 texture, it behaves the same as above, except rows 3 and 4 are used for the opponent slots. + +### Creating a Slot Modification Behaviour + +You need to create a subclass of `SlotModificationBehaviour` to implement your slot's logic. You can also add on API interface triggers as part of this. + +For example, this slot deals one damage to the card in it every end step: + +```csharp +class SharpSlotBehaviour : SlotModificationBehaviour +{ + public override bool RespondsToTurnEnd(bool playerTurnEnd) => playerTurnEnd == Slot.IsPlayerSlot; + + public override IEnumerator OnTurnEnd(bool playerTurnEnd) + { + if (Slot.Card != null) + yield return Slot.Card.TakeDamage(1, null); + } +} +``` + +...and this slot adds 1 passive attack to the card in it... + +```csharp +public class BuffSlot : SlotModificationBehaviour, IPassiveAttackBuff +{ + public int GetPassiveAttackBuff(PlayableCard target) + { + return Slot.Card == target ? 1 : 0; + } +} +``` + +If you want your slot to grant another ability to the card in it, there's a helper for that: `SlotModificationGainAbilityBehaviour`: + +```csharp +public class SharpQuillsSlot : SlotModificationGainAbilityBehaviour +{ + protected override Ability AbilityToGain => Ability.Sharp; +} +``` + +If you want your slot to take an action when it is first created or when it is removed, override `Setup` and `Cleanup` respectively. Often these could be used to add additional visual flair to your slot: + +```csharp +public class AwesomeLookingSlot : SlotModificationBehaviour +{ + public override IEnumerator Setup() + { + yield return ShowSomeAwesomeVisuals(); + } + + public override IEnumerator Cleanup(SlotModificationManager.ModificationType replacement) + { + yield return DestroyMyAwesomeVisuals(); + } +} +``` + +### Registering A Slot Modification + +The pattern is very similar to creating a new sigil; you need to call the `SlotModificationManager` to register your slot mod and then store the result you get back: + +```csharp +public static readonly SlotModificationManager.ModificationType SharpQuillsSlot = SlotModificationManager.New( + "MyPluginGuid", + "SharpQuillsSlot", + typeof(SharpQuillsSlot), + TextureHelper.GetImageAsTexture("my_3d_card_slot.png", typeof(SharpQuillsSlot).Assembly), + TextureHelper.GetImageAsTexture("my_2d_card_slot.png", typeof(SharpQuillsSlot).Assembly) +) +``` + +### Activating A Slot Modification + +You'll almost always end up creating a slot modification as part of another sigil. Here, we have an example custom sigil that activates the slot when the card dies. You do this by using the extension method `SetSlotModification`. + +```csharp +public class LeaveSharpBehindBehaviour : AbilityBehaviour +{ + public override bool RespondsToPreDeathAnimation(bool wasSacrifice) => Card.OnBoard; + + public override IEnumerator OnPreDeathAnimation(bool wasSacrifice) + { + // SharpQuillSlot is the ID that was returned by the SlotModificationManager + yield return Card.Slot.SetSlotModification(SharpQuillsSlot); + } +} +``` \ No newline at end of file