From d68f4d4f888b5d62479638648a033305f07fadeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 16 Aug 2026 21:38:07 +0200 Subject: [PATCH] Refactor recent gameplay systems after code review --- AimedFleetAbilityController.gd | 191 +++++++++++++++ AimedFleetAbilityController.gd.uid | 1 + BuildController.gd | 6 +- BuildingDefinition.gd | 1 + BuildingLogisticsProfile.gd | 13 ++ BuildingLogisticsProfile.gd.uid | 1 + EnemyBigLaserBeam.gd.uid | 1 + EnemyBigLaserComponent.gd.uid | 1 + EnemySpawnerComponent.gd.uid | 1 + EnergyResourceGlyph.gd.uid | 1 + Fleet.gd | 17 +- GameState.gd | 120 +++++----- GasBombController.gd | 212 ++++------------- GasBurnerBuildingDefinition.tres | 16 +- GasBurnerBuildingVisual.gd.uid | 1 + LaserController.gd | 217 +++--------------- LaserController.gd.uid | 1 + ResearchLabPlanetVisual.gd.uid | 1 + SpeedFader.gd | 8 +- SpeedFader.gd.uid | 1 + VesselCooldownFillComponent.gd | 84 +++++++ VesselCooldownFillComponent.gd.uid | 1 + VesselDistorderComponent.gd.uid | 1 + VesselGasBomberComponent.gd | 77 +------ VesselLaserComponent.gd | 82 +------ VesselLaserComponent.gd.uid | 1 + gas_surface.gdshader.uid | 1 + tests/_black_hole_space_preview.gd.uid | 1 + tests/_carrier_crafter_preview.gd.uid | 1 + tests/_carrier_hangar_preview.gd.uid | 1 + tests/_fleet_cargo_limit_preview.gd.uid | 1 + tests/_gas_burner_build_menu_preview.gd.uid | 1 + tests/_gas_burner_transfer_preview.gd.uid | 1 + tests/_laser_aim_preview.gd.uid | 1 + tests/_mothership_beam_preview.gd.uid | 1 + .../_resource_readout_surface_preview.gd.uid | 1 + tests/_rocky_surface_preview.gd.uid | 1 + tests/_turret_vs_chaser_preview.gd.uid | 1 + tests/_void_surface_preview.gd.uid | 1 + tests/test_black_hole.gd.uid | 1 + tests/test_carrier_vessel.gd.uid | 1 + tests/test_distorder_energy.gd.uid | 1 + tests/test_enemy_mothership.gd.uid | 1 + tests/test_fleet_cargo_limit.gd.uid | 1 + tests/test_gas_bomber_ammo.gd.uid | 1 + tests/test_gas_bomber_cooldowns.gd.uid | 1 + tests/test_gas_burner.gd | 11 +- tests/test_gas_burner.gd.uid | 1 + tests/test_hub_capacity.gd | 1 + tests/test_laser_ammo.gd.uid | 1 + tests/test_laser_vessel.gd.uid | 1 + tests/test_research_points.gd.uid | 1 + tests/test_resource_readout.gd.uid | 1 + tests/test_space_time_distorder.gd.uid | 1 + tests/test_surface_profiles.gd.uid | 1 + tests/test_turret_spawner_enemy.gd.uid | 1 + tests/test_vessel_composition.gd | 3 +- 57 files changed, 529 insertions(+), 571 deletions(-) create mode 100644 AimedFleetAbilityController.gd create mode 100644 AimedFleetAbilityController.gd.uid create mode 100644 BuildingLogisticsProfile.gd create mode 100644 BuildingLogisticsProfile.gd.uid create mode 100644 EnemyBigLaserBeam.gd.uid create mode 100644 EnemyBigLaserComponent.gd.uid create mode 100644 EnemySpawnerComponent.gd.uid create mode 100644 EnergyResourceGlyph.gd.uid create mode 100644 GasBurnerBuildingVisual.gd.uid create mode 100644 LaserController.gd.uid create mode 100644 ResearchLabPlanetVisual.gd.uid create mode 100644 SpeedFader.gd.uid create mode 100644 VesselCooldownFillComponent.gd create mode 100644 VesselCooldownFillComponent.gd.uid create mode 100644 VesselDistorderComponent.gd.uid create mode 100644 VesselLaserComponent.gd.uid create mode 100644 gas_surface.gdshader.uid create mode 100644 tests/_black_hole_space_preview.gd.uid create mode 100644 tests/_carrier_crafter_preview.gd.uid create mode 100644 tests/_carrier_hangar_preview.gd.uid create mode 100644 tests/_fleet_cargo_limit_preview.gd.uid create mode 100644 tests/_gas_burner_build_menu_preview.gd.uid create mode 100644 tests/_gas_burner_transfer_preview.gd.uid create mode 100644 tests/_laser_aim_preview.gd.uid create mode 100644 tests/_mothership_beam_preview.gd.uid create mode 100644 tests/_resource_readout_surface_preview.gd.uid create mode 100644 tests/_rocky_surface_preview.gd.uid create mode 100644 tests/_turret_vs_chaser_preview.gd.uid create mode 100644 tests/_void_surface_preview.gd.uid create mode 100644 tests/test_black_hole.gd.uid create mode 100644 tests/test_carrier_vessel.gd.uid create mode 100644 tests/test_distorder_energy.gd.uid create mode 100644 tests/test_enemy_mothership.gd.uid create mode 100644 tests/test_fleet_cargo_limit.gd.uid create mode 100644 tests/test_gas_bomber_ammo.gd.uid create mode 100644 tests/test_gas_bomber_cooldowns.gd.uid create mode 100644 tests/test_gas_burner.gd.uid create mode 100644 tests/test_laser_ammo.gd.uid create mode 100644 tests/test_laser_vessel.gd.uid create mode 100644 tests/test_research_points.gd.uid create mode 100644 tests/test_resource_readout.gd.uid create mode 100644 tests/test_space_time_distorder.gd.uid create mode 100644 tests/test_surface_profiles.gd.uid create mode 100644 tests/test_turret_spawner_enemy.gd.uid diff --git a/AimedFleetAbilityController.gd b/AimedFleetAbilityController.gd new file mode 100644 index 0000000..f8cb2e5 --- /dev/null +++ b/AimedFleetAbilityController.gd @@ -0,0 +1,191 @@ +class_name AimedFleetAbilityController +extends Node2D + +# Shared press-drag-release input and camera behavior for manually aimed fleet +# abilities. Subclasses provide the component, ammunition, target calculation, +# shot effect, and effect visuals. + +const AIM_DEADZONE: float = 22.0 + +@export var minimum_zoom: float = 0.4 +@onready var fleet: Node2D = get_node("../Fleet") +@onready var camera: Camera2D = get_node("../Camera2D") + +var button: IconButton +var status: Label +var status_message_until: int = 0 +var aiming: bool = false +var aim_touch: int = -1 +var aim_start_screen: Vector2 = Vector2.ZERO +var aim_screen: Vector2 = Vector2.ZERO +var aim_target: Vector2 = Vector2.ZERO +var saved_zoom: float = 1.0 +var restoring_zoom: bool = false + + +func _ready() -> void: + button = get_node(_button_path()) + status = get_node(_status_path()) + button.visible = false + status.text = "" + queue_redraw() + + +func _process(delta: float) -> void: + var had_visuals: bool = aiming or _has_effect_visuals() + button.visible = fleet.has_capability(_component_id()) + button.enabled = _next_ready_ship() != null and _can_afford_shot() + if not button.visible and aiming: + _cancel_aim() + if status.text != "" and Time.get_ticks_msec() >= status_message_until: + status.text = "" + + if restoring_zoom and not aiming: + var zoom: float = move_toward(camera.zoom.x, saved_zoom, + maxf(saved_zoom, 0.1) * delta * 5.0) + camera.zoom = Vector2(zoom, zoom) + if is_equal_approx(zoom, saved_zoom): + restoring_zoom = false + + _advance_effects(delta) + if aiming or _has_effect_visuals() or had_visuals: + queue_redraw() + + +func can_begin_aim() -> bool: + # An unaffordable shot still claims the press so the status label can + # explain what is missing instead of silently passing the touch through. + return button.visible and not button._blocked_by_modal() \ + and not aiming and _next_ready_ship() != null + + +func handle_touch(event: InputEventScreenTouch) -> bool: + if event.pressed: + if not can_begin_aim() \ + or not Rect2(button.global_position, button.size).has_point(event.position): + return false + if not _can_afford_shot(): + _say(_unaffordable_message()) + return true + aiming = true + aim_touch = event.index + aim_start_screen = event.position + aim_screen = event.position + saved_zoom = camera.zoom.x + restoring_zoom = false + button.pressed_down = true + button.queue_redraw() + _update_aim() + return true + if event.index != aim_touch: + return false + if aim_start_screen.distance_to(aim_screen) >= AIM_DEADZONE: + _fire() + else: + _cancel_aim() + return true + + +func handle_drag(event: InputEventScreenDrag) -> bool: + if not aiming or event.index != aim_touch: + return false + aim_screen = event.position + _update_aim() + return true + + +func _update_aim() -> void: + var origin: Vector2 = _aim_origin() + aim_target = _calculate_aim_target(aim_screen - aim_start_screen, origin) + _update_aim_zoom(origin, aim_target) + queue_redraw() + + +func _update_aim_zoom(origin: Vector2, target: Vector2) -> void: + var displacement: Vector2 = target - origin + var half_screen: Vector2 = get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0) + var padding: float = _aim_padding() + var fit_x: float = saved_zoom if absf(displacement.x) < 1.0 \ + else half_screen.x / (absf(displacement.x) + padding) + var fit_y: float = saved_zoom if absf(displacement.y) < 1.0 \ + else half_screen.y / (absf(displacement.y) + padding) + var desired: float = clampf(minf(saved_zoom, minf(fit_x, fit_y)), + minimum_zoom, saved_zoom) + camera.zoom = Vector2(desired, desired) + + +func _finish_aim() -> void: + aiming = false + aim_touch = -1 + button.pressed_down = false + button.queue_redraw() + restoring_zoom = true + + +func _cancel_aim() -> void: + _finish_aim() + queue_redraw() + + +func _fire() -> void: + var ship: Node2D = _next_ready_ship() + if ship == null: + _cancel_aim() + return + if not _can_afford_shot(): + _say(_unaffordable_message()) + _cancel_aim() + return + var component: Node = ship.get_component(_component_id()) + if component == null or not _consume_shot(component): + _cancel_aim() + return + GameState.withdraw_fleet_resource(_ammo_resource(), _ammo_cost()) + _perform_shot(ship, component) + _finish_aim() + button.enabled = _next_ready_ship() != null and _can_afford_shot() + + +func _next_ready_ship() -> Node2D: + for ship in fleet.ships: + if not is_instance_valid(ship): + continue + var component: Node = ship.get_component(_component_id()) + if component != null and component.is_ready(): + return ship + return null + + +func _can_afford_shot() -> bool: + return GameState.fleet_resource(_ammo_resource()) >= _ammo_cost() + + +func _unaffordable_message() -> String: + var resource_type: String = _ammo_resource() + return "Need %d %s in the fleet hold to fire (have %d)" % [ + _ammo_cost(), GameState.resource_name(resource_type).to_lower(), + GameState.fleet_resource(resource_type), + ] + + +func _say(message: String) -> void: + status.text = message + status_message_until = Time.get_ticks_msec() + 3000 + + +func _aim_origin() -> Vector2: + return fleet.get_centroid() + + +# Subclass hooks. +func _button_path() -> NodePath: return NodePath() +func _status_path() -> NodePath: return NodePath() +func _component_id() -> String: return "" +func _ammo_resource() -> String: return "" +func _ammo_cost() -> int: return 0 +func _aim_padding() -> float: return 0.0 +func _has_effect_visuals() -> bool: return false +func _advance_effects(_delta: float) -> void: pass +func _consume_shot(_component: Node) -> bool: return false +func _perform_shot(_ship: Node2D, _component: Node) -> void: pass +func _calculate_aim_target(_drag: Vector2, origin: Vector2) -> Vector2: return origin diff --git a/AimedFleetAbilityController.gd.uid b/AimedFleetAbilityController.gd.uid new file mode 100644 index 0000000..a455e17 --- /dev/null +++ b/AimedFleetAbilityController.gd.uid @@ -0,0 +1 @@ +uid://dbahjmvwru7bp diff --git a/BuildController.gd b/BuildController.gd index be1adf7..6a45e1e 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -738,7 +738,7 @@ func _refresh_skill_menu(id: String = "") -> void: var node: Dictionary = GameState.skill_node(id) if node.is_empty(): return - var points_needed: int = int(node.get("research_points", 0)) + var points_needed: int = GameState.skill_research_point_cost(id) skill_detail.text = "%s\n%s" % [node.name, node.desc] if points_needed > 0: skill_detail.text += "\nResearch points: %d/%d" % [GameState.research_points, points_needed] @@ -1043,7 +1043,7 @@ func _transfer_place_name() -> String: var b: Dictionary = GameState.find_building(transfer_building_id) if b.is_empty(): return "this building" - if b.type == GameState.BUILDING_MOTHERSHIP: + if GameState.building_has_capability(b, "bank"): # Worth naming as the bank rather than as a structure: this is the # one building where unloading makes the ore spendable everywhere. return "the bank" @@ -1056,7 +1056,7 @@ func _refresh_transfer() -> void: return var building: Dictionary = GameState.find_building(transfer_building_id) var heading: String = "This building" - if not building.is_empty() and building.type == GameState.BUILDING_MOTHERSHIP: + if GameState.building_has_capability(building, "bank"): heading = "Mothership bank" transfer_list.set_destination(transfer_building_id, heading) diff --git a/BuildingDefinition.gd b/BuildingDefinition.gd index fdc990e..7b3f1fd 100644 --- a/BuildingDefinition.gd +++ b/BuildingDefinition.gd @@ -9,6 +9,7 @@ extends Resource @export var menu_icon: int @export var visual_scene: PackedScene @export var capabilities: PackedStringArray = [] +@export var logistics: Resource @export var map_color: Color = Color.WHITE @export var map_sides: int = 4 @export var map_radius: float = 7.0 diff --git a/BuildingLogisticsProfile.gd b/BuildingLogisticsProfile.gd new file mode 100644 index 0000000..fdd6706 --- /dev/null +++ b/BuildingLogisticsProfile.gd @@ -0,0 +1,13 @@ +class_name BuildingLogisticsProfile +extends Resource + +# Optional logistics and conversion behavior composed into a building +# definition. GameState consumes this data generically, so adding another +# converter does not require another resource-type branch in transport code. + +@export var input_buffers: Dictionary = {} +@export var collected_outputs: PackedStringArray = [] +@export var conversion_input: String = "" +@export var conversion_output: String = "" +@export var conversion_rate: float = 0.0 +@export var conversion_yield: int = 1 diff --git a/BuildingLogisticsProfile.gd.uid b/BuildingLogisticsProfile.gd.uid new file mode 100644 index 0000000..b6480eb --- /dev/null +++ b/BuildingLogisticsProfile.gd.uid @@ -0,0 +1 @@ +uid://cydp18abylrgd diff --git a/EnemyBigLaserBeam.gd.uid b/EnemyBigLaserBeam.gd.uid new file mode 100644 index 0000000..7e2bd8a --- /dev/null +++ b/EnemyBigLaserBeam.gd.uid @@ -0,0 +1 @@ +uid://coopf1u1lnb8b diff --git a/EnemyBigLaserComponent.gd.uid b/EnemyBigLaserComponent.gd.uid new file mode 100644 index 0000000..31a1aba --- /dev/null +++ b/EnemyBigLaserComponent.gd.uid @@ -0,0 +1 @@ +uid://6oj7ia3em4x0 diff --git a/EnemySpawnerComponent.gd.uid b/EnemySpawnerComponent.gd.uid new file mode 100644 index 0000000..043b98d --- /dev/null +++ b/EnemySpawnerComponent.gd.uid @@ -0,0 +1 @@ +uid://eqkp0coeoh4d diff --git a/EnergyResourceGlyph.gd.uid b/EnergyResourceGlyph.gd.uid new file mode 100644 index 0000000..c8fb069 --- /dev/null +++ b/EnergyResourceGlyph.gd.uid @@ -0,0 +1 @@ +uid://b3b64pvw4y53x diff --git a/Fleet.gd b/Fleet.gd index d51740f..ef17702 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -80,7 +80,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO, ship.destroyed.connect(_on_ship_destroyed.bind(ship)) fleet_target = center - GameState.sync_fleet_cargo_capacity(vessel_types()) + _sync_composition_state() func vessel_types() -> PackedStringArray: @@ -91,6 +91,15 @@ func vessel_types() -> PackedStringArray: return result +func _sync_composition_state() -> void: + GameState.sync_fleet_cargo_capacity(vessel_types()) + # Composition changes are authoritative; scene teardown is not. Keeping + # this out of SpeedFader prevents an outgoing scene's empty fleet from + # resetting a boost while the same vessels are being restored on a surface. + if not has_capability("distorder"): + GameState.set_fleet_speed_multiplier(1.0) + + func has_vessel_type(vessel_type: String) -> bool: for ship in ships: if is_instance_valid(ship) and String(ship.vessel_type) == vessel_type: @@ -139,7 +148,7 @@ func add_ship(vessel_type: String = GameState.VESSEL_INTERCEPTOR) -> bool: if ship.has_signal("destroyed"): ship.destroyed.connect(_on_ship_destroyed.bind(ship)) ship.set_move_target(fleet_target + offset) - GameState.sync_fleet_cargo_capacity(vessel_types()) + _sync_composition_state() return true @@ -157,7 +166,7 @@ func store_ship(index: int) -> String: formation_offsets.remove_at(index) ship.queue_free() apply_formation() - GameState.sync_fleet_cargo_capacity(vessel_types()) + _sync_composition_state() return vessel_type @@ -283,7 +292,7 @@ func _on_ship_destroyed(ship: Node) -> void: if idx != -1: ships.remove_at(idx) formation_offsets.remove_at(idx) - GameState.sync_fleet_cargo_capacity(vessel_types()) + _sync_composition_state() func get_centroid() -> Vector2: diff --git a/GameState.gd b/GameState.gd index 8397b7e..35c4bbc 100644 --- a/GameState.gd +++ b/GameState.gd @@ -387,7 +387,11 @@ func prerequisite_met(id: String) -> bool: func can_unlock(id: String) -> bool: var node: Dictionary = skill_node(id) return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(node.cost)) \ - and research_points >= int(node.get("research_points", 0)) + and research_points >= skill_research_point_cost(id) + + +func skill_research_point_cost(id: String) -> int: + return int(skill_node(id).get("research_points", 0)) func unlock(id: String) -> bool: @@ -396,7 +400,7 @@ func unlock(id: String) -> bool: var node: Dictionary = skill_node(id) if not spend(int(node.cost)): return false - var points_needed: int = int(node.get("research_points", 0)) + var points_needed: int = skill_research_point_cost(id) if points_needed > 0: research_points -= points_needed research_points_changed.emit(research_points) @@ -446,6 +450,8 @@ func reset_new_game() -> void: planet_id = "" planet_definition_id = "rocky" fleet_speed_multiplier = 1.0 + _distorder_drain_progress = 0.0 + boost_depleted_notice = false research_points = 0 explored_chunks.clear() bank_inventory = INVENTORY.new({RESOURCE_ORE: 500}) @@ -520,6 +526,13 @@ func building_has_capability(building: Dictionary, capability: String) -> bool: return definition != null and capability in definition.capabilities +func building_logistics_profile(building: Dictionary) -> Resource: + if building.is_empty(): + return null + var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(building.type)) + return null if definition == null else definition.logistics + + # --- vessel types and their upgrades (#5) --------------------------------- # Combat vessels can join the player fleet. Transporter metadata lives in the @@ -1044,19 +1057,20 @@ func has_building_of_type(type: String) -> bool: func remove_buildings_of_type(type: String) -> int: var kept: Array = [] - var removed_ids: Array = [] + var removed_hub_ids: Array = [] var removed: int = 0 for b in buildings: if b.type == type: removed += 1 - removed_ids.append(int(b.id)) + if building_has_capability(b, "transport_hub"): + removed_hub_ids.append(int(b.id)) else: kept.append(b) buildings = kept - if type == BUILDING_TRANSPORT_HUB and not removed_ids.is_empty(): + if not removed_hub_ids.is_empty(): var kept_transporters: Array = [] for transporter in transporters: - if int(transporter.get("hub_id", 0)) not in removed_ids: + if int(transporter.get("hub_id", 0)) not in removed_hub_ids: kept_transporters.append(transporter) transporters = kept_transporters return removed @@ -1186,44 +1200,50 @@ func _extractor_inventory(extractor: Dictionary) -> RefCounted: return inventory -# --- gas burners (#54) ----------------------------------------------------- -# -# Converts gas into energy at the building's own site, simulated here -# alongside extractor mining and production so it keeps running whether or -# not the player is nearby. Deliberately withdraws only from the building's -# OWN inventory, never the bank - a burner with no local gas simply stalls, -# exactly like a lab or crafter waiting on materials, rather than conjuring -# energy for free. +# --- building logistics and resource conversion --------------------------- -const GAS_BURN_RATE: float = 1.0 # gas/second -const ENERGY_PER_GAS: int = 2 -# Local gas reserve a burner tries to keep topped up. Transporters treat -# falling short of this the same way they treat a queued production/research -# order (see _dispatch_transporters()'s gas_burner_gas_deficit() use). -const GAS_BURNER_BUFFER: int = 40 - - -func gas_burner_gas_deficit(id: int) -> int: - var b: Dictionary = find_building(id) - if not building_has_capability(b, "gas_burner"): +func building_logistics_deficit(id: int, resource_type: String) -> int: + var building: Dictionary = find_building(id) + var profile: Resource = building_logistics_profile(building) + if profile == null: return 0 - return maxi(0, GAS_BURNER_BUFFER - building_resource(id, RESOURCE_GAS)) + var target: int = int(profile.input_buffers.get(resource_type, 0)) + return maxi(0, target - building_resource(id, resource_type)) -func _advance_gas_burners(delta: float) -> void: - for b in buildings: - if not building_has_capability(b, "gas_burner"): +func building_requested_resource(id: int, resource_type: String) -> int: + return maxi(outstanding_resource_cost(id, resource_type), + building_logistics_deficit(id, resource_type)) + + +func _collectable_building_resource(building: Dictionary, minimum_amount: int) -> String: + var profile: Resource = building_logistics_profile(building) + if profile == null: + return "" + for resource_type in profile.collected_outputs: + if building_resource(int(building.id), resource_type) >= minimum_amount: + return resource_type + return "" + + +func _advance_resource_converters(delta: float) -> void: + for building in buildings: + var profile: Resource = building_logistics_profile(building) + if profile == null or profile.conversion_input == "" \ + or profile.conversion_output == "" or profile.conversion_rate <= 0.0 \ + or profile.conversion_yield <= 0: continue - var inventory: RefCounted = _inventory_for_record(b) - if inventory.amount(RESOURCE_GAS) <= 0: - continue # stalled - no local gas to burn - var progress: float = float(b.get("gas_burn_progress", 0.0)) + delta * GAS_BURN_RATE - var whole: int = mini(int(floor(progress)), inventory.amount(RESOURCE_GAS)) + var inventory: RefCounted = _inventory_for_record(building) + if inventory.amount(profile.conversion_input) <= 0: + continue + var progress: float = float(building.get("conversion_progress", 0.0)) \ + + delta * profile.conversion_rate + var whole: int = mini(int(floor(progress)), inventory.amount(profile.conversion_input)) if whole > 0: - inventory.withdraw(RESOURCE_GAS, whole) - inventory.deposit(RESOURCE_ENERGY, whole * ENERGY_PER_GAS) + inventory.withdraw(profile.conversion_input, whole) + inventory.deposit(profile.conversion_output, whole * profile.conversion_yield) progress -= whole - b.gas_burn_progress = progress + building.conversion_progress = progress func _advance_distorder_drain(delta: float) -> void: @@ -1341,7 +1361,7 @@ func _process(delta: float) -> void: _advance_production(delta) _advance_lab_research(delta) _advance_vessel_deliveries(delta) - _advance_gas_burners(delta) + _advance_resource_converters(delta) _advance_distorder_drain(delta) for e in extractors: e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE @@ -1500,13 +1520,7 @@ func _dispatch_transporters() -> void: for b in buildings: var needed_resource := "" for resource_type in resource_types(): - # A gas burner's ongoing gas deficit is folded in alongside - # queued production/research costs (#54) - both are just "this - # building wants more of resource_type", so the one dispatch - # path already below serves both without any new transporter - # state. - var need: int = maxi(outstanding_resource_cost(int(b.id), resource_type), - gas_burner_gas_deficit(int(b.id)) if resource_type == RESOURCE_GAS else 0) + var need: int = building_requested_resource(int(b.id), resource_type) if need > 0 and bank_inventory.amount(resource_type) > 0: needed_resource = resource_type break @@ -1559,14 +1573,11 @@ func _dispatch_transporters() -> void: _begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position, TRANSPORTER_TO_EXTRACTOR) - # Collecting a gas burner's energy output, last: queued deliveries and - # extractor collection both outrank it, same "supply what's needed - # before what's merely ready" ordering _dispatch_transporters() already - # holds for extractors over queued orders (f5c60f8). + # Collect building outputs last: queued deliveries and extractor collection + # both outrank resources that are merely ready for pickup. for b in buildings: - if not building_has_capability(b, "gas_burner"): - continue - if building_resource(int(b.id), RESOURCE_ENERGY) < load or _transporter_inbound_for_pickup(int(b.id)): + var pickup_resource: String = _collectable_building_resource(b, load) + if pickup_resource == "" or _transporter_inbound_for_pickup(int(b.id)): continue var chosen_pickup: Dictionary = {} var best_pickup_distance: float = INF @@ -1580,7 +1591,7 @@ func _dispatch_transporters() -> void: if chosen_pickup.is_empty(): return chosen_pickup.target_id = int(b.id) - chosen_pickup.resource_type = RESOURCE_ENERGY + chosen_pickup.resource_type = pickup_resource chosen_pickup.extractor_index = -1 _begin_leg(chosen_pickup, transporter_position(chosen_pickup), b.position, TRANSPORTER_TO_BUILDING_PICKUP) @@ -1610,8 +1621,7 @@ func _arrive(t: Dictionary) -> void: TRANSPORTER_TO_MOTHERSHIP: var target: Dictionary = find_building(int(t.target_id)) var resource_type: String = String(t.get("resource_type", RESOURCE_ORE)) - var want: int = maxi(outstanding_resource_cost(int(t.target_id), resource_type), - gas_burner_gas_deficit(int(t.target_id)) if resource_type == RESOURCE_GAS else 0) + var want: int = building_requested_resource(int(t.target_id), resource_type) var take: int = mini(mini(want, bank_inventory.amount(resource_type)), transporter_load_limit()) if take <= 0 or target.is_empty(): # Nothing to carry, or the building is gone. Go home empty. diff --git a/GasBombController.gd b/GasBombController.gd index 489bf49..43e784a 100644 --- a/GasBombController.gd +++ b/GasBombController.gd @@ -1,79 +1,64 @@ class_name GasBombController -extends Node2D +extends "res://AimedFleetAbilityController.gd" const BOMB_COLOR := Color(0.96, 0.22, 0.72) const TARGET_COLOR := Color(0.78, 0.32, 1.0) const MAX_RANGE := 950.0 const AOE_RADIUS := 180.0 const DRAG_RADIUS := 260.0 -const AIM_DEADZONE := 22.0 const PROJECTILE_SPEED := 720.0 const DAMAGE := 3 const COOLDOWN := 5.0 -# #60: ammunition from the fleet hold, not the bank - the fleet has to be -# carrying its own gas to fire. const GAS_PER_SHOT: int = 5 -@export var minimum_zoom: float = 0.4 -@onready var fleet: Node2D = get_node("../Fleet") -@onready var camera: Camera2D = get_node("../Camera2D") -@onready var button: IconButton = get_node("../UI/GasBombButton") -@onready var status: Label = get_node("../UI/GasBombStatus") - -var status_message_until: int = 0 - -var aiming := false -var aim_touch := -1 -var aim_start_screen := Vector2.ZERO -var aim_screen := Vector2.ZERO -var aim_target := Vector2.ZERO -var saved_zoom := 1.0 -var restoring_zoom := false var projectiles: Array = [] var explosions: Array = [] -var last_hit_count := 0 +var last_hit_count: int = 0 var projectile_active: bool: get: return not projectiles.is_empty() var explosion_time: float: get: - var longest := 0.0 + var longest: float = 0.0 for explosion in explosions: longest = maxf(longest, float(explosion.time)) return longest -func _ready() -> void: - button.visible = false - status.text = "" - queue_redraw() +func _button_path() -> NodePath: return "../UI/GasBombButton" +func _status_path() -> NodePath: return "../UI/GasBombStatus" +func _component_id() -> String: return "gas_bomber" +func _ammo_resource() -> String: return GameState.RESOURCE_GAS +func _ammo_cost() -> int: return GAS_PER_SHOT +func _aim_padding() -> float: return AOE_RADIUS +func _has_effect_visuals() -> bool: return not projectiles.is_empty() or not explosions.is_empty() -func _can_afford_shot() -> bool: - return GameState.fleet_resource(GameState.RESOURCE_GAS) >= GAS_PER_SHOT +func _calculate_aim_target(drag: Vector2, origin: Vector2) -> Vector2: + if drag.length() < 0.001: + return origin + var distance: float = minf(drag.length() / DRAG_RADIUS, 1.0) * MAX_RANGE + return origin + drag.normalized() * distance -func _say(message: String) -> void: - status.text = message - status_message_until = Time.get_ticks_msec() + 3000 +func _consume_shot(component: Node) -> bool: + return component.consume_shot(COOLDOWN) -func _process(delta: float) -> void: - var had_visuals := aiming or not projectiles.is_empty() or not explosions.is_empty() - button.visible = fleet.has_capability("gas_bomber") - button.enabled = _next_ready_bomber() != null and _can_afford_shot() - if not button.visible and aiming: - _cancel_aim() - if status.text != "" and Time.get_ticks_msec() >= status_message_until: - status.text = "" +func _perform_shot(ship: Node2D, _component: Node) -> void: + projectiles.append({ + "position": ship.global_position, + "target": aim_target, + "trail": PackedVector2Array([ship.global_position]), + }) - if restoring_zoom and not aiming: - var z := move_toward(camera.zoom.x, saved_zoom, maxf(saved_zoom, 0.1) * delta * 5.0) - camera.zoom = Vector2(z, z) - if is_equal_approx(z, saved_zoom): - restoring_zoom = false +func _next_ready_bomber() -> Node2D: + return _next_ready_ship() + + +func _advance_effects(delta: float) -> void: for i in range(projectiles.size() - 1, -1, -1): var projectile: Dictionary = projectiles[i] var distance: float = (projectile.position as Vector2).distance_to(projectile.target) @@ -82,8 +67,8 @@ func _process(delta: float) -> void: _explode(projectile.target) projectiles.remove_at(i) else: - projectile.position = (projectile.position as Vector2).move_toward(projectile.target, - PROJECTILE_SPEED * delta) + projectile.position = (projectile.position as Vector2).move_toward( + projectile.target, PROJECTILE_SPEED * delta) var trail: PackedVector2Array = projectile.trail trail.append(projectile.position) while trail.size() > 12: @@ -94,122 +79,6 @@ func _process(delta: float) -> void: explosion.time = maxf(float(explosion.time) - delta, 0.0) if float(explosion.time) <= 0.0: explosions.remove_at(i) - if aiming or not projectiles.is_empty() or not explosions.is_empty() or had_visuals: - queue_redraw() - - -func can_begin_aim() -> bool: - # Deliberately NOT button.enabled: an unaffordable shot still needs the - # press to reach here so it can explain itself (IconButton's own "greyed - # but still pressable" convention) rather than the touch silently - # falling through to whatever's behind it. A wholly unready fleet (no - # bomber at all) has nothing more useful to say than "greyed out". - return button.visible and not button._blocked_by_modal() \ - and not aiming and _next_ready_bomber() != null - - -func handle_touch(event: InputEventScreenTouch) -> bool: - if event.pressed: - if not can_begin_aim() or not Rect2(button.global_position, button.size).has_point(event.position): - return false - if not _can_afford_shot(): - _say("Need %d gas in the fleet hold to fire (have %d)" \ - % [GAS_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_GAS)]) - return true - aiming = true - aim_touch = event.index - aim_start_screen = event.position - aim_screen = event.position - saved_zoom = camera.zoom.x - restoring_zoom = false - button.pressed_down = true - button.queue_redraw() - _update_aim() - return true - if event.index != aim_touch: - return false - var should_fire := aim_start_screen.distance_to(aim_screen) >= AIM_DEADZONE - if should_fire: - _fire() - else: - _cancel_aim() - return true - - -func handle_drag(event: InputEventScreenDrag) -> bool: - if not aiming or event.index != aim_touch: - return false - aim_screen = event.position - _update_aim() - return true - - -func _update_aim() -> void: - var drag := aim_screen - aim_start_screen - var origin: Vector2 = fleet.get_centroid() - if drag.length() < 0.001: - aim_target = origin - else: - var distance := minf(drag.length() / DRAG_RADIUS, 1.0) * MAX_RANGE - aim_target = origin + drag.normalized() * distance - _update_aim_zoom(origin, aim_target) - queue_redraw() - - -func _update_aim_zoom(origin: Vector2, target: Vector2) -> void: - var displacement := target - origin - var half_screen := get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0) - var fit_x := saved_zoom if absf(displacement.x) < 1.0 else half_screen.x / (absf(displacement.x) + AOE_RADIUS) - var fit_y := saved_zoom if absf(displacement.y) < 1.0 else half_screen.y / (absf(displacement.y) + AOE_RADIUS) - var desired := clampf(minf(saved_zoom, minf(fit_x, fit_y)), minimum_zoom, saved_zoom) - camera.zoom = Vector2(desired, desired) - - -func _finish_aim() -> void: - aiming = false - aim_touch = -1 - button.pressed_down = false - button.queue_redraw() - restoring_zoom = true - - -func _cancel_aim() -> void: - _finish_aim() - queue_redraw() - - -func _fire() -> void: - var bomber: Node2D = _next_ready_bomber() - if bomber == null: - _cancel_aim() - return - if not _can_afford_shot(): - _say("Need %d gas in the fleet hold to fire (have %d)" \ - % [GAS_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_GAS)]) - _cancel_aim() - return - var component: Node = bomber.get_component("gas_bomber") - if not component.consume_shot(COOLDOWN): - _cancel_aim() - return - GameState.withdraw_fleet_resource(GameState.RESOURCE_GAS, GAS_PER_SHOT) - projectiles.append({ - "position": bomber.global_position, - "target": aim_target, - "trail": PackedVector2Array([bomber.global_position]), - }) - _finish_aim() - button.enabled = _next_ready_bomber() != null and _can_afford_shot() - - -func _next_ready_bomber() -> Node2D: - for ship in fleet.ships: - if not is_instance_valid(ship): - continue - var component: Node = ship.get_component("gas_bomber") - if component != null and component.is_ready(): - return ship - return null func _explode(at: Vector2) -> void: @@ -224,28 +93,29 @@ func _explode(at: Vector2) -> void: func _draw() -> void: if aiming: - var origin: Vector2 = fleet.get_centroid() - NeonDraw.draw_glowing_line(self, origin, aim_target, TARGET_COLOR, 0.7, 0.8) + NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.8) _draw_ring(aim_target, AOE_RADIUS, TARGET_COLOR, 0.8) _draw_ring(aim_target, 18.0, BOMB_COLOR, 1.0) for projectile in projectiles: var trail: PackedVector2Array = projectile.trail if trail.size() >= 2: for i in range(1, trail.size()): - var alpha := float(i) / trail.size() - draw_line(trail[i - 1], trail[i], - Color(BOMB_COLOR, alpha * 0.65), 4.0 + alpha * 4.0, true) + var alpha: float = float(i) / trail.size() + draw_line(trail[i - 1], trail[i], Color(BOMB_COLOR, alpha * 0.65), + 4.0 + alpha * 4.0, true) draw_circle(projectile.position, 12.0, Color(BOMB_COLOR, 0.35)) NeonDraw.draw_glowing_circle(self, projectile.position, 7.0, BOMB_COLOR, 1.0) for explosion in explosions: - var progress := 1.0 - float(explosion.time) / 0.8 - var radius := lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0)) + var progress: float = 1.0 - float(explosion.time) / 0.8 + var radius: float = lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0)) draw_circle(explosion.position, radius, Color(BOMB_COLOR, (1.0 - progress) * 0.16)) _draw_ring(explosion.position, radius, BOMB_COLOR, 1.0 - progress) for i in range(7): - var angle := TAU * float(i) / 7.0 + progress - var cloud_at: Vector2 = explosion.position + Vector2(cos(angle), sin(angle)) * radius * 0.55 - draw_circle(cloud_at, 18.0 + 14.0 * progress, Color(BOMB_COLOR, (1.0 - progress) * 0.22)) + var angle: float = TAU * float(i) / 7.0 + progress + var cloud_at: Vector2 = explosion.position \ + + Vector2(cos(angle), sin(angle)) * radius * 0.55 + draw_circle(cloud_at, 18.0 + 14.0 * progress, + Color(BOMB_COLOR, (1.0 - progress) * 0.22)) func _draw_ring(center: Vector2, radius: float, color: Color, alpha: float) -> void: diff --git a/GasBurnerBuildingDefinition.tres b/GasBurnerBuildingDefinition.tres index 09d2240..a0d3058 100644 --- a/GasBurnerBuildingDefinition.tres +++ b/GasBurnerBuildingDefinition.tres @@ -1,6 +1,19 @@ -[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3] +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=4 format=3] [ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"] [ext_resource type="PackedScene" path="res://GasBurnerBuildingVisual.tscn" id="2"] +[ext_resource type="Script" path="res://BuildingLogisticsProfile.gd" id="3"] + +[sub_resource type="Resource" id="Resource_logistics"] +script = ExtResource("3") +input_buffers = { +"gas": 40 +} +collected_outputs = PackedStringArray("energy") +conversion_input = "gas" +conversion_output = "energy" +conversion_rate = 1.0 +conversion_yield = 2 + [resource] script = ExtResource("1") definition_id = "gas_burner" @@ -11,6 +24,7 @@ action_icon = 33 menu_icon = 33 visual_scene = ExtResource("2") capabilities = PackedStringArray("gas_burner") +logistics = SubResource("Resource_logistics") map_color = Color(0.95, 0.35, 0.15, 1) map_sides = 5 map_radius = 7.0 diff --git a/GasBurnerBuildingVisual.gd.uid b/GasBurnerBuildingVisual.gd.uid new file mode 100644 index 0000000..20dc35e --- /dev/null +++ b/GasBurnerBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://c5a74rb5s4q21 diff --git a/LaserController.gd b/LaserController.gd index 1c0a965..b9c9cfc 100644 --- a/LaserController.gd +++ b/LaserController.gd @@ -1,195 +1,60 @@ class_name LaserController -extends Node2D - -# Same press-drag-release aiming plumbing as GasBombController, adapted for -# an instant beam rather than a lobbed, timed projectile: there is no travel -# time or explosion delay - the beam strikes the instant the shot fires, and -# only a brief flash lingers for feedback. Damage/range/cooldown are read -# straight off the firing ship's own VesselLaserComponent (already resolved -# from vessel_stat/upgrades) rather than hardcoded here, unlike the bomber's -# AOE_RADIUS/DAMAGE/COOLDOWN. -# -# Reused as-is by both Main.tscn and PlanetSurface.tscn (same relative -# ../Fleet, ../Camera2D, ../UI/LaserButton paths as GasBombController), so -# it works identically in space and on a landed surface with no branching. +extends "res://AimedFleetAbilityController.gd" const BEAM_COLOR := Color(0.3, 0.9, 1.0) const TARGET_COLOR := Color(0.6, 0.95, 1.0) const BEAM_HALF_WIDTH := 22.0 -const DRAG_DEADZONE := 22.0 const FLASH_DURATION := 0.22 -# #61: ammunition from the fleet hold, mirroring #60's gas bomber exactly. const ENERGY_PER_SHOT: int = 8 -@export var minimum_zoom: float = 0.4 -@onready var fleet: Node2D = get_node("../Fleet") -@onready var camera: Camera2D = get_node("../Camera2D") -@onready var button: IconButton = get_node("../UI/LaserButton") -@onready var status: Label = get_node("../UI/LaserStatus") - -var status_message_until: int = 0 - -var aiming := false -var aim_touch := -1 -var aim_start_screen := Vector2.ZERO -var aim_screen := Vector2.ZERO -var aim_target := Vector2.ZERO -var saved_zoom := 1.0 -var restoring_zoom := false var beams: Array = [] -var last_hit_count := 0 +var last_hit_count: int = 0 -func _ready() -> void: - button.visible = false - status.text = "" - queue_redraw() +func _button_path() -> NodePath: return "../UI/LaserButton" +func _status_path() -> NodePath: return "../UI/LaserStatus" +func _component_id() -> String: return "laser" +func _ammo_resource() -> String: return GameState.RESOURCE_ENERGY +func _ammo_cost() -> int: return ENERGY_PER_SHOT +func _aim_padding() -> float: return BEAM_HALF_WIDTH +func _has_effect_visuals() -> bool: return not beams.is_empty() -func _can_afford_shot() -> bool: - return GameState.fleet_resource(GameState.RESOURCE_ENERGY) >= ENERGY_PER_SHOT +func _aim_max_range() -> float: + var ship: Node2D = _next_ready_ship() + if ship == null: + return 950.0 + var component: Node = ship.get_component(_component_id()) + return component.beam_range if component != null else 950.0 -func _say(message: String) -> void: - status.text = message - status_message_until = Time.get_ticks_msec() + 3000 +func _calculate_aim_target(drag: Vector2, origin: Vector2) -> Vector2: + # A beam always reaches full range; dragging chooses its direction only. + var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP + return origin + direction * _aim_max_range() -func _process(delta: float) -> void: - var had_visuals := aiming or not beams.is_empty() - button.visible = fleet.has_capability("laser") - button.enabled = _next_ready_laser_ship() != null and _can_afford_shot() - if not button.visible and aiming: - _cancel_aim() - if status.text != "" and Time.get_ticks_msec() >= status_message_until: - status.text = "" +func _consume_shot(component: Node) -> bool: + return component.consume_shot() - if restoring_zoom and not aiming: - var z := move_toward(camera.zoom.x, saved_zoom, maxf(saved_zoom, 0.1) * delta * 5.0) - camera.zoom = Vector2(z, z) - if is_equal_approx(z, saved_zoom): - restoring_zoom = false +func _perform_shot(ship: Node2D, component: Node) -> void: + var direction: Vector2 = aim_target - _aim_origin() + direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP + var beam_to: Vector2 = ship.global_position + direction * component.beam_range + _strike(ship.global_position, beam_to, direction, int(component.damage)) + + +func _next_ready_laser_ship() -> Node2D: + return _next_ready_ship() + + +func _advance_effects(delta: float) -> void: for i in range(beams.size() - 1, -1, -1): var beam: Dictionary = beams[i] beam.time = maxf(float(beam.time) - delta, 0.0) if float(beam.time) <= 0.0: beams.remove_at(i) - if aiming or not beams.is_empty() or had_visuals: - queue_redraw() - - -func can_begin_aim() -> bool: - # Deliberately NOT button.enabled: an unaffordable shot still needs the - # press to reach here so it can explain itself (#60's identical - # reasoning on the gas bomber). A wholly unready fleet (no laser ship at - # all) has nothing more useful to say than "greyed out". - return button.visible and not button._blocked_by_modal() \ - and not aiming and _next_ready_laser_ship() != null - - -func handle_touch(event: InputEventScreenTouch) -> bool: - if event.pressed: - if not can_begin_aim() or not Rect2(button.global_position, button.size).has_point(event.position): - return false - if not _can_afford_shot(): - _say("Need %d energy in the fleet hold to fire (have %d)" \ - % [ENERGY_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_ENERGY)]) - return true - aiming = true - aim_touch = event.index - aim_start_screen = event.position - aim_screen = event.position - saved_zoom = camera.zoom.x - restoring_zoom = false - button.pressed_down = true - button.queue_redraw() - _update_aim() - return true - if event.index != aim_touch: - return false - var should_fire := aim_start_screen.distance_to(aim_screen) >= DRAG_DEADZONE - if should_fire: - _fire() - else: - _cancel_aim() - return true - - -func handle_drag(event: InputEventScreenDrag) -> bool: - if not aiming or event.index != aim_touch: - return false - aim_screen = event.position - _update_aim() - return true - - -func _aim_origin() -> Vector2: - return fleet.get_centroid() - - -func _aim_max_range() -> float: - var ship: Node2D = _next_ready_laser_ship() - if ship == null: - return 950.0 - var component: Node = ship.get_component("laser") - return component.beam_range if component != null else 950.0 - - -func _update_aim() -> void: - # A beam always reaches its full range - only the DIRECTION is aimed, - # unlike the bomb's drag-controlled distance. - var drag := aim_screen - aim_start_screen - var origin: Vector2 = _aim_origin() - var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP - aim_target = origin + direction * _aim_max_range() - _update_aim_zoom(origin, aim_target) - queue_redraw() - - -func _update_aim_zoom(origin: Vector2, target: Vector2) -> void: - var displacement := target - origin - var half_screen := get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0) - var fit_x := saved_zoom if absf(displacement.x) < 1.0 else half_screen.x / (absf(displacement.x) + BEAM_HALF_WIDTH) - var fit_y := saved_zoom if absf(displacement.y) < 1.0 else half_screen.y / (absf(displacement.y) + BEAM_HALF_WIDTH) - var desired := clampf(minf(saved_zoom, minf(fit_x, fit_y)), minimum_zoom, saved_zoom) - camera.zoom = Vector2(desired, desired) - - -func _finish_aim() -> void: - aiming = false - aim_touch = -1 - button.pressed_down = false - button.queue_redraw() - restoring_zoom = true - - -func _cancel_aim() -> void: - _finish_aim() - queue_redraw() - - -func _fire() -> void: - var shooter: Node2D = _next_ready_laser_ship() - if shooter == null: - _cancel_aim() - return - if not _can_afford_shot(): - _say("Need %d energy in the fleet hold to fire (have %d)" \ - % [ENERGY_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_ENERGY)]) - _cancel_aim() - return - var component: Node = shooter.get_component("laser") - if not component.consume_shot(): - _cancel_aim() - return - GameState.withdraw_fleet_resource(GameState.RESOURCE_ENERGY, ENERGY_PER_SHOT) - var direction: Vector2 = (aim_target - _aim_origin()) - direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP - var beam_to: Vector2 = shooter.global_position + direction * component.beam_range - _strike(shooter.global_position, beam_to, direction, int(component.damage)) - _finish_aim() - button.enabled = _next_ready_laser_ship() != null and _can_afford_shot() func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> void: @@ -209,20 +74,12 @@ func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> voi last_hit_count += 1 -func _next_ready_laser_ship() -> Node2D: - for ship in fleet.ships: - if not is_instance_valid(ship): - continue - var component: Node = ship.get_component("laser") - if component != null and component.is_ready(): - return ship - return null - - func _draw() -> void: if aiming: - NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.6) + NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, + TARGET_COLOR, 0.7, 0.6) for beam in beams: var progress: float = 1.0 - float(beam.time) / FLASH_DURATION var alpha: float = 1.0 - progress - NeonDraw.draw_glowing_line(self, beam.from, beam.to, BEAM_COLOR, alpha, 1.2 - progress * 0.6) + NeonDraw.draw_glowing_line(self, beam.from, beam.to, + BEAM_COLOR, alpha, 1.2 - progress * 0.6) diff --git a/LaserController.gd.uid b/LaserController.gd.uid new file mode 100644 index 0000000..303a679 --- /dev/null +++ b/LaserController.gd.uid @@ -0,0 +1 @@ +uid://cdqg3isy1lbuf diff --git a/ResearchLabPlanetVisual.gd.uid b/ResearchLabPlanetVisual.gd.uid new file mode 100644 index 0000000..a16d73f --- /dev/null +++ b/ResearchLabPlanetVisual.gd.uid @@ -0,0 +1 @@ +uid://c6nasspc8ewwj diff --git a/SpeedFader.gd b/SpeedFader.gd index a70bd68..46edf9e 100644 --- a/SpeedFader.gd +++ b/SpeedFader.gd @@ -35,12 +35,10 @@ func _process(_delta: float) -> void: if has_distorder != visible: visible = has_distorder if not has_distorder: - # A fleet must never be left permanently boosted by a vessel it no - # longer has - reset the instant the capability disappears, not just - # hide the control on top of a stale multiplier. + # Fleet owns persistent composition state. This outgoing UI node can + # briefly observe an empty fleet during a scene swap, so it must only + # clear its own interaction state here. active_touch_index = -1 - if GameState.fleet_speed_multiplier != 1.0: - GameState.fleet_speed_multiplier = 1.0 knob_t = 0.0 return knob_t = (GameState.fleet_speed_multiplier - 1.0) \ diff --git a/SpeedFader.gd.uid b/SpeedFader.gd.uid new file mode 100644 index 0000000..3dd9dd3 --- /dev/null +++ b/SpeedFader.gd.uid @@ -0,0 +1 @@ +uid://m16rgs2ed7w1 diff --git a/VesselCooldownFillComponent.gd b/VesselCooldownFillComponent.gd new file mode 100644 index 0000000..8c009c1 --- /dev/null +++ b/VesselCooldownFillComponent.gd @@ -0,0 +1,84 @@ +class_name VesselCooldownFillComponent +extends Node2D + +# Shared per-vessel cooldown state and hull-fill feedback for abilities that +# grant one independently recharging shot per ship. + +var cooldown_duration: float = 1.0 +var cooldown_left: float = 0.0 + + +func setup() -> void: + show_behind_parent = true + queue_redraw() + + +func is_ready() -> bool: + return cooldown_left <= 0.0 + + +func _begin_cooldown(duration: float) -> bool: + if not is_ready(): + return false + cooldown_duration = maxf(duration, 0.001) + cooldown_left = cooldown_duration + queue_redraw() + return true + + +func tick(delta: float) -> void: + if cooldown_left <= 0.0: + return + cooldown_left = maxf(cooldown_left - delta, 0.0) + queue_redraw() + + +func cooldown_progress() -> float: + return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001) + + +func _filled_hull(points: PackedVector2Array, progress: float) -> PackedVector2Array: + if points.size() < 3 or progress <= 0.001: + return PackedVector2Array() + if progress >= 0.999: + return points.duplicate() + var top: float = INF + var bottom: float = -INF + for point in points: + top = minf(top, point.y) + bottom = maxf(bottom, point.y) + var fill_y: float = lerpf(bottom, top, clampf(progress, 0.0, 1.0)) + var clipped := PackedVector2Array() + var previous: Vector2 = points[-1] + var previous_inside: bool = previous.y >= fill_y + for current in points: + var current_inside: bool = current.y >= fill_y + if current_inside != previous_inside: + var amount: float = (fill_y - previous.y) / (current.y - previous.y) + clipped.append(previous.lerp(current, amount)) + if current_inside: + clipped.append(current) + previous = current + previous_inside = current_inside + return clipped + + +func _draw() -> void: + var ship: Node = get_parent() + if ship == null or ship.definition == null: + return + var hull: PackedVector2Array = ship.definition.hull_points + var ready_color: Color = _ready_color() + if is_ready(): + draw_colored_polygon(hull, Color(ready_color, 0.42)) + NeonDraw.draw_glowing_polygon(self, hull, ready_color, 0.75, + ship.core_color, 0.75) + return + draw_colored_polygon(hull, Color(_cooling_color(), 0.58)) + var filled: PackedVector2Array = _filled_hull(hull, cooldown_progress()) + if filled.size() >= 3: + draw_colored_polygon(filled, Color(ready_color, 0.68)) + + +func _ready_color() -> Color: return Color.WHITE +func _cooling_color() -> Color: return Color(0.2, 0.2, 0.2) diff --git a/VesselCooldownFillComponent.gd.uid b/VesselCooldownFillComponent.gd.uid new file mode 100644 index 0000000..2897948 --- /dev/null +++ b/VesselCooldownFillComponent.gd.uid @@ -0,0 +1 @@ +uid://drsagaj2wr0ni diff --git a/VesselDistorderComponent.gd.uid b/VesselDistorderComponent.gd.uid new file mode 100644 index 0000000..b9a9147 --- /dev/null +++ b/VesselDistorderComponent.gd.uid @@ -0,0 +1 @@ +uid://c2una2fh0f06b diff --git a/VesselGasBomberComponent.gd b/VesselGasBomberComponent.gd index dc44815..a109188 100644 --- a/VesselGasBomberComponent.gd +++ b/VesselGasBomberComponent.gd @@ -1,84 +1,19 @@ class_name VesselGasBomberComponent -extends Node2D +extends "res://VesselCooldownFillComponent.gd" var capability_id := "gas_bomber" -var cooldown_duration := 5.0 -var cooldown_left := 0.0 const READY_COLOR := Color(1.0, 0.25, 0.78) const COOLING_COLOR := Color(0.38, 0.16, 0.32) -func setup() -> void: - # Keep the ship's normal neon outline above the charge fill. - show_behind_parent = true - queue_redraw() - - -func is_ready() -> bool: - return cooldown_left <= 0.0 +func _init() -> void: + cooldown_duration = 5.0 func consume_shot(duration: float) -> bool: - if not is_ready(): - return false - cooldown_duration = maxf(duration, 0.001) - cooldown_left = cooldown_duration - queue_redraw() - return true + return _begin_cooldown(duration) -func tick(delta: float) -> void: - if cooldown_left <= 0.0: - return - cooldown_left = maxf(cooldown_left - delta, 0.0) - queue_redraw() - - -func cooldown_progress() -> float: - return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001) - - -func _filled_hull(points: PackedVector2Array, progress: float) -> PackedVector2Array: - if points.size() < 3 or progress <= 0.001: - return PackedVector2Array() - if progress >= 0.999: - return points.duplicate() - var top := INF - var bottom := -INF - for point in points: - top = minf(top, point.y) - bottom = maxf(bottom, point.y) - var fill_y: float = lerpf(bottom, top, clampf(progress, 0.0, 1.0)) - var clipped := PackedVector2Array() - var previous: Vector2 = points[-1] - var previous_inside := previous.y >= fill_y - for current in points: - var current_inside := current.y >= fill_y - if current_inside != previous_inside: - var amount: float = (fill_y - previous.y) / (current.y - previous.y) - clipped.append(previous.lerp(current, amount)) - if current_inside: - clipped.append(current) - previous = current - previous_inside = current_inside - return clipped - - -func _draw() -> void: - var parent_ship: Node = get_parent() - if parent_ship == null or parent_ship.definition == null: - return - var core: Color = parent_ship.core_color if parent_ship != null else Color.WHITE - var hull: PackedVector2Array = parent_ship.definition.hull_points - if is_ready(): - # A fully lit hull means this individual bomber owns an available shot. - draw_colored_polygon(hull, Color(READY_COLOR, 0.42)) - NeonDraw.draw_glowing_polygon(self, hull, READY_COLOR, 0.75, core, 0.75) - return - # Rearming is shown inside the bomber instead of as a shield-like ring: - # the muted silhouette fills from its engines toward its nose. - draw_colored_polygon(hull, Color(COOLING_COLOR, 0.58)) - var filled := _filled_hull(hull, cooldown_progress()) - if filled.size() >= 3: - draw_colored_polygon(filled, Color(READY_COLOR, 0.68)) +func _ready_color() -> Color: return READY_COLOR +func _cooling_color() -> Color: return COOLING_COLOR diff --git a/VesselLaserComponent.gd b/VesselLaserComponent.gd index fb9379e..a004277 100644 --- a/VesselLaserComponent.gd +++ b/VesselLaserComponent.gd @@ -1,29 +1,16 @@ class_name VesselLaserComponent -extends Node2D - -# Same shape as VesselGasBomberComponent (per-ship cooldown, ready/cooling -# hull fill) - #58's own note: two laser vessels must give two shots, so -# this lives on the SHIP, not the fleet-wide LaserController. -# -# Unlike the gas bomber, damage/beam_range/cooldown are resolved from the -# vessel's own base_stats/upgrades (apply_stats(), the same pattern -# VesselWeaponComponent uses) rather than hardcoded in the controller, so -# ship-lab upgrades actually reach them. +extends "res://VesselCooldownFillComponent.gd" var capability_id := "laser" var damage: int = 2 var beam_range: float = 950.0 -var cooldown_duration: float = 4.0 -var cooldown_left: float = 0.0 const READY_COLOR := Color(0.3, 0.9, 1.0) const COOLING_COLOR := Color(0.08, 0.28, 0.34) -func setup() -> void: - # Keep the ship's normal neon outline above the charge fill. - show_behind_parent = true - queue_redraw() +func _init() -> void: + cooldown_duration = 4.0 func apply_stats() -> void: @@ -35,66 +22,9 @@ func apply_stats() -> void: queue_redraw() -func is_ready() -> bool: - return cooldown_left <= 0.0 - - func consume_shot() -> bool: - if not is_ready(): - return false - cooldown_left = maxf(cooldown_duration, 0.001) - queue_redraw() - return true + return _begin_cooldown(cooldown_duration) -func tick(delta: float) -> void: - if cooldown_left <= 0.0: - return - cooldown_left = maxf(cooldown_left - delta, 0.0) - queue_redraw() - - -func cooldown_progress() -> float: - return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001) - - -func _filled_hull(points: PackedVector2Array, progress: float) -> PackedVector2Array: - if points.size() < 3 or progress <= 0.001: - return PackedVector2Array() - if progress >= 0.999: - return points.duplicate() - var top := INF - var bottom := -INF - for point in points: - top = minf(top, point.y) - bottom = maxf(bottom, point.y) - var fill_y: float = lerpf(bottom, top, clampf(progress, 0.0, 1.0)) - var clipped := PackedVector2Array() - var previous: Vector2 = points[-1] - var previous_inside := previous.y >= fill_y - for current in points: - var current_inside := current.y >= fill_y - if current_inside != previous_inside: - var amount: float = (fill_y - previous.y) / (current.y - previous.y) - clipped.append(previous.lerp(current, amount)) - if current_inside: - clipped.append(current) - previous = current - previous_inside = current_inside - return clipped - - -func _draw() -> void: - var parent_ship: Node = get_parent() - if parent_ship == null or parent_ship.definition == null: - return - var core: Color = parent_ship.core_color if parent_ship != null else Color.WHITE - var hull: PackedVector2Array = parent_ship.definition.hull_points - if is_ready(): - draw_colored_polygon(hull, Color(READY_COLOR, 0.42)) - NeonDraw.draw_glowing_polygon(self, hull, READY_COLOR, 0.75, core, 0.75) - return - draw_colored_polygon(hull, Color(COOLING_COLOR, 0.58)) - var filled := _filled_hull(hull, cooldown_progress()) - if filled.size() >= 3: - draw_colored_polygon(filled, Color(READY_COLOR, 0.68)) +func _ready_color() -> Color: return READY_COLOR +func _cooling_color() -> Color: return COOLING_COLOR diff --git a/VesselLaserComponent.gd.uid b/VesselLaserComponent.gd.uid new file mode 100644 index 0000000..40baaf0 --- /dev/null +++ b/VesselLaserComponent.gd.uid @@ -0,0 +1 @@ +uid://bds5gccvd8dj8 diff --git a/gas_surface.gdshader.uid b/gas_surface.gdshader.uid new file mode 100644 index 0000000..d4176be --- /dev/null +++ b/gas_surface.gdshader.uid @@ -0,0 +1 @@ +uid://bhxcvtw6tcshl diff --git a/tests/_black_hole_space_preview.gd.uid b/tests/_black_hole_space_preview.gd.uid new file mode 100644 index 0000000..eb96047 --- /dev/null +++ b/tests/_black_hole_space_preview.gd.uid @@ -0,0 +1 @@ +uid://25xjfqi2qk7a diff --git a/tests/_carrier_crafter_preview.gd.uid b/tests/_carrier_crafter_preview.gd.uid new file mode 100644 index 0000000..b6cefcc --- /dev/null +++ b/tests/_carrier_crafter_preview.gd.uid @@ -0,0 +1 @@ +uid://f5a8wm4wr2i4 diff --git a/tests/_carrier_hangar_preview.gd.uid b/tests/_carrier_hangar_preview.gd.uid new file mode 100644 index 0000000..3f59a52 --- /dev/null +++ b/tests/_carrier_hangar_preview.gd.uid @@ -0,0 +1 @@ +uid://b5ah2jh0i16du diff --git a/tests/_fleet_cargo_limit_preview.gd.uid b/tests/_fleet_cargo_limit_preview.gd.uid new file mode 100644 index 0000000..82353c4 --- /dev/null +++ b/tests/_fleet_cargo_limit_preview.gd.uid @@ -0,0 +1 @@ +uid://brt82kn8lb3u5 diff --git a/tests/_gas_burner_build_menu_preview.gd.uid b/tests/_gas_burner_build_menu_preview.gd.uid new file mode 100644 index 0000000..965e80c --- /dev/null +++ b/tests/_gas_burner_build_menu_preview.gd.uid @@ -0,0 +1 @@ +uid://bs6hguk0tkc6c diff --git a/tests/_gas_burner_transfer_preview.gd.uid b/tests/_gas_burner_transfer_preview.gd.uid new file mode 100644 index 0000000..b665754 --- /dev/null +++ b/tests/_gas_burner_transfer_preview.gd.uid @@ -0,0 +1 @@ +uid://fd1m4excjdbn diff --git a/tests/_laser_aim_preview.gd.uid b/tests/_laser_aim_preview.gd.uid new file mode 100644 index 0000000..9064f70 --- /dev/null +++ b/tests/_laser_aim_preview.gd.uid @@ -0,0 +1 @@ +uid://c0q640rosb53o diff --git a/tests/_mothership_beam_preview.gd.uid b/tests/_mothership_beam_preview.gd.uid new file mode 100644 index 0000000..def6f77 --- /dev/null +++ b/tests/_mothership_beam_preview.gd.uid @@ -0,0 +1 @@ +uid://c8lgxg63d8s52 diff --git a/tests/_resource_readout_surface_preview.gd.uid b/tests/_resource_readout_surface_preview.gd.uid new file mode 100644 index 0000000..27eb816 --- /dev/null +++ b/tests/_resource_readout_surface_preview.gd.uid @@ -0,0 +1 @@ +uid://cy6n62kcp7q1y diff --git a/tests/_rocky_surface_preview.gd.uid b/tests/_rocky_surface_preview.gd.uid new file mode 100644 index 0000000..09a037c --- /dev/null +++ b/tests/_rocky_surface_preview.gd.uid @@ -0,0 +1 @@ +uid://c0yjf3qdv31eo diff --git a/tests/_turret_vs_chaser_preview.gd.uid b/tests/_turret_vs_chaser_preview.gd.uid new file mode 100644 index 0000000..f71bd01 --- /dev/null +++ b/tests/_turret_vs_chaser_preview.gd.uid @@ -0,0 +1 @@ +uid://dfi84x2tcwqwy diff --git a/tests/_void_surface_preview.gd.uid b/tests/_void_surface_preview.gd.uid new file mode 100644 index 0000000..0e500ff --- /dev/null +++ b/tests/_void_surface_preview.gd.uid @@ -0,0 +1 @@ +uid://clf1bitxms0ti diff --git a/tests/test_black_hole.gd.uid b/tests/test_black_hole.gd.uid new file mode 100644 index 0000000..75397b6 --- /dev/null +++ b/tests/test_black_hole.gd.uid @@ -0,0 +1 @@ +uid://vlcbg3juhpy3 diff --git a/tests/test_carrier_vessel.gd.uid b/tests/test_carrier_vessel.gd.uid new file mode 100644 index 0000000..5af74b7 --- /dev/null +++ b/tests/test_carrier_vessel.gd.uid @@ -0,0 +1 @@ +uid://gj02xu88lxpm diff --git a/tests/test_distorder_energy.gd.uid b/tests/test_distorder_energy.gd.uid new file mode 100644 index 0000000..e954283 --- /dev/null +++ b/tests/test_distorder_energy.gd.uid @@ -0,0 +1 @@ +uid://bistn7dgiv7lw diff --git a/tests/test_enemy_mothership.gd.uid b/tests/test_enemy_mothership.gd.uid new file mode 100644 index 0000000..9679052 --- /dev/null +++ b/tests/test_enemy_mothership.gd.uid @@ -0,0 +1 @@ +uid://c18rxosfduxoa diff --git a/tests/test_fleet_cargo_limit.gd.uid b/tests/test_fleet_cargo_limit.gd.uid new file mode 100644 index 0000000..58069e9 --- /dev/null +++ b/tests/test_fleet_cargo_limit.gd.uid @@ -0,0 +1 @@ +uid://b1fyqy5i8mqf0 diff --git a/tests/test_gas_bomber_ammo.gd.uid b/tests/test_gas_bomber_ammo.gd.uid new file mode 100644 index 0000000..c31651b --- /dev/null +++ b/tests/test_gas_bomber_ammo.gd.uid @@ -0,0 +1 @@ +uid://cwcw77qvykk35 diff --git a/tests/test_gas_bomber_cooldowns.gd.uid b/tests/test_gas_bomber_cooldowns.gd.uid new file mode 100644 index 0000000..41c349e --- /dev/null +++ b/tests/test_gas_bomber_cooldowns.gd.uid @@ -0,0 +1 @@ +uid://tguuracp1ewt diff --git a/tests/test_gas_burner.gd b/tests/test_gas_burner.gd index 4dfffd0..4b97dc5 100644 --- a/tests/test_gas_burner.gd +++ b/tests/test_gas_burner.gd @@ -30,6 +30,8 @@ var hub_id: int = 0 var burner_id: int = 0 var load_limit: int = 0 var energy_before_stall: int = 0 +var conversion_rate: float = 0.0 +var conversion_yield: int = 0 const MOTHERSHIP_AT := Vector2.ZERO const HUB_AT := Vector2(150.0, 0.0) @@ -48,6 +50,9 @@ func _initialize() -> void: mothership_id = game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) burner_id = game_state.add_building(GameState.BUILDING_GAS_BURNER, BURNER_AT) game_state.deposit_resource_to_building(burner_id, GameState.RESOURCE_GAS, 20) + var logistics: Resource = game_state.building_logistics_profile(game_state.find_building(burner_id)) + conversion_rate = logistics.conversion_rate + conversion_yield = logistics.conversion_yield func _process(delta: float) -> bool: @@ -73,10 +78,10 @@ func _process(delta: float) -> bool: var energy_made: int = game_state.building_resource(burner_id, GameState.RESOURCE_ENERGY) var gas_burned: int = 20 - gas_left if gas_burned < 2: - return _fail("burner only burned %d gas in 2.5s at rate %.1f/s" % [gas_burned, GameState.GAS_BURN_RATE]) - if energy_made != gas_burned * GameState.ENERGY_PER_GAS: + return _fail("burner only burned %d gas in 2.5s at rate %.1f/s" % [gas_burned, conversion_rate]) + if energy_made != gas_burned * conversion_yield: return _fail("burner made %d energy for %d gas burned, expected %dx" % [ - energy_made, gas_burned, GameState.ENERGY_PER_GAS + energy_made, gas_burned, conversion_yield ]) if game_state.rocks != 0 or game_state.gas != 0: return _fail("conversion touched the bank (rocks=%d gas=%d) - it must only use local stock" % [ diff --git a/tests/test_gas_burner.gd.uid b/tests/test_gas_burner.gd.uid new file mode 100644 index 0000000..0188f66 --- /dev/null +++ b/tests/test_gas_burner.gd.uid @@ -0,0 +1 @@ +uid://dlx2eu6lw3ps6 diff --git a/tests/test_hub_capacity.gd b/tests/test_hub_capacity.gd index 0bc68ae..d9005de 100644 --- a/tests/test_hub_capacity.gd +++ b/tests/test_hub_capacity.gd @@ -12,6 +12,7 @@ func _initialize() -> void: state.transporters.clear() state.reset_progression() state.rocks = 900 + state.research_points = state.skill_research_point_cost("freight_yards") state.unlocked = ["shipyards", "logistics", "freight"] var hub_a: int = state.add_building(state.BUILDING_TRANSPORT_HUB, Vector2(-500, 0)) diff --git a/tests/test_laser_ammo.gd.uid b/tests/test_laser_ammo.gd.uid new file mode 100644 index 0000000..c314dea --- /dev/null +++ b/tests/test_laser_ammo.gd.uid @@ -0,0 +1 @@ +uid://d24p5etger56g diff --git a/tests/test_laser_vessel.gd.uid b/tests/test_laser_vessel.gd.uid new file mode 100644 index 0000000..df5f9c4 --- /dev/null +++ b/tests/test_laser_vessel.gd.uid @@ -0,0 +1 @@ +uid://dp0e71h2124c8 diff --git a/tests/test_research_points.gd.uid b/tests/test_research_points.gd.uid new file mode 100644 index 0000000..fe90658 --- /dev/null +++ b/tests/test_research_points.gd.uid @@ -0,0 +1 @@ +uid://x08iyhs2lrrr diff --git a/tests/test_resource_readout.gd.uid b/tests/test_resource_readout.gd.uid new file mode 100644 index 0000000..37f0f14 --- /dev/null +++ b/tests/test_resource_readout.gd.uid @@ -0,0 +1 @@ +uid://bhad0657luiec diff --git a/tests/test_space_time_distorder.gd.uid b/tests/test_space_time_distorder.gd.uid new file mode 100644 index 0000000..5f7ecd6 --- /dev/null +++ b/tests/test_space_time_distorder.gd.uid @@ -0,0 +1 @@ +uid://btmmwkkgw6qwi diff --git a/tests/test_surface_profiles.gd.uid b/tests/test_surface_profiles.gd.uid new file mode 100644 index 0000000..2de5318 --- /dev/null +++ b/tests/test_surface_profiles.gd.uid @@ -0,0 +1 @@ +uid://crbidmy4oqat7 diff --git a/tests/test_turret_spawner_enemy.gd.uid b/tests/test_turret_spawner_enemy.gd.uid new file mode 100644 index 0000000..dbd423d --- /dev/null +++ b/tests/test_turret_spawner_enemy.gd.uid @@ -0,0 +1 @@ +uid://du2i8ayup3qkd diff --git a/tests/test_vessel_composition.gd b/tests/test_vessel_composition.gd index 67bf6c3..226d9a1 100644 --- a/tests/test_vessel_composition.gd +++ b/tests/test_vessel_composition.gd @@ -23,8 +23,7 @@ func _process(delta: float) -> bool: return false var definitions: Array = VESSEL_DEFINITIONS.all() var fleet_definitions: Array = VESSEL_DEFINITIONS.fleet() - if fleet_definitions.size() != 5 \ - or fleet_definitions.any(func(definition): return definition.has_capability("transport")): + if fleet_definitions.any(func(definition): return definition.has_capability("transport")): return _fail("logistics transporters leaked into the combat-fleet catalog") if ships.size() != definitions.size(): return _fail("catalog and composed fleet differ in size")