Something went wrong. Try again.
An atproto Tilemap editor
Something went wrong. Try again.
30 kB · 746 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747// ---------------------------------------------------------------------------// Map export: bake the editor's live stores into a *self-contained* artifact a// rendering engine can draw with no knowledge of the source tilesets and no// auto-tiling logic of its own.//// A map pulls tiles/objects from several tilesets at once (the loaded set), and// at export time we bake ONE per-map sprite sheet (an atlas) that packs every// distinct *resolved* display tile and object frame the map uses — rasterised at// the map's cell resolution (world scale) so the renderer can scale the whole// sheet by a single uniform zoom. The offset-grid corner-Wang resolution// (lib/wang.ts) is run HERE, at bake time, once per source tileset: a painted// layer is drawn as one pass per referenced tileset (each pass resolves only its// own tileset's cells, so transitions join within a tileset and never across// one — matching the live editor), and each resolved display tile (a whole tile,// or four composed quadrants) becomes one atlas entry. A canvas therefore// exports as a flat list of `{atlas, x, y}` sprite blits. Source tilesets are// kept only as provenance (`sources`), never to render.//// Coordinate spaces, deliberately split:// • grid indices — internal to the bake (cell keys, the offset vertex grid).// • cellPx px — everything the renderer sees (sprite x/y, object pos /// origin / collision, collision + area rects, atlas rects).// `cell.w`/`cell.h` default to the MAP grid (world scale); each tileset// scales from its own tile size into the atlas at that size and the renderer// applies one uniform zoom on top. Geometry authored in MAP px (object// position, areas) scales by the map grid; object-local geometry (origin,// collision) and tile art scale by the owning tileset's tile size.//// Animation is NOT baked: a painted material id is baked as its painted (rest)// frame; render-time playback over the baked sheet is a separate feature.// ---------------------------------------------------------------------------
import { animationMembership, type MaterialAnimation } from '../store/editorStore'import { bareMaterial, type CanvasNode, cellKey, cornerKey, edgeKey, isCollisionNode, materialTileset, parseCell, tileRefSource, useMapStore, visibleAreas, visibleLinks, visibleMarkers, visibleCanvases, visibleObjects, walkVisibleComposite, type CornerDir, type GroupNode, type Rect,} from '../store/mapStore'import { useLoadedTilesetsStore, type LoadedTileset } from '../store/loadedTilesetsStore'import { buildWangIndex, drawQuad, resolveDisplayTile, type QuadResult, type WangIndex } from './wang'import { depthKeyOffset, resolveCollision, resolveTransform } from '../engine/transform'import { MAP_EXPORT_VERSION, projectDepth, type AnimSprite2D, type AtlasEntry, type CanvasExport, type ExportNode, type LinkExport, type MapExport, type ObjectDefExport, type ObjectExport, type Sprite2D,} from '../engine/contract'
// The render-ready contract now lives in the self-contained engine; this module// owns only the *builder* (`buildMapExport`) that bakes the live editor stores// into it. Re-export the contract so existing importers of this path are// unaffected.export { MAP_EXPORT_VERSION, projectDepth, type AnimSprite2D, type AreaExport, type AtlasEntry, type CanvasExport, type CollisionExport, type ExportNode, type LinkExport, type MapExport, type MarkerExport, type ObjectDefExport, type ObjectExport, type Sprite2D, type Vec2,} from '../engine/contract'
/** One baked blit paired with its depth: its contact line along the axis (the * cell-boundary coordinate the runtime sorts it by — the feet line, matching how * objects and the player sort). Depth is supplied by the bake, which knows each * blit's grid placement (offset display tile vs flat cell). */interface DepthBlit<T> { blit: T depth: number}
/** Split a dynamic layer's baked blits into depth bands at its slice cuts. * * Cuts (ascending, in cellPx) partition the blits by their depth: a blit joins * band `k` = how many cuts its depth lies past. Each band then sorts at *its * own* far edge — the deepest blit it holds — so a long fence occludes per-run * (objects/player interleave between runs) instead of as one unit. With no cuts * this is a single band at the layer's true far edge, which (unlike the old * cell-only depth) also accounts for edge/corner-only content. Returns one entry * per non-empty band, ascending by depth. */function sliceBands( sprites: DepthBlit<Sprite2D>[], animSprites: DepthBlit<AnimSprite2D>[], slices: number[],): { depth: number; sprites: Sprite2D[]; animSprites: AnimSprite2D[] }[] { const cuts = [...slices].sort((a, b) => a - b) const bandOf = (d: number) => { let k = 0 while (k < cuts.length && d > cuts[k]) k++ return k } const bands = new Map<number, { depth: number; sprites: Sprite2D[]; animSprites: AnimSprite2D[] }>() const place = (d: number) => { const k = bandOf(d) let b = bands.get(k) if (!b) { b = { depth: -Infinity, sprites: [], animSprites: [] } bands.set(k, b) } if (d > b.depth) b.depth = d return b } for (const s of sprites) place(s.depth).sprites.push(s.blit) for (const s of animSprites) place(s.depth).animSprites.push(s.blit) return [...bands.entries()].sort((a, b) => a[0] - b[0]).map(([, b]) => b)}
const ATLAS_MAX_WIDTH = 2048
type DrawOp = (ctx: CanvasRenderingContext2D, e: AtlasEntry) => void
/** Per-tileset bake context: the resolved render inputs for one loaded tileset, * plus its scale from tileset px → the map's cell (world scale). */interface TilesetBake { uri: string image: HTMLImageElement | null tileWidth: number tileHeight: number offsetX: number offsetY: number index: WangIndex materialColor: Map<string, string> objMap: Map<string, LoadedTileset['objects'][number]> /** member material id → its animation + frame index, for playback substitution. */ animMembership: ReturnType<typeof animationMembership> /** the tileset's animations that have ≥2 frames, keyed by id. */ animById: Map<string, MaterialAnimation> /** whether this tileset has usable art (else its cells fall back to colour). */ artReady: boolean /** tileset px → cellPx (object-local geometry is authored in tileset px). */ scaleX: number scaleY: number}
/** * Snapshot the current map + the loaded tilesets into a self-contained MapExport. * `cellW`/`cellH` default to the MAP grid's tile size — the world scale; each * source tileset is scaled from its own tile size into the atlas at that size. */export function buildMapExport(cellW?: number, cellH?: number): MapExport { const map = useMapStore.getState() const tilesets = useLoadedTilesetsStore.getState().tilesets // canonical cell = the map grid (world scale); the renderer applies one zoom. const cw = Math.max(1, Math.round(cellW ?? map.grid.tileWidth ?? 16)) const ch = Math.max(1, Math.round(cellH ?? map.grid.tileHeight ?? 16)) const hw = cw / 2 const hh = ch / 2 // map px → cellPx, for geometry authored in MAP pixels (object pos, areas). const mapScaleX = map.grid.tileWidth > 0 ? cw / map.grid.tileWidth : 1 const mapScaleY = map.grid.tileHeight > 0 ? ch / map.grid.tileHeight : 1
// ---- per-tileset bake contexts, keyed + ordered by the loaded set ---------- const bakes = new Map<string, TilesetBake>() for (const t of tilesets) { const index = buildWangIndex(t.sectionAssignments, t.tileProperties) const { tileWidth, tileHeight, offsetX, offsetY } = t.grid bakes.set(t.uri, { uri: t.uri, image: t.image, tileWidth, tileHeight, offsetX, offsetY, index, materialColor: new Map(t.materials.map((m) => [m.id, m.color])), objMap: new Map(t.objects.map((o) => [o.id, o])), animMembership: animationMembership(t.materialAnimations), animById: new Map( t.materialAnimations.filter((a) => a.frameMaterialIds.length >= 2).map((a) => [a.id, a]), ), artReady: !!t.image && tileWidth > 0 && tileHeight > 0 && index.whole.size > 0, scaleX: tileWidth > 0 ? cw / tileWidth : 1, scaleY: tileHeight > 0 ? ch / tileHeight : 1, }) }
// ---- atlas interning: dedupe every distinct display tile / frame / colour -- const entries: AtlasEntry[] = [] const entryByKey = new Map<string, number>() const ops: { id: number; draw: DrawOp }[] = [] const intern = (key: string, w: number, h: number, draw: DrawOp): number => { const existing = entryByKey.get(key) if (existing !== undefined) return existing const id = entries.length entries.push({ id, x: 0, y: 0, w, h }) entryByKey.set(key, id) ops.push({ id, draw }) return id }
const qsig = (q: QuadResult): string => q === null ? '_' : q === 'red' ? 'R' : `${q.col},${q.row},${q.quad}${q.transform ? ':' + q.transform : ''}`
/** Intern the atlas entry for one resolved display tile of tileset `ts` (always * four quadrants — a whole tile is just four quadrants of the same source). * The key carries the tileset uri so identical (col,row) in different tilesets * never collide. */ const displayEntry = (dt: NonNullable<ReturnType<typeof resolveDisplayTile>>, ts: TilesetBake): number => { const { tl, tr, bl, br } = dt const key = `c:${ts.uri}|${qsig(tl)}|${qsig(tr)}|${qsig(bl)}|${qsig(br)}` const htw = ts.tileWidth / 2 const hth = ts.tileHeight / 2 return intern(key, cw, ch, (ctx, e) => { const place = (q: QuadResult, dqx: number, dqy: number) => { if (q === null) return const dx = e.x + dqx * hw const dy = e.y + dqy * hh if (q === 'red') { ctx.fillStyle = '#ff3b3b' ctx.fillRect(dx, dy, hw, hh) return } if (ts.image) { const sqx = q.quad === 'tr' || q.quad === 'br' ? 1 : 0 const sqy = q.quad === 'bl' || q.quad === 'br' ? 1 : 0 drawQuad( ctx, ts.image, ts.offsetX + q.col * ts.tileWidth + sqx * htw, ts.offsetY + q.row * ts.tileHeight + sqy * hth, htw, hth, dx, dy, hw, hh, q.transform, ) } } place(tl, 0, 0) place(tr, 1, 0) place(bl, 0, 1) place(br, 1, 1) }) }
const colorEntry = (color: string) => intern(`mat:${color}`, cw, ch, (ctx, e) => { ctx.fillStyle = color ctx.fillRect(e.x, e.y, e.w, e.h) })
const nodes: ExportNode[] = [] let minX = Infinity let minY = Infinity let maxX = -Infinity let maxY = -Infinity const grow = (x: number, y: number, w: number, h: number) => { if (x < minX) minX = x if (y < minY) minY = y if (x + w > maxX) maxX = x + w if (y + h > maxY) maxY = y + h }
// If an animated material drives the display tile at offset vertex (i,j), // bake every frame and return the atlas-id sequence; else null. Mirrors the // editor's `animFrameIdFor`: substitute every member of one animation for its // frame-f material (others pass through), re-resolve, and bake. A vertex is // owned by the first candidate animation that actually varies it — a tile // influenced by two animations at once cycles on just that one. const animatedFramesAt = ( i: number, j: number, ts: TilesetBake, layer: CanvasNode['layers'][number], anims: MaterialAnimation[], ): { frames: number[]; frameDurationMs: number } | null => { for (const a of anims) { const n = a.frameMaterialIds.length const sub = (v: string | undefined, f: number): string | undefined => { if (v === undefined || materialTileset(v) !== ts.uri) return undefined const bare = bareMaterial(v) const m = ts.animMembership.get(bare) return m && m.animationId === a.id ? a.frameMaterialIds[f] : bare } const frames: number[] = [] for (let f = 0; f < n; f++) { const baseF = (c: number, r: number) => sub(layer.cells[cellKey(c, r)], f) const cornerF = (c: number, r: number, w: CornerDir) => sub(layer.corners[cornerKey(c, r, w)], f) const edgeF = (o: 'H' | 'V', c: number, r: number) => sub(layer.edges[edgeKey(o, c, r)], f) const dtf = resolveDisplayTile(i, j, baseF, cornerF, ts.index, edgeF) if (!dtf) break // tile vanishes on some frame → not a clean cycle; stay static frames.push(displayEntry(dtf, ts)) } if (frames.length === n && frames.some((id) => id !== frames[0])) { return { frames, frameDurationMs: a.frameDurationMs } } } return null }
// ---- canvas layers: one pass per referenced tileset, resolved + stacked ---- const axis = map.depthAxis // leaf node id → its enclosing composite group, so we can collapse a group's // drawables onto one shared sort key (see {@link GroupNode.composite}). const compositeOf = new Map<string, GroupNode>() walkVisibleComposite(map.nodes, (leaf, composite) => { if (composite) compositeOf.set(leaf.id, composite) }) // export nodes that belong to a composite, with the far-edge `base` they'd // sort by on their own; resolved into `sortKey` once all members are known. const composed: { node: CanvasExport | ObjectExport; composite: GroupNode; base: number }[] = [] const canvasDepth = (canvas: CanvasNode): number => { let max = -Infinity for (const layer of canvas.layers) { for (const k of Object.keys(layer.cells)) { const [c, r] = parseCell(k) const far = axis === 'x' ? layer.offsetX + (c + 1) * cw : layer.offsetY + (r + 1) * ch if (far > max) max = far } // stamped tiles sit on the offset grid: vertex (i,j) covers ±half a cell for (const k of Object.keys(layer.tiles)) { const [i, j] = parseCell(k) const far = axis === 'x' ? layer.offsetX + (i + 0.5) * cw : layer.offsetY + (j + 0.5) * ch if (far > max) max = far } } return max === -Infinity ? 0 : max } for (const canvas of visibleCanvases(map.nodes)) { const sort = canvas.sort const depth = canvasDepth(canvas) const composite = compositeOf.get(canvas.id) ?? null // push a canvas node, recording it as a composite member when applicable const pushCanvas = (node: CanvasExport) => { nodes.push(node) if (composite) composed.push({ node, composite, base: depth }) } for (const layer of canvas.layers) { if (layer.hidden) continue const name = `${canvas.name} · ${layer.name}` const cellKeys = Object.keys(layer.cells) const edgeKeys = Object.keys(layer.edges) const cornerKeys = Object.keys(layer.corners) const tileEntries = Object.entries(layer.tiles) if (cellKeys.length === 0 && edgeKeys.length === 0 && cornerKeys.length === 0 && tileEntries.length === 0) { pushCanvas({ kind: 'canvas', id: layer.id, name, sort, depth, sprites: [], animSprites: [] }) continue } const sprites: Sprite2D[] = [] const animSprites: AnimSprite2D[] = [] // parallel to sprites/animSprites: each blit's depth (contact line along // the axis, in cellPx). Only consumed when this is a dynamic band layer. const spriteDepths: number[] = [] const animDepths: number[] = [] const ox = layer.offsetX const oy = layer.offsetY
// which tilesets does this layer reference (across all three channels)? const present = new Set<string>() for (const channel of [layer.cells, layer.edges, layer.corners]) { for (const v of Object.values(channel)) { const uri = materialTileset(v) if (uri) present.add(uri) } } // cell-index bounds (the offset grid spans one past each edge). Strokes and // corner dots can sit outside the filled cells (a path/outline with no // fill), so widen the bounds over every painted feature — otherwise a // fill-less stroke falls outside the bake loop and silently disappears. let cMin = Infinity let cMax = -Infinity let rMin = Infinity let rMax = -Infinity const growCell = (c: number, r: number) => { if (c < cMin) cMin = c if (c > cMax) cMax = c if (r < rMin) rMin = r if (r > rMax) rMax = r } for (const k of cellKeys) growCell(...parseCell(k)) // edge key is "H:c,r" / "V:c,r"; corner key is "c,r:which" for (const k of edgeKeys) growCell(...parseCell(k.slice(k.indexOf(':') + 1))) for (const k of cornerKeys) growCell(...parseCell(k.slice(0, k.indexOf(':'))))
// one pass per referenced tileset, in the loaded set's order (stacking). for (const ts of bakes.values()) { if (!present.has(ts.uri)) continue // resolve a stored value to this tileset's bare material, or undefined // when it belongs to another tileset / is void — so it reads as empty. const own = (v: string | undefined): string | undefined => v === undefined || materialTileset(v) !== ts.uri ? undefined : bareMaterial(v) const base = (c: number, r: number) => own(layer.cells[cellKey(c, r)]) const corner = (c: number, r: number, which: CornerDir) => own(layer.corners[cornerKey(c, r, which)]) const edge = (o: 'H' | 'V', c: number, r: number) => own(layer.edges[edgeKey(o, c, r)])
// candidate animations: those with a member painted in this layer for // this tileset (empty ⇒ the static fast path below, untouched). const presentMembers = new Set<string>() for (const channel of [layer.cells, layer.edges, layer.corners]) { for (const v of Object.values(channel)) { if (materialTileset(v) !== ts.uri) continue const bare = bareMaterial(v) if (ts.animMembership.has(bare)) presentMembers.add(bare) } } const layerAnims = [...ts.animById.values()].filter((a) => a.frameMaterialIds.some((mid) => presentMembers.has(mid)), )
if (ts.artReady) { for (let j = rMin; j <= rMax + 1; j++) { for (let i = cMin; i <= cMax + 1; i++) { const dt = resolveDisplayTile(i, j, base, corner, ts.index, edge) if (!dt) continue const x = ox + (i - 0.5) * cw const y = oy + (j - 0.5) * ch // display tiles sit on the offset grid (centred on a cell vertex); // the vertex line — the blit's centre — is the cell boundary it // sorts at, so objects/player interleave at the painted cells' feet // (not half a cell past, as the geometric bottom would give). const d = axis === 'x' ? ox + i * cw : oy + j * ch const anim = layerAnims.length ? animatedFramesAt(i, j, ts, layer, layerAnims) : null if (anim) { animSprites.push({ frames: anim.frames, x, y, frameDurationMs: anim.frameDurationMs }) animDepths.push(d) } else { sprites.push({ atlas: displayEntry(dt, ts), x, y }) spriteDepths.push(d) } grow(x, y, cw, ch) } } } else { // no art: flat colour swatch per cell owned by this tileset. for (const [k, v] of Object.entries(layer.cells)) { if (materialTileset(v) !== ts.uri) continue const color = ts.materialColor.get(bareMaterial(v)) if (!color) continue const [col, row] = parseCell(k) const atlas = colorEntry(color) const x = ox + col * cw const y = oy + row * ch sprites.push({ atlas, x, y }) // flat swatch is a non-offset full cell, so its contact line is the // cell's far edge (bottom / right). spriteDepths.push(axis === 'x' ? x + cw : y + ch) grow(x, y, cw, ch) } } }
// Stamped tiles draw over the layer's auto-tiled art, so they bake after // every tileset pass. Each distinct source tile interns one atlas entry, // scaled to the map cell — the same footprint as a resolved display tile. for (const [k, v] of tileEntries) { const uri = materialTileset(v) const ts = uri ? bakes.get(uri) : undefined if (!ts?.image || ts.tileWidth <= 0 || ts.tileHeight <= 0) continue const image = ts.image const [tc, trw] = tileRefSource(v) const atlas = intern(`t:${ts.uri}|${tc},${trw}`, cw, ch, (ctx, e) => { ctx.drawImage( image, ts.offsetX + tc * ts.tileWidth, ts.offsetY + trw * ts.tileHeight, ts.tileWidth, ts.tileHeight, e.x, e.y, e.w, e.h, ) }) const [i, j] = parseCell(k) const x = ox + (i - 0.5) * cw const y = oy + (j - 0.5) * ch sprites.push({ atlas, x, y }) // same contact line as a resolved display tile: the vertex line spriteDepths.push(axis === 'x' ? ox + i * cw : oy + j * ch) grow(x, y, cw, ch) }
// A dynamic layer interleaves with the depth pool, so bake it out into a // set of band nodes — split at its slice cuts — each carrying a concrete // `sortKey` (its far-edge depth). The runtime then sorts these exactly // like any other keyed node; nothing re-derives a dynamic depth. (Composite // members collapse to one unit key downstream, so they keep one node.) if (sort === 'dynamic' && !composite) { const bands = sliceBands( sprites.map((blit, i) => ({ blit, depth: spriteDepths[i] })), animSprites.map((blit, i) => ({ blit, depth: animDepths[i] })), layer.slices, ) bands.forEach((b, k) => { const banded = bands.length > 1 pushCanvas({ kind: 'canvas', id: banded ? `${layer.id}#${k}` : layer.id, name: banded ? `${name} · band ${k + 1}` : name, sort, depth: b.depth, sortKey: b.depth, sprites: b.sprites, animSprites: b.animSprites, }) }) } else { pushCanvas({ kind: 'canvas', id: layer.id, name, sort, depth, sprites, animSprites }) } } }
// ---- objects: deduped self-contained defs + placed instances ---- const objectDefs: ObjectDefExport[] = [] const defById = new Map<string, ObjectDefExport>() for (const inst of visibleObjects(map.nodes)) { const ts = bakes.get(inst.tileset) const src = ts?.objMap.get(inst.objectId) // Objects need real art to render. A missing image is usually transient (the // tileset hadn't finished loading at bake time) and silently drops the // instance from an otherwise self-contained blob — so make it loud. if (ts && src && !ts.image) { console.warn( `[mapExport] dropped object "${inst.name}" (${inst.objectId}): tileset image not loaded — re-bake once tilesets are ready`, ) } if (!ts || !src || !ts.image) continue
let def = defById.get(inst.objectId) if (!def) { const image = ts.image const frameCells = [{ col: src.col, row: src.row }, ...src.frames] const frames = frameCells.map((fc, i) => intern(`obj:${ts.uri}:${inst.objectId}:${i}`, src.cols * cw, src.rows * ch, (ctx, e) => { ctx.drawImage( image, ts.offsetX + fc.col * ts.tileWidth, ts.offsetY + fc.row * ts.tileHeight, src.cols * ts.tileWidth, src.rows * ts.tileHeight, e.x, e.y, e.w, e.h, ) }), ) const artH = src.rows * ts.tileHeight def = { id: inst.objectId, source: { tilesetRef: ts.uri, objectId: inst.objectId }, // origin is authored bottom-left / y-up; convert to top-left / y-down px origin: { x: src.origin.x * ts.scaleX, y: (artH - src.origin.y) * ts.scaleY }, size: { w: src.cols * cw, h: src.rows * ch }, collision: src.collision.map((b) => ({ x: b.x * ts.scaleX, y: b.y * ts.scaleY, w: b.w * ts.scaleX, h: b.h * ts.scaleY, })), frames, frameDurationMs: src.frameDurationMs, properties: src.properties, } objectDefs.push(def) defById.set(inst.objectId, def) }
// object origin (feet) is placed at its world-px position (MAP px → cellPx). // The scale/rotation/flip transform is anchor-relative and unit-agnostic, so // it carries through unchanged (the runtime applies it about the anchor). const t = resolveTransform(inst) const pos = { x: inst.x * mapScaleX, y: inst.y * mapScaleY } const node: ObjectExport = { kind: 'object', id: inst.id, name: inst.name, defId: inst.objectId, pos, scale: t.scale, rotation: t.rotation, flipX: t.flipX, flipY: t.flipY, // resolved world-px collision: the def's boxes mirrored/scaled/rotated and // offset to pos, so consumers (incl. the deterministic sim) never re-derive // it. Same math as the runtime in engine/scene.ts. collision: resolveCollision(def.origin, def.collision, t, pos), properties: inst.properties, } nodes.push(node) const composite = compositeOf.get(inst.id) if (composite) { // far edge of the transformed footprint — matches the runtime zIndex the // object would get on its own (Sandbox.tsx), so composites pick up the // deepest member correctly. const dOff = depthKeyOffset( { x: -def.origin.x, y: -def.origin.y, w: def.size.w, h: def.size.h }, t, axis, ) composed.push({ node, composite, base: projectDepth(axis, pos.x, pos.y) + dOff }) } }
// ---- composite groups: collapse each unit's members onto one sort key ------ // The unit sorts by its manual anchor (MAP px → cellPx) or, failing that, its // deepest member's far edge. A tiny per-member increment preserves draw order // within the unit (earlier member furthest back). Order here is build order — // canvas layers first, then objects in panel order — so a canvas member always // sits behind the unit's objects (the usual intent for a pinned decoration). if (composed.length > 0) { const unitKey = new Map<string, number>() for (const m of composed) { const g = m.composite const k = g.anchor ? projectDepth(axis, g.anchor.x * mapScaleX, g.anchor.y * mapScaleY) : Math.max(unitKey.get(g.id) ?? -Infinity, m.base) unitKey.set(g.id, k) } const seq = new Map<string, number>() for (const m of composed) { const g = m.composite const i = seq.get(g.id) ?? 0 seq.set(g.id, i + 1) m.node.sortKey = (unitKey.get(g.id) ?? m.base) + i * 1e-3 } }
// ---- areas (MAP px → cellPx); collision = an area flagged with the property - for (const a of visibleAreas(map.nodes)) { const scaled = (b: Rect): Rect => ({ x: b.x * mapScaleX, y: b.y * mapScaleY, w: b.w * mapScaleX, h: b.h * mapScaleY, }) if (isCollisionNode(a)) { a.boxes.forEach((b, i) => { nodes.push({ kind: 'collision', id: `${a.id}:${i}`, name: a.name, rect: scaled(b), properties: a.properties }) }) } else { nodes.push({ kind: 'area', id: a.id, name: a.name, rects: a.boxes.map(scaled), properties: a.properties }) } }
// ---- markers (MAP px → cellPx); meaning-carrying points (no art) ---- for (const m of visibleMarkers(map.nodes)) { nodes.push({ kind: 'marker', id: m.id, name: m.name, x: m.x * mapScaleX, y: m.y * mapScaleY, properties: m.properties, }) }
// ---- links: non-visual directed edges between nodes (no geometry) ---- // `source`/`target` reference other nodes by the same id they export with, so a // game can resolve the endpoints in the baked scene. for (const l of visibleLinks(map.nodes)) { const link: LinkExport = { kind: 'link', id: l.id, name: l.name, source: l.source, target: l.target, properties: l.properties, } nodes.push(link) }
// ---- pack the atlas (simple shelf packer) + rasterise ---- let px = 0 let py = 0 let shelfH = 0 let atlasW = 0 for (const e of entries) { if (px > 0 && px + e.w > ATLAS_MAX_WIDTH) { px = 0 py += shelfH shelfH = 0 } e.x = px e.y = py px += e.w shelfH = Math.max(shelfH, e.h) atlasW = Math.max(atlasW, px) } const atlasH = py + shelfH
let dataUrl: string | null = null if (entries.length > 0 && atlasW > 0 && atlasH > 0) { const canvas = document.createElement('canvas') canvas.width = atlasW canvas.height = atlasH const ctx = canvas.getContext('2d') if (ctx) { ctx.imageSmoothingEnabled = false // crisp nearest-neighbour for pixel art for (const op of ops) op.draw(ctx, entries[op.id]) try { dataUrl = canvas.toDataURL('image/png') } catch { dataUrl = null // tainted canvas — leave the renderer to fall back } } }
const bounds = minX === Infinity ? null : { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
return { version: MAP_EXPORT_VERSION, cell: { w: cw, h: ch }, sources: map.tilesets.map((t) => t.uri), sheet: { dataUrl, width: atlasW, height: atlasH, entries }, objectDefs, depthAxis: map.depthAxis, background: map.background, bounds, nodes, }}