From bbb1d4b0c5dba0519e0128b46dab549720d672d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 16 Aug 2026 16:20:39 +0200 Subject: [PATCH] Add carrier vessel (#57) An unarmed hauler with no weapon component - only VesselCargoComponent, so it structurally cannot fire, the same pattern that already made the shield vessel weaponless after #35. Its cargo_capacity (120, six interceptors' worth) makes it the first real payoff for #56's fleet hold cap. Wired into the crafter, hangar and ship lab menus, all of which were already generic over VesselDefinitions.fleet()/.all() - only the per-vessel UI *nodes* are hand-placed (Main.tscn), not the logic, so this repacks those three button rows to 4/4/5 columns and extends BuildController's matching onready arrays. No new signal handlers were needed. Also fixes test_vessel_composition.gd's hardcoded fleet-catalog size (3 -> 4) now that a fourth vessel is fleet-eligible. --- BuildController.gd | 12 +- CarrierCapacityUpgrade.tres | 11 ++ CarrierVesselDefinition.tres | 25 +++ GameState.gd | 1 + IconButton.gd | 19 +++ Main.tscn | 167 ++++++++++++++----- VesselDefinitions.gd | 3 +- tests/_carrier_crafter_preview.gd | 17 ++ tests/_carrier_crafter_preview.tscn | 6 + tests/_carrier_hangar_preview.gd | 21 +++ tests/_carrier_hangar_preview.tscn | 6 + tests/test_carrier_vessel.gd | 239 ++++++++++++++++++++++++++++ tests/test_vessel_composition.gd | 2 +- 13 files changed, 489 insertions(+), 40 deletions(-) create mode 100644 CarrierCapacityUpgrade.tres create mode 100644 CarrierVesselDefinition.tres create mode 100644 tests/_carrier_crafter_preview.gd create mode 100644 tests/_carrier_crafter_preview.tscn create mode 100644 tests/_carrier_hangar_preview.gd create mode 100644 tests/_carrier_hangar_preview.tscn create mode 100644 tests/test_carrier_vessel.gd diff --git a/BuildController.gd b/BuildController.gd index 44ca2c9..96a905c 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -31,11 +31,13 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") @onready var lab_vessel_buttons: Array = [ get_node("../UI/LabMenu/VesselA"), get_node("../UI/LabMenu/VesselB"), get_node("../UI/LabMenu/VesselC"), get_node("../UI/LabMenu/VesselD"), + get_node("../UI/LabMenu/VesselE"), ] @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"), get_node("../UI/LabMenu/VesselDLabel"), + get_node("../UI/LabMenu/VesselELabel"), ] @onready var lab_detail: Label = get_node("../UI/LabMenu/DetailLabel") @onready var lab_status: Label = get_node("../UI/LabMenu/StatusLabel") @@ -63,21 +65,25 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") get_node("../UI/HangarMenu/AvailableInterceptor"), get_node("../UI/HangarMenu/AvailableShield"), get_node("../UI/HangarMenu/AvailableGasBomber"), + get_node("../UI/HangarMenu/AvailableCarrier"), ] @onready var hangar_available_labels: Array = [ get_node("../UI/HangarMenu/AvailableInterceptorLabel"), get_node("../UI/HangarMenu/AvailableShieldLabel"), get_node("../UI/HangarMenu/AvailableGasBomberLabel"), + get_node("../UI/HangarMenu/AvailableCarrierLabel"), ] @onready var hangar_fleet_slots: Array = [ get_node("../UI/HangarMenu/FleetInterceptor"), get_node("../UI/HangarMenu/FleetShield"), get_node("../UI/HangarMenu/FleetGasBomber"), + get_node("../UI/HangarMenu/FleetCarrier"), ] @onready var hangar_fleet_labels: Array = [ get_node("../UI/HangarMenu/FleetInterceptorLabel"), get_node("../UI/HangarMenu/FleetShieldLabel"), get_node("../UI/HangarMenu/FleetGasBomberLabel"), + get_node("../UI/HangarMenu/FleetCarrierLabel"), ] var build_menu_entries: Array = [] @@ -103,11 +109,13 @@ var lab_building_id: int = 0 @onready var craft_shield_label: Label = get_node("../UI/CraftMenu/ShieldLabel") @onready var craft_gas_bomber_button: IconButton = get_node("../UI/CraftMenu/GasBomberButton") @onready var craft_gas_bomber_label: Label = get_node("../UI/CraftMenu/GasBomberLabel") +@onready var craft_carrier_button: IconButton = get_node("../UI/CraftMenu/CarrierButton") +@onready var craft_carrier_label: Label = get_node("../UI/CraftMenu/CarrierLabel") @onready var craft_vessel_buttons: Array = [ - craft_interceptor_button, craft_shield_button, craft_gas_bomber_button, + craft_interceptor_button, craft_shield_button, craft_gas_bomber_button, craft_carrier_button, ] @onready var craft_vessel_labels: Array = [ - craft_interceptor_label, craft_shield_label, craft_gas_bomber_label, + craft_interceptor_label, craft_shield_label, craft_gas_bomber_label, craft_carrier_label, ] # --- the transfer panel (#24) --------------------------------------------- diff --git a/CarrierCapacityUpgrade.tres b/CarrierCapacityUpgrade.tres new file mode 100644 index 0000000..8736c1b --- /dev/null +++ b/CarrierCapacityUpgrade.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 = "car_capacity" +display_name = "Reinforced Hold" +description = "+30 cargo capacity per level." +base_cost = 40 +max_level = 3 +icon = 21 +additive_modifiers = {"cargo_capacity": 30.0} diff --git a/CarrierVesselDefinition.tres b/CarrierVesselDefinition.tres new file mode 100644 index 0000000..6d7012b --- /dev/null +++ b/CarrierVesselDefinition.tres @@ -0,0 +1,25 @@ +[gd_resource type="Resource" script_class="VesselDefinition" load_steps=4 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="Resource" path="res://CarrierCapacityUpgrade.tres" id="3"] +[resource] +script = ExtResource("1") +definition_id = "carrier" +display_name = "Carrier" +description = "Unarmed hauler with a much larger hold than any combat vessel. Cannot fire." +build_cost = 24 +build_time = 20.0 +icon = 34 +glow_color = Color(1, 0.78, 0.3, 1) +hull_points = PackedVector2Array(-32, -10, -18, -18, 18, -18, 32, -10, 32, 14, -32, 14) +fleet_eligible = true +capabilities = PackedStringArray("cargo") +component_scenes = Array[PackedScene]([ExtResource("2")]) +base_stats = {"max_speed": 380.0, "acceleration": 360.0, "delivery_speed": 180.0, "cargo_capacity": 120.0} +upgrades = Array[Resource]([ExtResource("3")]) +unlock_id = "unlock_carrier" +unlock_name = "Unlock Carrier" +unlock_description = "Research a dedicated hauler hull that trades weapons for a much larger cargo hold." +unlock_cost = 0 +unlock_resource_costs = {"ore": 60} +unlock_icon = -1 diff --git a/GameState.gd b/GameState.gd index c3bae6c..cfc0c2d 100644 --- a/GameState.gd +++ b/GameState.gd @@ -463,6 +463,7 @@ const VESSEL_INTERCEPTOR: String = "interceptor" const VESSEL_SHIELD: String = "shield" const VESSEL_GAS_BOMBER: String = "gas_bomber" const VESSEL_TRANSPORTER: String = "transporter" +const VESSEL_CARRIER: String = "carrier" # Compatibility constants for callers and old tests. Runtime metadata is # resolved from VesselDefinition below. diff --git a/IconButton.gd b/IconButton.gd index 70b0a16..6a5e02f 100644 --- a/IconButton.gd +++ b/IconButton.gd @@ -35,6 +35,9 @@ enum Icon { # inserting there would silently renumber every icon int already # stored in existing scenes (#54). GAS_BURNER, + # Same append-only rule (#57): a wide hauler hull, distinct from every + # vessel glyph above. + CARRIER_VESSEL, } # Size the glyphs were originally drawn against. Every glyph dimension is @@ -227,6 +230,8 @@ func _draw() -> void: _draw_gas_bomb(center, pulse, s) Icon.GAS_BURNER: _draw_gas_burner_icon(center, pulse, s) + Icon.CARRIER_VESSEL: + _draw_carrier(center, pulse, s) func _draw_close(center: Vector2, pulse: float, s: float) -> void: @@ -251,6 +256,20 @@ func _draw_shield_vessel(center: Vector2, pulse: float, s: float) -> void: NeonDraw.draw_glowing_polygon(self, points, _active_color(), pulse, core_color) +func _draw_carrier(center: Vector2, pulse: float, s: float) -> void: + # Wide and flat-bottomed rather than pointed - reads as a hauler, not a + # combat silhouette, matching the ship's own hull_points. + var hull := PackedVector2Array([ + center + Vector2(-13, -4) * s, + center + Vector2(-7, -8) * s, + center + Vector2(7, -8) * s, + center + Vector2(13, -4) * s, + center + Vector2(13, 6) * s, + center + Vector2(-13, 6) * s, + ]) + NeonDraw.draw_glowing_polygon(self, hull, _active_color(), pulse, core_color) + + func _draw_gas_bomber(center: Vector2, pulse: float, s: float) -> void: var hull := PackedVector2Array([ center + Vector2(0, -12) * s, diff --git a/Main.tscn b/Main.tscn index 8cbed22..663e18e 100644 --- a/Main.tscn +++ b/Main.tscn @@ -840,8 +840,8 @@ horizontal_alignment = 1 [node name="AvailableShield" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -56 -offset_right = 56 +offset_left = -134 +offset_right = -22 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -852,8 +852,8 @@ glow_color = Color(0.45, 0.8, 1, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -96 -offset_right = 96 +offset_left = -174 +offset_right = 18 offset_top = 380 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -862,8 +862,8 @@ horizontal_alignment = 1 [node name="AvailableGasBomber" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 178 -offset_right = 290 +offset_left = 22 +offset_right = 134 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -874,6 +874,28 @@ glow_color = Color(0.96, 0.32, 0.78, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 +offset_left = -18 +offset_right = 174 +offset_top = 380 +offset_bottom = 460 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 + +[node name="AvailableCarrier" type="Control" parent="UI/HangarMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 178 +offset_right = 290 +offset_top = 255 +offset_bottom = 375 +script = ExtResource("10") +icon = 34 +glow_color = Color(1, 0.78, 0.3, 1) + +[node name="AvailableCarrierLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 offset_left = 138 offset_right = 330 offset_top = 380 @@ -914,8 +936,8 @@ horizontal_alignment = 1 [node name="FleetShield" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -56 -offset_right = 56 +offset_left = -134 +offset_right = -22 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -926,8 +948,8 @@ glow_color = Color(0.45, 0.8, 1, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -96 -offset_right = 96 +offset_left = -174 +offset_right = 18 offset_top = 690 offset_bottom = 770 theme_override_font_sizes/font_size = 22 @@ -936,8 +958,8 @@ horizontal_alignment = 1 [node name="FleetGasBomber" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 178 -offset_right = 290 +offset_left = 22 +offset_right = 134 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -948,6 +970,28 @@ glow_color = Color(0.96, 0.32, 0.78, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 +offset_left = -18 +offset_right = 174 +offset_top = 690 +offset_bottom = 770 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 + +[node name="FleetCarrier" type="Control" parent="UI/HangarMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 178 +offset_right = 290 +offset_top = 565 +offset_bottom = 685 +script = ExtResource("10") +icon = 34 +glow_color = Color(1, 0.78, 0.3, 1) + +[node name="FleetCarrierLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 offset_left = 138 offset_right = 330 offset_top = 690 @@ -1000,7 +1044,7 @@ script = ExtResource("24") anchor_left = 0.5 anchor_right = 0.5 offset_left = -300 -offset_right = -190 +offset_right = -212 offset_top = 274 offset_bottom = 394 script = ExtResource("10") @@ -1010,8 +1054,8 @@ glow_color = Color(0.25, 0.85, 1, 1) [node name="VesselB" type="Control" parent="UI/LabMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -145 -offset_right = -35 +offset_left = -172 +offset_right = -84 offset_top = 274 offset_bottom = 394 script = ExtResource("10") @@ -1021,8 +1065,8 @@ glow_color = Color(0.45, 0.8, 1, 1) [node name="VesselC" type="Control" parent="UI/LabMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 35 -offset_right = 145 +offset_left = -44 +offset_right = 44 offset_top = 274 offset_bottom = 394 script = ExtResource("10") @@ -1032,14 +1076,25 @@ glow_color = Color(0.96, 0.32, 0.78, 1) [node name="VesselD" type="Control" parent="UI/LabMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 190 -offset_right = 300 +offset_left = 84 +offset_right = 172 offset_top = 274 offset_bottom = 394 script = ExtResource("10") icon = 12 glow_color = Color(0.95, 0.55, 0.85, 1) +[node name="VesselE" type="Control" parent="UI/LabMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 212 +offset_right = 300 +offset_top = 274 +offset_bottom = 394 +script = ExtResource("10") +icon = 34 +glow_color = Color(1, 0.78, 0.3, 1) + [node name="VesselLabel" type="Label" parent="UI/LabMenu"] visible = false mouse_filter = 2 @@ -1057,7 +1112,7 @@ mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 offset_left = -330 -offset_right = -160 +offset_right = -182 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -1068,8 +1123,8 @@ text = "Interceptor" mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -175 -offset_right = -5 +offset_left = -202 +offset_right = -54 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -1080,8 +1135,8 @@ text = "Shield" mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = 5 -offset_right = 175 +offset_left = -74 +offset_right = 74 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -1092,14 +1147,26 @@ text = "Gas Bomber" mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = 160 -offset_right = 330 +offset_left = 54 +offset_right = 202 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 horizontal_alignment = 1 text = "Transporter" +[node name="VesselELabel" type="Label" parent="UI/LabMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 182 +offset_right = 330 +offset_top = 400 +offset_bottom = 460 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 +text = "Carrier" + [node name="VesselUpgradeSeparator" type="ColorRect" parent="UI/LabMenu"] mouse_filter = 2 anchor_right = 1.0 @@ -1345,7 +1412,7 @@ anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -310 -offset_right = -182 +offset_right = -208 offset_top = -100 offset_bottom = 48 script = ExtResource("10") @@ -1358,7 +1425,7 @@ anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -330 -offset_right = -162 +offset_right = -188 offset_top = 56 offset_bottom = 136 theme_override_font_sizes/font_size = 24 @@ -1370,8 +1437,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -148 -offset_right = -20 +offset_left = -174 +offset_right = -72 offset_top = -100 offset_bottom = 48 script = ExtResource("10") @@ -1384,8 +1451,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -168 -offset_right = 0 +offset_left = -194 +offset_right = -52 offset_top = 56 offset_bottom = 136 theme_override_font_sizes/font_size = 24 @@ -1398,8 +1465,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 14 -offset_right = 142 +offset_left = -38 +offset_right = 64 offset_top = -100 offset_bottom = 28 script = ExtResource("10") @@ -1412,8 +1479,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -6 -offset_right = 162 +offset_left = -58 +offset_right = 84 offset_top = 56 offset_bottom = 150 theme_override_font_sizes/font_size = 24 @@ -1421,6 +1488,34 @@ autowrap_mode = 2 text = "Gas Bomber" horizontal_alignment = 1 +[node name="CarrierButton" type="Control" parent="UI/CraftMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 98 +offset_right = 200 +offset_top = -100 +offset_bottom = 48 +script = ExtResource("10") +icon = 34 +glow_color = Color(1, 0.78, 0.3, 1) + +[node name="CarrierLabel" type="Label" parent="UI/CraftMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 78 +offset_right = 220 +offset_top = 56 +offset_bottom = 136 +theme_override_font_sizes/font_size = 24 +autowrap_mode = 2 +text = "Carrier" +horizontal_alignment = 1 + [node name="QueueLabel" type="Label" parent="UI/CraftMenu"] mouse_filter = 2 anchor_left = 1.0 diff --git a/VesselDefinitions.gd b/VesselDefinitions.gd index eeb3bca..92675a8 100644 --- a/VesselDefinitions.gd +++ b/VesselDefinitions.gd @@ -1,12 +1,13 @@ class_name VesselDefinitions extends RefCounted -const ORDER: PackedStringArray = ["interceptor", "shield", "gas_bomber", "transporter"] +const ORDER: PackedStringArray = ["interceptor", "shield", "gas_bomber", "transporter", "carrier"] const DEFINITIONS: Dictionary = { "interceptor": preload("res://InterceptorVesselDefinition.tres"), "shield": preload("res://ShieldVesselDefinition.tres"), "gas_bomber": preload("res://GasBomberVesselDefinition.tres"), "transporter": preload("res://TransporterVesselDefinition.tres"), + "carrier": preload("res://CarrierVesselDefinition.tres"), } static func get_definition(definition_id: String) -> Resource: diff --git a/tests/_carrier_crafter_preview.gd b/tests/_carrier_crafter_preview.gd new file mode 100644 index 0000000..36fa21c --- /dev/null +++ b/tests/_carrier_crafter_preview.gd @@ -0,0 +1,17 @@ +extends Node2D + +# Preview, not a test - #57's crafter menu screenshot with all 4 fleet +# vessels researched, carrier included as the new 4th entry. +# +# bash tests/screenshot.sh res://tests/_carrier_crafter_preview.tscn /tmp/crafter.png 20 720x1280 + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.rocks = 500 + GameState.vessel_levels = {"unlock_shield": 1, "unlock_gas_bomber": 1, "unlock_carrier": 1} + var main: Node = load("res://Main.tscn").instantiate() + add_child(main) + main.get_node("UI/CraftMenu").visible = true + main.get_node("BuildController")._refresh_craft_status() diff --git a/tests/_carrier_crafter_preview.tscn b/tests/_carrier_crafter_preview.tscn new file mode 100644 index 0000000..76c339a --- /dev/null +++ b/tests/_carrier_crafter_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_carrier_crafter_preview.gd" id="1"] + +[node name="CarrierCrafterPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_carrier_hangar_preview.gd b/tests/_carrier_hangar_preview.gd new file mode 100644 index 0000000..2bd4a83 --- /dev/null +++ b/tests/_carrier_hangar_preview.gd @@ -0,0 +1,21 @@ +extends Node2D + +# Preview, not a test - #57's hangar menu screenshot with all 4 fleet +# vessels researched, one of each stored, and a carrier crewed into the +# fleet so both rows show the new entry. +# +# bash tests/screenshot.sh res://tests/_carrier_hangar_preview.tscn /tmp/hangar.png 20 720x1280 + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.rocks = 500 + GameState.vessel_levels = {"unlock_shield": 1, "unlock_gas_bomber": 1, "unlock_carrier": 1} + GameState.stored_vessels = ["shield", "gas_bomber", "carrier"] + var main: Node = load("res://Main.tscn").instantiate() + add_child(main) + var fleet: Node = main.get_node("Fleet") + fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray(["interceptor", "carrier"])) + main.get_node("UI/HangarMenu").visible = true + main.get_node("BuildController")._refresh_hangar() diff --git a/tests/_carrier_hangar_preview.tscn b/tests/_carrier_hangar_preview.tscn new file mode 100644 index 0000000..105d0a8 --- /dev/null +++ b/tests/_carrier_hangar_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_carrier_hangar_preview.gd" id="1"] + +[node name="CarrierHangarPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_carrier_vessel.gd b/tests/test_carrier_vessel.gd new file mode 100644 index 0000000..adf159b --- /dev/null +++ b/tests/test_carrier_vessel.gd @@ -0,0 +1,239 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_carrier_vessel.gd +# Forgejo #57: an unarmed hauler that substantially raises how much the +# fleet can carry (#56). +# +# Covers: +# A. crewing a carrier raises GameState.fleet_cargo_capacity() by its stat +# B. a carrier never fires - no weapon component, no projectile ever +# originates from it even with an enemy parked in interceptor fire range +# C. it is unbuildable until researched +# D. it appears in the crafter, hangar and ship lab with correct name, +# cost, icon and colour, and is buildable once researched + +var game_state: Node +var scene: Node +var fleet: Node +var controller: Node +var field: Node +var crafter_id: int = 0 +var mothership_id: int = 0 +var definition: Resource +var capacity_before: int = 0 +var enemy: Node +var stage: int = 0 +var elapsed: float = 0.0 + +const MOTHERSHIP_AT: Vector2 = Vector2(0.0, 0.0) +const CRAFTER_AT: Vector2 = Vector2(3000.0, 0.0) +# Same slot the crafter/hangar UI arrays give carrier: VesselDefinitions.ORDER +# appends carrier after transporter, and VesselDefinitions.fleet() filters +# transporter out, so carrier lands last among the 4 fleet-eligible vessels +# (interceptor, shield, gas_bomber, carrier) in both craft_vessel_* and the +# hangar_* arrays. +const FLEET_UI_INDEX: int = 3 +# The ship lab shows every vessel including the transporter, so carrier is +# the 5th and last slot there. +const LAB_UI_INDEX: int = 4 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.stored_vessels.clear() + game_state.reset_progression() + game_state.build_time_scale = 0.02 + game_state.rocks = 500 + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + controller = scene.get_node("BuildController") + + +func _clear_hazards() -> void: + for a in get_nodes_in_group("asteroids"): + if is_instance_valid(a): + a.queue_free() + for e in get_nodes_in_group("enemies"): + if is_instance_valid(e): + e.queue_free() + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.2: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + _clear_hazards() + + # --- structural: unarmed, cargo-capable, fleet-eligible --------- + definition = game_state.vessel_definition(GameState.VESSEL_CARRIER) + if definition == null or not definition.fleet_eligible: + return _fail("carrier is not a fleet-eligible vessel definition") + if definition.capabilities.has("weapon"): + return _fail("carrier declares a weapon capability") + if not definition.capabilities.has("cargo"): + return _fail("carrier does not declare the cargo capability") + + # --- A. capacity math, directly ----------------------------------- + if game_state.fleet_cargo_capacity(PackedStringArray(["carrier"])) != 120: + return _fail("one carrier's capacity contribution is %d, expected 120" \ + % game_state.fleet_cargo_capacity(PackedStringArray(["carrier"]))) + print("structural: unarmed, cargo capability, contributes 120 alone") + + # --- C. gated until researched -------------------------------- + field = scene.get_node("BuildingField") + field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) + mothership_id = int(game_state.buildings[-1].id) + field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) + crafter_id = int(game_state.buildings[-1].id) + field.place(GameState.BUILDING_SHIP_LAB, CRAFTER_AT + Vector2(0.0, 400.0)) + if game_state.vessel_is_unlocked(GameState.VESSEL_CARRIER): + return _fail("carrier starts unlocked") + if game_state.queue_order(crafter_id, GameState.VESSEL_CARRIER): + return _fail("queue_order accepted an unresearched carrier") + print("unresearched carrier is unbuildable, as expected") + + # --- D. locked state in the crafter UI ------------------------- + controller.craft_building_id = crafter_id + controller._refresh_craft_status() + if controller.craft_vessel_buttons[FLEET_UI_INDEX].enabled: + return _fail("a locked carrier shows enabled in the crafter menu") + + stage = 1 + elapsed = 0.0 + return false + 1: + if elapsed < 0.1: + return false + # --- C continued: unlocking ------------------------------------ + if not game_state.unlock_vessel(GameState.VESSEL_CARRIER): + return _fail("unlock_vessel(carrier) failed with 500 ore in the bank") + if not game_state.vessel_is_unlocked(GameState.VESSEL_CARRIER): + return _fail("carrier still reports locked after unlocking") + if game_state.product_costs(GameState.VESSEL_CARRIER) != {GameState.RESOURCE_ORE: 24}: + return _fail("carrier's build cost is %s, expected 24 ore" \ + % game_state.product_costs(GameState.VESSEL_CARRIER)) + print("researched: unlocked, costs %s" % game_state.product_costs(GameState.VESSEL_CARRIER)) + + # --- D. unlocked state in the crafter UI ------------------------ + controller._refresh_craft_status() + var craft_button: IconButton = controller.craft_vessel_buttons[FLEET_UI_INDEX] + var craft_label: Label = controller.craft_vessel_labels[FLEET_UI_INDEX] + if not craft_button.enabled: + return _fail("an unlocked carrier still shows disabled in the crafter menu") + if craft_button.icon != definition.icon or craft_button.glow_color != definition.glow_color: + return _fail("crafter carrier button does not match its definition's icon/colour") + if not craft_label.text.begins_with(definition.display_name): + return _fail("crafter carrier label reads '%s', expected to start with '%s'" \ + % [craft_label.text, definition.display_name]) + if not game_state.queue_order(crafter_id, GameState.VESSEL_CARRIER): + return _fail("queue_order rejected a researched carrier") + print("crafter: enabled, correct icon/colour, correct label, buildable") + + # Bypass the build timer/delivery wait - that queue mechanism is + # generic and already covered for every other vessel + # (test_building_stock_queue.gd, test_resources_and_buildings.gd + # etc.); this ticket is about the carrier's OWN behaviour, not + # re-proving crafting works. + game_state.stored_vessels.append(GameState.VESSEL_CARRIER) + + # --- D. hangar display ------------------------------------------- + controller._refresh_hangar() + var available_slot: IconButton = controller.hangar_available_slots[FLEET_UI_INDEX] + var available_label: Label = controller.hangar_available_labels[FLEET_UI_INDEX] + if available_slot.icon != definition.icon or available_slot.glow_color != definition.glow_color: + return _fail("hangar carrier slot does not match its definition's icon/colour") + var expected_available: String = "%s\n1 available" % definition.display_name + if available_label.text != expected_available: + return _fail("hangar carrier label reads '%s', expected '%s'" \ + % [available_label.text, expected_available]) + print("hangar: correct icon/colour, shows 1 available") + + # --- A continued: crewing it raises capacity --------------------- + # The default fleet (5 interceptors) already sits at + # max_fleet_size() with reset_progression()'s cleared skills, which + # would make _on_hangar_crew_type refuse for being full - shrink + # to 1 ship first so there is room to crew the carrier at all. + fleet.spawn_ships(1, MOTHERSHIP_AT, PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + capacity_before = game_state.fleet_cargo_total_capacity() + controller._on_hangar_crew_type(FLEET_UI_INDEX) + if not fleet.has_vessel_type(GameState.VESSEL_CARRIER): + return _fail("crewing from the hangar did not add a carrier to the fleet") + var capacity_after: int = game_state.fleet_cargo_total_capacity() + if capacity_after != capacity_before + 120: + return _fail("crewing a carrier moved capacity %d -> %d, expected +120" \ + % [capacity_before, capacity_after]) + print("crewed: capacity %d -> %d (+120)" % [capacity_before, capacity_after]) + + # --- D. ship lab display ------------------------------------------- + controller.lab_vessel = GameState.VESSEL_CARRIER + controller._refresh_lab() + var lab_button: IconButton = controller.lab_vessel_buttons[LAB_UI_INDEX] + var lab_label: Label = controller.lab_vessel_labels[LAB_UI_INDEX] + if lab_button.icon != definition.icon or lab_button.glow_color != definition.glow_color: + return _fail("ship lab carrier slot does not match its definition's icon/colour") + if lab_label.text != definition.display_name: + return _fail("ship lab carrier label reads '%s', expected '%s'" \ + % [lab_label.text, definition.display_name]) + print("ship lab: correct icon/colour/label") + + stage = 2 + elapsed = 0.0 + return false + 2: + # --- B. never fires, even with a target in range ----------------- + # Isolate: JUST a carrier in the fleet, nothing else nearby that + # could fire instead and make the check meaningless. + fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_CARRIER])) + stage = 3 + elapsed = 0.0 + return false + 3: + # A settling tick before the enemy appears: queue_free() on the + # ships spawn_ships() just replaced does not remove them from the + # tree instantly, so a stale interceptor's weapon could otherwise + # still get one last physics tick against a target added in the + # same frame - a false positive, not the carrier firing. + if elapsed < 0.3: + return false + var enemy_scene: PackedScene = load("res://Enemy.tscn") + enemy = enemy_scene.instantiate() + enemy.position = fleet.get_centroid() + Vector2(220.0, 0.0) + scene.add_child(enemy) + # Frozen in place: a chaser closes distance and kills on contact, + # and an unarmed carrier has nothing to stop that. Letting it + # approach would wipe the fleet, trip the "rebuild at the + # mothership" safety net (a default INTERCEPTOR), and that + # interceptor firing would look like a carrier-firing false + # positive - this test is about whether the carrier fires, not + # whether it can survive unescorted. + enemy.set_physics_process(false) + stage = 4 + elapsed = 0.0 + return false + 4: + if elapsed < 2.0: + return false + var projectiles: Array = get_nodes_in_group("player_projectiles") + if not projectiles.is_empty(): + return _fail("%d projectile(s) originated from a fleet made only of a carrier" \ + % projectiles.size()) + print("no projectiles after %.1fs with an enemy in interceptor fire range" % elapsed) + print("TEST_CARRIER_VESSEL: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_CARRIER_VESSEL: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_vessel_composition.gd b/tests/test_vessel_composition.gd index e11811f..aaa425f 100644 --- a/tests/test_vessel_composition.gd +++ b/tests/test_vessel_composition.gd @@ -23,7 +23,7 @@ func _process(delta: float) -> bool: return false var definitions: Array = VESSEL_DEFINITIONS.all() var fleet_definitions: Array = VESSEL_DEFINITIONS.fleet() - if fleet_definitions.size() != 3 \ + if fleet_definitions.size() != 4 \ or fleet_definitions.any(func(definition): return definition.has_capability("transport")): return _fail("logistics transporters leaked into the combat-fleet catalog") if ships.size() != definitions.size():