Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions docs/redesign-plan.md
Original file line number Diff line number Diff line change
@@ -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<void>` — `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`, `<meta theme-color>`, 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 `<canvas>` 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.
Loading