docs(plans): 📝 Update and refine project planning documents in .claude/plans/ to reflect roadmap and strategic initiatives.

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-25 23:53:20 -07:00
parent 491167f118
commit 55abdcc1d9
2 changed files with 181 additions and 75 deletions

View file

@ -0,0 +1,121 @@
# Sprite Stream Ticker — Live Ambient Display
## Context
The spritegen UI (`tools/sprite-generation/gui/`) currently has no real-time feedback when sprites are being generated. The dashboard polls `/api/progress` every 10s for aggregate counts, but there's no visual of actual images arriving. The goal is a beautiful ambient ticker across the top of the app that streams completed sprites horizontally — functioning as both a progress indicator and an idle screensaver.
## Architecture
```
Backend (SSE + REST) Frontend (React)
GET /api/variants/recent ─────────► Initial load (30 recent sprites)
GET /api/stream/variants ─SSE──────► Real-time push of new completions
SpriteStream.tsx
├── CSS @keyframes marquee (infinite scroll)
├── SSE EventSource consumer
├── Glow animation on new arrivals
└── Pause on hover, click to navigate
```
## Changes
### 1. `engine/registry.py` — Add `get_recent_variants()`
New method on `SpriteRegistry` (after `get_pending_variants` ~line 427):
```python
def get_recent_variants(self, limit: int = 30, since: str | None = None) -> list[dict]:
```
- JOIN `variants v` to `sprites s` on `v.sprite_id = s.id`
- Filter: `v.job_status = 'completed' AND v.raw_path IS NOT NULL`
- Optional: `AND v.created_at > ?` when `since` provided
- ORDER BY `v.created_at DESC`, LIMIT
- Return: `variant_id, sprite_id, category, entity_id, raw_path, processed_path, seed, created_at`
### 2. `server.py` — Two new endpoints
**REST:** `GET /api/variants/recent?limit=30` — calls `registry.get_recent_variants()`, returns JSON array. Goes in the "Progress & Queue" section.
**SSE:** `GET /api/stream/variants` — async generator polling DB every 3s for new completions since last check. Sends `data: [...]` events with new variants, or SSE comment keepalives. Uses `StreamingResponse(media_type="text/event-stream")`. Add imports: `asyncio`, `StreamingResponse`.
### 3. `gui/src/types.ts` — Add `RecentVariant` interface
```typescript
export interface RecentVariant {
variant_id: number
sprite_id: string
category: string
entity_id: string
raw_path: string
processed_path: string | null
seed: number
created_at: string
}
```
### 4. `gui/src/api.ts` — Add fetch helpers
- `fetchRecentVariants(limit = 30): Promise<RecentVariant[]>`
- `variantStreamUrl(): string` — returns SSE endpoint URL
### 5. `gui/src/SpriteStream.tsx` — New component (the main piece)
**Visual design:**
- Full-width strip at top of app, ~110px tall
- Background: gradient fade from `colors.surface` to transparent — ambient, not blocky
- Sprites scroll right-to-left infinitely via CSS `@keyframes` marquee
- Each sprite: 80px tall image + 10px entity label below, semi-transparent (opacity 0.7)
- New arrivals from SSE get a 3s highlight glow animation (`colors.highlight` box-shadow pulse)
- Hover pauses the scroll, raises opacity to 1.0
- Click navigates to `/sprite/{id}` for review
- Empty state: subtle pulsing "Waiting for sprites..." text
**Marquee technique:**
- Render the sprite array twice in a flex row (A + A clone)
- `translateX(0) → translateX(-50%)` animation creates seamless infinite loop
- Duration scales with sprite count (minimum 20s, ~2s per sprite)
- `will-change: transform` for GPU compositing
**SSE lifecycle:**
- On mount: fetch initial sprites, open EventSource
- On SSE message: parse JSON, dedupe by variant_id, prepend to list, cap at 60 items
- On SSE error: retry with backoff (5s → 10s → 20s, cap 30s), fall back to 10s polling after 3 failures
- On unmount: close EventSource
**Injected `<style>` tag** for `@keyframes` (project uses inline styles only, keyframes can't be inline).
### 6. `gui/src/App.tsx` — Wire it in
Import `SpriteStream`, render it above the `<nav>` element. The ticker sits at the very top of the viewport across all pages.
## File Summary
| File | Action |
|------|--------|
| `engine/registry.py` | Add method |
| `server.py` | Add 2 endpoints + imports |
| `gui/src/types.ts` | Add interface |
| `gui/src/api.ts` | Add 2 functions |
| `gui/src/SpriteStream.tsx` | **Create** |
| `gui/src/App.tsx` | Add import + component |
## Build Order
1. `registry.py` (no deps)
2. `server.py` (depends on 1)
3. `types.ts` + `api.ts` (parallel, no deps)
4. `SpriteStream.tsx` (depends on 3)
5. `App.tsx` (depends on 4)
## Verification
1. Start backend: `cd tools/sprite-generation && uvicorn server:app --port 5801 --reload`
2. Test REST: `curl localhost:5801/api/variants/recent`
3. Test SSE: `curl -N localhost:5801/api/stream/variants` (should see keepalive comments every 3s)
4. Start frontend: `cd tools/sprite-generation/gui && pnpm dev`
5. Open `localhost:5802` — ticker should appear with any completed sprites from the DB
6. If generation is active, new sprites should appear with glow animation in real-time

View file

@ -1,100 +1,85 @@
# Plan: Flat hex tiles in polar projection
# Plan: Proper polar hex grid rendering
## Context
The current polar view (v2) uses nearest-neighbor sampling to create Voronoi-like cells from the azimuthal projection. These cells are warped/distorted by the projection — they don't look like the flat hex tiles in the equator view. The user wants actual regular flat hexagons placed at their polar-projected positions, with gaps between tiles that increase toward the edges.
The current polar view attempts to project all 40 hex columns at every latitude ring into a circular disk. This is fundamentally broken because:
- **Near the pole, 40 tiles converge to a point** — they can't all fit at uniform size
- **Tile shapes warp** — the flat-top hex containment test doesn't account for angular position
- **The center is unusable** — an unreadable mess of overlapping slivers
## Approach: hex-shape mask in the fragment shader
## Research findings
Keep the existing polar projection pipeline but add a **hex-shape containment test**. For each pixel:
Per [Red Blob Games](https://www.redblobgames.com/x/1640-hexagon-tiling-of-sphere/) and [CivFanatics sphere hex discussion](https://forums.civfanatics.com/threads/solution-the-globe-in-civ-v-a-really-spherical-map-with-only-hexagonal-tiles.356334/):
1. Map screen UV → grid (col, row) via `polarToGrid()` (existing)
2. Convert to pixel space → find nearest hex center → get integer (col, row)
3. Project that hex center **back to screen UV** via inverse polar (new function)
4. Test if the pixel falls within a **regular flat-top hexagon** at that screen position (new function)
5. If outside hex → dark background; if inside → color with hex data (existing layer logic)
- **You cannot tile a sphere with uniform hexagons** — 12 pentagons are always required (Euler's theorem)
- **Civ V/VI avoid the problem entirely** — they use a cylindrical map that wraps E-W but NOT N-S. Poles are just the top/bottom edge of a flat rectangle. There is no polar view.
- **Icosahedron subdivision** is the proper approach for spherical hex grids, but our game uses a flat equirectangular grid (40×24), not a geodesic sphere
- The fundamental issue: our grid has a **fixed column count at every latitude**. On a real sphere, polar latitudes should have fewer tiles.
This gives flat regular hexagons positioned in polar layout. Near the pole they cluster tightly; near the edges they spread apart with visible gaps.
## Recommended approach: Column decimation with uniform tiles
## File: `guide/engine/src/components/climate-sim/hexGLShaders.ts`
Since our grid is equirectangular (not geodesic), the polar view is a **visualization tool**, not a gameplay surface. The approach:
### New GLSL functions
**For each latitude ring, compute how many columns physically fit at uniform tile size, and only render those columns evenly spaced around the ring.**
**`findNearestHex(px)`** — same search as `sampleNearest` but returns `vec2(col, row)` instead of texture data:
```glsl
vec2 findNearestHex(vec2 px) {
// Same 5×3 neighborhood search as sampleNearest
// Returns vec2(float(bestCol), float(bestRow))
}
```
### Algorithm
**`gridToScreenUV(col, row, pole)`** — inverse polar projection (grid → screen):
```glsl
vec2 gridToScreenUV(float col, float row, int pole) {
lonFrac = (col + 0.5) / mapWidth
latFrac = row / (mapHeight - 1) [or inverted for south pole]
angle = lonFrac * 2π - π
radius = latFrac * 0.5
return vec2(0.5 + radius*cos(angle)/aspect, 0.5 + radius*sin(angle))
}
```
1. Compute uniform hex size from zoom level (row spacing)
2. For each row ring at radius `r` from the pole center:
- Compute circumference: `C = 2π × r`
- Compute how many uniform-width tiles fit: `maxCols = floor(C / tileWidth)`
- Clamp to `[1, 40]`
- Compute column step: `colStep = max(1, round(40 / maxCols))`
- Only render columns that are multiples of `colStep`
3. For the pole center (row 0, radius ≈ 0): render a single centered hex
4. Each rendered tile samples data from its grid column, giving a representative view
**`hexContains(pixelUV, centerUV, hexR)`** — flat-top regular hexagon test in aspect-corrected screen space:
```glsl
bool hexContains(vec2 pUV, vec2 cUV, float hexR) {
// Aspect-correct the delta
// Flat-top hex test: |dx| <= R, |dy| <= R*√3/2, |dx|*√3/2 + |dy|*0.5 <= R*√3/2
}
```
### Why not octagons?
### Modified polar path in `main()`
Octagons don't tile a plane (they leave square gaps). The research shows the best approach is hexagons + pentagons (geodesic), but since our data is on a fixed 40-column grid, the column decimation approach is the pragmatic solution. The tiles remain proper flat-top hexagons — we just show fewer of them near the pole.
Replace the current polar branch (lines ~242-263) with:
### Shader changes (`hexGLShaders.ts`)
In the polar path of `main()`, after finding the nearest hex `(nearCol, nearRow)`:
```glsl
} else {
vec2 grid = polarToGrid(vUv, uViewCenter);
if (grid.x < 0.0) { outsideDisk = true; px = vec2(0.0); }
else {
px = gridToPx(grid);
vec2 nearHex = findNearestHex(px);
float nearCol = nearHex.x;
float nearRow = nearHex.y;
// How many columns fit at this ring's circumference
float rSpace = polarRowSpacing();
float rowOffset = (uViewCenter == 1) ? nearRow : (uMapSize.y - 1.0) - nearRow;
float radius = rowOffset * rSpace;
float hw = rSpace * 0.45 * (uHexW / uHexH); // hex half-width from zoom
float tileWidth = hw * 2.5; // tile width + gap
float maxCols = max(1.0, floor(6.28318 * radius / tileWidth));
float colStep = max(1.0, ceil(uMapSize.x / maxCols));
// Project hex center to screen for shape test
vec2 hexScreenUV = gridToScreenUV(nearCol, nearRow, uViewCenter);
// Hex size: radial spacing, scaled down near pole by tangential spacing
float radialR = 0.5 / (uMapSize.y - 1.0);
float latFrac = (uViewCenter == 1) ? nearRow / (uMapSize.y - 1.0) : 1.0 - nearRow / (uMapSize.y - 1.0);
float tangentialR = 3.14159265 * latFrac * 0.5 / uMapSize.x;
float hexR = min(radialR, tangentialR) * 0.8;
hexR = max(hexR, 0.003); // minimum visible size at pole
if (!hexContains(vUv, hexScreenUV, hexR)) {
gl_FragColor = vec4(0.04, 0.03, 0.06, 1.0);
return;
}
// Sample data for this specific hex
vec2 dataUV = vec2((nearCol + 0.5) / uMapSize.x, (nearRow + 0.5) / uMapSize.y);
s.a = texture2D(uTexA, dataUV);
s.b = texture2D(uTexB, dataUV);
}
}
// Snap to nearest visible column
float snappedCol = floor(nearCol / colStep + 0.5) * colStep;
snappedCol = mod(snappedCol, uMapSize.x);
```
### Delta layer update
Then use `snappedCol` for `gridToScreenUV` and data sampling. The `hexContains` test stays the same — uniform size at all positions.
The delta layer block also needs to use `findNearestHex` + direct texture lookup in polar mode (same pattern as main sampling).
### Renderer changes (`HexGLRenderer.tsx`)
## No changes needed in other files
Already done: `uPolarRows` uniform + mouse wheel handler. No additional changes needed.
`HexGLRenderer.tsx` and `ClimateSimDisplay.tsx` are already correct from the previous iteration.
## Files to modify
| File | Change |
|------|--------|
| `guide/engine/src/components/climate-sim/hexGLShaders.ts` | Column decimation in polar path |
## Verification
1. `?view=equator` — unchanged flat hex grid
2. `?view=north` — flat regular hexagons in polar layout, ice at center, gaps increasing toward edges
3. `?view=south` — same from south pole
4. Take Playwright screenshots of all 3 views
1. `?view=north` — pole center shows 1-3 tiles, rings radiate outward with increasing column count
2. All visible tiles are the same size flat-top hexagons
3. Mouse wheel zooms in/out (fewer/more rows, larger/smaller tiles)
4. `?view=equator` — completely unchanged
5. Take Playwright screenshots at multiple zoom levels
## Sources
- [Red Blob Games: Hex tiling of sphere](https://www.redblobgames.com/x/1640-hexagon-tiling-of-sphere/)
- [CivFanatics: Globe in Civ V](https://forums.civfanatics.com/threads/solution-the-globe-in-civ-v-a-really-spherical-map-with-only-hexagonal-tiles.356334/)
- [D3 Azimuthal Equidistant](https://d3js.org/d3-geo/azimuthal)
- [Freeciv Sphere discussion](https://freeciv.fandom.com/wiki/Sphere)