commit 370cc5bbbd3ccf9b085b1d2eecd0e62ac7689914 Author: iisaev Date: Sun Oct 18 12:41:40 2026 +0300 init mod diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..9149192 --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,2 @@ +mod_items_base_name = Items Base +mod_items_base_desc = Item definitions and factories, inventory and recipe state, inventory UI and their save state. diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..e7b6822 --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,2 @@ +mod_items_base_name = Предметы и инвентарь +mod_items_base_desc = Определения предметов и фабрики, состояние инвентаря и рецептов, интерфейс инвентаря и их сохранение. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..3bcb875 --- /dev/null +++ b/manifest.json @@ -0,0 +1,15 @@ +{ + "id": "items_base", + "name": "Items Base", + "version": "1.0.0", + "author": "ct", + "description": "Item definitions and factories, inventory and recipe state, inventory UI and their save state", + "category": "gameplay", + "sources": [ + "src/**/*.cs" + ], + "depends": ["base2d>=1", "interface>=1"], + "test_suites": [ + {"id": "inventory", "activate": ["--itest=inventory"]} + ] +} diff --git a/sounds/items/target.ogg b/sounds/items/target.ogg new file mode 100644 index 0000000..49d8a86 Binary files /dev/null and b/sounds/items/target.ogg differ diff --git a/sounds/items/use.ogg b/sounds/items/use.ogg new file mode 100644 index 0000000..acf4ac1 Binary files /dev/null and b/sounds/items/use.ogg differ diff --git a/src/Ingredient.cs b/src/Ingredient.cs new file mode 100644 index 0000000..9da9f2c --- /dev/null +++ b/src/Ingredient.cs @@ -0,0 +1,17 @@ + + +namespace Cthangover.Core.Items +{ + /// + /// Plain POCO implementing IIngredient. Used by + /// RecipeFactory to materialise ingredient entries from + /// IngredientInfo DTOs after resolving the item reference + /// through ItemFactory. + /// + public class Ingredient : IIngredient{ + /// The resolved item this formula entry references. + public IItem Item { get; set; } + /// How many copies the recipe consumes or produces. + public int Count { get; set; } + } +} diff --git a/src/IngredientInfo.cs b/src/IngredientInfo.cs new file mode 100644 index 0000000..4912c47 --- /dev/null +++ b/src/IngredientInfo.cs @@ -0,0 +1,25 @@ +using System; +using System.Text.Json.Serialization; +using Cthangover.Core.Factories; + +namespace Cthangover.Core.Items +{ + /// + /// JSON deserialization DTO for a recipe ingredient entry. Carries + /// only the item ID string and Count — not the resolved + /// IItem — because recipe JSON files are loaded before item + /// definitions may be fully populated across mods. The actual item + /// reference is wired up later by RecipeFactory.CreateIngredient, + /// which looks up the ID through ItemFactory and validates + /// that the referenced item exists. + /// + [Serializable] + public class IngredientInfo : IIdentifiable + { + /// Item ID this entry references; serialized as "Id" in JSON. + [JsonPropertyName("Id")] + public string ID { get; set; } + /// Quantity consumed or produced by the recipe. + public int Count { get; set; } + } +} diff --git a/src/Inventory.cs b/src/Inventory.cs new file mode 100644 index 0000000..9fbc273 --- /dev/null +++ b/src/Inventory.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Cthangover.Core.Events; +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Items +{ + /// + /// Concrete player inventory backed by an ordered List<IItemContainer>. + /// Uses LINQ scans (FirstOrDefault, Any) rather than a + /// Dictionary because inventory sizes in this game are small + /// enough that O(n) lookups are imperceptible, and list ordering + /// directly drives UI slot layout — the first container appears in + /// the first grid cell. + /// + /// On Remove, when a stack count reaches zero the entire + /// container is deleted from the list rather than kept as an empty + /// slot, preventing the UI from rendering gaps. The constructor seeds + /// wolf meat and branches for testing convenience — in production, + /// save data would override this initial state. + /// + /// The Change event fires with (itemId, delta) so + /// subscribers receive full context. Additionally, each mutation + /// publishes an InventoryChangedEvent to the global + /// ModEventBus. + /// + public class Inventory : IInventory + { + /// Ordered slot list; order drives UI layout and empty slots are removed on zero stacks. + public List Items { get; set; } = new(); + /// Fires on every successful add/remove with (itemId, signed delta); also mirrored as on the ModEventBus. + public event Action Change; + + /// + /// Creates an inventory pre-seeded with the starter kit (10 wolf meat, + /// 10 branches) so a new game always has basic supplies. + /// + public Inventory() + { + AddStarterItem("food/wolf_meat", 10); + AddStarterItem("resource/branches", 10); + } + + /// + /// Seeds one starter stack, skipping the entry when the item definition + /// cannot be resolved. This can legitimately happen when the inventory is + /// constructed while the launcher has not finished loading mods; adding a + /// container with a null item would otherwise crash every later + /// call. + /// + private void AddStarterItem(string id, int count) + { + var item = ItemFactory.Instance.Get(id); + if (item == null) + { + GameLogger.Log("INVENTORY", $"starter item '{id}' not found, skipping seed", LogLevel.Warning); + return; + } + Items.Add(new ItemContainer { Item = item, Count = count }); + } + + /// True when any slot holds the given item (null-safe). + public bool HasItem(IItem item) + { + if (item == null) + return false; + return HasItem(item.ID); + } + + /// True when any slot holds the item with the given id; null/empty ids return false. + public bool HasItem(string id) + { + if (string.IsNullOrWhiteSpace(id)) + return false; + return Items.Any(o => o.Item.ID == id); + } + + /// Returns the slot holding the given item, or null (null-safe). + public IItemContainer TryGet(IItem item) + { + if (item == null) + return null; + return TryGet(item.ID); + } + + /// Returns the slot holding the given id, or null for null/empty ids. + public IItemContainer TryGet(string id) + { + if (string.IsNullOrWhiteSpace(id)) + return null; + return Items.FirstOrDefault(o => o.Item != null && o.Item.ID == id); + } + + /// Total count across slots for the given item (0 when absent or null). + public int CheckCount(IItem item) + { + if (item == null) + return 0; + return CheckCount(item.ID); + } + + /// Total count across slots for the given id (0 when absent). + public int CheckCount(string id) + { + var container = TryGet(id); + return container?.Count ?? 0; + } + + /// Adds the item (delegates to the id overload); null items are ignored. + public void Add(IItem item, int count = 1) + { + if (item == null) + return; + Add(item.ID, count); + } + + /// + /// Stacks onto an existing slot or creates a new one by resolving the id + /// through ; unknown ids are + /// silently ignored. Fires and publishes + /// on success. + /// + public void Add(string id, int count = 1) + { + var container = TryGet(id); + if (container != null) + { + container.Count += count; + Change?.Invoke(id, count); + PublishInventoryChanged(id, count, container.Count); + return; + } + var item = ItemFactory.Instance.Get(id); + if (item != null) + { + container = new ItemContainer + { + Item = item, + Count = count, + }; + Items.Add(container); + Change?.Invoke(id, count); + PublishInventoryChanged(id, count, count); + } + } + + /// Removes the item (delegates to the id overload); null items return false. + public bool Remove(IItem item, int count = 1) + { + if (item == null) + return false; + return Remove(item.ID, count); + } + + /// + /// Removes items from the slot, deleting the + /// container when the stack reaches zero. Returns false without + /// changes when the slot holds fewer items than requested. + /// + public bool Remove(string id, int count = 1) + { + var container = TryGet(id); + if (container == null) + return false; + + var delta = container.Count - count; + if (delta < 0) + return false; + + if (delta == 0) + Items.Remove(container); + else + container.Count -= count; + + var newCount = delta == 0 ? 0 : delta; + Change?.Invoke(id, -count); + PublishInventoryChanged(id, -count, newCount); + return true; + } + + private static void PublishInventoryChanged(string itemId, int delta, int newCount) + { + ModEventBus.Publish(new InventoryChangedEvent + { + ItemId = itemId, + Delta = delta, + NewCount = newCount, + }); + } + } +} diff --git a/src/Item.cs b/src/Item.cs new file mode 100644 index 0000000..fa3d65a --- /dev/null +++ b/src/Item.cs @@ -0,0 +1,33 @@ +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Core.Items +{ + /// + /// Concrete item instance produced by + /// from an DTO. The factory builds a fresh instance per + /// Get call (resolving the sprite and the on-use action by id), so + /// callers may mutate their copy freely — inventories share the definition + /// fields but each slot only tracks its own count. + /// + public class Item : IItem + { + /// Unique item ID (path-style, e.g. "food/ration"); the factory lookup key. + public string ID { get; set; } + /// Display name (resolved locale string). + public string Name { get; set; } + /// Tooltip/description text (resolved locale string). + public string Description { get; set; } + /// Base purchase price in the game economy. + public int Cost { get; set; } + /// Icon texture resolved from the mod's items group via . + public Texture2D Sprite { get; set; } + /// flags describing the item's categories. + public ItemType ItemType { get; set; } + /// On-use behaviour resolved via ; null means no use effect. + public IItemAction ItemAction { get; set; } + /// String-keyed extension bag imported from extra JSON keys, for mod-defined item data. + public PropertyData Properties { get; set; } = new(); + } + +} diff --git a/src/ItemActionConsumer.cs b/src/ItemActionConsumer.cs new file mode 100644 index 0000000..bbef3d4 --- /dev/null +++ b/src/ItemActionConsumer.cs @@ -0,0 +1,26 @@ +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Items; +using Cthangover.Core.Mods.Consumers; + +namespace Cthangover.Mods.Items +{ + /// + /// Feeds with every mod's C# + /// implementations as the mods load (for example + /// cooking's recipe actions) and removes them when their mod unloads, so a + /// hot-reloaded mod's actions run its fresh code and the dying assembly is + /// not pinned. GDScript-declared actions do not come from an assembly and + /// still arrive through . + /// + public class ItemActionConsumer : ModTypeConsumer + { + /// + public override string Id => "items.actions"; + + /// + protected override void Add(IItemAction instance, string modId) => ItemActionFactory.Instance.Register(instance); + + /// + protected override void Remove(IItemAction instance, string modId) => ItemActionFactory.Instance.Unregister(instance); + } +} diff --git a/src/ItemActionFactory.cs b/src/ItemActionFactory.cs new file mode 100644 index 0000000..f3e50d2 --- /dev/null +++ b/src/ItemActionFactory.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using Cthangover.Core.Items; + +namespace Cthangover.Core.Factories.Impls +{ + /// + /// Registry of item action behaviours, indexed by ID. It discovers + /// nothing itself: C# actions of every mod arrive through + /// ItemActionConsumer as the kernel registers their assembly and + /// leave with their mod, and GDScript-declared actions arrive through + /// ItemActionBridge. A mod therefore introduces a new item action + /// simply by containing a public class that implements the interface. + /// + /// The "null" ID string is treated as a no-op action (the + /// fallback for items that don't have an on-use effect), avoiding + /// the need for a sentinel NullItemAction class. + /// + /// The single instance lives in this mod assembly as a static lazy + /// singleton; the static facade stays as the public API. + /// + public class ItemActionFactory + { + private static ItemActionFactory _instance; + + /// Static lazy singleton; kept as the public API. + public static ItemActionFactory Instance => _instance ??= new ItemActionFactory(); + + private readonly IDictionary data = new Dictionary(); + + internal ItemActionFactory() + { + } + + /// Registers a single action under its ; the first action of an id wins, null/empty ids are ignored. + public void Register(IItemAction action) + { + if (action != null && !string.IsNullOrEmpty(action.ID) && !data.ContainsKey(action.ID)) + data[action.ID] = action; + } + + /// + /// Removes exactly (its mod is being unloaded); + /// an id owned by another action instance is left alone. + /// + public void Unregister(IItemAction action) + { + if (action != null && !string.IsNullOrEmpty(action.ID) && data.TryGetValue(action.ID, out var registered) && registered == action) + data.Remove(action.ID); + } + + /// + /// Resolves the behaviour registered under . + /// The special id "null" (or an empty value) is an explicit + /// no-action marker and returns null, as does an unknown id. + /// + public IItemAction Get(string id) + { + if (string.IsNullOrWhiteSpace(id) || id == "null") + return null; + return data[id]; + } + } +} diff --git a/src/ItemContainer.cs b/src/ItemContainer.cs new file mode 100644 index 0000000..4ef2fcf --- /dev/null +++ b/src/ItemContainer.cs @@ -0,0 +1,19 @@ +namespace Cthangover.Core.Items +{ + /// + /// Concrete inventory slot holding a shared IItem reference + /// and a mutable stack count. The item reference is shared across + /// every container that holds the same item type — only the count + /// is slot-specific — which keeps memory footprint low when + /// multiple stacks of the same item exist (e.g. 3 stacks of arrows + /// in different inventory rows). + /// + public class ItemContainer : IItemContainer + { + /// The shared item definition held by this slot (one instance per item type, not per stack). + public IItem Item { get; set; } + /// Stack size; deletes the container when it reaches zero. + public int Count { get; set; } + } + +} diff --git a/src/ItemFactory.cs b/src/ItemFactory.cs new file mode 100644 index 0000000..4fe0c60 --- /dev/null +++ b/src/ItemFactory.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using Cthangover.Core.Items; +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Factories.Impls +{ + /// + /// The single instance lives in this mod assembly as a static lazy + /// singleton; the static facade stays as the public API. + /// + public class ItemFactory + { + private static ItemFactory _instance; + + /// Static lazy singleton; kept as the public API. + public static ItemFactory Instance => _instance ??= new ItemFactory(); + + private Dictionary items; + + /// + /// Builds a fresh from the collected , + /// resolving the sprite through , the on-use + /// action through , and importing extra JSON + /// keys into . Unknown or empty ids return + /// null. + /// + public IItem Get(string id) + { + if (string.IsNullOrEmpty(id)) + return null; + + var infos = items ?? Entities.Collect(); + + // The registry refuses mod-data access until Boot.IsReady, so + // this snapshot can never be collected from a half-built mod set; + // the cached field is reset when ModManager.Reload rebuilds the + // registry. + if (ModManager.Instance.IsInitialized) + items ??= infos; + + if (!infos.TryGetValue(id, out var info)) + { + GameLogger.Log("FACTORY", $"item '{id}' not found", LogLevel.Error); + return null; + } + + var properties = new PropertyData(); + PropertyData.ImportJsonProperties(properties, info.ExtraProperties); + + return new Item + { + ID = info.ID, + Name = info.Name, + Description = info.Description, + Cost = info.Cost, + Sprite = ItemSpriteFactory.Instance.Get(info.Sprite), + ItemType = info.ItemType, + ItemAction = ItemActionFactory.Instance.Get(info.ItemAction), + Properties = properties, + }; + } + } +} diff --git a/src/ItemInfo.cs b/src/ItemInfo.cs new file mode 100644 index 0000000..0c5616f --- /dev/null +++ b/src/ItemInfo.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Cthangover.Core.Factories; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Items +{ + /// + /// JSON-serializable item descriptor collected by the entity resolver + /// ([EntityKind("item")] — files declare "Kind": "item"). + /// Typed fields cover the stock item schema; any additional JSON keys are + /// captured by and imported by + /// into , + /// so mods can attach custom data to items without schema changes. + /// + [Serializable] + [EntityKind("item")] + public class ItemInfo : IIdentifiable + { + /// Unique item ID, serialized as "Id" in JSON; path-style ids (e.g. "food/ration") double as save-data references. + [JsonPropertyName("Id")] + public string ID { get; set; } + /// Locale key for the item's display name, resolved from .properties files. + public string Name { get; set; } + /// Locale key for the item's tooltip/description text. + public string Description { get; set; } + /// Base purchase price in the game economy. + public int Cost { get; set; } + /// Sprite path inside the mod's items texture group, resolved by . + public string Sprite { get; set; } + /// Category flags parsed from a comma-separated string (e.g. "Resource,Food") via the flags enum converter. + [JsonConverter(typeof(FlagsStringEnumConverter))] + public ItemType ItemType { get; set; } + /// ID of the on-use behaviour, resolved through ; "null"/empty means no use effect. + public string ItemAction { get; set; } + /// Captures every JSON key not matched by the typed fields; imported into at factory time. + [JsonExtensionData] + public Dictionary ExtraProperties { get; set; } + } +} diff --git a/src/ItemsModInitializer.cs b/src/ItemsModInitializer.cs new file mode 100644 index 0000000..c57d077 --- /dev/null +++ b/src/ItemsModInitializer.cs @@ -0,0 +1,40 @@ +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Items; +using Cthangover.Core.Mods; +using Cthangover.Core.Settings; + +namespace Cthangover.Mods.Items +{ + /// + /// Lifecycle entry point of the items mod. The core no longer owns the + /// inventory/recipe state or the item factories, so this initializer: + /// + /// installs the factory hooks, so the + /// core state bag lazily produces the mod-owned Inventory and + /// RecipeData on first access; and + /// binds to this mod's + /// ItemActionFactory, flushing every GDScript-declared action + /// buffered so far. + /// + /// C# item actions of every mod reach the factory through + /// , not through this initializer. + /// Persistence lives in , discovered + /// automatically by ModSaveableRegistry. + /// + public class ItemsModInitializer : IModInitializer + { + /// No per-mod work is needed on load; discovery is reflection-based. + public void OnModLoaded(string modId) + { + } + + /// Installs the RuntimeData factory hooks and binds the item-action bridge (both idempotent). + public void OnModResourcesReady() + { + RuntimeData.InventoryFactory ??= () => new Inventory(); + RuntimeData.RecipeDataFactory ??= () => new RecipeData(); + + ItemActionBridge.Bind(action => ItemActionFactory.Instance.Register(action)); + } + } +} diff --git a/src/ItemsSaveable.cs b/src/ItemsSaveable.cs new file mode 100644 index 0000000..ed60b7e --- /dev/null +++ b/src/ItemsSaveable.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Linq; +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Items; +using Cthangover.Core.Mods; +using Cthangover.Core.Settings; + +namespace Cthangover.Mods.Items +{ + /// + /// Persists the player inventory and unlocked recipes in the items mod's + /// own save slot. The core used to carry the "Inventory" and + /// "Recipes" blobs inside the __core__ slot; the blob + /// format (CItem id/count pairs, recipe id list) is unchanged — only the + /// owning slot moved. Backward compatibility with old saves' core blobs + /// is intentionally not provided (precedent: recruiting) — old saves + /// start with an empty inventory and no unlocked recipes. + /// + public class ItemsSaveable : IModSaveable + { + private const string InventoryBlob = "Inventory"; + private const string RecipesBlob = "Recipes"; + + /// Writes item stacks and unlocked recipe IDs into the mod slot. + public void OnSave(ModSlot slot) + { + var runtime = GameData.Instance?.Runtime; + if (runtime == null) + return; + + slot.Data[InventoryBlob] = new DataBlob(1, runtime.Inventory.Items.Select(o => + new CItem { ID = o.Item.ID, Count = o.Count }).ToList()); + slot.Data[RecipesBlob] = new DataBlob(1, runtime.RecipeData.Data.ToList()); + } + + /// Restores inventory and recipes; absent blobs leave the current state untouched. + public bool OnLoad(ModSlot slot, out string errorMessage) + { + errorMessage = null; + var runtime = GameData.Instance?.Runtime; + if (runtime == null || slot?.Data == null) + return true; + + if (slot.Data.TryGetValue(InventoryBlob, out var invBlob)) + { + var items = invBlob.ReadJson>(); + runtime.Inventory.Items = items?.Select(o => + (IItemContainer)new ItemContainer + { Item = ItemFactory.Instance.Get(o.ID), Count = o.Count }).ToList() + ?? new List(); + } + + if (slot.Data.TryGetValue(RecipesBlob, out var recipeBlob)) + runtime.RecipeData.Data = recipeBlob.ReadJson>()?.ToHashSet() + ?? new HashSet(); + + return true; + } + } +} diff --git a/src/Recipe.cs b/src/Recipe.cs new file mode 100644 index 0000000..c0bb501 --- /dev/null +++ b/src/Recipe.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace Cthangover.Core.Items +{ + /// + /// Concrete recipe fulfilling IRecipe. Populated by + /// RecipeFactory.CreateRecipe from a RecipeInfo DTO + /// after resolving every ingredient entry's item ID through + /// ItemFactory. Recipes are treated as immutable value + /// objects by the crafting UI — RecipeFactory.Get returns + /// the cached instance directly without copying. + /// + public class Recipe : IRecipe + { + /// Unique recipe ID (path-style, e.g. "recipe/wolf_meat_to_ration"). + public string ID { get; set; } + /// Locale key for the recipe display name. + public string Name { get; set; } + /// Locale key for the recipe description text. + public string Description { get; set; } + /// Crafting time in in-game minutes. + public int Time { get; set; } + /// Required crafting station (only exists). + public WorkbenchType WorkbenchType { get; set; } + /// Ingredients consumed on a successful craft; item references resolved by . + public List Input { get; set; } + /// Items produced on a successful craft. + public List Output { get; set; } + } +} diff --git a/src/RecipeData.cs b/src/RecipeData.cs new file mode 100644 index 0000000..d2dfdc2 --- /dev/null +++ b/src/RecipeData.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Linq; +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Items; + +namespace Cthangover.Core.Settings +{ + /// + /// Tracks which crafting recipes the player has unlocked. Internally + /// a of recipe IDs that provides O(1) + /// lookup. Recipes can be queried by + /// via , which resolves each ID through + /// and + /// filters the resulting objects. + /// + public class RecipeData : IRecipeData + { + /// Set of known recipe IDs (duplicate insertion is silently ignored). + public HashSet Data { get; set; } = new(); + + /// + /// Unlocks a recipe by ID. Returns false if the recipe + /// was already known (duplicate). + /// + public bool Add(string id) + { + return Data.Add(id); + } + + /// true if the recipe with the given ID is already unlocked. + public bool Has(string id) + { + return Data.Contains(id); + } + + /// + /// Returns all known recipes that belong to the specified + /// . Each ID is resolved via + /// + /// and filtered; IDs that resolve to null are silently + /// dropped. + /// + public List GetRecipesByType(WorkbenchType type) + { + return Data.Select(o => RecipeFactory.Instance.Get(o)) + .Where(o => o.WorkbenchType == type) + .ToList(); + } + } +} diff --git a/src/RecipeFactory.cs b/src/RecipeFactory.cs new file mode 100644 index 0000000..1c20145 --- /dev/null +++ b/src/RecipeFactory.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using System.Linq; +using Cthangover.Core.Items; +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Factories.Impls +{ + /// + /// Factory for crafting recipes. Uses BoundedCache because the + /// crafting UI may repeatedly query recipes during scrolling and the + /// ingredient resolution (through ItemFactory) is non-trivial. + /// Recipes are treated as immutable value objects — Get returns + /// the cached instance directly without copying. Missing ingredient + /// items emit an error and skip that ingredient rather than failing the + /// whole recipe, so a mod author's typo in one input doesn't silently + /// break the crafting UI for unrelated recipes. + /// + /// The single instance lives in this mod assembly as a static lazy + /// singleton; the static facade stays as the public API. + /// + public class RecipeFactory : ICacheLoader + { + private static RecipeFactory _instance; + + /// Static lazy singleton; kept as the public API. + public static RecipeFactory Instance => _instance ??= new RecipeFactory(); + + private Dictionary _allInfos; + private readonly BoundedCache _cache; + + internal RecipeFactory() + { + var size = ModConfig.Instance.Cache.GetCacheSize("recipes", 128); + _cache = new BoundedCache(size, this); + } + + IRecipe ICacheLoader.Load(string id) + { + var infos = EnsureInfos(); + if (infos.TryGetValue(id, out var info)) + return CreateRecipe(info); + return null; + } + + /// + /// Returns the recipe definitions, collecting them on first use. + /// Publication to _allInfos waits until the mod registry is + /// finalized so an early lookup from the launcher boot window cannot + /// freeze an empty snapshot for the session. + /// + private Dictionary EnsureInfos() + { + if (_allInfos != null) + return _allInfos; + + var infos = Entities.Collect(); + if (ModManager.Instance.IsInitialized) + _allInfos = infos; + return infos; + } + + /// + /// Returns the cached recipe for (resolved lazily + /// through ), or null for unknown + /// or empty ids. The shared instance is returned without copying — + /// recipes are treated as immutable. + /// + public IRecipe Get(string id) + { + if (string.IsNullOrEmpty(id)) + return null; + + EnsureInfos(); + return _cache.Get(id); + } + + private static Recipe CreateRecipe(RecipeInfo info) + { + return new Recipe + { + ID = info.ID, + Name = info.Name, + Description = info.Description, + Time = info.Time, + WorkbenchType = info.Type, + Input = info.Input + .Select(i => CreateIngredient(i)) + .Where(i => i != null) + .ToList(), + Output = info.Output + .Select(i => CreateIngredient(i)) + .Where(i => i != null) + .ToList() + }; + } + + private static IIngredient CreateIngredient(IngredientInfo info) + { + var item = ItemFactory.Instance.Get(info.ID); + if (item == null) + { + GameLogger.Log("MOD_TEST", $"RecipeFactory: item '{info.ID}' not found in ItemFactory", LogLevel.Error); + return null; + } + return new Ingredient { Item = item, Count = info.Count }; + } + } +} diff --git a/src/RecipeInfo.cs b/src/RecipeInfo.cs new file mode 100644 index 0000000..80c1772 --- /dev/null +++ b/src/RecipeInfo.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Cthangover.Core.Factories; + +namespace Cthangover.Core.Items +{ + /// + /// JSON DTO for recipe definitions in mod files. Uses a plain + /// JsonStringEnumConverter for WorkbenchType (simple + /// enum, no flags needed) rather than the + /// FlagsStringEnumConverter used by ItemInfo. + /// Input and Output are raw IngredientInfo + /// lists — the actual IItem references are resolved by + /// RecipeFactory at cache population time, which also + /// validates that every referenced item exists in ItemFactory. + /// + [Serializable] + [EntityKind("recipe")] + public class RecipeInfo : IIdentifiable + { + /// Unique recipe ID, serialized as "Id" in JSON. + [JsonPropertyName("Id")] + public string ID { get; set; } + /// Locale key for the recipe display name. + public string Name { get; set; } + /// Locale key for the recipe description text. + public string Description { get; set; } + /// Required crafting station, serialized as its enum name ("Cooking"). + [JsonConverter(typeof(JsonStringEnumConverter))] + public WorkbenchType Type { get; set; } + /// Crafting time in in-game minutes. + public int Time { get; set; } + /// Raw ingredient entries; item references are resolved and validated by . + public List Input { get; set; } + /// Raw output entries resolved at factory time. + public List Output { get; set; } + } +} diff --git a/src/SubsystemRegistration.cs b/src/SubsystemRegistration.cs new file mode 100644 index 0000000..40bad9f --- /dev/null +++ b/src/SubsystemRegistration.cs @@ -0,0 +1,14 @@ +namespace Cthangover.Mods.Items +{ + /// + /// Migration marker for the coverage pipeline + /// (SubsystemsBridge reflection scan): declares the items + /// subsystem fully moved out of the core assembly into this engine mod. + /// The id must match the DeclaredTests entry in + /// SubsystemsCoverageTestSetup. + /// + public static class SubsystemRegistration + { + public static string SubsystemId => "items"; + } +} diff --git a/src/ui/CharacterTargetPanel.cs b/src/ui/CharacterTargetPanel.cs new file mode 100644 index 0000000..6167e46 --- /dev/null +++ b/src/ui/CharacterTargetPanel.cs @@ -0,0 +1,183 @@ +using System; +using Cthangover.Core.Characters; +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Settings; +using Cthangover.Core.UI.Theming; +using Godot; + +namespace Cthangover.Core.UI.Inventory +{ + /// + /// Inline target picker for items, + /// embedded directly into the item passport instead of replacing the + /// whole right column. Each party member is a button row with a health + /// bar, current/max HP and the translated name; a cancel button closes + /// the picker. Extends because the previous + /// Control-based version left its children unmanaged — a plain Control + /// never laid them out, so title, rows and cancel button collapsed into + /// the top-left corner. + /// + public partial class CharacterTargetPanel : VBoxContainer + { + /// Raised when the player picks a target; the effect is applied by the inventory panel. + public event Action CharacterSelected; + /// Raised when the player aborts target selection. + public event Action Cancelled; + + /// Builds the picker inline: title, one row per party member, and a cancel button. + public CharacterTargetPanel() + { + SizeFlagsVertical = SizeFlags.ExpandFill; + MouseFilter = MouseFilterEnum.Stop; + AddThemeConstantOverride("separation", 7); + + var title = new Label(); + title.Text = InventoryItemUi.Translate("ui/inventory/select_target").ToUpperInvariant(); + title.AddThemeFontSizeOverride("font_size", 10); + title.AddThemeColorOverride("font_color", UiTokens.TypeTarget); + title.MouseFilter = MouseFilterEnum.Ignore; + AddChild(title); + + var characterData = GameData.Instance.Runtime.CharacterData; + foreach (var id in characterData.BattleSet) + { + if (!characterData.Characters.TryGetValue(id, out var info)) + continue; + AddChild(BuildTargetRow(info)); + } + + var cancel = new Button(); + cancel.Text = InventoryItemUi.Translate("ui/inventory/cancel"); + cancel.FocusMode = FocusModeEnum.None; + cancel.AddThemeFontSizeOverride("font_size", 13); + cancel.AddThemeStyleboxOverride("normal", MakeCancelStyle(false)); + cancel.AddThemeStyleboxOverride("hover", MakeCancelStyle(true)); + cancel.AddThemeStyleboxOverride("pressed", MakeCancelStyle(true)); + cancel.AddThemeStyleboxOverride("focus", new StyleBoxEmpty()); + cancel.AddThemeColorOverride("font_color", UiTokens.Body); + cancel.AddThemeColorOverride("font_hover_color", Colors.White); + cancel.Pressed += OnCancelPressed; + AddChild(cancel); + } + + private Button BuildTargetRow(CharacterInfoData info) + { + var template = CharacterTemplateRegistry.Get(info.ID); + var displayName = InventoryItemUi.Translate(template?.Name ?? info.ID); + var health = info.Attributes?.Health; + var value = health?.Value ?? 0; + var max = health?.BaseValue ?? 0; + + var button = new Button(); + button.FocusMode = FocusModeEnum.None; + button.CustomMinimumSize = new Vector2(0, 46); + button.SizeFlagsHorizontal = SizeFlags.ExpandFill; + button.AddThemeStyleboxOverride("normal", MakeRowStyle(false)); + button.AddThemeStyleboxOverride("hover", MakeRowStyle(true)); + button.AddThemeStyleboxOverride("pressed", MakeRowStyle(true)); + button.AddThemeStyleboxOverride("focus", new StyleBoxEmpty()); + + var row = new HBoxContainer(); + row.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + row.OffsetLeft = 12; + row.OffsetRight = -12; + row.AddThemeConstantOverride("separation", 10); + row.MouseFilter = MouseFilterEnum.Ignore; + button.AddChild(row); + + var name = new Label(); + name.Text = displayName; + name.AddThemeFontSizeOverride("font_size", 14); + name.AddThemeColorOverride("font_color", UiTokens.Ink); + name.TextOverrunBehavior = TextServer.OverrunBehavior.TrimEllipsis; + name.ClipText = true; + name.SizeFlagsHorizontal = SizeFlags.ExpandFill; + name.VerticalAlignment = VerticalAlignment.Center; + name.MouseFilter = MouseFilterEnum.Ignore; + row.AddChild(name); + + row.AddChild(BuildHealthBar(value, max)); + + var hp = new Label(); + hp.Text = value + "/" + max; + hp.AddThemeFontSizeOverride("font_size", 12); + hp.AddThemeColorOverride("font_color", UiTokens.Muted); + hp.VerticalAlignment = VerticalAlignment.Center; + hp.MouseFilter = MouseFilterEnum.Ignore; + row.AddChild(hp); + + var captured = info; + button.Pressed += () => CharacterSelected?.Invoke(captured); + return button; + } + + private void OnCancelPressed() + { + Cancelled?.Invoke(); + } + + private static ProgressBar BuildHealthBar(int value, int max) + { + var ratio = max > 0 ? (float)value / max : 1f; + + var bar = new ProgressBar(); + bar.CustomMinimumSize = new Vector2(96, 7); + bar.MaxValue = max > 0 ? max : 1; + bar.Value = value; + bar.ShowPercentage = false; + bar.MouseFilter = MouseFilterEnum.Ignore; + bar.AddThemeStyleboxOverride("background", MakeBarStyle(UiTokens.LineDisabled)); + bar.AddThemeStyleboxOverride("fill", MakeBarStyle(ratio < 0.4f + ? UiTokens.HpLow + : UiTokens.HpOk)); + return bar; + } + + private static StyleBoxFlat MakeRowStyle(bool hover) + { + var style = new StyleBoxFlat(); + style.BgColor = hover ? UiTokens.TargetHover : UiTokens.Surface2; + style.BorderColor = hover ? UiTokens.TargetLine : UiTokens.Line; + style.BorderWidthLeft = 1; + style.BorderWidthRight = 1; + style.BorderWidthTop = 1; + style.BorderWidthBottom = 1; + style.CornerRadiusTopLeft = 6; + style.CornerRadiusTopRight = 6; + style.CornerRadiusBottomLeft = 6; + style.CornerRadiusBottomRight = 6; + return style; + } + + private static StyleBoxFlat MakeBarStyle(Color color) + { + var style = new StyleBoxFlat(); + style.BgColor = color; + style.CornerRadiusTopLeft = 4; + style.CornerRadiusTopRight = 4; + style.CornerRadiusBottomLeft = 4; + style.CornerRadiusBottomRight = 4; + return style; + } + + private static StyleBoxFlat MakeCancelStyle(bool hover) + { + var style = new StyleBoxFlat(); + style.BgColor = hover ? UiTokens.BtnHover : UiTokens.Surface3; + style.BorderColor = hover ? UiTokens.Alpha(UiTokens.Accent, 0.55f) : UiTokens.Line; + style.BorderWidthLeft = 1; + style.BorderWidthRight = 1; + style.BorderWidthTop = 1; + style.BorderWidthBottom = 1; + style.CornerRadiusTopLeft = 4; + style.CornerRadiusTopRight = 4; + style.CornerRadiusBottomLeft = 4; + style.CornerRadiusBottomRight = 4; + style.ContentMarginLeft = 12; + style.ContentMarginRight = 12; + style.ContentMarginTop = 9; + style.ContentMarginBottom = 9; + return style; + } + } +} diff --git a/src/ui/InventoryBagBehaviour.cs b/src/ui/InventoryBagBehaviour.cs new file mode 100644 index 0000000..c3fd2d0 --- /dev/null +++ b/src/ui/InventoryBagBehaviour.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using Cthangover.Core.Items; +using Cthangover.Mods.Interface.Base.Lists.Impls; + +namespace Cthangover.Core.UI.Inventory +{ + /// + /// Grid-based inventory display using ColumnCellListWidget. Binds an + /// external List<IItemContainer>. The List property setter auto-refreshes + /// the UI — intended for data-driven updates where the inventory source + /// pushes a new list reference rather than items mutating in place. + /// + public partial class InventoryBagBehaviour : ColumnCellListWidget + { + private List list; + + /// + /// Bound inventory data source. Setting this property immediately rebuilds + /// the grid display via . The entire grid is + /// reconstructed from the new list reference. + /// + public List List + { + get => list; + set + { + list = value; + Refresh(); + } + } + + /// + /// Provides the current as the data collection for the + /// base. + /// + public override ICollection CreateModels() + { + return list; + } + } +} diff --git a/src/ui/InventoryItemBehaviour.cs b/src/ui/InventoryItemBehaviour.cs new file mode 100644 index 0000000..6d9eb33 --- /dev/null +++ b/src/ui/InventoryItemBehaviour.cs @@ -0,0 +1,244 @@ +using System; +using Cthangover.Core.Items; +using Cthangover.Mods.Interface.Base.Lists; +using Cthangover.Core.UI.Theming; +using Godot; + +namespace Cthangover.Core.UI.Inventory +{ + /// + /// Interactive cell of the inventory grid. Unlike the old thin cell that + /// only drew an icon and a stack counter, this one owns the whole visual + /// language of a slot: the frame (normal/hover/selected), the type accent + /// dot, the short action tag and the dimmed state for inert items. It is + /// built entirely in code because the grid needs pixel-accurate + /// frame swapping that theme overrides in the + /// tscn pipeline cannot express. + /// + /// Click handling lives inside the cell: it raises + /// after consuming the mouse event, instead of the panel subscribing to + /// GuiInput from outside, so event propagation is stopped by the + /// control that actually owns the click. + /// + public partial class InventoryItemBehaviour : ListItem + { + /// Raised on a left-click after the cell consumed the event. The payload is the cell itself. + public event Action Clicked; + + private static readonly StyleBoxFlat NormalFrame = MakeFrame(UiTokens.LineSoft, UiTokens.Surface1); + private static readonly StyleBoxFlat HoverFrame = MakeFrame(UiTokens.LineStrong, UiTokens.SurfaceHover); + private static readonly StyleBoxFlat SelectedFrame = MakeFrame(UiTokens.Accent, UiTokens.SurfaceSelected); + private static readonly StyleBoxFlat TagStyle = MakeTagStyle(); + private static readonly Color IconDimmed = UiTokens.Alpha(UiTokens.White, 0.38f); + + private Panel _frame; + private TextureRect _icon; + private Label _count; + private Panel _dot; + private StyleBoxFlat _dotStyle; + private PanelContainer _tag; + private Label _tagLabel; + private bool _built; + private bool _selected; + private bool _hovered; + + /// Stops mouse propagation and wires hover/click handling before any model is bound. + public InventoryItemBehaviour() + { + MouseFilter = MouseFilterEnum.Stop; + GuiInput += OnGuiInput; + MouseEntered += OnMouseEntered; + MouseExited += OnMouseExited; + } + + /// + /// Binds the item stack and lazily builds the visual tree on first + /// use; repeated binds only refresh the rendered data. + /// + public override void Construct(IItemContainer model) + { + base.Construct(model); + EnsureBuilt(); + UpdateInfo(); + } + + /// + /// Pushes the bound stack into the visuals: icon, stack counter + /// (hidden for singletons), type accent dot, action tag and the + /// dimmed state for items without a usable action. + /// + public void UpdateInfo() + { + if (!_built || Model?.Item == null) + return; + + var item = Model.Item; + _icon.Texture = item.Sprite; + _count.Text = Model.Count > 1 ? "×" + Model.Count : string.Empty; + _count.Visible = Model.Count > 1; + _dotStyle.BgColor = InventoryItemUi.AccentColor(item.ItemType); + + var tagKey = InventoryItemUi.TagKey(item); + _tag.Visible = tagKey != null; + if (tagKey != null) + _tagLabel.Text = InventoryItemUi.Translate(tagKey); + + var dimmed = !InventoryItemUi.IsUsable(item); + _icon.Modulate = dimmed ? IconDimmed : Colors.White; + _count.Modulate = dimmed ? IconDimmed : Colors.White; + } + + /// Switches the frame to the gold selected state; the panel owns this flag. + public void SetSelected(bool selected) + { + _selected = selected; + ApplyFrame(); + } + + /// Removes the cell from the scene tree; the grid rebuilds cells instead of recycling them. + public override void Destruct() + { + QueueFree(); + } + + private void EnsureBuilt() + { + if (_built) + return; + _built = true; + + _frame = new Panel(); + _frame.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + _frame.AddThemeStyleboxOverride("panel", NormalFrame); + _frame.MouseFilter = MouseFilterEnum.Ignore; + AddChild(_frame); + + _dot = new Panel(); + _dot.SetAnchorsAndOffsetsPreset(LayoutPreset.TopLeft); + _dot.OffsetLeft = 6; + _dot.OffsetTop = 6; + _dot.OffsetRight = 13; + _dot.OffsetBottom = 13; + _dot.MouseFilter = MouseFilterEnum.Ignore; + _dotStyle = new StyleBoxFlat(); + _dotStyle.CornerRadiusTopLeft = 2; + _dotStyle.CornerRadiusTopRight = 2; + _dotStyle.CornerRadiusBottomLeft = 2; + _dotStyle.CornerRadiusBottomRight = 2; + _dot.AddThemeStyleboxOverride("panel", _dotStyle); + AddChild(_dot); + + _icon = new TextureRect(); + _icon.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + _icon.OffsetLeft = 12; + _icon.OffsetTop = 12; + _icon.OffsetRight = -12; + _icon.OffsetBottom = -12; + _icon.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize; + _icon.StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered; + _icon.MouseFilter = MouseFilterEnum.Ignore; + AddChild(_icon); + + _count = new Label(); + _count.SetAnchorsAndOffsetsPreset(LayoutPreset.BottomRight); + _count.OffsetLeft = -70; + _count.OffsetTop = -26; + _count.OffsetRight = -6; + _count.OffsetBottom = -3; + _count.HorizontalAlignment = HorizontalAlignment.Right; + _count.VerticalAlignment = VerticalAlignment.Center; + _count.AddThemeFontSizeOverride("font_size", 13); + _count.AddThemeColorOverride("font_color", UiTokens.Stack); + _count.AddThemeColorOverride("font_outline_color", new Color(0f, 0f, 0f, 0.9f)); + _count.AddThemeConstantOverride("outline_size", 4); + _count.MouseFilter = MouseFilterEnum.Ignore; + AddChild(_count); + + var tagRow = new HBoxContainer(); + tagRow.SetAnchorsAndOffsetsPreset(LayoutPreset.TopWide); + tagRow.OffsetLeft = 5; + tagRow.OffsetTop = 4; + tagRow.OffsetRight = -5; + tagRow.OffsetBottom = 24; + tagRow.Alignment = BoxContainer.AlignmentMode.End; + tagRow.MouseFilter = MouseFilterEnum.Ignore; + AddChild(tagRow); + + _tag = new PanelContainer(); + _tag.AddThemeStyleboxOverride("panel", TagStyle); + _tag.MouseFilter = MouseFilterEnum.Ignore; + tagRow.AddChild(_tag); + + _tagLabel = new Label(); + _tagLabel.AddThemeFontSizeOverride("font_size", 9); + _tagLabel.AddThemeColorOverride("font_color", UiTokens.AccentBright); + _tagLabel.MouseFilter = MouseFilterEnum.Ignore; + _tag.AddChild(_tagLabel); + } + + private void OnGuiInput(InputEvent @event) + { + if (@event is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left) + { + Clicked?.Invoke(this); + AcceptEvent(); + } + } + + private void OnMouseEntered() + { + _hovered = true; + ApplyFrame(); + } + + private void OnMouseExited() + { + _hovered = false; + ApplyFrame(); + } + + private void ApplyFrame() + { + if (!_built || _frame == null) + return; + _frame.AddThemeStyleboxOverride("panel", + _selected ? SelectedFrame : (_hovered ? HoverFrame : NormalFrame)); + } + + private static StyleBoxFlat MakeFrame(Color border, Color background) + { + var style = new StyleBoxFlat(); + style.BgColor = background; + style.BorderColor = border; + style.BorderWidthLeft = 1; + style.BorderWidthRight = 1; + style.BorderWidthTop = 1; + style.BorderWidthBottom = 1; + style.CornerRadiusTopLeft = 6; + style.CornerRadiusTopRight = 6; + style.CornerRadiusBottomLeft = 6; + style.CornerRadiusBottomRight = 6; + return style; + } + + private static StyleBoxFlat MakeTagStyle() + { + var style = new StyleBoxFlat(); + style.BgColor = UiTokens.SlotTagBg; + style.BorderColor = UiTokens.AccentLine; + style.BorderWidthLeft = 1; + style.BorderWidthRight = 1; + style.BorderWidthTop = 1; + style.BorderWidthBottom = 1; + style.CornerRadiusTopLeft = 3; + style.CornerRadiusTopRight = 3; + style.CornerRadiusBottomLeft = 3; + style.CornerRadiusBottomRight = 3; + style.ContentMarginLeft = 5; + style.ContentMarginRight = 5; + style.ContentMarginTop = 1; + style.ContentMarginBottom = 1; + return style; + } + } +} diff --git a/src/ui/InventoryItemUi.cs b/src/ui/InventoryItemUi.cs new file mode 100644 index 0000000..da08a31 --- /dev/null +++ b/src/ui/InventoryItemUi.cs @@ -0,0 +1,141 @@ +using System.Collections.Generic; +using Cthangover.Core.Items; +using Cthangover.Core.UI.Theming; +using Godot; + +namespace Cthangover.Core.UI.Inventory +{ + /// + /// Shared presentation rules for inventory items, used by both the grid + /// cell and the item passport so the two can never disagree about an + /// item's affordances. is a flags enum, so every + /// question ("can this be used?", "what badge colour?") has to pick a + /// priority among possibly overlapping flags — the order in + /// defines that priority consistently. + /// A custom verb can be supplied by mods through the item's + /// Properties bag under the verb key; it is treated as a + /// locale key when it contains a slash and as a literal otherwise. + /// + public static class InventoryItemUi + { + /// Badge order from most narrative to most technical flag. + public static readonly IReadOnlyList BadgeOrder = new[] + { + ItemType.Quest, + ItemType.Used, + ItemType.TargetUsed, + ItemType.Food, + ItemType.Resource, + ItemType.Recipe, + ItemType.CantDrop, + }; + + /// + /// Mirrors InventoryPanelBehaviour use-button rule: an item is + /// usable only when it carries an action and one of the "used" flags. + /// Food or resources without an are inert. + /// + public static bool IsUsable(IItem item) + { + if (item == null || item.ItemAction == null) + return false; + return item.ItemType.HasFlag(ItemType.Used) || item.ItemType.HasFlag(ItemType.TargetUsed); + } + + /// False only for items (quest objectives, key items). + public static bool CanDrop(IItem item) + { + return item != null && !item.ItemType.HasFlag(ItemType.CantDrop); + } + + /// + /// Locale key (or literal) of the verb shown on the action button. + /// Resolution order: mod-provided verb property, recipe, + /// targeted, food, generic use. + /// + public static string VerbKey(IItem item) + { + if (item == null) + return "ui/inventory/verb_use"; + + var custom = item.Properties?.GetStr("verb"); + if (!string.IsNullOrWhiteSpace(custom)) + return custom; + + if (item.ItemType.HasFlag(ItemType.Recipe)) + return "ui/inventory/verb_recipe"; + if (item.ItemType.HasFlag(ItemType.TargetUsed)) + return "ui/inventory/verb_target"; + if (item.ItemType.HasFlag(ItemType.Food)) + return "ui/inventory/verb_eat"; + return "ui/inventory/verb_use"; + } + + /// + /// Short corner tag for the grid cell. Only actionable items get a + /// tag — an inert resource must not advertise a verb it cannot run. + /// + public static string TagKey(IItem item) + { + if (item?.ItemAction == null) + return null; + + if (item.ItemType.HasFlag(ItemType.Recipe)) + return "ui/inventory/tag_recipe"; + if (item.ItemType.HasFlag(ItemType.TargetUsed)) + return "ui/inventory/tag_target"; + if (item.ItemType.HasFlag(ItemType.Food)) + return "ui/inventory/tag_food"; + return "ui/inventory/tag_use"; + } + + /// + /// Accent colour of an item: quest gold dominates, then recipe, + /// food, targeted, used and finally plain resources. + /// + public static Color AccentColor(ItemType type) + { + if (type.HasFlag(ItemType.Quest)) + return UiTokens.TypeQuest; + if (type.HasFlag(ItemType.Recipe)) + return UiTokens.TypeRecipe; + if (type.HasFlag(ItemType.Food)) + return UiTokens.TypeFood; + if (type.HasFlag(ItemType.TargetUsed)) + return UiTokens.TypeTarget; + if (type.HasFlag(ItemType.Used)) + return UiTokens.TypeUsed; + if (type.HasFlag(ItemType.Resource)) + return UiTokens.TypeResource; + return UiTokens.Muted; + } + + /// Locale key of the badge caption for a single type flag. + public static string TypeBadgeKey(ItemType type) + { + if (type == ItemType.Quest) + return "ui/inventory/type_quest"; + if (type == ItemType.Used) + return "ui/inventory/type_used"; + if (type == ItemType.TargetUsed) + return "ui/inventory/type_target"; + if (type == ItemType.Food) + return "ui/inventory/type_food"; + if (type == ItemType.Resource) + return "ui/inventory/type_resource"; + if (type == ItemType.Recipe) + return "ui/inventory/type_recipe"; + if (type == ItemType.CantDrop) + return "ui/inventory/type_cant_drop"; + return null; + } + + /// Translates a locale key, falling back to the key itself for unknown keys and literals. + public static string Translate(string keyOrLiteral) + { + if (string.IsNullOrEmpty(keyOrLiteral)) + return string.Empty; + return TranslationServer.Translate(keyOrLiteral).ToString(); + } + } +} diff --git a/src/ui/InventoryPanelBehaviour.cs b/src/ui/InventoryPanelBehaviour.cs new file mode 100644 index 0000000..b67e58a --- /dev/null +++ b/src/ui/InventoryPanelBehaviour.cs @@ -0,0 +1,1254 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.Audio; +using Cthangover.Core.Characters; +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Items; +using Cthangover.Core.Scenes; +using Cthangover.Core.Settings; +using Cthangover.Core.UI.Theming; +using Godot; + +using Cthangover.Mods.Interface.Base.Lists; +using Cthangover.Mods.Interface.Theming; +namespace Cthangover.Core.UI.Inventory +{ + /// + /// Full-screen inventory panel following mockup 1: a header with type + /// tabs and battle action-point pips, a search/sort toolbar, a six-column + /// item grid on the left and an "item passport" on the right. The passport + /// shows the translated name, type badges, description, a stats table, the + /// mod-defined PropertyData bag and the contextual actions for the + /// selected item (use/apply/learn/drop) with a quantity slider. + /// + /// Affordances are centralised in so the grid + /// cell, the passport buttons and the target list never disagree. Actions + /// that fail (UseAction returning false) do not consume the + /// item and surface the reason in the passport message area instead. + /// During battle every use/apply/drop costs one action point from the + /// active character; outside battle actions are free. + /// + /// The grid rebuilds on every , preserving + /// the selected item by ID when it still exists. + /// + public partial class InventoryPanelBehaviour : Widget + { + private enum BagFilter + { + All, + Food, + Resource, + Recipe, + Quest, + } + + private enum SortMode + { + Type, + Name, + Cost, + Count, + } + + private enum MessageKind + { + None, + Ok, + Error, + } + + private const int Columns = 6; + private const float CellGap = 10f; + private const float PassportWidth = 430f; + + private static readonly (BagFilter Filter, string Key)[] FilterTabs = + { + (BagFilter.All, "ui/inventory/tab_all"), + (BagFilter.Food, "ui/inventory/tab_food"), + (BagFilter.Resource, "ui/inventory/tab_resource"), + (BagFilter.Recipe, "ui/inventory/tab_recipe"), + (BagFilter.Quest, "ui/inventory/tab_quest"), + }; + + private static readonly Color PanelColor = UiTokens.Surface0; + private static readonly Color CellColor = UiTokens.Surface1; + private static readonly Color StatColor = UiTokens.Surface2; + private static readonly Color LineColor = UiTokens.Line; + private static readonly Color CellLineColor = UiTokens.LineSoft; + private static readonly Color InkColor = UiTokens.Ink; + private static readonly Color InkBrightColor = UiTokens.InkBright; + private static readonly Color BodyColor = UiTokens.Body; + private static readonly Color MutedColor = UiTokens.Muted; + private static readonly Color FaintColor = UiTokens.Faint; + private static readonly Color GoldColor = UiTokens.Accent; + private static readonly Color GoldBrightColor = UiTokens.AccentBright; + private static readonly Color DangerColor = UiTokens.Danger; + private static readonly Color OkColor = UiTokens.Ok; + private static readonly Color ErrorColor = UiTokens.DangerText; + + private BagFilter _filter = BagFilter.All; + private SortMode _sort = SortMode.Type; + private string _search = string.Empty; + private IItemContainer _selectedContainer; + private InventoryItemBehaviour _selectedCell; + private int _dropQuantity = 1; + private bool _dropOpen; + private bool _targetOpen; + private MessageKind _messageKind = MessageKind.None; + private string _messageText; + + private readonly List _itemCells = new(); + + private Control _gridContent; + private HBoxContainer _tabsRow; + private LineEdit _searchEdit; + private OptionButton _sortOption; + private HBoxContainer _apBox; + private HBoxContainer _apPips; + private Label _shownLabel; + private VBoxContainer _passport; + private Label _dropQuantityLabel; + private System.Action _onInventoryChange; + + // Scene nodes (inventory_panel.tscn): static layout lives in the scene, + // the behaviour resolves them once and owns only the dynamic layer. + private Label _title; + private Button _closeButton; + private Label _hintLabel; + private Label _apLabel; + + /// Subscribes to inventory changes so the grid refreshes live while the panel is open. + public override void _Ready() + { + base._Ready(); + _onInventoryChange = (_, _) => Refresh(); + GameData.Instance.Runtime.Inventory.Change += _onInventoryChange; + MouseFilter = MouseFilterEnum.Stop; + } + + /// Unsubscribes from inventory change events so a closed panel stops refreshing. + public override void _ExitTree() + { + base._ExitTree(); + GameData.Instance.Runtime.Inventory.Change -= _onInventoryChange; + } + + protected override void OnceConstruct() + { + MouseFilter = MouseFilterEnum.Stop; + + // Scene nodes (inventory_panel.tscn); static layout lives in the scene. + _title = GetNode