Aim the laser by depth, and ease the aim view

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
This commit is contained in:
Loïc Wisniewski 2026-09-05 09:32:42 +02:00
commit 2239235993
5 changed files with 123 additions and 28 deletions

View file

@ -8,6 +8,17 @@ const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
# shot effect, and effect visuals. # shot effect, and effect visuals.
const AIM_DEADZONE: float = 22.0 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 @export var minimum_zoom: float = 0.4
@onready var fleet: Node2D = get_node("../Fleet") @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_screen: Vector2 = Vector2.ZERO
var aim_target: Vector2 = Vector2.ZERO var aim_target: Vector2 = Vector2.ZERO
var saved_zoom: float = 1.0 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 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: if status.text != "" and Time.get_ticks_msec() >= status_message_until:
status.text = "" status.text = ""
if restoring_zoom and not aiming: if aiming:
var zoom: float = move_toward(camera.zoom.x, saved_zoom, _ease_zoom(desired_zoom, delta)
maxf(saved_zoom, 0.1) * delta * 5.0) elif restoring_zoom:
camera.zoom = Vector2(zoom, zoom) _ease_zoom(saved_zoom, delta)
if is_equal_approx(zoom, saved_zoom): if is_equal_approx(camera.zoom.x, saved_zoom):
restoring_zoom = false restoring_zoom = false
_advance_effects(delta) _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 # Recorded before firing because _fire() ends by restoring it, and a stale
# value from an earlier aim would snap the camera somewhere unrelated. # value from an earlier aim would snap the camera somewhere unrelated.
saved_zoom = camera.zoom.x saved_zoom = camera.zoom.x
desired_zoom = saved_zoom
restoring_zoom = false restoring_zoom = false
aim_target = world_target aim_target = world_target
_fire() _fire()
@ -96,6 +111,7 @@ func handle_touch(event: InputEventScreenTouch) -> bool:
aim_start_screen = event.position aim_start_screen = event.position
aim_screen = event.position aim_screen = event.position
saved_zoom = camera.zoom.x saved_zoom = camera.zoom.x
desired_zoom = saved_zoom
restoring_zoom = false restoring_zoom = false
button.pressed_down = true button.pressed_down = true
button.queue_redraw() button.queue_redraw()
@ -165,9 +181,18 @@ func _update_aim_zoom(origin: Vector2, target: Vector2) -> void:
else half_screen.x / (absf(displacement.x) + padding) else half_screen.x / (absf(displacement.x) + padding)
var fit_y: float = saved_zoom if absf(displacement.y) < 1.0 \ var fit_y: float = saved_zoom if absf(displacement.y) < 1.0 \
else half_screen.y / (absf(displacement.y) + padding) 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) 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: func _finish_aim() -> void:

View file

@ -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. # #203's range mark and the assist's target ring, in screen pixels.
const END_TICK_PIXELS: float = 17.0 const END_TICK_PIXELS: float = 17.0
const TARGET_RING_PIXELS: float = 15.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 BEAM_HALF_WIDTH := BEAM_EFFECT.BEAM_HALF_WIDTH
const FLASH_DURATION := BEAM_EFFECT.FLASH_DURATION const FLASH_DURATION := BEAM_EFFECT.FLASH_DURATION
const ENERGY_PER_SHOT: int = VesselLaserComponent.ENERGY_PER_SHOT 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: 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 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 # 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() return beam.tip()
# #203: the aim view frames the beam's WHOLE reach, not the point the aim # #203: framed around the chosen target rather than around the fleet. The
# happens to sit on. The assist can settle that point on an enemy a few # inherited floor on the zoom was sized for the gas bomb's 950 units and left
# hundred units away (#201), and fitting the camera to it would say the shot # most of a 2900-unit aim off screen; the laser's own floor lets this fit.
# stops there. Fitting the far end instead is also what makes the range # Twice the target's distance, so what lies beyond it is on screen too - that
# visible at all: the beam is 2900 units long and the inherited floor on the # is the half the player is reaching into when they drag further out.
# zoom - sized for the gas bomb's 950 - left three quarters of it off screen.
func _update_aim_zoom(origin: Vector2, target: Vector2) -> void: 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: func _aim_heading(origin: Vector2, target: Vector2) -> Vector2:

View file

@ -46,12 +46,26 @@ func _ready() -> void:
press.position = button.global_position + button.size * 0.5 press.position = button.global_position + button.size * 0.5
press.pressed = true press.pressed = true
skill.handle_touch(press) skill.handle_touch(press)
var drag := InputEventScreenDrag.new() # Enemies BEFORE the drag, so the aim helper actually sees them. A nearer
drag.index = 9 # one on the heading and a further one just off it: the state this preview
drag.position = press.position + Vector2(-245.0, -235.0) # exists to show is the drag reaching past the first to name the second.
skill.handle_drag(drag) var heading: Vector2 = Vector2(-245.0, -235.0).normalized()
for t in [0.35, 0.6]: 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() 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) main.add_child(enemy)
enemy.speed = 0.0 # after add_child - _ready() sets it from the definition 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)

View file

@ -120,6 +120,12 @@ func _process(delta: float) -> bool:
drag.index = 7 drag.index = 7
drag.position = Vector2(350, 700) drag.position = Vector2(350, 700)
main._handle_drag(drag) 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: if main.get_node("Camera2D").zoom.x >= original_zoom:
return _fail_now("long aim did not zoom out") return _fail_now("long aim did not zoom out")

View file

@ -26,6 +26,10 @@ var skill: Node
# in a test script has been observed to corrupt method resolution for the # 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). # WHOLE script. See tests/test_gas_bomber_cooldowns.gd's identical fix (#60).
const BEAM_HALF_WIDTH: float = 38.0 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 on_line_enemies: Array = []
var off_line_enemy: Node var off_line_enemy: Node
var beyond_range_enemy: Node var beyond_range_enemy: Node
@ -120,20 +124,28 @@ func _process(delta: float) -> bool:
drag.index = 7 drag.index = 7
drag.position = press.position + Vector2(300.0, 0.0) drag.position = press.position + Vector2(300.0, 0.0)
main._handle_drag(drag) 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: if main.get_node("Camera2D").zoom.x >= original_zoom:
return _fail_now("aiming did not zoom out") 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 # #203: framed to twice the distance of the chosen target, so what
# horizontal, so the screen's narrow half is the binding one. # 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 \ var half_screen: Vector2 = skill.get_viewport_rect().size * 0.5 \
- Vector2(54.0, 54.0) - 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 * main.get_node("Camera2D").zoom.x
if framed > half_screen.x + 1.0: 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]) % [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 --- # --- 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)]: 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") 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") 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") print("TEST_LASER_VESSEL: PASS")
quit(0) quit(0)
return true return true