spaceship/vessels/Fleet.gd
2026-09-03 23:14:02 +02:00

408 lines
15 KiB
GDScript

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
# An aimed Propulser burst translates the complete formation as one body.
var dash_direction: Vector2 = Vector2.ZERO
var dash_remaining: float = 0.0
const DASH_SPEED: float = 2800.0
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()
# Moves the complete formation immediately while preserving every authored
# slot. Unlike move_to(), this is a scene transition hidden behind a full
# screen cover, not a travel order ships should visibly fly toward (#183).
func teleport_to(world_pos: Vector2) -> void:
var displacement: Vector2 = world_pos - get_formation_origin()
for ship in ships:
if not is_instance_valid(ship):
continue
ship.position += displacement
ship.move_target += displacement
ship.velocity = Vector2.ZERO
ship.fleet_velocity = Vector2.ZERO
steer_direction = Vector2.ZERO
dash_remaining = 0.0
dash_direction = Vector2.ZERO
fleet_target = world_pos
anchor = world_pos
GameState.fleet_position = get_centroid()
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 dash(direction: Vector2, distance: float) -> bool:
if direction.length() < 0.001 or distance <= 0.0 or ships.is_empty():
return false
if dash_remaining <= 0.0:
anchor = get_formation_origin()
dash_direction = direction.normalized()
dash_remaining = distance
return true
func is_dashing() -> bool:
return dash_remaining > 0.0
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 is_dashing():
_advance_dash(delta)
return
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 _advance_dash(delta: float) -> void:
var step: float = minf(DASH_SPEED * delta, dash_remaining)
var displacement: Vector2 = dash_direction * step
dash_remaining -= step
anchor += displacement
fleet_target += displacement
for ship in ships:
if not is_instance_valid(ship):
continue
# Move the hull and its destination together. That preserves every
# formation offset and prevents normal steering from trying to "repair"
# the burst by pulling ships back toward their pre-dash targets.
ship.position += displacement
ship.move_target += displacement
ship.velocity = Vector2.ZERO
ship.fleet_velocity = Vector2.ZERO
ship.rotation = dash_direction.angle() + PI / 2.0
if dash_remaining <= 0.001:
dash_remaining = 0.0
if steer_direction != Vector2.ZERO:
_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()