This commit is contained in:
2026-09-10 10:58:00 +03:00
commit 27f38c6c1f
50 changed files with 3403 additions and 0 deletions
+37
View File
@@ -0,0 +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
+4
View File
@@ -0,0 +1,4 @@
mod_card_battle_name = CardBattle
mod_card_battle_desc = Card battle mechanics
battle/end_turn = End turn
+4
View File
@@ -0,0 +1,4 @@
mod_card_battle_name=Карточная битва
mod_card_battle_desc=Механики карточных сражений
battle/end_turn=Конец хода
+11
View File
@@ -0,0 +1,11 @@
{
"id": "card_battle",
"name": "CardBattle",
"version": "1.0.0",
"author": "ct",
"description": "Card battle mechanics",
"sources": [
"src/**/*.cs"
],
"depends": [ "core>=1", "interface>=1" ]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+46
View File
@@ -0,0 +1,46 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/ActionCardNode.cs" id="ActionCardNodeScript"]
[node name="ActionCardNode" type="Control" script=ExtResource("ActionCardNodeScript")]
custom_minimum_size = Vector2(200, 280)
layout_mode = 3
[node name="Image" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
offset_left = 15.0
offset_top = 15.0
offset_right = -15.0
offset_bottom = -40.0
mouse_filter = 2
expand_mode = 1
stretch_mode = 4
[node name="Frame" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 4
[node name="NameLabel" type="Label" parent="."]
layout_mode = 1
anchors_preset = 12
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
offset_top = -40.0
offset_bottom = 0.0
theme_override_font_sizes/font_size = 14
horizontal_alignment = 1
+14
View File
@@ -0,0 +1,14 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/BattleCardPanel.cs" id="1_script"]
[node name="BattleCardPanel" type="Control" script=ExtResource("1_script")]
layout_mode = 3
[node name="Content" type="Control" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
+135
View File
@@ -0,0 +1,135 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/CharacterCardNode.cs" id="1_script"]
[node name="CharacterCardNode" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(230, 420)
layout_mode = 3
[node name="TeamOutline" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
offset_left = -4.0
offset_top = -4.0
offset_right = 4.0
offset_bottom = 4.0
mouse_filter = 2
[node name="Image" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
offset_left = 20.0
offset_top = 20.0
offset_right = -20.0
offset_bottom = -60.0
mouse_filter = 2
expand_mode = 1
stretch_mode = 4
[node name="Frame" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 4
[node name="Selection" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="InfoPanel" type="Control" parent="."]
layout_mode = 1
anchors_preset = 12
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
offset_top = -60.0
offset_bottom = 0.0
[node name="NameLabel" type="Label" parent="InfoPanel"]
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
grow_horizontal = 2
offset_bottom = 18.0
horizontal_alignment = 1
theme_override_font_sizes/font_size = 16
[node name="HealthBar" type="Control" parent="InfoPanel"]
layout_mode = 1
anchors_preset = 12
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
offset_top = -18.0
offset_bottom = 0.0
[node name="HealthBg" type="ColorRect" parent="InfoPanel/HealthBar"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0.2, 0.2, 0.2, 1)
[node name="HealthRect" type="ColorRect" parent="InfoPanel/HealthBar"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0, 0.8, 0, 1)
[node name="Labels" type="HBoxContainer" parent="InfoPanel"]
layout_mode = 1
anchors_preset = 13
anchor_left = 0.0
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
grow_horizontal = 2
alignment = 1
[node name="LabelS" type="Label" parent="InfoPanel/Labels"]
text = "S"
horizontal_alignment = 1
[node name="DefenceLabel" type="Label" parent="InfoPanel/Labels"]
text = "0"
[node name="LabelA" type="Label" parent="InfoPanel/Labels"]
text = "A"
horizontal_alignment = 1
[node name="AttackLabel" type="Label" parent="InfoPanel/Labels"]
text = "0"
[node name="LabelP" type="Label" parent="InfoPanel/Labels"]
text = "P"
horizontal_alignment = 1
[node name="PointsLabel" type="Label" parent="InfoPanel/Labels"]
text = "0"
+16
View File
@@ -0,0 +1,16 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/EndTurnButton.cs" id="1_script"]
[node name="EndTurnButton" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(160, 50)
layout_mode = 3
[node name="BtnEndTurn" type="Button" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_font_sizes/font_size = 16
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+87
View File
@@ -0,0 +1,87 @@
using Cthangover.CardBattle.UI;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.CardBattle.Actions
{
/// <summary>
/// Base class for animated battle actions that move, rotate, and scale card controls
/// through a sequence of visual phases. Subclasses implement <c>DoInternalAction</c> to advance
/// a frame-by-frame animation state machine driven by <see cref="CardBattleCore"/>'s
/// per-frame polling loop (<c>while (!DoAction()) await frame;</c>).
/// Captures source/target positions and rotations in <see cref="DoStart"/> so that
/// <see cref="DoEnd"/> can restore them regardless of what the animation did.
/// </summary>
public abstract class AbstractBattleAction : IBattleAction
{
protected double Timestamp { get; set; } = -1;
protected float Speed { get; set; }
protected ActionCharacter Action { get; set; }
protected CharacterCardNode Source { get; set; }
protected CharacterCardNode Target { get; set; }
protected Control SourceControl { get; set; }
protected Vector2 SourcePos { get; set; }
protected float SourceRot { get; set; }
protected Control TargetControl { get; set; }
protected Vector2 TargetPos { get; set; }
protected float TargetRot { get; set; }
/// <summary>
/// Advances the animation by one frame. Called in a loop by <see cref="CardBattleCore.RunEnemyTurn"/>
/// with <c>await root.ToSignal(frame)</c> between calls. Returns <c>true</c> when the animation
/// has completed all phases and the action result has been applied.
/// Returns <c>true</c> early if any required node became null (card was destroyed mid-animation).
/// </summary>
/// <returns><c>true</c> when the animation is finished or cannot proceed.</returns>
public bool DoAction()
{
if (Source == null || Target == null || Action == null || SourceControl == null || TargetControl == null)
return true;
return DoInternalAction();
}
protected abstract bool DoInternalAction();
protected abstract void DoInternalStart();
protected abstract void DoInternalEnd();
protected AbstractBattleAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1)
{
Source = source;
Target = target;
Action = action;
Speed = speed;
}
/// <summary>
/// Captures the current positions, rotations, and <see cref="Control"/> references of both source
/// and target cards before the animation begins, then delegates to the subclass
/// <see cref="DoInternalStart"/> for any additional setup (e.g. resetting timestamps).
/// </summary>
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();
}
/// <summary>
/// Restores source and target card controls to their original positions, rotations, and visual state
/// captured during <see cref="DoStart"/>. Delegates specific cleanup to <see cref="DoInternalEnd"/>.
/// </summary>
public void DoEnd()
{
if (Source == null || Target == null || Action == null)
return;
DoInternalEnd();
}
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace Cthangover.CardBattle.Actions
{
/// <summary>
/// Phases of the animated battle action state machine used by <see cref="PhysicsAttackAction"/>
/// and <see cref="PhysicsDefenceAction"/>. Each tick, the action evaluates the current phase,
/// interpolates card positions/rotations/scales, and transitions to the next phase when its
/// duration elapses. The <c>Recover</c> phase signals completion by returning <c>true</c> from <c>DoInternalAction</c>.
/// </summary>
public enum ActionPhase
{
/// <summary>Initial delay before any movement.</summary>
Wait,
/// <summary>Source card rotates toward target and begins slight movement.</summary>
Prepare,
/// <summary>Source card travels from its position to the target along a movement pattern.</summary>
MoveForAttack,
/// <summary>Damage/effect is applied to the target card at the point of impact.</summary>
AttackImpact,
/// <summary>Target card shows knockback, squashing, and shaking reaction.</summary>
TargetReaction,
/// <summary>Source card returns toward its original position.</summary>
MoveForBack,
/// <summary>All cards smoothly return to their original transforms.</summary>
Recover
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Utils;
namespace Cthangover.CardBattle.Actions
{
/// <summary>
/// Registry that maps action type IDs (<c>"physics/attack"</c>, <c>"physics/defence"</c>, <c>"physics/stun"</c>)
/// to their respective <see cref="IActionExecutor"/> implementations. Created by <see cref="CardBattleCore"/>
/// and registered with <see cref="ActionExecutorHub"/> at battle startup so that actions resolved
/// during drag-and-drop or enemy AI turns go through the correct damage/defence/stun logic.
/// </summary>
public class CardBattleActionProvider : IActionExecutorProvider
{
private readonly Dictionary<string, IActionExecutor> _executors;
/// <summary>
/// Initializes the provider with the three card-battle action executors:
/// <see cref="PhysicsDamageActionCard"/> for attacks, <see cref="PhysicsDefenceActionCard"/> for defence buffs,
/// and <see cref="PhysicsStunActionCard"/> for stun effects.
/// </summary>
public CardBattleActionProvider()
{
_executors = new Dictionary<string, IActionExecutor>
{
["physics/attack"] = new PhysicsDamageActionCard(),
["physics/defence"] = new PhysicsDefenceActionCard(),
["physics/stun"] = new PhysicsStunActionCard(),
};
}
/// <summary>
/// Looks up the <see cref="IActionExecutor"/> for the given <paramref name="actionId"/>.
/// Logs a warning and returns <c>null</c> if no executor is registered, allowing the caller
/// to gracefully skip unrecognized actions.
/// </summary>
public IActionExecutor GetExecutor(string actionId)
{
if (!_executors.TryGetValue(actionId, out var executor))
GameLogger.Log("CARD_BATTLE", $"No executor registered for action '{actionId}'", LogLevel.Warning);
return executor;
}
}
}
+357
View File
@@ -0,0 +1,357 @@
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Utils;
using Cthangover.Core.Characters;
using Cthangover.CardBattle.UI;
using Godot;
namespace Cthangover.CardBattle.Actions
{
/// <summary>
/// Animated battle action that moves the source card toward an enemy target, applies damage/defence
/// via <see cref="ActionExecutorHub"/>, and animates the target's knockback/squash reaction.
/// The source card follows one of five randomly-selected <see cref="MovementPattern"/> trajectories
/// (direct, arc, zigzag, spiral, dash). This is the visual counterpart of <see cref="PhysicsDamageActionCard"/>
/// — it handles the presentation while the executor handles the stat changes.
/// Created by <see cref="CardBattleCore.CreateAnimatedAction"/> for enemy turns.
/// </summary>
public class PhysicsAttackAction : AbstractBattleAction
{
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// Creates an attack animation from <paramref name="source"/> to <paramref name="target"/>
/// using the stats defined in <paramref name="action"/>. The <see cref="MovementPattern"/> is
/// randomly selected on construction to make enemy attacks visually distinct.
/// <paramref name="speed"/> acts as a multiplier on all phase durations.
/// </summary>
public PhysicsAttackAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1)
: base(source, target, action, speed)
{
movementPattern = (MovementPattern)GD.RandRange(0, 5);
}
protected override void DoInternalStart()
{
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
damageApplied = false;
stunEffectSpawned = false;
phase = ActionPhase.Wait;
targetOriginalScale = TargetControl.Scale;
}
private float GetPhaseProgress()
{
float elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)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 = Time.GetTicksUsec() / 1_000_000.0;
}
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)
{
if (result.Target.Damage > 0)
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, TargetControl, TargetControl.GlobalPosition);
var defenceLost = -result.Target.Defence;
if (defenceLost > 0)
ShowDamageBehaviour.SpawnDefence(defenceLost, TargetControl, TargetControl.GlobalPosition);
}
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;
}
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;
}
}
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;
}
}
}
+55
View File
@@ -0,0 +1,55 @@
using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.CardBattle.Actions
{
/// <summary>
/// Executor that applies physical damage from an attack card to a target.
/// Calculates damage as <c>card_attack_multiplier × user_attack_stat</c>, 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 <c>OnTakeDamage</c>/<c>OnDealDamage</c> hooks
/// on both characters' <see cref="StatusEffectQueue"/> so that damage-modifying status effects
/// can intercept and adjust the values before they are applied.
/// Registered in <see cref="CardBattleActionProvider"/> under <c>"physics/attack"</c>.
/// </summary>
public class PhysicsDamageActionCard : ActionBase
{
/// <summary>
/// Unique identifier registered in <see cref="CardBattleActionProvider"/> as <c>"physics/attack"</c>.
/// </summary>
public override string ID => "PhysicsDamageActionCard";
/// <summary>
/// Resolves the attack: deducts action points from <paramref name="user"/> via <c>CheckRequiredAndUsePoint</c>,
/// computes raw damage from the card's attack multiplier and the user's attack stat,
/// applies status-effect damage hooks, consumes <paramref name="target"/>'s defence shield first,
/// then subtracts remaining damage from health. Returns a <see cref="ChangedAttributes"/> with
/// the actual damage and defence-loss deltas for the floating text display.
/// </summary>
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);
target.StatusEffectQueue.OnTakeDamage(user, ref damageDelta);
user.StatusEffectQueue.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}};
}
}
}
+215
View File
@@ -0,0 +1,215 @@
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Characters;
using Cthangover.CardBattle.UI;
using Godot;
namespace Cthangover.CardBattle.Actions
{
/// <summary>
/// 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 <see cref="ActionExecutorHub"/>,
/// and shows a subtle block/stagger reaction on the target. Used for both <c>ToSelf</c> and
/// <c>ToAlias</c> action types. This is the visual counterpart of <see cref="PhysicsDefenceActionCard"/>.
/// Created by <see cref="CardBattleCore.CreateAnimatedAction"/> for enemy self-buff/support turns.
/// </summary>
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;
/// <summary>
/// Creates a defence/support animation. Both <paramref name="source"/> and <paramref name="target"/>
/// can be the same card (for self-buffs) or different (for ally support).
/// <paramref name="speed"/> multiplies all phase durations.
/// </summary>
public PhysicsDefenceAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1)
: base(source, target, action, speed) { }
protected override void DoInternalStart()
{
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
effectApplied = false;
phase = ActionPhase.Prepare;
targetOriginalScale = TargetControl.Scale;
targetOriginalColor = TargetControl.Modulate;
}
private float GetPhaseProgress()
{
float elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)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 = Time.GetTicksUsec() / 1_000_000.0;
}
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);
}
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;
}
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;
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using Cthangover.Core.Characters;
namespace Cthangover.CardBattle.Actions
{
/// <summary>
/// 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 <see cref="PhysicsDamageActionCard"/>). The defence delta
/// comes from the <c>"Defence"</c> property on the action card.
/// Registered in <see cref="CardBattleActionProvider"/> under <c>"physics/defence"</c>.
/// </summary>
public class PhysicsDefenceActionCard : ActionBase
{
/// <summary>
/// Unique identifier registered in <see cref="CardBattleActionProvider"/> as <c>"physics/defence"</c>.
/// </summary>
public override string ID => "PhysicsDefenceActionCard";
/// <summary>
/// Adds a flat defence value to <paramref name="target"/> after deducting action points
/// from <paramref name="user"/>. The returned <see cref="ChangedAttributes"/> carries the
/// defence delta for floating text display.
/// </summary>
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}};
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using Cthangover.Core.Characters;
namespace Cthangover.CardBattle.Actions
{
/// <summary>
/// Executor that applies a stun status effect to the target character.
/// The stun duration (number of turns) is read from the <c>"Turn"</c> property on the action card.
/// During a stunned character's turn, <see cref="StatusEffectQueue.HasStun"/> returns <c>true</c>,
/// causing <see cref="CardBattleCore.RunEnemyTurn"/> to skip that character's actions entirely.
/// Registered in <see cref="CardBattleActionProvider"/> under <c>"physics/stun"</c>.
/// </summary>
public class PhysicsStunActionCard : ActionBase
{
/// <summary>
/// Unique identifier registered in <see cref="CardBattleActionProvider"/> as <c>"physics/stun"</c>.
/// </summary>
public override string ID => "PhysicsStunActionCard";
/// <summary>
/// Adds a <see cref="StunStatusEffect"/> with the configured turn count to <paramref name="target"/>'s
/// <see cref="StatusEffectQueue"/> after deducting action points from <paramref name="user"/>.
/// </summary>
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 };
target.StatusEffectQueue.Add("effect/physics/stun", actionCard.GetInt("Turn", 3));
return new ChangedAttributes { Result = true };
}
}
}
+390
View File
@@ -0,0 +1,390 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
using Cthangover.Core.Characters;
using Cthangover.CardBattle.Player;
using Cthangover.CardBattle.UI;
using Godot;
namespace Cthangover.CardBattle
{
/// <summary>
/// Core orchestrator for the card-based turn battle system.
/// Manages the full battle lifecycle: initializing UI panels (<see cref="BattleCardPanel"/> and <see cref="CardController"/>),
/// cycling between player and enemy turns, spawning animated actions via <see cref="AbstractBattleAction"/>,
/// applying status effects each turn, and detecting win/loss conditions when all cards on one side are dead.
/// Implements <see cref="IBattleCore"/> so it can be registered as a battle type provider in the core battle engine.
/// </summary>
public class CardBattleCore : IBattleCore
{
/// <summary>
/// Unique identifier for this battle core, used by the battle engine to select the card battle mod.
/// </summary>
public string Id => "card_battle";
/// <summary>
/// Provides <see cref="IActionExecutor"/> instances for the three card action types
/// (physics/attack, physics/defence, physics/stun) used during action resolution.
/// </summary>
public IActionExecutorProvider ActionProvider { get; } = new Actions.CardBattleActionProvider();
private Character[] _playerChars;
private Character[] _enemyChars;
private IBattleContext _ctx;
private BattleCardPanel _playerPanel;
private BattleCardPanel _enemyPanel;
private CardController _cardController;
private Control _actionPanel;
private Control _toolPanel;
private EndTurnButton _endTurnButton;
private BattleSide _currentOrder;
private const float CELL_OFFSET = 10f;
private const int PLAYER_CARD_LIMIT = 4;
private const float BASE_CARD_WIDTH = 230f;
private const float BASE_CARD_HEIGHT = 420f;
/// <summary>
/// Initializes the card battle by constructing the player and enemy <see cref="BattleCardPanel"/> instances,
/// the <see cref="CardController"/> for drag-and-drop input, and the <see cref="EndTurnButton"/>.
/// Enemy characters are deep-copied so defeats are tracked independently per battle instance.
/// The <see cref="CardBattleActionProvider"/> is registered with <see cref="ActionExecutorHub"/>.
/// </summary>
public void Init(Character[] playerChars, Character[] enemyChars, IBattleContext ctx)
{
_playerChars = playerChars;
_ctx = ctx;
_enemyChars = new Character[enemyChars.Length];
for (int i = 0; i < enemyChars.Length; i++)
_enemyChars[i] = enemyChars[i]?.Copy() ?? enemyChars[i];
ActionExecutorHub.Instance.SetActiveProvider(ActionProvider);
var root = ctx.RootNode as Node;
var panel = root.GetNodeOrNull<Control>("Panel") ?? root;
_playerPanel = (BattleCardPanel)TscnScenes.LoadAndBuild("scenes/battle_card_panel.tscn");
_playerPanel.Name = "PlayerPanel";
_playerPanel.EnsureConstructed();
panel.AddChild(_playerPanel);
_enemyPanel = (BattleCardPanel)TscnScenes.LoadAndBuild("scenes/battle_card_panel.tscn");
_enemyPanel.Name = "EnemyPanel";
_enemyPanel.AlignType = "Right";
_enemyPanel.EnsureConstructed();
_enemyPanel.MouseFilter = Control.MouseFilterEnum.Ignore;
panel.AddChild(_enemyPanel);
_actionPanel = new Control { Name = "ActionPanel" };
panel.AddChild(_actionPanel);
_toolPanel = root.GetNodeOrNull<Control>("ToolPanel");
_endTurnButton = (EndTurnButton)TscnScenes.LoadAndBuild("scenes/end_turn_button.tscn");
_endTurnButton.Name = "EndTurnButton";
_endTurnButton.EnsureConstructed();
_endTurnButton.SetVisible(false);
_endTurnButton.OnPressed += OnEndTurnPressed;
_toolPanel?.AddChild(_endTurnButton);
_cardController = new CardController { Name = "CardController" };
panel.AddChild(_cardController);
_cardController.FindPanels();
}
/// <summary>
/// Positions the UI panels relative to the viewport, calculates a unified <c>cardScale</c> that
/// fits both player and enemy cards on screen, initializes all character cards, and starts the first player turn.
/// Subscribes to <c>OnCardDead</c> to record defeated enemies and to <c>OnActionExecuted</c> to detect
/// automatic turn completion when all player cards exhaust their action points.
/// </summary>
public void Start()
{
var root = _ctx.RootNode as Node;
var panelSize = root.GetViewport().GetVisibleRect().Size;
var enemyCount = _enemyChars?.Length ?? 1;
_enemyPanel.UseRows = enemyCount > 6;
_playerPanel.Position = new Vector2(0, panelSize.Y * 0.55f);
_playerPanel.Size = new Vector2(panelSize.X * 0.55f, panelSize.Y * 0.35f);
_enemyPanel.Position = new Vector2(panelSize.X * 0.02f, panelSize.Y * 0.022f);
_enemyPanel.Size = new Vector2(panelSize.X * 0.96f, panelSize.Y * 0.446f);
_actionPanel.Position = new Vector2(panelSize.X * 0.53f, panelSize.Y * 0.55f);
_actionPanel.Size = new Vector2(panelSize.X * 0.46f, panelSize.Y * 0.35f);
if (_endTurnButton != null)
_endTurnButton.Position = new Vector2(panelSize.X - 180f, (panelSize.Y * 0.1f - 50f) / 2);
var cardScale = CalculateCardScale(enemyCount);
_cardController.CardScale = cardScale;
_playerPanel.Init(_playerChars, true, cardScale);
_enemyPanel.Init(_enemyChars, false, cardScale);
_enemyPanel.OnCardDead += OnEnemyCardDead;
_cardController.OnActionExecuted += CheckPlayerTurnEnd;
BattleSceneContext.Instance.OnBattleCleared += OnBattleCleared;
_currentOrder = BattleSide.Player;
StartPlayerTurn();
}
private float CalculateCardScale(int enemyCount)
{
var viewport = ((Node)_ctx.RootNode).GetViewport().GetVisibleRect().Size;
float enemyPanelWidth = viewport.X * 0.96f;
float playerPanelWidth = viewport.X * 0.55f;
float enemyPanelHeight = viewport.Y * 0.446f;
float maxCardScale = 1f;
if (_enemyPanel.UseRows)
{
int maxPerRow = _enemyPanel.GetMaxCardsPerRow();
int rows = _enemyPanel.GetCurrentRowCount(enemyCount);
float widthScale = (enemyPanelWidth - (maxPerRow - 1) * CELL_OFFSET) / (maxPerRow * BASE_CARD_WIDTH);
float heightScale = (enemyPanelHeight - (rows - 1) * CELL_OFFSET) / (rows * BASE_CARD_HEIGHT);
maxCardScale = Mathf.Max(0.1f, Mathf.Min(widthScale, heightScale));
}
else
{
int effectiveCount = Mathf.Max(enemyCount, 1);
maxCardScale = Mathf.Max(0.1f, (enemyPanelWidth - (effectiveCount - 1) * CELL_OFFSET) / (effectiveCount * BASE_CARD_WIDTH));
}
float playerFitScale = Mathf.Max(0.1f, (playerPanelWidth - (PLAYER_CARD_LIMIT - 1) * CELL_OFFSET) / (PLAYER_CARD_LIMIT * BASE_CARD_WIDTH));
float cardScale = Mathf.Min(maxCardScale, playerFitScale);
return Mathf.Min(cardScale, 1f);
}
private void OnEnemyCardDead(CharacterCardNode card)
{
if (card?.Card == null)
return;
var ctx = BattleSceneContext.Instance;
if (ctx != null)
{
ctx.RecordEnemyDefeated(card.Card);
ctx.NotifyCharacterDied(card.Card);
}
}
private void StartPlayerTurn()
{
GameLogger.Log("CARD_BATTLE", "Player turn started", LogLevel.Debug);
_currentOrder = BattleSide.Player;
BattleSceneContext.Instance.IsWait = false;
_cardController.ClearSelections();
_cardController.ClearActions();
_endTurnButton?.SetVisible(true);
foreach (var card in _playerPanel.CardList)
{
card.Card.StatusEffectQueue.OnTurnStart();
card.Card.Attributes.Point.Value = card.Card.Attributes.Point.BaseValue;
card.UpdateInfo();
}
}
private void OnEndTurnPressed()
{
GameLogger.Log("CARD_BATTLE", "End turn button pressed", LogLevel.Debug);
OnPlayerTurnEnd();
}
private void CheckPlayerTurnEnd()
{
if (_currentOrder != BattleSide.Player)
return;
if (!HasAliveCards(_enemyPanel))
{
_ctx.EndBattle(BattleSide.Player);
return;
}
bool allOutOfPoints = true;
foreach (var card in _playerPanel.CardList)
{
if (!card.IsDead && card.Card?.Attributes?.Point?.Value > 0)
{
allOutOfPoints = false;
break;
}
}
if (allOutOfPoints)
OnPlayerTurnEnd();
}
/// <summary>
/// Ends the player's turn by hiding the end-turn button, setting the battle to <c>IsWait</c>,
/// and yielding two frames for UI to settle before running the enemy AI turn via <c>RunEnemyTurn</c>.
/// If all enemies are already dead, the battle ends immediately with the player as winner.
/// This method is <c>async void</c> so it can await frames without blocking the game loop.
/// </summary>
public async void OnPlayerTurnEnd()
{
GameLogger.Log("CARD_BATTLE", "Player turn ended", LogLevel.Debug);
_endTurnButton?.SetVisible(false);
BattleSceneContext.Instance.IsWait = true;
if (!HasAliveCards(_enemyPanel))
{
_ctx.EndBattle(BattleSide.Player);
return;
}
var root = (Node)_ctx.RootNode;
await root.ToSignal(root.GetTree(), "process_frame");
await root.ToSignal(root.GetTree().CreateTimer(0.5f), "timeout");
await RunEnemyTurn();
}
private async Task RunEnemyTurn()
{
GameLogger.Log("CARD_BATTLE", "Enemy turn started", LogLevel.Debug);
_currentOrder = BattleSide.Enemy;
_endTurnButton?.SetVisible(false);
var root = (Node)_ctx.RootNode;
foreach (var card in _enemyPanel.CardList.ToList())
{
if (card.IsDead || card.Card?.Actions == null || card.Card.Actions.Count == 0)
continue;
card.Card.StatusEffectQueue.OnTurnStart();
if (card.Card.StatusEffectQueue.HasStun())
{
GameLogger.Log("CARD_BATTLE", $"{card.Card.Name} is stunned, skipping", LogLevel.Debug);
continue;
}
card.Card.Attributes.Point.Value = card.Card.Attributes.Point.BaseValue;
var action = PickEnemyAction(card);
if (action == null)
continue;
var target = PickEnemyTarget(card, action);
if (target == null)
continue;
GameLogger.Log("CARD_BATTLE",
$"Enemy action: {card.Card.Name} -> {action.Name} -> {target.Card.Name}",
LogLevel.Message);
var animatedAction = CreateAnimatedAction(card, target, action);
if (animatedAction != null)
{
animatedAction.DoStart();
while (!animatedAction.DoAction())
await root.ToSignal(root.GetTree(), "process_frame");
animatedAction.DoEnd();
}
await root.ToSignal(root.GetTree().CreateTimer(0.2f), "timeout");
if (!HasAliveCards(_playerPanel))
{
GameLogger.Log("CARD_BATTLE", "All players dead — enemy wins", LogLevel.Message);
_ctx.EndBattle(BattleSide.Enemy);
return;
}
}
if (!HasAliveCards(_playerPanel))
{
_ctx.EndBattle(BattleSide.Enemy);
return;
}
StartPlayerTurn();
}
private ActionCharacter PickEnemyAction(CharacterCardNode card)
{
var actions = card.Card.Actions;
if (actions == null || actions.Count == 0)
return null;
var random = new Random();
return actions[random.Next(actions.Count)];
}
private CharacterCardNode PickEnemyTarget(CharacterCardNode source, ActionCharacter action)
{
if (action.Type == ActionCharacterType.ToEnemy)
{
var alivePlayers = _playerPanel.CardList
.Where(c => !c.IsDead && c.Card?.Attributes?.Health?.Value > 0)
.ToList();
if (alivePlayers.Count == 0)
return null;
return alivePlayers[new Random().Next(alivePlayers.Count)];
}
if (action.Type == ActionCharacterType.ToAlias || action.Type == ActionCharacterType.ToSelf)
return source;
GameLogger.Log("CARD_BATTLE", $"PickEnemyTarget: unsupported action type {action.Type}", LogLevel.Warning);
return null;
}
private Actions.AbstractBattleAction CreateAnimatedAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
{
if (action.Type == ActionCharacterType.ToEnemy)
return new Actions.PhysicsAttackAction(source, target, action);
if (action.Type == ActionCharacterType.ToAlias || action.Type == ActionCharacterType.ToSelf)
return new Actions.PhysicsDefenceAction(source, target, action);
GameLogger.Log("CARD_BATTLE", $"CreateAnimatedAction: unsupported action type {action.Type}", LogLevel.Warning);
return null;
}
private bool HasAliveCards(BattleCardPanel panel)
{
foreach (var card in panel.CardList)
{
if (!card.IsDead && card.Card?.Attributes?.Health?.Value > 0)
return true;
}
return false;
}
private void OnBattleCleared()
{
_playerPanel?.ClearAll();
_enemyPanel?.ClearAll();
_cardController?.ClearActions();
_endTurnButton?.SetVisible(false);
}
}
}
+24
View File
@@ -0,0 +1,24 @@
using Godot;
namespace Cthangover.CardBattle
{
/// <summary>
/// Common interface for all card UI nodes in the card battle system.
/// Exposes the <c>Frame</c> and <c>Image</c> <see cref="TextureRect"/> controls so that
/// <see cref="CardController"/> can perform hit-testing during drag-and-drop targeting
/// without knowing the concrete card type (<see cref="CharacterCardNode"/> or <see cref="ActionCardNode"/>).
/// </summary>
public interface ICard
{
/// <summary>
/// The frame <see cref="TextureRect"/> used for hit-testing whether a pointer is over this card.
/// </summary>
TextureRect Frame { get; }
/// <summary>
/// The portrait/image <see cref="TextureRect"/> of the card, used for dissolve shader effects on death.
/// </summary>
TextureRect Image { get; }
}
}
+57
View File
@@ -0,0 +1,57 @@
using Cthangover.CardBattle.UI;
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Characters;
namespace Cthangover.CardBattle.Player
{
/// <summary>
/// Strategy for <c>ToEnemy</c> attack actions. Validates that the target is an enemy
/// (non-player card), highlights both cards with a red attack outline, and executes
/// damage via <see cref="ActionExecutorHub"/> with floating damage/defence numbers.
/// This is the standard strategy for all offensive cards in the player's deck.
/// </summary>
public class AttackToEnemyStrategy : ICardActionStrategy
{
/// <summary>
/// Returns <c>true</c> if the action type is <c>ToEnemy</c> and the target is an enemy card.
/// </summary>
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
{
return action.Type == ActionCharacterType.ToEnemy && target != null && !target.IsPlayer;
}
/// <summary>
/// Shows a red attack overlay on both the dragged action card and the target enemy card.
/// </summary>
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
{
actionCard.Attack();
target.Attack();
}
/// <summary>
/// Resolves the attack via <see cref="ActionExecutorHub"/> and spawns floating damage
/// and defence-loss numbers above the <paramref name="target"/> card.
/// </summary>
public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
{
if (source?.Card == null || target?.Card == null || action == null)
return;
var result = ActionExecutorHub.Instance.Execute(action, source.Card, target.Card);
source.UpdateInfo();
target.UpdateInfo();
if (result.Result)
{
if (result.Target.Damage > 0)
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, target, target.GlobalPosition);
var defenceLost = -result.Target.Defence;
if (defenceLost > 0)
ShowDamageBehaviour.SpawnDefence(defenceLost, target, target.GlobalPosition);
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
using Cthangover.CardBattle.UI;
using Cthangover.Core.Characters;
namespace Cthangover.CardBattle.Player
{
/// <summary>
/// Factory that returns the appropriate <see cref="ICardActionStrategy"/> implementation
/// for a given <see cref="ActionCharacter"/>. Dispatches based on <see cref="ActionCharacterType"/>:
/// <c>ToEnemy</c> → <see cref="AttackToEnemyStrategy"/>,
/// <c>ToAlias</c> → <see cref="SupportStrategy"/>,
/// <c>ToSelf</c> → <see cref="SelfStrategy"/>.
/// Used by <see cref="CardController"/> during drag-and-drop to determine valid targeting behavior
/// and by <see cref="CanTarget"/> for pre-validation.
/// </summary>
public static class CardActionStrategyFactory
{
/// <summary>
/// Returns the matching strategy for the action's type, or <c>null</c> if any argument is null
/// or the type is unrecognized.
/// </summary>
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;
}
}
/// <summary>
/// Convenience method that gets the strategy via <see cref="Get"/> and immediately calls
/// <see cref="ICardActionStrategy.Check"/> on it. Returns <c>false</c> if no strategy matches
/// or any argument is null.
/// </summary>
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;
}
}
}
+32
View File
@@ -0,0 +1,32 @@
using Cthangover.CardBattle.UI;
using Cthangover.Core.Characters;
namespace Cthangover.CardBattle.Player
{
/// <summary>
/// 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 <see cref="CardActionStrategyFactory"/> dispatches to the correct strategy based on
/// the action's <see cref="ActionCharacterType"/>. <see cref="CardController"/> calls
/// <see cref="Check"/> during drag to validate targets, <see cref="HighlightTarget"/> to show
/// valid/invalid visual feedback, and <see cref="Execute"/> when the card is dropped on a valid target.
/// </summary>
public interface ICardActionStrategy
{
/// <summary>
/// Validates whether <paramref name="action"/> can be performed by <paramref name="source"/>
/// on <paramref name="target"/>. Called each frame during drag to determine visual feedback.
/// </summary>
bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target);
/// <summary>
/// Applies visual highlighting to both the <paramref name="actionCard"/> being dragged
/// and the <paramref name="target"/> character card (e.g. red outline for attack, green for support).
/// </summary>
void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target);
/// <summary>
/// Executes the action via <see cref="ActionExecutorHub"/> and spawns floating damage/defence text.
/// Called on successful card drop by <see cref="CardController.OnEndDrag"/>.
/// </summary>
void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action);
}
}
+56
View File
@@ -0,0 +1,56 @@
using Cthangover.CardBattle.UI;
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Characters;
namespace Cthangover.CardBattle.Player
{
/// <summary>
/// Strategy for self-targeting actions (<c>ToSelf</c>). Validates that source and target
/// are the same card, highlights with a green selection outline, and applies the action
/// to the source card itself via <see cref="ActionExecutorHub"/>. Used for self-buffs like
/// defence boosts. Damage and defence floating text spawn above the source card.
/// </summary>
public class SelfStrategy : ICardActionStrategy
{
/// <summary>
/// Returns <c>true</c> only when <paramref name="source"/> and <paramref name="target"/> are the same card.
/// </summary>
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
{
return source != null && source == target;
}
/// <summary>
/// Shows a green selection overlay on both the dragged action card and the source card.
/// </summary>
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
{
actionCard.Select();
target.Select();
}
/// <summary>
/// Executes the self-targeted action and spawns floating defence/damage numbers
/// above the <paramref name="source"/> card. Damage numbers are marked with the
/// <c>isHeal</c> flag for green coloring via <see cref="ShowDamageBehaviour"/>.
/// </summary>
public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
{
if (source?.Card == null || action == null)
return;
var result = ActionExecutorHub.Instance.Execute(action, source.Card, source.Card);
source.UpdateInfo();
if (result.Result)
{
if (result.Target.Defence > 0)
ShowDamageBehaviour.SpawnDefence(result.Target.Defence, source, source.GlobalPosition);
if (result.Target.Damage > 0)
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, source, source.GlobalPosition, true);
}
}
}
}
+49
View File
@@ -0,0 +1,49 @@
using Cthangover.CardBattle.UI;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Characters;
namespace Cthangover.CardBattle.Player
{
/// <summary>
/// Strategy for stun actions targeting enemies. Extends the standard <c>ToEnemy</c> check
/// by also verifying that the action has at least 2 turns of stun duration
/// (<c>"Turn" > 1</c>). Highlights with red attack overlays and applies
/// the stun effect via <see cref="ActionExecutorHub"/> without spawning damage text
/// (stun has no numerical effect to display).
/// Not currently returned by <see cref="CardActionStrategyFactory"/> — available for
/// future use when stun cards need distinct drag-and-drop behavior from attack cards.
/// </summary>
public class StunToEnemyStrategy : ICardActionStrategy
{
/// <summary>
/// Returns <c>true</c> when the action targets an enemy and has a turn count greater than 1.
/// </summary>
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
{
return action.Type == ActionCharacterType.ToEnemy && target != null && !target.IsPlayer
&& action.GetInt("Turn", 0) > 1;
}
/// <summary>
/// Shows a red attack overlay on both cards, same as <see cref="AttackToEnemyStrategy"/>.
/// </summary>
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
{
actionCard.Attack();
target.Attack();
}
/// <summary>
/// Applies the stun via <see cref="ActionExecutorHub"/>. Does not spawn damage/defence text.
/// </summary>
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();
}
}
}
+56
View File
@@ -0,0 +1,56 @@
using Cthangover.CardBattle.UI;
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Characters;
namespace Cthangover.CardBattle.Player
{
/// <summary>
/// Strategy for ally-support actions (<c>ToAlias</c>). Validates that the target is a friendly
/// player card, highlights with green selection overlays, and applies defence/healing effects
/// via <see cref="ActionExecutorHub"/>. Allows one player card to buff another during the player's turn.
/// </summary>
public class SupportStrategy : ICardActionStrategy
{
/// <summary>
/// Returns <c>true</c> when the action type is <c>ToAlias</c> and the target is a player card.
/// </summary>
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
{
return action.Type == ActionCharacterType.ToAlias && target != null && target.IsPlayer;
}
/// <summary>
/// Shows a green selection overlay on both the dragged action card and the target ally card.
/// </summary>
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
{
actionCard.Select();
target.Select();
}
/// <summary>
/// Resolves the support action and spawns floating defence and heal text above
/// the <paramref name="target"/> ally. Damage numbers are shown with the <c>isHeal</c>
/// flag for green coloring.
/// </summary>
public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
{
if (source?.Card == null || target?.Card == null || action == null)
return;
var result = ActionExecutorHub.Instance.Execute(action, source.Card, target.Card);
source.UpdateInfo();
target.UpdateInfo();
if (result.Result)
{
if (result.Target.Defence > 0)
ShowDamageBehaviour.SpawnDefence(result.Target.Defence, target, target.GlobalPosition);
if (result.Target.Damage > 0)
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, target, target.GlobalPosition, true);
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace Cthangover.CardBattle.StatusEffect
{
/// <summary>
/// Interface exposed by <see cref="StatusEffectQueue"/> to <see cref="IStatusEffect"/> instances
/// so that status effects can communicate back to the queue during turn lifecycle callbacks.
/// <c>SkipTurn</c> allows an effect (e.g. stun) to mark the character's entire turn as skipped.
/// <c>RemoveStatus</c> allows an effect to self-remove from the queue.
/// </summary>
public interface IStatusActions
{
/// <summary>
/// Marks the character's current turn as skipped. Called by <see cref="StunStatusEffect.OnTurnStart"/>.
/// </summary>
void SkipTurn();
/// <summary>
/// Removes the specified status effect from the queue. Called by effects that want to
/// self-terminate before their turn count expires.
/// </summary>
void RemoveStatus(IStatusEffect status);
}
}
+26
View File
@@ -0,0 +1,26 @@
using Cthangover.Core.Characters;
namespace Cthangover.CardBattle.StatusEffect
{
/// <summary>
/// Defines a status effect that is applied to a <see cref="Character"/> during battle
/// and processed each turn by <see cref="StatusEffectQueue"/>. Effects receive callbacks
/// at turn start and turn end, and self-report expiration via <see cref="IsExpired"/>
/// when their <see cref="Turns"/> counter reaches zero. The <see cref="IStatusActions"/>
/// callback parameter allows effects to influence the character's turn (e.g. skip it).
/// </summary>
public interface IStatusEffect
{
/// <summary>Integer identifier for the effect type (1 = stun). Used for serialization.</summary>
int Type { get; }
/// <summary>Remaining turn count before expiry. Decremented by <see cref="StatusEffectQueue"/>.</summary>
int Turns { get; set; }
/// <summary>Returns <c>true</c> when <see cref="Turns"/> ≤ 0, signalling removal from the queue.</summary>
bool IsExpired { get; }
/// <summary>Called by <see cref="StatusEffectQueue.OnTurnStart"/> before the turn counter is decremented.</summary>
void OnTurnStart(Character character, IStatusActions actions);
/// <summary>Called by <see cref="StatusEffectQueue.OnTurnEnd"/> after the character's actions have resolved.</summary>
void OnTurnEnd(Character character, IStatusActions actions);
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace Cthangover.CardBattle.StatusEffect
{
/// <summary>
/// Data-only container for a status effect's serializable fields (<see cref="Type"/> and <see cref="Turns"/>).
/// Used in <see cref="StatusEffectQueue.Copy"/> to deep-clone status effect state when a character
/// is copied for battle. Cast to <see cref="IStatusEffect"/> when added back to a queue.
/// </summary>
public class StatusEffectItem
{
/// <summary>Effect type identifier (matches <see cref="IStatusEffect.Type"/>).</summary>
public int Type { get; set; }
/// <summary>Remaining turns before expiry.</summary>
public int Turns { get; set; }
/// <summary>
/// Creates a field-by-field copy for deep-cloning during character duplication.
/// </summary>
public StatusEffectItem Copy()
{
return new StatusEffectItem
{
Type = Type,
Turns = Turns
};
}
}
}
+103
View File
@@ -0,0 +1,103 @@
using System.Collections.Generic;
using Cthangover.Core.Characters;
namespace Cthangover.CardBattle.StatusEffect
{
/// <summary>
/// Manages the collection of <see cref="IStatusEffect"/> instances attached to a single <see cref="Character"/>.
/// Implements <see cref="IStatusActions"/> so that individual effects can call back into the queue
/// (e.g. to skip the turn). During <see cref="OnTurnStart"/>, each effect's callback is invoked and
/// its turn counter is decremented; expired effects are removed. <see cref="SkipTurn"/> is checked by
/// <see cref="CardBattleCore.RunEnemyTurn"/> to skip stunned enemies.
/// Supports deep copying for character duplication via <see cref="Copy"/>.
/// </summary>
public class StatusEffectQueue : IStatusActions
{
private readonly Character _character;
private readonly List<IStatusEffect> _effects = new();
/// <summary>
/// Set to <c>true</c> when an effect calls <see cref="IStatusActions.SkipTurn"/> during <see cref="OnTurnStart"/>.
/// Reset to <c>false</c> at the start of each turn before effects are processed.
/// </summary>
public bool SkipTurn { get; private set; }
/// <summary>
/// Creates a new queue bound to the given <paramref name="character"/>.
/// </summary>
public StatusEffectQueue(Character character)
{
_character = character;
}
/// <summary>
/// Creates a deep copy of this queue bound to <paramref name="newCharacter"/>.
/// <see cref="StatusEffectItem"/> effects are field-copied; other effect types are shared by reference.
/// </summary>
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;
}
/// <summary>
/// Adds a new status effect to the end of the processing list. Effects are processed
/// in reverse order during turn callbacks (last-added first).
/// </summary>
public void AddEffect(IStatusEffect effect)
{
_effects.Add(effect);
}
/// <summary>
/// Processes all effects at the start of the character's turn: resets <see cref="SkipTurn"/>,
/// calls <see cref="IStatusEffect.OnTurnStart"/> for each effect in reverse order,
/// decrements the turn counter, and removes expired effects.
/// Called by <see cref="CardBattleCore.StartPlayerTurn"/> and <see cref="CardBattleCore.RunEnemyTurn"/>.
/// </summary>
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);
}
}
/// <summary>
/// Processes all effects at the end of the character's turn: calls
/// <see cref="IStatusEffect.OnTurnEnd"/> in reverse order and removes expired effects.
/// </summary>
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);
}
}
}
+52
View File
@@ -0,0 +1,52 @@
using Cthangover.Core.Characters;
namespace Cthangover.CardBattle.StatusEffect
{
/// <summary>
/// Status effect that causes a character to skip their entire turn.
/// On <see cref="OnTurnStart"/>, calls <see cref="IStatusActions.SkipTurn"/> which sets
/// <see cref="StatusEffectQueue.SkipTurn"/> to <c>true</c>. <see cref="CardBattleCore.RunEnemyTurn"/>
/// checks this flag and skips the character's action selection and resolution entirely.
/// Created by <see cref="PhysicsStunActionCard"/> when a stun card is played,
/// and added to the target's <see cref="StatusEffectQueue"/> via <c>Add("effect/physics/stun", turns)</c>.
/// </summary>
public class StunStatusEffect : IStatusEffect
{
/// <summary>Type identifier for stun (1).</summary>
public int Type => 1;
/// <summary>Number of turns this stun remains active.</summary>
public int Turns { get; set; }
/// <summary>Expired when the turn counter reaches zero or below.</summary>
public bool IsExpired => Turns <= 0;
/// <summary>
/// Creates a stun effect lasting for <paramref name="turns"/> turns.
/// </summary>
public StunStatusEffect(int turns)
{
Turns = turns;
}
/// <summary>
/// Parameterless constructor for deserialization. Sets <see cref="Turns"/> to 0 (expired immediately).
/// </summary>
public StunStatusEffect()
{
}
/// <summary>
/// Calls <see cref="IStatusActions.SkipTurn"/> to mark the character's turn as skipped.
/// </summary>
public void OnTurnStart(Character character, IStatusActions actions)
{
actions.SkipTurn();
}
/// <summary>
/// Stun has no end-of-turn effect.
/// </summary>
public void OnTurnEnd(Character character, IStatusActions actions)
{
}
}
}
+137
View File
@@ -0,0 +1,137 @@
using Cthangover.Core.Cards;
using Cthangover.Core.Characters;
using Cthangover.Core.UI;
using Godot;
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// UI node representing a single action card that the player can drag onto a target.
/// Implements <see cref="ICard"/> so <see cref="CardController"/> can hit-test drag targets.
/// Displayed in the <see cref="CardController"/>'s action panel below the character cards.
/// Supports hover scale-up animation, selection highlighting (<see cref="Select"/>, <see cref="Attack"/>, <see cref="Invalid"/>),
/// and info updates from the underlying <see cref="ActionCharacter"/> data model.
/// </summary>
public partial class ActionCardNode : ModWidget, ICard
{
private TextureRect frame;
private TextureRect image;
private Label nameLabel;
/// <inheritdoc />
public TextureRect Frame => frame;
/// <inheritdoc />
public TextureRect Image => image;
/// <summary>
/// The <see cref="ActionCharacter"/> data model backing this card. Contains the action's
/// name, image, type, and stat modifiers used during execution.
/// </summary>
public ActionCharacter Card { get; set; }
/// <summary>
/// Returns <c>this</c> — action cards are <see cref="Control"/> nodes themselves,
/// unlike <see cref="CharacterCardNode"/> which wraps a separate control.
/// </summary>
public Control GetControlNode() => this;
private Tween hoverTween;
private float baseY;
private bool isHovered;
private Vector2 _baseScale;
protected override void Construct()
{
image = GetNode<TextureRect>("Image");
frame = GetNode<TextureRect>("Frame");
nameLabel = GetNode<Label>("NameLabel");
Size = CustomMinimumSize;
MouseEntered += OnMouseEntered;
MouseExited += OnMouseExited;
}
/// <summary>
/// Placeholder for selection highlight. Currently unused — action cards use
/// <see cref="Attack"/> and <see cref="Invalid"/> for drag-over feedback instead.
/// </summary>
public void Select()
{
}
/// <summary>
/// Placeholder for attack-mode highlight. Currently unused — target highlighting
/// is applied by <see cref="ICardActionStrategy.HighlightTarget"/> instead.
/// </summary>
public void Attack()
{
}
/// <summary>
/// Placeholder for invalid-target highlight. Currently unused — invalidity is
/// indicated by <see cref="ICardActionStrategy.HighlightTarget"/> calling
/// <see cref="CharacterCardNode.Invalid"/> on the target card instead.
/// </summary>
public void Invalid()
{
}
/// <summary>
/// Resets the Z-index to 0 when the card is no longer being dragged.
/// </summary>
public void Unselect()
{
ZIndex = 0;
}
private void OnMouseEntered()
{
if (isHovered) return;
isHovered = true;
baseY = Position.Y;
hoverTween?.Kill();
hoverTween = CreateTween();
hoverTween.SetParallel(true);
hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
hoverTween.TweenProperty(this, "position:y", baseY - 10f, 0.12f);
hoverTween.TweenProperty(this, "scale", _baseScale * 1.15f, 0.12f);
}
private void OnMouseExited()
{
if (!isHovered) return;
isHovered = false;
hoverTween?.Kill();
hoverTween = CreateTween();
hoverTween.SetParallel(true);
hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
hoverTween.TweenProperty(this, "position:y", baseY, 0.12f);
hoverTween.TweenProperty(this, "scale", _baseScale, 0.12f);
}
/// <summary>
/// Initializes the card with data from <paramref name="card"/>, caches the base scale
/// for hover animation, sets the card image texture, and refreshes displayed text.
/// Called by <see cref="CardController.CreateAction"/>.
/// </summary>
public void Init(ActionCharacter card)
{
Card = card;
_baseScale = Scale;
if (image != null)
image.Texture = card.Image;
UpdateInfo();
}
/// <summary>
/// Refreshes the displayed name label from the translated <see cref="ActionCharacter.Name"/>.
/// </summary>
public void UpdateInfo()
{
if (nameLabel != null && Card != null)
nameLabel.Text = TranslationServer.Translate(Card.Name);
}
}
}
+263
View File
@@ -0,0 +1,263 @@
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Characters;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI;
using Godot;
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// Container panel that manages a list of <see cref="CharacterCardNode"/> instances for one side
/// of the battle (player or enemy). Handles card creation, multi-row layout when enemy count exceeds
/// <c>MAX_CARDS_ROW</c> (10 per row), smooth animated repositioning, health-triggered death,
/// and the <see cref="CardDeathAnimation"/> dissolve sequence.
/// Two instances are created by <see cref="CardBattleCore.Init"/>: a left-aligned player panel
/// and a right-aligned enemy panel.
/// </summary>
public partial class BattleCardPanel : ModWidget
{
private const int MAX_CARDS_ROW = 10;
private const float BASE_CARD_HEIGHT = 280f;
[Export] private Control content;
/// <summary>
/// Controls card alignment within the panel. <c>"Left"</c> for the player side,
/// <c>"Right"</c> for the enemy side. Affects <see cref="CalcTargetPosition"/>.
/// </summary>
[Export] public string AlignType { get; set; } = "Left";
[Export] private float cellOffset = 10f;
/// <summary>
/// All character cards currently managed by this panel. Populated by <see cref="Init"/>
/// and modified by <see cref="Dead"/> (removal) and <see cref="ClearAll"/> (full clear).
/// </summary>
public List<CharacterCardNode> CardList { get; } = new();
private Dictionary<CharacterCardNode, Tween> activeTweens = new();
/// <summary>
/// When <c>true</c>, cards are laid out in multiple rows (up to 3) instead of a single row.
/// Set by <see cref="CardBattleCore.Start"/> when enemy count exceeds 6.
/// </summary>
public bool UseRows { get; set; }
/// <summary>
/// Fired when a card's death animation completes. <see cref="CardBattleCore"/> subscribes
/// to record the defeated character in <see cref="BattleSceneContext"/>.
/// </summary>
public event System.Action<CharacterCardNode> OnCardDead;
protected override void Construct()
{
content ??= GetNodeOrNull<Control>("Content");
}
/// <summary>
/// Clears the current card list and creates new <see cref="CharacterCardNode"/> instances
/// for each <paramref name="cards"/> entry. Sets player/enemy team colors and subscribes
/// the health-change handler. Returns the created card list.
/// Called by <see cref="CardBattleCore.Start"/> with the full character roster for each side.
/// </summary>
public List<CharacterCardNode> Init(IEnumerable<Character> 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;
}
/// <summary>
/// Instantiates a single <see cref="CharacterCardNode"/>, 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 <see cref="Init"/> for each character.
/// </summary>
public CharacterCardNode Create(Character card, bool isPlayer, float cardScale)
{
var behaviour = (CharacterCardNode)TscnScenes.LoadAndBuild("scenes/character_card_node.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);
}
}
/// <summary>
/// Frees all card nodes from the scene tree, clears the card list, and triggers a redraw.
/// Called by <see cref="CardBattleCore.OnBattleCleared"/> when the battle scene is cleaned up.
/// </summary>
public void ClearAll()
{
foreach (var card in CardList.ToList())
{
if (GodotObject.IsInstanceValid(card))
card.QueueFree();
}
CardList.Clear();
activeTweens.Clear();
Redraw(animate: false);
}
/// <summary>
/// Recalculates and applies positions for all cards in the list. When <paramref name="animate"/>
/// is <c>true</c>, 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.
/// </summary>
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);
}
/// <summary>
/// Calculates how many rows are needed to display <paramref name="enemyCount"/> enemies
/// when <see cref="UseRows"/> is enabled. Returns 1, 2, or 3.
/// Called by <see cref="CardBattleCore.CalculateCardScale"/> to determine maximum card scale.
/// </summary>
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;
}
/// <summary>
/// Returns the maximum cards per row constant (<c>MAX_CARDS_ROW = 10</c>).
/// Used by <see cref="CardBattleCore.CalculateCardScale"/> for scale calculations.
/// </summary>
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();
}
/// <summary>
/// Marks the card as dead and starts the <see cref="CardDeathAnimation"/> dissolve sequence.
/// Once the animation completes, the card is removed from <see cref="CardList"/>,
/// <see cref="OnCardDead"/> is fired, and the remaining cards are redrawn.
/// Triggered by the health-change handler when a character's health reaches zero.
/// </summary>
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();
});
}
}
}
+510
View File
@@ -0,0 +1,510 @@
using System.Collections.Generic;
using Cthangover.Core.Cards;
using Cthangover.Core.Audio;
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
using Cthangover.Core.Characters;
using Cthangover.Core.UI;
using Godot;
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// Central input handler for the card battle UI. Processes mouse clicks and drags to implement
/// the full player interaction flow: click a character card to show its action cards in the
/// action panel, drag an action card onto a valid target (enemy for attacks, ally for support,
/// self for self-buffs), and execute the action on drop. Valid targets are determined by
/// <see cref="CardActionStrategyFactory.Get"/> dispatching the appropriate <see cref="ICardActionStrategy"/>.
/// Fires <see cref="OnActionExecuted"/> after each action, which <see cref="CardBattleCore"/>
/// uses to detect automatic turn end when all points are exhausted.
/// </summary>
public partial class CardController : InputHandlerNode
{
private Control actionPanel;
private BattleCardPanel[] panels;
private List<CharacterCardNode> characterCards = new();
private List<ActionCardNode> actionCards = new();
private CharacterCardNode selectedCharacter;
private CharacterCardNode targetCharacter;
private ActionCardNode currentActionCharacter;
private bool isDragging;
private bool dragValid;
private float dragThreshold = 15f;
private Vector2 pressPosition;
private Node _originalActionCardParent;
/// <summary>
/// Locates the action panel and both battle card panels (<see cref="BattleCardPanel"/>) in the
/// scene tree relative to this node. Called once by <see cref="CardBattleCore.Init"/>
/// after the controller is added to the scene.
/// </summary>
public void FindPanels()
{
actionPanel = GetNodeOrNull<Control>("../ActionPanel");
panels = new[]
{
GetNodeOrNull<BattleCardPanel>("../PlayerPanel"),
GetNodeOrNull<BattleCardPanel>("../EnemyPanel"),
};
}
protected override void OnInput(InputEvent @event)
{
if (@event is InputEventMouseButton mb1 && mb1.Pressed && mb1.ButtonIndex == MouseButton.Left)
GameLogger.Log("CARD_CTRL", "_Input received mouse press", LogLevel.Debug);
if (Cthangover.Core.Battle.BattleSceneContext.Instance != null &&
(Cthangover.Core.Battle.BattleSceneContext.Instance.IsWait ||
Cthangover.Core.Battle.BattleSceneContext.Instance.IsDestroyed))
{
if (@event is InputEventMouseButton mb2 && !mb2.Pressed)
GameLogger.Log("CARD_CTRL",
$"Input blocked: IsWait={Cthangover.Core.Battle.BattleSceneContext.Instance.IsWait} IsDestroyed={Cthangover.Core.Battle.BattleSceneContext.Instance.IsDestroyed}",
LogLevel.Debug);
return;
}
if (@event is InputEventMouseButton mouseButton)
{
if (mouseButton.ButtonIndex == MouseButton.Left)
{
if (mouseButton.Pressed)
{
pressPosition = mouseButton.GlobalPosition;
isDragging = false;
dragValid = false;
OnBeginDrag(mouseButton.GlobalPosition);
}
else
{
if (isDragging && dragValid)
OnEndDrag(mouseButton.GlobalPosition);
else
OnPointerClick(mouseButton.GlobalPosition);
isDragging = false;
dragValid = false;
}
}
}
if (@event is InputEventMouseMotion mouseMotion)
{
if (mouseMotion.ButtonMask.HasFlag(MouseButtonMask.Left))
{
if (dragValid && !isDragging && (mouseMotion.GlobalPosition - pressPosition).Length() > dragThreshold)
isDragging = true;
if (isDragging && dragValid)
OnDrag(mouseMotion.GlobalPosition);
}
}
}
private float cardScale = 1f;
/// <summary>
/// Uniform scale factor applied to all action cards created by this controller.
/// Set by <see cref="CardBattleCore.Start"/> based on viewport fitting calculations.
/// </summary>
public float CardScale
{
get => cardScale;
set => cardScale = value;
}
/// <summary>
/// Fired after a successful drag-and-drop action execution. Subscribed by
/// <see cref="CardBattleCore.CheckPlayerTurnEnd"/> to auto-end the player's turn
/// when all action points are spent.
/// </summary>
public event System.Action OnActionExecuted;
/// <summary>
/// Clears previous action cards and creates new <see cref="ActionCardNode"/> instances
/// for every action in <paramref name="character"/>'s deck. Action cards fade in and are
/// laid out horizontally in the action panel. Called when the player clicks a character card.
/// </summary>
public void ShowActions(CharacterCardNode character)
{
if (character?.Card?.Actions == null)
{
GameLogger.Log("CARD_CTRL",
$"ShowActions: NULL actions for character {(character?.Card?.Name ?? "null")}",
LogLevel.Error);
return;
}
GameLogger.Log("CARD_CTRL",
$"ShowActions: {character.Card.Name} ({character.Card.Actions.Count} actions)",
LogLevel.Debug);
DiscardActionCharacters();
var originPos = character.GlobalPosition * actionPanel.GetGlobalTransformWithCanvas().AffineInverse();
var count = character.Card.Actions.Count;
for (int i = 0; i < count; i++)
{
var action = character.Card.Actions[i];
if (!action.Type.UseInBattle())
continue;
var cardNode = CreateAction(action, cardScale);
cardNode.Modulate = new Color(1, 1, 1, 0);
cardNode.Position = originPos - cardNode.Size / 2;
actionCards.Add(cardNode);
var targetPos = new Vector2(cardNode.Size.X * i, 0);
var tween = CreateTween();
tween.SetParallel(true);
tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
tween.TweenProperty(cardNode, "modulate:a", 1f, 0.5f);
tween.TweenProperty(cardNode, "position", targetPos, 0.5f);
}
}
/// <summary>
/// Animates existing action cards fading out and dropping down, then frees them.
/// Called before showing new action cards for a different character.
/// </summary>
public void DiscardActionCharacters()
{
if (actionCards.Count == 0)
return;
var oldCards = new List<ActionCardNode>(actionCards);
actionCards.Clear();
foreach (var card in oldCards)
{
if (!GodotObject.IsInstanceValid(card))
continue;
card.MouseFilter = Control.MouseFilterEnum.Ignore;
var tween = CreateTween();
tween.SetParallel(true);
tween.SetEase(Tween.EaseType.In).SetTrans(Tween.TransitionType.Cubic);
tween.TweenProperty(card, "modulate:a", 0f, 0.15f);
tween.TweenProperty(card, "position:y", card.Position.Y + 40f, 0.15f);
tween.Chain().TweenCallback(Callable.From(card.QueueFree));
}
}
/// <summary>
/// Deselects the current action card, source character, and target character, clearing
/// their visual highlights and optionally discarding action cards. When
/// <paramref name="clearSource"/> is <c>true</c>, the action panel is also cleared. Triggers
/// a redraw of all battle panels to restore normal Z-ordering.
/// </summary>
public void ClearSelections(bool clearSource = true)
{
if (currentActionCharacter != null)
{
currentActionCharacter.Unselect();
currentActionCharacter = null;
}
if (selectedCharacter != null && clearSource)
{
selectedCharacter.Unselect();
selectedCharacter = null;
DiscardActionCharacters();
}
if (targetCharacter != null)
{
targetCharacter.Unselect();
targetCharacter = null;
}
if (panels != null)
{
foreach (var panel in panels)
panel?.Redraw();
}
}
/// <summary>
/// Immediately frees all action card nodes without animation. Called at battle cleanup
/// by <see cref="CardBattleCore.OnBattleCleared"/>.
/// </summary>
public void ClearActions()
{
if (actionCards.Count > 0)
{
foreach (var card in actionCards)
card.QueueFree();
actionCards.Clear();
}
}
/// <summary>
/// Re-positions all action cards in a horizontal row within the action panel.
/// When <paramref name="animate"/> is <c>true</c>, cards tween smoothly to their targets.
/// </summary>
public void Redraw(bool animate = true)
{
var size = actionCards.Count;
for (int i = 0; i < size; i++)
SetActionCharacterPosition(actionCards[i], i, size, animate);
}
private void SetActionCharacterPosition(ActionCardNode card, int index, int count, bool animate)
{
var rect = card.GetControlNode();
var targetPos = new Vector2(rect.Size.X * index, 0);
if (animate)
{
var tween = CreateTween();
tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
tween.TweenProperty(rect, "position", targetPos, 0.25f);
}
else
{
rect.Position = targetPos;
}
}
/// <summary>
/// Instantiates an <see cref="ActionCardNode"/>, adds it to the action panel, applies
/// the given <paramref name="cardScale"/>, and initializes it with <paramref name="actionCard"/> data.
/// Called by <see cref="ShowActions"/> for each action in a character's deck.
/// </summary>
public ActionCardNode CreateAction(ActionCharacter actionCard, float cardScale)
{
var card = (ActionCardNode)TscnScenes.LoadAndBuild("scenes/action_card_node.tscn");
card.EnsureConstructed();
actionPanel.AddChild(card);
card.Scale = new Vector2(cardScale, cardScale);
card.Init(actionCard);
return card;
}
/// <summary>
/// Resolves the currently selected action against the selected target using the
/// appropriate <see cref="ICardActionStrategy"/>. Called by <see cref="OnEndDrag"/>
/// when a valid drop is confirmed.
/// </summary>
public void DoActionToTarget()
{
var strategy = Cthangover.CardBattle.Player.CardActionStrategyFactory.Get(
currentActionCharacter.Card, selectedCharacter, targetCharacter);
strategy?.Execute(selectedCharacter, targetCharacter, currentActionCharacter.Card);
}
private void OnPointerClick(Vector2 screenPosition)
{
characterCards = GetAllCharacters();
var source = TryFindCardByPosition(screenPosition, characterCards);
if (source != null)
GameLogger.Log("CARD_CTRL",
$"Click on card: {source.Card.Name} isPlayer={source.IsPlayer} actionsCount={source.Card.Actions?.Count ?? 0}",
LogLevel.Debug);
else
GameLogger.Log("CARD_CTRL", "Click on empty space", LogLevel.Debug);
if (source != selectedCharacter && selectedCharacter != null)
selectedCharacter.Unselect();
if (source != null && source.IsPlayer)
{
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService");
audioService?.PlaySound("battle/card_show_actions", Cthangover.Core.Audio.SoundType.CardEffect);
selectedCharacter = source;
ShowActions(selectedCharacter);
selectedCharacter.Select();
}
else
{
ClearSelections();
}
}
private void OnBeginDrag(Vector2 screenPosition)
{
var source = TryFindCardByPosition(screenPosition, actionCards);
if (currentActionCharacter != null && source != currentActionCharacter)
currentActionCharacter.Unselect();
if (source != null)
{
dragValid = true;
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService");
audioService?.PlaySound("battle/card_pick", 4, Cthangover.Core.Audio.SoundType.CardEffect);
GameLogger.Log("CARD_CTRL",
$"Begin drag: {source.Card.Name}",
LogLevel.Debug);
currentActionCharacter = source;
var control = currentActionCharacter.GetControlNode();
_originalActionCardParent = control.GetParent();
var globalPos = control.GlobalPosition;
control.Reparent(GetTree().Root);
control.GlobalPosition = globalPos;
control.ZIndex = 4096;
var pickTween = CreateTween();
pickTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Back);
pickTween.TweenProperty(control, "scale", new Vector2(1.1f, 1.1f), 0.1f);
foreach (var card in actionCards)
{
if (card != currentActionCharacter && GodotObject.IsInstanceValid(card))
card.MouseFilter = Control.MouseFilterEnum.Ignore;
}
}
}
private void OnDrag(Vector2 screenPosition)
{
if (currentActionCharacter == null)
return;
var cardControl = currentActionCharacter.GetControlNode();
cardControl.GlobalPosition = screenPosition - cardControl.Size / 2;
var target = TryFindCardByRect(cardControl, characterCards);
if (target != targetCharacter && targetCharacter != null)
targetCharacter.Unselect();
if (target != null)
{
targetCharacter = target;
var strategy = Cthangover.CardBattle.Player.CardActionStrategyFactory.Get(
currentActionCharacter.Card, selectedCharacter, target);
var valid = strategy != null && strategy.Check(currentActionCharacter.Card, selectedCharacter, target);
GameLogger.Log("CARD_CTRL",
$"Drag over: {target.Card.Name} valid={valid}",
LogLevel.Debug);
if (valid)
{
strategy.HighlightTarget(currentActionCharacter, targetCharacter);
}
else
{
targetCharacter.Invalid();
currentActionCharacter.Invalid();
}
}
else
{
targetCharacter = null;
currentActionCharacter.Unselect();
}
}
private void OnEndDrag(Vector2 screenPosition)
{
foreach (var card in actionCards)
{
if (card != null && GodotObject.IsInstanceValid(card))
card.MouseFilter = Control.MouseFilterEnum.Stop;
}
if (selectedCharacter != null && currentActionCharacter != null && targetCharacter != null)
{
var strategy = Cthangover.CardBattle.Player.CardActionStrategyFactory.Get(
currentActionCharacter.Card, selectedCharacter, targetCharacter);
var valid = strategy != null && strategy.Check(currentActionCharacter.Card, selectedCharacter, targetCharacter);
GameLogger.Log("CARD_CTRL",
$"End drag: {currentActionCharacter.Card.Name} from {selectedCharacter.Card.Name} to {targetCharacter.Card.Name} valid={valid}",
LogLevel.Debug);
if (valid)
{
if (_originalActionCardParent != null)
{
currentActionCharacter.GetControlNode().Reparent(_originalActionCardParent);
_originalActionCardParent = null;
}
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService");
audioService?.PlaySound("battle/card_drop", 4, Cthangover.Core.Audio.SoundType.CardEffect);
DoActionToTarget();
if (panels != null)
{
foreach (var panel in panels)
panel?.Redraw();
}
ClearSelections();
OnActionExecuted?.Invoke();
return;
}
}
if (currentActionCharacter != null)
{
var control = currentActionCharacter.GetControlNode();
if (_originalActionCardParent != null)
{
control.Reparent(_originalActionCardParent);
_originalActionCardParent = null;
}
var resetTween = CreateTween();
resetTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
resetTween.TweenProperty(control, "scale", Vector2.One, 0.15f);
control.ZIndex = 0;
}
Redraw();
ClearSelections(false);
}
private T TryFindCardByPosition<T>(Vector2 screenPosition, IList<T> cards, ICard exclude = null) where T : class, ICard
{
if (cards == null)
return null;
foreach (var card in cards)
{
if (card == exclude || card == null)
continue;
if (card.Frame != null && card.Frame.GetGlobalRect().HasPoint(screenPosition))
return card;
}
return null;
}
private T TryFindCardByRect<T>(Control draggingRect, IList<T> cards, ICard exclude = null) where T : class, ICard
{
Vector2 screenPoint = draggingRect.GlobalPosition + draggingRect.Size / 2;
foreach (var card in cards)
{
if (card == exclude || card == null)
continue;
if (card.Frame != null && card.Frame.GetGlobalRect().HasPoint(screenPoint))
return card;
}
return null;
}
private List<CharacterCardNode> GetAllCharacters()
{
var list = new List<CharacterCardNode>();
var root = GetParent();
if (root != null)
CollectNodes<CharacterCardNode>(root, list);
return list;
}
private static void CollectNodes<T>(Node parent, List<T> results) where T : Node
{
foreach (Node child in parent.GetChildren())
{
if (child is T t)
results.Add(t);
CollectNodes(child, results);
}
}
}
}
+101
View File
@@ -0,0 +1,101 @@
using System;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.UI;
using Godot;
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// Plays a death animation on a <see cref="CharacterCardNode"/> 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 <see cref="CharacterCardNode.AllImages"/> textures.
/// The dissolve shader (<c>"scene_transition"</c>) is resolved once via <see cref="ModManager"/>
/// and cached statically. Created and started by <see cref="BattleCardPanel.Dead"/>.
/// </summary>
public partial class CardDeathAnimation : Node
{
private CharacterCardNode card;
private Vector2 center;
private Action onComplete;
private static Shader dissolveShader;
/// <summary>
/// Begins the death animation sequence. Calculates a target position at the viewport center
/// (relative to the card's parent), starts fading all child <see cref="Label"/> nodes,
/// applies the dissolve shader material to all card images, and tweens position/rotation/alpha.
/// On completion, invokes <paramref name="onComplete"/> and frees this node.
/// </summary>
public void StartAnimation(CharacterCardNode card, Action onComplete)
{
this.card = card;
this.onComplete = onComplete;
var viewportCenter = card.GetViewport().GetVisibleRect().Size / 2;
center = card.GetParent<Control>() 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<float>(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);
}
}
}
}
+277
View File
@@ -0,0 +1,277 @@
using Cthangover.Core.Cards;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Characters;
using Cthangover.Core.UI;
using Godot;
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// UI node representing a single character (player or enemy) on the battle field.
/// Displays the character portrait, name, health bar, and stat labels (defence S, attack A, action points P).
/// Implements <see cref="ICard"/> for hit-testing during drag-and-drop targeting.
/// Supports team-colored outlines (blue for player, yellow for enemy), hover scale-up animation,
/// and multiple selection highlight modes (<see cref="Select"/>, <see cref="Attack"/>, <see cref="Invalid"/>)
/// driven by the <see cref="ICardActionStrategy"/> flow. Manages Z-index layering for selected,
/// hovered, and normal states.
/// </summary>
public partial class CharacterCardNode : ModWidget, ICard
{
private ColorRect teamOutline;
private TextureRect frame;
private TextureRect image;
private TextureRect selection;
private ColorRect healthRect;
private Label nameLabel;
private Label defenceLabel;
private Label attackLabel;
private Label pointsLabel;
private Color selectionState;
private float defaultWidth;
private float baseY;
private bool isHovered;
private Tween hoverTween;
private Tween selectionTween;
private static Texture2D cachedSelectTex;
private static bool selectTexLoaded;
/// <summary>
/// Distinguishes player-owned cards from enemy cards. Affects team outline color in
/// <see cref="SetTeamColors"/> and determines whether the card is clickable in
/// <see cref="CardController.OnPointerClick"/>.
/// </summary>
public bool IsPlayer { get; set; }
/// <summary>
/// Marks the card as dead. Set by <see cref="BattleCardPanel.Dead"/>, checked by
/// <see cref="CardBattleCore"/> when counting alive cards and by
/// <see cref="CardBattleCore.RunEnemyTurn"/> when iterating for enemy actions.
/// </summary>
public bool IsDead { get; set; }
private int _baseZIndex;
private bool _selected;
private Vector2 _baseScale;
/// <inheritdoc />
public TextureRect Frame => frame;
/// <inheritdoc />
public TextureRect Image => image;
/// <summary>
/// All <see cref="TextureRect"/> nodes on this card (frame, image, selection).
/// Consumed by <see cref="CardDeathAnimation"/> to apply the dissolve shader to every visual element.
/// </summary>
public TextureRect[] AllImages => _allImages ??= new[] { frame, image, selection };
private TextureRect[] _allImages;
/// <summary>
/// The <see cref="Character"/> data model backing this card. Contains attributes
/// (health, attack, defence, points) and the action deck.
/// </summary>
public Character Card { get; set; }
/// <summary>
/// Returns <c>this</c> — character cards are self-contained <see cref="Control"/> nodes.
/// </summary>
public Control GetControlNode() => this;
protected override void Construct()
{
teamOutline = GetNode<ColorRect>("TeamOutline");
image = GetNode<TextureRect>("Image");
frame = GetNode<TextureRect>("Frame");
selection = GetNode<TextureRect>("Selection");
nameLabel = GetNode<Label>("InfoPanel/NameLabel");
healthRect = GetNode<ColorRect>("InfoPanel/HealthBar/HealthRect");
defenceLabel = GetNode<Label>("InfoPanel/Labels/DefenceLabel");
attackLabel = GetNode<Label>("InfoPanel/Labels/AttackLabel");
pointsLabel = GetNode<Label>("InfoPanel/Labels/PointsLabel");
Size = CustomMinimumSize;
defaultWidth = 230f;
selectionState = new Color(0, 0, 0, 0);
if (!selectTexLoaded)
{
cachedSelectTex = Textures.Resolve("select");
selectTexLoaded = true;
}
if (cachedSelectTex != null)
{
selection.Texture = cachedSelectTex;
selection.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize;
selection.StretchMode = TextureRect.StretchModeEnum.Scale;
}
MouseEntered += OnMouseEntered;
MouseExited += OnMouseExited;
}
/// <summary>
/// Initializes the card with the given <paramref name="characterCard"/> data, sets the
/// portrait and frame textures, resets selection visuals, and refreshes stat labels.
/// Called by <see cref="BattleCardPanel.Create"/>.
/// </summary>
public void Init(Character characterCard)
{
Card = characterCard;
_baseScale = Scale;
if (image != null)
image.Texture = characterCard.Image;
if (frame != null && characterCard?.Image != null)
frame.Texture = characterCard.Image;
if (selection != null)
selection.Modulate = new Color(0, 0, 0, 0);
selectionState = new Color(0, 0, 0, 0);
UpdateInfo();
}
/// <summary>
/// Applies a team-colored outline: blue for player cards (<see cref="IsPlayer"/> = <c>true</c>),
/// yellow for enemy cards. Called by <see cref="BattleCardPanel.Create"/> after initialization.
/// </summary>
public void SetTeamColors()
{
if (teamOutline != null)
teamOutline.Color = IsPlayer
? new Color(0.2f, 0.4f, 1f, 0.5f)
: new Color(1f, 0.8f, 0.1f, 0.5f);
}
/// <summary>
/// Sets the base Z-index used for layering cards. When not selected, the card renders at
/// this Z-index. Selected cards jump to Z-index 4096 to render above all others.
/// Called by <see cref="BattleCardPanel.SetCardPosition"/> during layout.
/// </summary>
public void SetBaseZIndex(int z)
{
_baseZIndex = z;
if (!_selected)
ZIndex = z;
}
/// <summary>
/// Applies a green selection highlight, raises Z-index to 4096 to ensure the card
/// is clickable above other cards during drag-and-drop targeting, and animates
/// the selection overlay to green. Called when the player clicks a character card
/// or when a valid support/self target is dragged over.
/// </summary>
public void Select()
{
_selected = true;
selectionState = new Color(0, 1, 0, 1);
AnimateSelection(selectionState);
if (selection != null) selection.ZIndex = 1;
ZIndex = 4096;
}
/// <summary>
/// Clears the selection highlight, restores Z-index to <see cref="SetBaseZIndex"/> value,
/// and fades the selection overlay to transparent.
/// </summary>
public void Unselect()
{
_selected = false;
selectionState = new Color(0, 0, 0, 0);
AnimateSelection(selectionState);
if (selection != null) selection.ZIndex = 0;
ZIndex = _baseZIndex;
}
/// <summary>
/// Applies a red highlight indicating this card is being targeted for an attack.
/// Called by <see cref="ICardActionStrategy.HighlightTarget"/> during drag-over.
/// </summary>
public void Attack()
{
selectionState = new Color(1, 0, 0, 1);
AnimateSelection(selectionState);
if (selection != null) selection.ZIndex = 1;
}
/// <summary>
/// Applies a gray dimming highlight indicating this card is an invalid target
/// for the currently dragged action card. Called by <see cref="CardController.OnDrag"/>
/// when the strategy's <c>Check</c> returns <c>false</c>.
/// </summary>
public void Invalid()
{
selectionState = new Color(0.4f, 0.4f, 0.4f, 0.8f);
AnimateSelection(selectionState);
if (selection != null) selection.ZIndex = 1;
}
private void AnimateSelection(Color target)
{
if (selection == null) return;
selectionTween?.Kill();
selectionTween = CreateTween();
selectionTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad);
selectionTween.TweenProperty(selection, "modulate", target, 0.15f);
}
/// <summary>
/// Refreshes all displayed stats from the underlying <see cref="Character"/> data:
/// name, defence, attack, action points, and health bar width (based on health percent).
/// Called after every action execution and during initial setup.
/// </summary>
public void UpdateInfo()
{
if (healthRect == null || Card == null) return;
if (nameLabel != null)
nameLabel.Text = TranslationServer.Translate(Card.Name);
if (defenceLabel != null)
defenceLabel.Text = Card.Attributes.Defence.Value.ToString();
if (attackLabel != null)
attackLabel.Text = Card.Attributes.Attack.Value.ToString();
if (pointsLabel != null)
pointsLabel.Text = Card.Attributes.Point.Value.ToString();
var sizeDelta = healthRect.Size;
sizeDelta.X = Card.Attributes.Health.Percent * defaultWidth;
healthRect.SetDeferred(Control.PropertyName.Size, sizeDelta);
}
private void OnMouseEntered()
{
if (isHovered) return;
isHovered = true;
baseY = Position.Y;
if (!_selected)
ZIndex = _baseZIndex + 100;
hoverTween?.Kill();
hoverTween = CreateTween();
hoverTween.SetParallel(true);
hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
hoverTween.TweenProperty(this, "position:y", baseY - 10f, 0.15f);
hoverTween.TweenProperty(this, "scale", _baseScale * 1.15f, 0.15f);
}
private void OnMouseExited()
{
if (!isHovered) return;
isHovered = false;
if (!_selected)
ZIndex = _baseZIndex;
hoverTween?.Kill();
hoverTween = CreateTween();
hoverTween.SetParallel(true);
hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
hoverTween.TweenProperty(this, "position:y", baseY, 0.15f);
hoverTween.TweenProperty(this, "scale", _baseScale, 0.15f);
}
}
}
+41
View File
@@ -0,0 +1,41 @@
using Cthangover.Core.UI;
using Godot;
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// 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 <see cref="OnPressed"/> when clicked, which <see cref="CardBattleCore"/> binds
/// to <c>OnPlayerTurnEnd</c>. The button text is translated via the <c>"battle/end_turn"</c> key.
/// </summary>
public partial class EndTurnButton : ModWidget
{
private Button _button;
/// <summary>
/// Fired when the button is pressed. Subscribed by <see cref="CardBattleCore.OnEndTurnPressed"/>.
/// </summary>
public event System.Action OnPressed;
protected override void Construct()
{
_button = GetNode<Button>("BtnEndTurn");
Size = CustomMinimumSize;
_button.Text = TranslationServer.Translate("battle/end_turn");
_button.Pressed += () => OnPressed?.Invoke();
}
/// <summary>
/// Shows or hides the underlying <see cref="Button"/> control.
/// Called by <see cref="CardBattleCore"/> during turn transitions and battle cleanup.
/// </summary>
public void SetVisible(bool visible)
{
if (_button != null)
_button.Visible = visible;
}
}
}