diff --git a/Hooks/ModCardHandOutlinePatchHelper.cs b/Hooks/ModCardHandOutlinePatchHelper.cs new file mode 100644 index 0000000..2287430 --- /dev/null +++ b/Hooks/ModCardHandOutlinePatchHelper.cs @@ -0,0 +1,53 @@ +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Nodes.Cards.Holders; + +namespace BaseLib.Hooks; + +internal static class ModCardHandOutlinePatchHelper +{ + internal static bool TryGetRule(NHandCardHolder holder, out CardModel model, out ModCardHandOutlineRule rule) + { + model = null!; + rule = default; + + if (!holder.IsNodeReady() || holder.CardNode?.Model is not { } m) + return false; + + var evaluated = ModCardHandOutlineRegistry.EvaluateBest(m); + if (evaluated is not { } r) + return false; + + model = m; + rule = r; + return true; + } + + internal static void ApplyHighlight(NHandCardHolder holder, CardModel model, ModCardHandOutlineRule rule) + { + if (CombatManager.Instance is not { IsInProgress: true }) + return; + + var vanillaShow = model.CanPlay() || model.ShouldGlowRed || model.ShouldGlowGold; + var force = rule.VisibleWhenUnplayable && !vanillaShow; + if (!vanillaShow && !force) + return; + + var highlight = holder.CardNode!.CardHighlight; + if (force) + highlight.AnimShow(); + + highlight.Modulate = rule.Color; + } + + internal static void ApplyFlash(NHandCardHolder holder, ModCardHandOutlineRule rule) + { + if (AccessTools.Field(typeof(NHandCardHolder), "_flash")?.GetValue(holder) is not Control flash || + !GodotObject.IsInstanceValid(flash)) + return; + + flash.Modulate = rule.Color; + } +} \ No newline at end of file diff --git a/Hooks/ModCardHandOutlineRegistry.cs b/Hooks/ModCardHandOutlineRegistry.cs new file mode 100644 index 0000000..01a02e0 --- /dev/null +++ b/Hooks/ModCardHandOutlineRegistry.cs @@ -0,0 +1,194 @@ +using System.Collections.Concurrent; +using Godot; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Nodes.Cards.Holders; + +namespace BaseLib.Hooks; + +/// +/// Per–card-type custom outline colors for the in-hand . +/// Applied after vanilla via Harmony. Foreign providers (e.g. RitsuLib) +/// merge via . +/// +public static class ModCardHandOutlineRegistry +{ + private static readonly Func ForeignPredicateAlreadySatisfied = static _ => true; + + private static int _sequence; + private static int _foreignOrder; + + private static readonly ConcurrentDictionary> RulesByCardType = new(); + private static readonly Lock ForeignLock = new(); + private static readonly List ForeignProviders = []; + + /// + /// Registers a rule for . + /// + public static void Register(ModCardHandOutlineRule rule) where TCard : CardModel + { + Register(typeof(TCard), rule); + } + + /// + /// Registers a rule for (concrete subtype). + /// + public static void Register(Type cardType, ModCardHandOutlineRule rule) + { + ArgumentNullException.ThrowIfNull(cardType); + ArgumentNullException.ThrowIfNull(rule.When); + + if (cardType.IsAbstract || !typeof(CardModel).IsAssignableFrom(cardType)) + throw new ArgumentException( + $"Type '{cardType.FullName}' must be a concrete subtype of {typeof(CardModel).FullName}.", + nameof(cardType)); + + var seq = Interlocked.Increment(ref _sequence); + var wrapped = new RegisteredRule(rule, seq); + + RulesByCardType.AddOrUpdate( + cardType, + _ => [wrapped], + (_, existing) => + { + var copy = new List(existing) { wrapped }; + return copy; + }); + } + + /// + /// Merges outline evaluation from another assembly (e.g. RitsuLib). The delegate must return + /// when no rule applies, otherwise paint fields only — the foreign registry has already + /// evaluated When. Uses so the boundary stays a nullable struct (no heap boxing). + /// + public static void RegisterForeign(string modId, string sourceId, + Func evaluateBestFromForeign) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modId); + ArgumentException.ThrowIfNullOrWhiteSpace(sourceId); + ArgumentNullException.ThrowIfNull(evaluateBestFromForeign); + + var order = Interlocked.Increment(ref _foreignOrder); + lock (ForeignLock) + { + ForeignProviders.Add(new ForeignProvider(evaluateBestFromForeign, order)); + } + } + + /// + /// Clears all rules and foreign providers (tests / tooling). + /// + public static void ClearForTests() + { + RulesByCardType.Clear(); + lock (ForeignLock) + { + ForeignProviders.Clear(); + } + } + + /// + /// Applies the best matching registered outline for this holder. + /// + /// if a rule was applied. + public static bool TryRefreshOutlineForHolder(NHandCardHolder? holder) + { + if (holder == null || !holder.IsNodeReady() || holder.CardNode?.Model is not { } model) + return false; + + var rule = EvaluateBest(model); + if (!rule.HasValue) + return false; + + ModCardHandOutlinePatchHelper.ApplyHighlight(holder, model, rule.Value); + return true; + } + + internal static ModCardHandOutlineRule? EvaluateBest(CardModel model) + { + var local = EvaluateLocalBest(model); + ForeignCandidate? foreignBest = null; + + List snapshot; + lock (ForeignLock) + { + snapshot = [..ForeignProviders]; + } + + foreach (var provider in snapshot) + { + (Color Color, int Priority, bool VisibleWhenUnplayable)? foreignPaint; + try + { + foreignPaint = provider.Evaluate(model); + } + catch + { + continue; + } + + if (foreignPaint is not { } paint) + continue; + + var candidate = new ModCardHandOutlineRule(ForeignPredicateAlreadySatisfied, paint.Color, paint.Priority, + paint.VisibleWhenUnplayable); + + if (foreignBest is null || + RuleWins(candidate, provider.Order, foreignBest.Value.Rule, foreignBest.Value.Order)) + foreignBest = new ForeignCandidate(candidate, provider.Order); + } + + switch (local) + { + case null when foreignBest is null: + return null; + case null: + return foreignBest.Value.Rule; + } + + if (foreignBest is null) + return local.Value.Rule; + + return RuleWins(foreignBest.Value.Rule, foreignBest.Value.Order, local.Value.Rule, local.Value.Sequence) + ? foreignBest.Value.Rule + : local.Value.Rule; + } + + private static RegisteredRule? EvaluateLocalBest(CardModel model) + { + RegisteredRule? best = null; + + for (var t = model.GetType(); + t != null && typeof(CardModel).IsAssignableFrom(t); + t = t.BaseType) + { + if (!RulesByCardType.TryGetValue(t, out var list)) + continue; + + foreach (var entry in list.Where(entry => entry.Rule.When(model)).Where(entry => best is null + || entry.Rule.Priority > best.Value.Rule.Priority + || (entry.Rule.Priority == best.Value.Rule.Priority && + entry.Sequence > best.Value.Sequence))) + best = entry; + } + + return best; + } + + private static bool RuleWins(ModCardHandOutlineRule challenger, int challengerOrder, + ModCardHandOutlineRule incumbent, + int incumbentOrder) + { + if (challenger.Priority != incumbent.Priority) + return challenger.Priority > incumbent.Priority; + + return challengerOrder > incumbentOrder; + } + + private readonly record struct RegisteredRule(ModCardHandOutlineRule Rule, int Sequence); + + private readonly record struct ForeignProvider( + Func Evaluate, + int Order); + + private readonly record struct ForeignCandidate(ModCardHandOutlineRule Rule, int Order); +} \ No newline at end of file diff --git a/Hooks/ModCardHandOutlineRule.cs b/Hooks/ModCardHandOutlineRule.cs new file mode 100644 index 0000000..0595ef4 --- /dev/null +++ b/Hooks/ModCardHandOutlineRule.cs @@ -0,0 +1,24 @@ +using Godot; +using MegaCrit.Sts2.Core.Models; + +namespace BaseLib.Hooks; + +/// +/// Custom hand-card outline tint for after vanilla +/// playable / gold / red. Register with . +/// +/// When this returns true for the card instance, the outline color may apply. +/// Godot modulate color (alpha is respected; vanilla highlights use ~0.98). +/// +/// When several rules match, the highest wins; ties favor the most recently registered +/// rule. +/// +/// +/// If true, the highlight is forced visible with this color even when the card is not playable and vanilla would not +/// show gold/red (still only while combat is in progress). +/// +public readonly record struct ModCardHandOutlineRule( + Func When, + Color Color, + int Priority = 0, + bool VisibleWhenUnplayable = false); \ No newline at end of file diff --git a/Patches/Cards/NHandCardHolderHandOutlinePatches.cs b/Patches/Cards/NHandCardHolderHandOutlinePatches.cs new file mode 100644 index 0000000..c13dc30 --- /dev/null +++ b/Patches/Cards/NHandCardHolderHandOutlinePatches.cs @@ -0,0 +1,31 @@ +using BaseLib.Hooks; +using HarmonyLib; +using MegaCrit.Sts2.Core.Nodes.Cards.Holders; + +namespace BaseLib.Patches.Cards; + +[HarmonyPatch(typeof(NHandCardHolder), nameof(NHandCardHolder.UpdateCard))] +internal static class NHandCardHolderUpdateCardHandOutlinePatch +{ + [HarmonyPostfix] + public static void Postfix(NHandCardHolder __instance) + { + if (!ModCardHandOutlinePatchHelper.TryGetRule(__instance, out var model, out var rule)) + return; + + ModCardHandOutlinePatchHelper.ApplyHighlight(__instance, model, rule); + } +} + +[HarmonyPatch(typeof(NHandCardHolder), nameof(NHandCardHolder.Flash))] +internal static class NHandCardHolderFlashHandOutlinePatch +{ + [HarmonyPostfix] + public static void Postfix(NHandCardHolder __instance) + { + if (!ModCardHandOutlinePatchHelper.TryGetRule(__instance, out _, out var rule)) + return; + + ModCardHandOutlinePatchHelper.ApplyFlash(__instance, rule); + } +} \ No newline at end of file