From 2239235993514d9e875ccce6c43d39b4569fc1d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 5 Sep 2026 09:32:42 +0200 Subject: [PATCH] Aim the laser by depth, and ease the aim view Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD --- controllers/AimedFleetAbilityController.gd | 39 +++++++++++++---- controllers/LaserController.gd | 31 ++++++++++---- tests/_laser_aim_preview.gd | 26 +++++++++--- tests/test_gas_bomber.gd | 6 +++ tests/test_laser_vessel.gd | 49 +++++++++++++++++++--- 5 files changed, 123 insertions(+), 28 deletions(-) diff --git a/controllers/AimedFleetAbilityController.gd b/controllers/AimedFleetAbilityController.gd index b9600df..5e4bd00 100644 --- a/controllers/AimedFleetAbilityController.gd +++ b/controllers/AimedFleetAbilityController.gd @@ -8,6 +8,17 @@ const COMBAT_TARGET = preload("res://core/CombatTarget.gd") # shot effect, and effect visuals. const AIM_DEADZONE: float = 22.0 +# The aim view eases to its framing instead of cutting to it, in both +# directions. A hard jump loses where everything just went - the more so now +# that aiming can travel a long way - and the eye cannot follow a cut it did +# not make. Fast, though: this happens under a thumb that is already aiming, +# so it settles in about a quarter of a second rather than gliding. +const ZOOM_EASE_SPEED: float = 12.0 +# A floor under that rate, as a multiple of the zoom being approached. A purely +# proportional ease slows as it closes and never actually arrives, which would +# leave "has the camera restored" false forever and let the next aim record a +# resting zoom that is still moving. +const ZOOM_MIN_RATE: float = 4.0 @export var minimum_zoom: float = 0.4 @onready var fleet: Node2D = get_node("../Fleet") @@ -22,6 +33,9 @@ var aim_start_screen: Vector2 = Vector2.ZERO var aim_screen: Vector2 = Vector2.ZERO var aim_target: Vector2 = Vector2.ZERO var saved_zoom: float = 1.0 +# What the aim wants to be framed at. Written by _update_aim_zoom(); the +# camera walks toward it in _process() rather than being assigned it. +var desired_zoom: float = 1.0 var restoring_zoom: bool = false @@ -45,11 +59,11 @@ func _process(delta: float) -> void: if status.text != "" and Time.get_ticks_msec() >= status_message_until: status.text = "" - if restoring_zoom and not aiming: - var zoom: float = move_toward(camera.zoom.x, saved_zoom, - maxf(saved_zoom, 0.1) * delta * 5.0) - camera.zoom = Vector2(zoom, zoom) - if is_equal_approx(zoom, saved_zoom): + if aiming: + _ease_zoom(desired_zoom, delta) + elif restoring_zoom: + _ease_zoom(saved_zoom, delta) + if is_equal_approx(camera.zoom.x, saved_zoom): restoring_zoom = false _advance_effects(delta) @@ -77,6 +91,7 @@ func fire_at(world_target: Vector2) -> bool: # Recorded before firing because _fire() ends by restoring it, and a stale # value from an earlier aim would snap the camera somewhere unrelated. saved_zoom = camera.zoom.x + desired_zoom = saved_zoom restoring_zoom = false aim_target = world_target _fire() @@ -96,6 +111,7 @@ func handle_touch(event: InputEventScreenTouch) -> bool: aim_start_screen = event.position aim_screen = event.position saved_zoom = camera.zoom.x + desired_zoom = saved_zoom restoring_zoom = false button.pressed_down = true button.queue_redraw() @@ -165,9 +181,18 @@ func _update_aim_zoom(origin: Vector2, target: Vector2) -> void: else half_screen.x / (absf(displacement.x) + padding) var fit_y: float = saved_zoom if absf(displacement.y) < 1.0 \ else half_screen.y / (absf(displacement.y) + padding) - var desired: float = clampf(minf(saved_zoom, minf(fit_x, fit_y)), + desired_zoom = clampf(minf(saved_zoom, minf(fit_x, fit_y)), minimum_zoom, saved_zoom) - camera.zoom = Vector2(desired, desired) + + +# Proportional rather than a fixed step per second, so the same easing reads +# the same whether the camera is crossing from 2.5 to 0.15 or barely moving, +# with the floor above closing the last of it exactly. +func _ease_zoom(target: float, delta: float) -> void: + var step: float = maxf(absf(target - camera.zoom.x) * ZOOM_EASE_SPEED, + maxf(target, 0.1) * ZOOM_MIN_RATE) * delta + var zoom: float = move_toward(camera.zoom.x, target, step) + camera.zoom = Vector2(zoom, zoom) func _finish_aim() -> void: diff --git a/controllers/LaserController.gd b/controllers/LaserController.gd index caf9fb3..9119f8f 100644 --- a/controllers/LaserController.gd +++ b/controllers/LaserController.gd @@ -19,6 +19,14 @@ const TARGET_COLOR := Color(0.6, 0.95, 1.0) # #203's range mark and the assist's target ring, in screen pixels. const END_TICK_PIXELS: float = 17.0 const TARGET_RING_PIXELS: float = 15.0 +# How far the thumb travels to walk the aim point out to the beam's full +# reach. The same idiom the gas bomb uses for its throw distance. +const DRAG_RADIUS: float = 260.0 +# The aim view frames twice the distance to the chosen target, so there is as +# much space beyond it as in front of it. Framing the target itself would put +# it on the edge of the screen, which is the half that matters when the point +# of dragging further is to reach past what is already close. +const FRAMING_FACTOR: float = 2.0 const BEAM_HALF_WIDTH := BEAM_EFFECT.BEAM_HALF_WIDTH const FLASH_DURATION := BEAM_EFFECT.FLASH_DURATION const ENERGY_PER_SHOT: int = VesselLaserComponent.ENERGY_PER_SHOT @@ -55,9 +63,15 @@ func _aim_max_range() -> float: func _calculate_aim_target(drag: Vector2, origin: Vector2) -> Vector2: - # A beam always reaches full range; dragging chooses its direction only. + # The shot always reaches full range; the drag chooses a direction AND a + # depth along it. Depth is what lets the helper reach a target standing + # behind a nearer one: the assist looks for something near the point the + # thumb actually named, and only falls back to the nearest thing in the + # cone when the player has named no place in particular. var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP - return _assisted_target(origin, origin + direction * _aim_max_range()) + var reach: float = _aim_max_range() + var depth: float = minf(drag.length() / DRAG_RADIUS, 1.0) * reach + return _assisted_target(origin, origin + direction * maxf(depth, 1.0)) # The mouse path (#80) names a world point directly instead of dragging one @@ -117,14 +131,13 @@ func beam_tip(beam: Node2D) -> Vector2: return beam.tip() -# #203: the aim view frames the beam's WHOLE reach, not the point the aim -# happens to sit on. The assist can settle that point on an enemy a few -# hundred units away (#201), and fitting the camera to it would say the shot -# stops there. Fitting the far end instead is also what makes the range -# visible at all: the beam is 2900 units long and the inherited floor on the -# zoom - sized for the gas bomb's 950 - left three quarters of it off screen. +# #203: framed around the chosen target rather than around the fleet. The +# inherited floor on the zoom was sized for the gas bomb's 950 units and left +# most of a 2900-unit aim off screen; the laser's own floor lets this fit. +# Twice the target's distance, so what lies beyond it is on screen too - that +# is the half the player is reaching into when they drag further out. func _update_aim_zoom(origin: Vector2, target: Vector2) -> void: - super(origin, origin + _aim_heading(origin, target) * _aim_max_range()) + super(origin, origin + (target - origin) * FRAMING_FACTOR) func _aim_heading(origin: Vector2, target: Vector2) -> Vector2: diff --git a/tests/_laser_aim_preview.gd b/tests/_laser_aim_preview.gd index d6d50f1..0031237 100644 --- a/tests/_laser_aim_preview.gd +++ b/tests/_laser_aim_preview.gd @@ -46,12 +46,26 @@ func _ready() -> void: press.position = button.global_position + button.size * 0.5 press.pressed = true skill.handle_touch(press) - var drag := InputEventScreenDrag.new() - drag.index = 9 - drag.position = press.position + Vector2(-245.0, -235.0) - skill.handle_drag(drag) - for t in [0.35, 0.6]: + # Enemies BEFORE the drag, so the aim helper actually sees them. A nearer + # one on the heading and a further one just off it: the state this preview + # exists to show is the drag reaching past the first to name the second. + var heading: Vector2 = Vector2(-245.0, -235.0).normalized() + var reach: float = skill._aim_max_range() + var origin: Vector2 = fleet.get_centroid() + var far_offset: Vector2 = heading.rotated(deg_to_rad(8.0)) * 1900.0 + for offset in [heading * 900.0, far_offset]: var enemy: Node = load("res://enemies/Enemy.tscn").instantiate() - enemy.position = fleet.get_centroid().lerp(skill.aim_target, t) + enemy.position = origin + offset main.add_child(enemy) enemy.speed = 0.0 # after add_child - _ready() sets it from the definition + + var drag := InputEventScreenDrag.new() + drag.index = 9 + # Dragged out to the further enemy's depth rather than to the beam's end. + drag.position = press.position + far_offset.normalized() \ + * (1900.0 / reach * LaserController.DRAG_RADIUS) + skill.handle_drag(drag) + # The zoom eases now, so a preview that captures the first frame would + # catch it mid-move. Settle it before the screenshot is taken. + for _step in range(40): + skill._process(0.05) diff --git a/tests/test_gas_bomber.gd b/tests/test_gas_bomber.gd index 6af6ee3..9451c69 100644 --- a/tests/test_gas_bomber.gd +++ b/tests/test_gas_bomber.gd @@ -120,6 +120,12 @@ func _process(delta: float) -> bool: drag.index = 7 drag.position = Vector2(350, 700) main._handle_drag(drag) + # The aim view eases to its framing now instead of cutting to it, + # so the drag alone has not moved the camera yet. + if not is_equal_approx(main.get_node("Camera2D").zoom.x, original_zoom): + return _fail_now("the aim zoom jumped instead of easing") + for _step in range(40): + skill._process(0.05) if main.get_node("Camera2D").zoom.x >= original_zoom: return _fail_now("long aim did not zoom out") diff --git a/tests/test_laser_vessel.gd b/tests/test_laser_vessel.gd index 0538403..95b7619 100644 --- a/tests/test_laser_vessel.gd +++ b/tests/test_laser_vessel.gd @@ -26,6 +26,10 @@ var skill: Node # in a test script has been observed to corrupt method resolution for the # WHOLE script. See tests/test_gas_bomber_cooldowns.gd's identical fix (#60). const BEAM_HALF_WIDTH: float = 38.0 +# Mirrored for the same reason, and it bites exactly as documented: a bare +# DRAG_RADIUS here made every method call in this script +# resolve against a plain Node2D. +const DRAG_RADIUS: float = 260.0 var on_line_enemies: Array = [] var off_line_enemy: Node var beyond_range_enemy: Node @@ -120,20 +124,28 @@ func _process(delta: float) -> bool: drag.index = 7 drag.position = press.position + Vector2(300.0, 0.0) main._handle_drag(drag) + # The aim view eases to its framing rather than cutting to it, so + # the drag alone has not moved the camera yet. + if not is_equal_approx(main.get_node("Camera2D").zoom.x, original_zoom): + return _fail_now("the aim zoom jumped instead of easing") + for _step in range(40): + skill._process(0.05) if main.get_node("Camera2D").zoom.x >= original_zoom: return _fail_now("aiming did not zoom out") - print("aiming zoomed the camera out") + print("aiming eased the camera out instead of cutting to it") - # #203: far enough out to show the WHOLE beam. The drag above is - # horizontal, so the screen's narrow half is the binding one. + # #203: framed to twice the distance of the chosen target, so what + # stands behind it is on screen too. The drag above is horizontal, + # so the screen's narrow half is the binding one. var half_screen: Vector2 = skill.get_viewport_rect().size * 0.5 \ - Vector2(54.0, 54.0) - var framed: float = (2900.0 + BEAM_HALF_WIDTH) \ + var aimed_distance: float = skill._aim_origin().distance_to(skill.aim_target) + var framed: float = (aimed_distance * 2.0 + BEAM_HALF_WIDTH) \ * main.get_node("Camera2D").zoom.x if framed > half_screen.x + 1.0: - return _fail_now("aiming framed %.0fpx of beam on %.0fpx of screen" \ + return _fail_now("framed %.0fpx of aim on %.0fpx of screen" \ % [framed, half_screen.x]) - print("aiming framed the beam's full range") + print("aiming framed twice the distance to the target") # --- B. targets: two on the line, one off it, one past range --- for offset in [Vector2(200.0, 0.0), Vector2(500.0, 0.0)]: @@ -251,6 +263,31 @@ func _process(delta: float) -> bool: return _fail_now("the aim bent toward an enemy outside the assist cone") print("an enemy outside the cone left the aim where it was pointed") + # --- F. the drag names a DEPTH, so the aim can reach past a + # nearer target instead of always settling on the closest one. + var near_enemy: Node = load("res://enemies/Enemy.tscn").instantiate() + near_enemy.position = origin + Vector2(700.0, 0.0) + main.add_child(near_enemy) + near_enemy.speed = 0.0 + # Further out and off to one side, but still inside the assist + # cone: without depth this one is unreachable, because the nearer + # enemy wins the cone every time. + var far_enemy: Node = load("res://enemies/Enemy.tscn").instantiate() + far_enemy.position = origin + Vector2(2000.0, 0.0).rotated(deg_to_rad(10.0)) + main.add_child(far_enemy) + far_enemy.speed = 0.0 + + var near_drag := Vector2(700.0 / 2900.0 * DRAG_RADIUS, 0.0) + var near_pick: Vector2 = skill._calculate_aim_target(near_drag, origin) + if near_pick.distance_to(near_enemy.global_position) > 1.0: + return _fail_now("a short drag did not settle on the nearer enemy") + var far_drag := Vector2(2000.0 / 2900.0 * DRAG_RADIUS, 0.0) \ + .rotated(deg_to_rad(10.0)) + var far_pick: Vector2 = skill._calculate_aim_target(far_drag, origin) + if far_pick.distance_to(far_enemy.global_position) > 1.0: + return _fail_now("dragging further did not reach past the nearer enemy") + print("drag depth selects between a near and a far target in the same cone") + print("TEST_LASER_VESSEL: PASS") quit(0) return true