diff --git a/tests/test_outpost_garrison_patrol.gd b/tests/test_outpost_garrison_patrol.gd index 6013c7c..225709d 100644 --- a/tests/test_outpost_garrison_patrol.gd +++ b/tests/test_outpost_garrison_patrol.gd @@ -10,7 +10,11 @@ extends SceneTree # A. a crewed outpost puts a real vessel in the world, and that vessel is # NOT in the fleet - it must not follow the formation, must not eat a # fleet slot, and must not fly off when the player leaves -# B. left alone it circles its post rather than sitting on it +# B. left alone it WANDERS its post: it visits genuinely different places, +# it is actually travelling rather than sliding, and it faces where it +# is going (#109 - it used to walk a circle in steps shorter than +# Ship's arrive_radius, so every frame it snapped and zeroed its +# velocity, and Ship only turns while it is moving) # C. an enemy inside its detection range is engaged and shot at # D. it will not be walked away: an enemy beyond the leash is ignored, and # an engagement never drags it further than the leash from its post @@ -30,6 +34,10 @@ var stage: int = 0 var elapsed: float = 0.0 var orbit_min: float = INF var orbit_max: float = 0.0 +var visited: Array = [] +var top_speed: float = 0.0 +var moving_frames: int = 0 +var facing_frames: int = 0 var max_from_post: float = 0.0 var dummy_hp_before: int = 0 @@ -117,20 +125,50 @@ func _process(delta: float) -> bool: stage = 2 return false 2: - # --- B. it circles the post --------------------------------- + # --- B. it wanders its post --------------------------------- var from_post: float = guard.global_position.length() orbit_min = minf(orbit_min, from_post) orbit_max = maxf(orbit_max, from_post) - if elapsed < 4.0: + visited.append(guard.global_position) + var speed: float = guard.velocity.length() + top_speed = maxf(top_speed, speed) + if speed > 40.0: + # Facing is only meaningful while actually travelling, which + # is the whole of what #109 was about. Measured as a FRACTION + # of the travel rather than as a worst instant: a vessel that + # has just been given a new heading is legitimately still + # swinging onto it at its own rotation_speed, and one frame + # mid-turn says nothing about whether it flies nose-first. + moving_frames += 1 + var drift: float = absf(angle_difference(guard.rotation, + guard.velocity.angle() + PI / 2.0)) + if drift <= PI / 4.0: + facing_frames += 1 + if elapsed < 8.0: return false if component.is_engaging(): return _fail("the garrison thinks it is fighting with no enemy on the map") - if orbit_min < 40.0: - return _fail("the garrison sat on top of its outpost (closest %.0fpx)" % orbit_min) if orbit_max > component.leash_range: return _fail("patrolling took it %.0fpx from its post, past the %.0fpx leash" \ % [orbit_max, component.leash_range]) - print("patrolled between %.0f and %.0fpx of its post" % [orbit_min, orbit_max]) + if top_speed < 60.0: + return _fail("the picket never got above %.0fpx/s - it is sliding, not flying" \ + % top_speed) + if moving_frames <= 0: + return _fail("it never travelled at all") + var aligned: float = float(facing_frames) / float(moving_frames) + if aligned < 0.7: + return _fail("it was pointing where it was going for only %.0f%% of its travel" \ + % (aligned * 100.0)) + # Genuinely different places, not one path walked over and over. + var spread: float = 0.0 + for a in visited: + for b in visited: + spread = maxf(spread, a.distance_to(b)) + if spread < 120.0: + return _fail("everywhere it went was within %.0fpx - that is not wandering" % spread) + print("wandered %.0fpx across, %.0f-%.0fpx from its post, up to %.0fpx/s, nose-first %.0f%% of the way" \ + % [spread, orbit_min, orbit_max, top_speed, aligned * 100.0]) # --- D (first half). an enemy beyond the leash is ignored ---- enemy = _dummy(Vector2(component.leash_range + component.engage_distance + 900.0, 0.0)) diff --git a/vessels/VesselGarrisonComponent.gd b/vessels/VesselGarrisonComponent.gd index d2e5365..c447b9f 100644 --- a/vessels/VesselGarrisonComponent.gd +++ b/vessels/VesselGarrisonComponent.gd @@ -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)