Click to fire. Every bullet is a brand-new copy of a saved scene, created the instant you press the button.
Loading the game engine. This takes a moment the first time.
This lesson builds new nodes while the game runs. Every bullet is a fresh copy of bullet.tscn, made the instant you click.
■ UI node · ■ 2D node · % unique name · .tscn instanced scene
extends Node2D
# How many targets are in this level. Counted in "_ready()" below.
var total_targets: int = 0
func _ready() -> void:
# "get_children()" hands back every node sitting directly under this one.
# We walk that list and pick out only the targets.
for child in get_children():
if child is EnemyTarget:
total_targets += 1
# "destroyed" is a LOCAL signal, written inside enemy_target.gd.
# We can connect to it because each target is our own child, so
# we can reach it. game_events.gd explains when to use which.
child.destroyed.connect(_on_target_destroyed)
# HUD is our child too, so we skip signals and just call its function.
%HUD.set_total(total_targets)
# Runs when any one target emits "destroyed".
func _on_target_destroyed() -> void:
# hud.gd connects to "target_destroyed" and moves the counter up.
GameEvents.target_destroyed.emit()extends Area2D
## How fast the bullet flies.
@export var speed: float = 500.0
## How many seconds the bullet lives before erasing itself.
@export var lifetime: float = 2.0
func _ready() -> void:
# Bullets that miss would fly on forever and slowly clog the game up.
# "create_timer()" makes a one-time timer, and we hand it "queue_free"
# so the bullet erases itself when the time is up.
var timer: SceneTreeTimer = get_tree().create_timer(lifetime)
timer.timeout.connect(queue_free)
body_entered.connect(_on_body_entered)
func _physics_process(delta: float) -> void:
# "Vector2.RIGHT" is an arrow pointing right. ".rotated(rotation)" turns
# that arrow to match the way the bullet is facing. Multiplying by speed
# and delta gives exactly how far to travel this frame.
position += Vector2.RIGHT.rotated(rotation) * speed * delta
# "body" is whatever the bullet ran into.
func _on_body_entered(body: Node2D) -> void:
if body is EnemyTarget:
# "body_entered" already handed us the target as "body", so we just
# call a function on it. No signal needed.
body.take_hit()
# Erase the bullet no matter WHAT it hit, even a wall.
# This line sits outside the "if" on purpose.
queue_free()class_name EnemyTarget
extends StaticBody2D
## How many hits this target survives.
@export var health: int = 3
# "destroyed" is a LOCAL signal. We wrote it right here, so it belongs to
# this one target. It is NOT in game_events.gd and does not need to be:
# shooting_bullets.gd is our parent, so it can reach every target and
# connect to each one. game_events.gd explains when to use which kind.
signal destroyed
## Called BY bullet.gd, not by anything in this file.
func take_hit() -> void:
health -= 1
if health <= 0:
# Emit FIRST, then erase. Erasing first would kill this target
# before the message got out, and the score would never move.
destroyed.emit()
queue_free()extends CanvasLayer
# How many targets are broken so far.
var targets_destroyed: int = 0
# How many there were to begin with. shooting_bullets.gd fills this in.
var total_targets: int = 0
@onready var score_label: Label = %ScoreLabel
func _ready() -> void:
# shooting_bullets.gd emits "target_destroyed" each time one breaks.
GameEvents.target_destroyed.connect(_on_target_destroyed)
%HomeButton.pressed.connect(_on_home_pressed)
_update_label()
# Loads the lesson list screen, which replaces this whole scene.
func _on_home_pressed() -> void:
get_tree().change_scene_to_file("res://shared/ui/lesson_select.tscn")
## Called BY shooting_bullets.gd with no signal at all. It can do that
## because this HUD is its child, so it can reach us with "%HUD".
func set_total(value: int) -> void:
total_targets = value
_update_label()
func _on_target_destroyed() -> void:
targets_destroyed += 1
_update_label()
if targets_destroyed >= total_targets:
score_label.text = "All targets destroyed!"
# Its own function because three functions above all need it.
func _update_label() -> void:
# Two blank spots this time, so the numbers go in a list: [first, second]
score_label.text = "Targets: %d / %d" % [targets_destroyed, total_targets]extends CharacterBody2D
## How fast the player moves.
@export var speed: float = 200.0
## Which scene to copy for each bullet. Set to bullet.tscn in Godot.
@export var bullet_scene: PackedScene
## How many seconds to wait between shots.
@export var fire_rate: float = 0.3
# True when the gun is loaded. Goes false the instant we fire.
var _can_shoot: bool = true
@onready var shoot_timer: Timer = %ShootTimer
# Muzzle is an empty node parked at the tip of the gun, so bullets start
# there instead of in the middle of the player.
@onready var muzzle: Marker2D = %Muzzle
func _ready() -> void:
shoot_timer.wait_time = fire_rate
# When the timer runs out, "_on_shoot_timer_timeout" reloads the gun.
shoot_timer.timeout.connect(_on_shoot_timer_timeout)
func _physics_process(_delta: float) -> void:
# Move with WASD.
var direction: Vector2 = Input.get_vector(
"move_left", "move_right", "move_up", "move_down"
)
velocity = direction * speed
move_and_slide()
# Turn to face the mouse, the same as the Mouse Aiming lesson.
look_at(get_global_mouse_position())
# Aiming left would turn the picture upside down, so mirror it back.
$Body.flip_v = abs(rotation) > PI / 2
# "is_action_pressed" stays true the whole time you HOLD the button, so
# you can hold to keep firing. "_can_shoot" is the only thing stopping
# it from making 60 bullets a second.
if Input.is_action_pressed("shoot") and _can_shoot:
_shoot()
# Makes one bullet. The "_" in front means only this file uses it.
func _shoot() -> void:
# Nobody picked a scene in Godot, so there is nothing to copy.
if bullet_scene == null:
return
# Lock the gun, then start the countdown that unlocks it again.
_can_shoot = false
shoot_timer.start()
# "instantiate()" makes one real copy of bullet.tscn.
var bullet: Node2D = bullet_scene.instantiate()
# Start it at the gun tip, pointed the way the player is facing.
bullet.global_position = muzzle.global_position
bullet.rotation = rotation
# Add the bullet to the LEVEL, not to the player. If it were our child,
# it would swing around with us every time we turned.
get_tree().current_scene.add_child(bullet)
# Runs when the shoot timer finishes. Loads the gun again.
func _on_shoot_timer_timeout() -> void:
_can_shoot = true