Transport Hub building

Forgejo #18, part 5 of #12. The building only - the vessels that do the
hauling are #19. Placeable, costed at 30, gated behind a new "Freight
Handling" research node hanging off Logistics, with its own info popup,
build-menu glyph and map marker.

Drawn as an octagonal dock ring with berth arms and a squared-off freight
core: deliberately radial-but-flat so it does not read as a small
mothership, which is the only structure with swept arms. Its stationed
transports are NeonPiece children of one rotating holder, per #10 - the
geometry is rasterised once and only the holder's rotation changes, so
the traffic costs no redraws.

Building._draw()'s match has a catch-all that renders anything
unrecognised as a ship crafter, so a missing case is silent and looks
almost right. The test checks for the hub's own _hub_traffic holder
rather than trusting that something plausible appeared - which caught
exactly that: a _ready() patch had silently not applied and the hub was
rendering through the crafter branch.

The build menu grew a third row. Every button, label and price moved:
the old row occupied 332px against a 372px pitch, and three of those do
not fit above a 1280px screen. Rows are now shorter (128px buttons, 40px
price digits) at a 290px pitch, ending at y=1176. Rendered and looked at
- the first attempt tightened the pitch alone and left every price
overlapping the row beneath it, which no headless test would have seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-11 17:45:07 +02:00
commit 823828f3b3
10 changed files with 400 additions and 52 deletions

View file

@ -27,6 +27,7 @@ extends Node2D
@onready var build_close_button: IconButton = get_node("../UI/BuildMenu/CloseButton") @onready var build_close_button: IconButton = get_node("../UI/BuildMenu/CloseButton")
@onready var build_status: Label = get_node("../UI/BuildMenu/StatusLabel") @onready var build_status: Label = get_node("../UI/BuildMenu/StatusLabel")
@onready var build_extractor_button: IconButton = get_node("../UI/BuildMenu/ExtractorButton") @onready var build_extractor_button: IconButton = get_node("../UI/BuildMenu/ExtractorButton")
@onready var build_hub_button: IconButton = get_node("../UI/BuildMenu/TransportHubButton")
@onready var planet_field: Node2D = get_node("../PlanetField") @onready var planet_field: Node2D = get_node("../PlanetField")
@onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup") @onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup")
@onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel") @onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel")
@ -76,6 +77,10 @@ const BUILDING_INFO: Dictionary = {
"title": "Command Center", "title": "Command Center",
"body": "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it.", "body": "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it.",
}, },
GameState.BUILDING_TRANSPORT_HUB: {
"title": "Transport Hub",
"body": "Stations transport vessels. They fetch ore from your mothership and deliver it to whichever building is waiting on it.",
},
GameState.BUILDING_EXTRACTOR: { GameState.BUILDING_EXTRACTOR: {
"title": "Extractor", "title": "Extractor",
"body": "Mines a planet and runs a hauler to your mothership. It keeps earning while you are away.", "body": "Mines a planet and runs a hauler to your mothership. It keeps earning while you are away.",
@ -100,6 +105,7 @@ func _ready() -> void:
build_crafter_button.pressed.connect(_on_place_crafter) build_crafter_button.pressed.connect(_on_place_crafter)
build_command_button.pressed.connect(_on_place_command_center) build_command_button.pressed.connect(_on_place_command_center)
build_extractor_button.pressed.connect(_on_place_extractor) build_extractor_button.pressed.connect(_on_place_extractor)
build_hub_button.pressed.connect(_on_place_transport_hub)
info_close_button.pressed.connect(_close_info) info_close_button.pressed.connect(_close_info)
info_build_button.pressed.connect(_confirm_build) info_build_button.pressed.connect(_confirm_build)
@ -194,6 +200,8 @@ func _action_icon_for(type: String) -> int:
match type: match type:
GameState.BUILDING_SHIP_CRAFTER: GameState.BUILDING_SHIP_CRAFTER:
return IconButton.Icon.SHIP return IconButton.Icon.SHIP
GameState.BUILDING_TRANSPORT_HUB:
return IconButton.Icon.TRANSPORT_HUB
GameState.BUILDING_MOTHERSHIP: GameState.BUILDING_MOTHERSHIP:
return IconButton.Icon.MOTHERSHIP return IconButton.Icon.MOTHERSHIP
_: _:
@ -243,6 +251,7 @@ func _refresh_build_status() -> void:
# explain itself in the info popup. # explain itself in the info popup.
build_crafter_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER) build_crafter_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
build_command_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_COMMAND_CENTER) build_command_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_COMMAND_CENTER)
build_hub_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB)
build_extractor_button.enabled = _planet_under_fleet() != null \ build_extractor_button.enabled = _planet_under_fleet() != null \
and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR)
@ -266,6 +275,10 @@ func _on_place_command_center() -> void:
_show_building_info(GameState.BUILDING_COMMAND_CENTER) _show_building_info(GameState.BUILDING_COMMAND_CENTER)
func _on_place_transport_hub() -> void:
_show_building_info(GameState.BUILDING_TRANSPORT_HUB)
func _on_place_extractor() -> void: func _on_place_extractor() -> void:
# The option stays pressable while greyed, so this is also how a # The option stays pressable while greyed, so this is also how a
# not-over-a-planet extractor explains itself. # not-over-a-planet extractor explains itself.
@ -274,6 +287,8 @@ func _on_place_extractor() -> void:
func cost_of(type: String) -> int: func cost_of(type: String) -> int:
match type: match type:
GameState.BUILDING_TRANSPORT_HUB:
return GameState.TRANSPORT_HUB_COST
GameState.BUILDING_MOTHERSHIP: GameState.BUILDING_MOTHERSHIP:
return GameState.MOTHERSHIP_COST return GameState.MOTHERSHIP_COST
GameState.BUILDING_SHIP_CRAFTER: GameState.BUILDING_SHIP_CRAFTER:

