extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_aim_assist.gd # Forgejo #89: a shot fired roughly toward an enemy bends onto it, so aiming # with a thumb on a stick does not have to be exact. # # Covers: # A. an enemy just off the aimed heading is picked up, and the bolt flies at # IT rather than where the stick pointed # B. an enemy far off the heading is not - past the cone the shot goes where # the player pointed, or the gun would be choosing its own targets again, # which is exactly what #80 took away # C. cursor proximity can select a target outside the directional cone # D. with several candidates, the one nearest the cursor wins rather than # blindly choosing the one nearest the shooter # E. asteroids participate through the same aimable-target contract # F. the guide snaps to the target position and reachable guns bend to the # same target, so the on-screen reticle tells the truth var game_state: Node var main: Node var fleet: Node var controller: Node var weapon: Node var shooter: Node2D var stage: int = 0 var elapsed: float = 0.0 const AIM := Vector2(1.0, 0.0) func _initialize() -> void: game_state = root.get_node("GameState") game_state.buildings.clear() game_state.extractors.clear() game_state.reset_progression() main = load("res://world/Main.tscn").instantiate() root.add_child(main) current_scene = main fleet = main.get_node("Fleet") controller = main.get_node("FireStickController") func _clear() -> void: for node in get_nodes_in_group("enemies") + get_nodes_in_group("asteroids") \ + get_nodes_in_group("player_projectiles"): if is_instance_valid(node): node.free() func _enemy_at(offset: Vector2) -> Node2D: var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate() enemy.position = shooter.global_position + offset main.add_child(enemy) enemy.speed = 0.0 return enemy func _heading(direction: Vector2) -> Vector2: var script: Script = load("res://vessels/VesselWeaponComponent.gd") return script.assisted_heading(self, shooter.global_position, direction, weapon.fire_range) func _point(at: Vector2) -> Vector2: var script: Script = load("res://vessels/VesselWeaponComponent.gd") return script.assisted_point(self, shooter.global_position, at, weapon.fire_range) func _process(delta: float) -> bool: elapsed += delta if stage != 0: return false if elapsed < 0.3: return false stage = 1 main.get_node("EnemySpawner").set_process(false) main.get_node("AsteroidField").set_process(false) _clear() fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_INTERCEPTOR])) shooter = fleet.ships[0] weapon = shooter.get_component("weapon") # --- A. just off the heading is picked up ----------------------------- var near_angle: float = deg_to_rad(18.0) var target: Node2D = _enemy_at(Vector2(600.0, 0.0).rotated(near_angle)) var assisted: Vector2 = _heading(AIM) var wanted: Vector2 = (target.global_position - shooter.global_position).normalized() if assisted.distance_to(wanted) > 0.01: return _fail("an enemy 18 degrees off the heading was not picked up (heading %s)" % assisted) print("an enemy 18 degrees off the aim is picked up") # --- B. far off the heading is not ------------------------------------ _clear() _enemy_at(Vector2(600.0, 0.0).rotated(deg_to_rad(55.0))) assisted = _heading(AIM) if assisted.distance_to(AIM) > 0.01: return _fail("an enemy 55 degrees off the heading was still picked up (heading %s)" % assisted) print("an enemy 55 degrees off the aim is ignored - the shot goes where it was aimed") # --- C. a nearby cursor is magnetic too ------------------------------- _clear() var raw_cursor: Vector2 = shooter.global_position + Vector2(250.0, 0.0) var cursor_target: Node2D = _enemy_at(Vector2(150.0, 0.0).rotated(deg_to_rad(45.0))) var snapped: Vector2 = _point(raw_cursor) if snapped.distance_to(cursor_target.global_position) > 0.01: return _fail("a cursor %.0f units from a target outside the cone did not stick to it" \ % raw_cursor.distance_to(cursor_target.global_position)) print("a cursor near a target sticks to its position even outside the directional cone") # --- D. cursor proximity chooses between cone candidates -------------- _clear() var far_distance: float = weapon.fire_range - 100.0 var far_target: Node2D = _enemy_at(Vector2(far_distance, 0.0).rotated(deg_to_rad(5.0))) var close_target: Node2D = _enemy_at(Vector2(300.0, 0.0).rotated(deg_to_rad(20.0))) assisted = _heading(AIM) var to_close: Vector2 = (close_target.global_position - shooter.global_position).normalized() var to_far: Vector2 = (far_target.global_position - shooter.global_position).normalized() if assisted.distance_to(to_far) > 0.01: return _fail("the assist ignored the cursor-near enemy at %.0f for the shooter-near one at 300 (heading %s, close %s, far %s)" \ % [far_distance, assisted, to_close, to_far]) print("with two in the cone, the target nearest the cursor wins") # --- E. asteroids use the same assist contract ------------------------- _clear() var rock: Node2D = load("res://world/Asteroid.tscn").instantiate() rock.position = shooter.global_position + Vector2(400.0, 0.0).rotated(deg_to_rad(15.0)) main.add_child(rock) assisted = _heading(AIM) var to_rock: Vector2 = (rock.global_position - shooter.global_position).normalized() if assisted.distance_to(to_rock) > 0.01: return _fail("the assist did not snap onto an asteroid (heading %s, asteroid %s)" \ % [assisted, to_rock]) print("asteroids opt into the same aim-assist target group as enemies") # --- F. the guide and reachable guns share the snapped target ---------- _clear() var guide_target: Node2D = _enemy_at(Vector2(500.0, 0.0).rotated(deg_to_rad(20.0))) controller.aim(AIM) if controller.aim_point.distance_to(guide_target.global_position) > 0.02: return _fail("the reticle stayed at %s instead of sticking to %s" \ % [controller.aim_point, guide_target.global_position]) var gun_heading: Vector2 = weapon.firing_heading() var target_heading: Vector2 = (guide_target.global_position - shooter.global_position).normalized() if gun_heading.distance_to(target_heading) > 0.02: return _fail("the reachable gun headed %s instead of toward snapped target %s" \ % [gun_heading, target_heading]) # And the guide must be drawn from that same helper, not a copy of the maths. var source: FileAccess = FileAccess.open("res://controllers/FireStickController.gd", FileAccess.READ) var text: String = source.get_as_text() source.close() if "assisted_point" not in text: return _fail("FireStickController draws its guide without the assist, so the line and the shot would diverge") print("the reticle snaps to the target and reachable guns bend toward it") print("TEST_AIM_ASSIST: PASS") quit(0) return true func _fail(message: String) -> bool: print("TEST_AIM_ASSIST: FAIL (%s)" % message) quit(1) return true