class_name AimedFleetAbilityController extends Node2D const COMBAT_TARGET = preload("res://core/CombatTarget.gd") # 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() # #122: "there's no action button available, only movements". Checked # here rather than by the director reaching in, because this line runs # every frame and would put the button straight back. button.visible = fleet.has_capability(_component_id()) and not GameState.intro_active 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(COMBAT_TARGET.GROUP): 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