Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions Hooks/ModCardHandOutlinePatchHelper.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
194 changes: 194 additions & 0 deletions Hooks/ModCardHandOutlineRegistry.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Per–card-type custom outline colors for the in-hand <see cref="MegaCrit.Sts2.Core.Nodes.Cards.NCardHighlight" />.
/// Applied after vanilla <see cref="NHandCardHolder.UpdateCard" /> via Harmony. Foreign providers (e.g. RitsuLib)
/// merge via <see cref="RegisterForeign" />.
/// </summary>
public static class ModCardHandOutlineRegistry
{
private static readonly Func<CardModel, bool> ForeignPredicateAlreadySatisfied = static _ => true;

private static int _sequence;
private static int _foreignOrder;

private static readonly ConcurrentDictionary<Type, List<RegisteredRule>> RulesByCardType = new();
private static readonly Lock ForeignLock = new();
private static readonly List<ForeignProvider> ForeignProviders = [];

/// <summary>
/// Registers a rule for <typeparamref name="TCard" />.
/// </summary>
public static void Register<TCard>(ModCardHandOutlineRule rule) where TCard : CardModel
{
Register(typeof(TCard), rule);
}

/// <summary>
/// Registers a rule for <paramref name="cardType" /> (concrete <see cref="CardModel" /> subtype).
/// </summary>
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<RegisteredRule>(existing) { wrapped };
return copy;
});
}

/// <summary>
/// Merges outline evaluation from another assembly (e.g. RitsuLib). The delegate must return
/// <see langword="null" /> when no rule applies, otherwise paint fields only — the foreign registry has already
/// evaluated <c>When</c>. Uses <see cref="ValueTuple" /> so the boundary stays a nullable struct (no heap boxing).
/// </summary>
public static void RegisterForeign(string modId, string sourceId,
Func<CardModel, (Color Color, int Priority, bool VisibleWhenUnplayable)?> evaluateBestFromForeign)
{
ArgumentException.ThrowIfNullOrWhiteSpace(modId);
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
ArgumentNullException.ThrowIfNull(evaluateBestFromForeign);

var order = Interlocked.Increment(ref _foreignOrder);
lock (ForeignLock)
{
ForeignProviders.Add(new ForeignProvider(evaluateBestFromForeign, order));
}
}

/// <summary>
/// Clears all rules and foreign providers (tests / tooling).
/// </summary>
public static void ClearForTests()
{
RulesByCardType.Clear();
lock (ForeignLock)
{
ForeignProviders.Clear();
}
}

/// <summary>
/// Applies the best matching registered outline for this holder.
/// </summary>
/// <returns><see langword="true" /> if a rule was applied.</returns>
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<ForeignProvider> 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<CardModel, (Color Color, int Priority, bool VisibleWhenUnplayable)?> Evaluate,
int Order);

private readonly record struct ForeignCandidate(ModCardHandOutlineRule Rule, int Order);
}
24 changes: 24 additions & 0 deletions Hooks/ModCardHandOutlineRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Godot;
using MegaCrit.Sts2.Core.Models;

namespace BaseLib.Hooks;

/// <summary>
/// Custom hand-card outline tint for <see cref="MegaCrit.Sts2.Core.Nodes.Cards.NCardHighlight" /> after vanilla
/// playable / gold / red. Register with <see cref="ModCardHandOutlineRegistry" />.
/// </summary>
/// <param name="When">When this returns true for the card instance, the outline color may apply.</param>
/// <param name="Color">Godot modulate color (alpha is respected; vanilla highlights use ~0.98).</param>
/// <param name="Priority">
/// When several rules match, the highest <paramref name="Priority" /> wins; ties favor the most recently registered
/// rule.
/// </param>
/// <param name="VisibleWhenUnplayable">
/// 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).
/// </param>
public readonly record struct ModCardHandOutlineRule(
Func<CardModel, bool> When,
Color Color,
int Priority = 0,
bool VisibleWhenUnplayable = false);
31 changes: 31 additions & 0 deletions Patches/Cards/NHandCardHolderHandOutlinePatches.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}