init mod
This commit is contained in:
commit
370cc5bbbd
29 files changed
+3093
No files matched your search
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_items_base_name = Предметы и инвентарь
|
||||
mod_items_base_desc = Определения предметов и фабрики, состояние инвентаря и рецептов, интерфейс инвентаря и их сохранение.
|
||||
@@ -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"]}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,17 @@
|
||||
|
||||
|
||||
namespace Cthangover.Core.Items
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain POCO implementing <c>IIngredient</c>. Used by
|
||||
/// <c>RecipeFactory</c> to materialise ingredient entries from
|
||||
/// <c>IngredientInfo</c> DTOs after resolving the item reference
|
||||
/// through <c>ItemFactory</c>.
|
||||
/// </summary>
|
||||
public class Ingredient : IIngredient{
|
||||
/// <summary>The resolved item this formula entry references.</summary>
|
||||
public IItem Item { get; set; }
|
||||
/// <summary>How many copies the recipe consumes or produces.</summary>
|
||||
public int Count { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cthangover.Core.Factories;
|
||||
|
||||
namespace Cthangover.Core.Items
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON deserialization DTO for a recipe ingredient entry. Carries
|
||||
/// only the item <c>ID</c> string and <c>Count</c> — not the resolved
|
||||
/// <c>IItem</c> — because recipe JSON files are loaded before item
|
||||
/// definitions may be fully populated across mods. The actual item
|
||||
/// reference is wired up later by <c>RecipeFactory.CreateIngredient</c>,
|
||||
/// which looks up the ID through <c>ItemFactory</c> and validates
|
||||
/// that the referenced item exists.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class IngredientInfo : IIdentifiable
|
||||
{
|
||||
/// <summary>Item ID this entry references; serialized as "Id" in JSON.</summary>
|
||||
[JsonPropertyName("Id")]
|
||||
public string ID { get; set; }
|
||||
/// <summary>Quantity consumed or produced by the recipe.</summary>
|
||||
public int Count { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Concrete player inventory backed by an ordered <c>List<IItemContainer></c>.
|
||||
/// Uses LINQ scans (<c>FirstOrDefault</c>, <c>Any</c>) rather than a
|
||||
/// <c>Dictionary</c> 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 <c>Remove</c>, 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 <c>Change</c> event fires with <c>(itemId, delta)</c> so
|
||||
/// subscribers receive full context. Additionally, each mutation
|
||||
/// publishes an <c>InventoryChangedEvent</c> to the global
|
||||
/// <c>ModEventBus</c>.
|
||||
/// </summary>
|
||||
public class Inventory : IInventory
|
||||
{
|
||||
/// <summary>Ordered slot list; order drives UI layout and empty slots are removed on zero stacks.</summary>
|
||||
public List<IItemContainer> Items { get; set; } = new();
|
||||
/// <summary>Fires on every successful add/remove with (itemId, signed delta); also mirrored as <see cref="Events.InventoryChangedEvent"/> on the ModEventBus.</summary>
|
||||
public event Action<string, int> Change;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an inventory pre-seeded with the starter kit (10 wolf meat,
|
||||
/// 10 branches) so a new game always has basic supplies.
|
||||
/// </summary>
|
||||
public Inventory()
|
||||
{
|
||||
AddStarterItem("food/wolf_meat", 10);
|
||||
AddStarterItem("resource/branches", 10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>null</c> item would otherwise crash every later
|
||||
/// <see cref="HasItem(string)"/> call.
|
||||
/// </summary>
|
||||
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 });
|
||||
}
|
||||
|
||||
/// <summary>True when any slot holds the given item (null-safe).</summary>
|
||||
public bool HasItem(IItem item)
|
||||
{
|
||||
if (item == null)
|
||||
return false;
|
||||
return HasItem(item.ID);
|
||||
}
|
||||
|
||||
/// <summary>True when any slot holds the item with the given id; null/empty ids return false.</summary>
|
||||
public bool HasItem(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return false;
|
||||
return Items.Any(o => o.Item.ID == id);
|
||||
}
|
||||
|
||||
/// <summary>Returns the slot holding the given item, or <c>null</c> (null-safe).</summary>
|
||||
public IItemContainer TryGet(IItem item)
|
||||
{
|
||||
if (item == null)
|
||||
return null;
|
||||
return TryGet(item.ID);
|
||||
}
|
||||
|
||||
/// <summary>Returns the slot holding the given id, or <c>null</c> for null/empty ids.</summary>
|
||||
public IItemContainer TryGet(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return null;
|
||||
return Items.FirstOrDefault(o => o.Item != null && o.Item.ID == id);
|
||||
}
|
||||
|
||||
/// <summary>Total count across slots for the given item (0 when absent or null).</summary>
|
||||
public int CheckCount(IItem item)
|
||||
{
|
||||
if (item == null)
|
||||
return 0;
|
||||
return CheckCount(item.ID);
|
||||
}
|
||||
|
||||
/// <summary>Total count across slots for the given id (0 when absent).</summary>
|
||||
public int CheckCount(string id)
|
||||
{
|
||||
var container = TryGet(id);
|
||||
return container?.Count ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>Adds the item (delegates to the id overload); null items are ignored.</summary>
|
||||
public void Add(IItem item, int count = 1)
|
||||
{
|
||||
if (item == null)
|
||||
return;
|
||||
Add(item.ID, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stacks onto an existing slot or creates a new one by resolving the id
|
||||
/// through <see cref="Factories.Impls.ItemFactory"/>; unknown ids are
|
||||
/// silently ignored. Fires <see cref="Change"/> and publishes
|
||||
/// <see cref="Events.InventoryChangedEvent"/> on success.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes the item (delegates to the id overload); null items return false.</summary>
|
||||
public bool Remove(IItem item, int count = 1)
|
||||
{
|
||||
if (item == null)
|
||||
return false;
|
||||
return Remove(item.ID, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes <paramref name="count"/> items from the slot, deleting the
|
||||
/// container when the stack reaches zero. Returns <c>false</c> without
|
||||
/// changes when the slot holds fewer items than requested.
|
||||
/// </summary>
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Core.Items
|
||||
{
|
||||
/// <summary>
|
||||
/// Concrete item instance produced by <see cref="Factories.Impls.ItemFactory"/>
|
||||
/// from an <see cref="ItemInfo"/> DTO. The factory builds a fresh instance per
|
||||
/// <c>Get</c> 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.
|
||||
/// </summary>
|
||||
public class Item : IItem
|
||||
{
|
||||
/// <summary>Unique item ID (path-style, e.g. <c>"food/ration"</c>); the factory lookup key.</summary>
|
||||
public string ID { get; set; }
|
||||
/// <summary>Display name (resolved locale string).</summary>
|
||||
public string Name { get; set; }
|
||||
/// <summary>Tooltip/description text (resolved locale string).</summary>
|
||||
public string Description { get; set; }
|
||||
/// <summary>Base purchase price in the game economy.</summary>
|
||||
public int Cost { get; set; }
|
||||
/// <summary>Icon texture resolved from the mod's <c>items</c> group via <see cref="Factories.Impls.ItemSpriteFactory"/>.</summary>
|
||||
public Texture2D Sprite { get; set; }
|
||||
/// <summary><see cref="ItemType"/> flags describing the item's categories.</summary>
|
||||
public ItemType ItemType { get; set; }
|
||||
/// <summary>On-use behaviour resolved via <see cref="ItemActionFactory"/>; <c>null</c> means no use effect.</summary>
|
||||
public IItemAction ItemAction { get; set; }
|
||||
/// <summary>String-keyed extension bag imported from extra JSON keys, for mod-defined item data.</summary>
|
||||
public PropertyData Properties { get; set; } = new();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Cthangover.Core.Factories.Impls;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Core.Mods.Consumers;
|
||||
|
||||
namespace Cthangover.Mods.Items
|
||||
{
|
||||
/// <summary>
|
||||
/// Feeds <see cref="ItemActionFactory"/> with every mod's C#
|
||||
/// <see cref="IItemAction"/> 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 <see cref="ItemActionBridge"/>.
|
||||
/// </summary>
|
||||
public class ItemActionConsumer : ModTypeConsumer<IItemAction>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string Id => "items.actions";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Add(IItemAction instance, string modId) => ItemActionFactory.Instance.Register(instance);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Remove(IItemAction instance, string modId) => ItemActionFactory.Instance.Unregister(instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Items;
|
||||
|
||||
namespace Cthangover.Core.Factories.Impls
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry of item action behaviours, indexed by <c>ID</c>. It discovers
|
||||
/// nothing itself: C# actions of every mod arrive through
|
||||
/// <c>ItemActionConsumer</c> as the kernel registers their assembly and
|
||||
/// leave with their mod, and GDScript-declared actions arrive through
|
||||
/// <c>ItemActionBridge</c>. A mod therefore introduces a new item action
|
||||
/// simply by containing a public class that implements the interface.
|
||||
///
|
||||
/// The <c>"null"</c> 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 <c>NullItemAction</c> class.
|
||||
/// <para/>
|
||||
/// The single instance lives in this mod assembly as a static lazy
|
||||
/// singleton; the static facade stays as the public API.
|
||||
/// </summary>
|
||||
public class ItemActionFactory
|
||||
{
|
||||
private static ItemActionFactory _instance;
|
||||
|
||||
/// <summary>Static lazy singleton; kept as the public API.</summary>
|
||||
public static ItemActionFactory Instance => _instance ??= new ItemActionFactory();
|
||||
|
||||
private readonly IDictionary<string, IItemAction> data = new Dictionary<string, IItemAction>();
|
||||
|
||||
internal ItemActionFactory()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Registers a single action under its <see cref="IItemAction.ID"/>; the first action of an id wins, null/empty ids are ignored.</summary>
|
||||
public void Register(IItemAction action)
|
||||
{
|
||||
if (action != null && !string.IsNullOrEmpty(action.ID) && !data.ContainsKey(action.ID))
|
||||
data[action.ID] = action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes exactly <paramref name="action"/> (its mod is being unloaded);
|
||||
/// an id owned by another action instance is left alone.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the behaviour registered under <paramref name="id"/>.
|
||||
/// The special id <c>"null"</c> (or an empty value) is an explicit
|
||||
/// no-action marker and returns <c>null</c>, as does an unknown id.
|
||||
/// </summary>
|
||||
public IItemAction Get(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id) || id == "null")
|
||||
return null;
|
||||
return data[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Cthangover.Core.Items
|
||||
{
|
||||
/// <summary>
|
||||
/// Concrete inventory slot holding a shared <c>IItem</c> 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).
|
||||
/// </summary>
|
||||
public class ItemContainer : IItemContainer
|
||||
{
|
||||
/// <summary>The shared item definition held by this slot (one instance per item type, not per stack).</summary>
|
||||
public IItem Item { get; set; }
|
||||
/// <summary>Stack size; <see cref="Inventory"/> deletes the container when it reaches zero.</summary>
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// The single instance lives in this mod assembly as a static lazy
|
||||
/// singleton; the static facade stays as the public API.
|
||||
/// </summary>
|
||||
public class ItemFactory
|
||||
{
|
||||
private static ItemFactory _instance;
|
||||
|
||||
/// <summary>Static lazy singleton; kept as the public API.</summary>
|
||||
public static ItemFactory Instance => _instance ??= new ItemFactory();
|
||||
|
||||
private Dictionary<string, ItemInfo> items;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a fresh <see cref="Item"/> from the collected <see cref="ItemInfo"/>,
|
||||
/// resolving the sprite through <see cref="ItemSpriteFactory"/>, the on-use
|
||||
/// action through <see cref="ItemActionFactory"/>, and importing extra JSON
|
||||
/// keys into <see cref="Item.Properties"/>. Unknown or empty ids return
|
||||
/// <c>null</c>.
|
||||
/// </summary>
|
||||
public IItem Get(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
return null;
|
||||
|
||||
var infos = items ?? Entities.Collect<ItemInfo>();
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON-serializable item descriptor collected by the entity resolver
|
||||
/// (<c>[EntityKind("item")]</c> — files declare <c>"Kind": "item"</c>).
|
||||
/// Typed fields cover the stock item schema; any additional JSON keys are
|
||||
/// captured by <see cref="ExtraProperties"/> and imported by
|
||||
/// <see cref="Factories.Impls.ItemFactory"/> into <see cref="Item.Properties"/>,
|
||||
/// so mods can attach custom data to items without schema changes.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
[EntityKind("item")]
|
||||
public class ItemInfo : IIdentifiable
|
||||
{
|
||||
/// <summary>Unique item ID, serialized as "Id" in JSON; path-style ids (e.g. "food/ration") double as save-data references.</summary>
|
||||
[JsonPropertyName("Id")]
|
||||
public string ID { get; set; }
|
||||
/// <summary>Locale key for the item's display name, resolved from .properties files.</summary>
|
||||
public string Name { get; set; }
|
||||
/// <summary>Locale key for the item's tooltip/description text.</summary>
|
||||
public string Description { get; set; }
|
||||
/// <summary>Base purchase price in the game economy.</summary>
|
||||
public int Cost { get; set; }
|
||||
/// <summary>Sprite path inside the mod's <c>items</c> texture group, resolved by <see cref="Factories.Impls.ItemSpriteFactory"/>.</summary>
|
||||
public string Sprite { get; set; }
|
||||
/// <summary>Category flags parsed from a comma-separated string (e.g. "Resource,Food") via the flags enum converter.</summary>
|
||||
[JsonConverter(typeof(FlagsStringEnumConverter<ItemType>))]
|
||||
public ItemType ItemType { get; set; }
|
||||
/// <summary>ID of the on-use behaviour, resolved through <see cref="Factories.Impls.ItemActionFactory"/>; "null"/empty means no use effect.</summary>
|
||||
public string ItemAction { get; set; }
|
||||
/// <summary>Captures every JSON key not matched by the typed fields; imported into <see cref="Item.Properties"/> at factory time.</summary>
|
||||
[JsonExtensionData]
|
||||
public Dictionary<string, object> ExtraProperties { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Lifecycle entry point of the items mod. The core no longer owns the
|
||||
/// inventory/recipe state or the item factories, so this initializer:
|
||||
/// <list type="number">
|
||||
/// <item>installs the <see cref="RuntimeData"/> factory hooks, so the
|
||||
/// core state bag lazily produces the mod-owned <c>Inventory</c> and
|
||||
/// <c>RecipeData</c> on first access; and</item>
|
||||
/// <item>binds <see cref="ItemActionBridge"/> to this mod's
|
||||
/// <c>ItemActionFactory</c>, flushing every GDScript-declared action
|
||||
/// buffered so far.</item>
|
||||
/// </list>
|
||||
/// C# item actions of every mod reach the factory through
|
||||
/// <see cref="ItemActionConsumer"/>, not through this initializer.
|
||||
/// Persistence lives in <see cref="ItemsSaveable"/>, discovered
|
||||
/// automatically by <c>ModSaveableRegistry</c>.
|
||||
/// </summary>
|
||||
public class ItemsModInitializer : IModInitializer
|
||||
{
|
||||
/// <summary>No per-mod work is needed on load; discovery is reflection-based.</summary>
|
||||
public void OnModLoaded(string modId)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Installs the RuntimeData factory hooks and binds the item-action bridge (both idempotent).</summary>
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
RuntimeData.InventoryFactory ??= () => new Inventory();
|
||||
RuntimeData.RecipeDataFactory ??= () => new RecipeData();
|
||||
|
||||
ItemActionBridge.Bind(action => ItemActionFactory.Instance.Register(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists the player inventory and unlocked recipes in the items mod's
|
||||
/// own save slot. The core used to carry the <c>"Inventory"</c> and
|
||||
/// <c>"Recipes"</c> blobs inside the <c>__core__</c> 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.
|
||||
/// </summary>
|
||||
public class ItemsSaveable : IModSaveable
|
||||
{
|
||||
private const string InventoryBlob = "Inventory";
|
||||
private const string RecipesBlob = "Recipes";
|
||||
|
||||
/// <summary>Writes item stacks and unlocked recipe IDs into the mod slot.</summary>
|
||||
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());
|
||||
}
|
||||
|
||||
/// <summary>Restores inventory and recipes; absent blobs leave the current state untouched.</summary>
|
||||
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<List<CItem>>();
|
||||
runtime.Inventory.Items = items?.Select(o =>
|
||||
(IItemContainer)new ItemContainer
|
||||
{ Item = ItemFactory.Instance.Get(o.ID), Count = o.Count }).ToList()
|
||||
?? new List<IItemContainer>();
|
||||
}
|
||||
|
||||
if (slot.Data.TryGetValue(RecipesBlob, out var recipeBlob))
|
||||
runtime.RecipeData.Data = recipeBlob.ReadJson<List<string>>()?.ToHashSet()
|
||||
?? new HashSet<string>();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Cthangover.Core.Items
|
||||
{
|
||||
/// <summary>
|
||||
/// Concrete recipe fulfilling <c>IRecipe</c>. Populated by
|
||||
/// <c>RecipeFactory.CreateRecipe</c> from a <c>RecipeInfo</c> DTO
|
||||
/// after resolving every ingredient entry's item ID through
|
||||
/// <c>ItemFactory</c>. Recipes are treated as immutable value
|
||||
/// objects by the crafting UI — <c>RecipeFactory.Get</c> returns
|
||||
/// the cached instance directly without copying.
|
||||
/// </summary>
|
||||
public class Recipe : IRecipe
|
||||
{
|
||||
/// <summary>Unique recipe ID (path-style, e.g. <c>"recipe/wolf_meat_to_ration"</c>).</summary>
|
||||
public string ID { get; set; }
|
||||
/// <summary>Locale key for the recipe display name.</summary>
|
||||
public string Name { get; set; }
|
||||
/// <summary>Locale key for the recipe description text.</summary>
|
||||
public string Description { get; set; }
|
||||
/// <summary>Crafting time in in-game minutes.</summary>
|
||||
public int Time { get; set; }
|
||||
/// <summary>Required crafting station (only <see cref="WorkbenchType.Cooking"/> exists).</summary>
|
||||
public WorkbenchType WorkbenchType { get; set; }
|
||||
/// <summary>Ingredients consumed on a successful craft; item references resolved by <see cref="Factories.Impls.RecipeFactory"/>.</summary>
|
||||
public List<IIngredient> Input { get; set; }
|
||||
/// <summary>Items produced on a successful craft.</summary>
|
||||
public List<IIngredient> Output { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Factories.Impls;
|
||||
using Cthangover.Core.Items;
|
||||
|
||||
namespace Cthangover.Core.Settings
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks which crafting recipes the player has unlocked. Internally
|
||||
/// a <see cref="HashSet{String}"/> of recipe IDs that provides <c>O(1)</c>
|
||||
/// lookup. Recipes can be queried by <see cref="IRecipe.WorkbenchType"/>
|
||||
/// via <see cref="GetRecipesByType"/>, which resolves each ID through
|
||||
/// <see cref="Cthangover.Core.Factories.Impls.RecipeFactory"/> and
|
||||
/// filters the resulting <see cref="IRecipe"/> objects.
|
||||
/// </summary>
|
||||
public class RecipeData : IRecipeData
|
||||
{
|
||||
/// <summary>Set of known recipe IDs (duplicate insertion is silently ignored).</summary>
|
||||
public HashSet<string> Data { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Unlocks a recipe by ID. Returns <c>false</c> if the recipe
|
||||
/// was already known (duplicate).
|
||||
/// </summary>
|
||||
public bool Add(string id)
|
||||
{
|
||||
return Data.Add(id);
|
||||
}
|
||||
|
||||
/// <summary><c>true</c> if the recipe with the given ID is already unlocked.</summary>
|
||||
public bool Has(string id)
|
||||
{
|
||||
return Data.Contains(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all known recipes that belong to the specified
|
||||
/// <see cref="WorkbenchType"/>. Each ID is resolved via
|
||||
/// <see cref="Cthangover.Core.Factories.Impls.RecipeFactory.Instance"/>
|
||||
/// and filtered; IDs that resolve to <c>null</c> are silently
|
||||
/// dropped.
|
||||
/// </summary>
|
||||
public List<IRecipe> GetRecipesByType(WorkbenchType type)
|
||||
{
|
||||
return Data.Select(o => RecipeFactory.Instance.Get(o))
|
||||
.Where(o => o.WorkbenchType == type)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Factory for crafting recipes. Uses <c>BoundedCache</c> because the
|
||||
/// crafting UI may repeatedly query recipes during scrolling and the
|
||||
/// ingredient resolution (through <c>ItemFactory</c>) is non-trivial.
|
||||
/// Recipes are treated as immutable value objects — <c>Get</c> 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.
|
||||
/// <para/>
|
||||
/// The single instance lives in this mod assembly as a static lazy
|
||||
/// singleton; the static facade stays as the public API.
|
||||
/// </summary>
|
||||
public class RecipeFactory : ICacheLoader<string, IRecipe>
|
||||
{
|
||||
private static RecipeFactory _instance;
|
||||
|
||||
/// <summary>Static lazy singleton; kept as the public API.</summary>
|
||||
public static RecipeFactory Instance => _instance ??= new RecipeFactory();
|
||||
|
||||
private Dictionary<string, RecipeInfo> _allInfos;
|
||||
private readonly BoundedCache<string, IRecipe> _cache;
|
||||
|
||||
internal RecipeFactory()
|
||||
{
|
||||
var size = ModConfig.Instance.Cache.GetCacheSize("recipes", 128);
|
||||
_cache = new BoundedCache<string, IRecipe>(size, this);
|
||||
}
|
||||
|
||||
IRecipe ICacheLoader<string, IRecipe>.Load(string id)
|
||||
{
|
||||
var infos = EnsureInfos();
|
||||
if (infos.TryGetValue(id, out var info))
|
||||
return CreateRecipe(info);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the recipe definitions, collecting them on first use.
|
||||
/// Publication to <c>_allInfos</c> waits until the mod registry is
|
||||
/// finalized so an early lookup from the launcher boot window cannot
|
||||
/// freeze an empty snapshot for the session.
|
||||
/// </summary>
|
||||
private Dictionary<string, RecipeInfo> EnsureInfos()
|
||||
{
|
||||
if (_allInfos != null)
|
||||
return _allInfos;
|
||||
|
||||
var infos = Entities.Collect<RecipeInfo>();
|
||||
if (ModManager.Instance.IsInitialized)
|
||||
_allInfos = infos;
|
||||
return infos;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached recipe for <paramref name="id"/> (resolved lazily
|
||||
/// through <see cref="RecipeFactory.Load"/>), or <c>null</c> for unknown
|
||||
/// or empty ids. The shared instance is returned without copying —
|
||||
/// recipes are treated as immutable.
|
||||
/// </summary>
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cthangover.Core.Factories;
|
||||
|
||||
namespace Cthangover.Core.Items
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON DTO for recipe definitions in mod files. Uses a plain
|
||||
/// <c>JsonStringEnumConverter</c> for <c>WorkbenchType</c> (simple
|
||||
/// enum, no flags needed) rather than the
|
||||
/// <c>FlagsStringEnumConverter</c> used by <c>ItemInfo</c>.
|
||||
/// <c>Input</c> and <c>Output</c> are raw <c>IngredientInfo</c>
|
||||
/// lists — the actual <c>IItem</c> references are resolved by
|
||||
/// <c>RecipeFactory</c> at cache population time, which also
|
||||
/// validates that every referenced item exists in <c>ItemFactory</c>.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
[EntityKind("recipe")]
|
||||
public class RecipeInfo : IIdentifiable
|
||||
{
|
||||
/// <summary>Unique recipe ID, serialized as "Id" in JSON.</summary>
|
||||
[JsonPropertyName("Id")]
|
||||
public string ID { get; set; }
|
||||
/// <summary>Locale key for the recipe display name.</summary>
|
||||
public string Name { get; set; }
|
||||
/// <summary>Locale key for the recipe description text.</summary>
|
||||
public string Description { get; set; }
|
||||
/// <summary>Required crafting station, serialized as its enum name ("Cooking").</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public WorkbenchType Type { get; set; }
|
||||
/// <summary>Crafting time in in-game minutes.</summary>
|
||||
public int Time { get; set; }
|
||||
/// <summary>Raw ingredient entries; item references are resolved and validated by <see cref="Factories.Impls.RecipeFactory"/>.</summary>
|
||||
public List<IngredientInfo> Input { get; set; }
|
||||
/// <summary>Raw output entries resolved at factory time.</summary>
|
||||
public List<IngredientInfo> Output { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Cthangover.Mods.Items
|
||||
{
|
||||
/// <summary>
|
||||
/// Migration marker for the coverage pipeline
|
||||
/// (<c>SubsystemsBridge</c> reflection scan): declares the items
|
||||
/// subsystem fully moved out of the core assembly into this engine mod.
|
||||
/// The id must match the <c>DeclaredTests</c> entry in
|
||||
/// <c>SubsystemsCoverageTestSetup</c>.
|
||||
/// </summary>
|
||||
public static class SubsystemRegistration
|
||||
{
|
||||
public static string SubsystemId => "items";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Inline target picker for <see cref="ItemType.TargetUsed"/> 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 <see cref="VBoxContainer"/> 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.
|
||||
/// </summary>
|
||||
public partial class CharacterTargetPanel : VBoxContainer
|
||||
{
|
||||
/// <summary>Raised when the player picks a target; the effect is applied by the inventory panel.</summary>
|
||||
public event Action<CharacterInfoData> CharacterSelected;
|
||||
/// <summary>Raised when the player aborts target selection.</summary>
|
||||
public event Action Cancelled;
|
||||
|
||||
/// <summary>Builds the picker inline: title, one row per party member, and a cancel button.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Mods.Interface.Base.Lists.Impls;
|
||||
|
||||
namespace Cthangover.Core.UI.Inventory
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public partial class InventoryBagBehaviour : ColumnCellListWidget<InventoryItemBehaviour, IItemContainer>
|
||||
{
|
||||
private List<IItemContainer> list;
|
||||
|
||||
/// <summary>
|
||||
/// Bound inventory data source. Setting this property immediately rebuilds
|
||||
/// the grid display via <see cref="Refresh"/>. The entire grid is
|
||||
/// reconstructed from the new list reference.
|
||||
/// </summary>
|
||||
public List<IItemContainer> List
|
||||
{
|
||||
get => list;
|
||||
set
|
||||
{
|
||||
list = value;
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides the current <see cref="List"/> as the data collection for the
|
||||
/// <see cref="ColumnCellListWidget{TItem, TModel}"/> base.
|
||||
/// </summary>
|
||||
public override ICollection<IItemContainer> CreateModels()
|
||||
{
|
||||
return list;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="StyleBoxFlat"/> frame swapping that theme overrides in the
|
||||
/// tscn pipeline cannot express.
|
||||
///
|
||||
/// Click handling lives inside the cell: it raises <see cref="Clicked"/>
|
||||
/// after consuming the mouse event, instead of the panel subscribing to
|
||||
/// <c>GuiInput</c> from outside, so event propagation is stopped by the
|
||||
/// control that actually owns the click.
|
||||
/// </summary>
|
||||
public partial class InventoryItemBehaviour : ListItem<IItemContainer>
|
||||
{
|
||||
/// <summary>Raised on a left-click after the cell consumed the event. The payload is the cell itself.</summary>
|
||||
public event Action<InventoryItemBehaviour> 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;
|
||||
|
||||
/// <summary>Stops mouse propagation and wires hover/click handling before any model is bound.</summary>
|
||||
public InventoryItemBehaviour()
|
||||
{
|
||||
MouseFilter = MouseFilterEnum.Stop;
|
||||
GuiInput += OnGuiInput;
|
||||
MouseEntered += OnMouseEntered;
|
||||
MouseExited += OnMouseExited;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds the item stack and lazily builds the visual tree on first
|
||||
/// use; repeated binds only refresh the rendered data.
|
||||
/// </summary>
|
||||
public override void Construct(IItemContainer model)
|
||||
{
|
||||
base.Construct(model);
|
||||
EnsureBuilt();
|
||||
UpdateInfo();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Switches the frame to the gold selected state; the panel owns this flag.</summary>
|
||||
public void SetSelected(bool selected)
|
||||
{
|
||||
_selected = selected;
|
||||
ApplyFrame();
|
||||
}
|
||||
|
||||
/// <summary>Removes the cell from the scene tree; the grid rebuilds cells instead of recycling them.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Core.UI.Inventory
|
||||
{
|
||||
/// <summary>
|
||||
/// 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. <see cref="ItemType"/> 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
|
||||
/// <see cref="BadgeOrder"/> defines that priority consistently.
|
||||
/// A custom verb can be supplied by mods through the item's
|
||||
/// <c>Properties</c> bag under the <c>verb</c> key; it is treated as a
|
||||
/// locale key when it contains a slash and as a literal otherwise.
|
||||
/// </summary>
|
||||
public static class InventoryItemUi
|
||||
{
|
||||
/// <summary>Badge order from most narrative to most technical flag.</summary>
|
||||
public static readonly IReadOnlyList<ItemType> BadgeOrder = new[]
|
||||
{
|
||||
ItemType.Quest,
|
||||
ItemType.Used,
|
||||
ItemType.TargetUsed,
|
||||
ItemType.Food,
|
||||
ItemType.Resource,
|
||||
ItemType.Recipe,
|
||||
ItemType.CantDrop,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <c>InventoryPanelBehaviour</c> use-button rule: an item is
|
||||
/// usable only when it carries an action and one of the "used" flags.
|
||||
/// Food or resources without an <see cref="IItemAction"/> are inert.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>False only for <see cref="ItemType.CantDrop"/> items (quest objectives, key items).</summary>
|
||||
public static bool CanDrop(IItem item)
|
||||
{
|
||||
return item != null && !item.ItemType.HasFlag(ItemType.CantDrop);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locale key (or literal) of the verb shown on the action button.
|
||||
/// Resolution order: mod-provided <c>verb</c> property, recipe,
|
||||
/// targeted, food, generic use.
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Short corner tag for the grid cell. Only actionable items get a
|
||||
/// tag — an inert resource must not advertise a verb it cannot run.
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accent colour of an item: quest gold dominates, then recipe,
|
||||
/// food, targeted, used and finally plain resources.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Locale key of the badge caption for a single type flag.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Translates a locale key, falling back to the key itself for unknown keys and literals.</summary>
|
||||
public static string Translate(string keyOrLiteral)
|
||||
{
|
||||
if (string.IsNullOrEmpty(keyOrLiteral))
|
||||
return string.Empty;
|
||||
return TranslationServer.Translate(keyOrLiteral).ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "Inventory Test",
|
||||
"description": "Integration test mod: drives the inventory GUI end-to-end (open panel, seed items via public API, verify grid, click a cell, search filter, close) and reports [TEST_RESULT]. Activated by --itest=inventory user arg.",
|
||||
"sources": [
|
||||
"src/*.cs"
|
||||
],
|
||||
"depends": [
|
||||
"visual_novel_core>=1"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Bootstrap;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.UI;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Mods.InventoryTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Драйвер интеграционного GUI-теста инвентаря. Проходит по реальному UI
|
||||
/// теми же контрактами, что и игрок: открывает панель через публичный
|
||||
/// <see cref="UiRegistry.Toggle"/> (тот же путь, что кнопка тулбара),
|
||||
/// сеет детерминированные предметы через публичный <c>IInventory.Add</c>,
|
||||
/// кликает ячейку эмуляцией Godot-сигнала <c>gui_input</c> (обработчик
|
||||
/// ячейки подписан на него — мод не может переопределять виртуальные
|
||||
/// методы, но сигналы из мод-сборок работают), фильтрует через
|
||||
/// <c>LineEdit</c>-сигнал <c>text_changed</c> и закрывает панель.
|
||||
///
|
||||
/// Машина состояний: один шаг за тик таймера (0.4с), чтобы отложенные
|
||||
/// пересборки сетки успевали примениться. Каждый шаг — отдельная
|
||||
/// проверка: исход фиксируется, провал не прерывает остальные. Итог —
|
||||
/// контракт раннера <c>[TEST_RESULT] name=inventory_test passed=N
|
||||
/// failed=M</c> и код выхода процесса. Общий watchdog
|
||||
/// (<c>--timeout=</c>, по умолчанию 60с) гасит зависание с failed=1 —
|
||||
/// тем же контрактом, что и у DialogAutoDriverNode.
|
||||
/// </summary>
|
||||
public partial class InventoryTestDriverNode : Node
|
||||
{
|
||||
private const string ResultName = "inventory_test";
|
||||
private const string PanelEntryId = "inventory";
|
||||
private const float TickSeconds = 0.4f;
|
||||
private const float DefaultTimeoutSeconds = 60f;
|
||||
|
||||
private readonly float? timeoutSeconds;
|
||||
private readonly List<string> failures = new();
|
||||
private int passed;
|
||||
private int failed;
|
||||
private int step;
|
||||
private double elapsed;
|
||||
|
||||
private Control panel;
|
||||
private Control gridContent;
|
||||
private LineEdit search;
|
||||
|
||||
/// <summary>Создаёт драйвер с watchdog-таймаутом (null — дефолт 60с) и ставит тикающий таймер-ребёнок.</summary>
|
||||
public InventoryTestDriverNode(float? timeoutSeconds)
|
||||
{
|
||||
this.timeoutSeconds = timeoutSeconds;
|
||||
var timer = new Timer
|
||||
{
|
||||
Name = "TickTimer",
|
||||
WaitTime = TickSeconds,
|
||||
Autostart = true,
|
||||
OneShot = false
|
||||
};
|
||||
AddChild(timer);
|
||||
timer.Timeout += Tick;
|
||||
}
|
||||
|
||||
/// <summary>Один шаг машины состояний: ожидание готовности, затем проверки по очереди.</summary>
|
||||
private void Tick()
|
||||
{
|
||||
if (step < 0)
|
||||
return; // тест уже завершён
|
||||
|
||||
elapsed += TickSeconds;
|
||||
var limit = timeoutSeconds ?? DefaultTimeoutSeconds;
|
||||
if (elapsed >= limit)
|
||||
{
|
||||
Finish($"timeout ({limit:0}s) reached at step {step}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
RunStep();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
failures.Add($"step {step}: unhandled {ex.GetType().Name}: {ex.Message}");
|
||||
failed++;
|
||||
Log($"[FAIL] step {step}: {ex.Message}");
|
||||
Finish(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void RunStep()
|
||||
{
|
||||
// Шаг 0: ждём готовности — бут завершён и сцена вошла в дерево.
|
||||
if (step == 0)
|
||||
{
|
||||
var ready = Boot.IsReady
|
||||
&& GameData.Instance?.Runtime != null
|
||||
&& (Godot.Engine.GetMainLoop() as SceneTree)?.CurrentScene != null;
|
||||
if (!ready)
|
||||
return; // ждём дальше, watchdog страхует
|
||||
Log("environment ready (boot complete, scene entered)");
|
||||
Next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 1: UI-запись инвентаря зарегистрирована модом visual_novel_core.
|
||||
if (step == 1)
|
||||
{
|
||||
Check("entry registered", () =>
|
||||
{
|
||||
var entry = AppServices.Current.Ui.GetById(PanelEntryId);
|
||||
if (entry == null)
|
||||
throw new CheckException($"ui entry '{PanelEntryId}' not found");
|
||||
});
|
||||
Next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 2: сеем детерминированные предметы через публичный API
|
||||
// инвентаря (в этой сессии; сохранения тест не пишет).
|
||||
if (step == 2)
|
||||
{
|
||||
Check("inventory seed via IInventory.Add", () =>
|
||||
{
|
||||
var inv = GameData.Instance.Runtime.Inventory;
|
||||
if (inv == null)
|
||||
throw new CheckException("Runtime.Inventory is null (items mod factory missing?)");
|
||||
inv.Add("food/ration", 3);
|
||||
inv.Add("food/wolf_meat", 2);
|
||||
if (inv.CheckCount("food/ration") < 3)
|
||||
throw new CheckException("food/ration count < 3 after Add");
|
||||
if (inv.CheckCount("food/wolf_meat") < 2)
|
||||
throw new CheckException("food/wolf_meat count < 2 after Add");
|
||||
});
|
||||
Next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 3: открываем панель тем же вызовом, что и кнопка тулбара.
|
||||
if (step == 3)
|
||||
{
|
||||
Check("open via UiRegistry.Toggle", () =>
|
||||
{
|
||||
UiRegistry.Toggle(PanelEntryId);
|
||||
if (!UiHost.Instance.TryGetInstance(PanelEntryId, out panel) || panel == null)
|
||||
throw new CheckException("panel instance not created by Toggle");
|
||||
if (!panel.Visible)
|
||||
throw new CheckException("panel not visible after Toggle");
|
||||
});
|
||||
Next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 4: сетка отрисовала засеянные предметы.
|
||||
if (step == 4)
|
||||
{
|
||||
Check("grid renders seeded items", () =>
|
||||
{
|
||||
gridContent = panel.FindChild("GridContent", true, false) as Control;
|
||||
if (gridContent == null)
|
||||
throw new CheckException("GridContent node not found");
|
||||
// Один кадр на пересборку после Add: если ячейки ещё не
|
||||
// успели, тик-ждать нельзя (шаг уже выполняется), поэтому
|
||||
// сравниваем с запасом на асинхронность через событие Change,
|
||||
// которое панель обрабатывает синхронно.
|
||||
if (gridContent.GetChildCount() < 2)
|
||||
throw new CheckException($"grid has {gridContent.GetChildCount()} cell(s), expected >= 2");
|
||||
});
|
||||
Next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 5: клик по первой ячейке — эмуляция gui_input, как будто
|
||||
// щёлкнул игрок; панель выбирает предмет и наполняет паспорт.
|
||||
if (step == 5)
|
||||
{
|
||||
Check("cell click fills passport", () =>
|
||||
{
|
||||
var cell = gridContent.GetChildCount() > 0 ? gridContent.GetChild(0) as Control : null;
|
||||
if (cell == null)
|
||||
throw new CheckException("no cell to click");
|
||||
var click = new InputEventMouseButton
|
||||
{
|
||||
ButtonIndex = MouseButton.Left,
|
||||
Pressed = true
|
||||
};
|
||||
cell.EmitSignal("gui_input", click);
|
||||
|
||||
var passport = panel.FindChild("Passport", true, false);
|
||||
if (passport == null)
|
||||
throw new CheckException("Passport node not found");
|
||||
var hasText = false;
|
||||
foreach (var child in passport.FindChildren("*", "Label", true, false))
|
||||
{
|
||||
if (child is Label l && !string.IsNullOrWhiteSpace(l.Text))
|
||||
{
|
||||
hasText = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasText)
|
||||
throw new CheckException("passport has no non-empty label after selection");
|
||||
});
|
||||
Next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 6: поиск-фильтр через сигнал LineEdit. Старые ячейки сетка
|
||||
// удаляет через QueueFree — они исчезают лишь в конце кадра, поэтому
|
||||
// в этом же тике проверяем синхронный индикатор ShownLabel
|
||||
// ("показано 0"), а количество нод — на следующем тике.
|
||||
if (step == 6)
|
||||
{
|
||||
Check("no-match filter zeroes the shown counter", () =>
|
||||
{
|
||||
search = panel.FindChild("Search", true, false) as LineEdit;
|
||||
if (search == null)
|
||||
throw new CheckException("Search LineEdit not found");
|
||||
|
||||
search.Text = "zzz_no_such_item";
|
||||
search.EmitSignal("text_changed", search.Text);
|
||||
|
||||
var shown = panel.FindChild("ShownLabel", true, false) as Label;
|
||||
var text = shown?.Text ?? "";
|
||||
if (!text.Contains("0"))
|
||||
throw new CheckException($"ShownLabel '{text}' does not show zero after no-match filter");
|
||||
});
|
||||
Next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 7: кадр прошёл — ячейки после no-match фильтра реально
|
||||
// удалены из дерева; затем возвращаем фильтр в пустое значение.
|
||||
if (step == 7)
|
||||
{
|
||||
Check("grid empties after filter, restores after reset", () =>
|
||||
{
|
||||
var after = gridContent.GetChildCount();
|
||||
if (after != 0)
|
||||
throw new CheckException($"grid has {after} cell(s) after no-match filter, expected 0");
|
||||
|
||||
search.Text = "";
|
||||
search.EmitSignal("text_changed", search.Text);
|
||||
});
|
||||
Next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 8: сетка восстановлена; повторный Toggle закрывает панель.
|
||||
if (step == 8)
|
||||
{
|
||||
Check("grid restored and second Toggle closes", () =>
|
||||
{
|
||||
var restored = gridContent.GetChildCount();
|
||||
if (restored < 2)
|
||||
throw new CheckException($"grid has {restored} cell(s) after filter reset, expected >= 2");
|
||||
|
||||
UiRegistry.Toggle(PanelEntryId);
|
||||
if (panel.Visible)
|
||||
throw new CheckException("panel still visible after second Toggle");
|
||||
});
|
||||
Finish(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void Next()
|
||||
{
|
||||
step++;
|
||||
}
|
||||
|
||||
private void Check(string title, System.Action body)
|
||||
{
|
||||
var sw = Godot.Time.GetTicksUsec();
|
||||
try
|
||||
{
|
||||
body();
|
||||
passed++;
|
||||
Log($"[PASS] {title} ({(Godot.Time.GetTicksUsec() - sw) / 1000.0:0.0} ms)");
|
||||
}
|
||||
catch (CheckException ex)
|
||||
{
|
||||
failed++;
|
||||
failures.Add($"{title}: {ex.Message}");
|
||||
Log($"[FAIL] {title}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Печатает итог и завершает процесс; после вызова шаги не выполняются.</summary>
|
||||
private void Finish(string reason)
|
||||
{
|
||||
if (step < 0)
|
||||
return;
|
||||
step = -1;
|
||||
|
||||
if (reason != null)
|
||||
{
|
||||
failed++;
|
||||
Log($"[FAIL] {reason}");
|
||||
}
|
||||
foreach (var f in failures)
|
||||
Log($" failed: {f}");
|
||||
Log($"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");
|
||||
GetTree().Quit(failed == 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
private void Log(string message)
|
||||
{
|
||||
GameLogger.Log("TEST", $"InventoryTest: {message}");
|
||||
}
|
||||
|
||||
/// <summary>Отличает ожидаемую проверку от неожиданного исключения шага.</summary>
|
||||
private sealed class CheckException : System.Exception
|
||||
{
|
||||
public CheckException(string message) : base(message) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Mods.InventoryTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Точка входа интеграционного теста инвентаря (прецедент live2d_test:
|
||||
/// мод сам везёт драйвер и сам печатает [TEST_RESULT]). Активация —
|
||||
/// только по явному пользовательскому флагу <c>--itest=inventory</c>
|
||||
/// (user-args после --), чтобы мод никогда не вмешивался в обычную игру и
|
||||
/// в другие смоук-прогоны. Само переключение сцен выполняет ядро
|
||||
/// (<c>--scene=</c>), драйверу остаётся только дождаться готового шелла.
|
||||
/// Драйвер ставится в корень дерева отложенно: <c>OnModResourcesReady</c>
|
||||
/// срабатывает в фазе Scenes, до окончания бута и до входа в сцену.
|
||||
/// </summary>
|
||||
public class InventoryTestInitializer : IModInitializer
|
||||
{
|
||||
private static bool driverQueued;
|
||||
|
||||
/// <summary>Per-mod инициализация не требуется — драйвер ставится на resources-ready.</summary>
|
||||
public void OnModLoaded(string modId) { }
|
||||
|
||||
/// <summary>
|
||||
/// Разбирает user-args; при активационном флаге добавляет драйвер
|
||||
/// теста в корень дерева. Повторный <c>ModManager.Reload()</c> не
|
||||
/// должен плодить драйверы — защищено статическим флагом.
|
||||
/// </summary>
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
if (driverQueued)
|
||||
return;
|
||||
|
||||
var args = OS.GetCmdlineUserArgs();
|
||||
// Ровно свой флаг: широкая проверка StartsWith("--itest=") активировала
|
||||
// бы этот тест и при запуске чужого itest-мода, а его драйвер квитит
|
||||
// процесс в конце — второй тест не успевал бы отработать.
|
||||
if (!args.Any(a => a == "--itest=inventory"))
|
||||
return;
|
||||
|
||||
var timeout = ParseTimeout(args);
|
||||
driverQueued = true;
|
||||
GameLogger.Log("TEST", $"InventoryTestInitializer: activating inventory GUI test, timeout={timeout?.ToString() ?? "none"}s");
|
||||
|
||||
// Deferred: сцена ещё не готова; драйвер сам ждёт Boot.IsReady и
|
||||
// появления CurrentScene, поэтому порядок с загрузкой сцены не важен.
|
||||
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new InventoryTestDriverNode(timeout));
|
||||
}
|
||||
|
||||
private static float? ParseTimeout(string[] args)
|
||||
{
|
||||
var raw = args
|
||||
.Where(a => a.StartsWith("--timeout=", System.StringComparison.Ordinal))
|
||||
.Select(a => a.Substring("--timeout=".Length))
|
||||
.FirstOrDefault();
|
||||
return float.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture, out var t) && t > 0
|
||||
? t
|
||||
: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user