diff --git a/.project/designs/app/src/App.tsx b/.project/designs/app/src/App.tsx index 0ab5a7f7..e2d4f6a3 100644 --- a/.project/designs/app/src/App.tsx +++ b/.project/designs/app/src/App.tsx @@ -5,6 +5,7 @@ import { CombatCalculatorPage } from "./pages/CombatCalculator"; import { PermutationsPage } from "./pages/Permutations"; import { HexFormationPage } from "./pages/HexFormation"; import { AudioSystemPage } from "./pages/AudioSystem"; +import { CreditsPage } from "./pages/Credits"; // Remaining sketch pages are stubs pending port from HTML function Stub({ name }: { name: string }): React.ReactElement { @@ -30,6 +31,7 @@ export function App(): React.ReactElement { } /> } /> } /> + } /> } /> } /> } /> diff --git a/.project/designs/app/src/pages/Credits.tsx b/.project/designs/app/src/pages/Credits.tsx new file mode 100644 index 00000000..ff5913b2 --- /dev/null +++ b/.project/designs/app/src/pages/Credits.tsx @@ -0,0 +1,247 @@ +import { Link } from "react-router-dom"; +import styled from "styled-components"; +import { t } from "../theme"; +import creditsData from "@game-data/credits.json"; +import audioSourcesCsv from "@game-assets/audio/sources.csv?raw"; + +// ── Types: mirror credits.json + sources.csv ───────────────────────────────── + +interface CreditEntry { + name: string; + role?: string; + url?: string; +} +interface CreditSection { + heading: string; + entries: CreditEntry[]; +} +interface AutoSection { + heading: string; + source: string; +} +interface CreditsFile { + sections: CreditSection[]; + auto_sections?: AutoSection[]; +} + +// ── CSV parsing — mirrors credits.gd::_audio_credit_entries ───────────────── +// +// Parses sources.csv (header + rows, '#'-comment lines), groups by attribution, +// and emits one entry per pack with its file-count + licence. The single +// source of truth for the resulting list is the CSV: nothing is hand-authored +// on the design side. + +function parseSourcesCsv(text: string): CreditEntry[] { + const lines: string[] = text.split("\n").map((s: string): string => s.trim()); + let headers: string[] = []; + type Bucket = { count: number; licences: Set; url: string }; + const packs: Map = new Map(); + for (const line of lines) { + if (!line || line.startsWith("#")) continue; + const fields: string[] = line.split(","); + if (headers.length === 0) { + headers = fields; + continue; + } + if (fields.length < headers.length) continue; + const row: Record = {}; + for (let i: number = 0; i < headers.length; i++) { + row[headers[i]] = fields[i]; + } + const attribution: string = (row["attribution"] ?? "").trim(); + if (!attribution || attribution === "—") continue; + const licence: string = (row["license"] ?? "").trim(); + const url: string = (row["source_url"] ?? "").split("#")[0]; + let bucket: Bucket | undefined = packs.get(attribution); + if (!bucket) { + bucket = { count: 0, licences: new Set(), url: "" }; + packs.set(attribution, bucket); + } + bucket.count += 1; + bucket.licences.add(licence); + if (!bucket.url) bucket.url = url; + } + const entries: CreditEntry[] = []; + const sortedNames: string[] = Array.from(packs.keys()).sort( + (a: string, b: string): number => { + const ba: Bucket = packs.get(a)!; + const bb: Bucket = packs.get(b)!; + if (ba.count !== bb.count) return bb.count - ba.count; + return a.localeCompare(b); + }, + ); + for (const name of sortedNames) { + const b: Bucket = packs.get(name)!; + const lic: string = Array.from(b.licences).sort().join(", "); + const e: CreditEntry = { name, role: `${b.count} file(s) — ${lic}` }; + if (b.url) e.url = b.url; + entries.push(e); + } + return entries; +} + +function resolveAutoSection(spec: AutoSection): CreditSection | null { + let entries: CreditEntry[] = []; + switch (spec.source) { + case "audio_sources_csv": + entries = parseSourcesCsv(audioSourcesCsv); + break; + default: + throw new Error(`Credits: unknown auto_section source '${spec.source}'`); + } + if (entries.length === 0) return null; + return { heading: spec.heading, entries }; +} + +// ── Styling ────────────────────────────────────────────────────────────────── + +const Page = styled.div` + max-width: 760px; + margin: 0 auto; + padding: 48px 32px 96px; + color: ${t.text.primary}; +`; + +const Title = styled.h1` + font-family: ${t.font.heading}; + font-size: 36px; + color: ${t.text.title}; + margin-bottom: 6px; +`; + +const Sub = styled.p` + color: ${t.text.muted}; + font-size: 13px; + margin-bottom: 32px; + font-family: ${t.font.mono}; +`; + +const Back = styled(Link)` + display: inline-block; + margin-bottom: 28px; + color: ${t.accent.gold}; + text-decoration: none; + font-size: 13px; + &:hover { color: ${t.accent.goldBright}; } +`; + +const Section = styled.section` + margin-bottom: 28px; +`; + +const Heading = styled.h2` + font-family: ${t.font.heading}; + font-size: 16px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: ${t.accent.goldBright}; + margin: 0 0 6px 0; + padding-bottom: 4px; + border-bottom: 1px solid ${t.border.divider}; +`; + +const Entry = styled.div` + font-size: 14px; + line-height: 1.6; + color: ${t.text.secondary}; + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +`; + +const Name = styled.span` + color: ${t.text.primary}; + font-weight: 500; +`; + +const Role = styled.span` + color: ${t.text.muted}; + &::before { content: "— "; color: ${t.text.muted}; } +`; + +const Url = styled.a` + color: ${t.accent.science}; + text-decoration: none; + font-family: ${t.font.mono}; + font-size: 12px; + &:hover { text-decoration: underline; } +`; + +const SourceTag = styled.span<{ $auto: boolean }>` + display: inline-block; + margin-left: 8px; + padding: 1px 6px; + border-radius: 2px; + font-size: 10px; + font-family: ${t.font.mono}; + letter-spacing: 0.05em; + color: ${(p): string => (p.$auto ? "#9bbfe0" : t.text.muted)}; + background: ${(p): string => (p.$auto ? "#1a2533" : "transparent")}; + border: 1px solid ${(p): string => (p.$auto ? "#9bbfe055" : "transparent")}; + text-transform: uppercase; +`; + +// ── Page ──────────────────────────────────────────────────────────────────── + +export function CreditsPage(): React.ReactElement { + const data: CreditsFile = creditsData as CreditsFile; + const handAuthored: CreditSection[] = data.sections; + const auto: CreditSection[] = (data.auto_sections ?? []) + .map((spec: AutoSection): CreditSection | null => resolveAutoSection(spec)) + .filter((s: CreditSection | null): s is CreditSection => s !== null); + + return ( + + ← Back to design index + Credits + + Hand-authored sections from credits.json; Audio & Music + is generated from assets/audio/sources.csv at load time. + + + {handAuthored.map((s: CreditSection): React.ReactElement => ( + + ))} + {auto.map((s: CreditSection): React.ReactElement => ( + + ))} + + ); +} + +function CreditSectionView({ + section, + isAuto, +}: { + section: CreditSection; + isAuto: boolean; +}): React.ReactElement { + return ( +
+ + {section.heading} + {isAuto && auto · sources.csv} + + {section.entries.map((e: CreditEntry, i: number): React.ReactElement => ( + + {e.name} + {e.role && {e.role}} + {e.url && ( + + {hostnameOf(e.url)} + + )} + + ))} +
+ ); +} + +function hostnameOf(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} diff --git a/.project/designs/app/src/pages/Index.tsx b/.project/designs/app/src/pages/Index.tsx index 6f7034f8..a6b982c1 100644 --- a/.project/designs/app/src/pages/Index.tsx +++ b/.project/designs/app/src/pages/Index.tsx @@ -54,6 +54,7 @@ const routes = [ { path: "/combat", label: "📋 Combat Preview — static scenarios with real stats" }, { path: "/hex", label: "⬢ Hex Centre + 6 Edge Slots — ecotone blends, the differentiator" }, { path: "/audio", label: "♪ Audio System — manifest browser, fallback ladder, mixer" }, + { path: "/credits", label: "✦ Credits — hand-authored + audio auto-generated from sources.csv" }, { path: "/gallery", label: "🖼 Design Gallery — colors, type, components" }, { path: "/hud", label: "🗺 World Map HUD — top bar, unit panel, minimap" }, { path: "/city", label: "🏛 City Screen — production queue, buildings, citizens" }, 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"): diff --git a/src/game/engine/tests/unit/test_credits_screen.gd b/src/game/engine/tests/unit/test_credits_screen.gd index 3d872188..173dadca 100644 --- a/src/game/engine/tests/unit/test_credits_screen.gd +++ b/src/game/engine/tests/unit/test_credits_screen.gd @@ -60,6 +60,44 @@ func test_credits_script_loads_sections_list() -> void: instance.free() +# ── Audio auto-section: groups sources.csv by attribution into credit rows ─ + + +func test_credits_declares_audio_auto_section() -> void: + var data: Dictionary = _load_credits() + var auto: Array = data.get("auto_sections", []) as Array + assert_gt(auto.size(), 0, "credits.json must declare at least one auto_section") + var sources: Array[String] = [] + for entry_variant: Variant in auto: + if entry_variant is Dictionary: + sources.append(String((entry_variant as Dictionary).get("source", ""))) + assert_true(sources.has("audio_sources_csv"), + "credits.json must list audio_sources_csv as an auto-section source") + + +func test_audio_credit_entries_grouped_by_pack() -> void: + var instance: Node = CreditsScript.new() + var entries: Array = instance.call("_audio_credit_entries") as Array + instance.free() + assert_gte(entries.size(), 2, + "audio credits must surface at least two distinct packs (we ship Kenney + rubberduck/Junkala)") + var names: Array[String] = [] + for e_variant: Variant in entries: + assert_true(e_variant is Dictionary, "entry must be a dict") + var e: Dictionary = e_variant + assert_true(e.has("name") and not String(e["name"]).is_empty(), + "audio credit entry must have a non-empty name") + var role: String = String(e.get("role", "")) + assert_true(role.contains("file") and role.contains("CC"), + "role must announce the file count and the CC licence — got '%s'" % role) + names.append(String(e["name"])) + # No duplicates — the grouping is the whole point. + var dedup: Dictionary = {} + for n: String in names: + assert_false(dedup.has(n), "pack '%s' appears twice — grouping is broken" % n) + dedup[n] = true + + func test_vocabulary_has_credits_key() -> void: assert_true(ThemeVocabulary.has_key("credits"), "vocabulary must expose a 'credits' label for the button")