Combat base added

This commit is contained in:
gamer147
2026-04-02 08:29:24 -04:00
parent 470e89b15b
commit ce92c6e435
14 changed files with 402 additions and 18 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(find:*)"
]
}
}

View File

@@ -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`.

View File

@@ -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 1100. 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 1100 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

View File

@@ -25,11 +25,9 @@ func _ready() -> void:
func snap_to_grid(pos: Vector2) -> Vector2: func snap_to_grid(pos: Vector2) -> Vector2:
return Vector2(floorf(pos.x / TILE_SIZE), floorf(pos.y / TILE_SIZE)) * TILE_SIZE return Vector2(floorf(pos.x / TILE_SIZE), floorf(pos.y / TILE_SIZE)) * TILE_SIZE
func world_to_coords(pos: Vector2) -> Vector2i: func world_to_coords(pos: Vector2) -> Vector2i:
return Vector2i(snap_to_grid(pos) / TILE_SIZE) return Vector2i(snap_to_grid(pos) / TILE_SIZE)
func coords_to_world(coords: Vector2i) -> Vector2: func coords_to_world(coords: Vector2i) -> Vector2:
return Vector2(coords) * TILE_SIZE return Vector2(coords) * TILE_SIZE

50
nodes/combat_system.gd Normal file
View File

@@ -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

View File

@@ -0,0 +1 @@
uid://cf4ivrcbky0s3

View File

@@ -4,6 +4,8 @@ const SPEED = 192.0
@export var dl_map: CombatMap @export var dl_map: CombatMap
signal combat_requested(attacker: Unit, defender: Unit)
var _selected_unit: Unit = null var _selected_unit: Unit = null
var _target_pos: Vector2 var _target_pos: Vector2
var _goal_pos: Vector2 var _goal_pos: Vector2
@@ -16,6 +18,9 @@ func _unhandled_input(event: InputEvent) -> void:
var clicked_unit := _get_unit_at(world_pos) var clicked_unit := _get_unit_at(world_pos)
if clicked_unit: if clicked_unit:
if _selected_unit and clicked_unit != _selected_unit:
combat_requested.emit(_selected_unit, clicked_unit)
else:
_select_unit(clicked_unit) _select_unit(clicked_unit)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif _selected_unit: elif _selected_unit:

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://b4oatflqabi37

View File

@@ -2,8 +2,10 @@ class_name UnitStats extends Resource
@export var max_hp: int = 10 @export var max_hp: int = 10
@export var current_hp: int @export var current_hp: int
@export var phys_atk: int = 1 @export var max_sp: int = 10
@export var phys_def: int = 1 @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_atk: int = 0
@export var magic_def: int = 0 @export var magic_def: int = 0
@export var hit: int = 85 @export var hit: int = 85

View File

@@ -7,7 +7,7 @@
resource_name = "StartButton" resource_name = "StartButton"
script/source = "extends Button 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 UNIT_SCENE = preload(\"res://prefabs/unit.tscn\")
const PLAYER_ALLEGIANCE = preload(\"res://resources/allegiance_types/player_allegiance.tres\") const PLAYER_ALLEGIANCE = preload(\"res://resources/allegiance_types/player_allegiance.tres\")
const ENEMY_ALLEGIANCE = preload(\"res://resources/allegiance_types/enemy_allegiance.tres\") const ENEMY_ALLEGIANCE = preload(\"res://resources/allegiance_types/enemy_allegiance.tres\")

View File

@@ -1,26 +1,31 @@
[gd_scene format=3 uid="uid://wy7ur5r23ek3"] [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://cy7r0udfcsqbn" path="res://prefabs/combat_ui.tscn" id="1_6gip4"]
[ext_resource type="PackedScene" uid="uid://dkhyh5ce4iuk3" path="res://prefabs/combat_map.tscn" id="1_7abyo"] [ext_resource type="PackedScene" uid="uid://dkhyh5ce4iuk3" path="res://prefabs/combat_map.tscn" id="2_iuoca"]
[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="3_esrqm"]
[ext_resource type="Script" uid="uid://dfojm3n0em4ef" path="res://nodes/player_controller.gd" id="4_s5ga2"] [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="6_0yobm"] [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="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")] [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") 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] [node name="Camera2D" type="Camera2D" parent="." unique_id=1739569732]
zoom = Vector2(1.5, 1.5) zoom = Vector2(1.5, 1.5)
script = ExtResource("3_cam") script = ExtResource("4_ww3c6")
[node name="AudioStreamPlayer" type="AudioStreamPlayer" parent="." unique_id=1057500234] [node name="AudioStreamPlayer" type="AudioStreamPlayer" parent="." unique_id=1057500234]
stream = ExtResource("6_0yobm") stream = ExtResource("5_ficdm")
autoplay = true autoplay = true
[connection signal="combat_requested" from="PlayerController" to="CombatSystem" method="process_combat"]

View File

@@ -8,6 +8,7 @@
shader = ExtResource("1_nd71p") shader = ExtResource("1_nd71p")
shader_parameter/flag_mask = ExtResource("2_7ddre") shader_parameter/flag_mask = ExtResource("2_7ddre")
shader_parameter/team_color = Color(0.84830123, 0.29993045, 0.292207, 1) 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"] [sub_resource type="AtlasTexture" id="AtlasTexture_j8ivh"]
atlas = ExtResource("1_g7g4h") atlas = ExtResource("1_g7g4h")

View File

@@ -2,9 +2,18 @@ shader_type canvas_item;
uniform sampler2D flag_mask : source_color, hint_default_black; 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 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() { 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 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 // Use the grey luminance as a brightness multiplier on the team color