littlejsengine 1.22.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
 
@@ -438,7 +742,14 @@ async function editorSave(record, pickAgain=false)
438
742
  if (!record) return;
439
743
  editorStrokeEnd();
440
744
  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); })();
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
+ })();
442
753
  record.saving = saved.catch(()=> {});
443
754
  return saved;
444
755
  }
@@ -562,7 +873,7 @@ function editorPaintData(record, data, objects, width=record.map.width, height=r
562
873
  editorStrokeEnd();
563
874
  if (width !== record.map.width || height !== record.map.height)
564
875
  {
565
- if (!levelEditor.onRestart) return;
876
+ if (!editorHas('onRestart')) return;
566
877
  const before = editorMapSnapshot(record);
567
878
  editorSetMapSnapshot(record, {width, height, layers: data, objects});
568
879
  editorMapChanged(record, before);
@@ -607,7 +918,7 @@ function editorPendingFits(record, saved)
607
918
  {
608
919
  const map = record.map, data = editorTileLayerData(map.layers), groups = editorObjectGroups(map.layers).length;
609
920
  const width = saved.width ?? map.width, height = saved.height ?? map.height;
610
- return (width === map.width && height === map.height || !!levelEditor.onRestart) &&
921
+ return (width === map.width && height === map.height || editorHas('onRestart')) &&
611
922
  saved.layers?.length === data.length && saved.layers.every((layer)=> layer.length === width * height) &&
612
923
  (saved.objects ?? []).every((objects, i)=> i < groups || !objects.length);
613
924
  }
@@ -711,7 +1022,7 @@ function editorMapChanged(record, before)
711
1022
  function editorResize(record, width, height)
712
1023
  {
713
1024
  const map = record?.map;
714
- if (!map || record.synthetic || record.pending || !levelEditor.onRestart) return false;
1025
+ if (!map || record.synthetic || record.pending || !editorHas('onRestart')) return false;
715
1026
  if (width === map.width && height === map.height) return false;
716
1027
  editorStrokeEnd();
717
1028
  const before = editorMapSnapshot(record);
@@ -765,7 +1076,7 @@ function editorSetCell(layer, pos, gid)
765
1076
  }
766
1077
  const t = editorGidToTile(gid);
767
1078
  live.setData(pos, t ? new TileLayerData(t.tile, t.direction, t.mirror, color) : new TileLayerData, true);
768
- if (levelEditor.onTile)
1079
+ if (editorHas('onTile'))
769
1080
  levelEditor.onTile(live, pos.copy(), t?.tile);
770
1081
  else if (live instanceof TileCollisionLayer && live.isSolid)
771
1082
  live.setCollisionData(pos, gid ? 1 : 0); // as tileLayersLoad gives it
@@ -834,7 +1145,7 @@ function editorChanged(stroke)
834
1145
  editorRedraw(stroke);
835
1146
  for (const record of new Set(stroke.map(editorEntryRecord)))
836
1147
  editorAutosave(record);
837
- stroke.some((entry)=> entry.resize) && levelEditor.onRestart?.();
1148
+ stroke.some((entry)=> entry.resize) && editorHas('onRestart') && levelEditor.onRestart();
838
1149
  }
839
1150
 
840
1151
  // the tile layers a stroke touched draw again whole
@@ -1027,6 +1338,7 @@ let editorObjectDrag, editorObjectBox;
1027
1338
  function editorSelectLayer(layer)
1028
1339
  {
1029
1340
  editorStrokeEnd();
1341
+ levelEditor.tool = undefined; // a tool of the game's own is put down
1030
1342
  editorLastCell = editorObjectDrag = editorRightPress = editorObjectBox = undefined; // presses stay on their layer
1031
1343
  editorSelectionDrag = undefined; // a move so far is kept, the stroke ended
1032
1344
  editorObjectSelection.clear();
@@ -1654,6 +1966,7 @@ const editorHelpLines =
1654
1966
  function editorHint()
1655
1967
  {
1656
1968
  if (keyIsDown('Space')) return 'Drag to pan';
1969
+ if (editorGameTool()) return editorGameTool().hint || levelEditor.tool;
1657
1970
  if (editorObjectLayer)
1658
1971
  return editorObjectSelection.size ? 'Selection: drag moves · Delete removes · Ctrl+C copy · Ctrl+X cut · click to clear' :
1659
1972
  'Left place / select · drag moves · Right pick / drag select · Delete removes';
@@ -1722,6 +2035,8 @@ function editorPanelInit()
1722
2035
  button(undo, 'Undo', ()=> editorUndo(), 'Ctrl+Z');
1723
2036
  button(undo, 'Redo', ()=> editorUndo(true), 'Ctrl+Y');
1724
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);
1725
2040
 
1726
2041
  // a file that changed under its autosave
1727
2042
  const pending = editorElement('div', editorPanel, 'padding:4px;margin:4px 0;background:#630;border-radius:3px');
@@ -1808,9 +2123,10 @@ function editorPanelInit()
1808
2123
  const help = editorElement('div', editorPanel, 'color:#aaa;margin-top:4px;border-top:1px solid #444;padding-top:4px');
1809
2124
  for (const line of editorHelpLines)
1810
2125
  editorElement('div', help, 'margin:2px 0', line);
2126
+ const gameHelp = editorElement('div', help);
1811
2127
  button(help, 'Close', ()=> editorHelp = false, '?');
1812
2128
 
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};
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};
1814
2130
  }
1815
2131
 
1816
2132
  // the palette's cell size in pixels and how many to a row
@@ -1969,15 +2285,15 @@ function editorPanelUpdate()
1969
2285
  p.hint.textContent = editorHint();
1970
2286
  p.help.style.display = editorHelp ? '' : 'none';
1971
2287
  p.storage.style.display = editorSaveFailed ? '' : 'none';
1972
- p.restart.style.display = levelEditor.onRestart ? '' : 'none';
2288
+ p.restart.style.display = editorHas('onRestart') ? '' : 'none';
1973
2289
  p.advancedToggle.textContent = editorAdvanced ? 'Advanced ▾' : 'Advanced ▸';
1974
2290
  p.advanced.style.display = editorAdvanced ? '' : 'none';
1975
- p.playFromLabel.style.display = levelEditor.onPlayFrom ? 'flex' : 'none';
2291
+ p.playFromLabel.style.display = editorHas('onPlayFrom') ? 'flex' : 'none';
1976
2292
  p.playFrom.checked = editorPlayFromMouse;
1977
2293
 
1978
2294
  // the level's size, shown until a field is being typed in; without the Restart hook it can not change
1979
2295
  const record = editorRecord(), map = record?.map;
1980
- const canResize = !!levelEditor.onRestart && !record?.synthetic && !record?.pending;
2296
+ const canResize = editorHas('onRestart') && !record?.synthetic && !record?.pending;
1981
2297
  p.sizeRow.style.display = map ? 'flex' : 'none';
1982
2298
  if (map && !p.sizeRow.contains(document.activeElement))
1983
2299
  {
@@ -2047,6 +2363,7 @@ function editorIsTextField(target)
2047
2363
  function editorOnKeyDown(e)
2048
2364
  {
2049
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
2050
2367
  if (editorSelectionDrag && !(debugKey && e.code === debugKey))
2051
2368
  {
2052
2369
  // 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 +2371,12 @@ function editorOnKeyDown(e)
2054
2371
  editorStrokeEnd();
2055
2372
  editorSelectionDrag = undefined;
2056
2373
  }
2057
- let key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
2058
- 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])
2059
2376
  key = e.code?.match(/^Key([A-Z])$/)?.[1].toLowerCase() ?? key;
2060
- 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];
2061
2380
  if (action && action(e.shiftKey) !== false)
2062
2381
  e.preventDefault();
2063
2382
  }
@@ -2251,6 +2570,15 @@ function editorUpdate()
2251
2570
  return;
2252
2571
  }
2253
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
+
2254
2582
  // in an editing session Escape, the debug key, switches between playing and editing; taken, so the debug
2255
2583
  // overlay waits till the session ends
2256
2584
  if (editorSession && debugKey && keyWasPressed(debugKey))
