Implement card pack system with gacha mechanics

Add card packs that can be opened via right-click Use menu to receive
random cards based on weighted slot configurations. Packs support
customizable rarity-based or card-specific drop rates.

Key features:
- CardPack/PackSlot data classes with weighted random selection
- PackParser for user-defined pack configurations
- PackUsageBehavior extending game's UsageBehavior system
- Runtime data lookup to handle Unity serialization limitations
- Pack consumption after opening with stack support
- Auto-generated default packs per card set

Technical notes:
- UsageUtilities registration for context menu integration
- All non-serializable fields (Dictionary, List<PackSlot>) looked up
  at runtime from ModBehaviour to survive Unity instantiation
- F9 debug spawn now uses InstantiateSync for proper item copies

🤖 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 21:44:29 -06:00
parent 0d9ab65422
commit 91ee0333db
8 changed files with 1000 additions and 7 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -50,6 +50,9 @@
<Reference Include="UnityEngine.InputLegacyModule">
<HintPath>$(DuckovPath)$(SubPath)UnityEngine.InputLegacyModule.dll</HintPath>
</Reference>
<Reference Include="UniTask">
<HintPath>$(DuckovPath)$(SubPath)UniTask.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>

155
src/CardPack.cs Normal file
View File

@ -0,0 +1,155 @@
using System.Collections.Generic;
namespace TradingCardMod
{
/// <summary>
/// Represents a card pack that can be opened to receive random cards.
/// </summary>
public class CardPack
{
/// <summary>
/// Display name of the pack.
/// </summary>
public string PackName { get; set; } = string.Empty;
/// <summary>
/// The card set this pack draws from.
/// </summary>
public string SetName { get; set; } = string.Empty;
/// <summary>
/// Path to the pack's icon image.
/// </summary>
public string ImagePath { get; set; } = string.Empty;
/// <summary>
/// Filename of the pack image (before full path is set).
/// </summary>
public string ImageFile { get; set; } = string.Empty;
/// <summary>
/// In-game currency value of the pack.
/// </summary>
public int Value { get; set; }
/// <summary>
/// Weight for appearing in loot (0 = not lootable).
/// </summary>
public float Weight { get; set; } = 0.1f;
/// <summary>
/// The slots in this pack, each with their own drop weights.
/// </summary>
public List<PackSlot> Slots { get; set; } = new List<PackSlot>();
/// <summary>
/// Whether this is an auto-generated default pack.
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// Generates a unique TypeID for this pack.
/// Uses hash of set name + pack name for uniqueness.
/// </summary>
public int GenerateTypeID()
{
// Start from 300000 range for packs (cards are 100000+, storage is 200000+)
string uniqueKey = $"CardPack_{SetName}_{PackName}";
return 300000 + System.Math.Abs(uniqueKey.GetHashCode() % 100000);
}
}
/// <summary>
/// Represents a single slot in a card pack with drop weights.
/// Each slot can use either rarity-based or card-specific weights.
/// </summary>
public class PackSlot
{
/// <summary>
/// Whether this slot uses rarity-based weights (true) or specific card weights (false).
/// </summary>
public bool UseRarityWeights { get; set; } = true;
/// <summary>
/// Rarity-based weights. Key = rarity string (e.g., "Common"), Value = weight.
/// Used when UseRarityWeights is true.
/// </summary>
public Dictionary<string, float> RarityWeights { get; set; } = new Dictionary<string, float>();
/// <summary>
/// Specific card weights. Key = card name, Value = weight.
/// Used when UseRarityWeights is false.
/// </summary>
public Dictionary<string, float> CardWeights { get; set; } = new Dictionary<string, float>();
}
/// <summary>
/// Default slot configurations for auto-generated packs.
/// </summary>
public static class DefaultPackSlots
{
/// <summary>
/// Slot 1: Favors common cards.
/// </summary>
public static PackSlot CommonSlot => new PackSlot
{
UseRarityWeights = true,
RarityWeights = new Dictionary<string, float>
{
{ "Common", 100f },
{ "Uncommon", 30f },
{ "Rare", 5f },
{ "Very Rare", 1f },
{ "Ultra Rare", 0f },
{ "Legendary", 0f }
}
};
/// <summary>
/// Slot 2: Balanced towards uncommon.
/// </summary>
public static PackSlot UncommonSlot => new PackSlot
{
UseRarityWeights = true,
RarityWeights = new Dictionary<string, float>
{
{ "Common", 60f },
{ "Uncommon", 80f },
{ "Rare", 20f },
{ "Very Rare", 5f },
{ "Ultra Rare", 1f },
{ "Legendary", 1f }
}
};
/// <summary>
/// Slot 3: Better odds for rare+ cards.
/// </summary>
public static PackSlot RareSlot => new PackSlot
{
UseRarityWeights = true,
RarityWeights = new Dictionary<string, float>
{
{ "Common", 30f },
{ "Uncommon", 60f },
{ "Rare", 60f },
{ "Very Rare", 20f },
{ "Ultra Rare", 5f },
{ "Legendary", 5f }
}
};
/// <summary>
/// Gets the default slots for an auto-generated pack (3 slots).
/// </summary>
public static List<PackSlot> GetDefaultSlots()
{
return new List<PackSlot>
{
CommonSlot,
UncommonSlot,
RareSlot
};
}
}
}

