diff --git a/controllers/FireStickController.gd b/controllers/FireStickController.gd index 97d03a2..453c92e 100644 --- a/controllers/FireStickController.gd +++ b/controllers/FireStickController.gd @@ -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 diff --git a/enemies/Enemy.gd b/enemies/Enemy.gd index 8b2c50c..d4bec9a 100644 --- a/enemies/Enemy.gd +++ b/enemies/Enemy.gd @@ -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 diff --git a/tests/test_aim_assist.gd b/tests/test_aim_assist.gd index 6b585a7..1a3f268 100644 --- a/tests/test_aim_assist.gd +++ b/tests/test_aim_assist.gd @@ -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) diff --git a/vessels/VesselWeaponComponent.gd b/vessels/VesselWeaponComponent.gd index e621e01..d54b4d1 100644 --- a/vessels/VesselWeaponComponent.gd +++ b/vessels/VesselWeaponComponent.gd @@ -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: diff --git a/world/Asteroid.gd b/world/Asteroid.gd index b35df34..0a85a57 100644 --- a/world/Asteroid.gd +++ b/world/Asteroid.gd @@ -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