using System;
using System.Linq;
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.FFBattle.UI
{
///
/// Battle card of a single combatant, shared by the party and enemy panels.
/// The node tree lives in scenes/ff_character_widget.tscn ; this class
/// only fetches the nodes, dresses them with styles and
/// refreshes their content. Visually the card follows the unified style guide:
/// a team accent line on top, a sprite well, status badges in the upper right
/// and an info block holding name, HP numbers, HP bar, action point pips
/// (party) or attack caption (enemies) and the defence shield chip.
///
/// The public surface is animation-facing, not layout-facing:
/// paints the selection frame, and
/// give impact feedback,
/// dissolves the card. Battle code never touches labels directly — it mutates
/// the and calls , which
/// re-reads health, defence, points and stun in one place. Panels must assign
/// before , because the team colour is
/// applied during initialisation.
///
public partial class FFCharacterWidget : ModWidget
{
/// Diameter of one action point pip.
private const int PipSize = 9;
private TextureRect _art;
private ColorRect _teamLine;
private Panel _selection;
private Panel _teamDot;
private Label _nameLabel;
private Label _hpNumLabel;
private ProgressBar _hpBar;
private HBoxContainer _pipsBox;
private Label _atkLabel;
private PanelContainer _shieldChip;
private Label _shieldValue;
private VBoxContainer _statusBox;
private PanelContainer _stunBadge;
private TextureRect _stunIcon;
private Label _stunLabel;
private Tween _animTween;
private static Shader _dissolveShader;
/// Whether this widget belongs to the player party (affects highlight/targeting logic).
public bool IsPlayer { get; set; }
/// Whether the character is dead; set by .
public bool IsDead { get; set; }
/// The character data model backing this widget: stats, actions, status effects.
public Character Card { get; set; }
/// Original scale captured at initialisation; used to restore after death animation.
public Vector2 BaseScale { get; private set; }
/// Exposes this widget as a for layout calculations.
public Control ControlNode => this;
protected override void Construct()
{
CthangoverTheme.Apply(this);
_teamLine = GetNode("TeamLine");
_art = GetNode("Art");
_nameLabel = GetNode("Info/Column/NameRow/NameLabel");
_teamDot = GetNode("Info/Column/NameRow/TeamDot");
_hpNumLabel = GetNode("Info/Column/NameRow/HpNumLabel");
_hpBar = GetNode("Info/Column/HpBar");
_pipsBox = GetNode("Info/Column/Foot/PipsBox");
_atkLabel = GetNode("Info/Column/Foot/AtkLabel");
_shieldChip = GetNode("Info/Column/Foot/ShieldChip");
_shieldValue = GetNode("Info/Column/Foot/ShieldChip/ShieldRow/ShieldValue");
_statusBox = GetNode("StatusBox");
_stunBadge = GetNode("StatusBox/StunBadge");
_stunIcon = GetNode("StatusBox/StunBadge/StunRow/StunIcon");
_stunLabel = GetNode("StatusBox/StunBadge/StunRow/StunLabel");
_selection = GetNode("Selection");
ApplyStyles();
}
///
/// Binds the widget to a : sprite, localised name,
/// team colour and per-side info layout. The team colour is derived from
/// , which is why callers set it before calling here.
///
public void Init(Character character)
{
Card = character;
BaseScale = Scale;
IsDead = false;
if (_art != null && character?.Image != null)
_art.Texture = character.Image;
if (_nameLabel != null)
_nameLabel.Text = TranslationServer.Translate(character?.Name ?? string.Empty).ToString();
var teamColor = IsPlayer ? UiTokens.Info : UiTokens.Danger;
if (_teamLine != null)
_teamLine.Color = UiTokens.Alpha(teamColor, 0.55f);
if (_teamDot != null)
_teamDot.AddThemeStyleboxOverride("panel", UiKit.Frame(4, UiTokens.Alpha(teamColor, 0.9f)));
if (_pipsBox != null)
_pipsBox.Visible = IsPlayer;
if (_atkLabel != null)
_atkLabel.Visible = !IsPlayer;
UpdateInfo();
}
///
/// Single refresh entry point for all mutable character state: HP bar and
/// numbers with threshold colours, action point pips, shield chip, attack
/// caption and the stun badge with its remaining turns. Called by battle
/// code and by animations after each resolved action.
///
public void UpdateInfo()
{
if (Card?.Attributes == null)
return;
var health = Card.Attributes.Health;
var percent = Mathf.Clamp(health.Percent, 0f, 1f);
var fillColor = percent > 0.5f ? UiTokens.HpOk : percent > 0.25f ? UiTokens.Warning : UiTokens.HpLow;
var textColor = percent > 0.5f ? UiTokens.HpText : percent > 0.25f ? UiTokens.Warning : UiTokens.DangerText;
if (_hpBar != null)
{
_hpBar.Value = percent * 100f;
_hpBar.AddThemeStyleboxOverride("background", UiKit.Frame(UiTokens.RadiusSm, UiTokens.LineDisabled));
_hpBar.AddThemeStyleboxOverride("fill", UiKit.Frame(UiTokens.RadiusSm, fillColor));
}
if (_hpNumLabel != null)
{
_hpNumLabel.Text = $"{health.Value}/{health.BaseValue}";
_hpNumLabel.AddThemeColorOverride("font_color", textColor);
}
if (_pipsBox != null && IsPlayer)
RefreshPips();
if (_atkLabel != null && !IsPlayer)
_atkLabel.Text = string.Format("{0} {1}", Translate("ui/character_panel/stat_atk"), Card.Attributes.Attack.Value);
if (_shieldChip != null)
{
var defence = Card.Attributes.Defence?.Value ?? 0;
_shieldChip.Visible = defence > 0;
if (_shieldValue != null)
_shieldValue.Text = defence.ToString();
}
RefreshStun();
}
/// Shows a coloured selection frame over the card with a 0.15s fade.
public void Highlight(Color color)
{
if (_selection == null)
return;
_animTween?.Kill();
_animTween = CreateTween();
_animTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad);
_animTween.TweenProperty(_selection, "modulate", color, 0.15f);
}
/// Fades the selection frame out via a 0.15s tween.
public void ClearHighlight()
{
if (_selection == null)
return;
_animTween?.Kill();
_animTween = CreateTween();
_animTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad);
_animTween.TweenProperty(_selection, "modulate", UiTokens.Alpha(UiTokens.White, 0f), 0.15f);
}
/// Rapidly oscillates the widget with random offsets to simulate impact feedback.
public void Shake(float intensity, float duration)
{
var originalPos = Position;
_animTween?.Kill();
_animTween = CreateTween();
for (int i = 0; i < 6; i++)
{
var offset = new Vector2(
(float)GD.RandRange(-intensity, intensity),
(float)GD.RandRange(-intensity, intensity)
);
_animTween.TweenProperty(this, "position", originalPos + offset, duration / 12f);
_animTween.TweenProperty(this, "position", originalPos, duration / 12f);
}
}
/// Briefly tints the whole card with a colour pulse (30% rise, 70% fall).
public void Flash(Color color, float duration)
{
var originalModulate = Modulate;
_animTween?.Kill();
_animTween = CreateTween();
_animTween.TweenProperty(this, "modulate", color, duration * 0.3f);
_animTween.TweenProperty(this, "modulate", originalModulate, duration * 0.7f);
}
///
/// Plays a dissolve-and-shrink death sequence. The sprite gets the
/// scene_transition shader with a growing dissolve progress while the
/// card fades and squashes; the info block fades with it. Invokes
/// when finished so the parent panel can
/// remove the widget and re-grid the survivors.
///
public void PlayDeathAnimation(Action onComplete)
{
if (IsDead)
{
onComplete?.Invoke();
return;
}
IsDead = true;
MouseFilter = MouseFilterEnum.Ignore;
ClearHighlight();
_animTween?.Kill();
var duration = 0.7f;
if (_dissolveShader == null)
_dissolveShader = Shaders.Resolve("scene_transition");
if (_dissolveShader != null && _art?.Texture != null)
{
var material = new ShaderMaterial { Shader = _dissolveShader };
material.SetShaderParameter("noise_scale", 5f);
material.SetShaderParameter("smoothness", 0.25f);
material.SetShaderParameter("distortion", 0.092f);
material.SetShaderParameter("glow_intensity", 0f);
material.SetShaderParameter("hue_shift", 0f);
material.SetShaderParameter("invert_direction", false);
material.SetShaderParameter("progress", 0f);
_art.Material = material;
CreateTween().TweenMethod(
Callable.From(t => material.SetShaderParameter("progress", t)),
0f, 1f, duration);
}
var tween = CreateTween();
tween.SetParallel(true);
tween.TweenProperty(this, "modulate:a", 0f, duration * 0.5f);
tween.TweenProperty(this, "scale", BaseScale * new Vector2(0.1f, 1.2f), duration);
tween.TweenProperty(_hpBar, "modulate:a", 0f, duration);
tween.TweenProperty(_hpNumLabel, "modulate:a", 0f, duration);
tween.TweenProperty(_nameLabel, "modulate:a", 0f, duration);
tween.TweenProperty(_pipsBox, "modulate:a", 0f, duration);
tween.TweenProperty(_atkLabel, "modulate:a", 0f, duration);
tween.TweenProperty(_shieldChip, "modulate:a", 0f, duration);
tween.TweenProperty(_statusBox, "modulate:a", 0f, duration);
tween.Chain().TweenProperty(this, "scale", Vector2.Zero, 0.15f);
tween.Finished += () =>
{
onComplete?.Invoke();
};
}
private void ApplyStyles()
{
_teamLine.Color = UiTokens.Alpha(UiTokens.Danger, 0.55f);
var infoStyle = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Alpha(UiTokens.Surface2, 0.93f), UiTokens.Line);
infoStyle.CornerRadiusTopLeft = 0;
infoStyle.CornerRadiusTopRight = 0;
infoStyle.ContentMarginLeft = 8;
infoStyle.ContentMarginRight = 8;
infoStyle.ContentMarginTop = 6;
infoStyle.ContentMarginBottom = 8;
GetNode("Info").AddThemeStyleboxOverride("panel", infoStyle);
_nameLabel.AddThemeColorOverride("font_color", UiTokens.InkBright);
_hpNumLabel.AddThemeColorOverride("font_color", UiTokens.HpText);
_atkLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
_teamDot.AddThemeStyleboxOverride("panel", UiKit.Frame(4, UiTokens.Danger));
var hpBackground = UiKit.Frame(UiTokens.RadiusSm, UiTokens.LineDisabled);
var hpFill = UiKit.Frame(UiTokens.RadiusSm, UiTokens.HpOk);
_hpBar.AddThemeStyleboxOverride("background", hpBackground);
_hpBar.AddThemeStyleboxOverride("fill", hpFill);
var shieldStyle = UiKit.Frame(UiTokens.RadiusXs, UiTokens.StatBg, UiTokens.StatLine);
shieldStyle.ContentMarginLeft = 7;
shieldStyle.ContentMarginRight = 7;
shieldStyle.ContentMarginTop = 2;
shieldStyle.ContentMarginBottom = 2;
_shieldChip.AddThemeStyleboxOverride("panel", shieldStyle);
GetNode("Info/Column/Foot/ShieldChip/ShieldRow/ShieldKey").AddThemeColorOverride("font_color", UiTokens.Muted);
_shieldValue.AddThemeColorOverride("font_color", UiTokens.Info);
var stunStyle = UiKit.Frame(UiTokens.RadiusXs, UiTokens.CooldownSoft, UiTokens.CooldownLine);
stunStyle.ContentMarginLeft = 5;
stunStyle.ContentMarginRight = 8;
stunStyle.ContentMarginTop = 3;
stunStyle.ContentMarginBottom = 3;
_stunBadge.AddThemeStyleboxOverride("panel", stunStyle);
_stunLabel.AddThemeColorOverride("font_color", UiTokens.Cooldown);
var selectionStyle = UiKit.Frame(UiTokens.RadiusMd, new Color(1f, 1f, 1f, 0.07f), Colors.White, 2);
_selection.AddThemeStyleboxOverride("panel", selectionStyle);
_selection.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
}
private void RefreshPips()
{
foreach (var child in _pipsBox.GetChildren())
{
_pipsBox.RemoveChild(child);
child.QueueFree();
}
var basePoints = Mathf.Max(0, Card.Attributes.Point.BaseValue);
var points = Card.Attributes.Point.Value;
for (var i = 0; i < basePoints; i++)
{
var on = i < points;
var pip = new Panel
{
MouseFilter = MouseFilterEnum.Ignore,
CustomMinimumSize = new Vector2(PipSize, PipSize),
};
pip.AddThemeStyleboxOverride("panel", UiKit.Frame(
PipSize / 2,
on ? UiTokens.AccentBright : UiTokens.LineDisabled,
on ? UiTokens.Accent : UiTokens.LineFaint));
_pipsBox.AddChild(pip);
}
}
private void RefreshStun()
{
if (_stunBadge == null)
return;
var stun = StatusEffectService.Of(Card)?.ActiveStatusEffects?
.FirstOrDefault(effect => effect.EffectType == StatusEffectType.Stun);
if (stun == null)
{
_stunBadge.Visible = false;
return;
}
_stunBadge.Visible = true;
if (_stunIcon != null)
_stunIcon.Texture = stun.Icon;
if (_stunLabel != null)
_stunLabel.Text = string.Format(Translate("ff_battle/status_stun"), stun.RemainingTurns);
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
}
}