View File

@ -17,6 +17,42 @@ namespace TradingCardMod
private static ModBehaviour? _instance;
public static ModBehaviour Instance => _instance!;
/// <summary>
/// Gets the list of registered card items (for loot injection).
/// </summary>
public List<Item> GetRegisteredItems() => _registeredItems;
/// <summary>
/// Gets cards for a specific set (used by PackUsageBehavior).
/// </summary>
public List<TradingCard> GetCardsBySet(string setName)
{
if (_cardsBySet.TryGetValue(setName, out var cards))
{
return cards;
}
return new List<TradingCard>();
}
/// <summary>
/// Gets the card name to TypeID mapping (used by PackUsageBehavior).
/// </summary>
public Dictionary<string, int> GetCardTypeIds() => _cardNameToTypeId;
/// <summary>
/// Gets slot configuration for a specific pack (used by PackUsageBehavior).
/// </summary>
public List<PackSlot> GetPackSlots(string setName, string packName)
{
string key = $"{setName}|{packName}";
if (_packDefinitions.TryGetValue(key, out var pack))
{
return pack.Slots;
}
Debug.LogWarning($"[TradingCardMod] Pack definition not found: {key}");
return new List<PackSlot>();
}
// Base game item ID to clone (135 is commonly used for collectibles)
private const int BASE_ITEM_ID = 135;
@ -32,6 +68,14 @@ namespace TradingCardMod
private Item? _binderItem;
private Item? _cardBoxItem;
// Track cards and IDs per set for pack creation
private Dictionary<string, List<TradingCard>> _cardsBySet = new Dictionary<string, List<TradingCard>>();
private Dictionary<string, int> _cardNameToTypeId = new Dictionary<string, int>();
private List<Item> _registeredPacks = new List<Item>();
// Store pack definitions for runtime lookup (key = "SetName|PackName")
private Dictionary<string, CardPack> _packDefinitions = new Dictionary<string, CardPack>();
// Debug: track spawn cycling
private int _debugSpawnIndex = 0;
private List<Item> _allSpawnableItems = new List<Item>();
@ -54,6 +98,9 @@ namespace TradingCardMod
try
{
// Log all available tags for reference
TagHelper.LogAvailableTags();
// Create our custom tag first
_tradingCardTag = TagHelper.GetOrCreateTradingCardTag();
@ -63,10 +110,14 @@ namespace TradingCardMod
// Create storage items
CreateStorageItems();
// Build spawnable items list (cards + storage)
// Create card packs
CreateCardPacks();
// Build spawnable items list (cards + storage + packs)
_allSpawnableItems.AddRange(_registeredItems);
if (_binderItem != null) _allSpawnableItems.Add(_binderItem);
if (_cardBoxItem != null) _allSpawnableItems.Add(_cardBoxItem);
_allSpawnableItems.AddRange(_registeredPacks);
Debug.Log("[TradingCardMod] Mod initialized successfully!");
}
@ -102,6 +153,34 @@ 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 items (cycles through cards, then binder, then box)");
// Clear the search cache so our items can be found
ClearSearchCache();
}
/// <summary>
/// Clears the ItemAssetsCollection search cache so dynamically registered items can be found.
/// </summary>
private void ClearSearchCache()
{
try
{
var cacheField = typeof(ItemAssetsCollection).GetField("cachedSearchResults",
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
if (cacheField != null)
{
var cache = cacheField.GetValue(null) as System.Collections.IDictionary;
if (cache != null)
{
cache.Clear();
Debug.Log("[TradingCardMod] Cleared search cache for loot table integration");
}
}
}
catch (Exception ex)
{
Debug.LogWarning($"[TradingCardMod] Could not clear search cache: {ex.Message}");
}
}
/// <summary>
@ -138,6 +217,61 @@ namespace TradingCardMod
);
}
/// <summary>
/// Creates card packs for each loaded card set.
/// </summary>
private void CreateCardPacks()
{
foreach (var setEntry in _cardsBySet)
{
string setName = setEntry.Key;
var cards = setEntry.Value;
if (cards.Count == 0)
{
Debug.LogWarning($"[TradingCardMod] No cards in set {setName}, skipping pack creation");
continue;
}
// Create default pack for this set
string setDirectory = Path.Combine(_modPath, "CardSets", setName);
string imagesDirectory = Path.Combine(setDirectory, "images");
CardPack defaultPack = PackParser.CreateDefaultPack(setName, imagesDirectory);
// Check for user-defined packs
string packsFile = Path.Combine(setDirectory, "packs.txt");
var userPacks = PackParser.ParseFile(packsFile, setName, imagesDirectory);
// Create all packs
var allPacks = new List<CardPack> { defaultPack };
allPacks.AddRange(userPacks);
foreach (var pack in allPacks)
{
// Store pack definition for runtime lookup
string packKey = $"{pack.SetName}|{pack.PackName}";
_packDefinitions[packKey] = pack;
// Load pack icon
Sprite? packIcon = null;
if (File.Exists(pack.ImagePath))
{
packIcon = LoadSpriteFromFile(pack.ImagePath, pack.GenerateTypeID());
}
// Create pack item
Item? packItem = PackHelper.CreatePackItem(pack, packIcon);
if (packItem != null)
{
_registeredPacks.Add(packItem);
}
}
}
Debug.Log($"[TradingCardMod] Created {_registeredPacks.Count} card packs");
}
/// <summary>
/// Update is called every frame. Used for debug input handling.
/// </summary>
@ -162,14 +296,23 @@ namespace TradingCardMod
}
// Cycle through all spawnable items
Item itemToSpawn = _allSpawnableItems[_debugSpawnIndex % _allSpawnableItems.Count];
Item prefab = _allSpawnableItems[_debugSpawnIndex % _allSpawnableItems.Count];
_debugSpawnIndex++;
try
{
// Instantiate a fresh copy of the item (don't send prefab directly)
Item instance = ItemAssetsCollection.InstantiateSync(prefab.TypeID);
if (instance != null)
{
// Use game's utility to give item to player
ItemUtilities.SendToPlayer(itemToSpawn);
Debug.Log($"[TradingCardMod] Spawned: {itemToSpawn.DisplayName} (ID: {itemToSpawn.TypeID})");
ItemUtilities.SendToPlayer(instance);
Debug.Log($"[TradingCardMod] Spawned: {instance.DisplayName} (ID: {instance.TypeID})");
}
else
{
Debug.LogError($"[TradingCardMod] Failed to instantiate {prefab.DisplayName} (ID: {prefab.TypeID})");
}
}
catch (Exception ex)
{
@ -200,6 +343,12 @@ namespace TradingCardMod
string imagesDirectory = Path.Combine(setDirectory, "images");
var cards = CardParser.ParseFile(cardsFile, imagesDirectory);
// Initialize set tracking
if (!_cardsBySet.ContainsKey(setName))
{
_cardsBySet[setName] = new List<TradingCard>();
}
foreach (var card in cards)
{
// Validate card
@ -214,6 +363,7 @@ namespace TradingCardMod
}
_loadedCards.Add(card);
_cardsBySet[setName].Add(card);
// Register card as game item
RegisterCardWithGame(card);
@ -261,12 +411,14 @@ namespace TradingCardMod
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);
// Use public setters for properties that have them
item.Value = card.Value;
item.Quality = card.GetQuality();
// Set display quality based on rarity
SetDisplayQuality(item, card.GetQuality());
@ -280,6 +432,25 @@ namespace TradingCardMod
item.Tags.Add(luxuryTag);
}
// ============================================================
// TODO: REMOVE BEFORE RELEASE - TEST TAGS FOR LOOT SPAWNING
// These tags make cards appear frequently in loot for testing.
// Replace with appropriate tags (Collection, Misc, etc.) or
// implement proper loot table integration before shipping.
// ============================================================
Tag? medicTag = TagHelper.GetTargetTag("Medic");
if (medicTag != null)
{
item.Tags.Add(medicTag);
}
Tag? toolTag = TagHelper.GetTargetTag("Tool");
if (toolTag != null)
{
item.Tags.Add(toolTag);
}
// ============================================================
// Add our custom TradingCard tag
if (_tradingCardTag != null)
{
@ -305,6 +476,7 @@ namespace TradingCardMod
if (ItemAssetsCollection.AddDynamicEntry(item))
{
_registeredItems.Add(item);
_cardNameToTypeId[card.CardName] = typeId;
Debug.Log($"[TradingCardMod] Registered: {card.CardName} (ID: {typeId})");
}
else
@ -440,10 +612,17 @@ namespace TradingCardMod
// Clean up storage items
StorageHelper.Cleanup();
// Clean up packs
PackHelper.Cleanup();
// Clean up tags
TagHelper.Cleanup();
_loadedCards.Clear();
_cardsBySet.Clear();
_cardNameToTypeId.Clear();
_registeredPacks.Clear();
_packDefinitions.Clear();
_allSpawnableItems.Clear();
Debug.Log("[TradingCardMod] Cleanup complete.");

168
src/PackHelper.cs Normal file
View File

@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using ItemStatsSystem;
using SodaCraft.Localizations;
using Duckov.Utilities;
namespace TradingCardMod
{
/// <summary>
/// Helper class for creating card pack items.
/// </summary>
public static class PackHelper
{
// Base item to clone for packs (same as cards)
private const int BASE_ITEM_ID = 135;
// Track created packs for cleanup
private static readonly List<Item> _createdPacks = new List<Item>();
private static readonly List<GameObject> _createdGameObjects = new List<GameObject>();
/// <summary>
/// Creates a card pack item with gacha functionality.
/// </summary>
public static Item? CreatePackItem(
CardPack pack,
Sprite? icon = null)
{
try
{
// Get base item to clone
Item original = ItemAssetsCollection.GetPrefab(BASE_ITEM_ID);
if (original == null)
{
Debug.LogError($"[TradingCardMod] Base item ID {BASE_ITEM_ID} not found for pack!");
return null;
}
// Clone the item
GameObject clone = UnityEngine.Object.Instantiate(original.gameObject);
clone.name = $"CardPack_{pack.SetName}_{pack.PackName}";
UnityEngine.Object.DontDestroyOnLoad(clone);
_createdGameObjects.Add(clone);
Item item = clone.GetComponent<Item>();
if (item == null)
{
Debug.LogError("[TradingCardMod] Cloned pack object has no Item component!");
return null;
}
// Set item properties
int typeId = pack.GenerateTypeID();
string locKey = $"TC_Pack_{pack.SetName}_{pack.PackName}".Replace(" ", "_");
item.SetPrivateField("typeID", typeId);
item.SetPrivateField("weight", pack.Weight);
item.SetPrivateField("displayName", locKey);
item.SetPrivateField("order", 0);
item.SetPrivateField("maxStackCount", 10); // Packs can stack
// Use public setters
item.Value = pack.Value;
item.Quality = 3; // Uncommon quality for packs
item.DisplayQuality = (DisplayQuality)3;
// Set tags
item.Tags.Clear();
// Add Misc tag for loot spawning
Tag? miscTag = TagHelper.GetTargetTag("Misc");
if (miscTag != null)
{
item.Tags.Add(miscTag);
}
// Set icon if provided
if (icon != null)
{
item.SetPrivateField("icon", icon);
}
// Set up UsageUtilities for the "Use" context menu
// First, get or create UsageUtilities component
UsageUtilities usageUtils = clone.GetComponent<UsageUtilities>();
if (usageUtils == null)
{
usageUtils = clone.AddComponent<UsageUtilities>();
}
// Clear any existing behaviors from the cloned base item
usageUtils.behaviors.Clear();
// Add our custom usage behavior for gacha
var usageBehavior = clone.AddComponent<PackUsageBehavior>();
usageBehavior.SetName = pack.SetName;
usageBehavior.PackName = pack.PackName;
// Note: Slots, AvailableCards, and CardTypeIds are looked up at runtime via ModBehaviour
// Register our behavior with UsageUtilities
usageUtils.behaviors.Add(usageBehavior);
// Set the Item's usageUtilities field to enable the Use option
item.SetPrivateField("usageUtilities", usageUtils);
// Set localization
LocalizationManager.SetOverrideText(locKey, pack.PackName);
string slotDesc = pack.Slots.Count == 1 ? "1 card" : $"{pack.Slots.Count} cards";
LocalizationManager.SetOverrideText($"{locKey}_Desc",
$"Open to receive {slotDesc} from {pack.SetName}");
// Register with game
if (ItemAssetsCollection.AddDynamicEntry(item))
{
_createdPacks.Add(item);
Debug.Log($"[TradingCardMod] Registered pack: {pack.PackName} (ID: {typeId}, Slots: {pack.Slots.Count})");
return item;
}
else
{
Debug.LogError($"[TradingCardMod] Failed to register pack {pack.PackName}!");
UnityEngine.Object.Destroy(clone);
_createdGameObjects.Remove(clone);
return null;
}
}
catch (Exception ex)
{
Debug.LogError($"[TradingCardMod] Error creating pack {pack.PackName}: {ex.Message}");
return null;
}
}
/// <summary>
/// Gets all pack items created by this helper.
/// </summary>
public static IReadOnlyList<Item> GetCreatedPacks()
{
return _createdPacks.AsReadOnly();
}
/// <summary>
/// Cleans up all packs created by this helper.
/// </summary>
public static void Cleanup()
{
foreach (var item in _createdPacks)
{
if (item != null)
{
ItemAssetsCollection.RemoveDynamicEntry(item);
}
}
_createdPacks.Clear();
foreach (var go in _createdGameObjects)
{
if (go != null)
{
UnityEngine.Object.Destroy(go);
}
}
_createdGameObjects.Clear();
Debug.Log("[TradingCardMod] PackHelper cleaned up.");
}
}
}

233
src/PackParser.cs Normal file
View File

@ -0,0 +1,233 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace TradingCardMod
{
/// <summary>
/// Parses pack definition files (packs.txt) into CardPack objects.
/// </summary>
public static class PackParser
{
/// <summary>
/// Parses a packs.txt file into a list of CardPack objects.
/// </summary>
/// <param name="filePath">Path to the packs.txt file.</param>
/// <param name="setName">The card set name these packs belong to.</param>
/// <param name="imagesDirectory">Directory containing pack images.</param>
/// <returns>List of parsed CardPack objects.</returns>
public static List<CardPack> ParseFile(string filePath, string setName, string imagesDirectory)
{
var packs = new List<CardPack>();
if (!File.Exists(filePath))
{
return packs;
}
string[] lines = File.ReadAllLines(filePath);
CardPack? currentPack = null;
for (int i = 0; i < lines.Length; i++)
{
string line = lines[i];
string trimmedLine = line.Trim();
// Skip empty lines and comments
if (string.IsNullOrWhiteSpace(trimmedLine) || trimmedLine.StartsWith("#"))
{
continue;
}
// Check if this is a slot definition (indented line)
bool isIndented = line.StartsWith(" ") || line.StartsWith("\t");
if (isIndented && currentPack != null)
{
// Parse slot definition
PackSlot? slot = ParseSlotLine(trimmedLine);
if (slot != null)
{
currentPack.Slots.Add(slot);
}
}
else if (!isIndented)
{
// Parse pack header line
CardPack? pack = ParsePackHeader(trimmedLine, setName, imagesDirectory);
if (pack != null)
{
if (currentPack != null)
{
packs.Add(currentPack);
}
currentPack = pack;
}
}
}
// Don't forget the last pack
if (currentPack != null)
{
packs.Add(currentPack);
}
return packs;
}
/// <summary>
/// Parses a pack header line.
/// Format: PackName | Image | Value
/// </summary>
private static CardPack? ParsePackHeader(string line, string setName, string imagesDirectory)
{
string[] parts = line.Split('|');
if (parts.Length < 3)
{
return null;
}
string packName = parts[0].Trim();
string imageFile = parts[1].Trim();
if (!int.TryParse(parts[2].Trim(), out int value))
{
value = 100;
}
float weight = 0.1f;
if (parts.Length >= 4 && float.TryParse(parts[3].Trim(), out float parsedWeight))
{
weight = parsedWeight;
}
return new CardPack
{
PackName = packName,
SetName = setName,
ImageFile = imageFile,
ImagePath = Path.Combine(imagesDirectory, imageFile),
Value = value,
Weight = weight,
IsDefault = false
};
}
/// <summary>
/// Parses a slot definition line.
/// Format: RARITY: Common:100, Uncommon:50, Rare:10
/// or: CARDS: CardName:100, CardName:50
/// </summary>
private static PackSlot? ParseSlotLine(string line)
{
var slot = new PackSlot();
if (line.StartsWith("RARITY:", StringComparison.OrdinalIgnoreCase))
{
slot.UseRarityWeights = true;
string weightsStr = line.Substring(7).Trim();
slot.RarityWeights = ParseWeights(weightsStr);
}
else if (line.StartsWith("CARDS:", StringComparison.OrdinalIgnoreCase))
{
slot.UseRarityWeights = false;
string weightsStr = line.Substring(6).Trim();
slot.CardWeights = ParseWeights(weightsStr);
}
else
{
// Default to rarity weights if no prefix
slot.UseRarityWeights = true;
slot.RarityWeights = ParseWeights(line);
}
return slot;
}
/// <summary>
/// Parses a comma-separated list of Name:Weight pairs.
/// </summary>
private static Dictionary<string, float> ParseWeights(string weightsStr)
{
var weights = new Dictionary<string, float>();
string[] pairs = weightsStr.Split(',');
foreach (string pair in pairs)
{
string[] keyValue = pair.Split(':');
if (keyValue.Length == 2)
{
string key = keyValue[0].Trim();
if (float.TryParse(keyValue[1].Trim(), out float weight))
{
weights[key] = weight;
}
}
}
return weights;
}
/// <summary>
/// Creates a default pack for a card set.
/// </summary>
/// <param name="setName">The card set name.</param>
/// <param name="imagesDirectory">Directory for pack images.</param>
/// <returns>A default CardPack with standard slot weights.</returns>
public static CardPack CreateDefaultPack(string setName, string imagesDirectory)
{
string packName = $"{setName} Pack";
string imageFile = "pack.png";
return new CardPack
{
PackName = packName,
SetName = setName,
ImageFile = imageFile,
ImagePath = Path.Combine(imagesDirectory, imageFile),
Value = 100,
Weight = 0.1f,
IsDefault = true,
Slots = DefaultPackSlots.GetDefaultSlots()
};
}
/// <summary>
/// Validates a CardPack and returns any errors found.
/// </summary>
public static List<string> ValidatePack(CardPack pack)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(pack.PackName))
{
errors.Add("Pack name is required");
}
if (pack.Slots.Count == 0)
{
errors.Add("Pack must have at least one slot");
}
foreach (var slot in pack.Slots)
{
if (slot.UseRarityWeights && slot.RarityWeights.Count == 0)
{
errors.Add("Rarity slot must have at least one weight defined");
}
else if (!slot.UseRarityWeights && slot.CardWeights.Count == 0)
{
errors.Add("Card slot must have at least one card defined");
}
}
if (pack.Value < 0)
{
errors.Add("Pack value must be non-negative");
}
return errors;
}
}
}

