spaceship/vessels/VesselGarrisonComponent.gd
Loïc Wisniewski b50597b757 Garrison laser vessels and gas bombers, on a shared ability seam
#105 shipped with interceptors as the only garrisonable vessel, and the
reason was a design fault rather than a decision: firing the laser and the
gas bomb lived in the player's fleet controllers, so a stationed laser would
have held its position and never shot.

The shot is now a thing in the world rather than something a controller
draws. LaserBeamEffect and GasBombEffect are self-contained, self-animating
and self-freeing, added to the tree root - the same shape EnemyBigLaserBeam
and ArtilleryShell already had on the enemy side - and they outlive the
vessel that fired them.

Above them sits one seam, on VesselCooldownFillComponent: ability_range(),
ability_ammo_resource(), ability_ammo_cost() and fire_ability(). A beam and a
bomb are now the same shape of thing to whoever pulls the trigger, so the
garrison AI never names an ability - the test asserts that literally, by
reading the AI's own source. A third armed vessel needs to implement the
seam and nothing else.

Ammunition is the CALLER's business, deliberately: the fleet spends from its
hold, a garrison from its outpost's store, and neither concern belongs in
the component. A garrison with an empty magazine holds fire rather than
falling back on the fleet's hold.

Outpost storage: 50 gas per stationed bomber and 50 energy per stationed
laser, off the VESSEL definitions, so an outpost never learns which vessel
needs what. It is a capacity, not a stock - hub transporters fill it exactly
as they fill a gas burner's buffer, because building_logistics_deficit now
takes the larger of the definition's static buffer and the garrison's
magazine. Every other resource is capped at zero: an outpost is not a depot,
and ore parked at one has nothing to do there and no transporter coming for
it.

Also restores two properties the Godot editor dropped from
GasBurnerBuildingDefinition.tres when it re-saved the file:
collected_outputs and capabilities. Without the first, nothing ever collects
the burner's energy - test_gas_burner caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 19:23:35 +02:00

147 lines
6 KiB
GDScript

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
_shoot(ship, target, distance, to_target)
# Two kinds of armament, one rule: point the gun, or fire the ability. Which
# one this vessel has is a question about its components, not about its type,
# so a third armed vessel needs nothing here.
func _shoot(ship: Node2D, target: Node2D, distance: float, to_target: Vector2) -> void:
var weapon: Node = _component(ship, "weapon")
if weapon != null:
# The gun is a held trigger: it is aimed every tick and stops the
# moment the direction is cleared.
weapon.set_fire_direction(to_target if distance <= weapon.fire_range else Vector2.ZERO)
for component in ship.get_children():
if not component.has_method("fire_ability") or not component.has_ability():
continue
if not component.is_ready() or distance > component.ability_range():
continue
# The magazine is the OUTPOST's, not the fleet's hold. A garrison
# spends what was flown out to it, which is the whole point of the
# outpost holding a store at all - and if the store is empty the
# vessel holds its fire rather than firing free shots.
var resource_type: String = component.ability_ammo_resource()
var cost: int = component.ability_ammo_cost()
if not GameState.spend_garrison_ammo(outpost_id, resource_type, cost):
continue
if not component.fire_ability(get_tree().root, ship.global_position,
target.global_position):
# It refused after being paid - put the ammunition back rather
# than burning it on a shot that never happened.
GameState.building_inventory(outpost_id).deposit(resource_type, cost)
func _component(ship: Node2D, capability: String) -> Node:
return ship.get_component(capability) if ship.has_method("get_component") else null
func _hold_fire(ship: Node2D) -> void:
var weapon: Node = _component(ship, "weapon")
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