From ce92c6e4357b3e00ef2efb48b2ac13569c494264 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 2 Apr 2026 08:29:24 -0400 Subject: [PATCH] Combat base added --- .claude/settings.local.json | 7 + .../plans/2026-04-02-combat-system.md | 205 ++++++++++++++++++ .../specs/2026-04-02-combat-system-design.md | 87 ++++++++ nodes/combat_map.gd | 2 - nodes/combat_system.gd | 50 +++++ nodes/combat_system.gd.uid | 1 + nodes/player_controller.gd | 7 +- .../resource_definitions/combat_proposal.gd | 13 ++ .../combat_proposal.gd.uid | 1 + resources/resource_definitions/unit_stats.gd | 6 +- scenes/main_menu.tscn | 2 +- .../{combat_test.tscn => strategy_phase.tscn} | 27 ++- scenes/test_scene.tscn | 1 + shaders/masked_palette_swap.gdshader | 11 +- 14 files changed, 402 insertions(+), 18 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 docs/superpowers/plans/2026-04-02-combat-system.md create mode 100644 docs/superpowers/specs/2026-04-02-combat-system-design.md create mode 100644 nodes/combat_system.gd create mode 100644 nodes/combat_system.gd.uid create mode 100644 resources/resource_definitions/combat_proposal.gd create mode 100644 resources/resource_definitions/combat_proposal.gd.uid rename scenes/{combat_test.tscn => strategy_phase.tscn} (56%) diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..136586c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(find:*)" + ] + } +} diff --git a/docs/superpowers/plans/2026-04-02-combat-system.md b/docs/superpowers/plans/2026-04-02-combat-system.md new file mode 100644 index 0000000..92f52c3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-combat-system.md @@ -0,0 +1,205 @@ +# Combat System 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:** Create a stateless combat system that produces pre-combat proposals and resolves attacks between two units. + +**Architecture:** A `CombatProposal` resource holds snapshot stats for attacker and defender. A `CombatSystem` node exposes `create_proposal()` to build proposals and `apply_proposal()` to resolve combat, modifying unit HP directly. The system is added to the strategy phase scene tree. + +**Tech Stack:** Godot 4.6, GDScript + +--- + +## File Structure + +| File | Responsibility | +|------|---------------| +| `resources/resource_definitions/combat_proposal.gd` | CombatProposal resource with CombatantStats inner class — holds unit references and snapshot stats | +| `nodes/combat_system.gd` | CombatSystem node — `create_proposal()` and `apply_proposal()` methods | +| `scenes/strategy_phase.tscn` | Scene tree — add CombatSystem node | + +--- + +### Task 1: Create the CombatProposal resource + +**Files:** +- Create: `resources/resource_definitions/combat_proposal.gd` + +- [ ] **Step 1: Create the CombatantStats inner class and CombatProposal resource** + +```gdscript +# resources/resource_definitions/combat_proposal.gd +class_name CombatProposal extends Resource + +class CombatantStats: + var unit: Unit + var hp: int + var sp: int + var hit: int + var atk: int + var def: int + var spd: int + +var attacker: CombatantStats +var defender: CombatantStats +``` + +- [ ] **Step 2: Verify the file loads without errors** + +Open the Godot editor and check the Output panel for parse errors. Confirm `CombatProposal` appears as a recognized class by typing it in any script's autocomplete. + +--- + +### Task 2: Create the CombatSystem node + +**Files:** +- Create: `nodes/combat_system.gd` +- Reference: `resources/resource_definitions/combat_proposal.gd` +- Reference: `resources/resource_definitions/unit_stats.gd` +- Reference: `nodes/unit.gd` + +- [ ] **Step 1: Create combat_system.gd with create_proposal** + +```gdscript +# nodes/combat_system.gd +class_name CombatSystem extends Node + +func create_proposal(attacker: Unit, defender: Unit) -> CombatProposal: + var proposal := CombatProposal.new() + + proposal.attacker = _snapshot(attacker, defender) + proposal.defender = _snapshot(defender, attacker) + + return proposal + +func _snapshot(unit: Unit, opponent: Unit) -> CombatProposal.CombatantStats: + var stats := CombatProposal.CombatantStats.new() + stats.unit = unit + 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 2: Add apply_proposal method** + +Append to `nodes/combat_system.gd`: + +```gdscript +func apply_proposal(proposal: CombatProposal) -> void: + var atk_stats := proposal.attacker + var def_stats := proposal.defender + var atk_unit := atk_stats.unit + var def_unit := def_stats.unit + + # Attacker strikes + var atk_roll := randi_range(1, 100) + if atk_roll <= atk_stats.hit: + var damage := maxi(atk_stats.atk - def_stats.def, 0) + def_unit.current_stats.current_hp -= damage + + # Counterattack if defender survives + if def_unit.current_stats.current_hp > 0: + var def_roll := randi_range(1, 100) + if def_roll <= def_stats.hit: + var damage := maxi(def_stats.atk - atk_stats.def, 0) + atk_unit.current_stats.current_hp -= damage +``` + +- [ ] **Step 3: Verify the file loads without errors** + +Open the Godot editor and check the Output panel for parse errors on `combat_system.gd`. + +--- + +### Task 3: Add CombatSystem to the strategy phase scene + +**Files:** +- Modify: `scenes/strategy_phase.tscn` + +- [ ] **Step 1: Add CombatSystem node and script reference to the scene** + +Add a new `ext_resource` entry for the combat_system.gd script and a new node to the scene file. Append after the existing ext_resources and add the node as a child of the root `CombatTest` node: + +New ext_resource (use the next available id, `6_xxxxx` — the exact suffix will be assigned): +``` +[ext_resource type="Script" path="res://nodes/combat_system.gd" id="6_combat"] +``` + +New node (add after the PlayerController node block): +``` +[node name="CombatSystem" type="Node" parent="."] +script = ExtResource("6_combat") +``` + +The resulting scene tree order: +``` +CombatTest (Node2D) + ├─ CombatUI + ├─ CombatMap + ├─ PlayerController + ├─ CombatSystem <-- new + ├─ Camera2D + └─ AudioStreamPlayer +``` + +- [ ] **Step 2: Verify in the Godot editor** + +Open `scenes/strategy_phase.tscn` in the editor. Confirm: +- CombatSystem node appears in the scene tree +- No errors in the Output panel +- The scene runs without crashes + +--- + +### Task 4: Manual integration verification + +- [ ] **Step 1: Verify create_proposal works** + +Temporarily add the following to `CombatSystem._ready()` to test proposal creation (remove after verification): + +```gdscript +func _ready() -> void: + # Temporary test — remove after verification + var units := get_tree().get_nodes_in_group("units") + if units.size() >= 2: + var proposal := create_proposal(units[0], units[1]) + print("Proposal created:") + print(" Attacker: ", proposal.attacker.unit.current_info.name, + " HP=", proposal.attacker.hp, + " ATK=", proposal.attacker.atk, + " DEF=", proposal.attacker.def, + " HIT=", proposal.attacker.hit) + print(" Defender: ", proposal.defender.unit.current_info.name, + " HP=", proposal.defender.hp, + " ATK=", proposal.defender.atk, + " DEF=", proposal.defender.def, + " HIT=", proposal.defender.hit) +``` + +Run the scene with at least 2 units deployed. Check the Output panel for correct stat snapshots. + +- [ ] **Step 2: Verify apply_proposal works** + +Extend the temporary `_ready()` to also apply the proposal: + +```gdscript + print("Before combat: Attacker HP=", proposal.attacker.unit.current_stats.current_hp, + " Defender HP=", proposal.defender.unit.current_stats.current_hp) + apply_proposal(proposal) + print("After combat: Attacker HP=", proposal.attacker.unit.current_stats.current_hp, + " Defender HP=", proposal.defender.unit.current_stats.current_hp) +``` + +Run multiple times (results will vary due to hit rolls). Confirm: +- Defender HP decreases when attacker hits +- Attacker HP decreases when defender counterattacks +- HP never increases (no negative damage) +- If defender dies (HP <= 0), no counterattack occurs + +- [ ] **Step 3: Remove temporary test code** + +Delete the entire `_ready()` method from `combat_system.gd`. diff --git a/docs/superpowers/specs/2026-04-02-combat-system-design.md b/docs/superpowers/specs/2026-04-02-combat-system-design.md new file mode 100644 index 0000000..71504b8 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-combat-system-design.md @@ -0,0 +1,87 @@ +# Combat System + +## Overview + +A stateless CombatSystem node that creates pre-combat proposals and applies combat resolution between two units. The system encapsulates all combat stat calculation so other systems don't need to know proposal logic. + +## CombatProposal Resource + +`resources/resource_definitions/combat_proposal.gd` + +A Resource containing two sides — attacker and defender. Each side holds: + +- A reference to the `Unit` node +- Pre-combat snapshot stats via an inner class `CombatantStats`: + - `hp: int` — from `current_stats.current_hp` + - `sp: int` — from `current_stats.current_sp` + - `hit: int` — calculated as `own hit - opponent's eva` + - `atk: int` — from `current_stats.phys_atk` + - `def: int` — from `current_stats.phys_def` + - `spd: int` — from `current_stats.spd` + +### Stat Calculation + +For the **attacker** side: +- `atk` = attacker's `phys_atk` +- `def` = attacker's `phys_def` +- `hit` = attacker's `hit` - defender's `eva` +- `hp`, `sp`, `spd` copied directly from attacker's `current_stats` + +For the **defender** side: +- `atk` = defender's `phys_atk` +- `def` = defender's `phys_def` +- `hit` = defender's `hit` - attacker's `eva` +- `hp`, `sp`, `spd` copied directly from defender's `current_stats` + +In the future, different attacks may swap between physical and magic stats, but for now only physical stats are used. + +## CombatSystem Node + +`nodes/combat_system.gd` + +A `Node` added to the strategy phase scene tree. Stateless — operates purely on units passed in. + +### `create_proposal(attacker: Unit, defender: Unit) -> CombatProposal` + +- Reads both units' `current_stats` +- Builds a `CombatProposal` with snapshot stats for each side +- Applies cross-calculations (hit - eva) +- Returns the proposal without modifying any unit state + +### `apply_proposal(proposal: CombatProposal) -> void` + +1. **Attacker strikes:** Roll random int 1–100. If roll <= attacker's calculated `hit`, apply `max(attacker.atk - defender.def, 0)` damage to the defender Unit's `current_stats.current_hp` (modifies the actual unit, not the snapshot). +2. **Counterattack:** If the defender Unit's `current_stats.current_hp > 0` after the attack, roll 1–100 for defender. If roll <= defender's calculated `hit`, apply `max(defender.atk - attacker.def, 0)` damage to the attacker Unit's `current_stats.current_hp`. + +- Damage has a floor of 0 (no negative damage / healing). +- Range is ignored for now. In the future, counterattack will depend on whether the defender has skills at appropriate range (defaulting to a "defend" action if not). + +## Scene Integration + +The CombatSystem node is added to `scenes/strategy_phase.tscn` as a sibling of PlayerController, CombatMap, etc. No exports needed. + +``` +CombatTest (Node2D) + ├─ CombatUI + ├─ CombatMap + ├─ PlayerController + ├─ CombatSystem <-- new + ├─ Camera2D + └─ AudioStreamPlayer +``` + +## Files Changed + +| File | Change | +|------|--------| +| `resources/resource_definitions/combat_proposal.gd` | New — CombatProposal resource with CombatantStats inner class | +| `nodes/combat_system.gd` | New — CombatSystem node with `create_proposal` and `apply_proposal` | +| `scenes/strategy_phase.tscn` | Add CombatSystem node to scene tree | + +## Out of Scope + +- Magic attack/defense selection (future: different attack types swap stats) +- Range-based counterattack eligibility (future: defend action when no skills in range) +- Critical hits +- UI for displaying the combat proposal +- Animations or visual feedback for combat resolution diff --git a/nodes/combat_map.gd b/nodes/combat_map.gd index fa5b28e..4143c81 100644 --- a/nodes/combat_map.gd +++ b/nodes/combat_map.gd @@ -25,11 +25,9 @@ func _ready() -> void: func snap_to_grid(pos: Vector2) -> Vector2: return Vector2(floorf(pos.x / TILE_SIZE), floorf(pos.y / TILE_SIZE)) * TILE_SIZE - func world_to_coords(pos: Vector2) -> Vector2i: return Vector2i(snap_to_grid(pos) / TILE_SIZE) - func coords_to_world(coords: Vector2i) -> Vector2: return Vector2(coords) * TILE_SIZE diff --git a/nodes/combat_system.gd b/nodes/combat_system.gd new file mode 100644 index 0000000..f062c47 --- /dev/null +++ b/nodes/combat_system.gd @@ -0,0 +1,50 @@ +class_name CombatSystem extends Node + +func create_proposal(attacker: Unit, defender: Unit) -> CombatProposal: + var proposal := CombatProposal.new() + + proposal.attacker = _snapshot(attacker, defender) + proposal.defender = _snapshot(defender, attacker) + + return proposal + +func _snapshot(unit: Unit, opponent: Unit) -> CombatProposal.CombatantStats: + var stats := CombatProposal.CombatantStats.new() + stats.unit = unit + 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 + +func process_combat(attacker: Unit, defender: Unit) -> void: + var proposal := create_proposal(attacker, defender) + var atk_name := attacker.current_info.name + var def_name := defender.current_info.name + print("=== Combat: %s vs %s ===" % [atk_name, def_name]) + print(" %s — HP:%d ATK:%d DEF:%d HIT:%d" % [atk_name, proposal.attacker.hp, proposal.attacker.atk, proposal.attacker.def, proposal.attacker.hit]) + print(" %s — HP:%d ATK:%d DEF:%d HIT:%d" % [def_name, proposal.defender.hp, proposal.defender.atk, proposal.defender.def, proposal.defender.hit]) + apply_proposal(proposal) + print(" Result: %s HP=%d, %s HP=%d" % [atk_name, attacker.current_stats.current_hp, def_name, defender.current_stats.current_hp]) + + +func apply_proposal(proposal: CombatProposal) -> void: + var atk_stats := proposal.attacker + var def_stats := proposal.defender + var atk_unit := atk_stats.unit + var def_unit := def_stats.unit + + # Attacker strikes + var atk_roll := randi_range(1, 100) + if atk_roll <= atk_stats.hit: + var damage := maxi(atk_stats.atk - def_stats.def, 0) + def_unit.current_stats.current_hp -= damage + + # Counterattack if defender survives + if def_unit.current_stats.current_hp > 0: + var def_roll := randi_range(1, 100) + if def_roll <= def_stats.hit: + var damage := maxi(def_stats.atk - atk_stats.def, 0) + atk_unit.current_stats.current_hp -= damage diff --git a/nodes/combat_system.gd.uid b/nodes/combat_system.gd.uid new file mode 100644 index 0000000..1381262 --- /dev/null +++ b/nodes/combat_system.gd.uid @@ -0,0 +1 @@ +uid://cf4ivrcbky0s3 diff --git a/nodes/player_controller.gd b/nodes/player_controller.gd index 9557e95..ee74b39 100644 --- a/nodes/player_controller.gd +++ b/nodes/player_controller.gd @@ -4,6 +4,8 @@ const SPEED = 192.0 @export var dl_map: CombatMap +signal combat_requested(attacker: Unit, defender: Unit) + var _selected_unit: Unit = null var _target_pos: Vector2 var _goal_pos: Vector2 @@ -16,7 +18,10 @@ func _unhandled_input(event: InputEvent) -> void: var clicked_unit := _get_unit_at(world_pos) if clicked_unit: - _select_unit(clicked_unit) + if _selected_unit and clicked_unit != _selected_unit: + combat_requested.emit(_selected_unit, clicked_unit) + else: + _select_unit(clicked_unit) get_viewport().set_input_as_handled() elif _selected_unit: var snapped_pos := dl_map.snap_to_grid(world_pos) diff --git a/resources/resource_definitions/combat_proposal.gd b/resources/resource_definitions/combat_proposal.gd new file mode 100644 index 0000000..d31911b --- /dev/null +++ b/resources/resource_definitions/combat_proposal.gd @@ -0,0 +1,13 @@ +class_name CombatProposal extends Resource + +class CombatantStats: + var unit: Unit + var hp: int + var sp: int + var hit: int + var atk: int + var def: int + var spd: int + +var attacker: CombatantStats +var defender: CombatantStats diff --git a/resources/resource_definitions/combat_proposal.gd.uid b/resources/resource_definitions/combat_proposal.gd.uid new file mode 100644 index 0000000..01a9197 --- /dev/null +++ b/resources/resource_definitions/combat_proposal.gd.uid @@ -0,0 +1 @@ +uid://b4oatflqabi37 diff --git a/resources/resource_definitions/unit_stats.gd b/resources/resource_definitions/unit_stats.gd index f235b8b..426ebf6 100644 --- a/resources/resource_definitions/unit_stats.gd +++ b/resources/resource_definitions/unit_stats.gd @@ -2,8 +2,10 @@ class_name UnitStats extends Resource @export var max_hp: int = 10 @export var current_hp: int -@export var phys_atk: int = 1 -@export var phys_def: int = 1 +@export var max_sp: int = 10 +@export var current_sp: int = 10 +@export var phys_atk: int = 10 +@export var phys_def: int = 5 @export var magic_atk: int = 0 @export var magic_def: int = 0 @export var hit: int = 85 diff --git a/scenes/main_menu.tscn b/scenes/main_menu.tscn index ded2c59..44dd2ba 100644 --- a/scenes/main_menu.tscn +++ b/scenes/main_menu.tscn @@ -7,7 +7,7 @@ resource_name = "StartButton" script/source = "extends Button -const COMBAT_SCENE = preload(\"res://scenes/combat_test.tscn\") +const COMBAT_SCENE = preload(\"res://scenes/strategy_phase.tscn\") const UNIT_SCENE = preload(\"res://prefabs/unit.tscn\") const PLAYER_ALLEGIANCE = preload(\"res://resources/allegiance_types/player_allegiance.tres\") const ENEMY_ALLEGIANCE = preload(\"res://resources/allegiance_types/enemy_allegiance.tres\") diff --git a/scenes/combat_test.tscn b/scenes/strategy_phase.tscn similarity index 56% rename from scenes/combat_test.tscn rename to scenes/strategy_phase.tscn index adf8674..3017670 100644 --- a/scenes/combat_test.tscn +++ b/scenes/strategy_phase.tscn @@ -1,26 +1,31 @@ [gd_scene format=3 uid="uid://wy7ur5r23ek3"] -[ext_resource type="PackedScene" uid="uid://cy7r0udfcsqbn" path="res://prefabs/combat_ui.tscn" id="1_5jbmu"] -[ext_resource type="PackedScene" uid="uid://dkhyh5ce4iuk3" path="res://prefabs/combat_map.tscn" id="1_7abyo"] -[ext_resource type="Script" uid="uid://csdcbi2gtwrly" path="res://scripts/camera_controller.gd" id="3_cam"] -[ext_resource type="Script" uid="uid://dfojm3n0em4ef" path="res://nodes/player_controller.gd" id="4_s5ga2"] -[ext_resource type="AudioStream" uid="uid://dsikulned64qt" path="res://assets/music/combat_bgm_01.OGG" id="6_0yobm"] +[ext_resource type="PackedScene" uid="uid://cy7r0udfcsqbn" path="res://prefabs/combat_ui.tscn" id="1_6gip4"] +[ext_resource type="PackedScene" uid="uid://dkhyh5ce4iuk3" path="res://prefabs/combat_map.tscn" id="2_iuoca"] +[ext_resource type="Script" uid="uid://dfojm3n0em4ef" path="res://nodes/player_controller.gd" id="3_esrqm"] +[ext_resource type="Script" uid="uid://csdcbi2gtwrly" path="res://scripts/camera_controller.gd" id="4_ww3c6"] +[ext_resource type="AudioStream" uid="uid://dsikulned64qt" path="res://assets/music/combat_bgm_01.OGG" id="5_ficdm"] +[ext_resource type="Script" uid="uid://cf4ivrcbky0s3" path="res://nodes/combat_system.gd" id="6_combat"] [node name="CombatTest" type="Node2D" unique_id=855645983] -[node name="CombatUI" parent="." unique_id=329168107 instance=ExtResource("1_5jbmu")] +[node name="CombatUI" parent="." unique_id=329168107 instance=ExtResource("1_6gip4")] -[node name="CombatMap" parent="." unique_id=546780706 instance=ExtResource("1_7abyo")] +[node name="CombatMap" parent="." unique_id=546780706 instance=ExtResource("2_iuoca")] [node name="PlayerController" type="Node" parent="." unique_id=774568109 node_paths=PackedStringArray("dl_map")] -script = ExtResource("4_s5ga2") +script = ExtResource("3_esrqm") dl_map = NodePath("../CombatMap") +[node name="CombatSystem" type="Node" parent="." unique_id=1234567890] +script = ExtResource("6_combat") + [node name="Camera2D" type="Camera2D" parent="." unique_id=1739569732] zoom = Vector2(1.5, 1.5) -script = ExtResource("3_cam") - +script = ExtResource("4_ww3c6") [node name="AudioStreamPlayer" type="AudioStreamPlayer" parent="." unique_id=1057500234] -stream = ExtResource("6_0yobm") +stream = ExtResource("5_ficdm") autoplay = true + +[connection signal="combat_requested" from="PlayerController" to="CombatSystem" method="process_combat"] diff --git a/scenes/test_scene.tscn b/scenes/test_scene.tscn index 0f53f3a..7c0a603 100644 --- a/scenes/test_scene.tscn +++ b/scenes/test_scene.tscn @@ -8,6 +8,7 @@ shader = ExtResource("1_nd71p") shader_parameter/flag_mask = ExtResource("2_7ddre") shader_parameter/team_color = Color(0.84830123, 0.29993045, 0.292207, 1) +shader_parameter/chroma_threshold = 0.10000000475 [sub_resource type="AtlasTexture" id="AtlasTexture_j8ivh"] atlas = ExtResource("1_g7g4h") diff --git a/shaders/masked_palette_swap.gdshader b/shaders/masked_palette_swap.gdshader index 05f501e..15d7b16 100644 --- a/shaders/masked_palette_swap.gdshader +++ b/shaders/masked_palette_swap.gdshader @@ -2,9 +2,18 @@ shader_type canvas_item; uniform sampler2D flag_mask : source_color, hint_default_black; uniform vec4 team_color : source_color = vec4(1.0, 0.0, 0.0, 1.0); // red by default +uniform float chroma_threshold : hint_range(0.0, 1.0) = 0.01; void fragment() { - vec4 base = texture(TEXTURE, UV); + vec4 base = texture(TEXTURE, UV); + + // --- Chroma key: discard near-black pixels --- + float max_channel = max(base.r, max(base.g, base.b)); + if (max_channel < chroma_threshold) { + discard; + } + + // --- Palette swap using masks --- float mask = texture(flag_mask, UV).r; // 1.0 on flag pixels, 0.0 on castle // Use the grey luminance as a brightness multiplier on the team color