@@ -2281,7 +2609,7 @@ function editorUpdate()
2281
2609
  // keeps them while it is open
2282
2610
  for (let i = 1; !debugOverlay && i <= 9; ++i)
2283
2611
  {
2284
- if (!keyWasPressed('Digit' + i)) continue;
2612
+ if (!keyWasPressed('Digit' + i) || levelEditor.keys[i]) continue; // a digit of the game's own is its
2285
2613
  inputClearKey('Digit' + i);
2286
2614
  const layer = editorLayers()[i - 1];
2287
2615
  layer && layer !== (editorObjectLayer ?? editorLayer) && editorSelectLayer(layer); // a drag does not carry across
@@ -2294,6 +2622,8 @@ function editorUpdate()
2294
2622
  editorApplyCamera();
2295
2623
 
2296
2624
  editorMouseOnPanel || (editorMouseScreen = mousePosScreen.copy());
2625
+ editorCall('onUpdate');
2626
+ if (editorToolUpdate(space)) return; // a tool of the game's own has the mouse
2297
2627
  if (editorObjectLayer)
2298
2628
  return editorUpdateObjects(space);
2299
2629
  const layer = editorLayer?.live.destroyed ? undefined : editorLayer, mouse = screenToWorld(editorMouseScreen);
@@ -2363,10 +2693,87 @@ function editorUpdate()
2363
2693
  }
2364
2694
  }
2365
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
+
2366
2773
  // switch to play and have the game rebuild its level from the changed map, when it has a hook for that
2367
2774
  function editorRestart()
2368
2775
  {
2369
- if (!levelEditor.onRestart) return;
2776
+ if (!editorHas('onRestart')) return;
2370
2777
  editorSetOpen(false); // ends a held stroke, and hands back the game's pause and camera, Escape comes back
2371
2778
  levelEditor.onRestart();
2372
2779
  }
@@ -2380,6 +2787,11 @@ function editorPreRender() { editorIsOpen && editorApplyCamera(); }
2380
2787
  function editorRender()
2381
2788
  {
2382
2789
  headlessMode || editorPanelUpdate();
2790
+ if (!headlessMode && editorIsOpen && editorPanelParts)
2791
+ {
2792
+ editorGamePanel(editorPanelParts.game);
2793
+ editorGameHelp(editorPanelParts.gameHelp);
2794
+ }
2383
2795
  if (!editorIsOpen || headlessMode) return;
2384
2796
  const layer = editorLayer?.live.destroyed ? undefined : editorLayer;
2385
2797
  if (!layer && !editorObjectLayer) return;
@@ -2428,12 +2840,12 @@ function editorRender()
2428
2840
  if (editorSelection && !editorObjectLayer)
2429
2841
  outline(editorSelection.min, editorSelection.max.add(vec2(1)), hsl(.15, 1, .6), thin * 3);
2430
2842
  const last = editorLastPlaced, shift = keyIsDown('ShiftLeft') || keyIsDown('ShiftRight');
2431
- if (shift && editorHover && last?.layer === layer && !mouseIsDown(0))
2843
+ if (shift && editorHover && last?.layer === layer && !mouseIsDown(0) && !editorGameTool())
2432
2844
  drawLine(live.pos.add(last.pos).add(vec2(.5)), live.pos.add(editorHover).add(vec2(.5)), thin * 2,
2433
2845
  hsl(.55, 1, .6, .6), undefined, 0, glEnable, false);
2434
2846
 
2435
- // the brush where it would paint, the Erase brush's cells in red
2436
- 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())
2437
2849
  {
2438
2850
  const grid = editorStampGrid(layer, editorBrush), {width: w, height: h} = editorBrush;
2439
2851
  for (let y = h; y--;)
@@ -2451,7 +2863,24 @@ function editorRender()
2451
2863
  editorObjectLayer && editorRenderObjects(thin);
2452
2864
  }
2453
2865
 
2454
- 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(); });
2455
2884
 
2456
2885
  // the editor's own listeners for letter shortcuts and the wheel, the engine's input reads keys by position
2457
2886
  if (debug && globalThis.document?.addEventListener)