littlejsengine 1.23.0 → 1.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/plugins/zzfxm.js CHANGED
@@ -1,160 +1,162 @@
1
- /**
2
- * LittleJS ZzFXM Plugin
3
- * @namespace ZzFXM
4
- */
5
-
6
- 'use strict';
7
-
8
- /**
9
- * Music Object - Stores a zzfx music track for later use
10
- *
11
- * <a href=https://keithclark.github.io/ZzFXM/>Create music with the ZzFXM tracker.</a>
12
- * @extends Sound
13
- * @memberof ZzFXM
14
- * @example
15
- * // create some music
16
- * const music_example = new ZzFXMusic(
17
- * [
18
- * [ // instruments
19
- * [,0,400] // simple note
20
- * ],
21
- * [ // patterns
22
- * [ // pattern 1
23
- * [ // channel 0
24
- * 0, -1, // instrument 0, left speaker
25
- * 1, 0, 9, 1 // channel notes
26
- * ],
27
- * [ // channel 1
28
- * 0, 1, // instrument 0, right speaker
29
- * 0, 12, 17, -1 // channel notes
30
- * ]
31
- * ],
32
- * ],
33
- * [0, 0, 0, 0], // sequence, play pattern 0 four times
34
- * 90 // BPM
35
- * ]);
36
- *
37
- * // play the music on a loop
38
- * music_example.playMusic();
39
- */
40
- class ZzFXMusic extends Sound
41
- {
42
- /** Create a music object and cache the zzfx music samples for later use
43
- * @param {[Array, Array, Array, number?, ...any[]]} zzfxMusic - Array of zzfx music parameters: instruments, patterns, sequence, and an optional BPM; anything after it, like the tracker's metadata, is ignored
44
- */
45
- constructor(zzfxMusic)
46
- {
47
- super(undefined);
48
-
49
- if (!soundEnable || headlessMode) return;
50
- const [instruments, patterns, sequence, BPM] = zzfxMusic;
51
- super.sampleChannels = zzfxM(instruments, patterns, sequence, BPM); // the setter, without declaring a field that hides it in the typings
52
- this.buildSampleBuffer(); // hand the samples to an audio buffer now, like a zzfx sound, so the arrays are released
53
- this.loadedPercent = 1; // generated in place, so it is loaded like a zzfx sound
54
- }
55
- }
56
-
57
- ///////////////////////////////////////////////////////////////////////////////
58
- // ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force
59
-
60
- /** Generate samples for a ZzFM song with given parameters
61
- * @param {Array} instruments - Array of ZzFX sound parameters
62
- * @param {Array} patterns - Array of pattern data
63
- * @param {Array} sequence - Array of pattern indexes
64
- * @param {number} [BPM] - Playback speed of the song in BPM
65
- * @return {Array} - Left and right channel sample data
66
- * @memberof ZzFXM */
67
- function zzfxM(instruments, patterns, sequence, BPM = 125)
68
- {
69
- let i, j, k;
70
- let instrumentParameters;
71
- let note;
72
- let sample;
73
- let patternChannel;
74
- let notFirstBeat;
75
- let stop;
76
- let instrument;
77
- let attenuation;
78
- let outSampleOffset;
79
- let isSequenceEnd;
80
- let sampleOffset = 0;
81
- let nextSampleOffset;
82
- let sampleBuffer = [];
83
- let leftChannelBuffer = [];
84
- let rightChannelBuffer = [];
85
- let channelIndex = 0;
86
- let panning = 0;
87
- let hasMore = 1;
88
- let sampleCache = {};
89
- let beatLength = audioDefaultSampleRate / BPM * 60 >> 2;
90
-
91
- // for each channel in order until there are no more
92
- for (; hasMore; channelIndex++) {
93
-
94
- // reset current values
95
- sampleBuffer = [hasMore = notFirstBeat = outSampleOffset = 0];
96
-
97
- // for each pattern in sequence
98
- sequence.forEach((patternIndex, sequenceIndex)=> {
99
- // get pattern for current channel, use empty 1 note pattern if none found
100
- patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0];
101
-
102
- // check if there are more channels
103
- hasMore |= patterns[patternIndex][channelIndex]&&1;
104
-
105
- // get next offset, use the length of first channel
106
- nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - (notFirstBeat?0:1)) * beatLength;
107
- // for each beat in pattern, plus one extra if end of sequence
108
- isSequenceEnd = sequenceIndex === sequence.length - 1;
109
- for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) {
110
-
111
- // <channel-note>
112
- note = patternChannel[i];
113
-
114
- // stop if end, different instrument or new note
115
- stop = i === patternChannel.length + isSequenceEnd - 1 && isSequenceEnd ||
116
- instrument !== (patternChannel[0] || 0) || note | 0;
117
-
118
- // fill buffer with samples for previous beat, most cpu intensive part
119
- for (j = 0; j < beatLength && notFirstBeat;
120
-
121
- // fade off attenuation at end of beat if stopping note, prevents clicking
122
- j++ > beatLength - 99 && stop && attenuation < 1? attenuation += 1 / 99 : 0
123
- ) {
124
- // copy sample to stereo buffers with panning
125
- sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0;
126
- leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample;
127
- rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample;
128
- }
129
-
130
- // set up for next note
131
- if (note) {
132
- // set attenuation
133
- attenuation = note % 1;
134
- panning = patternChannel[1] || 0;
135
- if (note |= 0) {
136
- // get cached sample
137
- sampleBuffer = sampleCache[
138
- [
139
- instrument = patternChannel[sampleOffset = 0] || 0,
140
- note
141
- ]
142
- ] = sampleCache[[instrument, note]] || (
143
- // add sample to cache
144
- instrumentParameters = [...instruments[instrument]],
145
- instrumentParameters[2] = (instrumentParameters[2] || 220) * 2**(note / 12 - 1),
146
-
147
- // allow negative values to stop notes
148
- note > 0 ? zzfxG(...instrumentParameters) : []
149
- );
150
- }
151
- }
152
- }
153
-
154
- // update the sample offset
155
- outSampleOffset = nextSampleOffset;
156
- });
157
- }
158
-
159
- return [leftChannelBuffer, rightChannelBuffer];
1
+ /**
2
+ * LittleJS ZzFXM Plugin
3
+ * - A port of ZzFXM, the Zuper Zmall Zound Zynth music player, by Keith Clark and Frank Force, MIT licensed,
4
+ * https://github.com/keithclark/ZzFXM; its notice is in COPYRIGHT.txt
5
+ * @namespace ZzFXM
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ /**
11
+ * Music Object - Stores a zzfx music track for later use
12
+ *
13
+ * <a href=https://keithclark.github.io/ZzFXM/>Create music with the ZzFXM tracker.</a>
14
+ * @extends Sound
15
+ * @memberof ZzFXM
16
+ * @example
17
+ * // create some music
18
+ * const music_example = new ZzFXMusic(
19
+ * [
20
+ * [ // instruments
21
+ * [,0,400] // simple note
22
+ * ],
23
+ * [ // patterns
24
+ * [ // pattern 1
25
+ * [ // channel 0
26
+ * 0, -1, // instrument 0, left speaker
27
+ * 1, 0, 9, 1 // channel notes
28
+ * ],
29
+ * [ // channel 1
30
+ * 0, 1, // instrument 0, right speaker
31
+ * 0, 12, 17, -1 // channel notes
32
+ * ]
33
+ * ],
34
+ * ],
35
+ * [0, 0, 0, 0], // sequence, play pattern 0 four times
36
+ * 90 // BPM
37
+ * ]);
38
+ *
39
+ * // play the music on a loop
40
+ * music_example.playMusic();
41
+ */
42
+ class ZzFXMusic extends Sound
43
+ {
44
+ /** Create a music object and cache the zzfx music samples for later use
45
+ * @param {[Array, Array, Array, number?, ...any[]]} zzfxMusic - Array of zzfx music parameters: instruments, patterns, sequence, and an optional BPM; anything after it, like the tracker's metadata, is ignored
46
+ */
47
+ constructor(zzfxMusic)
48
+ {
49
+ super(undefined);
50
+
51
+ if (!soundEnable || headlessMode) return;
52
+ const [instruments, patterns, sequence, BPM] = zzfxMusic;
53
+ super.sampleChannels = zzfxM(instruments, patterns, sequence, BPM); // the setter, without declaring a field that hides it in the typings
54
+ this.buildSampleBuffer(); // hand the samples to an audio buffer now, like a zzfx sound, so the arrays are released
55
+ this.loadedPercent = 1; // generated in place, so it is loaded like a zzfx sound
56
+ }
57
+ }
58
+
59
+ ///////////////////////////////////////////////////////////////////////////////
60
+ // ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force
61
+
62
+ /** Generate samples for a ZzFM song with given parameters
63
+ * @param {Array} instruments - Array of ZzFX sound parameters
64
+ * @param {Array} patterns - Array of pattern data
65
+ * @param {Array} sequence - Array of pattern indexes
66
+ * @param {number} [BPM] - Playback speed of the song in BPM
67
+ * @return {Array} - Left and right channel sample data
68
+ * @memberof ZzFXM */
69
+ function zzfxM(instruments, patterns, sequence, BPM = 125)
70
+ {
71
+ let i, j, k;
72
+ let instrumentParameters;
73
+ let note;
74
+ let sample;
75
+ let patternChannel;
76
+ let notFirstBeat;
77
+ let stop;
78
+ let instrument;
79
+ let attenuation;
80
+ let outSampleOffset;
81
+ let isSequenceEnd;
82
+ let sampleOffset = 0;
83
+ let nextSampleOffset;
84
+ let sampleBuffer = [];
85
+ let leftChannelBuffer = [];
86
+ let rightChannelBuffer = [];
87
+ let channelIndex = 0;
88
+ let panning = 0;
89
+ let hasMore = 1;
90
+ let sampleCache = {};
91
+ let beatLength = audioDefaultSampleRate / BPM * 60 >> 2;
92
+
93
+ // for each channel in order until there are no more
94
+ for (; hasMore; channelIndex++) {
95
+
96
+ // reset current values
97
+ sampleBuffer = [hasMore = notFirstBeat = outSampleOffset = 0];
98
+
99
+ // for each pattern in sequence
100
+ sequence.forEach((patternIndex, sequenceIndex)=> {
101
+ // get pattern for current channel, use empty 1 note pattern if none found
102
+ patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0];
103
+
104
+ // check if there are more channels
105
+ hasMore |= patterns[patternIndex][channelIndex]&&1;
106
+
107
+ // get next offset, use the length of first channel
108
+ nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - (notFirstBeat?0:1)) * beatLength;
109
+ // for each beat in pattern, plus one extra if end of sequence
110
+ isSequenceEnd = sequenceIndex === sequence.length - 1;
111
+ for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) {
112
+
113
+ // <channel-note>
114
+ note = patternChannel[i];
115
+
116
+ // stop if end, different instrument or new note
117
+ stop = i === patternChannel.length + isSequenceEnd - 1 && isSequenceEnd ||
118
+ instrument !== (patternChannel[0] || 0) || note | 0;
119
+
120
+ // fill buffer with samples for previous beat, most cpu intensive part
121
+ for (j = 0; j < beatLength && notFirstBeat;
122
+
123
+ // fade off attenuation at end of beat if stopping note, prevents clicking
124
+ j++ > beatLength - 99 && stop && attenuation < 1? attenuation += 1 / 99 : 0
125
+ ) {
126
+ // copy sample to stereo buffers with panning
127
+ sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0;
128
+ leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample;
129
+ rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample;
130
+ }
131
+
132
+ // set up for next note
133
+ if (note) {
134
+ // set attenuation
135
+ attenuation = note % 1;
136
+ panning = patternChannel[1] || 0;
137
+ if (note |= 0) {
138
+ // get cached sample
139
+ sampleBuffer = sampleCache[
140
+ [
141
+ instrument = patternChannel[sampleOffset = 0] || 0,
142
+ note
143
+ ]
144
+ ] = sampleCache[[instrument, note]] || (
145
+ // add sample to cache
146
+ instrumentParameters = [...instruments[instrument]],
147
+ instrumentParameters[2] = (instrumentParameters[2] || 220) * 2**(note / 12 - 1),
148
+
149
+ // allow negative values to stop notes
150
+ note > 0 ? zzfxG(...instrumentParameters) : []
151
+ );
152
+ }
153
+ }
154
+ }
155
+
156
+ // update the sample offset
157
+ outSampleOffset = nextSampleOffset;
158
+ });
159
+ }
160
+
161
+ return [leftChannelBuffer, rightChannelBuffer];
160
162
  }
