littlejsengine 1.21.0 → 1.23.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.
@@ -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
185
+ * @param {string} text
186
+ * @param {string} fileName
187
+ * @return {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
 
@@ -432,12 +736,28 @@ function editorSetBaseline(record, written)
432
736
  // save a map as Tiled JSON: where the browser lets a page write files, Chrome and Edge, to a file picked once and
433
737
  // written again on each Save after, even after a reload once the browser gives permission, or picked again with
434
738
  // Save As; elsewhere as a download under the name of the file it came from; resolves to how it saved, undefined
435
- // when the picker was closed
739
+ // when the picker was closed; saves of a map run one at a time in the order asked, so the file ends with the last
436
740
  async function editorSave(record, pickAgain=false)
437
741
  {
438
742
  if (!record) return;
439
743
  editorStrokeEnd();
440
- const text = editorMapJSON(record), picker = /** @type {any} */ (globalThis).showSaveFilePicker;
744
+ const text = editorMapJSON(record); // the map as it is now, later edits wait for a later save
745
+ // its place in line is taken now, and the game's hook is asked when its turn comes, so saves are written in
746
+ // the order they were asked for however long a hook takes
747
+ const saved = (async ()=>
748
+ {
749
+ await record.saving;
750
+ if (await editorCall('onSave', text, record.fileName) === true) return 'kept'; // the game kept it itself
751
+ return editorSaveText(record, text, pickAgain);
752
+ })();
753
+ record.saving = saved.catch(()=> {});
754
+ return saved;
755
+ }
756
+
757
+ // write one save of a map, the one before it done
758
+ async function editorSaveText(record, text, pickAgain)
759
+ {
760
+ const picker = /** @type {any} */ (globalThis).showSaveFilePicker;
441
761
  if (picker)
442
762
  {
443
763
  try
@@ -553,7 +873,7 @@ function editorPaintData(record, data, objects, width=record.map.width, height=r
553
873
  editorStrokeEnd();
554
874
  if (width !== record.map.width || height !== record.map.height)
555
875
  {
556
- if (!levelEditor.onRestart) return;
876
+ if (!editorHas('onRestart')) return;
557
877
  const before = editorMapSnapshot(record);
558
878
  editorSetMapSnapshot(record, {width, height, layers: data, objects});
559
879
  editorMapChanged(record, before);
@@ -598,7 +918,7 @@ function editorPendingFits(record, saved)
598
918
  {
599
919
  const map = record.map, data = editorTileLayerData(map.layers), groups = editorObjectGroups(map.layers).length;
600
920
  const width = saved.width ?? map.width, height = saved.height ?? map.height;
601
- return (width === map.width && height === map.height || !!levelEditor.onRestart) &&
921
+ return (width === map.width && height === map.height || editorHas('onRestart')) &&
602
922
  saved.layers?.length === data.length && saved.layers.every((layer)=> layer.length === width * height) &&
603
923
  (saved.objects ?? []).every((objects, i)=> i < groups || !objects.length);
604
924
  }
@@ -702,7 +1022,7 @@ function editorMapChanged(record, before)
702
1022
  function editorResize(record, width, height)
703
1023
  {
704
1024
  const map = record?.map;
705
- if (!map || record.synthetic || record.pending || !levelEditor.onRestart) return false;
1025
+ if (!map || record.synthetic || record.pending || !editorHas('onRestart')) return false;
706
1026
  if (width === map.width && height === map.height) return false;
707
1027
  editorStrokeEnd();
708
1028
  const before = editorMapSnapshot(record);
@@ -756,7 +1076,7 @@ function editorSetCell(layer, pos, gid)
756
1076
  }
757
1077
  const t = editorGidToTile(gid);
758
1078
  live.setData(pos, t ? new TileLayerData(t.tile, t.direction, t.mirror, color) : new TileLayerData, true);
759
- if (levelEditor.onTile)
1079
+ if (editorHas('onTile'))
760
1080
  levelEditor.onTile(live, pos.copy(), t?.tile);
761
1081
  else if (live instanceof TileCollisionLayer && live.isSolid)
762
1082
  live.setCollisionData(pos, gid ? 1 : 0); // as tileLayersLoad gives it
@@ -825,7 +1145,7 @@ function editorChanged(stroke)
825
1145
  editorRedraw(stroke);
826
1146
  for (const record of new Set(stroke.map(editorEntryRecord)))
827
1147
  editorAutosave(record);
828
- stroke.some((entry)=> entry.resize) && levelEditor.onRestart?.();
1148
+ stroke.some((entry)=> entry.resize) && editorHas('onRestart') && levelEditor.onRestart();
829
1149
  }
830
1150
 
831
1151
  // the tile layers a stroke touched draw again whole
@@ -1018,6 +1338,7 @@ let editorObjectDrag, editorObjectBox;
1018
1338
  function editorSelectLayer(layer)
1019
1339
  {
1020
1340
  editorStrokeEnd();
1341
+ levelEditor.tool = undefined; // a tool of the game's own is put down
1021
1342
  editorLastCell = editorObjectDrag = editorRightPress = editorObjectBox = undefined; // presses stay on their layer
1022
1343
  editorSelectionDrag = undefined; // a move so far is kept, the stroke ended
1023
1344
  editorObjectSelection.clear();
@@ -1645,6 +1966,7 @@ const editorHelpLines =
1645
1966
  function editorHint()
1646
1967
  {
1647
1968
  if (keyIsDown('Space')) return 'Drag to pan';
1969
+ if (editorGameTool()) return editorGameTool().hint || levelEditor.tool;
1648
1970
  if (editorObjectLayer)
1649
1971
  return editorObjectSelection.size ? 'Selection: drag moves · Delete removes · Ctrl+C copy · Ctrl+X cut · click to clear' :
1650
1972
  'Left place / select · drag moves · Right pick / drag select · Delete removes';
@@ -1713,6 +2035,8 @@ function editorPanelInit()
1713
2035
  button(undo, 'Undo', ()=> editorUndo(), 'Ctrl+Z');
1714
2036
  button(undo, 'Redo', ()=> editorUndo(true), 'Ctrl+Y');
1715
2037
  button(undo, 'Keys', ()=> editorHelp = !editorHelp, 'Every control, ?');
2038
+ // the game's own tools, buttons and controls, near the top where the tools are
2039
+ const game = editorElement('div', editorPanel);
1716
2040
 
1717
2041
  // a file that changed under its autosave
1718
2042
  const pending = editorElement('div', editorPanel, 'padding:4px;margin:4px 0;background:#630;border-radius:3px');
@@ -1799,9 +2123,10 @@ function editorPanelInit()
1799
2123
  const help = editorElement('div', editorPanel, 'color:#aaa;margin-top:4px;border-top:1px solid #444;padding-top:4px');
1800
2124
  for (const line of editorHelpLines)
1801
2125
  editorElement('div', help, 'margin:2px 0', line);
2126
+ const gameHelp = editorElement('div', help);
1802
2127
  button(help, 'Close', ()=> editorHelp = false, '?');
1803
2128
 
1804
- editorPanelParts = {pendingUnfit, reset, advancedToggle, advanced, playFromLabel, playFrom, sizeRow, sizeX, sizeY, resize, restart, pending, layerRow, allLayers, turns, palette, brush, properties, status, storage, hint, help, layers: undefined};
2129
+ 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};
1805
2130
  }
1806
2131
 
1807
2132
  // the palette's cell size in pixels and how many to a row
@@ -1960,15 +2285,15 @@ function editorPanelUpdate()
1960
2285
  p.hint.textContent = editorHint();
1961
2286
  p.help.style.display = editorHelp ? '' : 'none';
1962
2287
  p.storage.style.display = editorSaveFailed ? '' : 'none';
1963
- p.restart.style.display = levelEditor.onRestart ? '' : 'none';
2288
+ p.restart.style.display = editorHas('onRestart') ? '' : 'none';
1964
2289
  p.advancedToggle.textContent = editorAdvanced ? 'Advanced ▾' : 'Advanced ▸';
1965
2290
  p.advanced.style.display = editorAdvanced ? '' : 'none';
1966
- p.playFromLabel.style.display = levelEditor.onPlayFrom ? 'flex' : 'none';
2291
+ p.playFromLabel.style.display = editorHas('onPlayFrom') ? 'flex' : 'none';
1967
2292
  p.playFrom.checked = editorPlayFromMouse;
1968
2293
 
1969
2294
  // the level's size, shown until a field is being typed in; without the Restart hook it can not change
1970
2295
  const record = editorRecord(), map = record?.map;
1971
- const canResize = !!levelEditor.onRestart && !record?.synthetic && !record?.pending;
2296
+ const canResize = editorHas('onRestart') && !record?.synthetic && !record?.pending;
1972
2297
  p.sizeRow.style.display = map ? 'flex' : 'none';
1973
2298
  if (map && !p.sizeRow.contains(document.activeElement))
1974
2299
  {
@@ -2038,6 +2363,7 @@ function editorIsTextField(target)
2038
2363
  function editorOnKeyDown(e)
2039
2364
  {
2040
2365
  if (!editorIsOpen || e.repeat || e.altKey || editorIsTextField(e.target)) return;
2366
+ if (editorToolHeld) return; // the keys wait for a tool of the game's own to let go
2041
2367
  if (editorSelectionDrag && !(debugKey && e.code === debugKey))
2042
2368
  {
2043
2369
  // a key ends a selection drag where it is, an undo of its own, so Delete or undo acts on what is there;
@@ -2045,10 +2371,12 @@ function editorOnKeyDown(e)
2045
2371
  editorStrokeEnd();
2046
2372
  editorSelectionDrag = undefined;
2047
2373
  }
2048
- let key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
2049
- if (key.length === 1 && !/[a-z?]/.test(key))
2374
+ let key = e.key === ' ' ? 'Space' : e.key.length === 1 ? e.key.toLowerCase() : e.key;
2375
+ if (key.length === 1 && !/[a-z?]/.test(key) && !levelEditor.keys[key])
2050
2376
  key = e.code?.match(/^Key([A-Z])$/)?.[1].toLowerCase() ?? key;
2051
- const action = (e.ctrlKey || e.metaKey ? editorCtrlKeys : editorKeys)[key];
2377
+ // a key of the game's own first, it takes the place of one of the editor's
2378
+ const ctrl = e.ctrlKey || e.metaKey, own = levelEditor.keys[(ctrl ? 'ctrl+' : '') + key];
2379
+ const action = own ? (shift)=> own.action.call(levelEditor, shift) : (ctrl ? editorCtrlKeys : editorKeys)[key];
2052
2380
  if (action && action(e.shiftKey) !== false)
2053
2381
  e.preventDefault();
2054
2382
  }
@@ -2242,6 +2570,15 @@ function editorUpdate()
2242
2570
  return;
2243
2571
  }
2244
2572
 
2573
+ // Escape while a tool of the game's own is held takes its press back, and stays in the editor
2574
+ if (editorToolHeld && debugKey && keyWasPressed(debugKey))
2575
+ {
2576
+ inputClearKey(debugKey);
2577
+ editorStrokeCancel();
2578
+ editorToolHeld = false;
2579
+ return;
2580
+ }
2581
+
2245
2582
  // in an editing session Escape, the debug key, switches between playing and editing; taken, so the debug
2246
2583
  // overlay waits till the session ends
2247
2584
  if (editorSession && debugKey && keyWasPressed(debugKey))
@@ -2272,7 +2609,7 @@ function editorUpdate()
2272
2609
  // keeps them while it is open
2273
2610
  for (let i = 1; !debugOverlay && i <= 9; ++i)
2274
2611
  {
2275
- if (!keyWasPressed('Digit' + i)) continue;
2612
+ if (!keyWasPressed('Digit' + i) || levelEditor.keys[i]) continue; // a digit of the game's own is its
2276
2613
  inputClearKey('Digit' + i);
2277
2614
  const layer = editorLayers()[i - 1];
2278
2615
  layer && layer !== (editorObjectLayer ?? editorLayer) && editorSelectLayer(layer); // a drag does not carry across
@@ -2285,6 +2622,8 @@ function editorUpdate()
2285
2622
  editorApplyCamera();
2286
2623
 
2287
2624
  editorMouseOnPanel || (editorMouseScreen = mousePosScreen.copy());
2625
+ editorCall('onUpdate');
2626
+ if (editorToolUpdate(space)) return; // a tool of the game's own has the mouse
2288
2627
  if (editorObjectLayer)
2289
2628
  return editorUpdateObjects(space);
2290
2629
  const layer = editorLayer?.live.destroyed ? undefined : editorLayer, mouse = screenToWorld(editorMouseScreen);
@@ -2354,10 +2693,87 @@ function editorUpdate()
2354
2693
  }
2355
2694
  }
2356
2695
 
2696
+ // 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
2697
+ // picking a layer, a tile or an object type puts the game's tool down
2698
+ let editorToolHeld = false, editorToolName, editorToolPicked = [];
2699
+
2700
+ // what a tool's callbacks are given: where the mouse is in the level, the cell under it on the selected tile
2701
+ // layer, and the keys held
2702
+ function editorToolAt()
2703
+ {
2704
+ const layer = editorObjectLayer || editorLayer?.live.destroyed ? undefined : editorLayer;
2705
+ const pos = screenToWorld(editorMouseScreen);
2706
+ return {pos, cell: layer && !editorMouseOnPanel ? editorCellAt(layer.live, pos) || undefined : undefined,
2707
+ ray: undefined, shift: keyIsDown('ShiftLeft') || keyIsDown('ShiftRight'),
2708
+ ctrl: keyIsDown('ControlLeft') || keyIsDown('ControlRight')};
2709
+ }
2710
+
2711
+ // the game's tool, when one is on: the left button is its, a press, a drag and a release one undo, the right
2712
+ // button takes a held press back; true when it has the mouse
2713
+ function editorToolUpdate(space)
2714
+ {
2715
+ const picked = [editorLayer, editorObjectLayer, editorBrush, editorObjectBrush];
2716
+ if (levelEditor.tool !== editorToolName)
2717
+ editorToolName = levelEditor.tool, editorToolPicked = picked; // turned on, or off, by its key or button
2718
+ else if (picked.some((value, i)=> value !== editorToolPicked[i]))
2719
+ levelEditor.tool = editorToolName = undefined; // something of the editor's was picked
2720
+ const tool = editorGameTool();
2721
+ if (!tool)
2722
+ {
2723
+ editorToolHeld = false;
2724
+ return false;
2725
+ }
2726
+ const at = editorToolAt();
2727
+ editorHover = at.cell;
2728
+ editorRightPress = undefined; // a right press from before the tool was on is not the editor's to finish
2729
+ if (editorToolHeld && mouseWasPressed(2))
2730
+ {
2731
+ inputClearKey(2);
2732
+ editorStrokeCancel();
2733
+ editorToolHeld = false;
2734
+ }
2735
+ else if (mouseWasPressed(0) && !space && !editorMouseOnPanel)
2736
+ editorToolHeld = tool.onPress?.(at) !== false;
2737
+ else if (editorToolHeld && mouseIsDown(0))
2738
+ tool.onDrag?.(at);
2739
+ if (editorToolHeld && !mouseIsDown(0))
2740
+ {
2741
+ tool.onRelease?.(at);
2742
+ editorStrokeEnd();
2743
+ editorToolHeld = false;
2744
+ }
2745
+ return true;
2746
+ }
2747
+
2748
+ // the 2D editor's edit functions, what levelEditor.edit2D gives a game's own keys, buttons and tools
2749
+ const editorEdit2D =
2750
+ {
2751
+ get map() { return editorRecord()?.map; },
2752
+ get layer() { return editorObjectLayer ? undefined : editorLayer?.live; },
2753
+ get objects() { return editorObjectLayer ? editorObjectsCopy(editorObjectList(editorObjectLayer)) : []; },
2754
+ get hover() { return editorHover && !editorObjectLayer ? editorHover.copy() : undefined; },
2755
+ get selection() { return editorObjectSelection; },
2756
+ paint(cell, tile, direction=0, mirror=false)
2757
+ {
2758
+ const layer = editorObjectLayer ? undefined : editorLayer, size = layer?.live.size;
2759
+ if (!layer || layer.live.destroyed || layer.record.pending || !isVector2(cell)) return false;
2760
+ const x = floor(cell.x), y = floor(cell.y);
2761
+ if (x < 0 || y < 0 || x >= size.x || y >= size.y) return false;
2762
+ editorPaint(layer, vec2(x, y), tile < 0 ? 0 : editorTileToGid(tile, direction & 3, mirror));
2763
+ return true;
2764
+ },
2765
+ changeObjects(change) { return editorChangeObjects(editorObjectLayer, change); },
2766
+ strokeEnd() { editorStrokeEnd(); },
2767
+ strokeCancel() { editorStrokeCancel(); },
2768
+ bulk(edit) { editorBulkEdit(edit); },
2769
+ undo(redo=false) { return editorUndo(redo); },
2770
+ toJSON() { const record = editorRecord(); return record ? editorMapJSON(record) : ''; },
2771
+ };
2772
+
2357
2773
  // switch to play and have the game rebuild its level from the changed map, when it has a hook for that
2358
2774
  function editorRestart()
2359
2775
  {
2360
- if (!levelEditor.onRestart) return;
2776
+ if (!editorHas('onRestart')) return;
2361
2777
  editorSetOpen(false); // ends a held stroke, and hands back the game's pause and camera, Escape comes back
2362
2778
  levelEditor.onRestart();
2363
2779
  }
@@ -2371,6 +2787,11 @@ function editorPreRender() { editorIsOpen && editorApplyCamera(); }
2371
2787
  function editorRender()
2372
2788
  {
2373
2789
  headlessMode || editorPanelUpdate();
2790
+ if (!headlessMode && editorIsOpen && editorPanelParts)
2791
+ {
2792
+ editorGamePanel(editorPanelParts.game);
2793
+ editorGameHelp(editorPanelParts.gameHelp);
2794
+ }
2374
2795
  if (!editorIsOpen || headlessMode) return;
2375
2796
  const layer = editorLayer?.live.destroyed ? undefined : editorLayer;
2376
2797
  if (!layer && !editorObjectLayer) return;
@@ -2419,12 +2840,12 @@ function editorRender()
2419
2840
  if (editorSelection && !editorObjectLayer)
2420
2841
  outline(editorSelection.min, editorSelection.max.add(vec2(1)), hsl(.15, 1, .6), thin * 3);
2421
2842
  const last = editorLastPlaced, shift = keyIsDown('ShiftLeft') || keyIsDown('ShiftRight');
2422
- if (shift && editorHover && last?.layer === layer && !mouseIsDown(0))
2843
+ if (shift && editorHover && last?.layer === layer && !mouseIsDown(0) && !editorGameTool())
2423
2844
  drawLine(live.pos.add(last.pos).add(vec2(.5)), live.pos.add(editorHover).add(vec2(.5)), thin * 2,
2424
2845
  hsl(.55, 1, .6, .6), undefined, 0, glEnable, false);
2425
2846
 
2426
- // the brush where it would paint, the Erase brush's cells in red
2427
- if (editorHover)
2847
+ // the brush where it would paint, the Erase brush's cells in red; a tool of the game's own draws its own
2848
+ if (editorHover && !editorGameTool())
2428
2849
  {
2429
2850
  const grid = editorStampGrid(layer, editorBrush), {width: w, height: h} = editorBrush;
2430
2851
  for (let y = h; y--;)
@@ -2442,7 +2863,24 @@ function editorRender()
2442
2863
  editorObjectLayer && editorRenderObjects(thin);
2443
2864
  }
2444
2865
 
2445
- debug && engineAddPlugin(editorUpdate, editorRender);
2866
+ // the game's own drawing in the level, over the editor's: the editor's onDraw, and its tool's while one is on
2867
+ function editorRenderGame()
2868
+ {
2869
+ if (!editorIsOpen) return;
2870
+ editorCall('onDraw');
2871
+ editorGameTool()?.onDraw?.(editorToolAt());
2872
+ }
2873
+
2874
+ // the lines of the game's own keys in a panel's help, made again when they change
2875
+ function editorGameHelp(element)
2876
+ {
2877
+ const lines = editorGameHelpLines(), key = lines.join('\n');
2878
+ if (element.dataset.key === key) return;
2879
+ element.dataset.key = key;
2880
+ element.replaceChildren(...lines.map((line)=> editorElement('div', undefined, 'margin:2px 0', line)));
2881
+ }
2882
+
2883
+ debug && engineAddPlugin(editorUpdate, ()=> { editorRender(); editorRenderGame(); });
2446
2884
 
2447
2885
  // the editor's own listeners for letter shortcuts and the wheel, the engine's input reads keys by position
2448
2886
  if (debug && globalThis.document?.addEventListener)