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:
parent
41601f6f7e
commit
9a92017e45
2 changed files with 138 additions and 10 deletions
|
|
@ -58,5 +58,8 @@
|
|||
{ "name": "Magic: The Gathering", "role": "Color pie inspiration" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"auto_sections": [
|
||||
{ "heading": "Audio & Music", "source": "audio_sources_csv" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue