Targets keep appearing, so destroy fifteen before they crowd you out. Assembles Mouse Aiming, Shooting Bullets, and Enemy Spawner.
Loading the game engine. This takes a moment the first time.
This is a project, not a lesson: no new idea lives in this folder. Aim, shoot, and destroy fifteen targets. They keep appearing whether you keep up or not.
Timer plus instantiate(), spawning targets instead of enemies, at random spots instead of the screen edges.Each spawned target has a local "destroyed" signal, and since this file creates every target, it connects to each one right after instantiate(), the same parent-reaches-down pattern as the Shooting Bullets lesson. The count lives here; the HUD hears about it on the bus.
■ UI node · ■ 2D node · % unique name · .tscn instanced scene
extends Node2D
## Which scene to copy each time we spawn. Set to target.tscn in Godot.
@export var target_scene: PackedScene
## How many seconds to wait between spawns.
@export var spawn_interval: float = 1.1
## The most targets allowed on screen at once.
@export var max_targets: int = 6
## Destroy this many to win.
@export var targets_to_win: int = 15
var _destroyed: int = 0
var _alive: int = 0
var _game_over: bool = false
@onready var spawn_timer: Timer = %SpawnTimer
@onready var player: BlitzPlayer = $BlitzPlayer
func _ready() -> void:
spawn_timer.wait_time = spawn_interval
spawn_timer.timeout.connect(_on_spawn_timer_timeout)
spawn_timer.start()
func _on_spawn_timer_timeout() -> void:
if _game_over or _alive >= max_targets:
return
if target_scene == null:
return
var target: BlitzTarget = target_scene.instantiate()
# Anywhere in the middle of the play field, away from the edges and
# never right on top of the player's corner.
target.global_position = Vector2(randf_range(260, 1080), randf_range(90, 580))
# We made this target, so we can connect to its local signal, exactly
# how the Shooting Bullets level counts its four hand-placed targets.
target.destroyed.connect(_on_target_destroyed)
target.tree_exiting.connect(_on_target_removed)
add_child(target)
_alive += 1
func _on_target_destroyed() -> void:
if _game_over:
return
_destroyed += 1
# hud.gd connects to this bus signal and redraws the score.
GameEvents.target_destroyed.emit()
if _destroyed >= targets_to_win:
_game_over = true
spawn_timer.stop()
player.set_physics_process(false)
# hud.gd shows the win message.
GameEvents.goal_reached.emit()
_restart_soon()
func _on_target_removed() -> void:
_alive -= 1
func _restart_soon() -> void:
var timer: SceneTreeTimer = get_tree().create_timer(2.0)
timer.timeout.connect(_on_restart_timeout)
func _on_restart_timeout() -> void:
get_tree().reload_current_scene()extends Area2D
## How fast the bullet flies.
@export var speed: float = 520.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. This one-time timer erases
# them when their 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:
position += Vector2.RIGHT.rotated(rotation) * speed * delta
func _on_body_entered(body: Node2D) -> void:
# "BlitzTarget" is the class_name at the top of target.gd.
if body is BlitzTarget:
body.take_hit()
# Erase the bullet no matter what it hit, even a wall.
queue_free()extends CanvasLayer
## Keep in step with "targets_to_win" on the level. It is worth seeing that
## these are two different numbers that merely agree: the level owns the
## rule, the HUD only owns the words.
@export var targets_to_win: int = 15
var _destroyed: int = 0
@onready var score_label: Label = %ScoreLabel
@onready var result_label: Label = %ResultLabel
func _ready() -> void:
GameEvents.target_destroyed.connect(_on_target_destroyed)
GameEvents.goal_reached.connect(_on_goal_reached)
%HomeButton.pressed.connect(_on_home_pressed)
_update_label()
func _on_target_destroyed() -> void:
_destroyed += 1
_update_label()
func _on_goal_reached() -> void:
result_label.text = "You win!"
func _update_label() -> void:
score_label.text = "Targets: %d / %d" % [_destroyed, targets_to_win]
# 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")class_name BlitzPlayer
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.25
# True when the gun is loaded. Goes false the instant we fire.
var _can_shoot: bool = true
@onready var shoot_timer: Timer = %ShootTimer
@onready var muzzle: Marker2D = %Muzzle
func _ready() -> void:
shoot_timer.wait_time = fire_rate
shoot_timer.timeout.connect(_on_shoot_timer_timeout)
func _physics_process(_delta: float) -> void:
# Move with WASD: the Top-Down Movement block.
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 Mouse Aiming block.
look_at(get_global_mouse_position())
# Aiming left would turn the picture upside down, so mirror it back.
$Sprite2D.flip_v = abs(rotation) > PI / 2
# Hold to keep firing: the Shooting Bullets block.
if Input.is_action_pressed("shoot") and _can_shoot:
_shoot()
func _shoot() -> void:
if bullet_scene == null:
return
_can_shoot = false
shoot_timer.start()
var bullet: Node2D = bullet_scene.instantiate()
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)
func _on_shoot_timer_timeout() -> void:
_can_shoot = trueclass_name BlitzTarget
extends StaticBody2D
## How many hits this target survives.
@export var health: int = 1
# A LOCAL signal: it belongs to this one target. The level made us, so the
# level connects to it. game_events.gd explains when local is the right call.
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: erased nodes cannot send messages.
destroyed.emit()
queue_free()