Compose buildings from definitions and capabilities

This commit is contained in:
Loïc Wisniewski 2026-08-14 21:44:28 +02:00
commit 0504b24d8d
46 changed files with 1091 additions and 878 deletions

View file

@ -1,5 +1,7 @@
extends Node2D extends Node2D
const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd")
# Space-side building UI. Owns: # Space-side building UI. Owns:
# - the build menu (place a mothership, ship crafter or command centre) # - the build menu (place a mothership, ship crafter or command centre)
# - the shared ACTION button, which adapts to whatever the fleet is # - the shared ACTION button, which adapts to whatever the fleet is
@ -22,14 +24,8 @@ extends Node2D
@onready var building_action_button: IconButton = get_node("../UI/BuildingActionButton") @onready var building_action_button: IconButton = get_node("../UI/BuildingActionButton")
@onready var build_menu: Control = get_node("../UI/BuildMenu") @onready var build_menu: Control = get_node("../UI/BuildMenu")
@onready var build_mothership_button: IconButton = get_node("../UI/BuildMenu/MothershipButton")
@onready var build_crafter_button: IconButton = get_node("../UI/BuildMenu/CrafterButton")
@onready var build_command_button: IconButton = get_node("../UI/BuildMenu/CommandButton")
@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_hub_button: IconButton = get_node("../UI/BuildMenu/TransportHubButton")
@onready var build_lab_button: IconButton = get_node("../UI/BuildMenu/ShipLabButton")
@onready var lab_menu: Control = get_node("../UI/LabMenu") @onready var lab_menu: Control = get_node("../UI/LabMenu")
@onready var lab_vessel_buttons: Array = [ @onready var lab_vessel_buttons: Array = [
get_node("../UI/LabMenu/VesselA"), get_node("../UI/LabMenu/VesselB"), get_node("../UI/LabMenu/VesselA"), get_node("../UI/LabMenu/VesselB"),
@ -78,20 +74,7 @@ extends Node2D
get_node("../UI/HangarMenu/FleetShieldLabel"), get_node("../UI/HangarMenu/FleetShieldLabel"),
get_node("../UI/HangarMenu/FleetTransporterLabel"), get_node("../UI/HangarMenu/FleetTransporterLabel"),
] ]
@onready var build_menu_entries: Array = [ var build_menu_entries: Array = []
[GameState.BUILDING_MOTHERSHIP, build_mothership_button,
get_node("../UI/BuildMenu/MothershipLabel"), get_node("../UI/BuildMenu/MothershipCost")],
[GameState.BUILDING_SHIP_CRAFTER, build_crafter_button,
get_node("../UI/BuildMenu/CrafterLabel"), get_node("../UI/BuildMenu/CrafterCost")],
[GameState.BUILDING_COMMAND_CENTER, build_command_button,
get_node("../UI/BuildMenu/CommandLabel"), get_node("../UI/BuildMenu/CommandCost")],
[GameState.BUILDING_EXTRACTOR, build_extractor_button,
get_node("../UI/BuildMenu/ExtractorLabel"), get_node("../UI/BuildMenu/ExtractorCost")],
[GameState.BUILDING_TRANSPORT_HUB, build_hub_button,
get_node("../UI/BuildMenu/TransportHubLabel"), get_node("../UI/BuildMenu/TransportHubCost")],
[GameState.BUILDING_SHIP_LAB, build_lab_button,
get_node("../UI/BuildMenu/ShipLabLabel"), get_node("../UI/BuildMenu/ShipLabCost")],
]
# Which vessel the lab screen is showing, and which visible upgrade card is # Which vessel the lab screen is showing, and which visible upgrade card is
# selected. Each vessel gets its own small set of direct cards; the detail and # selected. Each vessel gets its own small set of direct cards; the detail and
@ -148,41 +131,6 @@ var lab_building_id: int = 0
@onready var formation_close_button: IconButton = get_node("../UI/FormationMenu/CloseButton") @onready var formation_close_button: IconButton = get_node("../UI/FormationMenu/CloseButton")
@onready var formation_reset_button: IconButton = get_node("../UI/FormationMenu/ResetButton") @onready var formation_reset_button: IconButton = get_node("../UI/FormationMenu/ResetButton")
# What each option is and does, shown before anything is spent. Picking a
# structure is a real commitment - it costs ore, and a mothership placement
# can destroy the one you already have - so no option builds straight from
# the menu; each one explains itself first and waits for a confirmation.
#
# The bodies are kept short on purpose: at the UI's font size the popup holds
# roughly eight lines, and a placement condition or a price warning is
# appended below them and needs some of those lines.
const BUILDING_INFO: Dictionary = {
GameState.BUILDING_MOTHERSHIP: {
"title": "Mothership",
"body": "Fleet home port. A wiped-out fleet rebuilds here, and extractor haulers deliver ore here.",
},
GameState.BUILDING_SHIP_CRAFTER: {
"title": "Ship Crafter",
"body": "Fly the fleet onto it, then use the action button to build extra vessels.",
},
GameState.BUILDING_COMMAND_CENTER: {
"title": "Command Center",
"body": "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it.",
},
GameState.BUILDING_SHIP_LAB: {
"title": "Ship Lab",
"body": "Fly the fleet onto it to upgrade each vessel type, and to unlock the shield vessel.",
},
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: {
"title": "Extractor",
"body": "Mines a planet and runs a hauler to your mothership. It keeps earning while you are away.",
},
}
# Which structure the popup is currently offering to build, or "" when it is # Which structure the popup is currently offering to build, or "" when it is
# only explaining something that cannot be built right now. # only explaining something that cannot be built right now.
var pending_build_type: String = "" var pending_build_type: String = ""
@ -210,12 +158,7 @@ var context_actions_expanded: bool = false
func _ready() -> void: func _ready() -> void:
build_button.pressed.connect(_on_build_pressed) build_button.pressed.connect(_on_build_pressed)
build_close_button.pressed.connect(_close_build_menu) build_close_button.pressed.connect(_close_build_menu)
build_mothership_button.pressed.connect(_on_place_mothership) _configure_build_menu_entries()
build_crafter_button.pressed.connect(_on_place_crafter)
build_command_button.pressed.connect(_on_place_command_center)
build_extractor_button.pressed.connect(_on_place_extractor)
build_hub_button.pressed.connect(_on_place_transport_hub)
build_lab_button.pressed.connect(_on_place_ship_lab)
craft_shield_button.pressed.connect(_on_craft_shield) craft_shield_button.pressed.connect(_on_craft_shield)
lab_close_button.pressed.connect(func(): lab_menu.visible = false) lab_close_button.pressed.connect(func(): lab_menu.visible = false)
lab_buy_button.pressed.connect(_on_lab_buy) lab_buy_button.pressed.connect(_on_lab_buy)
@ -267,6 +210,21 @@ func _ready() -> void:
_layout_context_buttons() _layout_context_buttons()
func _configure_build_menu_entries() -> void:
build_menu_entries.clear()
for definition in BUILDING_DEFINITIONS.all():
if definition.menu_button_name == "":
continue
var button: IconButton = get_node("../UI/BuildMenu/%s" % definition.menu_button_name)
var label: Label = get_node("../UI/BuildMenu/%s" % definition.menu_label_name)
var cost_number: NeonNumber = get_node("../UI/BuildMenu/%s" % definition.menu_cost_name)
button.icon = definition.menu_icon
button.pressed.connect(_show_building_info.bind(String(definition.definition_id)))
label.text = definition.display_name
cost_number.amount = definition.cost
build_menu_entries.append([definition.definition_id, button, label, cost_number, definition])
# True while a building prompt or panel owns the shared action slot, so the # True while a building prompt or panel owns the shared action slot, so the
# enter-atmosphere prompt knows to stay out of the way. # enter-atmosphere prompt knows to stay out of the way.
func is_showing_building_prompt() -> bool: func is_showing_building_prompt() -> bool:
@ -318,9 +276,7 @@ func _process(delta: float) -> void:
if build_menu.visible: if build_menu.visible:
# Keep the greyed state live while the menu is open, so flying onto # Keep the greyed state live while the menu is open, so flying onto
# a planet lights the option up without reopening it. # a planet lights the option up without reopening it.
build_extractor_button.enabled = _planet_under_fleet() != null \ _refresh_build_option_states()
and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR)
build_extractor_button.queue_redraw()
if craft_menu.visible: if craft_menu.visible:
# Live, so a delivery arriving while the menu is open visibly works # Live, so a delivery arriving while the menu is open visibly works
@ -359,10 +315,7 @@ func _process(delta: float) -> void:
current_building = pending_building current_building = pending_building
context_actions_expanded = false context_actions_expanded = false
# The hangar is where finished vessels are delivered (#23), so it belongs hangar_button.visible = false
# at the mothership and nowhere else.
hangar_button.visible = current_building != null \
and current_building.building_type == GameState.BUILDING_MOTHERSHIP
# Every building can hold ore, so the transfer button rides alongside the # Every building can hold ore, so the transfer button rides alongside the
# action button rather than being one of the things the action button # action button rather than being one of the things the action button
# turns into - the two are independent choices at the same structure. # turns into - the two are independent choices at the same structure.
@ -371,7 +324,7 @@ func _process(delta: float) -> void:
action_button.visible = current_building != null action_button.visible = current_building != null
if current_building != null: if current_building != null:
var actions: Array = _context_action_buttons() var actions: Array = _context_action_buttons()
building_action_button.icon = _action_icon_for(current_building.building_type) building_action_button.icon = int(current_building.definition.action_icon)
building_action_button.queue_redraw() building_action_button.queue_redraw()
action_button.icon = building_action_button.icon if actions.size() == 1 else IconButton.Icon.ACTION_MENU action_button.icon = building_action_button.icon if actions.size() == 1 else IconButton.Icon.ACTION_MENU
action_button.queue_redraw() action_button.queue_redraw()
@ -403,13 +356,20 @@ func _layout_context_buttons() -> void:
func _context_action_buttons() -> Array: func _context_action_buttons() -> Array:
if current_building == null: if current_building == null:
return [] return []
var actions: Array = [transfer_button] var buttons: Array = []
if current_building.building_type in [GameState.BUILDING_MOTHERSHIP, GameState.BUILDING_SHIP_CRAFTER, for action in current_building.available_actions():
GameState.BUILDING_SHIP_LAB, GameState.BUILDING_COMMAND_CENTER]: var button: IconButton = _button_for_building_action(action)
actions.push_front(building_action_button) if button != null and button not in buttons:
if current_building.building_type == GameState.BUILDING_MOTHERSHIP: buttons.append(button)
actions.append(hangar_button) return buttons
return actions
func _button_for_building_action(action: String) -> IconButton:
if action == "transfer":
return transfer_button
if action == "hangar":
return hangar_button
return building_action_button
func _on_context_action_pressed() -> void: func _on_context_action_pressed() -> void:
@ -433,20 +393,6 @@ func _collapse_context_actions() -> void:
_layout_context_buttons() _layout_context_buttons()
func _action_icon_for(type: String) -> int:
match type:
GameState.BUILDING_SHIP_CRAFTER:
return IconButton.Icon.SHIP
GameState.BUILDING_TRANSPORT_HUB:
return IconButton.Icon.TRANSPORT_HUB
GameState.BUILDING_SHIP_LAB:
return IconButton.Icon.SHIP_LAB
GameState.BUILDING_MOTHERSHIP:
return IconButton.Icon.MOTHERSHIP
_:
return IconButton.Icon.COMMAND_CENTER
func _check_fleet_wipe() -> void: func _check_fleet_wipe() -> void:
if not fleet.ships.is_empty(): if not fleet.ships.is_empty():
return return
@ -465,11 +411,12 @@ func _on_action_pressed() -> void:
for bar in resource_bars: for bar in resource_bars:
bar.building_id = current_building.building_id bar.building_id = current_building.building_id
if current_building.building_type == GameState.BUILDING_MOTHERSHIP: var action: String = current_building.primary_action()
if action == "research":
skill_menu.visible = true skill_menu.visible = true
skill_tree.reset_scroll() skill_tree.reset_scroll()
_refresh_skill_menu() _refresh_skill_menu()
elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: elif action == "craft":
# Remembered explicitly: _process() clears current_building while any # Remembered explicitly: _process() clears current_building while any
# menu is open, so by the next frame the panel would no longer know # menu is open, so by the next frame the panel would no longer know
# which crafter it belongs to. Losing this line makes every button in # which crafter it belongs to. Losing this line makes every button in
@ -478,11 +425,11 @@ func _on_action_pressed() -> void:
craft_building_id = current_building.building_id craft_building_id = current_building.building_id
craft_menu.visible = true craft_menu.visible = true
_refresh_craft_status() _refresh_craft_status()
elif current_building.building_type == GameState.BUILDING_SHIP_LAB: elif action == "lab":
lab_building_id = current_building.building_id lab_building_id = current_building.building_id
lab_menu.visible = true lab_menu.visible = true
_refresh_lab() _refresh_lab()
elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER: elif action == "formation":
formation_menu.visible = true formation_menu.visible = true
formation_editor.queue_redraw() formation_editor.queue_redraw()
@ -551,8 +498,7 @@ func _refresh_build_status() -> void:
# Researched but not usable right here is still shown, greyed: an # Researched but not usable right here is still shown, greyed: an
# extractor needs a planet under the fleet, which is a "not now" rather # extractor needs a planet under the fleet, which is a "not now" rather
# than a "not yet". # than a "not yet".
build_extractor_button.enabled = _planet_under_fleet() != null \ _refresh_build_option_states()
and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR)
# Prices are shown under each option now, so this only needs to say # Prices are shown under each option now, so this only needs to say
# whether the fleet is somewhere an extractor could go. # whether the fleet is somewhere an extractor could go.
@ -562,16 +508,14 @@ func _refresh_build_status() -> void:
build_status.text = "" build_status.text = ""
func _on_place_mothership() -> void: func _refresh_build_option_states() -> void:
_show_building_info(GameState.BUILDING_MOTHERSHIP) for entry in build_menu_entries:
var definition: Resource = entry[4]
var enabled: bool = GameState.is_building_unlocked(definition.definition_id)
func _on_place_crafter() -> void: if definition.placement_mode == "planet":
_show_building_info(GameState.BUILDING_SHIP_CRAFTER) enabled = enabled and _planet_under_fleet() != null
entry[1].enabled = enabled
entry[1].queue_redraw()
func _on_place_command_center() -> void:
_show_building_info(GameState.BUILDING_COMMAND_CENTER)
func _close_build_menu() -> void: func _close_build_menu() -> void:
@ -582,40 +526,17 @@ func _close_build_menu() -> void:
build_menu.visible = false build_menu.visible = false
func _on_place_ship_lab() -> void:
_show_building_info(GameState.BUILDING_SHIP_LAB)
func _on_place_transport_hub() -> void:
_show_building_info(GameState.BUILDING_TRANSPORT_HUB)
func _on_place_extractor() -> void:
# The option stays pressable while greyed, so this is also how a
# not-over-a-planet extractor explains itself.
_show_building_info(GameState.BUILDING_EXTRACTOR)
func cost_of(type: String) -> int: func cost_of(type: String) -> int:
match type: var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
GameState.BUILDING_TRANSPORT_HUB: return int(definition.cost) if definition != null else 0
return GameState.TRANSPORT_HUB_COST
GameState.BUILDING_SHIP_LAB:
return GameState.SHIP_LAB_COST
GameState.BUILDING_MOTHERSHIP:
return GameState.MOTHERSHIP_COST
GameState.BUILDING_SHIP_CRAFTER:
return GameState.SHIP_CRAFTER_COST
GameState.BUILDING_COMMAND_CENTER:
return GameState.COMMAND_CENTER_COST
_:
return GameState.EXTRACTOR_COST
func _show_building_info(type: String) -> void: func _show_building_info(type: String) -> void:
var info: Dictionary = BUILDING_INFO[type] var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
var cost: int = cost_of(type) if definition == null:
var lines: Array = [info.body] return
var cost: int = int(definition.cost)
var lines: Array = [definition.description]
var can_build: bool = true var can_build: bool = true
# Locked structures still list themselves, greyed, so the player can see # Locked structures still list themselves, greyed, so the player can see
@ -626,7 +547,7 @@ func _show_building_info(type: String) -> void:
# Anything that would stop this particular placement is spelled out in # Anything that would stop this particular placement is spelled out in
# the same popup, rather than silently failing after the press. # the same popup, rather than silently failing after the press.
if can_build and type == GameState.BUILDING_EXTRACTOR: if can_build and definition.placement_mode == "planet":
var planet: Node2D = _planet_under_fleet() var planet: Node2D = _planet_under_fleet()
if planet == null: if planet == null:
lines.append("No planet in reach - fly over one.") lines.append("No planet in reach - fly over one.")
@ -635,15 +556,15 @@ func _show_building_info(type: String) -> void:
lines.append("%s already has an extractor." % planet.planet_name) lines.append("%s already has an extractor." % planet.planet_name)
can_build = false can_build = false
else: else:
lines.append("Will mine %s from %s." % ["gas" if planet.resource_type == "gas" else "ore", planet.planet_name]) lines.append("Will mine %s from %s." % [GameState.resource_name(planet.resource_type), planet.planet_name])
elif can_build and type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): elif can_build and definition.unique and GameState.has_building_of_type(type):
lines.append("WARNING: only one may exist - this destroys your current one.") lines.append("WARNING: only one may exist - this destroys your current one.")
if can_build and not GameState.can_afford(cost): if can_build and not GameState.can_afford(cost):
lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.rocks]) lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.rocks])
can_build = false can_build = false
info_title.text = info.title info_title.text = definition.display_name
info_message.text = "\n\n".join(lines) info_message.text = "\n\n".join(lines)
info_cost.amount = cost info_cost.amount = cost
info_build_button.visible = can_build info_build_button.visible = can_build
@ -668,28 +589,31 @@ func _confirm_build() -> void:
# planet an extractor was going to mine. # planet an extractor was going to mine.
if not GameState.is_building_unlocked(type): if not GameState.is_building_unlocked(type):
return return
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
if definition == null:
return
if type == GameState.BUILDING_EXTRACTOR: if definition.placement_mode == "planet":
var planet: Node2D = _planet_under_fleet() var planet: Node2D = _planet_under_fleet()
if planet == null or GameState.has_extractor_at(planet.planet_name): if planet == null or GameState.has_extractor_at(planet.planet_name):
build_status.text = "No planet in reach any more" build_status.text = "No planet in reach any more"
return return
if not GameState.spend(GameState.EXTRACTOR_COST): if not GameState.spend(definition.cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [GameState.EXTRACTOR_COST, GameState.rocks] build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.rocks]
return return
GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type) GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type)
build_menu.visible = false build_menu.visible = false
return return
if type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): if definition.unique and GameState.has_building_of_type(type):
if not GameState.can_afford(GameState.MOTHERSHIP_COST): if not GameState.can_afford(definition.cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [ build_status.text = "Not enough rocks (need %d, have %d)" % [
GameState.MOTHERSHIP_COST, GameState.rocks definition.cost, GameState.rocks
] ]
return return
# Only now is the old one destroyed - never before the new one is # Only now is the old one destroyed - never before the new one is
# known to be affordable, or the player would be left with none. # known to be affordable, or the player would be left with none.
GameState.remove_buildings_of_type(GameState.BUILDING_MOTHERSHIP) GameState.remove_buildings_of_type(type)
building_field.rebuild_all() building_field.rebuild_all()
_try_place(type, cost_of(type)) _try_place(type, cost_of(type))

View file

@ -1,665 +1,42 @@
extends Node2D extends Node2D
# A player-placed structure in space. Three kinds for now: const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd")
# 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" @export var definition_id: 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 building_id: int = 0
@export var interact_radius: float = 150.0 @export var interact_radius: float = 150.0
const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55) var definition: Resource
const CRAFTER_COLOR: Color = Color(1.0, 0.72, 0.25) var visual: Node2D
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 var building_type: String:
# over it. Reads as a workshop rather than a factory - the crafter already get: return definition_id
# owns "assembly line", so this is measurement, not production. set(value): definition_id = value
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: func _ready() -> void:
add_to_group("buildings") add_to_group("buildings")
z_index = -1 z_index = -1
# The crafter runs an assembly line and the mothership holds a live definition = BUILDING_DEFINITIONS.get_definition(definition_id)
# reactor with satellites in orbit, so both animate. The command centre is if definition == null or definition.visual_scene == null:
# entirely static and needs no per-frame work at all. return
var animated: bool = building_type != GameState.BUILDING_COMMAND_CENTER visual = definition.visual_scene.instantiate()
set_process(animated) get_node("VisualHost").add_child(visual)
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: func available_actions() -> PackedStringArray:
anim_time = fmod(anim_time + delta, CYCLE) if visual == null:
phase_time += delta return PackedStringArray()
apply_animation() var provider: Node = visual.get_node_or_null("ActionProvider")
return provider.actions if provider != null else PackedStringArray()
# Pushes the current clock onto the movable pieces. Normally driven from func primary_action() -> String:
# _process; public so a preview or a test can pin the clock and see exactly for action in available_actions():
# that frame without having to run the animation up to it in real time. if action != "transfer" and action != "hangar":
func apply_animation() -> void: return action
match building_type: return ""
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: func get_display_name() -> String:
match building_type: return String(definition.display_name) if definition != null else definition_id.capitalize()
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))

View file

@ -4,3 +4,5 @@
[node name="Building" type="Node2D"] [node name="Building" type="Node2D"]
script = ExtResource("1") script = ExtResource("1")
[node name="VisualHost" type="Node2D" parent="."]

View file

@ -0,0 +1,3 @@
extends Node
@export var actions: PackedStringArray = []

View file

@ -0,0 +1 @@
uid://c4dah6xluvenl

20
BuildingDefinition.gd Normal file
View file

@ -0,0 +1,20 @@
class_name BuildingDefinition
extends Resource
@export var definition_id: String
@export var display_name: String
@export_multiline var description: String
@export var cost: int
@export var action_icon: int
@export var menu_icon: int
@export var visual_scene: PackedScene
@export var capabilities: PackedStringArray = []
@export var map_color: Color = Color.WHITE
@export var map_sides: int = 4
@export var map_radius: float = 7.0
@export var map_phase: float = 0.0
@export var menu_button_name: String
@export var menu_label_name: String
@export var menu_cost_name: String
@export_enum("space", "planet") var placement_mode: String = "space"
@export var unique: bool = false

View file

@ -0,0 +1 @@
uid://d10s0frtxiw62

21
BuildingDefinitions.gd Normal file
View file

@ -0,0 +1,21 @@
class_name BuildingDefinitions
extends RefCounted
const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "extractor", "transport_hub", "ship_lab"]
const DEFINITIONS: Dictionary = {
"mothership": preload("res://MothershipBuildingDefinition.tres"),
"ship_crafter": preload("res://CrafterBuildingDefinition.tres"),
"command_center": preload("res://CommandBuildingDefinition.tres"),
"extractor": preload("res://ExtractorBuildingDefinition.tres"),
"transport_hub": preload("res://TransportHubBuildingDefinition.tres"),
"ship_lab": preload("res://ShipLabBuildingDefinition.tres"),
}
static func get_definition(definition_id: String) -> Resource:
return DEFINITIONS.get(definition_id)
static func all() -> Array:
var result: Array = []
for definition_id in ORDER:
result.append(DEFINITIONS[definition_id])
return result

View file

@ -0,0 +1 @@
uid://cpm0u3gu0exke

View file

@ -50,7 +50,7 @@ func get_building_fleet_is_on() -> Node2D:
func _spawn(record: Dictionary) -> void: func _spawn(record: Dictionary) -> void:
var b: Node2D = building_scene.instantiate() var b: Node2D = building_scene.instantiate()
b.position = record.position b.position = record.position
b.building_type = record.type b.definition_id = record.type
b.building_id = int(record.get("id", 0)) b.building_id = int(record.get("id", 0))
# Deferred for the same reason as the other field spawners: this can run # Deferred for the same reason as the other field spawners: this can run
# from inside a parent's _ready() chain, where a direct add_child() is # from inside a parent's _ready() chain, where a direct add_child() is

606
BuildingVisualBase.gd Normal file
View file

@ -0,0 +1,606 @@
class_name BuildingVisualBase
extends Node2D
# A player-placed structure in space. Three kinds for now:
# mothership - where a wiped-out fleet is rebuilt
# ship_crafter - fly the fleet onto it to buy additional ships
# command_center - reconfigure the fleet formation
#
# Buildings are stationary and harmless; whether the fleet is "on" one is
# decided by BuildingField comparing the fleet CENTROID against
# interact_radius, the same stable single-number test Planet uses (see
# Planet.gd for why per-ship overlap was a poor fit).
#
# Nothing here rests on ground or vents into an atmosphere - these are
# orbital structures, so they are built out of trusses, radiator fins,
# reactor cores and open cradles rather than walls and roofs.
#
# The crafter and the mothership animate, so they run _process. The command
# centre is drawn once and never redrawn - per-frame redraws of art that
# never changes were a real framerate cost earlier on.
const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55)
const CRAFTER_COLOR: Color = Color(1.0, 0.72, 0.25)
const COMMAND_COLOR: Color = Color(0.55, 0.6, 1.0)
const TRANSPORT_HUB_COLOR: Color = Color(0.95, 0.55, 0.85)
const SHIP_LAB_COLOR: Color = Color(0.55, 0.95, 0.9)
# 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 _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:
pass
# Where the satellites are, and how open the reactor core is. Both are read
# by _draw and by the test that checks they stay continuous across the
# assembly clock's wrap.
func orbit_angle() -> float:
return phase_time * ORBIT_SPEED
func core_pulse() -> float:
return 0.5 + 0.5 * sin(phase_time * PULSE_SPEED)
# How many times this building's geometry has actually been rasterised. The
# whole point of the NeonPiece decomposition is that this stays at 1 for the
# building's lifetime however long it animates, so it is worth being able to
# assert rather than assume.
var draw_count: int = 0
func register_draw() -> void:
draw_count += 1
# --- shared helpers -------------------------------------------------------
func _ring(centre: Vector2, radius: float, sides: int, phase: float = 0.0) -> PackedVector2Array:
var pts: PackedVector2Array = PackedVector2Array()
for i in range(sides):
var a: float = (float(i) / float(sides)) * TAU + phase
pts.append(centre + Vector2(cos(a), sin(a)) * radius)
return pts
func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int) -> PackedVector2Array:
var pts: PackedVector2Array = PackedVector2Array()
for i in range(steps + 1):
var a: float = lerpf(from_a, to_a, float(i) / float(steps))
pts.append(centre + Vector2(cos(a), sin(a)) * radius)
return pts
# --- command centre: a tactical plotting hub ------------------------------
# The mothership is radial-and-swept, the crafter is an asymmetric assembly
# line, so the command centre reads best as the odd one out: rigid, hexagonal,
# a wheel rather than a claw. Six spokes tie a hex rim to a central diamond
# readout, and every spoke ends in a formation port - half of them holding a
# token, half left open - so the structure itself shows fleet slots waiting
# to be filled, which is exactly what the drag-and-drop editor inside does.
const CMD_HEX_RADIUS: float = 88.0
const CMD_DECK_RADIUS: float = 66.0
const CMD_HUB_RADIUS: float = 30.0
const CMD_SCREEN_RADIUS: float = 14.0
const CMD_PORT_COUNT: int = 6
const CMD_PORT_LENGTH: float = 16.0
const CMD_TOKEN_RADIUS: float = 9.0
const CMD_STUD_RADIUS: float = 7.0
# --- ship lab -------------------------------------------------------------
func _draw_ship_lab_static(target: CanvasItem) -> void:
var c: Color = SHIP_LAB_COLOR
# Outer test frame, open at the corners so it reads as a rig rather than
# a solid hull.
for sx in [-1.0, 1.0]:
for sy in [-1.0, 1.0]:
var corner: Vector2 = Vector2(sx * LAB_FRAME_W, sy * LAB_FRAME_H)
NeonDraw.draw_glowing_line(target, corner,
corner - Vector2(sx * LAB_FRAME_W * 0.45, 0.0), c, 0.9, 0.42)
NeonDraw.draw_glowing_line(target, corner,
corner - Vector2(0.0, sy * LAB_FRAME_H * 0.5), c, 0.9, 0.42)
# The vessel under test, held nose-up in the middle - deliberately the
# fleet's own silhouette, so it is obvious what the lab is for.
NeonDraw.draw_glowing_polygon(target, PackedVector2Array([
Vector2(0, -26), Vector2(13, 12), Vector2(0, 5), Vector2(-13, 12),
]), HULL_COLOR, 0.95)
# Mount ring and its bracing.
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, LAB_MOUNT_RADIUS, 6, PI / 6.0), c, 0.5,
NeonDraw.DEFAULT_CORE, 0.7)
for i in range(4):
var a: float = (float(i) / 4.0) * TAU + PI / 4.0
var dir: Vector2 = Vector2(cos(a), sin(a))
NeonDraw.draw_glowing_line(target, dir * LAB_MOUNT_RADIUS, dir * 40.0, c, 0.45, 0.3)
func _build_lab_pieces() -> void:
# Two instrument booms sweeping round the vessel. One holder, rasterised
# once, rotated per frame (#10).
_lab_scanners = Node2D.new()
add_child(_lab_scanners)
for i in range(2):
var a: float = float(i) * PI
var at: Vector2 = Vector2(cos(a), sin(a)) * LAB_SCANNER_ORBIT
var piece: NeonPiece = NeonPiece.create(_lab_scanners, at, SHIP_LAB_COLOR)
piece.add_polygon(PackedVector2Array([
Vector2(-4, -12), Vector2(4, -12), Vector2(4, 12), Vector2(-4, 12),
]))
piece.add_line(Vector2(0, 0), Vector2(-cos(a), -sin(a)) * 26.0, 0.3, 0.5)
piece.rotation = a
piece.queue_redraw()
func _animate_ship_lab() -> void:
_lab_scanners.rotation = phase_time * LAB_SCAN_SPEED
# --- transport hub --------------------------------------------------------
func _draw_transport_hub_static(target: CanvasItem) -> void:
var c: Color = TRANSPORT_HUB_COLOR
# Dock ring and the deck nested inside it.
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_RING_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 1.0)
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_DECK_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 0.45, NeonDraw.DEFAULT_CORE, 0.7)
# Freight core: a squared-off hold rather than a reactor, so it reads as
# storage and not as another power plant.
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_CORE_RADIUS, 4, PI / 4.0), c, 0.95)
for i in range(HUB_BERTH_COUNT):
var a: float = (float(i) / HUB_BERTH_COUNT) * TAU + PI / 8.0
var dir: Vector2 = Vector2(cos(a), sin(a))
NeonDraw.draw_glowing_line(target, dir * HUB_CORE_RADIUS, dir * HUB_DECK_RADIUS, c, 0.5, 0.32)
# Berth arm standing off the ring, where a transporter parks.
NeonDraw.draw_glowing_line(target, dir * HUB_RING_RADIUS,
dir * (HUB_RING_RADIUS + HUB_BERTH_LENGTH), c, 0.75, 0.38)
func _build_hub_pieces() -> void:
# Stationed transports, as NeonPiece children of one holder that turns.
# Per #10 the geometry is rasterised once and only the holder's rotation
# changes, so the traffic costs no redraws at all.
_hub_traffic = Node2D.new()
add_child(_hub_traffic)
for i in range(HUB_DOCKED_COUNT):
var a: float = (float(i) / HUB_DOCKED_COUNT) * TAU
var at: Vector2 = Vector2(cos(a), sin(a)) * HUB_DOCKED_ORBIT
var piece: NeonPiece = NeonPiece.create(_hub_traffic, at, TRANSPORT_HUB_COLOR)
# A stubby freighter: blunt, boxy, nothing like the fleet's darts.
piece.add_polygon(PackedVector2Array([
Vector2(-9, -5), Vector2(9, -5), Vector2(12, 0), Vector2(9, 5), Vector2(-9, 5),
]))
piece.rotation = a + PI / 2.0
piece.queue_redraw()
func _animate_transport_hub() -> void:
_hub_traffic.rotation = phase_time * HUB_SPIN_SPEED
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))

View file

@ -0,0 +1 @@
uid://p7vuv1xsksc

View file

@ -0,0 +1,20 @@
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://CommandBuildingVisual.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "command_center"
display_name = "Command Center"
description = "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it."
cost = 15
action_icon = 10
menu_icon = 10
visual_scene = ExtResource("2")
capabilities = PackedStringArray("formation")
map_color = Color(0.55, 0.6, 1, 1)
map_sides = 4
map_radius = 6.0
map_phase = 0.785398
menu_button_name = "CommandButton"
menu_label_name = "CommandLabel"
menu_cost_name = "CommandCost"

9
CommandBuildingVisual.gd Normal file
View file

@ -0,0 +1,9 @@
extends "res://BuildingVisualBase.gd"
func _ready() -> void:
set_process(false)
queue_redraw()
func _draw() -> void:
register_draw()
_draw_command_center()

View file

@ -0,0 +1 @@
uid://bpb5pq8g56ywn

View file

@ -0,0 +1,11 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://CommandBuildingVisual.gd" id="1"]
[ext_resource type="Script" path="res://BuildingActionProvider.gd" id="2"]
[node name="Visual" type="Node2D"]
script = ExtResource("1")
[node name="ActionProvider" type="Node" parent="."]
script = ExtResource("2")
actions = PackedStringArray("formation", "transfer")

View file

@ -0,0 +1,19 @@
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://CrafterBuildingVisual.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "ship_crafter"
display_name = "Ship Crafter"
description = "Fly the fleet onto it, then use the action button to build extra vessels."
cost = 12
action_icon = 2
menu_icon = 9
visual_scene = ExtResource("2")
capabilities = PackedStringArray("production")
map_color = Color(1, 0.72, 0.25, 1)
map_sides = 4
map_radius = 7.0
menu_button_name = "CrafterButton"
menu_label_name = "CrafterLabel"
menu_cost_name = "CrafterCost"

14
CrafterBuildingVisual.gd Normal file
View file

@ -0,0 +1,14 @@
extends "res://BuildingVisualBase.gd"
func _ready() -> void:
for part in SHIP_PARTS:
_hull_parts.append(PackedVector2Array(part.points))
_build_crafter_pieces()
queue_redraw()
func apply_animation() -> void:
_animate_crafter()
func _draw() -> void:
register_draw()
_draw_crafter_static(self)

View file

@ -0,0 +1 @@
uid://dbwebd1vf7biy

View file

@ -0,0 +1,11 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://CrafterBuildingVisual.gd" id="1"]
[ext_resource type="Script" path="res://BuildingActionProvider.gd" id="2"]
[node name="Visual" type="Node2D"]
script = ExtResource("1")
[node name="ActionProvider" type="Node" parent="."]
script = ExtResource("2")
actions = PackedStringArray("craft", "transfer")

View file

@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
definition_id = "extractor"
display_name = "Extractor"
description = "Mines resources into local storage for transport vessels to collect. It keeps extracting while you are away."
cost = 25
action_icon = 11
menu_icon = 11
capabilities = PackedStringArray("extraction")
map_color = Color(1, 0.85, 0.3, 1)
menu_button_name = "ExtractorButton"
menu_label_name = "ExtractorLabel"
menu_cost_name = "ExtractorCost"
placement_mode = "planet"

View file

@ -2,6 +2,7 @@ extends Node
const INVENTORY = preload("res://Inventory.gd") const INVENTORY = preload("res://Inventory.gd")
const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd") const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd")
const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd")
# Autoload singleton - the one thing that survives change_scene_to_file() # Autoload singleton - the one thing that survives change_scene_to_file()
@ -104,7 +105,7 @@ func building_inventory(id: int) -> RefCounted:
var building: Dictionary = find_building(id) var building: Dictionary = find_building(id)
if building.is_empty(): if building.is_empty():
return null return null
if building.type == BUILDING_MOTHERSHIP: if building_has_capability(building, "bank"):
return bank_inventory return bank_inventory
return _inventory_for_record(building) return _inventory_for_record(building)
@ -313,11 +314,6 @@ func unlock(id: String) -> bool:
func is_building_unlocked(type: String) -> bool: func is_building_unlocked(type: String) -> bool:
# The mothership is always buildable: it is where research happens, so
# locking it would leave a player with no mothership unable to ever get
# one.
if type == BUILDING_MOTHERSHIP:
return true
for node in SKILL_TREE: for node in SKILL_TREE:
if node.get("unlocks", "") == type: if node.get("unlocks", "") == type:
return is_unlocked(node.id) return is_unlocked(node.id)
@ -392,6 +388,13 @@ func find_building(id: int) -> Dictionary:
return {} return {}
func building_has_capability(building: Dictionary, capability: String) -> bool:
if building.is_empty():
return false
var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(building.type))
return definition != null and capability in definition.capabilities
# --- vessel types and their upgrades (#5) --------------------------------- # --- vessel types and their upgrades (#5) ---------------------------------
# The three things that can be flying around. The first and last double as # The three things that can be flying around. The first and last double as
@ -494,7 +497,7 @@ signal vessel_upgraded(upgrade_id: String)
func has_ship_lab() -> bool: func has_ship_lab() -> bool:
for b in buildings: for b in buildings:
if b.type == BUILDING_SHIP_LAB: if building_has_capability(b, "vessel_research"):
return true return true
return false return false
@ -627,9 +630,8 @@ func product_cost(product: String) -> int:
func can_produce(type: String) -> bool: func can_produce(type: String) -> bool:
# Only the ship crafter builds anything. The hub STATIONS transporters, var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
# it does not make them (#12). return definition != null and "production" in definition.capabilities
return type == BUILDING_SHIP_CRAFTER
func building_stock(id: int) -> int: func building_stock(id: int) -> int:
@ -777,7 +779,7 @@ func _advance_production(delta: float) -> void:
func queue_lab_research(lab_id: int, upgrade_id: String) -> bool: func queue_lab_research(lab_id: int, upgrade_id: String) -> bool:
var lab: Dictionary = find_building(lab_id) var lab: Dictionary = find_building(lab_id)
if lab.is_empty() or lab.type != BUILDING_SHIP_LAB or upgrade_id == "": if not building_has_capability(lab, "vessel_research") or upgrade_id == "":
return false return false
var orders: Array = lab.get("research_orders", []) var orders: Array = lab.get("research_orders", [])
if not orders.is_empty() or (upgrade_id != UNLOCK_SHIELD_VESSEL and upgrade_maxed(upgrade_id)): if not orders.is_empty() or (upgrade_id != UNLOCK_SHIELD_VESSEL and upgrade_maxed(upgrade_id)):
@ -795,7 +797,7 @@ func lab_research_order(lab_id: int) -> Dictionary:
func _advance_lab_research(delta: float) -> void: func _advance_lab_research(delta: float) -> void:
for lab in buildings: for lab in buildings:
if lab.type != BUILDING_SHIP_LAB: if not building_has_capability(lab, "vessel_research"):
continue continue
var orders: Array = lab.get("research_orders", []) var orders: Array = lab.get("research_orders", [])
if orders.is_empty(): if orders.is_empty():
@ -841,6 +843,13 @@ func has_mothership() -> bool:
return get_mothership_position() != null return get_mothership_position() != null
func has_building_of_type(type: String) -> bool:
for building in buildings:
if building.type == type:
return true
return false
func remove_buildings_of_type(type: String) -> int: func remove_buildings_of_type(type: String) -> int:
var kept: Array = [] var kept: Array = []
var removed: int = 0 var removed: int = 0
@ -857,7 +866,7 @@ func get_mothership_position() -> Variant:
# Where a wiped-out fleet gets rebuilt. Returns null when no mothership # Where a wiped-out fleet gets rebuilt. Returns null when no mothership
# has been built yet, so the caller can fall back to the world origin. # has been built yet, so the caller can fall back to the world origin.
for b in buildings: for b in buildings:
if b.type == BUILDING_MOTHERSHIP: if building_has_capability(b, "bank"):
return b.position return b.position
return null return null
@ -1017,7 +1026,7 @@ func hub_capacity() -> int:
func hub_count() -> int: func hub_count() -> int:
var n: int = 0 var n: int = 0
for b in buildings: for b in buildings:
if b.type == BUILDING_TRANSPORT_HUB: if building_has_capability(b, "transport_hub"):
n += 1 n += 1
return n return n
@ -1056,7 +1065,7 @@ func nearest_hub_to(at: Vector2) -> Dictionary:
var best: Dictionary = {} var best: Dictionary = {}
var best_d: float = INF var best_d: float = INF
for b in buildings: for b in buildings:
if b.type != BUILDING_TRANSPORT_HUB: if not building_has_capability(b, "transport_hub"):
continue continue
var d: float = at.distance_squared_to(b.position) var d: float = at.distance_squared_to(b.position)
if d < best_d: if d < best_d:

View file

@ -0,0 +1,21 @@
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://MothershipBuildingVisual.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "mothership"
display_name = "Mothership"
description = "Fleet home port. A wiped-out fleet rebuilds here, and extractor transports deliver resources here."
cost = 20
action_icon = 8
menu_icon = 8
visual_scene = ExtResource("2")
capabilities = PackedStringArray("bank", "research", "hangar")
map_color = Color(0.45, 0.9, 0.55, 1)
map_sides = 6
map_radius = 8.0
map_phase = 0.523599
menu_button_name = "MothershipButton"
menu_label_name = "MothershipLabel"
menu_cost_name = "MothershipCost"
unique = true

View file

@ -0,0 +1,12 @@
extends "res://BuildingVisualBase.gd"
func _ready() -> void:
_build_mothership_pieces()
queue_redraw()
func apply_animation() -> void:
_animate_mothership()
func _draw() -> void:
register_draw()
_draw_mothership_static(self)

View file

@ -0,0 +1 @@
uid://m43bwdwhqgy4

View file

@ -0,0 +1,11 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://MothershipBuildingVisual.gd" id="1"]
[ext_resource type="Script" path="res://BuildingActionProvider.gd" id="2"]
[node name="Visual" type="Node2D"]
script = ExtResource("1")
[node name="ActionProvider" type="Node" parent="."]
script = ExtResource("2")
actions = PackedStringArray("research", "transfer", "hangar")

View file

@ -0,0 +1,20 @@
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://ShipLabBuildingVisual.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "ship_lab"
display_name = "Ship Lab"
description = "Fly the fleet onto it to upgrade each vessel type and unlock new vessel designs."
cost = 35
action_icon = 13
menu_icon = 13
visual_scene = ExtResource("2")
capabilities = PackedStringArray("vessel_research")
map_color = Color(0.55, 0.95, 0.9, 1)
map_sides = 3
map_radius = 7.5
map_phase = -1.570796
menu_button_name = "ShipLabButton"
menu_label_name = "ShipLabLabel"
menu_cost_name = "ShipLabCost"

12
ShipLabBuildingVisual.gd Normal file
View file

@ -0,0 +1,12 @@
extends "res://BuildingVisualBase.gd"
func _ready() -> void:
_build_lab_pieces()
queue_redraw()
func apply_animation() -> void:
_animate_ship_lab()
func _draw() -> void:
register_draw()
_draw_ship_lab_static(self)

View file

@ -0,0 +1 @@
uid://b0p7p0fs0th0b

View file

@ -0,0 +1,11 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://ShipLabBuildingVisual.gd" id="1"]
[ext_resource type="Script" path="res://BuildingActionProvider.gd" id="2"]
[node name="Visual" type="Node2D"]
script = ExtResource("1")
[node name="ActionProvider" type="Node" parent="."]
script = ExtResource("2")
actions = PackedStringArray("lab", "transfer")

View file

@ -0,0 +1,20 @@
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://TransportHubBuildingVisual.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "transport_hub"
display_name = "Transport Hub"
description = "Stations transport vessels. They collect extracted resources and supply buildings that are waiting on materials."
cost = 30
action_icon = 12
menu_icon = 12
visual_scene = ExtResource("2")
capabilities = PackedStringArray("transport_hub")
map_color = Color(0.95, 0.55, 0.85, 1)
map_sides = 8
map_radius = 7.5
map_phase = 0.392699
menu_button_name = "TransportHubButton"
menu_label_name = "TransportHubLabel"
menu_cost_name = "TransportHubCost"

View file

@ -0,0 +1,12 @@
extends "res://BuildingVisualBase.gd"
func _ready() -> void:
_build_hub_pieces()
queue_redraw()
func apply_animation() -> void:
_animate_transport_hub()
func _draw() -> void:
register_draw()
_draw_transport_hub_static(self)

View file

@ -0,0 +1 @@
uid://6qyl5qnhw0ho

View file

@ -0,0 +1,11 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://TransportHubBuildingVisual.gd" id="1"]
[ext_resource type="Script" path="res://BuildingActionProvider.gd" id="2"]
[node name="Visual" type="Node2D"]
script = ExtResource("1")
[node name="ActionProvider" type="Node" parent="."]
script = ExtResource("2")
actions = PackedStringArray("transfer")

View file

