diff --git a/EnemyArtilleryComponent.gd b/EnemyArtilleryComponent.gd index 43250c3..9832905 100644 --- a/EnemyArtilleryComponent.gd +++ b/EnemyArtilleryComponent.gd @@ -56,6 +56,11 @@ func _drift(enemy: Node2D, delta: float) -> void: if fleet == null or fleet.ships.is_empty(): return var to_fleet: Vector2 = fleet.get_centroid() - enemy.position + # #69: gated by the same fire_range it already shells within, rather + # than a second tunable that could drift out of sync - outside its own + # firing envelope it has nothing to be drifting toward yet. + if to_fleet.length() > fire_range: + return if to_fleet.length() < 1.0: return enemy.position += to_fleet.normalized() * drift_speed * delta diff --git a/EnemyPursuitComponent.gd b/EnemyPursuitComponent.gd index 661f432..6f7ae45 100644 --- a/EnemyPursuitComponent.gd +++ b/EnemyPursuitComponent.gd @@ -7,11 +7,20 @@ extends Node var capability_id: String = "pursuit" var velocity: Vector2 = Vector2.ZERO +# #69: used to beeline from anywhere on the map the instant it spawned, +# which read as omniscient rather than alert. Idle until the fleet actually +# comes within this radius. A persistent stalking threat wants the opposite +# of this - see EnemyPursuitComponentMothership.tscn, which overrides it to +# an effectively unlimited value for the mothership's own instance. +@export var detection_range: float = 850.0 + 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 + var target: Node2D = _nearest_ship(enemy) + if target == null or enemy.position.distance_to(target.position) > detection_range: + return + var to_target: Vector2 = target.position - enemy.position if to_target.length() > 1.0: var desired_velocity: Vector2 = to_target.normalized() * enemy.speed @@ -23,10 +32,10 @@ func tick(delta: float) -> void: enemy.rotation = lerp_angle(enemy.rotation, target_angle, enemy.rotation_speed * delta) -func _nearest_ship_position(enemy: Node2D) -> Vector2: +func _nearest_ship(enemy: Node2D) -> Node2D: var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") if fleet == null or fleet.ships.is_empty(): - return enemy.position + return null var nearest: Node2D = fleet.ships[0] var best_dist: float = enemy.position.distance_to(nearest.position) @@ -35,4 +44,4 @@ func _nearest_ship_position(enemy: Node2D) -> Vector2: if d < best_dist: best_dist = d nearest = ship - return nearest.position + return nearest diff --git a/EnemyPursuitComponentMothership.tscn b/EnemyPursuitComponentMothership.tscn new file mode 100644 index 0000000..108610d --- /dev/null +++ b/EnemyPursuitComponentMothership.tscn @@ -0,0 +1,7 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://EnemyPursuitComponent.gd" id="1"] + +[node name="EnemyPursuitComponentMothership" type="Node"] +script = ExtResource("1") +detection_range = 1000000.0 diff --git a/EnemyStandoffComponent.gd b/EnemyStandoffComponent.gd index 0111738..02e96a6 100644 --- a/EnemyStandoffComponent.gd +++ b/EnemyStandoffComponent.gd @@ -11,6 +11,10 @@ var capability_id: String = "standoff" @export var preferred_range: float = 620.0 @export var retreat_range_factor: float = 0.75 +# #69: kept comfortably beyond preferred_range so it still has room to close +# in before settling at stand-off distance, but the fleet has to actually be +# somewhere on the board first - see EnemyPursuitComponent's identical fix. +@export var detection_range: float = 1200.0 var velocity: Vector2 = Vector2.ZERO @@ -22,7 +26,7 @@ func tick(delta: float) -> void: return var to_target: Vector2 = target.position - enemy.position var distance: float = to_target.length() - if distance < 1.0: + if distance < 1.0 or distance > detection_range: return var direction: Vector2 = to_target / distance var retreat_range: float = preferred_range * retreat_range_factor diff --git a/MothershipEnemyDefinition.tres b/MothershipEnemyDefinition.tres index cd03144..f031799 100644 --- a/MothershipEnemyDefinition.tres +++ b/MothershipEnemyDefinition.tres @@ -1,7 +1,7 @@ [gd_resource type="Resource" script_class="EnemyDefinition" load_steps=6 format=3] [ext_resource type="Script" path="res://EnemyDefinition.gd" id="1"] -[ext_resource type="PackedScene" path="res://EnemyPursuitComponent.tscn" id="2"] +[ext_resource type="PackedScene" path="res://EnemyPursuitComponentMothership.tscn" id="2"] [ext_resource type="PackedScene" path="res://EnemySpawnerComponentChaser.tscn" id="3"] [ext_resource type="PackedScene" path="res://EnemySpawnerComponentArtillery.tscn" id="4"] [ext_resource type="PackedScene" path="res://EnemyBigLaserComponent.tscn" id="5"] diff --git a/tests/test_enemy_detection_range.gd b/tests/test_enemy_detection_range.gd new file mode 100644 index 0000000..63f7e6e --- /dev/null +++ b/tests/test_enemy_detection_range.gd @@ -0,0 +1,129 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_enemy_detection_range.gd +# Forgejo #69: enemies used to beeline toward the fleet from anywhere on the +# map the instant they spawned. Chasers (EnemyPursuitComponent) and snipers +# (EnemyStandoffComponent) now sit idle until the fleet is within their own +# detection_range; the enemy mothership deliberately keeps an (effectively) +# unlimited range instead, since it is meant to read as a persistent, +# unavoidable stalking threat rather than a local sentry. +# +# Covers: +# A. a chaser beyond detection_range does not move at all +# B. the same chaser, once the fleet is within range, closes in +# C. a sniper beyond detection_range does not move either +# D. the enemy mothership closes in even from far beyond a chaser's range + +var game_state: Node +var main: Node +var fleet: Node +var chaser: Node +var sniper: Node +var mothership: Node +var chaser_start: Vector2 +var sniper_start: Vector2 +var mothership_start: Vector2 +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.reset_progression() + main = load("res://Main.tscn").instantiate() + root.add_child(main) + fleet = main.get_node("Fleet") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.2: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + for a in get_nodes_in_group("asteroids"): + a.queue_free() + fleet.spawn_ships(1, Vector2.ZERO) + + # --- A. beyond detection_range: no movement at all --------------- + chaser = load("res://Enemy.tscn").instantiate() + chaser.enemy_id = "chaser" + var pursuit: Node = null + main.add_child(chaser) + pursuit = chaser.get_component("pursuit") + chaser.position = fleet.get_centroid() + Vector2(pursuit.detection_range + 300.0, 0.0) + chaser_start = chaser.position + + sniper = load("res://Enemy.tscn").instantiate() + sniper.enemy_id = "sniper" + main.add_child(sniper) + var standoff: Node = sniper.get_component("standoff") + sniper.position = fleet.get_centroid() + Vector2(0.0, standoff.detection_range + 300.0) + sniper_start = sniper.position + + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 1.0: + return false + var chaser_moved: float = chaser.position.distance_to(chaser_start) + var sniper_moved: float = sniper.position.distance_to(sniper_start) + print("beyond range for 1.0s: chaser moved %.1f, sniper moved %.1f" % [chaser_moved, sniper_moved]) + if chaser_moved > 1.0: + return _fail("a chaser beyond detection_range moved %.1f - it should sit idle" % chaser_moved) + if sniper_moved > 1.0: + return _fail("a sniper beyond detection_range moved %.1f - it should sit idle" % sniper_moved) + + # --- B. bring the chaser within range, it should now close in ---- + var pursuit: Node = chaser.get_component("pursuit") + chaser.position = fleet.get_centroid() + Vector2(pursuit.detection_range * 0.5, 0.0) + chaser_start = chaser.position + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 1.0: + return false + var chaser_moved: float = chaser.position.distance_to(chaser_start) + print("within range for 1.0s: chaser moved %.1f" % chaser_moved) + if chaser_moved < 20.0: + return _fail("a chaser within detection_range did not close in (moved %.1f)" % chaser_moved) + print("chaser correctly idle beyond range, closes in once inside it") + + # --- D. the mothership closes in from far beyond a chaser's range - + mothership = load("res://Enemy.tscn").instantiate() + mothership.enemy_id = "enemy_mothership" + main.add_child(mothership) + var mother_pursuit: Node = mothership.get_component("pursuit") + if mother_pursuit.detection_range < 50000.0: + return _fail("the mothership's pursuit range is not effectively unlimited (%f)" \ + % mother_pursuit.detection_range) + mothership.position = fleet.get_centroid() + Vector2(5000.0, 0.0) + mothership_start = mothership.position + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 1.0: + return false + var mothership_moved: float = mothership.position.distance_to(mothership_start) + print("mothership 5000 units out, 1.0s: moved %.1f" % mothership_moved) + if mothership_moved < 5.0: + return _fail("the mothership did not close in from far away (moved %.1f)" % mothership_moved) + print("TEST_ENEMY_DETECTION_RANGE: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_ENEMY_DETECTION_RANGE: FAIL (%s)" % message) + quit(1) + return true