| sidebar_position | 13 |
|---|---|
| title | Lesson 13 · Three enemy types and the final guardian |
Reference checkpoint: node scripts/course-create.mjs --lesson 13 builds this lesson's runnable end state beside your project — compare, never overwrite. See Practice checkpoints.
There was only one spirit. For the whole 90 seconds, the same thing chased you at the same speed.
This lesson grows that to three types, and the moment the last beacon lights, the forest guardian appears.
There is still one scene.
The easy way is to copy spirit.tscn three times.
That is also what this course has been avoiding.
- Fixing hit cooldown means editing three places
- A new enemy means another scene
- Thirteen kinds means thirteen places
One scene, several settings. We use a custom resource.
class_name SpiritKind
extends Resource
@export var display_name: String = "Spirit"
@export var sheet: Texture2D = null
@export var cell: int = 16
@export var rows: int = 4
@export var columns: int = 4
@export var speed: float = 62.0
@export var turn_rate: float = 5.0
@export var body_scale: float = 1.0
@export var tint: Color = Color(0.315, 0.35, 0.57, 1)
@export var anim_fps: float = 5.0One .tres file is one enemy type.
A new enemy needs neither code nor a scene.
| Speed | Turn rate | Color | Personality | |
|---|---|---|---|---|
| Drifting spirit | 62 | 5.0 | blue light | default. Lesson 8's spirit |
| Night bat | 70 | 2.2 | deep blue | wide turns, easy to predict |
| Ember spirit | 84 | 8.5 | red light | fast and persistent |
| Forest guardian | 52 | 3.2 | violet | slow but large |
| Docs | What we take |
|---|---|
| Resources | inherit Resource to make your own type |
class_name |
expose that type in the inspector |
SpriteFrames |
build animations in code |
Through Lesson 3 we wrote AtlasTexture into the .tscn.
Four kinds would mean four of those too.
func _apply_kind() -> void:
scale = Vector2.ONE * kind.body_scale
modulate = kind.tint
speed = kind.speed
_turn_rate = kind.turn_rate
var frames: SpriteFrames = SpriteFrames.new()
frames.remove_animation(&"default")
if kind.rows == 1:
## One row means one facing: every column is a frame of the same
## look, laid out left to right along row zero.
_add_float_animation(frames, &"float_down")
for col in kind.columns:
frames.add_frame(&"float_down", _slice(col, 0))
else:
for col in kind.columns:
var anim: StringName = &"float_" + FACING_NAMES[col]
_add_float_animation(frames, anim)
for row in kind.rows:
frames.add_frame(anim, _slice(col, row))
_sprite.sprite_frames = frames
func _add_float_animation(frames: SpriteFrames, anim: StringName) -> void:
if frames.has_animation(anim):
return
frames.add_animation(anim)
frames.set_animation_loop(anim, true)
frames.set_animation_speed(anim, kind.anim_fps)
func _slice(col: int, row: int) -> AtlasTexture:
var atlas: AtlasTexture = AtlasTexture.new()
atlas.atlas = kind.sheet
atlas.region = Rect2(col * kind.cell, row * kind.cell, kind.cell, kind.cell)
return atlasThe kind also drives the gait: without the speed and turn_rate
lines, every enemy would move like the default and the table above would
be decoration.
:::warning Do not forget remove_animation(&"default")
A new SpriteFrames already contains one empty animation named default.
Leave it and an empty entry stays in the list.
:::
:::danger Only the guardian's layout is different
| Cell | Rows | Columns | Animations built | |
|---|---|---|---|---|
| Spirits | 16 | 4 (frames) | 4 (facings) | 4 facings × 4 frames |
| Guardian | 50 | 1 | 5 (frames) | 1 facing × 5 frames |
The guardian sheet is a single row, so it has one facing. The picture
is the same from every side, and its five columns are five frames of
float_down along row zero — not five facings. Reading columns as
facings here would build float_right twice and starve every animation
to a single frame.
That is why cell, rows, and columns are all settings, and why the
branch above tests rows == 1 before it slices.
Hard-code any of them and you cannot add the guardian.
:::
const SPAWN_POINTS: Array[Vector2] = [
Vector2(120, 170), Vector2(690, 170), Vector2(120, 310), Vector2(690, 310),
]
func _farthest_spawn() -> Vector2:
var best: Vector2 = SPAWN_POINTS[0]
for point in SPAWN_POINTS:
if point.distance_to(_player.position) > best.distance_to(_player.position):
best = point
return bestThey spawn at the point farthest from the player. Popping up in your face is unfair.
func materialize() -> void:
var alpha: float = modulate.a
modulate.a = 0.0
_hitbox.monitoring = false
var fade: Tween = create_tween()
fade.tween_property(self, "modulate:a", alpha, MATERIALIZE_SECONDS)
await fade.finished
if not is_inside_tree():
return
_hitbox.monitoring = trueThey cannot hit you while appearing. Getting hit by something that is
not solid yet is unfair.
Same call as turning monitoring on late for Lesson 9's moonlight gate.
const SPIRIT_KINDS: Array[String] = [
"res://resources/wisp.tres",
"res://resources/drifter.tres",
"res://resources/ember.tres",
"res://resources/ember.tres",
]
var kind_path: String = SPIRIT_KINDS[mini(_lit_count, SPIRIT_KINDS.size() - 1)]Kind is chosen by how many beacons are lit. Difficulty is paced by progress, not the clock, so skilled players get challenged sooner and lost players get challenged later.
Cap with MAX_SPIRITS = 3. A screen full of spirits is punishment, not a game.
:::danger At first you died the instant the last beacon lit Simply adding the guardian meant one guardian + three spirits charged at once. With 3 health that was not hard — it was impossible.
When the guardian appears, lesser spirits withdraw.
_prune_spirits()
for s in _spirits:
s.retreat()
_spirits.clear()
_spawn_timer = INFClearing the stage makes the last stretch one-on-one with the guardian. It also fits the story — when the master arrives, the lesser ones withdraw. :::
_bgm.stop()
_bgm.stream = GUARDIAN_THEME
_bgm.play()We use 17 - Fight. It is the opposite of the arena track (27 - Chill):
big swings and fast.
In Lesson 3 we chose the arena track as "the one with the smallest swings." Here we pick by the opposite criterion. Same ruler, other end.
One spirit was @onready var _spirit. Now they spawn and vanish.
func _prune_spirits() -> void:
_spirits = _spirits.filter(func(s: Node2D) -> bool: return is_instance_valid(s))Strip the dead ones before you use the list. The is_instance_valid()
we put in Lesson 9 pays off here.
- More spirits appear over time (max three)
- Meaner kinds appear as you light beacons
- New spirits appear from far away, gradually
- You are not hit while they appear
- Lighting the last beacon brings the guardian and lesser spirits withdraw
- The music changes
① Remove the retreat() calls
→ You die the instant the last beacon lights. We actually shipped it this
way first, then fixed it.
② Remove materialize()
→ Spirits pop in, and you get hit at that instant.
③ Use a fixed SPAWN_POINTS[0] instead of _farthest_spawn()
→ Standing top-left, they keep spawning in your face.
④ Remove mini from mini(index, kind.columns - 1)
→ The guardian dies looking for an animation that does not exist.
⑤ Set MAX_SPIRITS to 12
→ The screen is covered in spirits. Nowhere to dodge.
- [ ] Four enemy types, one scene
- [ ] One more
.tresmakes a new enemy - [ ] The guardian works even with a sheet that has no facing
- [ ] When the guardian appears, lesser spirits withdraw and the music changes
- [ ]
godot --headless --path . --importfinishes with exit code 0 and no errors in its output
-
Make one more
.tresand add a fifth enemy. Reuse theSpiritsheet and setspeedto120. Do not write a line of code. -
Raise the guardian's
body_scaleto2.0. Like_normalize_reach()on Lesson 7's beacon, the hitbox grows with it. Think about whether that is the right behavior. -
Point every entry in
SPIRIT_KINDSatember.tres. The meanest kind appears from the start. The difficulty curve disappears.