diff --git a/.project/designs/app/src/App.tsx b/.project/designs/app/src/App.tsx index c330b401..971f692d 100644 --- a/.project/designs/app/src/App.tsx +++ b/.project/designs/app/src/App.tsx @@ -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 { } /> } /> } /> + } /> } /> } /> } /> diff --git a/.project/designs/app/src/pages/Index.tsx b/.project/designs/app/src/pages/Index.tsx index 3462fd46..2de5e11a 100644 --- a/.project/designs/app/src/pages/Index.tsx +++ b/.project/designs/app/src/pages/Index.tsx @@ -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" }, diff --git a/.project/designs/app/src/pages/SpriteShowcase.tsx b/.project/designs/app/src/pages/SpriteShowcase.tsx new file mode 100644 index 00000000..9cf72845 --- /dev/null +++ b/.project/designs/app/src/pages/SpriteShowcase.tsx @@ -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; + +const BUILDING_SPRITE_URLS = import.meta.glob( + "@game-assets/sprites/buildings/*.png", + { eager: true, query: "?url", import: "default" }, +) as Record; + +// 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 = (() => { + const out: Record = {}; + 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 = (() => { + const out: Record = {}; + 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; + +const BUILDING_JSONS = import.meta.glob( + "@resources/buildings/*.json", + { eager: true, import: "default" }, +) as Record; + +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; + 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; + 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("units"); + const [search, setSearch] = useState(""); + const [tierFilter, setTierFilter] = useState("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(); + 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 ( + +
+

Sprite Showcase

+

+ 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. +

+
+ + + ⚠ DEMO ONLY β€” Wesnoth sprites are CC-BY-SA / GPL-2.0+ (copyleft). Replace before Steam release. + + + + + { setTab("units"); setTierFilter("all"); }}> + Units ({allUnits.length}) + + { setTab("buildings"); setTierFilter("all"); }}> + Buildings ({allBuildings.length}) + + + + setSearch(e.target.value)} + /> + + setTierFilter(e.target.value === "all" ? "all" : Number(e.target.value))} + > + + {tiers.map(tier => ( + + ))} + + + + {covered} + / + {items.length} + sprites covered + + + + {byTier.map(([tier, group]) => ( + + + T{tier} + {group.length} {tab} + {" Β· "} + {group.filter(item => !!urlMap[item.id]).length}/{group.length} sprited + + + {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 ( + + {url ? ( + + ) : maleUrl ? ( + + ) : ( + ? + )} + {item.name} + {"unit_type" in item && ( + {(item as UnitRecord).unit_type} + )} + {"category" in item && ( + {(item as BuildingRecord).category} + )} + {item.id} + {(maleUrl || femaleUrl) && ( +
+ {maleUrl && } + {femaleUrl && } +
+ )} + {!url && !maleUrl && missing} +
+ ); + })} +
+
+ ))} +
+ ); +} diff --git a/.project/designs/sprite-gallery.html b/.project/designs/sprite-gallery.html new file mode 100644 index 00000000..1cae70f4 --- /dev/null +++ b/.project/designs/sprite-gallery.html @@ -0,0 +1,137 @@ + + + + +Age of Dwarves β€” Sprite Gallery + + + + + +

Sprite Gallery

+

Age of Dwarves β€” Demo sprites (Battle for Wesnoth CC-BY-SA / GPL-2.0+)

+
⚠ DEMO ONLY β€” Wesnoth sprites are copyleft. Replace before Steam release.
+
+ + + + +
0 / 0 sprites covered
+
+
+ + + + diff --git a/.project/screenshots/p2-74-cluster3-hud-minimap.png b/.project/screenshots/p2-74-cluster3-hud-minimap.png new file mode 100644 index 00000000..72a0aeab Binary files /dev/null and b/.project/screenshots/p2-74-cluster3-hud-minimap.png differ