Ship Lab: per-vessel research, and the shield vessel

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>
This commit is contained in:
Loïc Wisniewski 2026-08-11 23:31:49 +02:00
commit 1cbae8ff70
13 changed files with 876 additions and 5 deletions

View file

@ -29,6 +29,16 @@ 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
@ -100,6 +110,8 @@ 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
@ -120,6 +132,8 @@ func _ready() -> void:
_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()
@ -140,6 +154,8 @@ func apply_animation() -> void:
_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
@ -182,6 +198,8 @@ func _draw() -> void:
_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)
@ -224,6 +242,57 @@ 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: