#105 (#97g): garrison vessels patrol and defend their outpost

A garrisoned vessel circles its post, engages what comes into range, and
goes back to circling. It steers by writing move_target and lets Ship's own
steering fly it, so it accelerates, banks and brakes exactly like a vessel
in the formation.

It is not in Fleet.ships. It does not follow the formation, does not count
against max_fleet_size(), and does not move when the player flies away -
which is the whole point of stationing it. A garrison that ate a fleet slot
would mean stationing a vessel cost the player a place in their own wedge.

The leash is the load-bearing part. Enemies are judged from the POST rather
than from the vessel, so a garrison that has drifted out to its leash cannot
see further still and be walked away from its outpost one enemy at a time,
and an engagement is clamped to a circle around the post rather than to a
straight line, so it still manoeuvres while pinned.

The orbit starts from where the vessel actually is rather than a random
bearing. A random one makes the first move_target a point on the far side of
the circle, and the vessel flies straight across the middle - through the
outpost it is guarding.

EnemyTargets.nearest_ship now searches the player_ships GROUP instead of
Fleet.ships: an enemy that ignored the guns shooting at it, because they
were not in the player's formation, would let a garrison hold a position
without ever being fought back.

The laser vessel is deliberately not garrisonable. Firing the laser lives in
LaserController, a fleet ability driven by the laser button, rather than in
VesselLaserComponent - so a garrisoned laser would hold its position and
never shoot, and offering a vessel that cannot fight is worse than a short
list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-23 14:13:47 +02:00
commit bd0cc842ff
5 changed files with 444 additions and 6 deletions

96
vessels/GarrisonField.gd Normal file
View file

@ -0,0 +1,96 @@
extends Node2D
# #105 (#97g): keeps the vessels stationed at outposts in step with what
# GameState says is stationed there.
#
# Garrison vessels are NOT in Fleet.ships. They are spawned here, under a
# node of their own, so the formation, the fleet's speed fader and
# max_fleet_size() all continue to be about the fleet - a garrison that
# counted against the fleet's slots would mean stationing a vessel cost the
# player a place in their own wedge, which is the opposite of what an outpost
# is for.
#
# The record is the truth, as everywhere else: this node is rebuilt from
# GameState.garrison_crew() when the space scene loads, so a garrison
# survives a trip to a planet surface.
var ship_scene: PackedScene = preload("res://vessels/Ship.tscn")
const GARRISON_COMPONENT = preload("res://vessels/VesselGarrisonComponent.gd")
# {outpost_id: [ship, ...]}
var stationed: Dictionary = {}
func _ready() -> void:
add_to_group("garrison_field")
sync()
func _process(_delta: float) -> void:
sync()
func ships_at(outpost_id: int) -> Array:
var out: Array = []
for ship in stationed.get(outpost_id, []):
if is_instance_valid(ship) and not ship.is_queued_for_deletion():
out.append(ship)
return out
func all_ships() -> Array:
var out: Array = []
for outpost_id in stationed:
out.append_array(ships_at(outpost_id))
return out
func sync() -> void:
# Retire whole outposts that no longer exist, then reconcile the rest.
for outpost_id in stationed.keys():
if GameState.find_building(outpost_id).is_empty():
for ship in ships_at(outpost_id):
ship.queue_free()
stationed.erase(outpost_id)
for outpost_id in GameState.outpost_ids():
var record: Dictionary = GameState.find_building(outpost_id)
var crew: Array = GameState.garrison_crew(outpost_id)
var here: Array = ships_at(outpost_id)
stationed[outpost_id] = here
if here.size() == crew.size():
continue
if here.size() > crew.size():
# The record shrank under us - a vessel was released or lost
# while this node was not looking.
for i in range(crew.size(), here.size()):
here[i].queue_free()
stationed[outpost_id] = here.slice(0, crew.size())
continue
for i in range(here.size(), crew.size()):
here.append(_spawn(outpost_id, String(crew[i]), record.position, i))
func _spawn(outpost_id: int, vessel_type: String, at: Vector2, index: int) -> Node2D:
var ship: Area2D = ship_scene.instantiate()
# Before add_child, so Ship._ready() builds the right vessel - the same
# order Fleet.add_ship() relies on.
ship.vessel_type = vessel_type
var spread: float = TAU * float(index) / 4.0
ship.position = at + Vector2(cos(spread), sin(spread)) * 170.0
add_child(ship)
var component: Node = GARRISON_COMPONENT.new()
component.outpost_id = outpost_id
component.station = at
ship.add_child(component)
component.setup()
# A garrison vessel that dies frees its berth, so #104 sends another.
ship.destroyed.connect(_on_garrison_lost.bind(outpost_id, vessel_type))
return ship
func _on_garrison_lost(outpost_id: int, vessel_type: String) -> void:
var crew: Array = GameState.garrison_crew(outpost_id)
var index: int = crew.find(vessel_type)
if index >= 0:
crew.remove_at(index)