package/src/engine.js CHANGED
@@ -32,7 +32,7 @@ const engineName = 'LittleJS';
32
32
  * @type {string}
33
33
  * @default
34
34
  * @memberof Engine */
35
- const engineVersion = '1.23.0';
35
+ const engineVersion = '1.23.1';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -222,7 +222,13 @@ class Sound
222
222
  */
223
223
  constructor(asset, randomness, range, taper=soundDefaultTaper, onloadCallback)
224
224
  {
225
- if (!soundEnable || headlessMode) return;
225
+ if (!soundEnable || headlessMode)
226
+ {
227
+ // no sound is made: it counts as loaded, so a game that waits for its sounds goes on
228
+ this.loadedPercent = 1;
229
+ onloadCallback?.(this);
230
+ return;
231
+ }
226
232
  const rangeIsDefault = range === undefined;
227
233
  if (rangeIsDefault)
228
234
  range = soundDefaultRange;
@@ -6,7 +6,7 @@
6
6
  * - Produces TypeScript definition file (.d.ts)
7
7
  * - Includes plugin files in builds
8
8
  * - Validates code and checks for errors
9
- * - Outputs to dist/ folder
9
+ * - Outputs to dist/ folder, which is left as it was when a step fails
10
10
  */
11
11
 
12
12
  import fs from 'node:fs';
@@ -19,7 +19,9 @@ const __dirname = dirname(__filename);
19
19
  const ROOT_DIR = join(__dirname, '..');
20
20
 
21
21
  const ENGINE_NAME = 'littlejs';
22
- const BUILD_FOLDER = join(ROOT_DIR, 'dist');
22
+ // built in a folder of its own and put in dist when all of it is there, so a step that fails leaves dist whole
23
+ const OUTPUT_FOLDER = join(ROOT_DIR, 'dist');
24
+ const BUILD_FOLDER = join(ROOT_DIR, 'dist.build');
23
25
  const SOURCE_FOLDER = join(ROOT_DIR, 'src');
24
26
  const PLUGIN_FOLDER = join(ROOT_DIR, 'plugins');
25
27
  const engineSourceFiles =
@@ -104,6 +106,19 @@ catch (e) { handleError(e, 'Failed to create build folder!'); }
104
106
  // Build all versions
105
107
  await buildAll();
106
108
 
109
+ try
110
+ {
111
+ // every step is done: dist is given what was built, and loses what is no longer built
112
+ fs.mkdirSync(OUTPUT_FOLDER, { recursive: true });
113
+ const built = fs.readdirSync(BUILD_FOLDER);
114
+ for (const file of built)
115
+ fs.copyFileSync(join(BUILD_FOLDER, file), join(OUTPUT_FOLDER, file));
116
+ for (const file of fs.readdirSync(OUTPUT_FOLDER))
117
+ built.includes(file) || fs.rmSync(join(OUTPUT_FOLDER, file), { recursive: true, force: true });
118
+ fs.rmSync(BUILD_FOLDER, { recursive: true, force: true });
119
+ }
120
+ catch (e) { handleError(e, 'Failed to put the build in dist!'); }
121
+
107
122
  console.log(`Engine built in ${((Date.now() - startTime)/1e3).toFixed(2)} seconds! ✨`);
108
123
 
109
124
  ///////////////////////////////////////////////////////////////////////////////
package/src/engineDraw.js CHANGED
@@ -943,7 +943,7 @@ function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, l
943
943
  // build regular polygon points
944
944
  const points = [];
945
945
  const sizeX = size.x/2, sizeY = size.y/2;
946
- for (let i=sides; i--;)
946
+ for (let i=sides; i-- > 0;) // a count that is not whole, or below zero, still ends
947
947
  {
948
948
  const a = (i/sides)*PI*2;
949
949
  points.push(vec2(sin(a)*sizeX, cos(a)*sizeY));
@@ -1054,7 +1054,7 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
1054
1054
  if (!ring)
1055
1055
  {
1056
1056
  const points = [];
1057
- for (let i=sides; i--;)
1057
+ for (let i=sides; i-- > 0;)
1058
1058
  {
1059
1059
  const a = (i/sides)*PI*2;
1060
1060
  points.push(vec2(sin(a), cos(a)));
@@ -1154,7 +1154,7 @@ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLE
1154
1154
  const startA = (offset%sides)/sides*PI*2;
1155
1155
  const points = [rim(startA)];
1156
1156
  const colors = [outerInt];
1157
- for (let i=sides; i--;)
1157
+ for (let i=sides; i-- > 0;)
1158
1158
  {
1159
1159
  const a = ((i+offset)%sides)/sides*PI*2;
1160
1160
  points.push(pos);
@@ -1577,7 +1577,8 @@ function isOnScreen(pos, size=0)
1577
1577
  function setAdditiveBlendMode(additive=true)
1578
1578
  {
1579
1579
  glAdditive = additive;
1580
- drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
1580
+ if (drawContext) // none headless
1581
+ drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
1581
1582
  }
1582
1583
 
1583
1584
  /** Set the Shader that 2D draws use from now on, none for the engine's own
@@ -1700,9 +1701,12 @@ function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth,
1700
1701
  }
1701
1702
  else
1702
1703
  {
1703
- // copy to offscreen canvas
1704
- workReadCanvas.width = sWidth;
1705
- workReadCanvas.height = sHeight;
1704
+ // copy to offscreen canvas, sized again only when the size changes: setting a size remakes the canvas even
1705
+ // to the same one, and a game's tinted tiles are mostly of one size
1706
+ if (workReadCanvas.width !== sWidth || workReadCanvas.height !== sHeight)
1707
+ workReadCanvas.width = sWidth, workReadCanvas.height = sHeight;
1708
+ else
1709
+ workReadContext.clearRect(0, 0, sWidth, sHeight);
1706
1710
  workReadContext.drawImage(image, sx|0, sy|0, sWidth, sHeight, 0, 0, sWidth, sHeight);
1707
1711
 
1708
1712
  // tint image using offscreen work context
@@ -181,10 +181,10 @@ class LevelEditor
181
181
  onPanel(element) {}
182
182
 
183
183
  /** Called by Save with the text of the file and its name; return true when the game kept it itself, and the
184
- * editor writes no file, to set or override
184
+ * editor writes no file, to set or override; it may be async
185
185
  * @param {string} text
186
186
  * @param {string} fileName
187
- * @return {boolean|void} */
187
+ * @return {boolean|void|Promise<boolean|void>} */
188
188
  onSave(text, fileName) { return false; }
189
189
 
190
190
  /** True while the editor is open, the game is paused under it
@@ -551,7 +551,7 @@ function editorMapRestore(map)
551
551
  if (!map.layers || data.some((layer)=> !Array.isArray(layer))) return map;
552
552
  const objects = editorObjectGroups(map.layers).map((group)=> group.objects ?? []);
553
553
  const url = editorFetchedURLs.get(map), hash = editorMapHash(data, objects, editorMapLayout(map));
554
- const fileName = url?.split(/[?#]/)[0].split('/').pop() || 'level.json';
554
+ const fileName = editorFileName(url, 'level.json');
555
555
  const record = {map, url, fileName, key: editorMapKey(map, url, hash), hash,
556
556
  original: data.map((layer)=> [...layer]), originalObjects: editorObjectsCopy(objects),
557
557
  originalSize: {width: map.width, height: map.height}, layers: []};
@@ -582,6 +582,8 @@ function editorMapRestore(map)
582
582
  editorRestoreObjects(map, saved);
583
583
  console.warn(`LittleJS editor: brought back unsaved edits to ${record.fileName}, ` +
584
584
  'Save in the editor (Esc then 0) writes them to the file');
585
+ saved.stale && console.warn(`LittleJS editor: they are older than the last edits to ${record.fileName}, ` +
586
+ 'which storage had no room for');
585
587
  }
586
588
  return map;
587
589
  }
@@ -783,6 +785,7 @@ async function editorSaveText(record, text, pickAgain)
783
785
  if (error?.name === 'AbortError') return; // the picker was closed, nothing saved
784
786
  record.fileHandle = undefined; // a file it could not write, a download instead, and not kept
785
787
  editorFileStore.set(editorFileKey(record), undefined);
788
+ console.warn(`LittleJS editor: ${record.fileName} could not be written, downloaded instead`, error);
786
789
  }
787
790
  }
788
791
  saveText(text, record.fileName, 'application/json');
@@ -799,15 +802,35 @@ const editorSaves = ()=> readSaveData(editorSaveName(), {});
799
802
  // if the last autosave did not fit in storage, the panel says so
800
803
  let editorSaveFailed = false;
801
804
 
802
- // write the autosaves, noting whether storage took them
803
- function editorWriteSaves(saves)
805
+ // write the autosaves, noting whether storage took them; when it did not, the autosave it still has of this map
806
+ // is an older one, and is marked so, for the reload that brings it back to say so
807
+ function editorWriteSaves(saves, key, name=editorSaveName())
804
808
  {
805
809
  try
806
810
  {
807
- localStorage.setItem(editorSaveName(), JSON.stringify(saves));
808
- editorSaveFailed = false;
811
+ localStorage.setItem(name, JSON.stringify(saves));
812
+ return editorSaveFailed = false;
809
813
  }
810
814
  catch { editorSaveFailed = true; }
815
+ try
816
+ {
817
+ const kept = readSaveData(name, {});
818
+ if (!kept[key] || kept[key].stale) return true;
819
+ kept[key].stale = true;
820
+ localStorage.setItem(name, JSON.stringify(kept));
821
+ }
822
+ catch {} // no room for the mark either
823
+ return true;
824
+ }
825
+
826
+ // the name of the file a level was fetched from, as it is on disk: without its folder and query, and with what
827
+ // the url spells with a percent as it is written, so it is the name a picked file has
828
+ function editorFileName(url, fallback)
829
+ {
830
+ const name = url?.split(/[?#]/)[0].split('/').pop();
831
+ if (!name) return fallback;
832
+ try { return decodeURIComponent(name); }
833
+ catch { return name; }
811
834
  }
812
835
 
813
836
  // a map's name for its autosave, the file it was fetched from without a query, or its size, layer names and data as loaded; a map with
@@ -863,12 +886,12 @@ function editorAutosave(record)
863
886
  else
864
887
  saves[record.key] = {hash: record.hash, savedHash: record.savedHash, width: map.width, height: map.height,
865
888
  layers: data, objects, nextobjectid: map.nextobjectid};
866
- editorWriteSaves(saves);
889
+ editorWriteSaves(saves, record.key);
867
890
  }
868
891
 
869
892
  // paint every cell of a map's layers from a list of tile data, the tile layers of the map in order, and set its
870
893
  // object layers' objects from a list of them; data of another size resizes the map, which needs the Restart hook
871
- function editorPaintData(record, data, objects, width=record.map.width, height=record.map.height)
894
+ function editorPaintData(record, data, objects, width=record.map.width, height=record.map.height, keepUnknown=false)
872
895
  {
873
896
  editorStrokeEnd();
874
897
  if (width !== record.map.width || height !== record.map.height)
@@ -888,7 +911,9 @@ function editorPaintData(record, data, objects, width=record.map.width, height=r
888
911
  if (gids?.length === width * height)
889
912
  gids.forEach((gid, i)=> editorPaint(layer, vec2(i % width, height - 1 - (i / width | 0)), gid));
890
913
  }
891
- editorObjectLayers(record).forEach((layer, i)=> objects &&
914
+ // an autosave from before the file had an object layer leaves that layer as the file has it; Reset to file
915
+ // empties a layer the file did not have
916
+ editorObjectLayers(record).forEach((layer, i)=> objects && !(keepUnknown && i >= objects.length) &&
892
917
  editorChangeObjects(layer, (list)=> list.splice(0, list.length, ...editorObjectsCopy(objects[i] ?? []))));
893
918
  });
894
919
  editorStroke ? editorStrokeEnd() : editorAutosave(record);
@@ -908,7 +933,7 @@ function editorApplyPending(record)
908
933
  return false;
909
934
  }
910
935
  record.pending = record.pendingUnfit = undefined;
911
- editorPaintData(record, saved.layers, saved.objects, saved.width, saved.height);
936
+ editorPaintData(record, saved.layers, saved.objects, saved.width, saved.height, true);
912
937
  return true;
913
938
  }
914
939
 
@@ -1013,8 +1038,7 @@ function editorClearSelections()
1013
1038
  function editorMapChanged(record, before)
1014
1039
  {
1015
1040
  const stroke = [{resize: record, before, after: editorMapSnapshot(record)}];
1016
- editorUndoList.push(stroke);
1017
- editorRedoList.length = 0;
1041
+ editorUndoPush(stroke);
1018
1042
  editorChanged(stroke);
1019
1043
  }
1020
1044
 
@@ -1091,12 +1115,20 @@ function editorPaint(layer, pos, gid)
1091
1115
  (editorStroke ||= []).push({layer, pos: pos.copy(), before, after: gid});
1092
1116
  }
1093
1117
 
1118
+ // an edit goes on the undo list, which keeps the last 100, as the 3D editor's does: a fill of a large layer is
1119
+ // an entry for every cell, and a session of them would hold millions
1120
+ function editorUndoPush(stroke)
1121
+ {
1122
+ editorUndoList.push(stroke);
1123
+ editorUndoList.length > 100 && editorUndoList.shift();
1124
+ editorRedoList.length = 0;
1125
+ }
1126
+
1094
1127
  // the stroke is done, it can be undone as one
1095
1128
  function editorStrokeEnd()
1096
1129
  {
1097
1130
  if (!editorStroke) return;
1098
- editorUndoList.push(editorStroke);
1099
- editorRedoList.length = 0;
1131
+ editorUndoPush(editorStroke);
1100
1132
  editorChanged(editorStroke);
1101
1133
  editorStroke = undefined;
1102
1134
  }
@@ -1506,7 +1538,7 @@ function editorUpdateObjects(space)
1506
1538
  if (!editorObjectSelection.has(hit.id))
1507
1539
  editorObjectSelection = new Set([hit.id]);
1508
1540
  editorObjectDrag = {start: snap(mouse), from: new Map(editorSelectedObjects().map((object)=>
1509
- [object.id, editorObjectPos(record, object)]))};
1541
+ [object.id, {x: object.x, y: object.y}]))};
1510
1542
  }
1511
1543
  else if (editorObjectSelection.size)
1512
1544
  editorObjectSelection.clear();
@@ -1521,8 +1553,10 @@ function editorUpdateObjects(space)
1521
1553
  {
1522
1554
  for (const object of list)
1523
1555
  {
1524
- const from = drag.from.get(object.id);
1525
- from && editorObjectSetPos(record, object, from.add(delta));
1556
+ // from the numbers it had, in the map's own units: through cells and back they pick up a hair
1557
+ const from = drag.from.get(object.id), {tilewidth=1, tileheight=1} = record.map;
1558
+ if (from)
1559
+ object.x = from.x + delta.x * tilewidth, object.y = from.y - delta.y * tileheight;
1526
1560
  }
1527
1561
  });
1528
1562
  }