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.
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.
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.
None of the earlier lessons use these. Each one shows the line where it appears here.
Tells Godot to treat a value as a certain type, like child as Label, so you can use what that type has.
var click := event as InputEventMouseButtonDraws a line through its points. add_point() adds one more point to the end.
var line := Line2D.new()Puts a node in a group from code. remove_from_group() takes it back out.
add_to_group("creep")Moves a value toward a target by at most a set step, and never past it.
position = position.move_toward(target, speed * delta)A list of every node in the game that is in that group.
for node: Node in get_tree().get_nodes_in_group("creep"):Turns a value into text, so it can be joined onto other words with +.
level_label.text = str(level)A value part of the way between two others. a.lerp(b, 0.5) is halfway from a to b.
body.color = Color(0.35, 0.6, 0.9).lerp(Color(1.0, 0.85, 0.3), (level - 1) / 2.0)Turns a spot in the game world into a spot measured from this node.
zap.points = PackedVector2Array([Vector2.ZERO, to_local(target_position)])The list of corner points a Polygon2D joins up and fills in.
range_ring.polygon = points■ UI node · ■ 2D node · % unique name · .tscn instanced scene
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)class_name Enemy
extends Node2D
## Sent when a tower finishes this creep off. The level pays gold for it.
signal died
## Sent when this creep survives the whole path. The level takes a life.
signal leaked
# All three are filled in by game.gd the moment it makes
# this creep, which is how one scene covers five different waves.
var path: Array[Vector2] = []
var speed: float = 110.0
var max_health: int = 9
var health: int = 9
var target_index: int = 1
# True once this creep has died or leaked. queue_free() does not take
# effect until the end of the frame, so without this flag two towers
# firing on the same frame would both finish off the same creep and the
# level would count two kills for one enemy.
var _finished: bool = false
@onready var body: Polygon2D = $Body
@onready var health_fill: ColorRect = $HealthBar/Fill
func _ready() -> void:
health = max_health
# The towers find their targets by looking up this group, so a creep
# that forgets to join it is invisible and invincible.
add_to_group("creep")
if not path.is_empty():
position = path[0]
func _physics_process(delta: float) -> void:
if _finished:
return
var target := path[target_index]
# move_toward walks at most "speed * delta" pixels and never overshoots,
# so arriving is exactly "we are on it now".
position = position.move_toward(target, speed * delta)
if position.distance_to(target) < 1.0:
target_index += 1
if target_index >= path.size():
_finished = true
remove_from_group("creep")
leaked.emit()
queue_free()
## Called BY tower.gd when a shot lands.
func take_damage(amount: int) -> void:
if _finished:
return
health -= amount
health_fill.size.x = 32.0 * (float(health) / float(max_health))
if health <= 0:
_finished = true
# Leaving the group here rather than waiting for queue_free() also
# stops every other tower wasting its next shot on a corpse.
remove_from_group("creep")
died.emit()
queue_free()class_name TowerHUD
extends CanvasLayer
@onready var gold_label: Label = %GoldLabel
@onready var lives_label: Label = %LivesLabel
@onready var wave_label: Label = %WaveLabel
@onready var price_label: Label = %PriceLabel
@onready var message_label: Label = %MessageLabel
@onready var home_button: Button = %HomeButton
func _ready() -> void:
home_button.pressed.connect(_on_home_pressed)
## Called BY game.gd whenever any of these change.
func set_stats(
gold: int, lives: int, wave: int, wave_count: int, tower_price: int, upgrade_price: int
) -> void:
gold_label.text = "%d gold" % gold
lives_label.text = "%d lives" % lives
wave_label.text = "Wave %d of %d" % [maxi(wave, 1), wave_count]
price_label.text = "Click an empty pad to build (%d) · click your tower to upgrade (%d)" % [
tower_price, upgrade_price
]
## The one line under the readouts. It carries the wave countdown during a
## break, so it is set every frame rather than flashed and cleared.
func set_message(text: String) -> void:
message_label.text = text
# 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 Tower
extends Node2D
## How far this tower can reach. The level sets it from one shared number.
var attack_range: float = 190.0
## Level 1 towers deal this much per shot. Each upgrade adds three more.
var damage: int = 3
var level: int = 1
const FIRE_SECONDS: float = 0.4
var cooldown: float = 0.0
@onready var zap: Line2D = $Zap
@onready var body: Polygon2D = $Body
@onready var level_label: Label = $LevelLabel
@onready var range_ring: Polygon2D = $RangeRing
func _ready() -> void:
_draw_range_ring()
_refresh_look()
func _physics_process(delta: float) -> void:
cooldown -= delta
if cooldown > 0.0:
return
var target := _nearest_creep()
if target == null:
return
cooldown = FIRE_SECONDS
target.take_damage(damage)
_show_zap(target.global_position)
# Closest creep inside the circle, or null if the coast is clear.
func _nearest_creep() -> Enemy:
var best: Enemy = null
var best_distance := attack_range
for node: Node in get_tree().get_nodes_in_group("creep"):
var creep := node as Enemy
var distance := global_position.distance_to(creep.global_position)
if distance <= best_distance:
best = creep
best_distance = distance
return best
## Called BY game.gd when you click a pad you already own.
func upgrade() -> void:
level += 1
damage += 3
_refresh_look()
func _refresh_look() -> void:
level_label.text = str(level)
# Brighter with every upgrade, so the map shows you where your money
# went without you having to click anything.
body.color = Color(0.35, 0.6, 0.9).lerp(Color(1.0, 0.85, 0.3), (level - 1) / 2.0)
# The shot is instant. This line is only the evidence, shown for a moment
# so you can see which creep the tower picked.
func _show_zap(target_position: Vector2) -> void:
zap.points = PackedVector2Array([Vector2.ZERO, to_local(target_position)])
zap.visible = true
var timer: SceneTreeTimer = get_tree().create_timer(0.08)
timer.timeout.connect(func() -> void: zap.visible = false)
# A circle has no corners, so a Polygon2D needs one made for it: 28 points
# spaced evenly around the middle. Enough that nobody can count them.
func _draw_range_ring() -> void:
var points := PackedVector2Array()
for i: int in 28:
var angle := TAU * i / 28.0
points.append(Vector2(cos(angle), sin(angle)) * attack_range)
range_ring.polygon = points