extends Node2D # Formation slots come from GameState.formation (editable at a command # centre) rather than being fixed here, so a layout the player arranged # survives scene swaps and fleet rebuilds. Ships are always # spawned programmatically (spawn_ships), using however many of these # offsets are needed. The optional vessel-type list preserves the exact # mixed fleet through scene swaps; a fresh start still defaults each slot # to an interceptor. # The fleet's cruise speed. This is the speed of the FLEET as a whole: the # anchor below advances at exactly this rate, and individual ships are # allowed to fly faster than it (Ship.max_speed) so they can close on their # formation slot - which is what makes a pivot while under way look sharp # instead of sluggish. # #88: the whole game moves 1.5x faster. This is the figure the pace is # actually set by - vessel max_speed and acceleration were scaled with it, # and so were the enemies, so it is a change of TEMPO rather than a # difficulty cut. @export var cruise_speed: float = 390.0 # Per-scene neon palette for the fleet. PlanetSurface.tscn overrides these # with darker values, since the lunar surface is bright grey and the default # near-white core would wash out against it. See NeonDraw. @export var ship_glow_color: Color = Color(0.25, 0.85, 1.0) @export var ship_core_color: Color = Color(1.0, 1.0, 1.0) var ship_scene: PackedScene = preload("res://vessels/Ship.tscn") var ships: Array = [] var formation_offsets: Array = [] var fleet_target: Vector2 = Vector2.ZERO # Formation centre while under joystick steering, and the heading it is # travelling along. Zero heading means the fleet is not being steered. var anchor: Vector2 = Vector2.ZERO var steer_direction: Vector2 = Vector2.ZERO func _ready() -> void: # Lets anything resolve "the fleet" without a hardcoded ../Fleet sibling # path - enemies parented under different hosts (e.g. a planet surface's # SurfaceFeatures) need this to find their target. add_to_group("player_fleet") # Researching a vessel upgrade improves the ships already flying, not # just the next one built (#5). GameState.vessel_upgraded.connect(_on_vessel_upgraded) func _on_vessel_upgraded(_id: String) -> void: for ship in ships: if is_instance_valid(ship): ship.apply_vessel_stats() func spawn_ships(count: int, center: Vector2 = Vector2.ZERO, vessel_types: PackedStringArray = PackedStringArray()) -> void: for ship in ships: if is_instance_valid(ship): ship.queue_free() ships.clear() formation_offsets.clear() var spawn_types: PackedStringArray = PackedStringArray() if vessel_types.is_empty(): for i in range(count): spawn_types.append(GameState.VESSEL_INTERCEPTOR) else: for vessel_type in vessel_types: if GameState.vessel_is_fleet_eligible(vessel_type): spawn_types.append(vessel_type) if spawn_types.is_empty(): spawn_types.append(GameState.VESSEL_INTERCEPTOR) var n: int = clamp(spawn_types.size(), 1, GameState.max_fleet_size()) for i in range(n): var offset: Vector2 = GameState.formation[i] var ship: Area2D = ship_scene.instantiate() ship.vessel_type = spawn_types[i] ship.position = center + offset ship.glow_color = ship_glow_color ship.core_color = ship_core_color add_child(ship) ships.append(ship) formation_offsets.append(offset) if ship.has_signal("destroyed"): ship.destroyed.connect(_on_ship_destroyed.bind(ship)) fleet_target = center _sync_composition_state() func vessel_types() -> PackedStringArray: var result: PackedStringArray = PackedStringArray() for ship in ships: if is_instance_valid(ship): result.append(String(ship.vessel_type)) return result func _sync_composition_state() -> void: # #106: the Planning Center counts vessels across the whole game, and # the fleet is the one place it cannot see - the ships are scene nodes # and GameState outlives the scene. Pushed here rather than polled, # alongside the cargo capacity and distorder count that already are, # and for the same reason: composition changes are authoritative, and # this is the one function every one of them goes through. GameState.fleet_vessel_types = vessel_types() 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. # Each distorder vessel past the first widens the speed ceiling, so the # COUNT matters, not merely whether one is present. GameState.set_distorder_vessel_count(count_ships_with_capability("distorder")) 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: return true return false func first_vessel_of_type(vessel_type: String) -> Node2D: for ship in ships: if is_instance_valid(ship) and String(ship.vessel_type) == vessel_type: return ship return null func has_capability(capability: String) -> bool: return first_ship_with_capability(capability) != null func count_ships_with_capability(capability: String) -> int: var total: int = 0 for ship in ships: if is_instance_valid(ship) and ship.has_capability(capability): total += 1 return total func first_ship_with_capability(capability: String) -> Node2D: for ship in ships: if is_instance_valid(ship) and ship.has_capability(capability): return ship return null func add_ship(vessel_type: String = GameState.VESSEL_INTERCEPTOR) -> bool: # Adds one vessel in the next free formation slot (bought from a ship # crafter). Returns false when the wedge is already full. if not GameState.vessel_is_fleet_eligible(vessel_type) \ or ships.size() >= GameState.max_fleet_size(): return false var offset: Vector2 = GameState.formation[ships.size()] var ship: Area2D = ship_scene.instantiate() # Before add_child, so Ship._ready() builds the right vessel - the shield # ring is created there and only for a shield vessel. ship.vessel_type = vessel_type # From the formation origin, not the centroid: with a lopsided layout the # centroid is already displaced by the average offset, so adding the new # ship's own offset to it would drop it well outside the wedge. ship.position = get_formation_origin() + offset ship.glow_color = ship_glow_color ship.core_color = ship_core_color add_child(ship) ships.append(ship) formation_offsets.append(offset) if ship.has_signal("destroyed"): ship.destroyed.connect(_on_ship_destroyed.bind(ship)) ship.set_move_target(fleet_target + offset) _sync_composition_state() return true func store_ship(index: int) -> String: # A fleet always keeps one ship deployed. Removing the last one would trip # the wiped-out-fleet safety net on the next frame and immediately spawn a # replacement, which is the opposite of storing it at the mothership. if ships.size() <= 1 or index < 0 or index >= ships.size(): return "" var ship: Node = ships[index] if not is_instance_valid(ship): return "" var vessel_type: String = String(ship.vessel_type) ships.remove_at(index) formation_offsets.remove_at(index) ship.queue_free() apply_formation() _sync_composition_state() return vessel_type func apply_formation() -> void: # Re-seats the EXISTING ships into the current GameState.formation. # Without this, editing the layout at a command centre (or pivoting it) # only affected ships built later, which read as the edit doing nothing. for i in range(ships.size()): if i >= GameState.max_fleet_size(): break formation_offsets[i] = GameState.formation[i] _assign_slot_targets() func move_to(world_pos: Vector2) -> void: steer_direction = Vector2.ZERO fleet_target = world_pos anchor = world_pos _assign_slot_targets() func move_in_direction(direction: Vector2) -> void: # Joystick steering. Sets the heading; _process advances the anchor. # # This used to park a target 2000 units ahead and let the ships chase # it. That made formation changes barely register while flying: with # the aim point that far away, a 60-unit formation offset moved a # ship's heading by under two degrees, so pivoting mid-flight looked # like it did almost nothing. Anchoring the formation and having each # ship hold its own slot relative to it fixes that. if direction == Vector2.ZERO: stop() return if steer_direction == Vector2.ZERO: anchor = get_formation_origin() steer_direction = direction.normalized() func _process(delta: float) -> void: # #158: a building may pay a local cost out of the hold when the fleet is # actually standing at it, and the autoload has no other way to know # where that is. Written before the early return below - a parked fleet # is exactly the case that matters. GameState.fleet_position = get_centroid() if steer_direction == Vector2.ZERO: return # The anchor - the fleet's formation centre - travels at cruise_speed. # Ships may exceed it individually to reach their slots. #59: multiplied # at the point of use, not baked into cruise_speed itself, so the base # value stays stable regardless of the current boost. anchor += steer_direction * _effective_cruise_speed() * delta fleet_target = anchor _assign_slot_targets() func _effective_cruise_speed() -> float: return cruise_speed * GameState.fleet_speed_multiplier func _assign_slot_targets() -> void: var effective_cruise_speed: float = _effective_cruise_speed() for i in range(ships.size()): ships[i].set_move_target(fleet_target + formation_offsets[i]) # Context, not a command: the ship adds this to its own velocity to # work out how it is really moving. See Ship.fleet_velocity. ships[i].fleet_velocity = steer_direction * effective_cruise_speed func stop() -> void: steer_direction = Vector2.ZERO # Aim each ship at the point where its own braking curve brings it to # rest, so releasing the stick coasts to a halt instead of snapping to # a dead stop (which is what targeting the current position would do, # since that lands inside arrive_radius immediately). fleet_target = get_formation_origin() for ship in ships: # A ship's `velocity` is already its true motion, including the # fleet's travel, because Ship feeds the slot velocity forward into # its own steering rather than carrying it in a separate field. So # stopping is only a matter of telling it the slot has halted and # aiming it at the point its own braking curve reaches. # # This used to do `ship.velocity += ship.fleet_velocity` here, to # compensate for `velocity` reading zero while the ship was in fact # cruising (Ship snapped to its slot every tick and zeroed it). That # compensation was an unbounded ACCUMULATOR: every release added a # whole cruise velocity, the next steer refilled fleet_velocity, and # tapping a direction repeatedly stacked them up faster than # `acceleration` could bleed them off - so a vessel could be flung # along at many times its own top speed, and ships fell out of sync # because each bled off at a rate set by its own distance to its # slot. Ship now bounds its total speed every tick as well, so # neither half of that can come back. var motion: Vector2 = ship.velocity # The fleet is not cruising any more, so the slot is standing still. ship.fleet_velocity = Vector2.ZERO var speed: float = motion.length() if speed <= 0.001: ship.set_move_target(ship.position) continue var braking_distance: float = (speed * speed) / (2.0 * ship.acceleration) ship.set_move_target(ship.position + motion.normalized() * braking_distance) func get_move_targets() -> Array: var result: Array = [] for ship in ships: result.append(ship.move_target) return result func restore(previous_fleet_target: Vector2, previous_move_targets: Array) -> void: fleet_target = previous_fleet_target for i in range(ships.size()): ships[i].move_target = previous_move_targets[i] func _on_ship_destroyed(ship: Node) -> void: var idx: int = ships.find(ship) if idx != -1: ships.remove_at(idx) formation_offsets.remove_at(idx) _sync_composition_state() func get_centroid() -> Vector2: if ships.is_empty(): return fleet_target var sum: Vector2 = Vector2.ZERO for ship in ships: sum += ship.position return sum / ships.size() func get_formation_origin() -> Vector2: # Where the anchor must be for the ships to be standing in their slots # right now. This is NOT the centroid: ships sit at anchor + offset, so # the centroid is anchor + the AVERAGE offset, and the two only coincide # when the layout happens to be balanced around its origin. # # The default wedge is balanced, which is why using the centroid as the # anchor looked fine - until a layout dragged off to one corner at a # command centre made the average offset large. Seeding the anchor from # the centroid then displaced every slot by that average the instant # steering began, so the fleet lurched towards the formation's own # lopsided side before the anchor caught up and it flew the right way. if ships.is_empty(): return fleet_target var sum: Vector2 = Vector2.ZERO for offset in formation_offsets: sum += offset return get_centroid() - sum / formation_offsets.size()