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: func aim_at(point: Vector2) -> void:
if fleet == null: if fleet == null:
return 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 has_aim = true
aim_direction = point - fleet.get_centroid() aim_direction = aim_point - origin
for ship in fleet.ships: for ship in fleet.ships:
if not is_instance_valid(ship): if not is_instance_valid(ship):
continue continue
@ -148,10 +155,9 @@ 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()
# Drawn along the heading a shot will ACTUALLY take, assist included, so # aim_at() has already resolved the shared assisted point, so the line and
# the line keeps telling the truth about where fire is going (#89). # reticle show the position that every in-range hull resolves toward (#181).
var heading: Vector2 = WEAPON_COMPONENT.assisted_heading(get_tree(), origin, var heading: Vector2 = aim_direction.normalized()
aim_direction, reach)
# #172: the line stops at the aimed PLACE when that is inside the guns' # #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 # 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 # 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: func _ready() -> void:
add_to_group("enemies") 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) definition = ENEMY_DEFINITIONS.get_definition(enemy_id)
speed = definition.speed speed = definition.speed
acceleration = definition.acceleration 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 # 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, # the player pointed, or the gun would be choosing its own targets again,
# which is exactly what #80 took away # which is exactly what #80 took away
# C. the NEAREST candidate inside the cone wins # C. cursor proximity can select a target outside the directional cone
# D. asteroids are never assisted onto. Snapping to a rock while the player # D. with several candidates, the one nearest the cursor wins rather than
# is trying to hit the thing shooting at them is the assist working # blindly choosing the one nearest the shooter
# against them # E. asteroids participate through the same aimable-target contract
# E. the aim guide follows the assist, so the dotted line keeps telling the # F. the guide snaps to the target position and reachable guns bend to the
# truth about where fire is going # same target, so the on-screen reticle tells the truth
var game_state: Node var game_state: Node
var main: Node var main: Node
@ -63,6 +63,12 @@ func _heading(direction: Vector2) -> Vector2:
weapon.fire_range) 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: func _process(delta: float) -> bool:
elapsed += delta elapsed += delta
if stage != 0: 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) 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") 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() _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))) var close_target: Node2D = _enemy_at(Vector2(300.0, 0.0).rotated(deg_to_rad(20.0)))
assisted = _heading(AIM) assisted = _heading(AIM)
var to_close: Vector2 = (close_target.global_position - shooter.global_position).normalized() var to_close: Vector2 = (close_target.global_position - shooter.global_position).normalized()
var to_far: Vector2 = (far_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: if assisted.distance_to(to_far) > 0.01:
return _fail("the assist chose the enemy at 800 over the one at 300 (heading %s, close %s, far %s)" \ return _fail("the assist ignored the cursor-near enemy at %.0f for the shooter-near one at 300 (heading %s, close %s, far %s)" \
% [assisted, to_close, to_far]) % [far_distance, assisted, to_close, to_far])
print("with two in the cone, the nearer one wins") 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() _clear()
var rock: Node2D = load("res://world/Asteroid.tscn").instantiate() 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)) rock.position = shooter.global_position + Vector2(400.0, 0.0).rotated(deg_to_rad(15.0))
main.add_child(rock) main.add_child(rock)
assisted = _heading(AIM) assisted = _heading(AIM)
if assisted.distance_to(AIM) > 0.01: var to_rock: Vector2 = (rock.global_position - shooter.global_position).normalized()
return _fail("the assist snapped onto an asteroid (heading %s)" % assisted) if assisted.distance_to(to_rock) > 0.01:
print("asteroids are never assisted onto") 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() _clear()
var guide_target: Node2D = _enemy_at(Vector2(500.0, 0.0).rotated(deg_to_rad(20.0))) var guide_target: Node2D = _enemy_at(Vector2(500.0, 0.0).rotated(deg_to_rad(20.0)))
controller.aim(AIM) controller.aim(AIM)
controller.aim_direction = AIM if controller.aim_point.distance_to(guide_target.global_position) > 0.02:
var guided: Vector2 = load("res://vessels/VesselWeaponComponent.gd").assisted_heading( return _fail("the reticle stayed at %s instead of sticking to %s" \
self, fleet.get_centroid(), controller.aim_direction, controller.guide_range()) % [controller.aim_point, guide_target.global_position])
var from_fleet: Vector2 = (guide_target.global_position - fleet.get_centroid()).normalized() var gun_heading: Vector2 = weapon.firing_heading()
if guided.distance_to(from_fleet) > 0.02: var target_heading: Vector2 = (guide_target.global_position - shooter.global_position).normalized()
return _fail("the aim guide does not follow the assist (guide %s, target %s)" % [guided, from_fleet]) 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. # 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 source: FileAccess = FileAccess.open("res://controllers/FireStickController.gd", FileAccess.READ)
var text: String = source.get_as_text() var text: String = source.get_as_text()
source.close() 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") 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") print("TEST_AIM_ASSIST: PASS")
quit(0) 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 # past about 45 the gun starts choosing targets on its own, which is the
# behaviour #80 deliberately took away. # behaviour #80 deliberately took away.
const ASSIST_CONE_DEGREES: float = 28.0 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 # The concrete point an aim should stick to. A target can qualify in two ways:
# take: straight, unless an enemy sits inside the assist cone and within # it is close to the cursor, or it lies inside the established directional
# reach, in which case it points at the nearest one. # 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 # Targets opt in through one group rather than this utility branching on
# FireStickController._draw). If the guide drew the raw stick heading while # enemies versus asteroids. That keeps future aimable compositions out of the
# the shot took an assisted one, the line would be telling the player # weapon code: joining the contract is all they need to do.
# something the gun does not do - the same class of mismatch #86 removed static func assisted_point(tree: SceneTree, origin: Vector2, point: Vector2,
# between the guide's length and a bolt's actual reach. reach: float) -> Vector2:
# var to_point: Vector2 = point - origin
# Enemies only, never asteroids. Snapping onto a rock while the player is var heading: Vector2 = to_point.normalized()
# trying to hit the thing shooting at them would be the assist working if heading == Vector2.ZERO or tree == null or reach <= 0.0:
# against them; rocks are large, stationary enough, and easy to hit on return point
# purpose. 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, static func assisted_heading(tree: SceneTree, origin: Vector2, direction: Vector2,
reach: float) -> Vector2: reach: float) -> Vector2:
var heading: Vector2 = direction.normalized() var heading: Vector2 = direction.normalized()
if heading == Vector2.ZERO or tree == null: if heading == Vector2.ZERO:
return heading return heading
var cone: float = cos(deg_to_rad(ASSIST_CONE_DEGREES)) var point: Vector2 = assisted_point(tree, origin, origin + heading * reach, reach)
var nearest: Vector2 = Vector2.ZERO return (point - origin).normalized()
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()
# Where this gun is firing FROM. The hull it is bolted to. # Where this gun is firing FROM. The hull it is bolted to.
@ -130,10 +154,12 @@ func is_firing() -> bool:
func firing_heading() -> Vector2: func firing_heading() -> Vector2:
if not has_fire_target: if not has_fire_target:
return Vector2.ZERO 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: if to_point.length() <= 0.001:
return Vector2.ZERO 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: func tick(delta: float) -> void:

View file

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