diff --git a/BuildController.gd b/BuildController.gd index 114575f..1d33801 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -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() diff --git a/Building.gd b/Building.gd index d4387d7..b9dea7e 100644 --- a/Building.gd +++ b/Building.gd @@ -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() diff --git a/Building.tscn b/Building.tscn index 174c302..3929101 100644 --- a/Building.tscn +++ b/Building.tscn @@ -4,3 +4,5 @@ [node name="Building" type="Node2D"] script = ExtResource("1") + +[node name="VisualHost" type="Node2D" parent="."] diff --git a/BuildingActionProvider.gd b/BuildingActionProvider.gd new file mode 100644 index 0000000..1a9dba8 --- /dev/null +++ b/BuildingActionProvider.gd @@ -0,0 +1,3 @@ +extends Node + +@export var actions: PackedStringArray = [] diff --git a/BuildingActionProvider.gd.uid b/BuildingActionProvider.gd.uid new file mode 100644 index 0000000..e5e07f8 --- /dev/null +++ b/BuildingActionProvider.gd.uid @@ -0,0 +1 @@ +uid://c4dah6xluvenl diff --git a/BuildingDefinition.gd b/BuildingDefinition.gd new file mode 100644 index 0000000..fdc990e --- /dev/null +++ b/BuildingDefinition.gd @@ -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 diff --git a/BuildingDefinition.gd.uid b/BuildingDefinition.gd.uid new file mode 100644 index 0000000..7473bd0 --- /dev/null +++ b/BuildingDefinition.gd.uid @@ -0,0 +1 @@ +uid://d10s0frtxiw62 diff --git a/BuildingDefinitions.gd b/BuildingDefinitions.gd new file mode 100644 index 0000000..f05bf8e --- /dev/null +++ b/BuildingDefinitions.gd @@ -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 diff --git a/BuildingDefinitions.gd.uid b/BuildingDefinitions.gd.uid new file mode 100644 index 0000000..0dead32 --- /dev/null +++ b/BuildingDefinitions.gd.uid @@ -0,0 +1 @@ +uid://cpm0u3gu0exke diff --git a/BuildingField.gd b/BuildingField.gd index 496c26a..09b37bd 100644 --- a/BuildingField.gd +++ b/BuildingField.gd @@ -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 diff --git a/BuildingVisualBase.gd b/BuildingVisualBase.gd new file mode 100644 index 0000000..cd339b3 --- /dev/null +++ b/BuildingVisualBase.gd @@ -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)) diff --git a/BuildingVisualBase.gd.uid b/BuildingVisualBase.gd.uid new file mode 100644 index 0000000..8827724 --- /dev/null +++ b/BuildingVisualBase.gd.uid @@ -0,0 +1 @@ +uid://p7vuv1xsksc diff --git a/CommandBuildingDefinition.tres b/CommandBuildingDefinition.tres new file mode 100644 index 0000000..727a6f4 --- /dev/null +++ b/CommandBuildingDefinition.tres @@ -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" diff --git a/CommandBuildingVisual.gd b/CommandBuildingVisual.gd new file mode 100644 index 0000000..713b407 --- /dev/null +++ b/CommandBuildingVisual.gd @@ -0,0 +1,9 @@ +extends "res://BuildingVisualBase.gd" + +func _ready() -> void: + set_process(false) + queue_redraw() + +func _draw() -> void: + register_draw() + _draw_command_center() diff --git a/CommandBuildingVisual.gd.uid b/CommandBuildingVisual.gd.uid new file mode 100644 index 0000000..9a270e2 --- /dev/null +++ b/CommandBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://bpb5pq8g56ywn diff --git a/CommandBuildingVisual.tscn b/CommandBuildingVisual.tscn new file mode 100644 index 0000000..048b3f3 --- /dev/null +++ b/CommandBuildingVisual.tscn @@ -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") diff --git a/CrafterBuildingDefinition.tres b/CrafterBuildingDefinition.tres new file mode 100644 index 0000000..2a3f91f --- /dev/null +++ b/CrafterBuildingDefinition.tres @@ -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" diff --git a/CrafterBuildingVisual.gd b/CrafterBuildingVisual.gd new file mode 100644 index 0000000..ddd41a8 --- /dev/null +++ b/CrafterBuildingVisual.gd @@ -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) diff --git a/CrafterBuildingVisual.gd.uid b/CrafterBuildingVisual.gd.uid new file mode 100644 index 0000000..05576b0 --- /dev/null +++ b/CrafterBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://dbwebd1vf7biy diff --git a/CrafterBuildingVisual.tscn b/CrafterBuildingVisual.tscn new file mode 100644 index 0000000..c3bb670 --- /dev/null +++ b/CrafterBuildingVisual.tscn @@ -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") diff --git a/ExtractorBuildingDefinition.tres b/ExtractorBuildingDefinition.tres new file mode 100644 index 0000000..0584758 --- /dev/null +++ b/ExtractorBuildingDefinition.tres @@ -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" diff --git a/GameState.gd b/GameState.gd index f855f2a..37d110a 100644 --- a/GameState.gd +++ b/GameState.gd @@ -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 diff --git a/GasGiantDefinition.tres b/GasGiantDefinition.tres new file mode 100644 index 0000000..0a5109c --- /dev/null +++ b/GasGiantDefinition.tres @@ -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) diff --git a/GasGiantVisual.gd b/GasGiantVisual.gd new file mode 100644 index 0000000..e9172ca --- /dev/null +++ b/GasGiantVisual.gd @@ -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) diff --git a/GasGiantVisual.gd.uid b/GasGiantVisual.gd.uid new file mode 100644 index 0000000..af718cf --- /dev/null +++ b/GasGiantVisual.gd.uid @@ -0,0 +1 @@ +uid://bxfw4ja2hw4pf diff --git a/GasGiantVisual.tscn b/GasGiantVisual.tscn new file mode 100644 index 0000000..513b87e --- /dev/null +++ b/GasGiantVisual.tscn @@ -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") diff --git a/GasResourceDefinition.tres b/GasResourceDefinition.tres new file mode 100644 index 0000000..a36af07 --- /dev/null +++ b/GasResourceDefinition.tres @@ -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") diff --git a/GasResourceGlyph.gd b/GasResourceGlyph.gd new file mode 100644 index 0000000..8bd9965 --- /dev/null +++ b/GasResourceGlyph.gd @@ -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)) diff --git a/GasResourceGlyph.gd.uid b/GasResourceGlyph.gd.uid new file mode 100644 index 0000000..1dfbb0c --- /dev/null +++ b/GasResourceGlyph.gd.uid @@ -0,0 +1 @@ +uid://d1ko6yhtjjavx diff --git a/GasResourceGlyph.tscn b/GasResourceGlyph.tscn new file mode 100644 index 0000000..ee02276 --- /dev/null +++ b/GasResourceGlyph.tscn @@ -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") diff --git a/GasSurfaceFeatures.tscn b/GasSurfaceFeatures.tscn new file mode 100644 index 0000000..576b66d --- /dev/null +++ b/GasSurfaceFeatures.tscn @@ -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") diff --git a/GasSurfaceProfile.tres b/GasSurfaceProfile.tres new file mode 100644 index 0000000..0267c48 --- /dev/null +++ b/GasSurfaceProfile.tres @@ -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) diff --git a/GasVentField.gd b/GasVentField.gd index 9cdd338..831ca4c 100644 --- a/GasVentField.gd +++ b/GasVentField.gd @@ -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 diff --git a/InterceptorDamageUpgrade.tres b/InterceptorDamageUpgrade.tres new file mode 100644 index 0000000..835851a --- /dev/null +++ b/InterceptorDamageUpgrade.tres @@ -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} diff --git a/InterceptorRangeUpgrade.tres b/InterceptorRangeUpgrade.tres new file mode 100644 index 0000000..ce01efe --- /dev/null +++ b/InterceptorRangeUpgrade.tres @@ -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} diff --git a/InterceptorRateUpgrade.tres b/InterceptorRateUpgrade.tres new file mode 100644 index 0000000..4add45e --- /dev/null +++ b/InterceptorRateUpgrade.tres @@ -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} diff --git a/InterceptorSpeedUpgrade.tres b/InterceptorSpeedUpgrade.tres new file mode 100644 index 0000000..5549971 --- /dev/null +++ b/InterceptorSpeedUpgrade.tres @@ -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} diff --git a/InterceptorVesselDefinition.tres b/InterceptorVesselDefinition.tres new file mode 100644 index 0000000..a18f6c4 --- /dev/null +++ b/InterceptorVesselDefinition.tres @@ -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")]) diff --git a/Inventory.gd b/Inventory.gd new file mode 100644 index 0000000..aab25fd --- /dev/null +++ b/Inventory.gd @@ -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() diff --git a/Inventory.gd.uid b/Inventory.gd.uid new file mode 100644 index 0000000..59722a2 --- /dev/null +++ b/Inventory.gd.uid @@ -0,0 +1 @@ +uid://celx1yj6cp2rf diff --git a/MothershipBuildingDefinition.tres b/MothershipBuildingDefinition.tres new file mode 100644 index 0000000..a032777 --- /dev/null +++ b/MothershipBuildingDefinition.tres @@ -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 diff --git a/MothershipBuildingVisual.gd b/MothershipBuildingVisual.gd new file mode 100644 index 0000000..ba2f617 --- /dev/null +++ b/MothershipBuildingVisual.gd @@ -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) diff --git a/MothershipBuildingVisual.gd.uid b/MothershipBuildingVisual.gd.uid new file mode 100644 index 0000000..7ac3669 --- /dev/null +++ b/MothershipBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://m43bwdwhqgy4 diff --git a/MothershipBuildingVisual.tscn b/MothershipBuildingVisual.tscn new file mode 100644 index 0000000..0cc3615 --- /dev/null +++ b/MothershipBuildingVisual.tscn @@ -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") diff --git a/OreResourceDefinition.tres b/OreResourceDefinition.tres new file mode 100644 index 0000000..6da27dc --- /dev/null +++ b/OreResourceDefinition.tres @@ -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") diff --git a/OreResourceGlyph.gd b/OreResourceGlyph.gd new file mode 100644 index 0000000..e28f7a3 --- /dev/null +++ b/OreResourceGlyph.gd @@ -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)) diff --git a/OreResourceGlyph.gd.uid b/OreResourceGlyph.gd.uid new file mode 100644 index 0000000..2ba6598 --- /dev/null +++ b/OreResourceGlyph.gd.uid @@ -0,0 +1 @@ +uid://cwgkhqo4ev5ao diff --git a/OreResourceGlyph.tscn b/OreResourceGlyph.tscn new file mode 100644 index 0000000..384a20e --- /dev/null +++ b/OreResourceGlyph.tscn @@ -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") diff --git a/Planet.gd b/Planet.gd index c4eddbd..4d17d84 100644 --- a/Planet.gd +++ b/Planet.gd @@ -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) diff --git a/Planet.tscn b/Planet.tscn index 844672b..0e7fe1f 100644 --- a/Planet.tscn +++ b/Planet.tscn @@ -4,3 +4,5 @@ [node name="Planet" type="Node2D"] script = ExtResource("1") + +[node name="VisualHost" type="Node2D" parent="."] diff --git a/PlanetAtmosphereController.gd b/PlanetAtmosphereController.gd index cc497d4..3a66ea4 100644 --- a/PlanetAtmosphereController.gd +++ b/PlanetAtmosphereController.gd @@ -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) diff --git a/PlanetDefinition.gd b/PlanetDefinition.gd new file mode 100644 index 0000000..e184241 --- /dev/null +++ b/PlanetDefinition.gd @@ -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) diff --git a/PlanetDefinition.gd.uid b/PlanetDefinition.gd.uid new file mode 100644 index 0000000..ee09e6d --- /dev/null +++ b/PlanetDefinition.gd.uid @@ -0,0 +1 @@ +uid://dt6tsdmmlnfaf diff --git a/PlanetDefinitions.gd b/PlanetDefinitions.gd new file mode 100644 index 0000000..fa9dd2d --- /dev/null +++ b/PlanetDefinitions.gd @@ -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]) diff --git a/PlanetDefinitions.gd.uid b/PlanetDefinitions.gd.uid new file mode 100644 index 0000000..188741c --- /dev/null +++ b/PlanetDefinitions.gd.uid @@ -0,0 +1 @@ +uid://ipsld2ex2t8e diff --git a/PlanetField.gd b/PlanetField.gd index 4dbbdd8..6a8c3c6 100644 --- a/PlanetField.gd +++ b/PlanetField.gd @@ -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 diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index 507ec2b..81cea2d 100644 --- a/PlanetSurface.tscn +++ b/PlanetSurface.tscn @@ -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") diff --git a/PlanetSurfaceController.gd b/PlanetSurfaceController.gd index 502d8d4..7c683d4 100644 --- a/PlanetSurfaceController.gd +++ b/PlanetSurfaceController.gd @@ -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 diff --git a/PlanetSurfaceProfile.gd b/PlanetSurfaceProfile.gd new file mode 100644 index 0000000..d2ea9be --- /dev/null +++ b/PlanetSurfaceProfile.gd @@ -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) diff --git a/PlanetSurfaceProfile.gd.uid b/PlanetSurfaceProfile.gd.uid new file mode 100644 index 0000000..884fe16 --- /dev/null +++ b/PlanetSurfaceProfile.gd.uid @@ -0,0 +1 @@ +uid://b31vef6ge1tjh diff --git a/PlanetSurfaceProfiles.gd b/PlanetSurfaceProfiles.gd new file mode 100644 index 0000000..97da418 --- /dev/null +++ b/PlanetSurfaceProfiles.gd @@ -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]) diff --git a/PlanetSurfaceProfiles.gd.uid b/PlanetSurfaceProfiles.gd.uid new file mode 100644 index 0000000..c0f3f93 --- /dev/null +++ b/PlanetSurfaceProfiles.gd.uid @@ -0,0 +1 @@ +uid://bufr6mnwbobxb diff --git a/ResourceBar.gd b/ResourceBar.gd index 2bf11b4..6594fc1 100644 --- a/ResourceBar.gd +++ b/ResourceBar.gd @@ -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 diff --git a/ResourceDefinition.gd b/ResourceDefinition.gd new file mode 100644 index 0000000..acb5acc --- /dev/null +++ b/ResourceDefinition.gd @@ -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 diff --git a/ResourceDefinition.gd.uid b/ResourceDefinition.gd.uid new file mode 100644 index 0000000..440e5e4 --- /dev/null +++ b/ResourceDefinition.gd.uid @@ -0,0 +1 @@ +uid://kvy02iu7pbyu diff --git a/ResourceDefinitions.gd b/ResourceDefinitions.gd new file mode 100644 index 0000000..7ddf4c0 --- /dev/null +++ b/ResourceDefinitions.gd @@ -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 diff --git a/ResourceDefinitions.gd.uid b/ResourceDefinitions.gd.uid new file mode 100644 index 0000000..9ef7d79 --- /dev/null +++ b/ResourceDefinitions.gd.uid @@ -0,0 +1 @@ +uid://ci7yyyxyd3o67 diff --git a/ResourceGlyph.gd b/ResourceGlyph.gd index aa80578..14a90d0 100644 --- a/ResourceGlyph.gd +++ b/ResourceGlyph.gd @@ -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) diff --git a/ResourceTransferList.gd b/ResourceTransferList.gd index 1883119..859f0f1 100644 --- a/ResourceTransferList.gd +++ b/ResourceTransferList.gd @@ -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) diff --git a/RockField.gd b/RockField.gd index a82389c..dd839ed 100644 --- a/RockField.gd +++ b/RockField.gd @@ -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. diff --git a/RockyPlanetDefinition.tres b/RockyPlanetDefinition.tres new file mode 100644 index 0000000..b0a3e9f --- /dev/null +++ b/RockyPlanetDefinition.tres @@ -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) diff --git a/RockyPlanetVisual.gd b/RockyPlanetVisual.gd new file mode 100644 index 0000000..dc2a889 --- /dev/null +++ b/RockyPlanetVisual.gd @@ -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) diff --git a/RockyPlanetVisual.gd.uid b/RockyPlanetVisual.gd.uid new file mode 100644 index 0000000..b6ecadb --- /dev/null +++ b/RockyPlanetVisual.gd.uid @@ -0,0 +1 @@ +uid://b82xh8nosedlm diff --git a/RockyPlanetVisual.tscn b/RockyPlanetVisual.tscn new file mode 100644 index 0000000..ee6a302 --- /dev/null +++ b/RockyPlanetVisual.tscn @@ -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") diff --git a/RockySurfaceFeatures.tscn b/RockySurfaceFeatures.tscn new file mode 100644 index 0000000..f31b5c8 --- /dev/null +++ b/RockySurfaceFeatures.tscn @@ -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") diff --git a/RockySurfaceProfile.tres b/RockySurfaceProfile.tres new file mode 100644 index 0000000..ab220e0 --- /dev/null +++ b/RockySurfaceProfile.tres @@ -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) diff --git a/ShieldHitsUpgrade.tres b/ShieldHitsUpgrade.tres new file mode 100644 index 0000000..2530597 --- /dev/null +++ b/ShieldHitsUpgrade.tres @@ -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} diff --git a/ShieldRechargeUpgrade.tres b/ShieldRechargeUpgrade.tres new file mode 100644 index 0000000..d73cf09 --- /dev/null +++ b/ShieldRechargeUpgrade.tres @@ -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} diff --git a/ShieldSpeedUpgrade.tres b/ShieldSpeedUpgrade.tres new file mode 100644 index 0000000..fe5a4b6 --- /dev/null +++ b/ShieldSpeedUpgrade.tres @@ -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} diff --git a/ShieldVesselDefinition.tres b/ShieldVesselDefinition.tres new file mode 100644 index 0000000..9df3bf8 --- /dev/null +++ b/ShieldVesselDefinition.tres @@ -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 diff --git a/Ship.gd b/Ship.gd index 624c312..1c2727c 100644 --- a/Ship.gd +++ b/Ship.gd @@ -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) diff --git a/ShipLabBuildingDefinition.tres b/ShipLabBuildingDefinition.tres new file mode 100644 index 0000000..0823f52 --- /dev/null +++ b/ShipLabBuildingDefinition.tres @@ -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" diff --git a/ShipLabBuildingVisual.gd b/ShipLabBuildingVisual.gd new file mode 100644 index 0000000..9655162 --- /dev/null +++ b/ShipLabBuildingVisual.gd @@ -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) diff --git a/ShipLabBuildingVisual.gd.uid b/ShipLabBuildingVisual.gd.uid new file mode 100644 index 0000000..fbb5203 --- /dev/null +++ b/ShipLabBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://b0p7p0fs0th0b diff --git a/ShipLabBuildingVisual.tscn b/ShipLabBuildingVisual.tscn new file mode 100644 index 0000000..e71fdf2 --- /dev/null +++ b/ShipLabBuildingVisual.tscn @@ -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") diff --git a/TransportCapacityUpgrade.tres b/TransportCapacityUpgrade.tres new file mode 100644 index 0000000..2c2b920 --- /dev/null +++ b/TransportCapacityUpgrade.tres @@ -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} diff --git a/TransportHubBuildingDefinition.tres b/TransportHubBuildingDefinition.tres new file mode 100644 index 0000000..59ad15d --- /dev/null +++ b/TransportHubBuildingDefinition.tres @@ -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" diff --git a/TransportHubBuildingVisual.gd b/TransportHubBuildingVisual.gd new file mode 100644 index 0000000..a8a0d44 --- /dev/null +++ b/TransportHubBuildingVisual.gd @@ -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) diff --git a/TransportHubBuildingVisual.gd.uid b/TransportHubBuildingVisual.gd.uid new file mode 100644 index 0000000..b1ca4cb --- /dev/null +++ b/TransportHubBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://6qyl5qnhw0ho diff --git a/TransportHubBuildingVisual.tscn b/TransportHubBuildingVisual.tscn new file mode 100644 index 0000000..1426761 --- /dev/null +++ b/TransportHubBuildingVisual.tscn @@ -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") diff --git a/TransportSpeedUpgrade.tres b/TransportSpeedUpgrade.tres new file mode 100644 index 0000000..43c3c96 --- /dev/null +++ b/TransportSpeedUpgrade.tres @@ -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} diff --git a/TransporterVesselDefinition.tres b/TransporterVesselDefinition.tres new file mode 100644 index 0000000..c654a48 --- /dev/null +++ b/TransporterVesselDefinition.tres @@ -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")]) diff --git a/VesselCargoComponent.gd b/VesselCargoComponent.gd new file mode 100644 index 0000000..4373500 --- /dev/null +++ b/VesselCargoComponent.gd @@ -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")) diff --git a/VesselCargoComponent.gd.uid b/VesselCargoComponent.gd.uid new file mode 100644 index 0000000..85443bc --- /dev/null +++ b/VesselCargoComponent.gd.uid @@ -0,0 +1 @@ +uid://bo16x4wq6hg5k diff --git a/VesselCargoComponent.tscn b/VesselCargoComponent.tscn new file mode 100644 index 0000000..34eafa6 --- /dev/null +++ b/VesselCargoComponent.tscn @@ -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") diff --git a/VesselDefinition.gd b/VesselDefinition.gd new file mode 100644 index 0000000..0f6637f --- /dev/null +++ b/VesselDefinition.gd @@ -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 diff --git a/VesselDefinition.gd.uid b/VesselDefinition.gd.uid new file mode 100644 index 0000000..371e084 --- /dev/null +++ b/VesselDefinition.gd.uid @@ -0,0 +1 @@ +uid://c6txy3v61blm8 diff --git a/VesselDefinitions.gd b/VesselDefinitions.gd new file mode 100644 index 0000000..2b33845 --- /dev/null +++ b/VesselDefinitions.gd @@ -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 diff --git a/VesselDefinitions.gd.uid b/VesselDefinitions.gd.uid new file mode 100644 index 0000000..d907359 --- /dev/null +++ b/VesselDefinitions.gd.uid @@ -0,0 +1 @@ +uid://cgwj1q4nf3ar7 diff --git a/VesselDelivery.gd b/VesselDelivery.gd index 4c07a6d..6392b62 100644 --- a/VesselDelivery.gd +++ b/VesselDelivery.gd @@ -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) diff --git a/VesselDeliveryField.gd b/VesselDeliveryField.gd index 606b1cf..9b47319 100644 --- a/VesselDeliveryField.gd +++ b/VesselDeliveryField.gd @@ -17,9 +17,9 @@ func _process(_delta: float) -> void: var node: Node2D = active.get(i) if node == null or not is_instance_valid(node): node = delivery_script.new() + node.vessel_type = String(delivery.type) get_parent().add_child(node) active[i] = node - node.vessel_type = String(delivery.type) node.position = pos var direction: Vector2 = (delivery.to as Vector2) - (delivery.from as Vector2) if direction.length() > 0.1: diff --git a/VesselShieldComponent.gd b/VesselShieldComponent.gd new file mode 100644 index 0000000..15a8edf --- /dev/null +++ b/VesselShieldComponent.gd @@ -0,0 +1,69 @@ +class_name VesselShieldComponent +extends Node + +const SHIELD_COLOR: Color = Color(0.45, 0.8, 1.0) +const SHIELD_RADIUS: float = 30.0 + +var capability_id: String = "shield" +var charges: int = 0 +var recharge_left: float = 0.0 +var ring: Node2D + + +func setup() -> void: + var ship: Node2D = get_parent() + var state: Node = ship.get_tree().root.get_node("GameState") + ring = NeonPiece.create(ship, Vector2.ZERO, SHIELD_COLOR) + var points := PackedVector2Array() + for i in range(14): + var angle: float = float(i) / 14.0 * TAU + points.append(Vector2(cos(angle), sin(angle)) * SHIELD_RADIUS) + ring.add_polygon(points, 0.7) + ring.queue_redraw() + charges = int(state.vessel_stat(ship.vessel_type, "shield_charges")) + _update_visual() + + +func apply_stats() -> void: + var was_up: bool = charges > 0 + if was_up: + charges = int(_state().vessel_stat(get_parent().vessel_type, "shield_charges")) + _update_visual() + + +func tick(delta: float) -> void: + var maximum: int = int(_state().vessel_stat(get_parent().vessel_type, "shield_charges")) + if charges >= maximum: + return + recharge_left -= delta + if recharge_left <= 0.0: + charges += 1 + recharge_left = _state().vessel_stat(get_parent().vessel_type, "shield_recharge") + _update_visual() + + +func is_active() -> bool: + return charges > 0 + + +func absorb_hit() -> bool: + if not is_active(): + return false + charges -= 1 + recharge_left = _state().vessel_stat(get_parent().vessel_type, "shield_recharge") + _update_visual() + return true + + +func set_charges(value: int) -> void: + charges = value + _update_visual() + + +func _state() -> Node: + return get_tree().root.get_node("GameState") + + +func _update_visual() -> void: + if ring != null: + ring.visible = is_active() diff --git a/VesselShieldComponent.gd.uid b/VesselShieldComponent.gd.uid new file mode 100644 index 0000000..4ab2382 --- /dev/null +++ b/VesselShieldComponent.gd.uid @@ -0,0 +1 @@ +uid://ghacatobxl11 diff --git a/VesselShieldComponent.tscn b/VesselShieldComponent.tscn new file mode 100644 index 0000000..50ba89e --- /dev/null +++ b/VesselShieldComponent.tscn @@ -0,0 +1,4 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://VesselShieldComponent.gd" id="1"] +[node name="ShieldComponent" type="Node"] +script = ExtResource("1") diff --git a/VesselTransportComponent.gd b/VesselTransportComponent.gd new file mode 100644 index 0000000..7aec619 --- /dev/null +++ b/VesselTransportComponent.gd @@ -0,0 +1,9 @@ +class_name VesselTransportComponent +extends Node + +var capability_id: String = "transport" +var travel_speed: float + +func apply_stats() -> void: + var state: Node = get_tree().root.get_node("GameState") + travel_speed = state.vessel_stat(get_parent().vessel_type, "transport_speed") diff --git a/VesselTransportComponent.gd.uid b/VesselTransportComponent.gd.uid new file mode 100644 index 0000000..a2c81fd --- /dev/null +++ b/VesselTransportComponent.gd.uid @@ -0,0 +1 @@ +uid://tsapg8ld1vl8 diff --git a/VesselTransportComponent.tscn b/VesselTransportComponent.tscn new file mode 100644 index 0000000..a16d4e9 --- /dev/null +++ b/VesselTransportComponent.tscn @@ -0,0 +1,4 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://VesselTransportComponent.gd" id="1"] +[node name="TransportComponent" type="Node"] +script = ExtResource("1") diff --git a/VesselUpgradeDefinition.gd b/VesselUpgradeDefinition.gd new file mode 100644 index 0000000..083e092 --- /dev/null +++ b/VesselUpgradeDefinition.gd @@ -0,0 +1,11 @@ +class_name VesselUpgradeDefinition +extends Resource + +@export var upgrade_id: String +@export var display_name: String +@export_multiline var description: String +@export var base_cost: int +@export var max_level: int = 1 +@export var icon: int +@export var additive_modifiers: Dictionary = {} +@export var multiplicative_modifiers: Dictionary = {} diff --git a/VesselUpgradeDefinition.gd.uid b/VesselUpgradeDefinition.gd.uid new file mode 100644 index 0000000..c615214 --- /dev/null +++ b/VesselUpgradeDefinition.gd.uid @@ -0,0 +1 @@ +uid://dw41jux1cn3sw diff --git a/VesselWeaponComponent.gd b/VesselWeaponComponent.gd new file mode 100644 index 0000000..1bc6855 --- /dev/null +++ b/VesselWeaponComponent.gd @@ -0,0 +1,47 @@ +class_name VesselWeaponComponent +extends Node + +var capability_id: String = "weapon" +var fire_range: float +var fire_cooldown: float +var projectile_damage: int +var fire_timer: float = 0.0 +var projectile_scene: PackedScene = preload("res://Projectile.tscn") + + +func apply_stats() -> void: + var ship: Node = get_parent() + var state: Node = ship.get_tree().root.get_node("GameState") + fire_range = state.vessel_stat(ship.vessel_type, "fire_range") + fire_cooldown = state.vessel_stat(ship.vessel_type, "fire_cooldown") + projectile_damage = int(state.vessel_stat(ship.vessel_type, "damage")) + + +func tick(delta: float) -> void: + fire_timer -= delta + if fire_timer > 0.0: + return + var ship: Node2D = get_parent() + var target: Node2D = _find_nearest_enemy(ship) + if target == null: + fire_timer = 0.25 + return + var projectile: Node2D = projectile_scene.instantiate() + projectile.position = ship.position + projectile.damage = projectile_damage + ship.get_tree().root.add_child(projectile) + projectile.launch(target.position - ship.position) + fire_timer = fire_cooldown + + +func _find_nearest_enemy(ship: Node2D) -> Node2D: + var nearest: Node2D = null + var best_distance: float = fire_range + for enemy in ship.get_tree().get_nodes_in_group("enemies"): + if not is_instance_valid(enemy): + continue + var distance: float = ship.position.distance_to(enemy.position) + if distance <= best_distance: + best_distance = distance + nearest = enemy + return nearest diff --git a/VesselWeaponComponent.gd.uid b/VesselWeaponComponent.gd.uid new file mode 100644 index 0000000..9fd5306 --- /dev/null +++ b/VesselWeaponComponent.gd.uid @@ -0,0 +1 @@ +uid://cwo0nkmr4e1sl diff --git a/VesselWeaponComponent.tscn b/VesselWeaponComponent.tscn new file mode 100644 index 0000000..b3bd611 --- /dev/null +++ b/VesselWeaponComponent.tscn @@ -0,0 +1,4 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://VesselWeaponComponent.gd" id="1"] +[node name="WeaponComponent" type="Node"] +script = ExtResource("1") diff --git a/WorldGen.gd b/WorldGen.gd index e36a51f..5e73ee5 100644 --- a/WorldGen.gd +++ b/WorldGen.gd @@ -1,6 +1,8 @@ class_name WorldGen extends RefCounted +const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd") + # Deterministic procedural world generation: the world is divided into # square chunks, and each chunk's biome is derived from a seeded # low-frequency noise field sampled at the chunk's own coordinates - so @@ -22,13 +24,6 @@ const GAS_GIANT_THRESHOLD: float = 0.55 const NEARBY_GAS_GIANT_CHUNK: Vector2i = Vector2i.ZERO const NEARBY_GAS_GIANT_LOCAL_POS: Vector2 = Vector2(900.0, 600.0) -const PLANET_NAMES: Array = [ - "Kepler's Rest", "Ceres Minor", "Vesta's Shadow", "Ironhold", - "Drift Anchor", "Stonefall", "Grey Watch", "Silent Quarry", - "Baros Rock", "Umbra Point", -] -const GAS_GIANT_NAMES: Array = ["Aurelia", "Boreas", "Cerulean Deep", "Helios Major", "Vesper"] - static var _noise: FastNoiseLite static var _planet_noise: FastNoiseLite @@ -88,15 +83,16 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary: var guaranteed_nearby_giant: bool = chunk == NEARBY_GAS_GIANT_CHUNK var local_pos: Vector2 = NEARBY_GAS_GIANT_LOCAL_POS if guaranteed_nearby_giant \ else Vector2(rng.randf() * CHUNK_SIZE, rng.randf() * CHUNK_SIZE) - var planet_name: String = PLANET_NAMES[rng.randi() % PLANET_NAMES.size()] var shape_seed: int = rng.randi() # A stable per-chunk roll makes gas giants persistent landmarks rather # than a different resource whenever the scene reloads. var gas_giant: bool = guaranteed_nearby_giant or biome_at_chunk(chunk) == Biome.SPACE - if gas_giant: - planet_name = GAS_GIANT_NAMES[rng.randi() % GAS_GIANT_NAMES.size()] + var definition_id: String = PLANET_DEFINITIONS.GAS_GIANT_ID if gas_giant else PLANET_DEFINITIONS.ROCKY_ID + var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id) + var names: PackedStringArray = definition.names + var planet_name: String = names[rng.randi() % names.size()] return {"local_pos": local_pos, "name": planet_name, "shape_seed": shape_seed, - "planet_type": "gas_giant" if gas_giant else "rocky", "resource_type": "gas" if gas_giant else "ore"} + "definition_id": definition_id} static func biome_color(biome: int) -> Color: diff --git a/WorldMapContent.gd b/WorldMapContent.gd index e8ba8df..5d4667b 100644 --- a/WorldMapContent.gd +++ b/WorldMapContent.gd @@ -1,17 +1,9 @@ extends Node2D -const WORLD_TO_LOCAL: float = 0.04 +const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd") +const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") -# Mirrors Building.gd's own MOTHERSHIP_COLOR/CRAFTER_COLOR/COMMAND_COLOR so a -# marker on the map reads as "the same building" at a glance. Duplicated -# rather than cross-referenced - the same colour is already independently -# duplicated between Ship.gd, Fleet.gd and Building.gd's HULL_COLOR, so this -# follows the codebase's existing convention rather than inventing a new one. -const MOTHERSHIP_MARKER_COLOR: Color = Color(0.45, 0.9, 0.55) -const CRAFTER_MARKER_COLOR: Color = Color(1.0, 0.72, 0.25) -const COMMAND_MARKER_COLOR: Color = Color(0.55, 0.6, 1.0) -const TRANSPORT_HUB_MARKER_COLOR: Color = Color(0.95, 0.55, 0.85) -const SHIP_LAB_MARKER_COLOR: Color = Color(0.55, 0.95, 0.9) +const WORLD_TO_LOCAL: float = 0.04 # A structure built on a planet's surface (see GameState.extractors) is not # itself a map marker - it is a small badge on the existing planet marker, @@ -38,28 +30,20 @@ func _process(_delta: float) -> void: func building_markers() -> Array: var markers: Array = [] for b in GameState.buildings: + var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(b.type)) + if definition == null: + continue markers.append({ "position": b.position * WORLD_TO_LOCAL, "type": b.type, - "color": _building_marker_color(b.type), + "color": definition.map_color, + "sides": definition.map_sides, + "radius": definition.map_radius, + "phase": definition.map_phase, }) return markers -func _building_marker_color(type: String) -> Color: - match type: - GameState.BUILDING_MOTHERSHIP: - return MOTHERSHIP_MARKER_COLOR - GameState.BUILDING_SHIP_CRAFTER: - return CRAFTER_MARKER_COLOR - GameState.BUILDING_TRANSPORT_HUB: - return TRANSPORT_HUB_MARKER_COLOR - GameState.BUILDING_SHIP_LAB: - return SHIP_LAB_MARKER_COLOR - _: - return COMMAND_MARKER_COLOR - - # Whether the planet in this (explored) chunk has an extractor on it, per # GameState.has_extractor_at(). A separate predicate for the same # GPU-less-testability reason as building_markers(). @@ -105,7 +89,8 @@ func _draw() -> void: # spawn_radius_chunks of the fleet). var data: Dictionary = WorldGen.planet_data_at_chunk(chunk) var planet_local: Vector2 = origin_local + data.local_pos * WORLD_TO_LOCAL - draw_circle(planet_local, 5.0, Color(0.7, 0.65, 0.55)) + var definition: Resource = PLANET_DEFINITIONS.get_definition(String(data.definition_id)) + draw_circle(planet_local, 5.0, definition.map_color) draw_arc(planet_local, 5.0, 0.0, TAU, 16, Color(1.0, 1.0, 1.0, 0.9), 1.0, true) if chunk_has_extractor_badge(chunk): @@ -116,17 +101,7 @@ func _draw() -> void: # Buildings are placed freely in space rather than tied to a chunk, so # they get their own pass rather than living inside the chunk loop above. for marker in building_markers(): - match marker.type: - GameState.BUILDING_MOTHERSHIP: - _draw_marker_polygon(_regular_polygon(marker.position, 8.0, 6, PI / 6.0), marker.color) - GameState.BUILDING_SHIP_CRAFTER: - _draw_marker_polygon(_regular_polygon(marker.position, 7.0, 4, 0.0), marker.color) - GameState.BUILDING_TRANSPORT_HUB: - _draw_marker_polygon(_regular_polygon(marker.position, 7.5, 8, PI / 8.0), marker.color) - GameState.BUILDING_SHIP_LAB: - _draw_marker_polygon(_regular_polygon(marker.position, 7.5, 3, -PI / 2.0), marker.color) - _: - _draw_marker_polygon(_regular_polygon(marker.position, 6.0, 4, PI / 4.0), marker.color) + _draw_marker_polygon(_regular_polygon(marker.position, marker.radius, marker.sides, marker.phase), marker.color) var fleet_local: Vector2 = fleet.get_centroid() * WORLD_TO_LOCAL var marker_points: PackedVector2Array = PackedVector2Array([ diff --git a/tests/_build_popup_preview.gd b/tests/_build_popup_preview.gd index 3bb938e..d3c6b1d 100644 --- a/tests/_build_popup_preview.gd +++ b/tests/_build_popup_preview.gd @@ -28,4 +28,4 @@ func _ready() -> void: # Flip SHOW_POPUP to check the menu underneath # instead of the popup that covers it. if SHOW_POPUP: - controller._on_place_mothership() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) diff --git a/tests/_crafter_preview.gd b/tests/_crafter_preview.gd index dc89fe4..52888aa 100644 --- a/tests/_crafter_preview.gd +++ b/tests/_crafter_preview.gd @@ -31,9 +31,9 @@ func _ready() -> void: b.position = Vector2(-240.0 + float(i % 3) * 240.0, -105.0 + float(i / 3) * 215.0) add_child(b) if cell.has("phase"): - b.set_process(false) - b.anim_time = float(cell.phase) * b.CYCLE - b.apply_animation() + b.visual.set_process(false) + b.visual.anim_time = float(cell.phase) * b.visual.CYCLE + b.visual.apply_animation() var cam: Camera2D = Camera2D.new() cam.zoom = Vector2(1.65, 1.65) diff --git a/tests/_gas_surface_preview.gd b/tests/_gas_surface_preview.gd index ba68e1c..5dafbe5 100644 --- a/tests/_gas_surface_preview.gd +++ b/tests/_gas_surface_preview.gd @@ -3,7 +3,7 @@ extends Node2D func _ready() -> void: GameState.planet_name = "Violet Tempest" - GameState.planet_type = "gas_giant" + GameState.planet_definition_id = "gas_giant" GameState.surviving_ship_count = 3 GameState.rocks = 128 GameState.gas = 46 diff --git a/tests/test_animation_continuity.gd b/tests/test_animation_continuity.gd index d373a25..3919aa6 100644 --- a/tests/test_animation_continuity.gd +++ b/tests/test_animation_continuity.gd @@ -53,7 +53,7 @@ func _process(delta: float) -> bool: var ship: Node2D = null for b in field.spawned: if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP: - ship = b + ship = b.visual if ship == null: print("TEST_ANIMATION_CONTINUITY: FAIL (mothership never spawned)") quit(1) diff --git a/tests/test_build_info_popup.gd b/tests/test_build_info_popup.gd index ef1d708..c67d45c 100644 --- a/tests/test_build_info_popup.gd +++ b/tests/test_build_info_popup.gd @@ -28,9 +28,9 @@ var elapsed: float = 0.0 # Extractor is excluded: it needs a planet underneath, which # test_extractor_building covers in full. const OPTIONS: Array = [ - {"press": "_on_place_mothership", "type": "mothership", "title": "Mothership"}, - {"press": "_on_place_crafter", "type": "ship_crafter", "title": "Ship Crafter"}, - {"press": "_on_place_command_center", "type": "command_center", "title": "Command Center"}, + {"type": "mothership", "title": "Mothership"}, + {"type": "ship_crafter", "title": "Ship Crafter"}, + {"type": "command_center", "title": "Command Center"}, ] @@ -84,7 +84,7 @@ func _process(delta: float) -> bool: var built_before: int = _count(type) controller._on_build_pressed() - controller.call(option.press) + controller._show_building_info(type) var informed: bool = popup.visible \ and title.text == option.title \ @@ -111,7 +111,7 @@ func _process(delta: float) -> bool: # --- reopening and confirming builds it ------------------ controller._on_build_pressed() - controller.call(option.press) + controller._show_building_info(type) controller._confirm_build() var built: bool = _count(type) == built_before + 1 var charged: bool = game_state.rocks == rocks_before - expected_cost diff --git a/tests/test_building_composition.gd b/tests/test_building_composition.gd new file mode 100644 index 0000000..7cadf5d --- /dev/null +++ b/tests/test_building_composition.gd @@ -0,0 +1,35 @@ +extends SceneTree + +const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") + + +func _initialize() -> void: + var scene: PackedScene = load("res://Building.tscn") + for definition in BUILDING_DEFINITIONS.all(): + if definition.placement_mode != "space": + continue + var building: Node2D = scene.instantiate() + building.definition_id = definition.definition_id + building._ready() + if building.definition == null \ + or building.definition.definition_id != definition.definition_id \ + or building.visual == null: + _fail("%s did not compose its definition and visual (resolved=%s visual=%s)" % [ + definition.definition_id, + "null" if building.definition == null else building.definition.definition_id, + building.visual, + ]) + return + var provider: Node = building.visual.get_node_or_null("ActionProvider") + if provider == null or not provider.actions.has("transfer"): + _fail("%s has no composed action provider" % definition.definition_id) + return + building.free() + + print("TEST_BUILDING_COMPOSITION: PASS") + quit(0) + + +func _fail(message: String) -> void: + print("TEST_BUILDING_COMPOSITION: FAIL (%s)" % message) + quit(1) diff --git a/tests/test_building_composition.gd.uid b/tests/test_building_composition.gd.uid new file mode 100644 index 0000000..f74b39a --- /dev/null +++ b/tests/test_building_composition.gd.uid @@ -0,0 +1 @@ +uid://bljnijxm3n20e diff --git a/tests/test_building_redraw_cost.gd b/tests/test_building_redraw_cost.gd index c083754..1c81451 100644 --- a/tests/test_building_redraw_cost.gd +++ b/tests/test_building_redraw_cost.gd @@ -61,11 +61,11 @@ func _process(delta: float) -> bool: continue match b.building_type: GameState.BUILDING_SHIP_CRAFTER: - crafter = b + crafter = b.visual GameState.BUILDING_MOTHERSHIP: - mothership = b + mothership = b.visual GameState.BUILDING_COMMAND_CENTER: - command = b + command = b.visual if crafter == null or mothership == null or command == null: print("TEST_BUILDING_REDRAW_COST: FAIL (buildings never spawned)") quit(1) @@ -88,7 +88,7 @@ func _process(delta: float) -> bool: for b in [crafter, mothership, command]: if b.draw_count != 1: print("TEST_BUILDING_REDRAW_COST: FAIL (%s redrew its geometry %d times)" % [ - b.building_type, b.draw_count + b.name, b.draw_count ]) quit(1) return true diff --git a/tests/test_crafter_animation.gd b/tests/test_crafter_animation.gd index 7ddab14..9b5d57e 100644 --- a/tests/test_crafter_animation.gd +++ b/tests/test_crafter_animation.gd @@ -57,16 +57,19 @@ func _process(delta: float) -> bool: # Spawning is deferred, so give the tree a frame to attach them. if elapsed < 0.2: return false - crafter = _find(GameState.BUILDING_SHIP_CRAFTER) - if crafter == null: + var crafter_building: Node2D = _find(GameState.BUILDING_SHIP_CRAFTER) + if crafter_building == null: print("TEST_CRAFTER_ANIMATION: FAIL (crafter never spawned)") quit(1) return true + crafter = crafter_building.visual var animated: bool = crafter.is_processing() - var mothership: Node2D = _find(GameState.BUILDING_MOTHERSHIP) + var mothership_building: Node2D = _find(GameState.BUILDING_MOTHERSHIP) + var mothership: Node2D = mothership_building.visual if mothership_building != null else null var mothership_animates: bool = mothership != null and mothership.is_processing() - var command: Node2D = _find(GameState.BUILDING_COMMAND_CENTER) + var command_building: Node2D = _find(GameState.BUILDING_COMMAND_CENTER) + var command: Node2D = command_building.visual if command_building != null else null var command_static: bool = command != null and not command.is_processing() print("crafter animates=%s mothership animates=%s command centre static=%s" % [ animated, mothership_animates, command_static diff --git a/tests/test_extractor_building.gd b/tests/test_extractor_building.gd index 13b2567..0a4b769 100644 --- a/tests/test_extractor_building.gd +++ b/tests/test_extractor_building.gd @@ -69,7 +69,7 @@ func _process(delta: float) -> bool: print("TEST_EXTRACTOR_BUILDING: FAIL (enabled with no planet in reach)") quit(1) return true - controller._on_place_extractor() + controller._show_building_info(GameState.BUILDING_EXTRACTOR) var explained: bool = popup.visible and "planet" in popup.get_node("Panel/MessageLabel").text.to_lower() var offered: bool = popup.get_node("Panel/BuildButton").visible print("no planet: greyed=%s popup_shown=%s confirm_offered=%s" % [ @@ -118,7 +118,7 @@ func _process(delta: float) -> bool: return true var rocks_before: int = game_state.rocks - controller._on_place_extractor() + controller._show_building_info(GameState.BUILDING_EXTRACTOR) var planet_field2: Node = main_scene.get_node("PlanetField") var planet2: Node2D = planet_field2.populated_chunks[target_chunk][0] @@ -148,7 +148,7 @@ func _process(delta: float) -> bool: # A second one on the same planet must be refused, not charged. var rocks_now: int = game_state.rocks controller._on_build_pressed() - controller._on_place_extractor() + controller._show_building_info(GameState.BUILDING_EXTRACTOR) var re_offered: bool = popup.get_node("Panel/BuildButton").visible controller._confirm_build() # must do nothing even if pressed var still_one: bool = game_state.extractors.size() == 1 and not re_offered diff --git a/tests/test_gas_system.gd b/tests/test_gas_system.gd index 898e43d..94edc1d 100644 --- a/tests/test_gas_system.gd +++ b/tests/test_gas_system.gd @@ -27,8 +27,7 @@ func _process(delta: float) -> bool: return _fail("nearby gas giant chunk does not contain a planet") if WorldGen.biome_at_chunk(nearby_chunk) != WorldGen.Biome.SPACE: return _fail("nearby gas giant is not in the normal space biome") - if String(nearby_data.get("planet_type", "")) != "gas_giant" \ - or String(nearby_data.get("resource_type", "")) != "gas": + if String(nearby_data.get("definition_id", "")) != "gas_giant": return _fail("nearby guaranteed planet is not a gas source") if nearby_position.length() > WorldGen.CHUNK_SIZE * 2.0: return _fail("nearby gas giant spawned too far away: %s" % nearby_position) @@ -36,34 +35,49 @@ func _process(delta: float) -> bool: # Gas surfaces have vents only: no deferred ore pickups, and the vent # emission advances continuously so the source visibly looks active. game_state.planet_name = "Gas Test" - game_state.planet_type = "gas_giant" + game_state.planet_definition_id = "gas_giant" var surface: Node = load("res://PlanetSurface.tscn").instantiate() root.add_child(surface) - var rock_field: Node = surface.get_node("RockField") - var gas_field: Node = surface.get_node("GasVentField") - if not rock_field.rocks.is_empty() or rock_field.is_processing(): + var gas_field: Node = surface.get_node("SurfaceFeatures/ResourceProvider") + if gas_field.get_script() != load("res://GasVentField.gd"): surface.free() - return _fail("gas surface spawned or continued processing ore rocks") + return _fail("gas surface did not compose the gas feature scene") + if surface.get_node_or_null("RockField") != null: + surface.free() + return _fail("gas surface tree contains an ore producer") gas_field._process(0.5) if gas_field.emission_phase <= 0.0: surface.free() return _fail("gas-source emission animation did not advance") surface.free() + game_state.planet_definition_id = "rocky" + var rocky_surface: Node = load("res://PlanetSurface.tscn").instantiate() + root.add_child(rocky_surface) + var rocky_features: Node = rocky_surface.get_node("SurfaceFeatures/ResourceProvider") + if rocky_features.get_script() != load("res://RockField.gd"): + rocky_surface.free() + return _fail("rocky surface did not compose the ore feature scene") + if rocky_surface.get_node_or_null("GasVentField") != null: + rocky_surface.free() + return _fail("rocky surface tree contains a gas producer") + rocky_surface.free() + var found_giant: bool = false for x in range(-12, 13): for y in range(-12, 13): var data: Dictionary = WorldGen.planet_data_at_chunk(Vector2i(x, y)) - if String(data.get("planet_type", "")) == "gas_giant": - found_giant = String(data.get("resource_type", "")) == "gas" + if String(data.get("definition_id", "")) == "gas_giant": + found_giant = true break if found_giant: break if not found_giant: return _fail("world generation produced no deterministic gas giant") var giant: Node2D = load("res://Planet.tscn").instantiate() - giant.planet_type = "gas_giant" - giant._process(0.5) + giant.definition_id = "gas_giant" + root.add_child(giant) + giant.visual._process(0.5) if giant.gas_phase <= 0.0: return _fail("gas giant animation did not advance") giant.free() diff --git a/tests/test_generic_inventory.gd b/tests/test_generic_inventory.gd new file mode 100644 index 0000000..9e9418c --- /dev/null +++ b/tests/test_generic_inventory.gd @@ -0,0 +1,48 @@ +extends SceneTree + +const INVENTORY = preload("res://Inventory.gd") +const TEST_RESOURCE: String = "crystal" + + +func _initialize() -> void: + var game_state: Node = root.get_node("GameState") + game_state.buildings.clear() + game_state.bank_inventory.set_amount(TEST_RESOURCE, 40) + game_state.fleet_inventory.set_amount(TEST_RESOURCE, 0) + + var mothership_id: int = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + var crafter_id: int = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(100, 0)) + if game_state.transfer_resource_to_fleet(mothership_id, TEST_RESOURCE, 15) != 15: + return _fail("third resource could not move from bank to fleet") + if game_state.transfer_resource_to_building(crafter_id, TEST_RESOURCE, 9) != 9: + return _fail("third resource could not move from fleet to building") + if game_state.transfer_resource_to_fleet(crafter_id, TEST_RESOURCE, 4) != 4: + return _fail("third resource could not move back out of building") + if game_state.banked_resource(TEST_RESOURCE) != 25 \ + or game_state.fleet_resource(TEST_RESOURCE) != 10 \ + or game_state.building_resource(crafter_id, TEST_RESOURCE) != 5: + return _fail("third-resource amounts were not conserved") + + var limited: RefCounted = INVENTORY.new({}, 10) + limited.resource_capacities[TEST_RESOURCE] = 6 + if limited.deposit(TEST_RESOURCE, 99) != 6 or limited.deposit("water", 99) != 4: + return _fail("total or per-resource capacity was not enforced") + + var legacy_id: int = game_state.next_building_id + game_state.next_building_id += 1 + game_state.buildings.append({"id": legacy_id, "type": GameState.BUILDING_SHIP_CRAFTER, + "position": Vector2.ZERO, "stock": 7, "gas_stock": 3, "orders": []}) + if game_state.building_resource(legacy_id, GameState.RESOURCE_ORE) != 7 \ + or game_state.building_resource(legacy_id, GameState.RESOURCE_GAS) != 3: + return _fail("legacy building stock was not migrated") + var migrated: Dictionary = game_state.find_building(legacy_id) + if migrated.has("stock") or migrated.has("gas_stock") or not migrated.has("inventory"): + return _fail("legacy fields remained after migration") + + print("TEST_GENERIC_INVENTORY: PASS") + quit(0) + + +func _fail(message: String) -> void: + print("TEST_GENERIC_INVENTORY: FAIL (%s)" % message) + quit(1) diff --git a/tests/test_generic_inventory.gd.uid b/tests/test_generic_inventory.gd.uid new file mode 100644 index 0000000..d8972c6 --- /dev/null +++ b/tests/test_generic_inventory.gd.uid @@ -0,0 +1 @@ +uid://53pexhmp3ns0 diff --git a/tests/test_hangar_management.gd b/tests/test_hangar_management.gd index eb6d399..4de3cac 100644 --- a/tests/test_hangar_management.gd +++ b/tests/test_hangar_management.gd @@ -68,6 +68,7 @@ func _process(delta: float) -> bool: # own non-overlapping berths to the right only after expansion. var mothership: Node2D = load("res://Building.tscn").instantiate() mothership.set("building_type", GameState.BUILDING_MOTHERSHIP) + mothership._ready() controller.current_building = mothership controller.context_actions_expanded = true controller._layout_context_buttons() diff --git a/tests/test_manual_supply_route.gd b/tests/test_manual_supply_route.gd index f8479d1..bfb1f86 100644 --- a/tests/test_manual_supply_route.gd +++ b/tests/test_manual_supply_route.gd @@ -42,7 +42,8 @@ const CRAFTER_AT: Vector2 = Vector2(3000.0, 0.0) func _total() -> int: var sum: int = game_state.rocks + game_state.fleet_cargo for b in game_state.buildings: - sum += int(b.get("stock", 0)) + if b.type != GameState.BUILDING_MOTHERSHIP: + sum += game_state.building_resource(int(b.id), GameState.RESOURCE_ORE) return sum diff --git a/tests/test_mothership_replacement.gd b/tests/test_mothership_replacement.gd index 6ebe817..576f039 100644 --- a/tests/test_mothership_replacement.gd +++ b/tests/test_mothership_replacement.gd @@ -67,7 +67,7 @@ func _process(delta: float) -> bool: return false # --- first one: informed, then built ------------------------- controller._on_build_pressed() - controller._on_place_mothership() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) var first_text: String = popup.get_node("Panel/MessageLabel").text.to_lower() var first_warned: bool = "destroy" in first_text print("first: popup=%s warned=%s built_before_confirm=%d" % [ @@ -86,7 +86,7 @@ func _process(delta: float) -> bool: # --- second: warns, builds nothing --------------------------- _place_fleet(second_pos) controller._on_build_pressed() - controller._on_place_mothership() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) var warned: bool = popup.visible and "destroy" in popup.get_node("Panel/MessageLabel").text.to_lower() print("second press: warned=%s motherships=%d" % [warned, _count_motherships()]) if not warned or _count_motherships() != 1: @@ -107,7 +107,7 @@ func _process(delta: float) -> bool: # --- confirming replaces ------------------------------------- var rocks_before: int = game_state.rocks - controller._on_place_mothership() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) controller._confirm_build() var only_one: bool = _count_motherships() == 1 var moved: bool = game_state.get_mothership_position().distance_to(second_pos) < 60.0 @@ -124,7 +124,7 @@ func _process(delta: float) -> bool: game_state.rocks = GameState.MOTHERSHIP_COST - 1 _place_fleet(Vector2(2500.0, -800.0)) controller._on_build_pressed() - controller._on_place_mothership() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) # With nothing affordable there is no confirm button to press; # pressing it anyway must still do nothing. var offered: bool = popup.get_node("Panel/BuildButton").visible diff --git a/tests/test_resource_transfer.gd b/tests/test_resource_transfer.gd index 7b6a00c..f5035bc 100644 --- a/tests/test_resource_transfer.gd +++ b/tests/test_resource_transfer.gd @@ -39,7 +39,7 @@ func _total() -> int: var sum: int = game_state.rocks + game_state.fleet_cargo for b in game_state.buildings: if b.type != GameState.BUILDING_MOTHERSHIP: - sum += int(b.get("stock", 0)) + sum += game_state.building_resource(int(b.id), GameState.RESOURCE_ORE) return sum diff --git a/tests/test_resources_and_buildings.gd b/tests/test_resources_and_buildings.gd index 6069a1b..17c6780 100644 --- a/tests/test_resources_and_buildings.gd +++ b/tests/test_resources_and_buildings.gd @@ -38,7 +38,7 @@ func _process(delta: float) -> bool: match stage: 0: # --- gather a rock ------------------------------------------- - scene.get_node("RockField").set_process(false) + scene.get_node("SurfaceFeatures/ResourceProvider").set_process(false) rock = load("res://Rock.tscn").instantiate() rock.build_shape(1) # Placed OUTSIDE the pickup shape but inside attract_radius, so diff --git a/tests/test_skill_tree.gd b/tests/test_skill_tree.gd index 7b2a887..5c9d21c 100644 --- a/tests/test_skill_tree.gd +++ b/tests/test_skill_tree.gd @@ -72,9 +72,9 @@ func _process(delta: float) -> bool: # Since #21 an unresearched structure is not listed at all, rather # than listed and greyed. It is therefore unreachable from the # menu - but the popup must still refuse if something calls - # _on_place_crafter() by another route. + # the generic definition entry point by another route. var hidden: bool = not scene.get_node("UI/BuildMenu/CrafterButton").visible - controller._on_place_crafter() + controller._show_building_info(GameState.BUILDING_SHIP_CRAFTER) var offered: bool = popup.get_node("Panel/BuildButton").visible print("locked crafter: hidden_from_menu=%s confirm_offered=%s" % [hidden, offered]) if not hidden or offered: diff --git a/tests/test_transport_hub_building.gd b/tests/test_transport_hub_building.gd index b2cc4eb..dcb03a6 100644 --- a/tests/test_transport_hub_building.gd +++ b/tests/test_transport_hub_building.gd @@ -1,5 +1,7 @@ extends SceneTree +const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") + # Headless gameplay test: run with # godot --headless -s res://tests/test_transport_hub_building.gd # Forgejo #18 (part 5 of #12): the Transport Hub is a placeable building of @@ -67,7 +69,7 @@ func _process(delta: float) -> bool: var icons: Dictionary = {} for type in [GameState.BUILDING_MOTHERSHIP, GameState.BUILDING_SHIP_CRAFTER, GameState.BUILDING_COMMAND_CENTER, GameState.BUILDING_TRANSPORT_HUB]: - var icon: int = controller._action_icon_for(type) + var icon: int = BUILDING_DEFINITIONS.get_definition(type).action_icon if icons.has(icon): print("TEST_TRANSPORT_HUB_BUILDING: FAIL (%s and %s share an icon)" % [type, icons[icon]]) quit(1) @@ -99,7 +101,7 @@ func _process(delta: float) -> bool: return false for b in field.spawned: if is_instance_valid(b) and b.building_type == GameState.BUILDING_TRANSPORT_HUB: - hub = b + hub = b.visual if hub == null: print("TEST_TRANSPORT_HUB_BUILDING: FAIL (never spawned)") quit(1) @@ -120,9 +122,9 @@ func _process(delta: float) -> bool: for m in markers: if m.type == GameState.BUILDING_TRANSPORT_HUB: found = true - if m.color == map_content.CRAFTER_MARKER_COLOR \ - or m.color == map_content.MOTHERSHIP_MARKER_COLOR \ - or m.color == map_content.COMMAND_MARKER_COLOR: + if m.color == BUILDING_DEFINITIONS.get_definition(GameState.BUILDING_SHIP_CRAFTER).map_color \ + or m.color == BUILDING_DEFINITIONS.get_definition(GameState.BUILDING_MOTHERSHIP).map_color \ + or m.color == BUILDING_DEFINITIONS.get_definition(GameState.BUILDING_COMMAND_CENTER).map_color: print("TEST_TRANSPORT_HUB_BUILDING: FAIL (map marker reuses another building's colour)") quit(1) return true diff --git a/tests/test_transporter_supply.gd b/tests/test_transporter_supply.gd index 8130e16..ca9dcbb 100644 --- a/tests/test_transporter_supply.gd +++ b/tests/test_transporter_supply.gd @@ -43,7 +43,8 @@ const CRAFTER_AT: Vector2 = Vector2(900.0, -300.0) func _total() -> int: var sum: int = game_state.rocks + game_state.fleet_cargo for b in game_state.buildings: - sum += int(b.get("stock", 0)) + if b.type != GameState.BUILDING_MOTHERSHIP: + sum += game_state.building_resource(int(b.id), GameState.RESOURCE_ORE) for t in game_state.transporters: sum += int(t.get("carrying", 0)) # Ore inside a build that has STARTED but not finished (#23). It has left diff --git a/tests/test_vessel_composition.gd b/tests/test_vessel_composition.gd new file mode 100644 index 0000000..f25f447 --- /dev/null +++ b/tests/test_vessel_composition.gd @@ -0,0 +1,50 @@ +extends SceneTree + +const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") + +var ships: Array = [] +var elapsed: float = 0.0 +var game_state: Node + + +func _initialize() -> void: + game_state = root.get_node("GameState") + var scene: PackedScene = load("res://Ship.tscn") + for definition in VESSEL_DEFINITIONS.all(): + var ship: Area2D = scene.instantiate() + ship.vessel_type = definition.definition_id + root.add_child(ship) + ships.append(ship) + + +func _process(delta: float) -> bool: + elapsed += delta + if elapsed < 0.1: + return false + var definitions: Array = VESSEL_DEFINITIONS.all() + if ships.size() != definitions.size(): + return _fail("catalog and composed fleet differ in size") + for i in range(definitions.size()): + var definition: Resource = definitions[i] + var ship: Node = ships[i] + if ship.definition == null or ship.definition.definition_id != definition.definition_id: + return _fail("%s did not resolve its definition" % definition.definition_id) + for capability in definition.capabilities: + if not ship.has_capability(capability): + return _fail("%s did not compose %s" % [definition.definition_id, capability]) + if game_state.product_cost(definition.definition_id) != definition.build_cost \ + or game_state.vessel_name(definition.definition_id) != definition.display_name: + return _fail("%s metadata did not come from its definition" % definition.definition_id) + if ships[0].has_capability("shield") or not ships[1].has_capability("shield"): + return _fail("shield behavior is not controlled by component presence") + if ships[2].has_capability("weapon") or not ships[2].has_capability("transport"): + return _fail("transporter capabilities were composed incorrectly") + print("TEST_VESSEL_COMPOSITION: PASS") + quit(0) + return true + + +func _fail(message: String) -> bool: + print("TEST_VESSEL_COMPOSITION: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_vessel_composition.gd.uid b/tests/test_vessel_composition.gd.uid new file mode 100644 index 0000000..d490e27 --- /dev/null +++ b/tests/test_vessel_composition.gd.uid @@ -0,0 +1 @@ +uid://cq73poxld1e35