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