Phase 14 - Organized directories.

This commit is contained in:
2026-10-03 19:13:39 -04:00
parent 6666173c4f
commit d950fd9d9a
307 changed files with 1452 additions and 1110 deletions
+55
View File
@@ -0,0 +1,55 @@
class_name ContentPaths
extends RefCounted
## Canonical project-managed content directories and legacy path migration.
const STICKMEN_DIR: String = "res://content/stickmen"
const PROPS_DIR: String = "res://content/props"
const STAGES_DIR: String = "res://content/stages"
const LEGACY_DIRS: Dictionary = {
"stickmen": "content/stickmen",
"props": "content/props",
"game_scenes": "content/stages",
}
static func canonicalize(path: String) -> String:
if path.is_empty():
return path
var normalized: String = path.replace("\\", "/")
if normalized.begins_with("res://"):
var relative_path: String = normalized.substr("res://".length())
var migrated: String = _canonicalize_relative(relative_path)
return "res://" + migrated if migrated != relative_path else path
if normalized.begins_with("user://"):
return path
if normalized.is_absolute_path():
return _canonicalize_absolute(normalized, path)
var migrated_relative: String = _canonicalize_relative(normalized)
return migrated_relative if migrated_relative != normalized else path
static func _canonicalize_relative(path: String) -> String:
var normalized: String = path.simplify_path()
for legacy_dir: String in LEGACY_DIRS:
var canonical_dir: String = String(LEGACY_DIRS[legacy_dir])
if normalized == legacy_dir:
return canonical_dir
var prefix: String = legacy_dir + "/"
if normalized.begins_with(prefix):
return canonical_dir + "/" + normalized.substr(prefix.length())
return path
static func _canonicalize_absolute(path: String, original_path: String) -> String:
var project_root: String = ProjectSettings.globalize_path("res://").replace("\\", "/").trim_suffix("/")
if path == project_root:
return original_path
var project_prefix: String = project_root + "/"
if not path.begins_with(project_prefix):
return original_path
var relative_path: String = path.substr(project_prefix.length())
var migrated: String = _canonicalize_relative(relative_path)
if migrated == relative_path:
return original_path
return project_root.path_join(migrated)
+1
View File
@@ -0,0 +1 @@
uid://d4im476gdaq6j
+127
View File
@@ -0,0 +1,127 @@
class_name PropThumbnail
extends Node
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
const PROP_VISUALS := preload("res://scripts/props/prop_visuals.gd")
const SIZE := Vector2i(200, 200)
var _viewport: SubViewport
var _world: Node2D
var _camera: Camera2D
func _ready() -> void:
_viewport = SubViewport.new()
_viewport.size = SIZE
_viewport.transparent_bg = true
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
add_child(_viewport)
_world = Node2D.new()
_viewport.add_child(_world)
_camera = Camera2D.new()
_camera.enabled = true
_viewport.add_child(_camera)
_camera.make_current()
func render(payload: Dictionary, material_preset: int) -> Texture2D:
_clear_world()
var points := _compute_points(payload)
var fill := Color(payload.get("fill_color", Color.WHITE))
var outline := Color(payload.get("outline_color", Color.BLACK))
if material_preset != PROP_BLOCK.MaterialPreset.NONE:
var tint := PROP_BLOCK.tint_for(material_preset)
fill = tint
outline = tint.darkened(0.55)
var outline_width := float(payload.get("outline_width", 2.0))
var visual := Node2D.new()
var poly := Polygon2D.new()
poly.polygon = points
poly.color = fill
var line := Line2D.new()
var loop := points.duplicate()
if not loop.is_empty():
loop.append(points[0])
line.points = loop
line.default_color = outline
line.width = outline_width
line.joint_mode = Line2D.LINE_JOINT_ROUND
line.begin_cap_mode = Line2D.LINE_CAP_ROUND
line.end_cap_mode = Line2D.LINE_CAP_ROUND
visual.add_child(poly)
visual.add_child(line)
_world.add_child(visual)
_frame_camera(_points_bbox(points))
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var tex := _viewport.get_texture()
visual.queue_free()
if tex == null:
return null
var img := tex.get_image()
if _is_blank(img):
return null
return ImageTexture.create_from_image(img)
func render_shapes(shapes: Array[Dictionary]) -> Texture2D:
_clear_world()
var visual := Node2D.new()
_world.add_child(visual)
var bounds: Rect2 = PROP_VISUALS.build(visual, shapes)
_frame_camera(bounds if bounds.size.length_squared() > 0.0 else Rect2(Vector2(-24, -24), Vector2(48, 48)))
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var tex := _viewport.get_texture()
visual.queue_free()
if tex == null or _is_blank(tex.get_image()): return null
return ImageTexture.create_from_image(tex.get_image())
func _compute_points(payload: Dictionary) -> PackedVector2Array:
var shape_type: int = int(payload.get("type", PROP_BLOCK.ShapeType.POLYGON))
if shape_type == PROP_BLOCK.ShapeType.CIRCLE:
var radius := float(payload.get("radius", 24.0))
var loop := PackedVector2Array()
for i: int in PROP_BLOCK.CIRCLE_SEGMENTS:
var angle: float = TAU * float(i) / float(PROP_BLOCK.CIRCLE_SEGMENTS)
loop.append(Vector2(cos(angle), sin(angle)) * radius)
return loop
return payload.get("points", PackedVector2Array())
func _points_bbox(points: PackedVector2Array) -> Rect2:
if points.is_empty():
return Rect2(Vector2(-24, -24), Vector2(48, 48))
var rect := Rect2(points[0], Vector2.ZERO)
for p: Vector2 in points:
rect = rect.expand(p)
if rect.size.x < 1.0 or rect.size.y < 1.0:
return Rect2(Vector2(-24, -24), Vector2(48, 48))
return rect
func _clear_world() -> void:
for child: Node in _world.get_children():
child.queue_free()
func _frame_camera(bbox: Rect2) -> void:
var margin := 12.0
var fit := minf((SIZE.x - margin * 2.0) / bbox.size.x, (SIZE.y - margin * 2.0) / bbox.size.y)
_camera.position = bbox.get_center()
_camera.zoom = Vector2(maxf(fit, 0.05), maxf(fit, 0.05))
func _is_blank(img: Image) -> bool:
if img == null or img.is_empty():
return true
var used := img.get_used_rect()
if used.size.x <= 0 or used.size.y <= 0:
return true
for y: int in range(used.position.y, used.end.y):
for x: int in range(used.position.x, used.end.x):
if img.get_pixel(x, y).a > 0.0:
return false
return true
@@ -0,0 +1 @@
uid://baau4hydvu46y
@@ -0,0 +1,73 @@
class_name StickmanThumbnail
extends Node
const STICKMAN_FACTORY := preload("res://scripts/characters/stickman/stickman_factory.gd")
const STAGE_SPAWNER := preload("res://scripts/stage/stage_spawner.gd")
const SIZE := Vector2i(200, 200)
var _viewport: SubViewport
var _world: Node2D
var _camera: Camera2D
func _ready() -> void:
_viewport = SubViewport.new()
_viewport.size = SIZE
_viewport.transparent_bg = true
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
add_child(_viewport)
_world = Node2D.new()
_viewport.add_child(_world)
_camera = Camera2D.new()
_camera.enabled = true
_viewport.add_child(_camera)
_camera.make_current()
func render(stk_data: Dictionary) -> Texture2D:
_clear_world()
var rig: Node2D = STICKMAN_FACTORY.spawn_from_data(stk_data)
if rig == null:
return null
_world.add_child(rig)
rig.position = Vector2.ZERO
var bbox := STAGE_SPAWNER.get_world_aabb(rig)
if not bbox.has_area() or bbox.size.x < 1.0 or bbox.size.y < 1.0:
bbox = Rect2(Vector2(-60, -500), Vector2(120, 500))
_frame_camera(bbox)
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var tex := _viewport.get_texture()
rig.queue_free()
if tex == null:
return null
var img := tex.get_image()
if _is_blank(img):
return null
return ImageTexture.create_from_image(img)
func _clear_world() -> void:
for child: Node in _world.get_children():
child.queue_free()
func _frame_camera(bbox: Rect2) -> void:
var margin := 12.0
var fit := minf((SIZE.x - margin * 2.0) / bbox.size.x, (SIZE.y - margin * 2.0) / bbox.size.y)
_camera.position = bbox.get_center()
_camera.zoom = Vector2(maxf(fit, 0.05), maxf(fit, 0.05))
func _is_blank(img: Image) -> bool:
if img == null or img.is_empty():
return true
var used := img.get_used_rect()
if used.size.x <= 0 or used.size.y <= 0:
return true
for y: int in range(used.position.y, used.end.y):
for x: int in range(used.position.x, used.end.x):
if img.get_pixel(x, y).a > 0.0:
return false
return true
@@ -0,0 +1 @@
uid://5b0eubudwgua
@@ -0,0 +1,94 @@
class_name ThumbnailCache
extends RefCounted
const STICKMEN_DIR := "user://thumbnails/stickmen"
const PROP_DIR := "user://thumbnails/props"
const PROP_VERSION := 1
var _memory: Dictionary = {}
func get_texture(cache_path: String) -> Texture2D:
if _memory.has(cache_path):
return _memory[cache_path] as Texture2D
var texture: Texture2D = load_png(cache_path)
if texture != null:
_memory[cache_path] = texture
return texture
func store_texture(cache_path: String, texture: Texture2D) -> Error:
if texture == null:
return ERR_INVALID_PARAMETER
_memory[cache_path] = texture
return save_png(texture, cache_path)
func forget_memory(cache_path: String) -> void:
_memory.erase(cache_path)
func clear_memory() -> void:
_memory.clear()
func stickman_key(path: String) -> String:
return "%s_%d" % [path.get_file().get_basename(), FileAccess.get_modified_time(path)]
func stickman_png(key: String) -> String:
return STICKMEN_DIR + "/" + key + ".png"
func prop_png(id: String) -> String:
return PROP_DIR + "/" + id + "_v" + str(PROP_VERSION) + ".png"
func prop_file_key(path: String) -> String:
var canonical: String = path.simplify_path()
var digest: String = canonical.sha256_text().substr(0, 12)
return "file_%s_%d" % [digest, FileAccess.get_modified_time(path)]
func prop_file_png(path: String) -> String:
return PROP_DIR + "/" + prop_file_key(path) + ".png"
func load_png(png_path: String) -> Texture2D:
if _memory.has(png_path):
return _memory[png_path] as Texture2D
if not FileAccess.file_exists(png_path):
return null
var img := Image.load_from_file(png_path)
if img == null:
return null
var texture: Texture2D = ImageTexture.create_from_image(img)
_memory[png_path] = texture
return texture
func save_png(tex: Texture2D, png_path: String) -> Error:
if tex == null:
return ERR_INVALID_PARAMETER
var img := tex.get_image()
if img == null:
return ERR_CANT_CREATE
ensure_dir(png_path.get_base_dir())
_memory[png_path] = tex
return img.save_png(png_path)
func ensure_dir(dir: String) -> void:
DirAccess.make_dir_recursive_absolute(dir)
func clean_stale_stickmen(valid_keys: Dictionary) -> void:
if not DirAccess.dir_exists_absolute(STICKMEN_DIR):
return
var dir := DirAccess.open(STICKMEN_DIR)
if dir == null:
return
dir.list_dir_begin()
var fname := dir.get_next()
while fname != "":
if not dir.current_is_dir() and fname.ends_with(".png"):
var key := fname.trim_suffix(".png")
if not valid_keys.has(key):
DirAccess.remove_absolute(STICKMEN_DIR + "/" + fname)
fname = dir.get_next()
dir.list_dir_end()
@@ -0,0 +1 @@
uid://ceffhet5bsvy3
+58
View File
@@ -0,0 +1,58 @@
class_name AssetCard
extends Button
signal activated(payload: Dictionary)
signal drag_started(payload: Dictionary)
var _payload: Dictionary = {}
var _press_position: Vector2 = Vector2.ZERO
var _dragging: bool = false
var _drag_threshold: float = 8.0
var _icon: TextureRect = null
var _glyph: Label = null
var _label: Label = null
func configure(payload: Dictionary, icon_texture: Texture2D = null, theme: Variant = null) -> void:
_payload = payload.duplicate(true)
_drag_threshold = theme.number("palette.drag_threshold", 8.0, 1.0, 128.0) if theme != null else 8.0
custom_minimum_size = Vector2(96, 108)
clip_contents = true
if theme != null:
var size_data: Variant = theme.value("palette.card_size", [96, 94])
if size_data is Array and (size_data as Array).size() == 2: custom_minimum_size = Vector2(float(size_data[0]), maxf(108.0, float(size_data[1])))
add_theme_stylebox_override("normal", theme.stylebox("palette"))
var thumbnail_size: Vector2 = _payload.get("thumbnail_size", Vector2(64, 64))
focus_mode = Control.FOCUS_ALL
tooltip_text = String(_payload.get("label", ""))
for child: Node in get_children(): child.queue_free()
var box := VBoxContainer.new(); box.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT); box.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.clip_contents = true
box.alignment = BoxContainer.ALIGNMENT_CENTER; box.add_theme_constant_override("separation", 2); add_child(box)
_icon = TextureRect.new(); _icon.custom_minimum_size = thumbnail_size; _icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE; _icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED; _icon.texture = icon_texture; _icon.visible = icon_texture != null; _icon.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.add_child(_icon)
_glyph = Label.new(); _glyph.text = String(_payload.get("icon", "")); _glyph.visible = icon_texture == null; _glyph.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER; _glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE; _glyph.add_theme_font_size_override("font_size", 30); box.add_child(_glyph)
_label = Label.new(); _label.text = String(_payload.get("label", "")); _label.custom_minimum_size = Vector2(0, 28); _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER; _label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART; _label.clip_text = true; _label.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.add_child(_label)
func set_thumbnail(texture: Texture2D) -> void:
if _icon != null: _icon.texture = texture; _icon.visible = texture != null
if _glyph != null and texture != null: _glyph.visible = false
func thumbnail_rect() -> TextureRect:
return _icon
func set_selected(selected: bool) -> void:
button_pressed = selected
func payload() -> Dictionary:
return _payload.duplicate(true)
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
_press_position = event.position; _dragging = false
elif _dragging:
accept_event()
elif event is InputEventMouseMotion and Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) and not _dragging:
if event.position.distance_to(_press_position) >= _drag_threshold:
_dragging = true; drag_started.emit(payload()); accept_event()
func _pressed() -> void:
if not _dragging: activated.emit(payload())
+1
View File
@@ -0,0 +1 @@
uid://dayameygn6kdb
+205
View File
@@ -0,0 +1,205 @@
class_name AssetSelector
extends PopupPanel
signal item_selected(entry: Dictionary)
signal cancelled()
signal browse_requested()
signal refresh_requested()
const COLUMNS := 4
const ROWS := 3
const PAGE_SIZE := COLUMNS * ROWS
const CELL_MIN_SIZE := Vector2(190.0, 220.0)
const THUMB_SIZE := Vector2(156.0, 156.0)
const ASSET_CARD := preload("res://scripts/shared/ui/asset_card.gd")
var kind: String = ""
var _entries: Array[Dictionary] = []
var _page: int = 0
var _thumbnails: Dictionary = {}
var _cell_texrects: Dictionary = {}
var _placeholder_tex: ImageTexture = null
var _ui_font: Font = null
var _emoji_font: Font = null
var _sandbox_theme: Variant = null
@onready var _title_label: Label = %TitleLabel
@onready var _grid: GridContainer = %GridContainer
@onready var _empty_label: Label = %EmptyLabel
@onready var _page_label: Label = %PageLabel
@onready var _prev_button: Button = %PrevButton
@onready var _next_button: Button = %NextButton
@onready var _browse_button: Button = %BrowseButton
@onready var _refresh_button: Button = %RefreshButton
@onready var _close_button: Button = %CloseButton
func _ready() -> void:
exclusive = true
_prev_button.pressed.connect(_on_prev)
_next_button.pressed.connect(_on_next)
_browse_button.pressed.connect(func() -> void: browse_requested.emit())
_refresh_button.pressed.connect(func() -> void: refresh_requested.emit())
_close_button.pressed.connect(func() -> void: cancelled.emit())
get_tree().root.size_changed.connect(_on_root_size_changed)
func _on_root_size_changed() -> void:
if visible:
popup_centered()
func open(p_kind: String, entries: Array[Dictionary]) -> void:
kind = p_kind
_title_label.text = "Choose Your Stickman" if kind == "stickman" else "Choose a Prop"
_browse_button.visible = kind == "stickman" or kind == "prop"
_refresh_button.visible = kind == "stickman" or kind == "prop"
_empty_label.text = "No props found. Browse for a .prop file." if kind == "prop" else "No stickmen found."
set_entries(entries)
popup_centered()
func set_entries(entries: Array[Dictionary]) -> void:
_entries = entries
_thumbnails.clear()
_cell_texrects.clear()
_page = 0
_rebuild()
func set_thumbnail(entry: Dictionary, tex: Texture2D) -> void:
var key := _entry_key(entry)
_thumbnails[key] = tex
if _cell_texrects.has(key):
(_cell_texrects[key] as TextureRect).texture = tex
func close() -> void:
hide()
func apply_font(ui_font: Font, emoji_font: Font) -> void:
_ui_font = ui_font
_emoji_font = emoji_font
var controls: Array = [
_title_label, _empty_label, _page_label,
_prev_button, _next_button, _browse_button, _refresh_button, _close_button,
]
for c: Control in controls:
_apply_font_to(c)
func apply_theme(theme: Variant) -> void:
_sandbox_theme = theme
if theme != null:
self.theme = theme.control_theme()
add_theme_stylebox_override("panel", theme.stylebox("popups.asset_selector"))
var cell_size: Variant = theme.value("popups.asset_selector.cell_size", CELL_MIN_SIZE)
if cell_size is Array and (cell_size as Array).size() == 2:
_cell_min_size = Vector2(float(cell_size[0]), float(cell_size[1]))
var thumbnail_size: Variant = theme.value("popups.asset_selector.thumbnail_size", THUMB_SIZE)
if thumbnail_size is Array and (thumbnail_size as Array).size() == 2:
_thumbnail_size = Vector2(float(thumbnail_size[0]), float(thumbnail_size[1]))
var _cell_min_size: Vector2 = CELL_MIN_SIZE
var _thumbnail_size: Vector2 = THUMB_SIZE
func _unhandled_input(event: InputEvent) -> void:
if visible and event is InputEventKey:
var key := event as InputEventKey
if key.pressed and not key.echo and key.keycode == KEY_ESCAPE:
get_viewport().set_input_as_handled()
cancelled.emit()
static func page_bounds(total: int, page: int, page_size: int = PAGE_SIZE) -> Dictionary:
var page_count := maxi(1, int(ceil(float(total) / float(page_size))))
var start := page * page_size
if start >= total:
start = 0
var end := mini(start + page_size, total)
return { "start": start, "end": end, "total": total, "page_count": page_count }
func _entry_key(entry: Dictionary) -> String:
if kind == "stickman":
return String(entry.get("path", ""))
return String(entry.get("id", ""))
func _on_prev() -> void:
if _page > 0:
_page -= 1
_rebuild()
func _on_next() -> void:
if (_page + 1) * PAGE_SIZE < _entries.size():
_page += 1
_rebuild()
func _page_count() -> int:
if _entries.is_empty():
return 1
return int(ceil(float(_entries.size()) / float(PAGE_SIZE)))
func _rebuild() -> void:
for c: Node in _grid.get_children():
c.queue_free()
_cell_texrects.clear()
_empty_label.visible = _entries.is_empty()
_grid.visible = not _entries.is_empty()
var total := _page_count()
var single := total <= 1
_prev_button.visible = not single
_next_button.visible = not single
_page_label.visible = not single
_prev_button.disabled = _page <= 0
_next_button.disabled = (_page + 1) * PAGE_SIZE >= _entries.size()
_page_label.text = "Page %d/%d" % [_page + 1, total]
if _entries.is_empty():
return
var bounds := page_bounds(_entries.size(), _page)
for i: int in range(int(bounds["start"]), int(bounds["end"])):
_grid.add_child(_build_cell(_entries[i]))
func _build_cell(entry: Dictionary) -> Control:
var key := _entry_key(entry)
var cell: Variant = ASSET_CARD.new()
# Selector cells inherit the popup theme. Avoid a per-card state override so
# opening a selector never implies that one asset is pre-selected.
cell.configure({"placement_id": _entry_key(entry), "label": String(entry.get("name", entry.get("id", ""))), "icon": "", "category": String(entry.get("material_label", "")), "thumbnail_size": _thumbnail_size}, _thumbnails.get(key, null), null)
cell.custom_minimum_size = _cell_min_size
cell.focus_mode = Control.FOCUS_NONE
cell.activated.connect(func(_payload: Dictionary) -> void: _on_cell_pressed(entry))
var tex: Texture2D = _thumbnails.get(key, null)
cell.set_thumbnail(tex if tex != null else _get_placeholder())
_cell_texrects[key] = cell.thumbnail_rect()
return cell
func _on_cell_pressed(entry: Dictionary) -> void:
item_selected.emit(entry)
func _apply_font_to(c: Control) -> void:
if c == null:
return
if _ui_font != null:
c.add_theme_font_override("font", _ui_font)
elif _emoji_font != null:
c.add_theme_font_override("font", _emoji_font)
func _get_placeholder() -> ImageTexture:
if _placeholder_tex == null:
var img := Image.create(int(_thumbnail_size.x), int(_thumbnail_size.y), false, Image.FORMAT_RGBA8)
img.fill(Color(0.16, 0.16, 0.16, 1.0))
_placeholder_tex = ImageTexture.create_from_image(img)
return _placeholder_tex
+1
View File
@@ -0,0 +1 @@
uid://b1ikg6vt3tiu3
@@ -0,0 +1,17 @@
class_name ColorSwatchSelector
extends HBoxContainer
signal color_selected(color: Color)
var _selected: Color = Color.WHITE
var _group: ButtonGroup = ButtonGroup.new()
func configure(colors: Array[Color], selected: Color, theme: Variant = null) -> void:
for child: Node in get_children(): child.queue_free()
_selected = selected
for color: Color in colors:
var button := Button.new(); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group; button.tooltip_text = color.to_html(true)
var box := StyleBoxFlat.new(); box.bg_color = color; box.set_corner_radius_all(22); button.add_theme_stylebox_override("normal", box)
button.pressed.connect(func() -> void: set_selected(color, true)); add_child(button)
button.set_pressed_no_signal(color.is_equal_approx(selected))
func set_selected(color: Color, emit_signal: bool = false) -> void:
_selected = color
if emit_signal: color_selected.emit(color)
func get_selected() -> Color: return _selected
@@ -0,0 +1 @@
uid://djy6yykmgtxk0
+10
View File
@@ -0,0 +1,10 @@
class_name EmojiQuickGrid
extends GridContainer
signal emoji_selected(text: String)
func configure(items: Array[String], columns: int = 8, theme: Variant = null) -> void:
for child: Node in get_children(): child.queue_free()
self.columns = maxi(1, columns)
for text: String in items:
var button := Button.new(); button.text = text; button.custom_minimum_size = Vector2(44,44); button.focus_mode = Control.FOCUS_ALL
if theme != null: button.add_theme_font_override("font", theme.emoji_font())
button.pressed.connect(func() -> void: emoji_selected.emit(text)); add_child(button)
@@ -0,0 +1 @@
uid://dlstr7bm0x258
+17
View File
@@ -0,0 +1,17 @@
class_name FontPresetPicker
extends HBoxContainer
signal preset_selected(id: String)
var _selected_id: String = ""
var _group: ButtonGroup = ButtonGroup.new()
func configure(presets: Array[Dictionary], selected_id: String, theme: Variant = null) -> void:
for child: Node in get_children(): child.queue_free()
_selected_id = selected_id
for item: Dictionary in presets:
var id: String = String(item.get("id", "")); if id.is_empty(): continue
var button := Button.new(); button.text = String(item.get("label", id)); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group
if item.get("font", null) is Font: button.add_theme_font_override("font", item["font"])
button.pressed.connect(func() -> void: set_selected(id, true)); add_child(button); button.set_pressed_no_signal(id == selected_id)
func set_selected(id: String, emit_signal: bool = false) -> void:
_selected_id = id
if emit_signal: preset_selected.emit(id)
func get_selected_id() -> String: return _selected_id
@@ -0,0 +1 @@
uid://dfwkdo5sx6i4h
+25
View File
@@ -0,0 +1,25 @@
class_name PresetSizeToggle
extends HBoxContainer
signal preset_selected(id: String, value: int)
var _presets: Array[Dictionary] = []
var _selected_id: String = ""
var _group: ButtonGroup = ButtonGroup.new()
func configure(presets: Array[Dictionary], selected_id: String, theme: Variant = null) -> void:
for child: Node in get_children(): child.queue_free()
_presets = presets.duplicate(true); _selected_id = selected_id
for item: Dictionary in _presets:
if String(item.get("id","")).is_empty(): continue
var button := Button.new(); button.text = String(item.get("label", item["id"])); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group
button.pressed.connect(func() -> void: set_selected(String(item["id"]), true)); add_child(button); button.set_pressed_no_signal(String(item["id"]) == selected_id)
if not _presets.is_empty() and _selected_id.is_empty(): _selected_id = String(_presets[0].get("id", ""))
func set_selected(id: String, emit_signal: bool = false) -> void:
for item: Dictionary in _presets:
if String(item.get("id", "")) == id:
_selected_id = id
if emit_signal: preset_selected.emit(id, int(item.get("value", 0)))
return
func get_selected_id() -> String: return _selected_id
func get_selected_value() -> int:
for item: Dictionary in _presets:
if String(item.get("id", "")) == _selected_id: return int(item.get("value", 0))
return 0
@@ -0,0 +1 @@
uid://fx38yavxtiv4