littlejsengine 1.22.0 → 1.23.1

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.
@@ -31,32 +31,101 @@
31
31
  * @memberof Editor
32
32
  */
33
33
 
34
+ /**
35
+ * @typedef {Object} EditorToolAt - What the level editor gives the callbacks of a tool of the game's own
36
+ * @property {any} pos - Where the mouse is in the level: a Vector2 in the 2D editor; in the 3D one a Vector3 on
37
+ * the level or the ground, undefined when the mouse is over the panel
38
+ * @property {Vector2|undefined} cell - 2D: the tile cell under the mouse on the selected tile layer
39
+ * @property {any} ray - 3D: the mouse's Ray3D
40
+ * @property {boolean} shift - Is Shift held
41
+ * @property {boolean} ctrl - Is Ctrl held
42
+ * @memberof Editor
43
+ */
44
+
45
+ /**
46
+ * @typedef {Object} EditorTool - A tool of the game's own for the level editor, see LevelEditor.addTool
47
+ * @property {string} [key] - The key that picks it, as addKey spells one
48
+ * @property {string} [hint] - The hint line while it is on
49
+ * @property {function(EditorToolAt): any} [onPress] - The left button went down in the level; returning false
50
+ * says the press was not the tool's
51
+ * @property {function(EditorToolAt): any} [onDrag] - Each frame it is held
52
+ * @property {function(EditorToolAt): any} [onRelease] - It was let go
53
+ * @property {function(EditorToolAt): any} [onDraw] - Each frame the tool is on, to draw its cursor or preview
54
+ * @memberof Editor
55
+ */
56
+
57
+ /**
58
+ * @typedef {Object} EditorEdit2D - The 2D level editor's edit functions, levelEditor.edit2D
59
+ * @property {Object|undefined} map - The Tiled map of the selected layer
60
+ * @property {TileLayer|undefined} layer - The selected tile layer, undefined on an object layer
61
+ * @property {Array<Object>} objects - A copy of the selected object layer's objects, as Tiled has them
62
+ * @property {Vector2|undefined} hover - The cell under the mouse on the selected tile layer
63
+ * @property {Set<number>} selection - The selected objects' ids
64
+ * @property {function(Vector2, number, ...any): boolean} paint - Set a cell of the selected tile layer
65
+ * to a tile index, -1 erases, with a direction 0 to 3 and a mirror; false off the layer or with none
66
+ * @property {function(function(Array<Object>): void): boolean} changeObjects - Edit a copy of the selected object
67
+ * layer's objects; false when nothing changed
68
+ * @property {function(): void} strokeEnd - End the edit: one undo step, and the autosave
69
+ * @property {function(): void} strokeCancel - Take the edit being made back
70
+ * @property {function(function(): void): void} bulk - Many paints with one redraw a layer
71
+ * @property {function(...any): any} undo - Undo, or redo with true
72
+ * @property {function(): string} toJSON - The map as the text Save writes
73
+ * @memberof Editor
74
+ */
75
+
76
+ /**
77
+ * @typedef {Object} EditorEdit3D - The 3D level editor's edit functions, levelEditor.edit3D; a position, a
78
+ * rotation and a scale are Vector3, the rotation in degrees
79
+ * @property {Object|undefined} level - The level object being edited
80
+ * @property {Array<Object>} objects - Its objects, the list in the level: read it, edit through change
81
+ * @property {Set<number>} selection - The selected objects' ids
82
+ * @property {function(): Array<Object>} selected - The selected objects
83
+ * @property {function(number): any} made - What the game made for an object's id
84
+ * @property {function(function(Array<Object>): void): boolean} change - Edit a copy of the object list; false
85
+ * when nothing changed
86
+ * @property {function(string, function(any): any): boolean} changePart - Edit the level's scene, voxels, terrain
87
+ * or prefabs block
88
+ * @property {function(): void} strokeEnd - End the edit: one undo step, and the autosave
89
+ * @property {function(): void} strokeCancel - Take the edit being made back
90
+ * @property {function(string, any): number|undefined} place - Add an object of a type at a position, selected;
91
+ * its id, undefined when there is no such type or the level can not be edited
92
+ * @property {function(Object, ...any): void} setTransform - Write a place, a rotation and a scale on an
93
+ * object of the list given to change; one left out is kept
94
+ * @property {function(Object, string, any): void} setProperty - Write a property on an object of the list given
95
+ * to change, left out of the file when it is the type's default
96
+ * @property {function(Object): any} pos - An object's position
97
+ * @property {function(Object): any} rotation - Its rotation, in degrees
98
+ * @property {function(Object): any} scale - Its scale
99
+ * @property {function(): any} mousePoint - Where the mouse is on the level or the ground, undefined over the panel
100
+ * @property {function(...any): boolean} undo - Undo, or redo with true
101
+ * @property {function(): string} toJSON - The level as the text Save writes
102
+ * @memberof Editor
103
+ */
104
+
34
105
  /**
35
106
  * The level editor, open it to pause the game and edit its level, close it to play on with the changes
36
107
  * - 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
108
+ * - A game makes it its own: it sets the hooks, adds keys, panel buttons and tools, or extends this class and
109
+ * gives its own with setLevelEditor; the same calls work in the 2D editor and the 3D one, see EDITOR.md
110
+ * - In release builds LevelEditor is a stub that never opens: what a game adds is taken and never called
38
111
  * @memberof Editor
39
112
  * @example
40
113
  * levelEditor.onRestart = ()=> loadLevel(); // adds a Restart button that rebuilds the level
41
114
  * levelEditor.onTile = (layer, pos, tile)=> layer.setCollisionData(pos, tile === ladderTile ? -1 : tile ? 1 : 0);
115
+ * levelEditor.addKey('k', ()=> clearEnemies(), 'K: clear the enemies');
116
+ *
117
+ * // or as a class of the game's own
118
+ * class MyEditor extends LevelEditor
119
+ * {
120
+ * onRestart() { loadLevel(); }
121
+ * onDraw() { drawSpawnZones(); }
122
+ * }
123
+ * setLevelEditor(new MyEditor);
42
124
  */
43
125
  class LevelEditor