View file

@ -28,6 +28,19 @@ extends Node2D
const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55) const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55)
const CRAFTER_COLOR: Color = Color(1.0, 0.72, 0.25) const CRAFTER_COLOR: Color = Color(1.0, 0.72, 0.25)
const COMMAND_COLOR: Color = Color(0.55, 0.6, 1.0) const COMMAND_COLOR: Color = Color(0.55, 0.6, 1.0)
const TRANSPORT_HUB_COLOR: Color = Color(0.95, 0.55, 0.85)
# 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 # 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. # reads as "one of yours being built" rather than as shipyard machinery.
const HULL_COLOR: Color = Color(0.25, 0.85, 1.0) const HULL_COLOR: Color = Color(0.25, 0.85, 1.0)
@ -85,6 +98,8 @@ var _hoist: NeonPiece = null
var _sparks: Node2D = null var _sparks: Node2D = null
var _hull_holder: Node2D = null var _hull_holder: Node2D = null
var _hull_pieces: Array = [] var _hull_pieces: Array = []
# Rotating holder for the transports stationed at a transport hub (#18).
var _hub_traffic: Node2D = null
var _flash_ring: NeonPiece = null var _flash_ring: NeonPiece = null
var _wash: NeonPiece = null var _wash: NeonPiece = null
@ -103,6 +118,8 @@ func _ready() -> void:
_build_crafter_pieces() _build_crafter_pieces()
elif building_type == GameState.BUILDING_MOTHERSHIP: elif building_type == GameState.BUILDING_MOTHERSHIP:
_build_mothership_pieces() _build_mothership_pieces()
elif building_type == GameState.BUILDING_TRANSPORT_HUB:
_build_hub_pieces()
queue_redraw() queue_redraw()
@ -121,6 +138,8 @@ func apply_animation() -> void:
_animate_mothership() _animate_mothership()
GameState.BUILDING_SHIP_CRAFTER: GameState.BUILDING_SHIP_CRAFTER:
_animate_crafter() _animate_crafter()
GameState.BUILDING_TRANSPORT_HUB:
_animate_transport_hub()
# Where the satellites are, and how open the reactor core is. Both are read # Where the satellites are, and how open the reactor core is. Both are read
@ -161,6 +180,8 @@ func _draw() -> void:
_draw_command_center() _draw_command_center()
GameState.BUILDING_MOTHERSHIP: GameState.BUILDING_MOTHERSHIP:
_draw_mothership_static(self) _draw_mothership_static(self)
GameState.BUILDING_TRANSPORT_HUB:
_draw_transport_hub_static(self)
_: _:
_draw_crafter_static(self) _draw_crafter_static(self)
@ -203,6 +224,50 @@ const CMD_TOKEN_RADIUS: float = 9.0
const CMD_STUD_RADIUS: float = 7.0 const CMD_STUD_RADIUS: float = 7.0
# --- 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: func _diamond(centre: Vector2, r: float) -> PackedVector2Array:
return PackedVector2Array([ return PackedVector2Array([
centre + Vector2(0, -r), centre + Vector2(r, 0), centre + Vector2(0, -r), centre + Vector2(r, 0),

View file

@ -101,9 +101,13 @@ const BUILDING_COMMAND_CENTER: String = "command_center"
# Extractors are not placed in space like the others - they are built ON a # Extractors are not placed in space like the others - they are built ON a
# planet's surface and tracked separately (see `extractors`). # planet's surface and tracked separately (see `extractors`).
const BUILDING_EXTRACTOR: String = "extractor" const BUILDING_EXTRACTOR: String = "extractor"
# Hosts the transport vessels that supply other buildings (#18). Built in
# space like the rest; it is the transporters (#19) that do the moving.
const BUILDING_TRANSPORT_HUB: String = "transport_hub"
const COMMAND_CENTER_COST: int = 15 const COMMAND_CENTER_COST: int = 15
const EXTRACTOR_COST: int = 25 const EXTRACTOR_COST: int = 25
const TRANSPORT_HUB_COST: int = 30
var buildings: Array = [] var buildings: Array = []
@ -136,6 +140,11 @@ const SKILL_TREE: Array = [
"x": 0.82, "unlocks": BUILDING_COMMAND_CENTER, "x": 0.82, "unlocks": BUILDING_COMMAND_CENTER,
"desc": "Build command centers, where the fleet formation is arranged.", "desc": "Build command centers, where the fleet formation is arranged.",
}, },
{
"id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics",
"x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB,
"desc": "Build transport hubs, which supply your other buildings with ore.",
},
{ {
"id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination", "id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination",
"x": 0.82, "fleet_bonus": 1, "x": 0.82, "fleet_bonus": 1,

View file

@ -15,7 +15,7 @@ enum Icon {
# build menu used to pick from the generic set, which left the mothership # build menu used to pick from the generic set, which left the mothership
# and the command centre sharing a folded-map glyph and neither of them # and the command centre sharing a folded-map glyph and neither of them
# looking anything like the structure they build. # looking anything like the structure they build.
MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR, MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR, TRANSPORT_HUB,
} }
# Size the glyphs were originally drawn against. Every glyph dimension is # Size the glyphs were originally drawn against. Every glyph dimension is
@ -119,6 +119,8 @@ func _draw() -> void:
_draw_command_icon(center, pulse, s) _draw_command_icon(center, pulse, s)
Icon.EXTRACTOR: Icon.EXTRACTOR:
_draw_extractor_icon(center, pulse, s) _draw_extractor_icon(center, pulse, s)
Icon.TRANSPORT_HUB:
_draw_transport_hub_icon(center, pulse, s)
func _draw_close(center: Vector2, pulse: float, s: float) -> void: func _draw_close(center: Vector2, pulse: float, s: float) -> void:
@ -204,6 +206,32 @@ func _draw_mothership_icon(center: Vector2, pulse: float, s: float) -> void:
NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color) NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color)
func _draw_transport_hub_icon(center: Vector2, pulse: float, s: float) -> void:
# A ring of docking cradles with a freight box in the middle: reads as
# "things are stationed here and go out from here", which is what the hub
# does, and shares no silhouette with the crafter's open cradle.
var ring_r: float = 11.0 * s
var ring: PackedVector2Array = PackedVector2Array()
for i in range(8):
var a: float = (float(i) / 8.0) * TAU + PI / 8.0
ring.append(center + Vector2(cos(a), sin(a)) * ring_r)
NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color)
var box: float = 4.2 * s
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
center + Vector2(-box, -box), center + Vector2(box, -box),
center + Vector2(box, box), center + Vector2(-box, box),
]), _active_color(), pulse, core_color)
# Three stubby berths, so the ring reads as occupied rather than as a
# plain octagon.
for i in range(3):
var a: float = (float(i) / 3.0) * TAU - PI / 2.0
var dir: Vector2 = Vector2(cos(a), sin(a))
NeonDraw.draw_glowing_line(self, center + dir * (box * 1.5),
center + dir * (ring_r - 1.5 * s), _active_color(), pulse, 0.35 * s, core_color)
func _draw_crafter_icon(center: Vector2, pulse: float, s: float) -> void: func _draw_crafter_icon(center: Vector2, pulse: float, s: float) -> void:
# Reactor core, truss spine, and an open cradle holding a hull. # Reactor core, truss spine, and an open cradle holding a hull.
var core_centre: Vector2 = center + Vector2(-11, 0) * s var core_centre: Vector2 = center + Vector2(-11, 0) * s

