Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
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")
|
|
|
|
|
# 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")
|
2026-08-15 04:45:15 +02:00
|
|
|
@onready var gas_bomb_controller: GasBombController = get_node_or_null("GasBombController")
|
Add laser ship vessel and its skill shot (#58)
A manually aimed instant beam, modelled on the gas bomber's press-drag-
release aiming (GasBombController/VesselGasBomberComponent) but firing
the moment the touch releases - no projectile travel time. Damage,
beam_range and cooldown are resolved per ship from base_stats/upgrades
(VesselLaserComponent.apply_stats(), the same pattern
VesselWeaponComponent uses) rather than hardcoded in the controller, so
the new damage/range/cooldown ship-lab upgrades actually reach them.
Cooldown lives on the component (per ship), not the controller (per
fleet) - two laser ships give two shots, the exact bug already fixed
for bombers in eb79efd.
LaserController.gd is reused unmodified by both Main.tscn and
PlanetSurface.tscn (relative ../Fleet, ../Camera2D, ../UI/LaserButton
paths, matching GasBombController), so it works identically in space
and on a landed surface. Wired into GameController's touch/drag
first-refusal chain alongside the bomb controller.
The crafter, hangar and ship-lab menus were already generic over
VesselDefinitions.fleet()/.all() - only the per-vessel Main.tscn nodes
are hand-placed, not the logic - so this repacks those rows to
5/5/6 columns and extends BuildController's onready arrays. No new
signal handlers were needed. Also fixes test_vessel_composition.gd's
hardcoded fleet-catalog size again (4 -> 5) now that laser is a fifth
fleet-eligible vessel.
Note for later tickets: a brand-new class_name script (LaserController)
resolves fine as a static type when its own file is the godot -s entry
point, but the identical type hint 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. Untyped
Node + duck-typed calls sidesteps it; GameController.gd's
laser_controller field documents this in place.
2026-08-16 17:29:53 +02:00
|
|
|
# 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")
|
2026-08-16 17:59:04 +02:00
|
|
|
@onready var speed_fader: Node = get_node_or_null("UI/SpeedFader")
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
var pivot_hold_amount: 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
|
|
|
|
|
|
|
|
|
|
@onready var keyboard_hint: Label = get_node_or_null("UI/KeyboardHintLabel")
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
if keyboard_hint != null:
|
|
|
|
|
keyboard_hint.visible = false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
if keyboard_hint != null:
|
|
|
|
|
keyboard_hint.visible = 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.
|
|
|
|
|
if pivot_hold_amount < PIVOT_MIN_STEP:
|
|
|
|
|
_pivot((PIVOT_MIN_STEP - pivot_hold_amount) * direction)
|
|
|
|
|
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:
|
|
|
|
|
_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.
|
2026-08-15 04:45:15 +02:00
|
|
|
if gas_bomb_controller != null and gas_bomb_controller.handle_touch(event):
|
|
|
|
|
return
|
Add laser ship vessel and its skill shot (#58)
A manually aimed instant beam, modelled on the gas bomber's press-drag-
release aiming (GasBombController/VesselGasBomberComponent) but firing
the moment the touch releases - no projectile travel time. Damage,
beam_range and cooldown are resolved per ship from base_stats/upgrades
(VesselLaserComponent.apply_stats(), the same pattern
VesselWeaponComponent uses) rather than hardcoded in the controller, so
the new damage/range/cooldown ship-lab upgrades actually reach them.
Cooldown lives on the component (per ship), not the controller (per
fleet) - two laser ships give two shots, the exact bug already fixed
for bombers in eb79efd.
LaserController.gd is reused unmodified by both Main.tscn and
PlanetSurface.tscn (relative ../Fleet, ../Camera2D, ../UI/LaserButton
paths, matching GasBombController), so it works identically in space
and on a landed surface. Wired into GameController's touch/drag
first-refusal chain alongside the bomb controller.
The crafter, hangar and ship-lab menus were already generic over
VesselDefinitions.fleet()/.all() - only the per-vessel Main.tscn nodes
are hand-placed, not the logic - so this repacks those rows to
5/5/6 columns and extends BuildController's onready arrays. No new
signal handlers were needed. Also fixes test_vessel_composition.gd's
hardcoded fleet-catalog size again (4 -> 5) now that laser is a fifth
fleet-eligible vessel.
Note for later tickets: a brand-new class_name script (LaserController)
resolves fine as a static type when its own file is the godot -s entry
point, but the identical type hint 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. Untyped
Node + duck-typed calls sidesteps it; GameController.gd's
laser_controller field documents this in place.
2026-08-16 17:29:53 +02:00
|
|
|
if laser_controller != null and laser_controller.handle_touch(event):
|
|
|
|
|
return
|
2026-08-16 17:59:04 +02:00
|
|
|
if speed_fader != null and speed_fader.handle_touch(event):
|
|
|
|
|
return
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
if _handle_pivot_touch(event):
|
|
|
|
|
return
|
|
|
|
|
if joystick != null and joystick.visible and joystick.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:
|
2026-08-15 04:45:15 +02:00
|
|
|
if gas_bomb_controller != null and gas_bomb_controller.handle_drag(event):
|
|
|
|
|
return
|
Add laser ship vessel and its skill shot (#58)
A manually aimed instant beam, modelled on the gas bomber's press-drag-
release aiming (GasBombController/VesselGasBomberComponent) but firing
the moment the touch releases - no projectile travel time. Damage,
beam_range and cooldown are resolved per ship from base_stats/upgrades
(VesselLaserComponent.apply_stats(), the same pattern
VesselWeaponComponent uses) rather than hardcoded in the controller, so
the new damage/range/cooldown ship-lab upgrades actually reach them.
Cooldown lives on the component (per ship), not the controller (per
fleet) - two laser ships give two shots, the exact bug already fixed
for bombers in eb79efd.
LaserController.gd is reused unmodified by both Main.tscn and
PlanetSurface.tscn (relative ../Fleet, ../Camera2D, ../UI/LaserButton
paths, matching GasBombController), so it works identically in space
and on a landed surface. Wired into GameController's touch/drag
first-refusal chain alongside the bomb controller.
The crafter, hangar and ship-lab menus were already generic over
VesselDefinitions.fleet()/.all() - only the per-vessel Main.tscn nodes
are hand-placed, not the logic - so this repacks those rows to
5/5/6 columns and extends BuildController's onready arrays. No new
signal handlers were needed. Also fixes test_vessel_composition.gd's
hardcoded fleet-catalog size again (4 -> 5) now that laser is a fifth
fleet-eligible vessel.
Note for later tickets: a brand-new class_name script (LaserController)
resolves fine as a static type when its own file is the godot -s entry
point, but the identical type hint 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. Untyped
Node + duck-typed calls sidesteps it; GameController.gd's
laser_controller field documents this in place.
2026-08-16 17:29:53 +02:00
|
|
|
if laser_controller != null and laser_controller.handle_drag(event):
|
|
|
|
|
return
|
2026-08-16 17:59:04 +02:00
|
|
|
if speed_fader != null and speed_fader.handle_drag(event):
|
|
|
|
|
return
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
if joystick != null and joystick.visible and joystick.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)
|