diff --git a/collections/card_novel.json b/collections/card_novel.json
new file mode 100644
index 0000000..f5f8677
--- /dev/null
+++ b/collections/card_novel.json
@@ -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"]
+ }
+ ]
+}
diff --git a/locale/ui_en.properties b/locale/ui_en.properties
index e2a74ae..4fbcf76 100644
--- a/locale/ui_en.properties
+++ b/locale/ui_en.properties
@@ -1,4 +1,39 @@
-mod_card_battle_name = CardBattle
-mod_card_battle_desc = Card battle mechanics
+mod_card_battle_name=CardBattle
+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}"
diff --git a/locale/ui_ru.properties b/locale/ui_ru.properties
index be650d0..4be24b8 100644
--- a/locale/ui_ru.properties
+++ b/locale/ui_ru.properties
@@ -1,4 +1,39 @@
mod_card_battle_name=Карточная битва
mod_card_battle_desc=Механики карточных сражений
-battle/end_turn=Конец хода
\ No newline at end of file
+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}»
diff --git a/scenes/action_card.tscn b/scenes/action_card.tscn
new file mode 100644
index 0000000..0cb50dc
--- /dev/null
+++ b/scenes/action_card.tscn
@@ -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
diff --git a/scenes/action_card_node.tscn b/scenes/action_card_node.tscn
deleted file mode 100644
index 83011dd..0000000
--- a/scenes/action_card_node.tscn
+++ /dev/null
@@ -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
diff --git a/scenes/battle_actions_panel.tscn b/scenes/battle_actions_panel.tscn
new file mode 100644
index 0000000..63be3b6
--- /dev/null
+++ b/scenes/battle_actions_panel.tscn
@@ -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"
diff --git a/scenes/battle_banner.tscn b/scenes/battle_banner.tscn
new file mode 100644
index 0000000..a010113
--- /dev/null
+++ b/scenes/battle_banner.tscn
@@ -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
diff --git a/scenes/battle_card_panel.tscn b/scenes/battle_card_panel.tscn
index 9b50421..4f0d6f1 100644
--- a/scenes/battle_card_panel.tscn
+++ b/scenes/battle_card_panel.tscn
@@ -1,4 +1,4 @@
-[gd_scene format=3]
+[gd_scene format=3]
[ext_resource type="Script" path="src/UI/BattleCardPanel.cs" id="1_script"]
diff --git a/scenes/battle_hud.tscn b/scenes/battle_hud.tscn
new file mode 100644
index 0000000..20bcd33
--- /dev/null
+++ b/scenes/battle_hud.tscn
@@ -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"
diff --git a/scenes/battle_log_panel.tscn b/scenes/battle_log_panel.tscn
new file mode 100644
index 0000000..187e8f6
--- /dev/null
+++ b/scenes/battle_log_panel.tscn
@@ -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
diff --git a/scenes/card_battle_screen.tscn b/scenes/card_battle_screen.tscn
new file mode 100644
index 0000000..7105552
--- /dev/null
+++ b/scenes/card_battle_screen.tscn
@@ -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
diff --git a/scenes/character_card.tscn b/scenes/character_card.tscn
new file mode 100644
index 0000000..15b154a
--- /dev/null
+++ b/scenes/character_card.tscn
@@ -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
diff --git a/scenes/character_card_node.tscn b/scenes/character_card_node.tscn
deleted file mode 100644
index 918cc71..0000000
--- a/scenes/character_card_node.tscn
+++ /dev/null
@@ -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"
diff --git a/scenes/end_turn_button.tscn b/scenes/end_turn_button.tscn
index 27d3858..4951269 100644
--- a/scenes/end_turn_button.tscn
+++ b/scenes/end_turn_button.tscn
@@ -1,4 +1,4 @@
-[gd_scene format=3]
+[gd_scene format=3]
[ext_resource type="Script" path="src/UI/EndTurnButton.cs" id="1_script"]
@@ -14,3 +14,4 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_font_sizes/font_size = 16
+text = "battle/end_turn"
diff --git a/src/Actions/PhysicsAttackAction.cs b/src/Actions/PhysicsAttackAction.cs
index 4af8690..a5a60f6 100644
--- a/src/Actions/PhysicsAttackAction.cs
+++ b/src/Actions/PhysicsAttackAction.cs
@@ -1,5 +1,6 @@
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
+using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Cthangover.TurnBattle;
using Cthangover.Core.Characters;
@@ -167,12 +168,7 @@ namespace Cthangover.CardBattle.Actions
if (result.Result)
{
- if (result.Target.Damage > 0)
- ShowDamageBehaviour.SpawnDamage(result.Target.Damage, TargetControl, TargetControl.GlobalPosition);
-
- var defenceLost = -result.Target.Defence;
- if (defenceLost > 0)
- ShowDamageBehaviour.SpawnDefence(defenceLost, TargetControl, TargetControl.GlobalPosition);
+ LogAttackResult(result);
}
else
{
@@ -277,6 +273,52 @@ namespace Cthangover.CardBattle.Actions
}
}
+ ///
+ /// 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.
+ ///
+ 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()
{
Vector2 direction = (TargetPos - SourcePos).Normalized();
diff --git a/src/Actions/PhysicsDamageActionCard.cs b/src/Actions/PhysicsDamageActionCard.cs
index cdafbf4..df6e13b 100644
--- a/src/Actions/PhysicsDamageActionCard.cs
+++ b/src/Actions/PhysicsDamageActionCard.cs
@@ -1,3 +1,4 @@
+using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters;
using Godot;
@@ -34,8 +35,8 @@ namespace Cthangover.CardBattle.Actions
return new ChangedAttributes { Result = false };
var damageDelta = Mathf.RoundToInt(actionCard.GetFloat("Attack", 1f) * user.Attributes.Attack.Value);
- target.StatusEffectQueue.OnTakeDamage(user, ref damageDelta);
- user.StatusEffectQueue.OnDealDamage(target, ref damageDelta);
+ StatusEffectService.Of(target).OnTakeDamage(user, ref damageDelta);
+ StatusEffectService.Of(user).OnDealDamage(target, ref damageDelta);
var defenceDelta = 0;
var ignoreArmor = user.HasPassiveEffect("IgnoreArmor");
diff --git a/src/Actions/PhysicsDefenceAction.cs b/src/Actions/PhysicsDefenceAction.cs
index 472ca03..2d00436 100644
--- a/src/Actions/PhysicsDefenceAction.cs
+++ b/src/Actions/PhysicsDefenceAction.cs
@@ -1,5 +1,6 @@
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
+using Cthangover.Core.UI.Theming;
using Cthangover.Core.Characters;
using Cthangover.TurnBattle;
using Cthangover.CardBattle.UI;
@@ -119,7 +120,15 @@ namespace Cthangover.CardBattle.Actions
Target.UpdateInfo();
if (result.Result && result.Target.Defence > 0)
+ {
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)
@@ -212,5 +221,8 @@ namespace Cthangover.CardBattle.Actions
{
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();
}
}
diff --git a/src/Actions/PhysicsStunActionCard.cs b/src/Actions/PhysicsStunActionCard.cs
index e42c67e..462041a 100644
--- a/src/Actions/PhysicsStunActionCard.cs
+++ b/src/Actions/PhysicsStunActionCard.cs
@@ -1,3 +1,4 @@
+using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters;
namespace Cthangover.CardBattle.Actions
@@ -30,7 +31,7 @@ namespace Cthangover.CardBattle.Actions
if (target.HasPassiveEffect("StunImmune"))
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 };
}
diff --git a/src/BattleLogHub.cs b/src/BattleLogHub.cs
new file mode 100644
index 0000000..2c43522
--- /dev/null
+++ b/src/BattleLogHub.cs
@@ -0,0 +1,24 @@
+using System;
+using Cthangover.CardBattle.UI;
+
+namespace Cthangover.CardBattle
+{
+ ///
+ /// 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
+ /// 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
+ /// ActionExecutorHub and ShowDamageBehaviour.
+ ///
+ public static class BattleLogHub
+ {
+ /// Fired for every entry; handlers receive the text and its kind.
+ public static event Action Entry;
+
+ /// Publishes one journal row. No-op when no battle UI is listening.
+ public static void Log(string text, BattleLogKind kind = BattleLogKind.Info)
+ => Entry?.Invoke(text, kind);
+ }
+}
diff --git a/src/CardBattleCore.cs b/src/CardBattleCore.cs
index e3c7122..456a31d 100644
--- a/src/CardBattleCore.cs
+++ b/src/CardBattleCore.cs
@@ -4,7 +4,9 @@ using System.Linq;
using System.Threading.Tasks;
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
+using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Scenes;
+using Cthangover.Core.UI.CharacterPanel;
using Cthangover.Core.Utils;
using Cthangover.Core.Characters;
using Cthangover.CardBattle.Player;
@@ -14,49 +16,58 @@ using Godot;
namespace Cthangover.CardBattle
{
///
- /// Core orchestrator for the card-based turn battle system.
- /// Manages the full battle lifecycle: initializing UI panels ( and ),
- /// cycling between player and enemy turns, spawning animated actions via ,
- /// applying status effects each turn, and detecting win/loss conditions when all cards on one side are dead.
- /// Implements so it can be registered as a battle type provider in the core battle engine.
+ /// Core orchestrator for the card-based turn battle system. Owns the full
+ /// battle lifecycle: it instantiates the UI screen rebuilt from the mockup
+ /// (mockups/card_battle, defined in scenes/card_battle_screen.tscn),
+ /// cycles between player and enemy turns, drives the animated enemy actions
+ /// through , applies status effects each
+ /// turn and detects win/loss when one side loses all cards.
+ ///
+ /// The core talks to the screen through its assembled widgets
+ /// (, ,
+ /// , panels) instead of walking the scene tree, and
+ /// publishes journal rows via so other battle code
+ /// can log without a UI reference. Implements so the
+ /// battle engine can select this mod as the active battle type.
///
public class CardBattleCore : IBattleCore, IBattleAutoPlayable
{
- ///
- /// Unique identifier for this battle core, used by the battle engine to select the card battle mod.
- ///
- public string Id => "card_battle";
-
- ///
- /// Provides instances for the three card action types
- /// (physics/attack, physics/defence, physics/stun) used during action resolution.
- ///
- public IActionExecutorProvider ActionProvider { get; } = new Actions.CardBattleActionProvider();
+ private const int PlayerCardLimit = 4;
+ private const float BaseCardWidth = 230f;
+ private const float BaseCardHeight = 420f;
+ private const float CellOffset = 10f;
private Character[] _playerChars;
private Character[] _enemyChars;
private IBattleContext _ctx;
+ private CardBattleScreen _screen;
private BattleCardPanel _playerPanel;
private BattleCardPanel _enemyPanel;
private CardController _cardController;
- private Control _actionPanel;
- private Control _toolPanel;
private EndTurnButton _endTurnButton;
private BattleSide _currentOrder;
private bool _playerTurnActive;
+ private int _round;
- private const float CELL_OFFSET = 10f;
- private const int PLAYER_CARD_LIMIT = 4;
- private const float BASE_CARD_WIDTH = 230f;
- private const float BASE_CARD_HEIGHT = 420f;
+ /// Unique identifier for this battle core, used by the battle engine to select the card battle mod.
+ public string Id => "card_battle";
///
- /// Initializes the card battle by constructing the player and enemy instances,
- /// the for drag-and-drop input, and the .
- /// Enemy characters are deep-copied so defeats are tracked independently per battle instance.
- /// The is registered with .
+ /// Provides instances for the three card
+ /// action types (physics/attack, physics/defence, physics/stun) used
+ /// during action resolution.
+ ///
+ public IActionExecutorProvider ActionProvider { get; } = new Actions.CardBattleActionProvider();
+
+ ///
+ /// Instantiates the battle screen from scenes/card_battle_screen.tscn,
+ /// attaches it to the battle scene's Panel and binds the panels,
+ /// end-turn button and input controller. Enemy characters are deep-copied
+ /// so defeats are tracked independently per battle instance. The
+ /// is registered with
+ /// .
///
public void Init(Character[] playerChars, Character[] enemyChars, IBattleContext ctx)
{
@@ -64,71 +75,42 @@ namespace Cthangover.CardBattle
_ctx = ctx;
_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];
ActionExecutorHub.Instance.SetActiveProvider(ActionProvider);
var root = ctx.RootNode as Node;
-
var panel = root.GetNodeOrNull("Panel") ?? root;
- _playerPanel = (BattleCardPanel)TscnScenes.LoadAndBuild("scenes/battle_card_panel.tscn");
- _playerPanel.Name = "PlayerPanel";
- _playerPanel.EnsureConstructed();
- panel.AddChild(_playerPanel);
+ _screen = (CardBattleScreen)TscnScenes.LoadAndBuild("scenes/card_battle_screen.tscn");
+ _screen.Name = "CardBattleScreen";
+ _screen.EnsureConstructed();
+ panel.AddChild(_screen);
- _enemyPanel = (BattleCardPanel)TscnScenes.LoadAndBuild("scenes/battle_card_panel.tscn");
- _enemyPanel.Name = "EnemyPanel";
- _enemyPanel.AlignType = "Right";
- _enemyPanel.EnsureConstructed();
- _enemyPanel.MouseFilter = Control.MouseFilterEnum.Ignore;
- panel.AddChild(_enemyPanel);
+ _playerPanel = _screen.PlayerPanel;
+ _enemyPanel = _screen.EnemyPanel;
- _actionPanel = new Control { Name = "ActionPanel" };
- panel.AddChild(_actionPanel);
-
- _toolPanel = root.GetNodeOrNull("ToolPanel");
-
- _endTurnButton = (EndTurnButton)TscnScenes.LoadAndBuild("scenes/end_turn_button.tscn");
- _endTurnButton.Name = "EndTurnButton";
- _endTurnButton.EnsureConstructed();
+ _endTurnButton = _screen.EndTurnButton;
_endTurnButton.SetVisible(false);
_endTurnButton.OnPressed += OnEndTurnPressed;
- _toolPanel?.AddChild(_endTurnButton);
- _cardController = new CardController { Name = "CardController" };
- panel.AddChild(_cardController);
- _cardController.FindPanels();
+ _cardController = _screen.Controller;
+ _cardController.Setup(_screen);
}
///
- /// Positions the UI panels relative to the viewport, calculates a unified cardScale that
- /// fits both player and enemy cards on screen, initializes all character cards, and starts the first player turn.
- /// Subscribes to OnCardDead to record defeated enemies and to OnActionExecuted to detect
- /// automatic turn completion when all player cards exhaust their action points.
+ /// Calculates the pointer scale fitting both sides on screen, initializes
+ /// every card and starts the first player turn. Subscribes to
+ /// OnCardDead to record defeated enemies for the victory screen and
+ /// to OnActionExecuted to detect automatic turn completion.
///
public void Start()
{
- var root = _ctx.RootNode as Node;
-
- var panelSize = root.GetViewport().GetVisibleRect().Size;
var enemyCount = _enemyChars?.Length ?? 1;
_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);
_cardController.CardScale = cardScale;
@@ -140,6 +122,7 @@ namespace Cthangover.CardBattle
BattleSceneContext.Instance.OnBattleCleared += OnBattleCleared;
+ _round = 0;
_currentOrder = BattleSide.Player;
StartPlayerTurn();
}
@@ -147,31 +130,31 @@ namespace Cthangover.CardBattle
private float CalculateCardScale(int enemyCount)
{
var viewport = ((Node)_ctx.RootNode).GetViewport().GetVisibleRect().Size;
- float enemyPanelWidth = viewport.X * 0.96f;
- float playerPanelWidth = viewport.X * 0.55f;
- float enemyPanelHeight = viewport.Y * 0.446f;
-
- float maxCardScale = 1f;
+ var enemyPanelWidth = viewport.X * 0.96f;
+ var playerPanelWidth = viewport.X * 0.55f;
+ var enemyPanelHeight = viewport.Y * 0.446f;
+ float maxCardScale;
if (_enemyPanel.UseRows)
{
- int maxPerRow = _enemyPanel.GetMaxCardsPerRow();
- int rows = _enemyPanel.GetCurrentRowCount(enemyCount);
+ var maxPerRow = _enemyPanel.GetMaxCardsPerRow();
+ var rows = _enemyPanel.GetCurrentRowCount(enemyCount);
- float widthScale = (enemyPanelWidth - (maxPerRow - 1) * CELL_OFFSET) / (maxPerRow * BASE_CARD_WIDTH);
- float heightScale = (enemyPanelHeight - (rows - 1) * CELL_OFFSET) / (rows * BASE_CARD_HEIGHT);
+ var widthScale = (enemyPanelWidth - (maxPerRow - 1) * CellOffset) / (maxPerRow * BaseCardWidth);
+ var heightScale = (enemyPanelHeight - (rows - 1) * CellOffset) / (rows * BaseCardHeight);
maxCardScale = Mathf.Max(0.1f, Mathf.Min(widthScale, heightScale));
}
else
{
- int effectiveCount = Mathf.Max(enemyCount, 1);
- maxCardScale = Mathf.Max(0.1f, (enemyPanelWidth - (effectiveCount - 1) * CELL_OFFSET) / (effectiveCount * BASE_CARD_WIDTH));
+ var effectiveCount = Mathf.Max(enemyCount, 1);
+ 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(cardScale, 1f);
+ return Mathf.Min(Mathf.Min(maxCardScale, playerFitScale), 1f);
}
private void OnEnemyCardDead(CharacterCardNode card)
@@ -185,34 +168,43 @@ namespace Cthangover.CardBattle
ctx.RecordEnemyDefeated(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()
{
- GameLogger.Log("CARD_BATTLE", "Player turn started", LogLevel.Debug);
-
+ _round += 1;
_currentOrder = BattleSide.Player;
_playerTurnActive = true;
BattleSceneContext.Instance.IsWait = false;
_cardController.ClearSelections();
_cardController.ClearActions();
+ _screen.ActionsPanel.ClearBody();
+ _screen.ActionsPanel.ClearSubject();
_endTurnButton?.SetVisible(true);
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.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()
- {
- GameLogger.Log("CARD_BATTLE", "End turn button pressed", LogLevel.Debug);
- OnPlayerTurnEnd();
- }
+ private void OnEndTurnPressed() => OnPlayerTurnEnd();
private void CheckPlayerTurnEnd()
{
@@ -225,30 +217,24 @@ namespace Cthangover.CardBattle
return;
}
- bool allOutOfPoints = true;
foreach (var card in _playerPanel.CardList)
{
if (!card.IsDead && card.Card?.Attributes?.Point?.Value > 0)
- {
- allOutOfPoints = false;
- break;
- }
+ return;
}
- if (allOutOfPoints)
- OnPlayerTurnEnd();
+ BattleLogHub.Log(Translate("battle/log_out_of_points"), BattleLogKind.Info);
+ OnPlayerTurnEnd();
}
///
- /// Ends the player's turn by hiding the end-turn button, setting the battle to IsWait,
- /// and yielding two frames for UI to settle before running the enemy AI turn via RunEnemyTurn.
- /// If all enemies are already dead, the battle ends immediately with the player as winner.
- /// This method is async void so it can await frames without blocking the game loop.
+ /// Ends the player's turn: hides the end-turn button, pauses the battle,
+ /// plays the enemy-turn banner and journal line, waits for two frames plus
+ /// a short pause so the UI settles, then runs the enemy AI. Ends the battle
+ /// immediately when no enemies remain.
///
public async void OnPlayerTurnEnd()
{
- GameLogger.Log("CARD_BATTLE", "Player turn ended", LogLevel.Debug);
-
_playerTurnActive = false;
_endTurnButton?.SetVisible(false);
BattleSceneContext.Instance.IsWait = true;
@@ -259,6 +245,10 @@ namespace Cthangover.CardBattle
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;
await root.ToSignal(root.GetTree(), "process_frame");
await root.ToSignal(root.GetTree().CreateTimer(0.5f), "timeout");
@@ -268,8 +258,6 @@ namespace Cthangover.CardBattle
private async Task RunEnemyTurn()
{
- GameLogger.Log("CARD_BATTLE", "Enemy turn started", LogLevel.Debug);
-
_currentOrder = BattleSide.Enemy;
_endTurnButton?.SetVisible(false);
@@ -280,11 +268,15 @@ namespace Cthangover.CardBattle
if (card.IsDead || card.Card?.Actions == null || card.Card.Actions.Count == 0)
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;
}
@@ -298,9 +290,17 @@ namespace Cthangover.CardBattle
if (target == null)
continue;
- GameLogger.Log("CARD_BATTLE",
- $"Enemy action: {card.Card.Name} -> {action.Name} -> {target.Card.Name}",
- LogLevel.Message);
+ card.UpdateInfo();
+
+ 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);
if (animatedAction != null)
@@ -315,7 +315,6 @@ namespace Cthangover.CardBattle
if (!HasAliveCards(_playerPanel))
{
- GameLogger.Log("CARD_BATTLE", "All players dead — enemy wins", LogLevel.Message);
_ctx.EndBattle(BattleSide.Enemy);
return;
}
@@ -336,8 +335,7 @@ namespace Cthangover.CardBattle
if (actions == null || actions.Count == 0)
return null;
- var random = new Random();
- return actions[random.Next(actions.Count)];
+ return actions[GD.RandRange(0, actions.Count - 1)];
}
private CharacterCardNode PickEnemyTarget(CharacterCardNode source, ActionCharacter action)
@@ -350,7 +348,7 @@ namespace Cthangover.CardBattle
if (alivePlayers.Count == 0)
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)
@@ -360,7 +358,8 @@ namespace Cthangover.CardBattle
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)
return new Actions.PhysicsAttackAction(source, target, action);
@@ -372,7 +371,7 @@ namespace Cthangover.CardBattle
return null;
}
- private bool HasAliveCards(BattleCardPanel panel)
+ private static bool HasAliveCards(BattleCardPanel panel)
{
foreach (var card in panel.CardList)
{
@@ -382,14 +381,25 @@ namespace Cthangover.CardBattle
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()
{
- _playerPanel?.ClearAll();
- _enemyPanel?.ClearAll();
- _cardController?.ClearActions();
- _endTurnButton?.SetVisible(false);
+ _screen?.Clear();
}
+ private static string Translate(string key)
+ => TranslationServer.Translate(key).ToString();
+
///
/// true between and the start of
/// — matches the visibility window of the
@@ -397,9 +407,7 @@ namespace Cthangover.CardBattle
///
public bool IsWaitingForPlayerInput => _playerTurnActive;
- ///
- /// Automation hook: ends the player's turn without selecting cards.
- ///
+ /// Automation hook: ends the player's turn without selecting cards.
public void AdvancePlayerTurn() => OnPlayerTurnEnd();
}
}
diff --git a/src/ICard.cs b/src/ICard.cs
index 173ce8b..ae7be6d 100644
--- a/src/ICard.cs
+++ b/src/ICard.cs
@@ -2,23 +2,15 @@
namespace Cthangover.CardBattle
{
-
///
- /// Common interface for all card UI nodes in the card battle system.
- /// Exposes the Frame and Image controls so that
- /// can perform hit-testing during drag-and-drop targeting
- /// without knowing the concrete card type ( or ).
+ /// Common contract for every card the input controller can hit-test. Both
+ /// the character cards on the field and the draggable action cards expose
+ /// their root , because layout, scale and the drag
+ /// ghost all operate on the whole card rect rather than on an inner sprite.
///
public interface ICard
{
- ///
- /// The frame used for hit-testing whether a pointer is over this card.
- ///
- TextureRect Frame { get; }
- ///
- /// The portrait/image of the card, used for dissolve shader effects on death.
- ///
- TextureRect Image { get; }
+ /// Root control used for hit-testing and for reparenting during a drag.
+ Control GetControlNode();
}
-
-}
\ No newline at end of file
+}
diff --git a/src/Player/AttackToEnemyStrategy.cs b/src/Player/AttackToEnemyStrategy.cs
index 2b5fef0..7289ad2 100644
--- a/src/Player/AttackToEnemyStrategy.cs
+++ b/src/Player/AttackToEnemyStrategy.cs
@@ -1,30 +1,31 @@
using Cthangover.CardBattle.UI;
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
+using Cthangover.Core.UI.Theming;
using Cthangover.TurnBattle;
using Cthangover.Core.Characters;
+using Godot;
namespace Cthangover.CardBattle.Player
{
///
- /// Strategy for ToEnemy attack actions. Validates that the target is an enemy
- /// (non-player card), highlights both cards with a red attack outline, and executes
- /// damage via with floating damage/defence numbers.
- /// This is the standard strategy for all offensive cards in the player's deck.
+ /// Strategy for ToEnemy actions. Validates that the target is an enemy,
+ /// highlights both cards with a red attack outline, executes the action via
+ /// and reports the outcome: floating damage /
+ /// defence-loss numbers on the target, a colour pulse on the card and a
+ /// journal row through . 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.
///
public class AttackToEnemyStrategy : ICardActionStrategy
{
- ///
- /// Returns true if the action type is ToEnemy and the target is an enemy card.
- ///
+ /// Returns true if the action type is ToEnemy and the target is an enemy card.
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
{
return action.Type == ActionCharacterType.ToEnemy && target != null && !target.IsPlayer;
}
- ///
- /// Shows a red attack overlay on both the dragged action card and the target enemy card.
- ///
+ /// Shows a red attack overlay on both the dragged action card and the target enemy card.
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
{
actionCard.Attack();
@@ -32,8 +33,9 @@ namespace Cthangover.CardBattle.Player
}
///
- /// Resolves the attack via and spawns floating damage
- /// and defence-loss numbers above the card.
+ /// Resolves the attack and publishes its result: damage and absorbed
+ /// shield as floating numbers plus journal rows, or the stun effect with
+ /// its zap flash for control actions.
///
public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
{
@@ -44,15 +46,50 @@ namespace Cthangover.CardBattle.Player
source.UpdateInfo();
target.UpdateInfo();
- if (result.Result)
- {
- if (result.Target.Damage > 0)
- ShowDamageBehaviour.SpawnDamage(result.Target.Damage, target, target.GlobalPosition);
+ if (!result.Result)
+ return;
- var defenceLost = -result.Target.Defence;
- if (defenceLost > 0)
- ShowDamageBehaviour.SpawnDefence(defenceLost, 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;
+ if (defenceLost > 0)
+ {
+ 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();
}
}
diff --git a/src/Player/SelfStrategy.cs b/src/Player/SelfStrategy.cs
index d6bff74..cb86946 100644
--- a/src/Player/SelfStrategy.cs
+++ b/src/Player/SelfStrategy.cs
@@ -1,41 +1,36 @@
using Cthangover.CardBattle.UI;
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
+using Cthangover.Core.UI.Theming;
using Cthangover.TurnBattle;
using Cthangover.Core.Characters;
+using Godot;
namespace Cthangover.CardBattle.Player
{
///
- /// Strategy for self-targeting actions (ToSelf). Validates that source and target
- /// are the same card, highlights with a green selection outline, and applies the action
- /// to the source card itself via . Used for self-buffs like
- /// defence boosts. Damage and defence floating text spawn above the source card.
+ /// Strategy for self-targeting actions (ToSelf). Validates that the
+ /// source and target are the same card, highlights with a green selection
+ /// outline and applies the effect to the caster. Shield gains print a
+ /// floating blue number and a journal row; the card pulses to make the buff
+ /// readable without reading the log.
///
public class SelfStrategy : ICardActionStrategy
{
- ///
- /// Returns true only when and are the same card.
- ///
+ /// Returns true only when and are the same card.
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
{
return source != null && source == target;
}
- ///
- /// Shows a green selection overlay on both the dragged action card and the source card.
- ///
+ /// Shows a green selection overlay on both the dragged action card and the source card.
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
{
actionCard.Select();
target.Select();
}
- ///
- /// Executes the self-targeted action and spawns floating defence/damage numbers
- /// above the card. Damage numbers are marked with the
- /// isHeal flag for green coloring via .
- ///
+ /// Executes the self-targeted action and reports shields and healing.
public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
{
if (source?.Card == null || action == null)
@@ -44,14 +39,23 @@ namespace Cthangover.CardBattle.Player
var result = ActionExecutorHub.Instance.Execute(action, source.Card, source.Card);
source.UpdateInfo();
- if (result.Result)
- {
- if (result.Target.Defence > 0)
- ShowDamageBehaviour.SpawnDefence(result.Target.Defence, source, source.GlobalPosition);
+ if (!result.Result)
+ return;
- if (result.Target.Damage > 0)
- ShowDamageBehaviour.SpawnDamage(result.Target.Damage, source, source.GlobalPosition, true);
+ if (result.Target.Defence > 0)
+ {
+ 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)
+ ShowDamageBehaviour.SpawnDamage(result.Target.Damage, source, source.GlobalPosition, true);
}
+
+ private static string Translate(string key)
+ => TranslationServer.Translate(key).ToString();
}
}
diff --git a/src/Player/SupportStrategy.cs b/src/Player/SupportStrategy.cs
index 3ecfd3c..1d085b2 100644
--- a/src/Player/SupportStrategy.cs
+++ b/src/Player/SupportStrategy.cs
@@ -1,40 +1,36 @@
using Cthangover.CardBattle.UI;
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
+using Cthangover.Core.UI.Theming;
using Cthangover.TurnBattle;
using Cthangover.Core.Characters;
+using Godot;
namespace Cthangover.CardBattle.Player
{
///
- /// Strategy for ally-support actions (ToAlias). Validates that the target is a friendly
- /// player card, highlights with green selection overlays, and applies defence/healing effects
- /// via . Allows one player card to buff another during the player's turn.
+ /// Strategy for ally-support actions (ToAlias). Validates that the
+ /// target is a friendly card, highlights with green selection overlays and
+ /// applies the effect via . 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.
///
public class SupportStrategy : ICardActionStrategy
{
- ///
- /// Returns true when the action type is ToAlias and the target is a player card.
- ///
+ /// Returns true when the action type is ToAlias and the target is a player card.
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
{
return action.Type == ActionCharacterType.ToAlias && target != null && target.IsPlayer;
}
- ///
- /// Shows a green selection overlay on both the dragged action card and the target ally card.
- ///
+ /// Shows a green selection overlay on both the dragged action card and the target ally card.
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
{
actionCard.Select();
target.Select();
}
- ///
- /// Resolves the support action and spawns floating defence and heal text above
- /// the ally. Damage numbers are shown with the isHeal
- /// flag for green coloring.
- ///
+ /// Resolves the support action and reports shields and healing.
public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
{
if (source?.Card == null || target?.Card == null || action == null)
@@ -44,14 +40,23 @@ namespace Cthangover.CardBattle.Player
source.UpdateInfo();
target.UpdateInfo();
- if (result.Result)
- {
- if (result.Target.Defence > 0)
- ShowDamageBehaviour.SpawnDefence(result.Target.Defence, target, target.GlobalPosition);
+ if (!result.Result)
+ return;
- if (result.Target.Damage > 0)
- ShowDamageBehaviour.SpawnDamage(result.Target.Damage, target, target.GlobalPosition, true);
+ if (result.Target.Defence > 0)
+ {
+ 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)
+ ShowDamageBehaviour.SpawnDamage(result.Target.Damage, target, target.GlobalPosition, true);
}
+
+ private static string Translate(string key)
+ => TranslationServer.Translate(key).ToString();
}
}
diff --git a/src/UI/ActionCardNode.cs b/src/UI/ActionCardNode.cs
index 19e3c42..9a2fc1c 100644
--- a/src/UI/ActionCardNode.cs
+++ b/src/UI/ActionCardNode.cs
@@ -1,5 +1,5 @@
-using Cthangover.Core.Cards;
using Cthangover.Core.Characters;
+using Cthangover.Core.UI.CharacterPanel;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
@@ -7,134 +7,210 @@ using Godot;
namespace Cthangover.CardBattle.UI
{
///
- /// UI node representing a single action card that the player can drag onto a target.
- /// Implements so can hit-test drag targets.
- /// Displayed in the 's action panel below the character cards.
- /// Supports hover scale-up animation, selection highlighting (, , ),
- /// and info updates from the underlying data model.
+ /// Draggable action card from the interface mockup (mockups/card_battle):
+ /// the horizontal kit card with a type-coloured accent strip, action icon,
+ /// name and ID, badges (targeting type, action-point cost, stun turns) and
+ /// a property-chip row. The node tree lives in scenes/action_card.tscn;
+ /// this class fetches the nodes, dresses them and fills the dynamic badge /
+ /// chip rows using the shared presentation
+ /// rules, so the battle UI can never disagree with the character panel about
+ /// an action's colour, caption or cost.
+ ///
+ /// Implements so the input controller can hit-test drag
+ /// targets; the hover style, disabled state and the preview builder
+ /// () are the only UI surface the controller and
+ /// the actions panel need.
///
public partial class ActionCardNode : ModWidget, ICard
{
- private TextureRect frame;
- private TextureRect image;
- private Label nameLabel;
+ private PanelContainer _panel;
+ private ColorRect _accent;
+ private TextureRect _icon;
+ private Label _nameLabel;
+ private Label _idLabel;
+ private HBoxContainer _badges;
+ private HBoxContainer _props;
- ///
- public TextureRect Frame => frame;
- ///
- public TextureRect Image => image;
+ private StyleBoxFlat _idleStyle;
+ private StyleBoxFlat _hoverStyle;
+ private bool _disabled;
- ///
- /// The data model backing this card. Contains the action's
- /// name, image, type, and stat modifiers used during execution.
- ///
+ /// The action data model backing this card.
public ActionCharacter Card { get; set; }
- ///
- /// Returns this — action cards are nodes themselves,
- /// unlike which wraps a separate control.
- ///
- public Control GetControlNode() => this;
+ /// True while the character cannot pay the card's action-point cost.
+ public bool IsDisabled => _disabled;
- private Tween hoverTween;
- private float baseY;
- private bool isHovered;
- private Vector2 _baseScale;
+ /// Raised when the cursor enters the card; the actions panel uses it to print the preview.
+ public event System.Action Hovered;
+
+ /// Raised when the cursor leaves the card; the preview returns to its default text.
+ public event System.Action Unhovered;
+
+ ///
+ public Control GetControlNode() => this;
protected override void Construct()
{
CthangoverTheme.Apply(this);
- image = GetNode("Image");
- frame = GetNode("Frame");
- nameLabel = GetNode