Combat panel, mouse movement still passes through

This commit is contained in:
gamer147
2026-04-02 11:34:17 -04:00
parent 528cb50e58
commit 86c29569d4
9 changed files with 744 additions and 2 deletions

View File

@@ -0,0 +1,367 @@
# 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:
```gdscript
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()`:
```gdscript
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**
```bash
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**
```bash
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:
```gdscript
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**
```bash
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`**
```gdscript
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_requested``CombatSystem.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**
```bash
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:
```bash
git add -u
git commit -m "fix: address issues found during combat proposal UI verification"
```

View File

@@ -0,0 +1,165 @@
# 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 |