new battle gui

This commit is contained in:
cthangover committed 2026-09-28 10:48:06 +03:00
1 parent 86e3de904e
commit ba2c08b1dc
22 files changed
+1613 -120

No files matched your search

+46
View File
@@ -12,3 +12,49 @@ ff_battle/select_target = Select a target
ff_battle/escape_fail = Escape failed!
ff_battle/escape_success = You escaped!
ff_battle/no_actions = No actions
ff_battle/hud_title = Battle
ff_battle/round = Round
ff_battle/enemies = Enemies
ff_battle/allies = Party
ff_battle/phase_player = Player turn
ff_battle/phase_enemy = Enemy turn
ff_battle/banner_player = Player turn
ff_battle/banner_enemy = Enemy turn
ff_battle/hint_target = Select a target · RMB / Esc — cancel
ff_battle/hint_ally = Select an ally · RMB / Esc — cancel
ff_battle/menu_caption = {0} · AP {1}/{2}
ff_battle/menu_actions = Actions
ff_battle/log_title = Battle log
ff_battle/log_round = round {0}
ff_battle/log_battle_start = Battle started
ff_battle/log_turn_player = — Player turn · round {0} —
ff_battle/log_turn_enemy = — Enemy turn · round {0} —
ff_battle/log_action = {0}: {1} → {2}
ff_battle/log_action_self = {0}: {1}
ff_battle/log_damage = {0}: {1} → {2} · -{3} HP
ff_battle/log_shield = {0}'s shield absorbed {1} · left {2}
ff_battle/log_defence = Defend: {0} · +{1} DEF (total {2})
ff_battle/log_stun = Stunned: {0} · {1} turns
ff_battle/log_stun_skip = {0} is stunned — skips the turn
ff_battle/log_heal = {0}: {1} restored {2} HP
ff_battle/log_death = {0} defeated
ff_battle/log_escape_success = Escape succeeded
ff_battle/log_escape_failed = Escape failed! {0} loses 1 AP
ff_battle/log_no_points = The party is out of AP
ff_battle/detail_attack = Character action submenu. AP cost is shown next to the name.
ff_battle/detail_items = 1 AP per item. Targeted items require an ally.
ff_battle/detail_defend = Shortcut: the ally-target action is applied to self. Shield +4 DEF.
ff_battle/detail_escape = 70% success chance. On failure the character loses 1 AP.
ff_battle/detail_back = Return to the main command menu.
ff_battle/detail_action = Special action without numeric parameters.
ff_battle/detail_item = An item from the shared inventory.
ff_battle/detail_damage = Damage {0} (ATK {1} × {2}) · cost {3} AP
ff_battle/detail_stun = Stun for {0} turns · cost {1} AP
ff_battle/detail_defence = Target shield +{0} · cost {1} AP
ff_battle/status_stun = Stunned {0}
ff_battle/badge_turns = {0} turns
ff_battle/badge_self = Self
+46
View File
@@ -12,3 +12,49 @@ ff_battle/select_target=Выберите цель
ff_battle/escape_fail=Побег не удался!
ff_battle/escape_success=Вы сбежали!
ff_battle/no_actions=Нет действий
ff_battle/hud_title=Бой
ff_battle/round=Раунд
ff_battle/enemies=Врагов
ff_battle/allies=Отряд
ff_battle/phase_player=Ход игрока
ff_battle/phase_enemy=Ход врага
ff_battle/banner_player=Ход игрока
ff_battle/banner_enemy=Ход врага
ff_battle/hint_target=Выберите цель · ПКМ / Esc — отмена
ff_battle/hint_ally=Выберите союзника · ПКМ / Esc — отмена
ff_battle/menu_caption={0} · ОД {1}/{2}
ff_battle/menu_actions=Действия
ff_battle/log_title=Журнал боя
ff_battle/log_round=раунд {0}
ff_battle/log_battle_start=Бой начался
ff_battle/log_turn_player=— Ход игрока · раунд {0} —
ff_battle/log_turn_enemy=— Ход врага · раунд {0} —
ff_battle/log_action={0}: {1} → {2}
ff_battle/log_action_self={0}: {1}
ff_battle/log_damage={0}: {1} → {2} · −{3} HP
ff_battle/log_shield=Щит {0} поглотил {1} · осталось {2}
ff_battle/log_defence=Защита: {0} · +{1} ЗАЩ (всего {2})
ff_battle/log_stun=Ошеломление: {0} · {1} хода
ff_battle/log_stun_skip={0} ошеломлён — пропускает ход
ff_battle/log_heal={0}: {1} восстановил {2} HP
ff_battle/log_death={0} повержен
ff_battle/log_escape_success=Побег удался
ff_battle/log_escape_failed=Побег не удался! {0} теряет 1 ОД
ff_battle/log_no_points=У отряда закончились ОД
ff_battle/detail_attack=Подменю действий персонажа. Стоимость в ОД указана справа от названия.
ff_battle/detail_items=Расход 1 ОД за предмет. Предметы «по цели» требуют выбора союзника.
ff_battle/detail_defend=Шорткат: действие «по союзнику» применяется на себя. Щит +4 ЗАЩ.
ff_battle/detail_escape=Шанс успеха 70%. При провале персонаж теряет 1 ОД.
ff_battle/detail_back=Вернуться в главное меню команд.
ff_battle/detail_action=Особое действие без числовых параметров.
ff_battle/detail_item=Предмет из общего инвентаря.
ff_battle/detail_damage=Урон {0} (АТК {1} × {2}) · расход {3} ОД
ff_battle/detail_stun=Ошеломление на {0} хода · расход {1} ОД
ff_battle/detail_defence=Щит цели +{0} · расход {1} ОД
ff_battle/status_stun=Ошеломлён {0}
ff_battle/badge_turns={0} хода
ff_battle/badge_self=На себя
+94
View File
@@ -0,0 +1,94 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/FFActionDetailPanel.cs" id="1_script"]
[node name="FFActionDetailPanel" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(300, 160)
layout_mode = 3
mouse_filter = 2
[node name="Box" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme_override_constants/separation = 10
[node name="Card" type="PanelContainer" parent="Box"]
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="CardRow" type="HBoxContainer" parent="Box/Card"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 0
[node name="Stripe" type="ColorRect" parent="Box/Card/CardRow"]
custom_minimum_size = Vector2(4, 0)
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 2
[node name="IconMargin" type="MarginContainer" parent="Box/Card/CardRow"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_left = 8
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 12
theme_override_constants/margin_bottom = 8
[node name="CardIcon" type="TextureRect" parent="Box/Card/CardRow/IconMargin"]
custom_minimum_size = Vector2(52, 52)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
expand_mode = 1
stretch_mode = 5
[node name="Body" type="VBoxContainer" parent="Box/Card/CardRow"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
mouse_filter = 2
theme_override_constants/separation = 4
[node name="NameRow" type="HBoxContainer" parent="Box/Card/CardRow/Body"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="NameLabel" type="Label" parent="Box/Card/CardRow/Body/NameRow"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
theme_override_font_sizes/font_size = 14
clip_text = true
text_overrun_behavior = 3
[node name="IdLabel" type="Label" parent="Box/Card/CardRow/Body/NameRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 9
[node name="Badges" type="HFlowContainer" parent="Box/Card/CardRow/Body"]
layout_mode = 2
theme_override_constants/h_separation = 5
theme_override_constants/v_separation = 5
[node name="Props" type="HFlowContainer" parent="Box/Card/CardRow/Body"]
layout_mode = 2
theme_override_constants/h_separation = 5
theme_override_constants/v_separation = 3
[node name="Note" type="PanelContainer" parent="Box"]
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="NoteLabel" type="Label" parent="Box/Note"]
layout_mode = 2
theme_override_font_sizes/font_size = 12
autowrap_mode = 3
+26
View File
@@ -0,0 +1,26 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/FFBattleBanner.cs" id="1_script"]
[node name="FFBattleBanner" type="Control" script=ExtResource("1_script")]
layout_mode = 3
mouse_filter = 2
[node name="Center" type="CenterContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Panel" type="PanelContainer" parent="Center"]
layout_mode = 2
mouse_filter = 2
[node name="Label" type="Label" parent="Center/Panel"]
layout_mode = 2
theme_override_font_sizes/font_size = 34
horizontal_alignment = 1
uppercase = true
+88
View File
@@ -0,0 +1,88 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/FFBattleHud.cs" id="1_script"]
[node name="FFBattleHud" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(380, 92)
layout_mode = 3
mouse_filter = 2
[node name="Panel" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Column" type="VBoxContainer" parent="Panel"]
layout_mode = 2
theme_override_constants/separation = 9
[node name="TopRow" type="HBoxContainer" parent="Panel/Column"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="TitleLabel" type="Label" parent="Panel/Column/TopRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "ff_battle/hud_title"
auto_translate = true
uppercase = true
[node name="PhaseBadge" type="PanelContainer" parent="Panel/Column/TopRow"]
layout_mode = 2
size_flags_horizontal = 8
size_flags_vertical = 4
mouse_filter = 2
[node name="PhaseLabel" type="Label" parent="Panel/Column/TopRow/PhaseBadge"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
uppercase = true
[node name="MetaRow" type="HBoxContainer" parent="Panel/Column"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="RoundCaption" type="Label" parent="Panel/Column/MetaRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "ff_battle/round"
auto_translate = true
[node name="RoundValue" type="Label" parent="Panel/Column/MetaRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "1"
[node name="EnemiesCaption" type="Label" parent="Panel/Column/MetaRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "ff_battle/enemies"
auto_translate = true
[node name="EnemiesValue" type="Label" parent="Panel/Column/MetaRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "0"
[node name="AlliesCaption" type="Label" parent="Panel/Column/MetaRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "ff_battle/allies"
auto_translate = true
[node name="AlliesValue" type="Label" parent="Panel/Column/MetaRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "0"
+65
View File
@@ -0,0 +1,65 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/FFBattleLogPanel.cs" id="1_script"]
[node name="FFBattleLogPanel" type="Control" script=ExtResource("1_script")]
layout_mode = 3
mouse_filter = 2
[node name="Window" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Column" type="VBoxContainer" parent="Window"]
layout_mode = 2
theme_override_constants/separation = 0
[node name="Header" type="MarginContainer" parent="Window/Column"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_left = 16
theme_override_constants/margin_top = 13
theme_override_constants/margin_right = 16
theme_override_constants/margin_bottom = 10
[node name="HeaderRow" type="HBoxContainer" parent="Window/Column/Header"]
layout_mode = 2
mouse_filter = 2
[node name="TitleLabel" type="Label" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "ff_battle/log_title"
auto_translate = true
uppercase = true
[node name="Spacer" type="Control" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
[node name="RoundLabel" type="Label" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
[node name="Rule" type="HSeparator" parent="Window/Column"]
layout_mode = 2
[node name="Scroll" type="ScrollContainer" parent="Window/Column"]
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
mouse_filter = 2
[node name="Rows" type="VBoxContainer" parent="Window/Column/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 0
mouse_filter = 2
+35
View File
@@ -0,0 +1,35 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/FFBattleTargetHint.cs" id="1_script"]
[node name="FFBattleTargetHint" type="Control" script=ExtResource("1_script")]
layout_mode = 3
mouse_filter = 2
[node name="Center" type="CenterContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Toast" type="PanelContainer" parent="Center"]
layout_mode = 2
mouse_filter = 2
[node name="Row" type="HBoxContainer" parent="Center/Toast"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="IconLabel" type="Label" parent="Center/Toast/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 14
text = "◎"
[node name="TextLabel" type="Label" parent="Center/Toast/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 13
+132 -28
View File
@@ -5,51 +5,155 @@
[node name="FFCharacterWidget" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(180, 260)
layout_mode = 3
mouse_filter = 0
[node name="Sprite" type="TextureRect" parent="."]
[node name="TeamLine" type="ColorRect" parent="."]
layout_mode = 1
anchor_right = 1.0
offset_bottom = 2.0
mouse_filter = 2
[node name="Art" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 3.0
offset_top = 3.0
offset_right = -3.0
offset_bottom = -92.0
grow_horizontal = 2
grow_vertical = 2
offset_left = 8.0
offset_top = 8.0
offset_right = -8.0
offset_bottom = -44.0
mouse_filter = 2
expand_mode = 1
stretch_mode = 5
[node name="HpBg" type="ColorRect" parent="."]
layout_mode = 0
offset_left = 4.0
offset_top = 218.0
offset_right = 176.0
offset_bottom = 236.0
[node name="Info" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 12
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = -92.0
grow_horizontal = 2
grow_vertical = 0
mouse_filter = 2
color = Color(0.137255, 0.145098, 0.184314, 1)
[node name="HpFill" type="ColorRect" parent="."]
layout_mode = 0
offset_left = 4.0
offset_top = 218.0
offset_right = 176.0
offset_bottom = 236.0
[node name="Column" type="VBoxContainer" parent="Info"]
layout_mode = 2
theme_override_constants/separation = 5
[node name="NameRow" type="HBoxContainer" parent="Info/Column"]
layout_mode = 2
theme_override_constants/separation = 6
[node name="TeamDot" type="Panel" parent="Info/Column/NameRow"]
custom_minimum_size = Vector2(7, 7)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
color = Color(0.560784, 0.682353, 0.419608, 1)
[node name="NameLabel" type="Label" parent="."]
layout_mode = 0
offset_left = 4.0
offset_top = 238.0
offset_right = 176.0
offset_bottom = 256.0
[node name="NameLabel" type="Label" parent="Info/Column/NameRow"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
theme_override_font_sizes/font_size = 14
clip_text = true
text_overrun_behavior = 3
[node name="HpNumLabel" type="Label" parent="Info/Column/NameRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 11
[node name="HpBar" type="ProgressBar" parent="Info/Column"]
custom_minimum_size = Vector2(0, 8)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
horizontal_alignment = 1
theme_override_font_sizes/font_size = 13
show_percentage = false
[node name="Selection" type="TextureRect" parent="."]
[node name="Foot" type="HBoxContainer" parent="Info/Column"]
layout_mode = 2
theme_override_constants/separation = 6
[node name="PipsBox" type="HBoxContainer" parent="Info/Column/Foot"]
layout_mode = 2
size_flags_vertical = 4
theme_override_constants/separation = 4
[node name="AtkLabel" type="Label" parent="Info/Column/Foot"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
[node name="Spacer" type="Control" parent="Info/Column/Foot"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
[node name="ShieldChip" type="PanelContainer" parent="Info/Column/Foot"]
visible = false
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="ShieldRow" type="HBoxContainer" parent="Info/Column/Foot/ShieldChip"]
layout_mode = 2
theme_override_constants/separation = 4
[node name="ShieldKey" type="Label" parent="Info/Column/Foot/ShieldChip/ShieldRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
text = "ui/character_panel/stat_def"
auto_translate = true
uppercase = true
[node name="ShieldValue" type="Label" parent="Info/Column/Foot/ShieldChip/ShieldRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 11
text = "0"
[node name="StatusBox" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -140.0
offset_top = 8.0
offset_right = -8.0
offset_bottom = 40.0
grow_horizontal = 0
mouse_filter = 2
theme_override_constants/separation = 4
[node name="StunBadge" type="PanelContainer" parent="StatusBox"]
visible = false
layout_mode = 2
size_flags_horizontal = 8
mouse_filter = 2
[node name="StunRow" type="HBoxContainer" parent="StatusBox/StunBadge"]
layout_mode = 2
theme_override_constants/separation = 5
[node name="StunIcon" type="TextureRect" parent="StatusBox/StunBadge/StunRow"]
custom_minimum_size = Vector2(14, 14)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
expand_mode = 1
stretch_mode = 5
[node name="StunLabel" type="Label" parent="StatusBox/StunBadge/StunRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
uppercase = true
[node name="Selection" type="Panel" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
+10
View File
@@ -34,3 +34,13 @@ mouse_filter = 2
visible = false
theme_override_font_sizes/font_size = 18
theme_override_colors/font_color = Color(0.941176, 0.756863, 0.411765, 1)
[node name="Caption" type="Label" parent="."]
offset_left = 2.0
offset_top = -25.0
offset_right = 218.0
offset_bottom = -7.0
mouse_filter = 2
visible = false
theme_override_font_sizes/font_size = 11
uppercase = true
+3 -2
View File
@@ -1,3 +1,4 @@
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters;
using Cthangover.Core.Utils;
using Godot;
@@ -23,8 +24,8 @@ namespace Cthangover.FFBattle.Actions
return new ChangedAttributes { Result = false };
var damageDelta = Mathf.RoundToInt(action.GetFloat("Attack", 1f) * user.Attributes.Attack.Value);
target.StatusEffectQueue.OnTakeDamage(user, ref damageDelta);
user.StatusEffectQueue.OnDealDamage(target, ref damageDelta);
StatusEffectService.Of(target).OnTakeDamage(user, ref damageDelta);
StatusEffectService.Of(user).OnDealDamage(target, ref damageDelta);
var defenceDelta = 0;
var ignoreArmor = user.HasPassiveEffect("IgnoreArmor");
+2 -2
View File
@@ -1,3 +1,4 @@
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters;
using Cthangover.Core.Utils;
@@ -23,8 +24,7 @@ namespace Cthangover.FFBattle.Actions
return new ChangedAttributes { Result = true };
var turns = action.GetInt("Turn", 3);
target.StatusEffectQueue.Add("effect/physics/stun", turns);
StatusEffectService.Of(target).Add("effect/physics/stun", turns);
GameLogger.Log("FF_BATTLE",
$"{user.Name} stuns {target.Name} for {turns} turns",
LogLevel.Debug);
+287 -10
View File
@@ -4,10 +4,13 @@ using System.Linq;
using System.Threading.Tasks;
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters;
using Cthangover.Core.Items;
using Cthangover.Core.Settings;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI;
using Cthangover.Core.UI.CharacterPanel;
using Cthangover.Core.Utils;
using Cthangover.FFBattle.UI;
using Cthangover.Core.UI.Theming;
@@ -38,6 +41,11 @@ namespace Cthangover.FFBattle
private FFPlayerPanel _playerPanel;
private FFEnemyPanel _enemyPanel;
private FFMenuPanel _menuPanel;
private FFActionDetailPanel _detailPanel;
private FFBattleHud _hud;
private FFBattleLogPanel _logPanel;
private FFBattleBanner _banner;
private FFBattleTargetHint _targetHint;
private FFBattleController _controller;
private Control _toolPanel;
@@ -49,6 +57,12 @@ namespace Cthangover.FFBattle
private bool _targetSelectMode;
private bool _targetAlly;
private System.Action<FFCharacterWidget> _onTargetSelected;
private int _round;
private int _detailKind;
private const int DetailMain = 0;
private const int DetailActions = 1;
private const int DetailItems = 2;
private const float PLAYER_SCALE = 0.85f;
@@ -91,6 +105,33 @@ namespace Cthangover.FFBattle
_menuPanel.HideMenu();
panel.AddChild(_menuPanel);
_detailPanel = (FFActionDetailPanel)TscnScenes.LoadAndBuild("scenes/ff_action_detail_panel.tscn");
_detailPanel.Name = "FFActionDetailPanel";
_detailPanel.EnsureConstructed();
_detailPanel.HidePassport();
panel.AddChild(_detailPanel);
_hud = (FFBattleHud)TscnScenes.LoadAndBuild("scenes/ff_battle_hud.tscn");
_hud.Name = "FFBattleHud";
_hud.EnsureConstructed();
panel.AddChild(_hud);
_logPanel = (FFBattleLogPanel)TscnScenes.LoadAndBuild("scenes/ff_battle_log_panel.tscn");
_logPanel.Name = "FFBattleLogPanel";
_logPanel.EnsureConstructed();
panel.AddChild(_logPanel);
_banner = (FFBattleBanner)TscnScenes.LoadAndBuild("scenes/ff_battle_banner.tscn");
_banner.Name = "FFBattleBanner";
_banner.EnsureConstructed();
panel.AddChild(_banner);
_targetHint = (FFBattleTargetHint)TscnScenes.LoadAndBuild("scenes/ff_battle_target_hint.tscn");
_targetHint.Name = "FFBattleTargetHint";
_targetHint.EnsureConstructed();
_targetHint.HideHint();
panel.AddChild(_targetHint);
_endTurnButton = new Button();
_endTurnButton.Text = TranslationServer.Translate("ff_battle/end_turn");
_endTurnButton.Pressed += OnEndTurnPressed;
@@ -112,6 +153,7 @@ namespace Cthangover.FFBattle
_enemyPanel.OnEnemyClicked += OnEnemyClicked;
_enemyPanel.OnEnemyDead += OnEnemyDied;
_menuPanel.OnItemSelected += OnMenuItemSelected;
_menuPanel.OnSelectionChanged += OnMenuSelectionChanged;
_menuPanel.OnCancelled += OnMenuCancel;
}
@@ -138,6 +180,20 @@ namespace Cthangover.FFBattle
_menuPanel.Position = new Vector2(viewportSize.X * 0.66f, viewportSize.Y * 0.58f);
_menuPanel.Size = new Vector2(viewportSize.X * 0.3f, 160f);
_detailPanel.Position = new Vector2(viewportSize.X * 0.66f - 320f, viewportSize.Y * 0.58f);
_detailPanel.Size = new Vector2(300f, 190f);
_hud.Position = new Vector2(26f, 20f);
_logPanel.Position = new Vector2(viewportSize.X - 466f, 64f);
_logPanel.Size = new Vector2(440f, 440f);
_banner.Position = new Vector2(0f, viewportSize.Y * 0.43f);
_banner.Size = new Vector2(viewportSize.X, 70f);
_targetHint.Position = new Vector2(0f, viewportSize.Y * 0.48f);
_targetHint.Size = new Vector2(viewportSize.X, 40f);
if (_endTurnButton != null)
_endTurnButton.Position = new Vector2(viewportSize.X - 180f, viewportSize.Y * 0.92f);
@@ -147,6 +203,9 @@ namespace Cthangover.FFBattle
BattleSceneContext.Instance.OnBattleCleared += OnBattleCleared;
_isPlayerTurn = true;
_round = 0;
UpdateHud();
_logPanel.AddEntry(Translate("ff_battle/log_battle_start"), FFBattleLogKind.Turn);
StartPlayerTurn();
}
@@ -160,13 +219,15 @@ namespace Cthangover.FFBattle
_currentCharacter = null;
_targetSelectMode = false;
_menuPanel.HideMenu();
_detailPanel.HidePassport();
_targetHint.HideHint();
BattleSceneContext.Instance.ActiveCharacter = null;
foreach (var widget in _playerPanel.Widgets)
{
if (!widget.IsDead)
{
widget.Card.StatusEffectQueue.OnTurnStart();
StatusEffectService.Of(widget.Card).OnTurnStart();
widget.Card.Attributes.Point.Value = widget.Card.Attributes.Point.BaseValue;
}
widget.UpdateInfo();
@@ -180,6 +241,13 @@ namespace Cthangover.FFBattle
foreach (var widget in _playerPanel.Widgets)
widget.ClearHighlight();
_round += 1;
UpdateHud();
_hud.SetPhase(true);
_banner.Show(Translate("ff_battle/banner_player"));
_logPanel.SetRound(_round);
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_turn_player"), _round), FFBattleLogKind.Turn);
}
private void OnWidgetClicked(FFCharacterWidget widget)
@@ -201,6 +269,7 @@ namespace Cthangover.FFBattle
_targetSelectMode = false;
foreach (var w in _enemyPanel.Widgets) w.ClearHighlight();
foreach (var w in _playerPanel.Widgets) w.ClearHighlight();
_targetHint.HideHint();
ExecuteMenuAction(widget);
return;
@@ -250,12 +319,14 @@ namespace Cthangover.FFBattle
};
_currentMenuAction = null;
_menuPanel.ShowMenu(entries);
_detailKind = DetailMain;
_menuPanel.ShowMenu(entries, string.Format(Translate("ff_battle/menu_caption"),
ActorName(), _currentCharacter.Card.Attributes.Point.Value, _currentCharacter.Card.Attributes.Point.BaseValue));
_controller.State = FFBattleState.MenuOpen;
foreach (var widget in _playerPanel.Widgets)
widget.ClearHighlight();
_currentCharacter?.Highlight(UiTokens.Alpha(UiTokens.Info, 0.6f));
_currentCharacter?.Highlight(UiTokens.Accent);
}
private void OnMenuItemSelected(int index)
@@ -367,7 +438,8 @@ namespace Cthangover.FFBattle
Enabled = true
});
_menuPanel.ShowMenu(entries);
_detailKind = DetailActions;
_menuPanel.ShowMenu(entries, ActorName() + " · " + Translate("ff_battle/menu_actions"));
_controller.State = FFBattleState.SubMenuOpen;
}
@@ -408,7 +480,8 @@ namespace Cthangover.FFBattle
Enabled = true
});
_menuPanel.ShowMenu(entries);
_detailKind = DetailItems;
_menuPanel.ShowMenu(entries, ActorName() + " · " + Translate("ff_battle/menu_items"));
_controller.State = FFBattleState.SubMenuOpen;
}
@@ -418,6 +491,7 @@ namespace Cthangover.FFBattle
_targetAlly = targetAlly;
_menuPanel.HideMenu();
_controller.State = FFBattleState.TargetSelect;
_targetHint.ShowHint(Translate(targetAlly ? "ff_battle/hint_ally" : "ff_battle/hint_target"));
foreach (var widget in _playerPanel.Widgets)
widget.ClearHighlight();
@@ -428,13 +502,13 @@ namespace Cthangover.FFBattle
{
foreach (var widget in _playerPanel.Widgets)
if (!widget.IsDead)
widget.Highlight(UiTokens.Alpha(UiTokens.Success, 0.5f));
widget.Highlight(UiTokens.Success);
}
else
{
foreach (var widget in _enemyPanel.Widgets)
if (!widget.IsDead)
widget.Highlight(UiTokens.Alpha(UiTokens.Danger, 0.5f));
widget.Highlight(UiTokens.Danger);
}
_onTargetSelected = null;
@@ -451,6 +525,7 @@ namespace Cthangover.FFBattle
_targetSelectMode = false;
foreach (var w in _enemyPanel.Widgets) w.ClearHighlight();
foreach (var w in _playerPanel.Widgets) w.ClearHighlight();
_targetHint.HideHint();
ExecuteMenuAction(widget);
}
@@ -469,6 +544,7 @@ namespace Cthangover.FFBattle
_targetSelectMode = false;
foreach (var w in _enemyPanel.Widgets) w.ClearHighlight();
foreach (var w in _playerPanel.Widgets) w.ClearHighlight();
_targetHint.HideHint();
ExecuteMenuAction(widget);
}
@@ -480,12 +556,22 @@ namespace Cthangover.FFBattle
_controller.State = FFBattleState.AnimationPlaying;
_endTurnButton.Visible = false;
_detailPanel.HidePassport();
_targetHint.HideHint();
var source = _currentCharacter;
var actionName = string.Empty;
var hpBefore = target.Card.Attributes.Health.Value;
var defBefore = target.Card.Attributes.Defence.Value;
var stunBefore = CountStunTurns(target.Card);
switch (_currentMenuAction.Key)
{
case "action":
{
var action = _currentMenuAction.Data as ActionCharacter;
actionName = TranslationServer.Translate(action.Name).ToString();
LogActionDeclared(source, actionName, target);
IBattleAction anim = null;
if (action.Type == ActionCharacterType.ToEnemy)
@@ -504,6 +590,8 @@ namespace Cthangover.FFBattle
case "item":
{
var item = _currentMenuAction.Data as IItem;
actionName = TranslationServer.Translate(item.Name).ToString();
LogActionDeclared(source, actionName, target);
var dummyAction = new ActionCharacter { ID = "ff/item" };
var anim = new Actions.FFItemAnimation(_currentCharacter, target, dummyAction, item);
await RunAnimation(anim);
@@ -511,6 +599,8 @@ namespace Cthangover.FFBattle
}
}
LogOutcome(source, target, actionName, hpBefore, defBefore, stunBefore);
_currentMenuAction = null;
_endTurnButton.Visible = true;
@@ -537,12 +627,15 @@ namespace Cthangover.FFBattle
private void DoEscapeAction()
{
_menuPanel.HideMenu();
_detailPanel.HidePassport();
_controller.State = FFBattleState.Idle;
var escapeRoll = (float)GD.RandRange(0, 100);
if (escapeRoll < 70f)
{
GameLogger.Log("FF_BATTLE", "Escape succeeded", LogLevel.Debug);
_banner.Show(Translate("ff_battle/escape_success"));
_logPanel.AddEntry(Translate("ff_battle/log_escape_success"), FFBattleLogKind.Turn);
_ctx.EndBattle(BattleSide.Player);
}
else
@@ -550,6 +643,8 @@ namespace Cthangover.FFBattle
GameLogger.Log("FF_BATTLE", "Escape failed", LogLevel.Debug);
_currentCharacter.Card.Attributes.Point.Value -= 1;
_currentCharacter.UpdateInfo();
_banner.Show(Translate("ff_battle/escape_fail"));
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_escape_failed"), NameOf(_currentCharacter)), FFBattleLogKind.Status);
_currentCharacter = null;
BattleSceneContext.Instance.ActiveCharacter = null;
TryNextCharacterOrEndTurn();
@@ -588,6 +683,7 @@ namespace Cthangover.FFBattle
_targetSelectMode = false;
foreach (var w in _enemyPanel.Widgets) w.ClearHighlight();
foreach (var w in _playerPanel.Widgets) w.ClearHighlight();
_targetHint.HideHint();
ShowMainMenu();
return;
}
@@ -599,6 +695,7 @@ namespace Cthangover.FFBattle
}
_menuPanel.HideMenu();
_detailPanel.HidePassport();
_controller.State = FFBattleState.Idle;
_currentCharacter = null;
_currentMenuAction = null;
@@ -635,11 +732,12 @@ namespace Cthangover.FFBattle
if (widget.IsDead || widget.Card?.Attributes?.Point?.Value <= 0)
continue;
if (widget.Card.StatusEffectQueue.HasStun())
if (StatusEffectService.Of(widget.Card).HasStun())
{
GameLogger.Log("FF_BATTLE", $"{widget.Card.Name} is stunned, skipping", LogLevel.Debug);
widget.Card.Attributes.Point.Value = 0;
widget.UpdateInfo();
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_stun_skip"), NameOf(widget)), FFBattleLogKind.Status);
continue;
}
@@ -649,6 +747,7 @@ namespace Cthangover.FFBattle
return;
}
_logPanel.AddEntry(Translate("ff_battle/log_no_points"), FFBattleLogKind.Info);
OnPlayerTurnEnd();
}
@@ -668,6 +767,8 @@ namespace Cthangover.FFBattle
_isPlayerTurn = false;
_endTurnButton.Visible = false;
_menuPanel.HideMenu();
_detailPanel.HidePassport();
_targetHint.HideHint();
_controller.State = FFBattleState.AnimationPlaying;
BattleSceneContext.Instance.IsWait = true;
@@ -676,6 +777,10 @@ namespace Cthangover.FFBattle
foreach (var widget in _enemyPanel.Widgets)
widget.ClearHighlight();
_hud.SetPhase(false);
_banner.Show(Translate("ff_battle/banner_enemy"));
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_turn_enemy"), _round), FFBattleLogKind.Turn);
var root = (Node)_ctx.RootNode;
await root.ToSignal(root.GetTree(), "process_frame");
await root.ToSignal(root.GetTree().CreateTimer(0.5f), "timeout");
@@ -694,11 +799,13 @@ namespace Cthangover.FFBattle
if (enemyWidget.IsDead || enemyWidget.Card?.Actions == null || enemyWidget.Card.Actions.Count == 0)
continue;
enemyWidget.Card.StatusEffectQueue.OnTurnStart();
StatusEffectService.Of(enemyWidget.Card).OnTurnStart();
enemyWidget.UpdateInfo();
if (enemyWidget.Card.StatusEffectQueue.HasStun())
if (StatusEffectService.Of(enemyWidget.Card).HasStun())
{
GameLogger.Log("FF_BATTLE", $"{enemyWidget.Card.Name} is stunned, skipping", LogLevel.Debug);
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_stun_skip"), NameOf(enemyWidget)), FFBattleLogKind.Status);
continue;
}
@@ -716,6 +823,12 @@ namespace Cthangover.FFBattle
$"Enemy: {enemyWidget.Card.Name} → {action.Name} → {target.Card.Name}",
LogLevel.Debug);
var actionName = TranslationServer.Translate(action.Name).ToString();
LogActionDeclared(enemyWidget, actionName, target);
var hpBefore = target.Card.Attributes.Health.Value;
var defBefore = target.Card.Attributes.Defence.Value;
var stunBefore = CountStunTurns(target.Card);
var anim = CreateEnemyAnimation(enemyWidget, target, action);
if (anim != null)
{
@@ -725,6 +838,8 @@ namespace Cthangover.FFBattle
anim.DoEnd();
}
LogOutcome(enemyWidget, target, actionName, hpBefore, defBefore, stunBefore);
await root.ToSignal(root.GetTree().CreateTimer(0.2f), "timeout");
if (!_playerPanel.HasAlive())
@@ -803,6 +918,8 @@ namespace Cthangover.FFBattle
ctx.NotifyCharacterDied(widget.Card);
}
UpdateHudCounts();
if (!_enemyPanel.HasAlive())
{
GameLogger.Log("FF_BATTLE", "All enemies dead — player wins", LogLevel.Debug);
@@ -852,10 +969,170 @@ namespace Cthangover.FFBattle
_playerPanel?.ClearAll();
_enemyPanel?.ClearAll();
_menuPanel?.HideMenu();
_detailPanel?.HidePassport();
_targetHint?.HideHint();
_logPanel?.Clear();
if (_endTurnButton != null)
_endTurnButton.Visible = false;
}
private void OnMenuSelectionChanged(int index)
{
if (_menuPanel.Entries == null || index < 0 || index >= _menuPanel.Entries.Count)
{
_detailPanel.HidePassport();
return;
}
var entry = _menuPanel.Entries[index];
_detailPanel.ShowPassport();
if (_detailKind == DetailActions)
{
if (entry.Data is ActionCharacter action)
_detailPanel.ShowAction(action, BuildActionNote(action));
else
_detailPanel.ShowHint(Translate("ff_battle/detail_back"));
return;
}
if (_detailKind == DetailItems)
{
if (entry.Data is IItem item)
_detailPanel.ShowItem(item, item.ItemType.HasFlag(ItemType.TargetUsed), BuildItemNote(item));
else
_detailPanel.ShowHint(Translate("ff_battle/detail_back"));
return;
}
_detailPanel.ShowHint(MainHintFor(entry.Key));
}
private string BuildActionNote(ActionCharacter action)
{
if (action.Properties == null)
return TranslationServer.Translate("ff_battle/detail_action").ToString();
var cost = action.GetInt("RequiredPoint", 1);
var attack = action.GetFloat("Attack", 0f);
if (attack > 0f)
{
var attacker = _currentCharacter?.Card?.Attributes?.Attack?.Value ?? 0;
return string.Format(Translate("ff_battle/detail_damage"),
Mathf.RoundToInt(attack * attacker), attacker, attack.ToString("0.0"), cost);
}
var turns = action.GetInt("Turn", 0);
if (turns > 0)
return string.Format(Translate("ff_battle/detail_stun"), turns, cost);
var defence = action.GetInt("Defence", 0);
if (defence > 0)
return string.Format(Translate("ff_battle/detail_defence"), defence, cost);
var description = ActionCharacterUi.StripMarkup(ActionCharacterUi.Translate(action.Description));
return !string.IsNullOrEmpty(description) ? description : Translate("ff_battle/detail_action");
}
private static string BuildItemNote(IItem item)
{
var description = ActionCharacterUi.StripMarkup(ActionCharacterUi.Translate(item.Description));
return !string.IsNullOrEmpty(description)
? description
: TranslationServer.Translate("ff_battle/detail_item").ToString();
}
private static string MainHintFor(string key)
{
switch (key)
{
case "attack": return TranslationServer.Translate("ff_battle/detail_attack").ToString();
case "items": return TranslationServer.Translate("ff_battle/detail_items").ToString();
case "defend": return TranslationServer.Translate("ff_battle/detail_defend").ToString();
case "escape": return TranslationServer.Translate("ff_battle/detail_escape").ToString();
default: return TranslationServer.Translate("ff_battle/detail_back").ToString();
}
}
private void LogActionDeclared(FFCharacterWidget source, string actionName, FFCharacterWidget target)
{
var entry = source == target
? string.Format(Translate("ff_battle/log_action_self"), NameOf(source), actionName)
: string.Format(Translate("ff_battle/log_action"), NameOf(source), actionName, NameOf(target));
_logPanel.AddEntry(entry, FFBattleLogKind.Info);
}
private void LogOutcome(FFCharacterWidget source, FFCharacterWidget target, string actionName,
int hpBefore, int defBefore, int stunBefore)
{
var hpAfter = target.Card.Attributes.Health.Value;
var defAfter = target.Card.Attributes.Defence.Value;
var stunAfter = CountStunTurns(target.Card);
if (defAfter > defBefore)
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_defence"),
NameOf(target), defAfter - defBefore, defAfter), FFBattleLogKind.Buff);
var shieldLost = defBefore - defAfter;
if (shieldLost > 0)
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_shield"),
NameOf(target), shieldLost, defAfter), FFBattleLogKind.Shield);
if (stunAfter > stunBefore)
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_stun"),
NameOf(target), stunAfter), FFBattleLogKind.Status);
if (hpAfter > hpBefore)
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_heal"),
actionName, NameOf(target), hpAfter - hpBefore), FFBattleLogKind.Buff);
if (hpAfter < hpBefore)
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_damage"),
actionName, NameOf(source), NameOf(target), hpBefore - hpAfter), FFBattleLogKind.Damage);
if (hpAfter <= 0 && hpBefore > 0)
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_death"), NameOf(target)), FFBattleLogKind.Dead);
UpdateHudCounts();
}
private static int CountStunTurns(Character character)
{
var stun = StatusEffectService.Of(character)?.ActiveStatusEffects?
.FirstOrDefault(effect => effect.EffectType == StatusEffectType.Stun);
return stun?.RemainingTurns ?? 0;
}
private string ActorName()
=> _currentCharacter?.Card == null
? string.Empty
: TranslationServer.Translate(_currentCharacter.Card.Name).ToString();
private static string NameOf(FFCharacterWidget widget)
=> widget?.Card == null
? string.Empty
: TranslationServer.Translate(widget.Card.Name).ToString();
private void UpdateHud()
{
if (_hud == null)
return;
_hud.SetRound(_round);
UpdateHudCounts();
}
private void UpdateHudCounts()
{
if (_hud == null)
return;
_hud.SetCounts(
_enemyPanel.Widgets.Count(w => !w.IsDead),
_playerPanel.Widgets.Count(w => !w.IsDead));
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
/// <summary>
/// <c>true</c> while the core accepts a player turn advance; flips to
/// <c>false</c> synchronously at the start of <see cref="OnPlayerTurnEnd"/>.
+179
View File
@@ -0,0 +1,179 @@
using Cthangover.Core.Characters;
using Cthangover.Core.Items;
using Cthangover.Core.UI;
using Cthangover.Core.UI.CharacterPanel;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.FFBattle.UI
{
/// <summary>
/// Action/item passport shown to the left of the command menu. It mirrors the
/// action-card component of the unified style guide: a type accent stripe, the
/// action icon, name and ID, type/cost/cooldown badges, property chips and a
/// note line with the computed effect preview. The panel exists so the small
/// FF-style menu can stay a compact list while the player still sees what the
/// highlighted command actually does. Node structure lives in
/// <c>scenes/ff_action_detail_panel.tscn</c>; the class fills it and reuses
/// <see cref="ActionCharacterUi"/> for badges and properties so the battle and
/// the character panel can never disagree about an action's presentation.
/// </summary>
public partial class FFActionDetailPanel : ModWidget
{
private PanelContainer _card;
private ColorRect _stripe;
private TextureRect _icon;
private Label _nameLabel;
private Label _idLabel;
private HFlowContainer _badges;
private HFlowContainer _props;
private PanelContainer _note;
private Label _noteLabel;
protected override void Construct()
{
CthangoverTheme.Apply(this);
_card = GetNode<PanelContainer>("Box/Card");
_stripe = GetNode<ColorRect>("Box/Card/CardRow/Stripe");
_icon = GetNode<TextureRect>("Box/Card/CardRow/IconMargin/CardIcon");
_nameLabel = GetNode<Label>("Box/Card/CardRow/Body/NameRow/NameLabel");
_idLabel = GetNode<Label>("Box/Card/CardRow/Body/NameRow/IdLabel");
_badges = GetNode<HFlowContainer>("Box/Card/CardRow/Body/Badges");
_props = GetNode<HFlowContainer>("Box/Card/CardRow/Body/Props");
_note = GetNode<PanelContainer>("Box/Note");
_noteLabel = GetNode<Label>("Box/Note/NoteLabel");
var cardStyle = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Alpha(UiTokens.Surface1, 0.92f), UiTokens.LineCard);
cardStyle.ContentMarginLeft = 0;
cardStyle.ContentMarginRight = 0;
cardStyle.ContentMarginTop = 0;
cardStyle.ContentMarginBottom = 0;
_card.AddThemeStyleboxOverride("panel", cardStyle);
var noteStyle = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Alpha(UiTokens.Surface2, 0.9f), UiTokens.Line);
noteStyle.ContentMarginLeft = 12;
noteStyle.ContentMarginRight = 12;
noteStyle.ContentMarginTop = 9;
noteStyle.ContentMarginBottom = 9;
_note.AddThemeStyleboxOverride("panel", noteStyle);
_nameLabel.AddThemeColorOverride("font_color", UiTokens.InkBright);
_idLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
_noteLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
}
/// <summary>
/// Fills the card with an action passport. <paramref name="note"/> is a
/// ready localised effect preview built by the core, because only the core
/// knows the acting character's attack value for the damage estimate.
/// </summary>
public void ShowAction(ActionCharacter action, string note)
{
if (action == null)
{
ShowHint(note);
return;
}
_stripe.Color = ActionCharacterUi.AccentColor(action.Type);
_icon.Texture = action.Image;
_nameLabel.Text = ActionCharacterUi.Translate(action.Name);
_idLabel.Text = action.ID ?? string.Empty;
_card.Visible = true;
ResetBody();
AddBadge(ActionCharacterUi.Translate(ActionCharacterUi.TypeLabelKey(action.Type)), ActionCharacterUi.AccentColor(action.Type));
var costText = ActionCharacterUi.CostText(action);
if (!string.IsNullOrEmpty(costText))
AddBadge(costText, UiTokens.TypeResource);
var turns = action.GetInt("Turn", 0);
if (turns > 0)
AddBadge(string.Format(Translate("ff_battle/badge_turns"), turns), UiTokens.Cooldown);
foreach (var pair in ActionCharacterUi.SortedProperties(action))
{
if (pair.Key == "RequiredPoint")
continue;
_props.AddChild(ActionCharacterUi.MakePropertyChip(pair.Key, pair.Value));
}
SetNote(note);
}
/// <summary>Fills the card with an inventory item passport for the item sub-menu.</summary>
public void ShowItem(IItem item, bool targetAlly, string note)
{
if (item == null)
{
ShowHint(note);
return;
}
_stripe.Color = UiTokens.TypeUsed;
_icon.Texture = item.Sprite;
_nameLabel.Text = ActionCharacterUi.Translate(item.Name);
_idLabel.Text = item.ID ?? string.Empty;
_card.Visible = true;
ResetBody();
if (item.ItemType.HasFlag(ItemType.Used))
AddBadge(ActionCharacterUi.Translate("ui/inventory/type_used"), UiTokens.TypeUsed);
AddBadge(ActionCharacterUi.Translate(targetAlly ? "ui/inventory/type_target" : "ff_battle/badge_self"),
targetAlly ? UiTokens.TypeTarget : UiTokens.TypeSelf);
SetNote(note);
}
/// <summary>Shows a plain directive note under the card, used for highlighted menu hints.</summary>
public void ShowHint(string note)
{
ResetBody();
_card.Visible = false;
SetNote(note);
}
/// <summary>
/// Hides the whole passport; the core calls it when the menu closes. The
/// inherited <see cref="CanvasItem.Hide"/> semantics are enough, so the
/// method is only a named alias for readability at call sites.
/// </summary>
public void HidePassport()
=> Visible = false;
/// <summary>Shows the panel; the core calls it before filling any content.</summary>
public void ShowPassport()
=> Visible = true;
private void ResetBody()
{
foreach (var child in _badges.GetChildren())
{
_badges.RemoveChild(child);
child.QueueFree();
}
foreach (var child in _props.GetChildren())
{
_props.RemoveChild(child);
child.QueueFree();
}
}
private void AddBadge(string text, Color color)
=> _badges.AddChild(ActionCharacterUi.MakeBadge(text, color));
private void SetNote(string note)
{
var hasNote = !string.IsNullOrEmpty(note);
_note.Visible = hasNote;
_noteLabel.Text = note ?? string.Empty;
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
}
}
+52
View File
@@ -0,0 +1,52 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.FFBattle.UI
{
/// <summary>
/// Centered announcement strip ("Player turn", "Enemy turn", escape results).
/// The node tree lives in <c>scenes/ff_battle_banner.tscn</c>; this class
/// dresses it with tokens and owns the fade in/hold/fade out tween. The banner
/// is purely informational, so it ignores the mouse and the core places it on
/// its own anchored strip between the enemy grid and the party row.
/// </summary>
public partial class FFBattleBanner : ModWidget
{
private Label _label;
private Tween _tween;
protected override void Construct()
{
CthangoverTheme.Apply(this);
_label = GetNode<Label>("Center/Panel/Label");
_label.AddThemeColorOverride("font_color", UiTokens.InkBright);
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;
GetNode<PanelContainer>("Center/Panel").AddThemeStyleboxOverride("panel", style);
Modulate = UiTokens.Alpha(UiTokens.White, 0f);
}
/// <summary>Shows the given caption and restarts the 1.6s fade cycle.</summary>
public void Show(string text)
{
_label.Text = text;
_tween?.Kill();
Modulate = UiTokens.Alpha(UiTokens.White, 0f);
_tween = CreateTween();
_tween.TweenProperty(this, "modulate:a", 1f, 0.2f);
_tween.TweenInterval(1.1f);
_tween.TweenProperty(this, "modulate:a", 0f, 0.3f);
}
}
}
+89
View File
@@ -0,0 +1,89 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.FFBattle.UI
{
/// <summary>
/// Top-left battle HUD: title, phase badge and the round / roster counters.
/// The node tree comes from <c>scenes/ff_battle_hud.tscn</c>; this class only
/// fetches nodes, dresses them with <see cref="UiTokens"/> styles and exposes
/// narrow setters so <see cref="FFBattleCore"/> never assembles controls by
/// hand. The phase badge reuses the type-accent palette: blue for the player
/// turn, red for the enemy turn.
/// </summary>
public partial class FFBattleHud : ModWidget
{
private Label _title;
private PanelContainer _phaseBadge;
private Label _phaseLabel;
private Label _roundValue;
private Label _enemiesValue;
private Label _alliesValue;
protected override void Construct()
{
CthangoverTheme.Apply(this);
_title = GetNode<Label>("Panel/Column/TopRow/TitleLabel");
_phaseBadge = GetNode<PanelContainer>("Panel/Column/TopRow/PhaseBadge");
_phaseLabel = GetNode<Label>("Panel/Column/TopRow/PhaseBadge/PhaseLabel");
_roundValue = GetNode<Label>("Panel/Column/MetaRow/RoundValue");
_enemiesValue = GetNode<Label>("Panel/Column/MetaRow/EnemiesValue");
_alliesValue = GetNode<Label>("Panel/Column/MetaRow/AlliesValue");
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);
_title.AddThemeColorOverride("font_color", UiTokens.InkBright);
foreach (var name in new[] { "RoundCaption", "EnemiesCaption", "AlliesCaption" })
GetNode<Label>("Panel/Column/MetaRow/" + name).AddThemeColorOverride("font_color", UiTokens.Muted);
foreach (var name in new[] { "RoundValue", "EnemiesValue", "AlliesValue" })
GetNode<Label>("Panel/Column/MetaRow/" + name).AddThemeColorOverride("font_color", UiTokens.AccentBright);
ApplyPhaseStyle(true);
}
/// <summary>Switches the phase badge between the player and enemy turns.</summary>
public void SetPhase(bool playerTurn)
{
_phaseLabel.Text = Translate(playerTurn ? "ff_battle/phase_player" : "ff_battle/phase_enemy");
ApplyPhaseStyle(playerTurn);
}
/// <summary>Updates the round counter.</summary>
public void SetRound(int round)
=> _roundValue.Text = round.ToString();
/// <summary>Updates living combatant counters.</summary>
public void SetCounts(int enemies, int allies)
{
_enemiesValue.Text = enemies.ToString();
_alliesValue.Text = allies.ToString();
}
private void ApplyPhaseStyle(bool playerTurn)
{
var accent = playerTurn ? UiTokens.TypeSelf : 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);
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace Cthangover.FFBattle.UI
{
/// <summary>
/// Semantic categories of <see cref="FFBattleLogPanel"/> entries. The kind
/// selects the row colour and the left border accent: turn separators are
/// gold, damage red, absorbed shield blue, buffs green, statuses amber and
/// deaths grey. <see cref="Info"/> is the neutral narration fallback.
/// </summary>
public enum FFBattleLogKind
{
Info,
Turn,
Damage,
Shield,
Buff,
Status,
Dead
}
}
+130
View File
@@ -0,0 +1,130 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.FFBattle.UI
{
/// <summary>
/// Battle journal docked to the upper right: a window with a header carrying
/// the round counter and a scrollable list of colour-coded rows. The panel is
/// a proposal on top of the bare battle screen — it gives damage, shield
/// absorption, status and death events a readable history that the original
/// FF-style UI lacked. Node structure lives in
/// <c>scenes/ff_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.
/// </summary>
public partial class FFBattleLogPanel : 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.AddThemeColorOverride("font_color", UiTokens.Muted);
_roundLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
}
/// <summary>Updates the round caption in the header.</summary>
public void SetRound(int round)
=> _roundLabel.Text = string.Format(Translate("ff_battle/log_round"), round);
/// <summary>Drops every row; used 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 coloured row and trims the oldest entries past the cap.
/// The scroll is pushed to the bottom so the newest event is always visible.
/// </summary>
public void AddEntry(string text, FFBattleLogKind kind)
{
var style = UiKit.Frame(0, BackgroundOf(kind), new Color(0f, 0f, 0f, 0f));
style.BorderWidthLeft = 2;
style.BorderColor = BorderOf(kind);
style.ContentMarginLeft = 16;
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(FFBattleLogKind kind)
=> kind == FFBattleLogKind.Turn ? UiTokens.AccentSoft : new Color(0f, 0f, 0f, 0f);
private static Color BorderOf(FFBattleLogKind kind)
{
switch (kind)
{
case FFBattleLogKind.Turn: return UiTokens.Accent;
case FFBattleLogKind.Damage: return UiTokens.Danger;
case FFBattleLogKind.Shield: return UiTokens.TypeResource;
case FFBattleLogKind.Buff: return UiTokens.TypePassive;
case FFBattleLogKind.Status: return UiTokens.Cooldown;
case FFBattleLogKind.Dead: return UiTokens.LineFaint;
default: return UiTokens.Line;
}
}
private static Color TextOf(FFBattleLogKind kind)
{
switch (kind)
{
case FFBattleLogKind.Turn: return UiTokens.AccentBright;
case FFBattleLogKind.Damage: return UiTokens.DangerText;
case FFBattleLogKind.Shield: return UiTokens.Info;
case FFBattleLogKind.Buff: return UiTokens.SuccessText;
case FFBattleLogKind.Status: return UiTokens.Cooldown;
case FFBattleLogKind.Dead: return UiTokens.Faint;
default: return UiTokens.Muted;
}
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
}
}
+51
View File
@@ -0,0 +1,51 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.FFBattle.UI
{
/// <summary>
/// Toast-style directive shown while the player picks a battle target
/// ("Select a target · RMB / Esc — cancel"). It is a deliberate addition over
/// the bare FF UI: the original mod highlighted widgets without ever telling
/// the player what the highlight meant, which is easy to miss on a large enemy
/// grid. The node tree lives in <c>scenes/ff_battle_target_hint.tscn</c>; the
/// class dresses it with tokens and toggles visibility.
/// </summary>
public partial class FFBattleTargetHint : ModWidget
{
private Label _icon;
private Label _text;
protected override void Construct()
{
CthangoverTheme.Apply(this);
_icon = GetNode<Label>("Center/Toast/Row/IconLabel");
_text = GetNode<Label>("Center/Toast/Row/TextLabel");
_icon.AddThemeColorOverride("font_color", UiTokens.AccentBright);
_text.AddThemeColorOverride("font_color", UiTokens.Ink);
var style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.ToastBg, UiTokens.Line);
style.ContentMarginLeft = 14;
style.ContentMarginRight = 14;
style.ContentMarginTop = 10;
style.ContentMarginBottom = 10;
style.ShadowColor = new Color(0f, 0f, 0f, 0.5f);
style.ShadowSize = 10;
style.ShadowOffset = new Vector2(0f, 4f);
GetNode<PanelContainer>("Center/Toast").AddThemeStyleboxOverride("panel", style);
}
/// <summary>Shows the hint with the given caption.</summary>
public void ShowHint(string text)
{
_text.Text = text;
Visible = true;
}
/// <summary>Hides the hint; called when a target is confirmed or selection is cancelled.</summary>
public void HideHint()
=> Visible = false;
}
}
+230 -75
View File
@@ -1,7 +1,8 @@
using System;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
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;
@@ -9,23 +10,44 @@ using Godot;
namespace Cthangover.FFBattle.UI
{
/// <summary>
/// Visual representation of a single character (player or enemy) in the FF battle UI.
/// Composite widget containing a sprite, HP bar, name label, and selection overlay.
/// Supports highlight, flash, shake, and dissolve-death animations via Godot tweens.
/// Created per-character by <see cref="FFPlayerPanel.Init"/> and
/// <see cref="FFEnemyPanel.Init"/>. Clickable via <c>GuiInput</c> events;
/// embedding panels wire these to <see cref="FFBattleCore"/> for action flow.
/// Battle card of a single combatant, shared by the party and enemy panels.
/// The node tree lives in <c>scenes/ff_character_widget.tscn</c>; this class
/// only fetches the nodes, dresses them with <see cref="UiTokens"/> 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.
/// <para/>
/// The public surface is animation-facing, not layout-facing:
/// <see cref="Highlight"/> paints the selection frame, <see cref="Flash"/> and
/// <see cref="Shake"/> give impact feedback, <see cref="PlayDeathAnimation"/>
/// dissolves the card. Battle code never touches labels directly — it mutates
/// the <see cref="Character"/> and calls <see cref="UpdateInfo"/>, which
/// re-reads health, defence, points and stun in one place. Panels must assign
/// <see cref="IsPlayer"/> before <see cref="Init"/>, because the team colour is
/// applied during initialisation.
/// </summary>
public partial class FFCharacterWidget : ModWidget
{
private TextureRect _sprite;
private TextureRect _selection;
/// <summary>Diameter of one action point pip.</summary>
private const int PipSize = 9;
private TextureRect _art;
private ColorRect _teamLine;
private Panel _selection;
private Panel _teamDot;
private Label _nameLabel;
private ColorRect _hpBg;
private ColorRect _hpFill;
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 Texture2D _cachedSelectTex;
private static bool _selectTexLoaded;
private static Shader _dissolveShader;
/// <summary>Whether this widget belongs to the player party (affects highlight/targeting logic).</summary>
@@ -34,7 +56,7 @@ namespace Cthangover.FFBattle.UI
public bool IsDead { get; set; }
/// <summary>The character data model backing this widget: stats, actions, status effects.</summary>
public Character Card { get; set; }
/// <summary>Original scale captured at construction; used to restore after death animation.</summary>
/// <summary>Original scale captured at initialisation; used to restore after death animation.</summary>
public Vector2 BaseScale { get; private set; }
/// <summary>Exposes this widget as a <see cref="Godot.Control"/> for layout calculations.</summary>
public Control ControlNode => this;
@@ -43,33 +65,29 @@ namespace Cthangover.FFBattle.UI
{
CthangoverTheme.Apply(this);
_sprite = GetNode<TextureRect>("Sprite");
_hpBg = GetNode<ColorRect>("HpBg");
_hpFill = GetNode<ColorRect>("HpFill");
_nameLabel = GetNode<Label>("NameLabel");
_selection = GetNode<TextureRect>("Selection");
_teamLine = GetNode<ColorRect>("TeamLine");
_art = GetNode<TextureRect>("Art");
_nameLabel = GetNode<Label>("Info/Column/NameRow/NameLabel");
_teamDot = GetNode<Panel>("Info/Column/NameRow/TeamDot");
_hpNumLabel = GetNode<Label>("Info/Column/NameRow/HpNumLabel");
_hpBar = GetNode<ProgressBar>("Info/Column/HpBar");
_pipsBox = GetNode<HBoxContainer>("Info/Column/Foot/PipsBox");
_atkLabel = GetNode<Label>("Info/Column/Foot/AtkLabel");
_shieldChip = GetNode<PanelContainer>("Info/Column/Foot/ShieldChip");
_shieldValue = GetNode<Label>("Info/Column/Foot/ShieldChip/ShieldRow/ShieldValue");
_statusBox = GetNode<VBoxContainer>("StatusBox");
_stunBadge = GetNode<PanelContainer>("StatusBox/StunBadge");
_stunIcon = GetNode<TextureRect>("StatusBox/StunBadge/StunRow/StunIcon");
_stunLabel = GetNode<Label>("StatusBox/StunBadge/StunRow/StunLabel");
_selection = GetNode<Panel>("Selection");
Size = CustomMinimumSize;
if (!_selectTexLoaded)
{
_cachedSelectTex = Textures.Resolve("select");
_selectTexLoaded = true;
}
if (_cachedSelectTex != null)
{
_selection.Texture = _cachedSelectTex;
_selection.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize;
_selection.StretchMode = TextureRect.StretchModeEnum.Scale;
_selection.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
}
ApplyStyles();
}
/// <summary>
/// Binds this widget to a <see cref="Character"/> model. Sets the sprite from
/// <see cref="Character.Image"/>, localises the name via
/// <see cref="TranslationServer"/>, and calls <see cref="UpdateInfo"/>
/// to refresh the HP bar.
/// Binds the widget to a <see cref="Character"/>: sprite, localised name,
/// team colour and per-side info layout. The team colour is derived from
/// <see cref="IsPlayer"/>, which is why callers set it before calling here.
/// </summary>
public void Init(Character character)
{
@@ -77,33 +95,72 @@ namespace Cthangover.FFBattle.UI
BaseScale = Scale;
IsDead = false;
if (_sprite != null && character.Image != null)
_sprite.Texture = character.Image;
if (_art != null && character?.Image != null)
_art.Texture = character.Image;
if (_nameLabel != null)
_nameLabel.Text = TranslationServer.Translate(character.Name);
_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();
}
/// <summary>Refreshes the HP bar fill width and colour gradient based on current health percentage.</summary>
/// <summary>
/// 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.
/// </summary>
public void UpdateInfo()
{
if (_hpFill == null || Card == null)
if (Card?.Attributes == null)
return;
var hpPercent = Card.Attributes.Health.Percent;
_hpFill.Size = new Vector2((Size.X - 8) * hpPercent, 18);
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 (hpPercent > 0.5f)
_hpFill.Color = UiTokens.HpOk;
else if (hpPercent > 0.25f)
_hpFill.Color = UiTokens.Warning;
else
_hpFill.Color = UiTokens.HpLow;
if (_hpBar != null)
{
_hpBar.Value = percent * 100f;
_hpBar.AddThemeStyleboxOverride("background", UiKit.Frame(UiTokens.RadiusSm, UiTokens.LineDisabled));
_hpBar.AddThemeStyleboxOverride("fill", UiKit.Frame(UiTokens.RadiusSm, fillColor));
}
/// <summary>Shows a coloured selection overlay with a 0.15s tween fade-in. Used for target highlighting and selection indication.</summary>
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();
}
/// <summary>Shows a coloured selection frame over the card with a 0.15s fade.</summary>
public void Highlight(Color color)
{
if (_selection == null)
@@ -115,7 +172,7 @@ namespace Cthangover.FFBattle.UI
_animTween.TweenProperty(_selection, "modulate", color, 0.15f);
}
/// <summary>Fades out the selection overlay to transparent via a 0.15s tween.</summary>
/// <summary>Fades the selection frame out via a 0.15s tween.</summary>
public void ClearHighlight()
{
if (_selection == null)
@@ -127,7 +184,7 @@ namespace Cthangover.FFBattle.UI
_animTween.TweenProperty(_selection, "modulate", UiTokens.Alpha(UiTokens.White, 0f), 0.15f);
}
/// <summary>Rapidly oscillates the widget's position with random offsets (6 iterations) to simulate impact feedback.</summary>
/// <summary>Rapidly oscillates the widget with random offsets to simulate impact feedback.</summary>
public void Shake(float intensity, float duration)
{
var originalPos = Position;
@@ -145,7 +202,7 @@ namespace Cthangover.FFBattle.UI
}
}
/// <summary>Briefly tints the entire widget with a colour pulse (30% rise, 70% fall) for damage/heal feedback.</summary>
/// <summary>Briefly tints the whole card with a colour pulse (30% rise, 70% fall).</summary>
public void Flash(Color color, float duration)
{
var originalModulate = Modulate;
@@ -156,11 +213,11 @@ namespace Cthangover.FFBattle.UI
}
/// <summary>
/// Plays a dissolve-and-shrink death sequence. If a <c>"scene_transition"</c>
/// shader is available, applies a dissolve material to the sprite. Fades the
/// widget alpha, scales it to zero, and hides the HP bar and name label.
/// Invokes <paramref name="onComplete"/> when finished so the parent panel
/// can remove the widget from the grid.
/// Plays a dissolve-and-shrink death sequence. The sprite gets the
/// <c>scene_transition</c> shader with a growing dissolve progress while the
/// card fades and squashes; the info block fades with it. Invokes
/// <paramref name="onComplete"/> when finished so the parent panel can
/// remove the widget and re-grid the survivors.
/// </summary>
public void PlayDeathAnimation(Action onComplete)
{
@@ -180,21 +237,20 @@ namespace Cthangover.FFBattle.UI
if (_dissolveShader == null)
_dissolveShader = Shaders.Resolve("scene_transition");
if (_dissolveShader != null && _sprite?.Texture != null)
if (_dissolveShader != null && _art?.Texture != null)
{
var mat = new ShaderMaterial { Shader = _dissolveShader };
mat.SetShaderParameter("noise_scale", 5f);
mat.SetShaderParameter("smoothness", 0.25f);
mat.SetShaderParameter("distortion", 0.092f);
mat.SetShaderParameter("glow_intensity", 0f);
mat.SetShaderParameter("hue_shift", 0f);
mat.SetShaderParameter("invert_direction", false);
mat.SetShaderParameter("progress", 0f);
_sprite.Material = mat;
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;
var matTween = CreateTween();
matTween.TweenMethod(
Callable.From<float>(t => mat.SetShaderParameter("progress", t)),
CreateTween().TweenMethod(
Callable.From<float>(t => material.SetShaderParameter("progress", t)),
0f, 1f, duration);
}
@@ -202,9 +258,13 @@ namespace Cthangover.FFBattle.UI
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(_hpBg, "modulate:a", 0f, duration);
tween.TweenProperty(_hpFill, "modulate:a", 0f, 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);
@@ -213,5 +273,100 @@ namespace Cthangover.FFBattle.UI
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<PanelContainer>("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<Label>("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();
}
}
+1 -1
View File
@@ -57,8 +57,8 @@ namespace Cthangover.FFBattle.UI
var widget = (FFCharacterWidget)TscnScenes.LoadAndBuild("scenes/ff_character_widget.tscn");
widget.EnsureConstructed();
widget.Scale = new Vector2(scale, scale);
widget.Init(enemies[i]);
widget.IsPlayer = false;
widget.Init(enemies[i]);
widget.MouseFilter = MouseFilterEnum.Stop;
widget.GuiInput += (evt) =>
{
+27 -1
View File
@@ -39,6 +39,9 @@ namespace Cthangover.FFBattle.UI
private ColorRect _background;
private Panel _border;
private Label _cursorLabel;
private Label _caption;
private StyleBoxFlat _rowActiveStyle;
private StyleBoxFlat _rowIdleStyle;
private List<Label> _itemLabels = new();
private Tween _cursorTween;
private int _selectedIndex;
@@ -70,6 +73,8 @@ namespace Cthangover.FFBattle.UI
/// <summary>Raised when an entry is confirmed (Enter/Space or left click).</summary>
public event System.Action<int> OnItemSelected;
/// <summary>Raised when the highlighted entry changes (keyboard or mouse hover); carries the new index.</summary>
public event System.Action<int> OnSelectionChanged;
/// <summary>Raised when the menu is cancelled (right click, or Escape handled externally).</summary>
public event System.Action OnCancelled;
@@ -87,6 +92,12 @@ namespace Cthangover.FFBattle.UI
_cursorLabel = GetNode<Label>("Cursor");
_cursorLabel.AddThemeColorOverride("font_color", UiTokens.AccentBright);
_caption = GetNode<Label>("Caption");
_caption.AddThemeColorOverride("font_color", UiTokens.Muted);
_rowActiveStyle = UiKit.Frame(UiTokens.RadiusSm, UiTokens.AccentTrace, UiTokens.Alpha(UiTokens.White, 0f));
_rowIdleStyle = UiKit.Frame(UiTokens.RadiusSm, UiTokens.Alpha(UiTokens.White, 0f), UiTokens.Alpha(UiTokens.White, 0f));
GuiInput += OnPanelGuiInput;
}
@@ -112,6 +123,7 @@ namespace Cthangover.FFBattle.UI
label.AddThemeColorOverride("font_color", entries[i].Enabled
? UiTokens.Ink
: UiTokens.Faint);
label.AddThemeStyleboxOverride("normal", _rowIdleStyle);
label.MouseFilter = MouseFilterEnum.Stop;
label.Position = new Vector2(PADDING + 24, PADDING + i * ITEM_HEIGHT + CURSOR_OFFSET_Y);
label.Size = new Vector2(width - PADDING * 2 - 24, ITEM_HEIGHT);
@@ -145,16 +157,19 @@ namespace Cthangover.FFBattle.UI
_selectedIndex = 0;
MoveCursor(0);
_cursorLabel.Visible = true;
_caption.Text = title ?? string.Empty;
_caption.Visible = !string.IsNullOrEmpty(title);
_visible = true;
Visible = true;
}
/// <summary>Hides the menu and cursor. Does not clear entries — call <see cref="ShowMenu"/> to repopulate.</summary>
/// <summary>Hides the menu, caption and cursor. Does not clear entries — call <see cref="ShowMenu"/> to repopulate.</summary>
public void HideMenu()
{
_visible = false;
Visible = false;
_cursorLabel.Visible = false;
_caption.Visible = false;
}
/// <summary>Moves selection down, wrapping around and skipping disabled entries.</summary>
@@ -208,6 +223,17 @@ namespace Cthangover.FFBattle.UI
_cursorTween.SetLoops();
_cursorTween.TweenProperty(_cursorLabel, "position:x", CURSOR_OFFSET_X + 3f, 0.3f);
_cursorTween.TweenProperty(_cursorLabel, "position:x", CURSOR_OFFSET_X, 0.3f);
for (var i = 0; i < _itemLabels.Count; i++)
{
var enabled = i < Entries.Count && Entries[i].Enabled;
var selected = i == index && enabled;
_itemLabels[i].AddThemeStyleboxOverride("normal", selected ? _rowActiveStyle : _rowIdleStyle);
_itemLabels[i].AddThemeColorOverride("font_color",
selected ? UiTokens.AccentBright : enabled ? UiTokens.Ink : UiTokens.Faint);
}
OnSelectionChanged?.Invoke(index);
}
private void OnPanelGuiInput(InputEvent evt)
+1 -1
View File
@@ -47,8 +47,8 @@ namespace Cthangover.FFBattle.UI
var widget = (FFCharacterWidget)TscnScenes.LoadAndBuild("scenes/ff_character_widget.tscn");
widget.EnsureConstructed();
widget.Scale = new Vector2(scale, scale);
widget.Init(players[i]);
widget.IsPlayer = true;
widget.Init(players[i]);
widget.MouseFilter = MouseFilterEnum.Stop;
widget.GuiInput += (evt) =>
{