View file

@ -0,0 +1,120 @@
class_name VesselGarrisonComponent
extends Node
# #105 (#97g): "When a vessel is assigned to an outpost, it navigates around
# the outpost by itself, attacking nearby ennemies if any are detected."
#
# Attached to a Ship at spawn by GarrisonField rather than declared on the
# vessel definition: garrisoning is a ROLE a hull is put into, not a property
# of the hull. The same interceptor is a fleet vessel one minute and a
# garrison the next, and nothing about it changes but who is steering.
#
# It steers by writing move_target and lets Ship's own steering do the
# flying, so a garrison vessel accelerates, banks and brakes exactly like one
# in the formation. It is not in Fleet.ships, so it does not follow the
# formation, does not count against max_fleet_size(), and does not move when
# the player flies away - which is the whole point of stationing it.
var capability_id: String = "garrison"
# Which outpost this belongs to, and where that outpost is. The position is
# copied rather than looked up every tick: the record is the truth, and if
# the outpost is levelled the vessel should hold the ground it was left on
# rather than snap to the origin.
var outpost_id: int = 0
var station: Vector2 = Vector2.ZERO
@export var orbit_radius: float = 170.0
@export var orbit_speed: float = 0.85
@export var detection_range: float = 950.0
# How far from its post it will go. Without a leash a garrison would be
# drawn off by anything that flew past, which is exactly what stationing it
# was meant to stop - and the outpost would be left open behind it.
@export var leash_range: float = 620.0
# Held off the target rather than ramming it: these are gunships, and a
# vessel that closed to zero would trade itself for a chaser every time.
@export var engage_distance: float = 300.0
var _angle: float = 0.0
var _engaging: bool = false
func setup() -> void:
# Start the orbit from where the vessel actually IS, not from a random
# bearing. A random one makes the first move_target a point on the far
# side of the circle, and the vessel flies straight across the middle to
# reach it - through the outpost it is supposed to be guarding.
var ship: Node2D = get_parent()
var offset: Vector2 = ship.global_position - station
_angle = offset.angle() if offset.length() > 1.0 else randf() * TAU
func is_engaging() -> bool:
return _engaging
func tick(delta: float) -> void:
var ship: Node2D = get_parent()
var target: Node2D = _nearest_enemy(ship)
_engaging = target != null
if target == null:
_patrol(ship, delta)
_hold_fire(ship)
return
_engage(ship, target, delta)
func _patrol(ship: Node2D, delta: float) -> void:
_angle += orbit_speed * delta
ship.move_target = station + Vector2(cos(_angle), sin(_angle)) * orbit_radius
func _engage(ship: Node2D, target: Node2D, delta: float) -> void:
# Keep station off the target, but never further from the post than the
# leash allows - the approach is clamped to a circle around the outpost
# rather than to a straight line, so it still manoeuvres while pinned.
var to_target: Vector2 = target.global_position - ship.global_position
var distance: float = to_target.length()
var wanted: Vector2 = target.global_position
if distance > 0.001:
wanted = target.global_position - to_target.normalized() * engage_distance
var from_station: Vector2 = wanted - station
if from_station.length() > leash_range:
wanted = station + from_station.normalized() * leash_range
ship.move_target = wanted
# The orbit angle keeps advancing while engaged, so breaking off does not
# snap the vessel back to whatever bearing it left on.
_angle += orbit_speed * delta
var weapon: Node = ship.get_component("weapon") if ship.has_method("get_component") else null
if weapon == null:
return
if distance <= weapon.fire_range:
weapon.set_fire_direction(to_target)
else:
weapon.set_fire_direction(Vector2.ZERO)
func _hold_fire(ship: Node2D) -> void:
var weapon: Node = ship.get_component("weapon") if ship.has_method("get_component") else null
if weapon != null:
weapon.set_fire_direction(Vector2.ZERO)
func _nearest_enemy(ship: Node2D) -> Node2D:
var nearest: Node2D = null
var best: float = detection_range
for enemy in get_tree().get_nodes_in_group("enemies"):
if not is_instance_valid(enemy) or enemy.is_queued_for_deletion():
continue
var at: Vector2 = (enemy as Node2D).global_position
# Judged from the POST, not from the vessel: otherwise a garrison
# that had drifted out to its leash would see further still, and
# could be walked away from its outpost one enemy at a time.
if station.distance_to(at) > leash_range + engage_distance:
continue
var d: float = ship.global_position.distance_to(at)
if d < best:
best = d
nearest = enemy
return nearest