feat(@projects/@magic-civilization): add credits page navigation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-29 12:40:19 -04:00
parent 59a5e7f67d
commit bfd71c4009
6 changed files with 426 additions and 10 deletions

View file

@ -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 {
<Route path="/combat" element={<CombatPreviewPage />} />
<Route path="/hex" element={<HexFormationPage />} />
<Route path="/audio" element={<AudioSystemPage />} />
<Route path="/credits" element={<CreditsPage />} />
<Route path="/gallery" element={<Stub name="Design Gallery" />} />
<Route path="/hud" element={<Stub name="World Map HUD" />} />
<Route path="/city" element={<Stub name="City Screen" />} />

View file

@ -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<string>; url: string };
const packs: Map<string, Bucket> = 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<string, string> = {};
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<string>(), 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 (
<Page>
<Back to="/"> Back to design index</Back>
<Title>Credits</Title>
<Sub>
Hand-authored sections from <code>credits.json</code>; Audio &amp; Music
is generated from <code>assets/audio/sources.csv</code> at load time.
</Sub>
{handAuthored.map((s: CreditSection): React.ReactElement => (
<CreditSectionView key={s.heading} section={s} isAuto={false} />
))}
{auto.map((s: CreditSection): React.ReactElement => (
<CreditSectionView key={s.heading} section={s} isAuto={true} />
))}
</Page>
);
}
function CreditSectionView({
section,
isAuto,
}: {
section: CreditSection;
isAuto: boolean;
}): React.ReactElement {
return (
<Section>
<Heading>
{section.heading}
{isAuto && <SourceTag $auto={true}>auto · sources.csv</SourceTag>}
</Heading>
{section.entries.map((e: CreditEntry, i: number): React.ReactElement => (
<Entry key={`${e.name}-${i}`}>
<Name>{e.name}</Name>
{e.role && <Role>{e.role}</Role>}
{e.url && (
<Url href={e.url} target="_blank" rel="noopener noreferrer">
{hostnameOf(e.url)}
</Url>
)}
</Entry>
))}
</Section>
);
}
function hostnameOf(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}

View file

@ -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" },

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"):

View file

@ -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")