diff --git a/characters/actions.json b/characters/actions.json new file mode 100644 index 0000000..fc7f09a --- /dev/null +++ b/characters/actions.json @@ -0,0 +1,5 @@ +{ "Items": [ + ${actions/physics_attack}, + ${actions/physics_defence}, + ${actions/physics_stun} +]} \ No newline at end of file diff --git a/characters/actions/physics_attack.json b/characters/actions/physics_attack.json new file mode 100644 index 0000000..6f061fe --- /dev/null +++ b/characters/actions/physics_attack.json @@ -0,0 +1,12 @@ +{ + "Id": "physics/attack", + "Name": "characters/action/physics_attack/name", + "Description": "characters/action/physics_attack/description", + "Image": "characters/actions/physics_attack", + "Type": "ToEnemy", + "Values": [ + "RequiredPoint=1", + "Attack=1.0", + "AttackType=Physics" + ] +} \ No newline at end of file diff --git a/characters/actions/physics_attack.png b/characters/actions/physics_attack.png new file mode 100644 index 0000000..3c82744 Binary files /dev/null and b/characters/actions/physics_attack.png differ diff --git a/characters/actions/physics_defence.json b/characters/actions/physics_defence.json new file mode 100644 index 0000000..7465c31 --- /dev/null +++ b/characters/actions/physics_defence.json @@ -0,0 +1,11 @@ +{ + "Id": "physics/defence", + "Name": "characters/action/defence/name", + "Description": "characters/action/defence/description", + "Image": "characters/actions/physics_defence", + "Type": "ToAlias", + "Values": [ + "RequiredPoint=1", + "Defence=4" + ] +} \ No newline at end of file diff --git a/characters/actions/physics_defence.png b/characters/actions/physics_defence.png new file mode 100644 index 0000000..2ea0797 Binary files /dev/null and b/characters/actions/physics_defence.png differ diff --git a/characters/actions/physics_stun.json b/characters/actions/physics_stun.json new file mode 100644 index 0000000..ecff35d --- /dev/null +++ b/characters/actions/physics_stun.json @@ -0,0 +1,12 @@ +{ + "Id": "physics/stun", + "Name": "characters/action/stun/name", + "Description": "characters/action/stun/description", + "Image": "characters/actions/physics_stun", + "Type": "ToEnemy", + "Values": [ + "RequiredPoint=2", + "AttackType=Physics", + "Turn=3" + ] +} \ No newline at end of file diff --git a/characters/actions/physics_stun.png b/characters/actions/physics_stun.png new file mode 100644 index 0000000..f97dbc6 Binary files /dev/null and b/characters/actions/physics_stun.png differ diff --git a/characters/effects/stun.png b/characters/effects/stun.png new file mode 100644 index 0000000..3cf4d2a Binary files /dev/null and b/characters/effects/stun.png differ diff --git a/manifest.json b/manifest.json index 44f507a..24b1d49 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ -{ +{ "id": "turn_battle_base", "name": "Turn-based battle engine", - "version": "1.0.0", + "version": "2.0.0", "author": "ct", - "description": "Turn-based battle GUI: battle scene, victory/defeat screens, EXP and loot reports", + "description": "Turn-based battle platform: battle ruleset contracts, core registry, action executors, status effects, battle scene, victory/defeat screens, EXP and loot reports", "category": "engine, gameplay", "sources": [ "src/**/*.cs" diff --git a/scenes/battle/battle.tscn b/scenes/battle/battle.tscn index 3f23a62..eb76acd 100644 --- a/scenes/battle/battle.tscn +++ b/scenes/battle/battle.tscn @@ -1,7 +1,7 @@ [gd_scene format=3 uid="uid://bsrphp6130ptb"] -[ext_resource type="Script" uid="uid://dq85ubii8cfid" path="res://Core/Battle/Actions/BattleActionMachine.cs" id="BattleActionMachineScript"] -[ext_resource type="Script" uid="uid://c1rwrejr5aoc0" path="res://Core/Battle/BattleSceneContext.cs" id="BattleSceneContextScript"] +[ext_resource type="Script" path="src/Actions/BattleActionMachine.cs" id="BattleActionMachineScript"] +[ext_resource type="Script" path="src/BattleSceneContext.cs" id="BattleSceneContextScript"] [ext_resource type="Script" path="src/BattleSpeedControl.cs" id="BattleSpeedControlScript"] [ext_resource type="PackedScene" uid="uid://bjhtml8ujba7p" path="res://scenes/battle/deadground.tscn" id="DeadgroundScene"] [ext_resource type="Script" uid="uid://icsu2hv2c68r" path="res://Core/UI/Lights/UiLightController.cs" id="UiLightControllerScript"] @@ -47,7 +47,6 @@ grow_vertical = 0 script = ExtResource("BattleActionMachineScript") [node name="BattleSpeedControl" type="Control" parent="."] -layout_mode = 0 script = ExtResource("BattleSpeedControlScript") [node name="Deadground" parent="." unique_id=2064995106 instance=ExtResource("DeadgroundScene")] diff --git a/src/Actions/ActionExecutorHub.cs b/src/Actions/ActionExecutorHub.cs new file mode 100644 index 0000000..15fa226 --- /dev/null +++ b/src/Actions/ActionExecutorHub.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using Cthangover.Core.Characters; + +namespace Cthangover.Core.Battle.Actions +{ + /// + /// 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). + /// + public class ActionExecutorHub + { + /// + /// Singleton hub instance. There is one global dispatcher shared + /// across all battle cores; cores swap providers via + /// when they activate. + /// + public static readonly ActionExecutorHub Instance = new(); + + private IActionExecutorProvider _activeProvider; + private readonly Dictionary _globalExecutors = new(); + + /// + /// 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. + /// + public void SetActiveProvider(IActionExecutorProvider provider) + { + _activeProvider = provider; + } + + /// + /// Registers a fallback executor in the global pool, keyed by + /// . Global executors are + /// used when no active provider supplies a match. + /// + public void RegisterGlobal(IActionExecutor executor) + { + if (executor != null && !string.IsNullOrEmpty(executor.ActionId)) + _globalExecutors[executor.ActionId] = executor; + } + + /// + /// Dispatches an action to the appropriate executor. Resolution + /// order: active provider first, then global registry. Returns + /// Result = false if no executor is found or if + /// is null. + /// + 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; + } + } +} diff --git a/src/Actions/BattleActionMachine.cs b/src/Actions/BattleActionMachine.cs new file mode 100644 index 0000000..1e1fb9c --- /dev/null +++ b/src/Actions/BattleActionMachine.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using Cthangover.Core.UI; +using Godot; + +namespace Cthangover.Core.Battle.Actions +{ + + /// + /// 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. + /// + /// Derives from 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). + /// + public partial class BattleActionMachine : ModNode + { + + /// + /// 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. + /// + public List Actions { get; set; } = new(); + private IBattleAction CurrentAction { get; set; } + private bool _hadActions; + + /// + /// Fired when the machine drains naturally or is explicitly stopped. + /// Battle cores subscribe to advance the turn phase without polling. + /// + 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; + } + } + + /// + /// Enqueues a single action. If the machine is idle (no current + /// action), processing starts on the next _Process tick. + /// + public void AddAction(IBattleAction action) + { + if (action == null) + return; + Actions.Add(action); + } + + /// + /// Batch-enqueues multiple actions. All are appended to the existing + /// queue and processed in FIFO order on subsequent frames. + /// + public void AddActions(List actions) + { + if(actions == null || actions.Count == 0) + return; + Actions.AddRange(actions); + } + + /// + /// Immediately clears the queue, aborts the current action, and + /// fires . Called on tree exit and + /// when the queue drains naturally. + /// + public void StopMachine() + { + CurrentAction = null; + Actions.Clear(); + _hadActions = false; + OnStopMachine?.Invoke(); + } + + } + +} diff --git a/src/Actions/GDBattleActionExecutor.cs b/src/Actions/GDBattleActionExecutor.cs new file mode 100644 index 0000000..f2af034 --- /dev/null +++ b/src/Actions/GDBattleActionExecutor.cs @@ -0,0 +1,48 @@ +using Cthangover.Core.Characters; +using Godot; + +namespace Cthangover.Core.Battle.Actions +{ + /// + /// Adapter that lets a GDScript mod act as a battle action executor. The + /// script is detected by when it exposes + /// get_action_id() and execute(action, user, target); this adapter + /// flattens the C# arguments into Godot dictionaries via + /// and maps the returned dictionary back to + /// . 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. + /// + public class GDBattleActionExecutor : IActionExecutor + { + private readonly GodotObject _instance; + + /// Creates an adapter around a live GDScript object. The object is retained by reference. + public GDBattleActionExecutor(string actionId, GodotObject instance) + { + ActionId = actionId; + _instance = instance; + } + + /// Action id reported by the script through get_action_id(). + public string ActionId { get; } + + /// + /// 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. + /// + 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()); + } + } +} diff --git a/src/Actions/IActionExecutor.cs b/src/Actions/IActionExecutor.cs new file mode 100644 index 0000000..6c4ef98 --- /dev/null +++ b/src/Actions/IActionExecutor.cs @@ -0,0 +1,27 @@ +using Cthangover.Core.Characters; + +namespace Cthangover.Core.Battle.Actions +{ + /// + /// 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. + /// + public interface IActionExecutor + { + /// + /// String key that matches . + /// The hub uses this to resolve executors without knowing + /// concrete types. + /// + string ActionId { get; } + + /// + /// Applies the action's effects. Receives the action definition + /// and the user/target instances; returns + /// with deltas and success/failure. + /// + ChangedAttributes Execute(ActionCharacter action, Character user, Character target); + } +} diff --git a/src/Actions/IActionExecutorProvider.cs b/src/Actions/IActionExecutorProvider.cs new file mode 100644 index 0000000..c2d238b --- /dev/null +++ b/src/Actions/IActionExecutorProvider.cs @@ -0,0 +1,18 @@ +namespace Cthangover.Core.Battle.Actions +{ + /// + /// Typed executor source. Each IBattleCore returns its own provider, + /// giving cores the ability to supply custom executor sets without + /// polluting the global registry. + /// + public interface IActionExecutorProvider + { + /// + /// Returns the executor registered for , + /// or null if no override is configured. Called by + /// before falling back to the + /// global registry. + /// + IActionExecutor GetExecutor(string actionId); + } +} diff --git a/src/Actions/IBattleAction.cs b/src/Actions/IBattleAction.cs new file mode 100644 index 0000000..090c9b7 --- /dev/null +++ b/src/Actions/IBattleAction.cs @@ -0,0 +1,32 @@ +namespace Cthangover.Core.Battle.Actions +{ + /// + /// 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. + /// + public interface IBattleAction + { + /// + /// Called once when the action is dequeued and begins execution. + /// Use for setup: spawning nodes, caching references, starting + /// tweens or coroutines. + /// + void DoStart(); + /// + /// Polled every frame. Returns true when the action has + /// finished its work; returns false to remain active. + /// This model eliminates the need for the machine to manage timers. + /// + bool DoAction(); + /// + /// Cleanup callback fired after returns + /// true. Use for teardown: freeing nodes, resetting state, + /// applying final stat deltas. + /// + void DoEnd(); + } +} diff --git a/src/Actions/StopMachineDelegate.cs b/src/Actions/StopMachineDelegate.cs new file mode 100644 index 0000000..fcba1b0 --- /dev/null +++ b/src/Actions/StopMachineDelegate.cs @@ -0,0 +1,9 @@ +namespace Cthangover.Core.Battle.Actions +{ + /// + /// 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. + /// + public delegate void StopMachineDelegate(); +} diff --git a/src/Adapters/AnimationRegistry.cs b/src/Adapters/AnimationRegistry.cs new file mode 100644 index 0000000..759aa44 --- /dev/null +++ b/src/Adapters/AnimationRegistry.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; + +namespace Cthangover.Core.Battle +{ + /// + /// 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. + /// + public class AnimationRegistry + { + /// + /// Singleton registry. One set of animation players serves the + /// entire runtime; individual players dispatch per-core via + /// internal logic. + /// + public static readonly AnimationRegistry Instance = new(); + + private readonly Dictionary _players = new(); + + /// + /// Registers a player under . + /// Replaces any existing player for the same key — last write wins. + /// + public void Register(string animType, IBattleAnimationPlayer player) + { + _players[animType] = player; + } + + /// + /// Retrieves the player registered for , + /// or null if no match exists. + /// + public IBattleAnimationPlayer Get(string animType) + { + _players.TryGetValue(animType, out var player); + return player; + } + } +} diff --git a/src/Adapters/BattleContextImpl.cs b/src/Adapters/BattleContextImpl.cs new file mode 100644 index 0000000..19d4369 --- /dev/null +++ b/src/Adapters/BattleContextImpl.cs @@ -0,0 +1,79 @@ +using Cthangover.Core.Characters; +using Godot; + +namespace Cthangover.Core.Battle +{ + /// + /// 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. + /// + public class BattleContextImpl : IBattleContext + { + private readonly BattleSceneContext _sceneContext; + + /// + /// Root Godot of the battle scene, used by + /// animation actions to parent spawned visual effects. + /// + public Node RootNode => _sceneContext; + + /// + /// The owning that manages + /// the battle lifecycle. + /// + public BattleSceneContext SceneContext => _sceneContext; + + /// + /// Creates a context adapter around the given scene context. + /// + public BattleContextImpl(BattleSceneContext sceneContext) + { + _sceneContext = sceneContext; + } + + /// + /// Subtracts from + /// , clamping at 0 to prevent negative + /// health from defence overflow, and applies the result to + /// 's health. + /// + public void ApplyDamage(Character source, Character target, int rawDamage, int defence) + { + var finalDamage = Mathf.Max(0, rawDamage - defence); + target.Attributes.Health.Value -= finalDamage; + } + + /// + /// Stub — specific battle cores override this to enqueue + /// animation actions into the . + /// + public void ScheduleAnimation(IBattleAnimation animation) + { + } + + /// + /// Stub — cores implement this to advance the turn sequence + /// when a side finishes its action phase. + /// + public void EndTurn(BattleSide side) + { + } + + /// + /// Resolves the battle outcome by delegating to + /// for player + /// victory or + /// for enemy victory. + /// + public void EndBattle(BattleSide winner) + { + if (winner == BattleSide.Player) + _sceneContext.ShowWinground(); + else + _sceneContext.ShowDeadground(); + } + } +} diff --git a/src/Adapters/IBattleAnimation.cs b/src/Adapters/IBattleAnimation.cs new file mode 100644 index 0000000..aa38d21 --- /dev/null +++ b/src/Adapters/IBattleAnimation.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using Cthangover.Core.Characters; + +namespace Cthangover.Core.Battle +{ + /// + /// 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. + /// + public interface IBattleAnimation + { + /// The performing the action. + Character Source { get; } + /// The receiving the action. + Character Target { get; } + /// + /// Key used by to dispatch to the + /// correct . + /// + string AnimationType { get; } + /// Multiplier for the playback speed (1.0 = normal). + float Speed { get; } + /// + /// Open-ended data bag for animation-specific values + /// (e.g. damage amount, elemental flag) — no interface changes + /// needed when adding new animation types. + /// + Dictionary Parameters { get; } + } +} diff --git a/src/Adapters/IBattleAnimationPlayer.cs b/src/Adapters/IBattleAnimationPlayer.cs new file mode 100644 index 0000000..d903399 --- /dev/null +++ b/src/Adapters/IBattleAnimationPlayer.cs @@ -0,0 +1,26 @@ +using System; + +namespace Cthangover.Core.Battle +{ + /// + /// 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. + /// + public interface IBattleAnimationPlayer + { + /// + /// String key used by to select + /// this player for a given . + /// + string AnimationType { get; } + + /// + /// Starts playing the animation described by . + /// Must invoke once the animation + /// finishes, so the action machine can advance to the next step. + /// + void Play(IBattleAnimation anim, Action onComplete); + } +} diff --git a/src/Adapters/IBattleContext.cs b/src/Adapters/IBattleContext.cs new file mode 100644 index 0000000..2d0191c --- /dev/null +++ b/src/Adapters/IBattleContext.cs @@ -0,0 +1,27 @@ +using Cthangover.Core.Characters; +using Godot; + +namespace Cthangover.Core.Battle +{ + /// + /// 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). + /// + 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; } + } +} diff --git a/src/Adapters/IBattleCore.cs b/src/Adapters/IBattleCore.cs new file mode 100644 index 0000000..53a62bc --- /dev/null +++ b/src/Adapters/IBattleCore.cs @@ -0,0 +1,46 @@ +using Cthangover.Core.Battle.Actions; +using Cthangover.Core.Characters; + +namespace Cthangover.Core.Battle +{ + /// + /// 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. + /// + public interface IBattleCore + { + /// + /// Unique identifier for this battle engine (e.g. "card_battle", + /// "ff_battle"). Used by and + /// for lookup. + /// + string Id { get; } + + /// + /// Provider that returns core-specific + /// overrides. Set on the when + /// this core activates, so actions route through custom logic + /// before falling back to the global registry. + /// + IActionExecutorProvider ActionProvider { get; } + + /// + /// Initialises the core with the two character arrays and a + /// for callbacks. Called once per + /// battle before . + /// + void Init(Character[] playerChars, Character[] enemyChars, IBattleContext ctx); + + /// + /// Begins the turn loop. Cores may use coroutines, polling, or + /// event-driven state machines — the core owns its own lifecycle + /// after this call. + /// + void Start(); + } +} diff --git a/src/Adapters/IBattleSide.cs b/src/Adapters/IBattleSide.cs new file mode 100644 index 0000000..e92f70e --- /dev/null +++ b/src/Adapters/IBattleSide.cs @@ -0,0 +1,15 @@ +namespace Cthangover.Core.Battle +{ + /// + /// 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). + /// + public enum BattleSide + { + /// The player's party. + Player, + /// The opposing force (monsters, NPCs). + Enemy, + } +} diff --git a/src/Base/ActionBase.cs b/src/Base/ActionBase.cs new file mode 100644 index 0000000..b6f5557 --- /dev/null +++ b/src/Base/ActionBase.cs @@ -0,0 +1,62 @@ + +using Cthangover.Core.Battle.Actions; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Characters +{ + + /// + /// 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. + /// + public abstract class ActionBase : IActionExecutor + { + + /// + /// Maps to the abstract so the hub can resolve + /// this executor by action name. + /// + public string ActionId => ID; + + /// + /// Unique action identifier that matches + /// in data. + /// + public abstract string ID { get; } + /// + /// Applies the action's effects to + /// using 's stats and the + /// definition. Returns stat deltas + /// and a success/failure flag. + /// + public abstract ChangedAttributes Execute(ActionCharacter action, Character user, Character target); + + /// + /// Validates that has at least + /// "RequiredPoint" remaining, deducts it, and + /// returns true. Returns false with a warning log + /// if the user lacks sufficient points, allowing the action to + /// fail gracefully. + /// + 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; + } + + } + +} diff --git a/src/BattleCoreRegistry.cs b/src/BattleCoreRegistry.cs new file mode 100644 index 0000000..1195449 --- /dev/null +++ b/src/BattleCoreRegistry.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Battle +{ + /// + /// 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. + /// + public class BattleCoreRegistry + { + /// Singleton registry instance. + public static readonly BattleCoreRegistry Instance = new(); + + private readonly Dictionary _cores = new(); + private string _activeCoreId; + + /// + /// Scans for non-abstract + /// implementations, instantiates each + /// to read its , and registers the + /// type by ID. Called at startup so mods can ship drop-in cores. + /// + public void RegisterAssembly(Assembly assembly) + { + foreach (var instance in Reflections.FindAndCreate(assembly)) + { + _cores[instance.Id] = instance.GetType(); + } + } + + /// + /// Scans every assembly loaded in the current + /// for 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. + /// + public void RegisterAll() + { + foreach (var instance in Reflections.FindAndCreate()) + { + _cores[instance.Id] = instance.GetType(); + } + + GameLogger.Log("BATTLE", + $"BattleCoreRegistry.RegisterAll: {_cores.Count} core(s) registered: [{string.Join(", ", CoreIds)}]"); + } + + /// + /// 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 . + /// + public void SetActive(string id) + { + if (!_cores.ContainsKey(id)) + throw new Exception($"Battle core '{id}' not found"); + _activeCoreId = id; + } + + /// + /// 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. + /// + 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."); + } + + /// + /// 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. + /// + public string GetFirstCoreId() + { + return _cores.Keys.FirstOrDefault(); + } + + /// + /// Checks whether a core with the given + /// has been registered. + /// + public bool HasCore(string id) + { + return _cores.ContainsKey(id); + } + + /// + /// 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. + /// + public IReadOnlyList CoreIds + { + get + { + var ids = new List(_cores.Keys); + ids.Sort(System.StringComparer.Ordinal); + return ids; + } + } + + /// + /// Returns the registered for a core, or + /// null. Lets tooling inspect a core's implemented interfaces + /// (e.g. ) without instantiating it. + /// + public Type GetCoreType(string id) + { + return _cores.TryGetValue(id, out var type) ? type : null; + } + } +} diff --git a/src/BattleData.cs b/src/BattleData.cs new file mode 100644 index 0000000..acf1c98 --- /dev/null +++ b/src/BattleData.cs @@ -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 +{ + /// + /// Immutable descriptor for a battle encounter, constructed via the + /// static factory 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. + /// + public class BattleData + { + /// The quest that triggered this battle; may be null + /// for non-quest skirmishes. + public QuestBase Quest { get; set; } + /// Auxiliary tag set on new characters acquired via recruitment + /// after this battle. + public string NewTag { get; set; } + + /// List of enemy instances spawned from + /// the provided IDs or default templates. + public List EnemiesCards { get; private set; } + /// Battle background texture (full-resolution). + public Texture2D Background { get; private set; } + /// Depth map texture for the parallax post-process effect. + public Texture2D DepthMap { get; set; } + /// Albedo/colour map texture for the parallax post-process effect. + public Texture2D AlbedoMap { get; set; } + /// Which side of the screen the player's cards are drawn on. + public BattleSide BattleSide { get; private set; } = BattleSide.Player; + /// Identifier for the active battle core logic module. + public string ActiveBattleCore { get; set; } + /// Scene name to transition to when the battle finishes or is + /// fled. Stored as a string to avoid type coupling. + public string ReturnScene { get; private set; } + + private BattleData() + { } + + /// + /// Convenience overload that defaults to + /// BattleSide.Player. Constructs enemies from their string IDs + /// via , + /// falling back to a level-1 template when the ID is unknown. + /// + public static BattleData InitBattle(Texture2D background, string returnScene, params string[] enemies) + { + return InitBattle(background, returnScene, BattleSide.Player, enemies); + } + + /// + /// Creates a battle with a specified and + /// enemy IDs. Each ID is resolved through + /// . + /// Unknown IDs produce a fallback with 20 HP + /// and 3 action points. + /// + 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); + } + + /// + /// Core factory that directly accepts a pre-built + /// for the enemy roster. This is the + /// overload that all other InitBattle variants delegate to. + /// + public static BattleData InitBattle(Texture2D background, string returnScene, List enemiesCards, BattleSide cardOrder = BattleSide.Player) + { + return new BattleData + { + Background = background, + ReturnScene = returnScene, + EnemiesCards = enemiesCards, + BattleSide = cardOrder, + }; + } + } +} diff --git a/src/BattleEndedEvent.cs b/src/BattleEndedEvent.cs new file mode 100644 index 0000000..50c7159 --- /dev/null +++ b/src/BattleEndedEvent.cs @@ -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; } + } +} diff --git a/src/BattleGDScriptBridge.cs b/src/BattleGDScriptBridge.cs new file mode 100644 index 0000000..1b315e8 --- /dev/null +++ b/src/BattleGDScriptBridge.cs @@ -0,0 +1,190 @@ +using Cthangover.Core.Characters; +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Items; +using Godot; + +namespace Cthangover.Core.Battle +{ + /// + /// Converts battle data to and from plain Godot collections so GDScript mods + /// can work with it. The previous bridge exposed [GlobalClass] 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. + /// + /// Keys are snake_case to match GDScript conventions. The same helpers are + /// used by (action/character snapshots in, + /// result deltas out) and (victory context + /// in, exp/loot mutations out). + /// + public static class BattleGDScriptBridge + { + /// Flattens a character into a dictionary of primitive stats and identity fields. + 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(); + if (attrs.Properties?.Values != null) + { + foreach (var kvp in attrs.Properties.Values) + properties[kvp.Key] = kvp.Value; + } + data["properties"] = properties; + } + + return data; + } + + /// Flattens an action card into a dictionary of identity fields and raw properties. + 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(); + if (action.Properties?.Values != null) + { + foreach (var kvp in action.Properties.Values) + properties[kvp.Key] = kvp.Value; + } + data["properties"] = properties; + + return data; + } + + /// + /// Builds from the dictionary a GDScript + /// executor returns. Missing keys default to zero/false; result + /// defaults to true so a script that only reports deltas still + /// counts as a successful action. + /// + 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; + } + + /// Builds the mutable victory context dictionary passed to GDScript winground hooks. + 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(); + 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(); + if (ctx.DefeatedEnemies != null) + { + foreach (var enemy in ctx.DefeatedEnemies) + defeated.Add(CharacterToDictionary(enemy)); + } + data["defeated_enemies"] = defeated; + + var loot = new Godot.Collections.Array(); + 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; + } + + /// + /// Writes GDScript mutations from a winground context dictionary back to + /// the C# pipeline. Only exp_gained and generated_loot are + /// synced — defeated enemies are read-only snapshots, matching the + /// documented contract of . + /// + 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, + }); + } + } + } + } +} diff --git a/src/BattleInitAction.cs b/src/BattleInitAction.cs new file mode 100644 index 0000000..e49b358 --- /dev/null +++ b/src/BattleInitAction.cs @@ -0,0 +1,43 @@ +using Cthangover.Core.Actions; + +namespace Cthangover.Core.Battle +{ + /// + /// Dispatched by the scenario DSL to initiate battle. Reads "scene", + /// "enemies", "quest_id" and "new_tag" from dialog variables and calls + /// . 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. + /// + /// Registered by the battle mod's assembly scan, so the core scenario action + /// registry stays free of battle commands. + /// + public class BattleInitAction : IScenarioAction + { + /// Registered as "battle.init" — the scenario DSL's primary battle initiation command. + public string Name => "battle.init"; + + /// + /// 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. + /// + 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}'"); + } + } +} diff --git a/src/BattleRequest.cs b/src/BattleRequest.cs new file mode 100644 index 0000000..29e7db5 --- /dev/null +++ b/src/BattleRequest.cs @@ -0,0 +1,18 @@ +using Cthangover.Core.Settings; + +namespace Cthangover.Core.Battle +{ + /// + /// Static hand-off slot between the scenario side (which builds an encounter + /// through ) and the battle scene (which consumes it + /// in ). Lives in the battle mod instead of the + /// core runtime state so the core does not need to know about battle data; + /// is transient — it is never serialized and is cleared + /// by the victory/defeat screens when the player leaves the encounter. + /// + public static class BattleRequest + { + /// Pending encounter, or null when no battle was requested. + public static BattleData Current { get; set; } + } +} diff --git a/src/BattleSceneContext.cs b/src/BattleSceneContext.cs new file mode 100644 index 0000000..d11a1fb --- /dev/null +++ b/src/BattleSceneContext.cs @@ -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 +{ + /// + /// 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 , resolves the active + /// via , 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. + /// + /// The context derives from because mod assemblies get + /// no Godot source generation: the core base receives the engine lifecycle + /// callbacks and forwards them to the OnXxx overrides. It also + /// implements so core UI (the inventory + /// panel's action-point row) can read the acting character without knowing + /// anything about battle. + /// + public partial class BattleSceneContext : ModNode, IActiveCharacterProvider + { + /// + /// Singleton reference to the active battle scene context. + /// Set on enter-tree, cleared on exit-tree. + /// External systems read this to access battle state. + /// + public static BattleSceneContext Instance { get; private set; } + + private TextureRect battleBg; + + /// + /// The node that + /// processes the animation queue for this battle. Cores push + /// animation actions through the context and the machine drives + /// playback frame-by-frame. + /// + public Actions.BattleActionMachine BattleActionMachine { get; private set; } + + private IBattleSpeedControl _battleSpeedControl; + private Widget deadground; + private Widget winground; + + /// + /// Per-character EXP earned during this battle, keyed by + /// . Populated by + /// ; consumed by the victory screen + /// provided by the turn-battle GUI mod. + /// + public Dictionary PlayerExpGained { get; private set; } = new(); + /// + /// Enemies defeated during this battle (for loot generation + /// and recruitment rolls). + /// + public List DefeatedEnemies { get; private set; } = new(); + + /// + /// Guards against double-triggering end-of-battle paths. + /// Set to true by or + /// . + /// + public bool IsDestroyed { get; private set; } + + /// + /// When true, the battle core pauses processing (e.g. + /// while a UI overlay is open). Set externally by pause widgets. + /// + public bool IsWait { get; set; } + + /// + /// The 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. + /// null 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. + /// + public Character ActiveCharacter { get; set; } + + /// Fired when a character's health reaches 0. + public event System.Action OnCharacterDied; + /// + /// Fired after battle state is cleared (both victory and defeat). + /// External systems can hook this to release battle resources. + /// + public event System.Action OnBattleCleared; + + /// + /// Invokes to notify listeners + /// (quests, achievements, the battle core) that a character + /// has been defeated. + /// + 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 pendingPlayerCards; + private List pendingEnemyCards; + private IBattleCore activeBattleCore; + + /// + /// The battle core driving this scene, resolved from + /// when the scene becomes ready. Exposed + /// so tooling and tests can query the active core (for example for + /// ) without reaching into UI nodes. + /// + public IBattleCore ActiveBattleCore => activeBattleCore; + + protected override void OnReady() + { + battleBg = GetNodeOrNull("BattleBg"); + + if (battleBg == null) + { + GameLogger.Log("BATTLE", "battleBg not found!", LogLevel.Error); + return; + } + + BattleActionMachine = GetNodeOrNull("ToolPanel/BattleActionMachine"); + _battleSpeedControl = GetNodeOrNull("BattleSpeedControl") as IBattleSpeedControl; + deadground = GetNodeOrNull("Deadground"); + + winground = GetNodeOrNull("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("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(); + 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(); + + 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(), + }); + } + + /// + /// Triggers the defeat screen: sets , + /// resets battle speed, clears battle state, and shows the + /// deadground widget. + /// + public void ShowDeadground() + { + if (IsDestroyed) + return; + IsDestroyed = true; + _battleSpeedControl?.ResetToNormal(); + ClearBattle(); + deadground?.Show(); + } + + /// + /// Triggers the victory screen: sets , + /// resets battle speed, clears battle state, and shows the + /// winground widget. + /// + public void ShowWinground() + { + if (IsDestroyed) + return; + IsDestroyed = true; + _battleSpeedControl?.ResetToNormal(); + ClearBattle(); + winground?.Show(); + } + + /// + /// Records a defeated enemy: adds it to + /// and distributes its EXP + /// (Level * Exp) equally among all surviving player + /// characters in . + /// + 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", + }); + } + } +} diff --git a/src/BattleService.cs b/src/BattleService.cs new file mode 100644 index 0000000..bdc2c41 --- /dev/null +++ b/src/BattleService.cs @@ -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 +{ + /// + /// Entry point for starting a battle encounter. Constructs + /// from scenario parameters and stores it in for the + /// battle scene to pick up. Captures the current background texture (via + /// using ) + /// so the battle scene has the correct visual context, and snapshots the + /// depth/albedo lighting maps from 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 — wrapped in try/catch + /// because the registry may not be initialized when called from non-battle + /// contexts (e.g. preloading). + /// + public static class BattleService + { + /// + /// Constructs and stores 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 . + /// + 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; + } + } +} diff --git a/src/BattleSetCoreAction.cs b/src/BattleSetCoreAction.cs new file mode 100644 index 0000000..ec35f4a --- /dev/null +++ b/src/BattleSetCoreAction.cs @@ -0,0 +1,40 @@ +using Cthangover.Core.Actions; + +namespace Cthangover.Core.Battle +{ + /// + /// Selects which battle core (combat ruleset) the next battle will use. + /// The "core" variable maps to a entry by + /// string ID. Must be called before a battle starts, as + /// reads the active core at initialization time. + /// + public class BattleSetCoreAction : IScenarioAction + { + /// + /// 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. + /// + public string Name => "battle.set_core"; + + /// + /// Reads the "core" variable and sets it as the active battle core + /// via . The core determines + /// combat mechanics (turn order, ability system, etc.) for the + /// next initiated battle. + /// + 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}'"); + } + } +} + diff --git a/src/BattleStartedEvent.cs b/src/BattleStartedEvent.cs new file mode 100644 index 0000000..c86c4d8 --- /dev/null +++ b/src/BattleStartedEvent.cs @@ -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; } + } +} diff --git a/src/ChangedAttributes.cs b/src/ChangedAttributes.cs new file mode 100644 index 0000000..57175f4 --- /dev/null +++ b/src/ChangedAttributes.cs @@ -0,0 +1,30 @@ +namespace Cthangover.Core.Characters +{ + /// + /// 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. + /// + public struct ChangedAttributes + { + /// + /// Snapshot of the action source's damage and defence after the + /// exchange. + /// + public CharacterSet Source; + /// + /// Snapshot of the action target's damage and defence after the + /// exchange. + /// + public CharacterSet Target; + /// + /// Whether the action succeeded (e.g. hit landed vs. missed or + /// blocked). + /// + public bool Result; + + } + +} diff --git a/src/CharacterDiedEvent.cs b/src/CharacterDiedEvent.cs new file mode 100644 index 0000000..32642c0 --- /dev/null +++ b/src/CharacterDiedEvent.cs @@ -0,0 +1,15 @@ +namespace Cthangover.Core.Events +{ + /// + /// 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). + /// + [EventKey("character_died")] + public class CharacterDiedEvent : ModEvent + { + public string CharacterId { get; init; } + public string CharacterName { get; init; } + public string Source { get; init; } + } +} diff --git a/src/CharacterSet.cs b/src/CharacterSet.cs new file mode 100644 index 0000000..d3fa44c --- /dev/null +++ b/src/CharacterSet.cs @@ -0,0 +1,20 @@ +namespace Cthangover.Core.Characters +{ + /// + /// 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. + /// + public struct CharacterSet + { + /// + /// Damage dealt or received in this exchange. + /// + public int Damage; + /// + /// Defence value at the time of the exchange. + /// + public int Defence; + } +} diff --git a/src/DeadgroundBehaviour.cs b/src/DeadgroundBehaviour.cs index 84300d6..8d977e1 100644 --- a/src/DeadgroundBehaviour.cs +++ b/src/DeadgroundBehaviour.cs @@ -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("/root/GodotSceneService"); sceneService?.SwitchToMenu(); - GameData.Instance.Runtime.BattleData = null; + BattleRequest.Current = null; } /// diff --git a/src/GDWingroundListener.cs b/src/GDWingroundListener.cs new file mode 100644 index 0000000..0762e96 --- /dev/null +++ b/src/GDWingroundListener.cs @@ -0,0 +1,64 @@ +using Godot; + +namespace Cthangover.Core.Battle +{ + /// + /// GDScript bridge for . Holds a reference to a + /// GDScript object and delegates each hook through + /// / . + /// + /// All three hooks are optional — the GDScript mod implements only the methods + /// it needs. The context is marshalled into a plain Godot dictionary + /// (exp_gained, defeated_enemies, generated_loot, + /// return_scene) 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. + /// + public class GDWingroundListener : IWingroundListener + { + private readonly GodotObject _instance; + + public string Id { get; } + + /// + /// Creates a bridge around a live GDScript object. The caller must ensure + /// the object outlives the registry. + /// + 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); + } + } +} diff --git a/src/IBattleAutoPlayable.cs b/src/IBattleAutoPlayable.cs new file mode 100644 index 0000000..5e550c1 --- /dev/null +++ b/src/IBattleAutoPlayable.cs @@ -0,0 +1,30 @@ +namespace Cthangover.Core.Battle +{ + /// + /// Optional automation contract for battle cores. The base + /// 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. + /// + public interface IBattleAutoPlayable + { + /// + /// true while the core accepts a player turn advance. Must + /// become false immediately when the turn ends, so a polling + /// driver cannot trigger two turns in a row. + /// + bool IsWaitingForPlayerInput { get; } + + /// + /// 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 + /// is false. + /// + void AdvancePlayerTurn(); + } +} diff --git a/src/IBattleSpeedControl.cs b/src/IBattleSpeedControl.cs new file mode 100644 index 0000000..9b7a916 --- /dev/null +++ b/src/IBattleSpeedControl.cs @@ -0,0 +1,18 @@ +namespace Cthangover.Core.Battle +{ + /// + /// Minimal contract between 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. + /// + public interface IBattleSpeedControl + { + /// + /// Restores Engine.TimeScale 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. + /// + void ResetToNormal(); + } +} diff --git a/src/IWingroundListener.cs b/src/IWingroundListener.cs new file mode 100644 index 0000000..013a12d --- /dev/null +++ b/src/IWingroundListener.cs @@ -0,0 +1,61 @@ +namespace Cthangover.Core.Battle +{ + /// + /// Extension point for mods to inspect and mutate victory-screen + /// data. Implementations are auto-discovered via + /// (C#) + /// or detected by method signature in + /// (GDScript). + /// + /// The three hooks fire in order during + /// : + /// + /// — EXP data is available + /// but has not yet been applied to characters. + /// — loot has been + /// rolled from enemies but has not yet been added to inventory. + /// — all processing is done; + /// final chance to mutate data before the UI appears. + /// + /// The same instance is passed to + /// all listeners across all three phases, so mutations from earlier + /// hooks are visible to later hooks and to subsequent listeners. + /// + public interface IWingroundListener + { + /// + /// Unique identifier for this listener. Used in logs and for + /// cross-mod dependency ordering. Convention: + /// "mod_id.listener_name". + /// + string Id { get; } + + /// + /// Called before EXP is awarded to surviving characters. + /// and + /// are + /// populated; + /// is empty at this stage. + /// + void OnBeforeExpAward(WingroundContext ctx); + + /// + /// Called after loot has been generated from + /// but before + /// the items are added to the player's inventory. + /// is populated + /// and mutable — adding, removing, or modifying entries here + /// directly affects the inventory outcome. + /// + void OnAfterLootGenerated(WingroundContext ctx); + + /// + /// Called after EXP, loot, inventory, and recruitment have + /// all been processed, immediately before the victory UI + /// is shown (base.Show()). Use for post-processing + /// or cleanup that must happen before the player sees the + /// screen. + /// + void OnBeforeShow(WingroundContext ctx); + } +} diff --git a/src/StatusEffect/IStatusActions.cs b/src/StatusEffect/IStatusActions.cs new file mode 100644 index 0000000..6be828d --- /dev/null +++ b/src/StatusEffect/IStatusActions.cs @@ -0,0 +1,58 @@ +using Cthangover.Core.Characters; + +namespace Cthangover.Core.Cards.StatusEffect +{ + /// + /// 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. + /// + public interface IStatusActions + { + /// Factory key matching the Actions field in + /// , e.g. "basic/stun". + string ID { get; } + + /// Fires when the owning character's turn begins. Use for + /// start-of-turn damage-over-time or regeneration effects. + void OnTurnStart(Character target); + /// Fires when the owning character's turn ends. Mirrors + /// for symmetry; useful for effects that + /// activate after the character acts. + void OnTurnEnd(Character target); + /// Called before the owning character deals damage to + /// . Modify via + /// to amplify or reduce outgoing damage — + /// all active effects stack multiplicatively through the queue's + /// iteration. + void OnDealDamage(Character source, Character target, ref int damage); + /// Called before the owning character receives damage from + /// . Modify via + /// to add damage reduction or vulnerability. + /// Fires after in the damage + /// pipeline. + void OnTakeDamage(Character target, Character source, ref int damage); + /// Fires immediately when the effect is first placed on a + /// character. Use for one-shot stat boosts, visual feedback, or + /// initialisation logic. + void OnApply(Character target); + /// Fires when the effect is manually removed (e.g. via a + /// cleanse card). Not called on natural expiration — use + /// for that path. + void OnRemove(Character target); + /// 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. + void OnFinalAction(Character target); + /// 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. + void ModifyAttributes(CharacterAttributes attributes); + } +} diff --git a/src/StatusEffect/IStatusEffect.cs b/src/StatusEffect/IStatusEffect.cs new file mode 100644 index 0000000..5854d68 --- /dev/null +++ b/src/StatusEffect/IStatusEffect.cs @@ -0,0 +1,49 @@ +using Godot; + +namespace Cthangover.Core.Cards.StatusEffect +{ + + /// + /// 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. + /// + public interface IStatusEffect + { + /// Unique mod identifier, e.g. "basic/stun". Used by factories + /// and the queue to look up and remove effects by key. + string ID { get; } + /// Display name shown in the battle UI. + string Name { get; } + /// Flavour text explaining the effect to the player. + string Description { get; } + /// High-level category (Buff, Debuff, Stun) used + /// for UI tinting and quick type checks like + /// . + StatusEffectType EffectType { get; } + /// Base duration in turns. Set during effect creation; may be + /// overridden by the caller via . + /// A value of 0 or negative means the effect never expires + /// naturally. + int Duration { get; set; } + /// Turns remaining before the effect expires. Decremented each + /// turn boundary by the queue. Independent of Duration so + /// that the original value is preserved for display or re-apply + /// logic. + int RemainingTurns { get; set; } + /// Icon texture loaded from the "characters" mod group, + /// displayed beside the effect name in the HUD. + Texture2D Icon { get; } + /// Behaviour hooks that fire during turn phases and on damage + /// events. Resolved by StatusEffectActionFactory from the + /// string ID. + IStatusActions Actions { get; } + + /// Creates a shallow clone. Actions is a shared reference + /// (stateless singleton) so it is not deep-copied. Used by + /// when duplicating a + /// character. + IStatusEffect Copy(); + } +} diff --git a/src/StatusEffect/Impls/StunStatusEffect.cs b/src/StatusEffect/Impls/StunStatusEffect.cs new file mode 100644 index 0000000..09ceee4 --- /dev/null +++ b/src/StatusEffect/Impls/StunStatusEffect.cs @@ -0,0 +1,49 @@ +using Cthangover.Core.Characters; + +namespace Cthangover.Core.Cards.StatusEffect.Impls +{ + /// + /// 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. + /// + public class StunStatusEffect : IStatusActions + { + /// + public string ID => "basic/stun"; + + /// + public void OnTurnStart(Character target) + { } + + /// + public void OnTurnEnd(Character target) + { } + + /// + public void OnDealDamage(Character source, Character target, ref int damage) + { } + + /// + public void OnTakeDamage(Character target, Character source, ref int damage) + { } + + /// + public void OnApply(Character target) + { } + + /// + public void OnRemove(Character target) + { } + + /// + public void OnFinalAction(Character target) + { } + + /// + public void ModifyAttributes(CharacterAttributes attributes) + { } + } +} diff --git a/src/StatusEffect/StatusEffectInfo.cs b/src/StatusEffect/StatusEffectInfo.cs new file mode 100644 index 0000000..eb0decb --- /dev/null +++ b/src/StatusEffect/StatusEffectInfo.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; +using Cthangover.Core.Factories; + +namespace Cthangover.Core.Cards.StatusEffect +{ + + /// + /// 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. + /// + [System.Serializable] + public class StatusEffectInfo : IIdentifiable + { + /// Unique effect identifier used as the factory key. + /// Serialised as "Id" in JSON for consistency with the + /// modding schema. + [JsonPropertyName("Id")] + public string ID { get; set; } + /// Display name for the effect, shown in tooltips and the + /// character status panel. + public string Name { get; set; } + /// Human-readable description explaining what the effect + /// does. + public string Description { get; set; } + /// Category of the effect. Serialised as a string + /// ("Buff", "Debuff", "Stun") via + /// JsonStringEnumConverter for readable mod configs. + [JsonConverter(typeof(JsonStringEnumConverter))] + public StatusEffectType Type { get; set; } + /// Base duration in turns before the effect naturally + /// expires. + public int Duration { get; set; } + /// Factory ID string for the behaviour set, resolved by + /// StatusEffectActionFactory into an + /// instance at runtime. + public string Actions { get; set; } + /// Filename of the icon texture within the + /// "characters" mod group, loaded by + /// during construction. + public string Icon { get; set; } + } +} diff --git a/src/StatusEffect/StatusEffectItem.cs b/src/StatusEffect/StatusEffectItem.cs new file mode 100644 index 0000000..8008bef --- /dev/null +++ b/src/StatusEffect/StatusEffectItem.cs @@ -0,0 +1,78 @@ +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Core.Cards.StatusEffect +{ + + /// + /// 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. + /// + public class StatusEffectItem : IStatusEffect + { + /// + public string ID { get; private set; } + /// + public string Name { get; private set; } + /// + public string Description { get; private set; } + /// + public int Duration { get; set; } + /// + public int RemainingTurns { get; set; } + /// + public StatusEffectType EffectType { get; private set; } + /// + public Texture2D Icon { get; private set; } + /// + public IStatusActions Actions { get; private set; } + + /// Constructs a runtime effect from its JSON-serialisable + /// definition. Resolves the icon via + /// TextureUtils.LoadFromModGroup and the action set via + /// StatusEffectActionFactory. + /// Deserialised effect data from a mod + /// config. + 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); + } + + /// Parameterless constructor for cases where properties are + /// set manually (e.g. by or deserialisation + /// paths). + public StatusEffectItem() { } + + /// Shallow clone that copies primitives and reuses the + /// shared Actions reference. Used when the queue + /// duplicates effects during character cloning. + /// A new with identical + /// property values. + public IStatusEffect Copy() + { + return new StatusEffectItem + { + ID = ID, + Name = Name, + Description = Description, + Duration = Duration, + RemainingTurns = RemainingTurns, + EffectType = EffectType, + Icon = Icon, + Actions = Actions, + }; + } + } +} diff --git a/src/StatusEffect/StatusEffectQueue.cs b/src/StatusEffect/StatusEffectQueue.cs new file mode 100644 index 0000000..d673469 --- /dev/null +++ b/src/StatusEffect/StatusEffectQueue.cs @@ -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 +{ + + /// + /// 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. + /// + public class StatusEffectQueue + { + private readonly List activeStatusEffectData; + private readonly Character owner; + + /// Defensive copy of the internal effect list. Returns + /// a new List each access so external code cannot mutate + /// the queue's backing store. Safe for enumeration. + public List ActiveStatusEffects => activeStatusEffectData.ToList(); + + /// Initialises an empty queue bound to . + /// The owner reference is used by all action-hook calls to pass + /// the affected character. + /// The character who carries these + /// effects. + public StatusEffectQueue(Character owner) + { + this.activeStatusEffectData = new List(); + this.owner = owner; + } + + /// Checks whether any active effect has + /// . Called by the battle + /// engine to determine if the owner should skip their turn. + /// if at least one Stun-type effect + /// is active. + public bool HasStun() + { + foreach (var statusEffect in activeStatusEffectData) + if (statusEffect.EffectType == StatusEffectType.Stun) + return true; + return false; + } + + /// Looks up in the factory, + /// constructs a , optionally + /// overrides its duration, fires OnApply, and appends it + /// to the active list. + /// Factory key matching a + /// entry. + /// If positive, overwrites the default + /// duration from the effect definition. + /// if the factory lookup fails + /// (key not found); otherwise . + 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; + } + + /// Removes the first effect matching + /// from the active list. + /// Fires OnRemove so the effect can clean up before + /// disappearing (e.g. revert stat modifications). + /// Factory key of the effect to + /// remove. + /// if no matching effect was + /// found; otherwise . + 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; + } + + /// Advances all active effects at the start of the owner's + /// turn. Fires OnTurnStart on each effect, decrements + /// for timed effects, + /// and removes expired ones after firing + /// OnFinalAction. Iterates backwards so removals do not + /// corrupt the index. + 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); + } + } + } + } + + /// Fires end-of-turn hooks on all active effects. Operates + /// on a defensive copy so that OnTurnEnd callbacks that + /// add or remove effects do not invalidate the enumerator. + /// Decrements remaining turns and removes expired effects via + /// (which also fires + /// OnRemove). + 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); + } + } + } + + /// Pipes outgoing damage through every active effect's + /// OnDealDamage hook, allowing each to modify + /// via . + /// Effects iterate in insertion order; multiplicative stacking + /// means order-agnostic results for flat modifiers but + /// order-sensitive for percentage modifiers. + /// The character receiving the damage. + /// The damage value, modifiable by effects + /// through the parameter. + public void OnDealDamage(Character target, ref int damage) + { + foreach (var statusEffect in activeStatusEffectData) + { + statusEffect.Actions?.OnDealDamage(owner, target, ref damage); + } + } + + /// Pipes incoming damage through every active effect's + /// OnTakeDamage hook. Called after + /// in the damage pipeline, so + /// defensive modifiers stack on top of the attacker's + /// amplified value. + /// The character dealing the damage. + /// The damage value, modifiable by effects + /// through the parameter. + public void OnTakeDamage(Character source, ref int damage) + { + foreach (var statusEffect in activeStatusEffectData) + { + statusEffect.Actions?.OnTakeDamage(owner, source, ref damage); + } + } + + /// Removes all active effects, firing OnRemove on + /// each before clearing the list. Used when a character dies or + /// an encounter ends so effects do not leak between battles. + public void ClearAll() + { + foreach (var statusEffect in activeStatusEffectData) + { + statusEffect.Actions?.OnRemove(owner); + } + activeStatusEffectData.Clear(); + } + + /// Deep-clones the entire queue for a new character owner. + /// Each effect is copied via , + /// and OnApply is re-fired on + /// so initialisation hooks run in the target's context. Used + /// when a character is duplicated mid-encounter (e.g. a summon + /// or mirror effect). + /// The character who will own the + /// clone. + /// A new bound to + /// with copies of all active + /// effects. + 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; + } + + } +} diff --git a/src/StatusEffect/StatusEffectService.cs b/src/StatusEffect/StatusEffectService.cs new file mode 100644 index 0000000..ac7199b --- /dev/null +++ b/src/StatusEffect/StatusEffectService.cs @@ -0,0 +1,40 @@ +using System.Runtime.CompilerServices; +using Cthangover.Core.Characters; + +namespace Cthangover.Core.Cards.StatusEffect +{ + /// + /// Side-table that owns the of every character + /// participating in battle. The queue used to live on + /// itself, but the core character model must stay free of battle subsystems — + /// so the battle mod keeps queues keyed by character instance instead. + /// + /// Uses : queues die together + /// with their character, which matters because each battle constructs fresh + /// copies via 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. + /// + public static class StatusEffectService + { + private static readonly ConditionalWeakTable queues = new(); + + /// + /// Returns the queue of , creating an empty one + /// on first access. Returns null for a null character so call sites + /// can keep their existing null-conditional patterns. + /// + 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); + } + } +} diff --git a/src/StatusEffect/StatusEffectType.cs b/src/StatusEffect/StatusEffectType.cs new file mode 100644 index 0000000..b5146fc --- /dev/null +++ b/src/StatusEffect/StatusEffectType.cs @@ -0,0 +1,26 @@ +namespace Cthangover.Core.Cards.StatusEffect +{ + /// + /// 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. + /// + public enum StatusEffectType + { + /// Positive effect that benefits the bearer (e.g. stat boost, + /// regeneration). Rendered with a friendly tint in the UI. + Buff, + /// Negative effect that harms the bearer (e.g. damage over + /// time, stat reduction). Rendered with a hostile tint in the + /// UI. + Debuff, + /// Crowd-control effect that causes the bearer to skip + /// their turn. Checked by + /// ; the action hooks + /// are deliberately no-ops — the skip is enforced by the battle + /// engine reading this type flag, not by the + /// callbacks. + Stun, + } +} diff --git a/src/StatusEffectActionFactory.cs b/src/StatusEffectActionFactory.cs new file mode 100644 index 0000000..39ab5c4 --- /dev/null +++ b/src/StatusEffectActionFactory.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.Cards.StatusEffect; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Factories.Impls +{ + /// + /// Reflection-based plugin registry for status effect behaviour + /// classes, mirroring the pattern used by ItemActionFactory. + /// Each IStatusActions 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. + /// + public class StatusEffectActionFactory + { + private readonly Dictionary actions; + +#region Singleton + + private static readonly Lazy instance = new(() => new StatusEffectActionFactory()); + public static StatusEffectActionFactory Instance => instance.Value; + + public StatusEffectActionFactory() + { + actions = new Dictionary(); + foreach (var action in Reflections.FindAndCreate()) + { + 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; + } + + } +} diff --git a/src/StatusEffectInfoFactory.cs b/src/StatusEffectInfoFactory.cs new file mode 100644 index 0000000..cc39a60 --- /dev/null +++ b/src/StatusEffectInfoFactory.cs @@ -0,0 +1,19 @@ +using System; +using Cthangover.Core.Cards.StatusEffect; + +namespace Cthangover.Core.Factories.Impls +{ + /// + /// Thin FileFactory for status effect metadata — duration, stack + /// behaviour, visual icon, etc. Works in tandem with + /// StatusEffectActionFactory: this factory provides the data + /// (what the effect is), while the action factory provides the + /// behaviour (what the effect does each turn). + /// + public class StatusEffectInfoFactory : FileFactory + { + private static readonly Lazy lazy = new(() => new StatusEffectInfoFactory()); + public static StatusEffectInfoFactory Instance => lazy.Value; + public override string GroupName => "status_effects"; + } +} diff --git a/src/TurnBattleGDScriptHook.cs b/src/TurnBattleGDScriptHook.cs new file mode 100644 index 0000000..3825eaf --- /dev/null +++ b/src/TurnBattleGDScriptHook.cs @@ -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 +{ + /// + /// GDScript hook of the turn-battle mod. The core's ModGDScriptLoader walks + /// every .gd file generically and hands objects to registered hooks; + /// this hook recognizes the two battle-side contracts: + /// + /// get_action_id() + execute(action, user, target) — wraps + /// the script in a and registers it in the + /// global pool. + /// get_winground_listener_id() plus any of the three + /// on_winground_* methods — wraps the script in a + /// . + /// + /// Keeping the recognition here means the core loader no longer mentions any + /// battle method name. + /// + public class TurnBattleGDScriptHook : IGDScriptHook + { + /// Stable id used by for replacement on reload. + public string Id => "turn_battle_base.gd_hook"; + + /// Inspects one compiled script and registers an adapter when a contract matches. + 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})"); + } + } + } + } +} diff --git a/src/TurnBattleSceneInitializer.cs b/src/TurnBattleSceneInitializer.cs index 0c656c4..1ef9c17 100644 --- a/src/TurnBattleSceneInitializer.cs +++ b/src/TurnBattleSceneInitializer.cs @@ -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 { /// - /// Registers the turn-based battle view scene with the core so that - /// 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: + /// + /// Publishes the logical "Battle" scene route with fade transition and + /// combat music, so the core can route switch_scene Battle without + /// hardcoding a battle path. + /// Registers so GDScript mods can + /// still supply action executors and winground listeners after the battle + /// bridge moved out of the core loader. + /// Discovers and + /// 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). + /// /// public class TurnBattleSceneInitializer : IModInitializer { private const string BattleScenePath = "scenes/battle/battle.tscn"; - /// No-op: registration only needs the resources-ready stage. - public void OnModLoaded(string modId) { } + /// + /// 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 .gd sources during + /// assembly registration finalization, before resources become ready. + /// + public void OnModLoaded(string modId) + { + GDScriptHookRegistry.Register(new TurnBattleGDScriptHook()); + RegisterAssembly(FindAssembly(modId)); + } - /// Publishes the battle view scene path to . + /// + /// Publishes the scene route and rescans all loaded assemblies for battle + /// cores and winground listeners. + /// 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); } } } diff --git a/src/WingroundBehaviour.cs b/src/WingroundBehaviour.cs index 1d8b2dc..0b6594b 100644 --- a/src/WingroundBehaviour.cs +++ b/src/WingroundBehaviour.cs @@ -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 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 enemies) + private static void PublishDefeatedCharacters(List 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("/root/GodotSceneService"); var sceneManager = GetNode("/root/SceneManager"); diff --git a/src/WingroundContext.cs b/src/WingroundContext.cs new file mode 100644 index 0000000..6b8e6ee --- /dev/null +++ b/src/WingroundContext.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using Cthangover.Core.Characters; +using Cthangover.Core.Items; +using Cthangover.Core.Settings; + +namespace Cthangover.Core.Battle +{ + /// + /// Mutable context passed to 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 reads these + /// fields after each hook, so changes take effect immediately. + /// + public class WingroundContext + { + /// + /// Per-character EXP earned during this battle, keyed by + /// . Populated from + /// before the + /// first hook and consumed by + /// to award EXP to . + /// + public Dictionary PlayerExpGained { get; set; } = new(); + + /// + /// Enemies defeated during this battle. Used by the victory + /// screen for loot generation and recruitment rolls. Null + /// entries are skipped by . + /// + public List DefeatedEnemies { get; set; } = new(); + + /// + /// Loot items generated from . + /// Filled by between the + /// "before EXP" and "after loot" hooks. Listeners can + /// add, remove, or modify entries before the items are + /// committed to inventory. + /// + public List GeneratedLoot { get; set; } = new(); + + /// + /// The 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 + /// ReturnScene are writable. + /// + public BattleData BattleData { get; set; } + } +} diff --git a/src/WingroundListenerRegistry.cs b/src/WingroundListenerRegistry.cs new file mode 100644 index 0000000..ab85eb7 --- /dev/null +++ b/src/WingroundListenerRegistry.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Battle +{ + /// + /// Singleton registry that collects + /// implementations (C# via reflection, GDScript via + /// ) and broadcasts winground + /// lifecycle hooks to all of them in registration order. + /// + /// Reflects the same pattern as the recruiting mod's + /// RecruitBehaviourRegistry: a + /// static Instance, reflection-based RegisterAssembly, + /// and manual RegisterListener for GDScript bridges. + /// + public class WingroundListenerRegistry + { + public static readonly WingroundListenerRegistry Instance = new(); + + private readonly List _listeners = new(); + + /// + /// Scans for concrete types that + /// implement and instantiates + /// them via their parameterless constructor. Called by the battle + /// mod's initializer as mods announce themselves. + /// + public void RegisterAssembly(Assembly assembly) + { + foreach (var instance in Reflections.FindAndCreate(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); + } + } + } + + /// + /// Scans every assembly loaded in the current + /// and registers all implementations. + /// Called by the battle mod's initializer so the core does not need to + /// reference battle types during its assembly fan-out. + /// + public void RegisterAll() + { + foreach (var instance in Reflections.FindAndCreate()) + RegisterListener(instance); + + GameLogger.Log("BATTLE", + $"WingroundListenerRegistry.RegisterAll: {_listeners.Count} listener(s) registered"); + } + + /// + /// Registers a listener, replacing any earlier one with the same + /// . Deduplication matters because the + /// registry is rescanned from the whole AppDomain on mod reload (used by + /// the GDScript bridge and by + /// ). 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. + /// + 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}'"); + } + + /// + /// Broadcasts + /// to every registered listener. Exceptions from individual + /// listeners are logged and swallowed so one broken mod cannot + /// break the victory screen for others. + /// + 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); + } + } + } + + /// + /// Broadcasts + /// to every registered listener. + /// + 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); + } + } + } + + /// + /// Broadcasts + /// to every registered listener. + /// + 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); + } + } + } + } +} diff --git a/status_effects/status_effects.json b/status_effects/status_effects.json new file mode 100644 index 0000000..deacc5a Binary files /dev/null and b/status_effects/status_effects.json differ