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)