Everything lived in one flat directory, so a diff gave no clue what area it touched and finding the file you wanted meant knowing its exact name. Twelve folders now, grouped by what a thing IS rather than what type it is - a vessel's definition, its component scenes and its upgrade resources sit together, because that is the set you read when you change one of them. buildings core controllers enemies logistics planets resources shaders surface ui vessels vfx world 332 files moved and every res:// reference rewritten - 306 files touched, including the autoload and main-scene paths in project.godot. Nothing renamed: this is purely where files sit, so the diff is a pile of pure moves and no behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at the root, where Godot and the export preset expect them. Verified three ways, because a suite alone would not have caught all of it: the full 117 pass, the web export builds clean, and the game renders. The export is why that matters. The FIRST export after the move failed with "Attempt to open script 'res://GameState.gd'" - the old path - while project.godot on disk plainly said res://core/GameState.gd. That is this project's documented Android FUSE mtime trap: Godot served a cached copy of a file whose content had changed. A `touch` fixed it. Nothing in the test suite would have found that, because the suite runs from a copy on a native filesystem - the one place the bug cannot happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
366 lines
15 KiB
GDScript
366 lines
15 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 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)
|
|
|
|
|
|
func _note_keyboard() -> void:
|
|
if keyboard_seen:
|
|
return
|
|
keyboard_seen = true
|
|
# The stick is only taken away where there is no touchscreen to work it
|
|
# with. A touch device that also has a keyboard keeps both, and a device
|
|
# wrongly reported as non-touch still shows the stick until the player
|
|
# has demonstrably used a keyboard - so nobody is ever left with no way
|
|
# to steer.
|
|
if joystick != null and not DisplayServer.is_touchscreen_available():
|
|
joystick.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.
|
|
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)
|
|
|
|
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 _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 InputEventMouseButton and event.pressed:
|
|
# 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)
|