feat(@projects/@magic-civilization): ✨ add credits page navigation
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
59a5e7f67d
commit
bfd71c4009
6 changed files with 426 additions and 10 deletions
|
|
@ -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" />} />
|
||||
|
|
|
|||
247
.project/designs/app/src/pages/Credits.tsx
Normal file
247
.project/designs/app/src/pages/Credits.tsx
Normal 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 & 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue