Forgejo #5. A new building gated behind a "Vessel Science" research node. Its own screen upgrades each vessel type - weapon, movement, shield and freight - and unlocks the shield vessel, which the ship crafter can then build. Vessel research is deliberately NOT more nodes in the mothership tree: that tree gates what may be built at all and is a strategic spine, while these are repeatable tuning on vessels already owned. Levels cost base * (level + 1), so the third point in something is not the price of the first. Upgrades are read live rather than baked in at build time, so researching one improves the ships already flying. The shield is personal, not a bubble: it absorbs a hit that would destroy the vessel - ships have no health, contact kills - and is then down until it cycles. Its ring is a NeonPiece child toggled with `visible` per #10, not a redraw of the hull. The build menu now holds six entries across three rows, which the #21 repacking already handles. Enemies still have no weapon, so "blocks shots" has nothing to block yet; that is filed separately rather than widened into this ticket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
665 lines
27 KiB
GDScript
665 lines
27 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"
|
|
# Which GameState.buildings record this node is showing. Set by
|
|
# BuildingField when it spawns, so a node can be traced back to the stock
|
|
# and production queue that survive the scene swap (#16).
|
|
@export var building_id: int = 0
|
|
@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)
|
|
const TRANSPORT_HUB_COLOR: Color = Color(0.95, 0.55, 0.85)
|
|
const SHIP_LAB_COLOR: Color = Color(0.55, 0.95, 0.9)
|
|
|
|
# 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 _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 animate. The command centre is
|
|
# entirely static and needs no per-frame work at all.
|
|
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))
|
|
_build_crafter_pieces()
|
|
elif building_type == GameState.BUILDING_MOTHERSHIP:
|
|
_build_mothership_pieces()
|
|
elif building_type == GameState.BUILDING_TRANSPORT_HUB:
|
|
_build_hub_pieces()
|
|
elif building_type == GameState.BUILDING_SHIP_LAB:
|
|
_build_lab_pieces()
|
|
queue_redraw()
|
|
|
|
|
|
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:
|
|
match building_type:
|
|
GameState.BUILDING_MOTHERSHIP:
|
|
_animate_mothership()
|
|
GameState.BUILDING_SHIP_CRAFTER:
|
|
_animate_crafter()
|
|
GameState.BUILDING_TRANSPORT_HUB:
|
|
_animate_transport_hub()
|
|
GameState.BUILDING_SHIP_LAB:
|
|
_animate_ship_lab()
|
|
|
|
|
|
# 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"
|
|
|
|
|
|
# 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 _draw() -> void:
|
|
draw_count += 1
|
|
# Runs exactly once per building: this is the fixed structure. Everything
|
|
# that moves is a NeonPiece child, animated through node properties, so
|
|
# nothing here ever asks for a redraw again.
|
|
match building_type:
|
|
GameState.BUILDING_COMMAND_CENTER:
|
|
_draw_command_center()
|
|
GameState.BUILDING_MOTHERSHIP:
|
|
_draw_mothership_static(self)
|
|
GameState.BUILDING_TRANSPORT_HUB:
|
|
_draw_transport_hub_static(self)
|
|
GameState.BUILDING_SHIP_LAB:
|
|
_draw_ship_lab_static(self)
|
|
_:
|
|
_draw_crafter_static(self)
|
|
|
|
|
|
# --- 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
|
|
|
|
|
|
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))
|