44
126
  {
45
127
  constructor()
46
128
  {
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
129
  /** @property {Array<number>|undefined} - The tiles the palette shows, in its order, for a sheet that also holds
61
130
  * sprites and art that are not level tiles; undefined shows every tile of the sheet
62
131
  * @type {Array<number>|undefined} */
@@ -65,15 +134,133 @@ class LevelEditor
65
134
  * 3D one when a level was loaded with level3DLoad and there is a Render3DPlugin
66
135
  * @type {boolean|undefined} */
67
136
  this.use3D = undefined;
137
+ /** @property {string|undefined} - The tool of the game's own that is on, one added with addTool, undefined
138
+ * while a tool of the editor's is
139
+ * @type {string|undefined} */
140
+ this.tool = undefined;
141
+ // what the game added: its keys by how they are spelled, its panel buttons and its tools by name
142
+ /** @type {Object<string, {action: function(boolean): any, help: string|undefined, warned: boolean}>} */
143
+ this.keys = {};
144
+ /** @type {Array<{label: string, onClick: Function, title: string}>} */
145
+ this.buttons = [];
146
+ /** @type {Object<string, EditorTool>} */
147
+ this.tools = {};
68
148
  }
69
149
 
150
+ /** What the game does when the 2D editor paints a tile, like setting its collision or its look the way the
151
+ * game does when it loads the level; set it or override it, without one the collision layer gets collision 1
152
+ * where there is a tile
153
+ * @param {TileLayer} layer - The layer painted
154
+ * @param {Vector2} pos - The cell's position in the layer
155
+ * @param {number|undefined} tile - The tile painted, undefined when erased */
156
+ onTile(layer, pos, tile) {}
157
+
158
+ /** Rebuild the level from what the editor changed; set it or override it and the editor has a Restart button,
159
+ * which calls it after switching to play */
160
+ onRestart() {}
161
+
162
+ /** Put the player at a position, a Vector2 in the 2D editor and a Vector3 in the 3D one; set it or override it
163
+ * and the editor has Play from mouse, which starts play there
164
+ * @param {any} pos */
165
+ onPlayFrom(pos) {}
166
+
167
+ /** Called when the editor opens, to set or override */
168
+ onOpen() {}
169
+
170
+ /** Called when the editor closes and the game plays on, to set or override */
171
+ onClose() {}
172
+
173
+ /** Called each frame while the editor is open, to set or override */
174
+ onUpdate() {}
175
+
176
+ /** Called while the editor draws the level, to draw overlays of the game's own in it, to set or override */
177
+ onDraw() {}
178
+
179
+ /** Called once when the editor's panel is made, with a box in it for the game's own controls, to set or override
180
+ * @param {HTMLElement} element */
181
+ onPanel(element) {}
182
+
183
+ /** Called by Save with the text of the file and its name; return true when the game kept it itself, and the
184
+ * editor writes no file, to set or override; it may be async
185
+ * @param {string} text
186
+ * @param {string} fileName
187
+ * @return {boolean|void|Promise<boolean|void>} */
188
+ onSave(text, fileName) { return false; }
189
+
70
190
  /** True while the editor is open, the game is paused under it
71
191
  * @return {boolean} */
72
- get isOpen() { return editorIsOpen; }
192
+ get isOpen() { return editorIsOpen || !!editorOther?.isOpen(); }
193
+
194
+ /** True when the editor in use is the 3D one, as use3D says or since a 3D level was loaded
195
+ * @return {boolean} */
196
+ get is3D() { return !!editorOther?.wanted(); }
197
+
198
+ /** The 2D editor's edit functions, for a key, a button or a tool of the game's own: map, the Tiled map; layer,
199
+ * the selected TileLayer; objects, a copy of the selected object layer's; hover, the cell under the mouse;
200
+ * selection, the selected objects' ids; paint(cell, tile, direction, mirror), a tile index, -1 erases;
201
+ * changeObjects((list)=> ...); strokeEnd() and strokeCancel(); bulk(()=> ...); undo(redo); toJSON()
202
+ * @return {EditorEdit2D} */
203
+ get edit2D() { return editorEdit2D; }
204
+
205
+ /** The 3D editor's edit functions, undefined without the 3D plugins: level; objects; selection, a Set of ids;
206
+ * selected(); made(id); change((list)=> ...); changePart(name, (part)=> ...); strokeEnd() and strokeCancel();
207
+ * place(type, pos3D); setTransform(object, pos3D, rotationDegrees, scale3D); setProperty(object, name, value);
208
+ * pos(object), rotation(object) and scale(object); mousePoint(); undo(redo); toJSON()
209
+ * @return {EditorEdit3D|undefined} */
210
+ get edit3D() { return editorOther?.edit; }
211
+
212
+ /** Add a key of the game's own to the editor, the 2D and the 3D one
213
+ * - Escape and 0 are the editor's own, to play and to exit, and Ctrl is the one modifier: Shift is given to
214
+ * the action
215
+ * @param {string} key - A letter or digit, 'k', or a key's name in any case, 'Delete', 'F2', 'Enter', 'Space';
216
+ * 'ctrl+k' with Ctrl or Cmd
217
+ * @param {function(boolean): any} action - Called with whether Shift is held; returning false says it did
218
+ * nothing, and the key is left to the browser
219
+ * @param {string} [helpLine] - A line for the editor's help */
220
+ addKey(key, action, helpLine)
221
+ {
222
+ ASSERT(typeof action == 'function', 'a key needs an action');
223
+ const spelled = editorKeySpelling(key), name = spelled.replace('ctrl+', '');
224
+ if (name === 'Escape' || name === '0' || name.length > 1 && name.includes('+'))
225
+ {
226
+ console.warn(`levelEditor.addKey: ${key} is not taken, ` + (name.includes('+') ?
227
+ 'Ctrl is the one modifier, Shift is given to the action' : 'Escape and 0 are the editor\'s own'));
228
+ return;
229
+ }
230
+ this.keys[spelled] = {action, help: helpLine, warned: false};
231
+ this === levelEditor && editorKeyClashes();
232
+ }
233
+
234
+ /** Add a button of the game's own to the editor's panel
235
+ * @param {string} label
236
+ * @param {Function} onClick
237
+ * @param {string} [title] - What it says when the mouse is over it */
238
+ addButton(label, onClick, title='') { this.buttons.push({label, onClick, title}); }
239
+
240
+ /** Add a tool of the game's own: a button in the panel by its name, and while it is on the left button in the
241
+ * level is its, not the editor's own tools'
242
+ * - Each callback is given {pos, cell, ray, shift, ctrl}: pos is where the mouse is in the level, a Vector2 in
243
+ * 2D and a Vector3 on the level or the ground in 3D; cell is the tile under it in 2D; ray is the mouse's
244
+ * in 3D
245
+ * - What the callbacks change through edit2D or edit3D is one undo: the editor ends the stroke at the release,
246
+ * and takes it back when the right button or Escape ends the press
247
+ * @param {string} name
248
+ * @param {EditorTool} tool - {key, hint, onPress, onDrag, onRelease, onDraw}, each optional; onPress returning
249
+ * false says the press was not the tool's */
250
+ addTool(name, tool)
251
+ {
252
+ ASSERT(!!tool && typeof tool == 'object', 'a tool is an object of callbacks');
253
+ const before = this.tools[name]; // added again, it takes the place of the first, its key too
254
+ before?.key && delete this.keys[editorKeySpelling(before.key)];
255
+ this.tools[name] = tool;
256
+ tool.key && this.addKey(tool.key, ()=> { this.tool = this.tool === name ? undefined : name; });
257
+ }
73
258
 
74
259
  /** Open the editor, pausing the game; until close(), Escape (the debug key) switches between playing and editing */
75
260
  open()
76
261
  {
262
+ if (editorOther?.wanted())
263
+ return editorOther.open();
77
264
  if (!editorSession)
78
265
  editorCameraScale = cameraScale; // a new session starts at the game's zoom, a return keeps the editor's
79
266
  editorSession = true;
@@ -84,6 +271,8 @@ class LevelEditor
84
271
  * overlay again */
85
272
  close()
86
273
  {
274
+ if (editorOther?.active())
275
+ return editorOther.close();
87
276
  editorSession = false;
88
277
  editorSetOpen(false);
89
278
  }
@@ -92,7 +281,118 @@ class LevelEditor
92
281
  /** The level editor, levelEditor.open() to edit the level, levelEditor.close() to play on with the changes
93
282
  * @type {LevelEditor}
94
283
  * @memberof Editor */
95
- const levelEditor = new LevelEditor;
284
+ let levelEditor = new LevelEditor;
285
+
286
+ /** Make a level editor of the game's own the one in use: an instance of a class that extends LevelEditor, with
287
+ * the game's hooks as its methods; set it before the editor opens
288
+ * @param {LevelEditor} editor
289
+ * @memberof Editor */
290
+ function setLevelEditor(editor)
291
+ {
292
+ ASSERT(editor instanceof LevelEditor, 'a level editor extends LevelEditor');
293
+ levelEditor.isOpen && console.warn('setLevelEditor: the editor is open, close it first');
294
+ levelEditor = editor;
295
+ }
296
+
297
+ // the other editor, the 3D one, when its plugin is there: it says if it is the one wanted and open, opens and
298
+ // closes, and has its edit functions; the 2D editor is this file
299
+ /** @type {{wanted: function(): boolean, isOpen: function(): boolean, active: function(): boolean, open: Function,
300
+ * close: Function, hasKey: function(string, boolean): boolean, edit: EditorEdit3D}|undefined} */
301
+ let editorOther;
302
+
303
+ // if the game gave a hook: set on the editor, or a method of its own class, not the empty one of LevelEditor
304
+ function editorHas(name)
305
+ {
306
+ const hook = levelEditor[name];
307
+ return typeof hook == 'function' && hook !== LevelEditor.prototype[name];
308
+ }
309
+
310
+ // a hook or a moment of the game's, called when it is one: a game may set one to undefined to take it away
311
+ function editorCall(name, ...args)
312
+ {
313
+ const hook = levelEditor[name];
314
+ return typeof hook == 'function' ? hook.apply(levelEditor, args) : undefined;
315
+ }
316
+
317
+ // the names keys have, by their lower case, so a game may spell one in any case
318
+ const editorKeyNames = {};
319
+ for (const name of ['Delete', 'Backspace', 'Enter', 'Tab', 'Escape', 'Space', 'Home', 'End', 'PageUp', 'PageDown',
320
+ 'Insert', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ...[...Array(12)].map((v, i)=> 'F' + (i + 1))])
321
+ editorKeyNames[name.toLowerCase()] = name;
322
+
323
+ // a key as addKey keeps it: a letter in lower case, a name as keys are named, 'ctrl+' in front with Ctrl
324
+ function editorKeySpelling(key)
325
+ {
326
+ const text = String(key), ctrl = text.trim().toLowerCase().startsWith('ctrl+');
327
+ let name = ctrl ? text.trim().slice(5) : text;
328
+ name = name === ' ' ? 'Space' : name.trim();
329
+ name = name.length === 1 ? name.toLowerCase() : editorKeyNames[name.toLowerCase()] ?? name;
330
+ return (ctrl ? 'ctrl+' : '') + name;
331
+ }
332
+
333
+ // say once, for each key of the game's own that is one of the open editor's too, that the game's takes its place:
334
+ // when the editor opens, or when the key is added while it is open, since the 2D and 3D editors' keys differ
335
+ function editorKeyClashes()
336
+ {
337
+ if (!levelEditor.isOpen) return;
338
+ for (const [spelled, key] of Object.entries(levelEditor.keys))
339
+ {
340
+ const ctrl = spelled.startsWith('ctrl+'), name = spelled.slice(ctrl ? 5 : 0);
341
+ // in 2D the digits 1 to 9 pick a layer
342
+ const clash = levelEditor.is3D ? editorOther.hasKey(name, ctrl) :
343
+ !!(ctrl ? editorCtrlKeys : editorKeys)[name] || !ctrl && name.length === 1 && name >= '1' && name <= '9';
344
+ if (key.warned || !clash) continue;
345
+ key.warned = true;
346
+ console.warn(`levelEditor.addKey: ${spelled} is a key of the editor's, the game's takes its place`);
347
+ }
348
+ }
349
+
350
+ // the help lines of the game's own keys
351
+ function editorGameHelpLines()
352
+ { return Object.values(levelEditor.keys).map((key)=> key.help).filter((line)=> !!line); }
353
+
354
+ // the tool of the game's own that is on, undefined when there is none
355
+ function editorGameTool() { return levelEditor.tool !== undefined ? levelEditor.tools[levelEditor.tool] : undefined; }
356
+
357
+ // the game's box of an editor's panel: a row of its tools and its buttons, made again when one was added, the
358
+ // tool that is on lit; and, the first time for a level editor, onPanel is given the box for controls of its own
359
+ const editorGamePanels = new WeakMap;
360
+ function editorGamePanel(box)
361
+ {
362
+ let state = editorGamePanels.get(box);
363
+ if (state?.owner !== levelEditor)
364
+ {
365
+ box.replaceChildren();
366
+ state = {owner: levelEditor, row: editorElement('div', box, 'display:flex;gap:4px;margin:4px 0;flex-wrap:wrap'),
367
+ key: '', buttons: {}};
368
+ editorGamePanels.set(box, state);
369
+ editorCall('onPanel', box);
370
+ }
371
+ const tools = Object.keys(levelEditor.tools);
372
+ const key = tools.map((name)=> name + '\t' + (levelEditor.tools[name].hint || '')).join('\n') + '\n\n' +
373
+ levelEditor.buttons.length;
374
+ if (state.key !== key)
375
+ {
376
+ state.key = key;
377
+ state.buttons = {};
378
+ const press = 'padding:3px 6px;cursor:pointer';
379
+ state.row.replaceChildren(...tools.map((name)=>
380
+ {
381
+ const b = state.buttons[name] = editorElement('button', undefined, press, name);
382
+ b.title = levelEditor.tools[name].hint || '';
383
+ b.onclick = ()=> { levelEditor.tool = levelEditor.tool === name ? undefined : name; b.blur?.(); };
384
+ return b;
385
+ }), ...levelEditor.buttons.map((button)=>
386
+ {
387
+ const b = editorElement('button', undefined, press, button.label);
388
+ b.title = button.title;
389
+ b.onclick = ()=> { button.onClick.call(levelEditor); b.blur?.(); };
390
+ return b;
391
+ }));
392
+ }
393
+ for (const name of tools)
394
+ state.buttons[name].style.outline = name === levelEditor.tool ? '2px solid #4af' : '';
395
+ }
96
396
 
97
397
  // if the editor is open, and if it was opened and not exited, while Escape switches between playing and editing
98
398
  let editorIsOpen = false, editorSession = false;
@@ -104,7 +404,7 @@ let editorPlayFromMouse = false, editorAdvanced = false;
104
404
  function editorPlay(pos)
105
405
  {
106
406
  editorSetOpen(false);
107
- editorPlayFromMouse && pos && levelEditor.onPlayFrom?.(pos.copy());
407
+ editorPlayFromMouse && pos && editorHas('onPlayFrom') && levelEditor.onPlayFrom(pos.copy());
108
408
  }
109
409
 
110
410
  // the game's pause and camera from before the editor opened, handed back when it closes
@@ -156,10 +456,13 @@ function editorSetOpen(open)
156
456
  layers.filter((layer)=> !layer.isObjects).at(-1));
157
457
  layers.includes(editorObjectLayer) || (editorObjectLayer = editorLayer ? undefined :
158
458
  layers.find((layer)=> layer.isObjects)); // a level of objects alone
459
+ editorCall('onOpen');
460
+ editorKeyClashes();
159
461
  }
160
462
  else
161
463
  {
162
464
  editorStrokeEnd();
465
+ editorToolHeld = false;
163
466
  editorSelection = editorSelectionDrag = editorRightPress = editorObjectDrag = editorObjectBox = undefined;
164
467
  editorObjectSelection.clear();
165
468
  editorMouseOnPanel = false; // the panel hides, with no mouseleave
@@ -168,6 +471,7 @@ function editorSetOpen(open)
168
471
  setCameraPos(state.cameraPos);
169
472
  setCameraScale(state.cameraScale);
170
473
  setCameraAngle(state.cameraAngle);
474
+ editorCall('onClose');
171
475
  }
172
476
  }
173
477
 
@@ -247,7 +551,7 @@ function editorMapRestore(map)
247
551
  if (!map.layers || data.some((layer)=> !Array.isArray(layer))) return map;
248
552
  const objects = editorObjectGroups(map.layers).map((group)=> group.objects ?? []);
249
553
  const url = editorFetchedURLs.get(map), hash = editorMapHash(data, objects, editorMapLayout(map));
250
- const fileName = url?.split(/[?#]/)[0].split('/').pop() || 'level.json';
554
+ const fileName = editorFileName(url, 'level.json');
251
555
  const record = {map, url, fileName, key: editorMapKey(map, url, hash), hash,
252
556
  original: data.map((layer)=> [...layer]), originalObjects: editorObjectsCopy(objects),
253
557
  originalSize: {width: map.width, height: map.height}, layers: []};
@@ -278,6 +582,8 @@ function editorMapRestore(map)
278
582
  editorRestoreObjects(map, saved);
279
583
  console.warn(`LittleJS editor: brought back unsaved edits to ${record.fileName}, ` +
280
584
  'Save in the editor (Esc then 0) writes them to the file');
585
+ saved.stale && console.warn(`LittleJS editor: they are older than the last edits to ${record.fileName}, ` +
586
+ 'which storage had no room for');
281
587
  }
282
588
  return map;
283
589
  }
@@ -438,7 +744,14 @@ async function editorSave(record, pickAgain=false)
438
744
  if (!record) return;
439
745
  editorStrokeEnd();
440
746
  const text = editorMapJSON(record); // the map as it is now, later edits wait for a later save
441
- const saved = (async ()=> { await record.saving; return editorSaveText(record, text, pickAgain); })();
747
+ // its place in line is taken now, and the game's hook is asked when its turn comes, so saves are written in
748
+ // the order they were asked for however long a hook takes
749
+ const saved = (async ()=>
750
+ {
751
+ await record.saving;
752
+ if (await editorCall('onSave', text, record.fileName) === true) return 'kept'; // the game kept it itself
753
+ return editorSaveText(record, text, pickAgain);
754
+ })();
442
755
  record.saving = saved.catch(()=> {});
443
756
  return saved;
444
757
  }
@@ -472,6 +785,7 @@ async function editorSaveText(record, text, pickAgain)
472
785
  if (error?.name === 'AbortError') return; // the picker was closed, nothing saved
473
786
  record.fileHandle = undefined; // a file it could not write, a download instead, and not kept
474
787
  editorFileStore.set(editorFileKey(record), undefined);
788
+ console.warn(`LittleJS editor: ${record.fileName} could not be written, downloaded instead`, error);
475
789
  }
476
790
  }
477
791
  saveText(text, record.fileName, 'application/json');
@@ -488,15 +802,35 @@ const editorSaves = ()=> readSaveData(editorSaveName(), {});
488
802
  // if the last autosave did not fit in storage, the panel says so
489
803
  let editorSaveFailed = false;
490
804
 
491
- // write the autosaves, noting whether storage took them
492
- function editorWriteSaves(saves)
805
+ // write the autosaves, noting whether storage took them; when it did not, the autosave it still has of this map
806
+ // is an older one, and is marked so, for the reload that brings it back to say so
807
+ function editorWriteSaves(saves, key, name=editorSaveName())
493
808
  {
494
809
  try
495
810
  {
496
- localStorage.setItem(editorSaveName(), JSON.stringify(saves));
497
- editorSaveFailed = false;
811
+ localStorage.setItem(name, JSON.stringify(saves));
812
+ return editorSaveFailed = false;
498
813
  }
499
814
  catch { editorSaveFailed = true; }
815
+ try
816
+ {
817
+ const kept = readSaveData(name, {});
818
+ if (!kept[key] || kept[key].stale) return true;
819
+ kept[key].stale = true;
820
+ localStorage.setItem(name, JSON.stringify(kept));
821
+ }
822
+ catch {} // no room for the mark either
823
+ return true;
824
+ }
825
+
826
+ // the name of the file a level was fetched from, as it is on disk: without its folder and query, and with what
827
+ // the url spells with a percent as it is written, so it is the name a picked file has
828
+ function editorFileName(url, fallback)
829
+ {
830
+ const name = url?.split(/[?#]/)[0].split('/').pop();
831
+ if (!name) return fallback;
832
+ try { return decodeURIComponent(name); }
833
+ catch { return name; }
500
834
  }
501
835
 
502
836
  // 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
@@ -552,17 +886,17 @@ function editorAutosave(record)
552
886
  else
553
887
  saves[record.key] = {hash: record.hash, savedHash: record.savedHash, width: map.width, height: map.height,
554
888
  layers: data, objects, nextobjectid: map.nextobjectid};
555
- editorWriteSaves(saves);
889
+ editorWriteSaves(saves, record.key);
556
890
  }
557
891
 
558
892
  // 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
559
893
  // object layers' objects from a list of them; data of another size resizes the map, which needs the Restart hook
560
- function editorPaintData(record, data, objects, width=record.map.width, height=record.map.height)
894
+ function editorPaintData(record, data, objects, width=record.map.width, height=record.map.height, keepUnknown=false)
561
895
  {
562
896
  editorStrokeEnd();
563
897
  if (width !== record.map.width || height !== record.map.height)
564
898
  {
565
- if (!levelEditor.onRestart) return;
899
+ if (!editorHas('onRestart')) return;
566
900
  const before = editorMapSnapshot(record);
567
901
  editorSetMapSnapshot(record, {width, height, layers: data, objects});
568
902
  editorMapChanged(record, before);
@@ -577,7 +911,9 @@ function editorPaintData(record, data, objects, width=record.map.width, height=r
577
911
  if (gids?.length === width * height)
578
912
  gids.forEach((gid, i)=> editorPaint(layer, vec2(i % width, height - 1 - (i / width | 0)), gid));
579
913
  }
580
- editorObjectLayers(record).forEach((layer, i)=> objects &&
914
+ // an autosave from before the file had an object layer leaves that layer as the file has it; Reset to file
915
+ // empties a layer the file did not have
916
+ editorObjectLayers(record).forEach((layer, i)=> objects && !(keepUnknown && i >= objects.length) &&
581
917
  editorChangeObjects(layer, (list)=> list.splice(0, list.length, ...editorObjectsCopy(objects[i] ?? []))));
582
918
  });
583
919
  editorStroke ? editorStrokeEnd() : editorAutosave(record);
@@ -597,7 +933,7 @@ function editorApplyPending(record)
597
933
  return false;
598
934
  }
599
935
  record.pending = record.pendingUnfit = undefined;
600
- editorPaintData(record, saved.layers, saved.objects, saved.width, saved.height);
936
+ editorPaintData(record, saved.layers, saved.objects, saved.width, saved.height, true);
601
937
  return true;
602
938
  }
603
939
 
@@ -607,7 +943,7 @@ function editorPendingFits(record, saved)
607
943
  {
608
944
  const map = record.map, data = editorTileLayerData(map.layers), groups = editorObjectGroups(map.layers).length;
609
945
  const width = saved.width ?? map.width, height = saved.height ?? map.height;
610
- return (width === map.width && height === map.height || !!levelEditor.onRestart) &&
946
+ return (width === map.width && height === map.height || editorHas('onRestart')) &&
611
947
  saved.layers?.length === data.length && saved.layers.every((layer)=> layer.length === width * height) &&
612
948
  (saved.objects ?? []).every((objects, i)=> i < groups || !objects.length);
613
949
  }
@@ -702,8 +1038,7 @@ function editorClearSelections()
702
1038
  function editorMapChanged(record, before)
703
1039
  {
704
1040
  const stroke = [{resize: record, before, after: editorMapSnapshot(record)}];
705
- editorUndoList.push(stroke);
706
- editorRedoList.length = 0;
1041
+ editorUndoPush(stroke);
707
1042
  editorChanged(stroke);
708
1043
  }
709
1044
 
@@ -711,7 +1046,7 @@ function editorMapChanged(record, before)
711
1046
  function editorResize(record, width, height)
712
1047
  {
713
1048
  const map = record?.map;
714
- if (!map || record.synthetic || record.pending || !levelEditor.onRestart) return false;
1049
+ if (!map || record.synthetic || record.pending || !editorHas('onRestart')) return false;
715
1050
  if (width === map.width && height === map.height) return false;
716
1051
  editorStrokeEnd();
717
1052
  const before = editorMapSnapshot(record);
@@ -765,7 +1100,7 @@ function editorSetCell(layer, pos, gid)
765
1100
  }
766
1101
  const t = editorGidToTile(gid);
767
1102
  live.setData(pos, t ? new TileLayerData(t.tile, t.direction, t.mirror, color) : new TileLayerData, true);
768
- if (levelEditor.onTile)
1103
+ if (editorHas('onTile'))
769
1104
  levelEditor.onTile(live, pos.copy(), t?.tile);
770
1105
  else if (live instanceof TileCollisionLayer && live.isSolid)
771
1106
  live.setCollisionData(pos, gid ? 1 : 0); // as tileLayersLoad gives it
@@ -780,12 +1115,20 @@ function editorPaint(layer, pos, gid)
780
1115
  (editorStroke ||= []).push({layer, pos: pos.copy(), before, after: gid});
