spaceship/AimedFleetAbilityController.gd
Loïc Wisniewski baac11ae8b Implement #80, #81, #82 and #83
#80 - desktop combat. The mouse aims, the left button fires, and the number
keys pick the weapon: 1 the fleet's guns, 2 and up the abilities. The twin
sticks are built for thumbs; on a desktop they mean dragging a virtual stick
with a pointer that could simply BE the crosshair.

The pointer path hands its heading to FireStickController rather than driving
weapons itself, so exactly one thing in the scene aims a gun, and a held stick
wins - a finger on it is a deliberate act, a mouse resting somewhere is not.
Abilities gained fire_at(), which aims and fires in one call instead of
pretending to be a press-drag-release touch.

It is gated on the absence of a touchscreen, because Godot emulates a mouse
button from every touch and without that a tap on a phone would fire the guns.
The gate has an explicit override, and not for tidiness: headless reports a
touchscreen, so with the automatic answer alone the aiming could never be
exercised by a test and the only covered behaviour would be the guard.

#81 - the bottom-left control hint is gone, from both scenes and from
GameController.

#82 - a chaser is destroyed by the impact when it reaches a vessel. It used to
pass through the wreck untouched and line up another pass, which made a
ramming enemy strictly better than any other. Data-driven per enemy
(dies_on_contact), because a sniper that happens to drift into a ship has not
spent itself doing it - and routed through take_damage, so it dies exactly as
it dies to gunfire. The enemy is told about the impact BEFORE the ship
resolves its own damage, since resolving it can free the ship.

#83 - the research tree's buy button is gone; tapping a node twice buys it.
Deliberately "the same node twice" rather than a timed double-tap: no window
to miss on a slow phone, no accidental purchase while browsing, and the first
tap always does something visible, so the second is never a blind commitment.
The status line now says "Tap again to research" - with the button gone it is
the only thing distinguishing an affordable node from an unaffordable one.

test_enemy_collision asserted the exact behaviour #82 reversed, and now checks
the ram costs both. Suite 117/117.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:49:47 +02:00

244 lines
7.6 KiB
GDScript

