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. @export var cruise_speed: float = 260.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://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 GameState.sync_fleet_cargo_capacity(vessel_types()) 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 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 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) GameState.sync_fleet_cargo_capacity(vessel_types()) 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() GameState.sync_fleet_cargo_capacity(vessel_types()) 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: 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 own `velocity` is NOT how fast it is really travelling # while the fleet cruises. Ship snaps to its slot and reports zero # the moment it is within arrive_radius of it, even though the # anchor is dragging that slot forward at cruise_speed underneath # it. Its true motion is its catch-up velocity PLUS the fleet's, # which it already carries as `fleet_velocity` (zero for a # point-to-point move_to, where `velocity` alone is the whole # story). # # Braking from `velocity` alone therefore stopped the fleet dead # on any frame where the ships happened to be seated - and how # often that happens depends on the frame rate, because the anchor # advances cruise_speed * delta per frame. At ~25fps it steps far # enough that ships always lag and always report motion, which is # the only reason releasing the stick looked right. var motion: Vector2 = ship.velocity + ship.fleet_velocity # HAND THE FLEET'S MOTION TO THE SHIP. Until now the anchor was # carrying it along and its own `velocity` was ~0, so aiming it at a # braking point ahead made it start from REST: the fleet stopped # dead, then accelerated into its own stopping distance and halted a # second time. That is the "pause, then it moves again" stutter. # Giving it the velocity it was actually travelling at makes the # stop one continuous decelerating coast. ship.velocity = motion # Context that is no longer true: the fleet is not cruising any # more, so leaving it set would have Ship face a heading it is not # travelling on. 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) GameState.sync_fleet_cargo_capacity(vessel_types()) 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()