From bd0cc842ff5cca48f3ee1433ab63ed2f496b5d94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 23 Aug 2026 14:13:47 +0200 Subject: [PATCH] #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 --- enemies/EnemyTargets.gd | 13 +- tests/test_outpost_garrison_patrol.gd | 217 ++++++++++++++++++++++++++ vessels/GarrisonField.gd | 96 ++++++++++++ vessels/VesselGarrisonComponent.gd | 120 ++++++++++++++ world/Main.tscn | 4 + 5 files changed, 444 insertions(+), 6 deletions(-) create mode 100644 tests/test_outpost_garrison_patrol.gd create mode 100644 vessels/GarrisonField.gd create mode 100644 vessels/VesselGarrisonComponent.gd diff --git a/enemies/EnemyTargets.gd b/enemies/EnemyTargets.gd index 0e4fcad..31a6456 100644 --- a/enemies/EnemyTargets.gd +++ b/enemies/EnemyTargets.gd @@ -19,16 +19,17 @@ extends RefCounted const SHIP_PRIORITY_RANGE: float = 520.0 +# The GROUP, not Fleet.ships. #105 puts vessels at outposts that are not in +# the fleet at all, and an enemy that ignored the guns actually shooting at +# it - because they were not in the player's formation - would let a +# garrison hold a position without ever being fought back. static func nearest_ship(node: Node, from: Vector2) -> Node2D: - var fleet: Node2D = node.get_tree().get_first_node_in_group("player_fleet") - if fleet == null: - return null var nearest: Node2D = null var best: float = INF - for ship in fleet.ships: - if not is_instance_valid(ship): + for ship in node.get_tree().get_nodes_in_group("player_ships"): + if not is_instance_valid(ship) or ship.is_queued_for_deletion(): continue - var d: float = from.distance_to(ship.global_position) + var d: float = from.distance_to((ship as Node2D).global_position) if d < best: best = d nearest = ship diff --git a/tests/test_outpost_garrison_patrol.gd b/tests/test_outpost_garrison_patrol.gd new file mode 100644 index 0000000..6013c7c --- /dev/null +++ b/tests/test_outpost_garrison_patrol.gd @@ -0,0 +1,217 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_outpost_garrison_patrol.gd +# Forgejo #105 (#97g): "When a vessel is assigned to an outpost, it +# navigates around the outpost by itself, attacking nearby ennemies if any +# are detected." +# +# Covers: +# 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 +# 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 +# E. it goes back to patrolling once the enemy is gone +# F. losing a garrison vessel frees the berth, so it gets crewed again +# G. enemies fight back - a garrison vessel is a target like any other + +var game_state: Node +var main: Node +var fleet: Node +var field: Node +var outpost_id: int = 0 +var guard: Node2D +var component: Node +var enemy: Node2D +var stage: int = 0 +var elapsed: float = 0.0 +var orbit_min: float = INF +var orbit_max: float = 0.0 +var max_from_post: float = 0.0 +var dummy_hp_before: int = 0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.stored_vessels.clear() + game_state.vessel_deliveries.clear() + game_state.reset_progression() + game_state.reset_waves() + game_state.unlocked.append("forward_bases") + main = load("res://world/Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + field = main.get_node("GarrisonField") + + +func _fail(message: String) -> bool: + print("TEST_OUTPOST_GARRISON_PATROL: FAIL (%s)" % message) + quit(1) + return true + + +# A pinned target that neither shoots back nor dies. The point of these +# stages is where the GARRISON goes and whether it fires, and a sniper that +# killed the guard mid-check, or a chaser that died to it, would end the +# observation before the thing being measured had happened. +func _dummy(at: Vector2) -> Node2D: + var dummy: Node2D = load("res://enemies/Enemy.tscn").instantiate() + dummy.enemy_id = "chaser" + dummy.position = at + main.add_child(dummy) + dummy.speed = 0.0 + dummy.hp = 100000 + return dummy + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + # The fleet is parked a long way off, so nothing here is the + # fleet's doing. + fleet.spawn_ships(1, Vector2(-9000.0, 0.0), + PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + fleet.ships[0].move_target = fleet.ships[0].position + + main.get_node("BuildingField").place(GameState.BUILDING_OUTPOST, Vector2.ZERO) + outpost_id = int(game_state.buildings[-1].id) + game_state.set_garrison_desired(outpost_id, GameState.VESSEL_INTERCEPTOR, 1) + game_state.garrison_crew(outpost_id).append(GameState.VESSEL_INTERCEPTOR) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + # --- A. a real vessel, and not a fleet one ------------------- + var here: Array = field.ships_at(outpost_id) + if here.size() != 1: + return _fail("a crewed outpost put %d vessels in the world" % here.size()) + guard = here[0] + component = guard.get_component("garrison") + if component == null: + return _fail("the garrison vessel has no garrison component steering it") + if guard in fleet.ships: + return _fail("the garrison vessel joined the fleet") + if fleet.ships.size() != 1: + return _fail("stationing a vessel changed the fleet to %d ships" % fleet.ships.size()) + print("outpost crewed with 1 vessel, fleet still %d and untouched" % fleet.ships.size()) + elapsed = 0.0 + stage = 2 + return false + 2: + # --- B. it circles the 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: + 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]) + + # --- D (first half). an enemy beyond the leash is ignored ---- + enemy = _dummy(Vector2(component.leash_range + component.engage_distance + 900.0, 0.0)) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 1.0: + return false + if component.is_engaging(): + return _fail("the garrison went after an enemy well beyond its leash") + print("an enemy %.0fpx out is left alone" % enemy.global_position.length()) + enemy.queue_free() + + # --- C. an enemy in reach is engaged and shot at ------------- + enemy = _dummy(Vector2(300.0, 0.0)) + dummy_hp_before = enemy.hp + max_from_post = 0.0 + elapsed = 0.0 + stage = 4 + return false + 4: + if not is_instance_valid(guard): + return _fail("the garrison vessel was destroyed mid-engagement") + max_from_post = maxf(max_from_post, guard.global_position.length()) + if elapsed < 2.5: + return false + if not component.is_engaging(): + return _fail("an enemy 300px from the outpost was not engaged") + # Damage taken, not projectiles in flight: a bolt that has already + # connected has freed itself, so counting live ones at an instant + # reads zero exactly when the gun is working best. + if enemy.hp >= dummy_hp_before: + return _fail("the garrison engaged but the enemy took no damage") + # --- D (second half). the leash holds during a fight --------- + if max_from_post > component.leash_range + 60.0: + return _fail("the fight pulled it %.0fpx from its post, past a %.0fpx leash" \ + % [max_from_post, component.leash_range]) + print("engaged, took %d hp off it, and stayed within %.0fpx of its post" \ + % [dummy_hp_before - enemy.hp, max_from_post]) + + # --- G. and it can be fought back --------------------------- + var targets: Script = load("res://enemies/EnemyTargets.gd") + var picked: Node2D = targets.nearest_ship(main, enemy.global_position) + if picked != guard: + return _fail("an enemy beside the garrison picks %s instead of it - it cannot be fought back" \ + % ("nothing" if picked == null else str(picked.name))) + print("enemies see the garrison as a target too") + + if is_instance_valid(enemy): + enemy.queue_free() + elapsed = 0.0 + stage = 5 + return false + 5: + # --- E. back to the patrol ---------------------------------- + if elapsed < 1.5: + return false + if component.is_engaging(): + return _fail("the garrison is still fighting an enemy that is gone") + print("stood down and went back to its patrol") + + # --- F. a lost vessel frees its berth ------------------------ + if game_state.garrison_crew(outpost_id).size() != 1: + return _fail("the crew is not 1 before the kill") + guard.take_damage(1) + elapsed = 0.0 + stage = 6 + return false + 6: + if elapsed < 0.3: + return false + if not game_state.garrison_crew(outpost_id).is_empty(): + return _fail("a destroyed garrison vessel still holds its berth") + if game_state.garrison_desired_of(outpost_id, GameState.VESSEL_INTERCEPTOR) != 1: + return _fail("losing the vessel cancelled the order as well") + if field.ships_at(outpost_id).size() != 0: + return _fail("the dead vessel is still stationed") + print("losing the vessel frees the berth and leaves the order standing") + + print("TEST_OUTPOST_GARRISON_PATROL: PASS") + quit(0) + return true + return false diff --git a/vessels/GarrisonField.gd b/vessels/GarrisonField.gd new file mode 100644 index 0000000..a29e530 --- /dev/null +++ b/vessels/GarrisonField.gd @@ -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) diff --git a/vessels/VesselGarrisonComponent.gd b/vessels/VesselGarrisonComponent.gd new file mode 100644 index 0000000..d910ed3 --- /dev/null +++ b/vessels/VesselGarrisonComponent.gd @@ -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 diff --git a/world/Main.tscn b/world/Main.tscn index adc866b..31b6244 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -22,6 +22,7 @@ [ext_resource type="Script" path="res://ui/SkillTreeView.gd" id="21"] [ext_resource type="Script" path="res://ui/WaveAlert.gd" id="32"] [ext_resource type="Script" path="res://ui/GarrisonPanel.gd" id="33"] +[ext_resource type="Script" path="res://vessels/GarrisonField.gd" id="34"] [ext_resource type="Script" path="res://logistics/TransporterField.gd" id="23"] [ext_resource type="Script" path="res://ui/ResourceBar.gd" id="24"] [ext_resource type="Script" path="res://logistics/VesselDeliveryField.gd" id="26"] @@ -90,6 +91,9 @@ script = ExtResource("11") [node name="BuildingField" type="Node2D" parent="."] script = ExtResource("14") +[node name="GarrisonField" type="Node2D" parent="."] +script = ExtResource("34") + [node name="TransporterField" type="Node2D" parent="."] script = ExtResource("23")