commit b6dd3758cb81ab530a377808fa8963599319cc1b Author: iisaev Date: Sun Oct 18 12:41:37 2026 +0300 init mod diff --git a/characters/actions/physics_attack.json b/characters/actions/physics_attack.json new file mode 100644 index 0000000..5476359 --- /dev/null +++ b/characters/actions/physics_attack.json @@ -0,0 +1,17 @@ +{ + "Kind": "action", + "Items": [ + { + "Id": "physics/attack", + "Name": "characters/action/physics_attack/name", + "Description": "characters/action/physics_attack/description", + "Image": "characters/actions/physics_attack", + "Type": "ToEnemy", + "Values": [ + "RequiredPoint=1", + "Attack=1.0", + "AttackType=Physics" + ] + } + ] +} diff --git a/characters/actions/physics_attack.png b/characters/actions/physics_attack.png new file mode 100644 index 0000000..3c82744 Binary files /dev/null and b/characters/actions/physics_attack.png differ diff --git a/characters/actions/physics_defence.json b/characters/actions/physics_defence.json new file mode 100644 index 0000000..1c59f98 --- /dev/null +++ b/characters/actions/physics_defence.json @@ -0,0 +1,16 @@ +{ + "Kind": "action", + "Items": [ + { + "Id": "physics/defence", + "Name": "characters/action/defence/name", + "Description": "characters/action/defence/description", + "Image": "characters/actions/physics_defence", + "Type": "ToAlias", + "Values": [ + "RequiredPoint=1", + "Defence=4" + ] + } + ] +} diff --git a/characters/actions/physics_defence.png b/characters/actions/physics_defence.png new file mode 100644 index 0000000..2ea0797 Binary files /dev/null and b/characters/actions/physics_defence.png differ diff --git a/characters/actions/physics_stun.json b/characters/actions/physics_stun.json new file mode 100644 index 0000000..a7fc388 --- /dev/null +++ b/characters/actions/physics_stun.json @@ -0,0 +1,17 @@ +{ + "Kind": "action", + "Items": [ + { + "Id": "physics/stun", + "Name": "characters/action/stun/name", + "Description": "characters/action/stun/description", + "Image": "characters/actions/physics_stun", + "Type": "ToEnemy", + "Values": [ + "RequiredPoint=2", + "AttackType=Physics", + "Turn=3" + ] + } + ] +} diff --git a/characters/actions/physics_stun.png b/characters/actions/physics_stun.png new file mode 100644 index 0000000..f97dbc6 Binary files /dev/null and b/characters/actions/physics_stun.png differ diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..fc0dc65 --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,2 @@ +mod_characters_base_name = Characters Engine +mod_characters_base_desc = Engine characters subsystem: character and action-card factories, the avatar factory and the character panel UI. Kernel resolves templates through ICharacterTemplateProvider/IActionTemplateProvider/IAvatarProvider contracts. diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..4c7cbc1 --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,2 @@ +mod_characters_base_name = Движок персонажей +mod_characters_base_desc = Подсистема персонажей движка: фабрики персонажей и боевых карточек, фабрика аватаров и панель персонажа. Ядро получает шаблоны через контракты ICharacterTemplateProvider/IActionTemplateProvider/IAvatarProvider. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..6b2588d --- /dev/null +++ b/manifest.json @@ -0,0 +1,22 @@ +{ + "id": "characters_base", + "name": "Characters Engine", + "version": "1.0.0", + "author": "ct", + "description": "Engine characters subsystem: the character and action-card factories (entity kinds 'character' and 'action'), the avatar factory and the character panel UI. Replaces the former Core/Characters factories and Core/UI/CharacterPanel — the CharactersModInitializer publishes kernel contracts (ICharacterTemplateProvider, IActionTemplateProvider, IAvatarProvider) so core code resolves templates without referencing this assembly.", + "category": "engine, library", + "sources": [ + "src/**/*.cs" + ], + "depends": ["base2d>=1", "interface>=1"], + "ui": [ + { + "id": "characters", + "kind": "panel", + "slot": "ModBeforeUIPanel", + "scene_path": "scenes/ui/character_panel.tscn", + "button": { "icon": "cards", "locale": "ui/character_panel/title", "index": 30 }, + "priority": 10 + } + ] +} diff --git a/scenes/ui/action_card.tscn b/scenes/ui/action_card.tscn new file mode 100644 index 0000000..c75f667 --- /dev/null +++ b/scenes/ui/action_card.tscn @@ -0,0 +1,84 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://mods/characters_base/src/UI/CharacterPanel/ActionCardBehaviour.cs" id="1_script"] + +[node name="ActionCard" type="Control"] +custom_minimum_size = Vector2(0, 86) +layout_mode = 3 +mouse_filter = 0 +script = ExtResource("1_script") + +[node name="Frame" type="Panel" 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="Accent" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 9 +anchor_bottom = 1.0 +offset_right = 4.0 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Icon" type="TextureRect" parent="."] +layout_mode = 1 +anchors_preset = 4 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 8.0 +offset_top = -26.0 +offset_right = 60.0 +offset_bottom = 26.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +expand_mode = 1 +stretch_mode = 5 + +[node name="Body" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 68.0 +offset_top = 6.0 +offset_right = -8.0 +offset_bottom = -6.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +theme_override_constants/separation = 4 + +[node name="NameRow" type="HBoxContainer" parent="Body"] +layout_mode = 2 +mouse_filter = 2 +theme_override_constants/separation = 8 + +[node name="NameLabel" type="Label" parent="Body/NameRow"] +layout_mode = 2 +size_flags_horizontal = 3 +mouse_filter = 2 +clip_text = true +text_overrun_behavior = 3 + +[node name="IdLabel" type="Label" parent="Body/NameRow"] +layout_mode = 2 +mouse_filter = 2 +theme_override_font_sizes/font_size = 9 +vertical_alignment = 1 + +[node name="BadgesRow" type="HBoxContainer" parent="Body"] +layout_mode = 2 +mouse_filter = 2 +theme_override_constants/separation = 5 + +[node name="PropsRow" type="HFlowContainer" parent="Body"] +layout_mode = 2 +mouse_filter = 2 +theme_override_constants/h_separation = 5 +theme_override_constants/v_separation = 3 diff --git a/scenes/ui/action_pool.tscn b/scenes/ui/action_pool.tscn new file mode 100644 index 0000000..27503b4 --- /dev/null +++ b/scenes/ui/action_pool.tscn @@ -0,0 +1,63 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://mods/characters_base/src/UI/CharacterPanel/ActionPoolBehaviour.cs" id="1_script"] + +[node name="ActionPool" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 0 +script = ExtResource("1_script") + +[node name="Root" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 +theme_override_constants/separation = 10 + +[node name="Header" type="HBoxContainer" parent="Root"] +layout_mode = 2 +mouse_filter = 2 +theme_override_constants/separation = 8 + +[node name="Title" type="Label" parent="Root/Header"] +layout_mode = 2 +mouse_filter = 2 + +[node name="CountLabel" type="Label" parent="Root/Header"] +layout_mode = 2 +mouse_filter = 2 + +[node name="SearchEdit" type="LineEdit" parent="Root"] +layout_mode = 2 +theme_override_font_sizes/font_size = 12 + +[node name="TabsRow" type="HFlowContainer" parent="Root"] +layout_mode = 2 +mouse_filter = 2 +theme_override_constants/h_separation = 4 +theme_override_constants/v_separation = 4 + +[node name="Scroll" type="ScrollContainer" parent="Root"] +layout_mode = 2 +size_flags_vertical = 3 +horizontal_scroll_mode = 0 + +[node name="List" type="VBoxContainer" parent="Root/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +mouse_filter = 1 +theme_override_constants/separation = 8 + +[node name="Footer" type="Label" parent="Root"] +layout_mode = 2 +mouse_filter = 2 +theme_override_font_sizes/font_size = 11 +autowrap_mode = 3 diff --git a/scenes/ui/action_slot.tscn b/scenes/ui/action_slot.tscn new file mode 100644 index 0000000..d4c2f24 --- /dev/null +++ b/scenes/ui/action_slot.tscn @@ -0,0 +1,40 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://mods/characters_base/src/UI/CharacterPanel/ActionSlotBehaviour.cs" id="1_script"] + +[node name="ActionSlot" type="Control"] +custom_minimum_size = Vector2(0, 88) +layout_mode = 3 +mouse_filter = 0 +script = ExtResource("1_script") + +[node name="Frame" type="Panel" 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="SlotLabel" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 0 +offset_left = 8.0 +offset_top = 5.0 +offset_right = 120.0 +offset_bottom = 21.0 +mouse_filter = 2 +theme_override_font_sizes/font_size = 9 + +[node name="EmptyLabel" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +theme_override_font_sizes/font_size = 12 +horizontal_alignment = 1 +vertical_alignment = 1 diff --git a/scenes/ui/character_card.tscn b/scenes/ui/character_card.tscn new file mode 100644 index 0000000..0e24cfc --- /dev/null +++ b/scenes/ui/character_card.tscn @@ -0,0 +1,77 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://mods/characters_base/src/UI/CharacterPanel/CharacterCardBehaviour.cs" id="1_script"] +[ext_resource type="PackedScene" path="res://mods/characters_base/scenes/ui/action_slot.tscn" id="2_slot"] + +[node name="CharacterCard" type="PanelContainer"] +custom_minimum_size = Vector2(520, 0) +layout_mode = 3 +mouse_filter = 0 +script = ExtResource("1_script") + +[node name="Margin" type="MarginContainer" parent="."] +layout_mode = 2 +mouse_filter = 2 +theme_override_constants/margin_left = 14 +theme_override_constants/margin_top = 12 +theme_override_constants/margin_right = 14 +theme_override_constants/margin_bottom = 14 + +[node name="Root" type="VBoxContainer" parent="Margin"] +layout_mode = 2 +mouse_filter = 2 +theme_override_constants/separation = 10 + +[node name="Header" type="HBoxContainer" parent="Margin/Root"] +layout_mode = 2 +mouse_filter = 2 +theme_override_constants/separation = 12 + +[node name="PortraitPanel" type="PanelContainer" parent="Margin/Root/Header"] +custom_minimum_size = Vector2(58, 58) +layout_mode = 2 +mouse_filter = 2 + +[node name="Portrait" type="TextureRect" parent="Margin/Root/Header/PortraitPanel"] +layout_mode = 2 +mouse_filter = 2 +expand_mode = 1 +stretch_mode = 6 + +[node name="Info" type="VBoxContainer" parent="Margin/Root/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +mouse_filter = 2 +theme_override_constants/separation = 4 + +[node name="NameLabel" type="Label" parent="Margin/Root/Header/Info"] +layout_mode = 2 +mouse_filter = 2 +theme_override_font_sizes/font_size = 19 +clip_text = true + +[node name="SubLabel" type="Label" parent="Margin/Root/Header/Info"] +layout_mode = 2 +mouse_filter = 2 +theme_override_font_sizes/font_size = 10 +clip_text = true + +[node name="StatsRow" type="HFlowContainer" parent="Margin/Root/Header/Info"] +layout_mode = 2 +mouse_filter = 2 +theme_override_constants/h_separation = 5 +theme_override_constants/v_separation = 3 + +[node name="SlotsBox" type="VBoxContainer" parent="Margin/Root"] +layout_mode = 2 +mouse_filter = 2 +theme_override_constants/separation = 8 + +[node name="Slot0" parent="Margin/Root/SlotsBox" instance=ExtResource("2_slot")] +layout_mode = 2 + +[node name="Slot1" parent="Margin/Root/SlotsBox" instance=ExtResource("2_slot")] +layout_mode = 2 + +[node name="Slot2" parent="Margin/Root/SlotsBox" instance=ExtResource("2_slot")] +layout_mode = 2 diff --git a/scenes/ui/character_panel.tscn b/scenes/ui/character_panel.tscn new file mode 100644 index 0000000..04badd6 --- /dev/null +++ b/scenes/ui/character_panel.tscn @@ -0,0 +1,163 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://mods/characters_base/src/UI/CharacterPanel/CharacterPanelBehaviour.cs" id="1_script"] + +[node name="CharacterPanel" 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 = 70.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 = 14 +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 = 10 +mouse_filter = 1 + +[node name="Header" type="HBoxContainer" parent="Panel/Margin/Root"] +layout_mode = 2 +theme_override_constants/separation = 18 +mouse_filter = 2 + +[node name="Title" type="Label" parent="Panel/Margin/Root/Header"] +layout_mode = 2 +theme_override_font_sizes/font_size = 22 + +[node name="Legend" type="HFlowContainer" parent="Panel/Margin/Root/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/h_separation = 5 +theme_override_constants/v_separation = 4 +mouse_filter = 2 + +[node name="DragHint" type="Label" parent="Panel/Margin/Root/Header"] +layout_mode = 2 +theme_override_font_sizes/font_size = 11 + +[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="Rule1" type="Panel" parent="Panel/Margin/Root"] +custom_minimum_size = Vector2(0, 1) +layout_mode = 2 +mouse_filter = 2 + +[node name="Body" type="HBoxContainer" parent="Panel/Margin/Root"] +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/separation = 14 +mouse_filter = 0 + +[node name="Left" type="VBoxContainer" parent="Panel/Margin/Root/Body"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 8 +mouse_filter = 0 + +[node name="TitleRow" type="HBoxContainer" parent="Panel/Margin/Root/Body/Left"] +layout_mode = 2 +theme_override_constants/separation = 8 +mouse_filter = 2 + +[node name="PartyTitle" type="Label" parent="Panel/Margin/Root/Body/Left/TitleRow"] +layout_mode = 2 +theme_override_font_sizes/font_size = 12 + +[node name="PartyCount" type="Label" parent="Panel/Margin/Root/Body/Left/TitleRow"] +layout_mode = 2 +theme_override_font_sizes/font_size = 11 + +[node name="TitleSpacer" type="Control" parent="Panel/Margin/Root/Body/Left/TitleRow"] +layout_mode = 2 +size_flags_horizontal = 3 +mouse_filter = 2 + +[node name="PartySub" type="Label" parent="Panel/Margin/Root/Body/Left/TitleRow"] +layout_mode = 2 +theme_override_font_sizes/font_size = 11 + +[node name="Scroll" type="ScrollContainer" parent="Panel/Margin/Root/Body/Left"] +layout_mode = 2 +size_flags_vertical = 3 +horizontal_scroll_mode = 0 + +[node name="CharactersContainer" type="HFlowContainer" parent="Panel/Margin/Root/Body/Left/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/h_separation = 14 +theme_override_constants/v_separation = 14 +mouse_filter = 0 + +[node name="Right" type="PanelContainer" parent="Panel/Margin/Root/Body"] +custom_minimum_size = Vector2(420, 0) +layout_mode = 2 + +[node name="ActionPool" type="Control" parent="Panel/Margin/Root/Body/Right"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +mouse_filter = 0 + +[node name="Rule2" type="Panel" parent="Panel/Margin/Root"] +custom_minimum_size = Vector2(0, 1) +layout_mode = 2 +mouse_filter = 2 + +[node name="Footer" type="HBoxContainer" parent="Panel/Margin/Root"] +layout_mode = 2 +theme_override_constants/separation = 10 +mouse_filter = 2 + +[node name="StatusLabel" type="Label" parent="Panel/Margin/Root/Footer"] +layout_mode = 2 +theme_override_font_sizes/font_size = 12 + +[node name="FooterSpacer" type="Control" parent="Panel/Margin/Root/Footer"] +layout_mode = 2 +size_flags_horizontal = 3 +mouse_filter = 2 + +[node name="ResetButton" type="Button" parent="Panel/Margin/Root/Footer"] +layout_mode = 2 +focus_mode = 0 diff --git a/src/Characters/ActionAssignmentReset.cs b/src/Characters/ActionAssignmentReset.cs new file mode 100644 index 0000000..7b2570c --- /dev/null +++ b/src/Characters/ActionAssignmentReset.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Cthangover.Core.Settings; + +namespace Cthangover.Core.Characters +{ + /// + /// Implements the character panel's "reset assignments" operation: every + /// party member's action slots go back to the first three actions of their + /// factory template, and template actions beyond the third are pushed into + /// the shared action pool. + /// + /// The critical invariant is that awarded cards survive the reset. Cards + /// can enter the pool from sources that have no template entry at all — + /// a skill granted through being the + /// canonical example — and once such a card is assigned to a slot it is + /// absent from the pool. Rebuilding the slots without first returning the + /// old slot contents to the pool would silently destroy it. Therefore all + /// non-empty slot IDs are collected before the slots are overwritten; + /// template IDs are filtered back out afterwards, so the pool ends up + /// holding exactly the awarded cards that are no longer assigned. + /// + /// The template lookup is injected as a delegate because the production + /// caller resolves it through CharacterFactory (a mod-aware JSON + /// factory), while tests can supply plain objects. + /// + public static class ActionAssignmentReset + { + /// + /// Rewrites every battle-set character's + /// from its template and replaces + /// with the surviving pool plus recovered awarded cards. + /// + /// Roster whose battle set and slots are reset. + /// Shared action pool; may be null, in which case pool contents are recomputed but not stored. + /// Resolves a character ID to its factory template; may return null for unknown IDs. + public static void Apply( + CharacterData characterData, + ActionPoolData actionPool, + Func resolveTemplate) + { + if (characterData?.BattleSet == null || characterData.Characters == null) + return; + + var extraPoolIds = new HashSet(actionPool?.ActionIds ?? new List()); + var assignedAfterReset = new HashSet(); + + foreach (var characterId in characterData.BattleSet) + { + if (!characterData.Characters.TryGetValue(characterId, out var info)) + continue; + + if (info.ActionSlots != null) + foreach (var slotId in info.ActionSlots) + if (!string.IsNullOrEmpty(slotId)) + extraPoolIds.Add(slotId); + + var defaultActionIds = resolveTemplate?.Invoke(characterId)?.Actions? + .Select(action => action.ID).ToList() ?? new List(); + + var slots = new List(); + for (var i = 0; i < 3; i++) + { + if (i < defaultActionIds.Count) + { + slots.Add(defaultActionIds[i]); + assignedAfterReset.Add(defaultActionIds[i]); + } + else + { + slots.Add(null); + } + } + + for (var i = 3; i < defaultActionIds.Count; i++) + extraPoolIds.Add(defaultActionIds[i]); + + info.ActionSlots = slots; + } + + extraPoolIds.ExceptWith(assignedAfterReset); + if (actionPool != null) + actionPool.ActionIds = extraPoolIds.ToList(); + } + } +} diff --git a/src/Characters/ActionCharacterActiveExtension.cs b/src/Characters/ActionCharacterActiveExtension.cs new file mode 100644 index 0000000..d35e5e1 --- /dev/null +++ b/src/Characters/ActionCharacterActiveExtension.cs @@ -0,0 +1,63 @@ +namespace Cthangover.Core.Characters +{ + /// + /// Cooldown helpers for cards. + /// Active cards are one-shot abilities usable outside battle (from the + /// character panel). The last-use timestamp is stored in + /// under + /// 'LastUsed' - the cooldown duration + /// (seconds) under 'ActiveCooldown' + /// Absence of LastUsed means the card has never been activated. + /// + public static class ActionCharacterActiveExtension + { + /// + /// Returns true when the card is + /// and its cooldown has expired (or it was never used). + /// + public static bool IsReadyToUse(this ActionCharacter action, long currentTime) + { + if (action.Type != ActionCharacterType.Active) + return false; + + var lastUsed = action.Properties.GetLong("LastUsed", 0); + var cooldown = action.GetInt("ActiveCooldown", 0); + + if (lastUsed == 0) + return true; + if (cooldown <= 0) + return false; + + return currentTime - lastUsed >= cooldown; + } + + /// + /// Seconds remaining until the cooldown expires. Returns 0 when + /// ready, never used, or not an Active card. + /// + public static long GetRemainingCooldown(this ActionCharacter action, long currentTime) + { + if (action.Type != ActionCharacterType.Active) + return 0; + + var lastUsed = action.Properties.GetLong("LastUsed", 0); + var cooldown = action.GetInt("ActiveCooldown", 0); + + if (lastUsed == 0 || cooldown <= 0) + return 0; + + var elapsed = currentTime - lastUsed; + return elapsed >= cooldown ? 0 : cooldown - elapsed; + } + + /// + /// Records the activation timestamp. Has no effect on non-Active cards. + /// + public static void MarkUsed(this ActionCharacter action, long timestamp) + { + if (action.Type != ActionCharacterType.Active) + return; + action.Properties.SetLong("LastUsed", timestamp); + } + } +} diff --git a/src/Characters/ActionCharacterInfo.cs b/src/Characters/ActionCharacterInfo.cs new file mode 100644 index 0000000..874e018 --- /dev/null +++ b/src/Characters/ActionCharacterInfo.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Cthangover.Core.Factories; + +namespace Cthangover.Core.Characters +{ + /// + /// JSON-serializable descriptor for an ActionCharacter. The Values list is a + /// flat string array carrying the PropertyData pairs — serialized as a list + /// rather than a dictionary to maintain JSON readability and avoid key + /// escaping issues. Type uses a string enum converter so the JSON reads + /// "ToSelf"/"ToAlias"/"ToEnemy" instead of integers. The Image field is a + /// resource path string, resolved to Texture2D by the factory at load time. + /// + [Serializable] + [EntityKind("action")] + public class ActionCharacterInfo : IIdentifiable + { + /// + /// Unique action identifier, serialized as "Id" in JSON. + /// + [JsonPropertyName("Id")] + public string ID { get; set; } + /// + /// Display name for the battle action menu. + /// + public string Name { get; set; } + /// + /// Description text for the battle action tooltip. + /// + public string Description { get; set; } + /// + /// Targeting category, serialized as string (e.g. "ToSelf", + /// "ToAlias", "ToEnemy") via + /// for human-readable JSON. + /// + [JsonConverter(typeof(JsonStringEnumConverter))] + public ActionCharacterType Type { get; set; } + /// + /// Resource path string for the action icon. Resolved to + /// Texture2D by at load + /// time. + /// + public string Image { get; set; } + /// + /// Flat list of alternating key-value pairs that populate the + /// bag at load time. + /// Index 0 = first key, index 1 = first value, index 2 = second + /// key, etc. Stored as a list rather than a dictionary to + /// simplify JSON authoring and avoid key-escaping issues. + /// + public List Values { get; set; } + } + +} diff --git a/src/Characters/CharacterInfo.cs b/src/Characters/CharacterInfo.cs new file mode 100644 index 0000000..f12b077 --- /dev/null +++ b/src/Characters/CharacterInfo.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Cthangover.Core.Factories; + +namespace Cthangover.Core.Characters +{ + /// + /// JSON-serializable character descriptor loaded from data files. + /// Only the four core battle stats (Health, Defence, Attack, Point) + /// are hardcoded — they map directly to fields + /// in . All other properties + /// (any mod-defined extras) are captured by + /// and flowed into at + /// factory time, so mods can add arbitrary stat keys without + /// changing the DTO schema. + /// + [Serializable] + [EntityKind("character")] + public class CharacterInfo : IIdentifiable + { + /// + /// Unique character ID, serialized as "Id" in JSON. Serves as the + /// factory lookup key and save-data identifier. + /// + [JsonPropertyName("Id")] + public string ID { get; set; } + /// + /// AI behaviour class name for this character in battle. Resolved + /// externally — null means no AI override. + /// + public string Behaviour { get; set; } + /// + /// Starting level for this character when recruited. + /// + public int Level { get; set; } + /// + /// Resource path string for the full-body portrait. Resolved to a + /// Texture2D by at load time. + /// + public string Image { get; set; } + /// + /// Display name, localized via TranslationServer when shown to the + /// player. + /// + public string Name { get; set; } + /// + /// Starting health points (used as .BaseValue). + /// + public int Health { get; set; } + /// + /// Starting defence value. + /// + public int Defence { get; set; } + /// + /// Starting action points (resource pool for abilities). + /// + public int Point { get; set; } + /// + /// Starting attack power. + /// + public int Attack { get; set; } + /// + /// Starting experience points. + /// + public int Exp { get; set; } + /// + /// Recruitment chance percentage (0–100) after defeating this + /// character in battle. + /// + public int RecruitmentChance { get; set; } + /// + /// Action ID strings, each referencing an + /// entry in the action data file. + /// Stored as a JSON array for clean mod-patching with + /// $add/$remove/$set suffix operations. + /// + public List Actions { get; set; } + /// + /// Loot drop table for this character when defeated as an enemy. + /// + public List Loot { get; set; } + /// + /// Captures any JSON keys not matched by the hardcoded properties + /// above — including former DTO fields like any mod-defined extras. + /// These are imported into + /// by . + /// + [JsonExtensionData] + public Dictionary ExtraProperties { get; set; } + } + +} diff --git a/src/Characters/CharacterPassiveExtension.cs b/src/Characters/CharacterPassiveExtension.cs new file mode 100644 index 0000000..955436d --- /dev/null +++ b/src/Characters/CharacterPassiveExtension.cs @@ -0,0 +1,25 @@ +using System.Linq; + +namespace Cthangover.Core.Characters +{ + /// + /// Extension for querying passive effects on a . + /// Passive actions are entries with + /// whose + /// declare effect flags as boolean keys (e.g. "IgnoreArmor"). The key names + /// are chosen by the mod author — the core imposes no registry or constants. + /// + public static class CharacterPassiveExtension + { + /// + /// Returns true if the character has at least one Passive action + /// whose contains + /// with a true boolean value. + /// + public static bool HasPassiveEffect(this Character character, string key) + { + return character?.Actions?.Any(a => + a.Type == ActionCharacterType.Passive && a.GetBool(key)) ?? false; + } + } +} diff --git a/src/CharactersModInitializer.cs b/src/CharactersModInitializer.cs new file mode 100644 index 0000000..cc1c76c --- /dev/null +++ b/src/CharactersModInitializer.cs @@ -0,0 +1,86 @@ +using Cthangover.Core.Bootstrap; +using Cthangover.Core.Characters; +using Cthangover.Core.Factories; +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Mods; +using Cthangover.Core.Settings; +using Cthangover.Core.Utils; + +namespace Cthangover.Mods.Characters +{ + /// + /// Engine-mod initializer for the characters subsystem. Publishes the + /// factories living in this assembly to the kernel contracts + /// (, + /// , ) + /// as soon as the assembly is registered, so core-side consumers — + /// CharacterData, inventory and skills UI, dialog avatar lookups — + /// resolve templates without referencing a runtime-compiled mod assembly. + /// + /// Registration happens in (the earliest and + /// most frequent initializer callback) rather than on resources-ready: + /// CharacterData.AddCharacterToParty is called from other mods' + /// OnModResourcesReady (starter party), and initializer callbacks + /// are delivered per mod registration, so being first here removes any + /// ordering dependency between mods. The factories themselves only + /// collect entity data lazily on the first Get, after the mod + /// registry is finalized. Registration is guarded and idempotent because + /// ModManager.Reload() re-runs OnModLoaded for every mod. + /// + public class CharactersModInitializer : IModInitializer + { + private static bool registered; + + /// Publishes this mod's factories to the kernel registries; safe to call again after a mod reload. + public void OnModLoaded(string modId) + { + if (registered) + return; + registered = true; + + CharacterTemplateRegistry.Provider = CharacterFactory.Instance; + ActionTemplateRegistry.Provider = ActionCharacterFactory.Instance; + AvatarProviderRegistry.Provider = AvatarFactory.Instance; + + GameLogger.Log("MODS", "CharactersModInitializer: template providers registered (character, action, avatar)"); + } + + /// + /// Registers the new-game roster reset owner with AppServices + /// (idempotent — duplicates by Id are ignored). Template collection + /// stays lazy on first lookup. Persistence needs no setup here: + /// is an IModSaveable + /// discovered by ModSaveableRegistry automatically. + /// + public void OnModResourcesReady() + { + AppServices.Current.RegisterResettable(new CharactersResettable()); + } + } + + /// + /// New-game state owner of the characters mod: clears the party roster + /// and battle set. Today the roster dies implicitly with + /// GameData.ResetRuntime (it lives inside RuntimeData), + /// which runs before the reset loop, so this reset is a no-op in the + /// normal pipeline; it stays correct if the roster ever moves to + /// mod-owned static storage (the recruiting path) or if the reset loop + /// runs standalone, and makes the characters mod an explicit new-game + /// state owner like quests and recruiting. Registered from + /// ; + /// deduplicates by . + /// + internal sealed class CharactersResettable : IResettable + { + /// Resettable key under which AppServices deduplicates registration. + public string Id => "characters"; + + /// Clears the party roster and battle set on "New Game". + public void ResetForNewGame() + { + var characterData = GameData.Instance.Runtime.CharacterData; + characterData.Characters.Clear(); + characterData.BattleSet.Clear(); + } + } +} diff --git a/src/CharactersSaveable.cs b/src/CharactersSaveable.cs new file mode 100644 index 0000000..842a13c --- /dev/null +++ b/src/CharactersSaveable.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Linq; +using Cthangover.Core.Mods; +using Cthangover.Core.Settings; + +namespace Cthangover.Mods.Characters +{ + /// + /// Persists the character roster (party composition and per-character + /// progression) through the generic contract + /// under the mod id characters_base. The core save used to carry + /// the "Characters" and "BattleSet" blobs inside the + /// __core__ slot (RuntimeSerializer.BuildCoreSlot); the + /// roster belongs to the characters engine mod, so it moves to the mod + /// slot like every other migrated subsystem (quests, items, recruiting). + /// + /// The state itself is not duplicated: marshals + /// GameData.Instance.Runtime.CharacterData — the same runtime + /// objects the core, battle and UI read — into the mod slot, and + /// writes the restored dictionaries back into that + /// runtime, so no other code changes its data path. While the transition + /// is staged (the core slot still carries the old blobs), load order is + /// safe: the core restores __core__ first and this saveable + /// re-restores the identical data afterwards from the mod slot. When the + /// core blobs are removed, old saves degrade gracefully — + /// RuntimeSerializer.RestoreCoreState skips missing blob keys, and + /// an absent mod blob keeps the fresh runtime roster. + /// + public class CharactersSaveable : IModSaveable + { + private const string CharactersBlobKey = "Characters"; + private const string BattleSetBlobKey = "BattleSet"; + + /// Writes the roster and battle set into the mod slot. + public void OnSave(ModSlot slot) + { + var characterData = GameData.Instance.Runtime.CharacterData; + slot.Data[CharactersBlobKey] = new DataBlob(1, characterData.Characters.Values.ToList()); + slot.Data[BattleSetBlobKey] = new DataBlob(1, characterData.BattleSet.ToList()); + } + + /// + /// Restores roster and battle set from the mod slot; an absent blob + /// keeps the corresponding part of the current runtime state. + /// + public bool OnLoad(ModSlot slot, out string errorMessage) + { + errorMessage = null; + + if (slot?.Data == null) + return true; + + var characterData = GameData.Instance.Runtime.CharacterData; + + if (slot.Data.TryGetValue(CharactersBlobKey, out var charactersBlob)) + { + var characters = charactersBlob.ReadJson>(); + characterData.Characters = new Dictionary(); + if (characters != null) + foreach (var info in characters) + characterData.Characters[info.CharacterType] = info; + } + + if (slot.Data.TryGetValue(BattleSetBlobKey, out var battleSetBlob)) + characterData.BattleSet = battleSetBlob.ReadJson>()?.ToHashSet() + ?? new HashSet(); + + return true; + } + } +} diff --git a/src/Factories/ActionCharacterFactory.cs b/src/Factories/ActionCharacterFactory.cs new file mode 100644 index 0000000..ad69c63 --- /dev/null +++ b/src/Factories/ActionCharacterFactory.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; +using Cthangover.Core.Characters; +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Core.Factories.Impls +{ + /// + /// Factory for battle action cards equipped by characters. Returns a + /// copy of the cached ActionCharacter on every Get + /// call — action cards carry mutable battle state (cooldowns, modifiers) + /// and must never be shared across character instances. Properties are + /// parsed from a compact key=value string list rather than a full + /// JSON object, keeping mod JSON terse for authored content. + /// + /// Images are loaded via TextureUtils.LoadFromModGroup — not + /// through a texture factory — because action character assets live + /// inside the same "characters" asset group as their PNGs, + /// making co-location of data and art the default mod layout. The JSON + /// definitions themselves are found by kind, not by that directory. + /// + /// The factory lives in the characters engine mod; its singleton is also + /// published to the kernel through by + /// CharactersModInitializer, so core-side consumers resolve + /// templates without referencing this assembly. + /// + public class ActionCharacterFactory : ICacheLoader, IActionTemplateProvider + { + /// Process-wide singleton for this mod assembly; the public API for action template lookups. + public static ActionCharacterFactory Instance { get; } = new ActionCharacterFactory(); + + private Dictionary _allInfos; + private readonly BoundedCache _cache; + + internal ActionCharacterFactory() + { + var size = ModConfig.Instance.Cache.GetCacheSize("actions", 128); + _cache = new BoundedCache(size, this); + } + + ActionCharacter ICacheLoader.Load(string id) + { + var infos = EnsureInfos(); + if (infos.TryGetValue(id, out var info)) + return CreateActionCharacter(info); + return null; + } + + /// + /// Returns the action definitions, collecting them on first use. + /// Publication to _allInfos waits until the mod registry is + /// finalized so an early lookup from the launcher boot window cannot + /// freeze an empty snapshot for the session. + /// + private Dictionary EnsureInfos() + { + if (_allInfos != null) + return _allInfos; + + var infos = Entities.Collect(); + if (ModManager.Instance.IsInitialized) + _allInfos = infos; + return infos; + } + + /// + /// Returns an independent copy of the action definition for + /// , or null when unknown. The copy is + /// deliberate: action cards carry mutable battle state, so callers + /// never share one instance. + /// + public ActionCharacter Get(string id) + { + EnsureInfos(); + var item = _cache.Get(id); + return item?.Copy(); + } + + /// + /// Returns all known action info IDs. Used by the character panel to determine + /// which actions are not assigned to any character (the action pool). + /// + public IReadOnlyCollection GetAllActionIds() + { + return EnsureInfos()?.Keys; + } + + private ActionCharacter CreateActionCharacter(ActionCharacterInfo info) + { + if (info == null) + return null; + + var item = new ActionCharacter + { + ID = info.ID, + Name = info.Name, + Description = info.Description, + Type = info.Type, + Properties = ParseValues(info.Values), + Image = LoadImage(info.Image), + }; + + return item; + } + + private Texture2D LoadImage(string imagePath) + { + return TextureUtils.LoadFromModGroup("characters", imagePath); + } + + private static PropertyData ParseValues(List values) + { + var props = new PropertyData(); + if (values == null) + return props; + + foreach (var value in values) + { + var idx = value.IndexOf('='); + if (idx > 0) + props.Values[value.Substring(0, idx)] = value.Substring(idx + 1); + } + return props; + } + } +} diff --git a/src/Factories/AvatarFactory.cs b/src/Factories/AvatarFactory.cs new file mode 100644 index 0000000..8cfb390 --- /dev/null +++ b/src/Factories/AvatarFactory.cs @@ -0,0 +1,30 @@ +using Cthangover.Core.Factories; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Core.Factories.Impls +{ + /// + /// Thin Texture2DFactory for character portrait avatars. The + /// separate factory (as opposed to reusing UITextureFactory) + /// isolates avatar images into their own mod group with a dedicated + /// cache budget, so frequently-swapped dialog portraits don't evict + /// other UI textures under memory pressure. + /// + /// The factory lives in the characters engine mod; its singleton is also + /// published to the kernel through by + /// CharactersModInitializer, so core-side consumers resolve + /// avatars without referencing this assembly. + /// + public class AvatarFactory : Texture2DFactory, IAvatarProvider + { + internal AvatarFactory() : base("avatars", 64) { } + + /// Process-wide singleton for this mod assembly; the public API for avatar lookups. + public static AvatarFactory Instance { get; } = new AvatarFactory(); + + /// Mod asset group scanned by this factory — files live under mods/*/avatars. + public override string GroupName => "avatars"; + + } +} diff --git a/src/Factories/CharacterFactory.cs b/src/Factories/CharacterFactory.cs new file mode 100644 index 0000000..5622355 --- /dev/null +++ b/src/Factories/CharacterFactory.cs @@ -0,0 +1,157 @@ +using System.Collections.Generic; +using System.Linq; +using Cthangover.Core.Characters; +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Core.Factories.Impls +{ + /// + /// Factory for full character definitions. Returns a copy of + /// the cached Character on every Get call because + /// Character.Attributes mutate during gameplay. + /// + /// Character actions are resolved through + /// ActionCharacterFactory. Attribute fields that are missing + /// or zero in JSON receive nonzero fallback values so incomplete + /// mod data doesn't produce unplayable characters. + /// + /// Patching is handled transparently by the shared + /// EntityResolver — the factory receives already-merged + /// CharacterInfo DTOs routed by their character kind and + /// simply maps them to the runtime model. + /// + /// The factory lives in the characters engine mod; its singleton is also + /// published to the kernel through + /// by CharactersModInitializer, so core-side consumers resolve + /// templates without referencing this assembly. + /// + public class CharacterFactory : ICacheLoader, ICharacterTemplateProvider + { + /// Process-wide singleton for this mod assembly; the public API for template lookups. + public static CharacterFactory Instance { get; } = new CharacterFactory(); + + private Dictionary _allInfos; + private readonly BoundedCache _cache; + + internal CharacterFactory() + { + var size = ModConfig.Instance.Cache.GetCacheSize("characters", 128); + _cache = new BoundedCache(size, this); + } + + Character ICacheLoader.Load(string id) + { + var infos = EnsureInfos(); + + GameLogger.Log("FACTORY", $"CharacterFactory.Load('{id}'): infos count={infos?.Count ?? -1}", LogLevel.Debug); + if (infos.TryGetValue(id, out var info)) + return CreateCharacter(info); + + return null; + } + + /// + /// Returns the character definitions, collecting them on first use. + /// The result is published to _allInfos only after the mod + /// registry is finalized. A stale empty snapshot cannot appear: the + /// registry refuses mod-data access until Boot.IsReady, so the + /// first collection always sees the final mod set, and the cached + /// snapshot is reset when ModManager.Reload rebuilds the + /// registry. + /// + private Dictionary EnsureInfos() + { + if (_allInfos != null) + return _allInfos; + + GameLogger.Log("FACTORY", $"CharacterFactory.EnsureInfos: START, ModRegistry.IsInitialized={ModRegistry.Instance.IsInitialized}, mods count={ModRegistry.Instance.Mods?.Count ?? 0}", LogLevel.Debug); + + var infos = Entities.Collect(); + if (ModManager.Instance.IsInitialized) + _allInfos = infos; + + GameLogger.Log("FACTORY", $"CharacterFactory.EnsureInfos: END, loaded {infos?.Count ?? 0} infos", LogLevel.Debug); + return infos; + } + + /// + /// Returns an independent copy of the character template for + /// , or null (with an error log) when the id + /// is unknown. The copy semantics matter: battle and party systems mutate + /// attributes freely without touching the cached template. + /// + public Character Get(string id) + { + var infos = EnsureInfos(); + + if (!infos.ContainsKey(id)) + { + GameLogger.Log("FACTORY", $"CharacterFactory.Get('{id}'): NOT FOUND in infos", LogLevel.Error); + return null; + } + + var item = _cache.Get(id); + if(item?.Image == null) + GameLogger.Log("FACTORY", $"CharacterFactory.Get('{id}'): found, image=null", LogLevel.Error); + + return item?.Copy(); + } + + private static Character CreateCharacter(CharacterInfo info) + { + if (info == null) + return null; + + var item = new Character + { + ID = info.ID, + Behaviour = info.Behaviour, + Name = info.Name, + Level = info.Level, + Exp = info.Exp, + RecruitmentChance = info.RecruitmentChance, + Actions = ResolveActions(info.Actions ?? new List()), + Image = LoadImage(info.Image), + Loot = info.Loot, + }; + + item.Attributes.Health.Init(info.Health > 0 ? info.Health : 1); + item.Attributes.Defence.Init(info.Defence); + item.Attributes.Attack.Init(info.Attack); + item.Attributes.Point.Init(info.Point > 0 ? info.Point : 1); + + PropertyData.ImportJsonProperties(item.Attributes.Properties, info.ExtraProperties); + + return item; + } + + private static List ResolveActions(List actions) + { + if (actions == null || actions.Count == 0) + return new List(); + + return actions + .Select(id => ActionCharacterFactory.Instance.Get(id)) + .Where(card => card != null) + .ToList(); + } + + private static Texture2D LoadImage(string imagePath) + { + if (string.IsNullOrWhiteSpace(imagePath)) + { + GameLogger.Log("FACTORY", $"LoadImage: imagePath is null/empty", LogLevel.Error); + return null; + } + + var tex = TextureUtils.LoadFromModGroup("characters", imagePath); + + if(tex == null) + GameLogger.Log("FACTORY", $"LoadImage: result=null", LogLevel.Error); + return tex; + } + } +} diff --git a/src/SubsystemRegistration.cs b/src/SubsystemRegistration.cs new file mode 100644 index 0000000..7e180b3 --- /dev/null +++ b/src/SubsystemRegistration.cs @@ -0,0 +1,16 @@ +namespace Cthangover.Mods.Characters +{ + /// + /// Migration marker scanned by the integration coverage test + /// (SubsystemsBridge): declares the characters subsystem as + /// migrated out of the core assembly into this engine mod. The + /// subsystem id must match the entry in + /// SubsystemsCoverageTestSetup.DeclaredTests and + /// tests/tests.manifest.json. + /// + public static class SubsystemRegistration + { + /// Subsystem id reported to the coverage test. + public static string SubsystemId => "characters"; + } +} diff --git a/src/UI/CharacterPanel/ActionCardBehaviour.cs b/src/UI/CharacterPanel/ActionCardBehaviour.cs new file mode 100644 index 0000000..c73aee8 --- /dev/null +++ b/src/UI/CharacterPanel/ActionCardBehaviour.cs @@ -0,0 +1,210 @@ +using Cthangover.Core.Characters; +using Cthangover.Core.Scenes; +using Cthangover.Core.UI.Theming; +using Godot; + +using Cthangover.Mods.Interface.Theming; +namespace Cthangover.Core.UI.CharacterPanel +{ + /// + /// Visual representation of a single inside + /// a character slot or in the action pool. The static layout (frame panel, + /// accent stripe, icon well, name row, badge and property containers) + /// lives in scenes/ui/action_card.tscn; this behaviour resolves + /// those nodes and owns only the dynamic layer: type accent colour, + /// translated name, ID watermark, badges, property chips, hover/selected + /// frames and the compact (pool) vs expanded (slot) size states. + /// + /// Instances are created through (the card + /// scene), never via new — the node tree comes from the scene. + /// The card never handles input itself: + /// owns drag orchestration through its node-level _Input (GuiInput + /// stops firing once the cursor leaves the card mid-drag). The card only + /// exposes state setters (, ) + /// and is moved between containers by the panel/slot. + /// + public partial class ActionCardBehaviour : Control + { + private const float NormalHeight = 86f; + private const float CompactHeight = 60f; + + /// Factory action bound to this card; null before . + public ActionCharacter ActionData { get; private set; } + + /// Slot that currently owns the card, or null when the card lives in the pool. + public ActionSlotBehaviour CurrentSlot { get; set; } + + private static readonly StyleBoxFlat NormalFrame = UiKit.Frame( + UiTokens.RadiusMd, UiTokens.Surface1, UiTokens.LineCard); + private static readonly StyleBoxFlat HoverFrame = UiKit.Frame( + UiTokens.RadiusMd, UiTokens.SurfaceHover, UiTokens.LineStrong); + private static readonly StyleBoxFlat SelectedFrame = UiKit.Frame( + UiTokens.RadiusMd, UiTokens.SurfaceSelected, UiTokens.Accent); + + private Panel _frame; + private ColorRect _accent; + private TextureRect _icon; + private Label _nameLabel; + private Label _idLabel; + private HBoxContainer _badgesRow; + private HFlowContainer _propsRow; + private bool _compact; + private bool _selected; + private bool _hovered; + + /// Binds hover feedback; node references resolve lazily so the card can be bound before entering the tree. + public ActionCardBehaviour() + { + MouseFilter = MouseFilterEnum.Stop; + MouseEntered += OnMouseEntered; + MouseExited += OnMouseExited; + } + + /// Re-anchors to the parent's full rect for slot placement, mirroring the scene-root layout. + public override void _Ready() + { + } + + /// + /// Binds the action data and refreshes every rendered element: icon, + /// translated name, ID watermark, type/cost/cooldown badges, property + /// chips and the rich tooltip. + /// + public void SetAction(ActionCharacter action) + { + ActionData = action; + ResolveNodes(); + if (action == null || _frame == null) + return; + + _icon.Texture = action.Image; + _nameLabel.Text = ActionCharacterUi.Translate(action.Name); + _idLabel.Text = action.ID; + TooltipText = ActionCharacterUi.BuildTooltip(action); + + BuildBadges(action); + BuildProperties(action); + } + + /// + /// Compact mode drops the property chip row and shrinks the card to the + /// single-line pool layout; slot cards use the expanded layout. + /// + public void SetCompact(bool compact) + { + _compact = compact; + CustomMinimumSize = new Vector2(0, compact ? CompactHeight : NormalHeight); + ResolveNodes(); + if (_propsRow != null) + _propsRow.Visible = !compact && _propsRow.GetChildCount() > 0; + if (_icon != null) + { + var half = compact ? 22 : 26; + _icon.OffsetTop = -half; + _icon.OffsetBottom = half; + _icon.OffsetRight = compact ? 52 : 60; + } + } + + /// Gold frame for click-selected cards; drag state is handled by the panel. + public void SetSelected(bool selected) + { + _selected = selected; + ApplyFrame(); + } + + /// + /// Returns the card to its owning slot, or leaves pool cards to the + /// panel — a pool card has no slot to return to, so the caller must + /// restore it through . + /// + public void ReturnToOrigin() + { + if (CurrentSlot == null) + return; + + if (GetParent() != CurrentSlot) + { + GetParent()?.RemoveChild(this); + CurrentSlot.AddChild(this); + } + SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + } + + /// Marks the card as detached from every container while it follows the cursor. + public void SetDragging(bool dragging) + { + Modulate = dragging ? UiTokens.Alpha(UiTokens.White, 0.55f) : UiTokens.White; + } + + private void ResolveNodes() + { + _frame ??= GetNodeOrNull("Frame"); + _accent ??= GetNodeOrNull("Accent"); + _icon ??= GetNodeOrNull("Icon"); + _nameLabel ??= GetNodeOrNull