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) # #102. Olive rather than another blue or amber: every existing structure is # a place you go to DO something, and this is the one you leave people at. const OUTPOST_COLOR: Color = Color(0.72, 0.86, 0.45) # #106. A cool grey-blue: this is the one structure that does no work at all, # it only decides what work the others do. const PLANNING_COLOR: Color = Color(0.62, 0.74, 0.92) # #116. Warm steel: the workshop is the only structure whose whole job is # looking after the others, and it should read as a yard rather than a plant. const WORKSHOP_COLOR: Color = Color(0.5, 0.95, 0.8) # Workshop: an open service gantry with a cradle under it and drone berths # either side. Deliberately unenclosed - what it holds is other people's # buildings, arriving and leaving. const YARD_GANTRY_W: float = 96.0 const YARD_GANTRY_H: float = 16.0 const YARD_LEG_DROP: float = 58.0 const YARD_CRADLE_R: float = 34.0 const YARD_BERTH_X: float = 78.0 # Planning centre: a standing board with rows of tallies on it, flanked by # two slim pylons. Rectilinear on purpose - every other structure here is # radial or trussed, and this one is an office. const PLAN_BOARD_W: float = 86.0 const PLAN_BOARD_H: float = 62.0 const PLAN_ROW_COUNT: int = 4 const PLAN_PYLON_X: float = 104.0 const PLAN_PYLON_H: float = 54.0 # Outpost: a watch mast on a tripod footing, with two open berths beside it # where a garrison sits. Deliberately the least machinery of any structure # here - it makes nothing and refines nothing, it just holds a position - # and deliberately not radial, so it cannot be mistaken for a small hub. const OUTPOST_MAST_TOP: float = -84.0 const OUTPOST_MAST_FOOT: float = 46.0 const OUTPOST_HEAD_RADIUS: float = 20.0 const OUTPOST_FOOT_SPAN: float = 62.0 const OUTPOST_FOOT_DROP: float = 26.0 const OUTPOST_BERTH_X: float = 74.0 const OUTPOST_BERTH_RADIUS: float = 22.0 const OUTPOST_VANE_COUNT: int = 3 # 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 # #117: "The ship crafter and the ship lab should only animate when # something is being crafted in them." # # The clock stops rather than the drawing: every moving element is a NeonPiece # whose position is pushed from this clock, so a frozen clock leaves the # building standing exactly as it was with the cranes parked. Stopping # apply_animation() instead would leave them wherever the last frame put # them, which is not the same thing as idle. # # Default true: a mothership and a transport hub turn because that is what # they are, not because they have an order in hand. func is_working() -> bool: return true # The record this visual belongs to. The chain is Building -> VisualHost -> # this, and the id lives on the Building. # By tree lookup, not by the bare `GameState` identifier. These visual # scripts are reached through a BuildingDefinition's visual_scene, which is # preloaded while the definition catalog is still loading - early enough that # the autoload is not yet in the global class table, and a bare reference # fails to COMPILE the whole file. Same hazard GameState notes for WORLD_GEN. func _state() -> Node: var tree: SceneTree = get_tree() return tree.root.get_node_or_null("GameState") if tree != null else null func building_id() -> int: var host: Node = get_parent() var building: Node = host.get_parent() if host != null else null return int(building.building_id) if building != null and "building_id" in building else 0 func _process(delta: float) -> void: if not is_working(): return 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) func _draw_workshop() -> void: var c: Color = WORKSHOP_COLOR var lit: Color = Color(0.86, 1.0, 0.95) # The gantry beam, and the legs it stands on. NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ Vector2(-YARD_GANTRY_W, -YARD_GANTRY_H - 40.0), Vector2(YARD_GANTRY_W, -YARD_GANTRY_H - 40.0), Vector2(YARD_GANTRY_W, -40.0), Vector2(-YARD_GANTRY_W, -40.0), ]), c, 1.0) for side in [-1.0, 1.0]: var x: float = YARD_GANTRY_W * side * 0.82 NeonDraw.draw_glowing_line(self, Vector2(x, -40.0), Vector2(x, YARD_LEG_DROP), c, 0.8, 0.42) # Cross-bracing, so it reads as a frame rather than two posts. NeonDraw.draw_glowing_line(self, Vector2(x, -40.0), Vector2(x * 0.35, YARD_LEG_DROP), c, 0.45, 0.26) # The cradle: an open arc under the beam, with the thing being worked on # implied by the lit core rather than drawn. NeonDraw.draw_glowing_path(self, _arc(Vector2(0.0, 6.0), YARD_CRADLE_R, 0.0, PI, 14), c, 0.85, 0.4) NeonDraw.draw_glowing_circle(self, Vector2(0.0, 6.0), 11.0, lit, 0.9) # A hoist line down from the beam to what is in the cradle. NeonDraw.draw_glowing_line(self, Vector2(0.0, -40.0), Vector2(0.0, -6.0), c, 0.6, 0.3) # Drone berths, one either side, drawn as open brackets. for side in [-1.0, 1.0]: var berth: Vector2 = Vector2(YARD_BERTH_X * side, -70.0) NeonDraw.draw_glowing_path(self, PackedVector2Array([ berth + Vector2(-14.0 * side, -12.0), berth + Vector2(6.0 * side, -12.0), berth + Vector2(6.0 * side, 12.0), berth + Vector2(-14.0 * side, 12.0), ]), c, 0.7, 0.32) NeonDraw.draw_glowing_polygon(self, _diamond(berth, 7.0), lit, 0.85) func _draw_planning_center() -> void: var c: Color = PLANNING_COLOR var lit: Color = Color(0.88, 0.94, 1.0) # The board. NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ Vector2(-PLAN_BOARD_W, -PLAN_BOARD_H), Vector2(PLAN_BOARD_W, -PLAN_BOARD_H), Vector2(PLAN_BOARD_W, PLAN_BOARD_H), Vector2(-PLAN_BOARD_W, PLAN_BOARD_H), ]), c, 1.0) # Rows of tallies at differing lengths - a plan being met unevenly, which # is what the building is for. var fractions: Array = [0.86, 0.42, 0.68, 0.24] for i in range(PLAN_ROW_COUNT): var y: float = lerpf(-PLAN_BOARD_H + 16.0, PLAN_BOARD_H - 16.0, float(i) / float(PLAN_ROW_COUNT - 1)) var left: float = -PLAN_BOARD_W + 14.0 var span: float = (PLAN_BOARD_W - 14.0) * 2.0 NeonDraw.draw_glowing_line(self, Vector2(left, y), Vector2(left + span * float(fractions[i]), y), lit, 0.8, 0.34) NeonDraw.draw_glowing_line(self, Vector2(left + span * float(fractions[i]), y), Vector2(left + span, y), c, 0.35, 0.22) # Pylons holding it up, and a footing bar under the whole thing. for side in [-1.0, 1.0]: var x: float = PLAN_PYLON_X * side NeonDraw.draw_glowing_line(self, Vector2(x, -PLAN_PYLON_H), Vector2(x, PLAN_PYLON_H), c, 0.75, 0.4) NeonDraw.draw_glowing_line(self, Vector2(PLAN_BOARD_W * side, 0.0), Vector2(x, 0.0), c, 0.55, 0.3) NeonDraw.draw_glowing_polygon(self, _diamond(Vector2(x, -PLAN_PYLON_H), 9.0), c, 0.9) NeonDraw.draw_glowing_line(self, Vector2(-PLAN_PYLON_X, PLAN_PYLON_H), Vector2(PLAN_PYLON_X, PLAN_PYLON_H), c, 0.5, 0.3) func _draw_outpost() -> void: var c: Color = OUTPOST_COLOR var lit: Color = Color(0.94, 0.99, 0.82) # Tripod footing. var foot: Vector2 = Vector2(0.0, OUTPOST_MAST_FOOT) for side in [-1.0, 0.0, 1.0]: var toe: Vector2 = Vector2(OUTPOST_FOOT_SPAN * side, OUTPOST_MAST_FOOT + OUTPOST_FOOT_DROP) NeonDraw.draw_glowing_line(self, foot, toe, c, 0.7, 0.42) NeonDraw.draw_glowing_line(self, Vector2(-OUTPOST_FOOT_SPAN, OUTPOST_MAST_FOOT + OUTPOST_FOOT_DROP), Vector2(OUTPOST_FOOT_SPAN, OUTPOST_MAST_FOOT + OUTPOST_FOOT_DROP), c, 0.55, 0.34) # The mast, with rungs so it reads as climbable structure rather than a # drawn line. var top: Vector2 = Vector2(0.0, OUTPOST_MAST_TOP) NeonDraw.draw_glowing_line(self, foot, top, c, 0.9, 0.5) for i in range(4): var y: float = lerpf(OUTPOST_MAST_FOOT, OUTPOST_MAST_TOP + OUTPOST_HEAD_RADIUS, float(i + 1) / 5.0) NeonDraw.draw_glowing_line(self, Vector2(-11.0, y), Vector2(11.0, y), c, 0.45, 0.28) # Sensor head: a lit diamond inside a cage, with vanes looking outward. NeonDraw.draw_glowing_polygon(self, _ring(top, OUTPOST_HEAD_RADIUS, 6, PI / 6.0), c, 1.0) NeonDraw.draw_glowing_polygon(self, _diamond(top, OUTPOST_HEAD_RADIUS * 0.5), lit, 0.95) for i in range(OUTPOST_VANE_COUNT): var a: float = PI + (float(i) / float(OUTPOST_VANE_COUNT - 1)) * PI var dir: Vector2 = Vector2(cos(a), sin(a)) NeonDraw.draw_glowing_line(self, top + dir * OUTPOST_HEAD_RADIUS, top + dir * (OUTPOST_HEAD_RADIUS + 16.0), c, 0.7, 0.3) # Two open berths. Open, not enclosed: what sits in them belongs to the # player and flies away, so they are cradles rather than hangars. for side in [-1.0, 1.0]: var berth: Vector2 = Vector2(OUTPOST_BERTH_X * side, 4.0) NeonDraw.draw_glowing_path(self, _arc(berth, OUTPOST_BERTH_RADIUS, -PI * 0.5, PI * 0.5, 10), c, 0.75, 0.36) NeonDraw.draw_glowing_line(self, Vector2(12.0 * side, 4.0), berth - Vector2(OUTPOST_BERTH_RADIUS * side, 0.0), c, 0.6, 0.34) # --- 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))