Gate enemies behind a detection range instead of instant omniscient pursuit (#69)
Chasers (EnemyPursuitComponent) and snipers (EnemyStandoffComponent) used to beeline/close in from anywhere on the map the instant they spawned - no matter how far the fleet was, which read as omniscient rather than alert. Both now sit idle until the fleet is within their own detection_range. Artillery's slow drift is gated the same way, but reuses its own existing fire_range rather than a new tunable - outside its firing envelope it has nothing to be drifting toward yet. The enemy mothership deliberately keeps the opposite behaviour: it is meant to read as a persistent, unavoidable stalking threat, not a local sentry, so it gets its own EnemyPursuitComponentMothership.tscn variant with an effectively unlimited detection_range - the same scene-variant pattern EnemySpawnerComponentChaser/Artillery already use for per-definition property overrides on a shared component script.
This commit is contained in:
parent
8d089d6f76
commit
1de66c6fb2
6 changed files with 161 additions and 7 deletions
|
|
@ -56,6 +56,11 @@ func _drift(enemy: Node2D, delta: float) -> void:
|
||||||
if fleet == null or fleet.ships.is_empty():
|
if fleet == null or fleet.ships.is_empty():
|
||||||
return
|
return
|
||||||
var to_fleet: Vector2 = fleet.get_centroid() - enemy.position
|
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:
|
if to_fleet.length() < 1.0:
|
||||||
return
|
return
|
||||||
enemy.position += to_fleet.normalized() * drift_speed * delta
|
enemy.position += to_fleet.normalized() * drift_speed * delta
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,20 @@ extends Node
|
||||||
var capability_id: String = "pursuit"
|
var capability_id: String = "pursuit"
|
||||||
var velocity: Vector2 = Vector2.ZERO
|
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:
|
func tick(delta: float) -> void:
|
||||||
var enemy: Node2D = get_parent()
|
var enemy: Node2D = get_parent()
|
||||||
var target_pos: Vector2 = _nearest_ship_position(enemy)
|
var target: Node2D = _nearest_ship(enemy)
|
||||||
var to_target: Vector2 = target_pos - enemy.position
|
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:
|
if to_target.length() > 1.0:
|
||||||
var desired_velocity: Vector2 = to_target.normalized() * enemy.speed
|
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)
|
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")
|
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||||
if fleet == null or fleet.ships.is_empty():
|
if fleet == null or fleet.ships.is_empty():
|
||||||
return enemy.position
|
return null
|
||||||
|
|
||||||
var nearest: Node2D = fleet.ships[0]
|
var nearest: Node2D = fleet.ships[0]
|
||||||
var best_dist: float = enemy.position.distance_to(nearest.position)
|
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:
|
if d < best_dist:
|
||||||
best_dist = d
|
best_dist = d
|
||||||
nearest = ship
|
nearest = ship
|
||||||
return nearest.position
|
return nearest
|
||||||
|
|
|
||||||
7
EnemyPursuitComponentMothership.tscn
Normal file
7
EnemyPursuitComponentMothership.tscn
Normal file
|
|
@ -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
|
||||||
|
|
@ -11,6 +11,10 @@ var capability_id: String = "standoff"
|
||||||
|
|
||||||
@export var preferred_range: float = 620.0
|
@export var preferred_range: float = 620.0
|
||||||
@export var retreat_range_factor: float = 0.75
|
@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
|
var velocity: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
|
@ -22,7 +26,7 @@ func tick(delta: float) -> void:
|
||||||
return
|
return
|
||||||
var to_target: Vector2 = target.position - enemy.position
|
var to_target: Vector2 = target.position - enemy.position
|
||||||
var distance: float = to_target.length()
|
var distance: float = to_target.length()
|
||||||
if distance < 1.0:
|
if distance < 1.0 or distance > detection_range:
|
||||||
return
|
return
|
||||||
var direction: Vector2 = to_target / distance
|
var direction: Vector2 = to_target / distance
|
||||||
var retreat_range: float = preferred_range * retreat_range_factor
|
var retreat_range: float = preferred_range * retreat_range_factor
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=6 format=3]
|
[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="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://EnemySpawnerComponentChaser.tscn" id="3"]
|
||||||
[ext_resource type="PackedScene" path="res://EnemySpawnerComponentArtillery.tscn" id="4"]
|
[ext_resource type="PackedScene" path="res://EnemySpawnerComponentArtillery.tscn" id="4"]
|
||||||
[ext_resource type="PackedScene" path="res://EnemyBigLaserComponent.tscn" id="5"]
|
[ext_resource type="PackedScene" path="res://EnemyBigLaserComponent.tscn" id="5"]
|
||||||
|
|
|
||||||
129
tests/test_enemy_detection_range.gd
Normal file
129
tests/test_enemy_detection_range.gd
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue