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