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