@ -1,20 +1,10 @@
extends Node2D extends Node2D
const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd") const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd")
const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd")
const WORLD_TO_LOCAL: float = 0.04 const WORLD_TO_LOCAL: float = 0.04
# Mirrors Building.gd's own MOTHERSHIP_COLOR/CRAFTER_COLOR/COMMAND_COLOR so a
# marker on the map reads as "the same building" at a glance. Duplicated
# rather than cross-referenced - the same colour is already independently
# duplicated between Ship.gd, Fleet.gd and Building.gd's HULL_COLOR, so this
# follows the codebase's existing convention rather than inventing a new one.
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 COMMAND_MARKER_COLOR: Color = Color(0.55, 0.6, 1.0)
const TRANSPORT_HUB_MARKER_COLOR: Color = Color(0.95, 0.55, 0.85)
const SHIP_LAB_MARKER_COLOR: Color = Color(0.55, 0.95, 0.9)
# 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,
# in a colour distinct from both the planets and the space buildings so it # in a colour distinct from both the planets and the space buildings so it
@ -40,28 +30,20 @@ func _process(_delta: float) -> void:
func building_markers() -> Array: func building_markers() -> Array:
var markers: Array = [] var markers: Array = []
for b in GameState.buildings: for b in GameState.buildings:
var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(b.type))
if definition == null:
continue
markers.append({ markers.append({
"position": b.position * WORLD_TO_LOCAL, "position": b.position * WORLD_TO_LOCAL,
"type": b.type, "type": b.type,
"color": _building_marker_color(b.type), "color": definition.map_color,
"sides": definition.map_sides,
"radius": definition.map_radius,
"phase": definition.map_phase,
}) })
return markers return markers
func _building_marker_color(type: String) -> Color:
match type:
GameState.BUILDING_MOTHERSHIP:
return MOTHERSHIP_MARKER_COLOR
GameState.BUILDING_SHIP_CRAFTER:
return CRAFTER_MARKER_COLOR
GameState.BUILDING_TRANSPORT_HUB:
return TRANSPORT_HUB_MARKER_COLOR
GameState.BUILDING_SHIP_LAB:
return SHIP_LAB_MARKER_COLOR
_:
return COMMAND_MARKER_COLOR
# Whether the planet in this (explored) chunk has an extractor on it, per # Whether the planet in this (explored) chunk has an extractor on it, per
# GameState.has_extractor_at(). A separate predicate for the same # GameState.has_extractor_at(). A separate predicate for the same
# GPU-less-testability reason as building_markers(). # GPU-less-testability reason as building_markers().
@ -119,17 +101,7 @@ func _draw() -> void:
# Buildings are placed freely in space rather than tied to a chunk, so # Buildings are placed freely in space rather than tied to a chunk, so
# they get their own pass rather than living inside the chunk loop above. # they get their own pass rather than living inside the chunk loop above.
for marker in building_markers(): for marker in building_markers():
match marker.type: _draw_marker_polygon(_regular_polygon(marker.position, marker.radius, marker.sides, marker.phase), marker.color)
GameState.BUILDING_MOTHERSHIP:
_draw_marker_polygon(_regular_polygon(marker.position, 8.0, 6, PI / 6.0), marker.color)
GameState.BUILDING_SHIP_CRAFTER:
_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)
GameState.BUILDING_SHIP_LAB:
_draw_marker_polygon(_regular_polygon(marker.position, 7.5, 3, -PI / 2.0), marker.color)
_:
_draw_marker_polygon(_regular_polygon(marker.position, 6.0, 4, PI / 4.0), marker.color)
var fleet_local: Vector2 = fleet.get_centroid() * WORLD_TO_LOCAL var fleet_local: Vector2 = fleet.get_centroid() * WORLD_TO_LOCAL
var marker_points: PackedVector2Array = PackedVector2Array([ var marker_points: PackedVector2Array = PackedVector2Array([

View file

@ -28,4 +28,4 @@ func _ready() -> void:
# Flip SHOW_POPUP to check the menu underneath # Flip SHOW_POPUP to check the menu underneath
# instead of the popup that covers it. # instead of the popup that covers it.
if SHOW_POPUP: if SHOW_POPUP:
controller._on_place_mothership() controller._show_building_info(GameState.BUILDING_MOTHERSHIP)

View file

@ -31,9 +31,9 @@ func _ready() -> void:
b.position = Vector2(-240.0 + float(i % 3) * 240.0, -105.0 + float(i / 3) * 215.0) b.position = Vector2(-240.0 + float(i % 3) * 240.0, -105.0 + float(i / 3) * 215.0)
add_child(b) add_child(b)
if cell.has("phase"): if cell.has("phase"):
b.set_process(false) b.visual.set_process(false)
b.anim_time = float(cell.phase) * b.CYCLE b.visual.anim_time = float(cell.phase) * b.visual.CYCLE
b.apply_animation() b.visual.apply_animation()
var cam: Camera2D = Camera2D.new() var cam: Camera2D = Camera2D.new()
cam.zoom = Vector2(1.65, 1.65) cam.zoom = Vector2(1.65, 1.65)

View file

@ -53,7 +53,7 @@ func _process(delta: float) -> bool:
var ship: Node2D = null var ship: Node2D = null
for b in field.spawned: for b in field.spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP: if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP:
ship = b ship = b.visual
if ship == null: if ship == null:
print("TEST_ANIMATION_CONTINUITY: FAIL (mothership never spawned)") print("TEST_ANIMATION_CONTINUITY: FAIL (mothership never spawned)")
quit(1) quit(1)

View file

@ -28,9 +28,9 @@ var elapsed: float = 0.0
# Extractor is excluded: it needs a planet underneath, which # Extractor is excluded: it needs a planet underneath, which
# test_extractor_building covers in full. # test_extractor_building covers in full.
const OPTIONS: Array = [ const OPTIONS: Array = [
{"press": "_on_place_mothership", "type": "mothership", "title": "Mothership"}, {"type": "mothership", "title": "Mothership"},
{"press": "_on_place_crafter", "type": "ship_crafter", "title": "Ship Crafter"}, {"type": "ship_crafter", "title": "Ship Crafter"},
{"press": "_on_place_command_center", "type": "command_center", "title": "Command Center"}, {"type": "command_center", "title": "Command Center"},
] ]
@ -84,7 +84,7 @@ func _process(delta: float) -> bool:
var built_before: int = _count(type) var built_before: int = _count(type)
controller._on_build_pressed() controller._on_build_pressed()
controller.call(option.press) controller._show_building_info(type)
var informed: bool = popup.visible \ var informed: bool = popup.visible \
and title.text == option.title \ and title.text == option.title \
@ -111,7 +111,7 @@ func _process(delta: float) -> bool:
# --- reopening and confirming builds it ------------------ # --- reopening and confirming builds it ------------------
controller._on_build_pressed() controller._on_build_pressed()
controller.call(option.press) controller._show_building_info(type)
controller._confirm_build() controller._confirm_build()
var built: bool = _count(type) == built_before + 1 var built: bool = _count(type) == built_before + 1
var charged: bool = game_state.rocks == rocks_before - expected_cost var charged: bool = game_state.rocks == rocks_before - expected_cost

View file

@ -0,0 +1,35 @@
extends SceneTree
const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd")
func _initialize() -> void:
var scene: PackedScene = load("res://Building.tscn")
for definition in BUILDING_DEFINITIONS.all():
if definition.placement_mode != "space":
continue
var building: Node2D = scene.instantiate()
building.definition_id = definition.definition_id
building._ready()
if building.definition == null \
or building.definition.definition_id != definition.definition_id \
or building.visual == null:
_fail("%s did not compose its definition and visual (resolved=%s visual=%s)" % [
definition.definition_id,
"null" if building.definition == null else building.definition.definition_id,
building.visual,
])
return
var provider: Node = building.visual.get_node_or_null("ActionProvider")
if provider == null or not provider.actions.has("transfer"):
_fail("%s has no composed action provider" % definition.definition_id)
return
building.free()
print("TEST_BUILDING_COMPOSITION: PASS")
quit(0)
func _fail(message: String) -> void:
print("TEST_BUILDING_COMPOSITION: FAIL (%s)" % message)
quit(1)

View file

@ -61,11 +61,11 @@ func _process(delta: float) -> bool:
continue continue
match b.building_type: match b.building_type:
GameState.BUILDING_SHIP_CRAFTER: GameState.BUILDING_SHIP_CRAFTER:
crafter = b crafter = b.visual
GameState.BUILDING_MOTHERSHIP: GameState.BUILDING_MOTHERSHIP:
mothership = b mothership = b.visual
GameState.BUILDING_COMMAND_CENTER: GameState.BUILDING_COMMAND_CENTER:
command = b command = b.visual
if crafter == null or mothership == null or command == null: if crafter == null or mothership == null or command == null:
print("TEST_BUILDING_REDRAW_COST: FAIL (buildings never spawned)") print("TEST_BUILDING_REDRAW_COST: FAIL (buildings never spawned)")
quit(1) quit(1)
@ -88,7 +88,7 @@ func _process(delta: float) -> bool:
for b in [crafter, mothership, command]: for b in [crafter, mothership, command]:
if b.draw_count != 1: if b.draw_count != 1:
print("TEST_BUILDING_REDRAW_COST: FAIL (%s redrew its geometry %d times)" % [ print("TEST_BUILDING_REDRAW_COST: FAIL (%s redrew its geometry %d times)" % [
b.building_type, b.draw_count b.name, b.draw_count
]) ])
quit(1) quit(1)
return true return true

View file

@ -57,16 +57,19 @@ func _process(delta: float) -> bool:
# Spawning is deferred, so give the tree a frame to attach them. # Spawning is deferred, so give the tree a frame to attach them.
if elapsed < 0.2: if elapsed < 0.2:
return false return false
crafter = _find(GameState.BUILDING_SHIP_CRAFTER) var crafter_building: Node2D = _find(GameState.BUILDING_SHIP_CRAFTER)
if crafter == null: if crafter_building == null:
print("TEST_CRAFTER_ANIMATION: FAIL (crafter never spawned)") print("TEST_CRAFTER_ANIMATION: FAIL (crafter never spawned)")
quit(1) quit(1)
return true return true
crafter = crafter_building.visual
var animated: bool = crafter.is_processing() var animated: bool = crafter.is_processing()
var mothership: Node2D = _find(GameState.BUILDING_MOTHERSHIP) var mothership_building: Node2D = _find(GameState.BUILDING_MOTHERSHIP)
var mothership: Node2D = mothership_building.visual if mothership_building != null else null
var mothership_animates: bool = mothership != null and mothership.is_processing() var mothership_animates: bool = mothership != null and mothership.is_processing()
var command: Node2D = _find(GameState.BUILDING_COMMAND_CENTER) var command_building: Node2D = _find(GameState.BUILDING_COMMAND_CENTER)
var command: Node2D = command_building.visual if command_building != null else null
var command_static: bool = command != null and not command.is_processing() var command_static: bool = command != null and not command.is_processing()
print("crafter animates=%s mothership animates=%s command centre static=%s" % [ print("crafter animates=%s mothership animates=%s command centre static=%s" % [
animated, mothership_animates, command_static animated, mothership_animates, command_static

View file

@ -69,7 +69,7 @@ func _process(delta: float) -> bool:
print("TEST_EXTRACTOR_BUILDING: FAIL (enabled with no planet in reach)") print("TEST_EXTRACTOR_BUILDING: FAIL (enabled with no planet in reach)")
quit(1) quit(1)
return true return true
controller._on_place_extractor() controller._show_building_info(GameState.BUILDING_EXTRACTOR)
var explained: bool = popup.visible and "planet" in popup.get_node("Panel/MessageLabel").text.to_lower() var explained: bool = popup.visible and "planet" in popup.get_node("Panel/MessageLabel").text.to_lower()
var offered: bool = popup.get_node("Panel/BuildButton").visible var offered: bool = popup.get_node("Panel/BuildButton").visible
print("no planet: greyed=%s popup_shown=%s confirm_offered=%s" % [ print("no planet: greyed=%s popup_shown=%s confirm_offered=%s" % [
@ -118,7 +118,7 @@ func _process(delta: float) -> bool:
return true return true
var rocks_before: int = game_state.rocks var rocks_before: int = game_state.rocks
controller._on_place_extractor() controller._show_building_info(GameState.BUILDING_EXTRACTOR)
var planet_field2: Node = main_scene.get_node("PlanetField") var planet_field2: Node = main_scene.get_node("PlanetField")
var planet2: Node2D = planet_field2.populated_chunks[target_chunk][0] var planet2: Node2D = planet_field2.populated_chunks[target_chunk][0]
@ -148,7 +148,7 @@ func _process(delta: float) -> bool:
# A second one on the same planet must be refused, not charged. # A second one on the same planet must be refused, not charged.
var rocks_now: int = game_state.rocks var rocks_now: int = game_state.rocks
controller._on_build_pressed() controller._on_build_pressed()
controller._on_place_extractor() controller._show_building_info(GameState.BUILDING_EXTRACTOR)
var re_offered: bool = popup.get_node("Panel/BuildButton").visible var re_offered: bool = popup.get_node("Panel/BuildButton").visible
controller._confirm_build() # must do nothing even if pressed controller._confirm_build() # must do nothing even if pressed
var still_one: bool = game_state.extractors.size() == 1 and not re_offered var still_one: bool = game_state.extractors.size() == 1 and not re_offered

View file

@ -67,7 +67,7 @@ func _process(delta: float) -> bool:
return false return false
# --- first one: informed, then built ------------------------- # --- first one: informed, then built -------------------------
controller._on_build_pressed() controller._on_build_pressed()
controller._on_place_mothership() controller._show_building_info(GameState.BUILDING_MOTHERSHIP)
var first_text: String = popup.get_node("Panel/MessageLabel").text.to_lower() var first_text: String = popup.get_node("Panel/MessageLabel").text.to_lower()
var first_warned: bool = "destroy" in first_text var first_warned: bool = "destroy" in first_text
print("first: popup=%s warned=%s built_before_confirm=%d" % [ print("first: popup=%s warned=%s built_before_confirm=%d" % [
@ -86,7 +86,7 @@ func _process(delta: float) -> bool:
# --- second: warns, builds nothing --------------------------- # --- second: warns, builds nothing ---------------------------
_place_fleet(second_pos) _place_fleet(second_pos)
controller._on_build_pressed() controller._on_build_pressed()
controller._on_place_mothership() controller._show_building_info(GameState.BUILDING_MOTHERSHIP)
var warned: bool = popup.visible and "destroy" in popup.get_node("Panel/MessageLabel").text.to_lower() var warned: bool = popup.visible and "destroy" in popup.get_node("Panel/MessageLabel").text.to_lower()
print("second press: warned=%s motherships=%d" % [warned, _count_motherships()]) print("second press: warned=%s motherships=%d" % [warned, _count_motherships()])
if not warned or _count_motherships() != 1: if not warned or _count_motherships() != 1:
@ -107,7 +107,7 @@ func _process(delta: float) -> bool:
# --- confirming replaces ------------------------------------- # --- confirming replaces -------------------------------------
var rocks_before: int = game_state.rocks var rocks_before: int = game_state.rocks
controller._on_place_mothership() controller._show_building_info(GameState.BUILDING_MOTHERSHIP)
controller._confirm_build() controller._confirm_build()
var only_one: bool = _count_motherships() == 1 var only_one: bool = _count_motherships() == 1
var moved: bool = game_state.get_mothership_position().distance_to(second_pos) < 60.0 var moved: bool = game_state.get_mothership_position().distance_to(second_pos) < 60.0
@ -124,7 +124,7 @@ func _process(delta: float) -> bool:
game_state.rocks = GameState.MOTHERSHIP_COST - 1 game_state.rocks = GameState.MOTHERSHIP_COST - 1
_place_fleet(Vector2(2500.0, -800.0)) _place_fleet(Vector2(2500.0, -800.0))
controller._on_build_pressed() controller._on_build_pressed()
controller._on_place_mothership() controller._show_building_info(GameState.BUILDING_MOTHERSHIP)
# With nothing affordable there is no confirm button to press; # With nothing affordable there is no confirm button to press;
# pressing it anyway must still do nothing. # pressing it anyway must still do nothing.
var offered: bool = popup.get_node("Panel/BuildButton").visible var offered: bool = popup.get_node("Panel/BuildButton").visible

View file

@ -72,9 +72,9 @@ func _process(delta: float) -> bool:
# Since #21 an unresearched structure is not listed at all, rather # Since #21 an unresearched structure is not listed at all, rather
# than listed and greyed. It is therefore unreachable from the # than listed and greyed. It is therefore unreachable from the
# menu - but the popup must still refuse if something calls # menu - but the popup must still refuse if something calls
# _on_place_crafter() by another route. # the generic definition entry point by another route.
var hidden: bool = not scene.get_node("UI/BuildMenu/CrafterButton").visible var hidden: bool = not scene.get_node("UI/BuildMenu/CrafterButton").visible
controller._on_place_crafter() controller._show_building_info(GameState.BUILDING_SHIP_CRAFTER)
var offered: bool = popup.get_node("Panel/BuildButton").visible var offered: bool = popup.get_node("Panel/BuildButton").visible
print("locked crafter: hidden_from_menu=%s confirm_offered=%s" % [hidden, offered]) print("locked crafter: hidden_from_menu=%s confirm_offered=%s" % [hidden, offered])
if not hidden or offered: if not hidden or offered:

View file

@ -1,5 +1,7 @@
extends SceneTree extends SceneTree
const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd")
# Headless gameplay test: run with # Headless gameplay test: run with
# godot --headless -s res://tests/test_transport_hub_building.gd # godot --headless -s res://tests/test_transport_hub_building.gd
# Forgejo #18 (part 5 of #12): the Transport Hub is a placeable building of # Forgejo #18 (part 5 of #12): the Transport Hub is a placeable building of
@ -67,7 +69,7 @@ func _process(delta: float) -> bool:
var icons: Dictionary = {} var icons: Dictionary = {}
for type in [GameState.BUILDING_MOTHERSHIP, GameState.BUILDING_SHIP_CRAFTER, for type in [GameState.BUILDING_MOTHERSHIP, GameState.BUILDING_SHIP_CRAFTER,
GameState.BUILDING_COMMAND_CENTER, GameState.BUILDING_TRANSPORT_HUB]: GameState.BUILDING_COMMAND_CENTER, GameState.BUILDING_TRANSPORT_HUB]:
var icon: int = controller._action_icon_for(type) var icon: int = BUILDING_DEFINITIONS.get_definition(type).action_icon
if icons.has(icon): if icons.has(icon):
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (%s and %s share an icon)" % [type, icons[icon]]) print("TEST_TRANSPORT_HUB_BUILDING: FAIL (%s and %s share an icon)" % [type, icons[icon]])
quit(1) quit(1)
@ -99,7 +101,7 @@ func _process(delta: float) -> bool:
return false return false
for b in field.spawned: for b in field.spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_TRANSPORT_HUB: if is_instance_valid(b) and b.building_type == GameState.BUILDING_TRANSPORT_HUB:
hub = b hub = b.visual
if hub == null: if hub == null:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (never spawned)") print("TEST_TRANSPORT_HUB_BUILDING: FAIL (never spawned)")
quit(1) quit(1)
@ -120,9 +122,9 @@ func _process(delta: float) -> bool:
for m in markers: for m in markers:
if m.type == GameState.BUILDING_TRANSPORT_HUB: if m.type == GameState.BUILDING_TRANSPORT_HUB:
found = true found = true
if m.color == map_content.CRAFTER_MARKER_COLOR \ if m.color == BUILDING_DEFINITIONS.get_definition(GameState.BUILDING_SHIP_CRAFTER).map_color \
or m.color == map_content.MOTHERSHIP_MARKER_COLOR \ or m.color == BUILDING_DEFINITIONS.get_definition(GameState.BUILDING_MOTHERSHIP).map_color \
or m.color == map_content.COMMAND_MARKER_COLOR: or m.color == BUILDING_DEFINITIONS.get_definition(GameState.BUILDING_COMMAND_CENTER).map_color:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (map marker reuses another building's colour)") print("TEST_TRANSPORT_HUB_BUILDING: FAIL (map marker reuses another building's colour)")
quit(1) quit(1)
return true return true