249 lines
7.8 KiB
GDScript
249 lines
7.8 KiB
GDScript
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
|