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:
parent
0d9ab65422
commit
91ee0333db
BIN
CardSets/ExampleSet/images/pack.png
Normal file
BIN
CardSets/ExampleSet/images/pack.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@ -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
155
src/CardPack.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
{
|
||||
// Use game's utility to give item to player
|
||||
ItemUtilities.SendToPlayer(itemToSpawn);
|
||||
Debug.Log($"[TradingCardMod] Spawned: {itemToSpawn.DisplayName} (ID: {itemToSpawn.TypeID})");
|
||||
// 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(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
168
src/PackHelper.cs
Normal 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
233
src/PackParser.cs
Normal 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
252
src/PackUsageBehavior.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -74,4 +74,7 @@ namespace TradingCardMod
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search injection patch disabled - causing index errors
|
||||
// TODO: Find better approach for loot table integration
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user