spaceship/buildings/BuildingVisualBase.gd
Loïc Wisniewski 2f161621c3 #84: move 215 root files into a folder hierarchy
Everything lived in one flat directory, so a diff gave no clue what area it
touched and finding the file you wanted meant knowing its exact name. Twelve
folders now, grouped by what a thing IS rather than what type it is - a
vessel's definition, its component scenes and its upgrade resources sit
together, because that is the set you read when you change one of them.

  buildings  core  controllers  enemies  logistics  planets
  resources  shaders  surface  ui  vessels  vfx  world

332 files moved and every res:// reference rewritten - 306 files touched,
including the autoload and main-scene paths in project.godot. Nothing renamed:
this is purely where files sit, so the diff is a pile of pure moves and no
behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at
the root, where Godot and the export preset expect them.

Verified three ways, because a suite alone would not have caught all of it:
the full 117 pass, the web export builds clean, and the game renders.

The export is why that matters. The FIRST export after the move failed with
"Attempt to open script 'res://GameState.gd'" - the old path - while
project.godot on disk plainly said res://core/GameState.gd. That is this
project's documented Android FUSE mtime trap: Godot served a cached copy of a
file whose content had changed. A `touch` fixed it. Nothing in the test suite
would have found that, because the suite runs from a copy on a native
filesystem - the one place the bug cannot happen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00

652 lines
27 KiB
GDScript

class_name BuildingVisualBase
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.
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)
const TRANSPORT_HUB_COLOR: Color = Color(0.95, 0.55, 0.85)
const SHIP_LAB_COLOR: Color = Color(0.55, 0.95, 0.9)
const GAS_BURNER_COLOR: Color = Color(0.95, 0.35, 0.15)
const GAS_BURNER_ENERGY_COLOR: Color = Color(0.85, 0.95, 0.15)
# Ship lab: a test frame with a vessel held in it and instruments sweeping
# over it. Reads as a workshop rather than a factory - the crafter already
# owns "assembly line", so this is measurement, not production.
const LAB_FRAME_W: float = 78.0
const LAB_FRAME_H: float = 58.0
const LAB_MOUNT_RADIUS: float = 15.0
const LAB_SCANNER_ORBIT: float = 96.0
const LAB_SCAN_SPEED: float = 0.9
# Transport hub: an octagonal dock ring with berths around it and a freight
# core in the middle. Deliberately radial-but-flat, so it does not read as a
# small mothership - the mothership is the only thing with swept arms.
const HUB_RING_RADIUS: float = 74.0
const HUB_DECK_RADIUS: float = 54.0
const HUB_CORE_RADIUS: float = 22.0
const HUB_BERTH_COUNT: int = 8
const HUB_BERTH_LENGTH: float = 20.0
const HUB_DOCKED_COUNT: int = 3
const HUB_DOCKED_ORBIT: float = 96.0
const HUB_SPIN_SPEED: float = 0.22
# 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
const FLASH_RING_RADIUS: float = 20.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] = []
# Every moving element is its own NeonPiece, rasterised once when the
# building is created and thereafter only moved, scaled, faded or hidden.
# Nothing here ever calls queue_redraw() again: the building's own _draw()
# lays down the fixed structure a single time, and the animation is pure
# node-property work from then on.
var _core_cell: NeonPiece = null
var _satellites: Node2D = null
var _beacons: Array = []
var _clamps: NeonPiece = null
var _crane: Node2D = null
var _hoist: NeonPiece = null
var _sparks: Node2D = null
var _hull_holder: Node2D = null
var _hull_pieces: Array = []
# Rotating holder for the transports stationed at a transport hub (#18).
var _hub_traffic: Node2D = null
# Sweeping instrument booms at a ship lab (#5).
var _lab_scanners: Node2D = null
var _flash_ring: NeonPiece = null
var _wash: NeonPiece = null
func _process(delta: float) -> void:
anim_time = fmod(anim_time + delta, CYCLE)
phase_time += delta
apply_animation()
# Pushes the current clock onto the movable pieces. Normally driven from
# _process; public so a preview or a test can pin the clock and see exactly
# that frame without having to run the animation up to it in real time.
func apply_animation() -> void:
pass
# 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)
# How many times this building's geometry has actually been rasterised. The
# whole point of the NeonPiece decomposition is that this stays at 1 for the
# building's lifetime however long it animates, so it is worth being able to
# assert rather than assume.
var draw_count: int = 0
func register_draw() -> void:
draw_count += 1
# --- 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
# --- command centre: a tactical plotting hub ------------------------------
# The mothership is radial-and-swept, the crafter is an asymmetric assembly
# line, so the command centre reads best as the odd one out: rigid, hexagonal,
# a wheel rather than a claw. Six spokes tie a hex rim to a central diamond
# readout, and every spoke ends in a formation port - half of them holding a
# token, half left open - so the structure itself shows fleet slots waiting
# to be filled, which is exactly what the drag-and-drop editor inside does.
const CMD_HEX_RADIUS: float = 88.0
const CMD_DECK_RADIUS: float = 66.0
const CMD_HUB_RADIUS: float = 30.0
const CMD_SCREEN_RADIUS: float = 14.0
const CMD_PORT_COUNT: int = 6
const CMD_PORT_LENGTH: float = 16.0
const CMD_TOKEN_RADIUS: float = 9.0
const CMD_STUD_RADIUS: float = 7.0
# --- ship lab -------------------------------------------------------------
func _draw_ship_lab_static(target: CanvasItem) -> void:
var c: Color = SHIP_LAB_COLOR
# Outer test frame, open at the corners so it reads as a rig rather than
# a solid hull.
for sx in [-1.0, 1.0]:
for sy in [-1.0, 1.0]:
var corner: Vector2 = Vector2(sx * LAB_FRAME_W, sy * LAB_FRAME_H)
NeonDraw.draw_glowing_line(target, corner,
corner - Vector2(sx * LAB_FRAME_W * 0.45, 0.0), c, 0.9, 0.42)
NeonDraw.draw_glowing_line(target, corner,
corner - Vector2(0.0, sy * LAB_FRAME_H * 0.5), c, 0.9, 0.42)
# The vessel under test, held nose-up in the middle - deliberately the
# fleet's own silhouette, so it is obvious what the lab is for.
NeonDraw.draw_glowing_polygon(target, PackedVector2Array([
Vector2(0, -26), Vector2(13, 12), Vector2(0, 5), Vector2(-13, 12),
]), HULL_COLOR, 0.95)
# Mount ring and its bracing.
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, LAB_MOUNT_RADIUS, 6, PI / 6.0), c, 0.5,
NeonDraw.DEFAULT_CORE, 0.7)
for i in range(4):
var a: float = (float(i) / 4.0) * TAU + PI / 4.0
var dir: Vector2 = Vector2(cos(a), sin(a))
NeonDraw.draw_glowing_line(target, dir * LAB_MOUNT_RADIUS, dir * 40.0, c, 0.45, 0.3)
func _build_lab_pieces() -> void:
# Two instrument booms sweeping round the vessel. One holder, rasterised
# once, rotated per frame (#10).
_lab_scanners = Node2D.new()
add_child(_lab_scanners)
for i in range(2):
var a: float = float(i) * PI
var at: Vector2 = Vector2(cos(a), sin(a)) * LAB_SCANNER_ORBIT
var piece: NeonPiece = NeonPiece.create(_lab_scanners, at, SHIP_LAB_COLOR)
piece.add_polygon(PackedVector2Array([
Vector2(-4, -12), Vector2(4, -12), Vector2(4, 12), Vector2(-4, 12),
]))
piece.add_line(Vector2(0, 0), Vector2(-cos(a), -sin(a)) * 26.0, 0.3, 0.5)
piece.rotation = a
piece.queue_redraw()
func _animate_ship_lab() -> void:
_lab_scanners.rotation = phase_time * LAB_SCAN_SPEED
# --- transport hub --------------------------------------------------------
func _draw_transport_hub_static(target: CanvasItem) -> void:
var c: Color = TRANSPORT_HUB_COLOR
# Dock ring and the deck nested inside it.
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_RING_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 1.0)
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_DECK_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 0.45, NeonDraw.DEFAULT_CORE, 0.7)
# Freight core: a squared-off hold rather than a reactor, so it reads as
# storage and not as another power plant.
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_CORE_RADIUS, 4, PI / 4.0), c, 0.95)
for i in range(HUB_BERTH_COUNT):
var a: float = (float(i) / HUB_BERTH_COUNT) * TAU + PI / 8.0
var dir: Vector2 = Vector2(cos(a), sin(a))
NeonDraw.draw_glowing_line(target, dir * HUB_CORE_RADIUS, dir * HUB_DECK_RADIUS, c, 0.5, 0.32)
# Berth arm standing off the ring, where a transporter parks.
NeonDraw.draw_glowing_line(target, dir * HUB_RING_RADIUS,
dir * (HUB_RING_RADIUS + HUB_BERTH_LENGTH), c, 0.75, 0.38)
func _build_hub_pieces() -> void:
# Stationed transports, as NeonPiece children of one holder that turns.
# Per #10 the geometry is rasterised once and only the holder's rotation
# changes, so the traffic costs no redraws at all.
_hub_traffic = Node2D.new()
add_child(_hub_traffic)
for i in range(HUB_DOCKED_COUNT):
var a: float = (float(i) / HUB_DOCKED_COUNT) * TAU
var at: Vector2 = Vector2(cos(a), sin(a)) * HUB_DOCKED_ORBIT
var piece: NeonPiece = NeonPiece.create(_hub_traffic, at, TRANSPORT_HUB_COLOR)
# A stubby freighter: blunt, boxy, nothing like the fleet's darts.
piece.add_polygon(PackedVector2Array([
Vector2(-9, -5), Vector2(9, -5), Vector2(12, 0), Vector2(9, 5), Vector2(-9, 5),
]))
piece.rotation = a + PI / 2.0
piece.queue_redraw()
func _animate_transport_hub() -> void:
_hub_traffic.rotation = phase_time * HUB_SPIN_SPEED
# --- gas burner (#54) ------------------------------------------------------
# A squat furnace rather than more scaffolding: a hexagonal combustion
# chamber with a hot inner core, fed by intake vents drawn in gas's own
# colour and exhausting through ports glowing in energy's colour - so the
# structure itself reads as "gas in, energy out" without needing a label.
# Drawn once and never redrawn, same as the command centre.
const BURNER_CHAMBER_RADIUS: float = 50.0
const BURNER_CORE_RADIUS: float = 34.0
const BURNER_VENT_COUNT: int = 4
const BURNER_VENT_LENGTH: float = 26.0
const BURNER_PORT_RADIUS: float = 6.0
# `cold` draws the burner switched off (#78): the structure is still there,
# but the fire in the middle is out and the output ports are dark. A paused
# building that looked identical to a running one would be a setting the
# player has to remember rather than see.
func _draw_gas_burner_static(target: CanvasItem, cold: bool = false) -> void:
var c: Color = GAS_BURNER_COLOR.darkened(0.55) if cold else GAS_BURNER_COLOR
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, BURNER_CHAMBER_RADIUS, 6, PI / 6.0), c,
0.55 if cold else 1.0)
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, BURNER_CORE_RADIUS, 6, PI / 6.0), c, 0.5,
NeonDraw.DEFAULT_CORE, 0.7)
if not cold:
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, BURNER_CORE_RADIUS * 0.5, 6, PI / 6.0),
Color(1.0, 0.75, 0.35), 0.85)
for i in range(BURNER_VENT_COUNT):
var a: float = (float(i) / BURNER_VENT_COUNT) * TAU + PI / 4.0
var dir: Vector2 = Vector2(cos(a), sin(a))
var tip: Vector2 = dir * (BURNER_CHAMBER_RADIUS + BURNER_VENT_LENGTH)
# Intake vent in gas's own colour, so it is unmistakably the feed
# rather than another structural brace.
NeonDraw.draw_glowing_line(target, dir * BURNER_CHAMBER_RADIUS, tip,
Color(0.85, 0.52, 1.0), 0.25 if cold else 0.7, 0.34)
# Output port glowing in energy's colour, at the same vent tip -
# the building's own silhouette shows both ends of the conversion.
NeonDraw.draw_glowing_polygon(target, _ring(tip, BURNER_PORT_RADIUS, 5),
GAS_BURNER_ENERGY_COLOR.darkened(0.6) if cold else GAS_BURNER_ENERGY_COLOR,
0.3 if cold else 0.9)
func _diamond(centre: Vector2, r: float) -> PackedVector2Array:
return PackedVector2Array([
centre + Vector2(0, -r), centre + Vector2(r, 0),
centre + Vector2(0, r), centre + Vector2(-r, 0),
])
func _draw_command_center() -> void:
var c: Color = COMMAND_COLOR
var readout: Color = Color(0.82, 0.86, 1.0)
# The rim: a hex table holding the whole layout together, with a fainter
# deck ring nested just inside it - the same "plating has layers" read the
# mothership gets from its nested reactor rings.
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_HEX_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 1.0)
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_DECK_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 0.5, NeonDraw.DEFAULT_CORE, 0.7)
for i in range(CMD_PORT_COUNT):
var a: float = (float(i) / CMD_PORT_COUNT) * TAU + PI / 6.0
var dir: Vector2 = Vector2(cos(a), sin(a))
# Spoke bracing the hub to the rim.
NeonDraw.draw_glowing_line(self, dir * CMD_HUB_RADIUS, dir * CMD_HEX_RADIUS, c, 0.65, 0.36)
# Socket where the mast plugs into the rim.
NeonDraw.draw_glowing_polygon(self, _ring(dir * CMD_HEX_RADIUS, CMD_STUD_RADIUS, 6, a), c, 0.85)
# Port mast standing proud of the rim; every other one is manned.
var port_tip: Vector2 = dir * (CMD_HEX_RADIUS + CMD_PORT_LENGTH)
NeonDraw.draw_glowing_line(self, dir * CMD_HEX_RADIUS, port_tip, c, 0.8, 0.4)
if i % 2 == 0:
NeonDraw.draw_glowing_polygon(self, _diamond(port_tip, CMD_TOKEN_RADIUS), c, 1.0)
# Command core: a diamond hub with a lit readout set inside it.
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_HUB_RADIUS), c, 1.0)
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9)
# --- 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_static(canvas: CanvasItem) -> void:
var c: Color = MOTHERSHIP_COLOR
for i in range(ARM_COUNT):
var base: float = (float(i) / float(ARM_COUNT)) * TAU
NeonDraw.draw_glowing_polygon(canvas, _talon(base), c, 0.95)
# Crystal panel set into the gap behind each arm.
var pa: float = base - 0.30
NeonDraw.draw_glowing_polygon(canvas, 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 housing: the nested rings around the cell that breathes.
NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0)
NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85)
const CORE_CELL_RADIUS: float = 12.0
func _build_mothership_pieces() -> void:
var core_color: Color = Color(0.75, 1.0, 0.85)
# The breathing cell: drawn at a reference size and then scaled, rather
# than rebuilt at a new radius every frame.
_core_cell = NeonPiece.create(self, Vector2.ZERO, core_color)
_core_cell.add_polygon(_ring(Vector2.ZERO, CORE_CELL_RADIUS, 6, PI / 6.0))
# Satellites sit at fixed offsets inside a holder; the orbit is the
# holder's rotation, so four hexagon pairs become one transform update.
_satellites = Node2D.new()
add_child(_satellites)
for i in range(SATELLITE_COUNT):
var a: float = (float(i) / float(SATELLITE_COUNT)) * TAU
var at: Vector2 = Vector2(cos(a), sin(a)) * SATELLITE_ORBIT
var shell: NeonPiece = NeonPiece.create(_satellites, at, MOTHERSHIP_COLOR)
shell.add_polygon(_ring(Vector2.ZERO, 10.0, 6, PI / 6.0), 0.85)
var cell: NeonPiece = NeonPiece.create(_satellites, at, core_color)
cell.add_polygon(_ring(Vector2.ZERO, 4.5, 6, PI / 6.0), 0.8)
func _animate_mothership() -> void:
var breath: float = core_pulse()
var radius: float = lerpf(9.0, 15.0, breath)
_core_cell.scale = Vector2.ONE * (radius / CORE_CELL_RADIUS)
_core_cell.modulate.a = lerpf(0.55, 1.0, breath)
_satellites.rotation = orbit_angle()
# --- ship crafter: an orbital yard with a hull in the cradle --------------
func _draw_crafter_static(canvas: CanvasItem) -> void:
var c: Color = CRAFTER_COLOR
# Fabricator core housing. This replaces the chimney - nothing vents
# smoke in vacuum.
NeonDraw.draw_glowing_polygon(canvas, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0)
# 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(canvas, fin, c, 0.75)
for f in [0.35, 0.68]:
NeonDraw.draw_glowing_line(canvas,
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(canvas, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4)
NeonDraw.draw_glowing_line(canvas, 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(canvas, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28)
NeonDraw.draw_glowing_line(canvas, 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(canvas, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7)
NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7)
NeonDraw.draw_glowing_line(canvas, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55)
for side in [-1.0, 1.0]:
NeonDraw.draw_glowing_line(canvas,
BAY_CENTRE + Vector2(RIB_RADIUS * side, -6.0), Vector2(BAY_CENTRE.x + 44.0 * side, RAIL_Y), c, 0.7, 0.3)
func _build_crafter_pieces() -> void:
var c: Color = CRAFTER_COLOR
# The core's inner cell: fades with the reactor rather than being redrawn
# at a new brightness each frame.
_core_cell = NeonPiece.create(self, CORE_CENTRE, Color(1.0, 0.9, 0.6))
_core_cell.add_polygon(_ring(Vector2.ZERO, 12.0, 6, PI / 6.0))
# 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 beacon: NeonPiece = NeonPiece.create(self, lamp, Color(1.0, 0.95, 0.75))
beacon.add_polygon(_ring(Vector2.ZERO, 3.0, 4))
_beacons.append(beacon)
# Clamp arms reaching in from each rib. They let go as the hull launches,
# which is a fade rather than a redraw.
_clamps = NeonPiece.create(self, Vector2.ZERO, c)
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)
_clamps.add_line(outer, inner, 0.35)
_clamps.add_line(inner + Vector2(0, -5), inner + Vector2(0, 5), 0.3)
# Crane: the head rides the rail, so only its x moves. The hoist is drawn
# one unit long and stretched with scale.y to reach whatever seam is
# being welded - stretching a line needs no redraw, redrawing it would.
_crane = Node2D.new()
add_child(_crane)
var head: NeonPiece = NeonPiece.create(_crane, Vector2.ZERO, c)
head.add_polygon(PackedVector2Array([
Vector2(-10, -7), Vector2(10, -7), Vector2(6, 6), Vector2(-6, 6),
]))
head.add_line(Vector2(0, -7), Vector2(0, RAIL_Y - (RAIL_Y + 13.0)), 0.35, 0.8)
_hoist = NeonPiece.create(_crane, Vector2(0, 6), c, 0.5)
_hoist.add_line(Vector2.ZERO, Vector2(0, 1), 0.3)
# Weld sparks: four spokes on a holder that spins and stretches. The
# jitter is genuinely procedural, but rotating and scaling a holder gives
# the same flicker without re-tessellating the spokes every frame.
_sparks = Node2D.new()
add_child(_sparks)
for i in range(4):
var a: float = float(i) * TAU / 4.0
var spark: NeonPiece = NeonPiece.create(_sparks, Vector2.ZERO, Color(1.0, 0.95, 0.7))
spark.add_line(Vector2.ZERO, Vector2(cos(a), sin(a)) * 11.0, 0.3)
# The hull under construction. Each part is its own piece so attaching one
# is a `visible` flip, and the launch is the holder's position and alpha.
_hull_holder = Node2D.new()
add_child(_hull_holder)
for i in range(_hull_parts.size()):
var part: NeonPiece = NeonPiece.create(_hull_holder, Vector2.ZERO, HULL_COLOR)
part.add_polygon(_hull_parts[i])
part.visible = false
_hull_pieces.append(part)
# Engine wash under the hull, and the completion ring. The ring is drawn
# at a reference radius and scaled outwards.
_wash = NeonPiece.create(_hull_holder, Vector2.ZERO, Color(1.0, 0.85, 0.45), 0.0)
for dx in [-4.0, 4.0]:
_wash.add_line(Vector2(dx, 14), Vector2(dx * 1.6, 30.0), 0.45)
_flash_ring = NeonPiece.create(self, BAY_CENTRE, HULL_COLOR, 0.0)
_flash_ring.add_polygon(_ring(Vector2.ZERO, FLASH_RING_RADIUS, 6, PI / 6.0))
func _animate_crafter() -> void:
var t: float = anim_time / CYCLE
_core_cell.modulate.a = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0))
for i in range(_beacons.size()):
_beacons[i].modulate.a = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI))
if t < ASSEMBLE_END:
_animate_assembly(t / ASSEMBLE_END)
elif t < FLASH_END:
_animate_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END))
else:
_animate_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 _place_hull(centre: Vector2, alpha: float) -> void:
# The parts are defined around their own origin, so the holder goes
# straight to the point they should be centred on.
_hull_holder.position = centre
_hull_holder.modulate.a = alpha
func _animate_assembly(p: float) -> void:
_clamps.visible = true
_clamps.modulate.a = 0.8
_flash_ring.modulate.a = 0.0
_wash.modulate.a = 0.0
_place_hull(BAY_CENTRE, 1.0)
var next_part: int = attached_part_count(p)
for i in range(_hull_pieces.size()):
var attached: bool = i < next_part
_hull_pieces[i].visible = attached
if attached:
# Freshly attached parts flare, then settle to full brightness.
var age: float = p - float(SHIP_PARTS[i].at)
_hull_pieces[i].modulate.a = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08)
# 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_pieces.size():
weld.y = RAIL_Y + 16.0
_crane.position = Vector2(weld.x, RAIL_Y + 13.0)
_hoist.scale.y = maxf(weld.y - (RAIL_Y + 19.0), 1.0)
var welding: bool = next_part < _hull_pieces.size()
_sparks.visible = welding
if welding:
var flicker: float = 0.45 + 0.55 * absf(sin(phase_time * 34.0))
_sparks.position = weld
_sparks.rotation = phase_time * 7.0
_sparks.scale = Vector2.ONE * (0.55 + 0.45 * flicker)
_sparks.modulate.a = flicker
func _animate_completion_flash(p: float) -> void:
# Whole hull, clamps springing open, and a ring pushing outwards to mark
# it as finished.
_clamps.modulate.a = 1.0 - p
_sparks.visible = false
_place_hull(BAY_CENTRE, 1.0)
for piece in _hull_pieces:
piece.visible = true
piece.modulate.a = 1.0
# Retracted, so it does not hang over the finished hull.
_crane.position = Vector2(BAY_CENTRE.x, RAIL_Y + 13.0)
_hoist.scale.y = 1.0
_flash_ring.modulate.a = 1.0 - p
_flash_ring.scale = Vector2.ONE * (lerpf(18.0, 56.0, p) / FLASH_RING_RADIUS)
func _animate_launch(p: float) -> void:
# Clears the cradle and fades out; the next cycle starts from bare ribs.
_clamps.modulate.a = 0.0
_sparks.visible = false
_flash_ring.modulate.a = 0.0
var eased: float = p * p
var fade: float = clampf(1.0 - p * 1.15, 0.0, 1.0)
_place_hull(BAY_CENTRE + Vector2(0.0, -124.0 * eased), fade)
for piece in _hull_pieces:
piece.visible = true
piece.modulate.a = 1.0
# Engine wash, strongest just after it breaks free of the clamps.
_wash.modulate.a = 0.4 + 0.6 * sin(minf(p * 3.2, PI))