You'll build a UI that spawns a configurable number of cards into an auto-layout container, supports tap-to-select, and adapts to portrait phone resolution. This is the base layer of Lexicon Duel's main battle screen.
1Make Card a Control-friendly scene.
Right now your Card extends Node2D. For an HBoxContainer to lay it out, it needs to extend Control. We have two options:
Control (right-click root โ Change Type). Replace the ColorRect-as-background with a PanelContainer that holds a VBoxContainer with Label children. This is the Right Way for UI-first games.Control. Lazier but works.Pick Option A. Re-save card.tscn. The script's extends Node2D needs to become extends Control.
extends Control
class_name Card
signal card_tapped(card: Card)
@export var data: CardData
@onready var letter_label: Label = $Layout/LetterLabel
@onready var value_label: Label = $Layout/ValueLabel
func _ready() -> void:
custom_minimum_size = Vector2(140, 200) # minimum tap-friendly size
if data:
letter_label.text = data.letter
value_label.text = str(data.value)
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed:
card_tapped.emit(self)
print("tapped: ", data.letter)
Scene structure inside Card:
Card (Control, custom_minimum_size = 140,200)
โโโ Layout (PanelContainer, Full Rect)
โโโ VBox (VBoxContainer)
โโโ LetterLabel (Label, big font)
โโโ ValueLabel (Label, small font, bottom-right)
2Main scene with a hand container.
Open scenes/main.tscn. Delete the Card instance from Module 1's exercise. Structure:
Main (Node2D)
โโโ UI (CanvasLayer)
โโโ Root (Control, Full Rect)
โโโ Margins (MarginContainer, Full Rect, padding 40 all sides)
โโโ HandContainer (HBoxContainer, Bottom Wide, alignment center, separation 16)
Set MarginContainer theme_override constants โ all margins = 40.
Set HandContainer alignment = Center, separation = 16.
3Spawn cards at runtime.
Create scripts/main.gd attached to the Main root. Paste:
extends Node2D
const CARD_SCENE := preload("res://scenes/card.tscn")
# Starting letters โ later these'll come from a deck resource.
const STARTING_LETTERS := [
{"letter": "L", "value": 1},
{"letter": "E", "value": 1},
{"letter": "X", "value": 8},
{"letter": "I", "value": 1},
{"letter": "C", "value": 3},
{"letter": "O", "value": 1},
{"letter": "N", "value": 1},
]
@onready var hand: HBoxContainer = $UI/Root/Margins/HandContainer
func _ready() -> void:
for entry in STARTING_LETTERS:
var data := CardData.new()
data.letter = entry.letter
data.value = entry.value
_spawn_card(data)
func _spawn_card(card_data: CardData) -> void:
var card := CARD_SCENE.instantiate()
card.data = card_data
card.card_tapped.connect(_on_card_tapped)
hand.add_child(card)
func _on_card_tapped(card: Card) -> void:
print("selected: ", card.data.letter)
4Selection visual.
In scripts/card.gd, add a toggle for selected state:
@onready var layout: PanelContainer = $Layout
var selected := false
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed:
toggle_selected()
card_tapped.emit(self)
func toggle_selected() -> void:
selected = !selected
modulate = Color(1.2, 1.2, 0.7) if selected else Color.WHITE
Selected cards tint yellow. Tap again to deselect. Simple feedback.
5Run and resize.
F5. You should see seven cards in a row across the bottom of the screen. Resize the window โ the cards stay centered along the bottom. Tap a card โ it lights up. Tap again โ deselects.
_gui_input, which is the clean, safe pattern for UI.custom_minimum_size set to 0x0. Make sure Bottom Wide anchor is applied.custom_minimum_size set โ Containers use minimum sizes to lay out children.mouse_filter = Stop or Pass. Default is Stop, which is correct.$Layout/LetterLabel but the path may differ. Use the Scene dock to get the exact path by right-clicking the label.