Landed as one piece across #122, #123 and #124: the frame is useless without a script, and the script has nowhere to run without the frame. The director drives the REAL scene rather than being a scene of its own. It wants the real fleet, the real enemies and the real asteroid field, and it makes "then the game starts" cost nothing - the director stops steering, clears the flag, and what is left running is the ordinary game. The script is a list of beats, each with a deadline as well as its own completion test, so a beat whose condition is never met - a player who refuses to fly at the ambush - runs out rather than wedging the prologue. skip() ends the whole thing at any point and lands in exactly the state playing it through does, so the game has one way to start rather than two that have to be kept in step. The test pins that equivalence. "No action button available, only movements" is enforced where the buttons are decided, not by the director reaching in: the ability controllers set their own visibility every frame and would have put them straight back, and the map button belongs to the map. The joystick, the pivot and the fader stay. The convoy is ten hulls where a new game's fleet is one, so it needs an explicit fleet-size override - a plain number rather than a fake doctrine, because nothing about the player's own progression should be bent to fit a cutscene. It is carriers rather than transporters: transporters belong to hubs and are not fleet-eligible, so a convoy of them would be silently filtered down to its escorts. The ambush is chasers and only chasers. "Easy enough" is the requirement, and a sniper against a convoy the player cannot yet fight properly is neither easy nor a good first impression. The breach takes the wheel through one check in GameController, covering the stick, the keyboard and the released-again path together - three separate gates would be three chances for one of them to keep steering. The shake is written to camera.offset, never to its position, because GameController owns where the camera is and would smooth a shake away. The vessel that survives is chosen as the interceptor rather than whichever happened to be first in the list, and the prologue ends on exactly a new game's opening position. Also: PlanetAtmosphereController no longer spawns the opening fleet while the prologue is arming, and the tests that are about what the GAME starts from now say so by turning the prologue off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
7.8 KiB
GDScript
247 lines
7.8 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()
|
|
# #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("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
|