new battle gui
This commit is contained in:
1 parent
f65663fd91
commit
b243a318f4
35 files changed
+2423
-958
No files matched your search
+160
-84
@@ -1,5 +1,5 @@
|
||||
using Cthangover.Core.Cards;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.UI.CharacterPanel;
|
||||
using Cthangover.Core.UI;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
@@ -7,134 +7,210 @@ 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.
|
||||
/// Draggable action card from the interface mockup (mockups/card_battle):
|
||||
/// the horizontal kit card with a type-coloured accent strip, action icon,
|
||||
/// name and ID, badges (targeting type, action-point cost, stun turns) and
|
||||
/// a property-chip row. The node tree lives in <c>scenes/action_card.tscn</c>;
|
||||
/// this class fetches the nodes, dresses them and fills the dynamic badge /
|
||||
/// chip rows using the shared <see cref="ActionCharacterUi"/> presentation
|
||||
/// rules, so the battle UI can never disagree with the character panel about
|
||||
/// an action's colour, caption or cost.
|
||||
/// <para/>
|
||||
/// Implements <see cref="ICard"/> so the input controller can hit-test drag
|
||||
/// targets; the hover style, disabled state and the preview builder
|
||||
/// (<see cref="DescribeFrom"/>) are the only UI surface the controller and
|
||||
/// the actions panel need.
|
||||
/// </summary>
|
||||
public partial class ActionCardNode : ModWidget, ICard
|
||||
{
|
||||
private TextureRect frame;
|
||||
private TextureRect image;
|
||||
private Label nameLabel;
|
||||
private PanelContainer _panel;
|
||||
private ColorRect _accent;
|
||||
private TextureRect _icon;
|
||||
private Label _nameLabel;
|
||||
private Label _idLabel;
|
||||
private HBoxContainer _badges;
|
||||
private HBoxContainer _props;
|
||||
|
||||
/// <inheritdoc />
|
||||
public TextureRect Frame => frame;
|
||||
/// <inheritdoc />
|
||||
public TextureRect Image => image;
|
||||
private StyleBoxFlat _idleStyle;
|
||||
private StyleBoxFlat _hoverStyle;
|
||||
private bool _disabled;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="ActionCharacter"/> data model backing this card. Contains the action's
|
||||
/// name, image, type, and stat modifiers used during execution.
|
||||
/// </summary>
|
||||
/// <summary>The action data model backing this card.</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;
|
||||
/// <summary>True while the character cannot pay the card's action-point cost.</summary>
|
||||
public bool IsDisabled => _disabled;
|
||||
|
||||
private Tween hoverTween;
|
||||
private float baseY;
|
||||
private bool isHovered;
|
||||
private Vector2 _baseScale;
|
||||
/// <summary>Raised when the cursor enters the card; the actions panel uses it to print the preview.</summary>
|
||||
public event System.Action<ActionCardNode> Hovered;
|
||||
|
||||
/// <summary>Raised when the cursor leaves the card; the preview returns to its default text.</summary>
|
||||
public event System.Action<ActionCardNode> Unhovered;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Control GetControlNode() => this;
|
||||
|
||||
protected override void Construct()
|
||||
{
|
||||
CthangoverTheme.Apply(this);
|
||||
|
||||
image = GetNode<TextureRect>("Image");
|
||||
frame = GetNode<TextureRect>("Frame");
|
||||
nameLabel = GetNode<Label>("NameLabel");
|
||||
_panel = GetNode<PanelContainer>("Panel");
|
||||
_accent = GetNode<ColorRect>("Panel/Row/Accent");
|
||||
_icon = GetNode<TextureRect>("Panel/Row/IconWrap/Icon");
|
||||
_nameLabel = GetNode<Label>("Panel/Row/Body/Column/NameRow/NameLabel");
|
||||
_idLabel = GetNode<Label>("Panel/Row/Body/Column/NameRow/IdLabel");
|
||||
_badges = GetNode<HBoxContainer>("Panel/Row/Body/Column/Badges");
|
||||
_props = GetNode<HBoxContainer>("Panel/Row/Body/Column/Props");
|
||||
|
||||
Size = CustomMinimumSize;
|
||||
_idleStyle = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface1, UiTokens.LineCard);
|
||||
_hoverStyle = UiKit.Frame(UiTokens.RadiusMd, UiTokens.SurfaceHover, UiTokens.LineStrong);
|
||||
_panel.AddThemeStyleboxOverride("panel", _idleStyle);
|
||||
|
||||
_nameLabel.AddThemeColorOverride("font_color", UiTokens.InkBright);
|
||||
_idLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||
|
||||
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.
|
||||
/// Fills the card from the action definition: icon, name, ID, accent
|
||||
/// strip, badges and property chips. Called by the input controller when
|
||||
/// the action deck of a character is opened.
|
||||
/// </summary>
|
||||
public void Init(ActionCharacter card)
|
||||
{
|
||||
Card = card;
|
||||
|
||||
if (_icon != null && card?.Image != null)
|
||||
_icon.Texture = card.Image;
|
||||
|
||||
_accent.Color = ActionCharacterUi.AccentColor(card.Type);
|
||||
_nameLabel.Text = ActionCharacterUi.Translate(card.Name);
|
||||
_idLabel.Text = card.ID;
|
||||
TooltipText = ActionCharacterUi.BuildTooltip(card);
|
||||
|
||||
BuildBadges();
|
||||
BuildProps();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes the texts of an already initialised card.</summary>
|
||||
public void UpdateInfo()
|
||||
{
|
||||
if (Card == null)
|
||||
return;
|
||||
_nameLabel.Text = ActionCharacterUi.Translate(Card.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dims the card when the selected character cannot pay its cost; hover
|
||||
/// feedback is suppressed so the disabled state reads as inert.
|
||||
/// </summary>
|
||||
public void SetDisabled(bool disabled)
|
||||
{
|
||||
_disabled = disabled;
|
||||
Modulate = UiTokens.Alpha(UiTokens.White, disabled ? 0.45f : 1f);
|
||||
_panel.AddThemeStyleboxOverride("panel", _idleStyle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the hint printed in the actions panel when the card is
|
||||
/// hovered: the expected effect computed from the source character's
|
||||
/// stats ("Урон 5 (АТК 5 × 1.0) · расход 1 ОД").
|
||||
/// </summary>
|
||||
public string DescribeFrom(Character source)
|
||||
{
|
||||
if (Card == null || source?.Attributes == null)
|
||||
return string.Empty;
|
||||
|
||||
var cost = ActionCharacterUi.CostOf(Card);
|
||||
if (Card.Type == ActionCharacterType.ToEnemy && Card.GetInt("Turn", 0) > 1)
|
||||
return string.Format(Translate("battle/preview_stun"), Card.GetInt("Turn", 0), cost);
|
||||
|
||||
if (Card.Type == ActionCharacterType.ToEnemy)
|
||||
{
|
||||
var multiplier = Card.GetFloat("Attack", 1f);
|
||||
var damage = Mathf.RoundToInt(multiplier * source.Attributes.Attack.Value);
|
||||
return string.Format(
|
||||
Translate("battle/preview_damage"),
|
||||
damage,
|
||||
source.Attributes.Attack.Value,
|
||||
multiplier.ToString("0.0"),
|
||||
cost);
|
||||
}
|
||||
|
||||
return string.Format(Translate("battle/preview_defence"), Card.GetInt("Defence"), cost);
|
||||
}
|
||||
|
||||
/// <summary>Placeholder for the source-card highlight; the drag ghost covers it.</summary>
|
||||
public void Select()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder for attack-mode highlight. Currently unused — target highlighting
|
||||
/// is applied by <see cref="ICardActionStrategy.HighlightTarget"/> instead.
|
||||
/// </summary>
|
||||
/// <summary>Placeholder: target highlighting is applied by the action strategy.</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>
|
||||
/// <summary>Placeholder: validity is painted 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>
|
||||
/// <summary>Restores the neutral Z-index after a cancelled drag.</summary>
|
||||
public void Unselect()
|
||||
{
|
||||
ZIndex = 0;
|
||||
}
|
||||
|
||||
private void BuildBadges()
|
||||
{
|
||||
foreach (var child in _badges.GetChildren())
|
||||
{
|
||||
_badges.RemoveChild(child);
|
||||
child.QueueFree();
|
||||
}
|
||||
|
||||
var accent = ActionCharacterUi.AccentColor(Card.Type);
|
||||
_badges.AddChild(ActionCharacterUi.MakeBadge(
|
||||
ActionCharacterUi.Translate(ActionCharacterUi.TypeLabelKey(Card.Type)).ToUpperInvariant(), accent));
|
||||
|
||||
var cost = ActionCharacterUi.CostText(Card);
|
||||
if (!string.IsNullOrEmpty(cost))
|
||||
_badges.AddChild(ActionCharacterUi.MakeBadge(cost, UiTokens.Info));
|
||||
|
||||
var turns = Card.GetInt("Turn", 0);
|
||||
if (turns > 1 && Card.Type == ActionCharacterType.ToEnemy)
|
||||
_badges.AddChild(ActionCharacterUi.MakeBadge(
|
||||
string.Format(Translate("battle/turns_badge"), turns), UiTokens.Cooldown));
|
||||
}
|
||||
|
||||
private void BuildProps()
|
||||
{
|
||||
foreach (var child in _props.GetChildren())
|
||||
{
|
||||
_props.RemoveChild(child);
|
||||
child.QueueFree();
|
||||
}
|
||||
|
||||
foreach (var pair in ActionCharacterUi.SortedProperties(Card))
|
||||
_props.AddChild(ActionCharacterUi.MakePropertyChip(pair.Key, pair.Value));
|
||||
}
|
||||
|
||||
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);
|
||||
if (!_disabled)
|
||||
_panel.AddThemeStyleboxOverride("panel", _hoverStyle);
|
||||
Hovered?.Invoke(this);
|
||||
}
|
||||
|
||||
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);
|
||||
_panel.AddThemeStyleboxOverride("panel", _idleStyle);
|
||||
Unhovered?.Invoke(this);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
private static string Translate(string key)
|
||||
=> TranslationServer.Translate(key).ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using Cthangover.Core.UI;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.CardBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// "Действия" window from the interface mockup (mockups/card_battle): the
|
||||
/// lower-right panel that opens when a party card is clicked. Layout lives
|
||||
/// in <c>scenes/battle_actions_panel.tscn</c>: a header with the character
|
||||
/// name and action-point pips, a two-column grid that receives the
|
||||
/// <see cref="ActionCardNode"/> instances, a description well and a footer
|
||||
/// hint. The class owns only content: it repaints pips, swaps the empty
|
||||
/// state and prints the hovered action's numbers.
|
||||
/// </summary>
|
||||
public partial class BattleActionsPanel : ModWidget
|
||||
{
|
||||
private const int PipSize = 11;
|
||||
|
||||
private Label _subject;
|
||||
private HBoxContainer _pips;
|
||||
private Label _apHint;
|
||||
private Label _emptyLabel;
|
||||
private GridContainer _grid;
|
||||
private PanelContainer _descWell;
|
||||
private Label _descLabel;
|
||||
|
||||
/// <summary>Container that receives action cards; owned by the input controller.</summary>
|
||||
public Control Body => _grid;
|
||||
|
||||
protected override void Construct()
|
||||
{
|
||||
CthangoverTheme.Apply(this);
|
||||
|
||||
_subject = GetNode<Label>("Window/Column/Header/HeaderRow/SubjectLabel");
|
||||
_pips = GetNode<HBoxContainer>("Window/Column/Header/HeaderRow/Pips");
|
||||
_apHint = GetNode<Label>("Window/Column/Header/HeaderRow/ApHint");
|
||||
_emptyLabel = GetNode<Label>("Window/Column/Body/BodyColumn/EmptyLabel");
|
||||
_grid = GetNode<GridContainer>("Window/Column/Body/BodyColumn/Grid");
|
||||
_descWell = GetNode<PanelContainer>("Window/Column/DescWrap/DescWell");
|
||||
_descLabel = GetNode<Label>("Window/Column/DescWrap/DescWell/DescLabel");
|
||||
|
||||
var windowStyle = UiKit.Frame(UiTokens.RadiusLg, UiTokens.Alpha(UiTokens.Surface0, 0.84f), UiTokens.Line);
|
||||
windowStyle.ContentMarginLeft = 0;
|
||||
windowStyle.ContentMarginRight = 0;
|
||||
windowStyle.ContentMarginTop = 0;
|
||||
windowStyle.ContentMarginBottom = 0;
|
||||
windowStyle.ShadowColor = new Color(0f, 0f, 0f, 0.6f);
|
||||
windowStyle.ShadowSize = 18;
|
||||
windowStyle.ShadowOffset = new Vector2(0f, 8f);
|
||||
GetNode<PanelContainer>("Window").AddThemeStyleboxOverride("panel", windowStyle);
|
||||
|
||||
foreach (var path in new[]
|
||||
{
|
||||
"Window/Column/Header/HeaderRow/TitleLabel",
|
||||
"Window/Column/Header/HeaderRow/ApCaption",
|
||||
"Window/Column/Body/BodyColumn/EmptyLabel",
|
||||
"Window/Column/Footer/FooterRow/HintLabel",
|
||||
"Window/Column/Footer/FooterRow/AutoEndLabel",
|
||||
})
|
||||
{
|
||||
var label = GetNode<Label>(path);
|
||||
label.Text = Translate(label.Text);
|
||||
}
|
||||
|
||||
GetNode<Label>("Window/Column/Header/HeaderRow/TitleLabel").AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||
GetNode<Label>("Window/Column/Header/HeaderRow/ApCaption").AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||
_apHint.AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||
_emptyLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||
GetNode<Label>("Window/Column/Footer/FooterRow/HintLabel").AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||
GetNode<Label>("Window/Column/Footer/FooterRow/AutoEndLabel").AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||
|
||||
var descStyle = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.Line);
|
||||
descStyle.ContentMarginLeft = 12;
|
||||
descStyle.ContentMarginRight = 12;
|
||||
descStyle.ContentMarginTop = 10;
|
||||
descStyle.ContentMarginBottom = 10;
|
||||
_descWell.AddThemeStyleboxOverride("panel", descStyle);
|
||||
ClearSubject();
|
||||
}
|
||||
|
||||
/// <summary>Shows the selected character's name in the header.</summary>
|
||||
public void SetSubject(string name)
|
||||
{
|
||||
_subject.RemoveThemeColorOverride("font_color");
|
||||
_subject.AddThemeColorOverride("font_color", UiTokens.InkBright);
|
||||
_subject.Text = name;
|
||||
}
|
||||
|
||||
/// <summary>Restores the "no character selected" header and the empty state.</summary>
|
||||
public void ClearSubject()
|
||||
{
|
||||
_subject.AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||
_subject.Text = Translate("battle/no_character");
|
||||
_pips.Visible = false;
|
||||
_apHint.Text = string.Empty;
|
||||
SetDescriptionDefault();
|
||||
}
|
||||
|
||||
/// <summary>Repaints the action-point pips for the selected character.</summary>
|
||||
public void SetPoints(int current, int basePoints)
|
||||
{
|
||||
_pips.Visible = true;
|
||||
foreach (var child in _pips.GetChildren())
|
||||
{
|
||||
_pips.RemoveChild(child);
|
||||
child.QueueFree();
|
||||
}
|
||||
|
||||
for (var i = 0; i < basePoints; i++)
|
||||
{
|
||||
var on = i < current;
|
||||
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));
|
||||
_pips.AddChild(pip);
|
||||
}
|
||||
|
||||
_apHint.Text = current + " / " + basePoints;
|
||||
}
|
||||
|
||||
/// <summary>Frees every action card and restores the empty state.</summary>
|
||||
public void ClearBody()
|
||||
{
|
||||
foreach (var child in _grid.GetChildren())
|
||||
{
|
||||
_grid.RemoveChild(child);
|
||||
child.QueueFree();
|
||||
}
|
||||
RefreshEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Hides the empty state as soon as the grid holds action cards.</summary>
|
||||
public void RefreshEmpty()
|
||||
=> _emptyLabel.Visible = _grid.GetChildCount() == 0;
|
||||
|
||||
/// <summary>Prints the hovered action preview ("Урон 5 · расход 1 ОД") in the well.</summary>
|
||||
public void SetDescription(string text)
|
||||
{
|
||||
_descLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||
_descLabel.Text = text;
|
||||
}
|
||||
|
||||
/// <summary>Restores the well's invitation text.</summary>
|
||||
public void SetDescriptionDefault()
|
||||
{
|
||||
_descLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||
_descLabel.Text = Translate("battle/desc_default");
|
||||
}
|
||||
|
||||
private static string Translate(string key)
|
||||
=> TranslationServer.Translate(key).ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Cthangover.Core.UI;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.CardBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Centre-screen turn banner ("Ход игрока" / "Ход врага") from the interface
|
||||
/// mockup (mockups/card_battle). The plate lives in
|
||||
/// <c>scenes/battle_banner.tscn</c>; the class only repaints the caption and
|
||||
/// runs the fade-in / hold / fade-out tween. Tweens are driven by the
|
||||
/// process delta, so the battle speed control scales them together with the
|
||||
/// action animations.
|
||||
/// </summary>
|
||||
public partial class BattleBanner : ModWidget
|
||||
{
|
||||
private const float FadeInSeconds = 0.25f;
|
||||
private const float HoldSeconds = 1.0f;
|
||||
private const float FadeOutSeconds = 0.3f;
|
||||
|
||||
private PanelContainer _plate;
|
||||
private Label _label;
|
||||
private Tween _tween;
|
||||
|
||||
protected override void Construct()
|
||||
{
|
||||
CthangoverTheme.Apply(this);
|
||||
|
||||
_plate = GetNode<PanelContainer>("Plate");
|
||||
_label = GetNode<Label>("Plate/Label");
|
||||
|
||||
var style = UiKit.Frame(0, UiTokens.Alpha(UiTokens.Page, 0.62f), new Color(0f, 0f, 0f, 0f));
|
||||
style.BorderWidthTop = 1;
|
||||
style.BorderWidthBottom = 1;
|
||||
style.BorderColor = UiTokens.AccentLine;
|
||||
style.ContentMarginLeft = 44;
|
||||
style.ContentMarginRight = 44;
|
||||
style.ContentMarginTop = 12;
|
||||
style.ContentMarginBottom = 12;
|
||||
_plate.AddThemeStyleboxOverride("panel", style);
|
||||
|
||||
_label.AddThemeColorOverride("font_color", UiTokens.InkBright);
|
||||
_plate.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
}
|
||||
|
||||
/// <summary>Pops the banner with the given caption, then fades it away.</summary>
|
||||
public void Show(string text)
|
||||
{
|
||||
_label.Text = text;
|
||||
_tween?.Kill();
|
||||
_plate.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
_tween = CreateTween();
|
||||
_tween.TweenProperty(_plate, "modulate:a", 1f, FadeInSeconds).SetTrans(Tween.TransitionType.Cubic);
|
||||
_tween.TweenInterval(HoldSeconds);
|
||||
_tween.TweenProperty(_plate, "modulate:a", 0f, FadeOutSeconds).SetTrans(Tween.TransitionType.Cubic);
|
||||
}
|
||||
|
||||
/// <summary>Hides the banner immediately (battle cleared).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
_tween?.Kill();
|
||||
_plate.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ namespace Cthangover.CardBattle.UI
|
||||
/// </summary>
|
||||
public CharacterCardNode Create(Character card, bool isPlayer, float cardScale)
|
||||
{
|
||||
var behaviour = (CharacterCardNode)TscnScenes.LoadAndBuild("scenes/character_card_node.tscn");
|
||||
var behaviour = (CharacterCardNode)TscnScenes.LoadAndBuild("scenes/character_card.tscn");
|
||||
behaviour.EnsureConstructed();
|
||||
|
||||
if (content != null)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using Cthangover.Core.UI;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.CardBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Battle header from the interface mockup (mockups/card_battle): a compact
|
||||
/// window in the upper left with the battle title, a phase badge that flips
|
||||
/// between player and enemy turns, the round counter and the number of
|
||||
/// enemies still standing. The node tree lives in
|
||||
/// <c>scenes/battle_hud.tscn</c>; this class only fetches the nodes and
|
||||
/// repaints the badge for the current phase.
|
||||
/// </summary>
|
||||
public partial class BattleHud : ModWidget
|
||||
{
|
||||
private Label _phaseLabel;
|
||||
private PanelContainer _phaseBadge;
|
||||
private Label _roundValue;
|
||||
private Label _enemiesValue;
|
||||
|
||||
protected override void Construct()
|
||||
{
|
||||
CthangoverTheme.Apply(this);
|
||||
|
||||
_phaseBadge = GetNode<PanelContainer>("Panel/Column/TitleRow/PhaseBadge");
|
||||
_phaseLabel = GetNode<Label>("Panel/Column/TitleRow/PhaseBadge/PhaseLabel");
|
||||
_roundValue = GetNode<Label>("Panel/Column/MetaRow/RoundValue");
|
||||
_enemiesValue = GetNode<Label>("Panel/Column/MetaRow/EnemiesValue");
|
||||
|
||||
var panelStyle = UiKit.Frame(UiTokens.RadiusLg, UiTokens.Alpha(UiTokens.Surface0, 0.86f), UiTokens.Line);
|
||||
panelStyle.ContentMarginLeft = 18;
|
||||
panelStyle.ContentMarginRight = 18;
|
||||
panelStyle.ContentMarginTop = 12;
|
||||
panelStyle.ContentMarginBottom = 13;
|
||||
panelStyle.ShadowColor = new Color(0f, 0f, 0f, 0.6f);
|
||||
panelStyle.ShadowSize = 18;
|
||||
panelStyle.ShadowOffset = new Vector2(0f, 8f);
|
||||
GetNode<PanelContainer>("Panel").AddThemeStyleboxOverride("panel", panelStyle);
|
||||
|
||||
var title = GetNode<Label>("Panel/Column/TitleRow/TitleLabel");
|
||||
title.Text = Translate(title.Text);
|
||||
title.AddThemeColorOverride("font_color", UiTokens.InkBright);
|
||||
|
||||
foreach (var caption in new[] { "RoundCaption", "EnemiesCaption" })
|
||||
{
|
||||
var label = GetNode<Label>("Panel/Column/MetaRow/" + caption);
|
||||
label.Text = Translate(label.Text);
|
||||
label.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||
}
|
||||
|
||||
_phaseLabel.Text = Translate(_phaseLabel.Text);
|
||||
_roundValue.AddThemeColorOverride("font_color", UiTokens.AccentBright);
|
||||
_enemiesValue.AddThemeColorOverride("font_color", UiTokens.AccentBright);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repaints the phase badge: blue for the player phase, red for the
|
||||
/// enemy phase — the same colour language as the targeting highlights.
|
||||
/// </summary>
|
||||
public void SetTurn(bool player)
|
||||
{
|
||||
var accent = player ? UiTokens.Info : UiTokens.TypeEnemy;
|
||||
var style = UiKit.Frame(UiTokens.RadiusXs, UiTokens.BadgeFill(accent), UiTokens.BadgeLine(accent));
|
||||
style.ContentMarginLeft = 7;
|
||||
style.ContentMarginRight = 7;
|
||||
style.ContentMarginTop = 2;
|
||||
style.ContentMarginBottom = 2;
|
||||
|
||||
_phaseBadge.AddThemeStyleboxOverride("panel", style);
|
||||
_phaseLabel.AddThemeColorOverride("font_color", accent);
|
||||
_phaseLabel.Text = Translate(player ? "battle/turn_player" : "battle/turn_enemy");
|
||||
}
|
||||
|
||||
/// <summary>Writes the round number into the meta row.</summary>
|
||||
public void SetRound(int round) => _roundValue.Text = round.ToString();
|
||||
|
||||
/// <summary>Writes the number of enemies still alive into the meta row.</summary>
|
||||
public void SetEnemiesAlive(int count) => _enemiesValue.Text = count.ToString();
|
||||
|
||||
private static string Translate(string key)
|
||||
=> TranslationServer.Translate(key).ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Cthangover.CardBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Semantic category of a battle journal row, mirrored from the mockup's
|
||||
/// colour scheme (mockups/card_battle). The panel maps each kind to a
|
||||
/// left border accent, a background tint and a text colour, so callers
|
||||
/// only decide what happened, never how it is painted.
|
||||
/// </summary>
|
||||
public enum BattleLogKind
|
||||
{
|
||||
/// <summary>Neutral note: enemy intent, hints, odds and ends.</summary>
|
||||
Info,
|
||||
/// <summary>Turn or battle boundary (gold, highlighted row).</summary>
|
||||
Turn,
|
||||
/// <summary>Health loss (red).</summary>
|
||||
Damage,
|
||||
/// <summary>Shield gain or absorption (blue).</summary>
|
||||
Shield,
|
||||
/// <summary>Support buff (green).</summary>
|
||||
Buff,
|
||||
/// <summary>Status effect such as stun (warm).</summary>
|
||||
Status,
|
||||
/// <summary>Character defeat (faint).</summary>
|
||||
Dead,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using Cthangover.Core.UI;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.CardBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Battle journal docked between the player and action panels — the
|
||||
/// "Журнал боя" window from the interface mockup (mockups/card_battle).
|
||||
/// The node tree lives in <c>scenes/battle_log_panel.tscn</c>; rows are
|
||||
/// created at runtime because they are data, not layout, and are capped to
|
||||
/// <see cref="MaxRows"/> so a long battle cannot grow unbounded.
|
||||
/// <para/>
|
||||
/// The panel is the single subscriber of <see cref="BattleLogHub"/>, so any
|
||||
/// battle code can publish entries without knowing about the UI. The
|
||||
/// subscription is dropped on <see cref="Destruct"/> to keep the static hub
|
||||
/// clean between battles.
|
||||
/// </summary>
|
||||
public partial class BattleLogPanel : ModWidget
|
||||
{
|
||||
private const int MaxRows = 80;
|
||||
|
||||
private Label _title;
|
||||
private Label _roundLabel;
|
||||
private ScrollContainer _scroll;
|
||||
private VBoxContainer _rows;
|
||||
|
||||
protected override void Construct()
|
||||
{
|
||||
CthangoverTheme.Apply(this);
|
||||
|
||||
_title = GetNode<Label>("Window/Column/Header/HeaderRow/TitleLabel");
|
||||
_roundLabel = GetNode<Label>("Window/Column/Header/HeaderRow/RoundLabel");
|
||||
_scroll = GetNode<ScrollContainer>("Window/Column/Scroll");
|
||||
_rows = GetNode<VBoxContainer>("Window/Column/Scroll/Rows");
|
||||
|
||||
var windowStyle = UiKit.Frame(UiTokens.RadiusLg, UiTokens.Alpha(UiTokens.Surface0, 0.84f), UiTokens.Line);
|
||||
windowStyle.ContentMarginLeft = 0;
|
||||
windowStyle.ContentMarginRight = 0;
|
||||
windowStyle.ContentMarginTop = 0;
|
||||
windowStyle.ContentMarginBottom = 0;
|
||||
windowStyle.ShadowColor = new Color(0f, 0f, 0f, 0.6f);
|
||||
windowStyle.ShadowSize = 18;
|
||||
windowStyle.ShadowOffset = new Vector2(0f, 8f);
|
||||
GetNode<PanelContainer>("Window").AddThemeStyleboxOverride("panel", windowStyle);
|
||||
|
||||
_title.Text = Translate(_title.Text);
|
||||
_title.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||
_roundLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||
|
||||
BattleLogHub.Entry += Append;
|
||||
}
|
||||
|
||||
protected override void Destruct()
|
||||
{
|
||||
BattleLogHub.Entry -= Append;
|
||||
}
|
||||
|
||||
/// <summary>Updates the "раунд N" caption in the header.</summary>
|
||||
public void SetRound(int round)
|
||||
=> _roundLabel.Text = string.Format(Translate("battle/log_round"), round);
|
||||
|
||||
/// <summary>Drops every row; called when the battle is cleared or restarted.</summary>
|
||||
public void Clear()
|
||||
{
|
||||
foreach (var child in _rows.GetChildren())
|
||||
{
|
||||
_rows.RemoveChild(child);
|
||||
child.QueueFree();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends one colour-coded row and trims the oldest entries past the cap.
|
||||
/// The scroll is pushed to the bottom so the newest event stays visible.
|
||||
/// </summary>
|
||||
public void Append(string text, BattleLogKind kind)
|
||||
{
|
||||
var style = UiKit.Frame(0, BackgroundOf(kind), new Color(0f, 0f, 0f, 0f));
|
||||
style.BorderWidthLeft = 2;
|
||||
style.BorderColor = BorderOf(kind);
|
||||
style.ContentMarginLeft = 12;
|
||||
style.ContentMarginRight = 12;
|
||||
style.ContentMarginTop = 7;
|
||||
style.ContentMarginBottom = 7;
|
||||
|
||||
var row = new PanelContainer { MouseFilter = MouseFilterEnum.Ignore };
|
||||
row.AddThemeStyleboxOverride("panel", style);
|
||||
|
||||
var label = UiKit.Label(text, UiTokens.FontBodySmall, TextOf(kind));
|
||||
label.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
||||
row.AddChild(label);
|
||||
|
||||
_rows.AddChild(row);
|
||||
|
||||
while (_rows.GetChildCount() > MaxRows)
|
||||
{
|
||||
var first = _rows.GetChild(0);
|
||||
_rows.RemoveChild(first);
|
||||
first.QueueFree();
|
||||
}
|
||||
|
||||
_scroll.ScrollVertical = (int)_scroll.GetVScrollBar().MaxValue;
|
||||
}
|
||||
|
||||
private static Color BackgroundOf(BattleLogKind kind)
|
||||
=> kind == BattleLogKind.Turn ? UiTokens.AccentSoft : new Color(0f, 0f, 0f, 0f);
|
||||
|
||||
private static Color BorderOf(BattleLogKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case BattleLogKind.Turn: return UiTokens.Accent;
|
||||
case BattleLogKind.Damage: return UiTokens.Danger;
|
||||
case BattleLogKind.Shield: return UiTokens.TypeResource;
|
||||
case BattleLogKind.Buff: return UiTokens.TypePassive;
|
||||
case BattleLogKind.Status: return UiTokens.Cooldown;
|
||||
case BattleLogKind.Dead: return UiTokens.LineFaint;
|
||||
default: return UiTokens.Line;
|
||||
}
|
||||
}
|
||||
|
||||
private static Color TextOf(BattleLogKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case BattleLogKind.Turn: return UiTokens.AccentBright;
|
||||
case BattleLogKind.Damage: return UiTokens.DangerText;
|
||||
case BattleLogKind.Shield: return UiTokens.Info;
|
||||
case BattleLogKind.Buff: return UiTokens.SuccessText;
|
||||
case BattleLogKind.Status: return UiTokens.Cooldown;
|
||||
case BattleLogKind.Dead: return UiTokens.Faint;
|
||||
default: return UiTokens.Muted;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Translate(string key)
|
||||
=> TranslationServer.Translate(key).ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Cthangover.Core.UI;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.CardBattle.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Root of the card battle interface rebuilt from the mockup
|
||||
/// (mockups/card_battle). The whole layout lives in
|
||||
/// <c>scenes/card_battle_screen.tscn</c> and is composed of the mockup's
|
||||
/// parts: the HUD window, the enemy and party card panels, the "Действия"
|
||||
/// window, the battle journal, the turn banner and the end-turn button.
|
||||
/// This class is the single assembly point: it constructs every child widget
|
||||
/// once (mod widgets are not auto-constructed by Godot) and exposes them to
|
||||
/// the battle core and the input controller, so neither has to walk the
|
||||
/// scene tree by hand.
|
||||
/// </summary>
|
||||
public partial class CardBattleScreen : ModWidget
|
||||
{
|
||||
/// <summary>Top-left window with the phase badge, round and enemy count.</summary>
|
||||
public BattleHud Hud { get; private set; }
|
||||
|
||||
/// <summary>Panel that manages the enemy card row(s); aligned right.</summary>
|
||||
public BattleCardPanel EnemyPanel { get; private set; }
|
||||
|
||||
/// <summary>Panel that manages the party card row; aligned left.</summary>
|
||||
public BattleCardPanel PlayerPanel { get; private set; }
|
||||
|
||||
/// <summary>Lower-right "Действия" window with the action card grid.</summary>
|
||||
public BattleActionsPanel ActionsPanel { get; private set; }
|
||||
|
||||
/// <summary>Battle journal window that renders <see cref="BattleLogHub"/> entries.</summary>
|
||||
public BattleLogPanel LogPanel { get; private set; }
|
||||
|
||||
/// <summary>Centre-screen turn banner.</summary>
|
||||
public BattleBanner Banner { get; private set; }
|
||||
|
||||
/// <summary>End-turn button in the top-right corner, next to the battle speed bar.</summary>
|
||||
public EndTurnButton EndTurnButton { get; private set; }
|
||||
|
||||
/// <summary>Global input handler that drives selection and drag-and-drop.</summary>
|
||||
public CardController Controller { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Canvas layer above the whole battle UI (layer 100). The dragged action
|
||||
/// card is temporarily reparented here, so it always renders over enemy
|
||||
/// and party cards regardless of their Z-index inside the panels.
|
||||
/// </summary>
|
||||
public CanvasLayer DragLayer { get; private set; }
|
||||
|
||||
protected override void Construct()
|
||||
{
|
||||
CthangoverTheme.Apply(this);
|
||||
|
||||
Hud = GetNode<BattleHud>("Hud");
|
||||
Hud.EnsureConstructed();
|
||||
|
||||
EnemyPanel = GetNode<BattleCardPanel>("EnemyPanel");
|
||||
EnemyPanel.EnsureConstructed();
|
||||
EnemyPanel.AlignType = "Right";
|
||||
|
||||
PlayerPanel = GetNode<BattleCardPanel>("PlayerPanel");
|
||||
PlayerPanel.EnsureConstructed();
|
||||
|
||||
ActionsPanel = GetNode<BattleActionsPanel>("ActionsPanel");
|
||||
ActionsPanel.EnsureConstructed();
|
||||
|
||||
LogPanel = GetNode<BattleLogPanel>("LogPanel");
|
||||
LogPanel.EnsureConstructed();
|
||||
|
||||
Banner = GetNode<BattleBanner>("Banner");
|
||||
Banner.EnsureConstructed();
|
||||
|
||||
EndTurnButton = GetNode<EndTurnButton>("EndTurnButton");
|
||||
EndTurnButton.EnsureConstructed();
|
||||
|
||||
Controller = GetNode<CardController>("CardController");
|
||||
|
||||
DragLayer = GetNode<CanvasLayer>("DragLayer");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases every widget when the battle scene is cleared: cards, action
|
||||
/// deck, journal rows, banner and the end-turn button. Called by the
|
||||
/// battle core on <c>OnBattleCleared</c>.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
EnemyPanel.ClearAll();
|
||||
PlayerPanel.ClearAll();
|
||||
ActionsPanel.ClearBody();
|
||||
ActionsPanel.ClearSubject();
|
||||
LogPanel.Clear();
|
||||
Banner.Clear();
|
||||
EndTurnButton.SetVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+262
-308
@@ -1,182 +1,162 @@
|
||||
using System.Collections.Generic;
|
||||
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 Cthangover.Core.UI.CharacterPanel;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
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.
|
||||
/// Central input handler for the card battle UI rebuilt from the mockup
|
||||
/// (mockups/card_battle). Processes mouse clicks and drags: click a party
|
||||
/// card to open its action deck in the "Действия" window, drag an action
|
||||
/// card onto a valid target (enemy for attacks, ally for support, self for
|
||||
/// self-buffs) and execute it on drop. Valid targets are decided by
|
||||
/// <see cref="Player.CardActionStrategyFactory"/>; after every executed
|
||||
/// action the controller fires <see cref="OnActionExecuted"/> so the core can
|
||||
/// end the turn when all action points are spent.
|
||||
/// <para/>
|
||||
/// Actions are instantiated from <c>scenes/action_card.tscn</c> and parented
|
||||
/// into the grid of <see cref="BattleActionsPanel"/>, which owns the empty
|
||||
/// state, the point pips and the effect preview well; the controller only
|
||||
/// feeds it. Hit-testing uses the card root rects exposed through
|
||||
/// <see cref="ICard.GetControlNode"/>.
|
||||
/// </summary>
|
||||
public partial class CardController : InputHandlerNode
|
||||
{
|
||||
private Control actionPanel;
|
||||
private BattleCardPanel[] panels;
|
||||
private CardBattleScreen _screen;
|
||||
private BattleCardPanel[] _panels;
|
||||
|
||||
private List<CharacterCardNode> characterCards = new();
|
||||
private List<ActionCardNode> actionCards = new();
|
||||
private readonly List<CharacterCardNode> _characterCards = new();
|
||||
private readonly List<ActionCardNode> _actionCards = new();
|
||||
|
||||
private CharacterCardNode selectedCharacter;
|
||||
private CharacterCardNode targetCharacter;
|
||||
private ActionCardNode currentActionCharacter;
|
||||
private CharacterCardNode _selectedCharacter;
|
||||
private CharacterCardNode _targetCharacter;
|
||||
private ActionCardNode _currentActionCard;
|
||||
|
||||
private bool isDragging;
|
||||
private bool dragValid;
|
||||
private float dragThreshold = 15f;
|
||||
private Vector2 pressPosition;
|
||||
private bool _isDragging;
|
||||
private bool _dragValid;
|
||||
private const 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.
|
||||
/// Uniform scale factor applied to all action cards created by this
|
||||
/// controller. Set by the battle core from the viewport fitting
|
||||
/// calculation.
|
||||
/// </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;
|
||||
}
|
||||
public float CardScale { get; set; } = 1f;
|
||||
|
||||
/// <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.
|
||||
/// the battle core to auto-end the player's turn when all 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.
|
||||
/// Binds the controller to the assembled battle screen: keeps the panels
|
||||
/// for hit-testing and redraws and the actions panel for the deck grid and
|
||||
/// the hover preview. Called once by the battle core right after the
|
||||
/// screen is added to the battle scene.
|
||||
/// </summary>
|
||||
public void Setup(CardBattleScreen screen)
|
||||
{
|
||||
_screen = screen;
|
||||
_panels = new[] { screen.PlayerPanel, screen.EnemyPanel };
|
||||
}
|
||||
|
||||
protected override void OnInput(InputEvent @event)
|
||||
{
|
||||
if (Cthangover.Core.Battle.BattleSceneContext.Instance != null &&
|
||||
(Cthangover.Core.Battle.BattleSceneContext.Instance.IsWait ||
|
||||
Cthangover.Core.Battle.BattleSceneContext.Instance.IsDestroyed))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (@event is InputEventMouseButton mouseButton && 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 &&
|
||||
mouseMotion.ButtonMask.HasFlag(MouseButtonMask.Left) &&
|
||||
_dragValid)
|
||||
{
|
||||
if (!_isDragging && (mouseMotion.GlobalPosition - _pressPosition).Length() > DragThreshold)
|
||||
_isDragging = true;
|
||||
if (_isDragging)
|
||||
OnDrag(mouseMotion.GlobalPosition);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears previous action cards and fades in a fresh deck for
|
||||
/// <paramref name="character"/>: one card per battle-usable action, dimmed
|
||||
/// when the character cannot pay its cost. Called when a party card is
|
||||
/// clicked.
|
||||
/// </summary>
|
||||
public void ShowActions(CharacterCardNode character)
|
||||
{
|
||||
if (character?.Card?.Actions == null)
|
||||
{
|
||||
GameLogger.Log("CARD_CTRL",
|
||||
$"ShowActions: NULL actions for character {(character?.Card?.Name ?? "null")}",
|
||||
"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 points = character.Card.Attributes.Point.Value;
|
||||
foreach (var action in character.Card.Actions)
|
||||
{
|
||||
var action = character.Card.Actions[i];
|
||||
if (!action.Type.UseInBattle())
|
||||
continue;
|
||||
var cardNode = CreateAction(action, cardScale);
|
||||
cardNode.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
cardNode.Position = originPos - cardNode.Size / 2;
|
||||
actionCards.Add(cardNode);
|
||||
|
||||
var targetPos = new Vector2(cardNode.Size.X * i, 0);
|
||||
var cardNode = CreateAction(action, CardScale);
|
||||
cardNode.SetDisabled(points < ActionCharacterUi.CostOf(action));
|
||||
cardNode.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
cardNode.Hovered += OnActionCardHovered;
|
||||
cardNode.Unhovered += OnActionCardUnhovered;
|
||||
_actionCards.Add(cardNode);
|
||||
|
||||
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);
|
||||
tween.TweenProperty(cardNode, "modulate:a", 1f, 0.25f);
|
||||
}
|
||||
|
||||
_screen.ActionsPanel.RefreshEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Animates existing action cards fading out and dropping down, then frees them.
|
||||
/// Called before showing new action cards for a different character.
|
||||
/// </summary>
|
||||
/// <summary>Fades the current deck out and frees it.</summary>
|
||||
public void DiscardActionCharacters()
|
||||
{
|
||||
if (actionCards.Count == 0)
|
||||
if (_actionCards.Count == 0)
|
||||
return;
|
||||
|
||||
var oldCards = new List<ActionCardNode>(actionCards);
|
||||
actionCards.Clear();
|
||||
var oldCards = new List<ActionCardNode>(_actionCards);
|
||||
_actionCards.Clear();
|
||||
|
||||
foreach (var card in oldCards)
|
||||
{
|
||||
@@ -184,257 +164,232 @@ namespace Cthangover.CardBattle.UI
|
||||
continue;
|
||||
|
||||
card.MouseFilter = Control.MouseFilterEnum.Ignore;
|
||||
card.Hovered -= OnActionCardHovered;
|
||||
card.Unhovered -= OnActionCardUnhovered;
|
||||
|
||||
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));
|
||||
tween.TweenCallback(Callable.From(card.QueueFree));
|
||||
}
|
||||
|
||||
_screen.ActionsPanel.RefreshEmpty();
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Drops the source and target highlights, optionally resets the actions
|
||||
/// window back to its empty state and redraws the panels so the Z-order
|
||||
/// returns to normal. Called after each executed action, on empty clicks
|
||||
/// and when a drag is cancelled.
|
||||
/// </summary>
|
||||
public void ClearSelections(bool clearSource = true)
|
||||
{
|
||||
if (currentActionCharacter != null)
|
||||
if (_currentActionCard != null)
|
||||
{
|
||||
currentActionCharacter.Unselect();
|
||||
currentActionCharacter = null;
|
||||
}
|
||||
if (selectedCharacter != null && clearSource)
|
||||
{
|
||||
selectedCharacter.Unselect();
|
||||
selectedCharacter = null;
|
||||
DiscardActionCharacters();
|
||||
}
|
||||
if (targetCharacter != null)
|
||||
{
|
||||
targetCharacter.Unselect();
|
||||
targetCharacter = null;
|
||||
_currentActionCard.Unselect();
|
||||
_currentActionCard = null;
|
||||
}
|
||||
|
||||
if (panels != null)
|
||||
if (_selectedCharacter != null && clearSource)
|
||||
{
|
||||
foreach (var panel in panels)
|
||||
_selectedCharacter.Unselect();
|
||||
_selectedCharacter = null;
|
||||
DiscardActionCharacters();
|
||||
_screen.ActionsPanel.ClearSubject();
|
||||
}
|
||||
|
||||
ClearTargetMarkers();
|
||||
|
||||
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"/>.
|
||||
/// Removes the targeting frame from the current target and forgets it.
|
||||
/// Every path that changes or abandons a target must go through here:
|
||||
/// dragging over the gap between cards first nulls the target, and
|
||||
/// without the explicit <see cref="CharacterCardNode.Unselect"/> the
|
||||
/// discarded card would keep its red or grey marker forever.
|
||||
/// </summary>
|
||||
private void ClearTargetMarkers()
|
||||
{
|
||||
if (_targetCharacter != null && GodotObject.IsInstanceValid(_targetCharacter))
|
||||
_targetCharacter.Unselect();
|
||||
_targetCharacter = null;
|
||||
}
|
||||
|
||||
/// <summary>Immediately frees all action card nodes without animation (battle cleanup).</summary>
|
||||
public void ClearActions()
|
||||
{
|
||||
if (actionCards.Count > 0)
|
||||
{
|
||||
foreach (var card in actionCards)
|
||||
card.QueueFree();
|
||||
actionCards.Clear();
|
||||
}
|
||||
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.
|
||||
/// Instantiates an <see cref="ActionCardNode"/> inside the actions grid
|
||||
/// and fills it from the action definition.
|
||||
/// </summary>
|
||||
public ActionCardNode CreateAction(ActionCharacter actionCard, float cardScale)
|
||||
{
|
||||
var card = (ActionCardNode)TscnScenes.LoadAndBuild("scenes/action_card_node.tscn");
|
||||
var card = (ActionCardNode)TscnScenes.LoadAndBuild("scenes/action_card.tscn");
|
||||
card.EnsureConstructed();
|
||||
actionPanel.AddChild(card);
|
||||
_screen.ActionsPanel.Body.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.
|
||||
/// Resolves the currently selected action against the selected target
|
||||
/// through the matching <see cref="Player.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);
|
||||
var strategy = Player.CardActionStrategyFactory.Get(
|
||||
_currentActionCard.Card, _selectedCharacter, _targetCharacter);
|
||||
strategy?.Execute(_selectedCharacter, _targetCharacter, _currentActionCard.Card);
|
||||
}
|
||||
|
||||
private void OnActionCardHovered(ActionCardNode card)
|
||||
=> _screen.ActionsPanel.SetDescription(card.DescribeFrom(_selectedCharacter?.Card));
|
||||
|
||||
private void OnActionCardUnhovered(ActionCardNode card)
|
||||
=> _screen.ActionsPanel.SetDescriptionDefault();
|
||||
|
||||
private void OnPointerClick(Vector2 screenPosition)
|
||||
{
|
||||
characterCards = GetAllCharacters();
|
||||
var source = TryFindCardByPosition(screenPosition, characterCards);
|
||||
var source = TryFindCardByPosition(screenPosition, CollectCharacters());
|
||||
|
||||
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
|
||||
if (source == null || !source.IsPlayer || source.IsDead)
|
||||
{
|
||||
ClearSelections();
|
||||
return;
|
||||
}
|
||||
|
||||
if (source != _selectedCharacter && _selectedCharacter != null)
|
||||
_selectedCharacter.Unselect();
|
||||
|
||||
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService");
|
||||
audioService?.PlaySound("battle/card_show_actions", SoundType.CardEffect);
|
||||
|
||||
_selectedCharacter = source;
|
||||
_screen.ActionsPanel.SetSubject(TranslationServer.Translate(source.Card.Name).ToString());
|
||||
_screen.ActionsPanel.SetPoints(source.Card.Attributes.Point.Value, source.Card.Attributes.Point.BaseValue);
|
||||
ShowActions(_selectedCharacter);
|
||||
_selectedCharacter.Select();
|
||||
}
|
||||
|
||||
private void OnBeginDrag(Vector2 screenPosition)
|
||||
{
|
||||
var source = TryFindCardByPosition(screenPosition, actionCards);
|
||||
if (currentActionCharacter != null && source != currentActionCharacter)
|
||||
currentActionCharacter.Unselect();
|
||||
var source = TryFindCardByPosition(screenPosition, _actionCards);
|
||||
|
||||
if (source != null)
|
||||
if (source == null)
|
||||
return;
|
||||
|
||||
if (source.IsDisabled)
|
||||
{
|
||||
dragValid = true;
|
||||
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService");
|
||||
audioService?.PlaySound("battle/card_pick", 4, Cthangover.Core.Audio.SoundType.CardEffect);
|
||||
BattleLogHub.Log(
|
||||
string.Format(Translate("battle/log_not_enough_points"), ActionCharacterUi.Translate(source.Card.Name)),
|
||||
BattleLogKind.Status);
|
||||
return;
|
||||
}
|
||||
|
||||
GameLogger.Log("CARD_CTRL",
|
||||
$"Begin drag: {source.Card.Name}",
|
||||
LogLevel.Debug);
|
||||
if (_currentActionCard != null && source != _currentActionCard)
|
||||
_currentActionCard.Unselect();
|
||||
|
||||
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);
|
||||
_dragValid = true;
|
||||
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService");
|
||||
audioService?.PlaySound("battle/card_pick", 4, SoundType.CardEffect);
|
||||
|
||||
foreach (var card in actionCards)
|
||||
{
|
||||
if (card != currentActionCharacter && GodotObject.IsInstanceValid(card))
|
||||
card.MouseFilter = Control.MouseFilterEnum.Ignore;
|
||||
}
|
||||
_currentActionCard = source;
|
||||
var control = _currentActionCard.GetControlNode();
|
||||
_originalActionCardParent = control.GetParent();
|
||||
var globalPos = control.GlobalPosition;
|
||||
// Отдельный CanvasLayer поверх всего боя: иначе карта рисуется
|
||||
// под ветками панелей врагов и союзников.
|
||||
control.Reparent(_screen.DragLayer);
|
||||
control.GlobalPosition = globalPos;
|
||||
|
||||
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 != _currentActionCard && GodotObject.IsInstanceValid(card))
|
||||
card.MouseFilter = Control.MouseFilterEnum.Ignore;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDrag(Vector2 screenPosition)
|
||||
{
|
||||
if (currentActionCharacter == null)
|
||||
if (_currentActionCard == null)
|
||||
return;
|
||||
|
||||
var cardControl = currentActionCharacter.GetControlNode();
|
||||
var cardControl = _currentActionCard.GetControlNode();
|
||||
cardControl.GlobalPosition = screenPosition - cardControl.Size / 2;
|
||||
|
||||
var target = TryFindCardByRect(cardControl, characterCards);
|
||||
var target = TryFindCardByRect(cardControl, _characterCards);
|
||||
if (target == _targetCharacter)
|
||||
return;
|
||||
|
||||
if (target != targetCharacter && targetCharacter != null)
|
||||
targetCharacter.Unselect();
|
||||
ClearTargetMarkers();
|
||||
|
||||
if (target != null)
|
||||
if (target == null)
|
||||
{
|
||||
targetCharacter = target;
|
||||
var strategy = Cthangover.CardBattle.Player.CardActionStrategyFactory.Get(
|
||||
currentActionCharacter.Card, selectedCharacter, target);
|
||||
_currentActionCard.Unselect();
|
||||
return;
|
||||
}
|
||||
|
||||
var valid = strategy != null && strategy.Check(currentActionCharacter.Card, selectedCharacter, target);
|
||||
_targetCharacter = target;
|
||||
var strategy = Player.CardActionStrategyFactory.Get(
|
||||
_currentActionCard.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();
|
||||
}
|
||||
if (strategy != null && strategy.Check(_currentActionCard.Card, _selectedCharacter, target))
|
||||
{
|
||||
strategy.HighlightTarget(_currentActionCard, _targetCharacter);
|
||||
}
|
||||
else
|
||||
{
|
||||
targetCharacter = null;
|
||||
currentActionCharacter.Unselect();
|
||||
_targetCharacter.Invalid();
|
||||
_currentActionCard.Invalid();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEndDrag(Vector2 screenPosition)
|
||||
{
|
||||
foreach (var card in actionCards)
|
||||
foreach (var card in _actionCards)
|
||||
{
|
||||
if (card != null && GodotObject.IsInstanceValid(card))
|
||||
card.MouseFilter = Control.MouseFilterEnum.Stop;
|
||||
}
|
||||
|
||||
if (selectedCharacter != null && currentActionCharacter != null && targetCharacter != null)
|
||||
if (_selectedCharacter != null && _currentActionCard != null && _targetCharacter != null)
|
||||
{
|
||||
var strategy = Cthangover.CardBattle.Player.CardActionStrategyFactory.Get(
|
||||
currentActionCharacter.Card, selectedCharacter, targetCharacter);
|
||||
var strategy = Player.CardActionStrategyFactory.Get(
|
||||
_currentActionCard.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 (strategy != null && strategy.Check(_currentActionCard.Card, _selectedCharacter, _targetCharacter))
|
||||
{
|
||||
if (_originalActionCardParent != null)
|
||||
{
|
||||
currentActionCharacter.GetControlNode().Reparent(_originalActionCardParent);
|
||||
_currentActionCard.GetControlNode().Reparent(_originalActionCardParent);
|
||||
_originalActionCardParent = null;
|
||||
}
|
||||
|
||||
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService");
|
||||
audioService?.PlaySound("battle/card_drop", 4, Cthangover.Core.Audio.SoundType.CardEffect);
|
||||
audioService?.PlaySound("battle/card_drop", 4, SoundType.CardEffect);
|
||||
|
||||
DoActionToTarget();
|
||||
|
||||
if (panels != null)
|
||||
if (_panels != null)
|
||||
{
|
||||
foreach (var panel in panels)
|
||||
foreach (var panel in _panels)
|
||||
panel?.Redraw();
|
||||
}
|
||||
|
||||
@@ -444,9 +399,9 @@ namespace Cthangover.CardBattle.UI
|
||||
}
|
||||
}
|
||||
|
||||
if (currentActionCharacter != null)
|
||||
if (_currentActionCard != null)
|
||||
{
|
||||
var control = currentActionCharacter.GetControlNode();
|
||||
var control = _currentActionCard.GetControlNode();
|
||||
if (_originalActionCardParent != null)
|
||||
{
|
||||
control.Reparent(_originalActionCardParent);
|
||||
@@ -455,57 +410,56 @@ namespace Cthangover.CardBattle.UI
|
||||
var resetTween = CreateTween();
|
||||
resetTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
||||
resetTween.TweenProperty(control, "scale", Vector2.One, 0.15f);
|
||||
control.ZIndex = 0;
|
||||
|
||||
BattleLogHub.Log(
|
||||
string.Format(Translate("battle/log_invalid_target"), ActionCharacterUi.Translate(_currentActionCard.Card.Name)),
|
||||
BattleLogKind.Status);
|
||||
}
|
||||
|
||||
Redraw();
|
||||
ClearSelections(false);
|
||||
}
|
||||
|
||||
private T TryFindCardByPosition<T>(Vector2 screenPosition, IList<T> cards, ICard exclude = null) where T : class, ICard
|
||||
private T TryFindCardByPosition<T>(Vector2 screenPosition, IList<T> cards) where T : class, ICard
|
||||
{
|
||||
if (cards == null)
|
||||
return null;
|
||||
foreach (var card in cards)
|
||||
{
|
||||
if (card == exclude || card == null)
|
||||
if (card == null || !GodotObject.IsInstanceValid(card.GetControlNode()))
|
||||
continue;
|
||||
if (card.Frame != null && card.Frame.GetGlobalRect().HasPoint(screenPosition))
|
||||
var rect = card.GetControlNode();
|
||||
if (rect.GetGlobalRect().HasPoint(screenPosition))
|
||||
return card;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private T TryFindCardByRect<T>(Control draggingRect, IList<T> cards, ICard exclude = null) where T : class, ICard
|
||||
private T TryFindCardByRect<T>(Control draggingRect, IList<T> cards) where T : class, ICard
|
||||
{
|
||||
Vector2 screenPoint = draggingRect.GlobalPosition + draggingRect.Size / 2;
|
||||
var screenPoint = draggingRect.GlobalPosition + draggingRect.Size / 2;
|
||||
foreach (var card in cards)
|
||||
{
|
||||
if (card == exclude || card == null)
|
||||
if (card == null || !GodotObject.IsInstanceValid(card.GetControlNode()))
|
||||
continue;
|
||||
if (card.Frame != null && card.Frame.GetGlobalRect().HasPoint(screenPoint))
|
||||
var rect = card.GetControlNode();
|
||||
if (rect.GetGlobalRect().HasPoint(screenPoint))
|
||||
return card;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<CharacterCardNode> GetAllCharacters()
|
||||
private List<CharacterCardNode> CollectCharacters()
|
||||
{
|
||||
var list = new List<CharacterCardNode>();
|
||||
var root = GetParent();
|
||||
if (root != null)
|
||||
CollectNodes<CharacterCardNode>(root, list);
|
||||
return list;
|
||||
_characterCards.Clear();
|
||||
foreach (var panel in _panels)
|
||||
{
|
||||
if (panel == null)
|
||||
continue;
|
||||
foreach (var card in panel.CardList)
|
||||
_characterCards.Add(card);
|
||||
}
|
||||
return _characterCards;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
private static string Translate(string key)
|
||||
=> TranslationServer.Translate(key).ToString();
|
||||
}
|
||||
}
|
||||
+266
-168
@@ -1,5 +1,5 @@
|
||||
using Cthangover.Core.Cards;
|
||||
using Cthangover.Core.Mods;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Cards.StatusEffect;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.UI;
|
||||
@@ -9,150 +9,192 @@ 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.
|
||||
/// Party or enemy card on the battle field, rebuilt from the interface
|
||||
/// mockup (mockups/card_battle): full-bleed portrait, team-coloured frame,
|
||||
/// bottom info block with the name, four stat chips (HP/ЗАЩ/АТК/ОД) and an
|
||||
/// HP bar, plus stun badge, zap flash and selection frame in the upper right.
|
||||
/// The node tree lives in <c>scenes/character_card.tscn</c>; this class
|
||||
/// fetches the nodes once, dresses them with <see cref="UiTokens"/> styles and
|
||||
/// exposes the battle-facing API: <see cref="UpdateInfo"/> is the single
|
||||
/// refresh entry point for mutable stats, <see cref="Select"/> /
|
||||
/// <see cref="Attack"/> / <see cref="Invalid"/> paint target feedback for the
|
||||
/// drag-and-drop flow, <see cref="PlayZap"/> and <see cref="Flash"/> give
|
||||
/// impact feedback, <see cref="AllImages"/> feeds the dissolve shader of
|
||||
/// <see cref="CardDeathAnimation"/>.
|
||||
/// <para/>
|
||||
/// Implements <see cref="ICard"/> for hit-testing during drag-and-drop
|
||||
/// targeting and keeps the hover lift / Z-index layering rules of the
|
||||
/// original widget so overlapping cards stay readable.
|
||||
/// </summary>
|
||||
public partial class CharacterCardNode : ModWidget, ICard
|
||||
{
|
||||
private ColorRect teamOutline;
|
||||
private TextureRect frame;
|
||||
private TextureRect image;
|
||||
private TextureRect selection;
|
||||
private const float HoverLift = 10f;
|
||||
private const float HoverScale = 1.12f;
|
||||
|
||||
private ColorRect healthRect;
|
||||
private Label nameLabel;
|
||||
private Label defenceLabel;
|
||||
private Label attackLabel;
|
||||
private Label pointsLabel;
|
||||
private TextureRect _art;
|
||||
private Panel _teamFrame;
|
||||
private Label _nameLabel;
|
||||
private ProgressBar _hpBar;
|
||||
private Label _hpValue;
|
||||
private Label _defValue;
|
||||
private Label _atkValue;
|
||||
private Label _apValue;
|
||||
private VBoxContainer _status;
|
||||
private PanelContainer _stunBadge;
|
||||
private TextureRect _stunIcon;
|
||||
private Label _stunLabel;
|
||||
private TextureRect _zap;
|
||||
private Panel _fx;
|
||||
private TextureRect _selection;
|
||||
|
||||
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;
|
||||
private Color _selectionState = Colors.Transparent;
|
||||
private float _baseY;
|
||||
private bool _isHovered;
|
||||
private bool _selected;
|
||||
private int _baseZIndex;
|
||||
private Vector2 _baseScale = Vector2.One;
|
||||
private Tween _hoverTween;
|
||||
private Tween _selectionTween;
|
||||
|
||||
private static Texture2D _cachedSelectTex;
|
||||
private static bool _texturesLoaded;
|
||||
|
||||
/// <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"/>.
|
||||
/// Distinguishes player-owned cards from enemy cards. Must be assigned by
|
||||
/// <see cref="BattleCardPanel"/> before <see cref="Init"/>, because the
|
||||
/// team frame colour is applied during initialisation.
|
||||
/// </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.
|
||||
/// Marks the card as dead. Set by <see cref="BattleCardPanel.Dead"/>,
|
||||
/// checked by the battle core when counting alive cards and when the
|
||||
/// enemy AI picks targets.
|
||||
/// </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>
|
||||
/// <summary>The character data model backing this card: stats, action deck, status effects.</summary>
|
||||
public Character Card { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>this</c> — character cards are self-contained <see cref="Control"/> nodes.
|
||||
/// Textures that receive the dissolve shader during the death animation:
|
||||
/// the portrait and the selection frame. The team frame and the info
|
||||
/// block are simple styleboxes and fade with the whole card node.
|
||||
/// </summary>
|
||||
public TextureRect[] AllImages => _allImages ??= new[] { _art, _selection };
|
||||
private TextureRect[] _allImages;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Control GetControlNode() => this;
|
||||
|
||||
protected override void Construct()
|
||||
{
|
||||
CthangoverTheme.Apply(this);
|
||||
|
||||
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");
|
||||
_art = GetNode<TextureRect>("Art");
|
||||
_teamFrame = GetNode<Panel>("TeamFrame");
|
||||
_nameLabel = GetNode<Label>("Info/Column/NameLabel");
|
||||
_hpBar = GetNode<ProgressBar>("Info/Column/HpBar");
|
||||
_hpValue = GetNode<Label>("Info/Column/Stats/HpChip/Row/Value");
|
||||
_defValue = GetNode<Label>("Info/Column/Stats/DefChip/Row/Value");
|
||||
_atkValue = GetNode<Label>("Info/Column/Stats/AtkChip/Row/Value");
|
||||
_apValue = GetNode<Label>("Info/Column/Stats/ApChip/Row/Value");
|
||||
_status = GetNode<VBoxContainer>("Status");
|
||||
_stunBadge = GetNode<PanelContainer>("Status/StunBadge");
|
||||
_stunIcon = GetNode<TextureRect>("Status/StunBadge/Row/StunIcon");
|
||||
_stunLabel = GetNode<Label>("Status/StunBadge/Row/StunLabel");
|
||||
_zap = GetNode<TextureRect>("Zap");
|
||||
_fx = GetNode<Panel>("Fx");
|
||||
_selection = GetNode<TextureRect>("Selection");
|
||||
|
||||
Size = CustomMinimumSize;
|
||||
_baseScale = Scale;
|
||||
|
||||
defaultWidth = 230f;
|
||||
selectionState = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
if (!_texturesLoaded)
|
||||
{
|
||||
_cachedSelectTex = Textures.Resolve("select");
|
||||
_texturesLoaded = true;
|
||||
}
|
||||
|
||||
if (!selectTexLoaded)
|
||||
{
|
||||
cachedSelectTex = Textures.Resolve("select");
|
||||
selectTexLoaded = true;
|
||||
}
|
||||
if (cachedSelectTex != null)
|
||||
{
|
||||
selection.Texture = cachedSelectTex;
|
||||
selection.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize;
|
||||
selection.StretchMode = TextureRect.StretchModeEnum.Scale;
|
||||
}
|
||||
if (_cachedSelectTex != null)
|
||||
_selection.Texture = _cachedSelectTex;
|
||||
|
||||
ApplyStyles();
|
||||
_selectionState = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
|
||||
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.
|
||||
/// Binds the card to a character: sets the portrait, resets selection
|
||||
/// visuals and refreshes every stat label through <see cref="UpdateInfo"/>.
|
||||
/// Called by <see cref="BattleCardPanel.Create"/>.
|
||||
/// </summary>
|
||||
public void Init(Character characterCard)
|
||||
{
|
||||
Card = characterCard;
|
||||
_baseScale = Scale;
|
||||
IsDead = false;
|
||||
|
||||
if (image != null)
|
||||
image.Texture = characterCard.Image;
|
||||
if (_art != null && characterCard?.Image != null)
|
||||
_art.Texture = characterCard.Image;
|
||||
|
||||
if (frame != null && characterCard?.Image != null)
|
||||
frame.Texture = characterCard.Image;
|
||||
if (_selection != null)
|
||||
_selection.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
|
||||
if (selection != null)
|
||||
selection.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
|
||||
selectionState = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
if (_fx != null)
|
||||
_fx.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
if (_zap != null)
|
||||
_zap.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
|
||||
_selectionState = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
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.
|
||||
/// Paints the team frame: blue for the party, gold for enemies — the
|
||||
/// same accent language the mockup uses for card outlines. Called by
|
||||
/// <see cref="BattleCardPanel.Create"/> after <see cref="Init"/>.
|
||||
/// </summary>
|
||||
public void SetTeamColors()
|
||||
{
|
||||
if (teamOutline != null)
|
||||
teamOutline.Color = IsPlayer
|
||||
? UiTokens.Alpha(UiTokens.Info, 0.5f)
|
||||
: UiTokens.Alpha(UiTokens.Accent, 0.5f);
|
||||
var accent = IsPlayer ? UiTokens.Info : UiTokens.Accent;
|
||||
var style = UiKit.Frame(UiTokens.RadiusMd, new Color(0f, 0f, 0f, 0f), UiTokens.Alpha(accent, 0.5f), 2);
|
||||
_teamFrame.AddThemeStyleboxOverride("panel", style);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Refreshes every mutable value from the character model: name, HP
|
||||
/// number and bar (turning red below 40%), defence, attack, action
|
||||
/// points and the stun badge with its remaining turns. Called after each
|
||||
/// action, on turn start, and by the panel after re-gridding.
|
||||
/// </summary>
|
||||
public void UpdateInfo()
|
||||
{
|
||||
if (Card?.Attributes == null)
|
||||
return;
|
||||
|
||||
_nameLabel.Text = TranslationServer.Translate(Card.Name ?? string.Empty).ToString();
|
||||
|
||||
var health = Card.Attributes.Health;
|
||||
var percent = Mathf.Clamp(health.Percent, 0f, 1f);
|
||||
_hpBar.Value = percent * 100f;
|
||||
_hpBar.AddThemeStyleboxOverride("background", UiKit.Frame(UiTokens.RadiusSm, UiTokens.LineDisabled));
|
||||
_hpBar.AddThemeStyleboxOverride("fill", UiKit.Frame(UiTokens.RadiusSm, percent < 0.4f ? UiTokens.HpLow : UiTokens.HpOk));
|
||||
|
||||
_hpValue.Text = health.Value + "/" + health.BaseValue;
|
||||
_defValue.Text = Card.Attributes.Defence.Value.ToString();
|
||||
_atkValue.Text = Card.Attributes.Attack.Value.ToString();
|
||||
_apValue.Text = Card.Attributes.Point.Value.ToString();
|
||||
|
||||
RefreshStun();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the base Z-index used for layering cards. Selected cards jump to
|
||||
/// Z-index 4096 in <see cref="Select"/>; hovered cards rise by 100 so
|
||||
/// they cover their neighbours.
|
||||
/// </summary>
|
||||
public void SetBaseZIndex(int z)
|
||||
{
|
||||
@@ -161,120 +203,176 @@ namespace Cthangover.CardBattle.UI
|
||||
ZIndex = z;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a target-accent 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 <see cref="UiTokens.TypeTarget"/>. Called when the player clicks a character card
|
||||
/// or when a valid support/self target is dragged over.
|
||||
/// </summary>
|
||||
/// <summary>Highlights the card as the chosen source or a valid support target (teal).</summary>
|
||||
public void Select()
|
||||
{
|
||||
_selected = true;
|
||||
selectionState = UiTokens.TypeTarget;
|
||||
AnimateSelection(selectionState);
|
||||
if (selection != null) selection.ZIndex = 1;
|
||||
_selectionState = UiTokens.TypeTarget;
|
||||
AnimateSelection(_selectionState);
|
||||
ZIndex = 4096;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the selection highlight, restores Z-index to <see cref="SetBaseZIndex"/> value,
|
||||
/// and fades the selection overlay to transparent.
|
||||
/// </summary>
|
||||
/// <summary>Clears the selection highlight and restores the base Z-index.</summary>
|
||||
public void Unselect()
|
||||
{
|
||||
_selected = false;
|
||||
selectionState = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
AnimateSelection(selectionState);
|
||||
if (selection != null) selection.ZIndex = 0;
|
||||
_selectionState = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
AnimateSelection(_selectionState);
|
||||
ZIndex = _baseZIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies an enemy-accent highlight indicating this card is being targeted for an attack.
|
||||
/// Called by <see cref="ICardActionStrategy.HighlightTarget"/> during drag-over.
|
||||
/// </summary>
|
||||
/// <summary>Highlights a valid enemy target for an attack (red).</summary>
|
||||
public void Attack()
|
||||
{
|
||||
selectionState = UiTokens.TypeEnemy;
|
||||
AnimateSelection(selectionState);
|
||||
if (selection != null) selection.ZIndex = 1;
|
||||
_selectionState = UiTokens.TypeEnemy;
|
||||
AnimateSelection(_selectionState);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <summary>Grays out the card as an invalid target for the dragged action.</summary>
|
||||
public void Invalid()
|
||||
{
|
||||
selectionState = UiTokens.Alpha(UiTokens.Muted, 0.8f);
|
||||
AnimateSelection(selectionState);
|
||||
if (selection != null) selection.ZIndex = 1;
|
||||
_selectionState = UiTokens.Alpha(UiTokens.Muted, 0.8f);
|
||||
AnimateSelection(_selectionState);
|
||||
}
|
||||
|
||||
/// <summary>Plays the electric stun flash over the portrait (additive blend).</summary>
|
||||
public void PlayZap()
|
||||
{
|
||||
if (_zap == null)
|
||||
return;
|
||||
|
||||
_zap.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
var tween = CreateTween();
|
||||
tween.TweenProperty(_zap, "modulate:a", 1f, 0.12f);
|
||||
tween.TweenProperty(_zap, "modulate:a", 0f, 0.45f).SetDelay(0.25f);
|
||||
}
|
||||
|
||||
/// <summary>Pulses the whole card with a colour (damage red, shield blue).</summary>
|
||||
public void Flash(Color color)
|
||||
{
|
||||
_fx.Modulate = UiTokens.Alpha(color, 0f);
|
||||
var tween = CreateTween();
|
||||
tween.TweenProperty(_fx, "modulate:a", 0.55f, 0.12f);
|
||||
tween.TweenProperty(_fx, "modulate:a", 0f, 0.38f);
|
||||
}
|
||||
|
||||
private void ApplyStyles()
|
||||
{
|
||||
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<PanelContainer>("Info").AddThemeStyleboxOverride("panel", infoStyle);
|
||||
|
||||
_nameLabel.AddThemeColorOverride("font_color", UiTokens.InkBright);
|
||||
|
||||
StyleChip("Info/Column/Stats/HpChip", UiTokens.HpText);
|
||||
StyleChip("Info/Column/Stats/DefChip", UiTokens.Ink);
|
||||
StyleChip("Info/Column/Stats/AtkChip", UiTokens.Ink);
|
||||
StyleChip("Info/Column/Stats/ApChip", UiTokens.AccentBright);
|
||||
|
||||
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);
|
||||
|
||||
_zap.Material = new CanvasItemMaterial { BlendMode = CanvasItemMaterial.BlendModeEnum.Add };
|
||||
_zap.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
_fx.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
_selection.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
|
||||
}
|
||||
|
||||
private void StyleChip(string path, Color valueColor)
|
||||
{
|
||||
var chip = GetNode<PanelContainer>(path);
|
||||
var style = UiKit.Frame(UiTokens.RadiusXs, UiTokens.StatBg, UiTokens.StatLine);
|
||||
style.ContentMarginLeft = 6;
|
||||
style.ContentMarginRight = 6;
|
||||
style.ContentMarginTop = 2;
|
||||
style.ContentMarginBottom = 2;
|
||||
chip.AddThemeStyleboxOverride("panel", style);
|
||||
|
||||
var row = chip.GetNode<HBoxContainer>("Row");
|
||||
var key = row.GetNode<Label>("Key");
|
||||
key.Text = TranslationServer.Translate(key.Text).ToString();
|
||||
key.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||
row.GetNode<Label>("Value").AddThemeColorOverride("font_color", valueColor);
|
||||
}
|
||||
|
||||
private void RefreshStun()
|
||||
{
|
||||
var stun = StatusEffectService.Of(Card)?.ActiveStatusEffects?
|
||||
.FirstOrDefault(effect => effect.EffectType == StatusEffectType.Stun);
|
||||
|
||||
if (stun == null)
|
||||
{
|
||||
_stunBadge.Visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_stunBadge.Visible = true;
|
||||
if (stun.Icon != null)
|
||||
{
|
||||
// Текстура приходит из данных эффекта: группа "characters" не
|
||||
// входит в texture_groups, поэтому Textures.Resolve("stun") её
|
||||
// не видит, а StatusEffectInfo.Icon грузится через TextureUtils.
|
||||
_stunIcon.Texture = stun.Icon;
|
||||
_zap.Texture = stun.Icon;
|
||||
}
|
||||
_stunLabel.Text = string.Format(
|
||||
TranslationServer.Translate("battle/stun_status").ToString(), stun.RemainingTurns);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
if (_selection == null)
|
||||
return;
|
||||
|
||||
/// <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);
|
||||
_selectionTween?.Kill();
|
||||
_selectionTween = CreateTween();
|
||||
_selectionTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad);
|
||||
_selectionTween.TweenProperty(_selection, "modulate", target, 0.15f);
|
||||
}
|
||||
|
||||
private void OnMouseEntered()
|
||||
{
|
||||
if (isHovered) return;
|
||||
isHovered = true;
|
||||
baseY = Position.Y;
|
||||
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);
|
||||
_hoverTween?.Kill();
|
||||
_hoverTween = CreateTween();
|
||||
_hoverTween.SetParallel(true);
|
||||
_hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
|
||||
_hoverTween.TweenProperty(this, "position:y", _baseY - HoverLift, 0.15f);
|
||||
_hoverTween.TweenProperty(this, "scale", _baseScale * HoverScale, 0.15f);
|
||||
}
|
||||
|
||||
private void OnMouseExited()
|
||||
{
|
||||
if (!isHovered) return;
|
||||
isHovered = false;
|
||||
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);
|
||||
_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,11 @@ namespace Cthangover.CardBattle.UI
|
||||
|
||||
Size = CustomMinimumSize;
|
||||
|
||||
_button.Text = TranslationServer.Translate("battle/end_turn");
|
||||
_button.Pressed += () => OnPressed?.Invoke();
|
||||
_button.Pressed += RaisePressed;
|
||||
}
|
||||
|
||||
private void RaisePressed() => OnPressed?.Invoke();
|
||||
|
||||
/// <summary>
|
||||
/// Shows or hides the underlying <see cref="Button"/> control.
|
||||
/// Called by <see cref="CardBattleCore"/> during turn transitions and battle cleanup.
|
||||
|
||||
Reference in new issue
Block a user