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

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