Compare commits

..
Author SHA1 Message Date
Loïc Wisniewski
14081ad230 Compose vessels from definitions and capabilities 2026-08-14 22:12:57 +02:00
Loïc Wisniewski
0504b24d8d Compose buildings from definitions and capabilities 2026-08-14 21:44:28 +02:00
Loïc Wisniewski
1721edd123 Store resources in generic inventories 2026-08-14 20:32:58 +02:00
Loïc Wisniewski
02643c76c4 Drive planets from definitions 2026-08-14 16:13:24 +02:00
Loïc Wisniewski
2f68515f33 Compose planet surfaces from profiles 2026-08-14 16:05:32 +02:00
137 changed files with 2476 additions and 1546 deletions

View file

@ -1,5 +1,8 @@
extends Node2D
const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd")
const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
# Space-side building UI. Owns:
# - the build menu (place a mothership, ship crafter or command centre)
# - the shared ACTION button, which adapts to whatever the fleet is
@ -22,20 +25,18 @@ extends Node2D
@onready var building_action_button: IconButton = get_node("../UI/BuildingActionButton")
@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_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_vessel_buttons: Array = [
get_node("../UI/LabMenu/VesselA"), get_node("../UI/LabMenu/VesselB"),
get_node("../UI/LabMenu/VesselC"),
]
@onready var lab_vessel_label: Label = get_node("../UI/LabMenu/VesselLabel")
@onready var lab_vessel_labels: Array = [
get_node("../UI/LabMenu/VesselALabel"), get_node("../UI/LabMenu/VesselBLabel"),
get_node("../UI/LabMenu/VesselCLabel"),
]
@onready var lab_detail: Label = get_node("../UI/LabMenu/DetailLabel")
@onready var lab_status: Label = get_node("../UI/LabMenu/StatusLabel")
@onready var lab_buy_button: IconButton = get_node("../UI/LabMenu/BuyButton")
@ -78,20 +79,7 @@ extends Node2D
get_node("../UI/HangarMenu/FleetShieldLabel"),
get_node("../UI/HangarMenu/FleetTransporterLabel"),
]
@onready 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")],
]
var build_menu_entries: Array = []
# 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
@ -115,6 +103,12 @@ var lab_building_id: int = 0
@onready var craft_interceptor_label: Label = get_node("../UI/CraftMenu/InterceptorLabel")
@onready var craft_transporter_label: Label = get_node("../UI/CraftMenu/TransporterLabel")
@onready var craft_shield_label: Label = get_node("../UI/CraftMenu/ShieldLabel")
@onready var craft_vessel_buttons: Array = [
craft_interceptor_button, craft_shield_button, craft_transporter_button,
]
@onready var craft_vessel_labels: Array = [
craft_interceptor_label, craft_shield_label, craft_transporter_label,
]
# --- the transfer panel (#24) ---------------------------------------------
# Ore used to be moved from inside whichever panel happened to be open: "load
@ -148,41 +142,6 @@ var lab_building_id: int = 0
@onready var formation_close_button: IconButton = get_node("../UI/FormationMenu/CloseButton")
@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
# only explaining something that cannot be built right now.
var pending_build_type: String = ""
@ -210,13 +169,7 @@ var context_actions_expanded: bool = false
func _ready() -> void:
build_button.pressed.connect(_on_build_pressed)
build_close_button.pressed.connect(_close_build_menu)
build_mothership_button.pressed.connect(_on_place_mothership)
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)
_configure_build_menu_entries()
lab_close_button.pressed.connect(func(): lab_menu.visible = false)
lab_buy_button.pressed.connect(_on_lab_buy)
for i in range(lab_vessel_buttons.size()):
@ -229,8 +182,9 @@ func _ready() -> void:
action_button.pressed.connect(_on_context_action_pressed)
building_action_button.pressed.connect(_on_action_pressed)
craft_interceptor_button.pressed.connect(_on_craft_interceptor)
craft_transporter_button.pressed.connect(_on_craft_transporter)
for i in range(mini(craft_vessel_buttons.size(), VESSEL_DEFINITIONS.all().size())):
var definition: Resource = VESSEL_DEFINITIONS.all()[i]
craft_vessel_buttons[i].pressed.connect(_on_craft_vessel.bind(definition.definition_id))
craft_close_button.pressed.connect(func(): craft_menu.visible = false)
transfer_button.pressed.connect(_on_transfer_pressed)
@ -267,6 +221,21 @@ func _ready() -> void:
_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
# enter-atmosphere prompt knows to stay out of the way.
func is_showing_building_prompt() -> bool:
@ -318,9 +287,7 @@ func _process(delta: float) -> void:
if build_menu.visible:
# Keep the greyed state live while the menu is open, so flying onto
# a planet lights the option up without reopening it.
build_extractor_button.enabled = _planet_under_fleet() != null \
and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR)
build_extractor_button.queue_redraw()
_refresh_build_option_states()
if craft_menu.visible:
# Live, so a delivery arriving while the menu is open visibly works
@ -359,10 +326,7 @@ func _process(delta: float) -> void:
current_building = pending_building
context_actions_expanded = false
# The hangar is where finished vessels are delivered (#23), so it belongs
# at the mothership and nowhere else.
hangar_button.visible = current_building != null \
and current_building.building_type == GameState.BUILDING_MOTHERSHIP
hangar_button.visible = false
# Every building can hold ore, so the transfer button rides alongside the
# action button rather than being one of the things the action button
# turns into - the two are independent choices at the same structure.
@ -371,7 +335,7 @@ func _process(delta: float) -> void:
action_button.visible = current_building != null
if current_building != null:
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()
action_button.icon = building_action_button.icon if actions.size() == 1 else IconButton.Icon.ACTION_MENU
action_button.queue_redraw()
@ -403,13 +367,20 @@ func _layout_context_buttons() -> void:
func _context_action_buttons() -> Array:
if current_building == null:
return []
var actions: Array = [transfer_button]
if current_building.building_type in [GameState.BUILDING_MOTHERSHIP, GameState.BUILDING_SHIP_CRAFTER,
GameState.BUILDING_SHIP_LAB, GameState.BUILDING_COMMAND_CENTER]:
actions.push_front(building_action_button)
if current_building.building_type == GameState.BUILDING_MOTHERSHIP:
actions.append(hangar_button)
return actions
var buttons: Array = []
for action in current_building.available_actions():
var button: IconButton = _button_for_building_action(action)
if button != null and button not in buttons:
buttons.append(button)
return buttons
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:
@ -433,20 +404,6 @@ func _collapse_context_actions() -> void:
_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:
if not fleet.ships.is_empty():
return
@ -465,11 +422,12 @@ func _on_action_pressed() -> void:
for bar in resource_bars:
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_tree.reset_scroll()
_refresh_skill_menu()
elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
elif action == "craft":
# Remembered explicitly: _process() clears current_building while any
# 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
@ -478,11 +436,11 @@ func _on_action_pressed() -> void:
craft_building_id = current_building.building_id
craft_menu.visible = true
_refresh_craft_status()
elif current_building.building_type == GameState.BUILDING_SHIP_LAB:
elif action == "lab":
lab_building_id = current_building.building_id
lab_menu.visible = true
_refresh_lab()
elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER:
elif action == "formation":
formation_menu.visible = true
formation_editor.queue_redraw()
@ -551,8 +509,7 @@ func _refresh_build_status() -> void:
# Researched but not usable right here is still shown, greyed: an
# extractor needs a planet under the fleet, which is a "not now" rather
# than a "not yet".
build_extractor_button.enabled = _planet_under_fleet() != null \
and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR)
_refresh_build_option_states()
# Prices are shown under each option now, so this only needs to say
# whether the fleet is somewhere an extractor could go.
@ -562,16 +519,14 @@ func _refresh_build_status() -> void:
build_status.text = ""
func _on_place_mothership() -> void:
_show_building_info(GameState.BUILDING_MOTHERSHIP)
func _on_place_crafter() -> void:
_show_building_info(GameState.BUILDING_SHIP_CRAFTER)
func _on_place_command_center() -> void:
_show_building_info(GameState.BUILDING_COMMAND_CENTER)
func _refresh_build_option_states() -> void:
for entry in build_menu_entries:
var definition: Resource = entry[4]
var enabled: bool = GameState.is_building_unlocked(definition.definition_id)
if definition.placement_mode == "planet":
enabled = enabled and _planet_under_fleet() != null
entry[1].enabled = enabled
entry[1].queue_redraw()
func _close_build_menu() -> void:
@ -582,40 +537,17 @@ func _close_build_menu() -> void:
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:
match type:
GameState.BUILDING_TRANSPORT_HUB:
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
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
return int(definition.cost) if definition != null else 0
func _show_building_info(type: String) -> void:
var info: Dictionary = BUILDING_INFO[type]
var cost: int = cost_of(type)
var lines: Array = [info.body]
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
if definition == null:
return
var cost: int = int(definition.cost)
var lines: Array = [definition.description]
var can_build: bool = true
# Locked structures still list themselves, greyed, so the player can see
@ -626,7 +558,7 @@ func _show_building_info(type: String) -> void:
# Anything that would stop this particular placement is spelled out in
# 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()
if planet == null:
lines.append("No planet in reach - fly over one.")
@ -635,15 +567,15 @@ func _show_building_info(type: String) -> void:
lines.append("%s already has an extractor." % planet.planet_name)
can_build = false
else:
lines.append("Will mine %s from %s." % ["gas" if planet.resource_type == "gas" else "ore", planet.planet_name])
elif can_build and type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership():
lines.append("Will mine %s from %s." % [GameState.resource_name(planet.resource_type), planet.planet_name])
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.")
if can_build and not GameState.can_afford(cost):
lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.rocks])
can_build = false
info_title.text = info.title
info_title.text = definition.display_name
info_message.text = "\n\n".join(lines)
info_cost.amount = cost
info_build_button.visible = can_build
@ -668,28 +600,31 @@ func _confirm_build() -> void:
# planet an extractor was going to mine.
if not GameState.is_building_unlocked(type):
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()
if planet == null or GameState.has_extractor_at(planet.planet_name):
build_status.text = "No planet in reach any more"
return
if not GameState.spend(GameState.EXTRACTOR_COST):
build_status.text = "Not enough rocks (need %d, have %d)" % [GameState.EXTRACTOR_COST, GameState.rocks]
if not GameState.spend(definition.cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.rocks]
return
GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type)
build_menu.visible = false
return
if type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership():
if not GameState.can_afford(GameState.MOTHERSHIP_COST):
if definition.unique and GameState.has_building_of_type(type):
if not GameState.can_afford(definition.cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [
GameState.MOTHERSHIP_COST, GameState.rocks
definition.cost, GameState.rocks
]
return
# 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.
GameState.remove_buildings_of_type(GameState.BUILDING_MOTHERSHIP)
GameState.remove_buildings_of_type(type)
building_field.rebuild_all()
_try_place(type, cost_of(type))
@ -768,26 +703,19 @@ func _refresh_craft_status() -> void:
# Prices live with the buttons they describe. The top of the screen is
# intentionally reserved for the shared resource bar and a short action
# message; repeating fleet, queue and transport counts here buried the UI.
craft_interceptor_label.text = "Interceptor\n%d ore" % GameState.SHIP_COST
craft_transporter_label.text = "Transporter\n%d ore" % GameState.TRANSPORTER_COST
craft_shield_label.text = "Shield Vessel\n%d ore" % GameState.SHIELD_COST
craft_status.text = ""
# Nowhere to station one means the order would be refused, so say so
# rather than letting the button look available and do nothing.
craft_transporter_button.enabled = GameState.has_transport_hub() \
and GameState.can_station_transporter()
craft_transporter_button.queue_redraw()
craft_shield_button.enabled = GameState.shield_vessel_unlocked()
craft_shield_button.queue_redraw()
# Fleet capacity no longer gates ORDERING (#23). A finished vessel waits
# at the mothership until the player crews it, so a full fleet is a
# reason not to collect one yet - not a reason the crafter cannot build.
# This is also what stopped the crafter being inert on a fresh save,
# where the fleet starts at exactly its cap.
craft_interceptor_button.enabled = true
craft_interceptor_button.queue_redraw()
var definitions: Array = VESSEL_DEFINITIONS.all()
for i in range(mini(definitions.size(), craft_vessel_buttons.size())):
var definition: Resource = definitions[i]
var button: IconButton = craft_vessel_buttons[i]
craft_vessel_labels[i].text = "%s\n%d ore" % [definition.display_name, definition.build_cost]
button.icon = definition.icon
button.glow_color = definition.glow_color
button.enabled = GameState.vessel_is_unlocked(definition.definition_id)
if definition.has_capability("transport"):
button.enabled = button.enabled and GameState.has_transport_hub() \
and GameState.can_station_transporter()
button.queue_redraw()
_refresh_queue()
if craft_message != "":
if Time.get_ticks_msec() < craft_message_until:
@ -797,40 +725,40 @@ func _refresh_craft_status() -> void:
func _on_craft_interceptor() -> void:
_on_craft_vessel(GameState.VESSEL_INTERCEPTOR)
func _on_craft_vessel(vessel: String) -> void:
if craft_building_id == 0:
_say("No crafter selected - reopen this panel from the building")
return
# No affordability check: an order the crafter cannot pay for yet is
# QUEUED, and completes by itself once ore is delivered here (#16). But
# queueing silently looks identical to a dead button, so it says which
# happened and what the crafter is still short of.
GameState.queue_order(craft_building_id)
_report_order(GameState.PRODUCT_SHIP)
var definition: Resource = VESSEL_DEFINITIONS.get_definition(vessel)
if definition == null:
return
if not GameState.queue_order(craft_building_id, vessel):
if not GameState.vessel_is_unlocked(vessel):
_say("%s must be unlocked at a ship lab first" % definition.display_name)
elif definition.has_capability("transport") and not GameState.has_transport_hub():
_say("Build a Transport Hub first - a transporter needs somewhere to be stationed")
elif definition.has_capability("transport"):
_say("All %d berths are taken - build another hub, or research Freight Yards" %
GameState.transport_capacity())
return
_report_order(vessel)
func _report_order(product: String) -> void:
var short: int = GameState.outstanding_cost(craft_building_id)
if short > 0:
_say("%s queued - %d more ore must be delivered here" % [
GameState.VESSEL_NAMES.get(product, product), short
GameState.vessel_name(product), short
])
else:
_say("%s ordered - building now" % GameState.VESSEL_NAMES.get(product, product))
_say("%s ordered - building now" % GameState.vessel_name(product))
func _on_craft_transporter() -> void:
if craft_building_id == 0:
_say("No crafter selected - reopen this panel from the building")
return
if not GameState.queue_order(craft_building_id, GameState.PRODUCT_TRANSPORTER):
# Two different refusals, and the player needs to know WHICH.
if not GameState.has_transport_hub():
_say("Build a Transport Hub first - a transporter needs somewhere to be stationed")
else:
_say("All %d berths are taken - build another hub, or research Freight Yards" %
GameState.transport_capacity())
return
_refresh_craft_status()
_on_craft_vessel(GameState.VESSEL_TRANSPORTER)
# --- ship lab (#5) --------------------------------------------------------
@ -840,10 +768,13 @@ func _lab_options() -> Array:
# entry, because unlocking the type and upgrading it belong in the same
# place - and its upgrades are pointless until it exists.
var options: Array = []
if lab_vessel == GameState.VESSEL_SHIELD and not GameState.shield_vessel_unlocked():
options.append({"unlock": true, "name": "Unlock Shield Vessel",
"desc": "Lets the ship crafter build shield vessels. Its shield absorbs one lethal hit, then cycles back up.",
"cost": GameState.UNLOCK_SHIELD_COST})
var definition: Resource = VESSEL_DEFINITIONS.get_definition(lab_vessel)
if definition == null:
return options
if not GameState.vessel_is_unlocked(lab_vessel):
options.append({"unlock": true, "id": definition.unlock_id,
"name": definition.unlock_name, "desc": definition.unlock_description,
"cost": definition.unlock_cost, "icon": IconButton.Icon.UPGRADE_UNLOCK_SHIELD})
return options
for u in GameState.upgrades_for(lab_vessel):
options.append({"unlock": false, "id": u.id, "name": u.name, "desc": u.desc})
@ -851,8 +782,10 @@ func _lab_options() -> Array:
func _on_lab_vessel(index: int) -> void:
lab_vessel = [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD,
GameState.VESSEL_TRANSPORTER][index]
var definitions: Array = VESSEL_DEFINITIONS.all()
if index < 0 or index >= definitions.size():
return
lab_vessel = definitions[index].definition_id
lab_choice = 0
_refresh_lab()
@ -870,7 +803,7 @@ func _on_lab_buy() -> void:
if options.is_empty():
return
var choice: Dictionary = options[lab_choice]
var id: String = GameState.UNLOCK_SHIELD_VESSEL if choice.unlock else String(choice.id)
var id: String = String(choice.id)
if not GameState.queue_lab_research(lab_building_id, id):
lab_status.text = "Research is already queued or complete"
return
@ -879,14 +812,17 @@ func _on_lab_buy() -> void:
func _refresh_lab() -> void:
for i in range(lab_vessel_buttons.size()):
var this_vessel: String = [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD,
GameState.VESSEL_TRANSPORTER][i]
var definitions: Array = VESSEL_DEFINITIONS.all()
for i in range(mini(lab_vessel_buttons.size(), definitions.size())):
var definition: Resource = definitions[i]
var this_vessel: String = definition.definition_id
lab_vessel_buttons[i].enabled = true
lab_vessel_buttons[i].glow_color = _vessel_colour(this_vessel)
lab_vessel_buttons[i].icon = definition.icon
lab_vessel_buttons[i].glow_color = definition.glow_color
lab_vessel_labels[i].text = definition.display_name
lab_vessel_buttons[i].highlighted = this_vessel == lab_vessel
lab_vessel_buttons[i].queue_redraw()
lab_vessel_label.text = "%s research" % GameState.VESSEL_NAMES.get(lab_vessel, lab_vessel)
lab_vessel_label.text = "%s research" % GameState.vessel_name(lab_vessel)
var options: Array = _lab_options()
for i in range(lab_upgrade_buttons.size()):
@ -896,7 +832,7 @@ func _refresh_lab() -> void:
if not visible:
continue
var option: Dictionary = options[i]
var price_text: String = "%d ore" % (GameState.UNLOCK_SHIELD_COST if bool(option.unlock) else GameState.upgrade_cost(option.id))
var price_text: String = "%d ore" % (int(option.cost) if bool(option.unlock) else GameState.upgrade_cost(option.id))
if not bool(option.unlock) and GameState.upgrade_maxed(option.id):
price_text = "MAX"
var level_note: String = "" if bool(option.unlock) else " %d/%d" % [
@ -924,29 +860,11 @@ func _refresh_lab() -> void:
func _upgrade_icon(option: Dictionary) -> IconButton.Icon:
if bool(option.unlock):
return IconButton.Icon.UPGRADE_UNLOCK_SHIELD
match String(option.id):
"int_damage": return IconButton.Icon.UPGRADE_DAMAGE
"int_rate": return IconButton.Icon.UPGRADE_RATE
"int_range": return IconButton.Icon.UPGRADE_RANGE
"int_speed", "shd_speed": return IconButton.Icon.UPGRADE_THRUSTERS
"shd_hits": return IconButton.Icon.UPGRADE_SHIELD_HITS
"shd_recharge": return IconButton.Icon.UPGRADE_SHIELD_RECHARGE
"trn_capacity": return IconButton.Icon.UPGRADE_CARGO
"trn_speed": return IconButton.Icon.UPGRADE_ENGINES
_: return IconButton.Icon.SHIP_LAB
return int(option.get("icon", IconButton.Icon.SHIP_LAB))
func _on_craft_shield() -> void:
if craft_building_id == 0:
_say("No crafter selected - reopen this panel from the building")
return
if not GameState.shield_vessel_unlocked():
_say("Shield vessels must be unlocked at a ship lab first")
return
GameState.queue_order(craft_building_id, GameState.VESSEL_SHIELD)
_report_order(GameState.VESSEL_SHIELD)
_on_craft_vessel(GameState.VESSEL_SHIELD)
# --- moving ore, in both directions, at any building (#24) ----------------
@ -1033,8 +951,8 @@ func _refresh_queue() -> void:
if not slot.visible:
continue
var order: Dictionary = orders[i]
slot.icon = IconButton.Icon.TRANSPORT_HUB if order.product == GameState.PRODUCT_TRANSPORTER \
else IconButton.Icon.SHIP
var definition: Resource = VESSEL_DEFINITIONS.get_definition(String(order.product))
slot.icon = definition.icon
slot.glow_color = _vessel_colour(String(order.product))
# Only the one under construction reads as active; the rest are
# waiting, and greyed says so without needing a caption each.
@ -1047,29 +965,24 @@ func _refresh_queue() -> void:
var head: Dictionary = orders[0]
if bool(head.started):
queue_detail.text = "Building %s %d%%\nTap to cancel" % [
GameState.VESSEL_NAMES.get(head.product, head.product),
GameState.vessel_name(String(head.product)),
int(round(float(head.progress) * 100.0)),
]
else:
queue_detail.text = "%s waiting for ore\nTap to cancel" % \
GameState.VESSEL_NAMES.get(head.product, head.product)
GameState.vessel_name(String(head.product))
func _vessel_colour(product: String) -> Color:
match product:
GameState.VESSEL_SHIELD:
return Color(0.45, 0.8, 1.0)
GameState.PRODUCT_TRANSPORTER:
return Color(0.95, 0.55, 0.85)
_:
return Color(0.25, 0.85, 1.0)
var definition: Resource = VESSEL_DEFINITIONS.get_definition(product)
return definition.glow_color if definition != null else Color(0.25, 0.85, 1.0)
func _on_queue_slot(index: int) -> void:
var orders: Array = GameState.order_list(craft_building_id)
if index >= orders.size():
return
var vessel: String = GameState.VESSEL_NAMES.get(orders[index].product, orders[index].product)
var vessel: String = GameState.vessel_name(String(orders[index].product))
var refunded: bool = bool(orders[index].started)
if not GameState.cancel_order(craft_building_id, index):
return
@ -1094,12 +1007,12 @@ func _on_hangar_crew_type(index: int) -> void:
return
var stored_index: int = GameState.stored_vessels.find(type)
if stored_index < 0:
hangar_status.text = "No %s available" % GameState.VESSEL_NAMES.get(type, type)
hangar_status.text = "No %s available" % GameState.vessel_name(type)
return
if not fleet.add_ship(GameState.take_stored_vessel(stored_index)):
hangar_status.text = "Could not crew that vessel"
return
hangar_status.text = "%s joined the fleet" % GameState.VESSEL_NAMES.get(type, type)
hangar_status.text = "%s joined the fleet" % GameState.vessel_name(type)
_refresh_hangar()
@ -1111,32 +1024,40 @@ func _on_hangar_store_type(index: int) -> void:
ship_index = i
break
if ship_index < 0:
hangar_status.text = "No %s in the fleet" % GameState.VESSEL_NAMES.get(type, type)
hangar_status.text = "No %s in the fleet" % GameState.vessel_name(type)
return
var stored: String = fleet.store_ship(ship_index)
if stored == "":
hangar_status.text = "Keep one ship deployed"
return
GameState.stored_vessels.append(stored)
hangar_status.text = "%s stored in the hangar" % GameState.VESSEL_NAMES.get(stored, stored)
hangar_status.text = "%s stored in the hangar" % GameState.vessel_name(stored)
_refresh_hangar()
func _hangar_types() -> Array:
return [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, GameState.VESSEL_TRANSPORTER]
var types: Array = []
for definition in VESSEL_DEFINITIONS.all():
types.append(definition.definition_id)
return types
func _refresh_hangar() -> void:
var types: Array = _hangar_types()
for i in range(types.size()):
var type: String = types[i]
var definition: Resource = VESSEL_DEFINITIONS.get_definition(type)
var available: int = GameState.stored_vessels.count(type)
var deployed: int = 0
for ship in fleet.ships:
if is_instance_valid(ship) and String(ship.vessel_type) == type:
deployed += 1
hangar_available_labels[i].text = "%s\n%d available" % [GameState.VESSEL_NAMES.get(type, type), available]
hangar_fleet_labels[i].text = "%s\n%d in fleet" % [GameState.VESSEL_NAMES.get(type, type), deployed]
hangar_available_labels[i].text = "%s\n%d available" % [definition.display_name, available]
hangar_fleet_labels[i].text = "%s\n%d in fleet" % [definition.display_name, deployed]
hangar_available_slots[i].icon = definition.icon
hangar_fleet_slots[i].icon = definition.icon
hangar_available_slots[i].glow_color = definition.glow_color
hangar_fleet_slots[i].glow_color = definition.glow_color
hangar_available_slots[i].enabled = available > 0 and fleet.ships.size() < GameState.max_fleet_size()
hangar_fleet_slots[i].enabled = deployed > 0 and fleet.ships.size() > 1
hangar_available_slots[i].queue_redraw()

View file

@ -1,665 +1,42 @@
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 BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd")
@export var building_type: String = "mothership"
# Which GameState.buildings record this node is showing. Set by
# BuildingField when it spawns, so a node can be traced back to the stock
# and production queue that survive the scene swap (#16).
@export var definition_id: String = "mothership"
@export var building_id: int = 0
@export var interact_radius: float = 150.0
const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55)
const CRAFTER_COLOR: Color = Color(1.0, 0.72, 0.25)
const COMMAND_COLOR: Color = Color(0.55, 0.6, 1.0)
const TRANSPORT_HUB_COLOR: Color = Color(0.95, 0.55, 0.85)
const SHIP_LAB_COLOR: Color = Color(0.55, 0.95, 0.9)
var definition: Resource
var visual: Node2D
# 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
var building_type: String:
get: return definition_id
set(value): definition_id = value
func _ready() -> void:
add_to_group("buildings")
z_index = -1
# The crafter runs an assembly line and the mothership holds a live
# reactor with satellites in orbit, so both animate. The command centre is
# entirely static and needs no per-frame work at all.
var animated: bool = building_type != GameState.BUILDING_COMMAND_CENTER
set_process(animated)
if building_type == GameState.BUILDING_SHIP_CRAFTER:
for part in SHIP_PARTS:
_hull_parts.append(PackedVector2Array(part.points))
_build_crafter_pieces()
elif building_type == GameState.BUILDING_MOTHERSHIP:
_build_mothership_pieces()
elif building_type == GameState.BUILDING_TRANSPORT_HUB:
_build_hub_pieces()
elif building_type == GameState.BUILDING_SHIP_LAB:
_build_lab_pieces()
queue_redraw()
definition = BUILDING_DEFINITIONS.get_definition(definition_id)
if definition == null or definition.visual_scene == null:
return
visual = definition.visual_scene.instantiate()
get_node("VisualHost").add_child(visual)
func _process(delta: float) -> void:
anim_time = fmod(anim_time + delta, CYCLE)
phase_time += delta
apply_animation()
func available_actions() -> PackedStringArray:
if visual == null:
return PackedStringArray()
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
# _process; public so a preview or a test can pin the clock and see exactly
# that frame without having to run the animation up to it in real time.
func apply_animation() -> void:
match building_type:
GameState.BUILDING_MOTHERSHIP:
_animate_mothership()
GameState.BUILDING_SHIP_CRAFTER:
_animate_crafter()
GameState.BUILDING_TRANSPORT_HUB:
_animate_transport_hub()
GameState.BUILDING_SHIP_LAB:
_animate_ship_lab()
# Where the satellites are, and how open the reactor core is. Both are read
# by _draw and by the test that checks they stay continuous across the
# assembly clock's wrap.
func orbit_angle() -> float:
return phase_time * ORBIT_SPEED
func core_pulse() -> float:
return 0.5 + 0.5 * sin(phase_time * PULSE_SPEED)
func primary_action() -> String:
for action in available_actions():
if action != "transfer" and action != "hangar":
return action
return ""
func get_display_name() -> String:
match building_type:
GameState.BUILDING_MOTHERSHIP:
return "Mothership"
GameState.BUILDING_SHIP_CRAFTER:
return "Ship Crafter"
_:
return "Command Center"
# How many times this building's geometry has actually been rasterised. The
# whole point of the NeonPiece decomposition is that this stays at 1 for the
# building's lifetime however long it animates, so it is worth being able to
# assert rather than assume.
var draw_count: int = 0
func _draw() -> void:
draw_count += 1
# Runs exactly once per building: this is the fixed structure. Everything
# that moves is a NeonPiece child, animated through node properties, so
# nothing here ever asks for a redraw again.
match building_type:
GameState.BUILDING_COMMAND_CENTER:
_draw_command_center()
GameState.BUILDING_MOTHERSHIP:
_draw_mothership_static(self)
GameState.BUILDING_TRANSPORT_HUB:
_draw_transport_hub_static(self)
GameState.BUILDING_SHIP_LAB:
_draw_ship_lab_static(self)
_:
_draw_crafter_static(self)
# --- shared helpers -------------------------------------------------------
func _ring(centre: Vector2, radius: float, sides: int, phase: float = 0.0) -> PackedVector2Array:
var pts: PackedVector2Array = PackedVector2Array()
for i in range(sides):
var a: float = (float(i) / float(sides)) * TAU + phase
pts.append(centre + Vector2(cos(a), sin(a)) * radius)
return pts
func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int) -> PackedVector2Array:
var pts: PackedVector2Array = PackedVector2Array()
for i in range(steps + 1):
var a: float = lerpf(from_a, to_a, float(i) / float(steps))
pts.append(centre + Vector2(cos(a), sin(a)) * radius)
return pts
# --- command centre: a tactical plotting hub ------------------------------
# The mothership is radial-and-swept, the crafter is an asymmetric assembly
# line, so the command centre reads best as the odd one out: rigid, hexagonal,
# a wheel rather than a claw. Six spokes tie a hex rim to a central diamond
# readout, and every spoke ends in a formation port - half of them holding a
# token, half left open - so the structure itself shows fleet slots waiting
# to be filled, which is exactly what the drag-and-drop editor inside does.
const CMD_HEX_RADIUS: float = 88.0
const CMD_DECK_RADIUS: float = 66.0
const CMD_HUB_RADIUS: float = 30.0
const CMD_SCREEN_RADIUS: float = 14.0
const CMD_PORT_COUNT: int = 6
const CMD_PORT_LENGTH: float = 16.0
const CMD_TOKEN_RADIUS: float = 9.0
const CMD_STUD_RADIUS: float = 7.0
# --- ship lab -------------------------------------------------------------
func _draw_ship_lab_static(target: CanvasItem) -> void:
var c: Color = SHIP_LAB_COLOR
# Outer test frame, open at the corners so it reads as a rig rather than
# a solid hull.
for sx in [-1.0, 1.0]:
for sy in [-1.0, 1.0]:
var corner: Vector2 = Vector2(sx * LAB_FRAME_W, sy * LAB_FRAME_H)
NeonDraw.draw_glowing_line(target, corner,
corner - Vector2(sx * LAB_FRAME_W * 0.45, 0.0), c, 0.9, 0.42)
NeonDraw.draw_glowing_line(target, corner,
corner - Vector2(0.0, sy * LAB_FRAME_H * 0.5), c, 0.9, 0.42)
# The vessel under test, held nose-up in the middle - deliberately the
# fleet's own silhouette, so it is obvious what the lab is for.
NeonDraw.draw_glowing_polygon(target, PackedVector2Array([
Vector2(0, -26), Vector2(13, 12), Vector2(0, 5), Vector2(-13, 12),
]), HULL_COLOR, 0.95)
# Mount ring and its bracing.
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, LAB_MOUNT_RADIUS, 6, PI / 6.0), c, 0.5,
NeonDraw.DEFAULT_CORE, 0.7)
for i in range(4):
var a: float = (float(i) / 4.0) * TAU + PI / 4.0
var dir: Vector2 = Vector2(cos(a), sin(a))
NeonDraw.draw_glowing_line(target, dir * LAB_MOUNT_RADIUS, dir * 40.0, c, 0.45, 0.3)
func _build_lab_pieces() -> void:
# Two instrument booms sweeping round the vessel. One holder, rasterised
# once, rotated per frame (#10).
_lab_scanners = Node2D.new()
add_child(_lab_scanners)
for i in range(2):
var a: float = float(i) * PI
var at: Vector2 = Vector2(cos(a), sin(a)) * LAB_SCANNER_ORBIT
var piece: NeonPiece = NeonPiece.create(_lab_scanners, at, SHIP_LAB_COLOR)
piece.add_polygon(PackedVector2Array([
Vector2(-4, -12), Vector2(4, -12), Vector2(4, 12), Vector2(-4, 12),
]))
piece.add_line(Vector2(0, 0), Vector2(-cos(a), -sin(a)) * 26.0, 0.3, 0.5)
piece.rotation = a
piece.queue_redraw()
func _animate_ship_lab() -> void:
_lab_scanners.rotation = phase_time * LAB_SCAN_SPEED
# --- transport hub --------------------------------------------------------
func _draw_transport_hub_static(target: CanvasItem) -> void:
var c: Color = TRANSPORT_HUB_COLOR
# Dock ring and the deck nested inside it.
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_RING_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 1.0)
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_DECK_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 0.45, NeonDraw.DEFAULT_CORE, 0.7)
# Freight core: a squared-off hold rather than a reactor, so it reads as
# storage and not as another power plant.
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_CORE_RADIUS, 4, PI / 4.0), c, 0.95)
for i in range(HUB_BERTH_COUNT):
var a: float = (float(i) / HUB_BERTH_COUNT) * TAU + PI / 8.0
var dir: Vector2 = Vector2(cos(a), sin(a))
NeonDraw.draw_glowing_line(target, dir * HUB_CORE_RADIUS, dir * HUB_DECK_RADIUS, c, 0.5, 0.32)
# Berth arm standing off the ring, where a transporter parks.
NeonDraw.draw_glowing_line(target, dir * HUB_RING_RADIUS,
dir * (HUB_RING_RADIUS + HUB_BERTH_LENGTH), c, 0.75, 0.38)
func _build_hub_pieces() -> void:
# Stationed transports, as NeonPiece children of one holder that turns.
# Per #10 the geometry is rasterised once and only the holder's rotation
# changes, so the traffic costs no redraws at all.
_hub_traffic = Node2D.new()
add_child(_hub_traffic)
for i in range(HUB_DOCKED_COUNT):
var a: float = (float(i) / HUB_DOCKED_COUNT) * TAU
var at: Vector2 = Vector2(cos(a), sin(a)) * HUB_DOCKED_ORBIT
var piece: NeonPiece = NeonPiece.create(_hub_traffic, at, TRANSPORT_HUB_COLOR)
# A stubby freighter: blunt, boxy, nothing like the fleet's darts.
piece.add_polygon(PackedVector2Array([
Vector2(-9, -5), Vector2(9, -5), Vector2(12, 0), Vector2(9, 5), Vector2(-9, 5),
]))
piece.rotation = a + PI / 2.0
piece.queue_redraw()
func _animate_transport_hub() -> void:
_hub_traffic.rotation = phase_time * HUB_SPIN_SPEED
func _diamond(centre: Vector2, r: float) -> PackedVector2Array:
return PackedVector2Array([
centre + Vector2(0, -r), centre + Vector2(r, 0),
centre + Vector2(0, r), centre + Vector2(-r, 0),
])
func _draw_command_center() -> void:
var c: Color = COMMAND_COLOR
var readout: Color = Color(0.82, 0.86, 1.0)
# The rim: a hex table holding the whole layout together, with a fainter
# deck ring nested just inside it - the same "plating has layers" read the
# mothership gets from its nested reactor rings.
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_HEX_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 1.0)
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_DECK_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 0.5, NeonDraw.DEFAULT_CORE, 0.7)
for i in range(CMD_PORT_COUNT):
var a: float = (float(i) / CMD_PORT_COUNT) * TAU + PI / 6.0
var dir: Vector2 = Vector2(cos(a), sin(a))
# Spoke bracing the hub to the rim.
NeonDraw.draw_glowing_line(self, dir * CMD_HUB_RADIUS, dir * CMD_HEX_RADIUS, c, 0.65, 0.36)
# Socket where the mast plugs into the rim.
NeonDraw.draw_glowing_polygon(self, _ring(dir * CMD_HEX_RADIUS, CMD_STUD_RADIUS, 6, a), c, 0.85)
# Port mast standing proud of the rim; every other one is manned.
var port_tip: Vector2 = dir * (CMD_HEX_RADIUS + CMD_PORT_LENGTH)
NeonDraw.draw_glowing_line(self, dir * CMD_HEX_RADIUS, port_tip, c, 0.8, 0.4)
if i % 2 == 0:
NeonDraw.draw_glowing_polygon(self, _diamond(port_tip, CMD_TOKEN_RADIUS), c, 1.0)
# Command core: a diamond hub with a lit readout set inside it.
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_HUB_RADIUS), c, 1.0)
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9)
# --- mothership: a radial flagship ----------------------------------------
# Not a big fighter. A mothership is the thing everything else orbits, so it
# is built radially around a reactor core rather than pointed in a direction:
# six swept talons, crystal panels set between them, and satellites circling
# the whole assembly. A forward-facing hull, however large, just reads as
# another ship.
const ARM_COUNT: int = 6
const ARM_INNER: float = 42.0
const ARM_OUTER: float = 106.0
const ARM_SWEEP: float = 0.72
const SATELLITE_COUNT: int = 4
const SATELLITE_ORBIT: float = 126.0
const ORBIT_SPEED: float = 0.32
const PULSE_SPEED: float = 2.4
func _talon(base_angle: float) -> PackedVector2Array:
# One swept claw: the outer edge runs from the core to the tip, and the
# inner edge comes back a little behind it, closing to a point so the arm
# tapers instead of ending in a blunt stub.
var steps: int = 7
var pts: PackedVector2Array = PackedVector2Array()
for i in range(steps + 1):
var f: float = float(i) / float(steps)
var a: float = base_angle + ARM_SWEEP * pow(f, 1.3)
pts.append(Vector2(cos(a), sin(a)) * lerpf(ARM_INNER, ARM_OUTER, f))
for i in range(steps, -1, -1):
var f: float = float(i) / float(steps)
var trail: float = 0.34 * (1.0 - f) + 0.04
var a: float = base_angle + ARM_SWEEP * pow(f, 1.3) - trail
pts.append(Vector2(cos(a), sin(a)) * lerpf(ARM_INNER, ARM_OUTER, f))
return pts
func _draw_mothership_static(canvas: CanvasItem) -> void:
var c: Color = MOTHERSHIP_COLOR
for i in range(ARM_COUNT):
var base: float = (float(i) / float(ARM_COUNT)) * TAU
NeonDraw.draw_glowing_polygon(canvas, _talon(base), c, 0.95)
# Crystal panel set into the gap behind each arm.
var pa: float = base - 0.30
NeonDraw.draw_glowing_polygon(canvas, PackedVector2Array([
Vector2(cos(pa - 0.11), sin(pa - 0.11)) * 54.0,
Vector2(cos(pa + 0.11), sin(pa + 0.11)) * 54.0,
Vector2(cos(pa + 0.15), sin(pa + 0.15)) * 86.0,
Vector2(cos(pa - 0.15), sin(pa - 0.15)) * 86.0,
]), c, 0.7)
# Reactor housing: the nested rings around the cell that breathes.
NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0)
NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85)
const CORE_CELL_RADIUS: float = 12.0
func _build_mothership_pieces() -> void:
var core_color: Color = Color(0.75, 1.0, 0.85)
# The breathing cell: drawn at a reference size and then scaled, rather
# than rebuilt at a new radius every frame.
_core_cell = NeonPiece.create(self, Vector2.ZERO, core_color)
_core_cell.add_polygon(_ring(Vector2.ZERO, CORE_CELL_RADIUS, 6, PI / 6.0))
# Satellites sit at fixed offsets inside a holder; the orbit is the
# holder's rotation, so four hexagon pairs become one transform update.
_satellites = Node2D.new()
add_child(_satellites)
for i in range(SATELLITE_COUNT):
var a: float = (float(i) / float(SATELLITE_COUNT)) * TAU
var at: Vector2 = Vector2(cos(a), sin(a)) * SATELLITE_ORBIT
var shell: NeonPiece = NeonPiece.create(_satellites, at, MOTHERSHIP_COLOR)
shell.add_polygon(_ring(Vector2.ZERO, 10.0, 6, PI / 6.0), 0.85)
var cell: NeonPiece = NeonPiece.create(_satellites, at, core_color)
cell.add_polygon(_ring(Vector2.ZERO, 4.5, 6, PI / 6.0), 0.8)
func _animate_mothership() -> void:
var breath: float = core_pulse()
var radius: float = lerpf(9.0, 15.0, breath)
_core_cell.scale = Vector2.ONE * (radius / CORE_CELL_RADIUS)
_core_cell.modulate.a = lerpf(0.55, 1.0, breath)
_satellites.rotation = orbit_angle()
# --- ship crafter: an orbital yard with a hull in the cradle --------------
func _draw_crafter_static(canvas: CanvasItem) -> void:
var c: Color = CRAFTER_COLOR
# Fabricator core housing. This replaces the chimney - nothing vents
# smoke in vacuum.
NeonDraw.draw_glowing_polygon(canvas, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0)
# Radiator fins above and below the core, slatted like real ones.
for dir_y in [-1.0, 1.0]:
var fin: PackedVector2Array = PackedVector2Array([
CORE_CENTRE + Vector2(-16, 30 * dir_y), CORE_CENTRE + Vector2(18, 34 * dir_y),
CORE_CENTRE + Vector2(24, 54 * dir_y), CORE_CENTRE + Vector2(-10, 50 * dir_y),
])
NeonDraw.draw_glowing_polygon(canvas, fin, c, 0.75)
for f in [0.35, 0.68]:
NeonDraw.draw_glowing_line(canvas,
fin[0].lerp(fin[3], f), fin[1].lerp(fin[2], f), c, 0.5, 0.28)
# Truss spine linking the core to the cradle: two rails with X-bracing,
# the standard "this was assembled in orbit" read.
var spine_top: float = 4.0
var spine_bottom: float = 22.0
NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4)
NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4)
for i in range(3):
var x0: float = -32.0 + float(i) * 15.0
var x1: float = x0 + 15.0
NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28)
NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28)
# Construction cradle: two open ribs holding the hull, with the crane
# rail overhead. Open at the top so the finished hull can clear it.
NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7)
NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7)
NeonDraw.draw_glowing_line(canvas, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55)
for side in [-1.0, 1.0]:
NeonDraw.draw_glowing_line(canvas,
BAY_CENTRE + Vector2(RIB_RADIUS * side, -6.0), Vector2(BAY_CENTRE.x + 44.0 * side, RAIL_Y), c, 0.7, 0.3)
func _build_crafter_pieces() -> void:
var c: Color = CRAFTER_COLOR
# The core's inner cell: fades with the reactor rather than being redrawn
# at a new brightness each frame.
_core_cell = NeonPiece.create(self, CORE_CENTRE, Color(1.0, 0.9, 0.6))
_core_cell.add_polygon(_ring(Vector2.ZERO, 12.0, 6, PI / 6.0))
# Docking beacons, blinking out of step with each other.
for i in range(2):
var lamp: Vector2 = BAY_CENTRE + Vector2(-RIB_RADIUS + 2.0 + float(i) * (RIB_RADIUS * 2.0 - 4.0), 30.0)
var beacon: NeonPiece = NeonPiece.create(self, lamp, Color(1.0, 0.95, 0.75))
beacon.add_polygon(_ring(Vector2.ZERO, 3.0, 4))
_beacons.append(beacon)
# Clamp arms reaching in from each rib. They let go as the hull launches,
# which is a fade rather than a redraw.
_clamps = NeonPiece.create(self, Vector2.ZERO, c)
for side in [-1.0, 1.0]:
var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0)
var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0)
_clamps.add_line(outer, inner, 0.35)
_clamps.add_line(inner + Vector2(0, -5), inner + Vector2(0, 5), 0.3)
# Crane: the head rides the rail, so only its x moves. The hoist is drawn
# one unit long and stretched with scale.y to reach whatever seam is
# being welded - stretching a line needs no redraw, redrawing it would.
_crane = Node2D.new()
add_child(_crane)
var head: NeonPiece = NeonPiece.create(_crane, Vector2.ZERO, c)
head.add_polygon(PackedVector2Array([
Vector2(-10, -7), Vector2(10, -7), Vector2(6, 6), Vector2(-6, 6),
]))
head.add_line(Vector2(0, -7), Vector2(0, RAIL_Y - (RAIL_Y + 13.0)), 0.35, 0.8)
_hoist = NeonPiece.create(_crane, Vector2(0, 6), c, 0.5)
_hoist.add_line(Vector2.ZERO, Vector2(0, 1), 0.3)
# Weld sparks: four spokes on a holder that spins and stretches. The
# jitter is genuinely procedural, but rotating and scaling a holder gives
# the same flicker without re-tessellating the spokes every frame.
_sparks = Node2D.new()
add_child(_sparks)
for i in range(4):
var a: float = float(i) * TAU / 4.0
var spark: NeonPiece = NeonPiece.create(_sparks, Vector2.ZERO, Color(1.0, 0.95, 0.7))
spark.add_line(Vector2.ZERO, Vector2(cos(a), sin(a)) * 11.0, 0.3)
# The hull under construction. Each part is its own piece so attaching one
# is a `visible` flip, and the launch is the holder's position and alpha.
_hull_holder = Node2D.new()
add_child(_hull_holder)
for i in range(_hull_parts.size()):
var part: NeonPiece = NeonPiece.create(_hull_holder, Vector2.ZERO, HULL_COLOR)
part.add_polygon(_hull_parts[i])
part.visible = false
_hull_pieces.append(part)
# Engine wash under the hull, and the completion ring. The ring is drawn
# at a reference radius and scaled outwards.
_wash = NeonPiece.create(_hull_holder, Vector2.ZERO, Color(1.0, 0.85, 0.45), 0.0)
for dx in [-4.0, 4.0]:
_wash.add_line(Vector2(dx, 14), Vector2(dx * 1.6, 30.0), 0.45)
_flash_ring = NeonPiece.create(self, BAY_CENTRE, HULL_COLOR, 0.0)
_flash_ring.add_polygon(_ring(Vector2.ZERO, FLASH_RING_RADIUS, 6, PI / 6.0))
func _animate_crafter() -> void:
var t: float = anim_time / CYCLE
_core_cell.modulate.a = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0))
for i in range(_beacons.size()):
_beacons[i].modulate.a = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI))
if t < ASSEMBLE_END:
_animate_assembly(t / ASSEMBLE_END)
elif t < FLASH_END:
_animate_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END))
else:
_animate_launch((t - FLASH_END) / (1.0 - FLASH_END))
# How many pieces are on the hull at this point through the assembly phase.
# Parts attach in order, so this is just how many thresholds have passed.
func attached_part_count(p: float) -> int:
var n: int = 0
for part in SHIP_PARTS:
if p >= float(part.at):
n += 1
return n
func _place_hull(centre: Vector2, alpha: float) -> void:
# The parts are defined around their own origin, so the holder goes
# straight to the point they should be centred on.
_hull_holder.position = centre
_hull_holder.modulate.a = alpha
func _animate_assembly(p: float) -> void:
_clamps.visible = true
_clamps.modulate.a = 0.8
_flash_ring.modulate.a = 0.0
_wash.modulate.a = 0.0
_place_hull(BAY_CENTRE, 1.0)
var next_part: int = attached_part_count(p)
for i in range(_hull_pieces.size()):
var attached: bool = i < next_part
_hull_pieces[i].visible = attached
if attached:
# Freshly attached parts flare, then settle to full brightness.
var age: float = p - float(SHIP_PARTS[i].at)
_hull_pieces[i].modulate.a = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08)
# The crane rides the rail over whichever seam is being worked, and
# retracts once there is nothing left to attach.
var weld: Vector2 = BAY_CENTRE + (SHIP_PARTS[mini(next_part, SHIP_PARTS.size() - 1)].weld as Vector2)
if next_part >= _hull_pieces.size():
weld.y = RAIL_Y + 16.0
_crane.position = Vector2(weld.x, RAIL_Y + 13.0)
_hoist.scale.y = maxf(weld.y - (RAIL_Y + 19.0), 1.0)
var welding: bool = next_part < _hull_pieces.size()
_sparks.visible = welding
if welding:
var flicker: float = 0.45 + 0.55 * absf(sin(phase_time * 34.0))
_sparks.position = weld
_sparks.rotation = phase_time * 7.0
_sparks.scale = Vector2.ONE * (0.55 + 0.45 * flicker)
_sparks.modulate.a = flicker
func _animate_completion_flash(p: float) -> void:
# Whole hull, clamps springing open, and a ring pushing outwards to mark
# it as finished.
_clamps.modulate.a = 1.0 - p
_sparks.visible = false
_place_hull(BAY_CENTRE, 1.0)
for piece in _hull_pieces:
piece.visible = true
piece.modulate.a = 1.0
# Retracted, so it does not hang over the finished hull.
_crane.position = Vector2(BAY_CENTRE.x, RAIL_Y + 13.0)
_hoist.scale.y = 1.0
_flash_ring.modulate.a = 1.0 - p
_flash_ring.scale = Vector2.ONE * (lerpf(18.0, 56.0, p) / FLASH_RING_RADIUS)
func _animate_launch(p: float) -> void:
# Clears the cradle and fades out; the next cycle starts from bare ribs.
_clamps.modulate.a = 0.0
_sparks.visible = false
_flash_ring.modulate.a = 0.0
var eased: float = p * p
var fade: float = clampf(1.0 - p * 1.15, 0.0, 1.0)
_place_hull(BAY_CENTRE + Vector2(0.0, -124.0 * eased), fade)
for piece in _hull_pieces:
piece.visible = true
piece.modulate.a = 1.0
# Engine wash, strongest just after it breaks free of the clamps.
_wash.modulate.a = 0.4 + 0.6 * sin(minf(p * 3.2, PI))
return String(definition.display_name) if definition != null else definition_id.capitalize()

