lecodes-cli 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ // Tilesets as data: one `.tiles.ts` file per atlas, `export default defineTileset({...})` — the
2
+ // material vocabulary (autotile rules) for maps painted by material id. The pure model + deriver
3
+ // live in ./autotile.ts; this file is the runtime handle plus the `Autotile2D` aspect that binds a
4
+ // tilemap node to its tileset:
5
+ //
6
+ // // world.tiles.ts
7
+ // export default defineTileset({
8
+ // image: asset('./TileSet.png'),
9
+ // tile: 32,
10
+ // materials: {
11
+ // grass: { id: 1, kind: 'patch', color: '#4a7c3a', fill: [261, [262, 0.1]],
12
+ // over: { '*': { edges: [197, 262, 325, 260], outer: [198, 326, 324, 196],
13
+ // inner: [199, 327, 391, 263] } } },
14
+ // road: { id: 2, kind: 'path', color: '#8a8578', straights: [520, 521], corners: [522, 523, 524, 525] },
15
+ // },
16
+ // })
17
+ //
18
+ // // meadow.scene2d.ts
19
+ // import world from './world.tiles'
20
+ // ground: { tilemap: { texture: asset('./TileSet.png'), tile: 32, atlas: [64, 64],
21
+ // cells: cells('…') }, // cells hold MATERIAL ids here
22
+ // aspects: [use(Autotile2D, { tileset: world, seed: 3 })] }
23
+ //
24
+ // The aspect flips the meaning of the same `cells('…')` payload from raw atlas indices to material
25
+ // ids; the scene2d loader runs the deriver BEFORE the Tilemap is constructed (aspects are data on
26
+ // the node def), so no post-construction data path exists and derived indices are never persisted.
27
+
28
+ import { Aspect } from "../core/Aspect"
29
+ import type { Node2D } from "./Node2D"
30
+ import { deriveCells, type TilesetDef, type TileMaterial } from "./autotile"
31
+
32
+ export class Tileset {
33
+ private _def: TilesetDef
34
+
35
+ constructor(def: TilesetDef) { this._def = def }
36
+
37
+ /** The tileset definition (read-only by convention — the editor owns the file). */
38
+ get def(): TilesetDef { return this._def }
39
+
40
+ /** Names of the declared materials, in file order. */
41
+ get materialNames(): string[] { return Object.keys(this._def.materials ?? {}) }
42
+
43
+ /** A material by name. */
44
+ material(name: string): TileMaterial | undefined { return this._def.materials?.[name] }
45
+
46
+ /** The cell value maps store for a named material (what you paint / pass in `data`). */
47
+ id(name: string): number | undefined { return this._def.materials?.[name]?.id }
48
+
49
+ /** Derive display atlas indices from a material-id grid (what Autotile2D does at load). */
50
+ derive(cols: number, rows: number, data: ArrayLike<number>, seed = 0): Int32Array {
51
+ return deriveCells(this._def, cols, rows, data, seed)
52
+ }
53
+ }
54
+
55
+ /** Declare a tileset (the default export of a `.tiles.ts` file). */
56
+ export const defineTileset = (def: TilesetDef): Tileset => new Tileset(def)
57
+
58
+ /**
59
+ * Marks a tilemap node's `cells` as MATERIAL ids of a tileset instead of raw atlas indices:
60
+ * `aspects: [use(Autotile2D, { tileset: world })]`. The scene2d loader derives the display
61
+ * indices (neighbor-mask autotile + seeded scatter, deterministic) before building the tilemap;
62
+ * the aspect itself holds no behavior. Change `seed` to reshuffle scatter variants.
63
+ */
64
+ export class Autotile2D extends Aspect<"autotile", Node2D> {
65
+ static readonly aspect = "autotile"
66
+
67
+ /** The imported `.tiles.ts` handle. */
68
+ tileset: Tileset | null = null
69
+ /** Scatter seed — same seed, same map, same result on every platform. */
70
+ seed = 0
71
+ }
@@ -0,0 +1,433 @@
1
+ // Autotile materials: the pure model + deriver behind `.tiles.ts` files (docs/scene2d-editor-plan.md
2
+ // §7). A map painted with MATERIAL ids (intent) is turned into atlas tile indices (display) by
3
+ // neighbor-mask rules — never the other way around, and the derived indices are never persisted.
4
+ //
5
+ // Two material kinds, differing only in their neighbor classifier:
6
+ // - 'patch' — area materials (grass, dirt, water, platformer ground): the 8-neighbor blob mask.
7
+ // Minimal slot set = fill (+ weighted scatter variants) + 4 edges + 4 outer corners + 4 inner
8
+ // corners; missing slots fall back to simpler ones, so a partial set always renders.
9
+ // `size: 2 | 3` makes the material live on a size×size BLOCK lattice (anchored at map 0,0):
10
+ // classification runs block-to-block, fill holds size² tiles and each edge `size` tiles, while
11
+ // corners stay one cell. That is what art with a multi-cell pattern phase needs (herringbone,
12
+ // lane dashes), and it makes a one-block-wide strip carry BOTH edges by construction — a
13
+ // 2-lane road with its curbs and centre line is just `size: 2` painted one block wide.
14
+ // - 'path' — line materials (roads, fences, walls, rails): the 4-neighbor mask, 16 classes
15
+ // (isolated / ends / straights / corners / tees / cross), same fallback principle.
16
+ //
17
+ // Direction conventions (data is row-major, row 0 = the TOP row): N = row-1, S = row+1, E = col+1,
18
+ // W = col-1. Edge arrays are [n, e, s, w]; corner arrays are [ne, se, sw, nw] (clockwise from
19
+ // north-east). Cells outside the map count as SAME material, so patches continue off-map instead
20
+ // of growing a border along the map edge.
21
+ //
22
+ // A cell holds a material id, a part id, -1 for empty, or — below -1 — a RAW atlas tile
23
+ // (`rawCell(index)`), so hand-placed art can sit in a material map without colliding with ids.
24
+ //
25
+ // Any atlas index (a raw cell, a slot value) may carry an ORIENTATION packed into the same int:
26
+ // `rot90(i)` / `flipX(i)` — see the oriented-tiles section. The deriver treats oriented values as
27
+ // opaque positive ints and copies them through; renderers decode the bits into a UV permutation.
28
+ //
29
+ // Pure and engine-free on purpose — the 2D editor keeps a mirrored copy
30
+ // (packages/editor-2d/src/autotile.ts, like cells.ts / history.ts): keep them in sync.
31
+
32
+ /** A fill entry: an atlas index, or [index, weight] for seeded scatter variants. */
33
+ export type FillEntry = number | [number, number]
34
+
35
+ export type Dir4 = [number, number, number, number]
36
+
37
+ /** One side's tiles: a single index (used for every cell along that side), or — with `size` > 1 —
38
+ * the `size` indices ALONG it (n/s run left→right, w/e run top→bottom). */
39
+ export type EdgeSlot = number | number[]
40
+
41
+ /** A whole `size`×`size` piece: one index (drawn in every cell of the block), or size² indices
42
+ * row-major. Path pieces and parts are blocks — at size 1 they are just a tile. */
43
+ export type TileBlock = number | number[]
44
+
45
+ /** `size`² indices, row-major within the block. */
46
+ export type FillBlock = number[]
47
+
48
+ /** A named element placed BY HAND (a pedestrian crossing in a road, a gate in a fence). It carries
49
+ * its own `id`, so the map stores it like any material — but it CONNECTS as its parent, so the
50
+ * run continues straight through it. Never picked at random: you paint it from the palette, ONE
51
+ * CELL at a time, and the tile is worked out from where that cell sits. */
52
+ export type TilePart = {
53
+ /** Stable small int, unique across every material and part of the tileset. */
54
+ id: number
55
+ /** Which run the art is drawn for — 'v' vertical, 'h' horizontal. It also picks the art: the
56
+ * element varies ACROSS the line (a crossing has one tile per lane) and repeats along it, so
57
+ * `tiles` is `size` long, indexed by row for 'h' and by column for 'v'. */
58
+ axis?: "v" | "h"
59
+ /** The tiles — one index, `size` across the run when `axis` is set, else the size² block. */
60
+ tiles?: TileBlock
61
+ }
62
+
63
+ /** A block variant: the block, or [block, weight] for seeded scatter. */
64
+ export type BlockEntry = FillBlock | [FillBlock, number]
65
+
66
+ /** One transition set of a patch material — the tiles its border shows against a neighbor. */
67
+ export type PatchSlots = {
68
+ /** Edge line tiles [n, e, s, w] — e.g. `edges[0]` draws where the patch stops toward the top. */
69
+ edges?: [EdgeSlot, EdgeSlot, EdgeSlot, EdgeSlot]
70
+ /** Convex corner tiles [ne, se, sw, nw] — the patch's outside corners. Always one cell. */
71
+ outer?: Dir4
72
+ /** Concave corner tiles [ne, se, sw, nw] — the notch where two patch arms meet. One cell. */
73
+ inner?: Dir4
74
+ }
75
+
76
+ export type PatchMaterial = {
77
+ /** Stable small int — what `cells('…')` stores when a map carries Autotile2D. */
78
+ id: number
79
+ kind: "patch"
80
+ /** Editor swatch color ('#4a7c3a'). */
81
+ color?: string
82
+ /** Block size in cells (default 1). With 2 or 3 the material is classified block-to-block on a
83
+ * lattice anchored at the map origin, `fill` holds size² tiles and each edge `size` tiles. */
84
+ size?: number
85
+ /** Interior tile — one index, or an ARRAY of scatter variants (each `index` or
86
+ * `[index, weight]`; bare indices weigh 1): `fill: [261, [262, 0.1], [263, 0.05]]`.
87
+ * With `size` > 1 it is instead the row-major BLOCK of size² indices
88
+ * (`fill: [130, 131, 146, 147]`), or a list of such blocks for scatter variants. */
89
+ fill: number | FillEntry[] | BlockEntry[]
90
+ /** Transition sets keyed by the neighboring material's name; '*' = any other neighbor
91
+ * (including empty). Baked packs draw a material fading into a SPECIFIC background. */
92
+ over?: Record<string, PatchSlots>
93
+ /** Hand-placed elements that connect as this material (see TilePart). */
94
+ parts?: Record<string, TilePart>
95
+ }
96
+
97
+ export type PathMaterial = {
98
+ id: number
99
+ kind: "path"
100
+ color?: string
101
+ /** Cells ACROSS the line (default 1). A `size: 2` road is two lanes wide: connectivity runs
102
+ * block to block and every piece below is a 2×2 block instead of one tile. */
103
+ size?: number
104
+ /** No connections at all. */
105
+ isolated?: TileBlock
106
+ /** End caps [n, e, s, w] — `ends[0]` connects toward the top only. */
107
+ ends?: [TileBlock, TileBlock, TileBlock, TileBlock]
108
+ /** [vertical (n+s), horizontal (e+w)]. */
109
+ straights?: [TileBlock, TileBlock]
110
+ /** Elbows [ne, se, sw, nw] — `corners[0]` connects top + right. */
111
+ corners?: [TileBlock, TileBlock, TileBlock, TileBlock]
112
+ /** T pieces [n, e, s, w] — `tees[0]` is the tee MISSING the top connection. */
113
+ tees?: [TileBlock, TileBlock, TileBlock, TileBlock]
114
+ /** All four connections. */
115
+ cross?: TileBlock
116
+ /** Hand-placed elements that connect as this material (see TilePart). */
117
+ parts?: Record<string, TilePart>
118
+ }
119
+
120
+ export type TileMaterial = PatchMaterial | PathMaterial
121
+
122
+ export type TilesetDef = {
123
+ /** The atlas image — write `asset('./TileSet.png')`. */
124
+ image: string
125
+ /** Atlas cell size in image pixels — a number (square) or [w, h]. */
126
+ tile: number | [number, number]
127
+ /** Named materials; names are the editor/`over:` vocabulary, `id`s are what maps store. */
128
+ materials?: Record<string, TileMaterial>
129
+ }
130
+
131
+ // ---- helpers -------------------------------------------------------------------
132
+
133
+ export const normalizeFill = (fill: number | FillEntry[]): [number, number][] => {
134
+ if (typeof fill === "number") return [ [ fill, 1 ] ]
135
+ return fill.map((e) => (typeof e === "number" ? [ e, 1 ] : [ e[0], e[1] ]))
136
+ }
137
+
138
+ /** A material's block size — 1 unless it declares `size` (clamped to a positive int). */
139
+ export const blockSize = (mat: TileMaterial): number => Math.max(1, Math.round(mat.size ?? 1))
140
+
141
+ /** One cell of a block: a scalar covers the whole block, an array reads row-major. Undefined =
142
+ * unassigned (missing, or the negative sentinel the editor writes for a blank slot). */
143
+ export const blockTile = (
144
+ block: TileBlock | undefined, n: number, lx: number, ly: number,
145
+ ): number | undefined => {
146
+ if (typeof block === "number") return block >= 0 ? block : undefined
147
+ if (!Array.isArray(block)) return undefined
148
+ const t = block[ly * n + lx]
149
+ return typeof t === "number" && t >= 0 ? t : undefined
150
+ }
151
+
152
+ /** How many tiles a part holds: `size` across the run when it declares an axis, else the block. */
153
+ export const partSlots = (part: TilePart, n: number): number => (part.axis ? n : n * n)
154
+
155
+ // ---- raw tiles inside a material map ------------------------------------------------------------
156
+ // A material map stores INTENT, so a cell holding a plain atlas index would be ambiguous (is `3`
157
+ // material 3 or tile 3?). Raw tiles therefore live below -1, out of the material id space: -2 is
158
+ // tile 0, -3 is tile 1, and so on. -1 stays empty, and a raw cell simply draws itself.
159
+
160
+ /** The cell value that draws atlas tile `index` verbatim in a material map. */
161
+ export const rawCell = (index: number): number => -index - 2
162
+
163
+ /** True for a cell holding a raw atlas tile rather than a material id. */
164
+ export const isRawCell = (v: number): boolean => v <= -2
165
+
166
+ /** The atlas index behind a raw cell (garbage in, garbage out — guard with isRawCell). */
167
+ export const rawIndex = (v: number): number => -v - 2
168
+
169
+ // ---- oriented tiles -----------------------------------------------------------------------------
170
+ // Any tile value — a slot in a `.tiles.ts` material, a raw cell, a plain index in a raw map — can
171
+ // carry an orientation in the SAME int: bits 0–27 hold the atlas index, bit 28 a horizontal mirror,
172
+ // bits 29–30 the clockwise quarter-turns; the mirror applies FIRST, then the rotation. The sign bit
173
+ // is never touched, so every oriented value stays positive: -1 (empty), raw cells (≤ -2) and the
174
+ // negative "unassigned slot" sentinel all keep working, and the deriver passes oriented values
175
+ // through untouched. Renderers permute the atlas cell's UV corners — same quads, same batches.
176
+ // `rot90`/`flipX` COMPOSE (they act on the value's current orientation), so `rot90(rot90(t))` is a
177
+ // half turn and all 8 orientations of a tile are reachable; write them in `.tiles.ts` files
178
+ // directly: `ends: [121, rot90(121), rot180(121), rot270(121)]`.
179
+
180
+ export const TILE_INDEX_MASK = 0x0fffffff
181
+ const TILE_FLIP = 1 << 28
182
+
183
+ /** The atlas index behind a (possibly oriented) tile value. */
184
+ export const tileIndex = (v: number): number => v & TILE_INDEX_MASK
185
+ /** Clockwise quarter-turns (0–3) of a tile value. */
186
+ export const tileTurns = (v: number): number => (v >> 29) & 3
187
+ /** Whether the tile is mirrored horizontally (the mirror applies before the rotation). */
188
+ export const tileFlip = (v: number): boolean => (v & TILE_FLIP) !== 0
189
+ /** True when the value carries any orientation (renderers keep the fast path otherwise). */
190
+ export const tileOriented = (v: number): boolean => v > TILE_INDEX_MASK
191
+ /** Build an oriented tile value: `index`, mirrored when `flip`, then `turns` quarter-turns CW. */
192
+ export const packTile = (index: number, turns = 0, flip = false): number =>
193
+ (index & TILE_INDEX_MASK) | (flip ? TILE_FLIP : 0) | ((turns & 3) << 29)
194
+
195
+ /** The value rotated a further 90° clockwise. */
196
+ export const rot90 = (v: number): number => packTile(tileIndex(v), tileTurns(v) + 1, tileFlip(v))
197
+ /** The value rotated a further 180°. */
198
+ export const rot180 = (v: number): number => packTile(tileIndex(v), tileTurns(v) + 2, tileFlip(v))
199
+ /** The value rotated a further 270° clockwise (90° counter-clockwise). */
200
+ export const rot270 = (v: number): number => packTile(tileIndex(v), tileTurns(v) + 3, tileFlip(v))
201
+ /** The value mirrored horizontally (on screen — existing turns are re-based, group math). */
202
+ export const flipX = (v: number): number => packTile(tileIndex(v), 4 - tileTurns(v), !tileFlip(v))
203
+
204
+ /** The tile a part draws in a cell — indexed ACROSS the run ('h' by row, 'v' by column) so one
205
+ * element covers a lane each and repeats along the run; without an axis it is a plain block. */
206
+ export const partTile = (part: TilePart, n: number, lx: number, ly: number): number | undefined => {
207
+ const tiles = part.tiles
208
+ if (typeof tiles === "number") return tiles >= 0 ? tiles : undefined
209
+ if (!Array.isArray(tiles)) return undefined
210
+ const t = tiles[part.axis === "h" ? ly : part.axis === "v" ? lx : ly * n + lx]
211
+ return typeof t === "number" && t >= 0 ? t : undefined
212
+ }
213
+
214
+ /** `fill` as weighted BLOCKS of `size`² indices (row-major). Size 1 keeps the scalar/scatter
215
+ * reading; with size > 1 a flat number array is one positional block, an array of arrays is the
216
+ * variant list, and a bare number fills the whole block. Missing entries read as -1. */
217
+ export const fillBlocks = (fill: PatchMaterial["fill"], size = 1): [number[], number][] => {
218
+ const n = Math.max(1, Math.round(size))
219
+ const area = n * n
220
+ const pad = (b: readonly number[]): number[] =>
221
+ Array.from({ length: area }, (_, i) => (typeof b[i] === "number" ? b[i]! : -1))
222
+ if (typeof fill === "number") return [ [ new Array<number>(area).fill(fill), 1 ] ]
223
+ if (!Array.isArray(fill) || fill.length === 0) return []
224
+ if (n === 1) return normalizeFill(fill as FillEntry[]).map(([ i, w ]) => [ [ i ], w ])
225
+ if (!Array.isArray(fill[0])) return [ [ pad(fill as number[]), 1 ] ]
226
+ return (fill as BlockEntry[]).map((e): [number[], number] =>
227
+ Array.isArray(e[0]) ? [ pad(e[0] as number[]), (e as [FillBlock, number])[1] ?? 1 ] : [ pad(e as number[]), 1 ])
228
+ }
229
+
230
+ /** Deterministic per-cell hash in [0, 1) — scatter must not reshuffle when nearby cells change. */
231
+ const hash01 = (x: number, y: number, seed: number): number => {
232
+ let h = (Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(seed, 2246822519)) | 0
233
+ h = Math.imul(h ^ (h >>> 13), 1274126177)
234
+ h ^= h >>> 16
235
+ return (h >>> 0) / 4294967296
236
+ }
237
+
238
+ /** The fill tile for block (bc, br) at block-local (lx, ly). Scatter rolls once per BLOCK, so a
239
+ * multi-cell pattern never tears; at size 1 the block IS the cell (identical to before). */
240
+ const pickFill = (
241
+ mat: PatchMaterial, bc: number, br: number, seed: number, n: number, lx: number, ly: number,
242
+ ): number => {
243
+ const variants = fillBlocks(mat.fill, n)
244
+ if (variants.length === 0) return -1
245
+ const at = ly * n + lx
246
+ if (variants.length === 1) return variants[0]![0][at] ?? -1
247
+ const total = variants.reduce((s, v) => s + v[1], 0)
248
+ let roll = hash01(bc, br, seed + mat.id * 7919) * total
249
+ for (const [ block, w ] of variants) {
250
+ roll -= w
251
+ if (roll < 0) return block[at] ?? -1
252
+ }
253
+ return variants[variants.length - 1]![0][at] ?? -1
254
+ }
255
+
256
+ // corner order [ne, se, sw, nw] as (dx, dy) pairs; edge order [n, e, s, w]
257
+ const EDGE_D: [number, number][] = [ [ 0, -1 ], [ 1, 0 ], [ 0, 1 ], [ -1, 0 ] ]
258
+ const CORNER_D: [number, number][] = [ [ 1, -1 ], [ 1, 1 ], [ -1, 1 ], [ -1, -1 ] ]
259
+
260
+ // ---- the deriver ---------------------------------------------------------------
261
+
262
+ /**
263
+ * Derive display atlas indices from a material-id grid (row-major, row 0 = top, -1 = empty).
264
+ * Values that match no material id pass through unchanged (so converting maps degrades
265
+ * gracefully); -1 stays -1. Deterministic: same inputs + seed → same output on every platform.
266
+ */
267
+ export const deriveCells = (
268
+ def: TilesetDef, cols: number, rows: number, cells: ArrayLike<number>, seed = 0,
269
+ ): Int32Array => {
270
+ const byId = new Map<number, { name: string, mat: TileMaterial }>()
271
+ const parts = new Map<number, { part: TilePart, mat: TileMaterial, owner: number }>()
272
+ for (const [ name, mat ] of Object.entries(def.materials ?? {})) {
273
+ if (!byId.has(mat.id)) byId.set(mat.id, { name, mat })
274
+ for (const part of Object.values(mat.parts ?? {})) {
275
+ if (!parts.has(part.id)) parts.set(part.id, { part, mat, owner: mat.id })
276
+ }
277
+ }
278
+ // a part's cells read as their PARENT everywhere connectivity is asked — the run continues
279
+ // straight through a crossing or a gate; only the drawing differs
280
+ const ownerOf = (v: number): number => parts.get(v)?.owner ?? v
281
+
282
+ const out = new Int32Array(cols * rows)
283
+ const at = (c: number, r: number, self: number): number =>
284
+ (c < 0 || r < 0 || c >= cols || r >= rows) ? self : ownerOf(cells[r * cols + c] as number)
285
+ /** A block belongs to `self` when ANY of its cells does — so a block you painted one cell at a
286
+ * time still reads as part of the run (and off-map keeps counting as same). */
287
+ const blockAt = (ax: number, ay: number, n: number, self: number): number => {
288
+ if (n === 1) return at(ax, ay, self)
289
+ for (let y = ay; y < ay + n; y++) {
290
+ for (let x = ax; x < ax + n; x++) if (at(x, y, self) === self) return self
291
+ }
292
+ return at(ax, ay, self)
293
+ }
294
+
295
+ for (let r = 0; r < rows; r++) {
296
+ for (let c = 0; c < cols; c++) {
297
+ const raw = cells[r * cols + c] as number
298
+ // a raw cell draws its atlas tile verbatim — hand-placed art inside a material map
299
+ if (isRawCell(raw)) { out[r * cols + c] = rawIndex(raw); continue }
300
+ const v = raw >= 0 ? ownerOf(raw) : raw
301
+ const entry = v >= 0 ? byId.get(v) : undefined
302
+ if (!entry) { out[r * cols + c] = raw; continue }
303
+ const hit = parts.get(raw)
304
+ if (hit) {
305
+ const n = blockSize(entry.mat)
306
+ const t = partTile(hit.part, n, c - Math.floor(c / n) * n, r - Math.floor(r / n) * n)
307
+ // an unassigned part still draws its material, so a half-authored element never blanks out
308
+ if (t !== undefined) { out[r * cols + c] = t; continue }
309
+ }
310
+ out[r * cols + c] = entry.mat.kind === "patch"
311
+ ? derivePatch(entry.mat, byId, blockAt, c, r, v, seed)
312
+ : derivePath(entry.mat, blockAt, c, r, v)
313
+ }
314
+ }
315
+ return out
316
+ }
317
+
318
+ /** Convex corners as [cardA, cardB, cornerIndex]: n+e → ne(0), e+s → se(1), s+w → sw(2), w+n → nw(3). */
319
+ const CORNER_PAIRS: [number, number, number][] = [ [ 0, 1, 0 ], [ 1, 2, 1 ], [ 2, 3, 2 ], [ 3, 0, 3 ] ]
320
+
321
+ /** Where each concave corner shows inside a block: [localX, localY, cardA, cardB], in [ne, se, sw, nw]. */
322
+ const INNER_AT = (n: number): [number, number, number, number][] => [
323
+ [ n - 1, 0, 0, 1 ], [ n - 1, n - 1, 1, 2 ], [ 0, n - 1, 2, 3 ], [ 0, 0, 3, 0 ],
324
+ ]
325
+
326
+ /** Samples a whole BLOCK: (anchorX, anchorY, size, self) → the material that block belongs to. */
327
+ type BlockAt = (ax: number, ay: number, n: number, self: number) => number
328
+
329
+ const derivePatch = (
330
+ mat: PatchMaterial, byId: Map<number, { name: string, mat: TileMaterial }>,
331
+ at: BlockAt, c: number, r: number, v: number, seed: number,
332
+ ): number => {
333
+ // With `size` > 1 the material lives on a block lattice anchored at the map origin: neighbors are
334
+ // sampled one BLOCK away (at that block's anchor cell) and the tile is placed by the cell's
335
+ // position INSIDE the block. n = 1 makes every block one cell — the original per-cell rules.
336
+ const n = blockSize(mat)
337
+ const bc = Math.floor(c / n), br = Math.floor(r / n)
338
+ const lx = c - bc * n, ly = r - br * n
339
+ const ax = bc * n, ay = br * n
340
+ const same = (dx: number, dy: number): boolean => at(ax + dx * n, ay + dy * n, n, v) === v
341
+ // cardinal + diagonal same-bits (diagonals only matter where both adjacent cardinals are same)
342
+ const card = EDGE_D.map(([ dx, dy ]) => same(dx, dy)) // [n, e, s, w]
343
+ const diag = CORNER_D.map(([ dx, dy ]) => same(dx, dy)) // [ne, se, sw, nw]
344
+ const fill = (): number => pickFill(mat, bc, br, seed, n, lx, ly)
345
+
346
+ const slots = pickSlots(mat, byId, at, ax, ay, v, n)
347
+ if (!slots) return fill()
348
+ // a negative value inside a slot array means "unassigned" (the editor writes partial sets)
349
+ const pick = (t: number | undefined): number | undefined => (t !== undefined && t >= 0 ? t : undefined)
350
+ const outer = (i: number): number | undefined => pick(slots.outer?.[i])
351
+ const edge = (d: number): number | undefined => {
352
+ const s = slots.edges?.[d]
353
+ if (typeof s === "number") return pick(s)
354
+ return Array.isArray(s) ? pick(s[d === 0 || d === 2 ? lx : ly]) : undefined
355
+ }
356
+
357
+ // sides of THIS cell that sit on a block border facing another material
358
+ const on = [
359
+ !card[0]! && ly === 0, !card[1]! && lx === n - 1, !card[2]! && ly === n - 1, !card[3]! && lx === 0,
360
+ ]
361
+ for (const [ a, b, i ] of CORNER_PAIRS) {
362
+ if (on[a]! && on[b]!) { const t = outer(i); if (t !== undefined) return t }
363
+ }
364
+ for (let d = 0; d < 4; d++) if (on[d]!) { const t = edge(d); if (t !== undefined) return t }
365
+ if (on.some(Boolean)) return fill()
366
+
367
+ // block interior — a differing relevant diagonal is a concave (inner) corner, drawn in the one
368
+ // cell of the block that touches it
369
+ const inner = INNER_AT(n)
370
+ for (let i = 0; i < 4; i++) {
371
+ const [ px, py, a, b ] = inner[i]!
372
+ if (lx === px && ly === py && card[a]! && card[b]! && !diag[i]!) return pick(slots.inner?.[i]) ?? fill()
373
+ }
374
+ return fill()
375
+ }
376
+
377
+ /** The transition set for this block: keyed by the dominant differing neighbor's material name,
378
+ * '*' as the fallback (empty and unknown neighbors always land on '*'). */
379
+ const pickSlots = (
380
+ mat: PatchMaterial, byId: Map<number, { name: string, mat: TileMaterial }>,
381
+ at: BlockAt, ax: number, ay: number, v: number, n: number,
382
+ ): PatchSlots | undefined => {
383
+ const over = mat.over
384
+ if (!over) return undefined
385
+ const counts = new Map<number, number>()
386
+ for (const [ dx, dy ] of [ ...EDGE_D, ...CORNER_D ]) {
387
+ const nv = at(ax + dx * n, ay + dy * n, n, v)
388
+ if (nv !== v) counts.set(nv, (counts.get(nv) ?? 0) + 1)
389
+ }
390
+ let bestId: number | null = null, best = 0
391
+ for (const [ id, n ] of counts) {
392
+ if (n > best || (n === best && bestId !== null && id < bestId)) { best = n; bestId = id }
393
+ }
394
+ const name = bestId !== null && bestId >= 0 ? byId.get(bestId)?.name : undefined
395
+ return (name !== undefined ? over[name] : undefined) ?? over["*"]
396
+ }
397
+
398
+ const derivePath = (
399
+ mat: PathMaterial, at: BlockAt, c: number, r: number, v: number,
400
+ ): number => {
401
+ // `size` makes the line that many cells ACROSS: connectivity is block to block and every piece
402
+ // is a size² block placed by the cell's position inside it (size 1 = one tile per piece).
403
+ const n = blockSize(mat)
404
+ const bc = Math.floor(c / n), br = Math.floor(r / n)
405
+ const lx = c - bc * n, ly = r - br * n
406
+ const ax = bc * n, ay = br * n
407
+ const conn = EDGE_D.map(([ dx, dy ]) => at(ax + dx * n, ay + dy * n, n, v) === v) // [n, e, s, w]
408
+ const links = conn.filter(Boolean).length
409
+ // a negative value inside a piece means "unassigned" (the editor writes partial sets)
410
+ const pick = (b: TileBlock | undefined): number | undefined => blockTile(b, n, lx, ly)
411
+ const any = (): number =>
412
+ pick(mat.isolated) ?? pick(mat.cross) ?? pick(mat.straights?.[1]) ?? pick(mat.straights?.[0])
413
+ ?? mat.ends?.map((b) => pick(b)).find((t) => t !== undefined)
414
+ ?? pick(mat.corners?.[0]) ?? pick(mat.tees?.[0]) ?? v
415
+
416
+ if (links === 0) return pick(mat.isolated) ?? any()
417
+ if (links === 1) {
418
+ const d = conn.findIndex(Boolean)
419
+ return pick(mat.ends?.[d]) ?? pick(mat.straights?.[d % 2 === 0 ? 0 : 1]) ?? any()
420
+ }
421
+ if (links === 2) {
422
+ if (conn[0] && conn[2]) return pick(mat.straights?.[0]) ?? any()
423
+ if (conn[1] && conn[3]) return pick(mat.straights?.[1]) ?? any()
424
+ // elbows: n+e → ne(0), e+s → se(1), s+w → sw(2), w+n → nw(3)
425
+ const corner = conn[0] && conn[1] ? 0 : conn[1] && conn[2] ? 1 : conn[2] && conn[3] ? 2 : 3
426
+ return pick(mat.corners?.[corner]) ?? pick(mat.cross) ?? any()
427
+ }
428
+ if (links === 3) {
429
+ const missing = conn.findIndex((s) => !s)
430
+ return pick(mat.tees?.[missing]) ?? pick(mat.cross) ?? pick(mat.straights?.[missing % 2 === 0 ? 1 : 0]) ?? any()
431
+ }
432
+ return pick(mat.cross) ?? any()
433
+ }
@@ -0,0 +1,91 @@
1
+ // The `cells('…')` payload literal used by `.scene2d.ts` tilemap blocks (and terrain material maps
2
+ // later): one opaque base64 string holding the grid dimensions + row-major Int32 cell values, so a
3
+ // whole map prints on one line of the scene file. Layout (little-endian Int32): [cols, rows,
4
+ // runLen₁, value₁, runLen₂, value₂, …] — run-length pairs, because tilemaps are dominated by long
5
+ // runs of the same tile (a 100×100 grass field is a handful of pairs, not 10k ints). The payload is
6
+ // versionless by design: cols is always > 0, so a future format can claim a negative sentinel.
7
+ //
8
+ // Self-contained on purpose — QuickJS has no atob/Buffer, and the editor keeps its own mirrored
9
+ // copy of this codec (the scene-doc/editor side must not depend on the SDK).
10
+
11
+ export type CellsData = {
12
+ cols: number
13
+ rows: number
14
+ /** Row-major tile values (row 0 = the top row), length cols*rows. */
15
+ data: Int32Array
16
+ }
17
+
18
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
19
+ let B64_REV: Int8Array | null = null
20
+
21
+ const b64encode = (bytes: Uint8Array): string => {
22
+ let out = ""
23
+ for (let i = 0; i < bytes.length; i += 3) {
24
+ const b0 = bytes[i]!, b1 = bytes[i + 1], b2 = bytes[i + 2]
25
+ out += B64[b0 >> 2]! + B64[((b0 & 3) << 4) | ((b1 ?? 0) >> 4)]!
26
+ out += b1 === undefined ? "=" : B64[((b1 & 15) << 2) | ((b2 ?? 0) >> 6)]!
27
+ out += b2 === undefined ? "=" : B64[b2 & 63]!
28
+ }
29
+ return out
30
+ }
31
+
32
+ const b64decode = (s: string): Uint8Array => {
33
+ if (!B64_REV) {
34
+ B64_REV = new Int8Array(128).fill(-1)
35
+ for (let i = 0; i < 64; i++) B64_REV[B64.charCodeAt(i)] = i
36
+ }
37
+ const clean = s.replace(/=+$/, "")
38
+ const out = new Uint8Array(Math.floor(clean.length * 3 / 4))
39
+ let acc = 0, bits = 0, w = 0
40
+ for (let i = 0; i < clean.length; i++) {
41
+ const v = B64_REV[clean.charCodeAt(i)] ?? -1
42
+ if (v < 0) throw new Error("cells(): payload is not base64")
43
+ acc = (acc << 6) | v
44
+ bits += 6
45
+ if (bits >= 8) { bits -= 8; out[w++] = (acc >> bits) & 0xff }
46
+ }
47
+ return out
48
+ }
49
+
50
+ /** Encode a grid into a `cells()` payload (the editor / tools side of the codec). */
51
+ export const encodeCells = (cols: number, rows: number, data: ArrayLike<number>): string => {
52
+ if (data.length !== cols * rows) throw new Error(`encodeCells: expected ${cols * rows} values, got ${data.length}`)
53
+ const ints: number[] = [ cols, rows ]
54
+ for (let i = 0; i < data.length;) {
55
+ const v = data[i]!
56
+ let run = 1
57
+ while (i + run < data.length && data[i + run] === v) run++
58
+ ints.push(run, v)
59
+ i += run
60
+ }
61
+ const bytes = new Uint8Array(ints.length * 4)
62
+ const dv = new DataView(bytes.buffer)
63
+ ints.forEach((n, i) => dv.setInt32(i * 4, n, true))
64
+ return b64encode(bytes)
65
+ }
66
+
67
+ /**
68
+ * Decode a `cells('…')` payload into the grid a tilemap block consumes. Scene files call this via
69
+ * the injected global; the value is plain data, so hand-written code may pass any
70
+ * `{ cols, rows, data }` of its own instead (procedural maps).
71
+ */
72
+ export const cells = (payload: string): CellsData => {
73
+ const bytes = b64decode(payload)
74
+ if (bytes.length < 16 || bytes.length % 4 !== 0) throw new Error("cells(): malformed payload")
75
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
76
+ const cols = dv.getInt32(0, true)
77
+ const rows = dv.getInt32(4, true)
78
+ const count = bytes.length / 4 - 2
79
+ if (cols <= 0 || rows <= 0 || count % 2 !== 0) throw new Error("cells(): malformed payload")
80
+ const data = new Int32Array(cols * rows)
81
+ let w = 0
82
+ for (let i = 0; i < count; i += 2) {
83
+ const run = dv.getInt32(8 + i * 4, true)
84
+ const value = dv.getInt32(12 + i * 4, true)
85
+ if (run <= 0 || w + run > data.length) throw new Error("cells(): payload does not match its dimensions")
86
+ data.fill(value, w, w + run)
87
+ w += run
88
+ }
89
+ if (w !== data.length) throw new Error("cells(): payload does not match its dimensions")
90
+ return { cols, rows, data }
91
+ }