feat(designs): sprite showcase /sprites route + standalone scoped gallery

Lands remaining bridge-cse worktree WIP onto main:
- SpriteShowcase.tsx React page + wired /sprites route (App.tsx) + Index nav link
- standalone scoped sprite-gallery.html (160 units / 165 buildings, 0 missing)
- p2-74 cluster-3 HUD/minimap proof screenshot

SpriteShowcase typechecks clean (@game-assets glob + theme, no WASM dep).
Excluded tsconfig.tsbuildinfo (build cache); tile_info_panel.gd kept main's
global-theme version. User-authorized override of autocommit (daemon idle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-06 02:11:21 -07:00
parent 5953f4f465
commit 26bf44f077
5 changed files with 582 additions and 1 deletions

View file

@ -25,6 +25,7 @@ import { MainMenuPage } from "./pages/MainMenu";
import { TechTreePage } from "./pages/TechTree";
import { PromotionPickerPage } from "./pages/PromotionPicker";
import { DesignGalleryPage } from "./pages/DesignGallery";
import { SpriteShowcasePage } from "./pages/SpriteShowcase";
import { BuildingTreesPage } from "./pages/BuildingTrees";
import { StatisticsPage } from "./pages/Statistics";
import { EndGameSummaryPage } from "./pages/EndGameSummary";
@ -58,6 +59,7 @@ export function App(): React.ReactElement {
<Route path="/audio/packs/:packId" element={<AudioPackDetailPage />} />
<Route path="/credits" element={<CreditsPage />} />
<Route path="/gallery" element={<DesignGalleryPage />} />
<Route path="/sprites" element={<SpriteShowcasePage />} />
<Route path="/hud" element={<HudPage />} />
<Route path="/city" element={<CityScreenPage />} />
<Route path="/menu" element={<MainMenuPage />} />

View file

@ -136,7 +136,8 @@ const routeCategories: RouteCategory[] = [
label: "Reference",
routes: [
{ path: "/hex", label: "⬢ Hex Centre + 6 Edge Slots — ecotone blends, the differentiator" },
{ path: "/gallery", label: "🖼 Design Gallery — colors, type, components" },
{ path: "/sprites", label: "🖼 Sprite Showcase — all unit + building demo sprites, tier grid, coverage stats" },
{ path: "/gallery", label: "🎨 Design Gallery — colors, type, components" },
{ path: "/audio", label: "♪ Audio System — manifest browser, fallback ladder, mixer" },
{ path: "/credits", label: "✦ Credits — hand-authored + audio auto-generated from sources.csv" },
{ path: "/civilopedia", label: "📖 Civilopedia — searchable encyclopedia: units, buildings, tech, terrain, resources, clans" },

View file

@ -0,0 +1,441 @@
import { useMemo, useState } from "react";
import styled from "styled-components";
import { t } from "../theme";
// ── Vite glob: all unit sprites as URL strings ────────────────────────────────
const UNIT_SPRITE_URLS = import.meta.glob(
"@game-assets/sprites/units/*.png",
{ eager: true, query: "?url", import: "default" },
) as Record<string, string>;
const BUILDING_SPRITE_URLS = import.meta.glob(
"@game-assets/sprites/buildings/*.png",
{ eager: true, query: "?url", import: "default" },
) as Record<string, string>;
// Normalise glob keys → bare id (strip path + extension)
function extractId(absPath: string, dir: string): string {
const idx = absPath.lastIndexOf(dir);
if (idx < 0) return "";
return absPath.slice(idx + dir.length).replace(/\.png$/, "");
}
const UNIT_URLS: Record<string, string> = (() => {
const out: Record<string, string> = {};
for (const [k, url] of Object.entries(UNIT_SPRITE_URLS)) {
const id = extractId(k, "/units/");
if (id) out[id] = url as string;
}
return out;
})();
const BUILDING_URLS: Record<string, string> = (() => {
const out: Record<string, string> = {};
for (const [k, url] of Object.entries(BUILDING_SPRITE_URLS)) {
const id = extractId(k, "/buildings/");
if (id) out[id] = url as string;
}
return out;
})();
// ── Raw unit/building data ────────────────────────────────────────────────────
// Loaded via Vite alias @resources → public/resources/
const UNIT_JSONS = import.meta.glob(
"@resources/units/*.json",
{ eager: true, import: "default" },
) as Record<string, unknown>;
const BUILDING_JSONS = import.meta.glob(
"@resources/buildings/*.json",
{ eager: true, import: "default" },
) as Record<string, unknown>;
interface BaseRecord {
id: string;
name: string;
tier: number;
}
interface UnitRecord extends BaseRecord {
unit_type: string;
domain: string;
}
interface BuildingRecord extends BaseRecord {
category: string;
}
function loadUnits(): UnitRecord[] {
const out: UnitRecord[] = [];
for (const raw of Object.values(UNIT_JSONS)) {
const arr = Array.isArray(raw) ? raw : [raw];
const entry = arr[0] as Record<string, unknown>;
if (!entry?.id) continue;
out.push({
id: String(entry.id),
name: String(entry.name ?? entry.id),
tier: Number(entry.tier ?? 1),
unit_type: String(entry.unit_type ?? ""),
domain: String(entry.domain ?? "land"),
});
}
return out.sort((a, b) => a.tier - b.tier || a.name.localeCompare(b.name));
}
function loadBuildings(): BuildingRecord[] {
const out: BuildingRecord[] = [];
for (const raw of Object.values(BUILDING_JSONS)) {
const arr = Array.isArray(raw) ? raw : [raw];
const entry = arr[0] as Record<string, unknown>;
if (!entry?.id) continue;
out.push({
id: String(entry.id),
name: String(entry.name ?? entry.id),
tier: Number(entry.tier ?? 1),
category: String(entry.category ?? ""),
});
}
return out.sort((a, b) => a.tier - b.tier || a.name.localeCompare(b.name));
}
// ── Styled components ─────────────────────────────────────────────────────────
const Page = styled.div`
min-height: 100vh;
background: ${t.bg.deepest};
color: ${t.text.primary};
font-family: "Inter", system-ui, sans-serif;
padding: 24px 32px;
`;
const Header = styled.header`
margin-bottom: 20px;
h1 {
font-family: "Cinzel", serif;
color: ${t.text.title};
margin: 0 0 4px 0;
font-size: 26px;
}
p {
color: ${t.text.secondary};
margin: 0;
font-size: 13px;
line-height: 1.5;
}
`;
const DemoWarning = styled.div`
display: inline-flex;
align-items: center;
gap: 8px;
background: #3a2200;
border: 1px solid ${t.accent.gold};
border-radius: 4px;
padding: 7px 14px;
font-size: 12px;
color: ${t.accent.goldBright};
margin-bottom: 16px;
`;
const Controls = styled.div`
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
`;
const TabBar = styled.nav`
display: flex;
gap: 4px;
margin-bottom: 0;
`;
const Tab = styled.button<{ $active: boolean }>`
background: ${p => (p.$active ? t.bg.raised : "transparent")};
border: none;
border-bottom: 2px solid ${p => (p.$active ? t.accent.gold : "transparent")};
color: ${p => (p.$active ? t.accent.gold : t.text.muted)};
padding: 8px 16px;
font-size: 13px;
cursor: pointer;
border-radius: 3px 3px 0 0;
&:hover { color: ${t.text.primary}; }
`;
const SearchInput = styled.input`
background: ${t.bg.panel};
border: 1px solid ${t.bg.raised};
border-radius: 4px;
color: ${t.text.primary};
padding: 6px 10px;
font-size: 13px;
width: 200px;
&:focus { outline: 1px solid ${t.accent.gold}; }
&::placeholder { color: ${t.text.muted}; }
`;
const TierSelect = styled.select`
background: ${t.bg.panel};
border: 1px solid ${t.bg.raised};
border-radius: 4px;
color: ${t.text.primary};
padding: 6px 10px;
font-size: 13px;
cursor: pointer;
`;
const CoverageBar = styled.div`
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: ${t.text.muted};
margin-left: auto;
`;
const TierSection = styled.section`
margin-bottom: 28px;
`;
const TierHeading = styled.h2`
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: ${t.text.muted};
margin: 0 0 10px 0;
padding-bottom: 6px;
border-bottom: 1px solid ${t.bg.raised};
display: flex;
align-items: center;
gap: 8px;
`;
const TierBadge = styled.span`
background: ${t.bg.raised};
color: ${t.accent.gold};
border-radius: 10px;
padding: 1px 8px;
font-size: 11px;
`;
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
`;
const Card = styled.div<{ $hassprite: boolean }>`
background: ${t.bg.panel};
border: 1px solid ${p => (p.$hassprite ? t.bg.raised : "#2a1a00")};
border-radius: 4px;
padding: 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
opacity: ${p => (p.$hassprite ? 1 : 0.5)};
cursor: default;
&:hover {
border-color: ${p => (p.$hassprite ? t.accent.gold : "#5a3a00")};
background: ${t.bg.surface};
}
`;
const SpriteImg = styled.img`
width: 72px;
height: 72px;
object-fit: contain;
image-rendering: pixelated;
background: ${t.bg.deepest};
border-radius: 3px;
`;
const PlaceholderBox = styled.div`
width: 72px;
height: 72px;
background: ${t.bg.deepest};
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: ${t.text.muted};
`;
const CardName = styled.span`
font-size: 10px;
color: ${t.text.secondary};
text-align: center;
line-height: 1.3;
word-break: break-word;
`;
const CardType = styled.span`
font-size: 9px;
color: ${t.text.muted};
text-transform: uppercase;
letter-spacing: 0.5px;
`;
const CardId = styled.span`
font-size: 9px;
color: ${t.text.disabled};
font-family: monospace;
`;
const MissingBadge = styled.span`
font-size: 9px;
color: #e69933;
text-transform: uppercase;
`;
// ── Page component ────────────────────────────────────────────────────────────
type TabId = "units" | "buildings";
export function SpriteShowcasePage(): React.ReactElement {
const [tab, setTab] = useState<TabId>("units");
const [search, setSearch] = useState("");
const [tierFilter, setTierFilter] = useState<number | "all">("all");
const allUnits = useMemo(loadUnits, []);
const allBuildings = useMemo(loadBuildings, []);
const unitItems = useMemo(() => {
const q = search.toLowerCase();
return allUnits.filter(u => {
if (tierFilter !== "all" && u.tier !== tierFilter) return false;
if (q && !u.name.toLowerCase().includes(q) && !u.id.includes(q)) return false;
return true;
});
}, [allUnits, search, tierFilter]);
const buildingItems = useMemo(() => {
const q = search.toLowerCase();
return allBuildings.filter(b => {
if (tierFilter !== "all" && b.tier !== tierFilter) return false;
if (q && !b.name.toLowerCase().includes(q) && !b.id.includes(q)) return false;
return true;
});
}, [allBuildings, search, tierFilter]);
const items: BaseRecord[] = tab === "units" ? unitItems : buildingItems;
const urlMap = tab === "units" ? UNIT_URLS : BUILDING_URLS;
const covered = items.filter(item => !!urlMap[item.id]).length;
const byTier = useMemo(() => {
const map = new Map<number, BaseRecord[]>();
for (const item of items) {
const arr = map.get(item.tier) ?? [];
arr.push(item);
map.set(item.tier, arr);
}
return [...map.entries()].sort((a, b) => a[0] - b[0]);
}, [items]);
const tiers = useMemo(() => {
const all = tab === "units"
? [...new Set(allUnits.map(u => u.tier))]
: [...new Set(allBuildings.map(b => b.tier))];
return all.sort((a, b) => a - b);
}, [tab, allUnits, allBuildings]);
return (
<Page>
<Header>
<h1>Sprite Showcase</h1>
<p>
Demo sprites (Battle for Wesnoth CC-BY-SA/GPL-2.0+) stand-ins for
all renderer slots. Replace with paid art before commercial ship.
{" "}{Object.keys(UNIT_URLS).length} unit sprites ·{" "}
{Object.keys(BUILDING_URLS).length} building sprites loaded.
</p>
</Header>
<DemoWarning>
DEMO ONLY Wesnoth sprites are CC-BY-SA / GPL-2.0+ (copyleft). Replace before Steam release.
</DemoWarning>
<Controls>
<TabBar>
<Tab $active={tab === "units"} onClick={() => { setTab("units"); setTierFilter("all"); }}>
Units ({allUnits.length})
</Tab>
<Tab $active={tab === "buildings"} onClick={() => { setTab("buildings"); setTierFilter("all"); }}>
Buildings ({allBuildings.length})
</Tab>
</TabBar>
<SearchInput
placeholder="Search by name or id…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
<TierSelect
value={tierFilter === "all" ? "all" : String(tierFilter)}
onChange={e => setTierFilter(e.target.value === "all" ? "all" : Number(e.target.value))}
>
<option value="all">All Tiers</option>
{tiers.map(tier => (
<option key={tier} value={String(tier)}>Tier {tier}</option>
))}
</TierSelect>
<CoverageBar>
<span style={{ color: t.sem.positive }}>{covered}</span>
<span>/</span>
<span>{items.length}</span>
<span>sprites covered</span>
</CoverageBar>
</Controls>
{byTier.map(([tier, group]) => (
<TierSection key={tier}>
<TierHeading>
<TierBadge>T{tier}</TierBadge>
{group.length} {tab}
{" · "}
{group.filter(item => !!urlMap[item.id]).length}/{group.length} sprited
</TierHeading>
<Grid>
{group.map(item => {
const url = urlMap[item.id];
const maleUrl = tab === "units" ? UNIT_URLS[`${item.id}_dwarf_male`] : undefined;
const femaleUrl = tab === "units" ? UNIT_URLS[`${item.id}_dwarf_female`] : undefined;
return (
<Card key={item.id} $hassprite={!!url} title={item.id}>
{url ? (
<SpriteImg src={url} alt={item.name} />
) : maleUrl ? (
<SpriteImg src={maleUrl} alt={item.name} />
) : (
<PlaceholderBox>?</PlaceholderBox>
)}
<CardName>{item.name}</CardName>
{"unit_type" in item && (
<CardType>{(item as UnitRecord).unit_type}</CardType>
)}
{"category" in item && (
<CardType>{(item as BuildingRecord).category}</CardType>
)}
<CardId>{item.id}</CardId>
{(maleUrl || femaleUrl) && (
<div style={{ display: "flex", gap: 4 }}>
{maleUrl && <SpriteImg src={maleUrl} alt="♂" style={{ width: 32, height: 32 }} />}
{femaleUrl && <SpriteImg src={femaleUrl} alt="♀" style={{ width: 32, height: 32 }} />}
</div>
)}
{!url && !maleUrl && <MissingBadge>missing</MissingBadge>}
</Card>
);
})}
</Grid>
</TierSection>
))}
</Page>
);
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB