init mod
This commit is contained in:
commit
b6dd3758cb
32 files changed
+2999
No files matched your search
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_characters_base_name = Движок персонажей
|
||||
mod_characters_base_desc = Подсистема персонажей движка: фабрики персонажей и боевых карточек, фабрика аватаров и панель персонажа. Ядро получает шаблоны через контракты ICharacterTemplateProvider/IActionTemplateProvider/IAvatarProvider.
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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><g>1.0</g></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>();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user