class_name EnemySpawnerComponent extends Node # Continuously spawns another enemy type beside its host, at a fixed # interval, capped at max_alive concurrently. The spawned enemies are # added as children of the spawner's OWN PARENT - not the spawner itself - # so killing the spawner never frees its already-spawned children (they # are siblings, not descendants) and so a surface-hosted spawner keeps its # spawns under the same host a `../Fleet`-style path assumption would miss # (see #44's note on why that hazard exists for anything parented under a # planet surface's PlanetSetupHost rather than directly under Main.tscn). var capability_id: String = "spawner" @export var spawn_interval: float = 4.0 @export var spawned_enemy_id: String = "chaser" @export var max_alive: int = 6 @export var spawn_radius: float = 90.0 var spawn_timer: float = 0.0 var spawned: Array = [] # Resolved lazily via load() on first use rather than preload()d as a # compile-time constant: when a definition's component_scenes chain leads # BACK to Enemy.tscn while it is still mid-load (e.g. two different preset # scenes of this same script both live on one definition, as the # mothership's chaser/artillery spawners are), the preloaded constant can # get permanently baked in as a broken, empty PackedScene - confirmed by # ResourceLoader.has_cached() being true (a good copy exists) while the # preloaded reference's own resource_path was empty. load() at first real # use instead always resolves against whatever is actually in the cache # by then. var enemy_scene: PackedScene = null func _ready() -> void: # Start counting down from a full interval rather than firing on the # very first tick: besides reading more naturally (a freshly-placed # spawner doesn't insta-spawn), this keeps two sibling spawners with # different intervals (e.g. the mothership's chaser/artillery presets) # from both landing their first attempt on the exact same physics # frame - which hit a transient PackedScene.instantiate() failure on # this engine build when it happened. spawn_timer = spawn_interval func tick(delta: float) -> void: spawned = spawned.filter(func(node): return is_instance_valid(node)) if spawned.size() >= max_alive: return spawn_timer -= delta if spawn_timer > 0.0: return spawn_timer = spawn_interval var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") if fleet == null or fleet.ships.is_empty(): return var spawner: Node2D = get_parent() var host: Node = spawner.get_parent() if host == null: return var angle: float = randf() * TAU var offset: Vector2 = Vector2(cos(angle), sin(angle)) * spawn_radius if enemy_scene == null: enemy_scene = load("res://enemies/Enemy.tscn") var new_enemy: Node2D = enemy_scene.instantiate() new_enemy.enemy_id = spawned_enemy_id # spawner.position is already expressed in `host`'s local space, and # new_enemy is about to be added to that same `host` - unlike # EnemyRangedWeaponComponent's projectile, which reparents to root and # needs global_position, there is no space conversion needed here. new_enemy.position = spawner.position + offset host.add_child(new_enemy) spawned.append(new_enemy)