diff --git a/BuildController.gd b/BuildController.gd index 2bc0179..1266c18 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -28,6 +28,20 @@ extends Node2D @onready var build_status: Label = get_node("../UI/BuildMenu/StatusLabel") @onready var build_extractor_button: IconButton = get_node("../UI/BuildMenu/ExtractorButton") @onready var build_hub_button: IconButton = get_node("../UI/BuildMenu/TransportHubButton") +@onready var build_lab_button: IconButton = get_node("../UI/BuildMenu/ShipLabButton") +@onready var lab_menu: Control = get_node("../UI/LabMenu") +@onready var lab_vessel_buttons: Array = [ + get_node("../UI/LabMenu/VesselA"), get_node("../UI/LabMenu/VesselB"), + get_node("../UI/LabMenu/VesselC"), +] +@onready var lab_vessel_label: Label = get_node("../UI/LabMenu/VesselLabel") +@onready var lab_list: Label = get_node("../UI/LabMenu/UpgradeList") +@onready var lab_detail: Label = get_node("../UI/LabMenu/DetailLabel") +@onready var lab_status: Label = get_node("../UI/LabMenu/StatusLabel") +@onready var lab_next_button: IconButton = get_node("../UI/LabMenu/NextButton") +@onready var lab_buy_button: IconButton = get_node("../UI/LabMenu/BuyButton") +@onready var lab_close_button: IconButton = get_node("../UI/LabMenu/CloseButton") +@onready var craft_shield_button: IconButton = get_node("../UI/CraftMenu/ShieldButton") @onready var build_menu_entries: Array = [ [GameState.BUILDING_MOTHERSHIP, build_mothership_button, get_node("../UI/BuildMenu/MothershipLabel"), get_node("../UI/BuildMenu/MothershipCost")], @@ -39,7 +53,16 @@ extends Node2D get_node("../UI/BuildMenu/ExtractorLabel"), get_node("../UI/BuildMenu/ExtractorCost")], [GameState.BUILDING_TRANSPORT_HUB, build_hub_button, get_node("../UI/BuildMenu/TransportHubLabel"), get_node("../UI/BuildMenu/TransportHubCost")], + [GameState.BUILDING_SHIP_LAB, build_lab_button, + get_node("../UI/BuildMenu/ShipLabLabel"), get_node("../UI/BuildMenu/ShipLabCost")], ] + +# Which vessel the lab screen is showing, and which of its upgrades is +# selected. The screen steps through one upgrade at a time rather than +# listing tappable rows: on a phone a row of small hit targets is worse than +# one big Buy button and a Next. +var lab_vessel: String = GameState.VESSEL_INTERCEPTOR +var lab_choice: int = 0 @onready var planet_field: Node2D = get_node("../PlanetField") @onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup") @onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel") @@ -92,6 +115,10 @@ const BUILDING_INFO: Dictionary = { "title": "Command Center", "body": "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it.", }, + GameState.BUILDING_SHIP_LAB: { + "title": "Ship Lab", + "body": "Fly the fleet onto it to upgrade each vessel type, and to unlock the shield vessel.", + }, GameState.BUILDING_TRANSPORT_HUB: { "title": "Transport Hub", "body": "Stations transport vessels. They fetch ore from your mothership and deliver it to whichever building is waiting on it.", @@ -127,6 +154,13 @@ func _ready() -> void: build_command_button.pressed.connect(_on_place_command_center) build_extractor_button.pressed.connect(_on_place_extractor) build_hub_button.pressed.connect(_on_place_transport_hub) + build_lab_button.pressed.connect(_on_place_ship_lab) + craft_shield_button.pressed.connect(_on_craft_shield) + lab_close_button.pressed.connect(func(): lab_menu.visible = false) + lab_next_button.pressed.connect(_on_lab_next) + lab_buy_button.pressed.connect(_on_lab_buy) + for i in range(lab_vessel_buttons.size()): + lab_vessel_buttons[i].pressed.connect(_on_lab_vessel.bind(i)) info_close_button.pressed.connect(_close_info) info_build_button.pressed.connect(_confirm_build) @@ -151,6 +185,7 @@ func _ready() -> void: craft_menu.visible = false formation_menu.visible = false skill_menu.visible = false + lab_menu.visible = false action_button.visible = false @@ -183,7 +218,7 @@ func _unhandled_input(event: InputEvent) -> void: formation_editor.handle_drag(event) get_viewport().set_input_as_handled() return - if build_menu.visible or craft_menu.visible or world_map.visible: + if build_menu.visible or craft_menu.visible or world_map.visible or lab_menu.visible: # These panels have no raw-input handler of their own, but the world # behind them must still not react. get_viewport().set_input_as_handled() @@ -205,7 +240,7 @@ func _process(delta: float) -> void: _refresh_craft_status() if world_map.visible or build_menu.visible or craft_menu.visible \ - or formation_menu.visible or skill_menu.visible: + or formation_menu.visible or skill_menu.visible or lab_menu.visible: current_building = null pending_building = null action_button.visible = false @@ -237,6 +272,8 @@ func _action_icon_for(type: String) -> int: return IconButton.Icon.SHIP GameState.BUILDING_TRANSPORT_HUB: return IconButton.Icon.TRANSPORT_HUB + GameState.BUILDING_SHIP_LAB: + return IconButton.Icon.SHIP_LAB GameState.BUILDING_MOTHERSHIP: return IconButton.Icon.MOTHERSHIP _: @@ -262,6 +299,9 @@ func _on_action_pressed() -> void: elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: craft_menu.visible = true _refresh_craft_status() + elif current_building.building_type == GameState.BUILDING_SHIP_LAB: + lab_menu.visible = true + _refresh_lab() elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER: formation_menu.visible = true formation_editor.queue_redraw() @@ -354,6 +394,10 @@ func _on_place_command_center() -> void: _show_building_info(GameState.BUILDING_COMMAND_CENTER) +func _on_place_ship_lab() -> void: + _show_building_info(GameState.BUILDING_SHIP_LAB) + + func _on_place_transport_hub() -> void: _show_building_info(GameState.BUILDING_TRANSPORT_HUB) @@ -368,6 +412,8 @@ func cost_of(type: String) -> int: match type: GameState.BUILDING_TRANSPORT_HUB: return GameState.TRANSPORT_HUB_COST + GameState.BUILDING_SHIP_LAB: + return GameState.SHIP_LAB_COST GameState.BUILDING_MOTHERSHIP: return GameState.MOTHERSHIP_COST GameState.BUILDING_SHIP_CRAFTER: @@ -547,6 +593,8 @@ func _refresh_craft_status() -> void: and GameState.can_station_transporter() craft_transporter_button.queue_redraw() + craft_shield_button.enabled = GameState.shield_vessel_unlocked() + craft_shield_button.queue_redraw() craft_store_label.text = "Store ore here\nHold %d" % GameState.fleet_cargo # Deliberately left ENABLED with an empty hold: a greyed-out button that # cannot say why is exactly the dead-button problem this panel already @@ -645,6 +693,118 @@ func _on_craft_transporter() -> void: _refresh_craft_status() +# --- ship lab (#5) -------------------------------------------------------- + +func _lab_options() -> Array: + # The shield VESSEL unlock is offered on the shield page as an extra + # 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}) + for u in GameState.upgrades_for(lab_vessel): + options.append({"unlock": false, "id": u.id, "name": u.name, "desc": u.desc}) + return options + + +func _on_lab_vessel(index: int) -> void: + lab_vessel = [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, + GameState.VESSEL_TRANSPORTER][index] + lab_choice = 0 + _refresh_lab() + + +func _on_lab_next() -> void: + var options: Array = _lab_options() + if options.is_empty(): + return + lab_choice = (lab_choice + 1) % options.size() + _refresh_lab() + + +func _on_lab_buy() -> void: + var options: Array = _lab_options() + if options.is_empty(): + return + var choice: Dictionary = options[lab_choice] + if choice.unlock: + if GameState.unlock_shield_vessel(): + lab_status.text = "Shield vessel unlocked - build one at a ship crafter" + else: + lab_status.text = "Not enough ore: needs %d, you have %d" % [ + GameState.UNLOCK_SHIELD_COST, GameState.rocks + ] + lab_choice = 0 + _refresh_lab() + return + var id: String = choice.id + if GameState.upgrade_maxed(id): + lab_status.text = "%s is already fully researched" % choice.name + return + var cost: int = GameState.upgrade_cost(id) + if not GameState.buy_upgrade(id): + lab_status.text = "Not enough ore: needs %d, you have %d" % [cost, GameState.rocks] + return + lab_status.text = "%s is now level %d" % [choice.name, GameState.upgrade_level(id)] + _refresh_lab() + + +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] + lab_vessel_buttons[i].enabled = this_vessel != lab_vessel + lab_vessel_buttons[i].queue_redraw() + lab_vessel_label.text = GameState.VESSEL_NAMES.get(lab_vessel, lab_vessel) + + var options: Array = _lab_options() + if options.is_empty(): + lab_list.text = "" + lab_detail.text = "" + lab_buy_button.enabled = false + return + lab_choice = clampi(lab_choice, 0, options.size() - 1) + + # The whole page at a glance, with the selected line marked - so the + # player can see what else is there without stepping through it. + var lines: Array = [] + for i in range(options.size()): + var o: Dictionary = options[i] + var marker: String = ">" if i == lab_choice else " " + if o.unlock: + lines.append("%s %s %d ore" % [marker, o.name, GameState.UNLOCK_SHIELD_COST]) + else: + var lvl: int = GameState.upgrade_level(o.id) + var max_lvl: int = int(GameState.upgrade_def(o.id).max_level) + var price: String = "MAX" if GameState.upgrade_maxed(o.id) else "%d ore" % GameState.upgrade_cost(o.id) + lines.append("%s %s %d/%d %s" % [marker, o.name, lvl, max_lvl, price]) + lab_list.text = "\n".join(lines) + + var choice: Dictionary = options[lab_choice] + lab_detail.text = "%s\n%s" % [choice.name, choice.desc] + if choice.unlock: + lab_buy_button.enabled = GameState.can_afford(GameState.UNLOCK_SHIELD_COST) + else: + lab_buy_button.enabled = GameState.can_buy_upgrade(choice.id) + lab_buy_button.queue_redraw() + + +func _on_craft_shield() -> void: + if craft_building_id == 0: + return + if not GameState.shield_vessel_unlocked(): + _say("Shield vessels must be unlocked at a ship lab first") + return + if fleet.ships.size() + GameState.vessels_owed.size() \ + + GameState.pending_orders(craft_building_id) >= GameState.max_fleet_size(): + _say("Fleet is full (%d slots)" % GameState.max_fleet_size()) + return + GameState.queue_order(craft_building_id, GameState.VESSEL_SHIELD) + _report_order(GameState.VESSEL_SHIELD) + + func _on_formation_changed() -> void: fleet.apply_formation() diff --git a/Building.gd b/Building.gd index 54e5d1a..d4387d7 100644 --- a/Building.gd +++ b/Building.gd @@ -29,6 +29,16 @@ const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55) const CRAFTER_COLOR: Color = Color(1.0, 0.72, 0.25) const COMMAND_COLOR: Color = Color(0.55, 0.6, 1.0) const TRANSPORT_HUB_COLOR: Color = Color(0.95, 0.55, 0.85) +const SHIP_LAB_COLOR: Color = Color(0.55, 0.95, 0.9) + +# Ship lab: a test frame with a vessel held in it and instruments sweeping +# over it. Reads as a workshop rather than a factory - the crafter already +# owns "assembly line", so this is measurement, not production. +const LAB_FRAME_W: float = 78.0 +const LAB_FRAME_H: float = 58.0 +const LAB_MOUNT_RADIUS: float = 15.0 +const LAB_SCANNER_ORBIT: float = 96.0 +const LAB_SCAN_SPEED: float = 0.9 # Transport hub: an octagonal dock ring with berths around it and a freight # core in the middle. Deliberately radial-but-flat, so it does not read as a @@ -100,6 +110,8 @@ var _hull_holder: Node2D = null var _hull_pieces: Array = [] # Rotating holder for the transports stationed at a transport hub (#18). var _hub_traffic: Node2D = null +# Sweeping instrument booms at a ship lab (#5). +var _lab_scanners: Node2D = null var _flash_ring: NeonPiece = null var _wash: NeonPiece = null @@ -120,6 +132,8 @@ func _ready() -> void: _build_mothership_pieces() elif building_type == GameState.BUILDING_TRANSPORT_HUB: _build_hub_pieces() + elif building_type == GameState.BUILDING_SHIP_LAB: + _build_lab_pieces() queue_redraw() @@ -140,6 +154,8 @@ func apply_animation() -> void: _animate_crafter() GameState.BUILDING_TRANSPORT_HUB: _animate_transport_hub() + GameState.BUILDING_SHIP_LAB: + _animate_ship_lab() # Where the satellites are, and how open the reactor core is. Both are read @@ -182,6 +198,8 @@ func _draw() -> void: _draw_mothership_static(self) GameState.BUILDING_TRANSPORT_HUB: _draw_transport_hub_static(self) + GameState.BUILDING_SHIP_LAB: + _draw_ship_lab_static(self) _: _draw_crafter_static(self) @@ -224,6 +242,57 @@ const CMD_TOKEN_RADIUS: float = 9.0 const CMD_STUD_RADIUS: float = 7.0 +# --- ship lab ------------------------------------------------------------- + +func _draw_ship_lab_static(target: CanvasItem) -> void: + var c: Color = SHIP_LAB_COLOR + + # Outer test frame, open at the corners so it reads as a rig rather than + # a solid hull. + for sx in [-1.0, 1.0]: + for sy in [-1.0, 1.0]: + var corner: Vector2 = Vector2(sx * LAB_FRAME_W, sy * LAB_FRAME_H) + NeonDraw.draw_glowing_line(target, corner, + corner - Vector2(sx * LAB_FRAME_W * 0.45, 0.0), c, 0.9, 0.42) + NeonDraw.draw_glowing_line(target, corner, + corner - Vector2(0.0, sy * LAB_FRAME_H * 0.5), c, 0.9, 0.42) + + # The vessel under test, held nose-up in the middle - deliberately the + # fleet's own silhouette, so it is obvious what the lab is for. + NeonDraw.draw_glowing_polygon(target, PackedVector2Array([ + Vector2(0, -26), Vector2(13, 12), Vector2(0, 5), Vector2(-13, 12), + ]), HULL_COLOR, 0.95) + + # Mount ring and its bracing. + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, LAB_MOUNT_RADIUS, 6, PI / 6.0), c, 0.5, + NeonDraw.DEFAULT_CORE, 0.7) + for i in range(4): + var a: float = (float(i) / 4.0) * TAU + PI / 4.0 + var dir: Vector2 = Vector2(cos(a), sin(a)) + NeonDraw.draw_glowing_line(target, dir * LAB_MOUNT_RADIUS, dir * 40.0, c, 0.45, 0.3) + + +func _build_lab_pieces() -> void: + # Two instrument booms sweeping round the vessel. One holder, rasterised + # once, rotated per frame (#10). + _lab_scanners = Node2D.new() + add_child(_lab_scanners) + for i in range(2): + var a: float = float(i) * PI + var at: Vector2 = Vector2(cos(a), sin(a)) * LAB_SCANNER_ORBIT + var piece: NeonPiece = NeonPiece.create(_lab_scanners, at, SHIP_LAB_COLOR) + piece.add_polygon(PackedVector2Array([ + Vector2(-4, -12), Vector2(4, -12), Vector2(4, 12), Vector2(-4, 12), + ])) + piece.add_line(Vector2(0, 0), Vector2(-cos(a), -sin(a)) * 26.0, 0.3, 0.5) + piece.rotation = a + piece.queue_redraw() + + +func _animate_ship_lab() -> void: + _lab_scanners.rotation = phase_time * LAB_SCAN_SPEED + + # --- transport hub -------------------------------------------------------- func _draw_transport_hub_static(target: CanvasItem) -> void: diff --git a/IconButton.gd b/IconButton.gd index 7dd8ea3..999e2fe 100644 --- a/IconButton.gd +++ b/IconButton.gd @@ -15,7 +15,7 @@ enum Icon { # build menu used to pick from the generic set, which left the mothership # and the command centre sharing a folded-map glyph and neither of them # looking anything like the structure they build. - MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR, TRANSPORT_HUB, + MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR, TRANSPORT_HUB, SHIP_LAB, } # Size the glyphs were originally drawn against. Every glyph dimension is @@ -139,6 +139,8 @@ func _draw() -> void: _draw_extractor_icon(center, pulse, s) Icon.TRANSPORT_HUB: _draw_transport_hub_icon(center, pulse, s) + Icon.SHIP_LAB: + _draw_ship_lab_icon(center, pulse, s) func _draw_close(center: Vector2, pulse: float, s: float) -> void: @@ -224,6 +226,22 @@ func _draw_mothership_icon(center: Vector2, pulse: float, s: float) -> void: NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color) +func _draw_ship_lab_icon(center: Vector2, pulse: float, s: float) -> void: + # A vessel inside brackets: something being examined, not built. + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + center + Vector2(0, -9) * s, center + Vector2(5, 5) * s, + center + Vector2(0, 2) * s, center + Vector2(-5, 5) * s, + ]), _active_color(), pulse, core_color) + for sx in [-1.0, 1.0]: + var x: float = sx * 11.0 * s + NeonDraw.draw_glowing_line(self, center + Vector2(x, -11 * s), center + Vector2(x, 11 * s), + _active_color(), pulse, 0.35 * s, core_color) + NeonDraw.draw_glowing_line(self, center + Vector2(x, -11 * s), center + Vector2(x - sx * 4.0 * s, -11 * s), + _active_color(), pulse, 0.35 * s, core_color) + NeonDraw.draw_glowing_line(self, center + Vector2(x, 11 * s), center + Vector2(x - sx * 4.0 * s, 11 * s), + _active_color(), pulse, 0.35 * s, core_color) + + func _draw_transport_hub_icon(center: Vector2, pulse: float, s: float) -> void: # A ring of docking cradles with a freight box in the middle: reads as # "things are stationed here and go out from here", which is what the hub diff --git a/Main.tscn b/Main.tscn index 88b0a2a..fa9b08a 100644 --- a/Main.tscn +++ b/Main.tscn @@ -479,6 +479,48 @@ script = ExtResource("19") amount = 30 digit_height = 40.0 +[node name="ShipLabButton" type="Control" parent="UI/BuildMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 76 +offset_right = 204 +offset_top = 280 +offset_bottom = 408 +script = ExtResource("10") +icon = 13 +glow_color = Color(0.55, 0.95, 0.9, 1) + +[node name="ShipLabLabel" type="Label" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -10 +offset_right = 290 +offset_top = 414 +offset_bottom = 480 +theme_override_font_sizes/font_size = 32 +autowrap_mode = 2 +text = "Ship Lab" +horizontal_alignment = 1 + +[node name="ShipLabCost" type="Control" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 80 +offset_right = 200 +offset_top = 484 +offset_bottom = 536 +script = ExtResource("19") +amount = 35 +digit_height = 40.0 + [node name="CloseButton" type="Control" parent="UI/BuildMenu"] anchor_left = 1.0 anchor_right = 1.0 @@ -576,6 +618,162 @@ script = ExtResource("10") icon = 1 glow_color = Color(0, 0.62, 0.7, 1) +[node name="LabMenu" type="Control" parent="UI" groups=["modal_menus"]] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Backdrop" type="ColorRect" parent="UI/LabMenu"] +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.03, 0.03, 0.08, 0.94) + +[node name="TitleLabel" type="Label" parent="UI/LabMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24 +offset_top = 60 +offset_right = -24 +offset_bottom = 170 +text = "Ship Lab" +horizontal_alignment = 1 + +[node name="VesselA" type="Control" parent="UI/LabMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -290 +offset_right = -170 +offset_top = 190 +offset_bottom = 310 +script = ExtResource("10") +icon = 2 +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 = -60 +offset_right = 60 +offset_top = 190 +offset_bottom = 310 +script = ExtResource("10") +icon = 2 +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 = 170 +offset_right = 290 +offset_top = 190 +offset_bottom = 310 +script = ExtResource("10") +icon = 12 +glow_color = Color(0.95, 0.55, 0.85, 1) + +[node name="VesselLabel" type="Label" parent="UI/LabMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24 +offset_top = 318 +offset_right = -24 +offset_bottom = 380 +theme_override_font_sizes/font_size = 34 +horizontal_alignment = 1 +text = "" + +[node name="UpgradeList" type="Label" parent="UI/LabMenu"] +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 32 +offset_top = 396 +offset_right = -32 +offset_bottom = -470 +theme_override_font_sizes/font_size = 30 +autowrap_mode = 2 +text = "" + +[node name="DetailLabel" type="Label" parent="UI/LabMenu"] +mouse_filter = 2 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 32 +offset_top = -460 +offset_right = -32 +offset_bottom = -330 +theme_override_font_sizes/font_size = 32 +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" + +[node name="StatusLabel" type="Label" parent="UI/LabMenu"] +mouse_filter = 2 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 32 +offset_top = -324 +offset_right = -32 +offset_bottom = -256 +theme_override_font_sizes/font_size = 30 +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" + +[node name="NextButton" type="Control" parent="UI/LabMenu"] +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -230 +offset_right = -110 +offset_top = -240 +offset_bottom = -120 +script = ExtResource("10") +icon = 6 +glow_color = Color(0.55, 0.95, 0.9, 1) + +[node name="BuyButton" type="Control" parent="UI/LabMenu"] +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = 110 +offset_right = 230 +offset_top = -240 +offset_bottom = -120 +script = ExtResource("10") +icon = 4 +glow_color = Color(0.45, 0.9, 0.55, 1) + +[node name="HintLabel" type="Label" parent="UI/LabMenu"] +mouse_filter = 2 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 24 +offset_top = -110 +offset_right = -24 +offset_bottom = -20 +theme_override_font_sizes/font_size = 28 +autowrap_mode = 2 +horizontal_alignment = 1 +text = "Next upgrade Buy" + +[node name="CloseButton" type="Control" parent="UI/LabMenu"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -172 +offset_top = 24 +offset_right = -24 +offset_bottom = 172 +script = ExtResource("10") +icon = 1 +glow_color = Color(0.55, 0.95, 0.9, 1) + [node name="CraftMenu" type="Control" parent="UI" groups=["modal_menus"]] visible = false anchor_right = 1.0 @@ -662,6 +860,34 @@ autowrap_mode = 2 text = "Transporter" horizontal_alignment = 1 +[node name="ShieldButton" type="Control" parent="UI/CraftMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -74 +offset_right = 74 +offset_top = -260 +offset_bottom = -112 +script = ExtResource("10") +icon = 2 +glow_color = Color(0.45, 0.8, 1, 1) + +[node name="ShieldLabel" 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 = -160 +offset_right = 160 +offset_top = -104 +offset_bottom = -44 +theme_override_font_sizes/font_size = 32 +autowrap_mode = 2 +text = "Shield Vessel" +horizontal_alignment = 1 + [node name="StoreOreButton" type="Control" parent="UI/CraftMenu"] anchor_left = 0.5 anchor_top = 1.0 diff --git a/WorldMapContent.gd b/WorldMapContent.gd index b8b1ad2..e8ba8df 100644 --- a/WorldMapContent.gd +++ b/WorldMapContent.gd @@ -11,6 +11,7 @@ const MOTHERSHIP_MARKER_COLOR: Color = Color(0.45, 0.9, 0.55) const CRAFTER_MARKER_COLOR: Color = Color(1.0, 0.72, 0.25) const COMMAND_MARKER_COLOR: Color = Color(0.55, 0.6, 1.0) const TRANSPORT_HUB_MARKER_COLOR: Color = Color(0.95, 0.55, 0.85) +const SHIP_LAB_MARKER_COLOR: Color = Color(0.55, 0.95, 0.9) # A structure built on a planet's surface (see GameState.extractors) is not # itself a map marker - it is a small badge on the existing planet marker, @@ -53,6 +54,8 @@ func _building_marker_color(type: String) -> Color: return CRAFTER_MARKER_COLOR GameState.BUILDING_TRANSPORT_HUB: return TRANSPORT_HUB_MARKER_COLOR + GameState.BUILDING_SHIP_LAB: + return SHIP_LAB_MARKER_COLOR _: return COMMAND_MARKER_COLOR @@ -120,6 +123,8 @@ func _draw() -> void: _draw_marker_polygon(_regular_polygon(marker.position, 7.0, 4, 0.0), marker.color) GameState.BUILDING_TRANSPORT_HUB: _draw_marker_polygon(_regular_polygon(marker.position, 7.5, 8, PI / 8.0), marker.color) + GameState.BUILDING_SHIP_LAB: + _draw_marker_polygon(_regular_polygon(marker.position, 7.5, 3, -PI / 2.0), marker.color) _: _draw_marker_polygon(_regular_polygon(marker.position, 6.0, 4, PI / 4.0), marker.color) diff --git a/tests/_ingame_buildings_preview.gd b/tests/_ingame_buildings_preview.gd index d586710..4e6a40b 100644 --- a/tests/_ingame_buildings_preview.gd +++ b/tests/_ingame_buildings_preview.gd @@ -16,6 +16,7 @@ func _ready() -> void: GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-330.0, 0.0)) GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(140.0, 0.0)) GameState.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(480.0, 0.0)) + GameState.add_building(GameState.BUILDING_SHIP_LAB, Vector2(820.0, 0.0)) var main: Node = load("res://Main.tscn").instantiate() add_child(main) @@ -23,6 +24,6 @@ func _ready() -> void: main.get_node("EnemySpawner").set_process(false) # Park the fleet below the pair so the camera frames both, and zoom out # far enough to see them whole. - main.get_node("Fleet").move_to(Vector2(0.0, 210.0)) + main.get_node("Fleet").move_to(Vector2(240.0, 260.0)) var cam: Camera2D = main.get_node("Camera2D") - cam.zoom = Vector2(1.0, 1.0) + cam.zoom = Vector2(0.78, 0.78) diff --git a/tests/_lab_menu_preview.gd b/tests/_lab_menu_preview.gd new file mode 100644 index 0000000..31db6d8 --- /dev/null +++ b/tests/_lab_menu_preview.gd @@ -0,0 +1,25 @@ +extends Node2D + +# Preview, not a test. The ship lab's upgrade screen (#5), so its layout can +# be checked on a real viewport - headless draws no pixels. +# +# bash tests/screenshot.sh res://tests/_lab_menu_preview.tscn /tmp/lab.png 20 720x1280 + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.transporters.clear() + GameState.reset_progression() + GameState.rocks = 240 + GameState.unlocked = ["shipyards", "vessel_science"] + var main: Node = load("res://Main.tscn").instantiate() + add_child(main) + main.get_node("BuildingField").place(GameState.BUILDING_SHIP_LAB, Vector2(400.0, 0.0)) + # A level already bought, so the list shows both a researched and an + # unresearched line rather than a uniform page of zeroes. + GameState.vessel_levels["int_damage"] = 1 + var bc: Node = main.get_node("BuildController") + main.get_node("UI/LabMenu").visible = true + bc.lab_vessel = GameState.VESSEL_INTERCEPTOR + bc._refresh_lab() diff --git a/tests/_lab_menu_preview.gd.uid b/tests/_lab_menu_preview.gd.uid new file mode 100644 index 0000000..f27f4bd --- /dev/null +++ b/tests/_lab_menu_preview.gd.uid @@ -0,0 +1 @@ +uid://dlueabpa84uyf diff --git a/tests/_lab_menu_preview.tscn b/tests/_lab_menu_preview.tscn new file mode 100644 index 0000000..f6ee7ee --- /dev/null +++ b/tests/_lab_menu_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_lab_menu_preview.gd" id="1"] + +[node name="LabMenuPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_shield_vessel.gd b/tests/test_shield_vessel.gd new file mode 100644 index 0000000..bab46c3 --- /dev/null +++ b/tests/test_shield_vessel.gd @@ -0,0 +1,181 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_shield_vessel.gd +# Forgejo #5: the shield vessel's shield absorbs a hit that would destroy any +# other ship, and is then down until it cycles back up. +# +# The shield is PERSONAL - it protects the vessel carrying it and nothing +# else. Ships have no health in this game: contact with an enemy or an +# asteroid destroys them outright, so "absorbs a hit" means surviving the +# collision that would otherwise have killed it. +# +# Verifies: +# 1. an ordinary interceptor still dies to a rock (so the shield result +# below means something) +# 2. a shield vessel survives the same rock, and the rock is destroyed +# 3. its shield is then DOWN - a second rock while it is cycling kills it, +# or the shield would be permanent invulnerability +# 4. the shield comes back on its own after the recharge time +# 5. researched capacity lets it take more than one hit +# 6. the recharge upgrade actually shortens the wait + +var game_state: Node +var scene: Node +var fleet: Node +var interceptor: Node +var shield_ship: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.vessels_owed.clear() + game_state.reset_progression() + game_state.rocks = 2000 + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + + +func _rock_onto(ship: Node) -> void: + var rock: Area2D = load("res://Asteroid.tscn").instantiate() + rock.velocity = Vector2.ZERO + rock.global_position = ship.global_position + scene.add_child(rock) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + for a in get_nodes_in_group("asteroids"): + if is_instance_valid(a): + a.queue_free() + fleet.spawn_ships(1, Vector2.ZERO) + interceptor = fleet.ships[0] + # Well clear of the interceptor, or one rock hits both. + fleet.add_ship(GameState.VESSEL_SHIELD) + shield_ship = fleet.ships[1] + shield_ship.position = Vector2(2000.0, 0.0) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + if not shield_ship.has_shield(): + print("TEST_SHIELD_VESSEL: FAIL (a fresh shield vessel has no shield)") + quit(1) + return true + # --- 1. the control: an interceptor dies -------------------- + _rock_onto(interceptor) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.4: + return false + if is_instance_valid(interceptor): + print("TEST_SHIELD_VESSEL: FAIL (an ordinary interceptor survived a rock - the test proves nothing)") + quit(1) + return true + print("control: interceptor destroyed by a rock") + + # --- 2. the shield vessel survives it ----------------------- + _rock_onto(shield_ship) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.4: + return false + if not is_instance_valid(shield_ship): + print("TEST_SHIELD_VESSEL: FAIL (the shield did not absorb the hit)") + quit(1) + return true + if shield_ship.has_shield(): + print("TEST_SHIELD_VESSEL: FAIL (shield still up after taking a hit - it never goes down)") + quit(1) + return true + print("shield vessel survived, shield now down") + + # --- 3. defenceless while it cycles ------------------------- + _rock_onto(shield_ship) + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.4: + return false + if is_instance_valid(shield_ship): + print("TEST_SHIELD_VESSEL: FAIL (survived a second hit with the shield down - it is just invulnerable)") + quit(1) + return true + print("second hit while cycling destroyed it, as it should") + + # --- 4 + 5. recharge, and researched capacity --------------- + game_state.vessel_levels["shd_hits"] = 1 + fleet.add_ship(GameState.VESSEL_SHIELD) + shield_ship = fleet.ships[fleet.ships.size() - 1] + shield_ship.position = Vector2(-2000.0, 0.0) + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < 0.2: + return false + if shield_ship.shield_charges != game_state.shield_max_charges(): + print("TEST_SHIELD_VESSEL: FAIL (researched capacity %d, vessel has %d)" % [ + game_state.shield_max_charges(), shield_ship.shield_charges + ]) + quit(1) + return true + print("with Layered Emitters researched: %d charges" % shield_ship.shield_charges) + _rock_onto(shield_ship) + elapsed = 0.0 + stage = 6 + return false + 6: + if elapsed < 0.4: + return false + if not is_instance_valid(shield_ship) or not shield_ship.has_shield(): + print("TEST_SHIELD_VESSEL: FAIL (a two-charge shield did not survive one hit with a charge left)") + quit(1) + return true + print("two-charge shield took a hit and is still up (%d left)" % shield_ship.shield_charges) + + # --- 6. the recharge upgrade shortens the wait -------------- + var slow: float = game_state.shield_recharge_time() + game_state.vessel_levels["shd_recharge"] = 2 + var fast: float = game_state.shield_recharge_time() + print("recharge %.1fs -> %.1fs with Fast Cycling" % [slow, fast]) + if fast >= slow: + print("TEST_SHIELD_VESSEL: FAIL (the recharge upgrade did not shorten the wait)") + quit(1) + return true + + # --- 4. it really does come back ---------------------------- + shield_ship.shield_charges = 0 + shield_ship.shield_recharge_left = 0.15 + elapsed = 0.0 + stage = 7 + return false + 7: + if elapsed < 0.6: + return false + if not shield_ship.has_shield(): + print("TEST_SHIELD_VESSEL: FAIL (the shield never cycled back up)") + quit(1) + return true + print("shield cycled back up on its own") + print("TEST_SHIELD_VESSEL: PASS") + quit(0) + return true + return false diff --git a/tests/test_shield_vessel.gd.uid b/tests/test_shield_vessel.gd.uid new file mode 100644 index 0000000..19ece12 --- /dev/null +++ b/tests/test_shield_vessel.gd.uid @@ -0,0 +1 @@ +uid://bmhp0nqvetr56 diff --git a/tests/test_ship_lab.gd b/tests/test_ship_lab.gd new file mode 100644 index 0000000..8c3b91c --- /dev/null +++ b/tests/test_ship_lab.gd @@ -0,0 +1,177 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_ship_lab.gd +# Forgejo #5: the ship lab researches upgrades per vessel type and unlocks +# new vessel types. The shield vessel is the first of those. +# +# Verifies: +# 1. the lab is gated behind its own research node, like every structure +# 2. upgrades cannot be bought without a lab built - it is a place you go, +# not a menu that exists once researched +# 3. buying raises the level, charges the escalating price, and stops at +# max +# 4. an upgrade improves the ships ALREADY FLYING, not just the next one +# built +# 5. the shield vessel is unlocked at the lab, and only then can the +# crafter build one +# 6. a shield vessel is what comes back from production - a typed order +# must not decay into an interceptor +# 7. upgrades are per vessel type: an interceptor upgrade does not tune +# the transporter + +var game_state: Node +var scene: Node +var fleet: Node +var field: Node +var controller: Node +var crafter_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.vessels_owed.clear() + game_state.reset_progression() + game_state.rocks = 1200 + game_state.fleet_cargo = 0 + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + field = scene.get_node("BuildingField") + controller = scene.get_node("BuildController") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + for a in get_nodes_in_group("asteroids"): + if is_instance_valid(a): + a.queue_free() + + # --- 1. gated behind research ------------------------------- + if game_state.is_building_unlocked(GameState.BUILDING_SHIP_LAB): + print("TEST_SHIP_LAB: FAIL (lab buildable with no research)") + quit(1) + return true + var gate: String = game_state.research_for_building(GameState.BUILDING_SHIP_LAB) + if gate == "": + print("TEST_SHIP_LAB: FAIL (no research node gates the lab)") + quit(1) + return true + print("lab gated behind '%s'" % gate) + + # --- 2. no lab, no upgrades --------------------------------- + if game_state.can_buy_upgrade("int_damage"): + print("TEST_SHIP_LAB: FAIL (upgrades purchasable with no lab built)") + quit(1) + return true + if game_state.buy_upgrade("int_damage"): + print("TEST_SHIP_LAB: FAIL (bought an upgrade with no lab built)") + quit(1) + return true + print("no lab -> no upgrades") + + game_state.unlocked = ["shipyards", "vessel_science", "coordination", "wing", "squadron"] + field.place(GameState.BUILDING_SHIP_LAB, Vector2(-700.0, 0.0)) + field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(700.0, 0.0)) + crafter_id = int(game_state.buildings[-1].id) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + # --- 3. buying raises the level and charges ------------------ + var before_rocks: int = game_state.rocks + var first_cost: int = game_state.upgrade_cost("int_damage") + if not game_state.buy_upgrade("int_damage"): + print("TEST_SHIP_LAB: FAIL (could not buy an upgrade with a lab built)") + quit(1) + return true + if game_state.upgrade_level("int_damage") != 1: + print("TEST_SHIP_LAB: FAIL (level did not rise)") + quit(1) + return true + if game_state.rocks != before_rocks - first_cost: + print("TEST_SHIP_LAB: FAIL (wrong price charged)") + quit(1) + return true + var second_cost: int = game_state.upgrade_cost("int_damage") + if second_cost <= first_cost: + print("TEST_SHIP_LAB: FAIL (level 2 costs %d, no more than level 1 at %d)" % [second_cost, first_cost]) + quit(1) + return true + print("bought level 1 for %d, level 2 now costs %d" % [first_cost, second_cost]) + + # --- 4. it reaches ships already flying ---------------------- + var flying: Node = fleet.ships[0] + if flying.projectile_damage != GameState.SHIP_BASE_DAMAGE + 1: + print("TEST_SHIP_LAB: FAIL (a ship already flying still does %d damage)" % flying.projectile_damage) + quit(1) + return true + print("ship already flying now does %d damage" % flying.projectile_damage) + + # --- 7. per vessel type -------------------------------------- + var trn_speed_before: float = game_state.transporter_speed() + game_state.buy_upgrade("int_speed") + if game_state.transporter_speed() != trn_speed_before: + print("TEST_SHIP_LAB: FAIL (an interceptor upgrade changed the transporter)") + quit(1) + return true + print("interceptor upgrades leave the transporter alone") + + # --- 5. the shield vessel is unlocked at the lab ------------- + if game_state.shield_vessel_unlocked(): + print("TEST_SHIP_LAB: FAIL (shield vessel available before it was unlocked)") + quit(1) + return true + if game_state.queue_order(crafter_id, GameState.VESSEL_SHIELD): + print("TEST_SHIP_LAB: FAIL (crafter took a shield order before the type was unlocked)") + quit(1) + return true + if not game_state.unlock_shield_vessel(): + print("TEST_SHIP_LAB: FAIL (could not unlock the shield vessel at the lab)") + quit(1) + return true + print("shield vessel unlocked at the lab") + + # --- 6. a typed order comes back as that type ---------------- + fleet.spawn_ships(1, Vector2.ZERO) + if not game_state.queue_order(crafter_id, GameState.VESSEL_SHIELD): + print("TEST_SHIP_LAB: FAIL (crafter refused a shield order after unlocking)") + quit(1) + return true + game_state.deposit_to_building(crafter_id, GameState.SHIELD_COST) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.6: + return false + var shield_ship: Node = null + for ship in fleet.ships: + if is_instance_valid(ship) and ship.vessel_type == GameState.VESSEL_SHIELD: + shield_ship = ship + if shield_ship == null: + print("TEST_SHIP_LAB: FAIL (the shield order produced no shield vessel: %d ships, owed %s)" % [ + fleet.ships.size(), game_state.vessels_owed + ]) + quit(1) + return true + if not shield_ship.has_shield(): + print("TEST_SHIP_LAB: FAIL (the shield vessel arrived with no shield up)") + quit(1) + return true + print("shield vessel built, shield up with %d charge(s)" % shield_ship.shield_charges) + print("TEST_SHIP_LAB: PASS") + quit(0) + return true + return false diff --git a/tests/test_ship_lab.gd.uid b/tests/test_ship_lab.gd.uid new file mode 100644 index 0000000..0cab49c --- /dev/null +++ b/tests/test_ship_lab.gd.uid @@ -0,0 +1 @@ +uid://cwwl0p787wqdh