feat(climate-sim): ✨ Add climate simulation visualization components and sprite-based engine support
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
670cc24d7a
commit
1f67736b6e
8 changed files with 259 additions and 18 deletions
|
|
@ -57,6 +57,8 @@ function frameAsSnapshot(frame: FramePayload, stats?: { avg_moisture: number; to
|
|||
avg_water_quality: 1,
|
||||
avg_aerosol: 0,
|
||||
avg_evapotranspiration: 0,
|
||||
net_energy: 0,
|
||||
net_hydro: 0,
|
||||
terrain_counts: {},
|
||||
},
|
||||
events: [],
|
||||
|
|
|
|||
|
|
@ -69,16 +69,24 @@ const METRIC_CATALOG: Record<string, MetricDef> = {
|
|||
et: { key: 'evapotrans', label: 'ET', tooltip: 'Average evapotranspiration across land. Moisture recycled by vegetation per turn.', color: '#80DEEA', getValue: (s) => s.avg_evapotranspiration, formatValue: fmt4, formatDelta: fmtDelta4 },
|
||||
albedo: { key: 'albedo', label: 'Albedo', tooltip: 'Global average surface reflectivity (0=absorbs all, 1=reflects all). Ice/snow raise it, forests/water lower it.', color: '#BDBDBD', getValue: (s) => s.avg_albedo, formatValue: fmt3, formatDelta: fmtDelta3 },
|
||||
aerosol: { key: 'aerosol', label: 'Aerosol', tooltip: 'Global average sulfate aerosol opacity. Volcanic eruptions inject aerosols that cool and dry the atmosphere.', color: '#90A4AE', getValue: (s) => s.avg_aerosol, formatValue: fmt4, formatDelta: fmtDelta4 },
|
||||
// -- Land ecology --
|
||||
land_canopy: { key: 'land_canopy', label: 'Flora', tooltip: 'Average tree canopy cover across land (0=barren, 1=full canopy). Drives succession and shading.', color: '#2E7D32', getValue: (s) => s.avg_land_canopy, formatValue: fmt3, formatDelta: fmtDelta3 },
|
||||
net_energy: { key: 'net_energy', label: 'Net Energy', tooltip: 'Per-turn temperature change. Positive = planet absorbing more heat than it radiates (warming). Negative = cooling.', color: '#FF7043', getValue: (s) => s.net_energy, formatValue: (v) => (v >= 0 ? '+' : '') + v.toFixed(4), formatDelta: fmtDelta4 },
|
||||
net_hydro: { key: 'net_hydro', label: 'Net Hydro', tooltip: 'Per-turn moisture change. Positive = planet gaining water (wetting). Negative = losing water (drying).', color: '#4FC3F7', getValue: (s) => s.net_hydro, formatValue: (v) => (v >= 0 ? '+' : '') + v.toFixed(4), formatDelta: fmtDelta4 },
|
||||
// -- Land ecology: quality + population --
|
||||
land_fauna_q: { key: 'land_fauna_q', label: 'Fauna Qlty', tooltip: 'Average land tile quality (1-5). Determines max fauna species tier that can spawn. Higher = rarer species.', color: '#FFD54F', getValue: (s) => s.avg_land_quality, formatValue: fmt2, formatDelta: fmtDelta2 },
|
||||
land_fauna_p: { key: 'land_fauna_p', label: 'Fauna Pop', tooltip: 'Average fauna habitat suitability across land (0=inhospitable, 1=thriving). Combines flora density, moisture, temperature.', color: '#A1887F', getValue: (s) => s.avg_land_habitat, formatValue: fmt3, formatDelta: fmtDelta3 },
|
||||
land_flora_q: { key: 'land_flora_q', label: 'Flora Qlty', tooltip: 'Average land tile quality (1-5). Determines max flora species tier. Higher quality = denser, more diverse vegetation.', color: '#FFD54F', getValue: (s) => s.avg_land_quality, formatValue: fmt2, formatDelta: fmtDelta2 },
|
||||
land_flora_p: { key: 'land_flora_p', label: 'Flora Pop', tooltip: 'Average tree canopy cover across land (0=barren, 1=full canopy). Drives succession, shading, and lumber yield.', color: '#2E7D32', getValue: (s) => s.avg_land_canopy, formatValue: fmt3, formatDelta: fmtDelta3 },
|
||||
// -- Land ecology: details --
|
||||
land_under: { key: 'land_undergrowth', label: 'Undergrowth', tooltip: 'Average ground vegetation across land (0=bare, 1=dense). Drives food yield and habitat quality.', color: '#66BB6A', getValue: (s) => s.avg_land_undergrowth, formatValue: fmt3, formatDelta: fmtDelta3 },
|
||||
land_fungi: { key: 'land_fungi', label: 'Fungi', tooltip: 'Average mycorrhizal network across land (0=none, 1=dense). Accelerates forest regrowth, boosts ecosystem resilience.', color: '#8D6E63', getValue: (s) => s.avg_land_fungi, formatValue: fmt3, formatDelta: fmtDelta3 },
|
||||
land_habitat: { key: 'land_habitat', label: 'Fauna', tooltip: 'Average fauna habitat suitability across land (0=inhospitable, 1=thriving). Combines flora density, moisture, temperature.', color: '#A1887F', getValue: (s) => s.avg_land_habitat, formatValue: fmt3, formatDelta: fmtDelta3 },
|
||||
land_quality: { key: 'land_quality', label: 'Quality', tooltip: 'Average tile quality across land (1=Prolific, 5=Epic). Ecology composite of flora health, fauna diversity, biome stability.', color: '#FFD54F', getValue: (s) => s.avg_land_quality, formatValue: fmt2, formatDelta: fmtDelta2 },
|
||||
// -- Water ecology --
|
||||
water_reef: { key: 'water_reef', label: 'Flora', tooltip: 'Average reef health across water tiles (0=dead, 1=pristine). Bleaching from high temps (>0.75). Dead reefs halve fish capacity.', color: '#29B6F6', getValue: (s) => s.avg_water_reef, formatValue: fmt3, formatDelta: fmtDelta3 },
|
||||
water_fish: { key: 'water_fish', label: 'Fauna', tooltip: 'Average fish stock across water tiles (0=empty, 100+=abundant). Logistic reproduction, temperature-scaled.', color: '#26C6DA', getValue: (s) => s.avg_water_fish, formatValue: fmt2, formatDelta: fmtDelta2 },
|
||||
water_quality:{ key: 'water_quality', label: 'Quality', tooltip: 'Average tile quality across water tiles (1=Prolific, 5=Epic). Reflects marine ecosystem health.', color: '#42A5F5', getValue: (s) => s.avg_water_quality, formatValue: fmt2, formatDelta: fmtDelta2 },
|
||||
land_fungi: { key: 'land_fungi', label: 'Fungi Net', tooltip: 'Average mycorrhizal network across land (0=none, 1=dense). Accelerates forest regrowth, boosts ecosystem resilience.', color: '#8D6E63', getValue: (s) => s.avg_land_fungi, formatValue: fmt3, formatDelta: fmtDelta3 },
|
||||
land_quality: { key: 'land_quality', label: 'Biome Qlty', tooltip: 'Average biome quality across land (1-5). Composite of flora health, fauna diversity, biome stability.', color: '#FFD54F', getValue: (s) => s.avg_land_quality, formatValue: fmt2, formatDelta: fmtDelta2 },
|
||||
// -- Water ecology: quality + population --
|
||||
water_fauna_q:{ key: 'water_fauna_q', label: 'Fauna Qlty', tooltip: 'Average water tile quality (1-5). Determines max marine fauna species tier.', color: '#42A5F5', getValue: (s) => s.avg_water_quality, formatValue: fmt2, formatDelta: fmtDelta2 },
|
||||
water_fauna_p:{ key: 'water_fauna_p', label: 'Fauna Pop', tooltip: 'Average fish stock across water tiles (0=depleted, 1=abundant). Logistic reproduction, temperature-scaled.', color: '#26C6DA', getValue: (s) => s.avg_water_fish, formatValue: fmt3, formatDelta: fmtDelta3 },
|
||||
water_flora_q:{ key: 'water_flora_q', label: 'Flora Qlty', tooltip: 'Average water tile quality (1-5). Determines max marine flora species tier.', color: '#42A5F5', getValue: (s) => s.avg_water_quality, formatValue: fmt2, formatDelta: fmtDelta2 },
|
||||
water_flora_p:{ key: 'water_flora_p', label: 'Flora Pop', tooltip: 'Average reef health across water tiles (0=dead, 1=pristine). Bleaching from high temps (>0.75).', color: '#29B6F6', getValue: (s) => s.avg_water_reef, formatValue: fmt3, formatDelta: fmtDelta3 },
|
||||
water_quality:{ key: 'water_quality', label: 'Biome Qlty', tooltip: 'Average biome quality across water tiles (1-5). Reflects marine ecosystem health.', color: '#42A5F5', getValue: (s) => s.avg_water_quality, formatValue: fmt2, formatDelta: fmtDelta2 },
|
||||
water_reef: { key: 'water_reef', label: 'Reef Health', tooltip: 'Average coral reef health across coastal tiles (0=dead, 1=pristine). Bleaching from high temps (>0.75) destroys reefs.', color: '#29B6F6', getValue: (s) => s.avg_water_reef, formatValue: fmt3, formatDelta: fmtDelta3 },
|
||||
}
|
||||
|
||||
const m = (key: string): MetricDef => METRIC_CATALOG[key]
|
||||
|
|
@ -100,16 +108,26 @@ const PRIMARY_METRICS: MetricDef[] = [
|
|||
formatDelta: fmtDelta2,
|
||||
},
|
||||
m('sea_level'),
|
||||
m('albedo'),
|
||||
m('net_energy'),
|
||||
m('net_hydro'),
|
||||
m('aerosol'),
|
||||
]
|
||||
|
||||
// Life mode: LAND column — Fauna, Flora, Quality, then details
|
||||
// Life mode: LAND column
|
||||
const LIFE_LEFT: MetricDef[] = [
|
||||
m('land_habitat'), m('land_canopy'), m('land_quality'), m('land_under'), m('land_fungi'),
|
||||
m('land_quality'),
|
||||
m('land_fauna_q'), m('land_fauna_p'),
|
||||
m('land_flora_q'), m('land_flora_p'),
|
||||
m('land_under'), m('land_fungi'),
|
||||
]
|
||||
|
||||
// Life mode: WATER column — Fauna, Flora, Quality
|
||||
// Life mode: WATER column
|
||||
const LIFE_RIGHT: MetricDef[] = [
|
||||
m('water_fish'), m('water_reef'), m('water_quality'),
|
||||
m('water_quality'),
|
||||
m('water_fauna_q'), m('water_fauna_p'),
|
||||
m('water_flora_q'), m('water_flora_p'),
|
||||
m('water_reef'),
|
||||
]
|
||||
|
||||
// Environment mode: left column (energy budget)
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export function encodeSnapshot(
|
|||
turn: number,
|
||||
events: EcologicalEvent[] = [],
|
||||
terrainCache?: Map<string, TerrainData>,
|
||||
prevStats?: TurnStats,
|
||||
): GridSnapshot {
|
||||
const n = grid.tiles.length
|
||||
const texA = new Float32Array(n * 4)
|
||||
|
|
@ -117,7 +118,7 @@ export function encodeSnapshot(
|
|||
texC[base + 3] = tile.habitat_suitability ?? 0.0
|
||||
}
|
||||
|
||||
const stats = computeTurnStats(grid, terrainCache)
|
||||
const stats = computeTurnStats(grid, terrainCache, prevStats)
|
||||
|
||||
return {
|
||||
texA, texB, texC,
|
||||
|
|
@ -138,6 +139,7 @@ const EMPTY_SCHOOL_RECORD: Record<LeySchool, number> = { death: 0, life: 0, natu
|
|||
export function computeTurnStats(
|
||||
grid: GridState,
|
||||
terrainCache?: Map<string, TerrainData>,
|
||||
prevStats?: TurnStats,
|
||||
): TurnStats {
|
||||
const { tiles, width, height } = grid
|
||||
let tempSum = 0
|
||||
|
|
@ -216,6 +218,8 @@ export function computeTurnStats(
|
|||
avg_water_quality: waterCount > 0 ? waterQualitySum / waterCount : 1,
|
||||
avg_aerosol: aerosolSum / n,
|
||||
avg_evapotranspiration: landCount > 0 ? etSum / landCount : 0,
|
||||
net_energy: prevStats ? (landCount > 0 ? tempSum / landCount : 0.5) - prevStats.avg_temp : 0,
|
||||
net_hydro: prevStats ? (landCount > 0 ? moistSum / landCount : 0.5) - prevStats.avg_moisture : 0,
|
||||
terrain_counts,
|
||||
}
|
||||
}
|
||||
|
|
@ -312,7 +316,8 @@ export function runScenarioSync(
|
|||
|
||||
const events = physics.processStep(grid, turn, worldSeed)
|
||||
ecology.processStep(grid)
|
||||
snapshots.push(encodeSnapshot(grid, scenarioTurn, events, terrainCache))
|
||||
const prev = snapshots.length > 0 ? snapshots[snapshots.length - 1].stats : undefined
|
||||
snapshots.push(encodeSnapshot(grid, scenarioTurn, events, terrainCache, prev))
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -349,7 +354,8 @@ export function extendSimulation(
|
|||
|
||||
const events = physics.processStep(grid, turn, worldSeed)
|
||||
ecology.processStep(grid)
|
||||
snapshots.push(encodeSnapshot(grid, scenarioTurn, events, terrainCache))
|
||||
const prev = snapshots.length > 0 ? snapshots[snapshots.length - 1].stats : undefined
|
||||
snapshots.push(encodeSnapshot(grid, scenarioTurn, events, terrainCache, prev))
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ export interface TurnStats {
|
|||
avg_water_quality: number // average quality across water tiles (1-5)
|
||||
avg_aerosol: number // global average sulfate aerosol opacity
|
||||
avg_evapotranspiration: number // average ET contribution across land tiles
|
||||
net_energy: number // net energy balance: solar_in - radiative_loss (positive=warming, negative=cooling)
|
||||
net_hydro: number // net water balance: evap+ET - decay-loss (positive=wetting, negative=drying)
|
||||
terrain_counts: Record<string, number>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
import { Routes, Route, Link } from 'react-router-dom'
|
||||
import { Routes, Route, Link, useSearchParams } from 'react-router-dom'
|
||||
import type { ReactElement } from 'react'
|
||||
import { DashboardPage } from './pages/DashboardPage'
|
||||
import { ReviewQueuePage } from './pages/ReviewQueuePage'
|
||||
import { CategoryPage } from './pages/CategoryPage'
|
||||
import { SpritePage } from './pages/SpritePage'
|
||||
import SpriteTheaterPage from './pages/SpriteTheaterPage'
|
||||
import { SpriteStream } from './SpriteStream'
|
||||
import { colors } from './pages/theme'
|
||||
|
||||
function DashboardOrTheater(): ReactElement {
|
||||
const [params] = useSearchParams()
|
||||
if (params.get('spriteTheater') === 'true') {
|
||||
return <SpriteTheaterPage />
|
||||
}
|
||||
return <DashboardPage />
|
||||
}
|
||||
|
||||
export function App(): ReactElement {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -22,6 +31,7 @@ export function App(): ReactElement {
|
|||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 24px',
|
||||
height: 48,
|
||||
background: colors.surface,
|
||||
|
|
@ -40,9 +50,18 @@ export function App(): ReactElement {
|
|||
>
|
||||
Sprite Review
|
||||
</Link>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Link to="/?spriteTheater=true" style={{ color: colors.muted, textDecoration: 'none', fontSize: 13 }}>
|
||||
Theater
|
||||
</Link>
|
||||
<Link to="/review" style={{ color: colors.muted, textDecoration: 'none', fontSize: 13 }}>
|
||||
Review
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/" element={<DashboardOrTheater />} />
|
||||
<Route path="/theater" element={<SpriteTheaterPage />} />
|
||||
<Route path="/review" element={<ReviewQueuePage />} />
|
||||
<Route path="/category/:name" element={<CategoryPage />} />
|
||||
<Route path="/sprite/*" element={<SpritePage />} />
|
||||
|
|
|
|||
194
tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx
Normal file
194
tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { useEffect, useState, useRef, type CSSProperties } from 'react'
|
||||
import { fetchRecentVariants, variantStreamUrl, rawImageUrl } from '../api'
|
||||
import type { RecentVariant } from '../types'
|
||||
import { colors } from './theme'
|
||||
|
||||
function extractFilename(rawPath: string): string {
|
||||
return rawPath.replace(/\\/g, '/').split('/').pop() ?? ''
|
||||
}
|
||||
|
||||
function parseScores(notes: string | null): Record<string, number> | null {
|
||||
if (!notes) return null
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(notes)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const result: Record<string, number> = {}
|
||||
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof v === 'number') result[k] = v
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : null
|
||||
}
|
||||
} catch { /* parse failure — no scores */ }
|
||||
return null
|
||||
}
|
||||
|
||||
function confidence(v: RecentVariant): number {
|
||||
const scores = parseScores(v.notes)
|
||||
if (!scores) return 0
|
||||
const vals = Object.values(scores)
|
||||
return vals.reduce((a, b) => a + b, 0) / vals.length
|
||||
}
|
||||
|
||||
const page: CSSProperties = {
|
||||
minHeight: '100vh',
|
||||
background: '#0a0a14',
|
||||
color: colors.text,
|
||||
padding: 0,
|
||||
}
|
||||
|
||||
const headerStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 24px',
|
||||
borderBottom: `1px solid ${colors.accent}`,
|
||||
background: colors.bg,
|
||||
}
|
||||
|
||||
const gridStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
gap: '12px',
|
||||
padding: '16px',
|
||||
}
|
||||
|
||||
function ScoreBadge({ label, value }: { label: string; value: number }) {
|
||||
const bg = value >= 0.7 ? 'rgba(16,185,129,0.2)' : value >= 0.5 ? 'rgba(245,158,11,0.2)' : 'rgba(239,68,68,0.2)'
|
||||
const fg = value >= 0.7 ? '#10b981' : value >= 0.5 ? '#f59e0b' : '#ef4444'
|
||||
return (
|
||||
<span style={{ fontSize: 9, padding: '1px 4px', borderRadius: 3, background: bg, color: fg }}>
|
||||
{label}: {(value * 100).toFixed(0)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Card({ v }: { v: RecentVariant }) {
|
||||
const conf = confidence(v)
|
||||
const scores = parseScores(v.notes)
|
||||
const passing = conf >= 0.7
|
||||
const filename = extractFilename(v.raw_path)
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: colors.surface,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
border: passing ? '2px solid #10b981' : v.is_approved ? '2px solid #8b5cf6' : '1px solid #1e293b',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<img
|
||||
src={rawImageUrl(filename)}
|
||||
alt={v.entity_id}
|
||||
style={{ width: '100%', display: 'block', aspectRatio: '1/1', objectFit: 'cover' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
{conf > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute', top: 8, right: 8,
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 700,
|
||||
background: passing ? '#10b981' : conf >= 0.5 ? '#f59e0b' : '#ef4444',
|
||||
color: '#000',
|
||||
}}>{(conf * 100).toFixed(0)}%</span>
|
||||
)}
|
||||
<span style={{
|
||||
position: 'absolute', top: 8, left: 8,
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 10, fontWeight: 600,
|
||||
background: 'rgba(0,0,0,0.7)', color: colors.muted,
|
||||
}}>{v.category}</span>
|
||||
{v.is_approved && (
|
||||
<span style={{
|
||||
position: 'absolute', bottom: 8, right: 8,
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 700,
|
||||
background: '#8b5cf6', color: '#000',
|
||||
}}>APPROVED</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: '8px 10px', fontSize: 11, color: colors.muted }}>
|
||||
<div style={{ fontWeight: 600, color: colors.text, marginBottom: 2 }}>{v.entity_id}</div>
|
||||
<div>seed: {v.seed}</div>
|
||||
{scores && (
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 4 }}>
|
||||
{Object.entries(scores).map(([k, val]) => (
|
||||
<ScoreBadge key={k} label={k.replace('_', ' ').replace('production ', 'prod ')} value={val} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SpriteTheaterPage() {
|
||||
const [variants, setVariants] = useState<RecentVariant[]>([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecentVariants(100).then(setVariants).catch(() => { /* initial load failed — will retry via SSE */ })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const es = new EventSource(variantStreamUrl())
|
||||
eventSourceRef.current = es
|
||||
|
||||
es.onopen = () => setConnected(true)
|
||||
es.onerror = () => setConnected(false)
|
||||
|
||||
es.onmessage = (event: MessageEvent<string>) => {
|
||||
try {
|
||||
const newVariants: RecentVariant[] = JSON.parse(event.data) as RecentVariant[]
|
||||
setVariants(prev => {
|
||||
const ids = new Set(prev.map(v => v.variant_id))
|
||||
const fresh = newVariants.filter(v => !ids.has(v.variant_id))
|
||||
if (fresh.length === 0) return prev
|
||||
return [...fresh, ...prev]
|
||||
})
|
||||
} catch { /* keepalive or malformed — ignore */ }
|
||||
}
|
||||
|
||||
return () => es.close()
|
||||
}, [])
|
||||
|
||||
const sorted = [...variants].sort((a, b) => {
|
||||
const ca = confidence(a)
|
||||
const cb = confidence(b)
|
||||
if (ca !== cb) return cb - ca
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={page}>
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<a href="/" style={{ color: colors.text, textDecoration: 'none', fontSize: 18, fontWeight: 700 }}>
|
||||
Sprite Theater
|
||||
</a>
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: connected ? '#10b981' : '#ef4444',
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
<span style={{ fontSize: 12, color: colors.muted }}>
|
||||
{connected ? 'Live' : 'Disconnected'} — {variants.length} sprites
|
||||
</span>
|
||||
</div>
|
||||
<a href="/" style={{ color: colors.muted, textDecoration: 'none', fontSize: 13 }}>
|
||||
Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{variants.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 80, color: colors.muted }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>Waiting for sprites...</div>
|
||||
<div>Generate sprites with <code style={{ color: colors.text }}>./run tools spritegen generate</code></div>
|
||||
<div style={{ marginTop: 8 }}>New images will appear here in real-time</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={gridStyle}>
|
||||
{sorted.map(v => <Card key={v.variant_id} v={v} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
BIN
tools/sprite-generation/sprites.db-shm
Normal file
BIN
tools/sprite-generation/sprites.db-shm
Normal file
Binary file not shown.
0
tools/sprite-generation/sprites.db-wal
Normal file
0
tools/sprite-generation/sprites.db-wal
Normal file
Loading…
Add table
Reference in a new issue