View file

@ -4,3 +4,5 @@
[node name="Building" type="Node2D"]
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:
var b: Node2D = building_scene.instantiate()
b.position = record.position
b.building_type = record.type
b.definition_id = record.type
b.building_id = int(record.get("id", 0))
# 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

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

@ -1,5 +1,11 @@
extends Node
const INVENTORY = preload("res://Inventory.gd")
const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd")
const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd")
const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
# Autoload singleton - the one thing that survives change_scene_to_file()
# scene swaps between space (Main.tscn) and a planet's surface
# (PlanetSurface.tscn). Everything else in either scene gets torn down
@ -12,7 +18,7 @@ var return_world_position: Vector2 = Vector2.ZERO
var return_camera_zoom: float = 1.0
var planet_name: String = ""
var planet_type: String = ""
var planet_definition_id: String = "rocky"
# Explored-chunk history for the world map's fog-of-war reveal. Lives
# here (not on ExplorationTracker, which gets destroyed and recreated on
@ -22,126 +28,101 @@ var explored_chunks: Dictionary = {}
# --- Resources -------------------------------------------------------------
# Ore that has been BANKED at the mothership. This is the only pool anything
# can be bought with.
# Starts stocked for play-testing so buildings can be tried without mining
# first - drop this back to 0 for a real playthrough.
var rocks: int = 500
const RESOURCE_ORE: String = RESOURCE_DEFINITIONS.ORE_ID
const RESOURCE_GAS: String = RESOURCE_DEFINITIONS.GAS_ID
const RESOURCE_TYPES: PackedStringArray = RESOURCE_DEFINITIONS.ORDER
# Gas is a second, banked resource. It is deliberately separate from ore:
# construction still spends ore, while future gas technologies can consume
# gas without making the two materials silently interchangeable.
var gas: int = 0
# All resource-bearing locations use the same container. The old public
# names below are computed compatibility accessors, not separate storage.
var bank_inventory: RefCounted = INVENTORY.new({RESOURCE_ORE: 500})
var fleet_inventory: RefCounted = INVENTORY.new()
# Ore the fleet is physically carrying and has not delivered yet (#14). Mining
# fills this, not `rocks`: ore is cargo until the fleet brings it home, so a
# long mining run is a real trip with something to lose rather than instant
# income from anywhere on the map.
#
# Lives here, like everything else that has to survive Main.tscn being torn
# down and rebuilt on every trip to a planet's surface - and cargo mined ON a
# surface would otherwise be dropped the moment the player took off.
var fleet_cargo: int = 0
var fleet_gas_cargo: int = 0
var rocks: int:
get: return bank_inventory.amount(RESOURCE_ORE)
set(value): bank_inventory.set_amount(RESOURCE_ORE, value)
# Resource-facing UI and logistics iterate this list instead of growing a
# second set of controls and controller branches every time a material is
# introduced. Keep presentation data here with the state it describes so a
# resource has one name and colour everywhere it appears.
const RESOURCE_ORE: String = "ore"
const RESOURCE_GAS: String = "gas"
const RESOURCE_TYPES: PackedStringArray = [RESOURCE_ORE, RESOURCE_GAS]
const RESOURCE_NAMES: Dictionary = {
RESOURCE_ORE: "Ore",
RESOURCE_GAS: "Gas",
}
const RESOURCE_COLORS: Dictionary = {
RESOURCE_ORE: Color(0.0, 0.72, 0.82),
RESOURCE_GAS: Color(0.85, 0.52, 1.0),
}
var gas: int:
get: return bank_inventory.amount(RESOURCE_GAS)
set(value): bank_inventory.set_amount(RESOURCE_GAS, value)
var fleet_cargo: int:
get: return fleet_inventory.amount(RESOURCE_ORE)
set(value): fleet_inventory.set_amount(RESOURCE_ORE, value)
var fleet_gas_cargo: int:
get: return fleet_inventory.amount(RESOURCE_GAS)
set(value): fleet_inventory.set_amount(RESOURCE_GAS, value)
func collect_cargo(amount: int = 1) -> void:
fleet_cargo += amount
fleet_inventory.deposit(RESOURCE_ORE, amount)
func collect_gas_cargo(amount: int = 1) -> void:
fleet_gas_cargo += amount
fleet_inventory.deposit(RESOURCE_GAS, amount)
func resource_types() -> PackedStringArray:
return RESOURCE_DEFINITIONS.ids()
func resource_definition(resource_type: String) -> Resource:
return RESOURCE_DEFINITIONS.get_definition(resource_type)
func resource_name(resource_type: String) -> String:
return String(RESOURCE_NAMES.get(resource_type, resource_type.capitalize()))
var definition: Resource = resource_definition(resource_type)
return String(definition.display_name) if definition != null else resource_type.capitalize()
func resource_color(resource_type: String) -> Color:
return RESOURCE_COLORS.get(resource_type, Color.WHITE)
var definition: Resource = resource_definition(resource_type)
return definition.color if definition != null else Color.WHITE
func banked_resource(resource_type: String) -> int:
match resource_type:
RESOURCE_ORE: return rocks
RESOURCE_GAS: return gas
_: return 0
return bank_inventory.amount(resource_type)
func fleet_resource(resource_type: String) -> int:
match resource_type:
RESOURCE_ORE: return fleet_cargo
RESOURCE_GAS: return fleet_gas_cargo
_: return 0
return fleet_inventory.amount(resource_type)
func _inventory_for_record(record: Dictionary) -> RefCounted:
var inventory: RefCounted = record.get("inventory") as RefCounted
if inventory == null:
inventory = INVENTORY.new()
record.inventory = inventory
# Compatibility migration for state made before #33. Erase each legacy
# field once absorbed so it cannot drift out of step with the inventory.
if record.has("stock"):
inventory.set_amount(RESOURCE_ORE, int(record.stock))
record.erase("stock")
if record.has("gas_stock"):
inventory.set_amount(RESOURCE_GAS, int(record.gas_stock))
record.erase("gas_stock")
return inventory
func building_inventory(id: int) -> RefCounted:
var building: Dictionary = find_building(id)
if building.is_empty():
return null
if building_has_capability(building, "bank"):
return bank_inventory
return _inventory_for_record(building)
func building_resource(id: int, resource_type: String) -> int:
var building: Dictionary = find_building(id)
if building.is_empty():
return 0
if building.type == BUILDING_MOTHERSHIP:
return banked_resource(resource_type)
match resource_type:
RESOURCE_ORE: return int(building.get("stock", 0))
RESOURCE_GAS: return int(building.get("gas_stock", 0))
_: return 0
var inventory: RefCounted = building_inventory(id)
return 0 if inventory == null else inventory.amount(resource_type)
func transfer_resource_to_building(id: int, resource_type: String, amount: int) -> int:
var building: Dictionary = find_building(id)
if building.is_empty():
return 0
var moved: int = mini(maxi(amount, 0), fleet_resource(resource_type))
if moved <= 0:
return 0
match resource_type:
RESOURCE_ORE: fleet_cargo -= moved
RESOURCE_GAS: fleet_gas_cargo -= moved
_: return 0
if building.type == BUILDING_MOTHERSHIP:
match resource_type:
RESOURCE_ORE: rocks += moved
RESOURCE_GAS: gas += moved
else:
match resource_type:
RESOURCE_ORE: building.stock = int(building.get("stock", 0)) + moved
RESOURCE_GAS: building.gas_stock = int(building.get("gas_stock", 0)) + moved
return moved
return fleet_inventory.transfer_to(building_inventory(id), resource_type, amount)
func transfer_resource_to_fleet(id: int, resource_type: String, amount: int) -> int:
var building: Dictionary = find_building(id)
if building.is_empty():
return 0
var moved: int = mini(maxi(amount, 0), building_resource(id, resource_type))
if moved <= 0:
return 0
if building.type == BUILDING_MOTHERSHIP:
match resource_type:
RESOURCE_ORE: rocks -= moved
RESOURCE_GAS: gas -= moved
else:
match resource_type:
RESOURCE_ORE: building.stock = int(building.get("stock", 0)) - moved
RESOURCE_GAS: building.gas_stock = int(building.get("gas_stock", 0)) - moved
match resource_type:
RESOURCE_ORE: fleet_cargo += moved
RESOURCE_GAS: fleet_gas_cargo += moved
return moved
var source: RefCounted = building_inventory(id)
return 0 if source == null else source.transfer_to(fleet_inventory, resource_type, amount)
# The batch size the transfer menu offers, next to "All". A batch rather
@ -334,11 +315,6 @@ func unlock(id: 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:
if node.get("unlocks", "") == type:
return is_unlocked(node.id)
@ -400,8 +376,8 @@ func add_building(type: String, at_position: Vector2) -> int:
var id: int = next_building_id
next_building_id += 1
buildings.append({
"id": id, "type": type, "position": at_position, "stock": 0,
"gas_stock": 0, "orders": [],
"id": id, "type": type, "position": at_position,
"inventory": INVENTORY.new(), "orders": [],
})
return id
@ -413,6 +389,13 @@ func find_building(id: int) -> Dictionary:
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) ---------------------------------
# The three things that can be flying around. The first and last double as
@ -422,21 +405,10 @@ const VESSEL_INTERCEPTOR: String = "interceptor"
const VESSEL_SHIELD: String = "shield"
const VESSEL_TRANSPORTER: String = "transporter"
const VESSEL_NAMES: Dictionary = {
VESSEL_INTERCEPTOR: "Interceptor",
VESSEL_SHIELD: "Shield Vessel",
VESSEL_TRANSPORTER: "Transporter",
}
# Compatibility constants for callers and old tests. Runtime metadata is
# resolved from VesselDefinition below.
const SHIELD_COST: int = 14
# How long one vessel takes to build, once its ore is actually in the
# building. A queue you can watch and cancel needs time to exist in.
const BUILD_TIMES: Dictionary = {
VESSEL_INTERCEPTOR: 10.0,
VESSEL_SHIELD: 16.0,
VESSEL_TRANSPORTER: 8.0,
}
const TRANSPORTER_COST: int = 10
const LAB_RESEARCH_TIME: float = 8.0
@ -447,57 +419,14 @@ var build_time_scale: float = 1.0
func build_time(product: String) -> float:
return float(BUILD_TIMES.get(product, 10.0)) * build_time_scale
var definition: Resource = VESSEL_DEFINITIONS.get_definition(product)
return (definition.build_time if definition != null else 10.0) * build_time_scale
func lab_research_time() -> float:
return LAB_RESEARCH_TIME * build_time_scale
# Researched at the ship lab, per vessel type. Separate from SKILL_TREE on
# purpose: that gates what the player may build at all and is a strategic
# spine, while these are repeatable tuning on a vessel you already have.
# `cost` is the FIRST level; each level after costs cost * (level + 1), so
# the third point in something is not the same price as the first.
const VESSEL_UPGRADES: Array = [
{
"id": "int_damage", "vessel": VESSEL_INTERCEPTOR, "name": "Heavy Slugs",
"cost": 30, "max_level": 3, "desc": "+1 damage per shot.",
},
{
"id": "int_rate", "vessel": VESSEL_INTERCEPTOR, "name": "Autoloader",
"cost": 25, "max_level": 3, "desc": "Fires 15% faster per level.",
},
{
"id": "int_range", "vessel": VESSEL_INTERCEPTOR, "name": "Targeting Array",
"cost": 20, "max_level": 3, "desc": "+60 firing range per level.",
},
{
"id": "int_speed", "vessel": VESSEL_INTERCEPTOR, "name": "Thrusters",
"cost": 25, "max_level": 3, "desc": "+40 top speed, +60 acceleration per level.",
},
{
"id": "shd_hits", "vessel": VESSEL_SHIELD, "name": "Layered Emitters",
"cost": 60, "max_level": 2, "desc": "+1 hit absorbed before the shield drops.",
},
{
"id": "shd_recharge", "vessel": VESSEL_SHIELD, "name": "Fast Cycling",
"cost": 35, "max_level": 3, "desc": "Shield returns 1.2s sooner per level.",
},
{
"id": "shd_speed", "vessel": VESSEL_SHIELD, "name": "Thrusters",
"cost": 25, "max_level": 3, "desc": "+40 top speed, +60 acceleration per level.",
},
{
"id": "trn_capacity", "vessel": VESSEL_TRANSPORTER, "name": "Expanded Hold",
"cost": 30, "max_level": 3, "desc": "Carries 8 more ore per supply run.",
},
{
"id": "trn_speed", "vessel": VESSEL_TRANSPORTER, "name": "Freight Engines",
"cost": 30, "max_level": 3, "desc": "+70 speed on supply runs.",
},
]
# Base figures every vessel starts from, before anything is researched.
# Compatibility constants. VesselDefinition owns the live base figures.
const SHIP_BASE_DAMAGE: int = 1
const SHIP_BASE_COOLDOWN: float = 0.5
const SHIP_BASE_RANGE: float = 320.0
@ -515,15 +444,13 @@ signal vessel_upgraded(upgrade_id: String)
func has_ship_lab() -> bool:
for b in buildings:
if b.type == BUILDING_SHIP_LAB:
if building_has_capability(b, "vessel_research"):
return true
return false
func shield_vessel_unlocked() -> bool:
# Unlocked AT the lab, by paying for it there - a vessel type rather than
# a stat, so it is a one-off rather than a level.
return int(vessel_levels.get(UNLOCK_SHIELD_VESSEL, 0)) > 0
return vessel_is_unlocked(VESSEL_SHIELD)
const UNLOCK_SHIELD_VESSEL: String = "unlock_shield"
@ -531,27 +458,69 @@ const UNLOCK_SHIELD_COST: int = 45
func unlock_shield_vessel() -> bool:
if shield_vessel_unlocked() or not has_ship_lab():
return unlock_vessel(VESSEL_SHIELD)
func vessel_definition(vessel: String) -> Resource:
return VESSEL_DEFINITIONS.get_definition(vessel)
func vessel_name(vessel: String) -> String:
var definition: Resource = vessel_definition(vessel)
return definition.display_name if definition != null else vessel.capitalize()
func vessel_has_capability(vessel: String, capability: String) -> bool:
var definition: Resource = vessel_definition(vessel)
return definition != null and definition.has_capability(capability)
func vessel_is_unlocked(vessel: String) -> bool:
var definition: Resource = vessel_definition(vessel)
if definition == null:
return false
if not spend(UNLOCK_SHIELD_COST):
return definition.unlock_id == "" or int(vessel_levels.get(definition.unlock_id, 0)) > 0
func vessel_for_unlock(unlock_id: String) -> Resource:
for definition in VESSEL_DEFINITIONS.all():
if definition.unlock_id == unlock_id:
return definition
return null
func unlock_vessel(vessel: String) -> bool:
var definition: Resource = vessel_definition(vessel)
if definition == null or definition.unlock_id == "" or vessel_is_unlocked(vessel) or not has_ship_lab():
return false
vessel_levels[UNLOCK_SHIELD_VESSEL] = 1
vessel_upgraded.emit(UNLOCK_SHIELD_VESSEL)
if not spend(definition.unlock_cost):
return false
vessel_levels[definition.unlock_id] = 1
vessel_upgraded.emit(definition.unlock_id)
return true
func upgrade_def(id: String) -> Dictionary:
for u in VESSEL_UPGRADES:
if u.id == id:
return u
for definition in VESSEL_DEFINITIONS.all():
for upgrade in definition.upgrades:
if upgrade.upgrade_id == id:
return {"id": upgrade.upgrade_id, "vessel": definition.definition_id,
"name": upgrade.display_name, "desc": upgrade.description,
"cost": upgrade.base_cost, "max_level": upgrade.max_level,
"icon": upgrade.icon, "resource": upgrade}
return {}
func upgrades_for(vessel: String) -> Array:
var out: Array = []
for u in VESSEL_UPGRADES:
if u.vessel == vessel:
out.append(u)
var definition: Resource = vessel_definition(vessel)
if definition == null:
return out
for upgrade in definition.upgrades:
out.append({"id": upgrade.upgrade_id, "vessel": vessel,
"name": upgrade.display_name, "desc": upgrade.description,
"cost": upgrade.base_cost, "max_level": upgrade.max_level,
"icon": upgrade.icon, "resource": upgrade})
return out
@ -585,77 +554,60 @@ func buy_upgrade(id: String) -> bool:
return true
# --- what those upgrades actually come to ---------------------------------
# Read live rather than baked into a vessel when it is built, so researching
# something improves the ships already flying instead of only the next ones.
func ship_damage(vessel: String) -> int:
return SHIP_BASE_DAMAGE + (upgrade_level("int_damage") if vessel == VESSEL_INTERCEPTOR else 0)
func vessel_stat(vessel: String, stat: String) -> float:
var definition: Resource = vessel_definition(vessel)
if definition == null:
return 0.0
var value: float = float(definition.base_stats.get(stat, 0.0))
for upgrade in definition.upgrades:
var level: int = upgrade_level(upgrade.upgrade_id)
value += float(upgrade.additive_modifiers.get(stat, 0.0)) * level
if upgrade.multiplicative_modifiers.has(stat):
value *= pow(float(upgrade.multiplicative_modifiers[stat]), level)
if definition.minimum_stats.has(stat):
value = maxf(value, float(definition.minimum_stats[stat]))
return value
func ship_fire_cooldown(vessel: String) -> float:
if vessel != VESSEL_INTERCEPTOR:
return SHIP_BASE_COOLDOWN
return SHIP_BASE_COOLDOWN * pow(0.85, float(upgrade_level("int_rate")))
func ship_fire_range(vessel: String) -> float:
return SHIP_BASE_RANGE + (60.0 * upgrade_level("int_range") if vessel == VESSEL_INTERCEPTOR else 0.0)
func _speed_levels(vessel: String) -> int:
if vessel == VESSEL_SHIELD:
return upgrade_level("shd_speed")
return upgrade_level("int_speed")
func ship_max_speed(vessel: String) -> float:
return SHIP_BASE_SPEED + 40.0 * _speed_levels(vessel)
func ship_acceleration(vessel: String) -> float:
return SHIP_BASE_ACCEL + 60.0 * _speed_levels(vessel)
func ship_damage(vessel: String) -> int: return int(vessel_stat(vessel, "damage"))
func ship_fire_cooldown(vessel: String) -> float: return vessel_stat(vessel, "fire_cooldown")
func ship_fire_range(vessel: String) -> float: return vessel_stat(vessel, "fire_range")
func ship_max_speed(vessel: String) -> float: return vessel_stat(vessel, "max_speed")
func ship_acceleration(vessel: String) -> float: return vessel_stat(vessel, "acceleration")
func shield_max_charges() -> int:
return SHIELD_BASE_CHARGES + upgrade_level("shd_hits")
var definition: Resource = VESSEL_DEFINITIONS.with_capability("shield")
return int(vessel_stat(definition.definition_id, "shield_charges"))
func shield_recharge_time() -> float:
return maxf(2.0, SHIELD_BASE_RECHARGE - 1.2 * upgrade_level("shd_recharge"))
var definition: Resource = VESSEL_DEFINITIONS.with_capability("shield")
return vessel_stat(definition.definition_id, "shield_recharge")
func transporter_load_limit() -> int:
return TRANSPORTER_BASE_LOAD + 8 * upgrade_level("trn_capacity")
var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport")
return int(vessel_stat(definition.definition_id, "cargo_capacity"))
func transporter_speed() -> float:
return TRANSPORTER_BASE_SPEED + 70.0 * upgrade_level("trn_speed")
var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport")
return vessel_stat(definition.definition_id, "transport_speed")
const PRODUCT_SHIP: String = "interceptor"
const PRODUCT_TRANSPORTER: String = "transporter"
const TRANSPORTER_COST: int = 10
func product_cost(product: String) -> int:
if product == PRODUCT_TRANSPORTER:
return TRANSPORTER_COST
if product == VESSEL_SHIELD:
return SHIELD_COST
return SHIP_COST
var definition: Resource = vessel_definition(product)
return definition.build_cost if definition != null else 0
func can_produce(type: String) -> bool:
# Only the ship crafter builds anything. The hub STATIONS transporters,
# it does not make them (#12).
return type == BUILDING_SHIP_CRAFTER
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
return definition != null and "production" in definition.capabilities
func building_stock(id: int) -> int:
var b: Dictionary = find_building(id)
return 0 if b.is_empty() else int(b.get("stock", 0))
return building_resource(id, RESOURCE_ORE)
func pending_orders(id: int) -> int:
@ -702,7 +654,7 @@ func cancel_order(id: int, index: int) -> bool:
return false
var order: Dictionary = orders[index]
if bool(order.started):
b.stock = int(b.get("stock", 0)) + product_cost(String(order.product))
_inventory_for_record(b).deposit(RESOURCE_ORE, product_cost(String(order.product)))
orders.remove_at(index)
return true
@ -717,7 +669,7 @@ func outstanding_cost(id: int) -> int:
# A started build has already paid, so it is not still owed.
if not bool(o.started):
need += product_cost(String(o.product))
return maxi(need - int(b.get("stock", 0)), 0)
return maxi(need - _inventory_for_record(b).amount(RESOURCE_ORE), 0)
func deposit_to_building(id: int, amount: int) -> int:
@ -726,21 +678,21 @@ func deposit_to_building(id: int, amount: int) -> int:
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
b.stock = int(b.get("stock", 0)) + amount
return amount
return _inventory_for_record(b).deposit(RESOURCE_ORE, amount)
func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool:
var b: Dictionary = find_building(id)
if b.is_empty() or not can_produce(b.type):
return false
if product == VESSEL_SHIELD and not shield_vessel_unlocked():
var definition: Resource = vessel_definition(product)
if definition == null or not vessel_is_unlocked(product):
return false
if product == PRODUCT_TRANSPORTER and not has_transport_hub():
if definition.has_capability("transport") and not has_transport_hub():
# Nowhere to station it. Refused rather than queued, so the player is
# told now instead of watching an order sit forever.
return false
if product == PRODUCT_TRANSPORTER and not can_station_transporter():
if definition.has_capability("transport") and not can_station_transporter():
# Every berth is taken or spoken for (#20).
return false
(b.orders as Array).append({"product": product, "elapsed": 0.0, "started": false})
@ -762,17 +714,19 @@ func _advance_production(delta: float) -> void:
var order: Dictionary = orders[0]
var product: String = String(order.product)
var cost: int = product_cost(product)
var inventory: RefCounted = _inventory_for_record(b)
var definition: Resource = vessel_definition(product)
if not bool(order.started):
if int(b.get("stock", 0)) < cost:
if inventory.amount(RESOURCE_ORE) < cost:
continue
if product == PRODUCT_TRANSPORTER and transporters.size() >= transport_capacity():
if definition.has_capability("transport") and transporters.size() >= transport_capacity():
# A hub was demolished after this was ordered, so the berth it
# was going to use is gone. Hold rather than overfill.
continue
if product == PRODUCT_TRANSPORTER and nearest_hub_to(b.position).is_empty():
if definition.has_capability("transport") and nearest_hub_to(b.position).is_empty():
continue
b.stock = int(b.stock) - cost
inventory.withdraw(RESOURCE_ORE, cost)
order.started = true
order.elapsed = float(order.elapsed) + delta
@ -780,7 +734,7 @@ func _advance_production(delta: float) -> void:
continue
orders.pop_front()
if product == PRODUCT_TRANSPORTER:
if definition.has_capability("transport"):
# Transporters are unchanged: they take themselves to the nearest
# hub and wait for work (#19).
_station_transporter(b.position)
@ -799,10 +753,11 @@ func _advance_production(delta: float) -> void:
func queue_lab_research(lab_id: int, upgrade_id: String) -> bool:
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
var orders: Array = lab.get("research_orders", [])
if not orders.is_empty() or (upgrade_id != UNLOCK_SHIELD_VESSEL and upgrade_maxed(upgrade_id)):
var unlock_definition: Resource = vessel_for_unlock(upgrade_id)
if not orders.is_empty() or (unlock_definition == null and upgrade_maxed(upgrade_id)):
return false
orders.append({"id": upgrade_id, "elapsed": 0.0, "started": false})
lab.research_orders = orders
@ -817,23 +772,25 @@ func lab_research_order(lab_id: int) -> Dictionary:
func _advance_lab_research(delta: float) -> void:
for lab in buildings:
if lab.type != BUILDING_SHIP_LAB:
if not building_has_capability(lab, "vessel_research"):
continue
var orders: Array = lab.get("research_orders", [])
if orders.is_empty():
continue
var order: Dictionary = orders[0]
var id: String = String(order.id)
var cost: int = UNLOCK_SHIELD_COST if id == UNLOCK_SHIELD_VESSEL else upgrade_cost(id)
var unlock_definition: Resource = vessel_for_unlock(id)
var cost: int = unlock_definition.unlock_cost if unlock_definition != null else upgrade_cost(id)
var inventory: RefCounted = _inventory_for_record(lab)
if not bool(order.started):
if int(lab.get("stock", 0)) < cost:
if inventory.amount(RESOURCE_ORE) < cost:
continue
lab.stock = int(lab.stock) - cost
inventory.withdraw(RESOURCE_ORE, cost)
order.started = true
order.elapsed = float(order.elapsed) + delta
if float(order.elapsed) < lab_research_time():
continue
if id == UNLOCK_SHIELD_VESSEL:
if unlock_definition != null:
vessel_levels[id] = 1
else:
vessel_levels[id] = upgrade_level(id) + 1
@ -862,6 +819,13 @@ func has_mothership() -> bool:
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:
var kept: Array = []
var removed: int = 0
@ -878,7 +842,7 @@ func get_mothership_position() -> Variant:
# 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.
for b in buildings:
if b.type == BUILDING_MOTHERSHIP:
if building_has_capability(b, "bank"):
return b.position
return null
@ -917,7 +881,7 @@ func rotate_formation(radians: float) -> void:
# Extractors mine into their OWN local stock. They do not own a magical
# hauler: ore becomes spendable only when a hub-stationed transporter picks
# up a full load and physically delivers it to the mothership. Entries are
# {"position", "planet_name", "resource_type", "stock", "mining_progress"}.
# {"position", "planet_name", "resource_type", "inventory", "mining_progress"}.
const EXTRACTOR_RATE: float = 2.0 # ore per second
var extractors: Array = []
@ -938,7 +902,7 @@ func add_extractor(planet_name: String, at_position: Vector2, resource_type: Str
"position": at_position,
"planet_name": planet_name,
"resource_type": resource_type,
"stock": 0,
"inventory": INVENTORY.new(),
"mining_progress": 0.0,
})
# An extractor is built from SPACE, with the fleet parked on the planet -
@ -951,7 +915,21 @@ func add_extractor(planet_name: String, at_position: Vector2, resource_type: Str
func extractor_stock(index: int) -> int:
if index < 0 or index >= extractors.size():
return 0
return int(extractors[index].get("stock", 0))
var extractor: Dictionary = extractors[index]
return _extractor_inventory(extractor).amount(String(extractor.get("resource_type", RESOURCE_ORE)))
func _extractor_inventory(extractor: Dictionary) -> RefCounted:
var inventory: RefCounted = extractor.get("inventory") as RefCounted
if inventory == null:
inventory = INVENTORY.new()
extractor.inventory = inventory
# Old extractor state had one stock field whose material was described by
# resource_type. Absorb it into that key on first access.
if extractor.has("stock"):
inventory.set_amount(String(extractor.get("resource_type", RESOURCE_ORE)), int(extractor.stock))
extractor.erase("stock")
return inventory
func _process(delta: float) -> void:
@ -964,7 +942,7 @@ func _process(delta: float) -> void:
e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE
var whole: int = int(floor(float(e.mining_progress)))
if whole > 0:
e.stock = int(e.get("stock", 0)) + whole
_extractor_inventory(e).deposit(String(e.get("resource_type", RESOURCE_ORE)), whole)
e.mining_progress = float(e.mining_progress) - whole
_advance_transporters(delta)
@ -1024,7 +1002,7 @@ func hub_capacity() -> int:
func hub_count() -> int:
var n: int = 0
for b in buildings:
if b.type == BUILDING_TRANSPORT_HUB:
if building_has_capability(b, "transport_hub"):
n += 1
return n
@ -1039,7 +1017,7 @@ func pending_transporter_orders() -> int:
var n: int = 0
for b in buildings:
for o in b.get("orders", []):
if String(o.product) == PRODUCT_TRANSPORTER:
if vessel_has_capability(String(o.product), "transport"):
n += 1
return n
@ -1063,7 +1041,7 @@ func nearest_hub_to(at: Vector2) -> Dictionary:
var best: Dictionary = {}
var best_d: float = INF
for b in buildings:
if b.type != BUILDING_TRANSPORT_HUB:
if not building_has_capability(b, "transport_hub"):
continue
var d: float = at.distance_squared_to(b.position)
if d < best_d:
@ -1126,7 +1104,8 @@ func _dispatch_transporters() -> void:
var load: int = transporter_load_limit()
for i in range(extractors.size()):
var extractor: Dictionary = extractors[i]
if int(extractor.get("stock", 0)) < load or _transporter_inbound_to_extractor(i):
var resource_id: String = String(extractor.get("resource_type", RESOURCE_ORE))
if _extractor_inventory(extractor).amount(resource_id) < load or _transporter_inbound_to_extractor(i):
continue
var chosen_extractor: Dictionary = {}
var best_extractor_distance: float = INF
@ -1140,7 +1119,7 @@ func _dispatch_transporters() -> void:
if chosen_extractor.is_empty():
return
chosen_extractor.extractor_index = i
chosen_extractor.resource_type = String(extractor.get("resource_type", "ore"))
chosen_extractor.resource_type = resource_id
chosen_extractor.target_id = 0
_begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position,
TRANSPORTER_TO_EXTRACTOR)
@ -1191,13 +1170,13 @@ func _arrive(t: Dictionary) -> void:
TRANSPORTER_TO_MOTHERSHIP:
var target: Dictionary = find_building(int(t.target_id))
var want: int = outstanding_cost(int(t.target_id))
var take: int = mini(mini(want, rocks), transporter_load_limit())
var take: int = mini(mini(want, bank_inventory.amount(RESOURCE_ORE)), transporter_load_limit())
if take <= 0 or target.is_empty():
# Nothing to carry, or the building is gone. Go home empty.
t.target_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
return
rocks -= take
bank_inventory.withdraw(RESOURCE_ORE, take)
t.carrying = take
_begin_leg(t, here, target.position, TRANSPORTER_TO_BUILDING)
TRANSPORTER_TO_BUILDING:
@ -1205,7 +1184,7 @@ func _arrive(t: Dictionary) -> void:
if deposit_to_building(int(t.target_id), int(t.carrying)) <= 0:
# Building demolished in transit - put the ore back rather
# than quietly destroying it.
rocks += int(t.carrying)
bank_inventory.deposit(RESOURCE_ORE, int(t.carrying))
t.carrying = 0
t.target_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
@ -1216,20 +1195,19 @@ func _arrive(t: Dictionary) -> void:
return
var extractor: Dictionary = extractors[extractor_index]
var load: int = transporter_load_limit()
if int(extractor.get("stock", 0)) < load:
var resource_id: String = String(extractor.get("resource_type", RESOURCE_ORE))
var extractor_inventory: RefCounted = _extractor_inventory(extractor)
if extractor_inventory.amount(resource_id) < load:
t.extractor_index = -1
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
return
extractor.stock = int(extractor.stock) - load
extractor_inventory.withdraw(resource_id, load)
t.carrying = load
t.resource_type = String(extractor.get("resource_type", "ore"))
t.resource_type = resource_id
_begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR)
TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR:
# Bank only on arrival; cargo retains the extractor's resource type.
if String(t.get("resource_type", "ore")) == "gas":
gas += int(t.carrying)
else:
rocks += int(t.carrying)
bank_inventory.deposit(String(t.get("resource_type", RESOURCE_ORE)), int(t.carrying))
t.carrying = 0
t.extractor_index = -1
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
@ -1239,11 +1217,11 @@ func _arrive(t: Dictionary) -> void:
func can_afford(cost: int) -> bool:
return rocks >= cost
return bank_inventory.amount(RESOURCE_ORE) >= cost
func spend(cost: int) -> bool:
if not can_afford(cost):
return false
rocks -= cost
bank_inventory.withdraw(RESOURCE_ORE, cost)
return true

14
GasGiantDefinition.tres Normal file
View file

@ -0,0 +1,14 @@
[gd_resource type="Resource" script_class="PlanetDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://PlanetDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://GasGiantVisual.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "gas_giant"
display_name = "Gas giant"
names = PackedStringArray("Aurelia", "Boreas", "Cerulean Deep", "Helios Major", "Vesper")
visual_scene = ExtResource("2")
surface_profile_id = "gas"
resource_id = "gas"
map_color = Color(0.65, 0.42, 0.9, 1)

31
GasGiantVisual.gd Normal file
View file

@ -0,0 +1,31 @@
extends Node2D
var gas_phase: float = 0.0
func configure(_shape_seed: int) -> void:
queue_redraw()
func extractor_anchor() -> Vector2:
return Vector2(0.0, -118.0)
func _process(delta: float) -> void:
gas_phase += delta
queue_redraw()
func _draw() -> void:
var rim := PackedVector2Array()
for i in range(32):
var angle := TAU * float(i) / 32.0
rim.append(Vector2(cos(angle) * 118.0, sin(angle) * 118.0))
NeonDraw.draw_glowing_polygon(self, rim, Color(0.4, 0.78, 1.0), 0.9)
for row in [-62.0, -28.0, 8.0, 43.0, 73.0]:
var band := PackedVector2Array()
var half_width: float = sqrt(maxf(0.0, 118.0 * 118.0 - row * row))
for i in range(15):
var x := lerpf(-half_width, half_width, float(i) / 14.0)
band.append(Vector2(x, row + sin(x * 0.045 + gas_phase * 1.6 + row) * 7.0))
NeonDraw.draw_glowing_path(self, band, Color(0.85, 0.52, 1.0), 0.75, 0.65)

1
GasGiantVisual.gd.uid Normal file
View file

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

6
GasGiantVisual.tscn Normal file
View file

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

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="ResourceDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://ResourceDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://GasResourceGlyph.tscn" id="2"]
[resource]
script = ExtResource("1")
resource_id = "gas"
display_name = "Gas"
color = Color(0.85, 0.52, 1, 1)
glyph_scene = ExtResource("2")

