Build new enemies on a timer while the game is already running, instead of placing them by hand in the editor.
Loading the game engine. This takes a moment the first time.
Every lesson before this one used nodes you placed by hand in Godot. This one builds brand new nodes while the game is already running.
■ UI node · ■ 2D node · % unique name · .tscn instanced scene
extends Node2D
## Which scene to copy each time we spawn. Set to spawn_enemy.tscn in Godot.
@export var enemy_scene: PackedScene
## How many seconds to wait between spawns.
@export var spawn_interval: float = 1.5
## The most enemies allowed to be alive at the same time.
@export var max_enemies: int = 10
# How many enemies are alive right now.
var _enemy_count: int = 0
@onready var spawn_timer: Timer = %SpawnTimer
@onready var player: SpawnerPlayer = %SpawnerPlayer
func _ready() -> void:
spawn_timer.wait_time = spawn_interval
# "timeout" is a signal every Timer sends when it reaches zero. We put
# "_on_spawn_timer_timeout" inside ".connect()". That is the function
# Godot runs each time the timer finishes.
spawn_timer.timeout.connect(_on_spawn_timer_timeout)
# A Timer sits still until you start it.
spawn_timer.start()
func _on_spawn_timer_timeout() -> void:
# Already at the limit, so skip this one. "return" leaves early.
if _enemy_count >= max_enemies:
return
# Nobody picked a scene in Godot, so there is nothing to copy.
if enemy_scene == null:
return
# "instantiate()" makes one real copy of the saved scene. The copy exists
# now, but it is NOT in the game until "add_child()" near the bottom.
var enemy: SpawnEnemy = enemy_scene.instantiate()
# Pick one of the four screen edges at random.
# "randi()" gives a random whole number, and "% 4" chops it down to
# 0, 1, 2, or 3. "match" then runs only the line that matches.
var side: int = randi() % 4
match side:
0: enemy.global_position = Vector2(randf_range(50, 1100), 10)
1: enemy.global_position = Vector2(randf_range(50, 1100), 638)
2: enemy.global_position = Vector2(10, randf_range(50, 600))
3: enemy.global_position = Vector2(1140, randf_range(50, 600))
# Aim the new enemy at wherever the player is standing right now.
enemy.direction = (player.global_position - enemy.global_position).normalized()
# "tree_exiting" is a signal a node sends right before it gets erased.
# That is how we find out to subtract one from our count.
enemy.tree_exiting.connect(_on_enemy_removed)
# NOW the enemy is really in the game and starts moving.
add_child(enemy)
_enemy_count += 1
# Runs when any enemy we made is about to be erased.
func _on_enemy_removed() -> void:
_enemy_count -= 1class_name SpawnerPlayer
extends CharacterBody2D
## How fast the player moves. "@export" puts "speed" at the top of the
## Inspector panel, so you can change it there instead of in this file.
@export var speed: float = 200.0
# "_physics_process()" runs about 60 times a second. Godot calls it for you.
func _physics_process(_delta: float) -> void:
# "Input.get_vector(...)" checks all four keys at once and hands back
# an arrow pointing where to go. The four names in quotes are ours.
# See them under Project > Project Settings > Input Map.
var direction: Vector2 = Input.get_vector(
"move_left", "move_right", "move_up", "move_down"
)
# "direction" is only WHICH WAY (its length is always 1).
# "direction * speed" stretches it, so "velocity" is also HOW FAST.
velocity = direction * speed
# Nothing moves until "move_and_slide()" runs.
# "Slide" means we slide along walls instead of sticking to them.
move_and_slide()
# The warrior picture only faces right. Walking left mirrors it.
if direction.x != 0.0:
$Sprite2D.flip_h = direction.x < 0.0class_name SpawnEnemy
extends CharacterBody2D
## How fast this enemy drifts along.
@export var speed: float = 80.0
# Which way to go. enemy_spawner.gd fills this in right after making us.
var direction: Vector2 = Vector2.ZERO
func _physics_process(delta: float) -> void:
# We change "global_position" directly instead of using
# "move_and_slide()". These enemies are meant to drift off the edge of
# the screen in a straight line, not to bump into anything on the way.
global_position += direction * speed * delta
# Once an enemy is off screen nobody can see it, but it would keep
# running forever and slowly clog the game up. "queue_free()" erases it.
if global_position.x < -50 or global_position.x > 1200:
queue_free()
if global_position.y < -50 or global_position.y > 700:
queue_free()