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