From f14ee656ead0581a5ae3805ff72c9689e6452e2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 14:43:29 +0200 Subject: [PATCH] Add enemy foundation: definitions, components, ranged damage (#44) Enemy.gd now resolves stats/hull/color from EnemyDefinition resources and composes behaviour from capability components (EnemyPursuitComponent holds today's chase-the-nearest-ship logic), the same shape Ship.gd already uses for vessels. Fleet lookups go through a "player_fleet" group instead of a hardcoded ../Fleet sibling path, so an enemy parented anywhere (e.g. under a planet surface's SurfaceFeatures) still finds it. Also closes a real gap: enemies previously had no way to damage the fleet at range at all (Ship only reacted to physical contact, Projectile.gd was player-only). Ship.take_damage() is now a proper entry point - contact damage routes through it unchanged - and EnemyProjectile lets an enemy component fire at player_ships. Both ranged enemy archetypes in #45/#46 build on this. Part of the #40 conquest-mechanic breakdown. --- ChaserEnemyDefinition.tres | 16 ++++ Enemy.gd | 76 ++++++++++--------- EnemyDefinition.gd | 19 +++++ EnemyDefinition.gd.uid | 1 + EnemyDefinitions.gd | 19 +++++ EnemyDefinitions.gd.uid | 1 + EnemyProjectile.gd | 45 +++++++++++ EnemyProjectile.gd.uid | 1 + EnemyProjectile.tscn | 12 +++ EnemyPursuitComponent.gd | 38 ++++++++++ EnemyPursuitComponent.gd.uid | 1 + EnemyPursuitComponent.tscn | 6 ++ Fleet.gd | 4 + Ship.gd | 22 ++++-- tests/_gas_bomb_aim_preview.gd | 2 +- tests/test_enemy_composition.gd | 78 +++++++++++++++++++ tests/test_enemy_composition.gd.uid | 1 + tests/test_enemy_projectile_damage.gd | 91 +++++++++++++++++++++++ tests/test_enemy_projectile_damage.gd.uid | 1 + tests/test_gas_bomber.gd | 7 +- 20 files changed, 396 insertions(+), 45 deletions(-) create mode 100644 ChaserEnemyDefinition.tres create mode 100644 EnemyDefinition.gd create mode 100644 EnemyDefinition.gd.uid create mode 100644 EnemyDefinitions.gd create mode 100644 EnemyDefinitions.gd.uid create mode 100644 EnemyProjectile.gd create mode 100644 EnemyProjectile.gd.uid create mode 100644 EnemyProjectile.tscn create mode 100644 EnemyPursuitComponent.gd create mode 100644 EnemyPursuitComponent.gd.uid create mode 100644 EnemyPursuitComponent.tscn create mode 100644 tests/test_enemy_composition.gd create mode 100644 tests/test_enemy_composition.gd.uid create mode 100644 tests/test_enemy_projectile_damage.gd create mode 100644 tests/test_enemy_projectile_damage.gd.uid diff --git a/ChaserEnemyDefinition.tres b/ChaserEnemyDefinition.tres new file mode 100644 index 0000000..90f4385 --- /dev/null +++ b/ChaserEnemyDefinition.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://EnemyDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://EnemyPursuitComponent.tscn" id="2"] + +[resource] +script = ExtResource("1") +enemy_id = "chaser" +display_name = "Chaser" +max_hp = 3 +speed = 140.0 +acceleration = 260.0 +rotation_speed = 6.0 +hull_points = PackedVector2Array(0, -16, 12, 0, 0, 16, -12, 0) +glow_color = Color(1, 0.15, 0.2, 1) +component_scenes = Array[PackedScene]([ExtResource("2")]) diff --git a/Enemy.gd b/Enemy.gd index 5f1ee45..5d5e796 100644 --- a/Enemy.gd +++ b/Enemy.gd @@ -2,50 +2,53 @@ extends Area2D signal destroyed -@export var speed: float = 140.0 -@export var acceleration: float = 260.0 -@export var rotation_speed: float = 6.0 -@export var max_hp: int = 3 +const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd") -var velocity: Vector2 = Vector2.ZERO +@export var enemy_id: String = "chaser" + +var definition: Resource var hp: int -@onready var fleet: Node2D = get_node("../Fleet") - +# Mirrors Ship.gd's own max_speed/acceleration: copied from the definition +# once, then mutable, so a test/preview can pin an enemy in place (e.g. +# `enemy.speed = 0.0`) without needing to know about definitions at all. +var speed: float +var acceleration: float +var rotation_speed: float +var max_hp: int func _ready() -> void: - hp = max_hp add_to_group("enemies") + definition = ENEMY_DEFINITIONS.get_definition(enemy_id) + speed = definition.speed + acceleration = definition.acceleration + rotation_speed = definition.rotation_speed + max_hp = definition.max_hp + hp = max_hp + for component_scene in definition.component_scenes: + var component: Node = component_scene.instantiate() + add_child(component) + if component.has_method("setup"): + component.setup() queue_redraw() +func get_component(capability: String) -> Node: + for child in get_children(): + if child.get("capability_id") == capability: + return child + return null + + +func has_capability(capability: String) -> bool: + return get_component(capability) != null + + func _physics_process(delta: float) -> void: - var target_pos: Vector2 = _nearest_ship_position() - var to_target: Vector2 = target_pos - position - - if to_target.length() > 1.0: - var desired_velocity: Vector2 = to_target.normalized() * speed - velocity = velocity.move_toward(desired_velocity, acceleration * delta) - position += velocity * delta - - if velocity.length() > 5.0: - var target_angle: float = velocity.angle() + PI / 2.0 - rotation = lerp_angle(rotation, target_angle, rotation_speed * delta) - - -func _nearest_ship_position() -> Vector2: - if fleet.ships.is_empty(): - return position - - var nearest: Node2D = fleet.ships[0] - var best_dist: float = position.distance_to(nearest.position) - for ship in fleet.ships: - var d: float = position.distance_to(ship.position) - if d < best_dist: - best_dist = d - nearest = ship - return nearest.position + for child in get_children(): + if child.has_method("tick"): + child.tick(delta) func take_damage(amount: int) -> void: @@ -57,8 +60,9 @@ func take_damage(amount: int) -> void: func _draw() -> void: - var points: PackedVector2Array = PackedVector2Array([ - Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0) + var points: PackedVector2Array = definition.hull_points if definition != null else PackedVector2Array([ + Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0), ]) + var glow: Color = definition.glow_color if definition != null else Color(1.0, 0.15, 0.2) # Constant glow - see Ship.gd. Nothing animates, so no per-frame redraw. - NeonDraw.draw_glowing_polygon(self, points, Color(1.0, 0.15, 0.2), 1.0) + NeonDraw.draw_glowing_polygon(self, points, glow, 1.0) diff --git a/EnemyDefinition.gd b/EnemyDefinition.gd new file mode 100644 index 0000000..bb0927b --- /dev/null +++ b/EnemyDefinition.gd @@ -0,0 +1,19 @@ +class_name EnemyDefinition +extends Resource + +# Data-driven enemy stats and appearance, mirroring VesselDefinition's role +# for player ships. Enemy.gd instantiates component_scenes exactly like +# Ship.gd does, so behaviour (pursuit, ranged fire, ...) lives in capability +# components rather than in a type switch here. + +@export var enemy_id: String = "chaser" +@export var display_name: String = "Chaser" +@export var max_hp: int = 3 +@export var speed: float = 140.0 +@export var acceleration: float = 260.0 +@export var rotation_speed: float = 6.0 +@export var hull_points: PackedVector2Array = PackedVector2Array([ + Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0), +]) +@export var glow_color: Color = Color(1.0, 0.15, 0.2) +@export var component_scenes: Array[PackedScene] = [] diff --git a/EnemyDefinition.gd.uid b/EnemyDefinition.gd.uid new file mode 100644 index 0000000..884a36d --- /dev/null +++ b/EnemyDefinition.gd.uid @@ -0,0 +1 @@ +uid://bkgt6o1m12l0g diff --git a/EnemyDefinitions.gd b/EnemyDefinitions.gd new file mode 100644 index 0000000..fa910b5 --- /dev/null +++ b/EnemyDefinitions.gd @@ -0,0 +1,19 @@ +class_name EnemyDefinitions +extends RefCounted + +const DEFAULT_ID: String = "chaser" +const ORDER: PackedStringArray = ["chaser"] +const DEFINITIONS: Dictionary = { + "chaser": preload("res://ChaserEnemyDefinition.tres"), +} + + +static func get_definition(enemy_id: String) -> Resource: + return DEFINITIONS.get(enemy_id, DEFINITIONS[DEFAULT_ID]) + + +static func all() -> Array: + var result: Array = [] + for enemy_id in ORDER: + result.append(DEFINITIONS[enemy_id]) + return result diff --git a/EnemyDefinitions.gd.uid b/EnemyDefinitions.gd.uid new file mode 100644 index 0000000..736702a --- /dev/null +++ b/EnemyDefinitions.gd.uid @@ -0,0 +1 @@ +uid://cfc4w3atpf8yj diff --git a/EnemyProjectile.gd b/EnemyProjectile.gd new file mode 100644 index 0000000..88d012d --- /dev/null +++ b/EnemyProjectile.gd @@ -0,0 +1,45 @@ +extends Area2D + +# Mirrors Projectile.gd, but travels the other direction: fired by an enemy +# component (see EnemyRangedWeaponComponent, #45) and damages player_ships. +# This is the half of ranged combat that never existed before #44 - enemies +# could previously only hurt the fleet by physical contact. + +@export var speed: float = 260.0 +@export var lifetime: float = 3.0 +@export var damage: int = 1 +@export var bolt_length: float = 20.0 +@export var color: Color = Color(1.0, 0.4, 0.35) + +var velocity: Vector2 = Vector2.ZERO +var age: float = 0.0 + + +func _ready() -> void: + add_to_group("enemy_projectiles") + area_entered.connect(_on_area_entered) + queue_redraw() + + +func launch(direction: Vector2) -> void: + velocity = direction.normalized() * speed + rotation = direction.angle() + PI / 2.0 + + +func _physics_process(delta: float) -> void: + position += velocity * delta + age += delta + if age >= lifetime: + queue_free() + + +func _on_area_entered(area: Area2D) -> void: + if area.is_in_group("player_ships"): + area.take_damage(damage) + queue_free() + + +func _draw() -> void: + var tip: Vector2 = Vector2(0, -bolt_length * 0.6) + var tail: Vector2 = Vector2(0, bolt_length * 0.4) + NeonDraw.draw_glowing_line(self, tail, tip, color, 1.0, 0.5) diff --git a/EnemyProjectile.gd.uid b/EnemyProjectile.gd.uid new file mode 100644 index 0000000..e5c290a --- /dev/null +++ b/EnemyProjectile.gd.uid @@ -0,0 +1 @@ +uid://b4hja3oj3rdrc diff --git a/EnemyProjectile.tscn b/EnemyProjectile.tscn new file mode 100644 index 0000000..798768c --- /dev/null +++ b/EnemyProjectile.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://EnemyProjectile.gd" id="1"] + +[sub_resource type="CircleShape2D" id="1"] +radius = 4.0 + +[node name="EnemyProjectile" type="Area2D"] +script = ExtResource("1") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +shape = SubResource("1") diff --git a/EnemyPursuitComponent.gd b/EnemyPursuitComponent.gd new file mode 100644 index 0000000..661f432 --- /dev/null +++ b/EnemyPursuitComponent.gd @@ -0,0 +1,38 @@ +class_name EnemyPursuitComponent +extends Node + +# Chases the nearest player ship. Moved out of Enemy.gd verbatim so the +# behaviour is opt-in per definition rather than hardcoded into every enemy. + +var capability_id: String = "pursuit" +var velocity: Vector2 = Vector2.ZERO + + +func tick(delta: float) -> void: + var enemy: Node2D = get_parent() + var target_pos: Vector2 = _nearest_ship_position(enemy) + var to_target: Vector2 = target_pos - enemy.position + + if to_target.length() > 1.0: + var desired_velocity: Vector2 = to_target.normalized() * enemy.speed + velocity = velocity.move_toward(desired_velocity, enemy.acceleration * delta) + enemy.position += velocity * delta + + if velocity.length() > 5.0: + var target_angle: float = velocity.angle() + PI / 2.0 + enemy.rotation = lerp_angle(enemy.rotation, target_angle, enemy.rotation_speed * delta) + + +func _nearest_ship_position(enemy: Node2D) -> Vector2: + var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") + if fleet == null or fleet.ships.is_empty(): + return enemy.position + + var nearest: Node2D = fleet.ships[0] + var best_dist: float = enemy.position.distance_to(nearest.position) + for ship in fleet.ships: + var d: float = enemy.position.distance_to(ship.position) + if d < best_dist: + best_dist = d + nearest = ship + return nearest.position diff --git a/EnemyPursuitComponent.gd.uid b/EnemyPursuitComponent.gd.uid new file mode 100644 index 0000000..30c1640 --- /dev/null +++ b/EnemyPursuitComponent.gd.uid @@ -0,0 +1 @@ +uid://n8wohkrnl42e diff --git a/EnemyPursuitComponent.tscn b/EnemyPursuitComponent.tscn new file mode 100644 index 0000000..8b68d67 --- /dev/null +++ b/EnemyPursuitComponent.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://EnemyPursuitComponent.gd" id="1"] + +[node name="EnemyPursuitComponent" type="Node"] +script = ExtResource("1") diff --git a/Fleet.gd b/Fleet.gd index 949c0ac..ba32666 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -32,6 +32,10 @@ var steer_direction: Vector2 = Vector2.ZERO func _ready() -> void: + # Lets anything resolve "the fleet" without a hardcoded ../Fleet sibling + # path - enemies parented under different hosts (e.g. a planet surface's + # SurfaceFeatures) need this to find their target. + add_to_group("player_fleet") # Researching a vessel upgrade improves the ships already flying, not # just the next one built (#5). GameState.vessel_upgraded.connect(_on_vessel_upgraded) diff --git a/Ship.gd b/Ship.gd index 1c2727c..cdaa542 100644 --- a/Ship.gd +++ b/Ship.gd @@ -115,17 +115,27 @@ func _physics_process(delta: float) -> void: rotation = lerp_angle(rotation, target_angle, rotation_speed * delta) -func _on_area_entered(area: Area2D) -> void: - if not (area.is_in_group("enemies") or area.is_in_group("asteroids")): - return +func take_damage(_amount: int) -> bool: + # Ships have no hit points - any unabsorbed hit is lethal. A shield + # charge absorbs a hit entirely regardless of its amount, matching + # VesselShieldComponent's existing all-or-nothing semantics; `_amount` + # is accepted (not `int`-clamped away) so callers like EnemyProjectile + # have a normal damage parameter to pass even though it isn't graded here. for child in get_children(): if child.has_method("absorb_hit") and child.absorb_hit(): - if area.is_in_group("asteroids") and is_instance_valid(area): - area.queue_free() - return + return true ShipExplosionVFX.spawn(get_tree().root, position) destroyed.emit() queue_free() + return false + + +func _on_area_entered(area: Area2D) -> void: + if not (area.is_in_group("enemies") or area.is_in_group("asteroids")): + return + var absorbed: bool = take_damage(1) + if absorbed and area.is_in_group("asteroids") and is_instance_valid(area): + area.queue_free() func _draw() -> void: diff --git a/tests/_gas_bomb_aim_preview.gd b/tests/_gas_bomb_aim_preview.gd index 8a38362..cb1922c 100644 --- a/tests/_gas_bomb_aim_preview.gd +++ b/tests/_gas_bomb_aim_preview.gd @@ -39,5 +39,5 @@ func _ready() -> void: for offset in [Vector2(-65, 0), Vector2(45, -45), Vector2(55, 65)]: var enemy: Node = load("res://Enemy.tscn").instantiate() enemy.position = skill.aim_target + offset - enemy.speed = 0.0 main.add_child(enemy) + enemy.speed = 0.0 # after add_child - _ready() sets it from the definition diff --git a/tests/test_enemy_composition.gd b/tests/test_enemy_composition.gd new file mode 100644 index 0000000..a7660d3 --- /dev/null +++ b/tests/test_enemy_composition.gd @@ -0,0 +1,78 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_enemy_composition.gd +# Verifies: +# 1. an Enemy resolves its stats/hull/color from EnemyDefinitions, not +# hardcoded fields +# 2. an enemy instantiated under a node that is NOT a sibling of Fleet +# still finds the fleet and pursues it (the old `../Fleet` path would +# break here - this is exactly the planet-surface hazard #44 exists +# to close before #45/#46 land) + +var main_scene: Node +var fleet: Node +var nested_host: Node2D +var enemy: Node +var elapsed: float = 0.0 +var stage: int = 0 + + +func _initialize() -> void: + var chaser_def: Resource = EnemyDefinitions.get_definition("chaser") + if chaser_def.enemy_id != "chaser" or chaser_def.max_hp != 3: + return _fail("EnemyDefinitions did not resolve the chaser definition") + + main_scene = load("res://Main.tscn").instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + main_scene.get_node("EnemySpawner").set_process(false) + + # Deliberately NOT a sibling of Fleet - two levels deep under an + # unrelated node, the way a planet surface's SurfaceFeatures parents + # things. + nested_host = Node2D.new() + nested_host.name = "NestedHost" + main_scene.add_child(nested_host) + var inner: Node2D = Node2D.new() + inner.name = "Inner" + nested_host.add_child(inner) + + enemy = load("res://Enemy.tscn").instantiate() + enemy.position = fleet.get_centroid() + Vector2(600, 0) + inner.add_child(enemy) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.1: + return false + if enemy.definition == null or enemy.definition.enemy_id != "chaser": + return _fail("enemy did not resolve its definition on _ready") + if enemy.hp != enemy.definition.max_hp: + return _fail("enemy hp did not come from the definition") + if not enemy.has_capability("pursuit"): + return _fail("chaser did not compose a pursuit component") + elapsed = 0.0 + stage = 1 + return false + 1: + # Give the pursuit component a few physics ticks to close distance. + if elapsed < 1.0: + return false + var distance: float = enemy.position.distance_to(fleet.get_centroid()) + print("distance_after_1s=%.1f (started at 600)" % distance) + if distance >= 550.0: + return _fail("enemy under a non-sibling host never approached the fleet") + print("TEST_ENEMY_COMPOSITION: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_ENEMY_COMPOSITION: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_enemy_composition.gd.uid b/tests/test_enemy_composition.gd.uid new file mode 100644 index 0000000..002eb3d --- /dev/null +++ b/tests/test_enemy_composition.gd.uid @@ -0,0 +1 @@ +uid://dltu3a0n318lo diff --git a/tests/test_enemy_projectile_damage.gd b/tests/test_enemy_projectile_damage.gd new file mode 100644 index 0000000..a72fb36 --- /dev/null +++ b/tests/test_enemy_projectile_damage.gd @@ -0,0 +1,91 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_enemy_projectile_damage.gd +# Verifies the ranged damage path #44 adds: an EnemyProjectile destroys an +# unshielded ship on contact, and is absorbed by a shielded ship, removing +# exactly one charge rather than destroying it. + +var game_state: Node +var scene: Node +var fleet: Node +var interceptor: Node +var shield_ship: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.stored_vessels.clear() + game_state.build_time_scale = 0.02 + game_state.reset_progression() + game_state.rocks = 2000 + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + + +func _fire_at(ship: Node) -> void: + var projectile: Area2D = load("res://EnemyProjectile.tscn").instantiate() + projectile.global_position = ship.global_position + projectile.launch(Vector2.ZERO) # already on top of the target + scene.add_child(projectile) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + fleet.spawn_ships(1, Vector2.ZERO) + interceptor = fleet.ships[0] + fleet.add_ship(GameState.VESSEL_SHIELD) + shield_ship = fleet.ships[1] + shield_ship.position = Vector2(2000.0, 0.0) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + if not shield_ship.has_shield(): + return _fail("a fresh shield vessel has no shield") + _fire_at(interceptor) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.4: + return false + if is_instance_valid(interceptor): + return _fail("an unshielded ship survived an enemy projectile") + print("unshielded interceptor destroyed by an enemy projectile") + + _fire_at(shield_ship) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.4: + return false + if not is_instance_valid(shield_ship): + return _fail("a shielded ship was destroyed by an enemy projectile") + if shield_ship.shield_charges != 0: + return _fail("shield charges did not drop to 0 after absorbing one hit (charges=%d)" % + shield_ship.shield_charges) + print("shielded ship absorbed the hit and lost exactly one charge") + print("TEST_ENEMY_PROJECTILE_DAMAGE: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_ENEMY_PROJECTILE_DAMAGE: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_enemy_projectile_damage.gd.uid b/tests/test_enemy_projectile_damage.gd.uid new file mode 100644 index 0000000..592713a --- /dev/null +++ b/tests/test_enemy_projectile_damage.gd.uid @@ -0,0 +1 @@ +uid://cx7f3e82jkumb diff --git a/tests/test_gas_bomber.gd b/tests/test_gas_bomber.gd index 8bd1210..cda2a2f 100644 --- a/tests/test_gas_bomber.gd +++ b/tests/test_gas_bomber.gd @@ -107,16 +107,19 @@ func _process(delta: float) -> bool: if main.get_node("Camera2D").zoom.x >= original_zoom: return _fail_now("long aim did not zoom out") + # speed is set AFTER add_child(): _ready() copies it from the + # enemy's definition, which would otherwise clobber a + # pre-add_child override the instant the enemy entered the tree. for offset in [Vector2(-60, 0), Vector2(70, 35)]: var enemy: Node = load("res://Enemy.tscn").instantiate() enemy.position = skill.aim_target + offset - enemy.speed = 0.0 main.add_child(enemy) + enemy.speed = 0.0 inside_enemies.append(enemy) outside_enemy = load("res://Enemy.tscn").instantiate() outside_enemy.position = skill.aim_target + Vector2(GasBombController.AOE_RADIUS + 80.0, 0) - outside_enemy.speed = 0.0 main.add_child(outside_enemy) + outside_enemy.speed = 0.0 var release := InputEventScreenTouch.new() release.index = 7