littlejsengine 1.19.3 → 1.21.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/FAQ.md +841 -820
  2. package/README.md +29 -25
  3. package/dist/littlejs.d.ts +2857 -955
  4. package/dist/littlejs.esm.js +33428 -20995
  5. package/dist/littlejs.esm.min.js +4 -3
  6. package/dist/littlejs.js +33386 -20996
  7. package/dist/littlejs.min.js +2 -2
  8. package/dist/littlejs.release.js +27841 -20422
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +28 -24
  11. package/plugins/box2d.js +2516 -2073
  12. package/plugins/drawUtilities.js +206 -31
  13. package/plugins/gltf.js +786 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -332
  16. package/plugins/math3d.js +148 -27
  17. package/plugins/medalSystem.js +326 -280
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +25 -0
  21. package/plugins/postProcess.js +96 -24
  22. package/plugins/render3d.js +2067 -1632
  23. package/plugins/render3dDebug.js +110 -0
  24. package/plugins/render3dEditor.js +1812 -0
  25. package/plugins/render3dExtras.js +1948 -0
  26. package/plugins/render3dLevel.js +189 -0
  27. package/plugins/render3dVoxels.js +514 -0
  28. package/plugins/sceneSystem.js +58 -0
  29. package/plugins/textureSheet.js +496 -458
  30. package/plugins/threejs.js +183 -163
  31. package/plugins/tweakables.js +411 -0
  32. package/plugins/tweenSystem.js +265 -145
  33. package/plugins/uiSystem.js +2024 -1687
  34. package/plugins/zzfxm.js +159 -159
  35. package/src/engine.js +378 -97
  36. package/src/engineAudio.js +231 -127
  37. package/src/engineBuild.mjs +292 -256
  38. package/src/engineDebug.js +967 -743
  39. package/src/engineDraw.js +391 -198
  40. package/src/engineEditor.js +2452 -0
  41. package/src/engineExport.js +18 -0
  42. package/src/engineInput.js +1466 -1293
  43. package/src/engineLogo.js +19 -0
  44. package/src/engineMath.js +1274 -1252
  45. package/src/engineObject.js +197 -82
  46. package/src/engineParticles.js +583 -532
  47. package/src/engineRelease.js +79 -45
  48. package/src/engineSettings.js +115 -29
  49. package/src/engineTileLayer.js +995 -741
  50. package/src/engineUtilities.js +19 -13
  51. package/src/engineWebGL.js +239 -82
  52. package/src/jsconfig.json +17 -13
  53. package/plugins/box2d.wasm.js +0 -630
  54. package/plugins/box2d.wasm.wasm +0 -0
