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 # Seconds between automatic saves. Long enough that writing the file is # never felt, short enough that a lost run is an annoyance rather than an # evening. const AUTOSAVE_INTERVAL: float = 30.0 var autosave_countdown: float = AUTOSAVE_INTERVAL func _ready() -> void: # Most visuals still animate per rendered frame. The main/surface scenes opt # their roots out of global interpolation, then opt their physics-follow # camera and Ship.tscn hulls back in. # Running the camera after ordinary physics nodes lets it follow the ships' # newest centroid, and interpolation fills the display frames in between. process_physics_priority = 1 camera.reset_physics_interpolation() # #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()) # Same moment, same reason: build the sfx voices before the first # sound needs them rather than during it. Sfx.warm(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() # #225: the phone is where this is played, and Android kills an app in # the background without warning - so the run is written on the way out # rather than only on a timer. elif what == NOTIFICATION_WM_CLOSE_REQUEST \ or what == NOTIFICATION_APPLICATION_PAUSED: autosave() 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() # Written on a timer as well as on the way out: a crash or a battery pull # gives no notification at all, and losing half an hour is what makes a save # system feel absent even when it is there. func autosave() -> void: if GameState.intro_active: # Nothing worth keeping yet, and saving mid-prologue would resume # into a run that has not been set up. return SaveGame.save(GameState) autosave_countdown = AUTOSAVE_INTERVAL func _process(delta: float) -> void: autosave_countdown -= delta if autosave_countdown <= 0.0: autosave() 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 _physics_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() func _physics_process(delta: float) -> void: _update_camera(delta) func _update_camera(delta: float) -> void: 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)