lecodes-cli 0.10.4 → 0.12.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.
Files changed (54) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +48887 -6261
  3. package/package.json +4 -3
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk/compile/aspectMacro.ts +42 -0
  6. package/runtime/sdk/compile/assetIconMacro.ts +382 -0
  7. package/runtime/sdk/compile/assetMacro.ts +45 -0
  8. package/runtime/sdk/compile/assetName.ts +50 -0
  9. package/runtime/sdk/compile/bundler.ts +244 -0
  10. package/runtime/sdk/compile/compileProject.ts +106 -0
  11. package/runtime/sdk/compile/detectEntry.ts +125 -0
  12. package/runtime/sdk/compile/fontMacro.ts +459 -0
  13. package/runtime/sdk/compile/fontRegistry.ts +78 -0
  14. package/runtime/sdk/compile/header.ts +55 -0
  15. package/runtime/sdk/compile/index.ts +53 -0
  16. package/runtime/sdk/compile/libraryImports.ts +52 -0
  17. package/runtime/sdk/compile/sceneEditor.ts +78 -0
  18. package/runtime/sdk/compile/sfnt.ts +98 -0
  19. package/runtime/sdk/compile/sourcemap.ts +25 -0
  20. package/runtime/sdk/g2/Node2D.ts +3 -0
  21. package/runtime/sdk/g2/SpriteAnimation.ts +101 -20
  22. package/runtime/sdk/g2/SpriteSheet.ts +166 -0
  23. package/runtime/sdk/g2/Tileset.ts +71 -0
  24. package/runtime/sdk/g2/autotile.ts +394 -0
  25. package/runtime/sdk/g2/cells.ts +91 -0
  26. package/runtime/sdk/g2/defineScene2d.ts +381 -0
  27. package/runtime/sdk/g2/scenarios2d.ts +69 -0
  28. package/runtime/sdk/inject.ts +18 -0
  29. package/runtime/sdk/runtime/app.ts +21 -3
  30. package/runtime/sdk/scene/defineScene.ts +10 -70
  31. package/runtime/sdk/scene/grammar.ts +85 -0
  32. package/runtime/sdk/ui/NativeView.ts +9 -12
  33. package/runtime/sdk/ui/UI.ts +9 -1
  34. package/runtime/sdk/ui/UIBottomSheet.ts +10 -20
  35. package/runtime/sdk/ui/UIButton.ts +19 -36
  36. package/runtime/sdk/ui/UIContainer.ts +41 -103
  37. package/runtime/sdk/ui/UIImage.ts +10 -18
  38. package/runtime/sdk/ui/UIInput.ts +14 -26
  39. package/runtime/sdk/ui/UIModal.ts +10 -20
  40. package/runtime/sdk/ui/UINode.ts +142 -36
  41. package/runtime/sdk/ui/UIPager.ts +29 -28
  42. package/runtime/sdk/ui/UIPopover.ts +10 -16
  43. package/runtime/sdk/ui/UIScreen.ts +23 -40
  44. package/runtime/sdk/ui/UIScrollable.ts +37 -47
  45. package/runtime/sdk/ui/UISpacer.ts +4 -8
  46. package/runtime/sdk/ui/UITabs.ts +175 -0
  47. package/runtime/sdk/ui/UIText.ts +7 -13
  48. package/runtime/sdk/ui/UIVideo.ts +11 -13
  49. package/runtime/sdk/ui/UIVirtualizedList.ts +43 -49
  50. package/runtime/sdk/ui/UIWidget.ts +24 -36
  51. package/runtime/sdk/ui/fonts.ts +3 -0
  52. package/runtime/sdk/ui/router.ts +13 -0
  53. package/runtime/sdk/ui/theme.ts +21 -30
  54. package/runtime/sdk-types.json +1 -1