@@ -0,0 +1,2452 @@
1
+ /**
2
+ * LittleJS Level Editor
3
+ * - Paint the game's tile layers while it is paused, then keep playing with the changes
4
+ * - Press 0 while the debug overlay is open to edit, 0 again to play, or call levelEditor.open() and close()
5
+ * - Edits the Tiled map the game loaded, saves it back as Tiled JSON, and autosaves every change
6
+ * - Debug builds only, the release build has stubs for its names in engineRelease.js and none of its code
7
+ * @namespace Editor
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ ///////////////////////////////////////////////////////////////////////////////
13
+ // the level editor
14
+
15
+ /**
16
+ * @callback EditorPlayFromCallback - Puts the player at a world position, for the level editor's Play from mouse
17
+ * @param {Vector2} pos - Where to start playing
18
+ * @memberof Editor
19
+ */
20
+
21
+ /**
22
+ * @callback EditorRestartCallback - Rebuilds the level from the map the level editor changed
23
+ * @memberof Editor
24
+ */
25
+
26
+ /**
27
+ * @callback EditorTileCallback - What the game does when the level editor paints a tile
28
+ * @param {TileLayer} layer - The layer painted
29
+ * @param {Vector2} pos - The cell's position in the layer
30
+ * @param {number|undefined} tile - The tile painted, undefined when erased
31
+ * @memberof Editor
32
+ */
33
+
34
+ /**
35
+ * The level editor, open it to pause the game and edit its level, close it to play on with the changes
36
+ * - One of it, levelEditor, 0 on the debug overlay opens and closes it too
37
+ * - In release builds levelEditor is a stub that never opens, and its hooks are never called
38
+ * @memberof Editor
39
+ * @example
40
+ * levelEditor.onRestart = ()=> loadLevel(); // adds a Restart button that rebuilds the level
41
+ * levelEditor.onTile = (layer, pos, tile)=> layer.setCollisionData(pos, tile === ladderTile ? -1 : tile ? 1 : 0);
42
+ */
43
+ class LevelEditor
44
+ {
45
+ constructor()
46
+ {
47
+ /** @property {EditorTileCallback|undefined} - What the game does when the editor paints a tile, like
48
+ * setting its collision or its look the way the game does when it loads the level; without one, the
49
+ * collision layer gets collision 1 where there is a tile
50
+ * @type {EditorTileCallback|undefined} */
51
+ this.onTile = undefined;
52
+ /** @property {Function|undefined} - Rebuild the level from the map the editor changed, a Restart button
53
+ * calls it after switching to play; without one there is no Restart button
54
+ * @type {EditorRestartCallback|undefined} */
55
+ this.onRestart = undefined;
56
+ /** @property {Function|undefined} - Put the player at a world position; with it, the editor's Advanced
57
+ * section has Play from mouse, which starts play there, Escape at the mouse and Play at the view center
58
+ * @type {EditorPlayFromCallback|undefined} */
59
+ this.onPlayFrom = undefined;
60
+ /** @property {Array<number>|undefined} - The tiles the palette shows, in its order, for a sheet that also holds
61
+ * sprites and art that are not level tiles; undefined shows every tile of the sheet
62
+ * @type {Array<number>|undefined} */
63
+ this.paletteTiles = undefined;
64
+ /** @property {boolean|undefined} - Which level editor opens: true the 3D one, false the 2D one, undefined the
65
+ * 3D one when a level was loaded with level3DLoad and there is a Render3DPlugin
66
+ * @type {boolean|undefined} */
67
+ this.use3D = undefined;
68
+ }
69
+
70
+ /** True while the editor is open, the game is paused under it
71
+ * @return {boolean} */
72
+ get isOpen() { return editorIsOpen; }
73
+
74
+ /** Open the editor, pausing the game; until close(), Escape (the debug key) switches between playing and editing */
75
+ open()
76
+ {
77
+ if (!editorSession)
78
+ editorCameraScale = cameraScale; // a new session starts at the game's zoom, a return keeps the editor's
79
+ editorSession = true;
80
+ editorSetOpen(true);
81
+ }
82
+
83
+ /** Close the editor and end its session, the game carries on with the changes and Escape opens the debug
84
+ * overlay again */
85
+ close()
86
+ {
87
+ editorSession = false;
88
+ editorSetOpen(false);
89
+ }
90
+ }
91
+
92
+ /** The level editor, levelEditor.open() to edit the level, levelEditor.close() to play on with the changes
93
+ * @type {LevelEditor}
94
+ * @memberof Editor */
95
+ const levelEditor = new LevelEditor;
96
+
97
+ // if the editor is open, and if it was opened and not exited, while Escape switches between playing and editing
98
+ let editorIsOpen = false, editorSession = false;
99
+
100
+ // if switching to play puts the player at the mouse, and if the panel's Advanced section is shown
101
+ let editorPlayFromMouse = false, editorAdvanced = false;
102
+
103
+ // switch to play in the session, with the player at a position when Play from mouse is on and the game has a hook
104
+ function editorPlay(pos)
105
+ {
106
+ editorSetOpen(false);
107
+ editorPlayFromMouse && pos && levelEditor.onPlayFrom?.(pos.copy());
108
+ }
109
+
110
+ // the game's pause and camera from before the editor opened, handed back when it closes
111
+ let editorGameState;
112
+
113
+ // the editor's own view, so the game's camera is left where the game had it
114
+ let editorCameraPos = vec2(), editorCameraScale = 32;
115
+
116
+ // the map being edited, the selected object layer's or tile layer's, a level of objects alone has no tile layer
117
+ const editorRecord = ()=> (editorObjectLayer ?? editorLayer)?.record;
118
+
119
+ // the layer being painted, the cell under the mouse, the last cell of a stroke, the last cell a stamp was
120
+ // placed on, for a Shift line
121
+ let editorLayer, editorHover, editorLastCell, editorLastPlaced;
122
+
123
+ // the selected area, cells of the layer being edited from min to max, the right button's press while it is held,
124
+ // and a left press that only cleared a selection, which paints nothing until let go
125
+ let editorSelection, editorRightPress, editorLeftSpent;
126
+
127
+ // a left drag moving the selection: the layer, the cell pressed and how far it moved, the area and its tiles as
128
+ // they were, and with All Layers the ids of the objects in it, by object layer
129
+ let editorSelectionDrag;
130
+
131
+ // if the mouse is over the panel, and where it was last on the level, which is where the editor reads it while it is
132
+ // over the panel, so a drag held across it stops at its edge and paints nothing behind it
133
+ let editorMouseOnPanel = false, editorMouseScreen = vec2();
134
+
135
+ // if selection edits, Delete, Ctrl+C and Ctrl+X, act on every tile layer of the edited layer's map
136
+ let editorAllLayers = false;
137
+
138
+ // the grid drawn over the layer, G, and the list of keys in the panel, ?
139
+ let editorGrid = true, editorHelp = false;
140
+
141
+ // open or close the editor, the game is paused while it is open and carries on with the changes after
142
+ function editorSetOpen(open)
143
+ {
144
+ if (!debug || editorIsOpen === !!open) return;
145
+ editorIsOpen = !!open;
146
+ if (editorIsOpen)
147
+ {
148
+ editorGameState = {paused, cameraPos: cameraPos.copy(), cameraScale, cameraAngle};
149
+ editorCameraPos = cameraPos.copy(); // where the game is, the zoom stays the editor's
150
+ setPaused(true);
151
+ setDebugOverlay(false); // out of the way of the level
152
+
153
+ // the layer edited last, when it is still there, or the collision layer
154
+ const layers = editorLayers();
155
+ layers.includes(editorLayer) || (editorLayer = layers.find((layer)=> layer.live?.isSolid) ??
156
+ layers.filter((layer)=> !layer.isObjects).at(-1));
157
+ layers.includes(editorObjectLayer) || (editorObjectLayer = editorLayer ? undefined :
158
+ layers.find((layer)=> layer.isObjects)); // a level of objects alone
159
+ }
160
+ else
161
+ {
162
+ editorStrokeEnd();
163
+ editorSelection = editorSelectionDrag = editorRightPress = editorObjectDrag = editorObjectBox = undefined;
164
+ editorObjectSelection.clear();
165
+ editorMouseOnPanel = false; // the panel hides, with no mouseleave
166
+ const state = editorGameState;
167
+ setPaused(state.paused);
168
+ setCameraPos(state.cameraPos);
169
+ setCameraScale(state.cameraScale);
170
+ setCameraAngle(state.cameraAngle);
171
+ }
172
+ }
173
+
174
+ // the editor's view, applied before it reads the mouse and before each render, since a game may move the
175
+ // camera from gameUpdatePost, which runs while paused
176
+ function editorApplyCamera()
177
+ {
178
+ setCameraPos(editorCameraPos);
179
+ setCameraScale(editorCameraScale);
180
+ setCameraAngle(0); // painting is on the grid as the level is laid out
181
+ }
182
+
183
+ ///////////////////////////////////////////////////////////////////////////////
184
+ // tiles as Tiled gids
185
+
186
+ // a tile, its quarter turns and mirror as a Tiled gid, its flips in the top bits as tileLayersLoad reads them,
187
+ // 0 for an empty cell; unsigned, as Tiled writes it
188
+ function editorTileToGid(tile, direction=0, mirror=false)
189
+ {
190
+ if (tile === undefined) return 0;
191
+ const flips = tileLayersTiledFlips.findIndex(([d, m])=> d === direction && !!m === !!mirror);
192
+ return ((flips << 29) | (tile + 1)) >>> 0;
193
+ }
194
+
195
+ // a gid back to a tile, its quarter turns and mirror, undefined for an empty cell
196
+ function editorGidToTile(gid)
197
+ {
198
+ if (!gid) return;
199
+ const [direction, mirror] = tileLayersTiledFlips[gid >>> 29];
200
+ return {tile: (gid & 0x0fffffff) - 1, direction, mirror: !!mirror};
201
+ }
202
+
203
+ ///////////////////////////////////////////////////////////////////////////////
204
+ // maps
205
+
206
+ // every map the game loaded, and one for each layer it made in code; the editor changes a map's data in place,
207
+ // so a game that loads the same map object again, as a restart does, gets the changes
208
+ const editorMapList = [];
209
+
210
+
211
+ // the tile layers of a map's Tiled layers in the order tileLayersLoad makes them, groups flattened
212
+ function editorTileLayers(layers, list=[])
213
+ {
214
+ for (const layer of layers)
215
+ {
216
+ if (layer.type === 'group')
217
+ editorTileLayers(layer.layers || [], list);
218
+ else if (!layer.type || layer.type === 'tilelayer')
219
+ list.push(layer);
220
+ }
221
+ return list;
222
+ }
223
+
224
+ // the tile layers' data arrays of a map, in the same order
225
+ const editorTileLayerData = (layers)=> editorTileLayers(layers).map((layer)=> layer.data);
226
+
227
+ // called by tileLayersLoad before it reads a map; a map it has not seen gets a record, and its autosave is copied
228
+ // in when the file is the one it was made over; a file that changed since keeps the autosave pending for the
229
+ // panel to apply or drop, and one that already has the autosaved data, saved from the editor, drops it
230
+ function editorMapRestore(map)
231
+ {
232
+ if (editorMapList.some((record)=> record.map === map)) return map; // loaded again, it has the changes
233
+
234
+ // a map object seen before, its record retired when its layers went, gets it back as it was, its data has the
235
+ // changes already
236
+ const retired = editorRetiredMaps.get(map);
237
+ if (retired)
238
+ {
239
+ editorRetire((record)=> record.key === retired.key);
240
+ editorMapList.push(retired);
241
+ return map;
242
+ }
243
+
244
+ // a map it can not read as plain gids, an infinite one or one with typed arrays, is left to tileLayersLoad,
245
+ // which asserts on what it can not load, and a layer made from it gets a map of its own when edited
246
+ const data = map.layers ? editorTileLayerData(map.layers) : [];
247
+ if (!map.layers || data.some((layer)=> !Array.isArray(layer))) return map;
248
+ const objects = editorObjectGroups(map.layers).map((group)=> group.objects ?? []);
249
+ const url = editorFetchedURLs.get(map), hash = editorMapHash(data, objects, editorMapLayout(map));
250
+ const fileName = url?.split(/[?#]/)[0].split('/').pop() || 'level.json';
251
+ const record = {map, url, fileName, key: editorMapKey(map, url, hash), hash,
252
+ original: data.map((layer)=> [...layer]), originalObjects: editorObjectsCopy(objects),
253
+ originalSize: {width: map.width, height: map.height}, layers: []};
254
+
255
+ // a new copy of a map takes over from the one before, and maps whose layers are all gone step aside
256
+ editorRetire((other)=> other.key === record.key || editorObjectsGone(other) ||
257
+ other.layers.length && other.layers.every((layer)=> layer.live.destroyed));
258
+ editorMapList.push(record);
259
+
260
+ const saved = editorSaves()[record.key];
261
+ if (!saved) return map;
262
+ // an autosave from before the map's shape was in the hash matches on its tiles and objects, and one from before
263
+ // objects were matches on its tiles; one of a resized map is resized to first
264
+ const sameFile = saved.hash === record.hash || saved.savedHash === record.hash || !saved.width &&
265
+ (saved.hash === editorMapHash(data, objects) || !saved.objects && saved.hash === editorMapHash(data));
266
+ if ((saved.width ?? map.width) === map.width && (saved.height ?? map.height) === map.height &&
267
+ editorSameData(saved.layers, data) && editorSameData(saved.objects ?? [], objects))
268
+ editorDiscardPending(record);
269
+ else if (!sameFile || !editorResizeMap(map, saved.width ?? map.width, saved.height ?? map.height) ||
270
+ !editorCopyData(data, saved.layers))
271
+ {
272
+ record.pending = saved;
273
+ console.warn(`LittleJS editor: ${record.fileName} changed since its autosaved edits, ` +
274
+ 'open the editor (Esc then 0) to apply or drop them');
275
+ }
276
+ else
277
+ {
278
+ editorRestoreObjects(map, saved);
279
+ console.warn(`LittleJS editor: brought back unsaved edits to ${record.fileName}, ` +
280
+ 'Save in the editor (Esc then 0) writes them to the file');
281
+ }
282
+ return map;
283
+ }
284
+
285
+ // the records of maps the game moved on from, by map object, for a map it loads again
286
+ const editorRetiredMaps = new WeakMap;
287
+
288
+ // take records out of the list, with the undo that would change them, so an undo never writes a map
289
+ // the game no longer shows; a map object loaded again gets its record back
290
+ function editorRetire(isRetired)
291
+ {
292
+ const gone = editorMapList.filter(isRetired);
293
+ if (!gone.length) return;
294
+ for (const record of gone)
295
+ {
296
+ editorMapList.splice(editorMapList.indexOf(record), 1);
297
+ record.synthetic || editorRetiredMaps.set(record.map, record);
298
+ }
299
+ for (const list of [editorUndoList, editorRedoList])
300
+ for (let i = list.length; i--;)
301
+ list[i].some((entry)=> gone.includes(editorEntryRecord(entry))) && list.splice(i, 1);
302
+ if (gone.includes(editorLayer?.record))
303
+ editorLayer = editorSelection = editorSelectionDrag = undefined; // the selection was an area of that layer
304
+ if (gone.includes(editorObjectLayer?.record))
305
+ editorObjectLayer = undefined;
306
+ }
307
+
308
+ // called by tileLayersLoad with the layers it made, a map loaded again keeps its layer records, with the new layers
309
+ function editorMapLoaded(map, tileLayers, flatLayers)
310
+ {
311
+ const record = editorMapList.find((record)=> record.map === map);
312
+ record && tileLayers.forEach((live, i)=>
313
+ {
314
+ const {dataLayer: source, color} = flatLayers[i];
315
+ const layer = record.layers.find((layer)=> layer.source === source);
316
+ layer ? Object.assign(layer, {live, color}) : record.layers.push({record, source, live, color});
317
+ });
318
+ }
319
+
320
+ // the editor's record of a live layer; a layer the game made in code gets a map of its own, from its tiles now
321
+ function editorLayerRecord(live)
322
+ {
323
+ for (const record of editorMapList)
324
+ for (const layer of record.layers)
325
+ if (layer.live === live) return layer;
326
+
327
+ const {x: width, y: height} = live.size, size = live.tileInfo?.size ?? vec2(16);
328
+ const data = [];
329
+ for (let y = height; y--;)
330
+ for (let x = 0; x < width; ++x)
331
+ {
332
+ const d = live.getData(vec2(x, y));
333
+ data.push(editorTileToGid(d.tile, d.direction, d.mirror));
334
+ }
335
+ const source = {type: 'tilelayer', id: 1, name: 'Tile Layer 1', x: 0, y: 0, width, height,
336
+ opacity: 1, visible: true, data};
337
+ const map = {type: 'map', version: '1.10', orientation: 'orthogonal', renderorder: 'right-down', infinite: false,
338
+ width, height, tilewidth: size.x, tileheight: size.y, nextlayerid: 2, nextobjectid: 1,
339
+ tilesets: editorTilesets(live), layers: [source]};
340
+ const record = {map, synthetic: true, fileName: 'level.json', layers: []};
341
+ const layer = {record, source, live, color: WHITE};
342
+ record.layers.push(layer);
343
+ editorMapList.push(record);
344
+ return layer;
345
+ }
346
+
347
+ // a Tiled tileset for a layer made in code, from its image
348
+ function editorTilesets(live)
349
+ {
350
+ const tileInfo = live.tileInfo, image = tileInfo?.textureInfo?.image;
351
+ if (!image) return [];
352
+ const {x: tilewidth, y: tileheight} = tileInfo.size, padding = tileInfo.padding;
353
+ const columns = tileInfo.columns || floor(image.width / (tilewidth + padding*2));
354
+ const rows = floor(image.height / (tileheight + padding*2));
355
+ const file = image.src ? image.src.split('/').pop() : 'tiles.png';
356
+ return [{firstgid: 1, name: file.replace(/\.\w+$/, ''), image: file, imagewidth: image.width,
357
+ imageheight: image.height, tilewidth, tileheight, margin: padding, spacing: padding*2, columns,
358
+ tilecount: columns * rows}];
359
+ }
360
+
361
+ // the file each json fetchJSON loaded came from, its name for saving and autosaving
362
+ const editorFetchedURLs = new WeakMap;
363
+ function editorJSONFetched(url, json)
364
+ { json && typeof json === 'object' && editorFetchedURLs.set(json, String(url)); }
365
+
366
+ // a map as Tiled JSON, everything it was loaded with kept, the tile data as the editor left it
367
+ function editorMapJSON(record) { return JSON.stringify(record.map); }
368
+
369
+ // the files picked to save maps to, kept across reloads in IndexedDB by page and map, which is where a browser
370
+ // lets a page keep them; each resolves to undefined when the store can not be used
371
+ let editorFileStore =
372
+ {
373
+ get: (key)=> editorFileStoreRequest('readonly', (store)=> store.get(key)),
374
+ set: (key, handle)=> editorFileStoreRequest('readwrite', (store)=> handle ? store.put(handle, key) : store.delete(key)),
375
+ };
376
+
377
+ // a request on the editor's file store, resolves to its result, or undefined if anything failed
378
+ function editorFileStoreRequest(mode, request)
379
+ {
380
+ return new Promise((resolve)=>
381
+ {
382
+ try
383
+ {
384
+ const open = indexedDB.open('LittleJS editor', 1);
385
+ open.onupgradeneeded = ()=> open.result.createObjectStore('files');
386
+ open.onerror = ()=> resolve(undefined);
387
+ open.onsuccess = ()=>
388
+ {
389
+ const db = open.result, done = (result)=> { db.close(); resolve(result); };
390
+ try
391
+ {
392
+ const r = request(db.transaction('files', mode).objectStore('files'));
393
+ r.onsuccess = ()=> done(r.result);
394
+ r.onerror = ()=> done(undefined);
395
+ }
396
+ catch { done(undefined); }
397
+ };
398
+ }
399
+ catch { resolve(undefined); }
400
+ });
401
+ }
402
+
403
+ // the file a map was saved to on an earlier page load, when the browser gives permission to write it again
404
+ async function editorRememberedFile(record)
405
+ {
406
+ const handle = await editorFileStore.get(editorFileKey(record)), mode = {mode: 'readwrite'};
407
+ try
408
+ {
409
+ if (handle && (await handle.queryPermission(mode) === 'granted' ||
410
+ await handle.requestPermission(mode) === 'granted'))
411
+ return handle;
412
+ }
413
+ catch { } // a handle that can not ask, the picker instead
414
+ }
415
+ const editorFileKey = (record)=> (globalThis.location?.pathname ?? '') + ' ' + record.key;
416
+
417
+ // a map as a file Save wrote is the file from then on: Reset to file goes back to it, and a reload of it has nothing
418
+ // to apply; a download can not say it replaced the file, so it does not, and neither does a file of another name, a
419
+ // copy, since the game still loads the one it came from; the browser gives a picked file's name and not its folder,
420
+ // so a file of the same name may be a copy too, and the autosave stays, with the hash of the file the game loads and
421
+ // of the file written, until a reload shows which one the game loads
422
+ function editorSetBaseline(record, written)
423
+ {
424
+ if (record.synthetic) return; // a layer made in code has no file to load it from
425
+ const data = editorTileLayerData(written.layers);
426
+ const objects = editorObjectGroups(written.layers).map((group)=> group.objects ?? []);
427
+ Object.assign(record, {original: data, originalObjects: objects, savedHash: editorMapHash(data, objects,
428
+ editorMapLayout(written)), originalSize: {width: written.width, height: written.height}});
429
+ record.pending || editorAutosave(record); // edits waiting to be applied keep their autosave
430
+ }
431
+
432
+ // save a map as Tiled JSON: where the browser lets a page write files, Chrome and Edge, to a file picked once and
433
+ // written again on each Save after, even after a reload once the browser gives permission, or picked again with
434
+ // Save As; elsewhere as a download under the name of the file it came from; resolves to how it saved, undefined
435
+ // when the picker was closed
436
+ async function editorSave(record, pickAgain=false)
437
+ {
438
+ if (!record) return;
439
+ editorStrokeEnd();
440
+ const text = editorMapJSON(record), picker = /** @type {any} */ (globalThis).showSaveFilePicker;
441
+ if (picker)
442
+ {
443
+ try
444
+ {
445
+ if (!pickAgain && !record.fileHandle)
446
+ record.fileHandle = await editorRememberedFile(record);
447
+ if (pickAgain || !record.fileHandle)
448
+ {
449
+ record.fileHandle = await picker.call(globalThis, {suggestedName: record.fileName,
450
+ types: [{description: 'Tiled JSON', accept: {'application/json': ['.json']}}]});
451
+ editorFileStore.set(editorFileKey(record), record.fileHandle);
452
+ }
453
+ const writable = await record.fileHandle.createWritable();
454
+ await writable.write(text);
455
+ await writable.close();
456
+ // the browser gives the picked file's name but not its folder, so the name is what says it is the map's
457
+ if (record.fileHandle.name === record.fileName)
458
+ editorSetBaseline(record, JSON.parse(text));
459
+ return 'written';
460
+ }
461
+ catch (error)
462
+ {
463
+ if (error?.name === 'AbortError') return; // the picker was closed, nothing saved
464
+ record.fileHandle = undefined; // a file it could not write, a download instead, and not kept
465
+ editorFileStore.set(editorFileKey(record), undefined);
466
+ }
467
+ }
468
+ saveText(text, record.fileName, 'application/json');
469
+ return 'downloaded';
470
+ }
471
+
472
+ ///////////////////////////////////////////////////////////////////////////////
473
+ // autosave
474
+
475
+ // every page keeps its own autosaves, one for each map by its key
476
+ function editorSaveName() { return 'LittleJS editor ' + (globalThis.location?.pathname ?? ''); }
477
+ const editorSaves = ()=> readSaveData(editorSaveName(), {});
478
+
479
+ // if the last autosave did not fit in storage, the panel says so
480
+ let editorSaveFailed = false;
481
+
482
+ // write the autosaves, noting whether storage took them
483
+ function editorWriteSaves(saves)
484
+ {
485
+ try
486
+ {
487
+ localStorage.setItem(editorSaveName(), JSON.stringify(saves));
488
+ editorSaveFailed = false;
489
+ }
490
+ catch { editorSaveFailed = true; }
491
+ }
492
+
493
+ // a map's name for its autosave, the file it was fetched from without a query, or its size, layer names and data as loaded; a map with
494
+ // no file can not tell a changed map from another one, so each is its own
495
+ function editorMapKey(map, url, hash)
496
+ { return url?.split(/[?#]/)[0] ?? `${map.width}x${map.height} ` + map.layers.map((layer)=> layer.name).join() + ' #' + hash; }
497
+
498
+ // a quick hash of a map's tile data, objects and shape, to know when the file changed under its autosave
499
+ function editorMapHash(data, objects=[], layout='')
500
+ {
501
+ let hash = 2166136261;
502
+ for (const layer of data)
503
+ for (const gid of layer)
504
+ hash = Math.imul(hash ^ gid, 16777619);
505
+ const text = (objects.length ? JSON.stringify(objects) : '') + layout;
506
+ for (let i = 0; i < text.length; ++i)
507
+ hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
508
+ return hash >>> 0;
509
+ }
510
+
511
+ // a map's shape: its size, tile size, and its tile and object layers by name, in order, so a file whose tiles read
512
+ // the same laid out another way is a changed file
513
+ function editorMapLayout(map)
514
+ {
515
+ const names = (layers)=> layers.map((layer)=> layer.id + ':' + layer.name).join();
516
+ return `${map.width}x${map.height} ${map.tilewidth}x${map.tileheight} ` +
517
+ names(editorTileLayers(map.layers)) + ' | ' + names(editorObjectGroups(map.layers));
518
+ }
519
+
520
+ const editorSameData = (a, b)=> JSON.stringify(a) === JSON.stringify(b);
521
+
522
+ // copy saved tile data into a map's layers in place, when every layer is the size it was
523
+ function editorCopyData(data, saved)
524
+ {
525
+ if (saved.length !== data.length || saved.some((layer, i)=> layer.length !== data[i].length)) return false;
526
+ saved.forEach((layer, i)=> { for (let j = layer.length; j--;) data[i][j] = layer[j]; });
527
+ return true;
528
+ }
529
+
530
+ // remember a map's tile data and objects, or forget them when they are back to the file
531
+ function editorAutosave(record)
532
+ {
533
+ if (record.synthetic) return; // a layer made in code has no load to bring it back in, save it to a file
534
+ const saves = editorSaves(), map = record.map, data = editorTileLayerData(map.layers);
535
+ const objects = editorObjectGroups(map.layers).map((group)=> group.objects ?? []);
536
+ const original = record.originalObjects ?? [], kept = objects.slice();
537
+ while (kept.length > original.length && !kept.at(-1).length)
538
+ kept.pop(); // an Objects layer the editor made, empty again, is not an edit
539
+ const size = record.originalSize, sameSize = map.width === size.width && map.height === size.height;
540
+ // the map as it was loaded has nothing to keep; one a Save wrote is kept until a reload shows the file has it
541
+ if (sameSize && editorSameData(data, record.original) && editorSameData(kept, original) && !record.savedHash)
542
+ delete saves[record.key];
543
+ else
544
+ saves[record.key] = {hash: record.hash, savedHash: record.savedHash, width: map.width, height: map.height,
545
+ layers: data, objects, nextobjectid: map.nextobjectid};
546
+ editorWriteSaves(saves);
547
+ }
548
+
549
+ // paint every cell of a map's layers from a list of tile data, the tile layers of the map in order, and set its
550
+ // object layers' objects from a list of them; data of another size resizes the map, which needs the Restart hook
551
+ function editorPaintData(record, data, objects, width=record.map.width, height=record.map.height)
552
+ {
553
+ editorStrokeEnd();
554
+ if (width !== record.map.width || height !== record.map.height)
555
+ {
556
+ if (!levelEditor.onRestart) return;
557
+ const before = editorMapSnapshot(record);
558
+ editorSetMapSnapshot(record, {width, height, layers: data, objects});
559
+ editorMapChanged(record, before);
560
+ return;
561
+ }
562
+ const all = editorTileLayerData(record.map.layers);
563
+ editorBulkEdit(()=>
564
+ {
565
+ for (const layer of record.layers)
566
+ {
567
+ const gids = data[all.indexOf(layer.source.data)], {x: width, y: height} = layer.live.size;
568
+ if (gids?.length === width * height)
569
+ gids.forEach((gid, i)=> editorPaint(layer, vec2(i % width, height - 1 - (i / width | 0)), gid));
570
+ }
571
+ editorObjectLayers(record).forEach((layer, i)=> objects &&
572
+ editorChangeObjects(layer, (list)=> list.splice(0, list.length, ...editorObjectsCopy(objects[i] ?? []))));
573
+ });
574
+ editorStroke ? editorStrokeEnd() : editorAutosave(record);
575
+ }
576
+
577
+ // the autosaved edits of a file that changed since, applied as one undo; edits that do not fit the file now are
578
+ // kept waiting, with their autosave, until they are dropped, and it returns false
579
+ function editorApplyPending(record)
580
+ {
581
+ const saved = record?.pending;
582
+ if (!saved) return false;
583
+ if (!editorPendingFits(record, saved))
584
+ {
585
+ record.pendingUnfit = true;
586
+ console.warn(`LittleJS editor: the autosaved edits to ${record.fileName} do not fit the file now, ` +
587
+ 'they are kept until dropped');
588
+ return false;
589
+ }
590
+ record.pending = record.pendingUnfit = undefined;
591
+ editorPaintData(record, saved.layers, saved.objects, saved.width, saved.height);
592
+ return true;
593
+ }
594
+
595
+ // if autosaved edits fit a map now: as many tile layers, each the size they were saved at, which needs the Restart
596
+ // hook when the map is another size, and no objects in object layers the map no longer has
597
+ function editorPendingFits(record, saved)
598
+ {
599
+ const map = record.map, data = editorTileLayerData(map.layers), groups = editorObjectGroups(map.layers).length;
600
+ const width = saved.width ?? map.width, height = saved.height ?? map.height;
601
+ return (width === map.width && height === map.height || !!levelEditor.onRestart) &&
602
+ saved.layers?.length === data.length && saved.layers.every((layer)=> layer.length === width * height) &&
603
+ (saved.objects ?? []).every((objects, i)=> i < groups || !objects.length);
604
+ }
605
+
606
+ // drop the autosaved edits of a file that changed since
607
+ function editorDiscardPending(record)
608
+ {
609
+ if (!record) return;
610
+ record.pending = record.pendingUnfit = undefined;
611
+ const saves = editorSaves();
612
+ delete saves[record.key];
613
+ editorWriteSaves(saves);
614
+ }
615
+
616
+ // put every layer back to the file, its size too, as one undo
617
+ function editorRevert(record)
618
+ {
619
+ if (!record || record.synthetic) return; // a layer made in code has no file
620
+ record.pending = record.pendingUnfit = undefined;
621
+ const {width, height} = record.originalSize ?? record.map;
622
+ editorPaintData(record, record.original, record.originalObjects, width, height);
623
+ }
624
+
625
+ ///////////////////////////////////////////////////////////////////////////////
626
+ // resizing
627
+
628
+ // change a map's size in place, at the right and top, so the tiles and objects keep their places from the bottom
629
+ // left; a row added goes at the start of each layer's data, Tiled's rows run from the top, and objects past the new
630
+ // edges are dropped; returns false for a size it can not have
631
+ function editorResizeMap(map, width, height)
632
+ {
633
+ if (!(width >= 1 && height >= 1 && width % 1 === 0 && height % 1 === 0)) return false;
634
+ if (width === map.width && height === map.height) return true;
635
+ const oldWidth = map.width, added = height - map.height;
636
+ for (const layer of editorTileLayers(map.layers))
637
+ {
638
+ const old = [...layer.data];
639
+ layer.data.length = 0;
640
+ for (let row = 0; row < height; ++row)
641
+ for (let x = 0; x < width; ++x)
642
+ {
643
+ const oldRow = row - added;
644
+ layer.data.push(x < oldWidth && oldRow >= 0 && old[x + oldRow * oldWidth] || 0);
645
+ }
646
+ layer.width = width;
647
+ layer.height = height;
648
+ }
649
+ // an object on the map before and off it after is dropped, one that was already off it stays
650
+ const {tilewidth=1, tileheight=1} = map, oldHeight = map.height;
651
+ const inside = (object, w, h)=> object.x >= 0 && object.x < w * tilewidth && object.y > 0 && object.y <= h * tileheight;
652
+ for (const group of editorObjectGroups(map.layers))
653
+ {
654
+ group.objects &&= group.objects.filter((object)=>
655
+ {
656
+ const was = inside(object, oldWidth, oldHeight);
657
+ object.y += added * tileheight;
658
+ return !was || inside(object, width, height);
659
+ });
660
+ }
661
+ map.width = width;
662
+ map.height = height;
663
+ return true;
664
+ }
665
+
666
+ // a map's size, tiles and objects, for an undo
667
+ function editorMapSnapshot(record)
668
+ {
669
+ const map = record.map, objects = editorObjectGroups(map.layers).map((group)=> group.objects ?? []);
670
+ return editorObjectsCopy({width: map.width, height: map.height, layers: editorTileLayerData(map.layers), objects});
671
+ }
672
+
673
+ // give a map a size, tiles and objects, the game's Restart hook makes its layers again
674
+ function editorSetMapSnapshot(record, snapshot)
675
+ {
676
+ const map = record.map;
677
+ editorResizeMap(map, snapshot.width, snapshot.height);
678
+ editorCopyData(editorTileLayerData(map.layers), snapshot.layers);
679
+ editorRestoreObjects(map, snapshot);
680
+ for (const group of editorObjectGroups(map.layers).slice(snapshot.objects.length))
681
+ group.objects = []; // an Objects layer the editor made since
682
+ editorClearSelections();
683
+ }
684
+
685
+ // the selections go when the map changes size, they are cells and objects of the old one
686
+ function editorClearSelections()
687
+ {
688
+ editorSelection = editorSelectionDrag = editorLastCell = editorHover = undefined;
689
+ editorObjectSelection.clear();
690
+ }
691
+
692
+ // a map changed whole, the undo can put it back; it is autosaved, and the game makes its level again, in the editor
693
+ function editorMapChanged(record, before)
694
+ {
695
+ const stroke = [{resize: record, before, after: editorMapSnapshot(record)}];
696
+ editorUndoList.push(stroke);
697
+ editorRedoList.length = 0;
698
+ editorChanged(stroke);
699
+ }
700
+
701
+ // resize a map, as one undo; a game without the Restart hook can not make its layers again, so it can not
702
+ function editorResize(record, width, height)
703
+ {
704
+ const map = record?.map;
705
+ if (!map || record.synthetic || record.pending || !levelEditor.onRestart) return false;
706
+ if (width === map.width && height === map.height) return false;
707
+ editorStrokeEnd();
708
+ const before = editorMapSnapshot(record);
709
+ if (!editorResizeMap(map, width, height)) return false;
710
+ editorClearSelections();
711
+ editorMapChanged(record, before);
712
+ return true;
713
+ }
714
+
715
+ ///////////////////////////////////////////////////////////////////////////////
716
+ // cells and undo
717
+
718
+ // the strokes that can be undone and redone, each a list of cells with the gid before and after, and of object
719
+ // layers with their objects before and after, or a resize with the map before and after
720
+ const editorUndoList = [], editorRedoList = [];
721
+ const editorEntryRecord = (entry)=> entry.resize ?? (entry.layer ?? entry.objectLayer).record;
722
+ let editorStroke;
723
+
724
+ // during a big edit, the layers whose cell by cell redraws are held off, with the redraw each had of its own
725
+ let editorHeldRedraws;
726
+
727
+ // make a big edit, a fill, clear, undo, apply or revert, with each layer's cell by cell redraws held off, a game's
728
+ // tile callback's included; the stroke's end draws each layer it touched again whole
729
+ function editorBulkEdit(edit)
730
+ {
731
+ const held = editorHeldRedraws = new Map;
732
+ try { edit(); }
733
+ finally
734
+ {
735
+ editorHeldRedraws = undefined;
736
+ for (const [live, own] of held)
737
+ own ? live.redrawTileData = own : delete live.redrawTileData;
738
+ }
739
+ }
740
+
741
+ // set a cell to a gid, in the map and on the live layer, with the game's rules, and return the gid it had
742
+ function editorSetCell(layer, pos, gid)
743
+ {
744
+ const {source, live, color} = layer;
745
+ const index = pos.x + (live.size.y - 1 - pos.y) * live.size.x;
746
+ const before = source.data[index];
747
+ if (before === gid && (!gid || live.destroyed || live.getData(pos).tile !== undefined))
748
+ return before; // the same, unless the layer lost the tile in play, a ghost painted with its own tile
749
+ source.data[index] = gid;
750
+ if (live.destroyed) return before; // a layer the game let go of, the map still has the change
751
+
752
+ if (editorHeldRedraws && !editorHeldRedraws.has(live))
753
+ {
754
+ editorHeldRedraws.set(live, live.hasOwnProperty('redrawTileData') ? live.redrawTileData : undefined);
755
+ live.redrawTileData = ()=> {};
756
+ }
757
+ const t = editorGidToTile(gid);
758
+ live.setData(pos, t ? new TileLayerData(t.tile, t.direction, t.mirror, color) : new TileLayerData, true);
759
+ if (levelEditor.onTile)
760
+ levelEditor.onTile(live, pos.copy(), t?.tile);
761
+ else if (live instanceof TileCollisionLayer && live.isSolid)
762
+ live.setCollisionData(pos, gid ? 1 : 0); // as tileLayersLoad gives it
763
+ return before;
764
+ }
765
+
766
+ // paint a cell as part of the stroke being made
767
+ function editorPaint(layer, pos, gid)
768
+ {
769
+ const before = editorSetCell(layer, pos, gid);
770
+ if (before === gid) return;
771
+ (editorStroke ||= []).push({layer, pos: pos.copy(), before, after: gid});
772
+ }
773
+
774
+ // the stroke is done, it can be undone as one
775
+ function editorStrokeEnd()
776
+ {
777
+ if (!editorStroke) return;
778
+ editorUndoList.push(editorStroke);
779
+ editorRedoList.length = 0;
780
+ editorChanged(editorStroke);
781
+ editorStroke = undefined;
782
+ }
783
+
784
+ // undo the stroke being made, with no undo entry, as if it was never made
785
+ function editorStrokeCancel()
786
+ {
787
+ const stroke = editorStroke;
788
+ editorStroke = undefined;
789
+ if (!stroke) return;
790
+ editorBulkEdit(()=>
791
+ {
792
+ for (const entry of [...stroke].reverse())
793
+ entry.objectLayer ? editorSetObjects(entry.objectLayer, entry.before) :
794
+ editorSetCell(entry.layer, entry.pos, entry.before);
795
+ });
796
+ editorRedraw(stroke);
797
+ }
798
+
799
+ // undo the last stroke, or redo the last one undone
800
+ function editorUndo(redo=false)
801
+ {
802
+ editorStrokeEnd();
803
+ const stroke = (redo ? editorRedoList : editorUndoList).pop();
804
+ if (!stroke) return;
805
+ (redo ? editorUndoList : editorRedoList).push(stroke);
806
+ editorBulkEdit(()=>
807
+ {
808
+ for (const entry of redo ? stroke : [...stroke].reverse())
809
+ {
810
+ if (entry.resize)
811
+ editorSetMapSnapshot(entry.resize, redo ? entry.after : entry.before);
812
+ else if (entry.objectLayer)
813
+ editorSetObjects(entry.objectLayer, redo ? entry.after : entry.before);
814
+ else
815
+ editorSetCell(entry.layer, entry.pos, redo ? entry.after : entry.before);
816
+ }
817
+ });
818
+ editorChanged(stroke);
819
+ }
820
+
821
+ // after a change the tile layers it touched draw again whole, so a game's onRedraw decoration sees the new tiles,
822
+ // and the maps it touched are autosaved; a resized map is made again by the game, with the editor staying open
823
+ function editorChanged(stroke)
824
+ {
825
+ editorRedraw(stroke);
826
+ for (const record of new Set(stroke.map(editorEntryRecord)))
827
+ editorAutosave(record);
828
+ stroke.some((entry)=> entry.resize) && levelEditor.onRestart?.();
829
+ }
830
+
831
+ // the tile layers a stroke touched draw again whole
832
+ function editorRedraw(stroke)
833
+ {
834
+ for (const layer of new Set(stroke.filter((entry)=> entry.layer).map((entry)=> entry.layer)))
835
+ layer.live.destroyed || layer.live.redraw();
836
+ }
837
+
838
+ ///////////////////////////////////////////////////////////////////////////////
839
+ // objects
840
+
841
+ // the object layers of a map's Tiled layers in order, groups flattened
842
+ function editorObjectGroups(layers, list=[])
843
+ {
844
+ for (const layer of layers || [])
845
+ {
846
+ if (layer.type === 'group')
847
+ editorObjectGroups(layer.layers, list);
848
+ else if (layer.type === 'objectgroup')
849
+ list.push(layer);
850
+ }
851
+ return list;
852
+ }
853
+
854
+ // the editor's object layers of a map, each {record, isObjects, group, name, instances}: group is the Tiled object
855
+ // layer, undefined for one not made yet, and instances what the game made for each object id; a map with no object
856
+ // layer gets one to place objects in once the game has added a type, made in the map the first time it is used
857
+ function editorObjectLayers(record)
858
+ {
859
+ if (!record || record.synthetic) return [];
860
+ record.objectLayers ||= editorObjectGroups(record.map.layers).map((group)=>
861
+ ({record, isObjects: true, group, name: group.name || 'Objects', instances: new Map}));
862
+ if (!record.objectLayers.length && objectLayersTypes.size)
863
+ record.objectLayers.push({record, isObjects: true, group: undefined, name: 'Objects', instances: new Map});
864
+ return record.objectLayers;
865
+ }
866
+
867
+ // a new Tiled object layer named Objects, on top of a map
868
+ function editorNewObjectGroup(map)
869
+ {
870
+ const id = map.nextlayerid ?? map.layers.length + 1, group = {draworder: 'topdown', id, name: 'Objects',
871
+ objects: [], opacity: 1, type: 'objectgroup', visible: true, x: 0, y: 0};
872
+ map.nextlayerid = id + 1;
873
+ map.layers.push(group);
874
+ return group;
875
+ }
876
+
877
+ // the Tiled object layer of an editor object layer, made the first time it is needed
878
+ function editorObjectGroup(layer) { return layer.group ||= editorNewObjectGroup(layer.record.map); }
879
+
880
+ // the next object id of a map, as Tiled counts them, and past every id the map has, for a map with no nextobjectid
881
+ // or one an autosave left behind
882
+ function editorNextObjectId(map)
883
+ {
884
+ const ids = editorObjectGroups(map.layers).flatMap((group)=> (group.objects ?? []).map((object)=> object.id));
885
+ const id = max(map.nextobjectid ?? 1, ...ids.map((id)=> id + 1));
886
+ map.nextobjectid = id + 1;
887
+ return id;
888
+ }
889
+
890
+ // an object's world position, as objectLayersLoad places it
891
+ function editorObjectPos(record, object)
892
+ {
893
+ const {height=0, tilewidth=1, tileheight=1} = record.map;
894
+ return vec2(object.x / tilewidth, height - object.y / tileheight);
895
+ }
896
+
897
+ // set an object's position in the map from a world position
898
+ function editorObjectSetPos(record, object, pos)
899
+ {
900
+ const {height=0, tilewidth=1, tileheight=1} = record.map;
901
+ object.x = pos.x * tilewidth;
902
+ object.y = (height - pos.y) * tileheight;
903
+ }
904
+
905
+ // called by objectLayersLoad for each object it read, the editor keeps what the game made by the object's id
906
+ function editorObjectMade(map, group, object, made)
907
+ {
908
+ const record = editorMapList.find((record)=> record.map === map);
909
+ const layer = editorObjectLayers(record).find((layer)=> layer.group === group);
910
+ layer && (made ? layer.instances.set(object.id, made) : layer.instances.delete(object.id));
911
+ }
912
+
913
+ // a copy of objects as undo and autosave keep them, and an object layer's objects now
914
+ const editorObjectsCopy = (value)=> JSON.parse(JSON.stringify(value));
915
+ const editorObjectList = (layer)=> editorObjectsCopy(layer.group?.objects ?? []);
916
+
917
+ // set an object layer's objects to a list and bring the game's objects in line: one gone is destroyed, a new one
918
+ // made, a moved one moved, one with changed properties given them; a type made by an arrow function, like a player
919
+ // start, is called again when its object changes, and a class is not made again for an object the game let go of
920
+ function editorSetObjects(layer, list)
921
+ {
922
+ const {record, instances} = layer, group = editorObjectGroup(layer);
923
+ const before = new Map(group.objects.map((object)=> [object.id, object]));
924
+ group.objects = editorObjectsCopy(list);
925
+ const ids = new Set(group.objects.map((object)=> object.id));
926
+ for (const [id, made] of instances)
927
+ {
928
+ if (ids.has(id)) continue;
929
+ made.destroy?.();
930
+ instances.delete(id);
931
+ }
932
+ for (const object of group.objects)
933
+ {
934
+ const old = before.get(object.id), made = instances.get(object.id);
935
+ const name = object.type || object.class, type = objectLayersTypes.get(name);
936
+ if (!type || old && editorSameData(old, object)) continue;
937
+ if (!old || (old.type || old.class) !== name || !type.make.prototype)
938
+ {
939
+ // new, of a new type, or a function to call again
940
+ made?.destroy?.();
941
+ const result = objectLayersMake(record.map, object);
942
+ result ? instances.set(object.id, result) : instances.delete(object.id);
943
+ }
944
+ else if (made && !made.destroyed)
945
+ {
946
+ // only what the edit changed, so moving an object keeps its health and the rest of its state from play
947
+ if ('pos' in made && (old.x !== object.x || old.y !== object.y))
948
+ made.pos = editorObjectPos(record, object);
949
+ const was = objectLayersProperties(type, old), now = objectLayersProperties(type, object);
950
+ for (const name in now)
951
+ editorSameData(was[name], now[name]) || (made[name] = now[name]);
952
+ }
953
+ }
954
+ }
955
+
956
+ // change an object layer's objects as part of the stroke being made: change edits a copy of the list; one stroke's
957
+ // changes to one layer are one undo entry, so a drag is one undo
958
+ function editorChangeObjects(layer, change)
959
+ {
960
+ if (!layer || layer.record.pending) return false; // its autosaved edits wait to be applied or dropped first
961
+ const before = editorObjectList(layer), after = editorObjectsCopy(before);
962
+ change(after);
963
+ if (editorSameData(before, after)) return false;
964
+ editorSetObjects(layer, after);
965
+ const last = editorStroke?.at(-1);
966
+ if (last?.objectLayer === layer)
967
+ last.after = after;
968
+ else
969
+ (editorStroke ||= []).push({objectLayer: layer, before, after});
970
+ return true;
971
+ }
972
+
973
+ // set an object's property as Tiled keeps it, only where it differs from its type's default, a color as #AARRGGBB
974
+ // and a Vector2 as the string x,y
975
+ function editorObjectSetProperty(object, name, value, defaultValue)
976
+ {
977
+ if (Number.isInteger(defaultValue) && isNumber(value))
978
+ value = round(value); // an integer stays one, as Tiled keeps an int
979
+ const properties = (object.properties ?? []).filter((property)=> property.name !== name);
980
+ const text = (v)=> isColor(v) ? v.toString() : isVector2(v) ? v.x + ',' + v.y : JSON.stringify(v);
981
+ if (text(value) !== text(defaultValue))
982
+ {
983
+ const hex = isColor(value) && value.toString();
984
+ properties.push(hex ? {name, type: 'color', value: '#' + hex.slice(7, 9) + hex.slice(1, 7)} :
985
+ isVector2(value) ? {name, type: 'string', value: text(value)} :
986
+ {name, type: typeof value === 'boolean' ? 'bool' : typeof value === 'string' ? 'string' :
987
+ Number.isInteger(defaultValue) ? 'int' : 'float', value});
988
+ }
989
+ if (properties.length)
990
+ object.properties = properties;
991
+ else
992
+ delete object.properties;
993
+ }
994
+
995
+ // copy saved objects into a map's object layers, making an Objects layer for any it saved that the map lacks
996
+ function editorRestoreObjects(map, saved)
997
+ {
998
+ const groups = editorObjectGroups(map.layers);
999
+ (saved.objects ?? []).forEach((objects, i)=>
1000
+ {
1001
+ (groups[i] ?? editorNewObjectGroup(map)).objects = editorObjectsCopy(objects);
1002
+ });
1003
+ if (saved.nextobjectid > (map.nextobjectid ?? 1))
1004
+ map.nextobjectid = saved.nextobjectid;
1005
+ }
1006
+
1007
+ ///////////////////////////////////////////////////////////////////////////////
1008
+ // object editing
1009
+
1010
+ // the object layer being edited, undefined while a tile layer is; the ids of the objects selected in it; the object
1011
+ // brush and the last objects copied, each a list of {type, properties, offset} placed at the mouse plus the offset;
1012
+ // a left drag moving the selected objects, and a right drag's box
1013
+ let editorObjectLayer, editorObjectSelection = new Set, editorObjectBrush, editorObjectClipboard;
1014
+ let editorObjectDrag, editorObjectBox;
1015
+
1016
+ // make a layer the one being edited, a tile layer or an object layer; an object layer keeps a tile layer of its map
1017
+ // as editorLayer, for All Layers
1018
+ function editorSelectLayer(layer)
1019
+ {
1020
+ editorStrokeEnd();
1021
+ editorLastCell = editorObjectDrag = editorRightPress = editorObjectBox = undefined; // presses stay on their layer
1022
+ editorSelectionDrag = undefined; // a move so far is kept, the stroke ended
1023
+ editorObjectSelection.clear();
1024
+ if (layer.isObjects)
1025
+ {
1026
+ editorObjectLayer = layer;
1027
+ if (editorLayer?.record !== layer.record)
1028
+ editorLayer = layer.record.layers[0]; // none for a level of objects alone
1029
+ }
1030
+ else
1031
+ {
1032
+ editorObjectLayer = undefined;
1033
+ editorLayer = layer;
1034
+ }
1035
+ }
1036
+
1037
+ // the size of an object for picking it, what the game made it, or a cell
1038
+ function editorObjectSize(layer, object)
1039
+ {
1040
+ const made = layer.instances.get(object.id);
1041
+ return made && !made.destroyed && isVector2(made.size) ? made.size : vec2(1);
1042
+ }
1043
+
1044
+ // the object under a world position, the one drawn last first
1045
+ function editorObjectAt(layer, pos)
1046
+ {
1047
+ const objects = layer.group?.objects ?? [];
1048
+ for (let i = objects.length; i--;)
1049
+ {
1050
+ const object = objects[i], center = editorObjectPos(layer.record, object);
1051
+ const size = editorObjectSize(layer, object);
1052
+ if (abs(pos.x - center.x) <= size.x / 2 && abs(pos.y - center.y) <= size.y / 2)
1053
+ return object;
1054
+ }
1055
+ }
1056
+
1057
+ // the ids of the objects whose positions are inside the box between two world positions
1058
+ function editorObjectsIn(layer, a, b)
1059
+ {
1060
+ const low = vec2(min(a.x, b.x), min(a.y, b.y)), high = vec2(max(a.x, b.x), max(a.y, b.y));
1061
+ return (layer.group?.objects ?? []).filter((object)=>
1062
+ {
1063
+ const pos = editorObjectPos(layer.record, object);
1064
+ return pos.x >= low.x && pos.y >= low.y && pos.x <= high.x && pos.y <= high.y;
1065
+ }).map((object)=> object.id);
1066
+ }
1067
+
1068
+ // the brush takes an object's type and properties
1069
+ function editorPickObject(object)
1070
+ { editorObjectBrush = [{type: object.type || object.class, properties: editorObjectsCopy(object.properties ?? []), offset: vec2()}]; }
1071
+
1072
+ // a new Tiled point object for a brush's object, at a world position, with the map's next id
1073
+ function editorNewObject(record, {type, properties}, pos)
1074
+ {
1075
+ const object = {id: editorNextObjectId(record.map), name: '', type, point: true, rotation: 0, visible: true,
1076
+ width: 0, height: 0, x: 0, y: 0};
1077
+ properties.length && (object.properties = editorObjectsCopy(properties));
1078
+ editorObjectSetPos(record, object, pos);
1079
+ return object;
1080
+ }
1081
+
1082
+ // place an object brush's objects at a position plus each one's offset, as one undo
1083
+ function editorPlaceObjects(layer, pos, brush)
1084
+ {
1085
+ editorChangeObjects(layer, (list)=>
1086
+ {
1087
+ for (const object of brush)
1088
+ list.push(editorNewObject(layer.record, object, pos.add(object.offset)));
1089
+ });
1090
+ editorStrokeEnd();
1091
+ }
1092
+
1093
+ // the selected objects of the object layer being edited
1094
+ function editorSelectedObjects()
1095
+ { return (editorObjectLayer?.group?.objects ?? []).filter((object)=> editorObjectSelection.has(object.id)); }
1096
+
1097
+ // remove the selected objects, as one undo
1098
+ function editorDeleteObjects()
1099
+ {
1100
+ if (!editorObjectSelection.size) return false;
1101
+ const selection = editorObjectSelection;
1102
+ editorStrokeEnd();
1103
+ editorChangeObjects(editorObjectLayer, (list)=>
1104
+ list.splice(0, list.length, ...list.filter((object)=> !selection.has(object.id))));
1105
+ editorObjectSelection = new Set;
1106
+ editorStrokeEnd();
1107
+ return true;
1108
+ }
1109
+
1110
+ // the selected objects into the brush, their offsets from the lowest corner of their positions, and the selection
1111
+ // cleared so the next click places them
1112
+ function editorCopyObjects()
1113
+ {
1114
+ const objects = editorSelectedObjects();
1115
+ if (!objects.length) return false;
1116
+ const positions = objects.map((object)=> editorObjectPos(editorObjectLayer.record, object));
1117
+ const low = vec2(min(...positions.map((p)=> p.x)), min(...positions.map((p)=> p.y)));
1118
+ editorObjectBrush = editorObjectClipboard = objects.map((object, i)=> ({type: object.type || object.class,
1119
+ properties: editorObjectsCopy(object.properties ?? []), offset: positions[i].subtract(low)}));
1120
+ editorObjectSelection.clear();
1121
+ return true;
1122
+ }
1123
+
1124
+ // copy the selected objects, then remove them, as one undo
1125
+ function editorCutObjects()
1126
+ {
1127
+ const selection = new Set(editorObjectSelection);
1128
+ if (!editorCopyObjects()) return false;
1129
+ editorObjectSelection = selection;
1130
+ return editorDeleteObjects();
1131
+ }
1132
+
1133
+ // the last copied objects back into the brush
1134
+ function editorPasteObjects()
1135
+ {
1136
+ if (!editorObjectClipboard) return false;
1137
+ editorObjectBrush = editorObjectClipboard;
1138
+ return true;
1139
+ }
1140
+
1141
+ // the mouse on an object layer: the left places, selects and drags objects, the right picks one or box-selects,
1142
+ // Shift adding and Ctrl taking away
1143
+ function editorUpdateObjects(space)
1144
+ {
1145
+ const layer = editorObjectLayer, record = layer.record, mouse = screenToWorld(editorMouseScreen);
1146
+ const snap = editorSnap;
1147
+ const shift = keyIsDown('ShiftLeft') || keyIsDown('ShiftRight');
1148
+ const ctrl = keyIsDown('ControlLeft') || keyIsDown('ControlRight') || keyIsDown('MetaLeft') || keyIsDown('MetaRight');
1149
+ editorHover = undefined;
1150
+
1151
+ // the right button: a click picks the object under the mouse, or clears the selection, a drag box-selects
1152
+ if (mouseWasPressed(2))
1153
+ editorRightPress = {screen: mousePosScreen.copy(), world: mouse.copy(), drag: false};
1154
+ const press = editorRightPress;
1155
+ if (press)
1156
+ {
1157
+ press.drag ||= mousePosScreen.distance(press.screen) > max(4, editorCameraScale / 2);
1158
+ editorObjectBox = press.drag ? {a: press.world, b: mouse} : undefined;
1159
+ if (!mouseIsDown(2))
1160
+ {
1161
+ const hit = !press.drag && editorObjectAt(layer, mouse);
1162
+ const ids = press.drag ? editorObjectsIn(layer, press.world, mouse) : hit ? [hit.id] : [];
1163
+ if (press.drag || hit && (shift || ctrl))
1164
+ {
1165
+ shift || ctrl || editorObjectSelection.clear();
1166
+ for (const id of ids)
1167
+ ctrl ? editorObjectSelection.delete(id) : editorObjectSelection.add(id);
1168
+ }
1169
+ else if (hit)
1170
+ editorPickObject(hit);
1171
+ else
1172
+ editorObjectSelection.clear();
1173
+ editorRightPress = editorObjectBox = undefined;
1174
+ }
1175
+ return;
1176
+ }
1177
+
1178
+ // the left button: on an object selects it and starts a drag of the selection, on empty space the first click
1179
+ // with a selection only clears it, otherwise it places the brush
1180
+ if (mouseWasPressed(0) && !space)
1181
+ {
1182
+ const hit = editorObjectAt(layer, mouse);
1183
+ if (hit)
1184
+ {
1185
+ if (!editorObjectSelection.has(hit.id))
1186
+ editorObjectSelection = new Set([hit.id]);
1187
+ editorObjectDrag = {start: snap(mouse), from: new Map(editorSelectedObjects().map((object)=>
1188
+ [object.id, editorObjectPos(record, object)]))};
1189
+ }
1190
+ else if (editorObjectSelection.size)
1191
+ editorObjectSelection.clear();
1192
+ else if (editorObjectBrush)
1193
+ editorPlaceObjects(layer, snap(mouse), editorObjectBrush);
1194
+ }
1195
+ const drag = editorObjectDrag;
1196
+ if (drag && mouseIsDown(0))
1197
+ {
1198
+ const delta = snap(mouse).subtract(drag.start);
1199
+ editorChangeObjects(layer, (list)=>
1200
+ {
1201
+ for (const object of list)
1202
+ {
1203
+ const from = drag.from.get(object.id);
1204
+ from && editorObjectSetPos(record, object, from.add(delta));
1205
+ }
1206
+ });
1207
+ }
1208
+ if (!mouseIsDown(0))
1209
+ {
1210
+ editorObjectDrag = undefined;
1211
+ editorStrokeEnd();
1212
+ }
1213
+ }
1214
+
1215
+ ///////////////////////////////////////////////////////////////////////////////
1216
+ // editing
1217
+
1218
+ // every tile layer in the game, as the editor's records, in render order, then the object layers of their maps, and
1219
+ // of the maps the game loaded objects alone from
1220
+ function editorLayers()
1221
+ {
1222
+ const tiles = engineObjects.filter((o)=> o instanceof TileLayer && !o.destroyed)
1223
+ .sort((a, b)=> a.renderOrder - b.renderOrder).map(editorLayerRecord);
1224
+ const records = [...new Set(tiles.map((layer)=> layer.record))];
1225
+ for (const record of editorMapList)
1226
+ record.synthetic || record.layers.length || records.includes(record) || records.push(record);
1227
+ return [...tiles, ...records.flatMap(editorObjectLayers)];
1228
+ }
1229
+
1230
+ // if a map of objects alone has nothing left in the game, the level moved on, a marker that is no game object counts
1231
+ // as nothing
1232
+ const editorObjectsGone = (record)=> !record.layers.length &&
1233
+ editorObjectLayers(record).every((layer)=> [...layer.instances.values()].every((made)=> !made.destroy || made.destroyed));
1234
+
1235
+ // the cell of a layer at a world position, undefined off the layer
1236
+ function editorCellAt(live, worldPos)
1237
+ {
1238
+ const pos = worldPos.subtract(live.pos).floor();
1239
+ return pos.arrayCheck(live.size) ? pos : undefined;
1240
+ }
1241
+
1242
+ // the cell of a layer nearest a world position, clamped onto the layer
1243
+ function editorCellClamped(live, worldPos)
1244
+ {
1245
+ const pos = worldPos.subtract(live.pos).floor();
1246
+ return vec2(clamp(pos.x, 0, live.size.x - 1), clamp(pos.y, 0, live.size.y - 1));
1247
+ }
1248
+
1249
+ // the area between two cells, corners in any order
1250
+ function editorArea(a, b)
1251
+ { return {min: vec2(min(a.x, b.x), min(a.y, b.y)), max: vec2(max(a.x, b.x), max(a.y, b.y))}; }
1252
+
1253
+ // the tile info a layer draws a tile with, as TileLayer.drawTileData picks it
1254
+ function editorTileInfo(live, tile)
1255
+ {
1256
+ const t = live.tileInfo;
1257
+ return t && (t.columns ? t.frame(tile) : t.index(tile));
1258
+ }
1259
+
1260
+ ///////////////////////////////////////////////////////////////////////////////
1261
+ // stamps
1262
+
1263
+ // the brush is a stamp: its size and, for each layer it paints, a grid of gids from its bottom row up, undefined
1264
+ // see-through; one grid paints the layer being edited, one copied from all layers has a grid for each layer of the
1265
+ // map, in order; the last one copied is kept for Ctrl+V
1266
+ let editorBrush = editorStampTile(editorTileToGid(0)), editorClipboard;
1267
+
1268
+ // a stamp of one tile, gid 0 is the Erase brush
1269
+ function editorStampTile(gid) { return {width: 1, height: 1, grids: [[gid]]}; }
1270
+
1271
+ // the gid in a cell of a layer, as the map holds it
1272
+ function editorGidAt(layer, cell)
1273
+ { return layer.source.data[cell.x + (layer.live.size.y - 1 - cell.y) * layer.live.size.x]; }
1274
+
1275
+ // each layer a stamp paints with its grid: a stamp of one grid paints the layer being edited, one copied from all
1276
+ // layers paints the layers of that layer's map, as many as it has
1277
+ function editorStampTargets(layer, stamp)
1278
+ {
1279
+ return stamp.grids.length > 1 ?
1280
+ layer.record.layers.slice(0, stamp.grids.length).map((target, i)=> [target, stamp.grids[i]]) :
1281
+ [[layer, stamp.grids[0]]];
1282
+ }
1283
+
1284
+ // the grid of a stamp that paints a layer
1285
+ function editorStampGrid(layer, stamp)
1286
+ { return stamp.grids.length > 1 ? stamp.grids[layer.record.layers.indexOf(layer)] ?? [] : stamp.grids[0]; }
1287
+
1288
+ // paint a stamp with its bottom left on a cell, its see-through cells and cells off the layer leave things as they are
1289
+ function editorPaintStamp(layer, pos, stamp=editorBrush)
1290
+ {
1291
+ if (layer.record.pending) return; // its autosaved edits wait to be applied or dropped first
1292
+ for (const [target, grid] of editorStampTargets(layer, stamp))
1293
+ for (let y = stamp.height; y--;)
1294
+ for (let x = stamp.width; x--;)
1295
+ {
1296
+ const gid = grid[x + y * stamp.width], cell = pos.add(vec2(x, y));
1297
+ gid === undefined || !cell.arrayCheck(target.live.size) || editorPaint(target, cell, gid);
1298
+ }
1299
+ editorLastPlaced = {layer, pos: pos.copy()};
1300
+ }
1301
+
1302
+ // paint a stamp on every cell of the line between two cells
1303
+ function editorPaintBetween(layer, from, to, stamp=editorBrush)
1304
+ {
1305
+ const steps = max(abs(to.x - from.x), abs(to.y - from.y));
1306
+ for (let i = 0; i <= steps; ++i)
1307
+ editorPaintStamp(layer, from.lerp(to, steps ? i / steps : 1).floor(), stamp);
1308
+ }
1309
+
1310
+ // paint a stamp along a drag from its last cell, so a fast drag leaves no gaps
1311
+ function editorPaintLine(layer, pos, stamp=editorBrush)
1312
+ {
1313
+ editorPaintBetween(layer, editorLastCell ?? pos, pos, stamp);
1314
+ editorLastCell = pos;
1315
+ }
1316
+
1317
+ // a gid turned a quarter turn clockwise on screen: Tiled draws direction one that way, flipped diagonally then
1318
+ // horizontally, and tileLayersLoad reads it the same, so a turned stamp looks the same in both
1319
+ function editorGidTurn(gid)
1320
+ {
1321
+ const t = editorGidToTile(gid);
1322
+ return t ? editorTileToGid(t.tile, (t.direction + 1) % 4, t.mirror) : gid;
1323
+ }
1324
+
1325
+ // a gid mirrored left to right, Tiled's horizontal flip: a mirror over a turn is the mirror under the turn the
1326
+ // other way
1327
+ function editorGidMirror(gid)
1328
+ {
1329
+ const t = editorGidToTile(gid);
1330
+ return t ? editorTileToGid(t.tile, (4 - t.direction) % 4, !t.mirror) : gid;
1331
+ }
1332
+
1333
+ // a stamp turned a quarter turn clockwise on screen, or back, its cells, their tiles and its objects together; cell
1334
+ // (x, y) goes to (y, width - 1 - x), so the bottom row becomes the left column, its left end at the top
1335
+ function editorStampTurn(stamp, back=false)
1336
+ {
1337
+ for (let turns = back ? 3 : 1; turns--;)
1338
+ {
1339
+ const {width, height} = stamp;
1340
+ const objects = stamp.objects?.map((object)=> ({...object, offset: vec2(object.offset.y, width - object.offset.x)}));
1341
+ stamp = {width: height, height: width, grids: stamp.grids.map((grid)=>
1342
+ {
1343
+ const turned = [];
1344
+ for (let y = height; y--;)
1345
+ for (let x = width; x--;)
1346
+ turned[y + (width - 1 - x) * height] = editorGidTurn(grid[x + y * width]);
1347
+ return turned;
1348
+ }), ...(objects && {objects})};
1349
+ }
1350
+ return stamp;
1351
+ }
1352
+
1353
+ // a stamp mirrored left to right, its cells, their tiles and its objects together
1354
+ function editorStampMirror(stamp)
1355
+ {
1356
+ const {width, height} = stamp;
1357
+ const objects = stamp.objects?.map((object)=> ({...object, offset: vec2(width - object.offset.x, object.offset.y)}));
1358
+ return {width, height, grids: stamp.grids.map((grid)=>
1359
+ {
1360
+ const mirrored = [];
1361
+ for (let y = height; y--;)
1362
+ for (let x = width; x--;)
1363
+ mirrored[width - 1 - x + y * width] = editorGidMirror(grid[x + y * width]);
1364
+ return mirrored;
1365
+ }), ...(objects && {objects})};
1366
+ }
1367
+
1368
+ // the brush's tile when it is one tile, with its turn and mirror, undefined for the Erase brush or a bigger stamp
1369
+ function editorBrushTile()
1370
+ {
1371
+ const {width, height, grids} = editorBrush;
1372
+ return width === 1 && height === 1 && grids.length === 1 ? editorGidToTile(grids[0][0]) : undefined;
1373
+ }
1374
+
1375
+ // the brush takes the tile in a cell, an empty cell gives the Erase brush
1376
+ function editorPick(layer, pos) { editorBrush = editorStampTile(editorGidAt(layer, pos) || 0); }
1377
+
1378
+ // a palette slot into the brush: slot 0 is the Erase brush, slot n the palette's nth tile, keeping a one tile
1379
+ // brush's turn; on an object layer slot n is the nth object type
1380
+ function editorPalettePick(slot)
1381
+ {
1382
+ if (editorObjectLayer)
1383
+ {
1384
+ // on an object layer the palette is the types the game added, in order
1385
+ const type = [...objectLayersTypes.keys()][slot];
1386
+ type && (editorObjectBrush = [{type, properties: [], offset: vec2()}]);
1387
+ return;
1388
+ }
1389
+ const t = editorBrushTile();
1390
+ const tile = slot && (editorPaletteTiles(editorLayer)[slot - 1]?.tile ?? slot - 1); // every tile, in order
1391
+ editorBrush = editorStampTile(slot ? editorTileToGid(tile, t?.direction, t?.mirror) : 0);
1392
+ }
1393
+
1394
+ ///////////////////////////////////////////////////////////////////////////////
1395
+ // selection
1396
+
1397
+ // the layers the selection acts on, the one being edited, or all of its map's with All Layers on
1398
+ function editorSelectionLayers()
1399
+ { return !editorLayer ? [] : editorAllLayers ? editorLayer.record.layers : [editorLayer]; }
1400
+
1401
+ // call back for each selected cell that is on a layer
1402
+ function editorSelectionCells(live, callback)
1403
+ {
1404
+ const {min: a, max: b} = editorSelection;
1405
+ for (let y = a.y; y <= b.y; ++y)
1406
+ for (let x = a.x; x <= b.x; ++x)
1407
+ {
1408
+ const cell = vec2(x, y);
1409
+ cell.arrayCheck(live.size) && callback(cell);
1410
+ }
1411
+ }
1412
+
1413
+ // fill with the brush: the selection when there is one, or the cells joined to the one under the mouse that
1414
+ // match it exactly, on the layer being edited; a stamp repeats across it, as one undo
1415
+ function editorFill()
1416
+ {
1417
+ const layer = editorLayer, stamp = editorBrush, {width: w, height: h} = stamp;
1418
+ if (!layer || layer.live.destroyed || layer.record.pending) return;
1419
+ editorStrokeEnd(); // a drag held while F is pressed is an undo of its own
1420
+ const paint = (target, grid, cell, anchor)=>
1421
+ {
1422
+ const x = ((cell.x - anchor.x) % w + w) % w, y = ((cell.y - anchor.y) % h + h) % h;
1423
+ const gid = grid[x + y * w];
1424
+ gid === undefined || editorPaint(target, cell, gid);
1425
+ };
1426
+ editorBulkEdit(()=>
1427
+ {
1428
+ if (editorSelection)
1429
+ {
1430
+ for (const [target, grid] of editorStampTargets(layer, stamp))
1431
+ editorSelectionCells(target.live, (cell)=> paint(target, grid, cell, editorSelection.min));
1432
+ }
1433
+ else if (editorHover)
1434
+ {
1435
+ const grid = editorStampGrid(layer, stamp), start = editorHover;
1436
+ for (const cell of editorFloodCells(layer, start))
1437
+ paint(layer, grid, cell, start);
1438
+ }
1439
+ });
1440
+ editorStrokeEnd();
1441
+ }
1442
+
1443
+ // the cells joined through their sides to a cell, holding the same gid, turn and mirror included
1444
+ function editorFloodCells(layer, start)
1445
+ {
1446
+ // cells by index, x + y * width, each looked at once
1447
+ const {x: width, y: height} = layer.live.size, gid = editorGidAt(layer, start);
1448
+ const seen = new Uint8Array(width * height), cells = [], open = [start.x + start.y * width];
1449
+ while (open.length)
1450
+ {
1451
+ const i = open.pop();
1452
+ if (seen[i]) continue;
1453
+ seen[i] = 1;
1454
+ const x = i % width, y = i / width | 0, cell = vec2(x, y);
1455
+ if (editorGidAt(layer, cell) !== gid) continue;
1456
+ cells.push(cell);
1457
+ x + 1 < width && open.push(i + 1);
1458
+ x && open.push(i - 1);
1459
+ y + 1 < height && open.push(i + width);
1460
+ y && open.push(i - width);
1461
+ }
1462
+ return cells;
1463
+ }
1464
+
1465
+ // clear the selected area, with All Layers the objects in it too, as one undo
1466
+ function editorClear()
1467
+ {
1468
+ if (!editorSelection) return;
1469
+ editorStrokeEnd(); // a drag held while Delete is pressed is an undo of its own
1470
+ editorBulkEdit(()=>
1471
+ {
1472
+ for (const layer of editorSelectionLayers())
1473
+ layer.record.pending || editorSelectionCells(layer.live, (cell)=> editorPaint(layer, cell, 0));
1474
+ if (editorAllLayers && editorLayer)
1475
+ {
1476
+ const corner = editorLayer.live.pos.add(editorSelection.min);
1477
+ const far = editorLayer.live.pos.add(editorSelection.max).add(vec2(1));
1478
+ for (const objectLayer of editorObjectLayers(editorLayer.record))
1479
+ {
1480
+ const ids = new Set(editorObjectsIn(objectLayer, corner, far));
1481
+ ids.size && editorChangeObjects(objectLayer, (list)=>
1482
+ list.splice(0, list.length, ...list.filter((object)=> !ids.has(object.id))));
1483
+ }
1484
+ }
1485
+ });
1486
+ editorStrokeEnd();
1487
+ }
1488
+
1489
+ // the objects of a map's object layers inside a tile area, each {group, type, properties, offset}: the index of its
1490
+ // object layer, and its offset from the area's bottom left corner, as a stamp keeps them
1491
+ function editorAreaObjects(layer, area)
1492
+ {
1493
+ const corner = layer.live.pos.add(area.min), far = layer.live.pos.add(area.max).add(vec2(1));
1494
+ return editorObjectLayers(layer.record).flatMap((objectLayer, group)=>
1495
+ {
1496
+ const ids = new Set(editorObjectsIn(objectLayer, corner, far));
1497
+ return (objectLayer.group?.objects ?? []).filter((object)=> ids.has(object.id)).map((object)=>
1498
+ ({group, type: object.type || object.class, properties: editorObjectsCopy(object.properties ?? []),
1499
+ offset: editorObjectPos(layer.record, object).subtract(corner)}));
1500
+ });
1501
+ }
1502
+
1503
+ // place a stamp's objects with the stamp's bottom left on a cell of a tile layer, into its map's object layers
1504
+ function editorPlaceStampObjects(layer, cell, stamp)
1505
+ {
1506
+ const targets = editorObjectLayers(layer.record), corner = layer.live.pos.add(cell);
1507
+ targets.forEach((target, group)=>
1508
+ {
1509
+ const objects = (stamp.objects ?? []).filter((object)=> (targets[object.group] ? object.group : 0) === group);
1510
+ objects.length && editorChangeObjects(target, (list)=>
1511
+ {
1512
+ for (const object of objects)
1513
+ list.push(editorNewObject(layer.record, object, corner.add(object.offset)));
1514
+ });
1515
+ });
1516
+ }
1517
+
1518
+ // the tiles of an area on the selection layers as a stamp, its empty cells see-through
1519
+ function editorSelectionStamp(area)
1520
+ {
1521
+ const width = area.max.x - area.min.x + 1, height = area.max.y - area.min.y + 1;
1522
+ const grids = editorSelectionLayers().map((layer)=>
1523
+ {
1524
+ const grid = [];
1525
+ for (let y = height; y--;)
1526
+ for (let x = width; x--;)
1527
+ {
1528
+ const cell = area.min.add(vec2(x, y));
1529
+ grid[x + y * width] = cell.arrayCheck(layer.live.size) && editorGidAt(layer, cell) || undefined;
1530
+ }
1531
+ return grid;
1532
+ });
1533
+ return {width, height, grids};
1534
+ }
1535
+
1536
+ // the selected area into the brush as a stamp, its empty cells see-through, with All Layers the objects in it too,
1537
+ // and the selection cleared so the next click paints it
1538
+ function editorCopy()
1539
+ {
1540
+ const area = editorSelection;
1541
+ if (!area || !editorLayer) return false;
1542
+ const stamp = editorSelectionStamp(area), objects = editorAllLayers ? editorAreaObjects(editorLayer, area) : [];
1543
+ if (!objects.length && stamp.grids.every((grid)=> grid.every((gid)=> gid === undefined)))
1544
+ return false; // nothing there, the brush stays as it was
1545
+ editorBrush = editorClipboard = objects.length ? {...stamp, objects} : stamp;
1546
+ editorSelection = undefined;
1547
+ return true;
1548
+ }
1549
+
1550
+ // start dragging the selection from a cell in it, with All Layers the objects in it go too
1551
+ function editorSelectionDragStart(layer, cell)
1552
+ {
1553
+ editorStrokeEnd();
1554
+ const area = editorSelection, objects = [];
1555
+ if (editorAllLayers)
1556
+ {
1557
+ const corner = layer.live.pos.add(area.min), far = layer.live.pos.add(area.max).add(vec2(1));
1558
+ for (const objectLayer of editorObjectLayers(layer.record))
1559
+ {
1560
+ const ids = new Set(editorObjectsIn(objectLayer, corner, far));
1561
+ ids.size && objects.push({objectLayer, ids});
1562
+ }
1563
+ }
1564
+ editorSelectionDrag = {layer, start: cell, delta: vec2(), area, stamp: editorSelectionStamp(area), objects};
1565
+ }
1566
+
1567
+ // move the dragged selection to a cell, as the stroke being made: taken back, then its area cleared and its tiles
1568
+ // and objects put down that far from where they were; letting go ends the stroke, one undo
1569
+ function editorSelectionDragTo(cell)
1570
+ {
1571
+ const drag = editorSelectionDrag, delta = cell.subtract(drag.start), {layer, area, stamp} = drag;
1572
+ if (delta.x === drag.delta.x && delta.y === drag.delta.y) return;
1573
+ drag.delta = delta;
1574
+ editorStrokeCancel();
1575
+ editorSelection = {min: area.min.add(delta), max: area.max.add(delta)};
1576
+ if (!delta.x && !delta.y) return; // back where it was, no change
1577
+ editorBulkEdit(()=>
1578
+ {
1579
+ for (const [target] of editorStampTargets(layer, stamp))
1580
+ for (let y = area.min.y; y <= area.max.y; ++y)
1581
+ for (let x = area.min.x; x <= area.max.x; ++x)
1582
+ {
1583
+ const from = vec2(x, y);
1584
+ from.arrayCheck(target.live.size) && editorPaint(target, from, 0);
1585
+ }
1586
+ editorPaintStamp(layer, editorSelection.min, stamp);
1587
+ for (const {objectLayer, ids} of drag.objects)
1588
+ editorChangeObjects(objectLayer, (list)=>
1589
+ {
1590
+ for (const object of list)
1591
+ ids.has(object.id) && editorObjectSetPos(layer.record, object,
1592
+ editorObjectPos(layer.record, object).add(delta));
1593
+ });
1594
+ });
1595
+ editorStroke && editorRedraw(editorStroke);
1596
+ }
1597
+
1598
+ // put the dragged selection back where it was, with nothing to undo
1599
+ function editorSelectionDragCancel()
1600
+ {
1601
+ editorStrokeCancel();
1602
+ editorSelection = editorSelectionDrag.area;
1603
+ editorSelectionDrag = undefined;
1604
+ }
1605
+
1606
+ // copy the selected area, then clear it, as one undo
1607
+ function editorCut()
1608
+ {
1609
+ const area = editorSelection;
1610
+ if (!editorCopy()) return false;
1611
+ editorSelection = area;
1612
+ editorClear();
1613
+ editorSelection = undefined;
1614
+ return true;
1615
+ }
1616
+
1617
+ // the last copied stamp back into the brush
1618
+ function editorPaste()
1619
+ {
1620
+ if (!editorClipboard) return false;
1621
+ editorBrush = editorClipboard;
1622
+ return true;
1623
+ }
1624
+
1625
+ ///////////////////////////////////////////////////////////////////////////////
1626
+ // panel
1627
+
1628
+ let editorPanel, editorPanelParts;
1629
+
1630
+ // the list of keys ? shows
1631
+ const editorHelpLines =
1632
+ [
1633
+ 'Left: paint · Shift+Left: line from the last tile',
1634
+ 'Right click: pick a tile · Right drag: select',
1635
+ 'Left drag a selection: move it · Esc or right click: put it back',
1636
+ 'Middle drag or Space+drag: pan · Wheel or pinch: zoom',
1637
+ '1-9: layer · Esc: play and edit · 0: exit the editor',
1638
+ 'F: fill · Delete: clear the selection',
1639
+ 'Ctrl+C / X / V: copy, cut, paste · Ctrl+Z / Y: undo, redo',
1640
+ 'R, Shift+R: turn · M: mirror · E: erase · G: grid · ?: keys',
1641
+ 'Objects layer: left places or selects, drag moves, right picks or box-selects, Delete removes, Ctrl+C / X / V',
1642
+ ];
1643
+
1644
+ // the hint line, for what is held and whether there is a selection
1645
+ function editorHint()
1646
+ {
1647
+ if (keyIsDown('Space')) return 'Drag to pan';
1648
+ if (editorObjectLayer)
1649
+ return editorObjectSelection.size ? 'Selection: drag moves · Delete removes · Ctrl+C copy · Ctrl+X cut · click to clear' :
1650
+ 'Left place / select · drag moves · Right pick / drag select · Delete removes';
1651
+ if (editorSelection)
1652
+ return 'Selection: drag moves · F fill · Delete clear · Ctrl+C copy · Ctrl+X cut · click to clear';
1653
+ if (keyIsDown('ShiftLeft') || keyIsDown('ShiftRight')) return 'Shift: line from the last tile';
1654
+ return 'Left paint · Right pick / drag select · F fill · Space or middle drag pans · ? keys';
1655
+ }
1656
+
1657
+ // what the brush is, in words
1658
+ function editorBrushLabel()
1659
+ {
1660
+ if (editorObjectLayer)
1661
+ {
1662
+ const brush = editorObjectBrush;
1663
+ return !brush ? 'Brush: none' : brush.length > 1 ? `Brush: ${brush.length} objects` : `Brush: ${brush[0].type}`;
1664
+ }
1665
+ const {width, height, grids} = editorBrush, t = editorBrushTile();
1666
+ if (t)
1667
+ return `Brush: tile ${t.tile}` + (t.direction ? `, turned ${t.direction * 90}°` : '') +
1668
+ (t.mirror ? ', mirrored' : '');
1669
+ if (width === 1 && height === 1 && grids.length === 1) return grids[0][0] === 0 ? 'Brush: Erase' : 'Brush: empty';
1670
+ const objects = editorBrush.objects?.length;
1671
+ return `Brush: ${width}x${height} stamp` + (grids.length > 1 ? `, ${grids.length} layers` : '') +
1672
+ (objects ? `, ${objects} object${objects > 1 ? 's' : ''}` : '');
1673
+ }
1674
+
1675
+ function editorElement(tag, parent, style='', text='')
1676
+ {
1677
+ const element = document.createElement(tag);
1678
+ element.style.cssText = style;
1679
+ element.textContent = text;
1680
+ parent?.appendChild(element);
1681
+ return element;
1682
+ }
1683
+
1684
+ // the panel, made the first time the editor opens, on the left so it can sit beside the tweakables panel
1685
+ function editorPanelInit()
1686
+ {
1687
+ editorPanel = editorElement('div', document.body,
1688
+ 'position:fixed;top:8px;left:8px;width:260px;max-height:calc(100% - 16px);overflow-y:auto;' +
1689
+ 'box-sizing:border-box;padding:8px;background:#111d;color:#eee;font:12px monospace;' +
1690
+ 'border-radius:4px;z-index:9999;user-select:none');
1691
+
1692
+ // a click or touch on the panel is not the game's, a mouse up still goes on so a drag can let go
1693
+ for (const type of ['mousedown','wheel','touchstart','touchmove','touchend','touchcancel'])
1694
+ editorPanel.addEventListener(type, (e)=> e.stopPropagation());
1695
+ editorPanel.addEventListener('mouseenter', ()=> editorMouseOnPanel = true);
1696
+ editorPanel.addEventListener('mouseleave', ()=> editorMouseOnPanel = false);
1697
+
1698
+ const row = ()=> editorElement('div', editorPanel, 'display:flex;gap:4px;margin:4px 0');
1699
+ const button = (parent, text, onclick, title='')=>
1700
+ {
1701
+ const b = editorElement('button', parent, 'flex:1;padding:3px;cursor:pointer', text);
1702
+ b.onclick = (e)=> { onclick(e); b.blur(); }; // the keys go back to the editor
1703
+ b.title = title;
1704
+ return b;
1705
+ };
1706
+
1707
+ editorElement('div', editorPanel, 'font-weight:bold', 'Level Editor');
1708
+ const top = row();
1709
+ button(top, 'Play', ()=> editorPlay(editorCameraPos), 'Esc, and Esc again comes back to the editor');
1710
+ const restart = button(top, 'Restart', editorRestart, 'Rebuild the level and play it');
1711
+ button(top, 'Exit', ()=> levelEditor.close(), '0, then Esc opens the debug overlay again');
1712
+ const undo = row();
1713
+ button(undo, 'Undo', ()=> editorUndo(), 'Ctrl+Z');
1714
+ button(undo, 'Redo', ()=> editorUndo(true), 'Ctrl+Y');
1715
+ button(undo, 'Keys', ()=> editorHelp = !editorHelp, 'Every control, ?');
1716
+
1717
+ // a file that changed under its autosave
1718
+ const pending = editorElement('div', editorPanel, 'padding:4px;margin:4px 0;background:#630;border-radius:3px');
1719
+ editorElement('div', pending, '', 'The level file changed since your autosaved edits');
1720
+ const pendingRow = editorElement('div', pending, 'display:flex;gap:4px;margin-top:4px');
1721
+ button(pendingRow, 'Apply edits', ()=> editorApplyPending(editorRecord()));
1722
+ button(pendingRow, 'Drop them', ()=> editorDiscardPending(editorRecord()));
1723
+ const pendingUnfit = editorElement('div', pending, 'color:#fb8;margin-top:4px',
1724
+ 'They do not fit the file now, its layers or size changed; they are kept until dropped');
1725
+
1726
+ // the layers as numbered buttons, made again when layers come or go, and All Layers
1727
+ const layerRow = editorElement('div', editorPanel, 'display:flex;gap:4px;margin:4px 0;flex-wrap:wrap');
1728
+ const allLayers = editorElement('button', editorPanel, 'width:100%;padding:3px;cursor:pointer;margin-bottom:4px');
1729
+ allLayers.title = 'Delete, Ctrl+C and Ctrl+X act on every layer of the map';
1730
+ allLayers.onclick = ()=> { editorAllLayers = !editorAllLayers; allLayers.blur(); };
1731
+
1732
+ const turns = row();
1733
+ button(turns, 'Turn', ()=> editorBrush = editorStampTurn(editorBrush), 'R, Shift+R turns back');
1734
+ button(turns, 'Mirror', ()=> editorBrush = editorStampMirror(editorBrush), 'M');
1735
+
1736
+ // the layer's tiles in a grid, a click picks one
1737
+ const palette = editorElement('canvas', editorPanel,
1738
+ 'display:block;background:#333;cursor:crosshair;margin:4px 0');
1739
+ palette.onclick = (e)=>
1740
+ {
1741
+ const cell = editorPaletteCell, slot = (e.offsetY / cell | 0) * editorPaletteColumns + (e.offsetX / cell | 0);
1742
+ const slots = editorObjectLayer ? objectLayersTypes.size : editorPaletteTiles(editorLayer).length + 1;
1743
+ slot < slots && e.offsetX < editorPaletteColumns * cell && editorPalettePick(slot);
1744
+ };
1745
+ const brush = editorElement('div', editorPanel, 'color:#ccc;margin:2px 0');
1746
+ const properties = editorElement('div', editorPanel, 'margin:4px 0;padding:4px;background:#222;border-radius:3px');
1747
+
1748
+ const file = row();
1749
+ // Save says so for a moment when it saved, Save As shows where a page can write files
1750
+ const saved = (b, label)=> (result)=> result && (b.textContent = 'Saved', setTimeout(()=> b.textContent = label, 1e3));
1751
+ const save = button(file, 'Save', ()=> editorSave(editorRecord()).then(saved(save, 'Save')),
1752
+ 'Save, to the file picked the first time, or a download');
1753
+ const saveAs = button(file, 'Save As', ()=> editorSave(editorRecord(), true).then(saved(saveAs, 'Save As')),
1754
+ 'Save As, to a file picked again');
1755
+ /** @type {any} */ (globalThis).showSaveFilePicker || (saveAs.style.display = 'none');
1756
+
1757
+ // the Advanced section, shown by its heading: Play from mouse, the level's size, Reset to file
1758
+ const advancedToggle = editorElement('button', editorPanel,
1759
+ 'width:100%;padding:3px;cursor:pointer;margin-top:4px;text-align:left');
1760
+ advancedToggle.onclick = ()=> { editorAdvanced = !editorAdvanced; advancedToggle.blur(); };
1761
+ const advanced = editorElement('div', editorPanel, 'margin:4px 0;padding:4px;background:#222;border-radius:3px');
1762
+ const playFromLabel = editorElement('label', advanced, 'display:flex;gap:6px;align-items:center;margin:2px 0');
1763
+ playFromLabel.title = 'Escape starts play with the player at the mouse, Play at the view center';
1764
+ const playFrom = editorElement('input', playFromLabel);
1765
+ playFrom.type = 'checkbox';
1766
+ playFrom.onchange = ()=> { editorPlayFromMouse = playFrom.checked; playFrom.blur(); };
1767
+ editorElement('span', playFromLabel, '', 'Play from mouse');
1768
+ const sizeRow = editorElement('div', advanced, 'display:flex;gap:4px;align-items:center;margin:4px 0');
1769
+ sizeRow.title = 'The level grows or shrinks at the right and top, can be undone';
1770
+ editorElement('span', sizeRow, '', 'Size');
1771
+ const sizeInput = ()=>
1772
+ {
1773
+ const input = editorElement('input', sizeRow, 'width:48px;background:#333;color:#eee');
1774
+ input.type = 'number';
1775
+ input.min = input.step = '1';
1776
+ input.onkeydown = (e)=> { e.key === 'Enter' && resizeLevel(); }; // returning false would block typing
1777
+ return input;
1778
+ };
1779
+ const sizeX = sizeInput();
1780
+ editorElement('span', sizeRow, '', '×');
1781
+ const sizeY = sizeInput();
1782
+ const resize = editorElement('button', sizeRow, 'flex:1;padding:3px;cursor:pointer', 'Resize');
1783
+ const resizeLevel = ()=>
1784
+ {
1785
+ // up to 1000 a side from here, editorResize takes any size from code
1786
+ const size = (input)=> clamp(parseInt(input.value) || 1, 1, 1e3);
1787
+ editorResize(editorRecord(), size(sizeX), size(sizeY));
1788
+ resize.blur(); sizeX.blur(); sizeY.blur(); // the fields show the size again
1789
+ };
1790
+ resize.onclick = resizeLevel;
1791
+ const reset = editorElement('button', advanced, 'width:100%;padding:3px;cursor:pointer;margin-top:4px',
1792
+ 'Reset to file');
1793
+ reset.title = 'Put the level back to the file it was loaded from, or last saved to, can be undone';
1794
+ reset.onclick = ()=> { editorRevert(editorRecord()); reset.blur(); };
1795
+ const storage = editorElement('div', editorPanel, 'color:#f86;margin-top:4px',
1796
+ 'Autosave failed, storage is full: Save to a file');
1797
+ const status = editorElement('div', editorPanel, 'color:#aaa;margin-top:4px;min-height:1em');
1798
+ const hint = editorElement('div', editorPanel, 'color:#8ab;margin-top:4px');
1799
+ const help = editorElement('div', editorPanel, 'color:#aaa;margin-top:4px;border-top:1px solid #444;padding-top:4px');
1800
+ for (const line of editorHelpLines)
1801
+ editorElement('div', help, 'margin:2px 0', line);
1802
+ button(help, 'Close', ()=> editorHelp = false, '?');
1803
+
1804
+ editorPanelParts = {pendingUnfit, reset, advancedToggle, advanced, playFromLabel, playFrom, sizeRow, sizeX, sizeY, resize, restart, pending, layerRow, allLayers, turns, palette, brush, properties, status, storage, hint, help, layers: undefined};
1805
+ }
1806
+
1807
+ // the palette's cell size in pixels and how many to a row
1808
+ const editorPaletteCell = 30, editorPaletteColumns = 8;
1809
+
1810
+ // the tiles a layer's palette shows, each its index and the tile info it draws with: the game's
1811
+ // levelEditor.paletteTiles, or every tile up to the image's edge without the blank ones at the end, a tile of one
1812
+ // flat color; found once for each layer and list
1813
+ function editorPaletteTiles(layer)
1814
+ {
1815
+ const live = layer?.live, image = live?.tileInfo?.textureInfo?.image, list = levelEditor.paletteTiles;
1816
+ if (list && live)
1817
+ {
1818
+ if (layer.palette?.list !== list)
1819
+ layer.palette = {list, tiles: list.map((tile)=> ({tile, tileInfo: editorTileInfo(live, tile)}))};
1820
+ return layer.palette.tiles;
1821
+ }
1822
+ if (!image) return [];
1823
+ if (layer.palette?.image === image) return layer.palette.tiles;
1824
+
1825
+ const tiles = [];
1826
+ for (let i = 0; i < 4096; ++i)
1827
+ {
1828
+ const t = editorTileInfo(live, i);
1829
+ if (t.pos.x + t.size.x > image.width || t.pos.y + t.size.y > image.height) break;
1830
+ tiles.push({tile: i, tileInfo: t});
1831
+ }
1832
+ try
1833
+ {
1834
+ // an image from a file page can not be read back, then every tile is kept
1835
+ const context = createCanvasContext(image.width, image.height, true);
1836
+ context.drawImage(image, 0, 0);
1837
+ const blank = (t)=>
1838
+ {
1839
+ const {pos, size} = t.tileInfo, pixels = new Uint32Array(context.getImageData(pos.x, pos.y, size.x,
1840
+ size.y).data.buffer);
1841
+ return pixels.every((p)=> p === pixels[0]);
1842
+ };
1843
+ while (tiles.length > 1 && blank(tiles.at(-1)))
1844
+ tiles.pop();
1845
+ }
1846
+ catch {}
1847
+ layer.palette = {image, tiles};
1848
+ return tiles;
1849
+ }
1850
+
1851
+ // draw the palette of object types, each its icon or the start of its name, the brush's type outlined
1852
+ function editorPaletteDrawObjects(canvas)
1853
+ {
1854
+ const cell = editorPaletteCell, columns = editorPaletteColumns, types = [...objectLayersTypes];
1855
+ canvas.style.display = types.length ? '' : 'none';
1856
+ canvas.width = columns * cell;
1857
+ canvas.height = ceil(types.length / columns) * cell;
1858
+ const context = canvas.getContext('2d');
1859
+ context.imageSmoothingEnabled = false;
1860
+ context.font = '10px monospace';
1861
+ context.textAlign = 'center';
1862
+ context.textBaseline = 'middle';
1863
+ types.forEach(([name, {tileInfo}], i)=>
1864
+ {
1865
+ const x = i % columns * cell, y = (i / columns | 0) * cell, image = tileInfo?.textureInfo?.image;
1866
+ if (image)
1867
+ context.drawImage(image, tileInfo.pos.x, tileInfo.pos.y, tileInfo.size.x, tileInfo.size.y,
1868
+ x + 2, y + 2, cell - 4, cell - 4);
1869
+ else
1870
+ {
1871
+ context.fillStyle = '#ccc';
1872
+ context.fillText(name.slice(0, 4), x + cell / 2, y + cell / 2);
1873
+ }
1874
+ if (editorObjectBrush?.length !== 1 || editorObjectBrush[0].type !== name) return;
1875
+ context.strokeStyle = '#4af';
1876
+ context.lineWidth = 2;
1877
+ context.strokeRect(x + 1, y + 1, cell - 2, cell - 2);
1878
+ });
1879
+ }
1880
+
1881
+ // draw the palette again, for a new layer or brush tile
1882
+ function editorPaletteDraw(canvas, layer)
1883
+ {
1884
+ const tiles = editorPaletteTiles(layer), cell = editorPaletteCell, columns = editorPaletteColumns;
1885
+ const image = layer?.live.tileInfo?.textureInfo?.image, slots = tiles.length + 1;
1886
+ canvas.style.display = tiles.length ? '' : 'none';
1887
+ canvas.width = columns * cell;
1888
+ canvas.height = ceil(slots / columns) * cell;
1889
+ const context = canvas.getContext('2d');
1890
+ context.imageSmoothingEnabled = false;
1891
+
1892
+ // slot 0 is the Erase brush, a red cross, the tiles follow
1893
+ context.strokeStyle = '#e44';
1894
+ context.lineWidth = 2;
1895
+ context.strokeRect(4, 4, cell - 8, cell - 8);
1896
+ context.beginPath();
1897
+ context.moveTo(8, 8), context.lineTo(cell - 8, cell - 8);
1898
+ context.moveTo(cell - 8, 8), context.lineTo(8, cell - 8);
1899
+ context.stroke();
1900
+ tiles.forEach(({tileInfo: {pos, size}}, i)=>
1901
+ {
1902
+ const slot = i + 1, x = slot % columns * cell, y = (slot / columns | 0) * cell;
1903
+ context.drawImage(image, pos.x, pos.y, size.x, size.y, x + 2, y + 2, cell - 4, cell - 4);
1904
+ });
1905
+
1906
+ // the brush's slot outlined, when it is one tile the palette shows or Erase
1907
+ const t = editorBrushTile(), erase = !t && editorBrush.width === 1 && editorBrush.height === 1 &&
1908
+ editorBrush.grids[0][0] === 0, shown = t ? tiles.findIndex((p)=> p.tile === t.tile) : -1;
1909
+ const selected = erase ? 0 : shown >= 0 ? shown + 1 : -1;
1910
+ if (selected < 0) return;
1911
+ context.strokeStyle = '#4af';
1912
+ context.strokeRect(selected % columns * cell + 1, (selected / columns | 0) * cell + 1, cell - 2, cell - 2);
1913
+ }
1914
+
1915
+ // while playing in an editing session, a tag in the corner says how to get back to the editor
1916
+ let editorTag;
1917
+ function editorTagUpdate()
1918
+ {
1919
+ const show = editorSession && !editorIsOpen;
1920
+ if (!show && !editorTag) return;
1921
+ editorTag ||= editorElement('div', document.body, 'position:fixed;bottom:8px;left:8px;padding:4px 8px;' +
1922
+ 'background:#111d;color:#eee;font:12px monospace;border-radius:4px;z-index:9999;pointer-events:none');
1923
+ editorTag.textContent = `${debugKey === 'Escape' ? 'Esc' : debugKey}: edit level`;
1924
+ editorTag.style.display = show ? '' : 'none';
1925
+ }
1926
+
1927
+ // shows or hides the panel, and shows what changed since the last frame
1928
+ function editorPanelUpdate()
1929
+ {
1930
+ editorTagUpdate();
1931
+ if (!editorIsOpen)
1932
+ {
1933
+ editorPanel && (editorPanel.style.display = 'none');
1934
+ return;
1935
+ }
1936
+ editorPanel || editorPanelInit();
1937
+ editorPanel.style.display = '';
1938
+ const p = editorPanelParts, layers = editorLayers();
1939
+
1940
+ // the layer buttons, made again when layers came or went
1941
+ if (!p.layers || layers.length !== p.layers.length || layers.some((layer, i)=> layer !== p.layers[i]))
1942
+ {
1943
+ p.layers = layers;
1944
+ p.layerRow.replaceChildren(...layers.map((layer, i)=>
1945
+ {
1946
+ // its number key and name, cut short when the row runs out of room, the whole name in its tooltip
1947
+ const name = layer.isObjects ? layer.name : layer.source.name;
1948
+ const b = editorElement('button', undefined, 'flex:1;min-width:28px;padding:3px;cursor:pointer;' +
1949
+ 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis', (i < 9 ? String(i + 1) : '·') +
1950
+ (name ? ' ' + name : ''));
1951
+ b.title = `${name || 'Layer ' + (i + 1)} (${layer.record.fileName})`;
1952
+ b.onclick = ()=> { editorSelectLayer(layer); b.blur(); };
1953
+ return b;
1954
+ }));
1955
+ }
1956
+ layers.forEach((layer, i)=> p.layerRow.children[i].style.outline =
1957
+ layer === (editorObjectLayer ?? editorLayer) ? '2px solid #4af' : '');
1958
+ p.allLayers.textContent = editorAllLayers ? 'All Layers: on' : 'All Layers: off';
1959
+ p.brush.textContent = editorBrushLabel();
1960
+ p.hint.textContent = editorHint();
1961
+ p.help.style.display = editorHelp ? '' : 'none';
1962
+ p.storage.style.display = editorSaveFailed ? '' : 'none';
1963
+ p.restart.style.display = levelEditor.onRestart ? '' : 'none';
1964
+ p.advancedToggle.textContent = editorAdvanced ? 'Advanced ▾' : 'Advanced ▸';
1965
+ p.advanced.style.display = editorAdvanced ? '' : 'none';
1966
+ p.playFromLabel.style.display = levelEditor.onPlayFrom ? 'flex' : 'none';
1967
+ p.playFrom.checked = editorPlayFromMouse;
1968
+
1969
+ // the level's size, shown until a field is being typed in; without the Restart hook it can not change
1970
+ const record = editorRecord(), map = record?.map;
1971
+ const canResize = !!levelEditor.onRestart && !record?.synthetic && !record?.pending;
1972
+ p.sizeRow.style.display = map ? 'flex' : 'none';
1973
+ if (map && !p.sizeRow.contains(document.activeElement))
1974
+ {
1975
+ p.sizeX.value = String(map.width);
1976
+ p.sizeY.value = String(map.height);
1977
+ }
1978
+ p.sizeX.disabled = p.sizeY.disabled = p.resize.disabled = !canResize;
1979
+ p.resize.title = canResize ? '' : 'Resizing needs levelEditor.onRestart, to make the level again';
1980
+ p.turns.style.display = editorObjectLayer ? 'none' : ''; // the tile brush's, not an object layer's
1981
+ p.pending.style.display = record?.pending ? '' : 'none';
1982
+ p.pendingUnfit.style.display = record?.pendingUnfit ? '' : 'none';
1983
+ p.reset.style.display = record?.synthetic ? 'none' : ''; // a layer made in code has no file
1984
+
1985
+ // the palette, drawn again when the layer or the brush changed, a change makes a new brush
1986
+ const live = editorLayer?.live;
1987
+ const paletteLayer = editorObjectLayer ?? editorLayer, paletteBrush = editorObjectLayer ? editorObjectBrush : editorBrush;
1988
+ if (p.paletteLayer !== paletteLayer || p.paletteBrush !== paletteBrush)
1989
+ {
1990
+ p.paletteLayer = paletteLayer;
1991
+ p.paletteBrush = paletteBrush;
1992
+ editorObjectLayer ? editorPaletteDrawObjects(p.palette) : editorPaletteDraw(p.palette, editorLayer);
1993
+ }
1994
+ editorPropertiesUpdate(p.properties);
1995
+
1996
+ const t = editorHover && editorGidToTile(editorLayer.source.data[editorHover.x +
1997
+ (live.size.y - 1 - editorHover.y) * live.size.x]);
1998
+ p.status.textContent = editorHover ? `cell ${editorHover.x}, ${editorHover.y}` +
1999
+ (t ? ` tile ${t.tile}` : '') : '';
2000
+ }
2001
+
2002
+ ///////////////////////////////////////////////////////////////////////////////
2003
+ // keys and wheel
2004
+
2005
+ // letter shortcuts, by the key's label so they follow the letter printed on any keyboard layout, each called
2006
+ // with whether Shift is held; one that returns false did nothing, and leaves the key to the browser
2007
+ const editorKeys =
2008
+ {
2009
+ r: (shift)=> editorObjectLayer ? false : editorBrush = editorStampTurn(editorBrush, shift),
2010
+ m: ()=> editorObjectLayer ? false : editorBrush = editorStampMirror(editorBrush),
2011
+ e: ()=> editorObjectLayer ? false : editorBrush = editorStampTile(0),
2012
+ g: ()=> { editorGrid = !editorGrid; },
2013
+ '?': ()=> { editorHelp = !editorHelp; },
2014
+ f: ()=> editorObjectLayer ? false : editorFill(),
2015
+ Delete: ()=> editorObjectLayer ? editorDeleteObjects() : editorClear(),
2016
+ Backspace: ()=> editorObjectLayer ? editorDeleteObjects() : editorClear(),
2017
+ };
2018
+ const editorCtrlKeys =
2019
+ {
2020
+ z: (shift)=> editorUndo(shift),
2021
+ y: ()=> editorUndo(true),
2022
+ c: ()=> editorObjectLayer ? editorCopyObjects() : editorCopy(),
2023
+ x: ()=> editorObjectLayer ? editorCutObjects() : editorCut(),
2024
+ v: ()=> editorObjectLayer ? editorPasteObjects() : editorPaste(),
2025
+ };
2026
+
2027
+ // a field that takes typed keys, as the engine counts one: a checkbox, slider or button left with focus, as a
2028
+ // tweak is, does not take them
2029
+ function editorIsTextField(target)
2030
+ {
2031
+ const field = target?.closest?.('input,textarea,select,[contenteditable]');
2032
+ return !!field && !(field.tagName === 'INPUT' &&
2033
+ /^(button|checkbox|color|file|image|radio|range|reset|submit)$/i.test(field.type));
2034
+ }
2035
+
2036
+ // the editor's own key listener, a key typed into a field is the field's, and Alt with Ctrl is AltGr typing
2037
+ // a character; a letter that is not a Latin one, on a Cyrillic or Greek keyboard, goes by the key's position
2038
+ function editorOnKeyDown(e)
2039
+ {
2040
+ if (!editorIsOpen || e.repeat || e.altKey || editorIsTextField(e.target)) return;
2041
+ if (editorSelectionDrag && !(debugKey && e.code === debugKey))
2042
+ {
2043
+ // a key ends a selection drag where it is, an undo of its own, so Delete or undo acts on what is there;
2044
+ // the debug key, Escape, is left to the update, which puts the selection back
2045
+ editorStrokeEnd();
2046
+ editorSelectionDrag = undefined;
2047
+ }
2048
+ let key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
2049
+ if (key.length === 1 && !/[a-z?]/.test(key))
2050
+ key = e.code?.match(/^Key([A-Z])$/)?.[1].toLowerCase() ?? key;
2051
+ const action = (e.ctrlKey || e.metaKey ? editorCtrlKeys : editorKeys)[key];
2052
+ if (action && action(e.shiftKey) !== false)
2053
+ e.preventDefault();
2054
+ }
2055
+
2056
+ // the wheel zooms toward the mouse by how far it moved; a trackpad pinch is a wheel with ctrlKey, which the engine
2057
+ // leaves alone
2058
+ function editorOnWheel(e)
2059
+ {
2060
+ if (!editorIsOpen || editorIsTextField(e.target)) return;
2061
+ const pixels = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1);
2062
+ editorZoom(Math.exp(-pixels * (e.ctrlKey ? .01 : .002)));
2063
+ }
2064
+
2065
+ // zoom by a factor, keeping the point under the mouse where it is
2066
+ function editorZoom(factor)
2067
+ {
2068
+ editorApplyCamera();
2069
+ const before = screenToWorld(mousePosScreen);
2070
+ editorCameraScale = clamp(editorCameraScale * factor, 1, 1e3);
2071
+ editorApplyCamera();
2072
+ editorCameraPos = editorCameraPos.add(before.subtract(screenToWorld(mousePosScreen)));
2073
+ editorApplyCamera();
2074
+ }
2075
+
2076
+ ///////////////////////////////////////////////////////////////////////////////
2077
+ // objects in the panel and the level
2078
+
2079
+ // the position a click places or drags to, a cell center unless the grid is off
2080
+ function editorSnap(pos) { return editorGrid ? pos.floor().add(vec2(.5)) : pos.copy(); }
2081
+
2082
+ // if the properties box has an input for a default's type, a number, boolean, string or Color
2083
+ const editorPropertyEditable = (value)=>
2084
+ ['number', 'boolean', 'string'].includes(typeof value) || isColor(value) || isVector2(value);
2085
+
2086
+ // set a property of the one selected object, from the properties box, as one undo
2087
+ function editorSetSelectedProperty(name, value)
2088
+ {
2089
+ const selected = editorSelectedObjects(), object = selected[0];
2090
+ const type = object && objectLayersTypes.get(object.type || object.class), defaultValue = type?.defaults[name];
2091
+ if (selected.length !== 1 || !editorPropertyEditable(defaultValue) ||
2092
+ (isColor(defaultValue) ? !isColor(value) : isVector2(defaultValue) ? !isVector2(value) :
2093
+ typeof value !== typeof defaultValue)) return false;
2094
+ editorStrokeEnd();
2095
+ editorChangeObjects(editorObjectLayer, (list)=>
2096
+ editorObjectSetProperty(list.find((o)=> o.id === object.id), name, value, type.defaults[name]));
2097
+ editorStrokeEnd();
2098
+ return true;
2099
+ }
2100
+
2101
+ // if the game has nothing where the level puts an object: gone in play, moved in play, or not a game object, like a
2102
+ // player start, so the editor draws its icon there
2103
+ function editorObjectIsGhost(layer, object)
2104
+ {
2105
+ const made = layer.instances.get(object.id);
2106
+ return !made || made.destroyed || !isVector2(made.pos) ||
2107
+ made.pos.distance(editorObjectPos(layer.record, object)) > .01;
2108
+ }
2109
+
2110
+ // a world box's outline
2111
+ function editorDrawBox(center, size, color, width)
2112
+ {
2113
+ const half = size.scale(.5), corners = [vec2(-1, -1), vec2(1, -1), vec2(1, 1), vec2(-1, 1)]
2114
+ .map((c)=> center.add(c.multiply(half)));
2115
+ corners.forEach((c, i)=> drawLine(c, corners[(i + 1) % 4], width, color, undefined, 0, glEnable, false));
2116
+ }
2117
+
2118
+ // an object type's icon, or a box with its name when it has none
2119
+ function editorDrawObjectIcon(name, pos, size, alpha)
2120
+ {
2121
+ const tileInfo = objectLayersTypes.get(name)?.tileInfo;
2122
+ if (tileInfo)
2123
+ drawTile(pos, size, tileInfo, hsl(0, 0, 1, alpha), 0, undefined, undefined, glEnable, false);
2124
+ else
2125
+ {
2126
+ drawRect(pos, size, hsl(0, 0, 0, alpha * .6), undefined, undefined, false);
2127
+ drawText(name ?? '?', pos, min(size.x, size.y) * .3, hsl(0, 0, 1, alpha));
2128
+ }
2129
+ }
2130
+
2131
+ // the objects of the object layer being edited: each one's outline, yellow when selected, a ghost of one the game does
2132
+ // not have where the level puts it, the box being dragged, and the brush's objects at the mouse
2133
+ function editorRenderObjects(thin)
2134
+ {
2135
+ const layer = editorObjectLayer, record = layer.record;
2136
+ for (const object of layer.group?.objects ?? [])
2137
+ {
2138
+ const pos = editorObjectPos(record, object), size = editorObjectSize(layer, object);
2139
+ if (editorObjectIsGhost(layer, object))
2140
+ editorDrawObjectIcon(object.type || object.class, pos, size, .5);
2141
+ const selected = editorObjectSelection.has(object.id);
2142
+ editorDrawBox(pos, size, selected ? hsl(.15, 1, .6) : hsl(.55, 1, .6, .6), thin * (selected ? 3 : 1.5));
2143
+ }
2144
+ const box = editorObjectBox;
2145
+ if (box)
2146
+ editorDrawBox(box.a.add(box.b).scale(.5), box.b.subtract(box.a).abs(), hsl(.15, 1, .6), thin * 2);
2147
+ else if (editorObjectBrush && !editorObjectDrag && !editorObjectSelection.size && !mouseIsDown(1))
2148
+ {
2149
+ const pos = editorSnap(screenToWorld(mousePosScreen));
2150
+ for (const object of editorObjectBrush)
2151
+ editorDrawObjectIcon(object.type, pos.add(object.offset), vec2(1), .5);
2152
+ }
2153
+ }
2154
+
2155
+ // the properties box: an input for each default of the one selected object's type, made again when the selection or
2156
+ // the object changes, but not while one of its inputs is being typed in
2157
+ function editorPropertiesUpdate(box)
2158
+ {
2159
+ const selected = editorObjectLayer ? editorSelectedObjects() : [], object = selected.length === 1 && selected[0];
2160
+ const type = object && objectLayersTypes.get(object.type || object.class);
2161
+ const key = type ? editorObjectLayer.record.fileName + object.id + JSON.stringify(object.properties) : '';
2162
+ box.style.display = key ? '' : 'none';
2163
+ if (box.dataset.key === key || box.contains(document.activeElement)) return;
2164
+ box.dataset.key = key;
2165
+ box.replaceChildren();
2166
+ if (!type) return;
2167
+
2168
+ editorElement('div', box, 'color:#aaa;margin-bottom:2px', `${object.type || object.class} ${object.id}`);
2169
+ const values = objectLayersProperties(type, object);
2170
+ for (const [name, defaultValue] of Object.entries(type.defaults))
2171
+ {
2172
+ const row = editorElement('label', box, 'display:flex;gap:6px;align-items:center;margin:2px 0');
2173
+ editorElement('span', row, 'flex:1', name);
2174
+ const value = values[name], input = editorElement('input', row, 'width:110px;background:#222;color:#eee');
2175
+ const set = (v)=> { editorSetSelectedProperty(name, v); input.blur(); };
2176
+ if (typeof defaultValue === 'boolean')
2177
+ {
2178
+ input.type = 'checkbox';
2179
+ input.checked = !!value;
2180
+ input.onchange = ()=> set(input.checked);
2181
+ }
2182
+ else if (isColor(defaultValue))
2183
+ {
2184
+ input.type = 'color';
2185
+ input.value = value.toString(false);
2186
+ input.onchange = ()=> { const color = rgb().setHex(input.value); color.a = value.a; set(color); };
2187
+ }
2188
+ else if (typeof defaultValue === 'number')
2189
+ {
2190
+ input.type = 'number';
2191
+ input.step = Number.isInteger(defaultValue) ? '1' : 'any';
2192
+ input.value = String(value);
2193
+ input.onchange = ()=> { const v = parseFloat(input.value); isNumber(v) && set(v); };
2194
+ }
2195
+ else if (typeof defaultValue === 'string')
2196
+ {
2197
+ input.type = 'text';
2198
+ input.value = String(value ?? '');
2199
+ input.onchange = ()=> set(input.value);
2200
+ }
2201
+ else if (isVector2(defaultValue))
2202
+ {
2203
+ // x and y, the row's second input for y
2204
+ const y = editorElement('input', row, 'width:52px;background:#222;color:#eee');
2205
+ input.style.width = '52px';
2206
+ for (const [field, v] of [[input, value.x], [y, value.y]])
2207
+ {
2208
+ field.type = 'number';
2209
+ field.step = 'any';
2210
+ field.value = String(v);
2211
+ field.onchange = ()=>
2212
+ {
2213
+ // both checked before a vector is made of them, an emptied or half typed field shows its value again
2214
+ const px = parseFloat(input.value), py = parseFloat(y.value);
2215
+ if (isNumber(px) && isNumber(py))
2216
+ set(vec2(px, py));
2217
+ else
2218
+ input.value = String(value.x), y.value = String(value.y);
2219
+ field.blur();
2220
+ };
2221
+ }
2222
+ }
2223
+ else
2224
+ {
2225
+ // a type it has no input for, an array say, shown as it is
2226
+ input.readOnly = true;
2227
+ input.value = String(value);
2228
+ }
2229
+ }
2230
+ }
2231
+
2232
+ ///////////////////////////////////////////////////////////////////////////////
2233
+ // plugin
2234
+
2235
+ function editorUpdate()
2236
+ {
2237
+ // Escape while dragging a selection puts it back, and stays in the editor
2238
+ if (editorSelectionDrag && debugKey && keyWasPressed(debugKey))
2239
+ {
2240
+ inputClearKey(debugKey);
2241
+ editorSelectionDragCancel();
2242
+ return;
2243
+ }
2244
+
2245
+ // in an editing session Escape, the debug key, switches between playing and editing; taken, so the debug
2246
+ // overlay waits till the session ends
2247
+ if (editorSession && debugKey && keyWasPressed(debugKey))
2248
+ {
2249
+ inputClearKey(debugKey);
2250
+ if (editorIsOpen)
2251
+ {
2252
+ editorApplyCamera(); // the mouse is read through the editor's view
2253
+ editorPlay(screenToWorld(mousePosScreen));
2254
+ }
2255
+ else
2256
+ editorSetOpen(true);
2257
+ return;
2258
+ }
2259
+ if (!editorIsOpen) return;
2260
+ editorApplyCamera();
2261
+
2262
+ // 0 exits the editor, the overlay's 0 does it while the overlay is open; cleared so debugKeysAlways
2263
+ // does not toggle it back in the same step
2264
+ if (!debugOverlay && keyWasPressed('Digit0'))
2265
+ {
2266
+ inputClearKey('Digit0');
2267
+ levelEditor.close();
2268
+ return;
2269
+ }
2270
+
2271
+ // 1 to 9 pick a layer from the back; taken, so debugKeysAlways does not also flip the debug views, the overlay
2272
+ // keeps them while it is open
2273
+ for (let i = 1; !debugOverlay && i <= 9; ++i)
2274
+ {
2275
+ if (!keyWasPressed('Digit' + i)) continue;
2276
+ inputClearKey('Digit' + i);
2277
+ const layer = editorLayers()[i - 1];
2278
+ layer && layer !== (editorObjectLayer ?? editorLayer) && editorSelectLayer(layer); // a drag does not carry across
2279
+ }
2280
+
2281
+ // the middle button, or Space with the left, drags the view
2282
+ const space = keyIsDown('Space');
2283
+ if (mouseIsDown(1) || space && mouseIsDown(0))
2284
+ editorCameraPos = editorCameraPos.subtract(screenToWorldDelta(mouseDeltaScreen));
2285
+ editorApplyCamera();
2286
+
2287
+ editorMouseOnPanel || (editorMouseScreen = mousePosScreen.copy());
2288
+ if (editorObjectLayer)
2289
+ return editorUpdateObjects(space);
2290
+ const layer = editorLayer?.live.destroyed ? undefined : editorLayer, mouse = screenToWorld(editorMouseScreen);
2291
+ editorHover = layer && !editorMouseOnPanel && editorCellAt(layer.live, mouse); // over the panel, a line starts again
2292
+
2293
+ // a right press while dragging the selection puts it back, and is taken so it does not pick or select
2294
+ if (editorSelectionDrag && mouseWasPressed(2))
2295
+ {
2296
+ inputClearKey(2);
2297
+ editorSelectionDragCancel();
2298
+ }
2299
+
2300
+ // the right button: a click picks the tile under the mouse, or clears the selection off the layer, a drag
2301
+ // selects an area; a release within half a cell of the press, or 4 pixels zoomed far out, is a click
2302
+ if (mouseWasPressed(2) && layer)
2303
+ editorRightPress = {screen: mousePosScreen.copy(), cell: editorCellClamped(layer.live, mouse), drag: false};
2304
+ const press = editorRightPress;
2305
+ if (press && layer)
2306
+ {
2307
+ press.drag ||= mousePosScreen.distance(press.screen) > max(4, editorCameraScale / 2); // a few pixels of wobble at any zoom
2308
+ if (press.drag)
2309
+ editorSelection = editorArea(press.cell, editorCellClamped(layer.live, mouse));
2310
+ if (!mouseIsDown(2))
2311
+ {
2312
+ if (!press.drag)
2313
+ editorHover ? editorPick(layer, editorHover) : editorSelection = undefined;
2314
+ editorRightPress = undefined;
2315
+ }
2316
+ }
2317
+ else
2318
+ editorRightPress = undefined;
2319
+
2320
+ // the left button paints; a press in the selection drags it, the first press outside it only clears it, Shift
2321
+ // draws a line from the last tile placed; a quick click let go before this step still reads as pressed
2322
+ if (mouseWasPressed(0) && !space && editorSelection)
2323
+ {
2324
+ const {min: a, max: b} = editorSelection, cell = editorHover;
2325
+ if (cell && !layer.record.pending && cell.x >= a.x && cell.y >= a.y && cell.x <= b.x && cell.y <= b.y)
2326
+ editorSelectionDragStart(layer, cell);
2327
+ else
2328
+ editorSelection = undefined;
2329
+ editorLeftSpent = true;
2330
+ }
2331
+ if (editorSelectionDrag && layer)
2332
+ editorSelectionDragTo(editorCellClamped(layer.live, mouse));
2333
+ const left = mouseIsDown(0) || mouseWasPressed(0);
2334
+ if (!editorHover)
2335
+ editorLastCell = undefined; // off the layer, coming back in starts the line again
2336
+ else if (left && !space && !editorLeftSpent && !editorRightPress)
2337
+ {
2338
+ const last = editorLastPlaced, shift = keyIsDown('ShiftLeft') || keyIsDown('ShiftRight');
2339
+ if (mouseWasPressed(0) && shift && last?.layer === layer)
2340
+ {
2341
+ editorPaintBetween(layer, last.pos, editorHover);
2342
+ editorLastCell = editorHover;
2343
+ }
2344
+ else
2345
+ editorPaintLine(layer, editorHover);
2346
+ if (mouseWasPressed(0) && editorBrush.objects)
2347
+ editorPlaceStampObjects(layer, editorHover, editorBrush); // once a click, not along the drag
2348
+ }
2349
+ if (!mouseIsDown(0))
2350
+ {
2351
+ editorStrokeEnd();
2352
+ editorLastCell = editorSelectionDrag = undefined;
2353
+ editorLeftSpent = false;
2354
+ }
2355
+ }
2356
+
2357
+ // switch to play and have the game rebuild its level from the changed map, when it has a hook for that
2358
+ function editorRestart()
2359
+ {
2360
+ if (!levelEditor.onRestart) return;
2361
+ editorSetOpen(false); // ends a held stroke, and hands back the game's pause and camera, Escape comes back
2362
+ levelEditor.onRestart();
2363
+ }
2364
+
2365
+ // called by the engine before the camera goes to WebGL, so the level is drawn with the editor's view, a game may move
2366
+ // the camera from gameUpdatePost, which runs while paused
2367
+ function editorPreRender() { editorIsOpen && editorApplyCamera(); }
2368
+
2369
+ // the layer's edge, or the map's for a level of objects alone, a grid when zoomed in, the map's tiles the layer does not
2370
+ // show, and the brush under the mouse
2371
+ function editorRender()
2372
+ {
2373
+ headlessMode || editorPanelUpdate();
2374
+ if (!editorIsOpen || headlessMode) return;
2375
+ const layer = editorLayer?.live.destroyed ? undefined : editorLayer;
2376
+ if (!layer && !editorObjectLayer) return;
2377
+
2378
+ // the area drawn, the tile layer's, or for a level of objects alone the map's, where objectLayersLoad puts it
2379
+ const map = editorRecord().map, live = layer?.live, source = layer?.source;
2380
+ const pos = live ? live.pos : vec2(), size = live ? live.size : vec2(map.width, map.height);
2381
+ const {x: width, y: height} = size;
2382
+
2383
+ // the cells on screen
2384
+ const low = screenToWorld(vec2(0, mainCanvasSize.y)).subtract(pos);
2385
+ const high = screenToWorld(vec2(mainCanvasSize.x, 0)).subtract(pos);
2386
+ const x0 = max(0, floor(low.x)), y0 = max(0, floor(low.y));
2387
+ const x1 = min(width, ceil(high.x)), y1 = min(height, ceil(high.y));
2388
+
2389
+ // ghosts, cells the map has that the layer does not show: markers a game made objects of, tiles broken in play
2390
+ const ghost = hsl(0, 0, 1, .4);
2391
+ for (let x = x0; live && !editorObjectLayer && x < x1; ++x)
2392
+ for (let y = y0; y < y1; ++y)
2393
+ {
2394
+ const t = editorGidToTile(source.data[x + (height - 1 - y) * width]);
2395
+ if (t && live.getData(vec2(x, y)).tile === undefined)
2396
+ drawTile(live.pos.add(vec2(x + .5, y + .5)), vec2(1), editorTileInfo(live, t.tile), ghost,
2397
+ t.direction * PI/2, t.mirror, undefined, glEnable, false);
2398
+ }
2399
+
2400
+ // a grid once the cells are big enough to see one
2401
+ const line = hsl(0, 0, 1, .12), thin = 1 / editorCameraScale;
2402
+ if (editorGrid && editorCameraScale >= 12)
2403
+ {
2404
+ for (let x = x0; x <= x1; ++x)
2405
+ drawLine(pos.add(vec2(x, y0)), pos.add(vec2(x, y1)), thin, line, undefined, 0, glEnable, false);
2406
+ for (let y = y0; y <= y1; ++y)
2407
+ drawLine(pos.add(vec2(x0, y)), pos.add(vec2(x1, y)), thin, line, undefined, 0, glEnable, false);
2408
+ }
2409
+
2410
+ // the layer's edge
2411
+ const outline = (a, b, color, width)=>
2412
+ {
2413
+ const corners = [vec2(a.x, a.y), vec2(b.x, a.y), vec2(b.x, b.y), vec2(a.x, b.y)];
2414
+ corners.forEach((c, i)=> drawLine(pos.add(c), pos.add(corners[(i + 1) % 4]), width, color, undefined, 0, glEnable, false));
2415
+ };
2416
+ outline(vec2(), size, hsl(.55, 1, .6, .8), thin * 2);
2417
+
2418
+ // the selection, and the line Shift would draw
2419
+ if (editorSelection && !editorObjectLayer)
2420
+ outline(editorSelection.min, editorSelection.max.add(vec2(1)), hsl(.15, 1, .6), thin * 3);
2421
+ const last = editorLastPlaced, shift = keyIsDown('ShiftLeft') || keyIsDown('ShiftRight');
2422
+ if (shift && editorHover && last?.layer === layer && !mouseIsDown(0))
2423
+ drawLine(live.pos.add(last.pos).add(vec2(.5)), live.pos.add(editorHover).add(vec2(.5)), thin * 2,
2424
+ hsl(.55, 1, .6, .6), undefined, 0, glEnable, false);
2425
+
2426
+ // the brush where it would paint, the Erase brush's cells in red
2427
+ if (editorHover)
2428
+ {
2429
+ const grid = editorStampGrid(layer, editorBrush), {width: w, height: h} = editorBrush;
2430
+ for (let y = h; y--;)
2431
+ for (let x = w; x--;)
2432
+ {
2433
+ const gid = grid[x + y * w], t = editorGidToTile(gid);
2434
+ const center = live.pos.add(editorHover).add(vec2(x + .5, y + .5));
2435
+ if (t)
2436
+ drawTile(center, vec2(1), editorTileInfo(live, t.tile), hsl(0, 0, 1, .7), t.direction * PI/2, t.mirror, undefined, glEnable, false);
2437
+ else if (gid === 0)
2438
+ drawRect(center, vec2(1), hsl(0, 1, .5, .3), undefined, undefined, false);
2439
+ }
2440
+ drawRect(live.pos.add(editorHover).add(vec2(w / 2, h / 2)), vec2(w, h), hsl(.55, 1, .6, .25), undefined, undefined, false);
2441
+ }
2442
+ editorObjectLayer && editorRenderObjects(thin);
2443
+ }
2444
+
2445
+ debug && engineAddPlugin(editorUpdate, editorRender);
2446
+
2447
+ // the editor's own listeners for letter shortcuts and the wheel, the engine's input reads keys by position
2448
+ if (debug && globalThis.document?.addEventListener)
2449
+ {
2450
+ document.addEventListener('keydown', editorOnKeyDown);
2451
+ document.addEventListener('wheel', editorOnWheel, {passive: true});
2452
+ }