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