#109: garrison vessels wander their post instead of walking a ring

Two things were wrong and the second was the real one. A ring is not what a
picket does - but each step around it was shorter than Ship's arrive_radius,
so every frame the vessel SNAPPED onto its target and zeroed its velocity,
and Ship only turns to face a heading while it is actually moving. The
result slid around the outpost pointing whichever way it had spawned.

So: real destinations, far enough apart to be flown to. The vessel picks a
point somewhere in its patrol area, flies there, pauses, and picks another.
A leg is never shorter than wander_min_leg, which is what keeps the
snap-and-stop from coming straight back, and a leg that cannot be reached
times out rather than stranding the picket.

Breaking off a fight now starts a fresh leg from where the fight ended,
rather than flying back to a waypoint chosen before it began.

Measured: 485px of wander, 46-271px from its post, up to 690px/s, pointing
where it was going for 88% of its travel. The test measures that facing as a
FRACTION rather than a worst instant - a vessel just given a new heading is
legitimately still swinging onto it at its own rotation_speed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-23 21:38:07 +02:00
commit 457ae7023c
2 changed files with 107 additions and 21 deletions

View file

@ -24,8 +24,23 @@ var capability_id: String = "garrison"
var outpost_id: int = 0
var station: Vector2 = Vector2.ZERO
@export var orbit_radius: float = 170.0
@export var orbit_speed: float = 0.85
# #109: it used to walk a circle, one small step per frame. That looked
# wrong for two reasons and the second was the real one. A ring is not what
# a picket does - but worse, each step was shorter than Ship's arrive_radius,
# so every frame the vessel SNAPPED onto its target and set velocity to zero,
# and Ship only turns to face a heading while it is actually moving. The
# result slid around the outpost pointing whichever way it had spawned.
#
# So: real destinations, far enough apart to be flown to. The vessel picks a
# point somewhere in its patrol area, goes there, pauses, and picks another.
@export var wander_radius: float = 340.0
# A new leg is never shorter than this, or the snap-and-stop problem comes
# straight back.
@export var wander_min_leg: float = 150.0
@export var wander_arrive: float = 60.0
@export var wander_pause: float = 0.8
# A leg that somehow cannot be reached must not strand the picket.
@export var wander_leg_timeout: float = 12.0
@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
@ -35,18 +50,14 @@ var station: Vector2 = Vector2.ZERO
# 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
var _wander_to: Vector2 = Vector2.ZERO
var _leg_time: float = 0.0
var _pause_left: float = 0.0
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
_pick_leg()
func is_engaging() -> bool:
@ -65,8 +76,43 @@ func tick(delta: float) -> void:
func _patrol(ship: Node2D, delta: float) -> void:
_angle += orbit_speed * delta
ship.move_target = station + Vector2(cos(_angle), sin(_angle)) * orbit_radius
if _pause_left > 0.0:
# Sitting still between legs, which is most of what makes it read as
# a picket holding a position rather than a thing on a rail.
_pause_left -= delta
return
_leg_time += delta
if ship.global_position.distance_to(_wander_to) <= wander_arrive \
or _leg_time >= wander_leg_timeout:
_pause_left = randf_range(wander_pause * 0.5, wander_pause * 1.5)
_pick_leg()
return
ship.move_target = _wander_to
func _pick_leg() -> void:
var ship: Node2D = get_parent()
var from: Vector2 = ship.global_position if ship != null else station
# Rejection-sampled rather than solved: a handful of tries always finds a
# point that is both inside the patrol area and far enough to be worth
# flying to, and the alternative is arithmetic nobody will read.
for attempt in range(8):
var angle: float = randf() * TAU
var radius: float = sqrt(randf()) * wander_radius
var candidate: Vector2 = station + Vector2(cos(angle), sin(angle)) * radius
if from.distance_to(candidate) >= wander_min_leg:
_wander_to = candidate
_leg_time = 0.0
return
# Nothing far enough came up - head straight out to the edge instead.
var away: Vector2 = (from - station)
away = away.normalized() if away.length() > 1.0 else Vector2.RIGHT
_wander_to = station - away * wander_radius
_leg_time = 0.0
func patrol_destination() -> Vector2:
return _wander_to
func _engage(ship: Node2D, target: Node2D, delta: float) -> void:
@ -82,9 +128,11 @@ func _engage(ship: Node2D, target: Node2D, delta: float) -> void:
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
# Breaking off starts a fresh leg from wherever the fight ended, rather
# than flying back to a waypoint chosen before it began.
_wander_to = wanted
_leg_time = 0.0
_pause_left = 0.0
_shoot(ship, target, distance, to_target)