diff --git a/docs/redesign-plan.md b/docs/redesign-plan.md new file mode 100644 index 0000000..145670f --- /dev/null +++ b/docs/redesign-plan.md @@ -0,0 +1,123 @@ +# Civ2-Style Isometric Frontend Redesign + +## Context + +The current frontend (SvelteKit SPA + Pixi.js 8) renders the game world as a **flat-top hex grid** with all tile art drawn procedurally in code. The user finds it unattractive and wants a complete visual overhaul in the style of **Civilization II** — a 2.5D **isometric** look built from **pixel-art sprites**. + +The backend world is a **square 75×75 tile grid**, so an isometric diamond projection is a *more* natural fit than the current hex mapping (each `(x,y)` becomes a diamond via a simple affine transform). The redesign is **frontend-only**: it reuses the existing RPC data contract entirely and creates **no new RPCs** and touches no generated/proto code. + +### Confirmed decisions (from the user) +1. **Isometric diamond grid** (2:1), replacing the hex grid. +2. **Sprite-based** pixel art via a Pixi `Assets`/spritesheet pipeline. Art must be **CC0/public-domain or generated by us** — never copyrighted Civ2/Firaxis assets. A procedural-diamond fallback guarantees the map always renders. +3. **Whole-app scope**: game renderer + HUD, plus reskin of login/register, a real landing page, and a global theme (design tokens + fonts). +4. **Modern-retro hybrid HUD**: Civ2-style beveled stone/metal panels + a minimap, but a clean readable UI font (pixel/bitmap font reserved for titles/accents). + +### Key insight that de-risks the work +- **Picking gets simpler.** Hex `pixelToHex` uses cube-rounding; the iso square grid is an affine map, so screen→tile is just the inverse matrix + `Math.round`. Guard with a round-trip assertion. +- **Fog already works.** `getVisDist()` (`web/src/routes/game/+page.svelte`) is already correct square-grid Chebyshev math — **keep verbatim**. +- **Camera is coordinate-agnostic.** `animateCam`/`zoomAt`/`panBy`/drag/`clampPos` operate in container pixel space; only map-bounds constants and the tile↔pixel calls change. +- **Buildings are single-tile.** A city is a `size×size` territory with one center building; each `Building` occupies one tile. Depth-sort by `x+y` with single-tile tall sprites is sufficient — no multi-tile footprint sorting. + +--- + +## 1. Asset strategy (CC0 only) + +**Standard dims:** `TW=64`, `TH=32` base diamond (pixel-art iso standard). Flat terrain = `64×32`; tall buildings = `64×(80–128)` with the bottom `64×32` being the base diamond. Kenney iso packs are `128px` (exactly 2×) — downscale or use at 2× with nearest-neighbor. + +**License-safe backbone (all CC0):** +| Source | URL | Use | +|---|---|---| +| Screaming Brain — Iso Overworld Pack | https://screamingbrainstudios.itch.io/iso-overworld-pack | pixel grass/dirt/water/farm | +| Screaming Brain — Iso Town Pack | https://screamingbrainstudios.itch.io/iso-town-pack | pixel houses/barracks/roofs | +| Screaming Brain — Iso Floor/Wall/Pathways/Object packs | https://screamingbrainstudios.itch.io/ | city ground, roads, mine/props | +| Kenney — Isometric Tiles Landscape | https://kenney.nl/assets/isometric-tiles-landscape | terrain fallback | +| Kenney — Isometric Tiles Buildings / City | https://kenney.nl/assets/isometric-tiles-buildings · https://kenney.nl/assets/isometric-tiles-city | houses, civic, keep/tower | +| Kenney — isometric tag (Miniature/Bases) | https://kenney.nl/assets/tag:isometric | mine/keep props | +| OpenGameArt — CC0 isometric collections | https://opengameart.org/content/cc0-isometric-tiles · https://opengameart.org/content/cc0-isometric | supplemental — **verify each item's CC0 badge** (collections mix licenses) | + +**MUST NOT USE (fail the CC0 constraint):** OGA *Medieval Building Tiles* (Bellanger, CC-BY-SA/GPL), OGA *Isometric 64×64 Outside Tileset* (Yar, CC-BY 3.0), and any LPC content (CC-BY-SA/GPL). + +**Primary = Screaming Brain (genuine CC0 pixel art); Kenney (CC0, flat-shaded) fills gaps.** Pick **one** primary pack per category and normalize to a single light direction to keep styles coherent. AI pixel-art generators are unreliable for coherent multi-angle iso sprites — treat as last resort; the runtime procedural fallback covers anything missing. + +**Atlas:** pack chosen PNGs offline with free `free-tex-packer` (or TexturePacker CLI, "Phaser/Pixi JSON Hash" preset) into a **single** `web/static/sprites/tiles.png` + `tiles.json`. Runtime `Assets.load('/sprites/tiles.json')` → `Spritesheet`; set `texture.source.scaleMode = 'nearest'`. Single texture → batched draws. A hand-authored JSON Hash is fine for the ~15 frames needed. + +**TileKind → sprite mapping** (`grass, city, farm, house, barracks, mine, town_center, city_center` → sprites above; `fog` → generated dark diamond; `construction/selection/starving` → Pixi `Graphics` overlays, redrawn as diamonds). Variant chosen via existing `tileHash` (`colors.ts`). + +**Custom-sprite generation path (optional, for uncovered kinds):** offline `web/scripts/gen-sprites.mjs` (not bundled) — Canvas2D procedural draw reusing `colors.ts`, or a headless Blender 2:1 dimetric render. Anything still missing falls through to the runtime fallback. + +--- + +## 2. Isometric renderer + +### New `web/src/lib/game/iso.ts` (replaces `hex.ts` consumers) +``` +TW=64, TH=32, HW=32, HH=16 +DIAMOND_VERTS = [0,-HH, HW,0, 0,HH, -HW,0] // top,right,bottom,left +tileToScreen(x,y) = { sx:(x-y)*HW, sy:(x+y)*HH } +screenToTile(sx,sy) = { x:round((sx/HW+sy/HH)/2), y:round((sy/HH-sx/HW)/2) } +tileKey(x,y) = `${x},${y}` // keep identical signature +NEIGHBORS4 = [[1,0],[0,1],[-1,0],[0,-1]]; neighbors4(x,y) +EDGE_TO_NEIGHBOR: diamond edge i → [T-R:(x,y-1), R-B:(x+1,y), B-L:(x,y+1), L-T:(x-1,y)] +mapBounds(N): diamond AABB for culling/clamp +``` +Keep `tileKey` unchanged; export `DIAMOND_VERTS` to slot in where `HEX_VERTS` was used. + +### New `web/src/lib/game/sprites.ts` (replaces `tiles.ts`) +- `initSprites(): Promise` — `Assets.load` the atlas, nearest-neighbor scale mode, store the `Spritesheet`. Call once in `onMount` before first `loadVisible`. +- `SPRITE_META: kind → { frame, w, h, anchorX, anchorY, variants }`; `anchorY=(h-HH)/h` so a tall sprite's base-diamond center lands on the tile; `anchorX=0.5`. Terrain stays flat-anchored. +- `getTileSprite(kind,col,row,level): Sprite` — variant via `tileHash`, optional level-tiered frame, `Texture` cache keyed `kind:variant:levelTier` (mirror current `tiles.ts` cache). +- `makeFallbackDiamond(kind): Texture` — Canvas2D diamond using `BASE[kind]`+`varyColor`, 2px bevel + short south face. Used for `fog` and any missing frame. + +### `web/src/routes/game/+page.svelte` rework (the large edit) +Swap the geometry layer; keep all state machinery. Repeated pattern: +- Imports: `hex`→`iso`, `tiles`→`sprites`. +- `renderTile`: `hexToPixel`→`tileToScreen`; depth `tc.zIndex=(col+row)*8` with the building sprite as a later child (+bias); anchor from `SPRITE_META`. Same `loaded` container cache. +- Territory borders / starving segments: replace the 6-edge `hexNeighbors` loop with a 4-edge loop over `DIAMOND_VERTS` + `EDGE_TO_NEIGHBOR` comparing `city.cityId`. Owner colors unchanged (blue/red/gray). +- Construction ring / selection / `drawSel`: `HEX_VERTS`→`DIAMOND_VERTS` (the ring/arc code is geometry-agnostic). +- `loadVisible` (iso culling): `screenToTile` the 4 viewport corners → min/max `x,y` with padding (`+2` sides, `+4` bottom rows for tall sprites poking up), clamp, iterate. +- `clampPos`: map bounds from `mapBounds(mapSize)`. `getCenter`/`pointerup` picking: `pixelToHex`→`screenToTile`. +- **Keep unchanged:** `getVisDist`, `buildLookup`, `tileData`, rAF-debounced `rebuildTiles`, `computeProd`, `centerOnCity`/`cycleCity`, all HUD data bindings, `buildingClient`/`cityClient` actions, wheel/keyboard input. + +**Trickiest parts:** (1) tall-sprite depth — `zIndex=(x+y)*8+bias`, deterministic tie-break; (2) rotated-viewport culling — bbox-in-tile-space + optional center-in-rect; (3) anchor/z-fighting — per-sprite `anchorY`, ≥1px atlas padding, nearest-neighbor; (4) picking — assert `screenToTile(tileToScreen(x,y))===(x,y)`. + +--- + +## 3. HUD + theme + +- **Tokens** (`web/tailwind.config.ts`, `theme.extend`): warm stone greys + parchment + bronze/gold accents (emerald kept secondary); `fontFamily.sans` (Inter) + `fontFamily.display` (pixel font); `boxShadow` `bevel`/`bevel-inset` (layered inset light-top-left + dark-bottom-right for beveled metal/stone); blocky radii. +- **Fonts (self-hosted, offline-safe):** UI = **Inter** (OFL, `@fontsource-variable/inter`); display = **Press Start 2P** or **Silkscreen** (OFL, `@fontsource/*`) for titles/accents only. Import in `app.css`. +- **`app.css`**: `@layer base` body bg (dark stone) + CSS vars; `@layer components` `.panel` (bevel), `.panel-title`, `.btn`/`.btn-primary`/`.btn-danger`, `.field`. Define once, reuse. +- **`app.html`**: `lang`, ``, background color (avoid white flash). +- **Shared components** `web/src/lib/components/`: `Panel.svelte`, `Button.svelte`, `TextField.svelte` (absorbs show/hide-password logic), `Brand.svelte`, `MiniMap.svelte` — eliminates the duplicated markup across login/register. +- **Minimap** (`MiniMap.svelte`, **no new RPC**): a ~150×150 `` drawn as a plain **square** grid (readability, not iso). Pixels from `$cities` (owner AABB colors) + `$buildings` + fog; viewport rectangle from `$mapCenter`+zoom; click-to-pan via a callback prop wired to the renderer's `centerCam`. rAF-throttled redraw on store change. +- **Landing + shell:** `+layout.svelte` gets a themed wrapper. `routes/+page.svelte` becomes a real landing page (hero, pixel-title, CTAs) **while preserving** the existing "valid token → `getUser` → `/game`" auto-redirect for returning users (show landing only when logged out). + +--- + +## 4. File-level change list + +**New:** `web/src/lib/game/iso.ts`, `web/src/lib/game/sprites.ts`, `web/static/sprites/tiles.{png,json}`, `web/src/lib/components/{Panel,Button,TextField,Brand,MiniMap}.svelte`, (optional, unbundled) `web/scripts/gen-sprites.mjs`. + +**Modified:** `web/tailwind.config.ts` (tokens), `web/src/app.css` (fonts + component layer), `web/src/app.html` (lang/theme/bg), `web/src/routes/+layout.svelte` (shell), `web/src/routes/+page.svelte` (landing, keep redirect), `web/src/routes/login/+page.svelte` + `register/+page.svelte` (reskin via shared components), `web/src/routes/game/+page.svelte` (renderer + HUD rework + mount minimap), `web/package.json` (add `@fontsource*`; drop pixi asset needs — none). **Stretch:** `web/src/lib/stores.ts` + `game/+layout.svelte` (armies store + stream render — data already shipped, still no new RPC). + +**Deleted:** `web/static/grass1.png` (orphaned). + +**Off-limits (unchanged):** `web/src/lib/gen/**`, all proto, all RPC clients. `web/src/lib/game/{hex.ts,tiles.ts}` are superseded by `iso.ts`/`sprites.ts` — delete once no importers remain. + +--- + +## 5. Verification + +- **Run:** `yarn dev` in `web/` with backend on `http://localhost:8080` (`.env.development`). Register/login → `/game`. +- **Observe/click:** iso diamond map with sprites; grass variants differ per tile; fog beyond `visionRadius`; owner-colored diamond territory borders; click selects a tile (diamond highlight, inspector shows correct `x,y`); Build/Upgrade/Demolish work; construction ring + starving border animate; drag/flick/zoom/pinch/trackpad + WASD/`[`/`]`/`C`/`0` nav work; minimap shows cities + viewport box and click-to-pan works; reskinned login/register + new landing render with theme/fonts. +- **Assert:** `screenToTile(tileToScreen(x,y))===(x,y)` across sample tiles (picking round-trip). +- **Gates:** `yarn check` (svelte-check) and `yarn lint` (prettier+eslint) clean. Style: tabs, single quotes, no trailing commas, width 200. +- **Performance:** 75×75 = 5,625 tiles but culling renders only the visible window; single atlas → batched draws; reuse `loaded` cache + rAF-debounced rebuild. Test full zoom-out + rapid pan; if zoomed-out count spikes, cap min-zoom or add a low-detail LOD. + +--- + +## 6. Sequencing & risks + +**Build order:** (1) theme foundation (tokens, `app.css`, `app.html`, `+layout`, shared components) → (2) `iso.ts` + picking assertion → (3) sprite pipeline with 1–2 tiles + fallback → (4) full CC0 atlas + all 9 kinds → (5) renderer rework in `game/+page.svelte` → (6) HUD/minimap restyle → (7) auth reskin + landing → (8) stretch: armies. + +**Top risks:** (1) **art coherence** across mixed CC0 packs — one primary pack per category, single light direction, prefer Screaming Brain; (2) **performance** — culling + single atlas + container reuse, LOD if needed; (3) **iso picking** — round-trip assertion; (4) **anchor/z-fighting** — per-sprite anchorY, atlas padding, nearest-neighbor, `zIndex=(x+y)*8+bias`; (5) **license drift** — CC0-only, exclude the flagged CC-BY/SA/GPL sets, verify each OGA-collection item.