19
GasResourceGlyph.gd Normal file
View file

@ -0,0 +1,19 @@
extends Control
@export var colour: Color = Color(0.85, 0.52, 1.0)
func _draw() -> void:
var centre: Vector2 = size * 0.5
var radius: float = minf(size.x, size.y) * 0.34
var bubbles: Array = [
[centre + Vector2(-radius * 0.38, radius * 0.12), radius * 0.46],
[centre + Vector2(radius * 0.34, radius * 0.26), radius * 0.58],
[centre + Vector2(radius * 0.38, -radius * 0.48), radius * 0.32],
]
for bubble in bubbles:
var ring := PackedVector2Array()
for i in range(12):
var angle: float = TAU * float(i) / 12.0
ring.append(bubble[0] + Vector2(cos(angle), sin(angle)) * float(bubble[1]))
NeonDraw.draw_glowing_polygon(self, ring, colour, 1.0, colour.darkened(0.78), maxf(0.5, radius * 0.035))

1
GasResourceGlyph.gd.uid Normal file
View file

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

13
GasResourceGlyph.tscn Normal file
View file

@ -0,0 +1,13 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://GasResourceGlyph.gd" id="1"]
[node name="GlyphVisual" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1")

7
GasSurfaceFeatures.tscn Normal file
View file

@ -0,0 +1,7 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://GasVentField.gd" id="1"]
[node name="ResourceProvider" type="Node2D"]
script = ExtResource("1")
fleet_path = NodePath("../../Fleet")

14
GasSurfaceProfile.tres Normal file
View file

@ -0,0 +1,14 @@
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=3 format=3]
[ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"]
[ext_resource type="PackedScene" path="res://GasSurfaceFeatures.tscn" id="2"]
[resource]
script = ExtResource("1")
profile_id = "gas"
features_scene = ExtResource("2")
base_color = Color(0.13, 0.025, 0.22, 1)
crater_color = Color(0.055, 0.008, 0.11, 1)
highlight_color = Color(0.52, 0.17, 0.7, 1)
caption_color = Color(0.78, 0.88, 0.94, 1)
caption_outline_color = Color(0.02, 0.01, 0.05, 0.9)

View file

@ -1,5 +1,6 @@
extends Node2D
@onready var fleet: Node2D = get_node("../Fleet")
@export var fleet_path: NodePath = NodePath("../Fleet")
@onready var fleet: Node2D = get_node(fleet_path)
var vents: Array[Vector2] = []
var progress: float = 0.0
var emission_phase: float = 0.0

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "int_damage"
display_name = "Heavy Slugs"
description = "+1 damage per shot."
base_cost = 30
max_level = 3
icon = 15
additive_modifiers = {"damage": 1.0}

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "int_range"
display_name = "Targeting Array"
description = "+60 firing range per level."
base_cost = 20
max_level = 3
icon = 17
additive_modifiers = {"fire_range": 60.0}

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "int_rate"
display_name = "Autoloader"
description = "Fires 15% faster per level."
base_cost = 25
max_level = 3
icon = 16
multiplicative_modifiers = {"fire_cooldown": 0.85}

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "int_speed"
display_name = "Thrusters"
description = "+40 top speed, +60 acceleration per level."
base_cost = 25
max_level = 3
icon = 18
additive_modifiers = {"max_speed": 40.0, "acceleration": 60.0}

View file

@ -0,0 +1,20 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=7 format=3]
[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://VesselWeaponComponent.tscn" id="2"]
[ext_resource type="Resource" path="res://InterceptorDamageUpgrade.tres" id="3"]
[ext_resource type="Resource" path="res://InterceptorRateUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://InterceptorRangeUpgrade.tres" id="5"]
[ext_resource type="Resource" path="res://InterceptorSpeedUpgrade.tres" id="6"]
[resource]
script = ExtResource("1")
definition_id = "interceptor"
display_name = "Interceptor"
description = "Fast combat vessel with a rapid-firing cannon."
build_cost = 6
build_time = 10.0
icon = 2
glow_color = Color(0.25, 0.85, 1, 1)
capabilities = PackedStringArray("weapon")
component_scenes = Array[PackedScene]([ExtResource("2")])
base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 450.0}
upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5"), ExtResource("6")])