class_name AimedFleetAbilityController
extends Node2D
# Shared press-drag-release input and camera behavior for manually aimed fleet
# abilities. Subclasses provide the component, ammunition, target calculation,
# shot effect, and effect visuals.
const AIM_DEADZONE: float = 22.0
@export var minimum_zoom: float = 0.4
@onready var fleet: Node2D = get_node("../Fleet")
@onready var camera: Camera2D = get_node("../Camera2D")
var button: IconButton
var status: Label
var status_message_until: int = 0
var aiming: bool = false
var aim_touch: int = -1
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
var restoring_zoom: bool = false
func _ready() -> void:
button = get_node(_button_path())
status = get_node(_status_path())
button.visible = false
status.text = ""
queue_redraw()
func _process(delta: float) -> void:
var had_visuals: bool = aiming or _has_effect_visuals()
button.visible = fleet.has_capability(_component_id())
button.enabled = _next_ready_ship() != null and _can_afford_shot()
if not button.visible and aiming:
_cancel_aim()
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):
restoring_zoom = false
_advance_effects(delta)
if aiming or _has_effect_visuals() or had_visuals:
queue_redraw()
func can_begin_aim() -> bool:
# An unaffordable shot still claims the press so the status label can
# explain what is missing instead of silently passing the touch through.
return button.visible and not button._blocked_by_modal() \
and not aiming and _next_ready_ship() != null
# Fires immediately at a world point, with no press-drag-release. The mouse
# path (#80) has a crosshair already - it does not need to be dragged into
# existence - so it aims and fires in one call rather than pretending to be
# a touch.
func fire_at(world_target: Vector2) -> bool:
if not button.visible or button._blocked_by_modal() or _next_ready_ship() == null:
return false
if not _can_afford_shot():
_say(_unaffordable_message())
return false
# 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
restoring_zoom = false
aim_target = world_target
_fire()
return true
func handle_touch(event: InputEventScreenTouch) -> bool:
if event.pressed:
if not can_begin_aim() \
or not Rect2(button.global_position, button.size).has_point(event.position):
return false
if not _can_afford_shot():
_say(_unaffordable_message())
return true
aiming = true
aim_touch = event.index
aim_start_screen = event.position
aim_screen = event.position
saved_zoom = camera.zoom.x
restoring_zoom = false
button.pressed_down = true
button.queue_redraw()
_update_aim()
return true
if event.index != aim_touch:
return false
if aim_start_screen.distance_to(aim_screen) >= AIM_DEADZONE:
_fire()
elif _aim_at_nearest_enemy():
# Tapping the button used to do nothing at all - the press was claimed,
# the aim began, and releasing without dragging simply threw it away.
# A tap now fires at the nearest enemy in range; dragging remains the
# way to place a shot precisely.
_fire()
else:
_say("Nothing in range - drag from the button to aim")
_cancel_aim()
return true
# Points aim_target at the closest enemy within reach, or reports that there
# is none. Both aimed abilities read only the aim_target when they fire - the
# laser takes its DIRECTION and always extends to full range, the gas bomb
# takes the point itself - so setting it here is all auto-aim has to do, and
# each ability keeps its own meaning for it.
func _aim_at_nearest_enemy() -> bool:
var origin: Vector2 = _aim_origin()
var reach: float = _aim_max_range()
var nearest: Node2D = null
var nearest_distance: float = INF
for enemy in get_tree().get_nodes_in_group("enemies"):
if not is_instance_valid(enemy) or not (enemy is Node2D):
continue
var distance: float = origin.distance_to((enemy as Node2D).global_position)
if distance > reach or distance >= nearest_distance:
continue
nearest_distance = distance
nearest = enemy
if nearest == null:
return false
aim_target = nearest.global_position
queue_redraw()
return true
func handle_drag(event: InputEventScreenDrag) -> bool:
if not aiming or event.index != aim_touch:
return false
aim_screen = event.position
_update_aim()
return true
func _update_aim() -> void:
var origin: Vector2 = _aim_origin()
aim_target = _calculate_aim_target(aim_screen - aim_start_screen, origin)
_update_aim_zoom(origin, aim_target)
queue_redraw()
func _update_aim_zoom(origin: Vector2, target: Vector2) -> void:
var displacement: Vector2 = target - origin
var half_screen: Vector2 = get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0)
var padding: float = _aim_padding()
var fit_x: float = saved_zoom if absf(displacement.x) < 1.0 \
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)),
minimum_zoom, saved_zoom)
camera.zoom = Vector2(desired, desired)
func _finish_aim() -> void:
aiming = false
aim_touch = -1
button.pressed_down = false
button.queue_redraw()
restoring_zoom = true
func _cancel_aim() -> void:
_finish_aim()
queue_redraw()
func _fire() -> void:
var ship: Node2D = _next_ready_ship()
if ship == null:
_cancel_aim()
return
if not _can_afford_shot():
_say(_unaffordable_message())
_cancel_aim()
return
var component: Node = ship.get_component(_component_id())
if component == null or not _consume_shot(component):
_cancel_aim()
return
GameState.withdraw_fleet_resource(_ammo_resource(), _ammo_cost())
_perform_shot(ship, component)
_finish_aim()
button.enabled = _next_ready_ship() != null and _can_afford_shot()
func _next_ready_ship() -> Node2D:
for ship in fleet.ships:
if not is_instance_valid(ship):
continue
var component: Node = ship.get_component(_component_id())
if component != null and component.is_ready():
return ship
return null
func _can_afford_shot() -> bool:
return GameState.fleet_resource(_ammo_resource()) >= _ammo_cost()
func _unaffordable_message() -> String:
var resource_type: String = _ammo_resource()
return "Need %d %s in the fleet hold to fire (have %d)" % [
_ammo_cost(), GameState.resource_name(resource_type).to_lower(),
GameState.fleet_resource(resource_type),
]
func _say(message: String) -> void:
status.text = message
status_message_until = Time.get_ticks_msec() + 3000
func _aim_origin() -> Vector2:
return fleet.get_centroid()
# Subclass hooks.
func _button_path() -> NodePath: return NodePath()
func _status_path() -> NodePath: return NodePath()
func _component_id() -> String: return ""
func _ammo_resource() -> String: return ""
func _ammo_cost() -> int: return 0
func _aim_padding() -> float: return 0.0
# How far this ability can reach, for auto-aim and for the aim guide.
func _aim_max_range() -> float: return 0.0
func _has_effect_visuals() -> bool: return false
func _advance_effects(_delta: float) -> void: pass
func _consume_shot(_component: Node) -> bool: return false
func _perform_shot(_ship: Node2D, _component: Node) -> void: pass
func _calculate_aim_target(_drag: Vector2, origin: Vector2) -> Vector2: return origin