feat(credits): Add auto-generated audio source credits display in the credits scene

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-29 09:41:38 -07:00
parent 41601f6f7e
commit 9a92017e45
2 changed files with 138 additions and 10 deletions

View file

@ -58,5 +58,8 @@
{ "name": "Magic: The Gathering", "role": "Color pie inspiration" }
]
}
],
"auto_sections": [
{ "heading": "Audio & Music", "source": "audio_sources_csv" }
]
}

View file

@ -4,6 +4,7 @@ extends Control
## headings, each with (name — role) rows.
const CREDITS_PATH: String = "res://public/games/age-of-dwarves/data/credits.json"
const AUDIO_SOURCES_CSV: String = "res://public/games/age-of-dwarves/assets/audio/sources.csv"
@onready var _title_label: Label = %TitleLabel
@onready var _content_vbox: VBoxContainer = %ContentVBox
@ -35,6 +36,14 @@ func _populate() -> void:
continue
var section: Dictionary = section_variant
_content_vbox.add_child(_build_section(section))
for auto_variant: Variant in _load_auto_sections():
if not auto_variant is Dictionary:
continue
var auto: Dictionary = auto_variant
var resolved: Dictionary = _resolve_auto_section(auto)
if resolved.is_empty():
continue
_content_vbox.add_child(_build_section(resolved))
func _load_sections() -> Array:
@ -59,6 +68,101 @@ func _load_sections() -> Array:
return sections
func _load_auto_sections() -> Array:
if not FileAccess.file_exists(CREDITS_PATH):
return []
var file: FileAccess = FileAccess.open(CREDITS_PATH, FileAccess.READ)
if file == null:
return []
var json: JSON = JSON.new()
if json.parse(file.get_as_text()) != OK:
file.close()
return []
file.close()
var data: Dictionary = json.data if json.data is Dictionary else {}
return data.get("auto_sections", []) as Array
## Dispatch on `source` to build a section dict from external data.
## Returns {} when the source is unknown or yields no entries — caller
## then skips the section so the page never shows a blank header.
func _resolve_auto_section(spec: Dictionary) -> Dictionary:
var source: String = String(spec.get("source", ""))
var heading: String = String(spec.get("heading", ""))
var entries: Array = []
match source:
"audio_sources_csv":
entries = _audio_credit_entries()
_:
push_warning("credits.gd: unknown auto_section source '%s'" % source)
return {}
if entries.is_empty():
return {}
return {"heading": heading, "entries": entries}
## Group sources.csv rows by attribution; one entry per pack with the
## file count and the licence in the role string.
func _audio_credit_entries() -> Array:
if not FileAccess.file_exists(AUDIO_SOURCES_CSV):
return []
var file: FileAccess = FileAccess.open(AUDIO_SOURCES_CSV, FileAccess.READ)
if file == null:
return []
var text: String = file.get_as_text()
file.close()
# Per-pack accumulators: attribution -> {count, licences:set, urls:set}.
var packs: Dictionary = {}
var headers: PackedStringArray = []
for raw_line: String in text.split("\n"):
var line: String = raw_line.strip_edges()
if line.is_empty() or line.begins_with("#"):
continue
var fields: PackedStringArray = line.split(",", false)
if headers.is_empty():
headers = fields
continue
if fields.size() < headers.size():
continue
var row: Dictionary = {}
for i: int in range(headers.size()):
row[headers[i]] = fields[i]
var attribution: String = String(row.get("attribution", "")).strip_edges()
if attribution.is_empty() or attribution == "":
continue
var licence: String = String(row.get("license", "")).strip_edges()
var url: String = String(row.get("source_url", "")).split("#")[0]
var bucket: Dictionary = packs.get(attribution, {"count": 0, "licences": {}, "url": ""})
bucket["count"] = int(bucket.get("count", 0)) + 1
(bucket["licences"] as Dictionary)[licence] = true
if String(bucket.get("url", "")).is_empty():
bucket["url"] = url
packs[attribution] = bucket
# Stable order: descending by count, then alphabetical.
var names: Array[String] = []
for k: String in packs:
names.append(k)
names.sort_custom(func(a: String, b: String) -> bool:
var ca: int = int((packs[a] as Dictionary).get("count", 0))
var cb: int = int((packs[b] as Dictionary).get("count", 0))
if ca == cb:
return a < b
return ca > cb)
var entries: Array = []
for name: String in names:
var bucket: Dictionary = packs[name]
var lic_keys: Array = (bucket["licences"] as Dictionary).keys()
lic_keys.sort()
var lic_str: String = ", ".join(PackedStringArray(lic_keys))
var role: String = "%d file(s) — %s" % [int(bucket["count"]), lic_str]
var entry: Dictionary = {"name": name, "role": role}
var url: String = String(bucket.get("url", ""))
if not url.is_empty():
entry["url"] = url
entries.append(entry)
return entries
func _build_section(section: Dictionary) -> Control:
var box: VBoxContainer = VBoxContainer.new()
box.add_theme_constant_override("separation", 4)
@ -76,19 +180,40 @@ func _build_section(section: Dictionary) -> Control:
if not entry_variant is Dictionary:
continue
var entry: Dictionary = entry_variant
var row: Label = Label.new()
var name: String = str(entry.get("name", ""))
var role: String = str(entry.get("role", ""))
if role.is_empty():
row.text = name
else:
row.text = ThemeVocabulary.lookup("fmt_credits_entry") % [name, role]
row.add_theme_font_size_override("font_size", 13)
row.add_theme_color_override("font_color", Color(0.85, 0.82, 0.72))
box.add_child(row)
box.add_child(_build_entry_row(entry))
return box
## One credit row. If the entry carries a `url`, render the row as a
## RichTextLabel with [url] BBCode so users can ctrl-click out — keeps
## the page browsable instead of merely informational.
func _build_entry_row(entry: Dictionary) -> Control:
var name: String = str(entry.get("name", ""))
var role: String = str(entry.get("role", ""))
var url: String = str(entry.get("url", ""))
var line: String
if role.is_empty():
line = name
else:
line = ThemeVocabulary.lookup("fmt_credits_entry") % [name, role]
if url.is_empty():
var lbl: Label = Label.new()
lbl.text = line
lbl.add_theme_font_size_override("font_size", 13)
lbl.add_theme_color_override("font_color", Color(0.85, 0.82, 0.72))
return lbl
var rich: RichTextLabel = RichTextLabel.new()
rich.bbcode_enabled = true
rich.fit_content = true
rich.scroll_active = false
rich.add_theme_font_size_override("normal_font_size", 13)
rich.add_theme_color_override("default_color", Color(0.85, 0.82, 0.72))
# Escape-friendly: `line` is author-controlled JSON / CSV, not BBCode.
rich.text = "%s [color=#9bbfe0][url=%s]%s[/url][/color]" % [line, url, url]
rich.meta_clicked.connect(func(meta: Variant) -> void: OS.shell_open(str(meta)))
return rich
func _on_back() -> void:
var main: Node = get_tree().root.get_node_or_null("Main")
if main != null and main.has_method("change_scene"):