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