From 9a92017e45ad72773cd713b1d7da6c77a3e39218 Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 29 Apr 2026 09:41:38 -0700 Subject: [PATCH] =?UTF-8?q?feat(credits):=20=E2=9C=A8=20Add=20auto-generat?= =?UTF-8?q?ed=20audio=20source=20credits=20display=20in=20the=20credits=20?= =?UTF-8?q?scene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- public/games/age-of-dwarves/data/credits.json | 3 + src/game/engine/scenes/menus/credits.gd | 145 ++++++++++++++++-- 2 files changed, 138 insertions(+), 10 deletions(-) diff --git a/public/games/age-of-dwarves/data/credits.json b/public/games/age-of-dwarves/data/credits.json index 624fb616..33f3c4bb 100644 --- a/public/games/age-of-dwarves/data/credits.json +++ b/public/games/age-of-dwarves/data/credits.json @@ -58,5 +58,8 @@ { "name": "Magic: The Gathering", "role": "Color pie inspiration" } ] } + ], + "auto_sections": [ + { "heading": "Audio & Music", "source": "audio_sources_csv" } ] } diff --git a/src/game/engine/scenes/menus/credits.gd b/src/game/engine/scenes/menus/credits.gd index b75971f7..9eed7080 100644 --- a/src/game/engine/scenes/menus/credits.gd +++ b/src/game/engine/scenes/menus/credits.gd @@ -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"):