This commit is contained in:
cthangover committed 2026-09-10 10:58:00 +03:00
commit 27f38c6c1f
50 files changed
+3403

No files matched your search

+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;
}
}
}