diff --git a/controllers/FireStickController.gd b/controllers/FireStickController.gd index a5b4548..dee06ef 100644 --- a/controllers/FireStickController.gd +++ b/controllers/FireStickController.gd @@ -21,6 +21,10 @@ const GUIDE_GAP: float = 16.0 const GUIDE_WIDTH: float = 2.0 const GUIDE_COLOR: Color = Color(1.0, 0.55, 0.38, 0.28) +# By path rather than by class name, for the load-order reason documented in +# PlanetSetups and elsewhere. +const WEAPON_COMPONENT = preload("res://vessels/VesselWeaponComponent.gd") + @onready var fleet: Node2D = get_node("../Fleet") # Optional so a scene without a fire stick (a preview, a test harness) still # runs - the guns simply stay quiet, which is the correct resting state. @@ -82,7 +86,10 @@ func _draw() -> void: if reach <= 0.0: return var origin: Vector2 = fleet.get_centroid() - var heading: Vector2 = aim_direction.normalized() + # Drawn along the heading a shot will ACTUALLY take, assist included, so + # the line keeps telling the truth about where fire is going (#89). + var heading: Vector2 = WEAPON_COMPONENT.assisted_heading(get_tree(), origin, + aim_direction, reach) var travelled: float = GUIDE_GAP while travelled < reach: var dash_end: float = minf(travelled + GUIDE_DASH, reach) diff --git a/tests/test_aim_assist.gd b/tests/test_aim_assist.gd new file mode 100644 index 0000000..6b585a7 --- /dev/null +++ b/tests/test_aim_assist.gd @@ -0,0 +1,146 @@ +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. the NEAREST candidate inside the cone wins +# D. asteroids are never assisted onto. Snapping to a rock while the player +# is trying to hit the thing shooting at them is the assist working +# against them +# E. the aim guide follows the assist, so the dotted line keeps telling the +# truth about where fire is going + +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 _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. the nearest inside the cone wins ------------------------------ + _clear() + var far_target: Node2D = _enemy_at(Vector2(800.0, 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_close) > 0.01: + return _fail("the assist chose the enemy at 800 over the one at 300 (heading %s, close %s, far %s)" \ + % [assisted, to_close, to_far]) + print("with two in the cone, the nearer one wins") + + # --- D. never onto an asteroid ---------------------------------------- + _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) + if assisted.distance_to(AIM) > 0.01: + return _fail("the assist snapped onto an asteroid (heading %s)" % assisted) + print("asteroids are never assisted onto") + + # --- E. the guide follows the assist ---------------------------------- + _clear() + var guide_target: Node2D = _enemy_at(Vector2(500.0, 0.0).rotated(deg_to_rad(20.0))) + controller.aim(AIM) + controller.aim_direction = AIM + var guided: Vector2 = load("res://vessels/VesselWeaponComponent.gd").assisted_heading( + self, fleet.get_centroid(), controller.aim_direction, controller.guide_range()) + var from_fleet: Vector2 = (guide_target.global_position - fleet.get_centroid()).normalized() + if guided.distance_to(from_fleet) > 0.02: + return _fail("the aim guide does not follow the assist (guide %s, target %s)" % [guided, from_fleet]) + # 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_heading" not in text: + return _fail("FireStickController draws its guide without the assist, so the line and the shot would diverge") + print("the aim guide is drawn along the assisted heading, from the same helper the gun uses") + + 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 diff --git a/vessels/InterceptorVesselDefinition.tres b/vessels/InterceptorVesselDefinition.tres index 33eacdf..b99b0cb 100644 --- a/vessels/InterceptorVesselDefinition.tres +++ b/vessels/InterceptorVesselDefinition.tres @@ -17,5 +17,5 @@ glow_color = Color(0.25, 0.85, 1, 1) fleet_eligible = true capabilities = PackedStringArray("weapon", "cargo") component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("7")]) -base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 620.0, "max_speed": 690.0, "acceleration": 2025.0, "delivery_speed": 180.0, "cargo_capacity": 20.0} +base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 950.0, "max_speed": 690.0, "acceleration": 2025.0, "delivery_speed": 180.0, "cargo_capacity": 20.0} upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5")]) diff --git a/vessels/ShieldVesselDefinition.tres b/vessels/ShieldVesselDefinition.tres index 3673632..314d385 100644 --- a/vessels/ShieldVesselDefinition.tres +++ b/vessels/ShieldVesselDefinition.tres @@ -16,7 +16,7 @@ fleet_eligible = true capabilities = PackedStringArray("shield") component_scenes = Array[PackedScene]([ExtResource("3")]) hull_points = PackedVector2Array(0, -24, 20.7846, -12, 20.7846, 12, 0, 24, -20.7846, 12, -20.7846, -12) -base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 620.0, "max_speed": 690.0, "acceleration": 2025.0, "shield_charges": 1.0, "shield_recharge": 6.0, "delivery_speed": 180.0} +base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 950.0, "max_speed": 690.0, "acceleration": 2025.0, "shield_charges": 1.0, "shield_recharge": 6.0, "delivery_speed": 180.0} minimum_stats = {"shield_recharge": 2.0} upgrades = Array[Resource]([ExtResource("4"), ExtResource("5")]) unlock_id = "unlock_shield" diff --git a/vessels/VesselWeaponComponent.gd b/vessels/VesselWeaponComponent.gd index 2630966..f52d064 100644 --- a/vessels/VesselWeaponComponent.gd +++ b/vessels/VesselWeaponComponent.gd @@ -33,6 +33,50 @@ func apply_stats() -> void: projectile_damage = int(state.vessel_stat(ship.vessel_type, "damage")) +# Forgejo #89: how far off the aimed heading a target may sit and still be +# picked up. 28 degrees is wide enough that pointing roughly at something hits +# it, and narrow enough that the shot still goes where the player pointed - +# past about 45 the gun starts choosing targets on its own, which is the +# behaviour #80 deliberately took away. +const ASSIST_CONE_DEGREES: float = 28.0 + + +# The heading a shot fired from `origin` along `direction` should actually +# take: straight, unless an enemy sits inside the assist cone and within +# reach, in which case it points at the nearest one. +# +# Static, and used by BOTH the gun and the aim guide (see +# FireStickController._draw). If the guide drew the raw stick heading while +# the shot took an assisted one, the line would be telling the player +# something the gun does not do - the same class of mismatch #86 removed +# between the guide's length and a bolt's actual reach. +# +# Enemies only, never asteroids. Snapping onto a rock while the player is +# trying to hit the thing shooting at them would be the assist working +# against them; rocks are large, stationary enough, and easy to hit on +# purpose. +static func assisted_heading(tree: SceneTree, origin: Vector2, direction: Vector2, + reach: float) -> Vector2: + var heading: Vector2 = direction.normalized() + if heading == Vector2.ZERO or tree == null: + return heading + var cone: float = cos(deg_to_rad(ASSIST_CONE_DEGREES)) + var nearest: Vector2 = Vector2.ZERO + var nearest_distance: float = INF + for enemy in tree.get_nodes_in_group("enemies"): + if not is_instance_valid(enemy) or not (enemy is Node2D): + continue + var to_enemy: Vector2 = (enemy as Node2D).global_position - origin + var distance: float = to_enemy.length() + if distance > reach or distance < 0.001 or distance >= nearest_distance: + continue + if to_enemy.normalized().dot(heading) < cone: + continue + nearest_distance = distance + nearest = to_enemy + return heading if nearest == Vector2.ZERO else nearest.normalized() + + # Called by FireStickController. A zero vector stops the gun. func set_fire_direction(direction: Vector2) -> void: fire_direction = direction if direction.length() > 0.001 else Vector2.ZERO @@ -59,9 +103,10 @@ func tick(delta: float) -> void: ship.get_tree().root.add_child(projectile) # Aimed by DIRECTION, not at a target: launch() takes a displacement, so # the gun's own range decides how far the shot is sent. - projectile.launch(fire_direction.normalized() * fire_range) + var heading: Vector2 = assisted_heading(ship.get_tree(), ship.global_position, + fire_direction, fire_range) + projectile.launch(heading * fire_range) # #87: sparks off the hull that fired, so a shot is visibly attached to # the vessel it came from. - MUZZLE_SPARK.spawn(ship.get_tree().root, ship.global_position, - fire_direction.normalized()) + MUZZLE_SPARK.spawn(ship.get_tree().root, ship.global_position, heading) fire_timer = fire_cooldown