Files
mod_ff_battle/src/UI/FFEnemyPanel.cs
T
2026-09-26 08:05:13 +03:00

201 lines
7.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>
/// 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 <see cref="FFCharacterWidget.PlayDeathAnimation"/>
/// when HP reaches zero, then removes the dead widget and re-grids the survivors.
/// Raises <see cref="OnEnemyDead"/> so <see cref="FFBattleCore"/> can check win conditions.
/// </summary>
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;
/// <summary>All enemy character widgets currently in the panel, including dead ones until removed.</summary>
public List<FFCharacterWidget> Widgets { get; } = new();
/// <summary>Raised when a widget's death animation completes and the widget is removed.</summary>
public event System.Action<FFCharacterWidget> OnEnemyDead;
/// <summary>Raised when an enemy widget is clicked (used for target selection).</summary>
public event System.Action<FFCharacterWidget> OnEnemyClicked;
protected override void Construct()
{
CthangoverTheme.Apply(this);
}
/// <summary>Number of enemy widget instances currently managed (including dead).</summary>
public int EnemyCount => Widgets.Count;
/// <summary>
/// Creates <see cref="FFCharacterWidget"/> instances for each enemy, scales
/// them uniformly, wires click and health-change handlers, and calls
/// <see cref="GridLayout"/> to arrange them.
/// </summary>
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();
}
/// <summary>
/// Computes a uniform scale factor so all enemies fit within
/// <paramref name="panelSize"/> with padding. Uses
/// <see cref="GetGridDimensions"/> to determine layout, then chooses
/// the tighter of width-based and height-based scales, clamped to
/// [<c>MIN_SCALE</c>, <c>MAX_SCALE</c>].
/// </summary>
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;
}
/// <summary>
/// Determines grid dimensions for a given count: 1–4 → single row,
/// 5–10 → 2 rows, 11–16 → 3 rows, 17+ → 4 rows.
/// </summary>
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);
}
/// <summary>Repositions all widgets into the calculated grid, with optional 0.3s cubic tween animation.</summary>
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);
});
}
/// <summary>Removes a widget from the panel, frees it, and re-grids the remaining widgets.</summary>
public void RemoveWidget(FFCharacterWidget widget)
{
Widgets.Remove(widget);
widget.QueueFree();
GridLayout();
}
/// <summary>Returns <c>true</c> if any widget in the panel is not marked dead.</summary>
public bool HasAlive()
{
foreach (var w in Widgets)
if (!w.IsDead)
return true;
return false;
}
/// <summary>Frees all widget instances and clears the widget list. Called on battle cleanup.</summary>
public void ClearAll()
{
foreach (var widget in Widgets.ToList())
{
if (GodotObject.IsInstanceValid(widget))
widget.QueueFree();
}
Widgets.Clear();
GridLayout(false);
}
}
}