diff --git a/Hooks/ModCardHandOutlinePatchHelper.cs b/Hooks/ModCardHandOutlinePatchHelper.cs new file mode 100644 index 00000000..1d5e5b90 --- /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.ResolveColor(model); + } + + internal static void ApplyFlash(NHandCardHolder holder, CardModel model, ModCardHandOutlineRule rule) + { + if (AccessTools.Field(typeof(NHandCardHolder), "_flash")?.GetValue(holder) is not Control flash || + !GodotObject.IsInstanceValid(flash)) + return; + + flash.Modulate = rule.ResolveColor(model); + } +} \ No newline at end of file diff --git a/Hooks/ModCardHandOutlineRegistry.cs b/Hooks/ModCardHandOutlineRegistry.cs new file mode 100644 index 00000000..c8b203bd --- /dev/null +++ b/Hooks/ModCardHandOutlineRegistry.cs @@ -0,0 +1,268 @@ +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 = []; + private static readonly List ForeignDynamicProviders = []; + + /// + /// Registers a rule for . + /// + public static void Register(ModCardHandOutlineRule rule) where TCard : CardModel + { + Register(typeof(TCard), rule); + } + + /// + /// Registers a rule for ( subtype). + /// + public static void Register(Type cardType, ModCardHandOutlineRule rule) + { + ArgumentNullException.ThrowIfNull(cardType); + ArgumentNullException.ThrowIfNull(rule.When); + + if (!typeof(CardModel).IsAssignableFrom(cardType)) + throw new ArgumentException( + $"Type '{cardType.FullName}' must be a 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)); + } + } + + /// + /// Merges dynamic outline evaluation from another assembly. The delegate returns current paint resolver and metadata. + /// + public static void RegisterForeignDynamic(string modId, string sourceId, + Func ResolveColor, int Priority, bool VisibleWhenUnplayable)?> evaluateBestFromForeign) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modId); + ArgumentException.ThrowIfNullOrWhiteSpace(sourceId); + ArgumentNullException.ThrowIfNull(evaluateBestFromForeign); + + var order = Interlocked.Increment(ref _foreignOrder); + lock (ForeignLock) + { + ForeignDynamicProviders.Add(new ForeignDynamicProvider(evaluateBestFromForeign, order)); + } + } + + /// + /// Clears all rules and foreign providers (tests / tooling). + /// + public static void ClearForTests() + { + RulesByCardType.Clear(); + lock (ForeignLock) + { + ForeignProviders.Clear(); + ForeignDynamicProviders.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; + } + + /// + /// Applies outline only when the winning rule uses . + /// + public static bool TryRefreshDynamicOutlineForHolder(NHandCardHolder? holder) + { + if (holder == null || !holder.IsNodeReady() || holder.CardNode?.Model is not { } model) + return false; + + var rule = EvaluateBest(model); + if (!rule.HasValue || rule.Value.DynamicColor == null) + 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); + } + + List dynamicSnapshot; + lock (ForeignLock) + { + dynamicSnapshot = [..ForeignDynamicProviders]; + } + + foreach (var provider in dynamicSnapshot) + { + (Func ResolveColor, int Priority, bool VisibleWhenUnplayable)? foreignPaint; + try + { + foreignPaint = provider.Evaluate(model); + } + catch + { + continue; + } + + if (foreignPaint is not { } paint || paint.ResolveColor == null) + continue; + + var candidate = new ModCardHandOutlineRule( + ForeignPredicateAlreadySatisfied, + paint.ResolveColor(), + paint.Priority, + paint.VisibleWhenUnplayable) + { + DynamicColor = _ => paint.ResolveColor(), + }; + + 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 ForeignDynamicProvider( + Func ResolveColor, int Priority, bool VisibleWhenUnplayable)?> 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 00000000..66046a5a --- /dev/null +++ b/Hooks/ModCardHandOutlineRule.cs @@ -0,0 +1,53 @@ +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) +{ + /// + /// Optional dynamic color resolver. When assigned and passes, this is evaluated on refresh + /// to produce current outline color. + /// + public Func? DynamicColor { get; init; } + + /// + /// Creates a rule with a dynamic color resolver. + /// + public static ModCardHandOutlineRule Dynamic( + Func when, + Func colorWhen, + int priority = 0, + bool visibleWhenUnplayable = false) + { + ArgumentNullException.ThrowIfNull(when); + ArgumentNullException.ThrowIfNull(colorWhen); + return new ModCardHandOutlineRule(when, Colors.White, priority, visibleWhenUnplayable) + { + DynamicColor = colorWhen, + }; + } + + internal Color ResolveColor(CardModel card) + { + return DynamicColor?.Invoke(card) ?? Color; + } +} \ No newline at end of file diff --git a/Patches/Cards/NHandCardHolderHandOutlinePatches.cs b/Patches/Cards/NHandCardHolderHandOutlinePatches.cs new file mode 100644 index 00000000..739de2a0 --- /dev/null +++ b/Patches/Cards/NHandCardHolderHandOutlinePatches.cs @@ -0,0 +1,96 @@ +using System.Collections.Concurrent; +using BaseLib.Hooks; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.Core.Helpers; +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 var model, out var rule)) + return; + + ModCardHandOutlinePatchHelper.ApplyFlash(__instance, model, rule); + } +} + +[HarmonyPatch(typeof(NHandCardHolder), nameof(NHandCardHolder._Ready))] +internal static class NHandCardHolderDynamicOutlineReadyPatch +{ + private static readonly ConcurrentDictionary TokensByHolderId = new(); + + [HarmonyPostfix] + public static void Postfix(NHandCardHolder __instance) + { + if (!GodotObject.IsInstanceValid(__instance) || !__instance.IsInsideTree() || __instance.GetTree() == null) + return; + + var id = __instance.GetInstanceId(); + if (!TokensByHolderId.TryAdd(id, new CancellationTokenSource())) + return; + + var cts = TokensByHolderId[id]; + TaskHelper.RunSafely(RunDynamicRefreshLoop(__instance, id, cts.Token)); + } + + private static async Task RunDynamicRefreshLoop(NHandCardHolder holder, ulong id, CancellationToken token) + { + try + { + while (!token.IsCancellationRequested && GodotObject.IsInstanceValid(holder)) + { + if (!holder.IsInsideTree()) + break; + + ModCardHandOutlineRegistry.TryRefreshDynamicOutlineForHolder(holder); + var tree = holder.GetTree(); + if (tree == null || !GodotObject.IsInstanceValid(tree)) + break; + + await tree.ToSignal(tree, SceneTree.SignalName.ProcessFrame); + } + } + finally + { + StopLoop(id); + } + } + + internal static void StopLoop(ulong id) + { + if (!TokensByHolderId.TryRemove(id, out var cts)) + return; + + cts.Cancel(); + cts.Dispose(); + } +} + +[HarmonyPatch(typeof(NHandCardHolder), nameof(NHandCardHolder._ExitTree))] +internal static class NHandCardHolderDynamicOutlineExitTreePatch +{ + [HarmonyPrefix] + public static void Prefix(NHandCardHolder __instance) + { + NHandCardHolderDynamicOutlineReadyPatch.StopLoop(__instance.GetInstanceId()); + } +} \ No newline at end of file