diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..3030c1a
--- /dev/null
+++ b/.gitattributes
@@ -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
diff --git a/.gitignore b/.gitignore
index bb88804..c301ca8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,37 +1,37 @@
-# Godot 4.x
-.godot/
-.idea/
-sdk/
-bin/
-obj/
-
-#ai
-.omo/
-.opencode/
-
-gen/
-obj/
-bin/
-
-*.tres~
-*.tscn~
-
-# GDScript LSP
-.godot/
-
-# Export
-*.exe
-*.dmg
-*.apk
-*.aab
-*.pck
-*.zip
-*.log
-
-# OS
-.DS_Store
-Thumbs.db
-
-# Editor
-*.import
-*.godot.uid
+# Godot 4.x
+.godot/
+.idea/
+sdk/
+bin/
+obj/
+
+#ai
+.omo/
+.opencode/
+
+gen/
+obj/
+bin/
+
+*.tres~
+*.tscn~
+
+# GDScript LSP
+.godot/
+
+# Export
+*.exe
+*.dmg
+*.apk
+*.aab
+*.pck
+*.zip
+*.log
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Editor
+*.import
+*.godot.uid
diff --git a/src/Actions/AbstractBattleAction.cs b/src/Actions/AbstractBattleAction.cs
index bb72844..0511ba4 100644
--- a/src/Actions/AbstractBattleAction.cs
+++ b/src/Actions/AbstractBattleAction.cs
@@ -1,101 +1,101 @@
-using Cthangover.CardBattle.UI;
-using Cthangover.Core.Battle.Actions;
-using Cthangover.Core.Characters;
-using Godot;
-
-namespace Cthangover.CardBattle.Actions
-{
- ///
- /// Base class for animated battle actions that move, rotate, and scale card controls
- /// through a sequence of visual phases. Subclasses implement DoInternalAction to advance
- /// a frame-by-frame animation state machine driven by 's
- /// per-frame polling loop (while (!DoAction()) await frame;).
- /// Captures source/target positions and rotations in so that
- /// can restore them regardless of what the animation did.
- ///
- public abstract class AbstractBattleAction : IBattleAction
- {
- /// Seconds timestamp of the current phase start (used for phase progress); -1 before the first phase.
- protected double Timestamp { get; set; } = -1;
- /// Playback speed multiplier applied to all phase durations.
- protected float Speed { get; set; }
- /// The action definition whose stats drive the animation.
- protected ActionCharacter Action { get; set; }
- /// Acting card node.
- protected CharacterCardNode Source { get; set; }
- /// Receiving card node.
- protected CharacterCardNode Target { get; set; }
- /// Visual control of the source card (null-safe abort when freed mid-animation).
- protected Control SourceControl { get; set; }
- /// Source position captured in for restoration in .
- protected Vector2 SourcePos { get; set; }
- /// Source rotation captured in .
- protected float SourceRot { get; set; }
- /// Visual control of the target card.
- protected Control TargetControl { get; set; }
- /// Target position captured in .
- protected Vector2 TargetPos { get; set; }
- /// Target rotation captured in .
- protected float TargetRot { get; set; }
-
- ///
- /// Advances the animation by one frame. Called in a loop by
- /// with await root.ToSignal(frame) between calls. Returns true when the animation
- /// has completed all phases and the action result has been applied.
- /// Returns true early if any required node became null (card was destroyed mid-animation).
- ///
- /// true when the animation is finished or cannot proceed.
- public bool DoAction()
- {
- if (Source == null || Target == null || Action == null || SourceControl == null || TargetControl == null)
- return true;
- return DoInternalAction();
- }
-
- /// Frame tick of the animation; implementors advance phases and return true when finished.
- protected abstract bool DoInternalAction();
- /// Phase setup called once before the first tick.
- protected abstract void DoInternalStart();
- /// Cleanup called once after completion — restore captured positions/rotations here.
- protected abstract void DoInternalEnd();
-
- protected AbstractBattleAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1)
- {
- Source = source;
- Target = target;
- Action = action;
- Speed = speed;
- }
-
- ///
- /// Captures the current positions, rotations, and references of both source
- /// and target cards before the animation begins, then delegates to the subclass
- /// for any additional setup (e.g. resetting timestamps).
- ///
- public void DoStart()
- {
- if (Source == null || Target == null || Action == null)
- return;
-
- SourceControl = Source.GetControlNode();
- SourcePos = SourceControl.GlobalPosition;
- SourceRot = SourceControl.Rotation;
- TargetControl = Target.GetControlNode();
- TargetPos = TargetControl.GlobalPosition;
- TargetRot = TargetControl.Rotation;
-
- DoInternalStart();
- }
-
- ///
- /// Restores source and target card controls to their original positions, rotations, and visual state
- /// captured during . Delegates specific cleanup to .
- ///
- public void DoEnd()
- {
- if (Source == null || Target == null || Action == null)
- return;
- DoInternalEnd();
- }
- }
-}
+using Cthangover.CardBattle.UI;
+using Cthangover.Core.Battle.Actions;
+using Cthangover.Core.Characters;
+using Godot;
+
+namespace Cthangover.CardBattle.Actions
+{
+ ///
+ /// Base class for animated battle actions that move, rotate, and scale card controls
+ /// through a sequence of visual phases. Subclasses implement DoInternalAction to advance
+ /// a frame-by-frame animation state machine driven by 's
+ /// per-frame polling loop (while (!DoAction()) await frame;).
+ /// Captures source/target positions and rotations in so that
+ /// can restore them regardless of what the animation did.
+ ///
+ public abstract class AbstractBattleAction : IBattleAction
+ {
+ /// Seconds timestamp of the current phase start (used for phase progress); -1 before the first phase.
+ protected double Timestamp { get; set; } = -1;
+ /// Playback speed multiplier applied to all phase durations.
+ protected float Speed { get; set; }
+ /// The action definition whose stats drive the animation.
+ protected ActionCharacter Action { get; set; }
+ /// Acting card node.
+ protected CharacterCardNode Source { get; set; }
+ /// Receiving card node.
+ protected CharacterCardNode Target { get; set; }
+ /// Visual control of the source card (null-safe abort when freed mid-animation).
+ protected Control SourceControl { get; set; }
+ /// Source position captured in for restoration in .
+ protected Vector2 SourcePos { get; set; }
+ /// Source rotation captured in .
+ protected float SourceRot { get; set; }
+ /// Visual control of the target card.
+ protected Control TargetControl { get; set; }
+ /// Target position captured in .
+ protected Vector2 TargetPos { get; set; }
+ /// Target rotation captured in .
+ protected float TargetRot { get; set; }
+
+ ///
+ /// Advances the animation by one frame. Called in a loop by
+ /// with await root.ToSignal(frame) between calls. Returns true when the animation
+ /// has completed all phases and the action result has been applied.
+ /// Returns true early if any required node became null (card was destroyed mid-animation).
+ ///
+ /// true when the animation is finished or cannot proceed.
+ public bool DoAction()
+ {
+ if (Source == null || Target == null || Action == null || SourceControl == null || TargetControl == null)
+ return true;
+ return DoInternalAction();
+ }
+
+ /// Frame tick of the animation; implementors advance phases and return true when finished.
+ protected abstract bool DoInternalAction();
+ /// Phase setup called once before the first tick.
+ protected abstract void DoInternalStart();
+ /// Cleanup called once after completion — restore captured positions/rotations here.
+ protected abstract void DoInternalEnd();
+
+ protected AbstractBattleAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1)
+ {
+ Source = source;
+ Target = target;
+ Action = action;
+ Speed = speed;
+ }
+
+ ///
+ /// Captures the current positions, rotations, and references of both source
+ /// and target cards before the animation begins, then delegates to the subclass
+ /// for any additional setup (e.g. resetting timestamps).
+ ///
+ public void DoStart()
+ {
+ if (Source == null || Target == null || Action == null)
+ return;
+
+ SourceControl = Source.GetControlNode();
+ SourcePos = SourceControl.GlobalPosition;
+ SourceRot = SourceControl.Rotation;
+ TargetControl = Target.GetControlNode();
+ TargetPos = TargetControl.GlobalPosition;
+ TargetRot = TargetControl.Rotation;
+
+ DoInternalStart();
+ }
+
+ ///
+ /// Restores source and target card controls to their original positions, rotations, and visual state
+ /// captured during . Delegates specific cleanup to .
+ ///
+ public void DoEnd()
+ {
+ if (Source == null || Target == null || Action == null)
+ return;
+ DoInternalEnd();
+ }
+ }
+}
diff --git a/src/Actions/ActionPhase.cs b/src/Actions/ActionPhase.cs
index 0bd4930..afb378a 100644
--- a/src/Actions/ActionPhase.cs
+++ b/src/Actions/ActionPhase.cs
@@ -1,26 +1,26 @@
-namespace Cthangover.CardBattle.Actions
-{
- ///
- /// Phases of the animated battle action state machine used by
- /// and . Each tick, the action evaluates the current phase,
- /// interpolates card positions/rotations/scales, and transitions to the next phase when its
- /// duration elapses. The Recover phase signals completion by returning true from DoInternalAction.
- ///
- public enum ActionPhase
- {
- /// Initial delay before any movement.
- Wait,
- /// Source card rotates toward target and begins slight movement.
- Prepare,
- /// Source card travels from its position to the target along a movement pattern.
- MoveForAttack,
- /// Damage/effect is applied to the target card at the point of impact.
- AttackImpact,
- /// Target card shows knockback, squashing, and shaking reaction.
- TargetReaction,
- /// Source card returns toward its original position.
- MoveForBack,
- /// All cards smoothly return to their original transforms.
- Recover
- }
-}
+namespace Cthangover.CardBattle.Actions
+{
+ ///
+ /// Phases of the animated battle action state machine used by
+ /// and . Each tick, the action evaluates the current phase,
+ /// interpolates card positions/rotations/scales, and transitions to the next phase when its
+ /// duration elapses. The Recover phase signals completion by returning true from DoInternalAction.
+ ///
+ public enum ActionPhase
+ {
+ /// Initial delay before any movement.
+ Wait,
+ /// Source card rotates toward target and begins slight movement.
+ Prepare,
+ /// Source card travels from its position to the target along a movement pattern.
+ MoveForAttack,
+ /// Damage/effect is applied to the target card at the point of impact.
+ AttackImpact,
+ /// Target card shows knockback, squashing, and shaking reaction.
+ TargetReaction,
+ /// Source card returns toward its original position.
+ MoveForBack,
+ /// All cards smoothly return to their original transforms.
+ Recover
+ }
+}
diff --git a/src/Actions/PhysicsAttackAction.cs b/src/Actions/PhysicsAttackAction.cs
index 670c5fe..e83a84f 100644
--- a/src/Actions/PhysicsAttackAction.cs
+++ b/src/Actions/PhysicsAttackAction.cs
@@ -1,403 +1,403 @@
-using Cthangover.Core.Battle;
-using Cthangover.Core.Battle.Actions;
-using Cthangover.Core.UI.Theming;
-using Cthangover.Core.Utils;
-using Cthangover.TurnBattle;
-using Cthangover.Core.Characters;
-using Cthangover.CardBattle.UI;
-using Godot;
-
-namespace Cthangover.CardBattle.Actions
-{
- ///
- /// Animated battle action that moves the source card toward an enemy target, applies damage/defence
- /// via , and animates the target's knockback/squash reaction.
- /// The source card follows one of five randomly-selected trajectories
- /// (direct, arc, zigzag, spiral, dash). This is the visual counterpart of
- /// — it handles the presentation while the executor handles the stat changes.
- /// Created by for enemy turns.
- ///
- public class PhysicsAttackAction : AbstractBattleAction
- {
- ///
- /// Pre-defined movement trajectory patterns for the source card's approach to the target.
- /// Selected randomly at construction to add visual variety to enemy attack animations.
- ///
- public enum MovementPattern
- {
- Direct,
- Arc,
- Zigzag,
- Spiral,
- Dash
- }
-
- private ActionPhase phase = ActionPhase.Wait;
- private float attackRotation;
- private bool damageApplied;
- private bool stunEffectSpawned;
-
- private Vector2 targetOriginalPos;
- private Vector2 targetOriginalScale;
- private Vector2 knockbackDirection;
- private float targetReactionIntensity = 0.3f;
-
- private Vector2 knockbackPos;
- private float shake;
- private Vector2 squashScale;
-
- private float waitDuration = 0.2f;
- private float prepareDuration = 0.3f;
- private float moveForAttackDuration = 0.4f;
- private float attackImpactDuration = 0.3f;
- private float targetReactionDuration = 0.4f;
- private float moveForBackDuration = 0.5f;
- private float recoverDuration = 0.3f;
-
- private MovementPattern movementPattern = MovementPattern.Direct;
- private Vector2 movementControlPoint;
-
- ///
- /// Creates an attack animation from to
- /// using the stats defined in . The is
- /// randomly selected on construction to make enemy attacks visually distinct.
- /// acts as a multiplier on all phase durations.
- ///
- public PhysicsAttackAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1)
- : base(source, target, action, speed)
- {
- movementPattern = (MovementPattern)GD.RandRange(0, 5);
- }
-
- /// Resets the phase clock and starts the attack sequence from the Wait phase.
- protected override void DoInternalStart()
- {
- Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
- damageApplied = false;
- stunEffectSpawned = false;
- phase = ActionPhase.Wait;
- targetOriginalScale = TargetControl.Scale;
- }
-
- private float GetPhaseProgress()
- {
- float elapsed = (float)(Godot.Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Godot.Engine.TimeScale;
- float duration = GetCurrentPhaseDuration();
- return Mathf.Clamp(elapsed / duration, 0f, 1f);
- }
-
- private float GetCurrentPhaseDuration()
- {
- switch (phase)
- {
- case ActionPhase.Wait: return waitDuration;
- case ActionPhase.Prepare: return prepareDuration;
- case ActionPhase.MoveForAttack: return moveForAttackDuration;
- case ActionPhase.AttackImpact: return attackImpactDuration;
- case ActionPhase.TargetReaction: return targetReactionDuration;
- case ActionPhase.MoveForBack: return moveForBackDuration;
- case ActionPhase.Recover: return recoverDuration;
- default: return 1f;
- }
- }
-
- private void ResetTimestamp()
- {
- Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
- }
-
- /// Advances the multi-phase attack: dash in, impact, damage/stun application, return and recover.
- protected override bool DoInternalAction()
- {
- float progress = GetPhaseProgress();
-
- switch (phase)
- {
- case ActionPhase.Wait:
- if (progress >= 1f)
- {
- phase = ActionPhase.Prepare;
- ResetTimestamp();
- targetOriginalPos = TargetControl.GlobalPosition;
- targetOriginalScale = TargetControl.Scale;
- knockbackDirection = (TargetControl.GlobalPosition - SourceControl.GlobalPosition).Normalized();
- InitializeMovementPattern();
- }
- break;
-
- case ActionPhase.Prepare:
- {
- float prepareProgress = EaseOutQuad(Mathf.Min(progress, 1f));
- Vector2 preparePosition = GetMovementPosition(prepareProgress * 0.1f);
- SourceControl.GlobalPosition = SourcePos.Lerp(preparePosition, prepareProgress);
- SourceControl.Rotation = Mathf.Lerp(SourceRot, TargetRot, prepareProgress * 0.3f);
-
- if (progress >= 1f)
- {
- phase = ActionPhase.MoveForAttack;
- ResetTimestamp();
- }
- break;
- }
-
- case ActionPhase.MoveForAttack:
- {
- float attackProgress = EaseInOutQuad(Mathf.Min(progress, 1f));
- Vector2 targetPosition = GetMovementPosition(attackProgress);
- SourceControl.GlobalPosition = targetPosition;
- SourceControl.Rotation = Mathf.Lerp(SourceRot, TargetRot, attackProgress);
-
- if (progress >= 1f)
- {
- phase = ActionPhase.AttackImpact;
- ResetTimestamp();
- attackRotation = (float)GD.RandRange(-35f, 35f) * Mathf.Pi / 180f;
- }
- break;
- }
-
- case ActionPhase.AttackImpact:
- {
- float impactProgress = EaseInOutQuad(Mathf.Min(progress * 3f, 1f));
- SourceControl.Rotation = Mathf.Lerp(TargetRot, attackRotation, impactProgress);
-
- if (!damageApplied && progress >= 0.3f)
- {
- damageApplied = true;
- var result = ActionExecutorHub.Instance.Execute(Action, Source.Card, Target.Card);
- Source.UpdateInfo();
- Target.UpdateInfo();
-
- if (result.Result)
- {
- LogAttackResult(result);
- }
- else
- {
- GameLogger.Log("CARD_ACT",
- $"PhysicsAttackAction: failed to execute action {Action.Name} (source={Source.Card.Name} target={Target.Card.Name})",
- LogLevel.Error);
- }
- }
-
- if (progress >= 1f)
- {
- phase = ActionPhase.TargetReaction;
- ResetTimestamp();
- }
- break;
- }
-
- case ActionPhase.TargetReaction:
- {
- float reactionProgress = EaseInOutQuad(Mathf.Min(progress, 1f));
-
- bool isStun = Action != null && Action.GetInt("Turn", 0) > 1;
- float currentKnockback = targetReactionIntensity;
- if (isStun)
- currentKnockback *= 2.5f;
-
- knockbackPos = targetOriginalPos + knockbackDirection * currentKnockback;
- TargetControl.GlobalPosition = targetOriginalPos.Lerp(knockbackPos, reactionProgress);
-
- squashScale = targetOriginalScale;
- float currentSquash = isStun ? 0.35f : 0.2f;
- squashScale.X *= 1f - reactionProgress * currentSquash;
- squashScale.Y *= 1f + reactionProgress * (currentSquash * 0.5f);
- TargetControl.Scale = squashScale;
-
- float currentShakeMultiplier = isStun ? 1.8f : 1f;
- shake = Mathf.Sin(reactionProgress * Mathf.Pi * (isStun ? 6f : 4f)) * (1f - reactionProgress) * 0.05f * currentShakeMultiplier;
- TargetControl.Rotation = shake * 30f * Mathf.Pi / 180f;
-
- if (progress >= 1f)
- {
- phase = ActionPhase.MoveForBack;
- ResetTimestamp();
- }
- break;
- }
-
- case ActionPhase.MoveForBack:
- {
- float backProgress = EaseOutQuad(Mathf.Min(progress, 1f));
- Vector2 backPosition = GetMovementPosition(1f - progress);
- SourceControl.GlobalPosition = backPosition.Lerp(SourcePos, backProgress);
- SourceControl.Rotation = Mathf.Lerp(attackRotation, SourceRot, backProgress);
-
- TargetControl.GlobalPosition = knockbackPos.Lerp(targetOriginalPos, backProgress);
- TargetControl.Scale = squashScale.Lerp(targetOriginalScale, backProgress);
- float currentShake = Mathf.Sin(backProgress * Mathf.Pi) * shake * (1f - backProgress);
- TargetControl.Rotation = Mathf.Lerp(currentShake * 30f * Mathf.Pi / 180f, TargetRot, backProgress);
-
- if (progress >= 1f)
- {
- phase = ActionPhase.Recover;
- ResetTimestamp();
- }
- break;
- }
-
- case ActionPhase.Recover:
- {
- float recoverProgress = EaseOutQuad(Mathf.Min(progress, 1f));
-
- SourceControl.GlobalPosition = SourceControl.GlobalPosition.Lerp(SourcePos, recoverProgress);
- SourceControl.Rotation = Mathf.Lerp(SourceControl.Rotation, SourceRot, recoverProgress);
- TargetControl.GlobalPosition = TargetControl.GlobalPosition.Lerp(TargetPos, recoverProgress);
- TargetControl.Rotation = Mathf.Lerp(TargetControl.Rotation, TargetRot, recoverProgress);
- TargetControl.Scale = TargetControl.Scale.Lerp(targetOriginalScale, recoverProgress);
-
- if (progress >= 1f)
- return true;
- break;
- }
-
- default:
- return true;
- }
-
- return false;
- }
-
- /// Restores the target's captured position, scale and rotation after the attack animation.
- protected override void DoInternalEnd()
- {
- if (SourceControl != null)
- {
- SourceControl.GlobalPosition = SourcePos;
- SourceControl.Rotation = SourceRot;
- }
- if (TargetControl != null)
- {
- TargetControl.GlobalPosition = TargetPos;
- TargetControl.Rotation = TargetRot;
- TargetControl.Scale = targetOriginalScale;
- }
- }
-
- ///
- /// Mirrors the resolved action onto the UI: floating damage / absorbed
- /// shield numbers, a colour pulse on the target card and journal rows, so
- /// an AI attack reads the same as the player's own attack. Stun actions
- /// (a "Turn" above 1) flash the electric zap instead of damage numbers.
- ///
- private void LogAttackResult(ChangedAttributes result)
- {
- var targetName = TranslationServer.Translate(Target.Card.Name).ToString();
- var isStun = Action != null && Action.GetInt("Turn", 0) > 1;
-
- var defenceLost = -result.Target.Defence;
- if (defenceLost > 0)
- {
- ShowDamageBehaviour.SpawnDefence(defenceLost, TargetControl, TargetControl.GlobalPosition);
- BattleLogHub.Log(
- string.Format(Translate("battle/log_shield_absorb"), targetName, defenceLost),
- UI.BattleLogKind.Shield);
- }
-
- if (isStun)
- {
- Target.PlayZap();
- BattleLogHub.Log(
- string.Format(Translate("battle/log_stun"), targetName, Action.GetInt("Turn", 0)),
- UI.BattleLogKind.Status);
- return;
- }
-
- if (result.Target.Damage > 0)
- {
- ShowDamageBehaviour.SpawnDamage(result.Target.Damage, TargetControl, TargetControl.GlobalPosition);
- Target.Flash(UiTokens.DangerText);
- BattleLogHub.Log(
- string.Format(Translate("battle/log_damage"),
- Translate(Action.Name),
- TranslationServer.Translate(Source.Card.Name).ToString(),
- targetName,
- result.Target.Damage),
- UI.BattleLogKind.Damage);
- }
- }
-
- private static string Translate(string key)
- => TranslationServer.Translate(key).ToString();
-
- private void InitializeMovementPattern()
- {
- Vector2 direction = (TargetPos - SourcePos).Normalized();
- Vector2 perpendicular = new Vector2(-direction.Y, direction.X);
-
- switch (movementPattern)
- {
- case MovementPattern.Arc:
- movementControlPoint = SourcePos + direction * 0.5f + perpendicular * 2f;
- break;
-
- case MovementPattern.Zigzag:
- movementControlPoint = SourcePos + direction * 0.5f + perpendicular * 1.5f;
- break;
-
- case MovementPattern.Spiral:
- movementControlPoint = TargetPos + perpendicular * 1.5f;
- break;
-
- case MovementPattern.Dash:
- movementControlPoint = SourcePos + direction * 0.3f;
- break;
-
- default:
- movementControlPoint = TargetPos;
- break;
- }
- }
-
- private Vector2 GetMovementPosition(float progress)
- {
- switch (movementPattern)
- {
- case MovementPattern.Direct:
- return SourcePos.Lerp(TargetPos, progress);
-
- case MovementPattern.Arc:
- return SourcePos.Lerp(movementControlPoint, progress)
- .Lerp(movementControlPoint.Lerp(TargetPos, progress), progress);
-
- case MovementPattern.Zigzag:
- if (progress < 0.5f)
- return SourcePos.Lerp(movementControlPoint, progress * 2f);
- else
- return movementControlPoint.Lerp(TargetPos, (progress - 0.5f) * 2f);
-
- case MovementPattern.Spiral:
- {
- Vector2 toTarget = TargetPos - SourcePos;
- float angle = progress * Mathf.Pi * 2f;
- float radius = (1f - progress) * 1.5f;
- Vector2 spiralOffset = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
- return SourcePos.Lerp(TargetPos, progress) + spiralOffset;
- }
-
- case MovementPattern.Dash:
- if (progress < 0.3f)
- return SourcePos.Lerp(movementControlPoint, progress / 0.3f);
- else if (progress < 0.7f)
- return movementControlPoint;
- else
- return movementControlPoint.Lerp(TargetPos, (progress - 0.7f) / 0.3f);
-
- default:
- return SourcePos.Lerp(TargetPos, progress);
- }
- }
-
- private float EaseOutQuad(float t)
- {
- return 1f - (1f - t) * (1f - t);
- }
-
- private float EaseInOutQuad(float t)
- {
- return t < 0.5f ? 2f * t * t : 1f - Mathf.Pow(-2f * t + 2f, 2f) / 2f;
- }
- }
-}
+using Cthangover.Core.Battle;
+using Cthangover.Core.Battle.Actions;
+using Cthangover.Core.UI.Theming;
+using Cthangover.Core.Utils;
+using Cthangover.TurnBattle;
+using Cthangover.Core.Characters;
+using Cthangover.CardBattle.UI;
+using Godot;
+
+namespace Cthangover.CardBattle.Actions
+{
+ ///
+ /// Animated battle action that moves the source card toward an enemy target, applies damage/defence
+ /// via , and animates the target's knockback/squash reaction.
+ /// The source card follows one of five randomly-selected trajectories
+ /// (direct, arc, zigzag, spiral, dash). This is the visual counterpart of
+ /// — it handles the presentation while the executor handles the stat changes.
+ /// Created by for enemy turns.
+ ///
+ public class PhysicsAttackAction : AbstractBattleAction
+ {
+ ///
+ /// Pre-defined movement trajectory patterns for the source card's approach to the target.
+ /// Selected randomly at construction to add visual variety to enemy attack animations.
+ ///
+ public enum MovementPattern
+ {
+ Direct,
+ Arc,
+ Zigzag,
+ Spiral,
+ Dash
+ }
+
+ private ActionPhase phase = ActionPhase.Wait;
+ private float attackRotation;
+ private bool damageApplied;
+ private bool stunEffectSpawned;
+
+ private Vector2 targetOriginalPos;
+ private Vector2 targetOriginalScale;
+ private Vector2 knockbackDirection;
+ private float targetReactionIntensity = 0.3f;
+
+ private Vector2 knockbackPos;
+ private float shake;
+ private Vector2 squashScale;
+
+ private float waitDuration = 0.2f;
+ private float prepareDuration = 0.3f;
+ private float moveForAttackDuration = 0.4f;
+ private float attackImpactDuration = 0.3f;
+ private float targetReactionDuration = 0.4f;
+ private float moveForBackDuration = 0.5f;
+ private float recoverDuration = 0.3f;
+
+ private MovementPattern movementPattern = MovementPattern.Direct;
+ private Vector2 movementControlPoint;
+
+ ///
+ /// Creates an attack animation from to
+ /// using the stats defined in . The is
+ /// randomly selected on construction to make enemy attacks visually distinct.
+ /// acts as a multiplier on all phase durations.
+ ///
+ public PhysicsAttackAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1)
+ : base(source, target, action, speed)
+ {
+ movementPattern = (MovementPattern)GD.RandRange(0, 5);
+ }
+
+ /// Resets the phase clock and starts the attack sequence from the Wait phase.
+ protected override void DoInternalStart()
+ {
+ Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
+ damageApplied = false;
+ stunEffectSpawned = false;
+ phase = ActionPhase.Wait;
+ targetOriginalScale = TargetControl.Scale;
+ }
+
+ private float GetPhaseProgress()
+ {
+ float elapsed = (float)(Godot.Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Godot.Engine.TimeScale;
+ float duration = GetCurrentPhaseDuration();
+ return Mathf.Clamp(elapsed / duration, 0f, 1f);
+ }
+
+ private float GetCurrentPhaseDuration()
+ {
+ switch (phase)
+ {
+ case ActionPhase.Wait: return waitDuration;
+ case ActionPhase.Prepare: return prepareDuration;
+ case ActionPhase.MoveForAttack: return moveForAttackDuration;
+ case ActionPhase.AttackImpact: return attackImpactDuration;
+ case ActionPhase.TargetReaction: return targetReactionDuration;
+ case ActionPhase.MoveForBack: return moveForBackDuration;
+ case ActionPhase.Recover: return recoverDuration;
+ default: return 1f;
+ }
+ }
+
+ private void ResetTimestamp()
+ {
+ Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
+ }
+
+ /// Advances the multi-phase attack: dash in, impact, damage/stun application, return and recover.
+ protected override bool DoInternalAction()
+ {
+ float progress = GetPhaseProgress();
+
+ switch (phase)
+ {
+ case ActionPhase.Wait:
+ if (progress >= 1f)
+ {
+ phase = ActionPhase.Prepare;
+ ResetTimestamp();
+ targetOriginalPos = TargetControl.GlobalPosition;
+ targetOriginalScale = TargetControl.Scale;
+ knockbackDirection = (TargetControl.GlobalPosition - SourceControl.GlobalPosition).Normalized();
+ InitializeMovementPattern();
+ }
+ break;
+
+ case ActionPhase.Prepare:
+ {
+ float prepareProgress = EaseOutQuad(Mathf.Min(progress, 1f));
+ Vector2 preparePosition = GetMovementPosition(prepareProgress * 0.1f);
+ SourceControl.GlobalPosition = SourcePos.Lerp(preparePosition, prepareProgress);
+ SourceControl.Rotation = Mathf.Lerp(SourceRot, TargetRot, prepareProgress * 0.3f);
+
+ if (progress >= 1f)
+ {
+ phase = ActionPhase.MoveForAttack;
+ ResetTimestamp();
+ }
+ break;
+ }
+
+ case ActionPhase.MoveForAttack:
+ {
+ float attackProgress = EaseInOutQuad(Mathf.Min(progress, 1f));
+ Vector2 targetPosition = GetMovementPosition(attackProgress);
+ SourceControl.GlobalPosition = targetPosition;
+ SourceControl.Rotation = Mathf.Lerp(SourceRot, TargetRot, attackProgress);
+
+ if (progress >= 1f)
+ {
+ phase = ActionPhase.AttackImpact;
+ ResetTimestamp();
+ attackRotation = (float)GD.RandRange(-35f, 35f) * Mathf.Pi / 180f;
+ }
+ break;
+ }
+
+ case ActionPhase.AttackImpact:
+ {
+ float impactProgress = EaseInOutQuad(Mathf.Min(progress * 3f, 1f));
+ SourceControl.Rotation = Mathf.Lerp(TargetRot, attackRotation, impactProgress);
+
+ if (!damageApplied && progress >= 0.3f)
+ {
+ damageApplied = true;
+ var result = ActionExecutorHub.Instance.Execute(Action, Source.Card, Target.Card);
+ Source.UpdateInfo();
+ Target.UpdateInfo();
+
+ if (result.Result)
+ {
+ LogAttackResult(result);
+ }
+ else
+ {
+ GameLogger.Log("CARD_ACT",
+ $"PhysicsAttackAction: failed to execute action {Action.Name} (source={Source.Card.Name} target={Target.Card.Name})",
+ LogLevel.Error);
+ }
+ }
+
+ if (progress >= 1f)
+ {
+ phase = ActionPhase.TargetReaction;
+ ResetTimestamp();
+ }
+ break;
+ }
+
+ case ActionPhase.TargetReaction:
+ {
+ float reactionProgress = EaseInOutQuad(Mathf.Min(progress, 1f));
+
+ bool isStun = Action != null && Action.GetInt("Turn", 0) > 1;
+ float currentKnockback = targetReactionIntensity;
+ if (isStun)
+ currentKnockback *= 2.5f;
+
+ knockbackPos = targetOriginalPos + knockbackDirection * currentKnockback;
+ TargetControl.GlobalPosition = targetOriginalPos.Lerp(knockbackPos, reactionProgress);
+
+ squashScale = targetOriginalScale;
+ float currentSquash = isStun ? 0.35f : 0.2f;
+ squashScale.X *= 1f - reactionProgress * currentSquash;
+ squashScale.Y *= 1f + reactionProgress * (currentSquash * 0.5f);
+ TargetControl.Scale = squashScale;
+
+ float currentShakeMultiplier = isStun ? 1.8f : 1f;
+ shake = Mathf.Sin(reactionProgress * Mathf.Pi * (isStun ? 6f : 4f)) * (1f - reactionProgress) * 0.05f * currentShakeMultiplier;
+ TargetControl.Rotation = shake * 30f * Mathf.Pi / 180f;
+
+ if (progress >= 1f)
+ {
+ phase = ActionPhase.MoveForBack;
+ ResetTimestamp();
+ }
+ break;
+ }
+
+ case ActionPhase.MoveForBack:
+ {
+ float backProgress = EaseOutQuad(Mathf.Min(progress, 1f));
+ Vector2 backPosition = GetMovementPosition(1f - progress);
+ SourceControl.GlobalPosition = backPosition.Lerp(SourcePos, backProgress);
+ SourceControl.Rotation = Mathf.Lerp(attackRotation, SourceRot, backProgress);
+
+ TargetControl.GlobalPosition = knockbackPos.Lerp(targetOriginalPos, backProgress);
+ TargetControl.Scale = squashScale.Lerp(targetOriginalScale, backProgress);
+ float currentShake = Mathf.Sin(backProgress * Mathf.Pi) * shake * (1f - backProgress);
+ TargetControl.Rotation = Mathf.Lerp(currentShake * 30f * Mathf.Pi / 180f, TargetRot, backProgress);
+
+ if (progress >= 1f)
+ {
+ phase = ActionPhase.Recover;
+ ResetTimestamp();
+ }
+ break;
+ }
+
+ case ActionPhase.Recover:
+ {
+ float recoverProgress = EaseOutQuad(Mathf.Min(progress, 1f));
+
+ SourceControl.GlobalPosition = SourceControl.GlobalPosition.Lerp(SourcePos, recoverProgress);
+ SourceControl.Rotation = Mathf.Lerp(SourceControl.Rotation, SourceRot, recoverProgress);
+ TargetControl.GlobalPosition = TargetControl.GlobalPosition.Lerp(TargetPos, recoverProgress);
+ TargetControl.Rotation = Mathf.Lerp(TargetControl.Rotation, TargetRot, recoverProgress);
+ TargetControl.Scale = TargetControl.Scale.Lerp(targetOriginalScale, recoverProgress);
+
+ if (progress >= 1f)
+ return true;
+ break;
+ }
+
+ default:
+ return true;
+ }
+
+ return false;
+ }
+
+ /// Restores the target's captured position, scale and rotation after the attack animation.
+ protected override void DoInternalEnd()
+ {
+ if (SourceControl != null)
+ {
+ SourceControl.GlobalPosition = SourcePos;
+ SourceControl.Rotation = SourceRot;
+ }
+ if (TargetControl != null)
+ {
+ TargetControl.GlobalPosition = TargetPos;
+ TargetControl.Rotation = TargetRot;
+ TargetControl.Scale = targetOriginalScale;
+ }
+ }
+
+ ///
+ /// Mirrors the resolved action onto the UI: floating damage / absorbed
+ /// shield numbers, a colour pulse on the target card and journal rows, so
+ /// an AI attack reads the same as the player's own attack. Stun actions
+ /// (a "Turn" above 1) flash the electric zap instead of damage numbers.
+ ///
+ private void LogAttackResult(ChangedAttributes result)
+ {
+ var targetName = TranslationServer.Translate(Target.Card.Name).ToString();
+ var isStun = Action != null && Action.GetInt("Turn", 0) > 1;
+
+ var defenceLost = -result.Target.Defence;
+ if (defenceLost > 0)
+ {
+ ShowDamageBehaviour.SpawnDefence(defenceLost, TargetControl, TargetControl.GlobalPosition);
+ BattleLogHub.Log(
+ string.Format(Translate("battle/log_shield_absorb"), targetName, defenceLost),
+ UI.BattleLogKind.Shield);
+ }
+
+ if (isStun)
+ {
+ Target.PlayZap();
+ BattleLogHub.Log(
+ string.Format(Translate("battle/log_stun"), targetName, Action.GetInt("Turn", 0)),
+ UI.BattleLogKind.Status);
+ return;
+ }
+
+ if (result.Target.Damage > 0)
+ {
+ ShowDamageBehaviour.SpawnDamage(result.Target.Damage, TargetControl, TargetControl.GlobalPosition);
+ Target.Flash(UiTokens.DangerText);
+ BattleLogHub.Log(
+ string.Format(Translate("battle/log_damage"),
+ Translate(Action.Name),
+ TranslationServer.Translate(Source.Card.Name).ToString(),
+ targetName,
+ result.Target.Damage),
+ UI.BattleLogKind.Damage);
+ }
+ }
+
+ private static string Translate(string key)
+ => TranslationServer.Translate(key).ToString();
+
+ private void InitializeMovementPattern()
+ {
+ Vector2 direction = (TargetPos - SourcePos).Normalized();
+ Vector2 perpendicular = new Vector2(-direction.Y, direction.X);
+
+ switch (movementPattern)
+ {
+ case MovementPattern.Arc:
+ movementControlPoint = SourcePos + direction * 0.5f + perpendicular * 2f;
+ break;
+
+ case MovementPattern.Zigzag:
+ movementControlPoint = SourcePos + direction * 0.5f + perpendicular * 1.5f;
+ break;
+
+ case MovementPattern.Spiral:
+ movementControlPoint = TargetPos + perpendicular * 1.5f;
+ break;
+
+ case MovementPattern.Dash:
+ movementControlPoint = SourcePos + direction * 0.3f;
+ break;
+
+ default:
+ movementControlPoint = TargetPos;
+ break;
+ }
+ }
+
+ private Vector2 GetMovementPosition(float progress)
+ {
+ switch (movementPattern)
+ {
+ case MovementPattern.Direct:
+ return SourcePos.Lerp(TargetPos, progress);
+
+ case MovementPattern.Arc:
+ return SourcePos.Lerp(movementControlPoint, progress)
+ .Lerp(movementControlPoint.Lerp(TargetPos, progress), progress);
+
+ case MovementPattern.Zigzag:
+ if (progress < 0.5f)
+ return SourcePos.Lerp(movementControlPoint, progress * 2f);
+ else
+ return movementControlPoint.Lerp(TargetPos, (progress - 0.5f) * 2f);
+
+ case MovementPattern.Spiral:
+ {
+ Vector2 toTarget = TargetPos - SourcePos;
+ float angle = progress * Mathf.Pi * 2f;
+ float radius = (1f - progress) * 1.5f;
+ Vector2 spiralOffset = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
+ return SourcePos.Lerp(TargetPos, progress) + spiralOffset;
+ }
+
+ case MovementPattern.Dash:
+ if (progress < 0.3f)
+ return SourcePos.Lerp(movementControlPoint, progress / 0.3f);
+ else if (progress < 0.7f)
+ return movementControlPoint;
+ else
+ return movementControlPoint.Lerp(TargetPos, (progress - 0.7f) / 0.3f);
+
+ default:
+ return SourcePos.Lerp(TargetPos, progress);
+ }
+ }
+
+ private float EaseOutQuad(float t)
+ {
+ return 1f - (1f - t) * (1f - t);
+ }
+
+ private float EaseInOutQuad(float t)
+ {
+ return t < 0.5f ? 2f * t * t : 1f - Mathf.Pow(-2f * t + 2f, 2f) / 2f;
+ }
+ }
+}
diff --git a/src/Actions/PhysicsDamageActionCard.cs b/src/Actions/PhysicsDamageActionCard.cs
index bcf79fa..df6e13b 100644
--- a/src/Actions/PhysicsDamageActionCard.cs
+++ b/src/Actions/PhysicsDamageActionCard.cs
@@ -1,56 +1,56 @@
-using Cthangover.Core.Cards.StatusEffect;
-using Cthangover.Core.Characters;
-using Godot;
-
-namespace Cthangover.CardBattle.Actions
-{
-
- ///
- /// Executor that applies physical damage from an attack card to a target.
- /// Calculates damage as card_attack_multiplier × user_attack_stat, reduces it by
- /// the target's current defence value (which is consumed in the process), and applies the
- /// remainder to the target's health. Invokes OnTakeDamage/OnDealDamage hooks
- /// on both characters' so that damage-modifying status effects
- /// can intercept and adjust the values before they are applied.
- /// Registered in under "physics/attack".
- ///
- public class PhysicsDamageActionCard : ActionBase
- {
-
- ///
- /// Unique identifier registered in as "physics/attack".
- ///
- public override string ID => "PhysicsDamageActionCard";
-
- ///
- /// Resolves the attack: deducts action points from via CheckRequiredAndUsePoint,
- /// computes raw damage from the card's attack multiplier and the user's attack stat,
- /// applies status-effect damage hooks, consumes 's defence shield first,
- /// then subtracts remaining damage from health. Returns a with
- /// the actual damage and defence-loss deltas for the floating text display.
- ///
- public override ChangedAttributes Execute(ActionCharacter actionCard, Character user, Character target)
- {
- if (target == null || user == null || !CheckRequiredAndUsePoint(actionCard, user))
- return new ChangedAttributes { Result = false };
-
- var damageDelta = Mathf.RoundToInt(actionCard.GetFloat("Attack", 1f) * user.Attributes.Attack.Value);
- StatusEffectService.Of(target).OnTakeDamage(user, ref damageDelta);
- StatusEffectService.Of(user).OnDealDamage(target, ref damageDelta);
-
- var defenceDelta = 0;
- var ignoreArmor = user.HasPassiveEffect("IgnoreArmor");
- if (!ignoreArmor && target.Attributes.Defence.Value > 0)
- {
- defenceDelta = target.Attributes.Defence.Value >= damageDelta ? damageDelta : target.Attributes.Defence.Value;
- damageDelta -= defenceDelta;
- }
- target.Attributes.Defence.Value -= defenceDelta;
- target.Attributes.Health.Value -= damageDelta;
-
- return new ChangedAttributes { Result = true, Target = { Damage = damageDelta, Defence = -defenceDelta}};
- }
-
- }
-
-}
+using Cthangover.Core.Cards.StatusEffect;
+using Cthangover.Core.Characters;
+using Godot;
+
+namespace Cthangover.CardBattle.Actions
+{
+
+ ///
+ /// Executor that applies physical damage from an attack card to a target.
+ /// Calculates damage as card_attack_multiplier × user_attack_stat, reduces it by
+ /// the target's current defence value (which is consumed in the process), and applies the
+ /// remainder to the target's health. Invokes OnTakeDamage/OnDealDamage hooks
+ /// on both characters' so that damage-modifying status effects
+ /// can intercept and adjust the values before they are applied.
+ /// Registered in under "physics/attack".
+ ///
+ public class PhysicsDamageActionCard : ActionBase
+ {
+
+ ///
+ /// Unique identifier registered in as "physics/attack".
+ ///
+ public override string ID => "PhysicsDamageActionCard";
+
+ ///
+ /// Resolves the attack: deducts action points from via CheckRequiredAndUsePoint,
+ /// computes raw damage from the card's attack multiplier and the user's attack stat,
+ /// applies status-effect damage hooks, consumes 's defence shield first,
+ /// then subtracts remaining damage from health. Returns a with
+ /// the actual damage and defence-loss deltas for the floating text display.
+ ///
+ public override ChangedAttributes Execute(ActionCharacter actionCard, Character user, Character target)
+ {
+ if (target == null || user == null || !CheckRequiredAndUsePoint(actionCard, user))
+ return new ChangedAttributes { Result = false };
+
+ var damageDelta = Mathf.RoundToInt(actionCard.GetFloat("Attack", 1f) * user.Attributes.Attack.Value);
+ StatusEffectService.Of(target).OnTakeDamage(user, ref damageDelta);
+ StatusEffectService.Of(user).OnDealDamage(target, ref damageDelta);
+
+ var defenceDelta = 0;
+ var ignoreArmor = user.HasPassiveEffect("IgnoreArmor");
+ if (!ignoreArmor && target.Attributes.Defence.Value > 0)
+ {
+ defenceDelta = target.Attributes.Defence.Value >= damageDelta ? damageDelta : target.Attributes.Defence.Value;
+ damageDelta -= defenceDelta;
+ }
+ target.Attributes.Defence.Value -= defenceDelta;
+ target.Attributes.Health.Value -= damageDelta;
+
+ return new ChangedAttributes { Result = true, Target = { Damage = damageDelta, Defence = -defenceDelta}};
+ }
+
+ }
+
+}
diff --git a/src/Actions/PhysicsDefenceAction.cs b/src/Actions/PhysicsDefenceAction.cs
index a7b807a..bbc7c63 100644
--- a/src/Actions/PhysicsDefenceAction.cs
+++ b/src/Actions/PhysicsDefenceAction.cs
@@ -1,231 +1,231 @@
-using Cthangover.Core.Battle;
-using Cthangover.Core.Battle.Actions;
-using Cthangover.Core.UI.Theming;
-using Cthangover.Core.Characters;
-using Cthangover.TurnBattle;
-using Cthangover.CardBattle.UI;
-using Godot;
-
-namespace Cthangover.CardBattle.Actions
-{
- ///
- /// Animated battle action that plays a self-buff or ally-support animation: the source card
- /// briefly rotates toward the target, applies a defence boost via ,
- /// and shows a subtle block/stagger reaction on the target. Used for both ToSelf and
- /// ToAlias action types. This is the visual counterpart of .
- /// Created by for enemy self-buff/support turns.
- ///
- public class PhysicsDefenceAction : AbstractBattleAction
- {
- private ActionPhase phase = ActionPhase.Prepare;
- private float defendRotation;
- private bool effectApplied;
-
- private Vector2 targetOriginalScale;
- private Color targetOriginalColor;
-
- private Vector2 blockScale;
- private Vector2 blockKnockback;
- private Color blockColor;
-
- private float prepareDuration = 0.3f;
- private float attackImpactDuration = 0.4f;
- private float targetReactionDuration = 0.3f;
- private float moveForBackDuration = 0.4f;
- private float recoverDuration = 0.3f;
-
- ///
- /// Creates a defence/support animation. Both and
- /// can be the same card (for self-buffs) or different (for ally support).
- /// multiplies all phase durations.
- ///
- public PhysicsDefenceAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1)
- : base(source, target, action, speed) { }
-
- /// Resets the phase clock and captures the target's original scale and colour.
- protected override void DoInternalStart()
- {
- Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
- effectApplied = false;
- phase = ActionPhase.Prepare;
- targetOriginalScale = TargetControl.Scale;
- targetOriginalColor = TargetControl.Modulate;
- }
-
- private float GetPhaseProgress()
- {
- float elapsed = (float)(Godot.Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Godot.Engine.TimeScale;
- float duration = GetCurrentPhaseDuration();
- return Mathf.Clamp(elapsed / duration, 0f, 1f);
- }
-
- private float GetCurrentPhaseDuration()
- {
- switch (phase)
- {
- case ActionPhase.Prepare: return prepareDuration;
- case ActionPhase.AttackImpact: return attackImpactDuration;
- case ActionPhase.TargetReaction: return targetReactionDuration;
- case ActionPhase.MoveForBack: return moveForBackDuration;
- case ActionPhase.Recover: return recoverDuration;
- default: return 1f;
- }
- }
-
- private void ResetTimestamp()
- {
- Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
- }
-
- /// Advances the guard animation: approach, impact, defence stat application, return and recover.
- protected override bool DoInternalAction()
- {
- float progress = GetPhaseProgress();
-
- switch (phase)
- {
- case ActionPhase.Prepare:
- {
- float prepareProgress = EaseOutQuad(Mathf.Min(progress, 1f));
- SourceControl.Rotation = Mathf.Lerp(SourceRot, TargetRot, prepareProgress);
-
- if (progress >= 1f)
- {
- phase = ActionPhase.AttackImpact;
- ResetTimestamp();
- defendRotation = (float)GD.RandRange(-15f, 15f) * Mathf.Pi / 180f;
-
- targetOriginalScale = TargetControl.Scale;
- targetOriginalColor = TargetControl.Modulate;
- }
- break;
- }
-
- case ActionPhase.AttackImpact:
- {
- float impactProgress = EaseInOutQuad(Mathf.Min(progress * 2f, 1f));
- SourceControl.Rotation = Mathf.Lerp(TargetRot, defendRotation, impactProgress);
-
- blockScale = targetOriginalScale * (1f + impactProgress * 0.1f);
- TargetControl.Scale = blockScale;
-
- blockColor = targetOriginalColor;
- blockColor.G = Mathf.Min(1f, blockColor.G + impactProgress * 0.3f);
- blockColor.B = Mathf.Min(1f, blockColor.B + impactProgress * 0.2f);
- TargetControl.Modulate = blockColor;
-
- if (!effectApplied && progress >= 0.4f)
- {
- effectApplied = true;
- var result = ActionExecutorHub.Instance.Execute(Action, Source.Card, Target.Card);
- Source.UpdateInfo();
- Target.UpdateInfo();
-
- if (result.Result && result.Target.Defence > 0)
- {
- ShowDamageBehaviour.SpawnDefence(result.Target.Defence, TargetControl, TargetControl.GlobalPosition);
- Target.Flash(UiTokens.Info);
- BattleLogHub.Log(
- string.Format(Translate("battle/log_shield_gain"),
- TranslationServer.Translate(Target.Card.Name).ToString(),
- result.Target.Defence),
- UI.BattleLogKind.Buff);
- }
- }
-
- if (progress >= 1f)
- {
- phase = ActionPhase.TargetReaction;
- ResetTimestamp();
- }
- break;
- }
-
- case ActionPhase.TargetReaction:
- {
- float reactionProgress = EaseInOutQuad(Mathf.Min(progress * 1.5f, 1f));
-
- blockKnockback = TargetPos + (TargetPos - SourcePos).Normalized() * 0.1f;
- TargetControl.GlobalPosition = TargetPos.Lerp(blockKnockback, reactionProgress * 0.5f);
-
- TargetControl.Scale = blockScale.Lerp(targetOriginalScale, reactionProgress);
-
- if (progress >= 1f)
- {
- phase = ActionPhase.MoveForBack;
- ResetTimestamp();
- }
- break;
- }
-
- case ActionPhase.MoveForBack:
- {
- float backProgress = EaseOutQuad(Mathf.Min(progress, 1f));
- SourceControl.GlobalPosition = TargetPos.Lerp(SourcePos, backProgress);
- SourceControl.Rotation = Mathf.Lerp(defendRotation, SourceRot, backProgress);
-
- TargetControl.GlobalPosition = TargetControl.GlobalPosition.Lerp(TargetPos, backProgress);
- TargetControl.Scale = TargetControl.Scale.Lerp(targetOriginalScale, backProgress);
-
- Color currentColor = TargetControl.Modulate;
- TargetControl.Modulate = currentColor.Lerp(targetOriginalColor, backProgress);
-
- if (progress >= 1f)
- {
- phase = ActionPhase.Recover;
- ResetTimestamp();
- }
- break;
- }
-
- case ActionPhase.Recover:
- {
- float recoverProgress = EaseOutQuad(Mathf.Min(progress, 1f));
-
- SourceControl.GlobalPosition = SourceControl.GlobalPosition.Lerp(SourcePos, recoverProgress);
- SourceControl.Rotation = Mathf.Lerp(SourceControl.Rotation, SourceRot, recoverProgress);
- TargetControl.Rotation = Mathf.Lerp(TargetControl.Rotation, TargetRot, recoverProgress);
-
- if (progress >= 1f)
- return true;
- break;
- }
-
- default:
- return true;
- }
-
- return false;
- }
-
- /// Restores the target's captured visual state after the defence animation.
- protected override void DoInternalEnd()
- {
- if (SourceControl != null)
- {
- SourceControl.GlobalPosition = SourcePos;
- SourceControl.Rotation = SourceRot;
- }
- if (TargetControl != null)
- {
- TargetControl.GlobalPosition = TargetPos;
- TargetControl.Rotation = TargetRot;
- TargetControl.Scale = targetOriginalScale;
- TargetControl.Modulate = targetOriginalColor;
- }
- }
-
- private float EaseOutQuad(float t)
- {
- return 1f - (1f - t) * (1f - t);
- }
-
- private float EaseInOutQuad(float t)
- {
- return t < 0.5f ? 2f * t * t : 1f - Mathf.Pow(-2f * t + 2f, 2f) / 2f;
- }
-
- private static string Translate(string key)
- => TranslationServer.Translate(key).ToString();
- }
-}
+using Cthangover.Core.Battle;
+using Cthangover.Core.Battle.Actions;
+using Cthangover.Core.UI.Theming;
+using Cthangover.Core.Characters;
+using Cthangover.TurnBattle;
+using Cthangover.CardBattle.UI;
+using Godot;
+
+namespace Cthangover.CardBattle.Actions
+{
+ ///
+ /// Animated battle action that plays a self-buff or ally-support animation: the source card
+ /// briefly rotates toward the target, applies a defence boost via ,
+ /// and shows a subtle block/stagger reaction on the target. Used for both ToSelf and
+ /// ToAlias action types. This is the visual counterpart of .
+ /// Created by for enemy self-buff/support turns.
+ ///
+ public class PhysicsDefenceAction : AbstractBattleAction
+ {
+ private ActionPhase phase = ActionPhase.Prepare;
+ private float defendRotation;
+ private bool effectApplied;
+
+ private Vector2 targetOriginalScale;
+ private Color targetOriginalColor;
+
+ private Vector2 blockScale;
+ private Vector2 blockKnockback;
+ private Color blockColor;
+
+ private float prepareDuration = 0.3f;
+ private float attackImpactDuration = 0.4f;
+ private float targetReactionDuration = 0.3f;
+ private float moveForBackDuration = 0.4f;
+ private float recoverDuration = 0.3f;
+
+ ///
+ /// Creates a defence/support animation. Both and
+ /// can be the same card (for self-buffs) or different (for ally support).
+ /// multiplies all phase durations.
+ ///
+ public PhysicsDefenceAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1)
+ : base(source, target, action, speed) { }
+
+ /// Resets the phase clock and captures the target's original scale and colour.
+ protected override void DoInternalStart()
+ {
+ Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
+ effectApplied = false;
+ phase = ActionPhase.Prepare;
+ targetOriginalScale = TargetControl.Scale;
+ targetOriginalColor = TargetControl.Modulate;
+ }
+
+ private float GetPhaseProgress()
+ {
+ float elapsed = (float)(Godot.Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Godot.Engine.TimeScale;
+ float duration = GetCurrentPhaseDuration();
+ return Mathf.Clamp(elapsed / duration, 0f, 1f);
+ }
+
+ private float GetCurrentPhaseDuration()
+ {
+ switch (phase)
+ {
+ case ActionPhase.Prepare: return prepareDuration;
+ case ActionPhase.AttackImpact: return attackImpactDuration;
+ case ActionPhase.TargetReaction: return targetReactionDuration;
+ case ActionPhase.MoveForBack: return moveForBackDuration;
+ case ActionPhase.Recover: return recoverDuration;
+ default: return 1f;
+ }
+ }
+
+ private void ResetTimestamp()
+ {
+ Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
+ }
+
+ /// Advances the guard animation: approach, impact, defence stat application, return and recover.
+ protected override bool DoInternalAction()
+ {
+ float progress = GetPhaseProgress();
+
+ switch (phase)
+ {
+ case ActionPhase.Prepare:
+ {
+ float prepareProgress = EaseOutQuad(Mathf.Min(progress, 1f));
+ SourceControl.Rotation = Mathf.Lerp(SourceRot, TargetRot, prepareProgress);
+
+ if (progress >= 1f)
+ {
+ phase = ActionPhase.AttackImpact;
+ ResetTimestamp();
+ defendRotation = (float)GD.RandRange(-15f, 15f) * Mathf.Pi / 180f;
+
+ targetOriginalScale = TargetControl.Scale;
+ targetOriginalColor = TargetControl.Modulate;
+ }
+ break;
+ }
+
+ case ActionPhase.AttackImpact:
+ {
+ float impactProgress = EaseInOutQuad(Mathf.Min(progress * 2f, 1f));
+ SourceControl.Rotation = Mathf.Lerp(TargetRot, defendRotation, impactProgress);
+
+ blockScale = targetOriginalScale * (1f + impactProgress * 0.1f);
+ TargetControl.Scale = blockScale;
+
+ blockColor = targetOriginalColor;
+ blockColor.G = Mathf.Min(1f, blockColor.G + impactProgress * 0.3f);
+ blockColor.B = Mathf.Min(1f, blockColor.B + impactProgress * 0.2f);
+ TargetControl.Modulate = blockColor;
+
+ if (!effectApplied && progress >= 0.4f)
+ {
+ effectApplied = true;
+ var result = ActionExecutorHub.Instance.Execute(Action, Source.Card, Target.Card);
+ Source.UpdateInfo();
+ Target.UpdateInfo();
+
+ if (result.Result && result.Target.Defence > 0)
+ {
+ ShowDamageBehaviour.SpawnDefence(result.Target.Defence, TargetControl, TargetControl.GlobalPosition);
+ Target.Flash(UiTokens.Info);
+ BattleLogHub.Log(
+ string.Format(Translate("battle/log_shield_gain"),
+ TranslationServer.Translate(Target.Card.Name).ToString(),
+ result.Target.Defence),
+ UI.BattleLogKind.Buff);
+ }
+ }
+
+ if (progress >= 1f)
+ {
+ phase = ActionPhase.TargetReaction;
+ ResetTimestamp();
+ }
+ break;
+ }
+
+ case ActionPhase.TargetReaction:
+ {
+ float reactionProgress = EaseInOutQuad(Mathf.Min(progress * 1.5f, 1f));
+
+ blockKnockback = TargetPos + (TargetPos - SourcePos).Normalized() * 0.1f;
+ TargetControl.GlobalPosition = TargetPos.Lerp(blockKnockback, reactionProgress * 0.5f);
+
+ TargetControl.Scale = blockScale.Lerp(targetOriginalScale, reactionProgress);
+
+ if (progress >= 1f)
+ {
+ phase = ActionPhase.MoveForBack;
+ ResetTimestamp();
+ }
+ break;
+ }
+
+ case ActionPhase.MoveForBack:
+ {
+ float backProgress = EaseOutQuad(Mathf.Min(progress, 1f));
+ SourceControl.GlobalPosition = TargetPos.Lerp(SourcePos, backProgress);
+ SourceControl.Rotation = Mathf.Lerp(defendRotation, SourceRot, backProgress);
+
+ TargetControl.GlobalPosition = TargetControl.GlobalPosition.Lerp(TargetPos, backProgress);
+ TargetControl.Scale = TargetControl.Scale.Lerp(targetOriginalScale, backProgress);
+
+ Color currentColor = TargetControl.Modulate;
+ TargetControl.Modulate = currentColor.Lerp(targetOriginalColor, backProgress);
+
+ if (progress >= 1f)
+ {
+ phase = ActionPhase.Recover;
+ ResetTimestamp();
+ }
+ break;
+ }
+
+ case ActionPhase.Recover:
+ {
+ float recoverProgress = EaseOutQuad(Mathf.Min(progress, 1f));
+
+ SourceControl.GlobalPosition = SourceControl.GlobalPosition.Lerp(SourcePos, recoverProgress);
+ SourceControl.Rotation = Mathf.Lerp(SourceControl.Rotation, SourceRot, recoverProgress);
+ TargetControl.Rotation = Mathf.Lerp(TargetControl.Rotation, TargetRot, recoverProgress);
+
+ if (progress >= 1f)
+ return true;
+ break;
+ }
+
+ default:
+ return true;
+ }
+
+ return false;
+ }
+
+ /// Restores the target's captured visual state after the defence animation.
+ protected override void DoInternalEnd()
+ {
+ if (SourceControl != null)
+ {
+ SourceControl.GlobalPosition = SourcePos;
+ SourceControl.Rotation = SourceRot;
+ }
+ if (TargetControl != null)
+ {
+ TargetControl.GlobalPosition = TargetPos;
+ TargetControl.Rotation = TargetRot;
+ TargetControl.Scale = targetOriginalScale;
+ TargetControl.Modulate = targetOriginalColor;
+ }
+ }
+
+ private float EaseOutQuad(float t)
+ {
+ return 1f - (1f - t) * (1f - t);
+ }
+
+ private float EaseInOutQuad(float t)
+ {
+ return t < 0.5f ? 2f * t * t : 1f - Mathf.Pow(-2f * t + 2f, 2f) / 2f;
+ }
+
+ private static string Translate(string key)
+ => TranslationServer.Translate(key).ToString();
+ }
+}
diff --git a/src/Actions/PhysicsDefenceActionCard.cs b/src/Actions/PhysicsDefenceActionCard.cs
index dc0e180..da51abc 100644
--- a/src/Actions/PhysicsDefenceActionCard.cs
+++ b/src/Actions/PhysicsDefenceActionCard.cs
@@ -1,39 +1,39 @@
-using Cthangover.Core.Characters;
-
-namespace Cthangover.CardBattle.Actions
-{
-
- ///
- /// Executor that adds a fixed amount of defence to the target character.
- /// Defence acts as a damage-absorbing shield that is consumed before health when the
- /// character takes damage (see ). The defence delta
- /// comes from the "Defence" property on the action card.
- /// Registered in under "physics/defence".
- ///
- public class PhysicsDefenceActionCard : ActionBase
- {
-
- ///
- /// Unique identifier registered in as "physics/defence".
- ///
- public override string ID => "PhysicsDefenceActionCard";
-
- ///
- /// Adds a flat defence value to after deducting action points
- /// from . The returned carries the
- /// defence delta for floating text display.
- ///
- public override ChangedAttributes Execute(ActionCharacter actionCard, Character user, Character target)
- {
- if (target == null || user == null || !CheckRequiredAndUsePoint(actionCard, user))
- return new ChangedAttributes { Result = false };
-
- var defenceDelta = actionCard.GetInt("Defence");
- target.Attributes.Defence.Value += defenceDelta;
-
- return new ChangedAttributes { Result = true, Target = { Defence = defenceDelta}};
- }
-
- }
-
-}
+using Cthangover.Core.Characters;
+
+namespace Cthangover.CardBattle.Actions
+{
+
+ ///
+ /// Executor that adds a fixed amount of defence to the target character.
+ /// Defence acts as a damage-absorbing shield that is consumed before health when the
+ /// character takes damage (see ). The defence delta
+ /// comes from the "Defence" property on the action card.
+ /// Registered in under "physics/defence".
+ ///
+ public class PhysicsDefenceActionCard : ActionBase
+ {
+
+ ///
+ /// Unique identifier registered in as "physics/defence".
+ ///
+ public override string ID => "PhysicsDefenceActionCard";
+
+ ///
+ /// Adds a flat defence value to after deducting action points
+ /// from . The returned carries the
+ /// defence delta for floating text display.
+ ///
+ public override ChangedAttributes Execute(ActionCharacter actionCard, Character user, Character target)
+ {
+ if (target == null || user == null || !CheckRequiredAndUsePoint(actionCard, user))
+ return new ChangedAttributes { Result = false };
+
+ var defenceDelta = actionCard.GetInt("Defence");
+ target.Attributes.Defence.Value += defenceDelta;
+
+ return new ChangedAttributes { Result = true, Target = { Defence = defenceDelta}};
+ }
+
+ }
+
+}
diff --git a/src/Actions/PhysicsStunActionCard.cs b/src/Actions/PhysicsStunActionCard.cs
index 2c1a21d..462041a 100644
--- a/src/Actions/PhysicsStunActionCard.cs
+++ b/src/Actions/PhysicsStunActionCard.cs
@@ -1,40 +1,40 @@
-using Cthangover.Core.Cards.StatusEffect;
-using Cthangover.Core.Characters;
-
-namespace Cthangover.CardBattle.Actions
-{
-
- ///
- /// Executor that applies a stun status effect to the target character.
- /// The stun duration (number of turns) is read from the "Turn" property on the action card.
- /// During a stunned character's turn, returns true,
- /// causing to skip that character's actions entirely.
- /// Registered in under "physics/stun".
- ///
- public class PhysicsStunActionCard : ActionBase
- {
-
- ///
- /// Unique identifier registered in as "physics/stun".
- ///
- public override string ID => "PhysicsStunActionCard";
-
- ///
- /// Adds a with the configured turn count to 's
- /// after deducting action points from .
- ///
- public override ChangedAttributes Execute(ActionCharacter actionCard, Character user, Character target)
- {
- if (target == null || user == null || !CheckRequiredAndUsePoint(actionCard, user))
- return new ChangedAttributes { Result = false };
-
- if (target.HasPassiveEffect("StunImmune"))
- return new ChangedAttributes { Result = true };
-
- StatusEffectService.Of(target).Add("effect/physics/stun", actionCard.GetInt("Turn", 3));
-
- return new ChangedAttributes { Result = true };
- }
- }
-
-}
+using Cthangover.Core.Cards.StatusEffect;
+using Cthangover.Core.Characters;
+
+namespace Cthangover.CardBattle.Actions
+{
+
+ ///
+ /// Executor that applies a stun status effect to the target character.
+ /// The stun duration (number of turns) is read from the "Turn" property on the action card.
+ /// During a stunned character's turn, returns true,
+ /// causing to skip that character's actions entirely.
+ /// Registered in under "physics/stun".
+ ///
+ public class PhysicsStunActionCard : ActionBase
+ {
+
+ ///
+ /// Unique identifier registered in as "physics/stun".
+ ///
+ public override string ID => "PhysicsStunActionCard";
+
+ ///
+ /// Adds a with the configured turn count to 's
+ /// after deducting action points from .
+ ///
+ public override ChangedAttributes Execute(ActionCharacter actionCard, Character user, Character target)
+ {
+ if (target == null || user == null || !CheckRequiredAndUsePoint(actionCard, user))
+ return new ChangedAttributes { Result = false };
+
+ if (target.HasPassiveEffect("StunImmune"))
+ return new ChangedAttributes { Result = true };
+
+ StatusEffectService.Of(target).Add("effect/physics/stun", actionCard.GetInt("Turn", 3));
+
+ return new ChangedAttributes { Result = true };
+ }
+ }
+
+}
diff --git a/src/Player/CardActionStrategyFactory.cs b/src/Player/CardActionStrategyFactory.cs
index 3c78a92..1224ca6 100644
--- a/src/Player/CardActionStrategyFactory.cs
+++ b/src/Player/CardActionStrategyFactory.cs
@@ -1,52 +1,52 @@
-using Cthangover.CardBattle.UI;
-using Cthangover.Core.Characters;
-
-namespace Cthangover.CardBattle.Player
-{
- ///
- /// Factory that returns the appropriate implementation
- /// for a given . Dispatches based on :
- /// ToEnemy → ,
- /// ToAlias → ,
- /// ToSelf → .
- /// Used by during drag-and-drop to determine valid targeting behavior
- /// and by for pre-validation.
- ///
- public static class CardActionStrategyFactory
- {
- ///
- /// Returns the matching strategy for the action's type, or null if any argument is null
- /// or the type is unrecognized.
- ///
- public static ICardActionStrategy Get(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
- {
- if (action == null || source == null || target == null)
- return null;
-
- switch (action.Type)
- {
- case ActionCharacterType.ToEnemy:
- return new AttackToEnemyStrategy();
- case ActionCharacterType.ToAlias:
- return new SupportStrategy();
- case ActionCharacterType.ToSelf:
- return new SelfStrategy();
- default:
- return null;
- }
- }
-
- ///
- /// Convenience method that gets the strategy via and immediately calls
- /// on it. Returns false if no strategy matches
- /// or any argument is null.
- ///
- public static bool CanTarget(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
- {
- if (action == null || source == null || target == null)
- return false;
-
- return Get(action, source, target)?.Check(action, source, target) ?? false;
- }
- }
-}
+using Cthangover.CardBattle.UI;
+using Cthangover.Core.Characters;
+
+namespace Cthangover.CardBattle.Player
+{
+ ///
+ /// Factory that returns the appropriate implementation
+ /// for a given . Dispatches based on :
+ /// ToEnemy → ,
+ /// ToAlias → ,
+ /// ToSelf → .
+ /// Used by during drag-and-drop to determine valid targeting behavior
+ /// and by for pre-validation.
+ ///
+ public static class CardActionStrategyFactory
+ {
+ ///
+ /// Returns the matching strategy for the action's type, or null if any argument is null
+ /// or the type is unrecognized.
+ ///
+ public static ICardActionStrategy Get(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
+ {
+ if (action == null || source == null || target == null)
+ return null;
+
+ switch (action.Type)
+ {
+ case ActionCharacterType.ToEnemy:
+ return new AttackToEnemyStrategy();
+ case ActionCharacterType.ToAlias:
+ return new SupportStrategy();
+ case ActionCharacterType.ToSelf:
+ return new SelfStrategy();
+ default:
+ return null;
+ }
+ }
+
+ ///
+ /// Convenience method that gets the strategy via and immediately calls
+ /// on it. Returns false if no strategy matches
+ /// or any argument is null.
+ ///
+ public static bool CanTarget(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
+ {
+ if (action == null || source == null || target == null)
+ return false;
+
+ return Get(action, source, target)?.Check(action, source, target) ?? false;
+ }
+ }
+}
diff --git a/src/Player/ICardActionStrategy.cs b/src/Player/ICardActionStrategy.cs
index 99ee1c9..0662d81 100644
--- a/src/Player/ICardActionStrategy.cs
+++ b/src/Player/ICardActionStrategy.cs
@@ -1,32 +1,32 @@
-using Cthangover.CardBattle.UI;
-using Cthangover.Core.Characters;
-
-namespace Cthangover.CardBattle.Player
-{
- ///
- /// Strategy interface that defines validation, visual highlighting, and execution for
- /// a specific type of card action during the player's drag-and-drop targeting flow.
- /// The dispatches to the correct strategy based on
- /// the action's . calls
- /// during drag to validate targets, to show
- /// valid/invalid visual feedback, and when the card is dropped on a valid target.
- ///
- public interface ICardActionStrategy
- {
- ///
- /// Validates whether can be performed by
- /// on . Called each frame during drag to determine visual feedback.
- ///
- bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target);
- ///
- /// Applies visual highlighting to both the being dragged
- /// and the character card (e.g. red outline for attack, green for support).
- ///
- void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target);
- ///
- /// Executes the action via and spawns floating damage/defence text.
- /// Called on successful card drop by .
- ///
- void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action);
- }
-}
+using Cthangover.CardBattle.UI;
+using Cthangover.Core.Characters;
+
+namespace Cthangover.CardBattle.Player
+{
+ ///
+ /// Strategy interface that defines validation, visual highlighting, and execution for
+ /// a specific type of card action during the player's drag-and-drop targeting flow.
+ /// The dispatches to the correct strategy based on
+ /// the action's . calls
+ /// during drag to validate targets, to show
+ /// valid/invalid visual feedback, and when the card is dropped on a valid target.
+ ///
+ public interface ICardActionStrategy
+ {
+ ///
+ /// Validates whether can be performed by
+ /// on . Called each frame during drag to determine visual feedback.
+ ///
+ bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target);
+ ///
+ /// Applies visual highlighting to both the being dragged
+ /// and the character card (e.g. red outline for attack, green for support).
+ ///
+ void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target);
+ ///
+ /// Executes the action via and spawns floating damage/defence text.
+ /// Called on successful card drop by .
+ ///
+ void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action);
+ }
+}
diff --git a/src/Player/StunToEnemyStrategy.cs b/src/Player/StunToEnemyStrategy.cs
index d62bbc6..60ed090 100644
--- a/src/Player/StunToEnemyStrategy.cs
+++ b/src/Player/StunToEnemyStrategy.cs
@@ -1,49 +1,49 @@
-using Cthangover.CardBattle.UI;
-using Cthangover.Core.Battle.Actions;
-using Cthangover.Core.Characters;
-
-namespace Cthangover.CardBattle.Player
-{
- ///
- /// Strategy for stun actions targeting enemies. Extends the standard ToEnemy check
- /// by also verifying that the action has at least 2 turns of stun duration
- /// ("Turn" > 1). Highlights with red attack overlays and applies
- /// the stun effect via without spawning damage text
- /// (stun has no numerical effect to display).
- /// Not currently returned by — available for
- /// future use when stun cards need distinct drag-and-drop behavior from attack cards.
- ///
- public class StunToEnemyStrategy : ICardActionStrategy
- {
- ///
- /// Returns true when the action targets an enemy and has a turn count greater than 1.
- ///
- public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
- {
- return action.Type == ActionCharacterType.ToEnemy && target != null && !target.IsPlayer
- && action.GetInt("Turn", 0) > 1;
- }
-
- ///
- /// Shows a red attack overlay on both cards, same as .
- ///
- public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
- {
- actionCard.Attack();
- target.Attack();
- }
-
- ///
- /// Applies the stun via . Does not spawn damage/defence text.
- ///
- public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
- {
- if (source?.Card == null || target?.Card == null || action == null)
- return;
-
- ActionExecutorHub.Instance.Execute(action, source.Card, target.Card);
- source.UpdateInfo();
- target.UpdateInfo();
- }
- }
-}
+using Cthangover.CardBattle.UI;
+using Cthangover.Core.Battle.Actions;
+using Cthangover.Core.Characters;
+
+namespace Cthangover.CardBattle.Player
+{
+ ///
+ /// Strategy for stun actions targeting enemies. Extends the standard ToEnemy check
+ /// by also verifying that the action has at least 2 turns of stun duration
+ /// ("Turn" > 1). Highlights with red attack overlays and applies
+ /// the stun effect via without spawning damage text
+ /// (stun has no numerical effect to display).
+ /// Not currently returned by — available for
+ /// future use when stun cards need distinct drag-and-drop behavior from attack cards.
+ ///
+ public class StunToEnemyStrategy : ICardActionStrategy
+ {
+ ///
+ /// Returns true when the action targets an enemy and has a turn count greater than 1.
+ ///
+ public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
+ {
+ return action.Type == ActionCharacterType.ToEnemy && target != null && !target.IsPlayer
+ && action.GetInt("Turn", 0) > 1;
+ }
+
+ ///
+ /// Shows a red attack overlay on both cards, same as .
+ ///
+ public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
+ {
+ actionCard.Attack();
+ target.Attack();
+ }
+
+ ///
+ /// Applies the stun via . Does not spawn damage/defence text.
+ ///
+ public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
+ {
+ if (source?.Card == null || target?.Card == null || action == null)
+ return;
+
+ ActionExecutorHub.Instance.Execute(action, source.Card, target.Card);
+ source.UpdateInfo();
+ target.UpdateInfo();
+ }
+ }
+}
diff --git a/src/StatusEffect/IStatusActions.cs b/src/StatusEffect/IStatusActions.cs
index 2d1073e..c9586f5 100644
--- a/src/StatusEffect/IStatusActions.cs
+++ b/src/StatusEffect/IStatusActions.cs
@@ -1,21 +1,21 @@
-namespace Cthangover.CardBattle.StatusEffect
-{
- ///
- /// Interface exposed by to instances
- /// so that status effects can communicate back to the queue during turn lifecycle callbacks.
- /// SkipTurn allows an effect (e.g. stun) to mark the character's entire turn as skipped.
- /// RemoveStatus allows an effect to self-remove from the queue.
- ///
- public interface IStatusActions
- {
- ///
- /// Marks the character's current turn as skipped. Called by .
- ///
- void SkipTurn();
- ///
- /// Removes the specified status effect from the queue. Called by effects that want to
- /// self-terminate before their turn count expires.
- ///
- void RemoveStatus(IStatusEffect status);
- }
-}
+namespace Cthangover.CardBattle.StatusEffect
+{
+ ///
+ /// Interface exposed by to instances
+ /// so that status effects can communicate back to the queue during turn lifecycle callbacks.
+ /// SkipTurn allows an effect (e.g. stun) to mark the character's entire turn as skipped.
+ /// RemoveStatus allows an effect to self-remove from the queue.
+ ///
+ public interface IStatusActions
+ {
+ ///
+ /// Marks the character's current turn as skipped. Called by .
+ ///
+ void SkipTurn();
+ ///
+ /// Removes the specified status effect from the queue. Called by effects that want to
+ /// self-terminate before their turn count expires.
+ ///
+ void RemoveStatus(IStatusEffect status);
+ }
+}
diff --git a/src/StatusEffect/IStatusEffect.cs b/src/StatusEffect/IStatusEffect.cs
index fedeb3e..d011660 100644
--- a/src/StatusEffect/IStatusEffect.cs
+++ b/src/StatusEffect/IStatusEffect.cs
@@ -1,26 +1,26 @@
-using Cthangover.Core.Characters;
-
-namespace Cthangover.CardBattle.StatusEffect
-{
- ///
- /// Defines a status effect that is applied to a during battle
- /// and processed each turn by . Effects receive callbacks
- /// at turn start and turn end, and self-report expiration via
- /// when their counter reaches zero. The
- /// callback parameter allows effects to influence the character's turn (e.g. skip it).
- ///
- public interface IStatusEffect
- {
- /// Integer identifier for the effect type (1 = stun). Used for serialization.
- int Type { get; }
- /// Remaining turn count before expiry. Decremented by .
- int Turns { get; set; }
- /// Returns true when ≤ 0, signalling removal from the queue.
- bool IsExpired { get; }
-
- /// Called by before the turn counter is decremented.
- void OnTurnStart(Character character, IStatusActions actions);
- /// Called by after the character's actions have resolved.
- void OnTurnEnd(Character character, IStatusActions actions);
- }
-}
+using Cthangover.Core.Characters;
+
+namespace Cthangover.CardBattle.StatusEffect
+{
+ ///
+ /// Defines a status effect that is applied to a during battle
+ /// and processed each turn by . Effects receive callbacks
+ /// at turn start and turn end, and self-report expiration via
+ /// when their counter reaches zero. The
+ /// callback parameter allows effects to influence the character's turn (e.g. skip it).
+ ///
+ public interface IStatusEffect
+ {
+ /// Integer identifier for the effect type (1 = stun). Used for serialization.
+ int Type { get; }
+ /// Remaining turn count before expiry. Decremented by .
+ int Turns { get; set; }
+ /// Returns true when ≤ 0, signalling removal from the queue.
+ bool IsExpired { get; }
+
+ /// Called by before the turn counter is decremented.
+ void OnTurnStart(Character character, IStatusActions actions);
+ /// Called by after the character's actions have resolved.
+ void OnTurnEnd(Character character, IStatusActions actions);
+ }
+}
diff --git a/src/StatusEffect/StatusEffectItem.cs b/src/StatusEffect/StatusEffectItem.cs
index f09f0e9..b55a3fe 100644
--- a/src/StatusEffect/StatusEffectItem.cs
+++ b/src/StatusEffect/StatusEffectItem.cs
@@ -1,27 +1,27 @@
-namespace Cthangover.CardBattle.StatusEffect
-{
- ///
- /// Data-only container for a status effect's serializable fields ( and ).
- /// Used in to deep-clone status effect state when a character
- /// is copied for battle. Cast to when added back to a queue.
- ///
- public class StatusEffectItem
- {
- /// Effect type identifier (matches ).
- public int Type { get; set; }
- /// Remaining turns before expiry.
- public int Turns { get; set; }
-
- ///
- /// Creates a field-by-field copy for deep-cloning during character duplication.
- ///
- public StatusEffectItem Copy()
- {
- return new StatusEffectItem
- {
- Type = Type,
- Turns = Turns
- };
- }
- }
-}
+namespace Cthangover.CardBattle.StatusEffect
+{
+ ///
+ /// Data-only container for a status effect's serializable fields ( and ).
+ /// Used in to deep-clone status effect state when a character
+ /// is copied for battle. Cast to when added back to a queue.
+ ///
+ public class StatusEffectItem
+ {
+ /// Effect type identifier (matches ).
+ public int Type { get; set; }
+ /// Remaining turns before expiry.
+ public int Turns { get; set; }
+
+ ///
+ /// Creates a field-by-field copy for deep-cloning during character duplication.
+ ///
+ public StatusEffectItem Copy()
+ {
+ return new StatusEffectItem
+ {
+ Type = Type,
+ Turns = Turns
+ };
+ }
+ }
+}
diff --git a/src/StatusEffect/StatusEffectQueue.cs b/src/StatusEffect/StatusEffectQueue.cs
index 23ed74b..be6e6aa 100644
--- a/src/StatusEffect/StatusEffectQueue.cs
+++ b/src/StatusEffect/StatusEffectQueue.cs
@@ -1,103 +1,103 @@
-using System.Collections.Generic;
-using Cthangover.Core.Characters;
-
-namespace Cthangover.CardBattle.StatusEffect
-{
- ///
- /// Manages the collection of instances attached to a single .
- /// Implements so that individual effects can call back into the queue
- /// (e.g. to skip the turn). During , each effect's callback is invoked and
- /// its turn counter is decremented; expired effects are removed. is checked by
- /// to skip stunned enemies.
- /// Supports deep copying for character duplication via .
- ///
- public class StatusEffectQueue : IStatusActions
- {
- private readonly Character _character;
- private readonly List _effects = new();
-
- ///
- /// Set to true when an effect calls during .
- /// Reset to false at the start of each turn before effects are processed.
- ///
- public bool SkipTurn { get; private set; }
-
- ///
- /// Creates a new queue bound to the given .
- ///
- public StatusEffectQueue(Character character)
- {
- _character = character;
- }
-
- ///
- /// Creates a deep copy of this queue bound to .
- /// effects are field-copied; other effect types are shared by reference.
- ///
- public StatusEffectQueue Copy(Character newCharacter)
- {
- var clone = new StatusEffectQueue(newCharacter);
- foreach (var effect in _effects)
- {
- if (effect is StatusEffectItem item)
- clone._effects.Add(item.Copy() as IStatusEffect);
- else
- clone._effects.Add(effect);
- }
- return clone;
- }
-
- ///
- /// Adds a new status effect to the end of the processing list. Effects are processed
- /// in reverse order during turn callbacks (last-added first).
- ///
- public void AddEffect(IStatusEffect effect)
- {
- _effects.Add(effect);
- }
-
- ///
- /// Processes all effects at the start of the character's turn: resets ,
- /// calls for each effect in reverse order,
- /// decrements the turn counter, and removes expired effects.
- /// Called by and .
- ///
- public void OnTurnStart()
- {
- SkipTurn = false;
- for (int i = _effects.Count - 1; i >= 0; i--)
- {
- var effect = _effects[i];
- effect.OnTurnStart(_character, this);
- effect.Turns--;
- if (effect.IsExpired)
- _effects.RemoveAt(i);
- }
- }
-
- ///
- /// Processes all effects at the end of the character's turn: calls
- /// in reverse order and removes expired effects.
- ///
- public void OnTurnEnd()
- {
- for (int i = _effects.Count - 1; i >= 0; i--)
- {
- var effect = _effects[i];
- effect.OnTurnEnd(_character, this);
- if (effect.IsExpired)
- _effects.RemoveAt(i);
- }
- }
-
- void IStatusActions.SkipTurn()
- {
- SkipTurn = true;
- }
-
- void IStatusActions.RemoveStatus(IStatusEffect status)
- {
- _effects.Remove(status);
- }
- }
-}
+using System.Collections.Generic;
+using Cthangover.Core.Characters;
+
+namespace Cthangover.CardBattle.StatusEffect
+{
+ ///
+ /// Manages the collection of instances attached to a single .
+ /// Implements so that individual effects can call back into the queue
+ /// (e.g. to skip the turn). During , each effect's callback is invoked and
+ /// its turn counter is decremented; expired effects are removed. is checked by
+ /// to skip stunned enemies.
+ /// Supports deep copying for character duplication via .
+ ///
+ public class StatusEffectQueue : IStatusActions
+ {
+ private readonly Character _character;
+ private readonly List _effects = new();
+
+ ///
+ /// Set to true when an effect calls during .
+ /// Reset to false at the start of each turn before effects are processed.
+ ///
+ public bool SkipTurn { get; private set; }
+
+ ///
+ /// Creates a new queue bound to the given .
+ ///
+ public StatusEffectQueue(Character character)
+ {
+ _character = character;
+ }
+
+ ///
+ /// Creates a deep copy of this queue bound to .
+ /// effects are field-copied; other effect types are shared by reference.
+ ///
+ public StatusEffectQueue Copy(Character newCharacter)
+ {
+ var clone = new StatusEffectQueue(newCharacter);
+ foreach (var effect in _effects)
+ {
+ if (effect is StatusEffectItem item)
+ clone._effects.Add(item.Copy() as IStatusEffect);
+ else
+ clone._effects.Add(effect);
+ }
+ return clone;
+ }
+
+ ///
+ /// Adds a new status effect to the end of the processing list. Effects are processed
+ /// in reverse order during turn callbacks (last-added first).
+ ///
+ public void AddEffect(IStatusEffect effect)
+ {
+ _effects.Add(effect);
+ }
+
+ ///
+ /// Processes all effects at the start of the character's turn: resets ,
+ /// calls for each effect in reverse order,
+ /// decrements the turn counter, and removes expired effects.
+ /// Called by and .
+ ///
+ public void OnTurnStart()
+ {
+ SkipTurn = false;
+ for (int i = _effects.Count - 1; i >= 0; i--)
+ {
+ var effect = _effects[i];
+ effect.OnTurnStart(_character, this);
+ effect.Turns--;
+ if (effect.IsExpired)
+ _effects.RemoveAt(i);
+ }
+ }
+
+ ///
+ /// Processes all effects at the end of the character's turn: calls
+ /// in reverse order and removes expired effects.
+ ///
+ public void OnTurnEnd()
+ {
+ for (int i = _effects.Count - 1; i >= 0; i--)
+ {
+ var effect = _effects[i];
+ effect.OnTurnEnd(_character, this);
+ if (effect.IsExpired)
+ _effects.RemoveAt(i);
+ }
+ }
+
+ void IStatusActions.SkipTurn()
+ {
+ SkipTurn = true;
+ }
+
+ void IStatusActions.RemoveStatus(IStatusEffect status)
+ {
+ _effects.Remove(status);
+ }
+ }
+}
diff --git a/src/StatusEffect/StunStatusEffect.cs b/src/StatusEffect/StunStatusEffect.cs
index afdc851..d77fcbc 100644
--- a/src/StatusEffect/StunStatusEffect.cs
+++ b/src/StatusEffect/StunStatusEffect.cs
@@ -1,52 +1,52 @@
-using Cthangover.Core.Characters;
-
-namespace Cthangover.CardBattle.StatusEffect
-{
- ///
- /// Status effect that causes a character to skip their entire turn.
- /// On , calls which sets
- /// to true.
- /// checks this flag and skips the character's action selection and resolution entirely.
- /// Created by when a stun card is played,
- /// and added to the target's via Add("effect/physics/stun", turns).
- ///
- public class StunStatusEffect : IStatusEffect
- {
- /// Type identifier for stun (1).
- public int Type => 1;
- /// Number of turns this stun remains active.
- public int Turns { get; set; }
- /// Expired when the turn counter reaches zero or below.
- public bool IsExpired => Turns <= 0;
-
- ///
- /// Creates a stun effect lasting for turns.
- ///
- public StunStatusEffect(int turns)
- {
- Turns = turns;
- }
-
- ///
- /// Parameterless constructor for deserialization. Sets to 0 (expired immediately).
- ///
- public StunStatusEffect()
- {
- }
-
- ///
- /// Calls to mark the character's turn as skipped.
- ///
- public void OnTurnStart(Character character, IStatusActions actions)
- {
- actions.SkipTurn();
- }
-
- ///
- /// Stun has no end-of-turn effect.
- ///
- public void OnTurnEnd(Character character, IStatusActions actions)
- {
- }
- }
-}
+using Cthangover.Core.Characters;
+
+namespace Cthangover.CardBattle.StatusEffect
+{
+ ///
+ /// Status effect that causes a character to skip their entire turn.
+ /// On , calls which sets
+ /// to true.
+ /// checks this flag and skips the character's action selection and resolution entirely.
+ /// Created by when a stun card is played,
+ /// and added to the target's via Add("effect/physics/stun", turns).
+ ///
+ public class StunStatusEffect : IStatusEffect
+ {
+ /// Type identifier for stun (1).
+ public int Type => 1;
+ /// Number of turns this stun remains active.
+ public int Turns { get; set; }
+ /// Expired when the turn counter reaches zero or below.
+ public bool IsExpired => Turns <= 0;
+
+ ///
+ /// Creates a stun effect lasting for turns.
+ ///
+ public StunStatusEffect(int turns)
+ {
+ Turns = turns;
+ }
+
+ ///
+ /// Parameterless constructor for deserialization. Sets to 0 (expired immediately).
+ ///
+ public StunStatusEffect()
+ {
+ }
+
+ ///
+ /// Calls to mark the character's turn as skipped.
+ ///
+ public void OnTurnStart(Character character, IStatusActions actions)
+ {
+ actions.SkipTurn();
+ }
+
+ ///
+ /// Stun has no end-of-turn effect.
+ ///
+ public void OnTurnEnd(Character character, IStatusActions actions)
+ {
+ }
+ }
+}
diff --git a/src/UI/BattleCardPanel.cs b/src/UI/BattleCardPanel.cs
index 9958aa2..ed44a62 100644
--- a/src/UI/BattleCardPanel.cs
+++ b/src/UI/BattleCardPanel.cs
@@ -1,269 +1,269 @@
-using System.Collections.Generic;
-using System.Linq;
-using Cthangover.Core.Characters;
-using Cthangover.Core.Scenes;
-using Cthangover.Core.UI;
-using Cthangover.Core.UI.Theming;
-using Godot;
-
+using System.Collections.Generic;
+using System.Linq;
+using Cthangover.Core.Characters;
+using Cthangover.Core.Scenes;
+using Cthangover.Core.UI;
+using Cthangover.Core.UI.Theming;
+using Godot;
+
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.View;
-namespace Cthangover.CardBattle.UI
-{
- ///
- /// Container panel that manages a list of instances for one side
- /// of the battle (player or enemy). Handles card creation, multi-row layout when enemy count exceeds
- /// MAX_CARDS_ROW (10 per row), smooth animated repositioning, health-triggered death,
- /// and the dissolve sequence.
- /// Two instances are created by : a left-aligned player panel
- /// and a right-aligned enemy panel.
- ///
- public partial class BattleCardPanel : ModWidget
- {
- private const int MAX_CARDS_ROW = 10;
- private const float BASE_CARD_HEIGHT = 280f;
-
- [Export] private Control content;
- ///
- /// Controls card alignment within the panel. "Left" for the player side,
- /// "Right" for the enemy side. Affects .
- ///
- [Export] public string AlignType { get; set; } = "Left";
- [Export] private float cellOffset = 10f;
-
- ///
- /// All character cards currently managed by this panel. Populated by
- /// and modified by (removal) and (full clear).
- ///
- public List CardList { get; } = new();
- private Dictionary activeTweens = new();
-
- ///
- /// When true, cards are laid out in multiple rows (up to 3) instead of a single row.
- /// Set by when enemy count exceeds 6.
- ///
- public bool UseRows { get; set; }
-
- ///
- /// Fired when a card's death animation completes. subscribes
- /// to record the defeated character in .
- ///
- public event System.Action OnCardDead;
-
- /// Builds the card grid container rows for one battle side.
- protected override void Construct()
- {
- CthangoverTheme.Apply(this);
-
- content ??= GetNodeOrNull("Content");
- }
-
- ///
- /// Clears the current card list and creates new instances
- /// for each entry. Sets player/enemy team colors and subscribes
- /// the health-change handler. Returns the created card list.
- /// Called by with the full character roster for each side.
- ///
- public List Init(IEnumerable cards, bool isPlayer, float cardScale)
- {
- CardList.Clear();
- activeTweens.Clear();
-
- int idx = 0;
- foreach (var card in cards)
- {
- CardList.Add(Create(card, isPlayer, cardScale));
- idx++;
- }
- Redraw(animate: false);
-
- return CardList;
- }
-
- ///
- /// Instantiates a single , adds it to the content panel,
- /// sets its scale, initializes it with card data, applies team colors, and hooks
- /// the health-change event. Called by for each character.
- ///
- public CharacterCardNode Create(Character card, bool isPlayer, float cardScale)
- {
- var behaviour = (CharacterCardNode)TscnScenes.LoadAndBuild("scenes/character_card.tscn");
- behaviour.EnsureConstructed();
-
- if (content != null)
- content.AddChild(behaviour);
- else
- AddChild(behaviour);
-
- behaviour.Scale = new Vector2(cardScale, cardScale);
-
- behaviour.Init(card);
- behaviour.IsPlayer = isPlayer;
- behaviour.SetTeamColors();
-
- if (card != null)
- card.Attributes.Health.OnChange += CheckHealthAttribute;
-
- return behaviour;
- }
-
- private void CheckHealthAttribute(float value, float basevalue)
- {
- if (value > 0)
- return;
-
- foreach (var cardItem in CardList.ToList())
- {
- if (cardItem.Card.Attributes.Health.Value <= 0)
- Dead(cardItem);
- }
- }
-
- ///
- /// Frees all card nodes from the scene tree, clears the card list, and triggers a redraw.
- /// Called by when the battle scene is cleaned up.
- ///
- public void ClearAll()
- {
- foreach (var card in CardList.ToList())
- {
- if (GodotObject.IsInstanceValid(card))
- card.QueueFree();
- }
- CardList.Clear();
- activeTweens.Clear();
- Redraw(animate: false);
- }
-
- ///
- /// Recalculates and applies positions for all cards in the list. When
- /// is true, cards tween to their new positions with a cubic ease-out; otherwise they
- /// snap instantly. Called after cards are added, removed, or when a selection/unselection
- /// changes the sort order.
- ///
- public void Redraw(bool animate = true)
- {
- for (int i = 0; i < CardList.Count; i++)
- {
- var card = CardList[i];
- if (card == null)
- continue;
-
- var (row, col) = GetRowCol(i);
- SetCardPosition(card, i, row, col, animate);
- }
- }
-
- private (int row, int col) GetRowCol(int index)
- {
- if (!UseRows || index < MAX_CARDS_ROW)
- return (0, index);
-
- if (index < MAX_CARDS_ROW * 2)
- return (1, index - MAX_CARDS_ROW);
-
- return (2, index - MAX_CARDS_ROW * 2);
- }
-
- private int CalcBaseZIndex(int row, int col)
- {
- return row * 20 + col;
- }
-
- private Vector2 CalcTargetPosition(CharacterCardNode card, int row, int col)
- {
- var panelSize = Size;
- float effectiveWidth = card.Size.X * card.Scale.X;
- float effectiveHeight = card.Size.Y * card.Scale.Y;
-
- float y = row * (effectiveHeight + cellOffset);
-
- if (AlignType == "Left")
- return new Vector2((cellOffset + effectiveWidth) * col, y);
- else
- return new Vector2(panelSize.X - (cellOffset + effectiveWidth) * (col + 1), y);
- }
-
- ///
- /// Calculates how many rows are needed to display enemies
- /// when is enabled. Returns 1, 2, or 3.
- /// Called by to determine maximum card scale.
- ///
- public int GetCurrentRowCount(int enemyCount)
- {
- if (!UseRows)
- return 1;
-
- if (enemyCount <= MAX_CARDS_ROW)
- return 1;
- if (enemyCount <= MAX_CARDS_ROW * 2)
- return 2;
- return 3;
- }
-
- ///
- /// Returns the maximum cards per row constant (MAX_CARDS_ROW = 10).
- /// Used by for scale calculations.
- ///
- public int GetMaxCardsPerRow()
- {
- return MAX_CARDS_ROW;
- }
-
- private void SetCardPosition(CharacterCardNode card, int index, int row, int col, bool animate)
- {
- if (card == null)
- return;
-
- var targetPos = CalcTargetPosition(card, row, col);
-
- if (activeTweens.TryGetValue(card, out var prevTween) && prevTween != null && prevTween.IsValid())
- prevTween.Kill();
-
- if (animate)
- {
- var tween = CreateTween();
- tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
- tween.TweenProperty(card, "position", targetPos, 0.3f);
- activeTweens[card] = tween;
- }
- else
- {
- card.Position = targetPos;
- activeTweens.Remove(card);
- }
-
- var baseZ = CalcBaseZIndex(row, col);
- card.SetBaseZIndex(baseZ);
-
- if (content != null)
- content.MoveChild(card, index);
- else
- MoveChild(card, index);
- card.UpdateInfo();
- }
-
- ///
- /// Marks the card as dead and starts the dissolve sequence.
- /// Once the animation completes, the card is removed from ,
- /// is fired, and the remaining cards are redrawn.
- /// Triggered by the health-change handler when a character's health reaches zero.
- ///
- public void Dead(CharacterCardNode characterCardNode)
- {
- if (characterCardNode == null || characterCardNode.IsDead)
- return;
-
- characterCardNode.IsDead = true;
- var animator = new Cthangover.CardBattle.UI.CardDeathAnimation();
- characterCardNode.AddChild(animator);
- animator.StartAnimation(characterCardNode, () =>
- {
- OnCardDead?.Invoke(characterCardNode);
- CardList.Remove(characterCardNode);
- activeTweens.Remove(characterCardNode);
- Redraw();
- });
- }
- }
-}
+namespace Cthangover.CardBattle.UI
+{
+ ///
+ /// Container panel that manages a list of instances for one side
+ /// of the battle (player or enemy). Handles card creation, multi-row layout when enemy count exceeds
+ /// MAX_CARDS_ROW (10 per row), smooth animated repositioning, health-triggered death,
+ /// and the dissolve sequence.
+ /// Two instances are created by : a left-aligned player panel
+ /// and a right-aligned enemy panel.
+ ///
+ public partial class BattleCardPanel : ModWidget
+ {
+ private const int MAX_CARDS_ROW = 10;
+ private const float BASE_CARD_HEIGHT = 280f;
+
+ [Export] private Control content;
+ ///
+ /// Controls card alignment within the panel. "Left" for the player side,
+ /// "Right" for the enemy side. Affects .
+ ///
+ [Export] public string AlignType { get; set; } = "Left";
+ [Export] private float cellOffset = 10f;
+
+ ///
+ /// All character cards currently managed by this panel. Populated by
+ /// and modified by (removal) and (full clear).
+ ///
+ public List CardList { get; } = new();
+ private Dictionary activeTweens = new();
+
+ ///
+ /// When true, cards are laid out in multiple rows (up to 3) instead of a single row.
+ /// Set by when enemy count exceeds 6.
+ ///
+ public bool UseRows { get; set; }
+
+ ///
+ /// Fired when a card's death animation completes. subscribes
+ /// to record the defeated character in .
+ ///
+ public event System.Action OnCardDead;
+
+ /// Builds the card grid container rows for one battle side.
+ protected override void Construct()
+ {
+ CthangoverTheme.Apply(this);
+
+ content ??= GetNodeOrNull("Content");
+ }
+
+ ///
+ /// Clears the current card list and creates new instances
+ /// for each entry. Sets player/enemy team colors and subscribes
+ /// the health-change handler. Returns the created card list.
+ /// Called by with the full character roster for each side.
+ ///
+ public List Init(IEnumerable cards, bool isPlayer, float cardScale)
+ {
+ CardList.Clear();
+ activeTweens.Clear();
+
+ int idx = 0;
+ foreach (var card in cards)
+ {
+ CardList.Add(Create(card, isPlayer, cardScale));
+ idx++;
+ }
+ Redraw(animate: false);
+
+ return CardList;
+ }
+
+ ///
+ /// Instantiates a single , adds it to the content panel,
+ /// sets its scale, initializes it with card data, applies team colors, and hooks
+ /// the health-change event. Called by for each character.
+ ///
+ public CharacterCardNode Create(Character card, bool isPlayer, float cardScale)
+ {
+ var behaviour = (CharacterCardNode)TscnScenes.LoadAndBuild("scenes/character_card.tscn");
+ behaviour.EnsureConstructed();
+
+ if (content != null)
+ content.AddChild(behaviour);
+ else
+ AddChild(behaviour);
+
+ behaviour.Scale = new Vector2(cardScale, cardScale);
+
+ behaviour.Init(card);
+ behaviour.IsPlayer = isPlayer;
+ behaviour.SetTeamColors();
+
+ if (card != null)
+ card.Attributes.Health.OnChange += CheckHealthAttribute;
+
+ return behaviour;
+ }
+
+ private void CheckHealthAttribute(float value, float basevalue)
+ {
+ if (value > 0)
+ return;
+
+ foreach (var cardItem in CardList.ToList())
+ {
+ if (cardItem.Card.Attributes.Health.Value <= 0)
+ Dead(cardItem);
+ }
+ }
+
+ ///
+ /// Frees all card nodes from the scene tree, clears the card list, and triggers a redraw.
+ /// Called by when the battle scene is cleaned up.
+ ///
+ public void ClearAll()
+ {
+ foreach (var card in CardList.ToList())
+ {
+ if (GodotObject.IsInstanceValid(card))
+ card.QueueFree();
+ }
+ CardList.Clear();
+ activeTweens.Clear();
+ Redraw(animate: false);
+ }
+
+ ///
+ /// Recalculates and applies positions for all cards in the list. When
+ /// is true, cards tween to their new positions with a cubic ease-out; otherwise they
+ /// snap instantly. Called after cards are added, removed, or when a selection/unselection
+ /// changes the sort order.
+ ///
+ public void Redraw(bool animate = true)
+ {
+ for (int i = 0; i < CardList.Count; i++)
+ {
+ var card = CardList[i];
+ if (card == null)
+ continue;
+
+ var (row, col) = GetRowCol(i);
+ SetCardPosition(card, i, row, col, animate);
+ }
+ }
+
+ private (int row, int col) GetRowCol(int index)
+ {
+ if (!UseRows || index < MAX_CARDS_ROW)
+ return (0, index);
+
+ if (index < MAX_CARDS_ROW * 2)
+ return (1, index - MAX_CARDS_ROW);
+
+ return (2, index - MAX_CARDS_ROW * 2);
+ }
+
+ private int CalcBaseZIndex(int row, int col)
+ {
+ return row * 20 + col;
+ }
+
+ private Vector2 CalcTargetPosition(CharacterCardNode card, int row, int col)
+ {
+ var panelSize = Size;
+ float effectiveWidth = card.Size.X * card.Scale.X;
+ float effectiveHeight = card.Size.Y * card.Scale.Y;
+
+ float y = row * (effectiveHeight + cellOffset);
+
+ if (AlignType == "Left")
+ return new Vector2((cellOffset + effectiveWidth) * col, y);
+ else
+ return new Vector2(panelSize.X - (cellOffset + effectiveWidth) * (col + 1), y);
+ }
+
+ ///
+ /// Calculates how many rows are needed to display enemies
+ /// when is enabled. Returns 1, 2, or 3.
+ /// Called by to determine maximum card scale.
+ ///
+ public int GetCurrentRowCount(int enemyCount)
+ {
+ if (!UseRows)
+ return 1;
+
+ if (enemyCount <= MAX_CARDS_ROW)
+ return 1;
+ if (enemyCount <= MAX_CARDS_ROW * 2)
+ return 2;
+ return 3;
+ }
+
+ ///
+ /// Returns the maximum cards per row constant (MAX_CARDS_ROW = 10).
+ /// Used by for scale calculations.
+ ///
+ public int GetMaxCardsPerRow()
+ {
+ return MAX_CARDS_ROW;
+ }
+
+ private void SetCardPosition(CharacterCardNode card, int index, int row, int col, bool animate)
+ {
+ if (card == null)
+ return;
+
+ var targetPos = CalcTargetPosition(card, row, col);
+
+ if (activeTweens.TryGetValue(card, out var prevTween) && prevTween != null && prevTween.IsValid())
+ prevTween.Kill();
+
+ if (animate)
+ {
+ var tween = CreateTween();
+ tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
+ tween.TweenProperty(card, "position", targetPos, 0.3f);
+ activeTweens[card] = tween;
+ }
+ else
+ {
+ card.Position = targetPos;
+ activeTweens.Remove(card);
+ }
+
+ var baseZ = CalcBaseZIndex(row, col);
+ card.SetBaseZIndex(baseZ);
+
+ if (content != null)
+ content.MoveChild(card, index);
+ else
+ MoveChild(card, index);
+ card.UpdateInfo();
+ }
+
+ ///
+ /// Marks the card as dead and starts the dissolve sequence.
+ /// Once the animation completes, the card is removed from ,
+ /// is fired, and the remaining cards are redrawn.
+ /// Triggered by the health-change handler when a character's health reaches zero.
+ ///
+ public void Dead(CharacterCardNode characterCardNode)
+ {
+ if (characterCardNode == null || characterCardNode.IsDead)
+ return;
+
+ characterCardNode.IsDead = true;
+ var animator = new Cthangover.CardBattle.UI.CardDeathAnimation();
+ characterCardNode.AddChild(animator);
+ animator.StartAnimation(characterCardNode, () =>
+ {
+ OnCardDead?.Invoke(characterCardNode);
+ CardList.Remove(characterCardNode);
+ activeTweens.Remove(characterCardNode);
+ Redraw();
+ });
+ }
+ }
+}
diff --git a/src/UI/CardDeathAnimation.cs b/src/UI/CardDeathAnimation.cs
index fc3760b..620d612 100644
--- a/src/UI/CardDeathAnimation.cs
+++ b/src/UI/CardDeathAnimation.cs
@@ -1,101 +1,101 @@
-using System;
-using Cthangover.Core.Mods;
-using Cthangover.Core.Mods.Resolvers;
-using Cthangover.Core.UI;
-using Godot;
-
-namespace Cthangover.CardBattle.UI
-{
- ///
- /// Plays a death animation on a when its health reaches zero.
- /// The card simultaneously fades out, shrinks toward the screen center, spins 360 degrees,
- /// and applies a dissolve shader to all its textures.
- /// The dissolve shader ("scene_transition") is resolved once via
- /// and cached statically. Created and started by .
- ///
- public partial class CardDeathAnimation : Node
- {
- private CharacterCardNode card;
- private Vector2 center;
- private Action onComplete;
-
- private static Shader dissolveShader;
-
- ///
- /// Begins the death animation sequence. Calculates a target position at the viewport center
- /// (relative to the card's parent), starts fading all child nodes,
- /// applies the dissolve shader material to all card images, and tweens position/rotation/alpha.
- /// On completion, invokes and frees this node.
- ///
- public void StartAnimation(CharacterCardNode card, Action onComplete)
- {
- this.card = card;
- this.onComplete = onComplete;
-
- var viewportCenter = card.GetViewport().GetVisibleRect().Size / 2;
- center = card.GetParent() is Control parent
- ? viewportCenter - parent.GlobalPosition
- : viewportCenter - card.GlobalPosition + card.Position;
-
- RunAnimation();
- }
-
- private void RunAnimation()
- {
- float duration = 0.8f;
-
- Vector2 endPos = center;
-
- StartFadeOutRecursive(card, duration);
-
- if (dissolveShader == null)
- dissolveShader = Shaders.Resolve("scene_transition");
-
- if (dissolveShader != null)
- {
- foreach (var img in card.AllImages)
- {
- var mat = new ShaderMaterial { Shader = dissolveShader };
- mat.SetShaderParameter("noise_scale", 5f);
- mat.SetShaderParameter("smoothness", 0.25f);
- mat.SetShaderParameter("distortion", 0.092f);
- mat.SetShaderParameter("glow_intensity", 0f);
- mat.SetShaderParameter("hue_shift", 0f);
- mat.SetShaderParameter("invert_direction", false);
- mat.SetShaderParameter("progress", 0f);
- img.Material = mat;
-
- var matTween = CreateTween();
- matTween.TweenMethod(
- Callable.From(t => mat.SetShaderParameter("progress", t)),
- 0f, 1f, duration);
- }
- }
-
- var tween = CreateTween();
- tween.SetParallel(true);
- tween.TweenProperty(card, "position", endPos, duration);
- tween.TweenProperty(card, "rotation", Mathf.DegToRad(360f), duration);
- tween.TweenProperty(card, "modulate:a", 0f, duration);
-
- tween.Finished += () =>
- {
- onComplete?.Invoke();
- QueueFree();
- };
- }
-
- private static void StartFadeOutRecursive(Node node, float duration)
- {
- foreach (Node child in node.GetChildren())
- {
- if (child is Label label)
- {
- var t = child.CreateTween();
- t.TweenProperty(label, "modulate:a", 0f, duration);
- }
- StartFadeOutRecursive(child, duration);
- }
- }
- }
-}
+using System;
+using Cthangover.Core.Mods;
+using Cthangover.Core.Mods.Resolvers;
+using Cthangover.Core.UI;
+using Godot;
+
+namespace Cthangover.CardBattle.UI
+{
+ ///
+ /// Plays a death animation on a when its health reaches zero.
+ /// The card simultaneously fades out, shrinks toward the screen center, spins 360 degrees,
+ /// and applies a dissolve shader to all its textures.
+ /// The dissolve shader ("scene_transition") is resolved once via
+ /// and cached statically. Created and started by .
+ ///
+ public partial class CardDeathAnimation : Node
+ {
+ private CharacterCardNode card;
+ private Vector2 center;
+ private Action onComplete;
+
+ private static Shader dissolveShader;
+
+ ///
+ /// Begins the death animation sequence. Calculates a target position at the viewport center
+ /// (relative to the card's parent), starts fading all child nodes,
+ /// applies the dissolve shader material to all card images, and tweens position/rotation/alpha.
+ /// On completion, invokes and frees this node.
+ ///
+ public void StartAnimation(CharacterCardNode card, Action onComplete)
+ {
+ this.card = card;
+ this.onComplete = onComplete;
+
+ var viewportCenter = card.GetViewport().GetVisibleRect().Size / 2;
+ center = card.GetParent() is Control parent
+ ? viewportCenter - parent.GlobalPosition
+ : viewportCenter - card.GlobalPosition + card.Position;
+
+ RunAnimation();
+ }
+
+ private void RunAnimation()
+ {
+ float duration = 0.8f;
+
+ Vector2 endPos = center;
+
+ StartFadeOutRecursive(card, duration);
+
+ if (dissolveShader == null)
+ dissolveShader = Shaders.Resolve("scene_transition");
+
+ if (dissolveShader != null)
+ {
+ foreach (var img in card.AllImages)
+ {
+ var mat = new ShaderMaterial { Shader = dissolveShader };
+ mat.SetShaderParameter("noise_scale", 5f);
+ mat.SetShaderParameter("smoothness", 0.25f);
+ mat.SetShaderParameter("distortion", 0.092f);
+ mat.SetShaderParameter("glow_intensity", 0f);
+ mat.SetShaderParameter("hue_shift", 0f);
+ mat.SetShaderParameter("invert_direction", false);
+ mat.SetShaderParameter("progress", 0f);
+ img.Material = mat;
+
+ var matTween = CreateTween();
+ matTween.TweenMethod(
+ Callable.From(t => mat.SetShaderParameter("progress", t)),
+ 0f, 1f, duration);
+ }
+ }
+
+ var tween = CreateTween();
+ tween.SetParallel(true);
+ tween.TweenProperty(card, "position", endPos, duration);
+ tween.TweenProperty(card, "rotation", Mathf.DegToRad(360f), duration);
+ tween.TweenProperty(card, "modulate:a", 0f, duration);
+
+ tween.Finished += () =>
+ {
+ onComplete?.Invoke();
+ QueueFree();
+ };
+ }
+
+ private static void StartFadeOutRecursive(Node node, float duration)
+ {
+ foreach (Node child in node.GetChildren())
+ {
+ if (child is Label label)
+ {
+ var t = child.CreateTween();
+ t.TweenProperty(label, "modulate:a", 0f, duration);
+ }
+ StartFadeOutRecursive(child, duration);
+ }
+ }
+ }
+}
diff --git a/src/UI/EndTurnButton.cs b/src/UI/EndTurnButton.cs
index dceb2b2..8a6d652 100644
--- a/src/UI/EndTurnButton.cs
+++ b/src/UI/EndTurnButton.cs
@@ -1,48 +1,48 @@
-using Cthangover.Core.UI;
-using Cthangover.Core.UI.Theming;
-using Godot;
-
+using Cthangover.Core.UI;
+using Cthangover.Core.UI.Theming;
+using Godot;
+
using Cthangover.Mods.Interface.Base.Lists;
-namespace Cthangover.CardBattle.UI
-{
- ///
- /// A styled button widget that allows the player to manually end their turn.
- /// Displayed during the player's turn and hidden during enemy turns and battle cleanup.
- /// Fires when clicked, which binds
- /// to OnPlayerTurnEnd. The button text is translated via the "battle/end_turn" key.
- ///
- public partial class EndTurnButton : ModWidget
- {
- private Button _button;
-
- ///
- /// Fired when the button is pressed. Subscribed by .
- ///
- public event System.Action OnPressed;
-
- /// Builds the end-turn button and binds its pressed event.
- protected override void Construct()
- {
- CthangoverTheme.Apply(this);
-
- _button = GetNode