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 # #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 # 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 _engaging: bool = false var _wander_to: Vector2 = Vector2.ZERO var _leg_time: float = 0.0 var _pause_left: float = 0.0 func setup() -> void: _pick_leg() 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: 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: # 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 # 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) # 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