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

7.6 KiB

Combat Proposal UI Design

Overview

Add a centered combat forecast overlay to the existing CombatUI that displays a CombatProposal with attacker/defender stats and Fight/Cancel buttons. The system uses parent-wired signals — no node references outside parent-child relationships.

Signal Flow

When a player clicks a defender while an attacker is selected:

  1. PlayerController emits combat_requested(attacker, defender)
  2. Parent scene script catches it, calls CombatSystem.create_proposal(attacker, defender)
  3. Parent calls CombatUI.show_proposal(proposal)
  4. Player sees the combat forecast panel

On Fight:

  1. CombatUI emits fight_confirmed(proposal)
  2. Parent catches it, calls CombatSystem.apply_proposal(proposal)
  3. Panel closes

On Cancel (Cancel button or right-click):

  1. CombatUI emits fight_cancelled
  2. Parent catches it (no action needed beyond the signal existing for future use)
  3. Panel closes

Architecture

Encapsulation Rules

  • CombatUI knows only about its own children (UnitPanel, CombatProposalPanel, buttons). Exposes signals and public methods.
  • PlayerController knows nothing about CombatUI or CombatSystem. Emits combat_requested.
  • CombatSystem knows nothing about UI. Exposes create_proposal() and apply_proposal().
  • Parent scene script (strategy_phase.tscn root node) is the only place that references sibling nodes via @onready. It wires signals in _ready() and contains the orchestration logic.

Parent Scene Script

A new script on the strategy_phase.tscn root node (~20 lines) that:

  • Holds @onready references to PlayerController, CombatSystem, and CombatUI
  • Connects PlayerController.combat_requested to a local method that creates a proposal and passes it to CombatUI.show_proposal()
  • Connects CombatUI.fight_confirmed to a local method that calls CombatSystem.apply_proposal()
  • Connects CombatUI.fight_cancelled (no-op for now, but wired for future use)

This replaces the current direct signal connection from PlayerController.combat_requested to CombatSystem.process_combat in the scene editor.

CombatUI Changes

New Signals

  • fight_confirmed(proposal: CombatProposal) — emitted when Fight button pressed
  • fight_cancelled — emitted when Cancel button pressed or right-click detected

New Public Methods

  • show_proposal(proposal: CombatProposal) — stores the proposal reference, populates all labels and progress bars from the proposal's CombatantStats, shows the CombatProposalPanel
  • _hide_proposal() — hides the panel, clears stored proposal reference

Input Handling

Right-click while the proposal panel is visible triggers _hide_proposal() and emits fight_cancelled. Handled via _unhandled_input on CombatUI.

Existing Unit Panel

The bottom-left unit info panel remains visible and unaffected. It continues to show the selected unit's info, which provides useful context alongside the forecast.

CombatProposalPanel Layout

New PanelContainer child of the CombatUI CanvasLayer, centered on screen via anchor presets. Hidden by default.

┌─────────────────────────────────────────────┐
│              COMBAT FORECAST                │
│                                             │
│   [Attacker]           [Defender]           │
│   Name                     Name             │
│   HP ████████░░  HP ██████░░░░              │
│   ATK: 12              ATK: 8              │
│   DEF: 5               DEF: 6              │
│   HIT: 78%             HIT: 65%            │
│   SPD: 10              SPD: 7              │
│                                             │
│         [ Fight ]    [ Cancel ]             │
└─────────────────────────────────────────────┘

Scene Structure

CombatUI (CanvasLayer) — combat_ui.gd
├── UnitPanel (PanelContainer) — existing, bottom-left
│   └── MarginContainer
│       └── VBoxContainer
│           ├── NameLabel (Label)
│           └── HPBar (ProgressBar)
└── CombatProposalPanel (PanelContainer) — new, centered
    └── MarginContainer
        └── VBoxContainer
            ├── TitleLabel (Label) — "COMBAT FORECAST"
            ├── StatsContainer (HBoxContainer)
            │   ├── AttackerStats (VBoxContainer)
            │   │   ├── AttackerNameLabel (Label)
            │   │   ├── AttackerHPBar (ProgressBar)
            │   │   ├── AttackerATKLabel (Label) — "ATK: {value}"
            │   │   ├── AttackerDEFLabel (Label) — "DEF: {value}"
            │   │   ├── AttackerHITLabel (Label) — "HIT: {value}%"
            │   │   └── AttackerSPDLabel (Label) — "SPD: {value}"
            │   └── DefenderStats (VBoxContainer)
            │       ├── DefenderNameLabel (Label)
            │       ├── DefenderHPBar (ProgressBar)
            │       ├── DefenderATKLabel (Label) — "ATK: {value}"
            │       ├── DefenderDEFLabel (Label) — "DEF: {value}"
            │       ├── DefenderHITLabel (Label) — "HIT: {value}%"
            │       └── DefenderSPDLabel (Label) — "SPD: {value}"
            └── ButtonContainer (HBoxContainer) — centered
                ├── FightButton (Button) — "Fight"
                └── CancelButton (Button) — "Cancel"

Data Mapping

All values come from CombatProposal.CombatantStats:

Panel Field Source
Name combatant_stats.unit.info.name
HP bar max combatant_stats.max_hp
HP bar value combatant_stats.hp
ATK combatant_stats.atk
DEF combatant_stats.def
HIT combatant_stats.hit (already adjusted: attacker hit - defender eva)
SPD combatant_stats.spd

CombatProposal Change

CombatantStats in resources/resource_definitions/combat_proposal.gd needs a new max_hp: int field added so the HP bar can display a meaningful ratio. CombatSystem._snapshot() must populate it from unit.current_stats.max_hp.

Styling

  • Uses the existing main_ui_theme.tres (Minecraft font)
  • Panel background: default theme PanelContainer style
  • Centered via anchors_preset = CENTER
  • No fixed pixel size — let the content determine width naturally with margin padding

Button Wiring

FightButton and CancelButton pressed signals connect to methods on combat_ui.gd within the scene (parent-child, no encapsulation issue):

  • FightButton.pressed_on_fight_pressed() — emits fight_confirmed(stored_proposal), calls _hide_proposal()
  • CancelButton.pressed_on_cancel_pressed() — emits fight_cancelled, calls _hide_proposal()

Files Changed

File Change
resources/resource_definitions/combat_proposal.gd Add max_hp field to CombatantStats
nodes/combat_system.gd Populate max_hp in _snapshot()
scripts/combat_ui.gd Add signals, show_proposal(), _hide_proposal(), _unhandled_input for right-click cancel, button handlers
prefabs/combat_ui.tscn Add CombatProposalPanel subtree, wire button signals
scenes/strategy_phase.tscn Add root script, remove direct PlayerController→CombatSystem signal connection

Files Added

File Purpose
nodes/strategy_phase.gd Parent orchestration script for strategy_phase.tscn root node