using System; 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; namespace Cthangover.FFBattle.UI { /// /// Panel that arranges enemy character widgets in a grid layout and handles their /// lifecycle. Calculates a uniform scale for all enemy widgets based on the panel /// dimensions and enemy count, centering rows horizontally. Listens to each /// widget's health change and triggers /// when HP reaches zero, then removes the dead widget and re-grids the survivors. /// Raises so can check win conditions. /// public partial class FFEnemyPanel : ModWidget { private const float BASE_W = 180f; private const float BASE_H = 260f; private const float MIN_SCALE = 0.25f; private const float MAX_SCALE = 1.0f; private const float CELL_PADDING = 10f; /// All enemy character widgets currently in the panel, including dead ones until removed. public List Widgets { get; } = new(); /// Raised when a widget's death animation completes and the widget is removed. public event System.Action OnEnemyDead; /// Raised when an enemy widget is clicked (used for target selection). public event System.Action OnEnemyClicked; protected override void Construct() { CthangoverTheme.Apply(this); } /// Number of enemy widget instances currently managed (including dead). public int EnemyCount => Widgets.Count; /// /// Creates instances for each enemy, scales /// them uniformly, wires click and health-change handlers, and calls /// to arrange them. /// public void Init(Character[] enemies, float scale) { foreach (var child in Widgets) child.QueueFree(); Widgets.Clear(); for (int i = 0; i < enemies.Length; i++) { var widget = (FFCharacterWidget)TscnScenes.LoadAndBuild("scenes/ff_character_widget.tscn"); widget.EnsureConstructed(); widget.Scale = new Vector2(scale, scale); widget.Init(enemies[i]); widget.IsPlayer = false; widget.MouseFilter = MouseFilterEnum.Stop; widget.GuiInput += (evt) => { if (evt is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left) OnEnemyClicked?.Invoke(widget); }; AddChild(widget); Widgets.Add(widget); enemies[i].Attributes.Health.OnChange += (value, baseValue) => { if (value <= 0 && !widget.IsDead) HandleDeath(widget); }; } GridLayout(); } /// /// Computes a uniform scale factor so all enemies fit within /// with padding. Uses /// to determine layout, then chooses /// the tighter of width-based and height-based scales, clamped to /// [MIN_SCALE, MAX_SCALE]. /// public static float CalculateScale(int enemyCount, Vector2 panelSize) { var (cols, rows) = GetGridDimensions(enemyCount); float availW = panelSize.X - CELL_PADDING * 2; float availH = panelSize.Y - CELL_PADDING * 2; float scaleByW = (availW - (cols - 1) * CELL_PADDING) / (cols * BASE_W); float scaleByH = (availH - (rows - 1) * CELL_PADDING) / (rows * BASE_H); float scale = Mathf.Min(scaleByW, scaleByH); scale = Mathf.Clamp(scale, MIN_SCALE, MAX_SCALE); return scale; } /// /// Determines grid dimensions for a given count: 1–4 → single row, /// 5–10 → 2 rows, 11–16 → 3 rows, 17+ → 4 rows. /// public static (int cols, int rows) GetGridDimensions(int count) { if (count <= 4) return (count, 1); if (count <= 10) return ((count + 1) / 2, 2); if (count <= 16) return ((count + 2) / 3, 3); return ((count + 3) / 4, 4); } /// Repositions all widgets into the calculated grid, with optional 0.3s cubic tween animation. public void GridLayout(bool animate = true) { var (cols, rows) = GetGridDimensions(Widgets.Count); for (int i = 0; i < Widgets.Count; i++) { var widget = Widgets[i]; if (widget == null) continue; int row = i / cols; int col = i % cols; var totalColsInRow = row < rows - 1 ? cols : (Widgets.Count - row * cols); if (totalColsInRow <= 0) totalColsInRow = 1; var effW = widget.Size.X * widget.Scale.X; var effH = widget.Size.Y * widget.Scale.Y; float startX = (Size.X - (totalColsInRow * effW + (totalColsInRow - 1) * CELL_PADDING)) / 2f; float startY = CELL_PADDING; var targetPos = new Vector2( startX + col * (effW + CELL_PADDING), startY + row * (effH + CELL_PADDING) ); if (animate) { var tween = CreateTween(); tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic); tween.TweenProperty(widget, "position", targetPos, 0.3f); } else { widget.Position = targetPos; } } } private void HandleDeath(FFCharacterWidget widget) { if (widget.IsDead) return; widget.PlayDeathAnimation(() => { RemoveWidget(widget); GridLayout(true); OnEnemyDead?.Invoke(widget); }); } /// Removes a widget from the panel, frees it, and re-grids the remaining widgets. public void RemoveWidget(FFCharacterWidget widget) { Widgets.Remove(widget); widget.QueueFree(); GridLayout(); } /// Returns true if any widget in the panel is not marked dead. public bool HasAlive() { foreach (var w in Widgets) if (!w.IsDead) return true; return false; } /// Frees all widget instances and clears the widget list. Called on battle cleanup. public void ClearAll() { foreach (var widget in Widgets.ToList()) { if (GodotObject.IsInstanceValid(widget)) widget.QueueFree(); } Widgets.Clear(); GridLayout(false); } } }