using System.Collections.Generic; using System.Linq; using Cthangover.Core.Characters; using Cthangover.Core.Scenes; using Cthangover.Core.UI; using Cthangover.Core.UI.Theming; using Godot; using Cthangover.Mods.Interface.Base.Lists; using Cthangover.Mods.Interface.View; namespace Cthangover.CardBattle.UI { /// /// Container panel that manages a list of instances for one side /// of the battle (player or enemy). Handles card creation, multi-row layout when enemy count exceeds /// MAX_CARDS_ROW (10 per row), smooth animated repositioning, health-triggered death, /// and the dissolve sequence. /// Two instances are created by : a left-aligned player panel /// and a right-aligned enemy panel. /// public partial class BattleCardPanel : ModWidget { private const int MAX_CARDS_ROW = 10; private const float BASE_CARD_HEIGHT = 280f; [Export] private Control content; /// /// Controls card alignment within the panel. "Left" for the player side, /// "Right" for the enemy side. Affects . /// [Export] public string AlignType { get; set; } = "Left"; [Export] private float cellOffset = 10f; /// /// All character cards currently managed by this panel. Populated by /// and modified by (removal) and (full clear). /// public List CardList { get; } = new(); private Dictionary activeTweens = new(); /// /// When true, cards are laid out in multiple rows (up to 3) instead of a single row. /// Set by when enemy count exceeds 6. /// public bool UseRows { get; set; } /// /// Fired when a card's death animation completes. subscribes /// to record the defeated character in . /// public event System.Action OnCardDead; /// Builds the card grid container rows for one battle side. protected override void Construct() { CthangoverTheme.Apply(this); content ??= GetNodeOrNull("Content"); } /// /// Clears the current card list and creates new instances /// for each entry. Sets player/enemy team colors and subscribes /// the health-change handler. Returns the created card list. /// Called by with the full character roster for each side. /// public List Init(IEnumerable cards, bool isPlayer, float cardScale) { CardList.Clear(); activeTweens.Clear(); int idx = 0; foreach (var card in cards) { CardList.Add(Create(card, isPlayer, cardScale)); idx++; } Redraw(animate: false); return CardList; } /// /// Instantiates a single , adds it to the content panel, /// sets its scale, initializes it with card data, applies team colors, and hooks /// the health-change event. Called by for each character. /// public CharacterCardNode Create(Character card, bool isPlayer, float cardScale) { var behaviour = (CharacterCardNode)TscnScenes.LoadAndBuild("scenes/character_card.tscn"); behaviour.EnsureConstructed(); if (content != null) content.AddChild(behaviour); else AddChild(behaviour); behaviour.Scale = new Vector2(cardScale, cardScale); behaviour.Init(card); behaviour.IsPlayer = isPlayer; behaviour.SetTeamColors(); if (card != null) card.Attributes.Health.OnChange += CheckHealthAttribute; return behaviour; } private void CheckHealthAttribute(float value, float basevalue) { if (value > 0) return; foreach (var cardItem in CardList.ToList()) { if (cardItem.Card.Attributes.Health.Value <= 0) Dead(cardItem); } } /// /// Frees all card nodes from the scene tree, clears the card list, and triggers a redraw. /// Called by when the battle scene is cleaned up. /// public void ClearAll() { foreach (var card in CardList.ToList()) { if (GodotObject.IsInstanceValid(card)) card.QueueFree(); } CardList.Clear(); activeTweens.Clear(); Redraw(animate: false); } /// /// Recalculates and applies positions for all cards in the list. When /// is true, cards tween to their new positions with a cubic ease-out; otherwise they /// snap instantly. Called after cards are added, removed, or when a selection/unselection /// changes the sort order. /// public void Redraw(bool animate = true) { for (int i = 0; i < CardList.Count; i++) { var card = CardList[i]; if (card == null) continue; var (row, col) = GetRowCol(i); SetCardPosition(card, i, row, col, animate); } } private (int row, int col) GetRowCol(int index) { if (!UseRows || index < MAX_CARDS_ROW) return (0, index); if (index < MAX_CARDS_ROW * 2) return (1, index - MAX_CARDS_ROW); return (2, index - MAX_CARDS_ROW * 2); } private int CalcBaseZIndex(int row, int col) { return row * 20 + col; } private Vector2 CalcTargetPosition(CharacterCardNode card, int row, int col) { var panelSize = Size; float effectiveWidth = card.Size.X * card.Scale.X; float effectiveHeight = card.Size.Y * card.Scale.Y; float y = row * (effectiveHeight + cellOffset); if (AlignType == "Left") return new Vector2((cellOffset + effectiveWidth) * col, y); else return new Vector2(panelSize.X - (cellOffset + effectiveWidth) * (col + 1), y); } /// /// Calculates how many rows are needed to display enemies /// when is enabled. Returns 1, 2, or 3. /// Called by to determine maximum card scale. /// public int GetCurrentRowCount(int enemyCount) { if (!UseRows) return 1; if (enemyCount <= MAX_CARDS_ROW) return 1; if (enemyCount <= MAX_CARDS_ROW * 2) return 2; return 3; } /// /// Returns the maximum cards per row constant (MAX_CARDS_ROW = 10). /// Used by for scale calculations. /// public int GetMaxCardsPerRow() { return MAX_CARDS_ROW; } private void SetCardPosition(CharacterCardNode card, int index, int row, int col, bool animate) { if (card == null) return; var targetPos = CalcTargetPosition(card, row, col); if (activeTweens.TryGetValue(card, out var prevTween) && prevTween != null && prevTween.IsValid()) prevTween.Kill(); if (animate) { var tween = CreateTween(); tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic); tween.TweenProperty(card, "position", targetPos, 0.3f); activeTweens[card] = tween; } else { card.Position = targetPos; activeTweens.Remove(card); } var baseZ = CalcBaseZIndex(row, col); card.SetBaseZIndex(baseZ); if (content != null) content.MoveChild(card, index); else MoveChild(card, index); card.UpdateInfo(); } /// /// Marks the card as dead and starts the dissolve sequence. /// Once the animation completes, the card is removed from , /// is fired, and the remaining cards are redrawn. /// Triggered by the health-change handler when a character's health reaches zero. /// public void Dead(CharacterCardNode characterCardNode) { if (characterCardNode == null || characterCardNode.IsDead) return; characterCardNode.IsDead = true; var animator = new Cthangover.CardBattle.UI.CardDeathAnimation(); characterCardNode.AddChild(animator); animator.StartAnimation(characterCardNode, () => { OnCardDead?.Invoke(characterCardNode); CardList.Remove(characterCardNode); activeTweens.Remove(characterCardNode); Redraw(); }); } } }