Files
MaidEngine/docs/superpowers/plans/2026-04-02-combat-proposal-ui.md

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_hp field to CombatantStats

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_hp in _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 = false
  • anchors_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:

  1. Select the root node CombatTest
  2. Attach the script res://nodes/strategy_phase.gd to it
  3. Go to the Node > Signals panel on PlayerController
  4. Disconnect the existing combat_requestedCombatSystem.process_combat connection

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 root CombatTest node

  • 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:

  1. Click Start — strategy phase loads with two units
  2. Click the player unit (Putit) — UnitPanel appears bottom-left with name and HP
  3. Click the enemy unit — CombatProposalPanel appears centered showing both units' stats
  4. Click Cancel or right-click — panel closes, nothing happens
  5. Repeat step 3, click Fight — panel closes, combat resolves (check console for combat log from process_combat... wait, process_combat is 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

  1. Launch game from main menu
  2. Click Start
  3. Select player unit → UnitPanel shows
  4. Click enemy unit → CombatProposalPanel shows with correct stats for both units
  5. Click Fight → panel closes, combat resolves
  6. Select the damaged unit → UnitPanel HP bar reflects new HP
  7. Repeat combat until a unit dies → unit disappears, panels hide correctly
  • Step 2: Test cancel paths
  1. Open proposal panel
  2. Click Cancel → panel closes, no combat
  3. Open proposal panel again
  4. Right-click → panel closes, no combat
  5. 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:

  1. This would require a unit dying from another source while the panel is open. For now, confirm that the _on_unit_died check in combat_ui.gd exists 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"