One enemy, three phases, and no attack button. Read the warning, dodge it, then run in and touch the boss while its guard is down.
Loading the game engine. This takes a moment the first time.
Play it first, then read this. Arrow keys move, and there is no attack key at all. You hurt the boss by TOUCHING it, and only while it is gold.
In every other lesson the level holds a world and the enemies are guests in it. Here the boss IS the level: there is nothing else to reach, no exit and no coins, so its brain lives in the level script rather than off in a file of its own.
A shape and a question. _slice_attack() cuts the arena into equal strips and lights some of them up; _near_attack() lights a circle round the boss. Then, on every frame the attack is live, the level asks one question: is the player inside any lit shape? That is the entire combat system, and it is why the tell and the damage can never disagree. They are the same rectangle.
The four-strip attacks always leave a safe strip touching every deadly one, so the furthest you ever have to run is half a strip. The warning is short, but it is always long enough. A boss that can kill you before you could physically have moved is not difficult, it is broken.
None of the earlier lessons use these. Each one shows the line where it appears here.
A set of named options, like State.WAIT and State.STRIKE. Easier to read than remembering what 0, 1 and 2 meant.
enum State { WAIT, TELEGRAPH, STRIKE, RECOVER }A rectangle: a corner and a size. has_point() checks if a spot is inside it.
const ARENA := Rect2(40, 210, 1072, 410)Sets a property by its name in quotes, like node.set("color", RED).
(zone["node"] as Node).set("color", STRIKE_COLOR)Makes a tween: something that slides a value smoothly to a new one over time.
var tween := create_tween()Sets the shape of a tween's slide. TRANS_SINE eases in and out instead of moving at one speed.
tween.set_trans(Tween.TRANS_SINE)This node: the one the script is on.
tween.tween_property(self, "position:x", new_x, 0.45)Slides one property of a node, like its position, to a new value over some seconds.
tween.tween_property(self, "position:x", new_x, 0.45)How many nodes sit directly under this one. get_child(0) is the first of them.
if heart_row.get_child_count() != max_hearts:Keeps a number between a low and a high limit.
position.x = clampf(position.x, bounds.position.x, bounds.end.x)■ UI node · ■ 2D node · % unique name · .tscn instanced scene
extends Node2D
# The four states, and the boss is in exactly one of them at any moment.
enum State { WAIT, TELEGRAPH, STRIKE, RECOVER }
# The floor the fight happens on. Attacks are cut out of this rectangle,
# and the player is kept inside it.
const ARENA := Rect2(40, 210, 1072, 410)
const BOSS_MAX_HEALTH: int = 6
const PLAYER_MAX_HEARTS: int = 3
## How close you must get to land a hit during an opening.
@export var touch_range: float = 110.0
# Phase 1 timings. Every phase shortens them, see _phase_scale().
const WAIT_SECONDS: float = 0.7
const TELEGRAPH_SECONDS: float = 0.85
const STRIKE_SECONDS: float = 0.35
const RECOVER_SECONDS: float = 1.7
# The warning colour and the it-is-happening-now colour. The SHAPE never
# changes between them, only the paint.
const TELEGRAPH_COLOR := Color(0.92, 0.26, 0.28, 0.2)
const STRIKE_COLOR := Color(0.96, 0.3, 0.3, 0.62)
var state: State = State.WAIT
var state_timer: float = 0.0
var boss_health: int = BOSS_MAX_HEALTH
var fight_over: bool = false
# The shapes that are live right now. Each entry is the maths of one
# attack plus the rectangle or circle the player can see.
var zones: Array[Dictionary] = []
# Which attacks the boss is allowed to use. Phases push more onto it.
var moves: Array[String] = ["half", "near"]
var phase: int = 1
@onready var boss: Enemy = $Enemy
@onready var player: Player = $Player
@onready var zone_layer: Node2D = $Zones
@onready var hud: BossHUD = $HUD
func _ready() -> void:
_draw_arena()
# The arena is described once, here, and handed to everything that
# needs it. The player is kept inside it and the attacks are cut out
# of it, so the two can never disagree about where the floor is.
player.bounds = ARENA
boss.set_health(boss_health, BOSS_MAX_HEALTH)
hud.set_hearts(player.hearts, PLAYER_MAX_HEARTS)
hud.set_phase(phase)
hud.set_message("Dodge the red. Touch the boss while it is gold.")
_enter_state(State.WAIT)
func _physics_process(delta: float) -> void:
if fight_over:
return
state_timer -= delta
# While an attack is live, ask the one question, every frame.
if state == State.STRIKE and _player_in_a_zone():
_hurt_player()
# The opening. Touching the boss now is the only damage you can do.
if state == State.RECOVER:
if player.position.distance_to(boss.position) <= touch_range:
_hit_boss()
return
if state_timer <= 0.0:
_advance_state()
# The loop that IS the boss: wait, warn, hit, sit still, wait again.
func _advance_state() -> void:
match state:
State.WAIT:
_begin_attack()
_enter_state(State.TELEGRAPH)
State.TELEGRAPH:
# Same shapes, now solid, now deadly. A ColorRect and a
# Polygon2D both have a "color", so one line repaints either.
for zone: Dictionary in zones:
(zone["node"] as Node).set("color", STRIKE_COLOR)
_enter_state(State.STRIKE)
State.STRIKE:
_clear_zones()
boss.set_vulnerable(true)
_enter_state(State.RECOVER)
State.RECOVER:
boss.set_vulnerable(false)
_slide_boss()
_enter_state(State.WAIT)
func _enter_state(next: State) -> void:
state = next
var factor := _phase_scale()
match next:
State.WAIT:
state_timer = WAIT_SECONDS * factor
State.TELEGRAPH:
state_timer = TELEGRAPH_SECONDS * factor
State.STRIKE:
state_timer = STRIKE_SECONDS
State.RECOVER:
state_timer = RECOVER_SECONDS * factor
# Everything except the strike itself gets faster each phase. The strike
# stays put, because shortening it would not make the fight harder, only
# less readable.
func _phase_scale() -> float:
if phase >= 3:
return 0.65
if phase == 2:
return 0.82
return 1.0
func _begin_attack() -> void:
var move: String = moves[randi_range(0, moves.size() - 1)]
match move:
"half":
# Two strips, one of them deadly. The oldest attack in games.
_slice_attack(true, 2, 1)
"columns":
_slice_attack(true, 4, 2)
"rows":
_slice_attack(false, 4, 2)
"near":
_near_attack()
# Cut the arena into equal strips and light some up. "vertical" decides
# whether the strips stand up or lie down, which is the only difference
# between the columns attack and the rows attack.
func _slice_attack(vertical: bool, count: int, fire: int) -> void:
var span := ARENA.size.x if vertical else ARENA.size.y
var width := span / count
for index: int in _pick_slices(count, fire):
var rect: Rect2
if vertical:
rect = Rect2(ARENA.position.x + index * width, ARENA.position.y, width, ARENA.size.y)
else:
rect = Rect2(ARENA.position.x, ARENA.position.y + index * width, ARENA.size.x, width)
_add_rect_zone(rect)
# A circle centred on the boss. The one attack that pushes you AWAY from
# the thing you need to touch.
func _near_attack() -> void:
_add_circle_zone(boss.position, 260.0)
# Which strips fire. With two strips it is a coin flip. With four, the two
# that fire are never neighbours, so a safe strip is always next door.
func _pick_slices(count: int, fire: int) -> Array[int]:
if fire <= 1:
var single: Array[int] = [randi_range(0, count - 1)]
return single
var pairs: Array = [[0, 2], [0, 3], [1, 3]]
var pair: Array = pairs[randi_range(0, pairs.size() - 1)]
var picked: Array[int] = [int(pair[0]), int(pair[1])]
return picked
func _add_rect_zone(rect: Rect2) -> void:
var view := ColorRect.new()
view.color = TELEGRAPH_COLOR
view.position = rect.position
view.size = rect.size
zone_layer.add_child(view)
zones.append({"kind": "rect", "rect": rect, "node": view})
func _add_circle_zone(centre: Vector2, radius: float) -> void:
var view := Polygon2D.new()
var points := PackedVector2Array()
for i: int in 32:
var angle := TAU * i / 32.0
points.append(centre + Vector2(cos(angle), sin(angle)) * radius)
view.polygon = points
view.color = TELEGRAPH_COLOR
zone_layer.add_child(view)
zones.append({"kind": "circle", "centre": centre, "radius": radius, "node": view})
func _clear_zones() -> void:
for zone: Dictionary in zones:
(zone["node"] as Node).queue_free()
zones.clear()
# The one question, asked of every live shape.
func _player_in_a_zone() -> bool:
for zone: Dictionary in zones:
if zone["kind"] == "rect":
if (zone["rect"] as Rect2).has_point(player.position):
return true
elif player.position.distance_to(zone["centre"]) <= float(zone["radius"]):
return true
return false
func _hurt_player() -> void:
if not player.take_damage():
return
hud.set_hearts(player.hearts, PLAYER_MAX_HEARTS)
if player.hearts <= 0:
_finish(false)
func _hit_boss() -> void:
boss_health -= 1
boss.set_health(boss_health, BOSS_MAX_HEALTH)
boss.set_vulnerable(false)
if boss_health <= 0:
_finish(true)
return
_check_phase()
# One touch, one hit: the opening closes the instant it lands, so you
# cannot stand inside the boss and win by leaning on it.
_slide_boss()
_enter_state(State.WAIT)
# A phase change is two lines: a new move on the list, and every timer
# shortened. Same boss, new fight.
func _check_phase() -> void:
if boss_health <= 2 and phase < 3:
phase = 3
moves.append("rows")
hud.set_phase(phase)
hud.set_message("Phase 3. It is faster, and the floor is not safe either.")
elif boss_health <= 4 and phase < 2:
phase = 2
moves.append("columns")
hud.set_phase(phase)
hud.set_message("Phase 2. It learned a new attack.")
func _slide_boss() -> void:
# The opening turns up somewhere new every time, so you cannot just
# park where the boss used to be.
boss.slide_to(randf_range(320.0, 830.0))
func _finish(won: bool) -> void:
fight_over = true
_clear_zones()
player.set_physics_process(false)
if won:
hud.set_message("The boss is down. Press Space to fight it again.")
else:
hud.set_message("Out of hearts. Press Space to try again.")
func _unhandled_input(event: InputEvent) -> void:
if fight_over and event.is_action_pressed("jump"):
get_tree().reload_current_scene()
func _draw_arena() -> void:
var floor_rect := ColorRect.new()
floor_rect.color = Color(0.12, 0.13, 0.17)
floor_rect.position = ARENA.position
floor_rect.size = ARENA.size
$Floor.add_child(floor_rect)class_name Enemy
extends Node2D
const CALM_COLOR := Color(0.62, 0.28, 0.72)
const OPEN_COLOR := Color(1.0, 0.82, 0.28)
@onready var body: Polygon2D = $Body
@onready var health_fill: ColorRect = $HealthBar/Fill
@onready var eye: Polygon2D = $Eye
## Called BY game.gd at the start of the opening, and again the
## instant the opening is used up.
func set_vulnerable(open: bool) -> void:
body.color = OPEN_COLOR if open else CALM_COLOR
eye.visible = not open
## Called BY game.gd every time it loses a point of health.
func set_health(health: int, max_health: int) -> void:
health_fill.size.x = 220.0 * (float(health) / float(max_health))
## Called BY game.gd after every hit and every attack, so the
## opening never appears twice in the same place.
func slide_to(new_x: float) -> void:
var tween := create_tween()
tween.set_trans(Tween.TRANS_SINE)
tween.tween_property(self, "position:x", new_x, 0.45)class_name BossHUD
extends CanvasLayer
# Full hearts are bright, spent ones are left in place and dimmed, so you
# can see what a mistake cost as well as what is left.
const HEART_FULL := Color(0.9, 0.3, 0.35)
const HEART_SPENT := Color(0.24, 0.16, 0.19)
@onready var heart_row: HBoxContainer = %HeartRow
@onready var phase_label: Label = %PhaseLabel
@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 at the start and after every heart lost.
func set_hearts(hearts: int, max_hearts: int) -> void:
# Make the pips the first time, then only recolour them. Squares are
# used rather than a heart character because a font that is missing
# one draws an empty box instead, and this has to work everywhere.
if heart_row.get_child_count() != max_hearts:
for child: Node in heart_row.get_children():
child.queue_free()
for i: int in max_hearts:
var pip := ColorRect.new()
pip.custom_minimum_size = Vector2(26, 26)
heart_row.add_child(pip)
for i: int in heart_row.get_child_count():
var pip := heart_row.get_child(i) as ColorRect
pip.color = HEART_FULL if i < hearts else HEART_SPENT
## Called BY game.gd when the boss changes phase.
func set_phase(phase: int) -> void:
phase_label.text = "Phase %d" % phase
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 Player
extends CharacterBody2D
## How fast the player moves. Fast enough to cross half a strip inside the
## shortest warning the boss ever gives.
@export var speed: float = 280.0
var hearts: int = 3
# The floor. game.gd hands this over so the arena is described in
# exactly one place.
var bounds: Rect2 = Rect2(40, 210, 1072, 410)
var _invulnerable: bool = false
@onready var sprite: Sprite2D = $Sprite2D
func _physics_process(_delta: float) -> void:
var direction: Vector2 = Input.get_vector(
"move_left", "move_right", "move_up", "move_down"
)
velocity = direction * speed
move_and_slide()
# The arena has no walls to bump into, so the edges are done with
# maths instead. clamp() refuses to let a number leave its range.
position.x = clampf(position.x, bounds.position.x, bounds.end.x)
position.y = clampf(position.y, bounds.position.y, bounds.end.y)
# The warrior picture only faces right. Walking left mirrors it.
if direction.x != 0.0:
sprite.flip_h = direction.x < 0.0
## Called BY game.gd on every frame the player is standing in an
## attack. Returns false while the mercy window is open, so the level knows
## the hit did not count.
func take_damage() -> bool:
if _invulnerable:
return false
hearts -= 1
_invulnerable = true
modulate = Color(1, 1, 1, 0.45)
var timer: SceneTreeTimer = get_tree().create_timer(1.0)
timer.timeout.connect(func() -> void:
_invulnerable = false
modulate = Color(1, 1, 1, 1))
return true