@@ -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,394 @@
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
+ // Pure and engine-free on purpose — the 2D editor keeps a mirrored copy
26
+ // (packages/editor-2d/src/autotile.ts, like cells.ts / history.ts): keep them in sync.
27
+
28
+ /** A fill entry: an atlas index, or [index, weight] for seeded scatter variants. */
29
+ export type FillEntry = number | [number, number]
30
+
31
+ export type Dir4 = [number, number, number, number]
32
+
33
+ /** One side's tiles: a single index (used for every cell along that side), or — with `size` > 1 —
34
+ * the `size` indices ALONG it (n/s run left→right, w/e run top→bottom). */
35
+ export type EdgeSlot = number | number[]
36
+
37
+ /** A whole `size`×`size` piece: one index (drawn in every cell of the block), or size² indices
38
+ * row-major. Path pieces and parts are blocks — at size 1 they are just a tile. */
39
+ export type TileBlock = number | number[]
40
+
41
+ /** `size`² indices, row-major within the block. */
42
+ export type FillBlock = number[]
43
+
44
+ /** A named element placed BY HAND (a pedestrian crossing in a road, a gate in a fence). It carries
45
+ * its own `id`, so the map stores it like any material — but it CONNECTS as its parent, so the
46
+ * run continues straight through it. Never picked at random: you paint it from the palette, ONE
47
+ * CELL at a time, and the tile is worked out from where that cell sits. */
48
+ export type TilePart = {
49
+ /** Stable small int, unique across every material and part of the tileset. */
50
+ id: number
51
+ /** Which run the art is drawn for — 'v' vertical, 'h' horizontal. It also picks the art: the
52
+ * element varies ACROSS the line (a crossing has one tile per lane) and repeats along it, so
53
+ * `tiles` is `size` long, indexed by row for 'h' and by column for 'v'. */
54
+ axis?: "v" | "h"
55
+ /** The tiles — one index, `size` across the run when `axis` is set, else the size² block. */
56
+ tiles?: TileBlock
57
+ }
58
+
59
+ /** A block variant: the block, or [block, weight] for seeded scatter. */
60
+ export type BlockEntry = FillBlock | [FillBlock, number]
61
+
62
+ /** One transition set of a patch material — the tiles its border shows against a neighbor. */
63
+ export type PatchSlots = {
64
+ /** Edge line tiles [n, e, s, w] — e.g. `edges[0]` draws where the patch stops toward the top. */
65
+ edges?: [EdgeSlot, EdgeSlot, EdgeSlot, EdgeSlot]
66
+ /** Convex corner tiles [ne, se, sw, nw] — the patch's outside corners. Always one cell. */
67
+ outer?: Dir4
68
+ /** Concave corner tiles [ne, se, sw, nw] — the notch where two patch arms meet. One cell. */
69
+ inner?: Dir4
70
+ }
71
+
72
+ export type PatchMaterial = {
73
+ /** Stable small int — what `cells('…')` stores when a map carries Autotile2D. */
74
+ id: number
75
+ kind: "patch"
76
+ /** Editor swatch color ('#4a7c3a'). */
77
+ color?: string
78
+ /** Block size in cells (default 1). With 2 or 3 the material is classified block-to-block on a
79
+ * lattice anchored at the map origin, `fill` holds size² tiles and each edge `size` tiles. */
80
+ size?: number
81
+ /** Interior tile — one index, or an ARRAY of scatter variants (each `index` or
82
+ * `[index, weight]`; bare indices weigh 1): `fill: [261, [262, 0.1], [263, 0.05]]`.
83
+ * With `size` > 1 it is instead the row-major BLOCK of size² indices
84
+ * (`fill: [130, 131, 146, 147]`), or a list of such blocks for scatter variants. */
85
+ fill: number | FillEntry[] | BlockEntry[]
86
+ /** Transition sets keyed by the neighboring material's name; '*' = any other neighbor
87
+ * (including empty). Baked packs draw a material fading into a SPECIFIC background. */
88
+ over?: Record<string, PatchSlots>
89
+ /** Hand-placed elements that connect as this material (see TilePart). */
90
+ parts?: Record<string, TilePart>
91
+ }
92
+
93
+ export type PathMaterial = {
94
+ id: number
95
+ kind: "path"
96
+ color?: string
97
+ /** Cells ACROSS the line (default 1). A `size: 2` road is two lanes wide: connectivity runs
98
+ * block to block and every piece below is a 2×2 block instead of one tile. */
99
+ size?: number
100
+ /** No connections at all. */
101
+ isolated?: TileBlock
102
+ /** End caps [n, e, s, w] — `ends[0]` connects toward the top only. */
103
+ ends?: [TileBlock, TileBlock, TileBlock, TileBlock]
104
+ /** [vertical (n+s), horizontal (e+w)]. */
105
+ straights?: [TileBlock, TileBlock]
106
+ /** Elbows [ne, se, sw, nw] — `corners[0]` connects top + right. */
107
+ corners?: [TileBlock, TileBlock, TileBlock, TileBlock]
108
+ /** T pieces [n, e, s, w] — `tees[0]` is the tee MISSING the top connection. */
109
+ tees?: [TileBlock, TileBlock, TileBlock, TileBlock]
110
+ /** All four connections. */
111
+ cross?: TileBlock
112
+ /** Hand-placed elements that connect as this material (see TilePart). */
113
+ parts?: Record<string, TilePart>
114
+ }
115
+
116
+ export type TileMaterial = PatchMaterial | PathMaterial
117
+
118
+ export type TilesetDef = {
119
+ /** The atlas image — write `asset('./TileSet.png')`. */
120
+ image: string
121
+ /** Atlas cell size in image pixels — a number (square) or [w, h]. */
122
+ tile: number | [number, number]
123
+ /** Named materials; names are the editor/`over:` vocabulary, `id`s are what maps store. */
124
+ materials?: Record<string, TileMaterial>
125
+ }
126
+
127
+ // ---- helpers -------------------------------------------------------------------
128
+
129
+ export const normalizeFill = (fill: number | FillEntry[]): [number, number][] => {
130
+ if (typeof fill === "number") return [ [ fill, 1 ] ]
131
+ return fill.map((e) => (typeof e === "number" ? [ e, 1 ] : [ e[0], e[1] ]))
132
+ }
133
+
134
+ /** A material's block size — 1 unless it declares `size` (clamped to a positive int). */
135
+ export const blockSize = (mat: TileMaterial): number => Math.max(1, Math.round(mat.size ?? 1))
136
+
137
+ /** One cell of a block: a scalar covers the whole block, an array reads row-major. Undefined =
138
+ * unassigned (missing, or the negative sentinel the editor writes for a blank slot). */
139
+ export const blockTile = (
140
+ block: TileBlock | undefined, n: number, lx: number, ly: number,
141
+ ): number | undefined => {
142
+ if (typeof block === "number") return block >= 0 ? block : undefined
143
+ if (!Array.isArray(block)) return undefined
144
+ const t = block[ly * n + lx]
145
+ return typeof t === "number" && t >= 0 ? t : undefined
146
+ }
147
+
148
+ /** How many tiles a part holds: `size` across the run when it declares an axis, else the block. */
149
+ export const partSlots = (part: TilePart, n: number): number => (part.axis ? n : n * n)
150
+
151
+ // ---- raw tiles inside a material map ------------------------------------------------------------
152
+ // A material map stores INTENT, so a cell holding a plain atlas index would be ambiguous (is `3`
153
+ // material 3 or tile 3?). Raw tiles therefore live below -1, out of the material id space: -2 is
154
+ // tile 0, -3 is tile 1, and so on. -1 stays empty, and a raw cell simply draws itself.
155
+
156
+ /** The cell value that draws atlas tile `index` verbatim in a material map. */
157
+ export const rawCell = (index: number): number => -index - 2
158
+
159
+ /** True for a cell holding a raw atlas tile rather than a material id. */
160
+ export const isRawCell = (v: number): boolean => v <= -2
161
+
162
+ /** The atlas index behind a raw cell (garbage in, garbage out — guard with isRawCell). */
163
+ export const rawIndex = (v: number): number => -v - 2
164
+
165
+ /** The tile a part draws in a cell — indexed ACROSS the run ('h' by row, 'v' by column) so one
166
+ * element covers a lane each and repeats along the run; without an axis it is a plain block. */
167
+ export const partTile = (part: TilePart, n: number, lx: number, ly: number): number | undefined => {
168
+ const tiles = part.tiles
169
+ if (typeof tiles === "number") return tiles >= 0 ? tiles : undefined
170
+ if (!Array.isArray(tiles)) return undefined
171
+ const t = tiles[part.axis === "h" ? ly : part.axis === "v" ? lx : ly * n + lx]
172
+ return typeof t === "number" && t >= 0 ? t : undefined
173
+ }
174
+
175
+ /** `fill` as weighted BLOCKS of `size`² indices (row-major). Size 1 keeps the scalar/scatter
176
+ * reading; with size > 1 a flat number array is one positional block, an array of arrays is the
177
+ * variant list, and a bare number fills the whole block. Missing entries read as -1. */
178
+ export const fillBlocks = (fill: PatchMaterial["fill"], size = 1): [number[], number][] => {
179
+ const n = Math.max(1, Math.round(size))
180
+ const area = n * n
181
+ const pad = (b: readonly number[]): number[] =>
182
+ Array.from({ length: area }, (_, i) => (typeof b[i] === "number" ? b[i]! : -1))
183
+ if (typeof fill === "number") return [ [ new Array<number>(area).fill(fill), 1 ] ]
184
+ if (!Array.isArray(fill) || fill.length === 0) return []
185
+ if (n === 1) return normalizeFill(fill as FillEntry[]).map(([ i, w ]) => [ [ i ], w ])
186
+ if (!Array.isArray(fill[0])) return [ [ pad(fill as number[]), 1 ] ]
187
+ return (fill as BlockEntry[]).map((e): [number[], number] =>
188
+ Array.isArray(e[0]) ? [ pad(e[0] as number[]), (e as [FillBlock, number])[1] ?? 1 ] : [ pad(e as number[]), 1 ])
189
+ }
190
+
191
+ /** Deterministic per-cell hash in [0, 1) — scatter must not reshuffle when nearby cells change. */
192
+ const hash01 = (x: number, y: number, seed: number): number => {
193
+ let h = (Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(seed, 2246822519)) | 0
194
+ h = Math.imul(h ^ (h >>> 13), 1274126177)
195
+ h ^= h >>> 16
196
+ return (h >>> 0) / 4294967296
197
+ }
198
+
199
+ /** The fill tile for block (bc, br) at block-local (lx, ly). Scatter rolls once per BLOCK, so a
200
+ * multi-cell pattern never tears; at size 1 the block IS the cell (identical to before). */
201
+ const pickFill = (
202
+ mat: PatchMaterial, bc: number, br: number, seed: number, n: number, lx: number, ly: number,
203
+ ): number => {
204
+ const variants = fillBlocks(mat.fill, n)
205
+ if (variants.length === 0) return -1
206
+ const at = ly * n + lx
207
+ if (variants.length === 1) return variants[0]![0][at] ?? -1
208
+ const total = variants.reduce((s, v) => s + v[1], 0)
209
+ let roll = hash01(bc, br, seed + mat.id * 7919) * total
210
+ for (const [ block, w ] of variants) {
211
+ roll -= w
212
+ if (roll < 0) return block[at] ?? -1
213
+ }
214
+ return variants[variants.length - 1]![0][at] ?? -1
215
+ }
216
+
217
+ // corner order [ne, se, sw, nw] as (dx, dy) pairs; edge order [n, e, s, w]
218
+ const EDGE_D: [number, number][] = [ [ 0, -1 ], [ 1, 0 ], [ 0, 1 ], [ -1, 0 ] ]
219
+ const CORNER_D: [number, number][] = [ [ 1, -1 ], [ 1, 1 ], [ -1, 1 ], [ -1, -1 ] ]
220
+
221
+ // ---- the deriver ---------------------------------------------------------------
222
+
223
+ /**
224
+ * Derive display atlas indices from a material-id grid (row-major, row 0 = top, -1 = empty).
225
+ * Values that match no material id pass through unchanged (so converting maps degrades
226
+ * gracefully); -1 stays -1. Deterministic: same inputs + seed → same output on every platform.
227
+ */
228
+ export const deriveCells = (
229
+ def: TilesetDef, cols: number, rows: number, cells: ArrayLike<number>, seed = 0,
230
+ ): Int32Array => {
231
+ const byId = new Map<number, { name: string, mat: TileMaterial }>()
232
+ const parts = new Map<number, { part: TilePart, mat: TileMaterial, owner: number }>()
233
+ for (const [ name, mat ] of Object.entries(def.materials ?? {})) {
234
+ if (!byId.has(mat.id)) byId.set(mat.id, { name, mat })
235
+ for (const part of Object.values(mat.parts ?? {})) {
236
+ if (!parts.has(part.id)) parts.set(part.id, { part, mat, owner: mat.id })
237
+ }
238
+ }
239
+ // a part's cells read as their PARENT everywhere connectivity is asked — the run continues
240
+ // straight through a crossing or a gate; only the drawing differs
241
+ const ownerOf = (v: number): number => parts.get(v)?.owner ?? v
242
+
243
+ const out = new Int32Array(cols * rows)
244
+ const at = (c: number, r: number, self: number): number =>
245
+ (c < 0 || r < 0 || c >= cols || r >= rows) ? self : ownerOf(cells[r * cols + c] as number)
246
+ /** A block belongs to `self` when ANY of its cells does — so a block you painted one cell at a
247
+ * time still reads as part of the run (and off-map keeps counting as same). */
248
+ const blockAt = (ax: number, ay: number, n: number, self: number): number => {
249
+ if (n === 1) return at(ax, ay, self)
250
+ for (let y = ay; y < ay + n; y++) {
251
+ for (let x = ax; x < ax + n; x++) if (at(x, y, self) === self) return self
252
+ }
253
+ return at(ax, ay, self)
254
+ }
255
+
256
+ for (let r = 0; r < rows; r++) {
257
+ for (let c = 0; c < cols; c++) {
258
+ const raw = cells[r * cols + c] as number
259
+ // a raw cell draws its atlas tile verbatim — hand-placed art inside a material map
260
+ if (isRawCell(raw)) { out[r * cols + c] = rawIndex(raw); continue }
261
+ const v = raw >= 0 ? ownerOf(raw) : raw
262
+ const entry = v >= 0 ? byId.get(v) : undefined
263
+ if (!entry) { out[r * cols + c] = raw; continue }
264
+ const hit = parts.get(raw)
265
+ if (hit) {
266
+ const n = blockSize(entry.mat)
267
+ const t = partTile(hit.part, n, c - Math.floor(c / n) * n, r - Math.floor(r / n) * n)
268
+ // an unassigned part still draws its material, so a half-authored element never blanks out
269
+ if (t !== undefined) { out[r * cols + c] = t; continue }
270
+ }
271
+ out[r * cols + c] = entry.mat.kind === "patch"
272
+ ? derivePatch(entry.mat, byId, blockAt, c, r, v, seed)
273
+ : derivePath(entry.mat, blockAt, c, r, v)
274
+ }
275
+ }
276
+ return out
277
+ }
278
+
279
+ /** Convex corners as [cardA, cardB, cornerIndex]: n+e → ne(0), e+s → se(1), s+w → sw(2), w+n → nw(3). */
280
+ const CORNER_PAIRS: [number, number, number][] = [ [ 0, 1, 0 ], [ 1, 2, 1 ], [ 2, 3, 2 ], [ 3, 0, 3 ] ]
281
+
282
+ /** Where each concave corner shows inside a block: [localX, localY, cardA, cardB], in [ne, se, sw, nw]. */
283
+ const INNER_AT = (n: number): [number, number, number, number][] => [
284
+ [ n - 1, 0, 0, 1 ], [ n - 1, n - 1, 1, 2 ], [ 0, n - 1, 2, 3 ], [ 0, 0, 3, 0 ],
285
+ ]
286
+
287
+ /** Samples a whole BLOCK: (anchorX, anchorY, size, self) → the material that block belongs to. */
288
+ type BlockAt = (ax: number, ay: number, n: number, self: number) => number
289
+
290
+ const derivePatch = (
291
+ mat: PatchMaterial, byId: Map<number, { name: string, mat: TileMaterial }>,
292
+ at: BlockAt, c: number, r: number, v: number, seed: number,
293
+ ): number => {
294
+ // With `size` > 1 the material lives on a block lattice anchored at the map origin: neighbors are
295
+ // sampled one BLOCK away (at that block's anchor cell) and the tile is placed by the cell's
296
+ // position INSIDE the block. n = 1 makes every block one cell — the original per-cell rules.
297
+ const n = blockSize(mat)
298
+ const bc = Math.floor(c / n), br = Math.floor(r / n)
299
+ const lx = c - bc * n, ly = r - br * n
300
+ const ax = bc * n, ay = br * n
301
+ const same = (dx: number, dy: number): boolean => at(ax + dx * n, ay + dy * n, n, v) === v
302
+ // cardinal + diagonal same-bits (diagonals only matter where both adjacent cardinals are same)
303
+ const card = EDGE_D.map(([ dx, dy ]) => same(dx, dy)) // [n, e, s, w]
304
+ const diag = CORNER_D.map(([ dx, dy ]) => same(dx, dy)) // [ne, se, sw, nw]
305
+ const fill = (): number => pickFill(mat, bc, br, seed, n, lx, ly)
306
+
307
+ const slots = pickSlots(mat, byId, at, ax, ay, v, n)
308
+ if (!slots) return fill()
309
+ // a negative value inside a slot array means "unassigned" (the editor writes partial sets)
310
+ const pick = (t: number | undefined): number | undefined => (t !== undefined && t >= 0 ? t : undefined)
311
+ const outer = (i: number): number | undefined => pick(slots.outer?.[i])
312
+ const edge = (d: number): number | undefined => {
313
+ const s = slots.edges?.[d]
314
+ if (typeof s === "number") return pick(s)
315
+ return Array.isArray(s) ? pick(s[d === 0 || d === 2 ? lx : ly]) : undefined
316
+ }
317
+
318
+ // sides of THIS cell that sit on a block border facing another material
319
+ const on = [
320
+ !card[0]! && ly === 0, !card[1]! && lx === n - 1, !card[2]! && ly === n - 1, !card[3]! && lx === 0,
321
+ ]
322
+ for (const [ a, b, i ] of CORNER_PAIRS) {
323
+ if (on[a]! && on[b]!) { const t = outer(i); if (t !== undefined) return t }
324
+ }
325
+ for (let d = 0; d < 4; d++) if (on[d]!) { const t = edge(d); if (t !== undefined) return t }
326
+ if (on.some(Boolean)) return fill()
327
+
328
+ // block interior — a differing relevant diagonal is a concave (inner) corner, drawn in the one
329
+ // cell of the block that touches it
330
+ const inner = INNER_AT(n)
331
+ for (let i = 0; i < 4; i++) {
332
+ const [ px, py, a, b ] = inner[i]!
333
+ if (lx === px && ly === py && card[a]! && card[b]! && !diag[i]!) return pick(slots.inner?.[i]) ?? fill()
334
+ }
335
+ return fill()
336
+ }
337
+
338
+ /** The transition set for this block: keyed by the dominant differing neighbor's material name,
339
+ * '*' as the fallback (empty and unknown neighbors always land on '*'). */
340
+ const pickSlots = (
341
+ mat: PatchMaterial, byId: Map<number, { name: string, mat: TileMaterial }>,
342
+ at: BlockAt, ax: number, ay: number, v: number, n: number,
343
+ ): PatchSlots | undefined => {
344
+ const over = mat.over
345
+ if (!over) return undefined
346
+ const counts = new Map<number, number>()
347
+ for (const [ dx, dy ] of [ ...EDGE_D, ...CORNER_D ]) {
348
+ const nv = at(ax + dx * n, ay + dy * n, n, v)
349
+ if (nv !== v) counts.set(nv, (counts.get(nv) ?? 0) + 1)
350
+ }
351
+ let bestId: number | null = null, best = 0
352
+ for (const [ id, n ] of counts) {
353
+ if (n > best || (n === best && bestId !== null && id < bestId)) { best = n; bestId = id }
354
+ }
355
+ const name = bestId !== null && bestId >= 0 ? byId.get(bestId)?.name : undefined
356
+ return (name !== undefined ? over[name] : undefined) ?? over["*"]
357
+ }
358
+
359
+ const derivePath = (
360
+ mat: PathMaterial, at: BlockAt, c: number, r: number, v: number,
361
+ ): number => {
362
+ // `size` makes the line that many cells ACROSS: connectivity is block to block and every piece
363
+ // is a size² block placed by the cell's position inside it (size 1 = one tile per piece).
364
+ const n = blockSize(mat)
365
+ const bc = Math.floor(c / n), br = Math.floor(r / n)
366
+ const lx = c - bc * n, ly = r - br * n
367
+ const ax = bc * n, ay = br * n
368
+ const conn = EDGE_D.map(([ dx, dy ]) => at(ax + dx * n, ay + dy * n, n, v) === v) // [n, e, s, w]
369
+ const links = conn.filter(Boolean).length
370
+ // a negative value inside a piece means "unassigned" (the editor writes partial sets)
371
+ const pick = (b: TileBlock | undefined): number | undefined => blockTile(b, n, lx, ly)
372
+ const any = (): number =>
373
+ pick(mat.isolated) ?? pick(mat.cross) ?? pick(mat.straights?.[1]) ?? pick(mat.straights?.[0])
374
+ ?? mat.ends?.map((b) => pick(b)).find((t) => t !== undefined)
375
+ ?? pick(mat.corners?.[0]) ?? pick(mat.tees?.[0]) ?? v
376
+
377
+ if (links === 0) return pick(mat.isolated) ?? any()
378
+ if (links === 1) {
379
+ const d = conn.findIndex(Boolean)
380
+ return pick(mat.ends?.[d]) ?? pick(mat.straights?.[d % 2 === 0 ? 0 : 1]) ?? any()
381
+ }
382
+ if (links === 2) {
383
+ if (conn[0] && conn[2]) return pick(mat.straights?.[0]) ?? any()
384
+ if (conn[1] && conn[3]) return pick(mat.straights?.[1]) ?? any()
385
+ // elbows: n+e → ne(0), e+s → se(1), s+w → sw(2), w+n → nw(3)
386
+ const corner = conn[0] && conn[1] ? 0 : conn[1] && conn[2] ? 1 : conn[2] && conn[3] ? 2 : 3
387
+ return pick(mat.corners?.[corner]) ?? pick(mat.cross) ?? any()
388
+ }
389
+ if (links === 3) {
390
+ const missing = conn.findIndex((s) => !s)
391
+ return pick(mat.tees?.[missing]) ?? pick(mat.cross) ?? pick(mat.straights?.[missing % 2 === 0 ? 1 : 0]) ?? any()
392
+ }
393
+ return pick(mat.cross) ?? any()
394
+ }
@@ -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
+ }