Something went wrong. Try again.
An atproto Tilemap editor
Something went wrong. Try again.
18 kB · 489 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490// ---------------------------------------------------------------------------// Runtime map (mapStore) → `world.tilemap.map` record.//// Mirrors `tilesetToRecord` (store/persistence.ts): a caller-supplied// `uploadBlob` turns a data URL into a stored blob ref (the sync layer owns the// agent/PDS), and this builds the wire record. A map needs more transformation// than a tileset because its runtime form differs from the lexicon:// • paint channels are `col,row`-keyed Records of *scoped* materials// (`"<tilesetUri> <materialId>"`) → flat `CellPaint`/`EdgePaint`/// `CornerPaint` arrays carrying a short per-map tileset `ref` + bare id.// • nodes are a `kind`-tagged union → a `$type`-tagged union.// • the backdrop holds a data-URL image → an uploaded blob.//// On top of the editable scene graph it bakes a self-contained render (a minimal// sprite sheet of every resolved display tile + object frame, see lib/mapExport)// and stores it as two blobs (atlas PNG + layout JSON) under `render`, so a// consumer can draw the map with no source tilesets and no auto-tiling.// ---------------------------------------------------------------------------
import { useMapStore, parseCell, materialTileset, bareMaterial, scopedMaterial, cellKey, edgeKey, cornerKey, tileKey, tileRefValue, tileRefSource,} from '../store/mapStore'import type { SceneNode as RuntimeNode, CanvasLayer as RuntimeLayer, MapBackground as RuntimeBackground, Box as RuntimeBox, CornerDir, MapSnapshot, MapSource, MapTilesetRef,} from '../store/mapStore'import { MAP_NSID, GROUP_NODE_TYPE, CANVAS_NODE_TYPE, OBJECT_NODE_TYPE, AREA_NODE_TYPE, MARKER_NODE_TYPE, LINK_NODE_TYPE, COLOR_BACKGROUND_TYPE, GRADIENT_BACKGROUND_TYPE, IMAGE_BACKGROUND_TYPE, TILE_BACKGROUND_TYPE, type MapRecord, type SceneNode as WireNode, type CanvasLayer as WireLayer, type CellPaint, type EdgePaint, type CornerPaint, type TilePaint, type TilesetRef, type MapBackground as WireBackground, type Box as WireBox, type BakedRender, type BlobRef,} from '../lexicon/map'import { buildMapExport, type MapExport } from './mapExport'
/** Version of the map save format this serializer produces (the lexicon's * `formatVersion`). */export const MAP_FORMAT_VERSION = 1
/** Upload a data URL to the repo and return its blob ref. Supplied by the write * layer (e.g. backed by `com.atproto.repo.uploadBlob`). */export type UploadBlob = (dataUrl: string) => Promise<BlobRef>
/** Bijective base-26 alias for a tileset index: 0→a, 25→z, 26→aa, … */function aliasFor(i: number): string { let n = i let s = '' do { s = String.fromCharCode(97 + (n % 26)) + s n = Math.floor(n / 26) - 1 } while (n >= 0) return s}
const roundBox = (b: RuntimeBox): WireBox => ({ x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.w), h: Math.round(b.h),})
/** Base64-encode a UTF-8 string as raw bytes (so it round-trips through * `dataUrlToBytes`, which reads each base64 byte verbatim). */function utf8ToBase64(s: string): string { const bytes = new TextEncoder().encode(s) let bin = '' for (const b of bytes) bin += String.fromCharCode(b) return btoa(bin)}
/** Assigns + remembers short `ref` aliases for the tilesets a map draws from, * collecting the wire `tilesets` list as a side effect. */interface TilesetAliaser { ref: (uri: string) => string entries: TilesetRef[]}
function makeAliaser(known: { uri: string; name: string }[]): TilesetAliaser { const byUri = new Map<string, string>() const entries: TilesetRef[] = [] const add = (uri: string, name?: string): string => { const a = aliasFor(byUri.size) byUri.set(uri, a) entries.push({ ref: a, uri, ...(name ? { name } : {}) }) return a } // seed with the map's reconciled tileset list (preserves its order) for (const t of known) add(t.uri, t.name) return { ref: (uri) => byUri.get(uri) ?? add(uri), entries, }}
/** A `col,row`-keyed runtime paint channel → a flat wire array. */function serializeLayer(layer: RuntimeLayer, ref: (uri: string) => string): WireLayer { const scopeRef = (v: string): { tileset?: string } => { const uri = materialTileset(v) return uri ? { tileset: ref(uri) } : {} }
const cells: CellPaint[] = Object.entries(layer.cells).map(([k, v]) => { const [col, row] = parseCell(k) return { col, row, material: bareMaterial(v), ...scopeRef(v) } }) const edges: EdgePaint[] = Object.entries(layer.edges).map(([k, v]) => { // key: "H:c,r" (top edge) or "V:c,r" (left edge) const orient = k[0] as 'H' | 'V' const [col, row] = parseCell(k.slice(2)) return { orient, col, row, material: bareMaterial(v), ...scopeRef(v) } }) const corners: CornerPaint[] = Object.entries(layer.corners).map(([k, v]) => { // key: "c,r:which" const colon = k.indexOf(':') const [col, row] = parseCell(k.slice(0, colon)) const corner = k.slice(colon + 1) as CornerDir return { corner, col, row, material: bareMaterial(v), ...scopeRef(v) } }) // stamped tiles: key is the offset-grid vertex; value always carries a tileset const tiles: TilePaint[] = [] for (const [k, v] of Object.entries(layer.tiles)) { const uri = materialTileset(v) if (!uri) continue // malformed (unscoped) stamp — nothing to reference const [col, row] = parseCell(k) const [tileCol, tileRow] = tileRefSource(v) tiles.push({ col, row, tileset: ref(uri), tileCol, tileRow }) }
return { id: layer.id, ...(layer.name ? { name: layer.name } : {}), ...(layer.hidden ? { hidden: true } : {}), ...(layer.offsetX ? { offsetX: layer.offsetX } : {}), ...(layer.offsetY ? { offsetY: layer.offsetY } : {}), ...(layer.slices.length ? { slices: layer.slices } : {}), ...(cells.length ? { cells } : {}), ...(edges.length ? { edges } : {}), ...(corners.length ? { corners } : {}), ...(tiles.length ? { tiles } : {}), }}
/** Runtime scene node → wire ($type-tagged) node. */function serializeNode(n: RuntimeNode, ref: (uri: string) => string): WireNode { const common = { id: n.id, ...(n.name ? { name: n.name } : {}), ...(n.hidden ? { hidden: true } : {}), ...(n.properties.length ? { properties: n.properties.map((p) => ({ key: p.key, value: p.value })) } : {}), } switch (n.kind) { case 'group': return { $type: GROUP_NODE_TYPE, ...common, ...(n.collapsed ? { collapsed: true } : {}), ...(n.composite ? { composite: true } : {}), ...(n.anchor ? { anchor: { x: n.anchor.x, y: n.anchor.y } } : {}), ...(n.children.length ? { children: n.children.map((c) => serializeNode(c, ref)) } : {}), } case 'canvas': return { $type: CANVAS_NODE_TYPE, ...common, ...(n.sort !== 'dynamic' ? { sort: n.sort } : {}), ...(n.layers.length ? { layers: n.layers.map((l) => serializeLayer(l, ref)) } : {}), } case 'object': return { $type: OBJECT_NODE_TYPE, ...common, tileset: ref(n.tileset), objectId: n.objectId, x: Math.round(n.x), y: Math.round(n.y), ...(n.scale != null && n.scale !== 1 ? { scale: String(n.scale) } : {}), ...(n.rotation ? { rotation: n.rotation } : {}), ...(n.flipX ? { flipX: true } : {}), ...(n.flipY ? { flipY: true } : {}), } case 'area': return { $type: AREA_NODE_TYPE, ...common, ...(n.boxes.length ? { boxes: n.boxes.map(roundBox) } : {}), } case 'marker': return { $type: MARKER_NODE_TYPE, ...common, x: Math.round(n.x), y: Math.round(n.y), } case 'link': return { $type: LINK_NODE_TYPE, ...common, source: n.source, target: n.target, } }}
/** Runtime backdrop → wire backdrop. An image backdrop uploads its bytes; a tile * backdrop names the primary tileset by its `ref`. */async function serializeBackground( bg: RuntimeBackground | null, primaryRef: string | undefined, uploadBlob: UploadBlob,): Promise<WireBackground | undefined> { if (!bg) return undefined switch (bg.kind) { case 'color': return { $type: COLOR_BACKGROUND_TYPE, color: bg.color } case 'gradient': return { $type: GRADIENT_BACKGROUND_TYPE, ...(bg.angle !== 90 ? { angle: bg.angle } : {}), stops: bg.stops, } case 'image': { if (!bg.dataUrl) return undefined const blob = await uploadBlob(bg.dataUrl) return { $type: IMAGE_BACKGROUND_TYPE, ...(bg.name ? { name: bg.name } : {}), blob, ...(bg.fit !== 'tile' ? { fit: bg.fit } : {}), } } case 'tile': // a tile backdrop draws from the primary tileset; drop it if none. return primaryRef ? { $type: TILE_BACKGROUND_TYPE, tileset: primaryRef, col: bg.col, row: bg.row } : undefined }}
/** Bake the map and upload the atlas PNG + layout JSON as two blobs. Returns * undefined when there's nothing to bake (no art). */async function buildRender(uploadBlob: UploadBlob): Promise<BakedRender | undefined> { const exp = buildMapExport() if (!exp.sheet.dataUrl) { // No atlas means the record ships with no `render` blob at all (consumers see // an unbaked map). Empty maps bake nothing legitimately; a non-empty one // usually means a tainted/blocked canvas — make that case loud. if (exp.nodes.length > 0) { console.warn('[mapRecord] no render baked: atlas is empty or the canvas was tainted — record will have no render blob') } return undefined } const image = await uploadBlob(exp.sheet.dataUrl) // the layout JSON is the MapExport without the inline atlas image. const payload: Omit<MapExport, 'sheet'> & { sheet: Omit<MapExport['sheet'], 'dataUrl'> } = { ...exp, sheet: { width: exp.sheet.width, height: exp.sheet.height, entries: exp.sheet.entries }, } const data = await uploadBlob(`data:application/json;base64,${utf8ToBase64(JSON.stringify(payload))}`) return { version: exp.version, cell: exp.cell, image, data, ...(exp.bounds ? { bounds: roundBox(exp.bounds) } : {}), }}
/** Serialize the active map (mapStore) to a `world.tilemap.map` record, baking a * self-contained render (atlas + layout blobs) via `uploadBlob`. */export async function mapToRecord(uploadBlob: UploadBlob): Promise<MapRecord> { const map = useMapStore.getState() const aliaser = makeAliaser(map.tilesets) const primaryRef = map.tilesets[0] ? aliaser.ref(map.tilesets[0].uri) : undefined
const nodes = map.nodes.map((n) => serializeNode(n, aliaser.ref)) const background = await serializeBackground(map.background, primaryRef, uploadBlob) const render = await buildRender(uploadBlob)
return { $type: MAP_NSID, formatVersion: MAP_FORMAT_VERSION, stableId: map.stableId, name: map.name, savedAt: new Date().toISOString(), grid: { tileWidth: map.grid.tileWidth, tileHeight: map.grid.tileHeight }, ...(aliaser.entries.length ? { tilesets: aliaser.entries } : {}), ...(map.depthAxis !== 'y' ? { depthAxis: map.depthAxis } : {}), ...(background ? { background } : {}), nodes, ...(map.properties.length ? { properties: map.properties.map((p) => ({ key: p.key, value: p.value })) } : {}), ...(render ? { render } : {}), }}
// ---------------------------------------------------------------------------// `world.tilemap.map` record → runtime map (MapSnapshot).//// The inverse of the serializers above: it re-keys the flat paint arrays back// into `col,row` channels, re-scopes each material from the map's per-record// tileset `ref` aliases to the full tileset AT-URI (so `tilesets` auto-loaders// and the renderer agree), and turns the $type-tagged node/background unions// back into the `kind`-tagged runtime forms. The baked `render` is ignored —// the editable scene graph is the source of truth. `resolveBlob` fetches an// image backdrop's bytes (the only async piece), mirroring `recordToTileset`.// ---------------------------------------------------------------------------
/** Resolve a tileset `ref` alias to its full AT-URI. */type AliasResolver = (alias: string) => string | undefined
function makeAliasResolver(refs: TilesetRef[] | undefined): AliasResolver { const byAlias = new Map<string, string>() for (const t of refs ?? []) byAlias.set(t.ref, t.uri) return (alias) => byAlias.get(alias)}
/** Re-scope a wire paint value (`material` + optional tileset `ref`) back into a * runtime scoped material. A void/unscoped entry (no `tileset`) is kept bare; * scopedMaterial also keeps the void sentinel bare. An unknown alias degrades to * the bare material rather than dropping the paint. */function rescope(material: string, alias: string | undefined, resolve: AliasResolver): string { if (!alias) return material const uri = resolve(alias) return uri ? scopedMaterial(uri, material) : material}
function deserializeLayer(layer: WireLayer, resolve: AliasResolver): RuntimeLayer { const cells: Record<string, string> = {} for (const c of layer.cells ?? []) cells[cellKey(c.col, c.row)] = rescope(c.material, c.tileset, resolve) const edges: Record<string, string> = {} for (const e of layer.edges ?? []) edges[edgeKey(e.orient, e.col, e.row)] = rescope(e.material, e.tileset, resolve) const corners: Record<string, string> = {} for (const k of layer.corners ?? []) corners[cornerKey(k.col, k.row, k.corner)] = rescope(k.material, k.tileset, resolve) const tiles: Record<string, string> = {} for (const t of layer.tiles ?? []) { // a stamp is unrenderable without its tileset; drop an unresolvable alias const uri = resolve(t.tileset) if (uri) tiles[tileKey(t.col, t.row)] = tileRefValue(uri, t.tileCol, t.tileRow) } return { id: layer.id, name: layer.name ?? '', hidden: layer.hidden ?? false, offsetX: layer.offsetX ?? 0, offsetY: layer.offsetY ?? 0, slices: layer.slices ?? [], cells, edges, corners, tiles, }}
function deserializeNode(n: WireNode, resolve: AliasResolver): RuntimeNode { const common = { id: n.id, name: n.name ?? '', hidden: n.hidden ?? false, properties: (n.properties ?? []).map((p) => ({ key: p.key, value: p.value })), } switch (n.$type) { case GROUP_NODE_TYPE: return { ...common, kind: 'group', collapsed: n.collapsed ?? false, ...(n.composite ? { composite: true } : {}), ...(n.anchor ? { anchor: { x: n.anchor.x, y: n.anchor.y } } : {}), children: (n.children ?? []).map((c) => deserializeNode(c, resolve)), } case CANVAS_NODE_TYPE: return { ...common, kind: 'canvas', sort: n.sort ?? 'dynamic', layers: (n.layers ?? []).map((l) => deserializeLayer(l, resolve)), } case OBJECT_NODE_TYPE: return { ...common, kind: 'object', tileset: resolve(n.tileset) ?? n.tileset, objectId: n.objectId, x: n.x, y: n.y, scale: n.scale != null ? Number(n.scale) : 1, rotation: n.rotation ?? 0, flipX: n.flipX ?? false, flipY: n.flipY ?? false, } case AREA_NODE_TYPE: return { ...common, kind: 'area', boxes: (n.boxes ?? []).map((b) => ({ x: b.x, y: b.y, w: b.w, h: b.h })), } case MARKER_NODE_TYPE: return { ...common, kind: 'marker', x: n.x, y: n.y, } case LINK_NODE_TYPE: return { ...common, kind: 'link', source: n.source, target: n.target, } }}
async function deserializeBackground( bg: WireBackground | undefined, resolveBlob: (blob: BlobRef) => Promise<string>,): Promise<RuntimeBackground | null> { if (!bg) return null switch (bg.$type) { case COLOR_BACKGROUND_TYPE: return { kind: 'color', color: bg.color } case GRADIENT_BACKGROUND_TYPE: return { kind: 'gradient', angle: bg.angle ?? 90, stops: bg.stops } case IMAGE_BACKGROUND_TYPE: return { kind: 'image', name: bg.name ?? '', dataUrl: await resolveBlob(bg.blob), fit: bg.fit ?? 'tile' } case TILE_BACKGROUND_TYPE: return { kind: 'tile', col: bg.col, row: bg.row } }}
/** Deserialize a `world.tilemap.map` record into the editor's runtime * {@link MapSnapshot}. `source` is the repo provenance (did + rkey) the record * was fetched from; `resolveBlob` turns an image backdrop's blob ref into a data * URL (only called when the map has an image background). */export async function recordToMap( rec: MapRecord, source: MapSource, resolveBlob: (blob: BlobRef) => Promise<string>,): Promise<MapSnapshot> { const resolve = makeAliasResolver(rec.tilesets) const tilesets: MapTilesetRef[] = (rec.tilesets ?? []).map((t) => ({ uri: t.uri, name: t.name ?? 'Untitled', })) const nodes = (rec.nodes ?? []).map((n) => deserializeNode(n, resolve)) const background = await deserializeBackground(rec.background, resolveBlob) return { stableId: rec.stableId ?? crypto.randomUUID(), name: rec.name?.trim() || 'Untitled map', grid: { tileWidth: rec.grid.tileWidth, tileHeight: rec.grid.tileHeight }, depthAxis: rec.depthAxis ?? 'y', background, nodes, tilesets, properties: (rec.properties ?? []).map((p) => ({ key: p.key, value: p.value })), source, }}