14 KiB
Combat Proposal UI Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a centered combat forecast overlay to CombatUI that displays a CombatProposal with stats and Fight/Cancel buttons, using parent-wired signals for encapsulation.
Architecture: CombatUI gains a CombatProposalPanel child and exposes show_proposal(), fight_confirmed, and fight_cancelled. A new parent script on the strategy_phase root node orchestrates the flow between PlayerController, CombatSystem, and CombatUI — the only place sibling references exist.
Tech Stack: Godot 4.6, GDScript, .tscn scene files
File Structure
| File | Role |
|---|---|
resources/resource_definitions/combat_proposal.gd |
Modify — add max_hp field to CombatantStats |
nodes/combat_system.gd |
Modify — populate max_hp in _snapshot() |
scripts/combat_ui.gd |
Modify — add proposal panel logic, signals, input handling |
prefabs/combat_ui.tscn |
Modify — add CombatProposalPanel node subtree |
nodes/strategy_phase.gd |
Create — parent orchestration script |
scenes/strategy_phase.tscn |
Modify — attach root script, rewire signals |
Task 1: Add max_hp to CombatProposal and CombatSystem
Files:
-
Modify:
resources/resource_definitions/combat_proposal.gd -
Modify:
nodes/combat_system.gd -
Step 1: Add
max_hpfield toCombatantStats
In resources/resource_definitions/combat_proposal.gd, add max_hp to the inner class:
class CombatantStats:
var unit: Unit
var max_hp: int
var hp: int
var sp: int
var hit: int
var atk: int
var def: int
var spd: int
- Step 2: Populate
max_hpin_snapshot()
In nodes/combat_system.gd, add the max_hp line to _snapshot():
func _snapshot(unit: Unit, opponent: Unit) -> CombatProposal.CombatantStats:
var stats := CombatProposal.CombatantStats.new()
stats.unit = unit
stats.max_hp = unit.current_stats.max_hp
stats.hp = unit.current_stats.current_hp
stats.sp = unit.current_stats.current_sp
stats.hit = unit.current_stats.hit - opponent.current_stats.eva
stats.atk = unit.current_stats.phys_atk
stats.def = unit.current_stats.phys_def
stats.spd = unit.current_stats.spd
return stats
- Step 3: Verify in editor
Open the project in the Godot editor. Confirm no parse errors in the Output panel. The game should still run and the existing process_combat flow should work unchanged.
- Step 4: Commit
git add resources/resource_definitions/combat_proposal.gd nodes/combat_system.gd
git commit -m "feat: add max_hp to CombatProposal.CombatantStats"
Task 2: Add CombatProposalPanel to combat_ui.tscn
Files:
-
Modify:
prefabs/combat_ui.tscn -
Step 1: Add the CombatProposalPanel subtree
Open prefabs/combat_ui.tscn in the Godot editor. Add the following node tree as a child of the root CombatUI CanvasLayer node:
CombatProposalPanel (PanelContainer) — unique name, centered, hidden by default
└── MarginContainer (margin 12 all sides)
└── VBoxContainer
├── TitleLabel (Label) — text: "COMBAT FORECAST", horizontal alignment: center
├── StatsContainer (HBoxContainer, separation: 24)
│ ├── AttackerStats (VBoxContainer)
│ │ ├── AttackerNameLabel (Label) — unique name, text: "Attacker"
│ │ ├── AttackerHPBar (ProgressBar) — unique name, show_percentage: false
│ │ ├── AttackerATKLabel (Label) — unique name, text: "ATK: 0"
│ │ ├── AttackerDEFLabel (Label) — unique name, text: "DEF: 0"
│ │ ├── AttackerHITLabel (Label) — unique name, text: "HIT: 0%"
│ │ └── AttackerSPDLabel (Label) — unique name, text: "SPD: 0"
│ └── DefenderStats (VBoxContainer)
│ ├── DefenderNameLabel (Label) — unique name, text: "Defender"
│ ├── DefenderHPBar (ProgressBar) — unique name, show_percentage: false
│ ├── DefenderATKLabel (Label) — unique name, text: "ATK: 0"
│ ├── DefenderDEFLabel (Label) — unique name, text: "DEF: 0"
│ ├── DefenderHITLabel (Label) — unique name, text: "HIT: 0%"
│ └── DefenderSPDLabel (Label) — unique name, text: "SPD: 0"
└── ButtonContainer (HBoxContainer, horizontal alignment: center)
├── FightButton (Button) — text: "Fight"
└── CancelButton (Button) — text: "Cancel"
CombatProposalPanel settings:
visible = falseanchors_preset = 8(Center)grow_horizontal = 2(Both)grow_vertical = 2(Both)theme = main_ui_theme.tres(same as UnitPanel)
All Label, ProgressBar, and Button nodes listed above must have "unique name in owner" checked so they can be referenced via %NodeName in script.
- Step 2: Verify in editor
Run the scene. The proposal panel should not be visible. The existing UnitPanel should still work as before (selecting a unit shows name + HP).
- Step 3: Commit
git add prefabs/combat_ui.tscn
git commit -m "feat: add CombatProposalPanel to combat_ui scene"
Task 3: Add proposal logic to combat_ui.gd
Files:
-
Modify:
scripts/combat_ui.gd -
Step 1: Add signals, onready vars, and proposal state
Add the new signals, @onready references for the proposal panel nodes, and a _current_proposal variable. The full updated script:
class_name CombatUI extends CanvasLayer
signal fight_confirmed(proposal: CombatProposal)
signal fight_cancelled
@onready var unit_panel: PanelContainer = %UnitPanel
@onready var name_label: Label = %NameLabel
@onready var hp_bar: ProgressBar = %HPBar
@onready var proposal_panel: PanelContainer = %CombatProposalPanel
@onready var atk_name_label: Label = %AttackerNameLabel
@onready var atk_hp_bar: ProgressBar = %AttackerHPBar
@onready var atk_atk_label: Label = %AttackerATKLabel
@onready var atk_def_label: Label = %AttackerDEFLabel
@onready var atk_hit_label: Label = %AttackerHITLabel
@onready var atk_spd_label: Label = %AttackerSPDLabel
@onready var def_name_label: Label = %DefenderNameLabel
@onready var def_hp_bar: ProgressBar = %DefenderHPBar
@onready var def_atk_label: Label = %DefenderATKLabel
@onready var def_def_label: Label = %DefenderDEFLabel
@onready var def_hit_label: Label = %DefenderHITLabel
@onready var def_spd_label: Label = %DefenderSPDLabel
@onready var fight_button: Button = %FightButton
@onready var cancel_button: Button = %CancelButton
var _selected_unit: Unit
var _current_proposal: CombatProposal
func _ready() -> void:
unit_panel.visible = false
proposal_panel.visible = false
fight_button.pressed.connect(_on_fight_pressed)
cancel_button.pressed.connect(_on_cancel_pressed)
for unit: Unit in get_tree().get_nodes_in_group("units"):
unit.unit_selected_changed.connect(_on_unit_selected_changed)
unit.unit_died.connect(_on_unit_died)
get_tree().node_added.connect(_on_node_added)
func _on_node_added(node: Node) -> void:
if node is Unit and node.is_in_group("units"):
if not node.unit_selected_changed.is_connected(_on_unit_selected_changed):
node.unit_selected_changed.connect(_on_unit_selected_changed)
if not node.unit_died.is_connected(_on_unit_died):
node.unit_died.connect(_on_unit_died)
func _on_unit_died(unit: Unit) -> void:
if _selected_unit == unit:
_selected_unit = null
unit_panel.visible = false
if _current_proposal:
if _current_proposal.attacker.unit == unit or _current_proposal.defender.unit == unit:
_hide_proposal()
func _process(_delta: float) -> void:
if _selected_unit and is_instance_valid(_selected_unit):
hp_bar.max_value = _selected_unit.current_stats.max_hp
hp_bar.value = _selected_unit.current_stats.current_hp
func _unhandled_input(event: InputEvent) -> void:
if proposal_panel.visible and event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
_hide_proposal()
fight_cancelled.emit()
get_viewport().set_input_as_handled()
func _on_unit_selected_changed(unit: Unit, selected: bool) -> void:
if selected:
_selected_unit = unit
name_label.text = unit.current_info.name
hp_bar.max_value = unit.current_stats.max_hp
hp_bar.value = unit.current_stats.current_hp
unit_panel.visible = true
else:
_selected_unit = null
unit_panel.visible = false
func show_proposal(proposal: CombatProposal) -> void:
_current_proposal = proposal
var atk := proposal.attacker
var def := proposal.defender
atk_name_label.text = atk.unit.current_info.name
atk_hp_bar.max_value = atk.max_hp
atk_hp_bar.value = atk.hp
atk_atk_label.text = "ATK: %d" % atk.atk
atk_def_label.text = "DEF: %d" % atk.def
atk_hit_label.text = "HIT: %d%%" % atk.hit
atk_spd_label.text = "SPD: %d" % atk.spd
def_name_label.text = def.unit.current_info.name
def_hp_bar.max_value = def.max_hp
def_hp_bar.value = def.hp
def_atk_label.text = "ATK: %d" % def.atk
def_def_label.text = "DEF: %d" % def.def
def_hit_label.text = "HIT: %d%%" % def.hit
def_spd_label.text = "SPD: %d" % def.spd
proposal_panel.visible = true
func _hide_proposal() -> void:
proposal_panel.visible = false
_current_proposal = null
func _on_fight_pressed() -> void:
if _current_proposal:
var proposal := _current_proposal
_hide_proposal()
fight_confirmed.emit(proposal)
func _on_cancel_pressed() -> void:
_hide_proposal()
fight_cancelled.emit()
- Step 2: Verify in editor
Open the project. Confirm no parse errors. Run the game — the proposal panel should remain hidden. Selecting units should still show the UnitPanel as before.
- Step 3: Commit
git add scripts/combat_ui.gd
git commit -m "feat: add combat proposal panel logic to CombatUI"
Task 4: Create parent orchestration script and rewire signals
Files:
-
Create:
nodes/strategy_phase.gd -
Modify:
scenes/strategy_phase.tscn -
Step 1: Create
nodes/strategy_phase.gd
class_name StrategyPhase extends Node2D
@onready var player_controller: PlayerController = $PlayerController
@onready var combat_system: CombatSystem = $CombatSystem
@onready var combat_ui: CombatUI = $CombatUI
func _ready() -> void:
player_controller.combat_requested.connect(_on_combat_requested)
combat_ui.fight_confirmed.connect(_on_fight_confirmed)
func _on_combat_requested(attacker: Unit, defender: Unit) -> void:
var proposal := combat_system.create_proposal(attacker, defender)
combat_ui.show_proposal(proposal)
func _on_fight_confirmed(proposal: CombatProposal) -> void:
combat_system.apply_proposal(proposal)
- Step 2: Attach script and remove old signal connection in
strategy_phase.tscn
Open scenes/strategy_phase.tscn in the Godot editor:
- Select the root node
CombatTest - Attach the script
res://nodes/strategy_phase.gdto it - Go to the Node > Signals panel on
PlayerController - Disconnect the existing
combat_requested→CombatSystem.process_combatconnection
If editing the .tscn file directly, the changes are:
-
Add
[ext_resource type="Script" path="res://nodes/strategy_phase.gd" id="7_strat"]to the ext_resources -
Add
script = ExtResource("7_strat")to the rootCombatTestnode -
Remove the line
[connection signal="combat_requested" from="PlayerController" to="CombatSystem" method="process_combat"] -
Step 3: Verify full flow
Run the game from the main menu:
- Click Start — strategy phase loads with two units
- Click the player unit (Putit) — UnitPanel appears bottom-left with name and HP
- Click the enemy unit — CombatProposalPanel appears centered showing both units' stats
- Click Cancel or right-click — panel closes, nothing happens
- Repeat step 3, click Fight — panel closes, combat resolves (check console for combat log from
process_combat... wait,process_combatis no longer wired)
Important: The parent script calls apply_proposal() directly, not process_combat(). The console debug prints from process_combat() will no longer appear. This is correct — process_combat() was a convenience wrapper that created and immediately applied a proposal with debug logging. The new flow separates these steps. The debug prints can be added to the parent script if needed for development, but are not required.
Verify combat works by checking that clicking Fight causes the defender to take damage (HP bar in UnitPanel updates when you re-select the damaged unit).
- Step 4: Commit
git add nodes/strategy_phase.gd scenes/strategy_phase.tscn
git commit -m "feat: add parent orchestration script, wire combat proposal flow"
Task 5: Final verification and cleanup
Files:
-
None new — verification pass only
-
Step 1: Test the full happy path
- Launch game from main menu
- Click Start
- Select player unit → UnitPanel shows
- Click enemy unit → CombatProposalPanel shows with correct stats for both units
- Click Fight → panel closes, combat resolves
- Select the damaged unit → UnitPanel HP bar reflects new HP
- Repeat combat until a unit dies → unit disappears, panels hide correctly
- Step 2: Test cancel paths
- Open proposal panel
- Click Cancel → panel closes, no combat
- Open proposal panel again
- Right-click → panel closes, no combat
- Verify units are unaffected after cancels
- Step 3: Test edge case — unit dies during proposal
This is unlikely in the current single-player flow but worth verifying the guard in _on_unit_died:
- This would require a unit dying from another source while the panel is open. For now, confirm that the
_on_unit_diedcheck incombat_ui.gdexists and would hide the panel if the attacker or defender dies.
- Step 4: Commit any fixes
If any issues were found and fixed during verification:
git add -u
git commit -m "fix: address issues found during combat proposal UI verification"