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")