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>
194 lines
6.4 KiB
GDScript
194 lines
6.4 KiB
GDScript
extends Node
|
|
|
|
# Autoload singleton - the one thing that survives change_scene_to_file()
|
|
# scene swaps between space (Main.tscn) and a planet's surface
|
|
# (PlanetSurface.tscn). Everything else in either scene gets torn down
|
|
# and rebuilt from scratch on each swap, so anything that needs to
|
|
# survive the trip has to live here instead.
|
|
|
|
var has_return_position: bool = false
|
|
var surviving_ship_count: int = 5
|
|
var return_world_position: Vector2 = Vector2.ZERO
|
|
var return_camera_zoom: float = 1.0
|
|
|
|
var planet_name: String = ""
|
|
var planet_type: String = ""
|
|
|
|
# Explored-chunk history for the world map's fog-of-war reveal. Lives
|
|
# here (not on ExplorationTracker, which gets destroyed and recreated on
|
|
# every scene swap) so exploration progress isn't lost every time the
|
|
# player lands on and leaves a planet.
|
|
var explored_chunks: Dictionary = {}
|
|
|
|
# --- Resources -------------------------------------------------------------
|
|
|
|
# Rocks mined from asteroid-planet surfaces, spent on buildings and ships.
|
|
# Starts stocked for play-testing so buildings can be tried without mining
|
|
# first - drop this back to 0 for a real playthrough.
|
|
var rocks: int = 500
|
|
|
|
const MOTHERSHIP_COST: int = 20
|
|
const SHIP_CRAFTER_COST: int = 12
|
|
const SHIP_COST: int = 6
|
|
|
|
# --- Buildings -------------------------------------------------------------
|
|
|
|
# Player-placed structures, as [{"type": String, "position": Vector2}].
|
|
# Buildings exist only in space (Main.tscn) but are stored here so they
|
|
# survive the scene swap to a planet's surface and back.
|
|
const BUILDING_MOTHERSHIP: String = "mothership"
|
|
const BUILDING_SHIP_CRAFTER: String = "ship_crafter"
|
|
const BUILDING_COMMAND_CENTER: String = "command_center"
|
|
# Extractors are not placed in space like the others - they are built ON a
|
|
# planet's surface and tracked separately (see `extractors`).
|
|
const BUILDING_EXTRACTOR: String = "extractor"
|
|
|
|
const COMMAND_CENTER_COST: int = 15
|
|
const EXTRACTOR_COST: int = 25
|
|
|
|
var buildings: Array = []
|
|
|
|
|
|
func add_building(type: String, at_position: Vector2) -> void:
|
|
buildings.append({"type": type, "position": at_position})
|
|
|
|
|
|
func has_mothership() -> bool:
|
|
return get_mothership_position() != null
|
|
|
|
|
|
func remove_buildings_of_type(type: String) -> int:
|
|
var kept: Array = []
|
|
var removed: int = 0
|
|
for b in buildings:
|
|
if b.type == type:
|
|
removed += 1
|
|
else:
|
|
kept.append(b)
|
|
buildings = kept
|
|
return removed
|
|
|
|
|
|
func get_mothership_position() -> Variant:
|
|
# Where a wiped-out fleet gets rebuilt. Returns null when no mothership
|
|
# has been built yet, so the caller can fall back to the world origin.
|
|
for b in buildings:
|
|
if b.type == BUILDING_MOTHERSHIP:
|
|
return b.position
|
|
return null
|
|
|
|
|
|
# --- Fleet formation -------------------------------------------------------
|
|
|
|
# Where each vessel sits relative to the fleet centre, editable at a command
|
|
# centre. Lives here (not as a const on Fleet) so a layout the player has
|
|
# arranged survives the trip to a planet's surface and back, and so a
|
|
# rebuilt fleet keeps their arrangement.
|
|
const DEFAULT_FORMATION: Array = [
|
|
Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32),
|
|
]
|
|
|
|
var formation: Array = DEFAULT_FORMATION.duplicate()
|
|
|
|
|
|
func reset_formation() -> void:
|
|
formation = DEFAULT_FORMATION.duplicate()
|
|
|
|
|
|
func rotate_formation(radians: float) -> void:
|
|
# Pivots every slot about the fleet centre, keeping the shape intact
|
|
# and only changing which way it faces.
|
|
for i in range(formation.size()):
|
|
formation[i] = formation[i].rotated(radians)
|
|
|
|
|
|
# --- Extractors and their haulers -----------------------------------------
|
|
|
|
# An extractor sits on a planet and runs a single automated hauler back and
|
|
# forth to the mothership, delivering ore on each arrival.
|
|
#
|
|
# The round trip is SIMULATED HERE, in the autoload, rather than by any
|
|
# node: it advances whether the player is in space, on a planet's surface,
|
|
# or far enough away that nothing about the route is loaded. `progress` is
|
|
# a fraction of one full there-and-back cycle, so a hauler's drawn position
|
|
# is derived from the same number that decides when ore is delivered - the
|
|
# visual arrival and the payout cannot drift apart, because they are the
|
|
# same event.
|
|
#
|
|
# Entries are {"position": Vector2 (the planet's position in SPACE),
|
|
# "planet_name": String, "progress": float}.
|
|
const HAULER_SPEED: float = 260.0
|
|
const HAULER_YIELD: int = 4
|
|
# Progress value at which the hauler reaches the mothership: exactly half
|
|
# way through the cycle, the outbound leg being the first half.
|
|
const HAULER_ARRIVAL: float = 0.5
|
|
|
|
var extractors: Array = []
|
|
|
|
|
|
func has_extractor_at(planet_name: String) -> bool:
|
|
for e in extractors:
|
|
if e.planet_name == planet_name:
|
|
return true
|
|
return false
|
|
|
|
|
|
func add_extractor(planet_name: String, at_position: Vector2) -> void:
|
|
extractors.append({
|
|
"position": at_position,
|
|
"planet_name": planet_name,
|
|
"progress": 0.0,
|
|
})
|
|
|
|
|
|
func get_hauler_route_time(extractor: Dictionary) -> float:
|
|
# Full cycle: out to the mothership and back again.
|
|
var mothership: Variant = get_mothership_position()
|
|
if mothership == null:
|
|
return 0.0
|
|
var distance: float = extractor.position.distance_to(mothership)
|
|
if distance < 1.0:
|
|
return 0.0
|
|
return (distance * 2.0) / HAULER_SPEED
|
|
|
|
|
|
func get_hauler_position(extractor: Dictionary) -> Variant:
|
|
# Where the hauler is right now, derived purely from `progress`. Returns
|
|
# null when there is no route (no mothership built yet).
|
|
var mothership: Variant = get_mothership_position()
|
|
if mothership == null:
|
|
return null
|
|
var t: float = extractor.progress
|
|
if t <= HAULER_ARRIVAL:
|
|
return extractor.position.lerp(mothership, t / HAULER_ARRIVAL)
|
|
return mothership.lerp(extractor.position, (t - HAULER_ARRIVAL) / (1.0 - HAULER_ARRIVAL))
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
# Runs in the autoload, so extraction keeps earning while the player is
|
|
# on a planet's surface or nowhere near the route.
|
|
if extractors.is_empty():
|
|
return
|
|
for e in extractors:
|
|
var cycle: float = get_hauler_route_time(e)
|
|
if cycle <= 0.0:
|
|
continue # nothing to deliver to yet
|
|
var before: float = e.progress
|
|
e.progress += delta / cycle
|
|
# Ore lands the instant the hauler reaches the mothership - the same
|
|
# instant get_hauler_position() puts it there.
|
|
if before < HAULER_ARRIVAL and e.progress >= HAULER_ARRIVAL:
|
|
rocks += HAULER_YIELD
|
|
while e.progress >= 1.0:
|
|
e.progress -= 1.0
|
|
|
|
|
|
func can_afford(cost: int) -> bool:
|
|
return rocks >= cost
|
|
|
|
|
|
func spend(cost: int) -> bool:
|
|
if not can_afford(cost):
|
|
return false
|
|
rocks -= cost
|
|
return true
|