79
Inventory.gd Normal file
View file

@ -0,0 +1,79 @@
class_name Inventory
extends RefCounted
var total_capacity: int = -1
var resource_capacities: Dictionary = {}
var _amounts: Dictionary = {}
func _init(initial: Dictionary = {}, capacity: int = -1) -> void:
total_capacity = capacity
for resource_id in initial:
set_amount(String(resource_id), int(initial[resource_id]))
func amount(resource_id: String) -> int:
return int(_amounts.get(resource_id, 0))
func total() -> int:
var result: int = 0
for value in _amounts.values():
result += int(value)
return result
func capacity_for(resource_id: String) -> int:
return int(resource_capacities.get(resource_id, -1))
func space_for(resource_id: String) -> int:
var space: int = 1 << 30
if total_capacity >= 0:
space = maxi(total_capacity - total(), 0)
var resource_capacity: int = capacity_for(resource_id)
if resource_capacity >= 0:
space = mini(space, maxi(resource_capacity - amount(resource_id), 0))
return space
func set_amount(resource_id: String, value: int) -> int:
_amounts.erase(resource_id)
var accepted: int = mini(maxi(value, 0), space_for(resource_id))
if accepted > 0:
_amounts[resource_id] = accepted
return amount(resource_id)
func deposit(resource_id: String, requested: int) -> int:
var moved: int = mini(maxi(requested, 0), space_for(resource_id))
if moved > 0:
_amounts[resource_id] = amount(resource_id) + moved
return moved
func withdraw(resource_id: String, requested: int) -> int:
var moved: int = mini(maxi(requested, 0), amount(resource_id))
if moved <= 0:
return 0
var remaining: int = amount(resource_id) - moved
if remaining > 0:
_amounts[resource_id] = remaining
else:
_amounts.erase(resource_id)
return moved
func transfer_to(destination: Inventory, resource_id: String, requested: int) -> int:
if destination == null:
return 0
var moved: int = mini(mini(maxi(requested, 0), amount(resource_id)), destination.space_for(resource_id))
if moved <= 0:
return 0
withdraw(resource_id, moved)
destination.deposit(resource_id, moved)
return moved
func snapshot() -> Dictionary:
return _amounts.duplicate()

