Lesson 32

Tower Defense

You are not in this one. Spend gold on towers beside the path, then watch them hold five waves without you touching anything.

Loading the game engine. This takes a moment the first time.

Play it first, then read this. Look for the warrior. There isn't one. You are not in this game, and that is the entire style: you spend, you place, and the things you placed do the fighting.

WHAT TO NOTICE WHILE YOU PLAY

NO PLAYER
Nothing on screen is you. There is no health bar with your name on it, and no key that moves anything.
THE PATH
The creeps never chase you and never turn around. They walk the same line every wave, so the level is not a place to survive, it is a shape to cover.
AUTO FIRE
A tower shoots on its own the moment something walks into its circle. You chose where. It chose when.
THE CHOICE
Late on you can afford one more tower OR one upgrade, never both. Spreading covers more path, upgrading kills harder in one spot, and gold left over at the end means you should have spent it.

THE ONE PAD THAT IS WORTH THREE

Click the pad in the middle, at the elbow of the path. Its circle touches three different stretches of the walk, so a creep crosses it three separate times. The pads are all the same price, which means the price is not what makes them different. The map is.

HOW A CREEP KNOWS WHERE TO GO

PATH is a plain list of corners. A creep holds a number saying which corner it is heading for, walks straight at it, and when it arrives it adds one to that number. Move a corner in the list and every creep ever spawned walks the new route, because none of them ever knew the route in the first place.

New code in this lesson

None of the earlier lessons use these. Each one shows the line where it appears here.

as

Tells Godot to treat a value as a certain type, like child as Label, so you can use what that type has.

game.gd
var click := event as InputEventMouseButton

Line2D

Draws a line through its points. add_point() adds one more point to the end.

game.gd
var line := Line2D.new()

add_to_group()

Puts a node in a group from code. remove_from_group() takes it back out.

enemy.gd
add_to_group("creep")

move_toward()

Moves a value toward a target by at most a set step, and never past it.

enemy.gd
position = position.move_toward(target, speed * delta)

get_nodes_in_group()

A list of every node in the game that is in that group.

tower.gd
for node: Node in get_tree().get_nodes_in_group("creep"):

str()

Turns a value into text, so it can be joined onto other words with +.

tower.gd
level_label.text = str(level)

lerp()

A value part of the way between two others. a.lerp(b, 0.5) is halfway from a to b.

tower.gd
body.color = Color(0.35, 0.6, 0.9).lerp(Color(1.0, 0.85, 0.3), (level - 1) / 2.0)

to_local()

Turns a spot in the game world into a spot measured from this node.

tower.gd
zap.points = PackedVector2Array([Vector2.ZERO, to_local(target_position)])

polygon

The list of corner points a Polygon2D joins up and fills in.

tower.gd
range_ring.polygon = points

The scenes

■ UI node · ■ 2D node · % unique name · .tscn instanced scene

game.tscn
  • Game Node2D game.gd
  • PathLine Node2D
  • Pads Node2D
  • Towers Node2D
  • Creeps Node2D
  • HUD CanvasLayer hud.tscn
enemy.tscn
  • Enemy Node2D enemy.gd
  • Body Polygon2D
  • HealthBar Node2D
  • Back ColorRect
  • Fill ColorRect
hud.tscn
  • HUD CanvasLayer hud.gd
  • Readouts HBoxContainer
  • GoldLabel Label %
  • LivesLabel Label %
  • WaveLabel Label %
  • MessageLabel Label %
  • PriceLabel Label %
tower.tscn
  • Tower Node2D tower.gd
  • RangeRing Polygon2D
  • Body Polygon2D
  • LevelLabel Label
  • Zap Line2D hidden

FileSystem

  • game.tscn
  • game.gd
  • enemy.tscn
  • enemy.gd
  • hud.tscn
  • hud.gd
  • tower.tscn
  • tower.gd

The code

extends Node2D

# The route, corner by corner. Everything about where the creeps go lives
# in this one list, off the left edge of the screen and off the right.
const PATH: Array[Vector2] = [
	Vector2(-40, 130),
	Vector2(330, 130),
	Vector2(330, 380),
	Vector2(760, 380),
	Vector2(760, 150),
	Vector2(1240, 150)
]

# The six places a tower is allowed to stand. Same price, very different
# value: see the note about the elbow pad up top.
const PADS: Array[Vector2] = [
	Vector2(150, 270),
	Vector2(450, 250),
	Vector2(560, 560),
	Vector2(610, 240),
	Vector2(900, 330),
	Vector2(1080, 330)
]

const TOWER_PRICE: int = 20
const UPGRADE_PRICE: int = 15
const MAX_TOWER_LEVEL: int = 3
## How far a tower can reach. This number decides which pads are good.
@export var tower_range: float = 190.0

const GOLD_PER_KILL: int = 4
const GOLD_PER_WAVE: int = 15
const START_GOLD: int = 60
const START_LIVES: int = 5
const WAVE_COUNT: int = 5
const BREAK_SECONDS: float = 4.0

const CREEP := preload("res://lessons/towerdefense_sampler/enemy.tscn")
const TOWER := preload("res://lessons/towerdefense_sampler/tower.tscn")

var gold: int = START_GOLD
var lives: int = START_LIVES
var wave: int = 0

# One slot per pad, in the same order as PADS. Null means the pad is empty.
var towers: Array[Node2D] = []

var live_creeps: int = 0
var spawns_left: int = 0
var spawn_timer: float = 0.0
var break_timer: float = 0.0
var game_over: bool = false

