Skill tree, node-based building animation, bigger UI, and a 19x faster test suite
Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
85814ce88c
commit
b641fa5891
48 changed files with 1985 additions and 303 deletions
379
Building.gd
379
Building.gd
|
|
@ -39,6 +39,7 @@ 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.
|
||||
|
|
@ -66,26 +67,56 @@ 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 = []
|
||||
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 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.
|
||||
# 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()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
anim_time = fmod(anim_time + delta, CYCLE)
|
||||
phase_time += delta
|
||||
queue_redraw()
|
||||
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()
|
||||
|
||||
|
||||
# Where the satellites are, and how open the reactor core is. Both are read
|
||||
|
|
@ -109,14 +140,25 @@ func get_display_name() -> String:
|
|||
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()
|
||||
_draw_mothership_static(self)
|
||||
_:
|
||||
_draw_crafter()
|
||||
_draw_crafter_static(self)
|
||||
|
||||
|
||||
# --- shared helpers -------------------------------------------------------
|
||||
|
|
@ -139,17 +181,57 @@ func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int
|
|||
|
||||
|
||||
|
||||
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),
|
||||
# --- 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
|
||||
|
||||
|
||||
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),
|
||||
])
|
||||
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)
|
||||
|
||||
|
||||
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 ----------------------------------------
|
||||
|
|
@ -187,49 +269,65 @@ func _talon(base_angle: float) -> PackedVector2Array:
|
|||
return pts
|
||||
|
||||
|
||||
func _draw_mothership() -> void:
|
||||
func _draw_mothership_static(canvas: CanvasItem) -> 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)
|
||||
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(self, PackedVector2Array([
|
||||
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 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))
|
||||
# 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)
|
||||
|
||||
# Satellites holding station around it, drifting slowly round.
|
||||
var orbit: float = orbit_angle()
|
||||
|
||||
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 = orbit + (float(i) / float(SATELLITE_COUNT)) * TAU
|
||||
var a: float = (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)
|
||||
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() -> void:
|
||||
func _draw_crafter_static(canvas: CanvasItem) -> 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)
|
||||
# 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]:
|
||||
|
|
@ -237,44 +335,114 @@ func _draw_crafter() -> void:
|
|||
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)
|
||||
NeonDraw.draw_glowing_polygon(canvas, fin, c, 0.75)
|
||||
for f in [0.35, 0.68]:
|
||||
NeonDraw.draw_glowing_line(self,
|
||||
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(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)
|
||||
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(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)
|
||||
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(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)
|
||||
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(self,
|
||||
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 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)
|
||||
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:
|
||||
_draw_assembly(t / ASSEMBLE_END, c)
|
||||
_animate_assembly(t / ASSEMBLE_END)
|
||||
elif t < FLASH_END:
|
||||
_draw_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END), c)
|
||||
_animate_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END))
|
||||
else:
|
||||
_draw_launch((t - FLASH_END) / (1.0 - FLASH_END))
|
||||
_animate_launch((t - FLASH_END) / (1.0 - FLASH_END))
|
||||
|
||||
|
||||
# How many pieces are on the hull at this point through the assembly phase.
|
||||
|
|
@ -287,78 +455,73 @@ func attached_part_count(p: float) -> int:
|
|||
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 _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 _draw_assembly(p: float, c: Color) -> void:
|
||||
_draw_clamps(c, 0.8)
|
||||
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)
|
||||
|
||||
# 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)
|
||||
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_parts.size():
|
||||
if next_part >= _hull_pieces.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)
|
||||
_crane.position = Vector2(weld.x, RAIL_Y + 13.0)
|
||||
_hoist.scale.y = maxf(weld.y - (RAIL_Y + 19.0), 1.0)
|
||||
|
||||
if next_part < _hull_parts.size():
|
||||
# Weld sparks: short spokes whose angles jitter frame to frame.
|
||||
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))
|
||||
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)
|
||||
_sparks.position = weld
|
||||
_sparks.rotation = phase_time * 7.0
|
||||
_sparks.scale = Vector2.ONE * (0.55 + 0.45 * flicker)
|
||||
_sparks.modulate.a = flicker
|
||||
|
||||
|
||||
func _draw_completion_flash(p: float, c: Color) -> void:
|
||||
func _animate_completion_flash(p: float) -> 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)
|
||||
_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 _draw_launch(p: float) -> void:
|
||||
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 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)
|
||||
_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.
|
||||
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)
|
||||
_wash.modulate.a = 0.4 + 0.6 * sin(minf(p * 3.2, PI))
|
||||
|
|
|
|||
Loading…
Reference in a new issue