Compare commits

..
3 Commits
Author SHA1 Message Date
cthangover 9e4d07df45 Mod cover 2026-10-22 17:09:50 +02:00
cthangover 1018c90c25 Line endings: LF everywhere
.gitattributes forces LF (CRLF only for .bat/.cmd), core.autocrlf is off;
files that differed only by CRLF are normalized.
2026-10-21 13:29:57 +02:00
cthangover 392b1caf0a Battle end reports the real outcome
Both result screens set IsDestroyed before ClearBattle, which derived the
result from that flag, so BattleEndedEvent.Result was always "defeat".
The screens now pass the outcome in.
2026-10-21 13:22:17 +02:00
29 changed files with 1713 additions and 1677 deletions

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# Line endings: LF everywhere, on every OS, whatever core.autocrlf says.
* text=auto eol=lf
# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF.
*.bat text eol=crlf
*.cmd text eol=crlf
# Binaries: never converted, never diffed as text.
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.svg text
*.ogg binary
*.wav binary
*.mp3 binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.dll binary
*.so binary
*.dylib binary
*.pdb binary
*.exe binary
*.zip binary
*.pck binary
*.moc3 binary
*.res binary
*.scn binary
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

+79 -79
View File
@@ -1,79 +1,79 @@
using System.Collections.Generic;
using Cthangover.Core.Characters;
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// Central action-execution dispatcher. Resolves an action ID to an
/// IActionExecutor in two tiers: first the active provider (set by
/// the current IBattleCore, allowing per-battle-engine overrides),
/// then a global fallback registry. This two-level lookup lets mods
/// supply custom executors for specific battle cores without replacing
/// the global ones. Execute returns ChangedAttributes to allow the
/// caller to inspect the result (success/failure, stat deltas).
/// </summary>
public class ActionExecutorHub
{
/// <summary>
/// Singleton hub instance. There is one global dispatcher shared
/// across all battle cores; cores swap providers via
/// <see cref="SetActiveProvider"/> when they activate.
/// </summary>
public static readonly ActionExecutorHub Instance = new();
private IActionExecutorProvider _activeProvider;
private readonly Dictionary<string, IActionExecutor> _globalExecutors = new();
/// <summary>
/// Installs a per-core executor provider. The active provider is
/// consulted before the global registry, allowing the current
/// battle core to override specific action handlers.
/// </summary>
public void SetActiveProvider(IActionExecutorProvider provider)
{
_activeProvider = provider;
}
/// <summary>
/// Registers a fallback executor in the global pool, keyed by
/// <see cref="IActionExecutor.ActionId"/>. Global executors are
/// used when no active provider supplies a match.
/// </summary>
public void RegisterGlobal(IActionExecutor executor)
{
if (executor != null && !string.IsNullOrEmpty(executor.ActionId))
_globalExecutors[executor.ActionId] = executor;
}
/// <summary>
/// Dispatches an action to the appropriate executor. Resolution
/// order: active provider first, then global registry. Returns
/// <c>Result = false</c> if no executor is found or if
/// <paramref name="action"/> is null.
/// </summary>
public ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
{
if (action == null)
return new ChangedAttributes { Result = false };
var executor = Resolve(action.ID);
if (executor == null)
return new ChangedAttributes { Result = false };
return executor.Execute(action, user, target);
}
private IActionExecutor Resolve(string actionId)
{
if (_activeProvider != null)
{
var executor = _activeProvider.GetExecutor(actionId);
if (executor != null)
return executor;
}
_globalExecutors.TryGetValue(actionId, out var fallback);
return fallback;
}
}
}
using System.Collections.Generic;
using Cthangover.Core.Characters;
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// Central action-execution dispatcher. Resolves an action ID to an
/// IActionExecutor in two tiers: first the active provider (set by
/// the current IBattleCore, allowing per-battle-engine overrides),
/// then a global fallback registry. This two-level lookup lets mods
/// supply custom executors for specific battle cores without replacing
/// the global ones. Execute returns ChangedAttributes to allow the
/// caller to inspect the result (success/failure, stat deltas).
/// </summary>
public class ActionExecutorHub
{
/// <summary>
/// Singleton hub instance. There is one global dispatcher shared
/// across all battle cores; cores swap providers via
/// <see cref="SetActiveProvider"/> when they activate.
/// </summary>
public static readonly ActionExecutorHub Instance = new();
private IActionExecutorProvider _activeProvider;
private readonly Dictionary<string, IActionExecutor> _globalExecutors = new();
/// <summary>
/// Installs a per-core executor provider. The active provider is
/// consulted before the global registry, allowing the current
/// battle core to override specific action handlers.
/// </summary>
public void SetActiveProvider(IActionExecutorProvider provider)
{
_activeProvider = provider;
}
/// <summary>
/// Registers a fallback executor in the global pool, keyed by
/// <see cref="IActionExecutor.ActionId"/>. Global executors are
/// used when no active provider supplies a match.
/// </summary>
public void RegisterGlobal(IActionExecutor executor)
{
if (executor != null && !string.IsNullOrEmpty(executor.ActionId))
_globalExecutors[executor.ActionId] = executor;
}
/// <summary>
/// Dispatches an action to the appropriate executor. Resolution
/// order: active provider first, then global registry. Returns
/// <c>Result = false</c> if no executor is found or if
/// <paramref name="action"/> is null.
/// </summary>
public ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
{
if (action == null)
return new ChangedAttributes { Result = false };
var executor = Resolve(action.ID);
if (executor == null)
return new ChangedAttributes { Result = false };
return executor.Execute(action, user, target);
}
private IActionExecutor Resolve(string actionId)
{
if (_activeProvider != null)
{
var executor = _activeProvider.GetExecutor(actionId);
if (executor != null)
return executor;
}
_globalExecutors.TryGetValue(actionId, out var fallback);
return fallback;
}
}
}
+121 -121
View File
@@ -1,122 +1,122 @@
using System.Collections.Generic;
using Cthangover.Core.UI;
using Godot;
using System.Collections.Generic;
using Cthangover.Core.UI;
using Godot;
using Cthangover.Mods.Interface.Base;
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// Sequential action queue that processes one IBattleAction per frame
/// until it signals completion (DoAction returns true), then moves to
/// the next. Fires OnStopMachine when the queue drains naturally or
/// is explicitly stopped — used by battle cores to detect when a turn's
/// animation sequence is finished. Actions can be added before or during
/// processing; the machine starts automatically once at least one action
/// is queued and no current action is running.
/// <para/>
/// Derives from <see cref="ModNode"/> so the engine lifecycle callbacks reach
/// this runtime-compiled script through the core base class (mod assemblies
/// have no Godot source generation of their own).
/// </summary>
public partial class BattleActionMachine : ModNode
{
/// <summary>
/// Pending action queue. Actions are dequeued one-by-one in the process
/// hook, starting the next as soon as the current signals completion.
/// Can be extended while the machine is running — new actions will
/// be processed after the current queue drains.
/// </summary>
public List<IBattleAction> Actions { get; set; } = new();
private IBattleAction CurrentAction { get; set; }
private bool _hadActions;
/// <summary>
/// Fired when the machine drains naturally or is explicitly stopped.
/// Battle cores subscribe to advance the turn phase without polling.
/// </summary>
public event StopMachineDelegate OnStopMachine;
/// <summary>Stops the machine and fires <see cref="OnStopMachine"/> when the node leaves the tree, so subscribers never wait on a dead node.</summary>
protected override void OnExitTree()
{
base.OnExitTree();
StopMachine();
}
/// <summary>Drives the queued actions once per frame; fires <see cref="OnStopMachine"/> when the queue drains.</summary>
protected override void OnProcess(double delta)
{
if (Actions.Count == 0 && CurrentAction == null)
{
if (_hadActions)
{
_hadActions = false;
StopMachine();
}
return;
}
var action = CurrentAction;
if (action == null)
{
CurrentAction = Actions[0];
Actions.RemoveAt(0);
action = CurrentAction;
_hadActions = true;
action?.DoStart();
}
if (action == null)
{
_hadActions = false;
StopMachine();
return;
}
if (action.DoAction())
{
action.DoEnd();
CurrentAction = null;
}
}
/// <summary>
/// Enqueues a single action. If the machine is idle (no current
/// action), processing starts on the next _Process tick.
/// </summary>
public void AddAction(IBattleAction action)
{
if (action == null)
return;
Actions.Add(action);
}
/// <summary>
/// Batch-enqueues multiple actions. All are appended to the existing
/// queue and processed in FIFO order on subsequent frames.
/// </summary>
public void AddActions(List<IBattleAction> actions)
{
if(actions == null || actions.Count == 0)
return;
Actions.AddRange(actions);
}
/// <summary>
/// Immediately clears the queue, aborts the current action, and
/// fires <see cref="OnStopMachine"/>. Called on tree exit and
/// when the queue drains naturally.
/// </summary>
public void StopMachine()
{
CurrentAction = null;
Actions.Clear();
_hadActions = false;
OnStopMachine?.Invoke();
}
}
}
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// Sequential action queue that processes one IBattleAction per frame
/// until it signals completion (DoAction returns true), then moves to
/// the next. Fires OnStopMachine when the queue drains naturally or
/// is explicitly stopped — used by battle cores to detect when a turn's
/// animation sequence is finished. Actions can be added before or during
/// processing; the machine starts automatically once at least one action
/// is queued and no current action is running.
/// <para/>
/// Derives from <see cref="ModNode"/> so the engine lifecycle callbacks reach
/// this runtime-compiled script through the core base class (mod assemblies
/// have no Godot source generation of their own).
/// </summary>
public partial class BattleActionMachine : ModNode
{
/// <summary>
/// Pending action queue. Actions are dequeued one-by-one in the process
/// hook, starting the next as soon as the current signals completion.
/// Can be extended while the machine is running — new actions will
/// be processed after the current queue drains.
/// </summary>
public List<IBattleAction> Actions { get; set; } = new();
private IBattleAction CurrentAction { get; set; }
private bool _hadActions;
/// <summary>
/// Fired when the machine drains naturally or is explicitly stopped.
/// Battle cores subscribe to advance the turn phase without polling.
/// </summary>
public event StopMachineDelegate OnStopMachine;
/// <summary>Stops the machine and fires <see cref="OnStopMachine"/> when the node leaves the tree, so subscribers never wait on a dead node.</summary>
protected override void OnExitTree()
{
base.OnExitTree();
StopMachine();
}
/// <summary>Drives the queued actions once per frame; fires <see cref="OnStopMachine"/> when the queue drains.</summary>
protected override void OnProcess(double delta)
{
if (Actions.Count == 0 && CurrentAction == null)
{
if (_hadActions)
{
_hadActions = false;
StopMachine();
}
return;
}
var action = CurrentAction;
if (action == null)
{
CurrentAction = Actions[0];
Actions.RemoveAt(0);
action = CurrentAction;
_hadActions = true;
action?.DoStart();
}
if (action == null)
{
_hadActions = false;
StopMachine();
return;
}
if (action.DoAction())
{
action.DoEnd();
CurrentAction = null;
}
}
/// <summary>
/// Enqueues a single action. If the machine is idle (no current
/// action), processing starts on the next _Process tick.
/// </summary>
public void AddAction(IBattleAction action)
{
if (action == null)
return;
Actions.Add(action);
}
/// <summary>
/// Batch-enqueues multiple actions. All are appended to the existing
/// queue and processed in FIFO order on subsequent frames.
/// </summary>
public void AddActions(List<IBattleAction> actions)
{
if(actions == null || actions.Count == 0)
return;
Actions.AddRange(actions);
}
/// <summary>
/// Immediately clears the queue, aborts the current action, and
/// fires <see cref="OnStopMachine"/>. Called on tree exit and
/// when the queue drains naturally.
/// </summary>
public void StopMachine()
{
CurrentAction = null;
Actions.Clear();
_hadActions = false;
OnStopMachine?.Invoke();
}
}
}
+27 -27
View File
@@ -1,27 +1,27 @@
using Cthangover.Core.Characters;
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// Executes a specific battle action. The executor is keyed by ActionId
/// (matching the action's ID) so the hub can look it up without
/// knowing the concrete type. Returns ChangedAttributes so callers
/// can apply stat deltas and check whether the action succeeded.
/// </summary>
public interface IActionExecutor
{
/// <summary>
/// String key that matches <see cref="ActionCharacter.ID"/>.
/// The hub uses this to resolve executors without knowing
/// concrete types.
/// </summary>
string ActionId { get; }
/// <summary>
/// Applies the action's effects. Receives the action definition
/// and the user/target <see cref="Character"/> instances; returns
/// <see cref="ChangedAttributes"/> with deltas and success/failure.
/// </summary>
ChangedAttributes Execute(ActionCharacter action, Character user, Character target);
}
}
using Cthangover.Core.Characters;
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// Executes a specific battle action. The executor is keyed by ActionId
/// (matching the action's ID) so the hub can look it up without
/// knowing the concrete type. Returns ChangedAttributes so callers
/// can apply stat deltas and check whether the action succeeded.
/// </summary>
public interface IActionExecutor
{
/// <summary>
/// String key that matches <see cref="ActionCharacter.ID"/>.
/// The hub uses this to resolve executors without knowing
/// concrete types.
/// </summary>
string ActionId { get; }
/// <summary>
/// Applies the action's effects. Receives the action definition
/// and the user/target <see cref="Character"/> instances; returns
/// <see cref="ChangedAttributes"/> with deltas and success/failure.
/// </summary>
ChangedAttributes Execute(ActionCharacter action, Character user, Character target);
}
}
+18 -18
View File
@@ -1,18 +1,18 @@
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// Typed executor source. Each IBattleCore returns its own provider,
/// giving cores the ability to supply custom executor sets without
/// polluting the global registry.
/// </summary>
public interface IActionExecutorProvider
{
/// <summary>
/// Returns the executor registered for <paramref name="actionId"/>,
/// or null if no override is configured. Called by
/// <see cref="ActionExecutorHub"/> before falling back to the
/// global registry.
/// </summary>
IActionExecutor GetExecutor(string actionId);
}
}
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// Typed executor source. Each IBattleCore returns its own provider,
/// giving cores the ability to supply custom executor sets without
/// polluting the global registry.
/// </summary>
public interface IActionExecutorProvider
{
/// <summary>
/// Returns the executor registered for <paramref name="actionId"/>,
/// or null if no override is configured. Called by
/// <see cref="ActionExecutorHub"/> before falling back to the
/// global registry.
/// </summary>
IActionExecutor GetExecutor(string actionId);
}
}
+32 -32
View File
@@ -1,32 +1,32 @@
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// A single step in the battle action queue (animation, damage, effect).
/// DoStart is called once when the action begins, DoAction is polled
/// every frame — it returns true when the action is done — and DoEnd
/// is the cleanup callback. This polling model lets actions drive
/// their own duration (e.g. a tween runs until finished) without
/// the machine needing timers.
/// </summary>
public interface IBattleAction
{
/// <summary>
/// Called once when the action is dequeued and begins execution.
/// Use for setup: spawning nodes, caching references, starting
/// tweens or coroutines.
/// </summary>
void DoStart();
/// <summary>
/// Polled every frame. Returns <c>true</c> when the action has
/// finished its work; returns <c>false</c> to remain active.
/// This model eliminates the need for the machine to manage timers.
/// </summary>
bool DoAction();
/// <summary>
/// Cleanup callback fired after <see cref="DoAction"/> returns
/// <c>true</c>. Use for teardown: freeing nodes, resetting state,
/// applying final stat deltas.
/// </summary>
void DoEnd();
}
}
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// A single step in the battle action queue (animation, damage, effect).
/// DoStart is called once when the action begins, DoAction is polled
/// every frame — it returns true when the action is done — and DoEnd
/// is the cleanup callback. This polling model lets actions drive
/// their own duration (e.g. a tween runs until finished) without
/// the machine needing timers.
/// </summary>
public interface IBattleAction
{
/// <summary>
/// Called once when the action is dequeued and begins execution.
/// Use for setup: spawning nodes, caching references, starting
/// tweens or coroutines.
/// </summary>
void DoStart();
/// <summary>
/// Polled every frame. Returns <c>true</c> when the action has
/// finished its work; returns <c>false</c> to remain active.
/// This model eliminates the need for the machine to manage timers.
/// </summary>
bool DoAction();
/// <summary>
/// Cleanup callback fired after <see cref="DoAction"/> returns
/// <c>true</c>. Use for teardown: freeing nodes, resetting state,
/// applying final stat deltas.
/// </summary>
void DoEnd();
}
}
+42 -42
View File
@@ -1,42 +1,42 @@
using System.Collections.Generic;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Global animation-player index keyed by AnimationType string.
/// Mods or cores register players; the action machine looks up the
/// player for a given animation type. Singleton — there is one set
/// of players for the entire runtime, but each player can make
/// per-core decisions internally.
/// </summary>
public class AnimationRegistry
{
/// <summary>
/// Singleton registry. One set of animation players serves the
/// entire runtime; individual players dispatch per-core via
/// internal logic.
/// </summary>
public static readonly AnimationRegistry Instance = new();
private readonly Dictionary<string, IBattleAnimationPlayer> _players = new();
/// <summary>
/// Registers a player under <paramref name="animType"/>.
/// Replaces any existing player for the same key — last write wins.
/// </summary>
public void Register(string animType, IBattleAnimationPlayer player)
{
_players[animType] = player;
}
/// <summary>
/// Retrieves the player registered for <paramref name="animType"/>,
/// or null if no match exists.
/// </summary>
public IBattleAnimationPlayer Get(string animType)
{
_players.TryGetValue(animType, out var player);
return player;
}
}
}
using System.Collections.Generic;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Global animation-player index keyed by AnimationType string.
/// Mods or cores register players; the action machine looks up the
/// player for a given animation type. Singleton — there is one set
/// of players for the entire runtime, but each player can make
/// per-core decisions internally.
/// </summary>
public class AnimationRegistry
{
/// <summary>
/// Singleton registry. One set of animation players serves the
/// entire runtime; individual players dispatch per-core via
/// internal logic.
/// </summary>
public static readonly AnimationRegistry Instance = new();
private readonly Dictionary<string, IBattleAnimationPlayer> _players = new();
/// <summary>
/// Registers a player under <paramref name="animType"/>.
/// Replaces any existing player for the same key — last write wins.
/// </summary>
public void Register(string animType, IBattleAnimationPlayer player)
{
_players[animType] = player;
}
/// <summary>
/// Retrieves the player registered for <paramref name="animType"/>,
/// or null if no match exists.
/// </summary>
public IBattleAnimationPlayer Get(string animType)
{
_players.TryGetValue(animType, out var player);
return player;
}
}
}
+79 -79
View File
@@ -1,79 +1,79 @@
using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Default IBattleContext wired to the current BattleSceneContext.
/// ApplyDamage clamps at 0 (no negative health from defence overflow),
/// EndBattle delegates to ShowWinground/ShowDeadground on the scene
/// context. ScheduleAnimation and EndTurn are stubs — specific battle
/// cores are expected to override or supplement this behaviour.
/// </summary>
public class BattleContextImpl : IBattleContext
{
private readonly BattleSceneContext _sceneContext;
/// <summary>
/// Root Godot <see cref="Node"/> of the battle scene, used by
/// animation actions to parent spawned visual effects.
/// </summary>
public Node RootNode => _sceneContext;
/// <summary>
/// The owning <see cref="BattleSceneContext"/> that manages
/// the battle lifecycle.
/// </summary>
public BattleSceneContext SceneContext => _sceneContext;
/// <summary>
/// Creates a context adapter around the given scene context.
/// </summary>
public BattleContextImpl(BattleSceneContext sceneContext)
{
_sceneContext = sceneContext;
}
/// <summary>
/// Subtracts <paramref name="defence"/> from
/// <paramref name="rawDamage"/>, clamping at 0 to prevent negative
/// health from defence overflow, and applies the result to
/// <paramref name="target"/>'s health.
/// </summary>
public void ApplyDamage(Character source, Character target, int rawDamage, int defence)
{
var finalDamage = Mathf.Max(0, rawDamage - defence);
target.Attributes.Health.Value -= finalDamage;
}
/// <summary>
/// Stub — specific battle cores override this to enqueue
/// animation actions into the <see cref="BattleActionMachine"/>.
/// </summary>
public void ScheduleAnimation(IBattleAnimation animation)
{
}
/// <summary>
/// Stub — cores implement this to advance the turn sequence
/// when a side finishes its action phase.
/// </summary>
public void EndTurn(BattleSide side)
{
}
/// <summary>
/// Resolves the battle outcome by delegating to
/// <see cref="BattleSceneContext.ShowWinground"/> for player
/// victory or <see cref="BattleSceneContext.ShowDeadground"/>
/// for enemy victory.
/// </summary>
public void EndBattle(BattleSide winner)
{
if (winner == BattleSide.Player)
_sceneContext.ShowWinground();
else
_sceneContext.ShowDeadground();
}
}
}
using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Default IBattleContext wired to the current BattleSceneContext.
/// ApplyDamage clamps at 0 (no negative health from defence overflow),
/// EndBattle delegates to ShowWinground/ShowDeadground on the scene
/// context. ScheduleAnimation and EndTurn are stubs — specific battle
/// cores are expected to override or supplement this behaviour.
/// </summary>
public class BattleContextImpl : IBattleContext
{
private readonly BattleSceneContext _sceneContext;
/// <summary>
/// Root Godot <see cref="Node"/> of the battle scene, used by
/// animation actions to parent spawned visual effects.
/// </summary>
public Node RootNode => _sceneContext;
/// <summary>
/// The owning <see cref="BattleSceneContext"/> that manages
/// the battle lifecycle.
/// </summary>
public BattleSceneContext SceneContext => _sceneContext;
/// <summary>
/// Creates a context adapter around the given scene context.
/// </summary>
public BattleContextImpl(BattleSceneContext sceneContext)
{
_sceneContext = sceneContext;
}
/// <summary>
/// Subtracts <paramref name="defence"/> from
/// <paramref name="rawDamage"/>, clamping at 0 to prevent negative
/// health from defence overflow, and applies the result to
/// <paramref name="target"/>'s health.
/// </summary>
public void ApplyDamage(Character source, Character target, int rawDamage, int defence)
{
var finalDamage = Mathf.Max(0, rawDamage - defence);
target.Attributes.Health.Value -= finalDamage;
}
/// <summary>
/// Stub — specific battle cores override this to enqueue
/// animation actions into the <see cref="BattleActionMachine"/>.
/// </summary>
public void ScheduleAnimation(IBattleAnimation animation)
{
}
/// <summary>
/// Stub — cores implement this to advance the turn sequence
/// when a side finishes its action phase.
/// </summary>
public void EndTurn(BattleSide side)
{
}
/// <summary>
/// Resolves the battle outcome by delegating to
/// <see cref="BattleSceneContext.ShowWinground"/> for player
/// victory or <see cref="BattleSceneContext.ShowDeadground"/>
/// for enemy victory.
/// </summary>
public void EndBattle(BattleSide winner)
{
if (winner == BattleSide.Player)
_sceneContext.ShowWinground();
else
_sceneContext.ShowDeadground();
}
}
}
+33 -33
View File
@@ -1,33 +1,33 @@
using System.Collections.Generic;
using Cthangover.Core.Characters;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Describes a battle animation without binding to a concrete player.
/// Carries Source/Target characters, an AnimationType key for dispatch,
/// a Speed multiplier, and an open-ended Parameters dictionary so
/// custom animations can pass arbitrary data (damage value, element
/// type, etc.) without interface changes.
/// </summary>
public interface IBattleAnimation
{
/// <summary>The <see cref="Character"/> performing the action.</summary>
Character Source { get; }
/// <summary>The <see cref="Character"/> receiving the action.</summary>
Character Target { get; }
/// <summary>
/// Key used by <see cref="AnimationRegistry"/> to dispatch to the
/// correct <see cref="IBattleAnimationPlayer"/>.
/// </summary>
string AnimationType { get; }
/// <summary>Multiplier for the playback speed (1.0 = normal).</summary>
float Speed { get; }
/// <summary>
/// Open-ended data bag for animation-specific values
/// (e.g. damage amount, elemental flag) — no interface changes
/// needed when adding new animation types.
/// </summary>
Dictionary<string, object> Parameters { get; }
}
}
using System.Collections.Generic;
using Cthangover.Core.Characters;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Describes a battle animation without binding to a concrete player.
/// Carries Source/Target characters, an AnimationType key for dispatch,
/// a Speed multiplier, and an open-ended Parameters dictionary so
/// custom animations can pass arbitrary data (damage value, element
/// type, etc.) without interface changes.
/// </summary>
public interface IBattleAnimation
{
/// <summary>The <see cref="Character"/> performing the action.</summary>
Character Source { get; }
/// <summary>The <see cref="Character"/> receiving the action.</summary>
Character Target { get; }
/// <summary>
/// Key used by <see cref="AnimationRegistry"/> to dispatch to the
/// correct <see cref="IBattleAnimationPlayer"/>.
/// </summary>
string AnimationType { get; }
/// <summary>Multiplier for the playback speed (1.0 = normal).</summary>
float Speed { get; }
/// <summary>
/// Open-ended data bag for animation-specific values
/// (e.g. damage amount, elemental flag) — no interface changes
/// needed when adding new animation types.
/// </summary>
Dictionary<string, object> Parameters { get; }
}
}
+26 -26
View File
@@ -1,26 +1,26 @@
using System;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Plays a specific animation type. Each player is keyed by
/// AnimationType so the registry can dispatch animations without
/// knowing concrete player types. The onComplete callback chains
/// into the action machine's polling loop.
/// </summary>
public interface IBattleAnimationPlayer
{
/// <summary>
/// String key used by <see cref="AnimationRegistry"/> to select
/// this player for a given <see cref="IBattleAnimation.AnimationType"/>.
/// </summary>
string AnimationType { get; }
/// <summary>
/// Starts playing the animation described by <paramref name="anim"/>.
/// Must invoke <paramref name="onComplete"/> once the animation
/// finishes, so the action machine can advance to the next step.
/// </summary>
void Play(IBattleAnimation anim, Action onComplete);
}
}
using System;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Plays a specific animation type. Each player is keyed by
/// AnimationType so the registry can dispatch animations without
/// knowing concrete player types. The onComplete callback chains
/// into the action machine's polling loop.
/// </summary>
public interface IBattleAnimationPlayer
{
/// <summary>
/// String key used by <see cref="AnimationRegistry"/> to select
/// this player for a given <see cref="IBattleAnimation.AnimationType"/>.
/// </summary>
string AnimationType { get; }
/// <summary>
/// Starts playing the animation described by <paramref name="anim"/>.
/// Must invoke <paramref name="onComplete"/> once the animation
/// finishes, so the action machine can advance to the next step.
/// </summary>
void Play(IBattleAnimation anim, Action onComplete);
}
}
+27 -27
View File
@@ -1,27 +1,27 @@
using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Bridge from the battle core back to the Godot scene. Cores work
/// against this interface so they remain engine-agnostic.
/// ApplyDamage computes final-damage-minus-defence and mutates
/// the target's Health directly (no event — the UI layer polls).
/// ScheduleAnimation is the hook for visual feedback; EndTurn/EndBattle
/// are phase signals. RootNode gives cores a Godot anchor for
/// instantiating nodes (e.g. damage numbers).
/// </summary>
public interface IBattleContext
{
void ApplyDamage(Character source, Character target, int rawDamage, int defence);
void ScheduleAnimation(IBattleAnimation animation);
void EndTurn(BattleSide side);
void EndBattle(BattleSide winner);
Node RootNode { get; }
}
}
using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Bridge from the battle core back to the Godot scene. Cores work
/// against this interface so they remain engine-agnostic.
/// ApplyDamage computes final-damage-minus-defence and mutates
/// the target's Health directly (no event — the UI layer polls).
/// ScheduleAnimation is the hook for visual feedback; EndTurn/EndBattle
/// are phase signals. RootNode gives cores a Godot anchor for
/// instantiating nodes (e.g. damage numbers).
/// </summary>
public interface IBattleContext
{
void ApplyDamage(Character source, Character target, int rawDamage, int defence);
void ScheduleAnimation(IBattleAnimation animation);
void EndTurn(BattleSide side);
void EndBattle(BattleSide winner);
Node RootNode { get; }
}
}
+46 -46
View File
@@ -1,46 +1,46 @@
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Characters;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Pluggable battle engine contract. Each implementation is a
/// self-contained battle rule set (turn order, AI, phases).
/// Init receives the two character arrays and a context adapter;
/// Start begins the loop. The ActionProvider property lets the
/// core inject its own executor overrides via the hub.
/// Cores are instantiated fresh per battle — they carry no
/// persistent state between encounters.
/// </summary>
public interface IBattleCore
{
/// <summary>
/// Unique identifier for this battle engine (e.g. "card_battle",
/// "ff_battle"). Used by <see cref="BattleCoreRegistry"/> and
/// <see cref="BattleData.ActiveBattleCore"/> for lookup.
/// </summary>
string Id { get; }
/// <summary>
/// Provider that returns core-specific <see cref="IActionExecutor"/>
/// overrides. Set on the <see cref="ActionExecutorHub"/> when
/// this core activates, so actions route through custom logic
/// before falling back to the global registry.
/// </summary>
IActionExecutorProvider ActionProvider { get; }
/// <summary>
/// Initialises the core with the two character arrays and a
/// <see cref="IBattleContext"/> for callbacks. Called once per
/// battle before <see cref="Start"/>.
/// </summary>
void Init(Character[] playerChars, Character[] enemyChars, IBattleContext ctx);
/// <summary>
/// Begins the turn loop. Cores may use coroutines, polling, or
/// event-driven state machines — the core owns its own lifecycle
/// after this call.
/// </summary>
void Start();
}
}
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Characters;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Pluggable battle engine contract. Each implementation is a
/// self-contained battle rule set (turn order, AI, phases).
/// Init receives the two character arrays and a context adapter;
/// Start begins the loop. The ActionProvider property lets the
/// core inject its own executor overrides via the hub.
/// Cores are instantiated fresh per battle — they carry no
/// persistent state between encounters.
/// </summary>
public interface IBattleCore
{
/// <summary>
/// Unique identifier for this battle engine (e.g. "card_battle",
/// "ff_battle"). Used by <see cref="BattleCoreRegistry"/> and
/// <see cref="BattleData.ActiveBattleCore"/> for lookup.
/// </summary>
string Id { get; }
/// <summary>
/// Provider that returns core-specific <see cref="IActionExecutor"/>
/// overrides. Set on the <see cref="ActionExecutorHub"/> when
/// this core activates, so actions route through custom logic
/// before falling back to the global registry.
/// </summary>
IActionExecutorProvider ActionProvider { get; }
/// <summary>
/// Initialises the core with the two character arrays and a
/// <see cref="IBattleContext"/> for callbacks. Called once per
/// battle before <see cref="Start"/>.
/// </summary>
void Init(Character[] playerChars, Character[] enemyChars, IBattleContext ctx);
/// <summary>
/// Begins the turn loop. Cores may use coroutines, polling, or
/// event-driven state machines — the core owns its own lifecycle
/// after this call.
/// </summary>
void Start();
}
}
+15 -15
View File
@@ -1,15 +1,15 @@
namespace Cthangover.Core.Battle
{
/// <summary>
/// Identifies which side of the battle a character or turn belongs to.
/// Used by IBattleContext.EndTurn and EndBattle to route results
/// (victory vs defeat screen).
/// </summary>
public enum BattleSide
{
/// <summary>The player's party.</summary>
Player,
/// <summary>The opposing force (monsters, NPCs).</summary>
Enemy,
}
}
namespace Cthangover.Core.Battle
{
/// <summary>
/// Identifies which side of the battle a character or turn belongs to.
/// Used by IBattleContext.EndTurn and EndBattle to route results
/// (victory vs defeat screen).
/// </summary>
public enum BattleSide
{
/// <summary>The player's party.</summary>
Player,
/// <summary>The opposing force (monsters, NPCs).</summary>
Enemy,
}
}
+62 -62
View File
@@ -1,62 +1,62 @@
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Characters
{
/// <summary>
/// Convenience base for IActionExecutor implementations. Provides
/// CheckRequiredAndUsePoint which validates that the user has enough
/// "Point" attribute (a generic action-point / mana pool) before
/// deducting it — returning false lets the action fail gracefully.
/// ID is abstract so each action declares its own identity.
/// </summary>
public abstract class ActionBase : IActionExecutor
{
/// <summary>
/// Maps to the abstract <see cref="ID"/> so the hub can resolve
/// this executor by action name.
/// </summary>
public string ActionId => ID;
/// <summary>
/// Unique action identifier that matches
/// <see cref="ActionCharacter.ID"/> in data.
/// </summary>
public abstract string ID { get; }
/// <summary>
/// Applies the action's effects to <paramref name="target"/>
/// using <paramref name="user"/>'s stats and the
/// <paramref name="action"/> definition. Returns stat deltas
/// and a success/failure flag.
/// </summary>
public abstract ChangedAttributes Execute(ActionCharacter action, Character user, Character target);
/// <summary>
/// Validates that <paramref name="user"/> has at least
/// <c>"RequiredPoint"</c> remaining, deducts it, and
/// returns <c>true</c>. Returns <c>false</c> with a warning log
/// if the user lacks sufficient points, allowing the action to
/// fail gracefully.
/// </summary>
protected bool CheckRequiredAndUsePoint(ActionCharacter action, Character user)
{
var requiredPoint = action.GetInt("RequiredPoint");
if (user.Attributes.Point.Value < requiredPoint)
{
GameLogger.Log("BATTLE", user.ID + " not checked point required [" + user.Attributes.Point.Value + "] for action " + action.ID + " [" + requiredPoint + "]", LogLevel.Warning);
return false;
}
GameLogger.Log("BATTLE", user.ID + " use [" + requiredPoint + "] points for action " + action.ID, LogLevel.Debug);
user.Attributes.Point.Value -= requiredPoint;
return true;
}
}
}
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Characters
{
/// <summary>
/// Convenience base for IActionExecutor implementations. Provides
/// CheckRequiredAndUsePoint which validates that the user has enough
/// "Point" attribute (a generic action-point / mana pool) before
/// deducting it — returning false lets the action fail gracefully.
/// ID is abstract so each action declares its own identity.
/// </summary>
public abstract class ActionBase : IActionExecutor
{
/// <summary>
/// Maps to the abstract <see cref="ID"/> so the hub can resolve
/// this executor by action name.
/// </summary>
public string ActionId => ID;
/// <summary>
/// Unique action identifier that matches
/// <see cref="ActionCharacter.ID"/> in data.
/// </summary>
public abstract string ID { get; }
/// <summary>
/// Applies the action's effects to <paramref name="target"/>
/// using <paramref name="user"/>'s stats and the
/// <paramref name="action"/> definition. Returns stat deltas
/// and a success/failure flag.
/// </summary>
public abstract ChangedAttributes Execute(ActionCharacter action, Character user, Character target);
/// <summary>
/// Validates that <paramref name="user"/> has at least
/// <c>"RequiredPoint"</c> remaining, deducts it, and
/// returns <c>true</c>. Returns <c>false</c> with a warning log
/// if the user lacks sufficient points, allowing the action to
/// fail gracefully.
/// </summary>
protected bool CheckRequiredAndUsePoint(ActionCharacter action, Character user)
{
var requiredPoint = action.GetInt("RequiredPoint");
if (user.Attributes.Point.Value < requiredPoint)
{
GameLogger.Log("BATTLE", user.ID + " not checked point required [" + user.Attributes.Point.Value + "] for action " + action.ID + " [" + requiredPoint + "]", LogLevel.Warning);
return false;
}
GameLogger.Log("BATTLE", user.ID + " use [" + requiredPoint + "] points for action " + action.ID, LogLevel.Debug);
user.Attributes.Point.Value -= requiredPoint;
return true;
}
}
}
+98 -98
View File
@@ -1,98 +1,98 @@
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Battle;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Quests;
using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.Core.Settings
{
/// <summary>
/// Immutable descriptor for a battle encounter, constructed via the
/// static factory <see cref="InitBattle"/> overloads. Holds the
/// background texture, enemy roster, quest reference, card order side,
/// and the scene to return to when the battle ends. The private
/// constructor forces creation through the factories so that all
/// required data is supplied upfront.
/// </summary>
public class BattleData
{
/// <summary>The quest that triggered this battle; may be <c>null</c>
/// for non-quest skirmishes.</summary>
public QuestBase Quest { get; set; }
/// <summary>Auxiliary tag set on new characters acquired via recruitment
/// after this battle.</summary>
public string NewTag { get; set; }
/// <summary>List of enemy <see cref="Character"/> instances spawned from
/// the provided IDs or default templates.</summary>
public List<Character> EnemiesCards { get; private set; }
/// <summary>Battle background texture (full-resolution).</summary>
public Texture2D Background { get; private set; }
/// <summary>Depth map texture for the parallax post-process effect.</summary>
public Texture2D DepthMap { get; set; }
/// <summary>Albedo/colour map texture for the parallax post-process effect.</summary>
public Texture2D AlbedoMap { get; set; }
/// <summary>Which side of the screen the player's cards are drawn on.</summary>
public BattleSide BattleSide { get; private set; } = BattleSide.Player;
/// <summary>Identifier for the active battle core logic module.</summary>
public string ActiveBattleCore { get; set; }
/// <summary>Scene name to transition to when the battle finishes or is
/// fled. Stored as a string to avoid type coupling.</summary>
public string ReturnScene { get; private set; }
private BattleData()
{ }
/// <summary>
/// Convenience overload that defaults <see cref="BattleSide"/> to
/// <c>BattleSide.Player</c>. Constructs enemies from their string IDs
/// via <see cref="Cthangover.Core.Factories.Impls.CharacterFactory"/>,
/// falling back to a level-1 template when the ID is unknown.
/// </summary>
public static BattleData InitBattle(Texture2D background, string returnScene, params string[] enemies)
{
return InitBattle(background, returnScene, BattleSide.Player, enemies);
}
/// <summary>
/// Creates a battle with a specified <see cref="BattleSide"/> and
/// enemy IDs. Each ID is resolved through
/// <see cref="Cthangover.Core.Factories.Impls.CharacterFactory.Get"/>.
/// Unknown IDs produce a fallback <see cref="Character"/> with 20 HP
/// and 3 action points.
/// </summary>
public static BattleData InitBattle(Texture2D background, string returnScene, BattleSide cardOrder = BattleSide.Player, params string[] enemies)
{
var list = enemies.Select(o => CharacterFactory.Instance.Get(o) ?? new Character
{
ID = o,
Attributes = new CharacterAttributes
{
Health = new Attribute { Value = 20, BaseValue = 20 },
Point = new Attribute { Value = 3, BaseValue = 3 },
},
Level = 1,
Exp = 10,
}).ToList();
return InitBattle(background, returnScene, list, cardOrder);
}
/// <summary>
/// Core factory that directly accepts a pre-built
/// <see cref="List{Character}"/> for the enemy roster. This is the
/// overload that all other <c>InitBattle</c> variants delegate to.
/// </summary>
public static BattleData InitBattle(Texture2D background, string returnScene, List<Character> enemiesCards, BattleSide cardOrder = BattleSide.Player)
{
return new BattleData
{
Background = background,
ReturnScene = returnScene,
EnemiesCards = enemiesCards,
BattleSide = cardOrder,
};
}
}
}
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Battle;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Quests;
using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.Core.Settings
{
/// <summary>
/// Immutable descriptor for a battle encounter, constructed via the
/// static factory <see cref="InitBattle"/> overloads. Holds the
/// background texture, enemy roster, quest reference, card order side,
/// and the scene to return to when the battle ends. The private
/// constructor forces creation through the factories so that all
/// required data is supplied upfront.
/// </summary>
public class BattleData
{
/// <summary>The quest that triggered this battle; may be <c>null</c>
/// for non-quest skirmishes.</summary>
public QuestBase Quest { get; set; }
/// <summary>Auxiliary tag set on new characters acquired via recruitment
/// after this battle.</summary>
public string NewTag { get; set; }
/// <summary>List of enemy <see cref="Character"/> instances spawned from
/// the provided IDs or default templates.</summary>
public List<Character> EnemiesCards { get; private set; }
/// <summary>Battle background texture (full-resolution).</summary>
public Texture2D Background { get; private set; }
/// <summary>Depth map texture for the parallax post-process effect.</summary>
public Texture2D DepthMap { get; set; }
/// <summary>Albedo/colour map texture for the parallax post-process effect.</summary>
public Texture2D AlbedoMap { get; set; }
/// <summary>Which side of the screen the player's cards are drawn on.</summary>
public BattleSide BattleSide { get; private set; } = BattleSide.Player;
/// <summary>Identifier for the active battle core logic module.</summary>
public string ActiveBattleCore { get; set; }
/// <summary>Scene name to transition to when the battle finishes or is
/// fled. Stored as a string to avoid type coupling.</summary>
public string ReturnScene { get; private set; }
private BattleData()
{ }
/// <summary>
/// Convenience overload that defaults <see cref="BattleSide"/> to
/// <c>BattleSide.Player</c>. Constructs enemies from their string IDs
/// via <see cref="Cthangover.Core.Factories.Impls.CharacterFactory"/>,
/// falling back to a level-1 template when the ID is unknown.
/// </summary>
public static BattleData InitBattle(Texture2D background, string returnScene, params string[] enemies)
{
return InitBattle(background, returnScene, BattleSide.Player, enemies);
}
/// <summary>
/// Creates a battle with a specified <see cref="BattleSide"/> and
/// enemy IDs. Each ID is resolved through
/// <see cref="Cthangover.Core.Factories.Impls.CharacterFactory.Get"/>.
/// Unknown IDs produce a fallback <see cref="Character"/> with 20 HP
/// and 3 action points.
/// </summary>
public static BattleData InitBattle(Texture2D background, string returnScene, BattleSide cardOrder = BattleSide.Player, params string[] enemies)
{
var list = enemies.Select(o => CharacterFactory.Instance.Get(o) ?? new Character
{
ID = o,
Attributes = new CharacterAttributes
{
Health = new Attribute { Value = 20, BaseValue = 20 },
Point = new Attribute { Value = 3, BaseValue = 3 },
},
Level = 1,
Exp = 10,
}).ToList();
return InitBattle(background, returnScene, list, cardOrder);
}
/// <summary>
/// Core factory that directly accepts a pre-built
/// <see cref="List{Character}"/> for the enemy roster. This is the
/// overload that all other <c>InitBattle</c> variants delegate to.
/// </summary>
public static BattleData InitBattle(Texture2D background, string returnScene, List<Character> enemiesCards, BattleSide cardOrder = BattleSide.Player)
{
return new BattleData
{
Background = background,
ReturnScene = returnScene,
EnemiesCards = enemiesCards,
BattleSide = cardOrder,
};
}
}
}
+366 -360
View File
@@ -1,362 +1,368 @@
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.Mods.Time;
using Cthangover.Core.Characters;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Utils;
using Godot;
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.Mods.Time;
using Cthangover.Core.Characters;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Mods.Interface.Base;
using Cthangover.Mods.Interface.Base.Lists;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Singleton scene-root for battle, wired as a self-registering Node
/// (sets Instance on enter-tree, clears on exit-tree). On ready it
/// assembles player characters from CharacterData.BattleSet, enemy
/// cards from the pending <see cref="BattleRequest"/>, resolves the active
/// <see cref="IBattleCore"/> via <see cref="BattleCoreRegistry"/>, and defers
/// Init+Start to the next frame so the scene tree is fully built before core
/// logic runs. Tracks PlayerExpGained (per-character, split evenly among
/// survivors) and DefeatedEnemies for the victory/loot screen. Exposes
/// OnCharacterDied and OnBattleCleared events so external systems (e.g.
/// quests, the action machine) can react without coupling.
/// ShowWinground / ShowDeadground are mutually exclusive end-of-battle
/// paths — both set IsDestroyed to prevent double-triggering and
/// reset battle speed before clearing state.
/// <para/>
/// The context derives from <see cref="ModNode"/> because mod assemblies get
/// no Godot source generation: the core base receives the engine lifecycle
/// callbacks and forwards them to the <c>OnXxx</c> overrides. It also
/// implements <see cref="IActiveCharacterProvider"/> so core UI (the inventory
/// panel's action-point row) can read the acting character without knowing
/// anything about battle.
/// </summary>
public partial class BattleSceneContext : ModNode, IActiveCharacterProvider
{
/// <summary>
/// Singleton reference to the active battle scene context.
/// Set on enter-tree, cleared on exit-tree.
/// External systems read this to access battle state.
/// </summary>
public static BattleSceneContext Instance { get; private set; }
private TextureRect battleBg;
/// <summary>
/// The <see cref="Actions.BattleActionMachine"/> node that
/// processes the animation queue for this battle. Cores push
/// animation actions through the context and the machine drives
/// playback frame-by-frame.
/// </summary>
public Actions.BattleActionMachine BattleActionMachine { get; private set; }
private IBattleSpeedControl _battleSpeedControl;
private Widget deadground;
private Widget winground;
/// <summary>
/// Per-character EXP earned during this battle, keyed by
/// <see cref="Character.ID"/>. Populated by
/// <see cref="RecordEnemyDefeated"/>; consumed by the victory screen
/// provided by the turn-battle GUI mod.
/// </summary>
public Dictionary<string, int> PlayerExpGained { get; private set; } = new();
/// <summary>
/// Enemies defeated during this battle (for loot generation
/// and recruitment rolls).
/// </summary>
public List<Character> DefeatedEnemies { get; private set; } = new();
/// <summary>
/// Guards against double-triggering end-of-battle paths.
/// Set to <c>true</c> by <see cref="ShowWinground"/> or
/// <see cref="ShowDeadground"/>.
/// </summary>
public bool IsDestroyed { get; private set; }
/// <summary>
/// When <c>true</c>, the battle core pauses processing (e.g.
/// while a UI overlay is open). Set externally by pause widgets.
/// </summary>
public bool IsWait { get; set; }
/// <summary>
/// The <see cref="Character"/> whose turn is currently active
/// in battle. Set by the battle core when a character is selected
/// and cleared when the turn ends or selection is cancelled.
/// <c>null</c> means no character is active (out of battle,
/// between turns, or idle state). External panels such as the
/// inventory panel read this to check whether the active
/// character has enough action points before performing
/// item-related actions.
/// </summary>
public Character ActiveCharacter { get; set; }
/// <summary>Fired when a character's health reaches 0.</summary>
public event System.Action<Character> OnCharacterDied;
/// <summary>
/// Fired after battle state is cleared (both victory and defeat).
/// External systems can hook this to release battle resources.
/// </summary>
public event System.Action OnBattleCleared;
/// <summary>
/// Invokes <see cref="OnCharacterDied"/> to notify listeners
/// (quests, achievements, the battle core) that a character
/// has been defeated.
/// </summary>
public void NotifyCharacterDied(Character character)
{
OnCharacterDied?.Invoke(character);
ModEventBus.Publish(new CharacterDiedEvent
{
CharacterId = character?.ID,
CharacterName = character?.Name,
Source = "battle",
});
}
/// <summary>Claims the scene singleton and registers as the <see cref="ActiveCharacterRegistry"/> provider.</summary>
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;
}
/// <summary>Releases the singleton slot and the active-character provider so a later scene can claim them.</summary>
protected override void OnExitTree()
{
base.OnExitTree();
if (Instance == this)
Instance = null;
if (ReferenceEquals(ActiveCharacterRegistry.Provider, this))
ActiveCharacterRegistry.Provider = null;
}
private List<Character> pendingPlayerCards;
private List<Character> pendingEnemyCards;
private IBattleCore activeBattleCore;
/// <summary>
/// The battle core driving this scene, resolved from
/// <see cref="BattleCoreRegistry"/> when the scene becomes ready. Exposed
/// so tooling and tests can query the active core (for example for
/// <see cref="IBattleAutoPlayable"/>) without reaching into UI nodes.
/// </summary>
public IBattleCore ActiveBattleCore => activeBattleCore;
/// <summary>Caches the battle backdrop node, resolves the active <see cref="IBattleCore"/> and starts the battle.</summary>
protected override void OnReady()
{
battleBg = GetNodeOrNull<TextureRect>("BattleBg");
if (battleBg == null)
{
GameLogger.Log("BATTLE", "battleBg not found!", LogLevel.Error);
return;
}
BattleActionMachine = GetNodeOrNull<Actions.BattleActionMachine>("ToolPanel/BattleActionMachine");
_battleSpeedControl = GetNodeOrNull<Control>("BattleSpeedControl") as IBattleSpeedControl;
deadground = GetNodeOrNull<Widget>("Deadground");
winground = GetNodeOrNull<Widget>("Winground");
var data = BattleRequest.Current;
if (data == null)
{
GameLogger.Log("BATTLE", "BattleRequest.Current is null!", LogLevel.Error);
return;
}
GameLogger.Log("BATTLE", $"set battle background: battleBg={(battleBg != null ? "found" : "NULL")}, texture={(data.Background != null ? "has" : "NULL")}", battleBg == null || data.Background == null ? LogLevel.Error : LogLevel.Debug);
if (data.Background != null)
{
battleBg.Texture = data.Background;
GameLogger.Log("BATTLE", $" battleBg.Texture assigned: width={battleBg.Texture.GetWidth()}, height={battleBg.Texture.GetHeight()}", LogLevel.Debug);
}
else
{
GameLogger.Log("BATTLE", $" data.Background is NULL — no background texture set", LogLevel.Error);
}
var timedShader = Shaders.Resolve("timed_sprite");
if (timedShader != null)
{
var bgMaterial = new ShaderMaterial { Shader = timedShader };
battleBg.Material = bgMaterial;
if (data.DepthMap != null)
bgMaterial.SetShaderParameter("depth_mask", data.DepthMap);
if (data.AlbedoMap != null)
bgMaterial.SetShaderParameter("albedo_map", data.AlbedoMap);
var lights = GetNodeOrNull<UiLightController>("Lights");
if (lights != null)
{
var viewportSize = GetViewport().GetVisibleRect().Size;
lights.SetPlayerLight(viewportSize / 2, 500f, 1f);
lights.RegisterMaterial(bgMaterial);
}
GameLogger.Log("BATTLE", $"battleBg shader: timed_sprite applied, depthMap={(data.DepthMap != null ? "has" : "NULL")}, albedoMap={(data.AlbedoMap != null ? "has" : "NULL")}, lightsCtrl={(lights != null ? "found" : "NULL")}", lights == null ? LogLevel.Error : LogLevel.Debug);
}
else
{
GameLogger.Log("BATTLE", "battleBg shader: timed_sprite NOT FOUND", LogLevel.Error);
}
pendingPlayerCards = new List<Character>();
foreach (var characterType in GameData.Instance.Runtime.CharacterData.BattleSet)
{
var characterInfo = GameData.Instance.Runtime.CharacterData.Characters[characterType];
var template = CharacterFactory.Instance.Get(characterInfo.ID);
var character = template != null ? template.Copy() : new Character { ID = characterInfo.ID };
character.Attributes = characterInfo.Attributes ?? new CharacterAttributes();
character.Level = characterInfo.Level;
character.Exp = characterInfo.Exp;
if (characterInfo.ActionSlots != null && characterInfo.ActionSlots.Any(id => !string.IsNullOrEmpty(id)))
{
character.Actions = characterInfo.ActionSlots
.Where(id => !string.IsNullOrEmpty(id))
.Select(id => ActionCharacterFactory.Instance.Get(id))
.Where(a => a != null)
.ToList();
}
pendingPlayerCards.Add(character);
}
pendingEnemyCards = data.EnemiesCards?.Where(c => c != null).ToList() ?? new List<Character>();
if (!string.IsNullOrEmpty(data.ActiveBattleCore) && BattleCoreRegistry.Instance.HasCore(data.ActiveBattleCore))
BattleCoreRegistry.Instance.SetActive(data.ActiveBattleCore);
try
{
activeBattleCore = BattleCoreRegistry.Instance.GetActive();
}
catch
{
activeBattleCore = null;
}
if (activeBattleCore == null)
{
GameLogger.Log("BATTLE", "No battle core available", LogLevel.Error);
return;
}
Callable.From(DeferredInit).CallDeferred();
}
private void DeferredInit()
{
if (IsDestroyed || activeBattleCore == null)
return;
var ctx = new BattleContextImpl(this);
var playerArray = pendingPlayerCards?.ToArray() ?? new Character[0];
var enemyArray = pendingEnemyCards?.ToArray() ?? new Character[0];
activeBattleCore.Init(playerArray, enemyArray, ctx);
activeBattleCore.Start();
ModEventBus.Publish(new BattleStartedEvent
{
BattleType = activeBattleCore.GetType().Name,
Enemies = enemyArray.Select(c => c.Name).ToArray(),
});
}
/// <summary>
/// Triggers the defeat screen: sets <see cref="IsDestroyed"/>,
/// resets battle speed, clears battle state, and shows the
/// deadground widget.
/// </summary>
public void ShowDeadground()
{
if (IsDestroyed)
return;
IsDestroyed = true;
_battleSpeedControl?.ResetToNormal();
ClearBattle();
deadground?.Show();
}
/// <summary>
/// Triggers the victory screen: sets <see cref="IsDestroyed"/>,
/// resets battle speed, clears battle state, and shows the
/// winground widget.
/// </summary>
public void ShowWinground()
{
if (IsDestroyed)
return;
IsDestroyed = true;
_battleSpeedControl?.ResetToNormal();
ClearBattle();
winground?.Show();
}
/// <summary>
/// Records a defeated enemy: adds it to
/// <see cref="DefeatedEnemies"/> and distributes its EXP
/// (<c>Level * Exp</c>) equally among all surviving player
/// characters in <see cref="PlayerExpGained"/>.
/// </summary>
public void RecordEnemyDefeated(Character enemy)
{
if (enemy == null)
return;
DefeatedEnemies.Add(enemy);
var enemyExp = enemy.Level * enemy.Exp;
var alivePlayers = 0;
foreach (var c in pendingPlayerCards)
{
if (c?.Attributes?.Health?.Value > 0)
alivePlayers++;
}
if (alivePlayers == 0)
return;
var expPerPlayer = enemyExp / alivePlayers;
foreach (var c in pendingPlayerCards)
{
if (c?.Attributes?.Health?.Value <= 0)
continue;
var id = c.ID;
if (id == null)
continue;
PlayerExpGained.TryGetValue(id, out var current);
PlayerExpGained[id] = current + expPerPlayer;
}
}
private void ClearBattle()
{
BattleActionMachine?.StopMachine();
ActiveCharacter = null;
pendingPlayerCards?.Clear();
pendingEnemyCards?.Clear();
OnBattleCleared?.Invoke();
ModEventBus.Publish(new BattleEndedEvent
{
Result = IsDestroyed ? "defeat" : "victory",
});
}
}
}
namespace Cthangover.Core.Battle
{
/// <summary>
/// Singleton scene-root for battle, wired as a self-registering Node
/// (sets Instance on enter-tree, clears on exit-tree). On ready it
/// assembles player characters from CharacterData.BattleSet, enemy
/// cards from the pending <see cref="BattleRequest"/>, resolves the active
/// <see cref="IBattleCore"/> via <see cref="BattleCoreRegistry"/>, and defers
/// Init+Start to the next frame so the scene tree is fully built before core
/// logic runs. Tracks PlayerExpGained (per-character, split evenly among
/// survivors) and DefeatedEnemies for the victory/loot screen. Exposes
/// OnCharacterDied and OnBattleCleared events so external systems (e.g.
/// quests, the action machine) can react without coupling.
/// ShowWinground / ShowDeadground are mutually exclusive end-of-battle
/// paths — both set IsDestroyed to prevent double-triggering and
/// reset battle speed before clearing state.
/// <para/>
/// The context derives from <see cref="ModNode"/> because mod assemblies get
/// no Godot source generation: the core base receives the engine lifecycle
/// callbacks and forwards them to the <c>OnXxx</c> overrides. It also
/// implements <see cref="IActiveCharacterProvider"/> so core UI (the inventory
/// panel's action-point row) can read the acting character without knowing
/// anything about battle.
/// </summary>
public partial class BattleSceneContext : ModNode, IActiveCharacterProvider
{
/// <summary>
/// Singleton reference to the active battle scene context.
/// Set on enter-tree, cleared on exit-tree.
/// External systems read this to access battle state.
/// </summary>
public static BattleSceneContext Instance { get; private set; }
private TextureRect battleBg;
/// <summary>
/// The <see cref="Actions.BattleActionMachine"/> node that
/// processes the animation queue for this battle. Cores push
/// animation actions through the context and the machine drives
/// playback frame-by-frame.
/// </summary>
public Actions.BattleActionMachine BattleActionMachine { get; private set; }
private IBattleSpeedControl _battleSpeedControl;
private Widget deadground;
private Widget winground;
/// <summary>
/// Per-character EXP earned during this battle, keyed by
/// <see cref="Character.ID"/>. Populated by
/// <see cref="RecordEnemyDefeated"/>; consumed by the victory screen
/// provided by the turn-battle GUI mod.
/// </summary>
public Dictionary<string, int> PlayerExpGained { get; private set; } = new();
/// <summary>
/// Enemies defeated during this battle (for loot generation
/// and recruitment rolls).
/// </summary>
public List<Character> DefeatedEnemies { get; private set; } = new();
/// <summary>
/// Guards against double-triggering end-of-battle paths.
/// Set to <c>true</c> by <see cref="ShowWinground"/> or
/// <see cref="ShowDeadground"/>.
/// </summary>
public bool IsDestroyed { get; private set; }
/// <summary>
/// When <c>true</c>, the battle core pauses processing (e.g.
/// while a UI overlay is open). Set externally by pause widgets.
/// </summary>
public bool IsWait { get; set; }
/// <summary>
/// The <see cref="Character"/> whose turn is currently active
/// in battle. Set by the battle core when a character is selected
/// and cleared when the turn ends or selection is cancelled.
/// <c>null</c> means no character is active (out of battle,
/// between turns, or idle state). External panels such as the
/// inventory panel read this to check whether the active
/// character has enough action points before performing
/// item-related actions.
/// </summary>
public Character ActiveCharacter { get; set; }
/// <summary>Fired when a character's health reaches 0.</summary>
public event System.Action<Character> OnCharacterDied;
/// <summary>
/// Fired after battle state is cleared (both victory and defeat).
/// External systems can hook this to release battle resources.
/// </summary>
public event System.Action OnBattleCleared;
/// <summary>
/// Invokes <see cref="OnCharacterDied"/> to notify listeners
/// (quests, achievements, the battle core) that a character
/// has been defeated.
/// </summary>
public void NotifyCharacterDied(Character character)
{
OnCharacterDied?.Invoke(character);
ModEventBus.Publish(new CharacterDiedEvent
{
CharacterId = character?.ID,
CharacterName = character?.Name,
Source = "battle",
});
}
/// <summary>Claims the scene singleton and registers as the <see cref="ActiveCharacterRegistry"/> provider.</summary>
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;
}
/// <summary>Releases the singleton slot and the active-character provider so a later scene can claim them.</summary>
protected override void OnExitTree()
{
base.OnExitTree();
if (Instance == this)
Instance = null;
if (ReferenceEquals(ActiveCharacterRegistry.Provider, this))
ActiveCharacterRegistry.Provider = null;
}
private List<Character> pendingPlayerCards;
private List<Character> pendingEnemyCards;
private IBattleCore activeBattleCore;
/// <summary>
/// The battle core driving this scene, resolved from
/// <see cref="BattleCoreRegistry"/> when the scene becomes ready. Exposed
/// so tooling and tests can query the active core (for example for
/// <see cref="IBattleAutoPlayable"/>) without reaching into UI nodes.
/// </summary>
public IBattleCore ActiveBattleCore => activeBattleCore;
/// <summary>Caches the battle backdrop node, resolves the active <see cref="IBattleCore"/> and starts the battle.</summary>
protected override void OnReady()
{
battleBg = GetNodeOrNull<TextureRect>("BattleBg");
if (battleBg == null)
{
GameLogger.Log("BATTLE", "battleBg not found!", LogLevel.Error);
return;
}
BattleActionMachine = GetNodeOrNull<Actions.BattleActionMachine>("ToolPanel/BattleActionMachine");
_battleSpeedControl = GetNodeOrNull<Control>("BattleSpeedControl") as IBattleSpeedControl;
deadground = GetNodeOrNull<Widget>("Deadground");
winground = GetNodeOrNull<Widget>("Winground");
var data = BattleRequest.Current;
if (data == null)
{
GameLogger.Log("BATTLE", "BattleRequest.Current is null!", LogLevel.Error);
return;
}
GameLogger.Log("BATTLE", $"set battle background: battleBg={(battleBg != null ? "found" : "NULL")}, texture={(data.Background != null ? "has" : "NULL")}", battleBg == null || data.Background == null ? LogLevel.Error : LogLevel.Debug);
if (data.Background != null)
{
battleBg.Texture = data.Background;
GameLogger.Log("BATTLE", $" battleBg.Texture assigned: width={battleBg.Texture.GetWidth()}, height={battleBg.Texture.GetHeight()}", LogLevel.Debug);
}
else
{
GameLogger.Log("BATTLE", $" data.Background is NULL — no background texture set", LogLevel.Error);
}
var timedShader = Shaders.Resolve("timed_sprite");
if (timedShader != null)
{
var bgMaterial = new ShaderMaterial { Shader = timedShader };
battleBg.Material = bgMaterial;
if (data.DepthMap != null)
bgMaterial.SetShaderParameter("depth_mask", data.DepthMap);
if (data.AlbedoMap != null)
bgMaterial.SetShaderParameter("albedo_map", data.AlbedoMap);
var lights = GetNodeOrNull<UiLightController>("Lights");
if (lights != null)
{
var viewportSize = GetViewport().GetVisibleRect().Size;
lights.SetPlayerLight(viewportSize / 2, 500f, 1f);
lights.RegisterMaterial(bgMaterial);
}
GameLogger.Log("BATTLE", $"battleBg shader: timed_sprite applied, depthMap={(data.DepthMap != null ? "has" : "NULL")}, albedoMap={(data.AlbedoMap != null ? "has" : "NULL")}, lightsCtrl={(lights != null ? "found" : "NULL")}", lights == null ? LogLevel.Error : LogLevel.Debug);
}
else
{
GameLogger.Log("BATTLE", "battleBg shader: timed_sprite NOT FOUND", LogLevel.Error);
}
pendingPlayerCards = new List<Character>();
foreach (var characterType in GameData.Instance.Runtime.CharacterData.BattleSet)
{
var characterInfo = GameData.Instance.Runtime.CharacterData.Characters[characterType];
var template = CharacterFactory.Instance.Get(characterInfo.ID);
var character = template != null ? template.Copy() : new Character { ID = characterInfo.ID };
character.Attributes = characterInfo.Attributes ?? new CharacterAttributes();
character.Level = characterInfo.Level;
character.Exp = characterInfo.Exp;
if (characterInfo.ActionSlots != null && characterInfo.ActionSlots.Any(id => !string.IsNullOrEmpty(id)))
{
character.Actions = characterInfo.ActionSlots
.Where(id => !string.IsNullOrEmpty(id))
.Select(id => ActionCharacterFactory.Instance.Get(id))
.Where(a => a != null)
.ToList();
}
pendingPlayerCards.Add(character);
}
pendingEnemyCards = data.EnemiesCards?.Where(c => c != null).ToList() ?? new List<Character>();
if (!string.IsNullOrEmpty(data.ActiveBattleCore) && BattleCoreRegistry.Instance.HasCore(data.ActiveBattleCore))
BattleCoreRegistry.Instance.SetActive(data.ActiveBattleCore);
try
{
activeBattleCore = BattleCoreRegistry.Instance.GetActive();
}
catch
{
activeBattleCore = null;
}
if (activeBattleCore == null)
{
GameLogger.Log("BATTLE", "No battle core available", LogLevel.Error);
return;
}
Callable.From(DeferredInit).CallDeferred();
}
private void DeferredInit()
{
if (IsDestroyed || activeBattleCore == null)
return;
var ctx = new BattleContextImpl(this);
var playerArray = pendingPlayerCards?.ToArray() ?? new Character[0];
var enemyArray = pendingEnemyCards?.ToArray() ?? new Character[0];
activeBattleCore.Init(playerArray, enemyArray, ctx);
activeBattleCore.Start();
ModEventBus.Publish(new BattleStartedEvent
{
BattleType = activeBattleCore.GetType().Name,
Enemies = enemyArray.Select(c => c.Name).ToArray(),
});
}
/// <summary>
/// Triggers the defeat screen: sets <see cref="IsDestroyed"/>,
/// resets battle speed, clears battle state, and shows the
/// deadground widget.
/// </summary>
public void ShowDeadground()
{
if (IsDestroyed)
return;
IsDestroyed = true;
_battleSpeedControl?.ResetToNormal();
ClearBattle("defeat");
deadground?.Show();
}
/// <summary>
/// Triggers the victory screen: sets <see cref="IsDestroyed"/>,
/// resets battle speed, clears battle state, and shows the
/// winground widget.
/// </summary>
public void ShowWinground()
{
if (IsDestroyed)
return;
IsDestroyed = true;
_battleSpeedControl?.ResetToNormal();
ClearBattle("victory");
winground?.Show();
}
/// <summary>
/// Records a defeated enemy: adds it to
/// <see cref="DefeatedEnemies"/> and distributes its EXP
/// (<c>Level * Exp</c>) equally among all surviving player
/// characters in <see cref="PlayerExpGained"/>.
/// </summary>
public void RecordEnemyDefeated(Character enemy)
{
if (enemy == null)
return;
DefeatedEnemies.Add(enemy);
var enemyExp = enemy.Level * enemy.Exp;
var alivePlayers = 0;
foreach (var c in pendingPlayerCards)
{
if (c?.Attributes?.Health?.Value > 0)
alivePlayers++;
}
if (alivePlayers == 0)
return;
var expPerPlayer = enemyExp / alivePlayers;
foreach (var c in pendingPlayerCards)
{
if (c?.Attributes?.Health?.Value <= 0)
continue;
var id = c.ID;
if (id == null)
continue;
PlayerExpGained.TryGetValue(id, out var current);
PlayerExpGained[id] = current + expPerPlayer;
}
}
/// <summary>
/// Stops the battle and publishes <see cref="BattleEndedEvent"/>. The
/// outcome is passed in by the caller: both screens set
/// <see cref="IsDestroyed"/> before clearing, so it cannot be derived
/// from that flag (it used to be, and every battle reported "defeat").
/// </summary>
private void ClearBattle(string result)
{
BattleActionMachine?.StopMachine();
ActiveCharacter = null;
pendingPlayerCards?.Clear();
pendingEnemyCards?.Clear();
OnBattleCleared?.Invoke();
ModEventBus.Publish(new BattleEndedEvent
{
Result = result,
});
}
}
}
+39 -39
View File
@@ -1,40 +1,40 @@
using Cthangover.Core.Actions;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Selects which battle core (combat ruleset) the next battle will use.
/// The "core" variable maps to a <see cref="BattleCoreRegistry"/> entry by
/// string ID. Must be called before a battle starts, as
/// <see cref="BattleService.Init"/> reads the active core at initialization time.
/// </summary>
public class BattleSetCoreAction : IScenarioAction
{
/// <summary>
/// Registered as "battle.set_core" — selects the combat ruleset
/// for the next battle encounter. If "core" is empty or missing, logs a
/// warning and returns without modifying the registry.
/// </summary>
public string Name => "battle.set_core";
/// <summary>
/// Reads the "core" variable and sets it as the active battle core
/// via <see cref="BattleCoreRegistry.SetActive"/>. The core determines
/// combat mechanics (turn order, ability system, etc.) for the
/// next initiated battle.
/// </summary>
public void Run(IActionContext ctx)
{
var core = ctx.GetParam("core");
if (string.IsNullOrEmpty(core))
{
ctx.Log("EVENT", "BattleSetCoreAction: missing 'core' variable");
return;
}
BattleCoreRegistry.Instance.SetActive(core);
ctx.Log("EVENT", $"BattleSetCoreAction: battle core set to '{core}'");
}
}
}
using Cthangover.Core.Actions;
namespace Cthangover.Core.Battle
{
/// <summary>
/// Selects which battle core (combat ruleset) the next battle will use.
/// The "core" variable maps to a <see cref="BattleCoreRegistry"/> entry by
/// string ID. Must be called before a battle starts, as
/// <see cref="BattleService.Init"/> reads the active core at initialization time.
/// </summary>
public class BattleSetCoreAction : IScenarioAction
{
/// <summary>
/// Registered as "battle.set_core" — selects the combat ruleset
/// for the next battle encounter. If "core" is empty or missing, logs a
/// warning and returns without modifying the registry.
/// </summary>
public string Name => "battle.set_core";
/// <summary>
/// Reads the "core" variable and sets it as the active battle core
/// via <see cref="BattleCoreRegistry.SetActive"/>. The core determines
/// combat mechanics (turn order, ability system, etc.) for the
/// next initiated battle.
/// </summary>
public void Run(IActionContext ctx)
{
var core = ctx.GetParam("core");
if (string.IsNullOrEmpty(core))
{
ctx.Log("EVENT", "BattleSetCoreAction: missing 'core' variable");
return;
}
BattleCoreRegistry.Instance.SetActive(core);
ctx.Log("EVENT", $"BattleSetCoreAction: battle core set to '{core}'");
}
}
}
+110 -110
View File
@@ -1,110 +1,110 @@
using System.Collections.Generic;
using Cthangover.Core.Battle;
using Cthangover.Core.Settings;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.TurnBattle
{
/// <summary>
/// In-battle speed toggle bar (x1/x4/x8). Directly sets
/// Godot.Engine.TimeScale to globally accelerate the entire game loop —
/// not just animations. Persists the choice to GameData.Settings
/// so it survives scene reloads. <see cref="ResetToNormal"/> restores 1x
/// without saving and is the part the core scene consumes through
/// <see cref="IBattleSpeedControl"/> when battle ends.
/// </summary>
public partial class BattleSpeedControl : Control, IBattleSpeedControl
{
private readonly Dictionary<int, Button> _buttons = new();
private int _activeSpeed;
/// <summary>
/// Builds the speed bar in the constructor. Godot never runs a
/// <c>_Ready</c> override declared in a mod assembly, so the buttons and the
/// saved-speed restore must happen here. Anchors are used instead of a
/// viewport-size lookup because the viewport is unavailable before the node
/// enters the tree.
/// </summary>
public BattleSpeedControl()
{
CthangoverTheme.Apply(this);
var speeds = new[] { 1, 4, 8 };
var hbox = new HBoxContainer { Name = "SpeedButtons" };
hbox.Alignment = BoxContainer.AlignmentMode.Begin;
foreach (var speed in speeds)
{
var btn = new Button
{
Text = $"x{speed}",
CustomMinimumSize = new Vector2(48, 32)
};
btn.Pressed += () => OnSpeedSelected(speed);
hbox.AddChild(btn);
_buttons[speed] = btn;
}
AddChild(hbox);
AnchorLeft = 1f;
AnchorRight = 1f;
AnchorTop = 0f;
AnchorBottom = 0f;
OffsetLeft = -200f;
OffsetRight = -20f;
OffsetTop = 20f;
OffsetBottom = 52f;
ApplySavedSpeed();
}
/// <summary>
/// Restores the speed from <see cref="GameData.Settings.BattleSpeed"/>.
/// Called on <c>_Ready</c> to reapply the saved preference.
/// </summary>
public void ApplySavedSpeed()
{
var speed = GameData.Instance.Settings.BattleSpeed;
if (speed < 1 || speed > 8)
speed = 1;
_activeSpeed = speed;
Godot.Engine.TimeScale = speed;
UpdateHighlight();
}
/// <summary>
/// Resets <see cref="Godot.Engine.TimeScale"/> to 1.0x without
/// persisting to settings. Used when battle ends so the lobby
/// and menus run at normal speed regardless of the player's
/// in-battle preference.
/// </summary>
public void ResetToNormal()
{
Godot.Engine.TimeScale = 1.0f;
}
private void OnSpeedSelected(int speed)
{
_activeSpeed = speed;
Godot.Engine.TimeScale = speed;
var settings = GameData.Instance.Settings;
settings.BattleSpeed = speed;
settings.Save();
UpdateHighlight();
}
private void UpdateHighlight()
{
foreach (var kv in _buttons)
{
var isActive = kv.Key == _activeSpeed;
kv.Value.Modulate = isActive ? UiTokens.Success : UiTokens.White;
}
}
}
}
using System.Collections.Generic;
using Cthangover.Core.Battle;
using Cthangover.Core.Settings;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.TurnBattle
{
/// <summary>
/// In-battle speed toggle bar (x1/x4/x8). Directly sets
/// Godot.Engine.TimeScale to globally accelerate the entire game loop —
/// not just animations. Persists the choice to GameData.Settings
/// so it survives scene reloads. <see cref="ResetToNormal"/> restores 1x
/// without saving and is the part the core scene consumes through
/// <see cref="IBattleSpeedControl"/> when battle ends.
/// </summary>
public partial class BattleSpeedControl : Control, IBattleSpeedControl
{
private readonly Dictionary<int, Button> _buttons = new();
private int _activeSpeed;
/// <summary>
/// Builds the speed bar in the constructor. Godot never runs a
/// <c>_Ready</c> override declared in a mod assembly, so the buttons and the
/// saved-speed restore must happen here. Anchors are used instead of a
/// viewport-size lookup because the viewport is unavailable before the node
/// enters the tree.
/// </summary>
public BattleSpeedControl()
{
CthangoverTheme.Apply(this);
var speeds = new[] { 1, 4, 8 };
var hbox = new HBoxContainer { Name = "SpeedButtons" };
hbox.Alignment = BoxContainer.AlignmentMode.Begin;
foreach (var speed in speeds)
{
var btn = new Button
{
Text = $"x{speed}",
CustomMinimumSize = new Vector2(48, 32)
};
btn.Pressed += () => OnSpeedSelected(speed);
hbox.AddChild(btn);
_buttons[speed] = btn;
}
AddChild(hbox);
AnchorLeft = 1f;
AnchorRight = 1f;
AnchorTop = 0f;
AnchorBottom = 0f;
OffsetLeft = -200f;
OffsetRight = -20f;
OffsetTop = 20f;
OffsetBottom = 52f;
ApplySavedSpeed();
}
/// <summary>
/// Restores the speed from <see cref="GameData.Settings.BattleSpeed"/>.
/// Called on <c>_Ready</c> to reapply the saved preference.
/// </summary>
public void ApplySavedSpeed()
{
var speed = GameData.Instance.Settings.BattleSpeed;
if (speed < 1 || speed > 8)
speed = 1;
_activeSpeed = speed;
Godot.Engine.TimeScale = speed;
UpdateHighlight();
}
/// <summary>
/// Resets <see cref="Godot.Engine.TimeScale"/> to 1.0x without
/// persisting to settings. Used when battle ends so the lobby
/// and menus run at normal speed regardless of the player's
/// in-battle preference.
/// </summary>
public void ResetToNormal()
{
Godot.Engine.TimeScale = 1.0f;
}
private void OnSpeedSelected(int speed)
{
_activeSpeed = speed;
Godot.Engine.TimeScale = speed;
var settings = GameData.Instance.Settings;
settings.BattleSpeed = speed;
settings.Save();
UpdateHighlight();
}
private void UpdateHighlight()
{
foreach (var kv in _buttons)
{
var isActive = kv.Key == _activeSpeed;
kv.Value.Modulate = isActive ? UiTokens.Success : UiTokens.White;
}
}
}
}
+30 -30
View File
@@ -1,30 +1,30 @@
namespace Cthangover.Core.Characters
{
/// <summary>
/// Result struct for battle action resolution. Source and Target carry
/// Damage/Defence snapshots for both sides of the exchange. Result indicates
/// whether the action succeeded (e.g. hit landed vs. missed/blocked).
/// A struct rather than a class because these are short-lived stack values
/// created in tight battle loops — no heap allocation.
/// </summary>
public struct ChangedAttributes
{
/// <summary>
/// Snapshot of the action source's damage and defence after the
/// exchange.
/// </summary>
public CharacterSet Source;
/// <summary>
/// Snapshot of the action target's damage and defence after the
/// exchange.
/// </summary>
public CharacterSet Target;
/// <summary>
/// Whether the action succeeded (e.g. hit landed vs. missed or
/// blocked).
/// </summary>
public bool Result;
}
}
namespace Cthangover.Core.Characters
{
/// <summary>
/// Result struct for battle action resolution. Source and Target carry
/// Damage/Defence snapshots for both sides of the exchange. Result indicates
/// whether the action succeeded (e.g. hit landed vs. missed/blocked).
/// A struct rather than a class because these are short-lived stack values
/// created in tight battle loops — no heap allocation.
/// </summary>
public struct ChangedAttributes
{
/// <summary>
/// Snapshot of the action source's damage and defence after the
/// exchange.
/// </summary>
public CharacterSet Source;
/// <summary>
/// Snapshot of the action target's damage and defence after the
/// exchange.
/// </summary>
public CharacterSet Target;
/// <summary>
/// Whether the action succeeded (e.g. hit landed vs. missed or
/// blocked).
/// </summary>
public bool Result;
}
}
+20 -20
View File
@@ -1,20 +1,20 @@
namespace Cthangover.Core.Characters
{
/// <summary>
/// Lightweight snapshot of damage/defence values for battle action results.
/// A struct to avoid heap allocation in the battle loop where these are
/// created for every action exchange. Used by ChangedAttributes to carry
/// both source and target state.
/// </summary>
public struct CharacterSet
{
/// <summary>
/// Damage dealt or received in this exchange.
/// </summary>
public int Damage;
/// <summary>
/// Defence value at the time of the exchange.
/// </summary>
public int Defence;
}
}
namespace Cthangover.Core.Characters
{
/// <summary>
/// Lightweight snapshot of damage/defence values for battle action results.
/// A struct to avoid heap allocation in the battle loop where these are
/// created for every action exchange. Used by ChangedAttributes to carry
/// both source and target state.
/// </summary>
public struct CharacterSet
{
/// <summary>
/// Damage dealt or received in this exchange.
/// </summary>
public int Damage;
/// <summary>
/// Defence value at the time of the exchange.
/// </summary>
public int Defence;
}
}
+63 -63
View File
@@ -1,63 +1,63 @@
using Cthangover.Core.Audio;
using Cthangover.Core.Battle;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.TurnBattle
{
/// <summary>
/// Defeat screen widget. Minimal compared to WingroundBehaviour —
/// only sets the deadground background texture (resolved from mods)
/// and offers a Main Menu button that clears BattleData and reloads
/// the menu scene. No loot, no EXP.
/// </summary>
public partial class DeadgroundBehaviour : TransitionWidget
{
/// <summary>
/// Clears battle data and switches to the main menu scene.
/// Bound to the "Main Menu" button on the defeat screen.
/// </summary>
public void ToMainMenuClick()
{
var sceneService = GetNode<GodotSceneService>("/root/GodotSceneService");
sceneService?.SwitchToMenu();
BattleRequest.Current = null;
}
/// <summary>
/// Plays the defeat sound, resolves the deadground background
/// texture from mods, and shows the transition widget.
/// </summary>
public override void Show()
{
CthangoverTheme.Apply(this);
CthangoverTheme.ApplyPrimary(GetNodeOrNull<Button>("Content/CenterPanel/MainMenuButton"));
GameLogger.Log("BATTLE", $"Deadground.Show() ENTER: Visible={Visible}, GodotVisible={base.Visible}", LogLevel.Debug);
var audioService = GetNodeOrNull<Node>("/root/AudioService") as IAudioService;
audioService?.PlaySound("battle/deadground", SoundType.UI);
var bg = GetNodeOrNull<TextureRect>("TransitionBg");
GameLogger.Log("BATTLE", $"Deadground.Show: TransitionBg={(bg != null ? "found" : "NULL")}, texture={(bg?.Texture != null ? "has" : "null")}", LogLevel.Error);
if (bg != null && bg.Texture == null)
{
var tex = Textures.Resolve("deadground");
GameLogger.Log("BATTLE", $"Deadground.Show: ResolveTexture('deadground') = {(tex != null ? "loaded" : "NULL")}", LogLevel.Error);
bg.Texture = tex;
}
base.Show();
GameLogger.Log("BATTLE", $"Deadground.Show() EXIT: Visible={Visible}, GodotVisible={base.Visible}", LogLevel.Debug);
}
}
}
using Cthangover.Core.Audio;
using Cthangover.Core.Battle;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.TurnBattle
{
/// <summary>
/// Defeat screen widget. Minimal compared to WingroundBehaviour —
/// only sets the deadground background texture (resolved from mods)
/// and offers a Main Menu button that clears BattleData and reloads
/// the menu scene. No loot, no EXP.
/// </summary>
public partial class DeadgroundBehaviour : TransitionWidget
{
/// <summary>
/// Clears battle data and switches to the main menu scene.
/// Bound to the "Main Menu" button on the defeat screen.
/// </summary>
public void ToMainMenuClick()
{
var sceneService = GetNode<GodotSceneService>("/root/GodotSceneService");
sceneService?.SwitchToMenu();
BattleRequest.Current = null;
}
/// <summary>
/// Plays the defeat sound, resolves the deadground background
/// texture from mods, and shows the transition widget.
/// </summary>
public override void Show()
{
CthangoverTheme.Apply(this);
CthangoverTheme.ApplyPrimary(GetNodeOrNull<Button>("Content/CenterPanel/MainMenuButton"));
GameLogger.Log("BATTLE", $"Deadground.Show() ENTER: Visible={Visible}, GodotVisible={base.Visible}", LogLevel.Debug);
var audioService = GetNodeOrNull<Node>("/root/AudioService") as IAudioService;
audioService?.PlaySound("battle/deadground", SoundType.UI);
var bg = GetNodeOrNull<TextureRect>("TransitionBg");
GameLogger.Log("BATTLE", $"Deadground.Show: TransitionBg={(bg != null ? "found" : "NULL")}, texture={(bg?.Texture != null ? "has" : "null")}", LogLevel.Error);
if (bg != null && bg.Texture == null)
{
var tex = Textures.Resolve("deadground");
GameLogger.Log("BATTLE", $"Deadground.Show: ResolveTexture('deadground') = {(tex != null ? "loaded" : "NULL")}", LogLevel.Error);
bg.Texture = tex;
}
base.Show();
GameLogger.Log("BATTLE", $"Deadground.Show() EXIT: Visible={Visible}, GodotVisible={base.Visible}", LogLevel.Debug);
}
}
}
+46 -46
View File
@@ -1,46 +1,46 @@
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.TurnBattle
{
/// <summary>
/// Single character EXP report in the victory screen. Displays the
/// character icon, localized name, and "+N" EXP value. Uses the same
/// ResolveMissingNodes pattern as LootReportItem for robustness
/// against missing scene links.
/// </summary>
public partial class ExpReportCard : Control
{
[Export] private TextureRect icon;
[Export] private Label nameLabel;
[Export] private Label expLabel;
private void ResolveMissingNodes()
{
icon ??= GetNodeOrNull<TextureRect>("Panel/Icon");
nameLabel ??= GetNodeOrNull<Label>("Panel/NameLabel");
expLabel ??= GetNodeOrNull<Label>("Panel/ExpLabel");
}
/// <summary>
/// Populates the card with a character icon, localised name, and
/// "+N" EXP value. Calls <c>ResolveMissingNodes</c> to tolerate
/// incomplete scene wiring in the editor.
/// </summary>
public void Setup(Texture2D characterIcon, string characterName, int exp)
{
CthangoverTheme.Apply(this);
ResolveMissingNodes();
if (icon != null && characterIcon != null)
icon.Texture = characterIcon;
if (nameLabel != null)
nameLabel.Text = characterName;
if (expLabel != null)
expLabel.Text = "+" + exp;
}
}
}
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.TurnBattle
{
/// <summary>
/// Single character EXP report in the victory screen. Displays the
/// character icon, localized name, and "+N" EXP value. Uses the same
/// ResolveMissingNodes pattern as LootReportItem for robustness
/// against missing scene links.
/// </summary>
public partial class ExpReportCard : Control
{
[Export] private TextureRect icon;
[Export] private Label nameLabel;
[Export] private Label expLabel;
private void ResolveMissingNodes()
{
icon ??= GetNodeOrNull<TextureRect>("Panel/Icon");
nameLabel ??= GetNodeOrNull<Label>("Panel/NameLabel");
expLabel ??= GetNodeOrNull<Label>("Panel/ExpLabel");
}
/// <summary>
/// Populates the card with a character icon, localised name, and
/// "+N" EXP value. Calls <c>ResolveMissingNodes</c> to tolerate
/// incomplete scene wiring in the editor.
/// </summary>
public void Setup(Texture2D characterIcon, string characterName, int exp)
{
CthangoverTheme.Apply(this);
ResolveMissingNodes();
if (icon != null && characterIcon != null)
icon.Texture = characterIcon;
if (nameLabel != null)
nameLabel.Text = characterName;
if (expLabel != null)
expLabel.Text = "+" + exp;
}
}
}
+46 -46
View File
@@ -1,46 +1,46 @@
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.TurnBattle
{
/// <summary>
/// A single loot cell in the victory grid. Shows icon with an optional
/// "xN" count label (hidden when count is 1). ResolveMissingNodes
/// supports both editor-assigned exports and runtime lookups so the
/// prefab works with incomplete scene wiring. TooltipText is set to
/// the localized item name for hover.
/// </summary>
public partial class LootReportItem : Control
{
[Export] private TextureRect icon;
[Export] private Label countLabel;
private void ResolveMissingNodes()
{
icon ??= GetNodeOrNull<TextureRect>("Panel/Icon");
countLabel ??= GetNodeOrNull<Label>("Panel/CountLabel");
}
/// <summary>
/// Configures the loot cell: sets the item icon, a "xN" count
/// label (hidden when <paramref name="count"/> is 1), and the
/// localised name as the tooltip. Calls
/// <c>ResolveMissingNodes</c> to handle missing editor
/// assignments gracefully.
/// </summary>
public void Setup(Texture2D itemIcon, string localizedName, int count)
{
CthangoverTheme.Apply(this);
ResolveMissingNodes();
if (icon != null && itemIcon != null)
icon.Texture = itemIcon;
if (countLabel != null)
countLabel.Text = count > 1 ? "x" + count : "";
TooltipText = localizedName;
}
}
}
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.TurnBattle
{
/// <summary>
/// A single loot cell in the victory grid. Shows icon with an optional
/// "xN" count label (hidden when count is 1). ResolveMissingNodes
/// supports both editor-assigned exports and runtime lookups so the
/// prefab works with incomplete scene wiring. TooltipText is set to
/// the localized item name for hover.
/// </summary>
public partial class LootReportItem : Control
{
[Export] private TextureRect icon;
[Export] private Label countLabel;
private void ResolveMissingNodes()
{
icon ??= GetNodeOrNull<TextureRect>("Panel/Icon");
countLabel ??= GetNodeOrNull<Label>("Panel/CountLabel");
}
/// <summary>
/// Configures the loot cell: sets the item icon, a "xN" count
/// label (hidden when <paramref name="count"/> is 1), and the
/// localised name as the tooltip. Calls
/// <c>ResolveMissingNodes</c> to handle missing editor
/// assignments gracefully.
/// </summary>
public void Setup(Texture2D itemIcon, string localizedName, int count)
{
CthangoverTheme.Apply(this);
ResolveMissingNodes();
if (icon != null && itemIcon != null)
icon.Texture = itemIcon;
if (countLabel != null)
countLabel.Text = count > 1 ? "x" + count : "";
TooltipText = localizedName;
}
}
}
+117 -117
View File
@@ -1,117 +1,117 @@
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.TurnBattle
{
/// <summary>
/// Floating damage-number overlay. Self-contained: creates its own Label
/// in _Ready, floats upward with random horizontal drift, fades alpha,
/// then QueueFrees itself. Static factory methods (SpawnDamage,
/// SpawnDefence) instantiate and parent it in one call — damage is red,
/// heal is green, defence is blue. MouseFilter is Ignore so clicks pass
/// through to cards underneath.
/// </summary>
public partial class ShowDamageBehaviour : Control
{
private Label _label;
private float duration = 1.0f;
private float floatDistance = 80f;
private float driftRange = 30f;
private Vector2 startPosition;
/// <summary>
/// Builds the label on first use. Godot does not dispatch <c>_Ready</c>
/// overrides declared in mod assemblies (only methods generated in the core
/// project run), so the label cannot be created in <c>_Ready</c> — it is
/// created here, right before the first animation starts.
/// </summary>
private void EnsureLabel()
{
if (_label != null)
return;
MouseFilter = MouseFilterEnum.Ignore;
_label = new Label();
_label.SetAnchorsAndOffsetsPreset(LayoutPreset.Center);
_label.HorizontalAlignment = HorizontalAlignment.Center;
_label.VerticalAlignment = VerticalAlignment.Center;
_label.AddThemeFontSizeOverride("font_size", 28);
_label.AddThemeColorOverride("font_outline_color", UiTokens.Alpha(Colors.Black, 1f));
_label.AddThemeConstantOverride("outline_size", 6);
AddChild(_label);
}
/// <summary>
/// Creates and parents a floating damage (or heal) number.
/// Damage is red, heal is green. The label floats upward with
/// a random horizontal drift and auto-destructs after the
/// tween completes. Skips values &lt;= 0.
/// </summary>
public static void SpawnDamage(int amount, Node parent, Vector2 position, bool isHeal = false)
{
if (amount <= 0)
return;
var color = isHeal
? UiTokens.SuccessText
: UiTokens.DangerText;
var hint = new ShowDamageBehaviour();
parent.AddChild(hint);
hint.Init(amount, position, color);
}
/// <summary>
/// Creates and parents a floating defence number (blue).
/// Same floating behaviour as <see cref="SpawnDamage"/>.
/// Skips values &lt;= 0.
/// </summary>
public static void SpawnDefence(int amount, Node parent, Vector2 position)
{
if (amount <= 0)
return;
var hint = new ShowDamageBehaviour();
parent.AddChild(hint);
hint.Init(amount, position, UiTokens.Info);
}
/// <summary>
/// Sets the label text, colour, and world position, then starts
/// the floating animation tween. Called by the static factory
/// methods after instantiation.
/// </summary>
public void Init(int amount, Vector2 worldPosition, Color? color = null)
{
EnsureLabel();
_label.Text = amount.ToString();
if (color.HasValue)
_label.Modulate = color.Value;
GlobalPosition = worldPosition;
startPosition = Position;
RunAnimation();
}
private async void RunAnimation()
{
float driftX = (float)GD.RandRange(-driftRange, driftRange);
Vector2 targetPos = startPosition + new Vector2(driftX, -floatDistance);
var tween = CreateTween();
tween.SetParallel(true);
tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
tween.TweenProperty(this, "position", targetPos, duration);
var fadeTween = CreateTween();
fadeTween.TweenProperty(_label, "modulate:a", 0f, duration * 0.8f);
await ToSignal(tween, "finished");
QueueFree();
}
}
}
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.TurnBattle
{
/// <summary>
/// Floating damage-number overlay. Self-contained: creates its own Label
/// in _Ready, floats upward with random horizontal drift, fades alpha,
/// then QueueFrees itself. Static factory methods (SpawnDamage,
/// SpawnDefence) instantiate and parent it in one call — damage is red,
/// heal is green, defence is blue. MouseFilter is Ignore so clicks pass
/// through to cards underneath.
/// </summary>
public partial class ShowDamageBehaviour : Control
{
private Label _label;
private float duration = 1.0f;
private float floatDistance = 80f;
private float driftRange = 30f;
private Vector2 startPosition;
/// <summary>
/// Builds the label on first use. Godot does not dispatch <c>_Ready</c>
/// overrides declared in mod assemblies (only methods generated in the core
/// project run), so the label cannot be created in <c>_Ready</c> — it is
/// created here, right before the first animation starts.
/// </summary>
private void EnsureLabel()
{
if (_label != null)
return;
MouseFilter = MouseFilterEnum.Ignore;
_label = new Label();
_label.SetAnchorsAndOffsetsPreset(LayoutPreset.Center);
_label.HorizontalAlignment = HorizontalAlignment.Center;
_label.VerticalAlignment = VerticalAlignment.Center;
_label.AddThemeFontSizeOverride("font_size", 28);
_label.AddThemeColorOverride("font_outline_color", UiTokens.Alpha(Colors.Black, 1f));
_label.AddThemeConstantOverride("outline_size", 6);
AddChild(_label);
}
/// <summary>
/// Creates and parents a floating damage (or heal) number.
/// Damage is red, heal is green. The label floats upward with
/// a random horizontal drift and auto-destructs after the
/// tween completes. Skips values &lt;= 0.
/// </summary>
public static void SpawnDamage(int amount, Node parent, Vector2 position, bool isHeal = false)
{
if (amount <= 0)
return;
var color = isHeal
? UiTokens.SuccessText
: UiTokens.DangerText;
var hint = new ShowDamageBehaviour();
parent.AddChild(hint);
hint.Init(amount, position, color);
}
/// <summary>
/// Creates and parents a floating defence number (blue).
/// Same floating behaviour as <see cref="SpawnDamage"/>.
/// Skips values &lt;= 0.
/// </summary>
public static void SpawnDefence(int amount, Node parent, Vector2 position)
{
if (amount <= 0)
return;
var hint = new ShowDamageBehaviour();
parent.AddChild(hint);
hint.Init(amount, position, UiTokens.Info);
}
/// <summary>
/// Sets the label text, colour, and world position, then starts
/// the floating animation tween. Called by the static factory
/// methods after instantiation.
/// </summary>
public void Init(int amount, Vector2 worldPosition, Color? color = null)
{
EnsureLabel();
_label.Text = amount.ToString();
if (color.HasValue)
_label.Modulate = color.Value;
GlobalPosition = worldPosition;
startPosition = Position;
RunAnimation();
}
private async void RunAnimation()
{
float driftX = (float)GD.RandRange(-driftRange, driftRange);
Vector2 targetPos = startPosition + new Vector2(driftX, -floatDistance);
var tween = CreateTween();
tween.SetParallel(true);
tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
tween.TweenProperty(this, "position", targetPos, duration);
var fadeTween = CreateTween();
fadeTween.TweenProperty(_label, "modulate:a", 0f, duration * 0.8f);
await ToSignal(tween, "finished");
QueueFree();
}
}
}
+58 -58
View File
@@ -1,58 +1,58 @@
using Cthangover.Core.Characters;
namespace Cthangover.Core.Cards.StatusEffect
{
/// <summary>
/// Behaviour hooks for a status effect type. Each hook receives the
/// owning character so a single IStatusActions instance can be
/// shared across multiple characters. OnDealDamage / OnTakeDamage
/// use ref int to let effects amplify or reduce damage before it
/// is applied. ModifyAttributes is a passive stat modifier called
/// independently of turn phases. OnFinalAction fires once when the
/// effect expires. All hooks have empty default semantics — effects
/// override only the hooks they care about.
/// </summary>
public interface IStatusActions
{
/// <summary>Factory key matching the <c>Actions</c> field in
/// <see cref="StatusEffectInfo"/>, e.g. <c>"basic/stun"</c>.</summary>
string ID { get; }
/// <summary>Fires when the owning character's turn begins. Use for
/// start-of-turn damage-over-time or regeneration effects.</summary>
void OnTurnStart(Character target);
/// <summary>Fires when the owning character's turn ends. Mirrors
/// <see cref="OnTurnStart"/> for symmetry; useful for effects that
/// activate after the character acts.</summary>
void OnTurnEnd(Character target);
/// <summary>Called before the owning character deals damage to
/// <paramref name="target"/>. Modify <paramref name="damage"/> via
/// <see langword="ref"/> to amplify or reduce outgoing damage —
/// all active effects stack multiplicatively through the queue's
/// iteration.</summary>
void OnDealDamage(Character source, Character target, ref int damage);
/// <summary>Called before the owning character receives damage from
/// <paramref name="source"/>. Modify <paramref name="damage"/> via
/// <see langword="ref"/> to add damage reduction or vulnerability.
/// Fires after <see cref="OnDealDamage"/> in the damage
/// pipeline.</summary>
void OnTakeDamage(Character target, Character source, ref int damage);
/// <summary>Fires immediately when the effect is first placed on a
/// character. Use for one-shot stat boosts, visual feedback, or
/// initialisation logic.</summary>
void OnApply(Character target);
/// <summary>Fires when the effect is manually removed (e.g. via a
/// cleanse card). Not called on natural expiration — use
/// <see cref="OnFinalAction"/> for that path.</summary>
void OnRemove(Character target);
/// <summary>Fires once when the effect expires naturally (timer reaches
/// zero). Use for end-of-effect triggers like explosion damage or
/// stat restoration. Not called on manual removal.</summary>
void OnFinalAction(Character target);
/// <summary>Passive stat modifier invoked independently of turn phases.
/// Receives a reference to the character's attribute container;
/// implementors can add flat or percentage bonuses that other
/// effects may also contribute to.</summary>
void ModifyAttributes(CharacterAttributes attributes);
}
}
using Cthangover.Core.Characters;
namespace Cthangover.Core.Cards.StatusEffect
{
/// <summary>
/// Behaviour hooks for a status effect type. Each hook receives the
/// owning character so a single IStatusActions instance can be
/// shared across multiple characters. OnDealDamage / OnTakeDamage
/// use ref int to let effects amplify or reduce damage before it
/// is applied. ModifyAttributes is a passive stat modifier called
/// independently of turn phases. OnFinalAction fires once when the
/// effect expires. All hooks have empty default semantics — effects
/// override only the hooks they care about.
/// </summary>
public interface IStatusActions
{
/// <summary>Factory key matching the <c>Actions</c> field in
/// <see cref="StatusEffectInfo"/>, e.g. <c>"basic/stun"</c>.</summary>
string ID { get; }
/// <summary>Fires when the owning character's turn begins. Use for
/// start-of-turn damage-over-time or regeneration effects.</summary>
void OnTurnStart(Character target);
/// <summary>Fires when the owning character's turn ends. Mirrors
/// <see cref="OnTurnStart"/> for symmetry; useful for effects that
/// activate after the character acts.</summary>
void OnTurnEnd(Character target);
/// <summary>Called before the owning character deals damage to
/// <paramref name="target"/>. Modify <paramref name="damage"/> via
/// <see langword="ref"/> to amplify or reduce outgoing damage —
/// all active effects stack multiplicatively through the queue's
/// iteration.</summary>
void OnDealDamage(Character source, Character target, ref int damage);
/// <summary>Called before the owning character receives damage from
/// <paramref name="source"/>. Modify <paramref name="damage"/> via
/// <see langword="ref"/> to add damage reduction or vulnerability.
/// Fires after <see cref="OnDealDamage"/> in the damage
/// pipeline.</summary>
void OnTakeDamage(Character target, Character source, ref int damage);
/// <summary>Fires immediately when the effect is first placed on a
/// character. Use for one-shot stat boosts, visual feedback, or
/// initialisation logic.</summary>
void OnApply(Character target);
/// <summary>Fires when the effect is manually removed (e.g. via a
/// cleanse card). Not called on natural expiration — use
/// <see cref="OnFinalAction"/> for that path.</summary>
void OnRemove(Character target);
/// <summary>Fires once when the effect expires naturally (timer reaches
/// zero). Use for end-of-effect triggers like explosion damage or
/// stat restoration. Not called on manual removal.</summary>
void OnFinalAction(Character target);
/// <summary>Passive stat modifier invoked independently of turn phases.
/// Receives a reference to the character's attribute container;
/// implementors can add flat or percentage bonuses that other
/// effects may also contribute to.</summary>
void ModifyAttributes(CharacterAttributes attributes);
}
}
+49 -49
View File
@@ -1,49 +1,49 @@
using Cthangover.Core.Characters;
namespace Cthangover.Core.Cards.StatusEffect.Impls
{
/// <summary>
/// Stun behaviour set — intentionally all no-ops. The stun condition
/// is checked separately via StatusEffectQueue.HasStun() rather than
/// through action hooks, so the Actions implementation is empty.
/// Exists as a concrete type so the factory can resolve "basic/stun"
/// to a valid instance.
/// </summary>
public class StunStatusEffect : IStatusActions
{
/// <inheritdoc cref="IStatusActions.ID"/>
public string ID => "basic/stun";
/// <inheritdoc cref="IStatusActions.OnTurnStart"/>
public void OnTurnStart(Character target)
{ }
/// <inheritdoc cref="IStatusActions.OnTurnEnd"/>
public void OnTurnEnd(Character target)
{ }
/// <inheritdoc cref="IStatusActions.OnDealDamage"/>
public void OnDealDamage(Character source, Character target, ref int damage)
{ }
/// <inheritdoc cref="IStatusActions.OnTakeDamage"/>
public void OnTakeDamage(Character target, Character source, ref int damage)
{ }
/// <inheritdoc cref="IStatusActions.OnApply"/>
public void OnApply(Character target)
{ }
/// <inheritdoc cref="IStatusActions.OnRemove"/>
public void OnRemove(Character target)
{ }
/// <inheritdoc cref="IStatusActions.OnFinalAction"/>
public void OnFinalAction(Character target)
{ }
/// <inheritdoc cref="IStatusActions.ModifyAttributes"/>
public void ModifyAttributes(CharacterAttributes attributes)
{ }
}
}
using Cthangover.Core.Characters;
namespace Cthangover.Core.Cards.StatusEffect.Impls
{
/// <summary>
/// Stun behaviour set — intentionally all no-ops. The stun condition
/// is checked separately via StatusEffectQueue.HasStun() rather than
/// through action hooks, so the Actions implementation is empty.
/// Exists as a concrete type so the factory can resolve "basic/stun"
/// to a valid instance.
/// </summary>
public class StunStatusEffect : IStatusActions
{
/// <inheritdoc cref="IStatusActions.ID"/>
public string ID => "basic/stun";
/// <inheritdoc cref="IStatusActions.OnTurnStart"/>
public void OnTurnStart(Character target)
{ }
/// <inheritdoc cref="IStatusActions.OnTurnEnd"/>
public void OnTurnEnd(Character target)
{ }
/// <inheritdoc cref="IStatusActions.OnDealDamage"/>
public void OnDealDamage(Character source, Character target, ref int damage)
{ }
/// <inheritdoc cref="IStatusActions.OnTakeDamage"/>
public void OnTakeDamage(Character target, Character source, ref int damage)
{ }
/// <inheritdoc cref="IStatusActions.OnApply"/>
public void OnApply(Character target)
{ }
/// <inheritdoc cref="IStatusActions.OnRemove"/>
public void OnRemove(Character target)
{ }
/// <inheritdoc cref="IStatusActions.OnFinalAction"/>
public void OnFinalAction(Character target)
{ }
/// <inheritdoc cref="IStatusActions.ModifyAttributes"/>
public void ModifyAttributes(CharacterAttributes attributes)
{ }
}
}
+21 -21
View File
@@ -1,21 +1,21 @@
using System;
using Cthangover.Core.Cards.StatusEffect;
namespace Cthangover.Core.Factories.Impls
{
/// <summary>
/// Thin <c>FileFactory</c> for status effect metadata — duration, stack
/// behaviour, visual icon, etc. Works in tandem with
/// <c>StatusEffectActionFactory</c>: this factory provides the <b>data</b>
/// (what the effect is), while the action factory provides the
/// <b>behaviour</b> (what the effect does each turn). Files are discovered
/// by the shared entity resolver through the DTO's
/// <c>[EntityKind("status_effect")]</c> attribute.
/// </summary>
public class StatusEffectInfoFactory : FileFactory<StatusEffectInfo>
{
private static readonly Lazy<StatusEffectInfoFactory> lazy = new(() => new StatusEffectInfoFactory());
/// <summary>Lazy singleton facade over the AppServices-owned factory.</summary>
public static StatusEffectInfoFactory Instance => lazy.Value;
}
}
using System;
using Cthangover.Core.Cards.StatusEffect;
namespace Cthangover.Core.Factories.Impls
{
/// <summary>
/// Thin <c>FileFactory</c> for status effect metadata — duration, stack
/// behaviour, visual icon, etc. Works in tandem with
/// <c>StatusEffectActionFactory</c>: this factory provides the <b>data</b>
/// (what the effect is), while the action factory provides the
/// <b>behaviour</b> (what the effect does each turn). Files are discovered
/// by the shared entity resolver through the DTO's
/// <c>[EntityKind("status_effect")]</c> attribute.
/// </summary>
public class StatusEffectInfoFactory : FileFactory<StatusEffectInfo>
{
private static readonly Lazy<StatusEffectInfoFactory> lazy = new(() => new StatusEffectInfoFactory());
/// <summary>Lazy singleton facade over the AppServices-owned factory.</summary>
public static StatusEffectInfoFactory Instance => lazy.Value;
}
}
+13 -13
View File
@@ -1,14 +1,14 @@
{
"Kind": "status_effect",
"Items": [
{
"Id": "effect/physics/stun",
"Name": "status/",
"Description": "",
"Icon": "characters/effects/stun",
"Type": "Stun",
"Duration": 3,
"Actions": "basic/stun"
}
]
{
"Kind": "status_effect",
"Items": [
{
"Id": "effect/physics/stun",
"Name": "status/",
"Description": "",
"Icon": "characters/effects/stun",
"Type": "Stun",
"Duration": 3,
"Actions": "basic/stun"
}
]
}