commit 27f38c6c1fa6d09ff1de33d4222899a63e77867e Author: iisaev Date: Thu Sep 10 10:58:00 2026 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c301ca8 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/locale/ui_en.properties b/locale/ui_en.properties new file mode 100644 index 0000000..e2a74ae --- /dev/null +++ b/locale/ui_en.properties @@ -0,0 +1,4 @@ +mod_card_battle_name = CardBattle +mod_card_battle_desc = Card battle mechanics + +battle/end_turn = End turn diff --git a/locale/ui_ru.properties b/locale/ui_ru.properties new file mode 100644 index 0000000..be650d0 --- /dev/null +++ b/locale/ui_ru.properties @@ -0,0 +1,4 @@ +mod_card_battle_name=Карточная битва +mod_card_battle_desc=Механики карточных сражений + +battle/end_turn=Конец хода \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..c8f829d --- /dev/null +++ b/manifest.json @@ -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" ] +} diff --git a/mod.png b/mod.png new file mode 100644 index 0000000..2edfb90 Binary files /dev/null and b/mod.png differ diff --git a/scenes/action_card_node.tscn b/scenes/action_card_node.tscn new file mode 100644 index 0000000..83011dd --- /dev/null +++ b/scenes/action_card_node.tscn @@ -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 diff --git a/scenes/battle_card_panel.tscn b/scenes/battle_card_panel.tscn new file mode 100644 index 0000000..9b50421 --- /dev/null +++ b/scenes/battle_card_panel.tscn @@ -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 diff --git a/scenes/character_card_node.tscn b/scenes/character_card_node.tscn new file mode 100644 index 0000000..48f0f7f --- /dev/null +++ b/scenes/character_card_node.tscn @@ -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" diff --git a/scenes/end_turn_button.tscn b/scenes/end_turn_button.tscn new file mode 100644 index 0000000..27d3858 --- /dev/null +++ b/scenes/end_turn_button.tscn @@ -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 diff --git a/sounds/battle/card_defence_empty.ogg b/sounds/battle/card_defence_empty.ogg new file mode 100644 index 0000000..97c2baf Binary files /dev/null and b/sounds/battle/card_defence_empty.ogg differ diff --git a/sounds/battle/card_drop_1.ogg b/sounds/battle/card_drop_1.ogg new file mode 100644 index 0000000..7fa771d Binary files /dev/null and b/sounds/battle/card_drop_1.ogg differ diff --git a/sounds/battle/card_drop_2.ogg b/sounds/battle/card_drop_2.ogg new file mode 100644 index 0000000..af7482a Binary files /dev/null and b/sounds/battle/card_drop_2.ogg differ diff --git a/sounds/battle/card_drop_3.ogg b/sounds/battle/card_drop_3.ogg new file mode 100644 index 0000000..4db1209 Binary files /dev/null and b/sounds/battle/card_drop_3.ogg differ diff --git a/sounds/battle/card_drop_4.ogg b/sounds/battle/card_drop_4.ogg new file mode 100644 index 0000000..65e8f3c Binary files /dev/null and b/sounds/battle/card_drop_4.ogg differ diff --git a/sounds/battle/card_empty.ogg b/sounds/battle/card_empty.ogg new file mode 100644 index 0000000..6ce06f1 Binary files /dev/null and b/sounds/battle/card_empty.ogg differ diff --git a/sounds/battle/card_physics_damage.ogg b/sounds/battle/card_physics_damage.ogg new file mode 100644 index 0000000..7337e84 Binary files /dev/null and b/sounds/battle/card_physics_damage.ogg differ diff --git a/sounds/battle/card_physics_defence.ogg b/sounds/battle/card_physics_defence.ogg new file mode 100644 index 0000000..e30b214 Binary files /dev/null and b/sounds/battle/card_physics_defence.ogg differ diff --git a/sounds/battle/card_pick_1.ogg b/sounds/battle/card_pick_1.ogg new file mode 100644 index 0000000..49d8a86 Binary files /dev/null and b/sounds/battle/card_pick_1.ogg differ diff --git a/sounds/battle/card_pick_2.ogg b/sounds/battle/card_pick_2.ogg new file mode 100644 index 0000000..f27a4cd Binary files /dev/null and b/sounds/battle/card_pick_2.ogg differ diff --git a/sounds/battle/card_pick_3.ogg b/sounds/battle/card_pick_3.ogg new file mode 100644 index 0000000..6084701 Binary files /dev/null and b/sounds/battle/card_pick_3.ogg differ diff --git a/sounds/battle/card_pick_4.ogg b/sounds/battle/card_pick_4.ogg new file mode 100644 index 0000000..809152a Binary files /dev/null and b/sounds/battle/card_pick_4.ogg differ diff --git a/sounds/battle/card_show_actions.ogg b/sounds/battle/card_show_actions.ogg new file mode 100644 index 0000000..dba95d4 Binary files /dev/null and b/sounds/battle/card_show_actions.ogg differ diff --git a/sounds/battle/card_stun_electro.ogg b/sounds/battle/card_stun_electro.ogg new file mode 100644 index 0000000..a018095 Binary files /dev/null and b/sounds/battle/card_stun_electro.ogg differ diff --git a/src/Actions/AbstractBattleAction.cs b/src/Actions/AbstractBattleAction.cs new file mode 100644 index 0000000..826ed9a --- /dev/null +++ b/src/Actions/AbstractBattleAction.cs @@ -0,0 +1,87 @@ +using Cthangover.CardBattle.UI; +using Cthangover.Core.Battle.Actions; +using Cthangover.Core.Characters; +using Godot; + +namespace Cthangover.CardBattle.Actions +{ + /// + /// Base class for animated battle actions that move, rotate, and scale card controls + /// through a sequence of visual phases. Subclasses implement DoInternalAction to advance + /// a frame-by-frame animation state machine driven by 's + /// per-frame polling loop (while (!DoAction()) await frame;). + /// Captures source/target positions and rotations in so that + /// can restore them regardless of what the animation did. + /// + public abstract class AbstractBattleAction : IBattleAction + { + 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; } + + /// + /// Advances the animation by one frame. Called in a loop by + /// with await root.ToSignal(frame) between calls. Returns true when the animation + /// has completed all phases and the action result has been applied. + /// Returns true early if any required node became null (card was destroyed mid-animation). + /// + /// true when the animation is finished or cannot proceed. + public bool DoAction() + { + if (Source == null || Target == null || Action == null || SourceControl == null || TargetControl == null) + return true; + return DoInternalAction(); + } + + 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; + } + + /// + /// Captures the current positions, rotations, and references of both source + /// and target cards before the animation begins, then delegates to the subclass + /// for any additional setup (e.g. resetting timestamps). + /// + public void DoStart() + { + if (Source == null || Target == null || Action == null) + return; + + SourceControl = Source.GetControlNode(); + SourcePos = SourceControl.GlobalPosition; + SourceRot = SourceControl.Rotation; + TargetControl = Target.GetControlNode(); + TargetPos = TargetControl.GlobalPosition; + TargetRot = TargetControl.Rotation; + + DoInternalStart(); + } + + /// + /// Restores source and target card controls to their original positions, rotations, and visual state + /// captured during . Delegates specific cleanup to . + /// + public void DoEnd() + { + if (Source == null || Target == null || Action == null) + return; + DoInternalEnd(); + } + } +} diff --git a/src/Actions/ActionPhase.cs b/src/Actions/ActionPhase.cs new file mode 100644 index 0000000..afb378a --- /dev/null +++ b/src/Actions/ActionPhase.cs @@ -0,0 +1,26 @@ +namespace Cthangover.CardBattle.Actions +{ + /// + /// Phases of the animated battle action state machine used by + /// and . Each tick, the action evaluates the current phase, + /// interpolates card positions/rotations/scales, and transitions to the next phase when its + /// duration elapses. The Recover phase signals completion by returning true from DoInternalAction. + /// + public enum ActionPhase + { + /// Initial delay before any movement. + Wait, + /// Source card rotates toward target and begins slight movement. + Prepare, + /// Source card travels from its position to the target along a movement pattern. + MoveForAttack, + /// Damage/effect is applied to the target card at the point of impact. + AttackImpact, + /// Target card shows knockback, squashing, and shaking reaction. + TargetReaction, + /// Source card returns toward its original position. + MoveForBack, + /// All cards smoothly return to their original transforms. + Recover + } +} diff --git a/src/Actions/CardBattleActionProvider.cs b/src/Actions/CardBattleActionProvider.cs new file mode 100644 index 0000000..819f850 --- /dev/null +++ b/src/Actions/CardBattleActionProvider.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using Cthangover.Core.Battle.Actions; +using Cthangover.Core.Utils; + +namespace Cthangover.CardBattle.Actions +{ + /// + /// Registry that maps action type IDs ("physics/attack", "physics/defence", "physics/stun") + /// to their respective implementations. Created by + /// and registered with at battle startup so that actions resolved + /// during drag-and-drop or enemy AI turns go through the correct damage/defence/stun logic. + /// + public class CardBattleActionProvider : IActionExecutorProvider + { + private readonly Dictionary _executors; + + /// + /// Initializes the provider with the three card-battle action executors: + /// for attacks, for defence buffs, + /// and for stun effects. + /// + public CardBattleActionProvider() + { + _executors = new Dictionary + { + ["physics/attack"] = new PhysicsDamageActionCard(), + ["physics/defence"] = new PhysicsDefenceActionCard(), + ["physics/stun"] = new PhysicsStunActionCard(), + }; + } + + /// + /// Looks up the for the given . + /// Logs a warning and returns null if no executor is registered, allowing the caller + /// to gracefully skip unrecognized actions. + /// + 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; + } + } +} diff --git a/src/Actions/PhysicsAttackAction.cs b/src/Actions/PhysicsAttackAction.cs new file mode 100644 index 0000000..1c4149b --- /dev/null +++ b/src/Actions/PhysicsAttackAction.cs @@ -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 +{ + /// + /// Animated battle action that moves the source card toward an enemy target, applies damage/defence + /// via , and animates the target's knockback/squash reaction. + /// The source card follows one of five randomly-selected trajectories + /// (direct, arc, zigzag, spiral, dash). This is the visual counterpart of + /// — it handles the presentation while the executor handles the stat changes. + /// Created by for enemy turns. + /// + public class PhysicsAttackAction : AbstractBattleAction + { + /// + /// Pre-defined movement trajectory patterns for the source card's approach to the target. + /// Selected randomly at construction to add visual variety to enemy attack animations. + /// + public enum MovementPattern + { + Direct, + Arc, + Zigzag, + Spiral, + Dash + } + + private ActionPhase phase = ActionPhase.Wait; + private float attackRotation; + private bool damageApplied; + private bool stunEffectSpawned; + + private Vector2 targetOriginalPos; + private Vector2 targetOriginalScale; + private Vector2 knockbackDirection; + private float targetReactionIntensity = 0.3f; + + private Vector2 knockbackPos; + private float shake; + private Vector2 squashScale; + + private float waitDuration = 0.2f; + private float prepareDuration = 0.3f; + private float moveForAttackDuration = 0.4f; + private float attackImpactDuration = 0.3f; + private float targetReactionDuration = 0.4f; + private float moveForBackDuration = 0.5f; + private float recoverDuration = 0.3f; + + private MovementPattern movementPattern = MovementPattern.Direct; + private Vector2 movementControlPoint; + + /// + /// Creates an attack animation from to + /// using the stats defined in . The is + /// randomly selected on construction to make enemy attacks visually distinct. + /// acts as a multiplier on all phase durations. + /// + public PhysicsAttackAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1) + : base(source, target, action, speed) + { + movementPattern = (MovementPattern)GD.RandRange(0, 5); + } + + 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; + } + } +} diff --git a/src/Actions/PhysicsDamageActionCard.cs b/src/Actions/PhysicsDamageActionCard.cs new file mode 100644 index 0000000..cdafbf4 --- /dev/null +++ b/src/Actions/PhysicsDamageActionCard.cs @@ -0,0 +1,55 @@ +using Cthangover.Core.Characters; +using Godot; + +namespace Cthangover.CardBattle.Actions +{ + + /// + /// Executor that applies physical damage from an attack card to a target. + /// Calculates damage as card_attack_multiplier × user_attack_stat, reduces it by + /// the target's current defence value (which is consumed in the process), and applies the + /// remainder to the target's health. Invokes OnTakeDamage/OnDealDamage hooks + /// on both characters' so that damage-modifying status effects + /// can intercept and adjust the values before they are applied. + /// Registered in under "physics/attack". + /// + public class PhysicsDamageActionCard : ActionBase + { + + /// + /// Unique identifier registered in as "physics/attack". + /// + public override string ID => "PhysicsDamageActionCard"; + + /// + /// Resolves the attack: deducts action points from via CheckRequiredAndUsePoint, + /// computes raw damage from the card's attack multiplier and the user's attack stat, + /// applies status-effect damage hooks, consumes 's defence shield first, + /// then subtracts remaining damage from health. Returns a with + /// the actual damage and defence-loss deltas for the floating text display. + /// + public override ChangedAttributes Execute(ActionCharacter actionCard, Character user, Character target) + { + if (target == null || user == null || !CheckRequiredAndUsePoint(actionCard, user)) + return new ChangedAttributes { Result = false }; + + var damageDelta = Mathf.RoundToInt(actionCard.GetFloat("Attack", 1f) * user.Attributes.Attack.Value); + 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}}; + } + + } + +} diff --git a/src/Actions/PhysicsDefenceAction.cs b/src/Actions/PhysicsDefenceAction.cs new file mode 100644 index 0000000..f5fe6a6 --- /dev/null +++ b/src/Actions/PhysicsDefenceAction.cs @@ -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 +{ + /// + /// Animated battle action that plays a self-buff or ally-support animation: the source card + /// briefly rotates toward the target, applies a defence boost via , + /// and shows a subtle block/stagger reaction on the target. Used for both ToSelf and + /// ToAlias action types. This is the visual counterpart of . + /// Created by for enemy self-buff/support turns. + /// + public class PhysicsDefenceAction : AbstractBattleAction + { + private ActionPhase phase = ActionPhase.Prepare; + private float defendRotation; + private bool effectApplied; + + private Vector2 targetOriginalScale; + private Color targetOriginalColor; + + private Vector2 blockScale; + private Vector2 blockKnockback; + private Color blockColor; + + private float prepareDuration = 0.3f; + private float attackImpactDuration = 0.4f; + private float targetReactionDuration = 0.3f; + private float moveForBackDuration = 0.4f; + private float recoverDuration = 0.3f; + + /// + /// Creates a defence/support animation. Both and + /// can be the same card (for self-buffs) or different (for ally support). + /// multiplies all phase durations. + /// + public PhysicsDefenceAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1) + : base(source, target, action, speed) { } + + 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; + } + } +} diff --git a/src/Actions/PhysicsDefenceActionCard.cs b/src/Actions/PhysicsDefenceActionCard.cs new file mode 100644 index 0000000..da51abc --- /dev/null +++ b/src/Actions/PhysicsDefenceActionCard.cs @@ -0,0 +1,39 @@ +using Cthangover.Core.Characters; + +namespace Cthangover.CardBattle.Actions +{ + + /// + /// Executor that adds a fixed amount of defence to the target character. + /// Defence acts as a damage-absorbing shield that is consumed before health when the + /// character takes damage (see ). The defence delta + /// comes from the "Defence" property on the action card. + /// Registered in under "physics/defence". + /// + public class PhysicsDefenceActionCard : ActionBase + { + + /// + /// Unique identifier registered in as "physics/defence". + /// + public override string ID => "PhysicsDefenceActionCard"; + + /// + /// Adds a flat defence value to after deducting action points + /// from . The returned carries the + /// defence delta for floating text display. + /// + public override ChangedAttributes Execute(ActionCharacter actionCard, Character user, Character target) + { + if (target == null || user == null || !CheckRequiredAndUsePoint(actionCard, user)) + return new ChangedAttributes { Result = false }; + + var defenceDelta = actionCard.GetInt("Defence"); + target.Attributes.Defence.Value += defenceDelta; + + return new ChangedAttributes { Result = true, Target = { Defence = defenceDelta}}; + } + + } + +} diff --git a/src/Actions/PhysicsStunActionCard.cs b/src/Actions/PhysicsStunActionCard.cs new file mode 100644 index 0000000..e42c67e --- /dev/null +++ b/src/Actions/PhysicsStunActionCard.cs @@ -0,0 +1,39 @@ +using Cthangover.Core.Characters; + +namespace Cthangover.CardBattle.Actions +{ + + /// + /// Executor that applies a stun status effect to the target character. + /// The stun duration (number of turns) is read from the "Turn" property on the action card. + /// During a stunned character's turn, returns true, + /// causing to skip that character's actions entirely. + /// Registered in under "physics/stun". + /// + public class PhysicsStunActionCard : ActionBase + { + + /// + /// Unique identifier registered in as "physics/stun". + /// + public override string ID => "PhysicsStunActionCard"; + + /// + /// Adds a with the configured turn count to 's + /// after deducting action points from . + /// + public override ChangedAttributes Execute(ActionCharacter actionCard, Character user, Character target) + { + if (target == null || user == null || !CheckRequiredAndUsePoint(actionCard, user)) + return new ChangedAttributes { Result = false }; + + if (target.HasPassiveEffect("StunImmune")) + return new ChangedAttributes { Result = true }; + + target.StatusEffectQueue.Add("effect/physics/stun", actionCard.GetInt("Turn", 3)); + + return new ChangedAttributes { Result = true }; + } + } + +} diff --git a/src/CardBattleCore.cs b/src/CardBattleCore.cs new file mode 100644 index 0000000..483a91c --- /dev/null +++ b/src/CardBattleCore.cs @@ -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 +{ + /// + /// Core orchestrator for the card-based turn battle system. + /// Manages the full battle lifecycle: initializing UI panels ( and ), + /// cycling between player and enemy turns, spawning animated actions via , + /// applying status effects each turn, and detecting win/loss conditions when all cards on one side are dead. + /// Implements so it can be registered as a battle type provider in the core battle engine. + /// + public class CardBattleCore : IBattleCore + { + /// + /// Unique identifier for this battle core, used by the battle engine to select the card battle mod. + /// + public string Id => "card_battle"; + + /// + /// Provides instances for the three card action types + /// (physics/attack, physics/defence, physics/stun) used during action resolution. + /// + 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; + + /// + /// Initializes the card battle by constructing the player and enemy instances, + /// the for drag-and-drop input, and the . + /// Enemy characters are deep-copied so defeats are tracked independently per battle instance. + /// The is registered with . + /// + 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("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("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(); + } + + /// + /// Positions the UI panels relative to the viewport, calculates a unified cardScale that + /// fits both player and enemy cards on screen, initializes all character cards, and starts the first player turn. + /// Subscribes to OnCardDead to record defeated enemies and to OnActionExecuted to detect + /// automatic turn completion when all player cards exhaust their action points. + /// + 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(); + } + + /// + /// Ends the player's turn by hiding the end-turn button, setting the battle to IsWait, + /// and yielding two frames for UI to settle before running the enemy AI turn via RunEnemyTurn. + /// If all enemies are already dead, the battle ends immediately with the player as winner. + /// This method is async void so it can await frames without blocking the game loop. + /// + 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); + } + } +} diff --git a/src/ICard.cs b/src/ICard.cs new file mode 100644 index 0000000..173ce8b --- /dev/null +++ b/src/ICard.cs @@ -0,0 +1,24 @@ +using Godot; + +namespace Cthangover.CardBattle +{ + + /// + /// Common interface for all card UI nodes in the card battle system. + /// Exposes the Frame and Image controls so that + /// can perform hit-testing during drag-and-drop targeting + /// without knowing the concrete card type ( or ). + /// + public interface ICard + { + /// + /// The frame used for hit-testing whether a pointer is over this card. + /// + TextureRect Frame { get; } + /// + /// The portrait/image of the card, used for dissolve shader effects on death. + /// + TextureRect Image { get; } + } + +} \ No newline at end of file diff --git a/src/Player/AttackToEnemyStrategy.cs b/src/Player/AttackToEnemyStrategy.cs new file mode 100644 index 0000000..89ae99f --- /dev/null +++ b/src/Player/AttackToEnemyStrategy.cs @@ -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 +{ + /// + /// Strategy for ToEnemy attack actions. Validates that the target is an enemy + /// (non-player card), highlights both cards with a red attack outline, and executes + /// damage via with floating damage/defence numbers. + /// This is the standard strategy for all offensive cards in the player's deck. + /// + public class AttackToEnemyStrategy : ICardActionStrategy + { + /// + /// Returns true if the action type is ToEnemy and the target is an enemy card. + /// + public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target) + { + return action.Type == ActionCharacterType.ToEnemy && target != null && !target.IsPlayer; + } + + /// + /// Shows a red attack overlay on both the dragged action card and the target enemy card. + /// + public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target) + { + actionCard.Attack(); + target.Attack(); + } + + /// + /// Resolves the attack via and spawns floating damage + /// and defence-loss numbers above the card. + /// + 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); + } + } + } +} diff --git a/src/Player/CardActionStrategyFactory.cs b/src/Player/CardActionStrategyFactory.cs new file mode 100644 index 0000000..1224ca6 --- /dev/null +++ b/src/Player/CardActionStrategyFactory.cs @@ -0,0 +1,52 @@ +using Cthangover.CardBattle.UI; +using Cthangover.Core.Characters; + +namespace Cthangover.CardBattle.Player +{ + /// + /// Factory that returns the appropriate implementation + /// for a given . Dispatches based on : + /// ToEnemy, + /// ToAlias, + /// ToSelf. + /// Used by during drag-and-drop to determine valid targeting behavior + /// and by for pre-validation. + /// + public static class CardActionStrategyFactory + { + /// + /// Returns the matching strategy for the action's type, or null if any argument is null + /// or the type is unrecognized. + /// + public static ICardActionStrategy Get(ActionCharacter action, CharacterCardNode source, CharacterCardNode target) + { + if (action == null || source == null || target == null) + return null; + + switch (action.Type) + { + case ActionCharacterType.ToEnemy: + return new AttackToEnemyStrategy(); + case ActionCharacterType.ToAlias: + return new SupportStrategy(); + case ActionCharacterType.ToSelf: + return new SelfStrategy(); + default: + return null; + } + } + + /// + /// Convenience method that gets the strategy via and immediately calls + /// on it. Returns false if no strategy matches + /// or any argument is null. + /// + public static bool CanTarget(ActionCharacter action, CharacterCardNode source, CharacterCardNode target) + { + if (action == null || source == null || target == null) + return false; + + return Get(action, source, target)?.Check(action, source, target) ?? false; + } + } +} diff --git a/src/Player/ICardActionStrategy.cs b/src/Player/ICardActionStrategy.cs new file mode 100644 index 0000000..0662d81 --- /dev/null +++ b/src/Player/ICardActionStrategy.cs @@ -0,0 +1,32 @@ +using Cthangover.CardBattle.UI; +using Cthangover.Core.Characters; + +namespace Cthangover.CardBattle.Player +{ + /// + /// Strategy interface that defines validation, visual highlighting, and execution for + /// a specific type of card action during the player's drag-and-drop targeting flow. + /// The dispatches to the correct strategy based on + /// the action's . calls + /// during drag to validate targets, to show + /// valid/invalid visual feedback, and when the card is dropped on a valid target. + /// + public interface ICardActionStrategy + { + /// + /// Validates whether can be performed by + /// on . Called each frame during drag to determine visual feedback. + /// + bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target); + /// + /// Applies visual highlighting to both the being dragged + /// and the character card (e.g. red outline for attack, green for support). + /// + void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target); + /// + /// Executes the action via and spawns floating damage/defence text. + /// Called on successful card drop by . + /// + void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action); + } +} diff --git a/src/Player/SelfStrategy.cs b/src/Player/SelfStrategy.cs new file mode 100644 index 0000000..1c9bcd7 --- /dev/null +++ b/src/Player/SelfStrategy.cs @@ -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 +{ + /// + /// Strategy for self-targeting actions (ToSelf). 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 . Used for self-buffs like + /// defence boosts. Damage and defence floating text spawn above the source card. + /// + public class SelfStrategy : ICardActionStrategy + { + /// + /// Returns true only when and are the same card. + /// + public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target) + { + return source != null && source == target; + } + + /// + /// Shows a green selection overlay on both the dragged action card and the source card. + /// + public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target) + { + actionCard.Select(); + target.Select(); + } + + /// + /// Executes the self-targeted action and spawns floating defence/damage numbers + /// above the card. Damage numbers are marked with the + /// isHeal flag for green coloring via . + /// + 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); + } + } + } +} diff --git a/src/Player/StunToEnemyStrategy.cs b/src/Player/StunToEnemyStrategy.cs new file mode 100644 index 0000000..60ed090 --- /dev/null +++ b/src/Player/StunToEnemyStrategy.cs @@ -0,0 +1,49 @@ +using Cthangover.CardBattle.UI; +using Cthangover.Core.Battle.Actions; +using Cthangover.Core.Characters; + +namespace Cthangover.CardBattle.Player +{ + /// + /// Strategy for stun actions targeting enemies. Extends the standard ToEnemy check + /// by also verifying that the action has at least 2 turns of stun duration + /// ("Turn" > 1). Highlights with red attack overlays and applies + /// the stun effect via without spawning damage text + /// (stun has no numerical effect to display). + /// Not currently returned by — available for + /// future use when stun cards need distinct drag-and-drop behavior from attack cards. + /// + public class StunToEnemyStrategy : ICardActionStrategy + { + /// + /// Returns true when the action targets an enemy and has a turn count greater than 1. + /// + public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target) + { + return action.Type == ActionCharacterType.ToEnemy && target != null && !target.IsPlayer + && action.GetInt("Turn", 0) > 1; + } + + /// + /// Shows a red attack overlay on both cards, same as . + /// + public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target) + { + actionCard.Attack(); + target.Attack(); + } + + /// + /// Applies the stun via . Does not spawn damage/defence text. + /// + public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action) + { + if (source?.Card == null || target?.Card == null || action == null) + return; + + ActionExecutorHub.Instance.Execute(action, source.Card, target.Card); + source.UpdateInfo(); + target.UpdateInfo(); + } + } +} diff --git a/src/Player/SupportStrategy.cs b/src/Player/SupportStrategy.cs new file mode 100644 index 0000000..af9195e --- /dev/null +++ b/src/Player/SupportStrategy.cs @@ -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 +{ + /// + /// Strategy for ally-support actions (ToAlias). Validates that the target is a friendly + /// player card, highlights with green selection overlays, and applies defence/healing effects + /// via . Allows one player card to buff another during the player's turn. + /// + public class SupportStrategy : ICardActionStrategy + { + /// + /// Returns true when the action type is ToAlias and the target is a player card. + /// + public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target) + { + return action.Type == ActionCharacterType.ToAlias && target != null && target.IsPlayer; + } + + /// + /// Shows a green selection overlay on both the dragged action card and the target ally card. + /// + public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target) + { + actionCard.Select(); + target.Select(); + } + + /// + /// Resolves the support action and spawns floating defence and heal text above + /// the ally. Damage numbers are shown with the isHeal + /// flag for green coloring. + /// + 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); + } + } + } +} diff --git a/src/StatusEffect/IStatusActions.cs b/src/StatusEffect/IStatusActions.cs new file mode 100644 index 0000000..c9586f5 --- /dev/null +++ b/src/StatusEffect/IStatusActions.cs @@ -0,0 +1,21 @@ +namespace Cthangover.CardBattle.StatusEffect +{ + /// + /// Interface exposed by to instances + /// so that status effects can communicate back to the queue during turn lifecycle callbacks. + /// SkipTurn allows an effect (e.g. stun) to mark the character's entire turn as skipped. + /// RemoveStatus allows an effect to self-remove from the queue. + /// + public interface IStatusActions + { + /// + /// Marks the character's current turn as skipped. Called by . + /// + void SkipTurn(); + /// + /// Removes the specified status effect from the queue. Called by effects that want to + /// self-terminate before their turn count expires. + /// + void RemoveStatus(IStatusEffect status); + } +} diff --git a/src/StatusEffect/IStatusEffect.cs b/src/StatusEffect/IStatusEffect.cs new file mode 100644 index 0000000..d011660 --- /dev/null +++ b/src/StatusEffect/IStatusEffect.cs @@ -0,0 +1,26 @@ +using Cthangover.Core.Characters; + +namespace Cthangover.CardBattle.StatusEffect +{ + /// + /// Defines a status effect that is applied to a during battle + /// and processed each turn by . Effects receive callbacks + /// at turn start and turn end, and self-report expiration via + /// when their counter reaches zero. The + /// callback parameter allows effects to influence the character's turn (e.g. skip it). + /// + public interface IStatusEffect + { + /// Integer identifier for the effect type (1 = stun). Used for serialization. + int Type { get; } + /// Remaining turn count before expiry. Decremented by . + int Turns { get; set; } + /// Returns true when ≤ 0, signalling removal from the queue. + bool IsExpired { get; } + + /// Called by before the turn counter is decremented. + void OnTurnStart(Character character, IStatusActions actions); + /// Called by after the character's actions have resolved. + void OnTurnEnd(Character character, IStatusActions actions); + } +} diff --git a/src/StatusEffect/StatusEffectItem.cs b/src/StatusEffect/StatusEffectItem.cs new file mode 100644 index 0000000..b55a3fe --- /dev/null +++ b/src/StatusEffect/StatusEffectItem.cs @@ -0,0 +1,27 @@ +namespace Cthangover.CardBattle.StatusEffect +{ + /// + /// Data-only container for a status effect's serializable fields ( and ). + /// Used in to deep-clone status effect state when a character + /// is copied for battle. Cast to when added back to a queue. + /// + public class StatusEffectItem + { + /// Effect type identifier (matches ). + public int Type { get; set; } + /// Remaining turns before expiry. + public int Turns { get; set; } + + /// + /// Creates a field-by-field copy for deep-cloning during character duplication. + /// + public StatusEffectItem Copy() + { + return new StatusEffectItem + { + Type = Type, + Turns = Turns + }; + } + } +} diff --git a/src/StatusEffect/StatusEffectQueue.cs b/src/StatusEffect/StatusEffectQueue.cs new file mode 100644 index 0000000..be6e6aa --- /dev/null +++ b/src/StatusEffect/StatusEffectQueue.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using Cthangover.Core.Characters; + +namespace Cthangover.CardBattle.StatusEffect +{ + /// + /// Manages the collection of instances attached to a single . + /// Implements so that individual effects can call back into the queue + /// (e.g. to skip the turn). During , each effect's callback is invoked and + /// its turn counter is decremented; expired effects are removed. is checked by + /// to skip stunned enemies. + /// Supports deep copying for character duplication via . + /// + public class StatusEffectQueue : IStatusActions + { + private readonly Character _character; + private readonly List _effects = new(); + + /// + /// Set to true when an effect calls during . + /// Reset to false at the start of each turn before effects are processed. + /// + public bool SkipTurn { get; private set; } + + /// + /// Creates a new queue bound to the given . + /// + public StatusEffectQueue(Character character) + { + _character = character; + } + + /// + /// Creates a deep copy of this queue bound to . + /// effects are field-copied; other effect types are shared by reference. + /// + public StatusEffectQueue Copy(Character newCharacter) + { + var clone = new StatusEffectQueue(newCharacter); + foreach (var effect in _effects) + { + if (effect is StatusEffectItem item) + clone._effects.Add(item.Copy() as IStatusEffect); + else + clone._effects.Add(effect); + } + return clone; + } + + /// + /// Adds a new status effect to the end of the processing list. Effects are processed + /// in reverse order during turn callbacks (last-added first). + /// + public void AddEffect(IStatusEffect effect) + { + _effects.Add(effect); + } + + /// + /// Processes all effects at the start of the character's turn: resets , + /// calls for each effect in reverse order, + /// decrements the turn counter, and removes expired effects. + /// Called by and . + /// + public void OnTurnStart() + { + SkipTurn = false; + for (int i = _effects.Count - 1; i >= 0; i--) + { + var effect = _effects[i]; + effect.OnTurnStart(_character, this); + effect.Turns--; + if (effect.IsExpired) + _effects.RemoveAt(i); + } + } + + /// + /// Processes all effects at the end of the character's turn: calls + /// in reverse order and removes expired effects. + /// + public void OnTurnEnd() + { + for (int i = _effects.Count - 1; i >= 0; i--) + { + var effect = _effects[i]; + effect.OnTurnEnd(_character, this); + if (effect.IsExpired) + _effects.RemoveAt(i); + } + } + + void IStatusActions.SkipTurn() + { + SkipTurn = true; + } + + void IStatusActions.RemoveStatus(IStatusEffect status) + { + _effects.Remove(status); + } + } +} diff --git a/src/StatusEffect/StunStatusEffect.cs b/src/StatusEffect/StunStatusEffect.cs new file mode 100644 index 0000000..d77fcbc --- /dev/null +++ b/src/StatusEffect/StunStatusEffect.cs @@ -0,0 +1,52 @@ +using Cthangover.Core.Characters; + +namespace Cthangover.CardBattle.StatusEffect +{ + /// + /// Status effect that causes a character to skip their entire turn. + /// On , calls which sets + /// to true. + /// checks this flag and skips the character's action selection and resolution entirely. + /// Created by when a stun card is played, + /// and added to the target's via Add("effect/physics/stun", turns). + /// + public class StunStatusEffect : IStatusEffect + { + /// Type identifier for stun (1). + public int Type => 1; + /// Number of turns this stun remains active. + public int Turns { get; set; } + /// Expired when the turn counter reaches zero or below. + public bool IsExpired => Turns <= 0; + + /// + /// Creates a stun effect lasting for turns. + /// + public StunStatusEffect(int turns) + { + Turns = turns; + } + + /// + /// Parameterless constructor for deserialization. Sets to 0 (expired immediately). + /// + public StunStatusEffect() + { + } + + /// + /// Calls to mark the character's turn as skipped. + /// + public void OnTurnStart(Character character, IStatusActions actions) + { + actions.SkipTurn(); + } + + /// + /// Stun has no end-of-turn effect. + /// + public void OnTurnEnd(Character character, IStatusActions actions) + { + } + } +} diff --git a/src/UI/ActionCardNode.cs b/src/UI/ActionCardNode.cs new file mode 100644 index 0000000..75c283a --- /dev/null +++ b/src/UI/ActionCardNode.cs @@ -0,0 +1,137 @@ +using Cthangover.Core.Cards; +using Cthangover.Core.Characters; +using Cthangover.Core.UI; +using Godot; + +namespace Cthangover.CardBattle.UI +{ + /// + /// UI node representing a single action card that the player can drag onto a target. + /// Implements so can hit-test drag targets. + /// Displayed in the 's action panel below the character cards. + /// Supports hover scale-up animation, selection highlighting (, , ), + /// and info updates from the underlying data model. + /// + public partial class ActionCardNode : ModWidget, ICard + { + private TextureRect frame; + private TextureRect image; + private Label nameLabel; + + /// + public TextureRect Frame => frame; + /// + public TextureRect Image => image; + + /// + /// The data model backing this card. Contains the action's + /// name, image, type, and stat modifiers used during execution. + /// + public ActionCharacter Card { get; set; } + + /// + /// Returns this — action cards are nodes themselves, + /// unlike which wraps a separate control. + /// + public Control GetControlNode() => this; + + private Tween hoverTween; + private float baseY; + private bool isHovered; + private Vector2 _baseScale; + + protected override void Construct() + { + image = GetNode("Image"); + frame = GetNode("Frame"); + nameLabel = GetNode