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 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: 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) 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. They are taken away together, once, # the first time the player uses a keyboard or a mouse. # # Two guards, both deliberate. A touch device that ALSO has a keyboard keeps # everything, because a finger is still a way to play it. And a device wrongly # reported as non-touch keeps its controls until the player has demonstrably # used something else - so nobody is ever left with no way to steer or shoot. func _note_keyboard() -> void: if keyboard_seen: return keyboard_seen = true if DisplayServer.is_touchscreen_available(): return for control in [joystick, fire_stick, pivot_cw_button, pivot_ccw_button]: if control != null: control.visible = false 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: 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() elif event is InputEventMouseButton and event.pressed: _note_keyboard() # 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: # 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 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: 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 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)