init mod
This commit is contained in:
commit
b1ecbdf51b
22 files changed
+1384
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
mod_recruiting_name = Recruiting
|
||||||
|
mod_recruiting_desc = Recruitment of defeated characters: the roster panel, join conditions (e.g. enslaving a defeated enemy), per-recruit behaviours such as nutrition, the party bridge, tick updates and save state.
|
||||||
|
|
||||||
|
ui/recruit/title = Recruits
|
||||||
|
ui/recruit/close = Close
|
||||||
|
ui/recruit/sub = Captured enemies · any of them can join the party
|
||||||
|
ui/recruit/empty = No captured recruits yet
|
||||||
|
ui/recruit/status = Captured: {0} · in party: {1}
|
||||||
|
ui/recruit/add = Add to party
|
||||||
|
ui/recruit/remove = Remove from party
|
||||||
|
ui/recruit/hp = HP
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
mod_recruiting_name = Рекрутинг
|
||||||
|
mod_recruiting_desc = Вербовка побеждённых персонажей: панель ростера, условия присоединения (например, порабощение врага), поведение рекрутов (например, питание), мост в партию, тики и сохранение состояния.
|
||||||
|
|
||||||
|
ui/recruit/title = Рекруты
|
||||||
|
ui/recruit/close = Закрыть
|
||||||
|
ui/recruit/sub = Захваченные враги · любого можно добавить в отряд
|
||||||
|
ui/recruit/empty = Пока нет захваченных рекрутов
|
||||||
|
ui/recruit/status = Захвачено: {0} · в отряде: {1}
|
||||||
|
ui/recruit/add = В отряд
|
||||||
|
ui/recruit/remove = Из отряда
|
||||||
|
ui/recruit/hp = HP
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"id": "recruiting",
|
||||||
|
"name": "Recruiting",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"author": "ct",
|
||||||
|
"description": "Recruitment of defeated characters: the roster panel, join conditions (e.g. enslaving a defeated enemy), per-recruit behaviours such as nutrition, the party bridge, tick updates and save state.",
|
||||||
|
"category": "gameplay",
|
||||||
|
"sources": [
|
||||||
|
"src/**/*.cs"
|
||||||
|
],
|
||||||
|
"depends": ["base2d>=1", "characters_base>=1", "interface>=1"],
|
||||||
|
"ui": [
|
||||||
|
{
|
||||||
|
"id": "recruits",
|
||||||
|
"kind": "panel",
|
||||||
|
"slot": "ModBeforeUIPanel",
|
||||||
|
"scene_path": "scenes/recruit_panel.tscn",
|
||||||
|
"button": { "icon": "recruits", "locale": "ui/recruit/title", "index": 35 },
|
||||||
|
"priority": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="src/UI/RecruitPanelBehaviour.cs" id="1_script"]
|
||||||
|
|
||||||
|
[node name="RecruitPanel" 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="Spacer" type="Control" parent="Panel/Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
mouse_filter = 2
|
||||||
|
|
||||||
|
[node name="Sub" 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="Scroll" type="ScrollContainer" parent="Panel/Margin/Root"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
horizontal_scroll_mode = 0
|
||||||
|
|
||||||
|
[node name="List" type="VBoxContainer" parent="Panel/Margin/Root/Scroll"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
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="StatusLabel" type="Label" parent="Panel/Margin/Root"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 12
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using Cthangover.Core.Characters;
|
||||||
|
using Cthangover.Core.Events;
|
||||||
|
using Cthangover.Core.Factories.Impls;
|
||||||
|
using Cthangover.Core.Settings;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.Relationship
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Turns a <see cref="CharactersDefeatedEvent"/> into recruit attempts. The
|
||||||
|
/// recruiting mod subscribes to the core-neutral event, so the system that
|
||||||
|
/// defeated the characters (battle, trap, script) does not know about
|
||||||
|
/// recruiting — and recruiting does not depend on any combat mod. This
|
||||||
|
/// replaces the old hardcoded call inside the victory screen.
|
||||||
|
/// <para/>
|
||||||
|
/// For every reported id the character template is resolved through
|
||||||
|
/// <see cref="CharacterFactory"/> (the battle copy and the template share
|
||||||
|
/// <c>RecruitmentChance</c>), skipped when that enemy is already captured
|
||||||
|
/// (only one recruit per enemy type is allowed), checked against the
|
||||||
|
/// condition gate (<see cref="RecruitBehaviourRegistry.CanRecruit"/>),
|
||||||
|
/// rolled 1–100 and, on success, added to the roster. The roll is skipped
|
||||||
|
/// entirely when no conditions are registered or the chance is zero,
|
||||||
|
/// matching the old flow.
|
||||||
|
/// <para/>
|
||||||
|
/// The event also marks the end of a fight for the recruiting mod, so the
|
||||||
|
/// final health of every squad recruit is pulled back from its placeholder
|
||||||
|
/// card before new recruits are resolved.
|
||||||
|
/// </summary>
|
||||||
|
public static class DefeatedCharactersRecruiter
|
||||||
|
{
|
||||||
|
/// <summary>Event-bus handler; safe to call with a null or empty payload.</summary>
|
||||||
|
public static void Process(CharactersDefeatedEvent evt)
|
||||||
|
{
|
||||||
|
if (evt?.CharacterIds == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var runtime = GameData.Instance?.Runtime;
|
||||||
|
if (runtime == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var registry = RecruitBehaviourRegistry.Instance;
|
||||||
|
|
||||||
|
// A defeat resolves the battle: pull the final health of every
|
||||||
|
// squad recruit back from its placeholder card, since battle writes
|
||||||
|
// into the shared attributes object while the mod keeps its own data.
|
||||||
|
foreach (var recruit in RecruitingData.Instance.Data)
|
||||||
|
RecruitPartyBridge.SyncFromCard(recruit);
|
||||||
|
|
||||||
|
foreach (var id in evt.CharacterIds)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(id))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var enemy = CharacterFactory.Instance.Get(id);
|
||||||
|
if (enemy == null || enemy.RecruitmentChance <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (RecruitingData.Instance.HasCharacterID(enemy.ID))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!registry.CanRecruit(enemy, runtime))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (System.Random.Shared.Next(1, 101) > enemy.RecruitmentChance)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
RecruitingData.Instance.Add(enemy.ID, enemy.ID);
|
||||||
|
GameLogger.Log("RECRUIT",
|
||||||
|
$"Recruited '{enemy.ID}' after '{evt.Source}' defeat", LogLevel.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Cthangover.Core.Characters;
|
||||||
|
using Cthangover.Core.Relationship;
|
||||||
|
using Cthangover.Core.Settings;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.Relationship
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Recruitment condition that gates the "enslave" recruit option
|
||||||
|
/// behind the <c>slave_master</c> skill. When the player attempts to
|
||||||
|
/// recruit an NPC, the recruitment system queries all registered
|
||||||
|
/// <see cref="IRecruitCondition"/> implementations; if this condition
|
||||||
|
/// is present and the player does not have the skill, the enslavement
|
||||||
|
/// option is hidden or disabled.
|
||||||
|
/// </summary>
|
||||||
|
public class EnslaveCondition : IRecruitCondition
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies this condition as the enslavement gate for the
|
||||||
|
/// recruitment system (maps to <c>enslave</c> option).
|
||||||
|
/// </summary>
|
||||||
|
public string Id => "enslave";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether enslavement is available for the given enemy.
|
||||||
|
/// Checks the runtime skill data for the <c>slave_master</c> skill.
|
||||||
|
/// The target <paramref name="enemy"/> is provided but not inspected
|
||||||
|
/// because the condition depends solely on the player's skills.
|
||||||
|
/// </summary>
|
||||||
|
public bool CanRecruit(Character enemy, RuntimeData runtime)
|
||||||
|
{
|
||||||
|
return runtime.SkillData.HasSkill("slave_master");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Cthangover.Core.Settings;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.Relationship
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Per-recruit behaviour plugin discovered via reflection from mod
|
||||||
|
/// assemblies. Each implementation is registered into
|
||||||
|
/// <see cref="RecruitBehaviourRegistry"/> by
|
||||||
|
/// <see cref="Mods.ModAssemblyLoader.RegisterAssembly"/> and then
|
||||||
|
/// invoked for <b>every</b> active recruit — not just those from the
|
||||||
|
/// owning mod — during tick, configure, and removal lifecycle events.
|
||||||
|
///
|
||||||
|
/// This cross-recruit dispatch pattern is intentional: a behaviour
|
||||||
|
/// might want to introspect all recruits (e.g. a "rivalry" system
|
||||||
|
/// that checks for conflicting personalities), so the registry passes
|
||||||
|
/// the full recruit list to every behaviour rather than filtering by
|
||||||
|
/// ownership.
|
||||||
|
/// </summary>
|
||||||
|
public interface IRecruitBehaviour
|
||||||
|
{
|
||||||
|
/// <summary>Unique identifier for lookup via <c>RecruitBehaviourRegistry.Get</c>.</summary>
|
||||||
|
string Id { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called once when a recruit is added to the party
|
||||||
|
/// (via <c>RecruitingData.Add</c>). Use for initial stat
|
||||||
|
/// setup, buff application, or spawning companion nodes.
|
||||||
|
/// </summary>
|
||||||
|
void ConfigureRecruit(Recruit recruit, RuntimeData runtime);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called every game tick for each active recruit. The
|
||||||
|
/// <paramref name="currentTick"/> value is the global
|
||||||
|
/// <c>RuntimeData.Time.Tick</c> counter — use it for
|
||||||
|
/// cooldown-based or periodic logic rather than maintaining
|
||||||
|
/// a private timer.
|
||||||
|
/// </summary>
|
||||||
|
void OnTick(Recruit recruit, RuntimeData runtime, long currentTick);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when a recruit is removed from the party
|
||||||
|
/// (via <c>RecruitingData.Remove</c>). Clean up any
|
||||||
|
/// persisted state, buffs, or companion nodes.
|
||||||
|
/// </summary>
|
||||||
|
void OnRemove(Recruit recruit, RuntimeData runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Cthangover.Core.Characters;
|
||||||
|
using Cthangover.Core.Settings;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.Relationship
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gating predicate for the recruit system. Implementations are
|
||||||
|
/// auto-discovered from mod assemblies by
|
||||||
|
/// <see cref="RecruitBehaviourRegistry.RegisterAssembly"/> and
|
||||||
|
/// evaluated conjunctively — <b>all</b> conditions must return
|
||||||
|
/// <c>true</c> for recruitment to succeed.
|
||||||
|
///
|
||||||
|
/// Conditions receive the full <see cref="Character"/> and
|
||||||
|
/// <see cref="RuntimeData"/> so they can inspect character stats,
|
||||||
|
/// inventory, relationship flags, quest state, or any other
|
||||||
|
/// runtime-accessible data when deciding whether a given enemy is
|
||||||
|
/// recruitable.
|
||||||
|
/// </summary>
|
||||||
|
public interface IRecruitCondition
|
||||||
|
{
|
||||||
|
/// <summary>Unique identifier for debugging and override lookups.</summary>
|
||||||
|
string Id { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <c>true</c> if the given <paramref name="enemy"/>
|
||||||
|
/// can be recruited under current game state. Called from
|
||||||
|
/// <c>RecruitBehaviourRegistry.CanRecruit</c> as part of a
|
||||||
|
/// conjunctive gate — a single <c>false</c> blocks recruitment.
|
||||||
|
/// </summary>
|
||||||
|
bool CanRecruit(Character enemy, RuntimeData runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using Cthangover.Core.Relationship;
|
||||||
|
using Cthangover.Core.Settings;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.Relationship
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks nutrition and starvation for party recruits.
|
||||||
|
/// Works by recording the tick when a recruit last ate (<c>FullnessTime</c>),
|
||||||
|
/// then each tick computes the elapsed time as a percentage of a 24-hour window.
|
||||||
|
/// Once the fullness window expires (100%+), the recruit loses 1 HP per tick
|
||||||
|
/// until fed again via a ration or meal action.
|
||||||
|
/// Registered with the <see cref="Recruit"/> behaviour pipeline and called
|
||||||
|
/// by the time system on each global tick.
|
||||||
|
/// </summary>
|
||||||
|
public class NutritionBehaviour : IRecruitBehaviour
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unique behaviour identifier used to look up and configure this
|
||||||
|
/// behaviour from <see cref="Recruit"/>'s behaviour collection.
|
||||||
|
/// </summary>
|
||||||
|
public string Id => "nutrition";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when the behaviour is first attached to a recruit.
|
||||||
|
/// Seeds <c>FullnessTime</c> with the current global tick so that
|
||||||
|
/// starve-damage begins counting from this point.
|
||||||
|
/// </summary>
|
||||||
|
public void ConfigureRecruit(Recruit recruit, RuntimeData runtime)
|
||||||
|
{
|
||||||
|
recruit.Properties.SetLong("FullnessTime", runtime.Time.Tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates whether the recruit has gone beyond the fullness window
|
||||||
|
/// (24 in-game hours, represented as <c>24 * 4 * 60</c> ticks) since
|
||||||
|
/// <c>FullnessTime</c>. If the window is exceeded, reduces the
|
||||||
|
/// recruit's <c>PROP_HEALTH</c> by 1 per tick until they are fed.
|
||||||
|
/// Tick-driven starvation allows gradual death when rations run out.
|
||||||
|
/// </summary>
|
||||||
|
public void OnTick(Recruit recruit, RuntimeData runtime, long currentTick)
|
||||||
|
{
|
||||||
|
var fullnessTime = recruit.Properties.GetLong("FullnessTime");
|
||||||
|
if (fullnessTime == 0)
|
||||||
|
return;
|
||||||
|
var windowSize = 24 * 4 * 60L;
|
||||||
|
var deltaTime = currentTick - fullnessTime;
|
||||||
|
var fullness = (float)deltaTime / windowSize * 100f;
|
||||||
|
if (fullness > 100)
|
||||||
|
{
|
||||||
|
var hp = recruit.Properties.GetInt(Recruit.PROP_HEALTH);
|
||||||
|
if (hp > 0)
|
||||||
|
{
|
||||||
|
recruit.Properties.SetInt(Recruit.PROP_HEALTH, hp - 1);
|
||||||
|
RecruitPartyBridge.SyncToCard(recruit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cleanup hook invoked when the behaviour is detached from a recruit.
|
||||||
|
/// No persistent state requires teardown; exists for interface contract.
|
||||||
|
/// </summary>
|
||||||
|
public void OnRemove(Recruit recruit, RuntimeData runtime)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.Relationship
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Runtime model for a recruited party member. Stores the recruit's
|
||||||
|
/// unique ID, the character template it was created from, and a
|
||||||
|
/// modifiable <see cref="Utility.PropertyData"/> bag that behaviours
|
||||||
|
/// read and mutate during tick/configure events.
|
||||||
|
///
|
||||||
|
/// Marked <c>[Serializable]</c> so the recruiting roster survives
|
||||||
|
/// save/load — <c>PropertyData</c> serialises its nested values
|
||||||
|
/// automatically. The <c>PROP_HEALTH</c> constant is the conventional
|
||||||
|
/// key for health tracking; behaviours that need custom state
|
||||||
|
/// (e.g. morale, loyalty timers) add their own keys to the
|
||||||
|
/// <c>Properties</c> bag at configure time.
|
||||||
|
/// </summary>
|
||||||
|
[Serializable]
|
||||||
|
public class Recruit
|
||||||
|
{
|
||||||
|
/// <summary>Conventional key for the recruit's health value in the <c>Properties</c> bag.</summary>
|
||||||
|
public const string PROP_HEALTH = "Health";
|
||||||
|
|
||||||
|
/// <summary>Unique instance ID (auto-generated via <c>RecruitingData.GenID</c>).</summary>
|
||||||
|
public string ID { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Character template ID this recruit was spawned from.</summary>
|
||||||
|
public string CharacterID { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mutable property bag — behaviours read/write custom state here.</summary>
|
||||||
|
public PropertyData Properties { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Cthangover.Core.Relationship
|
||||||
|
{
|
||||||
|
using Cthangover.Core.Mods.Consumers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feeds <see cref="RecruitBehaviourRegistry"/> with every mod's
|
||||||
|
/// <see cref="IRecruitBehaviour"/> implementations as the mods load and
|
||||||
|
/// removes them when their mod unloads.
|
||||||
|
/// </summary>
|
||||||
|
public class RecruitBehaviourConsumer : ModTypeConsumer<IRecruitBehaviour>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Id => "recruiting.behaviours";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Add(IRecruitBehaviour instance, string modId) => RecruitBehaviourRegistry.Instance.RegisterBehaviour(instance);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Remove(IRecruitBehaviour instance, string modId) => RecruitBehaviourRegistry.Instance.UnregisterBehaviour(instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Characters;
|
||||||
|
using Cthangover.Core.Settings;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.Relationship
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Central registry and lifecycle dispatcher for recruit behaviours
|
||||||
|
/// and conditions. Behaviours (<see cref="IRecruitBehaviour"/>) and
|
||||||
|
/// conditions (<see cref="IRecruitCondition"/>) of every mod arrive
|
||||||
|
/// through the kernel's assembly consumers
|
||||||
|
/// (<see cref="RecruitBehaviourConsumer"/>, <see cref="RecruitConditionConsumer"/>)
|
||||||
|
/// as mods load, and leave again when their mod unloads — the kernel
|
||||||
|
/// stays the only place that enumerates assemblies.
|
||||||
|
///
|
||||||
|
/// <b>Condition gating</b> — <see cref="CanRecruit"/> evaluates every
|
||||||
|
/// registered condition conjunctively (logical AND), so a single
|
||||||
|
/// <c>false</c> blocks recruitment. This allows mods to compose
|
||||||
|
/// recruitment gates without coordination.
|
||||||
|
///
|
||||||
|
/// <b>Id-based deduplication</b> — a registration replaces any earlier
|
||||||
|
/// implementation with the same <c>Id</c>, so two mods shipping the same
|
||||||
|
/// id never double-dispatch notifications.
|
||||||
|
///
|
||||||
|
/// <b>Tick registration</b> — the first call to any lifecycle method
|
||||||
|
/// triggers <see cref="RecruitTickController.EnsureRegistered"/>,
|
||||||
|
/// which registers for the scene's timer event. This avoids paying
|
||||||
|
/// the tick overhead unless at least one behaviour is active.
|
||||||
|
///
|
||||||
|
/// <b>Singleton</b> — accessed via the static <c>Instance</c> field
|
||||||
|
/// rather than a <c>Lazy<T></c> wrapper because Godot
|
||||||
|
/// reflection-based discovery must tolerate partial-initialisation
|
||||||
|
/// scenarios where <c>Lazy</c>'s thread-safety check could deadlock.
|
||||||
|
/// </summary>
|
||||||
|
public class RecruitBehaviourRegistry
|
||||||
|
{
|
||||||
|
/// <summary>Eagerly-initialised singleton for reflection-safe access.</summary>
|
||||||
|
public static readonly RecruitBehaviourRegistry Instance = new();
|
||||||
|
|
||||||
|
private readonly List<IRecruitBehaviour> _behaviours = new();
|
||||||
|
private readonly List<IRecruitCondition> _conditions = new();
|
||||||
|
private readonly RecruitTickController _tickController = new();
|
||||||
|
|
||||||
|
private void EnsureTickRegistered()
|
||||||
|
{
|
||||||
|
_tickController.EnsureRegistered();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a behaviour, replacing an earlier one with the same Id. Fed
|
||||||
|
/// by <see cref="RecruitBehaviourConsumer"/> for every mod assembly.
|
||||||
|
/// </summary>
|
||||||
|
public void RegisterBehaviour(IRecruitBehaviour behaviour)
|
||||||
|
{
|
||||||
|
if (behaviour == null)
|
||||||
|
return;
|
||||||
|
_behaviours.RemoveAll(existing => existing.Id == behaviour.Id);
|
||||||
|
_behaviours.Add(behaviour);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes exactly <paramref name="behaviour"/> (its mod is being unloaded).</summary>
|
||||||
|
public void UnregisterBehaviour(IRecruitBehaviour behaviour)
|
||||||
|
{
|
||||||
|
_behaviours.Remove(behaviour);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a condition, replacing an earlier one with the same Id. Fed
|
||||||
|
/// by <see cref="RecruitConditionConsumer"/> for every mod assembly.
|
||||||
|
/// </summary>
|
||||||
|
public void RegisterCondition(IRecruitCondition condition)
|
||||||
|
{
|
||||||
|
if (condition == null)
|
||||||
|
return;
|
||||||
|
_conditions.RemoveAll(existing => existing.Id == condition.Id);
|
||||||
|
_conditions.Add(condition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes exactly <paramref name="condition"/> (its mod is being unloaded).</summary>
|
||||||
|
public void UnregisterCondition(IRecruitCondition condition)
|
||||||
|
{
|
||||||
|
_conditions.Remove(condition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Conjunctive gate: returns <c>true</c> only if every registered
|
||||||
|
/// <see cref="IRecruitCondition"/> returns <c>true</c> for the
|
||||||
|
/// given enemy and runtime state.
|
||||||
|
/// </summary>
|
||||||
|
public bool CanRecruit(Character enemy, RuntimeData runtime)
|
||||||
|
{
|
||||||
|
return _conditions.All(c => c.CanRecruit(enemy, runtime));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a behaviour by its <c>Id</c> (linear scan —
|
||||||
|
/// behaviour lists are small enough that a dictionary is
|
||||||
|
/// unnecessary). Returns <c>null</c> if not found.
|
||||||
|
/// </summary>
|
||||||
|
public IRecruitBehaviour Get(string id)
|
||||||
|
{
|
||||||
|
return _behaviours.Find(b => b.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns all registered behaviours for external enumeration.</summary>
|
||||||
|
public IEnumerable<IRecruitBehaviour> All()
|
||||||
|
{
|
||||||
|
return _behaviours;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dispatches <see cref="IRecruitBehaviour.OnTick"/> to every
|
||||||
|
/// behaviour for every active recruit. Ensures tick registration
|
||||||
|
/// on first call via lazy init.
|
||||||
|
/// </summary>
|
||||||
|
public void OnTick()
|
||||||
|
{
|
||||||
|
EnsureTickRegistered();
|
||||||
|
var runtime = GameData.Instance.Runtime;
|
||||||
|
if (runtime == null)
|
||||||
|
return;
|
||||||
|
var recruits = RecruitingData.Instance.Data;
|
||||||
|
foreach (var behaviour in _behaviours)
|
||||||
|
{
|
||||||
|
foreach (var recruit in recruits)
|
||||||
|
{
|
||||||
|
behaviour.OnTick(recruit, runtime, runtime.Time.Tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dispatches <see cref="IRecruitBehaviour.ConfigureRecruit"/> to
|
||||||
|
/// every behaviour for the newly-added <paramref name="recruit"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void OnConfigure(Recruit recruit)
|
||||||
|
{
|
||||||
|
EnsureTickRegistered();
|
||||||
|
var runtime = GameData.Instance.Runtime;
|
||||||
|
if (runtime == null)
|
||||||
|
return;
|
||||||
|
foreach (var behaviour in _behaviours)
|
||||||
|
behaviour.ConfigureRecruit(recruit, runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dispatches <see cref="IRecruitBehaviour.OnRemove"/> to every
|
||||||
|
/// behaviour for the departing <paramref name="recruit"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void OnRemove(Recruit recruit)
|
||||||
|
{
|
||||||
|
EnsureTickRegistered();
|
||||||
|
var runtime = GameData.Instance.Runtime;
|
||||||
|
if (runtime == null)
|
||||||
|
return;
|
||||||
|
foreach (var behaviour in _behaviours)
|
||||||
|
behaviour.OnRemove(recruit, runtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Cthangover.Core.Relationship
|
||||||
|
{
|
||||||
|
using Cthangover.Core.Mods.Consumers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feeds <see cref="RecruitBehaviourRegistry"/> with every mod's
|
||||||
|
/// <see cref="IRecruitCondition"/> implementations as the mods load and
|
||||||
|
/// removes them when their mod unloads.
|
||||||
|
/// </summary>
|
||||||
|
public class RecruitConditionConsumer : ModTypeConsumer<IRecruitCondition>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Id => "recruiting.conditions";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Add(IRecruitCondition instance, string modId) => RecruitBehaviourRegistry.Instance.RegisterCondition(instance);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Remove(IRecruitCondition instance, string modId) => RecruitBehaviourRegistry.Instance.UnregisterCondition(instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Cthangover.Core.Events
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Published by the recruiting mod when a captured recruit is placed into
|
||||||
|
/// the active party (see <c>RecruitPartyBridge.AddToSquad</c>). Purely
|
||||||
|
/// informational — the core party data is already updated when subscribers
|
||||||
|
/// see the event.
|
||||||
|
/// </summary>
|
||||||
|
[EventKey("recruit_joined")]
|
||||||
|
public class RecruitJoinedEvent : ModEvent
|
||||||
|
{
|
||||||
|
/// <summary>Instance id of the recruit.</summary>
|
||||||
|
public string RecruitId { get; init; }
|
||||||
|
/// <summary>Character template id the recruit was spawned from.</summary>
|
||||||
|
public string RecruitName { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Cthangover.Core.Events
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Published by the recruiting mod when a recruit leaves the active party
|
||||||
|
/// (see <c>RecruitPartyBridge.RemoveFromSquad</c>). The placeholder card and
|
||||||
|
/// its progression survive the leave — only the party slot is dropped.
|
||||||
|
/// </summary>
|
||||||
|
[EventKey("recruit_left")]
|
||||||
|
public class RecruitLeftEvent : ModEvent
|
||||||
|
{
|
||||||
|
/// <summary>Instance id of the recruit.</summary>
|
||||||
|
public string RecruitId { get; init; }
|
||||||
|
/// <summary>Character template id the recruit was spawned from.</summary>
|
||||||
|
public string RecruitName { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
using Cthangover.Core.Characters;
|
||||||
|
using Cthangover.Core.Settings;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.Relationship
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Bridge between the mod-owned recruit roster and the core party model.
|
||||||
|
/// Core knows nothing about recruiting, so the mod materializes a placeholder
|
||||||
|
/// <see cref="CharacterInfoData"/> for every recruit the player puts into the
|
||||||
|
/// squad: the entry is keyed by the template <see cref="Recruit.CharacterID"/>
|
||||||
|
/// (the roster guarantees at most one recruit per enemy, so the template id
|
||||||
|
/// stays a unique party key) and therefore needs no core changes — the
|
||||||
|
/// character panel, battle, item targeting and the save system all pick the
|
||||||
|
/// recruit up as a normal party member.
|
||||||
|
/// <para/>
|
||||||
|
/// The recruit itself remains the source of truth outside battle: the mod
|
||||||
|
/// calls <see cref="SyncToCard"/> whenever its own data changes (starvation
|
||||||
|
/// tick, joining the squad) and <see cref="SyncFromCard"/> once battle is
|
||||||
|
/// over, where the battle systems have already written the final health back
|
||||||
|
/// through the shared <see cref="CharacterInfoData.Attributes"/> reference.
|
||||||
|
/// <see cref="SyncToCard"/> must mutate the attribute in place — reassigning
|
||||||
|
/// the attributes object would detach it from the battle copies that still
|
||||||
|
/// point at the original instance.
|
||||||
|
/// </summary>
|
||||||
|
public static class RecruitPartyBridge
|
||||||
|
{
|
||||||
|
/// <summary>True when the recruit currently occupies a slot in the active party.</summary>
|
||||||
|
public static bool IsInSquad(Recruit recruit)
|
||||||
|
{
|
||||||
|
var characterData = GetCharacterData();
|
||||||
|
return characterData != null
|
||||||
|
&& recruit != null
|
||||||
|
&& characterData.BattleSet.Contains(recruit.CharacterID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the recruit to the active party. On the first join a placeholder
|
||||||
|
/// card is created from the character template (attributes, portrait and
|
||||||
|
/// default action slots); the card is then immediately synchronized with
|
||||||
|
/// the recruit's own health. Repeated calls are idempotent.
|
||||||
|
/// <para/>
|
||||||
|
/// The template is resolved by <c>CharacterData.AddCharacterToParty</c>,
|
||||||
|
/// which refuses unknown IDs; a refusal aborts the join so the battle
|
||||||
|
/// set never references a card that the roster does not have (the battle
|
||||||
|
/// scene indexes the roster directly for every battle-set entry).
|
||||||
|
/// </summary>
|
||||||
|
public static void AddToSquad(Recruit recruit)
|
||||||
|
{
|
||||||
|
var characterData = GetCharacterData();
|
||||||
|
if (characterData == null || recruit == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!characterData.Characters.ContainsKey(recruit.CharacterID)
|
||||||
|
&& !characterData.AddCharacterToParty(recruit.CharacterID))
|
||||||
|
{
|
||||||
|
GameLogger.Log("RECRUIT",
|
||||||
|
$"AddToSquad: no template for recruit '{recruit.CharacterID}' — join aborted",
|
||||||
|
LogLevel.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
characterData.BattleSet.Add(recruit.CharacterID);
|
||||||
|
SyncToCard(recruit);
|
||||||
|
|
||||||
|
GameLogger.Log("RECRUIT", $"Recruit '{recruit.CharacterID}' joined the squad", LogLevel.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the recruit from the active party but keeps the placeholder
|
||||||
|
/// card in <see cref="CharacterData.Characters"/> — the player's action
|
||||||
|
/// assignments and battle-earned experience survive a temporary leave and
|
||||||
|
/// are restored when the recruit joins again.
|
||||||
|
/// </summary>
|
||||||
|
public static void RemoveFromSquad(Recruit recruit)
|
||||||
|
{
|
||||||
|
var characterData = GetCharacterData();
|
||||||
|
if (characterData == null || recruit == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (characterData.BattleSet.Remove(recruit.CharacterID))
|
||||||
|
GameLogger.Log("RECRUIT", $"Recruit '{recruit.CharacterID}' left the squad", LogLevel.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drops every trace of the recruit: leaves the party and deletes the
|
||||||
|
/// placeholder card. Called when the recruit is removed from the roster
|
||||||
|
/// for good, so no orphan party entry survives in the core save.
|
||||||
|
/// </summary>
|
||||||
|
public static void Forget(Recruit recruit)
|
||||||
|
{
|
||||||
|
var characterData = GetCharacterData();
|
||||||
|
if (characterData == null || recruit == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
RemoveFromSquad(recruit);
|
||||||
|
characterData.Characters.Remove(recruit.CharacterID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes the recruit's health from its property bag into the placeholder
|
||||||
|
/// card. The attribute is mutated in place: battle copies share the same
|
||||||
|
/// <see cref="CharacterAttributes"/> instance, so a reassignment here
|
||||||
|
/// would leave the fight running on the stale object.
|
||||||
|
/// </summary>
|
||||||
|
public static void SyncToCard(Recruit recruit)
|
||||||
|
{
|
||||||
|
var info = GetCard(recruit);
|
||||||
|
if (info == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
info.Attributes ??= new CharacterAttributes();
|
||||||
|
info.Attributes.Health.Value = recruit.Properties.GetInt(Recruit.PROP_HEALTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pulls the health written by battle back into the recruit's property
|
||||||
|
/// bag. Called on the core-neutral <c>characters_defeated</c> event, which
|
||||||
|
/// the battle mod publishes after the victory screen resolves, so the
|
||||||
|
/// mod's own data stays authoritative once the fight is over.
|
||||||
|
/// </summary>
|
||||||
|
public static void SyncFromCard(Recruit recruit)
|
||||||
|
{
|
||||||
|
var info = GetCard(recruit);
|
||||||
|
if (info?.Attributes?.Health == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
recruit.Properties.SetInt(Recruit.PROP_HEALTH, info.Attributes.Health.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CharacterInfoData GetCard(Recruit recruit)
|
||||||
|
{
|
||||||
|
var characterData = GetCharacterData();
|
||||||
|
if (characterData?.Characters == null || recruit == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return characterData.Characters.TryGetValue(recruit.CharacterID, out var info) ? info : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CharacterData GetCharacterData()
|
||||||
|
{
|
||||||
|
return GameData.Instance?.Runtime?.CharacterData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using Cthangover.Core.Scenes;
|
||||||
|
using Cthangover.Core.UI.Event;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.Relationship
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Lazy bridge between the scene timer system and the recruit
|
||||||
|
/// behaviour tick loop. Implements <see cref="IOnTimeEvent"/>
|
||||||
|
/// so it can be registered with <see cref="SceneEventController"/>
|
||||||
|
/// via <c>AddTimerTickEventListener</c>.
|
||||||
|
///
|
||||||
|
/// Registration is lazy (<see cref="EnsureRegistered"/> is a no-op
|
||||||
|
/// after the first call) because the recruit system should not
|
||||||
|
/// force the timer subsystem to activate if no behaviours are
|
||||||
|
/// actually loaded. The first behaviour lifecycle method in
|
||||||
|
/// <see cref="RecruitBehaviourRegistry"/> calls
|
||||||
|
/// <c>EnsureRegistered</c>, which finds the scene's
|
||||||
|
/// <c>EventController</c> node and subscribes.
|
||||||
|
///
|
||||||
|
/// <c>Priority = 0</c> so recruit ticks fire at a neutral point in
|
||||||
|
/// the timer event chain — before or after other listeners as
|
||||||
|
/// determined by their relative priorities.
|
||||||
|
/// </summary>
|
||||||
|
public class RecruitTickController : IOnTimeEvent
|
||||||
|
{
|
||||||
|
/// <summary>Neutral priority — doesn't preempt or follow any specific listener.</summary>
|
||||||
|
public int Priority => 0;
|
||||||
|
|
||||||
|
private bool _registered;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the scene's <c>EventController</c> node and subscribes
|
||||||
|
/// this controller as a timer-tick listener. Idempotent —
|
||||||
|
/// subsequent calls do nothing.
|
||||||
|
/// </summary>
|
||||||
|
public void EnsureRegistered()
|
||||||
|
{
|
||||||
|
if (_registered)
|
||||||
|
return;
|
||||||
|
var eventController = SceneContextNode.FindNode<SceneEventController>("EventController");
|
||||||
|
if (eventController == null)
|
||||||
|
return;
|
||||||
|
eventController.AddTimerTickEventListener(this);
|
||||||
|
_registered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by <see cref="SceneEventController"/> on every
|
||||||
|
/// timer tick. Delegates to
|
||||||
|
/// <see cref="RecruitBehaviourRegistry.OnTick"/>.
|
||||||
|
/// Ticks also fire during the launcher boot window, so the registry
|
||||||
|
/// side must not touch <c>GameData.Instance.Runtime</c> or mod data
|
||||||
|
/// until <c>Boot.ModsReady</c> (see the boot-window contract on
|
||||||
|
/// <c>IOnTimeEvent</c>; core-side reference: TimeTickController).
|
||||||
|
/// </summary>
|
||||||
|
public void OnTimerTick()
|
||||||
|
{
|
||||||
|
RecruitBehaviourRegistry.Instance.OnTick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.Relationship
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Container and factory for the party's recruit roster. The roster is owned
|
||||||
|
/// by the recruiting mod through the static <see cref="Instance"/> instead of
|
||||||
|
/// living in the core <c>RuntimeData</c>, so the core knows nothing about
|
||||||
|
/// recruiting. Persistence is handled by the mod's <c>RecruitingSaveable</c>
|
||||||
|
/// (<c>IModSaveable</c>), and a new game clears the roster through the
|
||||||
|
/// <c>RecruitingReset</c> <c>IResettable</c> that <c>RecruitingInitializer</c>
|
||||||
|
/// registers with AppServices.
|
||||||
|
/// <para/>
|
||||||
|
/// Provides ID-based lookup, duplicate-safe addition (auto-generates a unique
|
||||||
|
/// <c>Guid</c> if the requested ID collides, and rejects a second recruit of
|
||||||
|
/// the same enemy type), and removal that notifies
|
||||||
|
/// <see cref="RecruitBehaviourRegistry"/> so behaviours can clean up and
|
||||||
|
/// detaches the recruit from the squad through <see cref="RecruitPartyBridge"/>.
|
||||||
|
/// <see cref="Add"/> initialises the recruit's <c>Properties</c> with a default
|
||||||
|
/// health of 10 via <see cref="Recruit.PROP_HEALTH"/> and immediately calls
|
||||||
|
/// <c>RecruitBehaviourRegistry.OnConfigure</c>, giving every registered
|
||||||
|
/// behaviour a chance to set up per-recruit state before the next tick fires.
|
||||||
|
/// </summary>
|
||||||
|
public class RecruitingData
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Session-wide roster. There is exactly one roster per playthrough —
|
||||||
|
/// starting a new game clears it, loading a save replaces its contents.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly RecruitingData Instance = new();
|
||||||
|
|
||||||
|
/// <summary>The current recruit roster.</summary>
|
||||||
|
public List<Recruit> Data { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>True if a recruit with the given ID exists in the roster.</summary>
|
||||||
|
public bool HasID(string id)
|
||||||
|
{
|
||||||
|
return GetByID(id) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the recruit with the given ID, or null if not found.</summary>
|
||||||
|
public Recruit GetByID(string id)
|
||||||
|
{
|
||||||
|
foreach (var item in Data)
|
||||||
|
{
|
||||||
|
if (item.ID == id)
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if a recruit spawned from the given character template is already
|
||||||
|
/// in the roster. Recruitment allows only one instance per enemy type,
|
||||||
|
/// which also keeps the template id usable as a unique party key.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasCharacterID(string characterID)
|
||||||
|
{
|
||||||
|
return GetByCharacterID(characterID) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the recruit spawned from the given template, or null if not found.</summary>
|
||||||
|
public Recruit GetByCharacterID(string characterID)
|
||||||
|
{
|
||||||
|
foreach (var item in Data)
|
||||||
|
{
|
||||||
|
if (item.CharacterID == characterID)
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a unique ID: if <paramref name="id"/> is free,
|
||||||
|
/// returns it unchanged; otherwise appends a <c>Guid</c> suffix.
|
||||||
|
/// Loops until an unused ID is found (guaranteed because Guid
|
||||||
|
/// is unique enough for any practical roster size).
|
||||||
|
/// </summary>
|
||||||
|
private string GenID(string id)
|
||||||
|
{
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
if (!HasID(id))
|
||||||
|
return id;
|
||||||
|
id = Guid.NewGuid().ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new <see cref="Recruit"/> from the given
|
||||||
|
/// <paramref name="characterID"/>, assigns a unique instance ID,
|
||||||
|
/// initialises default health, appends to the roster, and fires
|
||||||
|
/// <c>RecruitBehaviourRegistry.OnConfigure</c> so behaviours
|
||||||
|
/// can set up per-recruit state.
|
||||||
|
/// <para/>
|
||||||
|
/// Only one recruit per enemy type is allowed: a second attempt for an
|
||||||
|
/// already captured <paramref name="characterID"/> is ignored and the
|
||||||
|
/// existing entry is returned, so callers cannot create duplicates and
|
||||||
|
/// the template id remains a unique party card key.
|
||||||
|
/// </summary>
|
||||||
|
public Recruit Add(string id, string characterID)
|
||||||
|
{
|
||||||
|
var existing = GetByCharacterID(characterID);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
GameLogger.Log("RECRUIT",
|
||||||
|
$"Recruit '{characterID}' already captured, duplicate ignored", LogLevel.Warning);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = new Recruit
|
||||||
|
{
|
||||||
|
ID = GenID(id),
|
||||||
|
CharacterID = characterID,
|
||||||
|
};
|
||||||
|
data.Properties.SetInt(Recruit.PROP_HEALTH, 10);
|
||||||
|
Data.Add(data);
|
||||||
|
|
||||||
|
RecruitBehaviourRegistry.Instance.OnConfigure(data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the recruit with the given ID from the roster.
|
||||||
|
/// Notifies <see cref="RecruitBehaviourRegistry.OnRemove"/>
|
||||||
|
/// before deleting the entry so behaviours can persist state
|
||||||
|
/// or clean up companion nodes, then detaches the recruit from the
|
||||||
|
/// party and deletes its placeholder card via
|
||||||
|
/// <see cref="RecruitPartyBridge.Forget"/>, so no orphan party member
|
||||||
|
/// survives in the core save.
|
||||||
|
/// </summary>
|
||||||
|
public void Remove(string id)
|
||||||
|
{
|
||||||
|
for (int i = Data.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (Data[i].ID == id)
|
||||||
|
{
|
||||||
|
RecruitBehaviourRegistry.Instance.OnRemove(Data[i]);
|
||||||
|
RecruitPartyBridge.Forget(Data[i]);
|
||||||
|
Data.RemoveAt(i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Cthangover.Core.Bootstrap;
|
||||||
|
using Cthangover.Core.Events;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.Relationship
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Lifecycle entry point of the recruiting mod. Behaviours and conditions of
|
||||||
|
/// all mods are not discovered here: the kernel delivers them through
|
||||||
|
/// <see cref="RecruitBehaviourConsumer"/> and <see cref="RecruitConditionConsumer"/>.
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>It registers a <see cref="RecruitingReset"/> with
|
||||||
|
/// <see cref="AppServices"/>, so the core's "New Game" pipeline
|
||||||
|
/// (<c>GameData.StartNewGame</c> → <c>AppServices.ResetForNewGame</c>)
|
||||||
|
/// clears the roster — the core used to discard it implicitly with
|
||||||
|
/// RuntimeData, but the roster now lives in
|
||||||
|
/// <see cref="RecruitingData.Instance"/> and must be reset explicitly on
|
||||||
|
/// "New Game". Registration is idempotent: AppServices deduplicates by
|
||||||
|
/// <c>IResettable.Id</c>, so a repeated <c>OnModResourcesReady</c> is safe.</item>
|
||||||
|
/// <item>It subscribes to <see cref="CharactersDefeatedEvent"/> and routes it
|
||||||
|
/// into <see cref="DefeatedCharactersRecruiter"/> — the publisher (battle,
|
||||||
|
/// trap, script) stays unaware of recruiting, so the whole system can be
|
||||||
|
/// disabled by removing this mod.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Persistence is not handled here: <see cref="RecruitingSaveable"/> is an
|
||||||
|
/// <c>IModSaveable</c> and is discovered by ModSaveableRegistry automatically.
|
||||||
|
/// </summary>
|
||||||
|
public class RecruitingInitializer : IModInitializer
|
||||||
|
{
|
||||||
|
private static bool _defeatedSubscribed;
|
||||||
|
|
||||||
|
/// <summary>Nothing to do per mod: recruit plugins arrive through the kernel's assembly consumers.</summary>
|
||||||
|
public void OnModLoaded(string modId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the roster reset owner with AppServices (idempotent —
|
||||||
|
/// duplicates by Id are ignored) and installs the
|
||||||
|
/// <see cref="CharactersDefeatedEvent"/> subscription. The roster is no longer cleared through a
|
||||||
|
/// <c>NewGameStartedEvent</c> subscription: the core's reset loop
|
||||||
|
/// invokes <see cref="RecruitingReset"/> instead.
|
||||||
|
/// </summary>
|
||||||
|
public void OnModResourcesReady()
|
||||||
|
{
|
||||||
|
AppServices.Current.RegisterResettable(new RecruitingReset());
|
||||||
|
EnsureDefeatedSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureDefeatedSubscription()
|
||||||
|
{
|
||||||
|
if (_defeatedSubscribed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_defeatedSubscribed = true;
|
||||||
|
ModEventBus.Subscribe<CharactersDefeatedEvent>(DefeatedCharactersRecruiter.Process);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// New-game state owner of the recruiting mod: clears the session roster
|
||||||
|
/// (<see cref="RecruitingData.Data"/>). Registered from
|
||||||
|
/// <see cref="RecruitingInitializer.OnModResourcesReady"/>;
|
||||||
|
/// <see cref="AppServices"/> deduplicates by Id, so repeated registration
|
||||||
|
/// is a no-op. The clear is a plain list clear with no per-recruit side
|
||||||
|
/// effects — unlike <see cref="RecruitingData.Remove"/> it does not notify
|
||||||
|
/// <c>RecruitBehaviourRegistry.OnRemove</c> or detach companions through
|
||||||
|
/// <c>RecruitPartyBridge</c> — matching the previous
|
||||||
|
/// <c>NewGameStartedEvent</c> handler's behavior. Save loading is a
|
||||||
|
/// separate path: <c>RecruitingSaveable</c> replaces the roster contents
|
||||||
|
/// when a save is loaded.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class RecruitingReset : IResettable
|
||||||
|
{
|
||||||
|
/// <summary>Resettable key under which AppServices deduplicates registration.</summary>
|
||||||
|
public string Id => "recruiting";
|
||||||
|
|
||||||
|
/// <summary>Clears the roster wholesale on "New Game", without per-recruit notifications.</summary>
|
||||||
|
public void ResetForNewGame() => RecruitingData.Instance.Data.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Settings;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.Relationship
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the recruit roster in the mod's own save slot. The core save used
|
||||||
|
/// to carry a <c>"Recruits"</c> blob inside the <c>__core__</c> slot, but the
|
||||||
|
/// roster now belongs to the recruiting mod — so it saves through the generic
|
||||||
|
/// <see cref="IModSaveable"/> contract under the mod id <c>recruiting</c>.
|
||||||
|
/// <para/>
|
||||||
|
/// <see cref="OnLoad"/> replaces the roster wholesale and does not re-fire
|
||||||
|
/// <c>ConfigureRecruit</c>: persisted per-recruit properties (for example the
|
||||||
|
/// nutrition timer) are already in the blob and behaviours continue their
|
||||||
|
/// tick logic from the restored state, matching the old core behaviour.
|
||||||
|
/// Backward compatibility with the old core blob is intentionally not
|
||||||
|
/// provided — old saves start with an empty roster.
|
||||||
|
/// </summary>
|
||||||
|
public class RecruitingSaveable : IModSaveable
|
||||||
|
{
|
||||||
|
private const string BlobKey = "Recruits";
|
||||||
|
|
||||||
|
/// <summary>Writes the session roster into the mod slot.</summary>
|
||||||
|
public void OnSave(ModSlot slot)
|
||||||
|
{
|
||||||
|
slot.Data[BlobKey] = new DataBlob(1, RecruitingData.Instance.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Restores the roster from the mod slot; an absent blob yields an empty roster.</summary>
|
||||||
|
public bool OnLoad(ModSlot slot, out string errorMessage)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
|
||||||
|
var data = new List<Recruit>();
|
||||||
|
if (slot?.Data != null && slot.Data.TryGetValue(BlobKey, out var blob))
|
||||||
|
data = blob.ReadJson<List<Recruit>>() ?? new List<Recruit>();
|
||||||
|
|
||||||
|
RecruitingData.Instance.Data = data;
|
||||||
|
|
||||||
|
// The core slot (party cards) is restored before mod slots, so any
|
||||||
|
// squad recruit already has a placeholder card again. The mod owns
|
||||||
|
// its data outside battle, so push the saved health onto that card.
|
||||||
|
foreach (var recruit in data)
|
||||||
|
RecruitPartyBridge.SyncToCard(recruit);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
using Cthangover.Core.Characters;
|
||||||
|
using Cthangover.Core.Factories.Impls;
|
||||||
|
using Cthangover.Core.UI;
|
||||||
|
using Cthangover.Core.UI.CharacterPanel;
|
||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
using Cthangover.Mods.Interface.Base.Lists;
|
||||||
|
using Cthangover.Mods.Interface.Theming;
|
||||||
|
namespace Cthangover.Core.Relationship
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Mod-owned panel of captured recruits. It is declared in the mod manifest
|
||||||
|
/// as a `kind:panel` GUI entry, so the core toolbar renders its button
|
||||||
|
/// and the host caches the instance for the whole session; the core
|
||||||
|
/// character panel stays unaware of recruiting.
|
||||||
|
/// <para/>
|
||||||
|
/// The static layout (backdrop, framed panel, header row, scroll host,
|
||||||
|
/// rules and status footer) lives in <c>scenes/recruit_panel.tscn</c>;
|
||||||
|
/// this behaviour resolves those nodes and owns the dynamic layer: roster
|
||||||
|
/// rows rebuilt from <see cref="RecruitingData.Instance"/> on every show
|
||||||
|
/// and after every squad toggle. A recruit joins the active party through
|
||||||
|
/// <see cref="RecruitPartyBridge"/>, which materializes the placeholder
|
||||||
|
/// card the core character panel and battle systems consume.
|
||||||
|
/// </summary>
|
||||||
|
public class RecruitPanelBehaviour : Widget
|
||||||
|
{
|
||||||
|
// Scene nodes (recruit_panel.tscn)
|
||||||
|
private Label _title;
|
||||||
|
private Label _sub;
|
||||||
|
private Button _closeButton;
|
||||||
|
private VBoxContainer _list;
|
||||||
|
private Label _statusLabel;
|
||||||
|
|
||||||
|
/// <summary>Binds scene nodes and applies theme and localized captions.</summary>
|
||||||
|
protected override void OnceConstruct()
|
||||||
|
{
|
||||||
|
MouseFilter = MouseFilterEnum.Stop;
|
||||||
|
|
||||||
|
_title = GetNode<Label>("Panel/Margin/Root/Header/Title");
|
||||||
|
_sub = GetNode<Label>("Panel/Margin/Root/Header/Sub");
|
||||||
|
_closeButton = GetNode<Button>("Panel/Margin/Root/Header/CloseButton");
|
||||||
|
_list = GetNode<VBoxContainer>("Panel/Margin/Root/Scroll/List");
|
||||||
|
_statusLabel = GetNode<Label>("Panel/Margin/Root/StatusLabel");
|
||||||
|
|
||||||
|
ApplyTheme();
|
||||||
|
ApplyCaptions();
|
||||||
|
|
||||||
|
_closeButton.Pressed += Hide;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Theme overrides for scene nodes that depend on UiKit tokens.</summary>
|
||||||
|
private void ApplyTheme()
|
||||||
|
{
|
||||||
|
GetNode<Panel>("Panel").AddThemeStyleboxOverride("panel", UiKit.Frame(
|
||||||
|
UiTokens.RadiusLg, UiTokens.Surface0, UiTokens.Line));
|
||||||
|
|
||||||
|
_title.AddThemeColorOverride("font_color", ActionCharacterUi.InkBright);
|
||||||
|
_sub.AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||||
|
_statusLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||||
|
|
||||||
|
var backdrop = GetNode<ColorRect>("Backdrop");
|
||||||
|
UiKit.ApplyPanelBackdrop(backdrop);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Localized captions for all static labels of the scene.</summary>
|
||||||
|
private void ApplyCaptions()
|
||||||
|
{
|
||||||
|
_title.Text = ActionCharacterUi.Translate("ui/recruit/title").ToUpperInvariant();
|
||||||
|
_sub.Text = ActionCharacterUi.Translate("ui/recruit/sub");
|
||||||
|
_closeButton.TooltipText = ActionCharacterUi.Translate("ui/recruit/close");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rebuilds the roster rows from current data each time the panel is opened.</summary>
|
||||||
|
protected override void ShowConstruct()
|
||||||
|
{
|
||||||
|
Rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Frees the row nodes when the panel is hidden so the list does not go stale.</summary>
|
||||||
|
protected override void HideDestruct()
|
||||||
|
{
|
||||||
|
ClearList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Rebuild()
|
||||||
|
{
|
||||||
|
ClearList();
|
||||||
|
|
||||||
|
var roster = RecruitingData.Instance.Data;
|
||||||
|
foreach (var recruit in roster)
|
||||||
|
_list.AddChild(BuildRow(recruit));
|
||||||
|
|
||||||
|
if (roster.Count == 0)
|
||||||
|
_list.AddChild(BuildEmptyLabel());
|
||||||
|
|
||||||
|
_statusLabel.Text = string.Format(
|
||||||
|
ActionCharacterUi.Translate("ui/recruit/status"), roster.Count, CountInSquad());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Control BuildRow(Recruit recruit)
|
||||||
|
{
|
||||||
|
var template = CharacterFactory.Instance.Get(recruit.CharacterID);
|
||||||
|
var inSquad = RecruitPartyBridge.IsInSquad(recruit);
|
||||||
|
|
||||||
|
var card = new PanelContainer();
|
||||||
|
card.AddThemeStyleboxOverride("panel", UiKit.Frame(
|
||||||
|
UiTokens.RadiusMd, UiTokens.SurfaceRaised,
|
||||||
|
inSquad ? UiTokens.AccentLine : UiTokens.Line));
|
||||||
|
|
||||||
|
var margin = new MarginContainer();
|
||||||
|
margin.AddThemeConstantOverride("margin_left", 12);
|
||||||
|
margin.AddThemeConstantOverride("margin_right", 12);
|
||||||
|
margin.AddThemeConstantOverride("margin_top", 10);
|
||||||
|
margin.AddThemeConstantOverride("margin_bottom", 10);
|
||||||
|
card.AddChild(margin);
|
||||||
|
|
||||||
|
var row = new HBoxContainer();
|
||||||
|
row.AddThemeConstantOverride("separation", 12);
|
||||||
|
row.MouseFilter = MouseFilterEnum.Ignore;
|
||||||
|
margin.AddChild(row);
|
||||||
|
|
||||||
|
row.AddChild(BuildPortrait(template));
|
||||||
|
|
||||||
|
var info = new VBoxContainer();
|
||||||
|
info.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||||
|
info.AddThemeConstantOverride("separation", 4);
|
||||||
|
info.MouseFilter = MouseFilterEnum.Ignore;
|
||||||
|
row.AddChild(info);
|
||||||
|
|
||||||
|
var nameLabel = ActionCharacterUi.MakeLabel(
|
||||||
|
ActionCharacterUi.Translate(template?.Name ?? recruit.CharacterID), 18, UiTokens.InkBright);
|
||||||
|
nameLabel.ClipText = true;
|
||||||
|
info.AddChild(nameLabel);
|
||||||
|
|
||||||
|
var subLabel = ActionCharacterUi.MakeLabel(recruit.CharacterID, 10, UiTokens.Faint);
|
||||||
|
subLabel.ClipText = true;
|
||||||
|
info.AddChild(subLabel);
|
||||||
|
|
||||||
|
var health = recruit.Properties.GetInt(Recruit.PROP_HEALTH);
|
||||||
|
var maxHealth = template?.Attributes?.Health?.BaseValue ?? 0;
|
||||||
|
var healthText = maxHealth > 0 ? $"{health}/{maxHealth}" : health.ToString();
|
||||||
|
info.AddChild(UiKit.StatChip(
|
||||||
|
ActionCharacterUi.Translate("ui/recruit/hp"), healthText, UiTokens.HpText));
|
||||||
|
|
||||||
|
var button = UiKit.Button(
|
||||||
|
ActionCharacterUi.Translate(inSquad ? "ui/recruit/remove" : "ui/recruit/add"),
|
||||||
|
inSquad ? ButtonKind.Danger : ButtonKind.Primary);
|
||||||
|
button.FocusMode = FocusModeEnum.None;
|
||||||
|
button.SizeFlagsVertical = SizeFlags.ShrinkCenter;
|
||||||
|
var captured = recruit;
|
||||||
|
button.Pressed += () => OnToggleSquad(captured);
|
||||||
|
row.AddChild(button);
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Control BuildPortrait(Character template)
|
||||||
|
{
|
||||||
|
var portraitPanel = new PanelContainer();
|
||||||
|
portraitPanel.CustomMinimumSize = new Vector2(58, 58);
|
||||||
|
portraitPanel.AddThemeStyleboxOverride("panel", UiKit.Frame(
|
||||||
|
UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.Line));
|
||||||
|
portraitPanel.MouseFilter = MouseFilterEnum.Ignore;
|
||||||
|
|
||||||
|
var portrait = new TextureRect
|
||||||
|
{
|
||||||
|
Texture = template?.Image,
|
||||||
|
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
|
||||||
|
StretchMode = TextureRect.StretchModeEnum.KeepAspectCovered,
|
||||||
|
MouseFilter = MouseFilterEnum.Ignore,
|
||||||
|
};
|
||||||
|
portraitPanel.AddChild(portrait);
|
||||||
|
return portraitPanel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Control BuildEmptyLabel()
|
||||||
|
{
|
||||||
|
var empty = UiKit.EmptyState(ActionCharacterUi.Translate("ui/recruit/empty"));
|
||||||
|
empty.CustomMinimumSize = new Vector2(0, 120);
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnToggleSquad(Recruit recruit)
|
||||||
|
{
|
||||||
|
if (RecruitPartyBridge.IsInSquad(recruit))
|
||||||
|
RecruitPartyBridge.RemoveFromSquad(recruit);
|
||||||
|
else
|
||||||
|
RecruitPartyBridge.AddToSquad(recruit);
|
||||||
|
|
||||||
|
Rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int CountInSquad()
|
||||||
|
{
|
||||||
|
var count = 0;
|
||||||
|
foreach (var recruit in RecruitingData.Instance.Data)
|
||||||
|
{
|
||||||
|
if (RecruitPartyBridge.IsInSquad(recruit))
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearList()
|
||||||
|
{
|
||||||
|
if (_list == null)
|
||||||
|
return;
|
||||||
|
foreach (var child in _list.GetChildren())
|
||||||
|
child.QueueFree();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Reference in new issue
Block a user