1
Inventory.gd.uid Normal file
View file

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

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,11 @@
[gd_resource type="Resource" script_class="ResourceDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://ResourceDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://OreResourceGlyph.tscn" id="2"]
[resource]
script = ExtResource("1")
resource_id = "ore"
display_name = "Ore"
color = Color(0, 0.72, 0.82, 1)
glyph_scene = ExtResource("2")

14
OreResourceGlyph.gd Normal file
View file

@ -0,0 +1,14 @@
extends Control
@export var colour: Color = Color(0.0, 0.72, 0.82)
func _draw() -> void:
var centre: Vector2 = size * 0.5
var radius: float = minf(size.x, size.y) * 0.34
var points := PackedVector2Array()
for i in range(6):
var angle: float = TAU * float(i) / 6.0 - PI * 0.5
points.append(centre + Vector2(cos(angle), sin(angle)) * radius)
NeonDraw.draw_glowing_polygon(self, points, colour, 1.0, colour.darkened(0.78), maxf(0.5, radius * 0.035))
NeonDraw.draw_glowing_line(self, points[5], points[2], colour, 0.55, maxf(0.4, radius * 0.025), colour.darkened(0.78))

1
OreResourceGlyph.gd.uid Normal file
View file

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

13
OreResourceGlyph.tscn Normal file
View file

@ -0,0 +1,13 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://OreResourceGlyph.gd" id="1"]
[node name="GlyphVisual" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1")

View file

@ -1,5 +1,8 @@
extends Node2D
const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd")
# A landable world-object - unlike regular Asteroid.gd, planets are
# stationary ("Big Stable Asteroid" - stable means it doesn't drift or
# destroy ships on touch). Whether the fleet counts as "landed" is decided
@ -11,15 +14,33 @@ extends Node2D
# while the fleet as a whole was clearly sitting on the planet.
@export var planet_name: String = ""
@export var planet_type: String = "rocky"
@export var resource_type: String = "ore"
@export var definition_id: String = PLANET_DEFINITIONS.DEFAULT_DEFINITION_ID
@export var landing_radius: float = 140.0
# Set by whoever spawns this planet (PlanetField, seeded from the chunk's
# own coordinates) so the outline shape is stable across respawns of the
# same chunk, instead of reading as a "different" planet every time.
@export var shape_seed: int = 0
var shape_points: PackedVector2Array = PackedVector2Array()
var definition: Resource
var visual: Node2D
var resource_type: String:
get: return String(definition.resource_id) if definition != null else "ore"
var surface_profile_id: String:
get: return String(definition.surface_profile_id) if definition != null else "rocky"
var shape_points: PackedVector2Array:
get:
if visual != null and "shape_points" in visual:
return visual.shape_points
return PackedVector2Array()
var gas_phase: float:
get:
if visual != null and "gas_phase" in visual:
return float(visual.gas_phase)
return 0.0
# An extractor is built down on this planet's SURFACE, in a different scene
# the player is no longer looking at, so from space nothing distinguished a
@ -37,31 +58,20 @@ const DERRICK_HALF_WIDTH: float = 16.0
# only at _ready() left the planet looking untouched until it happened to
# despawn and respawn.
var shows_extractor: bool = false
var gas_phase: float = 0.0
func _ready() -> void:
add_to_group("planets")
z_index = -1
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
rng.seed = shape_seed
var point_count: int = 10
for i in range(point_count):
var angle: float = (float(i) / point_count) * TAU
var radius: float = rng.randf_range(70.0, 100.0)
shape_points.append(Vector2(cos(angle), sin(angle)) * radius)
definition = PLANET_DEFINITIONS.get_definition(definition_id)
visual = definition.visual_scene.instantiate()
visual.configure(shape_seed)
get_node("VisualHost").add_child(visual)
shows_extractor = GameState.has_extractor_at(planet_name)
GameState.extractor_added.connect(_on_extractor_added)
queue_redraw()
func _process(delta: float) -> void:
if planet_type == "gas_giant":
gas_phase += delta
queue_redraw()
func _on_extractor_added(built_on: String) -> void:
# Only the planet it was actually built on, and only a redraw when the
# answer really changed - every other planet in range gets this too.
@ -72,14 +82,9 @@ func _on_extractor_added(built_on: String) -> void:
func extractor_derrick_anchor() -> Vector2:
# The rim vertex the derrick stands on: the shape's own topmost point,
# rather than a fixed radius, so it sits ON the outline whatever lumpy
# shape the seed drew instead of floating off it or sinking inside it.
var base: Vector2 = Vector2(0.0, -100.0)
for p in shape_points:
if p.y < base.y:
base = p
return base
if visual != null and visual.has_method("extractor_anchor"):
return visual.extractor_anchor()
return Vector2(0.0, -100.0)
func extractor_derrick_tip() -> Vector2:
@ -111,28 +116,5 @@ func extractor_derrick_points() -> PackedVector2Array:
func _draw() -> void:
if planet_type == "gas_giant":
_draw_gas_giant()
if shows_extractor:
NeonDraw.draw_glowing_polygon(self, extractor_derrick_points(), EXTRACTOR_MARKER_COLOR, 1.0)
return
NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.6, 0.55, 0.5), 1.0)
if shows_extractor:
NeonDraw.draw_glowing_polygon(self, extractor_derrick_points(), EXTRACTOR_MARKER_COLOR, 1.0)
func _draw_gas_giant() -> void:
# A round, animated giant with drifting latitudinal bands. It is visibly
# unlike the rocky outline and keeps moving even while the fleet is idle.
var rim := PackedVector2Array()
for i in range(32):
var a := TAU * float(i) / 32.0
rim.append(Vector2(cos(a) * 118.0, sin(a) * 118.0))
NeonDraw.draw_glowing_polygon(self, rim, Color(0.4, 0.78, 1.0), 0.9)
for row in [-62.0, -28.0, 8.0, 43.0, 73.0]:
var band := PackedVector2Array()
var half_width: float = sqrt(maxf(0.0, 118.0 * 118.0 - row * row))
for i in range(15):
var x := lerpf(-half_width, half_width, float(i) / 14.0)
band.append(Vector2(x, row + sin(x * 0.045 + gas_phase * 1.6 + row) * 7.0))
NeonDraw.draw_glowing_path(self, band, Color(0.85, 0.52, 1.0), 0.75, 0.65)

View file

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

View file

@ -113,7 +113,7 @@ func _on_enter_pressed() -> void:
GameState.return_world_position = current_planet.position
GameState.return_camera_zoom = camera.zoom.x
GameState.planet_name = current_planet.planet_name
GameState.planet_type = current_planet.planet_type
GameState.planet_definition_id = current_planet.definition_id
var tween: Tween = create_tween()
tween.tween_property(fade_rect, "color:a", 1.0, fade_duration)

10
PlanetDefinition.gd Normal file
View file

@ -0,0 +1,10 @@
class_name PlanetDefinition
extends Resource
@export var definition_id: String = "rocky"
@export var display_name: String = "Rocky planet"
@export var names: PackedStringArray = []
@export var visual_scene: PackedScene
@export var surface_profile_id: String = "rocky"
@export var resource_id: String = "ore"
@export var map_color: Color = Color(0.7, 0.65, 0.55)

1
PlanetDefinition.gd.uid Normal file
View file

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

14
PlanetDefinitions.gd Normal file
View file