781
1116
  }
782
1117
 
1118
+ // an edit goes on the undo list, which keeps the last 100, as the 3D editor's does: a fill of a large layer is
1119
+ // an entry for every cell, and a session of them would hold millions
1120
+ function editorUndoPush(stroke)
1121
+ {
1122
+ editorUndoList.push(stroke);
1123
+ editorUndoList.length > 100 && editorUndoList.shift();
1124
+ editorRedoList.length = 0;
1125
+ }
1126
+
783
1127
  // the stroke is done, it can be undone as one
784
1128
  function editorStrokeEnd()
785
1129
  {
786
1130
  if (!editorStroke) return;
787
- editorUndoList.push(editorStroke);
788
- editorRedoList.length = 0;
1131
+ editorUndoPush(editorStroke);
789
1132
  editorChanged(editorStroke);
790
1133
  editorStroke = undefined;
791
1134
  }
@@ -834,7 +1177,7 @@ function editorChanged(stroke)
834
1177
  editorRedraw(stroke);
835
1178
  for (const record of new Set(stroke.map(editorEntryRecord)))
836
1179
  editorAutosave(record);
837
- stroke.some((entry)=> entry.resize) && levelEditor.onRestart?.();
1180
+ stroke.some((entry)=> entry.resize) && editorHas('onRestart') && levelEditor.onRestart();
838
1181
  }
839
1182
 
840
1183
  // the tile layers a stroke touched draw again whole
@@ -1027,6 +1370,7 @@ let editorObjectDrag, editorObjectBox;
1027
1370
  function editorSelectLayer(layer)
1028
1371
  {
1029
1372
  editorStrokeEnd();
1373
+ levelEditor.tool = undefined; // a tool of the game's own is put down
1030
1374
  editorLastCell = editorObjectDrag = editorRightPress = editorObjectBox = undefined; // presses stay on their layer
1031
1375
  editorSelectionDrag = undefined; // a move so far is kept, the stroke ended
1032
1376
  editorObjectSelection.clear();
@@ -1194,7 +1538,7 @@ function editorUpdateObjects(space)
1194
1538
  if (!editorObjectSelection.has(hit.id))
1195
1539
  editorObjectSelection = new Set([hit.id]);
1196
1540
  editorObjectDrag = {start: snap(mouse), from: new Map(editorSelectedObjects().map((object)=>
1197
- [object.id, editorObjectPos(record, object)]))};
1541
+ [object.id, {x: object.x, y: object.y}]))};
1198
1542
  }
1199
1543
  else if (editorObjectSelection.size)
1200
1544
  editorObjectSelection.clear();
@@ -1209,8 +1553,10 @@ function editorUpdateObjects(space)
1209
1553
  {
1210
1554
  for (const object of list)
1211
1555
  {
1212
- const from = drag.from.get(object.id);
1213
- from && editorObjectSetPos(record, object, from.add(delta));
1556
+ // from the numbers it had, in the map's own units: through cells and back they pick up a hair
1557
+ const from = drag.from.get(object.id), {tilewidth=1, tileheight=1} = record.map;
1558
+ if (from)
1559
+ object.x = from.x + delta.x * tilewidth, object.y = from.y - delta.y * tileheight;
1214
1560
  }
1215
1561
  });
1216
1562
  }
@@ -1654,6 +2000,7 @@ const editorHelpLines =
1654
2000
  function editorHint()
1655
2001
  {
1656
2002
  if (keyIsDown('Space')) return 'Drag to pan';
2003
+ if (editorGameTool()) return editorGameTool().hint || levelEditor.tool;
1657
2004
  if (editorObjectLayer)
1658
2005
  return editorObjectSelection.size ? 'Selection: drag moves · Delete removes · Ctrl+C copy · Ctrl+X cut · click to clear' :
1659
2006
  'Left place / select · drag moves · Right pick / drag select · Delete removes';
@@ -1722,6 +2069,8 @@ function editorPanelInit()
1722
2069
  button(undo, 'Undo', ()=> editorUndo(), 'Ctrl+Z');
1723
2070
  button(undo, 'Redo', ()=> editorUndo(true), 'Ctrl+Y');
1724
2071
  button(undo, 'Keys', ()=> editorHelp = !editorHelp, 'Every control, ?');
2072
+ // the game's own tools, buttons and controls, near the top where the tools are
2073
+ const game = editorElement('div', editorPanel);
1725
2074
 
1726
2075
  // a file that changed under its autosave
1727
2076
  const pending = editorElement('div', editorPanel, 'padding:4px;margin:4px 0;background:#630;border-radius:3px');
@@ -1808,9 +2157,10 @@ function editorPanelInit()
1808
2157
  const help = editorElement('div', editorPanel, 'color:#aaa;margin-top:4px;border-top:1px solid #444;padding-top:4px');
1809
2158
  for (const line of editorHelpLines)
1810
2159
  editorElement('div', help, 'margin:2px 0', line);
2160
+ const gameHelp = editorElement('div', help);
1811
2161
  button(help, 'Close', ()=> editorHelp = false, '?');
1812
2162
 
1813
- 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};
2163
+ editorPanelParts = {game, gameHelp, pendingUnfit, reset, advancedToggle, advanced, playFromLabel, playFrom, sizeRow, sizeX, sizeY, resize, restart, pending, layerRow, allLayers, turns, palette, brush, properties, status, storage, hint, help, layers: undefined};
1814
2164
  }
1815
2165
 
1816
2166
  // the palette's cell size in pixels and how many to a row
@@ -1969,15 +2319,15 @@ function editorPanelUpdate()
1969
2319
  p.hint.textContent = editorHint();
1970
2320
  p.help.style.display = editorHelp ? '' : 'none';
1971
2321
  p.storage.style.display = editorSaveFailed ? '' : 'none';
1972
- p.restart.style.display = levelEditor.onRestart ? '' : 'none';
2322
+ p.restart.style.display = editorHas('onRestart') ? '' : 'none';
1973
2323
  p.advancedToggle.textContent = editorAdvanced ? 'Advanced ▾' : 'Advanced ▸';
1974
2324
  p.advanced.style.display = editorAdvanced ? '' : 'none';
1975
- p.playFromLabel.style.display = levelEditor.onPlayFrom ? 'flex' : 'none';
2325
+ p.playFromLabel.style.display = editorHas('onPlayFrom') ? 'flex' : 'none';
1976
2326
  p.playFrom.checked = editorPlayFromMouse;
