Improve aim assist target stickiness

This commit is contained in:
Loïc Wisniewski 2026-09-02 17:15:12 +02:00
commit 3975959017
5 changed files with 122 additions and 64 deletions

View file

@ -104,9 +104,16 @@ func aim(direction: Vector2) -> void:
func aim_at(point: Vector2) -> void:
if fleet == null:
return
aim_point = point
var origin: Vector2 = fleet.get_centroid()
var reach: float = guide_range()
# The reticle resolves once from the fleet centre, so the player's cursor
# visibly sticks. Hulls keep the raw point and resolve it from their own
# muzzle/range below; a short-range ship must not bend toward something it
# cannot actually reach (#172).
aim_point = WEAPON_COMPONENT.assisted_point(get_tree(), origin, point, reach) \
if reach > 0.0 else point
has_aim = true
aim_direction = point - fleet.get_centroid()
aim_direction = aim_point - origin
for ship in fleet.ships:
if not is_instance_valid(ship):
continue
@ -148,10 +155,9 @@ func _draw() -> void:
if reach <= 0.0:
return
var origin: Vector2 = fleet.get_centroid()
# 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)
# aim_at() has already resolved the shared assisted point, so the line and
# reticle show the position that every in-range hull resolves toward (#181).
var heading: Vector2 = aim_direction.normalized()
# #172: the line stops at the aimed PLACE when that is inside the guns'
# reach. Running it on to full range past a point the fleet is converging
# on would be drawing a direction again, which is what the ticket took

View file

@ -30,6 +30,10 @@ var max_hp: int
func _ready() -> void:
add_to_group("enemies")
# Weapons discover anything the player may deliberately aim at through one
# compositional contract. Asteroids implement the same contract without the
# aim-assist code having to know either concrete type (#181).
add_to_group("player_aim_targets")
definition = ENEMY_DEFINITIONS.get_definition(enemy_id)
speed = definition.speed
acceleration = definition.acceleration

View file

@ -11,12 +11,12 @@ extends SceneTree
# 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
# 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
@ -63,6 +63,12 @@ func _heading(direction: Vector2) -> Vector2:
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:
@ -95,45 +101,60 @@ func _process(delta: float) -> bool:
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 ------------------------------
# --- C. a nearby cursor is magnetic too -------------------------------
_clear()
var far_target: Node2D = _enemy_at(Vector2(800.0, 0.0).rotated(deg_to_rad(5.0)))
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_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")
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")
# --- D. never onto an asteroid ----------------------------------------
# --- 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)
if assisted.distance_to(AIM) > 0.01:
return _fail("the assist snapped onto an asteroid (heading %s)" % assisted)
print("asteroids are never assisted onto")
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")
# --- E. the guide follows the assist ----------------------------------
# --- 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)
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])
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_heading" not in text:
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 aim guide is drawn along the assisted heading, from the same helper the gun uses")
print("the reticle snaps to the target and reachable guns bend toward it")
print("TEST_AIM_ASSIST: PASS")
quit(0)

View file

@ -54,42 +54,66 @@ func apply_stats() -> void:
# 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
# #181: a nearby cursor is a second way to express intent. This is deliberately
# smaller than the directional cone at ordinary weapon range: it catches a
# pointer placed beside something without making the whole screen magnetic.
const ASSIST_CURSOR_RADIUS: float = 180.0
const ASSIST_TARGET_GROUP: StringName = &"player_aim_targets"
# 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.
# The concrete point an aim should stick to. A target can qualify in two ways:
# it is close to the cursor, or it lies inside the established directional
# cone. Cursor proximity wins, then distance from the shooter breaks the old
# directional case. This preserves approximate stick aiming while letting a
# deliberately placed cursor choose a farther target in the same direction.
#
# 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.
# Targets opt in through one group rather than this utility branching on
# enemies versus asteroids. That keeps future aimable compositions out of the
# weapon code: joining the contract is all they need to do.
static func assisted_point(tree: SceneTree, origin: Vector2, point: Vector2,
reach: float) -> Vector2:
var to_point: Vector2 = point - origin
var heading: Vector2 = to_point.normalized()
if heading == Vector2.ZERO or tree == null or reach <= 0.0:
return point
var cone: float = cos(deg_to_rad(ASSIST_CONE_DEGREES))
var cursor_pick: Node2D = null
var cursor_distance: float = INF
var direction_pick: Node2D = null
var direction_distance: float = INF
for target_node in tree.get_nodes_in_group(ASSIST_TARGET_GROUP):
if not is_instance_valid(target_node) or not (target_node is Node2D) \
or target_node.is_queued_for_deletion():
continue
var target: Node2D = target_node as Node2D
var to_target: Vector2 = target.global_position - origin
var distance: float = to_target.length()
if distance > reach or distance < 0.001:
continue
var from_cursor: float = target.global_position.distance_to(point)
if from_cursor <= ASSIST_CURSOR_RADIUS and from_cursor < cursor_distance:
cursor_distance = from_cursor
cursor_pick = target
if to_target.normalized().dot(heading) >= cone and distance < direction_distance:
direction_distance = distance
direction_pick = target
if cursor_pick != null:
return cursor_pick.global_position
if direction_pick != null:
return direction_pick.global_position
return point
# Compatibility entry point for systems that genuinely have only a heading.
# Their virtual cursor sits at maximum range; the returned vector still points
# at the same target selected by assisted_point().
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:
if heading == Vector2.ZERO:
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()
var point: Vector2 = assisted_point(tree, origin, origin + heading * reach, reach)
return (point - origin).normalized()
# Where this gun is firing FROM. The hull it is bolted to.
@ -130,10 +154,12 @@ func is_firing() -> bool:
func firing_heading() -> Vector2:
if not has_fire_target:
return Vector2.ZERO
var to_point: Vector2 = fire_target - muzzle()
var origin: Vector2 = muzzle()
var to_point: Vector2 = fire_target - origin
if to_point.length() <= 0.001:
return Vector2.ZERO
return assisted_heading(get_tree(), muzzle(), to_point, fire_range)
var point: Vector2 = assisted_point(get_tree(), origin, fire_target, fire_range)
return (point - origin).normalized()
func tick(delta: float) -> void:

View file

@ -32,6 +32,7 @@ var shape_points: PackedVector2Array = PackedVector2Array()
func _ready() -> void:
randomize()
add_to_group("asteroids")
add_to_group("player_aim_targets")
area_entered.connect(_on_area_entered)
var point_count: int = 7