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>
364 lines
15 KiB
GDScript
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)
|