1977
2327
 
1978
2328
  // the level's size, shown until a field is being typed in; without the Restart hook it can not change
1979
2329
  const record = editorRecord(), map = record?.map;
1980
- const canResize = !!levelEditor.onRestart && !record?.synthetic && !record?.pending;
2330
+ const canResize = editorHas('onRestart') && !record?.synthetic && !record?.pending;
1981
2331
  p.sizeRow.style.display = map ? 'flex' : 'none';
1982
2332
  if (map && !p.sizeRow.contains(document.activeElement))
1983
2333
  {
@@ -2047,6 +2397,7 @@ function editorIsTextField(target)
2047
2397
  function editorOnKeyDown(e)
2048
2398
  {
2049
2399
  if (!editorIsOpen || e.repeat || e.altKey || editorIsTextField(e.target)) return;
2400
+ if (editorToolHeld) return; // the keys wait for a tool of the game's own to let go
2050
2401
  if (editorSelectionDrag && !(debugKey && e.code === debugKey))
2051
2402
  {
2052
2403
  // a key ends a selection drag where it is, an undo of its own, so Delete or undo acts on what is there;
@@ -2054,10 +2405,12 @@ function editorOnKeyDown(e)
2054
2405
  editorStrokeEnd();
2055
2406
  editorSelectionDrag = undefined;
2056
2407
  }
2057
- let key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
2058
- if (key.length === 1 && !/[a-z?]/.test(key))
2408
+ let key = e.key === ' ' ? 'Space' : e.key.length === 1 ? e.key.toLowerCase() : e.key;
2409
+ if (key.length === 1 && !/[a-z?]/.test(key) && !levelEditor.keys[key])
2059
2410
  key = e.code?.match(/^Key([A-Z])$/)?.[1].toLowerCase() ?? key;
2060
- const action = (e.ctrlKey || e.metaKey ? editorCtrlKeys : editorKeys)[key];
2411
+ // a key of the game's own first, it takes the place of one of the editor's
2412
+ const ctrl = e.ctrlKey || e.metaKey, own = levelEditor.keys[(ctrl ? 'ctrl+' : '') + key];
2413
+ const action = own ? (shift)=> own.action.call(levelEditor, shift) : (ctrl ? editorCtrlKeys : editorKeys)[key];
2061
2414
  if (action && action(e.shiftKey) !== false)
2062
2415
  e.preventDefault();
2063
2416
  }
@@ -2251,6 +2604,15 @@ function editorUpdate()
2251
2604
  return;
2252
2605
  }
2253
2606
 
2607
+ // Escape while a tool of the game's own is held takes its press back, and stays in the editor
2608
+ if (editorToolHeld && debugKey && keyWasPressed(debugKey))
2609
+ {
2610
+ inputClearKey(debugKey);
2611
+ editorStrokeCancel();
2612
+ editorToolHeld = false;
2613
+ return;
2614
+ }
2615
+
2254
2616
  // in an editing session Escape, the debug key, switches between playing and editing; taken, so the debug
2255
2617
  // overlay waits till the session ends
2256
2618
  if (editorSession && debugKey && keyWasPressed(debugKey))
@@ -2281,7 +2643,7 @@ function editorUpdate()
2281
2643
  // keeps them while it is open
2282
2644
  for (let i = 1; !debugOverlay && i <= 9; ++i)
2283
2645
  {
2284
- if (!keyWasPressed('Digit' + i)) continue;
2646
+ if (!keyWasPressed('Digit' + i) || levelEditor.keys[i]) continue; // a digit of the game's own is its
2285
2647
  inputClearKey('Digit' + i);
2286
2648
  const layer = editorLayers()[i - 1];
2287
2649
  layer && layer !== (editorObjectLayer ?? editorLayer) && editorSelectLayer(layer); // a drag does not carry across
@@ -2294,6 +2656,8 @@ function editorUpdate()
2294
2656
  editorApplyCamera();
2295
2657
 
2296
2658
  editorMouseOnPanel || (editorMouseScreen = mousePosScreen.copy());
2659
+ editorCall('onUpdate');
2660
+ if (editorToolUpdate(space)) return; // a tool of the game's own has the mouse
2297
2661
  if (editorObjectLayer)
2298
2662
  return editorUpdateObjects(space);
2299
2663
  const layer = editorLayer?.live.destroyed ? undefined : editorLayer, mouse = screenToWorld(editorMouseScreen);
@@ -2363,10 +2727,87 @@ function editorUpdate()
2363
2727
  }
2364
2728
  }
2365
2729
 
