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>
This commit is contained in:
Loïc Wisniewski 2026-08-10 18:41:58 +02:00
commit 85814ce88c
192 changed files with 9602 additions and 0 deletions

View file

@ -0,0 +1,124 @@
extends Node2D
# Space-side: bootstraps the fleet on Main.tscn startup (a fresh 5-ship
# fleet at the origin on first launch, or GameState's surviving ship
# count/position if we just returned from a planet's surface), shows an
# "enter atmosphere" prompt (name + button) when the fleet is standing on
# a landable planet, and - on press - fades out and swaps to a completely
# separate PlanetSurface.tscn scene (not an overlay toggled in place), so
# asteroids/enemies genuinely cannot exist there rather than merely being
# hidden. Fleet size and the world position/zoom to return to are handed
# off via the GameState autoload, since change_scene_to_file() tears down
# this whole scene tree.
@export var fade_duration: float = 0.4
@export var prompt_debounce_time: float = 0.15
@onready var planet_field: Node2D = get_node("../PlanetField")
@onready var asteroid_field: Node2D = get_node("../AsteroidField")
@onready var fleet: Node2D = get_node("../Fleet")
@onready var camera: Camera2D = get_node("../Camera2D")
@onready var planet_name_label: Label = get_node("../UI/PlanetNameLabel")
@onready var enter_button: IconButton = get_node("../UI/EnterAtmosphereButton")
@onready var world_map: Control = get_node("../UI/WorldMap")
@onready var build_controller: Node2D = get_node("../BuildController")
@onready var fade_rect: ColorRect = get_node("../FadeOverlay/FadeRect")
var current_planet: Node2D = null
var transitioning: bool = false
var pending_planet: Node2D = null
var pending_time: float = 0.0
func _ready() -> void:
enter_button.pressed.connect(_on_enter_pressed)
if GameState.has_return_position:
fleet.spawn_ships(GameState.surviving_ship_count, GameState.return_world_position)
camera.position = GameState.return_world_position
camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom)
GameState.has_return_position = false
else:
fleet.spawn_ships(5, Vector2.ZERO)
# AsteroidField/PlanetField normally only populate nearby chunks once
# their own check_interval timer elapses (~1s) - fine during normal
# continuous play (things appear far off-screen, gradually), but
# right after a fresh scene load it means an empty-looking world for
# a second before asteroids/the planet visibly pop in. Force an
# immediate synchronous populate now that the fleet is correctly
# positioned, so nothing is missing on the very first visible frame.
asteroid_field.update_chunks()
planet_field.update_chunks()
func _process(delta: float) -> void:
if transitioning:
return
if world_map.visible or build_controller.is_showing_building_prompt():
# EnterAtmosphereButton shares one "action" slot with the map's
# RecenterButton and with BuildController's building prompt - it doesn't make sense to enter
# a planet's atmosphere while looking at the star map anyway, so
# suppress the prompt entirely rather than let it sit hidden
# behind the map's backdrop only to pop back in the instant the
# map closes.
current_planet = null
pending_planet = null
planet_name_label.visible = false
enter_button.visible = false
return
# Debounced: only commit a visible/hidden change once the raw
# landing-detection result has stayed the same for prompt_debounce_time
# in a row, instead of reacting to every single frame's reading. Belt
# and suspenders on top of get_planet_fleet_is_on() already using the
# fleet's centroid (a single stable number - see Planet.gd) rather
# than any individual ship's position, so this only has to absorb
# genuine sub-debounce-window boundary crossings, not constant noise.
var detected_planet: Node2D = planet_field.get_planet_fleet_is_on()
if detected_planet != pending_planet:
pending_planet = detected_planet
pending_time = 0.0
else:
pending_time += delta
if pending_time < prompt_debounce_time or pending_planet == current_planet:
return
current_planet = pending_planet
if current_planet != null:
planet_name_label.text = current_planet.planet_name
planet_name_label.visible = true
enter_button.visible = true
else:
planet_name_label.visible = false
enter_button.visible = false
func _on_enter_pressed() -> void:
if transitioning or current_planet == null:
return
transitioning = true
planet_name_label.visible = false
enter_button.visible = false
GameState.has_return_position = true
GameState.surviving_ship_count = fleet.ships.size()
# The planet's own position, not fleet.get_centroid() - the fleet only
# needs to be somewhere within the planet's landing radius (not
# centered exactly on it) for the prompt to show, so the centroid can
# be tens of units off from the planet. Returning should always place
# the fleet back at the planet itself.
GameState.return_world_position = current_planet.position
GameState.return_camera_zoom = camera.zoom.x
GameState.planet_name = current_planet.planet_name
GameState.planet_type = current_planet.planet_type
var tween: Tween = create_tween()
tween.tween_property(fade_rect, "color:a", 1.0, fade_duration)
tween.tween_callback(_enter_surface)
func _enter_surface() -> void:
get_tree().change_scene_to_file("res://PlanetSurface.tscn")