@ -0,0 +1,14 @@
class_name PlanetDefinitions
extends RefCounted
const ROCKY_ID: String = "rocky"
const GAS_GIANT_ID: String = "gas_giant"
const DEFAULT_DEFINITION_ID: String = ROCKY_ID
const DEFINITIONS: Dictionary = {
ROCKY_ID: preload("res://RockyPlanetDefinition.tres"),
GAS_GIANT_ID: preload("res://GasGiantDefinition.tres"),
}
static func get_definition(definition_id: String) -> Resource:
return DEFINITIONS.get(definition_id, DEFINITIONS[DEFAULT_DEFINITION_ID])

1
PlanetDefinitions.gd.uid Normal file
View file

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

View file

@ -1,5 +1,6 @@
extends Node2D
# Spawns "Big Stable Asteroid" planets in ASTEROID_FIELD biome chunks near
# the fleet where WorldGen says one belongs, and despawns them once the
# fleet has moved far enough away - mirrors AsteroidField's chunk-lifecycle
@ -83,8 +84,7 @@ func _spawn_chunk_planet(chunk: Vector2i) -> void:
planet.position = origin + data.local_pos
planet.planet_name = data.name
planet.shape_seed = data.shape_seed
planet.planet_type = String(data.get("planet_type", "rocky"))
planet.resource_type = String(data.get("resource_type", "ore"))
planet.definition_id = String(data.get("definition_id", "rocky"))
# Deferred: update_chunks() can run synchronously from inside a
# parent's own _ready() chain (forced once right after the fleet is
# positioned, to avoid a pop-in on scene load) - Godot refuses a

View file

@ -7,9 +7,7 @@
[ext_resource type="Script" path="res://PlanetSurfaceController.gd" id="5"]
[ext_resource type="Script" path="res://IconButton.gd" id="6"]
[ext_resource type="Script" path="res://TouchJoystick.gd" id="7"]
[ext_resource type="Script" path="res://RockField.gd" id="8"]
[ext_resource type="Script" path="res://ResourceCounter.gd" id="9"]
[ext_resource type="Script" path="res://GasVentField.gd" id="10"]
[ext_resource type="Script" path="res://GasCounter.gd" id="11"]
[ext_resource type="Script" path="res://CargoCounter.gd" id="12"]
[ext_resource type="Script" path="res://GasCargoCounter.gd" id="13"]
@ -49,12 +47,7 @@ ship_core_color = Color(0.02, 0.09, 0.15, 1)
position = Vector2(0, 0)
zoom = Vector2(1, 1)
[node name="RockField" type="Node2D" parent="."]
script = ExtResource("8")
[node name="GasVentField" type="Node2D" parent="."]
visible = false
script = ExtResource("10")
[node name="SurfaceFeatures" type="Node2D" parent="."]
[node name="SurfaceController" type="Node2D" parent="."]
script = ExtResource("5")

View file

@ -1,5 +1,8 @@
extends Node2D
const SURFACE_PROFILES = preload("res://PlanetSurfaceProfiles.gd")
const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd")
# Bootstraps the surviving fleet on this scene's own startup (from
# GameState, since this is a fresh scene tree - nothing here carries
# over automatically) and handles the always-available exit prompt that
@ -13,29 +16,15 @@ extends Node2D
@onready var planet_name_label: Label = get_node("../UI/PlanetNameLabel")
@onready var exit_button: IconButton = get_node("../UI/ExitButton")
@onready var fade_rect: ColorRect = get_node("../FadeOverlay/FadeRect")
@onready var background: ColorRect = get_node("../BackgroundLayer/LunarBackground")
@onready var feature_host: Node2D = get_node("../SurfaceFeatures")
var transitioning: bool = false
func _ready() -> void:
if GameState.planet_type == "gas_giant":
# A gas giant is not lunar terrain: its surface view uses a deep,
# coloured atmosphere instead of the rocky field. The background is
# shader-driven, so changing ColorRect.color does not tint it; update
# the actual palette uniforms.
get_node("../RockField").visible = false
get_node("../GasVentField").visible = true
var background: ColorRect = get_node("../BackgroundLayer/LunarBackground")
var atmosphere: ShaderMaterial = background.material
atmosphere.set_shader_parameter("base_color", Color(0.13, 0.025, 0.22, 1.0))
atmosphere.set_shader_parameter("crater_color", Color(0.055, 0.008, 0.11, 1.0))
atmosphere.set_shader_parameter("highlight_color", Color(0.52, 0.17, 0.7, 1.0))
# The lunar surface uses dark captions on a bright ground. Flip only
# those captions for the night-dark gas atmosphere.
for caption_path in ["../UI/BankCaption", "../UI/FleetCaption"]:
var caption: Label = get_node(caption_path)
caption.add_theme_color_override("font_color", Color(0.78, 0.88, 0.94))
caption.add_theme_color_override("font_outline_color", Color(0.02, 0.01, 0.05, 0.9))
var planet_definition: Resource = PLANET_DEFINITIONS.get_definition(GameState.planet_definition_id)
_compose_surface(SURFACE_PROFILES.get_profile(planet_definition.surface_profile_id))
fleet.spawn_ships(GameState.surviving_ship_count, Vector2.ZERO)
camera.position = Vector2.ZERO
camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom)
@ -47,6 +36,21 @@ func _ready() -> void:
tween.tween_property(fade_rect, "color:a", 0.0, fade_duration)
func _compose_surface(profile: Resource) -> void:
var atmosphere: ShaderMaterial = background.material
atmosphere.set_shader_parameter("base_color", profile.base_color)
atmosphere.set_shader_parameter("crater_color", profile.crater_color)
atmosphere.set_shader_parameter("highlight_color", profile.highlight_color)
for caption_path in ["../UI/BankCaption", "../UI/FleetCaption"]:
var caption: Label = get_node(caption_path)
caption.add_theme_color_override("font_color", profile.caption_color)
caption.add_theme_color_override("font_outline_color", profile.caption_outline_color)
if profile.features_scene == null:
return
var features: Node = profile.features_scene.instantiate()
feature_host.add_child(features)
func _on_exit_pressed() -> void:
if transitioning:
return

13
PlanetSurfaceProfile.gd Normal file
View file

@ -0,0 +1,13 @@
class_name PlanetSurfaceProfile
extends Resource
# Declarative recipe for a landed planet surface. The host scene only knows
# how to apply one of these; the feature scene owns the actual resource nodes.
@export var profile_id: String = "rocky"
@export var features_scene: PackedScene
@export var base_color: Color = Color(0.75, 0.75, 0.76, 1.0)
@export var crater_color: Color = Color(0.44, 0.44, 0.45, 1.0)
@export var highlight_color: Color = Color(0.89, 0.89, 0.9, 1.0)
@export var caption_color: Color = Color(0.08, 0.16, 0.2, 1.0)
@export var caption_outline_color: Color = Color(0.9, 0.95, 0.96, 0.8)

View file

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

12
PlanetSurfaceProfiles.gd Normal file
View file

@ -0,0 +1,12 @@
class_name PlanetSurfaceProfiles
extends RefCounted
const DEFAULT_PROFILE_ID: String = "rocky"
const PROFILES: Dictionary = {
"rocky": preload("res://RockySurfaceProfile.tres"),
"gas": preload("res://GasSurfaceProfile.tres"),
}
static func get_profile(profile_id: String) -> Resource:
return PROFILES.get(profile_id, PROFILES[DEFAULT_PROFILE_ID])

View file

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

View file

@ -3,7 +3,7 @@ extends Control
# Compact resource table shared by crafting, research and the ship lab.
# Rows are resources; columns are locations. Adding another material to
# GameState.RESOURCE_TYPES automatically gives it a row here.
# The resource-definition catalog automatically gives each material a row.
var building_id: int = 0
@ -24,7 +24,7 @@ func _ready() -> void:
add_child(label)
_header_labels.append(label)
for resource_type in GameState.RESOURCE_TYPES:
for resource_type in GameState.resource_types():
var glyph := ResourceGlyph.new()
glyph.resource_type = resource_type
add_child(glyph)
@ -78,7 +78,7 @@ func _layout() -> void:
var row_height: float = (size.y - HEADER_HEIGHT - 4.0) / maxf(1.0, float(_rows.size()))
var row_index: int = 0
for resource_type in GameState.RESOURCE_TYPES:
for resource_type in GameState.resource_types():
var row: Dictionary = _rows[resource_type]
var y: float = HEADER_HEIGHT + float(row_index) * row_height
row.glyph.position = Vector2(2.0, y + 2.0)
@ -94,7 +94,7 @@ func _layout() -> void:
func _process(_delta: float) -> void:
for resource_type in GameState.RESOURCE_TYPES:
for resource_type in GameState.resource_types():
var row: Dictionary = _rows.get(resource_type, {})
if row.is_empty():
continue

7
ResourceDefinition.gd Normal file
View file

@ -0,0 +1,7 @@
class_name ResourceDefinition
extends Resource
@export var resource_id: String = "ore"
@export var display_name: String = "Ore"
@export var color: Color = Color.WHITE
@export var glyph_scene: PackedScene

View file

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

18
ResourceDefinitions.gd Normal file
View file

@ -0,0 +1,18 @@
class_name ResourceDefinitions
extends RefCounted
const ORE_ID: String = "ore"
const GAS_ID: String = "gas"
const ORDER: PackedStringArray = [ORE_ID, GAS_ID]
const DEFINITIONS: Dictionary = {
ORE_ID: preload("res://OreResourceDefinition.tres"),
GAS_ID: preload("res://GasResourceDefinition.tres"),
}
static func get_definition(resource_id: String) -> Resource:
return DEFINITIONS.get(resource_id)
static func ids() -> PackedStringArray:
return ORDER

View file

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

View file

@ -1,6 +1,8 @@
class_name ResourceGlyph
extends Control
const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd")
# A small, non-interactive resource mark shared by tables and transfer rows.
# Resource buttons used to borrow IconButton, which made decorative icons
# look tappable and encouraged each screen to invent its own version.
@ -12,43 +14,21 @@ func set_resource_type(value: String) -> void:
if resource_type == value:
return
resource_type = value
queue_redraw()
if is_node_ready():
_rebuild_visual()
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
queue_redraw()
_rebuild_visual()
func _draw() -> void:
var colour: Color = GameState.resource_color(resource_type)
var centre: Vector2 = size * 0.5
var radius: float = minf(size.x, size.y) * 0.34
match resource_type:
GameState.RESOURCE_GAS:
_draw_gas(centre, radius, colour)
_:
_draw_ore(centre, radius, colour)
func _draw_ore(centre: Vector2, radius: float, colour: Color) -> void:
var points := PackedVector2Array()
for i in range(6):
var angle: float = TAU * float(i) / 6.0 - PI * 0.5
points.append(centre + Vector2(cos(angle), sin(angle)) * radius)
NeonDraw.draw_glowing_polygon(self, points, colour, 1.0, colour.darkened(0.78), maxf(0.5, radius * 0.035))
NeonDraw.draw_glowing_line(self, points[5], points[2], colour, 0.55, maxf(0.4, radius * 0.025), colour.darkened(0.78))
func _draw_gas(centre: Vector2, radius: float, colour: Color) -> void:
var bubbles: Array = [
[centre + Vector2(-radius * 0.38, radius * 0.12), radius * 0.46],
[centre + Vector2(radius * 0.34, radius * 0.26), radius * 0.58],
[centre + Vector2(radius * 0.38, -radius * 0.48), radius * 0.32],
]
for bubble in bubbles:
var ring := PackedVector2Array()
for i in range(12):
var angle: float = TAU * float(i) / 12.0
ring.append(bubble[0] + Vector2(cos(angle), sin(angle)) * float(bubble[1]))
NeonDraw.draw_glowing_polygon(self, ring, colour, 1.0, colour.darkened(0.78), maxf(0.5, radius * 0.035))
func _rebuild_visual() -> void:
for child in get_children():
child.queue_free()
var definition: Resource = RESOURCE_DEFINITIONS.get_definition(resource_type)
if definition == null or definition.glyph_scene == null:
return
var visual: Control = definition.glyph_scene.instantiate()
visual.colour = definition.color
add_child(visual)

View file

@ -27,7 +27,7 @@ func _ready() -> void:
_destination_heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
add_child(_destination_heading)
for resource_type in GameState.RESOURCE_TYPES:
for resource_type in GameState.resource_types():
var colour: Color = GameState.resource_color(resource_type)
var glyph := ResourceGlyph.new()
glyph.resource_type = resource_type
@ -140,7 +140,7 @@ func set_destination(id: int, heading: String) -> void:
func refresh() -> void:
for resource_type in GameState.RESOURCE_TYPES:
for resource_type in GameState.resource_types():
var row: Dictionary = _rows.get(resource_type, {})
if row.is_empty():
continue
@ -169,7 +169,7 @@ func _layout() -> void:
var row_height: float = (size.y - HEADER_HEIGHT) / maxf(1.0, float(_rows.size()))
var row_index: int = 0
for resource_type in GameState.RESOURCE_TYPES:
for resource_type in GameState.resource_types():
var row: Dictionary = _rows[resource_type]
var y: float = HEADER_HEIGHT + float(row_index) * row_height
var glyph_size: float = minf(66.0, row_height * 0.23)

View file

@ -17,7 +17,8 @@ extends Node2D
@export var despawn_radius: float = 2600.0
@export var check_interval: float = 0.5
@onready var fleet: Node2D = get_node("../Fleet")
@export var fleet_path: NodePath = NodePath("../Fleet")
@onready var fleet: Node2D = get_node(fleet_path)
var rock_scene: PackedScene = preload("res://Rock.tscn")
var rocks: Array = []
@ -26,12 +27,6 @@ var rng: RandomNumberGenerator = RandomNumberGenerator.new()
func _ready() -> void:
# Gas giants have their own gas vents and must never receive ore pickups.
# This check happens before the initial population pass, unlike merely
# hiding the field later from PlanetSurfaceController.
if GameState.planet_type == "gas_giant":
set_process(false)
return
rng.randomize()
# Populate immediately rather than waiting out the first interval, so
# the surface isn't visibly empty for half a second on arrival.

View file

@ -0,0 +1,14 @@
[gd_resource type="Resource" script_class="PlanetDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://PlanetDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://RockyPlanetVisual.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "rocky"
display_name = "Rocky planet"
names = PackedStringArray("Kepler's Rest", "Ceres Minor", "Vesta's Shadow", "Ironhold", "Drift Anchor", "Stonefall", "Grey Watch", "Silent Quarry", "Baros Rock", "Umbra Point")
visual_scene = ExtResource("2")
surface_profile_id = "rocky"
resource_id = "ore"
map_color = Color(0.7, 0.65, 0.55, 1)

26
RockyPlanetVisual.gd Normal file
View file

@ -0,0 +1,26 @@
extends Node2D
var shape_points: PackedVector2Array = PackedVector2Array()
func configure(shape_seed: int) -> void:
shape_points.clear()
var rng := RandomNumberGenerator.new()
rng.seed = shape_seed
for i in range(10):
var angle: float = (float(i) / 10.0) * TAU
var radius: float = rng.randf_range(70.0, 100.0)
shape_points.append(Vector2(cos(angle), sin(angle)) * radius)
queue_redraw()
func extractor_anchor() -> Vector2:
var anchor := Vector2(0.0, -100.0)
for point in shape_points:
if point.y < anchor.y:
anchor = point
return anchor
func _draw() -> void:
NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.6, 0.55, 0.5), 1.0)

1
RockyPlanetVisual.gd.uid Normal file
View file

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

6
RockyPlanetVisual.tscn Normal file
View file

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

View file

@ -0,0 +1,7 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://RockField.gd" id="1"]
[node name="ResourceProvider" type="Node2D"]
script = ExtResource("1")
fleet_path = NodePath("../../Fleet")

14
RockySurfaceProfile.tres Normal file
View file

