Your abilities are cards. Play a hand of three to cross the room, open the chest, and pick one of the three cards it offers to join your deck for every hand after.
Loading the game engine. This takes a moment the first time.
This is a sampler, not a build lesson: play it before you read it. Same warrior as always, but put your arrow keys away. In a deck builder your abilities are CARDS, and the whole style grew out of paper card games.
Each button press runs one function: _walk(), _jump() or _break_wall(). An illegal move keeps the card in your hand and spends nothing. A legal one sends the card to the discard pile and refills the hand from the deck. The buttons themselves are rebuilt from the hand array after every play, exactly the way the lesson menu builds its buttons from the lesson list.
None of the earlier lessons use these. Each one shows the line where it appears here.
Puts a list in a random order, like shuffling a deck.
deck.shuffle()Removes the item at that spot in a list.
hand.remove_at(index)Adds every item from another list onto the end of this one.
discard.append_array(hand)Leaves the loop straight away.
breakReplaces everything in a typed list with the items from another list.
pool.assign(CARDS.keys())A list of every name stored in a dictionary.
pool.assign(CARDS.keys())A piece of a list, from one spot up to, but not including, another.
choices.assign(pool.slice(0, 3))The smallest a UI node is allowed to shrink to.
button.custom_minimum_size = Vector2(150, 90)Whether a button can be selected with the keyboard. FOCUS_NONE turns that off.
button.focus_mode = Control.FOCUS_NONEA copy of a list, so changing the copy leaves the original alone.
deck = discard.duplicate()Takes the first item off a list and hands it back.
hand.append(deck.pop_front())■ UI node · ■ 2D node · % unique name · .tscn instanced scene
extends Node2D
const TILE: int = 80
const HAND_SIZE: int = 3
# The rooms, as little maps: "." floor, "#" wall, "~" gap, "C" chest.
const ROOMS: Array[String] = [
".....~...C",
"..#.~..#.C",
".~.#..~.#C"
]
const CARDS := {
"move2": {"title": "Move 2", "info": "Walk forward two tiles."},
"move3": {"title": "Move 3", "info": "Walk forward three tiles."},
"jump": {"title": "Jump", "info": "Leap over the tile ahead."},
"break": {"title": "Break", "info": "Smash the wall ahead."}
}
# Where the row of tiles starts, so ten 80-pixel tiles sit centered on the
# 1152 x 648 screen.
const BOARD_ORIGIN := Vector2(176, 320)
var deck: Array[String] = ["move2", "move2", "move2", "jump", "break"]
var discard: Array[String] = []
var hand: Array[String] = []
# The current room, one cell per entry, copied from ROOMS so Break can
# actually change it.
var cells: Array[String] = []
var player_col: int = 0
var room_index: int = 0
# The three cards the open chest is offering. Empty means no chest is open.
var choices: Array[String] = []
@onready var board: Node2D = $Board
@onready var grid_player: Sprite2D = $Player
@onready var hand_bar: HBoxContainer = %HandBar
@onready var choice_bar: HBoxContainer = %ChoiceBar
@onready var piles_label: Label = %PilesLabel
@onready var message_label: Label = %MessageLabel
@onready var home_button: Button = %HomeButton
func _ready() -> void:
home_button.pressed.connect(_on_home_pressed)
deck.shuffle()
_load_room(0)
_draw_up()
_rebuild_hand_bar()
func _on_card_pressed(index: int) -> void:
var card: String = hand[index]
var result: String = ""
match card:
"move2":
result = _walk(2)
"move3":
result = _walk(3)
"jump":
result = _jump()
"break":
result = _break_wall()
# An illegal play costs nothing: the card never leaves the hand.
if result == "illegal":
return
discard.append(card)
hand.remove_at(index)
if result == "fell":
# The fall costs the REST of the hand too, straight to the discard
# pile. Harsh, but it also un-sticks a hand with no useful card.
discard.append_array(hand)
hand.clear()
if result == "chest":
# The chest interrupts play: the hand waits, greyed out, while you
# pick your prize. _on_choice_pressed() picks the game back up.
_draw_up()
_rebuild_hand_bar()
_offer_cards()
return
_draw_up()
_rebuild_hand_bar()
# Walk forward one tile at a time. A wall ends the walk early; a gap ends
# the run entirely.
func _walk(steps: int) -> String:
for i: int in steps:
var next := player_col + 1
if next >= cells.size() or cells[next] == "#":
_say("A wall stops you.")
break
player_col = next
if cells[next] == "~":
_place_player()
_say("You fell in the gap! Back to the start, new hand.")
player_col = 0
_place_player()
return "fell"
if cells[next] == "C":
_place_player()
return "chest"
_place_player()
return "ok"
# Leap over the tile ahead and land two tiles on, walls included. The
# landing tile is the only one that matters.
func _jump() -> String:
var landing := player_col + 2
if landing >= cells.size() or cells[landing] == "#":
_say("Nowhere to land. The card stays in your hand.")
return "illegal"
player_col = landing
_place_player()
if cells[landing] == "~":
_say("You leapt straight into the gap! Back to the start, new hand.")
player_col = 0
_place_player()
return "fell"
if cells[landing] == "C":
return "chest"
return "ok"
func _break_wall() -> String:
var target := player_col + 1
if target >= cells.size() or cells[target] != "#":
_say("No wall ahead. The card stays in your hand.")
return "illegal"
cells[target] = "."
_redraw_board()
return "ok"
# The chest is a DRAFT, not a gift: it deals three different cards face
# up, and only the one you press joins your deck. The buttons are built
# exactly like the hand's, just wired to _on_choice_pressed() instead.
func _offer_cards() -> void:
var pool: Array[String] = []
pool.assign(CARDS.keys())
pool.shuffle()
choices.assign(pool.slice(0, 3))
for i: int in choices.size():
var card: String = choices[i]
var button := Button.new()
button.text = "%s\n%s" % [CARDS[card]["title"], CARDS[card]["info"]]
button.custom_minimum_size = Vector2(150, 90)
button.focus_mode = Control.FOCUS_NONE
button.pressed.connect(_on_choice_pressed.bind(i))
choice_bar.add_child(button)
choice_bar.show()
# Grey the hand out so choosing is the only move left.
for child: Node in hand_bar.get_children():
if child is Button:
child.disabled = true
# Straight onto the label with no timer: the prompt should stay up
# until the choice is made.
message_label.text = "The chest opens! Pick ONE card to add to your deck."
func _on_choice_pressed(index: int) -> void:
var card: String = choices[index]
choices.clear()
for child: Node in choice_bar.get_children():
child.queue_free()
choice_bar.hide()
# Into the DISCARD pile, not the hand: new cards join the cycle and
# turn up a little later, like every deck builder does it.
discard.append(card)
_say("The %s card joins your deck!" % CARDS[card]["title"])
_load_room((room_index + 1) % ROOMS.size())
_draw_up()
_rebuild_hand_bar()
# Refill the hand to three. When the deck runs dry, the discard pile is
# shuffled and BECOMES the deck. That loop is why every card you gain
# keeps coming back, and it is the engine of the whole style.
func _draw_up() -> void:
while hand.size() < HAND_SIZE:
if deck.is_empty():
deck = discard.duplicate()
deck.shuffle()
discard.clear()
if deck.is_empty():
return
hand.append(deck.pop_front())
# Throw the old buttons away and build one per card in the hand: make it,
# fill it in, add_child(). The same three steps the lesson menu uses for
# its buttons and the Enemy Spawner uses for enemies.
func _rebuild_hand_bar() -> void:
for child: Node in hand_bar.get_children():
child.queue_free()
for i: int in hand.size():
var card: String = hand[i]
var button := Button.new()
button.text = "%s\n%s" % [CARDS[card]["title"], CARDS[card]["info"]]
button.custom_minimum_size = Vector2(150, 90)
button.focus_mode = Control.FOCUS_NONE
# ".bind(i)" glues this slot number onto the press, so the handler
# knows which card was chosen.
button.pressed.connect(_on_card_pressed.bind(i))
hand_bar.add_child(button)
piles_label.text = "Deck: %d Discard: %d" % [deck.size(), discard.size()]
func _load_room(index: int) -> void:
room_index = index
cells.clear()
for i: int in ROOMS[index].length():
cells.append(ROOMS[index][i])
player_col = 0
_redraw_board()
_place_player()
func _redraw_board() -> void:
for child: Node in board.get_children():
child.queue_free()
for col: int in cells.size():
var x := BOARD_ORIGIN.x + col * TILE
match cells[col]:
".":
_make_rect(x + 2, BOARD_ORIGIN.y + 2, TILE - 4, Color(0.16, 0.16, 0.2))
"#":
# Walls stand a full tile taller than the floor.
_make_rect(x + 2, BOARD_ORIGIN.y - TILE, TILE - 4, Color(0.4, 0.4, 0.4), TILE * 2)
"~":
_make_rect(x + 2, BOARD_ORIGIN.y + 2, TILE - 4, Color(0.03, 0.03, 0.05))
"C":
_make_rect(x + 2, BOARD_ORIGIN.y + 2, TILE - 4, Color(0.16, 0.16, 0.2))
_make_rect(x + 20, BOARD_ORIGIN.y + 20, TILE - 40, Color(1, 0.85, 0))
func _make_rect(x: float, y: float, width: float, color: Color, height: float = -1.0) -> void:
var rect := ColorRect.new()
rect.color = color
rect.position = Vector2(x, y)
rect.size = Vector2(width, width if height < 0.0 else height)
board.add_child(rect)
func _place_player() -> void:
grid_player.position = Vector2(
BOARD_ORIGIN.x + (player_col + 0.5) * TILE, BOARD_ORIGIN.y + TILE * 0.5
)
func _say(text: String) -> void:
message_label.text = text
var timer: SceneTreeTimer = get_tree().create_timer(2.5)
timer.timeout.connect(func() -> void:
if message_label.text == text:
message_label.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")