This commit is contained in:
iisaev committed 2026-10-18 12:41:40 +03:00
commit b1ecbdf51b
22 files changed
+1384

No files matched your search

+11
View File
@@ -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
+11
View File
@@ -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
+22
View File
@@ -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
}
]
}
+102
View File
@@ -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
+73
View File
@@ -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);
}
}
}
}
+34
View File
@@ -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");
}
}
}
+48
View File
@@ -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);
}
}
+33
View File
@@ -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);
}
}
+67
View File
@@ -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)
{
}
}
}
+35
View File
@@ -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();
}
}
+21
View File
@@ -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);
}
}
+164
View File
@@ -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&lt;T&gt;</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);
}
}
}
+21
View File
@@ -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);
}
}
+17
View File
@@ -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; }
}
}
+16
View File
@@ -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; }
}
}
+145
View File
@@ -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;
}
}
}
+62
View File
@@ -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();
}
}
}
+150
View File
@@ -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;
}
}
}
}
}
+81
View File
@@ -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();
}
}
+50
View File
@@ -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;
}
}
}
+221
View File
@@ -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