Implement Phase 2: Core card framework with clone + reflection

- Add ItemExtensions.cs with reflection helpers for private field access
- Add TagHelper.cs for game tag operations and custom TradingCard tag
- Rewrite ModBehaviour.cs with complete item creation pipeline:
  - Clone base game item (ID 135) as template
  - Set properties via reflection (typeID, weight, value, quality)
  - Load PNG sprites from CardSets/*/images/
  - Register items with ItemAssetsCollection
  - Proper cleanup on mod unload
- Add F9 debug key to spawn cards for testing
- Add deploy.sh and remove.sh scripts for quick mod installation
- Add placeholder card images for ExampleSet
- Add Unity module references (ImageConversion, InputLegacy)

Cards now appear in-game with custom icons and can be spawned to inventory.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-11-19 16:56:04 -06:00
parent 8d23f152eb
commit d05ba64700
11 changed files with 638 additions and 65 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -44,6 +44,12 @@
<Reference Include="UnityEngine.CoreModule"> <Reference Include="UnityEngine.CoreModule">
<HintPath>$(DuckovPath)$(SubPath)UnityEngine.CoreModule.dll</HintPath> <HintPath>$(DuckovPath)$(SubPath)UnityEngine.CoreModule.dll</HintPath>
</Reference> </Reference>
<Reference Include="UnityEngine.ImageConversionModule">
<HintPath>$(DuckovPath)$(SubPath)UnityEngine.ImageConversionModule.dll</HintPath>
</Reference>
<Reference Include="UnityEngine.InputLegacyModule">
<HintPath>$(DuckovPath)$(SubPath)UnityEngine.InputLegacyModule.dll</HintPath>
</Reference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@ -57,4 +63,9 @@
<None Include="info.ini" CopyToOutputDirectory="PreserveNewest" /> <None Include="info.ini" CopyToOutputDirectory="PreserveNewest" />
<None Include="preview.png" CopyToOutputDirectory="PreserveNewest" Condition="Exists('preview.png')" /> <None Include="preview.png" CopyToOutputDirectory="PreserveNewest" Condition="Exists('preview.png')" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- Exclude test files from main build (they're in the test project) -->
<Compile Remove="tests/**/*.cs" />
</ItemGroup>
</Project> </Project>

61
deploy.sh Executable file
View File

