449 lines
18 KiB
GDScript
449 lines
18 KiB
GDScript
extends Node2D
|
|
|
|
@export var min_zoom: float = 0.4
|
|
@export var max_zoom: float = 6.0
|
|
@export var camera_follow_speed: float = 5.0
|
|
# Distance (world units) below which the camera snaps exactly onto the fleet
|
|
# instead of continuing to lerp. Without this the exponential lerp only ever
|
|
# approaches the target asymptotically and NEVER arrives, so the camera keeps
|
|
# drifting by a fraction of a pixel every single frame - forever, even with
|
|
# the fleet completely stationary. That kept the background shaders' camera_pos
|
|
# uniform changing every frame, which made screen tearing permanently visible
|
|
# as horizontal bands of the background shifted sideways against each other.
|
|
# Same class of bug (and same fix) as the ship arrival oscillation.
|
|
@export var camera_snap_distance: float = 0.5
|
|
@export var debug_input_logging: bool = false
|
|
|
|
@onready var camera: Camera2D = $Camera2D
|
|
@onready var fleet: Node2D = $Fleet
|
|
# Optional: scenes without a joystick (none currently) still work.
|
|
@onready var joystick: TouchJoystick = get_node_or_null("UI/Joystick")
|
|
# The right-hand stick: aims and fires. Optional for the same reason as the
|
|
# movement stick - a scene without one still runs, with the guns silent.
|
|
@onready var fire_stick: TouchJoystick = get_node_or_null("UI/FireStick")
|
|
# Navigation section. Optional so a scene without it still works.
|
|
@onready var pivot_cw_button: IconButton = get_node_or_null("UI/PivotCWButton")
|
|
@onready var pivot_ccw_button: IconButton = get_node_or_null("UI/PivotCCWButton")
|
|
@onready var gas_bomb_controller: GasBombController = get_node_or_null("GasBombController")
|
|
# Untyped on purpose: LaserController resolves fine as a static type when a
|
|
# script IS the -s entry point, but the same class_name reference fails to
|
|
# parse ("Could not find type") when the script holding it is only reached
|
|
# indirectly - e.g. Main.tscn loaded from inside a _screenshot_tool.gd
|
|
# preview - for a class_name this new. Node + duck-typed calls sidesteps it.
|
|
@onready var laser_controller: Node = get_node_or_null("LaserController")
|
|
@onready var dash_controller: Node = get_node_or_null("DashController")
|
|
@onready var speed_fader: Node = get_node_or_null("UI/SpeedFader")
|
|
|
|
var touches: Dictionary = {}
|
|
var pinch_active: bool = false
|
|
var pinch_start_distance: float = 0.0
|
|
var pinch_start_zoom: float = 1.0
|
|
|
|
|
|
|
|
# Held pivot buttons turn the formation continuously at this rate; a quick
|
|
# tap that accumulates less than PIVOT_MIN_STEP is topped up to it on
|
|
# release, so tapping still produces a clear, visible nudge.
|
|
const PIVOT_SPEED: float = 1.6
|
|
const PIVOT_MIN_STEP: float = PI / 10.0
|
|
# #72: the tap top-up below used to fire in full on EVERY release, with no
|
|
# regard for how recently the last one landed - so tapping fast enough
|
|
# granted rotation far above PIVOT_SPEED's smooth rate, well beyond what
|
|
# holding the button can ever produce. Each of those artificially-frequent
|
|
# re-seats (Fleet.apply_formation()) forces every ship into a fresh
|
|
# max-speed catch-up dash toward its new slot with no time to settle
|
|
# between them, and since outer-wedge ships swing further per pivot than
|
|
# inner ones, spamming made them visibly diverge in speed ("desync") while
|
|
# each individual ship's own bounded max_speed made the fleet as a whole
|
|
# read as constantly at its speed ceiling ("build up speed"). This cooldown
|
|
# is exactly the time a genuine hold needs to reach PIVOT_MIN_STEP on its
|
|
# own, so tap-spam can't out-pace holding the button down.
|
|
const PIVOT_TAP_COOLDOWN: float = PIVOT_MIN_STEP / PIVOT_SPEED
|
|
|
|
var pivot_hold_amount: float = 0.0
|
|
# Tracked PER DIRECTION, not shared - a single quick correction (tap the
|
|
# other way right after an overshoot) is normal play, not the spam this
|
|
# cooldown targets, and Q/E each independently producing their own minimum
|
|
# step back-to-back is an existing, tested guarantee (test_keyboard_input.gd).
|
|
# Only repeatedly re-tapping the SAME direction needs to be capped.
|
|
var pivot_cw_tap_cooldown_left: float = 0.0
|
|
var pivot_ccw_tap_cooldown_left: float = 0.0
|
|
# Touch index currently holding each pivot button, or -1. These buttons are
|
|
# driven straight from raw touch events rather than through _gui_input,
|
|
# because Godot's Control GUI treats touch as a single emulated pointer and
|
|
# will not reliably deliver a SECOND simultaneous finger to another
|
|
# Control - which meant pivoting while steering with the joystick silently
|
|
# did nothing. The joystick already bypasses the GUI for the same reason.
|
|
var pivot_cw_touch: int = -1
|
|
var pivot_ccw_touch: int = -1
|
|
|
|
# --- keyboard and mouse ----------------------------------------------------
|
|
# The touch UI is the whole story on a phone. On a machine with a keyboard -
|
|
# which in practice means the web build - WASD or the arrow keys steer, Q/E
|
|
# pivot the formation, and the wheel zooms. Clicking the icon buttons already
|
|
# works without any code here, because the project emulates touch from the
|
|
# mouse (see project.godot), which is the same path the buttons already read.
|
|
#
|
|
# Keys are tracked from events rather than polled off the Input singleton so
|
|
# the whole thing is drivable from a headless test.
|
|
const KEY_DIRECTIONS: Dictionary = {
|
|
KEY_W: Vector2.UP, KEY_UP: Vector2.UP,
|
|
KEY_S: Vector2.DOWN, KEY_DOWN: Vector2.DOWN,
|
|
KEY_A: Vector2.LEFT, KEY_LEFT: Vector2.LEFT,
|
|
KEY_D: Vector2.RIGHT, KEY_RIGHT: Vector2.RIGHT,
|
|
}
|
|
const KEY_PIVOT_CW: int = KEY_E
|
|
const KEY_PIVOT_CCW: int = KEY_Q
|
|
const WHEEL_ZOOM_STEP: float = 1.12
|
|
|
|
|
|
var keys_down: Dictionary = {}
|
|
var keyboard_seen: bool = false
|
|
var keyboard_steering: bool = false
|
|
|
|
|
|
func _ready() -> void:
|
|
# #148: the main theme, which outlives this scene - see Music.ensure().
|
|
# This script is the root of BOTH the space scene and the planet
|
|
# surface, so one call here covers the whole game.
|
|
Music.ensure(get_tree())
|
|
if joystick != null:
|
|
joystick.direction_changed.connect(_on_joystick_direction)
|
|
|
|
|
|
func _held_pivot_direction() -> float:
|
|
var dir: float = 0.0
|
|
if pivot_cw_button != null and pivot_cw_button.pressed_down:
|
|
dir += 1.0
|
|
if pivot_ccw_button != null and pivot_ccw_button.pressed_down:
|
|
dir -= 1.0
|
|
if keys_down.has(KEY_PIVOT_CW):
|
|
dir += 1.0
|
|
if keys_down.has(KEY_PIVOT_CCW):
|
|
dir -= 1.0
|
|
return clampf(dir, -1.0, 1.0)
|
|
|
|
|
|
# #153: "after a dialog popup the godot input system is lost on keypress. If
|
|
# I had a key held it keeps moving in the key direction."
|
|
#
|
|
# It was not lost - it was never delivered. A dialog stops the tree (#128),
|
|
# a paused node receives no input, and this controller is paused along with
|
|
# everything else. So a key RELEASED while the line was up never reached
|
|
# _handle_key, keys_down kept it, and the fleet flew on in that direction
|
|
# once the game restarted.
|
|
#
|
|
# NOTIFICATION_UNPAUSED arrives whatever a node's process mode is, which
|
|
# makes it the one moment where the question can be asked. The physical
|
|
# keyboard is the authority on what is actually held down; keys_down is only
|
|
# a record of what we were told, and it is the record that went stale.
|
|
func _notification(what: int) -> void:
|
|
if what == NOTIFICATION_UNPAUSED:
|
|
_forget_released_keys()
|
|
|
|
|
|
func _forget_released_keys() -> void:
|
|
for key in keys_down.keys():
|
|
if not Input.is_key_pressed(key):
|
|
keys_down.erase(key)
|
|
|
|
|
|
func keyboard_direction() -> Vector2:
|
|
var dir: Vector2 = Vector2.ZERO
|
|
for key in KEY_DIRECTIONS:
|
|
if keys_down.has(key):
|
|
dir += KEY_DIRECTIONS[key]
|
|
return Vector2.ZERO if dir == Vector2.ZERO else dir.normalized()
|
|
|
|
|
|
func _handle_key(event: InputEventKey) -> void:
|
|
if event.echo:
|
|
return
|
|
if event.pressed:
|
|
keys_down[event.keycode] = true
|
|
_note_keyboard()
|
|
else:
|
|
keys_down.erase(event.keycode)
|
|
# A quick tap of a pivot key gets the same minimum step a tapped
|
|
# button does, rather than turning by a barely-visible sliver.
|
|
if event.keycode == KEY_PIVOT_CW:
|
|
_finish_pivot_tap(1.0)
|
|
elif event.keycode == KEY_PIVOT_CCW:
|
|
_finish_pivot_tap(-1.0)
|
|
|
|
|
|
# #85: on a computer the on-screen sticks and the pivot pair are dead weight -
|
|
# WASD steers, the mouse aims, Q/E pivot.
|
|
#
|
|
# #171: they now come and GO, rather than being taken away once and for all.
|
|
#
|
|
# The old rule asked DisplayServer.is_touchscreen_available() and, if it said
|
|
# yes, kept everything for ever - because the failure to avoid is a phone left
|
|
# with no way to steer. In a browser that query says yes on half the laptops
|
|
# in the world, so the web build showed thumb sticks to people holding a
|
|
# mouse, which is the ticket. It also could not be undone: a wrong answer the
|
|
# other way stranded a tablet with no controls until it was restarted.
|
|
#
|
|
# Following the LAST INPUT ACTUALLY USED fixes both ends. Touch the screen and
|
|
# the controls are there; use the mouse or the keyboard and they go. Nobody is
|
|
# ever stranded, because the gesture that needs them is the same gesture that
|
|
# brings them back, and it takes one tap. GameState owns the reading (see
|
|
# note_touch_input) so the mouse-aiming gate cannot disagree with this.
|
|
func _note_keyboard() -> void:
|
|
keyboard_seen = true
|
|
GameState.note_pointer_input()
|
|
_sync_touch_controls()
|
|
|
|
|
|
func _note_touch() -> void:
|
|
GameState.note_touch_input()
|
|
_sync_touch_controls()
|
|
|
|
|
|
func _sync_touch_controls() -> void:
|
|
var offered: bool = GameState.pointer_is_touch
|
|
for control in [joystick, fire_stick, pivot_cw_button, pivot_ccw_button]:
|
|
if control != null and control.visible != offered:
|
|
control.visible = offered
|
|
|
|
|
|
func _button_holds(button: IconButton, at_position: Vector2) -> bool:
|
|
return button != null and Rect2(button.global_position, button.size).has_point(at_position)
|
|
|
|
|
|
func _set_button_held(button: IconButton, held: bool) -> void:
|
|
if button == null or button.pressed_down == held:
|
|
return
|
|
button.pressed_down = held
|
|
button.queue_redraw()
|
|
|
|
|
|
# Returns true when a pivot button consumed the touch.
|
|
func _handle_pivot_touch(event: InputEventScreenTouch) -> bool:
|
|
if event.pressed:
|
|
if _button_holds(pivot_cw_button, event.position) and pivot_cw_touch == -1:
|
|
pivot_cw_touch = event.index
|
|
_set_button_held(pivot_cw_button, true)
|
|
return true
|
|
if _button_holds(pivot_ccw_button, event.position) and pivot_ccw_touch == -1:
|
|
pivot_ccw_touch = event.index
|
|
_set_button_held(pivot_ccw_button, true)
|
|
return true
|
|
return false
|
|
|
|
if event.index == pivot_cw_touch:
|
|
pivot_cw_touch = -1
|
|
_set_button_held(pivot_cw_button, false)
|
|
_finish_pivot_tap(1.0)
|
|
return true
|
|
if event.index == pivot_ccw_touch:
|
|
pivot_ccw_touch = -1
|
|
_set_button_held(pivot_ccw_button, false)
|
|
_finish_pivot_tap(-1.0)
|
|
return true
|
|
return false
|
|
|
|
|
|
func _update_pivot(delta: float) -> void:
|
|
var dir: float = _held_pivot_direction()
|
|
if dir == 0.0:
|
|
return
|
|
var amount: float = PIVOT_SPEED * delta * dir
|
|
pivot_hold_amount += absf(amount)
|
|
_pivot(amount)
|
|
|
|
|
|
func _finish_pivot_tap(direction: float) -> void:
|
|
# Fired on release. A press held long enough has already turned far
|
|
# enough on its own; a brief tap gets topped up to the minimum step -
|
|
# but only once THAT DIRECTION's own cooldown has cleared, so spamming
|
|
# one side can't grant rotation faster than holding the button would.
|
|
var cooldown_left: float = pivot_cw_tap_cooldown_left if direction > 0.0 else pivot_ccw_tap_cooldown_left
|
|
if pivot_hold_amount < PIVOT_MIN_STEP and cooldown_left <= 0.0:
|
|
_pivot((PIVOT_MIN_STEP - pivot_hold_amount) * direction)
|
|
if direction > 0.0:
|
|
pivot_cw_tap_cooldown_left = PIVOT_TAP_COOLDOWN
|
|
else:
|
|
pivot_ccw_tap_cooldown_left = PIVOT_TAP_COOLDOWN
|
|
pivot_hold_amount = 0.0
|
|
|
|
|
|
func _pivot(radians: float) -> void:
|
|
# Turns the formation and re-seats the ships already flying it, so the
|
|
# change is visible immediately rather than only on the next rebuild.
|
|
# apply_formation() writes the new offsets into Fleet.formation_offsets,
|
|
# which move_to()/move_in_direction() also read - so a pivot applied
|
|
# while the joystick is steering survives the next steering update
|
|
# instead of being overwritten by the old layout.
|
|
GameState.rotate_formation(radians)
|
|
fleet.apply_formation()
|
|
|
|
|
|
func _on_joystick_direction(direction: Vector2) -> void:
|
|
# Steering is re-issued every frame in _process while the stick is held,
|
|
# so this only needs to handle the release (stick back to centre).
|
|
if direction == Vector2.ZERO:
|
|
fleet.stop()
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
pivot_cw_tap_cooldown_left = maxf(pivot_cw_tap_cooldown_left - delta, 0.0)
|
|
pivot_ccw_tap_cooldown_left = maxf(pivot_ccw_tap_cooldown_left - delta, 0.0)
|
|
_update_pivot(delta)
|
|
|
|
# Keep the heading fresh while a control is held. Fleet advances its own
|
|
# anchor in _process, so this only needs to report the direction. The
|
|
# keyboard takes precedence, and releasing the keys has to stop the fleet
|
|
# itself - unlike the stick, there is no centred-again signal to hook.
|
|
# #124: the breach drives. One check here covers the stick, the keyboard
|
|
# and the released-again path together - three separate gates would be
|
|
# three chances for one of them to keep steering.
|
|
if GameState.intro_locks_movement or GameState.encounter_locks_movement:
|
|
keyboard_steering = false
|
|
else:
|
|
_apply_steering()
|
|
|
|
var target: Vector2 = fleet.get_centroid()
|
|
if camera.global_position.distance_to(target) <= camera_snap_distance:
|
|
# Snap and stop dead - see camera_snap_distance. Assigning the exact
|
|
# target means a settled fleet produces a bit-identical camera position
|
|
# frame after frame, so the background shaders stop re-rendering a
|
|
# shifted image and tearing has nothing to reveal.
|
|
camera.global_position = target
|
|
return
|
|
var t: float = clamp(camera_follow_speed * delta, 0.0, 1.0)
|
|
camera.global_position = camera.global_position.lerp(target, t)
|
|
|
|
|
|
func _apply_steering() -> void:
|
|
var keyed: Vector2 = keyboard_direction()
|
|
if keyed != Vector2.ZERO:
|
|
keyboard_steering = true
|
|
fleet.move_in_direction(keyed)
|
|
elif keyboard_steering:
|
|
keyboard_steering = false
|
|
fleet.stop()
|
|
elif joystick != null and joystick.direction != Vector2.ZERO:
|
|
fleet.move_in_direction(joystick.direction)
|
|
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if event is InputEventScreenTouch:
|
|
_handle_touch(event)
|
|
elif event is InputEventScreenDrag:
|
|
_handle_drag(event)
|
|
elif event is InputEventMagnifyGesture:
|
|
_handle_magnify(event)
|
|
elif event is InputEventKey:
|
|
_handle_key(event)
|
|
elif event is InputEventMouseMotion:
|
|
_note_keyboard()
|
|
if speed_fader != null:
|
|
speed_fader.handle_mouse_motion(event)
|
|
elif event is InputEventMouseButton:
|
|
_note_keyboard()
|
|
# #118: the fader gets first refusal, so dragging the boost does not
|
|
# also pan or zoom the world behind it.
|
|
if speed_fader != null and speed_fader.handle_mouse_button(event):
|
|
return
|
|
if not event.pressed:
|
|
return
|
|
# Wheel zoom, the desktop equivalent of the pinch.
|
|
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
|
|
_set_zoom(camera.zoom.x * WHEEL_ZOOM_STEP)
|
|
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
|
_set_zoom(camera.zoom.x / WHEEL_ZOOM_STEP)
|
|
|
|
|
|
func _handle_touch(event: InputEventScreenTouch) -> void:
|
|
# #171: a finger on the glass is the one unambiguous piece of evidence
|
|
# that this is a touch device being played by touch, and it is recorded
|
|
# before anything gets a chance to claim the event - a touch swallowed by
|
|
# the joystick is still a touch.
|
|
_note_touch()
|
|
# The joystick gets first refusal on every touch, and only claims ones
|
|
# that begin inside its own rect - so pinch-to-zoom still works anywhere
|
|
# else on screen.
|
|
if gas_bomb_controller != null and gas_bomb_controller.handle_touch(event):
|
|
return
|
|
if laser_controller != null and laser_controller.handle_touch(event):
|
|
return
|
|
if dash_controller != null and dash_controller.handle_touch(event):
|
|
return
|
|
if speed_fader != null and speed_fader.handle_touch(event):
|
|
return
|
|
if _handle_pivot_touch(event):
|
|
return
|
|
if joystick != null and joystick.visible and joystick.handle_touch(event):
|
|
return
|
|
# Order between the two sticks does not matter: each only claims touches
|
|
# that begin inside its own rect, and their rects do not overlap.
|
|
if fire_stick != null and fire_stick.visible and fire_stick.handle_touch(event):
|
|
return
|
|
|
|
if event.pressed:
|
|
touches[event.index] = event.position
|
|
if touches.size() == 2:
|
|
_start_pinch()
|
|
if debug_input_logging:
|
|
print("DEBUG touch pressed idx=%d pos=%s active_touches=%d" % [event.index, event.position, touches.size()])
|
|
else:
|
|
touches.erase(event.index)
|
|
if touches.size() < 2:
|
|
pinch_active = false
|
|
if debug_input_logging:
|
|
print("DEBUG touch released idx=%d active_touches=%d" % [event.index, touches.size()])
|
|
|
|
|
|
func _handle_drag(event: InputEventScreenDrag) -> void:
|
|
# A finger moving on the glass, same as a finger arriving on it (#171).
|
|
_note_touch()
|
|
if gas_bomb_controller != null and gas_bomb_controller.handle_drag(event):
|
|
return
|
|
if laser_controller != null and laser_controller.handle_drag(event):
|
|
return
|
|
if dash_controller != null and dash_controller.handle_drag(event):
|
|
return
|
|
if speed_fader != null and speed_fader.handle_drag(event):
|
|
return
|
|
if joystick != null and joystick.visible and joystick.handle_drag(event):
|
|
return
|
|
if fire_stick != null and fire_stick.visible and fire_stick.handle_drag(event):
|
|
return
|
|
if not touches.has(event.index):
|
|
return
|
|
touches[event.index] = event.position
|
|
|
|
if pinch_active and touches.size() == 2 and pinch_start_distance > 0.0:
|
|
var positions: Array = touches.values()
|
|
var current_distance: float = positions[0].distance_to(positions[1])
|
|
# Spreading the fingers apart zooms IN. Camera2D.zoom is a
|
|
# magnification (larger = closer), so the ratio goes
|
|
# current/start, not start/current.
|
|
var new_zoom: float = pinch_start_zoom * (current_distance / max(pinch_start_distance, 1.0))
|
|
_set_zoom(new_zoom)
|
|
|
|
if debug_input_logging:
|
|
print("DEBUG pinch drag distance=%.1f zoom=%.3f" % [current_distance, camera.zoom.x])
|
|
|
|
|
|
func _handle_magnify(event: InputEventMagnifyGesture) -> void:
|
|
# Fallback path: some platforms (and desktop trackpads) report pinch as a
|
|
# single semantic gesture instead of two raw touch streams. factor > 1
|
|
# means the gesture is spreading outward, which zooms in - and since
|
|
# Camera2D.zoom is a magnification, that multiplies.
|
|
_set_zoom(camera.zoom.x * event.factor)
|
|
if debug_input_logging:
|
|
print("DEBUG magnify gesture factor=%.3f zoom=%.3f" % [event.factor, camera.zoom.x])
|
|
|
|
|
|
func _start_pinch() -> void:
|
|
pinch_active = true
|
|
var positions: Array = touches.values()
|
|
pinch_start_distance = positions[0].distance_to(positions[1])
|
|
pinch_start_zoom = camera.zoom.x
|
|
|
|
|
|
func _set_zoom(raw_zoom: float) -> void:
|
|
var z: float = clamp(raw_zoom, min_zoom, max_zoom)
|
|
camera.zoom = Vector2(z, z)
|