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:
parent
5953f4f465
commit
26bf44f077
5 changed files with 582 additions and 1 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
441
.project/designs/app/src/pages/SpriteShowcase.tsx
Normal file
441
.project/designs/app/src/pages/SpriteShowcase.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
.project/designs/sprite-gallery.html
Normal file
137
.project/designs/sprite-gallery.html
Normal file
File diff suppressed because one or more lines are too long
BIN
.project/screenshots/p2-74-cluster3-hud-minimap.png
Normal file
BIN
.project/screenshots/p2-74-cluster3-hud-minimap.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
Loading…
Add table
Reference in a new issue