@onready var pad_layer: Node2D = $Pads
@onready var tower_layer: Node2D = $Towers
@onready var creep_layer: Node2D = $Creeps
@onready var path_line: Node2D = $PathLine
@onready var hud: TowerHUD = $HUD

# One square per pad, so a built pad can be recoloured without hunting for
# it. Same order as PADS again.
var pad_squares: Array[ColorRect] = []

func _ready() -> void:
	_draw_path()
	_build_pads()
	towers.resize(PADS.size())
	_refresh_hud()
	break_timer = 2.0

func _physics_process(delta: float) -> void:
	if game_over:
		return

	# Between waves. The countdown is the only thing running.
	if spawns_left == 0 and live_creeps == 0:
		break_timer -= delta
		hud.set_message("Wave %d in %.0f" % [wave + 1, ceilf(break_timer)])
		if break_timer <= 0.0:
			_start_wave()
		return

	if spawns_left > 0:
		spawn_timer -= delta
		if spawn_timer <= 0.0:
			_spawn_creep()
			spawn_timer = 0.75

func _start_wave() -> void:
	wave += 1
	if wave > WAVE_COUNT:
		_finish(true)
		return
	# Each wave is longer, tougher and faster than the last. Three numbers,
	# one line each, and that is the whole difficulty curve.
	spawns_left = 3 + wave * 2
	spawn_timer = 0.0
	hud.set_message("Wave %d of %d" % [wave, WAVE_COUNT])
	_refresh_hud()

# Make it, fill it in, add_child(). The level connects to the new creep's
# own two signals right here, because it is holding the creep: no bus
# needed. See the rule at the top of game_events.gd.
func _spawn_creep() -> void:
	spawns_left -= 1
	var creep: Enemy = CREEP.instantiate()
	creep.path = PATH
	creep.speed = 95.0 + wave * 8.0
	creep.max_health = 5 + wave * 10
	creep.died.connect(_on_creep_died)
	creep.leaked.connect(_on_creep_leaked)
	creep_layer.add_child(creep)
	live_creeps += 1

func _on_creep_died() -> void:
	live_creeps -= 1
	gold += GOLD_PER_KILL
	_wave_bookkeeping()

func _on_creep_leaked() -> void:
	live_creeps -= 1
	lives -= 1
	if lives <= 0:
		lives = 0
		_refresh_hud()
		_finish(false)
		return
	_wave_bookkeeping()

func _wave_bookkeeping() -> void:
	if spawns_left == 0 and live_creeps == 0 and not game_over:
		gold += GOLD_PER_WAVE
		break_timer = BREAK_SECONDS
	_refresh_hud()

# A click is just a position. Find the pad it landed on, then decide
# whether that is a build or an upgrade.
func _unhandled_input(event: InputEvent) -> void:
	if game_over and event.is_action_pressed("jump"):
		get_tree().reload_current_scene()
		return
	if not event is InputEventMouseButton:
		return
	var click := event as InputEventMouseButton
	if not click.pressed or click.button_index != MOUSE_BUTTON_LEFT:
		return

	var index := _pad_at(get_global_mouse_position())
	if index == -1:
		return
	if towers[index] == null:
		_build_tower(index)
	else:
		_upgrade_tower(index)

func _pad_at(point: Vector2) -> int:
	for i: int in PADS.size():
		if point.distance_to(PADS[i]) <= 46.0:
			return i
	return -1

func _build_tower(index: int) -> void:
	if gold < TOWER_PRICE:
		hud.set_message("Not enough gold. A tower is %d." % TOWER_PRICE)
		return
	gold -= TOWER_PRICE
	var tower: Tower = TOWER.instantiate()
	tower.position = PADS[index]
	tower.attack_range = tower_range
	tower_layer.add_child(tower)
	towers[index] = tower
	pad_squares[index].color = Color(0.2, 0.34, 0.28)
	_refresh_hud()

func _upgrade_tower(index: int) -> void:
	var tower := towers[index] as Tower
	if tower.level >= MAX_TOWER_LEVEL:
		hud.set_message("That tower is already at level %d." % MAX_TOWER_LEVEL)
		return
	if gold < UPGRADE_PRICE:
		hud.set_message("Not enough gold. An upgrade is %d." % UPGRADE_PRICE)
		return
	gold -= UPGRADE_PRICE
	tower.upgrade()
	_refresh_hud()

func _finish(won: bool) -> void:
	# Several creeps can leak on the same frame, so the last life can be
	# lost more than once. First one through wins.
	if game_over:
		return
	game_over = true
	if won:
		hud.set_message("All %d waves held. Press Space to play again." % WAVE_COUNT)
	else:
		hud.set_message("Too many leaks on wave %d. Press Space to try again." % wave)

func _refresh_hud() -> void:
	hud.set_stats(gold, lives, wave, WAVE_COUNT, TOWER_PRICE, UPGRADE_PRICE)

# The path is drawn from the same list the creeps walk, so the picture can
# never disagree with the route.
func _draw_path() -> void:
	var line := Line2D.new()
	for point: Vector2 in PATH:
		line.add_point(point)
	line.width = 46.0
	line.default_color = Color(0.17, 0.17, 0.22)
	line.joint_mode = Line2D.LINE_JOINT_ROUND
	line.begin_cap_mode = Line2D.LINE_CAP_ROUND
	line.end_cap_mode = Line2D.LINE_CAP_ROUND
	path_line.add_child(line)

func _build_pads() -> void:
	for pad: Vector2 in PADS:
		var square := ColorRect.new()
		square.color = Color(0.24, 0.24, 0.3)
		square.size = Vector2(64, 64)
		square.position = pad - Vector2(32, 32)
		pad_layer.add_child(square)
		pad_squares.append(square)