@ -0,0 +1,14 @@
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=3 format=3]
[ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"]
[ext_resource type="PackedScene" path="res://RockySurfaceFeatures.tscn" id="2"]
[resource]
script = ExtResource("1")
profile_id = "rocky"
features_scene = ExtResource("2")
base_color = Color(0.75, 0.75, 0.76, 1)
crater_color = Color(0.44, 0.44, 0.45, 1)
highlight_color = Color(0.89, 0.89, 0.9, 1)
caption_color = Color(0.08, 0.16, 0.2, 1)
caption_outline_color = Color(0.9, 0.95, 0.96, 0.8)

11
ShieldHitsUpgrade.tres Normal file
View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "shd_hits"
display_name = "Layered Emitters"
description = "+1 hit absorbed before the shield drops."
base_cost = 60
max_level = 2
icon = 19
additive_modifiers = {"shield_charges": 1.0}

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "shd_recharge"
display_name = "Fast Cycling"
description = "Shield returns 1.2s sooner per level."
base_cost = 35
max_level = 3
icon = 20
additive_modifiers = {"shield_recharge": -1.2}

11
ShieldSpeedUpgrade.tres Normal file
View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "shd_speed"
display_name = "Thrusters"
description = "+40 top speed, +60 acceleration per level."
base_cost = 25
max_level = 3
icon = 18
additive_modifiers = {"max_speed": 40.0, "acceleration": 60.0}

View file

@ -0,0 +1,25 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=7 format=3]
[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://VesselWeaponComponent.tscn" id="2"]
[ext_resource type="PackedScene" path="res://VesselShieldComponent.tscn" id="3"]
[ext_resource type="Resource" path="res://ShieldHitsUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://ShieldRechargeUpgrade.tres" id="5"]
[ext_resource type="Resource" path="res://ShieldSpeedUpgrade.tres" id="6"]
[resource]
script = ExtResource("1")
definition_id = "shield"
display_name = "Shield Vessel"
description = "Combat vessel whose rechargeable personal shield absorbs lethal impacts."
build_cost = 14
build_time = 16.0
icon = 2
glow_color = Color(0.45, 0.8, 1, 1)
capabilities = PackedStringArray("weapon", "shield")
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")])
base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 450.0, "shield_charges": 1.0, "shield_recharge": 6.0}
minimum_stats = {"shield_recharge": 2.0}
upgrades = Array[Resource]([ExtResource("4"), ExtResource("5"), ExtResource("6")])
unlock_id = "unlock_shield"
unlock_name = "Unlock Shield Vessel"
unlock_description = "Lets the ship crafter build shield vessels. Its shield absorbs one lethal hit, then cycles back up."
unlock_cost = 45

234
Ship.gd
View file

@ -2,224 +2,134 @@ extends Area2D
signal destroyed
# Deliberately faster than Fleet.cruise_speed: the fleet as a whole travels
# at the cruise rate, and this headroom is what lets an individual ship
# close on its formation slot - so a pivot mid-flight resolves quickly
# instead of the ship having no speed budget left over for it.
const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
@export var max_speed: float = 460.0
@export var acceleration: float = 450.0
@export var rotation_speed: float = 8.0
@export var arrive_radius: float = 6.0
@export var fire_range: float = 320.0
@export var fire_cooldown: float = 0.5
# How long to wait before looking for targets again after finding none.
@export var idle_rescan_interval: float = 0.25
@export var projectile_damage: int = 1
# Which kind of vessel this is (GameState.VESSEL_*). Drives both its
# researched statistics and whether it carries a shield (#5).
@export var vessel_type: String = "interceptor"
# --- shield (#5) ----------------------------------------------------------
const SHIELD_COLOR: Color = Color(0.45, 0.8, 1.0)
const SHIELD_RADIUS: float = 30.0
# A shield vessel survives a hit that would destroy any other ship, and is
# then defenceless until it cycles back up. Personal, not a bubble: it
# protects the vessel carrying it and nothing else.
var shield_charges: int = 0
var shield_recharge_left: float = 0.0
var _shield_ring: Node2D = null
# Neon colours, assigned by Fleet on spawn so a scene can tint its fleet.
# The lunar surface is a bright grey, where the default near-white core all
# but vanishes - PlanetSurface.tscn passes a dark core there. See NeonDraw.
var definition: Resource
var glow_color: Color = Color(0.25, 0.85, 1.0)
var core_color: Color = Color(1.0, 1.0, 1.0)
var core_color: Color = Color.WHITE
var velocity: Vector2 = Vector2.ZERO
# The fleet's own velocity, supplied by Fleet as CONTEXT rather than as a
# facing command - the ship works out where to point from it.
#
# It is needed because `velocity` below is not the ship's true motion while
# it is holding formation: the slot it chases sits inside arrive_radius, so
# the ship snaps onto it and reports zero velocity even though it is being
# carried along at cruise speed. Facing derived from `velocity` alone
# therefore froze at whatever heading the ship last happened to travel.
# Adding the two gives the ship's ACTUAL motion through the world, which is
# the honest thing to point along in every case: it equals the fleet's
# heading when station-keeping, the ship's own heading when manoeuvring
# alone, and the true blend of both while strafing into a new slot.
var fleet_velocity: Vector2 = Vector2.ZERO
var move_target: Vector2
var fire_timer: float = 0.0
var projectile_scene: PackedScene = preload("res://Projectile.tscn")
# Compatibility accessors keep old saves/tests readable while shield state
# itself belongs exclusively to the optional ShieldComponent.
var shield_charges: int:
get:
var component: Node = get_component("shield")
return component.charges if component != null else 0
set(value):
var component: Node = get_component("shield")
if component != null:
component.set_charges(value)
var shield_recharge_left: float:
get:
var component: Node = get_component("shield")
return component.recharge_left if component != null else 0.0
set(value):
var component: Node = get_component("shield")
if component != null:
component.recharge_left = value
var projectile_damage: int:
get:
var component: Node = get_component("weapon")
return component.projectile_damage if component != null else 0
func _ready() -> void:
move_target = position
add_to_group("player_ships")
area_entered.connect(_on_area_entered)
if vessel_type == GameState.VESSEL_SHIELD:
_build_shield_ring()
shield_charges = GameState.shield_max_charges()
definition = VESSEL_DEFINITIONS.get_definition(vessel_type)
if definition == null:
return
glow_color = definition.glow_color
for component_scene in definition.component_scenes:
var component: Node = component_scene.instantiate()
add_child(component)
if component.has_method("setup"):
component.setup()
apply_vessel_stats()
queue_redraw()
func get_component(capability: String) -> Node:
for child in get_children():
if child.get("capability_id") == capability:
return child
return null
func has_capability(capability: String) -> bool:
return get_component(capability) != null
func set_move_target(world_pos: Vector2) -> void:
move_target = world_pos
func apply_vessel_stats() -> void:
# Read from GameState rather than baked in at spawn, so researching an
# upgrade improves the ships already flying and not just the next one
# built. Fleet calls this on every vessel when something is bought.
max_speed = GameState.ship_max_speed(vessel_type)
acceleration = GameState.ship_acceleration(vessel_type)
fire_range = GameState.ship_fire_range(vessel_type)
fire_cooldown = GameState.ship_fire_cooldown(vessel_type)
projectile_damage = GameState.ship_damage(vessel_type)
if vessel_type == GameState.VESSEL_SHIELD:
# Topping up a live shield with researched capacity, without
# refilling one that is currently down and cycling.
var was_up: bool = shield_charges > 0
shield_charges = GameState.shield_max_charges() if was_up else shield_charges
_update_shield_visual()
if definition == null:
definition = VESSEL_DEFINITIONS.get_definition(vessel_type)
if definition == null:
return
max_speed = GameState.vessel_stat(vessel_type, "max_speed")
acceleration = GameState.vessel_stat(vessel_type, "acceleration")
for child in get_children():
if child.has_method("apply_stats"):
child.apply_stats()
func has_shield() -> bool:
return vessel_type == GameState.VESSEL_SHIELD and shield_charges > 0
func _update_shield_visual() -> void:
if _shield_ring == null:
return
# visible only - per #10 the ring is rasterised once and never redrawn to
# turn it on and off.
_shield_ring.visible = has_shield()
func _tick_shield(delta: float) -> void:
if vessel_type != GameState.VESSEL_SHIELD:
return
if shield_charges >= GameState.shield_max_charges():
return
shield_recharge_left -= delta
if shield_recharge_left <= 0.0:
shield_charges += 1
shield_recharge_left = GameState.shield_recharge_time()
_update_shield_visual()
var component: Node = get_component("shield")
return component != null and component.is_active()
func _physics_process(delta: float) -> void:
_tick_shield(delta)
for child in get_children():
if child.has_method("tick"):
child.tick(delta)
var to_target: Vector2 = move_target - position
var distance: float = to_target.length()
if distance <= arrive_radius:
# Snap and stop dead - prevents residual float-level jitter that
# would otherwise keep nudging back and forth around the target.
position = move_target
velocity = Vector2.ZERO
else:
# Braking-distance formula: the fastest speed we can be going right
# now and still be able to decelerate to 0 exactly at the target,
# given our acceleration. Without this the ship approaches at full
# speed, overshoots (stopping distance = max_speed^2 / (2*accel)),
# and oscillates back and forth forever instead of settling.
var braking_speed: float = sqrt(2.0 * acceleration * distance)
var desired_speed: float = min(max_speed, braking_speed)
var desired_velocity: Vector2 = to_target.normalized() * desired_speed
velocity = velocity.move_toward(desired_velocity, acceleration * delta)
position += velocity * delta
# One owner, one rule: face the way this ship is actually travelling.
var motion: Vector2 = velocity + fleet_velocity
if motion.length() > 8.0:
var target_angle: float = motion.angle() + PI / 2.0
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
_update_weapon(delta)
func _update_weapon(delta: float) -> void:
fire_timer -= delta
if fire_timer > 0.0:
return
var target: Node2D = _find_nearest_enemy()
if target == null:
# Back off before scanning again. Without this, fire_timer stays at
# or below zero and every ship queries the whole "enemies" group and
# loops it on EVERY physics frame whenever nothing is in range -
# which is most of the time.
fire_timer = idle_rescan_interval
return
fire_timer = fire_cooldown
var proj: Node2D = projectile_scene.instantiate()
proj.position = position
proj.damage = projectile_damage
get_tree().root.add_child(proj)
proj.launch(target.position - position)
func _find_nearest_enemy() -> Node2D:
var enemies: Array = get_tree().get_nodes_in_group("enemies")
var nearest: Node2D = null
var best_dist: float = fire_range
for e in enemies:
if not is_instance_valid(e):
continue
var d: float = position.distance_to(e.position)
if d <= best_dist:
best_dist = d
nearest = e
return nearest
func _on_area_entered(area: Area2D) -> void:
if not (area.is_in_group("enemies") or area.is_in_group("asteroids")):
return
if has_shield():
# Absorbed. The thing that hit it is still destroyed - a shield stops
# the vessel dying, it does not make the rock harmless - but the
# vessel survives and is defenceless until the shield cycles back.
shield_charges -= 1
shield_recharge_left = GameState.shield_recharge_time()
_update_shield_visual()
if area.is_in_group("asteroids") and is_instance_valid(area):
area.queue_free()
return
for child in get_children():
if child.has_method("absorb_hit") and child.absorb_hit():
if area.is_in_group("asteroids") and is_instance_valid(area):
area.queue_free()
return
ShipExplosionVFX.spawn(get_tree().root, position)
destroyed.emit()
queue_free()
func _build_shield_ring() -> void:
# Its own child, drawn once, toggled with `visible` (#10). A shield that
# came and went by redrawing the ship would re-tessellate the hull every
# time it cycled.
_shield_ring = NeonPiece.create(self, Vector2.ZERO, SHIELD_COLOR)
var points: PackedVector2Array = PackedVector2Array()
for i in range(14):
var a: float = (float(i) / 14.0) * TAU
points.append(Vector2(cos(a), sin(a)) * SHIELD_RADIUS)
_shield_ring.add_polygon(points, 0.7)
_shield_ring.queue_redraw()
_shield_ring.visible = false
func _draw() -> void:
var nose: Vector2 = Vector2(0, -22)
var left: Vector2 = Vector2(-14, 16)
var right: Vector2 = Vector2(14, 16)
var points: PackedVector2Array = PackedVector2Array([nose, left, right])
# Constant glow, drawn once. No pulse means nothing changes frame to
# frame, so the ship never needs redrawing after _ready() - which also
# removes four antialiased polyline rebuilds per ship per frame.
var points: PackedVector2Array = definition.hull_points if definition != null else PackedVector2Array([
Vector2(0, -22), Vector2(-14, 16), Vector2(14, 16),
])
NeonDraw.draw_glowing_polygon(self, points, glow_color, 1.0, core_color)

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,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "trn_capacity"
display_name = "Expanded Hold"
description = "Carries 8 more resources per supply run."
base_cost = 30
max_level = 3
icon = 21
additive_modifiers = {"cargo_capacity": 8.0}

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

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "trn_speed"
display_name = "Freight Engines"
description = "+70 speed on supply runs."
base_cost = 30
max_level = 3
icon = 22
additive_modifiers = {"transport_speed": 70.0}

View file

@ -0,0 +1,19 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=6 format=3]
[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://VesselCargoComponent.tscn" id="2"]
[ext_resource type="PackedScene" path="res://VesselTransportComponent.tscn" id="3"]
[ext_resource type="Resource" path="res://TransportCapacityUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://TransportSpeedUpgrade.tres" id="5"]
[resource]
script = ExtResource("1")
definition_id = "transporter"
display_name = "Transporter"
description = "Unarmed cargo vessel stationed at a transport hub to move resources."
build_cost = 10
build_time = 8.0
icon = 12
glow_color = Color(0.95, 0.55, 0.85, 1)
capabilities = PackedStringArray("cargo", "transport")
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")])
base_stats = {"max_speed": 460.0, "acceleration": 450.0, "cargo_capacity": 20.0, "transport_speed": 340.0}
upgrades = Array[Resource]([ExtResource("4"), ExtResource("5")])

9
VesselCargoComponent.gd Normal file
View file

@ -0,0 +1,9 @@
class_name VesselCargoComponent
extends Node
var capability_id: String = "cargo"
var capacity: int
func apply_stats() -> void:
var state: Node = get_tree().root.get_node("GameState")
capacity = int(state.vessel_stat(get_parent().vessel_type, "cargo_capacity"))

View file

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

View file

@ -0,0 +1,4 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselCargoComponent.gd" id="1"]
[node name="CargoComponent" type="Node"]
script = ExtResource("1")

26
VesselDefinition.gd Normal file
View file

@ -0,0 +1,26 @@
class_name VesselDefinition
extends Resource
@export var definition_id: String
@export var display_name: String
@export_multiline var description: String
@export var build_cost: int
@export var build_time: float = 10.0
@export var icon: int = 2
@export var glow_color: Color = Color(0.25, 0.85, 1.0)
@export var hull_points: PackedVector2Array = PackedVector2Array([
Vector2(0, -22), Vector2(-14, 16), Vector2(14, 16),
])
@export var capabilities: PackedStringArray = []
@export var component_scenes: Array[PackedScene] = []
@export var base_stats: Dictionary = {}
@export var minimum_stats: Dictionary = {}
@export var upgrades: Array[Resource] = []
@export var unlock_id: String
@export var unlock_name: String
@export_multiline var unlock_description: String
@export var unlock_cost: int
func has_capability(capability: String) -> bool:
return capability in capabilities

1
VesselDefinition.gd.uid Normal file
View file

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

24
VesselDefinitions.gd Normal file
View file

@ -0,0 +1,24 @@
class_name VesselDefinitions
extends RefCounted
const ORDER: PackedStringArray = ["interceptor", "shield", "transporter"]
const DEFINITIONS: Dictionary = {
"interceptor": preload("res://InterceptorVesselDefinition.tres"),
"shield": preload("res://ShieldVesselDefinition.tres"),
"transporter": preload("res://TransporterVesselDefinition.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
static func with_capability(capability: String) -> Resource:
for definition in all():
if definition.has_capability(capability):
return definition
return null

1
VesselDefinitions.gd.uid Normal file
View file

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

View file

@ -1,8 +1,10 @@
extends Node2D
const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
# Visual-only craft-to-mothership flight. GameState owns position/progress;
# this node just renders a finished hull headed toward the hangar.
var vessel_type: String = GameState.VESSEL_INTERCEPTOR
var vessel_type: String = "interceptor"
func _ready() -> void:
@ -11,6 +13,6 @@ func _ready() -> void:
func _draw() -> void:
var color: Color = Color(0.45, 0.8, 1.0) if vessel_type == GameState.VESSEL_SHIELD else Color(0.95, 0.55, 0.85) if vessel_type == GameState.VESSEL_TRANSPORTER else Color(0.25, 0.85, 1.0)
var hull := PackedVector2Array([Vector2(0, -20), Vector2(13, 15), Vector2(0, 9), Vector2(-13, 15)])
NeonDraw.draw_glowing_polygon(self, hull, color, 1.0)
var definition: Resource = VESSEL_DEFINITIONS.get_definition(vessel_type)
if definition != null:
NeonDraw.draw_glowing_polygon(self, definition.hull_points, definition.glow_color, 1.0)

Some files were not shown because too many files have changed in this diff Show more