Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,40 @@ pnpm dev
3. Right-click to erase tiles, and click-drag to paint quickly.
4. Save/load maps locally or export your map as PNG.

## Tile Assets

- Runtime source: `public/tiles/<realm>/r<row>-c<col>.png`
- Single tile sprite size: `130x230` pixels
- Isometric placement footprint on canvas: `128x64` pixels
- Optional index file: `public/tiles/manifest.json`

### Character Overlays

- Runtime source: `public/characters/<character-realm>/<character-id>.svg|png`
- Character sprite canvas should also be `130x230` pixels (same as tile sprite canvas)
- Recommended character figure footprint inside that canvas: around `44x88` pixels to `56x104` pixels
- Anchor character feet near the tile anchor baseline (roughly the same visual base line as terrain sprites)
- Characters render on top of terrain and occupy one tile cell
- Included sample set: `public/characters/hobbits/hobbit-1.png` to `hobbit-8.png`

### Add new tile items (flexible rows/cols)

You can add any number of items per row. The app no longer assumes fixed `0..11` columns.

1. Add tile PNG files with this naming pattern:
- `public/tiles/<realm>/r<row>-c<col>.png`
- Examples: `r0-c12.png`, `r0-c19.png`, `r1-c5.png`
2. Register each tile in the picker config at `lib/tiles.ts`:
- Use the correct group `row` and tile `col`.
- You do not need to pad groups with `"Empty"` placeholders.
- You can add new groups with new `row` indices when needed.
3. Restart the app (or refresh) and the tile will render in picker + canvas.

Notes:

- Runtime rendering supports variable row/col values and falls back to `r0-c0` if a specific tile image is missing.
- Collection validation now accepts tile coordinates as non-negative integers (`row >= 0`, `col >= 0`).

## Collections

Shared maps are available at `/collections`.
Expand Down
6 changes: 2 additions & 4 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { Suspense } from "react";
import IsoCanvas from "@/components/iso-canvas";
import TilePicker from "@/components/tile-picker";
import AssetPicker from "@/components/asset-picker";
import Toolbar from "@/components/toolbar";
import CollectionLoader from "@/components/collection-loader";



export default function Home() {
return (
<main className="flex h-dvh flex-col overflow-hidden">
Expand All @@ -14,7 +12,7 @@ export default function Home() {
</Suspense>
<Toolbar />
<IsoCanvas />
<TilePicker />
<AssetPicker />
</main>
);
}
4 changes: 2 additions & 2 deletions collections/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ Each tile in the map matrix is one of:

Ranges:

- `row`: `0..5`
- `col`: `0..11`
- `row`: integer `>= 0`
- `col`: integer `>= 0`
- `realm`: only for mixed mode, one of `shire`, `gondor`, `mordor`, `lothlorien`, `rohan`, `moria`, `rivendell`

## Validate locally
Expand Down
File renamed without changes.
8 changes: 4 additions & 4 deletions collections/schema/map.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,17 @@
"minItems": 2,
"maxItems": 2,
"prefixItems": [
{ "type": "integer", "minimum": 0, "maximum": 5 },
{ "type": "integer", "minimum": 0, "maximum": 11 }
{ "type": "integer", "minimum": 0 },
{ "type": "integer", "minimum": 0 }
]
},
{
"type": "array",
"minItems": 3,
"maxItems": 3,
"prefixItems": [
{ "type": "integer", "minimum": 0, "maximum": 5 },
{ "type": "integer", "minimum": 0, "maximum": 11 },
{ "type": "integer", "minimum": 0 },
{ "type": "integer", "minimum": 0 },
{
"type": "string",
"enum": [
Expand Down
34 changes: 34 additions & 0 deletions components/asset-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use client";

import TilePicker from "@/components/tile-picker";
import CharacterPicker from "@/components/character-picker";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { HugeiconsIcon } from "@hugeicons/react";
import { Castle02Icon, UniversalAccessIcon } from "@hugeicons/core-free-icons";

export default function AssetPicker() {
return (
<div className="shrink-0 border-t bg-background">
<Tabs defaultValue="buildings" className="gap-0">
<div className="border-b px-2 py-2 sm:px-3">
<TabsList>
<TabsTrigger value="buildings">
<HugeiconsIcon icon={Castle02Icon} />{" "}
<span className="font-light text-xs">Buildings</span>
</TabsTrigger>
<TabsTrigger value="characters">
<HugeiconsIcon icon={UniversalAccessIcon} />{" "}
<span className="font-light text-xs">Characters</span>
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="buildings" className="h-48">
<TilePicker />
</TabsContent>
<TabsContent value="characters" className="h-48">
<CharacterPicker />
</TabsContent>
</Tabs>
</div>
);
}
86 changes: 86 additions & 0 deletions components/character-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client";

import { useMemo } from "react";
import { useMapStore } from "@/lib/store";
import {
CHARACTERS,
CHARACTER_REALMS,
getCharacterPath,
} from "@/lib/characters";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MIXED_TEXTURE_PLACE_ID, TEXTURE_PLACES } from "@/lib/textures";

export default function CharacterPicker() {
const { activeCharacterTool, setActiveCharacterTool, location } = useMapStore();
const locationLabel =
location === MIXED_TEXTURE_PLACE_ID
? "Mixed"
: (TEXTURE_PLACES.find((place) => place.id === location)?.label ?? "Shire");

const groupedCharacters = useMemo(
() =>
CHARACTER_REALMS.map((realm) => ({
...realm,
characters: CHARACTERS.filter((character) => character.realm === realm.id),
})).filter((realm) => realm.characters.length > 0),
[],
);

return (
<ScrollArea className="h-full w-full bg-background/80">
<div className="flex flex-col gap-2 p-2 sm:p-3">
<span className="px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground whitespace-nowrap">
{locationLabel}
</span>
<div className="flex gap-3 sm:gap-4">
<TooltipProvider delayDuration={200}>
{groupedCharacters.map((realm) => (
<div key={realm.id} className="flex flex-col gap-1">
<span className="px-1 text-[11px] font-semibold text-muted-foreground whitespace-nowrap sm:text-xs">
{realm.label}
</span>
<div className="flex gap-1">
{realm.characters.map((character) => {
const isActive = activeCharacterTool === character.id;
return (
<Tooltip key={character.id}>
<TooltipTrigger asChild>
<button
className={cn(
"block h-[96px] w-[54px] shrink-0 overflow-hidden rounded-md border-2 transition-colors sm:h-[115px] sm:w-[65px]",
isActive
? "border-primary ring-2 ring-primary/30"
: "border-transparent hover:border-muted-foreground/30",
)}
style={{
backgroundImage: `url('${getCharacterPath(character.id)}')`,
backgroundRepeat: "no-repeat",
backgroundPosition: "center",
backgroundSize: "cover",
}}
onClick={() => setActiveCharacterTool(character.id)}
/>
</TooltipTrigger>
<TooltipContent side="top">
<p>{character.label}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
))}
</TooltipProvider>
</div>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
);
}
4 changes: 2 additions & 2 deletions components/collection-loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ const isTile = (value: unknown): value is [number, number, string?] => {
return false;
}
const [row, col, realm] = value;
if (!Number.isInteger(row) || row < 0 || row > 5) return false;
if (!Number.isInteger(col) || col < 0 || col > 11) return false;
if (!Number.isInteger(row) || row < 0) return false;
if (!Number.isInteger(col) || col < 0) return false;
if (realm !== undefined && (typeof realm !== "string" || !REALMS.has(realm))) {
return false;
}
Expand Down
Loading