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:
commit
85814ce88c
192 changed files with 9602 additions and 0 deletions
351
BuildController.gd
Normal file
351
BuildController.gd
Normal 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()
|
||||
Loading…
Reference in a new issue