new battle gui

This commit is contained in:
cthangover committed 2026-09-28 10:48:05 +03:00
1 parent f65663fd91
commit b243a318f4
35 files changed
+2402 -937

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
{
"id": "card_novel",
"name": "Card Novel",
"author": "ct",
"version": "1.0.0",
"description": "A visual novel with turn-based battles resolved as a card game: story core, battle engine, card layer and the novel interface.",
"mechanics": [
{
"id": "vn_core",
"name": "Novel Core",
"description": "Scenes, scenarios, dialogue and the story engine.",
"mods": ["visual_novel_core>=1"]
},
{
"id": "story",
"name": "Story",
"description": "The bundled story content.",
"mods": ["murakami>=1"]
},
{
"id": "battle",
"name": "Turn Battle",
"description": "Turn-based combat foundation.",
"mods": ["turn_battle_base>=1"]
},
{
"id": "cards",
"name": "Card Game",
"description": "Card hands, decks and card-driven battle actions.",
"mods": ["card_battle>=1"]
},
{
"id": "interface",
"name": "Interface",
"description": "Shared GUI panels and the narrative HUD.",
"mods": ["interface>=1"]
}
]
}
+38 -3
View File
@@ -1,4 +1,39 @@
mod_card_battle_name = CardBattle mod_card_battle_name=CardBattle
mod_card_battle_desc = Card battle mechanics mod_card_battle_desc=Card battle mechanics
battle/end_turn = End turn battle/end_turn=End turn
battle/hud_title=Card battle
battle/round=Round
battle/enemies_alive=Enemies alive
battle/turn_player=Player turn
battle/turn_enemy=Enemy turn
battle/actions_title=Actions
battle/no_character=no character selected
battle/empty_actions=Select a character to see their actions
battle/desc_default=Hover a card to see the numbers. Drag it onto a target to execute.
battle/action_hint=LMB — select a character · drag an action card onto a target
battle/action_autoend=0 AP — the turn ends by itself
battle/preview_damage=Damage {0} (ATK {1} × {2}) · cost {3} AP
battle/preview_defence=Target shield +{0} · cost {1} AP
battle/preview_stun=Stun for {0} turns · cost {1} AP
battle/turns_badge={0} turns
battle/stun_status=Stunned {0}
battle/log_title=Battle log
battle/log_round=round {0}
battle/log_turn_player=— Player turn
battle/log_turn_enemy=— Enemy turn
battle/log_enemy_action={0}: {1} → {2}
battle/log_damage={0}: {1} → {2} · −{3} HP
battle/log_shield_absorb=Shield of {0} absorbed {1}
battle/log_shield_gain=Defence: {0} · +{1} DEF
battle/log_absorbed={0}: {1} → {2} · damage absorbed by shield
battle/log_stun=Stun: {0} · {1} turns
battle/log_stun_skip={0} is stunned — skips the turn
battle/log_dead={0} is defeated
battle/log_out_of_points=The party is out of action points
battle/log_invalid_target=Invalid target for "{0}"
battle/log_not_enough_points=Not enough AP for "{0}"
+35
View File
@@ -2,3 +2,38 @@
mod_card_battle_desc=Механики карточных сражений mod_card_battle_desc=Механики карточных сражений
battle/end_turn=Конец хода battle/end_turn=Конец хода
battle/hud_title=Карточная битва
battle/round=Раунд
battle/enemies_alive=Живых врагов
battle/turn_player=Ход игрока
battle/turn_enemy=Ход врага
battle/actions_title=Действия
battle/no_character=персонаж не выбран
battle/empty_actions=Выберите персонажа, чтобы увидеть его действия
battle/desc_default=Наведите на карточку — покажу расчёт. Перетащите её на цель, чтобы выполнить.
battle/action_hint=ЛКМ — выбрать персонажа · карточка действия перетаскивается на цель
battle/action_autoend=0 ОД — ход завершится сам
battle/preview_damage=Урон {0} (АТК {1} × {2}) · расход {3} ОД
battle/preview_defence=Щит цели +{0} · расход {1} ОД
battle/preview_stun=Ошеломление на {0} хода · расход {1} ОД
battle/turns_badge={0} хода
battle/stun_status=Ошеломлён {0}
battle/log_title=Журнал боя
battle/log_round=раунд {0}
battle/log_turn_player=— Ход игрока
battle/log_turn_enemy=— Ход врага
battle/log_enemy_action={0}: {1} → {2}
battle/log_damage={0}: {1} → {2} · −{3} HP
battle/log_shield_absorb=Щит {0} поглотил {1}
battle/log_shield_gain=Защита: {0} · +{1} ЗАЩ
battle/log_absorbed={0}: {1} → {2} · урон поглощён щитом
battle/log_stun=Ошеломление: {0} · {1} хода
battle/log_stun_skip={0} ошеломлён — пропускает ход
battle/log_dead={0} повержен
battle/log_out_of_points=У отряда закончились ОД
battle/log_invalid_target=Недопустимая цель для «{0}»
battle/log_not_enough_points=Недостаточно ОД для «{0}»
+85
View File
@@ -0,0 +1,85 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/ActionCardNode.cs" id="1_script"]
[node name="ActionCardNode" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(0, 86)
layout_mode = 3
size_flags_horizontal = 3
[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="Row" type="HBoxContainer" parent="Panel"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 0
[node name="Accent" type="ColorRect" parent="Panel/Row"]
custom_minimum_size = Vector2(4, 0)
layout_mode = 2
mouse_filter = 2
[node name="IconWrap" type="MarginContainer" parent="Panel/Row"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_left = 10
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 10
theme_override_constants/margin_bottom = 8
[node name="Icon" type="TextureRect" parent="Panel/Row/IconWrap"]
custom_minimum_size = Vector2(52, 52)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
expand_mode = 1
stretch_mode = 6
[node name="Body" type="MarginContainer" parent="Panel/Row"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
theme_override_constants/margin_left = 4
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 10
theme_override_constants/margin_bottom = 8
[node name="Column" type="VBoxContainer" parent="Panel/Row/Body"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 4
[node name="NameRow" type="HBoxContainer" parent="Panel/Row/Body/Column"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 8
[node name="NameLabel" type="Label" parent="Panel/Row/Body/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="IdLabel" type="Label" parent="Panel/Row/Body/Column/NameRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 9
[node name="Badges" type="HBoxContainer" parent="Panel/Row/Body/Column"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 5
[node name="Props" type="HBoxContainer" parent="Panel/Row/Body/Column"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 5
-46
View File
@@ -1,46 +0,0 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/ActionCardNode.cs" id="ActionCardNodeScript"]
[node name="ActionCardNode" type="Control" script=ExtResource("ActionCardNodeScript")]
custom_minimum_size = Vector2(200, 280)
layout_mode = 3
[node name="Image" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
offset_left = 15.0
offset_top = 15.0
offset_right = -15.0
offset_bottom = -40.0
mouse_filter = 2
expand_mode = 1
stretch_mode = 4
[node name="Frame" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 4
[node name="NameLabel" type="Label" parent="."]
layout_mode = 1
anchors_preset = 12
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
offset_top = -40.0
offset_bottom = 0.0
theme_override_font_sizes/font_size = 14
horizontal_alignment = 1
+152
View File
@@ -0,0 +1,152 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/BattleActionsPanel.cs" id="1_script"]
[node name="BattleActionsPanel" 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
mouse_filter = 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 = 18
theme_override_constants/margin_top = 14
theme_override_constants/margin_right = 18
theme_override_constants/margin_bottom = 11
[node name="HeaderRow" type="HBoxContainer" parent="Window/Column/Header"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 12
[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 = "battle/actions_title"
uppercase = true
[node name="SubjectLabel" type="Label" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 14
text = "battle/no_character"
[node name="Spacer" type="Control" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
[node name="ApCaption" type="Label" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 11
text = "ui/character_panel/stat_ap"
uppercase = true
[node name="Pips" type="HBoxContainer" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
theme_override_constants/separation = 4
[node name="ApHint" type="Label" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 11
[node name="Rule" type="HSeparator" parent="Window/Column"]
layout_mode = 2
[node name="Body" type="MarginContainer" parent="Window/Column"]
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 2
theme_override_constants/margin_left = 18
theme_override_constants/margin_top = 14
theme_override_constants/margin_right = 18
theme_override_constants/margin_bottom = 0
[node name="BodyColumn" type="VBoxContainer" parent="Window/Column/Body"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 10
[node name="EmptyLabel" type="Label" parent="Window/Column/Body/BodyColumn"]
layout_mode = 2
size_flags_vertical = 3
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 14
text = "battle/empty_actions"
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 3
[node name="Grid" type="GridContainer" parent="Window/Column/Body/BodyColumn"]
layout_mode = 2
mouse_filter = 2
columns = 2
theme_override_constants/h_separation = 10
theme_override_constants/v_separation = 10
[node name="DescWrap" type="MarginContainer" parent="Window/Column"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_left = 18
theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 18
theme_override_constants/margin_bottom = 12
[node name="DescWell" type="PanelContainer" parent="Window/Column/DescWrap"]
custom_minimum_size = Vector2(0, 58)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="DescLabel" type="Label" parent="Window/Column/DescWrap/DescWell"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
autowrap_mode = 3
[node name="FooterRule" type="HSeparator" parent="Window/Column"]
layout_mode = 2
[node name="Footer" type="MarginContainer" parent="Window/Column"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_left = 18
theme_override_constants/margin_top = 9
theme_override_constants/margin_right = 18
theme_override_constants/margin_bottom = 11
[node name="FooterRow" type="HBoxContainer" parent="Window/Column/Footer"]
layout_mode = 2
mouse_filter = 2
[node name="HintLabel" type="Label" parent="Window/Column/Footer/FooterRow"]
layout_mode = 2
size_flags_vertical = 4
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 12
text = "battle/action_hint"
[node name="AutoEndLabel" type="Label" parent="Window/Column/Footer/FooterRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "battle/action_autoend"
+21
View File
@@ -0,0 +1,21 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/BattleBanner.cs" id="1_script"]
[node name="BattleBanner" type="Control" script=ExtResource("1_script")]
layout_mode = 3
mouse_filter = 2
[node name="Plate" type="PanelContainer" parent="."]
modulate = Color(1, 1, 1, 0)
layout_mode = 1
anchors_preset = 5
anchor_left = 0.5
anchor_right = 0.5
grow_horizontal = 2
mouse_filter = 2
[node name="Label" type="Label" parent="Plate"]
layout_mode = 2
theme_override_font_sizes/font_size = 30
horizontal_alignment = 1
+1 -1
View File
@@ -1,4 +1,4 @@
[gd_scene format=3] [gd_scene format=3]
[ext_resource type="Script" path="src/UI/BattleCardPanel.cs" id="1_script"] [ext_resource type="Script" path="src/UI/BattleCardPanel.cs" id="1_script"]
+76
View File
@@ -0,0 +1,76 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/BattleHud.cs" id="1_script"]
[node name="BattleHud" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(320, 88)
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
mouse_filter = 2
theme_override_constants/separation = 9
[node name="TitleRow" type="HBoxContainer" parent="Panel/Column"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 12
[node name="TitleLabel" type="Label" parent="Panel/Column/TitleRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "battle/hud_title"
uppercase = true
[node name="PhaseBadge" type="PanelContainer" parent="Panel/Column/TitleRow"]
layout_mode = 2
size_flags_horizontal = 8
size_flags_vertical = 4
mouse_filter = 2
[node name="PhaseLabel" type="Label" parent="Panel/Column/TitleRow/PhaseBadge"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
text = "battle/turn_player"
uppercase = true
[node name="MetaRow" type="HBoxContainer" parent="Panel/Column"]
layout_mode = 2
mouse_filter = 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 = "battle/round"
[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 = "battle/enemies_alive"
[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"
+65
View File
@@ -0,0 +1,65 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/BattleLogPanel.cs" id="1_script"]
[node name="BattleLogPanel" 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
mouse_filter = 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 = "battle/log_title"
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
mouse_filter = 2
theme_override_constants/separation = 0
+83
View File
@@ -0,0 +1,83 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/CardBattleScreen.cs" id="1_screen"]
[ext_resource type="Script" path="src/UI/CardController.cs" id="2_controller"]
[ext_resource type="PackedScene" path="scenes/battle_hud.tscn" id="3_hud"]
[ext_resource type="PackedScene" path="scenes/battle_card_panel.tscn" id="4_panel"]
[ext_resource type="PackedScene" path="scenes/battle_actions_panel.tscn" id="5_actions"]
[ext_resource type="PackedScene" path="scenes/battle_log_panel.tscn" id="6_log"]
[ext_resource type="PackedScene" path="scenes/battle_banner.tscn" id="7_banner"]
[ext_resource type="PackedScene" path="scenes/end_turn_button.tscn" id="8_endturn"]
[node name="CardBattleScreen" type="Control" script=ExtResource("1_screen")]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Hud" parent="." instance=ExtResource("3_hud")]
layout_mode = 1
offset_left = 20.0
offset_top = 26.0
offset_right = 340.0
offset_bottom = 114.0
[node name="EnemyPanel" parent="." instance=ExtResource("4_panel")]
layout_mode = 1
anchor_left = 0.02
anchor_top = 0.09375
anchor_right = 0.98
anchor_bottom = 0.53975
grow_horizontal = 2
grow_vertical = 2
[node name="PlayerPanel" parent="." instance=ExtResource("4_panel")]
layout_mode = 1
anchor_left = 0.01354
anchor_top = 0.5498
anchor_right = 0.56354
anchor_bottom = 0.96
grow_horizontal = 2
grow_vertical = 2
[node name="ActionsPanel" parent="." instance=ExtResource("5_actions")]
layout_mode = 1
anchor_left = 0.5297
anchor_top = 0.5498
anchor_right = 0.9865
anchor_bottom = 0.9521
grow_horizontal = 2
grow_vertical = 2
[node name="LogPanel" parent="." instance=ExtResource("6_log")]
layout_mode = 1
anchor_left = 0.2708
anchor_top = 0.5566
anchor_right = 0.5156
anchor_bottom = 0.9629
grow_horizontal = 2
grow_vertical = 2
[node name="EndTurnButton" parent="." instance=ExtResource("8_endturn")]
layout_mode = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -376.0
offset_top = 20.0
offset_right = -216.0
offset_bottom = 70.0
[node name="Banner" parent="." instance=ExtResource("7_banner")]
layout_mode = 1
anchor_top = 0.315
anchor_right = 1.0
anchor_bottom = 0.42
grow_horizontal = 2
[node name="CardController" type="Node" parent="." script=ExtResource("2_controller")]
[node name="DragLayer" type="CanvasLayer" parent="."]
layer = 100
+226
View File
@@ -0,0 +1,226 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/CharacterCardNode.cs" id="1_script"]
[node name="CharacterCardNode" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(230, 420)
layout_mode = 3
[node name="Art" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 4
[node name="TeamFrame" type="Panel" 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="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
[node name="Column" type="VBoxContainer" parent="Info"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 5
[node name="NameLabel" type="Label" parent="Info/Column"]
layout_mode = 2
theme_override_font_sizes/font_size = 14
horizontal_alignment = 1
clip_text = true
[node name="Stats" type="HBoxContainer" parent="Info/Column"]
layout_mode = 2
mouse_filter = 2
alignment = 1
theme_override_constants/separation = 4
[node name="HpChip" type="PanelContainer" parent="Info/Column/Stats"]
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="Row" type="HBoxContainer" parent="Info/Column/Stats/HpChip"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 4
[node name="Key" type="Label" parent="Info/Column/Stats/HpChip/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
text = "ui/character_panel/stat_hp"
uppercase = true
[node name="Value" type="Label" parent="Info/Column/Stats/HpChip/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 11
[node name="DefChip" type="PanelContainer" parent="Info/Column/Stats"]
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="Row" type="HBoxContainer" parent="Info/Column/Stats/DefChip"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 4
[node name="Key" type="Label" parent="Info/Column/Stats/DefChip/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
text = "ui/character_panel/stat_def"
uppercase = true
[node name="Value" type="Label" parent="Info/Column/Stats/DefChip/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 11
[node name="AtkChip" type="PanelContainer" parent="Info/Column/Stats"]
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="Row" type="HBoxContainer" parent="Info/Column/Stats/AtkChip"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 4
[node name="Key" type="Label" parent="Info/Column/Stats/AtkChip/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
text = "ui/character_panel/stat_atk"
uppercase = true
[node name="Value" type="Label" parent="Info/Column/Stats/AtkChip/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 11
[node name="ApChip" type="PanelContainer" parent="Info/Column/Stats"]
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="Row" type="HBoxContainer" parent="Info/Column/Stats/ApChip"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 4
[node name="Key" type="Label" parent="Info/Column/Stats/ApChip/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
text = "ui/character_panel/stat_ap"
uppercase = true
[node name="Value" type="Label" parent="Info/Column/Stats/ApChip/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 11
[node name="HpBar" type="ProgressBar" parent="Info/Column"]
custom_minimum_size = Vector2(0, 7)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
show_percentage = false
[node name="Status" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -160.0
offset_top = 10.0
offset_right = -10.0
offset_bottom = 40.0
grow_horizontal = 0
mouse_filter = 2
theme_override_constants/separation = 6
[node name="StunBadge" type="PanelContainer" parent="Status"]
visible = false
layout_mode = 2
size_flags_horizontal = 8
mouse_filter = 2
[node name="Row" type="HBoxContainer" parent="Status/StunBadge"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 5
[node name="StunIcon" type="TextureRect" parent="Status/StunBadge/Row"]
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="Status/StunBadge/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
uppercase = true
[node name="Zap" type="TextureRect" parent="."]
modulate = Color(1, 1, 1, 0)
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 14.0
offset_top = 20.0
offset_right = -14.0
offset_bottom = -70.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 5
[node name="Fx" type="Panel" parent="."]
modulate = Color(1, 1, 1, 0)
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="Selection" type="TextureRect" parent="."]
modulate = Color(1, 1, 1, 0)
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 0
-135
View File
@@ -1,135 +0,0 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/CharacterCardNode.cs" id="1_script"]
[node name="CharacterCardNode" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(230, 420)
layout_mode = 3
[node name="TeamOutline" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
offset_left = -4.0
offset_top = -4.0
offset_right = 4.0
offset_bottom = 4.0
mouse_filter = 2
[node name="Image" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
offset_left = 20.0
offset_top = 20.0
offset_right = -20.0
offset_bottom = -60.0
mouse_filter = 2
expand_mode = 1
stretch_mode = 4
[node name="Frame" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 4
[node name="Selection" type="TextureRect" 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="InfoPanel" type="Control" parent="."]
layout_mode = 1
anchors_preset = 12
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
offset_top = -60.0
offset_bottom = 0.0
[node name="NameLabel" type="Label" parent="InfoPanel"]
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
grow_horizontal = 2
offset_bottom = 18.0
horizontal_alignment = 1
theme_override_font_sizes/font_size = 16
[node name="HealthBar" type="Control" parent="InfoPanel"]
layout_mode = 1
anchors_preset = 12
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
offset_top = -18.0
offset_bottom = 0.0
[node name="HealthBg" type="ColorRect" parent="InfoPanel/HealthBar"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0.137255, 0.145098, 0.184314, 1)
[node name="HealthRect" type="ColorRect" parent="InfoPanel/HealthBar"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0.560784, 0.682353, 0.419608, 1)
[node name="Labels" type="HBoxContainer" parent="InfoPanel"]
layout_mode = 1
anchors_preset = 13
anchor_left = 0.0
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
grow_horizontal = 2
alignment = 1
[node name="LabelS" type="Label" parent="InfoPanel/Labels"]
text = "S"
horizontal_alignment = 1
[node name="DefenceLabel" type="Label" parent="InfoPanel/Labels"]
text = "0"
[node name="LabelA" type="Label" parent="InfoPanel/Labels"]
text = "A"
horizontal_alignment = 1
[node name="AttackLabel" type="Label" parent="InfoPanel/Labels"]
text = "0"
[node name="LabelP" type="Label" parent="InfoPanel/Labels"]
text = "P"
horizontal_alignment = 1
[node name="PointsLabel" type="Label" parent="InfoPanel/Labels"]
text = "0"
+2 -1
View File
@@ -1,4 +1,4 @@
[gd_scene format=3] [gd_scene format=3]
[ext_resource type="Script" path="src/UI/EndTurnButton.cs" id="1_script"] [ext_resource type="Script" path="src/UI/EndTurnButton.cs" id="1_script"]
@@ -14,3 +14,4 @@ anchor_bottom = 1.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
theme_override_font_sizes/font_size = 16 theme_override_font_sizes/font_size = 16
text = "battle/end_turn"
+48 -6
View File
@@ -1,5 +1,6 @@
using Cthangover.Core.Battle; using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions; using Cthangover.Core.Battle.Actions;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Cthangover.TurnBattle; using Cthangover.TurnBattle;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
@@ -167,12 +168,7 @@ namespace Cthangover.CardBattle.Actions
if (result.Result) if (result.Result)
{ {
if (result.Target.Damage > 0) LogAttackResult(result);
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, TargetControl, TargetControl.GlobalPosition);
var defenceLost = -result.Target.Defence;
if (defenceLost > 0)
ShowDamageBehaviour.SpawnDefence(defenceLost, TargetControl, TargetControl.GlobalPosition);
} }
else else
{ {
@@ -277,6 +273,52 @@ namespace Cthangover.CardBattle.Actions
} }
} }
/// <summary>
/// Mirrors the resolved action onto the UI: floating damage / absorbed
/// shield numbers, a colour pulse on the target card and journal rows, so
/// an AI attack reads the same as the player's own attack. Stun actions
/// (a "Turn" above 1) flash the electric zap instead of damage numbers.
/// </summary>
private void LogAttackResult(ChangedAttributes result)
{
var targetName = TranslationServer.Translate(Target.Card.Name).ToString();
var isStun = Action != null && Action.GetInt("Turn", 0) > 1;
var defenceLost = -result.Target.Defence;
if (defenceLost > 0)
{
ShowDamageBehaviour.SpawnDefence(defenceLost, TargetControl, TargetControl.GlobalPosition);
BattleLogHub.Log(
string.Format(Translate("battle/log_shield_absorb"), targetName, defenceLost),
UI.BattleLogKind.Shield);
}
if (isStun)
{
Target.PlayZap();
BattleLogHub.Log(
string.Format(Translate("battle/log_stun"), targetName, Action.GetInt("Turn", 0)),
UI.BattleLogKind.Status);
return;
}
if (result.Target.Damage > 0)
{
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, TargetControl, TargetControl.GlobalPosition);
Target.Flash(UiTokens.DangerText);
BattleLogHub.Log(
string.Format(Translate("battle/log_damage"),
Translate(Action.Name),
TranslationServer.Translate(Source.Card.Name).ToString(),
targetName,
result.Target.Damage),
UI.BattleLogKind.Damage);
}
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
private void InitializeMovementPattern() private void InitializeMovementPattern()
{ {
Vector2 direction = (TargetPos - SourcePos).Normalized(); Vector2 direction = (TargetPos - SourcePos).Normalized();
+3 -2
View File
@@ -1,3 +1,4 @@
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Godot; using Godot;
@@ -34,8 +35,8 @@ namespace Cthangover.CardBattle.Actions
return new ChangedAttributes { Result = false }; return new ChangedAttributes { Result = false };
var damageDelta = Mathf.RoundToInt(actionCard.GetFloat("Attack", 1f) * user.Attributes.Attack.Value); var damageDelta = Mathf.RoundToInt(actionCard.GetFloat("Attack", 1f) * user.Attributes.Attack.Value);
target.StatusEffectQueue.OnTakeDamage(user, ref damageDelta); StatusEffectService.Of(target).OnTakeDamage(user, ref damageDelta);
user.StatusEffectQueue.OnDealDamage(target, ref damageDelta); StatusEffectService.Of(user).OnDealDamage(target, ref damageDelta);
var defenceDelta = 0; var defenceDelta = 0;
var ignoreArmor = user.HasPassiveEffect("IgnoreArmor"); var ignoreArmor = user.HasPassiveEffect("IgnoreArmor");
+12
View File
@@ -1,5 +1,6 @@
using Cthangover.Core.Battle; using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions; using Cthangover.Core.Battle.Actions;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Cthangover.TurnBattle; using Cthangover.TurnBattle;
using Cthangover.CardBattle.UI; using Cthangover.CardBattle.UI;
@@ -119,7 +120,15 @@ namespace Cthangover.CardBattle.Actions
Target.UpdateInfo(); Target.UpdateInfo();
if (result.Result && result.Target.Defence > 0) if (result.Result && result.Target.Defence > 0)
{
ShowDamageBehaviour.SpawnDefence(result.Target.Defence, TargetControl, TargetControl.GlobalPosition); ShowDamageBehaviour.SpawnDefence(result.Target.Defence, TargetControl, TargetControl.GlobalPosition);
Target.Flash(UiTokens.Info);
BattleLogHub.Log(
string.Format(Translate("battle/log_shield_gain"),
TranslationServer.Translate(Target.Card.Name).ToString(),
result.Target.Defence),
UI.BattleLogKind.Buff);
}
} }
if (progress >= 1f) if (progress >= 1f)
@@ -212,5 +221,8 @@ namespace Cthangover.CardBattle.Actions
{ {
return t < 0.5f ? 2f * t * t : 1f - Mathf.Pow(-2f * t + 2f, 2f) / 2f; return t < 0.5f ? 2f * t * t : 1f - Mathf.Pow(-2f * t + 2f, 2f) / 2f;
} }
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
} }
} }
+2 -1
View File
@@ -1,3 +1,4 @@
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
namespace Cthangover.CardBattle.Actions namespace Cthangover.CardBattle.Actions
@@ -30,7 +31,7 @@ namespace Cthangover.CardBattle.Actions
if (target.HasPassiveEffect("StunImmune")) if (target.HasPassiveEffect("StunImmune"))
return new ChangedAttributes { Result = true }; return new ChangedAttributes { Result = true };
target.StatusEffectQueue.Add("effect/physics/stun", actionCard.GetInt("Turn", 3)); StatusEffectService.Of(target).Add("effect/physics/stun", actionCard.GetInt("Turn", 3));
return new ChangedAttributes { Result = true }; return new ChangedAttributes { Result = true };
} }
+24
View File
@@ -0,0 +1,24 @@
using System;
using Cthangover.CardBattle.UI;
namespace Cthangover.CardBattle
{
/// <summary>
/// Static sink for battle journal entries. Player action strategies and
/// the battle core run without a reference to the UI screen, so they
/// publish here instead of reaching into the scene tree; the
/// <see cref="BattleLogPanel"/> subscribes while it exists and unsubscribes
/// on tree exit, which keeps the hub empty outside of a battle. This is the
/// same service-locator pattern the battle code already uses for
/// <c>ActionExecutorHub</c> and <c>ShowDamageBehaviour</c>.
/// </summary>
public static class BattleLogHub
{
/// <summary>Fired for every entry; handlers receive the text and its kind.</summary>
public static event Action<string, BattleLogKind> Entry;
/// <summary>Publishes one journal row. No-op when no battle UI is listening.</summary>
public static void Log(string text, BattleLogKind kind = BattleLogKind.Info)
=> Entry?.Invoke(text, kind);
}
}
+131 -123
View File
@@ -4,7 +4,9 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Cthangover.Core.Battle; using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions; using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Scenes; using Cthangover.Core.Scenes;
using Cthangover.Core.UI.CharacterPanel;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Cthangover.CardBattle.Player; using Cthangover.CardBattle.Player;
@@ -14,49 +16,58 @@ using Godot;
namespace Cthangover.CardBattle namespace Cthangover.CardBattle
{ {
/// <summary> /// <summary>
/// Core orchestrator for the card-based turn battle system. /// Core orchestrator for the card-based turn battle system. Owns the full
/// Manages the full battle lifecycle: initializing UI panels (<see cref="BattleCardPanel"/> and <see cref="CardController"/>), /// battle lifecycle: it instantiates the UI screen rebuilt from the mockup
/// cycling between player and enemy turns, spawning animated actions via <see cref="AbstractBattleAction"/>, /// (mockups/card_battle, defined in <c>scenes/card_battle_screen.tscn</c>),
/// applying status effects each turn, and detecting win/loss conditions when all cards on one side are dead. /// cycles between player and enemy turns, drives the animated enemy actions
/// Implements <see cref="IBattleCore"/> so it can be registered as a battle type provider in the core battle engine. /// through <see cref="AbstractBattleAction"/>, applies status effects each
/// turn and detects win/loss when one side loses all cards.
/// <para/>
/// The core talks to the screen through its assembled widgets
/// (<see cref="CardBattleScreen.Hud"/>, <see cref="BattleLogPanel"/>,
/// <see cref="BattleBanner"/>, panels) instead of walking the scene tree, and
/// publishes journal rows via <see cref="BattleLogHub"/> so other battle code
/// can log without a UI reference. Implements <see cref="IBattleCore"/> so the
/// battle engine can select this mod as the active battle type.
/// </summary> /// </summary>
public class CardBattleCore : IBattleCore, IBattleAutoPlayable public class CardBattleCore : IBattleCore, IBattleAutoPlayable
{ {
/// <summary> private const int PlayerCardLimit = 4;
/// Unique identifier for this battle core, used by the battle engine to select the card battle mod. private const float BaseCardWidth = 230f;
/// </summary> private const float BaseCardHeight = 420f;
public string Id => "card_battle"; private const float CellOffset = 10f;
/// <summary>
/// Provides <see cref="IActionExecutor"/> instances for the three card action types
/// (physics/attack, physics/defence, physics/stun) used during action resolution.
/// </summary>
public IActionExecutorProvider ActionProvider { get; } = new Actions.CardBattleActionProvider();
private Character[] _playerChars; private Character[] _playerChars;
private Character[] _enemyChars; private Character[] _enemyChars;
private IBattleContext _ctx; private IBattleContext _ctx;
private CardBattleScreen _screen;
private BattleCardPanel _playerPanel; private BattleCardPanel _playerPanel;
private BattleCardPanel _enemyPanel; private BattleCardPanel _enemyPanel;
private CardController _cardController; private CardController _cardController;
private Control _actionPanel;
private Control _toolPanel;
private EndTurnButton _endTurnButton; private EndTurnButton _endTurnButton;
private BattleSide _currentOrder; private BattleSide _currentOrder;
private bool _playerTurnActive; private bool _playerTurnActive;
private int _round;
private const float CELL_OFFSET = 10f; /// <summary>Unique identifier for this battle core, used by the battle engine to select the card battle mod.</summary>
private const int PLAYER_CARD_LIMIT = 4; public string Id => "card_battle";
private const float BASE_CARD_WIDTH = 230f;
private const float BASE_CARD_HEIGHT = 420f;
/// <summary> /// <summary>
/// Initializes the card battle by constructing the player and enemy <see cref="BattleCardPanel"/> instances, /// Provides <see cref="IActionExecutor"/> instances for the three card
/// the <see cref="CardController"/> for drag-and-drop input, and the <see cref="EndTurnButton"/>. /// action types (physics/attack, physics/defence, physics/stun) used
/// Enemy characters are deep-copied so defeats are tracked independently per battle instance. /// during action resolution.
/// The <see cref="CardBattleActionProvider"/> is registered with <see cref="ActionExecutorHub"/>. /// </summary>
public IActionExecutorProvider ActionProvider { get; } = new Actions.CardBattleActionProvider();
/// <summary>
/// Instantiates the battle screen from <c>scenes/card_battle_screen.tscn</c>,
/// attaches it to the battle scene's <c>Panel</c> and binds the panels,
/// end-turn button and input controller. Enemy characters are deep-copied
/// so defeats are tracked independently per battle instance. The
/// <see cref="CardBattleActionProvider"/> is registered with
/// <see cref="ActionExecutorHub"/>.
/// </summary> /// </summary>
public void Init(Character[] playerChars, Character[] enemyChars, IBattleContext ctx) public void Init(Character[] playerChars, Character[] enemyChars, IBattleContext ctx)
{ {
@@ -64,71 +75,42 @@ namespace Cthangover.CardBattle
_ctx = ctx; _ctx = ctx;
_enemyChars = new Character[enemyChars.Length]; _enemyChars = new Character[enemyChars.Length];
for (int i = 0; i < enemyChars.Length; i++) for (var i = 0; i < enemyChars.Length; i++)
_enemyChars[i] = enemyChars[i]?.Copy() ?? enemyChars[i]; _enemyChars[i] = enemyChars[i]?.Copy() ?? enemyChars[i];
ActionExecutorHub.Instance.SetActiveProvider(ActionProvider); ActionExecutorHub.Instance.SetActiveProvider(ActionProvider);
var root = ctx.RootNode as Node; var root = ctx.RootNode as Node;
var panel = root.GetNodeOrNull<Control>("Panel") ?? root; var panel = root.GetNodeOrNull<Control>("Panel") ?? root;
_playerPanel = (BattleCardPanel)TscnScenes.LoadAndBuild("scenes/battle_card_panel.tscn"); _screen = (CardBattleScreen)TscnScenes.LoadAndBuild("scenes/card_battle_screen.tscn");
_playerPanel.Name = "PlayerPanel"; _screen.Name = "CardBattleScreen";
_playerPanel.EnsureConstructed(); _screen.EnsureConstructed();
panel.AddChild(_playerPanel); panel.AddChild(_screen);
_enemyPanel = (BattleCardPanel)TscnScenes.LoadAndBuild("scenes/battle_card_panel.tscn"); _playerPanel = _screen.PlayerPanel;
_enemyPanel.Name = "EnemyPanel"; _enemyPanel = _screen.EnemyPanel;
_enemyPanel.AlignType = "Right";
_enemyPanel.EnsureConstructed();
_enemyPanel.MouseFilter = Control.MouseFilterEnum.Ignore;
panel.AddChild(_enemyPanel);
_actionPanel = new Control { Name = "ActionPanel" }; _endTurnButton = _screen.EndTurnButton;
panel.AddChild(_actionPanel);
_toolPanel = root.GetNodeOrNull<Control>("ToolPanel");
_endTurnButton = (EndTurnButton)TscnScenes.LoadAndBuild("scenes/end_turn_button.tscn");
_endTurnButton.Name = "EndTurnButton";
_endTurnButton.EnsureConstructed();
_endTurnButton.SetVisible(false); _endTurnButton.SetVisible(false);
_endTurnButton.OnPressed += OnEndTurnPressed; _endTurnButton.OnPressed += OnEndTurnPressed;
_toolPanel?.AddChild(_endTurnButton);
_cardController = new CardController { Name = "CardController" }; _cardController = _screen.Controller;
panel.AddChild(_cardController); _cardController.Setup(_screen);
_cardController.FindPanels();
} }
/// <summary> /// <summary>
/// Positions the UI panels relative to the viewport, calculates a unified <c>cardScale</c> that /// Calculates the pointer scale fitting both sides on screen, initializes
/// fits both player and enemy cards on screen, initializes all character cards, and starts the first player turn. /// every card and starts the first player turn. Subscribes to
/// Subscribes to <c>OnCardDead</c> to record defeated enemies and to <c>OnActionExecuted</c> to detect /// <c>OnCardDead</c> to record defeated enemies for the victory screen and
/// automatic turn completion when all player cards exhaust their action points. /// to <c>OnActionExecuted</c> to detect automatic turn completion.
/// </summary> /// </summary>
public void Start() public void Start()
{ {
var root = _ctx.RootNode as Node;
var panelSize = root.GetViewport().GetVisibleRect().Size;
var enemyCount = _enemyChars?.Length ?? 1; var enemyCount = _enemyChars?.Length ?? 1;
_enemyPanel.UseRows = enemyCount > 6; _enemyPanel.UseRows = enemyCount > 6;
_playerPanel.Position = new Vector2(0, panelSize.Y * 0.55f);
_playerPanel.Size = new Vector2(panelSize.X * 0.55f, panelSize.Y * 0.35f);
_enemyPanel.Position = new Vector2(panelSize.X * 0.02f, panelSize.Y * 0.022f);
_enemyPanel.Size = new Vector2(panelSize.X * 0.96f, panelSize.Y * 0.446f);
_actionPanel.Position = new Vector2(panelSize.X * 0.53f, panelSize.Y * 0.55f);
_actionPanel.Size = new Vector2(panelSize.X * 0.46f, panelSize.Y * 0.35f);
if (_endTurnButton != null)
_endTurnButton.Position = new Vector2(panelSize.X - 180f, (panelSize.Y * 0.1f - 50f) / 2);
var cardScale = CalculateCardScale(enemyCount); var cardScale = CalculateCardScale(enemyCount);
_cardController.CardScale = cardScale; _cardController.CardScale = cardScale;
@@ -140,6 +122,7 @@ namespace Cthangover.CardBattle
BattleSceneContext.Instance.OnBattleCleared += OnBattleCleared; BattleSceneContext.Instance.OnBattleCleared += OnBattleCleared;
_round = 0;
_currentOrder = BattleSide.Player; _currentOrder = BattleSide.Player;
StartPlayerTurn(); StartPlayerTurn();
} }
@@ -147,31 +130,31 @@ namespace Cthangover.CardBattle
private float CalculateCardScale(int enemyCount) private float CalculateCardScale(int enemyCount)
{ {
var viewport = ((Node)_ctx.RootNode).GetViewport().GetVisibleRect().Size; var viewport = ((Node)_ctx.RootNode).GetViewport().GetVisibleRect().Size;
float enemyPanelWidth = viewport.X * 0.96f; var enemyPanelWidth = viewport.X * 0.96f;
float playerPanelWidth = viewport.X * 0.55f; var playerPanelWidth = viewport.X * 0.55f;
float enemyPanelHeight = viewport.Y * 0.446f; var enemyPanelHeight = viewport.Y * 0.446f;
float maxCardScale = 1f;
float maxCardScale;
if (_enemyPanel.UseRows) if (_enemyPanel.UseRows)
{ {
int maxPerRow = _enemyPanel.GetMaxCardsPerRow(); var maxPerRow = _enemyPanel.GetMaxCardsPerRow();
int rows = _enemyPanel.GetCurrentRowCount(enemyCount); var rows = _enemyPanel.GetCurrentRowCount(enemyCount);
float widthScale = (enemyPanelWidth - (maxPerRow - 1) * CELL_OFFSET) / (maxPerRow * BASE_CARD_WIDTH); var widthScale = (enemyPanelWidth - (maxPerRow - 1) * CellOffset) / (maxPerRow * BaseCardWidth);
float heightScale = (enemyPanelHeight - (rows - 1) * CELL_OFFSET) / (rows * BASE_CARD_HEIGHT); var heightScale = (enemyPanelHeight - (rows - 1) * CellOffset) / (rows * BaseCardHeight);
maxCardScale = Mathf.Max(0.1f, Mathf.Min(widthScale, heightScale)); maxCardScale = Mathf.Max(0.1f, Mathf.Min(widthScale, heightScale));
} }
else else
{ {
int effectiveCount = Mathf.Max(enemyCount, 1); var effectiveCount = Mathf.Max(enemyCount, 1);
maxCardScale = Mathf.Max(0.1f, (enemyPanelWidth - (effectiveCount - 1) * CELL_OFFSET) / (effectiveCount * BASE_CARD_WIDTH)); maxCardScale = Mathf.Max(0.1f,
(enemyPanelWidth - (effectiveCount - 1) * CellOffset) / (effectiveCount * BaseCardWidth));
} }
float playerFitScale = Mathf.Max(0.1f, (playerPanelWidth - (PLAYER_CARD_LIMIT - 1) * CELL_OFFSET) / (PLAYER_CARD_LIMIT * BASE_CARD_WIDTH)); var playerFitScale = Mathf.Max(0.1f,
(playerPanelWidth - (PlayerCardLimit - 1) * CellOffset) / (PlayerCardLimit * BaseCardWidth));
float cardScale = Mathf.Min(maxCardScale, playerFitScale); return Mathf.Min(Mathf.Min(maxCardScale, playerFitScale), 1f);
return Mathf.Min(cardScale, 1f);
} }
private void OnEnemyCardDead(CharacterCardNode card) private void OnEnemyCardDead(CharacterCardNode card)
@@ -185,34 +168,43 @@ namespace Cthangover.CardBattle
ctx.RecordEnemyDefeated(card.Card); ctx.RecordEnemyDefeated(card.Card);
ctx.NotifyCharacterDied(card.Card); ctx.NotifyCharacterDied(card.Card);
} }
_screen.Hud.SetEnemiesAlive(CountAlive(_enemyPanel));
BattleLogHub.Log(string.Format(Translate("battle/log_dead"), card.Card.Name), BattleLogKind.Dead);
} }
private void StartPlayerTurn() private void StartPlayerTurn()
{ {
GameLogger.Log("CARD_BATTLE", "Player turn started", LogLevel.Debug); _round += 1;
_currentOrder = BattleSide.Player; _currentOrder = BattleSide.Player;
_playerTurnActive = true; _playerTurnActive = true;
BattleSceneContext.Instance.IsWait = false; BattleSceneContext.Instance.IsWait = false;
_cardController.ClearSelections(); _cardController.ClearSelections();
_cardController.ClearActions(); _cardController.ClearActions();
_screen.ActionsPanel.ClearBody();
_screen.ActionsPanel.ClearSubject();
_endTurnButton?.SetVisible(true); _endTurnButton?.SetVisible(true);
foreach (var card in _playerPanel.CardList) foreach (var card in _playerPanel.CardList)
{ {
card.Card.StatusEffectQueue.OnTurnStart(); StatusEffectService.Of(card.Card).OnTurnStart();
card.Card.Attributes.Point.Value = card.Card.Attributes.Point.BaseValue; card.Card.Attributes.Point.Value = card.Card.Attributes.Point.BaseValue;
card.UpdateInfo(); card.UpdateInfo();
} }
_screen.Hud.SetTurn(true);
_screen.Hud.SetRound(_round);
_screen.Hud.SetEnemiesAlive(CountAlive(_enemyPanel));
_screen.LogPanel.SetRound(_round);
_screen.Banner.Show(Translate("battle/turn_player"));
BattleLogHub.Log(
Translate("battle/log_turn_player") + " · " + string.Format(Translate("battle/log_round"), _round),
BattleLogKind.Turn);
} }
private void OnEndTurnPressed() private void OnEndTurnPressed() => OnPlayerTurnEnd();
{
GameLogger.Log("CARD_BATTLE", "End turn button pressed", LogLevel.Debug);
OnPlayerTurnEnd();
}
private void CheckPlayerTurnEnd() private void CheckPlayerTurnEnd()
{ {
@@ -225,30 +217,24 @@ namespace Cthangover.CardBattle
return; return;
} }
bool allOutOfPoints = true;
foreach (var card in _playerPanel.CardList) foreach (var card in _playerPanel.CardList)
{ {
if (!card.IsDead && card.Card?.Attributes?.Point?.Value > 0) if (!card.IsDead && card.Card?.Attributes?.Point?.Value > 0)
{ return;
allOutOfPoints = false;
break;
}
} }
if (allOutOfPoints) BattleLogHub.Log(Translate("battle/log_out_of_points"), BattleLogKind.Info);
OnPlayerTurnEnd(); OnPlayerTurnEnd();
} }
/// <summary> /// <summary>
/// Ends the player's turn by hiding the end-turn button, setting the battle to <c>IsWait</c>, /// Ends the player's turn: hides the end-turn button, pauses the battle,
/// and yielding two frames for UI to settle before running the enemy AI turn via <c>RunEnemyTurn</c>. /// plays the enemy-turn banner and journal line, waits for two frames plus
/// If all enemies are already dead, the battle ends immediately with the player as winner. /// a short pause so the UI settles, then runs the enemy AI. Ends the battle
/// This method is <c>async void</c> so it can await frames without blocking the game loop. /// immediately when no enemies remain.
/// </summary> /// </summary>
public async void OnPlayerTurnEnd() public async void OnPlayerTurnEnd()
{ {
GameLogger.Log("CARD_BATTLE", "Player turn ended", LogLevel.Debug);
_playerTurnActive = false; _playerTurnActive = false;
_endTurnButton?.SetVisible(false); _endTurnButton?.SetVisible(false);
BattleSceneContext.Instance.IsWait = true; BattleSceneContext.Instance.IsWait = true;
@@ -259,6 +245,10 @@ namespace Cthangover.CardBattle
return; return;
} }
_screen.Hud.SetTurn(false);
_screen.Banner.Show(Translate("battle/turn_enemy"));
BattleLogHub.Log(Translate("battle/log_turn_enemy"), BattleLogKind.Turn);
var root = (Node)_ctx.RootNode; var root = (Node)_ctx.RootNode;
await root.ToSignal(root.GetTree(), "process_frame"); await root.ToSignal(root.GetTree(), "process_frame");
await root.ToSignal(root.GetTree().CreateTimer(0.5f), "timeout"); await root.ToSignal(root.GetTree().CreateTimer(0.5f), "timeout");
@@ -268,8 +258,6 @@ namespace Cthangover.CardBattle
private async Task RunEnemyTurn() private async Task RunEnemyTurn()
{ {
GameLogger.Log("CARD_BATTLE", "Enemy turn started", LogLevel.Debug);
_currentOrder = BattleSide.Enemy; _currentOrder = BattleSide.Enemy;
_endTurnButton?.SetVisible(false); _endTurnButton?.SetVisible(false);
@@ -280,11 +268,15 @@ namespace Cthangover.CardBattle
if (card.IsDead || card.Card?.Actions == null || card.Card.Actions.Count == 0) if (card.IsDead || card.Card?.Actions == null || card.Card.Actions.Count == 0)
continue; continue;
card.Card.StatusEffectQueue.OnTurnStart(); StatusEffectService.Of(card.Card).OnTurnStart();
if (card.Card.StatusEffectQueue.HasStun()) if (StatusEffectService.Of(card.Card).HasStun())
{ {
GameLogger.Log("CARD_BATTLE", $"{card.Card.Name} is stunned, skipping", LogLevel.Debug); card.UpdateInfo();
card.PlayZap();
BattleLogHub.Log(
string.Format(Translate("battle/log_stun_skip"), card.Card.Name),
BattleLogKind.Status);
continue; continue;
} }
@@ -298,9 +290,17 @@ namespace Cthangover.CardBattle
if (target == null) if (target == null)
continue; continue;
GameLogger.Log("CARD_BATTLE", card.UpdateInfo();
$"Enemy action: {card.Card.Name} -> {action.Name} -> {target.Card.Name}",
LogLevel.Message); if (action.Type == ActionCharacterType.ToEnemy)
{
BattleLogHub.Log(
string.Format(Translate("battle/log_enemy_action"),
card.Card.Name,
ActionCharacterUi.Translate(action.Name),
target.Card.Name),
BattleLogKind.Info);
}
var animatedAction = CreateAnimatedAction(card, target, action); var animatedAction = CreateAnimatedAction(card, target, action);
if (animatedAction != null) if (animatedAction != null)
@@ -315,7 +315,6 @@ namespace Cthangover.CardBattle
if (!HasAliveCards(_playerPanel)) if (!HasAliveCards(_playerPanel))
{ {
GameLogger.Log("CARD_BATTLE", "All players dead — enemy wins", LogLevel.Message);
_ctx.EndBattle(BattleSide.Enemy); _ctx.EndBattle(BattleSide.Enemy);
return; return;
} }
@@ -336,8 +335,7 @@ namespace Cthangover.CardBattle
if (actions == null || actions.Count == 0) if (actions == null || actions.Count == 0)
return null; return null;
var random = new Random(); return actions[GD.RandRange(0, actions.Count - 1)];
return actions[random.Next(actions.Count)];
} }
private CharacterCardNode PickEnemyTarget(CharacterCardNode source, ActionCharacter action) private CharacterCardNode PickEnemyTarget(CharacterCardNode source, ActionCharacter action)
@@ -350,7 +348,7 @@ namespace Cthangover.CardBattle
if (alivePlayers.Count == 0) if (alivePlayers.Count == 0)
return null; return null;
return alivePlayers[new Random().Next(alivePlayers.Count)]; return alivePlayers[GD.RandRange(0, alivePlayers.Count - 1)];
} }
if (action.Type == ActionCharacterType.ToAlias || action.Type == ActionCharacterType.ToSelf) if (action.Type == ActionCharacterType.ToAlias || action.Type == ActionCharacterType.ToSelf)
@@ -360,7 +358,8 @@ namespace Cthangover.CardBattle
return null; return null;
} }
private Actions.AbstractBattleAction CreateAnimatedAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action) private Actions.AbstractBattleAction CreateAnimatedAction(
CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
{ {
if (action.Type == ActionCharacterType.ToEnemy) if (action.Type == ActionCharacterType.ToEnemy)
return new Actions.PhysicsAttackAction(source, target, action); return new Actions.PhysicsAttackAction(source, target, action);
@@ -372,7 +371,7 @@ namespace Cthangover.CardBattle
return null; return null;
} }
private bool HasAliveCards(BattleCardPanel panel) private static bool HasAliveCards(BattleCardPanel panel)
{ {
foreach (var card in panel.CardList) foreach (var card in panel.CardList)
{ {
@@ -382,14 +381,25 @@ namespace Cthangover.CardBattle
return false; return false;
} }
private static int CountAlive(BattleCardPanel panel)
{
var alive = 0;
foreach (var card in panel.CardList)
{
if (!card.IsDead && card.Card?.Attributes?.Health?.Value > 0)
alive++;
}
return alive;
}
private void OnBattleCleared() private void OnBattleCleared()
{ {
_playerPanel?.ClearAll(); _screen?.Clear();
_enemyPanel?.ClearAll();
_cardController?.ClearActions();
_endTurnButton?.SetVisible(false);
} }
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
/// <summary> /// <summary>
/// <c>true</c> between <see cref="StartPlayerTurn"/> and the start of /// <c>true</c> between <see cref="StartPlayerTurn"/> and the start of
/// <see cref="OnPlayerTurnEnd"/> — matches the visibility window of the /// <see cref="OnPlayerTurnEnd"/> — matches the visibility window of the
@@ -397,9 +407,7 @@ namespace Cthangover.CardBattle
/// </summary> /// </summary>
public bool IsWaitingForPlayerInput => _playerTurnActive; public bool IsWaitingForPlayerInput => _playerTurnActive;
/// <summary> /// <summary>Automation hook: ends the player's turn without selecting cards.</summary>
/// Automation hook: ends the player's turn without selecting cards.
/// </summary>
public void AdvancePlayerTurn() => OnPlayerTurnEnd(); public void AdvancePlayerTurn() => OnPlayerTurnEnd();
} }
} }
+6 -14
View File
@@ -2,23 +2,15 @@
namespace Cthangover.CardBattle namespace Cthangover.CardBattle
{ {
/// <summary> /// <summary>
/// Common interface for all card UI nodes in the card battle system. /// Common contract for every card the input controller can hit-test. Both
/// Exposes the <c>Frame</c> and <c>Image</c> <see cref="TextureRect"/> controls so that /// the character cards on the field and the draggable action cards expose
/// <see cref="CardController"/> can perform hit-testing during drag-and-drop targeting /// their root <see cref="Control"/>, because layout, scale and the drag
/// without knowing the concrete card type (<see cref="CharacterCardNode"/> or <see cref="ActionCardNode"/>). /// ghost all operate on the whole card rect rather than on an inner sprite.
/// </summary> /// </summary>
public interface ICard public interface ICard
{ {
/// <summary> /// <summary>Root control used for hit-testing and for reparenting during a drag.</summary>
/// The frame <see cref="TextureRect"/> used for hit-testing whether a pointer is over this card. Control GetControlNode();
/// </summary>
TextureRect Frame { get; }
/// <summary>
/// The portrait/image <see cref="TextureRect"/> of the card, used for dissolve shader effects on death.
/// </summary>
TextureRect Image { get; }
} }
} }
+53 -16
View File
@@ -1,30 +1,31 @@
using Cthangover.CardBattle.UI; using Cthangover.CardBattle.UI;
using Cthangover.Core.Battle; using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions; using Cthangover.Core.Battle.Actions;
using Cthangover.Core.UI.Theming;
using Cthangover.TurnBattle; using Cthangover.TurnBattle;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.CardBattle.Player namespace Cthangover.CardBattle.Player
{ {
/// <summary> /// <summary>
/// Strategy for <c>ToEnemy</c> attack actions. Validates that the target is an enemy /// Strategy for <c>ToEnemy</c> actions. Validates that the target is an enemy,
/// (non-player card), highlights both cards with a red attack outline, and executes /// highlights both cards with a red attack outline, executes the action via
/// damage via <see cref="ActionExecutorHub"/> with floating damage/defence numbers. /// <see cref="ActionExecutorHub"/> and reports the outcome: floating damage /
/// This is the standard strategy for all offensive cards in the player's deck. /// defence-loss numbers on the target, a colour pulse on the card and a
/// journal row through <see cref="BattleLogHub"/>. Stun actions (those with a
/// "Turn" above 1) additionally flash the electric zap over the target
/// instead of printing damage, matching the mockup's stun feedback.
/// </summary> /// </summary>
public class AttackToEnemyStrategy : ICardActionStrategy public class AttackToEnemyStrategy : ICardActionStrategy
{ {
/// <summary> /// <summary>Returns <c>true</c> if the action type is <c>ToEnemy</c> and the target is an enemy card.</summary>
/// Returns <c>true</c> if the action type is <c>ToEnemy</c> and the target is an enemy card.
/// </summary>
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target) public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
{ {
return action.Type == ActionCharacterType.ToEnemy && target != null && !target.IsPlayer; return action.Type == ActionCharacterType.ToEnemy && target != null && !target.IsPlayer;
} }
/// <summary> /// <summary>Shows a red attack overlay on both the dragged action card and the target enemy card.</summary>
/// Shows a red attack overlay on both the dragged action card and the target enemy card.
/// </summary>
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target) public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
{ {
actionCard.Attack(); actionCard.Attack();
@@ -32,8 +33,9 @@ namespace Cthangover.CardBattle.Player
} }
/// <summary> /// <summary>
/// Resolves the attack via <see cref="ActionExecutorHub"/> and spawns floating damage /// Resolves the attack and publishes its result: damage and absorbed
/// and defence-loss numbers above the <paramref name="target"/> card. /// shield as floating numbers plus journal rows, or the stun effect with
/// its zap flash for control actions.
/// </summary> /// </summary>
public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action) public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
{ {
@@ -44,15 +46,50 @@ namespace Cthangover.CardBattle.Player
source.UpdateInfo(); source.UpdateInfo();
target.UpdateInfo(); target.UpdateInfo();
if (result.Result) if (!result.Result)
{ return;
if (result.Target.Damage > 0)
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, target, target.GlobalPosition); var actionName = Translate(action.Name);
var sourceName = Translate(source.Card.Name);
var targetName = Translate(target.Card.Name);
var isStun = action.GetInt("Turn", 0) > 1;
var defenceLost = -result.Target.Defence; var defenceLost = -result.Target.Defence;
if (defenceLost > 0) if (defenceLost > 0)
{
ShowDamageBehaviour.SpawnDefence(defenceLost, target, target.GlobalPosition); ShowDamageBehaviour.SpawnDefence(defenceLost, target, target.GlobalPosition);
BattleLogHub.Log(
string.Format(Translate("battle/log_shield_absorb"), targetName, defenceLost),
BattleLogKind.Shield);
}
if (isStun)
{
target.PlayZap();
BattleLogHub.Log(
string.Format(Translate("battle/log_stun"), targetName, action.GetInt("Turn", 0)),
BattleLogKind.Status);
return;
}
if (result.Target.Damage > 0)
{
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, target, target.GlobalPosition);
target.Flash(UiTokens.DangerText);
BattleLogHub.Log(
string.Format(Translate("battle/log_damage"),
actionName, sourceName, targetName, result.Target.Damage),
BattleLogKind.Damage);
}
else if (defenceLost == 0)
{
BattleLogHub.Log(
string.Format(Translate("battle/log_absorbed"), actionName, sourceName, targetName),
BattleLogKind.Shield);
} }
} }
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
} }
} }
+22 -18
View File
@@ -1,41 +1,36 @@
using Cthangover.CardBattle.UI; using Cthangover.CardBattle.UI;
using Cthangover.Core.Battle; using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions; using Cthangover.Core.Battle.Actions;
using Cthangover.Core.UI.Theming;
using Cthangover.TurnBattle; using Cthangover.TurnBattle;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.CardBattle.Player namespace Cthangover.CardBattle.Player
{ {
/// <summary> /// <summary>
/// Strategy for self-targeting actions (<c>ToSelf</c>). Validates that source and target /// Strategy for self-targeting actions (<c>ToSelf</c>). Validates that the
/// are the same card, highlights with a green selection outline, and applies the action /// source and target are the same card, highlights with a green selection
/// to the source card itself via <see cref="ActionExecutorHub"/>. Used for self-buffs like /// outline and applies the effect to the caster. Shield gains print a
/// defence boosts. Damage and defence floating text spawn above the source card. /// floating blue number and a journal row; the card pulses to make the buff
/// readable without reading the log.
/// </summary> /// </summary>
public class SelfStrategy : ICardActionStrategy public class SelfStrategy : ICardActionStrategy
{ {
/// <summary> /// <summary>Returns <c>true</c> only when <paramref name="source"/> and <paramref name="target"/> are the same card.</summary>
/// Returns <c>true</c> only when <paramref name="source"/> and <paramref name="target"/> are the same card.
/// </summary>
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target) public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
{ {
return source != null && source == target; return source != null && source == target;
} }
/// <summary> /// <summary>Shows a green selection overlay on both the dragged action card and the source card.</summary>
/// Shows a green selection overlay on both the dragged action card and the source card.
/// </summary>
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target) public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
{ {
actionCard.Select(); actionCard.Select();
target.Select(); target.Select();
} }
/// <summary> /// <summary>Executes the self-targeted action and reports shields and healing.</summary>
/// Executes the self-targeted action and spawns floating defence/damage numbers
/// above the <paramref name="source"/> card. Damage numbers are marked with the
/// <c>isHeal</c> flag for green coloring via <see cref="ShowDamageBehaviour"/>.
/// </summary>
public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action) public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
{ {
if (source?.Card == null || action == null) if (source?.Card == null || action == null)
@@ -44,14 +39,23 @@ namespace Cthangover.CardBattle.Player
var result = ActionExecutorHub.Instance.Execute(action, source.Card, source.Card); var result = ActionExecutorHub.Instance.Execute(action, source.Card, source.Card);
source.UpdateInfo(); source.UpdateInfo();
if (result.Result) if (!result.Result)
{ return;
if (result.Target.Defence > 0) if (result.Target.Defence > 0)
{
ShowDamageBehaviour.SpawnDefence(result.Target.Defence, source, source.GlobalPosition); ShowDamageBehaviour.SpawnDefence(result.Target.Defence, source, source.GlobalPosition);
source.Flash(UiTokens.Info);
BattleLogHub.Log(
string.Format(Translate("battle/log_shield_gain"), Translate(source.Card.Name), result.Target.Defence),
BattleLogKind.Buff);
}
if (result.Target.Damage > 0) if (result.Target.Damage > 0)
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, source, source.GlobalPosition, true); ShowDamageBehaviour.SpawnDamage(result.Target.Damage, source, source.GlobalPosition, true);
} }
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
} }
} }
+22 -17
View File
@@ -1,40 +1,36 @@
using Cthangover.CardBattle.UI; using Cthangover.CardBattle.UI;
using Cthangover.Core.Battle; using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions; using Cthangover.Core.Battle.Actions;
using Cthangover.Core.UI.Theming;
using Cthangover.TurnBattle; using Cthangover.TurnBattle;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.CardBattle.Player namespace Cthangover.CardBattle.Player
{ {
/// <summary> /// <summary>
/// Strategy for ally-support actions (<c>ToAlias</c>). Validates that the target is a friendly /// Strategy for ally-support actions (<c>ToAlias</c>). Validates that the
/// player card, highlights with green selection overlays, and applies defence/healing effects /// target is a friendly card, highlights with green selection overlays and
/// via <see cref="ActionExecutorHub"/>. Allows one player card to buff another during the player's turn. /// applies the effect via <see cref="ActionExecutorHub"/>. Shield gains are
/// shown as floating blue numbers, a colour pulse on the ally and a journal
/// row; healing, when the executor produces it, prints a green number.
/// </summary> /// </summary>
public class SupportStrategy : ICardActionStrategy public class SupportStrategy : ICardActionStrategy
{ {
/// <summary> /// <summary>Returns <c>true</c> when the action type is <c>ToAlias</c> and the target is a player card.</summary>
/// Returns <c>true</c> when the action type is <c>ToAlias</c> and the target is a player card.
/// </summary>
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target) public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
{ {
return action.Type == ActionCharacterType.ToAlias && target != null && target.IsPlayer; return action.Type == ActionCharacterType.ToAlias && target != null && target.IsPlayer;
} }
/// <summary> /// <summary>Shows a green selection overlay on both the dragged action card and the target ally card.</summary>
/// Shows a green selection overlay on both the dragged action card and the target ally card.
/// </summary>
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target) public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
{ {
actionCard.Select(); actionCard.Select();
target.Select(); target.Select();
} }
/// <summary> /// <summary>Resolves the support action and reports shields and healing.</summary>
/// Resolves the support action and spawns floating defence and heal text above
/// the <paramref name="target"/> ally. Damage numbers are shown with the <c>isHeal</c>
/// flag for green coloring.
/// </summary>
public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action) public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
{ {
if (source?.Card == null || target?.Card == null || action == null) if (source?.Card == null || target?.Card == null || action == null)
@@ -44,14 +40,23 @@ namespace Cthangover.CardBattle.Player
source.UpdateInfo(); source.UpdateInfo();
target.UpdateInfo(); target.UpdateInfo();
if (result.Result) if (!result.Result)
{ return;
if (result.Target.Defence > 0) if (result.Target.Defence > 0)
{
ShowDamageBehaviour.SpawnDefence(result.Target.Defence, target, target.GlobalPosition); ShowDamageBehaviour.SpawnDefence(result.Target.Defence, target, target.GlobalPosition);
target.Flash(UiTokens.Info);
BattleLogHub.Log(
string.Format(Translate("battle/log_shield_gain"), Translate(target.Card.Name), result.Target.Defence),
BattleLogKind.Buff);
}
if (result.Target.Damage > 0) if (result.Target.Damage > 0)
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, target, target.GlobalPosition, true); ShowDamageBehaviour.SpawnDamage(result.Target.Damage, target, target.GlobalPosition, true);
} }
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
} }
} }
+160 -84
View File
@@ -1,5 +1,5 @@
using Cthangover.Core.Cards;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Cthangover.Core.UI.CharacterPanel;
using Cthangover.Core.UI; using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming; using Cthangover.Core.UI.Theming;
using Godot; using Godot;
@@ -7,134 +7,210 @@ using Godot;
namespace Cthangover.CardBattle.UI namespace Cthangover.CardBattle.UI
{ {
/// <summary> /// <summary>
/// UI node representing a single action card that the player can drag onto a target. /// Draggable action card from the interface mockup (mockups/card_battle):
/// Implements <see cref="ICard"/> so <see cref="CardController"/> can hit-test drag targets. /// the horizontal kit card with a type-coloured accent strip, action icon,
/// Displayed in the <see cref="CardController"/>'s action panel below the character cards. /// name and ID, badges (targeting type, action-point cost, stun turns) and
/// Supports hover scale-up animation, selection highlighting (<see cref="Select"/>, <see cref="Attack"/>, <see cref="Invalid"/>), /// a property-chip row. The node tree lives in <c>scenes/action_card.tscn</c>;
/// and info updates from the underlying <see cref="ActionCharacter"/> data model. /// this class fetches the nodes, dresses them and fills the dynamic badge /
/// chip rows using the shared <see cref="ActionCharacterUi"/> presentation
/// rules, so the battle UI can never disagree with the character panel about
/// an action's colour, caption or cost.
/// <para/>
/// Implements <see cref="ICard"/> so the input controller can hit-test drag
/// targets; the hover style, disabled state and the preview builder
/// (<see cref="DescribeFrom"/>) are the only UI surface the controller and
/// the actions panel need.
/// </summary> /// </summary>
public partial class ActionCardNode : ModWidget, ICard public partial class ActionCardNode : ModWidget, ICard
{ {
private TextureRect frame; private PanelContainer _panel;
private TextureRect image; private ColorRect _accent;
private Label nameLabel; private TextureRect _icon;
private Label _nameLabel;
private Label _idLabel;
private HBoxContainer _badges;
private HBoxContainer _props;
/// <inheritdoc /> private StyleBoxFlat _idleStyle;
public TextureRect Frame => frame; private StyleBoxFlat _hoverStyle;
/// <inheritdoc /> private bool _disabled;
public TextureRect Image => image;
/// <summary> /// <summary>The action data model backing this card.</summary>
/// The <see cref="ActionCharacter"/> data model backing this card. Contains the action's
/// name, image, type, and stat modifiers used during execution.
/// </summary>
public ActionCharacter Card { get; set; } public ActionCharacter Card { get; set; }
/// <summary> /// <summary>True while the character cannot pay the card's action-point cost.</summary>
/// Returns <c>this</c> — action cards are <see cref="Control"/> nodes themselves, public bool IsDisabled => _disabled;
/// unlike <see cref="CharacterCardNode"/> which wraps a separate control.
/// </summary>
public Control GetControlNode() => this;
private Tween hoverTween; /// <summary>Raised when the cursor enters the card; the actions panel uses it to print the preview.</summary>
private float baseY; public event System.Action<ActionCardNode> Hovered;
private bool isHovered;
private Vector2 _baseScale; /// <summary>Raised when the cursor leaves the card; the preview returns to its default text.</summary>
public event System.Action<ActionCardNode> Unhovered;
/// <inheritdoc />
public Control GetControlNode() => this;
protected override void Construct() protected override void Construct()
{ {
CthangoverTheme.Apply(this); CthangoverTheme.Apply(this);
image = GetNode<TextureRect>("Image"); _panel = GetNode<PanelContainer>("Panel");
frame = GetNode<TextureRect>("Frame"); _accent = GetNode<ColorRect>("Panel/Row/Accent");
nameLabel = GetNode<Label>("NameLabel"); _icon = GetNode<TextureRect>("Panel/Row/IconWrap/Icon");
_nameLabel = GetNode<Label>("Panel/Row/Body/Column/NameRow/NameLabel");
_idLabel = GetNode<Label>("Panel/Row/Body/Column/NameRow/IdLabel");
_badges = GetNode<HBoxContainer>("Panel/Row/Body/Column/Badges");
_props = GetNode<HBoxContainer>("Panel/Row/Body/Column/Props");
Size = CustomMinimumSize; _idleStyle = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface1, UiTokens.LineCard);
_hoverStyle = UiKit.Frame(UiTokens.RadiusMd, UiTokens.SurfaceHover, UiTokens.LineStrong);
_panel.AddThemeStyleboxOverride("panel", _idleStyle);
_nameLabel.AddThemeColorOverride("font_color", UiTokens.InkBright);
_idLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
MouseEntered += OnMouseEntered; MouseEntered += OnMouseEntered;
MouseExited += OnMouseExited; MouseExited += OnMouseExited;
} }
/// <summary> /// <summary>
/// Placeholder for selection highlight. Currently unused — action cards use /// Fills the card from the action definition: icon, name, ID, accent
/// <see cref="Attack"/> and <see cref="Invalid"/> for drag-over feedback instead. /// strip, badges and property chips. Called by the input controller when
/// the action deck of a character is opened.
/// </summary> /// </summary>
public void Init(ActionCharacter card)
{
Card = card;
if (_icon != null && card?.Image != null)
_icon.Texture = card.Image;
_accent.Color = ActionCharacterUi.AccentColor(card.Type);
_nameLabel.Text = ActionCharacterUi.Translate(card.Name);
_idLabel.Text = card.ID;
TooltipText = ActionCharacterUi.BuildTooltip(card);
BuildBadges();
BuildProps();
}
/// <summary>Refreshes the texts of an already initialised card.</summary>
public void UpdateInfo()
{
if (Card == null)
return;
_nameLabel.Text = ActionCharacterUi.Translate(Card.Name);
}
/// <summary>
/// Dims the card when the selected character cannot pay its cost; hover
/// feedback is suppressed so the disabled state reads as inert.
/// </summary>
public void SetDisabled(bool disabled)
{
_disabled = disabled;
Modulate = UiTokens.Alpha(UiTokens.White, disabled ? 0.45f : 1f);
_panel.AddThemeStyleboxOverride("panel", _idleStyle);
}
/// <summary>
/// Builds the hint printed in the actions panel when the card is
/// hovered: the expected effect computed from the source character's
/// stats ("Урон 5 (АТК 5 × 1.0) · расход 1 ОД").
/// </summary>
public string DescribeFrom(Character source)
{
if (Card == null || source?.Attributes == null)
return string.Empty;
var cost = ActionCharacterUi.CostOf(Card);
if (Card.Type == ActionCharacterType.ToEnemy && Card.GetInt("Turn", 0) > 1)
return string.Format(Translate("battle/preview_stun"), Card.GetInt("Turn", 0), cost);
if (Card.Type == ActionCharacterType.ToEnemy)
{
var multiplier = Card.GetFloat("Attack", 1f);
var damage = Mathf.RoundToInt(multiplier * source.Attributes.Attack.Value);
return string.Format(
Translate("battle/preview_damage"),
damage,
source.Attributes.Attack.Value,
multiplier.ToString("0.0"),
cost);
}
return string.Format(Translate("battle/preview_defence"), Card.GetInt("Defence"), cost);
}
/// <summary>Placeholder for the source-card highlight; the drag ghost covers it.</summary>
public void Select() public void Select()
{ {
} }
/// <summary> /// <summary>Placeholder: target highlighting is applied by the action strategy.</summary>
/// Placeholder for attack-mode highlight. Currently unused — target highlighting
/// is applied by <see cref="ICardActionStrategy.HighlightTarget"/> instead.
/// </summary>
public void Attack() public void Attack()
{ {
} }
/// <summary> /// <summary>Placeholder: validity is painted on the target card instead.</summary>
/// Placeholder for invalid-target highlight. Currently unused — invalidity is
/// indicated by <see cref="ICardActionStrategy.HighlightTarget"/> calling
/// <see cref="CharacterCardNode.Invalid"/> on the target card instead.
/// </summary>
public void Invalid() public void Invalid()
{ {
} }
/// <summary> /// <summary>Restores the neutral Z-index after a cancelled drag.</summary>
/// Resets the Z-index to 0 when the card is no longer being dragged.
/// </summary>
public void Unselect() public void Unselect()
{ {
ZIndex = 0; ZIndex = 0;
} }
private void BuildBadges()
{
foreach (var child in _badges.GetChildren())
{
_badges.RemoveChild(child);
child.QueueFree();
}
var accent = ActionCharacterUi.AccentColor(Card.Type);
_badges.AddChild(ActionCharacterUi.MakeBadge(
ActionCharacterUi.Translate(ActionCharacterUi.TypeLabelKey(Card.Type)).ToUpperInvariant(), accent));
var cost = ActionCharacterUi.CostText(Card);
if (!string.IsNullOrEmpty(cost))
_badges.AddChild(ActionCharacterUi.MakeBadge(cost, UiTokens.Info));
var turns = Card.GetInt("Turn", 0);
if (turns > 1 && Card.Type == ActionCharacterType.ToEnemy)
_badges.AddChild(ActionCharacterUi.MakeBadge(
string.Format(Translate("battle/turns_badge"), turns), UiTokens.Cooldown));
}
private void BuildProps()
{
foreach (var child in _props.GetChildren())
{
_props.RemoveChild(child);
child.QueueFree();
}
foreach (var pair in ActionCharacterUi.SortedProperties(Card))
_props.AddChild(ActionCharacterUi.MakePropertyChip(pair.Key, pair.Value));
}
private void OnMouseEntered() private void OnMouseEntered()
{ {
if (isHovered) return; if (!_disabled)
isHovered = true; _panel.AddThemeStyleboxOverride("panel", _hoverStyle);
baseY = Position.Y; Hovered?.Invoke(this);
hoverTween?.Kill();
hoverTween = CreateTween();
hoverTween.SetParallel(true);
hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
hoverTween.TweenProperty(this, "position:y", baseY - 10f, 0.12f);
hoverTween.TweenProperty(this, "scale", _baseScale * 1.15f, 0.12f);
} }
private void OnMouseExited() private void OnMouseExited()
{ {
if (!isHovered) return; _panel.AddThemeStyleboxOverride("panel", _idleStyle);
isHovered = false; Unhovered?.Invoke(this);
hoverTween?.Kill();
hoverTween = CreateTween();
hoverTween.SetParallel(true);
hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
hoverTween.TweenProperty(this, "position:y", baseY, 0.12f);
hoverTween.TweenProperty(this, "scale", _baseScale, 0.12f);
} }
/// <summary> private static string Translate(string key)
/// Initializes the card with data from <paramref name="card"/>, caches the base scale => TranslationServer.Translate(key).ToString();
/// for hover animation, sets the card image texture, and refreshes displayed text.
/// Called by <see cref="CardController.CreateAction"/>.
/// </summary>
public void Init(ActionCharacter card)
{
Card = card;
_baseScale = Scale;
if (image != null)
image.Texture = card.Image;
UpdateInfo();
}
/// <summary>
/// Refreshes the displayed name label from the translated <see cref="ActionCharacter.Name"/>.
/// </summary>
public void UpdateInfo()
{
if (nameLabel != null && Card != null)
nameLabel.Text = TranslationServer.Translate(Card.Name);
}
} }
} }
+160
View File
@@ -0,0 +1,160 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// "Действия" window from the interface mockup (mockups/card_battle): the
/// lower-right panel that opens when a party card is clicked. Layout lives
/// in <c>scenes/battle_actions_panel.tscn</c>: a header with the character
/// name and action-point pips, a two-column grid that receives the
/// <see cref="ActionCardNode"/> instances, a description well and a footer
/// hint. The class owns only content: it repaints pips, swaps the empty
/// state and prints the hovered action's numbers.
/// </summary>
public partial class BattleActionsPanel : ModWidget
{
private const int PipSize = 11;
private Label _subject;
private HBoxContainer _pips;
private Label _apHint;
private Label _emptyLabel;
private GridContainer _grid;
private PanelContainer _descWell;
private Label _descLabel;
/// <summary>Container that receives action cards; owned by the input controller.</summary>
public Control Body => _grid;
protected override void Construct()
{
CthangoverTheme.Apply(this);
_subject = GetNode<Label>("Window/Column/Header/HeaderRow/SubjectLabel");
_pips = GetNode<HBoxContainer>("Window/Column/Header/HeaderRow/Pips");
_apHint = GetNode<Label>("Window/Column/Header/HeaderRow/ApHint");
_emptyLabel = GetNode<Label>("Window/Column/Body/BodyColumn/EmptyLabel");
_grid = GetNode<GridContainer>("Window/Column/Body/BodyColumn/Grid");
_descWell = GetNode<PanelContainer>("Window/Column/DescWrap/DescWell");
_descLabel = GetNode<Label>("Window/Column/DescWrap/DescWell/DescLabel");
var windowStyle = UiKit.Frame(UiTokens.RadiusLg, UiTokens.Alpha(UiTokens.Surface0, 0.84f), UiTokens.Line);
windowStyle.ContentMarginLeft = 0;
windowStyle.ContentMarginRight = 0;
windowStyle.ContentMarginTop = 0;
windowStyle.ContentMarginBottom = 0;
windowStyle.ShadowColor = new Color(0f, 0f, 0f, 0.6f);
windowStyle.ShadowSize = 18;
windowStyle.ShadowOffset = new Vector2(0f, 8f);
GetNode<PanelContainer>("Window").AddThemeStyleboxOverride("panel", windowStyle);
foreach (var path in new[]
{
"Window/Column/Header/HeaderRow/TitleLabel",
"Window/Column/Header/HeaderRow/ApCaption",
"Window/Column/Body/BodyColumn/EmptyLabel",
"Window/Column/Footer/FooterRow/HintLabel",
"Window/Column/Footer/FooterRow/AutoEndLabel",
})
{
var label = GetNode<Label>(path);
label.Text = Translate(label.Text);
}
GetNode<Label>("Window/Column/Header/HeaderRow/TitleLabel").AddThemeColorOverride("font_color", UiTokens.Muted);
GetNode<Label>("Window/Column/Header/HeaderRow/ApCaption").AddThemeColorOverride("font_color", UiTokens.Muted);
_apHint.AddThemeColorOverride("font_color", UiTokens.Faint);
_emptyLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
GetNode<Label>("Window/Column/Footer/FooterRow/HintLabel").AddThemeColorOverride("font_color", UiTokens.Faint);
GetNode<Label>("Window/Column/Footer/FooterRow/AutoEndLabel").AddThemeColorOverride("font_color", UiTokens.Faint);
var descStyle = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.Line);
descStyle.ContentMarginLeft = 12;
descStyle.ContentMarginRight = 12;
descStyle.ContentMarginTop = 10;
descStyle.ContentMarginBottom = 10;
_descWell.AddThemeStyleboxOverride("panel", descStyle);
ClearSubject();
}
/// <summary>Shows the selected character's name in the header.</summary>
public void SetSubject(string name)
{
_subject.RemoveThemeColorOverride("font_color");
_subject.AddThemeColorOverride("font_color", UiTokens.InkBright);
_subject.Text = name;
}
/// <summary>Restores the "no character selected" header and the empty state.</summary>
public void ClearSubject()
{
_subject.AddThemeColorOverride("font_color", UiTokens.Faint);
_subject.Text = Translate("battle/no_character");
_pips.Visible = false;
_apHint.Text = string.Empty;
SetDescriptionDefault();
}
/// <summary>Repaints the action-point pips for the selected character.</summary>
public void SetPoints(int current, int basePoints)
{
_pips.Visible = true;
foreach (var child in _pips.GetChildren())
{
_pips.RemoveChild(child);
child.QueueFree();
}
for (var i = 0; i < basePoints; i++)
{
var on = i < current;
var pip = new Panel
{
MouseFilter = MouseFilterEnum.Ignore,
CustomMinimumSize = new Vector2(PipSize, PipSize),
};
pip.AddThemeStyleboxOverride("panel", UiKit.Frame(
PipSize / 2,
on ? UiTokens.AccentBright : UiTokens.LineDisabled,
on ? UiTokens.Accent : UiTokens.LineFaint));
_pips.AddChild(pip);
}
_apHint.Text = current + " / " + basePoints;
}
/// <summary>Frees every action card and restores the empty state.</summary>
public void ClearBody()
{
foreach (var child in _grid.GetChildren())
{
_grid.RemoveChild(child);
child.QueueFree();
}
RefreshEmpty();
}
/// <summary>Hides the empty state as soon as the grid holds action cards.</summary>
public void RefreshEmpty()
=> _emptyLabel.Visible = _grid.GetChildCount() == 0;
/// <summary>Prints the hovered action preview ("Урон 5 · расход 1 ОД") in the well.</summary>
public void SetDescription(string text)
{
_descLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
_descLabel.Text = text;
}
/// <summary>Restores the well's invitation text.</summary>
public void SetDescriptionDefault()
{
_descLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
_descLabel.Text = Translate("battle/desc_default");
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
}
}
+65
View File
@@ -0,0 +1,65 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// Centre-screen turn banner ("Ход игрока" / "Ход врага") from the interface
/// mockup (mockups/card_battle). The plate lives in
/// <c>scenes/battle_banner.tscn</c>; the class only repaints the caption and
/// runs the fade-in / hold / fade-out tween. Tweens are driven by the
/// process delta, so the battle speed control scales them together with the
/// action animations.
/// </summary>
public partial class BattleBanner : ModWidget
{
private const float FadeInSeconds = 0.25f;
private const float HoldSeconds = 1.0f;
private const float FadeOutSeconds = 0.3f;
private PanelContainer _plate;
private Label _label;
private Tween _tween;
protected override void Construct()
{
CthangoverTheme.Apply(this);
_plate = GetNode<PanelContainer>("Plate");
_label = GetNode<Label>("Plate/Label");
var style = UiKit.Frame(0, UiTokens.Alpha(UiTokens.Page, 0.62f), new Color(0f, 0f, 0f, 0f));
style.BorderWidthTop = 1;
style.BorderWidthBottom = 1;
style.BorderColor = UiTokens.AccentLine;
style.ContentMarginLeft = 44;
style.ContentMarginRight = 44;
style.ContentMarginTop = 12;
style.ContentMarginBottom = 12;
_plate.AddThemeStyleboxOverride("panel", style);
_label.AddThemeColorOverride("font_color", UiTokens.InkBright);
_plate.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
}
/// <summary>Pops the banner with the given caption, then fades it away.</summary>
public void Show(string text)
{
_label.Text = text;
_tween?.Kill();
_plate.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
_tween = CreateTween();
_tween.TweenProperty(_plate, "modulate:a", 1f, FadeInSeconds).SetTrans(Tween.TransitionType.Cubic);
_tween.TweenInterval(HoldSeconds);
_tween.TweenProperty(_plate, "modulate:a", 0f, FadeOutSeconds).SetTrans(Tween.TransitionType.Cubic);
}
/// <summary>Hides the banner immediately (battle cleared).</summary>
public void Clear()
{
_tween?.Kill();
_plate.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
}
}
}
+1 -1
View File
@@ -84,7 +84,7 @@ namespace Cthangover.CardBattle.UI
/// </summary> /// </summary>
public CharacterCardNode Create(Character card, bool isPlayer, float cardScale) public CharacterCardNode Create(Character card, bool isPlayer, float cardScale)
{ {
var behaviour = (CharacterCardNode)TscnScenes.LoadAndBuild("scenes/character_card_node.tscn"); var behaviour = (CharacterCardNode)TscnScenes.LoadAndBuild("scenes/character_card.tscn");
behaviour.EnsureConstructed(); behaviour.EnsureConstructed();
if (content != null) if (content != null)
+84
View File
@@ -0,0 +1,84 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// Battle header from the interface mockup (mockups/card_battle): a compact
/// window in the upper left with the battle title, a phase badge that flips
/// between player and enemy turns, the round counter and the number of
/// enemies still standing. The node tree lives in
/// <c>scenes/battle_hud.tscn</c>; this class only fetches the nodes and
/// repaints the badge for the current phase.
/// </summary>
public partial class BattleHud : ModWidget
{
private Label _phaseLabel;
private PanelContainer _phaseBadge;
private Label _roundValue;
private Label _enemiesValue;
protected override void Construct()
{
CthangoverTheme.Apply(this);
_phaseBadge = GetNode<PanelContainer>("Panel/Column/TitleRow/PhaseBadge");
_phaseLabel = GetNode<Label>("Panel/Column/TitleRow/PhaseBadge/PhaseLabel");
_roundValue = GetNode<Label>("Panel/Column/MetaRow/RoundValue");
_enemiesValue = GetNode<Label>("Panel/Column/MetaRow/EnemiesValue");
var panelStyle = UiKit.Frame(UiTokens.RadiusLg, UiTokens.Alpha(UiTokens.Surface0, 0.86f), UiTokens.Line);
panelStyle.ContentMarginLeft = 18;
panelStyle.ContentMarginRight = 18;
panelStyle.ContentMarginTop = 12;
panelStyle.ContentMarginBottom = 13;
panelStyle.ShadowColor = new Color(0f, 0f, 0f, 0.6f);
panelStyle.ShadowSize = 18;
panelStyle.ShadowOffset = new Vector2(0f, 8f);
GetNode<PanelContainer>("Panel").AddThemeStyleboxOverride("panel", panelStyle);
var title = GetNode<Label>("Panel/Column/TitleRow/TitleLabel");
title.Text = Translate(title.Text);
title.AddThemeColorOverride("font_color", UiTokens.InkBright);
foreach (var caption in new[] { "RoundCaption", "EnemiesCaption" })
{
var label = GetNode<Label>("Panel/Column/MetaRow/" + caption);
label.Text = Translate(label.Text);
label.AddThemeColorOverride("font_color", UiTokens.Muted);
}
_phaseLabel.Text = Translate(_phaseLabel.Text);
_roundValue.AddThemeColorOverride("font_color", UiTokens.AccentBright);
_enemiesValue.AddThemeColorOverride("font_color", UiTokens.AccentBright);
}
/// <summary>
/// Repaints the phase badge: blue for the player phase, red for the
/// enemy phase — the same colour language as the targeting highlights.
/// </summary>
public void SetTurn(bool player)
{
var accent = player ? UiTokens.Info : UiTokens.TypeEnemy;
var style = UiKit.Frame(UiTokens.RadiusXs, UiTokens.BadgeFill(accent), UiTokens.BadgeLine(accent));
style.ContentMarginLeft = 7;
style.ContentMarginRight = 7;
style.ContentMarginTop = 2;
style.ContentMarginBottom = 2;
_phaseBadge.AddThemeStyleboxOverride("panel", style);
_phaseLabel.AddThemeColorOverride("font_color", accent);
_phaseLabel.Text = Translate(player ? "battle/turn_player" : "battle/turn_enemy");
}
/// <summary>Writes the round number into the meta row.</summary>
public void SetRound(int round) => _roundValue.Text = round.ToString();
/// <summary>Writes the number of enemies still alive into the meta row.</summary>
public void SetEnemiesAlive(int count) => _enemiesValue.Text = count.ToString();
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// Semantic category of a battle journal row, mirrored from the mockup's
/// colour scheme (mockups/card_battle). The panel maps each kind to a
/// left border accent, a background tint and a text colour, so callers
/// only decide what happened, never how it is painted.
/// </summary>
public enum BattleLogKind
{
/// <summary>Neutral note: enemy intent, hints, odds and ends.</summary>
Info,
/// <summary>Turn or battle boundary (gold, highlighted row).</summary>
Turn,
/// <summary>Health loss (red).</summary>
Damage,
/// <summary>Shield gain or absorption (blue).</summary>
Shield,
/// <summary>Support buff (green).</summary>
Buff,
/// <summary>Status effect such as stun (warm).</summary>
Status,
/// <summary>Character defeat (faint).</summary>
Dead,
}
}
+140
View File
@@ -0,0 +1,140 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// Battle journal docked between the player and action panels — the
/// "Журнал боя" window from the interface mockup (mockups/card_battle).
/// The node tree lives in <c>scenes/battle_log_panel.tscn</c>; rows are
/// created at runtime because they are data, not layout, and are capped to
/// <see cref="MaxRows"/> so a long battle cannot grow unbounded.
/// <para/>
/// The panel is the single subscriber of <see cref="BattleLogHub"/>, so any
/// battle code can publish entries without knowing about the UI. The
/// subscription is dropped on <see cref="Destruct"/> to keep the static hub
/// clean between battles.
/// </summary>
public partial class BattleLogPanel : ModWidget
{
private const int MaxRows = 80;
private Label _title;
private Label _roundLabel;
private ScrollContainer _scroll;
private VBoxContainer _rows;
protected override void Construct()
{
CthangoverTheme.Apply(this);
_title = GetNode<Label>("Window/Column/Header/HeaderRow/TitleLabel");
_roundLabel = GetNode<Label>("Window/Column/Header/HeaderRow/RoundLabel");
_scroll = GetNode<ScrollContainer>("Window/Column/Scroll");
_rows = GetNode<VBoxContainer>("Window/Column/Scroll/Rows");
var windowStyle = UiKit.Frame(UiTokens.RadiusLg, UiTokens.Alpha(UiTokens.Surface0, 0.84f), UiTokens.Line);
windowStyle.ContentMarginLeft = 0;
windowStyle.ContentMarginRight = 0;
windowStyle.ContentMarginTop = 0;
windowStyle.ContentMarginBottom = 0;
windowStyle.ShadowColor = new Color(0f, 0f, 0f, 0.6f);
windowStyle.ShadowSize = 18;
windowStyle.ShadowOffset = new Vector2(0f, 8f);
GetNode<PanelContainer>("Window").AddThemeStyleboxOverride("panel", windowStyle);
_title.Text = Translate(_title.Text);
_title.AddThemeColorOverride("font_color", UiTokens.Muted);
_roundLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
BattleLogHub.Entry += Append;
}
protected override void Destruct()
{
BattleLogHub.Entry -= Append;
}
/// <summary>Updates the "раунд N" caption in the header.</summary>
public void SetRound(int round)
=> _roundLabel.Text = string.Format(Translate("battle/log_round"), round);
/// <summary>Drops every row; called when the battle is cleared or restarted.</summary>
public void Clear()
{
foreach (var child in _rows.GetChildren())
{
_rows.RemoveChild(child);
child.QueueFree();
}
}
/// <summary>
/// Appends one colour-coded row and trims the oldest entries past the cap.
/// The scroll is pushed to the bottom so the newest event stays visible.
/// </summary>
public void Append(string text, BattleLogKind kind)
{
var style = UiKit.Frame(0, BackgroundOf(kind), new Color(0f, 0f, 0f, 0f));
style.BorderWidthLeft = 2;
style.BorderColor = BorderOf(kind);
style.ContentMarginLeft = 12;
style.ContentMarginRight = 12;
style.ContentMarginTop = 7;
style.ContentMarginBottom = 7;
var row = new PanelContainer { MouseFilter = MouseFilterEnum.Ignore };
row.AddThemeStyleboxOverride("panel", style);
var label = UiKit.Label(text, UiTokens.FontBodySmall, TextOf(kind));
label.AutowrapMode = TextServer.AutowrapMode.WordSmart;
row.AddChild(label);
_rows.AddChild(row);
while (_rows.GetChildCount() > MaxRows)
{
var first = _rows.GetChild(0);
_rows.RemoveChild(first);
first.QueueFree();
}
_scroll.ScrollVertical = (int)_scroll.GetVScrollBar().MaxValue;
}
private static Color BackgroundOf(BattleLogKind kind)
=> kind == BattleLogKind.Turn ? UiTokens.AccentSoft : new Color(0f, 0f, 0f, 0f);
private static Color BorderOf(BattleLogKind kind)
{
switch (kind)
{
case BattleLogKind.Turn: return UiTokens.Accent;
case BattleLogKind.Damage: return UiTokens.Danger;
case BattleLogKind.Shield: return UiTokens.TypeResource;
case BattleLogKind.Buff: return UiTokens.TypePassive;
case BattleLogKind.Status: return UiTokens.Cooldown;
case BattleLogKind.Dead: return UiTokens.LineFaint;
default: return UiTokens.Line;
}
}
private static Color TextOf(BattleLogKind kind)
{
switch (kind)
{
case BattleLogKind.Turn: return UiTokens.AccentBright;
case BattleLogKind.Damage: return UiTokens.DangerText;
case BattleLogKind.Shield: return UiTokens.Info;
case BattleLogKind.Buff: return UiTokens.SuccessText;
case BattleLogKind.Status: return UiTokens.Cooldown;
case BattleLogKind.Dead: return UiTokens.Faint;
default: return UiTokens.Muted;
}
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
}
}
+98
View File
@@ -0,0 +1,98 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// Root of the card battle interface rebuilt from the mockup
/// (mockups/card_battle). The whole layout lives in
/// <c>scenes/card_battle_screen.tscn</c> and is composed of the mockup's
/// parts: the HUD window, the enemy and party card panels, the "Действия"
/// window, the battle journal, the turn banner and the end-turn button.
/// This class is the single assembly point: it constructs every child widget
/// once (mod widgets are not auto-constructed by Godot) and exposes them to
/// the battle core and the input controller, so neither has to walk the
/// scene tree by hand.
/// </summary>
public partial class CardBattleScreen : ModWidget
{
/// <summary>Top-left window with the phase badge, round and enemy count.</summary>
public BattleHud Hud { get; private set; }
/// <summary>Panel that manages the enemy card row(s); aligned right.</summary>
public BattleCardPanel EnemyPanel { get; private set; }
/// <summary>Panel that manages the party card row; aligned left.</summary>
public BattleCardPanel PlayerPanel { get; private set; }
/// <summary>Lower-right "Действия" window with the action card grid.</summary>
public BattleActionsPanel ActionsPanel { get; private set; }
/// <summary>Battle journal window that renders <see cref="BattleLogHub"/> entries.</summary>
public BattleLogPanel LogPanel { get; private set; }
/// <summary>Centre-screen turn banner.</summary>
public BattleBanner Banner { get; private set; }
/// <summary>End-turn button in the top-right corner, next to the battle speed bar.</summary>
public EndTurnButton EndTurnButton { get; private set; }
/// <summary>Global input handler that drives selection and drag-and-drop.</summary>
public CardController Controller { get; private set; }
/// <summary>
/// Canvas layer above the whole battle UI (layer 100). The dragged action
/// card is temporarily reparented here, so it always renders over enemy
/// and party cards regardless of their Z-index inside the panels.
/// </summary>
public CanvasLayer DragLayer { get; private set; }
protected override void Construct()
{
CthangoverTheme.Apply(this);
Hud = GetNode<BattleHud>("Hud");
Hud.EnsureConstructed();
EnemyPanel = GetNode<BattleCardPanel>("EnemyPanel");
EnemyPanel.EnsureConstructed();
EnemyPanel.AlignType = "Right";
PlayerPanel = GetNode<BattleCardPanel>("PlayerPanel");
PlayerPanel.EnsureConstructed();
ActionsPanel = GetNode<BattleActionsPanel>("ActionsPanel");
ActionsPanel.EnsureConstructed();
LogPanel = GetNode<BattleLogPanel>("LogPanel");
LogPanel.EnsureConstructed();
Banner = GetNode<BattleBanner>("Banner");
Banner.EnsureConstructed();
EndTurnButton = GetNode<EndTurnButton>("EndTurnButton");
EndTurnButton.EnsureConstructed();
Controller = GetNode<CardController>("CardController");
DragLayer = GetNode<CanvasLayer>("DragLayer");
}
/// <summary>
/// Releases every widget when the battle scene is cleared: cards, action
/// deck, journal rows, banner and the end-turn button. Called by the
/// battle core on <c>OnBattleCleared</c>.
/// </summary>
public void Clear()
{
EnemyPanel.ClearAll();
PlayerPanel.ClearAll();
ActionsPanel.ClearBody();
ActionsPanel.ClearSubject();
LogPanel.Clear();
Banner.Clear();
EndTurnButton.SetVisible(false);
}
}
}
+253 -299
View File
@@ -1,182 +1,162 @@
using System.Collections.Generic; using System.Collections.Generic;
using Cthangover.Core.Cards; using Cthangover.Core.Cards;
using Cthangover.Core.Audio; using Cthangover.Core.Audio;
using Cthangover.Core.Scenes; using Cthangover.Core.Scenes;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Cthangover.Core.UI; using Cthangover.Core.UI;
using Cthangover.Core.UI.CharacterPanel;
using Cthangover.Core.UI.Theming; using Cthangover.Core.UI.Theming;
using Godot; using Godot;
namespace Cthangover.CardBattle.UI namespace Cthangover.CardBattle.UI
{ {
/// <summary> /// <summary>
/// Central input handler for the card battle UI. Processes mouse clicks and drags to implement /// Central input handler for the card battle UI rebuilt from the mockup
/// the full player interaction flow: click a character card to show its action cards in the /// (mockups/card_battle). Processes mouse clicks and drags: click a party
/// action panel, drag an action card onto a valid target (enemy for attacks, ally for support, /// card to open its action deck in the "Действия" window, drag an action
/// self for self-buffs), and execute the action on drop. Valid targets are determined by /// card onto a valid target (enemy for attacks, ally for support, self for
/// <see cref="CardActionStrategyFactory.Get"/> dispatching the appropriate <see cref="ICardActionStrategy"/>. /// self-buffs) and execute it on drop. Valid targets are decided by
/// Fires <see cref="OnActionExecuted"/> after each action, which <see cref="CardBattleCore"/> /// <see cref="Player.CardActionStrategyFactory"/>; after every executed
/// uses to detect automatic turn end when all points are exhausted. /// action the controller fires <see cref="OnActionExecuted"/> so the core can
/// end the turn when all action points are spent.
/// <para/>
/// Actions are instantiated from <c>scenes/action_card.tscn</c> and parented
/// into the grid of <see cref="BattleActionsPanel"/>, which owns the empty
/// state, the point pips and the effect preview well; the controller only
/// feeds it. Hit-testing uses the card root rects exposed through
/// <see cref="ICard.GetControlNode"/>.
/// </summary> /// </summary>
public partial class CardController : InputHandlerNode public partial class CardController : InputHandlerNode
{ {
private Control actionPanel; private CardBattleScreen _screen;
private BattleCardPanel[] panels; private BattleCardPanel[] _panels;
private List<CharacterCardNode> characterCards = new(); private readonly List<CharacterCardNode> _characterCards = new();
private List<ActionCardNode> actionCards = new(); private readonly List<ActionCardNode> _actionCards = new();
private CharacterCardNode selectedCharacter; private CharacterCardNode _selectedCharacter;
private CharacterCardNode targetCharacter; private CharacterCardNode _targetCharacter;
private ActionCardNode currentActionCharacter; private ActionCardNode _currentActionCard;
private bool isDragging; private bool _isDragging;
private bool dragValid; private bool _dragValid;
private float dragThreshold = 15f; private const float DragThreshold = 15f;
private Vector2 pressPosition; private Vector2 _pressPosition;
private Node _originalActionCardParent; private Node _originalActionCardParent;
/// <summary> /// <summary>
/// Locates the action panel and both battle card panels (<see cref="BattleCardPanel"/>) in the /// Uniform scale factor applied to all action cards created by this
/// scene tree relative to this node. Called once by <see cref="CardBattleCore.Init"/> /// controller. Set by the battle core from the viewport fitting
/// after the controller is added to the scene. /// calculation.
/// </summary> /// </summary>
public void FindPanels() public float CardScale { get; set; } = 1f;
{
actionPanel = GetNodeOrNull<Control>("../ActionPanel");
panels = new[]
{
GetNodeOrNull<BattleCardPanel>("../PlayerPanel"),
GetNodeOrNull<BattleCardPanel>("../EnemyPanel"),
};
}
protected override void OnInput(InputEvent @event)
{
if (@event is InputEventMouseButton mb1 && mb1.Pressed && mb1.ButtonIndex == MouseButton.Left)
GameLogger.Log("CARD_CTRL", "_Input received mouse press", LogLevel.Debug);
if (Cthangover.Core.Battle.BattleSceneContext.Instance != null &&
(Cthangover.Core.Battle.BattleSceneContext.Instance.IsWait ||
Cthangover.Core.Battle.BattleSceneContext.Instance.IsDestroyed))
{
if (@event is InputEventMouseButton mb2 && !mb2.Pressed)
GameLogger.Log("CARD_CTRL",
$"Input blocked: IsWait={Cthangover.Core.Battle.BattleSceneContext.Instance.IsWait} IsDestroyed={Cthangover.Core.Battle.BattleSceneContext.Instance.IsDestroyed}",
LogLevel.Debug);
return;
}
if (@event is InputEventMouseButton mouseButton)
{
if (mouseButton.ButtonIndex == MouseButton.Left)
{
if (mouseButton.Pressed)
{
pressPosition = mouseButton.GlobalPosition;
isDragging = false;
dragValid = false;
OnBeginDrag(mouseButton.GlobalPosition);
}
else
{
if (isDragging && dragValid)
OnEndDrag(mouseButton.GlobalPosition);
else
OnPointerClick(mouseButton.GlobalPosition);
isDragging = false;
dragValid = false;
}
}
}
if (@event is InputEventMouseMotion mouseMotion)
{
if (mouseMotion.ButtonMask.HasFlag(MouseButtonMask.Left))
{
if (dragValid && !isDragging && (mouseMotion.GlobalPosition - pressPosition).Length() > dragThreshold)
isDragging = true;
if (isDragging && dragValid)
OnDrag(mouseMotion.GlobalPosition);
}
}
}
private float cardScale = 1f;
/// <summary>
/// Uniform scale factor applied to all action cards created by this controller.
/// Set by <see cref="CardBattleCore.Start"/> based on viewport fitting calculations.
/// </summary>
public float CardScale
{
get => cardScale;
set => cardScale = value;
}
/// <summary> /// <summary>
/// Fired after a successful drag-and-drop action execution. Subscribed by /// Fired after a successful drag-and-drop action execution. Subscribed by
/// <see cref="CardBattleCore.CheckPlayerTurnEnd"/> to auto-end the player's turn /// the battle core to auto-end the player's turn when all points are spent.
/// when all action points are spent.
/// </summary> /// </summary>
public event System.Action OnActionExecuted; public event System.Action OnActionExecuted;
/// <summary> /// <summary>
/// Clears previous action cards and creates new <see cref="ActionCardNode"/> instances /// Binds the controller to the assembled battle screen: keeps the panels
/// for every action in <paramref name="character"/>'s deck. Action cards fade in and are /// for hit-testing and redraws and the actions panel for the deck grid and
/// laid out horizontally in the action panel. Called when the player clicks a character card. /// the hover preview. Called once by the battle core right after the
/// screen is added to the battle scene.
/// </summary>
public void Setup(CardBattleScreen screen)
{
_screen = screen;
_panels = new[] { screen.PlayerPanel, screen.EnemyPanel };
}
protected override void OnInput(InputEvent @event)
{
if (Cthangover.Core.Battle.BattleSceneContext.Instance != null &&
(Cthangover.Core.Battle.BattleSceneContext.Instance.IsWait ||
Cthangover.Core.Battle.BattleSceneContext.Instance.IsDestroyed))
{
return;
}
if (@event is InputEventMouseButton mouseButton && mouseButton.ButtonIndex == MouseButton.Left)
{
if (mouseButton.Pressed)
{
_pressPosition = mouseButton.GlobalPosition;
_isDragging = false;
_dragValid = false;
OnBeginDrag(mouseButton.GlobalPosition);
}
else
{
if (_isDragging && _dragValid)
OnEndDrag(mouseButton.GlobalPosition);
else
OnPointerClick(mouseButton.GlobalPosition);
_isDragging = false;
_dragValid = false;
}
}
if (@event is InputEventMouseMotion mouseMotion &&
mouseMotion.ButtonMask.HasFlag(MouseButtonMask.Left) &&
_dragValid)
{
if (!_isDragging && (mouseMotion.GlobalPosition - _pressPosition).Length() > DragThreshold)
_isDragging = true;
if (_isDragging)
OnDrag(mouseMotion.GlobalPosition);
}
}
/// <summary>
/// Clears previous action cards and fades in a fresh deck for
/// <paramref name="character"/>: one card per battle-usable action, dimmed
/// when the character cannot pay its cost. Called when a party card is
/// clicked.
/// </summary> /// </summary>
public void ShowActions(CharacterCardNode character) public void ShowActions(CharacterCardNode character)
{ {
if (character?.Card?.Actions == null) if (character?.Card?.Actions == null)
{ {
GameLogger.Log("CARD_CTRL", GameLogger.Log("CARD_CTRL",
$"ShowActions: NULL actions for character {(character?.Card?.Name ?? "null")}", "ShowActions: NULL actions for character " + (character?.Card?.Name ?? "null"),
LogLevel.Error); LogLevel.Error);
return; return;
} }
GameLogger.Log("CARD_CTRL",
$"ShowActions: {character.Card.Name} ({character.Card.Actions.Count} actions)",
LogLevel.Debug);
DiscardActionCharacters(); DiscardActionCharacters();
var originPos = character.GlobalPosition * actionPanel.GetGlobalTransformWithCanvas().AffineInverse(); var points = character.Card.Attributes.Point.Value;
var count = character.Card.Actions.Count; foreach (var action in character.Card.Actions)
for (int i = 0; i < count; i++)
{ {
var action = character.Card.Actions[i];
if (!action.Type.UseInBattle()) if (!action.Type.UseInBattle())
continue; continue;
var cardNode = CreateAction(action, cardScale);
cardNode.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
cardNode.Position = originPos - cardNode.Size / 2;
actionCards.Add(cardNode);
var targetPos = new Vector2(cardNode.Size.X * i, 0); var cardNode = CreateAction(action, CardScale);
cardNode.SetDisabled(points < ActionCharacterUi.CostOf(action));
cardNode.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
cardNode.Hovered += OnActionCardHovered;
cardNode.Unhovered += OnActionCardUnhovered;
_actionCards.Add(cardNode);
var tween = CreateTween(); var tween = CreateTween();
tween.SetParallel(true);
tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic); tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
tween.TweenProperty(cardNode, "modulate:a", 1f, 0.5f); tween.TweenProperty(cardNode, "modulate:a", 1f, 0.25f);
tween.TweenProperty(cardNode, "position", targetPos, 0.5f);
}
} }
/// <summary> _screen.ActionsPanel.RefreshEmpty();
/// Animates existing action cards fading out and dropping down, then frees them. }
/// Called before showing new action cards for a different character.
/// </summary> /// <summary>Fades the current deck out and frees it.</summary>
public void DiscardActionCharacters() public void DiscardActionCharacters()
{ {
if (actionCards.Count == 0) if (_actionCards.Count == 0)
return; return;
var oldCards = new List<ActionCardNode>(actionCards); var oldCards = new List<ActionCardNode>(_actionCards);
actionCards.Clear(); _actionCards.Clear();
foreach (var card in oldCards) foreach (var card in oldCards)
{ {
@@ -184,257 +164,232 @@ namespace Cthangover.CardBattle.UI
continue; continue;
card.MouseFilter = Control.MouseFilterEnum.Ignore; card.MouseFilter = Control.MouseFilterEnum.Ignore;
card.Hovered -= OnActionCardHovered;
card.Unhovered -= OnActionCardUnhovered;
var tween = CreateTween(); var tween = CreateTween();
tween.SetParallel(true);
tween.SetEase(Tween.EaseType.In).SetTrans(Tween.TransitionType.Cubic); tween.SetEase(Tween.EaseType.In).SetTrans(Tween.TransitionType.Cubic);
tween.TweenProperty(card, "modulate:a", 0f, 0.15f); tween.TweenProperty(card, "modulate:a", 0f, 0.15f);
tween.TweenProperty(card, "position:y", card.Position.Y + 40f, 0.15f); tween.TweenCallback(Callable.From(card.QueueFree));
tween.Chain().TweenCallback(Callable.From(card.QueueFree));
} }
_screen.ActionsPanel.RefreshEmpty();
} }
/// <summary> /// <summary>
/// Deselects the current action card, source character, and target character, clearing /// Drops the source and target highlights, optionally resets the actions
/// their visual highlights and optionally discarding action cards. When /// window back to its empty state and redraws the panels so the Z-order
/// <paramref name="clearSource"/> is <c>true</c>, the action panel is also cleared. Triggers /// returns to normal. Called after each executed action, on empty clicks
/// a redraw of all battle panels to restore normal Z-ordering. /// and when a drag is cancelled.
/// </summary> /// </summary>
public void ClearSelections(bool clearSource = true) public void ClearSelections(bool clearSource = true)
{ {
if (currentActionCharacter != null) if (_currentActionCard != null)
{ {
currentActionCharacter.Unselect(); _currentActionCard.Unselect();
currentActionCharacter = null; _currentActionCard = null;
}
if (selectedCharacter != null && clearSource)
{
selectedCharacter.Unselect();
selectedCharacter = null;
DiscardActionCharacters();
}
if (targetCharacter != null)
{
targetCharacter.Unselect();
targetCharacter = null;
} }
if (panels != null) if (_selectedCharacter != null && clearSource)
{ {
foreach (var panel in panels) _selectedCharacter.Unselect();
_selectedCharacter = null;
DiscardActionCharacters();
_screen.ActionsPanel.ClearSubject();
}
ClearTargetMarkers();
if (_panels != null)
{
foreach (var panel in _panels)
panel?.Redraw(); panel?.Redraw();
} }
} }
/// <summary> /// <summary>
/// Immediately frees all action card nodes without animation. Called at battle cleanup /// Removes the targeting frame from the current target and forgets it.
/// by <see cref="CardBattleCore.OnBattleCleared"/>. /// Every path that changes or abandons a target must go through here:
/// dragging over the gap between cards first nulls the target, and
/// without the explicit <see cref="CharacterCardNode.Unselect"/> the
/// discarded card would keep its red or grey marker forever.
/// </summary> /// </summary>
private void ClearTargetMarkers()
{
if (_targetCharacter != null && GodotObject.IsInstanceValid(_targetCharacter))
_targetCharacter.Unselect();
_targetCharacter = null;
}
/// <summary>Immediately frees all action card nodes without animation (battle cleanup).</summary>
public void ClearActions() public void ClearActions()
{ {
if (actionCards.Count > 0) foreach (var card in _actionCards)
{
foreach (var card in actionCards)
card.QueueFree(); card.QueueFree();
actionCards.Clear(); _actionCards.Clear();
}
} }
/// <summary> /// <summary>
/// Re-positions all action cards in a horizontal row within the action panel. /// Instantiates an <see cref="ActionCardNode"/> inside the actions grid
/// When <paramref name="animate"/> is <c>true</c>, cards tween smoothly to their targets. /// and fills it from the action definition.
/// </summary>
public void Redraw(bool animate = true)
{
var size = actionCards.Count;
for (int i = 0; i < size; i++)
SetActionCharacterPosition(actionCards[i], i, size, animate);
}
private void SetActionCharacterPosition(ActionCardNode card, int index, int count, bool animate)
{
var rect = card.GetControlNode();
var targetPos = new Vector2(rect.Size.X * index, 0);
if (animate)
{
var tween = CreateTween();
tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
tween.TweenProperty(rect, "position", targetPos, 0.25f);
}
else
{
rect.Position = targetPos;
}
}
/// <summary>
/// Instantiates an <see cref="ActionCardNode"/>, adds it to the action panel, applies
/// the given <paramref name="cardScale"/>, and initializes it with <paramref name="actionCard"/> data.
/// Called by <see cref="ShowActions"/> for each action in a character's deck.
/// </summary> /// </summary>
public ActionCardNode CreateAction(ActionCharacter actionCard, float cardScale) public ActionCardNode CreateAction(ActionCharacter actionCard, float cardScale)
{ {
var card = (ActionCardNode)TscnScenes.LoadAndBuild("scenes/action_card_node.tscn"); var card = (ActionCardNode)TscnScenes.LoadAndBuild("scenes/action_card.tscn");
card.EnsureConstructed(); card.EnsureConstructed();
actionPanel.AddChild(card); _screen.ActionsPanel.Body.AddChild(card);
card.Scale = new Vector2(cardScale, cardScale); card.Scale = new Vector2(cardScale, cardScale);
card.Init(actionCard); card.Init(actionCard);
return card; return card;
} }
/// <summary> /// <summary>
/// Resolves the currently selected action against the selected target using the /// Resolves the currently selected action against the selected target
/// appropriate <see cref="ICardActionStrategy"/>. Called by <see cref="OnEndDrag"/> /// through the matching <see cref="Player.ICardActionStrategy"/>.
/// when a valid drop is confirmed. /// Called by <see cref="OnEndDrag"/> when a valid drop is confirmed.
/// </summary> /// </summary>
public void DoActionToTarget() public void DoActionToTarget()
{ {
var strategy = Cthangover.CardBattle.Player.CardActionStrategyFactory.Get( var strategy = Player.CardActionStrategyFactory.Get(
currentActionCharacter.Card, selectedCharacter, targetCharacter); _currentActionCard.Card, _selectedCharacter, _targetCharacter);
strategy?.Execute(selectedCharacter, targetCharacter, currentActionCharacter.Card); strategy?.Execute(_selectedCharacter, _targetCharacter, _currentActionCard.Card);
} }
private void OnActionCardHovered(ActionCardNode card)
=> _screen.ActionsPanel.SetDescription(card.DescribeFrom(_selectedCharacter?.Card));
private void OnActionCardUnhovered(ActionCardNode card)
=> _screen.ActionsPanel.SetDescriptionDefault();
private void OnPointerClick(Vector2 screenPosition) private void OnPointerClick(Vector2 screenPosition)
{ {
characterCards = GetAllCharacters(); var source = TryFindCardByPosition(screenPosition, CollectCharacters());
var source = TryFindCardByPosition(screenPosition, characterCards);
if (source != null) if (source == null || !source.IsPlayer || source.IsDead)
GameLogger.Log("CARD_CTRL",
$"Click on card: {source.Card.Name} isPlayer={source.IsPlayer} actionsCount={source.Card.Actions?.Count ?? 0}",
LogLevel.Debug);
else
GameLogger.Log("CARD_CTRL", "Click on empty space", LogLevel.Debug);
if (source != selectedCharacter && selectedCharacter != null)
selectedCharacter.Unselect();
if (source != null && source.IsPlayer)
{
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService");
audioService?.PlaySound("battle/card_show_actions", Cthangover.Core.Audio.SoundType.CardEffect);
selectedCharacter = source;
ShowActions(selectedCharacter);
selectedCharacter.Select();
}
else
{ {
ClearSelections(); ClearSelections();
return;
} }
if (source != _selectedCharacter && _selectedCharacter != null)
_selectedCharacter.Unselect();
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService");
audioService?.PlaySound("battle/card_show_actions", SoundType.CardEffect);
_selectedCharacter = source;
_screen.ActionsPanel.SetSubject(TranslationServer.Translate(source.Card.Name).ToString());
_screen.ActionsPanel.SetPoints(source.Card.Attributes.Point.Value, source.Card.Attributes.Point.BaseValue);
ShowActions(_selectedCharacter);
_selectedCharacter.Select();
} }
private void OnBeginDrag(Vector2 screenPosition) private void OnBeginDrag(Vector2 screenPosition)
{ {
var source = TryFindCardByPosition(screenPosition, actionCards); var source = TryFindCardByPosition(screenPosition, _actionCards);
if (currentActionCharacter != null && source != currentActionCharacter)
currentActionCharacter.Unselect();
if (source != null) if (source == null)
return;
if (source.IsDisabled)
{ {
dragValid = true; BattleLogHub.Log(
string.Format(Translate("battle/log_not_enough_points"), ActionCharacterUi.Translate(source.Card.Name)),
BattleLogKind.Status);
return;
}
if (_currentActionCard != null && source != _currentActionCard)
_currentActionCard.Unselect();
_dragValid = true;
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService"); var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService");
audioService?.PlaySound("battle/card_pick", 4, Cthangover.Core.Audio.SoundType.CardEffect); audioService?.PlaySound("battle/card_pick", 4, SoundType.CardEffect);
GameLogger.Log("CARD_CTRL", _currentActionCard = source;
$"Begin drag: {source.Card.Name}", var control = _currentActionCard.GetControlNode();
LogLevel.Debug);
currentActionCharacter = source;
var control = currentActionCharacter.GetControlNode();
_originalActionCardParent = control.GetParent(); _originalActionCardParent = control.GetParent();
var globalPos = control.GlobalPosition; var globalPos = control.GlobalPosition;
control.Reparent(GetTree().Root); // Отдельный CanvasLayer поверх всего боя: иначе карта рисуется
// под ветками панелей врагов и союзников.
control.Reparent(_screen.DragLayer);
control.GlobalPosition = globalPos; control.GlobalPosition = globalPos;
control.ZIndex = 4096;
var pickTween = CreateTween(); var pickTween = CreateTween();
pickTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Back); pickTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Back);
pickTween.TweenProperty(control, "scale", new Vector2(1.1f, 1.1f), 0.1f); pickTween.TweenProperty(control, "scale", new Vector2(1.1f, 1.1f), 0.1f);
foreach (var card in actionCards) foreach (var card in _actionCards)
{ {
if (card != currentActionCharacter && GodotObject.IsInstanceValid(card)) if (card != _currentActionCard && GodotObject.IsInstanceValid(card))
card.MouseFilter = Control.MouseFilterEnum.Ignore; card.MouseFilter = Control.MouseFilterEnum.Ignore;
} }
} }
}
private void OnDrag(Vector2 screenPosition) private void OnDrag(Vector2 screenPosition)
{ {
if (currentActionCharacter == null) if (_currentActionCard == null)
return; return;
var cardControl = currentActionCharacter.GetControlNode(); var cardControl = _currentActionCard.GetControlNode();
cardControl.GlobalPosition = screenPosition - cardControl.Size / 2; cardControl.GlobalPosition = screenPosition - cardControl.Size / 2;
var target = TryFindCardByRect(cardControl, characterCards); var target = TryFindCardByRect(cardControl, _characterCards);
if (target == _targetCharacter)
return;
if (target != targetCharacter && targetCharacter != null) ClearTargetMarkers();
targetCharacter.Unselect();
if (target != null) if (target == null)
{ {
targetCharacter = target; _currentActionCard.Unselect();
var strategy = Cthangover.CardBattle.Player.CardActionStrategyFactory.Get( return;
currentActionCharacter.Card, selectedCharacter, target); }
var valid = strategy != null && strategy.Check(currentActionCharacter.Card, selectedCharacter, target); _targetCharacter = target;
var strategy = Player.CardActionStrategyFactory.Get(
_currentActionCard.Card, _selectedCharacter, target);
GameLogger.Log("CARD_CTRL", if (strategy != null && strategy.Check(_currentActionCard.Card, _selectedCharacter, target))
$"Drag over: {target.Card.Name} valid={valid}",
LogLevel.Debug);
if (valid)
{ {
strategy.HighlightTarget(currentActionCharacter, targetCharacter); strategy.HighlightTarget(_currentActionCard, _targetCharacter);
} }
else else
{ {
targetCharacter.Invalid(); _targetCharacter.Invalid();
currentActionCharacter.Invalid(); _currentActionCard.Invalid();
}
}
else
{
targetCharacter = null;
currentActionCharacter.Unselect();
} }
} }
private void OnEndDrag(Vector2 screenPosition) private void OnEndDrag(Vector2 screenPosition)
{ {
foreach (var card in actionCards) foreach (var card in _actionCards)
{ {
if (card != null && GodotObject.IsInstanceValid(card)) if (card != null && GodotObject.IsInstanceValid(card))
card.MouseFilter = Control.MouseFilterEnum.Stop; card.MouseFilter = Control.MouseFilterEnum.Stop;
} }
if (selectedCharacter != null && currentActionCharacter != null && targetCharacter != null) if (_selectedCharacter != null && _currentActionCard != null && _targetCharacter != null)
{ {
var strategy = Cthangover.CardBattle.Player.CardActionStrategyFactory.Get( var strategy = Player.CardActionStrategyFactory.Get(
currentActionCharacter.Card, selectedCharacter, targetCharacter); _currentActionCard.Card, _selectedCharacter, _targetCharacter);
var valid = strategy != null && strategy.Check(currentActionCharacter.Card, selectedCharacter, targetCharacter); if (strategy != null && strategy.Check(_currentActionCard.Card, _selectedCharacter, _targetCharacter))
GameLogger.Log("CARD_CTRL",
$"End drag: {currentActionCharacter.Card.Name} from {selectedCharacter.Card.Name} to {targetCharacter.Card.Name} valid={valid}",
LogLevel.Debug);
if (valid)
{ {
if (_originalActionCardParent != null) if (_originalActionCardParent != null)
{ {
currentActionCharacter.GetControlNode().Reparent(_originalActionCardParent); _currentActionCard.GetControlNode().Reparent(_originalActionCardParent);
_originalActionCardParent = null; _originalActionCardParent = null;
} }
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService"); var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService");
audioService?.PlaySound("battle/card_drop", 4, Cthangover.Core.Audio.SoundType.CardEffect); audioService?.PlaySound("battle/card_drop", 4, SoundType.CardEffect);
DoActionToTarget(); DoActionToTarget();
if (panels != null) if (_panels != null)
{ {
foreach (var panel in panels) foreach (var panel in _panels)
panel?.Redraw(); panel?.Redraw();
} }
@@ -444,9 +399,9 @@ namespace Cthangover.CardBattle.UI
} }
} }
if (currentActionCharacter != null) if (_currentActionCard != null)
{ {
var control = currentActionCharacter.GetControlNode(); var control = _currentActionCard.GetControlNode();
if (_originalActionCardParent != null) if (_originalActionCardParent != null)
{ {
control.Reparent(_originalActionCardParent); control.Reparent(_originalActionCardParent);
@@ -455,57 +410,56 @@ namespace Cthangover.CardBattle.UI
var resetTween = CreateTween(); var resetTween = CreateTween();
resetTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic); resetTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
resetTween.TweenProperty(control, "scale", Vector2.One, 0.15f); resetTween.TweenProperty(control, "scale", Vector2.One, 0.15f);
control.ZIndex = 0;
BattleLogHub.Log(
string.Format(Translate("battle/log_invalid_target"), ActionCharacterUi.Translate(_currentActionCard.Card.Name)),
BattleLogKind.Status);
} }
Redraw();
ClearSelections(false); ClearSelections(false);
} }
private T TryFindCardByPosition<T>(Vector2 screenPosition, IList<T> cards, ICard exclude = null) where T : class, ICard private T TryFindCardByPosition<T>(Vector2 screenPosition, IList<T> cards) where T : class, ICard
{ {
if (cards == null)
return null;
foreach (var card in cards) foreach (var card in cards)
{ {
if (card == exclude || card == null) if (card == null || !GodotObject.IsInstanceValid(card.GetControlNode()))
continue; continue;
if (card.Frame != null && card.Frame.GetGlobalRect().HasPoint(screenPosition)) var rect = card.GetControlNode();
if (rect.GetGlobalRect().HasPoint(screenPosition))
return card; return card;
} }
return null; return null;
} }
private T TryFindCardByRect<T>(Control draggingRect, IList<T> cards, ICard exclude = null) where T : class, ICard private T TryFindCardByRect<T>(Control draggingRect, IList<T> cards) where T : class, ICard
{ {
Vector2 screenPoint = draggingRect.GlobalPosition + draggingRect.Size / 2; var screenPoint = draggingRect.GlobalPosition + draggingRect.Size / 2;
foreach (var card in cards) foreach (var card in cards)
{ {
if (card == exclude || card == null) if (card == null || !GodotObject.IsInstanceValid(card.GetControlNode()))
continue; continue;
if (card.Frame != null && card.Frame.GetGlobalRect().HasPoint(screenPoint)) var rect = card.GetControlNode();
if (rect.GetGlobalRect().HasPoint(screenPoint))
return card; return card;
} }
return null; return null;
} }
private List<CharacterCardNode> GetAllCharacters() private List<CharacterCardNode> CollectCharacters()
{ {
var list = new List<CharacterCardNode>(); _characterCards.Clear();
var root = GetParent(); foreach (var panel in _panels)
if (root != null) {
CollectNodes<CharacterCardNode>(root, list); if (panel == null)
return list; continue;
foreach (var card in panel.CardList)
_characterCards.Add(card);
}
return _characterCards;
} }
private static void CollectNodes<T>(Node parent, List<T> results) where T : Node private static string Translate(string key)
{ => TranslationServer.Translate(key).ToString();
foreach (Node child in parent.GetChildren())
{
if (child is T t)
results.Add(t);
CollectNodes(child, results);
}
}
} }
} }
+266 -168
View File
@@ -1,5 +1,5 @@
using Cthangover.Core.Cards; using System.Linq;
using Cthangover.Core.Mods; using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Mods.Resolvers; using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Cthangover.Core.UI; using Cthangover.Core.UI;
@@ -9,150 +9,192 @@ using Godot;
namespace Cthangover.CardBattle.UI namespace Cthangover.CardBattle.UI
{ {
/// <summary> /// <summary>
/// UI node representing a single character (player or enemy) on the battle field. /// Party or enemy card on the battle field, rebuilt from the interface
/// Displays the character portrait, name, health bar, and stat labels (defence S, attack A, action points P). /// mockup (mockups/card_battle): full-bleed portrait, team-coloured frame,
/// Implements <see cref="ICard"/> for hit-testing during drag-and-drop targeting. /// bottom info block with the name, four stat chips (HP/ЗАЩ/АТК/ОД) and an
/// Supports team-colored outlines (blue for player, yellow for enemy), hover scale-up animation, /// HP bar, plus stun badge, zap flash and selection frame in the upper right.
/// and multiple selection highlight modes (<see cref="Select"/>, <see cref="Attack"/>, <see cref="Invalid"/>) /// The node tree lives in <c>scenes/character_card.tscn</c>; this class
/// driven by the <see cref="ICardActionStrategy"/> flow. Manages Z-index layering for selected, /// fetches the nodes once, dresses them with <see cref="UiTokens"/> styles and
/// hovered, and normal states. /// exposes the battle-facing API: <see cref="UpdateInfo"/> is the single
/// refresh entry point for mutable stats, <see cref="Select"/> /
/// <see cref="Attack"/> / <see cref="Invalid"/> paint target feedback for the
/// drag-and-drop flow, <see cref="PlayZap"/> and <see cref="Flash"/> give
/// impact feedback, <see cref="AllImages"/> feeds the dissolve shader of
/// <see cref="CardDeathAnimation"/>.
/// <para/>
/// Implements <see cref="ICard"/> for hit-testing during drag-and-drop
/// targeting and keeps the hover lift / Z-index layering rules of the
/// original widget so overlapping cards stay readable.
/// </summary> /// </summary>
public partial class CharacterCardNode : ModWidget, ICard public partial class CharacterCardNode : ModWidget, ICard
{ {
private ColorRect teamOutline; private const float HoverLift = 10f;
private TextureRect frame; private const float HoverScale = 1.12f;
private TextureRect image;
private TextureRect selection;
private ColorRect healthRect; private TextureRect _art;
private Label nameLabel; private Panel _teamFrame;
private Label defenceLabel; private Label _nameLabel;
private Label attackLabel; private ProgressBar _hpBar;
private Label pointsLabel; private Label _hpValue;
private Label _defValue;
private Label _atkValue;
private Label _apValue;
private VBoxContainer _status;
private PanelContainer _stunBadge;
private TextureRect _stunIcon;
private Label _stunLabel;
private TextureRect _zap;
private Panel _fx;
private TextureRect _selection;
private Color selectionState; private Color _selectionState = Colors.Transparent;
private float defaultWidth; private float _baseY;
private float baseY; private bool _isHovered;
private bool isHovered; private bool _selected;
private Tween hoverTween; private int _baseZIndex;
private Tween selectionTween; private Vector2 _baseScale = Vector2.One;
private static Texture2D cachedSelectTex; private Tween _hoverTween;
private static bool selectTexLoaded; private Tween _selectionTween;
private static Texture2D _cachedSelectTex;
private static bool _texturesLoaded;
/// <summary> /// <summary>
/// Distinguishes player-owned cards from enemy cards. Affects team outline color in /// Distinguishes player-owned cards from enemy cards. Must be assigned by
/// <see cref="SetTeamColors"/> and determines whether the card is clickable in /// <see cref="BattleCardPanel"/> before <see cref="Init"/>, because the
/// <see cref="CardController.OnPointerClick"/>. /// team frame colour is applied during initialisation.
/// </summary> /// </summary>
public bool IsPlayer { get; set; } public bool IsPlayer { get; set; }
/// <summary> /// <summary>
/// Marks the card as dead. Set by <see cref="BattleCardPanel.Dead"/>, checked by /// Marks the card as dead. Set by <see cref="BattleCardPanel.Dead"/>,
/// <see cref="CardBattleCore"/> when counting alive cards and by /// checked by the battle core when counting alive cards and when the
/// <see cref="CardBattleCore.RunEnemyTurn"/> when iterating for enemy actions. /// enemy AI picks targets.
/// </summary> /// </summary>
public bool IsDead { get; set; } public bool IsDead { get; set; }
private int _baseZIndex; /// <summary>The character data model backing this card: stats, action deck, status effects.</summary>
private bool _selected;
private Vector2 _baseScale;
/// <inheritdoc />
public TextureRect Frame => frame;
/// <inheritdoc />
public TextureRect Image => image;
/// <summary>
/// All <see cref="TextureRect"/> nodes on this card (frame, image, selection).
/// Consumed by <see cref="CardDeathAnimation"/> to apply the dissolve shader to every visual element.
/// </summary>
public TextureRect[] AllImages => _allImages ??= new[] { frame, image, selection };
private TextureRect[] _allImages;
/// <summary>
/// The <see cref="Character"/> data model backing this card. Contains attributes
/// (health, attack, defence, points) and the action deck.
/// </summary>
public Character Card { get; set; } public Character Card { get; set; }
/// <summary> /// <summary>
/// Returns <c>this</c> — character cards are self-contained <see cref="Control"/> nodes. /// Textures that receive the dissolve shader during the death animation:
/// the portrait and the selection frame. The team frame and the info
/// block are simple styleboxes and fade with the whole card node.
/// </summary> /// </summary>
public TextureRect[] AllImages => _allImages ??= new[] { _art, _selection };
private TextureRect[] _allImages;
/// <inheritdoc />
public Control GetControlNode() => this; public Control GetControlNode() => this;
protected override void Construct() protected override void Construct()
{ {
CthangoverTheme.Apply(this); CthangoverTheme.Apply(this);
teamOutline = GetNode<ColorRect>("TeamOutline"); _art = GetNode<TextureRect>("Art");
image = GetNode<TextureRect>("Image"); _teamFrame = GetNode<Panel>("TeamFrame");
frame = GetNode<TextureRect>("Frame"); _nameLabel = GetNode<Label>("Info/Column/NameLabel");
selection = GetNode<TextureRect>("Selection"); _hpBar = GetNode<ProgressBar>("Info/Column/HpBar");
nameLabel = GetNode<Label>("InfoPanel/NameLabel"); _hpValue = GetNode<Label>("Info/Column/Stats/HpChip/Row/Value");
healthRect = GetNode<ColorRect>("InfoPanel/HealthBar/HealthRect"); _defValue = GetNode<Label>("Info/Column/Stats/DefChip/Row/Value");
defenceLabel = GetNode<Label>("InfoPanel/Labels/DefenceLabel"); _atkValue = GetNode<Label>("Info/Column/Stats/AtkChip/Row/Value");
attackLabel = GetNode<Label>("InfoPanel/Labels/AttackLabel"); _apValue = GetNode<Label>("Info/Column/Stats/ApChip/Row/Value");
pointsLabel = GetNode<Label>("InfoPanel/Labels/PointsLabel"); _status = GetNode<VBoxContainer>("Status");
_stunBadge = GetNode<PanelContainer>("Status/StunBadge");
_stunIcon = GetNode<TextureRect>("Status/StunBadge/Row/StunIcon");
_stunLabel = GetNode<Label>("Status/StunBadge/Row/StunLabel");
_zap = GetNode<TextureRect>("Zap");
_fx = GetNode<Panel>("Fx");
_selection = GetNode<TextureRect>("Selection");
Size = CustomMinimumSize; Size = CustomMinimumSize;
_baseScale = Scale;
defaultWidth = 230f; if (!_texturesLoaded)
selectionState = UiTokens.Alpha(UiTokens.White, 0f); {
_cachedSelectTex = Textures.Resolve("select");
_texturesLoaded = true;
}
if (!selectTexLoaded) if (_cachedSelectTex != null)
{ _selection.Texture = _cachedSelectTex;
cachedSelectTex = Textures.Resolve("select");
selectTexLoaded = true; ApplyStyles();
} _selectionState = UiTokens.Alpha(UiTokens.White, 0f);
if (cachedSelectTex != null)
{
selection.Texture = cachedSelectTex;
selection.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize;
selection.StretchMode = TextureRect.StretchModeEnum.Scale;
}
MouseEntered += OnMouseEntered; MouseEntered += OnMouseEntered;
MouseExited += OnMouseExited; MouseExited += OnMouseExited;
} }
/// <summary> /// <summary>
/// Initializes the card with the given <paramref name="characterCard"/> data, sets the /// Binds the card to a character: sets the portrait, resets selection
/// portrait and frame textures, resets selection visuals, and refreshes stat labels. /// visuals and refreshes every stat label through <see cref="UpdateInfo"/>.
/// Called by <see cref="BattleCardPanel.Create"/>. /// Called by <see cref="BattleCardPanel.Create"/>.
/// </summary> /// </summary>
public void Init(Character characterCard) public void Init(Character characterCard)
{ {
Card = characterCard; Card = characterCard;
_baseScale = Scale; _baseScale = Scale;
IsDead = false;
if (image != null) if (_art != null && characterCard?.Image != null)
image.Texture = characterCard.Image; _art.Texture = characterCard.Image;
if (frame != null && characterCard?.Image != null) if (_selection != null)
frame.Texture = characterCard.Image; _selection.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
if (selection != null) if (_fx != null)
selection.Modulate = UiTokens.Alpha(UiTokens.White, 0f); _fx.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
if (_zap != null)
selectionState = UiTokens.Alpha(UiTokens.White, 0f); _zap.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
_selectionState = UiTokens.Alpha(UiTokens.White, 0f);
UpdateInfo(); UpdateInfo();
} }
/// <summary> /// <summary>
/// Applies a team-colored outline: blue for player cards (<see cref="IsPlayer"/> = <c>true</c>), /// Paints the team frame: blue for the party, gold for enemies — the
/// yellow for enemy cards. Called by <see cref="BattleCardPanel.Create"/> after initialization. /// same accent language the mockup uses for card outlines. Called by
/// <see cref="BattleCardPanel.Create"/> after <see cref="Init"/>.
/// </summary> /// </summary>
public void SetTeamColors() public void SetTeamColors()
{ {
if (teamOutline != null) var accent = IsPlayer ? UiTokens.Info : UiTokens.Accent;
teamOutline.Color = IsPlayer var style = UiKit.Frame(UiTokens.RadiusMd, new Color(0f, 0f, 0f, 0f), UiTokens.Alpha(accent, 0.5f), 2);
? UiTokens.Alpha(UiTokens.Info, 0.5f) _teamFrame.AddThemeStyleboxOverride("panel", style);
: UiTokens.Alpha(UiTokens.Accent, 0.5f);
} }
/// <summary> /// <summary>
/// Sets the base Z-index used for layering cards. When not selected, the card renders at /// Refreshes every mutable value from the character model: name, HP
/// this Z-index. Selected cards jump to Z-index 4096 to render above all others. /// number and bar (turning red below 40%), defence, attack, action
/// Called by <see cref="BattleCardPanel.SetCardPosition"/> during layout. /// points and the stun badge with its remaining turns. Called after each
/// action, on turn start, and by the panel after re-gridding.
/// </summary>
public void UpdateInfo()
{
if (Card?.Attributes == null)
return;
_nameLabel.Text = TranslationServer.Translate(Card.Name ?? string.Empty).ToString();
var health = Card.Attributes.Health;
var percent = Mathf.Clamp(health.Percent, 0f, 1f);
_hpBar.Value = percent * 100f;
_hpBar.AddThemeStyleboxOverride("background", UiKit.Frame(UiTokens.RadiusSm, UiTokens.LineDisabled));
_hpBar.AddThemeStyleboxOverride("fill", UiKit.Frame(UiTokens.RadiusSm, percent < 0.4f ? UiTokens.HpLow : UiTokens.HpOk));
_hpValue.Text = health.Value + "/" + health.BaseValue;
_defValue.Text = Card.Attributes.Defence.Value.ToString();
_atkValue.Text = Card.Attributes.Attack.Value.ToString();
_apValue.Text = Card.Attributes.Point.Value.ToString();
RefreshStun();
}
/// <summary>
/// Sets the base Z-index used for layering cards. Selected cards jump to
/// Z-index 4096 in <see cref="Select"/>; hovered cards rise by 100 so
/// they cover their neighbours.
/// </summary> /// </summary>
public void SetBaseZIndex(int z) public void SetBaseZIndex(int z)
{ {
@@ -161,120 +203,176 @@ namespace Cthangover.CardBattle.UI
ZIndex = z; ZIndex = z;
} }
/// <summary> /// <summary>Highlights the card as the chosen source or a valid support target (teal).</summary>
/// Applies a target-accent selection highlight, raises Z-index to 4096 to ensure the card
/// is clickable above other cards during drag-and-drop targeting, and animates
/// the selection overlay to <see cref="UiTokens.TypeTarget"/>. Called when the player clicks a character card
/// or when a valid support/self target is dragged over.
/// </summary>
public void Select() public void Select()
{ {
_selected = true; _selected = true;
selectionState = UiTokens.TypeTarget; _selectionState = UiTokens.TypeTarget;
AnimateSelection(selectionState); AnimateSelection(_selectionState);
if (selection != null) selection.ZIndex = 1;
ZIndex = 4096; ZIndex = 4096;
} }
/// <summary> /// <summary>Clears the selection highlight and restores the base Z-index.</summary>
/// Clears the selection highlight, restores Z-index to <see cref="SetBaseZIndex"/> value,
/// and fades the selection overlay to transparent.
/// </summary>
public void Unselect() public void Unselect()
{ {
_selected = false; _selected = false;
selectionState = UiTokens.Alpha(UiTokens.White, 0f); _selectionState = UiTokens.Alpha(UiTokens.White, 0f);
AnimateSelection(selectionState); AnimateSelection(_selectionState);
if (selection != null) selection.ZIndex = 0;
ZIndex = _baseZIndex; ZIndex = _baseZIndex;
} }
/// <summary> /// <summary>Highlights a valid enemy target for an attack (red).</summary>
/// Applies an enemy-accent highlight indicating this card is being targeted for an attack.
/// Called by <see cref="ICardActionStrategy.HighlightTarget"/> during drag-over.
/// </summary>
public void Attack() public void Attack()
{ {
selectionState = UiTokens.TypeEnemy; _selectionState = UiTokens.TypeEnemy;
AnimateSelection(selectionState); AnimateSelection(_selectionState);
if (selection != null) selection.ZIndex = 1;
} }
/// <summary> /// <summary>Grays out the card as an invalid target for the dragged action.</summary>
/// Applies a gray dimming highlight indicating this card is an invalid target
/// for the currently dragged action card. Called by <see cref="CardController.OnDrag"/>
/// when the strategy's <c>Check</c> returns <c>false</c>.
/// </summary>
public void Invalid() public void Invalid()
{ {
selectionState = UiTokens.Alpha(UiTokens.Muted, 0.8f); _selectionState = UiTokens.Alpha(UiTokens.Muted, 0.8f);
AnimateSelection(selectionState); AnimateSelection(_selectionState);
if (selection != null) selection.ZIndex = 1; }
/// <summary>Plays the electric stun flash over the portrait (additive blend).</summary>
public void PlayZap()
{
if (_zap == null)
return;
_zap.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
var tween = CreateTween();
tween.TweenProperty(_zap, "modulate:a", 1f, 0.12f);
tween.TweenProperty(_zap, "modulate:a", 0f, 0.45f).SetDelay(0.25f);
}
/// <summary>Pulses the whole card with a colour (damage red, shield blue).</summary>
public void Flash(Color color)
{
_fx.Modulate = UiTokens.Alpha(color, 0f);
var tween = CreateTween();
tween.TweenProperty(_fx, "modulate:a", 0.55f, 0.12f);
tween.TweenProperty(_fx, "modulate:a", 0f, 0.38f);
}
private void ApplyStyles()
{
var infoStyle = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Alpha(UiTokens.Surface2, 0.93f), UiTokens.Line);
infoStyle.CornerRadiusTopLeft = 0;
infoStyle.CornerRadiusTopRight = 0;
infoStyle.ContentMarginLeft = 8;
infoStyle.ContentMarginRight = 8;
infoStyle.ContentMarginTop = 6;
infoStyle.ContentMarginBottom = 8;
GetNode<PanelContainer>("Info").AddThemeStyleboxOverride("panel", infoStyle);
_nameLabel.AddThemeColorOverride("font_color", UiTokens.InkBright);
StyleChip("Info/Column/Stats/HpChip", UiTokens.HpText);
StyleChip("Info/Column/Stats/DefChip", UiTokens.Ink);
StyleChip("Info/Column/Stats/AtkChip", UiTokens.Ink);
StyleChip("Info/Column/Stats/ApChip", UiTokens.AccentBright);
var stunStyle = UiKit.Frame(UiTokens.RadiusXs, UiTokens.CooldownSoft, UiTokens.CooldownLine);
stunStyle.ContentMarginLeft = 5;
stunStyle.ContentMarginRight = 8;
stunStyle.ContentMarginTop = 3;
stunStyle.ContentMarginBottom = 3;
_stunBadge.AddThemeStyleboxOverride("panel", stunStyle);
_stunLabel.AddThemeColorOverride("font_color", UiTokens.Cooldown);
_zap.Material = new CanvasItemMaterial { BlendMode = CanvasItemMaterial.BlendModeEnum.Add };
_zap.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
_fx.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
_selection.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
}
private void StyleChip(string path, Color valueColor)
{
var chip = GetNode<PanelContainer>(path);
var style = UiKit.Frame(UiTokens.RadiusXs, UiTokens.StatBg, UiTokens.StatLine);
style.ContentMarginLeft = 6;
style.ContentMarginRight = 6;
style.ContentMarginTop = 2;
style.ContentMarginBottom = 2;
chip.AddThemeStyleboxOverride("panel", style);
var row = chip.GetNode<HBoxContainer>("Row");
var key = row.GetNode<Label>("Key");
key.Text = TranslationServer.Translate(key.Text).ToString();
key.AddThemeColorOverride("font_color", UiTokens.Muted);
row.GetNode<Label>("Value").AddThemeColorOverride("font_color", valueColor);
}
private void RefreshStun()
{
var stun = StatusEffectService.Of(Card)?.ActiveStatusEffects?
.FirstOrDefault(effect => effect.EffectType == StatusEffectType.Stun);
if (stun == null)
{
_stunBadge.Visible = false;
return;
}
_stunBadge.Visible = true;
if (stun.Icon != null)
{
// Текстура приходит из данных эффекта: группа "characters" не
// входит в texture_groups, поэтому Textures.Resolve("stun") её
// не видит, а StatusEffectInfo.Icon грузится через TextureUtils.
_stunIcon.Texture = stun.Icon;
_zap.Texture = stun.Icon;
}
_stunLabel.Text = string.Format(
TranslationServer.Translate("battle/stun_status").ToString(), stun.RemainingTurns);
} }
private void AnimateSelection(Color target) private void AnimateSelection(Color target)
{ {
if (selection == null) return; if (_selection == null)
selectionTween?.Kill(); return;
selectionTween = CreateTween();
selectionTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad);
selectionTween.TweenProperty(selection, "modulate", target, 0.15f);
}
/// <summary> _selectionTween?.Kill();
/// Refreshes all displayed stats from the underlying <see cref="Character"/> data: _selectionTween = CreateTween();
/// name, defence, attack, action points, and health bar width (based on health percent). _selectionTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad);
/// Called after every action execution and during initial setup. _selectionTween.TweenProperty(_selection, "modulate", target, 0.15f);
/// </summary>
public void UpdateInfo()
{
if (healthRect == null || Card == null) return;
if (nameLabel != null)
nameLabel.Text = TranslationServer.Translate(Card.Name);
if (defenceLabel != null)
defenceLabel.Text = Card.Attributes.Defence.Value.ToString();
if (attackLabel != null)
attackLabel.Text = Card.Attributes.Attack.Value.ToString();
if (pointsLabel != null)
pointsLabel.Text = Card.Attributes.Point.Value.ToString();
var sizeDelta = healthRect.Size;
sizeDelta.X = Card.Attributes.Health.Percent * defaultWidth;
healthRect.SetDeferred(Control.PropertyName.Size, sizeDelta);
} }
private void OnMouseEntered() private void OnMouseEntered()
{ {
if (isHovered) return; if (_isHovered)
isHovered = true; return;
baseY = Position.Y; _isHovered = true;
_baseY = Position.Y;
if (!_selected) if (!_selected)
ZIndex = _baseZIndex + 100; ZIndex = _baseZIndex + 100;
hoverTween?.Kill(); _hoverTween?.Kill();
hoverTween = CreateTween(); _hoverTween = CreateTween();
hoverTween.SetParallel(true); _hoverTween.SetParallel(true);
hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic); _hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
hoverTween.TweenProperty(this, "position:y", baseY - 10f, 0.15f); _hoverTween.TweenProperty(this, "position:y", _baseY - HoverLift, 0.15f);
hoverTween.TweenProperty(this, "scale", _baseScale * 1.15f, 0.15f); _hoverTween.TweenProperty(this, "scale", _baseScale * HoverScale, 0.15f);
} }
private void OnMouseExited() private void OnMouseExited()
{ {
if (!isHovered) return; if (!_isHovered)
isHovered = false; return;
_isHovered = false;
if (!_selected) if (!_selected)
ZIndex = _baseZIndex; ZIndex = _baseZIndex;
hoverTween?.Kill(); _hoverTween?.Kill();
hoverTween = CreateTween(); _hoverTween = CreateTween();
hoverTween.SetParallel(true); _hoverTween.SetParallel(true);
hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic); _hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
hoverTween.TweenProperty(this, "position:y", baseY, 0.15f); _hoverTween.TweenProperty(this, "position:y", _baseY, 0.15f);
hoverTween.TweenProperty(this, "scale", _baseScale, 0.15f); _hoverTween.TweenProperty(this, "scale", _baseScale, 0.15f);
} }
} }
} }
+3 -2
View File
@@ -28,10 +28,11 @@ namespace Cthangover.CardBattle.UI
Size = CustomMinimumSize; Size = CustomMinimumSize;
_button.Text = TranslationServer.Translate("battle/end_turn"); _button.Pressed += RaisePressed;
_button.Pressed += () => OnPressed?.Invoke();
} }
private void RaisePressed() => OnPressed?.Invoke();
/// <summary> /// <summary>
/// Shows or hides the underlying <see cref="Button"/> control. /// Shows or hides the underlying <see cref="Button"/> control.
/// Called by <see cref="CardBattleCore"/> during turn transitions and battle cleanup. /// Called by <see cref="CardBattleCore"/> during turn transitions and battle cleanup.