spaceship/Building.gd
Loïc Wisniewski 85814ce88c 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

364 lines
15 KiB
GDScript

extends Node2D
# A player-placed structure in space. Three kinds for now:
# mothership - where a wiped-out fleet is rebuilt
# ship_crafter - fly the fleet onto it to buy additional ships
# command_center - reconfigure the fleet formation
#
# Buildings are stationary and harmless; whether the fleet is "on" one is
# decided by BuildingField comparing the fleet CENTROID against
# interact_radius, the same stable single-number test Planet uses (see
# Planet.gd for why per-ship overlap was a poor fit).
#
# Nothing here rests on ground or vents into an atmosphere - these are
# orbital structures, so they are built out of trusses, radiator fins,
# reactor cores and open cradles rather than walls and roofs.
#
# The crafter and the mothership animate, so they run _process. The command
# centre is drawn once and never redrawn - per-frame redraws of art that
# never changes were a real framerate cost earlier on.
@export var building_type: String = "mothership"
@export var interact_radius: float = 150.0
const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55)
const CRAFTER_COLOR: Color = Color(1.0, 0.72, 0.25)
const COMMAND_COLOR: Color = Color(0.55, 0.6, 1.0)
# The vessel on the assembly line is drawn in the fleet's own colour, so it
# reads as "one of yours being built" rather than as shipyard machinery.
const HULL_COLOR: Color = Color(0.25, 0.85, 1.0)
# One assembly cycle: parts are welded on, the finished hull flashes, then
# it clears the cradle and the line starts over.
const CYCLE: float = 3.6
const ASSEMBLE_END: float = 0.66 # fraction of CYCLE spent welding
const FLASH_END: float = 0.76 # ... then the completion flash
# ... then launch, to 1.0
const CORE_CENTRE: Vector2 = Vector2(-58.0, 12.0)
const BAY_CENTRE: Vector2 = Vector2(46.0, 12.0)
const RIB_RADIUS: float = 36.0
const RAIL_Y: float = -32.0
# The hull in four pieces, attached in this order. Each carries the point
# the crane welds it on at, which is where the sparks fly from.
const SHIP_PARTS: Array = [
{"at": 0.04, "weld": Vector2(0, -12), "points": [Vector2(0, -26), Vector2(-8, 4), Vector2(8, 4)]},
{"at": 0.28, "weld": Vector2(-13, 7), "points": [Vector2(-8, -4), Vector2(-22, 14), Vector2(-8, 10)]},
{"at": 0.48, "weld": Vector2(13, 7), "points": [Vector2(8, -4), Vector2(22, 14), Vector2(8, 10)]},
{"at": 0.70, "weld": Vector2(0, 10), "points": [Vector2(-7, 4), Vector2(7, 4), Vector2(5, 14), Vector2(-5, 14)]},
]
# The assembly clock, wrapped at CYCLE because the whole build sequence is
# expressed as a fraction of it.
var anim_time: float = 0.0
# Free-running seconds for everything that is merely cyclic - reactor pulses,
# orbiting satellites, blinking beacons. These must NOT share anim_time:
# CYCLE is 3.6s, which is not a whole number of periods for any of them, so
# wrapping made every one of them leap at the wrap. The satellites were the
# obvious casualty, snapping back 66 degrees every 3.6 seconds.
#
# Deliberately never wrapped. GDScript floats are 64-bit, so even after hours
# of play the argument to sin() keeps far more precision than a frame needs,
# and any wrap point would reintroduce exactly the discontinuity above.
var phase_time: float = 0.0
var _hull_parts: Array[PackedVector2Array] = []
func _ready() -> void:
add_to_group("buildings")
z_index = -1
# The crafter runs an assembly line and the mothership holds a live
# reactor with satellites in orbit, so both redraw. The command centre is
# static art and is drawn exactly once - per-frame redraws of art that
# never changes were a real framerate cost earlier on.
var animated: bool = building_type != GameState.BUILDING_COMMAND_CENTER
set_process(animated)
if building_type == GameState.BUILDING_SHIP_CRAFTER:
for part in SHIP_PARTS:
_hull_parts.append(PackedVector2Array(part.points))
queue_redraw()
func _process(delta: float) -> void:
anim_time = fmod(anim_time + delta, CYCLE)
phase_time += delta
queue_redraw()
# Where the satellites are, and how open the reactor core is. Both are read
# by _draw and by the test that checks they stay continuous across the
# assembly clock's wrap.
func orbit_angle() -> float:
return phase_time * ORBIT_SPEED
func core_pulse() -> float:
return 0.5 + 0.5 * sin(phase_time * PULSE_SPEED)
func get_display_name() -> String:
match building_type:
GameState.BUILDING_MOTHERSHIP:
return "Mothership"
GameState.BUILDING_SHIP_CRAFTER:
return "Ship Crafter"
_:
return "Command Center"
func _draw() -> void:
match building_type:
GameState.BUILDING_COMMAND_CENTER:
_draw_command_center()
GameState.BUILDING_MOTHERSHIP:
_draw_mothership()
_:
_draw_crafter()
# --- shared helpers -------------------------------------------------------
func _ring(centre: Vector2, radius: float, sides: int, phase: float = 0.0) -> PackedVector2Array:
var pts: PackedVector2Array = PackedVector2Array()
for i in range(sides):
var a: float = (float(i) / float(sides)) * TAU + phase
pts.append(centre + Vector2(cos(a), sin(a)) * radius)
return pts
func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int) -> PackedVector2Array:
var pts: PackedVector2Array = PackedVector2Array()
for i in range(steps + 1):
var a: float = lerpf(from_a, to_a, float(i) / float(steps))
pts.append(centre + Vector2(cos(a), sin(a)) * radius)
return pts
func _draw_command_center() -> void:
# Diamond with three formation pips inside - reads as "arrangement".
var body: PackedVector2Array = PackedVector2Array([
Vector2(0, -62), Vector2(58, 0), Vector2(0, 62), Vector2(-58, 0),
])
NeonDraw.draw_glowing_polygon(self, body, COMMAND_COLOR, 1.0)
for p in [Vector2(0, -18), Vector2(-20, 16), Vector2(20, 16)]:
var pip: PackedVector2Array = PackedVector2Array([
p + Vector2(0, -9), p + Vector2(-8, 8), p + Vector2(8, 8),
])
NeonDraw.draw_glowing_polygon(self, pip, COMMAND_COLOR, 1.0)
# --- mothership: a radial flagship ----------------------------------------
# Not a big fighter. A mothership is the thing everything else orbits, so it
# is built radially around a reactor core rather than pointed in a direction:
# six swept talons, crystal panels set between them, and satellites circling
# the whole assembly. A forward-facing hull, however large, just reads as
# another ship.
const ARM_COUNT: int = 6
const ARM_INNER: float = 42.0
const ARM_OUTER: float = 106.0
const ARM_SWEEP: float = 0.72
const SATELLITE_COUNT: int = 4
const SATELLITE_ORBIT: float = 126.0
const ORBIT_SPEED: float = 0.32
const PULSE_SPEED: float = 2.4
func _talon(base_angle: float) -> PackedVector2Array:
# One swept claw: the outer edge runs from the core to the tip, and the
# inner edge comes back a little behind it, closing to a point so the arm
# tapers instead of ending in a blunt stub.
var steps: int = 7
var pts: PackedVector2Array = PackedVector2Array()
for i in range(steps + 1):
var f: float = float(i) / float(steps)
var a: float = base_angle + ARM_SWEEP * pow(f, 1.3)
pts.append(Vector2(cos(a), sin(a)) * lerpf(ARM_INNER, ARM_OUTER, f))
for i in range(steps, -1, -1):
var f: float = float(i) / float(steps)
var trail: float = 0.34 * (1.0 - f) + 0.04
var a: float = base_angle + ARM_SWEEP * pow(f, 1.3) - trail
pts.append(Vector2(cos(a), sin(a)) * lerpf(ARM_INNER, ARM_OUTER, f))
return pts
func _draw_mothership() -> void:
var c: Color = MOTHERSHIP_COLOR
var core_color: Color = Color(0.75, 1.0, 0.85)
for i in range(ARM_COUNT):
var base: float = (float(i) / float(ARM_COUNT)) * TAU
NeonDraw.draw_glowing_polygon(self, _talon(base), c, 0.95)
# Crystal panel set into the gap behind each arm.
var pa: float = base - 0.30
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
Vector2(cos(pa - 0.11), sin(pa - 0.11)) * 54.0,
Vector2(cos(pa + 0.11), sin(pa + 0.11)) * 54.0,
Vector2(cos(pa + 0.15), sin(pa + 0.15)) * 86.0,
Vector2(cos(pa - 0.15), sin(pa - 0.15)) * 86.0,
]), c, 0.7)
# Reactor core: nested rings around a cell that breathes.
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0)
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85)
var breath: float = core_pulse()
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, lerpf(9.0, 15.0, breath), 6, PI / 6.0),
core_color, lerpf(0.55, 1.0, breath))
# Satellites holding station around it, drifting slowly round.
var orbit: float = orbit_angle()
for i in range(SATELLITE_COUNT):
var a: float = orbit + (float(i) / float(SATELLITE_COUNT)) * TAU
var at: Vector2 = Vector2(cos(a), sin(a)) * SATELLITE_ORBIT
NeonDraw.draw_glowing_polygon(self, _ring(at, 10.0, 6, PI / 6.0), c, 0.85)
NeonDraw.draw_glowing_polygon(self, _ring(at, 4.5, 6, PI / 6.0), core_color, 0.8)
# --- ship crafter: an orbital yard with a hull in the cradle --------------
func _draw_crafter() -> void:
var c: Color = CRAFTER_COLOR
var t: float = anim_time / CYCLE
# Fabricator core: a reactor housing whose inner cell pulses as it feeds
# the yard. This replaces the chimney - nothing vents smoke in vacuum.
NeonDraw.draw_glowing_polygon(self, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0)
var reactor_glow: float = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0))
NeonDraw.draw_glowing_polygon(self, _ring(CORE_CENTRE, 12.0, 6, PI / 6.0), Color(1.0, 0.9, 0.6), reactor_glow)
# Radiator fins above and below the core, slatted like real ones.
for dir_y in [-1.0, 1.0]:
var fin: PackedVector2Array = PackedVector2Array([
CORE_CENTRE + Vector2(-16, 30 * dir_y), CORE_CENTRE + Vector2(18, 34 * dir_y),
CORE_CENTRE + Vector2(24, 54 * dir_y), CORE_CENTRE + Vector2(-10, 50 * dir_y),
])
NeonDraw.draw_glowing_polygon(self, fin, c, 0.75)
for f in [0.35, 0.68]:
NeonDraw.draw_glowing_line(self,
fin[0].lerp(fin[3], f), fin[1].lerp(fin[2], f), c, 0.5, 0.28)
# Truss spine linking the core to the cradle: two rails with X-bracing,
# the standard "this was assembled in orbit" read.
var spine_top: float = 4.0
var spine_bottom: float = 22.0
NeonDraw.draw_glowing_line(self, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4)
NeonDraw.draw_glowing_line(self, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4)
for i in range(3):
var x0: float = -32.0 + float(i) * 15.0
var x1: float = x0 + 15.0
NeonDraw.draw_glowing_line(self, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28)
NeonDraw.draw_glowing_line(self, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28)
# Construction cradle: two open ribs holding the hull, with the crane
# rail overhead. Open at the top so the finished hull can clear it.
NeonDraw.draw_glowing_path(self, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7)
NeonDraw.draw_glowing_path(self, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7)
NeonDraw.draw_glowing_line(self, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55)
for side in [-1.0, 1.0]:
NeonDraw.draw_glowing_line(self,
BAY_CENTRE + Vector2(RIB_RADIUS * side, -6.0), Vector2(BAY_CENTRE.x + 44.0 * side, RAIL_Y), c, 0.7, 0.3)
# Docking beacons, blinking out of step with each other.
for i in range(2):
var lamp: Vector2 = BAY_CENTRE + Vector2(-RIB_RADIUS + 2.0 + float(i) * (RIB_RADIUS * 2.0 - 4.0), 30.0)
var blink: float = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI))
NeonDraw.draw_glowing_polygon(self, _ring(lamp, 3.0, 4), Color(1.0, 0.95, 0.75), blink)
if t < ASSEMBLE_END:
_draw_assembly(t / ASSEMBLE_END, c)
elif t < FLASH_END:
_draw_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END), c)
else:
_draw_launch((t - FLASH_END) / (1.0 - FLASH_END))
# How many pieces are on the hull at this point through the assembly phase.
# Parts attach in order, so this is just how many thresholds have passed.
func attached_part_count(p: float) -> int:
var n: int = 0
for part in SHIP_PARTS:
if p >= float(part.at):
n += 1
return n
func _draw_clamps(c: Color, pulse: float) -> void:
# Arms reaching in from each rib to hold the hull steady. They let go
# once it launches, so this is not drawn during the launch phase.
for side in [-1.0, 1.0]:
var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0)
var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0)
NeonDraw.draw_glowing_line(self, outer, inner, c, pulse, 0.35)
NeonDraw.draw_glowing_line(self, inner + Vector2(0, -5), inner + Vector2(0, 5), c, pulse, 0.3)
func _draw_assembly(p: float, c: Color) -> void:
_draw_clamps(c, 0.8)
# Everything welded on so far, newest part still glowing from the seam.
var next_part: int = attached_part_count(p)
for i in range(next_part):
# Freshly attached parts flare, then settle to full brightness.
var age: float = p - float(SHIP_PARTS[i].at)
var pulse: float = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08)
_draw_hull_part(i, BAY_CENTRE, pulse)
# The crane rides the rail over whichever seam is being worked, and
# retracts once there is nothing left to attach.
var weld: Vector2 = BAY_CENTRE + (SHIP_PARTS[mini(next_part, SHIP_PARTS.size() - 1)].weld as Vector2)
if next_part >= _hull_parts.size():
weld.y = RAIL_Y + 16.0
var head: Vector2 = Vector2(weld.x, RAIL_Y + 13.0)
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
head + Vector2(-10, -7), head + Vector2(10, -7),
head + Vector2(6, 6), head + Vector2(-6, 6),
]), c, 1.0)
NeonDraw.draw_glowing_line(self, Vector2(head.x, RAIL_Y), head + Vector2(0, -7), c, 0.8, 0.35)
NeonDraw.draw_glowing_line(self, head + Vector2(0, 6), weld, c, 0.5, 0.3)
if next_part < _hull_parts.size():
# Weld sparks: short spokes whose angles jitter frame to frame.
var flicker: float = 0.45 + 0.55 * absf(sin(phase_time * 34.0))
for i in range(4):
var a: float = phase_time * 7.0 + float(i) * TAU / 4.0
var length: float = 6.0 + 5.0 * flicker
NeonDraw.draw_glowing_line(self, weld, weld + Vector2(cos(a), sin(a)) * length,
Color(1.0, 0.95, 0.7), flicker, 0.3)
func _draw_completion_flash(p: float, c: Color) -> void:
# Whole hull, clamps springing open, and a ring pushing outwards to mark
# it as finished.
_draw_clamps(c, 1.0 - p)
for i in range(_hull_parts.size()):
_draw_hull_part(i, BAY_CENTRE, 1.0)
NeonDraw.draw_glowing_polygon(self, _ring(BAY_CENTRE, lerpf(18.0, 56.0, p), 6, PI / 6.0), HULL_COLOR, 1.0 - p)
func _draw_launch(p: float) -> void:
# Clears the cradle and fades out; the next cycle starts from bare ribs.
var eased: float = p * p
var centre: Vector2 = BAY_CENTRE + Vector2(0.0, -124.0 * eased)
var fade: float = clampf(1.0 - p * 1.15, 0.0, 1.0)
for i in range(_hull_parts.size()):
_draw_hull_part(i, centre, fade)
# Engine wash, strongest just after it breaks free of the clamps.
var thrust: float = fade * (0.4 + 0.6 * sin(minf(p * 3.2, PI)))
for dx in [-4.0, 4.0]:
NeonDraw.draw_glowing_line(self, centre + Vector2(dx, 14),
centre + Vector2(dx * 1.6, 26.0 + 16.0 * thrust),
Color(1.0, 0.85, 0.45), thrust, 0.45)
func _draw_hull_part(index: int, centre: Vector2, pulse: float) -> void:
if pulse <= 0.01:
return
var moved: PackedVector2Array = PackedVector2Array()
for point in _hull_parts[index]:
moved.append(centre + point)
NeonDraw.draw_glowing_polygon(self, moved, HULL_COLOR, pulse)