spaceship/Fleet.gd
Loïc Wisniewski 3573ccf44f Buildings produce from their own stock, and queue orders until supplied
Forgejo #16, part 3 of #12. BuildController deducted the cost from the
bank at the moment of purchase and refused outright with "Not enough
rocks", so nothing ever had to be delivered anywhere. Each building now
carries its own stock, and an order it cannot pay for yet is QUEUED and
completes by itself once ore arrives.

Records get an `id` because a building has to be referable across the
space <-> planet-surface scene swap, when every Building NODE has been
destroyed and rebuilt. Position would nearly work and then fail on two
buildings placed on the same spot.

Production advances in the GameState autoload, the same way extractor
haulers already do, so an order can complete while the player is down a
gravity well. A finished vessel becomes `ships_owed` rather than being
handed straight to a Fleet that may not exist, and the fleet claims what
it is owed when it next has room.

The craft menu remembers which crafter it was opened for: _process()
clears current_building while any menu is open, so by the next frame the
menu would no longer know. It shows the ore delivered HERE rather than
the bank, since showing the bank would promise something the crafter
cannot actually build.

Ship crafting cannot be supplied until #17 lands - nothing yet moves ore
into a building.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:18:44 +02:00

229 lines
8.7 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 - both for a fresh start (5 ships) and for restoring
# a possibly-smaller surviving fleet after returning from a planet's
# surface, so both cases share one code path instead of two that could
# drift out of sync.
# 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 spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void:
for ship in ships:
if is_instance_valid(ship):
ship.queue_free()
ships.clear()
formation_offsets.clear()
var n: int = clamp(count, 1, GameState.max_fleet_size())
for i in range(n):
var offset: Vector2 = GameState.formation[i]
var ship: Area2D = ship_scene.instantiate()
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
func add_ship() -> bool:
# Adds one vessel in the next free formation slot (bought from a ship
# crafter). Returns false when the wedge is already full.
if ships.size() >= GameState.max_fleet_size():
return false
var offset: Vector2 = GameState.formation[ships.size()]
var ship: Area2D = ship_scene.instantiate()
# 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)
return true
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:
# Before the steering early-out: a ship the crafter has already built and
# paid for is owed whether or not the fleet happens to be flying (#16).
_claim_owed_ships()
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.
anchor += steer_direction * cruise_speed * delta
fleet_target = anchor
_assign_slot_targets()
func _claim_owed_ships() -> void:
# Production completes in the GameState autoload, which may well happen
# while the player is on a planet's surface with no Fleet node in
# existence to hand a vessel to. Those ships wait as `ships_owed` until a
# fleet with room can take them.
while GameState.ships_owed > 0 and ships.size() < GameState.max_fleet_size():
if not add_ship():
return
GameState.ships_owed -= 1
func _assign_slot_targets() -> void:
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 * 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
# 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)
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()