new battle gui

This commit is contained in:
cthangover committed 2026-09-28 10:48:04 +03:00
1 parent b5ad139d69
commit 8fbac46378
59 files changed
+2897 -37

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
using System.Collections.Generic;
using Cthangover.Core.Characters;
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// Central action-execution dispatcher. Resolves an action ID to an
/// IActionExecutor in two tiers: first the active provider (set by
/// the current IBattleCore, allowing per-battle-engine overrides),
/// then a global fallback registry. This two-level lookup lets mods
/// supply custom executors for specific battle cores without replacing
/// the global ones. Execute returns ChangedAttributes to allow the
/// caller to inspect the result (success/failure, stat deltas).
/// </summary>
public class ActionExecutorHub
{
/// <summary>
/// Singleton hub instance. There is one global dispatcher shared
/// across all battle cores; cores swap providers via
/// <see cref="SetActiveProvider"/> when they activate.
/// </summary>
public static readonly ActionExecutorHub Instance = new();
private IActionExecutorProvider _activeProvider;
private readonly Dictionary<string, IActionExecutor> _globalExecutors = new();
/// <summary>
/// Installs a per-core executor provider. The active provider is
/// consulted before the global registry, allowing the current
/// battle core to override specific action handlers.
/// </summary>
public void SetActiveProvider(IActionExecutorProvider provider)
{
_activeProvider = provider;
}
/// <summary>
/// Registers a fallback executor in the global pool, keyed by
/// <see cref="IActionExecutor.ActionId"/>. Global executors are
/// used when no active provider supplies a match.
/// </summary>
public void RegisterGlobal(IActionExecutor executor)
{
if (executor != null && !string.IsNullOrEmpty(executor.ActionId))
_globalExecutors[executor.ActionId] = executor;
}
/// <summary>
/// Dispatches an action to the appropriate executor. Resolution
/// order: active provider first, then global registry. Returns
/// <c>Result = false</c> if no executor is found or if
/// <paramref name="action"/> is null.
/// </summary>
public ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
{
if (action == null)
return new ChangedAttributes { Result = false };
var executor = Resolve(action.ID);
if (executor == null)
return new ChangedAttributes { Result = false };
return executor.Execute(action, user, target);
}
private IActionExecutor Resolve(string actionId)
{
if (_activeProvider != null)
{
var executor = _activeProvider.GetExecutor(actionId);
if (executor != null)
return executor;
}
_globalExecutors.TryGetValue(actionId, out var fallback);
return fallback;
}
}
}
+119
View File
@@ -0,0 +1,119 @@
using System.Collections.Generic;
using Cthangover.Core.UI;
using Godot;
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// Sequential action queue that processes one IBattleAction per frame
/// until it signals completion (DoAction returns true), then moves to
/// the next. Fires OnStopMachine when the queue drains naturally or
/// is explicitly stopped — used by battle cores to detect when a turn's
/// animation sequence is finished. Actions can be added before or during
/// processing; the machine starts automatically once at least one action
/// is queued and no current action is running.
/// <para/>
/// Derives from <see cref="ModNode"/> so the engine lifecycle callbacks reach
/// this runtime-compiled script through the core base class (mod assemblies
/// have no Godot source generation of their own).
/// </summary>
public partial class BattleActionMachine : ModNode
{
/// <summary>
/// Pending action queue. Actions are dequeued one-by-one in the process
/// hook, starting the next as soon as the current signals completion.
/// Can be extended while the machine is running — new actions will
/// be processed after the current queue drains.
/// </summary>
public List<IBattleAction> Actions { get; set; } = new();
private IBattleAction CurrentAction { get; set; }
private bool _hadActions;
/// <summary>
/// Fired when the machine drains naturally or is explicitly stopped.
/// Battle cores subscribe to advance the turn phase without polling.
/// </summary>
public event StopMachineDelegate OnStopMachine;
protected override void OnExitTree()
{
base.OnExitTree();
StopMachine();
}
protected override void OnProcess(double delta)
{
if (Actions.Count == 0 && CurrentAction == null)
{
if (_hadActions)
{
_hadActions = false;
StopMachine();
}
return;
}
var action = CurrentAction;
if (action == null)
{
CurrentAction = Actions[0];
Actions.RemoveAt(0);
action = CurrentAction;
_hadActions = true;
action?.DoStart();
}
if (action == null)
{
_hadActions = false;
StopMachine();
return;
}
if (action.DoAction())
{
action.DoEnd();
CurrentAction = null;
}
}
/// <summary>
/// Enqueues a single action. If the machine is idle (no current
/// action), processing starts on the next _Process tick.
/// </summary>
public void AddAction(IBattleAction action)
{
if (action == null)
return;
Actions.Add(action);
}
/// <summary>
/// Batch-enqueues multiple actions. All are appended to the existing
/// queue and processed in FIFO order on subsequent frames.
/// </summary>
public void AddActions(List<IBattleAction> actions)
{
if(actions == null || actions.Count == 0)
return;
Actions.AddRange(actions);
}
/// <summary>
/// Immediately clears the queue, aborts the current action, and
/// fires <see cref="OnStopMachine"/>. Called on tree exit and
/// when the queue drains naturally.
/// </summary>
public void StopMachine()
{
CurrentAction = null;
Actions.Clear();
_hadActions = false;
OnStopMachine?.Invoke();
}
}
}
+48
View File
@@ -0,0 +1,48 @@
using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// Adapter that lets a GDScript mod act as a battle action executor. The
/// script is detected by <see cref="TurnBattleGDScriptHook"/> when it exposes
/// <c>get_action_id()</c> and <c>execute(action, user, target)</c>; this adapter
/// flattens the C# arguments into Godot dictionaries via
/// <see cref="BattleGDScriptBridge"/> and maps the returned dictionary back to
/// <see cref="ChangedAttributes"/>. Dictionaries are used instead of custom
/// RefCounted DTOs because runtime-compiled mod classes get no Godot source
/// generation, so their members would not be visible to GDScript.
/// </summary>
public class GDBattleActionExecutor : IActionExecutor
{
private readonly GodotObject _instance;
/// <summary>Creates an adapter around a live GDScript object. The object is retained by reference.</summary>
public GDBattleActionExecutor(string actionId, GodotObject instance)
{
ActionId = actionId;
_instance = instance;
}
/// <summary>Action id reported by the script through <c>get_action_id()</c>.</summary>
public string ActionId { get; }
/// <summary>
/// Calls the script and converts its result. A script that returns
/// anything other than a dictionary (or throws) yields a failed result so
/// one broken mod cannot abort the action pipeline.
/// </summary>
public ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
{
var gdAction = BattleGDScriptBridge.ActionToDictionary(action);
var gdUser = BattleGDScriptBridge.CharacterToDictionary(user);
var gdTarget = BattleGDScriptBridge.CharacterToDictionary(target);
var variant = _instance.Call("execute", gdAction, gdUser, gdTarget);
if (variant.VariantType != Variant.Type.Dictionary)
return new ChangedAttributes { Result = false };
return BattleGDScriptBridge.ResultFromDictionary(variant.AsGodotDictionary());
}
}
}
+27
View File
@@ -0,0 +1,27 @@
using Cthangover.Core.Characters;
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// Executes a specific battle action. The executor is keyed by ActionId
/// (matching the action's ID) so the hub can look it up without
/// knowing the concrete type. Returns ChangedAttributes so callers
/// can apply stat deltas and check whether the action succeeded.
/// </summary>
public interface IActionExecutor
{
/// <summary>
/// String key that matches <see cref="ActionCharacter.ID"/>.
/// The hub uses this to resolve executors without knowing
/// concrete types.
/// </summary>
string ActionId { get; }
/// <summary>
/// Applies the action's effects. Receives the action definition
/// and the user/target <see cref="Character"/> instances; returns
/// <see cref="ChangedAttributes"/> with deltas and success/failure.
/// </summary>
ChangedAttributes Execute(ActionCharacter action, Character user, Character target);
}
}
+18
View File
@@ -0,0 +1,18 @@
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// Typed executor source. Each IBattleCore returns its own provider,
/// giving cores the ability to supply custom executor sets without
/// polluting the global registry.
/// </summary>
public interface IActionExecutorProvider
{
/// <summary>
/// Returns the executor registered for <paramref name="actionId"/>,
/// or null if no override is configured. Called by
/// <see cref="ActionExecutorHub"/> before falling back to the
/// global registry.
/// </summary>
IActionExecutor GetExecutor(string actionId);
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// A single step in the battle action queue (animation, damage, effect).
/// DoStart is called once when the action begins, DoAction is polled
/// every frame — it returns true when the action is done — and DoEnd
/// is the cleanup callback. This polling model lets actions drive
/// their own duration (e.g. a tween runs until finished) without
/// the machine needing timers.
/// </summary>
public interface IBattleAction
{
/// <summary>
/// Called once when the action is dequeued and begins execution.
/// Use for setup: spawning nodes, caching references, starting
/// tweens or coroutines.
/// </summary>
void DoStart();
/// <summary>
/// Polled every frame. Returns <c>true</c> when the action has
/// finished its work; returns <c>false</c> to remain active.
/// This model eliminates the need for the machine to manage timers.
/// </summary>
bool DoAction();
/// <summary>
/// Cleanup callback fired after <see cref="DoAction"/> returns
/// <c>true</c>. Use for teardown: freeing nodes, resetting state,
/// applying final stat deltas.
/// </summary>
void DoEnd();
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// Signal emitted when the action machine drains or is stopped.
/// Battle cores subscribe to this to advance to the next phase
/// (e.g. end turn, start enemy turn) without polling.
/// </summary>
public delegate void StopMachineDelegate();
}
+42
View File
@@ -0,0 +1,42 @@
using System.Collections.Generic;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Global animation-player index keyed by AnimationType string.
/// Mods or cores register players; the action machine looks up the
/// player for a given animation type. Singleton — there is one set
/// of players for the entire runtime, but each player can make
/// per-core decisions internally.
/// </summary>
public class AnimationRegistry
{
/// <summary>
/// Singleton registry. One set of animation players serves the
/// entire runtime; individual players dispatch per-core via
/// internal logic.
/// </summary>
public static readonly AnimationRegistry Instance = new();
private readonly Dictionary<string, IBattleAnimationPlayer> _players = new();
/// <summary>
/// Registers a player under <paramref name="animType"/>.
/// Replaces any existing player for the same key — last write wins.
/// </summary>
public void Register(string animType, IBattleAnimationPlayer player)
{
_players[animType] = player;
}
/// <summary>
/// Retrieves the player registered for <paramref name="animType"/>,
/// or null if no match exists.
/// </summary>
public IBattleAnimationPlayer Get(string animType)
{
_players.TryGetValue(animType, out var player);
return player;
}
}
}
+79
View File
@@ -0,0 +1,79 @@
using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Default IBattleContext wired to the current BattleSceneContext.
/// ApplyDamage clamps at 0 (no negative health from defence overflow),
/// EndBattle delegates to ShowWinground/ShowDeadground on the scene
/// context. ScheduleAnimation and EndTurn are stubs — specific battle
/// cores are expected to override or supplement this behaviour.
/// </summary>
public class BattleContextImpl : IBattleContext
{
private readonly BattleSceneContext _sceneContext;
/// <summary>
/// Root Godot <see cref="Node"/> of the battle scene, used by
/// animation actions to parent spawned visual effects.
/// </summary>
public Node RootNode => _sceneContext;
/// <summary>
/// The owning <see cref="BattleSceneContext"/> that manages
/// the battle lifecycle.
/// </summary>
public BattleSceneContext SceneContext => _sceneContext;
/// <summary>
/// Creates a context adapter around the given scene context.
/// </summary>
public BattleContextImpl(BattleSceneContext sceneContext)
{
_sceneContext = sceneContext;
}
/// <summary>
/// Subtracts <paramref name="defence"/> from
/// <paramref name="rawDamage"/>, clamping at 0 to prevent negative
/// health from defence overflow, and applies the result to
/// <paramref name="target"/>'s health.
/// </summary>
public void ApplyDamage(Character source, Character target, int rawDamage, int defence)
{
var finalDamage = Mathf.Max(0, rawDamage - defence);
target.Attributes.Health.Value -= finalDamage;
}
/// <summary>
/// Stub — specific battle cores override this to enqueue
/// animation actions into the <see cref="BattleActionMachine"/>.
/// </summary>
public void ScheduleAnimation(IBattleAnimation animation)
{
}
/// <summary>
/// Stub — cores implement this to advance the turn sequence
/// when a side finishes its action phase.
/// </summary>
public void EndTurn(BattleSide side)
{
}
/// <summary>
/// Resolves the battle outcome by delegating to
/// <see cref="BattleSceneContext.ShowWinground"/> for player
/// victory or <see cref="BattleSceneContext.ShowDeadground"/>
/// for enemy victory.
/// </summary>
public void EndBattle(BattleSide winner)
{
if (winner == BattleSide.Player)
_sceneContext.ShowWinground();
else
_sceneContext.ShowDeadground();
}
}
}
+33
View File
@@ -0,0 +1,33 @@
using System.Collections.Generic;
using Cthangover.Core.Characters;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Describes a battle animation without binding to a concrete player.
/// Carries Source/Target characters, an AnimationType key for dispatch,
/// a Speed multiplier, and an open-ended Parameters dictionary so
/// custom animations can pass arbitrary data (damage value, element
/// type, etc.) without interface changes.
/// </summary>
public interface IBattleAnimation
{
/// <summary>The <see cref="Character"/> performing the action.</summary>
Character Source { get; }
/// <summary>The <see cref="Character"/> receiving the action.</summary>
Character Target { get; }
/// <summary>
/// Key used by <see cref="AnimationRegistry"/> to dispatch to the
/// correct <see cref="IBattleAnimationPlayer"/>.
/// </summary>
string AnimationType { get; }
/// <summary>Multiplier for the playback speed (1.0 = normal).</summary>
float Speed { get; }
/// <summary>
/// Open-ended data bag for animation-specific values
/// (e.g. damage amount, elemental flag) — no interface changes
/// needed when adding new animation types.
/// </summary>
Dictionary<string, object> Parameters { get; }
}
}
+26
View File
@@ -0,0 +1,26 @@
using System;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Plays a specific animation type. Each player is keyed by
/// AnimationType so the registry can dispatch animations without
/// knowing concrete player types. The onComplete callback chains
/// into the action machine's polling loop.
/// </summary>
public interface IBattleAnimationPlayer
{
/// <summary>
/// String key used by <see cref="AnimationRegistry"/> to select
/// this player for a given <see cref="IBattleAnimation.AnimationType"/>.
/// </summary>
string AnimationType { get; }
/// <summary>
/// Starts playing the animation described by <paramref name="anim"/>.
/// Must invoke <paramref name="onComplete"/> once the animation
/// finishes, so the action machine can advance to the next step.
/// </summary>
void Play(IBattleAnimation anim, Action onComplete);
}
}
+27
View File
@@ -0,0 +1,27 @@
using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Bridge from the battle core back to the Godot scene. Cores work
/// against this interface so they remain engine-agnostic.
/// ApplyDamage computes final-damage-minus-defence and mutates
/// the target's Health directly (no event — the UI layer polls).
/// ScheduleAnimation is the hook for visual feedback; EndTurn/EndBattle
/// are phase signals. RootNode gives cores a Godot anchor for
/// instantiating nodes (e.g. damage numbers).
/// </summary>
public interface IBattleContext
{
void ApplyDamage(Character source, Character target, int rawDamage, int defence);
void ScheduleAnimation(IBattleAnimation animation);
void EndTurn(BattleSide side);
void EndBattle(BattleSide winner);
Node RootNode { get; }
}
}
+46
View File
@@ -0,0 +1,46 @@
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Characters;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Pluggable battle engine contract. Each implementation is a
/// self-contained battle rule set (turn order, AI, phases).
/// Init receives the two character arrays and a context adapter;
/// Start begins the loop. The ActionProvider property lets the
/// core inject its own executor overrides via the hub.
/// Cores are instantiated fresh per battle — they carry no
/// persistent state between encounters.
/// </summary>
public interface IBattleCore
{
/// <summary>
/// Unique identifier for this battle engine (e.g. "card_battle",
/// "ff_battle"). Used by <see cref="BattleCoreRegistry"/> and
/// <see cref="BattleData.ActiveBattleCore"/> for lookup.
/// </summary>
string Id { get; }
/// <summary>
/// Provider that returns core-specific <see cref="IActionExecutor"/>
/// overrides. Set on the <see cref="ActionExecutorHub"/> when
/// this core activates, so actions route through custom logic
/// before falling back to the global registry.
/// </summary>
IActionExecutorProvider ActionProvider { get; }
/// <summary>
/// Initialises the core with the two character arrays and a
/// <see cref="IBattleContext"/> for callbacks. Called once per
/// battle before <see cref="Start"/>.
/// </summary>
void Init(Character[] playerChars, Character[] enemyChars, IBattleContext ctx);
/// <summary>
/// Begins the turn loop. Cores may use coroutines, polling, or
/// event-driven state machines — the core owns its own lifecycle
/// after this call.
/// </summary>
void Start();
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace Cthangover.Core.Battle
{
/// <summary>
/// Identifies which side of the battle a character or turn belongs to.
/// Used by IBattleContext.EndTurn and EndBattle to route results
/// (victory vs defeat screen).
/// </summary>
public enum BattleSide
{
/// <summary>The player's party.</summary>
Player,
/// <summary>The opposing force (monsters, NPCs).</summary>
Enemy,
}
}
+62
View File
@@ -0,0 +1,62 @@
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Characters
{
/// <summary>
/// Convenience base for IActionExecutor implementations. Provides
/// CheckRequiredAndUsePoint which validates that the user has enough
/// "Point" attribute (a generic action-point / mana pool) before
/// deducting it — returning false lets the action fail gracefully.
/// ID is abstract so each action declares its own identity.
/// </summary>
public abstract class ActionBase : IActionExecutor
{
/// <summary>
/// Maps to the abstract <see cref="ID"/> so the hub can resolve
/// this executor by action name.
/// </summary>
public string ActionId => ID;
/// <summary>
/// Unique action identifier that matches
/// <see cref="ActionCharacter.ID"/> in data.
/// </summary>
public abstract string ID { get; }
/// <summary>
/// Applies the action's effects to <paramref name="target"/>
/// using <paramref name="user"/>'s stats and the
/// <paramref name="action"/> definition. Returns stat deltas
/// and a success/failure flag.
/// </summary>
public abstract ChangedAttributes Execute(ActionCharacter action, Character user, Character target);
/// <summary>
/// Validates that <paramref name="user"/> has at least
/// <c>"RequiredPoint"</c> remaining, deducts it, and
/// returns <c>true</c>. Returns <c>false</c> with a warning log
/// if the user lacks sufficient points, allowing the action to
/// fail gracefully.
/// </summary>
protected bool CheckRequiredAndUsePoint(ActionCharacter action, Character user)
{
var requiredPoint = action.GetInt("RequiredPoint");
if (user.Attributes.Point.Value < requiredPoint)
{
GameLogger.Log("BATTLE", user.ID + " not checked point required [" + user.Attributes.Point.Value + "] for action " + action.ID + " [" + requiredPoint + "]", LogLevel.Warning);
return false;
}
GameLogger.Log("BATTLE", user.ID + " use [" + requiredPoint + "] points for action " + action.ID, LogLevel.Debug);
user.Attributes.Point.Value -= requiredPoint;
return true;
}
}
}
+134
View File
@@ -0,0 +1,134 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Pluggable battle-core registry. Scans assemblies for non-abstract
/// IBattleCore implementations, registers them by ID (each core
/// self-identifies), and activates one by ID. GetActive constructs a
/// fresh instance every call — battle cores are stateless between
/// battles. Falls back to the first registered core if no active
/// ID was set, enabling mods to ship drop-in battle engines.
/// </summary>
public class BattleCoreRegistry
{
/// <summary>Singleton registry instance.</summary>
public static readonly BattleCoreRegistry Instance = new();
private readonly Dictionary<string, Type> _cores = new();
private string _activeCoreId;
/// <summary>
/// Scans <paramref name="assembly"/> for non-abstract
/// <see cref="IBattleCore"/> implementations, instantiates each
/// to read its <see cref="IBattleCore.Id"/>, and registers the
/// type by ID. Called at startup so mods can ship drop-in cores.
/// </summary>
public void RegisterAssembly(Assembly assembly)
{
foreach (var instance in Reflections.FindAndCreate<IBattleCore>(assembly))
{
_cores[instance.Id] = instance.GetType();
}
}
/// <summary>
/// Scans every assembly loaded in the current <see cref="System.AppDomain"/>
/// for <see cref="IBattleCore"/> implementations and registers them by id.
/// Called by the battle mod's initializer after assemblies land, which
/// replaces the old core-side fan-out in ModAssemblyLoader — the core no
/// longer references battle types. Re-registering an id overwrites the
/// earlier type, so repeated scans (mod reload) stay idempotent.
/// </summary>
public void RegisterAll()
{
foreach (var instance in Reflections.FindAndCreate<IBattleCore>())
{
_cores[instance.Id] = instance.GetType();
}
GameLogger.Log("BATTLE",
$"BattleCoreRegistry.RegisterAll: {_cores.Count} core(s) registered: [{string.Join(", ", CoreIds)}]");
}
/// <summary>
/// Selects the active core by ID. Throws if the core is not
/// registered. The selected core will be instantiated fresh on
/// the next call to <see cref="GetActive"/>.
/// </summary>
public void SetActive(string id)
{
if (!_cores.ContainsKey(id))
throw new Exception($"Battle core '{id}' not found");
_activeCoreId = id;
}
/// <summary>
/// Constructs a fresh instance of the active core type. Falls
/// back to the first registered core if no active ID was set.
/// Throws if no cores are registered at all.
/// </summary>
public IBattleCore GetActive()
{
if (_activeCoreId != null && _cores.ContainsKey(_activeCoreId))
return (IBattleCore)Activator.CreateInstance(_cores[_activeCoreId]);
var firstId = GetFirstCoreId();
if (firstId != null)
{
_activeCoreId = firstId;
return (IBattleCore)Activator.CreateInstance(_cores[firstId]);
}
throw new Exception("No active battle core set. Use battle.set_core first.");
}
/// <summary>
/// Returns the ID of the first registered core, or null if the
/// registry is empty. Used as a fallback when no active core
/// has been explicitly set.
/// </summary>
public string GetFirstCoreId()
{
return _cores.Keys.FirstOrDefault();
}
/// <summary>
/// Checks whether a core with the given <paramref name="id"/>
/// has been registered.
/// </summary>
public bool HasCore(string id)
{
return _cores.ContainsKey(id);
}
/// <summary>
/// Registered core IDs, sorted for stable output. Used by the battle
/// coverage tool as the single source of truth for how many battle
/// cores ship with the current mod set.
/// </summary>
public IReadOnlyList<string> CoreIds
{
get
{
var ids = new List<string>(_cores.Keys);
ids.Sort(System.StringComparer.Ordinal);
return ids;
}
}
/// <summary>
/// Returns the registered <see cref="Type"/> for a core, or
/// <c>null</c>. Lets tooling inspect a core's implemented interfaces
/// (e.g. <see cref="IBattleAutoPlayable"/>) without instantiating it.
/// </summary>
public Type GetCoreType(string id)
{
return _cores.TryGetValue(id, out var type) ? type : null;
}
}
}
+98
View File
@@ -0,0 +1,98 @@
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Battle;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Quests;
using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.Core.Settings
{
/// <summary>
/// Immutable descriptor for a battle encounter, constructed via the
/// static factory <see cref="InitBattle"/> overloads. Holds the
/// background texture, enemy roster, quest reference, card order side,
/// and the scene to return to when the battle ends. The private
/// constructor forces creation through the factories so that all
/// required data is supplied upfront.
/// </summary>
public class BattleData
{
/// <summary>The quest that triggered this battle; may be <c>null</c>
/// for non-quest skirmishes.</summary>
public QuestBase Quest { get; set; }
/// <summary>Auxiliary tag set on new characters acquired via recruitment
/// after this battle.</summary>
public string NewTag { get; set; }
/// <summary>List of enemy <see cref="Character"/> instances spawned from
/// the provided IDs or default templates.</summary>
public List<Character> EnemiesCards { get; private set; }
/// <summary>Battle background texture (full-resolution).</summary>
public Texture2D Background { get; private set; }
/// <summary>Depth map texture for the parallax post-process effect.</summary>
public Texture2D DepthMap { get; set; }
/// <summary>Albedo/colour map texture for the parallax post-process effect.</summary>
public Texture2D AlbedoMap { get; set; }
/// <summary>Which side of the screen the player's cards are drawn on.</summary>
public BattleSide BattleSide { get; private set; } = BattleSide.Player;
/// <summary>Identifier for the active battle core logic module.</summary>
public string ActiveBattleCore { get; set; }
/// <summary>Scene name to transition to when the battle finishes or is
/// fled. Stored as a string to avoid type coupling.</summary>
public string ReturnScene { get; private set; }
private BattleData()
{ }
/// <summary>
/// Convenience overload that defaults <see cref="BattleSide"/> to
/// <c>BattleSide.Player</c>. Constructs enemies from their string IDs
/// via <see cref="Cthangover.Core.Factories.Impls.CharacterFactory"/>,
/// falling back to a level-1 template when the ID is unknown.
/// </summary>
public static BattleData InitBattle(Texture2D background, string returnScene, params string[] enemies)
{
return InitBattle(background, returnScene, BattleSide.Player, enemies);
}
/// <summary>
/// Creates a battle with a specified <see cref="BattleSide"/> and
/// enemy IDs. Each ID is resolved through
/// <see cref="Cthangover.Core.Factories.Impls.CharacterFactory.Get"/>.
/// Unknown IDs produce a fallback <see cref="Character"/> with 20 HP
/// and 3 action points.
/// </summary>
public static BattleData InitBattle(Texture2D background, string returnScene, BattleSide cardOrder = BattleSide.Player, params string[] enemies)
{
var list = enemies.Select(o => CharacterFactory.Instance.Get(o) ?? new Character
{
ID = o,
Attributes = new CharacterAttributes
{
Health = new Attribute { Value = 20, BaseValue = 20 },
Point = new Attribute { Value = 3, BaseValue = 3 },
},
Level = 1,
Exp = 10,
}).ToList();
return InitBattle(background, returnScene, list, cardOrder);
}
/// <summary>
/// Core factory that directly accepts a pre-built
/// <see cref="List{Character}"/> for the enemy roster. This is the
/// overload that all other <c>InitBattle</c> variants delegate to.
/// </summary>
public static BattleData InitBattle(Texture2D background, string returnScene, List<Character> enemiesCards, BattleSide cardOrder = BattleSide.Player)
{
return new BattleData
{
Background = background,
ReturnScene = returnScene,
EnemiesCards = enemiesCards,
BattleSide = cardOrder,
};
}
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Cthangover.Core.Events
{
[LifecycleEvent]
[EventKey("battle_ended")]
public class BattleEndedEvent : ModEvent
{
public string Result { get; init; }
public int Turns { get; init; }
}
}
+190
View File
@@ -0,0 +1,190 @@
using Cthangover.Core.Characters;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Items;
using Godot;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Converts battle data to and from plain Godot collections so GDScript mods
/// can work with it. The previous bridge exposed <c>[GlobalClass]</c> DTOs
/// (GDCharacter, GDBattleResult, GDWingroundContext), but classes compiled at
/// runtime inside mods never go through Godot's C# source generator, so their
/// properties and methods are invisible to GDScript. Dictionaries and arrays
/// are native variants and work regardless of which assembly produced them.
/// <para/>
/// Keys are snake_case to match GDScript conventions. The same helpers are
/// used by <see cref="GDBattleActionExecutor"/> (action/character snapshots in,
/// result deltas out) and <see cref="GDWingroundListener"/> (victory context
/// in, exp/loot mutations out).
/// </summary>
public static class BattleGDScriptBridge
{
/// <summary>Flattens a character into a dictionary of primitive stats and identity fields.</summary>
public static Godot.Collections.Dictionary CharacterToDictionary(Character character)
{
var data = new Godot.Collections.Dictionary();
if (character == null)
return data;
data["id"] = character.ID;
data["name"] = character.Name;
data["level"] = character.Level;
data["exp"] = character.Exp;
data["recruitment_chance"] = character.RecruitmentChance;
var attrs = character.Attributes;
if (attrs != null)
{
data["health_value"] = attrs.Health?.Value ?? 0;
data["health_max"] = attrs.Health?.BaseValue ?? 0;
data["defence_value"] = attrs.Defence?.Value ?? 0;
data["defence_max"] = attrs.Defence?.BaseValue ?? 0;
data["attack_value"] = attrs.Attack?.Value ?? 0;
data["attack_max"] = attrs.Attack?.BaseValue ?? 0;
data["point_value"] = attrs.Point?.Value ?? 0;
data["point_max"] = attrs.Point?.BaseValue ?? 0;
var properties = new Godot.Collections.Dictionary<string, string>();
if (attrs.Properties?.Values != null)
{
foreach (var kvp in attrs.Properties.Values)
properties[kvp.Key] = kvp.Value;
}
data["properties"] = properties;
}
return data;
}
/// <summary>Flattens an action card into a dictionary of identity fields and raw properties.</summary>
public static Godot.Collections.Dictionary ActionToDictionary(ActionCharacter action)
{
var data = new Godot.Collections.Dictionary();
if (action == null)
return data;
data["id"] = action.ID;
data["name"] = action.Name;
data["description"] = action.Description;
data["type"] = (int)action.Type;
var properties = new Godot.Collections.Dictionary<string, string>();
if (action.Properties?.Values != null)
{
foreach (var kvp in action.Properties.Values)
properties[kvp.Key] = kvp.Value;
}
data["properties"] = properties;
return data;
}
/// <summary>
/// Builds <see cref="ChangedAttributes"/> from the dictionary a GDScript
/// executor returns. Missing keys default to zero/false; <c>result</c>
/// defaults to <c>true</c> so a script that only reports deltas still
/// counts as a successful action.
/// </summary>
public static ChangedAttributes ResultFromDictionary(Godot.Collections.Dictionary data)
{
if (data == null)
return new ChangedAttributes { Result = false };
var result = new ChangedAttributes();
if (data.TryGetValue("source_damage", out var sourceDamage))
result.Source.Damage = sourceDamage.AsInt32();
if (data.TryGetValue("source_defence", out var sourceDefence))
result.Source.Defence = sourceDefence.AsInt32();
if (data.TryGetValue("target_damage", out var targetDamage))
result.Target.Damage = targetDamage.AsInt32();
if (data.TryGetValue("target_defence", out var targetDefence))
result.Target.Defence = targetDefence.AsInt32();
result.Result = !data.TryGetValue("result", out var success) || success.AsBool();
return result;
}
/// <summary>Builds the mutable victory context dictionary passed to GDScript winground hooks.</summary>
public static Godot.Collections.Dictionary WingroundToDictionary(WingroundContext ctx)
{
var data = new Godot.Collections.Dictionary();
if (ctx == null)
return data;
var expGained = new Godot.Collections.Dictionary<string, int>();
if (ctx.PlayerExpGained != null)
{
foreach (var kvp in ctx.PlayerExpGained)
expGained[kvp.Key] = kvp.Value;
}
data["exp_gained"] = expGained;
var defeated = new Godot.Collections.Array<Godot.Collections.Dictionary>();
if (ctx.DefeatedEnemies != null)
{
foreach (var enemy in ctx.DefeatedEnemies)
defeated.Add(CharacterToDictionary(enemy));
}
data["defeated_enemies"] = defeated;
var loot = new Godot.Collections.Array<Godot.Collections.Dictionary>();
if (ctx.GeneratedLoot != null)
{
foreach (var item in ctx.GeneratedLoot)
{
loot.Add(new Godot.Collections.Dictionary
{
["item_id"] = item.Item?.ID ?? string.Empty,
["count"] = item.Count,
});
}
}
data["generated_loot"] = loot;
data["return_scene"] = ctx.BattleData?.ReturnScene ?? string.Empty;
return data;
}
/// <summary>
/// Writes GDScript mutations from a winground context dictionary back to
/// the C# pipeline. Only <c>exp_gained</c> and <c>generated_loot</c> are
/// synced — defeated enemies are read-only snapshots, matching the
/// documented contract of <see cref="IWingroundListener"/>.
/// </summary>
public static void SyncWingroundBack(Godot.Collections.Dictionary data, WingroundContext ctx)
{
if (data == null || ctx == null)
return;
if (data.TryGetValue("exp_gained", out var expVariant)
&& expVariant.AsGodotDictionary() is Godot.Collections.Dictionary expDict
&& ctx.PlayerExpGained != null)
{
ctx.PlayerExpGained.Clear();
foreach (var key in expDict.Keys)
ctx.PlayerExpGained[key.AsString()] = expDict[key].AsInt32();
}
if (data.TryGetValue("generated_loot", out var lootVariant)
&& lootVariant.AsGodotArray() is Godot.Collections.Array lootArray
&& ctx.GeneratedLoot != null)
{
ctx.GeneratedLoot.Clear();
foreach (var entry in lootArray)
{
if (entry.AsGodotDictionary() is not Godot.Collections.Dictionary itemDict)
continue;
var itemId = itemDict.TryGetValue("item_id", out var idVariant) ? idVariant.AsString() : null;
var count = itemDict.TryGetValue("count", out var countVariant) ? countVariant.AsInt32() : 0;
ctx.GeneratedLoot.Add(new ItemContainer
{
Item = string.IsNullOrEmpty(itemId) ? null : ItemFactory.Instance.Get(itemId),
Count = count,
});
}
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
using Cthangover.Core.Actions;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Dispatched by the scenario DSL to initiate battle. Reads "scene",
/// "enemies", "quest_id" and "new_tag" from dialog variables and calls
/// <see cref="BattleService.Init"/>. The enemies string is a comma-separated
/// list embedded in the scenario script. Battle initialization must happen
/// *after* the background has been set by a prior background action, otherwise
/// the encounter gets a null backdrop.
/// <para/>
/// Registered by the battle mod's assembly scan, so the core scenario action
/// registry stays free of battle commands.
/// </summary>
public class BattleInitAction : IScenarioAction
{
/// <summary>Registered as "battle.init" — the scenario DSL's primary battle initiation command.</summary>
public string Name => "battle.init";
/// <summary>
/// Reads battle parameters from dialog variables and initiates battle
/// construction. Requires both "scene" and "enemies" to be non-empty —
/// returns early with a log if either is missing.
/// </summary>
public void Run(IActionContext ctx)
{
var sceneRaw = ctx.GetParam("scene");
var enemies = ctx.GetParam("enemies");
var questId = ctx.GetParam("quest_id");
var newTag = ctx.GetParam("new_tag");
if (string.IsNullOrEmpty(sceneRaw) || string.IsNullOrEmpty(enemies))
{
ctx.Log("EVENT", "BattleInitAction: missing 'scene' or 'enemies' variable");
return;
}
BattleService.Init(sceneRaw, enemies, questId, newTag);
ctx.Log("EVENT", $"BattleInitAction: battle started at '{sceneRaw}' with enemies '{enemies}'");
}
}
}
+18
View File
@@ -0,0 +1,18 @@
using Cthangover.Core.Settings;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Static hand-off slot between the scenario side (which builds an encounter
/// through <see cref="BattleService"/>) and the battle scene (which consumes it
/// in <see cref="BattleSceneContext"/>). Lives in the battle mod instead of the
/// core runtime state so the core does not need to know about battle data;
/// <see cref="Current"/> is transient — it is never serialized and is cleared
/// by the victory/defeat screens when the player leaves the encounter.
/// </summary>
public static class BattleRequest
{
/// <summary>Pending encounter, or <c>null</c> when no battle was requested.</summary>
public static BattleData Current { get; set; }
}
}
+357
View File
@@ -0,0 +1,357 @@
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Events;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Lights;
using Cthangover.Core.Characters;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Singleton scene-root for battle, wired as a self-registering Node
/// (sets Instance on enter-tree, clears on exit-tree). On ready it
/// assembles player characters from CharacterData.BattleSet, enemy
/// cards from the pending <see cref="BattleRequest"/>, resolves the active
/// <see cref="IBattleCore"/> via <see cref="BattleCoreRegistry"/>, and defers
/// Init+Start to the next frame so the scene tree is fully built before core
/// logic runs. Tracks PlayerExpGained (per-character, split evenly among
/// survivors) and DefeatedEnemies for the victory/loot screen. Exposes
/// OnCharacterDied and OnBattleCleared events so external systems (e.g.
/// quests, the action machine) can react without coupling.
/// ShowWinground / ShowDeadground are mutually exclusive end-of-battle
/// paths — both set IsDestroyed to prevent double-triggering and
/// reset battle speed before clearing state.
/// <para/>
/// The context derives from <see cref="ModNode"/> because mod assemblies get
/// no Godot source generation: the core base receives the engine lifecycle
/// callbacks and forwards them to the <c>OnXxx</c> overrides. It also
/// implements <see cref="IActiveCharacterProvider"/> so core UI (the inventory
/// panel's action-point row) can read the acting character without knowing
/// anything about battle.
/// </summary>
public partial class BattleSceneContext : ModNode, IActiveCharacterProvider
{
/// <summary>
/// Singleton reference to the active battle scene context.
/// Set on enter-tree, cleared on exit-tree.
/// External systems read this to access battle state.
/// </summary>
public static BattleSceneContext Instance { get; private set; }
private TextureRect battleBg;
/// <summary>
/// The <see cref="Actions.BattleActionMachine"/> node that
/// processes the animation queue for this battle. Cores push
/// animation actions through the context and the machine drives
/// playback frame-by-frame.
/// </summary>
public Actions.BattleActionMachine BattleActionMachine { get; private set; }
private IBattleSpeedControl _battleSpeedControl;
private Widget deadground;
private Widget winground;
/// <summary>
/// Per-character EXP earned during this battle, keyed by
/// <see cref="Character.ID"/>. Populated by
/// <see cref="RecordEnemyDefeated"/>; consumed by the victory screen
/// provided by the turn-battle GUI mod.
/// </summary>
public Dictionary<string, int> PlayerExpGained { get; private set; } = new();
/// <summary>
/// Enemies defeated during this battle (for loot generation
/// and recruitment rolls).
/// </summary>
public List<Character> DefeatedEnemies { get; private set; } = new();
/// <summary>
/// Guards against double-triggering end-of-battle paths.
/// Set to <c>true</c> by <see cref="ShowWinground"/> or
/// <see cref="ShowDeadground"/>.
/// </summary>
public bool IsDestroyed { get; private set; }
/// <summary>
/// When <c>true</c>, the battle core pauses processing (e.g.
/// while a UI overlay is open). Set externally by pause widgets.
/// </summary>
public bool IsWait { get; set; }
/// <summary>
/// The <see cref="Character"/> whose turn is currently active
/// in battle. Set by the battle core when a character is selected
/// and cleared when the turn ends or selection is cancelled.
/// <c>null</c> means no character is active (out of battle,
/// between turns, or idle state). External panels such as the
/// inventory panel read this to check whether the active
/// character has enough action points before performing
/// item-related actions.
/// </summary>
public Character ActiveCharacter { get; set; }
/// <summary>Fired when a character's health reaches 0.</summary>
public event System.Action<Character> OnCharacterDied;
/// <summary>
/// Fired after battle state is cleared (both victory and defeat).
/// External systems can hook this to release battle resources.
/// </summary>
public event System.Action OnBattleCleared;
/// <summary>
/// Invokes <see cref="OnCharacterDied"/> to notify listeners
/// (quests, achievements, the battle core) that a character
/// has been defeated.
/// </summary>
public void NotifyCharacterDied(Character character)
{
OnCharacterDied?.Invoke(character);
ModEventBus.Publish(new CharacterDiedEvent
{
CharacterId = character?.ID,
CharacterName = character?.Name,
Source = "battle",
});
}
protected override void OnEnterTree()
{
if (Instance != null && GodotObject.IsInstanceValid(Instance))
{
var scene = GetTree()?.CurrentScene?.Name ?? "?";
var existingPath = Instance.GetPath().ToString();
var myPath = GetPath().ToString();
GameLogger.Log("DUPLICATE", $"BattleSceneContext.OnEnterTree: Instance ALREADY SET by '{existingPath}', overwriting with duplicate at '{myPath}' on scene '{scene}'", LogLevel.Error);
}
Instance = this;
ActiveCharacterRegistry.Provider = this;
}
protected override void OnExitTree()
{
base.OnExitTree();
if (Instance == this)
Instance = null;
if (ReferenceEquals(ActiveCharacterRegistry.Provider, this))
ActiveCharacterRegistry.Provider = null;
}
private List<Character> pendingPlayerCards;
private List<Character> pendingEnemyCards;
private IBattleCore activeBattleCore;
/// <summary>
/// The battle core driving this scene, resolved from
/// <see cref="BattleCoreRegistry"/> when the scene becomes ready. Exposed
/// so tooling and tests can query the active core (for example for
/// <see cref="IBattleAutoPlayable"/>) without reaching into UI nodes.
/// </summary>
public IBattleCore ActiveBattleCore => activeBattleCore;
protected override void OnReady()
{
battleBg = GetNodeOrNull<TextureRect>("BattleBg");
if (battleBg == null)
{
GameLogger.Log("BATTLE", "battleBg not found!", LogLevel.Error);
return;
}
BattleActionMachine = GetNodeOrNull<Actions.BattleActionMachine>("ToolPanel/BattleActionMachine");
_battleSpeedControl = GetNodeOrNull<Control>("BattleSpeedControl") as IBattleSpeedControl;
deadground = GetNodeOrNull<Widget>("Deadground");
winground = GetNodeOrNull<Widget>("Winground");
var data = BattleRequest.Current;
if (data == null)
{
GameLogger.Log("BATTLE", "BattleRequest.Current is null!", LogLevel.Error);
return;
}
GameLogger.Log("BATTLE", $"set battle background: battleBg={(battleBg != null ? "found" : "NULL")}, texture={(data.Background != null ? "has" : "NULL")}", battleBg == null || data.Background == null ? LogLevel.Error : LogLevel.Debug);
if (data.Background != null)
{
battleBg.Texture = data.Background;
GameLogger.Log("BATTLE", $" battleBg.Texture assigned: width={battleBg.Texture.GetWidth()}, height={battleBg.Texture.GetHeight()}", LogLevel.Debug);
}
else
{
GameLogger.Log("BATTLE", $" data.Background is NULL — no background texture set", LogLevel.Error);
}
var timedShader = Shaders.Resolve("timed_sprite");
if (timedShader != null)
{
var bgMaterial = new ShaderMaterial { Shader = timedShader };
battleBg.Material = bgMaterial;
if (data.DepthMap != null)
bgMaterial.SetShaderParameter("depth_mask", data.DepthMap);
if (data.AlbedoMap != null)
bgMaterial.SetShaderParameter("albedo_map", data.AlbedoMap);
var lights = GetNodeOrNull<UiLightController>("Lights");
if (lights != null)
{
var viewportSize = GetViewport().GetVisibleRect().Size;
lights.SetPlayerLight(viewportSize / 2, 500f, 1f);
lights.RegisterMaterial(bgMaterial);
}
GameLogger.Log("BATTLE", $"battleBg shader: timed_sprite applied, depthMap={(data.DepthMap != null ? "has" : "NULL")}, albedoMap={(data.AlbedoMap != null ? "has" : "NULL")}, lightsCtrl={(lights != null ? "found" : "NULL")}", lights == null ? LogLevel.Error : LogLevel.Debug);
}
else
{
GameLogger.Log("BATTLE", "battleBg shader: timed_sprite NOT FOUND", LogLevel.Error);
}
pendingPlayerCards = new List<Character>();
foreach (var characterType in GameData.Instance.Runtime.CharacterData.BattleSet)
{
var characterInfo = GameData.Instance.Runtime.CharacterData.Characters[characterType];
var template = CharacterFactory.Instance.Get(characterInfo.ID);
var character = template != null ? template.Copy() : new Character { ID = characterInfo.ID };
character.Attributes = characterInfo.Attributes ?? new CharacterAttributes();
character.Level = characterInfo.Level;
character.Exp = characterInfo.Exp;
if (characterInfo.ActionSlots != null && characterInfo.ActionSlots.Any(id => !string.IsNullOrEmpty(id)))
{
character.Actions = characterInfo.ActionSlots
.Where(id => !string.IsNullOrEmpty(id))
.Select(id => ActionCharacterFactory.Instance.Get(id))
.Where(a => a != null)
.ToList();
}
pendingPlayerCards.Add(character);
}
pendingEnemyCards = data.EnemiesCards?.Where(c => c != null).ToList() ?? new List<Character>();
if (!string.IsNullOrEmpty(data.ActiveBattleCore) && BattleCoreRegistry.Instance.HasCore(data.ActiveBattleCore))
BattleCoreRegistry.Instance.SetActive(data.ActiveBattleCore);
try
{
activeBattleCore = BattleCoreRegistry.Instance.GetActive();
}
catch
{
activeBattleCore = null;
}
if (activeBattleCore == null)
{
GameLogger.Log("BATTLE", "No battle core available", LogLevel.Error);
return;
}
Callable.From(DeferredInit).CallDeferred();
}
private void DeferredInit()
{
if (IsDestroyed || activeBattleCore == null)
return;
var ctx = new BattleContextImpl(this);
var playerArray = pendingPlayerCards?.ToArray() ?? new Character[0];
var enemyArray = pendingEnemyCards?.ToArray() ?? new Character[0];
activeBattleCore.Init(playerArray, enemyArray, ctx);
activeBattleCore.Start();
ModEventBus.Publish(new BattleStartedEvent
{
BattleType = activeBattleCore.GetType().Name,
Enemies = enemyArray.Select(c => c.Name).ToArray(),
});
}
/// <summary>
/// Triggers the defeat screen: sets <see cref="IsDestroyed"/>,
/// resets battle speed, clears battle state, and shows the
/// deadground widget.
/// </summary>
public void ShowDeadground()
{
if (IsDestroyed)
return;
IsDestroyed = true;
_battleSpeedControl?.ResetToNormal();
ClearBattle();
deadground?.Show();
}
/// <summary>
/// Triggers the victory screen: sets <see cref="IsDestroyed"/>,
/// resets battle speed, clears battle state, and shows the
/// winground widget.
/// </summary>
public void ShowWinground()
{
if (IsDestroyed)
return;
IsDestroyed = true;
_battleSpeedControl?.ResetToNormal();
ClearBattle();
winground?.Show();
}
/// <summary>
/// Records a defeated enemy: adds it to
/// <see cref="DefeatedEnemies"/> and distributes its EXP
/// (<c>Level * Exp</c>) equally among all surviving player
/// characters in <see cref="PlayerExpGained"/>.
/// </summary>
public void RecordEnemyDefeated(Character enemy)
{
if (enemy == null)
return;
DefeatedEnemies.Add(enemy);
var enemyExp = enemy.Level * enemy.Exp;
var alivePlayers = 0;
foreach (var c in pendingPlayerCards)
{
if (c?.Attributes?.Health?.Value > 0)
alivePlayers++;
}
if (alivePlayers == 0)
return;
var expPerPlayer = enemyExp / alivePlayers;
foreach (var c in pendingPlayerCards)
{
if (c?.Attributes?.Health?.Value <= 0)
continue;
var id = c.ID;
if (id == null)
continue;
PlayerExpGained.TryGetValue(id, out var current);
PlayerExpGained[id] = current + expPerPlayer;
}
}
private void ClearBattle()
{
BattleActionMachine?.StopMachine();
ActiveCharacter = null;
pendingPlayerCards?.Clear();
pendingEnemyCards?.Clear();
OnBattleCleared?.Invoke();
ModEventBus.Publish(new BattleEndedEvent
{
Result = IsDestroyed ? "defeat" : "victory",
});
}
}
}
+75
View File
@@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Quests;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Core.UI.Lights;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Entry point for starting a battle encounter. Constructs <see cref="BattleData"/>
/// from scenario parameters and stores it in <see cref="BattleRequest"/> for the
/// battle scene to pick up. Captures the current background texture (via
/// <see cref="BackgroundFactory"/> using <see cref="SceneContextNode.LastBackgroundID"/>)
/// so the battle scene has the correct visual context, and snapshots the
/// depth/albedo lighting maps from <see cref="UiLightController"/> for visual
/// consistency. If a quest id is provided, the quest is bound to the encounter
/// and an optional new tag triggers a quest notification. The active battle core
/// is resolved from <see cref="BattleCoreRegistry"/> — wrapped in try/catch
/// because the registry may not be initialized when called from non-battle
/// contexts (e.g. preloading).
/// </summary>
public static class BattleService
{
/// <summary>
/// Constructs and stores <see cref="BattleRequest.Current"/> for the upcoming
/// encounter. Must be called after a background has been set by a prior
/// scenario background action, otherwise the backdrop is null. The enemies
/// string is a comma-separated list resolved by <see cref="BattleData.InitBattle"/>.
/// </summary>
public static void Init(string sceneType, string enemies, string questId = null, string newTag = null)
{
var enemyList = enemies.Split(',');
var background = BackgroundFactory.Instance.Get(SceneContextNode.LastBackgroundID);
GameLogger.Log("BATTLE", $"BattleService.Init: lastBgId='{SceneContextNode.LastBackgroundID}', bgTexture={(background != null ? "loaded" : "NULL")}", background == null ? LogLevel.Error : LogLevel.Debug);
var data = BattleData.InitBattle(background, sceneType, enemyList);
data.DepthMap = UiLightController.Instance?.CurrentDepthMap;
data.AlbedoMap = UiLightController.Instance?.CurrentAlbedoMap;
GameLogger.Log("BATTLE", $"BattleService.Init: depthMap from LightsCtrl = {(data.DepthMap != null ? "captured" : "NULL")}, albedoMap = {(data.AlbedoMap != null ? "captured" : "NULL")}", data.DepthMap == null && data.AlbedoMap == null ? LogLevel.Error : LogLevel.Debug);
try
{
data.ActiveBattleCore = BattleCoreRegistry.Instance.GetActive()?.Id;
}
catch (Exception ex)
{
GameLogger.Log("BATTLE", $"BattleService.Init: {ex.Message}\n{ex.StackTrace}", LogLevel.Error);
}
if (!string.IsNullOrEmpty(questId))
{
try
{
var quest = QuestFactory.Instance.Get(questId);
data.Quest = quest;
if (!string.IsNullOrEmpty(newTag))
{
data.NewTag = newTag;
quest.SendNotification();
}
}
catch (KeyNotFoundException)
{
GameLogger.Log("BATTLE", $"BattleService.Init: quest '{questId}' not found, battle will proceed without quest", LogLevel.Error);
}
}
BattleRequest.Current = data;
}
}
}
+40
View File
@@ -0,0 +1,40 @@
using Cthangover.Core.Actions;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Selects which battle core (combat ruleset) the next battle will use.
/// The "core" variable maps to a <see cref="BattleCoreRegistry"/> entry by
/// string ID. Must be called before a battle starts, as
/// <see cref="BattleService.Init"/> reads the active core at initialization time.
/// </summary>
public class BattleSetCoreAction : IScenarioAction
{
/// <summary>
/// Registered as "battle.set_core" — selects the combat ruleset
/// for the next battle encounter. If "core" is empty or missing, logs a
/// warning and returns without modifying the registry.
/// </summary>
public string Name => "battle.set_core";
/// <summary>
/// Reads the "core" variable and sets it as the active battle core
/// via <see cref="BattleCoreRegistry.SetActive"/>. The core determines
/// combat mechanics (turn order, ability system, etc.) for the
/// next initiated battle.
/// </summary>
public void Run(IActionContext ctx)
{
var core = ctx.GetParam("core");
if (string.IsNullOrEmpty(core))
{
ctx.Log("EVENT", "BattleSetCoreAction: missing 'core' variable");
return;
}
BattleCoreRegistry.Instance.SetActive(core);
ctx.Log("EVENT", $"BattleSetCoreAction: battle core set to '{core}'");
}
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace Cthangover.Core.Events
{
[LifecycleEvent]
[EventKey("battle_started")]
public class BattleStartedEvent : ModEvent
{
public string BattleType { get; init; }
public string[] Enemies { get; init; }
}
}
+30
View File
@@ -0,0 +1,30 @@
namespace Cthangover.Core.Characters
{
/// <summary>
/// Result struct for battle action resolution. Source and Target carry
/// Damage/Defence snapshots for both sides of the exchange. Result indicates
/// whether the action succeeded (e.g. hit landed vs. missed/blocked).
/// A struct rather than a class because these are short-lived stack values
/// created in tight battle loops — no heap allocation.
/// </summary>
public struct ChangedAttributes
{
/// <summary>
/// Snapshot of the action source's damage and defence after the
/// exchange.
/// </summary>
public CharacterSet Source;
/// <summary>
/// Snapshot of the action target's damage and defence after the
/// exchange.
/// </summary>
public CharacterSet Target;
/// <summary>
/// Whether the action succeeded (e.g. hit landed vs. missed or
/// blocked).
/// </summary>
public bool Result;
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace Cthangover.Core.Events
{
/// <summary>
/// Published when a character dies in combat. Payload copies data
/// as strings — does not pass a live Node (may be freed by the time
/// deferred subscribers are called).
/// </summary>
[EventKey("character_died")]
public class CharacterDiedEvent : ModEvent
{
public string CharacterId { get; init; }
public string CharacterName { get; init; }
public string Source { get; init; }
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace Cthangover.Core.Characters
{
/// <summary>
/// Lightweight snapshot of damage/defence values for battle action results.
/// A struct to avoid heap allocation in the battle loop where these are
/// created for every action exchange. Used by ChangedAttributes to carry
/// both source and target state.
/// </summary>
public struct CharacterSet
{
/// <summary>
/// Damage dealt or received in this exchange.
/// </summary>
public int Damage;
/// <summary>
/// Defence value at the time of the exchange.
/// </summary>
public int Defence;
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
using Cthangover.Core.Audio;
using Cthangover.Core.Battle;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Scenes;
@@ -26,7 +27,7 @@ namespace Cthangover.TurnBattle
{
var sceneService = GetNode<GodotSceneService>("/root/GodotSceneService");
sceneService?.SwitchToMenu();
GameData.Instance.Runtime.BattleData = null;
BattleRequest.Current = null;
}
/// <summary>
+64
View File
@@ -0,0 +1,64 @@
using Godot;
namespace Cthangover.Core.Battle
{
/// <summary>
/// GDScript bridge for <see cref="IWingroundListener"/>. Holds a reference to a
/// GDScript object and delegates each hook through
/// <see cref="GodotObject.HasMethod"/> / <see cref="GodotObject.Call"/>.
/// <para/>
/// All three hooks are optional — the GDScript mod implements only the methods
/// it needs. The context is marshalled into a plain Godot dictionary
/// (<c>exp_gained</c>, <c>defeated_enemies</c>, <c>generated_loot</c>,
/// <c>return_scene</c>) before each call and synced back afterward, so
/// GDScript mutations to exp and loot propagate to the C# victory pipeline.
/// Dictionaries replace the old GDWingroundContext RefCounted DTO because mod
/// classes compiled at runtime are not visible to GDScript.
/// </summary>
public class GDWingroundListener : IWingroundListener
{
private readonly GodotObject _instance;
public string Id { get; }
/// <summary>
/// Creates a bridge around a live GDScript object. The caller must ensure
/// the object outlives the registry.
/// </summary>
public GDWingroundListener(string id, GodotObject instance)
{
Id = id;
_instance = instance;
}
public void OnBeforeExpAward(WingroundContext ctx)
{
if (!_instance.HasMethod("on_winground_before_exp"))
return;
var gdCtx = BattleGDScriptBridge.WingroundToDictionary(ctx);
_instance.Call("on_winground_before_exp", gdCtx);
BattleGDScriptBridge.SyncWingroundBack(gdCtx, ctx);
}
public void OnAfterLootGenerated(WingroundContext ctx)
{
if (!_instance.HasMethod("on_winground_after_loot"))
return;
var gdCtx = BattleGDScriptBridge.WingroundToDictionary(ctx);
_instance.Call("on_winground_after_loot", gdCtx);
BattleGDScriptBridge.SyncWingroundBack(gdCtx, ctx);
}
public void OnBeforeShow(WingroundContext ctx)
{
if (!_instance.HasMethod("on_winground_before_show"))
return;
var gdCtx = BattleGDScriptBridge.WingroundToDictionary(ctx);
_instance.Call("on_winground_before_show", gdCtx);
BattleGDScriptBridge.SyncWingroundBack(gdCtx, ctx);
}
}
}
+30
View File
@@ -0,0 +1,30 @@
namespace Cthangover.Core.Battle
{
/// <summary>
/// Optional automation contract for battle cores. The base
/// <see cref="IBattleCore"/> contract describes how a battle starts but
/// not how it advances: every core owns its own UI and waits for player
/// input in its own way. Integration tests and AI tooling therefore ask
/// the core itself when it waits for the player and how to advance the
/// turn, instead of hunting for core-specific buttons by node path.
/// Cores that don't implement this interface are still runnable but are
/// reported as not universally testable by the battle coverage tool.
/// </summary>
public interface IBattleAutoPlayable
{
/// <summary>
/// <c>true</c> while the core accepts a player turn advance. Must
/// become <c>false</c> immediately when the turn ends, so a polling
/// driver cannot trigger two turns in a row.
/// </summary>
bool IsWaitingForPlayerInput { get; }
/// <summary>
/// Ends the current player turn through the same path the core's UI
/// uses (skipping the player's actions is a valid strategy for
/// smoke tests). Must be a no-op when
/// <see cref="IsWaitingForPlayerInput"/> is <c>false</c>.
/// </summary>
void AdvancePlayerTurn();
}
}
+18
View File
@@ -0,0 +1,18 @@
namespace Cthangover.Core.Battle
{
/// <summary>
/// Minimal contract between <see cref="BattleSceneContext"/> and the
/// battle view's speed control. The concrete x1/x4/x8 widget lives in the
/// turn-battle GUI mod, so the core scene only needs the one call it makes
/// when a battle ends — resetting the global time scale without persisting it.
/// </summary>
public interface IBattleSpeedControl
{
/// <summary>
/// Restores <c>Engine.TimeScale</c> to 1x without saving the preference.
/// Called when the battle ends so menus and the lobby run at normal speed
/// regardless of the player's in-battle speed choice.
/// </summary>
void ResetToNormal();
}
}
+61
View File
@@ -0,0 +1,61 @@
namespace Cthangover.Core.Battle
{
/// <summary>
/// Extension point for mods to inspect and mutate victory-screen
/// data. Implementations are auto-discovered via
/// <see cref="WingroundListenerRegistry.RegisterAssembly"/> (C#)
/// or detected by method signature in
/// <see cref="Mods.ModGDScriptLoader"/> (GDScript).
///
/// The three hooks fire in order during
/// <see cref="WingroundBehaviour.Show"/>:
/// <list type="number">
/// <item><see cref="OnBeforeExpAward"/> — EXP data is available
/// but has not yet been applied to characters.</item>
/// <item><see cref="OnAfterLootGenerated"/> — loot has been
/// rolled from enemies but has not yet been added to inventory.</item>
/// <item><see cref="OnBeforeShow"/> — all processing is done;
/// final chance to mutate data before the UI appears.</item>
/// </list>
/// The same <see cref="WingroundContext"/> instance is passed to
/// all listeners across all three phases, so mutations from earlier
/// hooks are visible to later hooks and to subsequent listeners.
/// </summary>
public interface IWingroundListener
{
/// <summary>
/// Unique identifier for this listener. Used in logs and for
/// cross-mod dependency ordering. Convention:
/// <c>"mod_id.listener_name"</c>.
/// </summary>
string Id { get; }
/// <summary>
/// Called before EXP is awarded to surviving characters.
/// <see cref="WingroundContext.PlayerExpGained"/> and
/// <see cref="WingroundContext.DefeatedEnemies"/> are
/// populated; <see cref="WingroundContext.GeneratedLoot"/>
/// is empty at this stage.
/// </summary>
void OnBeforeExpAward(WingroundContext ctx);
/// <summary>
/// Called after loot has been generated from
/// <see cref="WingroundContext.DefeatedEnemies"/> but before
/// the items are added to the player's inventory.
/// <see cref="WingroundContext.GeneratedLoot"/> is populated
/// and mutable — adding, removing, or modifying entries here
/// directly affects the inventory outcome.
/// </summary>
void OnAfterLootGenerated(WingroundContext ctx);
/// <summary>
/// Called after EXP, loot, inventory, and recruitment have
/// all been processed, immediately before the victory UI
/// is shown (<c>base.Show()</c>). Use for post-processing
/// or cleanup that must happen before the player sees the
/// screen.
/// </summary>
void OnBeforeShow(WingroundContext ctx);
}
}
+58
View File
@@ -0,0 +1,58 @@
using Cthangover.Core.Characters;
namespace Cthangover.Core.Cards.StatusEffect
{
/// <summary>
/// Behaviour hooks for a status effect type. Each hook receives the
/// owning character so a single IStatusActions instance can be
/// shared across multiple characters. OnDealDamage / OnTakeDamage
/// use ref int to let effects amplify or reduce damage before it
/// is applied. ModifyAttributes is a passive stat modifier called
/// independently of turn phases. OnFinalAction fires once when the
/// effect expires. All hooks have empty default semantics — effects
/// override only the hooks they care about.
/// </summary>
public interface IStatusActions
{
/// <summary>Factory key matching the <c>Actions</c> field in
/// <see cref="StatusEffectInfo"/>, e.g. <c>"basic/stun"</c>.</summary>
string ID { get; }
/// <summary>Fires when the owning character's turn begins. Use for
/// start-of-turn damage-over-time or regeneration effects.</summary>
void OnTurnStart(Character target);
/// <summary>Fires when the owning character's turn ends. Mirrors
/// <see cref="OnTurnStart"/> for symmetry; useful for effects that
/// activate after the character acts.</summary>
void OnTurnEnd(Character target);
/// <summary>Called before the owning character deals damage to
/// <paramref name="target"/>. Modify <paramref name="damage"/> via
/// <see langword="ref"/> to amplify or reduce outgoing damage —
/// all active effects stack multiplicatively through the queue's
/// iteration.</summary>
void OnDealDamage(Character source, Character target, ref int damage);
/// <summary>Called before the owning character receives damage from
/// <paramref name="source"/>. Modify <paramref name="damage"/> via
/// <see langword="ref"/> to add damage reduction or vulnerability.
/// Fires after <see cref="OnDealDamage"/> in the damage
/// pipeline.</summary>
void OnTakeDamage(Character target, Character source, ref int damage);
/// <summary>Fires immediately when the effect is first placed on a
/// character. Use for one-shot stat boosts, visual feedback, or
/// initialisation logic.</summary>
void OnApply(Character target);
/// <summary>Fires when the effect is manually removed (e.g. via a
/// cleanse card). Not called on natural expiration — use
/// <see cref="OnFinalAction"/> for that path.</summary>
void OnRemove(Character target);
/// <summary>Fires once when the effect expires naturally (timer reaches
/// zero). Use for end-of-effect triggers like explosion damage or
/// stat restoration. Not called on manual removal.</summary>
void OnFinalAction(Character target);
/// <summary>Passive stat modifier invoked independently of turn phases.
/// Receives a reference to the character's attribute container;
/// implementors can add flat or percentage bonuses that other
/// effects may also contribute to.</summary>
void ModifyAttributes(CharacterAttributes attributes);
}
}
+49
View File
@@ -0,0 +1,49 @@
using Godot;
namespace Cthangover.Core.Cards.StatusEffect
{
/// <summary>
/// Status effect data contract. Separates identity (ID, Name, Icon)
/// from mutable state (Duration, RemainingTurns) and behaviour
/// (Actions — an IStatusActions hook set). Copy() is required so the
/// StatusEffectQueue can clone effects when characters are duplicated.
/// </summary>
public interface IStatusEffect
{
/// <summary>Unique mod identifier, e.g. <c>"basic/stun"</c>. Used by factories
/// and the queue to look up and remove effects by key.</summary>
string ID { get; }
/// <summary>Display name shown in the battle UI.</summary>
string Name { get; }
/// <summary>Flavour text explaining the effect to the player.</summary>
string Description { get; }
/// <summary>High-level category (<c>Buff</c>, <c>Debuff</c>, <c>Stun</c>) used
/// for UI tinting and quick type checks like
/// <see cref="StatusEffectQueue.HasStun"/>.</summary>
StatusEffectType EffectType { get; }
/// <summary>Base duration in turns. Set during effect creation; may be
/// overridden by the caller via <see cref="StatusEffectQueue.Add"/>.
/// A value of 0 or negative means the effect never expires
/// naturally.</summary>
int Duration { get; set; }
/// <summary>Turns remaining before the effect expires. Decremented each
/// turn boundary by the queue. Independent of <c>Duration</c> so
/// that the original value is preserved for display or re-apply
/// logic.</summary>
int RemainingTurns { get; set; }
/// <summary>Icon texture loaded from the <c>"characters"</c> mod group,
/// displayed beside the effect name in the HUD.</summary>
Texture2D Icon { get; }
/// <summary>Behaviour hooks that fire during turn phases and on damage
/// events. Resolved by <c>StatusEffectActionFactory</c> from the
/// <see cref="StatusEffectInfo.Actions"/> string ID.</summary>
IStatusActions Actions { get; }
/// <summary>Creates a shallow clone. <c>Actions</c> is a shared reference
/// (stateless singleton) so it is not deep-copied. Used by
/// <see cref="StatusEffectQueue.Copy"/> when duplicating a
/// character.</summary>
IStatusEffect Copy();
}
}
@@ -0,0 +1,49 @@
using Cthangover.Core.Characters;
namespace Cthangover.Core.Cards.StatusEffect.Impls
{
/// <summary>
/// Stun behaviour set — intentionally all no-ops. The stun condition
/// is checked separately via StatusEffectQueue.HasStun() rather than
/// through action hooks, so the Actions implementation is empty.
/// Exists as a concrete type so the factory can resolve "basic/stun"
/// to a valid instance.
/// </summary>
public class StunStatusEffect : IStatusActions
{
/// <inheritdoc cref="IStatusActions.ID"/>
public string ID => "basic/stun";
/// <inheritdoc cref="IStatusActions.OnTurnStart"/>
public void OnTurnStart(Character target)
{ }
/// <inheritdoc cref="IStatusActions.OnTurnEnd"/>
public void OnTurnEnd(Character target)
{ }
/// <inheritdoc cref="IStatusActions.OnDealDamage"/>
public void OnDealDamage(Character source, Character target, ref int damage)
{ }
/// <inheritdoc cref="IStatusActions.OnTakeDamage"/>
public void OnTakeDamage(Character target, Character source, ref int damage)
{ }
/// <inheritdoc cref="IStatusActions.OnApply"/>
public void OnApply(Character target)
{ }
/// <inheritdoc cref="IStatusActions.OnRemove"/>
public void OnRemove(Character target)
{ }
/// <inheritdoc cref="IStatusActions.OnFinalAction"/>
public void OnFinalAction(Character target)
{ }
/// <inheritdoc cref="IStatusActions.ModifyAttributes"/>
public void ModifyAttributes(CharacterAttributes attributes)
{ }
}
}
+45
View File
@@ -0,0 +1,45 @@
using System.Text.Json.Serialization;
using Cthangover.Core.Factories;
namespace Cthangover.Core.Cards.StatusEffect
{
/// <summary>
/// JSON-serialisable status effect definition loaded from mod files.
/// The Actions field is an ID string resolved by StatusEffectActionFactory
/// into an IStatusActions instance at runtime. Type uses
/// JsonStringEnumConverter for human-readable config files.
/// Implements IIdentifiable so it works with PrefabFactory caching.
/// </summary>
[System.Serializable]
public class StatusEffectInfo : IIdentifiable
{
/// <summary>Unique effect identifier used as the factory key.
/// Serialised as <c>"Id"</c> in JSON for consistency with the
/// modding schema.</summary>
[JsonPropertyName("Id")]
public string ID { get; set; }
/// <summary>Display name for the effect, shown in tooltips and the
/// character status panel.</summary>
public string Name { get; set; }
/// <summary>Human-readable description explaining what the effect
/// does.</summary>
public string Description { get; set; }
/// <summary>Category of the effect. Serialised as a string
/// (<c>"Buff"</c>, <c>"Debuff"</c>, <c>"Stun"</c>) via
/// <c>JsonStringEnumConverter</c> for readable mod configs.</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public StatusEffectType Type { get; set; }
/// <summary>Base duration in turns before the effect naturally
/// expires.</summary>
public int Duration { get; set; }
/// <summary>Factory ID string for the behaviour set, resolved by
/// <c>StatusEffectActionFactory</c> into an
/// <see cref="IStatusActions"/> instance at runtime.</summary>
public string Actions { get; set; }
/// <summary>Filename of the icon texture within the
/// <c>"characters"</c> mod group, loaded by
/// <see cref="StatusEffectItem"/> during construction.</summary>
public string Icon { get; set; }
}
}
+78
View File
@@ -0,0 +1,78 @@
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.Cards.StatusEffect
{
/// <summary>
/// Runtime instance of a status effect. Constructed from
/// StatusEffectInfo with the Actions behaviour resolved via
/// StatusEffectActionFactory. Icon is loaded from the "characters"
/// mod group via TextureUtils — effects share the same icon pool
/// as characters. Copy() is a shallow clone of primitives and
/// references (Actions is a shared singleton, not cloned) suitable
/// for StatusEffectQueue duplication.
/// </summary>
public class StatusEffectItem : IStatusEffect
{
/// <inheritdoc cref="IStatusEffect.ID"/>
public string ID { get; private set; }
/// <inheritdoc cref="IStatusEffect.Name"/>
public string Name { get; private set; }
/// <inheritdoc cref="IStatusEffect.Description"/>
public string Description { get; private set; }
/// <inheritdoc cref="IStatusEffect.Duration"/>
public int Duration { get; set; }
/// <inheritdoc cref="IStatusEffect.RemainingTurns"/>
public int RemainingTurns { get; set; }
/// <inheritdoc cref="IStatusEffect.EffectType"/>
public StatusEffectType EffectType { get; private set; }
/// <inheritdoc cref="IStatusEffect.Icon"/>
public Texture2D Icon { get; private set; }
/// <inheritdoc cref="IStatusEffect.Actions"/>
public IStatusActions Actions { get; private set; }
/// <summary>Constructs a runtime effect from its JSON-serialisable
/// definition. Resolves the icon via
/// <c>TextureUtils.LoadFromModGroup</c> and the action set via
/// <c>StatusEffectActionFactory</c>.</summary>
/// <param name="info">Deserialised effect data from a mod
/// config.</param>
public StatusEffectItem(StatusEffectInfo info)
{
ID = info.ID;
Name = info.Name;
Description = info.Description;
Duration = info.Duration;
EffectType = info.Type;
Icon = TextureUtils.LoadFromModGroup("characters", info.Icon);
Actions = StatusEffectActionFactory.Instance.Get(info.Actions);
}
/// <summary>Parameterless constructor for cases where properties are
/// set manually (e.g. by <see cref="Copy"/> or deserialisation
/// paths).</summary>
public StatusEffectItem() { }
/// <summary>Shallow clone that copies primitives and reuses the
/// shared <c>Actions</c> reference. Used when the queue
/// duplicates effects during character cloning.</summary>
/// <returns>A new <see cref="StatusEffectItem"/> with identical
/// property values.</returns>
public IStatusEffect Copy()
{
return new StatusEffectItem
{
ID = ID,
Name = Name,
Description = Description,
Duration = Duration,
RemainingTurns = RemainingTurns,
EffectType = EffectType,
Icon = Icon,
Actions = Actions,
};
}
}
}
+223
View File
@@ -0,0 +1,223 @@
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Characters;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.Cards.StatusEffect
{
/// <summary>
/// Per-character status-effect pipeline. Manages a list of active
/// effects, advancing their timers on turn boundaries and removing
/// expired ones after firing OnFinalAction. Add resolves the effect
/// info via the factory and calls OnApply immediately. OnTurnStart
/// iterates backwards to safely remove expired effects during
/// traversal. OnDealDamage / OnTakeDamage pipe through every active
/// effect via ref parameters, enabling multiplicative stacking.
/// ActiveStatusEffects returns a defensive copy so external code
/// cannot mutate the internal list. Copy creates a deep clone for
/// character duplication, re-firing OnApply on the new owner.
/// </summary>
public class StatusEffectQueue
{
private readonly List<IStatusEffect> activeStatusEffectData;
private readonly Character owner;
/// <summary>Defensive copy of the internal effect list. Returns
/// a new <c>List</c> each access so external code cannot mutate
/// the queue's backing store. Safe for enumeration.</summary>
public List<IStatusEffect> ActiveStatusEffects => activeStatusEffectData.ToList();
/// <summary>Initialises an empty queue bound to <paramref name="owner"/>.
/// The owner reference is used by all action-hook calls to pass
/// the affected character.</summary>
/// <param name="owner">The character who carries these
/// effects.</param>
public StatusEffectQueue(Character owner)
{
this.activeStatusEffectData = new List<IStatusEffect>();
this.owner = owner;
}
/// <summary>Checks whether any active effect has
/// <see cref="StatusEffectType.Stun"/>. Called by the battle
/// engine to determine if the owner should skip their turn.</summary>
/// <returns><see langword="true"/> if at least one Stun-type effect
/// is active.</returns>
public bool HasStun()
{
foreach (var statusEffect in activeStatusEffectData)
if (statusEffect.EffectType == StatusEffectType.Stun)
return true;
return false;
}
/// <summary>Looks up <paramref name="statusEffectId"/> in the factory,
/// constructs a <see cref="StatusEffectItem"/>, optionally
/// overrides its duration, fires <c>OnApply</c>, and appends it
/// to the active list.</summary>
/// <param name="statusEffectId">Factory key matching a
/// <see cref="StatusEffectInfo"/> entry.</param>
/// <param name="duration">If positive, overwrites the default
/// duration from the effect definition.</param>
/// <returns><see langword="false"/> if the factory lookup fails
/// (key not found); otherwise <see langword="true"/>.</returns>
public bool Add(string statusEffectId, int duration = -1)
{
var statusEffectInfo = Factories.Impls.StatusEffectInfoFactory.Instance.Get(statusEffectId);
if (statusEffectInfo == null)
{
GameLogger.Log("STATUS", $"statusEffect with ID '{statusEffectId}' not found!", LogLevel.Error);
return false;
}
var statusEffect = new StatusEffectItem(statusEffectInfo);
if (duration > 0)
{
statusEffect.Duration = duration;
statusEffect.RemainingTurns = duration;
}
statusEffect.Actions?.OnApply(owner);
activeStatusEffectData.Add(statusEffect);
return true;
}
/// <summary>Removes the first effect matching
/// <paramref name="statusEffectId"/> from the active list.
/// Fires <c>OnRemove</c> so the effect can clean up before
/// disappearing (e.g. revert stat modifications).</summary>
/// <param name="statusEffectId">Factory key of the effect to
/// remove.</param>
/// <returns><see langword="false"/> if no matching effect was
/// found; otherwise <see langword="true"/>.</returns>
public bool Remove(string statusEffectId)
{
var statusEffect = activeStatusEffectData.FirstOrDefault(b => b.ID == statusEffectId);
if (statusEffect == null)
return false;
statusEffect.Actions?.OnRemove(owner);
activeStatusEffectData.Remove(statusEffect);
return true;
}
/// <summary>Advances all active effects at the start of the owner's
/// turn. Fires <c>OnTurnStart</c> on each effect, decrements
/// <see cref="IStatusEffect.RemainingTurns"/> for timed effects,
/// and removes expired ones after firing
/// <c>OnFinalAction</c>. Iterates backwards so removals do not
/// corrupt the index.</summary>
public void OnTurnStart()
{
for (int i = activeStatusEffectData.Count - 1; i >= 0; i--)
{
var statusEffect = activeStatusEffectData[i];
statusEffect.Actions?.OnTurnStart(owner);
if (statusEffect.RemainingTurns > 0 && statusEffect.Duration > 0)
{
statusEffect.RemainingTurns--;
if (statusEffect.RemainingTurns <= 0)
{
statusEffect.Actions?.OnFinalAction(owner);
activeStatusEffectData.RemoveAt(i);
}
}
}
}
/// <summary>Fires end-of-turn hooks on all active effects. Operates
/// on a defensive copy so that <c>OnTurnEnd</c> callbacks that
/// add or remove effects do not invalidate the enumerator.
/// Decrements remaining turns and removes expired effects via
/// <see cref="Remove"/> (which also fires
/// <c>OnRemove</c>).</summary>
public void OnTurnEnd()
{
foreach (var statusEffect in ActiveStatusEffects)
{
statusEffect.Actions?.OnTurnEnd(owner);
statusEffect.RemainingTurns--;
if (statusEffect.RemainingTurns <= 0)
{
statusEffect.Actions?.OnFinalAction(owner);
Remove(statusEffect.ID);
}
}
}
/// <summary>Pipes outgoing damage through every active effect's
/// <c>OnDealDamage</c> hook, allowing each to modify
/// <paramref name="damage"/> via <see langword="ref"/>.
/// Effects iterate in insertion order; multiplicative stacking
/// means order-agnostic results for flat modifiers but
/// order-sensitive for percentage modifiers.</summary>
/// <param name="target">The character receiving the damage.</param>
/// <param name="damage">The damage value, modifiable by effects
/// through the <see langword="ref"/> parameter.</param>
public void OnDealDamage(Character target, ref int damage)
{
foreach (var statusEffect in activeStatusEffectData)
{
statusEffect.Actions?.OnDealDamage(owner, target, ref damage);
}
}
/// <summary>Pipes incoming damage through every active effect's
/// <c>OnTakeDamage</c> hook. Called after
/// <see cref="OnDealDamage"/> in the damage pipeline, so
/// defensive modifiers stack on top of the attacker's
/// amplified value.</summary>
/// <param name="source">The character dealing the damage.</param>
/// <param name="damage">The damage value, modifiable by effects
/// through the <see langword="ref"/> parameter.</param>
public void OnTakeDamage(Character source, ref int damage)
{
foreach (var statusEffect in activeStatusEffectData)
{
statusEffect.Actions?.OnTakeDamage(owner, source, ref damage);
}
}
/// <summary>Removes all active effects, firing <c>OnRemove</c> on
/// each before clearing the list. Used when a character dies or
/// an encounter ends so effects do not leak between battles.</summary>
public void ClearAll()
{
foreach (var statusEffect in activeStatusEffectData)
{
statusEffect.Actions?.OnRemove(owner);
}
activeStatusEffectData.Clear();
}
/// <summary>Deep-clones the entire queue for a new character owner.
/// Each effect is copied via <see cref="IStatusEffect.Copy"/>,
/// and <c>OnApply</c> is re-fired on <paramref name="newOwner"/>
/// so initialisation hooks run in the target's context. Used
/// when a character is duplicated mid-encounter (e.g. a summon
/// or mirror effect).</summary>
/// <param name="newOwner">The character who will own the
/// clone.</param>
/// <returns>A new <see cref="StatusEffectQueue"/> bound to
/// <paramref name="newOwner"/> with copies of all active
/// effects.</returns>
public StatusEffectQueue Copy(Character newOwner)
{
var newSystem = new StatusEffectQueue(newOwner);
foreach (var statusEffect in activeStatusEffectData)
{
var copiedstatusEffect = statusEffect.Copy();
copiedstatusEffect.Actions?.OnApply(newOwner);
newSystem.activeStatusEffectData.Add(copiedstatusEffect);
}
return newSystem;
}
}
}
+40
View File
@@ -0,0 +1,40 @@
using System.Runtime.CompilerServices;
using Cthangover.Core.Characters;
namespace Cthangover.Core.Cards.StatusEffect
{
/// <summary>
/// Side-table that owns the <see cref="StatusEffectQueue"/> of every character
/// participating in battle. The queue used to live on <see cref="Character"/>
/// itself, but the core character model must stay free of battle subsystems —
/// so the battle mod keeps queues keyed by character instance instead.
/// <para/>
/// Uses <see cref="ConditionalWeakTable{TKey, TValue}"/>: queues die together
/// with their character, which matters because each battle constructs fresh
/// copies via <see cref="Character.Copy"/> and discards them afterwards. All
/// access happens on the main thread during battle, so no extra locking is
/// needed beyond what the weak table provides.
/// </summary>
public static class StatusEffectService
{
private static readonly ConditionalWeakTable<Character, StatusEffectQueue> queues = new();
/// <summary>
/// Returns the queue of <paramref name="character"/>, creating an empty one
/// on first access. Returns <c>null</c> for a null character so call sites
/// can keep their existing null-conditional patterns.
/// </summary>
public static StatusEffectQueue Of(Character character)
{
if (character == null)
return null;
return queues.GetValue(character, CreateQueue);
}
private static StatusEffectQueue CreateQueue(Character owner)
{
return new StatusEffectQueue(owner);
}
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace Cthangover.Core.Cards.StatusEffect
{
/// <summary>
/// High-level classification for status effects. Buff/Debuff are
/// informational (UI colouring, stacking rules); Stun is singled
/// out because the queue has a dedicated HasStun() check that the
/// battle engine queries to skip a stunned character's turn.
/// </summary>
public enum StatusEffectType
{
/// <summary>Positive effect that benefits the bearer (e.g. stat boost,
/// regeneration). Rendered with a friendly tint in the UI.</summary>
Buff,
/// <summary>Negative effect that harms the bearer (e.g. damage over
/// time, stat reduction). Rendered with a hostile tint in the
/// UI.</summary>
Debuff,
/// <summary>Crowd-control effect that causes the bearer to skip
/// their turn. Checked by
/// <see cref="StatusEffectQueue.HasStun"/>; the action hooks
/// are deliberately no-ops — the skip is enforced by the battle
/// engine reading this type flag, not by the
/// <see cref="IStatusActions"/> callbacks.</summary>
Stun,
}
}
+46
View File
@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Factories.Impls
{
/// <summary>
/// Reflection-based plugin registry for status effect behaviour
/// classes, mirroring the pattern used by <c>ItemActionFactory</c>.
/// Each <c>IStatusActions</c> implementation corresponds to a status
/// effect ID referenced in battle data JSON — the factory auto-discovers
/// them at startup so mod authors can add new status effects (e.g.
/// poison, burn, freeze) by implementing the interface in their
/// assembly without touching core code.
/// </summary>
public class StatusEffectActionFactory
{
private readonly Dictionary<string, IStatusActions> actions;
#region Singleton
private static readonly Lazy<StatusEffectActionFactory> instance = new(() => new StatusEffectActionFactory());
public static StatusEffectActionFactory Instance => instance.Value;
public StatusEffectActionFactory()
{
actions = new Dictionary<string, IStatusActions>();
foreach (var action in Reflections.FindAndCreate<IStatusActions>())
{
actions.Add(action.ID, action);
}
}
#endregion
public IStatusActions Get(string id)
{
if (actions.TryGetValue(id, out var result))
return result;
GameLogger.Log("STATUS", "actions '" + id + "' not found!", LogLevel.Error);
return null;
}
}
}
+19
View File
@@ -0,0 +1,19 @@
using System;
using Cthangover.Core.Cards.StatusEffect;
namespace Cthangover.Core.Factories.Impls
{
/// <summary>
/// Thin <c>FileFactory</c> for status effect metadata — duration, stack
/// behaviour, visual icon, etc. Works in tandem with
/// <c>StatusEffectActionFactory</c>: this factory provides the <b>data</b>
/// (what the effect is), while the action factory provides the
/// <b>behaviour</b> (what the effect does each turn).
/// </summary>
public class StatusEffectInfoFactory : FileFactory<StatusEffectInfo>
{
private static readonly Lazy<StatusEffectInfoFactory> lazy = new(() => new StatusEffectInfoFactory());
public static StatusEffectInfoFactory Instance => lazy.Value;
public override string GroupName => "status_effects";
}
}
+62
View File
@@ -0,0 +1,62 @@
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.TurnBattle
{
/// <summary>
/// GDScript hook of the turn-battle mod. The core's ModGDScriptLoader walks
/// every <c>.gd</c> file generically and hands objects to registered hooks;
/// this hook recognizes the two battle-side contracts:
/// <list type="bullet">
/// <item><c>get_action_id()</c> + <c>execute(action, user, target)</c> — wraps
/// the script in a <see cref="GDBattleActionExecutor"/> and registers it in the
/// global <see cref="ActionExecutorHub"/> pool.</item>
/// <item><c>get_winground_listener_id()</c> plus any of the three
/// <c>on_winground_*</c> methods — wraps the script in a
/// <see cref="GDWingroundListener"/>.</item>
/// </list>
/// Keeping the recognition here means the core loader no longer mentions any
/// battle method name.
/// </summary>
public class TurnBattleGDScriptHook : IGDScriptHook
{
/// <summary>Stable id used by <see cref="GDScriptHookRegistry"/> for replacement on reload.</summary>
public string Id => "turn_battle_base.gd_hook";
/// <summary>Inspects one compiled script and registers an adapter when a contract matches.</summary>
public void TryRegister(GodotObject obj, string modId, string filePath)
{
if (obj == null)
return;
if (obj.HasMethod("get_action_id") && obj.HasMethod("execute"))
{
var actionId = obj.Call("get_action_id").AsString();
if (!string.IsNullOrEmpty(actionId))
{
ActionExecutorHub.Instance.RegisterGlobal(new GDBattleActionExecutor(actionId, obj));
GameLogger.Log("GDS_MOD",
$"Registered GDScript battle executor '{actionId}' from mod '{modId}' ({filePath})");
}
}
var hasWingroundHook = obj.HasMethod("on_winground_before_exp")
|| obj.HasMethod("on_winground_after_loot")
|| obj.HasMethod("on_winground_before_show");
if (hasWingroundHook && obj.HasMethod("get_winground_listener_id"))
{
var listenerId = obj.Call("get_winground_listener_id").AsString();
if (!string.IsNullOrEmpty(listenerId))
{
WingroundListenerRegistry.Instance.RegisterListener(new GDWingroundListener(listenerId, obj));
GameLogger.Log("GDS_MOD",
$"Registered GDScript winground listener '{listenerId}' from mod '{modId}' ({filePath})");
}
}
}
}
}
+60 -8
View File
@@ -1,25 +1,77 @@
using System;
using System.Linq;
using System.Reflection;
using Cthangover.Core.Audio;
using Cthangover.Core.Battle;
using Cthangover.Core.Mods;
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
namespace Cthangover.TurnBattle
{
/// <summary>
/// Registers the turn-based battle view scene with the core so that
/// <see cref="GodotSceneService"/> can route the logical "battle" scene to
/// this mod's tscn. Runs at resources-ready — before any battle can be
/// triggered — and is the reason the core no longer hardcodes a battle path.
/// Lifecycle entry point of the turn-battle mod. Three responsibilities:
/// <list type="number">
/// <item>Publishes the logical "Battle" scene route with fade transition and
/// combat music, so the core can route <c>switch_scene Battle</c> without
/// hardcoding a battle path.</item>
/// <item>Registers <see cref="TurnBattleGDScriptHook"/> so GDScript mods can
/// still supply action executors and winground listeners after the battle
/// bridge moved out of the core loader.</item>
/// <item>Discovers <see cref="IBattleCore"/> and <see cref="IWingroundListener"/>
/// implementations across mod assemblies — this replaces the core-side
/// registry fan-out in ModAssemblyLoader. Every mod announcement triggers a
/// scan of that mod's assembly, and the resources-ready pass re-scans the
/// whole AppDomain as a safety net for assemblies whose name differs from
/// the mod id (precompiled DLLs).</item>
/// </list>
/// </summary>
public class TurnBattleSceneInitializer : IModInitializer
{
private const string BattleScenePath = "scenes/battle/battle.tscn";
/// <summary>No-op: registration only needs the resources-ready stage.</summary>
public void OnModLoaded(string modId) { }
/// <summary>
/// Registers battle cores and winground listeners from the mod that just
/// announced itself, and installs the GDScript hook. Assemblies are looked
/// up by mod id (the compiler names every source-compiled assembly after
/// its mod), so no event plumbing is needed — the initializer simply
/// rescans as the mod set grows. The hook must be installed here, not at
/// resources-ready: ModGDScriptLoader processes <c>.gd</c> sources during
/// assembly registration finalization, before resources become ready.
/// </summary>
public void OnModLoaded(string modId)
{
GDScriptHookRegistry.Register(new TurnBattleGDScriptHook());
RegisterAssembly(FindAssembly(modId));
}
/// <summary>Publishes the battle view scene path to <see cref="BattleSceneRegistry"/>.</summary>
/// <summary>
/// Publishes the scene route and rescans all loaded assemblies for battle
/// cores and winground listeners.
/// </summary>
public void OnModResourcesReady()
{
BattleSceneRegistry.Register(BattleScenePath);
SceneRouteRegistry.Register("Battle", BattleScenePath, fade: true, musicScene: "Battle", music: MusicType.Combat);
BattleCoreRegistry.Instance.RegisterAll();
WingroundListenerRegistry.Instance.RegisterAll();
}
private static void RegisterAssembly(Assembly assembly)
{
if (assembly == null)
return;
BattleCoreRegistry.Instance.RegisterAssembly(assembly);
WingroundListenerRegistry.Instance.RegisterAssembly(assembly);
}
private static Assembly FindAssembly(string modId)
{
if (string.IsNullOrEmpty(modId))
return null;
return AppDomain.CurrentDomain.GetAssemblies()
.FirstOrDefault(assembly => assembly.GetName().Name == modId);
}
}
}
+17 -22
View File
@@ -3,10 +3,10 @@ using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Audio;
using Cthangover.Core.Battle;
using Cthangover.Core.Events;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Items;
using Cthangover.Core.Mods;
using Cthangover.Core.Relationship;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Core.UI.Base.Lists;
@@ -25,8 +25,11 @@ namespace Cthangover.TurnBattle
/// for missing slots to keep layout stable), rolls loot from defeated
/// enemies using per-item probability/range, lays out loot in a grid
/// whose cell size is computed from the container width and
/// lootAspectHeight, adds items to inventory, and processes character
/// recruitment via RecruitBehaviourRegistry. A "return" button sets
/// lootAspectHeight, and adds items to inventory. When enemies were
/// defeated it also publishes <see cref="CharactersDefeatedEvent"/> on the
/// core event bus — so systems like the recruiting mod can react without
/// this screen knowing about them (and without a mod dependency).
/// A "return" button sets
/// the pending scene and reloads BaseScene — the actual destination
/// scene (town_entry or the quest's ReturnScene) is injected by
/// the scene manager.
@@ -81,7 +84,7 @@ namespace Cthangover.TurnBattle
bg.Texture = tex;
}
data = GameData.Instance.Runtime.BattleData;
data = BattleRequest.Current;
GameLogger.Log("BATTLE_UI", $"Winground.Show: BattleData={(data != null ? "found" : "NULL")}", LogLevel.Error);
var expTracking = BuildExpTracking();
@@ -125,7 +128,7 @@ namespace Cthangover.TurnBattle
BuildLootDisplay(ctx.GeneratedLoot);
AddLootToInventory(ctx.GeneratedLoot);
ProcessRecruitment(ctx.DefeatedEnemies);
PublishDefeatedCharacters(ctx.DefeatedEnemies);
}
WingroundListenerRegistry.Instance.NotifyBeforeShow(ctx);
@@ -338,24 +341,16 @@ namespace Cthangover.TurnBattle
}
}
private void ProcessRecruitment(List<Character> enemies)
private static void PublishDefeatedCharacters(List<Character> enemies)
{
var runtime = GameData.Instance.Runtime;
var registry = RecruitBehaviourRegistry.Instance;
foreach (var enemy in enemies)
ModEventBus.Publish(new CharactersDefeatedEvent
{
if (enemy == null || enemy.RecruitmentChance <= 0)
continue;
if (!registry.CanRecruit(enemy, runtime))
continue;
var roll = random.Next(1, 101);
if (roll > enemy.RecruitmentChance)
continue;
runtime.RecruitingData.Add(enemy.ID, enemy.ID);
}
Source = "battle",
CharacterIds = enemies
.Where(enemy => enemy != null && !string.IsNullOrEmpty(enemy.ID))
.Select(enemy => enemy.ID)
.ToArray(),
});
}
private void ToReturnScene()
@@ -368,7 +363,7 @@ namespace Cthangover.TurnBattle
if (data?.Quest != null && data?.NewTag != null)
data.Quest.AddTag(data.NewTag);
var returnScene = data?.ReturnScene ?? "town_entry";
GameData.Instance.Runtime.BattleData = null;
BattleRequest.Current = null;
var sceneService = GetNode<GodotSceneService>("/root/GodotSceneService");
var sceneManager = GetNode<SceneManager>("/root/SceneManager");
+52
View File
@@ -0,0 +1,52 @@
using System.Collections.Generic;
using Cthangover.Core.Characters;
using Cthangover.Core.Items;
using Cthangover.Core.Settings;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Mutable context passed to <see cref="IWingroundListener"/> hooks
/// during the victory-screen pipeline. Listeners receive the same
/// instance across all three phases (before EXP, after loot, before
/// show) and can mutate any field — including removing enemies,
/// adjusting earned EXP per character, or modifying the generated
/// loot list. The <see cref="WingroundBehaviour"/> reads these
/// fields after each hook, so changes take effect immediately.
/// </summary>
public class WingroundContext
{
/// <summary>
/// Per-character EXP earned during this battle, keyed by
/// <see cref="Character.ID"/>. Populated from
/// <see cref="BattleSceneContext.PlayerExpGained"/> before the
/// first hook and consumed by <see cref="WingroundBehaviour"/>
/// to award EXP to <see cref="CharacterData"/>.
/// </summary>
public Dictionary<string, int> PlayerExpGained { get; set; } = new();
/// <summary>
/// Enemies defeated during this battle. Used by the victory
/// screen for loot generation and recruitment rolls. Null
/// entries are skipped by <see cref="WingroundBehaviour"/>.
/// </summary>
public List<Character> DefeatedEnemies { get; set; } = new();
/// <summary>
/// Loot items generated from <see cref="DefeatedEnemies"/>.
/// Filled by <see cref="WingroundBehaviour"/> between the
/// "before EXP" and "after loot" hooks. Listeners can
/// add, remove, or modify entries before the items are
/// committed to inventory.
/// </summary>
public List<ItemContainer> GeneratedLoot { get; set; } = new();
/// <summary>
/// The <see cref="BattleData"/> that started this battle.
/// Read-only reference — do not mutate the object itself (it
/// is shared with the quest system) but its fields such as
/// <c>ReturnScene</c> are writable.
/// </summary>
public BattleData BattleData { get; set; }
}
}
+150
View File
@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Singleton registry that collects <see cref="IWingroundListener"/>
/// implementations (C# via reflection, GDScript via
/// <see cref="RegisterListener"/>) and broadcasts winground
/// lifecycle hooks to all of them in registration order.
///
/// Reflects the same pattern as the recruiting mod's
/// <c>RecruitBehaviourRegistry</c>: a
/// static Instance, reflection-based <c>RegisterAssembly</c>,
/// and manual <c>RegisterListener</c> for GDScript bridges.
/// </summary>
public class WingroundListenerRegistry
{
public static readonly WingroundListenerRegistry Instance = new();
private readonly List<IWingroundListener> _listeners = new();
/// <summary>
/// Scans <paramref name="assembly"/> for concrete types that
/// implement <see cref="IWingroundListener"/> and instantiates
/// them via their parameterless constructor. Called by the battle
/// mod's initializer as mods announce themselves.
/// </summary>
public void RegisterAssembly(Assembly assembly)
{
foreach (var instance in Reflections.FindAndCreate<IWingroundListener>(assembly))
{
try
{
RegisterListener(instance);
GameLogger.Log("BATTLE",
$"WingroundListenerRegistry: registered C# listener '{instance.Id}' from assembly '{assembly.GetName().Name}'");
}
catch (Exception ex)
{
GameLogger.Log("BATTLE",
$"WingroundListenerRegistry: failed to create listener '{instance.GetType().FullName}': {ex.Message}",
LogLevel.Error);
}
}
}
/// <summary>
/// Scans every assembly loaded in the current <see cref="System.AppDomain"/>
/// and registers all <see cref="IWingroundListener"/> implementations.
/// Called by the battle mod's initializer so the core does not need to
/// reference battle types during its assembly fan-out.
/// </summary>
public void RegisterAll()
{
foreach (var instance in Reflections.FindAndCreate<IWingroundListener>())
RegisterListener(instance);
GameLogger.Log("BATTLE",
$"WingroundListenerRegistry.RegisterAll: {_listeners.Count} listener(s) registered");
}
/// <summary>
/// Registers a listener, replacing any earlier one with the same
/// <see cref="IWingroundListener.Id"/>. Deduplication matters because the
/// registry is rescanned from the whole AppDomain on mod reload (used by
/// the GDScript bridge <see cref="GDWingroundListener"/> and by
/// <see cref="RegisterAll"/>). Safe to call from any thread — the
/// underlying list is not thread-safe but all registrations happen during
/// mod initialisation before battle scenes are loaded.
/// </summary>
public void RegisterListener(IWingroundListener listener)
{
if (listener == null)
return;
_listeners.RemoveAll(existing => existing.Id == listener.Id);
_listeners.Add(listener);
GameLogger.Log("BATTLE",
$"WingroundListenerRegistry: registered listener '{listener.Id}'");
}
/// <summary>
/// Broadcasts <see cref="IWingroundListener.OnBeforeExpAward"/>
/// to every registered listener. Exceptions from individual
/// listeners are logged and swallowed so one broken mod cannot
/// break the victory screen for others.
/// </summary>
public void NotifyBeforeExpAward(WingroundContext ctx)
{
foreach (var listener in _listeners)
{
try
{
listener.OnBeforeExpAward(ctx);
}
catch (Exception ex)
{
GameLogger.Log("BATTLE",
$"WingroundListenerRegistry: {listener.Id}.OnBeforeExpAward threw: {ex.Message}",
LogLevel.Error);
}
}
}
/// <summary>
/// Broadcasts <see cref="IWingroundListener.OnAfterLootGenerated"/>
/// to every registered listener.
/// </summary>
public void NotifyAfterLootGenerated(WingroundContext ctx)
{
foreach (var listener in _listeners)
{
try
{
listener.OnAfterLootGenerated(ctx);
}
catch (Exception ex)
{
GameLogger.Log("BATTLE",
$"WingroundListenerRegistry: {listener.Id}.OnAfterLootGenerated threw: {ex.Message}",
LogLevel.Error);
}
}
}
/// <summary>
/// Broadcasts <see cref="IWingroundListener.OnBeforeShow"/>
/// to every registered listener.
/// </summary>
public void NotifyBeforeShow(WingroundContext ctx)
{
foreach (var listener in _listeners)
{
try
{
listener.OnBeforeShow(ctx);
}
catch (Exception ex)
{
GameLogger.Log("BATTLE",
$"WingroundListenerRegistry: {listener.Id}.OnBeforeShow threw: {ex.Message}",
LogLevel.Error);
}
}
}
}
}