spaceship/vessels/Fleet.gd
Loïc Wisniewski 93bfe62e76 Let the fleet grow past twenty vessels
max_fleet_size() clamped itself to DEFAULT_FORMATION.size(), so the ceiling
on the fleet was a property of the LAYOUT - the wedge ran out of authored
positions at twenty - rather than a rule anyone decided on. Capacity is the
supplied Fleet Offices and nothing else now.

Dropping the clamp alone would index off the end of the formation array, so
the wedge continues by rule instead of stopping: formation_slot() grows it
on demand, each further pair stepping 30 further out and 40 further back,
which is what the authored rows already do - the first twenty slots come
back unchanged. A grown slot is rotated by formation_rotation so a fleet
that outgrows the array after the player has pivoted does not sprout hulls
facing the original heading, and that angle is saved with the layout.

Two "fleet is full" messages pointed at the vanished ceiling. Both now say
what actually buys a berth, and the hangar's stops claiming it is research,
which it has not been since #219.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QR2Mdk42ghEpeyXPe4UFyn
2026-09-12 19:06:29 +02:00

452 lines
17 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
var dash_distance: float = 0.0
var dash_elapsed: float = 0.0
var dash_duration: 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)
# #219: a Fleet Office that runs dry loses the vessel it was keeping.
# GameState decides THAT it happens - it owns the supply - and the fleet
# owns the hulls, so it is the one that gives this one up.
GameState.fleet_upkeep_failed.connect(_on_fleet_upkeep_failed)
# Destroyed rather than stored: the vessel is not being put away, it is being
# lost, and taking it out through the ordinary death path means it explodes,
# is heard, and leaves the formation exactly as a vessel shot down does.
func _on_fleet_upkeep_failed(slot_index: int, _vessel_type: String) -> void:
if slot_index < 0 or slot_index >= ships.size():
return
var lost: Node = ships[slot_index]
if is_instance_valid(lost) and not lost.is_queued_for_deletion():
# scuttle(), not take_damage(): a shield charge would ABSORB a hit,
# and there is no hit here - the berth simply stopped being paid for.
lost.scuttle()
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_slot(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_slot(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()):
formation_offsets[i] = GameState.formation_slot(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
ship.reset_physics_interpolation()
steer_direction = Vector2.ZERO
dash_remaining = 0.0
dash_direction = Vector2.ZERO
dash_distance = 0.0
dash_elapsed = 0.0
dash_duration = 0.0
fleet_target = world_pos
anchor = world_pos
GameState.fleet_position = get_centroid()
func move_in_direction(direction: Vector2) -> void:
# Joystick steering. Sets the heading; _physics_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
dash_distance = distance
dash_elapsed = 0.0
dash_duration = distance / DASH_SPEED
return true
func is_dashing() -> bool:
return dash_remaining > 0.0
func is_using_speed_boost() -> bool:
return GameState.fleet_speed_multiplier > 1.0 \
and steer_direction != Vector2.ZERO and not is_dashing() \
and not ships.is_empty()
func _physics_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 previous_progress: float = dash_elapsed / dash_duration
dash_elapsed = minf(dash_elapsed + delta, dash_duration)
var progress: float = dash_elapsed / dash_duration
# Cubic ease-out gives the Propulser an immediate punch, then lets the
# formation settle smoothly at the aimed point. Applying the difference
# between eased positions keeps the final distance frame-rate independent.
var previous_distance: float = dash_distance * _dash_curve(previous_progress)
var target_distance: float = dash_distance * _dash_curve(progress)
var step: float = minf(target_distance - previous_distance, 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 _dash_curve(progress: float) -> float:
var remaining: float = 1.0 - clampf(progress, 0.0, 1.0)
return 1.0 - remaining * remaining * remaining
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()