From 14081ad2308bc8de0550e85ac005a160dc31a187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Fri, 14 Aug 2026 22:12:57 +0200 Subject: [PATCH] Compose vessels from definitions and capabilities --- BuildController.gd | 185 ++++++++++--------- GameState.gd | 228 +++++++++++------------- InterceptorDamageUpgrade.tres | 11 ++ InterceptorRangeUpgrade.tres | 11 ++ InterceptorRateUpgrade.tres | 11 ++ InterceptorSpeedUpgrade.tres | 11 ++ InterceptorVesselDefinition.tres | 20 +++ ShieldHitsUpgrade.tres | 11 ++ ShieldRechargeUpgrade.tres | 11 ++ ShieldSpeedUpgrade.tres | 11 ++ ShieldVesselDefinition.tres | 25 +++ Ship.gd | 234 ++++++++----------------- TransportCapacityUpgrade.tres | 11 ++ TransportSpeedUpgrade.tres | 11 ++ TransporterVesselDefinition.tres | 19 ++ VesselCargoComponent.gd | 9 + VesselCargoComponent.gd.uid | 1 + VesselCargoComponent.tscn | 4 + VesselDefinition.gd | 26 +++ VesselDefinition.gd.uid | 1 + VesselDefinitions.gd | 24 +++ VesselDefinitions.gd.uid | 1 + VesselDelivery.gd | 10 +- VesselDeliveryField.gd | 2 +- VesselShieldComponent.gd | 69 ++++++++ VesselShieldComponent.gd.uid | 1 + VesselShieldComponent.tscn | 4 + VesselTransportComponent.gd | 9 + VesselTransportComponent.gd.uid | 1 + VesselTransportComponent.tscn | 4 + VesselUpgradeDefinition.gd | 11 ++ VesselUpgradeDefinition.gd.uid | 1 + VesselWeaponComponent.gd | 47 +++++ VesselWeaponComponent.gd.uid | 1 + VesselWeaponComponent.tscn | 4 + tests/test_building_composition.gd.uid | 1 + tests/test_hangar_management.gd | 1 + tests/test_vessel_composition.gd | 50 ++++++ tests/test_vessel_composition.gd.uid | 1 + 39 files changed, 706 insertions(+), 387 deletions(-) create mode 100644 InterceptorDamageUpgrade.tres create mode 100644 InterceptorRangeUpgrade.tres create mode 100644 InterceptorRateUpgrade.tres create mode 100644 InterceptorSpeedUpgrade.tres create mode 100644 InterceptorVesselDefinition.tres create mode 100644 ShieldHitsUpgrade.tres create mode 100644 ShieldRechargeUpgrade.tres create mode 100644 ShieldSpeedUpgrade.tres create mode 100644 ShieldVesselDefinition.tres create mode 100644 TransportCapacityUpgrade.tres create mode 100644 TransportSpeedUpgrade.tres create mode 100644 TransporterVesselDefinition.tres create mode 100644 VesselCargoComponent.gd create mode 100644 VesselCargoComponent.gd.uid create mode 100644 VesselCargoComponent.tscn create mode 100644 VesselDefinition.gd create mode 100644 VesselDefinition.gd.uid create mode 100644 VesselDefinitions.gd create mode 100644 VesselDefinitions.gd.uid create mode 100644 VesselShieldComponent.gd create mode 100644 VesselShieldComponent.gd.uid create mode 100644 VesselShieldComponent.tscn create mode 100644 VesselTransportComponent.gd create mode 100644 VesselTransportComponent.gd.uid create mode 100644 VesselTransportComponent.tscn create mode 100644 VesselUpgradeDefinition.gd create mode 100644 VesselUpgradeDefinition.gd.uid create mode 100644 VesselWeaponComponent.gd create mode 100644 VesselWeaponComponent.gd.uid create mode 100644 VesselWeaponComponent.tscn create mode 100644 tests/test_building_composition.gd.uid create mode 100644 tests/test_vessel_composition.gd create mode 100644 tests/test_vessel_composition.gd.uid diff --git a/BuildController.gd b/BuildController.gd index b598703..1d33801 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -1,6 +1,7 @@ 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) @@ -32,6 +33,10 @@ const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") 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") @@ -98,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 @@ -159,7 +170,6 @@ func _ready() -> void: build_button.pressed.connect(_on_build_pressed) build_close_button.pressed.connect(_close_build_menu) _configure_build_menu_entries() - craft_shield_button.pressed.connect(_on_craft_shield) 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()): @@ -172,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) @@ -692,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: @@ -721,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) -------------------------------------------------------- @@ -764,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}) @@ -775,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() @@ -794,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 @@ -803,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()): @@ -820,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" % [ @@ -848,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) ---------------- @@ -957,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. @@ -971,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 @@ -1018,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() @@ -1035,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/GameState.gd b/GameState.gd index 56f773f..37d110a 100644 --- a/GameState.gd +++ b/GameState.gd @@ -3,6 +3,7 @@ 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() @@ -404,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 @@ -429,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 @@ -503,9 +450,7 @@ func has_ship_lab() -> bool: 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" @@ -513,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 @@ -567,66 +554,51 @@ 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: @@ -713,13 +685,14 @@ 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}) @@ -742,15 +715,16 @@ func _advance_production(delta: float) -> void: 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 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 inventory.withdraw(RESOURCE_ORE, cost) order.started = true @@ -760,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) @@ -782,7 +756,8 @@ func queue_lab_research(lab_id: int, upgrade_id: String) -> bool: 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 @@ -804,7 +779,8 @@ func _advance_lab_research(delta: float) -> void: 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 inventory.amount(RESOURCE_ORE) < cost: @@ -814,7 +790,7 @@ func _advance_lab_research(delta: float) -> void: 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 @@ -1041,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 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/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/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/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/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_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_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