2730
+ // a tool of the game's own: if its press is held, and what was picked in the editor when it was turned on, since
2731
+ // picking a layer, a tile or an object type puts the game's tool down
2732
+ let editorToolHeld = false, editorToolName, editorToolPicked = [];
2733
+
2734
+ // what a tool's callbacks are given: where the mouse is in the level, the cell under it on the selected tile
2735
+ // layer, and the keys held
2736
+ function editorToolAt()
2737
+ {
2738
+ const layer = editorObjectLayer || editorLayer?.live.destroyed ? undefined : editorLayer;
2739
+ const pos = screenToWorld(editorMouseScreen);
2740
+ return {pos, cell: layer && !editorMouseOnPanel ? editorCellAt(layer.live, pos) || undefined : undefined,
2741
+ ray: undefined, shift: keyIsDown('ShiftLeft') || keyIsDown('ShiftRight'),
2742
+ ctrl: keyIsDown('ControlLeft') || keyIsDown('ControlRight')};
2743
+ }
2744
+
2745
+ // the game's tool, when one is on: the left button is its, a press, a drag and a release one undo, the right
2746
+ // button takes a held press back; true when it has the mouse
2747
+ function editorToolUpdate(space)
2748
+ {
2749
+ const picked = [editorLayer, editorObjectLayer, editorBrush, editorObjectBrush];
2750
+ if (levelEditor.tool !== editorToolName)
2751
+ editorToolName = levelEditor.tool, editorToolPicked = picked; // turned on, or off, by its key or button
2752
+ else if (picked.some((value, i)=> value !== editorToolPicked[i]))
2753
+ levelEditor.tool = editorToolName = undefined; // something of the editor's was picked
2754
+ const tool = editorGameTool();
2755
+ if (!tool)
2756
+ {
2757
+ editorToolHeld = false;
2758
+ return false;
2759
+ }
2760
+ const at = editorToolAt();
2761
+ editorHover = at.cell;
2762
+ editorRightPress = undefined; // a right press from before the tool was on is not the editor's to finish
2763
+ if (editorToolHeld && mouseWasPressed(2))
2764
+ {
2765
+ inputClearKey(2);
2766
+ editorStrokeCancel();
2767
+ editorToolHeld = false;
2768
+ }
2769
+ else if (mouseWasPressed(0) && !space && !editorMouseOnPanel)
2770
+ editorToolHeld = tool.onPress?.(at) !== false;
2771
+ else if (editorToolHeld && mouseIsDown(0))
2772
+ tool.onDrag?.(at);
2773
+ if (editorToolHeld && !mouseIsDown(0))
2774
+ {
2775
+ tool.onRelease?.(at);
2776
+ editorStrokeEnd();
2777
+ editorToolHeld = false;
2778
+ }
2779
+ return true;
2780
+ }
2781
+
2782
+ // the 2D editor's edit functions, what levelEditor.edit2D gives a game's own keys, buttons and tools
2783
+ const editorEdit2D =
2784
+ {
2785
+ get map() { return editorRecord()?.map; },
2786
+ get layer() { return editorObjectLayer ? undefined : editorLayer?.live; },
2787
+ get objects() { return editorObjectLayer ? editorObjectsCopy(editorObjectList(editorObjectLayer)) : []; },
2788
+ get hover() { return editorHover && !editorObjectLayer ? editorHover.copy() : undefined; },
2789
+ get selection() { return editorObjectSelection; },
2790
+ paint(cell, tile, direction=0, mirror=false)
2791
+ {
2792
+ const layer = editorObjectLayer ? undefined : editorLayer, size = layer?.live.size;
2793
+ if (!layer || layer.live.destroyed || layer.record.pending || !isVector2(cell)) return false;
2794
+ const x = floor(cell.x), y = floor(cell.y);
2795
+ if (x < 0 || y < 0 || x >= size.x || y >= size.y) return false;
2796
+ editorPaint(layer, vec2(x, y), tile < 0 ? 0 : editorTileToGid(tile, direction & 3, mirror));
2797
+ return true;
2798
+ },
2799
+ changeObjects(change) { return editorChangeObjects(editorObjectLayer, change); },
2800
+ strokeEnd() { editorStrokeEnd(); },
2801
+ strokeCancel() { editorStrokeCancel(); },
2802
+ bulk(edit) { editorBulkEdit(edit); },
2803
+ undo(redo=false) { return editorUndo(redo); },
2804
+ toJSON() { const record = editorRecord(); return record ? editorMapJSON(record) : ''; },
2805
+ };
2806
+
2366
2807
  // switch to play and have the game rebuild its level from the changed map, when it has a hook for that
2367
2808
  function editorRestart()
2368
2809
  {
2369
- if (!levelEditor.onRestart) return;
2810
+ if (!editorHas('onRestart')) return;
2370
2811
  editorSetOpen(false); // ends a held stroke, and hands back the game's pause and camera, Escape comes back
2371
2812
  levelEditor.onRestart();
2372
2813
  }
@@ -2380,6 +2821,11 @@ function editorPreRender() { editorIsOpen && editorApplyCamera(); }
2380
2821
  function editorRender()
2381
2822
  {
2382
2823
  headlessMode || editorPanelUpdate();
2824
+ if (!headlessMode && editorIsOpen && editorPanelParts)
2825
+ {
2826
+ editorGamePanel(editorPanelParts.game);
2827
+ editorGameHelp(editorPanelParts.gameHelp);
2828
+ }
2383
2829
  if (!editorIsOpen || headlessMode) return;
2384
2830
  const layer = editorLayer?.live.destroyed ? undefined : editorLayer;
2385
2831
  if (!layer && !editorObjectLayer) return;
@@ -2428,12 +2874,12 @@ function editorRender()
2428
2874
  if (editorSelection && !editorObjectLayer)
2429
2875
  outline(editorSelection.min, editorSelection.max.add(vec2(1)), hsl(.15, 1, .6), thin * 3);
2430
2876
  const last = editorLastPlaced, shift = keyIsDown('ShiftLeft') || keyIsDown('ShiftRight');
2431
- if (shift && editorHover && last?.layer === layer && !mouseIsDown(0))
2877
+ if (shift && editorHover && last?.layer === layer && !mouseIsDown(0) && !editorGameTool())
2432
2878
  drawLine(live.pos.add(last.pos).add(vec2(.5)), live.pos.add(editorHover).add(vec2(.5)), thin * 2,
2433
2879
  hsl(.55, 1, .6, .6), undefined, 0, glEnable, false);
2434
2880
 
2435
- // the brush where it would paint, the Erase brush's cells in red
2436
- if (editorHover)
2881
+ // the brush where it would paint, the Erase brush's cells in red; a tool of the game's own draws its own
2882
+ if (editorHover && !editorGameTool())
2437
2883
  {
2438
2884
  const grid = editorStampGrid(layer, editorBrush), {width: w, height: h} = editorBrush;
2439
2885
  for (let y = h; y--;)
@@ -2451,7 +2897,24 @@ function editorRender()
2451
2897
  editorObjectLayer && editorRenderObjects(thin);
2452
2898
  }
2453
2899
 
2454
- debug && engineAddPlugin(editorUpdate, editorRender);
2900
+ // the game's own drawing in the level, over the editor's: the editor's onDraw, and its tool's while one is on
2901
+ function editorRenderGame()
2902
+ {
2903
+ if (!editorIsOpen) return;
2904
+ editorCall('onDraw');
2905
+ editorGameTool()?.onDraw?.(editorToolAt());
2906
+ }
2907
+
2908
+ // the lines of the game's own keys in a panel's help, made again when they change
2909
+ function editorGameHelp(element)
2910
+ {
2911
+ const lines = editorGameHelpLines(), key = lines.join('\n');
2912
+ if (element.dataset.key === key) return;
2913
+ element.dataset.key = key;
2914
+ element.replaceChildren(...lines.map((line)=> editorElement('div', undefined, 'margin:2px 0', line)));
2915
+ }
2916
+
2917
+ debug && engineAddPlugin(editorUpdate, ()=> { editorRender(); editorRenderGame(); });
2455
2918
 
2456
2919
  // the editor's own listeners for letter shortcuts and the wheel, the engine's input reads keys by position
2457
2920
  if (debug && globalThis.document?.addEventListener)