Compare commits

...
6 Commits
Author SHA1 Message Date
cthangover 704b69d93d Drop the unneeded visual_novel_core dependency
ff_battle needs only the battle platform and the interface; depending on the content mod pulled the whole novel into every game that uses this core.
2026-10-17 14:27:51 +00:00
iisaevandClaude Opus 5.5 00aa70e302 upd: sync local changes
Fix enemy turn hanging when a lethal hit frees the target widget:
UpdateInfo skips freed widgets, animations finish early and restore
the attacker only while it is alive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:18:14 +00:00
cthangover ba2c08b1dc new battle gui 2026-09-28 10:48:06 +03:00
cthangover 86e3de904e ui stule update 2026-09-26 08:05:13 +03:00
cthangover 28046f901e upd 2026-09-24 11:17:03 +03:00
cthangover 68f763510a move ui to mods 2026-09-24 10:42:43 +03:00
29 changed files with 1768 additions and 154 deletions

No files matched your search

+47 -1
View File
@@ -1,5 +1,5 @@
mod_ff_battle_name = FFBattle
mod_ff_battle_desc = Final Fantasy style battle system
mod_ff_battle_desc = Final Fantasy style battle system on top of turn_battle_base: menu-driven commands (attack/defence/item/stun), enemy and party panels, per-action animations and executors, battle HUD and log.
ff_battle/menu_attack = Attack
ff_battle/menu_items = Items
@@ -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
+47 -1
View File
@@ -1,5 +1,5 @@
mod_ff_battle_name=FF Битва
mod_ff_battle_desc=Боевая система в стиле Final Fantasy
mod_ff_battle_desc=Боевая система в стиле Final Fantasy на платформе turn_battle_base: командное меню (атака/защита/предмет/оглушение), панели партии и врагов, анимации и исполнители действий, HUD и лог боя.
ff_battle/menu_attack=Атака
ff_battle/menu_items=Предметы
@@ -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=На себя
+3 -2
View File
@@ -3,9 +3,10 @@
"name": "FFBattle",
"version": "1.0.0",
"author": "ct",
"description": "Final Fantasy style battle system",
"description": "Final Fantasy style battle system on top of turn_battle_base: menu-driven commands (attack/defence/item/stun), enemy and party panels, per-action animations and executors, battle HUD and log.",
"category": "gameplay",
"sources": [
"src/**/*.cs"
],
"depends": [ "core>=1", "interface>=1" ]
"depends": [ "interface>=1", "turn_battle_base>=1" ]
}
+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
+150 -28
View File
@@ -5,51 +5,173 @@
[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.15, 0.15, 0.15, 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.1, 0.85, 0.1, 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="HpBarStack" type="Control" parent="Info/Column"]
custom_minimum_size = Vector2(0, 8)
layout_mode = 2
mouse_filter = 2
horizontal_alignment = 1
theme_override_font_sizes/font_size = 13
[node name="Selection" type="TextureRect" parent="."]
[node name="ShieldBar" type="ProgressBar" parent="Info/Column/HpBarStack"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
show_percentage = false
[node name="HpBar" type="ProgressBar" parent="Info/Column/HpBarStack"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
show_percentage = false
[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
+13 -4
View File
@@ -13,9 +13,9 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
color = Color(0.02, 0.05, 0.12, 0.88)
color = Color(0.062745, 0.066667, 0.094118, 1)
[node name="Border" type="ColorRect" parent="."]
[node name="Border" type="Panel" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
@@ -27,11 +27,20 @@ offset_top = -3.0
offset_right = 3.0
offset_bottom = 3.0
mouse_filter = 2
color = Color(0.3, 0.5, 0.9, 0.9)
[node name="Cursor" type="Label" parent="."]
text = "►"
mouse_filter = 2
visible = false
theme_override_font_sizes/font_size = 18
theme_override_colors/font_color = Color(1, 0.95, 0.7, 1)
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
+11
View File
@@ -49,9 +49,17 @@ namespace Cthangover.FFBattle.Actions
{
if (Source == null || Target == null || Action == null)
return true;
// The target's death animation may free its widget mid-animation;
// finish early and let DoEnd snap the source back.
if (!IsAlive(Source) || !IsAlive(Target))
return true;
return DoInternalAction();
}
/// <summary>True while the widget is still in the tree and not queued for deletion.</summary>
protected static bool IsAlive(FFCharacterWidget widget)
=> GodotObject.IsInstanceValid(widget) && !widget.IsQueuedForDeletion();
/// <summary>Captures the initial world positions of source and target, then delegates to subclass setup.</summary>
public void DoStart()
{
@@ -71,8 +79,11 @@ namespace Cthangover.FFBattle.Actions
DoInternalEnd();
}
/// <summary>Frame tick of the animation; implementors advance phases and return true when finished.</summary>
protected abstract bool DoInternalAction();
/// <summary>Phase setup called once by <see cref="DoStart"/> after positions are captured.</summary>
protected abstract void DoInternalStart();
/// <summary>Cleanup called once by <see cref="DoEnd"/> — restore captured positions here.</summary>
protected abstract void DoInternalEnd();
/// <summary>Quadratic ease-out: fast start, decelerating to the target. Used for return-to-start motion.</summary>
+13 -6
View File
@@ -1,7 +1,9 @@
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Characters;
using Cthangover.TurnBattle;
using Cthangover.FFBattle.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.FFBattle.Actions
@@ -27,9 +29,10 @@ namespace Cthangover.FFBattle.Actions
public FFAttackAnimation(FFCharacterWidget source, FFCharacterWidget target, ActionCharacter action, float speed = 1f)
: base(source, target, action, speed) { }
/// <summary>Starts the attack sequence: compute the stop point short of the target and reset the phase clock.</summary>
protected override void DoInternalStart()
{
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
_damageApplied = false;
_phase = Phase.MoveForward;
@@ -37,9 +40,10 @@ namespace Cthangover.FFBattle.Actions
_midPoint = TargetPos - dir * 30f;
}
/// <summary>Advances move-in → impact (damage via <see cref="ActionExecutorHub"/>, hit flash/shake, damage popup) → move-back.</summary>
protected override bool DoInternalAction()
{
var elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Engine.TimeScale;
var elapsed = (float)(Godot.Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Godot.Engine.TimeScale;
switch (_phase)
{
@@ -51,7 +55,7 @@ namespace Cthangover.FFBattle.Actions
if (progress >= 1f)
{
_phase = Phase.Impact;
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
}
break;
}
@@ -69,7 +73,7 @@ namespace Cthangover.FFBattle.Actions
if (result.Result)
{
Target.Flash(new Color(1f, 0.3f, 0.3f, 1f), 0.2f);
Target.Flash(UiTokens.Danger, 0.2f);
Target.Shake(4f, 0.25f);
if (result.Target.Damage > 0)
@@ -84,7 +88,7 @@ namespace Cthangover.FFBattle.Actions
if (progress >= 1f)
{
_phase = Phase.MoveBack;
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
}
break;
}
@@ -106,11 +110,14 @@ namespace Cthangover.FFBattle.Actions
return false;
}
/// <summary>Returns the attacker to its original position and refreshes both widgets.</summary>
protected override void DoInternalEnd()
{
Source.GlobalPosition = SourcePos;
if (IsAlive(Source))
Source.GlobalPosition = SourcePos;
Source.UpdateInfo();
Target.UpdateInfo();
}
}
}
+5 -2
View File
@@ -1,3 +1,4 @@
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters;
using Cthangover.Core.Utils;
using Godot;
@@ -15,16 +16,18 @@ namespace Cthangover.FFBattle.Actions
/// </summary>
public class FFDamageExecutor : ActionBase
{
/// <summary>Hub registry key for this executor.</summary>
public override string ID => "FFDamageExecutor";
/// <summary>Applies attack damage with status hooks and defence absorption; consumes the action's required points.</summary>
public override ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
{
if (target == null || user == null || !CheckRequiredAndUsePoint(action, user))
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
View File
@@ -12,8 +12,10 @@ namespace Cthangover.FFBattle.Actions
/// </summary>
public class FFDefenceExecutor : ActionBase
{
/// <summary>Hub registry key for this executor.</summary>
public override string ID => "FFDefenceExecutor";
/// <summary>Adds the action's "Defence" value (default 4) to the target's defence shield.</summary>
public override ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
{
if (target == null || user == null || !CheckRequiredAndUsePoint(action, user))
+13 -6
View File
@@ -1,7 +1,9 @@
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Characters;
using Cthangover.TurnBattle;
using Cthangover.FFBattle.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.FFBattle.Actions
@@ -28,17 +30,19 @@ namespace Cthangover.FFBattle.Actions
public FFDefendAnimation(FFCharacterWidget source, FFCharacterWidget target, ActionCharacter action, float speed = 1f)
: base(source, target, action, speed) { }
/// <summary>Starts the defend sequence from the bounce phase and captures the glow state.</summary>
protected override void DoInternalStart()
{
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
_effectApplied = false;
_phase = Phase.Bounce;
_bounceOffset = new Vector2(0, -12f);
}
/// <summary>Advances bounce → glow (defence application with a green flash) → settle.</summary>
protected override bool DoInternalAction()
{
var elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Engine.TimeScale;
var elapsed = (float)(Godot.Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Godot.Engine.TimeScale;
switch (_phase)
{
@@ -51,7 +55,7 @@ namespace Cthangover.FFBattle.Actions
if (progress >= 1f)
{
_phase = Phase.Glow;
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
}
break;
}
@@ -69,7 +73,7 @@ namespace Cthangover.FFBattle.Actions
if (result.Result)
{
Target.Flash(new Color(0.3f, 1f, 0.3f, 1f), 0.4f);
Target.Flash(UiTokens.Success, 0.4f);
if (result.Target.Defence > 0)
ShowDamageBehaviour.SpawnDefence(result.Target.Defence, Target, Target.GlobalPosition);
@@ -82,7 +86,7 @@ namespace Cthangover.FFBattle.Actions
if (progress >= 1f)
{
_phase = Phase.Recover;
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
}
break;
}
@@ -104,11 +108,14 @@ namespace Cthangover.FFBattle.Actions
return false;
}
/// <summary>Returns the defender to its original position and refreshes both widgets.</summary>
protected override void DoInternalEnd()
{
Source.GlobalPosition = SourcePos;
if (IsAlive(Source))
Source.GlobalPosition = SourcePos;
Source.UpdateInfo();
Target.UpdateInfo();
}
}
}
+14 -7
View File
@@ -1,6 +1,7 @@
using Cthangover.Core.Characters;
using Cthangover.Core.Items;
using Cthangover.FFBattle.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.FFBattle.Actions
@@ -30,9 +31,10 @@ namespace Cthangover.FFBattle.Actions
_item = item;
}
/// <summary>Spawns the raised item icon above the user and starts the item sequence.</summary>
protected override void DoInternalStart()
{
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
_effectApplied = false;
_phase = Phase.RaiseItem;
@@ -48,9 +50,10 @@ namespace Cthangover.FFBattle.Actions
}
}
/// <summary>Advances raise-item → apply (item use with effect flash) → lower-icon.</summary>
protected override bool DoInternalAction()
{
var elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Engine.TimeScale;
var elapsed = (float)(Godot.Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Godot.Engine.TimeScale;
switch (_phase)
{
@@ -70,7 +73,7 @@ namespace Cthangover.FFBattle.Actions
if (progress >= 1f)
{
_phase = Phase.ApplyEffect;
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
}
break;
}
@@ -84,7 +87,7 @@ namespace Cthangover.FFBattle.Actions
var localTarget = Target.GlobalPosition - Source.Position;
var iconStart = Source.GlobalPosition - Source.Position + new Vector2(20, -70);
_itemIcon.Position = iconStart.Lerp(localTarget + new Vector2(20, -20), EaseInOutQuad(progress));
_itemIcon.Modulate = new Color(1, 1, 1, 1f - progress * 0.5f);
_itemIcon.Modulate = UiTokens.Alpha(UiTokens.White, 1f - progress * 0.5f);
}
if (!_effectApplied && progress >= 0.3f)
@@ -96,13 +99,13 @@ namespace Cthangover.FFBattle.Actions
Source.UpdateInfo();
Target.UpdateInfo();
Target.Flash(new Color(0.5f, 1f, 0.5f, 1f), 0.3f);
Target.Flash(UiTokens.Success, 0.3f);
}
if (progress >= 1f)
{
_phase = Phase.Recover;
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
}
break;
}
@@ -124,9 +127,11 @@ namespace Cthangover.FFBattle.Actions
return false;
}
/// <summary>Frees the item icon, returns the user to its original position and refreshes both widgets.</summary>
protected override void DoInternalEnd()
{
Source.GlobalPosition = SourcePos;
if (IsAlive(Source))
Source.GlobalPosition = SourcePos;
if (_itemIcon != null)
{
@@ -139,3 +144,5 @@ namespace Cthangover.FFBattle.Actions
}
}
}
+4 -2
View File
@@ -1,3 +1,4 @@
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters;
using Cthangover.Core.Utils;
@@ -12,8 +13,10 @@ namespace Cthangover.FFBattle.Actions
/// </summary>
public class FFStunExecutor : ActionBase
{
/// <summary>Hub registry key for this executor.</summary>
public override string ID => "FFStunExecutor";
/// <summary>Adds the stun effect for the action's "Turn" count (default 3); stun-immune targets consume the points but stay un-stunned.</summary>
public override ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
{
if (target == null || user == null || !CheckRequiredAndUsePoint(action, user))
@@ -23,8 +26,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);
+292 -11
View File
@@ -1,15 +1,19 @@
using System;
using System;
using System.Collections.Generic;
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;
using Godot;
namespace Cthangover.FFBattle
@@ -37,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;
@@ -48,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;
@@ -90,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;
@@ -111,6 +153,7 @@ namespace Cthangover.FFBattle
_enemyPanel.OnEnemyClicked += OnEnemyClicked;
_enemyPanel.OnEnemyDead += OnEnemyDied;
_menuPanel.OnItemSelected += OnMenuItemSelected;
_menuPanel.OnSelectionChanged += OnMenuSelectionChanged;
_menuPanel.OnCancelled += OnMenuCancel;
}
@@ -137,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);
@@ -146,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();
}
@@ -159,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();
@@ -179,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)
@@ -200,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;
@@ -249,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(new Color(0, 0.6f, 1f, 0.6f));
_currentCharacter?.Highlight(UiTokens.Accent);
}
private void OnMenuItemSelected(int index)
@@ -366,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;
}
@@ -407,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;
}
@@ -417,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();
@@ -427,13 +502,13 @@ namespace Cthangover.FFBattle
{
foreach (var widget in _playerPanel.Widgets)
if (!widget.IsDead)
widget.Highlight(new Color(0.3f, 1f, 0.3f, 0.5f));
widget.Highlight(UiTokens.Success);
}
else
{
foreach (var widget in _enemyPanel.Widgets)
if (!widget.IsDead)
widget.Highlight(new Color(1f, 0.3f, 0.3f, 0.5f));
widget.Highlight(UiTokens.Danger);
}
_onTargetSelected = null;
@@ -450,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);
}
@@ -468,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);
}
@@ -479,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)
@@ -503,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);
@@ -510,6 +599,8 @@ namespace Cthangover.FFBattle
}
}
LogOutcome(source, target, actionName, hpBefore, defBefore, stunBefore);
_currentMenuAction = null;
_endTurnButton.Visible = true;
@@ -536,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
@@ -549,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();
@@ -587,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;
}
@@ -598,6 +695,7 @@ namespace Cthangover.FFBattle
}
_menuPanel.HideMenu();
_detailPanel.HidePassport();
_controller.State = FFBattleState.Idle;
_currentCharacter = null;
_currentMenuAction = null;
@@ -634,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;
}
@@ -648,6 +747,7 @@ namespace Cthangover.FFBattle
return;
}
_logPanel.AddEntry(Translate("ff_battle/log_no_points"), FFBattleLogKind.Info);
OnPlayerTurnEnd();
}
@@ -667,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;
@@ -675,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");
@@ -693,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;
}
@@ -715,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)
{
@@ -724,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())
@@ -802,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);
@@ -851,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"/>.
@@ -868,3 +1146,6 @@ namespace Cthangover.FFBattle
public void AdvancePlayerTurn() => OnPlayerTurnEnd();
}
}
+182
View File
@@ -0,0 +1,182 @@
using Cthangover.Core.Characters;
using Cthangover.Core.Items;
using Cthangover.Core.UI;
using Cthangover.Core.UI.CharacterPanel;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
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;
/// <summary>Builds the action detail passport panel (description plate shown when an action is inspected).</summary>
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();
}
}
+55
View File
@@ -0,0 +1,55 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
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;
/// <summary>Builds the centre-screen turn/outcome banner label.</summary>
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);
}
}
}
+2
View File
@@ -1,6 +1,7 @@
using Cthangover.Core.UI;
using Godot;
using Cthangover.Mods.Interface.Base;
namespace Cthangover.FFBattle.UI
{
/// <summary>
@@ -77,6 +78,7 @@ namespace Cthangover.FFBattle.UI
ProcessMode = ProcessModeEnum.Always;
}
/// <summary>Routes menu/confirm input into the FF battle state machine (target select, turn advance).</summary>
protected override void OnInput(InputEvent @event)
{
if (State == FFBattleState.AnimationPlaying || State == FFBattleState.Ended)
+92
View File
@@ -0,0 +1,92 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
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;
/// <summary>Builds the FF battle HUD frame: command menu host, speed control and log toggle.</summary>
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
}
}
+133
View File
@@ -0,0 +1,133 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
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;
/// <summary>Builds the scrollable FF battle log with coloured entries per <see cref="FFBattleLogKind"/>.</summary>
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();
}
}
+54
View File
@@ -0,0 +1,54 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
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;
/// <summary>Builds the floating hint shown above the hovered target.</summary>
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;
}
}
+249 -75
View File
@@ -1,30 +1,59 @@
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;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
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, the layered health bar
/// (<see cref="UiKit.StyleShieldedHealthBar"/>: track → health fill → blue
/// defence segment drawn past the health edge while
/// <see cref="CharacterAttributes.Defence"/> absorbs damage), 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 _shieldBar;
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>
@@ -33,40 +62,40 @@ 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;
/// <summary>Builds the character widget: portrait frame, HP bar and status chips.</summary>
protected override void Construct()
{
_sprite = GetNode<TextureRect>("Sprite");
_hpBg = GetNode<ColorRect>("HpBg");
_hpFill = GetNode<ColorRect>("HpFill");
_nameLabel = GetNode<Label>("NameLabel");
_selection = GetNode<TextureRect>("Selection");
CthangoverTheme.Apply(this);
Size = CustomMinimumSize;
_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");
_shieldBar = GetNode<ProgressBar>("Info/Column/HpBarStack/ShieldBar");
_hpBar = GetNode<ProgressBar>("Info/Column/HpBarStack/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");
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 = new Color(0, 0, 0, 0);
}
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)
{
@@ -74,33 +103,78 @@ 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: layered
/// HP/defence 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)
// A lethal hit frees the widget through the death animation while
// the attack animation and Health.OnChange subscribers still hold it.
if (!IsInstanceValid(this) || IsQueuedForDeletion() || 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 defence = Card.Attributes.Defence?.Value ?? 0;
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 = new Color(0.1f, 0.85f, 0.1f, 1f);
else if (hpPercent > 0.25f)
_hpFill.Color = new Color(0.85f, 0.85f, 0.1f, 1f);
else
_hpFill.Color = new Color(0.85f, 0.1f, 0.1f, 1f);
if (_hpBar != null)
{
_hpBar.Value = percent * 100f;
_hpBar.AddThemeStyleboxOverride("fill", UiKit.Frame(UiTokens.RadiusSm, fillColor));
}
// Blue defence segment on top of the health fill; collapses onto
// the health edge when the shield is gone.
UiKit.UpdateShieldedHealthBar(_shieldBar, percent, defence, health.BaseValue);
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)
{
_shieldChip.Visible = defence > 0;
if (_shieldValue != null)
_shieldValue.Text = defence.ToString();
}
RefreshStun();
}
/// <summary>Shows a coloured selection overlay with a 0.15s tween fade-in. Used for target highlighting and selection indication.</summary>
/// <summary>Shows a coloured selection frame over the card with a 0.15s fade.</summary>
public void Highlight(Color color)
{
if (_selection == null)
@@ -112,7 +186,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)
@@ -121,10 +195,10 @@ namespace Cthangover.FFBattle.UI
_animTween?.Kill();
_animTween = CreateTween();
_animTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad);
_animTween.TweenProperty(_selection, "modulate", new Color(0, 0, 0, 0), 0.15f);
_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;
@@ -142,7 +216,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;
@@ -153,11 +227,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)
{
@@ -177,21 +251,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);
}
@@ -199,9 +272,14 @@ 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(_shieldBar, "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);
@@ -210,5 +288,101 @@ 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 hpFill = UiKit.Frame(UiTokens.RadiusSm, UiTokens.HpOk);
_hpBar.AddThemeStyleboxOverride("fill", hpFill);
// Layered pie: the shield bar owns the track and the blue defence
// fill; the health bar paints only its fill on top of it.
UiKit.StyleShieldedHealthBar(_shieldBar, _hpBar);
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();
}
}
+8 -2
View File
@@ -4,8 +4,10 @@ using System.Linq;
using Cthangover.Core.Characters;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
namespace Cthangover.FFBattle.UI
{
/// <summary>
@@ -32,7 +34,11 @@ namespace Cthangover.FFBattle.UI
/// <summary>Raised when an enemy widget is clicked (used for target selection).</summary>
public event System.Action<FFCharacterWidget> OnEnemyClicked;
protected override void Construct() { }
/// <summary>Builds the enemy grid panel and lays out enemy widgets.</summary>
protected override void Construct()
{
CthangoverTheme.Apply(this);
}
/// <summary>Number of enemy widget instances currently managed (including dead).</summary>
public int EnemyCount => Widgets.Count;
@@ -53,8 +59,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) =>
{
+44 -5
View File
@@ -1,7 +1,10 @@
using System.Collections.Generic;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.FFBattle.UI
{
/// <summary>
@@ -36,8 +39,11 @@ namespace Cthangover.FFBattle.UI
public partial class FFMenuPanel : ModWidget
{
private ColorRect _background;
private ColorRect _border;
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;
@@ -69,14 +75,31 @@ 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;
/// <summary>Builds the command menu list (Attack/Defence/Item/...) used during the player turn.</summary>
protected override void Construct()
{
CthangoverTheme.Apply(this);
_background = GetNode<ColorRect>("Background");
_border = GetNode<ColorRect>("Border");
_background.Color = UiTokens.Surface2;
_border = GetNode<Panel>("Border");
_border.AddThemeStyleboxOverride("panel",
UiKit.Frame(UiTokens.RadiusSm, UiTokens.Alpha(UiTokens.White, 0f), UiTokens.AccentLine, 2));
_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;
}
@@ -101,8 +124,9 @@ namespace Cthangover.FFBattle.UI
label.Text = entries[i].Label;
label.AddThemeFontSizeOverride("font_size", 17);
label.AddThemeColorOverride("font_color", entries[i].Enabled
? new Color(1f, 1f, 1f, 1f)
: new Color(0.5f, 0.5f, 0.5f, 1f));
? 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);
@@ -136,16 +160,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>
@@ -199,6 +226,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)
@@ -216,6 +254,7 @@ namespace Cthangover.FFBattle.UI
_itemLabels.Clear();
}
/// <summary>Frees the menu entries and disconnects selection handlers when the panel closes.</summary>
protected override void Destruct()
{
ClearEntries();
+8 -2
View File
@@ -3,8 +3,10 @@ using System.Linq;
using Cthangover.Core.Characters;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
namespace Cthangover.FFBattle.UI
{
/// <summary>
@@ -25,7 +27,11 @@ namespace Cthangover.FFBattle.UI
/// <summary>Raised when a player widget is clicked — routed to <see cref="FFBattleCore"/> for character selection.</summary>
public event System.Action<FFCharacterWidget> OnWidgetClicked;
protected override void Construct() { }
/// <summary>Builds the bottom player-party panel hosting one widget per party member.</summary>
protected override void Construct()
{
CthangoverTheme.Apply(this);
}
/// <summary>
/// Creates <see cref="FFCharacterWidget"/> instances for each player, scales
@@ -43,8 +49,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) =>
{