This commit is contained in:
iisaev committed 2026-10-18 12:41:37 +03:00
commit b6dd3758cb
32 files changed
+2999

No files matched your search

+17
View File
@@ -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"
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

+16
View File
@@ -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"
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+17
View File
@@ -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"
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

+2
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
mod_characters_base_name = Движок персонажей
mod_characters_base_desc = Подсистема персонажей движка: фабрики персонажей и боевых карточек, фабрика аватаров и панель персонажа. Ядро получает шаблоны через контракты ICharacterTemplateProvider/IActionTemplateProvider/IAvatarProvider.
+22
View File
@@ -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
}
]
}
+84
View File
@@ -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
+63
View File
@@ -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
+40
View File
@@ -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
+77
View File
@@ -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
+163
View File
@@ -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
+87
View File
@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Settings;
namespace Cthangover.Core.Characters
{
/// <summary>
/// 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.
/// <para/>
/// 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 <see cref="Skills.SkillService"/> 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.
/// <para/>
/// The template lookup is injected as a delegate because the production
/// caller resolves it through <c>CharacterFactory</c> (a mod-aware JSON
/// factory), while tests can supply plain <see cref="Character"/> objects.
/// </summary>
public static class ActionAssignmentReset
{
/// <summary>
/// Rewrites every battle-set character's <see cref="CharacterInfoData.ActionSlots"/>
/// from its template and replaces <see cref="ActionPoolData.ActionIds"/>
/// with the surviving pool plus recovered awarded cards.
/// </summary>
/// <param name="characterData">Roster whose battle set and slots are reset.</param>
/// <param name="actionPool">Shared action pool; may be <c>null</c>, in which case pool contents are recomputed but not stored.</param>
/// <param name="resolveTemplate">Resolves a character ID to its factory template; may return <c>null</c> for unknown IDs.</param>
public static void Apply(
CharacterData characterData,
ActionPoolData actionPool,
Func<string, Character> resolveTemplate)
{
if (characterData?.BattleSet == null || characterData.Characters == null)
return;
var extraPoolIds = new HashSet<string>(actionPool?.ActionIds ?? new List<string>());
var assignedAfterReset = new HashSet<string>();
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<string>();
var slots = new List<string>();
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();
}
}
}
@@ -0,0 +1,63 @@
namespace Cthangover.Core.Characters
{
/// <summary>
/// Cooldown helpers for <see cref="ActionCharacterType.Active"/> cards.
/// Active cards are one-shot abilities usable outside battle (from the
/// character panel). The last-use timestamp is stored in
/// <see cref="ActionCharacter.Properties"/> under
/// 'LastUsed' - the cooldown duration
/// (seconds) under 'ActiveCooldown'
/// Absence of <c>LastUsed</c> means the card has never been activated.
/// </summary>
public static class ActionCharacterActiveExtension
{
/// <summary>
/// Returns <c>true</c> when the card is <see cref="ActionCharacterType.Active"/>
/// and its cooldown has expired (or it was never used).
/// </summary>
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;
}
/// <summary>
/// Seconds remaining until the cooldown expires. Returns <c>0</c> when
/// ready, never used, or not an Active card.
/// </summary>
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;
}
/// <summary>
/// Records the activation timestamp. Has no effect on non-Active cards.
/// </summary>
public static void MarkUsed(this ActionCharacter action, long timestamp)
{
if (action.Type != ActionCharacterType.Active)
return;
action.Properties.SetLong("LastUsed", timestamp);
}
}
}
+56
View File
@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Cthangover.Core.Factories;
namespace Cthangover.Core.Characters
{
/// <summary>
/// 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.
/// </summary>
[Serializable]
[EntityKind("action")]
public class ActionCharacterInfo : IIdentifiable
{
/// <summary>
/// Unique action identifier, serialized as "Id" in JSON.
/// </summary>
[JsonPropertyName("Id")]
public string ID { get; set; }
/// <summary>
/// Display name for the battle action menu.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Description text for the battle action tooltip.
/// </summary>
public string Description { get; set; }
/// <summary>
/// Targeting category, serialized as string (e.g. "ToSelf",
/// "ToAlias", "ToEnemy") via <see cref="JsonStringEnumConverter"/>
/// for human-readable JSON.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public ActionCharacterType Type { get; set; }
/// <summary>
/// Resource path string for the action icon. Resolved to
/// <c>Texture2D</c> by <see cref="CharacterFactory"/> at load
/// time.
/// </summary>
public string Image { get; set; }
/// <summary>
/// Flat list of alternating key-value pairs that populate the
/// <see cref="ActionCharacter.Properties"/> 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.
/// </summary>
public List<string> Values { get; set; }
}
}
+93
View File
@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Cthangover.Core.Factories;
namespace Cthangover.Core.Characters
{
/// <summary>
/// JSON-serializable character descriptor loaded from data files.
/// Only the four core battle stats (Health, Defence, Attack, Point)
/// are hardcoded — they map directly to <see cref="Attribute"/> fields
/// in <see cref="CharacterAttributes"/>. All other properties
/// (any mod-defined extras) are captured by <see cref="ExtraProperties"/>
/// and flowed into <see cref="CharacterAttributes.Properties"/> at
/// factory time, so mods can add arbitrary stat keys without
/// changing the DTO schema.
/// </summary>
[Serializable]
[EntityKind("character")]
public class CharacterInfo : IIdentifiable
{
/// <summary>
/// Unique character ID, serialized as "Id" in JSON. Serves as the
/// factory lookup key and save-data identifier.
/// </summary>
[JsonPropertyName("Id")]
public string ID { get; set; }
/// <summary>
/// AI behaviour class name for this character in battle. Resolved
/// externally — null means no AI override.
/// </summary>
public string Behaviour { get; set; }
/// <summary>
/// Starting level for this character when recruited.
/// </summary>
public int Level { get; set; }
/// <summary>
/// Resource path string for the full-body portrait. Resolved to a
/// <c>Texture2D</c> by <see cref="CharacterFactory"/> at load time.
/// </summary>
public string Image { get; set; }
/// <summary>
/// Display name, localized via TranslationServer when shown to the
/// player.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Starting health points (used as <see cref="Attribute"/>.BaseValue).
/// </summary>
public int Health { get; set; }
/// <summary>
/// Starting defence value.
/// </summary>
public int Defence { get; set; }
/// <summary>
/// Starting action points (resource pool for abilities).
/// </summary>
public int Point { get; set; }
/// <summary>
/// Starting attack power.
/// </summary>
public int Attack { get; set; }
/// <summary>
/// Starting experience points.
/// </summary>
public int Exp { get; set; }
/// <summary>
/// Recruitment chance percentage (0–100) after defeating this
/// character in battle.
/// </summary>
public int RecruitmentChance { get; set; }
/// <summary>
/// Action ID strings, each referencing an
/// <see cref="ActionCharacterInfo"/> entry in the action data file.
/// Stored as a JSON array for clean mod-patching with
/// <c>$add</c>/<c>$remove</c>/<c>$set</c> suffix operations.
/// </summary>
public List<string> Actions { get; set; }
/// <summary>
/// Loot drop table for this character when defeated as an enemy.
/// </summary>
public List<LootEntry> Loot { get; set; }
/// <summary>
/// Captures any JSON keys not matched by the hardcoded properties
/// above — including former DTO fields like any mod-defined extras.
/// These are imported into <see cref="CharacterAttributes.Properties"/>
/// by <see cref="CharacterFactory.ImportExtraProperty"/>.
/// </summary>
[JsonExtensionData]
public Dictionary<string, object> ExtraProperties { get; set; }
}
}
@@ -0,0 +1,25 @@
using System.Linq;
namespace Cthangover.Core.Characters
{
/// <summary>
/// Extension for querying passive effects on a <see cref="Character"/>.
/// Passive actions are <see cref="ActionCharacter"/> entries with
/// <see cref="ActionCharacterType.Passive"/> whose <see cref="ActionCharacter.Properties"/>
/// 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.
/// </summary>
public static class CharacterPassiveExtension
{
/// <summary>
/// Returns <c>true</c> if the character has at least one Passive action
/// whose <see cref="ActionCharacter.Properties"/> contains <paramref name="key"/>
/// with a <c>true</c> boolean value.
/// </summary>
public static bool HasPassiveEffect(this Character character, string key)
{
return character?.Actions?.Any(a =>
a.Type == ActionCharacterType.Passive && a.GetBool(key)) ?? false;
}
}
}
+86
View File
@@ -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
{
/// <summary>
/// Engine-mod initializer for the characters subsystem. Publishes the
/// factories living in this assembly to the kernel contracts
/// (<see cref="CharacterTemplateRegistry"/>,
/// <see cref="ActionTemplateRegistry"/>, <see cref="AvatarProviderRegistry"/>)
/// as soon as the assembly is registered, so core-side consumers —
/// <c>CharacterData</c>, inventory and skills UI, dialog avatar lookups —
/// resolve templates without referencing a runtime-compiled mod assembly.
/// <para/>
/// Registration happens in <see cref="OnModLoaded"/> (the earliest and
/// most frequent initializer callback) rather than on resources-ready:
/// <c>CharacterData.AddCharacterToParty</c> is called from other mods'
/// <c>OnModResourcesReady</c> (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 <c>Get</c>, after the mod
/// registry is finalized. Registration is guarded and idempotent because
/// <c>ModManager.Reload()</c> re-runs <c>OnModLoaded</c> for every mod.
/// </summary>
public class CharactersModInitializer : IModInitializer
{
private static bool registered;
/// <summary>Publishes this mod's factories to the kernel registries; safe to call again after a mod reload.</summary>
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)");
}
/// <summary>
/// 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:
/// <see cref="CharactersSaveable"/> is an <c>IModSaveable</c>
/// discovered by ModSaveableRegistry automatically.
/// </summary>
public void OnModResourcesReady()
{
AppServices.Current.RegisterResettable(new CharactersResettable());
}
}
/// <summary>
/// New-game state owner of the characters mod: clears the party roster
/// and battle set. Today the roster dies implicitly with
/// <c>GameData.ResetRuntime</c> (it lives inside <c>RuntimeData</c>),
/// 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
/// <see cref="CharactersModInitializer.OnModResourcesReady"/>;
/// <see cref="AppServices"/> deduplicates by <see cref="IResettable.Id"/>.
/// </summary>
internal sealed class CharactersResettable : IResettable
{
/// <summary>Resettable key under which AppServices deduplicates registration.</summary>
public string Id => "characters";
/// <summary>Clears the party roster and battle set on "New Game".</summary>
public void ResetForNewGame()
{
var characterData = GameData.Instance.Runtime.CharacterData;
characterData.Characters.Clear();
characterData.BattleSet.Clear();
}
}
}
+71
View File
@@ -0,0 +1,71 @@
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
namespace Cthangover.Mods.Characters
{
/// <summary>
/// Persists the character roster (party composition and per-character
/// progression) through the generic <see cref="IModSaveable"/> contract
/// under the mod id <c>characters_base</c>. The core save used to carry
/// the <c>"Characters"</c> and <c>"BattleSet"</c> blobs inside the
/// <c>__core__</c> slot (<c>RuntimeSerializer.BuildCoreSlot</c>); the
/// roster belongs to the characters engine mod, so it moves to the mod
/// slot like every other migrated subsystem (quests, items, recruiting).
/// <para/>
/// The state itself is not duplicated: <see cref="OnSave"/> marshals
/// <c>GameData.Instance.Runtime.CharacterData</c> — the same runtime
/// objects the core, battle and UI read — into the mod slot, and
/// <see cref="OnLoad"/> 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 <c>__core__</c> first and this saveable
/// re-restores the identical data afterwards from the mod slot. When the
/// core blobs are removed, old saves degrade gracefully —
/// <c>RuntimeSerializer.RestoreCoreState</c> skips missing blob keys, and
/// an absent mod blob keeps the fresh runtime roster.
/// </summary>
public class CharactersSaveable : IModSaveable
{
private const string CharactersBlobKey = "Characters";
private const string BattleSetBlobKey = "BattleSet";
/// <summary>Writes the roster and battle set into the mod slot.</summary>
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());
}
/// <summary>
/// Restores roster and battle set from the mod slot; an absent blob
/// keeps the corresponding part of the current runtime state.
/// </summary>
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<List<CharacterInfoData>>();
characterData.Characters = new Dictionary<string, CharacterInfoData>();
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<List<string>>()?.ToHashSet()
?? new HashSet<string>();
return true;
}
}
}
+128
View File
@@ -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
{
/// <summary>
/// Factory for battle action cards equipped by characters. Returns a
/// <b>copy</b> of the cached <c>ActionCharacter</c> on every <c>Get</c>
/// call — action cards carry mutable battle state (cooldowns, modifiers)
/// and must never be shared across character instances. Properties are
/// parsed from a compact <c>key=value</c> string list rather than a full
/// JSON object, keeping mod JSON terse for authored content.
///
/// Images are loaded via <c>TextureUtils.LoadFromModGroup</c> — not
/// through a texture factory — because action character assets live
/// inside the same <c>"characters"</c> 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.
/// <para/>
/// The factory lives in the characters engine mod; its singleton is also
/// published to the kernel through <see cref="ActionTemplateRegistry"/> by
/// <c>CharactersModInitializer</c>, so core-side consumers resolve
/// templates without referencing this assembly.
/// </summary>
public class ActionCharacterFactory : ICacheLoader<string, ActionCharacter>, IActionTemplateProvider
{
/// <summary>Process-wide singleton for this mod assembly; the public API for action template lookups.</summary>
public static ActionCharacterFactory Instance { get; } = new ActionCharacterFactory();
private Dictionary<string, ActionCharacterInfo> _allInfos;
private readonly BoundedCache<string, ActionCharacter> _cache;
internal ActionCharacterFactory()
{
var size = ModConfig.Instance.Cache.GetCacheSize("actions", 128);
_cache = new BoundedCache<string, ActionCharacter>(size, this);
}
ActionCharacter ICacheLoader<string, ActionCharacter>.Load(string id)
{
var infos = EnsureInfos();
if (infos.TryGetValue(id, out var info))
return CreateActionCharacter(info);
return null;
}
/// <summary>
/// Returns the action 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, ActionCharacterInfo> EnsureInfos()
{
if (_allInfos != null)
return _allInfos;
var infos = Entities.Collect<ActionCharacterInfo>();
if (ModManager.Instance.IsInitialized)
_allInfos = infos;
return infos;
}
/// <summary>
/// Returns an independent copy of the action definition for
/// <paramref name="id"/>, or <c>null</c> when unknown. The copy is
/// deliberate: action cards carry mutable battle state, so callers
/// never share one instance.
/// </summary>
public ActionCharacter Get(string id)
{
EnsureInfos();
var item = _cache.Get(id);
return item?.Copy();
}
/// <summary>
/// Returns all known action info IDs. Used by the character panel to determine
/// which actions are not assigned to any character (the action pool).
/// </summary>
public IReadOnlyCollection<string> 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<string> 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;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using Cthangover.Core.Factories;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.Factories.Impls
{
/// <summary>
/// Thin <c>Texture2DFactory</c> for character portrait avatars. The
/// separate factory (as opposed to reusing <c>UITextureFactory</c>)
/// 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.
/// <para/>
/// The factory lives in the characters engine mod; its singleton is also
/// published to the kernel through <see cref="AvatarProviderRegistry"/> by
/// <c>CharactersModInitializer</c>, so core-side consumers resolve
/// avatars without referencing this assembly.
/// </summary>
public class AvatarFactory : Texture2DFactory, IAvatarProvider
{
internal AvatarFactory() : base("avatars", 64) { }
/// <summary>Process-wide singleton for this mod assembly; the public API for avatar lookups.</summary>
public static AvatarFactory Instance { get; } = new AvatarFactory();
/// <summary>Mod asset group scanned by this factory — files live under <c>mods/*/</c><b>avatars</b>.</summary>
public override string GroupName => "avatars";
}
}
+157
View File
@@ -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
{
/// <summary>
/// Factory for full character definitions. Returns a <b>copy</b> of
/// the cached <c>Character</c> on every <c>Get</c> call because
/// <c>Character.Attributes</c> mutate during gameplay.
///
/// Character actions are resolved through
/// <c>ActionCharacterFactory</c>. 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
/// <c>EntityResolver</c> — the factory receives already-merged
/// <c>CharacterInfo</c> DTOs routed by their <c>character</c> kind and
/// simply maps them to the runtime model.
/// <para/>
/// The factory lives in the characters engine mod; its singleton is also
/// published to the kernel through <see cref="CharacterTemplateRegistry"/>
/// by <c>CharactersModInitializer</c>, so core-side consumers resolve
/// templates without referencing this assembly.
/// </summary>
public class CharacterFactory : ICacheLoader<string, Character>, ICharacterTemplateProvider
{
/// <summary>Process-wide singleton for this mod assembly; the public API for template lookups.</summary>
public static CharacterFactory Instance { get; } = new CharacterFactory();
private Dictionary<string, CharacterInfo> _allInfos;
private readonly BoundedCache<string, Character> _cache;
internal CharacterFactory()
{
var size = ModConfig.Instance.Cache.GetCacheSize("characters", 128);
_cache = new BoundedCache<string, Character>(size, this);
}
Character ICacheLoader<string, Character>.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;
}
/// <summary>
/// Returns the character definitions, collecting them on first use.
/// The result is published to <c>_allInfos</c> only after the mod
/// registry is finalized. A stale empty snapshot cannot appear: the
/// registry refuses mod-data access until <c>Boot.IsReady</c>, so the
/// first collection always sees the final mod set, and the cached
/// snapshot is reset when <c>ModManager.Reload</c> rebuilds the
/// registry.
/// </summary>
private Dictionary<string, CharacterInfo> 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<CharacterInfo>();
if (ModManager.Instance.IsInitialized)
_allInfos = infos;
GameLogger.Log("FACTORY", $"CharacterFactory.EnsureInfos: END, loaded {infos?.Count ?? 0} infos", LogLevel.Debug);
return infos;
}
/// <summary>
/// Returns an independent copy of the character template for
/// <paramref name="id"/>, or <c>null</c> (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.
/// </summary>
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<string>()),
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<ActionCharacter> ResolveActions(List<string> actions)
{
if (actions == null || actions.Count == 0)
return new List<ActionCharacter>();
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;
}
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace Cthangover.Mods.Characters
{
/// <summary>
/// Migration marker scanned by the integration coverage test
/// (<c>SubsystemsBridge</c>): declares the characters subsystem as
/// migrated out of the core assembly into this engine mod. The
/// subsystem id must match the entry in
/// <c>SubsystemsCoverageTestSetup.DeclaredTests</c> and
/// <c>tests/tests.manifest.json</c>.
/// </summary>
public static class SubsystemRegistration
{
/// <summary>Subsystem id reported to the coverage test.</summary>
public static string SubsystemId => "characters";
}
}
@@ -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
{
/// <summary>
/// Visual representation of a single <see cref="ActionCharacter"/> 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 <c>scenes/ui/action_card.tscn</c>; 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.
/// <para/>
/// Instances are created through <see cref="ModScenePrefab"/> (the card
/// scene), never via <c>new</c> — the node tree comes from the scene.
/// The card never handles input itself: <see cref="CharacterPanelBehaviour"/>
/// owns drag orchestration through its node-level <c>_Input</c> (GuiInput
/// stops firing once the cursor leaves the card mid-drag). The card only
/// exposes state setters (<see cref="SetSelected"/>, <see cref="SetCompact"/>)
/// and is moved between containers by the panel/slot.
/// </summary>
public partial class ActionCardBehaviour : Control
{
private const float NormalHeight = 86f;
private const float CompactHeight = 60f;
/// <summary>Factory action bound to this card; <c>null</c> before <see cref="SetAction"/>.</summary>
public ActionCharacter ActionData { get; private set; }
/// <summary>Slot that currently owns the card, or <c>null</c> when the card lives in the pool.</summary>
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;
/// <summary>Binds hover feedback; node references resolve lazily so the card can be bound before entering the tree.</summary>
public ActionCardBehaviour()
{
MouseFilter = MouseFilterEnum.Stop;
MouseEntered += OnMouseEntered;
MouseExited += OnMouseExited;
}
/// <summary>Re-anchors to the parent's full rect for slot placement, mirroring the scene-root layout.</summary>
public override void _Ready()
{
}
/// <summary>
/// Binds the action data and refreshes every rendered element: icon,
/// translated name, ID watermark, type/cost/cooldown badges, property
/// chips and the rich tooltip.
/// </summary>
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);
}
/// <summary>
/// Compact mode drops the property chip row and shrinks the card to the
/// single-line pool layout; slot cards use the expanded layout.
/// </summary>
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;
}
}
/// <summary>Gold frame for click-selected cards; drag state is handled by the panel.</summary>
public void SetSelected(bool selected)
{
_selected = selected;
ApplyFrame();
}
/// <summary>
/// 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 <see cref="ActionPoolBehaviour"/>.
/// </summary>
public void ReturnToOrigin()
{
if (CurrentSlot == null)
return;
if (GetParent() != CurrentSlot)
{
GetParent()?.RemoveChild(this);
CurrentSlot.AddChild(this);
}
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
}
/// <summary>Marks the card as detached from every container while it follows the cursor.</summary>
public void SetDragging(bool dragging)
{
Modulate = dragging ? UiTokens.Alpha(UiTokens.White, 0.55f) : UiTokens.White;
}
private void ResolveNodes()
{
_frame ??= GetNodeOrNull<Panel>("Frame");
_accent ??= GetNodeOrNull<ColorRect>("Accent");
_icon ??= GetNodeOrNull<TextureRect>("Icon");
_nameLabel ??= GetNodeOrNull<Label>("Body/NameRow/NameLabel");
_idLabel ??= GetNodeOrNull<Label>("Body/NameRow/IdLabel");
_badgesRow ??= GetNodeOrNull<HBoxContainer>("Body/BadgesRow");
_propsRow ??= GetNodeOrNull<HFlowContainer>("Body/PropsRow");
if (_frame != null && !_frame.HasThemeStyleboxOverride("panel"))
_frame.AddThemeStyleboxOverride("panel", NormalFrame);
}
private void BuildBadges(ActionCharacter action)
{
ClearChildren(_badgesRow);
var accent = ActionCharacterUi.AccentColor(action.Type);
_accent.Color = accent;
_badgesRow.AddChild(ActionCharacterUi.MakeBadge(
ActionCharacterUi.Translate(ActionCharacterUi.TypeLabelKey(action.Type)).ToUpperInvariant(), accent));
var costText = ActionCharacterUi.CostText(action);
if (costText != null)
_badgesRow.AddChild(ActionCharacterUi.MakeBadge(costText, UiTokens.AccentBright));
var cooldownText = ActionCharacterUi.CooldownText(action);
if (cooldownText != null)
_badgesRow.AddChild(ActionCharacterUi.MakeBadge(cooldownText, UiTokens.Cooldown));
}
private void BuildProperties(ActionCharacter action)
{
ClearChildren(_propsRow);
foreach (var pair in ActionCharacterUi.SortedProperties(action))
_propsRow.AddChild(ActionCharacterUi.MakePropertyChip(pair.Key, pair.Value));
_propsRow.Visible = !_compact && _propsRow.GetChildCount() > 0;
}
private static void ClearChildren(Node parent)
{
if (parent == null)
return;
foreach (var child in parent.GetChildren())
child.QueueFree();
}
private void OnMouseEntered()
{
_hovered = true;
ApplyFrame();
}
private void OnMouseExited()
{
_hovered = false;
ApplyFrame();
}
private void ApplyFrame()
{
ResolveNodes();
if (_frame == null)
return;
_frame.AddThemeStyleboxOverride("panel",
_selected ? SelectedFrame : (_hovered ? HoverFrame : NormalFrame));
}
}
}
+255
View File
@@ -0,0 +1,255 @@
using System;
using System.Collections.Generic;
using System.Text;
using Cthangover.Core.Characters;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.Core.UI.CharacterPanel
{
/// <summary>
/// Shared presentation rules for battle action cards, used by the card,
/// the slot, the pool and the panel legend so they can never disagree
/// about an action's colour, badge caption, cost or cooldown. The action
/// model itself is deliberately schema-free — <see cref="ActionCharacter.Properties"/>
/// is a free-form string bag — so all "what to show" decisions live here
/// rather than on the model: known keys (<c>RequiredPoint</c>,
/// <c>ActiveCooldown</c>) get typed rendering, unknown keys are listed
/// verbatim. The palette is a thin alias layer over <see cref="UiTokens"/>
/// so the panel shares the central design system.
/// </summary>
public static class ActionCharacterUi
{
/// <summary>
/// Filter tabs of the action pool: <c>null</c> type means "all".
/// Order defines how the tabs are presented to the player.
/// </summary>
public static readonly (ActionCharacterType? Type, string Key)[] Filters =
{
(null, "ui/character_panel/filter_all"),
(ActionCharacterType.ToEnemy, "ui/character_panel/type_enemy"),
(ActionCharacterType.ToAlias, "ui/character_panel/type_alias"),
(ActionCharacterType.ToSelf, "ui/character_panel/type_self"),
(ActionCharacterType.Passive, "ui/character_panel/type_passive"),
(ActionCharacterType.Active, "ui/character_panel/type_active"),
};
/// <summary>Legend order of action types; matches <see cref="Filters"/> minus "all".</summary>
public static readonly ActionCharacterType[] LegendTypes =
{
ActionCharacterType.ToEnemy,
ActionCharacterType.ToAlias,
ActionCharacterType.ToSelf,
ActionCharacterType.Passive,
ActionCharacterType.Active,
};
/// <summary>Standard text alias for card body copy.</summary>
public static readonly Color Ink = UiTokens.Ink;
/// <summary>Title ink alias for card names.</summary>
public static readonly Color InkBright = UiTokens.InkBright;
/// <summary>Body text alias (descriptions and properties).</summary>
public static readonly Color Body = UiTokens.Body;
/// <summary>Muted text alias (badges and legends).</summary>
public static readonly Color Muted = UiTokens.Muted;
/// <summary>Secondary text alias (captions and metadata).</summary>
public static readonly Color Faint = UiTokens.Faint;
/// <summary>Primary accent alias for active card elements.</summary>
public static readonly Color Gold = UiTokens.Accent;
/// <summary>Bright accent alias for emphasized text and glyphs.</summary>
public static readonly Color GoldBright = UiTokens.AccentBright;
/// <summary>Default hairline border alias.</summary>
public static readonly Color Line = UiTokens.Line;
/// <summary>Card border alias.</summary>
public static readonly Color CardLine = UiTokens.LineCard;
/// <summary>Card fill alias.</summary>
public static readonly Color CardBg = UiTokens.Surface1;
/// <summary>List cell fill alias.</summary>
public static readonly Color CellBg = UiTokens.Surface2;
/// <summary>Danger accent alias for cost and damage captions.</summary>
public static readonly Color Danger = UiTokens.Danger;
/// <summary>Type accent colour following the mockup palette: enemy, ally, self, passive, active.</summary>
public static Color AccentColor(ActionCharacterType type)
{
switch (type)
{
case ActionCharacterType.ToEnemy:
return UiTokens.TypeEnemy;
case ActionCharacterType.ToAlias:
return UiTokens.TypeTarget;
case ActionCharacterType.ToSelf:
return UiTokens.TypeSelf;
case ActionCharacterType.Passive:
return UiTokens.TypePassive;
case ActionCharacterType.Active:
return UiTokens.TypeActive;
default:
return UiTokens.Muted;
}
}
/// <summary>Locale key of the badge caption for an action type.</summary>
public static string TypeLabelKey(ActionCharacterType type)
{
switch (type)
{
case ActionCharacterType.ToEnemy:
return "ui/character_panel/type_enemy";
case ActionCharacterType.ToAlias:
return "ui/character_panel/type_alias";
case ActionCharacterType.ToSelf:
return "ui/character_panel/type_self";
case ActionCharacterType.Passive:
return "ui/character_panel/type_passive";
case ActionCharacterType.Active:
return "ui/character_panel/type_active";
default:
return "ui/character_panel/type_self";
}
}
/// <summary>
/// Action point cost read from the free-form property bag. Absent or
/// non-positive values mean the action is free to slot (passive/active
/// cards authored without a battle cost).
/// </summary>
public static int CostOf(ActionCharacter action)
{
if (action?.Properties == null)
return 0;
return action.GetInt("RequiredPoint", 0);
}
/// <summary>Loc-format cost caption ("1 ОД"), or <c>null</c> when the action has no cost.</summary>
public static string CostText(ActionCharacter action)
{
var cost = CostOf(action);
return cost > 0 ? string.Format(Translate("ui/character_panel/cost"), cost) : null;
}
/// <summary>
/// Seconds left before an <see cref="ActionCharacterType.Active"/> card
/// can be used again. Zero for every other type, for cards that were
/// never used (<c>LastUsed</c> absent) and for cards without a positive
/// <c>ActiveCooldown</c>.
/// </summary>
public static long CooldownRemaining(ActionCharacter action)
{
if (action == null || action.Type != ActionCharacterType.Active || action.Properties == null)
return 0;
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return action.GetRemainingCooldown(now);
}
/// <summary>Cooldown badge caption ("КД 42:10") or <c>null</c> when the card is ready.</summary>
public static string CooldownText(ActionCharacter action)
{
var remaining = CooldownRemaining(action);
return remaining > 0
? string.Format(Translate("ui/character_panel/cooldown"), FormatDuration(remaining))
: null;
}
/// <summary>Formats seconds as <c>M:SS</c> or <c>H:MM:SS</c> for long cooldowns.</summary>
public static string FormatDuration(long seconds)
{
var span = TimeSpan.FromSeconds(Mathf.Max(0, seconds));
return span.TotalHours >= 1
? $"{(int)span.TotalHours}:{span.Minutes:00}:{span.Seconds:00}"
: $"{span.Minutes:00}:{span.Seconds:00}";
}
/// <summary>
/// Property entries of an action sorted by key for a stable display.
/// Keys are free-form mod data, so no whitelist filtering is applied.
/// </summary>
public static List<KeyValuePair<string, string>> SortedProperties(ActionCharacter action)
{
var result = new List<KeyValuePair<string, string>>();
if (action?.Properties?.Values == null)
return result;
foreach (var pair in action.Properties.Values)
result.Add(pair);
result.Sort((a, b) => string.CompareOrdinal(a.Key, b.Key));
return result;
}
/// <summary>Plain tooltip assembled from the card fields; markup tags are stripped.</summary>
public static string BuildTooltip(ActionCharacter action)
{
if (action == null)
return string.Empty;
var builder = new StringBuilder();
builder.AppendLine(Translate(action.Name));
builder.AppendLine(Translate(TypeLabelKey(action.Type)) + " · " + action.ID);
var description = StripMarkup(Translate(action.Description));
if (!string.IsNullOrEmpty(description))
builder.AppendLine(description);
var properties = SortedProperties(action);
if (properties.Count > 0)
{
builder.AppendLine();
foreach (var pair in properties)
builder.AppendLine($"{pair.Key}: {pair.Value}");
}
return builder.ToString().TrimEnd();
}
/// <summary>Removes view tags (e.g. <c>&lt;g&gt;1.0&lt;/g&gt;</c>) from localized descriptions.</summary>
public static string StripMarkup(string text)
{
if (string.IsNullOrEmpty(text))
return string.Empty;
var builder = new StringBuilder(text.Length);
var inTag = false;
foreach (var symbol in text)
{
if (symbol == '<')
{
inTag = true;
continue;
}
if (symbol == '>')
{
inTag = false;
continue;
}
if (!inTag)
builder.Append(symbol);
}
return builder.ToString();
}
/// <summary>Translates a locale key, falling back to the key itself for literals.</summary>
public static string Translate(string keyOrLiteral)
{
if (string.IsNullOrEmpty(keyOrLiteral))
return string.Empty;
return TranslationServer.Translate(keyOrLiteral).ToString();
}
/// <summary>Small bordered caption chip used for types, costs and cooldowns.</summary>
public static Control MakeBadge(string text, Color color, int fontSize = 10)
=> UiKit.Badge(text, color, fontSize);
/// <summary>Property chip ("Attack 1.0") for the expanded card body.</summary>
public static Control MakePropertyChip(string key, string value)
=> UiKit.PropertyChip(key, value);
/// <summary>Standard dark frame for cards, slots and list rows.</summary>
public static StyleBoxFlat MakeFrame(Color border, Color background, int radius = 6, int borderWidth = 1)
=> UiKit.Frame(radius, background, border, borderWidth);
/// <summary>Convenience label factory mirroring the inventory panel's styling.</summary>
public static Label MakeLabel(string text, int size, Color color)
=> UiKit.Label(text, size, color);
}
}
@@ -0,0 +1,257 @@
using System.Collections.Generic;
using Cthangover.Core.Characters;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.Core.UI.CharacterPanel
{
/// <summary>
/// Right-hand column of the character panel: the player's persistent
/// collection of unassigned action cards. Shows exactly what is stored in
/// <see cref="Settings.ActionPoolData.ActionIds"/> — no filtering by
/// assigned slots, duplicates allowed — and layers display-only search and
/// type filters on top. The static layout (header, search box, filter tab
/// row, scroll list, footer hint) lives in
/// <c>scenes/ui/action_pool.tscn</c>; the panel hosts the scene instance
/// and this behaviour resolves its nodes. Cards are rebuilt from
/// <see cref="ActionCharacterFactory"/> on every refresh, so the pool is a
/// pure projection of save data; drag orchestration stays in
/// <see cref="CharacterPanelBehaviour"/>.
/// </summary>
public partial class ActionPoolBehaviour : Control
{
private static readonly StyleBoxFlat InputStyle = MakeInputStyle(false);
private static readonly StyleBoxFlat InputFocusStyle = MakeInputStyle(true);
private readonly List<string> _ids = new();
private readonly List<ActionCardBehaviour> _cards = new();
private ActionCharacterType? _filter;
private string _search = string.Empty;
private Label _countLabel;
private Label _title;
private LineEdit _searchEdit;
private HFlowContainer _tabsRow;
private ScrollContainer _scroll;
private VBoxContainer _list;
private Label _footer;
/// <summary>Cards currently visible in the list, in list order.</summary>
public IReadOnlyList<ActionCardBehaviour> VisibleCards => _cards;
/// <summary>
/// Repopulates the pool from the persisted ID list. Every ID gets a
/// card, including duplicates; unknown IDs are skipped silently.
/// Search/filter state is preserved.
/// </summary>
public void Refresh(List<string> poolActionIds)
{
_ids.Clear();
if (poolActionIds != null)
_ids.AddRange(poolActionIds);
Rebuild();
}
/// <summary>Accepts a dragged card back into the pool; the data list is updated by the panel.</summary>
public void AcceptCard(ActionCardBehaviour card)
{
if (card == null)
return;
card.CurrentSlot?.RemoveCard();
card.CurrentSlot = null;
card.SetSelected(false);
card.SetCompact(true);
if (card.GetParent() != _list)
{
card.GetParent()?.RemoveChild(card);
_list.AddChild(card);
}
card.SizeFlagsHorizontal = SizeFlags.ExpandFill;
_cards.Add(card);
}
/// <summary>Detaches a card from the pool visuals without touching save data.</summary>
public void RemoveCard(ActionCardBehaviour card)
{
if (card == null)
return;
_cards.Remove(card);
card.GetParent()?.RemoveChild(card);
}
private void ResolveNodes()
{
_title ??= GetNodeOrNull<Label>("Root/Header/Title");
_countLabel ??= GetNodeOrNull<Label>("Root/Header/CountLabel");
_searchEdit ??= GetNodeOrNull<LineEdit>("Root/SearchEdit");
_tabsRow ??= GetNodeOrNull<HFlowContainer>("Root/TabsRow");
_scroll ??= GetNodeOrNull<ScrollContainer>("Root/Scroll");
_list ??= GetNodeOrNull<VBoxContainer>("Root/Scroll/List");
_footer ??= GetNodeOrNull<Label>("Root/Footer");
if (_searchEdit == null)
return;
if (string.IsNullOrEmpty(_searchEdit.PlaceholderText))
{
_searchEdit.PlaceholderText = ActionCharacterUi.Translate("ui/character_panel/search");
_searchEdit.AddThemeFontSizeOverride("font_size", 12);
_searchEdit.AddThemeStyleboxOverride("normal", InputStyle);
_searchEdit.AddThemeStyleboxOverride("focus", InputFocusStyle);
_searchEdit.AddThemeColorOverride("font_color", UiTokens.Ink);
_searchEdit.AddThemeColorOverride("font_placeholder_color", UiTokens.Faint);
_searchEdit.AddThemeColorOverride("caret_color", UiTokens.Accent);
_searchEdit.TextChanged += OnSearchChanged;
}
if (_title != null && string.IsNullOrEmpty(_title.Text))
{
_title.Text = ActionCharacterUi.Translate("ui/character_panel/pool_title").ToUpperInvariant();
_title.AddThemeColorOverride("font_color", UiTokens.Ink);
_title.AddThemeFontSizeOverride("font_size", 14);
}
if (_countLabel != null && string.IsNullOrEmpty(_countLabel.Text))
{
_countLabel.Text = "0";
_countLabel.AddThemeColorOverride("font_color", UiTokens.Body);
_countLabel.AddThemeFontSizeOverride("font_size", 11);
}
if (_footer != null && string.IsNullOrEmpty(_footer.Text))
{
_footer.Text = ActionCharacterUi.Translate("ui/character_panel/pool_hint");
_footer.AddThemeColorOverride("font_color", UiTokens.Faint);
}
}
private void Rebuild()
{
ResolveNodes();
if (_list == null || _countLabel == null)
return;
foreach (var card in _cards)
card.QueueFree();
_cards.Clear();
foreach (var id in _ids)
{
var action = ActionCharacterFactory.Instance.Get(id);
if (action == null || !Matches(action))
continue;
var card = CharacterPanelPrefabs.InstantiateCard();
if (card == null)
continue;
card.SetCompact(true);
card.SetAction(action);
card.SizeFlagsHorizontal = SizeFlags.ExpandFill;
_list.AddChild(card);
_cards.Add(card);
}
if (_cards.Count == 0)
_list.AddChild(BuildEmptyLabel());
_countLabel.Text = _ids.Count.ToString();
BuildTabs();
}
private Control BuildEmptyLabel()
{
var hasAny = _ids.Count > 0;
var label = ActionCharacterUi.MakeLabel(
ActionCharacterUi.Translate(hasAny
? "ui/character_panel/nothing_found"
: "ui/character_panel/pool_empty"), 12, UiTokens.Faint);
label.HorizontalAlignment = HorizontalAlignment.Center;
label.AutowrapMode = TextServer.AutowrapMode.WordSmart;
label.CustomMinimumSize = new Vector2(0, 80);
label.VerticalAlignment = VerticalAlignment.Center;
return label;
}
private void BuildTabs()
{
if (_tabsRow == null)
return;
foreach (var child in _tabsRow.GetChildren())
child.QueueFree();
foreach (var (type, key) in ActionCharacterUi.Filters)
{
var button = new Button
{
Text = ActionCharacterUi.Translate(key) + " " + CountByType(type),
ToggleMode = true,
ButtonPressed = type == _filter,
FocusMode = FocusModeEnum.None,
};
CthangoverTheme.ApplyTab(button);
var captured = type;
button.Pressed += () => OnFilterPressed(captured);
_tabsRow.AddChild(button);
}
}
private int CountByType(ActionCharacterType? type)
{
if (type == null)
return _ids.Count;
var count = 0;
foreach (var id in _ids)
{
var action = ActionCharacterFactory.Instance.Get(id);
if (action != null && action.Type == type)
count++;
}
return count;
}
private bool Matches(ActionCharacter action)
{
if (_filter != null && action.Type != _filter)
return false;
if (string.IsNullOrWhiteSpace(_search))
return true;
var needle = _search.Trim().ToLowerInvariant();
if (ActionCharacterUi.Translate(action.Name).ToLowerInvariant().Contains(needle))
return true;
return action.ID != null && action.ID.ToLowerInvariant().Contains(needle);
}
private void OnFilterPressed(ActionCharacterType? type)
{
_filter = type;
Rebuild();
}
private void OnSearchChanged(string text)
{
_search = text;
Rebuild();
}
private static StyleBoxFlat MakeInputStyle(bool focus)
{
var style = UiKit.Frame(UiTokens.RadiusSm, UiTokens.Well,
focus ? UiTokens.AccentLine : UiTokens.Line);
style.ContentMarginLeft = 10;
style.ContentMarginRight = 10;
style.ContentMarginTop = 8;
style.ContentMarginBottom = 8;
return style;
}
}
}
@@ -0,0 +1,149 @@
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
{
/// <summary>
/// A single action socket on a character card. Holds at most one
/// <see cref="ActionCardBehaviour"/> or shows an empty drop hint. The
/// static visuals (frame panel, "СЛОТ N" caption, empty hint) live in
/// <c>scenes/ui/action_slot.tscn</c>; instances are created by
/// <see cref="CharacterCardBehaviour"/> through <see cref="ModScenePrefab"/>,
/// never via <c>new</c>. The slot owns only its own visuals; drag input,
/// hit-testing and swap rules live in <see cref="CharacterPanelBehaviour"/>,
/// which asks the slot to highlight while a card hovers over it. Assignment
/// changes are written back to save data by
/// <see cref="CharacterCardBehaviour.CommitSlots"/>.
/// </summary>
public partial class ActionSlotBehaviour : Control
{
/// <summary>Character ID this slot belongs to; part of the save-data key.</summary>
public string CharacterId { get; private set; }
/// <summary>Zero-based slot index within the character card.</summary>
public int SlotIndex { get; private set; }
/// <summary>Card currently placed in the slot, or <c>null</c> when empty.</summary>
public ActionCardBehaviour CurrentCard { get; private set; }
/// <summary>True when no card is placed; empty slots accept any card.</summary>
public bool IsEmpty => CurrentCard == null;
private static readonly StyleBoxFlat EmptyFrame = UiKit.Frame(
UiTokens.RadiusMd, UiTokens.Surface0, UiTokens.LineFaint);
private static readonly StyleBoxFlat FilledFrame = UiKit.Frame(
UiTokens.RadiusMd, UiTokens.Surface1, UiTokens.LineCard);
private static readonly StyleBoxFlat HoverFrame = UiKit.Frame(
UiTokens.RadiusMd, UiTokens.DropBg, UiTokens.TypePassive, 2);
private Panel _frame;
private Label _slotLabel;
private Label _emptyLabel;
private bool _highlighted;
/// <summary>
/// Binds the save-data identity of the slot and renders the "СЛОТ N"
/// caption. Must be called by the owning card before use.
/// </summary>
public void Init(string characterId, int slotIndex)
{
CharacterId = characterId;
SlotIndex = slotIndex;
ResolveNodes();
_slotLabel.Text = string.Format(
ActionCharacterUi.Translate("ui/character_panel/slot"), slotIndex + 1).ToUpperInvariant();
UpdateState();
}
/// <summary>
/// Places the given card into this slot, detaching it from any previous
/// slot. Passing <c>null</c> clears the slot. The displaced card (if any)
/// is handed back to the caller by returning it through
/// <see cref="CurrentCard"/> before the call.
/// </summary>
public void PlaceCard(ActionCardBehaviour card)
{
if (card == null)
{
RemoveCard();
return;
}
if (CurrentCard != null && CurrentCard != card)
{
if (CurrentCard.GetParent() == this)
RemoveChild(CurrentCard);
CurrentCard.CurrentSlot = null;
CurrentCard = null;
}
card.CurrentSlot?.RemoveCard();
card.CurrentSlot = this;
if (card.GetParent() != this)
{
card.GetParent()?.RemoveChild(card);
AddChild(card);
}
card.SetCompact(false);
card.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
CurrentCard = card;
UpdateState();
}
/// <summary>Detaches the current card without destroying it; the card object stays valid for swapping.</summary>
public void RemoveCard()
{
if (CurrentCard == null)
return;
CurrentCard.CurrentSlot = null;
CurrentCard = null;
UpdateState();
}
/// <summary>Action ID currently in this slot, or <c>null</c> when empty.</summary>
public string GetActionId()
{
return CurrentCard?.ActionData?.ID;
}
/// <summary>Green frame while a dragged card hovers over the slot.</summary>
public void SetHighlight(bool highlighted)
{
_highlighted = highlighted;
ApplyFrame();
}
private void ResolveNodes()
{
_frame ??= GetNodeOrNull<Panel>("Frame");
_slotLabel ??= GetNodeOrNull<Label>("SlotLabel");
_emptyLabel ??= GetNodeOrNull<Label>("EmptyLabel");
if (_emptyLabel != null && string.IsNullOrEmpty(_emptyLabel.Text))
_emptyLabel.Text = ActionCharacterUi.Translate("ui/character_panel/slot_empty");
}
private void UpdateState()
{
ResolveNodes();
if (_emptyLabel != null)
_emptyLabel.Visible = CurrentCard == null;
ApplyFrame();
}
private void ApplyFrame()
{
ResolveNodes();
if (_frame == null)
return;
_frame.AddThemeStyleboxOverride("panel",
_highlighted ? HoverFrame : (CurrentCard == null ? EmptyFrame : FilledFrame));
}
}
}
@@ -0,0 +1,185 @@
using System.Collections.Generic;
using Cthangover.Core.Characters;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.Core.UI.CharacterPanel
{
/// <summary>
/// Party member column of the character panel: portrait, name, derived
/// stats and the three <see cref="ActionSlotBehaviour"/> sockets that the
/// player fills from the action pool. The static layout (margins, header,
/// stat row, slot stack with the three <c>action_slot.tscn</c> instances)
/// lives in <c>scenes/ui/character_card.tscn</c>; instances are created by
/// the panel through <see cref="ModScenePrefab"/>, never via <c>new</c>.
/// <para/>
/// The card owns the slot→save-data binding: <see cref="RefreshSlots"/>
/// reads <see cref="CharacterInfoData.ActionSlots"/> and materializes
/// action cards (through the card prefab), <see cref="CommitSlots"/> writes
/// the current placement back. Mods extend the card pool through
/// <c>ActionCharacterFactory</c> data, not through scene overrides.
/// </summary>
public partial class CharacterCardBehaviour : PanelContainer
{
/// <summary>Factory key of the character shown by this card.</summary>
public string CharacterId { get; private set; }
/// <summary>Fixed three sockets, index 0..2 maps to save-data slot order.</summary>
public ActionSlotBehaviour[] Slots { get; } = new ActionSlotBehaviour[3];
private static readonly StyleBoxFlat CardStyle = UiKit.Frame(
UiTokens.RadiusMd, UiTokens.SurfaceRaised, UiTokens.Line);
private TextureRect _portrait;
private Label _nameLabel;
private Label _subLabel;
private HFlowContainer _statsRow;
private CharacterInfoData _characterInfo;
private bool _styled;
/// <summary>Applies the card frame; node references resolve lazily so the card can be bound before entering the tree.</summary>
public CharacterCardBehaviour()
{
MouseFilter = MouseFilterEnum.Stop;
}
/// <summary>
/// Populates the card from a runtime character entry and its factory
/// template: portrait, localized name, template size caption, derived
/// stats, then the slot cards themselves.
/// </summary>
public void SetCharacter(string characterId, CharacterInfoData info)
{
CharacterId = characterId;
_characterInfo = info;
ResolveNodes();
var template = CharacterFactory.Instance.Get(characterId);
if (_portrait != null)
_portrait.Texture = template?.Image;
if (_nameLabel != null)
_nameLabel.Text = ActionCharacterUi.Translate(template?.Name ?? characterId);
if (_subLabel != null)
_subLabel.Text = $"{characterId} · " + string.Format(
ActionCharacterUi.Translate("ui/character_panel/template"), template?.Actions?.Count ?? 0);
BuildStats();
RefreshSlots();
}
/// <summary>
/// Rebuilds all three sockets from <see cref="CharacterInfoData.ActionSlots"/>.
/// Missing list entries are normalized to three slots with trailing
/// <c>null</c>s, matching the save format written by <see cref="CommitSlots"/>.
/// </summary>
public void RefreshSlots()
{
if (_characterInfo == null)
return;
if (_characterInfo.ActionSlots == null)
_characterInfo.ActionSlots = new List<string> { null, null, null };
while (_characterInfo.ActionSlots.Count < 3)
_characterInfo.ActionSlots.Add(null);
for (var i = 0; i < 3; i++)
{
var slot = Slots[i];
if (slot == null)
continue;
slot.Init(CharacterId, i);
if (slot.CurrentCard != null)
{
slot.CurrentCard.QueueFree();
slot.RemoveCard();
}
var actionId = _characterInfo.ActionSlots[i];
if (string.IsNullOrEmpty(actionId))
continue;
var action = ActionCharacterFactory.Instance.Get(actionId);
if (action == null)
continue;
var card = CharacterPanelPrefabs.InstantiateCard();
if (card == null)
continue;
slot.AddChild(card);
card.SetAction(action);
slot.PlaceCard(card);
}
}
/// <summary>Writes the current socket assignments back to <see cref="CharacterInfoData.ActionSlots"/>.</summary>
public void CommitSlots()
{
if (_characterInfo == null)
return;
if (_characterInfo.ActionSlots == null)
_characterInfo.ActionSlots = new List<string>();
while (_characterInfo.ActionSlots.Count < 3)
_characterInfo.ActionSlots.Add(null);
for (var i = 0; i < 3; i++)
_characterInfo.ActionSlots[i] = Slots[i]?.GetActionId();
}
private void ResolveNodes()
{
_portrait ??= GetNodeOrNull<TextureRect>("Margin/Root/Header/PortraitPanel/Portrait");
_nameLabel ??= GetNodeOrNull<Label>("Margin/Root/Header/Info/NameLabel");
_subLabel ??= GetNodeOrNull<Label>("Margin/Root/Header/Info/SubLabel");
_statsRow ??= GetNodeOrNull<HFlowContainer>("Margin/Root/Header/Info/StatsRow");
if (!_styled)
{
_styled = true;
AddThemeStyleboxOverride("panel", CardStyle);
}
if (Slots[0] == null)
{
Slots[0] = GetNodeOrNull<ActionSlotBehaviour>("Margin/Root/SlotsBox/Slot0");
Slots[1] = GetNodeOrNull<ActionSlotBehaviour>("Margin/Root/SlotsBox/Slot1");
Slots[2] = GetNodeOrNull<ActionSlotBehaviour>("Margin/Root/SlotsBox/Slot2");
}
}
private void BuildStats()
{
ClearChildren(_statsRow);
var attributes = _characterInfo?.Attributes;
if (attributes == null)
return;
var health = attributes.Health;
var healthText = health.BaseValue > 0
? $"{health.Value}/{health.BaseValue}"
: health.Value.ToString();
_statsRow.AddChild(UiKit.StatChip(
ActionCharacterUi.Translate("ui/character_panel/stat_hp"), healthText, UiTokens.HpText));
_statsRow.AddChild(UiKit.StatChip(
ActionCharacterUi.Translate("ui/character_panel/stat_ap"), attributes.Point.Value.ToString(), UiTokens.AccentBright));
_statsRow.AddChild(UiKit.StatChip(
ActionCharacterUi.Translate("ui/character_panel/stat_atk"), attributes.Attack.Value.ToString(), UiTokens.Ink));
_statsRow.AddChild(UiKit.StatChip(
ActionCharacterUi.Translate("ui/character_panel/stat_def"), attributes.Defence.Value.ToString(), UiTokens.Ink));
}
private static void ClearChildren(Node parent)
{
if (parent == null)
return;
foreach (var child in parent.GetChildren())
child.QueueFree();
}
}
}
@@ -0,0 +1,587 @@
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Characters;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Settings;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.Core.UI.CharacterPanel
{
/// <summary>
/// Character action panel: a header with the type legend chips and the
/// close button, a party column of <see cref="CharacterCardBehaviour"/>
/// and the <see cref="ActionPoolBehaviour"/> on the right, and a footer
/// with the assignment counter and reset button.
///
/// The static layout (backdrop, panel frame, headers, spacers, scroll
/// hosts) lives in <c>scenes/ui/character_panel.tscn</c>; this behaviour
/// resolves those nodes and owns only the dynamic layer: legend badges,
/// localized captions, character cards and their action cards (data-driven
/// widgets built from factory data), and the drag input model.
///
/// Input model: drag orchestration and click-assignment both run through
/// the node-level `_Input`, not per-card `GuiInput` — during a
/// drag the cursor leaves the card rect and GuiInput would stop firing.
/// A press on a card starts a potential drag; passing the 5 px threshold
/// promotes it to a real drag. A press without motion selects the card
/// (gold frame); clicking a socket afterwards moves or swaps it. Dropping
/// outside a socket and outside the pool returns the card to its origin.
/// </summary>
public partial class CharacterPanelBehaviour : Widget
{
private const int DragThreshold = 5;
// Scene nodes (character_panel.tscn)
private Label _title;
private HFlowContainer _legend;
private Label _dragHint;
private Button _closeButton;
private Label _partyTitle;
private Label _partyCountLabel;
private Label _partySub;
private HFlowContainer _charactersContainer;
private Control _actionPoolHost;
private Label _statusLabel;
private Button _resetButton;
private ActionPoolBehaviour _actionPool;
private ActionPoolData _actionPoolData;
private CanvasLayer _dragCanvas;
private readonly List<CharacterCardBehaviour> _characterCards = new();
private ActionCardBehaviour _draggedCard;
private ActionCardBehaviour _potentialCard;
private ActionCardBehaviour _selectedCard;
private ActionSlotBehaviour _hoveredSlot;
private Vector2 _localMouseOffset;
private Vector2 _dragStartMousePos;
private int _skipFrames;
/// <summary>Binds scene nodes, applies theme and localized captions, and wires the dynamic layer.</summary>
protected override void OnceConstruct()
{
MouseFilter = MouseFilterEnum.Stop;
_actionPoolData = GameData.Instance.Runtime.ActionPool;
_title = GetNode<Label>("Panel/Margin/Root/Header/Title");
_legend = GetNode<HFlowContainer>("Panel/Margin/Root/Header/Legend");
_dragHint = GetNode<Label>("Panel/Margin/Root/Header/DragHint");
_closeButton = GetNode<Button>("Panel/Margin/Root/Header/CloseButton");
_partyTitle = GetNode<Label>("Panel/Margin/Root/Body/Left/TitleRow/PartyTitle");
_partyCountLabel = GetNode<Label>("Panel/Margin/Root/Body/Left/TitleRow/PartyCount");
_partySub = GetNode<Label>("Panel/Margin/Root/Body/Left/TitleRow/PartySub");
_charactersContainer = GetNode<HFlowContainer>("Panel/Margin/Root/Body/Left/Scroll/CharactersContainer");
_actionPoolHost = GetNode<Control>("Panel/Margin/Root/Body/Right/ActionPool");
_statusLabel = GetNode<Label>("Panel/Margin/Root/Footer/StatusLabel");
_resetButton = GetNode<Button>("Panel/Margin/Root/Footer/ResetButton");
ApplyTheme();
ApplyCaptions();
_dragCanvas = new CanvasLayer { Name = "DragCanvas", Layer = 100 };
AddChild(_dragCanvas);
_actionPool = CharacterPanelPrefabs.Pool.Instantiate<ActionPoolBehaviour>();
_actionPoolHost.AddChild(_actionPool);
_closeButton.Pressed += Hide;
_resetButton.Pressed += OnResetClick;
foreach (var type in ActionCharacterUi.LegendTypes)
{
_legend.AddChild(ActionCharacterUi.MakeBadge(
ActionCharacterUi.Translate(ActionCharacterUi.TypeLabelKey(type)).ToUpperInvariant(),
ActionCharacterUi.AccentColor(type)));
}
}
/// <summary>Theme overrides for scene nodes that depend on UiKit tokens (styles stay code-side like the rest of the toolkit).</summary>
private void ApplyTheme()
{
var panel = GetNode<Panel>("Panel");
panel.AddThemeStyleboxOverride("panel", UiKit.Frame(
UiTokens.RadiusLg, UiTokens.Surface0, UiTokens.Line));
_title.AddThemeColorOverride("font_color", ActionCharacterUi.InkBright);
_dragHint.AddThemeColorOverride("font_color", UiTokens.Faint);
_dragHint.AddThemeColorOverride("font_outline_color", UiTokens.Alpha(Colors.Black, 1f));
_dragHint.AddThemeConstantOverride("outline_size", 4);
_partyTitle.AddThemeColorOverride("font_color", UiTokens.Muted);
_partyCountLabel.AddThemeColorOverride("font_color", UiTokens.Body);
_partySub.AddThemeColorOverride("font_color", UiTokens.Faint);
_statusLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
var right = GetNode<PanelContainer>("Panel/Margin/Root/Body/Right");
var style = UiKit.Frame(0, UiTokens.SideBg);
style.BorderColor = UiTokens.Line;
style.BorderWidthLeft = 1;
style.ContentMarginLeft = 16;
style.ContentMarginRight = 16;
style.ContentMarginTop = 14;
style.ContentMarginBottom = 14;
right.AddThemeStyleboxOverride("panel", style);
foreach (var ruleName in new[] { "Rule1", "Rule2" })
{
var rule = GetNode<Panel>($"Panel/Margin/Root/{ruleName}");
rule.AddThemeStyleboxOverride("panel", UiKit.Frame(0, UiTokens.Line, UiTokens.Line));
}
CthangoverTheme.ApplyIcon(_closeButton);
CthangoverTheme.ApplyDanger(_resetButton);
var backdrop = GetNode<ColorRect>("Backdrop");
UiKit.ApplyPanelBackdrop(backdrop);
}
/// <summary>Localized captions for all static labels of the scene.</summary>
private void ApplyCaptions()
{
_title.Text = ActionCharacterUi.Translate("ui/character_panel/title").ToUpperInvariant();
_dragHint.Text = ActionCharacterUi.Translate("ui/character_panel/drag_hint");
_partyTitle.Text = ActionCharacterUi.Translate("ui/character_panel/party").ToUpperInvariant();
_partySub.Text = ActionCharacterUi.Translate("ui/character_panel/party_sub");
_closeButton.TooltipText = ActionCharacterUi.Translate("ui/character_panel/close");
_resetButton.Text = ActionCharacterUi.Translate("ui/character_panel/reset");
}
protected override void ShowConstruct()
{
ReloadCharacters();
_skipFrames = 2;
}
protected override void HideDestruct()
{
ClearCharacterCards();
ClearSelection();
}
/// <summary>
/// Appends an action ID to the persistent pool and refreshes the list.
/// Public entry point for scenarios and mods that award collectible
/// action cards.
/// </summary>
public void AddActionToPool(string actionId)
{
if (string.IsNullOrEmpty(actionId))
return;
_actionPoolData?.ActionIds.Add(actionId);
RefreshPool();
}
/// <summary>Counts down the post-rebuild input skip frames.</summary>
public override void _Process(double delta)
{
if (_skipFrames > 0)
_skipFrames--;
}
/// <summary>Routes mouse input to card dragging; skipped for a few frames after layout rebuilds to avoid accidental drags.</summary>
public override void _Input(InputEvent @event)
{
if (!Visible || _skipFrames > 0)
return;
if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left)
{
if (mb.Pressed)
OnLeftPressed(mb.GlobalPosition);
else
OnLeftReleased(mb.GlobalPosition);
return;
}
if (@event is InputEventMouseMotion mm)
{
if (_draggedCard != null)
{
_draggedCard.GlobalPosition = mm.GlobalPosition - _localMouseOffset;
UpdateSlotHighlight(mm.GlobalPosition);
AcceptEvent();
}
else if (_potentialCard != null
&& (mm.GlobalPosition - _dragStartMousePos).Length() > DragThreshold)
{
StartDrag(_potentialCard);
}
}
}
private void OnLeftPressed(Vector2 mousePos)
{
if (_draggedCard != null)
return;
var card = FindValidCardAt(mousePos);
if (card != null)
{
_potentialCard = card;
_dragStartMousePos = mousePos;
return;
}
var slot = FindSlotAt(mousePos);
if (slot != null)
{
if (_selectedCard != null)
MoveToSlot(_selectedCard, slot);
return;
}
if (_selectedCard != null && !IsOverPool(mousePos))
ClearSelection();
}
private void OnLeftReleased(Vector2 mousePos)
{
if (_draggedCard != null)
{
FinishDrag(mousePos);
AcceptEvent();
return;
}
if (_potentialCard == null)
return;
var card = _potentialCard;
_potentialCard = null;
HandleCardClick(card);
}
private void StartDrag(ActionCardBehaviour card)
{
if (!GodotObject.IsInstanceValid(card))
return;
_potentialCard = null;
_draggedCard = card;
var sourceSlot = card.CurrentSlot;
ClearSelection();
_localMouseOffset = GetGlobalMousePosition() - card.GlobalPosition;
var globalPos = card.GlobalPosition;
var cardSize = card.Size;
GameLogger.Log("DRAG", $"StartDrag: action='{card.ActionData?.ID}', "
+ $"source={(sourceSlot != null ? $"slot[{sourceSlot.SlotIndex}] char='{sourceSlot.CharacterId}'" : "pool")}");
if (sourceSlot == null)
{
_actionPool?.RemoveCard(card);
RemovePoolId(card.ActionData?.ID);
}
card.GetParent()?.RemoveChild(card);
_dragCanvas.AddChild(card);
card.SetAnchorsPreset(LayoutPreset.TopLeft, false);
card.Size = cardSize;
card.GlobalPosition = globalPos;
card.ZIndex = 100;
card.SetDragging(true);
}
private void FinishDrag(Vector2 mousePos)
{
ClearHoverHighlight();
var card = _draggedCard;
_draggedCard = null;
var targetSlot = FindSlotAt(mousePos);
if (targetSlot != null && targetSlot != card.CurrentSlot)
MoveToSlot(card, targetSlot);
else if (IsOverPool(mousePos))
MoveToPool(card);
else
RestoreCard(card);
if (GodotObject.IsInstanceValid(card))
card.SetDragging(false);
UpdateStatus();
}
private void HandleCardClick(ActionCardBehaviour card)
{
if (!GodotObject.IsInstanceValid(card))
return;
if (_selectedCard == card)
{
ClearSelection();
return;
}
if (_selectedCard != null && card.CurrentSlot != null)
{
MoveToSlot(_selectedCard, card.CurrentSlot);
return;
}
SelectCard(card);
}
private void SelectCard(ActionCardBehaviour card)
{
ClearSelection();
_selectedCard = card;
card.SetSelected(true);
}
private void ClearSelection()
{
if (_selectedCard != null && GodotObject.IsInstanceValid(_selectedCard))
_selectedCard.SetSelected(false);
_selectedCard = null;
}
/// <summary>
/// Moves (or swaps) a card into the target slot. A card dragged from
/// another slot swaps with its displaced occupant; a card taken from
/// the pool sends the displaced card back to the pool and consumes one
/// pool-list entry (duplicates remain untouched).
/// </summary>
private void MoveToSlot(ActionCardBehaviour card, ActionSlotBehaviour target)
{
if (card == null || target == null || !GodotObject.IsInstanceValid(card))
return;
var source = card.CurrentSlot;
if (source == target)
{
ClearSelection();
return;
}
var displaced = target.CurrentCard;
var poolChanged = source == null;
if (source == null)
_actionPool?.RemoveCard(card);
target.PlaceCard(card);
if (displaced != null)
{
if (source != null)
source.PlaceCard(displaced);
else
AddPoolId(displaced.ActionData?.ID);
}
if (poolChanged)
RemovePoolId(card.ActionData?.ID);
CommitCards();
if (poolChanged)
RefreshPool();
ClearSelection();
UpdateStatus();
GameLogger.Log("DRAG", $"Move: action='{card.ActionData?.ID}' -> "
+ $"slot[{target.SlotIndex}] char='{target.CharacterId}'{(displaced != null ? $", displaced='{displaced.ActionData?.ID}'" : string.Empty)}");
}
private void MoveToPool(ActionCardBehaviour card)
{
var source = card.CurrentSlot;
source?.RemoveCard();
card.SetCompact(true);
_actionPool?.AcceptCard(card);
AddPoolId(card.ActionData?.ID);
if (source != null)
CommitCards();
RefreshPool();
UpdateStatus();
}
private void RestoreCard(ActionCardBehaviour card)
{
if (card.CurrentSlot != null)
{
card.ReturnToOrigin();
return;
}
card.QueueFree();
AddPoolId(card.ActionData?.ID);
RefreshPool();
UpdateStatus();
}
private void UpdateSlotHighlight(Vector2 mousePos)
{
var newHovered = FindSlotAt(mousePos);
if (newHovered == _hoveredSlot)
return;
ClearHoverHighlight();
_hoveredSlot = newHovered;
_hoveredSlot?.SetHighlight(true);
}
private void ClearHoverHighlight()
{
_hoveredSlot?.SetHighlight(false);
_hoveredSlot = null;
}
private void ReloadCharacters()
{
ClearCharacterCards();
var characterData = GameData.Instance.Runtime.CharacterData;
if (characterData?.BattleSet == null || characterData.Characters == null)
{
RefreshPool();
UpdateStatus();
return;
}
foreach (var characterId in characterData.BattleSet)
{
if (!characterData.Characters.TryGetValue(characterId, out var info))
continue;
var card = CharacterPanelPrefabs.CardInstance();
if (card == null)
continue;
card.Name = $"Card_{characterId}";
_charactersContainer.AddChild(card);
card.SetCharacter(characterId, info);
_characterCards.Add(card);
}
RefreshPool();
UpdateStatus();
}
private void ClearCharacterCards()
{
foreach (var card in _characterCards)
card.QueueFree();
_characterCards.Clear();
}
private void RefreshPool()
{
_actionPool?.Refresh(_actionPoolData?.ActionIds);
}
private void CommitCards()
{
foreach (var card in _characterCards)
card.CommitSlots();
}
private void AddPoolId(string id)
{
if (!string.IsNullOrEmpty(id))
_actionPoolData?.ActionIds.Add(id);
}
private void RemovePoolId(string id)
{
if (!string.IsNullOrEmpty(id))
_actionPoolData?.ActionIds.Remove(id);
}
private void UpdateStatus()
{
if (_statusLabel == null)
return;
var assigned = _characterCards.Sum(card => card.Slots.Count(slot => slot != null && !slot.IsEmpty));
var total = _characterCards.Count * 3;
var pool = _actionPoolData?.ActionIds?.Count ?? 0;
_statusLabel.Text = string.Format(
ActionCharacterUi.Translate("ui/character_panel/assigned"), assigned, total, pool)
+ " · " + ActionCharacterUi.Translate("ui/character_panel/hint_select");
if (_partyCountLabel != null)
_partyCountLabel.Text = _characterCards.Count.ToString();
}
private ActionCardBehaviour FindValidCardAt(Vector2 globalPos)
{
foreach (var characterCard in _characterCards)
{
foreach (var slot in characterCard.Slots)
{
var card = slot?.CurrentCard;
if (card == null || !GodotObject.IsInstanceValid(card))
continue;
if (card.GetGlobalRect().HasPoint(globalPos))
return card;
}
}
if (_actionPool != null)
{
foreach (var poolCard in _actionPool.VisibleCards)
{
if (GodotObject.IsInstanceValid(poolCard) && poolCard.GetGlobalRect().HasPoint(globalPos))
return poolCard;
}
}
return null;
}
private ActionSlotBehaviour FindSlotAt(Vector2 globalPos)
{
foreach (var characterCard in _characterCards)
{
foreach (var slot in characterCard.Slots)
{
if (slot != null && slot.GetGlobalRect().HasPoint(globalPos))
return slot;
}
}
return null;
}
private bool IsOverPool(Vector2 globalPos)
{
return _actionPool != null && _actionPool.GetGlobalRect().HasPoint(globalPos);
}
private void OnResetClick()
{
var dialog = new ConfirmationDialog();
dialog.Title = ActionCharacterUi.Translate("ui/character_panel/reset_title");
dialog.DialogText = ActionCharacterUi.Translate("ui/character_panel/reset_text");
dialog.OkButtonText = ActionCharacterUi.Translate("ui/character_panel/reset_ok");
dialog.CancelButtonText = ActionCharacterUi.Translate("ui/character_panel/reset_cancel");
dialog.Exclusive = true;
AddChild(dialog);
dialog.Confirmed += () =>
{
PerformReset();
dialog.QueueFree();
};
dialog.Canceled += () => dialog.QueueFree();
dialog.PopupCentered();
}
private void PerformReset()
{
var characterData = GameData.Instance.Runtime.CharacterData;
if (characterData?.BattleSet == null || characterData.Characters == null)
return;
ActionAssignmentReset.Apply(characterData, _actionPoolData, CharacterFactory.Instance.Get);
ReloadCharacters();
GameLogger.Log("UI", "CharacterPanel.PerformReset: assignments restored to templates, awarded cards returned to pool");
}
}
}
@@ -0,0 +1,41 @@
using Cthangover.Core.Scenes;
namespace Cthangover.Core.UI.CharacterPanel
{
/// <summary>
/// Shared <see cref="ModScenePrefab"/> handles for the character panel's
/// scene-built parts (cards, slots, the pool). Godot cannot load files
/// inside <c>mods/</c> directly, so every instantiation of these UI pieces
/// goes through the runtime tscn pipeline; the handles are created once and
/// reused so repeated <see cref="ModScenePrefab.Instantiate{T}"/> calls stay
/// cheap cache hits in <see cref="TscnScenes"/>.
/// </summary>
public static class CharacterPanelPrefabs
{
private static ModScenePrefab _card;
private static ModScenePrefab _slot;
private static ModScenePrefab _pool;
private static ModScenePrefab _characterCard;
/// <summary>Prefab of the action card (<c>scenes/ui/action_card.tscn</c>).</summary>
public static ModScenePrefab Card => _card ??= new ModScenePrefab("scenes/ui/action_card.tscn");
/// <summary>Prefab of the action slot (<c>scenes/ui/action_slot.tscn</c>).</summary>
public static ModScenePrefab Slot => _slot ??= new ModScenePrefab("scenes/ui/action_slot.tscn");
/// <summary>Prefab of the action pool column (<c>scenes/ui/action_pool.tscn</c>).</summary>
public static ModScenePrefab Pool => _pool ??= new ModScenePrefab("scenes/ui/action_pool.tscn");
/// <summary>Prefab of the character card (<c>scenes/ui/character_card.tscn</c>).</summary>
public static ModScenePrefab CharacterCard => _characterCard ??= new ModScenePrefab("scenes/ui/character_card.tscn");
/// <summary>Instantiates a fresh action card from its scene; null when the scene cannot be resolved.</summary>
public static ActionCardBehaviour InstantiateCard() => Card.Instantiate<ActionCardBehaviour>();
/// <summary>Instantiates a fresh action slot from its scene; null when the scene cannot be resolved.</summary>
public static ActionSlotBehaviour InstantiateSlot() => Slot.Instantiate<ActionSlotBehaviour>();
/// <summary>Instantiates a fresh character card from its scene; null when the scene cannot be resolved.</summary>
public static CharacterCardBehaviour CardInstance() => CharacterCard.Instantiate<CharacterCardBehaviour>();
}
}