@ -0,0 +1,61 @@
#!/bin/bash
# Deploy Trading Card Mod to Escape from Duckov
# Usage: ./deploy.sh [--release]
set -e
# Configuration
GAME_PATH="/mnt/NV2/SteamLibrary/steamapps/common/Escape from Duckov"
MOD_NAME="TradingCardMod"
MOD_DIR="$GAME_PATH/Duckov_Data/Mods/$MOD_NAME"
# Build configuration
BUILD_CONFIG="Debug"
if [[ "$1" == "--release" ]]; then
BUILD_CONFIG="Release"
fi
echo "=== Trading Card Mod Deployment ==="
echo "Build config: $BUILD_CONFIG"
echo "Target: $MOD_DIR"
echo ""
# Build the project
echo "[1/4] Building project..."
dotnet build TradingCardMod.csproj -c "$BUILD_CONFIG" --verbosity quiet
if [[ $? -ne 0 ]]; then
echo "ERROR: Build failed!"
exit 1
fi
echo " Build successful"
# Create mod directory if it doesn't exist
echo "[2/4] Creating mod directory..."
mkdir -p "$MOD_DIR"
mkdir -p "$MOD_DIR/CardSets"
# Copy mod files
echo "[3/4] Copying mod files..."
cp "bin/$BUILD_CONFIG/netstandard2.1/$MOD_NAME.dll" "$MOD_DIR/"
cp "info.ini" "$MOD_DIR/"
# Copy preview if it exists
if [[ -f "preview.png" ]]; then
cp "preview.png" "$MOD_DIR/"
fi
# Copy card sets
echo "[4/4] Copying card sets..."
if [[ -d "CardSets" ]]; then
cp -r CardSets/* "$MOD_DIR/CardSets/" 2>/dev/null || true
fi
echo ""
echo "=== Deployment Complete ==="
echo "Mod installed to: $MOD_DIR"
echo ""
echo "Contents:"
ls -la "$MOD_DIR/"
echo ""
echo "Card sets:"
ls -la "$MOD_DIR/CardSets/" 2>/dev/null || echo " (none)"

46
remove.sh Executable file
View File

@ -0,0 +1,46 @@
#!/bin/bash
# Remove Trading Card Mod from Escape from Duckov
# Usage: ./remove.sh [--backup]
set -e
# Configuration
GAME_PATH="/mnt/NV2/SteamLibrary/steamapps/common/Escape from Duckov"
MOD_NAME="TradingCardMod"
MOD_DIR="$GAME_PATH/Duckov_Data/Mods/$MOD_NAME"
BACKUP_DIR="$HOME/.local/share/TradingCardMod_backup"
echo "=== Trading Card Mod Removal ==="
echo "Target: $MOD_DIR"
echo ""
# Check if mod exists
if [[ ! -d "$MOD_DIR" ]]; then
echo "Mod not installed at: $MOD_DIR"
exit 0
fi
# Backup option
if [[ "$1" == "--backup" ]]; then
echo "[1/2] Creating backup..."
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_PATH="$BACKUP_DIR/${MOD_NAME}_$TIMESTAMP"
cp -r "$MOD_DIR" "$BACKUP_PATH"
echo " Backup saved to: $BACKUP_PATH"
echo "[2/2] Removing mod..."
else
echo "[1/1] Removing mod..."
fi
# Remove the mod
rm -rf "$MOD_DIR"
echo ""
echo "=== Removal Complete ==="
echo "Mod removed from: $MOD_DIR"
# Show backup location if created
if [[ "$1" == "--backup" ]]; then
echo "Backup location: $BACKUP_PATH"
fi

128
src/ItemExtensions.cs Normal file
View File

@ -0,0 +1,128 @@
using System;
using System.Reflection;
using UnityEngine;
namespace TradingCardMod
{
/// <summary>
/// Extension methods for reflection-based field access on Unity objects.
/// Used to set private fields on cloned game items since we can't use
/// constructors or public setters for internal game types.
/// </summary>
public static class ItemExtensions
{
/// <summary>
/// Sets a private field value on an object using reflection.
/// </summary>
/// <typeparam name="T">The type of the value to set.</typeparam>
/// <param name="obj">The object containing the field.</param>
/// <param name="fieldName">The name of the private field.</param>
/// <param name="value">The value to set.</param>
/// <exception cref="ArgumentNullException">Thrown when obj is null.</exception>
/// <exception cref="ArgumentException">Thrown when field is not found.</exception>
public static void SetPrivateField<T>(this object obj, string fieldName, T value)
{
if (obj == null)
throw new ArgumentNullException(nameof(obj));
Type type = obj.GetType();
FieldInfo field = null;
// Search up the inheritance hierarchy for the field
while (type != null && field == null)
{
field = type.GetField(fieldName,
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
type = type.BaseType;
}
if (field == null)
{
throw new ArgumentException(
$"Field '{fieldName}' not found on type '{obj.GetType().Name}' or its base types.");
}
field.SetValue(obj, value);
}
/// <summary>
/// Gets a private field value from an object using reflection.
/// </summary>
/// <typeparam name="T">The expected type of the field value.</typeparam>
/// <param name="obj">The object containing the field.</param>
/// <param name="fieldName">The name of the private field.</param>
/// <returns>The field value cast to type T.</returns>
/// <exception cref="ArgumentNullException">Thrown when obj is null.</exception>
/// <exception cref="ArgumentException">Thrown when field is not found.</exception>
/// <exception cref="InvalidCastException">Thrown when field value cannot be cast to T.</exception>
public static T GetPrivateField<T>(this object obj, string fieldName)
{
if (obj == null)
throw new ArgumentNullException(nameof(obj));
Type type = obj.GetType();
FieldInfo field = null;
// Search up the inheritance hierarchy for the field
while (type != null && field == null)
{
field = type.GetField(fieldName,
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
type = type.BaseType;
}
if (field == null)
{
throw new ArgumentException(
$"Field '{fieldName}' not found on type '{obj.GetType().Name}' or its base types.");
}
return (T)field.GetValue(obj);
}
/// <summary>
/// Attempts to set a private field value, logging errors instead of throwing.
/// Useful for non-critical field assignments where failure shouldn't halt execution.
/// </summary>
/// <typeparam name="T">The type of the value to set.</typeparam>
/// <param name="obj">The object containing the field.</param>
/// <param name="fieldName">The name of the private field.</param>
/// <param name="value">The value to set.</param>
/// <returns>True if successful, false otherwise.</returns>
public static bool TrySetPrivateField<T>(this object obj, string fieldName, T value)
{
try
{
obj.SetPrivateField(fieldName, value);
return true;
}
catch (Exception ex)
{
Debug.LogWarning($"[TradingCardMod] Failed to set field '{fieldName}': {ex.Message}");
return false;
}
}
/// <summary>
/// Attempts to get a private field value, returning default on failure.
/// Useful for optional field access where failure shouldn't halt execution.
/// </summary>
/// <typeparam name="T">The expected type of the field value.</typeparam>
/// <param name="obj">The object containing the field.</param>
/// <param name="fieldName">The name of the private field.</param>
/// <param name="defaultValue">The default value to return on failure.</param>
/// <returns>The field value if successful, otherwise defaultValue.</returns>
public static T TryGetPrivateField<T>(this object obj, string fieldName, T defaultValue = default)
{
try
{
return obj.GetPrivateField<T>(fieldName);
}
catch (Exception ex)
{
Debug.LogWarning($"[TradingCardMod] Failed to get field '{fieldName}': {ex.Message}");
return defaultValue;
}
}
}
}

View File

@ -4,6 +4,7 @@ using System.Collections.Generic;
using UnityEngine; using UnityEngine;
using ItemStatsSystem; using ItemStatsSystem;
using SodaCraft.Localizations; using SodaCraft.Localizations;
using Duckov.Utilities;
namespace TradingCardMod namespace TradingCardMod
{ {
@ -16,8 +17,17 @@ namespace TradingCardMod
private static ModBehaviour? _instance; private static ModBehaviour? _instance;
public static ModBehaviour Instance => _instance!; public static ModBehaviour Instance => _instance!;
// Base game item ID to clone (135 is commonly used for collectibles)
private const int BASE_ITEM_ID = 135;
private string _modPath = string.Empty; private string _modPath = string.Empty;
private List<TradingCard> _loadedCards = new List<TradingCard>(); private List<TradingCard> _loadedCards = new List<TradingCard>();
private List<Item> _registeredItems = new List<Item>();
private List<GameObject> _createdGameObjects = new List<GameObject>();
private Tag? _tradingCardTag;
// Debug: track if we've spawned test items
private int _debugSpawnIndex = 0;
/// <summary> /// <summary>
/// Called when the mod is loaded. Initialize the card system here. /// Called when the mod is loaded. Initialize the card system here.
@ -37,6 +47,10 @@ namespace TradingCardMod
try try
{ {
// Create our custom tag first
_tradingCardTag = TagHelper.GetOrCreateTradingCardTag();
// Load and register cards
LoadCardSets(); LoadCardSets();
} }
catch (Exception ex) catch (Exception ex)
@ -69,13 +83,53 @@ namespace TradingCardMod
} }
Debug.Log($"[TradingCardMod] Total cards loaded: {_loadedCards.Count}"); Debug.Log($"[TradingCardMod] Total cards loaded: {_loadedCards.Count}");
Debug.Log($"[TradingCardMod] Total items registered: {_registeredItems.Count}");
Debug.Log("[TradingCardMod] DEBUG: Press F9 to spawn a trading card!");
}
/// <summary>
/// Update is called every frame. Used for debug input handling.
/// </summary>
void Update()
{
// Debug: Press F9 to spawn a card
if (Input.GetKeyDown(KeyCode.F9))
{
SpawnDebugCard();
}
}
/// <summary>
/// Spawns a trading card for testing purposes.
/// </summary>
private void SpawnDebugCard()
{
if (_registeredItems.Count == 0)
{
Debug.LogWarning("[TradingCardMod] No cards registered to spawn!");
return;
}
// Cycle through registered cards
Item cardToSpawn = _registeredItems[_debugSpawnIndex % _registeredItems.Count];
_debugSpawnIndex++;
try
{
// Use game's utility to give item to player
ItemUtilities.SendToPlayer(cardToSpawn);
Debug.Log($"[TradingCardMod] Spawned card: {cardToSpawn.DisplayName} (ID: {cardToSpawn.TypeID})");
}
catch (Exception ex)
{
Debug.LogError($"[TradingCardMod] Failed to spawn card: {ex.Message}");
}
} }
/// <summary> /// <summary>
/// Loads a single card set from a directory. /// Loads a single card set from a directory.
/// Expects a cards.txt file with pipe-separated values. /// Expects a cards.txt file with pipe-separated values.
/// </summary> /// </summary>
/// <param name="setDirectory">Path to the card set directory</param>
private void LoadCardSet(string setDirectory) private void LoadCardSet(string setDirectory)
{ {
string setName = Path.GetFileName(setDirectory); string setName = Path.GetFileName(setDirectory);
@ -91,26 +145,30 @@ namespace TradingCardMod
try try
{ {
string[] lines = File.ReadAllLines(cardsFile); // Use CardParser to load cards
int cardCount = 0; string imagesDirectory = Path.Combine(setDirectory, "images");
var cards = CardParser.ParseFile(cardsFile, imagesDirectory);
foreach (string line in lines) foreach (var card in cards)
{ {
// Skip empty lines and comments // Validate card
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#")) var errors = CardParser.ValidateCard(card);
continue; if (errors.Count > 0)
TradingCard? card = ParseCardLine(line, setDirectory);
if (card != null)
{ {
_loadedCards.Add(card); foreach (var error in errors)
cardCount++; {
// TODO: Register card with game's item system Debug.LogWarning($"[TradingCardMod] {card.CardName}: {error}");
// RegisterCardWithGame(card); }
continue;
} }
_loadedCards.Add(card);
// Register card as game item
RegisterCardWithGame(card);
} }
Debug.Log($"[TradingCardMod] Loaded {cardCount} cards from {setName}"); Debug.Log($"[TradingCardMod] Loaded {cards.Count} cards from {setName}");
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -119,35 +177,181 @@ namespace TradingCardMod
} }
/// <summary> /// <summary>
/// Parses a single line from cards.txt into a TradingCard object. /// Creates a game item for a trading card using clone + reflection.
/// Format: CardName | SetName | SetNumber | ImageFile | Rarity | Weight | Value
/// </summary> /// </summary>
private TradingCard? ParseCardLine(string line, string setDirectory) private void RegisterCardWithGame(TradingCard card)
{ {
string[] parts = line.Split('|');
if (parts.Length < 7)
{
Debug.LogWarning($"[TradingCardMod] Invalid card line (expected 7 fields): {line}");
return null;
}
try try
{ {
return new TradingCard // Get base item to clone
Item original = ItemAssetsCollection.GetPrefab(BASE_ITEM_ID);
if (original == null)
{ {
CardName = parts[0].Trim(), Debug.LogError($"[TradingCardMod] Base item ID {BASE_ITEM_ID} not found!");
SetName = parts[1].Trim(), return;
SetNumber = int.Parse(parts[2].Trim()), }
ImagePath = Path.Combine(setDirectory, "images", parts[3].Trim()),
Rarity = parts[4].Trim(), // Clone the item
Weight = float.Parse(parts[5].Trim()), GameObject clone = UnityEngine.Object.Instantiate(original.gameObject);
Value = int.Parse(parts[6].Trim()) clone.name = $"TradingCard_{card.SetName}_{card.CardName}";
}; UnityEngine.Object.DontDestroyOnLoad(clone);
_createdGameObjects.Add(clone);
Item item = clone.GetComponent<Item>();
if (item == null)
{
Debug.LogError($"[TradingCardMod] Cloned object has no Item component!");
return;
}
// Set item properties via reflection
int typeId = card.GenerateTypeID();
string locKey = $"TC_{card.SetName}_{card.CardName}".Replace(" ", "_");
item.SetPrivateField("typeID", typeId);
item.SetPrivateField("weight", card.Weight);
item.SetPrivateField("value", card.Value);
item.SetPrivateField("displayName", locKey);
item.SetPrivateField("quality", card.GetQuality());
item.SetPrivateField("order", 0);
item.SetPrivateField("maxStackCount", 1);
// Set display quality based on rarity
SetDisplayQuality(item, card.GetQuality());
// Set tags
item.Tags.Clear();
// Add Luxury tag (for selling at shops)
Tag? luxuryTag = TagHelper.GetTargetTag("Luxury");
if (luxuryTag != null)
{
item.Tags.Add(luxuryTag);
}
// Add our custom TradingCard tag
if (_tradingCardTag != null)
{
item.Tags.Add(_tradingCardTag);
}
// Load and set icon
Sprite? cardSprite = LoadSpriteFromFile(card.ImagePath, typeId);
if (cardSprite != null)
{
item.SetPrivateField("icon", cardSprite);
}
else
{
Debug.LogWarning($"[TradingCardMod] Using default icon for {card.CardName}");
}
// Set localization
LocalizationManager.SetOverrideText(locKey, card.CardName);
LocalizationManager.SetOverrideText($"{locKey}_Desc", card.GetDescription());
// Register with game's item system
if (ItemAssetsCollection.AddDynamicEntry(item))
{
_registeredItems.Add(item);
Debug.Log($"[TradingCardMod] Registered: {card.CardName} (ID: {typeId})");
}
else
{
Debug.LogError($"[TradingCardMod] Failed to register {card.CardName}!");
UnityEngine.Object.Destroy(clone);
_createdGameObjects.Remove(clone);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.LogWarning($"[TradingCardMod] Failed to parse card line: {line} - {ex.Message}"); Debug.LogError($"[TradingCardMod] Error registering {card.CardName}: {ex.Message}");
}
}
/// <summary>
/// Sets the DisplayQuality enum based on quality level.
/// </summary>
private void SetDisplayQuality(Item item, int quality)
{
// DisplayQuality is cast from int - matching AdditionalCollectibles pattern
// Values: 0=Common, 2=Uncommon, 3=Rare, 4=Epic, 5=Legendary, 6=Mythic
int displayValue;
switch (quality)
{
case 2:
displayValue = 2;
break;
case 3:
displayValue = 3;
break;
case 4:
displayValue = 4;
break;
case 5:
displayValue = 5;
break;
case 6:
case 7:
displayValue = 6;
break;
default:
displayValue = 0;
break;
}
item.DisplayQuality = (DisplayQuality)displayValue;
}
/// <summary>
/// Loads a sprite from an image file (PNG/JPG).
/// </summary>
private Sprite? LoadSpriteFromFile(string imagePath, int itemId)
{
try
{
if (!File.Exists(imagePath))
{
Debug.LogWarning($"[TradingCardMod] Image not found: {imagePath}");
return null;
}
// Read image bytes
byte[] imageData = File.ReadAllBytes(imagePath);
// Create texture and load image
Texture2D texture = new Texture2D(2, 2, TextureFormat.RGBA32, false);
if (!ImageConversion.LoadImage(texture, imageData))
{
Debug.LogError($"[TradingCardMod] Failed to load image (not PNG/JPG?): {imagePath}");
return null;
}
texture.filterMode = FilterMode.Bilinear;
texture.Apply();
// Create sprite from texture
Sprite sprite = Sprite.Create(
texture,
new Rect(0f, 0f, texture.width, texture.height),
new Vector2(0.5f, 0.5f),
100f
);
// Create holder to keep texture/sprite alive
GameObject holder = new GameObject($"CardIcon_{itemId}");
UnityEngine.Object.DontDestroyOnLoad(holder);
_createdGameObjects.Add(holder);
// Store references on the holder to prevent GC
var resourceHolder = holder.AddComponent<CardResourceHolder>();
resourceHolder.Texture = texture;
resourceHolder.Sprite = sprite;
return sprite;
}
catch (Exception ex)
{
Debug.LogError($"[TradingCardMod] Error loading sprite: {ex.Message}");
return null; return null;
} }
} }
@ -162,42 +366,41 @@ namespace TradingCardMod
// Remove Harmony patches // Remove Harmony patches
Patches.RemovePatches(); Patches.RemovePatches();
// TODO: Remove registered items from game // Remove registered items from game
// foreach (var card in _loadedCards) foreach (var item in _registeredItems)
// { {
// ItemAssetsCollection.RemoveDynamicEntry(card.ItemPrefab); if (item != null)
// } {
ItemAssetsCollection.RemoveDynamicEntry(item);
}
}
_registeredItems.Clear();
// Destroy created GameObjects (including icon holders)
foreach (var go in _createdGameObjects)
{
if (go != null)
{
UnityEngine.Object.Destroy(go);
}
}
_createdGameObjects.Clear();
// Clean up tags
TagHelper.Cleanup();
_loadedCards.Clear(); _loadedCards.Clear();
Debug.Log("[TradingCardMod] Cleanup complete.");
} }
} }
/// <summary> /// <summary>
/// Represents a trading card's data loaded from a card set file. /// Component to hold texture and sprite references to prevent garbage collection.
/// </summary> /// </summary>
public class TradingCard public class CardResourceHolder : MonoBehaviour
{ {
public string CardName { get; set; } = string.Empty; public Texture2D? Texture;
public string SetName { get; set; } = string.Empty; public Sprite? Sprite;
public int SetNumber { get; set; }
public string ImagePath { get; set; } = string.Empty;
public string Rarity { get; set; } = string.Empty;
public float Weight { get; set; }
public int Value { get; set; }
// TODO: Add Unity prefab reference once we understand the item system better
// public Item? ItemPrefab { get; set; }
/// <summary>
/// Generates a unique TypeID for this card to avoid conflicts.
/// Uses hash of set name + card name for uniqueness.
/// </summary>
public int GenerateTypeID()
{
// Start from a high number to avoid conflicts with base game items
// Use hash to ensure consistency across loads
string uniqueKey = $"TradingCard_{SetName}_{CardName}";
return 100000 + Math.Abs(uniqueKey.GetHashCode() % 900000);
}
} }
} }

124
src/TagHelper.cs Normal file
View File

@ -0,0 +1,124 @@
using System.Collections.Generic;
using System.Linq;
using Duckov.Utilities;
using SodaCraft.Localizations;
using UnityEngine;
namespace TradingCardMod
{
/// <summary>
/// Helper class for working with the game's Tag system.
/// Tags are ScriptableObjects used for filtering items in slots.
/// </summary>
public static class TagHelper
{
// Track tags we create for cleanup
private static readonly List<Tag> _createdTags = new List<Tag>();
/// <summary>
/// Gets an existing game tag by name.
/// </summary>
/// <param name="tagName">The internal name of the tag (e.g., "Luxury", "Food").</param>
/// <returns>The Tag if found, null otherwise.</returns>
public static Tag GetTargetTag(string tagName)
{
return Resources.FindObjectsOfTypeAll<Tag>()
.FirstOrDefault(t => t.name == tagName);
}
/// <summary>
/// Creates a new custom tag by cloning an existing game tag.
/// If a tag with the same name already exists, returns that tag instead.
/// </summary>
/// <param name="tagName">The internal name for the new tag.</param>
/// <param name="displayName">The localized display name shown to players.</param>
/// <param name="templateTagName">The name of an existing tag to clone (default: "Luxury").</param>
/// <returns>The created or existing tag, or null if template not found.</returns>
public static Tag CreateOrCloneTag(string tagName, string displayName, string templateTagName = "Luxury")
{
// Check if tag already exists
Tag existing = Resources.FindObjectsOfTypeAll<Tag>()
.FirstOrDefault(t => t.name == tagName);
if (existing != null)
{
Debug.Log($"[TradingCardMod] Tag '{tagName}' already exists, reusing.");
return existing;
}
// Find template tag to clone
Tag template = Resources.FindObjectsOfTypeAll<Tag>()
.FirstOrDefault(t => t.name == templateTagName);
if (template == null)
{
Debug.LogError($"[TradingCardMod] Template tag '{templateTagName}' not found. Cannot create '{tagName}'.");
return null;
}
// Clone the template
Tag newTag = Object.Instantiate(template);
newTag.name = tagName;
Object.DontDestroyOnLoad(newTag);
// Track for cleanup
_createdTags.Add(newTag);
// Set localization for display
LocalizationManager.SetOverrideText($"Tag_{tagName}", displayName);
LocalizationManager.SetOverrideText($"Tag_{tagName}_Desc", "");
Debug.Log($"[TradingCardMod] Created custom tag '{tagName}' (display: '{displayName}').");
return newTag;
}
/// <summary>
/// Gets the "TradingCard" tag, creating it if it doesn't exist.
/// This is the primary tag used to identify and filter trading cards.
/// </summary>
/// <returns>The TradingCard tag.</returns>
public static Tag GetOrCreateTradingCardTag()
{
return CreateOrCloneTag("TradingCard", "Trading Card");
}
/// <summary>
/// Gets all tags created by this mod.
/// </summary>
/// <returns>List of tags created by the mod.</returns>
public static IReadOnlyList<Tag> GetCreatedTags()
{
return _createdTags.AsReadOnly();
}
/// <summary>
/// Cleans up all tags created by the mod.
/// Should be called when the mod is unloaded.
/// </summary>
public static void Cleanup()
{
foreach (var tag in _createdTags)
{
if (tag != null)
{
Object.Destroy(tag);
}
}
_createdTags.Clear();
Debug.Log("[TradingCardMod] TagHelper cleaned up.");
}
/// <summary>
/// Logs all available tags in the game (for debugging).
/// </summary>
public static void LogAvailableTags()
{
var tags = Resources.FindObjectsOfTypeAll<Tag>();
Debug.Log($"[TradingCardMod] Available tags ({tags.Length}):");
foreach (var tag in tags.OrderBy(t => t.name))
{
Debug.Log($" - {tag.name}");
}
}
}
}