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

351
BuildController.gd Normal file
View file

@ -0,0 +1,351 @@
extends Node2D
# Space-side building UI. Owns:
# - the build menu (place a mothership, ship crafter or command centre)
# - the shared ACTION button, which adapts to whatever the fleet is
# standing on (craft ships at a crafter, configure formation at a
# command centre). The enter-atmosphere prompt uses the same slot, and
# PlanetAtmosphereController suppresses itself while a building prompt
# is showing so the two can never fight over it.
# - rebuilding a wiped-out fleet at the mothership
#
# Buildings themselves live in GameState (they must outlive the trip to a
# planet's surface); this only drives the UI and spends resources.
@export var prompt_debounce_time: float = 0.15
@onready var fleet: Node2D = get_node("../Fleet")
@onready var building_field: Node2D = get_node("../BuildingField")
@onready var world_map: Control = get_node("../UI/WorldMap")
@onready var build_button: IconButton = get_node("../UI/BuildButton")
@onready var action_button: IconButton = get_node("../UI/BuildActionButton")
@onready var action_label: Label = get_node("../UI/BuildActionLabel")
@onready var build_menu: Control = get_node("../UI/BuildMenu")
@onready var build_mothership_button: IconButton = get_node("../UI/BuildMenu/MothershipButton")
@onready var build_crafter_button: IconButton = get_node("../UI/BuildMenu/CrafterButton")
@onready var build_command_button: IconButton = get_node("../UI/BuildMenu/CommandButton")
@onready var build_close_button: IconButton = get_node("../UI/BuildMenu/CloseButton")
@onready var build_status: Label = get_node("../UI/BuildMenu/StatusLabel")
@onready var build_extractor_button: IconButton = get_node("../UI/BuildMenu/ExtractorButton")
@onready var planet_field: Node2D = get_node("../PlanetField")
@onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup")
@onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel")
@onready var info_message: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/MessageLabel")
@onready var info_cost: NeonNumber = get_node("../UI/BuildMenu/InfoPopup/Panel/CostNumber")
@onready var info_build_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/BuildButton")
@onready var info_close_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/CloseButton")
@onready var craft_menu: Control = get_node("../UI/CraftMenu")
@onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton")
@onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton")
@onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel")
@onready var formation_menu: Control = get_node("../UI/FormationMenu")
@onready var formation_editor: Control = get_node("../UI/FormationMenu/Editor")
@onready var formation_close_button: IconButton = get_node("../UI/FormationMenu/CloseButton")
@onready var formation_reset_button: IconButton = get_node("../UI/FormationMenu/ResetButton")
# What each option is and does, shown before anything is spent. Picking a
# structure is a real commitment - it costs ore, and a mothership placement
# can destroy the one you already have - so no option builds straight from
# the menu; each one explains itself first and waits for a confirmation.
#
# The bodies are kept short on purpose: at the UI's font size the popup holds
# roughly eight lines, and a placement condition or a price warning is
# appended below them and needs some of those lines.
const BUILDING_INFO: Dictionary = {
GameState.BUILDING_MOTHERSHIP: {
"title": "Mothership",
"body": "Fleet home port. A wiped-out fleet rebuilds here, and extractor haulers deliver ore here.",
},
GameState.BUILDING_SHIP_CRAFTER: {
"title": "Ship Crafter",
"body": "Fly the fleet onto it, then use the action button to build extra vessels.",
},
GameState.BUILDING_COMMAND_CENTER: {
"title": "Command Center",
"body": "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it.",
},
GameState.BUILDING_EXTRACTOR: {
"title": "Extractor",
"body": "Mines a planet and runs a hauler to your mothership. It keeps earning while you are away.",
},
}
# Which structure the popup is currently offering to build, or "" when it is
# only explaining something that cannot be built right now.
var pending_build_type: String = ""
var current_building: Node2D = null
var pending_building: Node2D = null
var pending_time: float = 0.0
func _ready() -> void:
build_button.pressed.connect(_on_build_pressed)
build_close_button.pressed.connect(func(): build_menu.visible = false)
build_mothership_button.pressed.connect(_on_place_mothership)
build_crafter_button.pressed.connect(_on_place_crafter)
build_command_button.pressed.connect(_on_place_command_center)
build_extractor_button.pressed.connect(_on_place_extractor)
info_close_button.pressed.connect(_close_info)
info_build_button.pressed.connect(_confirm_build)
action_button.pressed.connect(_on_action_pressed)
craft_interceptor_button.pressed.connect(_on_craft_interceptor)
craft_close_button.pressed.connect(func(): craft_menu.visible = false)
formation_close_button.pressed.connect(func(): formation_menu.visible = false)
formation_reset_button.pressed.connect(_on_reset_formation)
formation_editor.formation_changed.connect(_on_formation_changed)
build_menu.visible = false
info_popup.visible = false
craft_menu.visible = false
formation_menu.visible = false
action_button.visible = false
action_label.visible = false
# True while a building prompt or panel owns the shared action slot, so the
# enter-atmosphere prompt knows to stay out of the way.
func is_showing_building_prompt() -> bool:
return current_building != null or build_menu.visible or craft_menu.visible or formation_menu.visible
func _unhandled_input(event: InputEvent) -> void:
# The formation editor is driven from here rather than via _gui_input,
# for the same reason as the joystick: its Control must not swallow
# touches, or dragging a slot would never reach it.
if not formation_menu.visible:
return
if event is InputEventScreenTouch and formation_editor.handle_touch(event):
get_viewport().set_input_as_handled()
elif event is InputEventScreenDrag and formation_editor.handle_drag(event):
get_viewport().set_input_as_handled()
func _process(delta: float) -> void:
_check_fleet_wipe()
if build_menu.visible:
# Keep the greyed state live while the menu is open, so flying onto
# a planet lights the option up without reopening it.
build_extractor_button.enabled = _planet_under_fleet() != null
if world_map.visible or build_menu.visible or craft_menu.visible or formation_menu.visible:
current_building = null
pending_building = null
action_button.visible = false
action_label.visible = false
return
# Debounced exactly like the enter-atmosphere prompt, so the action
# button can't flicker while the fleet drifts on the interact boundary.
var detected: Node2D = building_field.get_building_fleet_is_on()
if detected != null and detected.building_type == GameState.BUILDING_MOTHERSHIP:
detected = null # nothing to do at a mothership beyond respawning
if detected != pending_building:
pending_building = detected
pending_time = 0.0
else:
pending_time += delta
if pending_time < prompt_debounce_time or pending_building == current_building:
return
current_building = pending_building
action_button.visible = current_building != null
action_label.visible = current_building != null
if current_building != null:
action_button.icon = IconButton.Icon.SHIP if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER else IconButton.Icon.MAP
action_label.text = current_building.get_display_name()
action_button.queue_redraw()
func _check_fleet_wipe() -> void:
if not fleet.ships.is_empty():
return
# Whole fleet destroyed: rebuild a single vessel at the mothership, or
# at the world origin when none has been built yet.
var spawn_pos: Variant = GameState.get_mothership_position()
fleet.spawn_ships(1, spawn_pos if spawn_pos != null else Vector2.ZERO)
func _on_action_pressed() -> void:
if current_building == null:
return
if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
craft_menu.visible = true
_refresh_craft_status()
elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER:
formation_menu.visible = true
formation_editor.queue_redraw()
func _on_build_pressed() -> void:
build_menu.visible = true
info_popup.visible = false
pending_build_type = ""
_refresh_build_status()
func _planet_under_fleet() -> Node2D:
# An extractor mines a specific planet, so one has to be within reach.
return planet_field.get_planet_fleet_is_on()
func _refresh_build_status() -> void:
# The extractor option is always listed, but greyed unless the fleet is
# actually over a planet - showing it only sometimes would leave the
# player wondering whether the game has it at all.
build_extractor_button.enabled = _planet_under_fleet() != null
# Prices are shown under each option now, so this only needs to say
# whether the fleet is somewhere an extractor could go.
if _planet_under_fleet() != null:
build_status.text = "Over %s - an extractor can be built here" % _planet_under_fleet().planet_name
else:
build_status.text = ""
func _on_place_mothership() -> void:
_show_building_info(GameState.BUILDING_MOTHERSHIP)
func _on_place_crafter() -> void:
_show_building_info(GameState.BUILDING_SHIP_CRAFTER)
func _on_place_command_center() -> void:
_show_building_info(GameState.BUILDING_COMMAND_CENTER)
func _on_place_extractor() -> void:
# The option stays pressable while greyed, so this is also how a
# not-over-a-planet extractor explains itself.
_show_building_info(GameState.BUILDING_EXTRACTOR)
func cost_of(type: String) -> int:
match type:
GameState.BUILDING_MOTHERSHIP:
return GameState.MOTHERSHIP_COST
GameState.BUILDING_SHIP_CRAFTER:
return GameState.SHIP_CRAFTER_COST
GameState.BUILDING_COMMAND_CENTER:
return GameState.COMMAND_CENTER_COST
_:
return GameState.EXTRACTOR_COST
func _show_building_info(type: String) -> void:
var info: Dictionary = BUILDING_INFO[type]
var cost: int = cost_of(type)
var lines: Array = [info.body]
var can_build: bool = true
# Anything that would stop this particular placement is spelled out in
# the same popup, rather than silently failing after the press.
if type == GameState.BUILDING_EXTRACTOR:
var planet: Node2D = _planet_under_fleet()
if planet == null:
lines.append("No planet in reach - fly over one.")
can_build = false
elif GameState.has_extractor_at(planet.planet_name):
lines.append("%s already has an extractor." % planet.planet_name)
can_build = false
else:
lines.append("Will mine %s." % planet.planet_name)
elif type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership():
lines.append("WARNING: only one may exist - this destroys your current one.")
if can_build and not GameState.can_afford(cost):
lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.rocks])
can_build = false
info_title.text = info.title
info_message.text = "\n\n".join(lines)
info_cost.amount = cost
info_build_button.visible = can_build
pending_build_type = type if can_build else ""
info_popup.visible = true
func _close_info() -> void:
info_popup.visible = false
pending_build_type = ""
func _confirm_build() -> void:
var type: String = pending_build_type
if type == "":
return
pending_build_type = ""
info_popup.visible = false
# Everything is re-checked here rather than trusted from when the popup
# opened: the fleet keeps flying while it is up, so it can drift off the
# planet an extractor was going to mine.
if type == GameState.BUILDING_EXTRACTOR:
var planet: Node2D = _planet_under_fleet()
if planet == null or GameState.has_extractor_at(planet.planet_name):
build_status.text = "No planet in reach any more"
return
if not GameState.spend(GameState.EXTRACTOR_COST):
build_status.text = "Not enough rocks (need %d, have %d)" % [GameState.EXTRACTOR_COST, GameState.rocks]
return
GameState.add_extractor(planet.planet_name, planet.position)
build_menu.visible = false
return
if type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership():
if not GameState.can_afford(GameState.MOTHERSHIP_COST):
build_status.text = "Not enough rocks (need %d, have %d)" % [
GameState.MOTHERSHIP_COST, GameState.rocks
]
return
# Only now is the old one destroyed - never before the new one is
# known to be affordable, or the player would be left with none.
GameState.remove_buildings_of_type(GameState.BUILDING_MOTHERSHIP)
building_field.rebuild_all()
_try_place(type, cost_of(type))
func _try_place(type: String, cost: int) -> void:
if not GameState.spend(cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [cost, GameState.rocks]
return
building_field.place(type, fleet.get_centroid())
build_menu.visible = false
func _refresh_craft_status() -> void:
craft_status.text = "Interceptor %d rocks You have %d Fleet %d/%d" % [
GameState.SHIP_COST, GameState.rocks, fleet.ships.size(), GameState.formation.size()
]
func _on_craft_interceptor() -> void:
if fleet.ships.size() >= GameState.formation.size():
craft_status.text = "Fleet is full (%d slots)" % GameState.formation.size()
return
if not GameState.spend(GameState.SHIP_COST):
craft_status.text = "Not enough rocks (need %d, have %d)" % [GameState.SHIP_COST, GameState.rocks]
return
fleet.add_ship()
_refresh_craft_status()
func _on_formation_changed() -> void:
fleet.apply_formation()
func _on_reset_formation() -> void:
GameState.reset_formation()
fleet.apply_formation()
formation_editor.queue_redraw()