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 02de479..6a45e1e 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) --------------------------------------------- @@ -672,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 @@ -715,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 = 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] skill_cost.amount = int(node.cost) if GameState.is_unlocked(id): @@ -734,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 @@ -894,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()): @@ -906,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" % [ @@ -924,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() @@ -1003,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" @@ -1016,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/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 new file mode 100644 index 0000000..c9bc3e7 --- /dev/null +++ b/DistorderVesselDefinition.tres @@ -0,0 +1,25 @@ +[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} +research_point_cost = 1 +unlock_icon = -1 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 78e4627..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 @@ -200,18 +209,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: @@ -276,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/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..35c4bbc 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() @@ -22,6 +23,35 @@ 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 + +# #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) + + +# #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 @@ -93,6 +123,26 @@ 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) + + +# #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: @@ -230,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": "", @@ -243,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.", }, ] @@ -328,16 +385,27 @@ 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 >= 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: 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 = skill_research_point_cost(id) + 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 @@ -381,6 +449,10 @@ func reset_new_game() -> void: planet_name = "" 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}) fleet_inventory = INVENTORY.new() @@ -454,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 @@ -465,6 +544,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. @@ -556,10 +636,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 @@ -606,7 +690,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: @@ -614,6 +699,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 @@ -876,6 +965,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: @@ -904,10 +1001,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(): @@ -950,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 @@ -1092,44 +1200,76 @@ 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: + 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 @@ -1142,7 +1282,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): @@ -1153,8 +1293,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: @@ -1181,6 +1326,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: @@ -1206,7 +1361,8 @@ 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 var whole: int = int(floor(float(e.mining_progress))) @@ -1364,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 @@ -1423,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 @@ -1444,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) @@ -1474,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 4a5b213..43e784a 100644 --- a/GasBombController.gd +++ b/GasBombController.gd @@ -1,61 +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 +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") - -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 - 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 _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 - if not button.visible and aiming: - _cancel_aim() +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 - 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 _consume_shot(component: Node) -> bool: + return component.consume_shot(COOLDOWN) + + +func _perform_shot(ship: Node2D, _component: Node) -> void: + projectiles.append({ + "position": ship.global_position, + "target": aim_target, + "trail": PackedVector2Array([ship.global_position]), + }) + + +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) @@ -64,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: @@ -76,107 +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: - return button.visible and button.enabled and not button._blocked_by_modal() \ - and not aiming - - -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 - 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 - var component: Node = bomber.get_component("gas_bomber") - if not component.consume_shot(COOLDOWN): - _cancel_aim() - return - projectiles.append({ - "position": bomber.global_position, - "target": aim_target, - "trail": PackedVector2Array([bomber.global_position]), - }) - _finish_aim() - button.enabled = _next_ready_bomber() != null - - -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: @@ -191,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/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/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/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/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/LaserController.gd b/LaserController.gd index 78f8e81..b9c9cfc 100644 --- a/LaserController.gd +++ b/LaserController.gd @@ -1,164 +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 +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") - -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 - 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 _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 - if not button.visible and aiming: - _cancel_aim() +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 - 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 _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 _consume_shot(component: Node) -> bool: + return component.consume_shot() + + +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: - return button.visible and button.enabled and not button._blocked_by_modal() \ - and not aiming - - -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 - 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 - var component: Node = shooter.get_component("laser") - if not component.consume_shot(): - _cancel_aim() - return - 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 func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> void: @@ -178,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/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 7eaf569..e7a1427 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 @@ -264,6 +287,38 @@ 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="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 @@ -839,7 +894,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 +905,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 +914,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 +926,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 +936,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 +948,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 +958,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 +970,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 +980,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 +992,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 +1034,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 +1045,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 +1054,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 +1066,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 +1076,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 +1088,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 +1098,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 +1110,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 +1120,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 +1132,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 +1206,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 +1216,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 +1227,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 +1238,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 +1249,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 +1260,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 +1296,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 +1307,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 +1319,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 +1331,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 +1343,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 +1355,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 +1620,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 +1633,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 +1645,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 +1659,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 +1673,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 +1687,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 +1701,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 +1715,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 +1729,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 +1743,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 +1752,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 @@ -1936,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 @@ -1948,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 @@ -1962,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/PlanetSurface.tscn b/PlanetSurface.tscn index afdeb49..709872e 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 @@ -204,6 +229,38 @@ 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="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/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.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/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/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..46edf9e --- /dev/null +++ b/SpeedFader.gd @@ -0,0 +1,125 @@ +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 +var status_message_until: int = 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: + # 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 + knob_t = 0.0 + return + knob_t = (GameState.fleet_speed_multiplier - 1.0) \ + / maxf(GameState.MAX_FLEET_SPEED_MULTIPLIER - 1.0, 0.001) + # #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() + + +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/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/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/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/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/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.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/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/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/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/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/_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/_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 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_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 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_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_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_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.gd b/tests/test_gas_bomber.gd index cda2a2f..8ba50e7 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,10 @@ 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). # 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 +76,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 +125,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_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 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 index d03a550..41c349e 100644 --- a/tests/test_gas_bomber_cooldowns.gd.uid +++ b/tests/test_gas_bomber_cooldowns.gd.uid @@ -1 +1 @@ -uid://cwo6ake5taias +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_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_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_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 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 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_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_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_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() diff --git a/tests/test_space_time_distorder.gd b/tests/test_space_time_distorder.gd new file mode 100644 index 0000000..6890399 --- /dev/null +++ b/tests/test_space_time_distorder.gd @@ -0,0 +1,239 @@ +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 + # #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") + 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 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")