From 116e0f97d38db9406a9b7ab4b19b681af25605e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 16 Aug 2026 17:59:04 +0200 Subject: [PATCH 1/6] Add space-time distorder vessel and fleet speed fader (#59) GameState.fleet_speed_multiplier (1.0-3.0) is applied at the point of use - Fleet's cruise anchor and Ship's own max_speed/acceleration - rather than baked into stored stats, so a stat upgrade researched mid-boost can't corrupt the base value and the fader takes effect immediately with no re-apply step. Ship.arrive_radius is scaled by the same multiplier: at higher speeds each physics tick covers a proportionally larger step, and once that step outgrows a fixed arrive_radius the ship can overshoot its snap-to-target zone every tick while chasing a target that's now also receding faster - visible as the fleet stretching apart under boost. Scaling it preserves the same "snaps onto its slot" tightness at any boost level that 1.0x already has. SpeedFader.gd is a self-contained vertical drag control (TouchJoystick's raw-touch idiom, not _gui_input) that writes straight to the autoload rather than reporting a value for something else to apply. It also resets the multiplier to 1.0 the instant fleet.has_capability("distorder") goes false, so a fleet can never be left permanently boosted by a vessel it no longer has. VesselDistorderComponent carries no per-shot behaviour (energy drain while boosted is #62) - its presence is what matters. Registered in VesselDefinitions, wired into the crafter/hangar/ship-lab menus per #57's note (5/5/6 -> 6/6/7 columns). --- BuildController.gd | 12 +- DistorderVesselDefinition.tres | 24 +++ Fleet.gd | 13 +- GameController.gd | 5 + GameState.gd | 13 ++ IconButton.gd | 16 ++ Main.tscn | 268 +++++++++++++++++++++-------- PlanetSurface.tscn | 27 ++- Ship.gd | 21 ++- SpeedFader.gd | 112 ++++++++++++ VesselDefinitions.gd | 3 +- VesselDistorderComponent.gd | 9 + VesselDistorderComponent.tscn | 6 + tests/test_space_time_distorder.gd | 232 +++++++++++++++++++++++++ 14 files changed, 675 insertions(+), 86 deletions(-) create mode 100644 DistorderVesselDefinition.tres create mode 100644 SpeedFader.gd create mode 100644 VesselDistorderComponent.gd create mode 100644 VesselDistorderComponent.tscn create mode 100644 tests/test_space_time_distorder.gd diff --git a/BuildController.gd b/BuildController.gd index 02de479..c8ed446 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -32,12 +32,14 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") 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"), get_node("../UI/LabMenu/VesselF"), + get_node("../UI/LabMenu/VesselG"), ] @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"), get_node("../UI/LabMenu/VesselFLabel"), + get_node("../UI/LabMenu/VesselGLabel"), ] @onready var lab_detail: Label = get_node("../UI/LabMenu/DetailLabel") @onready var lab_status: Label = get_node("../UI/LabMenu/StatusLabel") @@ -67,6 +69,7 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") get_node("../UI/HangarMenu/AvailableGasBomber"), get_node("../UI/HangarMenu/AvailableCarrier"), get_node("../UI/HangarMenu/AvailableLaser"), + get_node("../UI/HangarMenu/AvailableDistorder"), ] @onready var hangar_available_labels: Array = [ get_node("../UI/HangarMenu/AvailableInterceptorLabel"), @@ -74,6 +77,7 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") get_node("../UI/HangarMenu/AvailableGasBomberLabel"), get_node("../UI/HangarMenu/AvailableCarrierLabel"), get_node("../UI/HangarMenu/AvailableLaserLabel"), + get_node("../UI/HangarMenu/AvailableDistorderLabel"), ] @onready var hangar_fleet_slots: Array = [ get_node("../UI/HangarMenu/FleetInterceptor"), @@ -81,6 +85,7 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") get_node("../UI/HangarMenu/FleetGasBomber"), get_node("../UI/HangarMenu/FleetCarrier"), get_node("../UI/HangarMenu/FleetLaser"), + get_node("../UI/HangarMenu/FleetDistorder"), ] @onready var hangar_fleet_labels: Array = [ get_node("../UI/HangarMenu/FleetInterceptorLabel"), @@ -88,6 +93,7 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") get_node("../UI/HangarMenu/FleetGasBomberLabel"), get_node("../UI/HangarMenu/FleetCarrierLabel"), get_node("../UI/HangarMenu/FleetLaserLabel"), + get_node("../UI/HangarMenu/FleetDistorderLabel"), ] var build_menu_entries: Array = [] @@ -117,13 +123,15 @@ var lab_building_id: int = 0 @onready var craft_carrier_label: Label = get_node("../UI/CraftMenu/CarrierLabel") @onready var craft_laser_button: IconButton = get_node("../UI/CraftMenu/LaserButton") @onready var craft_laser_label: Label = get_node("../UI/CraftMenu/LaserLabel") +@onready var craft_distorder_button: IconButton = get_node("../UI/CraftMenu/DistorderButton") +@onready var craft_distorder_label: Label = get_node("../UI/CraftMenu/DistorderLabel") @onready var craft_vessel_buttons: Array = [ craft_interceptor_button, craft_shield_button, craft_gas_bomber_button, craft_carrier_button, - craft_laser_button, + craft_laser_button, craft_distorder_button, ] @onready var craft_vessel_labels: Array = [ craft_interceptor_label, craft_shield_label, craft_gas_bomber_label, craft_carrier_label, - craft_laser_label, + craft_laser_label, craft_distorder_label, ] # --- the transfer panel (#24) --------------------------------------------- diff --git a/DistorderVesselDefinition.tres b/DistorderVesselDefinition.tres new file mode 100644 index 0000000..9e2a0de --- /dev/null +++ b/DistorderVesselDefinition.tres @@ -0,0 +1,24 @@ +[gd_resource type="Resource" script_class="VesselDefinition" load_steps=3 format=3] +[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://VesselDistorderComponent.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "distorder" +display_name = "Distorder" +description = "Unarmed space-time distortion hull. While one flies with the fleet, a fader lets you push cruise speed beyond its default." +build_cost = 14 +build_resource_costs = {"ore": 14, "energy": 8} +build_time = 16.0 +icon = 37 +glow_color = Color(0.7, 0.3, 1, 1) +hull_points = PackedVector2Array(0, -24, -14, -2, -6, 18, 10, 10, 12, -10) +fleet_eligible = true +capabilities = PackedStringArray("distorder") +component_scenes = Array[PackedScene]([ExtResource("2")]) +base_stats = {"max_speed": 430.0, "acceleration": 410.0, "delivery_speed": 180.0} +unlock_id = "unlock_distorder" +unlock_name = "Unlock Distorder" +unlock_description = "Research a space-time distortion hull - while one flies with the fleet, a fader lets you push cruise speed beyond its default." +unlock_cost = 0 +unlock_resource_costs = {"energy": 50} +unlock_icon = -1 diff --git a/Fleet.gd b/Fleet.gd index 78e4627..d51740f 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -200,18 +200,25 @@ func _process(delta: float) -> void: if steer_direction == Vector2.ZERO: return # The anchor - the fleet's formation centre - travels at cruise_speed. - # Ships may exceed it individually to reach their slots. - anchor += steer_direction * cruise_speed * delta + # Ships may exceed it individually to reach their slots. #59: multiplied + # at the point of use, not baked into cruise_speed itself, so the base + # value stays stable regardless of the current boost. + anchor += steer_direction * _effective_cruise_speed() * delta fleet_target = anchor _assign_slot_targets() +func _effective_cruise_speed() -> float: + return cruise_speed * GameState.fleet_speed_multiplier + + func _assign_slot_targets() -> void: + var effective_cruise_speed: float = _effective_cruise_speed() for i in range(ships.size()): ships[i].set_move_target(fleet_target + formation_offsets[i]) # Context, not a command: the ship adds this to its own velocity to # work out how it is really moving. See Ship.fleet_velocity. - ships[i].fleet_velocity = steer_direction * cruise_speed + ships[i].fleet_velocity = steer_direction * effective_cruise_speed func stop() -> void: diff --git a/GameController.gd b/GameController.gd index e3cb346..957914b 100644 --- a/GameController.gd +++ b/GameController.gd @@ -28,6 +28,7 @@ extends Node2D # indirectly - e.g. Main.tscn loaded from inside a _screenshot_tool.gd # preview - for a class_name this new. Node + duck-typed calls sidesteps it. @onready var laser_controller: Node = get_node_or_null("LaserController") +@onready var speed_fader: Node = get_node_or_null("UI/SpeedFader") var touches: Dictionary = {} var pinch_active: bool = false @@ -263,6 +264,8 @@ func _handle_touch(event: InputEventScreenTouch) -> void: return if laser_controller != null and laser_controller.handle_touch(event): return + if speed_fader != null and speed_fader.handle_touch(event): + return if _handle_pivot_touch(event): return if joystick != null and joystick.visible and joystick.handle_touch(event): @@ -287,6 +290,8 @@ func _handle_drag(event: InputEventScreenDrag) -> void: return if laser_controller != null and laser_controller.handle_drag(event): return + if speed_fader != null and speed_fader.handle_drag(event): + return if joystick != null and joystick.visible and joystick.handle_drag(event): return if not touches.has(event.index): diff --git a/GameState.gd b/GameState.gd index ced08da..b608490 100644 --- a/GameState.gd +++ b/GameState.gd @@ -22,6 +22,17 @@ var planet_name: String = "" var planet_id: String = "" var planet_definition_id: String = "rocky" +# #59: 1.0 is today's behaviour. Applied at the point of use (Fleet's cruise +# anchor, Ship's own max_speed/acceleration) rather than baked into stored +# stats, so a speed upgrade researched while boosted can't corrupt the base +# value - see Fleet.gd/Ship.gd for the multiply sites. +const MAX_FLEET_SPEED_MULTIPLIER: float = 3.0 +var fleet_speed_multiplier: float = 1.0 + + +func set_fleet_speed_multiplier(value: float) -> void: + fleet_speed_multiplier = clampf(value, 1.0, MAX_FLEET_SPEED_MULTIPLIER) + # Explored-chunk history for the world map's fog-of-war reveal. Lives # here (not on ExplorationTracker, which gets destroyed and recreated on # every scene swap) so exploration progress isn't lost every time the @@ -381,6 +392,7 @@ func reset_new_game() -> void: planet_name = "" planet_id = "" planet_definition_id = "rocky" + fleet_speed_multiplier = 1.0 explored_chunks.clear() bank_inventory = INVENTORY.new({RESOURCE_ORE: 500}) fleet_inventory = INVENTORY.new() @@ -465,6 +477,7 @@ const VESSEL_GAS_BOMBER: String = "gas_bomber" const VESSEL_TRANSPORTER: String = "transporter" const VESSEL_CARRIER: String = "carrier" const VESSEL_LASER: String = "laser" +const VESSEL_DISTORDER: String = "distorder" # Compatibility constants for callers and old tests. Runtime metadata is # resolved from VesselDefinition below. diff --git a/IconButton.gd b/IconButton.gd index 62878a0..5ba2701 100644 --- a/IconButton.gd +++ b/IconButton.gd @@ -41,6 +41,8 @@ enum Icon { # #58: LASER_VESSEL is the ship glyph, LASER_SHOT the action button - # same two-icon split as GAS_BOMBER/GAS_BOMB above. LASER_VESSEL, LASER_SHOT, + # #59: a warped ring reads as a space-time distortion rather than a hull. + DISTORDER_VESSEL, } # Size the glyphs were originally drawn against. Every glyph dimension is @@ -239,6 +241,8 @@ func _draw() -> void: _draw_laser_vessel(center, pulse, s) Icon.LASER_SHOT: _draw_laser_shot(center, pulse, s) + Icon.DISTORDER_VESSEL: + _draw_distorder_vessel(center, pulse, s) func _draw_close(center: Vector2, pulse: float, s: float) -> void: @@ -277,6 +281,18 @@ func _draw_carrier(center: Vector2, pulse: float, s: float) -> void: NeonDraw.draw_glowing_polygon(self, hull, _active_color(), pulse, core_color) +func _draw_distorder_vessel(center: Vector2, pulse: float, s: float) -> void: + # A ring whose radius wobbles with angle instead of a plain circle - + # reads as warped space rather than a hull silhouette. + var points: PackedVector2Array = PackedVector2Array() + for i in range(17): + var a: float = TAU * float(i) / 16.0 + var r: float = (10.0 + 3.0 * sin(a * 3.0)) * s + points.append(center + Vector2(cos(a), sin(a)) * r) + NeonDraw.draw_glowing_path(self, points, _active_color(), pulse, 0.8, core_color) + NeonDraw.draw_glowing_circle(self, center, 2.5 * s, _active_color(), pulse, core_color) + + func _draw_laser_vessel(center: Vector2, pulse: float, s: float) -> void: # A slim dart rather than the interceptor's plain triangle - the notch at # the nose reads as a fixed forward-facing emitter, not a hull point. diff --git a/Main.tscn b/Main.tscn index 7eaf569..125ce54 100644 --- a/Main.tscn +++ b/Main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=28 format=3] +[gd_scene load_steps=29 format=3] [ext_resource type="Script" path="res://Starfield.gd" id="1"] [ext_resource type="Script" path="res://Fleet.gd" id="2"] @@ -26,6 +26,7 @@ [ext_resource type="Script" path="res://ResourceTransferList.gd" id="27"] [ext_resource type="Script" path="res://GasBombController.gd" id="29"] [ext_resource type="Script" path="res://LaserController.gd" id="30"] +[ext_resource type="Script" path="res://SpeedFader.gd" id="31"] [sub_resource type="ShaderMaterial" id="1"] shader = ExtResource("5") @@ -221,6 +222,28 @@ offset_top = -424 offset_bottom = -24 script = ExtResource("13") +[node name="SpeedFader" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -94 +offset_right = -24 +offset_top = 740 +offset_bottom = 920 +script = ExtResource("31") + +[node name="Label" type="Label" parent="UI/SpeedFader"] +mouse_filter = 2 +offset_left = -20 +offset_right = 90 +offset_top = -32 +offset_bottom = -4 +theme_override_font_sizes/font_size = 20 +theme_override_colors/font_color = Color(0.7, 0.3, 1, 1) +horizontal_alignment = 1 +text = "x1.0" + [node name="LaserButton" type="Control" parent="UI"] visible = false mouse_filter = 2 @@ -839,7 +862,7 @@ horizontal_alignment = 1 anchor_left = 0.5 anchor_right = 0.5 offset_left = -290 -offset_right = -198 +offset_right = -215 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -850,7 +873,7 @@ mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 offset_left = -330 -offset_right = -158 +offset_right = -175 offset_top = 380 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -859,8 +882,8 @@ horizontal_alignment = 1 [node name="AvailableShield" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -168 -offset_right = -76 +offset_left = -189 +offset_right = -114 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -871,8 +894,8 @@ glow_color = Color(0.45, 0.8, 1, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -208 -offset_right = -36 +offset_left = -229 +offset_right = -74 offset_top = 380 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -881,8 +904,8 @@ horizontal_alignment = 1 [node name="AvailableGasBomber" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -46 -offset_right = 46 +offset_left = -88 +offset_right = -13 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -893,8 +916,8 @@ glow_color = Color(0.96, 0.32, 0.78, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -86 -offset_right = 86 +offset_left = -128 +offset_right = 27 offset_top = 380 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -903,8 +926,8 @@ horizontal_alignment = 1 [node name="AvailableCarrier" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 76 -offset_right = 168 +offset_left = 13 +offset_right = 88 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -915,8 +938,8 @@ glow_color = Color(1, 0.78, 0.3, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = 36 -offset_right = 208 +offset_left = -27 +offset_right = 128 offset_top = 380 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -925,8 +948,8 @@ horizontal_alignment = 1 [node name="AvailableLaser" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 198 -offset_right = 290 +offset_left = 114 +offset_right = 189 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -937,7 +960,29 @@ glow_color = Color(0.3, 0.9, 1, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = 158 +offset_left = 74 +offset_right = 229 +offset_top = 380 +offset_bottom = 460 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 + +[node name="AvailableDistorder" type="Control" parent="UI/HangarMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 215 +offset_right = 290 +offset_top = 255 +offset_bottom = 375 +script = ExtResource("10") +icon = 37 +glow_color = Color(0.7, 0.3, 1, 1) + +[node name="AvailableDistorderLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 175 offset_right = 330 offset_top = 380 offset_bottom = 460 @@ -957,7 +1002,7 @@ horizontal_alignment = 1 anchor_left = 0.5 anchor_right = 0.5 offset_left = -290 -offset_right = -198 +offset_right = -215 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -968,7 +1013,7 @@ mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 offset_left = -330 -offset_right = -158 +offset_right = -175 offset_top = 690 offset_bottom = 770 theme_override_font_sizes/font_size = 22 @@ -977,8 +1022,8 @@ horizontal_alignment = 1 [node name="FleetShield" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -168 -offset_right = -76 +offset_left = -189 +offset_right = -114 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -989,8 +1034,8 @@ glow_color = Color(0.45, 0.8, 1, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -208 -offset_right = -36 +offset_left = -229 +offset_right = -74 offset_top = 690 offset_bottom = 770 theme_override_font_sizes/font_size = 22 @@ -999,8 +1044,8 @@ horizontal_alignment = 1 [node name="FleetGasBomber" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -46 -offset_right = 46 +offset_left = -88 +offset_right = -13 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -1011,8 +1056,8 @@ glow_color = Color(0.96, 0.32, 0.78, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -86 -offset_right = 86 +offset_left = -128 +offset_right = 27 offset_top = 690 offset_bottom = 770 theme_override_font_sizes/font_size = 22 @@ -1021,8 +1066,8 @@ horizontal_alignment = 1 [node name="FleetCarrier" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 76 -offset_right = 168 +offset_left = 13 +offset_right = 88 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -1033,8 +1078,8 @@ glow_color = Color(1, 0.78, 0.3, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = 36 -offset_right = 208 +offset_left = -27 +offset_right = 128 offset_top = 690 offset_bottom = 770 theme_override_font_sizes/font_size = 22 @@ -1043,8 +1088,8 @@ horizontal_alignment = 1 [node name="FleetLaser" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 198 -offset_right = 290 +offset_left = 114 +offset_right = 189 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -1055,7 +1100,29 @@ glow_color = Color(0.3, 0.9, 1, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = 158 +offset_left = 74 +offset_right = 229 +offset_top = 690 +offset_bottom = 770 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 + +[node name="FleetDistorder" type="Control" parent="UI/HangarMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 215 +offset_right = 290 +offset_top = 565 +offset_bottom = 685 +script = ExtResource("10") +icon = 37 +glow_color = Color(0.7, 0.3, 1, 1) + +[node name="FleetDistorderLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 175 offset_right = 330 offset_top = 690 offset_bottom = 770 @@ -1107,7 +1174,7 @@ script = ExtResource("24") anchor_left = 0.5 anchor_right = 0.5 offset_left = -300 -offset_right = -230 +offset_right = -240 offset_top = 274 offset_bottom = 394 script = ExtResource("10") @@ -1117,8 +1184,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 = -194 -offset_right = -124 +offset_left = -210 +offset_right = -150 offset_top = 274 offset_bottom = 394 script = ExtResource("10") @@ -1128,8 +1195,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 = -88 -offset_right = -18 +offset_left = -120 +offset_right = -60 offset_top = 274 offset_bottom = 394 script = ExtResource("10") @@ -1139,8 +1206,8 @@ 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 = 18 -offset_right = 88 +offset_left = -30 +offset_right = 30 offset_top = 274 offset_bottom = 394 script = ExtResource("10") @@ -1150,8 +1217,8 @@ 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 = 124 -offset_right = 194 +offset_left = 60 +offset_right = 120 offset_top = 274 offset_bottom = 394 script = ExtResource("10") @@ -1161,14 +1228,25 @@ glow_color = Color(1, 0.78, 0.3, 1) [node name="VesselF" type="Control" parent="UI/LabMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 230 -offset_right = 300 +offset_left = 150 +offset_right = 210 offset_top = 274 offset_bottom = 394 script = ExtResource("10") icon = 35 glow_color = Color(0.3, 0.9, 1, 1) +[node name="VesselG" type="Control" parent="UI/LabMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 240 +offset_right = 300 +offset_top = 274 +offset_bottom = 394 +script = ExtResource("10") +icon = 37 +glow_color = Color(0.7, 0.3, 1, 1) + [node name="VesselLabel" type="Label" parent="UI/LabMenu"] visible = false mouse_filter = 2 @@ -1186,7 +1264,7 @@ mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 offset_left = -330 -offset_right = -200 +offset_right = -210 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -1197,8 +1275,8 @@ text = "Interceptor" mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -224 -offset_right = -94 +offset_left = -240 +offset_right = -120 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -1209,8 +1287,8 @@ text = "Shield" mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -118 -offset_right = 12 +offset_left = -150 +offset_right = -30 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -1221,8 +1299,8 @@ text = "Gas Bomber" mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = 12 -offset_right = 118 +offset_left = -60 +offset_right = 60 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -1233,8 +1311,8 @@ text = "Transporter" mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = 94 -offset_right = 224 +offset_left = 30 +offset_right = 150 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -1245,14 +1323,26 @@ text = "Carrier" mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = 200 -offset_right = 330 +offset_left = 120 +offset_right = 240 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 horizontal_alignment = 1 text = "Laser Ship" +[node name="VesselGLabel" type="Label" parent="UI/LabMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 210 +offset_right = 330 +offset_top = 400 +offset_bottom = 460 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 +text = "Distorder" + [node name="VesselUpgradeSeparator" type="ColorRect" parent="UI/LabMenu"] mouse_filter = 2 anchor_right = 1.0 @@ -1498,7 +1588,7 @@ anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -310 -offset_right = -232 +offset_right = -245 offset_top = -100 offset_bottom = 48 script = ExtResource("10") @@ -1511,7 +1601,7 @@ anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -330 -offset_right = -212 +offset_right = -225 offset_top = 56 offset_bottom = 136 theme_override_font_sizes/font_size = 24 @@ -1523,8 +1613,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -202 -offset_right = -124 +offset_left = -221 +offset_right = -156 offset_top = -100 offset_bottom = 48 script = ExtResource("10") @@ -1537,8 +1627,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -222 -offset_right = -104 +offset_left = -241 +offset_right = -136 offset_top = 56 offset_bottom = 136 theme_override_font_sizes/font_size = 24 @@ -1551,8 +1641,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -94 -offset_right = -16 +offset_left = -132 +offset_right = -67 offset_top = -100 offset_bottom = 28 script = ExtResource("10") @@ -1565,8 +1655,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -114 -offset_right = 4 +offset_left = -152 +offset_right = -47 offset_top = 56 offset_bottom = 150 theme_override_font_sizes/font_size = 24 @@ -1579,8 +1669,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 14 -offset_right = 92 +offset_left = -43 +offset_right = 22 offset_top = -100 offset_bottom = 48 script = ExtResource("10") @@ -1593,8 +1683,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -6 -offset_right = 112 +offset_left = -63 +offset_right = 42 offset_top = 56 offset_bottom = 136 theme_override_font_sizes/font_size = 24 @@ -1607,8 +1697,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 122 -offset_right = 200 +offset_left = 46 +offset_right = 111 offset_top = -100 offset_bottom = 48 script = ExtResource("10") @@ -1621,8 +1711,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 102 -offset_right = 220 +offset_left = 26 +offset_right = 131 offset_top = 56 offset_bottom = 136 theme_override_font_sizes/font_size = 24 @@ -1630,6 +1720,34 @@ autowrap_mode = 2 text = "Laser Ship" horizontal_alignment = 1 +[node name="DistorderButton" type="Control" parent="UI/CraftMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 135 +offset_right = 200 +offset_top = -100 +offset_bottom = 48 +script = ExtResource("10") +icon = 37 +glow_color = Color(0.7, 0.3, 1, 1) + +[node name="DistorderLabel" 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 = 115 +offset_right = 220 +offset_top = 56 +offset_bottom = 136 +theme_override_font_sizes/font_size = 24 +autowrap_mode = 2 +text = "Distorder" +horizontal_alignment = 1 + [node name="QueueLabel" type="Label" parent="UI/CraftMenu"] mouse_filter = 2 anchor_left = 1.0 diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index afdeb49..e4ab07a 100644 --- a/PlanetSurface.tscn +++ b/PlanetSurface.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=15 format=3] +[gd_scene load_steps=16 format=3] [ext_resource type="Script" path="res://GameController.gd" id="1"] [ext_resource type="Script" path="res://LunarBackground.gd" id="2"] @@ -10,6 +10,7 @@ [ext_resource type="Script" path="res://ResourceReadout.gd" id="9"] [ext_resource type="Script" path="res://GasBombController.gd" id="14"] [ext_resource type="Script" path="res://LaserController.gd" id="16"] +[ext_resource type="Script" path="res://SpeedFader.gd" id="17"] [ext_resource type="Script" path="res://CaptureLabel.gd" id="15"] [sub_resource type="ShaderMaterial" id="1"] @@ -157,6 +158,30 @@ script = ExtResource("7") glow_color = Color(0.06, 0.32, 0.5, 1) core_color = Color(0.02, 0.09, 0.15, 1) +[node name="SpeedFader" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -94 +offset_right = -24 +offset_top = 740 +offset_bottom = 920 +script = ExtResource("17") +glow_color = Color(0.5, 0.18, 0.6, 1) +core_color = Color(0.02, 0.09, 0.15, 1) + +[node name="Label" type="Label" parent="UI/SpeedFader"] +mouse_filter = 2 +offset_left = -20 +offset_right = 90 +offset_top = -32 +offset_bottom = -4 +theme_override_font_sizes/font_size = 20 +theme_override_colors/font_color = Color(0.7, 0.3, 1, 1) +horizontal_alignment = 1 +text = "x1.0" + [node name="LaserButton" type="Control" parent="UI"] visible = false mouse_filter = 2 diff --git a/Ship.gd b/Ship.gd index cdaa542..074c6b4 100644 --- a/Ship.gd +++ b/Ship.gd @@ -97,16 +97,29 @@ func _physics_process(delta: float) -> void: if child.has_method("tick"): child.tick(delta) + # #59: multiplied here, at the point of use, rather than baked into the + # cached max_speed/acceleration fields apply_vessel_stats() sets - those + # stay the true base values, so a stat upgrade researched mid-boost can't + # corrupt them, and the fader takes effect the instant it moves with no + # re-apply needed. arrive_radius is scaled too: at N physics ticks/sec a + # ship's per-tick step is `speed * delta`, and once that step outgrows + # the (otherwise fixed) arrive_radius it can overshoot the snap-to-target + # zone every tick while chasing a target that is ALSO now receding N + # times faster - a small, boost-proportional radius keeps the same + # "snaps onto its slot" formation tightness at any boost level that + # 1.0x already has. + var boost: float = GameState.fleet_speed_multiplier var to_target: Vector2 = move_target - position var distance: float = to_target.length() - if distance <= arrive_radius: + if distance <= arrive_radius * boost: position = move_target velocity = Vector2.ZERO else: - var braking_speed: float = sqrt(2.0 * acceleration * distance) - var desired_speed: float = min(max_speed, braking_speed) + var effective_acceleration: float = acceleration * boost + var braking_speed: float = sqrt(2.0 * effective_acceleration * distance) + var desired_speed: float = min(max_speed * boost, braking_speed) var desired_velocity: Vector2 = to_target.normalized() * desired_speed - velocity = velocity.move_toward(desired_velocity, acceleration * delta) + velocity = velocity.move_toward(desired_velocity, effective_acceleration * delta) position += velocity * delta var motion: Vector2 = velocity + fleet_velocity diff --git a/SpeedFader.gd b/SpeedFader.gd new file mode 100644 index 0000000..6449b77 --- /dev/null +++ b/SpeedFader.gd @@ -0,0 +1,112 @@ +class_name SpeedFader +extends Control + +# Vertical drag control for GameState.fleet_speed_multiplier. Same raw-touch +# idiom as TouchJoystick (handle_touch/handle_drag offered first by +# GameController, not _gui_input) and the same NeonDraw look, but a fader +# rather than a stick: the knob's Y position along a fixed vertical track IS +# the value, no spring-back to centre. +# +# Self-contained: writes straight to GameState.fleet_speed_multiplier rather +# than reporting a value for something else to apply, since the multiplier +# already lives in the one place (the autoload) that needs to survive scene +# swaps. + +@export var glow_color: Color = Color(0.7, 0.3, 1.0) +@export var core_color: Color = Color(1.0, 1.0, 1.0) +@export var track_margin: float = 28.0 + +@onready var fleet: Node2D = get_node("../../Fleet") +@onready var label: Label = get_node("Label") + +var active_touch_index: int = -1 +# 0 = bottom of the track (1.0x), 1 = top (GameState.MAX_FLEET_SPEED_MULTIPLIER). +var knob_t: float = 0.0 + + +func _ready() -> void: + visible = false + queue_redraw() + + +func _process(_delta: float) -> void: + var has_distorder: bool = fleet.has_capability("distorder") + if has_distorder != visible: + visible = has_distorder + if not has_distorder: + # A fleet must never be left permanently boosted by a vessel it no + # longer has - reset the instant the capability disappears, not just + # hide the control on top of a stale multiplier. + active_touch_index = -1 + if GameState.fleet_speed_multiplier != 1.0: + GameState.fleet_speed_multiplier = 1.0 + knob_t = 0.0 + return + knob_t = (GameState.fleet_speed_multiplier - 1.0) \ + / maxf(GameState.MAX_FLEET_SPEED_MULTIPLIER - 1.0, 0.001) + label.text = "x%.1f" % GameState.fleet_speed_multiplier + queue_redraw() + + +func is_active() -> bool: + return active_touch_index != -1 + + +func _blocked_by_modal() -> bool: + # Same check as IconButton._blocked_by_modal() - panels stay transparent + # to raw input (the skill tree/formation editor need _unhandled_input), + # so every raw-touch control checks for itself instead. + for menu in get_tree().get_nodes_in_group("modal_menus"): + if menu.is_visible_in_tree() and not menu.is_ancestor_of(self): + return true + return false + + +func handle_touch(event: InputEventScreenTouch) -> bool: + var local: Vector2 = event.position - global_position + if event.pressed: + if not visible or active_touch_index != -1 or _blocked_by_modal(): + return false + if not Rect2(Vector2.ZERO, size).has_point(local): + return false + active_touch_index = event.index + _update_from_local(local) + return true + if event.index != active_touch_index: + return false + active_touch_index = -1 + queue_redraw() + return true + + +func handle_drag(event: InputEventScreenDrag) -> bool: + if event.index != active_touch_index: + return false + _update_from_local(event.position - global_position) + return true + + +func _update_from_local(local: Vector2) -> void: + var usable: float = maxf(size.y - track_margin * 2.0, 1.0) + # Up is 1.0 (top of the track), matching how a real fader/throttle reads. + var t: float = 1.0 - clampf((local.y - track_margin) / usable, 0.0, 1.0) + knob_t = t + GameState.set_fleet_speed_multiplier(1.0 + t * (GameState.MAX_FLEET_SPEED_MULTIPLIER - 1.0)) + label.text = "x%.1f" % GameState.fleet_speed_multiplier + queue_redraw() + + +func _draw() -> void: + var top: Vector2 = Vector2(size.x * 0.5, track_margin) + var bottom: Vector2 = Vector2(size.x * 0.5, size.y - track_margin) + NeonDraw.draw_glowing_line(self, bottom, top, glow_color, 0.55, 0.5, core_color) + + # Tick marks at the extremes read as "min" and "max" at a glance. + for t in [0.0, 1.0]: + var y: float = lerpf(bottom.y, top.y, t) + NeonDraw.draw_glowing_line(self, Vector2(size.x * 0.5 - 12.0, y), + Vector2(size.x * 0.5 + 12.0, y), glow_color, 0.4, 0.35, core_color) + + var knob_pos: Vector2 = bottom.lerp(top, clampf(knob_t, 0.0, 1.0)) + NeonDraw.draw_glowing_circle(self, knob_pos, 16.0, glow_color, + 1.0 if is_active() else 0.8, core_color) diff --git a/VesselDefinitions.gd b/VesselDefinitions.gd index a037eb7..33ca763 100644 --- a/VesselDefinitions.gd +++ b/VesselDefinitions.gd @@ -1,7 +1,7 @@ class_name VesselDefinitions extends RefCounted -const ORDER: PackedStringArray = ["interceptor", "shield", "gas_bomber", "transporter", "carrier", "laser"] +const ORDER: PackedStringArray = ["interceptor", "shield", "gas_bomber", "transporter", "carrier", "laser", "distorder"] const DEFINITIONS: Dictionary = { "interceptor": preload("res://InterceptorVesselDefinition.tres"), "shield": preload("res://ShieldVesselDefinition.tres"), @@ -9,6 +9,7 @@ const DEFINITIONS: Dictionary = { "transporter": preload("res://TransporterVesselDefinition.tres"), "carrier": preload("res://CarrierVesselDefinition.tres"), "laser": preload("res://LaserVesselDefinition.tres"), + "distorder": preload("res://DistorderVesselDefinition.tres"), } static func get_definition(definition_id: String) -> Resource: diff --git a/VesselDistorderComponent.gd b/VesselDistorderComponent.gd new file mode 100644 index 0000000..25a31d7 --- /dev/null +++ b/VesselDistorderComponent.gd @@ -0,0 +1,9 @@ +class_name VesselDistorderComponent +extends Node + +# Pure presence marker (#59) - no per-shot behaviour, no tick(). Its only +# job is to make Ship.has_capability("distorder") true, which is what +# SpeedFader.gd/Fleet.gd key off to reveal and apply the speed boost. +# Energy drain while boosted is a later ticket (#62). + +var capability_id := "distorder" diff --git a/VesselDistorderComponent.tscn b/VesselDistorderComponent.tscn new file mode 100644 index 0000000..862f0d3 --- /dev/null +++ b/VesselDistorderComponent.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://VesselDistorderComponent.gd" id="1"] + +[node name="DistorderComponent" type="Node"] +script = ExtResource("1") diff --git a/tests/test_space_time_distorder.gd b/tests/test_space_time_distorder.gd new file mode 100644 index 0000000..8aff0e7 --- /dev/null +++ b/tests/test_space_time_distorder.gd @@ -0,0 +1,232 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_space_time_distorder.gd +# Forgejo #59: a researchable vessel that lets the whole fleet travel faster +# than its default cruise speed, via a fader driving +# game_state.fleet_speed_multiplier. +# +# Covers: +# A. the fader is absent with no distorder ship, appears once one joins +# B. dragging it up measurably increases distance travelled per second; +# at 1.0 the fleet moves exactly as it does today +# C. formation holds together under boost - ships stay near their slot +# D. losing the vessel resets the multiplier and hides the fader +# E. the multiplier survives the space -> surface scene swap (it lives +# in the autoload) + +var game_state: Node +var main: Node +var fleet: Node +var fader: Node +var stage: int = 0 +var elapsed: float = 0.0 +var baseline_distance: float = 0.0 +var boosted_distance: float = 0.0 +var start_centroid: Vector2 = Vector2.ZERO +var unboosted_lag: float = 0.0 + +const MEASURE_TIME: float = 1.0 + + +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.vessel_levels["unlock_distorder"] = 1 + main = load("res://Main.tscn").instantiate() + root.add_child(main) + fleet = main.get_node("Fleet") + fader = main.get_node("UI/SpeedFader") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.2: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + + # --- A. absent, then appears ------------------------------------- + if fader.visible: + return _fail_now("fader appeared without a distorder ship") + + fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_DISTORDER])) + fader._process(0.0) + if not fader.visible: + return _fail_now("a distorder ship in the fleet did not reveal the fader") + print("fader hidden with no distorder ship, visible once one joins") + + # --- B. baseline @ 1.0x ------------------------------------------- + if not is_equal_approx(game_state.fleet_speed_multiplier, 1.0): + return _fail_now("multiplier is not 1.0 by default: %f" % game_state.fleet_speed_multiplier) + start_centroid = fleet.get_centroid() + fleet.move_in_direction(Vector2(1.0, 0.0)) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < MEASURE_TIME: + return false + baseline_distance = fleet.get_centroid().distance_to(start_centroid) + # Reference for the formation check below: pure pursuit (a ship + # always aiming straight at its CURRENT, still-moving target + # rather than leading it) has its own inherent non-zero steady- + # state following distance even at 1.0x - it scales with the + # speeds involved, so "holds together under boost" has to mean + # staying proportional to this, not shrinking to some fixed + # absolute number regardless of how fast the fleet is going. + for i in range(fleet.ships.size()): + var ship: Node2D = fleet.ships[i] + if not is_instance_valid(ship): + continue + var expected: Vector2 = fleet.fleet_target + fleet.formation_offsets[i] + unboosted_lag = maxf(unboosted_lag, ship.position.distance_to(expected)) + fleet.stop() + print("baseline (1.0x): travelled %.1f over %.1fs, formation lag %.1f" \ + % [baseline_distance, MEASURE_TIME, unboosted_lag]) + elapsed = 0.0 + stage = 2 + return false + 2: + # Let the braking manoeuvre from stop() fully settle before + # measuring the boosted leg, so leftover momentum from the + # baseline pass can't pad the comparison either way. + if elapsed < 0.5: + return false + + var press := InputEventScreenTouch.new() + press.index = 5 + press.position = fader.global_position + Vector2(fader.size.x * 0.5, fader.size.y - 4.0) + press.pressed = true + main._handle_touch(press) + if not fader.is_active(): + return _fail_now("pressing the fader did not begin dragging") + var drag := InputEventScreenDrag.new() + drag.index = 5 + # All the way to the top of the track = MAX_FLEET_SPEED_MULTIPLIER. + drag.position = fader.global_position + Vector2(fader.size.x * 0.5, 0.0) + main._handle_drag(drag) + if not is_equal_approx(game_state.fleet_speed_multiplier, GameState.MAX_FLEET_SPEED_MULTIPLIER): + return _fail_now("dragging to the top did not reach the max multiplier: %f" \ + % game_state.fleet_speed_multiplier) + print("fader dragged to %.1fx" % game_state.fleet_speed_multiplier) + + start_centroid = fleet.get_centroid() + fleet.move_in_direction(Vector2(1.0, 0.0)) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < MEASURE_TIME: + return false + boosted_distance = fleet.get_centroid().distance_to(start_centroid) + print("boosted (%.1fx): travelled %.1f over %.1fs" \ + % [game_state.fleet_speed_multiplier, boosted_distance, MEASURE_TIME]) + if boosted_distance <= baseline_distance * 1.3: + return _fail_now("boosted travel (%.1f) was not measurably more than baseline (%.1f)" \ + % [boosted_distance, baseline_distance]) + elapsed = 0.0 + stage = 4 + return false + 4: + # --- C. formation holds together under boost ---------------------- + # Both the anchor's advance and the ships' own catch-up speed are + # scaled by the SAME multiplier, so the initial accelerate-from- + # rest transient covers a proportionally larger absolute distance + # under a 3x boost - a moment right after the boost begins isn't + # "trailing", it just hasn't finished the same (time-invariant) + # catch-up burst yet. Keep steering a while longer so that burst + # is over before judging whether formation holds. + if elapsed < 1.5: + return false + # Allow the lag to scale with the boost (it inherently does, even + # with both the anchor and ship speeds multiplied by the exact + # same factor - see the note by unboosted_lag above) but not + # beyond that, with slack for measurement noise. + var allowed_lag: float = maxf(unboosted_lag, 20.0) * game_state.fleet_speed_multiplier * 1.5 + for i in range(fleet.ships.size()): + var ship: Node2D = fleet.ships[i] + if not is_instance_valid(ship): + continue + var expected: Vector2 = fleet.fleet_target + fleet.formation_offsets[i] + var strayed: float = ship.position.distance_to(expected) + if strayed > allowed_lag: + return _fail_now("a ship strayed %.1f from its formation slot under sustained boost (unboosted lag was %.1f, allowed %.1f)" \ + % [strayed, unboosted_lag, allowed_lag]) + print("formation held together under sustained boost (lag stayed proportional to the boost)") + + fleet.stop() + var release := InputEventScreenTouch.new() + release.index = 5 + release.position = fader.global_position + Vector2(fader.size.x * 0.5, 0.0) + release.pressed = false + main._handle_touch(release) + + # --- E. survives the space -> surface scene swap ------------------- + # Done here, with the distorder ship still crewed and the fleet + # still boosted: tearing this scene down while nothing else + # changes the fleet's own composition proves the multiplier + # lives in the autoload rather than being reset as a side effect + # of the old scene's own SpeedFader still ticking once more + # during teardown (it would, correctly, self-reset the instant + # it next saw a fleet with no distorder ship - not a real + # scenario here since this fleet keeps its distorder ship the + # whole way across the swap, via GameState.surviving_vessel_types + # exactly like every other vessel type). + game_state.surviving_ship_count = fleet.ships.size() + game_state.surviving_vessel_types = fleet.vessel_types() + game_state.planet_id = "test_space_time_distorder" + game_state.planet_name = "Distorder Test" + main.queue_free() + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < 0.3: + return false + main = load("res://PlanetSurface.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + fader = main.get_node("UI/SpeedFader") + elapsed = 0.0 + stage = 6 + return false + 6: + if elapsed < 0.3: + return false + if not is_equal_approx(game_state.fleet_speed_multiplier, 3.0): + return _fail_now("multiplier did not survive the space -> surface swap: %f" \ + % game_state.fleet_speed_multiplier) + fader._process(0.0) + if not fader.visible: + return _fail_now("the surface scene's fader did not show the surviving distorder ship") + print("multiplier and fader both survived the space -> surface swap") + + # --- D. losing the vessel resets the multiplier ------------------- + fleet.spawn_ships(1, fleet.get_centroid(), PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + fader._process(0.0) + if fader.visible: + return _fail_now("fader stayed visible after losing the last distorder ship") + if not is_equal_approx(game_state.fleet_speed_multiplier, 1.0): + return _fail_now("multiplier was not reset after losing the distorder ship: %f" \ + % game_state.fleet_speed_multiplier) + print("losing the distorder ship reset the multiplier and hid the fader") + + print("TEST_SPACE_TIME_DISTORDER: PASS") + quit(0) + return true + return false + + +func _fail_now(message: String) -> bool: + print("TEST_SPACE_TIME_DISTORDER: FAIL (%s)" % message) + quit(1) + return true From 17d0cf7e66dc71b3a5c7bd1747c68fec62f970d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 16 Aug 2026 18:54:44 +0200 Subject: [PATCH 2/6] Gas bombers consume gas from the fleet hold when firing (#60) Shots cost GAS_PER_SHOT (5) from GameState.fleet_inventory, not the bank - the fleet has to be carrying its own ammunition. The button's enabled rule now also requires affordability, and a press on a ready- but-unaffordable bomber still consumes the touch to explain itself via a new status label (GasBombStatus, IconButton's own "greyed but still pressable" convention) rather than either silently doing nothing or consuming the bomber's cooldown for a shot that never fires. Also documents a real, reproducible GDScript trap hit while writing tests/test_gas_bomber_ammo.gd: a bare `ClassName.SOME_CONST` reference anywhere in a -s entry test script can corrupt method resolution for the WHOLE script, making an unrelated, earlier `some_node._process()` call fail with "Nonexistent function '_process' in base 'Nil'" despite the node being valid and the method existing (confirmed via has_method()). Root cause unconfirmed; fixed by mirroring the constant 's value into a local const instead of referencing the class directly, in all three gas-bomber tests. tests/test_gas_bomber.gd and test_gas_bomber_cooldowns.gd updated to stock fleet gas first, since shots are no longer free. --- GameState.gd | 6 ++ GasBombController.gd | 41 ++++++- Main.tscn | 16 +++ PlanetSurface.tscn | 16 +++ tests/test_gas_bomber.gd | 11 +- tests/test_gas_bomber_ammo.gd | 142 +++++++++++++++++++++++++ tests/test_gas_bomber_cooldowns.gd | 6 +- tests/test_gas_bomber_cooldowns.gd.uid | 1 - 8 files changed, 230 insertions(+), 9 deletions(-) create mode 100644 tests/test_gas_bomber_ammo.gd delete mode 100644 tests/test_gas_bomber_cooldowns.gd.uid diff --git a/GameState.gd b/GameState.gd index b608490..eb939f3 100644 --- a/GameState.gd +++ b/GameState.gd @@ -104,6 +104,12 @@ func fleet_resource(resource_type: String) -> int: return fleet_inventory.amount(resource_type) +# #60: consumed outright (ammunition), not moved to another inventory like +# transfer_resource_to_building/_fleet - returns the amount actually taken. +func withdraw_fleet_resource(resource_type: String, amount: int) -> int: + return fleet_inventory.withdraw(resource_type, amount) + + # #56: total across every resource type in the hold, and the combined cap # that limits it - a HUD readout wants both, not any one resource's amount. func fleet_cargo_used() -> int: diff --git a/GasBombController.gd b/GasBombController.gd index 4a5b213..489bf49 100644 --- a/GasBombController.gd +++ b/GasBombController.gd @@ -10,11 +10,17 @@ const AIM_DEADZONE := 22.0 const PROJECTILE_SPEED := 720.0 const DAMAGE := 3 const COOLDOWN := 5.0 +# #60: ammunition from the fleet hold, not the bank - the fleet has to be +# carrying its own gas to fire. +const GAS_PER_SHOT: int = 5 @export var minimum_zoom: float = 0.4 @onready var fleet: Node2D = get_node("../Fleet") @onready var camera: Camera2D = get_node("../Camera2D") @onready var button: IconButton = get_node("../UI/GasBombButton") +@onready var status: Label = get_node("../UI/GasBombStatus") + +var status_message_until: int = 0 var aiming := false var aim_touch := -1 @@ -40,15 +46,27 @@ var explosion_time: float: func _ready() -> void: button.visible = false + status.text = "" queue_redraw() +func _can_afford_shot() -> bool: + return GameState.fleet_resource(GameState.RESOURCE_GAS) >= GAS_PER_SHOT + + +func _say(message: String) -> void: + status.text = message + status_message_until = Time.get_ticks_msec() + 3000 + + func _process(delta: float) -> void: var had_visuals := aiming or not projectiles.is_empty() or not explosions.is_empty() button.visible = fleet.has_capability("gas_bomber") - button.enabled = _next_ready_bomber() != null + button.enabled = _next_ready_bomber() != null and _can_afford_shot() if not button.visible and aiming: _cancel_aim() + if status.text != "" and Time.get_ticks_msec() >= status_message_until: + status.text = "" if restoring_zoom and not aiming: var z := move_toward(camera.zoom.x, saved_zoom, maxf(saved_zoom, 0.1) * delta * 5.0) @@ -81,14 +99,23 @@ func _process(delta: float) -> void: func can_begin_aim() -> bool: - return button.visible and button.enabled and not button._blocked_by_modal() \ - and not aiming + # Deliberately NOT button.enabled: an unaffordable shot still needs the + # press to reach here so it can explain itself (IconButton's own "greyed + # but still pressable" convention) rather than the touch silently + # falling through to whatever's behind it. A wholly unready fleet (no + # bomber at all) has nothing more useful to say than "greyed out". + return button.visible and not button._blocked_by_modal() \ + and not aiming and _next_ready_bomber() != null func handle_touch(event: InputEventScreenTouch) -> bool: if event.pressed: if not can_begin_aim() or not Rect2(button.global_position, button.size).has_point(event.position): return false + if not _can_afford_shot(): + _say("Need %d gas in the fleet hold to fire (have %d)" \ + % [GAS_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_GAS)]) + return true aiming = true aim_touch = event.index aim_start_screen = event.position @@ -156,17 +183,23 @@ func _fire() -> void: if bomber == null: _cancel_aim() return + if not _can_afford_shot(): + _say("Need %d gas in the fleet hold to fire (have %d)" \ + % [GAS_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_GAS)]) + _cancel_aim() + return var component: Node = bomber.get_component("gas_bomber") if not component.consume_shot(COOLDOWN): _cancel_aim() return + GameState.withdraw_fleet_resource(GameState.RESOURCE_GAS, GAS_PER_SHOT) projectiles.append({ "position": bomber.global_position, "target": aim_target, "trail": PackedVector2Array([bomber.global_position]), }) _finish_aim() - button.enabled = _next_ready_bomber() != null + button.enabled = _next_ready_bomber() != null and _can_afford_shot() func _next_ready_bomber() -> Node2D: diff --git a/Main.tscn b/Main.tscn index 125ce54..7e14f37 100644 --- a/Main.tscn +++ b/Main.tscn @@ -287,6 +287,22 @@ script = ExtResource("10") icon = 32 glow_color = Color(0.96, 0.22, 0.72, 1) +[node name="GasBombStatus" type="Label" parent="UI"] +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -344 +offset_right = -24 +offset_top = -480 +offset_bottom = -430 +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(0.96, 0.22, 0.72, 1) +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" + [node name="PivotCWButton" type="Control" parent="UI"] mouse_filter = 2 anchor_left = 1.0 diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index e4ab07a..53f12ed 100644 --- a/PlanetSurface.tscn +++ b/PlanetSurface.tscn @@ -229,6 +229,22 @@ icon = 32 glow_color = Color(0.82, 0.08, 0.48, 1) core_color = Color(0.02, 0.09, 0.15, 1) +[node name="GasBombStatus" type="Label" parent="UI"] +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -344 +offset_right = -24 +offset_top = -480 +offset_bottom = -430 +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(0.82, 0.08, 0.48, 1) +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" + [node name="PivotCWButton" type="Control" parent="UI"] mouse_filter = 2 anchor_left = 1.0 diff --git a/tests/test_gas_bomber.gd b/tests/test_gas_bomber.gd index cda2a2f..8dc10cd 100644 --- a/tests/test_gas_bomber.gd +++ b/tests/test_gas_bomber.gd @@ -3,7 +3,10 @@ extends SceneTree var game_state: Node var main: Node var fleet: Node -var skill: GasBombController +var skill: Node +# Mirrors GasBombController.AOE_RADIUS's value rather than referencing the +# class constant directly - see test_gas_bomber_cooldowns.gd's identical fix. +const AOE_RADIUS_FOR_TEST: float = 180.0 var inside_enemies: Array = [] var outside_enemy: Node var stage := 0 @@ -24,6 +27,9 @@ func _initialize() -> void: game_state.fleet_cargo = 0 game_state.fleet_gas_cargo = 0 game_state.build_time_scale = 0.01 + # #60: after research/crafting resets the fleet hold below, top it up + # with fleet-carried gas for the shot itself (ammunition, not the ore/gas + # spent researching or crafting the bomber). # Research is gas-only: any amount of ore and 29 gas must still leave it # waiting, then the thirtieth unit starts and completes the unlock. @@ -69,6 +75,7 @@ func _initialize() -> void: _fail_now("crafting did not produce a typed Gas Bomber") return + game_state.fleet_gas_cargo = 20 main = load("res://Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") @@ -117,7 +124,7 @@ func _process(delta: float) -> bool: enemy.speed = 0.0 inside_enemies.append(enemy) outside_enemy = load("res://Enemy.tscn").instantiate() - outside_enemy.position = skill.aim_target + Vector2(GasBombController.AOE_RADIUS + 80.0, 0) + outside_enemy.position = skill.aim_target + Vector2(AOE_RADIUS_FOR_TEST + 80.0, 0) main.add_child(outside_enemy) outside_enemy.speed = 0.0 diff --git a/tests/test_gas_bomber_ammo.gd b/tests/test_gas_bomber_ammo.gd new file mode 100644 index 0000000..54aa122 --- /dev/null +++ b/tests/test_gas_bomber_ammo.gd @@ -0,0 +1,142 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_gas_bomber_ammo.gd +# Forgejo #60: gas bomb shots draw ammunition from the FLEET HOLD +# (GameState.fleet_inventory), not the bank - the fleet has to be carrying +# its own gas. +# +# Covers: +# A. gas sitting in the bank does not enable firing - only the fleet +# hold counts +# B. with insufficient fleet gas the button is disabled, pressing it +# explains why, and no projectile is created / no cooldown consumed +# C. firing drains exactly GAS_PER_SHOT from the fleet hold and nothing +# from the bank + +var game_state: Node +var main: Node +var fleet: Node +var skill: Node +var bomber_component: Node +var stage: int = 0 +var elapsed: float = 0.0 + +# Mirrors GasBombController.GAS_PER_SHOT's value rather than referencing +# the class constant directly - a bare ClassName.CONST reference elsewhere +# in a test +# script has been observed to corrupt method resolution for the WHOLE +# script (an unrelated skill._process() call earlier in the same file then +# fails with "Nonexistent function '_process' in base 'Nil'"), root cause +# unconfirmed. See tests/test_gas_bomber_cooldowns.gd's identical fix. +const GAS_PER_SHOT: int = 5 + + +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.vessel_levels["unlock_gas_bomber"] = 1 + game_state.rocks = 0 + game_state.gas = 0 + game_state.fleet_gas_cargo = 0 + main = load("res://Main.tscn").instantiate() + root.add_child(main) + fleet = main.get_node("Fleet") + skill = main.get_node("GasBombController") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.2: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + # An interceptor rides along purely for its "cargo" capability + # (#56): a fleet made only of a gas bomber has zero cargo + # capacity, which would silently clamp every fleet_gas_cargo + # assignment below to 0. + fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_GAS_BOMBER])) + bomber_component = fleet.ships[1].get_component("gas_bomber") + skill._process(0.0) + + # --- A. bank gas does not count ----------------------------------- + game_state.gas = 500 + game_state.fleet_gas_cargo = 0 + skill._process(0.0) + if skill.button.enabled: + return _fail_now("500 banked gas (0 in the fleet hold) left the control enabled") + print("500 banked gas, 0 fleet gas: control correctly disabled") + + # --- B. insufficient fleet gas -------------------------------------- + game_state.fleet_gas_cargo = GAS_PER_SHOT - 1 + skill._process(0.0) + if skill.button.enabled: + return _fail_now("fleet gas one short of GAS_PER_SHOT left the control enabled") + + var press := InputEventScreenTouch.new() + press.index = 3 + press.position = skill.button.global_position + skill.button.size * 0.5 + press.pressed = true + main._handle_touch(press) + if skill.aiming: + return _fail_now("aiming began despite insufficient fleet gas") + if skill.status.text == "": + return _fail_now("an unaffordable press did not explain itself") + print("insufficient fleet gas: disabled, press explains itself, no aiming began") + if not bomber_component.is_ready(): + return _fail_now("a refused press consumed the bomber's cooldown") + if game_state.fleet_resource(GameState.RESOURCE_GAS) != GAS_PER_SHOT - 1: + return _fail_now("a refused press spent fleet gas anyway") + print("no cooldown consumed, no gas spent on a refused press") + + # --- C. exact ammo cost on a real shot ------------------------------ + game_state.fleet_gas_cargo = GAS_PER_SHOT + 7 + skill._process(0.0) + if not skill.button.enabled: + return _fail_now("enough fleet gas still left the control disabled") + + var press2 := InputEventScreenTouch.new() + press2.index = 4 + press2.position = skill.button.global_position + skill.button.size * 0.5 + press2.pressed = true + main._handle_touch(press2) + if not skill.aiming: + return _fail_now("an affordable press did not begin aiming") + var drag := InputEventScreenDrag.new() + drag.index = 4 + drag.position = press2.position + Vector2(-300.0, 0.0) + main._handle_drag(drag) + var release := InputEventScreenTouch.new() + release.index = 4 + release.position = drag.position + release.pressed = false + main._handle_touch(release) + + if skill.projectiles.size() != 1: + return _fail_now("a paid-for shot did not launch a projectile") + if bomber_component.is_ready(): + return _fail_now("firing did not consume the bomber's cooldown") + if game_state.fleet_resource(GameState.RESOURCE_GAS) != 7: + return _fail_now("fleet gas after firing is %d, expected 7 (paid exactly %d)" \ + % [game_state.fleet_resource(GameState.RESOURCE_GAS), GAS_PER_SHOT]) + if game_state.gas != 500: + return _fail_now("firing touched the bank (%d), expected it untouched at 500" % game_state.gas) + print("firing drained exactly %d from the fleet hold, bank untouched" % GAS_PER_SHOT) + + print("TEST_GAS_BOMBER_AMMO: PASS") + quit(0) + return true + return false + + +func _fail_now(message: String) -> bool: + print("TEST_GAS_BOMBER_AMMO: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_gas_bomber_cooldowns.gd b/tests/test_gas_bomber_cooldowns.gd index 2a25403..968d0da 100644 --- a/tests/test_gas_bomber_cooldowns.gd +++ b/tests/test_gas_bomber_cooldowns.gd @@ -2,7 +2,7 @@ extends SceneTree var main: Node var fleet: Node -var skill: GasBombController +var skill: Node var elapsed := 0.0 @@ -12,6 +12,8 @@ func _initialize() -> void: state.extractors.clear() state.transporters.clear() state.vessel_levels["unlock_gas_bomber"] = 1 + # #60: shots draw from the fleet hold now - stock enough for both fires. + state.fleet_gas_cargo = 20 main = load("res://Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") @@ -53,7 +55,7 @@ func _process(delta: float) -> bool: # Rearming one bomber restores one available shot without touching the # other bomber's cooldown. - first.tick(GasBombController.COOLDOWN) + first.tick(5.0) # matches GasBombController.COOLDOWN skill._process(0.0) if not first.is_ready() or second.is_ready() or not skill.button.enabled: return _fail("one completed cooldown did not restore exactly one shot") diff --git a/tests/test_gas_bomber_cooldowns.gd.uid b/tests/test_gas_bomber_cooldowns.gd.uid deleted file mode 100644 index d03a550..0000000 --- a/tests/test_gas_bomber_cooldowns.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cwo6ake5taias From facbd83dd24dc887976a440bb8ace4308f7b58a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 16 Aug 2026 19:11:31 +0200 Subject: [PATCH 3/6] Laser shooters consume energy from the fleet hold when firing (#61) Mirrors #60's gas bomber ammunition exactly: ENERGY_PER_SHOT (8) drawn from GameState.fleet_inventory before consuming the firing ship's cooldown, the button's enabled rule requires affordability too, and an unaffordable-but-otherwise-ready press still explains itself through a new LaserStatus label rather than silently doing nothing. Added two small generic GameState helpers this needed: deposit_fleet_resource()/deposit_bank_resource(), counterparts to #60's withdraw_fleet_resource() for resources (energy) with no dedicated fleet_cargo/fleet_gas_cargo-style property. Along the way, confirmed deposit_resource_to_building() on a "bank" building deposits into that building's own per-record inventory, NOT bank_inventory itself - only building_resource()/building_inventory() special-case the "bank" capability to redirect there. test_laser_vessel.gd (#58) updated to stock fleet energy first, since shots are no longer free - same fix #60 required for the gas bomber's own pre-existing tests. Also fixed test_laser_vessel.gd's own instance of the bare-ClassName.CONST script-corruption trap (documented in #60's commit): var skill: LaserController and a bare LaserController.BEAM_HALF_WIDTH reference, mirrored into a local const instead. --- GameState.gd | 14 ++++ LaserController.gd | 39 ++++++++-- Main.tscn | 16 +++++ PlanetSurface.tscn | 16 +++++ tests/test_laser_ammo.gd | 141 +++++++++++++++++++++++++++++++++++++ tests/test_laser_vessel.gd | 16 ++++- 6 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 tests/test_laser_ammo.gd diff --git a/GameState.gd b/GameState.gd index eb939f3..53a1c4f 100644 --- a/GameState.gd +++ b/GameState.gd @@ -110,6 +110,20 @@ func withdraw_fleet_resource(resource_type: String, amount: int) -> int: return fleet_inventory.withdraw(resource_type, amount) +# #61: generic counterparts, for resources (energy) with no dedicated +# fleet_cargo/fleet_gas_cargo-style property of their own. Deliberately NOT +# the same as deposit_resource_to_building() on a "bank" building - that +# deposits into the building's OWN per-record inventory, not bank_inventory +# itself (only building_resource()/building_inventory() special-case "bank" +# capability to redirect there). +func deposit_fleet_resource(resource_type: String, amount: int) -> int: + return fleet_inventory.deposit(resource_type, amount) + + +func deposit_bank_resource(resource_type: String, amount: int) -> int: + return bank_inventory.deposit(resource_type, amount) + + # #56: total across every resource type in the hold, and the combined cap # that limits it - a HUD readout wants both, not any one resource's amount. func fleet_cargo_used() -> int: diff --git a/LaserController.gd b/LaserController.gd index 78f8e81..1c0a965 100644 --- a/LaserController.gd +++ b/LaserController.gd @@ -18,11 +18,16 @@ const TARGET_COLOR := Color(0.6, 0.95, 1.0) const BEAM_HALF_WIDTH := 22.0 const DRAG_DEADZONE := 22.0 const FLASH_DURATION := 0.22 +# #61: ammunition from the fleet hold, mirroring #60's gas bomber exactly. +const ENERGY_PER_SHOT: int = 8 @export var minimum_zoom: float = 0.4 @onready var fleet: Node2D = get_node("../Fleet") @onready var camera: Camera2D = get_node("../Camera2D") @onready var button: IconButton = get_node("../UI/LaserButton") +@onready var status: Label = get_node("../UI/LaserStatus") + +var status_message_until: int = 0 var aiming := false var aim_touch := -1 @@ -37,15 +42,27 @@ var last_hit_count := 0 func _ready() -> void: button.visible = false + status.text = "" queue_redraw() +func _can_afford_shot() -> bool: + return GameState.fleet_resource(GameState.RESOURCE_ENERGY) >= ENERGY_PER_SHOT + + +func _say(message: String) -> void: + status.text = message + status_message_until = Time.get_ticks_msec() + 3000 + + func _process(delta: float) -> void: var had_visuals := aiming or not beams.is_empty() button.visible = fleet.has_capability("laser") - button.enabled = _next_ready_laser_ship() != null + button.enabled = _next_ready_laser_ship() != null and _can_afford_shot() if not button.visible and aiming: _cancel_aim() + if status.text != "" and Time.get_ticks_msec() >= status_message_until: + status.text = "" if restoring_zoom and not aiming: var z := move_toward(camera.zoom.x, saved_zoom, maxf(saved_zoom, 0.1) * delta * 5.0) @@ -63,14 +80,22 @@ func _process(delta: float) -> void: func can_begin_aim() -> bool: - return button.visible and button.enabled and not button._blocked_by_modal() \ - and not aiming + # Deliberately NOT button.enabled: an unaffordable shot still needs the + # press to reach here so it can explain itself (#60's identical + # reasoning on the gas bomber). A wholly unready fleet (no laser ship at + # all) has nothing more useful to say than "greyed out". + return button.visible and not button._blocked_by_modal() \ + and not aiming and _next_ready_laser_ship() != null func handle_touch(event: InputEventScreenTouch) -> bool: if event.pressed: if not can_begin_aim() or not Rect2(button.global_position, button.size).has_point(event.position): return false + if not _can_afford_shot(): + _say("Need %d energy in the fleet hold to fire (have %d)" \ + % [ENERGY_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_ENERGY)]) + return true aiming = true aim_touch = event.index aim_start_screen = event.position @@ -149,16 +174,22 @@ func _fire() -> void: if shooter == null: _cancel_aim() return + if not _can_afford_shot(): + _say("Need %d energy in the fleet hold to fire (have %d)" \ + % [ENERGY_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_ENERGY)]) + _cancel_aim() + return var component: Node = shooter.get_component("laser") if not component.consume_shot(): _cancel_aim() return + GameState.withdraw_fleet_resource(GameState.RESOURCE_ENERGY, ENERGY_PER_SHOT) var direction: Vector2 = (aim_target - _aim_origin()) direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP var beam_to: Vector2 = shooter.global_position + direction * component.beam_range _strike(shooter.global_position, beam_to, direction, int(component.damage)) _finish_aim() - button.enabled = _next_ready_laser_ship() != null + button.enabled = _next_ready_laser_ship() != null and _can_afford_shot() func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> void: diff --git a/Main.tscn b/Main.tscn index 7e14f37..be098dc 100644 --- a/Main.tscn +++ b/Main.tscn @@ -303,6 +303,22 @@ autowrap_mode = 2 horizontal_alignment = 1 text = "" +[node name="LaserStatus" type="Label" parent="UI"] +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -344 +offset_right = -24 +offset_top = -540 +offset_bottom = -490 +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(0.3, 0.9, 1, 1) +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" + [node name="PivotCWButton" type="Control" parent="UI"] mouse_filter = 2 anchor_left = 1.0 diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index 53f12ed..709872e 100644 --- a/PlanetSurface.tscn +++ b/PlanetSurface.tscn @@ -245,6 +245,22 @@ autowrap_mode = 2 horizontal_alignment = 1 text = "" +[node name="LaserStatus" type="Label" parent="UI"] +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -344 +offset_right = -24 +offset_top = -540 +offset_bottom = -490 +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(0.2, 0.6, 0.68, 1) +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" + [node name="PivotCWButton" type="Control" parent="UI"] mouse_filter = 2 anchor_left = 1.0 diff --git a/tests/test_laser_ammo.gd b/tests/test_laser_ammo.gd new file mode 100644 index 0000000..a38b9ea --- /dev/null +++ b/tests/test_laser_ammo.gd @@ -0,0 +1,141 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_laser_ammo.gd +# Forgejo #61: laser shots draw ammunition from the FLEET HOLD +# (GameState.fleet_inventory), the same way gas bomb shots do (#60) - the +# fleet has to be carrying its own energy. +# +# Covers: +# A. banked energy does not enable firing - only the fleet hold counts +# B. with insufficient fleet energy the button is disabled, pressing it +# explains why, and no beam is fired / no cooldown consumed +# C. firing drains exactly ENERGY_PER_SHOT from the fleet hold and +# nothing from the bank + +var game_state: Node +var main: Node +var fleet: Node +var skill: Node +var laser_component: Node +var stage: int = 0 +var elapsed: float = 0.0 + +# Mirrors LaserController.ENERGY_PER_SHOT's value rather than referencing +# the class constant directly - a bare ClassName.CONST reference elsewhere +# in a test script has been observed to corrupt method resolution for the +# WHOLE script (an unrelated skill._process() call earlier in the same +# file then fails with "Nonexistent function '_process' in base 'Nil'"), +# root cause unconfirmed. See tests/test_gas_bomber_cooldowns.gd's +# identical fix (#60). +const ENERGY_PER_SHOT: int = 8 + + +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.vessel_levels["unlock_laser"] = 1 + game_state.rocks = 0 + game_state.gas = 0 + game_state.fleet_gas_cargo = 0 + main = load("res://Main.tscn").instantiate() + root.add_child(main) + fleet = main.get_node("Fleet") + skill = main.get_node("LaserController") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.2: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + # An interceptor rides along purely for its "cargo" capability + # (#56): a fleet made only of a laser ship has zero cargo + # capacity, which would silently clamp every fleet energy + # deposit below to 0. + fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_LASER])) + laser_component = fleet.ships[1].get_component("laser") + skill._process(0.0) + + # --- A. bank energy does not count ---------------------------- + game_state.deposit_bank_resource(GameState.RESOURCE_ENERGY, 500) + skill._process(0.0) + if skill.button.enabled: + return _fail_now("500 banked energy (0 in the fleet hold) left the control enabled") + print("500 banked energy, 0 fleet energy: control correctly disabled") + + # --- B. insufficient fleet energy ------------------------------- + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, ENERGY_PER_SHOT - 1) + skill._process(0.0) + if skill.button.enabled: + return _fail_now("fleet energy one short of ENERGY_PER_SHOT left the control enabled") + + var press := InputEventScreenTouch.new() + press.index = 3 + press.position = skill.button.global_position + skill.button.size * 0.5 + press.pressed = true + main._handle_touch(press) + if skill.aiming: + return _fail_now("aiming began despite insufficient fleet energy") + if skill.status.text == "": + return _fail_now("an unaffordable press did not explain itself") + print("insufficient fleet energy: disabled, press explains itself, no aiming began") + if not laser_component.is_ready(): + return _fail_now("a refused press consumed the laser ship's cooldown") + if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != ENERGY_PER_SHOT - 1: + return _fail_now("a refused press spent fleet energy anyway") + print("no cooldown consumed, no energy spent on a refused press") + + # --- C. exact ammo cost on a real shot ----------------------------- + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 8) + skill._process(0.0) + if not skill.button.enabled: + return _fail_now("enough fleet energy still left the control disabled") + var fleet_energy_before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY) + + var press2 := InputEventScreenTouch.new() + press2.index = 4 + press2.position = skill.button.global_position + skill.button.size * 0.5 + press2.pressed = true + main._handle_touch(press2) + if not skill.aiming: + return _fail_now("an affordable press did not begin aiming") + var drag := InputEventScreenDrag.new() + drag.index = 4 + drag.position = press2.position + Vector2(-300.0, 0.0) + main._handle_drag(drag) + var release := InputEventScreenTouch.new() + release.index = 4 + release.position = drag.position + release.pressed = false + main._handle_touch(release) + + if laser_component.is_ready(): + return _fail_now("firing did not consume the laser ship's cooldown") + var fleet_energy_after: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY) + if fleet_energy_before - fleet_energy_after != ENERGY_PER_SHOT: + return _fail_now("firing spent %d fleet energy, expected exactly %d" \ + % [fleet_energy_before - fleet_energy_after, ENERGY_PER_SHOT]) + if game_state.banked_resource(GameState.RESOURCE_ENERGY) != 500: + return _fail_now("firing touched the bank (%d), expected it untouched at 500" \ + % game_state.banked_resource(GameState.RESOURCE_ENERGY)) + print("firing drained exactly %d from the fleet hold, bank untouched" % ENERGY_PER_SHOT) + + print("TEST_LASER_AMMO: PASS") + quit(0) + return true + return false + + +func _fail_now(message: String) -> bool: + print("TEST_LASER_AMMO: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_laser_vessel.gd b/tests/test_laser_vessel.gd index 4c3001e..03819d4 100644 --- a/tests/test_laser_vessel.gd +++ b/tests/test_laser_vessel.gd @@ -20,7 +20,12 @@ extends SceneTree var game_state: Node var main: Node var fleet: Node -var skill: LaserController +var skill: Node +# Mirrors LaserController.BEAM_HALF_WIDTH's value rather than referencing +# the class constant directly - a bare ClassName.CONST reference elsewhere +# in a test script has been observed to corrupt method resolution for the +# WHOLE script. See tests/test_gas_bomber_cooldowns.gd's identical fix (#60). +const BEAM_HALF_WIDTH: float = 22.0 var on_line_enemies: Array = [] var off_line_enemy: Node var beyond_range_enemy: Node @@ -42,6 +47,13 @@ func _initialize() -> void: # this ticket is about the laser's own behaviour, not re-proving the # ship-lab research flow (already covered generically elsewhere). game_state.vessel_levels["unlock_laser"] = 1 + # #61: shots draw energy from the fleet hold now - stock enough for two + # fires. Set BEFORE the first spawn_ships() call (inside Main.tscn's own + # _ready(), triggered by instantiate() below): the fleet hold has no + # capacity cap yet at this point (#56's cap is only computed once a + # fleet composition exists), so this deposit can't be clamped by the + # soon-to-exist all-laser fleet having zero "cargo" capability. + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 20) main = load("res://Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") @@ -106,7 +118,7 @@ func _process(delta: float) -> bool: off_line_enemy = load("res://Enemy.tscn").instantiate() off_line_enemy.position = first_shooter.global_position \ - + Vector2(200.0, LaserController.BEAM_HALF_WIDTH + 60.0) + + Vector2(200.0, BEAM_HALF_WIDTH + 60.0) main.add_child(off_line_enemy) off_line_enemy.speed = 0.0 From 651f8b787eac40f9fa699a0d7302a32cde3852aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 16 Aug 2026 19:21:54 +0200 Subject: [PATCH 4/6] Space-time distorder drains energy while boosting (#62) GameState._advance_distorder_drain() runs in GameState._process() - not the distorder component's tick() - alongside extractor mining and production, so it (and the fleet's travel) keeps working across the space<->surface scene swap. Drain scales with how far above 1.0 the multiplier is: (fleet_speed_multiplier - 1.0) / (MAX - 1.0) * ENERGY_DRAIN_PER_SECOND (2.0 at full boost), accumulated fractionally the same way extractor mining_progress is so small multipliers still drain a nonzero amount instead of rounding to zero every frame. Running the fleet hold's energy to exactly zero force-resets fleet_speed_multiplier to 1.0 and raises a one-shot boost_depleted_notice flag, since GameState can't reach into whichever scene's SpeedFader may or may not currently exist. SpeedFader.gd consumes the flag on its next _process(), snaps its knob down (it was already reading the multiplier live, so this falls out for free) and shows "Out of energy!" - also clearing active_touch_index so a still-held drag can't immediately re-boost from the position the finger never moved away from. test_space_time_distorder.gd (#59) updated to stock fleet energy first, for the same reason #60/#61 needed it: boosting is no longer free, and that test's own multi-second boosted phases would otherwise get cut short mid-measurement by running out. --- GameState.gd | 38 ++++++++ SpeedFader.gd | 17 +++- tests/test_distorder_energy.gd | 150 +++++++++++++++++++++++++++++ tests/test_space_time_distorder.gd | 7 ++ 4 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 tests/test_distorder_energy.gd diff --git a/GameState.gd b/GameState.gd index 53a1c4f..6f1f295 100644 --- a/GameState.gd +++ b/GameState.gd @@ -29,6 +29,17 @@ var planet_definition_id: String = "rocky" const MAX_FLEET_SPEED_MULTIPLIER: float = 3.0 var fleet_speed_multiplier: float = 1.0 +# #62: energy cost of sustaining a boost, scaled by how far above 1.0 the +# multiplier is - a small boost costs proportionally less, full boost costs +# ENERGY_DRAIN_PER_SECOND. Sitting at exactly 1.0 costs nothing. +const ENERGY_DRAIN_PER_SECOND: float = 2.0 +var _distorder_drain_progress: float = 0.0 +# One-shot notice, consumed and cleared by SpeedFader.gd: true the instant +# a sustained boost is force-reset for running the fleet hold's energy +# to zero, so the control (which may not even be part of the current +# scene tree at that exact frame) can show why on its next _process(). +var boost_depleted_notice: bool = false + func set_fleet_speed_multiplier(value: float) -> void: fleet_speed_multiplier = clampf(value, 1.0, MAX_FLEET_SPEED_MULTIPLIER) @@ -1165,6 +1176,32 @@ func _advance_gas_burners(delta: float) -> void: b.gas_burn_progress = progress +func _advance_distorder_drain(delta: float) -> void: + if fleet_speed_multiplier <= 1.0: + # Nothing owed at 1.0x - and no stale fractional progress should + # carry over into a LATER, unrelated boost. + _distorder_drain_progress = 0.0 + return + var rate: float = (fleet_speed_multiplier - 1.0) / (MAX_FLEET_SPEED_MULTIPLIER - 1.0) \ + * ENERGY_DRAIN_PER_SECOND + _distorder_drain_progress += rate * delta + var whole: int = int(floor(_distorder_drain_progress)) + if whole <= 0: + return + _distorder_drain_progress -= whole + var available: int = fleet_inventory.amount(RESOURCE_ENERGY) + if available >= whole: + fleet_inventory.withdraw(RESOURCE_ENERGY, whole) + return + # Ran out mid-drain: take whatever is left (down to exactly zero) and + # force the boost back to its default, rather than let the fleet keep + # travelling fast on credit. + fleet_inventory.withdraw(RESOURCE_ENERGY, available) + fleet_speed_multiplier = 1.0 + _distorder_drain_progress = 0.0 + boost_depleted_notice = true + + # Planet conquest: a planet must have every one of its capture points held # before an extractor can be built there (see BuildController's placement # gates). Keyed by the same stable planet_id as `extractors`, entirely @@ -1240,6 +1277,7 @@ func _process(delta: float) -> void: _advance_lab_research(delta) _advance_vessel_deliveries(delta) _advance_gas_burners(delta) + _advance_distorder_drain(delta) for e in extractors: e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE var whole: int = int(floor(float(e.mining_progress))) diff --git a/SpeedFader.gd b/SpeedFader.gd index 6449b77..a70bd68 100644 --- a/SpeedFader.gd +++ b/SpeedFader.gd @@ -22,6 +22,7 @@ extends Control var active_touch_index: int = -1 # 0 = bottom of the track (1.0x), 1 = top (GameState.MAX_FLEET_SPEED_MULTIPLIER). var knob_t: float = 0.0 +var status_message_until: int = 0 func _ready() -> void: @@ -44,7 +45,21 @@ func _process(_delta: float) -> void: return knob_t = (GameState.fleet_speed_multiplier - 1.0) \ / maxf(GameState.MAX_FLEET_SPEED_MULTIPLIER - 1.0, 0.001) - label.text = "x%.1f" % GameState.fleet_speed_multiplier + # #62: GameState._process() (not this node's own _process()) is what + # drains energy and force-resets the multiplier, since it has to keep + # running across the space<->surface swap and while this control might + # not even be in the current scene tree. It leaves a one-shot flag for + # whichever SpeedFader picks it up next. + if GameState.boost_depleted_notice: + GameState.boost_depleted_notice = false + # Still holding the drag at the moment energy ran out would + # otherwise re-boost on the very next drag event, from a knob + # position the finger never actually moved away from. + active_touch_index = -1 + label.text = "Out of energy!" + status_message_until = Time.get_ticks_msec() + 3000 + elif Time.get_ticks_msec() >= status_message_until: + label.text = "x%.1f" % GameState.fleet_speed_multiplier queue_redraw() diff --git a/tests/test_distorder_energy.gd b/tests/test_distorder_energy.gd new file mode 100644 index 0000000..ea73206 --- /dev/null +++ b/tests/test_distorder_energy.gd @@ -0,0 +1,150 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_distorder_energy.gd +# Forgejo #62: sustaining a boost above the fleet's default speed drains +# energy from the fleet hold, continuously, proportional to how far above +# 1.0x the multiplier is. +# +# Most of this is pure GameState math (GameState._advance_distorder_drain, +# called directly the same way other tests call _advance_lab_research/ +# _advance_production) - no Main.tscn/Fleet needed for it, which is itself +# proof the drain is scene-independent (it lives in the autoload's own +# _process(), unlike a component's tick() which only runs while its ship's +# scene is loaded) and so "continues correctly across a landing and +# return" without a scene-swap simulation to re-prove that. +# +# Covers: +# A. sustained boost drains energy at the configured rate; half boost +# drains proportionally less than full boost +# B. a small multiplier still drains a nonzero amount over time - no +# fractional rounding loss +# D. at exactly 1.0x, energy is never consumed +# C. hitting zero energy forces the multiplier back to 1.0, and the +# SpeedFader visibly follows it down with the reason shown + +var game_state: Node +var main: Node +var fleet: Node +var fader: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.reset_progression() + game_state.fleet_speed_multiplier = 1.0 + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 1000) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # --- D. nothing drains at exactly 1.0x ---------------------------- + var before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY) + for i in range(60): + game_state._advance_distorder_drain(1.0) + if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != before: + return _fail_now("energy drained at 1.0x: %d -> %d" \ + % [before, game_state.fleet_resource(GameState.RESOURCE_ENERGY)]) + print("no drain at 1.0x, even over 60 simulated seconds") + + # --- A. full boost drains at the configured rate ------------------ + game_state.fleet_speed_multiplier = GameState.MAX_FLEET_SPEED_MULTIPLIER + var full_before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY) + for i in range(10): + game_state._advance_distorder_drain(1.0) + var full_drained: int = full_before - game_state.fleet_resource(GameState.RESOURCE_ENERGY) + if full_drained != 20: + return _fail_now("full boost drained %d over 10s, expected 20 (ENERGY_DRAIN_PER_SECOND=2.0)" \ + % full_drained) + print("full boost drained %d over 10s, matches ENERGY_DRAIN_PER_SECOND" % full_drained) + + # --- A continued: half boost drains proportionally less ----------- + game_state.fleet_speed_multiplier = 2.0 + var half_before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY) + for i in range(10): + game_state._advance_distorder_drain(1.0) + var half_drained: int = half_before - game_state.fleet_resource(GameState.RESOURCE_ENERGY) + if half_drained <= 0 or half_drained >= full_drained: + return _fail_now("half boost drained %d over 10s, expected between 0 and full's %d" \ + % [half_drained, full_drained]) + print("half boost drained %d over 10s - less than full's %d, still nonzero" \ + % [half_drained, full_drained]) + + # --- B. a small multiplier still drains a nonzero amount ---------- + game_state.fleet_speed_multiplier = 1.05 + var small_before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY) + for i in range(600): + game_state._advance_distorder_drain(0.1) + var small_drained: int = small_before - game_state.fleet_resource(GameState.RESOURCE_ENERGY) + if small_drained <= 0: + return _fail_now("a small (1.05x) sustained boost drained nothing over 60s - fractional rounding loss") + print("small boost (1.05x) still drained %d over 60s (no rounding loss)" % small_drained) + + # --- C. running out resets the multiplier and raises the notice --- + game_state.withdraw_fleet_resource(GameState.RESOURCE_ENERGY, + game_state.fleet_resource(GameState.RESOURCE_ENERGY)) + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 5) + game_state.fleet_speed_multiplier = GameState.MAX_FLEET_SPEED_MULTIPLIER + game_state.boost_depleted_notice = false + for i in range(10): + game_state._advance_distorder_drain(1.0) + if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != 0: + return _fail_now("fleet energy did not reach exactly 0, got %d" \ + % game_state.fleet_resource(GameState.RESOURCE_ENERGY)) + if not is_equal_approx(game_state.fleet_speed_multiplier, 1.0): + return _fail_now("multiplier was not forced back to 1.0 after running out: %f" \ + % game_state.fleet_speed_multiplier) + if not game_state.boost_depleted_notice: + return _fail_now("no depleted notice was raised when energy ran out") + print("running out of energy dropped the multiplier to 1.0 and raised the notice") + + stage = 1 + elapsed = 0.0 + return false + 1: + # --- C continued: the SpeedFader visibly follows it down ---------- + if elapsed < 0.2: + return false + main = load("res://Main.tscn").instantiate() + root.add_child(main) + fleet = main.get_node("Fleet") + fader = main.get_node("UI/SpeedFader") + fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_DISTORDER])) + fader._process(0.0) + if not fader.visible: + return _fail_now("fader hidden with a distorder ship in the fleet") + + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 4) + game_state.set_fleet_speed_multiplier(GameState.MAX_FLEET_SPEED_MULTIPLIER) + fader._process(0.0) + if fader.knob_t < 0.9: + return _fail_now("fader knob did not reflect the boosted multiplier before draining: %f" \ + % fader.knob_t) + + for i in range(10): + game_state._advance_distorder_drain(1.0) + if not is_equal_approx(game_state.fleet_speed_multiplier, 1.0): + return _fail_now("multiplier was not forced back to 1.0 after running out (with a live fleet)") + fader._process(0.0) + if fader.knob_t > 0.05: + return _fail_now("fader knob did not snap down after the boost was depleted: %f" % fader.knob_t) + if fader.label.text != "Out of energy!": + return _fail_now("fader did not show the depleted reason, text was '%s'" % fader.label.text) + if game_state.boost_depleted_notice: + return _fail_now("boost_depleted_notice was not consumed by the fader") + print("fader visibly snapped its knob down and explained the depletion") + + print("TEST_DISTORDER_ENERGY: PASS") + quit(0) + return true + return false + + +func _fail_now(message: String) -> bool: + print("TEST_DISTORDER_ENERGY: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_space_time_distorder.gd b/tests/test_space_time_distorder.gd index 8aff0e7..6890399 100644 --- a/tests/test_space_time_distorder.gd +++ b/tests/test_space_time_distorder.gd @@ -37,6 +37,13 @@ func _initialize() -> void: game_state.stored_vessels.clear() game_state.reset_progression() game_state.vessel_levels["unlock_distorder"] = 1 + # #62: sustaining a boost now drains fleet energy continuously - stock + # plenty so this test's own boosted phases aren't cut short by running + # out mid-measurement. Set BEFORE the first spawn_ships() call (inside + # Main.tscn's own _ready(), triggered by instantiate() below): the fleet + # hold has no capacity cap yet at this point, so this can't be clamped + # by the soon-to-exist fleet's cargo capability. + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 500) main = load("res://Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") From 3f3a22227d1875b65df4e1dde4a59376b2bfa3d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 16 Aug 2026 20:14:29 +0200 Subject: [PATCH 5/6] Add research lab planets and research points (#63) --- BuildController.gd | 38 ++++++- CarrierCapacityUpgrade.tres | 1 + CarrierVesselDefinition.tres | 1 + DistorderVesselDefinition.tres | 1 + GameState.gd | 97 ++++++++++++++--- GasBomberVesselDefinition.tres | 1 + InterceptorDamageUpgrade.tres | 1 + InterceptorRangeUpgrade.tres | 1 + InterceptorRateUpgrade.tres | 1 + InterceptorSpeedUpgrade.tres | 1 + LaserCooldownUpgrade.tres | 1 + LaserDamageUpgrade.tres | 1 + LaserRangeUpgrade.tres | 1 + LaserVesselDefinition.tres | 1 + Main.tscn | 12 +-- PlanetDefinition.gd | 4 + PlanetDefinitions.gd | 2 + PlanetSetupHost.gd | 3 +- PlanetSetups/ResearchLabSetup.tres | 42 ++++++++ PlanetSurfaceProfiles.gd | 1 + ResearchLabPlanetDefinition.tres | 15 +++ ResearchLabPlanetVisual.gd | 55 ++++++++++ ResearchLabPlanetVisual.tscn | 6 ++ ResearchLabSurfaceProfile.tres | 12 +++ ShieldHitsUpgrade.tres | 1 + ShieldRechargeUpgrade.tres | 1 + ShieldSpeedUpgrade.tres | 1 + ShieldVesselDefinition.tres | 1 + TransportCapacityUpgrade.tres | 1 + TransportSpeedUpgrade.tres | 1 + VesselDefinition.gd | 4 + VesselUpgradeDefinition.gd | 4 + WorldGen.gd | 8 ++ tests/_skill_menu_preview.gd | 17 ++- tests/test_carrier_vessel.gd | 1 + tests/test_extractor_income.gd | 1 + tests/test_gas_bomber.gd | 1 + tests/test_lab_resources.gd | 1 + tests/test_lab_transporter_delivery.gd | 1 + tests/test_multi_resource_supply.gd | 1 + tests/test_research_points.gd | 143 +++++++++++++++++++++++++ tests/test_ship_lab.gd | 1 + tests/test_skill_tree.gd | 1 + 43 files changed, 459 insertions(+), 30 deletions(-) create mode 100644 PlanetSetups/ResearchLabSetup.tres create mode 100644 ResearchLabPlanetDefinition.tres create mode 100644 ResearchLabPlanetVisual.gd create mode 100644 ResearchLabPlanetVisual.tscn create mode 100644 ResearchLabSurfaceProfile.tres create mode 100644 tests/test_research_points.gd diff --git a/BuildController.gd b/BuildController.gd index c8ed446..be1adf7 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -680,6 +680,13 @@ func _confirm_build() -> void: if not GameState.is_planet_conquered(planet.planet_id): build_status.text = "%s is not captured yet" % planet.planet_name return + if String(planet.resource_type) == "": + # #63: a research_lab planet's whole point is that it yields + # nothing to mine - an extractor there would sit on an empty + # resource id with no ResourceDefinition, breaking its own + # glyph/HUD rendering the moment it produced anything. + build_status.text = "%s has nothing to extract" % planet.planet_name + return if not GameState.spend(definition.cost): build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.rocks] return @@ -723,14 +730,18 @@ func _refresh_skill_menu(id: String = "") -> void: # leaving the panel blank, which reads as broken. skill_detail.text = "Select a node to see what it unlocks." skill_cost.amount = 0 - skill_status.text = "Fleet capacity %d" % GameState.max_fleet_size() + skill_status.text = "Fleet capacity %d · Research points %d" \ + % [GameState.max_fleet_size(), GameState.research_points] skill_unlock_button.visible = false return var node: Dictionary = GameState.skill_node(id) if node.is_empty(): return + var points_needed: int = int(node.get("research_points", 0)) skill_detail.text = "%s\n%s" % [node.name, node.desc] + if points_needed > 0: + skill_detail.text += "\nResearch points: %d/%d" % [GameState.research_points, points_needed] skill_cost.amount = int(node.cost) if GameState.is_unlocked(id): @@ -742,6 +753,12 @@ func _refresh_skill_menu(id: String = "") -> void: elif not GameState.can_afford(int(node.cost)): skill_status.text = "Not enough rocks - you have %d" % GameState.rocks skill_unlock_button.visible = false + elif GameState.research_points < points_needed: + # #63: a distinct reason from the ore one above - conflating them + # would leave the player trying to fix the wrong shortage. + skill_status.text = "Not enough research points - you have %d, need %d" \ + % [GameState.research_points, points_needed] + skill_unlock_button.visible = false else: skill_status.text = "" skill_unlock_button.visible = true @@ -902,7 +919,8 @@ func _refresh_lab() -> void: 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_name(lab_vessel) + lab_vessel_label.text = "%s research · %d RP" % [ + GameState.vessel_name(lab_vessel), GameState.research_points] var options: Array = _lab_options() for i in range(lab_upgrade_buttons.size()): @@ -914,6 +932,9 @@ func _refresh_lab() -> void: var option: Dictionary = options[i] var price_text: String = _format_costs(option.costs if bool(option.unlock) \ else GameState.research_costs(option.id)) + var point_cost: int = GameState.research_point_cost(String(option.id)) + if point_cost > 0: + price_text += " · %d RP" % point_cost 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" % [ @@ -932,11 +953,22 @@ func _refresh_lab() -> void: lab_choice = clampi(lab_choice, 0, options.size() - 1) var choice: Dictionary = options[lab_choice] + var choice_points: int = GameState.research_point_cost(String(choice.id)) lab_detail.text = "%s\n%s" % [choice.name, choice.desc] + if choice_points > 0: + lab_detail.text += "\nResearch points: %d/%d" % [GameState.research_points, choice_points] var queued: Dictionary = GameState.lab_research_order(lab_building_id) lab_buy_button.enabled = queued.is_empty() and (choice.unlock or not GameState.upgrade_maxed(choice.id)) if not queued.is_empty(): - lab_status.text = "%s %d%%" % ["Researching" if bool(queued.started) else "Waiting for resources", int(100.0 * float(queued.elapsed) / maxf(GameState.lab_research_time(), 0.001))] + var queued_points: int = GameState.research_point_cost(String(queued.id)) + if not bool(queued.started) and GameState.research_points < queued_points: + # #63: distinct from "Waiting for resources" - flying more ore/gas + # to this lab cannot fix a research-point shortfall, only + # conquering a research lab elsewhere can. + lab_status.text = "Need %d research point%s (have %d)" \ + % [queued_points, "" if queued_points == 1 else "s", GameState.research_points] + else: + lab_status.text = "%s %d%%" % ["Researching" if bool(queued.started) else "Waiting for resources", int(100.0 * float(queued.elapsed) / maxf(GameState.lab_research_time(), 0.001))] lab_buy_button.queue_redraw() diff --git a/CarrierCapacityUpgrade.tres b/CarrierCapacityUpgrade.tres index 8736c1b..99c01ac 100644 --- a/CarrierCapacityUpgrade.tres +++ b/CarrierCapacityUpgrade.tres @@ -7,5 +7,6 @@ display_name = "Reinforced Hold" description = "+30 cargo capacity per level." base_cost = 40 max_level = 3 +research_point_cost = 1 icon = 21 additive_modifiers = {"cargo_capacity": 30.0} diff --git a/CarrierVesselDefinition.tres b/CarrierVesselDefinition.tres index 6d7012b..91d2743 100644 --- a/CarrierVesselDefinition.tres +++ b/CarrierVesselDefinition.tres @@ -22,4 +22,5 @@ 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} +research_point_cost = 1 unlock_icon = -1 diff --git a/DistorderVesselDefinition.tres b/DistorderVesselDefinition.tres index 9e2a0de..c9bc3e7 100644 --- a/DistorderVesselDefinition.tres +++ b/DistorderVesselDefinition.tres @@ -21,4 +21,5 @@ unlock_name = "Unlock Distorder" unlock_description = "Research a space-time distortion hull - while one flies with the fleet, a fader lets you push cruise speed beyond its default." unlock_cost = 0 unlock_resource_costs = {"energy": 50} +research_point_cost = 1 unlock_icon = -1 diff --git a/GameState.gd b/GameState.gd index 6f1f295..8397b7e 100644 --- a/GameState.gd +++ b/GameState.gd @@ -4,6 +4,7 @@ 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") +const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd") # Autoload singleton - the one thing that survives change_scene_to_file() @@ -44,6 +45,13 @@ var boost_depleted_notice: bool = false func set_fleet_speed_multiplier(value: float) -> void: fleet_speed_multiplier = clampf(value, 1.0, MAX_FLEET_SPEED_MULTIPLIER) + +# #63: currency gated behind conquering a research_lab planet - required by +# both progression paths (skill tree nodes, vessel/upgrade research) on top +# of their existing ore/resource costs. +signal research_points_changed(total: int) +var research_points: int = 0 + # Explored-chunk history for the world map's fog-of-war reveal. Lives # here (not on ExplorationTracker, which gets destroyed and recreated on # every scene swap) so exploration progress isn't lost every time the @@ -272,6 +280,13 @@ var buildings: Array = [] # tree always reads root-at-the-bottom and cannot drift out of step with the # dependencies when nodes are added. `requires` is a single prerequisite id # ("" for a root). +# #63: research_points (default 0 where omitted) is a SEPARATE currency from +# cost (ore), spent only from GameState.research_points - earned by +# conquering a research_lab planet, never bought. Balance, decided +# deliberately: shipyards and logistics stay at 0 so a fresh game can still +# reach a ship crafter and an extractor with zero research points (see the +# closing comment on #63 for the reasoning); every other node - none of them +# required for that opening - costs at least 1. const SKILL_TREE: Array = [ { "id": "shipyards", "name": "Shipyards", "cost": 15, "requires": "", @@ -285,42 +300,42 @@ const SKILL_TREE: Array = [ }, { "id": "coordination", "name": "Coordination", "cost": 20, "requires": "shipyards", - "x": 0.50, "unlocks": BUILDING_COMMAND_CENTER, + "x": 0.50, "unlocks": BUILDING_COMMAND_CENTER, "research_points": 1, "desc": "Build command centers, where the fleet formation is arranged.", }, { "id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics", - "x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, + "x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, "research_points": 1, "desc": "Build transport hubs. Each hub includes its own automatic transporters.", }, { "id": "vessel_science", "name": "Vessel Science", "cost": 35, "requires": "shipyards", - "x": 0.82, "unlocks": BUILDING_SHIP_LAB, + "x": 0.82, "unlocks": BUILDING_SHIP_LAB, "research_points": 1, "desc": "Build ship labs, where each vessel type is upgraded and new ones unlocked.", }, { "id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight", - "x": 0.18, "hub_bonus": 1, + "x": 0.18, "hub_bonus": 1, "research_points": 2, "desc": "Permanent: +1 transporter stationed at every hub, including ones already built.", }, { "id": "power_systems", "name": "Power Systems", "cost": 45, "requires": "freight", - "x": 0.50, "unlocks": BUILDING_GAS_BURNER, + "x": 0.50, "unlocks": BUILDING_GAS_BURNER, "research_points": 2, "desc": "Build gas burners. Each converts gas into energy at its own site - hub transporters keep it stocked and collect what it makes.", }, { "id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination", - "x": 0.82, "fleet_bonus": 1, + "x": 0.82, "fleet_bonus": 1, "research_points": 1, "desc": "Permanent: +1 vessel in the fleet.", }, { "id": "squadron", "name": "Squadron Doctrine", "cost": 70, "requires": "wing", - "x": 0.50, "fleet_bonus": 1, + "x": 0.50, "fleet_bonus": 1, "research_points": 2, "desc": "Permanent: +1 vessel in the fleet.", }, { "id": "armada", "name": "Armada Doctrine", "cost": 150, "requires": "squadron", - "x": 0.50, "fleet_bonus": 1, + "x": 0.50, "fleet_bonus": 1, "research_points": 3, "desc": "Permanent: +1 vessel in the fleet.", }, ] @@ -370,16 +385,23 @@ func prerequisite_met(id: String) -> bool: func can_unlock(id: String) -> bool: - return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(skill_node(id).cost)) + var node: Dictionary = skill_node(id) + return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(node.cost)) \ + and research_points >= int(node.get("research_points", 0)) func unlock(id: String) -> bool: if not can_unlock(id): return false - if not spend(int(skill_node(id).cost)): + var node: Dictionary = skill_node(id) + if not spend(int(node.cost)): return false + var points_needed: int = int(node.get("research_points", 0)) + if points_needed > 0: + research_points -= points_needed + research_points_changed.emit(research_points) unlocked.append(id) - if int(skill_node(id).get("hub_bonus", 0)) > 0: + if int(node.get("hub_bonus", 0)) > 0: _sync_hub_transporters() return true @@ -424,6 +446,7 @@ func reset_new_game() -> void: planet_id = "" planet_definition_id = "rocky" fleet_speed_multiplier = 1.0 + research_points = 0 explored_chunks.clear() bank_inventory = INVENTORY.new({RESOURCE_ORE: 500}) fleet_inventory = INVENTORY.new() @@ -600,10 +623,14 @@ func unlock_vessel(vessel: String) -> bool: if definition == null or definition.unlock_id == "" or vessel_is_unlocked(vessel) or not has_ship_lab(): return false var costs: Dictionary = research_costs(definition.unlock_id) - if not can_afford_resources(costs): + var points_needed: int = int(definition.research_point_cost) + if not can_afford_resources(costs) or research_points < points_needed: return false for resource_type in costs: bank_inventory.withdraw(String(resource_type), int(costs[resource_type])) + if points_needed > 0: + research_points -= points_needed + research_points_changed.emit(research_points) vessel_levels[definition.unlock_id] = 1 vessel_upgraded.emit(definition.unlock_id) return true @@ -650,7 +677,8 @@ func upgrade_cost(id: String) -> int: func can_buy_upgrade(id: String) -> bool: - return has_ship_lab() and not upgrade_maxed(id) and can_afford(upgrade_cost(id)) + return has_ship_lab() and not upgrade_maxed(id) and can_afford(upgrade_cost(id)) \ + and research_points >= research_point_cost(id) func buy_upgrade(id: String) -> bool: @@ -658,6 +686,10 @@ func buy_upgrade(id: String) -> bool: return false if not spend(upgrade_cost(id)): return false + var points_needed: int = research_point_cost(id) + if points_needed > 0: + research_points -= points_needed + research_points_changed.emit(research_points) vessel_levels[id] = upgrade_level(id) + 1 vessel_upgraded.emit(id) return true @@ -920,6 +952,14 @@ func research_cost(id: String) -> int: return int(research_costs(id).get(RESOURCE_ORE, 0)) +func research_point_cost(id: String) -> int: + var unlock_definition: Resource = vessel_for_unlock(id) + if unlock_definition != null: + return int(unlock_definition.research_point_cost) + var d: Dictionary = upgrade_def(id) + return int(d.resource.research_point_cost) if not d.is_empty() else 0 + + func research_costs(id: String) -> Dictionary: var unlock_definition: Resource = vessel_for_unlock(id) if unlock_definition != null: @@ -948,10 +988,20 @@ func _advance_lab_research(delta: float) -> void: if inventory.amount(String(resource_type)) < int(costs[resource_type]): affordable = false break + # #63: research points are a fleet-wide currency, not delivered + # to the lab like ore/gas - checked and spent here too so a + # shortfall stalls the order the same way an undelivered + # resource does, rather than letting it start regardless. + var points_needed: int = research_point_cost(id) + if points_needed > research_points: + affordable = false if not affordable: continue for resource_type in costs: inventory.withdraw(String(resource_type), int(costs[resource_type])) + if points_needed > 0: + research_points -= points_needed + research_points_changed.emit(research_points) order.started = true order.elapsed = float(order.elapsed) + delta if float(order.elapsed) < lab_research_time(): @@ -1212,7 +1262,7 @@ var planet_capture: Dictionary = {} signal planet_conquered(planet_id: String) -func register_capture_points(planet_key: String, total: int) -> void: +func register_capture_points(planet_key: String, total: int, definition_id: String = "") -> void: # Idempotent: landing again on a planet must not wipe points already # captured on a previous visit. if planet_capture.has(planet_key): @@ -1223,8 +1273,13 @@ func register_capture_points(planet_key: String, total: int) -> void: # A planet demanding zero points is vacuously already held (#55: a # black hole's "void" profile has no authored PlanetSetup at all, so # this is the only registration it ever gets) - every planet WITH real - # points still starts unconquered, exactly as before. - planet_capture[planet_key] = {"captured": captured, "conquered": total <= 0} + # points still starts unconquered, exactly as before. The planet definition + # supplies an optional conquest reward, avoiding planet-id special cases in + # the capture system. + var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id) + var point_reward: int = 0 if definition == null else int(definition.conquest_research_points) + planet_capture[planet_key] = {"captured": captured, "conquered": total <= 0, + "research_point_reward": point_reward, "research_awarded": false} func capture_state(planet_key: String) -> Dictionary: @@ -1251,6 +1306,16 @@ func mark_point_captured(planet_key: String, index: int) -> void: if captured.all(func(c): return bool(c)): state.conquered = true planet_conquered.emit(planet_key) + # #63: definition-authored research points, exactly once per planet. + # state.conquered flipping above already makes a second call to this + # whole function a no-op via the early return above it, but + # research_awarded is its own explicit guard on the award itself + # regardless of how conquest was reached. + var reward: int = int(state.get("research_point_reward", 0)) + if reward > 0 and not bool(state.get("research_awarded", false)): + state.research_awarded = true + research_points += reward + research_points_changed.emit(research_points) func is_planet_conquered(planet_key: String) -> bool: diff --git a/GasBomberVesselDefinition.tres b/GasBomberVesselDefinition.tres index d464a4e..aad85ae 100644 --- a/GasBomberVesselDefinition.tres +++ b/GasBomberVesselDefinition.tres @@ -23,4 +23,5 @@ unlock_name = "Unlock Gas Bomber" unlock_description = "Research volatile-gas containment and unlock the Gas Bomber for ship crafters." unlock_cost = 0 unlock_resource_costs = {"gas": 30} +research_point_cost = 1 unlock_icon = 31 diff --git a/InterceptorDamageUpgrade.tres b/InterceptorDamageUpgrade.tres index 835851a..a0845fc 100644 --- a/InterceptorDamageUpgrade.tres +++ b/InterceptorDamageUpgrade.tres @@ -7,5 +7,6 @@ display_name = "Heavy Slugs" description = "+1 damage per shot." base_cost = 30 max_level = 3 +research_point_cost = 1 icon = 15 additive_modifiers = {"damage": 1.0} diff --git a/InterceptorRangeUpgrade.tres b/InterceptorRangeUpgrade.tres index ce01efe..b986ba0 100644 --- a/InterceptorRangeUpgrade.tres +++ b/InterceptorRangeUpgrade.tres @@ -7,5 +7,6 @@ display_name = "Targeting Array" description = "+60 firing range per level." base_cost = 20 max_level = 3 +research_point_cost = 1 icon = 17 additive_modifiers = {"fire_range": 60.0} diff --git a/InterceptorRateUpgrade.tres b/InterceptorRateUpgrade.tres index 4add45e..57dabe6 100644 --- a/InterceptorRateUpgrade.tres +++ b/InterceptorRateUpgrade.tres @@ -7,5 +7,6 @@ display_name = "Autoloader" description = "Fires 15% faster per level." base_cost = 25 max_level = 3 +research_point_cost = 1 icon = 16 multiplicative_modifiers = {"fire_cooldown": 0.85} diff --git a/InterceptorSpeedUpgrade.tres b/InterceptorSpeedUpgrade.tres index 5549971..16ac1fe 100644 --- a/InterceptorSpeedUpgrade.tres +++ b/InterceptorSpeedUpgrade.tres @@ -7,5 +7,6 @@ display_name = "Thrusters" description = "+40 top speed, +60 acceleration per level." base_cost = 25 max_level = 3 +research_point_cost = 1 icon = 18 additive_modifiers = {"max_speed": 40.0, "acceleration": 60.0} diff --git a/LaserCooldownUpgrade.tres b/LaserCooldownUpgrade.tres index 71ad6aa..b89ee4a 100644 --- a/LaserCooldownUpgrade.tres +++ b/LaserCooldownUpgrade.tres @@ -7,5 +7,6 @@ display_name = "Capacitor Bank" description = "Recharges 0.6s sooner per level." base_cost = 30 max_level = 3 +research_point_cost = 1 icon = 20 additive_modifiers = {"beam_cooldown": -0.6} diff --git a/LaserDamageUpgrade.tres b/LaserDamageUpgrade.tres index daa76ab..e9c4c97 100644 --- a/LaserDamageUpgrade.tres +++ b/LaserDamageUpgrade.tres @@ -7,5 +7,6 @@ display_name = "Focusing Lens" description = "+1 damage per shot." base_cost = 30 max_level = 3 +research_point_cost = 1 icon = 15 additive_modifiers = {"damage": 1.0} diff --git a/LaserRangeUpgrade.tres b/LaserRangeUpgrade.tres index 447c58e..755e585 100644 --- a/LaserRangeUpgrade.tres +++ b/LaserRangeUpgrade.tres @@ -7,5 +7,6 @@ display_name = "Beam Collimator" description = "+150 beam range per level." base_cost = 25 max_level = 3 +research_point_cost = 1 icon = 17 additive_modifiers = {"beam_range": 150.0} diff --git a/LaserVesselDefinition.tres b/LaserVesselDefinition.tres index 370a0d5..ea4b7bc 100644 --- a/LaserVesselDefinition.tres +++ b/LaserVesselDefinition.tres @@ -26,4 +26,5 @@ unlock_name = "Unlock Laser Ship" unlock_description = "Research a directed-energy hull for the ship crafter - manually aimed, no ammo, cools down between shots." unlock_cost = 0 unlock_resource_costs = {"energy": 40} +research_point_cost = 1 unlock_icon = -1 diff --git a/Main.tscn b/Main.tscn index be098dc..e7a1427 100644 --- a/Main.tscn +++ b/Main.tscn @@ -2086,9 +2086,9 @@ anchor_top = 1.0 anchor_right = 0.5 anchor_bottom = 1.0 offset_left = -64.0 -offset_top = -456.0 +offset_top = -400.0 offset_right = 76.0 -offset_bottom = -392.0 +offset_bottom = -336.0 script = ExtResource("19") digit_height = 52.0 @@ -2098,9 +2098,9 @@ anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 offset_left = 32.0 -offset_top = -386.0 +offset_top = -326.0 offset_right = -32.0 -offset_bottom = -318.0 +offset_bottom = -250.0 theme_override_font_sizes/font_size = 32 autowrap_mode = 2 horizontal_alignment = 1 @@ -2112,9 +2112,9 @@ anchor_top = 1.0 anchor_right = 0.5 anchor_bottom = 1.0 offset_left = -74.0 -offset_top = -304.0 +offset_top = -236.0 offset_right = 74.0 -offset_bottom = -156.0 +offset_bottom = -88.0 script = ExtResource("10") icon = 5 glow_color = Color(0.45, 0.9, 0.55, 1) diff --git a/PlanetDefinition.gd b/PlanetDefinition.gd index e184241..6678109 100644 --- a/PlanetDefinition.gd +++ b/PlanetDefinition.gd @@ -8,3 +8,7 @@ extends Resource @export var surface_profile_id: String = "rocky" @export var resource_id: String = "ore" @export var map_color: Color = Color(0.7, 0.65, 0.55) +# Optional reward granted once when every authored capture point is claimed. +# Keeping it on the definition lets conquest remain generic as new planet +# rewards are introduced. +@export var conquest_research_points: int = 0 diff --git a/PlanetDefinitions.gd b/PlanetDefinitions.gd index bada655..197202e 100644 --- a/PlanetDefinitions.gd +++ b/PlanetDefinitions.gd @@ -4,11 +4,13 @@ extends RefCounted const ROCKY_ID: String = "rocky" const GAS_GIANT_ID: String = "gas_giant" const BLACK_HOLE_ID: String = "black_hole" +const RESEARCH_LAB_ID: String = "research_lab" const DEFAULT_DEFINITION_ID: String = ROCKY_ID const DEFINITIONS: Dictionary = { ROCKY_ID: preload("res://RockyPlanetDefinition.tres"), GAS_GIANT_ID: preload("res://GasGiantDefinition.tres"), BLACK_HOLE_ID: preload("res://BlackHolePlanetDefinition.tres"), + RESEARCH_LAB_ID: preload("res://ResearchLabPlanetDefinition.tres"), } diff --git a/PlanetSetupHost.gd b/PlanetSetupHost.gd index 1b79167..99e145e 100644 --- a/PlanetSetupHost.gd +++ b/PlanetSetupHost.gd @@ -19,7 +19,8 @@ func apply_setup(new_setup: Resource) -> void: if setup == null: return - GameState.register_capture_points(GameState.planet_id, setup.capture_points.size()) + GameState.register_capture_points(GameState.planet_id, setup.capture_points.size(), + GameState.planet_definition_id) for i in range(setup.capture_points.size()): _add_capture_point(i, setup.capture_points[i]) for spawn in setup.enemy_spawns: diff --git a/PlanetSetups/ResearchLabSetup.tres b/PlanetSetups/ResearchLabSetup.tres new file mode 100644 index 0000000..20b4075 --- /dev/null +++ b/PlanetSetups/ResearchLabSetup.tres @@ -0,0 +1,42 @@ +[gd_resource type="Resource" script_class="PlanetSetup" format=3] + +[ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="1"] +[ext_resource type="Script" path="res://PlanetSetup.gd" id="2"] + +[sub_resource type="Resource" id="Resource_ta1"] +script = ExtResource("1") +enemy_id = "turret_spawner" +position = Vector2(-620.0, -180.0) + +[sub_resource type="Resource" id="Resource_ta2"] +script = ExtResource("1") +enemy_id = "turret_spawner" +position = Vector2(640.0, -160.0) + +[sub_resource type="Resource" id="Resource_ta3"] +script = ExtResource("1") +enemy_id = "artillery" +position = Vector2(0.0, -720.0) + +[sub_resource type="Resource" id="Resource_ta4"] +script = ExtResource("1") +enemy_id = "sniper" +position = Vector2(-380.0, 420.0) + +[sub_resource type="Resource" id="Resource_ta5"] +script = ExtResource("1") +enemy_id = "sniper" +position = Vector2(400.0, 440.0) + +[sub_resource type="Resource" id="Resource_ta6"] +script = ExtResource("1") +enemy_id = "chaser" +position = Vector2(120.0, 60.0) + +[resource] +script = ExtResource("2") +setup_id = "research_lab_1" +display_name = "Research Lab Garrison" +surface_profile_id = "research_lab" +capture_points = PackedVector2Array(-450.0, -60.0, 460.0, -40.0, 0.0, 480.0) +enemy_spawns = Array[Resource]([SubResource("Resource_ta1"), SubResource("Resource_ta2"), SubResource("Resource_ta3"), SubResource("Resource_ta4"), SubResource("Resource_ta5"), SubResource("Resource_ta6")]) diff --git a/PlanetSurfaceProfiles.gd b/PlanetSurfaceProfiles.gd index 5624fae..9d42e5e 100644 --- a/PlanetSurfaceProfiles.gd +++ b/PlanetSurfaceProfiles.gd @@ -6,6 +6,7 @@ const PROFILES: Dictionary = { "rocky": preload("res://RockySurfaceProfile.tres"), "gas": preload("res://GasSurfaceProfile.tres"), "void": preload("res://VoidSurfaceProfile.tres"), + "research_lab": preload("res://ResearchLabSurfaceProfile.tres"), } diff --git a/ResearchLabPlanetDefinition.tres b/ResearchLabPlanetDefinition.tres new file mode 100644 index 0000000..ac195df --- /dev/null +++ b/ResearchLabPlanetDefinition.tres @@ -0,0 +1,15 @@ +[gd_resource type="Resource" script_class="PlanetDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://PlanetDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://ResearchLabPlanetVisual.tscn" id="2"] + +[resource] +script = ExtResource("1") +definition_id = "research_lab" +display_name = "Research Lab" +names = PackedStringArray("Blacksite Zero", "Cordoned Array", "Dead Signal Station", "The Vault", "Interdicted Post") +visual_scene = ExtResource("2") +surface_profile_id = "research_lab" +resource_id = "" +map_color = Color(0.7, 0.2, 0.15, 1) +conquest_research_points = 1 diff --git a/ResearchLabPlanetVisual.gd b/ResearchLabPlanetVisual.gd new file mode 100644 index 0000000..7f832a9 --- /dev/null +++ b/ResearchLabPlanetVisual.gd @@ -0,0 +1,55 @@ +extends Node2D + +# Same informal interface as GasGiantVisual/RockyPlanetVisual - configure(), +# extractor_anchor(), animates via its own _process(). Deliberately angular +# and armoured rather than a natural silhouette (#63: this is an ENEMY +# installation, not a celestial body), with a rotating dish and blinking +# warning lights to read as occupied/hostile at a glance. + +var phase: float = 0.0 + +const RADIUS: float = 108.0 +const HULL_COLOR: Color = Color(0.55, 0.1, 0.08) +const CORE_COLOR: Color = Color(0.9, 0.25, 0.18) +const WARNING_COLOR: Color = Color(1.0, 0.2, 0.15) +const DARK_CORE: Color = Color(0.14, 0.02, 0.02) + + +func configure(_shape_seed: int) -> void: + queue_redraw() + + +func extractor_anchor() -> Vector2: + return Vector2(0.0, -(RADIUS + 40.0)) + + +func _process(delta: float) -> void: + phase += delta + queue_redraw() + + +func _draw() -> void: + var hull: PackedVector2Array = PackedVector2Array() + for i in range(6): + var angle: float = TAU * float(i) / 6.0 + hull.append(Vector2(cos(angle), sin(angle)) * RADIUS) + NeonDraw.draw_glowing_polygon(self, hull, HULL_COLOR, 0.85, DARK_CORE) + + var inner: PackedVector2Array = PackedVector2Array() + for i in range(6): + var angle: float = TAU * float(i) / 6.0 + PI / 6.0 + inner.append(Vector2(cos(angle), sin(angle)) * RADIUS * 0.55) + NeonDraw.draw_glowing_polygon(self, inner, CORE_COLOR, 0.9, DARK_CORE) + + # A slow-rotating dish - reads as actively transmitting, not derelict. + var dish_angle: float = phase * 0.6 + var dish_tip: Vector2 = Vector2(cos(dish_angle), sin(dish_angle)) * RADIUS * 1.35 + NeonDraw.draw_glowing_line(self, Vector2.ZERO, dish_tip, CORE_COLOR, 0.8, 0.55, DARK_CORE) + NeonDraw.draw_glowing_circle(self, dish_tip, 11.0, CORE_COLOR, 1.0, DARK_CORE) + + # Blinking hull-corner warning lights. + var blink: float = 0.4 + 0.6 * absf(sin(phase * 4.0)) + for i in range(6): + var angle: float = TAU * float(i) / 6.0 + var at: Vector2 = Vector2(cos(angle), sin(angle)) * RADIUS + NeonDraw.draw_glowing_circle(self, at, 5.0, WARNING_COLOR, blink, DARK_CORE) diff --git a/ResearchLabPlanetVisual.tscn b/ResearchLabPlanetVisual.tscn new file mode 100644 index 0000000..bb2ebaa --- /dev/null +++ b/ResearchLabPlanetVisual.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ResearchLabPlanetVisual.gd" id="1"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") diff --git a/ResearchLabSurfaceProfile.tres b/ResearchLabSurfaceProfile.tres new file mode 100644 index 0000000..98e83c8 --- /dev/null +++ b/ResearchLabSurfaceProfile.tres @@ -0,0 +1,12 @@ +[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=2 format=3] + +[ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"] + +[resource] +script = ExtResource("1") +profile_id = "research_lab" +base_color = Color(0.13, 0.1, 0.1, 1) +crater_color = Color(0.05, 0.03, 0.03, 1) +highlight_color = Color(0.55, 0.15, 0.1, 1) +caption_color = Color(0.9, 0.6, 0.55, 1) +caption_outline_color = Color(0.05, 0.02, 0.02, 0.9) diff --git a/ShieldHitsUpgrade.tres b/ShieldHitsUpgrade.tres index 2530597..bbbd180 100644 --- a/ShieldHitsUpgrade.tres +++ b/ShieldHitsUpgrade.tres @@ -7,5 +7,6 @@ display_name = "Layered Emitters" description = "+1 hit absorbed before the shield drops." base_cost = 60 max_level = 2 +research_point_cost = 1 icon = 19 additive_modifiers = {"shield_charges": 1.0} diff --git a/ShieldRechargeUpgrade.tres b/ShieldRechargeUpgrade.tres index d73cf09..756523c 100644 --- a/ShieldRechargeUpgrade.tres +++ b/ShieldRechargeUpgrade.tres @@ -7,5 +7,6 @@ display_name = "Fast Cycling" description = "Shield returns 1.2s sooner per level." base_cost = 35 max_level = 3 +research_point_cost = 1 icon = 20 additive_modifiers = {"shield_recharge": -1.2} diff --git a/ShieldSpeedUpgrade.tres b/ShieldSpeedUpgrade.tres index fe5a4b6..ef2b8a4 100644 --- a/ShieldSpeedUpgrade.tres +++ b/ShieldSpeedUpgrade.tres @@ -7,5 +7,6 @@ display_name = "Thrusters" description = "+40 top speed, +60 acceleration per level." base_cost = 25 max_level = 3 +research_point_cost = 1 icon = 18 additive_modifiers = {"max_speed": 40.0, "acceleration": 60.0} diff --git a/ShieldVesselDefinition.tres b/ShieldVesselDefinition.tres index aa4c440..0a99ba1 100644 --- a/ShieldVesselDefinition.tres +++ b/ShieldVesselDefinition.tres @@ -24,4 +24,5 @@ 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 +research_point_cost = 1 unlock_icon = 23 diff --git a/TransportCapacityUpgrade.tres b/TransportCapacityUpgrade.tres index 2c2b920..87b331c 100644 --- a/TransportCapacityUpgrade.tres +++ b/TransportCapacityUpgrade.tres @@ -7,5 +7,6 @@ display_name = "Expanded Hold" description = "Carries 8 more resources per supply run." base_cost = 30 max_level = 3 +research_point_cost = 1 icon = 21 additive_modifiers = {"cargo_capacity": 8.0} diff --git a/TransportSpeedUpgrade.tres b/TransportSpeedUpgrade.tres index 43c3c96..06c3f42 100644 --- a/TransportSpeedUpgrade.tres +++ b/TransportSpeedUpgrade.tres @@ -7,5 +7,6 @@ display_name = "Freight Engines" description = "+70 speed on supply runs." base_cost = 30 max_level = 3 +research_point_cost = 1 icon = 22 additive_modifiers = {"transport_speed": 70.0} diff --git a/VesselDefinition.gd b/VesselDefinition.gd index 1aecbfb..f7af0a0 100644 --- a/VesselDefinition.gd +++ b/VesselDefinition.gd @@ -23,6 +23,10 @@ extends Resource @export_multiline var unlock_description: String @export var unlock_cost: int @export var unlock_resource_costs: Dictionary = {} +# #63: spent from GameState.research_points, alongside unlock_resource_costs +# rather than instead of it. 0 (the default) means this unlock needs no +# research points at all. +@export var research_point_cost: int = 0 @export var unlock_icon: int = -1 diff --git a/VesselUpgradeDefinition.gd b/VesselUpgradeDefinition.gd index 083e092..0bc3872 100644 --- a/VesselUpgradeDefinition.gd +++ b/VesselUpgradeDefinition.gd @@ -9,3 +9,7 @@ extends Resource @export var icon: int @export var additive_modifiers: Dictionary = {} @export var multiplicative_modifiers: Dictionary = {} +# #63: spent from GameState.research_points per level bought, alongside +# base_cost (ore) rather than instead of it. 0 (the default) means this +# level needs no research points at all. +@export var research_point_cost: int = 0 diff --git a/WorldGen.gd b/WorldGen.gd index 3238051..b95837d 100644 --- a/WorldGen.gd +++ b/WorldGen.gd @@ -22,6 +22,10 @@ const GAS_GIANT_THRESHOLD: float = 0.55 # chance independent of biome - not a second smooth region to tune against # the first. const BLACK_HOLE_CHANCE: float = 0.05 +# #63: findable without excessive exploration - research points gate mid/ +# late skill-tree nodes and vessel research, so a player needs to run into +# one of these reasonably often, not just as a rare curiosity. +const RESEARCH_LAB_CHANCE: float = 0.08 # A predictable early gas source for play-testing. The origin chunk is # deterministically normal space. Its explicit in-chunk position puts it @@ -95,6 +99,8 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary: # roll doesn't shift which random values any LATER draw (planet_name, # below) consumes for chunks that would have existed before it. var black_hole_roll: float = rng.randf() + # Same principle, its own independent roll (#63). + var research_lab_roll: float = rng.randf() # A stable per-chunk roll makes gas giants persistent landmarks rather # than a different resource whenever the scene reloads. var gas_giant: bool = guaranteed_nearby_giant or biome_at_chunk(chunk) == Biome.SPACE @@ -104,6 +110,8 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary: # that chunk's whole purpose is to always be the same reachable source. if not guaranteed_nearby_giant and black_hole_roll < BLACK_HOLE_CHANCE: definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID + elif not guaranteed_nearby_giant and research_lab_roll < RESEARCH_LAB_CHANCE: + definition_id = PLANET_DEFINITIONS.RESEARCH_LAB_ID var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id) var names: PackedStringArray = definition.names var planet_name: String = names[rng.randi() % names.size()] diff --git a/tests/_skill_menu_preview.gd b/tests/_skill_menu_preview.gd index 96d4581..6999bdf 100644 --- a/tests/_skill_menu_preview.gd +++ b/tests/_skill_menu_preview.gd @@ -8,7 +8,13 @@ extends Node2D # ... and again at 720x960 to check a shorter screen format. -func _ready() -> void: +var configured: bool = false + + +func _process(_delta: float) -> void: + if configured: + return + configured = true GameState.buildings.clear() GameState.extractors.clear() GameState.transporters.clear() @@ -16,9 +22,12 @@ func _ready() -> void: GameState.gas = 36 GameState.fleet_cargo = 24 GameState.fleet_gas_cargo = 12 - GameState.unlocked = ["shipyards", "logistics", "freight"] + GameState.research_points = 0 + GameState.unlocked = ["shipyards"] var main: Node = load("res://Main.tscn").instantiate() add_child(main) + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) main.get_node("BuildingField").place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0)) var mothership_id: int = int(GameState.buildings[-1].id) var bc: Node = main.get_node("BuildController") @@ -27,5 +36,5 @@ func _ready() -> void: main.get_node("UI/SkillMenu").visible = true # Select a node so the description, price and status all have text - an # empty description cannot collide with anything. - bc.skill_tree.selected_id = "freight" - bc._refresh_skill_menu("freight") + bc.skill_tree.selected_id = "coordination" + bc._refresh_skill_menu("coordination") diff --git a/tests/test_carrier_vessel.gd b/tests/test_carrier_vessel.gd index adf159b..a5deec4 100644 --- a/tests/test_carrier_vessel.gd +++ b/tests/test_carrier_vessel.gd @@ -48,6 +48,7 @@ func _initialize() -> void: game_state.reset_progression() game_state.build_time_scale = 0.02 game_state.rocks = 500 + game_state.research_points = 1 scene = load("res://Main.tscn").instantiate() root.add_child(scene) fleet = scene.get_node("Fleet") diff --git a/tests/test_extractor_income.gd b/tests/test_extractor_income.gd index b89037e..f72ac4d 100644 --- a/tests/test_extractor_income.gd +++ b/tests/test_extractor_income.gd @@ -21,6 +21,7 @@ func _initialize() -> void: game_state.extractors.clear() game_state.transporters.clear() game_state.reset_progression() + game_state.research_points = 1 game_state.add_extractor("Test Planet", EXTRACTOR_AT) extractor_index = 0 diff --git a/tests/test_gas_bomber.gd b/tests/test_gas_bomber.gd index 8dc10cd..8ba50e7 100644 --- a/tests/test_gas_bomber.gd +++ b/tests/test_gas_bomber.gd @@ -27,6 +27,7 @@ func _initialize() -> void: game_state.fleet_cargo = 0 game_state.fleet_gas_cargo = 0 game_state.build_time_scale = 0.01 + game_state.research_points = 1 # #60: after research/crafting resets the fleet hold below, top it up # with fleet-carried gas for the shot itself (ammunition, not the ore/gas # spent researching or crafting the bomber). diff --git a/tests/test_lab_resources.gd b/tests/test_lab_resources.gd index e4cbeb7..57eabcb 100644 --- a/tests/test_lab_resources.gd +++ b/tests/test_lab_resources.gd @@ -10,6 +10,7 @@ func _initialize() -> void: game.vessel_levels.clear() game.build_time_scale = 0.02 game.rocks = 500 + game.research_points = 1 lab_id = game.add_building(GameState.BUILDING_SHIP_LAB, Vector2.ZERO) if not game.queue_lab_research(lab_id, "int_damage"): _fail("could not queue research") diff --git a/tests/test_lab_transporter_delivery.gd b/tests/test_lab_transporter_delivery.gd index dff33b2..6c1fe94 100644 --- a/tests/test_lab_transporter_delivery.gd +++ b/tests/test_lab_transporter_delivery.gd @@ -12,6 +12,7 @@ func _initialize() -> void: state.transporters.clear() state.reset_progression() state.rocks = 100 + state.research_points = 1 state.build_time_scale = 0.02 state.add_building(state.BUILDING_MOTHERSHIP, Vector2.ZERO) state.add_building(state.BUILDING_TRANSPORT_HUB, Vector2(50, 0)) diff --git a/tests/test_multi_resource_supply.gd b/tests/test_multi_resource_supply.gd index d29b574..ecf956c 100644 --- a/tests/test_multi_resource_supply.gd +++ b/tests/test_multi_resource_supply.gd @@ -10,6 +10,7 @@ func _initialize() -> void: state.reset_progression() state.rocks = 0 state.gas = 10 + state.research_points = 1 state.build_time_scale = 0.01 state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) diff --git a/tests/test_research_points.gd b/tests/test_research_points.gd new file mode 100644 index 0000000..72adc94 --- /dev/null +++ b/tests/test_research_points.gd @@ -0,0 +1,143 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_research_points.gd +# Forgejo #63: a rare enemy planet type that yields no resources but grants +# one research point when conquered - a required currency for mid/late +# skill-tree nodes and vessel/lab research, gated alongside their existing +# ore/resource costs. +# +# Covers: +# A. research labs generate deterministically and are rare +# (structural) the definition yields no resources and its surface has no +# resource provider at all +# F. a fresh game can still reach a ship crafter and an extractor with +# zero research points +# D. a research-point-gated skill node is refused while unaffordable, and +# explains why +# E. conquering a research_lab awards exactly one point, once (guarded +# even against a redundant re-mark), emits the signal once, and a +# non-research_lab planet's conquest never awards one +# D continued: unlocking spends the points and the balance updates +# G. the ship-lab research path gates and spends research points the +# same way, independent of the skill tree + +var game_state: Node + + +func _initialize() -> void: + game_state = root.get_node("GameState") + + # --- A. deterministic and rare ------------------------------------------- + var lab_count: int = 0 + var total_planets: int = 0 + for x in range(-40, 40): + for y in range(-40, 40): + var chunk: Vector2i = Vector2i(x, y) + if not WorldGen.has_planet_at_chunk(chunk): + continue + total_planets += 1 + var first: Dictionary = WorldGen.planet_data_at_chunk(chunk) + var second: Dictionary = WorldGen.planet_data_at_chunk(chunk) + if first.definition_id != second.definition_id or first.local_pos != second.local_pos: + return _fail("planet_data_at_chunk(%s) is not deterministic" % chunk) + if first.definition_id == "research_lab": + lab_count += 1 + print("scanned %d planet chunks: %d research labs" % [total_planets, lab_count]) + if lab_count == 0: + return _fail("no research labs generated across a wide scan - the rare outcome never fires") + if lab_count * 3 >= total_planets: + return _fail("research labs (%d of %d) are not rare" % [lab_count, total_planets]) + print("research labs are deterministic and rare (%d of %d planet chunks)" % [lab_count, total_planets]) + + # --- structural: yields nothing at all ------------------------------------ + var lab_def: Resource = load("res://PlanetDefinitions.gd").get_definition("research_lab") + if String(lab_def.resource_id) != "": + return _fail("research_lab planet's resource_id is '%s', expected empty" % lab_def.resource_id) + var profile: Resource = load("res://PlanetSurfaceProfiles.gd").get_profile("research_lab") + if profile.features_scene != null: + return _fail("research_lab surface profile has a features_scene - it must yield nothing") + print("research_lab yields no resource id and no surface feature provider") + + # --- F. a fresh game still reaches a crafter and an extractor ------------- + game_state.reset_new_game() + if not game_state.can_unlock("shipyards"): + return _fail("shipyards is not reachable with 0 research points") + game_state.unlock("shipyards") + if not game_state.can_unlock("logistics"): + return _fail("logistics is not reachable with 0 research points after shipyards") + game_state.unlock("logistics") + print("fresh game reaches a ship crafter (shipyards) and an extractor (logistics) at 0 research points") + + # --- D. a points-gated node is refused and explains why ------------------- + if game_state.can_unlock("coordination"): + return _fail("coordination (costs research points) was unlockable with 0 research points") + print("a research-point-gated node (coordination) correctly refused at 0 points") + + # --- E. earning exactly one point, once, with the signal ------------------ + var totals_seen: Array = [] + game_state.research_points_changed.connect(func(total): totals_seen.append(total)) + game_state.planet_capture.clear() + game_state.register_capture_points("test_lab_planet", 2, "research_lab") + if game_state.is_planet_conquered("test_lab_planet"): + return _fail("a lab with 2 real capture points started conquered") + game_state.mark_point_captured("test_lab_planet", 0) + if game_state.research_points != 0: + return _fail("a research point was awarded before every point was captured") + game_state.mark_point_captured("test_lab_planet", 1) + if game_state.research_points != 1: + return _fail("conquering a research_lab did not award exactly 1 point: %d" % game_state.research_points) + if totals_seen != [1]: + return _fail("research_points_changed did not fire exactly once with the new total: %s" % [totals_seen]) + print("conquering a research_lab awarded exactly 1 point and fired the signal once") + + game_state.mark_point_captured("test_lab_planet", 0) # already captured - redundant re-mark + if game_state.research_points != 1: + return _fail("re-marking an already-captured point re-awarded a research point: %d" % game_state.research_points) + print("double-award guarded against") + + game_state.register_capture_points("test_rocky_planet", 1, "rocky") + game_state.mark_point_captured("test_rocky_planet", 0) + if game_state.research_points != 1: + return _fail("conquering a non-research_lab planet awarded a research point: %d" % game_state.research_points) + print("a non-research_lab planet's conquest never awards a point") + + # --- D continued: unlocking spends the points, balance updates ------------ + if not game_state.can_unlock("coordination"): + return _fail("coordination is still not unlockable with 1 research point and enough ore") + if not game_state.unlock("coordination"): + return _fail("unlock(coordination) failed despite can_unlock() being true") + if game_state.research_points != 0: + return _fail("unlocking a 1-point node did not spend the point: %d remaining" % game_state.research_points) + print("unlocking a research-point-gated skill node spent the point, balance now 0") + + # --- G. the ship-lab research path gates and spends the same currency ----- + var carrier_def: Resource = game_state.vessel_definition(GameState.VESSEL_CARRIER) + var point_cost: int = int(carrier_def.research_point_cost) + if point_cost <= 0: + return _fail("carrier production data has no research-point cost") + game_state.vessel_levels.erase(carrier_def.unlock_id) + var lab_id: int = game_state.add_building(GameState.BUILDING_SHIP_LAB, Vector2.ZERO) + if not game_state.queue_lab_research(lab_id, carrier_def.unlock_id): + return _fail("could not queue carrier research") + game_state.deposit_resource_to_building(lab_id, GameState.RESOURCE_ORE, 1000) + game_state._advance_lab_research(100.0) + if game_state.vessel_is_unlocked(GameState.VESSEL_CARRIER): + return _fail("carrier research completed despite 0 research points") + print("lab research correctly stalled on a research-point shortfall") + + game_state.research_points = point_cost + game_state._advance_lab_research(100.0) + if not game_state.vessel_is_unlocked(GameState.VESSEL_CARRIER): + return _fail("carrier research did not complete once research points were available") + if game_state.research_points != 0: + return _fail("lab research did not spend the research points: %d remaining" % game_state.research_points) + print("lab research spent the research points once affordable, and completed") + + print("TEST_RESEARCH_POINTS: PASS") + quit(0) + + +func _fail(message: String) -> void: + print("TEST_RESEARCH_POINTS: FAIL (%s)" % message) + quit(1) diff --git a/tests/test_ship_lab.gd b/tests/test_ship_lab.gd index df2c168..1342cce 100644 --- a/tests/test_ship_lab.gd +++ b/tests/test_ship_lab.gd @@ -81,6 +81,7 @@ func _process(delta: float) -> bool: print("no lab -> no upgrades") game_state.unlocked = ["shipyards", "vessel_science", "coordination", "wing", "squadron"] + game_state.research_points = 10 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) diff --git a/tests/test_skill_tree.gd b/tests/test_skill_tree.gd index 5c9d21c..e7dc526 100644 --- a/tests/test_skill_tree.gd +++ b/tests/test_skill_tree.gd @@ -155,6 +155,7 @@ func _process(delta: float) -> bool: # --- fleet capacity ------------------------------------------- var base_cap: int = game_state.max_fleet_size() + game_state.research_points = 2 game_state.unlock("coordination") var wing_ok: bool = game_state.unlock("wing") var raised_cap: int = game_state.max_fleet_size() From d68f4d4f888b5d62479638648a033305f07fadeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 16 Aug 2026 21:38:07 +0200 Subject: [PATCH 6/6] Refactor recent gameplay systems after code review --- AimedFleetAbilityController.gd | 191 +++++++++++++++ AimedFleetAbilityController.gd.uid | 1 + BuildController.gd | 6 +- BuildingDefinition.gd | 1 + BuildingLogisticsProfile.gd | 13 ++ BuildingLogisticsProfile.gd.uid | 1 + EnemyBigLaserBeam.gd.uid | 1 + EnemyBigLaserComponent.gd.uid | 1 + EnemySpawnerComponent.gd.uid | 1 + EnergyResourceGlyph.gd.uid | 1 + Fleet.gd | 17 +- GameState.gd | 120 +++++----- GasBombController.gd | 212 ++++------------- GasBurnerBuildingDefinition.tres | 16 +- GasBurnerBuildingVisual.gd.uid | 1 + LaserController.gd | 217 +++--------------- LaserController.gd.uid | 1 + ResearchLabPlanetVisual.gd.uid | 1 + SpeedFader.gd | 8 +- SpeedFader.gd.uid | 1 + VesselCooldownFillComponent.gd | 84 +++++++ VesselCooldownFillComponent.gd.uid | 1 + VesselDistorderComponent.gd.uid | 1 + VesselGasBomberComponent.gd | 77 +------ VesselLaserComponent.gd | 82 +------ VesselLaserComponent.gd.uid | 1 + gas_surface.gdshader.uid | 1 + tests/_black_hole_space_preview.gd.uid | 1 + tests/_carrier_crafter_preview.gd.uid | 1 + tests/_carrier_hangar_preview.gd.uid | 1 + tests/_fleet_cargo_limit_preview.gd.uid | 1 + tests/_gas_burner_build_menu_preview.gd.uid | 1 + tests/_gas_burner_transfer_preview.gd.uid | 1 + tests/_laser_aim_preview.gd.uid | 1 + tests/_mothership_beam_preview.gd.uid | 1 + .../_resource_readout_surface_preview.gd.uid | 1 + tests/_rocky_surface_preview.gd.uid | 1 + tests/_turret_vs_chaser_preview.gd.uid | 1 + tests/_void_surface_preview.gd.uid | 1 + tests/test_black_hole.gd.uid | 1 + tests/test_carrier_vessel.gd.uid | 1 + tests/test_distorder_energy.gd.uid | 1 + tests/test_enemy_mothership.gd.uid | 1 + tests/test_fleet_cargo_limit.gd.uid | 1 + tests/test_gas_bomber_ammo.gd.uid | 1 + tests/test_gas_bomber_cooldowns.gd.uid | 1 + tests/test_gas_burner.gd | 11 +- tests/test_gas_burner.gd.uid | 1 + tests/test_hub_capacity.gd | 1 + tests/test_laser_ammo.gd.uid | 1 + tests/test_laser_vessel.gd.uid | 1 + tests/test_research_points.gd.uid | 1 + tests/test_resource_readout.gd.uid | 1 + tests/test_space_time_distorder.gd.uid | 1 + tests/test_surface_profiles.gd.uid | 1 + tests/test_turret_spawner_enemy.gd.uid | 1 + tests/test_vessel_composition.gd | 3 +- 57 files changed, 529 insertions(+), 571 deletions(-) create mode 100644 AimedFleetAbilityController.gd create mode 100644 AimedFleetAbilityController.gd.uid create mode 100644 BuildingLogisticsProfile.gd create mode 100644 BuildingLogisticsProfile.gd.uid create mode 100644 EnemyBigLaserBeam.gd.uid create mode 100644 EnemyBigLaserComponent.gd.uid create mode 100644 EnemySpawnerComponent.gd.uid create mode 100644 EnergyResourceGlyph.gd.uid create mode 100644 GasBurnerBuildingVisual.gd.uid create mode 100644 LaserController.gd.uid create mode 100644 ResearchLabPlanetVisual.gd.uid create mode 100644 SpeedFader.gd.uid create mode 100644 VesselCooldownFillComponent.gd create mode 100644 VesselCooldownFillComponent.gd.uid create mode 100644 VesselDistorderComponent.gd.uid create mode 100644 VesselLaserComponent.gd.uid create mode 100644 gas_surface.gdshader.uid create mode 100644 tests/_black_hole_space_preview.gd.uid create mode 100644 tests/_carrier_crafter_preview.gd.uid create mode 100644 tests/_carrier_hangar_preview.gd.uid create mode 100644 tests/_fleet_cargo_limit_preview.gd.uid create mode 100644 tests/_gas_burner_build_menu_preview.gd.uid create mode 100644 tests/_gas_burner_transfer_preview.gd.uid create mode 100644 tests/_laser_aim_preview.gd.uid create mode 100644 tests/_mothership_beam_preview.gd.uid create mode 100644 tests/_resource_readout_surface_preview.gd.uid create mode 100644 tests/_rocky_surface_preview.gd.uid create mode 100644 tests/_turret_vs_chaser_preview.gd.uid create mode 100644 tests/_void_surface_preview.gd.uid create mode 100644 tests/test_black_hole.gd.uid create mode 100644 tests/test_carrier_vessel.gd.uid create mode 100644 tests/test_distorder_energy.gd.uid create mode 100644 tests/test_enemy_mothership.gd.uid create mode 100644 tests/test_fleet_cargo_limit.gd.uid create mode 100644 tests/test_gas_bomber_ammo.gd.uid create mode 100644 tests/test_gas_bomber_cooldowns.gd.uid create mode 100644 tests/test_gas_burner.gd.uid create mode 100644 tests/test_laser_ammo.gd.uid create mode 100644 tests/test_laser_vessel.gd.uid create mode 100644 tests/test_research_points.gd.uid create mode 100644 tests/test_resource_readout.gd.uid create mode 100644 tests/test_space_time_distorder.gd.uid create mode 100644 tests/test_surface_profiles.gd.uid create mode 100644 tests/test_turret_spawner_enemy.gd.uid diff --git a/AimedFleetAbilityController.gd b/AimedFleetAbilityController.gd new file mode 100644 index 0000000..f8cb2e5 --- /dev/null +++ b/AimedFleetAbilityController.gd @@ -0,0 +1,191 @@ +class_name AimedFleetAbilityController +extends Node2D + +# Shared press-drag-release input and camera behavior for manually aimed fleet +# abilities. Subclasses provide the component, ammunition, target calculation, +# shot effect, and effect visuals. + +const AIM_DEADZONE: float = 22.0 + +@export var minimum_zoom: float = 0.4 +@onready var fleet: Node2D = get_node("../Fleet") +@onready var camera: Camera2D = get_node("../Camera2D") + +var button: IconButton +var status: Label +var status_message_until: int = 0 +var aiming: bool = false +var aim_touch: int = -1 +var aim_start_screen: Vector2 = Vector2.ZERO +var aim_screen: Vector2 = Vector2.ZERO +var aim_target: Vector2 = Vector2.ZERO +var saved_zoom: float = 1.0 +var restoring_zoom: bool = false + + +func _ready() -> void: + button = get_node(_button_path()) + status = get_node(_status_path()) + button.visible = false + status.text = "" + queue_redraw() + + +func _process(delta: float) -> void: + var had_visuals: bool = aiming or _has_effect_visuals() + button.visible = fleet.has_capability(_component_id()) + button.enabled = _next_ready_ship() != null and _can_afford_shot() + if not button.visible and aiming: + _cancel_aim() + if status.text != "" and Time.get_ticks_msec() >= status_message_until: + status.text = "" + + if restoring_zoom and not aiming: + var zoom: float = move_toward(camera.zoom.x, saved_zoom, + maxf(saved_zoom, 0.1) * delta * 5.0) + camera.zoom = Vector2(zoom, zoom) + if is_equal_approx(zoom, saved_zoom): + restoring_zoom = false + + _advance_effects(delta) + if aiming or _has_effect_visuals() or had_visuals: + queue_redraw() + + +func can_begin_aim() -> bool: + # An unaffordable shot still claims the press so the status label can + # explain what is missing instead of silently passing the touch through. + return button.visible and not button._blocked_by_modal() \ + and not aiming and _next_ready_ship() != null + + +func handle_touch(event: InputEventScreenTouch) -> bool: + if event.pressed: + if not can_begin_aim() \ + or not Rect2(button.global_position, button.size).has_point(event.position): + return false + if not _can_afford_shot(): + _say(_unaffordable_message()) + return true + aiming = true + aim_touch = event.index + aim_start_screen = event.position + aim_screen = event.position + saved_zoom = camera.zoom.x + restoring_zoom = false + button.pressed_down = true + button.queue_redraw() + _update_aim() + return true + if event.index != aim_touch: + return false + if aim_start_screen.distance_to(aim_screen) >= AIM_DEADZONE: + _fire() + else: + _cancel_aim() + return true + + +func handle_drag(event: InputEventScreenDrag) -> bool: + if not aiming or event.index != aim_touch: + return false + aim_screen = event.position + _update_aim() + return true + + +func _update_aim() -> void: + var origin: Vector2 = _aim_origin() + aim_target = _calculate_aim_target(aim_screen - aim_start_screen, origin) + _update_aim_zoom(origin, aim_target) + queue_redraw() + + +func _update_aim_zoom(origin: Vector2, target: Vector2) -> void: + var displacement: Vector2 = target - origin + var half_screen: Vector2 = get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0) + var padding: float = _aim_padding() + var fit_x: float = saved_zoom if absf(displacement.x) < 1.0 \ + else half_screen.x / (absf(displacement.x) + padding) + var fit_y: float = saved_zoom if absf(displacement.y) < 1.0 \ + else half_screen.y / (absf(displacement.y) + padding) + var desired: float = clampf(minf(saved_zoom, minf(fit_x, fit_y)), + minimum_zoom, saved_zoom) + camera.zoom = Vector2(desired, desired) + + +func _finish_aim() -> void: + aiming = false + aim_touch = -1 + button.pressed_down = false + button.queue_redraw() + restoring_zoom = true + + +func _cancel_aim() -> void: + _finish_aim() + queue_redraw() + + +func _fire() -> void: + var ship: Node2D = _next_ready_ship() + if ship == null: + _cancel_aim() + return + if not _can_afford_shot(): + _say(_unaffordable_message()) + _cancel_aim() + return + var component: Node = ship.get_component(_component_id()) + if component == null or not _consume_shot(component): + _cancel_aim() + return + GameState.withdraw_fleet_resource(_ammo_resource(), _ammo_cost()) + _perform_shot(ship, component) + _finish_aim() + button.enabled = _next_ready_ship() != null and _can_afford_shot() + + +func _next_ready_ship() -> Node2D: + for ship in fleet.ships: + if not is_instance_valid(ship): + continue + var component: Node = ship.get_component(_component_id()) + if component != null and component.is_ready(): + return ship + return null + + +func _can_afford_shot() -> bool: + return GameState.fleet_resource(_ammo_resource()) >= _ammo_cost() + + +func _unaffordable_message() -> String: + var resource_type: String = _ammo_resource() + return "Need %d %s in the fleet hold to fire (have %d)" % [ + _ammo_cost(), GameState.resource_name(resource_type).to_lower(), + GameState.fleet_resource(resource_type), + ] + + +func _say(message: String) -> void: + status.text = message + status_message_until = Time.get_ticks_msec() + 3000 + + +func _aim_origin() -> Vector2: + return fleet.get_centroid() + + +# Subclass hooks. +func _button_path() -> NodePath: return NodePath() +func _status_path() -> NodePath: return NodePath() +func _component_id() -> String: return "" +func _ammo_resource() -> String: return "" +func _ammo_cost() -> int: return 0 +func _aim_padding() -> float: return 0.0 +func _has_effect_visuals() -> bool: return false +func _advance_effects(_delta: float) -> void: pass +func _consume_shot(_component: Node) -> bool: return false +func _perform_shot(_ship: Node2D, _component: Node) -> void: pass +func _calculate_aim_target(_drag: Vector2, origin: Vector2) -> Vector2: return origin diff --git a/AimedFleetAbilityController.gd.uid b/AimedFleetAbilityController.gd.uid new file mode 100644 index 0000000..a455e17 --- /dev/null +++ b/AimedFleetAbilityController.gd.uid @@ -0,0 +1 @@ +uid://dbahjmvwru7bp diff --git a/BuildController.gd b/BuildController.gd index be1adf7..6a45e1e 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -738,7 +738,7 @@ func _refresh_skill_menu(id: String = "") -> void: var node: Dictionary = GameState.skill_node(id) if node.is_empty(): return - var points_needed: int = int(node.get("research_points", 0)) + var points_needed: int = GameState.skill_research_point_cost(id) skill_detail.text = "%s\n%s" % [node.name, node.desc] if points_needed > 0: skill_detail.text += "\nResearch points: %d/%d" % [GameState.research_points, points_needed] @@ -1043,7 +1043,7 @@ func _transfer_place_name() -> String: var b: Dictionary = GameState.find_building(transfer_building_id) if b.is_empty(): return "this building" - if b.type == GameState.BUILDING_MOTHERSHIP: + if GameState.building_has_capability(b, "bank"): # Worth naming as the bank rather than as a structure: this is the # one building where unloading makes the ore spendable everywhere. return "the bank" @@ -1056,7 +1056,7 @@ func _refresh_transfer() -> void: return var building: Dictionary = GameState.find_building(transfer_building_id) var heading: String = "This building" - if not building.is_empty() and building.type == GameState.BUILDING_MOTHERSHIP: + if GameState.building_has_capability(building, "bank"): heading = "Mothership bank" transfer_list.set_destination(transfer_building_id, heading) diff --git a/BuildingDefinition.gd b/BuildingDefinition.gd index fdc990e..7b3f1fd 100644 --- a/BuildingDefinition.gd +++ b/BuildingDefinition.gd @@ -9,6 +9,7 @@ extends Resource @export var menu_icon: int @export var visual_scene: PackedScene @export var capabilities: PackedStringArray = [] +@export var logistics: Resource @export var map_color: Color = Color.WHITE @export var map_sides: int = 4 @export var map_radius: float = 7.0 diff --git a/BuildingLogisticsProfile.gd b/BuildingLogisticsProfile.gd new file mode 100644 index 0000000..fdd6706 --- /dev/null +++ b/BuildingLogisticsProfile.gd @@ -0,0 +1,13 @@ +class_name BuildingLogisticsProfile +extends Resource + +# Optional logistics and conversion behavior composed into a building +# definition. GameState consumes this data generically, so adding another +# converter does not require another resource-type branch in transport code. + +@export var input_buffers: Dictionary = {} +@export var collected_outputs: PackedStringArray = [] +@export var conversion_input: String = "" +@export var conversion_output: String = "" +@export var conversion_rate: float = 0.0 +@export var conversion_yield: int = 1 diff --git a/BuildingLogisticsProfile.gd.uid b/BuildingLogisticsProfile.gd.uid new file mode 100644 index 0000000..b6480eb --- /dev/null +++ b/BuildingLogisticsProfile.gd.uid @@ -0,0 +1 @@ +uid://cydp18abylrgd diff --git a/EnemyBigLaserBeam.gd.uid b/EnemyBigLaserBeam.gd.uid new file mode 100644 index 0000000..7e2bd8a --- /dev/null +++ b/EnemyBigLaserBeam.gd.uid @@ -0,0 +1 @@ +uid://coopf1u1lnb8b diff --git a/EnemyBigLaserComponent.gd.uid b/EnemyBigLaserComponent.gd.uid new file mode 100644 index 0000000..31a1aba --- /dev/null +++ b/EnemyBigLaserComponent.gd.uid @@ -0,0 +1 @@ +uid://6oj7ia3em4x0 diff --git a/EnemySpawnerComponent.gd.uid b/EnemySpawnerComponent.gd.uid new file mode 100644 index 0000000..043b98d --- /dev/null +++ b/EnemySpawnerComponent.gd.uid @@ -0,0 +1 @@ +uid://eqkp0coeoh4d diff --git a/EnergyResourceGlyph.gd.uid b/EnergyResourceGlyph.gd.uid new file mode 100644 index 0000000..c8fb069 --- /dev/null +++ b/EnergyResourceGlyph.gd.uid @@ -0,0 +1 @@ +uid://b3b64pvw4y53x diff --git a/Fleet.gd b/Fleet.gd index d51740f..ef17702 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -80,7 +80,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO, ship.destroyed.connect(_on_ship_destroyed.bind(ship)) fleet_target = center - GameState.sync_fleet_cargo_capacity(vessel_types()) + _sync_composition_state() func vessel_types() -> PackedStringArray: @@ -91,6 +91,15 @@ func vessel_types() -> PackedStringArray: return result +func _sync_composition_state() -> void: + GameState.sync_fleet_cargo_capacity(vessel_types()) + # Composition changes are authoritative; scene teardown is not. Keeping + # this out of SpeedFader prevents an outgoing scene's empty fleet from + # resetting a boost while the same vessels are being restored on a surface. + if not has_capability("distorder"): + GameState.set_fleet_speed_multiplier(1.0) + + func has_vessel_type(vessel_type: String) -> bool: for ship in ships: if is_instance_valid(ship) and String(ship.vessel_type) == vessel_type: @@ -139,7 +148,7 @@ func add_ship(vessel_type: String = GameState.VESSEL_INTERCEPTOR) -> bool: if ship.has_signal("destroyed"): ship.destroyed.connect(_on_ship_destroyed.bind(ship)) ship.set_move_target(fleet_target + offset) - GameState.sync_fleet_cargo_capacity(vessel_types()) + _sync_composition_state() return true @@ -157,7 +166,7 @@ func store_ship(index: int) -> String: formation_offsets.remove_at(index) ship.queue_free() apply_formation() - GameState.sync_fleet_cargo_capacity(vessel_types()) + _sync_composition_state() return vessel_type @@ -283,7 +292,7 @@ func _on_ship_destroyed(ship: Node) -> void: if idx != -1: ships.remove_at(idx) formation_offsets.remove_at(idx) - GameState.sync_fleet_cargo_capacity(vessel_types()) + _sync_composition_state() func get_centroid() -> Vector2: diff --git a/GameState.gd b/GameState.gd index 8397b7e..35c4bbc 100644 --- a/GameState.gd +++ b/GameState.gd @@ -387,7 +387,11 @@ func prerequisite_met(id: String) -> bool: func can_unlock(id: String) -> bool: var node: Dictionary = skill_node(id) return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(node.cost)) \ - and research_points >= int(node.get("research_points", 0)) + and research_points >= skill_research_point_cost(id) + + +func skill_research_point_cost(id: String) -> int: + return int(skill_node(id).get("research_points", 0)) func unlock(id: String) -> bool: @@ -396,7 +400,7 @@ func unlock(id: String) -> bool: var node: Dictionary = skill_node(id) if not spend(int(node.cost)): return false - var points_needed: int = int(node.get("research_points", 0)) + var points_needed: int = skill_research_point_cost(id) if points_needed > 0: research_points -= points_needed research_points_changed.emit(research_points) @@ -446,6 +450,8 @@ func reset_new_game() -> void: planet_id = "" planet_definition_id = "rocky" fleet_speed_multiplier = 1.0 + _distorder_drain_progress = 0.0 + boost_depleted_notice = false research_points = 0 explored_chunks.clear() bank_inventory = INVENTORY.new({RESOURCE_ORE: 500}) @@ -520,6 +526,13 @@ func building_has_capability(building: Dictionary, capability: String) -> bool: return definition != null and capability in definition.capabilities +func building_logistics_profile(building: Dictionary) -> Resource: + if building.is_empty(): + return null + var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(building.type)) + return null if definition == null else definition.logistics + + # --- vessel types and their upgrades (#5) --------------------------------- # Combat vessels can join the player fleet. Transporter metadata lives in the @@ -1044,19 +1057,20 @@ func has_building_of_type(type: String) -> bool: func remove_buildings_of_type(type: String) -> int: var kept: Array = [] - var removed_ids: Array = [] + var removed_hub_ids: Array = [] var removed: int = 0 for b in buildings: if b.type == type: removed += 1 - removed_ids.append(int(b.id)) + if building_has_capability(b, "transport_hub"): + removed_hub_ids.append(int(b.id)) else: kept.append(b) buildings = kept - if type == BUILDING_TRANSPORT_HUB and not removed_ids.is_empty(): + if not removed_hub_ids.is_empty(): var kept_transporters: Array = [] for transporter in transporters: - if int(transporter.get("hub_id", 0)) not in removed_ids: + if int(transporter.get("hub_id", 0)) not in removed_hub_ids: kept_transporters.append(transporter) transporters = kept_transporters return removed @@ -1186,44 +1200,50 @@ func _extractor_inventory(extractor: Dictionary) -> RefCounted: return inventory -# --- gas burners (#54) ----------------------------------------------------- -# -# Converts gas into energy at the building's own site, simulated here -# alongside extractor mining and production so it keeps running whether or -# not the player is nearby. Deliberately withdraws only from the building's -# OWN inventory, never the bank - a burner with no local gas simply stalls, -# exactly like a lab or crafter waiting on materials, rather than conjuring -# energy for free. +# --- building logistics and resource conversion --------------------------- -const GAS_BURN_RATE: float = 1.0 # gas/second -const ENERGY_PER_GAS: int = 2 -# Local gas reserve a burner tries to keep topped up. Transporters treat -# falling short of this the same way they treat a queued production/research -# order (see _dispatch_transporters()'s gas_burner_gas_deficit() use). -const GAS_BURNER_BUFFER: int = 40 - - -func gas_burner_gas_deficit(id: int) -> int: - var b: Dictionary = find_building(id) - if not building_has_capability(b, "gas_burner"): +func building_logistics_deficit(id: int, resource_type: String) -> int: + var building: Dictionary = find_building(id) + var profile: Resource = building_logistics_profile(building) + if profile == null: return 0 - return maxi(0, GAS_BURNER_BUFFER - building_resource(id, RESOURCE_GAS)) + var target: int = int(profile.input_buffers.get(resource_type, 0)) + return maxi(0, target - building_resource(id, resource_type)) -func _advance_gas_burners(delta: float) -> void: - for b in buildings: - if not building_has_capability(b, "gas_burner"): +func building_requested_resource(id: int, resource_type: String) -> int: + return maxi(outstanding_resource_cost(id, resource_type), + building_logistics_deficit(id, resource_type)) + + +func _collectable_building_resource(building: Dictionary, minimum_amount: int) -> String: + var profile: Resource = building_logistics_profile(building) + if profile == null: + return "" + for resource_type in profile.collected_outputs: + if building_resource(int(building.id), resource_type) >= minimum_amount: + return resource_type + return "" + + +func _advance_resource_converters(delta: float) -> void: + for building in buildings: + var profile: Resource = building_logistics_profile(building) + if profile == null or profile.conversion_input == "" \ + or profile.conversion_output == "" or profile.conversion_rate <= 0.0 \ + or profile.conversion_yield <= 0: continue - var inventory: RefCounted = _inventory_for_record(b) - if inventory.amount(RESOURCE_GAS) <= 0: - continue # stalled - no local gas to burn - var progress: float = float(b.get("gas_burn_progress", 0.0)) + delta * GAS_BURN_RATE - var whole: int = mini(int(floor(progress)), inventory.amount(RESOURCE_GAS)) + var inventory: RefCounted = _inventory_for_record(building) + if inventory.amount(profile.conversion_input) <= 0: + continue + var progress: float = float(building.get("conversion_progress", 0.0)) \ + + delta * profile.conversion_rate + var whole: int = mini(int(floor(progress)), inventory.amount(profile.conversion_input)) if whole > 0: - inventory.withdraw(RESOURCE_GAS, whole) - inventory.deposit(RESOURCE_ENERGY, whole * ENERGY_PER_GAS) + inventory.withdraw(profile.conversion_input, whole) + inventory.deposit(profile.conversion_output, whole * profile.conversion_yield) progress -= whole - b.gas_burn_progress = progress + building.conversion_progress = progress func _advance_distorder_drain(delta: float) -> void: @@ -1341,7 +1361,7 @@ func _process(delta: float) -> void: _advance_production(delta) _advance_lab_research(delta) _advance_vessel_deliveries(delta) - _advance_gas_burners(delta) + _advance_resource_converters(delta) _advance_distorder_drain(delta) for e in extractors: e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE @@ -1500,13 +1520,7 @@ func _dispatch_transporters() -> void: for b in buildings: var needed_resource := "" for resource_type in resource_types(): - # A gas burner's ongoing gas deficit is folded in alongside - # queued production/research costs (#54) - both are just "this - # building wants more of resource_type", so the one dispatch - # path already below serves both without any new transporter - # state. - var need: int = maxi(outstanding_resource_cost(int(b.id), resource_type), - gas_burner_gas_deficit(int(b.id)) if resource_type == RESOURCE_GAS else 0) + var need: int = building_requested_resource(int(b.id), resource_type) if need > 0 and bank_inventory.amount(resource_type) > 0: needed_resource = resource_type break @@ -1559,14 +1573,11 @@ func _dispatch_transporters() -> void: _begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position, TRANSPORTER_TO_EXTRACTOR) - # Collecting a gas burner's energy output, last: queued deliveries and - # extractor collection both outrank it, same "supply what's needed - # before what's merely ready" ordering _dispatch_transporters() already - # holds for extractors over queued orders (f5c60f8). + # Collect building outputs last: queued deliveries and extractor collection + # both outrank resources that are merely ready for pickup. for b in buildings: - if not building_has_capability(b, "gas_burner"): - continue - if building_resource(int(b.id), RESOURCE_ENERGY) < load or _transporter_inbound_for_pickup(int(b.id)): + var pickup_resource: String = _collectable_building_resource(b, load) + if pickup_resource == "" or _transporter_inbound_for_pickup(int(b.id)): continue var chosen_pickup: Dictionary = {} var best_pickup_distance: float = INF @@ -1580,7 +1591,7 @@ func _dispatch_transporters() -> void: if chosen_pickup.is_empty(): return chosen_pickup.target_id = int(b.id) - chosen_pickup.resource_type = RESOURCE_ENERGY + chosen_pickup.resource_type = pickup_resource chosen_pickup.extractor_index = -1 _begin_leg(chosen_pickup, transporter_position(chosen_pickup), b.position, TRANSPORTER_TO_BUILDING_PICKUP) @@ -1610,8 +1621,7 @@ func _arrive(t: Dictionary) -> void: TRANSPORTER_TO_MOTHERSHIP: var target: Dictionary = find_building(int(t.target_id)) var resource_type: String = String(t.get("resource_type", RESOURCE_ORE)) - var want: int = maxi(outstanding_resource_cost(int(t.target_id), resource_type), - gas_burner_gas_deficit(int(t.target_id)) if resource_type == RESOURCE_GAS else 0) + var want: int = building_requested_resource(int(t.target_id), resource_type) var take: int = mini(mini(want, bank_inventory.amount(resource_type)), transporter_load_limit()) if take <= 0 or target.is_empty(): # Nothing to carry, or the building is gone. Go home empty. diff --git a/GasBombController.gd b/GasBombController.gd index 489bf49..43e784a 100644 --- a/GasBombController.gd +++ b/GasBombController.gd @@ -1,79 +1,64 @@ class_name GasBombController -extends Node2D +extends "res://AimedFleetAbilityController.gd" const BOMB_COLOR := Color(0.96, 0.22, 0.72) const TARGET_COLOR := Color(0.78, 0.32, 1.0) const MAX_RANGE := 950.0 const AOE_RADIUS := 180.0 const DRAG_RADIUS := 260.0 -const AIM_DEADZONE := 22.0 const PROJECTILE_SPEED := 720.0 const DAMAGE := 3 const COOLDOWN := 5.0 -# #60: ammunition from the fleet hold, not the bank - the fleet has to be -# carrying its own gas to fire. const GAS_PER_SHOT: int = 5 -@export var minimum_zoom: float = 0.4 -@onready var fleet: Node2D = get_node("../Fleet") -@onready var camera: Camera2D = get_node("../Camera2D") -@onready var button: IconButton = get_node("../UI/GasBombButton") -@onready var status: Label = get_node("../UI/GasBombStatus") - -var status_message_until: int = 0 - -var aiming := false -var aim_touch := -1 -var aim_start_screen := Vector2.ZERO -var aim_screen := Vector2.ZERO -var aim_target := Vector2.ZERO -var saved_zoom := 1.0 -var restoring_zoom := false var projectiles: Array = [] var explosions: Array = [] -var last_hit_count := 0 +var last_hit_count: int = 0 var projectile_active: bool: get: return not projectiles.is_empty() var explosion_time: float: get: - var longest := 0.0 + var longest: float = 0.0 for explosion in explosions: longest = maxf(longest, float(explosion.time)) return longest -func _ready() -> void: - button.visible = false - status.text = "" - queue_redraw() +func _button_path() -> NodePath: return "../UI/GasBombButton" +func _status_path() -> NodePath: return "../UI/GasBombStatus" +func _component_id() -> String: return "gas_bomber" +func _ammo_resource() -> String: return GameState.RESOURCE_GAS +func _ammo_cost() -> int: return GAS_PER_SHOT +func _aim_padding() -> float: return AOE_RADIUS +func _has_effect_visuals() -> bool: return not projectiles.is_empty() or not explosions.is_empty() -func _can_afford_shot() -> bool: - return GameState.fleet_resource(GameState.RESOURCE_GAS) >= GAS_PER_SHOT +func _calculate_aim_target(drag: Vector2, origin: Vector2) -> Vector2: + if drag.length() < 0.001: + return origin + var distance: float = minf(drag.length() / DRAG_RADIUS, 1.0) * MAX_RANGE + return origin + drag.normalized() * distance -func _say(message: String) -> void: - status.text = message - status_message_until = Time.get_ticks_msec() + 3000 +func _consume_shot(component: Node) -> bool: + return component.consume_shot(COOLDOWN) -func _process(delta: float) -> void: - var had_visuals := aiming or not projectiles.is_empty() or not explosions.is_empty() - button.visible = fleet.has_capability("gas_bomber") - button.enabled = _next_ready_bomber() != null and _can_afford_shot() - if not button.visible and aiming: - _cancel_aim() - if status.text != "" and Time.get_ticks_msec() >= status_message_until: - status.text = "" +func _perform_shot(ship: Node2D, _component: Node) -> void: + projectiles.append({ + "position": ship.global_position, + "target": aim_target, + "trail": PackedVector2Array([ship.global_position]), + }) - if restoring_zoom and not aiming: - var z := move_toward(camera.zoom.x, saved_zoom, maxf(saved_zoom, 0.1) * delta * 5.0) - camera.zoom = Vector2(z, z) - if is_equal_approx(z, saved_zoom): - restoring_zoom = false +func _next_ready_bomber() -> Node2D: + return _next_ready_ship() + + +func _advance_effects(delta: float) -> void: for i in range(projectiles.size() - 1, -1, -1): var projectile: Dictionary = projectiles[i] var distance: float = (projectile.position as Vector2).distance_to(projectile.target) @@ -82,8 +67,8 @@ func _process(delta: float) -> void: _explode(projectile.target) projectiles.remove_at(i) else: - projectile.position = (projectile.position as Vector2).move_toward(projectile.target, - PROJECTILE_SPEED * delta) + projectile.position = (projectile.position as Vector2).move_toward( + projectile.target, PROJECTILE_SPEED * delta) var trail: PackedVector2Array = projectile.trail trail.append(projectile.position) while trail.size() > 12: @@ -94,122 +79,6 @@ func _process(delta: float) -> void: explosion.time = maxf(float(explosion.time) - delta, 0.0) if float(explosion.time) <= 0.0: explosions.remove_at(i) - if aiming or not projectiles.is_empty() or not explosions.is_empty() or had_visuals: - queue_redraw() - - -func can_begin_aim() -> bool: - # Deliberately NOT button.enabled: an unaffordable shot still needs the - # press to reach here so it can explain itself (IconButton's own "greyed - # but still pressable" convention) rather than the touch silently - # falling through to whatever's behind it. A wholly unready fleet (no - # bomber at all) has nothing more useful to say than "greyed out". - return button.visible and not button._blocked_by_modal() \ - and not aiming and _next_ready_bomber() != null - - -func handle_touch(event: InputEventScreenTouch) -> bool: - if event.pressed: - if not can_begin_aim() or not Rect2(button.global_position, button.size).has_point(event.position): - return false - if not _can_afford_shot(): - _say("Need %d gas in the fleet hold to fire (have %d)" \ - % [GAS_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_GAS)]) - return true - aiming = true - aim_touch = event.index - aim_start_screen = event.position - aim_screen = event.position - saved_zoom = camera.zoom.x - restoring_zoom = false - button.pressed_down = true - button.queue_redraw() - _update_aim() - return true - if event.index != aim_touch: - return false - var should_fire := aim_start_screen.distance_to(aim_screen) >= AIM_DEADZONE - if should_fire: - _fire() - else: - _cancel_aim() - return true - - -func handle_drag(event: InputEventScreenDrag) -> bool: - if not aiming or event.index != aim_touch: - return false - aim_screen = event.position - _update_aim() - return true - - -func _update_aim() -> void: - var drag := aim_screen - aim_start_screen - var origin: Vector2 = fleet.get_centroid() - if drag.length() < 0.001: - aim_target = origin - else: - var distance := minf(drag.length() / DRAG_RADIUS, 1.0) * MAX_RANGE - aim_target = origin + drag.normalized() * distance - _update_aim_zoom(origin, aim_target) - queue_redraw() - - -func _update_aim_zoom(origin: Vector2, target: Vector2) -> void: - var displacement := target - origin - var half_screen := get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0) - var fit_x := saved_zoom if absf(displacement.x) < 1.0 else half_screen.x / (absf(displacement.x) + AOE_RADIUS) - var fit_y := saved_zoom if absf(displacement.y) < 1.0 else half_screen.y / (absf(displacement.y) + AOE_RADIUS) - var desired := clampf(minf(saved_zoom, minf(fit_x, fit_y)), minimum_zoom, saved_zoom) - camera.zoom = Vector2(desired, desired) - - -func _finish_aim() -> void: - aiming = false - aim_touch = -1 - button.pressed_down = false - button.queue_redraw() - restoring_zoom = true - - -func _cancel_aim() -> void: - _finish_aim() - queue_redraw() - - -func _fire() -> void: - var bomber: Node2D = _next_ready_bomber() - if bomber == null: - _cancel_aim() - return - if not _can_afford_shot(): - _say("Need %d gas in the fleet hold to fire (have %d)" \ - % [GAS_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_GAS)]) - _cancel_aim() - return - var component: Node = bomber.get_component("gas_bomber") - if not component.consume_shot(COOLDOWN): - _cancel_aim() - return - GameState.withdraw_fleet_resource(GameState.RESOURCE_GAS, GAS_PER_SHOT) - projectiles.append({ - "position": bomber.global_position, - "target": aim_target, - "trail": PackedVector2Array([bomber.global_position]), - }) - _finish_aim() - button.enabled = _next_ready_bomber() != null and _can_afford_shot() - - -func _next_ready_bomber() -> Node2D: - for ship in fleet.ships: - if not is_instance_valid(ship): - continue - var component: Node = ship.get_component("gas_bomber") - if component != null and component.is_ready(): - return ship - return null func _explode(at: Vector2) -> void: @@ -224,28 +93,29 @@ func _explode(at: Vector2) -> void: func _draw() -> void: if aiming: - var origin: Vector2 = fleet.get_centroid() - NeonDraw.draw_glowing_line(self, origin, aim_target, TARGET_COLOR, 0.7, 0.8) + NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.8) _draw_ring(aim_target, AOE_RADIUS, TARGET_COLOR, 0.8) _draw_ring(aim_target, 18.0, BOMB_COLOR, 1.0) for projectile in projectiles: var trail: PackedVector2Array = projectile.trail if trail.size() >= 2: for i in range(1, trail.size()): - var alpha := float(i) / trail.size() - draw_line(trail[i - 1], trail[i], - Color(BOMB_COLOR, alpha * 0.65), 4.0 + alpha * 4.0, true) + var alpha: float = float(i) / trail.size() + draw_line(trail[i - 1], trail[i], Color(BOMB_COLOR, alpha * 0.65), + 4.0 + alpha * 4.0, true) draw_circle(projectile.position, 12.0, Color(BOMB_COLOR, 0.35)) NeonDraw.draw_glowing_circle(self, projectile.position, 7.0, BOMB_COLOR, 1.0) for explosion in explosions: - var progress := 1.0 - float(explosion.time) / 0.8 - var radius := lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0)) + var progress: float = 1.0 - float(explosion.time) / 0.8 + var radius: float = lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0)) draw_circle(explosion.position, radius, Color(BOMB_COLOR, (1.0 - progress) * 0.16)) _draw_ring(explosion.position, radius, BOMB_COLOR, 1.0 - progress) for i in range(7): - var angle := TAU * float(i) / 7.0 + progress - var cloud_at: Vector2 = explosion.position + Vector2(cos(angle), sin(angle)) * radius * 0.55 - draw_circle(cloud_at, 18.0 + 14.0 * progress, Color(BOMB_COLOR, (1.0 - progress) * 0.22)) + var angle: float = TAU * float(i) / 7.0 + progress + var cloud_at: Vector2 = explosion.position \ + + Vector2(cos(angle), sin(angle)) * radius * 0.55 + draw_circle(cloud_at, 18.0 + 14.0 * progress, + Color(BOMB_COLOR, (1.0 - progress) * 0.22)) func _draw_ring(center: Vector2, radius: float, color: Color, alpha: float) -> void: diff --git a/GasBurnerBuildingDefinition.tres b/GasBurnerBuildingDefinition.tres index 09d2240..a0d3058 100644 --- a/GasBurnerBuildingDefinition.tres +++ b/GasBurnerBuildingDefinition.tres @@ -1,6 +1,19 @@ -[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3] +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=4 format=3] [ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"] [ext_resource type="PackedScene" path="res://GasBurnerBuildingVisual.tscn" id="2"] +[ext_resource type="Script" path="res://BuildingLogisticsProfile.gd" id="3"] + +[sub_resource type="Resource" id="Resource_logistics"] +script = ExtResource("3") +input_buffers = { +"gas": 40 +} +collected_outputs = PackedStringArray("energy") +conversion_input = "gas" +conversion_output = "energy" +conversion_rate = 1.0 +conversion_yield = 2 + [resource] script = ExtResource("1") definition_id = "gas_burner" @@ -11,6 +24,7 @@ action_icon = 33 menu_icon = 33 visual_scene = ExtResource("2") capabilities = PackedStringArray("gas_burner") +logistics = SubResource("Resource_logistics") map_color = Color(0.95, 0.35, 0.15, 1) map_sides = 5 map_radius = 7.0 diff --git a/GasBurnerBuildingVisual.gd.uid b/GasBurnerBuildingVisual.gd.uid new file mode 100644 index 0000000..20dc35e --- /dev/null +++ b/GasBurnerBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://c5a74rb5s4q21 diff --git a/LaserController.gd b/LaserController.gd index 1c0a965..b9c9cfc 100644 --- a/LaserController.gd +++ b/LaserController.gd @@ -1,195 +1,60 @@ class_name LaserController -extends Node2D - -# Same press-drag-release aiming plumbing as GasBombController, adapted for -# an instant beam rather than a lobbed, timed projectile: there is no travel -# time or explosion delay - the beam strikes the instant the shot fires, and -# only a brief flash lingers for feedback. Damage/range/cooldown are read -# straight off the firing ship's own VesselLaserComponent (already resolved -# from vessel_stat/upgrades) rather than hardcoded here, unlike the bomber's -# AOE_RADIUS/DAMAGE/COOLDOWN. -# -# Reused as-is by both Main.tscn and PlanetSurface.tscn (same relative -# ../Fleet, ../Camera2D, ../UI/LaserButton paths as GasBombController), so -# it works identically in space and on a landed surface with no branching. +extends "res://AimedFleetAbilityController.gd" const BEAM_COLOR := Color(0.3, 0.9, 1.0) const TARGET_COLOR := Color(0.6, 0.95, 1.0) const BEAM_HALF_WIDTH := 22.0 -const DRAG_DEADZONE := 22.0 const FLASH_DURATION := 0.22 -# #61: ammunition from the fleet hold, mirroring #60's gas bomber exactly. const ENERGY_PER_SHOT: int = 8 -@export var minimum_zoom: float = 0.4 -@onready var fleet: Node2D = get_node("../Fleet") -@onready var camera: Camera2D = get_node("../Camera2D") -@onready var button: IconButton = get_node("../UI/LaserButton") -@onready var status: Label = get_node("../UI/LaserStatus") - -var status_message_until: int = 0 - -var aiming := false -var aim_touch := -1 -var aim_start_screen := Vector2.ZERO -var aim_screen := Vector2.ZERO -var aim_target := Vector2.ZERO -var saved_zoom := 1.0 -var restoring_zoom := false var beams: Array = [] -var last_hit_count := 0 +var last_hit_count: int = 0 -func _ready() -> void: - button.visible = false - status.text = "" - queue_redraw() +func _button_path() -> NodePath: return "../UI/LaserButton" +func _status_path() -> NodePath: return "../UI/LaserStatus" +func _component_id() -> String: return "laser" +func _ammo_resource() -> String: return GameState.RESOURCE_ENERGY +func _ammo_cost() -> int: return ENERGY_PER_SHOT +func _aim_padding() -> float: return BEAM_HALF_WIDTH +func _has_effect_visuals() -> bool: return not beams.is_empty() -func _can_afford_shot() -> bool: - return GameState.fleet_resource(GameState.RESOURCE_ENERGY) >= ENERGY_PER_SHOT +func _aim_max_range() -> float: + var ship: Node2D = _next_ready_ship() + if ship == null: + return 950.0 + var component: Node = ship.get_component(_component_id()) + return component.beam_range if component != null else 950.0 -func _say(message: String) -> void: - status.text = message - status_message_until = Time.get_ticks_msec() + 3000 +func _calculate_aim_target(drag: Vector2, origin: Vector2) -> Vector2: + # A beam always reaches full range; dragging chooses its direction only. + var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP + return origin + direction * _aim_max_range() -func _process(delta: float) -> void: - var had_visuals := aiming or not beams.is_empty() - button.visible = fleet.has_capability("laser") - button.enabled = _next_ready_laser_ship() != null and _can_afford_shot() - if not button.visible and aiming: - _cancel_aim() - if status.text != "" and Time.get_ticks_msec() >= status_message_until: - status.text = "" +func _consume_shot(component: Node) -> bool: + return component.consume_shot() - if restoring_zoom and not aiming: - var z := move_toward(camera.zoom.x, saved_zoom, maxf(saved_zoom, 0.1) * delta * 5.0) - camera.zoom = Vector2(z, z) - if is_equal_approx(z, saved_zoom): - restoring_zoom = false +func _perform_shot(ship: Node2D, component: Node) -> void: + var direction: Vector2 = aim_target - _aim_origin() + direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP + var beam_to: Vector2 = ship.global_position + direction * component.beam_range + _strike(ship.global_position, beam_to, direction, int(component.damage)) + + +func _next_ready_laser_ship() -> Node2D: + return _next_ready_ship() + + +func _advance_effects(delta: float) -> void: for i in range(beams.size() - 1, -1, -1): var beam: Dictionary = beams[i] beam.time = maxf(float(beam.time) - delta, 0.0) if float(beam.time) <= 0.0: beams.remove_at(i) - if aiming or not beams.is_empty() or had_visuals: - queue_redraw() - - -func can_begin_aim() -> bool: - # Deliberately NOT button.enabled: an unaffordable shot still needs the - # press to reach here so it can explain itself (#60's identical - # reasoning on the gas bomber). A wholly unready fleet (no laser ship at - # all) has nothing more useful to say than "greyed out". - return button.visible and not button._blocked_by_modal() \ - and not aiming and _next_ready_laser_ship() != null - - -func handle_touch(event: InputEventScreenTouch) -> bool: - if event.pressed: - if not can_begin_aim() or not Rect2(button.global_position, button.size).has_point(event.position): - return false - if not _can_afford_shot(): - _say("Need %d energy in the fleet hold to fire (have %d)" \ - % [ENERGY_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_ENERGY)]) - return true - aiming = true - aim_touch = event.index - aim_start_screen = event.position - aim_screen = event.position - saved_zoom = camera.zoom.x - restoring_zoom = false - button.pressed_down = true - button.queue_redraw() - _update_aim() - return true - if event.index != aim_touch: - return false - var should_fire := aim_start_screen.distance_to(aim_screen) >= DRAG_DEADZONE - if should_fire: - _fire() - else: - _cancel_aim() - return true - - -func handle_drag(event: InputEventScreenDrag) -> bool: - if not aiming or event.index != aim_touch: - return false - aim_screen = event.position - _update_aim() - return true - - -func _aim_origin() -> Vector2: - return fleet.get_centroid() - - -func _aim_max_range() -> float: - var ship: Node2D = _next_ready_laser_ship() - if ship == null: - return 950.0 - var component: Node = ship.get_component("laser") - return component.beam_range if component != null else 950.0 - - -func _update_aim() -> void: - # A beam always reaches its full range - only the DIRECTION is aimed, - # unlike the bomb's drag-controlled distance. - var drag := aim_screen - aim_start_screen - var origin: Vector2 = _aim_origin() - var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP - aim_target = origin + direction * _aim_max_range() - _update_aim_zoom(origin, aim_target) - queue_redraw() - - -func _update_aim_zoom(origin: Vector2, target: Vector2) -> void: - var displacement := target - origin - var half_screen := get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0) - var fit_x := saved_zoom if absf(displacement.x) < 1.0 else half_screen.x / (absf(displacement.x) + BEAM_HALF_WIDTH) - var fit_y := saved_zoom if absf(displacement.y) < 1.0 else half_screen.y / (absf(displacement.y) + BEAM_HALF_WIDTH) - var desired := clampf(minf(saved_zoom, minf(fit_x, fit_y)), minimum_zoom, saved_zoom) - camera.zoom = Vector2(desired, desired) - - -func _finish_aim() -> void: - aiming = false - aim_touch = -1 - button.pressed_down = false - button.queue_redraw() - restoring_zoom = true - - -func _cancel_aim() -> void: - _finish_aim() - queue_redraw() - - -func _fire() -> void: - var shooter: Node2D = _next_ready_laser_ship() - if shooter == null: - _cancel_aim() - return - if not _can_afford_shot(): - _say("Need %d energy in the fleet hold to fire (have %d)" \ - % [ENERGY_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_ENERGY)]) - _cancel_aim() - return - var component: Node = shooter.get_component("laser") - if not component.consume_shot(): - _cancel_aim() - return - GameState.withdraw_fleet_resource(GameState.RESOURCE_ENERGY, ENERGY_PER_SHOT) - var direction: Vector2 = (aim_target - _aim_origin()) - direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP - var beam_to: Vector2 = shooter.global_position + direction * component.beam_range - _strike(shooter.global_position, beam_to, direction, int(component.damage)) - _finish_aim() - button.enabled = _next_ready_laser_ship() != null and _can_afford_shot() func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> void: @@ -209,20 +74,12 @@ func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> voi last_hit_count += 1 -func _next_ready_laser_ship() -> Node2D: - for ship in fleet.ships: - if not is_instance_valid(ship): - continue - var component: Node = ship.get_component("laser") - if component != null and component.is_ready(): - return ship - return null - - func _draw() -> void: if aiming: - NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.6) + NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, + TARGET_COLOR, 0.7, 0.6) for beam in beams: var progress: float = 1.0 - float(beam.time) / FLASH_DURATION var alpha: float = 1.0 - progress - NeonDraw.draw_glowing_line(self, beam.from, beam.to, BEAM_COLOR, alpha, 1.2 - progress * 0.6) + NeonDraw.draw_glowing_line(self, beam.from, beam.to, + BEAM_COLOR, alpha, 1.2 - progress * 0.6) diff --git a/LaserController.gd.uid b/LaserController.gd.uid new file mode 100644 index 0000000..303a679 --- /dev/null +++ b/LaserController.gd.uid @@ -0,0 +1 @@ +uid://cdqg3isy1lbuf diff --git a/ResearchLabPlanetVisual.gd.uid b/ResearchLabPlanetVisual.gd.uid new file mode 100644 index 0000000..a16d73f --- /dev/null +++ b/ResearchLabPlanetVisual.gd.uid @@ -0,0 +1 @@ +uid://c6nasspc8ewwj diff --git a/SpeedFader.gd b/SpeedFader.gd index a70bd68..46edf9e 100644 --- a/SpeedFader.gd +++ b/SpeedFader.gd @@ -35,12 +35,10 @@ func _process(_delta: float) -> void: if has_distorder != visible: visible = has_distorder if not has_distorder: - # A fleet must never be left permanently boosted by a vessel it no - # longer has - reset the instant the capability disappears, not just - # hide the control on top of a stale multiplier. + # Fleet owns persistent composition state. This outgoing UI node can + # briefly observe an empty fleet during a scene swap, so it must only + # clear its own interaction state here. active_touch_index = -1 - if GameState.fleet_speed_multiplier != 1.0: - GameState.fleet_speed_multiplier = 1.0 knob_t = 0.0 return knob_t = (GameState.fleet_speed_multiplier - 1.0) \ diff --git a/SpeedFader.gd.uid b/SpeedFader.gd.uid new file mode 100644 index 0000000..3dd9dd3 --- /dev/null +++ b/SpeedFader.gd.uid @@ -0,0 +1 @@ +uid://m16rgs2ed7w1 diff --git a/VesselCooldownFillComponent.gd b/VesselCooldownFillComponent.gd new file mode 100644 index 0000000..8c009c1 --- /dev/null +++ b/VesselCooldownFillComponent.gd @@ -0,0 +1,84 @@ +class_name VesselCooldownFillComponent +extends Node2D + +# Shared per-vessel cooldown state and hull-fill feedback for abilities that +# grant one independently recharging shot per ship. + +var cooldown_duration: float = 1.0 +var cooldown_left: float = 0.0 + + +func setup() -> void: + show_behind_parent = true + queue_redraw() + + +func is_ready() -> bool: + return cooldown_left <= 0.0 + + +func _begin_cooldown(duration: float) -> bool: + if not is_ready(): + return false + cooldown_duration = maxf(duration, 0.001) + cooldown_left = cooldown_duration + queue_redraw() + return true + + +func tick(delta: float) -> void: + if cooldown_left <= 0.0: + return + cooldown_left = maxf(cooldown_left - delta, 0.0) + queue_redraw() + + +func cooldown_progress() -> float: + return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001) + + +func _filled_hull(points: PackedVector2Array, progress: float) -> PackedVector2Array: + if points.size() < 3 or progress <= 0.001: + return PackedVector2Array() + if progress >= 0.999: + return points.duplicate() + var top: float = INF + var bottom: float = -INF + for point in points: + top = minf(top, point.y) + bottom = maxf(bottom, point.y) + var fill_y: float = lerpf(bottom, top, clampf(progress, 0.0, 1.0)) + var clipped := PackedVector2Array() + var previous: Vector2 = points[-1] + var previous_inside: bool = previous.y >= fill_y + for current in points: + var current_inside: bool = current.y >= fill_y + if current_inside != previous_inside: + var amount: float = (fill_y - previous.y) / (current.y - previous.y) + clipped.append(previous.lerp(current, amount)) + if current_inside: + clipped.append(current) + previous = current + previous_inside = current_inside + return clipped + + +func _draw() -> void: + var ship: Node = get_parent() + if ship == null or ship.definition == null: + return + var hull: PackedVector2Array = ship.definition.hull_points + var ready_color: Color = _ready_color() + if is_ready(): + draw_colored_polygon(hull, Color(ready_color, 0.42)) + NeonDraw.draw_glowing_polygon(self, hull, ready_color, 0.75, + ship.core_color, 0.75) + return + draw_colored_polygon(hull, Color(_cooling_color(), 0.58)) + var filled: PackedVector2Array = _filled_hull(hull, cooldown_progress()) + if filled.size() >= 3: + draw_colored_polygon(filled, Color(ready_color, 0.68)) + + +func _ready_color() -> Color: return Color.WHITE +func _cooling_color() -> Color: return Color(0.2, 0.2, 0.2) diff --git a/VesselCooldownFillComponent.gd.uid b/VesselCooldownFillComponent.gd.uid new file mode 100644 index 0000000..2897948 --- /dev/null +++ b/VesselCooldownFillComponent.gd.uid @@ -0,0 +1 @@ +uid://drsagaj2wr0ni diff --git a/VesselDistorderComponent.gd.uid b/VesselDistorderComponent.gd.uid new file mode 100644 index 0000000..b9a9147 --- /dev/null +++ b/VesselDistorderComponent.gd.uid @@ -0,0 +1 @@ +uid://c2una2fh0f06b diff --git a/VesselGasBomberComponent.gd b/VesselGasBomberComponent.gd index dc44815..a109188 100644 --- a/VesselGasBomberComponent.gd +++ b/VesselGasBomberComponent.gd @@ -1,84 +1,19 @@ class_name VesselGasBomberComponent -extends Node2D +extends "res://VesselCooldownFillComponent.gd" var capability_id := "gas_bomber" -var cooldown_duration := 5.0 -var cooldown_left := 0.0 const READY_COLOR := Color(1.0, 0.25, 0.78) const COOLING_COLOR := Color(0.38, 0.16, 0.32) -func setup() -> void: - # Keep the ship's normal neon outline above the charge fill. - show_behind_parent = true - queue_redraw() - - -func is_ready() -> bool: - return cooldown_left <= 0.0 +func _init() -> void: + cooldown_duration = 5.0 func consume_shot(duration: float) -> bool: - if not is_ready(): - return false - cooldown_duration = maxf(duration, 0.001) - cooldown_left = cooldown_duration - queue_redraw() - return true + return _begin_cooldown(duration) -func tick(delta: float) -> void: - if cooldown_left <= 0.0: - return - cooldown_left = maxf(cooldown_left - delta, 0.0) - queue_redraw() - - -func cooldown_progress() -> float: - return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001) - - -func _filled_hull(points: PackedVector2Array, progress: float) -> PackedVector2Array: - if points.size() < 3 or progress <= 0.001: - return PackedVector2Array() - if progress >= 0.999: - return points.duplicate() - var top := INF - var bottom := -INF - for point in points: - top = minf(top, point.y) - bottom = maxf(bottom, point.y) - var fill_y: float = lerpf(bottom, top, clampf(progress, 0.0, 1.0)) - var clipped := PackedVector2Array() - var previous: Vector2 = points[-1] - var previous_inside := previous.y >= fill_y - for current in points: - var current_inside := current.y >= fill_y - if current_inside != previous_inside: - var amount: float = (fill_y - previous.y) / (current.y - previous.y) - clipped.append(previous.lerp(current, amount)) - if current_inside: - clipped.append(current) - previous = current - previous_inside = current_inside - return clipped - - -func _draw() -> void: - var parent_ship: Node = get_parent() - if parent_ship == null or parent_ship.definition == null: - return - var core: Color = parent_ship.core_color if parent_ship != null else Color.WHITE - var hull: PackedVector2Array = parent_ship.definition.hull_points - if is_ready(): - # A fully lit hull means this individual bomber owns an available shot. - draw_colored_polygon(hull, Color(READY_COLOR, 0.42)) - NeonDraw.draw_glowing_polygon(self, hull, READY_COLOR, 0.75, core, 0.75) - return - # Rearming is shown inside the bomber instead of as a shield-like ring: - # the muted silhouette fills from its engines toward its nose. - draw_colored_polygon(hull, Color(COOLING_COLOR, 0.58)) - var filled := _filled_hull(hull, cooldown_progress()) - if filled.size() >= 3: - draw_colored_polygon(filled, Color(READY_COLOR, 0.68)) +func _ready_color() -> Color: return READY_COLOR +func _cooling_color() -> Color: return COOLING_COLOR diff --git a/VesselLaserComponent.gd b/VesselLaserComponent.gd index fb9379e..a004277 100644 --- a/VesselLaserComponent.gd +++ b/VesselLaserComponent.gd @@ -1,29 +1,16 @@ class_name VesselLaserComponent -extends Node2D - -# Same shape as VesselGasBomberComponent (per-ship cooldown, ready/cooling -# hull fill) - #58's own note: two laser vessels must give two shots, so -# this lives on the SHIP, not the fleet-wide LaserController. -# -# Unlike the gas bomber, damage/beam_range/cooldown are resolved from the -# vessel's own base_stats/upgrades (apply_stats(), the same pattern -# VesselWeaponComponent uses) rather than hardcoded in the controller, so -# ship-lab upgrades actually reach them. +extends "res://VesselCooldownFillComponent.gd" var capability_id := "laser" var damage: int = 2 var beam_range: float = 950.0 -var cooldown_duration: float = 4.0 -var cooldown_left: float = 0.0 const READY_COLOR := Color(0.3, 0.9, 1.0) const COOLING_COLOR := Color(0.08, 0.28, 0.34) -func setup() -> void: - # Keep the ship's normal neon outline above the charge fill. - show_behind_parent = true - queue_redraw() +func _init() -> void: + cooldown_duration = 4.0 func apply_stats() -> void: @@ -35,66 +22,9 @@ func apply_stats() -> void: queue_redraw() -func is_ready() -> bool: - return cooldown_left <= 0.0 - - func consume_shot() -> bool: - if not is_ready(): - return false - cooldown_left = maxf(cooldown_duration, 0.001) - queue_redraw() - return true + return _begin_cooldown(cooldown_duration) -func tick(delta: float) -> void: - if cooldown_left <= 0.0: - return - cooldown_left = maxf(cooldown_left - delta, 0.0) - queue_redraw() - - -func cooldown_progress() -> float: - return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001) - - -func _filled_hull(points: PackedVector2Array, progress: float) -> PackedVector2Array: - if points.size() < 3 or progress <= 0.001: - return PackedVector2Array() - if progress >= 0.999: - return points.duplicate() - var top := INF - var bottom := -INF - for point in points: - top = minf(top, point.y) - bottom = maxf(bottom, point.y) - var fill_y: float = lerpf(bottom, top, clampf(progress, 0.0, 1.0)) - var clipped := PackedVector2Array() - var previous: Vector2 = points[-1] - var previous_inside := previous.y >= fill_y - for current in points: - var current_inside := current.y >= fill_y - if current_inside != previous_inside: - var amount: float = (fill_y - previous.y) / (current.y - previous.y) - clipped.append(previous.lerp(current, amount)) - if current_inside: - clipped.append(current) - previous = current - previous_inside = current_inside - return clipped - - -func _draw() -> void: - var parent_ship: Node = get_parent() - if parent_ship == null or parent_ship.definition == null: - return - var core: Color = parent_ship.core_color if parent_ship != null else Color.WHITE - var hull: PackedVector2Array = parent_ship.definition.hull_points - if is_ready(): - draw_colored_polygon(hull, Color(READY_COLOR, 0.42)) - NeonDraw.draw_glowing_polygon(self, hull, READY_COLOR, 0.75, core, 0.75) - return - draw_colored_polygon(hull, Color(COOLING_COLOR, 0.58)) - var filled := _filled_hull(hull, cooldown_progress()) - if filled.size() >= 3: - draw_colored_polygon(filled, Color(READY_COLOR, 0.68)) +func _ready_color() -> Color: return READY_COLOR +func _cooling_color() -> Color: return COOLING_COLOR diff --git a/VesselLaserComponent.gd.uid b/VesselLaserComponent.gd.uid new file mode 100644 index 0000000..40baaf0 --- /dev/null +++ b/VesselLaserComponent.gd.uid @@ -0,0 +1 @@ +uid://bds5gccvd8dj8 diff --git a/gas_surface.gdshader.uid b/gas_surface.gdshader.uid new file mode 100644 index 0000000..d4176be --- /dev/null +++ b/gas_surface.gdshader.uid @@ -0,0 +1 @@ +uid://bhxcvtw6tcshl diff --git a/tests/_black_hole_space_preview.gd.uid b/tests/_black_hole_space_preview.gd.uid new file mode 100644 index 0000000..eb96047 --- /dev/null +++ b/tests/_black_hole_space_preview.gd.uid @@ -0,0 +1 @@ +uid://25xjfqi2qk7a diff --git a/tests/_carrier_crafter_preview.gd.uid b/tests/_carrier_crafter_preview.gd.uid new file mode 100644 index 0000000..b6cefcc --- /dev/null +++ b/tests/_carrier_crafter_preview.gd.uid @@ -0,0 +1 @@ +uid://f5a8wm4wr2i4 diff --git a/tests/_carrier_hangar_preview.gd.uid b/tests/_carrier_hangar_preview.gd.uid new file mode 100644 index 0000000..3f59a52 --- /dev/null +++ b/tests/_carrier_hangar_preview.gd.uid @@ -0,0 +1 @@ +uid://b5ah2jh0i16du diff --git a/tests/_fleet_cargo_limit_preview.gd.uid b/tests/_fleet_cargo_limit_preview.gd.uid new file mode 100644 index 0000000..82353c4 --- /dev/null +++ b/tests/_fleet_cargo_limit_preview.gd.uid @@ -0,0 +1 @@ +uid://brt82kn8lb3u5 diff --git a/tests/_gas_burner_build_menu_preview.gd.uid b/tests/_gas_burner_build_menu_preview.gd.uid new file mode 100644 index 0000000..965e80c --- /dev/null +++ b/tests/_gas_burner_build_menu_preview.gd.uid @@ -0,0 +1 @@ +uid://bs6hguk0tkc6c diff --git a/tests/_gas_burner_transfer_preview.gd.uid b/tests/_gas_burner_transfer_preview.gd.uid new file mode 100644 index 0000000..b665754 --- /dev/null +++ b/tests/_gas_burner_transfer_preview.gd.uid @@ -0,0 +1 @@ +uid://fd1m4excjdbn diff --git a/tests/_laser_aim_preview.gd.uid b/tests/_laser_aim_preview.gd.uid new file mode 100644 index 0000000..9064f70 --- /dev/null +++ b/tests/_laser_aim_preview.gd.uid @@ -0,0 +1 @@ +uid://c0q640rosb53o diff --git a/tests/_mothership_beam_preview.gd.uid b/tests/_mothership_beam_preview.gd.uid new file mode 100644 index 0000000..def6f77 --- /dev/null +++ b/tests/_mothership_beam_preview.gd.uid @@ -0,0 +1 @@ +uid://c8lgxg63d8s52 diff --git a/tests/_resource_readout_surface_preview.gd.uid b/tests/_resource_readout_surface_preview.gd.uid new file mode 100644 index 0000000..27eb816 --- /dev/null +++ b/tests/_resource_readout_surface_preview.gd.uid @@ -0,0 +1 @@ +uid://cy6n62kcp7q1y diff --git a/tests/_rocky_surface_preview.gd.uid b/tests/_rocky_surface_preview.gd.uid new file mode 100644 index 0000000..09a037c --- /dev/null +++ b/tests/_rocky_surface_preview.gd.uid @@ -0,0 +1 @@ +uid://c0yjf3qdv31eo diff --git a/tests/_turret_vs_chaser_preview.gd.uid b/tests/_turret_vs_chaser_preview.gd.uid new file mode 100644 index 0000000..f71bd01 --- /dev/null +++ b/tests/_turret_vs_chaser_preview.gd.uid @@ -0,0 +1 @@ +uid://dfi84x2tcwqwy diff --git a/tests/_void_surface_preview.gd.uid b/tests/_void_surface_preview.gd.uid new file mode 100644 index 0000000..0e500ff --- /dev/null +++ b/tests/_void_surface_preview.gd.uid @@ -0,0 +1 @@ +uid://clf1bitxms0ti diff --git a/tests/test_black_hole.gd.uid b/tests/test_black_hole.gd.uid new file mode 100644 index 0000000..75397b6 --- /dev/null +++ b/tests/test_black_hole.gd.uid @@ -0,0 +1 @@ +uid://vlcbg3juhpy3 diff --git a/tests/test_carrier_vessel.gd.uid b/tests/test_carrier_vessel.gd.uid new file mode 100644 index 0000000..5af74b7 --- /dev/null +++ b/tests/test_carrier_vessel.gd.uid @@ -0,0 +1 @@ +uid://gj02xu88lxpm diff --git a/tests/test_distorder_energy.gd.uid b/tests/test_distorder_energy.gd.uid new file mode 100644 index 0000000..e954283 --- /dev/null +++ b/tests/test_distorder_energy.gd.uid @@ -0,0 +1 @@ +uid://bistn7dgiv7lw diff --git a/tests/test_enemy_mothership.gd.uid b/tests/test_enemy_mothership.gd.uid new file mode 100644 index 0000000..9679052 --- /dev/null +++ b/tests/test_enemy_mothership.gd.uid @@ -0,0 +1 @@ +uid://c18rxosfduxoa diff --git a/tests/test_fleet_cargo_limit.gd.uid b/tests/test_fleet_cargo_limit.gd.uid new file mode 100644 index 0000000..58069e9 --- /dev/null +++ b/tests/test_fleet_cargo_limit.gd.uid @@ -0,0 +1 @@ +uid://b1fyqy5i8mqf0 diff --git a/tests/test_gas_bomber_ammo.gd.uid b/tests/test_gas_bomber_ammo.gd.uid new file mode 100644 index 0000000..c31651b --- /dev/null +++ b/tests/test_gas_bomber_ammo.gd.uid @@ -0,0 +1 @@ +uid://cwcw77qvykk35 diff --git a/tests/test_gas_bomber_cooldowns.gd.uid b/tests/test_gas_bomber_cooldowns.gd.uid new file mode 100644 index 0000000..41c349e --- /dev/null +++ b/tests/test_gas_bomber_cooldowns.gd.uid @@ -0,0 +1 @@ +uid://tguuracp1ewt diff --git a/tests/test_gas_burner.gd b/tests/test_gas_burner.gd index 4dfffd0..4b97dc5 100644 --- a/tests/test_gas_burner.gd +++ b/tests/test_gas_burner.gd @@ -30,6 +30,8 @@ var hub_id: int = 0 var burner_id: int = 0 var load_limit: int = 0 var energy_before_stall: int = 0 +var conversion_rate: float = 0.0 +var conversion_yield: int = 0 const MOTHERSHIP_AT := Vector2.ZERO const HUB_AT := Vector2(150.0, 0.0) @@ -48,6 +50,9 @@ func _initialize() -> void: mothership_id = game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) burner_id = game_state.add_building(GameState.BUILDING_GAS_BURNER, BURNER_AT) game_state.deposit_resource_to_building(burner_id, GameState.RESOURCE_GAS, 20) + var logistics: Resource = game_state.building_logistics_profile(game_state.find_building(burner_id)) + conversion_rate = logistics.conversion_rate + conversion_yield = logistics.conversion_yield func _process(delta: float) -> bool: @@ -73,10 +78,10 @@ func _process(delta: float) -> bool: var energy_made: int = game_state.building_resource(burner_id, GameState.RESOURCE_ENERGY) var gas_burned: int = 20 - gas_left if gas_burned < 2: - return _fail("burner only burned %d gas in 2.5s at rate %.1f/s" % [gas_burned, GameState.GAS_BURN_RATE]) - if energy_made != gas_burned * GameState.ENERGY_PER_GAS: + return _fail("burner only burned %d gas in 2.5s at rate %.1f/s" % [gas_burned, conversion_rate]) + if energy_made != gas_burned * conversion_yield: return _fail("burner made %d energy for %d gas burned, expected %dx" % [ - energy_made, gas_burned, GameState.ENERGY_PER_GAS + energy_made, gas_burned, conversion_yield ]) if game_state.rocks != 0 or game_state.gas != 0: return _fail("conversion touched the bank (rocks=%d gas=%d) - it must only use local stock" % [ diff --git a/tests/test_gas_burner.gd.uid b/tests/test_gas_burner.gd.uid new file mode 100644 index 0000000..0188f66 --- /dev/null +++ b/tests/test_gas_burner.gd.uid @@ -0,0 +1 @@ +uid://dlx2eu6lw3ps6 diff --git a/tests/test_hub_capacity.gd b/tests/test_hub_capacity.gd index 0bc68ae..d9005de 100644 --- a/tests/test_hub_capacity.gd +++ b/tests/test_hub_capacity.gd @@ -12,6 +12,7 @@ func _initialize() -> void: state.transporters.clear() state.reset_progression() state.rocks = 900 + state.research_points = state.skill_research_point_cost("freight_yards") state.unlocked = ["shipyards", "logistics", "freight"] var hub_a: int = state.add_building(state.BUILDING_TRANSPORT_HUB, Vector2(-500, 0)) diff --git a/tests/test_laser_ammo.gd.uid b/tests/test_laser_ammo.gd.uid new file mode 100644 index 0000000..c314dea --- /dev/null +++ b/tests/test_laser_ammo.gd.uid @@ -0,0 +1 @@ +uid://d24p5etger56g diff --git a/tests/test_laser_vessel.gd.uid b/tests/test_laser_vessel.gd.uid new file mode 100644 index 0000000..df5f9c4 --- /dev/null +++ b/tests/test_laser_vessel.gd.uid @@ -0,0 +1 @@ +uid://dp0e71h2124c8 diff --git a/tests/test_research_points.gd.uid b/tests/test_research_points.gd.uid new file mode 100644 index 0000000..fe90658 --- /dev/null +++ b/tests/test_research_points.gd.uid @@ -0,0 +1 @@ +uid://x08iyhs2lrrr diff --git a/tests/test_resource_readout.gd.uid b/tests/test_resource_readout.gd.uid new file mode 100644 index 0000000..37f0f14 --- /dev/null +++ b/tests/test_resource_readout.gd.uid @@ -0,0 +1 @@ +uid://bhad0657luiec diff --git a/tests/test_space_time_distorder.gd.uid b/tests/test_space_time_distorder.gd.uid new file mode 100644 index 0000000..5f7ecd6 --- /dev/null +++ b/tests/test_space_time_distorder.gd.uid @@ -0,0 +1 @@ +uid://btmmwkkgw6qwi diff --git a/tests/test_surface_profiles.gd.uid b/tests/test_surface_profiles.gd.uid new file mode 100644 index 0000000..2de5318 --- /dev/null +++ b/tests/test_surface_profiles.gd.uid @@ -0,0 +1 @@ +uid://crbidmy4oqat7 diff --git a/tests/test_turret_spawner_enemy.gd.uid b/tests/test_turret_spawner_enemy.gd.uid new file mode 100644 index 0000000..dbd423d --- /dev/null +++ b/tests/test_turret_spawner_enemy.gd.uid @@ -0,0 +1 @@ +uid://du2i8ayup3qkd diff --git a/tests/test_vessel_composition.gd b/tests/test_vessel_composition.gd index 67bf6c3..226d9a1 100644 --- a/tests/test_vessel_composition.gd +++ b/tests/test_vessel_composition.gd @@ -23,8 +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() != 5 \ - or fleet_definitions.any(func(definition): return definition.has_capability("transport")): + if 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(): return _fail("catalog and composed fleet differ in size")