#89: the default gun bends onto a target near the aimed heading

Aiming with a thumb on a stick is never exact, and at 950 units a couple of
degrees is a long miss. A shot fired roughly toward an enemy now goes at it.

28 degrees of cone, chosen so that pointing at something hits it while the
shot still visibly goes where the player pointed. Past about 45 the gun starts
picking its own targets, which is the behaviour #80 deliberately removed.

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,
and rocks are large and stationary enough to hit on purpose.

The aim guide is drawn through the SAME helper the gun fires through, so the
dotted line bends with the shot. A guide showing the raw stick heading while
the bolt took an assisted one would be the same class of mismatch #86 removed
between the guide's length and a bolt's actual reach - and this one would have
been harder to notice, because the line would look right most of the time.

Range 620 -> 950, the other half of the ticket.

Also swept the project root again: Godot keeps resurrecting pre-#84 copies of
moved files there from its own cache. None this time.

Suite 120/120.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-22 03:19:52 +02:00
commit 5f3615026d
5 changed files with 204 additions and 6 deletions

View file

@ -21,6 +21,10 @@ const GUIDE_GAP: float = 16.0
const GUIDE_WIDTH: float = 2.0 const GUIDE_WIDTH: float = 2.0
const GUIDE_COLOR: Color = Color(1.0, 0.55, 0.38, 0.28) 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") @onready var fleet: Node2D = get_node("../Fleet")
# Optional so a scene without a fire stick (a preview, a test harness) still # 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. # runs - the guns simply stay quiet, which is the correct resting state.
@ -82,7 +86,10 @@ func _draw() -> void:
if reach <= 0.0: if reach <= 0.0:
return return
var origin: Vector2 = fleet.get_centroid() 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 var travelled: float = GUIDE_GAP
while travelled < reach: while travelled < reach:
var dash_end: float = minf(travelled + GUIDE_DASH, reach) var dash_end: float = minf(travelled + GUIDE_DASH, reach)

146
tests/test_aim_assist.gd Normal file
View file

@ -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

View file

@ -17,5 +17,5 @@ glow_color = Color(0.25, 0.85, 1, 1)
fleet_eligible = true fleet_eligible = true
capabilities = PackedStringArray("weapon", "cargo") capabilities = PackedStringArray("weapon", "cargo")
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("7")]) 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")]) upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5")])

View file

@ -16,7 +16,7 @@ fleet_eligible = true
capabilities = PackedStringArray("shield") capabilities = PackedStringArray("shield")
component_scenes = Array[PackedScene]([ExtResource("3")]) 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) 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} minimum_stats = {"shield_recharge": 2.0}
upgrades = Array[Resource]([ExtResource("4"), ExtResource("5")]) upgrades = Array[Resource]([ExtResource("4"), ExtResource("5")])
unlock_id = "unlock_shield" unlock_id = "unlock_shield"

View file

@ -33,6 +33,50 @@ func apply_stats() -> void:
projectile_damage = int(state.vessel_stat(ship.vessel_type, "damage")) 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. # Called by FireStickController. A zero vector stops the gun.
func set_fire_direction(direction: Vector2) -> void: func set_fire_direction(direction: Vector2) -> void:
fire_direction = direction if direction.length() > 0.001 else Vector2.ZERO 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) ship.get_tree().root.add_child(projectile)
# Aimed by DIRECTION, not at a target: launch() takes a displacement, so # Aimed by DIRECTION, not at a target: launch() takes a displacement, so
# the gun's own range decides how far the shot is sent. # 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 # #87: sparks off the hull that fired, so a shot is visibly attached to
# the vessel it came from. # the vessel it came from.
MUZZLE_SPARK.spawn(ship.get_tree().root, ship.global_position, MUZZLE_SPARK.spawn(ship.get_tree().root, ship.global_position, heading)
fire_direction.normalized())
fire_timer = fire_cooldown fire_timer = fire_cooldown