138
Main.tscn
View file

@ -270,10 +270,10 @@ anchor_left = 0.5
anchor_right = 0.5 anchor_right = 0.5
anchor_top = 0.5 anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = -214 offset_left = -204
offset_right = -66 offset_right = -76
offset_top = -254 offset_top = -300
offset_bottom = -106 offset_bottom = -172
script = ExtResource("10") script = ExtResource("10")
icon = 8 icon = 8
glow_color = Color(0.45, 0.9, 0.55, 1) glow_color = Color(0.45, 0.9, 0.55, 1)
@ -286,9 +286,9 @@ anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = -290 offset_left = -290
offset_right = 10 offset_right = 10
offset_top = -96 offset_top = -166
offset_bottom = 4 offset_bottom = -100
theme_override_font_sizes/font_size = 36 theme_override_font_sizes/font_size = 32
autowrap_mode = 2 autowrap_mode = 2
text = "Mothership" text = "Mothership"
horizontal_alignment = 1 horizontal_alignment = 1
@ -299,23 +299,23 @@ anchor_left = 0.5
anchor_right = 0.5 anchor_right = 0.5
anchor_top = 0.5 anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = -204 offset_left = -200
offset_right = -64 offset_right = -80
offset_top = 14 offset_top = -96
offset_bottom = 78 offset_bottom = -44
script = ExtResource("19") script = ExtResource("19")
amount = 20 amount = 20
digit_height = 52.0 digit_height = 40.0
[node name="CrafterButton" type="Control" parent="UI/BuildMenu"] [node name="CrafterButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5 anchor_left = 0.5
anchor_right = 0.5 anchor_right = 0.5
anchor_top = 0.5 anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = 66 offset_left = 76
offset_right = 214 offset_right = 204
offset_top = -254 offset_top = -300
offset_bottom = -106 offset_bottom = -172
script = ExtResource("10") script = ExtResource("10")
icon = 9 icon = 9
glow_color = Color(1, 0.72, 0.25, 1) glow_color = Color(1, 0.72, 0.25, 1)
@ -328,9 +328,9 @@ anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = -10 offset_left = -10
offset_right = 290 offset_right = 290
offset_top = -96 offset_top = -166
offset_bottom = 4 offset_bottom = -100
theme_override_font_sizes/font_size = 36 theme_override_font_sizes/font_size = 32
autowrap_mode = 2 autowrap_mode = 2
text = "Ship Crafter" text = "Ship Crafter"
horizontal_alignment = 1 horizontal_alignment = 1
@ -341,23 +341,23 @@ anchor_left = 0.5
anchor_right = 0.5 anchor_right = 0.5
anchor_top = 0.5 anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = 76 offset_left = 80
offset_right = 216 offset_right = 200
offset_top = 14 offset_top = -96
offset_bottom = 78 offset_bottom = -44
script = ExtResource("19") script = ExtResource("19")
amount = 12 amount = 12
digit_height = 52.0 digit_height = 40.0
[node name="CommandButton" type="Control" parent="UI/BuildMenu"] [node name="CommandButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5 anchor_left = 0.5
anchor_right = 0.5 anchor_right = 0.5
anchor_top = 0.5 anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = -214 offset_left = -204
offset_right = -66 offset_right = -76
offset_top = 118 offset_top = -10
offset_bottom = 266 offset_bottom = 118
script = ExtResource("10") script = ExtResource("10")
icon = 10 icon = 10
glow_color = Color(0.55, 0.6, 1, 1) glow_color = Color(0.55, 0.6, 1, 1)
@ -370,9 +370,9 @@ anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = -290 offset_left = -290
offset_right = 10 offset_right = 10
offset_top = 276 offset_top = 124
offset_bottom = 376 offset_bottom = 190
theme_override_font_sizes/font_size = 36 theme_override_font_sizes/font_size = 32
autowrap_mode = 2 autowrap_mode = 2
text = "Command Center" text = "Command Center"
horizontal_alignment = 1 horizontal_alignment = 1
@ -383,23 +383,23 @@ anchor_left = 0.5
anchor_right = 0.5 anchor_right = 0.5
anchor_top = 0.5 anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = -204 offset_left = -200
offset_right = -64 offset_right = -80
offset_top = 386 offset_top = 194
offset_bottom = 450 offset_bottom = 246
script = ExtResource("19") script = ExtResource("19")
amount = 15 amount = 15
digit_height = 52.0 digit_height = 40.0
[node name="ExtractorButton" type="Control" parent="UI/BuildMenu"] [node name="ExtractorButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5 anchor_left = 0.5
anchor_right = 0.5 anchor_right = 0.5
anchor_top = 0.5 anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = 66 offset_left = 76
offset_right = 214 offset_right = 204
offset_top = 118 offset_top = -10
offset_bottom = 266 offset_bottom = 118
script = ExtResource("10") script = ExtResource("10")
icon = 11 icon = 11
glow_color = Color(0, 0.62, 0.7, 1) glow_color = Color(0, 0.62, 0.7, 1)
@ -412,9 +412,9 @@ anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = -10 offset_left = -10
offset_right = 290 offset_right = 290
offset_top = 276 offset_top = 124
offset_bottom = 376 offset_bottom = 190
theme_override_font_sizes/font_size = 36 theme_override_font_sizes/font_size = 32
autowrap_mode = 2 autowrap_mode = 2
text = "Extractor" text = "Extractor"
horizontal_alignment = 1 horizontal_alignment = 1
@ -425,13 +425,55 @@ anchor_left = 0.5
anchor_right = 0.5 anchor_right = 0.5
anchor_top = 0.5 anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = 76 offset_left = 80
offset_right = 216 offset_right = 200
offset_top = 386 offset_top = 194
offset_bottom = 450 offset_bottom = 246
script = ExtResource("19") script = ExtResource("19")
amount = 25 amount = 25
digit_height = 52.0 digit_height = 40.0
[node name="TransportHubButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -64
offset_right = 64
offset_top = 280
offset_bottom = 408
script = ExtResource("10")
icon = 12
glow_color = Color(0.95, 0.55, 0.85, 1)
[node name="TransportHubLabel" type="Label" parent="UI/BuildMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -150
offset_right = 150
offset_top = 414
offset_bottom = 480
theme_override_font_sizes/font_size = 32
autowrap_mode = 2
text = "Transport Hub"
horizontal_alignment = 1
[node name="TransportHubCost" type="Control" parent="UI/BuildMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -60
offset_right = 60
offset_top = 484
offset_bottom = 536
script = ExtResource("19")
amount = 30
digit_height = 40.0
[node name="CloseButton" type="Control" parent="UI/BuildMenu"] [node name="CloseButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 1.0 anchor_left = 1.0

View file

@ -10,6 +10,7 @@ const WORLD_TO_LOCAL: float = 0.04
const MOTHERSHIP_MARKER_COLOR: Color = Color(0.45, 0.9, 0.55) const MOTHERSHIP_MARKER_COLOR: Color = Color(0.45, 0.9, 0.55)
const CRAFTER_MARKER_COLOR: Color = Color(1.0, 0.72, 0.25) const CRAFTER_MARKER_COLOR: Color = Color(1.0, 0.72, 0.25)
const COMMAND_MARKER_COLOR: Color = Color(0.55, 0.6, 1.0) const COMMAND_MARKER_COLOR: Color = Color(0.55, 0.6, 1.0)
const TRANSPORT_HUB_MARKER_COLOR: Color = Color(0.95, 0.55, 0.85)
# A structure built on a planet's surface (see GameState.extractors) is not # A structure built on a planet's surface (see GameState.extractors) is not
# itself a map marker - it is a small badge on the existing planet marker, # itself a map marker - it is a small badge on the existing planet marker,
@ -50,6 +51,8 @@ func _building_marker_color(type: String) -> Color:
return MOTHERSHIP_MARKER_COLOR return MOTHERSHIP_MARKER_COLOR
GameState.BUILDING_SHIP_CRAFTER: GameState.BUILDING_SHIP_CRAFTER:
return CRAFTER_MARKER_COLOR return CRAFTER_MARKER_COLOR
GameState.BUILDING_TRANSPORT_HUB:
return TRANSPORT_HUB_MARKER_COLOR
_: _:
return COMMAND_MARKER_COLOR return COMMAND_MARKER_COLOR
@ -115,6 +118,8 @@ func _draw() -> void:
_draw_marker_polygon(_regular_polygon(marker.position, 8.0, 6, PI / 6.0), marker.color) _draw_marker_polygon(_regular_polygon(marker.position, 8.0, 6, PI / 6.0), marker.color)
GameState.BUILDING_SHIP_CRAFTER: GameState.BUILDING_SHIP_CRAFTER:
_draw_marker_polygon(_regular_polygon(marker.position, 7.0, 4, 0.0), marker.color) _draw_marker_polygon(_regular_polygon(marker.position, 7.0, 4, 0.0), marker.color)
GameState.BUILDING_TRANSPORT_HUB:
_draw_marker_polygon(_regular_polygon(marker.position, 7.5, 8, PI / 8.0), marker.color)
_: _:
_draw_marker_polygon(_regular_polygon(marker.position, 6.0, 4, PI / 4.0), marker.color) _draw_marker_polygon(_regular_polygon(marker.position, 6.0, 4, PI / 4.0), marker.color)

View file

@ -0,0 +1,20 @@
extends Node2D
# Preview, not a test. Opens the build menu itself, so the three-row layout
# added for the Transport Hub (#18) can be checked for fit on the real phone
# viewport - every button, label and price moved, and a bad offset is
# invisible to a headless test.
#
# bash tests/screenshot.sh res://tests/_build_menu_preview.tscn /tmp/menu.png 20 720x1280
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.rocks = 500
# Everything researched, so nothing is greyed out and the whole menu is
# legible at full strength.
GameState.unlocked = ["shipyards", "logistics", "freight", "coordination"]
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("UI/BuildMenu").visible = true

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_build_menu_preview.gd" id="1"]
[node name="BuildMenuPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -13,8 +13,9 @@ func _ready() -> void:
GameState.buildings.clear() GameState.buildings.clear()
GameState.extractors.clear() GameState.extractors.clear()
GameState.reset_formation() GameState.reset_formation()
GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-230.0, 0.0)) GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-330.0, 0.0))
GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(240.0, 0.0)) GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(140.0, 0.0))
GameState.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(480.0, 0.0))
var main: Node = load("res://Main.tscn").instantiate() var main: Node = load("res://Main.tscn").instantiate()
add_child(main) add_child(main)
@ -24,4 +25,4 @@ func _ready() -> void:
# far enough to see them whole. # far enough to see them whole.
main.get_node("Fleet").move_to(Vector2(0.0, 210.0)) main.get_node("Fleet").move_to(Vector2(0.0, 210.0))
var cam: Camera2D = main.get_node("Camera2D") var cam: Camera2D = main.get_node("Camera2D")
cam.zoom = Vector2(1.35, 1.35) cam.zoom = Vector2(1.0, 1.0)

View file

@ -0,0 +1,157 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_transport_hub_building.gd
# Forgejo #18 (part 5 of #12): the Transport Hub is a placeable building of
# its own. Only the building here - the vessels that do the hauling are #19.
#
# Verifies:
# 1. it is gated behind its research node, like every other structure
# 2. researching it makes it buildable, and placing it charges its cost
# 3. it is drawn as ITSELF, not as the crafter - Building._draw()'s match
# has a catch-all that renders anything unrecognised as a ship crafter,
# so a missing case is silent and looks almost right
# 4. its geometry is rasterised ONCE however long it animates (#10), and
# its stationed transports really do move
# 5. it has its own build-menu icon and its own map marker rather than
# borrowing another building's - the menu was once unreadable because
# two buildings shared a glyph
var game_state: Node
var scene: Node
var field: Node
var controller: Node
var map_content: Node2D
var hub: Node2D
var stage: int = 0
var elapsed: float = 0.0
var spin_at_start: float = 0.0
const HUB_AT: Vector2 = Vector2(700.0, 0.0)
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.reset_progression()
game_state.rocks = 500
game_state.fleet_cargo = 0
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
field = scene.get_node("BuildingField")
controller = scene.get_node("BuildController")
map_content = scene.get_node("UI/WorldMap/MapContent")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
# --- 1. locked until researched ------------------------------
if game_state.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB):
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (buildable with no research at all)")
quit(1)
return true
var gate: String = game_state.research_for_building(GameState.BUILDING_TRANSPORT_HUB)
if gate == "":
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (no research node gates it)")
quit(1)
return true
print("locked behind research: '%s'" % gate)
# --- 5. its own icon, distinct from every other building -----
var icons: Dictionary = {}
for type in [GameState.BUILDING_MOTHERSHIP, GameState.BUILDING_SHIP_CRAFTER,
GameState.BUILDING_COMMAND_CENTER, GameState.BUILDING_TRANSPORT_HUB]:
var icon: int = controller._action_icon_for(type)
if icons.has(icon):
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (%s and %s share an icon)" % [type, icons[icon]])
quit(1)
return true
icons[icon] = type
print("action icons distinct across %d buildings" % icons.size())
# --- 2. research it, then build it ---------------------------
game_state.unlocked = ["shipyards", "logistics", "freight"]
if not game_state.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB):
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (still locked after researching '%s')" % gate)
quit(1)
return true
var before: int = game_state.rocks
field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_AT)
game_state.spend(controller.cost_of(GameState.BUILDING_TRANSPORT_HUB))
if game_state.rocks != before - GameState.TRANSPORT_HUB_COST:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (cost %d charged, expected %d)" % [
before - game_state.rocks, GameState.TRANSPORT_HUB_COST
])
quit(1)
return true
print("researched and placed for %d ore" % GameState.TRANSPORT_HUB_COST)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
for b in field.spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_TRANSPORT_HUB:
hub = b
if hub == null:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (never spawned)")
quit(1)
return true
# --- 3. drawn as itself, not via the crafter catch-all -------
# _hub_traffic only exists if _ready() took the transport-hub
# branch; a missing case falls through to the crafter and would
# leave it null while still rendering something plausible.
if hub._hub_traffic == null:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (rendered through the crafter catch-all, not as a hub)")
quit(1)
return true
# --- 5. its own map marker ----------------------------------
var markers: Array = map_content.building_markers()
var found: bool = false
for m in markers:
if m.type == GameState.BUILDING_TRANSPORT_HUB:
found = true
if m.color == map_content.CRAFTER_MARKER_COLOR \
or m.color == map_content.MOTHERSHIP_MARKER_COLOR \
or m.color == map_content.COMMAND_MARKER_COLOR:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (map marker reuses another building's colour)")
quit(1)
return true
if not found:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (no map marker - see #11)")
quit(1)
return true
print("has its own map marker")
spin_at_start = hub._hub_traffic.rotation
elapsed = 0.0
stage = 2
return false
2:
# --- 4. animates without ever redrawing (#10) ----------------
if elapsed < 2.0:
return false
print("rasterisations after %.1fs: %d" % [elapsed, hub.draw_count])
if hub.draw_count != 1:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (redrew its geometry %d times to animate)" % hub.draw_count)
quit(1)
return true
var spun: float = absf(hub._hub_traffic.rotation - spin_at_start)
print("stationed transports turned %.3f rad" % spun)
if spun < 0.05:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (nothing moved - a frozen hub trivially passes the redraw check)")
quit(1)
return true
print("TEST_TRANSPORT_HUB_BUILDING: PASS")
quit(0)
return true
return false