252
src/PackUsageBehavior.cs Normal file
View File

@ -0,0 +1,252 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using ItemStatsSystem;
using UnityEngine;
namespace TradingCardMod
{
/// <summary>
/// Custom usage behavior for card packs that generates multiple cards based on slot weights.
/// </summary>
public class PackUsageBehavior : UsageBehavior
{
/// <summary>
/// The card set this pack draws from.
/// </summary>
public string SetName = string.Empty;
/// <summary>
/// The pack name within the set (for looking up slot config).
/// </summary>
public string PackName = string.Empty;
private bool _running;
/// <summary>
/// Gets available cards for this set from ModBehaviour.
/// </summary>
private List<TradingCard> GetAvailableCards()
{
if (ModBehaviour.Instance == null) return new List<TradingCard>();
return ModBehaviour.Instance.GetCardsBySet(SetName);
}
/// <summary>
/// Gets card type IDs from ModBehaviour.
/// </summary>
private Dictionary<string, int> GetCardTypeIds()
{
if (ModBehaviour.Instance == null) return new Dictionary<string, int>();
return ModBehaviour.Instance.GetCardTypeIds();
}
/// <summary>
/// Gets slot configuration from ModBehaviour.
/// </summary>
private List<PackSlot> GetSlots()
{
if (ModBehaviour.Instance == null) return new List<PackSlot>();
return ModBehaviour.Instance.GetPackSlots(SetName, PackName);
}
public override DisplaySettingsData DisplaySettings => new DisplaySettingsData
{
display = true,
description = $"Open to receive {GetSlots().Count} trading cards"
};
public override bool CanBeUsed(Item item, object user)
{
if (!(user is CharacterMainControl))
{
return false;
}
return true;
}
protected override void OnUse(Item item, object user)
{
CharacterMainControl character = user as CharacterMainControl;
var slots = GetSlots();
if (character != null && slots.Count > 0)
{
GenerateCards(item, character, slots).Forget();
}
else
{
Debug.LogWarning($"[TradingCardMod] Cannot open pack: character={character != null}, slots={slots.Count}");
}
}
private async UniTask GenerateCards(Item packItem, CharacterMainControl character, List<PackSlot> slots)
{
if (_running)
{
return;
}
_running = true;
var generatedCards = new List<string>();
try
{
foreach (var slot in slots)
{
int? cardTypeId = RollSlot(slot);
if (cardTypeId.HasValue)
{
Item card = await ItemAssetsCollection.InstantiateAsync(cardTypeId.Value);
if (card != null)
{
string cardName = card.DisplayName;
generatedCards.Add(cardName);
bool pickedUp = character.PickupItem(card);
if (!pickedUp && card != null)
{
if (card.ActiveAgent != null)
{
card.AgentUtilities.ReleaseActiveAgent();
}
PlayerStorage.Push(card);
}
}
}
}
// Show notification
if (generatedCards.Count > 0)
{
string message = $"Received: {string.Join(", ", generatedCards)}";
Debug.Log($"[TradingCardMod] Pack opened: {message}");
// NotificationText.Push(message); // Uncomment if NotificationText is accessible
}
// Consume the pack after successfully generating cards
ConsumeItem(packItem);
}
catch (Exception ex)
{
Debug.LogError($"[TradingCardMod] Error generating cards from pack: {ex.Message}");
}
_running = false;
}
/// <summary>
/// Consumes one pack from the stack or detaches if only one left.
/// </summary>
private void ConsumeItem(Item item)
{
if (item == null) return;
if (item.StackCount > 1)
{
item.StackCount -= 1;
}
else
{
item.Detach();
}
}
/// <summary>
/// Rolls a single slot to determine which card to give.
/// </summary>
private int? RollSlot(PackSlot slot)
{
if (slot.UseRarityWeights)
{
return RollByRarity(slot.RarityWeights);
}
else
{
return RollByCardName(slot.CardWeights);
}
}
/// <summary>
/// Selects a card based on rarity weights.
/// </summary>
private int? RollByRarity(Dictionary<string, float> rarityWeights)
{
// Build weighted list of cards based on rarity
var weightedCards = new List<(int typeId, float weight)>();
var availableCards = GetAvailableCards();
var cardTypeIds = GetCardTypeIds();
foreach (var card in availableCards)
{
if (cardTypeIds.TryGetValue(card.CardName, out int typeId))
{
// Get the weight for this card's rarity
if (rarityWeights.TryGetValue(card.Rarity, out float weight) && weight > 0)
{
weightedCards.Add((typeId, weight));
}
}
}
if (weightedCards.Count == 0)
{
Debug.LogWarning($"[TradingCardMod] No cards available for rarity roll (set: {SetName}, cards: {availableCards.Count})");
return null;
}
return WeightedRandom(weightedCards);
}
/// <summary>
/// Selects a card based on specific card name weights.
/// </summary>
private int? RollByCardName(Dictionary<string, float> cardWeights)
{
var weightedCards = new List<(int typeId, float weight)>();
var cardTypeIds = GetCardTypeIds();
foreach (var kvp in cardWeights)
{
if (cardTypeIds.TryGetValue(kvp.Key, out int typeId))
{
weightedCards.Add((typeId, kvp.Value));
}
else
{
Debug.LogWarning($"[TradingCardMod] Card '{kvp.Key}' not found for pack slot");
}
}
if (weightedCards.Count == 0)
{
Debug.LogWarning("[TradingCardMod] No cards available for card name roll");
return null;
}
return WeightedRandom(weightedCards);
}
/// <summary>
/// Performs weighted random selection.
/// </summary>
private int WeightedRandom(List<(int typeId, float weight)> items)
{
float totalWeight = items.Sum(x => x.weight);
float roll = UnityEngine.Random.Range(0f, totalWeight);
float cumulative = 0f;
foreach (var item in items)
{
cumulative += item.weight;
if (roll <= cumulative)
{
return item.typeId;
}
}
// Fallback to last item
return items[items.Count - 1].typeId;
}
}
}

View File

@ -74,4 +74,7 @@ namespace TradingCardMod
}
}
}
// Search injection patch disabled - causing index errors
// TODO: Find better approach for loot table integration
}