Skip to content

Instantly share code, notes, and snippets.

@glinesbdev
Created July 20, 2026 01:42
Show Gist options
  • Select an option

  • Save glinesbdev/89f25d64add38dd8dcf0b6379361cccd to your computer and use it in GitHub Desktop.

Select an option

Save glinesbdev/89f25d64add38dd8dcf0b6379361cccd to your computer and use it in GitHub Desktop.
Godot 4 Character Creator - Deck Building Tutorial
[gd_scene format=3 uid="uid://ivlw4ncmevxs"]
[ext_resource type="Theme" uid="uid://c744t38i4smat" path="res://editor_tools_theme.tres" id="1_5if5c"]
[node name="CharacterCreatorDrawer" type="ScrollContainer" unique_id=440571070]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme = ExtResource("1_5if5c")
horizontal_scroll_mode = 4
[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=1957873731]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="MarginContainer" type="MarginContainer" parent="VBoxContainer" unique_id=106395072]
layout_mode = 2
size_flags_vertical = 3
theme_override_constants/margin_left = 5
theme_override_constants/margin_top = 5
theme_override_constants/margin_right = 5
theme_override_constants/margin_bottom = 5
[node name="MainContainer" type="VBoxContainer" parent="VBoxContainer/MarginContainer" unique_id=1016908542]
layout_mode = 2
[node name="TitleLabel" type="Label" parent="VBoxContainer/MarginContainer/MainContainer" unique_id=1666338652]
layout_mode = 2
text = "Character Creator"
horizontal_alignment = 1
vertical_alignment = 1
[node name="HSeparator" type="HSeparator" parent="VBoxContainer/MarginContainer/MainContainer" unique_id=756342745]
layout_mode = 2
[node name="CharacterStatsContainer" type="VBoxContainer" parent="VBoxContainer/MarginContainer/MainContainer" unique_id=1874111179]
unique_name_in_owner = true
layout_mode = 2
[node name="Spacer" type="Control" parent="VBoxContainer/MarginContainer/MainContainer" unique_id=301660757]
custom_minimum_size = Vector2(0, 10)
layout_mode = 2
[node name="AddNewButton" type="Button" parent="VBoxContainer/MarginContainer/MainContainer" unique_id=747396700]
unique_name_in_owner = true
custom_minimum_size = Vector2(20, 10)
layout_mode = 2
size_flags_horizontal = 0
text = "Add New"
[node name="CreateButton" type="Button" parent="VBoxContainer/MarginContainer/MainContainer" unique_id=164779730]
unique_name_in_owner = true
custom_minimum_size = Vector2(40, 10)
layout_mode = 2
size_flags_horizontal = 4
size_flags_vertical = 10
text = "Create"
[node name="DeleteCharacterConfirmation" type="ConfirmationDialog" parent="." unique_id=1475399647]
unique_name_in_owner = true
oversampling_override = 1.0
title = "Delete Character"
initial_position = 4
size = Vector2i(214, 100)
ok_button_text = "Yes"
dialog_text = "Are you sure you want to delete {character_name}?"
cancel_button_text = "No"
@tool
extends EditorPlugin
enum RESOURCE_TYPE {CARD_PILE, CHARACTER_STATS}
const CHARACTER_DRAWER_SCENE = preload("res://addons/character_creator_drawer/character_creator_drawer.tscn")
const CHARACTER_STATS_ROW = preload("res://addons/character_creator_drawer/character_stats_row.tscn")
var instance: Control
var character_stats_container: Control
var add_new_button: Button
var create_button: Button
var delete_character_confirmation: ConfirmationDialog
var errors: Array[String] = []
var added_characters: Array[CharacterStatsRow] = []
var file_dialog: FileDialog
func _disable_plugin() -> void:
if instance:
errors.clear()
for stats in added_characters:
if stats.delete_button.pressed.is_connected(show_delete_popup):
stats.delete_button.pressed.disconnect(show_delete_popup)
stats.queue_free()
added_characters.clear()
remove_control_from_bottom_panel(instance)
instance.queue_free()
func _enter_tree() -> void:
create_file_dialog()
instance = CHARACTER_DRAWER_SCENE.instantiate()
add_control_to_bottom_panel(instance, "Character Creator")
character_stats_container = instance.get_node("%CharacterStatsContainer")
add_new_button = instance.get_node("%AddNewButton")
create_button = instance.get_node("%CreateButton")
delete_character_confirmation = instance.get_node("%DeleteCharacterConfirmation")
add_new_button.pressed.connect(add_new_character)
create_button.pressed.connect(submit_form)
add_new_character()
func _exit_tree() -> void:
if add_new_button and add_new_button.pressed.is_connected(add_new_character):
add_new_button.pressed.disconnect(add_new_character)
if create_button and create_button.pressed.is_connected(submit_form):
create_button.pressed.disconnect(submit_form)
for stats in added_characters:
if stats.delete_button.pressed.is_connected(show_delete_popup):
stats.delete_button.pressed.disconnect(show_delete_popup)
func create_file_dialog() -> void:
if not file_dialog:
file_dialog = FileDialog.new()
file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
file_dialog.add_filter("*.png, *.jpg")
func add_new_character() -> void:
var stats: CharacterStatsRow = CHARACTER_STATS_ROW.instantiate() as CharacterStatsRow
character_stats_container.add_child(stats)
added_characters.append(stats)
stats.delete_button.pressed.connect(show_delete_popup.bind(stats))
stats.art_button.pressed.connect(func():
instance.add_child(file_dialog)
file_dialog.popup_centered_ratio(0.5)
)
file_dialog.file_selected.connect(func(path: String): update_character_art.call(path, stats))
func submit_form() -> void:
errors.clear()
if not validate_form():
print_errors()
return
for index in added_characters.size():
var stats := added_characters[index]
create_character(stats)
reset_form()
func validate_form() -> bool:
var processed_names: Array[String] = []
for stats in added_characters:
var character_name = stats.character_name()
if character_name in processed_names:
errors.append("Duplicate character name found: `%s`. Each row must be unique." % character_name)
else:
processed_names.append(character_name)
if stats.character_name().is_empty():
errors.append("Character name cannot be empty")
return errors.is_empty()
func reset_form() -> void:
for stats in added_characters:
stats.queue_free()
added_characters.clear()
errors.clear()
refresh_file_system()
add_new_character()
func create_character(stats: CharacterStatsRow) -> void:
var character_name := stats.character_name()
var path := "res://characters/%s" % character_name
var dir := create_character_dir(path)
if not dir:
print("Directory `%s` couldn't be created" % path)
return
create_resources(dir.get_current_dir(), stats, character_name)
print_errors()
func create_character_dir(path: String) -> DirAccess:
if not DirAccess.dir_exists_absolute(path):
DirAccess.make_dir_recursive_absolute(path + "/cards")
return DirAccess.open(path)
func create_resources(path: String, stats: CharacterStatsRow, character_name: String) -> void:
var deck_name := "{0}_starting_deck".format([character_name])
var deck_path := "{0}/{1}.tres".format([path, deck_name])
var stats_path := "{0}/{1}.tres".format([path, character_name])
var card_pile := CardPile.new()
card_pile.resource_name = deck_name
card_pile.cards.resize(stats.starting_deck_count())
if ResourceSaver.save(card_pile, deck_path) != OK:
errors.append("Couldn't create starting deck for `{0}`".format([character_name]))
return
var char_stats := CharacterStats.new()
char_stats.art = stats.art_button.texture_normal
char_stats.max_mana = stats.max_mana()
char_stats.max_health = stats.max_health()
char_stats.cards_per_turn = stats.cards_per_turn()
char_stats.starting_deck = ResourceLoader.load(deck_path)
if ResourceSaver.save(char_stats, stats_path) != OK:
errors.append("Couldn't create character stats for `{0}`".format([character_name]))
func show_delete_popup(stats: CharacterStatsRow) -> void:
var cancel_delete: Callable = func():
delete_character_confirmation.dialog_text = "Are you sure you want to delete {character_name}?"
delete_character_confirmation.dialog_text = delete_character_confirmation.dialog_text.format({"character_name": stats.character_name()})
if delete_character_confirmation.confirmed.is_connected(delete_character):
delete_character_confirmation.confirmed.disconnect(delete_character)
if delete_character_confirmation.canceled.is_connected(cancel_delete):
delete_character_confirmation.canceled.disconnect(cancel_delete)
delete_character_confirmation.confirmed.connect(delete_character.bind(stats))
delete_character_confirmation.canceled.connect(cancel_delete)
delete_character_confirmation.show()
func delete_character(stats: CharacterStatsRow) -> void:
var index := added_characters.find(stats)
if index == -1: return
if added_characters.size() == 1:
reset_form()
return
added_characters.remove_at(index)
stats.queue_free()
func update_character_art(path: String, stats: CharacterStatsRow) -> void:
stats.art_button.texture_normal = load(path)
func print_errors() -> void:
for error in errors:
push_error(error)
func refresh_file_system() -> void:
if Engine.is_editor_hint():
var fs = EditorInterface.get_resource_filesystem()
if not fs.is_scanning():
fs.scan()
@tool
class_name CharacterStatsRow
extends HBoxContainer
@onready var art_button: TextureButton = $ArtGroup/ArtButton
@onready var name_line_edit: LineEdit = $NameGroup/NameLineEdit
@onready var starting_deck_count_spin_box: SpinBox = $StartingDeckCountGroup/StartingDeckCountSpinBox
@onready var max_mana_spin_box: SpinBox = $MaxManaGroup/MaxManaSpinBox
@onready var max_health_spin_box: SpinBox = $MaxHealthGroup/MaxHealthSpinBox
@onready var cards_per_turn_spin_box: SpinBox = $CardsPerTurnGroup/CardsPerTurnSpinBox
@onready var delete_button: Button = $DeleteGroup/DeleteButton
func character_name() -> String:
var name := name_line_edit.text.strip_edges()
return name if name else "unnamed"
func starting_deck_count() -> int:
return int(starting_deck_count_spin_box.get_line_edit().text.strip_edges())
func max_mana() -> int:
return int(max_mana_spin_box.get_line_edit().text.strip_edges())
func max_health() -> int:
return int(max_health_spin_box.get_line_edit().text.strip_edges())
func cards_per_turn() -> int:
return int(cards_per_turn_spin_box.get_line_edit().text.strip_edges())
func clear_fields() -> void:
name_line_edit.text = ""
starting_deck_count_spin_box.get_line_edit().text = ""
max_mana_spin_box.get_line_edit().text = ""
max_health_spin_box.get_line_edit().text = ""
cards_per_turn_spin_box.get_line_edit().text = ""
[gd_scene format=3 uid="uid://bc5m32xq54tju"]
[ext_resource type="Script" uid="uid://j020l4368jfi" path="res://addons/character_creator_drawer/character_stats_row.gd" id="1_422uy"]
[ext_resource type="Texture2D" uid="uid://cpydvk8mq66o5" path="res://art/tile_0087.png" id="1_w0b54"]
[sub_resource type="Theme" id="Theme_ti64l"]
[node name="CharacterStatsRow" type="HBoxContainer" unique_id=427058106]
script = ExtResource("1_422uy")
[node name="ArtGroup" type="VBoxContainer" parent="." unique_id=67371683]
layout_mode = 2
[node name="ArtLabel" type="Label" parent="ArtGroup" unique_id=120658590]
layout_mode = 2
text = "Art"
[node name="ArtButton" type="TextureButton" parent="ArtGroup" unique_id=1297880089]
custom_minimum_size = Vector2(40, 40)
layout_mode = 2
tooltip_text = "Art for character"
texture_normal = ExtResource("1_w0b54")
ignore_texture_size = true
stretch_mode = 5
[node name="NameGroup" type="VBoxContainer" parent="." unique_id=914715586]
layout_mode = 2
size_flags_horizontal = 3
[node name="NameLabel" type="Label" parent="NameGroup" unique_id=1094818735]
layout_mode = 2
text = "Name"
[node name="NameLineEdit" type="LineEdit" parent="NameGroup" unique_id=238075225]
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
theme = SubResource("Theme_ti64l")
select_all_on_focus = true
[node name="StartingDeckCountGroup" type="VBoxContainer" parent="." unique_id=1085978918]
layout_mode = 2
size_flags_horizontal = 3
[node name="StartingDeckLabel" type="Label" parent="StartingDeckCountGroup" unique_id=1481403146]
layout_mode = 2
text = "Starting Deck Amount"
[node name="StartingDeckCountSpinBox" type="SpinBox" parent="StartingDeckCountGroup" unique_id=940492767]
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
select_all_on_focus = true
[node name="MaxManaGroup" type="VBoxContainer" parent="." unique_id=1157395756]
layout_mode = 2
size_flags_horizontal = 3
[node name="MaxManaLabel" type="Label" parent="MaxManaGroup" unique_id=1403376699]
layout_mode = 2
text = "Mana"
[node name="MaxManaSpinBox" type="SpinBox" parent="MaxManaGroup" unique_id=1368201307]
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
theme = SubResource("Theme_ti64l")
select_all_on_focus = true
[node name="MaxHealthGroup" type="VBoxContainer" parent="." unique_id=1183418993]
layout_mode = 2
size_flags_horizontal = 3
[node name="MaxHealthLabel" type="Label" parent="MaxHealthGroup" unique_id=1890281232]
layout_mode = 2
text = "Max Health"
[node name="MaxHealthSpinBox" type="SpinBox" parent="MaxHealthGroup" unique_id=71402952]
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
select_all_on_focus = true
[node name="CardsPerTurnGroup" type="VBoxContainer" parent="." unique_id=1779224932]
layout_mode = 2
size_flags_horizontal = 3
[node name="CardsPerTurnLabel" type="Label" parent="CardsPerTurnGroup" unique_id=1649013645]
layout_mode = 2
text = "Cards Per Turn"
[node name="CardsPerTurnSpinBox" type="SpinBox" parent="CardsPerTurnGroup" unique_id=68314896]
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
select_all_on_focus = true
[node name="DeleteGroup" type="VBoxContainer" parent="." unique_id=1198990069]
layout_mode = 2
size_flags_vertical = 4
[node name="Spacer" type="Control" parent="DeleteGroup" unique_id=2082687789]
custom_minimum_size = Vector2(0, 30)
layout_mode = 2
[node name="DeleteButton" type="Button" parent="DeleteGroup" unique_id=2036493588]
custom_minimum_size = Vector2(20, 30)
layout_mode = 2
theme_override_font_sizes/font_size = 16
text = "X"
[plugin]
name="CharacterCreator"
description="Drawer to create a new character"
author=""
version=""
script="character_creator_editor_script.gd"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment