CS 470 — Week 10: 3D Tower Defense (2/3)
We can spawn enemies. Now we need to defend.
Place towers, kill enemies, earn money, win the game.
IDLE → TARGETING → ATTACKING
Towers cycle through states, finding enemies and attacking automatically.
Earn money on kills, spend on towers
Every decision has cost. Can't place everything everywhere.
Five increments. Each one builds on the previous. Let's start with the tower base class.
Towers cycle through states, finding and targeting enemies.
State machine: IDLE (scan for enemies) → TARGETING (lock on, rotate) → ATTACKING (deal damage, cooldown).
Area3D detects enemies in range. RangeIndicator shows tower's reach (will be used in L28).
extends Node3D
enum State { IDLE, TARGETING, ATTACKING }
var current_state: State = State.IDLE
var current_target: PathFollow3D = null
var time_since_last_attack: float = 0.0
# Stats (override in child classes)
var tower_damage: int = 10
var tower_range: float = 8.0
var attack_cooldown: float = 1.0
func _process(delta: float) -> void:
time_since_last_attack += delta
match current_state:
State.IDLE:
_find_target()
if current_target:
_change_state(State.TARGETING)
State.TARGETING:
if not _is_target_valid():
current_target = null
_change_state(State.IDLE)
return
look_at(current_target.global_position, Vector3.UP)
if time_since_last_attack >= attack_cooldown:
_change_state(State.ATTACKING)
State.ATTACKING:
_perform_attack()
time_since_last_attack = 0.0
_change_state(State.TARGETING)
@onready var detection_area: Area3D = $DetectionArea
func _find_target() -> void:
var bodies = detection_area.get_overlapping_bodies()
if bodies.is_empty():
return
var closest_enemy: PathFollow3D = null
var closest_distance := INF
for body in bodies:
if body is PathFollow3D and body.has_method("take_damage"):
var dist := global_position.distance_to(body.global_position)
if dist < closest_distance:
closest_distance = dist
closest_enemy = body
current_target = closest_enemy
func _is_target_valid() -> bool:
if not current_target or not is_instance_valid(current_target):
return false
var distance := global_position.distance_to(
current_target.global_position
)
return distance <= tower_range
func _perform_attack() -> void:
# Override in TowerInstant and TowerProjectile
pass
const COLOR_IDLE := Color(0.2, 0.4, 0.8) # Blue
const COLOR_TARGETING := Color(0.8, 0.6, 0.2) # Orange
const COLOR_ATTACKING := Color(0.8, 0.2, 0.2) # Red
func _change_state(new_state: State) -> void:
current_state = new_state
_update_color()
func _update_color() -> void:
var color: Color
match current_state:
State.IDLE: color = COLOR_IDLE
State.TARGETING: color = COLOR_TARGETING
State.ATTACKING: color = COLOR_ATTACKING
var material := StandardMaterial3D.new()
material.albedo_color = color
mesh_instance.set_surface_override_material(0, material)
Tower deals instant damage with a visual beam effect.
Fast attacks, short range, cheap cost.
extends "res://scripts/Tower.gd"
func _ready() -> void:
tower_cost = 100
tower_damage = 15
tower_range = 8.0
attack_cooldown = 1.0
super._ready() # Call parent _ready
func _perform_attack() -> void:
if not current_target:
return
# Deal instant damage
current_target.take_damage(tower_damage)
# Visual feedback
_create_beam_effect()
func _create_beam_effect() -> void:
var cylinder := CylinderMesh.new()
cylinder.height = global_position.distance_to(
current_target.global_position
)
cylinder.top_radius = 0.05
cylinder.bottom_radius = 0.05
var line := MeshInstance3D.new()
line.mesh = cylinder
line.global_position = global_position.lerp(
current_target.global_position, 0.5
)
line.look_at(current_target.global_position, Vector3.UP)
line.rotate_object_local(Vector3.RIGHT, PI / 2)
var material := StandardMaterial3D.new()
material.albedo_color = Color.YELLOW
material.emission_enabled = true
material.emission = Color.YELLOW
line.set_surface_override_material(0, material)
get_tree().root.add_child(line)
await get_tree().create_timer(0.1).timeout
line.queue_free()
Manually place tower_instant.tscn in World. Run F5.
Tower should turn orange when enemy gets close, flash red when shooting, yellow beam appears.
Tower spawns projectiles that fly toward enemies.
Longer range, slower cooldown, projectiles can miss if enemy dies.
extends "res://scripts/Tower.gd"
var projectile_scene := preload("res://scenes/projectile.tscn")
func _ready() -> void:
tower_cost = 150
tower_damage = 10
tower_range = 12.0
attack_cooldown = 1.5
super._ready()
func _perform_attack() -> void:
if not current_target:
return
var projectile := projectile_scene.instantiate()
get_tree().root.add_child(projectile)
projectile.global_position = global_position + Vector3.UP
projectile.target = current_target
projectile.damage = tower_damage
Simple sphere with glowing material. Script handles movement.
extends Node3D
const SPEED := 15.0
const MAX_LIFETIME := 5.0
var target: Node3D = null
var damage: int = 10
var lifetime: float = 0.0
func _process(delta: float) -> void:
lifetime += delta
if lifetime > MAX_LIFETIME:
queue_free()
return
if not target or not is_instance_valid(target):
queue_free()
return
var direction := global_position.direction_to(
target.global_position
)
global_position += direction * SPEED * delta
look_at(target.global_position, Vector3.UP)
if global_position.distance_to(target.global_position) < 0.5:
target.take_damage(damage)
queue_free()
Place tower_projectile.tscn in World. Orange bullets fly toward enemies.
Watch bullets track moving enemies and hit them.
Press 1 or 2 to select tower type. Click to place on grid.
Preview follows mouse. Green = valid, red = occupied. Snap to 2×2m grid.
const GRID_SIZE := 2.0
var occupied_cells: Array[Vector2i] = []
var selected_tower_type: int = 0 # 0 = instant, 1 = projectile
var preview_tower: Node3D = null
var tower_instant_scene := preload("res://scenes/tower_instant.tscn")
var tower_projectile_scene := preload("res://scenes/tower_projectile.tscn")
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed:
if event.keycode == KEY_1:
selected_tower_type = 0
elif event.keycode == KEY_2:
selected_tower_type = 1
@onready var camera: Camera3D = $Camera3D
func _get_world_position_from_mouse() -> Vector3:
var mouse_pos := get_viewport().get_mouse_position()
var ray_origin := camera.project_ray_origin(mouse_pos)
var ray_normal := camera.project_ray_normal(mouse_pos)
var ray_end := ray_origin + ray_normal * 1000
var space_state := get_world_3d().direct_space_state
var query := PhysicsRayQueryParameters3D.create(
ray_origin, ray_end
)
var result := space_state.intersect_ray(query)
if result:
return result.position
return Vector3.ZERO
func _get_grid_cell(world_pos: Vector3) -> Vector2i:
return Vector2i(
roundi(world_pos.x / GRID_SIZE),
roundi(world_pos.z / GRID_SIZE)
)
func _cell_to_world_pos(cell: Vector2i) -> Vector3:
return Vector3(
cell.x * GRID_SIZE,
0.0,
cell.y * GRID_SIZE
)
func _process(_delta: float) -> void:
if selected_tower_type >= 0:
_update_tower_preview()
func _update_tower_preview() -> void:
if preview_tower:
preview_tower.queue_free()
var scene := tower_instant_scene if selected_tower_type == 0 \
else tower_projectile_scene
preview_tower = scene.instantiate()
preview_tower.is_preview = true # Disable AI
add_child(preview_tower)
var world_pos := _get_world_position_from_mouse()
var cell := _get_grid_cell(world_pos)
preview_tower.position = _cell_to_world_pos(cell)
# Set transparency
preview_tower.modulate = Color(1, 1, 1, 0.5)
func _can_place_tower(cell: Vector2i) -> bool:
return not (cell in occupied_cells)
func _input(event: InputEvent) -> void:
# ... (tower selection from earlier)
if event is InputEventMouseButton:
if event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_place_selected_tower()
func _place_selected_tower() -> void:
var world_pos := _get_world_position_from_mouse()
var cell := _get_grid_cell(world_pos)
if not _can_place_tower(cell):
return
var scene := tower_instant_scene if selected_tower_type == 0 \
else tower_projectile_scene
var tower := scene.instantiate()
add_child(tower)
tower.position = _cell_to_world_pos(cell)
occupied_cells.append(cell)
Press 1. Preview appears. Click. Tower places and starts attacking.
Try placing multiple towers. Watch them work together.
Start with $500. Earn money when enemies die. Spend money to place towers.
Can't place towers you can't afford. Forces strategic choices.
const START_MONEY := 500
const KILL_REWARD_SLIME := 25
const KILL_REWARD_GOBLIN := 40
const KILL_REWARD_ORC := 75
var money: int = START_MONEY
@onready var money_label: Label = $UI/MoneyLabel
signal died(enemy_type: String)
func _die() -> void:
died.emit(enemy_type)
queue_free()
func _spawn_enemy(enemy_type: String) -> void:
var enemy := enemy_scene.instantiate()
enemy_path.add_child(enemy)
enemy.enemy_type = enemy_type
enemy.initialize()
enemy.reached_end.connect(_on_enemy_reached_end)
enemy.died.connect(_on_enemy_died) # New connection
func _on_enemy_died(enemy_type: String) -> void:
# Award money based on type
match enemy_type:
"slime": money += KILL_REWARD_SLIME
"goblin": money += KILL_REWARD_GOBLIN
"orc": money += KILL_REWARD_ORC
enemies_remaining_in_wave -= 1
_check_wave_complete()
_update_ui()
func _can_place_tower(cell: Vector2i, cost: int) -> bool:
if money < cost:
return false
if cell in occupied_cells:
return false
return true
func _place_selected_tower() -> void:
var world_pos := _get_world_position_from_mouse()
var cell := _get_grid_cell(world_pos)
var cost := 100 if selected_tower_type == 0 else 150
if not _can_place_tower(cell, cost):
return
money -= cost
# ... (instantiate and place tower)
_update_ui()
func _update_ui() -> void:
hp_label.text = "HP: %d" % player_hp
wave_label.text = "Wave: %d / %d" % [current_wave, waves.size()]
money_label.text = "Money: $%d" % money
Complete game! Place towers, kill enemies, earn money, survive 5 waves.
Try running out of money. Try different tower placements.
Show subtle grid lines so players see cell boundaries.
extends Node3D
const GRID_SIZE := 2.0
func _ready() -> void:
# Draw vertical lines (x-axis)
for x in range(-10, 11):
_draw_line(
Vector3(x * GRID_SIZE, 0.01, -15),
Vector3(x * GRID_SIZE, 0.01, 15)
)
# Draw horizontal lines (z-axis)
for z in range(-7, 8):
_draw_line(
Vector3(-20, 0.01, z * GRID_SIZE),
Vector3(20, 0.01, z * GRID_SIZE)
)
func _draw_line(from: Vector3, to: Vector3) -> void:
# Create thin line mesh
var mesh := ImmediateMesh.new()
var material := StandardMaterial3D.new()
material.albedo_color = Color(0.5, 0.5, 0.5, 0.3)
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
# ... (line generation code)