diff --git a/CardSets/ExampleSet/images/bread_seeker.png b/CardSets/ExampleSet/images/bread_seeker.png new file mode 100644 index 0000000..e2548c5 Binary files /dev/null and b/CardSets/ExampleSet/images/bread_seeker.png differ diff --git a/CardSets/ExampleSet/images/duck_hero.png b/CardSets/ExampleSet/images/duck_hero.png new file mode 100644 index 0000000..2b05e33 Binary files /dev/null and b/CardSets/ExampleSet/images/duck_hero.png differ diff --git a/CardSets/ExampleSet/images/feathered_fury.png b/CardSets/ExampleSet/images/feathered_fury.png new file mode 100644 index 0000000..9fc5e6d Binary files /dev/null and b/CardSets/ExampleSet/images/feathered_fury.png differ diff --git a/CardSets/ExampleSet/images/golden_quacker.png b/CardSets/ExampleSet/images/golden_quacker.png new file mode 100644 index 0000000..daf13f6 Binary files /dev/null and b/CardSets/ExampleSet/images/golden_quacker.png differ diff --git a/CardSets/ExampleSet/images/pond_guardian.png b/CardSets/ExampleSet/images/pond_guardian.png new file mode 100644 index 0000000..082ac9f Binary files /dev/null and b/CardSets/ExampleSet/images/pond_guardian.png differ diff --git a/TradingCardMod.csproj b/TradingCardMod.csproj index bbbed72..76f4029 100644 --- a/TradingCardMod.csproj +++ b/TradingCardMod.csproj @@ -44,6 +44,12 @@ $(DuckovPath)$(SubPath)UnityEngine.CoreModule.dll + + $(DuckovPath)$(SubPath)UnityEngine.ImageConversionModule.dll + + + $(DuckovPath)$(SubPath)UnityEngine.InputLegacyModule.dll + @@ -57,4 +63,9 @@ + + + + + diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..e4247c2 --- /dev/null +++ b/deploy.sh @@ -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)" diff --git a/remove.sh b/remove.sh new file mode 100755 index 0000000..4ceac6d --- /dev/null +++ b/remove.sh @@ -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 diff --git a/src/ItemExtensions.cs b/src/ItemExtensions.cs new file mode 100644 index 0000000..f436bba --- /dev/null +++ b/src/ItemExtensions.cs @@ -0,0 +1,128 @@ +using System; +using System.Reflection; +using UnityEngine; + +namespace TradingCardMod +{ + /// + /// 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. + /// + public static class ItemExtensions + { + /// + /// Sets a private field value on an object using reflection. + /// + /// The type of the value to set. + /// The object containing the field. + /// The name of the private field. + /// The value to set. + /// Thrown when obj is null. + /// Thrown when field is not found. + public static void SetPrivateField(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); + } + + /// + /// Gets a private field value from an object using reflection. + /// + /// The expected type of the field value. + /// The object containing the field. + /// The name of the private field. + /// The field value cast to type T. + /// Thrown when obj is null. + /// Thrown when field is not found. + /// Thrown when field value cannot be cast to T. + public static T GetPrivateField(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); + } + + /// + /// Attempts to set a private field value, logging errors instead of throwing. + /// Useful for non-critical field assignments where failure shouldn't halt execution. + /// + /// The type of the value to set. + /// The object containing the field. + /// The name of the private field. + /// The value to set. + /// True if successful, false otherwise. + public static bool TrySetPrivateField(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; + } + } + + /// + /// Attempts to get a private field value, returning default on failure. + /// Useful for optional field access where failure shouldn't halt execution. + /// + /// The expected type of the field value. + /// The object containing the field. + /// The name of the private field. + /// The default value to return on failure. + /// The field value if successful, otherwise defaultValue. + public static T TryGetPrivateField(this object obj, string fieldName, T defaultValue = default) + { + try + { + return obj.GetPrivateField(fieldName); + } + catch (Exception ex) + { + Debug.LogWarning($"[TradingCardMod] Failed to get field '{fieldName}': {ex.Message}"); + return defaultValue; + } + } + } +} diff --git a/src/ModBehaviour.cs b/src/ModBehaviour.cs index aa81de8..d183470 100644 --- a/src/ModBehaviour.cs +++ b/src/ModBehaviour.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using UnityEngine; using ItemStatsSystem; using SodaCraft.Localizations; +using Duckov.Utilities; namespace TradingCardMod { @@ -16,8 +17,17 @@ namespace TradingCardMod private static ModBehaviour? _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 List _loadedCards = new List(); + private List _registeredItems = new List(); + private List _createdGameObjects = new List(); + private Tag? _tradingCardTag; + + // Debug: track if we've spawned test items + private int _debugSpawnIndex = 0; /// /// Called when the mod is loaded. Initialize the card system here. @@ -37,6 +47,10 @@ namespace TradingCardMod try { + // Create our custom tag first + _tradingCardTag = TagHelper.GetOrCreateTradingCardTag(); + + // Load and register cards LoadCardSets(); } catch (Exception ex) @@ -69,13 +83,53 @@ namespace TradingCardMod } 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!"); + } + + /// + /// Update is called every frame. Used for debug input handling. + /// + void Update() + { + // Debug: Press F9 to spawn a card + if (Input.GetKeyDown(KeyCode.F9)) + { + SpawnDebugCard(); + } + } + + /// + /// Spawns a trading card for testing purposes. + /// + 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}"); + } } /// /// Loads a single card set from a directory. /// Expects a cards.txt file with pipe-separated values. /// - /// Path to the card set directory private void LoadCardSet(string setDirectory) { string setName = Path.GetFileName(setDirectory); @@ -91,26 +145,30 @@ namespace TradingCardMod try { - string[] lines = File.ReadAllLines(cardsFile); - int cardCount = 0; + // Use CardParser to load cards + 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 - if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#")) - continue; - - TradingCard? card = ParseCardLine(line, setDirectory); - if (card != null) + // Validate card + var errors = CardParser.ValidateCard(card); + if (errors.Count > 0) { - _loadedCards.Add(card); - cardCount++; - // TODO: Register card with game's item system - // RegisterCardWithGame(card); + foreach (var error in errors) + { + Debug.LogWarning($"[TradingCardMod] {card.CardName}: {error}"); + } + 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) { @@ -119,35 +177,181 @@ namespace TradingCardMod } /// - /// Parses a single line from cards.txt into a TradingCard object. - /// Format: CardName | SetName | SetNumber | ImageFile | Rarity | Weight | Value + /// Creates a game item for a trading card using clone + reflection. /// - 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 { - return new TradingCard + // Get base item to clone + Item original = ItemAssetsCollection.GetPrefab(BASE_ITEM_ID); + if (original == null) { - CardName = parts[0].Trim(), - SetName = parts[1].Trim(), - SetNumber = int.Parse(parts[2].Trim()), - ImagePath = Path.Combine(setDirectory, "images", parts[3].Trim()), - Rarity = parts[4].Trim(), - Weight = float.Parse(parts[5].Trim()), - Value = int.Parse(parts[6].Trim()) - }; + Debug.LogError($"[TradingCardMod] Base item ID {BASE_ITEM_ID} not found!"); + return; + } + + // Clone the item + GameObject clone = UnityEngine.Object.Instantiate(original.gameObject); + clone.name = $"TradingCard_{card.SetName}_{card.CardName}"; + UnityEngine.Object.DontDestroyOnLoad(clone); + _createdGameObjects.Add(clone); + + Item item = clone.GetComponent(); + 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) { - Debug.LogWarning($"[TradingCardMod] Failed to parse card line: {line} - {ex.Message}"); + Debug.LogError($"[TradingCardMod] Error registering {card.CardName}: {ex.Message}"); + } + } + + /// + /// Sets the DisplayQuality enum based on quality level. + /// + 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; + } + + /// + /// Loads a sprite from an image file (PNG/JPG). + /// + 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(); + resourceHolder.Texture = texture; + resourceHolder.Sprite = sprite; + + return sprite; + } + catch (Exception ex) + { + Debug.LogError($"[TradingCardMod] Error loading sprite: {ex.Message}"); return null; } } @@ -162,42 +366,41 @@ namespace TradingCardMod // Remove Harmony patches Patches.RemovePatches(); - // TODO: Remove registered items from game - // foreach (var card in _loadedCards) - // { - // ItemAssetsCollection.RemoveDynamicEntry(card.ItemPrefab); - // } + // Remove registered items from game + foreach (var item in _registeredItems) + { + 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(); + + Debug.Log("[TradingCardMod] Cleanup complete."); } } /// - /// Represents a trading card's data loaded from a card set file. + /// Component to hold texture and sprite references to prevent garbage collection. /// - public class TradingCard + public class CardResourceHolder : MonoBehaviour { - public string CardName { get; set; } = string.Empty; - public string SetName { get; set; } = string.Empty; - 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; } - - /// - /// Generates a unique TypeID for this card to avoid conflicts. - /// Uses hash of set name + card name for uniqueness. - /// - 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); - } + public Texture2D? Texture; + public Sprite? Sprite; } } diff --git a/src/TagHelper.cs b/src/TagHelper.cs new file mode 100644 index 0000000..8efbb10 --- /dev/null +++ b/src/TagHelper.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; +using System.Linq; +using Duckov.Utilities; +using SodaCraft.Localizations; +using UnityEngine; + +namespace TradingCardMod +{ + /// + /// Helper class for working with the game's Tag system. + /// Tags are ScriptableObjects used for filtering items in slots. + /// + public static class TagHelper + { + // Track tags we create for cleanup + private static readonly List _createdTags = new List(); + + /// + /// Gets an existing game tag by name. + /// + /// The internal name of the tag (e.g., "Luxury", "Food"). + /// The Tag if found, null otherwise. + public static Tag GetTargetTag(string tagName) + { + return Resources.FindObjectsOfTypeAll() + .FirstOrDefault(t => t.name == tagName); + } + + /// + /// Creates a new custom tag by cloning an existing game tag. + /// If a tag with the same name already exists, returns that tag instead. + /// + /// The internal name for the new tag. + /// The localized display name shown to players. + /// The name of an existing tag to clone (default: "Luxury"). + /// The created or existing tag, or null if template not found. + public static Tag CreateOrCloneTag(string tagName, string displayName, string templateTagName = "Luxury") + { + // Check if tag already exists + Tag existing = Resources.FindObjectsOfTypeAll() + .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() + .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; + } + + /// + /// Gets the "TradingCard" tag, creating it if it doesn't exist. + /// This is the primary tag used to identify and filter trading cards. + /// + /// The TradingCard tag. + public static Tag GetOrCreateTradingCardTag() + { + return CreateOrCloneTag("TradingCard", "Trading Card"); + } + + /// + /// Gets all tags created by this mod. + /// + /// List of tags created by the mod. + public static IReadOnlyList GetCreatedTags() + { + return _createdTags.AsReadOnly(); + } + + /// + /// Cleans up all tags created by the mod. + /// Should be called when the mod is unloaded. + /// + public static void Cleanup() + { + foreach (var tag in _createdTags) + { + if (tag != null) + { + Object.Destroy(tag); + } + } + _createdTags.Clear(); + Debug.Log("[TradingCardMod] TagHelper cleaned up."); + } + + /// + /// Logs all available tags in the game (for debugging). + /// + public static void LogAvailableTags() + { + var tags = Resources.FindObjectsOfTypeAll(); + Debug.Log($"[TradingCardMod] Available tags ({tags.Length}):"); + foreach (var tag in tags.OrderBy(t => t.name)) + { + Debug.Log($" - {tag.name}"); + } + } + } +}