Compare commits
6
Commits
ff082d9ea7
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8adcbc9c6d | ||
|
|
468e393550 | ||
|
|
d560638501 | ||
|
|
6bb47971a3 | ||
|
|
d29df6dab6 | ||
|
|
75c9d51cd0 |
No files matched your search
@@ -0,0 +1,30 @@
|
||||
# Line endings: LF everywhere, on every OS, whatever core.autocrlf says.
|
||||
* text=auto eol=lf
|
||||
|
||||
# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF.
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# Binaries: never converted, never diffed as text.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.webp binary
|
||||
*.svg text
|
||||
*.ogg binary
|
||||
*.wav binary
|
||||
*.mp3 binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.dll binary
|
||||
*.so binary
|
||||
*.dylib binary
|
||||
*.pdb binary
|
||||
*.exe binary
|
||||
*.zip binary
|
||||
*.pck binary
|
||||
*.moc3 binary
|
||||
*.res binary
|
||||
*.scn binary
|
||||
+18
-1
@@ -8,8 +8,25 @@
|
||||
"sources": [
|
||||
"src/**/*.cs"
|
||||
],
|
||||
"depends": ["base2d>=1", "interface>=1"],
|
||||
"test_sources": [
|
||||
"tests/*.cs"
|
||||
],
|
||||
"depends": ["shell2d_base>=1", "ui_base>=1"],
|
||||
"test_suites": [
|
||||
{"id": "inventory", "activate": ["--itest=inventory"]}
|
||||
],
|
||||
"ui": [
|
||||
{
|
||||
"id": "inventory",
|
||||
"kind": "panel",
|
||||
"slot": "ModBeforeUIPanel",
|
||||
"scene_path": "scenes/ui/inventory_panel.tscn",
|
||||
"button": {
|
||||
"icon": "bag",
|
||||
"locale": "ui/inventory/title",
|
||||
"index": 20
|
||||
},
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://mods/items_base/src/ui/InventoryItemBehaviour.cs" id="1"]
|
||||
|
||||
[node name="InventoryItem" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 0
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
script = ExtResource("1")
|
||||
@@ -0,0 +1,152 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://mods/items_base/src/ui/InventoryPanelBehaviour.cs" id="1_script"]
|
||||
|
||||
[node name="InventoryPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 1
|
||||
script = ExtResource("1_script")
|
||||
|
||||
[node name="Backdrop" type="ColorRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Panel" type="Panel" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 40.0
|
||||
offset_top = 78.0
|
||||
offset_right = -40.0
|
||||
offset_bottom = -30.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 1
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="Panel"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/margin_left = 24
|
||||
theme_override_constants/margin_top = 16
|
||||
theme_override_constants/margin_right = 24
|
||||
theme_override_constants/margin_bottom = 14
|
||||
mouse_filter = 1
|
||||
|
||||
[node name="Root" type="VBoxContainer" parent="Panel/Margin"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
mouse_filter = 1
|
||||
|
||||
[node name="Header" type="HBoxContainer" parent="Panel/Margin/Root"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 18
|
||||
|
||||
[node name="Title" type="Label" parent="Panel/Margin/Root/Header"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 24
|
||||
|
||||
[node name="Tabs" type="HBoxContainer" parent="Panel/Margin/Root/Header"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="HeaderSpacer" type="Control" parent="Panel/Margin/Root/Header"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="ApBox" type="HBoxContainer" parent="Panel/Margin/Root/Header"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="ApLabel" type="Label" parent="Panel/Margin/Root/Header/ApBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 11
|
||||
|
||||
[node name="ApPips" type="HBoxContainer" parent="Panel/Margin/Root/Header/ApBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 5
|
||||
|
||||
[node name="CloseButton" type="Button" parent="Panel/Margin/Root/Header"]
|
||||
custom_minimum_size = Vector2(38, 38)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "✕"
|
||||
|
||||
[node name="Rule" type="Panel" parent="Panel/Margin/Root"]
|
||||
custom_minimum_size = Vector2(0, 1)
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Toolbar" type="HBoxContainer" parent="Panel/Margin/Root"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="Search" type="LineEdit" parent="Panel/Margin/Root/Toolbar"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 13
|
||||
|
||||
[node name="SortOption" type="OptionButton" parent="Panel/Margin/Root/Toolbar"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
|
||||
[node name="Body" type="HBoxContainer" parent="Panel/Margin/Root"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 14
|
||||
|
||||
[node name="Left" type="VBoxContainer" parent="Panel/Margin/Root/Body"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="GridScroll" type="ScrollContainer" parent="Panel/Margin/Root/Body/Left"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="GridContent" type="Control" parent="Panel/Margin/Root/Body/Left/GridScroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
mouse_filter = 0
|
||||
|
||||
[node name="GridFooter" type="HBoxContainer" parent="Panel/Margin/Root/Body/Left"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="ShownLabel" type="Label" parent="Panel/Margin/Root/Body/Left/GridFooter"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 12
|
||||
|
||||
[node name="HintLabel" type="Label" parent="Panel/Margin/Root/Body/Left/GridFooter"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 12
|
||||
|
||||
[node name="Right" type="PanelContainer" parent="Panel/Margin/Root/Body"]
|
||||
custom_minimum_size = Vector2(430, 0)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="PassportScroll" type="ScrollContainer" parent="Panel/Margin/Root/Body/Right"]
|
||||
layout_mode = 2
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="Passport" type="VBoxContainer" parent="Panel/Margin/Root/Body/Right/PassportScroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 14
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Core.Settings;
|
||||
|
||||
namespace Cthangover.Mods.Items
|
||||
{
|
||||
/// <summary>
|
||||
/// Restores health of the chosen party member (<c>"ItemAction": "action/heal"</c>).
|
||||
/// The amount is item data, not code: the item's <c>"heal"</c> key (an extra JSON
|
||||
/// key, so it lands in <see cref="IItem.Properties"/>), which lets one action serve
|
||||
/// a mushroom and an elixir alike. Health never rises above its base value, and a
|
||||
/// fallen character (health 0) is not revived — that is a different effect.
|
||||
/// The party member's attributes are the very objects the battle uses, so a heal
|
||||
/// in the inventory during a battle shows on the battle widgets at once.
|
||||
/// </summary>
|
||||
public class HealItemAction : ITargetedItemAction
|
||||
{
|
||||
/// <summary>Item property with the amount of health restored.</summary>
|
||||
public const string HealProperty = "heal";
|
||||
|
||||
/// <summary>Action id referenced by item definitions.</summary>
|
||||
public string ID => "action/heal";
|
||||
|
||||
/// <summary>Healing always needs a target; without one nothing happens.</summary>
|
||||
public bool UseAction(IItem item) => false;
|
||||
|
||||
/// <summary>
|
||||
/// Adds the item's <c>heal</c> amount to the target's health, capped at the
|
||||
/// base value. Returns <c>false</c> (item not spent) for a missing target, a
|
||||
/// non-positive amount, a fallen target or one already at full health.
|
||||
/// </summary>
|
||||
public bool UseOn(IItem item, CharacterInfoData target)
|
||||
{
|
||||
var health = target?.Attributes?.Health;
|
||||
var amount = item?.Properties?.GetInt(HealProperty) ?? 0;
|
||||
if (health == null || amount <= 0 || health.Value <= 0 || health.Value >= health.BaseValue)
|
||||
return false;
|
||||
|
||||
health.Value = Math.Min(health.BaseValue, health.Value + amount);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Core.Settings;
|
||||
|
||||
namespace Cthangover.Mods.Items
|
||||
{
|
||||
/// <summary>
|
||||
/// Item action that needs to know whom it is applied to. The core
|
||||
/// <see cref="IItemAction"/> contract has no target, so an effect on a party
|
||||
/// member (healing) could not tell which member was picked in
|
||||
/// <see cref="Cthangover.Core.UI.Inventory.CharacterTargetPanel"/>. Items with
|
||||
/// <see cref="ItemType.TargetUsed"/> whose action implements this interface get
|
||||
/// <see cref="UseOn"/> with the chosen character; other actions keep receiving
|
||||
/// the plain <see cref="IItemAction.UseAction"/>.
|
||||
/// </summary>
|
||||
public interface ITargetedItemAction : IItemAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies the item to <paramref name="target"/>. Returns <c>false</c> when
|
||||
/// nothing happened (e.g. healing at full health), so the item is not spent.
|
||||
/// </summary>
|
||||
bool UseOn(IItem item, CharacterInfoData target);
|
||||
}
|
||||
}
|
||||
+19
-29
@@ -30,37 +30,17 @@ namespace Cthangover.Core.Items
|
||||
{
|
||||
/// <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>
|
||||
/// Stacks a loaded save names but no loaded mod defines (the mod was
|
||||
/// removed or the id renamed). They are no usable stacks — every
|
||||
/// container in <see cref="Items"/> has an item — but the items slot
|
||||
/// writes them back, so they return with their mod.
|
||||
/// </summary>
|
||||
public List<Settings.CItem> UnresolvedItems { get; } = 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)
|
||||
{
|
||||
@@ -74,7 +54,7 @@ namespace Cthangover.Core.Items
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return false;
|
||||
return Items.Any(o => o.Item.ID == id);
|
||||
return Items.Any(o => o.Item != null && o.Item.ID == id);
|
||||
}
|
||||
|
||||
/// <summary>Returns the slot holding the given item, or <c>null</c> (null-safe).</summary>
|
||||
@@ -124,6 +104,12 @@ namespace Cthangover.Core.Items
|
||||
/// </summary>
|
||||
public void Add(string id, int count = 1)
|
||||
{
|
||||
if (count <= 0)
|
||||
{
|
||||
GameLogger.Log("INVENTORY", $"Add('{id}', {count}): count must be positive, ignored", LogLevel.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var container = TryGet(id);
|
||||
if (container != null)
|
||||
{
|
||||
@@ -161,6 +147,10 @@ namespace Cthangover.Core.Items
|
||||
/// </summary>
|
||||
public bool Remove(string id, int count = 1)
|
||||
{
|
||||
// A negative count would pass the stock check and grow the stack.
|
||||
if (count <= 0)
|
||||
return false;
|
||||
|
||||
var container = TryGet(id);
|
||||
if (container == null)
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using Cthangover.Core.Actions;
|
||||
|
||||
namespace Cthangover.Mods.Items
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>action item.add id=resource/branches [count=3]</c> — puts items into the
|
||||
/// player's inventory from a scenario: a chest, a gift, a pickup on an RPG map.
|
||||
/// <c>count</c> defaults to 1; an unknown item id is reported by the inventory
|
||||
/// and adds nothing.
|
||||
/// </summary>
|
||||
public class ItemAddAction : IScenarioAction
|
||||
{
|
||||
/// <summary>DSL name.</summary>
|
||||
public string Name => "item.add";
|
||||
|
||||
/// <summary>Reads <c>id</c> and <c>count</c> and adds the stack.</summary>
|
||||
public void Run(IActionContext ctx)
|
||||
{
|
||||
var id = ctx.GetParam("id");
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
ctx.LogWarning("INVENTORY", "item.add: missing 'id'");
|
||||
return;
|
||||
}
|
||||
|
||||
var countText = ctx.GetParam("count");
|
||||
var count = 1;
|
||||
if (!string.IsNullOrWhiteSpace(countText) && (!int.TryParse(countText, out count) || count <= 0))
|
||||
{
|
||||
ctx.LogWarning("INVENTORY", $"item.add: 'count' must be a positive number, got '{countText}'");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.Inventory == null)
|
||||
{
|
||||
ctx.LogWarning("INVENTORY", "item.add: no inventory in this game");
|
||||
return;
|
||||
}
|
||||
ctx.Inventory.Add(id.Trim(), count);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-12
@@ -1,6 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
@@ -17,8 +15,6 @@ namespace Cthangover.Core.Factories.Impls
|
||||
/// <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
|
||||
@@ -31,14 +27,9 @@ namespace Cthangover.Core.Factories.Impls
|
||||
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;
|
||||
// Entities.Collect is cached by the entity resolver until the next
|
||||
// mod reload, so reading it every time serves the current data.
|
||||
var infos = Entities.Collect<ItemInfo>();
|
||||
|
||||
if (!infos.TryGetValue(id, out var info))
|
||||
{
|
||||
|
||||
+23
-7
@@ -4,6 +4,7 @@ using Cthangover.Core.Factories.Impls;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
namespace Cthangover.Mods.Items
|
||||
{
|
||||
@@ -28,8 +29,13 @@ namespace Cthangover.Mods.Items
|
||||
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());
|
||||
var items = runtime.Inventory.Items
|
||||
.Where(o => o.Item != null)
|
||||
.Select(o => new CItem { ID = o.Item.ID, Count = o.Count })
|
||||
.ToList();
|
||||
if (runtime.Inventory is Inventory inventory)
|
||||
items.AddRange(inventory.UnresolvedItems);
|
||||
slot.Data[InventoryBlob] = new DataBlob(1, items);
|
||||
slot.Data[RecipesBlob] = new DataBlob(1, runtime.RecipeData.Data.ToList());
|
||||
}
|
||||
|
||||
@@ -43,11 +49,21 @@ namespace Cthangover.Mods.Items
|
||||
|
||||
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>();
|
||||
var containers = new List<IItemContainer>();
|
||||
var unresolved = (runtime.Inventory as Inventory)?.UnresolvedItems;
|
||||
unresolved?.Clear();
|
||||
foreach (var saved in invBlob.ReadJson<List<CItem>>() ?? new List<CItem>())
|
||||
{
|
||||
var item = ItemFactory.Instance.Get(saved.ID);
|
||||
if (item != null)
|
||||
{
|
||||
containers.Add(new ItemContainer { Item = item, Count = saved.Count });
|
||||
continue;
|
||||
}
|
||||
GameLogger.Log("INVENTORY", $"saved item '{saved.ID}' x{saved.Count} is not defined by any loaded mod; kept in the save", LogLevel.Warning);
|
||||
unresolved?.Add(saved);
|
||||
}
|
||||
runtime.Inventory.Items = containers;
|
||||
}
|
||||
|
||||
if (slot.Data.TryGetValue(RecipesBlob, out var recipeBlob))
|
||||
|
||||
@@ -52,12 +52,13 @@ namespace Cthangover.Core.Factories.Impls
|
||||
/// </summary>
|
||||
private Dictionary<string, RecipeInfo> EnsureInfos()
|
||||
{
|
||||
if (_allInfos != null)
|
||||
return _allInfos;
|
||||
|
||||
// The resolver's dictionary identity is the data snapshot: a new one drops the built recipes.
|
||||
var infos = Entities.Collect<RecipeInfo>();
|
||||
if (ModManager.Instance.IsInitialized)
|
||||
if (!ReferenceEquals(infos, _allInfos))
|
||||
{
|
||||
_allInfos = infos;
|
||||
_cache.Clear();
|
||||
}
|
||||
return infos;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,23 @@ namespace Cthangover.Core.UI.Inventory
|
||||
_count.Modulate = dimmed ? IconDimmed : Colors.White;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides what <see cref="UpdateInfo"/> derived from the item's action,
|
||||
/// for grids where the action is not the point — the trade window shows a
|
||||
/// price in the corner tag (<paramref name="tag"/>, <c>null</c> hides it) and
|
||||
/// dims what cannot be traded. Call after <see cref="Construct"/>.
|
||||
/// </summary>
|
||||
public void Decorate(string tag, bool dimmed)
|
||||
{
|
||||
if (!_built)
|
||||
return;
|
||||
_tag.Visible = tag != null;
|
||||
if (tag != null)
|
||||
_tagLabel.Text = tag;
|
||||
_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)
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
using Cthangover.Mods.Interface.Base.Lists;
|
||||
using Cthangover.Mods.Items;
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
namespace Cthangover.Core.UI.Inventory
|
||||
{
|
||||
@@ -978,7 +979,10 @@ namespace Cthangover.Core.UI.Inventory
|
||||
if (!HasActionPoint())
|
||||
return;
|
||||
|
||||
if (!item.ItemAction.UseAction(item))
|
||||
var applied = item.ItemAction is ITargetedItemAction targeted
|
||||
? targeted.UseOn(item, info)
|
||||
: item.ItemAction.UseAction(item);
|
||||
if (!applied)
|
||||
{
|
||||
SetMessage(MessageKind.Error, InventoryItemUi.Translate("ui/inventory/applied_failed"));
|
||||
_targetOpen = false;
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
"src/*.cs"
|
||||
],
|
||||
"depends": [
|
||||
"visual_novel_core>=1"
|
||||
"cthangover_game>=1"
|
||||
]
|
||||
}
|
||||
@@ -100,7 +100,7 @@ namespace Mods.InventoryTest
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 1: UI-запись инвентаря зарегистрирована модом visual_novel_core.
|
||||
// Шаг 1: UI-запись инвентаря зарегистрирована модом cthangover_game.
|
||||
if (step == 1)
|
||||
{
|
||||
Check("entry registered", () =>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Cthangover.Mods.Items.Tests
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Actions;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Core.Mods;
|
||||
|
||||
/// <summary>Action context for scenario action tests: dialog variables and an inventory, no other services.</summary>
|
||||
public class FakeActionContext : IActionContext
|
||||
{
|
||||
/// <summary>Dialog variables returned by <see cref="GetParam"/>.</summary>
|
||||
public Dictionary<string, string> Params { get; } = new();
|
||||
|
||||
/// <summary>Warnings the action logged.</summary>
|
||||
public List<string> Warnings { get; } = new();
|
||||
|
||||
public IQuestService Quests => null;
|
||||
public ICharacterService Character => null;
|
||||
public ILightingService Lighting => null;
|
||||
public ISceneNodeService Scene => null;
|
||||
public IModRegistry ModRegistry => null;
|
||||
public IInventory Inventory { get; set; }
|
||||
public IMapZoneService Map => null;
|
||||
|
||||
public string GetParam(string name) => Params.TryGetValue(name, out var value) ? value : null;
|
||||
|
||||
public void Log(string category, string message)
|
||||
{
|
||||
}
|
||||
|
||||
public void LogWarning(string category, string message) => Warnings.Add(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace Cthangover.Mods.Items.Tests
|
||||
{
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// <c>action/heal</c>: the amount comes from the item's <c>heal</c> property, health is
|
||||
/// capped at its base value, and an item that does nothing is reported as not used.
|
||||
/// </summary>
|
||||
public class HealItemActionTests
|
||||
{
|
||||
private static Item Food(int heal)
|
||||
{
|
||||
var properties = new PropertyData();
|
||||
properties.SetInt(HealItemAction.HealProperty, heal);
|
||||
return new Item { ID = "test/food", Properties = properties };
|
||||
}
|
||||
|
||||
private static CharacterInfoData Hero(int health, int max)
|
||||
{
|
||||
var attributes = new CharacterAttributes();
|
||||
attributes.Health.Value = health;
|
||||
attributes.Health.BaseValue = max;
|
||||
return new CharacterInfoData { ID = "hero", Attributes = attributes };
|
||||
}
|
||||
|
||||
[ModTest("heal/restores_the_amount")]
|
||||
static void RestoresTheAmount(ModTestContext ctx)
|
||||
{
|
||||
var hero = Hero(10, 24);
|
||||
ModAssert.True(new HealItemAction().UseOn(Food(5), hero), "wounded hero is healed");
|
||||
ModAssert.Equal(15, hero.Attributes.Health.Value, "10 + 5");
|
||||
ModAssert.Equal(24, hero.Attributes.Health.BaseValue, "max unchanged");
|
||||
}
|
||||
|
||||
[ModTest("heal/capped_at_max")]
|
||||
static void CappedAtMax(ModTestContext ctx)
|
||||
{
|
||||
var hero = Hero(22, 24);
|
||||
ModAssert.True(new HealItemAction().UseOn(Food(20), hero), "partly wounded hero is healed");
|
||||
ModAssert.Equal(24, hero.Attributes.Health.Value, "never above max");
|
||||
}
|
||||
|
||||
[ModTest("heal/useless_heal_is_not_used")]
|
||||
static void UselessHealIsNotUsed(ModTestContext ctx)
|
||||
{
|
||||
var action = new HealItemAction();
|
||||
|
||||
var full = Hero(24, 24);
|
||||
ModAssert.False(action.UseOn(Food(5), full), "full health");
|
||||
ModAssert.Equal(24, full.Attributes.Health.Value, "full stays full");
|
||||
|
||||
var fallen = Hero(0, 24);
|
||||
ModAssert.False(action.UseOn(Food(5), fallen), "a fallen hero is not revived");
|
||||
ModAssert.Equal(0, fallen.Attributes.Health.Value, "fallen stays fallen");
|
||||
|
||||
var wounded = Hero(10, 24);
|
||||
ModAssert.False(action.UseOn(Food(0), wounded), "no heal amount");
|
||||
ModAssert.False(action.UseOn(new Item { ID = "plain" }, wounded), "no properties");
|
||||
ModAssert.False(action.UseOn(Food(5), null), "no target");
|
||||
ModAssert.False(action.UseOn(Food(5), new CharacterInfoData { ID = "bare" }), "no attributes");
|
||||
ModAssert.False(action.UseAction(Food(5)), "heal needs a target");
|
||||
ModAssert.Equal(10, wounded.Attributes.Health.Value, "untouched");
|
||||
}
|
||||
|
||||
[ModTest("heal/id")]
|
||||
static void Id(ModTestContext ctx)
|
||||
{
|
||||
ModAssert.Equal("action/heal", new HealItemAction().ID, "item definitions reference this id");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace Cthangover.Mods.Items.Tests
|
||||
{
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Stack arithmetic of <see cref="Inventory"/>: a count that is not
|
||||
/// positive is no operation at all — a negative removal must not grow a
|
||||
/// stack and an addition must not create an empty or negative one.
|
||||
/// </summary>
|
||||
public class InventoryTests
|
||||
{
|
||||
[ModTest("inventory/non_positive_counts_change_nothing")]
|
||||
static void NonPositiveCountsChangeNothing(ModTestContext ctx)
|
||||
{
|
||||
var id = Entities.Collect<ItemInfo>().Keys.First();
|
||||
var inventory = new Inventory();
|
||||
inventory.Add(id, 2);
|
||||
var before = inventory.CheckCount(id);
|
||||
|
||||
ModAssert.False(inventory.Remove(id, -5), "negative removal is refused");
|
||||
ModAssert.False(inventory.Remove(id, 0), "zero removal is refused");
|
||||
inventory.Add(id, -3);
|
||||
inventory.Add(id, 0);
|
||||
ModAssert.Equal(before, inventory.CheckCount(id), "stack count after non-positive operations");
|
||||
|
||||
var other = Entities.Collect<ItemInfo>().Keys.FirstOrDefault(k => !inventory.HasItem(k));
|
||||
if (other == null)
|
||||
return;
|
||||
inventory.Add(other, 0);
|
||||
ModAssert.False(inventory.HasItem(other), "zero addition creates no stack");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace Cthangover.Mods.Items.Tests
|
||||
{
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
|
||||
/// <summary><c>action item.add</c> from a scenario: a count defaults to one, a bad one adds nothing.</summary>
|
||||
public class ItemAddActionTests
|
||||
{
|
||||
private static FakeActionContext Context(string id, string count)
|
||||
{
|
||||
var ctx = new FakeActionContext { Inventory = new Inventory() };
|
||||
if (id != null)
|
||||
ctx.Params["id"] = id;
|
||||
if (count != null)
|
||||
ctx.Params["count"] = count;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
[ModTest("item_add/adds_count")]
|
||||
static void AddsCount(ModTestContext test)
|
||||
{
|
||||
var id = Entities.Collect<ItemInfo>().Keys.First();
|
||||
var ctx = Context(id, "3");
|
||||
var before = ctx.Inventory.CheckCount(id);
|
||||
|
||||
new ItemAddAction().Run(ctx);
|
||||
|
||||
ModAssert.Equal(before + 3, ctx.Inventory.CheckCount(id), "three added");
|
||||
ModAssert.Equal(0, ctx.Warnings.Count, "no warnings");
|
||||
}
|
||||
|
||||
[ModTest("item_add/count_defaults_to_one")]
|
||||
static void CountDefaultsToOne(ModTestContext test)
|
||||
{
|
||||
var id = Entities.Collect<ItemInfo>().Keys.First();
|
||||
var ctx = Context(id, null);
|
||||
var before = ctx.Inventory.CheckCount(id);
|
||||
|
||||
new ItemAddAction().Run(ctx);
|
||||
|
||||
ModAssert.Equal(before + 1, ctx.Inventory.CheckCount(id), "one added");
|
||||
}
|
||||
|
||||
[ModTest("item_add/bad_arguments_add_nothing")]
|
||||
static void BadArgumentsAddNothing(ModTestContext test)
|
||||
{
|
||||
var id = Entities.Collect<ItemInfo>().Keys.First();
|
||||
foreach (var count in new[] { "0", "-2", "many" })
|
||||
{
|
||||
var ctx = Context(id, count);
|
||||
var before = ctx.Inventory.CheckCount(id);
|
||||
new ItemAddAction().Run(ctx);
|
||||
ModAssert.Equal(before, ctx.Inventory.CheckCount(id), "count=" + count);
|
||||
ModAssert.Equal(1, ctx.Warnings.Count, "warned about count=" + count);
|
||||
}
|
||||
|
||||
var noId = Context(null, "1");
|
||||
new ItemAddAction().Run(noId);
|
||||
ModAssert.Equal(1, noId.Warnings.Count, "warned about the missing id");
|
||||
|
||||
var noInventory = new FakeActionContext();
|
||||
noInventory.Params["id"] = id;
|
||||
new ItemAddAction().Run(noInventory);
|
||||
ModAssert.Equal(1, noInventory.Warnings.Count, "warned about the missing inventory");
|
||||
}
|
||||
|
||||
[ModTest("item_add/name")]
|
||||
static void Name(ModTestContext test)
|
||||
{
|
||||
ModAssert.Equal("item.add", new ItemAddAction().Name, "DSL name");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace Cthangover.Mods.Items.Tests
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
using Cthangover.Core.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// The items slot across a change of the game's content: a save may name
|
||||
/// an item no loaded mod defines any more (its mod was removed or the id
|
||||
/// renamed). Loading it must keep the rest of the inventory usable, and
|
||||
/// the next save must still be written — with the unknown stack kept, so
|
||||
/// it comes back with its mod.
|
||||
/// </summary>
|
||||
public class ItemsSaveableTests
|
||||
{
|
||||
private const string UnknownId = "__test/no_such_item";
|
||||
|
||||
[ModTest("save/unknown_item_survives_load_and_save")]
|
||||
static void UnknownItemSurvivesLoadAndSave(ModTestContext ctx)
|
||||
{
|
||||
var known = Entities.Collect<ItemInfo>().Keys.First();
|
||||
var runtime = GameData.Instance.Runtime;
|
||||
var original = runtime.Inventory;
|
||||
try
|
||||
{
|
||||
runtime.Inventory = new Inventory();
|
||||
var slot = new ModSlot();
|
||||
slot.Data["Inventory"] = new DataBlob(1, new List<CItem>
|
||||
{
|
||||
new() { ID = known, Count = 4 },
|
||||
new() { ID = UnknownId, Count = 3 },
|
||||
});
|
||||
var saveable = new ItemsSaveable();
|
||||
|
||||
ModAssert.True(saveable.OnLoad(slot, out _), "slot with an unknown item loads");
|
||||
ModAssert.Equal(4, runtime.Inventory.CheckCount(known), "known stack restored");
|
||||
ModAssert.False(runtime.Inventory.HasItem(UnknownId), "unknown item is not a usable stack");
|
||||
|
||||
var saved = new ModSlot();
|
||||
saveable.OnSave(saved);
|
||||
var items = saved.Data["Inventory"].ReadJson<List<CItem>>();
|
||||
ModAssert.Equal(4, items.Single(i => i.ID == known).Count, "known stack saved");
|
||||
ModAssert.Equal(3, items.Single(i => i.ID == UnknownId).Count, "unknown stack kept in the save");
|
||||
}
|
||||
finally
|
||||
{
|
||||
runtime.Inventory = original;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user