Add standoff sniper enemy (#45)
EnemyStandoffComponent holds a preferred distance from the nearest player ship instead of ramming (advance beyond it, retreat inside retreat_range_factor of it, drift laterally in the comfortable band between) - never paired with a pursuit component. EnemyRangedWeaponComponent mirrors VesselWeaponComponent for an enemy: fires an EnemyProjectile aimed at the target's CURRENT position rather than led, so a moving ship can dodge it (projectile_speed 260 vs the interceptor's 460 max_speed). Registered in EnemyDefinitions so it's placeable from a planet setup by enemy_id. test_sniper_enemy.gd went through a few iterations to get its own timing right: physics ticks can run ahead of a SceneTree test's own _process() clock under this device's load, so the cooldown-violation check reads the component's own fire_timer directly (a rising value means it refired) instead of comparing projectile counts against a wall-clock wait, and manually repositioned ships need their move_target re-pinned or Ship's own steering silently drives them back to the stale target. Part of the #40 conquest-mechanic breakdown.
This commit is contained in:
parent
c4e2ee7178
commit
fc2b5ca74a
13 changed files with 455 additions and 1 deletions
|
|
@ -2,9 +2,10 @@ class_name EnemyDefinitions
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
|
|
||||||
const DEFAULT_ID: String = "chaser"
|
const DEFAULT_ID: String = "chaser"
|
||||||
const ORDER: PackedStringArray = ["chaser"]
|
const ORDER: PackedStringArray = ["chaser", "sniper"]
|
||||||
const DEFINITIONS: Dictionary = {
|
const DEFINITIONS: Dictionary = {
|
||||||
"chaser": preload("res://ChaserEnemyDefinition.tres"),
|
"chaser": preload("res://ChaserEnemyDefinition.tres"),
|
||||||
|
"sniper": preload("res://SniperEnemyDefinition.tres"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
54
EnemyRangedWeaponComponent.gd
Normal file
54
EnemyRangedWeaponComponent.gd
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
class_name EnemyRangedWeaponComponent
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
# Same shape as VesselWeaponComponent, mirrored for an enemy: fires an
|
||||||
|
# EnemyProjectile at the nearest player ship's CURRENT position, not led,
|
||||||
|
# so a moving ship can dodge it.
|
||||||
|
|
||||||
|
var capability_id: String = "ranged_weapon"
|
||||||
|
|
||||||
|
@export var fire_range: float = 700.0
|
||||||
|
@export var fire_cooldown: float = 2.2
|
||||||
|
@export var projectile_speed: float = 260.0
|
||||||
|
@export var damage: int = 1
|
||||||
|
|
||||||
|
var fire_timer: float = 0.0
|
||||||
|
var projectile_scene: PackedScene = preload("res://EnemyProjectile.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
func tick(delta: float) -> void:
|
||||||
|
fire_timer -= delta
|
||||||
|
if fire_timer > 0.0:
|
||||||
|
return
|
||||||
|
var enemy: Node2D = get_parent()
|
||||||
|
var target: Node2D = _nearest_ship_in_range(enemy)
|
||||||
|
if target == null:
|
||||||
|
fire_timer = 0.25
|
||||||
|
return
|
||||||
|
var projectile: Area2D = projectile_scene.instantiate()
|
||||||
|
# global_position: the projectile is reparented to root, a different
|
||||||
|
# node than the enemy's own (possibly nested) parent, so local
|
||||||
|
# position would be wrong the moment an enemy isn't a direct root
|
||||||
|
# child - exactly the case #44 introduced (planet-surface hosting).
|
||||||
|
projectile.position = enemy.global_position
|
||||||
|
projectile.damage = damage
|
||||||
|
projectile.speed = projectile_speed
|
||||||
|
enemy.get_tree().root.add_child(projectile)
|
||||||
|
projectile.launch(target.global_position - enemy.global_position)
|
||||||
|
fire_timer = fire_cooldown
|
||||||
|
|
||||||
|
|
||||||
|
func _nearest_ship_in_range(enemy: Node2D) -> Node2D:
|
||||||
|
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||||
|
if fleet == null:
|
||||||
|
return null
|
||||||
|
var nearest: Node2D = null
|
||||||
|
var best_distance: float = fire_range
|
||||||
|
for ship in fleet.ships:
|
||||||
|
if not is_instance_valid(ship):
|
||||||
|
continue
|
||||||
|
var distance: float = enemy.global_position.distance_to(ship.global_position)
|
||||||
|
if distance <= best_distance:
|
||||||
|
best_distance = distance
|
||||||
|
nearest = ship
|
||||||
|
return nearest
|
||||||
1
EnemyRangedWeaponComponent.gd.uid
Normal file
1
EnemyRangedWeaponComponent.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dx1lmxw6ia66a
|
||||||
6
EnemyRangedWeaponComponent.tscn
Normal file
6
EnemyRangedWeaponComponent.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://EnemyRangedWeaponComponent.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="EnemyRangedWeaponComponent" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
58
EnemyStandoffComponent.gd
Normal file
58
EnemyStandoffComponent.gd
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
class_name EnemyStandoffComponent
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
# Holds a preferred distance from the nearest player ship instead of
|
||||||
|
# ramming: advances when too far, retreats when too close, drifts
|
||||||
|
# laterally in the comfortable band between so it still reads as alive
|
||||||
|
# rather than frozen. Never pair with a pursuit component on the same
|
||||||
|
# enemy - the two would fight over velocity every tick.
|
||||||
|
|
||||||
|
var capability_id: String = "standoff"
|
||||||
|
|
||||||
|
@export var preferred_range: float = 620.0
|
||||||
|
@export var retreat_range_factor: float = 0.75
|
||||||
|
|
||||||
|
var velocity: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func tick(delta: float) -> void:
|
||||||
|
var enemy: Node2D = get_parent()
|
||||||
|
var target: Node2D = _nearest_ship(enemy)
|
||||||
|
if target == null:
|
||||||
|
return
|
||||||
|
var to_target: Vector2 = target.position - enemy.position
|
||||||
|
var distance: float = to_target.length()
|
||||||
|
if distance < 1.0:
|
||||||
|
return
|
||||||
|
var direction: Vector2 = to_target / distance
|
||||||
|
var retreat_range: float = preferred_range * retreat_range_factor
|
||||||
|
|
||||||
|
var desired_velocity: Vector2
|
||||||
|
if distance > preferred_range:
|
||||||
|
desired_velocity = direction * enemy.speed
|
||||||
|
elif distance < retreat_range:
|
||||||
|
desired_velocity = -direction * enemy.speed
|
||||||
|
else:
|
||||||
|
var lateral: Vector2 = Vector2(-direction.y, direction.x)
|
||||||
|
desired_velocity = lateral * enemy.speed * 0.5
|
||||||
|
|
||||||
|
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(enemy: Node2D) -> Node2D:
|
||||||
|
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||||
|
if fleet == null or fleet.ships.is_empty():
|
||||||
|
return null
|
||||||
|
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
|
||||||
1
EnemyStandoffComponent.gd.uid
Normal file
1
EnemyStandoffComponent.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://c7axi54f6p7gk
|
||||||
6
EnemyStandoffComponent.tscn
Normal file
6
EnemyStandoffComponent.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://EnemyStandoffComponent.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="EnemyStandoffComponent" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
17
SniperEnemyDefinition.tres
Normal file
17
SniperEnemyDefinition.tres
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=4 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://EnemyDefinition.gd" id="1"]
|
||||||
|
[ext_resource type="PackedScene" path="res://EnemyStandoffComponent.tscn" id="2"]
|
||||||
|
[ext_resource type="PackedScene" path="res://EnemyRangedWeaponComponent.tscn" id="3"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
enemy_id = "sniper"
|
||||||
|
display_name = "Sniper"
|
||||||
|
max_hp = 4
|
||||||
|
speed = 90.0
|
||||||
|
acceleration = 180.0
|
||||||
|
rotation_speed = 5.0
|
||||||
|
hull_points = PackedVector2Array(0, -28, 7, -6, 7, 22, -7, 22, -7, -6)
|
||||||
|
glow_color = Color(0.42, 0.72, 1.0, 1)
|
||||||
|
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")])
|
||||||
76
tests/_sniper_vs_chaser_preview.gd
Normal file
76
tests/_sniper_vs_chaser_preview.gd
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
# Setup is deferred to the first _process() tick rather than done in
|
||||||
|
# _ready(): PlanetAtmosphereController auto-spawns a 5-ship default fleet
|
||||||
|
# in ITS _ready() on any fresh (non-returning) Main.tscn load, and since
|
||||||
|
# this scene is itself added to the tree from inside _screenshot_tool.gd's
|
||||||
|
# own _initialize() (a nested, not-yet-settled context), there's no
|
||||||
|
# guaranteed ordering between the two _ready() calls - clearing the fleet
|
||||||
|
# here could run BEFORE that auto-spawn and then get silently overwritten
|
||||||
|
# by it. Waiting one frame guarantees it has already run first.
|
||||||
|
#
|
||||||
|
# Even after clearing, physics ticks can run several times before this
|
||||||
|
# script's own next _process() tick (softpipe rendering is slow, and
|
||||||
|
# physics is decoupled from it), so an auto-spawned ship can still get one
|
||||||
|
# shot off in flight toward these enemies before the clearing code above
|
||||||
|
# ever runs. Pausing the whole tree once everything is placed (last thing
|
||||||
|
# this script does) freezes any such shot in transit for good, rather than
|
||||||
|
# letting it arrive a few physics ticks later while the screenshot is
|
||||||
|
# still waiting out its frame count.
|
||||||
|
|
||||||
|
var main: Node
|
||||||
|
var setup_done: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
GameState.buildings.clear()
|
||||||
|
GameState.extractors.clear()
|
||||||
|
GameState.transporters.clear()
|
||||||
|
main = load("res://Main.tscn").instantiate()
|
||||||
|
add_child(main)
|
||||||
|
main.get_node("BackgroundLayer").visible = false
|
||||||
|
main.get_node("AsteroidField").visible = false
|
||||||
|
main.get_node("PlanetField").visible = false
|
||||||
|
main.get_node("BuildingField").visible = false
|
||||||
|
main.get_node("EnemySpawner").set_process(false)
|
||||||
|
main.get_node("AsteroidField").set_process(false)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if setup_done:
|
||||||
|
return
|
||||||
|
setup_done = true
|
||||||
|
|
||||||
|
var fleet: Node = main.get_node("Fleet")
|
||||||
|
for ship in fleet.ships:
|
||||||
|
if is_instance_valid(ship):
|
||||||
|
ship.set_physics_process(false)
|
||||||
|
fleet.spawn_ships(0, Vector2.ZERO)
|
||||||
|
# spawn_ships(0, ...) still isn't a truly empty fleet: it falls back to
|
||||||
|
# one default interceptor whenever the requested composition would
|
||||||
|
# otherwise be empty (Fleet.gd's own "the fleet is never really empty"
|
||||||
|
# rule). Clear that forced ship too, and halt it the same way.
|
||||||
|
for ship in fleet.ships:
|
||||||
|
if is_instance_valid(ship):
|
||||||
|
ship.set_physics_process(false)
|
||||||
|
ship.queue_free()
|
||||||
|
fleet.ships.clear()
|
||||||
|
for projectile in get_tree().get_nodes_in_group("player_projectiles"):
|
||||||
|
projectile.queue_free()
|
||||||
|
|
||||||
|
var chaser: Node = load("res://Enemy.tscn").instantiate()
|
||||||
|
chaser.enemy_id = "chaser"
|
||||||
|
chaser.position = Vector2(-160, -80)
|
||||||
|
main.add_child(chaser)
|
||||||
|
chaser.speed = 0.0
|
||||||
|
|
||||||
|
var sniper: Node = load("res://Enemy.tscn").instantiate()
|
||||||
|
sniper.enemy_id = "sniper"
|
||||||
|
sniper.position = Vector2(160, -80)
|
||||||
|
main.add_child(sniper)
|
||||||
|
sniper.speed = 0.0
|
||||||
|
|
||||||
|
var camera: Camera2D = main.get_node("Camera2D")
|
||||||
|
camera.zoom = Vector2(1.4, 1.4)
|
||||||
|
|
||||||
|
get_tree().paused = true
|
||||||
1
tests/_sniper_vs_chaser_preview.gd.uid
Normal file
1
tests/_sniper_vs_chaser_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://d2fj1p61hp2pk
|
||||||
6
tests/_sniper_vs_chaser_preview.tscn
Normal file
6
tests/_sniper_vs_chaser_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://tests/_sniper_vs_chaser_preview.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="SniperVsChaserPreview" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
226
tests/test_sniper_enemy.gd
Normal file
226
tests/test_sniper_enemy.gd
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless gameplay test: run with
|
||||||
|
# godot --headless -s res://tests/test_sniper_enemy.gd
|
||||||
|
# Verifies:
|
||||||
|
# 1. against a stationary fleet, a sniper closes to roughly its standoff
|
||||||
|
# component's preferred_range and never reaches contact range
|
||||||
|
# 2. its ranged weapon only fires within fire_range, and respects cooldown
|
||||||
|
# 3. the projectile is slow enough that a ship moving away right after the
|
||||||
|
# shot clears the impact point unharmed
|
||||||
|
# 4. a hit (ship held in place) destroys an unshielded ship and is
|
||||||
|
# absorbed by a shielded one, losing exactly one charge
|
||||||
|
#
|
||||||
|
# Movement (1) and combat (2-4) use SEPARATE sniper instances: a stationary
|
||||||
|
# ship left in weapon range for many seconds would otherwise take stray
|
||||||
|
# hits during the movement check and die before the later stages run, which
|
||||||
|
# is a test-harness artifact, not anything about the sniper's own design.
|
||||||
|
|
||||||
|
var game_state: Node
|
||||||
|
var main: Node
|
||||||
|
var fleet: Node
|
||||||
|
var mover: Node # movement-only: its ranged_weapon is removed
|
||||||
|
var sniper: Node # combat-only: pinned in place via speed = 0
|
||||||
|
var target_ship: Node
|
||||||
|
var interceptor: Node
|
||||||
|
var shield_ship: Node
|
||||||
|
var min_distance: float = INF
|
||||||
|
var cooldown_watermark: float = 0.0
|
||||||
|
var projectiles_before: int = 0
|
||||||
|
var stage: int = 0
|
||||||
|
var elapsed: float = 0.0
|
||||||
|
var settle_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
|
||||||
|
main = load("res://Main.tscn").instantiate()
|
||||||
|
root.add_child(main)
|
||||||
|
fleet = main.get_node("Fleet")
|
||||||
|
|
||||||
|
|
||||||
|
func _projectile_count() -> int:
|
||||||
|
return get_nodes_in_group("enemy_projectiles").size()
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> bool:
|
||||||
|
elapsed += delta
|
||||||
|
match stage:
|
||||||
|
0:
|
||||||
|
if elapsed < 0.1:
|
||||||
|
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)
|
||||||
|
target_ship = fleet.ships[0]
|
||||||
|
|
||||||
|
mover = load("res://Enemy.tscn").instantiate()
|
||||||
|
mover.enemy_id = "sniper"
|
||||||
|
mover.position = target_ship.position + Vector2(1000, 0)
|
||||||
|
main.add_child(mover)
|
||||||
|
mover.get_component("ranged_weapon").queue_free()
|
||||||
|
|
||||||
|
elapsed = 0.0
|
||||||
|
settle_elapsed = 0.0
|
||||||
|
stage = 1
|
||||||
|
return false
|
||||||
|
1:
|
||||||
|
# --- standoff: close in, never reach contact, settle near
|
||||||
|
# preferred_range -----------------------------------------
|
||||||
|
settle_elapsed += delta
|
||||||
|
min_distance = minf(min_distance, mover.position.distance_to(target_ship.position))
|
||||||
|
if min_distance < 100.0:
|
||||||
|
return _fail("sniper reached near-contact range (%.1f) against a stationary fleet" % min_distance)
|
||||||
|
if settle_elapsed < 8.0:
|
||||||
|
return false
|
||||||
|
var standoff: Node = mover.get_component("standoff")
|
||||||
|
var final_distance: float = mover.position.distance_to(target_ship.position)
|
||||||
|
var preferred: float = standoff.preferred_range
|
||||||
|
print("min_distance=%.1f final_distance=%.1f preferred=%.1f" % [min_distance, final_distance, preferred])
|
||||||
|
if final_distance < preferred * 0.6 or final_distance > preferred * 1.4:
|
||||||
|
return _fail("sniper did not settle near its preferred_range (final=%.1f preferred=%.1f)" % [
|
||||||
|
final_distance, preferred
|
||||||
|
])
|
||||||
|
mover.queue_free()
|
||||||
|
|
||||||
|
# --- fire range gating: a fresh, pinned sniper out of range ---
|
||||||
|
fleet.spawn_ships(1, target_ship.position + Vector2(-2000, 0))
|
||||||
|
interceptor = fleet.ships[0]
|
||||||
|
sniper = load("res://Enemy.tscn").instantiate()
|
||||||
|
sniper.enemy_id = "sniper"
|
||||||
|
main.add_child(sniper)
|
||||||
|
sniper.speed = 0.0
|
||||||
|
sniper.position = interceptor.position + Vector2(2000, 0) # beyond fire_range
|
||||||
|
projectiles_before = _projectile_count()
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 2
|
||||||
|
return false
|
||||||
|
2:
|
||||||
|
var ranged: Node = sniper.get_component("ranged_weapon")
|
||||||
|
if elapsed < ranged.fire_cooldown + 0.5:
|
||||||
|
return false
|
||||||
|
if _projectile_count() != projectiles_before:
|
||||||
|
return _fail("sniper fired while beyond fire_range")
|
||||||
|
|
||||||
|
# --- move into range: it should fire ---
|
||||||
|
sniper.position = interceptor.position + Vector2(400, 0)
|
||||||
|
projectiles_before = _projectile_count()
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 3
|
||||||
|
return false
|
||||||
|
3:
|
||||||
|
if elapsed < 0.4:
|
||||||
|
return false
|
||||||
|
if _projectile_count() <= projectiles_before:
|
||||||
|
return _fail("sniper did not fire once in range")
|
||||||
|
|
||||||
|
# The already-fired shot is still travelling toward interceptor's
|
||||||
|
# stationary position and would otherwise connect and vanish -
|
||||||
|
# sidestep it (staying within fire_range, so the next stage's
|
||||||
|
# wait for a fresh shot still has a target). Also re-pin
|
||||||
|
# move_target to match - otherwise Ship's own steering
|
||||||
|
# immediately drives it back toward the stale target.
|
||||||
|
interceptor.position += Vector2(0, 150)
|
||||||
|
interceptor.move_target = interceptor.position
|
||||||
|
|
||||||
|
# --- cooldown: fire_timer must not jump back up before it's
|
||||||
|
# counted all the way down, which is what a second shot would
|
||||||
|
# do. Read from the component's own state rather than inferring
|
||||||
|
# it from projectile counts against a wall-clock wait: physics
|
||||||
|
# ticks can run in catch-up bursts relative to this SceneTree's
|
||||||
|
# own _process() under device load, so an EnemyProjectile's age
|
||||||
|
# and this script's `elapsed` are not guaranteed to stay in
|
||||||
|
# lockstep even with --fixed-fps.
|
||||||
|
cooldown_watermark = sniper.get_component("ranged_weapon").fire_timer
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 4
|
||||||
|
return false
|
||||||
|
4:
|
||||||
|
# fire_timer can never be caught externally at exactly <= 0: the
|
||||||
|
# component decrements-checks-refires atomically in one tick, so
|
||||||
|
# by the time this poll runs it has either not reached zero yet
|
||||||
|
# or has already fired and jumped back up to fire_cooldown. A
|
||||||
|
# RISE from the last observed value is therefore the reliable
|
||||||
|
# externally-visible signal for "it just fired again" - within
|
||||||
|
# the first 0.6s that is a bug (still cooling down from the
|
||||||
|
# first shot), afterward it is the legitimate next shot to dodge.
|
||||||
|
var current: float = sniper.get_component("ranged_weapon").fire_timer
|
||||||
|
if current > cooldown_watermark:
|
||||||
|
if elapsed < 0.6:
|
||||||
|
return _fail("sniper's cooldown timer jumped back up (a second shot fired) before it finished counting down")
|
||||||
|
interceptor.position += Vector2(0, 900) # perpendicular, well clear
|
||||||
|
interceptor.move_target = interceptor.position
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 6
|
||||||
|
return false
|
||||||
|
cooldown_watermark = current
|
||||||
|
if elapsed > 6.0:
|
||||||
|
return _fail("sniper never fired its next shot")
|
||||||
|
return false
|
||||||
|
6:
|
||||||
|
# Give the projectile its full lifetime to have resolved either way.
|
||||||
|
if elapsed < 3.5:
|
||||||
|
return false
|
||||||
|
if not is_instance_valid(interceptor):
|
||||||
|
return _fail("a slow sniper shot still hit a ship that moved away immediately")
|
||||||
|
print("dodge held: interceptor survived after moving off the impact point")
|
||||||
|
|
||||||
|
# --- direct hit: ship held in place, unshielded then shielded ---
|
||||||
|
interceptor.position = sniper.position + Vector2(300, 0)
|
||||||
|
interceptor.move_target = interceptor.position
|
||||||
|
fleet.add_ship(GameState.VESSEL_SHIELD)
|
||||||
|
shield_ship = fleet.ships[fleet.ships.size() - 1]
|
||||||
|
shield_ship.position = Vector2(20000, 0) # far from the line of fire
|
||||||
|
shield_ship.move_target = shield_ship.position
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 7
|
||||||
|
return false
|
||||||
|
7:
|
||||||
|
if elapsed < 0.3:
|
||||||
|
return false
|
||||||
|
if not shield_ship.has_shield():
|
||||||
|
return _fail("a fresh shield vessel has no shield")
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 8
|
||||||
|
return false
|
||||||
|
8:
|
||||||
|
if is_instance_valid(interceptor):
|
||||||
|
if elapsed > 4.0:
|
||||||
|
return _fail("held interceptor was never hit by the sniper")
|
||||||
|
return false
|
||||||
|
print("stationary unshielded interceptor destroyed by sniper fire")
|
||||||
|
|
||||||
|
shield_ship.position = sniper.position + Vector2(300, 0)
|
||||||
|
shield_ship.move_target = shield_ship.position
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 9
|
||||||
|
return false
|
||||||
|
9:
|
||||||
|
if elapsed < 4.0:
|
||||||
|
return false
|
||||||
|
if not is_instance_valid(shield_ship):
|
||||||
|
return _fail("a shielded ship was destroyed by sniper fire")
|
||||||
|
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 a sniper hit and lost exactly one charge")
|
||||||
|
print("TEST_SNIPER_ENEMY: PASS")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_SNIPER_ENEMY: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_sniper_enemy.gd.uid
Normal file
1
tests/test_sniper_enemy.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://eaxineak2fte
|
||||||
Loading…
Reference in a new issue