From fc2b5ca74a9f353c26df778032fd6f8bcc1aeaee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 21:23:01 +0200 Subject: [PATCH] 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. --- EnemyDefinitions.gd | 3 +- EnemyRangedWeaponComponent.gd | 54 ++++++ EnemyRangedWeaponComponent.gd.uid | 1 + EnemyRangedWeaponComponent.tscn | 6 + EnemyStandoffComponent.gd | 58 +++++++ EnemyStandoffComponent.gd.uid | 1 + EnemyStandoffComponent.tscn | 6 + SniperEnemyDefinition.tres | 17 ++ tests/_sniper_vs_chaser_preview.gd | 76 +++++++++ tests/_sniper_vs_chaser_preview.gd.uid | 1 + tests/_sniper_vs_chaser_preview.tscn | 6 + tests/test_sniper_enemy.gd | 226 +++++++++++++++++++++++++ tests/test_sniper_enemy.gd.uid | 1 + 13 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 EnemyRangedWeaponComponent.gd create mode 100644 EnemyRangedWeaponComponent.gd.uid create mode 100644 EnemyRangedWeaponComponent.tscn create mode 100644 EnemyStandoffComponent.gd create mode 100644 EnemyStandoffComponent.gd.uid create mode 100644 EnemyStandoffComponent.tscn create mode 100644 SniperEnemyDefinition.tres create mode 100644 tests/_sniper_vs_chaser_preview.gd create mode 100644 tests/_sniper_vs_chaser_preview.gd.uid create mode 100644 tests/_sniper_vs_chaser_preview.tscn create mode 100644 tests/test_sniper_enemy.gd create mode 100644 tests/test_sniper_enemy.gd.uid diff --git a/EnemyDefinitions.gd b/EnemyDefinitions.gd index fa910b5..b641b60 100644 --- a/EnemyDefinitions.gd +++ b/EnemyDefinitions.gd @@ -2,9 +2,10 @@ class_name EnemyDefinitions extends RefCounted const DEFAULT_ID: String = "chaser" -const ORDER: PackedStringArray = ["chaser"] +const ORDER: PackedStringArray = ["chaser", "sniper"] const DEFINITIONS: Dictionary = { "chaser": preload("res://ChaserEnemyDefinition.tres"), + "sniper": preload("res://SniperEnemyDefinition.tres"), } diff --git a/EnemyRangedWeaponComponent.gd b/EnemyRangedWeaponComponent.gd new file mode 100644 index 0000000..c8600ce --- /dev/null +++ b/EnemyRangedWeaponComponent.gd @@ -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 diff --git a/EnemyRangedWeaponComponent.gd.uid b/EnemyRangedWeaponComponent.gd.uid new file mode 100644 index 0000000..b0cb4c9 --- /dev/null +++ b/EnemyRangedWeaponComponent.gd.uid @@ -0,0 +1 @@ +uid://dx1lmxw6ia66a diff --git a/EnemyRangedWeaponComponent.tscn b/EnemyRangedWeaponComponent.tscn new file mode 100644 index 0000000..7c8b203 --- /dev/null +++ b/EnemyRangedWeaponComponent.tscn @@ -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") diff --git a/EnemyStandoffComponent.gd b/EnemyStandoffComponent.gd new file mode 100644 index 0000000..0111738 --- /dev/null +++ b/EnemyStandoffComponent.gd @@ -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 diff --git a/EnemyStandoffComponent.gd.uid b/EnemyStandoffComponent.gd.uid new file mode 100644 index 0000000..b725855 --- /dev/null +++ b/EnemyStandoffComponent.gd.uid @@ -0,0 +1 @@ +uid://c7axi54f6p7gk diff --git a/EnemyStandoffComponent.tscn b/EnemyStandoffComponent.tscn new file mode 100644 index 0000000..7858cc2 --- /dev/null +++ b/EnemyStandoffComponent.tscn @@ -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") diff --git a/SniperEnemyDefinition.tres b/SniperEnemyDefinition.tres new file mode 100644 index 0000000..102455f --- /dev/null +++ b/SniperEnemyDefinition.tres @@ -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")]) diff --git a/tests/_sniper_vs_chaser_preview.gd b/tests/_sniper_vs_chaser_preview.gd new file mode 100644 index 0000000..9f87fe5 --- /dev/null +++ b/tests/_sniper_vs_chaser_preview.gd @@ -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 diff --git a/tests/_sniper_vs_chaser_preview.gd.uid b/tests/_sniper_vs_chaser_preview.gd.uid new file mode 100644 index 0000000..688d960 --- /dev/null +++ b/tests/_sniper_vs_chaser_preview.gd.uid @@ -0,0 +1 @@ +uid://d2fj1p61hp2pk diff --git a/tests/_sniper_vs_chaser_preview.tscn b/tests/_sniper_vs_chaser_preview.tscn new file mode 100644 index 0000000..a9680ba --- /dev/null +++ b/tests/_sniper_vs_chaser_preview.tscn @@ -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") diff --git a/tests/test_sniper_enemy.gd b/tests/test_sniper_enemy.gd new file mode 100644 index 0000000..3a4d369 --- /dev/null +++ b/tests/test_sniper_enemy.gd @@ -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 diff --git a/tests/test_sniper_enemy.gd.uid b/tests/test_sniper_enemy.gd.uid new file mode 100644 index 0000000..410c027 --- /dev/null +++ b/tests/test_sniper_enemy.gd.uid @@ -0,0 +1 @@ +uid://eaxineak2fte